@sigil-dev/grimoire 0.7.5 → 0.7.7

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 (58) hide show
  1. package/.grimoire/_routes.dom.js +8 -0
  2. package/.grimoire/_routes.hydrate.js +8 -0
  3. package/.grimoire/tsconfig.generated.json +11 -0
  4. package/.grimoire/types/ambient.d.ts +59 -0
  5. package/.grimoire/types/api/hello/$types.d.ts +50 -0
  6. package/.grimoire/types/api/items/$types.d.ts +50 -0
  7. package/.grimoire/types/echo/$types.d.ts +50 -0
  8. package/.grimoire/types/env-private.d.ts +5 -0
  9. package/.grimoire/types/env-public.d.ts +5 -0
  10. package/.grimoire/types/mixed/$types.d.ts +50 -0
  11. package/.grimoire/types/params/[docId]/$types.d.ts +52 -0
  12. package/.grimoire/types/reject/$types.d.ts +50 -0
  13. package/index.ts +21 -20
  14. package/package.json +13 -7
  15. package/preload.js +3 -0
  16. package/public/__grimoire__/hydrate.js +585 -0
  17. package/public/__grimoire__/index.js +490 -0
  18. package/server.ts +13 -13
  19. package/src/client/head.ts +29 -0
  20. package/src/client/router.ts +254 -40
  21. package/src/dev/compile-module.ts +173 -0
  22. package/src/dev/effect-registry.ts +23 -0
  23. package/src/dev/graph.ts +114 -0
  24. package/src/dev/hmr-client.ts +158 -0
  25. package/src/dev/hmr-server.ts +187 -0
  26. package/src/dev/loader.ts +47 -0
  27. package/src/dev/paths.ts +14 -0
  28. package/src/dev/runtime-bundle.ts +49 -0
  29. package/src/dev/watcher.ts +44 -0
  30. package/src/env/index.ts +25 -0
  31. package/src/env/plugin.ts +13 -0
  32. package/src/env/private.ts +5 -0
  33. package/src/env/public.ts +7 -0
  34. package/src/env/typegen.ts +51 -0
  35. package/src/integrations/vite.ts +1 -0
  36. package/src/rendering/head.ts +22 -2
  37. package/src/rendering/hydrate.ts +111 -18
  38. package/src/rendering/index.ts +263 -153
  39. package/src/rendering/ssrPlugin.ts +59 -39
  40. package/src/routing/manifest-gen.ts +18 -2
  41. package/src/routing/router.ts +94 -83
  42. package/src/routing/scanner.ts +26 -14
  43. package/src/routing/transform-routes.ts +68 -68
  44. package/src/server/build.ts +225 -76
  45. package/src/server/coordinator.ts +9 -0
  46. package/src/server/hooks.ts +24 -3
  47. package/src/server/index.ts +388 -104
  48. package/src/typegen/index.ts +30 -14
  49. package/src/types.ts +12 -2
  50. package/test/middleware.test.ts +6 -4
  51. package/test/rendering.test.ts +510 -356
  52. package/test/routing.test.ts +36 -0
  53. package/test/scanning.test.ts +39 -8
  54. package/test/scope.test.ts +24 -8
  55. package/test/server.test.ts +27 -7
  56. package/test/streaming.test.ts +117 -98
  57. package/test/typegen.test.ts +52 -24
  58. package/tsconfig.json +1 -0
@@ -1,27 +1,120 @@
1
- import { routes } from "#grimoire-routes";
2
- import { popHydrationNodes, pushHydrationNodes } from "@sigil-dev/runtime";
1
+ //@ts-expect-error compiler generated
2
+
3
+ import {
4
+ claim,
5
+ getHydrationNodes,
6
+ insert,
7
+ popHydrationNodes,
8
+ pushHydrationNodes,
9
+ } from "@sigil-dev/runtime";
10
+ import { layouts, routes } from "#grimoire-routes";
11
+ import {
12
+ afterNavigate,
13
+ beforeNavigate,
14
+ navigate,
15
+ onNavigate,
16
+ } from "../client/router";
3
17
  import { initRouter } from "../client/router.ts";
4
18
  import { withEffectScope } from "../client/scope.ts";
19
+ import { Head } from "./head";
20
+
21
+ // expose for HMR module shim
22
+ (globalThis as any).__grimoire_Head__ = Head;
23
+ (globalThis as any).__grimoire_navigate__ = navigate;
24
+ (globalThis as any).__grimoire_beforeNavigate__ = beforeNavigate;
25
+ (globalThis as any).__grimoire_onNavigate__ = onNavigate;
26
+ (globalThis as any).__grimoire_afterNavigate__ = afterNavigate;
5
27
 
6
28
  const stateEl = document.getElementById("__grimoire_state__");
7
29
  let initialDispose: (() => void) | undefined;
8
30
 
9
31
  if (stateEl) {
10
- const state = JSON.parse(stateEl.textContent!);
11
- const Page = routes[state.pattern];
12
- if (Page) {
13
- const slot = document.getElementById("grimoire-page");
14
- if (slot) {
15
- pushHydrationNodes(Array.from(slot.childNodes) as ChildNode[]);
16
- initialDispose = withEffectScope(() => {
17
- try {
18
- Page({ data: state.data, params: state.params });
19
- } finally {
20
- popHydrationNodes();
21
- }
22
- });
23
- }
24
- }
32
+ const state = JSON.parse(stateEl.textContent!);
33
+ const Page = routes[state.pattern];
34
+ if (Page) {
35
+ const slot = document.getElementById("grimoire-root");
36
+ if (slot) {
37
+ const matchedLayouts = layouts
38
+ .filter(
39
+ (l: any) =>
40
+ l.path === "/" ||
41
+ state.pattern === l.path ||
42
+ state.pattern.startsWith(l.path + "/"),
43
+ )
44
+ .sort((a: any, b: any) => a.path.length - b.path.length);
45
+
46
+ // When layouts are involved, nested <!--g--> delimiters in the flat SSR pool
47
+ // cause anchor-mount to claim the wrong anchor and remove layout DOM nodes.
48
+ // Clear SSR content and re-render in DOM mode (empty pool) instead.
49
+ const hasLayouts = matchedLayouts.length > 0;
50
+ if (hasLayouts) {
51
+ slot.replaceChildren();
52
+ }
53
+
54
+ const ssrClones = hasLayouts
55
+ ? []
56
+ : Array.from(slot.childNodes).map((n) => n.cloneNode(true));
57
+ pushHydrationNodes(
58
+ hasLayouts ? [] : (Array.from(slot.childNodes) as ChildNode[]),
59
+ );
60
+ try {
61
+ initialDispose = withEffectScope(() => {
62
+ try {
63
+ let renderFn = () => {
64
+ const pageDiv = claim(getHydrationNodes(), "div");
65
+ pageDiv.id = "grimoire-page";
66
+
67
+ if (pageDiv.childNodes.length > 0) {
68
+ pushHydrationNodes(
69
+ Array.from(pageDiv.childNodes) as ChildNode[],
70
+ );
71
+ const pageNode = Page({
72
+ data: state.data,
73
+ params: state.params,
74
+ });
75
+ popHydrationNodes();
76
+ insert(pageDiv, pageNode);
77
+ } else {
78
+ const pageNode = Page({
79
+ data: state.data,
80
+ params: state.params,
81
+ });
82
+ insert(pageDiv, pageNode);
83
+ }
84
+
85
+ return pageDiv;
86
+ };
87
+
88
+ for (let i = matchedLayouts.length - 1; i >= 0; i--) {
89
+ const LayoutComponent = matchedLayouts[i].component;
90
+ const innerRender = renderFn;
91
+ const layoutData = state.layoutData?.[i];
92
+ renderFn = () => {
93
+ // Pre-render inner content so children is a Node (insert() doesn't call functions)
94
+ const childNode = innerRender();
95
+ return LayoutComponent({
96
+ data: layoutData,
97
+ params: state.params,
98
+ children: childNode,
99
+ });
100
+ };
101
+ }
102
+
103
+ const rootNode = renderFn();
104
+ insert(slot, rootNode);
105
+ } finally {
106
+ popHydrationNodes();
107
+ }
108
+ });
109
+ } catch (e) {
110
+ console.warn("[grimoire] hydration error:", e);
111
+ }
112
+
113
+ if (!slot.hasChildNodes() && ssrClones.length > 0) {
114
+ slot.replaceChildren(...ssrClones);
115
+ }
116
+ }
117
+ }
25
118
  }
26
119
 
27
- initRouter(routes, initialDispose);
120
+ initRouter(routes, layouts, initialDispose);
@@ -1,4 +1,5 @@
1
1
  import { SafeHtml } from "@sigil-dev/runtime";
2
+ import { randomBytes } from "crypto";
2
3
  import { findClosestError, type MatchedRoute } from "../routing/router";
3
4
  import type { RouteFile } from "../routing/scanner";
4
5
  import { isErrorResult } from "../sentinels/error.ts";
@@ -11,176 +12,285 @@ import { collectHead, initHead } from "./head";
11
12
  export type ModuleLoader = (path: string) => Promise<any>;
12
13
 
13
14
  async function renderErrorPage(
14
- errorRoutes: RouteFile[],
15
- pathname: string,
16
- status: number,
17
- message: string,
15
+ errorRoutes: RouteFile[],
16
+ pathname: string,
17
+ status: number,
18
+ message: string,
19
+ error?: unknown,
18
20
  ): Promise<Response | null> {
19
- const errorPage = findClosestError(errorRoutes, pathname);
20
- if (!errorPage) return null;
21
- const mod = await import(errorPage.filePath);
22
- const html = mod.default({ status, message });
23
- return new Response(html, {
24
- status,
25
- headers: { "Content-Type": "text/html" },
26
- });
21
+ const errorPage = findClosestError(errorRoutes, pathname);
22
+ if (!errorPage) return null;
23
+ const mod = await import(errorPage.filePath);
24
+ const html = mod.default({ status, message, error, route: pathname });
25
+ return new Response(html, {
26
+ status,
27
+ headers: { "Content-Type": "text/html" },
28
+ });
27
29
  }
28
30
 
29
31
  export async function renderRoute(
30
- matched: MatchedRoute,
31
- req: Request,
32
- errorRoutes: RouteFile[] = [],
33
- loadModule: ModuleLoader = (path) => import(path),
34
- locals: Record<string, any> = {},
35
- plugins: GrimoirePlugin[] = [],
32
+ matched: MatchedRoute,
33
+ req: Request,
34
+ errorRoutes: RouteFile[] = [],
35
+ loadModule: ModuleLoader = (path) => import(path),
36
+ locals: Record<string, any> = {},
37
+ plugins: GrimoirePlugin[] = [],
38
+ cspNonce?: string,
39
+ dev?: boolean,
36
40
  ): Promise<Response> {
37
- return runWithContext(async () => {
38
- const context: LoadContext = {
39
- request: req,
40
- params: matched.params,
41
- url: new URL(req.url),
42
- locals,
43
- };
44
-
45
- initHead();
46
-
47
- const route: Route = {
48
- path: matched.route.path,
49
- params: matched.params,
50
- filePath: matched.route.filePath,
51
- loadPath: matched.pageServer?.filePath,
52
- layoutPath: matched.layouts.at(-1)?.filePath,
53
- };
54
- await runHook(plugins, "onRouteLoad", route, context);
55
-
56
- const layoutPairs: { layout: RouteFile; data: unknown }[] = [];
57
- for (const layout of matched.layouts) {
58
- const layoutServer = matched.layoutServers.find(
59
- ls => ls.path === layout.path
60
- );
61
- let data: unknown = undefined;
62
- if (layoutServer) {
63
- try {
64
- const mod = await import(layoutServer.filePath);
65
- data = await mod.load?.(context);
66
- } catch (e) {
67
- if (isRedirectResult(e)) return new Response(null, {
68
- status: e.status, headers: { Location: e.location }
69
- });
70
- if (isErrorResult(e)) return (
71
- await renderErrorPage(errorRoutes, context.url.pathname, e.status, e.message)
72
- ?? new Response(e.message, { status: e.status })
73
- );
74
- throw e;
75
- }
76
- }
77
- layoutPairs.push({ layout, data });
78
- }
79
-
80
- let pageData: unknown;
81
- if (matched.pageServer) {
82
- try {
83
- const mod = await import(matched.pageServer.filePath);
84
- pageData = await mod.load?.(context);
85
- } catch (e) {
86
- if (isRedirectResult(e)) {
87
- return new Response(null, {
88
- status: e.status,
89
- headers: { Location: e.location },
90
- });
91
- }
92
- if (isErrorResult(e)) {
93
- return (
94
- (await renderErrorPage(
95
- errorRoutes,
96
- context.url.pathname,
97
- e.status,
98
- e.message,
99
- )) ?? new Response(e.message, { status: e.status })
100
- );
101
- }
102
- throw e;
103
- }
104
- }
105
-
106
- const pageMod = await import(matched.route.filePath);
107
- const pageHtml = pageMod.default({
108
- data: pageData,
109
- params: matched.params,
110
- });
111
-
112
- // collect head AFTER page render so <Head> calls are captured
113
- const headHtml = collectHead();
114
-
115
- // navigation request: return JSON, client handles rendering
116
- if (req.headers.get("x-grimoire-navigate") === "1") {
117
- return Response.json({
118
- data: pageData ?? {},
119
- params: matched.params,
120
- pattern: matched.route.path,
121
- head: headHtml,
122
- });
123
- }
124
-
125
- const wrappedPage = `<div id="grimoire-page">${String(pageHtml)}</div>`;
126
-
127
- let bodyHtml: string = wrappedPage;
128
- for (const { layout, data } of [...layoutPairs].reverse()) {
129
- const layoutMod = await import(layout.filePath);
130
- bodyHtml = String(
131
- layoutMod.default({
132
- data,
133
- children: new SafeHtml(bodyHtml),
134
- params: matched.params,
135
- }),
136
- );
137
- }
138
-
139
- const stateJson = JSON.stringify({
140
- params: matched.params,
141
- data: pageData,
142
- pattern: matched.route.path,
143
- });
144
-
145
- // --- Streaming SSR ---
146
- // Send DOCTYPE + head skeleton immediately (browser starts resource fetch).
147
- // Then stream body + full head content + state as they become available.
148
- const stream = new ReadableStream({
149
- start(controller) {
150
- // 1. Document skeleton browser starts parsing, fetches CSS/JS
151
- controller.enqueue(
152
- `<!DOCTYPE html>
41
+ return runWithContext(async () => {
42
+ const context: LoadContext = {
43
+ request: req,
44
+ params: matched.params,
45
+ url: new URL(req.url),
46
+ locals,
47
+ };
48
+
49
+ initHead(cspNonce);
50
+
51
+ const route: Route = {
52
+ path: matched.route.path,
53
+ params: matched.params,
54
+ filePath: matched.route.filePath,
55
+ loadPath: matched.pageServer?.filePath,
56
+ layoutPath: matched.layouts.at(-1)?.filePath,
57
+ };
58
+ await runHook(plugins, "onRouteLoad", route, context);
59
+
60
+ const layoutPairs: { layout: RouteFile; mod: any; data: unknown }[] = [];
61
+ for (const layout of matched.layouts) {
62
+ const layoutMod = await loadModule(layout.filePath);
63
+
64
+ if (layoutMod.canMatch) {
65
+ try {
66
+ const result = await layoutMod.canMatch(context);
67
+ if (result === false)
68
+ return new Response("Not Found", { status: 404 });
69
+ if (isRedirectResult(result))
70
+ return new Response(null, {
71
+ status: result.status,
72
+ headers: { Location: result.location },
73
+ });
74
+ } catch (e) {
75
+ if (isRedirectResult(e))
76
+ return new Response(null, {
77
+ status: e.status,
78
+ headers: { Location: e.location },
79
+ });
80
+ if (isErrorResult(e))
81
+ return (
82
+ (await renderErrorPage(
83
+ errorRoutes,
84
+ context.url.pathname,
85
+ e.status,
86
+ e.message,
87
+ e,
88
+ )) ?? new Response(e.message, { status: e.status })
89
+ );
90
+ throw e;
91
+ }
92
+ }
93
+
94
+ const layoutServer = matched.layoutServers.find(
95
+ (ls) => ls.path === layout.path,
96
+ );
97
+ let data: unknown;
98
+ if (layoutServer) {
99
+ try {
100
+ const mod = await loadModule(layoutServer.filePath);
101
+ data = await mod.load?.(context);
102
+ } catch (e) {
103
+ if (isRedirectResult(e))
104
+ return new Response(null, {
105
+ status: e.status,
106
+ headers: { Location: e.location },
107
+ });
108
+ if (isErrorResult(e))
109
+ return (
110
+ (await renderErrorPage(
111
+ errorRoutes,
112
+ context.url.pathname,
113
+ e.status,
114
+ e.message,
115
+ e,
116
+ )) ?? new Response(e.message, { status: e.status })
117
+ );
118
+ throw e;
119
+ }
120
+ }
121
+ const universalLayoutData = layoutMod.load
122
+ ? await layoutMod.load(context)
123
+ : undefined;
124
+
125
+ const mergedLayoutData =
126
+ universalLayoutData !== undefined
127
+ ? //@ts-expect-error User provded data is always unknown.
128
+ { ...universalLayoutData, ...data }
129
+ : data;
130
+
131
+ layoutPairs.push({ layout, mod: layoutMod, data: mergedLayoutData });
132
+ }
133
+
134
+ let pageData: unknown;
135
+ if (matched.pageServer) {
136
+ try {
137
+ const mod = await loadModule(matched.pageServer.filePath);
138
+ pageData = await mod.load?.(context);
139
+ } catch (e) {
140
+ if (isRedirectResult(e)) {
141
+ return new Response(null, {
142
+ status: e.status,
143
+ headers: { Location: e.location },
144
+ });
145
+ }
146
+ if (isErrorResult(e)) {
147
+ return (
148
+ (await renderErrorPage(
149
+ errorRoutes,
150
+ context.url.pathname,
151
+ e.status,
152
+ e.message,
153
+ e,
154
+ )) ?? new Response(e.message, { status: e.status })
155
+ );
156
+ }
157
+ throw e;
158
+ }
159
+ }
160
+
161
+ const pageMod = await loadModule(matched.route.filePath);
162
+ if (pageMod.canMatch) {
163
+ try {
164
+ const result = await pageMod.canMatch(context);
165
+ if (result === false) return new Response("Not Found", { status: 404 });
166
+ if (isRedirectResult(result))
167
+ return new Response(null, {
168
+ status: result.status,
169
+ headers: { Location: result.location },
170
+ });
171
+ } catch (e) {
172
+ if (isRedirectResult(e))
173
+ return new Response(null, {
174
+ status: e.status,
175
+ headers: { Location: e.location },
176
+ });
177
+ if (isErrorResult(e))
178
+ return (
179
+ (await renderErrorPage(
180
+ errorRoutes,
181
+ context.url.pathname,
182
+ e.status,
183
+ e.message,
184
+ e,
185
+ )) ?? new Response(e.message, { status: e.status })
186
+ );
187
+ throw e;
188
+ }
189
+ }
190
+
191
+ const universalData = pageMod.load
192
+ ? await pageMod.load(context).catch((e) => {
193
+ if (isRedirectResult(e)) throw e;
194
+ if (isErrorResult(e)) throw e;
195
+ throw e;
196
+ })
197
+ : undefined;
198
+
199
+ const mergedData =
200
+ universalData !== undefined
201
+ ? //@ts-expect-error User provded data is always unknown.
202
+ { ...universalData, ...pageData }
203
+ : pageData;
204
+
205
+ const pageHtml = pageMod.default({
206
+ data: mergedData,
207
+ params: matched.params,
208
+ });
209
+
210
+ // collect head AFTER page render so <Head> calls are captured
211
+ const headHtml = collectHead();
212
+
213
+ // navigation request: return JSON, client handles rendering
214
+ if (req.headers.get("x-grimoire-navigate") === "1") {
215
+ return Response.json({
216
+ data: mergedData ?? {},
217
+ layoutData: layoutPairs.map((l) => l.data),
218
+ params: matched.params,
219
+ pattern: matched.route.path,
220
+ head: headHtml,
221
+ });
222
+ }
223
+
224
+ const wrappedPage = `<div id="grimoire-page">${String(pageHtml)}</div>`;
225
+
226
+ let bodyHtml: string = wrappedPage;
227
+ for (const { mod: layoutMod, data } of [...layoutPairs].reverse()) {
228
+ bodyHtml = String(
229
+ layoutMod.default({
230
+ data,
231
+ children: new SafeHtml(bodyHtml),
232
+ params: matched.params,
233
+ }),
234
+ );
235
+ }
236
+
237
+ bodyHtml = `<div id="grimoire-root">${bodyHtml}</div>`;
238
+ const csrfToken = randomBytes(32).toString("hex");
239
+ const stateJson = JSON.stringify({
240
+ params: matched.params,
241
+ data: mergedData,
242
+ layoutData: layoutPairs.map((l) => l.data),
243
+ pattern: matched.route.path,
244
+ });
245
+ const csrfInput = `<input type="hidden" name="_csrf" value="${csrfToken}">`;
246
+ bodyHtml = bodyHtml.replace(
247
+ /<form([^>]*action=[^>]*)>/gi,
248
+ `<form$1>${csrfInput}`,
249
+ );
250
+ // --- Streaming SSR ---
251
+ // Send DOCTYPE + head skeleton immediately (browser starts resource fetch).
252
+ // Then stream body + full head content + state as they become available.
253
+ const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : "";
254
+ const stream = new ReadableStream({
255
+ start(controller) {
256
+ // 1. Document skeleton — browser starts parsing, fetches CSS/JS
257
+ controller.enqueue(
258
+ `<!DOCTYPE html>
153
259
  <html>
154
260
  <head>
155
261
  <meta charset="UTF-8" />
156
262
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
157
- <script type="module" src="/__grimoire__/hydrate.js"></script>`,
158
- );
263
+ <script type="module" src="/__grimoire__/hydrate.js"${nonceAttr}></script>
264
+ ${dev ? `<script src="/__grimoire__/hmr-client.js"${nonceAttr}></script>` : ""}`,
265
+ );
159
266
 
160
- // 2. Head content (captured from <Head> component calls during render)
161
- if (headHtml) {
162
- controller.enqueue(`\n${headHtml}`);
163
- }
267
+ // 2. Head content (captured from <Head> component calls during render)
268
+ if (headHtml) {
269
+ controller.enqueue(`\n${headHtml}`);
270
+ }
164
271
 
165
- controller.enqueue(`\n</head>
272
+ controller.enqueue(`\n</head>
166
273
  <body>
167
274
  <div id="app">`);
168
275
 
169
- // 3. Page body
170
- controller.enqueue(bodyHtml);
276
+ // 3. Page body
277
+ controller.enqueue(bodyHtml);
171
278
 
172
- // 4. State script + closing tags
173
- controller.enqueue(`</div>
174
- <script id="__grimoire_state__" type="application/json">${stateJson}</script>
279
+ // 4. State script + closing tags
280
+ controller.enqueue(`</div>
281
+ <script id="__grimoire_state__" type="application/json"${nonceAttr}>${stateJson}</script>
175
282
  </body>
176
283
  </html>`);
177
284
 
178
- controller.close();
179
- },
180
- });
285
+ controller.close();
286
+ },
287
+ });
181
288
 
182
- return new Response(stream, {
183
- headers: { "Content-Type": "text/html" },
184
- });
185
- });
289
+ return new Response(stream, {
290
+ headers: {
291
+ "Content-Type": "text/html",
292
+ "Set-Cookie": "_csrf=${csrfToken}; SameSite=Strict; Path=/",
293
+ },
294
+ });
295
+ });
186
296
  }