From b339fde2251efed366fe9adec81638bbb77b3b34 Mon Sep 17 00:00:00 2001 From: eren-karakus0 Date: Fri, 3 Jul 2026 14:38:02 +0300 Subject: [PATCH 1/2] fix(runtime): register decorators at class-evaluation time, not construction Member decorators (@tool/@interceptor/@command/@install/@upgrade/@run) recorded their registrations from context.addInitializer, which per the TC39 proposal only runs when an instance is first constructed. But the runtime bridge reads the registry BEFORE constructing anything, so every lifecycle/tool/run registration looked empty: @install hooks silently no-op'd, @run loops reported "exited before signaling ready", and tool dispatch never reached the capsule. Defer each member registration onto a module-scoped queue instead; the @capsule class decorator (which runs last and knows the constructor) flushes the queue. Registration now lands synchronously at class-evaluation time -- before any construction and before the bridge reads it -- and runs exactly once per class rather than once per instance. Fixes #20. Also resolves the per-instance re-registration flagged in #18 (a second construction previously threw "Only one @install method allowed"). --- packages/astrid-sdk/src/capsule.ts | 27 ++++--- packages/astrid-sdk/src/runtime/registry.ts | 86 ++++++++++----------- packages/astrid-sdk/src/tool.ts | 32 ++++---- 3 files changed, 70 insertions(+), 75 deletions(-) diff --git a/packages/astrid-sdk/src/capsule.ts b/packages/astrid-sdk/src/capsule.ts index b8c7dad..b6e3ee4 100644 --- a/packages/astrid-sdk/src/capsule.ts +++ b/packages/astrid-sdk/src/capsule.ts @@ -13,13 +13,18 @@ import { recordInstall, recordUpgrade, recordRun, - adoptPending, + defer, + flushDeferred, } from "./runtime/registry.js"; /** * Class decorator marking the entry-point of the capsule. The decorated * class must have a no-arg constructor (state defaults via field initializers). * + * Runs last (TC39 applies the class decorator after all member decorators), + * so by now every @tool/@install/... has deferred its registration; we flush + * them here against the real constructor. + * * Exactly one `@capsule` class per compiled WASM module. */ export function capsule( @@ -27,7 +32,7 @@ export function capsule( _context: ClassDecoratorContext, ): T { registerCapsule(target); - adoptPending(target); + flushDeferred(target); return target; } @@ -43,10 +48,8 @@ export function install( if (context.private || context.static) { throw new Error("@install must be applied to a public instance method."); } - context.addInitializer(function () { - const ctor = (this as object).constructor as CapsuleConstructor; - recordInstall(ctor, String(context.name)); - }); + const methodName = String(context.name); + defer((ctor) => recordInstall(ctor, methodName)); } /** @@ -60,10 +63,8 @@ export function upgrade( if (context.private || context.static) { throw new Error("@upgrade must be applied to a public instance method."); } - context.addInitializer(function () { - const ctor = (this as object).constructor as CapsuleConstructor; - recordUpgrade(ctor, String(context.name)); - }); + const methodName = String(context.name); + defer((ctor) => recordUpgrade(ctor, methodName)); } /** @@ -83,8 +84,6 @@ export function run( if (context.private || context.static) { throw new Error("@run must be applied to a public instance method."); } - context.addInitializer(function () { - const ctor = (this as object).constructor as CapsuleConstructor; - recordRun(ctor, String(context.name)); - }); + const methodName = String(context.name); + defer((ctor) => recordRun(ctor, methodName)); } diff --git a/packages/astrid-sdk/src/runtime/registry.ts b/packages/astrid-sdk/src/runtime/registry.ts index b751737..aa77889 100644 --- a/packages/astrid-sdk/src/runtime/registry.ts +++ b/packages/astrid-sdk/src/runtime/registry.ts @@ -3,9 +3,18 @@ * @upgrade, @interceptor, @command, @run decorators. The bridge reads from * this registry to dispatch host export calls. * - * Decorators run during class evaluation, which happens at module init. - * The bridge is constructed AFTER all decorators have populated the - * registry, so reads are safe. + * Registration timing matters. Method decorators run *before* the class + * decorator (TC39 applies member decorators first, in source order, then the + * class decorator last) and none of them can see the constructor yet. So the + * member decorators defer their registration onto a module-scoped queue; the + * `@capsule` class decorator — which does have the constructor — flushes that + * queue via `flushDeferred(ctor)`. Everything therefore lands in the registry + * synchronously at class-evaluation time, before any instance is constructed + * and before the bridge ever reads it. + * + * (Earlier this was done from `context.addInitializer`, which only runs at + * *construction* time — but the bridge reads the registry before constructing + * anything, so every registration looked empty. See sdk-js#20.) */ export type CapsuleConstructor = new () => object; @@ -78,49 +87,35 @@ export function registerCapsule(ctor: CapsuleConstructor, description?: string): } /** - * Decorators may run before `@capsule` if class fields appear before the - * class itself in source. Buffer pending entries on the constructor so the - * eventual @capsule call picks them up. + * Member decorators (@tool/@interceptor/@command/@install/@upgrade/@run) run + * before the class exists, so they cannot name the constructor. Each defers a + * closure here; `@capsule` runs last and flushes them with the real ctor. */ -const pendingByCtor = new WeakMap(); +type DeferredRecord = (ctor: CapsuleConstructor) => void; +let deferred: DeferredRecord[] = []; -function ensureRegistration(ctor: CapsuleConstructor): CapsuleRegistration { - if (registration !== undefined && registration.ctor === ctor) { - return registration; - } - let pending = pendingByCtor.get(ctor); - if (pending === undefined) { - pending = newRegistration(ctor, undefined); - pendingByCtor.set(ctor, pending); - } - return pending; -} - -/** Adopt any decorator entries buffered before @capsule fired. */ -export function adoptPending(ctor: CapsuleConstructor): void { - if (registration === undefined || registration.ctor !== ctor) return; - const pending = pendingByCtor.get(ctor); - if (pending === undefined) return; - for (const [name, entry] of pending.tools) registration.tools.set(name, entry); - for (const [topic, entry] of pending.interceptors) registration.interceptors.set(topic, entry); - for (const [name, entry] of pending.commands) registration.commands.set(name, entry); - if (pending.installMethod !== undefined && registration.installMethod === undefined) { - registration.installMethod = pending.installMethod; - } - if (pending.upgradeMethod !== undefined && registration.upgradeMethod === undefined) { - registration.upgradeMethod = pending.upgradeMethod; - } - if (pending.runMethod !== undefined && registration.runMethod === undefined) { - registration.runMethod = pending.runMethod; - } - if (pending.description !== undefined && registration.description === undefined) { - registration.description = pending.description; +export function defer(record: DeferredRecord): void { + deferred.push(record); +} + +/** Apply every deferred member registration against the now-known ctor. */ +export function flushDeferred(ctor: CapsuleConstructor): void { + const pending = deferred; + deferred = []; + for (const record of pending) record(ctor); +} + +function requireRegistration(ctor: CapsuleConstructor): CapsuleRegistration { + if (registration === undefined || registration.ctor !== ctor) { + throw new Error( + `Internal: @capsule must register ${ctor.name} before recording its members.`, + ); } - pendingByCtor.delete(ctor); + return registration; } export function recordTool(ctor: CapsuleConstructor, entry: ToolEntry): void { - const target = ensureRegistration(ctor); + const target = requireRegistration(ctor); if (target.tools.has(entry.name)) { throw new Error(`@tool("${entry.name}") declared twice on ${ctor.name}.`); } @@ -128,7 +123,7 @@ export function recordTool(ctor: CapsuleConstructor, entry: ToolEntry): void { } export function recordInterceptor(ctor: CapsuleConstructor, entry: InterceptorEntry): void { - const target = ensureRegistration(ctor); + const target = requireRegistration(ctor); if (target.interceptors.has(entry.topic)) { throw new Error(`@interceptor("${entry.topic}") declared twice on ${ctor.name}.`); } @@ -136,7 +131,7 @@ export function recordInterceptor(ctor: CapsuleConstructor, entry: InterceptorEn } export function recordCommand(ctor: CapsuleConstructor, entry: CommandEntry): void { - const target = ensureRegistration(ctor); + const target = requireRegistration(ctor); if (target.commands.has(entry.name)) { throw new Error(`@command("${entry.name}") declared twice on ${ctor.name}.`); } @@ -144,7 +139,7 @@ export function recordCommand(ctor: CapsuleConstructor, entry: CommandEntry): vo } export function recordInstall(ctor: CapsuleConstructor, methodName: string): void { - const target = ensureRegistration(ctor); + const target = requireRegistration(ctor); if (target.installMethod !== undefined) { throw new Error(`Only one @install method allowed on ${ctor.name}.`); } @@ -152,7 +147,7 @@ export function recordInstall(ctor: CapsuleConstructor, methodName: string): voi } export function recordUpgrade(ctor: CapsuleConstructor, methodName: string): void { - const target = ensureRegistration(ctor); + const target = requireRegistration(ctor); if (target.upgradeMethod !== undefined) { throw new Error(`Only one @upgrade method allowed on ${ctor.name}.`); } @@ -160,7 +155,7 @@ export function recordUpgrade(ctor: CapsuleConstructor, methodName: string): voi } export function recordRun(ctor: CapsuleConstructor, methodName: string): void { - const target = ensureRegistration(ctor); + const target = requireRegistration(ctor); if (target.runMethod !== undefined) { throw new Error(`Only one @run method allowed on ${ctor.name}.`); } @@ -175,4 +170,5 @@ export function getRegistration(): CapsuleRegistration | undefined { /** Test-only: reset the registry to a clean state. */ export function __resetRegistry(): void { registration = undefined; + deferred = []; } diff --git a/packages/astrid-sdk/src/tool.ts b/packages/astrid-sdk/src/tool.ts index 90e5e37..e1d0bd8 100644 --- a/packages/astrid-sdk/src/tool.ts +++ b/packages/astrid-sdk/src/tool.ts @@ -9,7 +9,7 @@ */ import { - type CapsuleConstructor, + defer, recordTool, recordInterceptor, recordCommand, @@ -48,16 +48,16 @@ export function tool(name: string, options: ToolOptions = {}) { if (context.private || context.static) { throw new Error(`@tool("${name}") must be applied to a public instance method.`); } - context.addInitializer(function () { - const ctor = (this as object).constructor as CapsuleConstructor; + const methodName = String(context.name); + defer((ctor) => recordTool(ctor, { name, - methodName: String(context.name), + methodName, mutable: options.mutable === true, description: options.description, inputSchema: options.inputSchema, - }); - }); + }), + ); }; } @@ -80,14 +80,14 @@ export function interceptor(topic: string, options: InterceptorOptions = {}) { if (context.private || context.static) { throw new Error(`@interceptor("${topic}") must be applied to a public instance method.`); } - context.addInitializer(function () { - const ctor = (this as object).constructor as CapsuleConstructor; + const methodName = String(context.name); + defer((ctor) => recordInterceptor(ctor, { topic, - methodName: String(context.name), + methodName, mutable: options.mutable === true, - }); - }); + }), + ); }; } @@ -107,14 +107,14 @@ export function command(name: string, options: CommandOptions = {}) { if (context.private || context.static) { throw new Error(`@command("${name}") must be applied to a public instance method.`); } - context.addInitializer(function () { - const ctor = (this as object).constructor as CapsuleConstructor; + const methodName = String(context.name); + defer((ctor) => recordCommand(ctor, { name, - methodName: String(context.name), + methodName, mutable: options.mutable === true, - }); - }); + }), + ); }; } From a62abf0ec4c8cf8622d11615d91e9259d85fa546 Mon Sep 17 00:00:00 2001 From: eren-karakus0 Date: Fri, 3 Jul 2026 14:53:08 +0300 Subject: [PATCH 2/2] fix(runtime): isolate deferred registrations per class via metadata key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: a single module-scoped queue could leak a non-@capsule class's member registrations into the next @capsule class evaluated in the same module. Key the deferred closures by the class's shared `context.metadata` object (stored in a WeakMap) so each class only flushes its own. Where the runtime does not expose decorator metadata (no `Symbol.metadata`, e.g. the current ComponentizeJS engine) `context.metadata` is undefined and we fall back to the single module queue — correct for the enforced one-@capsule-per-module case, i.e. no worse than before. __resetRegistry clears the WeakMap too. --- packages/astrid-sdk/src/capsule.ts | 10 +++--- packages/astrid-sdk/src/runtime/registry.ts | 40 +++++++++++++++++---- packages/astrid-sdk/src/tool.ts | 6 ++-- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/astrid-sdk/src/capsule.ts b/packages/astrid-sdk/src/capsule.ts index b6e3ee4..f432796 100644 --- a/packages/astrid-sdk/src/capsule.ts +++ b/packages/astrid-sdk/src/capsule.ts @@ -29,10 +29,10 @@ import { */ export function capsule( target: T, - _context: ClassDecoratorContext, + context: ClassDecoratorContext, ): T { registerCapsule(target); - flushDeferred(target); + flushDeferred(target, context.metadata); return target; } @@ -49,7 +49,7 @@ export function install( throw new Error("@install must be applied to a public instance method."); } const methodName = String(context.name); - defer((ctor) => recordInstall(ctor, methodName)); + defer(context.metadata, (ctor) => recordInstall(ctor, methodName)); } /** @@ -64,7 +64,7 @@ export function upgrade( throw new Error("@upgrade must be applied to a public instance method."); } const methodName = String(context.name); - defer((ctor) => recordUpgrade(ctor, methodName)); + defer(context.metadata, (ctor) => recordUpgrade(ctor, methodName)); } /** @@ -85,5 +85,5 @@ export function run( throw new Error("@run must be applied to a public instance method."); } const methodName = String(context.name); - defer((ctor) => recordRun(ctor, methodName)); + defer(context.metadata, (ctor) => recordRun(ctor, methodName)); } diff --git a/packages/astrid-sdk/src/runtime/registry.ts b/packages/astrid-sdk/src/runtime/registry.ts index aa77889..46da34d 100644 --- a/packages/astrid-sdk/src/runtime/registry.ts +++ b/packages/astrid-sdk/src/runtime/registry.ts @@ -90,18 +90,45 @@ export function registerCapsule(ctor: CapsuleConstructor, description?: string): * Member decorators (@tool/@interceptor/@command/@install/@upgrade/@run) run * before the class exists, so they cannot name the constructor. Each defers a * closure here; `@capsule` runs last and flushes them with the real ctor. + * + * Deferred closures are keyed by the class's shared `context.metadata` object + * (the same reference reaches every decorator of one class), so members of a + * class that is never `@capsule`-decorated cannot leak into the next capsule + * evaluated in the same module. The metadata object is only available under + * TC39 decorator metadata; where the runtime does not provide it (undefined), + * we fall back to a single module queue — which is exactly right for the + * enforced "one @capsule class per module" case. */ type DeferredRecord = (ctor: CapsuleConstructor) => void; + let deferred: DeferredRecord[] = []; +let deferredByMetadata = new WeakMap(); -export function defer(record: DeferredRecord): void { - deferred.push(record); +export function defer(metadata: object | undefined, record: DeferredRecord): void { + if (metadata === undefined) { + deferred.push(record); + return; + } + let list = deferredByMetadata.get(metadata); + if (list === undefined) { + list = []; + deferredByMetadata.set(metadata, list); + } + list.push(record); } -/** Apply every deferred member registration against the now-known ctor. */ -export function flushDeferred(ctor: CapsuleConstructor): void { - const pending = deferred; - deferred = []; +/** Apply the deferred member registrations for this class against its ctor. */ +export function flushDeferred(ctor: CapsuleConstructor, metadata?: object): void { + let pending: DeferredRecord[] = []; + if (metadata !== undefined) { + pending = deferredByMetadata.get(metadata) ?? []; + deferredByMetadata.delete(metadata); + } + if (deferred.length > 0) { + // Fallback bucket (runtimes without decorator metadata). + pending = pending.concat(deferred); + deferred = []; + } for (const record of pending) record(ctor); } @@ -171,4 +198,5 @@ export function getRegistration(): CapsuleRegistration | undefined { export function __resetRegistry(): void { registration = undefined; deferred = []; + deferredByMetadata = new WeakMap(); } diff --git a/packages/astrid-sdk/src/tool.ts b/packages/astrid-sdk/src/tool.ts index e1d0bd8..52e12ad 100644 --- a/packages/astrid-sdk/src/tool.ts +++ b/packages/astrid-sdk/src/tool.ts @@ -49,7 +49,7 @@ export function tool(name: string, options: ToolOptions = {}) { throw new Error(`@tool("${name}") must be applied to a public instance method.`); } const methodName = String(context.name); - defer((ctor) => + defer(context.metadata, (ctor) => recordTool(ctor, { name, methodName, @@ -81,7 +81,7 @@ export function interceptor(topic: string, options: InterceptorOptions = {}) { throw new Error(`@interceptor("${topic}") must be applied to a public instance method.`); } const methodName = String(context.name); - defer((ctor) => + defer(context.metadata, (ctor) => recordInterceptor(ctor, { topic, methodName, @@ -108,7 +108,7 @@ export function command(name: string, options: CommandOptions = {}) { throw new Error(`@command("${name}") must be applied to a public instance method.`); } const methodName = String(context.name); - defer((ctor) => + defer(context.metadata, (ctor) => recordCommand(ctor, { name, methodName,