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.
156 lines
6.0 KiB
TypeScript
156 lines
6.0 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|