@sigil-dev/grimoire 0.7.5 → 0.7.6

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