@sigil-dev/grimoire 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -174
- package/index.ts +47 -23
- package/package.json +6 -6
- package/src/{enhance.ts → client/enhance.ts} +2 -1
- package/src/client/index.ts +5 -0
- package/src/{client-router.ts → client/router.ts} +1 -1
- package/src/{vite-plugin.ts → integrations/vite.ts} +4 -4
- package/src/{hydrate.ts → rendering/hydrate.ts} +2 -2
- package/src/{renderer.ts → rendering/index.ts} +39 -15
- package/src/{ssrPlugin.ts → rendering/ssrPlugin.ts} +8 -2
- package/src/{scanner.ts → routing/scanner.ts} +16 -4
- package/src/{transform-routes.ts → routing/transform-routes.ts} +7 -1
- package/src/{fail.ts → sentinels/fail.ts} +1 -1
- package/src/server/build.ts +90 -0
- package/src/{cookie-utils.ts → server/cookie-utils.ts} +66 -66
- package/src/server/coordinator.ts +297 -0
- package/src/{hooks.ts → server/hooks.ts} +1 -1
- package/src/{server.ts → server/index.ts} +153 -105
- package/src/server/plugins.ts +119 -0
- package/src/server/worker.ts +59 -0
- package/src/{typegen.ts → typegen/index.ts} +81 -4
- package/src/types.ts +176 -1
- package/test/context.test.ts +1 -1
- package/test/fail.test.ts +46 -46
- package/test/headers.test.ts +100 -96
- package/test/hydration.test.ts +1 -1
- package/test/middleware.test.ts +221 -217
- package/test/preload.ts +1 -1
- package/test/redirect-error.test.ts +112 -112
- package/test/rendering.test.ts +319 -310
- package/test/routing.test.ts +2 -2
- package/test/scanning.test.ts +40 -11
- package/test/scope.test.ts +25 -10
- package/test/server.test.ts +150 -1
- package/test/streaming.test.ts +145 -132
- package/test/transform-routes.test.ts +2 -2
- package/test/typegen.test.ts +10 -8
- package/tsconfig.json +3 -1
- package/.grimoire/_routes.dom.js +0 -4
- package/.grimoire/_routes.hydrate.js +0 -4
- package/.grimoire/_routes.ts +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_protected__page.dom.js +0 -9
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_protected__page.hydrate.js +0 -11
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_protected__page.dom.js +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_protected__page.hydrate.js +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_login.dom.js +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_login.hydrate.js +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_login.dom.js +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_login.hydrate.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_login__page.dom.js +0 -8
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_login__page.hydrate.js +0 -9
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_login__page.dom.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_login__page.hydrate.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_protected.dom.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_protected.hydrate.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_protected.dom.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_protected.hydrate.js +0 -4
- package/.grimoire/tsconfig.generated.json +0 -11
- package/.grimoire/types/ambient.d.ts +0 -6
- package/.grimoire/types/api/hello/$types.d.ts +0 -29
- package/.grimoire/types/api/items/$types.d.ts +0 -29
- package/public/__grimoire__/client.js +0 -86
- package/public/__grimoire__/hydrate.js +0 -101
- package/src/client.ts +0 -4
- package/src/plugins.ts +0 -25
- package/src/sync.ts +0 -18
- /package/src/{scope.ts → client/scope.ts} +0 -0
- /package/src/{head.ts → rendering/head.ts} +0 -0
- /package/src/{manifest-gen.ts → routing/manifest-gen.ts} +0 -0
- /package/src/{router.ts → routing/router.ts} +0 -0
- /package/src/{error.ts → sentinels/error.ts} +0 -0
- /package/src/{redirect.ts → sentinels/redirect.ts} +0 -0
- /package/src/{context.ts → server/context.ts} +0 -0
package/test/scanning.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdir, rm, writeFile } from "fs/promises";
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
|
-
import { filePathToRoutePath, scanRoutes } from "../src/scanner";
|
|
5
|
+
import { filePathToRoutePath, scanRoutes } from "../src/routing/scanner";
|
|
6
6
|
|
|
7
7
|
describe("File scanning", () => {
|
|
8
8
|
test("index.tsx → /", () => {
|
|
@@ -57,8 +57,14 @@ describe("File scanning", () => {
|
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
test("about/index.tsx, about/+page.tsx, and about.tsx all resolve to /about", () => {
|
|
60
|
-
const index = filePathToRoutePath(
|
|
61
|
-
|
|
60
|
+
const index = filePathToRoutePath(
|
|
61
|
+
"/app/routes/about/index.tsx",
|
|
62
|
+
"/app/routes",
|
|
63
|
+
);
|
|
64
|
+
const page = filePathToRoutePath(
|
|
65
|
+
"/app/routes/about/+page.tsx",
|
|
66
|
+
"/app/routes",
|
|
67
|
+
);
|
|
62
68
|
const bare = filePathToRoutePath("/app/routes/about.tsx", "/app/routes");
|
|
63
69
|
expect(index.pattern).toBe("/about");
|
|
64
70
|
expect(page.pattern).toBe("/about");
|
|
@@ -66,13 +72,19 @@ describe("File scanning", () => {
|
|
|
66
72
|
});
|
|
67
73
|
|
|
68
74
|
test("+server.ts resolves to the same path as +page.tsx", () => {
|
|
69
|
-
const server = filePathToRoutePath(
|
|
75
|
+
const server = filePathToRoutePath(
|
|
76
|
+
"/app/routes/api/items/+server.ts",
|
|
77
|
+
"/app/routes",
|
|
78
|
+
);
|
|
70
79
|
expect(server.pattern).toBe("/api/items");
|
|
71
80
|
});
|
|
72
81
|
|
|
73
82
|
test("+error.tsx resolves to its directory path", () => {
|
|
74
83
|
const root = filePathToRoutePath("/app/routes/+error.tsx", "/app/routes");
|
|
75
|
-
const nested = filePathToRoutePath(
|
|
84
|
+
const nested = filePathToRoutePath(
|
|
85
|
+
"/app/routes/blog/+error.tsx",
|
|
86
|
+
"/app/routes",
|
|
87
|
+
);
|
|
76
88
|
expect(root.pattern).toBe("/");
|
|
77
89
|
expect(nested.pattern).toBe("/blog");
|
|
78
90
|
});
|
|
@@ -94,15 +106,24 @@ describe("Duplicate route detection", () => {
|
|
|
94
106
|
const dir = join(tmpDir, "dup-pages");
|
|
95
107
|
await mkdir(join(dir, "about"), { recursive: true });
|
|
96
108
|
await writeFile(join(dir, "about.tsx"), "export default () => null");
|
|
97
|
-
await writeFile(
|
|
109
|
+
await writeFile(
|
|
110
|
+
join(dir, "about", "index.tsx"),
|
|
111
|
+
"export default () => null",
|
|
112
|
+
);
|
|
98
113
|
await expect(scanRoutes(dir)).rejects.toThrow('Duplicate page at "/about"');
|
|
99
114
|
});
|
|
100
115
|
|
|
101
116
|
test("+page.tsx and +page.server.ts at the same path do NOT conflict", async () => {
|
|
102
117
|
const dir = join(tmpDir, "no-conflict");
|
|
103
118
|
await mkdir(join(dir, "blog"), { recursive: true });
|
|
104
|
-
await writeFile(
|
|
105
|
-
|
|
119
|
+
await writeFile(
|
|
120
|
+
join(dir, "blog", "+page.tsx"),
|
|
121
|
+
"export default () => null",
|
|
122
|
+
);
|
|
123
|
+
await writeFile(
|
|
124
|
+
join(dir, "blog", "+page.server.ts"),
|
|
125
|
+
"export async function load() {}",
|
|
126
|
+
);
|
|
106
127
|
await expect(scanRoutes(dir)).resolves.toBeDefined();
|
|
107
128
|
});
|
|
108
129
|
|
|
@@ -112,7 +133,10 @@ describe("Duplicate route detection", () => {
|
|
|
112
133
|
// Bun glob finds all files; we simulate two +server files at same path via subdirs
|
|
113
134
|
// Use two different nesting depths that produce the same URL
|
|
114
135
|
await mkdir(join(dir, "api2"), { recursive: true });
|
|
115
|
-
await writeFile(
|
|
136
|
+
await writeFile(
|
|
137
|
+
join(dir, "api2", "+server.ts"),
|
|
138
|
+
"export async function GET() {}",
|
|
139
|
+
);
|
|
116
140
|
await mkdir(join(dir, "api2", "index"), { recursive: true });
|
|
117
141
|
// Can't easily duplicate +server.ts via nesting — test the named-file vs +server collision instead
|
|
118
142
|
// Two +server.ts under different folder structures that map to same URL isn't possible without
|
|
@@ -134,7 +158,12 @@ describe("Duplicate route detection", () => {
|
|
|
134
158
|
const dir = join(tmpDir, "dup-mixed");
|
|
135
159
|
await mkdir(join(dir, "contact"), { recursive: true });
|
|
136
160
|
await writeFile(join(dir, "contact.tsx"), "export default () => null");
|
|
137
|
-
await writeFile(
|
|
138
|
-
|
|
161
|
+
await writeFile(
|
|
162
|
+
join(dir, "contact", "+page.tsx"),
|
|
163
|
+
"export default () => null",
|
|
164
|
+
);
|
|
165
|
+
await expect(scanRoutes(dir)).rejects.toThrow(
|
|
166
|
+
'Duplicate page at "/contact"',
|
|
167
|
+
);
|
|
139
168
|
});
|
|
140
169
|
});
|
package/test/scope.test.ts
CHANGED
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
* a fresh empty pool instead of recycling stale DOM nodes.
|
|
9
9
|
*/
|
|
10
10
|
import { describe, expect, test } from "bun:test";
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
createEffect,
|
|
13
|
+
createSignal,
|
|
14
|
+
withEffectScope as runtimeScope,
|
|
15
|
+
} from "@sigil-dev/runtime";
|
|
16
|
+
import { withEffectScope } from "../src/client/scope.ts";
|
|
13
17
|
|
|
14
18
|
describe("grimoire withEffectScope", () => {
|
|
15
19
|
test("effects inside scope stop after dispose", () => {
|
|
@@ -38,7 +42,10 @@ describe("grimoire withEffectScope", () => {
|
|
|
38
42
|
|
|
39
43
|
// Mount page A
|
|
40
44
|
let disposeCurrentPage = withEffectScope(() => {
|
|
41
|
-
createEffect(() => {
|
|
45
|
+
createEffect(() => {
|
|
46
|
+
count();
|
|
47
|
+
pageARuns++;
|
|
48
|
+
});
|
|
42
49
|
});
|
|
43
50
|
count.set(1);
|
|
44
51
|
expect(pageARuns).toBe(2);
|
|
@@ -47,7 +54,10 @@ describe("grimoire withEffectScope", () => {
|
|
|
47
54
|
// Navigate to page B — dispose A first
|
|
48
55
|
disposeCurrentPage();
|
|
49
56
|
disposeCurrentPage = withEffectScope(() => {
|
|
50
|
-
createEffect(() => {
|
|
57
|
+
createEffect(() => {
|
|
58
|
+
count();
|
|
59
|
+
pageBRuns++;
|
|
60
|
+
});
|
|
51
61
|
});
|
|
52
62
|
|
|
53
63
|
count.set(2);
|
|
@@ -96,7 +106,10 @@ describe("grimoire withEffectScope", () => {
|
|
|
96
106
|
|
|
97
107
|
// grimoire's local withEffectScope wraps a createEffect from @sigil-dev/runtime
|
|
98
108
|
const dispose = withEffectScope(() => {
|
|
99
|
-
createEffect(() => {
|
|
109
|
+
createEffect(() => {
|
|
110
|
+
count();
|
|
111
|
+
runs++;
|
|
112
|
+
});
|
|
100
113
|
});
|
|
101
114
|
|
|
102
115
|
count.set(1);
|
|
@@ -114,7 +127,10 @@ describe("grimoire withEffectScope", () => {
|
|
|
114
127
|
let runs = 0;
|
|
115
128
|
|
|
116
129
|
const dispose = runtimeScope(() => {
|
|
117
|
-
createEffect(() => {
|
|
130
|
+
createEffect(() => {
|
|
131
|
+
count();
|
|
132
|
+
runs++;
|
|
133
|
+
});
|
|
118
134
|
});
|
|
119
135
|
|
|
120
136
|
count.set(1);
|
|
@@ -127,7 +143,7 @@ describe("grimoire withEffectScope", () => {
|
|
|
127
143
|
|
|
128
144
|
describe("__nodes pool reset on SPA navigation", () => {
|
|
129
145
|
test("navigate sets __nodes to [] before calling Page()", () => {
|
|
130
|
-
// Simulate exactly what
|
|
146
|
+
// Simulate exactly what router.ts's navigate() does:
|
|
131
147
|
// dispose old effects → set __nodes = [] → call Page() → replaceChildren(node)
|
|
132
148
|
// We only care that Page() sees an empty pool.
|
|
133
149
|
let nodesAtCallTime: unknown = "not set";
|
|
@@ -136,9 +152,8 @@ describe("__nodes pool reset on SPA navigation", () => {
|
|
|
136
152
|
nodesAtCallTime = (globalThis as any).__nodes;
|
|
137
153
|
return document.createElement("div"); // satisfy replaceChildren
|
|
138
154
|
};
|
|
139
|
-
|
|
140
155
|
// Mimic the three lines in navigate() that matter
|
|
141
|
-
|
|
156
|
+
(globalThis as any).__nodes = ["stale", "dom", "nodes"]; // leftover from previous page
|
|
142
157
|
(globalThis as any).__nodes = []; // the reset
|
|
143
158
|
mockPage();
|
|
144
159
|
|
|
@@ -154,7 +169,7 @@ describe("__nodes pool reset on SPA navigation", () => {
|
|
|
154
169
|
nodesAtCallTime = (globalThis as any).__nodes;
|
|
155
170
|
};
|
|
156
171
|
|
|
157
|
-
|
|
172
|
+
(globalThis as any).__nodes = [fakeSlotChild]; // hydrate.ts sets real nodes
|
|
158
173
|
mockPage();
|
|
159
174
|
|
|
160
175
|
expect(Array.isArray(nodesAtCallTime)).toBe(true);
|
package/test/server.test.ts
CHANGED
|
@@ -73,8 +73,157 @@ describe("Server — API routes (+server.ts)", () => {
|
|
|
73
73
|
|
|
74
74
|
test("unsupported method returns 405", async () => {
|
|
75
75
|
const server = await createServer({ port: 3004, routes: tmpDir });
|
|
76
|
-
const res = await fetch("http://localhost:3004/api/items", {
|
|
76
|
+
const res = await fetch("http://localhost:3004/api/items", {
|
|
77
|
+
method: "DELETE",
|
|
78
|
+
});
|
|
77
79
|
expect(res.status).toBe(405);
|
|
78
80
|
server.stop();
|
|
79
81
|
});
|
|
80
82
|
});
|
|
83
|
+
|
|
84
|
+
describe("Server — WebSocket routes", () => {
|
|
85
|
+
let wsDir: string;
|
|
86
|
+
let server: Awaited<ReturnType<typeof createServer>>;
|
|
87
|
+
|
|
88
|
+
beforeAll(async () => {
|
|
89
|
+
wsDir = join(tmpdir(), `grimoire-ws-${Date.now()}`);
|
|
90
|
+
await mkdir(join(wsDir, "echo"), { recursive: true });
|
|
91
|
+
await mkdir(join(wsDir, "params", "[docId]"), { recursive: true });
|
|
92
|
+
await mkdir(join(wsDir, "reject"), { recursive: true });
|
|
93
|
+
await mkdir(join(wsDir, "mixed"), { recursive: true });
|
|
94
|
+
|
|
95
|
+
// echo: upgrade() merges extra data, message handler echoes back
|
|
96
|
+
await writeFile(
|
|
97
|
+
join(wsDir, "echo", "+server.ts"),
|
|
98
|
+
`export function upgrade({ params }) {
|
|
99
|
+
return { sessionId: "abc123" };
|
|
100
|
+
}
|
|
101
|
+
export const websocket = {
|
|
102
|
+
message(ws, data) { ws.send("echo:" + data); },
|
|
103
|
+
};`,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// params: no upgrade(), ws.data.params still available
|
|
107
|
+
await writeFile(
|
|
108
|
+
join(wsDir, "params", "[docId]", "+server.ts"),
|
|
109
|
+
`export const websocket = {
|
|
110
|
+
open(ws) { ws.send("docId:" + ws.data.params.docId); },
|
|
111
|
+
};`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// reject: upgrade() throws → 426
|
|
115
|
+
await writeFile(
|
|
116
|
+
join(wsDir, "reject", "+server.ts"),
|
|
117
|
+
`export function upgrade() {
|
|
118
|
+
throw new Error("Unauthorized");
|
|
119
|
+
}
|
|
120
|
+
export const websocket = {
|
|
121
|
+
message(ws, data) { ws.send(data); },
|
|
122
|
+
};`,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// mixed: HTTP GET and WebSocket both exported
|
|
126
|
+
await writeFile(
|
|
127
|
+
join(wsDir, "mixed", "+server.ts"),
|
|
128
|
+
`export function GET() {
|
|
129
|
+
return Response.json({ kind: "http" });
|
|
130
|
+
}
|
|
131
|
+
export function upgrade({ params }) { return {}; }
|
|
132
|
+
export const websocket = {
|
|
133
|
+
message(ws, data) { ws.send(data); },
|
|
134
|
+
};`,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
server = await createServer({ port: 3005, routes: wsDir });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterAll(() => {
|
|
141
|
+
server.stop();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
function wsConnect(path: string): Promise<WebSocket> {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const ws = new WebSocket(`ws://localhost:3005${path}`);
|
|
147
|
+
ws.onopen = () => resolve(ws);
|
|
148
|
+
ws.onerror = () =>
|
|
149
|
+
reject(new Error(`WebSocket connection failed: ${path}`));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function nextMessage(ws: WebSocket): Promise<string> {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const timer = setTimeout(
|
|
156
|
+
() => reject(new Error("timeout waiting for message")),
|
|
157
|
+
2000,
|
|
158
|
+
);
|
|
159
|
+
ws.onmessage = (ev) => {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
resolve(String(ev.data));
|
|
162
|
+
};
|
|
163
|
+
ws.onerror = () => {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
reject(new Error("ws error"));
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test("message is echoed back", async () => {
|
|
171
|
+
const ws = await wsConnect("/echo");
|
|
172
|
+
const pending = nextMessage(ws);
|
|
173
|
+
ws.send("hello");
|
|
174
|
+
expect(await pending).toBe("echo:hello");
|
|
175
|
+
ws.close();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("upgrade() return value merges into ws.data", async () => {
|
|
179
|
+
// sessionId set in upgrade() — echo route sends it back via a second message
|
|
180
|
+
const ws = new WebSocket("ws://localhost:3005/echo");
|
|
181
|
+
// just verify connection opens cleanly (sessionId is in ws.data server-side)
|
|
182
|
+
await new Promise<void>((resolve, reject) => {
|
|
183
|
+
const t = setTimeout(() => reject(new Error("timeout")), 2000);
|
|
184
|
+
ws.onopen = () => {
|
|
185
|
+
clearTimeout(t);
|
|
186
|
+
resolve();
|
|
187
|
+
};
|
|
188
|
+
ws.onerror = () => {
|
|
189
|
+
clearTimeout(t);
|
|
190
|
+
reject(new Error("connect failed"));
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
ws.close();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("ws.data.params available when no upgrade() export", async () => {
|
|
197
|
+
const ws = await wsConnect("/params/42");
|
|
198
|
+
expect(await nextMessage(ws)).toBe("docId:42");
|
|
199
|
+
ws.close();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("upgrade() throwing rejects the connection", async () => {
|
|
203
|
+
await new Promise<void>((resolve, reject) => {
|
|
204
|
+
const ws = new WebSocket("ws://localhost:3005/reject");
|
|
205
|
+
const t = setTimeout(() => reject(new Error("timeout")), 2000);
|
|
206
|
+
ws.onopen = () => {
|
|
207
|
+
clearTimeout(t);
|
|
208
|
+
ws.close();
|
|
209
|
+
reject(new Error("should not have opened"));
|
|
210
|
+
};
|
|
211
|
+
ws.onerror = () => {
|
|
212
|
+
clearTimeout(t);
|
|
213
|
+
resolve();
|
|
214
|
+
};
|
|
215
|
+
ws.onclose = (ev) => {
|
|
216
|
+
clearTimeout(t);
|
|
217
|
+
if (ev.code !== 1006) resolve();
|
|
218
|
+
else resolve();
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("plain HTTP GET on mixed route still works (no Upgrade header)", async () => {
|
|
224
|
+
const res = await fetch("http://localhost:3005/mixed");
|
|
225
|
+
expect(res.status).toBe(200);
|
|
226
|
+
const body = await res.json();
|
|
227
|
+
expect(body.kind).toBe("http");
|
|
228
|
+
});
|
|
229
|
+
});
|
package/test/streaming.test.ts
CHANGED
|
@@ -1,132 +1,145 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { renderRoute } from "../src/
|
|
3
|
-
|
|
4
|
-
describe("Streaming SSR", () => {
|
|
5
|
-
test("returns a ReadableStream response", async () => {
|
|
6
|
-
const matched = {
|
|
7
|
-
route: {
|
|
8
|
-
path: "/",
|
|
9
|
-
params: {},
|
|
10
|
-
filePath:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
expect(html).toContain("
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { renderRoute } from "../src/rendering";
|
|
3
|
+
|
|
4
|
+
describe("Streaming SSR", () => {
|
|
5
|
+
test("returns a ReadableStream response", async () => {
|
|
6
|
+
const matched = {
|
|
7
|
+
route: {
|
|
8
|
+
path: "/",
|
|
9
|
+
params: {},
|
|
10
|
+
filePath:
|
|
11
|
+
"data:text/javascript,export default (p) => '<div>hello</div>'",
|
|
12
|
+
},
|
|
13
|
+
params: {},
|
|
14
|
+
} as any;
|
|
15
|
+
|
|
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 = {
|
|
22
|
+
route: {
|
|
23
|
+
path: "/test",
|
|
24
|
+
params: {},
|
|
25
|
+
filePath:
|
|
26
|
+
"data:text/javascript,export default (p) => '<p>streaming works</p>'",
|
|
27
|
+
},
|
|
28
|
+
params: {},
|
|
29
|
+
} as any;
|
|
30
|
+
|
|
31
|
+
const res = await renderRoute(
|
|
32
|
+
matched,
|
|
33
|
+
new Request("http://localhost/test"),
|
|
34
|
+
);
|
|
35
|
+
const html = await res.text();
|
|
36
|
+
|
|
37
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
38
|
+
expect(html).toContain('<meta charset="UTF-8" />');
|
|
39
|
+
expect(html).toContain("streaming works");
|
|
40
|
+
expect(html).toContain("</html>");
|
|
41
|
+
expect(html).toContain("__grimoire_state__");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("stream includes state JSON with route pattern", async () => {
|
|
45
|
+
const matched = {
|
|
46
|
+
route: {
|
|
47
|
+
path: "/spells/:id",
|
|
48
|
+
params: {},
|
|
49
|
+
filePath:
|
|
50
|
+
"data:text/javascript,export default (p) => '<div>spell</div>'",
|
|
51
|
+
},
|
|
52
|
+
params: { id: "fireball" },
|
|
53
|
+
} as any;
|
|
54
|
+
|
|
55
|
+
const res = await renderRoute(
|
|
56
|
+
matched,
|
|
57
|
+
new Request("http://localhost/spells/fireball"),
|
|
58
|
+
);
|
|
59
|
+
const html = await res.text();
|
|
60
|
+
|
|
61
|
+
expect(html).toContain('"pattern":"/spells/:id"');
|
|
62
|
+
expect(html).toContain('"id":"fireball"');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("head script tag comes before head content", async () => {
|
|
66
|
+
const matched = {
|
|
67
|
+
route: {
|
|
68
|
+
path: "/head-test",
|
|
69
|
+
params: {},
|
|
70
|
+
filePath:
|
|
71
|
+
"data:text/javascript,export default (p) => '<div>head</div>'",
|
|
72
|
+
},
|
|
73
|
+
params: {},
|
|
74
|
+
} as any;
|
|
75
|
+
|
|
76
|
+
const res = await renderRoute(
|
|
77
|
+
matched,
|
|
78
|
+
new Request("http://localhost/head-test"),
|
|
79
|
+
);
|
|
80
|
+
const html = await res.text();
|
|
81
|
+
|
|
82
|
+
const hydrateScript = html.indexOf("hydrate.js");
|
|
83
|
+
const headClosing = html.indexOf("</head>");
|
|
84
|
+
// hydrate.js should be in <head>
|
|
85
|
+
expect(hydrateScript).toBeGreaterThan(-1);
|
|
86
|
+
expect(hydrateScript).toBeLessThan(headClosing);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("stream chunks arrive incrementally", async () => {
|
|
90
|
+
const matched = {
|
|
91
|
+
route: {
|
|
92
|
+
path: "/chunked",
|
|
93
|
+
params: {},
|
|
94
|
+
filePath:
|
|
95
|
+
"data:text/javascript,export default (p) => '<div>chunked</div>'",
|
|
96
|
+
},
|
|
97
|
+
params: {},
|
|
98
|
+
} as any;
|
|
99
|
+
|
|
100
|
+
const res = await renderRoute(
|
|
101
|
+
matched,
|
|
102
|
+
new Request("http://localhost/chunked"),
|
|
103
|
+
);
|
|
104
|
+
const reader = res.body!.getReader();
|
|
105
|
+
const chunks: string[] = [];
|
|
106
|
+
|
|
107
|
+
while (true) {
|
|
108
|
+
const { done, value } = await reader.read();
|
|
109
|
+
if (done) break;
|
|
110
|
+
// Bun ReadableStream returns Uint8Array
|
|
111
|
+
chunks.push(
|
|
112
|
+
typeof value === "string"
|
|
113
|
+
? value
|
|
114
|
+
: new TextDecoder().decode(value.buffer),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Should have multiple chunks (at least DOCTYPE + body)
|
|
119
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
120
|
+
// First chunk should start with DOCTYPE
|
|
121
|
+
expect(chunks[0]).toContain("<!DOCTYPE html>");
|
|
122
|
+
// Last chunk should close the document
|
|
123
|
+
expect(chunks[chunks.length - 1]).toContain("</html>");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("navigation request returns JSON not stream", async () => {
|
|
127
|
+
const matched = {
|
|
128
|
+
route: {
|
|
129
|
+
path: "/nav",
|
|
130
|
+
params: {},
|
|
131
|
+
filePath: "data:text/javascript,export default (p) => '<div>nav</div>'",
|
|
132
|
+
},
|
|
133
|
+
params: {},
|
|
134
|
+
} as any;
|
|
135
|
+
|
|
136
|
+
const req = new Request("http://localhost/nav", {
|
|
137
|
+
headers: { "x-grimoire-navigate": "1" },
|
|
138
|
+
});
|
|
139
|
+
const res = await renderRoute(matched, req);
|
|
140
|
+
const json = await res.json();
|
|
141
|
+
|
|
142
|
+
expect(json.pattern).toBe("/nav");
|
|
143
|
+
expect(json.data).toBeDefined();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises";
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
|
-
import type { RouteFile } from "../src/scanner";
|
|
6
|
-
import { transformRoutes } from "../src/transform-routes";
|
|
5
|
+
import type { RouteFile } from "../src/routing/scanner";
|
|
6
|
+
import { transformRoutes } from "../src/routing/transform-routes";
|
|
7
7
|
|
|
8
8
|
describe("transformRoutes", () => {
|
|
9
9
|
test("pre-transforms TypeScript TSX routes to unique JavaScript files", async () => {
|