@sigil-dev/grimoire 0.7.6 → 0.8.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.
Files changed (41) hide show
  1. package/index.ts +35 -34
  2. package/package.json +8 -6
  3. package/preload.js +3 -2
  4. package/server.ts +13 -13
  5. package/src/client/head.ts +29 -29
  6. package/src/client/router.ts +120 -53
  7. package/src/dev/compile-module.ts +173 -0
  8. package/src/dev/effect-registry.ts +23 -0
  9. package/src/dev/graph.ts +114 -0
  10. package/src/dev/hmr-client.ts +158 -0
  11. package/src/dev/hmr-server.ts +187 -0
  12. package/src/dev/loader.ts +47 -0
  13. package/src/dev/paths.ts +14 -0
  14. package/src/dev/runtime-bundle.ts +49 -0
  15. package/src/dev/watcher.ts +44 -0
  16. package/src/integrations/vite.ts +73 -72
  17. package/src/rendering/hydrate.ts +102 -64
  18. package/src/rendering/index.ts +296 -199
  19. package/src/rendering/ssrPlugin.ts +67 -53
  20. package/src/routing/manifest-gen.ts +42 -39
  21. package/src/routing/router.ts +109 -106
  22. package/src/routing/scanner.ts +141 -135
  23. package/src/routing/transform-routes.ts +101 -101
  24. package/src/server/build.ts +239 -147
  25. package/src/server/coordinator.ts +306 -306
  26. package/src/server/index.ts +260 -50
  27. package/src/server/worker.ts +59 -59
  28. package/src/typegen/index.ts +356 -353
  29. package/src/types.ts +270 -269
  30. package/test/context.test.ts +52 -52
  31. package/test/hydration.test.ts +119 -119
  32. package/test/middleware.test.ts +223 -223
  33. package/test/rendering.test.ts +579 -425
  34. package/test/routing.test.ts +81 -83
  35. package/test/scanning.test.ts +200 -181
  36. package/test/scope.test.ts +24 -8
  37. package/test/server.test.ts +249 -229
  38. package/test/streaming.test.ts +125 -106
  39. package/test/transform-routes.test.ts +84 -84
  40. package/test/typegen.test.ts +35 -25
  41. package/tsconfig.json +1 -0
@@ -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,229 +1,249 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdir, rm, writeFile } from "fs/promises";
3
- import { tmpdir } from "os";
4
- import { join } from "path";
5
- import { createServer } from "../src/server";
6
-
7
- // ── Sandbox note ─────────────────────────────────────────────────────────────
8
- // The sigil babel plugin unconditionally inserts `import ... from "@sigil-dev/runtime"`
9
- // for all non-SSR route files. When createServer() calls Bun.build to bundle hydrate.js,
10
- // those compiled page files pull in @sigil-dev/runtime → Bun's test sandbox blocks the
11
- // read of packages/runtime/index.ts.
12
- //
13
- // Consequence: createServer() integration tests can only use +server.ts API routes
14
- // (which are never bundled by Bun.build). Form action behavior at the HTTP level cannot
15
- // be tested here — it is covered at unit level by fail.test.ts and redirect-error.test.ts,
16
- // and at the renderer level by rendering.test.ts.
17
- // ─────────────────────────────────────────────────────────────────────────────
18
-
19
- let tmpDir: string;
20
-
21
- beforeAll(async () => {
22
- tmpDir = join(tmpdir(), `grimoire-server-${Date.now()}`);
23
- await mkdir(join(tmpDir, "api", "hello"), { recursive: true });
24
- await mkdir(join(tmpDir, "api", "items"), { recursive: true });
25
-
26
- await writeFile(
27
- join(tmpDir, "api", "hello", "+server.ts"),
28
- `export async function GET() { return Response.json({ message: "hello" }); }`,
29
- );
30
-
31
- await writeFile(
32
- join(tmpDir, "api", "items", "+server.ts"),
33
- `export async function GET() {
34
- return Response.json([{ id: 1, name: "sword" }]);
35
- }
36
- export async function POST({ request }) {
37
- const body = await request.json();
38
- return Response.json({ id: 2, ...body }, { status: 201 });
39
- }`,
40
- );
41
- });
42
-
43
- describe("Server — API routes (+server.ts)", () => {
44
- test("GET returns JSON", async () => {
45
- const server = await createServer({ port: 3004, routes: tmpDir });
46
- const res = await fetch("http://localhost:3004/api/hello");
47
- const data = await res.json();
48
- expect(data.message).toBe("hello");
49
- server.stop();
50
- });
51
-
52
- test("GET on multi-method route returns correct JSON", async () => {
53
- const server = await createServer({ port: 3004, routes: tmpDir });
54
- const res = await fetch("http://localhost:3004/api/items");
55
- expect(res.status).toBe(200);
56
- const items = await res.json();
57
- expect(items[0].name).toBe("sword");
58
- server.stop();
59
- });
60
-
61
- test("POST returns 201 with created resource", async () => {
62
- const server = await createServer({ port: 3004, routes: tmpDir });
63
- const res = await fetch("http://localhost:3004/api/items", {
64
- method: "POST",
65
- headers: { "Content-Type": "application/json" },
66
- body: JSON.stringify({ name: "shield" }),
67
- });
68
- expect(res.status).toBe(201);
69
- const item = await res.json();
70
- expect(item.name).toBe("shield");
71
- server.stop();
72
- });
73
-
74
- test("unsupported method returns 405", async () => {
75
- const server = await createServer({ port: 3004, routes: tmpDir });
76
- const res = await fetch("http://localhost:3004/api/items", {
77
- method: "DELETE",
78
- });
79
- expect(res.status).toBe(405);
80
- server.stop();
81
- });
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
- });
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdir, writeFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { createServer } from "../src/server";
6
+
7
+ // ── Sandbox note ─────────────────────────────────────────────────────────────
8
+ // The Babel plugin unconditionally inserts `import ... from "@sigil-dev/runtime"`
9
+ // for all non-SSR route files. When createServer() calls Bun.build to bundle hydrate.js,
10
+ // those compiled page files pull in @sigil-dev/runtime → Bun's test sandbox blocks the
11
+ // read of packages/runtime/index.ts.
12
+ //
13
+ // Consequence: createServer() integration tests can only use +server.ts API routes
14
+ // (which are never bundled by Bun.build). Form action behavior at the HTTP level cannot
15
+ // be tested here — it is covered at unit level by fail.test.ts and redirect-error.test.ts,
16
+ // and at the renderer level by rendering.test.ts.
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ let tmpDir: string;
20
+
21
+ beforeAll(async () => {
22
+ tmpDir = join(tmpdir(), `grimoire-server-${Date.now()}`);
23
+ await mkdir(join(tmpDir, "api", "hello"), { recursive: true });
24
+ await mkdir(join(tmpDir, "api", "items"), { recursive: true });
25
+
26
+ await writeFile(
27
+ join(tmpDir, "api", "hello", "+server.ts"),
28
+ `export async function GET() { return Response.json({ message: "hello" }); }`,
29
+ );
30
+
31
+ await writeFile(
32
+ join(tmpDir, "api", "items", "+server.ts"),
33
+ `export async function GET() {
34
+ return Response.json([{ id: 1, name: "sword" }]);
35
+ }
36
+ export async function POST({ request }) {
37
+ const body = await request.json();
38
+ return Response.json({ id: 2, ...body }, { status: 201 });
39
+ }`,
40
+ );
41
+ });
42
+
43
+ describe("Server — API routes (+server.ts)", () => {
44
+ test("GET returns JSON", async () => {
45
+ const server = await createServer({
46
+ port: 3004,
47
+ routes: tmpDir,
48
+ _skipBuild: true,
49
+ });
50
+ const res = await fetch("http://localhost:3004/api/hello");
51
+ const data = await res.json();
52
+ expect(data.message).toBe("hello");
53
+ server.stop();
54
+ });
55
+
56
+ test("GET on multi-method route returns correct JSON", async () => {
57
+ const server = await createServer({
58
+ port: 3004,
59
+ routes: tmpDir,
60
+ _skipBuild: true,
61
+ });
62
+ const res = await fetch("http://localhost:3004/api/items");
63
+ expect(res.status).toBe(200);
64
+ const items = await res.json();
65
+ expect(items[0].name).toBe("sword");
66
+ server.stop();
67
+ });
68
+
69
+ test("POST returns 201 with created resource", async () => {
70
+ const server = await createServer({
71
+ port: 3004,
72
+ routes: tmpDir,
73
+ _skipBuild: true,
74
+ });
75
+ const res = await fetch("http://localhost:3004/api/items", {
76
+ method: "POST",
77
+ headers: { "Content-Type": "application/json" },
78
+ body: JSON.stringify({ name: "shield" }),
79
+ });
80
+ expect(res.status).toBe(201);
81
+ const item = await res.json();
82
+ expect(item.name).toBe("shield");
83
+ server.stop();
84
+ });
85
+
86
+ test("unsupported method returns 405", async () => {
87
+ const server = await createServer({
88
+ port: 3004,
89
+ routes: tmpDir,
90
+ _skipBuild: true,
91
+ });
92
+ const res = await fetch("http://localhost:3004/api/items", {
93
+ method: "DELETE",
94
+ });
95
+ expect(res.status).toBe(405);
96
+ server.stop();
97
+ });
98
+ });
99
+
100
+ describe("Server — WebSocket routes", () => {
101
+ let wsDir: string;
102
+ let server: Awaited<ReturnType<typeof createServer>>;
103
+
104
+ beforeAll(async () => {
105
+ wsDir = join(tmpdir(), `grimoire-ws-${Date.now()}`);
106
+ await mkdir(join(wsDir, "echo"), { recursive: true });
107
+ await mkdir(join(wsDir, "params", "[docId]"), { recursive: true });
108
+ await mkdir(join(wsDir, "reject"), { recursive: true });
109
+ await mkdir(join(wsDir, "mixed"), { recursive: true });
110
+
111
+ // echo: upgrade() merges extra data, message handler echoes back
112
+ await writeFile(
113
+ join(wsDir, "echo", "+server.ts"),
114
+ `export function upgrade({ params }) {
115
+ return { sessionId: "abc123" };
116
+ }
117
+ export const websocket = {
118
+ message(ws, data) { ws.send("echo:" + data); },
119
+ };`,
120
+ );
121
+
122
+ // params: no upgrade(), ws.data.params still available
123
+ await writeFile(
124
+ join(wsDir, "params", "[docId]", "+server.ts"),
125
+ `export const websocket = {
126
+ open(ws) { ws.send("docId:" + ws.data.params.docId); },
127
+ };`,
128
+ );
129
+
130
+ // reject: upgrade() throws → 426
131
+ await writeFile(
132
+ join(wsDir, "reject", "+server.ts"),
133
+ `export function upgrade() {
134
+ throw new Error("Unauthorized");
135
+ }
136
+ export const websocket = {
137
+ message(ws, data) { ws.send(data); },
138
+ };`,
139
+ );
140
+
141
+ // mixed: HTTP GET and WebSocket both exported
142
+ await writeFile(
143
+ join(wsDir, "mixed", "+server.ts"),
144
+ `export function GET() {
145
+ return Response.json({ kind: "http" });
146
+ }
147
+ export function upgrade({ params }) { return {}; }
148
+ export const websocket = {
149
+ message(ws, data) { ws.send(data); },
150
+ };`,
151
+ );
152
+
153
+ server = await createServer({
154
+ port: 3005,
155
+ routes: wsDir,
156
+ _skipBuild: true,
157
+ });
158
+ });
159
+
160
+ afterAll(() => {
161
+ server.stop();
162
+ });
163
+
164
+ function wsConnect(path: string): Promise<WebSocket> {
165
+ return new Promise((resolve, reject) => {
166
+ const ws = new WebSocket(`ws://localhost:3005${path}`);
167
+ ws.onopen = () => resolve(ws);
168
+ ws.onerror = () =>
169
+ reject(new Error(`WebSocket connection failed: ${path}`));
170
+ });
171
+ }
172
+
173
+ function nextMessage(ws: WebSocket): Promise<string> {
174
+ return new Promise((resolve, reject) => {
175
+ const timer = setTimeout(
176
+ () => reject(new Error("timeout waiting for message")),
177
+ 2000,
178
+ );
179
+ ws.onmessage = (ev) => {
180
+ clearTimeout(timer);
181
+ resolve(String(ev.data));
182
+ };
183
+ ws.onerror = () => {
184
+ clearTimeout(timer);
185
+ reject(new Error("ws error"));
186
+ };
187
+ });
188
+ }
189
+
190
+ test("message is echoed back", async () => {
191
+ const ws = await wsConnect("/echo");
192
+ const pending = nextMessage(ws);
193
+ ws.send("hello");
194
+ expect(await pending).toBe("echo:hello");
195
+ ws.close();
196
+ });
197
+
198
+ test("upgrade() return value merges into ws.data", async () => {
199
+ // sessionId set in upgrade() — echo route sends it back via a second message
200
+ const ws = new WebSocket("ws://localhost:3005/echo");
201
+ // just verify connection opens cleanly (sessionId is in ws.data server-side)
202
+ await new Promise<void>((resolve, reject) => {
203
+ const t = setTimeout(() => reject(new Error("timeout")), 2000);
204
+ ws.onopen = () => {
205
+ clearTimeout(t);
206
+ resolve();
207
+ };
208
+ ws.onerror = () => {
209
+ clearTimeout(t);
210
+ reject(new Error("connect failed"));
211
+ };
212
+ });
213
+ ws.close();
214
+ });
215
+
216
+ test("ws.data.params available when no upgrade() export", async () => {
217
+ const ws = await wsConnect("/params/42");
218
+ expect(await nextMessage(ws)).toBe("docId:42");
219
+ ws.close();
220
+ });
221
+
222
+ test("upgrade() throwing rejects the connection", async () => {
223
+ await new Promise<void>((resolve, reject) => {
224
+ const ws = new WebSocket("ws://localhost:3005/reject");
225
+ const t = setTimeout(() => reject(new Error("timeout")), 2000);
226
+ ws.onopen = () => {
227
+ clearTimeout(t);
228
+ ws.close();
229
+ reject(new Error("should not have opened"));
230
+ };
231
+ ws.onerror = () => {
232
+ clearTimeout(t);
233
+ resolve();
234
+ };
235
+ ws.onclose = (ev) => {
236
+ clearTimeout(t);
237
+ if (ev.code !== 1006) resolve();
238
+ else resolve();
239
+ };
240
+ });
241
+ });
242
+
243
+ test("plain HTTP GET on mixed route still works (no Upgrade header)", async () => {
244
+ const res = await fetch("http://localhost:3005/mixed");
245
+ expect(res.status).toBe(200);
246
+ const body = await res.json();
247
+ expect(body.kind).toBe("http");
248
+ });
249
+ });