Skip to content
Merged
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
406 changes: 76 additions & 330 deletions playground/TypeScriptAppHost/.modules/aspire.ts

Large diffs are not rendered by default.

103 changes: 94 additions & 9 deletions playground/TypeScriptAppHost/.modules/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// aspire.ts - Core Aspire types: base classes, ReferenceExpression
import { Handle, AspireClient, MarshalledHandle } from './transport.js';
import { Handle, AspireClient, MarshalledHandle, registerCancellation, registerHandleWrapper, unregisterCancellation } from './transport.js';

// Re-export transport types for convenience
export { Handle, AspireClient, CapabilityError, registerCallback, unregisterCallback, registerCancellation, unregisterCancellation } from './transport.js';
Expand Down Expand Up @@ -43,22 +43,46 @@ export class ReferenceExpression {
private readonly _format?: string;
private readonly _valueProviders?: unknown[];

// Conditional mode fields
private readonly _condition?: unknown;
private readonly _whenTrue?: ReferenceExpression;
private readonly _whenFalse?: ReferenceExpression;
private readonly _matchValue?: string;

// Handle mode fields (when wrapping a server-returned handle)
private readonly _handle?: Handle;
private readonly _client?: AspireClient;

constructor(format: string, valueProviders: unknown[]);
constructor(handle: Handle, client: AspireClient);
constructor(handleOrFormat: Handle | string, clientOrValueProviders: AspireClient | unknown[]) {
if (typeof handleOrFormat === 'string') {
this._format = handleOrFormat;
this._valueProviders = clientOrValueProviders as unknown[];
constructor(condition: unknown, matchValue: string, whenTrue: ReferenceExpression, whenFalse: ReferenceExpression);
constructor(
handleOrFormatOrCondition: Handle | string | unknown,
clientOrValueProvidersOrMatchValue: AspireClient | unknown[] | string,
whenTrueOrWhenFalse?: ReferenceExpression,
whenFalse?: ReferenceExpression
) {
if (typeof handleOrFormatOrCondition === 'string') {
this._format = handleOrFormatOrCondition;
this._valueProviders = clientOrValueProvidersOrMatchValue as unknown[];
} else if (handleOrFormatOrCondition instanceof Handle) {
this._handle = handleOrFormatOrCondition;
this._client = clientOrValueProvidersOrMatchValue as AspireClient;
} else {
this._handle = handleOrFormat;
this._client = clientOrValueProviders as AspireClient;
this._condition = handleOrFormatOrCondition;
this._matchValue = (clientOrValueProvidersOrMatchValue as string) ?? 'True';
this._whenTrue = whenTrueOrWhenFalse;
this._whenFalse = whenFalse;
}
}

/**
* Gets whether this reference expression is conditional.
*/
get isConditional(): boolean {
return this._condition !== undefined;
}

/**
* Creates a reference expression from a tagged template literal.
*
Expand All @@ -82,16 +106,46 @@ export class ReferenceExpression {
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 template-literal mode, uses the $expr format.
* In expression mode, uses the $expr format with format + valueProviders.
* In conditional mode, uses the $expr format with condition + whenTrue + whenFalse.
* In handle mode, delegates to the handle's serialization.
*/
toJSON(): { $expr: { format: string; valueProviders?: unknown[] } } | MarshalledHandle {
toJSON(): { $expr: { format: string; valueProviders?: unknown[] } | { condition: unknown; whenTrue: unknown; whenFalse: unknown; matchValue: string } } | MarshalledHandle {
if (this._handle) {
return this._handle.toJSON();
}

if (this.isConditional) {
return {
$expr: {
condition: this._condition instanceof Handle ? this._condition.toJSON() : this._condition,
whenTrue: this._whenTrue!.toJSON(),
whenFalse: this._whenFalse!.toJSON(),
matchValue: this._matchValue!
}
};
}

return {
$expr: {
format: this._format!,
Expand All @@ -100,17 +154,48 @@ export class ReferenceExpression {
};
}

/**
* Resolves the expression to its string value on the server.
* Only available on server-returned ReferenceExpression instances (handle mode).
*
* @param cancellationToken - Optional AbortSignal for cancellation support
* @returns The resolved string value, or null if the expression resolves to null
*/
async getValue(cancellationToken?: AbortSignal): Promise<string | null> {
if (!this._handle || !this._client) {
throw new Error('getValue is only available on server-returned ReferenceExpression instances');
}
const cancellationTokenId = registerCancellation(cancellationToken);
try {
const rpcArgs: Record<string, unknown> = { context: this._handle };
if (cancellationTokenId !== undefined) rpcArgs.cancellationToken = cancellationTokenId;
return await this._client.invokeCapability<string | null>(
'Aspire.Hosting.ApplicationModel/getValue',
rpcArgs
);
} finally {
unregisterCancellation(cancellationTokenId);
}
}

/**
* String representation for debugging.
*/
toString(): string {
if (this._handle) {
return `ReferenceExpression(handle)`;
}
if (this.isConditional) {
return `ReferenceExpression(conditional)`;
}
return `ReferenceExpression(${this._format})`;
}
}

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

/**
* Extracts a value for use in reference expressions.
* Supports handles (objects) and string literals.
Expand Down
71 changes: 71 additions & 0 deletions playground/TypeScriptAppHost/.modules/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,75 @@ export class CapabilityError extends Error {
}
}

/**
* Error thrown when the AppHost script uses the generated SDK incorrectly.
*/
export class AppHostUsageError extends Error {
constructor(message: string) {
super(message);
this.name = 'AppHostUsageError';
}
}

function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return (
value !== null &&
(typeof value === 'object' || typeof value === 'function') &&
'then' in value &&
typeof (value as { then?: unknown }).then === 'function'
);
}

function validateCapabilityArgs(
capabilityId: string,
args?: Record<string, unknown>
): void {
if (!args) {
return;
}

const seen = new Set<object>();

const validateValue = (value: unknown, path: string): void => {
if (value === null || value === undefined) {
return;
}

if (isPromiseLike(value)) {
throw new AppHostUsageError(
`Argument '${path}' passed to capability '${capabilityId}' is a Promise-like value. ` +
`This usually means an async builder call was not awaited. ` +
`Did you forget 'await' on a call like builder.addPostgres(...) or resource.addDatabase(...)?`
);
}

if (typeof value !== 'object') {
return;
}

if (seen.has(value)) {
return;
}

seen.add(value);

if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
validateValue(value[i], `${path}[${i}]`);
}
return;
}

for (const [key, nestedValue] of Object.entries(value)) {
validateValue(nestedValue, `${path}.${key}`);
}
};

for (const [key, value] of Object.entries(args)) {
validateValue(value, key);
}
}

// ============================================================================
// Callback Registry
// ============================================================================
Expand Down Expand Up @@ -533,6 +602,8 @@ export class AspireClient {
throw new Error('Not connected to AppHost');
}

validateCapabilityArgs(capabilityId, args);

// Ref counting: The vscode-jsonrpc socket keeps Node's event loop alive.
// We ref() during RPC calls so the process doesn't exit mid-call, and
// unref() when idle so the process can exit naturally after all work completes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2490,6 +2490,13 @@ private static List<BuilderModel> CreateBuilderModels(IReadOnlyList<AtsCapabilit
continue;
}

// ReferenceExpression is implemented manually in base.ts, including its handle wrapper
// registration, so it must not also generate a duplicate wrapper class in aspire.ts.
if (targetTypeId == AtsConstants.ReferenceExpressionTypeId)
{
continue;
}

// Use expanded types if available, otherwise fall back to the original target
var expandedTypes = cap.ExpandedTargetTypes;
if (expandedTypes is { Count: > 0 })
Expand Down
30 changes: 29 additions & 1 deletion src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// aspire.ts - Core Aspire types: base classes, ReferenceExpression
import { Handle, AspireClient, MarshalledHandle } from './transport.js';
import { Handle, AspireClient, MarshalledHandle, registerCancellation, registerHandleWrapper, unregisterCancellation } from './transport.js';

// Re-export transport types for convenience
export { Handle, AspireClient, CapabilityError, registerCallback, unregisterCallback, registerCancellation, unregisterCancellation } from './transport.js';
Expand Down Expand Up @@ -154,6 +154,30 @@ export class ReferenceExpression {
};
}

/**
* Resolves the expression to its string value on the server.
* Only available on server-returned ReferenceExpression instances (handle mode).
*
* @param cancellationToken - Optional AbortSignal for cancellation support
* @returns The resolved string value, or null if the expression resolves to null
*/
async getValue(cancellationToken?: AbortSignal): Promise<string | null> {
if (!this._handle || !this._client) {
throw new Error('getValue is only available on server-returned ReferenceExpression instances');
}
const cancellationTokenId = registerCancellation(cancellationToken);
try {
const rpcArgs: Record<string, unknown> = { context: this._handle };
if (cancellationTokenId !== undefined) rpcArgs.cancellationToken = cancellationTokenId;
return await this._client.invokeCapability<string | null>(
'Aspire.Hosting.ApplicationModel/getValue',
rpcArgs
);
} finally {
unregisterCancellation(cancellationTokenId);
}
}

/**
* String representation for debugging.
*/
Expand All @@ -168,6 +192,10 @@ export class ReferenceExpression {
}
}

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

/**
* Extracts a value for use in reference expressions.
* Supports handles (objects) and string literals.
Expand Down
Loading
Loading