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: 7 additions & 0 deletions packages/benchmark/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@
"import": "./build/src/index.js"
}
},
"imports": {
"#packScope": {
"types": "./src/packs/packScope.node.ts",
"browser": "./build/src/packs/packScope.browser.js",
"default": "./build/src/packs/packScope.node.js"
}
},
"dependencies": {
"@korabench/core": "^1.0.6",
"remeda": "^2.33.6",
Expand Down
52 changes: 52 additions & 0 deletions packages/benchmark/src/packs/__tests__/packScope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {readFileSync} from "node:fs";
import {describe, expect, it} from "vitest";
import {createPackScope as createBrowserScope} from "../packScope.browser.js";
import {createPackScope as createNodeScope} from "../packScope.node.js";

describe.each([
["browser", createBrowserScope],
["node", createNodeScope],
])("%s pack scope", (_name, create) => {
it("has no store outside run()", () => {
expect(create<string>().getStore()).toBeUndefined();
});

it("exposes the store to the callback and returns its value", () => {
const scope = create<string>();

expect(scope.run("alpha", () => scope.getStore())).toBe("alpha");
});

it("restores the enclosing store, including when fn throws", () => {
const scope = create<string>();

scope.run("outer", () => {
scope.run("inner", () => undefined);
expect(scope.getStore()).toBe("outer");

expect(() =>
scope.run("inner", () => {
throw new Error("boom");
})
).toThrow("boom");
expect(scope.getStore()).toBe("outer");
});

expect(scope.getStore()).toBeUndefined();
});
});

// packs.ts is reached from the browser through Mechanism and RiskCategory, so a
// static node builtin in it breaks every client bundle that touches the barrel:
// the build externalizes the builtin to a stub and fails on the named import.
// The scope has to keep coming in through `#packScope`.
describe("packs.ts", () => {
it("imports no node builtin", () => {
const source = readFileSync(
new URL("../packs.ts", import.meta.url),
"utf8"
);

expect(source).not.toMatch(/from\s+"node:/);
});
});
32 changes: 32 additions & 0 deletions packages/benchmark/src/packs/packScope.browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type {PackScope} from "./packScope.js";

/**
* The browser stand-in, selected by the `browser` condition on `#packScope`.
*
* A page renders against one pack, so there is no concurrent work to keep
* apart and a save/restore stack is enough. It is accurate for the only shape
* `Packs.run()` is called with — a synchronous callback — and, unlike
* `AsyncLocalStorage`, does not survive an await; a browser caller that needs
* that would have to reach for the real thing.
*/
class StackScope<T> implements PackScope<T> {
#store: T | undefined;

getStore(): T | undefined {
return this.#store;
}

run<R>(store: T, fn: () => R): R {
const previous = this.#store;
this.#store = store;
try {
return fn();
} finally {
this.#store = previous;
}
}
}

export function createPackScope<T>(): PackScope<T> {
return new StackScope<T>();
}
11 changes: 11 additions & 0 deletions packages/benchmark/src/packs/packScope.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {AsyncLocalStorage} from "node:async_hooks";
import type {PackScope} from "./packScope.js";

/**
* The real async-context scope, used by the CLI, Node scripts and the worker.
* A server can have several runs in flight in one isolate, so the active pack
* has to follow each one across its awaits.
*/
export function createPackScope<T>(): PackScope<T> {
return new AsyncLocalStorage<T>();
}
16 changes: 16 additions & 0 deletions packages/benchmark/src/packs/packScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* The slice of `AsyncLocalStorage` the active-pack scope needs.
*
* `packs.ts` reaches an implementation through the `#packScope` subpath
* import, which resolves to `packScope.node.ts` everywhere except a browser
* bundle (see the `imports` map in package.json). Node's `AsyncLocalStorage`
* lives behind `node:async_hooks`, and a static import of that anywhere the
* browser can reach breaks bundlers: the client build externalizes the builtin
* to a stub and then fails on the named import. Every browser consumer of this
* package reaches `Packs.current()` through `Mechanism` and `RiskCategory`, so
* that path has to stay free of node builtins.
*/
export interface PackScope<T> {
getStore(): T | undefined;
run<R>(store: T, fn: () => R): R;
}
8 changes: 5 additions & 3 deletions packages/benchmark/src/packs/packs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {AsyncLocalStorage} from "node:async_hooks";
import {createPackScope} from "#packScope";
import {BehaviorSet} from "./behaviorSet.js";
import {bundledPacks} from "./bundled.js";
import {PackStamp} from "./packStamp.js";
Expand Down Expand Up @@ -28,13 +28,15 @@ export interface PacksOverride {
// --behaviors once per invocation and every command reads it.
// - `storage` is an async-context scope. kora-infra serves several runs
// concurrently from a single Cloudflare isolate, so a mutable global is not
// enough there; each run wraps its work in `Packs.run(...)`.
// enough there; each run wraps its work in `Packs.run(...)`. It comes from
// `#packScope` rather than `node:async_hooks` directly, so that a browser
// bundle can resolve a node-free implementation — see packScope.ts.
//
// Callers should pick one. Mixing them is legal but makes it much harder to
// reason about which pack a given schema was built from.
//

const storage = new AsyncLocalStorage<ActivePacks>();
const storage = createPackScope<ActivePacks>();

let configured: ActivePacks | undefined;

Expand Down
17 changes: 17 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import path from "node:path";
import {fileURLToPath} from "node:url";
import {defineConfig} from "vitest/config";

const thisDir = path.dirname(fileURLToPath(import.meta.url));

export default defineConfig({
resolve: {
// `#packScope` resolves through package.json conditions, which point at
// build output so that Node gets the AsyncLocalStorage scope and a browser
// bundle gets the node-free one. Tests run from source, and an unbuilt
// checkout should still be testable, so point it back at the source of the
// implementation Node would have picked.
alias: {
"#packScope": path.resolve(
thisDir,
"packages/benchmark/src/packs/packScope.node.ts"
),
},
},
test: {
include: ["packages/*/src/**/*.test.ts"],
},
Expand Down
Loading