@pyreon/router 0.2.1 → 0.3.1

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/src/match.ts CHANGED
@@ -65,7 +65,303 @@ export function stringifyQuery(query: Record<string, string>): string {
65
65
  return parts.length ? `?${parts.join("&")}` : ""
66
66
  }
67
67
 
68
- // ─── Path matching ────────────────────────────────────────────────────────────
68
+ // ─── Compiled route structures ───────────────────────────────────────────────
69
+
70
+ /**
71
+ * Pre-compiled segment info — computed once per route, reused on every match.
72
+ * Avoids repeated split()/filter()/startsWith(":") on hot paths.
73
+ */
74
+ interface CompiledSegment {
75
+ /** Original segment string */
76
+ raw: string
77
+ /** true if this segment is a `:param` */
78
+ isParam: boolean
79
+ /** true if this segment is a `:param*` splat */
80
+ isSplat: boolean
81
+ /** Param name (without leading `:` and trailing `*`) — empty for static segments */
82
+ paramName: string
83
+ }
84
+
85
+ interface CompiledRoute {
86
+ route: RouteRecord
87
+ /** true for wildcard patterns: `(.*)` or `*` */
88
+ isWildcard: boolean
89
+ /** Pre-split and classified segments */
90
+ segments: CompiledSegment[]
91
+ /** Number of segments */
92
+ segmentCount: number
93
+ /** true if route has no dynamic segments (pure static) */
94
+ isStatic: boolean
95
+ /** For static routes: the normalized path (e.g. "/about") for Map lookup */
96
+ staticPath: string | null
97
+ /** Compiled children (lazily populated) */
98
+ children: CompiledRoute[] | null
99
+ /** First static segment (for dispatch index), null if first segment is dynamic or route is wildcard */
100
+ firstSegment: string | null
101
+ }
102
+
103
+ /**
104
+ * A flattened route entry — pre-joins parent+child segments at compile time
105
+ * so nested routes can be matched in a single pass without recursion.
106
+ */
107
+ interface FlattenedRoute {
108
+ /** All segments from root to leaf, concatenated */
109
+ segments: CompiledSegment[]
110
+ segmentCount: number
111
+ /** The full matched chain from root to leaf (e.g. [adminLayout, usersPage]) */
112
+ matchedChain: RouteRecord[]
113
+ /** true if all segments are static */
114
+ isStatic: boolean
115
+ /** For static flattened routes: the full joined path */
116
+ staticPath: string | null
117
+ /** Pre-merged meta from all routes in the chain */
118
+ meta: RouteMeta
119
+ /** First static segment for dispatch index */
120
+ firstSegment: string | null
121
+ /** true if any segment is a splat */
122
+ hasSplat: boolean
123
+ /** true if this is a wildcard catch-all route (`*` or `(.*)`) */
124
+ isWildcard: boolean
125
+ }
126
+
127
+ /** WeakMap cache: compile each RouteRecord[] once */
128
+ const _compiledCache = new WeakMap<RouteRecord[], CompiledRoute[]>()
129
+
130
+ function compileSegment(raw: string): CompiledSegment {
131
+ if (raw.endsWith("*") && raw.startsWith(":")) {
132
+ return { raw, isParam: true, isSplat: true, paramName: raw.slice(1, -1) }
133
+ }
134
+ if (raw.startsWith(":")) {
135
+ return { raw, isParam: true, isSplat: false, paramName: raw.slice(1) }
136
+ }
137
+ return { raw, isParam: false, isSplat: false, paramName: "" }
138
+ }
139
+
140
+ function compileRoute(route: RouteRecord): CompiledRoute {
141
+ const pattern = route.path
142
+ const isWildcard = pattern === "(.*)" || pattern === "*"
143
+
144
+ if (isWildcard) {
145
+ return {
146
+ route,
147
+ isWildcard: true,
148
+ segments: [],
149
+ segmentCount: 0,
150
+ isStatic: false,
151
+ staticPath: null,
152
+ children: null,
153
+ firstSegment: null,
154
+ }
155
+ }
156
+
157
+ const segments = pattern.split("/").filter(Boolean).map(compileSegment)
158
+ const isStatic = segments.every((s) => !s.isParam)
159
+ const staticPath = isStatic ? `/${segments.map((s) => s.raw).join("/")}` : null
160
+ const first = segments.length > 0 ? segments[0] : undefined
161
+ const firstSegment = first && !first.isParam ? first.raw : null
162
+
163
+ return {
164
+ route,
165
+ isWildcard: false,
166
+ segments,
167
+ segmentCount: segments.length,
168
+ isStatic,
169
+ staticPath,
170
+ children: null,
171
+ firstSegment,
172
+ }
173
+ }
174
+
175
+ function compileRoutes(routes: RouteRecord[]): CompiledRoute[] {
176
+ const cached = _compiledCache.get(routes)
177
+ if (cached) return cached
178
+
179
+ const compiled = routes.map((r) => {
180
+ const c = compileRoute(r)
181
+ if (r.children && r.children.length > 0) {
182
+ c.children = compileRoutes(r.children)
183
+ }
184
+ return c
185
+ })
186
+ _compiledCache.set(routes, compiled)
187
+ return compiled
188
+ }
189
+
190
+ // ─── Route flattening ────────────────────────────────────────────────────────
191
+
192
+ /** Extract first static segment from a segment list, or null if dynamic/empty */
193
+ function getFirstSegment(segments: CompiledSegment[]): string | null {
194
+ const first = segments[0]
195
+ if (first && !first.isParam) return first.raw
196
+ return null
197
+ }
198
+
199
+ /** Build a FlattenedRoute from segments + metadata */
200
+ function makeFlatEntry(
201
+ segments: CompiledSegment[],
202
+ chain: RouteRecord[],
203
+ meta: RouteMeta,
204
+ isWildcard: boolean,
205
+ ): FlattenedRoute {
206
+ const isStatic = !isWildcard && segments.every((s) => !s.isParam)
207
+ return {
208
+ segments,
209
+ segmentCount: segments.length,
210
+ matchedChain: chain,
211
+ isStatic,
212
+ staticPath: isStatic ? `/${segments.map((s) => s.raw).join("/")}` : null,
213
+ meta,
214
+ firstSegment: getFirstSegment(segments),
215
+ hasSplat: segments.some((s) => s.isSplat),
216
+ isWildcard,
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Flatten nested routes into leaf entries with pre-joined segments.
222
+ * This eliminates recursion during matching for the common case.
223
+ */
224
+ function flattenRoutes(compiled: CompiledRoute[]): FlattenedRoute[] {
225
+ const result: FlattenedRoute[] = []
226
+ flattenWalk(result, compiled, [], [], {})
227
+ return result
228
+ }
229
+
230
+ function flattenWalk(
231
+ result: FlattenedRoute[],
232
+ routes: CompiledRoute[],
233
+ parentSegments: CompiledSegment[],
234
+ parentChain: RouteRecord[],
235
+ parentMeta: RouteMeta,
236
+ ): void {
237
+ for (const c of routes) {
238
+ const chain = [...parentChain, c.route]
239
+ const meta = c.route.meta ? { ...parentMeta, ...c.route.meta } : { ...parentMeta }
240
+ flattenOne(result, c, parentSegments, chain, meta)
241
+ }
242
+ }
243
+
244
+ function flattenOne(
245
+ result: FlattenedRoute[],
246
+ c: CompiledRoute,
247
+ parentSegments: CompiledSegment[],
248
+ chain: RouteRecord[],
249
+ meta: RouteMeta,
250
+ ): void {
251
+ if (c.isWildcard) {
252
+ result.push(makeFlatEntry(parentSegments, chain, meta, true))
253
+ if (c.children && c.children.length > 0) {
254
+ flattenWalk(result, c.children, parentSegments, chain, meta)
255
+ }
256
+ return
257
+ }
258
+
259
+ const joined = [...parentSegments, ...c.segments]
260
+ if (c.children && c.children.length > 0) {
261
+ flattenWalk(result, c.children, joined, chain, meta)
262
+ }
263
+ result.push(makeFlatEntry(joined, chain, meta, false))
264
+ }
265
+
266
+ // ─── Combined index ─────────────────────────────────────────────────────────
267
+
268
+ interface RouteIndex {
269
+ /** O(1) lookup for fully static paths (including nested) */
270
+ staticMap: Map<string, FlattenedRoute>
271
+ /** First-segment dispatch: maps first path segment → candidate routes */
272
+ segmentMap: Map<string, FlattenedRoute[]>
273
+ /** Routes whose first segment is dynamic (fallback) */
274
+ dynamicFirst: FlattenedRoute[]
275
+ /** Wildcard/catch-all routes */
276
+ wildcards: FlattenedRoute[]
277
+ }
278
+
279
+ const _indexCache = new WeakMap<RouteRecord[], RouteIndex>()
280
+
281
+ /** Classify a single flattened route into the appropriate index bucket */
282
+ function indexFlatRoute(
283
+ f: FlattenedRoute,
284
+ staticMap: Map<string, FlattenedRoute>,
285
+ segmentMap: Map<string, FlattenedRoute[]>,
286
+ dynamicFirst: FlattenedRoute[],
287
+ wildcards: FlattenedRoute[],
288
+ ): void {
289
+ // Static map: first static entry wins (preserves definition order)
290
+ if (f.isStatic && f.staticPath && !staticMap.has(f.staticPath)) {
291
+ staticMap.set(f.staticPath, f)
292
+ }
293
+
294
+ if (f.isWildcard) {
295
+ wildcards.push(f)
296
+ return
297
+ }
298
+
299
+ // Root route "/" has 0 segments — already in static map
300
+ if (f.segmentCount === 0) return
301
+
302
+ // First-segment dispatch
303
+ if (f.firstSegment) {
304
+ let bucket = segmentMap.get(f.firstSegment)
305
+ if (!bucket) {
306
+ bucket = []
307
+ segmentMap.set(f.firstSegment, bucket)
308
+ }
309
+ bucket.push(f)
310
+ } else {
311
+ dynamicFirst.push(f)
312
+ }
313
+ }
314
+
315
+ function buildRouteIndex(routes: RouteRecord[], compiled: CompiledRoute[]): RouteIndex {
316
+ const cached = _indexCache.get(routes)
317
+ if (cached) return cached
318
+
319
+ const flattened = flattenRoutes(compiled)
320
+
321
+ const staticMap = new Map<string, FlattenedRoute>()
322
+ const segmentMap = new Map<string, FlattenedRoute[]>()
323
+ const dynamicFirst: FlattenedRoute[] = []
324
+ const wildcards: FlattenedRoute[] = []
325
+
326
+ for (const f of flattened) {
327
+ indexFlatRoute(f, staticMap, segmentMap, dynamicFirst, wildcards)
328
+ }
329
+
330
+ const index: RouteIndex = { staticMap, segmentMap, dynamicFirst, wildcards }
331
+ _indexCache.set(routes, index)
332
+ return index
333
+ }
334
+
335
+ // ─── Fast path splitting ─────────────────────────────────────────────────────
336
+
337
+ /** Split path into segments without allocating a filtered array */
338
+ function splitPath(path: string): string[] {
339
+ // Fast path for common cases
340
+ if (path === "/") return []
341
+ // Remove leading slash, split, no filter needed if path is clean
342
+ const start = path.charCodeAt(0) === 47 /* / */ ? 1 : 0
343
+ const end = path.length
344
+ if (start >= end) return []
345
+
346
+ const parts: string[] = []
347
+ let segStart = start
348
+ for (let i = start; i <= end; i++) {
349
+ if (i === end || path.charCodeAt(i) === 47 /* / */) {
350
+ if (i > segStart) {
351
+ parts.push(path.substring(segStart, i))
352
+ }
353
+ segStart = i + 1
354
+ }
355
+ }
356
+ return parts
357
+ }
358
+
359
+ /** Decode only if the segment contains a `%` character */
360
+ function decodeSafe(s: string): string {
361
+ return s.indexOf("%") >= 0 ? decodeURIComponent(s) : s
362
+ }
363
+
364
+ // ─── Path matching (compiled) ────────────────────────────────────────────────
69
365
 
70
366
  /**
71
367
  * Match a single route pattern against a path segment.
@@ -104,47 +400,78 @@ export function matchPath(pattern: string, path: string): Record<string, string>
104
400
  return params
105
401
  }
106
402
 
107
- /**
108
- * Check if a path starts with a route's prefix (for nested route matching).
109
- * Returns the remaining path suffix, or null if no match.
110
- */
111
- function matchPrefix(
112
- pattern: string,
113
- path: string,
114
- ): { params: Record<string, string>; rest: string } | null {
115
- if (pattern === "(.*)" || pattern === "*") return { params: {}, rest: path }
403
+ // ─── Compiled matching helpers ────────────────────────────────────────────────
116
404
 
117
- const patternParts = pattern.split("/").filter(Boolean)
118
- const pathParts = path.split("/").filter(Boolean)
405
+ /** Collect remaining path segments as a decoded splat value */
406
+ function captureSplat(pathParts: string[], from: number, pathLen: number): string {
407
+ const remaining: string[] = []
408
+ for (let j = from; j < pathLen; j++) {
409
+ const p = pathParts[j]
410
+ if (p !== undefined) remaining.push(decodeSafe(p))
411
+ }
412
+ return remaining.join("/")
413
+ }
119
414
 
120
- if (pathParts.length < patternParts.length) return null
415
+ // ─── Flattened route matching ─────────────────────────────────────────────────
416
+
417
+ /** Try to match a flattened route against path parts */
418
+ function matchFlattened(
419
+ f: FlattenedRoute,
420
+ pathParts: string[],
421
+ pathLen: number,
422
+ ): Record<string, string> | null {
423
+ if (f.segmentCount !== pathLen) {
424
+ // Could still match if route has a splat
425
+ if (!f.hasSplat || pathLen < f.segmentCount) return null
426
+ }
121
427
 
122
428
  const params: Record<string, string> = {}
123
- for (let i = 0; i < patternParts.length; i++) {
124
- const pp = patternParts[i] as string
125
- const pt = pathParts[i] as string
126
- // Splat param in prefix — captures the rest
127
- if (pp.endsWith("*") && pp.startsWith(":")) {
128
- const paramName = pp.slice(1, -1)
129
- params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/")
130
- return { params, rest: "/" }
429
+ const segments = f.segments
430
+ const count = f.segmentCount
431
+ for (let i = 0; i < count; i++) {
432
+ const seg = segments[i]
433
+ const pt = pathParts[i]
434
+ if (!seg || pt === undefined) return null
435
+ if (seg.isSplat) {
436
+ params[seg.paramName] = captureSplat(pathParts, i, pathLen)
437
+ return params
131
438
  }
132
- if (pp.startsWith(":")) {
133
- params[pp.slice(1)] = decodeURIComponent(pt)
134
- } else if (pp !== pt) {
439
+ if (seg.isParam) {
440
+ params[seg.paramName] = decodeSafe(pt)
441
+ } else if (seg.raw !== pt) {
135
442
  return null
136
443
  }
137
444
  }
445
+ return params
446
+ }
138
447
 
139
- const rest = `/${pathParts.slice(patternParts.length).join("/")}`
140
- return { params, rest }
448
+ /** Search a list of flattened candidates for a match */
449
+ function searchCandidates(
450
+ candidates: FlattenedRoute[],
451
+ pathParts: string[],
452
+ pathLen: number,
453
+ ): MatchResult | null {
454
+ for (let i = 0; i < candidates.length; i++) {
455
+ const f = candidates[i]
456
+ if (!f) continue
457
+ const params = matchFlattened(f, pathParts, pathLen)
458
+ if (params) {
459
+ return { params, matched: f.matchedChain }
460
+ }
461
+ }
462
+ return null
141
463
  }
142
464
 
143
465
  // ─── Route resolution ─────────────────────────────────────────────────────────
144
466
 
467
+ interface MatchResult {
468
+ params: Record<string, string>
469
+ matched: RouteRecord[]
470
+ }
471
+
145
472
  /**
146
473
  * Resolve a raw path (including query string and hash) against the route tree.
147
- * Handles nested routes recursively.
474
+ * Uses flattened index for O(1) static lookup and first-segment dispatch.
148
475
  */
149
476
  export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRoute {
150
477
  const qIdx = rawPath.indexOf("?")
@@ -157,63 +484,73 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
157
484
 
158
485
  const query = parseQuery(queryPart)
159
486
 
160
- const match = matchRoutes(cleanPath, routes, [])
161
- if (match) {
487
+ // Build index (cached after first call)
488
+ const compiled = compileRoutes(routes)
489
+ const index = buildRouteIndex(routes, compiled)
490
+
491
+ // Fast path 1: O(1) static Map lookup (covers nested static too)
492
+ const staticMatch = index.staticMap.get(cleanPath)
493
+ if (staticMatch) {
162
494
  return {
163
495
  path: cleanPath,
164
- params: match.params,
496
+ params: {},
165
497
  query,
166
498
  hash,
167
- matched: match.matched,
168
- meta: mergeMeta(match.matched),
499
+ matched: staticMatch.matchedChain,
500
+ meta: staticMatch.meta,
169
501
  }
170
502
  }
171
503
 
172
- return { path: cleanPath, params: {}, query, hash, matched: [], meta: {} }
173
- }
504
+ // Split path for segment-based matching
505
+ const pathParts = splitPath(cleanPath)
506
+ const pathLen = pathParts.length
174
507
 
175
- interface MatchResult {
176
- params: Record<string, string>
177
- matched: RouteRecord[]
178
- }
179
-
180
- function matchRoutes(
181
- path: string,
182
- routes: RouteRecord[],
183
- parentMatched: RouteRecord[],
184
- parentParams: Record<string, string> = {},
185
- ): MatchResult | null {
186
- for (const route of routes) {
187
- const result = matchSingleRoute(path, route, parentMatched, parentParams)
188
- if (result) return result
508
+ // Fast path 2: first-segment dispatch (O(1) bucket lookup + small scan)
509
+ if (pathLen > 0) {
510
+ const first = pathParts[0] as string
511
+ const bucket = index.segmentMap.get(first)
512
+ if (bucket) {
513
+ const match = searchCandidates(bucket, pathParts, pathLen)
514
+ if (match) {
515
+ return {
516
+ path: cleanPath,
517
+ params: match.params,
518
+ query,
519
+ hash,
520
+ matched: match.matched,
521
+ meta: mergeMeta(match.matched),
522
+ }
523
+ }
524
+ }
189
525
  }
190
- return null
191
- }
192
526
 
193
- function matchSingleRoute(
194
- path: string,
195
- route: RouteRecord,
196
- parentMatched: RouteRecord[],
197
- parentParams: Record<string, string>,
198
- ): MatchResult | null {
199
- if (!route.children || route.children.length === 0) {
200
- const params = matchPath(route.path, path)
201
- if (params === null) return null
202
- return { params: { ...parentParams, ...params }, matched: [...parentMatched, route] }
527
+ // Fallback: dynamic-first-segment routes
528
+ const dynMatch = searchCandidates(index.dynamicFirst, pathParts, pathLen)
529
+ if (dynMatch) {
530
+ return {
531
+ path: cleanPath,
532
+ params: dynMatch.params,
533
+ query,
534
+ hash,
535
+ matched: dynMatch.matched,
536
+ meta: mergeMeta(dynMatch.matched),
537
+ }
203
538
  }
204
539
 
205
- const prefix = matchPrefix(route.path, path)
206
- if (prefix === null) return null
207
-
208
- const allParams = { ...parentParams, ...prefix.params }
209
- const matched = [...parentMatched, route]
210
-
211
- const childMatch = matchRoutes(prefix.rest, route.children, matched, allParams)
212
- if (childMatch) return childMatch
540
+ // Fallback: wildcard/catch-all routes
541
+ const w = index.wildcards[0]
542
+ if (w) {
543
+ return {
544
+ path: cleanPath,
545
+ params: {},
546
+ query,
547
+ hash,
548
+ matched: w.matchedChain,
549
+ meta: w.meta,
550
+ }
551
+ }
213
552
 
214
- const exactParams = matchPath(route.path, path)
215
- if (exactParams === null) return null
216
- return { params: { ...parentParams, ...exactParams }, matched }
553
+ return { path: cleanPath, params: {}, query, hash, matched: [], meta: {} }
217
554
  }
218
555
 
219
556
  /** Merge meta from matched routes (leaf takes precedence) */