Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

96 changes: 58 additions & 38 deletions src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// base.ts - Core Aspire types: base classes, ReferenceExpression
// base.ts - Core Aspire types: base classes, ReferenceExpression
import { Handle, AspireClient, MarshalledHandle, CancellationToken, registerCancellation, registerHandleWrapper, unregisterCancellation } from './transport.js';

// Re-export transport types for convenience
Expand Down Expand Up @@ -38,7 +38,14 @@ export { AtsErrorCodes, isMarshalledHandle, isAtsError, wrapIfHandle } from './t
* await api.withEnvironment("REDIS_URL", expr);
* ```
*/
export class ReferenceExpression {
export interface ReferenceExpression {
readonly isConditional: boolean;
toJSON(): { $expr: { format: string; valueProviders?: unknown[] } | { condition: unknown; whenTrue: unknown; whenFalse: unknown; matchValue: string } } | MarshalledHandle;
getValue(cancellationToken?: AbortSignal | CancellationToken): Promise<string | null>;
toString(): string;
}

class ReferenceExpressionImpl implements ReferenceExpression {
// Expression mode fields
private readonly _format?: string;
private readonly _valueProviders?: unknown[];
Expand Down Expand Up @@ -90,40 +97,6 @@ export class ReferenceExpression {
* @param values - The interpolated values (handles to value providers)
* @returns A ReferenceExpression instance
*/
static create(strings: TemplateStringsArray, ...values: unknown[]): ReferenceExpression {
// Build the format string with {0}, {1}, etc. placeholders
let format = '';
for (let i = 0; i < strings.length; i++) {
format += strings[i];
if (i < values.length) {
format += `{${i}}`;
}
}

// Extract handles from values
const valueProviders = values.map(extractHandleForExpr);

return new ReferenceExpression(format, valueProviders);
}

/**
* Creates a conditional reference expression from its constituent parts.
*
* @param condition - A value provider whose result is compared to matchValue
* @param whenTrue - The expression to use when the condition matches
* @param whenFalse - The expression to use when the condition does not match
* @param matchValue - The value to compare the condition against (defaults to "True")
* @returns A ReferenceExpression instance in conditional mode
*/
static createConditional(
condition: unknown,
matchValue: string,
whenTrue: ReferenceExpression,
whenFalse: ReferenceExpression
): ReferenceExpression {
return new ReferenceExpression(condition, matchValue, whenTrue, whenFalse);
}

/**
* Serializes the reference expression for JSON-RPC transport.
* In expression mode, uses the $expr format with format + valueProviders.
Expand Down Expand Up @@ -192,8 +165,51 @@ export class ReferenceExpression {
}
}

function createReferenceExpression(strings: TemplateStringsArray, ...values: unknown[]): ReferenceExpression {
let format = '';
for (let i = 0; i < strings.length; i++) {
format += strings[i];
if (i < values.length) {
format += `{${i}}`;
}
}

const valueProviders = values.map(extractHandleForExpr);

return new ReferenceExpressionImpl(format, valueProviders);
}

function createConditionalReferenceExpression(
condition: unknown,
whenTrue: ReferenceExpression,
whenFalse: ReferenceExpression
): ReferenceExpression;
function createConditionalReferenceExpression(
condition: unknown,
matchValue: string,
whenTrue: ReferenceExpression,
whenFalse: ReferenceExpression
): ReferenceExpression;
function createConditionalReferenceExpression(
condition: unknown,
matchValueOrWhenTrue: string | ReferenceExpression,
whenTrueOrWhenFalse: ReferenceExpression,
whenFalse?: ReferenceExpression
): ReferenceExpression {
if (typeof matchValueOrWhenTrue === 'string') {
return new ReferenceExpressionImpl(condition, matchValueOrWhenTrue, whenTrueOrWhenFalse, whenFalse!);
}

return new ReferenceExpressionImpl(condition, 'True', matchValueOrWhenTrue, whenTrueOrWhenFalse);
}

export const ReferenceExpression = {
create: createReferenceExpression,
createConditional: createConditionalReferenceExpression
};

registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression', (handle, client) =>
new ReferenceExpression(handle, client)
new ReferenceExpressionImpl(handle, client)
);

/**
Expand Down Expand Up @@ -266,11 +282,15 @@ export function refExpr(strings: TemplateStringsArray, ...values: unknown[]): Re
// ResourceBuilderBase
// ============================================================================

export interface HandleReference {
toJSON(): MarshalledHandle;
}

/**
* Base class for resource builders (e.g., RedisBuilder, ContainerBuilder).
* Provides handle management and JSON serialization.
*/
export class ResourceBuilderBase<THandle extends Handle = Handle> {
export class ResourceBuilderBase<THandle extends Handle = Handle> implements HandleReference {
constructor(protected _handle: THandle, protected _client: AspireClient) {}

toJSON(): MarshalledHandle { return this._handle.toJSON(); }
Expand Down
94 changes: 64 additions & 30 deletions src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ function isAbortSignal(value: unknown): value is AbortSignal {
);
}

function isCancellationTokenLike(value: unknown): value is CancellationToken {
return (
value !== null &&
typeof value === 'object' &&
'register' in value &&
typeof (value as { register?: unknown }).register === 'function' &&
'toJSON' in value &&
typeof (value as { toJSON?: unknown }).toJSON === 'function'
);
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object') {
return false;
Expand Down Expand Up @@ -205,7 +216,12 @@ export class Handle<T extends string = string> {
* const connectionString = await connectionStringExpression.getValue(cancellationToken);
* ```
*/
export class CancellationToken {
export interface CancellationToken {
toJSON(): string | undefined;
register(client?: AspireClient): string | undefined;
}

class CancellationTokenImpl implements CancellationToken {
private readonly _signal?: AbortSignal;
private readonly _remoteTokenId?: string;

Expand All @@ -222,47 +238,49 @@ export class CancellationToken {
/**
* Creates a cancellation token from a local {@link AbortSignal}.
*/
static from(signal?: AbortSignal): CancellationToken {
return new CancellationToken(signal);
toJSON(): string | undefined {
return this._remoteTokenId;
}

register(client?: AspireClient): string | undefined {
if (this._remoteTokenId !== undefined) {
return this._remoteTokenId;
}

return client
? registerCancellation(client, this._signal)
: registerCancellation(this._signal);
}
}

/**
* Creates transport-safe cancellation token values for the generated SDK.
*/
export const CancellationToken = {
from(signal?: AbortSignal): CancellationToken {
return new CancellationTokenImpl(signal);
},

/**
* Creates a cancellation token from a transport value.
* Generated code uses this to materialize values that come from the AppHost.
*/
static fromValue(value: unknown): CancellationToken {
if (value instanceof CancellationToken) {
fromValue(value: unknown): CancellationToken {
if (isCancellationTokenLike(value)) {
return value;
}

if (typeof value === 'string') {
return new CancellationToken(value);
return new CancellationTokenImpl(value);
}

if (isAbortSignal(value)) {
return new CancellationToken(value);
return new CancellationTokenImpl(value);
}

return new CancellationToken();
return new CancellationTokenImpl();
}

/**
* Serializes the token for JSON-RPC transport.
*/
toJSON(): string | undefined {
return this._remoteTokenId;
}

register(client?: AspireClient): string | undefined {
if (this._remoteTokenId !== undefined) {
return this._remoteTokenId;
}

return client
? registerCancellation(client, this._signal)
: registerCancellation(this._signal);
}
}
};

// ============================================================================
// Handle Wrapper Registry
Expand Down Expand Up @@ -559,6 +577,22 @@ function resolveCancellationClient(client?: AspireClient): AspireClient {
);
}

function isAspireClientLike(value: unknown): value is AspireClient {
if (!value || typeof value !== 'object') {
return false;
}

const candidate = value as {
invokeCapability?: unknown;
cancelToken?: unknown;
connected?: unknown;
};

return typeof candidate.invokeCapability === 'function'
&& typeof candidate.cancelToken === 'function'
&& typeof candidate.connected === 'boolean';
}

/**
* Registers cancellation support for a local signal or SDK cancellation token.
* Returns a cancellation ID that should be passed to methods accepting cancellation input.
Expand All @@ -574,7 +608,7 @@ export function registerCancellation(client: AspireClient, signalOrToken?: Abort
* Registers cancellation support using the single connected AspireClient.
*
* @param signalOrToken - The signal or token to register (optional)
* @returns The cancellation ID, or undefined if no value was provided or the token maps to CancellationToken.None
* @returns The cancellation ID, or undefined if no value was provided, the signal was already aborted, or the token maps to CancellationToken.None
*
* @example
* const controller = new AbortController();
Expand All @@ -591,7 +625,7 @@ export function registerCancellation(
clientOrSignalOrToken?: AspireClient | AbortSignal | CancellationToken,
maybeSignalOrToken?: AbortSignal | CancellationToken
): string | undefined {
const client = clientOrSignalOrToken instanceof AspireClient ? clientOrSignalOrToken : undefined;
const client = isAspireClientLike(clientOrSignalOrToken) ? clientOrSignalOrToken : undefined;
const signalOrToken = client
? maybeSignalOrToken
: clientOrSignalOrToken as AbortSignal | CancellationToken | undefined;
Expand All @@ -600,7 +634,7 @@ export function registerCancellation(
return undefined;
}

if (signalOrToken instanceof CancellationToken) {
if (isCancellationTokenLike(signalOrToken)) {
return signalOrToken.register(client);
}

Expand Down Expand Up @@ -648,7 +682,7 @@ async function marshalTransportValue(
return value;
}

if (value instanceof CancellationToken) {
if (isCancellationTokenLike(value)) {
const cancellationId = value.register(client);
if (cancellationId !== undefined) {
cancellationIds.push(cancellationId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ public void FactoryMethod_ReturnsChildResourceType_NotParentType()

// Verify the thenable class also uses the child type's promise class.
// In TestRedisResourcePromise, addTestChildDatabase should return TestDatabaseResourcePromise.
Assert.Contains("new TestDatabaseResourcePromise(this._promise.then(obj => obj.addTestChildDatabase(", aspireTs);
Assert.Contains("new TestDatabaseResourcePromiseImpl(this._promise.then(obj => obj.addTestChildDatabase(", aspireTs);
}

[Fact]
Expand Down Expand Up @@ -582,23 +582,17 @@ public void Pattern4_InterfaceParameterType_HasCorrectTypeRef()
[Fact]
public void Pattern4_InterfaceParameterType_GeneratesUnionType()
{
// Pattern 4/5: Verify that parameters with interface handle types generate union types
// in the generated TypeScript.
// Interface-constrained resource parameters should accept a structural
// handle-bearing type instead of the nominal ResourceBuilderBase type.
var atsContext = CreateContextFromTestAssembly();

// Generate the TypeScript output
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.ts"];

// The withDependency method should have its dependency parameter as a union type:
// dependency: IResourceWithConnectionStringHandle | ResourceBuilderBase
// Note: The exact generated name depends on the type mapping, but it should contain
// both the handle type and ResourceBuilderBase.
Assert.Contains("ResourceBuilderBase", aspireTs);

// Also verify the union type pattern appears somewhere
// (the exact format depends on the type name mapping)
Assert.Contains("|", aspireTs); // Union types use pipe
Assert.Contains("export type { HandleReference } from './base.js';", aspireTs);
Assert.Contains("withDependency(dependency: HandleReference)", aspireTs);
Assert.DoesNotContain("withDependency(dependency: ResourceBuilderBase)", aspireTs);
}

[Fact]
Expand Down Expand Up @@ -1066,8 +1060,8 @@ public void Generate_TypeWithMethods_CreatesThenableWrapper()
var code = GenerateTwoPassCode();

// TestResourceContext has ExposeMethods=true - gets Promise wrapper
Assert.Contains("export class TestResourceContextPromise", code);
Assert.Contains("implements PromiseLike<TestResourceContext>", code);
Assert.Contains("class TestResourceContextPromiseImpl implements TestResourceContextPromise", code);
Assert.Contains("implements TestResourceContextPromise", code);
}

[Fact]
Expand Down Expand Up @@ -1139,7 +1133,7 @@ public void Scanner_CancellationToken_MapsToCorrectTypeId()
public void Generate_MethodWithCancellationToken_GeneratesCancellationTokenParameter()
{
// Generated input parameters should accept AbortSignal for user-authored cancellation,
// while callbacks and returned values continue to use the SDK CancellationToken wrapper.
// while callbacks and returned values use the structural SDK cancellation token interface.
var code = GenerateTwoPassCode();

Assert.Contains("cancellationToken?: AbortSignal | CancellationToken;", code);
Expand Down Expand Up @@ -1354,12 +1348,12 @@ public void Generate_ConcreteAndInterfaceWithSameClassName_NoDuplicateClasses()
var files = _generator.GenerateDistributedApplication(atsContext);
var code = files["aspire.ts"];

// Count occurrences of the class definition
var classCount = CountOccurrences(code, "export class TestVaultResource ");
// Count occurrences of the public interface definition.
var classCount = CountOccurrences(code, "export interface TestVaultResource ");
Assert.Equal(1, classCount);

// Also verify the Promise wrapper is not duplicated
var promiseCount = CountOccurrences(code, "export class TestVaultResourcePromise ");
// Also verify the Promise wrapper interface is not duplicated.
var promiseCount = CountOccurrences(code, "export interface TestVaultResourcePromise ");
Assert.Equal(1, promiseCount);
}

Expand Down
Loading
Loading