@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.
- package/.grimoire/_routes.dom.js +8 -0
- package/.grimoire/_routes.hydrate.js +8 -0
- package/.grimoire/tsconfig.generated.json +11 -0
- package/.grimoire/types/ambient.d.ts +59 -0
- package/.grimoire/types/api/hello/$types.d.ts +50 -0
- package/.grimoire/types/api/items/$types.d.ts +50 -0
- package/.grimoire/types/echo/$types.d.ts +50 -0
- package/.grimoire/types/env-private.d.ts +5 -0
- package/.grimoire/types/env-public.d.ts +5 -0
- package/.grimoire/types/mixed/$types.d.ts +50 -0
- package/.grimoire/types/params/[docId]/$types.d.ts +52 -0
- package/.grimoire/types/reject/$types.d.ts +50 -0
- package/index.ts +34 -34
- package/package.json +8 -4
- package/preload.js +2 -0
- package/public/__grimoire__/hydrate.js +585 -0
- package/public/__grimoire__/index.js +490 -0
- package/src/client/head.ts +29 -0
- package/src/client/router.ts +224 -76
- package/src/env/index.ts +25 -0
- package/src/env/plugin.ts +13 -0
- package/src/env/private.ts +5 -0
- package/src/env/public.ts +7 -0
- package/src/env/typegen.ts +51 -0
- package/src/integrations/vite.ts +72 -72
- package/src/rendering/head.ts +22 -2
- package/src/rendering/hydrate.ts +81 -27
- package/src/rendering/index.ts +199 -186
- package/src/rendering/ssrPlugin.ts +53 -47
- package/src/routing/manifest-gen.ts +39 -26
- package/src/routing/router.ts +106 -98
- package/src/routing/scanner.ts +135 -129
- package/src/routing/transform-routes.ts +101 -101
- package/src/server/build.ts +147 -90
- package/src/server/coordinator.ts +306 -297
- package/src/server/hooks.ts +24 -3
- package/src/server/index.ts +144 -70
- package/src/server/worker.ts +59 -59
- package/src/typegen/index.ts +353 -340
- package/src/types.ts +269 -260
- package/test/context.test.ts +52 -52
- package/test/hydration.test.ts +119 -119
- package/test/middleware.test.ts +223 -221
- package/test/rendering.test.ts +425 -425
- package/test/routing.test.ts +83 -45
- package/test/scanning.test.ts +181 -169
- package/test/server.test.ts +229 -229
- package/test/streaming.test.ts +106 -106
- package/test/transform-routes.test.ts +84 -84
- package/test/typegen.test.ts +19 -1
|
@@ -1,297 +1,306 @@
|
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
descriptor
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
await
|
|
222
|
-
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
port,
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
.
|
|
295
|
-
|
|
296
|
-
return JSON.
|
|
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
|
+
// Inject env vars prefixed with PUBLIC_ so they're available to client bundles
|
|
172
|
+
const publicEnvVars: Record<string, string> = {};
|
|
173
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
174
|
+
if (key.startsWith("PUBLIC_") && value !== undefined) {
|
|
175
|
+
publicEnvVars[key] = value;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const proc = spawn(["bun", "run", workerEntry], {
|
|
180
|
+
env: {
|
|
181
|
+
...process.env,
|
|
182
|
+
...workerEnv.env,
|
|
183
|
+
...publicEnvVars,
|
|
184
|
+
GRIMOIRE_MODE: mode,
|
|
185
|
+
GRIMOIRE_WORKER_INDEX: String(i),
|
|
186
|
+
GRIMOIRE_WORKER_PORT: String(workerPort),
|
|
187
|
+
GRIMOIRE_INTERNAL_SECRET: secret,
|
|
188
|
+
GRIMOIRE_WORKER_NAME: descriptor.name ?? `${mode}-${i}`,
|
|
189
|
+
},
|
|
190
|
+
ipc(message) {
|
|
191
|
+
// worker sends { ready: true } when Bun.serve is up
|
|
192
|
+
if (message?.ready) {
|
|
193
|
+
descriptor.pid = proc.pid;
|
|
194
|
+
runHook(plugins, "onWorkerReady", descriptor);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
onExit(proc, exitCode) {
|
|
198
|
+
const intentional = stopping;
|
|
199
|
+
runHook(
|
|
200
|
+
plugins,
|
|
201
|
+
"onWorkerDeath",
|
|
202
|
+
descriptor,
|
|
203
|
+
intentional ? "intentional" : "crash",
|
|
204
|
+
);
|
|
205
|
+
if (!intentional) {
|
|
206
|
+
console.error(
|
|
207
|
+
`[grimoire] worker ${descriptor.name ?? descriptor.mode + "-" + descriptor.index} crashed (exit ${exitCode}), respawning...`,
|
|
208
|
+
);
|
|
209
|
+
// respawn: same descriptor, same port
|
|
210
|
+
// (simplified — plugin-scale can override this behavior)
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
liveWorkers.push({ descriptor, process: proc, port: workerPort });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 6. wait for all workers ready (with timeout)
|
|
220
|
+
// workers send IPC { ready: true } — coordinator waits up to 10s
|
|
221
|
+
await waitForWorkers(liveWorkers);
|
|
222
|
+
|
|
223
|
+
// 7. fire onCoordinatorStart
|
|
224
|
+
const ctx: CoordinatorContext = {
|
|
225
|
+
workers: liveWorkers.map((w) => w.descriptor),
|
|
226
|
+
port,
|
|
227
|
+
secret,
|
|
228
|
+
routes: tree,
|
|
229
|
+
};
|
|
230
|
+
await runHook(plugins, "onCoordinatorStart", ctx);
|
|
231
|
+
|
|
232
|
+
// 8. start coordinator Bun.serve
|
|
233
|
+
|
|
234
|
+
const server = Bun.serve({
|
|
235
|
+
port,
|
|
236
|
+
hostname: host,
|
|
237
|
+
fetch: async (req) => {
|
|
238
|
+
// run hooks.server.ts to populate locals
|
|
239
|
+
const locals: App.Locals = {};
|
|
240
|
+
// ... same handle() chain as createServer but resolve() just
|
|
241
|
+
// forwards instead of rendering
|
|
242
|
+
|
|
243
|
+
// pick worker
|
|
244
|
+
const descriptors = liveWorkers.map((w) => w.descriptor);
|
|
245
|
+
const chosen =
|
|
246
|
+
(await runRouteRequest(plugins, req, descriptors, tree)) ??
|
|
247
|
+
defaultRoute(req, descriptors, tree);
|
|
248
|
+
|
|
249
|
+
// serialize locals
|
|
250
|
+
const serialized =
|
|
251
|
+
(await runSerializeLocals(plugins, locals)) ??
|
|
252
|
+
defaultSerialize(locals, secret);
|
|
253
|
+
|
|
254
|
+
// forward
|
|
255
|
+
return fetch(chosen.internalUrl + new URL(req.url).pathname, {
|
|
256
|
+
method: req.method,
|
|
257
|
+
headers: {
|
|
258
|
+
//@ts-expect-error shut Up
|
|
259
|
+
...Object.fromEntries(req.headers),
|
|
260
|
+
"X-Grimoire-Locals": serialized,
|
|
261
|
+
"X-Grimoire-Internal": secret,
|
|
262
|
+
},
|
|
263
|
+
body: req.body,
|
|
264
|
+
});
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// 9. shutdown
|
|
269
|
+
const handleShutdown = async () => {
|
|
270
|
+
if (stopping) return;
|
|
271
|
+
stopping = true;
|
|
272
|
+
await runHook(plugins, "onStop", "shutdown");
|
|
273
|
+
for (const w of liveWorkers) w.process.kill();
|
|
274
|
+
server.stop();
|
|
275
|
+
process.exit(0);
|
|
276
|
+
};
|
|
277
|
+
process.on("SIGINT", handleShutdown);
|
|
278
|
+
process.on("SIGTERM", handleShutdown);
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
server,
|
|
282
|
+
workers: liveWorkers.map((w) => ({
|
|
283
|
+
name: w.descriptor.name,
|
|
284
|
+
mode: w.descriptor.mode,
|
|
285
|
+
port: w.port,
|
|
286
|
+
index: w.descriptor.index,
|
|
287
|
+
})),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function defaultSerialize(locals: App.Locals, secret: string): string {
|
|
292
|
+
const payload = JSON.stringify(locals);
|
|
293
|
+
const sig = new Bun.CryptoHasher("sha256")
|
|
294
|
+
.update(secret + payload)
|
|
295
|
+
.digest("hex");
|
|
296
|
+
return Buffer.from(JSON.stringify({ payload, sig })).toString("base64");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function defaultDeserialize(raw: string, secret: string): App.Locals {
|
|
300
|
+
const { payload, sig } = JSON.parse(Buffer.from(raw, "base64").toString());
|
|
301
|
+
const expected = new Bun.CryptoHasher("sha256")
|
|
302
|
+
.update(secret + payload)
|
|
303
|
+
.digest("hex");
|
|
304
|
+
if (sig !== expected) throw new Error("Grimoire: locals signature invalid");
|
|
305
|
+
return JSON.parse(payload);
|
|
306
|
+
}
|
package/src/server/hooks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SvelteKit-style server hooks.
|
|
3
3
|
*
|
|
4
|
-
* Create a hooks.
|
|
4
|
+
* Create a hooks.server.ts in your project root:
|
|
5
5
|
*
|
|
6
6
|
* import type { Handle } from "@sigil-dev/grimoire/hooks";
|
|
7
7
|
*
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
* return response;
|
|
13
13
|
* };
|
|
14
14
|
*
|
|
15
|
+
* export const handleError: HandleError = ({ error, status, message }) => {
|
|
16
|
+
* console.error(`[${status}] ${message}`, error);
|
|
17
|
+
* };
|
|
18
|
+
*
|
|
15
19
|
* // optional: runs once at server start
|
|
16
20
|
* export const init = () => {
|
|
17
21
|
* console.log("Server started");
|
|
@@ -32,8 +36,10 @@ export interface RequestEvent {
|
|
|
32
36
|
request: Request;
|
|
33
37
|
url: URL;
|
|
34
38
|
params: Record<string, string>;
|
|
35
|
-
locals:
|
|
39
|
+
locals: App.Locals;
|
|
36
40
|
cookies: Cookies;
|
|
41
|
+
route: { id: string };
|
|
42
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
37
43
|
setHeaders: (headers: Record<string, string>) => void;
|
|
38
44
|
}
|
|
39
45
|
|
|
@@ -60,6 +66,21 @@ export type Handle = (input: {
|
|
|
60
66
|
resolve: ResolveFunction;
|
|
61
67
|
}) => MaybePromise<Response>;
|
|
62
68
|
|
|
69
|
+
export interface HandleErrorInput {
|
|
70
|
+
error: unknown;
|
|
71
|
+
event: RequestEvent;
|
|
72
|
+
status: number;
|
|
73
|
+
message: string;
|
|
74
|
+
}
|
|
75
|
+
export type HandleError = (input: HandleErrorInput) => void | Promise<void>;
|
|
76
|
+
|
|
77
|
+
export interface HandleFetchInput {
|
|
78
|
+
request: Request;
|
|
79
|
+
fetch: typeof globalThis.fetch;
|
|
80
|
+
event: RequestEvent;
|
|
81
|
+
}
|
|
82
|
+
export type HandleFetch = (input: HandleFetchInput) => MaybePromise<Response>;
|
|
83
|
+
|
|
63
84
|
export type InitFunction = () => void | Promise<void>;
|
|
64
85
|
|
|
65
86
|
/**
|
|
@@ -73,7 +94,7 @@ export function sequence(...handlers: Handle[]): Handle {
|
|
|
73
94
|
const next: ResolveFunction = async (evt) => {
|
|
74
95
|
if (i < handlers.length) {
|
|
75
96
|
const handler = handlers[i++];
|
|
76
|
-
return handler({ event: evt, resolve: next });
|
|
97
|
+
return handler!({ event: evt, resolve: next });
|
|
77
98
|
}
|
|
78
99
|
return resolve(evt);
|
|
79
100
|
};
|