@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,28 +1,28 @@
1
- import { isFailResult } from "./fail";
2
- import { isErrorResult } from "./error";
3
- import type { Handle, InitFunction, RequestEvent, ResolveFunction } from "./hooks";
4
- import { isRedirectResult } from "./redirect";
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";
9
+ import { buildProject } from "./build";
5
10
  import { createCookies } from "./cookie-utils";
6
- import { sigil } from "@sigil-dev/compiler/bun";
7
- import { mkdir } from "fs/promises";
8
- import { isAbsolute, join } from "path";
9
- import { generateManifest } from "./manifest-gen";
10
- import { runHook, runRequestHooks } from "./plugins";
11
- import { renderRoute } from "./renderer";
12
- import { findClosestError, matchRoute } from "./router";
13
- import { scanRoutes } from "./scanner";
14
- import { registerSSRPlugin } from "./ssrPlugin";
15
- import { transformRoutes } from "./transform-routes";
16
- import { generateTypes } from "./typegen";
17
- import type { GrimoireConfig } from "./types";
11
+ import type {
12
+ Handle,
13
+ InitFunction,
14
+ RequestEvent,
15
+ ResolveFunction,
16
+ } from "./hooks";
17
+ import { runDeserializeLocals, runHook, runRequestHooks } from "./plugins";
18
18
 
19
19
  /**
20
- * Try to load hooks.server.ts from the project root.
20
+ * Try to load hooks.index.ts from the project root.
21
21
  */
22
22
  async function loadHooks(
23
23
  projectRoot: string,
24
24
  ): Promise<{ handle?: Handle; init?: InitFunction }> {
25
- const hooksPath = join(projectRoot, "hooks.server.ts");
25
+ const hooksPath = `${projectRoot}/hooks.server.ts`;
26
26
  try {
27
27
  const mod = await import(hooksPath);
28
28
  return { handle: mod.handle, init: mod.init };
@@ -32,91 +32,49 @@ async function loadHooks(
32
32
  }
33
33
  }
34
34
 
35
+ interface _WsInternalData {
36
+ params: Record<string, string>;
37
+ // always present at runtime — optional only to satisfy TypeScript;
38
+ // upgrade block gates on mod.websocket before calling server.upgrade()
39
+ __handler?: WsRouteHandler<any>;
40
+ [key: string]: unknown;
41
+ }
42
+
35
43
  export async function createServer(config: GrimoireConfig = {}) {
44
+ // run config hooks
45
+ let finalConfig = config;
46
+ const earlyPlugins = config.plugins ?? [];
47
+ for (const plugin of earlyPlugins) {
48
+ finalConfig = plugin.config?.(finalConfig) ?? finalConfig;
49
+ }
36
50
 
37
- registerSSRPlugin();
51
+ //w timings????
38
52
  const {
39
53
  port = 3000,
40
54
  host = "localhost",
41
55
  plugins = [],
42
56
  routes = "src/routes",
43
- } = config;
44
-
45
- // run config hooks
46
- let finalConfig = config;
47
- for (const plugin of plugins) {
48
- finalConfig = plugin.config?.(finalConfig) ?? finalConfig;
57
+ _skipBuild = false,
58
+ } = finalConfig;
59
+
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());
49
72
  }
50
73
 
51
- // scan routes once at startup
52
- const routesDir = isAbsolute(routes)
53
- ? routes.replace(/\0/g, "")
54
- : join(process.cwd(), routes).replace(/\0/g, "");
55
- const tree = await scanRoutes(routesDir, process.cwd());
56
-
57
- await mkdir(join(process.cwd(), ".grimoire"), { recursive: true });
58
-
59
- await generateTypes(tree, {
60
- projectRoot: process.cwd(),
61
- routesDir,
62
- outDir: join(process.cwd(), ".grimoire/types"),
63
- });
64
-
65
- const compiledDir = join(process.cwd(), ".grimoire/compiled");
66
- await mkdir(compiledDir, { recursive: true });
67
-
68
- // Bun 1.3.13 bug: Bun.build's native parser runs before onLoad hooks fire,
69
- // so .tsx files with TS syntax crash the bundler even with a sigil plugin.
70
- // Fix: pre-transform route files to plain JS before Bun.build sees them,
71
- // then generate per-mode manifests pointing at the compiled .js files.
72
- const pageRoutes = tree.routes.filter(
73
- (r) => r.type === "page" || r.type === "simple",
74
+ // Load hooks.index.ts
75
+ const { handle: hooksHandle, init: hooksInit } = await loadHooks(
76
+ process.cwd(),
74
77
  );
75
- const [hydrateFiles, domFiles] = await Promise.all([
76
- transformRoutes(pageRoutes, compiledDir, "hydrate"),
77
- transformRoutes(pageRoutes, compiledDir, "dom"),
78
- ]);
79
-
80
- const hydrateManifest = join(process.cwd(), ".grimoire/_routes.hydrate.js");
81
- const domManifest = join(process.cwd(), ".grimoire/_routes.dom.js");
82
- await Promise.all([
83
- Bun.write(hydrateManifest, generateManifest(pageRoutes, hydrateFiles)),
84
- Bun.write(domManifest, generateManifest(pageRoutes, domFiles)),
85
- ]);
86
-
87
- const makeRoutesPlugin = (manifestPath: string) => ({
88
- name: "grimoire-routes",
89
- setup(build: any) {
90
- build.onResolve({ filter: /^#grimoire-routes$/ }, () => ({
91
- path: manifestPath,
92
- }));
93
- },
94
- });
95
-
96
- const [hydrateResult, domResult] = await Promise.all([
97
- Bun.build({
98
- entrypoints: [join(import.meta.dir, "./hydrate.ts")],
99
- outdir: join(process.cwd(), "public/__grimoire__"),
100
- plugins: [sigil({ mode: "hydrate" }), makeRoutesPlugin(hydrateManifest)],
101
- }),
102
- Bun.build({
103
- entrypoints: [join(import.meta.dir, "./client.ts")],
104
- outdir: join(process.cwd(), "public/__grimoire__"),
105
- plugins: [sigil({ mode: "dom" }), makeRoutesPlugin(domManifest)],
106
- }),
107
- ]);
108
-
109
- if (!hydrateResult.success) {
110
- for (const log of hydrateResult.logs) console.error(log);
111
- throw new Error("Grimoire: hydrate bundle build failed");
112
- }
113
- if (!domResult.success) {
114
- for (const log of domResult.logs) console.error(log);
115
- throw new Error("Grimoire: dom bundle build failed");
116
- }
117
-
118
- // Load hooks.server.ts
119
- const { handle: hooksHandle, init: hooksInit } = await loadHooks(process.cwd());
120
78
 
121
79
  // Run init hook if present
122
80
  await hooksInit?.();
@@ -125,12 +83,19 @@ export async function createServer(config: GrimoireConfig = {}) {
125
83
  port,
126
84
  hostname: host,
127
85
  fetch: async (req) => {
86
+ // Shared locals object for this request — same reference across all route handlers.
87
+ // hooks.index.ts handle() runs via resolve() for page routes only.
88
+ // For server/WebSocket routes, auth must use cookies/headers in upgrade().
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
+ : {};
94
+
128
95
  return runRequestHooks(plugins, req, async () => {
129
96
  const url = new URL(req.url);
130
97
 
131
- const publicFile = Bun.file(
132
- join(process.cwd(), "public", url.pathname),
133
- );
98
+ const publicFile = Bun.file(`${process.cwd()}/public${url.pathname}`);
134
99
  if (await publicFile.exists()) {
135
100
  return new Response(publicFile);
136
101
  }
@@ -150,14 +115,47 @@ export async function createServer(config: GrimoireConfig = {}) {
150
115
  return new Response("Not Found", { status: 404 });
151
116
  }
152
117
 
153
- // API routes — no hooks
118
+ // API routes (+server.ts)
154
119
  if (matched.route.type === "server") {
155
120
  const mod = await import(matched.route.filePath);
121
+
122
+ // WebSocket upgrade path
123
+ const isWsUpgrade =
124
+ req.headers.get("upgrade")?.toLowerCase() === "websocket";
125
+ if (isWsUpgrade && mod.websocket) {
126
+ let extraData: Record<string, unknown> = {};
127
+ if (mod.upgrade) {
128
+ try {
129
+ const result = await mod.upgrade({
130
+ request: req,
131
+ params: matched.params,
132
+ url,
133
+ locals,
134
+ });
135
+ if (result && typeof result === "object") extraData = result;
136
+ } catch {
137
+ return new Response("Upgrade Required", { status: 426 });
138
+ }
139
+ }
140
+ const wsData: _WsInternalData = {
141
+ params: matched.params,
142
+ __handler: mod.websocket,
143
+ ...extraData,
144
+ };
145
+ //@ts-expect-error i dont know what you are talking about please
146
+ if (server.upgrade(req, { data: wsData })) {
147
+ // Bun sends the 101 response — return undefined to signal no HTTP response
148
+ return undefined as unknown as Response;
149
+ }
150
+ return new Response("Upgrade Required", { status: 426 });
151
+ }
152
+
153
+ // HTTP method dispatch
156
154
  const handler = mod[req.method];
157
155
  if (!handler) {
158
156
  return new Response("Method Not Allowed", { status: 405 });
159
157
  }
160
- return handler({ request: req, params: matched.params, url });
158
+ return handler({ request: req, params: matched.params, url, locals });
161
159
  }
162
160
 
163
161
  const HTTP_METHODS = [
@@ -179,7 +177,7 @@ export async function createServer(config: GrimoireConfig = {}) {
179
177
  request: req,
180
178
  url,
181
179
  params: matched.params,
182
- locals: {},
180
+ locals,
183
181
  cookies,
184
182
  setHeaders: (headers) => Object.assign(setHeadersMap, headers),
185
183
  };
@@ -187,7 +185,10 @@ export async function createServer(config: GrimoireConfig = {}) {
187
185
  // Resolve function: runs the actual page/form action logic
188
186
  const resolve: ResolveFunction = async (evt) => {
189
187
  // form actions
190
- if (matched.pageServer && HTTP_METHODS.includes(evt.request.method as any)) {
188
+ if (
189
+ matched.pageServer &&
190
+ HTTP_METHODS.includes(evt.request.method as any)
191
+ ) {
191
192
  const mod = await import(matched.pageServer.filePath);
192
193
  const handler = mod[evt.request.method];
193
194
  if (handler) {
@@ -207,17 +208,25 @@ export async function createServer(config: GrimoireConfig = {}) {
207
208
  });
208
209
  }
209
210
  if (isErrorResult(e)) {
210
- const isApi = evt.request.headers.get("accept")?.includes("application/json");
211
+ const isApi = evt.request.headers
212
+ .get("accept")
213
+ ?.includes("application/json");
211
214
  if (isApi) {
212
215
  return Response.json(
213
216
  { error: true, status: e.status, message: e.message },
214
217
  { status: e.status },
215
218
  );
216
219
  }
217
- const errorPage = findClosestError(tree.errors, evt.url.pathname);
220
+ const errorPage = findClosestError(
221
+ tree.errors,
222
+ evt.url.pathname,
223
+ );
218
224
  if (errorPage) {
219
225
  const errMod = await import(errorPage.filePath);
220
- const html = errMod.default({ status: e.status, message: e.message });
226
+ const html = errMod.default({
227
+ status: e.status,
228
+ message: e.message,
229
+ });
221
230
  return new Response(html, {
222
231
  status: e.status,
223
232
  headers: { "Content-Type": "text/html" },
@@ -248,7 +257,14 @@ export async function createServer(config: GrimoireConfig = {}) {
248
257
  }
249
258
 
250
259
  // page routes
251
- const response = await renderRoute(matched, evt.request, tree.errors, undefined, evt.locals);
260
+ const response = await renderRoute(
261
+ matched,
262
+ evt.request,
263
+ tree.errors,
264
+ undefined,
265
+ evt.locals,
266
+ plugins,
267
+ );
252
268
 
253
269
  if (evt.request.headers.get("x-grimoire-navigate") === "1") {
254
270
  return response;
@@ -288,7 +304,10 @@ export async function createServer(config: GrimoireConfig = {}) {
288
304
 
289
305
  // Apply setHeaders
290
306
  const setCookieHeaders = cookies.toHeaders();
291
- if (setCookieHeaders.length > 0 || Object.keys(setHeadersMap).length > 0) {
307
+ if (
308
+ setCookieHeaders.length > 0 ||
309
+ Object.keys(setHeadersMap).length > 0
310
+ ) {
292
311
  const headers = new Headers(response.headers);
293
312
  for (const [k, v] of Object.entries(setHeadersMap)) {
294
313
  headers.set(k, v);
@@ -306,8 +325,37 @@ export async function createServer(config: GrimoireConfig = {}) {
306
325
  return response;
307
326
  });
308
327
  },
328
+ websocket: {
329
+ open(ws: ServerWebSocket<_WsInternalData>) {
330
+ ws.data.__handler?.open?.(ws);
331
+ },
332
+ message(ws: ServerWebSocket<_WsInternalData>, data: string | Buffer) {
333
+ ws.data.__handler?.message?.(ws, data);
334
+ },
335
+ close(
336
+ ws: ServerWebSocket<_WsInternalData>,
337
+ code: number,
338
+ reason?: string,
339
+ ) {
340
+ ws.data.__handler?.close?.(ws, code, reason);
341
+ },
342
+ drain(ws: ServerWebSocket<_WsInternalData>) {
343
+ ws.data.__handler?.drain?.(ws);
344
+ },
345
+ },
309
346
  });
310
347
 
348
+ let stopping = false;
349
+ const handleShutdown = async () => {
350
+ if (stopping) return;
351
+ stopping = true;
352
+ await runHook(plugins, "onStop", "shutdown");
353
+ server.stop();
354
+ process.exit(0);
355
+ };
356
+ process.on("SIGINT", handleShutdown);
357
+ process.on("SIGTERM", handleShutdown);
358
+
311
359
  await runHook(plugins, "onStart", {
312
360
  port: server.port,
313
361
  hostname: server.hostname,
@@ -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
+ }
@@ -0,0 +1,59 @@
1
+ import { join } from "node:path";
2
+ import type { GrimoireConfig, GrimoirePlugin, WorkerMode } from "../types";
3
+ import { createServer } from "./index";
4
+ import { runDeserializeLocals } from "./plugins";
5
+
6
+ export interface WorkerOptions {
7
+ config: GrimoireConfig;
8
+ plugins: GrimoirePlugin[];
9
+ secret: string;
10
+ mode: WorkerMode;
11
+ }
12
+
13
+ export async function startWorker(options: WorkerOptions) {
14
+ const { secret, plugins } = options;
15
+
16
+ const workerAuthPlugin: GrimoirePlugin = {
17
+ name: "__grimoire-worker-auth",
18
+ async onRequest(req, next) {
19
+ if (req.headers.get("X-Grimoire-Internal") !== secret) {
20
+ return new Response("Forbidden", { status: 403 });
21
+ }
22
+ return next();
23
+ },
24
+ };
25
+
26
+ return createServer({
27
+ ...options.config,
28
+ plugins: [workerAuthPlugin, ...plugins],
29
+ _skipBuild: true,
30
+ });
31
+ }
32
+
33
+ // bootstrap when spawned by coordinator
34
+ if (process.env.GRIMOIRE_INTERNAL_SECRET) {
35
+ const secret = process.env.GRIMOIRE_INTERNAL_SECRET;
36
+ const port = Number(process.env.GRIMOIRE_WORKER_PORT ?? 3001);
37
+ const cwd = process.cwd();
38
+
39
+ let config: GrimoireConfig = {};
40
+ try {
41
+ const mod = await import(join(cwd, "sigil.config.ts"));
42
+ config = mod.default ?? {};
43
+ } catch {}
44
+
45
+ let finalConfig: GrimoireConfig = { ...config, port, host: "127.0.0.1" };
46
+ const plugins = finalConfig.plugins ?? [];
47
+ for (const plugin of plugins) {
48
+ finalConfig = plugin.config?.(finalConfig) ?? finalConfig;
49
+ }
50
+
51
+ await startWorker({
52
+ config: finalConfig,
53
+ plugins,
54
+ secret,
55
+ mode: (process.env.GRIMOIRE_MODE ?? "full") as WorkerMode,
56
+ });
57
+
58
+ process.send?.({ ready: true });
59
+ }
@@ -1,6 +1,6 @@
1
1
  import { mkdir, rm, writeFile } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, join, relative } from "node:path";
3
- import type { RouteFile, RouteTree } from "./scanner";
3
+ import type { RouteFile, RouteTree } from "../routing/scanner.ts";
4
4
 
5
5
  export interface TypegenConfig {
6
6
  projectRoot: string;
@@ -111,7 +111,10 @@ function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
111
111
  ` : Record<string, never>;`,
112
112
  "",
113
113
  );
114
- lines.push(`export type PageProps = PageData & { params: Params };`, "");
114
+ lines.push(
115
+ `export type PageProps = { data: PageData; params: Params };`,
116
+ "",
117
+ );
115
118
  // PageServerLoad — annotate parameter, NOT return type, to preserve inference
116
119
  lines.push(
117
120
  `/** Annotate the load() parameter only — not the return type — or PageData loses its concrete keys. */`,
@@ -139,7 +142,7 @@ function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
139
142
  } else {
140
143
  lines.push(
141
144
  `export type PageData = Record<string, never>;`,
142
- `export type PageProps = { params: Params };`,
145
+ `export type PageProps = { data: PageData; params: Params };`,
143
146
  `export type PageServerLoad = never;`,
144
147
  `export type Actions = Record<string, never>;`,
145
148
  "",
@@ -161,7 +164,7 @@ function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
161
164
  }
162
165
 
163
166
  lines.push(
164
- `export type LayoutProps = LayoutData & { params: Params; children?: unknown };`,
167
+ `export type LayoutProps = { data: LayoutData; params: Params; children?: unknown };`,
165
168
  `export type LayoutServerLoad = (ctx: {`,
166
169
  ` params: Params;`,
167
170
  ` request: Request;`,
@@ -186,6 +189,27 @@ function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
186
189
  ` locals: App.Locals;`,
187
190
  ` }) => Response | Promise<Response>;`,
188
191
  `};`,
192
+ ``,
193
+ `export type UpgradeContext = {`,
194
+ ` request: Request;`,
195
+ ` params: Params;`,
196
+ ` url: URL;`,
197
+ ` locals: App.Locals;`,
198
+ `};`,
199
+ ``,
200
+ `type _UpgradeReturn = "upgrade" extends keyof _SRV`,
201
+ ` ? _SRV["upgrade"] extends (...args: any[]) => any`,
202
+ ` ? Awaited<ReturnType<_SRV["upgrade"]>>`,
203
+ ` : {}`,
204
+ ` : {};`,
205
+ `export type WsData = { params: Params } & Omit<_UpgradeReturn, "__handler">;`,
206
+ ``,
207
+ `export type WebSocketHandler = {`,
208
+ ` open?(ws: ServerWebSocket<WsData>): void | Promise<void>;`,
209
+ ` message?(ws: ServerWebSocket<WsData>, data: string | Buffer): void | Promise<void>;`,
210
+ ` close?(ws: ServerWebSocket<WsData>, code: number, reason?: string): void | Promise<void>;`,
211
+ ` drain?(ws: ServerWebSocket<WsData>): void | Promise<void>;`,
212
+ `};`,
189
213
  "",
190
214
  );
191
215
  }
@@ -210,6 +234,59 @@ function generateAmbient(): string {
210
234
  " interface Locals extends Record<string, unknown> {}",
211
235
  "}",
212
236
  "",
237
+ "// Available in all route files without import.",
238
+ "// Params default to Record<string,string>; for per-route typed params import from './$types'.",
239
+ "// Note: using these globals widens return types — PageData loses concrete keys.",
240
+ "// For precise data inference annotate load() with PageServerLoad from './$types'.",
241
+ "",
242
+ "type PageServerLoad<",
243
+ " P extends Record<string, string> = Record<string, string>",
244
+ "> = (ctx: {",
245
+ " params: P;",
246
+ " request: Request;",
247
+ " url: URL;",
248
+ " locals: App.Locals;",
249
+ "}) => Record<string, unknown> | Promise<Record<string, unknown>>;",
250
+ "",
251
+ "type LayoutServerLoad<",
252
+ " P extends Record<string, string> = Record<string, string>",
253
+ "> = (ctx: {",
254
+ " params: P;",
255
+ " request: Request;",
256
+ " url: URL;",
257
+ " locals: App.Locals;",
258
+ "}) => Record<string, unknown> | Promise<Record<string, unknown>>;",
259
+ "",
260
+ "type RequestHandler<",
261
+ " P extends Record<string, string> = Record<string, string>",
262
+ "> = (ctx: {",
263
+ " params: P;",
264
+ " request: Request;",
265
+ " url: URL;",
266
+ " locals: App.Locals;",
267
+ "}) => Response | Promise<Response>;",
268
+ "",
269
+ "type PageProps<",
270
+ " D extends Record<string, unknown> = Record<string, unknown>,",
271
+ " P extends Record<string, string> = Record<string, string>",
272
+ "> = { data: D; params: P };",
273
+ "",
274
+ "type LayoutProps<",
275
+ " D extends Record<string, unknown> = Record<string, unknown>,",
276
+ " P extends Record<string, string> = Record<string, string>",
277
+ "> = { data: D; params: P; children?: unknown };",
278
+ "",
279
+ "type ErrorProps = { status: number; message: string };",
280
+ "",
281
+ "type UpgradeContext<",
282
+ " P extends Record<string, string> = Record<string, string>",
283
+ "> = {",
284
+ " params: P;",
285
+ " request: Request;",
286
+ " url: URL;",
287
+ " locals: App.Locals;",
288
+ "};",
289
+ "",
213
290
  ].join("\n");
214
291
  }
215
292