@sigil-dev/grimoire 0.8.4 → 0.8.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.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
5
  "private": false,
6
- "version": "0.8.4",
6
+ "version": "0.8.6",
7
7
  "exports": {
8
8
  ".": "./index.ts",
9
9
  "./server": "./server.ts",
@@ -33,13 +33,13 @@
33
33
  "vite": "^8.0.16"
34
34
  },
35
35
  "peerDependencies": {
36
- "@sigil-dev/compiler": "0.8.4",
37
- "@sigil-dev/runtime": "0.8.4",
36
+ "@sigil-dev/compiler": "0.8.6",
37
+ "@sigil-dev/runtime": "0.8.6",
38
38
  "typescript": "^5"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "latest",
42
- "@sigil-dev/compiler": "0.8.4",
43
- "@sigil-dev/runtime": "0.8.4"
42
+ "@sigil-dev/compiler": "0.8.6",
43
+ "@sigil-dev/runtime": "0.8.6"
44
44
  }
45
45
  }
package/server.ts CHANGED
@@ -12,3 +12,4 @@ export {
12
12
  } from "./src/server/plugins";
13
13
  export type { TypegenConfig } from "./src/typegen";
14
14
  export { generateTypes } from "./src/typegen";
15
+ export {createWaiter, listenForKeys, stopListening} from "./src/server/interactive.ts"
@@ -36,14 +36,16 @@ export async function renderRoute(
36
36
  locals: Record<string, any> = {},
37
37
  plugins: GrimoirePlugin[] = [],
38
38
  cspNonce?: string,
39
- dev?: boolean,
39
+ dev?: boolean,
40
+ patchedFetch
40
41
  ): Promise<Response> {
41
42
  return runWithContext(async () => {
42
43
  const context: LoadContext = {
43
44
  request: req,
44
45
  params: matched.params,
45
46
  url: new URL(req.url),
46
- locals,
47
+ locals,
48
+ fetch: patchedFetch ?? globalThis.fetch
47
49
  };
48
50
 
49
51
  initHead(cspNonce);
@@ -76,8 +76,8 @@ export async function transformRoutes(
76
76
  plugins: ["typescript", "jsx"], // no isTSX
77
77
  },
78
78
  plugins: [
79
- [sigilPlugin, { hash, mode }],
80
- //@ts-expect-error it fills what it needs
79
+ [sigilPlugin, { hash, mode }],
80
+ //@ts-expect-error it fills what it needs
81
81
  rewriteRelativeImports(route.filePath),
82
82
  ],
83
83
  filename: route.filePath,
@@ -3,303 +3,270 @@ import { type Subprocess, spawn } from "bun";
3
3
  import { matchRoute } from "../routing/router";
4
4
  import { type RouteFile, type RouteTree, scanRoutes } from "../routing/scanner";
5
5
  import type {
6
- CoordinatorContext,
7
- GrimoireConfig,
8
- GrimoirePlugin,
9
- WorkerDescriptor,
10
- WorkerMode,
6
+ CoordinatorContext,
7
+ GrimoireConfig,
8
+ GrimoirePlugin,
9
+ WorkerDescriptor,
10
+ WorkerMode,
11
11
  } from "../types";
12
12
  import { buildProject } from "./build";
13
13
  import {
14
- runHook,
15
- runRouteRequest,
16
- runSerializeLocals,
17
- runWorkerSpawn,
14
+ runHook,
15
+ runRouteRequest,
16
+ runSerializeLocals,
17
+ runWorkerSpawn,
18
18
  } from "./plugins";
19
+ import { log } from "../logger/instance";
20
+
21
+ const logger = log.scope("loom")
19
22
 
20
23
  async function waitForWorkers(
21
- workers: LiveWorker[],
22
- timeoutMs = 10_000,
24
+ workers: LiveWorker[],
25
+ timeoutMs = 10_000,
23
26
  ): Promise<void> {
24
- const ready = new Set<number>();
25
- const start = Date.now();
26
-
27
- while (ready.size < workers.length) {
28
- if (Date.now() - start > timeoutMs) {
29
- const pending = workers
30
- .filter((w) => !ready.has(w.descriptor.globalIndex))
31
- .map(
32
- (w) =>
33
- w.descriptor.name ?? `${w.descriptor.mode}-${w.descriptor.index}`,
34
- )
35
- .join(", ");
36
- throw new Error(
37
- `Grimoire: workers timed out waiting to become ready: ${pending}`,
38
- );
39
- }
40
- for (const w of workers) {
41
- if (ready.has(w.descriptor.globalIndex)) continue;
42
- if (w.descriptor.pid != null) {
43
- ready.add(w.descriptor.globalIndex);
44
- }
45
- }
46
- await Bun.sleep(50);
47
- }
27
+ const ready = new Set<number>();
28
+ const start = Date.now();
29
+ while (ready.size < workers.length) {
30
+ if (Date.now() - start > timeoutMs) {
31
+ const pending = workers
32
+ .filter((w) => !ready.has(w.descriptor.globalIndex))
33
+ .map((w) => w.descriptor.name ?? `${w.descriptor.mode}-${w.descriptor.index}`)
34
+ .join(", ");
35
+ throw new Error(`Grimoire: workers timed out: ${pending}`);
36
+ }
37
+ for (const w of workers) {
38
+ if (!ready.has(w.descriptor.globalIndex) && w.descriptor.pid != null) {
39
+ ready.add(w.descriptor.globalIndex);
40
+ }
41
+ }
42
+ await Bun.sleep(50);
43
+ }
48
44
  }
49
45
 
50
46
  function roundRobin(workers: WorkerDescriptor[]): WorkerDescriptor {
51
- const idx = roundRobinCounter++ % workers.length;
52
- return workers[idx];
47
+ const idx = roundRobinCounter++ % workers.length;
48
+ return workers[idx];
53
49
  }
54
50
  let roundRobinCounter = 0;
55
51
 
56
- function consistentHash(
57
- req: Request,
58
- workers: WorkerDescriptor[],
59
- ): WorkerDescriptor {
60
- const key = req.headers.get("cookie") ?? req.url;
61
- let hash = 0;
62
- for (let i = 0; i < key.length; i++) {
63
- hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
64
- }
65
- return workers[hash % workers.length];
52
+ function consistentHash(req: Request, workers: WorkerDescriptor[]): WorkerDescriptor {
53
+ const key = req.headers.get("cookie") ?? req.url;
54
+ let hash = 0;
55
+ for (let i = 0; i < key.length; i++) hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
56
+ return workers[hash % workers.length];
66
57
  }
67
58
 
68
- function defaultRoute(
69
- req: Request,
70
- workers: WorkerDescriptor[],
71
- tree: RouteTree,
72
- ): WorkerDescriptor {
73
- const isWs = req.headers.get("upgrade")?.toLowerCase() === "websocket";
74
-
75
- if (isWs) {
76
- const wsWorkers = workers.filter(
77
- (w) => w.mode === "ws" || w.mode === "full",
78
- );
79
- if (wsWorkers.length) return consistentHash(req, wsWorkers);
80
- }
81
-
82
- const url = new URL(req.url);
83
- const matched = matchRoute(tree, url);
84
-
85
- if (matched?.route.type === "server") {
86
- const apiWorkers = workers.filter(
87
- (w) => w.mode === "api" || w.mode === "full",
88
- );
89
- if (apiWorkers.length) return roundRobin(apiWorkers);
90
- }
91
-
92
- const frontendWorkers = workers.filter(
93
- (w) => w.mode === "frontend" || w.mode === "full",
94
- );
95
- if (frontendWorkers.length) return roundRobin(frontendWorkers);
96
-
97
- // fallback — should not happen if scale spec is valid
98
- return workers[0];
59
+ function defaultRoute(req: Request, workers: WorkerDescriptor[], tree: RouteTree): WorkerDescriptor {
60
+ const isWs = req.headers.get("upgrade")?.toLowerCase() === "websocket";
61
+ if (isWs) {
62
+ const ws = workers.filter((w) => w.mode === "ws" || w.mode === "full");
63
+ if (ws.length) return consistentHash(req, ws);
64
+ }
65
+ const url = new URL(req.url);
66
+ const matched = matchRoute(tree, url);
67
+ if (matched?.route.type === "server") {
68
+ const api = workers.filter((w) => w.mode === "api" || w.mode === "full");
69
+ if (api.length) return roundRobin(api);
70
+ }
71
+ const frontend = workers.filter((w) => w.mode === "frontend" || w.mode === "full");
72
+ if (frontend.length) return roundRobin(frontend);
73
+ return workers[0];
99
74
  }
100
75
 
101
76
  export interface ScaleSpec {
102
- api?: number;
103
- frontend?: number;
104
- ws?: number;
105
- [mode: string]: number | undefined;
77
+ api?: number;
78
+ frontend?: number;
79
+ ws?: number;
80
+ [mode: string]: number | undefined;
106
81
  }
107
82
 
108
83
  export interface CoordinatorOptions {
109
- config: GrimoireConfig;
110
- plugins: GrimoirePlugin[];
111
- scale: ScaleSpec;
112
- noBuild: boolean;
84
+ config: GrimoireConfig;
85
+ plugins: GrimoirePlugin[];
86
+ scale: ScaleSpec;
87
+ noBuild: boolean;
113
88
  }
114
89
 
115
90
  interface LiveWorker {
116
- descriptor: WorkerDescriptor;
117
- process: Subprocess;
118
- port: number;
91
+ descriptor: WorkerDescriptor;
92
+ process: Subprocess;
93
+ port: number;
94
+ env: Record<string, string>;
95
+ intentional: boolean; // ← add
119
96
  }
120
97
 
121
98
  export async function startCoordinator(options: CoordinatorOptions) {
122
- const { config, plugins, scale, noBuild } = options;
123
- const { port = 3000, host = "localhost", routes = "src/routes" } = config;
124
- let stopping = false;
125
-
126
- // 1. build if needed
127
- if (!noBuild) {
128
- const { result } = await buildProject(config, plugins);
129
- if (!result.success) throw new Error("Grimoire: build failed");
130
- }
131
-
132
- // 2. generate ephemeral secret
133
- const secret = crypto.randomUUID() + crypto.randomUUID(); // 72 hex chars
134
-
135
- // 3. scan routes once — coordinator owns the route tree
136
- const routesDir = /* resolve */ routes;
137
- const tree = await scanRoutes(routesDir, process.cwd());
138
-
139
- // 4. derive route slices per mode
140
- const slices: Record<string, RouteFile[]> = {
141
- api: tree.routes.filter((r) => r.type === "server"),
142
- ws: tree.routes.filter((r) => r.type === "server"),
143
- frontend: tree.routes.filter(
144
- (r) => r.type === "page" || r.type === "simple",
145
- ),
146
- full: [...tree.routes],
147
- };
148
-
149
- // 5. spawn workers
150
- const liveWorkers: LiveWorker[] = [];
151
- let globalIndex = 0;
152
- let nextPort = port + 1;
153
-
154
- for (const [mode, count = 0] of Object.entries(scale)) {
155
- for (let i = 0; i < count; i++) {
156
- const workerPort = nextPort++;
157
- const descriptor: WorkerDescriptor = {
158
- mode: mode as WorkerMode,
159
- index: i + 1,
160
- globalIndex: globalIndex++,
161
- internalUrl: `http://127.0.0.1:${workerPort}`,
162
- routes: slices[mode] ?? slices.full,
163
- };
164
-
165
- // let plugins inject env and name
166
- const workerEnv = await runWorkerSpawn(plugins, descriptor);
167
- const workerEntry = join(import.meta.dir, "./worker.ts");
168
- if (workerEnv.name) descriptor.name = workerEnv.name;
169
-
170
- // Inject env vars prefixed with PUBLIC_ so they're available to client bundles
171
- const publicEnvVars: Record<string, string> = {};
172
- for (const [key, value] of Object.entries(process.env)) {
173
- if (key.startsWith("PUBLIC_") && value !== undefined) {
174
- publicEnvVars[key] = value;
175
- }
176
- }
177
-
178
- const proc = spawn(["bun", "run", workerEntry], {
179
- env: {
180
- ...process.env,
181
- ...workerEnv.env,
182
- ...publicEnvVars,
183
- GRIMOIRE_MODE: mode,
184
- GRIMOIRE_WORKER_INDEX: String(i),
185
- GRIMOIRE_WORKER_PORT: String(workerPort),
186
- GRIMOIRE_INTERNAL_SECRET: secret,
187
- GRIMOIRE_WORKER_NAME: descriptor.name ?? `${mode}-${i}`,
188
- },
189
- ipc(message) {
190
- // worker sends { ready: true } when Bun.serve is up
191
- if (message?.ready) {
192
- descriptor.pid = proc.pid;
193
- runHook(plugins, "onWorkerReady", descriptor);
194
- }
195
- },
196
- onExit(_proc, exitCode) {
197
- const intentional = stopping;
198
- runHook(
199
- plugins,
200
- "onWorkerDeath",
201
- descriptor,
202
- intentional ? "intentional" : "crash",
203
- );
204
- if (!intentional) {
205
- console.error(
206
- `[grimoire] worker ${descriptor.name ?? `${descriptor.mode}-${descriptor.index}`} crashed (exit ${exitCode}), respawning...`,
207
- );
208
- // respawn: same descriptor, same port
209
- // (simplified — plugin-scale can override this behavior)
210
- }
211
- },
212
- });
213
-
214
- liveWorkers.push({ descriptor, process: proc, port: workerPort });
215
- }
216
- }
217
-
218
- // 6. wait for all workers ready (with timeout)
219
- // workers send IPC { ready: true } — coordinator waits up to 10s
220
- await waitForWorkers(liveWorkers);
221
-
222
- // 7. fire onCoordinatorStart
223
- const ctx: CoordinatorContext = {
224
- workers: liveWorkers.map((w) => w.descriptor),
225
- port,
226
- secret,
227
- routes: tree,
228
- };
229
- await runHook(plugins, "onCoordinatorStart", ctx);
230
-
231
- // 8. start coordinator Bun.serve
232
-
233
- const server = Bun.serve({
234
- port,
235
- hostname: host,
236
- fetch: async (req) => {
237
- // run hooks.server.ts to populate locals
238
- const locals: App.Locals = {};
239
- // ... same handle() chain as createServer but resolve() just
240
- // forwards instead of rendering
241
-
242
- // pick worker
243
- const descriptors = liveWorkers.map((w) => w.descriptor);
244
- const chosen =
245
- (await runRouteRequest(plugins, req, descriptors, tree)) ??
246
- defaultRoute(req, descriptors, tree);
247
-
248
- // serialize locals
249
- const serialized =
250
- (await runSerializeLocals(plugins, locals)) ??
251
- defaultSerialize(locals, secret);
252
-
253
- // forward
254
- return fetch(chosen.internalUrl + new URL(req.url).pathname, {
255
- method: req.method,
256
- headers: {
257
- //@ts-expect-error shut Up
258
- ...Object.fromEntries(req.headers),
259
- "X-Grimoire-Locals": serialized,
260
- "X-Grimoire-Internal": secret,
261
- },
262
- body: req.body,
263
- });
264
- },
265
- });
266
-
267
- // 9. shutdown
268
- const handleShutdown = async () => {
269
- if (stopping) return;
270
- stopping = true;
271
- await runHook(plugins, "onStop", "shutdown");
272
- for (const w of liveWorkers) w.process.kill();
273
- server.stop();
274
- process.exit(0);
275
- };
276
- process.on("SIGINT", handleShutdown);
277
- process.on("SIGTERM", handleShutdown);
278
-
279
- return {
280
- server,
281
- workers: liveWorkers.map((w) => ({
282
- name: w.descriptor.name,
283
- mode: w.descriptor.mode,
284
- port: w.port,
285
- index: w.descriptor.index,
286
- })),
287
- };
99
+ const { config, plugins, scale, noBuild } = options;
100
+ const { port = 3000, host = "localhost", routes = "src/routes" } = config;
101
+ let stopping = false;
102
+
103
+ const workerEntry = join(import.meta.dir, "./worker.ts");
104
+
105
+ // ── inner helpers (close over plugins/stopping/workerEntry) ──
106
+
107
+ function spawnWorker(lw: LiveWorker): void {
108
+ const proc = spawn(["bun", "run", workerEntry], {
109
+ env: lw.env,
110
+ ipc(message) {
111
+ if (message?.ready) {
112
+ lw.descriptor.pid = proc.pid;
113
+ runHook(plugins, "onWorkerReady", lw.descriptor);
114
+ }
115
+ },
116
+ onExit(_, exitCode) {
117
+ if (lw.intentional) {
118
+ lw.intentional = false;
119
+ return;
120
+ }
121
+ runHook(plugins, "onWorkerDeath", lw.descriptor, stopping ? "intentional" : "crash");
122
+ if (!stopping) {
123
+ logger.error(`worker ${lw.descriptor.name ?? `${lw.descriptor.mode}-${lw.descriptor.index}`} crashed (exit ${exitCode}), respawning...`);
124
+ }
125
+ },
126
+ });
127
+ lw.process = proc;
128
+ }
129
+
130
+ async function respawnWorker(lw: LiveWorker): Promise<void> {
131
+ lw.intentional = true;
132
+ lw.process.kill();
133
+ lw.descriptor.pid = undefined;
134
+ spawnWorker(lw);
135
+ await waitForWorkers([lw]);
136
+ }
137
+
138
+ // 1. build
139
+ if (!noBuild) {
140
+ const { result } = await buildProject(config, plugins);
141
+ if (!result.success) throw new Error("Grimoire: build failed");
142
+ }
143
+
144
+ // 2. ephemeral secret
145
+ const secret = crypto.randomUUID() + crypto.randomUUID();
146
+
147
+ // 3. scan routes
148
+ const tree = await scanRoutes(routes, process.cwd());
149
+
150
+ // 4. route slices
151
+ const slices: Record<string, RouteFile[]> = {
152
+ api: tree.routes.filter((r) => r.type === "server"),
153
+ ws: tree.routes.filter((r) => r.type === "server"),
154
+ frontend: tree.routes.filter((r) => r.type === "page" || r.type === "simple"),
155
+ full: [...tree.routes],
156
+ };
157
+
158
+ // 5. spawn workers
159
+ const liveWorkers: LiveWorker[] = [];
160
+ let globalIndex = 0;
161
+ let nextPort = port + 1;
162
+
163
+ for (const [mode, count = 0] of Object.entries(scale)) {
164
+ for (let i = 0; i < count; i++) {
165
+ const workerPort = nextPort++;
166
+ const descriptor: WorkerDescriptor = {
167
+ mode: mode as WorkerMode,
168
+ index: i + 1,
169
+ globalIndex: globalIndex++,
170
+ internalUrl: `http://127.0.0.1:${workerPort}`,
171
+ routes: slices[mode] ?? slices.full,
172
+ };
173
+
174
+ const publicEnvVars: Record<string, string> = {};
175
+ for (const [k, v] of Object.entries(process.env)) {
176
+ if (k.startsWith("PUBLIC_") && v !== undefined) publicEnvVars[k] = v;
177
+ }
178
+
179
+ const workerEnv = await runWorkerSpawn(plugins, descriptor);
180
+ if (workerEnv.name) descriptor.name = workerEnv.name;
181
+
182
+ const env = {
183
+ ...process.env,
184
+ ...workerEnv.env,
185
+ ...publicEnvVars,
186
+ GRIMOIRE_MODE: mode,
187
+ GRIMOIRE_WORKER_INDEX: String(i),
188
+ GRIMOIRE_WORKER_PORT: String(workerPort),
189
+ GRIMOIRE_INTERNAL_SECRET: secret,
190
+ GRIMOIRE_WORKER_NAME: descriptor.name ?? `${mode}-${i}`,
191
+ } as Record<string, string>;
192
+
193
+ const lw: LiveWorker = { descriptor, process: null as any, port: workerPort, env, intentional: false };
194
+ spawnWorker(lw);
195
+ liveWorkers.push(lw);
196
+ }
197
+ }
198
+
199
+ // 6. wait for ready
200
+ await waitForWorkers(liveWorkers);
201
+
202
+ // 7. onCoordinatorStart
203
+ const ctx: CoordinatorContext = {
204
+ workers: liveWorkers.map((w) => w.descriptor),
205
+ port,
206
+ secret,
207
+ routes: tree,
208
+ };
209
+ await runHook(plugins, "onCoordinatorStart", ctx);
210
+
211
+ // 8. coordinator server
212
+ const server = Bun.serve({
213
+ port,
214
+ hostname: host,
215
+ fetch: async (req) => {
216
+ const locals: App.Locals = {};
217
+ const descriptors = liveWorkers.map((w) => w.descriptor);
218
+ const chosen =
219
+ (await runRouteRequest(plugins, req, descriptors, tree)) ??
220
+ defaultRoute(req, descriptors, tree);
221
+ const serialized =
222
+ (await runSerializeLocals(plugins, locals)) ??
223
+ defaultSerialize(locals, secret);
224
+ return fetch(chosen.internalUrl + new URL(req.url).pathname + new URL(req.url).search, {
225
+ method: req.method,
226
+ headers: {
227
+ //@ts-expect-error shut up
228
+ ...Object.fromEntries(req.headers),
229
+ "X-Grimoire-Locals": serialized,
230
+ "X-Grimoire-Internal": secret,
231
+ },
232
+ body: req.body,
233
+ });
234
+ },
235
+ });
236
+
237
+ // 9. expose stop + restartAll — CLI owns process lifetime
238
+ const stop = async () => {
239
+ if (stopping) return;
240
+ stopping = true;
241
+ await runHook(plugins, "onStop", "shutdown");
242
+ for (const w of liveWorkers) w.process.kill();
243
+ server.stop();
244
+ };
245
+
246
+ const restartAll = () => Promise.all(liveWorkers.map((w) => respawnWorker(w)));
247
+
248
+ return {
249
+ server,
250
+ stop,
251
+ restartAll,
252
+ workers: liveWorkers.map((w) => ({
253
+ name: w.descriptor.name,
254
+ mode: w.descriptor.mode,
255
+ port: w.port,
256
+ index: w.descriptor.index,
257
+ })),
258
+ };
288
259
  }
289
260
 
290
261
  function defaultSerialize(locals: App.Locals, secret: string): string {
291
- const payload = JSON.stringify(locals);
292
- const sig = new Bun.CryptoHasher("sha256")
293
- .update(secret + payload)
294
- .digest("hex");
295
- return Buffer.from(JSON.stringify({ payload, sig })).toString("base64");
262
+ const payload = JSON.stringify(locals);
263
+ const sig = new Bun.CryptoHasher("sha256").update(secret + payload).digest("hex");
264
+ return Buffer.from(JSON.stringify({ payload, sig })).toString("base64");
296
265
  }
297
266
 
298
267
  function _defaultDeserialize(raw: string, secret: string): App.Locals {
299
- const { payload, sig } = JSON.parse(Buffer.from(raw, "base64").toString());
300
- const expected = new Bun.CryptoHasher("sha256")
301
- .update(secret + payload)
302
- .digest("hex");
303
- if (sig !== expected) throw new Error("Grimoire: locals signature invalid");
304
- return JSON.parse(payload);
268
+ const { payload, sig } = JSON.parse(Buffer.from(raw, "base64").toString());
269
+ const expected = new Bun.CryptoHasher("sha256").update(secret + payload).digest("hex");
270
+ if (sig !== expected) throw new Error("Grimoire: locals signature invalid");
271
+ return JSON.parse(payload);
305
272
  }