@mandujs/core 0.9.22 → 0.9.24
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/package.json +2 -2
- package/src/runtime/router.test.ts +423 -0
- package/src/runtime/router.ts +467 -48
- package/src/runtime/streaming-ssr.ts +136 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.24",
|
|
4
4
|
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"src/**/*"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
|
-
"test": "bun test",
|
|
17
|
+
"test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
|
|
18
18
|
"test:hydration": "bun test tests/hydration",
|
|
19
19
|
"test:streaming": "bun test tests/streaming-ssr",
|
|
20
20
|
"test:watch": "bun test --watch"
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router v5 Tests
|
|
3
|
+
*
|
|
4
|
+
* Test cases:
|
|
5
|
+
* 1. Static vs Dynamic Priority
|
|
6
|
+
* 2. Parameter Matching
|
|
7
|
+
* 3. Wildcard Matching
|
|
8
|
+
* 4. Security (URI encoding)
|
|
9
|
+
* 5. Validation Errors
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, test, expect } from "bun:test";
|
|
13
|
+
import {
|
|
14
|
+
Router,
|
|
15
|
+
RouterError,
|
|
16
|
+
createRouter,
|
|
17
|
+
WILDCARD_PARAM_KEY,
|
|
18
|
+
} from "./router";
|
|
19
|
+
import type { RouteSpec } from "../spec/schema";
|
|
20
|
+
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
22
|
+
// Test Fixtures
|
|
23
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
function makeRoute(id: string, pattern: string, kind: "page" | "api" = "api"): RouteSpec {
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
pattern,
|
|
29
|
+
kind,
|
|
30
|
+
module: `generated/${id}.route.ts`,
|
|
31
|
+
...(kind === "page" ? { componentModule: `generated/${id}.route.tsx` } : {}),
|
|
32
|
+
} as RouteSpec;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
36
|
+
// 1. Static vs Dynamic Priority
|
|
37
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
38
|
+
|
|
39
|
+
describe("Static vs Dynamic Priority", () => {
|
|
40
|
+
test("static route takes precedence over param route", () => {
|
|
41
|
+
const router = createRouter([
|
|
42
|
+
makeRoute("todos-item", "/api/todos/:id"),
|
|
43
|
+
makeRoute("todos-stats", "/api/todos/stats"),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const result = router.match("/api/todos/stats");
|
|
47
|
+
|
|
48
|
+
expect(result).not.toBeNull();
|
|
49
|
+
expect(result!.route.id).toBe("todos-stats");
|
|
50
|
+
expect(result!.params).toEqual({});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("static route precedence regardless of registration order", () => {
|
|
54
|
+
// Register static AFTER dynamic
|
|
55
|
+
const router = createRouter([
|
|
56
|
+
makeRoute("users-item", "/users/:id"),
|
|
57
|
+
makeRoute("users-me", "/users/me"),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
expect(router.match("/users/me")!.route.id).toBe("users-me");
|
|
61
|
+
expect(router.match("/users/123")!.route.id).toBe("users-item");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("root path matching", () => {
|
|
65
|
+
const router = createRouter([
|
|
66
|
+
makeRoute("home", "/", "page"),
|
|
67
|
+
makeRoute("api", "/api"),
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
expect(router.match("/")!.route.id).toBe("home");
|
|
71
|
+
expect(router.match("/api")!.route.id).toBe("api");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
76
|
+
// 2. Parameter Matching
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
78
|
+
|
|
79
|
+
describe("Parameter Matching", () => {
|
|
80
|
+
test("extracts single param correctly", () => {
|
|
81
|
+
const router = createRouter([
|
|
82
|
+
makeRoute("todos-item", "/api/todos/:id"),
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const result = router.match("/api/todos/123");
|
|
86
|
+
|
|
87
|
+
expect(result).not.toBeNull();
|
|
88
|
+
expect(result!.route.id).toBe("todos-item");
|
|
89
|
+
expect(result!.params).toEqual({ id: "123" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("extracts multiple params correctly", () => {
|
|
93
|
+
const router = createRouter([
|
|
94
|
+
makeRoute("user-post", "/users/:userId/posts/:postId"),
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
const result = router.match("/users/42/posts/99");
|
|
98
|
+
|
|
99
|
+
expect(result).not.toBeNull();
|
|
100
|
+
expect(result!.params).toEqual({ userId: "42", postId: "99" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("decodes UTF-8 encoded params", () => {
|
|
104
|
+
const router = createRouter([
|
|
105
|
+
makeRoute("user", "/user/:name"),
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
// café encoded as caf%C3%A9
|
|
109
|
+
const result = router.match("/user/caf%C3%A9");
|
|
110
|
+
|
|
111
|
+
expect(result).not.toBeNull();
|
|
112
|
+
expect(result!.params).toEqual({ name: "café" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("handles non-ASCII static routes", () => {
|
|
116
|
+
const router = createRouter([
|
|
117
|
+
makeRoute("cafe", "/café", "page"),
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
expect(router.match("/café")!.route.id).toBe("cafe");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
125
|
+
// 3. Wildcard Matching
|
|
126
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
127
|
+
|
|
128
|
+
describe("Wildcard Matching", () => {
|
|
129
|
+
test("matches wildcard with remaining path", () => {
|
|
130
|
+
const router = createRouter([
|
|
131
|
+
makeRoute("files", "/files/*"),
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
const result = router.match("/files/a/b/c");
|
|
135
|
+
|
|
136
|
+
expect(result).not.toBeNull();
|
|
137
|
+
expect(result!.route.id).toBe("files");
|
|
138
|
+
expect(result!.params).toEqual({ [WILDCARD_PARAM_KEY]: "a/b/c" });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("wildcard with single segment", () => {
|
|
142
|
+
const router = createRouter([
|
|
143
|
+
makeRoute("docs", "/docs/*"),
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
const result = router.match("/docs/readme");
|
|
147
|
+
|
|
148
|
+
expect(result!.params).toEqual({ [WILDCARD_PARAM_KEY]: "readme" });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("Policy A: wildcard does NOT match base path", () => {
|
|
152
|
+
const router = createRouter([
|
|
153
|
+
makeRoute("files", "/files/*"),
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
// /files/* should NOT match /files
|
|
157
|
+
expect(router.match("/files")).toBeNull();
|
|
158
|
+
expect(router.match("/files/")).toBeNull(); // normalized to /files
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("static route takes precedence over wildcard", () => {
|
|
162
|
+
const router = createRouter([
|
|
163
|
+
makeRoute("files-wildcard", "/files/*"),
|
|
164
|
+
makeRoute("files-readme", "/files/readme"),
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
expect(router.match("/files/readme")!.route.id).toBe("files-readme");
|
|
168
|
+
expect(router.match("/files/other")!.route.id).toBe("files-wildcard");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
173
|
+
// 4. Security (URI Encoding)
|
|
174
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
175
|
+
|
|
176
|
+
describe("Security", () => {
|
|
177
|
+
test("blocks %2F (encoded slash) in path segments", () => {
|
|
178
|
+
const router = createRouter([
|
|
179
|
+
makeRoute("user", "/user/:name"),
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
// a%2Fb = a/b encoded
|
|
183
|
+
const result = router.match("/user/a%2Fb");
|
|
184
|
+
|
|
185
|
+
expect(result).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("blocks double-encoded slash (%252F)", () => {
|
|
189
|
+
const router = createRouter([
|
|
190
|
+
makeRoute("user", "/user/:name"),
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
// %252F decodes to %2F
|
|
194
|
+
const result = router.match("/user/%252F");
|
|
195
|
+
|
|
196
|
+
expect(result).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("blocks malformed UTF-8 encoding", () => {
|
|
200
|
+
const router = createRouter([
|
|
201
|
+
makeRoute("user", "/user/:name"),
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
// Invalid UTF-8 sequence
|
|
205
|
+
const result = router.match("/user/%C0%AE");
|
|
206
|
+
|
|
207
|
+
expect(result).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("allows valid percent-encoded characters", () => {
|
|
211
|
+
const router = createRouter([
|
|
212
|
+
makeRoute("search", "/search/:query"),
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
// hello%20world = "hello world"
|
|
216
|
+
const result = router.match("/search/hello%20world");
|
|
217
|
+
|
|
218
|
+
expect(result).not.toBeNull();
|
|
219
|
+
expect(result!.params).toEqual({ query: "hello world" });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
224
|
+
// 5. Validation Errors
|
|
225
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
226
|
+
|
|
227
|
+
describe("Validation Errors", () => {
|
|
228
|
+
test("throws DUPLICATE_PATTERN for same pattern", () => {
|
|
229
|
+
expect(() => {
|
|
230
|
+
createRouter([
|
|
231
|
+
makeRoute("route1", "/api/users"),
|
|
232
|
+
makeRoute("route2", "/api/users"),
|
|
233
|
+
]);
|
|
234
|
+
}).toThrow(RouterError);
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
createRouter([
|
|
238
|
+
makeRoute("route1", "/api/users"),
|
|
239
|
+
makeRoute("route2", "/api/users"),
|
|
240
|
+
]);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
expect(e).toBeInstanceOf(RouterError);
|
|
243
|
+
expect((e as RouterError).code).toBe("DUPLICATE_PATTERN");
|
|
244
|
+
expect((e as RouterError).routeId).toBe("route2");
|
|
245
|
+
expect((e as RouterError).conflictsWith).toBe("route1");
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("throws DUPLICATE_PATTERN for normalized duplicates (trailing slash)", () => {
|
|
250
|
+
expect(() => {
|
|
251
|
+
createRouter([
|
|
252
|
+
makeRoute("route1", "/api/users"),
|
|
253
|
+
makeRoute("route2", "/api/users/"),
|
|
254
|
+
]);
|
|
255
|
+
}).toThrow(RouterError);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
createRouter([
|
|
259
|
+
makeRoute("route1", "/api/users"),
|
|
260
|
+
makeRoute("route2", "/api/users/"),
|
|
261
|
+
]);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
expect((e as RouterError).code).toBe("DUPLICATE_PATTERN");
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("throws PARAM_NAME_CONFLICT for same-depth param mismatch", () => {
|
|
268
|
+
expect(() => {
|
|
269
|
+
createRouter([
|
|
270
|
+
makeRoute("users", "/users/:id"),
|
|
271
|
+
makeRoute("users-by-name", "/users/:name"),
|
|
272
|
+
]);
|
|
273
|
+
}).toThrow(RouterError);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
createRouter([
|
|
277
|
+
makeRoute("users", "/users/:id"),
|
|
278
|
+
makeRoute("users-by-name", "/users/:name"),
|
|
279
|
+
]);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
expect((e as RouterError).code).toBe("PARAM_NAME_CONFLICT");
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("allows same param name across different paths", () => {
|
|
286
|
+
// These should NOT conflict - different parent paths
|
|
287
|
+
const router = createRouter([
|
|
288
|
+
makeRoute("users", "/users/:id"),
|
|
289
|
+
makeRoute("posts", "/posts/:id"),
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
expect(router.match("/users/1")!.params).toEqual({ id: "1" });
|
|
293
|
+
expect(router.match("/posts/2")!.params).toEqual({ id: "2" });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("throws WILDCARD_NOT_LAST for non-terminal wildcard", () => {
|
|
297
|
+
expect(() => {
|
|
298
|
+
createRouter([
|
|
299
|
+
makeRoute("invalid", "/files/*/more"),
|
|
300
|
+
]);
|
|
301
|
+
}).toThrow(RouterError);
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
createRouter([
|
|
305
|
+
makeRoute("invalid", "/files/*/more"),
|
|
306
|
+
]);
|
|
307
|
+
} catch (e) {
|
|
308
|
+
expect((e as RouterError).code).toBe("WILDCARD_NOT_LAST");
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
314
|
+
// 6. Router API
|
|
315
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
316
|
+
|
|
317
|
+
describe("Router API", () => {
|
|
318
|
+
test("getStats returns correct counts", () => {
|
|
319
|
+
const router = createRouter([
|
|
320
|
+
makeRoute("home", "/"),
|
|
321
|
+
makeRoute("health", "/api/health"),
|
|
322
|
+
makeRoute("todos-item", "/api/todos/:id"),
|
|
323
|
+
makeRoute("files", "/files/*"),
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
const stats = router.getStats();
|
|
327
|
+
|
|
328
|
+
expect(stats.staticCount).toBe(2); // / and /api/health
|
|
329
|
+
expect(stats.dynamicCount).toBe(2); // /api/todos/:id and /files/*
|
|
330
|
+
expect(stats.totalRoutes).toBe(4);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("getRoutes returns all registered routes", () => {
|
|
334
|
+
const routes = [
|
|
335
|
+
makeRoute("home", "/"),
|
|
336
|
+
makeRoute("users", "/users/:id"),
|
|
337
|
+
];
|
|
338
|
+
const router = createRouter(routes);
|
|
339
|
+
|
|
340
|
+
const retrieved = router.getRoutes();
|
|
341
|
+
|
|
342
|
+
expect(retrieved.length).toBe(2);
|
|
343
|
+
expect(retrieved.map((r) => r.id).sort()).toEqual(["home", "users"]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("addRoute adds route to existing router", () => {
|
|
347
|
+
const router = createRouter([
|
|
348
|
+
makeRoute("home", "/"),
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
router.addRoute(makeRoute("about", "/about"));
|
|
352
|
+
|
|
353
|
+
expect(router.match("/about")).not.toBeNull();
|
|
354
|
+
expect(router.getStats().totalRoutes).toBe(2);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("addRoute validates against existing routes", () => {
|
|
358
|
+
const router = createRouter([
|
|
359
|
+
makeRoute("home", "/"),
|
|
360
|
+
]);
|
|
361
|
+
|
|
362
|
+
expect(() => {
|
|
363
|
+
router.addRoute(makeRoute("home2", "/"));
|
|
364
|
+
}).toThrow(RouterError);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
369
|
+
// 7. Edge Cases
|
|
370
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
371
|
+
|
|
372
|
+
describe("Edge Cases", () => {
|
|
373
|
+
test("empty routes", () => {
|
|
374
|
+
const router = createRouter([]);
|
|
375
|
+
|
|
376
|
+
expect(router.match("/")).toBeNull();
|
|
377
|
+
expect(router.getStats().totalRoutes).toBe(0);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("deep nested paths", () => {
|
|
381
|
+
const router = createRouter([
|
|
382
|
+
makeRoute("deep", "/a/b/c/d/e/:id"),
|
|
383
|
+
]);
|
|
384
|
+
|
|
385
|
+
const result = router.match("/a/b/c/d/e/123");
|
|
386
|
+
|
|
387
|
+
expect(result!.params).toEqual({ id: "123" });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("consecutive params", () => {
|
|
391
|
+
const router = createRouter([
|
|
392
|
+
makeRoute("date", "/calendar/:year/:month/:day"),
|
|
393
|
+
]);
|
|
394
|
+
|
|
395
|
+
const result = router.match("/calendar/2025/01/30");
|
|
396
|
+
|
|
397
|
+
expect(result!.params).toEqual({
|
|
398
|
+
year: "2025",
|
|
399
|
+
month: "01",
|
|
400
|
+
day: "30",
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("param followed by static", () => {
|
|
405
|
+
const router = createRouter([
|
|
406
|
+
makeRoute("user-posts", "/users/:id/posts"),
|
|
407
|
+
]);
|
|
408
|
+
|
|
409
|
+
const result = router.match("/users/42/posts");
|
|
410
|
+
|
|
411
|
+
expect(result!.route.id).toBe("user-posts");
|
|
412
|
+
expect(result!.params).toEqual({ id: "42" });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("trailing slash normalization", () => {
|
|
416
|
+
const router = createRouter([
|
|
417
|
+
makeRoute("api", "/api"),
|
|
418
|
+
]);
|
|
419
|
+
|
|
420
|
+
expect(router.match("/api")).not.toBeNull();
|
|
421
|
+
expect(router.match("/api/")).not.toBeNull(); // normalized to /api
|
|
422
|
+
});
|
|
423
|
+
});
|
package/src/runtime/router.ts
CHANGED
|
@@ -1,83 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Router v5 - Hybrid Trie Architecture
|
|
3
|
+
*
|
|
4
|
+
* @version 5.0.0
|
|
5
|
+
* @see docs/architecture/06_mandu_router_v5_hybrid_trie.md
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Static routes: Map O(1) lookup
|
|
9
|
+
* - Dynamic routes: Trie O(k) lookup (k = segments)
|
|
10
|
+
* - Security: %2F blocking, double-encoding protection
|
|
11
|
+
* - Validation: Duplicate detection, param name conflicts
|
|
12
|
+
*/
|
|
13
|
+
|
|
1
14
|
import type { RouteSpec } from "../spec/schema";
|
|
2
15
|
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
// Constants
|
|
18
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
19
|
+
|
|
20
|
+
/** Encoded slash pattern for security checks */
|
|
21
|
+
const ENCODED_SLASH_PATTERN = /%2f/i;
|
|
22
|
+
|
|
23
|
+
/** Fixed key for wildcard params */
|
|
24
|
+
const WILDCARD_PARAM_KEY = "$wildcard";
|
|
25
|
+
|
|
26
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
27
|
+
// Types & Interfaces
|
|
28
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
+
|
|
3
30
|
export interface MatchResult {
|
|
4
31
|
route: RouteSpec;
|
|
5
32
|
params: Record<string, string>;
|
|
6
33
|
}
|
|
7
34
|
|
|
35
|
+
export interface RouterOptions {
|
|
36
|
+
/** Enable debug logging */
|
|
37
|
+
debug?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RouterStats {
|
|
41
|
+
staticCount: number;
|
|
42
|
+
dynamicCount: number;
|
|
43
|
+
totalRoutes: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type RouterErrorCode =
|
|
47
|
+
| "DUPLICATE_PATTERN"
|
|
48
|
+
| "PARAM_NAME_CONFLICT"
|
|
49
|
+
| "WILDCARD_NOT_LAST"
|
|
50
|
+
| "ROUTE_CONFLICT";
|
|
51
|
+
|
|
52
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
53
|
+
// RouterError Class
|
|
54
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Router-specific error with error code for programmatic handling
|
|
58
|
+
*/
|
|
59
|
+
export class RouterError extends Error {
|
|
60
|
+
public readonly name = "RouterError";
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
message: string,
|
|
64
|
+
public readonly code: RouterErrorCode,
|
|
65
|
+
public readonly routeId: string,
|
|
66
|
+
public readonly conflictsWith?: string
|
|
67
|
+
) {
|
|
68
|
+
super(message);
|
|
69
|
+
|
|
70
|
+
// V8 stack trace capture
|
|
71
|
+
if (Error.captureStackTrace) {
|
|
72
|
+
Error.captureStackTrace(this, RouterError);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
78
|
+
// TrieNode Class
|
|
79
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Trie node for dynamic route matching
|
|
83
|
+
*
|
|
84
|
+
* Structure:
|
|
85
|
+
* - children: Map for static segments
|
|
86
|
+
* - paramChild: Single param child with name tracking (P0-4)
|
|
87
|
+
* - wildcardRoute: Route for wildcard (*) matching
|
|
88
|
+
* - route: Route that terminates at this node
|
|
89
|
+
*/
|
|
90
|
+
class TrieNode {
|
|
91
|
+
/** Static segment children */
|
|
92
|
+
children: Map<string, TrieNode> = new Map();
|
|
93
|
+
|
|
94
|
+
/** Parameter child with name for conflict detection */
|
|
95
|
+
paramChild: { name: string; node: TrieNode } | null = null;
|
|
96
|
+
|
|
97
|
+
/** Wildcard route (only valid at leaf) */
|
|
98
|
+
wildcardRoute: RouteSpec | null = null;
|
|
99
|
+
|
|
100
|
+
/** Route terminating at this node */
|
|
101
|
+
route: RouteSpec | null = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
105
|
+
// Security Functions
|
|
106
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Safe URI component decoding with 4-layer security
|
|
110
|
+
*
|
|
111
|
+
* Security checks:
|
|
112
|
+
* 1. Pre-decode %2F check (encoded slash)
|
|
113
|
+
* 2. decodeURIComponent execution
|
|
114
|
+
* 3. Post-decode slash check
|
|
115
|
+
* 4. Double-encoding check (%252F -> %2F)
|
|
116
|
+
*
|
|
117
|
+
* @returns Decoded string or null if security violation
|
|
118
|
+
*/
|
|
119
|
+
function safeDecodeURIComponent(str: string): string | null {
|
|
120
|
+
// 1. Pre-decode %2F check
|
|
121
|
+
if (ENCODED_SLASH_PATTERN.test(str)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 2. Decode
|
|
126
|
+
let decoded: string;
|
|
127
|
+
try {
|
|
128
|
+
decoded = decodeURIComponent(str);
|
|
129
|
+
} catch {
|
|
130
|
+
// Malformed UTF-8
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 3. Post-decode slash check
|
|
135
|
+
if (decoded.includes("/")) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 4. Double-encoding check
|
|
140
|
+
if (ENCODED_SLASH_PATTERN.test(decoded)) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return decoded;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
148
|
+
// Router Class
|
|
149
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Hybrid Trie Router
|
|
153
|
+
*
|
|
154
|
+
* Matching order:
|
|
155
|
+
* 1. Static routes (Map) - O(1)
|
|
156
|
+
* 2. Dynamic routes (Trie) - O(k)
|
|
157
|
+
*
|
|
158
|
+
* Static routes always take precedence over dynamic routes.
|
|
159
|
+
*/
|
|
8
160
|
export class Router {
|
|
9
|
-
|
|
10
|
-
private
|
|
161
|
+
/** Static routes for O(1) lookup */
|
|
162
|
+
private statics: Map<string, RouteSpec> = new Map();
|
|
163
|
+
|
|
164
|
+
/** Trie root for dynamic routes */
|
|
165
|
+
private trie: TrieNode = new TrieNode();
|
|
11
166
|
|
|
12
|
-
|
|
167
|
+
/** Registered patterns for duplicate detection (normalized -> routeId) */
|
|
168
|
+
private registeredPatterns: Map<string, string> = new Map();
|
|
169
|
+
|
|
170
|
+
/** Debug mode */
|
|
171
|
+
private debug: boolean;
|
|
172
|
+
|
|
173
|
+
constructor(routes: RouteSpec[] = [], options: RouterOptions = {}) {
|
|
174
|
+
this.debug = options.debug ?? false;
|
|
13
175
|
this.setRoutes(routes);
|
|
14
176
|
}
|
|
15
177
|
|
|
178
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
179
|
+
// Public API
|
|
180
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Set routes (replaces existing routes)
|
|
184
|
+
* @throws {RouterError} On validation failure
|
|
185
|
+
*/
|
|
16
186
|
setRoutes(routes: RouteSpec[]): void {
|
|
17
|
-
|
|
18
|
-
this.
|
|
187
|
+
// Clear existing state
|
|
188
|
+
this.statics.clear();
|
|
189
|
+
this.trie = new TrieNode();
|
|
190
|
+
this.registeredPatterns.clear();
|
|
19
191
|
|
|
192
|
+
// Register each route with validation
|
|
20
193
|
for (const route of routes) {
|
|
21
|
-
this.
|
|
194
|
+
this.validateAndRegister(route);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (this.debug) {
|
|
198
|
+
console.log(`[Router] Registered ${routes.length} routes`);
|
|
199
|
+
console.log(`[Router] Static: ${this.statics.size}, Dynamic: ${this.registeredPatterns.size - this.statics.size}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Add a single route
|
|
205
|
+
* @throws {RouterError} On validation failure
|
|
206
|
+
*/
|
|
207
|
+
addRoute(route: RouteSpec): void {
|
|
208
|
+
this.validateAndRegister(route);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Match pathname to route
|
|
213
|
+
*
|
|
214
|
+
* @returns MatchResult or null (including security violations)
|
|
215
|
+
*/
|
|
216
|
+
match(pathname: string): MatchResult | null {
|
|
217
|
+
const normalized = this.normalize(pathname);
|
|
218
|
+
|
|
219
|
+
// 1. Static lookup O(1)
|
|
220
|
+
const staticRoute = this.statics.get(normalized);
|
|
221
|
+
if (staticRoute) {
|
|
222
|
+
if (this.debug) {
|
|
223
|
+
console.log(`[Router] Static match: ${normalized} -> ${staticRoute.id}`);
|
|
224
|
+
}
|
|
225
|
+
return { route: staticRoute, params: {} };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 2. Trie lookup O(k)
|
|
229
|
+
return this.matchTrie(normalized);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get all registered routes
|
|
234
|
+
*/
|
|
235
|
+
getRoutes(): RouteSpec[] {
|
|
236
|
+
const routes: RouteSpec[] = [];
|
|
237
|
+
|
|
238
|
+
// Collect from statics
|
|
239
|
+
for (const route of this.statics.values()) {
|
|
240
|
+
routes.push(route);
|
|
22
241
|
}
|
|
242
|
+
|
|
243
|
+
// Collect from trie (DFS)
|
|
244
|
+
this.collectTrieRoutes(this.trie, routes);
|
|
245
|
+
|
|
246
|
+
return routes;
|
|
23
247
|
}
|
|
24
248
|
|
|
25
|
-
|
|
26
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Get router statistics
|
|
251
|
+
*/
|
|
252
|
+
getStats(): RouterStats {
|
|
253
|
+
const staticCount = this.statics.size;
|
|
254
|
+
const totalRoutes = this.registeredPatterns.size;
|
|
255
|
+
return {
|
|
256
|
+
staticCount,
|
|
257
|
+
dynamicCount: totalRoutes - staticCount,
|
|
258
|
+
totalRoutes,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
263
|
+
// Private: Normalization
|
|
264
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Normalize path (P0-1)
|
|
268
|
+
* - "/" stays as is
|
|
269
|
+
* - Remove trailing slash for others
|
|
270
|
+
*/
|
|
271
|
+
private normalize(path: string): string {
|
|
272
|
+
if (path === "/") return "/";
|
|
273
|
+
return path.replace(/\/+$/, "");
|
|
274
|
+
}
|
|
27
275
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Check if pattern is static (no params or wildcards)
|
|
278
|
+
*/
|
|
279
|
+
private isStatic(pattern: string): boolean {
|
|
280
|
+
return !pattern.includes(":") && !pattern.includes("*");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
284
|
+
// Private: Validation & Registration
|
|
285
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Validate and register a route
|
|
289
|
+
* @throws {RouterError} On validation failure
|
|
290
|
+
*/
|
|
291
|
+
private validateAndRegister(route: RouteSpec): void {
|
|
292
|
+
const { id, pattern } = route;
|
|
293
|
+
const normalized = this.normalize(pattern);
|
|
294
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
295
|
+
|
|
296
|
+
// P0-1: Duplicate check on normalized pattern
|
|
297
|
+
const existing = this.registeredPatterns.get(normalized);
|
|
298
|
+
if (existing) {
|
|
299
|
+
throw new RouterError(
|
|
300
|
+
`Pattern "${pattern}" duplicates existing pattern from route "${existing}"`,
|
|
301
|
+
"DUPLICATE_PATTERN",
|
|
302
|
+
id,
|
|
303
|
+
existing
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// P0-2: Segment-based wildcard validation
|
|
308
|
+
const wildcardIdx = segments.findIndex((s) => s === "*");
|
|
309
|
+
if (wildcardIdx !== -1 && wildcardIdx !== segments.length - 1) {
|
|
310
|
+
throw new RouterError(
|
|
311
|
+
`Wildcard must be the last segment in pattern "${pattern}"`,
|
|
312
|
+
"WILDCARD_NOT_LAST",
|
|
313
|
+
id
|
|
314
|
+
);
|
|
315
|
+
}
|
|
31
316
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
(
|
|
35
|
-
|
|
36
|
-
|
|
317
|
+
// Register based on type
|
|
318
|
+
if (this.isStatic(normalized)) {
|
|
319
|
+
this.statics.set(normalized, route);
|
|
320
|
+
} else {
|
|
321
|
+
this.insertTrie(normalized, segments, route);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this.registeredPatterns.set(normalized, id);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
328
|
+
// Private: Trie Operations
|
|
329
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Insert route into trie
|
|
333
|
+
* @throws {RouterError} On param name conflict
|
|
334
|
+
*/
|
|
335
|
+
private insertTrie(pattern: string, segments: string[], route: RouteSpec): void {
|
|
336
|
+
let node = this.trie;
|
|
337
|
+
|
|
338
|
+
for (let i = 0; i < segments.length; i++) {
|
|
339
|
+
const seg = segments[i];
|
|
340
|
+
|
|
341
|
+
// Wildcard handling
|
|
342
|
+
if (seg === "*") {
|
|
343
|
+
node.wildcardRoute = route;
|
|
344
|
+
return;
|
|
37
345
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
346
|
+
|
|
347
|
+
// Parameter handling
|
|
348
|
+
if (seg.startsWith(":")) {
|
|
349
|
+
const paramName = seg.slice(1);
|
|
350
|
+
|
|
351
|
+
// P0-3: Param name conflict detection
|
|
352
|
+
if (node.paramChild) {
|
|
353
|
+
if (node.paramChild.name !== paramName) {
|
|
354
|
+
throw new RouterError(
|
|
355
|
+
`Parameter name conflict at depth ${i}: ":${paramName}" vs existing ":${node.paramChild.name}" in pattern "${pattern}"`,
|
|
356
|
+
"PARAM_NAME_CONFLICT",
|
|
357
|
+
route.id
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
node = node.paramChild.node;
|
|
361
|
+
} else {
|
|
362
|
+
const newNode = new TrieNode();
|
|
363
|
+
node.paramChild = { name: paramName, node: newNode };
|
|
364
|
+
node = newNode;
|
|
365
|
+
}
|
|
366
|
+
continue;
|
|
50
367
|
}
|
|
51
|
-
);
|
|
52
368
|
|
|
53
|
-
|
|
54
|
-
|
|
369
|
+
// Static segment handling
|
|
370
|
+
if (!node.children.has(seg)) {
|
|
371
|
+
node.children.set(seg, new TrieNode());
|
|
372
|
+
}
|
|
373
|
+
node = node.children.get(seg)!;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
node.route = route;
|
|
55
377
|
}
|
|
56
378
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
379
|
+
/**
|
|
380
|
+
* Match pathname against trie
|
|
381
|
+
*/
|
|
382
|
+
private matchTrie(pathname: string): MatchResult | null {
|
|
383
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
384
|
+
const params: Record<string, string> = {};
|
|
385
|
+
let node = this.trie;
|
|
386
|
+
|
|
387
|
+
// Track wildcard candidate for backtracking
|
|
388
|
+
let wildcardMatch: { route: RouteSpec; consumed: number } | null = null;
|
|
389
|
+
|
|
390
|
+
for (let i = 0; i < segments.length; i++) {
|
|
391
|
+
const seg = segments[i];
|
|
392
|
+
|
|
393
|
+
// Save wildcard candidate before advancing
|
|
394
|
+
if (node.wildcardRoute) {
|
|
395
|
+
wildcardMatch = { route: node.wildcardRoute, consumed: i };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 1. Try static child first (higher priority)
|
|
399
|
+
const staticChild = node.children.get(seg);
|
|
400
|
+
if (staticChild) {
|
|
401
|
+
node = staticChild;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 2. Try param child
|
|
406
|
+
if (node.paramChild) {
|
|
407
|
+
const decoded = safeDecodeURIComponent(seg);
|
|
408
|
+
if (decoded === null) {
|
|
409
|
+
// Security violation
|
|
410
|
+
if (this.debug) {
|
|
411
|
+
console.log(`[Router] Security block: ${seg}`);
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
params[node.paramChild.name] = decoded;
|
|
416
|
+
node = node.paramChild.node;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 3. No match - try wildcard fallback
|
|
421
|
+
if (wildcardMatch) {
|
|
422
|
+
const remaining = segments.slice(wildcardMatch.consumed).join("/");
|
|
423
|
+
if (this.debug) {
|
|
424
|
+
console.log(`[Router] Wildcard match: ${wildcardMatch.route.id} with ${remaining}`);
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
route: wildcardMatch.route,
|
|
428
|
+
params: { [WILDCARD_PARAM_KEY]: remaining },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// No match at all
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// End of path - check for route at current node
|
|
437
|
+
if (node.route) {
|
|
438
|
+
if (this.debug) {
|
|
439
|
+
console.log(`[Router] Trie match: ${node.route.id}`);
|
|
440
|
+
}
|
|
441
|
+
return { route: node.route, params };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Check for wildcard at current node (but with no remaining segments)
|
|
445
|
+
// Policy A: /files/* does NOT match /files
|
|
446
|
+
if (node.wildcardRoute) {
|
|
447
|
+
// Don't match - wildcard requires at least one segment
|
|
448
|
+
if (this.debug) {
|
|
449
|
+
console.log(`[Router] Wildcard policy A: ${pathname} does not match wildcard`);
|
|
70
450
|
}
|
|
71
451
|
}
|
|
72
452
|
|
|
453
|
+
// Try wildcard fallback from earlier in the path
|
|
454
|
+
if (wildcardMatch) {
|
|
455
|
+
const remaining = segments.slice(wildcardMatch.consumed).join("/");
|
|
456
|
+
return {
|
|
457
|
+
route: wildcardMatch.route,
|
|
458
|
+
params: { [WILDCARD_PARAM_KEY]: remaining },
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
73
462
|
return null;
|
|
74
463
|
}
|
|
75
464
|
|
|
76
|
-
|
|
77
|
-
|
|
465
|
+
/**
|
|
466
|
+
* Collect routes from trie (for getRoutes)
|
|
467
|
+
*/
|
|
468
|
+
private collectTrieRoutes(node: TrieNode, routes: RouteSpec[]): void {
|
|
469
|
+
if (node.route && !this.statics.has(this.normalize(node.route.pattern))) {
|
|
470
|
+
routes.push(node.route);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (node.wildcardRoute) {
|
|
474
|
+
routes.push(node.wildcardRoute);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
for (const child of node.children.values()) {
|
|
478
|
+
this.collectTrieRoutes(child, routes);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (node.paramChild) {
|
|
482
|
+
this.collectTrieRoutes(node.paramChild.node, routes);
|
|
483
|
+
}
|
|
78
484
|
}
|
|
79
485
|
}
|
|
80
486
|
|
|
81
|
-
|
|
82
|
-
|
|
487
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
488
|
+
// Factory Function
|
|
489
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Create a new router instance
|
|
493
|
+
*/
|
|
494
|
+
export function createRouter(routes: RouteSpec[] = [], options: RouterOptions = {}): Router {
|
|
495
|
+
return new Router(routes, options);
|
|
83
496
|
}
|
|
497
|
+
|
|
498
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
499
|
+
// Exports
|
|
500
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
501
|
+
|
|
502
|
+
export { WILDCARD_PARAM_KEY };
|
|
@@ -83,8 +83,7 @@ export interface StreamingSSROptions {
|
|
|
83
83
|
routePattern?: string;
|
|
84
84
|
/** Critical 데이터 (Shell과 함께 즉시 전송) - JSON-serializable object만 허용 */
|
|
85
85
|
criticalData?: Record<string, unknown>;
|
|
86
|
-
|
|
87
|
-
deferredData?: Record<string, unknown>;
|
|
86
|
+
// Note: deferredData는 renderWithDeferredData의 deferredPromises로 대체됨
|
|
88
87
|
/** Hydration 설정 */
|
|
89
88
|
hydration?: HydrationConfig;
|
|
90
89
|
/** 번들 매니페스트 */
|
|
@@ -97,7 +96,7 @@ export interface StreamingSSROptions {
|
|
|
97
96
|
hmrPort?: number;
|
|
98
97
|
/** Client-side Router 활성화 */
|
|
99
98
|
enableClientRouter?: boolean;
|
|
100
|
-
/** Streaming 타임아웃 (ms) */
|
|
99
|
+
/** Streaming 타임아웃 (ms) - 전체 스트림 최대 시간 */
|
|
101
100
|
streamTimeout?: number;
|
|
102
101
|
/** Shell 렌더링 후 콜백 (TTFB 측정 시점) */
|
|
103
102
|
onShellReady?: () => void;
|
|
@@ -121,6 +120,11 @@ export interface StreamingSSROptions {
|
|
|
121
120
|
onError?: (error: Error) => void;
|
|
122
121
|
/** 메트릭 콜백 (observability) */
|
|
123
122
|
onMetrics?: (metrics: StreamingMetrics) => void;
|
|
123
|
+
/**
|
|
124
|
+
* HTML 닫기 태그 생략 여부 (내부용)
|
|
125
|
+
* true이면 </body></html>을 생략하여 deferred 스크립트 삽입 지점 확보
|
|
126
|
+
*/
|
|
127
|
+
_skipHtmlClose?: boolean;
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
export interface StreamingLoaderResult<T = unknown> {
|
|
@@ -239,7 +243,10 @@ function warnStreamingCaveats(isDev: boolean): void {
|
|
|
239
243
|
*/
|
|
240
244
|
function generateErrorScript(error: Error, routeId: string): string {
|
|
241
245
|
const safeMessage = error.message
|
|
242
|
-
.replace(
|
|
246
|
+
.replace(/\\/g, "\\\\") // 백슬래시 먼저 (다른 이스케이프에 영향)
|
|
247
|
+
.replace(/\n/g, "\\n") // 줄바꿈
|
|
248
|
+
.replace(/\r/g, "\\r") // 캐리지 리턴
|
|
249
|
+
.replace(/</g, "\\u003c") // XSS 방지
|
|
243
250
|
.replace(/>/g, "\\u003e")
|
|
244
251
|
.replace(/"/g, "\\u0022");
|
|
245
252
|
|
|
@@ -387,6 +394,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
387
394
|
islandOpenTag = `<div data-mandu-island="${routeId}" data-mandu-src="${bundleSrc}" data-mandu-priority="${priority}">`;
|
|
388
395
|
}
|
|
389
396
|
|
|
397
|
+
// Import map은 module 스크립트보다 먼저 정의되어야 bare specifier 해석 가능
|
|
390
398
|
return `<!DOCTYPE html>
|
|
391
399
|
<html lang="${lang}">
|
|
392
400
|
<head>
|
|
@@ -394,22 +402,22 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
394
402
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
395
403
|
<title>${title}</title>
|
|
396
404
|
${loadingStyles}
|
|
397
|
-
${headTags}
|
|
398
405
|
${importMapScript}
|
|
406
|
+
${headTags}
|
|
399
407
|
</head>
|
|
400
408
|
<body>
|
|
401
409
|
<div id="root">${islandOpenTag}`;
|
|
402
410
|
}
|
|
403
411
|
|
|
404
412
|
/**
|
|
405
|
-
* Streaming용 HTML Tail 생성 (</div id="root"> ~
|
|
413
|
+
* Streaming용 HTML Tail 스크립트 생성 (</div id="root"> ~ 스크립트들)
|
|
414
|
+
* `</body></html>`은 포함하지 않음 - deferred 스크립트 삽입 지점 확보
|
|
406
415
|
*/
|
|
407
|
-
function
|
|
416
|
+
function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
408
417
|
const {
|
|
409
418
|
routeId,
|
|
410
419
|
routePattern,
|
|
411
420
|
criticalData,
|
|
412
|
-
deferredData,
|
|
413
421
|
bundleManifest,
|
|
414
422
|
isDev = false,
|
|
415
423
|
hmrPort,
|
|
@@ -481,11 +489,27 @@ function generateHTMLTail(options: StreamingSSROptions): string {
|
|
|
481
489
|
const islandCloseTag = needsHydration ? "</div>" : "";
|
|
482
490
|
|
|
483
491
|
return `${islandCloseTag}</div>
|
|
484
|
-
${scripts.join("\n ")}
|
|
492
|
+
${scripts.join("\n ")}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* HTML 문서 닫기 태그
|
|
497
|
+
* Deferred 스크립트 삽입 후 호출
|
|
498
|
+
*/
|
|
499
|
+
function generateHTMLClose(): string {
|
|
500
|
+
return `
|
|
485
501
|
</body>
|
|
486
502
|
</html>`;
|
|
487
503
|
}
|
|
488
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Streaming용 HTML Tail 생성 (</div id="root"> ~ </html>)
|
|
507
|
+
* 하위 호환성 유지 - 내부적으로 generateHTMLTailContent + generateHTMLClose 사용
|
|
508
|
+
*/
|
|
509
|
+
function generateHTMLTail(options: StreamingSSROptions): string {
|
|
510
|
+
return generateHTMLTailContent(options) + generateHTMLClose();
|
|
511
|
+
}
|
|
512
|
+
|
|
489
513
|
/**
|
|
490
514
|
* Deferred 데이터 인라인 스크립트 생성
|
|
491
515
|
* Streaming 중에 데이터 도착 시 DOM에 주입
|
|
@@ -573,6 +597,7 @@ export async function renderToStream(
|
|
|
573
597
|
isDev = false,
|
|
574
598
|
routeId = "unknown",
|
|
575
599
|
criticalData,
|
|
600
|
+
streamTimeout,
|
|
576
601
|
} = options;
|
|
577
602
|
|
|
578
603
|
// 메트릭 수집
|
|
@@ -595,14 +620,20 @@ export async function renderToStream(
|
|
|
595
620
|
|
|
596
621
|
const encoder = new TextEncoder();
|
|
597
622
|
const htmlShell = generateHTMLShell(options);
|
|
598
|
-
|
|
623
|
+
// _skipHtmlClose가 true이면 </body></html> 생략 (deferred 스크립트 삽입용)
|
|
624
|
+
const htmlTail = options._skipHtmlClose
|
|
625
|
+
? generateHTMLTailContent(options)
|
|
626
|
+
: generateHTMLTail(options);
|
|
599
627
|
|
|
600
628
|
let shellSent = false;
|
|
629
|
+
let timedOut = false;
|
|
601
630
|
|
|
602
631
|
// React renderToReadableStream 호출
|
|
603
632
|
// 실패 시 throw → renderStreamingResponse에서 500 처리
|
|
604
633
|
const reactStream = await renderToReadableStream(element, {
|
|
605
634
|
onError: (error: Error) => {
|
|
635
|
+
if (timedOut) return;
|
|
636
|
+
|
|
606
637
|
metrics.hasError = true;
|
|
607
638
|
const streamingError: StreamingError = {
|
|
608
639
|
error,
|
|
@@ -638,6 +669,44 @@ export async function renderToStream(
|
|
|
638
669
|
// Custom stream으로 래핑 (Shell + React Content + Tail)
|
|
639
670
|
let tailSent = false;
|
|
640
671
|
const reader = reactStream.getReader();
|
|
672
|
+
const deadline = streamTimeout && streamTimeout > 0
|
|
673
|
+
? metrics.startTime + streamTimeout
|
|
674
|
+
: null;
|
|
675
|
+
|
|
676
|
+
async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array> | null> {
|
|
677
|
+
if (!deadline) {
|
|
678
|
+
return reader.read();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const remaining = deadline - Date.now();
|
|
682
|
+
if (remaining <= 0) {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
687
|
+
const timeoutPromise = new Promise<{ kind: "timeout" }>((resolve) => {
|
|
688
|
+
timeoutId = setTimeout(() => resolve({ kind: "timeout" }), remaining);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
const readPromise = reader
|
|
692
|
+
.read()
|
|
693
|
+
.then((result) => ({ kind: "read" as const, result }))
|
|
694
|
+
.catch((error) => ({ kind: "error" as const, error }));
|
|
695
|
+
|
|
696
|
+
const result = await Promise.race([readPromise, timeoutPromise]);
|
|
697
|
+
|
|
698
|
+
if (result.kind === "timeout") {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
703
|
+
|
|
704
|
+
if (result.kind === "error") {
|
|
705
|
+
throw result.error;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return result.result;
|
|
709
|
+
}
|
|
641
710
|
|
|
642
711
|
return new ReadableStream<Uint8Array>({
|
|
643
712
|
async start(controller) {
|
|
@@ -650,7 +719,44 @@ export async function renderToStream(
|
|
|
650
719
|
|
|
651
720
|
async pull(controller) {
|
|
652
721
|
try {
|
|
653
|
-
const
|
|
722
|
+
const readResult = await readWithTimeout();
|
|
723
|
+
|
|
724
|
+
// 타임아웃 발생
|
|
725
|
+
if (!readResult) {
|
|
726
|
+
const timeoutError = new Error(`Stream timeout: exceeded ${streamTimeout}ms`);
|
|
727
|
+
metrics.hasError = true;
|
|
728
|
+
timedOut = true;
|
|
729
|
+
if (isDev) {
|
|
730
|
+
console.warn(`[Mandu Streaming] Stream timeout after ${streamTimeout}ms`);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const streamingError: StreamingError = {
|
|
734
|
+
error: timeoutError,
|
|
735
|
+
isShellError: false,
|
|
736
|
+
recoverable: true,
|
|
737
|
+
timestamp: Date.now(),
|
|
738
|
+
};
|
|
739
|
+
onStreamError?.(streamingError);
|
|
740
|
+
|
|
741
|
+
controller.enqueue(encoder.encode(generateErrorScript(timeoutError, routeId)));
|
|
742
|
+
|
|
743
|
+
if (!tailSent) {
|
|
744
|
+
controller.enqueue(encoder.encode(htmlTail));
|
|
745
|
+
tailSent = true;
|
|
746
|
+
metrics.allReadyTime = Date.now() - metrics.startTime;
|
|
747
|
+
onMetrics?.(metrics);
|
|
748
|
+
}
|
|
749
|
+
controller.close();
|
|
750
|
+
try {
|
|
751
|
+
const cancelPromise = reader.cancel();
|
|
752
|
+
if (cancelPromise) {
|
|
753
|
+
cancelPromise.catch(() => {});
|
|
754
|
+
}
|
|
755
|
+
} catch {}
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const { done, value } = readResult;
|
|
654
760
|
|
|
655
761
|
if (done) {
|
|
656
762
|
if (!tailSent) {
|
|
@@ -697,7 +803,12 @@ export async function renderToStream(
|
|
|
697
803
|
},
|
|
698
804
|
|
|
699
805
|
cancel() {
|
|
700
|
-
|
|
806
|
+
try {
|
|
807
|
+
const cancelPromise = reader.cancel();
|
|
808
|
+
if (cancelPromise) {
|
|
809
|
+
cancelPromise.catch(() => {});
|
|
810
|
+
}
|
|
811
|
+
} catch {}
|
|
701
812
|
},
|
|
702
813
|
});
|
|
703
814
|
}
|
|
@@ -806,6 +917,7 @@ export async function renderWithDeferredData(
|
|
|
806
917
|
isDev = false,
|
|
807
918
|
...restOptions
|
|
808
919
|
} = options;
|
|
920
|
+
const streamTimeout = options.streamTimeout;
|
|
809
921
|
|
|
810
922
|
const encoder = new TextEncoder();
|
|
811
923
|
const startTime = Date.now();
|
|
@@ -845,11 +957,13 @@ export async function renderWithDeferredData(
|
|
|
845
957
|
: Promise.resolve().then(() => { allDeferredSettled = true; });
|
|
846
958
|
|
|
847
959
|
// 2. Base stream 즉시 시작 (TTFB 최소화의 핵심!)
|
|
960
|
+
// _skipHtmlClose: true로 </body></html> 생략 → deferred 스크립트 삽입 지점 확보
|
|
848
961
|
let baseMetrics: StreamingMetrics | null = null;
|
|
849
962
|
const baseStream = await renderToStream(element, {
|
|
850
963
|
...restOptions,
|
|
851
964
|
routeId,
|
|
852
965
|
isDev,
|
|
966
|
+
_skipHtmlClose: true, // deferred 스크립트를 </body> 전에 삽입하기 위해
|
|
853
967
|
onMetrics: (metrics) => {
|
|
854
968
|
baseMetrics = metrics;
|
|
855
969
|
},
|
|
@@ -865,7 +979,13 @@ export async function renderWithDeferredData(
|
|
|
865
979
|
// base stream 완료 후, deferred가 아직 안 끝났으면 잠시 대기
|
|
866
980
|
// (단, deferredTimeout 내에서만)
|
|
867
981
|
if (!allDeferredSettled) {
|
|
868
|
-
const
|
|
982
|
+
const elapsed = Date.now() - startTime;
|
|
983
|
+
let remainingTime = deferredTimeout - elapsed;
|
|
984
|
+
if (streamTimeout && streamTimeout > 0) {
|
|
985
|
+
const remainingStream = streamTimeout - elapsed;
|
|
986
|
+
remainingTime = Math.min(remainingTime, remainingStream);
|
|
987
|
+
}
|
|
988
|
+
remainingTime = Math.max(0, remainingTime);
|
|
869
989
|
if (remainingTime > 0) {
|
|
870
990
|
await Promise.race([
|
|
871
991
|
deferredSettledPromise,
|
|
@@ -885,6 +1005,9 @@ export async function renderWithDeferredData(
|
|
|
885
1005
|
console.log(`[Mandu Streaming] Injected ${injectedCount} deferred scripts`);
|
|
886
1006
|
}
|
|
887
1007
|
|
|
1008
|
+
// HTML 닫기 태그 추가 (</body></html>)
|
|
1009
|
+
controller.enqueue(encoder.encode(generateHTMLClose()));
|
|
1010
|
+
|
|
888
1011
|
// 최종 메트릭 보고 (injectedCount가 실제 메트릭)
|
|
889
1012
|
if (onMetrics && baseMetrics) {
|
|
890
1013
|
onMetrics({
|