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
7 changes: 6 additions & 1 deletion framework.manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 126 additions & 0 deletions packages/core/src/async-context.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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<number>('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<string>('the thing');
const task = async (value: string, delayMs: number): Promise<string> =>
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<string>('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<string>('the left');
const right = asyncContext<string>('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<BrowserBarrel> | undefined;

function barrel(): Promise<BrowserBarrel> {
built ??= (async (): Promise<BrowserBarrel> => {
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<BrowserBarrel>;
})();
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);
});
77 changes: 77 additions & 0 deletions packages/core/src/async-context.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
/** 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<R>(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<T>(subject: string): AsyncContext<T> {
let storage: AsyncLocalStorage<T> | undefined;

function open(): AsyncLocalStorage<T> | 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<T>();
return storage;
}

return {
get(): T | undefined {
return open()?.getStore();
},
run<R>(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);
},
};
}
27 changes: 27 additions & 0 deletions packages/core/src/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
21 changes: 13 additions & 8 deletions packages/core/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -72,7 +72,12 @@ export interface CtxInit {

export type CtxPatch = Omit<CtxInit, 'requestId'>;

const storage = new AsyncLocalStorage<Ctx>();
/**
* `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<Ctx>('the request context');

const neverAborted = new AbortController().signal;

Expand Down Expand Up @@ -133,16 +138,16 @@ export function createContext(init: CtxInit = {}): Ctx {
}

export function runWithContext<T>(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',
Expand All @@ -154,7 +159,7 @@ export function useContext(): Ctx {
}

export function hasContext(): boolean {
return storage.getStore() !== undefined;
return tryUseContext() !== undefined;
}

/**
Expand Down Expand Up @@ -183,7 +188,7 @@ export function withChildContext<T>(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. */
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/impersonate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
// 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<string>('the impersonation reason');

/**
* Run `fn` as `actor`, recording who asked and why.
Expand Down Expand Up @@ -45,18 +48,18 @@ export function impersonate<T>(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));
}

/**
* The innermost enclosing reason, or `undefined` outside every scope — which is every request in
* 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;
}
Loading