@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
@@ -10,6 +10,10 @@ const makeRoute = (path: string, type: RouteFile["type"]): RouteFile => ({
10
10
  .split("/")
11
11
  .filter((p) => p.startsWith(":"))
12
12
  .map((p) => p.slice(1)),
13
+ restParamNames: path
14
+ .split("/")
15
+ .filter((p) => p.startsWith("*"))
16
+ .map((p) => p.slice(1)),
13
17
  });
14
18
 
15
19
  const tree: RouteTree = {
@@ -43,3 +47,35 @@ describe("matchRoute", () => {
43
47
  expect(match).toBeNull();
44
48
  });
45
49
  });
50
+
51
+ describe("matchRoute — rest params (catch-all)", () => {
52
+ const restTree: RouteTree = {
53
+ routes: [],
54
+ layouts: [],
55
+ servers: [makeRoute("/docs/*path", "server")],
56
+ errors: [],
57
+ };
58
+
59
+ test("matches single remaining segment", () => {
60
+ const match = matchRoute(restTree, new URL("http://localhost/docs/abc"));
61
+ expect(match?.route.path).toBe("/docs/*path");
62
+ expect(match?.params).toEqual({ path: "abc" });
63
+ });
64
+
65
+ test("matches multiple remaining segments joined by /", () => {
66
+ const match = matchRoute(restTree, new URL("http://localhost/docs/a/b/c"));
67
+ expect(match?.route.path).toBe("/docs/*path");
68
+ expect(match?.params).toEqual({ path: "a/b/c" });
69
+ });
70
+
71
+ test("matches zero remaining segments as empty string", () => {
72
+ const match = matchRoute(restTree, new URL("http://localhost/docs/"));
73
+ expect(match?.route.path).toBe("/docs/*path");
74
+ expect(match?.params).toEqual({ path: "" });
75
+ });
76
+
77
+ test("does not match when prefix segments don't line up", () => {
78
+ const match = matchRoute(restTree, new URL("http://localhost/other/a"));
79
+ expect(match).toBeNull();
80
+ });
81
+ });
@@ -7,32 +7,32 @@ import { filePathToRoutePath, scanRoutes } from "../src/routing/scanner";
7
7
  describe("File scanning", () => {
8
8
  test("index.tsx → /", () => {
9
9
  expect(filePathToRoutePath("/app/routes/index.tsx", "/app/routes")).toEqual(
10
- { pattern: "/", params: [] },
10
+ { pattern: "/", params: [], restParams: [] },
11
11
  );
12
12
  });
13
13
 
14
14
  test("about.tsx → /about", () => {
15
15
  expect(filePathToRoutePath("/app/routes/about.tsx", "/app/routes")).toEqual(
16
- { pattern: "/about", params: [] },
16
+ { pattern: "/about", params: [], restParams: [] },
17
17
  );
18
18
  });
19
19
 
20
20
  test("[slug].tsx → /:slug", () => {
21
21
  expect(
22
22
  filePathToRoutePath("/app/routes/[slug].tsx", "/app/routes"),
23
- ).toEqual({ pattern: "/:slug", params: ["slug"] });
23
+ ).toEqual({ pattern: "/:slug", params: ["slug"], restParams: [] });
24
24
  });
25
25
 
26
26
  test("blog/+page.tsx → /blog", () => {
27
27
  expect(
28
28
  filePathToRoutePath("/app/routes/blog/+page.tsx", "/app/routes"),
29
- ).toEqual({ pattern: "/blog", params: [] });
29
+ ).toEqual({ pattern: "/blog", params: [], restParams: [] });
30
30
  });
31
31
 
32
32
  test("blog/[slug]/+page.tsx → /blog/:slug", () => {
33
33
  expect(
34
34
  filePathToRoutePath("/app/routes/blog/[slug]/+page.tsx", "/app/routes"),
35
- ).toEqual({ pattern: "/blog/:slug", params: ["slug"] });
35
+ ).toEqual({ pattern: "/blog/:slug", params: ["slug"], restParams: [] });
36
36
  });
37
37
 
38
38
  test("blog/[slug]/+page.server.ts → /blog/:slug", () => {
@@ -41,19 +41,34 @@ describe("File scanning", () => {
41
41
  "/app/routes/blog/[slug]/+page.server.ts",
42
42
  "/app/routes",
43
43
  ),
44
- ).toEqual({ pattern: "/blog/:slug", params: ["slug"] });
44
+ ).toEqual({ pattern: "/blog/:slug", params: ["slug"], restParams: [] });
45
45
  });
46
46
 
47
47
  test("+layout.tsx → /", () => {
48
48
  expect(
49
49
  filePathToRoutePath("/app/routes/+layout.tsx", "/app/routes"),
50
- ).toEqual({ pattern: "/", params: [] });
50
+ ).toEqual({ pattern: "/", params: [], restParams: [] });
51
51
  });
52
52
 
53
53
  test("nested +layout.tsx → /blog", () => {
54
54
  expect(
55
55
  filePathToRoutePath("/app/routes/blog/+layout.tsx", "/app/routes"),
56
- ).toEqual({ pattern: "/blog", params: [] });
56
+ ).toEqual({ pattern: "/blog", params: [], restParams: [] });
57
+ });
58
+
59
+ test("[...rest].tsx → /*rest (catch-all)", () => {
60
+ expect(
61
+ filePathToRoutePath("/app/routes/[...rest].tsx", "/app/routes"),
62
+ ).toEqual({ pattern: "/*rest", params: [], restParams: ["rest"] });
63
+ });
64
+
65
+ test("docs/[...path]/+server.ts → /docs/*path (nested catch-all)", () => {
66
+ expect(
67
+ filePathToRoutePath(
68
+ "/app/routes/docs/[...path]/+server.ts",
69
+ "/app/routes",
70
+ ),
71
+ ).toEqual({ pattern: "/docs/*path", params: [], restParams: ["path"] });
57
72
  });
58
73
 
59
74
  test("about/index.tsx, about/+page.tsx, and about.tsx all resolve to /about", () => {
@@ -88,6 +103,22 @@ describe("File scanning", () => {
88
103
  expect(root.pattern).toBe("/");
89
104
  expect(nested.pattern).toBe("/blog");
90
105
  });
106
+
107
+ test("(group)/page/+page.tsx → /page", () => {
108
+ const { pattern } = filePathToRoutePath(
109
+ "/routes/(marketing)/about/+page.tsx",
110
+ "/routes",
111
+ );
112
+ expect(pattern).toBe("/about");
113
+ });
114
+
115
+ test("nested groups collapse correctly", () => {
116
+ const { pattern } = filePathToRoutePath(
117
+ "/routes/(app)/(auth)/login/+page.tsx",
118
+ "/routes",
119
+ );
120
+ expect(pattern).toBe("/login");
121
+ });
91
122
  });
92
123
 
93
124
  describe("Duplicate route detection", () => {
@@ -6,17 +6,22 @@
6
6
  * 1. Effects created during a page mount stop running when the page is disposed.
7
7
  * 2. __nodes is reset to [] before each SPA navigation so the new page gets
8
8
  * a fresh empty pool instead of recycling stale DOM nodes.
9
+ *
10
+ * await tick() must be called after all signal edits, given Bun's runner is
11
+ * synchronous and doesnt wait for our microtasks. In the browser the microtasks
12
+ * flush between event loop turns
9
13
  */
10
14
  import { describe, expect, test } from "bun:test";
11
15
  import {
12
16
  createEffect,
13
17
  createSignal,
14
18
  withEffectScope as runtimeScope,
19
+ tick,
15
20
  } from "@sigil-dev/runtime";
16
21
  import { withEffectScope } from "../src/client/scope.ts";
17
22
 
18
23
  describe("grimoire withEffectScope", () => {
19
- test("effects inside scope stop after dispose", () => {
24
+ test("effects inside scope stop after dispose", async () => {
20
25
  const count = createSignal(0);
21
26
  let runs = 0;
22
27
  const dispose = withEffectScope(() => {
@@ -27,15 +32,16 @@ describe("grimoire withEffectScope", () => {
27
32
  });
28
33
 
29
34
  count.set(1);
30
- expect(runs).toBe(2); // initial + one update
35
+ await tick(); // let microtask queue flush
36
+ expect(runs).toBe(2);
31
37
 
32
38
  dispose();
33
-
34
39
  count.set(2);
35
- expect(runs).toBe(2); // stopped — dispose cut the subscription
40
+ await tick();
41
+ expect(runs).toBe(2);
36
42
  });
37
43
 
38
- test("simulates two page navigations — only latest page's effects run", () => {
44
+ test("simulates two page navigations — only latest page's effects run", async () => {
39
45
  const count = createSignal(0);
40
46
  let pageARuns = 0;
41
47
  let pageBRuns = 0;
@@ -48,6 +54,7 @@ describe("grimoire withEffectScope", () => {
48
54
  });
49
55
  });
50
56
  count.set(1);
57
+ await tick();
51
58
  expect(pageARuns).toBe(2);
52
59
  expect(pageBRuns).toBe(0);
53
60
 
@@ -61,17 +68,19 @@ describe("grimoire withEffectScope", () => {
61
68
  });
62
69
 
63
70
  count.set(2);
71
+ await tick();
64
72
  expect(pageARuns).toBe(2); // A's effect is gone
65
73
  expect(pageBRuns).toBe(2); // B initial + update
66
74
 
67
75
  // Navigate away from B — dispose B
68
76
  disposeCurrentPage();
69
77
  count.set(3);
78
+ await tick();
70
79
  expect(pageARuns).toBe(2);
71
80
  expect(pageBRuns).toBe(2); // B's effect is gone too
72
81
  });
73
82
 
74
- test("three navigations: each page's effects are independent", () => {
83
+ test("three navigations: each page's effects are independent", async () => {
75
84
  const signal = createSignal("a");
76
85
  const log: string[] = [];
77
86
 
@@ -86,18 +95,21 @@ describe("grimoire withEffectScope", () => {
86
95
  const disposeB = mountPage("B");
87
96
 
88
97
  signal.set("b");
98
+ await tick();
89
99
  expect(log).toEqual(["A:a", "B:a", "A:b", "B:b"]);
90
100
 
91
101
  disposeA();
92
102
  signal.set("c");
103
+ await tick();
93
104
  expect(log).toEqual(["A:a", "B:a", "A:b", "B:b", "B:c"]); // only B ran
94
105
 
95
106
  disposeB();
96
107
  signal.set("d");
108
+ await tick();
97
109
  expect(log).toEqual(["A:a", "B:a", "A:b", "B:b", "B:c"]); // neither ran
98
110
  });
99
111
 
100
- test("grimoire scope uses same globalThis channel as runtime scope", () => {
112
+ test("grimoire scope uses same globalThis channel as runtime scope", async () => {
101
113
  // Both must route through the same global so that createEffect
102
114
  // in page components (compiled by sigil, imports @sigil-dev/runtime)
103
115
  // is captured by grimoire's router scope.
@@ -113,14 +125,16 @@ describe("grimoire withEffectScope", () => {
113
125
  });
114
126
 
115
127
  count.set(1);
128
+ await tick();
116
129
  expect(runs).toBe(2);
117
130
 
118
131
  dispose();
119
132
  count.set(2);
133
+ await tick();
120
134
  expect(runs).toBe(2); // runtime's createEffect was captured by grimoire's scope ✓
121
135
  });
122
136
 
123
- test("runtime's withEffectScope and grimoire's are interchangeable", () => {
137
+ test("runtime's withEffectScope and grimoire's are interchangeable", async () => {
124
138
  // The runtime exports its own withEffectScope for user code.
125
139
  // Both implementations must work the same way via the globalThis channel.
126
140
  const count = createSignal(0);
@@ -134,9 +148,11 @@ describe("grimoire withEffectScope", () => {
134
148
  });
135
149
 
136
150
  count.set(1);
151
+ await tick();
137
152
  expect(runs).toBe(2);
138
153
  dispose();
139
154
  count.set(2);
155
+ await tick();
140
156
  expect(runs).toBe(2);
141
157
  });
142
158
  });
@@ -1,11 +1,11 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdir, rm, writeFile } from "fs/promises";
2
+ import { mkdir, writeFile } from "fs/promises";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { createServer } from "../src/server";
6
6
 
7
7
  // ── Sandbox note ─────────────────────────────────────────────────────────────
8
- // The sigil babel plugin unconditionally inserts `import ... from "@sigil-dev/runtime"`
8
+ // The Babel plugin unconditionally inserts `import ... from "@sigil-dev/runtime"`
9
9
  // for all non-SSR route files. When createServer() calls Bun.build to bundle hydrate.js,
10
10
  // those compiled page files pull in @sigil-dev/runtime → Bun's test sandbox blocks the
11
11
  // read of packages/runtime/index.ts.
@@ -42,7 +42,11 @@ beforeAll(async () => {
42
42
 
43
43
  describe("Server — API routes (+server.ts)", () => {
44
44
  test("GET returns JSON", async () => {
45
- const server = await createServer({ port: 3004, routes: tmpDir });
45
+ const server = await createServer({
46
+ port: 3004,
47
+ routes: tmpDir,
48
+ _skipBuild: true,
49
+ });
46
50
  const res = await fetch("http://localhost:3004/api/hello");
47
51
  const data = await res.json();
48
52
  expect(data.message).toBe("hello");
@@ -50,7 +54,11 @@ describe("Server — API routes (+server.ts)", () => {
50
54
  });
51
55
 
52
56
  test("GET on multi-method route returns correct JSON", async () => {
53
- const server = await createServer({ port: 3004, routes: tmpDir });
57
+ const server = await createServer({
58
+ port: 3004,
59
+ routes: tmpDir,
60
+ _skipBuild: true,
61
+ });
54
62
  const res = await fetch("http://localhost:3004/api/items");
55
63
  expect(res.status).toBe(200);
56
64
  const items = await res.json();
@@ -59,7 +67,11 @@ describe("Server — API routes (+server.ts)", () => {
59
67
  });
60
68
 
61
69
  test("POST returns 201 with created resource", async () => {
62
- const server = await createServer({ port: 3004, routes: tmpDir });
70
+ const server = await createServer({
71
+ port: 3004,
72
+ routes: tmpDir,
73
+ _skipBuild: true,
74
+ });
63
75
  const res = await fetch("http://localhost:3004/api/items", {
64
76
  method: "POST",
65
77
  headers: { "Content-Type": "application/json" },
@@ -72,7 +84,11 @@ describe("Server — API routes (+server.ts)", () => {
72
84
  });
73
85
 
74
86
  test("unsupported method returns 405", async () => {
75
- const server = await createServer({ port: 3004, routes: tmpDir });
87
+ const server = await createServer({
88
+ port: 3004,
89
+ routes: tmpDir,
90
+ _skipBuild: true,
91
+ });
76
92
  const res = await fetch("http://localhost:3004/api/items", {
77
93
  method: "DELETE",
78
94
  });
@@ -134,7 +150,11 @@ export const websocket = {
134
150
  };`,
135
151
  );
136
152
 
137
- server = await createServer({ port: 3005, routes: wsDir });
153
+ server = await createServer({
154
+ port: 3005,
155
+ routes: wsDir,
156
+ _skipBuild: true,
157
+ });
138
158
  });
139
159
 
140
160
  afterAll(() => {
@@ -2,105 +2,124 @@ import { describe, expect, test } from "bun:test";
2
2
  import { renderRoute } from "../src/rendering";
3
3
 
4
4
  function makeMatched(path: string, filePath: string, params = {}) {
5
- return {
6
- route: { path, params: {}, filePath },
7
- params,
8
- layouts: [],
9
- layoutServers: [],
10
- } as any;
5
+ return {
6
+ route: { path, params: {}, filePath },
7
+ params,
8
+ layouts: [],
9
+ layoutServers: [],
10
+ } as any;
11
11
  }
12
12
 
13
13
  describe("Streaming SSR", () => {
14
- test("returns a ReadableStream response", async () => {
15
- const matched = makeMatched("/", "data:text/javascript,export default (p) => '<div>hello</div>'");
16
- const res = await renderRoute(matched, new Request("http://localhost/"));
17
- expect(res.body).toBeInstanceOf(ReadableStream);
18
- });
19
-
20
- test("stream contains full HTML document", async () => {
21
- const matched = makeMatched("/test", "data:text/javascript,export default (p) => '<p>streaming works</p>'");
22
-
23
- const res = await renderRoute(
24
- matched,
25
- new Request("http://localhost/test"),
26
- );
27
- const html = await res.text();
28
-
29
- expect(html).toContain("<!DOCTYPE html>");
30
- expect(html).toContain('<meta charset="UTF-8" />');
31
- expect(html).toContain("streaming works");
32
- expect(html).toContain("</html>");
33
- expect(html).toContain("__grimoire_state__");
34
- });
35
-
36
- test("stream includes state JSON with route pattern", async () => {
37
- const matched = makeMatched("/spells/:id", "data:text/javascript,export default (p) => '<div>spell</div>'", { id: "fireball" });
38
-
39
- const res = await renderRoute(
40
- matched,
41
- new Request("http://localhost/spells/fireball"),
42
- );
43
- const html = await res.text();
44
-
45
- expect(html).toContain('"pattern":"/spells/:id"');
46
- expect(html).toContain('"id":"fireball"');
47
- });
48
-
49
- test("head script tag comes before head content", async () => {
50
- const matched = makeMatched("/head-test", "data:text/javascript,export default (p) => '<div>head</div>'");
51
-
52
- const res = await renderRoute(
53
- matched,
54
- new Request("http://localhost/head-test"),
55
- );
56
- const html = await res.text();
57
-
58
- const hydrateScript = html.indexOf("hydrate.js");
59
- const headClosing = html.indexOf("</head>");
60
- // hydrate.js should be in <head>
61
- expect(hydrateScript).toBeGreaterThan(-1);
62
- expect(hydrateScript).toBeLessThan(headClosing);
63
- });
64
-
65
- test("stream chunks arrive incrementally", async () => {
66
- const matched = makeMatched("/chunked", "data:text/javascript,export default (p) => '<div>chunked</div>'");
67
-
68
- const res = await renderRoute(
69
- matched,
70
- new Request("http://localhost/chunked"),
71
- );
72
- const reader = res.body!.getReader();
73
- const chunks: string[] = [];
74
-
75
- while (true) {
76
- const { done, value } = await reader.read();
77
- if (done) break;
78
- // Bun ReadableStream returns Uint8Array
79
- chunks.push(
80
- typeof value === "string"
81
- ? value
82
- : new TextDecoder().decode(value.buffer),
83
- );
84
- }
85
-
86
- // Should have multiple chunks (at least DOCTYPE + body)
87
- expect(chunks.length).toBeGreaterThan(1);
88
- // First chunk should start with DOCTYPE
89
- expect(chunks[0]).toContain("<!DOCTYPE html>");
90
- // Last chunk should close the document
91
- expect(chunks[chunks.length - 1]).toContain("</html>");
92
- });
93
-
94
- test("navigation request returns JSON not stream", async () => {
95
- const matched = makeMatched("/nav", "data:text/javascript,export default (p) => '<div>nav</div>'");
96
-
97
- const req = new Request("http://localhost/nav", {
98
- headers: { "x-grimoire-navigate": "1" },
99
- });
100
- const res = await renderRoute(matched, req);
101
- const json = await res.json();
102
-
103
- expect(json.pattern).toBe("/nav");
104
- expect(json.data).toBeDefined();
105
- });
14
+ test("returns a ReadableStream response", async () => {
15
+ const matched = makeMatched(
16
+ "/",
17
+ "data:text/javascript,export default (p) => '<div>hello</div>'",
18
+ );
19
+ const res = await renderRoute(matched, new Request("http://localhost/"));
20
+ expect(res.body).toBeInstanceOf(ReadableStream);
21
+ });
22
+
23
+ test("stream contains full HTML document", async () => {
24
+ const matched = makeMatched(
25
+ "/test",
26
+ "data:text/javascript,export default (p) => '<p>streaming works</p>'",
27
+ );
28
+
29
+ const res = await renderRoute(
30
+ matched,
31
+ new Request("http://localhost/test"),
32
+ );
33
+ const html = await res.text();
34
+
35
+ expect(html).toContain("<!DOCTYPE html>");
36
+ expect(html).toContain('<meta charset="UTF-8" />');
37
+ expect(html).toContain("streaming works");
38
+ expect(html).toContain("</html>");
39
+ expect(html).toContain("__grimoire_state__");
40
+ });
41
+
42
+ test("stream includes state JSON with route pattern", async () => {
43
+ const matched = makeMatched(
44
+ "/spells/:id",
45
+ "data:text/javascript,export default (p) => '<div>spell</div>'",
46
+ { id: "fireball" },
47
+ );
48
+
49
+ const res = await renderRoute(
50
+ matched,
51
+ new Request("http://localhost/spells/fireball"),
52
+ );
53
+ const html = await res.text();
54
+
55
+ expect(html).toContain('"pattern":"/spells/:id"');
56
+ expect(html).toContain('"id":"fireball"');
57
+ });
58
+
59
+ test("head script tag comes before head content", async () => {
60
+ const matched = makeMatched(
61
+ "/head-test",
62
+ "data:text/javascript,export default (p) => '<div>head</div>'",
63
+ );
64
+
65
+ const res = await renderRoute(
66
+ matched,
67
+ new Request("http://localhost/head-test"),
68
+ );
69
+ const html = await res.text();
70
+
71
+ const hydrateScript = html.indexOf("hydrate.js");
72
+ const headClosing = html.indexOf("</head>");
73
+ // hydrate.js should be in <head>
74
+ expect(hydrateScript).toBeGreaterThan(-1);
75
+ expect(hydrateScript).toBeLessThan(headClosing);
76
+ });
77
+
78
+ test("stream chunks arrive incrementally", async () => {
79
+ const matched = makeMatched(
80
+ "/chunked",
81
+ "data:text/javascript,export default (p) => '<div>chunked</div>'",
82
+ );
83
+
84
+ const res = await renderRoute(
85
+ matched,
86
+ new Request("http://localhost/chunked"),
87
+ );
88
+ const reader = res.body!.getReader();
89
+ const chunks: string[] = [];
90
+
91
+ while (true) {
92
+ const { done, value } = await reader.read();
93
+ if (done) break;
94
+ // Bun ReadableStream returns Uint8Array
95
+ chunks.push(
96
+ typeof value === "string"
97
+ ? value
98
+ : new TextDecoder().decode(value.buffer),
99
+ );
100
+ }
101
+
102
+ // Should have multiple chunks (at least DOCTYPE + body)
103
+ expect(chunks.length).toBeGreaterThan(1);
104
+ // First chunk should start with DOCTYPE
105
+ expect(chunks[0]).toContain("<!DOCTYPE html>");
106
+ // Last chunk should close the document
107
+ expect(chunks[chunks.length - 1]).toContain("</html>");
108
+ });
109
+
110
+ test("navigation request returns JSON not stream", async () => {
111
+ const matched = makeMatched(
112
+ "/nav",
113
+ "data:text/javascript,export default (p) => '<div>nav</div>'",
114
+ );
115
+
116
+ const req = new Request("http://localhost/nav", {
117
+ headers: { "x-grimoire-navigate": "1" },
118
+ });
119
+ const res = await renderRoute(matched, req);
120
+ const json = await res.json();
121
+
122
+ expect(json.pattern).toBe("/nav");
123
+ expect(json.data).toBeDefined();
124
+ });
106
125
  });