@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.
- package/.grimoire/_routes.dom.js +8 -0
- package/.grimoire/_routes.hydrate.js +8 -0
- package/.grimoire/tsconfig.generated.json +11 -0
- package/.grimoire/types/ambient.d.ts +59 -0
- package/.grimoire/types/api/hello/$types.d.ts +50 -0
- package/.grimoire/types/api/items/$types.d.ts +50 -0
- package/.grimoire/types/echo/$types.d.ts +50 -0
- package/.grimoire/types/env-private.d.ts +5 -0
- package/.grimoire/types/env-public.d.ts +5 -0
- package/.grimoire/types/mixed/$types.d.ts +50 -0
- package/.grimoire/types/params/[docId]/$types.d.ts +52 -0
- package/.grimoire/types/reject/$types.d.ts +50 -0
- package/index.ts +21 -20
- package/package.json +13 -7
- package/preload.js +3 -0
- package/public/__grimoire__/hydrate.js +585 -0
- package/public/__grimoire__/index.js +490 -0
- package/server.ts +13 -13
- package/src/client/head.ts +29 -0
- package/src/client/router.ts +254 -40
- package/src/dev/compile-module.ts +173 -0
- package/src/dev/effect-registry.ts +23 -0
- package/src/dev/graph.ts +114 -0
- package/src/dev/hmr-client.ts +158 -0
- package/src/dev/hmr-server.ts +187 -0
- package/src/dev/loader.ts +47 -0
- package/src/dev/paths.ts +14 -0
- package/src/dev/runtime-bundle.ts +49 -0
- package/src/dev/watcher.ts +44 -0
- package/src/env/index.ts +25 -0
- package/src/env/plugin.ts +13 -0
- package/src/env/private.ts +5 -0
- package/src/env/public.ts +7 -0
- package/src/env/typegen.ts +51 -0
- package/src/integrations/vite.ts +1 -0
- package/src/rendering/head.ts +22 -2
- package/src/rendering/hydrate.ts +111 -18
- package/src/rendering/index.ts +263 -153
- package/src/rendering/ssrPlugin.ts +59 -39
- package/src/routing/manifest-gen.ts +18 -2
- package/src/routing/router.ts +94 -83
- package/src/routing/scanner.ts +26 -14
- package/src/routing/transform-routes.ts +68 -68
- package/src/server/build.ts +225 -76
- package/src/server/coordinator.ts +9 -0
- package/src/server/hooks.ts +24 -3
- package/src/server/index.ts +388 -104
- package/src/typegen/index.ts +30 -14
- package/src/types.ts +12 -2
- package/test/middleware.test.ts +6 -4
- package/test/rendering.test.ts +510 -356
- package/test/routing.test.ts +36 -0
- package/test/scanning.test.ts +39 -8
- package/test/scope.test.ts +24 -8
- package/test/server.test.ts +27 -7
- package/test/streaming.test.ts +117 -98
- package/test/typegen.test.ts +52 -24
- package/tsconfig.json +1 -0
package/test/routing.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/test/scanning.test.ts
CHANGED
|
@@ -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", () => {
|
package/test/scope.test.ts
CHANGED
|
@@ -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
|
-
|
|
35
|
+
await tick(); // let microtask queue flush
|
|
36
|
+
expect(runs).toBe(2);
|
|
31
37
|
|
|
32
38
|
dispose();
|
|
33
|
-
|
|
34
39
|
count.set(2);
|
|
35
|
-
|
|
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
|
});
|
package/test/server.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdir,
|
|
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
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
153
|
+
server = await createServer({
|
|
154
|
+
port: 3005,
|
|
155
|
+
routes: wsDir,
|
|
156
|
+
_skipBuild: true,
|
|
157
|
+
});
|
|
138
158
|
});
|
|
139
159
|
|
|
140
160
|
afterAll(() => {
|
package/test/streaming.test.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
});
|