routup 5.2.0 → 6.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import QuickLRU from "quick-lru";
1
2
  import { FastURL } from "srvx";
2
3
  import { hasInstanceof, markInstanceof } from "@ebec/core";
3
4
  import { HTTPError, isHTTPError } from "@ebec/http";
@@ -7,6 +8,42 @@ import { compile } from "proxy-addr";
7
8
  import { get, getType } from "mime-explorer";
8
9
  import Negotiator from "negotiator";
9
10
  import { pathToRegexp } from "path-to-regexp";
11
+ //#region src/cache/lru.ts
12
+ const DEFAULT_MAX_SIZE = 1024;
13
+ /**
14
+ * Default `ICache` implementation — a bounded LRU backed by
15
+ * [`quick-lru`](https://github.com/sindresorhus/quick-lru). Picked for
16
+ * its small footprint (~1kB), ESM-only build (matches routup), and
17
+ * stable API.
18
+ *
19
+ * For TTL, size-based eviction, or dispose hooks, write your own
20
+ * `ICache` (e.g. wrapping `lru-cache`) and pass it via the router's
21
+ * `BaseRouterOptions.cache` slot.
22
+ */
23
+ var LruCache = class LruCache {
24
+ options;
25
+ inner;
26
+ constructor(options = {}) {
27
+ this.options = options;
28
+ this.inner = new QuickLRU({ maxSize: options.maxSize ?? DEFAULT_MAX_SIZE });
29
+ }
30
+ get(key) {
31
+ return this.inner.get(key);
32
+ }
33
+ set(key, value) {
34
+ this.inner.set(key, value);
35
+ }
36
+ delete(key) {
37
+ this.inner.delete(key);
38
+ }
39
+ clear() {
40
+ this.inner.clear();
41
+ }
42
+ clone() {
43
+ return new LruCache({ ...this.options });
44
+ }
45
+ };
46
+ //#endregion
10
47
  //#region src/constants.ts
11
48
  const MethodName = {
12
49
  GET: "GET",
@@ -51,6 +88,66 @@ const HeaderName = {
51
88
  X_FORWARDED_PROTO: "x-forwarded-proto"
52
89
  };
53
90
  //#endregion
91
+ //#region src/event/module.ts
92
+ var AppEvent = class {
93
+ request;
94
+ params;
95
+ path;
96
+ method;
97
+ mountPath;
98
+ headers;
99
+ searchParams;
100
+ response;
101
+ store;
102
+ signal;
103
+ appOptions;
104
+ _context;
105
+ _nextCalled = false;
106
+ _nextResult;
107
+ _nextCalledDeferred;
108
+ constructor(context) {
109
+ this._context = context;
110
+ this.request = context.request;
111
+ this.params = context.params;
112
+ this.path = context.path;
113
+ this.method = context.method;
114
+ this.mountPath = context.mountPath;
115
+ this.headers = context.headers;
116
+ this.searchParams = context.searchParams;
117
+ this.response = context.response;
118
+ this.store = context.store;
119
+ this.signal = context.signal;
120
+ this.appOptions = context.appOptions;
121
+ }
122
+ get nextCalled() {
123
+ return this._nextCalled;
124
+ }
125
+ get nextResult() {
126
+ return this._nextResult;
127
+ }
128
+ whenNextCalled() {
129
+ if (!this._nextCalledDeferred) {
130
+ let resolve;
131
+ const promise = new Promise((r) => {
132
+ resolve = r;
133
+ });
134
+ this._nextCalledDeferred = {
135
+ promise,
136
+ resolve
137
+ };
138
+ if (this._nextCalled) resolve();
139
+ }
140
+ return this._nextCalledDeferred.promise;
141
+ }
142
+ async next(error) {
143
+ if (this._nextCalled) return this._nextResult;
144
+ this._nextCalled = true;
145
+ this._nextResult = this._context.next(this, error);
146
+ if (this._nextCalledDeferred) this._nextCalledDeferred.resolve();
147
+ return this._nextResult;
148
+ }
149
+ };
150
+ //#endregion
54
151
  //#region src/response/helpers/cache.ts
55
152
  function setResponseCacheHeaders(event, options) {
56
153
  options = options || {};
@@ -64,11 +161,11 @@ function setResponseCacheHeaders(event, options) {
64
161
  }
65
162
  //#endregion
66
163
  //#region src/error/module.ts
67
- const ErrorSymbol = Symbol.for("RoutupError");
68
- var RoutupError = class extends HTTPError {
164
+ const ErrorSymbol = Symbol.for("AppError");
165
+ var AppError = class extends HTTPError {
69
166
  constructor(input = {}) {
70
167
  super(input);
71
- this.name = "RoutupError";
168
+ this.name = "AppError";
72
169
  markInstanceof(this, ErrorSymbol);
73
170
  }
74
171
  };
@@ -91,7 +188,7 @@ function serializeEventStreamMessage(message) {
91
188
  //#region src/response/helpers/event-stream/module.ts
92
189
  function createEventStream(event, options) {
93
190
  if (options?.maxMessageSize !== void 0) {
94
- if (!Number.isInteger(options.maxMessageSize) || options.maxMessageSize < 0) throw new RoutupError("maxMessageSize must be a non-negative integer.");
191
+ if (!Number.isInteger(options.maxMessageSize) || options.maxMessageSize < 0) throw new AppError("maxMessageSize must be a non-negative integer.");
95
192
  }
96
193
  let controller;
97
194
  let closed = false;
@@ -133,6 +230,30 @@ function createEventStream(event, options) {
133
230
  return handle;
134
231
  }
135
232
  //#endregion
233
+ //#region src/utils/accepts-json.ts
234
+ /**
235
+ * Check if the request accepts JSON responses.
236
+ *
237
+ * Parses the `Accept` header per RFC 7231 (media ranges + quality
238
+ * params) rather than substring-matching, so:
239
+ * - `application/json-seq` does NOT count as accepting `application/json`
240
+ * - `application/json;q=0` is treated as an explicit rejection
241
+ *
242
+ * Returns true if no Accept header is present (API-first default).
243
+ */
244
+ function acceptsJson(request) {
245
+ const accept = request.headers.get("accept");
246
+ if (!accept) return true;
247
+ return accept.toLowerCase().split(",").some((entry) => {
248
+ const parts = entry.split(";").map((part) => part.trim());
249
+ const mediaRange = parts[0];
250
+ const qParam = parts.slice(1).map((param) => param.split("=").map((s) => s.trim())).find(([key]) => key === "q");
251
+ const q = qParam ? Number.parseFloat(qParam[1] ?? "") : 1;
252
+ if (!Number.isFinite(q) || q <= 0) return false;
253
+ return mediaRange === "*/*" || mediaRange === "application/*" || mediaRange === "application/json" || mediaRange.endsWith("+json");
254
+ });
255
+ }
256
+ //#endregion
136
257
  //#region src/utils/header.ts
137
258
  function sanitizeHeaderValue(value) {
138
259
  return value.replace(/[\r\n]/g, "");
@@ -180,6 +301,16 @@ function buildEtagFn(input) {
180
301
  return createEtag(body, options);
181
302
  };
182
303
  }
304
+ /**
305
+ * Default `EtagFn` used by `toResponse()` when `appOptions.etag` is
306
+ * undefined. Module-scoped so we don't allocate per-request and so
307
+ * all consumers share the same closure.
308
+ *
309
+ * `appOptions.etag === null` (explicitly disabled by the user)
310
+ * remains distinct: consumers must check `=== undefined`, not
311
+ * `== null`, before falling back to this default.
312
+ */
313
+ const DEFAULT_ETAG_FN = buildEtagFn();
183
314
  //#endregion
184
315
  //#region src/utils/trust-proxy/module.ts
185
316
  function buildTrustProxyFn(input) {
@@ -189,6 +320,15 @@ function buildTrustProxyFn(input) {
189
320
  if (typeof input === "string") input = input.split(",").map((value) => value.trim());
190
321
  return compile(input || []);
191
322
  }
323
+ /**
324
+ * Default `TrustProxyFn` used by request helpers when neither the
325
+ * call's `options.trustProxy` nor `event.appOptions.trustProxy` is
326
+ * set. Trusts no addresses — the conservative default.
327
+ *
328
+ * Module-scoped so all helpers share the same reference and we don't
329
+ * allocate per-request.
330
+ */
331
+ const DEFAULT_TRUST_PROXY = buildTrustProxyFn();
192
332
  //#endregion
193
333
  //#region src/utils/mime.ts
194
334
  function getMimeType(type) {
@@ -226,18 +366,12 @@ function basename(input, extension) {
226
366
  return extension && lastSegment.endsWith(extension) ? lastSegment.slice(0, -extension.length) : lastSegment;
227
367
  }
228
368
  //#endregion
229
- //#region src/utils/url.ts
230
- const TRAILING_SLASH_RE = /\/$|\/\?/;
231
- function hasTrailingSlash(input = "", queryParams = false) {
232
- if (!queryParams) return input.endsWith("/");
233
- return TRAILING_SLASH_RE.test(input);
234
- }
235
- function withoutTrailingSlash(input = "", queryParams = false) {
236
- if (!queryParams) return (hasTrailingSlash(input) ? input.slice(0, -1) : input) || "/";
237
- if (!hasTrailingSlash(input, true)) return input || "/";
238
- const [s0, ...s] = input.split("?");
239
- return (s0.slice(0, -1) || "/") + (s.length ? `?${s.join("?")}` : "");
369
+ //#region src/utils/promise.ts
370
+ function isPromise(p) {
371
+ return isObject(p) && (p instanceof Promise || typeof p.then === "function");
240
372
  }
373
+ //#endregion
374
+ //#region src/utils/url.ts
241
375
  function hasLeadingSlash(input = "") {
242
376
  return input.startsWith("/");
243
377
  }
@@ -248,6 +382,33 @@ function cleanDoubleSlashes(input = "") {
248
382
  if (input.includes("://")) return input.split("://").map((str) => cleanDoubleSlashes(str)).join("://");
249
383
  return input.replace(/\/+/g, "/");
250
384
  }
385
+ /**
386
+ * Concatenate path parts into a single mount path.
387
+ *
388
+ * - Drops `undefined` and empty parts.
389
+ * - A lone `'/'` part still contributes (so `joinPaths('/')` returns
390
+ * `'/'`, distinguishing "match the root exactly" from "no path").
391
+ * - Returns `undefined` when every part is missing — callers
392
+ * interpret this as "no path" (always-match middleware).
393
+ * - Joins remaining parts with `/`, normalizes the leading slash,
394
+ * collapses any inner `//`, and trims a trailing slash on results
395
+ * longer than `/`.
396
+ *
397
+ * Used at registration time to fold a handler / router's intrinsic
398
+ * path into the mount path so the active `IRouter` is the
399
+ * only place that builds path matchers.
400
+ */
401
+ function joinPaths(...parts) {
402
+ const kept = [];
403
+ for (const part of parts) {
404
+ if (typeof part !== "string" || part === "") continue;
405
+ kept.push(part);
406
+ }
407
+ if (kept.length === 0) return;
408
+ const normalized = cleanDoubleSlashes(withLeadingSlash(kept.join("/")));
409
+ if (normalized.length > 1 && normalized.endsWith("/")) return normalized.slice(0, -1);
410
+ return normalized;
411
+ }
251
412
  //#endregion
252
413
  //#region src/response/helpers/header.ts
253
414
  function appendResponseHeader(event, name, value) {
@@ -345,8 +506,8 @@ function isNativeError(input) {
345
506
  }
346
507
  /**
347
508
  * Create an internal error object by
348
- * - an existing RoutupError (returned as-is)
349
- * - an HTTPError (wrapped into a RoutupError preserving status)
509
+ * - an existing AppError (returned as-is)
510
+ * - an HTTPError (wrapped into a AppError preserving status)
350
511
  * - an Error (wrapped preserving message and cause)
351
512
  * - an options object (status, message, etc.)
352
513
  * - a message string
@@ -355,33 +516,51 @@ function isNativeError(input) {
355
516
  */
356
517
  function createError(input) {
357
518
  if (isError(input)) return input;
358
- if (typeof input === "string") return new RoutupError(input);
359
- if (isHTTPError(input)) return new RoutupError({
519
+ if (typeof input === "string") return new AppError(input);
520
+ if (isHTTPError(input)) return new AppError({
360
521
  message: input.message,
361
522
  code: input.code,
362
523
  status: input.status,
363
524
  redirectURL: input.redirectURL,
364
525
  cause: input
365
526
  });
366
- if (isNativeError(input)) return new RoutupError({
527
+ if (isNativeError(input)) return new AppError({
367
528
  message: input.message,
368
529
  cause: input
369
530
  });
370
- if (!isObject(input)) return new RoutupError();
531
+ if (!isObject(input)) return new AppError();
371
532
  const options = { ...input };
372
533
  if (options.cause === void 0) options.cause = input;
373
- return new RoutupError(options);
534
+ return new AppError(options);
374
535
  }
375
536
  //#endregion
376
537
  //#region src/response/to-response.ts
377
538
  function stripWeakPrefix(etag) {
378
539
  return etag.startsWith("W/") ? etag.slice(2) : etag;
379
540
  }
541
+ /**
542
+ * Resolve the effective etag fn for this request. `null` means the
543
+ * user explicitly disabled ETag; `undefined` means the option was
544
+ * never set, and we apply the framework default. Anything else is the
545
+ * user's own fn.
546
+ */
547
+ function effectiveEtagFn(event) {
548
+ const opt = event.appOptions.etag;
549
+ if (opt === null) return null;
550
+ if (typeof opt === "undefined") return DEFAULT_ETAG_FN;
551
+ return opt;
552
+ }
553
+ /**
554
+ * Compute an ETag and conditionally return a 304, or set the header and
555
+ * return undefined to let the caller emit the full response. Always
556
+ * async because the ETag generator may be async (and typically is, via
557
+ * `uncrypto`).
558
+ */
380
559
  async function applyEtag(body, event, headers) {
381
- const etagFn = event.routerOptions.etag;
382
- if (!etagFn) return void 0;
560
+ const etagFn = effectiveEtagFn(event);
561
+ if (!etagFn) return;
383
562
  const etag = await etagFn(body);
384
- if (!etag) return void 0;
563
+ if (!etag) return;
385
564
  headers.set("etag", etag);
386
565
  const ifNoneMatch = event.headers.get("if-none-match");
387
566
  if (ifNoneMatch && (ifNoneMatch === "*" || ifNoneMatch.split(",").some((t) => stripWeakPrefix(t.trim()) === stripWeakPrefix(etag)))) return new Response(null, {
@@ -389,41 +568,60 @@ async function applyEtag(body, event, headers) {
389
568
  headers
390
569
  });
391
570
  }
392
- async function toResponse(value, event) {
571
+ /**
572
+ * Convert a handler's return value into a Web `Response`.
573
+ *
574
+ * Returns synchronously for the common cases (string, JSON object,
575
+ * binary, stream, blob) when ETag generation is disabled. Returns a
576
+ * `Promise` when an ETag must be computed (the generator is async).
577
+ *
578
+ * Callers that want the async return uniformly can `await` the result
579
+ * — `await` on a non-Promise still works but pays a microtask hop.
580
+ * The App fast path branches on `instanceof Promise` to keep the
581
+ * sync return truly sync.
582
+ */
583
+ function toResponse(value, event) {
393
584
  if (value === void 0) return;
394
585
  if (value === null) return new Response(null, {
395
586
  status: event.response.status,
396
587
  headers: event.response.headers
397
588
  });
398
- if (value instanceof Response) return value;
399
- const { status, headers } = event.response;
400
- if (typeof value === "string") {
589
+ const t = typeof value;
590
+ if (t === "string") {
591
+ const { status, headers } = event.response;
401
592
  if (!headers.has("content-type")) headers.set("content-type", "text/plain; charset=utf-8");
402
- const cached = await applyEtag(value, event, headers);
403
- if (cached) return cached;
404
- return new Response(value, {
593
+ if (event.appOptions.etag !== null) return applyEtag(value, event, headers).then((cached) => cached ?? new Response(value, {
405
594
  status,
406
595
  headers
407
- });
408
- }
409
- if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
410
- if (!headers.has("content-type")) headers.set("content-type", "application/octet-stream");
596
+ }));
411
597
  return new Response(value, {
412
598
  status,
413
599
  headers
414
600
  });
415
601
  }
416
- if (value instanceof ReadableStream) return new Response(value, {
417
- status,
418
- headers
419
- });
420
- if (value instanceof Blob) {
421
- if (!headers.has("content-type")) headers.set("content-type", value.type || "application/octet-stream");
422
- return new Response(value, {
602
+ if (t === "object" && !Array.isArray(value)) {
603
+ if (value instanceof Response) return value;
604
+ const { status, headers } = event.response;
605
+ if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
606
+ if (!headers.has("content-type")) headers.set("content-type", "application/octet-stream");
607
+ return new Response(value, {
608
+ status,
609
+ headers
610
+ });
611
+ }
612
+ if (value instanceof ReadableStream) return new Response(value, {
423
613
  status,
424
614
  headers
425
615
  });
616
+ if (value instanceof Blob) {
617
+ if (!headers.has("content-type")) headers.set("content-type", value.type || "application/octet-stream");
618
+ return new Response(value, {
619
+ status,
620
+ headers
621
+ });
622
+ }
426
623
  }
624
+ const { status, headers } = event.response;
427
625
  if (!headers.has("content-type")) headers.set("content-type", "application/json; charset=utf-8");
428
626
  let json;
429
627
  try {
@@ -435,8 +633,10 @@ async function toResponse(value, event) {
435
633
  cause: e
436
634
  });
437
635
  }
438
- const cached = await applyEtag(json, event, headers);
439
- if (cached) return cached;
636
+ if (event.appOptions.etag !== null) return applyEtag(json, event, headers).then((cached) => cached ?? new Response(json, {
637
+ status,
638
+ headers
639
+ }));
440
640
  return new Response(json, {
441
641
  status,
442
642
  headers
@@ -576,7 +776,7 @@ function isAllowedRedirectUrl(location) {
576
776
  }
577
777
  }
578
778
  function sendRedirect(event, location, statusCode = 302) {
579
- if (!isAllowedRedirectUrl(location)) throw new RoutupError({
779
+ if (!isAllowedRedirectUrl(location)) throw new AppError({
580
780
  status: 400,
581
781
  message: "Invalid redirect URL scheme."
582
782
  });
@@ -601,69 +801,6 @@ function sendStream(event, stream) {
601
801
  });
602
802
  }
603
803
  //#endregion
604
- //#region src/event/module.ts
605
- var RoutupEvent = class {
606
- request;
607
- params;
608
- path;
609
- method;
610
- mountPath;
611
- headers;
612
- searchParams;
613
- response;
614
- store;
615
- signal;
616
- _context;
617
- _routerOptions;
618
- _nextCalled = false;
619
- _nextResult;
620
- _nextCalledDeferred;
621
- constructor(context) {
622
- this._context = context;
623
- this.request = context.request;
624
- this.params = context.params;
625
- this.path = context.path;
626
- this.method = context.method;
627
- this.mountPath = context.mountPath;
628
- this.headers = context.headers;
629
- this.searchParams = context.searchParams;
630
- this.response = context.response;
631
- this.store = context.store;
632
- this.signal = context.signal;
633
- }
634
- get routerOptions() {
635
- if (!this._routerOptions) this._routerOptions = this._context.routerOptions();
636
- return this._routerOptions;
637
- }
638
- get nextCalled() {
639
- return this._nextCalled;
640
- }
641
- get nextResult() {
642
- return this._nextResult;
643
- }
644
- whenNextCalled() {
645
- if (!this._nextCalledDeferred) {
646
- let resolve;
647
- const promise = new Promise((r) => {
648
- resolve = r;
649
- });
650
- this._nextCalledDeferred = {
651
- promise,
652
- resolve
653
- };
654
- if (this._nextCalled) resolve();
655
- }
656
- return this._nextCalledDeferred.promise;
657
- }
658
- async next(error) {
659
- if (this._nextCalled) return this._nextResult;
660
- this._nextCalled = true;
661
- this._nextResult = this._context.next(this, error);
662
- if (this._nextCalledDeferred) this._nextCalledDeferred.resolve();
663
- return this._nextResult;
664
- }
665
- };
666
- //#endregion
667
804
  //#region src/dispatcher/module.ts
668
805
  var DispatcherEvent = class {
669
806
  request;
@@ -676,7 +813,20 @@ var DispatcherEvent = class {
676
813
  methodsAllowed;
677
814
  mountPath;
678
815
  error;
679
- routerPath;
816
+ /**
817
+ * Options of the App currently dispatching this event. Set on
818
+ * entry to `App.dispatch` and restored on exit (so nested apps
819
+ * temporarily override). Initialized to `{}` so consumers
820
+ * reading before any dispatch get a valid (empty) shape.
821
+ */
822
+ appOptions;
823
+ /**
824
+ * `true` while at least one `App.dispatch` is on the call stack
825
+ * for this event. `App.dispatch` reads this on entry to derive
826
+ * `isRoot` and writes it on entry/exit so nested calls see it
827
+ * already set.
828
+ */
829
+ isDispatching;
680
830
  _dispatched;
681
831
  _response;
682
832
  _store;
@@ -705,7 +855,8 @@ var DispatcherEvent = class {
705
855
  this.path = this._url.pathname;
706
856
  this.mountPath = "/";
707
857
  this.params = {};
708
- this.routerPath = [];
858
+ this.appOptions = {};
859
+ this.isDispatching = false;
709
860
  this.methodsAllowed = /* @__PURE__ */ new Set();
710
861
  this._dispatched = false;
711
862
  this._nextCalled = false;
@@ -771,7 +922,7 @@ var DispatcherEvent = class {
771
922
  this._nextResult = void 0;
772
923
  }
773
924
  build(signal) {
774
- return new RoutupEvent({
925
+ return new AppEvent({
775
926
  request: this.request,
776
927
  params: this.params,
777
928
  path: this.path,
@@ -782,7 +933,7 @@ var DispatcherEvent = class {
782
933
  response: this.response,
783
934
  store: this.store,
784
935
  signal: signal ?? this.signal,
785
- routerOptions: () => this.resolveOptions(),
936
+ appOptions: this.appOptions,
786
937
  next: (event, error) => this.next(event, error)
787
938
  });
788
939
  }
@@ -790,23 +941,6 @@ var DispatcherEvent = class {
790
941
  if (!this._store) this._store = Object.create(null);
791
942
  return this._store;
792
943
  }
793
- resolveOptions() {
794
- const resolved = {
795
- trustProxy: () => false,
796
- subdomainOffset: 2,
797
- etag: buildEtagFn(),
798
- proxyIpMax: 0
799
- };
800
- for (let i = 0; i < this.routerPath.length; i++) {
801
- const node = this.routerPath[i];
802
- const entries = Object.entries(node.options);
803
- for (const entry of entries) {
804
- const [key, value] = entry;
805
- if (typeof value !== "undefined") resolved[key] = value;
806
- }
807
- }
808
- return resolved;
809
- }
810
944
  };
811
945
  //#endregion
812
946
  //#region src/handler/constants.ts
@@ -818,8 +952,17 @@ const HandlerSymbol = Symbol.for("Handler");
818
952
  //#endregion
819
953
  //#region src/hook/constants.ts
820
954
  const HookName = {
821
- REQUEST: "request",
822
- RESPONSE: "response",
955
+ /**
956
+ * Fired at the start of `App.dispatch`, before the pipeline walk.
957
+ * Once per router per request.
958
+ */
959
+ START: "start",
960
+ /**
961
+ * Fired at the end of `App.dispatch`, after the pipeline walk
962
+ * (and OPTIONS auto-Allow synthesis) completes. Once per router per
963
+ * request.
964
+ */
965
+ END: "end",
823
966
  ERROR: "error",
824
967
  CHILD_MATCH: "childMatch",
825
968
  CHILD_DISPATCH_BEFORE: "childDispatchBefore",
@@ -829,8 +972,25 @@ const HookName = {
829
972
  //#region src/hook/module.ts
830
973
  var Hooks = class Hooks {
831
974
  items;
975
+ /**
976
+ * Derived bit: `true` iff at least one entry exists across all
977
+ * hook names. Maintained on every `addListener` / `removeListener`
978
+ * so the dispatch hot path can short-circuit on a single boolean
979
+ * read rather than per-name lookup. Apps that never register a
980
+ * hook (the common case) pay one boolean check per request
981
+ * instead of a property access per pipeline step.
982
+ */
983
+ _hasAny;
832
984
  constructor() {
833
985
  this.items = {};
986
+ this._hasAny = false;
987
+ }
988
+ hasAny() {
989
+ return this._hasAny;
990
+ }
991
+ hasListeners(name) {
992
+ if (!this._hasAny) return false;
993
+ return this.items[name] !== void 0;
834
994
  }
835
995
  addListener(name, fn, priority = 0) {
836
996
  this.items[name] = this.items[name] || [];
@@ -841,6 +1001,7 @@ var Hooks = class Hooks {
841
1001
  let i = 0;
842
1002
  while (i < this.items[name].length && this.items[name][i].priority >= priority) i++;
843
1003
  this.items[name].splice(i, 0, entry);
1004
+ this._hasAny = true;
844
1005
  return () => {
845
1006
  this.removeListener(name, fn);
846
1007
  };
@@ -849,6 +1010,7 @@ var Hooks = class Hooks {
849
1010
  if (!this.items[name]) return;
850
1011
  if (typeof fn === "undefined") {
851
1012
  delete this.items[name];
1013
+ this.recomputeHasAny();
852
1014
  return;
853
1015
  }
854
1016
  if (typeof fn === "function") {
@@ -856,6 +1018,20 @@ var Hooks = class Hooks {
856
1018
  if (index !== -1) this.items[name].splice(index, 1);
857
1019
  }
858
1020
  if (this.items[name].length === 0) delete this.items[name];
1021
+ this.recomputeHasAny();
1022
+ }
1023
+ /**
1024
+ * Recompute `_hasAny` from the current `items` map. O(k) where k
1025
+ * is the number of distinct hook names ever registered (≤ ~6) —
1026
+ * effectively O(1). Called from `removeListener` so the fast-path
1027
+ * flag stays in sync with registration state.
1028
+ */
1029
+ recomputeHasAny() {
1030
+ for (const name in this.items) if (this.items[name] && this.items[name].length > 0) {
1031
+ this._hasAny = true;
1032
+ return;
1033
+ }
1034
+ this._hasAny = false;
859
1035
  }
860
1036
  /**
861
1037
  * Create a new `Hooks` instance seeded with the same listeners as this
@@ -907,120 +1083,35 @@ var Hooks = class Hooks {
907
1083
  }
908
1084
  };
909
1085
  //#endregion
910
- //#region src/path/matcher.ts
911
- function decodeParam(val) {
912
- /* istanbul ignore next */
913
- if (typeof val !== "string" || val.length === 0) return val;
914
- try {
915
- return decodeURIComponent(val);
916
- } catch {
917
- return val;
1086
+ //#region src/handler/module.ts
1087
+ var Handler = class {
1088
+ config;
1089
+ hooks;
1090
+ method;
1091
+ constructor(handler) {
1092
+ this.config = handler;
1093
+ this.hooks = new Hooks();
1094
+ this.mountHooks();
1095
+ if (typeof handler.path === "string") this.config.path = withLeadingSlash(handler.path);
1096
+ this.method = this.config.method ? toMethodName(this.config.method) : void 0;
1097
+ markInstanceof(this, HandlerSymbol);
918
1098
  }
919
- }
920
- var PathMatcher = class {
921
- path;
922
- regexp;
923
- regexpKeys = [];
924
- regexpOptions;
925
- constructor(path, options) {
926
- this.path = path;
927
- this.regexpOptions = options || {};
928
- const regexp = pathToRegexp(path, options);
929
- this.regexp = regexp.regexp;
930
- this.regexpKeys = regexp.keys;
1099
+ get type() {
1100
+ return this.config.type;
931
1101
  }
932
- test(path) {
933
- return this.regexp.test(path);
934
- }
935
- exec(path) {
936
- if (this.path === "/" && this.regexpOptions.end === false) return {
937
- path: "/",
938
- params: Object.create(null)
939
- };
940
- const match = this.regexp.exec(path);
941
- if (!match) return;
942
- const params = Object.create(null);
943
- for (let i = 1; i < match.length; i++) {
944
- const key = this.regexpKeys[i - 1];
945
- if (!key) continue;
946
- const prop = key.name;
947
- const val = decodeParam(match[i]);
948
- if (typeof val !== "undefined") params[prop] = val;
949
- }
950
- return {
951
- path: match[0],
952
- params
953
- };
954
- }
955
- };
956
- //#endregion
957
- //#region src/path/utils.ts
958
- function isPath(input) {
959
- return typeof input === "string";
960
- }
961
- //#endregion
962
- //#region src/handler/utils.ts
963
- /**
964
- * Build a `PathMatcher` for a handler-side path.
965
- *
966
- * Returns `undefined` when no path is supplied. The `end` flag controls
967
- * whether the matcher requires a full match (`true` for method handlers
968
- * matching exact routes) or accepts a prefix (`false` for middleware).
969
- */
970
- function buildHandlerPathMatcher(path, end) {
971
- if (typeof path === "undefined") return;
972
- return new PathMatcher(typeof path === "string" ? withLeadingSlash(path) : path, { end });
973
- }
974
- /**
975
- * Match a request method against a handler's bound method.
976
- *
977
- * - When the handler has no method bound, matches every request method.
978
- * - Otherwise matches when the request method is the same.
979
- * - HEAD requests additionally match GET handlers.
980
- */
981
- function matchHandlerMethod(handlerMethod, requestMethod) {
982
- return !handlerMethod || requestMethod === handlerMethod || requestMethod === MethodName.HEAD && handlerMethod === MethodName.GET;
983
- }
984
- //#endregion
985
- //#region src/handler/module.ts
986
- var Handler = class {
987
- config;
988
- hooks;
989
- pathMatcher;
990
- method;
991
- constructor(handler) {
992
- this.config = handler;
993
- this.hooks = new Hooks();
994
- this.mountHooks();
995
- if (typeof handler.path === "string") this.config.path = withLeadingSlash(handler.path);
996
- this.pathMatcher = buildHandlerPathMatcher(this.config.path, !!this.config.method);
997
- this.method = this.config.method ? toMethodName(this.config.method) : void 0;
998
- markInstanceof(this, HandlerSymbol);
999
- }
1000
- get type() {
1001
- return this.config.type;
1002
- }
1003
- get path() {
1004
- return this.config.path;
1102
+ get path() {
1103
+ return this.config.path;
1005
1104
  }
1006
1105
  async dispatch(event) {
1007
- if (this.pathMatcher) {
1008
- const pathMatch = this.pathMatcher.exec(event.path);
1009
- if (pathMatch) event.params = {
1010
- ...event.params,
1011
- ...pathMatch.params
1012
- };
1106
+ if (this.hooks.hasListeners(HookName.CHILD_DISPATCH_BEFORE)) {
1107
+ await this.hooks.trigger(HookName.CHILD_DISPATCH_BEFORE, event);
1108
+ if (event.dispatched) return;
1013
1109
  }
1014
- await this.hooks.trigger(HookName.CHILD_DISPATCH_BEFORE, event);
1015
- if (event.dispatched) return;
1016
1110
  let response;
1017
1111
  try {
1018
- let result;
1019
- const previewEvent = event.build();
1020
- const effectiveTimeout = this.resolveTimeout(previewEvent.routerOptions);
1112
+ const effectiveTimeout = this.resolveTimeout(event.appOptions);
1021
1113
  let childController;
1022
1114
  let cleanupParentListener;
1023
- let handlerEvent = previewEvent;
1024
1115
  if (effectiveTimeout) {
1025
1116
  const parentSignal = event.signal;
1026
1117
  childController = new AbortController();
@@ -1030,37 +1121,44 @@ var Handler = class {
1030
1121
  parentSignal.addEventListener("abort", onAbort, { once: true });
1031
1122
  cleanupParentListener = () => parentSignal.removeEventListener("abort", onAbort);
1032
1123
  }
1033
- handlerEvent = event.build(childController.signal);
1034
1124
  }
1125
+ const handlerEvent = childController ? event.build(childController.signal) : event.build();
1126
+ let result;
1035
1127
  try {
1036
- if (this.config.type === HandlerType.ERROR) {
1037
- if (event.error) {
1038
- const { fn } = this.config;
1039
- const { error } = event;
1040
- result = await this.executeWithTimeout(() => this.resolveHandlerResult(fn(error, handlerEvent), handlerEvent), handlerEvent.routerOptions, childController);
1041
- }
1042
- } else {
1128
+ let skipFn = false;
1129
+ let invocation;
1130
+ if (this.config.type === HandlerType.ERROR) if (event.error) {
1131
+ const { fn } = this.config;
1132
+ invocation = fn(event.error, handlerEvent);
1133
+ } else skipFn = true;
1134
+ else {
1043
1135
  const { fn } = this.config;
1044
- result = await this.executeWithTimeout(() => this.resolveHandlerResult(fn(handlerEvent), handlerEvent), handlerEvent.routerOptions, childController);
1136
+ invocation = fn(handlerEvent);
1045
1137
  }
1138
+ if (skipFn) {} else if (effectiveTimeout) result = await this.executeWithTimeout(() => this.resolveHandlerResult(invocation, handlerEvent), effectiveTimeout, childController);
1139
+ else if (isPromise(invocation)) {
1140
+ const awaited = await invocation;
1141
+ result = typeof awaited === "undefined" ? await this.resolveHandlerResult(void 0, handlerEvent) : awaited;
1142
+ } else if (typeof invocation === "undefined") result = await this.resolveHandlerResult(void 0, handlerEvent);
1143
+ else result = invocation;
1046
1144
  } finally {
1047
1145
  if (cleanupParentListener) cleanupParentListener();
1048
1146
  }
1049
- response = await toResponse(result, handlerEvent);
1050
- if (response) event.dispatched = true;
1147
+ const toResp = toResponse(result, handlerEvent);
1148
+ response = isPromise(toResp) ? await toResp : toResp;
1149
+ if (response) {
1150
+ event.dispatched = true;
1151
+ if (this.config.type === HandlerType.ERROR && event.error) event.error = void 0;
1152
+ }
1051
1153
  } catch (e) {
1052
1154
  event.error = isError(e) ? e : createError(e);
1053
- await this.hooks.trigger(HookName.ERROR, event);
1155
+ if (this.hooks.hasListeners(HookName.ERROR)) await this.hooks.trigger(HookName.ERROR, event);
1054
1156
  if (event.dispatched) event.error = void 0;
1055
1157
  else throw event.error;
1056
1158
  }
1057
- await this.hooks.trigger(HookName.CHILD_DISPATCH_AFTER, event);
1159
+ if (this.hooks.hasListeners(HookName.CHILD_DISPATCH_AFTER)) await this.hooks.trigger(HookName.CHILD_DISPATCH_AFTER, event);
1058
1160
  return response;
1059
1161
  }
1060
- matchPath(path) {
1061
- if (!this.pathMatcher) return true;
1062
- return this.pathMatcher.test(path);
1063
- }
1064
1162
  /**
1065
1163
  * Resolve a handler's return value into the final value handed to `toResponse`.
1066
1164
  *
@@ -1096,8 +1194,7 @@ var Handler = class {
1096
1194
  });
1097
1195
  });
1098
1196
  }
1099
- async executeWithTimeout(fn, routerOptions, controller) {
1100
- const effectiveTimeout = this.resolveTimeout(routerOptions);
1197
+ async executeWithTimeout(fn, effectiveTimeout, controller) {
1101
1198
  if (!effectiveTimeout) return fn();
1102
1199
  let timerId;
1103
1200
  try {
@@ -1114,13 +1211,13 @@ var Handler = class {
1114
1211
  clearTimeout(timerId);
1115
1212
  }
1116
1213
  }
1117
- resolveTimeout(routerOptions) {
1118
- const routerDefault = routerOptions.handlerTimeout;
1214
+ resolveTimeout(appOptions) {
1215
+ const routerDefault = appOptions.handlerTimeout;
1119
1216
  const handlerOverride = this.config.timeout;
1120
1217
  if (!routerDefault && !handlerOverride) return;
1121
1218
  if (!routerDefault) return handlerOverride;
1122
1219
  if (!handlerOverride) return routerDefault;
1123
- if (routerOptions.handlerTimeoutOverridable) return handlerOverride;
1220
+ if (appOptions.handlerTimeoutOverridable) return handlerOverride;
1124
1221
  return Math.min(routerDefault, handlerOverride);
1125
1222
  }
1126
1223
  mountHooks() {
@@ -1226,10 +1323,10 @@ function callMiddleware(handler, req, res) {
1226
1323
  });
1227
1324
  }
1228
1325
  function createNodeBridge(handler, isMiddleware) {
1229
- if (typeof handler !== "function") throw new RoutupError("fromNodeHandler/fromNodeMiddleware expects a function.");
1326
+ if (typeof handler !== "function") throw new AppError("fromNodeHandler/fromNodeMiddleware expects a function.");
1230
1327
  return defineCoreHandler({ fn: (async (event) => {
1231
1328
  const node = event.request.runtime?.node;
1232
- if (!node?.req || !node?.res) throw new RoutupError("fromNodeHandler/fromNodeMiddleware requires a Node.js runtime.");
1329
+ if (!node?.req || !node?.res) throw new AppError("fromNodeHandler/fromNodeMiddleware requires a Node.js runtime.");
1233
1330
  const req = node.req;
1234
1331
  const res = node.res;
1235
1332
  if ((isMiddleware ? await callMiddleware(handler, req, res) : await callHandler(handler, req, res)) === kHandled) return null;
@@ -1277,7 +1374,7 @@ function isWebHandler(input) {
1277
1374
  //#region src/handler/adapters/web/define.ts
1278
1375
  function fromWebHandler(input) {
1279
1376
  if (isWebHandlerProvider(input)) return fromWebHandler(input.fetch.bind(input));
1280
- if (typeof input !== "function") throw new RoutupError("fromWebHandler expects a function or an object with a fetch method.");
1377
+ if (typeof input !== "function") throw new AppError("fromWebHandler expects a function or an object with a fetch method.");
1281
1378
  return defineCoreHandler({ fn: (event) => input(event.request) });
1282
1379
  }
1283
1380
  //#endregion
@@ -1289,6 +1386,18 @@ function isHandler(input) {
1289
1386
  return hasInstanceof(input, HandlerSymbol);
1290
1387
  }
1291
1388
  //#endregion
1389
+ //#region src/handler/utils.ts
1390
+ /**
1391
+ * Match a request method against a handler's bound method.
1392
+ *
1393
+ * - When the handler has no method bound, matches every request method.
1394
+ * - Otherwise matches when the request method is the same.
1395
+ * - HEAD requests additionally match GET handlers.
1396
+ */
1397
+ function matchHandlerMethod(handlerMethod, requestMethod) {
1398
+ return !handlerMethod || requestMethod === handlerMethod || requestMethod === MethodName.HEAD && handlerMethod === MethodName.GET;
1399
+ }
1400
+ //#endregion
1292
1401
  //#region src/request/helpers/cache.ts
1293
1402
  function isRequestCacheable(event, modifiedTime) {
1294
1403
  const modifiedSince = event.headers.get(HeaderName.IF_MODIFIED_SINCE);
@@ -1343,7 +1452,7 @@ function matchRequestContentType(event, contentType) {
1343
1452
  function getRequestHostName(event, options = {}) {
1344
1453
  let trustProxy;
1345
1454
  if (typeof options.trustProxy !== "undefined") trustProxy = buildTrustProxyFn(options.trustProxy);
1346
- else trustProxy = event.routerOptions.trustProxy;
1455
+ else trustProxy = event.appOptions.trustProxy ?? DEFAULT_TRUST_PROXY;
1347
1456
  let hostname = event.headers.get(HeaderName.X_FORWARDED_HOST);
1348
1457
  if (!hostname || !event.request.ip || !trustProxy(event.request.ip, 0)) hostname = event.headers.get(HeaderName.HOST);
1349
1458
  else if (hostname && hostname.includes(",")) hostname = hostname.substring(0, hostname.indexOf(",")).trimEnd();
@@ -1366,7 +1475,7 @@ function getRequestHostName(event, options = {}) {
1366
1475
  function getRequestIP(event, options = {}) {
1367
1476
  let trustProxy;
1368
1477
  if (typeof options.trustProxy !== "undefined") trustProxy = buildTrustProxyFn(options.trustProxy);
1369
- else trustProxy = event.routerOptions.trustProxy;
1478
+ else trustProxy = event.appOptions.trustProxy ?? DEFAULT_TRUST_PROXY;
1370
1479
  const socketAddr = event.request.ip;
1371
1480
  if (!socketAddr) return;
1372
1481
  const forwarded = event.headers.get(HeaderName.X_FORWARDED_FOR);
@@ -1386,7 +1495,7 @@ function getRequestIP(event, options = {}) {
1386
1495
  function getRequestProtocol(event, options = {}) {
1387
1496
  let trustProxy;
1388
1497
  if (typeof options.trustProxy !== "undefined") trustProxy = buildTrustProxyFn(options.trustProxy);
1389
- else trustProxy = event.routerOptions.trustProxy;
1498
+ else trustProxy = event.appOptions.trustProxy ?? DEFAULT_TRUST_PROXY;
1390
1499
  let protocol;
1391
1500
  try {
1392
1501
  if (new URL(event.request.url).protocol === "https:") protocol = "https";
@@ -1403,6 +1512,58 @@ function getRequestProtocol(event, options = {}) {
1403
1512
  return protocol;
1404
1513
  }
1405
1514
  //#endregion
1515
+ //#region src/path/matcher.ts
1516
+ function decodeParam(val) {
1517
+ /* istanbul ignore next */
1518
+ if (typeof val !== "string" || val.length === 0) return val;
1519
+ try {
1520
+ return decodeURIComponent(val);
1521
+ } catch {
1522
+ return val;
1523
+ }
1524
+ }
1525
+ var PathMatcher = class {
1526
+ path;
1527
+ regexp;
1528
+ regexpKeys = [];
1529
+ regexpOptions;
1530
+ constructor(path, options) {
1531
+ this.path = path;
1532
+ this.regexpOptions = options || {};
1533
+ const regexp = pathToRegexp(path, options);
1534
+ this.regexp = regexp.regexp;
1535
+ this.regexpKeys = regexp.keys;
1536
+ }
1537
+ test(path) {
1538
+ return this.regexp.test(path);
1539
+ }
1540
+ exec(path) {
1541
+ if (this.path === "/" && this.regexpOptions.end === false) return {
1542
+ path: "/",
1543
+ params: Object.create(null)
1544
+ };
1545
+ const match = this.regexp.exec(path);
1546
+ if (!match) return;
1547
+ const params = Object.create(null);
1548
+ for (let i = 1; i < match.length; i++) {
1549
+ const key = this.regexpKeys[i - 1];
1550
+ if (!key) continue;
1551
+ const prop = key.name;
1552
+ const val = decodeParam(match[i]);
1553
+ if (typeof val !== "undefined") params[prop] = val;
1554
+ }
1555
+ return {
1556
+ path: match[0],
1557
+ params
1558
+ };
1559
+ }
1560
+ };
1561
+ //#endregion
1562
+ //#region src/path/utils.ts
1563
+ function isPath(input) {
1564
+ return typeof input === "string";
1565
+ }
1566
+ //#endregion
1406
1567
  //#region src/plugin/error/constants.ts
1407
1568
  const PluginErrorCode = {
1408
1569
  PLUGIN: "PLUGIN",
@@ -1419,7 +1580,7 @@ function isPluginError(input) {
1419
1580
  }
1420
1581
  //#endregion
1421
1582
  //#region src/plugin/error/module.ts
1422
- var PluginError = class extends RoutupError {
1583
+ var PluginError = class extends AppError {
1423
1584
  constructor(input = {}) {
1424
1585
  const options = typeof input === "string" ? { message: input } : { ...input };
1425
1586
  if (!("code" in options) || !options.code) options.code = PluginErrorCode.PLUGIN;
@@ -1477,22 +1638,795 @@ function isPlugin(input) {
1477
1638
  return typeof input.install === "function" && input.install.length === 1;
1478
1639
  }
1479
1640
  //#endregion
1480
- //#region src/router/options.ts
1481
- function normalizeRouterOptions(input) {
1482
- if (typeof input.etag !== "undefined") input.etag = buildEtagFn(input.etag);
1483
- if (typeof input.trustProxy !== "undefined") input.trustProxy = buildTrustProxyFn(input.trustProxy);
1641
+ //#region src/router/utils.ts
1642
+ /**
1643
+ * Build a path-to-regexp-backed `PathMatcher` for the route's mount
1644
+ * path, applying the exact-vs-prefix convention every router should
1645
+ * agree on:
1646
+ *
1647
+ * - `route.method !== undefined` → exact match (method-bound route)
1648
+ * - `route.method === undefined` → prefix match (middleware / nested
1649
+ * app)
1650
+ *
1651
+ * Returns `undefined` when the route has no mount path — middleware
1652
+ * registered without a path matches every request.
1653
+ *
1654
+ * Routers are free to ignore this helper and build their own match
1655
+ * mechanism (radix tree, single aggregated regex, etc.) — it's
1656
+ * provided as a convenience for routers that want path-to-regexp
1657
+ * semantics with minimal boilerplate.
1658
+ */
1659
+ function buildRoutePathMatcher(route) {
1660
+ if (typeof route.path === "undefined") return;
1661
+ const end = typeof route.method !== "undefined";
1662
+ if (!end && route.path === "/") return;
1663
+ return new PathMatcher(route.path, { end });
1664
+ }
1665
+ //#endregion
1666
+ //#region src/router/linear/module.ts
1667
+ /**
1668
+ * Default router — walks registered routes linearly per request and
1669
+ * runs each route's mount-level matcher (built via `buildRoutePathMatcher`,
1670
+ * path-to-regexp-backed). Routes without a mount path (mount-less
1671
+ * middleware / nested apps registered via `.use(handler)`) match every
1672
+ * request directly — there is no per-route `matchPath()` fallback.
1673
+ *
1674
+ * Behaviour-preserving wrapper around the previous in-line stack walk
1675
+ * in `executePipelineStepLookup`. The matcher allocations live here
1676
+ * (not on the registered route), so routers using a different matching
1677
+ * strategy (radix tree, aggregated regex, …) can ignore this file
1678
+ * entirely.
1679
+ *
1680
+ * Optional per-router lookup cache: pass an `ICache` via
1681
+ * `BaseRouterOptions.cache` to skip the linear walk on repeated
1682
+ * requests for the same path. Default is no caching.
1683
+ */
1684
+ var LinearRouter = class LinearRouter {
1685
+ _routes;
1686
+ _matchers;
1687
+ cache;
1688
+ constructor(options = {}) {
1689
+ this._routes = [];
1690
+ this._matchers = [];
1691
+ this.cache = options.cache;
1692
+ }
1693
+ add(route) {
1694
+ this._routes.push(route);
1695
+ this._matchers.push(buildRoutePathMatcher(route));
1696
+ this.cache?.clear();
1697
+ }
1698
+ lookup(path, _method) {
1699
+ const cached = this.cache?.get(path);
1700
+ if (typeof cached !== "undefined") return cached;
1701
+ const matches = [];
1702
+ for (let i = 0; i < this._routes.length; i++) {
1703
+ const route = this._routes[i];
1704
+ const matcher = this._matchers[i];
1705
+ if (matcher) {
1706
+ const output = matcher.exec(path);
1707
+ if (typeof output === "undefined") continue;
1708
+ matches.push({
1709
+ route,
1710
+ index: i,
1711
+ params: output.params,
1712
+ path: output.path
1713
+ });
1714
+ continue;
1715
+ }
1716
+ matches.push({
1717
+ route,
1718
+ index: i,
1719
+ params: Object.create(null)
1720
+ });
1721
+ }
1722
+ this.cache?.set(path, matches);
1723
+ return matches;
1724
+ }
1725
+ clone() {
1726
+ return new LinearRouter({ cache: this.cache?.clone() });
1727
+ }
1728
+ };
1729
+ //#endregion
1730
+ //#region src/router/trie/parser.ts
1731
+ /**
1732
+ * Trie-native path parser.
1733
+ *
1734
+ * Replaces the call-out to `path-to-regexp` for the syntax surface
1735
+ * the trie advertises:
1736
+ *
1737
+ * - Static segments: `users`, `v1`
1738
+ * - Named params: `:id`, `:slug`
1739
+ * - Optional params: `:id?` (T2 — expanded to two variants)
1740
+ * - Optional groups: `{...}` (T2 — expanded to two variants)
1741
+ * - Bare splat: `*` (matches the rest of the path)
1742
+ * - Named splat: `*rest`
1743
+ *
1744
+ * Returns a list of `Segment[]` *variants* — one path string can
1745
+ * expand into multiple route variants when it contains optional
1746
+ * markers. The trie inserts every variant with the same registration
1747
+ * `index` so they dedupe naturally on the candidate list.
1748
+ *
1749
+ * Returns `null` when the path uses syntax outside this surface
1750
+ * (compound segments `/files/:n.ext`, escape sequences `\:`, regex
1751
+ * constraints, splat-not-last, …). The caller falls back to the
1752
+ * universal bucket so correctness is preserved via path-to-regexp.
1753
+ *
1754
+ * Variant cap: nested optional groups expand combinatorially. The
1755
+ * parser caps the variant count at `MAX_VARIANTS` and falls back to
1756
+ * the universal bucket above that — registration-time explosion
1757
+ * isn't worth the lookup-time win on degenerate paths.
1758
+ */
1759
+ const MAX_VARIANTS = 16;
1760
+ const PARAM_NAME = /^[a-zA-Z_]\w*$/;
1761
+ /**
1762
+ * Tokenize a single path segment (the substring between two `/`).
1763
+ * Returns `null` if the segment uses syntax we don't handle.
1764
+ *
1765
+ * Each segment must yield exactly one token — compound segments
1766
+ * (`/files/:n.ext`, `/users-:id`) produce multiple tokens and so
1767
+ * trip this check, falling back to the universal bucket.
1768
+ */
1769
+ function tokenizeSegment(segment) {
1770
+ if (segment === "") return null;
1771
+ if (segment === "*") return {
1772
+ kind: "splat",
1773
+ name: "*"
1774
+ };
1775
+ if (segment.charAt(0) === "*") {
1776
+ const rest = segment.slice(1);
1777
+ if (PARAM_NAME.test(rest)) return {
1778
+ kind: "splat",
1779
+ name: rest
1780
+ };
1781
+ return null;
1782
+ }
1783
+ if (segment.charAt(0) === ":") {
1784
+ const optional = segment.charAt(segment.length - 1) === "?";
1785
+ const nameRaw = optional ? segment.slice(1, -1) : segment.slice(1);
1786
+ if (PARAM_NAME.test(nameRaw)) return {
1787
+ kind: "param",
1788
+ name: nameRaw,
1789
+ optional
1790
+ };
1791
+ return null;
1792
+ }
1793
+ if (/^[a-zA-Z0-9_\-.~%]+$/.test(segment)) return {
1794
+ kind: "literal",
1795
+ value: segment
1796
+ };
1797
+ return null;
1798
+ }
1799
+ /**
1800
+ * Tokenize the full path into a token stream, recognizing slash-
1801
+ * spanning optional groups (`/users{/edit/:id}`).
1802
+ *
1803
+ * The group-open marker is emitted in place of the leading `/`
1804
+ * inside `{...}`; group-close before the trailing `}`. The walker
1805
+ * later expands by either keeping the run between markers or
1806
+ * dropping it.
1807
+ */
1808
+ function tokenizePath(path) {
1809
+ const trimmed = path.charAt(0) === "/" ? path.slice(1) : path;
1810
+ if (trimmed === "") return [];
1811
+ const tokens = [];
1812
+ let i = 0;
1813
+ const n = trimmed.length;
1814
+ while (i < n) {
1815
+ if (trimmed.charAt(i) === "{") {
1816
+ let close = -1;
1817
+ for (let j = i + 1; j < n; j++) {
1818
+ const c = trimmed.charAt(j);
1819
+ if (c === "{") return null;
1820
+ if (c === "}") {
1821
+ close = j;
1822
+ break;
1823
+ }
1824
+ }
1825
+ if (close === -1) return null;
1826
+ const inner = trimmed.slice(i + 1, close);
1827
+ if (inner.charAt(0) !== "/") return null;
1828
+ const after = close + 1 < n ? trimmed.charAt(close + 1) : "";
1829
+ if (after !== "" && after !== "/" && after !== "{") return null;
1830
+ tokens.push({ kind: "groupOpen" });
1831
+ const innerTokens = tokenizePath(inner);
1832
+ if (innerTokens === null) return null;
1833
+ for (const t of innerTokens) tokens.push(t);
1834
+ tokens.push({ kind: "groupClose" });
1835
+ i = close + 1;
1836
+ continue;
1837
+ }
1838
+ let segEnd = i;
1839
+ while (segEnd < n) {
1840
+ const c = trimmed.charAt(segEnd);
1841
+ if (c === "/" || c === "{") break;
1842
+ segEnd++;
1843
+ }
1844
+ const segment = trimmed.slice(i, segEnd);
1845
+ if (segment !== "") {
1846
+ const token = tokenizeSegment(segment);
1847
+ if (token === null) return null;
1848
+ tokens.push(token);
1849
+ }
1850
+ i = segEnd;
1851
+ if (i < n && trimmed.charAt(i) === "/") i++;
1852
+ }
1853
+ return tokens;
1854
+ }
1855
+ /**
1856
+ * Expand the token stream into one or more concrete `Token[]`
1857
+ * variants by:
1858
+ * 1. Splitting `groupOpen` … `groupClose` runs into a "with run"
1859
+ * and a "without run" choice (one optional group → ×2 variants).
1860
+ * 2. Splitting `param.optional = true` into a "with" and "without"
1861
+ * choice (one `:id?` → ×2 variants).
1862
+ *
1863
+ * Caps at `MAX_VARIANTS` — beyond that, returns `null` so the path
1864
+ * falls back to the universal bucket.
1865
+ *
1866
+ * Returns `Token[][]` (not `Segment[][]`) so the recursive
1867
+ * group-expansion can splice inner variants back into the outer
1868
+ * token stream without a lossy round-trip through `Segment`.
1869
+ * `parsePath` does the final `Token → Segment` projection.
1870
+ */
1871
+ function expand(tokens) {
1872
+ let variants = [[]];
1873
+ for (let i = 0; i < tokens.length; i++) {
1874
+ const token = tokens[i];
1875
+ if (token.kind === "groupOpen") {
1876
+ let depth = 1;
1877
+ let close = -1;
1878
+ for (let j = i + 1; j < tokens.length; j++) {
1879
+ const t = tokens[j];
1880
+ if (t.kind === "groupOpen") depth++;
1881
+ else if (t.kind === "groupClose") {
1882
+ depth--;
1883
+ if (depth === 0) {
1884
+ close = j;
1885
+ break;
1886
+ }
1887
+ }
1888
+ }
1889
+ if (close === -1) return null;
1890
+ const expandedInner = expand(tokens.slice(i + 1, close));
1891
+ if (expandedInner === null) return null;
1892
+ const next = [];
1893
+ for (const v of variants) {
1894
+ if (next.length >= MAX_VARIANTS) return null;
1895
+ next.push(v.slice());
1896
+ for (const innerVariant of expandedInner) {
1897
+ if (next.length >= MAX_VARIANTS) return null;
1898
+ next.push(v.concat(innerVariant));
1899
+ }
1900
+ }
1901
+ variants = next;
1902
+ i = close;
1903
+ continue;
1904
+ }
1905
+ if (token.kind === "param" && token.optional) {
1906
+ const stripped = {
1907
+ kind: "param",
1908
+ name: token.name,
1909
+ optional: false
1910
+ };
1911
+ const next = [];
1912
+ for (const v of variants) {
1913
+ if (next.length >= MAX_VARIANTS) return null;
1914
+ next.push(v.slice());
1915
+ if (next.length >= MAX_VARIANTS) return null;
1916
+ next.push(v.concat([stripped]));
1917
+ }
1918
+ variants = next;
1919
+ continue;
1920
+ }
1921
+ for (const v of variants) v.push(token);
1922
+ }
1923
+ return variants;
1924
+ }
1925
+ function tokenToSegment(t) {
1926
+ if (t.kind === "literal") return {
1927
+ kind: "static",
1928
+ value: t.value
1929
+ };
1930
+ if (t.kind === "param") return {
1931
+ kind: "param",
1932
+ name: t.name
1933
+ };
1934
+ if (t.kind === "splat") return {
1935
+ kind: "splat",
1936
+ name: t.name
1937
+ };
1938
+ return null;
1939
+ }
1940
+ /**
1941
+ * Stable, structural identity for a variant — used to drop duplicate
1942
+ * expansions like `/users{/:id?}` (which produces the bare-`/users`
1943
+ * variant twice: once from the "without group" branch and once from
1944
+ * the "with group, without optional param" branch).
1945
+ */
1946
+ function variantKey(segs) {
1947
+ let out = "";
1948
+ for (const s of segs) if (s.kind === "static") out += `/s:${s.value}`;
1949
+ else if (s.kind === "param") out += `/p:${s.name}`;
1950
+ else out += `/*:${s.name}`;
1951
+ return out;
1952
+ }
1953
+ function parsePath(path) {
1954
+ const tokens = tokenizePath(path);
1955
+ if (tokens === null) return null;
1956
+ const variants = expand(tokens);
1957
+ if (variants === null) return null;
1958
+ const result = [];
1959
+ const seen = /* @__PURE__ */ new Set();
1960
+ for (const v of variants) {
1961
+ const segs = [];
1962
+ for (const t of v) {
1963
+ const s = tokenToSegment(t);
1964
+ if (s === null) return null;
1965
+ segs.push(s);
1966
+ }
1967
+ for (let i = 0; i < segs.length - 1; i++) if (segs[i].kind === "splat") return null;
1968
+ const key = variantKey(segs);
1969
+ if (seen.has(key)) continue;
1970
+ seen.add(key);
1971
+ result.push(segs);
1972
+ }
1973
+ return result;
1974
+ }
1975
+ //#endregion
1976
+ //#region src/router/trie/node.ts
1977
+ function createMethodBuckets() {
1978
+ return Object.create(null);
1979
+ }
1980
+ function createTrieNode() {
1981
+ return {
1982
+ staticChildren: /* @__PURE__ */ new Map(),
1983
+ splatRoutes: createMethodBuckets(),
1984
+ exactRoutes: createMethodBuckets(),
1985
+ prefixRoutes: []
1986
+ };
1987
+ }
1988
+ //#endregion
1989
+ //#region src/router/trie/module.ts
1990
+ function decodeOrRaw(s) {
1991
+ try {
1992
+ return decodeURIComponent(s);
1993
+ } catch {
1994
+ return s;
1995
+ }
1996
+ }
1997
+ /**
1998
+ * Build a `params` object from a request's pre-split segments using
1999
+ * the variant's pre-computed `ParamCapture[]`. No regex execution —
2000
+ * each capture is one indexed read from `segments` (and a join for
2001
+ * splats). Replaces the `matcher.exec` confirm pass for trie-walked
2002
+ * routes (T3).
2003
+ */
2004
+ function extractTrieParams(segments, indexMap) {
2005
+ const out = Object.create(null);
2006
+ for (const cap of indexMap) if (cap.kind === "segment") out[cap.name] = decodeOrRaw(segments[cap.depth]);
2007
+ else {
2008
+ const slice = segments.slice(cap.depth).join("/");
2009
+ out[cap.name] = decodeOrRaw(slice);
2010
+ }
2011
+ return out;
2012
+ }
2013
+ /**
2014
+ * Compute `match.path` (the matched-prefix string) from the request's
2015
+ * segments and the variant's recorded depth.
2016
+ *
2017
+ * - Non-splat variants: prefix = `segments[0..matchDepth].join('/')` —
2018
+ * exactly what the variant consumed (request length = variant
2019
+ * length for exact, request length ≥ variant length for prefix).
2020
+ * - Splat variants: the splat absorbed every remaining segment, so
2021
+ * the matched prefix is the entire request path. This mirrors what
2022
+ * `path-to-regexp`'s `output.path` would have returned pre-Phase-2
2023
+ * for `/files/*rest` matching `/files/a/b` (`/files/a/b`, not
2024
+ * `/files`).
2025
+ */
2026
+ function trieMatchedPath(segments, matchDepth, splatTerminated) {
2027
+ const upTo = splatTerminated ? segments.length : matchDepth;
2028
+ if (upTo === 0) return "/";
2029
+ return `/${segments.slice(0, upTo).join("/")}`;
2030
+ }
2031
+ /**
2032
+ * Pre-compute the `ParamCapture[]` for a variant's segments. Walk
2033
+ * the segments in order; emit one entry per `param` segment and a
2034
+ * terminal one for `splat` (always last). Static segments are
2035
+ * structurally consumed by the trie walk; they don't appear here.
2036
+ */
2037
+ function buildParamsIndexMap(segments) {
2038
+ const out = [];
2039
+ for (const [i, seg] of segments.entries()) if (seg.kind === "param") out.push({
2040
+ kind: "segment",
2041
+ depth: i,
2042
+ name: seg.name
2043
+ });
2044
+ else if (seg.kind === "splat") {
2045
+ out.push({
2046
+ kind: "splat",
2047
+ depth: i,
2048
+ name: seg.name
2049
+ });
2050
+ break;
2051
+ }
2052
+ return out;
2053
+ }
2054
+ /**
2055
+ * Decide which method buckets a given request method should pull
2056
+ * from. Always includes `''` (method-agnostic). For HEAD also
2057
+ * includes GET (per `matchHandlerMethod`). For OPTIONS or no-method
2058
+ * lookups, returns `null` to signal "emit every bucket" — needed so
2059
+ * `event.methodsAllowed` is populated for OPTIONS auto-Allow and so
2060
+ * `IRouter.lookup(path)` (no method) keeps returning a complete
2061
+ * candidate set.
2062
+ */
2063
+ function methodBucketKeys(method) {
2064
+ if (typeof method === "undefined" || method === MethodName.OPTIONS) return null;
2065
+ if (method === MethodName.HEAD) return [
2066
+ "",
2067
+ MethodName.HEAD,
2068
+ MethodName.GET
2069
+ ];
2070
+ return ["", method];
2071
+ }
2072
+ function emitBucket(buckets, method, out) {
2073
+ const keys = methodBucketKeys(method);
2074
+ if (keys === null) {
2075
+ for (const k in buckets) {
2076
+ const list = buckets[k];
2077
+ for (const r of list) out.push(r);
2078
+ }
2079
+ return;
2080
+ }
2081
+ for (const k of keys) {
2082
+ const list = buckets[k];
2083
+ if (!list) continue;
2084
+ for (const r of list) out.push(r);
2085
+ }
2086
+ }
2087
+ function hasAnyBucket(buckets) {
2088
+ for (const _k in buckets) return true;
2089
+ return false;
2090
+ }
2091
+ function pushIntoBucket(buckets, methodKey, route) {
2092
+ const bucket = buckets[methodKey];
2093
+ if (bucket) bucket.push(route);
2094
+ else buckets[methodKey] = [route];
2095
+ }
2096
+ /**
2097
+ * Radix-trie router — registers routes into a per-segment tree at
2098
+ * `add()` time and walks the tree at `lookup()` to collect
2099
+ * candidates by structure rather than by linear scan.
2100
+ *
2101
+ * Inspired by Hono's `TrieRouter` and rou3. The trie handles
2102
+ * routup's path vocabulary directly via its own parser
2103
+ * (`./parser.ts`):
2104
+ *
2105
+ * - Static segments (`/users`)
2106
+ * - Named params (`:id`)
2107
+ * - Optional params (`:id?`) — expanded to two route variants at
2108
+ * registration (T2)
2109
+ * - Optional groups (`/users{/edit}`) — same expansion strategy
2110
+ * - Bare and named splats (`/files/*`, `/files/*rest`)
2111
+ *
2112
+ * Per-leaf storage is bucketed by HTTP method (T4) so lookup
2113
+ * narrows to the request method's bucket(s) instead of emitting
2114
+ * every entry at the leaf and letting the dispatcher's filter
2115
+ * discard mismatches.
2116
+ *
2117
+ * Param extraction is `paramsIndexMap`-driven (T3): a pre-built
2118
+ * `Array<{ depth, name }>` per variant lets `extractTrieParams`
2119
+ * read params straight from the request's pre-split segments — no
2120
+ * regex execution per match.
2121
+ *
2122
+ * Paths the trie parser doesn't handle (compound segments like
2123
+ * `/files/:n.ext`, escape sequences `\:`, regex constraints) and
2124
+ * empty/root paths fall through to the `universal` bucket. That
2125
+ * bucket still uses `path-to-regexp` via `buildRoutePathMatcher`,
2126
+ * so correctness is preserved.
2127
+ *
2128
+ * Pure-static-spine fast path (`shortCircuit`): when the request
2129
+ * walks a static spine with no param/splat/prefix siblings on any
2130
+ * traversed node, the leaf's `exactRoutes` (filtered to the request
2131
+ * method's buckets) is the full answer — no need to walk the param
2132
+ * branch or collect prefix candidates at intermediate nodes.
2133
+ */
2134
+ var TrieRouter = class TrieRouter {
2135
+ /**
2136
+ * Monotonic counter assigned as the registration `index` on each
2137
+ * route — the dispatch loop uses it to preserve registration
2138
+ * order across the candidate list. App owns the canonical
2139
+ * `Route<T>[]` list (Plan 019); the trie no longer keeps a
2140
+ * parallel copy.
2141
+ */
2142
+ _routeCount;
2143
+ root;
2144
+ /**
2145
+ * Routes that bypass the trie — registered with no path, with
2146
+ * the root path `/`, or with a path containing syntax the
2147
+ * parser doesn't recognise. Walked linearly on every lookup,
2148
+ * merged into the result in registration order.
2149
+ */
2150
+ universal;
2151
+ cache;
2152
+ constructor(options = {}) {
2153
+ this._routeCount = 0;
2154
+ this.root = createTrieNode();
2155
+ this.universal = [];
2156
+ this.cache = options.cache;
2157
+ }
2158
+ add(route) {
2159
+ const index = this._routeCount++;
2160
+ if (typeof route.path !== "string" || route.path === "" || route.path === "/") {
2161
+ this.universal.push({
2162
+ route,
2163
+ index,
2164
+ matcher: buildRoutePathMatcher(route)
2165
+ });
2166
+ this.cache?.clear();
2167
+ return;
2168
+ }
2169
+ const variants = parsePath(route.path);
2170
+ if (variants === null) {
2171
+ this.universal.push({
2172
+ route,
2173
+ index,
2174
+ matcher: buildRoutePathMatcher(route)
2175
+ });
2176
+ this.cache?.clear();
2177
+ return;
2178
+ }
2179
+ for (const segments of variants) this.insertIntoTrie(segments, route, index);
2180
+ this.cache?.clear();
2181
+ }
2182
+ lookup(path, method) {
2183
+ const cacheKey = `${method ?? ""}\t${path}`;
2184
+ const cached = this.cache?.get(cacheKey);
2185
+ if (typeof cached !== "undefined") return cached;
2186
+ const candidates = [];
2187
+ for (const u of this.universal) candidates.push(u);
2188
+ const segments = this.parseRequestPath(path);
2189
+ const shortCircuit = this.shortCircuit(segments, method);
2190
+ if (shortCircuit !== null) for (const c of shortCircuit) candidates.push(c);
2191
+ else this.walk(this.root, segments, 0, candidates, method);
2192
+ candidates.sort((a, b) => {
2193
+ if (a.index !== b.index) return a.index - b.index;
2194
+ const ad = a.matchDepth ?? -1;
2195
+ return (b.matchDepth ?? -1) - ad;
2196
+ });
2197
+ const matches = [];
2198
+ let lastIndex = -1;
2199
+ for (const candidate of candidates) {
2200
+ const { route, index, matcher, paramsIndexMap, matchDepth } = candidate;
2201
+ if (index === lastIndex) continue;
2202
+ if (matcher) {
2203
+ const output = matcher.exec(path);
2204
+ if (typeof output === "undefined") continue;
2205
+ matches.push({
2206
+ route,
2207
+ index,
2208
+ params: this.assignParams(output.params),
2209
+ path: output.path
2210
+ });
2211
+ lastIndex = index;
2212
+ continue;
2213
+ }
2214
+ if (paramsIndexMap && typeof matchDepth === "number") {
2215
+ matches.push({
2216
+ route,
2217
+ index,
2218
+ params: extractTrieParams(segments, paramsIndexMap),
2219
+ path: trieMatchedPath(segments, matchDepth, candidate.splatTerminated === true)
2220
+ });
2221
+ lastIndex = index;
2222
+ continue;
2223
+ }
2224
+ matches.push({
2225
+ route,
2226
+ index,
2227
+ params: Object.create(null)
2228
+ });
2229
+ lastIndex = index;
2230
+ }
2231
+ this.cache?.set(cacheKey, matches);
2232
+ return matches;
2233
+ }
2234
+ clone() {
2235
+ return new TrieRouter({ cache: this.cache?.clone() });
2236
+ }
2237
+ /**
2238
+ * T1: returns the pre-computed candidate list when the request's
2239
+ * static spine has no param sibling, no prefix routes, and no
2240
+ * splats along the way. The leaf node's `exactRoutes` (filtered
2241
+ * to the request method's buckets) is then the complete answer —
2242
+ * no need to walk the param branch or collect prefix/splat
2243
+ * candidates from intermediate nodes. When any branch is
2244
+ * encountered, returns `null` and the caller falls through to
2245
+ * the regular `walk`.
2246
+ */
2247
+ shortCircuit(segments, method) {
2248
+ let node = this.root;
2249
+ for (const segment of segments) {
2250
+ if (node.paramChild || hasAnyBucket(node.splatRoutes) || node.prefixRoutes.length > 0) return null;
2251
+ const child = node.staticChildren.get(segment);
2252
+ if (!child) return null;
2253
+ node = child;
2254
+ }
2255
+ if (node.paramChild || hasAnyBucket(node.splatRoutes) || node.prefixRoutes.length > 0) return null;
2256
+ const out = [];
2257
+ emitBucket(node.exactRoutes, method, out);
2258
+ return out;
2259
+ }
2260
+ parseRequestPath(path) {
2261
+ const trimmed = path.charAt(0) === "/" ? path.slice(1) : path;
2262
+ if (trimmed === "") return [];
2263
+ const parts = trimmed.split("/");
2264
+ const result = [];
2265
+ for (const part of parts) if (part !== "") result.push(part);
2266
+ return result;
2267
+ }
2268
+ insertIntoTrie(segments, route, index) {
2269
+ let node = this.root;
2270
+ const exact = this.isExactMatchRoute(route);
2271
+ const methodKey = route.method ?? "";
2272
+ const paramsIndexMap = buildParamsIndexMap(segments);
2273
+ for (const [i, segment] of segments.entries()) {
2274
+ const seg = segment;
2275
+ if (seg.kind === "splat") {
2276
+ pushIntoBucket(node.splatRoutes, methodKey, {
2277
+ route,
2278
+ index,
2279
+ paramsIndexMap,
2280
+ matchDepth: i,
2281
+ splatTerminated: true
2282
+ });
2283
+ return;
2284
+ }
2285
+ if (seg.kind === "param") {
2286
+ if (!node.paramChild) node.paramChild = createTrieNode();
2287
+ node = node.paramChild;
2288
+ continue;
2289
+ }
2290
+ let child = node.staticChildren.get(seg.value);
2291
+ if (!child) {
2292
+ child = createTrieNode();
2293
+ node.staticChildren.set(seg.value, child);
2294
+ }
2295
+ node = child;
2296
+ }
2297
+ const indexed = {
2298
+ route,
2299
+ index,
2300
+ paramsIndexMap,
2301
+ matchDepth: segments.length
2302
+ };
2303
+ if (exact) pushIntoBucket(node.exactRoutes, methodKey, indexed);
2304
+ else node.prefixRoutes.push(indexed);
2305
+ }
2306
+ walk(node, segments, depth, collected, method) {
2307
+ emitBucket(node.splatRoutes, method, collected);
2308
+ if (depth === segments.length) {
2309
+ emitBucket(node.exactRoutes, method, collected);
2310
+ for (const p of node.prefixRoutes) collected.push(p);
2311
+ return;
2312
+ }
2313
+ for (const p of node.prefixRoutes) collected.push(p);
2314
+ const seg = segments[depth];
2315
+ const staticChild = node.staticChildren.get(seg);
2316
+ if (staticChild) this.walk(staticChild, segments, depth + 1, collected, method);
2317
+ if (node.paramChild) this.walk(node.paramChild, segments, depth + 1, collected, method);
2318
+ }
2319
+ isExactMatchRoute(route) {
2320
+ return typeof route.method !== "undefined";
2321
+ }
2322
+ /**
2323
+ * T5: copy params onto a prototype-less object so downstream
2324
+ * lookups skip prototype-chain traversal and avoid `__proto__` /
2325
+ * `hasOwnProperty` shadowing from user-controlled segment values.
2326
+ */
2327
+ assignParams(source) {
2328
+ const out = Object.create(null);
2329
+ for (const k in source) if (Object.prototype.hasOwnProperty.call(source, k)) out[k] = source[k];
2330
+ return out;
2331
+ }
2332
+ };
2333
+ //#endregion
2334
+ //#region src/router/smart/module.ts
2335
+ /**
2336
+ * Default crossover. Empirically `LinearRouter` wins for small route
2337
+ * counts (no per-request trie walk overhead, no static-spine setup);
2338
+ * `TrieRouter` wins past ~30 entries on typical workloads. Override
2339
+ * via `SmartRouterOptions.threshold` when a benchmark says otherwise
2340
+ * for your route shape.
2341
+ */
2342
+ const DEFAULT_THRESHOLD = 30;
2343
+ /**
2344
+ * Auto-selecting router. Accumulates registered routes in a pending
2345
+ * buffer; on the first `lookup()` call, picks `LinearRouter` or
2346
+ * `TrieRouter` based on the registered route count and replays the
2347
+ * pending list onto the chosen inner router. Every subsequent call
2348
+ * — `add`, `lookup`, `clone` — forwards to the inner.
2349
+ *
2350
+ * Use this when you don't want to commit to a router family up-front
2351
+ * (e.g. a library that registers a variable number of routes
2352
+ * depending on configuration). For known workloads, prefer the
2353
+ * concrete router — `SmartRouter` adds one indirection per call.
2354
+ *
2355
+ * Inspired by Hono's `SmartRouter` (which auto-selects across more
2356
+ * candidates including `RegExpRouter`); ours covers the only choice
2357
+ * that matters in routup today: linear-vs-trie at the registration-
2358
+ * size crossover.
2359
+ */
2360
+ var SmartRouter = class SmartRouter {
2361
+ inner;
2362
+ pending = [];
2363
+ threshold;
2364
+ /**
2365
+ * Cache handed off to whichever inner router gets chosen. Stays
2366
+ * `undefined` if the user didn't configure one.
2367
+ */
2368
+ cache;
2369
+ constructor(options = {}) {
2370
+ this.threshold = options.threshold ?? DEFAULT_THRESHOLD;
2371
+ this.cache = options.cache;
2372
+ }
2373
+ add(route) {
2374
+ if (this.inner) {
2375
+ this.inner.add(route);
2376
+ return;
2377
+ }
2378
+ this.pending.push(route);
2379
+ }
2380
+ lookup(path, method) {
2381
+ if (!this.inner) {
2382
+ this.inner = this.choose();
2383
+ for (const r of this.pending) this.inner.add(r);
2384
+ this.pending = [];
2385
+ }
2386
+ return this.inner.lookup(path, method);
2387
+ }
2388
+ clone() {
2389
+ return new SmartRouter({
2390
+ threshold: this.threshold,
2391
+ cache: this.cache?.clone()
2392
+ });
2393
+ }
2394
+ /**
2395
+ * Pick the inner router based on the registered route count.
2396
+ * `LinearRouter` for tiny tables, `TrieRouter` past the
2397
+ * configured threshold.
2398
+ *
2399
+ * @protected
2400
+ */
2401
+ choose() {
2402
+ if (this.pending.length < this.threshold) return new LinearRouter({ cache: this.cache });
2403
+ return new TrieRouter({ cache: this.cache });
2404
+ }
2405
+ };
2406
+ //#endregion
2407
+ //#region src/app/options.ts
2408
+ function normalizeAppOptions(input) {
2409
+ let etag;
2410
+ if (typeof input.etag !== "undefined") if (input.etag === null || input.etag === false) etag = null;
2411
+ else etag = buildEtagFn(input.etag);
2412
+ let trustProxy;
2413
+ if (typeof input.trustProxy !== "undefined") trustProxy = buildTrustProxyFn(input.trustProxy);
1484
2414
  if (typeof input.timeout !== "undefined") {
1485
- if (typeof input.timeout !== "number" || !Number.isFinite(input.timeout) || input.timeout <= 0) delete input.timeout;
2415
+ if (!Number.isFinite(input.timeout) || input.timeout <= 0) delete input.timeout;
1486
2416
  }
1487
2417
  if (typeof input.handlerTimeout !== "undefined") {
1488
- if (typeof input.handlerTimeout !== "number" || !Number.isFinite(input.handlerTimeout) || input.handlerTimeout <= 0) delete input.handlerTimeout;
2418
+ if (!Number.isFinite(input.handlerTimeout) || input.handlerTimeout <= 0) delete input.handlerTimeout;
1489
2419
  }
1490
- return input;
2420
+ return {
2421
+ ...input,
2422
+ etag,
2423
+ trustProxy
2424
+ };
1491
2425
  }
1492
2426
  //#endregion
1493
- //#region src/router/constants.ts
1494
- const RouterSymbol = Symbol.for("Router");
1495
- const RouterPipelineStep = {
2427
+ //#region src/app/constants.ts
2428
+ const AppSymbol = Symbol.for("App");
2429
+ const AppPipelineStep = {
1496
2430
  START: 0,
1497
2431
  LOOKUP: 1,
1498
2432
  CHILD_BEFORE: 2,
@@ -1500,65 +2434,73 @@ const RouterPipelineStep = {
1500
2434
  CHILD_AFTER: 4,
1501
2435
  FINISH: 5
1502
2436
  };
1503
- const RouterStackEntryType = {
1504
- ROUTER: "router",
2437
+ const RouteEntryType = {
2438
+ APP: "app",
1505
2439
  HANDLER: "handler"
1506
2440
  };
1507
2441
  //#endregion
1508
- //#region src/router/utils.ts
1509
- function isRouterInstance(input) {
1510
- return hasInstanceof(input, RouterSymbol);
2442
+ //#region src/app/check.ts
2443
+ function isAppInstance(input) {
2444
+ return hasInstanceof(input, AppSymbol);
1511
2445
  }
2446
+ //#endregion
2447
+ //#region src/app/module.ts
1512
2448
  /**
1513
- * Build a non-terminal `PathMatcher` for a router mount path.
1514
- *
1515
- * Returns `undefined` when the path is the root (`/`) or omitted entirely —
1516
- * a router mounted at the root has no intrinsic path filter.
2449
+ * Merge resolver-supplied path params into `event.params` *only* when
2450
+ * `match.params` actually has keys. Skipping the object spread on the
2451
+ * empty-params path (every static route, every middleware match) saves
2452
+ * an allocation per match the hottest path in static-route apps.
1517
2453
  */
1518
- function buildRouterPathMatcher(value) {
1519
- if (value === "/" || typeof value === "undefined") return;
1520
- return new PathMatcher(withLeadingSlash(withoutTrailingSlash(`${value}`)), { end: false });
2454
+ function mergeMatchParams(event, matchParams) {
2455
+ let hasKeys = false;
2456
+ for (const _k in matchParams) {
2457
+ hasKeys = true;
2458
+ break;
2459
+ }
2460
+ if (!hasKeys) return;
2461
+ event.params = {
2462
+ ...event.params,
2463
+ ...matchParams
2464
+ };
1521
2465
  }
1522
2466
  /**
1523
- * Check if the request accepts JSON responses.
1524
- * Matches application/json and +json suffixes (e.g. application/vnd.api+json).
1525
- * Returns true if no Accept header is present (API-first default).
2467
+ * Copy `source[key]` into `target[key]` when the target's value is
2468
+ * undefined; return whether a write happened.
2469
+ *
2470
+ * Bound to a single key `K` per call, so TypeScript can prove the
2471
+ * read and write hit the same property's value type — no `as` cast
2472
+ * needed. This is the standard escape hatch for the variance trap
2473
+ * you'd otherwise hit by writing `target[key] = source[key]` inside
2474
+ * a loop where `key: keyof AppOptions` is a *union* (read returns
2475
+ * the union of value types, write requires the intersection, which
2476
+ * collapses to `never`).
1526
2477
  */
1527
- function acceptsJson(request) {
1528
- const accept = request.headers.get("accept");
1529
- if (!accept) return true;
1530
- return accept.includes("application/json") || accept.includes("+json") || accept.includes("*/*");
1531
- }
1532
- //#endregion
1533
- //#region src/router/module.ts
1534
- const METHOD_TO_REGISTER = {
1535
- [MethodName.GET]: "get",
1536
- [MethodName.POST]: "post",
1537
- [MethodName.PUT]: "put",
1538
- [MethodName.PATCH]: "patch",
1539
- [MethodName.DELETE]: "delete",
1540
- [MethodName.HEAD]: "head",
1541
- [MethodName.OPTIONS]: "options"
1542
- };
1543
- var Router = class Router {
2478
+ function copyOptionIfUnset(target, source, key) {
2479
+ if (typeof target[key] !== "undefined") return false;
2480
+ target[key] = source[key];
2481
+ return true;
2482
+ }
2483
+ var App = class App {
1544
2484
  /**
1545
2485
  * A label for the router instance.
1546
2486
  */
1547
2487
  name;
1548
2488
  /**
1549
- * Array of mounted layers, routes & routers, each tagged by kind so the
1550
- * dispatch loop can discriminate without `isRouterInstance`/`isHandler`
1551
- * runtime checks.
2489
+ * Registration-time path prefix for entries registered on this
2490
+ * App. Local to this instance — never inherited from a parent.
1552
2491
  *
1553
2492
  * @protected
1554
2493
  */
1555
- stack = [];
2494
+ _path;
1556
2495
  /**
1557
- * Path matcher for the current mount path.
2496
+ * Pluggable router (route table) — owns the "which entries match
2497
+ * this path?" lookup. Defaults to `LinearRouter` (walks entries
2498
+ * linearly per request); swap in via `AppContext.router`
2499
+ * for a radix/trie implementation on apps with many routes.
1558
2500
  *
1559
2501
  * @protected
1560
2502
  */
1561
- pathMatcher;
2503
+ router;
1562
2504
  /**
1563
2505
  * Lifecycle hook registry.
1564
2506
  *
@@ -1566,7 +2508,14 @@ var Router = class Router {
1566
2508
  */
1567
2509
  hooks;
1568
2510
  /**
1569
- * Normalized options for this router instance.
2511
+ * Normalized options for this App instance.
2512
+ *
2513
+ * Frozen on construction and on every `extendOptions` update —
2514
+ * once published to `event.appOptions` it is shared across all
2515
+ * requests, and a handler must not be able to mutate
2516
+ * router-global state. `extendOptions` therefore uses a
2517
+ * functional update (build a new object, freeze it, replace
2518
+ * the slot) rather than mutating in place.
1570
2519
  */
1571
2520
  _options;
1572
2521
  /**
@@ -1575,18 +2524,51 @@ var Router = class Router {
1575
2524
  * @protected
1576
2525
  */
1577
2526
  plugins = /* @__PURE__ */ new Map();
2527
+ /**
2528
+ * Every route registered on this App, in registration order.
2529
+ *
2530
+ * App owns the canonical list — the `IRouter` contract has no
2531
+ * `routes` field, so cascades / clones / `setRouter` replay
2532
+ * read from here instead of asking the router. Routes are
2533
+ * pushed alongside every `this.router.add()` via the `register`
2534
+ * helper.
2535
+ *
2536
+ * @protected
2537
+ */
2538
+ _routes = [];
1578
2539
  constructor(input = {}) {
1579
2540
  this.name = input.name;
1580
- const { hooks = new Hooks(), plugins = /* @__PURE__ */ new Map(), ...options } = input;
1581
- this.hooks = hooks;
1582
- this.plugins = new Map(plugins);
1583
- this._options = normalizeRouterOptions(options);
1584
- this.pathMatcher = buildRouterPathMatcher(options.path);
1585
- markInstanceof(this, RouterSymbol);
1586
- }
1587
- matchPath(path) {
1588
- if (this.pathMatcher) return this.pathMatcher.test(path);
1589
- return true;
2541
+ this._path = input.path;
2542
+ this.hooks = input.hooks ?? new Hooks();
2543
+ this.plugins = new Map(input.plugins);
2544
+ this.router = input.router ?? new LinearRouter();
2545
+ this._options = Object.freeze(normalizeAppOptions(input.options ?? {}));
2546
+ markInstanceof(this, AppSymbol);
2547
+ }
2548
+ /**
2549
+ * Register a route with the active router and record it on the
2550
+ * App so we can replay it onto a different router later (see
2551
+ * `setRouter`) and so cascades / clones have a source of truth
2552
+ * independent of the router instance.
2553
+ *
2554
+ * @protected
2555
+ */
2556
+ register(route) {
2557
+ this.router.add(route);
2558
+ this._routes.push(route);
2559
+ }
2560
+ /**
2561
+ * Swap the active router. Replays every previously-registered
2562
+ * route onto the new router so lookups stay correct.
2563
+ *
2564
+ * Useful for picking a router after route shape is known (e.g.
2565
+ * a SmartRouter-style decision), or for testing alternatives
2566
+ * mid-flight without rebuilding the App. Any cache the previous
2567
+ * router carried is dropped along with it.
2568
+ */
2569
+ setRouter(router) {
2570
+ for (const route of this._routes) router.add(route);
2571
+ this.router = router;
1590
2572
  }
1591
2573
  /**
1592
2574
  * Public entry point — creates a DispatcherEvent from the request,
@@ -1640,136 +2622,188 @@ var Router = class Router {
1640
2622
  headers
1641
2623
  });
1642
2624
  }
2625
+ /**
2626
+ * Mount-time option inheritance — fill in any of this App's
2627
+ * unset option keys from the supplied parent options. Called by
2628
+ * `App.use(child)` after narrowing to `App` via `isAppInstance`.
2629
+ *
2630
+ * Public so callers don't need to reach into another App's
2631
+ * protected fields: the parent passes its options by value and
2632
+ * the child decides what to do with them.
2633
+ *
2634
+ * Shallow per-key merge. App-local concerns — `name`, `path`,
2635
+ * `hooks`, `plugins`, `router`, and the router's cache — are
2636
+ * deliberately not propagated; they sit on `AppContext`, not
2637
+ * inside `AppOptions`.
2638
+ *
2639
+ * Cascades to any Apps already mounted on this one — a deeper
2640
+ * grandchild gets the new keys too. Without this, mounting
2641
+ * grandchild → child → parent in that order would leave the
2642
+ * grandchild without parent's options (it adopted from child
2643
+ * before child had them).
2644
+ *
2645
+ * Late mutation of the parent's options after this call does NOT
2646
+ * propagate. `AppOptions` is configured at construction-and-mount;
2647
+ * later changes are not a supported workflow.
2648
+ */
2649
+ extendOptions(incoming) {
2650
+ let next;
2651
+ const keys = Object.keys(incoming);
2652
+ for (const key of keys) {
2653
+ if (typeof this._options[key] !== "undefined") continue;
2654
+ if (typeof incoming[key] === "undefined") continue;
2655
+ next ??= { ...this._options };
2656
+ copyOptionIfUnset(next, incoming, key);
2657
+ }
2658
+ if (!next) return;
2659
+ this._options = Object.freeze(next);
2660
+ for (const route of this._routes) if (route.data.type === RouteEntryType.APP && isAppInstance(route.data.data)) route.data.data.extendOptions(this._options);
2661
+ }
1643
2662
  async executePipelineStep(context) {
1644
- while (context.step !== RouterPipelineStep.FINISH) switch (context.step) {
1645
- case RouterPipelineStep.START:
2663
+ while (context.step !== AppPipelineStep.FINISH) switch (context.step) {
2664
+ case AppPipelineStep.START:
1646
2665
  await this.executePipelineStepStart(context);
1647
2666
  break;
1648
- case RouterPipelineStep.LOOKUP:
2667
+ case AppPipelineStep.LOOKUP:
1649
2668
  await this.executePipelineStepLookup(context);
1650
2669
  break;
1651
- case RouterPipelineStep.CHILD_BEFORE:
2670
+ case AppPipelineStep.CHILD_BEFORE:
1652
2671
  await this.executePipelineStepChildBefore(context);
1653
2672
  break;
1654
- case RouterPipelineStep.CHILD_DISPATCH:
2673
+ case AppPipelineStep.CHILD_DISPATCH:
1655
2674
  await this.executePipelineStepChildDispatch(context);
1656
2675
  break;
1657
- case RouterPipelineStep.CHILD_AFTER:
2676
+ case AppPipelineStep.CHILD_AFTER:
1658
2677
  await this.executePipelineStepChildAfter(context);
1659
2678
  break;
1660
2679
  default:
1661
- context.step = RouterPipelineStep.FINISH;
2680
+ context.step = AppPipelineStep.FINISH;
1662
2681
  break;
1663
2682
  }
1664
2683
  await this.executePipelineStepFinish(context);
1665
2684
  }
1666
2685
  async executePipelineStepStart(context) {
1667
- await this.hooks.trigger(HookName.REQUEST, context.event);
1668
- if (context.event.dispatched) context.step = RouterPipelineStep.FINISH;
1669
- else context.step = RouterPipelineStep.LOOKUP;
2686
+ if (this.hooks.hasListeners(HookName.START)) await this.hooks.trigger(HookName.START, context.event);
2687
+ if (context.event.dispatched) context.step = AppPipelineStep.FINISH;
2688
+ else context.step = AppPipelineStep.LOOKUP;
1670
2689
  }
1671
2690
  async executePipelineStepLookup(context) {
1672
- while (!context.event.dispatched && context.stackIndex < this.stack.length) {
1673
- const entry = this.stack[context.stackIndex];
1674
- if (entry.type === RouterStackEntryType.HANDLER) {
1675
- const handler = entry.data;
2691
+ if (typeof context.matches === "undefined" || context.matchesPath !== context.event.path) {
2692
+ context.matches = this.router.lookup(context.event.path, context.event.method);
2693
+ context.matchesPath = context.event.path;
2694
+ }
2695
+ const { matches } = context;
2696
+ while (!context.event.dispatched && context.matchIndex < matches.length) {
2697
+ const { route } = matches[context.matchIndex];
2698
+ if (route.data.type === RouteEntryType.HANDLER) {
2699
+ const handler = route.data.data;
1676
2700
  if (context.event.error && handler.type === HandlerType.CORE || !context.event.error && handler.type === HandlerType.ERROR) {
1677
- context.stackIndex++;
2701
+ context.matchIndex++;
1678
2702
  continue;
1679
2703
  }
1680
- if (entry.pathMatcher ? entry.pathMatcher.test(context.event.path) : handler.matchPath(context.event.path)) {
1681
- const method = entry.method ?? handler.method;
1682
- if (method) context.event.methodsAllowed.add(method);
1683
- if (matchHandlerMethod(method, context.event.method)) {
1684
- await this.hooks.trigger(HookName.CHILD_MATCH, context.event);
1685
- if (context.event.dispatched) context.step = RouterPipelineStep.FINISH;
1686
- else context.step = RouterPipelineStep.CHILD_BEFORE;
1687
- return;
1688
- }
2704
+ const { method } = route;
2705
+ if (method) context.event.methodsAllowed.add(method);
2706
+ if (!matchHandlerMethod(method, context.event.method)) {
2707
+ context.matchIndex++;
2708
+ continue;
1689
2709
  }
1690
- context.stackIndex++;
1691
- continue;
1692
2710
  }
1693
- if (entry.pathMatcher ? entry.pathMatcher.test(context.event.path) : entry.data.matchPath(context.event.path)) {
1694
- await this.hooks.trigger(HookName.CHILD_MATCH, context.event);
1695
- if (context.event.dispatched) context.step = RouterPipelineStep.FINISH;
1696
- else context.step = RouterPipelineStep.CHILD_BEFORE;
2711
+ if (this.hooks.hasListeners(HookName.CHILD_MATCH)) await this.hooks.trigger(HookName.CHILD_MATCH, context.event);
2712
+ if (context.event.dispatched) {
2713
+ context.step = AppPipelineStep.FINISH;
1697
2714
  return;
1698
2715
  }
1699
- context.stackIndex++;
2716
+ if (context.event.path !== context.matchesPath) {
2717
+ context.matches = void 0;
2718
+ context.matchIndex = 0;
2719
+ context.step = AppPipelineStep.LOOKUP;
2720
+ return;
2721
+ }
2722
+ context.step = AppPipelineStep.CHILD_BEFORE;
2723
+ return;
1700
2724
  }
1701
- context.step = RouterPipelineStep.FINISH;
2725
+ context.step = AppPipelineStep.FINISH;
1702
2726
  }
1703
2727
  async executePipelineStepChildBefore(context) {
1704
- await this.hooks.trigger(HookName.CHILD_DISPATCH_BEFORE, context.event);
1705
- if (context.event.dispatched) context.step = RouterPipelineStep.FINISH;
1706
- else context.step = RouterPipelineStep.CHILD_DISPATCH;
2728
+ if (this.hooks.hasListeners(HookName.CHILD_DISPATCH_BEFORE)) await this.hooks.trigger(HookName.CHILD_DISPATCH_BEFORE, context.event);
2729
+ if (context.event.dispatched) {
2730
+ context.step = AppPipelineStep.FINISH;
2731
+ return;
2732
+ }
2733
+ if (context.event.path !== context.matchesPath) {
2734
+ context.matches = void 0;
2735
+ context.matchIndex = 0;
2736
+ context.step = AppPipelineStep.LOOKUP;
2737
+ return;
2738
+ }
2739
+ context.step = AppPipelineStep.CHILD_DISPATCH;
1707
2740
  }
1708
2741
  async executePipelineStepChildAfter(context) {
1709
- await this.hooks.trigger(HookName.CHILD_DISPATCH_AFTER, context.event);
1710
- if (context.event.dispatched) context.step = RouterPipelineStep.FINISH;
1711
- else context.step = RouterPipelineStep.LOOKUP;
2742
+ if (this.hooks.hasListeners(HookName.CHILD_DISPATCH_AFTER)) await this.hooks.trigger(HookName.CHILD_DISPATCH_AFTER, context.event);
2743
+ if (context.event.dispatched) context.step = AppPipelineStep.FINISH;
2744
+ else context.step = AppPipelineStep.LOOKUP;
1712
2745
  }
1713
2746
  async executePipelineStepChildDispatch(context) {
1714
- const entry = this.stack[context.stackIndex];
1715
- if (context.event.dispatched || typeof entry === "undefined") {
1716
- context.step = RouterPipelineStep.FINISH;
2747
+ const match = context.matches?.[context.matchIndex];
2748
+ if (context.event.dispatched || typeof match === "undefined") {
2749
+ context.step = AppPipelineStep.FINISH;
1717
2750
  return;
1718
2751
  }
2752
+ const { route } = match;
1719
2753
  const { event } = context;
1720
2754
  const savedPath = event.path;
1721
2755
  const savedMountPath = event.mountPath;
1722
2756
  const savedParams = event.params;
1723
- if (entry.type === RouterStackEntryType.ROUTER && entry.pathMatcher) {
1724
- const output = entry.pathMatcher.exec(event.path);
1725
- if (typeof output !== "undefined") {
1726
- event.mountPath = cleanDoubleSlashes(`${event.mountPath}/${output.path}`);
1727
- if (event.path === output.path) event.path = "/";
1728
- else event.path = withLeadingSlash(event.path.substring(output.path.length));
1729
- event.params = {
1730
- ...event.params,
1731
- ...output.params
1732
- };
1733
- }
1734
- } else if (entry.type === RouterStackEntryType.HANDLER && entry.pathMatcher) {
1735
- const output = entry.pathMatcher.exec(event.path);
1736
- if (typeof output !== "undefined") event.params = {
1737
- ...event.params,
1738
- ...output.params
1739
- };
1740
- }
2757
+ if (route.data.type === RouteEntryType.APP && typeof match.path === "string") {
2758
+ event.mountPath = cleanDoubleSlashes(`${event.mountPath}/${match.path}`);
2759
+ if (event.path === match.path) event.path = "/";
2760
+ else event.path = withLeadingSlash(event.path.substring(match.path.length));
2761
+ mergeMatchParams(event, match.params);
2762
+ } else if (route.data.type === RouteEntryType.HANDLER && typeof match.path === "string") mergeMatchParams(event, match.params);
1741
2763
  try {
2764
+ const parentMatches = context.matches;
2765
+ const parentMatchesPath = context.matchesPath;
2766
+ const nextMatchIndex = context.matchIndex + 1;
1742
2767
  event.setNext(async (error) => {
1743
2768
  if (error) event.error = createError(error);
1744
- const nextContext = {
1745
- step: RouterPipelineStep.LOOKUP,
1746
- event,
1747
- stackIndex: context.stackIndex + 1,
1748
- response: void 0
1749
- };
1750
- await this.executePipelineStep(nextContext);
1751
- return nextContext.response;
2769
+ const pathChanged = event.path !== parentMatchesPath;
2770
+ const savedStep = context.step;
2771
+ const savedMatchIndex = context.matchIndex;
2772
+ const savedMatches = context.matches;
2773
+ const savedMatchesPath = context.matchesPath;
2774
+ context.step = AppPipelineStep.LOOKUP;
2775
+ context.matchIndex = pathChanged ? 0 : nextMatchIndex;
2776
+ context.matches = pathChanged ? void 0 : parentMatches;
2777
+ context.matchesPath = pathChanged ? void 0 : parentMatchesPath;
2778
+ try {
2779
+ await this.executePipelineStep(context);
2780
+ } finally {
2781
+ context.step = savedStep;
2782
+ context.matchIndex = savedMatchIndex;
2783
+ context.matches = savedMatches;
2784
+ context.matchesPath = savedMatchesPath;
2785
+ }
2786
+ return context.response;
1752
2787
  });
1753
- const response = await entry.data.dispatch(event);
2788
+ const response = await route.data.data.dispatch(event);
1754
2789
  if (response) {
1755
2790
  context.response = response;
1756
2791
  event.dispatched = true;
1757
2792
  }
1758
2793
  } catch (e) {
1759
2794
  event.error = createError(e);
1760
- await this.hooks.trigger(HookName.ERROR, event);
2795
+ if (this.hooks.hasListeners(HookName.ERROR)) await this.hooks.trigger(HookName.ERROR, event);
1761
2796
  }
1762
2797
  if (!event.dispatched) {
1763
2798
  event.path = savedPath;
1764
2799
  event.mountPath = savedMountPath;
1765
2800
  event.params = savedParams;
1766
2801
  }
1767
- context.stackIndex++;
1768
- context.step = RouterPipelineStep.CHILD_AFTER;
2802
+ context.matchIndex++;
2803
+ context.step = AppPipelineStep.CHILD_AFTER;
1769
2804
  }
1770
2805
  async executePipelineStepFinish(context) {
1771
- if (context.event.error || context.event.dispatched) return this.hooks.trigger(HookName.RESPONSE, context.event);
1772
- if (!context.event.dispatched && context.event.routerPath.length === 1 && context.event.method && context.event.method === MethodName.OPTIONS) {
2806
+ if (!context.event.error && !context.event.dispatched && context.isRoot && context.event.method === MethodName.OPTIONS) {
1773
2807
  if (context.event.methodsAllowed.has(MethodName.GET)) context.event.methodsAllowed.add(MethodName.HEAD);
1774
2808
  const options = [...context.event.methodsAllowed].map((key) => key.toUpperCase()).join(",");
1775
2809
  const optionsHeaders = new Headers(context.event.response.headers);
@@ -1780,37 +2814,28 @@ var Router = class Router {
1780
2814
  });
1781
2815
  context.event.dispatched = true;
1782
2816
  }
1783
- return this.hooks.trigger(HookName.RESPONSE, context.event);
1784
2817
  }
1785
2818
  async dispatch(event) {
1786
2819
  const savedPath = event.path;
1787
2820
  const savedMountPath = event.mountPath;
1788
2821
  const savedParams = event.params;
1789
- if (this.pathMatcher) {
1790
- const output = this.pathMatcher.exec(event.path);
1791
- if (typeof output !== "undefined") {
1792
- event.mountPath = cleanDoubleSlashes(`${event.mountPath}/${output.path}`);
1793
- if (event.path === output.path) event.path = "/";
1794
- else event.path = withLeadingSlash(event.path.substring(output.path.length));
1795
- event.params = {
1796
- ...event.params,
1797
- ...output.params
1798
- };
1799
- }
1800
- }
2822
+ const savedAppOptions = event.appOptions;
2823
+ const wasDispatching = event.isDispatching;
2824
+ const isRoot = !wasDispatching;
1801
2825
  const context = {
1802
- step: RouterPipelineStep.START,
2826
+ step: AppPipelineStep.START,
1803
2827
  event,
1804
- stackIndex: 0
2828
+ isRoot,
2829
+ matchIndex: 0
1805
2830
  };
1806
- event.routerPath.push({
1807
- name: this.name,
1808
- options: this._options
1809
- });
2831
+ event.appOptions = this._options;
2832
+ event.isDispatching = true;
1810
2833
  try {
1811
2834
  await this.executePipelineStep(context);
2835
+ if (this.hooks.hasListeners(HookName.END)) await this.hooks.trigger(HookName.END, event);
1812
2836
  } finally {
1813
- event.routerPath.pop();
2837
+ event.appOptions = savedAppOptions;
2838
+ event.isDispatching = wasDispatching;
1814
2839
  if (!event.dispatched) {
1815
2840
  event.path = savedPath;
1816
2841
  event.mountPath = savedMountPath;
@@ -1855,19 +2880,19 @@ var Router = class Router {
1855
2880
  continue;
1856
2881
  }
1857
2882
  let handler;
1858
- if (isHandlerOptions(element)) handler = new Handler({
2883
+ if (isHandler(element)) handler = element;
2884
+ else if (isHandlerOptions(element)) handler = new Handler({
1859
2885
  ...element,
1860
- method,
1861
- path: path ?? element.path
2886
+ method
1862
2887
  });
1863
- else if (isHandler(element)) handler = element;
1864
2888
  else continue;
1865
- this.stack.push({
1866
- type: RouterStackEntryType.HANDLER,
1867
- data: handler,
2889
+ this.register({
2890
+ path: joinPaths(this._path, path, handler.path),
1868
2891
  method,
1869
- path,
1870
- pathMatcher: buildHandlerPathMatcher(path ?? handler.path, true)
2892
+ data: {
2893
+ type: RouteEntryType.HANDLER,
2894
+ data: handler
2895
+ }
1871
2896
  });
1872
2897
  }
1873
2898
  }
@@ -1878,33 +2903,37 @@ var Router = class Router {
1878
2903
  path = withLeadingSlash(item);
1879
2904
  continue;
1880
2905
  }
1881
- if (isRouterInstance(item)) {
1882
- this.stack.push({
1883
- type: RouterStackEntryType.ROUTER,
1884
- data: item,
1885
- path,
1886
- pathMatcher: buildRouterPathMatcher(path)
2906
+ if (isAppInstance(item)) {
2907
+ item.extendOptions(this._options);
2908
+ this.register({
2909
+ path: joinPaths(this._path, path),
2910
+ data: {
2911
+ type: RouteEntryType.APP,
2912
+ data: item
2913
+ }
1887
2914
  });
1888
2915
  continue;
1889
2916
  }
1890
- if (isHandlerOptions(item)) {
1891
- const handler = new Handler({
1892
- ...item,
1893
- path: path ?? item.path
1894
- });
1895
- this.stack.push({
1896
- type: RouterStackEntryType.HANDLER,
1897
- data: handler,
1898
- path
2917
+ if (isHandler(item)) {
2918
+ this.register({
2919
+ path: joinPaths(this._path, path, item.path),
2920
+ method: item.method,
2921
+ data: {
2922
+ type: RouteEntryType.HANDLER,
2923
+ data: item
2924
+ }
1899
2925
  });
1900
2926
  continue;
1901
2927
  }
1902
- if (isHandler(item)) {
1903
- this.stack.push({
1904
- type: RouterStackEntryType.HANDLER,
1905
- data: item,
1906
- path,
1907
- pathMatcher: buildHandlerPathMatcher(path, !!item.method)
2928
+ if (isHandlerOptions(item)) {
2929
+ const handler = new Handler({ ...item });
2930
+ this.register({
2931
+ path: joinPaths(this._path, path, handler.path),
2932
+ method: handler.method,
2933
+ data: {
2934
+ type: RouteEntryType.HANDLER,
2935
+ data: handler
2936
+ }
1908
2937
  });
1909
2938
  continue;
1910
2939
  }
@@ -1928,7 +2957,10 @@ var Router = class Router {
1928
2957
  }
1929
2958
  install(plugin, context = {}) {
1930
2959
  if (this.plugins.has(plugin.name)) throw new PluginAlreadyInstalledError(plugin.name);
1931
- const router = new Router({ name: plugin.name });
2960
+ const router = new App({
2961
+ name: plugin.name,
2962
+ router: this.router.clone()
2963
+ });
1932
2964
  plugin.install(router);
1933
2965
  if (context.path) this.use(context.path, router);
1934
2966
  else this.use(router);
@@ -1936,7 +2968,7 @@ var Router = class Router {
1936
2968
  return this;
1937
2969
  }
1938
2970
  /**
1939
- * Return a new `Router` that mirrors this one but owns independent
2971
+ * Return a new `App` that mirrors this one but owns independent
1940
2972
  * mountable state.
1941
2973
  *
1942
2974
  * The new router has:
@@ -1952,26 +2984,33 @@ var Router = class Router {
1952
2984
  * mutations on one mount do not bleed into the others.
1953
2985
  */
1954
2986
  clone() {
1955
- const next = new Router({
1956
- ...this._options,
2987
+ const next = new App({
2988
+ name: this.name,
2989
+ path: this._path,
2990
+ options: { ...this._options },
1957
2991
  hooks: this.hooks.clone(),
1958
- plugins: this.plugins
2992
+ plugins: this.plugins,
2993
+ router: this.router.clone()
1959
2994
  });
1960
- for (const entry of this.stack) {
1961
- if (entry.type === RouterStackEntryType.ROUTER) {
1962
- const data = entry.data.clone();
1963
- if (entry.path) next.use(entry.path, data);
1964
- else next.use(data);
1965
- continue;
1966
- }
1967
- if (entry.method) {
1968
- const register = METHOD_TO_REGISTER[entry.method];
1969
- if (entry.path) next[register](entry.path, entry.data);
1970
- else next[register](entry.data);
2995
+ for (const route of this._routes) {
2996
+ if (route.data.type === RouteEntryType.APP) {
2997
+ next.register({
2998
+ path: route.path,
2999
+ data: {
3000
+ type: RouteEntryType.APP,
3001
+ data: route.data.data.clone()
3002
+ }
3003
+ });
1971
3004
  continue;
1972
3005
  }
1973
- if (entry.path) next.use(entry.path, entry.data);
1974
- else next.use(entry.data);
3006
+ next.register({
3007
+ path: route.path,
3008
+ method: route.method,
3009
+ data: {
3010
+ type: RouteEntryType.HANDLER,
3011
+ data: route.data.data
3012
+ }
3013
+ });
1975
3014
  }
1976
3015
  return next;
1977
3016
  }
@@ -1988,6 +3027,6 @@ var Router = class Router {
1988
3027
  }
1989
3028
  };
1990
3029
  //#endregion
1991
- export { setResponseHeaderAttachment as $, Handler as A, sendRedirect as B, fromWebHandler as C, fromNodeMiddleware as D, fromNodeHandler as E, HandlerSymbol as F, getRequestHeader as G, getRequestAcceptableContentType as H, HandlerType as I, sendAccepted as J, sendFile as K, DispatcherEvent as L, matchHandlerMethod as M, isPath as N, defineErrorHandler as O, PathMatcher as P, setResponseHeaderContentType as Q, RoutupEvent as R, isHandlerOptions as S, isWebHandlerProvider as T, getRequestAcceptableContentTypes as U, sendFormat as V, useRequestNegotiator as W, createError as X, toResponse as Y, isError as Z, getRequestAcceptableEncodings as _, PluginInstallError as a, serializeEventStreamMessage as at, isRequestCacheable as b, isPluginError as c, setResponseCacheHeaders as ct, getRequestIP as d, setResponseHeaderInline as et, getRequestHostName as f, getRequestAcceptableEncoding as g, getRequestAcceptableLanguages as h, PluginNotInstalledError as i, createEventStream as it, buildHandlerPathMatcher as j, defineCoreHandler as k, PluginErrorCode as l, HeaderName as lt, getRequestAcceptableLanguage as m, normalizeRouterOptions as n, appendResponseHeader as nt, PluginAlreadyInstalledError as o, ErrorSymbol as ot, matchRequestContentType as p, sendCreated as q, isPlugin as r, appendResponseHeaderDirective as rt, PluginError as s, RoutupError as st, Router as t, setResponseContentTypeByFileName as tt, getRequestProtocol as u, MethodName as ut, getRequestAcceptableCharset as v, isWebHandler as w, isHandler as x, getRequestAcceptableCharsets as y, sendStream as z };
3030
+ export { isError as $, fromWebHandler as A, DispatcherEvent as B, getRequestAcceptableEncodings as C, matchHandlerMethod as D, isRequestCacheable as E, defineErrorHandler as F, getRequestAcceptableContentTypes as G, sendRedirect as H, defineCoreHandler as I, sendFile as J, useRequestNegotiator as K, Handler as L, isWebHandlerProvider as M, fromNodeHandler as N, isHandler as O, fromNodeMiddleware as P, createError as Q, HandlerSymbol as R, getRequestAcceptableEncoding as S, getRequestAcceptableCharsets as T, sendFormat as U, sendStream as V, getRequestAcceptableContentType as W, sendAccepted as X, sendCreated as Y, toResponse as Z, getRequestIP as _, LinearRouter as a, appendResponseHeaderDirective as at, getRequestAcceptableLanguage as b, PluginNotInstalledError as c, AppError as ct, PluginError as d, AppEvent as dt, setResponseHeaderContentType as et, isPluginError as f, HeaderName as ft, getRequestProtocol as g, PathMatcher as h, TrieRouter as i, appendResponseHeader as it, isWebHandler as j, isHandlerOptions as k, PluginInstallError as l, ErrorSymbol as lt, isPath as m, LruCache as mt, normalizeAppOptions as n, setResponseHeaderInline as nt, buildRoutePathMatcher as o, createEventStream as ot, PluginErrorCode as p, MethodName as pt, getRequestHeader as q, SmartRouter as r, setResponseContentTypeByFileName as rt, isPlugin as s, serializeEventStreamMessage as st, App as t, setResponseHeaderAttachment as tt, PluginAlreadyInstalledError as u, setResponseCacheHeaders as ut, getRequestHostName as v, getRequestAcceptableCharset as w, getRequestAcceptableLanguages as x, matchRequestContentType as y, HandlerType as z };
1992
3031
 
1993
- //# sourceMappingURL=src-DX0rndew.mjs.map
3032
+ //# sourceMappingURL=src-gmPicCWT.mjs.map