@sigil-dev/grimoire 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/index.ts +35 -34
  2. package/package.json +8 -6
  3. package/preload.js +3 -2
  4. package/server.ts +13 -13
  5. package/src/client/head.ts +29 -29
  6. package/src/client/router.ts +120 -53
  7. package/src/dev/compile-module.ts +173 -0
  8. package/src/dev/effect-registry.ts +23 -0
  9. package/src/dev/graph.ts +114 -0
  10. package/src/dev/hmr-client.ts +158 -0
  11. package/src/dev/hmr-server.ts +187 -0
  12. package/src/dev/loader.ts +47 -0
  13. package/src/dev/paths.ts +14 -0
  14. package/src/dev/runtime-bundle.ts +49 -0
  15. package/src/dev/watcher.ts +44 -0
  16. package/src/integrations/vite.ts +73 -72
  17. package/src/rendering/hydrate.ts +102 -64
  18. package/src/rendering/index.ts +296 -199
  19. package/src/rendering/ssrPlugin.ts +67 -53
  20. package/src/routing/manifest-gen.ts +42 -39
  21. package/src/routing/router.ts +109 -106
  22. package/src/routing/scanner.ts +141 -135
  23. package/src/routing/transform-routes.ts +101 -101
  24. package/src/server/build.ts +239 -147
  25. package/src/server/coordinator.ts +306 -306
  26. package/src/server/index.ts +260 -50
  27. package/src/server/worker.ts +59 -59
  28. package/src/typegen/index.ts +356 -353
  29. package/src/types.ts +270 -269
  30. package/test/context.test.ts +52 -52
  31. package/test/hydration.test.ts +119 -119
  32. package/test/middleware.test.ts +223 -223
  33. package/test/rendering.test.ts +579 -425
  34. package/test/routing.test.ts +81 -83
  35. package/test/scanning.test.ts +200 -181
  36. package/test/scope.test.ts +24 -8
  37. package/test/server.test.ts +249 -229
  38. package/test/streaming.test.ts +125 -106
  39. package/test/transform-routes.test.ts +84 -84
  40. package/test/typegen.test.ts +35 -25
  41. package/tsconfig.json +1 -0
package/src/types.ts CHANGED
@@ -1,269 +1,270 @@
1
- import type { ServerWebSocket } from "bun";
2
- import type { RouteFile, RouteTree } from "./routing/scanner";
3
-
4
- /// <reference types="bun-types">
5
- declare global {
6
- namespace App {
7
- // augment in your own project via src/app.d.ts
8
- interface Locals {}
9
- interface Error {
10
- message: string;
11
- status?: number;
12
- }
13
- interface PageData {}
14
- interface Platform {}
15
- }
16
- }
17
-
18
- export interface Route {
19
- path: string; // /blog/:slug
20
- params: Record<string, string>;
21
- filePath: string; // absolute path to +page.tsx
22
- loadPath?: string; // absolute path to +page.ts
23
- serverPath?: string; // absolute path to +server.ts
24
- layoutPath?: string; // absolute path to +layout.tsx
25
- }
26
-
27
- export interface RouteInfo {
28
- path: string; // /blog/:slug
29
- filePath: string; // absolute path
30
- type: string; // page, layout etc
31
- }
32
-
33
- export interface LoadContext {
34
- request: Request;
35
- params: Record<string, string>;
36
- url: URL;
37
- locals: Record<string, unknown>; // set by plugins/hooks
38
- }
39
-
40
- export interface TypedLoadContext<
41
- P extends Record<string, string> = Record<string, string>,
42
- > {
43
- request: Request;
44
- params: P;
45
- url: URL;
46
- locals: App.Locals;
47
- }
48
-
49
- export interface RenderContext {
50
- route: RouteInfo;
51
- request: Request;
52
- params: Record<string, string>;
53
- data: unknown; // return value of load()
54
- }
55
-
56
- export interface BuildResult {
57
- success: boolean;
58
- outputs: string[];
59
- errors: string[];
60
- }
61
- export type Server = { port: number; hostname: string; stop(): void };
62
-
63
- export interface GrimoirePlugin {
64
- name: string;
65
-
66
- // server lifecycle
67
- onStart?(server: Server): void | Promise<void>;
68
- onStop?(reason: "shutdown" | "restart"): void | Promise<void>;
69
-
70
- // request pipeline
71
- onRequest?(
72
- req: Request,
73
- next: () => Promise<Response>,
74
- ): Response | Promise<Response>;
75
-
76
- // route lifecycle
77
- onRouteLoad?(route: Route, context: LoadContext): void | Promise<void>;
78
- /**
79
- * Called after the page HTML is fully rendered.
80
- * NOTE: When any plugin implements this hook, the streaming response is
81
- * buffered into a single string. Streaming is preserved for routes where
82
- * no plugin uses this hook. To avoid buffering, use onRequest to intercept
83
- * at the Response level.
84
- */
85
- onRouteRender?(
86
- html: string,
87
- context: RenderContext,
88
- ): string | Promise<string>;
89
-
90
- // build
91
- onBuildStart?(): void | Promise<void>;
92
- onBuildEnd?(result: BuildResult): void | Promise<void>;
93
-
94
- // config
95
- config?: (config: GrimoireConfig) => GrimoireConfig | undefined;
96
-
97
- // process lifecycle
98
-
99
- /**
100
- * Runs AFTER Sigil/Babel compilation. Receives compiled JS, not source TSX.
101
- * Suitable for string-level transforms (import rewriting, comment injection, etc.).
102
- * Return null/undefined to pass through unchanged.
103
- */
104
- transform?(
105
- code: string,
106
- id: string,
107
- ): string | null | undefined | Promise<string | null | undefined>;
108
-
109
- /**
110
- * Fires once after all workers are spawned and ready.
111
- * Use for coordinator-level setup (e.g. opening a shared store).
112
- */
113
- onCoordinatorStart?(ctx: CoordinatorContext): void | Promise<void>;
114
-
115
- /**
116
- * Fires for each worker before Bun.spawn is called.
117
- * Return WorkerEnv to inject env vars or set the worker name.
118
- * ALL plugins are called and results are merged.
119
- */
120
- // biome-ignore lint/suspicious/noConfusingVoidType: doesn't have to return a thing.
121
- onWorkerSpawn?(
122
- worker: WorkerDescriptor,
123
- ): WorkerEnv | void | Promise<WorkerEnv | void>;
124
-
125
- /**
126
- * Fires when a worker process is up and accepting requests.
127
- */
128
- onWorkerReady?(worker: WorkerDescriptor): void | Promise<void>;
129
-
130
- /**
131
- * Fires when a worker process exits.
132
- * reason "crash" = unexpected exit, "intentional" = coordinator stopped it.
133
- * Use for cleanup or respawn logic (see plugin-scale).
134
- */
135
- onWorkerDeath?(
136
- worker: WorkerDescriptor,
137
- reason: "crash" | "intentional",
138
- ): void | Promise<void>;
139
-
140
- // ── Locals boundary ────────────────────────────────────────
141
-
142
- /**
143
- * Called by coordinator before forwarding a request to a worker.
144
- * Return a serialized string representation of locals.
145
- * First plugin to return non-null wins. Default: signed JSON header.
146
- */
147
- serializeLocals?(locals: App.Locals): string | Promise<string>;
148
-
149
- /**
150
- * Called by worker on receiving a forwarded request.
151
- * Return the deserialized locals object.
152
- * First plugin to return non-null wins. Default: verify + JSON.parse.
153
- */
154
- deserializeLocals?(raw: string): App.Locals | Promise<App.Locals>;
155
-
156
- // ── Routing policy ─────────────────────────────────────────
157
-
158
- /**
159
- * Called by coordinator to decide which worker handles a request.
160
- * First plugin to return non-null wins.
161
- * Default: ws routes → consistent hash, everything else → round robin.
162
- * routes is the full RouteTree so plugins can make route-aware decisions.
163
- */
164
- routeRequest?(
165
- req: Request,
166
- workers: WorkerDescriptor[],
167
- routes: RouteTree,
168
- ): WorkerDescriptor | Promise<WorkerDescriptor>;
169
- }
170
-
171
- export interface GrimoireConfig {
172
- port?: number;
173
- host?: string;
174
- plugins?: GrimoirePlugin[];
175
- routes?: string; // glob, default 'src/routes/**';
176
- dev?: boolean;
177
- vitePort?: number;
178
- alias?: Record<string, string>; // e.g. { "$lib": "src/lib" } — resolved relative to cwd
179
- cspNonce?: string; // CSP nonce for <script>/<style> tags
180
- bundleStrategy?: "split" | "single"; // 'split' (default) or 'single' bundle
181
- _skipBuild?: boolean;
182
- }
183
-
184
- export function defineConfig(config: GrimoireConfig): GrimoireConfig {
185
- return config;
186
- }
187
-
188
- /** Generic load type for +page.server.ts. For per-route typed params import PageServerLoad from './$types'. */
189
- export type PageServerLoad<
190
- P extends Record<string, string> = Record<string, string>,
191
- > = (
192
- ctx: TypedLoadContext<P>,
193
- ) => Record<string, unknown> | Promise<Record<string, unknown>>;
194
-
195
- /** Generic load type for +layout.server.ts. For per-route typed params import LayoutServerLoad from './$types'. */
196
- export type LayoutServerLoad<
197
- P extends Record<string, string> = Record<string, string>,
198
- > = (
199
- ctx: TypedLoadContext<P>,
200
- ) => Record<string, unknown> | Promise<Record<string, unknown>>;
201
-
202
- /** Generic handler type for +server.ts API routes. For per-route typed params import RequestHandler from './$types'. */
203
- export type RequestHandler<
204
- P extends Record<string, string> = Record<string, string>,
205
- > = (ctx: TypedLoadContext<P>) => Response | Promise<Response>;
206
-
207
- /**
208
- * Shape of the `websocket` export in a +server.ts file.
209
- * T is the resolved ws.data shape (params + upgrade() return value).
210
- * For per-route typing import WebSocketHandler from './$types'.
211
- */
212
- export interface WsRouteHandler<T = Record<string, unknown>> {
213
- open?(ws: ServerWebSocket<T>): void | Promise<void>;
214
- message?(ws: ServerWebSocket<T>, data: string | Buffer): void | Promise<void>;
215
- close?(
216
- ws: ServerWebSocket<T>,
217
- code: number,
218
- reason?: string,
219
- ): void | Promise<void>;
220
- drain?(ws: ServerWebSocket<T>): void | Promise<void>;
221
- }
222
-
223
- // ***BIG ONE***
224
-
225
- /**
226
- * Built-in worker modes understood by the coordinator.
227
- * Plugins may define custom modes via WorkerMode.
228
- */
229
- export type BuiltinWorkerMode = "api" | "frontend" | "ws" | "full";
230
-
231
- /**
232
- * Open union — (string & {}) preserves autocomplete for built-in
233
- * values while allowing plugin-defined modes like "db" or "queue".
234
- */
235
- export type WorkerMode = BuiltinWorkerMode | (string & {});
236
-
237
- /**
238
- * Describes a single worker process managed by the coordinator.
239
- * Coordinator derives behavior from routes, not mode string
240
- * mode is informational and for plugin use only.
241
- */
242
- export interface WorkerDescriptor {
243
- mode: WorkerMode;
244
- index: number; // index within this mode (api-0, api-1...)
245
- globalIndex: number; // index across all workers
246
- internalUrl: string; // coordinator worker base URL
247
- name?: string; // set by namepool or onWorkerSpawn
248
- routes: RouteFile[]; // route slice assigned by coordinator
249
- pid?: number; // set after Bun.spawn
250
- }
251
-
252
- /**
253
- * Return value of onWorkerSpawn. All plugins are called and results
254
- * are merged env from all plugins is combined, last name wins.
255
- */
256
- export interface WorkerEnv {
257
- env?: Record<string, string>;
258
- name?: string;
259
- }
260
-
261
- /**
262
- * Passed to onCoordinatorStart once the coordinator is fully initialized.
263
- */
264
- export interface CoordinatorContext {
265
- workers: WorkerDescriptor[];
266
- port: number;
267
- secret: string; // ephemeral signing secret, generated per-run
268
- routes: RouteTree;
269
- }
1
+ import type { ServerWebSocket } from "bun";
2
+ import type { RouteFile, RouteTree } from "./routing/scanner";
3
+
4
+ /// <reference types="bun-types">
5
+ declare global {
6
+ namespace App {
7
+ // augment in your own project via src/app.d.ts
8
+ interface Locals {}
9
+ interface Error {
10
+ message: string;
11
+ status?: number;
12
+ }
13
+ interface PageData {}
14
+ interface Platform {}
15
+ }
16
+ }
17
+
18
+ export interface Route {
19
+ path: string; // /blog/:slug
20
+ params: Record<string, string>;
21
+ filePath: string; // absolute path to +page.tsx
22
+ loadPath?: string; // absolute path to +page.ts
23
+ serverPath?: string; // absolute path to +server.ts
24
+ layoutPath?: string; // absolute path to +layout.tsx
25
+ }
26
+
27
+ export interface RouteInfo {
28
+ path: string; // /blog/:slug
29
+ filePath: string; // absolute path
30
+ type: string; // page, layout etc
31
+ }
32
+
33
+ export interface LoadContext {
34
+ request: Request;
35
+ params: Record<string, string>;
36
+ url: URL;
37
+ locals: Record<string, unknown>; // set by plugins/hooks
38
+ }
39
+
40
+ export interface TypedLoadContext<
41
+ P extends Record<string, string> = Record<string, string>,
42
+ > {
43
+ request: Request;
44
+ params: P;
45
+ url: URL;
46
+ locals: App.Locals;
47
+ }
48
+
49
+ export interface RenderContext {
50
+ route: RouteInfo;
51
+ request: Request;
52
+ params: Record<string, string>;
53
+ data: unknown; // return value of load()
54
+ }
55
+
56
+ export interface BuildResult {
57
+ success: boolean;
58
+ outputs: string[];
59
+ errors: string[];
60
+ }
61
+ export type Server = { port: number; hostname: string; stop(): void };
62
+
63
+ export interface GrimoirePlugin {
64
+ name: string;
65
+
66
+ // server lifecycle
67
+ onStart?(server: Server): void | Promise<void>;
68
+ onStop?(reason: "shutdown" | "restart"): void | Promise<void>;
69
+
70
+ // request pipeline
71
+ onRequest?(
72
+ req: Request,
73
+ next: () => Promise<Response>,
74
+ ): Response | Promise<Response>;
75
+
76
+ // route lifecycle
77
+ onRouteLoad?(route: Route, context: LoadContext): void | Promise<void>;
78
+ /**
79
+ * Called after the page HTML is fully rendered.
80
+ * NOTE: When any plugin implements this hook, the streaming response is
81
+ * buffered into a single string. Streaming is preserved for routes where
82
+ * no plugin uses this hook. To avoid buffering, use onRequest to intercept
83
+ * at the Response level.
84
+ */
85
+ onRouteRender?(
86
+ html: string,
87
+ context: RenderContext,
88
+ ): string | Promise<string>;
89
+
90
+ // build
91
+ onBuildStart?(): void | Promise<void>;
92
+ onBuildEnd?(result: BuildResult): void | Promise<void>;
93
+
94
+ // config
95
+ config?: (config: GrimoireConfig) => GrimoireConfig | undefined;
96
+
97
+ // process lifecycle
98
+
99
+ /**
100
+ * Runs AFTER Sigil/Babel compilation. Receives compiled JS, not source TSX.
101
+ * Suitable for string-level transforms (import rewriting, comment injection, etc.).
102
+ * Return null/undefined to pass through unchanged.
103
+ */
104
+ transform?(
105
+ code: string,
106
+ id: string,
107
+ ): string | null | undefined | Promise<string | null | undefined>;
108
+
109
+ /**
110
+ * Fires once after all workers are spawned and ready.
111
+ * Use for coordinator-level setup (e.g. opening a shared store).
112
+ */
113
+ onCoordinatorStart?(ctx: CoordinatorContext): void | Promise<void>;
114
+
115
+ /**
116
+ * Fires for each worker before Bun.spawn is called.
117
+ * Return WorkerEnv to inject env vars or set the worker name.
118
+ * ALL plugins are called and results are merged.
119
+ */
120
+ onWorkerSpawn?(
121
+ worker: WorkerDescriptor,
122
+ // biome-ignore lint/suspicious/noConfusingVoidType: doesn't have to return a thing.
123
+ ): WorkerEnv | void | Promise<WorkerEnv | void>;
124
+
125
+ /**
126
+ * Fires when a worker process is up and accepting requests.
127
+ */
128
+ onWorkerReady?(worker: WorkerDescriptor): void | Promise<void>;
129
+
130
+ /**
131
+ * Fires when a worker process exits.
132
+ * reason "crash" = unexpected exit, "intentional" = coordinator stopped it.
133
+ * Use for cleanup or respawn logic (see plugin-scale).
134
+ */
135
+ onWorkerDeath?(
136
+ worker: WorkerDescriptor,
137
+ reason: "crash" | "intentional",
138
+ ): void | Promise<void>;
139
+
140
+ // ── Locals boundary ────────────────────────────────────────
141
+
142
+ /**
143
+ * Called by coordinator before forwarding a request to a worker.
144
+ * Return a serialized string representation of locals.
145
+ * First plugin to return non-null wins. Default: signed JSON header.
146
+ */
147
+ serializeLocals?(locals: App.Locals): string | Promise<string>;
148
+
149
+ /**
150
+ * Called by worker on receiving a forwarded request.
151
+ * Return the deserialized locals object.
152
+ * First plugin to return non-null wins. Default: verify + JSON.parse.
153
+ */
154
+ deserializeLocals?(raw: string): App.Locals | Promise<App.Locals>;
155
+
156
+ // ── Routing policy ─────────────────────────────────────────
157
+
158
+ /**
159
+ * Called by coordinator to decide which worker handles a request.
160
+ * First plugin to return non-null wins.
161
+ * Default: ws routes → consistent hash, everything else → round robin.
162
+ * routes is the full RouteTree so plugins can make route-aware decisions.
163
+ */
164
+ routeRequest?(
165
+ req: Request,
166
+ workers: WorkerDescriptor[],
167
+ routes: RouteTree,
168
+ ): WorkerDescriptor | Promise<WorkerDescriptor>;
169
+ }
170
+
171
+ export interface GrimoireConfig {
172
+ port?: number;
173
+ host?: string;
174
+ plugins?: GrimoirePlugin[];
175
+ routes?: string; // glob, default 'src/routes/**';
176
+ dev?: boolean;
177
+ devEditor?: boolean;
178
+ vitePort?: number;
179
+ alias?: Record<string, string>; // e.g. { "$lib": "src/lib" } — resolved relative to cwd
180
+ cspNonce?: string; // CSP nonce for <script>/<style> tags
181
+ bundleStrategy?: "split" | "single"; // 'split' (default) or 'single' bundle
182
+ _skipBuild?: boolean;
183
+ }
184
+
185
+ export function defineConfig(config: GrimoireConfig): GrimoireConfig {
186
+ return config;
187
+ }
188
+
189
+ /** Generic load type for +page.server.ts. For per-route typed params import PageServerLoad from './$types'. */
190
+ export type PageServerLoad<
191
+ P extends Record<string, string> = Record<string, string>,
192
+ > = (
193
+ ctx: TypedLoadContext<P>,
194
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
195
+
196
+ /** Generic load type for +layout.server.ts. For per-route typed params import LayoutServerLoad from './$types'. */
197
+ export type LayoutServerLoad<
198
+ P extends Record<string, string> = Record<string, string>,
199
+ > = (
200
+ ctx: TypedLoadContext<P>,
201
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
202
+
203
+ /** Generic handler type for +server.ts API routes. For per-route typed params import RequestHandler from './$types'. */
204
+ export type RequestHandler<
205
+ P extends Record<string, string> = Record<string, string>,
206
+ > = (ctx: TypedLoadContext<P>) => Response | Promise<Response>;
207
+
208
+ /**
209
+ * Shape of the `websocket` export in a +server.ts file.
210
+ * T is the resolved ws.data shape (params + upgrade() return value).
211
+ * For per-route typing import WebSocketHandler from './$types'.
212
+ */
213
+ export interface WsRouteHandler<T = Record<string, unknown>> {
214
+ open?(ws: ServerWebSocket<T>): void | Promise<void>;
215
+ message?(ws: ServerWebSocket<T>, data: string | Buffer): void | Promise<void>;
216
+ close?(
217
+ ws: ServerWebSocket<T>,
218
+ code: number,
219
+ reason?: string,
220
+ ): void | Promise<void>;
221
+ drain?(ws: ServerWebSocket<T>): void | Promise<void>;
222
+ }
223
+
224
+ // ***BIG ONE***
225
+
226
+ /**
227
+ * Built-in worker modes understood by the coordinator.
228
+ * Plugins may define custom modes via WorkerMode.
229
+ */
230
+ export type BuiltinWorkerMode = "api" | "frontend" | "ws" | "full";
231
+
232
+ /**
233
+ * Open union (string & {}) preserves autocomplete for built-in
234
+ * values while allowing plugin-defined modes like "db" or "queue".
235
+ */
236
+ export type WorkerMode = BuiltinWorkerMode | (string & {});
237
+
238
+ /**
239
+ * Describes a single worker process managed by the coordinator.
240
+ * Coordinator derives behavior from routes, not mode string —
241
+ * mode is informational and for plugin use only.
242
+ */
243
+ export interface WorkerDescriptor {
244
+ mode: WorkerMode;
245
+ index: number; // index within this mode (api-0, api-1...)
246
+ globalIndex: number; // index across all workers
247
+ internalUrl: string; // coordinator worker base URL
248
+ name?: string; // set by namepool or onWorkerSpawn
249
+ routes: RouteFile[]; // route slice assigned by coordinator
250
+ pid?: number; // set after Bun.spawn
251
+ }
252
+
253
+ /**
254
+ * Return value of onWorkerSpawn. All plugins are called and results
255
+ * are merged — env from all plugins is combined, last name wins.
256
+ */
257
+ export interface WorkerEnv {
258
+ env?: Record<string, string>;
259
+ name?: string;
260
+ }
261
+
262
+ /**
263
+ * Passed to onCoordinatorStart once the coordinator is fully initialized.
264
+ */
265
+ export interface CoordinatorContext {
266
+ workers: WorkerDescriptor[];
267
+ port: number;
268
+ secret: string; // ephemeral signing secret, generated per-run
269
+ routes: RouteTree;
270
+ }
@@ -1,52 +1,52 @@
1
- // packages/grimoire/src/context.test.ts
2
- import { describe, expect, test } from "bun:test";
3
- import { createContext, getContext, setContext } from "@sigil-dev/runtime";
4
- import { runWithContext } from "../src/server/context";
5
-
6
- const UserKey = createContext<string>();
7
- const ThemeKey = createContext<string>();
8
-
9
- // 1. Basic: does it work at all
10
- test("getContext returns value set in same context", async () => {
11
- await runWithContext(async () => {
12
- setContext(UserKey, "cane");
13
- expect(getContext(UserKey)).toBe("cane");
14
- });
15
- });
16
-
17
- // 2. Isolation: the whole point of AsyncLocalStorage
18
- test("concurrent requests do not bleed context", async () => {
19
- let resolveA!: () => void;
20
- let resolveB!: () => void;
21
-
22
- const barrier = {
23
- a: new Promise<void>((r) => (resolveA = r)),
24
- b: new Promise<void>((r) => (resolveB = r)),
25
- };
26
-
27
- const requestA = runWithContext(async () => {
28
- setContext(UserKey, "request-A");
29
- await barrier.a; // suspend, let B run
30
- // if context bleeds, this returns "request-B"
31
- return getContext(UserKey);
32
- });
33
-
34
- const requestB = runWithContext(async () => {
35
- setContext(UserKey, "request-B");
36
- resolveA(); // wake A up
37
- await barrier.b;
38
- return getContext(UserKey);
39
- });
40
-
41
- resolveB();
42
- const [a, b] = await Promise.all([requestA, requestB]);
43
-
44
- expect(a).toBe("request-A"); // fails if bleed
45
- expect(b).toBe("request-B");
46
- });
47
-
48
- // 3. Outside context: should not throw, returns undefined
49
- test("getContext outside runWithContext returns undefined gracefully", () => {
50
- const val = getContext(UserKey);
51
- expect(val).toBeUndefined();
52
- });
1
+ // packages/grimoire/src/context.test.ts
2
+ import { describe, expect, test } from "bun:test";
3
+ import { createContext, getContext, setContext } from "@sigil-dev/runtime";
4
+ import { runWithContext } from "../src/server/context";
5
+
6
+ const UserKey = createContext<string>();
7
+ const ThemeKey = createContext<string>();
8
+
9
+ // 1. Basic: does it work at all
10
+ test("getContext returns value set in same context", async () => {
11
+ await runWithContext(async () => {
12
+ setContext(UserKey, "cane");
13
+ expect(getContext(UserKey)).toBe("cane");
14
+ });
15
+ });
16
+
17
+ // 2. Isolation: the whole point of AsyncLocalStorage
18
+ test("concurrent requests do not bleed context", async () => {
19
+ let resolveA!: () => void;
20
+ let resolveB!: () => void;
21
+
22
+ const barrier = {
23
+ a: new Promise<void>((r) => (resolveA = r)),
24
+ b: new Promise<void>((r) => (resolveB = r)),
25
+ };
26
+
27
+ const requestA = runWithContext(async () => {
28
+ setContext(UserKey, "request-A");
29
+ await barrier.a; // suspend, let B run
30
+ // if context bleeds, this returns "request-B"
31
+ return getContext(UserKey);
32
+ });
33
+
34
+ const requestB = runWithContext(async () => {
35
+ setContext(UserKey, "request-B");
36
+ resolveA(); // wake A up
37
+ await barrier.b;
38
+ return getContext(UserKey);
39
+ });
40
+
41
+ resolveB();
42
+ const [a, b] = await Promise.all([requestA, requestB]);
43
+
44
+ expect(a).toBe("request-A"); // fails if bleed
45
+ expect(b).toBe("request-B");
46
+ });
47
+
48
+ // 3. Outside context: should not throw, returns undefined
49
+ test("getContext outside runWithContext returns undefined gracefully", () => {
50
+ const val = getContext(UserKey);
51
+ expect(val).toBeUndefined();
52
+ });