diff --git a/framework.manifest.json b/framework.manifest.json index eb4d93ac..703645af 100644 --- a/framework.manifest.json +++ b/framework.manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "buildId": "93daaafcf078589bc4501699e1881a0f64181bfbabedc2188f42c6f98102182b", + "buildId": "4a212e87b080759b7b19828bd37e5a82d813f61898c4307a02cbd29f46e25dde", "tiers": { "0": [ "core", @@ -402,6 +402,11 @@ "owner": "cli", "at": "packages/cli/src/error-codes.ts" }, + { + "code": "X_ASYNC_CONTEXT_UNAVAILABLE", + "owner": "core", + "at": "packages/core/src/error-codes.ts" + }, { "code": "X_AUDIT_SINK_FAILED", "owner": "action", diff --git a/packages/core/src/async-context.test.ts b/packages/core/src/async-context.test.ts new file mode 100644 index 00000000..6ed4edeb --- /dev/null +++ b/packages/core/src/async-context.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { asyncContext } from './async-context'; + +describe('asyncContext, on a server', () => { + test('is undefined outside a scope and the value inside one', () => { + const scope = asyncContext('the thing'); + expect(scope.get()).toBeUndefined(); + expect(scope.run('inside', () => scope.get())).toBe('inside'); + expect(scope.get()).toBeUndefined(); + }); + + test('propagates across an await, which is the whole reason for AsyncLocalStorage', async () => { + const scope = asyncContext('the thing'); + await scope.run(7, async () => { + await Bun.sleep(1); + expect(scope.get()).toBe(7); + }); + }); + + test('stays isolated across interleaved async tasks', async () => { + const scope = asyncContext('the thing'); + const task = async (value: string, delayMs: number): Promise => + scope.run(value, async () => { + await Bun.sleep(delayMs); + return scope.get() ?? 'lost'; + }); + expect(await Promise.all([task('a', 20), task('b', 5), task('c', 10)])).toEqual([ + 'a', + 'b', + 'c', + ]); + }); + + test('nests, innermost wins, and the outer value survives the inner scope', () => { + const scope = asyncContext('the thing'); + scope.run('outer', () => { + expect(scope.run('inner', () => scope.get())).toBe('inner'); + expect(scope.get()).toBe('outer'); + }); + }); + + test('two scopes cannot see each other', () => { + const left = asyncContext('the left'); + const right = asyncContext('the right'); + left.run('L', () => { + expect(right.get()).toBeUndefined(); + }); + }); +}); + +/** + * The mechanical guard for this whole defect class, one level up from the seam: the BARREL is the + * entry `@ultimat3/ui` reaches (`packages/ui/src/errors.ts` imports `@ultimat3/core`, not a file), + * so a fourth module-scope `new AsyncLocalStorage()` added anywhere in the package turns this red + * on the day it lands. The bundler's stub is the subject and cannot be faked by a test. + */ +describe('a browser bundle of @ultimat3/core', () => { + interface BrowserBarrel { + readonly hasContext: () => boolean; + readonly currentSpan: () => unknown; + readonly isImpersonating: () => boolean; + readonly createContext: () => unknown; + readonly anonymousActor: () => unknown; + readonly runWithContext: (ctx: unknown, fn: () => unknown) => unknown; + readonly withSpan: (name: string, fn: () => unknown) => unknown; + readonly impersonate: (actor: unknown, reason: string, fn: () => unknown) => unknown; + } + + let built: Promise | undefined; + + function barrel(): Promise { + built ??= (async (): Promise => { + const output = await Bun.build({ + entrypoints: [join(import.meta.dir, 'index.ts')], + target: 'browser', + }); + expect(output.success).toBe(true); + const chunk = output.outputs[0] ?? expect.unreachable('the browser build emitted no chunk'); + // A fresh path per run: the module cache would otherwise serve a chunk built before a fix. + const file = join(await mkdtemp(join(tmpdir(), 'ultimate-core-')), 'barrel.mjs'); + await Bun.write(file, await chunk.text()); + return import(file) as Promise; + })(); + return built; + } + + test('evaluates instead of throwing at module scope', async () => { + const core = await barrel(); + expect(typeof core.hasContext).toBe('function'); + }, 60_000); + + test('answers every ambient read with a definite no, not an exception', async () => { + const core = await barrel(); + expect(core.hasContext()).toBe(false); + expect(core.currentSpan()).toBeUndefined(); + expect(core.isImpersonating()).toBe(false); + }, 60_000); + + /** + * Also the guard that keeps the two tests above from being vacuous: had the bundler inlined the + * REAL `node:async_hooks`, these would open a scope instead of refusing, and evaluating the + * chunk would have proved nothing about the stub. + */ + test('refuses to OPEN a scope, with a code and a fix, on every write path', async () => { + const core = await barrel(); + expect(() => core.runWithContext(core.createContext(), () => 1)).toThrow( + /X_ASYNC_CONTEXT_UNAVAILABLE/, + ); + expect(() => core.withSpan('work', () => 1)).toThrow(/X_ASYNC_CONTEXT_UNAVAILABLE/); + }, 60_000); + + /** + * `impersonate()` needs no refusal of its own and deliberately has none: it reads the parent + * context first, and there is no parent here. A second code for the same fact would be two + * answers to one question. + */ + test('reports a missing parent, not a missing runtime, when impersonating', async () => { + const core = await barrel(); + expect(() => core.impersonate(core.anonymousActor(), 'ticket 4821', () => 1)).toThrow( + /X_NO_CONTEXT/, + ); + }, 60_000); +}); diff --git a/packages/core/src/async-context.ts b/packages/core/src/async-context.ts new file mode 100644 index 00000000..190c679a --- /dev/null +++ b/packages/core/src/async-context.ts @@ -0,0 +1,77 @@ +// Single responsibility: the one lazily-constructed `AsyncLocalStorage` in the framework. Every +// ambient value core carries — the request context, the active span, the impersonation reason — +// opens its scope through this seam, so "what happens where there is no async context" has one +// answer instead of one per module. + +// `node:async_hooks` is unavoidable and deliberate: nothing else in Bun makes a value ambient +// across an `await` without threading it through every signature in the framework. +import { AsyncLocalStorage } from 'node:async_hooks'; +import { UltimateError } from './errors'; + +export interface AsyncContext { + /** The value in flight, or `undefined` — outside a scope, and in a runtime that has none. */ + get(): T | undefined; + /** Run `fn` with `value` in flight. `X_ASYNC_CONTEXT_UNAVAILABLE` where that is impossible. */ + run(value: T, fn: () => R): R; +} + +/** + * The storage is constructed on first `run()`, never at module scope. That is the whole point of + * this file: a browser bundler stubs `node:async_hooks` to `{}` — Bun's `target: 'browser'` emits + * `var { AsyncLocalStorage } = (() => ({}))` — so a module-scope `new` threw + * `TypeError: undefined is not a constructor` at module EVALUATION, and every package that + * transitively imports core was dead on arrival in a client bundle. `@ultimat3/ui` calls itself a + * SolidJS design system and could not be put on a client by the only client bundler the framework + * has, for this reason and no other. + * + * The server pays nothing: `getStore()` before any `run()` answers `undefined` whether the storage + * was ever constructed or not, so deferring the construction changes no observable behaviour. + * + * **Reads degrade, writes throw**, and that split is the doctrine rather than a convenience. + * `get()` answers `undefined` in a browser because that is TRUE — nothing is in flight there, so + * "am I inside a scope" has a definite no for an answer and does not deserve an exception. It is + * the same call `@ultimat3/ui`'s `solid()` makes: inert where the capability is genuinely absent, + * throwing only where a caller asked for something the runtime cannot deliver. `run()` is that + * second case, so it names itself with a code and a fix rather than leaving a bare `TypeError` + * from a stack that mentions no file the caller wrote. + * + * **A synchronous save/restore fallback is not the answer here**, and this note exists so that it + * is not re-proposed: a module-level `current` swapped in a `try`/`finally` serves sync code and + * is silently WRONG across an `await` — two overlapping scopes interleave and the second one's + * `finally` restores a value the first is still inside. That is the `jobs: { driver: 'redis' }` + * failure mode this repo already paid for once: accepted, unwarned, and wrong in the dangerous + * direction. An error a caller can read beats an ambient value that is occasionally somebody + * else's. + * + * `subject` names what could not be opened, and is a `string` by construction — never an + * `unknown` reaching a `cause:`, which `bun run error-render` refuses. + */ +export function asyncContext(subject: string): AsyncContext { + let storage: AsyncLocalStorage | undefined; + + function open(): AsyncLocalStorage | undefined { + if (storage !== undefined) return storage; + // The stub is an object with no `AsyncLocalStorage` key, so the binding reads `undefined`. + if (typeof AsyncLocalStorage !== 'function') return undefined; + storage = new AsyncLocalStorage(); + return storage; + } + + return { + get(): T | undefined { + return open()?.getStore(); + }, + run(value: T, fn: () => R): R { + const store = open(); + if (store === undefined) { + throw new UltimateError({ + code: 'X_ASYNC_CONTEXT_UNAVAILABLE', + cause: `${subject} needs AsyncLocalStorage, and node:async_hooks is stubbed to {} in this runtime`, + fix: `${subject} is server-only — open it in apps/web/server.ts or a route handler, and keep every module that opens one out of the import graph of a client island entry`, + meta: { subject }, + }); + } + return store.run(value, fn); + }, + }; +} diff --git a/packages/core/src/context.test.ts b/packages/core/src/context.test.ts index d4760326..ec6dd137 100644 --- a/packages/core/src/context.test.ts +++ b/packages/core/src/context.test.ts @@ -181,3 +181,30 @@ describe('request-scoped log fields', () => { expect(JSON.parse(lines[0] ?? '{}')).not.toHaveProperty('orgId'); }); }); + +/** + * The browser-bundle guard for the whole package — including this module — lives in + * `async-context.test.ts`, beside the seam that owns the defect. What is asserted here is the + * half that seam must not have changed: the server. + */ +describe('the server path is unchanged by the lazy storage', () => { + test('resolves the same context object inside the scope, and none outside it', () => { + const ctx = createContext({ locale: 'de-DE' }); + expect(hasContext()).toBe(false); + const seen = runWithContext(ctx, () => { + expect(hasContext()).toBe(true); + return useContext(); + }); + expect(seen).toBe(ctx); + expect(hasContext()).toBe(false); + expect(tryUseContext()).toBeUndefined(); + }); + + test('propagates across an await, which is the whole reason for AsyncLocalStorage', async () => { + const ctx = createContext({ locale: 'fr-FR' }); + await runWithContext(ctx, async () => { + await Bun.sleep(1); + expect(useContext().locale).toBe('fr-FR'); + }); + }); +}); diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index 627934ba..2cbb97a6 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -2,8 +2,8 @@ // service bag reach every layer through AsyncLocalStorage instead of being threaded as // parameters — otherwise every signature in the framework grows a `ctx` argument twice. -import { AsyncLocalStorage } from 'node:async_hooks'; import { type Actor, anonymousActor } from './actor'; +import { asyncContext } from './async-context'; import { type Clock, systemClock } from './clock'; import { UltimateError } from './errors'; import { traceId as newTraceId, uuid } from './ids'; @@ -72,7 +72,12 @@ export interface CtxInit { export type CtxPatch = Omit; -const storage = new AsyncLocalStorage(); +/** + * `async-context.ts` owns why this is a lazily-opened seam rather than a module-scope + * `new AsyncLocalStorage()`, and why a browser gets `undefined` from a read and an error from a + * write. It is the same seam `telemetry.ts` and `impersonate.ts` open, on purpose: one answer. + */ +const requestContext = asyncContext('the request context'); const neverAborted = new AbortController().signal; @@ -133,16 +138,16 @@ export function createContext(init: CtxInit = {}): Ctx { } export function runWithContext(ctx: Ctx, fn: () => T): T { - return storage.run(ctx, fn); + return requestContext.run(ctx, fn); } /** The context, or `undefined` outside a request. Prefer `useContext()` in app code. */ export function tryUseContext(): Ctx | undefined { - return storage.getStore(); + return requestContext.get(); } export function useContext(): Ctx { - const ctx = storage.getStore(); + const ctx = tryUseContext(); if (ctx === undefined) { throw new UltimateError({ code: 'X_NO_CONTEXT', @@ -154,7 +159,7 @@ export function useContext(): Ctx { } export function hasContext(): boolean { - return storage.getStore() !== undefined; + return tryUseContext() !== undefined; } /** @@ -183,7 +188,7 @@ export function withChildContext(patch: CtxPatch, fn: () => T): T { signal: patch.signal ?? parent.signal, services: { ...carried, ...(patch.services ?? {}) }, }); - return storage.run(child, fn); + return requestContext.run(child, fn); } /** Resolve a late-bound service. Throws `X_SERVICE_MISSING` rather than returning undefined. */ @@ -222,7 +227,7 @@ export function throwIfAborted(ctx: Ctx = useContext()): void { * never mistaken for the customer's own. */ setLoggerContextFields(() => { - const ctx = storage.getStore(); + const ctx = tryUseContext(); if (ctx === undefined) return undefined; const { actor } = ctx; return { diff --git a/packages/core/src/error-codes.ts b/packages/core/src/error-codes.ts index 0cf383ed..66ecb4d1 100644 --- a/packages/core/src/error-codes.ts +++ b/packages/core/src/error-codes.ts @@ -28,6 +28,7 @@ export function errorDocsUrl(code: string): string { /** Codes owned by `@ultimat3/core`. Every other package calls `registerErrorCodes()`. */ const CORE_CODE_TITLES = { X_ABORTED: 'operation aborted', + X_ASYNC_CONTEXT_UNAVAILABLE: 'async context unavailable', X_CONFIG_INVALID: 'app.config.ts is invalid', X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query', X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key', diff --git a/packages/core/src/impersonate.ts b/packages/core/src/impersonate.ts index 50794fc2..f04bb5ca 100644 --- a/packages/core/src/impersonate.ts +++ b/packages/core/src/impersonate.ts @@ -2,13 +2,16 @@ // the mechanism; this is the ONE door through it, because a swap with no reason and no origin is // indistinguishable in an audit trail from the customer doing it themselves. -import { AsyncLocalStorage } from 'node:async_hooks'; import { type Actor, actorLabel, actorOrigin } from './actor'; import { assert } from './assert'; +import { asyncContext } from './async-context'; import { useContext, withChildContext } from './context'; import { currentSpan } from './telemetry'; -const storage = new AsyncLocalStorage(); +// The same lazily-opened seam `context.ts` uses. Its `run()` cannot be reached in a runtime with +// no async context: `impersonate()` calls `useContext()` first, and that throws `X_NO_CONTEXT` +// there — so no second refusal is written here for symmetry's sake. +const impersonation = asyncContext('the impersonation reason'); /** * Run `fn` as `actor`, recording who asked and why. @@ -45,7 +48,7 @@ export function impersonate(actor: Actor, reason: string, fn: () => T): T { 'actor.label': label, 'impersonation.reason': reason, }); - return withChildContext({ actor: impersonated }, () => storage.run(reason, fn)); + return withChildContext({ actor: impersonated }, () => impersonation.run(reason, fn)); } /** @@ -53,10 +56,10 @@ export function impersonate(actor: Actor, reason: string, fn: () => T): T { * an app where nobody is impersonating. Read by an audit sink, and by nothing else. */ export function impersonationReason(): string | undefined { - return storage.getStore(); + return impersonation.get(); } /** Is the caller acting as somebody else right now? */ export function isImpersonating(): boolean { - return storage.getStore() !== undefined; + return impersonation.get() !== undefined; } diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts index 4ad62261..29fc1517 100644 --- a/packages/core/src/telemetry.ts +++ b/packages/core/src/telemetry.ts @@ -2,7 +2,7 @@ // is a no-op so unconfigured apps pay nothing, and trace context is serialised explicitly // (`traceparent`) so a trace survives HTTP -> job -> live query. -import { AsyncLocalStorage } from 'node:async_hooks'; +import { asyncContext } from './async-context'; import { type Clock, systemClock } from './clock'; import { tryUseContext } from './context'; import { renderThrowable } from './error-render'; @@ -118,7 +118,10 @@ export function memoryExporter(): MemoryExporter { }; } -const activeSpan = new AsyncLocalStorage(); +// The same lazily-opened seam `context.ts` uses, and for the same reason: a module-scope +// `new AsyncLocalStorage()` throws at EVALUATION in a browser bundle, taking every importer of +// `@ultimat3/core` down with it. `async-context.ts` owns the argument. +const activeSpan = asyncContext('the active span'); let exporter: SpanExporter = noopExporter; let clock: Clock = systemClock; @@ -161,12 +164,12 @@ export function serviceResource(): SpanResource { } export function currentSpan(): Span | undefined { - return activeSpan.getStore(); + return activeSpan.get(); } /** The trace the caller is inside: active span, else the request context, else a fresh trace. */ export function currentSpanContext(): SpanContext | undefined { - const span = activeSpan.getStore(); + const span = activeSpan.get(); if (span !== undefined) return span.context; const ctx = tryUseContext(); if (ctx === undefined) return undefined; diff --git a/scripts/error-map-backlog.ts b/scripts/error-map-backlog.ts index c9b196ff..05278393 100644 --- a/scripts/error-map-backlog.ts +++ b/scripts/error-map-backlog.ts @@ -25,6 +25,7 @@ export const ERROR_STATUS_BACKLOG: Readonly> = // by an image/secrets path that answers nothing. `X_CURSOR_INVALID` left on purpose: it IS a // request, and it has a row. core: [ + 'X_ASYNC_CONTEXT_UNAVAILABLE', 'X_CONFIG_INVALID', 'X_CURSOR_SECRET_DEV', 'X_ENVIRONMENT_INVALID', diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index 92af9b3b..71dd0df4 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -34,6 +34,7 @@ X_DB_DRIFT: schema differs from migrations | `X_INVARIANT` | invariant violated | framework state that "cannot happen" happened | report it with `x doctor --json` output attached | | `X_UNREACHABLE` | unreachable branch was reached | an exhaustive `switch` met a value the type says cannot exist | narrow the union at the call site | | `X_NOT_IMPLEMENTED` | this driver does not implement the requested feature | an interface-complete driver whose remote half is unwritten | use the default driver, or implement the named method | +| `X_ASYNC_CONTEXT_UNAVAILABLE` | async context unavailable | `node:async_hooks` is stubbed to `{}` in this runtime, so nothing can be made ambient — a browser bundle | the scope is server-only: open it in `apps/web/server.ts` or a route handler, and keep every module that opens one out of the import graph of a client island entry | | `X_NO_CONTEXT` | no request context is active | framework code called outside the ALS context | `runWithContext(createContext({ … }), fn)` | | `X_SERVICE_MISSING` | service is not registered on the request context | `ctx.` used without providing it | pass it in `createContext({ services: { … } })` | | `X_SERVICE_DUPLICATE` | a service name is registered twice | two `defineService('name', ...)` calls used the same name | rename one of the two declarations |