Files
msd-core/sdk/src/runtime-bridge-sync/index.ts
Tom Boucher 8090456b66 feat(3555): QueryRuntimeBridge.executeForCjs synchronous primitive (Phase 5.0 of #3524)
Phase 5.0 of the CJS↔SDK hard-seam migration (parent #3524).
Foundational PR. Ships ONLY the synchronous primitive on the SDK
runtime bridge plus pinning tests. Per-family CJS router migrations
(state.*, verify.*, init.*, phase.*, phases.*, validate.*,
roadmap.*, frontmatter.*, config.*) become follow-up enhancements
that each reuse this primitive.

## What landed

- sdk/src/runtime-bridge-sync/index.ts (155 lines) — public API.
  Exports executeForCjs(input: RuntimeBridgeExecuteInput):
  RuntimeBridgeSyncResult. Synchronous; lazily creates the
  synckit sync function on first call.
- sdk/src/runtime-bridge-sync/worker.ts (167 lines) — synckit
  worker. Constructs a native-only QueryRuntimeBridge (with
  allowFallbackToSubprocess: false), awaits its async execute,
  catches GSDToolsError / GSDError, maps classification to the
  six ADR-0001 canonical error kinds plus exit code.
- sdk/src/runtime-bridge-sync/index.test.ts (197 lines, 8 vitest
  pinning fixtures) — success path; unknown_command;
  native_failure (shape); validation_error (shape); shape
  invariants; idempotency.
- tests/runtime-bridge-sync-smoke.test.cjs (99 lines, 4
  node:test cases) — proves the primitive works from CJS
  callers via require().

## Decisions

1. Synchronous-bridging mechanism: synckit. Disqualified:
   - deasync: stagnant (68 open issues, single maintainer,
     last release Nov 2025), private Node API (process.binding('uv')),
     untested on Node 22, documented deadlocks with modern
     Promise chains.
   - Sync-native SDK refactor: technically infeasible —
     acquireStateLock in state-mutation.ts uses await setTimeout
     for retry backoff; making that fully sync requires either
     Atomics.wait (which IS synckit), busy-loop (degrades
     responsiveness), or breaking 100+ SDK consumers.
   Synckit (v0.11.12) is pure JS, actively maintained (last
   push today), stable public APIs (Atomics.wait +
   SharedArrayBuffer), Node 22 compatible, no native compile.

2. Native-only transport inside the worker. The sync bridge
   uses allowFallbackToSubprocess: false. Unknown commands
   surface as unknown_command instead of spawning gsd-sdk.
   Keeps the worker self-contained and predictable.

3. Worker path resolution. resolveWorkerPath() navigates ../..
   from the loaded module URL to land at
   dist/runtime-bridge-sync/worker.js — works under both
   vitest (loads src) and CJS consumers (load dist).

4. GSDError.Blocked → validation_error. ADR-0001's 6-kind
   taxonomy has no `blocked` kind; Blocked classification is
   mapped onto validation_error since the operational shape
   matches (prerequisite missing).

## Numbers

- 8 SDK vitest pinning tests pass.
- 4 CJS smoke tests pass.
- Full suite: 9286/9286 pass (baseline 9282; +4 from the
  new smoke test cases).
- SDK vitest unit: 1860/1860 pass.
- Performance: 80ms first-call cold latency (Worker startup +
  bridge construction); 0.1ms steady-state per-call latency
  (10-call average after warmup). Well within budget for CJS
  dispatcher overhead.

## Canonical error kind coverage

- unknown_command: covered with pinning fixture
- native_failure: shape coverage (handler that throws)
- validation_error: shape coverage (GSDError.Validation +
  GSDError.Blocked)
- internal_error: shape coverage only (eliciting TypeError
  reliably from a registered handler requires elaborate fixture)
- native_timeout: NOT pinned (no registered handler genuinely
  times out; classification logic present in worker)
- fallback_failure: NOT pinned (subprocess fallback disabled
  by design in sync bridge)

The classification logic is in the worker regardless; per-family
migration PRs will exercise the unpinned kinds incidentally.

## Wiring

- sdk/package.json: synckit ^0.11.12 added as runtime
  dependency (not devDependency — it's required at runtime
  whenever a CJS caller invokes executeForCjs).
- sdk/package-lock.json: regenerated.
- CONTEXT.md: new "Sync Runtime Bridge Module" entry added
  after Dispatch Policy Module. Existing "CJS Command Router
  Adapter Module" entry amended with one sentence pointing at
  the primitive and the per-family migration roadmap.

No generator/freshness check needed for this phase — the
primitive IS the SDK (not a generated CJS mirror).

Closes #3555.
2026-05-15 11:10:03 -04:00

156 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* executeForCjs — synchronous SDK runtime bridge primitive.
*
* Provides a synchronous `executeForCjs()` function that CJS callers can use
* to invoke any registered SDK command without dealing with async/await or
* top-level-await restrictions that prevent CJS from using the async bridge.
*
* ## Mechanism
*
* Uses `synckit` (Atomics.wait + SharedArrayBuffer + worker_threads) to run
* the async `QueryRuntimeBridge.execute()` in a worker thread and block the
* calling thread until the result is available. The worker is spawned lazily
* on first call and reused for all subsequent calls (steady-state overhead is
* ~1–5 ms per call, depending on handler complexity).
*
* ## Worker overhead
*
* - First call (worker startup + registry initialisation): ~150–400 ms.
* - Subsequent calls (steady state): ~1–10 ms per call.
*
* ## CRITICAL: Do NOT call from an async context
*
* `executeForCjs` uses `Atomics.wait` under the hood, which **blocks the
* calling thread**. Calling it from inside an async function that is itself
* running on the Node.js main thread event loop will **deadlock** because
* the event loop cannot process the worker's response message while blocked.
*
* Safe callers:
* - CJS modules evaluated at require-time (synchronous module initialisation).
* - Worker threads that are not using the event loop.
*
* Unsafe callers:
* - Any `async function` on the main thread.
* - Anything inside a `Promise` callback on the main thread.
*
* ## CJS consumption
*
* ```js
* const { executeForCjs } = require('@gsd-build/sdk/dist/runtime-bridge-sync/index.js');
* const result = executeForCjs({ registryCommand: 'generate-slug', registryArgs: ['My Phase'], ... });
* if (result.ok) console.log(result.data); // { slug: 'my-phase' }
* ```
*
* @module runtime-bridge-sync
*/
import { createSyncFn } from 'synckit';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import type { RuntimeBridgeExecuteInput } from '../query-runtime-bridge.js';
// Re-export the input type so callers can import it alongside executeForCjs
export type { RuntimeBridgeExecuteInput };
// Convenience alias for callers who prefer a shorter name
export type ExecuteForCjsInput = RuntimeBridgeExecuteInput;
// ─── Result type ─────────────────────────────────────────────────────────────
/**
* The 6 canonical error kinds from ADR-0001 Dispatch Policy Module.
*/
export type SyncErrorKind =
| 'unknown_command'
| 'native_failure'
| 'native_timeout'
| 'fallback_failure'
| 'validation_error'
| 'internal_error';
/**
* Discriminated union returned by `executeForCjs`.
*
* - `ok: true` — command executed successfully. `data` is the handler's return value.
* - `ok: false` — command failed. `errorKind` identifies the failure category.
*/
export type RuntimeBridgeSyncResult =
| { ok: true; data: unknown; exitCode: 0 }
| {
ok: false;
exitCode: number;
errorKind: SyncErrorKind;
errorDetails?: unknown;
stderrLines: string[];
};
// ─── Lazy sync-fn factory ──────────────────────────────────────────────────
// Resolve worker path to the compiled dist artifact.
//
// The worker MUST be the compiled JS file (dist/runtime-bridge-sync/worker.js),
// NOT the TypeScript source. This is because:
// 1. synckit spawns the worker via Node.js directly (no tsx transform).
// 2. When vitest runs tests from the TS source tree, import.meta.url points to
// src/, not dist/. We must redirect to dist/ in all cases.
//
// Strategy: navigate from the current file's directory to the package root,
// then resolve to dist/runtime-bridge-sync/worker.js.
// The current file lives either in:
// src/runtime-bridge-sync/ (vitest TS source context)
// dist/runtime-bridge-sync/ (compiled CJS/ESM consumer context)
// In both cases, two levels up is the package root (sdk/).
const _moduleUrl = import.meta.url;
function resolveWorkerPath(): string {
const currentDir = dirname(fileURLToPath(_moduleUrl));
// currentDir is either src/runtime-bridge-sync or dist/runtime-bridge-sync.
// Two levels up is the sdk/ package root.
const pkgRoot = join(currentDir, '..', '..');
return join(pkgRoot, 'dist', 'runtime-bridge-sync', 'worker.js');
}
let _syncFn: ((input: RuntimeBridgeExecuteInput) => RuntimeBridgeSyncResult) | null = null;
function getSyncFn(): (input: RuntimeBridgeExecuteInput) => RuntimeBridgeSyncResult {
if (_syncFn) return _syncFn;
const workerPath = resolveWorkerPath();
_syncFn = createSyncFn<(input: RuntimeBridgeExecuteInput) => Promise<RuntimeBridgeSyncResult>>(
workerPath,
{ timeout: 60_000 },
);
return _syncFn;
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Execute a registered SDK command synchronously.
*
* This function blocks the calling thread until the command completes.
* It must NOT be called from an async context on the main event-loop thread
* (see module-level JSDoc for details).
*
* @param input - The command input, matching RuntimeBridgeExecuteInput.
* @returns A RuntimeBridgeSyncResult — either ok:true with data, or ok:false with errorKind.
*
* @example
* ```js
* const { executeForCjs } = require('@gsd-build/sdk/dist/runtime-bridge-sync/index.js');
* const result = executeForCjs({
* registryCommand: 'generate-slug',
* registryArgs: ['My Phase'],
* legacyCommand: 'generate-slug',
* legacyArgs: ['My Phase'],
* mode: 'json',
* projectDir: '/path/to/project',
* });
* if (result.ok) {
* console.log(result.data.slug); // 'my-phase'
* }
* ```
*/
export function executeForCjs(input: RuntimeBridgeExecuteInput): RuntimeBridgeSyncResult {
return getSyncFn()(input);
}