@sigil-dev/grimoire 0.4.0 → 0.6.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 (73) hide show
  1. package/README.md +174 -174
  2. package/index.ts +47 -23
  3. package/package.json +6 -6
  4. package/src/{enhance.ts → client/enhance.ts} +2 -1
  5. package/src/client/index.ts +5 -0
  6. package/src/{client-router.ts → client/router.ts} +1 -1
  7. package/src/{vite-plugin.ts → integrations/vite.ts} +4 -4
  8. package/src/{hydrate.ts → rendering/hydrate.ts} +2 -2
  9. package/src/{renderer.ts → rendering/index.ts} +39 -15
  10. package/src/{ssrPlugin.ts → rendering/ssrPlugin.ts} +8 -2
  11. package/src/{scanner.ts → routing/scanner.ts} +16 -4
  12. package/src/{transform-routes.ts → routing/transform-routes.ts} +7 -1
  13. package/src/{fail.ts → sentinels/fail.ts} +1 -1
  14. package/src/server/build.ts +90 -0
  15. package/src/{cookie-utils.ts → server/cookie-utils.ts} +66 -66
  16. package/src/server/coordinator.ts +297 -0
  17. package/src/{hooks.ts → server/hooks.ts} +1 -1
  18. package/src/{server.ts → server/index.ts} +153 -105
  19. package/src/server/plugins.ts +119 -0
  20. package/src/server/worker.ts +59 -0
  21. package/src/{typegen.ts → typegen/index.ts} +81 -4
  22. package/src/types.ts +176 -1
  23. package/test/context.test.ts +1 -1
  24. package/test/fail.test.ts +46 -46
  25. package/test/headers.test.ts +100 -96
  26. package/test/hydration.test.ts +1 -1
  27. package/test/middleware.test.ts +221 -217
  28. package/test/preload.ts +1 -1
  29. package/test/redirect-error.test.ts +112 -112
  30. package/test/rendering.test.ts +319 -310
  31. package/test/routing.test.ts +2 -2
  32. package/test/scanning.test.ts +40 -11
  33. package/test/scope.test.ts +25 -10
  34. package/test/server.test.ts +150 -1
  35. package/test/streaming.test.ts +145 -132
  36. package/test/transform-routes.test.ts +2 -2
  37. package/test/typegen.test.ts +10 -8
  38. package/tsconfig.json +3 -1
  39. package/.grimoire/_routes.dom.js +0 -4
  40. package/.grimoire/_routes.hydrate.js +0 -4
  41. package/.grimoire/_routes.ts +0 -4
  42. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_protected__page.dom.js +0 -9
  43. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_protected__page.hydrate.js +0 -11
  44. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_protected__page.dom.js +0 -4
  45. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_protected__page.hydrate.js +0 -4
  46. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_login.dom.js +0 -4
  47. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_login.hydrate.js +0 -4
  48. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_login.dom.js +0 -4
  49. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_login.hydrate.js +0 -4
  50. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_login__page.dom.js +0 -8
  51. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_login__page.hydrate.js +0 -9
  52. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_login__page.dom.js +0 -4
  53. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_login__page.hydrate.js +0 -4
  54. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_protected.dom.js +0 -4
  55. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_protected.hydrate.js +0 -4
  56. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_protected.dom.js +0 -4
  57. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_protected.hydrate.js +0 -4
  58. package/.grimoire/tsconfig.generated.json +0 -11
  59. package/.grimoire/types/ambient.d.ts +0 -6
  60. package/.grimoire/types/api/hello/$types.d.ts +0 -29
  61. package/.grimoire/types/api/items/$types.d.ts +0 -29
  62. package/public/__grimoire__/client.js +0 -86
  63. package/public/__grimoire__/hydrate.js +0 -101
  64. package/src/client.ts +0 -4
  65. package/src/plugins.ts +0 -25
  66. package/src/sync.ts +0 -18
  67. /package/src/{scope.ts → client/scope.ts} +0 -0
  68. /package/src/{head.ts → rendering/head.ts} +0 -0
  69. /package/src/{manifest-gen.ts → routing/manifest-gen.ts} +0 -0
  70. /package/src/{router.ts → routing/router.ts} +0 -0
  71. /package/src/{error.ts → sentinels/error.ts} +0 -0
  72. /package/src/{redirect.ts → sentinels/redirect.ts} +0 -0
  73. /package/src/{context.ts → server/context.ts} +0 -0
@@ -1,6 +1,7 @@
1
1
  import { transformSync } from "@babel/core";
2
2
  import sigilPlugin from "@sigil-dev/compiler/babel";
3
3
  import { basename, dirname, join, relative, resolve } from "path";
4
+ import type { GrimoirePlugin } from "../types";
4
5
  import type { RouteFile } from "./scanner";
5
6
 
6
7
  const STYLE_RE = /<style[^>]*>([\s\S]*?)<\/style>/gi;
@@ -56,6 +57,7 @@ export async function transformRoutes(
56
57
  routes: RouteFile[],
57
58
  outDir: string,
58
59
  mode: "hydrate" | "dom",
60
+ plugins: GrimoirePlugin[] = [],
59
61
  ): Promise<Map<string, string>> {
60
62
  const map = new Map<string, string>();
61
63
  const transpiler = new Bun.Transpiler({ loader: "tsx", target: "browser" });
@@ -76,7 +78,11 @@ export async function transformRoutes(
76
78
  ],
77
79
  filename: route.filePath,
78
80
  });
79
- const out = transpiler.transformSync(res?.code ?? "");
81
+ let out = transpiler.transformSync(res?.code ?? "");
82
+ for (const plugin of plugins) {
83
+ if (plugin.transform)
84
+ out = (await plugin.transform(out, route.filePath)) ?? out;
85
+ }
80
86
  const rel = relative(process.cwd(), route.filePath)
81
87
  .replace(/\.[cm]?[jt]sx?$/, "")
82
88
  .replace(/[^a-zA-Z0-9._-]/g, "_");
@@ -35,7 +35,7 @@ export function isFailResult(value: unknown): value is FailResult {
35
35
  return (
36
36
  typeof value === "object" &&
37
37
  value !== null &&
38
- "__fail" in value &&
38
+ "__fail" in value &&
39
39
  (value as any).__fail === true
40
40
  );
41
41
  }
@@ -0,0 +1,90 @@
1
+ import { sigil } from "@sigil-dev/compiler/bun";
2
+ import { mkdir } from "fs/promises";
3
+ import { isAbsolute, join } from "path";
4
+ import { generateManifest } from "../routing/manifest-gen";
5
+ import type { RouteTree } from "../routing/scanner";
6
+ import { scanRoutes } from "../routing/scanner";
7
+ import { transformRoutes } from "../routing/transform-routes";
8
+ import { generateTypes } from "../typegen";
9
+ import type { BuildResult, GrimoireConfig, GrimoirePlugin } from "../types";
10
+ import { runHook } from "./plugins";
11
+
12
+ export async function buildProject(
13
+ config: GrimoireConfig,
14
+ plugins: GrimoirePlugin[] = [],
15
+ ): Promise<{ result: BuildResult; tree: RouteTree }> {
16
+ await runHook(plugins, "onBuildStart");
17
+
18
+ const { routes = "src/routes" } = config;
19
+
20
+ const routesDir = isAbsolute(routes)
21
+ ? routes.replace(/\0/g, "")
22
+ : join(process.cwd(), routes).replace(/\0/g, "");
23
+ const tree = await scanRoutes(routesDir, process.cwd());
24
+
25
+ await mkdir(join(process.cwd(), ".grimoire"), { recursive: true });
26
+
27
+ await generateTypes(tree, {
28
+ projectRoot: process.cwd(),
29
+ routesDir,
30
+ outDir: join(process.cwd(), ".grimoire/types"),
31
+ });
32
+
33
+ const compiledDir = join(process.cwd(), ".grimoire/compiled");
34
+ await mkdir(compiledDir, { recursive: true });
35
+
36
+ const pageRoutes = tree.routes.filter(
37
+ (r) => r.type === "page" || r.type === "simple",
38
+ );
39
+ const [hydrateFiles, domFiles] = await Promise.all([
40
+ transformRoutes(pageRoutes, compiledDir, "hydrate", plugins),
41
+ transformRoutes(pageRoutes, compiledDir, "dom", plugins),
42
+ ]);
43
+
44
+ const hydrateManifest = join(process.cwd(), ".grimoire/_routes.hydrate.js");
45
+ const domManifest = join(process.cwd(), ".grimoire/_routes.dom.js");
46
+ await Promise.all([
47
+ Bun.write(hydrateManifest, generateManifest(pageRoutes, hydrateFiles)),
48
+ Bun.write(domManifest, generateManifest(pageRoutes, domFiles)),
49
+ ]);
50
+
51
+ const makeRoutesPlugin = (manifestPath: string) => ({
52
+ name: "grimoire-routes",
53
+ setup(build: any) {
54
+ build.onResolve({ filter: /^#grimoire-routes$/ }, () => ({
55
+ path: manifestPath,
56
+ }));
57
+ },
58
+ });
59
+
60
+ const [hydrateResult, domResult] = await Promise.all([
61
+ Bun.build({
62
+ entrypoints: [join(import.meta.dir, "../rendering/hydrate.ts")],
63
+ outdir: join(process.cwd(), "public/__grimoire__"),
64
+ plugins: [sigil({ mode: "hydrate" }), makeRoutesPlugin(hydrateManifest)],
65
+ }),
66
+ Bun.build({
67
+ entrypoints: [join(import.meta.dir, "../client/index.ts")],
68
+ outdir: join(process.cwd(), "public/__grimoire__"),
69
+ plugins: [sigil({ mode: "dom" }), makeRoutesPlugin(domManifest)],
70
+ }),
71
+ ]);
72
+
73
+ if (!hydrateResult.success) {
74
+ for (const log of hydrateResult.logs) console.error(log);
75
+ }
76
+ if (!domResult.success) {
77
+ for (const log of domResult.logs) console.error(log);
78
+ }
79
+
80
+ const result: BuildResult = {
81
+ success: hydrateResult.success && domResult.success,
82
+ outputs: [...hydrateResult.outputs, ...domResult.outputs].map(
83
+ (o) => o.path,
84
+ ),
85
+ errors: [...hydrateResult.logs, ...domResult.logs].map(String),
86
+ };
87
+
88
+ await runHook(plugins, "onBuildEnd", result);
89
+ return { result, tree };
90
+ }
@@ -1,66 +1,66 @@
1
- /**
2
- * Parse cookies from a Cookie header string.
3
- */
4
- export function parseCookies(cookieHeader: string): Map<string, string> {
5
- const map = new Map<string, string>();
6
- if (!cookieHeader) return map;
7
- for (const pair of cookieHeader.split(";")) {
8
- const [name, ...rest] = pair.split("=");
9
- if (name) map.set(name.trim(), rest.join("=").trim());
10
- }
11
- return map;
12
- }
13
-
14
- /**
15
- * Build a Set-Cookie header from name, value, and options.
16
- */
17
- export function serializeCookie(
18
- name: string,
19
- value: string,
20
- options?: {
21
- path?: string;
22
- domain?: string;
23
- maxAge?: number;
24
- expires?: Date;
25
- httpOnly?: boolean;
26
- secure?: boolean;
27
- sameSite?: "strict" | "lax" | "none";
28
- },
29
- ): string {
30
- let cookie = `${name}=${encodeURIComponent(value)}`;
31
- if (options?.path) cookie += `; Path=${options.path}`;
32
- if (options?.domain) cookie += `; Domain=${options.domain}`;
33
- if (options?.maxAge != null) cookie += `; Max-Age=${options.maxAge}`;
34
- if (options?.expires) cookie += `; Expires=${options.expires.toUTCString()}`;
35
- if (options?.httpOnly) cookie += "; HttpOnly";
36
- if (options?.secure) cookie += "; Secure";
37
- if (options?.sameSite) cookie += `; SameSite=${options.sameSite}`;
38
- return cookie;
39
- }
40
-
41
- /**
42
- * Create a Cookies helper from a Cookie header string.
43
- */
44
- export function createCookies(
45
- cookieHeader: string,
46
- ): import("./hooks").Cookies & { toHeaders(): string[] } {
47
- const store = parseCookies(cookieHeader);
48
- const pending: string[] = [];
49
-
50
- return {
51
- get(name: string) {
52
- return store.get(name);
53
- },
54
- set(name: string, value: string, options?: any) {
55
- store.set(name, value);
56
- pending.push(serializeCookie(name, value, options));
57
- },
58
- delete(name: string) {
59
- store.delete(name);
60
- pending.push(serializeCookie(name, "", { maxAge: 0 }));
61
- },
62
- toHeaders() {
63
- return pending;
64
- },
65
- };
66
- }
1
+ /**
2
+ * Parse cookies from a Cookie header string.
3
+ */
4
+ export function parseCookies(cookieHeader: string): Map<string, string> {
5
+ const map = new Map<string, string>();
6
+ if (!cookieHeader) return map;
7
+ for (const pair of cookieHeader.split(";")) {
8
+ const [name, ...rest] = pair.split("=");
9
+ if (name) map.set(name.trim(), rest.join("=").trim());
10
+ }
11
+ return map;
12
+ }
13
+
14
+ /**
15
+ * Build a Set-Cookie header from name, value, and options.
16
+ */
17
+ export function serializeCookie(
18
+ name: string,
19
+ value: string,
20
+ options?: {
21
+ path?: string;
22
+ domain?: string;
23
+ maxAge?: number;
24
+ expires?: Date;
25
+ httpOnly?: boolean;
26
+ secure?: boolean;
27
+ sameSite?: "strict" | "lax" | "none";
28
+ },
29
+ ): string {
30
+ let cookie = `${name}=${encodeURIComponent(value)}`;
31
+ if (options?.path) cookie += `; Path=${options.path}`;
32
+ if (options?.domain) cookie += `; Domain=${options.domain}`;
33
+ if (options?.maxAge != null) cookie += `; Max-Age=${options.maxAge}`;
34
+ if (options?.expires) cookie += `; Expires=${options.expires.toUTCString()}`;
35
+ if (options?.httpOnly) cookie += "; HttpOnly";
36
+ if (options?.secure) cookie += "; Secure";
37
+ if (options?.sameSite) cookie += `; SameSite=${options.sameSite}`;
38
+ return cookie;
39
+ }
40
+
41
+ /**
42
+ * Create a Cookies helper from a Cookie header string.
43
+ */
44
+ export function createCookies(
45
+ cookieHeader: string,
46
+ ): import("./hooks").Cookies & { toHeaders(): string[] } {
47
+ const store = parseCookies(cookieHeader);
48
+ const pending: string[] = [];
49
+
50
+ return {
51
+ get(name: string) {
52
+ return store.get(name);
53
+ },
54
+ set(name: string, value: string, options?: any) {
55
+ store.set(name, value);
56
+ pending.push(serializeCookie(name, value, options));
57
+ },
58
+ delete(name: string) {
59
+ store.delete(name);
60
+ pending.push(serializeCookie(name, "", { maxAge: 0 }));
61
+ },
62
+ toHeaders() {
63
+ return pending;
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,297 @@
1
+ import { type Subprocess, spawn } from "bun";
2
+ import { join } from "path";
3
+ import { matchRoute } from "../routing/router";
4
+ import { type RouteFile, type RouteTree, scanRoutes } from "../routing/scanner";
5
+ import type {
6
+ CoordinatorContext,
7
+ GrimoireConfig,
8
+ GrimoirePlugin,
9
+ Route,
10
+ WorkerDescriptor,
11
+ WorkerMode,
12
+ } from "../types";
13
+ import { buildProject } from "./build";
14
+ import {
15
+ runHook,
16
+ runRouteRequest,
17
+ runSerializeLocals,
18
+ runWorkerSpawn,
19
+ } from "./plugins";
20
+
21
+ async function waitForWorkers(
22
+ workers: LiveWorker[],
23
+ timeoutMs = 10_000,
24
+ ): Promise<void> {
25
+ const ready = new Set<number>();
26
+ const start = Date.now();
27
+
28
+ while (ready.size < workers.length) {
29
+ if (Date.now() - start > timeoutMs) {
30
+ const pending = workers
31
+ .filter((w) => !ready.has(w.descriptor.globalIndex))
32
+ .map(
33
+ (w) =>
34
+ w.descriptor.name ?? `${w.descriptor.mode}-${w.descriptor.index}`,
35
+ )
36
+ .join(", ");
37
+ throw new Error(
38
+ `Grimoire: workers timed out waiting to become ready: ${pending}`,
39
+ );
40
+ }
41
+ for (const w of workers) {
42
+ if (ready.has(w.descriptor.globalIndex)) continue;
43
+ if (w.descriptor.pid != null) {
44
+ ready.add(w.descriptor.globalIndex);
45
+ }
46
+ }
47
+ await Bun.sleep(50);
48
+ }
49
+ }
50
+
51
+ function roundRobin(workers: WorkerDescriptor[]): WorkerDescriptor {
52
+ const idx = roundRobinCounter++ % workers.length;
53
+ return workers[idx];
54
+ }
55
+ let roundRobinCounter = 0;
56
+
57
+ function consistentHash(
58
+ req: Request,
59
+ workers: WorkerDescriptor[],
60
+ ): WorkerDescriptor {
61
+ const key = req.headers.get("cookie") ?? req.url;
62
+ let hash = 0;
63
+ for (let i = 0; i < key.length; i++) {
64
+ hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
65
+ }
66
+ return workers[hash % workers.length];
67
+ }
68
+
69
+ function defaultRoute(
70
+ req: Request,
71
+ workers: WorkerDescriptor[],
72
+ tree: RouteTree,
73
+ ): WorkerDescriptor {
74
+ const isWs = req.headers.get("upgrade")?.toLowerCase() === "websocket";
75
+
76
+ if (isWs) {
77
+ const wsWorkers = workers.filter(
78
+ (w) => w.mode === "ws" || w.mode === "full",
79
+ );
80
+ if (wsWorkers.length) return consistentHash(req, wsWorkers);
81
+ }
82
+
83
+ const url = new URL(req.url);
84
+ const matched = matchRoute(tree, url);
85
+
86
+ if (matched?.route.type === "server") {
87
+ const apiWorkers = workers.filter(
88
+ (w) => w.mode === "api" || w.mode === "full",
89
+ );
90
+ if (apiWorkers.length) return roundRobin(apiWorkers);
91
+ }
92
+
93
+ const frontendWorkers = workers.filter(
94
+ (w) => w.mode === "frontend" || w.mode === "full",
95
+ );
96
+ if (frontendWorkers.length) return roundRobin(frontendWorkers);
97
+
98
+ // fallback — should not happen if scale spec is valid
99
+ return workers[0];
100
+ }
101
+
102
+ export interface ScaleSpec {
103
+ api?: number;
104
+ frontend?: number;
105
+ ws?: number;
106
+ [mode: string]: number | undefined;
107
+ }
108
+
109
+ export interface CoordinatorOptions {
110
+ config: GrimoireConfig;
111
+ plugins: GrimoirePlugin[];
112
+ scale: ScaleSpec;
113
+ noBuild: boolean;
114
+ }
115
+
116
+ interface LiveWorker {
117
+ descriptor: WorkerDescriptor;
118
+ process: Subprocess;
119
+ port: number;
120
+ }
121
+
122
+ export async function startCoordinator(options: CoordinatorOptions) {
123
+ const { config, plugins, scale, noBuild } = options;
124
+ const { port = 3000, host = "localhost", routes = "src/routes" } = config;
125
+ let stopping = false;
126
+
127
+ // 1. build if needed
128
+ if (!noBuild) {
129
+ const { result } = await buildProject(config, plugins);
130
+ if (!result.success) throw new Error("Grimoire: build failed");
131
+ }
132
+
133
+ // 2. generate ephemeral secret
134
+ const secret = crypto.randomUUID() + crypto.randomUUID(); // 72 hex chars
135
+
136
+ // 3. scan routes once — coordinator owns the route tree
137
+ const routesDir = /* resolve */ routes;
138
+ const tree = await scanRoutes(routesDir, process.cwd());
139
+
140
+ // 4. derive route slices per mode
141
+ const slices: Record<string, RouteFile[]> = {
142
+ api: tree.routes.filter((r) => r.type === "server"),
143
+ ws: tree.routes.filter((r) => r.type === "server"),
144
+ frontend: tree.routes.filter(
145
+ (r) => r.type === "page" || r.type === "simple",
146
+ ),
147
+ full: [...tree.routes],
148
+ };
149
+
150
+ // 5. spawn workers
151
+ const liveWorkers: LiveWorker[] = [];
152
+ let globalIndex = 0;
153
+ let nextPort = port + 1;
154
+
155
+ for (const [mode, count = 0] of Object.entries(scale)) {
156
+ for (let i = 0; i < count; i++) {
157
+ const workerPort = nextPort++;
158
+ const descriptor: WorkerDescriptor = {
159
+ mode: mode as WorkerMode,
160
+ index: i + 1,
161
+ globalIndex: globalIndex++,
162
+ internalUrl: `http://127.0.0.1:${workerPort}`,
163
+ routes: slices[mode] ?? slices.full,
164
+ };
165
+
166
+ // let plugins inject env and name
167
+ const workerEnv = await runWorkerSpawn(plugins, descriptor);
168
+ const workerEntry = join(import.meta.dir, "./worker.ts");
169
+ if (workerEnv.name) descriptor.name = workerEnv.name;
170
+
171
+ const proc = spawn(["bun", "run", workerEntry], {
172
+ env: {
173
+ ...process.env,
174
+ ...workerEnv.env,
175
+ GRIMOIRE_MODE: mode,
176
+ GRIMOIRE_WORKER_INDEX: String(i),
177
+ GRIMOIRE_WORKER_PORT: String(workerPort),
178
+ GRIMOIRE_INTERNAL_SECRET: secret,
179
+ GRIMOIRE_WORKER_NAME: descriptor.name ?? `${mode}-${i}`,
180
+ },
181
+ ipc(message) {
182
+ // worker sends { ready: true } when Bun.serve is up
183
+ if (message?.ready) {
184
+ descriptor.pid = proc.pid;
185
+ runHook(plugins, "onWorkerReady", descriptor);
186
+ }
187
+ },
188
+ onExit(proc, exitCode) {
189
+ const intentional = stopping;
190
+ runHook(
191
+ plugins,
192
+ "onWorkerDeath",
193
+ descriptor,
194
+ intentional ? "intentional" : "crash",
195
+ );
196
+ if (!intentional) {
197
+ console.error(
198
+ `[grimoire] worker ${descriptor.name ?? descriptor.mode + "-" + descriptor.index} crashed (exit ${exitCode}), respawning...`,
199
+ );
200
+ // respawn: same descriptor, same port
201
+ // (simplified — plugin-scale can override this behavior)
202
+ }
203
+ },
204
+ });
205
+
206
+ liveWorkers.push({ descriptor, process: proc, port: workerPort });
207
+ }
208
+ }
209
+
210
+ // 6. wait for all workers ready (with timeout)
211
+ // workers send IPC { ready: true } — coordinator waits up to 10s
212
+ await waitForWorkers(liveWorkers);
213
+
214
+ // 7. fire onCoordinatorStart
215
+ const ctx: CoordinatorContext = {
216
+ workers: liveWorkers.map((w) => w.descriptor),
217
+ port,
218
+ secret,
219
+ routes: tree,
220
+ };
221
+ await runHook(plugins, "onCoordinatorStart", ctx);
222
+
223
+ // 8. start coordinator Bun.serve
224
+
225
+ const server = Bun.serve({
226
+ port,
227
+ hostname: host,
228
+ fetch: async (req) => {
229
+ // run hooks.server.ts to populate locals
230
+ const locals: App.Locals = {};
231
+ // ... same handle() chain as createServer but resolve() just
232
+ // forwards instead of rendering
233
+
234
+ // pick worker
235
+ const descriptors = liveWorkers.map((w) => w.descriptor);
236
+ const chosen =
237
+ (await runRouteRequest(plugins, req, descriptors, tree)) ??
238
+ defaultRoute(req, descriptors, tree);
239
+
240
+ // serialize locals
241
+ const serialized =
242
+ (await runSerializeLocals(plugins, locals)) ??
243
+ defaultSerialize(locals, secret);
244
+
245
+ // forward
246
+ return fetch(chosen.internalUrl + new URL(req.url).pathname, {
247
+ method: req.method,
248
+ headers: {
249
+ //@ts-expect-error shut Up
250
+ ...Object.fromEntries(req.headers),
251
+ "X-Grimoire-Locals": serialized,
252
+ "X-Grimoire-Internal": secret,
253
+ },
254
+ body: req.body,
255
+ });
256
+ },
257
+ });
258
+
259
+ // 9. shutdown
260
+ const handleShutdown = async () => {
261
+ if (stopping) return;
262
+ stopping = true;
263
+ await runHook(plugins, "onStop", "shutdown");
264
+ for (const w of liveWorkers) w.process.kill();
265
+ server.stop();
266
+ process.exit(0);
267
+ };
268
+ process.on("SIGINT", handleShutdown);
269
+ process.on("SIGTERM", handleShutdown);
270
+
271
+ return {
272
+ server,
273
+ workers: liveWorkers.map((w) => ({
274
+ name: w.descriptor.name,
275
+ mode: w.descriptor.mode,
276
+ port: w.port,
277
+ index: w.descriptor.index,
278
+ })),
279
+ };
280
+ }
281
+
282
+ function defaultSerialize(locals: App.Locals, secret: string): string {
283
+ const payload = JSON.stringify(locals);
284
+ const sig = new Bun.CryptoHasher("sha256")
285
+ .update(secret + payload)
286
+ .digest("hex");
287
+ return Buffer.from(JSON.stringify({ payload, sig })).toString("base64");
288
+ }
289
+
290
+ function defaultDeserialize(raw: string, secret: string): App.Locals {
291
+ const { payload, sig } = JSON.parse(Buffer.from(raw, "base64").toString());
292
+ const expected = new Bun.CryptoHasher("sha256")
293
+ .update(secret + payload)
294
+ .digest("hex");
295
+ if (sig !== expected) throw new Error("Grimoire: locals signature invalid");
296
+ return JSON.parse(payload);
297
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * SvelteKit-style server hooks.
3
3
  *
4
- * Create a hooks.server.ts in your project root:
4
+ * Create a hooks.index.ts in your project root:
5
5
  *
6
6
  * import type { Handle } from "@sigil-dev/grimoire/hooks";
7
7
  *