@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.9.22",
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
+ });
@@ -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
- private routes: RouteSpec[] = [];
10
- private compiledPatterns: Map<string, { regex: RegExp; paramNames: string[] }> = new Map();
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
- constructor(routes: RouteSpec[] = []) {
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
- this.routes = routes;
18
- this.compiledPatterns.clear();
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.compiledPatterns.set(route.id, this.compilePattern(route.pattern));
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
- private compilePattern(pattern: string): { regex: RegExp; paramNames: string[] } {
26
- const paramNames: string[] = [];
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
- const PARAM_PLACEHOLDER = "\x00PARAM\x00";
30
- const paramMatches: string[] = [];
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
- const withPlaceholders = pattern.replace(
33
- /:([a-zA-Z_][a-zA-Z0-9_]*)/g,
34
- (_, paramName) => {
35
- paramMatches.push(paramName);
36
- return PARAM_PLACEHOLDER;
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
- // regex 특수문자 이스케이프 (/ 포함)
41
- const escaped = withPlaceholders.replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&");
42
-
43
- // 플레이스홀더를 캡처 그룹으로 복원하고 paramNames 채우기
44
- let paramIndex = 0;
45
- const regexStr = escaped.replace(
46
- new RegExp(PARAM_PLACEHOLDER.replace(/\x00/g, "\\x00"), "g"),
47
- () => {
48
- paramNames.push(paramMatches[paramIndex++]);
49
- return "([^/]+)";
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
- const regex = new RegExp(`^${regexStr}$`);
54
- return { regex, paramNames };
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
- match(pathname: string): MatchResult | null {
58
- for (const route of this.routes) {
59
- const compiled = this.compiledPatterns.get(route.id);
60
- if (!compiled) continue;
61
-
62
- const match = pathname.match(compiled.regex);
63
- if (match) {
64
- const params: Record<string, string> = {};
65
- compiled.paramNames.forEach((name, index) => {
66
- params[name] = match[index + 1];
67
- });
68
-
69
- return { route, params };
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
- getRoutes(): RouteSpec[] {
77
- return [...this.routes];
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
- export function createRouter(routes: RouteSpec[] = []): Router {
82
- return new Router(routes);
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
- /** Deferred 데이터 (Suspense 스트리밍) */
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(/</g, "\\u003c")
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"> ~ </html>)
413
+ * Streaming용 HTML Tail 스크립트 생성 (</div id="root"> ~ 스크립트들)
414
+ * `</body></html>`은 포함하지 않음 - deferred 스크립트 삽입 지점 확보
406
415
  */
407
- function generateHTMLTail(options: StreamingSSROptions): string {
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
- const htmlTail = generateHTMLTail(options);
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 { done, value } = await reader.read();
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
- reader.cancel();
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 remainingTime = Math.max(0, deferredTimeout - (Date.now() - startTime));
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({