@sigil-dev/grimoire 0.5.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.
- package/README.md +174 -174
- package/index.ts +34 -16
- package/package.json +6 -6
- package/src/{enhance.ts → client/enhance.ts} +2 -1
- package/src/client/index.ts +5 -0
- package/src/{client-router.ts → client/router.ts} +1 -1
- package/src/{vite-plugin.ts → integrations/vite.ts} +4 -4
- package/src/{hydrate.ts → rendering/hydrate.ts} +2 -2
- package/src/{renderer.ts → rendering/index.ts} +29 -16
- package/src/{ssrPlugin.ts → rendering/ssrPlugin.ts} +3 -2
- package/src/{scanner.ts → routing/scanner.ts} +16 -4
- package/src/{transform-routes.ts → routing/transform-routes.ts} +3 -2
- package/src/{fail.ts → sentinels/fail.ts} +1 -1
- package/src/{build.ts → server/build.ts} +12 -10
- package/src/server/coordinator.ts +297 -0
- package/src/{hooks.ts → server/hooks.ts} +1 -1
- package/src/{server.ts → server/index.ts} +85 -37
- package/src/server/plugins.ts +119 -0
- package/src/server/worker.ts +59 -0
- package/src/{typegen.ts → typegen/index.ts} +5 -2
- package/src/types.ts +134 -3
- package/test/context.test.ts +1 -1
- package/test/fail.test.ts +1 -1
- package/test/headers.test.ts +6 -2
- package/test/hydration.test.ts +1 -1
- package/test/middleware.test.ts +8 -4
- package/test/preload.ts +1 -1
- package/test/redirect-error.test.ts +2 -2
- package/test/rendering.test.ts +15 -6
- package/test/routing.test.ts +2 -2
- package/test/scanning.test.ts +40 -11
- package/test/scope.test.ts +25 -10
- package/test/server.test.ts +39 -10
- package/test/streaming.test.ts +21 -8
- package/test/transform-routes.test.ts +2 -2
- package/test/typegen.test.ts +5 -3
- package/tsconfig.json +3 -1
- package/.grimoire/_routes.ts +0 -4
- package/public/__grimoire__/client.js +0 -55
- package/public/__grimoire__/hydrate.js +0 -63
- package/src/client.ts +0 -4
- package/src/plugins.ts +0 -39
- package/src/sync.ts +0 -18
- /package/src/{scope.ts → client/scope.ts} +0 -0
- /package/src/{head.ts → rendering/head.ts} +0 -0
- /package/src/{manifest-gen.ts → routing/manifest-gen.ts} +0 -0
- /package/src/{router.ts → routing/router.ts} +0 -0
- /package/src/{error.ts → sentinels/error.ts} +0 -0
- /package/src/{redirect.ts → sentinels/redirect.ts} +0 -0
- /package/src/{context.ts → server/context.ts} +0 -0
- /package/src/{cookie-utils.ts → server/cookie-utils.ts} +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
|
+
import { sigil } from "@sigil-dev/compiler/bun";
|
|
1
2
|
import { mkdir } from "fs/promises";
|
|
2
3
|
import { isAbsolute, join } from "path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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";
|
|
5
10
|
import { runHook } from "./plugins";
|
|
6
|
-
import { scanRoutes } from "./scanner";
|
|
7
|
-
import type { RouteTree } from "./scanner";
|
|
8
|
-
import { transformRoutes } from "./transform-routes";
|
|
9
|
-
import { generateTypes } from "./typegen";
|
|
10
|
-
import type { BuildResult, GrimoireConfig, GrimoirePlugin } from "./types";
|
|
11
11
|
|
|
12
12
|
export async function buildProject(
|
|
13
13
|
config: GrimoireConfig,
|
|
@@ -59,12 +59,12 @@ export async function buildProject(
|
|
|
59
59
|
|
|
60
60
|
const [hydrateResult, domResult] = await Promise.all([
|
|
61
61
|
Bun.build({
|
|
62
|
-
entrypoints: [join(import.meta.dir, "
|
|
62
|
+
entrypoints: [join(import.meta.dir, "../rendering/hydrate.ts")],
|
|
63
63
|
outdir: join(process.cwd(), "public/__grimoire__"),
|
|
64
64
|
plugins: [sigil({ mode: "hydrate" }), makeRoutesPlugin(hydrateManifest)],
|
|
65
65
|
}),
|
|
66
66
|
Bun.build({
|
|
67
|
-
entrypoints: [join(import.meta.dir, "
|
|
67
|
+
entrypoints: [join(import.meta.dir, "../client/index.ts")],
|
|
68
68
|
outdir: join(process.cwd(), "public/__grimoire__"),
|
|
69
69
|
plugins: [sigil({ mode: "dom" }), makeRoutesPlugin(domManifest)],
|
|
70
70
|
}),
|
|
@@ -79,7 +79,9 @@ export async function buildProject(
|
|
|
79
79
|
|
|
80
80
|
const result: BuildResult = {
|
|
81
81
|
success: hydrateResult.success && domResult.success,
|
|
82
|
-
outputs: [...hydrateResult.outputs, ...domResult.outputs].map(
|
|
82
|
+
outputs: [...hydrateResult.outputs, ...domResult.outputs].map(
|
|
83
|
+
(o) => o.path,
|
|
84
|
+
),
|
|
83
85
|
errors: [...hydrateResult.logs, ...domResult.logs].map(String),
|
|
84
86
|
};
|
|
85
87
|
|
|
@@ -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,17 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import { renderRoute } from "../rendering";
|
|
3
|
+
import { registerSSRPlugin } from "../rendering/ssrPlugin";
|
|
4
|
+
import { findClosestError, matchRoute } from "../routing/router";
|
|
5
|
+
import { isErrorResult } from "../sentinels/error.ts";
|
|
6
|
+
import { isFailResult } from "../sentinels/fail.ts";
|
|
7
|
+
import { isRedirectResult } from "../sentinels/redirect.ts";
|
|
8
|
+
import type { GrimoireConfig, WsRouteHandler } from "../types";
|
|
6
9
|
import { buildProject } from "./build";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import { createCookies } from "./cookie-utils";
|
|
11
|
+
import type {
|
|
12
|
+
Handle,
|
|
13
|
+
InitFunction,
|
|
14
|
+
RequestEvent,
|
|
15
|
+
ResolveFunction,
|
|
16
|
+
} from "./hooks";
|
|
17
|
+
import { runDeserializeLocals, runHook, runRequestHooks } from "./plugins";
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
|
-
* Try to load hooks.
|
|
20
|
+
* Try to load hooks.index.ts from the project root.
|
|
15
21
|
*/
|
|
16
22
|
async function loadHooks(
|
|
17
23
|
projectRoot: string,
|
|
@@ -35,27 +41,40 @@ interface _WsInternalData {
|
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
export async function createServer(config: GrimoireConfig = {}) {
|
|
38
|
-
const {
|
|
39
|
-
port = 3000,
|
|
40
|
-
host = "localhost",
|
|
41
|
-
plugins = [],
|
|
42
|
-
} = config;
|
|
43
|
-
|
|
44
44
|
// run config hooks
|
|
45
45
|
let finalConfig = config;
|
|
46
|
-
|
|
46
|
+
const earlyPlugins = config.plugins ?? [];
|
|
47
|
+
for (const plugin of earlyPlugins) {
|
|
47
48
|
finalConfig = plugin.config?.(finalConfig) ?? finalConfig;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
//w timings????
|
|
52
|
+
const {
|
|
53
|
+
port = 3000,
|
|
54
|
+
host = "localhost",
|
|
55
|
+
plugins = [],
|
|
56
|
+
routes = "src/routes",
|
|
57
|
+
_skipBuild = false,
|
|
58
|
+
} = finalConfig;
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
registerSSRPlugin(plugins);
|
|
61
|
+
let tree: any;
|
|
62
|
+
if (!config._skipBuild) {
|
|
63
|
+
const { result, tree: _tree } = await buildProject(finalConfig, plugins);
|
|
64
|
+
if (!result.success) throw new Error("Grimoire: build failed");
|
|
65
|
+
tree = _tree;
|
|
66
|
+
} else {
|
|
67
|
+
// worker — build already done by loom, just scan routes
|
|
68
|
+
const { isAbsolute, join } = await import("node:path");
|
|
69
|
+
const { scanRoutes } = await import("../routing/scanner");
|
|
70
|
+
const routesDir = isAbsolute(routes) ? routes : join(process.cwd(), routes);
|
|
71
|
+
tree = await scanRoutes(routesDir, process.cwd());
|
|
55
72
|
}
|
|
56
73
|
|
|
57
|
-
// Load hooks.
|
|
58
|
-
const { handle: hooksHandle, init: hooksInit } = await loadHooks(
|
|
74
|
+
// Load hooks.index.ts
|
|
75
|
+
const { handle: hooksHandle, init: hooksInit } = await loadHooks(
|
|
76
|
+
process.cwd(),
|
|
77
|
+
);
|
|
59
78
|
|
|
60
79
|
// Run init hook if present
|
|
61
80
|
await hooksInit?.();
|
|
@@ -65,16 +84,18 @@ export async function createServer(config: GrimoireConfig = {}) {
|
|
|
65
84
|
hostname: host,
|
|
66
85
|
fetch: async (req) => {
|
|
67
86
|
// Shared locals object for this request — same reference across all route handlers.
|
|
68
|
-
// hooks.
|
|
87
|
+
// hooks.index.ts handle() runs via resolve() for page routes only.
|
|
69
88
|
// For server/WebSocket routes, auth must use cookies/headers in upgrade().
|
|
70
|
-
|
|
89
|
+
// If this request came from a coordinator, deserialize its locals
|
|
90
|
+
const rawLocals = req.headers.get("X-Grimoire-Locals");
|
|
91
|
+
const locals: App.Locals = rawLocals
|
|
92
|
+
? ((await runDeserializeLocals(plugins, rawLocals)) ?? {})
|
|
93
|
+
: {};
|
|
71
94
|
|
|
72
95
|
return runRequestHooks(plugins, req, async () => {
|
|
73
96
|
const url = new URL(req.url);
|
|
74
97
|
|
|
75
|
-
const publicFile = Bun.file(
|
|
76
|
-
`${process.cwd()}/public${url.pathname}`,
|
|
77
|
-
);
|
|
98
|
+
const publicFile = Bun.file(`${process.cwd()}/public${url.pathname}`);
|
|
78
99
|
if (await publicFile.exists()) {
|
|
79
100
|
return new Response(publicFile);
|
|
80
101
|
}
|
|
@@ -99,7 +120,8 @@ export async function createServer(config: GrimoireConfig = {}) {
|
|
|
99
120
|
const mod = await import(matched.route.filePath);
|
|
100
121
|
|
|
101
122
|
// WebSocket upgrade path
|
|
102
|
-
const isWsUpgrade =
|
|
123
|
+
const isWsUpgrade =
|
|
124
|
+
req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
103
125
|
if (isWsUpgrade && mod.websocket) {
|
|
104
126
|
let extraData: Record<string, unknown> = {};
|
|
105
127
|
if (mod.upgrade) {
|
|
@@ -120,6 +142,7 @@ export async function createServer(config: GrimoireConfig = {}) {
|
|
|
120
142
|
__handler: mod.websocket,
|
|
121
143
|
...extraData,
|
|
122
144
|
};
|
|
145
|
+
//@ts-expect-error i dont know what you are talking about please
|
|
123
146
|
if (server.upgrade(req, { data: wsData })) {
|
|
124
147
|
// Bun sends the 101 response — return undefined to signal no HTTP response
|
|
125
148
|
return undefined as unknown as Response;
|
|
@@ -162,7 +185,10 @@ export async function createServer(config: GrimoireConfig = {}) {
|
|
|
162
185
|
// Resolve function: runs the actual page/form action logic
|
|
163
186
|
const resolve: ResolveFunction = async (evt) => {
|
|
164
187
|
// form actions
|
|
165
|
-
if (
|
|
188
|
+
if (
|
|
189
|
+
matched.pageServer &&
|
|
190
|
+
HTTP_METHODS.includes(evt.request.method as any)
|
|
191
|
+
) {
|
|
166
192
|
const mod = await import(matched.pageServer.filePath);
|
|
167
193
|
const handler = mod[evt.request.method];
|
|
168
194
|
if (handler) {
|
|
@@ -182,17 +208,25 @@ export async function createServer(config: GrimoireConfig = {}) {
|
|
|
182
208
|
});
|
|
183
209
|
}
|
|
184
210
|
if (isErrorResult(e)) {
|
|
185
|
-
const isApi = evt.request.headers
|
|
211
|
+
const isApi = evt.request.headers
|
|
212
|
+
.get("accept")
|
|
213
|
+
?.includes("application/json");
|
|
186
214
|
if (isApi) {
|
|
187
215
|
return Response.json(
|
|
188
216
|
{ error: true, status: e.status, message: e.message },
|
|
189
217
|
{ status: e.status },
|
|
190
218
|
);
|
|
191
219
|
}
|
|
192
|
-
const errorPage = findClosestError(
|
|
220
|
+
const errorPage = findClosestError(
|
|
221
|
+
tree.errors,
|
|
222
|
+
evt.url.pathname,
|
|
223
|
+
);
|
|
193
224
|
if (errorPage) {
|
|
194
225
|
const errMod = await import(errorPage.filePath);
|
|
195
|
-
const html = errMod.default({
|
|
226
|
+
const html = errMod.default({
|
|
227
|
+
status: e.status,
|
|
228
|
+
message: e.message,
|
|
229
|
+
});
|
|
196
230
|
return new Response(html, {
|
|
197
231
|
status: e.status,
|
|
198
232
|
headers: { "Content-Type": "text/html" },
|
|
@@ -223,7 +257,14 @@ export async function createServer(config: GrimoireConfig = {}) {
|
|
|
223
257
|
}
|
|
224
258
|
|
|
225
259
|
// page routes
|
|
226
|
-
const response = await renderRoute(
|
|
260
|
+
const response = await renderRoute(
|
|
261
|
+
matched,
|
|
262
|
+
evt.request,
|
|
263
|
+
tree.errors,
|
|
264
|
+
undefined,
|
|
265
|
+
evt.locals,
|
|
266
|
+
plugins,
|
|
267
|
+
);
|
|
227
268
|
|
|
228
269
|
if (evt.request.headers.get("x-grimoire-navigate") === "1") {
|
|
229
270
|
return response;
|
|
@@ -263,7 +304,10 @@ export async function createServer(config: GrimoireConfig = {}) {
|
|
|
263
304
|
|
|
264
305
|
// Apply setHeaders
|
|
265
306
|
const setCookieHeaders = cookies.toHeaders();
|
|
266
|
-
if (
|
|
307
|
+
if (
|
|
308
|
+
setCookieHeaders.length > 0 ||
|
|
309
|
+
Object.keys(setHeadersMap).length > 0
|
|
310
|
+
) {
|
|
267
311
|
const headers = new Headers(response.headers);
|
|
268
312
|
for (const [k, v] of Object.entries(setHeadersMap)) {
|
|
269
313
|
headers.set(k, v);
|
|
@@ -288,7 +332,11 @@ export async function createServer(config: GrimoireConfig = {}) {
|
|
|
288
332
|
message(ws: ServerWebSocket<_WsInternalData>, data: string | Buffer) {
|
|
289
333
|
ws.data.__handler?.message?.(ws, data);
|
|
290
334
|
},
|
|
291
|
-
close(
|
|
335
|
+
close(
|
|
336
|
+
ws: ServerWebSocket<_WsInternalData>,
|
|
337
|
+
code: number,
|
|
338
|
+
reason?: string,
|
|
339
|
+
) {
|
|
292
340
|
ws.data.__handler?.close?.(ws, code, reason);
|
|
293
341
|
},
|
|
294
342
|
drain(ws: ServerWebSocket<_WsInternalData>) {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { RouteTree } from "../routing/scanner";
|
|
2
|
+
import type {
|
|
3
|
+
BuildResult,
|
|
4
|
+
CoordinatorContext,
|
|
5
|
+
GrimoirePlugin,
|
|
6
|
+
LoadContext,
|
|
7
|
+
Route,
|
|
8
|
+
Server,
|
|
9
|
+
WorkerDescriptor,
|
|
10
|
+
WorkerEnv,
|
|
11
|
+
} from "../types";
|
|
12
|
+
|
|
13
|
+
// Fire-and-forget hooks routed through runHook.
|
|
14
|
+
// onRequest and onRouteRender are intentionally excluded: they have distinct
|
|
15
|
+
// calling conventions (middleware chain / inline transform loop) handled in index.ts.
|
|
16
|
+
// Keep HookArgs in sync with GrimoirePlugin in types.ts.
|
|
17
|
+
type HookArgs = {
|
|
18
|
+
onStart: [server: Server];
|
|
19
|
+
onStop: [reason: "shutdown" | "restart"];
|
|
20
|
+
onBuildStart: [];
|
|
21
|
+
onBuildEnd: [result: BuildResult];
|
|
22
|
+
onRouteLoad: [route: Route, context: LoadContext];
|
|
23
|
+
onCoordinatorStart: [ctx: CoordinatorContext];
|
|
24
|
+
onWorkerReady: [worker: WorkerDescriptor];
|
|
25
|
+
onWorkerDeath: [worker: WorkerDescriptor, reason: "crash" | "intentional"];
|
|
26
|
+
};
|
|
27
|
+
type FireAndForgetHook = keyof HookArgs;
|
|
28
|
+
|
|
29
|
+
export async function runHook<K extends FireAndForgetHook>(
|
|
30
|
+
plugins: GrimoirePlugin[],
|
|
31
|
+
hook: K,
|
|
32
|
+
...args: HookArgs[K]
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
for (const plugin of plugins) {
|
|
35
|
+
const fn = plugin[hook] as ((...a: any[]) => any) | undefined;
|
|
36
|
+
await fn?.(...args);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runRequestHooks(
|
|
41
|
+
plugins: GrimoirePlugin[],
|
|
42
|
+
req: Request,
|
|
43
|
+
final: () => Promise<Response>,
|
|
44
|
+
): Promise<Response> {
|
|
45
|
+
const chain = plugins
|
|
46
|
+
.filter((p) => p.onRequest)
|
|
47
|
+
.reduceRight(
|
|
48
|
+
(next, plugin) => async () => plugin.onRequest!(req, next),
|
|
49
|
+
final,
|
|
50
|
+
);
|
|
51
|
+
return chain();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Calls onWorkerSpawn on ALL plugins and merges results.
|
|
56
|
+
* env is combined (later plugins override earlier on conflict).
|
|
57
|
+
* name is last-write-wins.
|
|
58
|
+
*/
|
|
59
|
+
export async function runWorkerSpawn(
|
|
60
|
+
plugins: GrimoirePlugin[],
|
|
61
|
+
worker: WorkerDescriptor,
|
|
62
|
+
): Promise<WorkerEnv> {
|
|
63
|
+
const merged: WorkerEnv = { env: {} };
|
|
64
|
+
for (const plugin of plugins) {
|
|
65
|
+
const result = await plugin.onWorkerSpawn?.(worker);
|
|
66
|
+
//@ts-expect-error WTF TS? its result? meaning its not gonna do anything if null.
|
|
67
|
+
if (result?.env) Object.assign(merged.env!, result.env);
|
|
68
|
+
//@ts-expect-error WTF TS? its result? meaning its not gonna do anything if null.
|
|
69
|
+
if (result?.name) merged.name = result.name;
|
|
70
|
+
}
|
|
71
|
+
return merged;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* First plugin to return a non-null string wins.
|
|
76
|
+
* Returns null if no plugin handles it — coordinator uses default.
|
|
77
|
+
*/
|
|
78
|
+
export async function runSerializeLocals(
|
|
79
|
+
plugins: GrimoirePlugin[],
|
|
80
|
+
locals: App.Locals,
|
|
81
|
+
): Promise<string | null> {
|
|
82
|
+
for (const plugin of plugins) {
|
|
83
|
+
const result = await plugin.serializeLocals?.(locals);
|
|
84
|
+
if (result != null) return result;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* First plugin to return non-null wins.
|
|
91
|
+
* Returns null if no plugin handles it — worker uses default.
|
|
92
|
+
*/
|
|
93
|
+
export async function runDeserializeLocals(
|
|
94
|
+
plugins: GrimoirePlugin[],
|
|
95
|
+
raw: string,
|
|
96
|
+
): Promise<App.Locals | null> {
|
|
97
|
+
for (const plugin of plugins) {
|
|
98
|
+
const result = await plugin.deserializeLocals?.(raw);
|
|
99
|
+
if (result != null) return result;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* First plugin to return a WorkerDescriptor wins.
|
|
106
|
+
* Returns null if no plugin handles it — coordinator uses default routing.
|
|
107
|
+
*/
|
|
108
|
+
export async function runRouteRequest(
|
|
109
|
+
plugins: GrimoirePlugin[],
|
|
110
|
+
req: Request,
|
|
111
|
+
workers: WorkerDescriptor[],
|
|
112
|
+
routes: RouteTree,
|
|
113
|
+
): Promise<WorkerDescriptor | null> {
|
|
114
|
+
for (const plugin of plugins) {
|
|
115
|
+
const result = await plugin.routeRequest?.(req, workers, routes);
|
|
116
|
+
if (result != null) return result;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|