@sigil-dev/grimoire 0.8.5 → 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.
@@ -17,12 +17,12 @@ import type { GrimoireConfig, WsRouteHandler } from "../types";
17
17
  import { buildProject } from "./build";
18
18
  import { createCookies } from "./cookie-utils";
19
19
  import type {
20
- Handle,
21
- HandleError,
22
- HandleFetch,
23
- InitFunction,
24
- RequestEvent,
25
- ResolveFunction,
20
+ Handle,
21
+ HandleError,
22
+ HandleFetch,
23
+ InitFunction,
24
+ RequestEvent,
25
+ ResolveFunction,
26
26
  } from "./hooks";
27
27
  import { runDeserializeLocals, runHook, runRequestHooks } from "./plugins";
28
28
 
@@ -31,621 +31,627 @@ const logger = log.scope("server");
31
31
  * Try to load hooks.server.ts from the project root.
32
32
  */
33
33
  async function loadHooks(projectRoot: string): Promise<{
34
- handle?: Handle;
35
- init?: InitFunction;
36
- handleError?: HandleError;
37
- handleFetch?: HandleFetch;
34
+ handle?: Handle;
35
+ init?: InitFunction;
36
+ handleError?: HandleError;
37
+ handleFetch?: HandleFetch;
38
38
  }> {
39
- const hooksPath = `${projectRoot}/hooks.server.ts`;
40
- try {
41
- const mod = await import(hooksPath);
42
- return {
43
- handle: mod.handle,
44
- init: mod.init,
45
- handleError: mod.handleError,
46
- handleFetch: mod.handleFetch,
47
- };
48
- } catch {
49
- // no hooks file — that's fine
50
- return {};
51
- }
39
+ const hooksPath = `${projectRoot}/hooks.server.ts`;
40
+ try {
41
+ const mod = await import(hooksPath);
42
+ return {
43
+ handle: mod.handle,
44
+ init: mod.init,
45
+ handleError: mod.handleError,
46
+ handleFetch: mod.handleFetch,
47
+ };
48
+ } catch {
49
+ // no hooks file — that's fine
50
+ return {};
51
+ }
52
52
  }
53
53
 
54
54
  interface _WsInternalData {
55
- params: Record<string, string>;
56
- // always present at runtime — optional only to satisfy TypeScript;
57
- // upgrade block gates on mod.websocket before calling server.upgrade()
58
- __handler?: WsRouteHandler<any>;
59
- [key: string]: unknown;
55
+ params: Record<string, string>;
56
+ // always present at runtime — optional only to satisfy TypeScript;
57
+ // upgrade block gates on mod.websocket before calling server.upgrade()
58
+ __handler?: WsRouteHandler<any>;
59
+ [key: string]: unknown;
60
60
  }
61
61
 
62
62
  export async function createServer(config: GrimoireConfig = {}) {
63
- // run config hooks
64
- let finalConfig = config;
65
- const earlyPlugins = config.plugins ?? [];
66
- for (const plugin of earlyPlugins) {
67
- finalConfig = plugin.config?.(finalConfig) ?? finalConfig;
68
- }
69
-
70
- const {
71
- port = 3000,
72
- host = "localhost",
73
- plugins = [],
74
- dev = false,
75
- routes = "src/routes",
76
- cspNonce,
77
- //biome-ignore lint: not implemented yet
78
- devEditor = "code",
79
- _skipBuild = false,
80
- } = finalConfig;
81
-
82
- let tree: any;
83
- if (!config._skipBuild) {
84
- const { result, tree: _tree } = await buildProject(finalConfig, plugins);
85
- if (!result.success) throw new Error("Grimoire: build failed");
86
- tree = _tree;
87
- } else {
88
- // worker — build already done by loom, just scan routes
89
- const routesDir = isAbsolute(routes) ? routes : join(process.cwd(), routes);
90
- const { scanRoutes } = await import("../routing/scanner");
91
- tree = await scanRoutes(routesDir, process.cwd());
92
- }
93
-
94
- // SSR plugin should NOT INTNERCEPT FILES BUNDLEDFOR CLIENT
95
- // WHAT ARE WE DOINGGGGGGGGG
96
- registerSSRPlugin(plugins);
97
-
98
- const graph = new DevGraph();
99
- const hmr = dev ? new HmrServer() : null;
100
-
101
- if (dev) {
102
- const { ensureRuntimeBundle } = await import("../dev/runtime-bundle.ts");
103
- await ensureRuntimeBundle(process.cwd());
104
-
105
- // clean dev-modules cache from previous session
106
- const { rmSync, mkdirSync } = await import("node:fs");
107
- const devModulesDir = join(process.cwd(), ".grimoire/dev-modules");
108
- try {
109
- rmSync(devModulesDir, { recursive: true, force: true });
110
- } catch {}
111
- mkdirSync(devModulesDir, { recursive: true });
112
-
113
- // register route files and scan their imports for the graph
114
- const srcDir = isAbsolute(routes) ? routes : join(process.cwd(), routes);
115
- const allFiles = [
116
- ...tree.routes,
117
- ...tree.layouts,
118
- ...tree.servers,
119
- ...tree.errors,
120
- ];
121
-
122
- await Promise.all(
123
- allFiles.map(async (rf) => {
124
- graph.getOrCreate(rf.filePath, rf.filePath);
125
- graph.markRoute(rf.filePath, rf);
126
- try {
127
- const source = await Bun.file(rf.filePath).text();
128
- const imports = new Bun.Transpiler({ loader: "tsx" }).scanImports(
129
- source,
130
- );
131
- for (const imp of imports) {
132
- if (imp.path.startsWith(".") || imp.path.startsWith("$lib")) {
133
- const resolved = imp.path.startsWith("$lib")
134
- ? join(process.cwd(), "src/lib", imp.path.slice(5))
135
- : join(dirname(rf.filePath), imp.path);
136
- graph.addEdge(rf.filePath, resolved);
137
- // also register lib files as graph nodes
138
- graph.getOrCreate(resolved, resolved);
139
- }
140
- }
141
- } catch {}
142
- }),
143
- );
144
-
145
- startWatcher(srcDir, (f) =>
146
- handleChange(f, graph, hmr!, srcDir, process.cwd(), tree),
147
- );
148
- }
149
-
150
- const loader = dev
151
- ? makeDevLoader(graph, process.cwd())
152
- : (p: string) => import(p);
153
- // Load hooks.server.ts
154
- const {
155
- handle: hooksHandle,
156
- init: hooksInit,
157
- handleError: hooksHandleError,
158
- handleFetch: hooksHandleFetch,
159
- } = await loadHooks(process.cwd());
160
-
161
- // Run init hook if present
162
- await hooksInit?.();
163
-
164
- // Register alias plugin so dynamically-imported +server.ts routes can resolve custom aliases
165
- const aliases = finalConfig.alias ?? {};
166
- if (Object.keys(aliases).length > 0) {
167
- bunPlugin({
168
- name: "grimoire-alias",
169
- setup(build) {
170
- for (const [prefix, target] of Object.entries(aliases)) {
171
- const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
172
- build.onResolve(
173
- { filter: new RegExp(`^${escaped}(/|$)`) },
174
- (args) => ({
175
- path: args.path.replace(prefix, join(process.cwd(), target)),
176
- }),
177
- );
178
- }
179
- },
180
- });
181
- }
182
- const server = Bun.serve({
183
- port,
184
- hostname: host,
185
- fetch: async (req) => {
186
- const rawLocals = req.headers.get("X-Grimoire-Locals");
187
- const locals: App.Locals = rawLocals
188
- ? ((await runDeserializeLocals(plugins, rawLocals)) ?? {})
189
- : {};
190
- const url = new URL(req.url);
191
-
192
- if (dev && url.pathname === "/__grimoire__/hmr") {
193
- //@ts-expect-error holy shut up
194
- if (server.upgrade(req, { data: { __hmrClient: true } })) {
195
- return undefined as unknown as Response;
196
- }
197
- return new Response("Upgrade Required", { status: 426 });
198
- }
199
- if (dev && url.pathname === "/__grimoire__/hmr-client.js") {
200
- return new Response(HMR_CLIENT_SOURCE, {
201
- headers: { "Content-Type": "application/javascript" },
202
- });
203
- }
204
- if (dev && url.pathname === "/__grimoire__/open") {
205
- const file = url.searchParams.get("file") ?? "";
206
- const line = url.searchParams.get("line") ?? "1";
207
- const col = url.searchParams.get("col") ?? "1";
208
- const editor = finalConfig.devEditor ?? "code";
209
- //@ts-expect-error Bun spawn has werird types just shut up
210
- Bun.spawn([editor, "--goto", `${file}:${line}:${col}`]);
211
- return new Response(null, { status: 204 });
212
- }
213
- if (dev && url.pathname.startsWith("/__grimoire__/dep/")) {
214
- const depName = decodeURIComponent(
215
- url.pathname.slice("/__grimoire__/dep/".length).replace(/\.js$/, ""),
216
- );
217
-
218
- // grimoire shim
219
- if (depName.includes("@sigil-dev__grimoire")) {
220
- return Response.redirect("/__grimoire__/grimoire-client.js", 302);
221
- }
222
-
223
- // convert URL-safe name back to package name
224
- // @codex__shared → @codex/shared
225
- const pkgName = depName.startsWith("@")
226
- ? depName.replace("__", "/")
227
- : depName;
228
-
229
- const depsDir = join(process.cwd(), ".grimoire/deps");
230
- mkdirSync(depsDir, { recursive: true });
231
- const outFile = join(depsDir, `${depName}.js`);
232
-
233
- if (!(await Bun.file(outFile).exists())) {
234
- logger.debug("bundling dep:", pkgName);
235
- try {
236
- const entry = Bun.resolveSync(pkgName, process.cwd());
237
- const result = await Bun.build({
238
- entrypoints: [entry],
239
- outdir: depsDir,
240
- naming: `${depName}.js`,
241
- target: "browser",
242
- format: "esm",
243
- minify: false,
244
- external: ["@sigil-dev/runtime"],
245
- });
246
- if (!result.success) {
247
- logger.error("dep bundle failed:", pkgName, result.logs);
248
- return new Response(
249
- `throw new Error("failed to bundle dep: ${pkgName}")`,
250
- { headers: { "Content-Type": "application/javascript" } },
251
- );
252
- }
253
- logger.debug("bundled dep:", pkgName);
254
- } catch (e: any) {
255
- return new Response(
256
- `throw new Error("dep not found: ${pkgName}: ${e.message}")`,
257
- { headers: { "Content-Type": "application/javascript" } },
258
- );
259
- }
260
- }
261
-
262
- return new Response(Bun.file(outFile), {
263
- headers: {
264
- "Content-Type": "application/javascript",
265
- "Cache-Control": "no-store",
266
- },
267
- });
268
- }
269
-
270
- if (dev && url.pathname.startsWith("/__grimoire__/m/")) {
271
- const clientPath = decodeURIComponent(
272
- url.pathname.slice("/__grimoire__/m/".length),
273
- );
274
- const v = url.searchParams.get("v");
275
-
276
- if (v) {
277
- // versioned request — serve from dev-modules cache
278
- // find the cache file for this version
279
- const { createHash } = await import("node:crypto");
280
- const absPath = join(process.cwd(), clientPath.replace(/\.js$/, ""));
281
- const fileHash = createHash("md5")
282
- .update(absPath)
283
- .digest("hex")
284
- .slice(0, 8);
285
- const cachePath = join(
286
- process.cwd(),
287
- `.grimoire/dev-modules/${fileHash}_v${v}.js`,
288
- );
289
- const file = Bun.file(cachePath);
290
- if (await file.exists()) {
291
- return new Response(file, {
292
- headers: {
293
- "Content-Type": "application/javascript",
294
- "Cache-Control": "no-store",
295
- },
296
- });
297
- }
298
- }
299
-
300
- // unversioned or cache miss — compile on demand
301
- const absPath = join(process.cwd(), clientPath.replace(/\.js$/, ""));
302
- try {
303
- const { compileForBrowser } = await import("../dev/compile-module");
304
- const result = await compileForBrowser(absPath, process.cwd());
305
- return new Response(result.js, {
306
- headers: {
307
- "Content-Type": "application/javascript",
308
- "Cache-Control": "no-store",
309
- },
310
- });
311
- } catch (e: any) {
312
- return new Response(`throw new Error(${JSON.stringify(e.message)})`, {
313
- headers: { "Content-Type": "application/javascript" },
314
- });
315
- }
316
- }
317
-
318
- return runRequestHooks(plugins, req, async () => {
319
- const publicFile = Bun.file(`${process.cwd()}/public${url.pathname}`);
320
- if (await publicFile.exists()) {
321
- return new Response(publicFile);
322
- }
323
-
324
- const HTTP_METHODS = [
325
- "GET",
326
- "POST",
327
- "PUT",
328
- "PATCH",
329
- "DELETE",
330
- "HEAD",
331
- "OPTIONS",
332
- ] as const;
333
-
334
- // Build event BEFORE matching — handle() runs pre-match like SvelteKit
335
- const cookieHeader = req.headers.get("cookie") ?? "";
336
- const cookies = createCookies(cookieHeader);
337
- const setHeadersMap: Record<string, string> = {};
338
-
339
- const event: RequestEvent = {
340
- request: req,
341
- url,
342
- params: {},
343
- locals,
344
- cookies,
345
- route: { id: "" },
346
- fetch: globalThis.fetch,
347
- setHeaders: (headers) => Object.assign(setHeadersMap, headers),
348
- };
349
-
350
- if (hooksHandleFetch) {
351
- event.fetch = (reqInfo: RequestInfo | URL, init?: RequestInit) => {
352
- const r = new Request(reqInfo, init);
353
- return Promise.resolve(
354
- hooksHandleFetch({ request: r, fetch: globalThis.fetch, event }),
355
- );
356
- };
357
- }
358
-
359
- const resolve: ResolveFunction = async (evt) => {
360
- // matching happens here — after handle() has run
361
- const matched = matchRoute(tree, evt.url);
362
-
363
- if (!matched) {
364
- const error = findClosestError(tree.errors, evt.url.pathname);
365
- if (error) {
366
- const mod = await loader(error.filePath);
367
- const html = mod.default({ status: 404, message: "Not Found" });
368
- return new Response(html, {
369
- status: 404,
370
- headers: { "Content-Type": "text/html" },
371
- });
372
- }
373
- return new Response("Not Found", { status: 404 });
374
- }
375
-
376
- // patch event with match results so handle() epilogue can read them
377
- evt.params = matched.params;
378
- evt.route = { id: matched.route.path };
379
-
380
- // API routes (+server.ts)
381
- if (matched.route.type === "server") {
382
- const mod = await loader(matched.route.filePath);
383
-
384
- const isWsUpgrade =
385
- evt.request.headers.get("upgrade")?.toLowerCase() === "websocket";
386
- if (isWsUpgrade && mod.websocket) {
387
- let extraData: Record<string, unknown> = {};
388
- if (mod.upgrade) {
389
- try {
390
- const result = await mod.upgrade({
391
- request: evt.request,
392
- params: matched.params,
393
- url: evt.url,
394
- locals: evt.locals,
395
- });
396
- if (result && typeof result === "object") extraData = result;
397
- } catch {
398
- return new Response("Upgrade Required", { status: 426 });
399
- }
400
- }
401
- const wsData: _WsInternalData = {
402
- params: matched.params,
403
- __handler: mod.websocket,
404
- ...extraData,
405
- };
406
- //@ts-expect-error i dont know what you are talking about please
407
- if (server.upgrade(req, { data: wsData })) {
408
- return undefined as unknown as Response;
409
- }
410
- return new Response("Upgrade Required", { status: 426 });
411
- }
412
-
413
- const handler = mod[evt.request.method];
414
- if (!handler) {
415
- return new Response("Method Not Allowed", { status: 405 });
416
- }
417
- return handler({
418
- request: evt.request,
419
- params: matched.params,
420
- url: evt.url,
421
- locals: evt.locals,
422
- });
423
- }
424
-
425
- // form actions (+page.server.ts)
426
- if (
427
- matched.pageServer &&
428
- HTTP_METHODS.includes(evt.request.method as any)
429
- ) {
430
- const mod = await loader(matched.pageServer.filePath);
431
-
432
- if (
433
- mod.csrf !== false &&
434
- evt.request.method !== "GET" &&
435
- evt.request.method !== "HEAD"
436
- ) {
437
- const cookieToken = cookies.get("_csrf");
438
- const formData = await evt.request
439
- .clone()
440
- .formData()
441
- .catch(() => null);
442
- const bodyToken = formData?.get("_csrf");
443
- if (!cookieToken || cookieToken !== String(bodyToken)) {
444
- return new Response("Forbidden", { status: 403 });
445
- }
446
- }
447
-
448
- const handler = mod[evt.request.method];
449
- if (handler) {
450
- let result: any;
451
- try {
452
- result = await handler({
453
- request: evt.request,
454
- params: matched.params,
455
- url: evt.url,
456
- locals: evt.locals,
457
- });
458
- } catch (e) {
459
- if (isRedirectResult(e)) {
460
- return new Response(null, {
461
- status: e.status,
462
- headers: { Location: e.location },
463
- });
464
- }
465
- if (isErrorResult(e)) {
466
- const isApi = evt.request.headers
467
- .get("accept")
468
- ?.includes("application/json");
469
- if (isApi) {
470
- return Response.json(
471
- { error: true, status: e.status, message: e.message },
472
- { status: e.status },
473
- );
474
- }
475
- const errorPage = findClosestError(
476
- tree.errors,
477
- evt.url.pathname,
478
- );
479
- if (errorPage) {
480
- const errMod = await loader(errorPage.filePath);
481
- const html = errMod.default({
482
- status: e.status,
483
- message: e.message,
484
- });
485
- return new Response(html, {
486
- status: e.status,
487
- headers: { "Content-Type": "text/html" },
488
- });
489
- }
490
- return new Response(e.message, { status: e.status });
491
- }
492
- throw e;
493
- }
494
-
495
- if (isFailResult(result)) {
496
- return Response.json(
497
- { fail: true, status: result.status, data: result.data },
498
- { status: result.status },
499
- );
500
- }
501
-
502
- const redirectTo =
503
- result?.redirect ?? evt.request.headers.get("referer") ?? "/";
504
- return new Response(null, {
505
- status: 303,
506
- headers: { Location: redirectTo },
507
- });
508
- }
509
- if (evt.request.method !== "GET") {
510
- return new Response("Method Not Allowed", { status: 405 });
511
- }
512
- }
513
-
514
- // page routes
515
- const response = await renderRoute(
516
- matched,
517
- evt.request,
518
- tree.errors,
519
- loader,
520
- evt.locals,
521
- plugins,
522
- cspNonce,
523
- dev,
524
- );
525
-
526
- if (evt.request.headers.get("x-grimoire-navigate") === "1") {
527
- return response;
528
- }
529
-
530
- const hasRenderPlugins = plugins.some((p) => p.onRouteRender);
531
- if (hasRenderPlugins) {
532
- let html = await response.text();
533
- for (const plugin of plugins) {
534
- html =
535
- (await plugin.onRouteRender?.(html, {
536
- route: matched.route,
537
- request: evt.request,
538
- params: matched.params,
539
- data: undefined,
540
- })) ?? html;
541
- }
542
- return new Response(html, {
543
- headers: { "Content-Type": "text/html" },
544
- });
545
- }
546
-
547
- return response;
548
- };
549
-
550
- let response: Response;
551
- try {
552
- if (!hooksHandle) {
553
- response = await resolve(event);
554
- } else {
555
- response = await hooksHandle({ event, resolve });
556
- }
557
- } catch (err) {
558
- console.error(
559
- `[grimoire] ${event.request.method} ${event.url.pathname} failed:`,
560
- err,
561
- );
562
- await hooksHandleError?.({
563
- error: err,
564
- event,
565
- status: 500,
566
- message: "Internal Server Error",
567
- });
568
- const errorHtmlPath = `${process.cwd()}/error.html`;
569
- try {
570
- const errorFile = Bun.file(errorHtmlPath);
571
- if (await errorFile.exists()) {
572
- return new Response(errorFile, {
573
- status: 500,
574
- headers: { "Content-Type": "text/html" },
575
- });
576
- }
577
- } catch {}
578
- const message =
579
- process.env.NODE_ENV === "production"
580
- ? "Internal Server Error"
581
- : `${err instanceof Error ? `${err.message}\n${err.stack}` : String(err)}`;
582
- return new Response(message, { status: 500 });
583
- }
584
-
585
- const setCookieHeaders = cookies.toHeaders();
586
- if (
587
- setCookieHeaders.length > 0 ||
588
- Object.keys(setHeadersMap).length > 0
589
- ) {
590
- const headers = new Headers(response.headers);
591
- for (const [k, v] of Object.entries(setHeadersMap)) {
592
- headers.set(k, v);
593
- }
594
- for (const sc of setCookieHeaders) {
595
- headers.append("Set-Cookie", sc);
596
- }
597
- response = new Response(response.body, {
598
- status: response.status,
599
- statusText: response.statusText,
600
- headers,
601
- });
602
- }
603
-
604
- return response;
605
- });
606
- },
607
- websocket: {
608
- open(ws: ServerWebSocket<any>) {
609
- if (ws.data.__hmrClient) {
610
- hmr?.addClient(ws);
611
- return;
612
- }
613
- (ws.data as _WsInternalData).__handler?.open?.(ws);
614
- },
615
- message(ws: ServerWebSocket<any>, data: string | Buffer) {
616
- if (ws.data.__hmrClient) return;
617
- (ws.data as _WsInternalData).__handler?.message?.(ws, data);
618
- },
619
- close(ws: ServerWebSocket<any>, code: number, reason?: string) {
620
- if (ws.data.__hmrClient) {
621
- hmr?.removeClient(ws);
622
- return;
623
- }
624
- (ws.data as _WsInternalData).__handler?.close?.(ws, code, reason);
625
- },
626
- drain(ws: ServerWebSocket<any>) {
627
- if (ws.data.__hmrClient) return;
628
- (ws.data as _WsInternalData).__handler?.drain?.(ws);
629
- },
630
- },
631
- });
632
-
633
- let stopping = false;
634
- const handleShutdown = async () => {
635
- if (stopping) return;
636
- stopping = true;
637
- await runHook(plugins, "onStop", "shutdown");
638
- server.stop();
639
- process.exit(0);
640
- };
641
- process.on("SIGINT", handleShutdown);
642
- process.on("SIGTERM", handleShutdown);
643
-
644
- await runHook(plugins, "onStart", {
645
- port: server.port,
646
- hostname: server.hostname,
647
- stop: () => server.stop(),
648
- });
649
- // console.log(`Grimoire running at http://${host}:${port}`);
650
- return server;
63
+ // run config hooks
64
+ let finalConfig = config;
65
+ const earlyPlugins = config.plugins ?? [];
66
+ for (const plugin of earlyPlugins) {
67
+ finalConfig = plugin.config?.(finalConfig) ?? finalConfig;
68
+ }
69
+
70
+ const {
71
+ port = 3000,
72
+ host = "localhost",
73
+ plugins = [],
74
+ dev = false,
75
+ routes = "src/routes",
76
+ cspNonce,
77
+ //biome-ignore lint: not implemented yet
78
+ devEditor = "code",
79
+ _skipBuild = false,
80
+ } = finalConfig;
81
+
82
+ let tree: any;
83
+ if (!config._skipBuild) {
84
+ const { result, tree: _tree } = await buildProject(finalConfig, plugins);
85
+ if (!result.success) throw new Error("Grimoire: build failed");
86
+ tree = _tree;
87
+ } else {
88
+ // worker — build already done by loom, just scan routes
89
+ const routesDir = isAbsolute(routes) ? routes : join(process.cwd(), routes);
90
+ const { scanRoutes } = await import("../routing/scanner");
91
+ tree = await scanRoutes(routesDir, process.cwd());
92
+ }
93
+
94
+ // SSR plugin should NOT INTNERCEPT FILES BUNDLEDFOR CLIENT
95
+ // WHAT ARE WE DOINGGGGGGGGG
96
+ registerSSRPlugin(plugins);
97
+
98
+ const graph = new DevGraph();
99
+ const hmr = dev ? new HmrServer() : null;
100
+
101
+ if (dev) {
102
+ const { ensureRuntimeBundle } = await import("../dev/runtime-bundle.ts");
103
+ await ensureRuntimeBundle(process.cwd());
104
+
105
+ // clean dev-modules cache from previous session
106
+ const { rmSync, mkdirSync } = await import("node:fs");
107
+ const devModulesDir = join(process.cwd(), ".grimoire/dev-modules");
108
+ try {
109
+ rmSync(devModulesDir, { recursive: true, force: true });
110
+ } catch { }
111
+ mkdirSync(devModulesDir, { recursive: true });
112
+
113
+ // register route files and scan their imports for the graph
114
+ const srcDir = isAbsolute(routes) ? routes : join(process.cwd(), routes);
115
+ const allFiles = [
116
+ ...tree.routes,
117
+ ...tree.layouts,
118
+ ...tree.servers,
119
+ ...tree.errors,
120
+ ];
121
+
122
+ await Promise.all(
123
+ allFiles.map(async (rf) => {
124
+ graph.getOrCreate(rf.filePath, rf.filePath);
125
+ graph.markRoute(rf.filePath, rf);
126
+ try {
127
+ const source = await Bun.file(rf.filePath).text();
128
+ const imports = new Bun.Transpiler({ loader: "tsx" }).scanImports(
129
+ source,
130
+ );
131
+ for (const imp of imports) {
132
+ if (imp.path.startsWith(".") || imp.path.startsWith("$lib")) {
133
+ const resolved = imp.path.startsWith("$lib")
134
+ ? join(process.cwd(), "src/lib", imp.path.slice(5))
135
+ : join(dirname(rf.filePath), imp.path);
136
+ graph.addEdge(rf.filePath, resolved);
137
+ // also register lib files as graph nodes
138
+ graph.getOrCreate(resolved, resolved);
139
+ }
140
+ }
141
+ } catch { }
142
+ }),
143
+ );
144
+
145
+ startWatcher(srcDir, (f) =>
146
+ handleChange(f, graph, hmr!, srcDir, process.cwd(), tree),
147
+ );
148
+ }
149
+
150
+ const loader = dev
151
+ ? makeDevLoader(graph, process.cwd())
152
+ : (p: string) => import(p);
153
+ // Load hooks.server.ts
154
+ const {
155
+ handle: hooksHandle,
156
+ init: hooksInit,
157
+ handleError: hooksHandleError,
158
+ handleFetch: hooksHandleFetch,
159
+ } = await loadHooks(process.cwd());
160
+
161
+ // Run init hook if present
162
+ await hooksInit?.();
163
+
164
+ // Register alias plugin so dynamically-imported +server.ts routes can resolve custom aliases
165
+ const aliases = finalConfig.alias ?? {};
166
+ if (Object.keys(aliases).length > 0) {
167
+ bunPlugin({
168
+ name: "grimoire-alias",
169
+ setup(build) {
170
+ for (const [prefix, target] of Object.entries(aliases)) {
171
+ const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
172
+ build.onResolve(
173
+ { filter: new RegExp(`^${escaped}(/|$)`) },
174
+ (args) => ({
175
+ path: args.path.replace(prefix, join(process.cwd(), target)),
176
+ }),
177
+ );
178
+ }
179
+ },
180
+ });
181
+ }
182
+ const server = Bun.serve({
183
+ port,
184
+ hostname: host,
185
+ fetch: async (req) => {
186
+ const rawLocals = req.headers.get("X-Grimoire-Locals");
187
+ const locals: App.Locals = rawLocals
188
+ ? ((await runDeserializeLocals(plugins, rawLocals)) ?? {})
189
+ : {};
190
+ const url = new URL(req.url);
191
+
192
+ if (dev && url.pathname === "/__grimoire__/hmr") {
193
+ //@ts-expect-error holy shut up
194
+ if (server.upgrade(req, { data: { __hmrClient: true } })) {
195
+ return undefined as unknown as Response;
196
+ }
197
+ return new Response("Upgrade Required", { status: 426 });
198
+ }
199
+ if (dev && url.pathname === "/__grimoire__/hmr-client.js") {
200
+ return new Response(HMR_CLIENT_SOURCE, {
201
+ headers: { "Content-Type": "application/javascript" },
202
+ });
203
+ }
204
+ if (dev && url.pathname === "/__grimoire__/open") {
205
+ const file = url.searchParams.get("file") ?? "";
206
+ const line = url.searchParams.get("line") ?? "1";
207
+ const col = url.searchParams.get("col") ?? "1";
208
+ const editor = finalConfig.devEditor ?? "code";
209
+ //@ts-expect-error Bun spawn has werird types just shut up
210
+ Bun.spawn([editor, "--goto", `${file}:${line}:${col}`]);
211
+ return new Response(null, { status: 204 });
212
+ }
213
+ if (dev && url.pathname.startsWith("/__grimoire__/dep/")) {
214
+ const depName = decodeURIComponent(
215
+ url.pathname.slice("/__grimoire__/dep/".length).replace(/\.js$/, ""),
216
+ );
217
+
218
+ // grimoire shim
219
+ if (depName.includes("@sigil-dev__grimoire")) {
220
+ return Response.redirect("/__grimoire__/grimoire-client.js", 302);
221
+ }
222
+
223
+ // convert URL-safe name back to package name
224
+ // @codex__shared → @codex/shared
225
+ const pkgName = depName.startsWith("@")
226
+ ? depName.replace("__", "/")
227
+ : depName;
228
+
229
+ const depsDir = join(process.cwd(), ".grimoire/deps");
230
+ mkdirSync(depsDir, { recursive: true });
231
+ const outFile = join(depsDir, `${depName}.js`);
232
+
233
+ if (!(await Bun.file(outFile).exists())) {
234
+ logger.debug("bundling dep:", pkgName);
235
+ try {
236
+ const entry = Bun.resolveSync(pkgName, process.cwd());
237
+ const result = await Bun.build({
238
+ entrypoints: [entry],
239
+ outdir: depsDir,
240
+ naming: `${depName}.js`,
241
+ target: "browser",
242
+ format: "esm",
243
+ minify: false,
244
+ external: ["@sigil-dev/runtime"],
245
+ });
246
+ if (!result.success) {
247
+ logger.error("dep bundle failed:", pkgName, result.logs);
248
+ return new Response(
249
+ `throw new Error("failed to bundle dep: ${pkgName}")`,
250
+ { headers: { "Content-Type": "application/javascript" } },
251
+ );
252
+ }
253
+ logger.debug("bundled dep:", pkgName);
254
+ } catch (e: any) {
255
+ return new Response(
256
+ `throw new Error("dep not found: ${pkgName}: ${e.message}")`,
257
+ { headers: { "Content-Type": "application/javascript" } },
258
+ );
259
+ }
260
+ }
261
+
262
+ return new Response(Bun.file(outFile), {
263
+ headers: {
264
+ "Content-Type": "application/javascript",
265
+ "Cache-Control": "no-store",
266
+ },
267
+ });
268
+ }
269
+
270
+ if (dev && url.pathname.startsWith("/__grimoire__/m/")) {
271
+ const clientPath = decodeURIComponent(
272
+ url.pathname.slice("/__grimoire__/m/".length),
273
+ );
274
+ const v = url.searchParams.get("v");
275
+
276
+ if (v) {
277
+ // versioned request — serve from dev-modules cache
278
+ // find the cache file for this version
279
+ const { createHash } = await import("node:crypto");
280
+ const absPath = join(process.cwd(), clientPath.replace(/\.js$/, ""));
281
+ const fileHash = createHash("md5")
282
+ .update(absPath)
283
+ .digest("hex")
284
+ .slice(0, 8);
285
+ const cachePath = join(
286
+ process.cwd(),
287
+ `.grimoire/dev-modules/${fileHash}_v${v}.js`,
288
+ );
289
+ const file = Bun.file(cachePath);
290
+ if (await file.exists()) {
291
+ return new Response(file, {
292
+ headers: {
293
+ "Content-Type": "application/javascript",
294
+ "Cache-Control": "no-store",
295
+ },
296
+ });
297
+ }
298
+ }
299
+
300
+ // unversioned or cache miss — compile on demand
301
+ const absPath = join(process.cwd(), clientPath.replace(/\.js$/, ""));
302
+ try {
303
+ const { compileForBrowser } = await import("../dev/compile-module");
304
+ const result = await compileForBrowser(absPath, process.cwd());
305
+ return new Response(result.js, {
306
+ headers: {
307
+ "Content-Type": "application/javascript",
308
+ "Cache-Control": "no-store",
309
+ },
310
+ });
311
+ } catch (e: any) {
312
+ return new Response(`throw new Error(${JSON.stringify(e.message)})`, {
313
+ headers: { "Content-Type": "application/javascript" },
314
+ });
315
+ }
316
+ }
317
+
318
+ return runRequestHooks(plugins, req, async () => {
319
+ const publicFile = Bun.file(`${process.cwd()}/public${url.pathname}`);
320
+ if (await publicFile.exists()) {
321
+ return new Response(publicFile);
322
+ }
323
+
324
+ const HTTP_METHODS = [
325
+ "GET",
326
+ "POST",
327
+ "PUT",
328
+ "PATCH",
329
+ "DELETE",
330
+ "HEAD",
331
+ "OPTIONS",
332
+ ] as const;
333
+
334
+ // Build event BEFORE matching — handle() runs pre-match like SvelteKit
335
+ const cookieHeader = req.headers.get("cookie") ?? "";
336
+ const cookies = createCookies(cookieHeader);
337
+ const setHeadersMap: Record<string, string> = {};
338
+
339
+ const event: RequestEvent = {
340
+ request: req,
341
+ url,
342
+ params: {},
343
+ locals,
344
+ cookies,
345
+ route: { id: "" },
346
+ fetch: globalThis.fetch,
347
+ setHeaders: (headers) => Object.assign(setHeadersMap, headers),
348
+ };
349
+
350
+ if (hooksHandleFetch) {
351
+ event.fetch = (reqInfo: RequestInfo | URL, init?: RequestInit) => {
352
+ const r = new Request(reqInfo, init);
353
+ return Promise.resolve(
354
+ hooksHandleFetch({ request: r, fetch: globalThis.fetch, event }),
355
+ );
356
+ };
357
+ }
358
+ const patchedFetch: typeof fetch = (input, init) => {
359
+ const resolved = typeof input === "string" && input.startsWith("/")
360
+ ? new URL(input, url.origin).toString()
361
+ : input;
362
+ return globalThis.fetch(resolved as any, init);
363
+ };
364
+ const resolve: ResolveFunction = async (evt) => {
365
+ // matching happens here — after handle() has run
366
+ const matched = matchRoute(tree, evt.url);
367
+
368
+ if (!matched) {
369
+ const error = findClosestError(tree.errors, evt.url.pathname);
370
+ if (error) {
371
+ const mod = await loader(error.filePath);
372
+ const html = mod.default({ status: 404, message: "Not Found" });
373
+ return new Response(html, {
374
+ status: 404,
375
+ headers: { "Content-Type": "text/html" },
376
+ });
377
+ }
378
+ return new Response("Not Found", { status: 404 });
379
+ }
380
+
381
+ // patch event with match results so handle() epilogue can read them
382
+ evt.params = matched.params;
383
+ evt.route = { id: matched.route.path };
384
+
385
+ // API routes (+server.ts)
386
+ if (matched.route.type === "server") {
387
+ const mod = await loader(matched.route.filePath);
388
+
389
+ const isWsUpgrade =
390
+ evt.request.headers.get("upgrade")?.toLowerCase() === "websocket";
391
+ if (isWsUpgrade && mod.websocket) {
392
+ let extraData: Record<string, unknown> = {};
393
+ if (mod.upgrade) {
394
+ try {
395
+ const result = await mod.upgrade({
396
+ request: evt.request,
397
+ params: matched.params,
398
+ url: evt.url,
399
+ locals: evt.locals,
400
+ });
401
+ if (result && typeof result === "object") extraData = result;
402
+ } catch {
403
+ return new Response("Upgrade Required", { status: 426 });
404
+ }
405
+ }
406
+ const wsData: _WsInternalData = {
407
+ params: matched.params,
408
+ __handler: mod.websocket,
409
+ ...extraData,
410
+ };
411
+ //@ts-expect-error i dont know what you are talking about please
412
+ if (server.upgrade(req, { data: wsData })) {
413
+ return undefined as unknown as Response;
414
+ }
415
+ return new Response("Upgrade Required", { status: 426 });
416
+ }
417
+
418
+ const handler = mod[evt.request.method];
419
+ if (!handler) {
420
+ return new Response("Method Not Allowed", { status: 405 });
421
+ }
422
+ return handler({
423
+ request: evt.request,
424
+ params: matched.params,
425
+ url: evt.url,
426
+ locals: evt.locals,
427
+ });
428
+ }
429
+
430
+ // form actions (+page.server.ts)
431
+ if (
432
+ matched.pageServer &&
433
+ HTTP_METHODS.includes(evt.request.method as any)
434
+ ) {
435
+ const mod = await loader(matched.pageServer.filePath);
436
+
437
+ if (
438
+ mod.csrf !== false &&
439
+ evt.request.method !== "GET" &&
440
+ evt.request.method !== "HEAD"
441
+ ) {
442
+ const cookieToken = cookies.get("_csrf");
443
+ const formData = await evt.request
444
+ .clone()
445
+ .formData()
446
+ .catch(() => null);
447
+ const bodyToken = formData?.get("_csrf");
448
+ if (!cookieToken || cookieToken !== String(bodyToken)) {
449
+ return new Response("Forbidden", { status: 403 });
450
+ }
451
+ }
452
+
453
+ const handler = mod[evt.request.method];
454
+ if (handler) {
455
+ let result: any;
456
+ try {
457
+ result = await handler({
458
+ request: evt.request,
459
+ params: matched.params,
460
+ url: evt.url,
461
+ locals: evt.locals,
462
+ fetch: patchedFetch,
463
+ });
464
+ } catch (e) {
465
+ if (isRedirectResult(e)) {
466
+ return new Response(null, {
467
+ status: e.status,
468
+ headers: { Location: e.location },
469
+ });
470
+ }
471
+ if (isErrorResult(e)) {
472
+ const isApi = evt.request.headers
473
+ .get("accept")
474
+ ?.includes("application/json");
475
+ if (isApi) {
476
+ return Response.json(
477
+ { error: true, status: e.status, message: e.message },
478
+ { status: e.status },
479
+ );
480
+ }
481
+ const errorPage = findClosestError(
482
+ tree.errors,
483
+ evt.url.pathname,
484
+ );
485
+ if (errorPage) {
486
+ const errMod = await loader(errorPage.filePath);
487
+ const html = errMod.default({
488
+ status: e.status,
489
+ message: e.message,
490
+ });
491
+ return new Response(html, {
492
+ status: e.status,
493
+ headers: { "Content-Type": "text/html" },
494
+ });
495
+ }
496
+ return new Response(e.message, { status: e.status });
497
+ }
498
+ throw e;
499
+ }
500
+
501
+ if (isFailResult(result)) {
502
+ return Response.json(
503
+ { fail: true, status: result.status, data: result.data },
504
+ { status: result.status },
505
+ );
506
+ }
507
+
508
+ const redirectTo =
509
+ result?.redirect ?? evt.request.headers.get("referer") ?? "/";
510
+ return new Response(null, {
511
+ status: 303,
512
+ headers: { Location: redirectTo },
513
+ });
514
+ }
515
+ if (evt.request.method !== "GET") {
516
+ return new Response("Method Not Allowed", { status: 405 });
517
+ }
518
+ }
519
+
520
+ // page routes
521
+ const response = await renderRoute(
522
+ matched,
523
+ evt.request,
524
+ tree.errors,
525
+ loader,
526
+ evt.locals,
527
+ plugins,
528
+ cspNonce,
529
+ dev,
530
+ patchedFetch
531
+ );
532
+
533
+ if (evt.request.headers.get("x-grimoire-navigate") === "1") {
534
+ return response;
535
+ }
536
+
537
+ const hasRenderPlugins = plugins.some((p) => p.onRouteRender);
538
+ if (hasRenderPlugins) {
539
+ let html = await response.text();
540
+ for (const plugin of plugins) {
541
+ html =
542
+ (await plugin.onRouteRender?.(html, {
543
+ route: matched.route,
544
+ request: evt.request,
545
+ params: matched.params,
546
+ data: undefined,
547
+ })) ?? html;
548
+ }
549
+ return new Response(html, {
550
+ headers: { "Content-Type": "text/html" },
551
+ });
552
+ }
553
+
554
+ return response;
555
+ };
556
+
557
+ let response: Response;
558
+ try {
559
+ if (!hooksHandle) {
560
+ response = await resolve(event);
561
+ } else {
562
+ response = await hooksHandle({ event, resolve });
563
+ }
564
+ } catch (err) {
565
+ logger.error(
566
+ `${event.request.method} ${event.url.pathname} failed:`,
567
+ err,
568
+ );
569
+ await hooksHandleError?.({
570
+ error: err,
571
+ event,
572
+ status: 500,
573
+ message: "Internal Server Error",
574
+ });
575
+ const errorHtmlPath = `${process.cwd()}/error.html`;
576
+ try {
577
+ const errorFile = Bun.file(errorHtmlPath);
578
+ if (await errorFile.exists()) {
579
+ return new Response(errorFile, {
580
+ status: 500,
581
+ headers: { "Content-Type": "text/html" },
582
+ });
583
+ }
584
+ } catch { }
585
+ const message =
586
+ process.env.NODE_ENV === "production"
587
+ ? "Internal Server Error"
588
+ : `${err instanceof Error ? `${err.message}\n${err.stack}` : String(err)}`;
589
+ return new Response(message, { status: 500 });
590
+ }
591
+
592
+ const setCookieHeaders = cookies.toHeaders();
593
+ if (
594
+ setCookieHeaders.length > 0 ||
595
+ Object.keys(setHeadersMap).length > 0
596
+ ) {
597
+ const headers = new Headers(response.headers);
598
+ for (const [k, v] of Object.entries(setHeadersMap)) {
599
+ headers.set(k, v);
600
+ }
601
+ for (const sc of setCookieHeaders) {
602
+ headers.append("Set-Cookie", sc);
603
+ }
604
+ response = new Response(response.body, {
605
+ status: response.status,
606
+ statusText: response.statusText,
607
+ headers,
608
+ });
609
+ }
610
+
611
+ return response;
612
+ });
613
+ },
614
+ websocket: {
615
+ open(ws: ServerWebSocket<any>) {
616
+ if (ws.data.__hmrClient) {
617
+ hmr?.addClient(ws);
618
+ return;
619
+ }
620
+ (ws.data as _WsInternalData).__handler?.open?.(ws);
621
+ },
622
+ message(ws: ServerWebSocket<any>, data: string | Buffer) {
623
+ if (ws.data.__hmrClient) return;
624
+ (ws.data as _WsInternalData).__handler?.message?.(ws, data);
625
+ },
626
+ close(ws: ServerWebSocket<any>, code: number, reason?: string) {
627
+ if (ws.data.__hmrClient) {
628
+ hmr?.removeClient(ws);
629
+ return;
630
+ }
631
+ (ws.data as _WsInternalData).__handler?.close?.(ws, code, reason);
632
+ },
633
+ drain(ws: ServerWebSocket<any>) {
634
+ if (ws.data.__hmrClient) return;
635
+ (ws.data as _WsInternalData).__handler?.drain?.(ws);
636
+ },
637
+ },
638
+ });
639
+
640
+ let stopping = false;
641
+ const handleShutdown = async () => {
642
+ if (stopping) return;
643
+ stopping = true;
644
+ await runHook(plugins, "onStop", "shutdown");
645
+ server.stop();
646
+ process.exit(0);
647
+ };
648
+ process.on("SIGINT", handleShutdown);
649
+ process.on("SIGTERM", handleShutdown);
650
+
651
+ await runHook(plugins, "onStart", {
652
+ port: server.port,
653
+ hostname: server.hostname,
654
+ stop: () => server.stop(),
655
+ });
656
+ return server;
651
657
  }