@lesto/router 0.1.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.
- package/package.json +39 -0
- package/src/compile.ts +173 -0
- package/src/errors.ts +31 -0
- package/src/file-routes.ts +703 -0
- package/src/index.ts +49 -0
- package/src/params.ts +98 -0
- package/src/scan.ts +143 -0
- package/src/table.ts +242 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lesto/router — the route-matching substrate for Lesto's code-first `lesto()` app.
|
|
3
|
+
*
|
|
4
|
+
* const table = new RouteTable<Handler>();
|
|
5
|
+
* table.add("GET", "/posts/:id", handler);
|
|
6
|
+
* table.match("GET", "/posts/3"); // { value: handler, params: { id: "3" } }
|
|
7
|
+
*
|
|
8
|
+
* Captured params are URL-decoded at match time, so `/posts/a%2Fb` binds the one
|
|
9
|
+
* param `"a/b"` (a `%2F` never smuggles a path separator) and a malformed `%`
|
|
10
|
+
* refuses with a coded `RouterError` the web tier maps to a 400 (see `RouteTable`).
|
|
11
|
+
* `pathFor` is the inverse — it encodes params back into a path that round-trips.
|
|
12
|
+
*
|
|
13
|
+
* The pattern compiler (`compile`) and type-level param inference (`ParamKeys` /
|
|
14
|
+
* `PathParams`) give `lesto()` handlers their `c.param(...)` keys with no codegen.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// The generic matcher the `lesto()` builder dispatches over, plus the shared
|
|
18
|
+
// pattern compiler and the type-level param inference that gives handlers their
|
|
19
|
+
// `c.param(...)` keys with no codegen.
|
|
20
|
+
export { pathFor, RouteTable } from "./table";
|
|
21
|
+
export type { Match } from "./table";
|
|
22
|
+
export { compile, escapeRegExp, PARAM_SEGMENT } from "./compile";
|
|
23
|
+
export type { CompiledPattern } from "./compile";
|
|
24
|
+
export type { CatchAllParamKeys, ParamKeys, PathParams, SingleParamKeys } from "./params";
|
|
25
|
+
|
|
26
|
+
// The file-based routing convention: scan a conventional dir (`app/`) into ordered
|
|
27
|
+
// route descriptors that compile to the same `:param` patterns above, so a
|
|
28
|
+
// file-route and a hand-written route share one router (the applier lives in
|
|
29
|
+
// `@lesto/web`'s `applyFileRoutes`, over these descriptors).
|
|
30
|
+
export {
|
|
31
|
+
BOUNDARY_KINDS,
|
|
32
|
+
compileFileRoutes,
|
|
33
|
+
dirKey,
|
|
34
|
+
NEAREST_BOUNDARY_KINDS,
|
|
35
|
+
ROUTE_FILE_NAMES,
|
|
36
|
+
} from "./file-routes";
|
|
37
|
+
export type {
|
|
38
|
+
BoundaryDepths,
|
|
39
|
+
BoundaryKind,
|
|
40
|
+
DiscoveredFile,
|
|
41
|
+
FileRoute,
|
|
42
|
+
FileRouteKind,
|
|
43
|
+
NearestBoundaryKind,
|
|
44
|
+
} from "./file-routes";
|
|
45
|
+
export { scanRoutes } from "./scan";
|
|
46
|
+
export type { DirEntry, DirReader } from "./scan";
|
|
47
|
+
|
|
48
|
+
export { LestoError, RouterError } from "./errors";
|
|
49
|
+
export type { RouterErrorCode } from "./errors";
|
package/src/params.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path params, inferred from the pattern at the type level.
|
|
3
|
+
*
|
|
4
|
+
* A route's `:param` segments are not just runtime captures — the compiler can
|
|
5
|
+
* read them straight off the pattern string. `ParamKeys<"/listings/:id">` is the
|
|
6
|
+
* literal union `"id"`; `PathParams<…>` lifts that union into the record a
|
|
7
|
+
* handler receives. This is the spine of Lesto's end-to-end typing: a handler for
|
|
8
|
+
* `"/posts/:postId/comments/:id"` knows, with no annotation and no codegen, that
|
|
9
|
+
* `c.param("postId")` and `c.param("id")` are the only valid keys.
|
|
10
|
+
*
|
|
11
|
+
* Purely type-level — these emit no JavaScript. The runtime captures still flow
|
|
12
|
+
* through {@link RouteTable}; these types only describe their shape.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The name a `:param` captures, stopping at a literal that follows it in the same
|
|
17
|
+
* segment.
|
|
18
|
+
*
|
|
19
|
+
* The runtime compiler captures a name as `[A-Za-z_][A-Za-z0-9_]*` (see
|
|
20
|
+
* `PARAM_SEGMENT`), so a pattern like `/files/:name.json` binds the param `name`
|
|
21
|
+
* and matches `.json` literally. A naive split-on-`/` would infer `name.json` and
|
|
22
|
+
* steer `c.param(...)` to a key that does not exist at runtime. This peels the
|
|
23
|
+
* trailing literal at the `.`/`-` separators a path segment uses, so the type
|
|
24
|
+
* agrees with the captured key.
|
|
25
|
+
*/
|
|
26
|
+
type ParamName<Raw extends string> = Raw extends `${infer Name}.${string}`
|
|
27
|
+
? ParamName<Name>
|
|
28
|
+
: Raw extends `${infer Name}-${string}`
|
|
29
|
+
? ParamName<Name>
|
|
30
|
+
: Raw;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The union of single-segment `:param` names in a path pattern.
|
|
34
|
+
*
|
|
35
|
+
* Walks the literal: a `:name/` prefix yields `name` and recurses on the rest; a
|
|
36
|
+
* trailing `:name` yields the final name; anything without a `:` yields `never`
|
|
37
|
+
* (a static path has no single params). A name stops at the next `/` — or at a
|
|
38
|
+
* `.`/`-` literal within the segment — mirroring the identifier the runtime
|
|
39
|
+
* captures. Catch-alls (`*name`) are read separately by {@link CatchAllParamKeys},
|
|
40
|
+
* since they carry a different VALUE type.
|
|
41
|
+
*/
|
|
42
|
+
export type SingleParamKeys<Path extends string> =
|
|
43
|
+
Path extends `${string}:${infer Param}/${infer Rest}`
|
|
44
|
+
? ParamName<Param> | SingleParamKeys<`/${Rest}`>
|
|
45
|
+
: Path extends `${string}:${infer Param}`
|
|
46
|
+
? ParamName<Param>
|
|
47
|
+
: never;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The catch-all name in a path pattern — the `name` of a `*name` / `*name?`, or
|
|
51
|
+
* `never` when there is none. A catch-all is always the FINAL token (the matcher
|
|
52
|
+
* refuses any other position), so it is read straight off the tail: the optional
|
|
53
|
+
* `*name?` form is matched first so the trailing `?` is a literal, not part of the
|
|
54
|
+
* captured name. Its value is a `string[]` (the run of segments it captured),
|
|
55
|
+
* which is why it is tracked apart from the single `:param` names.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* type A = CatchAllParamKeys<"/docs/*rest">; // "rest"
|
|
59
|
+
* type B = CatchAllParamKeys<"/docs/*rest?">; // "rest"
|
|
60
|
+
* type C = CatchAllParamKeys<"/listings/:id">; // never
|
|
61
|
+
*/
|
|
62
|
+
export type CatchAllParamKeys<Path extends string> = Path extends `${string}*${infer Name}?`
|
|
63
|
+
? Name
|
|
64
|
+
: Path extends `${string}*${infer Name}`
|
|
65
|
+
? Name
|
|
66
|
+
: never;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The union of param names in a path pattern — single `:param` AND `*catchAll`
|
|
70
|
+
* names alike. The spine of `c.param(...)` key-checking; {@link PathParams} adds
|
|
71
|
+
* the per-name value type on top.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* type A = ParamKeys<"/listings/:id">; // "id"
|
|
75
|
+
* type B = ParamKeys<"/posts/:postId/c/:id">; // "postId" | "id"
|
|
76
|
+
* type C = ParamKeys<"/files/:name.json">; // "name"
|
|
77
|
+
* type D = ParamKeys<"/docs/*rest">; // "rest"
|
|
78
|
+
* type E = ParamKeys<"/about">; // never
|
|
79
|
+
*/
|
|
80
|
+
export type ParamKeys<Path extends string> = SingleParamKeys<Path> | CatchAllParamKeys<Path>;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The record of path params for a pattern: each single `:param` mapped to `string`,
|
|
84
|
+
* each `*catchAll` mapped to `string[]`.
|
|
85
|
+
*
|
|
86
|
+
* Every single-segment capture is a `string` at runtime (the router never
|
|
87
|
+
* coerces); a catch-all capture is split on `/` into the `string[]` run of
|
|
88
|
+
* segments it matched (`[]` for an optional catch-all that matched none). A static
|
|
89
|
+
* path produces `{}` — no keys, nothing to read.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* type P = PathParams<"/listings/:id">; // { id: string }
|
|
93
|
+
* type Q = PathParams<"/docs/*rest">; // { rest: string[] }
|
|
94
|
+
* type R = PathParams<"/u/:id/*rest">; // { id: string; rest: string[] }
|
|
95
|
+
*/
|
|
96
|
+
export type PathParams<Path extends string> = {
|
|
97
|
+
[Key in ParamKeys<Path>]: Key extends CatchAllParamKeys<Path> ? string[] : string;
|
|
98
|
+
};
|
package/src/scan.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk a convention directory into the flat {@link DiscoveredFile} list the
|
|
3
|
+
* compiler takes — over an INJECTED reader, so the walk itself is pure logic.
|
|
4
|
+
*
|
|
5
|
+
* The reader is the one impure seam: `readDir(path)` lists a directory's entries
|
|
6
|
+
* (each a name + whether it is a directory). Everything else — descending the
|
|
7
|
+
* tree, recognizing `page`/`layout` by name, accumulating segments — is
|
|
8
|
+
* deterministic over what the reader returns, so the whole scan is exercised under
|
|
9
|
+
* a fake in-memory reader with no filesystem. The bin wires a real reader
|
|
10
|
+
* (`fs.readdir(..., { withFileTypes: true })`); a test hands a literal tree.
|
|
11
|
+
*
|
|
12
|
+
* Only the convention's recognized files (`page`, `layout`, `middleware`, and the
|
|
13
|
+
* `loading` / `error` / `not-found` boundaries — any recognized extension) are
|
|
14
|
+
* surfaced; every other file is ignored, so a co-located component, test, or
|
|
15
|
+
* stylesheet under the convention dir is never mistaken for a route. The EXTENSION is irrelevant
|
|
16
|
+
* to the convention — `page.tsx`, `page.ts`, `page.jsx`, `page.js` all name the
|
|
17
|
+
* directory's page — because the impure loader (the bin) is what resolves a
|
|
18
|
+
* concrete module path; the scan only needs the base name to classify the kind.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { DiscoveredFile, FileRouteKind } from "./file-routes";
|
|
22
|
+
import { ROUTE_FILE_NAMES } from "./file-routes";
|
|
23
|
+
|
|
24
|
+
/** One entry the reader yields for a directory: its name and whether it is a subdir. */
|
|
25
|
+
export interface DirEntry {
|
|
26
|
+
name: string;
|
|
27
|
+
|
|
28
|
+
isDirectory: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* List a directory's immediate entries. The single impure seam of the scan — the
|
|
33
|
+
* bin wires `fs.readdir(path, { withFileTypes: true })`, a test a literal map.
|
|
34
|
+
* Async because a real filesystem read is.
|
|
35
|
+
*/
|
|
36
|
+
export type DirReader = (path: string) => Promise<ReadonlyArray<DirEntry>>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The base name of a route file is `<kind>.<ext>` — `page.tsx`, `layout.ts`,
|
|
40
|
+
* `middleware.ts`, `not-found.tsx`. We split on the FIRST dot so a name like
|
|
41
|
+
* `page.test.tsx` classifies as `page` only if its base is exactly `page`; a
|
|
42
|
+
* co-located `page.helper.tsx` has base `page` too, so we additionally require the
|
|
43
|
+
* name to be a recognized kind (`page`/`layout`/`middleware`/`loading`/`error`/
|
|
44
|
+
* `not-found`) followed by a SINGLE extension. (A boundary's hyphen is part of its
|
|
45
|
+
* base — `not-found` is one
|
|
46
|
+
* base name, not a dot-delimited compound.) This pulls the base name (everything
|
|
47
|
+
* before the first dot) and the remainder so the classifier can demand a single
|
|
48
|
+
* extension segment.
|
|
49
|
+
*/
|
|
50
|
+
function baseAndExt(name: string): { base: string; rest: string } {
|
|
51
|
+
const dot = name.indexOf(".");
|
|
52
|
+
|
|
53
|
+
// A dotfile or extension-less name has no kind we recognize; `rest` empty marks it.
|
|
54
|
+
if (dot <= 0) return { base: name, rest: "" };
|
|
55
|
+
|
|
56
|
+
return { base: name.slice(0, dot), rest: name.slice(dot + 1) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Classify a file name as a route kind, or `undefined` if it is not one.
|
|
61
|
+
*
|
|
62
|
+
* A route file is exactly `page.<ext>` or `layout.<ext>` with a SINGLE extension
|
|
63
|
+
* segment (no inner dot), so `page.tsx` and `layout.js` count while `page.test.tsx`,
|
|
64
|
+
* `page.module.css`, and a bare `page` do not — a test or a co-located stylesheet
|
|
65
|
+
* beside a real page is never mistaken for a second route file. The extension's
|
|
66
|
+
* value does not matter (the loader resolves the concrete path); only that there
|
|
67
|
+
* is exactly one.
|
|
68
|
+
*/
|
|
69
|
+
function kindOf(name: string): FileRouteKind | undefined {
|
|
70
|
+
const { base, rest } = baseAndExt(name);
|
|
71
|
+
|
|
72
|
+
const kind = ROUTE_FILE_NAMES[base];
|
|
73
|
+
|
|
74
|
+
if (kind === undefined) return undefined;
|
|
75
|
+
|
|
76
|
+
// Exactly one extension segment: a `rest` with its own dot (`test.tsx`,
|
|
77
|
+
// `module.css`) is a co-located file, not the route file.
|
|
78
|
+
if (rest === "" || rest.includes(".")) return undefined;
|
|
79
|
+
|
|
80
|
+
return kind;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Scan a convention directory into the flat {@link DiscoveredFile} list the
|
|
85
|
+
* compiler takes, descending every subdirectory.
|
|
86
|
+
*
|
|
87
|
+
* `root` is the convention dir's own path (`app/`); the yielded segments are
|
|
88
|
+
* relative to it, so the root's own `page`/`layout` arrive with an empty segment
|
|
89
|
+
* chain and `app/listings/[id]/page.tsx` arrives as `segments: ["listings",
|
|
90
|
+
* "[id]"]`. The walk is breadth-stable: a directory's own route files are recorded
|
|
91
|
+
* before its children are descended, but the compiler re-orders for resolution, so
|
|
92
|
+
* the discovery order here only needs to be COMPLETE, not sorted.
|
|
93
|
+
*
|
|
94
|
+
* A `[param]` directory is descended like any other — it is just a segment whose
|
|
95
|
+
* name happens to compile to `:param` later; the scan stays oblivious to dynamic
|
|
96
|
+
* vs static, which keeps the one place that distinction matters in the compiler.
|
|
97
|
+
*/
|
|
98
|
+
export async function scanRoutes(
|
|
99
|
+
readDir: DirReader,
|
|
100
|
+
root: string,
|
|
101
|
+
): Promise<ReadonlyArray<DiscoveredFile>> {
|
|
102
|
+
const found: DiscoveredFile[] = [];
|
|
103
|
+
|
|
104
|
+
// Descend `dir` (whose path is `path`, whose segments-from-root are `segments`),
|
|
105
|
+
// recording its route files and recursing into its subdirectories.
|
|
106
|
+
const walk = async (path: string, segments: ReadonlyArray<string>): Promise<void> => {
|
|
107
|
+
const entries = await readDir(path);
|
|
108
|
+
|
|
109
|
+
// Record this directory's own page/layout files first, then descend — order
|
|
110
|
+
// within `found` is immaterial (the compiler sorts), so a simple two-pass over
|
|
111
|
+
// the entries keeps the walk readable.
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (entry.isDirectory) continue;
|
|
114
|
+
|
|
115
|
+
const kind = kindOf(entry.name);
|
|
116
|
+
|
|
117
|
+
if (kind !== undefined) {
|
|
118
|
+
found.push({ kind, segments });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The subdirectories, walked in turn — each adds one raw segment (its name).
|
|
123
|
+
const subdirs = entries.filter((entry) => entry.isDirectory);
|
|
124
|
+
|
|
125
|
+
await Promise.all(
|
|
126
|
+
subdirs.map((entry) => walk(joinPath(path, entry.name), [...segments, entry.name])),
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
await walk(root, []);
|
|
131
|
+
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Join a directory path and a child name with a single `/`. The scan never builds
|
|
137
|
+
* absolute paths — it only re-feeds the result to the reader — so a plain slash
|
|
138
|
+
* join is enough, and it keeps the (already-pure) walk free of a `node:path`
|
|
139
|
+
* import that would tie it to one runtime.
|
|
140
|
+
*/
|
|
141
|
+
function joinPath(dir: string, name: string): string {
|
|
142
|
+
return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
|
|
143
|
+
}
|
package/src/table.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generic route table: method + path pattern → a value of the caller's choice.
|
|
3
|
+
*
|
|
4
|
+
* Where the legacy {@link Router} hard-codes its value to a `"controller#action"`
|
|
5
|
+
* string, `RouteTable<T>` is agnostic — the `lesto()` builder stores whatever it
|
|
6
|
+
* needs to run a route (a handler chain, a page definition) as the value, and the
|
|
7
|
+
* table only owns matching: compile each pattern once, then resolve a request to
|
|
8
|
+
* the first route whose verb and path both match, handing back the value and the
|
|
9
|
+
* captured params.
|
|
10
|
+
*
|
|
11
|
+
* Insertion order is resolution order: the first matching route wins, so a more
|
|
12
|
+
* specific pattern declared earlier shadows a broader one declared later. Pure
|
|
13
|
+
* over plain strings — no socket, no handler invocation — so every matching edge
|
|
14
|
+
* is unit-testable in isolation.
|
|
15
|
+
*
|
|
16
|
+
* ## Captured params are URL-decoded at match time (BREAKING, Wave 5)
|
|
17
|
+
*
|
|
18
|
+
* Matching runs against the *encoded* path, then each capture is
|
|
19
|
+
* `decodeURIComponent`-d before it reaches the caller. Two consequences a handler
|
|
20
|
+
* can now rely on:
|
|
21
|
+
*
|
|
22
|
+
* - A percent-encoded separator never smuggles a segment. The pattern's `[^/]+`
|
|
23
|
+
* capture matches `%2F` as ONE segment, so `/files/a%2Fb` binds the single
|
|
24
|
+
* param `"a/b"` — it does NOT split into two segments or match a two-segment
|
|
25
|
+
* pattern. Decoding happens *after* the segment boundary is fixed, so the
|
|
26
|
+
* route shape is decided on the wire form and the slash can never be forged
|
|
27
|
+
* into the path structure. (`%2e%2e` likewise decodes to the literal `".."`
|
|
28
|
+
* value — a string the handler sees, not a path operator the router honors.)
|
|
29
|
+
* - Unicode is real text. `/u/%E2%9C%93` binds `"✓"`, not its bytes.
|
|
30
|
+
*
|
|
31
|
+
* A param that is not a well-formed percent-encoding (a stray `%`, `%zz`, a
|
|
32
|
+
* truncated `%E2`) is a client-malformed request, not a server fault: the decode
|
|
33
|
+
* refuses with a coded {@link RouterError} (`ROUTER_MALFORMED_PARAM`) so the web
|
|
34
|
+
* tier maps it to a 400 instead of letting `decodeURIComponent`'s bare `URIError`
|
|
35
|
+
* escape as a 500. The reverse of this — building an encoded path from decoded
|
|
36
|
+
* params — is {@link pathFor}, which round-trips: `match(pathFor(p, v)).params`
|
|
37
|
+
* recovers `v`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { compile, PARAM_TOKEN } from "./compile";
|
|
41
|
+
import { RouterError } from "./errors";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decode one captured segment, turning a malformed percent-sequence into a coded
|
|
45
|
+
* refusal instead of a bare `URIError`.
|
|
46
|
+
*
|
|
47
|
+
* `decodeURIComponent` throws a plain `URIError` on a stray or truncated `%`; left
|
|
48
|
+
* unhandled that surfaces as an opaque 500 for what is really a bad request. We
|
|
49
|
+
* catch exactly that case and re-raise a {@link RouterError} the web tier can map
|
|
50
|
+
* to a 400 by code (`ROUTER_MALFORMED_PARAM`).
|
|
51
|
+
*/
|
|
52
|
+
const decodeParam = (paramName: string, raw: string): string => {
|
|
53
|
+
try {
|
|
54
|
+
return decodeURIComponent(raw);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new RouterError(
|
|
57
|
+
"ROUTER_MALFORMED_PARAM",
|
|
58
|
+
`Route param "${paramName}" is not a valid percent-encoding: "${raw}".`,
|
|
59
|
+
{ param: paramName, raw },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** A compiled entry: its verb, the source pattern (for inspection), and the matcher. */
|
|
65
|
+
interface Entry<T> {
|
|
66
|
+
method: string;
|
|
67
|
+
|
|
68
|
+
pattern: string;
|
|
69
|
+
|
|
70
|
+
regExp: RegExp;
|
|
71
|
+
|
|
72
|
+
paramNames: ReadonlyArray<string>;
|
|
73
|
+
|
|
74
|
+
catchAllParams: ReadonlySet<string>;
|
|
75
|
+
|
|
76
|
+
value: T;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A successful match: the stored value and the params captured from the path.
|
|
81
|
+
*
|
|
82
|
+
* A single `:param` captures a `string`; a `*catchAll` captures the `string[]` run
|
|
83
|
+
* of segments it spanned (`[]` for an optional catch-all that matched none) — so a
|
|
84
|
+
* param value is `string | string[]`, narrowed per-key by `@lesto/router`'s
|
|
85
|
+
* `PathParams` wherever the pattern is known at the type level.
|
|
86
|
+
*/
|
|
87
|
+
export interface Match<T> {
|
|
88
|
+
value: T;
|
|
89
|
+
|
|
90
|
+
params: Record<string, string | string[]>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class RouteTable<T> {
|
|
94
|
+
// Insertion order is resolution order: the first matching route wins.
|
|
95
|
+
private readonly entries: Entry<T>[] = [];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Register a route. The pattern is compiled once, here, so a malformed or
|
|
99
|
+
* ambiguous pattern (see {@link compile}) fails at declaration, not at request.
|
|
100
|
+
*/
|
|
101
|
+
add(method: string, pattern: string, value: T): this {
|
|
102
|
+
const { regExp, paramNames, catchAllParams } = compile(pattern);
|
|
103
|
+
|
|
104
|
+
this.entries.push({ method, pattern, regExp, paramNames, catchAllParams, value });
|
|
105
|
+
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Find the route that answers this method + path.
|
|
111
|
+
*
|
|
112
|
+
* Returns the stored value and the extracted params, or `undefined` when
|
|
113
|
+
* nothing matches — either no pattern fits the path, or the matching pattern
|
|
114
|
+
* wants a different verb. The verb check is first and cheapest; the RegExp runs
|
|
115
|
+
* only for a method that could answer.
|
|
116
|
+
*/
|
|
117
|
+
match(method: string, path: string): Match<T> | undefined {
|
|
118
|
+
for (const entry of this.entries) {
|
|
119
|
+
if (entry.method !== method) continue;
|
|
120
|
+
|
|
121
|
+
const matched = entry.regExp.exec(path);
|
|
122
|
+
|
|
123
|
+
if (matched === null) continue;
|
|
124
|
+
|
|
125
|
+
// A NULL-PROTOTYPE map: param names come from the pattern, but a `:constructor`
|
|
126
|
+
// / `:__proto__` / dynamically-built name must read back as a plain key, never
|
|
127
|
+
// an inherited `Object.prototype` member (a truthy `params["constructor"]` could
|
|
128
|
+
// slip past an `if (!params[name])` check) and never mutate the object's proto.
|
|
129
|
+
const params: Record<string, string | string[]> = Object.create(null) as Record<
|
|
130
|
+
string,
|
|
131
|
+
string | string[]
|
|
132
|
+
>;
|
|
133
|
+
|
|
134
|
+
entry.paramNames.forEach((paramName, index) => {
|
|
135
|
+
// Group 0 is the whole match; captures start at 1, aligned with paramNames.
|
|
136
|
+
// The capture is the on-the-wire (encoded) form — decode it here, after the
|
|
137
|
+
// segment boundary is already fixed, so `%2F` can never smuggle a `/` into the
|
|
138
|
+
// route shape. A malformed `%` becomes a coded 400, not a 500.
|
|
139
|
+
const raw = matched[index + 1] as string | undefined;
|
|
140
|
+
|
|
141
|
+
if (entry.catchAllParams.has(paramName)) {
|
|
142
|
+
// A catch-all captured a `/`-joined run of segments — split on the literal
|
|
143
|
+
// boundary FIRST, then decode each, so the array element count is fixed on
|
|
144
|
+
// the wire form (a `%2F` decodes to `/` WITHIN an element, never a new one).
|
|
145
|
+
// An optional catch-all that matched nothing has no capture (`undefined`) → [].
|
|
146
|
+
params[paramName] =
|
|
147
|
+
raw === undefined
|
|
148
|
+
? []
|
|
149
|
+
: raw.split("/").map((segment) => decodeParam(paramName, segment));
|
|
150
|
+
} else {
|
|
151
|
+
params[paramName] = decodeParam(paramName, raw as string);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return { value: entry.value, params };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Every registered route's verb + pattern, in resolution order, for inspection. */
|
|
162
|
+
list(): ReadonlyArray<{ method: string; pattern: string }> {
|
|
163
|
+
return this.entries.map((entry) => ({ method: entry.method, pattern: entry.pattern }));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build a concrete path from a pattern by substituting and URL-encoding its params
|
|
169
|
+
* — the reverse of {@link RouteTable.match}, so a link never hardcodes a URL.
|
|
170
|
+
*
|
|
171
|
+
* Each `:param` is replaced by `encodeURIComponent` of its `string` value; each
|
|
172
|
+
* `*catchAll` by its `string[]` value's segments, each encoded and joined by `/`.
|
|
173
|
+
* That encoding is the exact inverse of the `decodeURIComponent` `match` applies,
|
|
174
|
+
* so the two round-trip: a value containing a `/` (or any unicode) survives the
|
|
175
|
+
* trip out and back unchanged — `pathFor("/files/:p", { p: "a/b" })` yields
|
|
176
|
+
* `/files/a%2Fb`, which `match` decodes back to `{ p: "a/b" }` as one segment;
|
|
177
|
+
* `pathFor("/docs/*r", { r: ["a", "b"] })` yields `/docs/a/b`, decoded back to the
|
|
178
|
+
* two-element array. An OPTIONAL catch-all given `[]` drops its whole segment —
|
|
179
|
+
* `pathFor("/shop/*r?", { r: [] })` is `/shop`, and the root `pathFor("/*r?", { r:
|
|
180
|
+
* [] })` is `/`.
|
|
181
|
+
*
|
|
182
|
+
* Throws a coded {@link RouterError} (`ROUTER_MISSING_PARAM`) if the pattern needs
|
|
183
|
+
* a param the caller did not supply, supplied empty, or supplied with the wrong
|
|
184
|
+
* shape: a `[^/]+` capture matches one-or-more chars, so an empty single value
|
|
185
|
+
* yields a path that can never route back (`/files/` misses `/files/:p`), and a
|
|
186
|
+
* required catch-all needs at least one segment. All wiring bugs caught here, not
|
|
187
|
+
* broken links shipped to a user.
|
|
188
|
+
*/
|
|
189
|
+
export const pathFor = (
|
|
190
|
+
pattern: string,
|
|
191
|
+
params: Record<string, string | readonly string[]> = {},
|
|
192
|
+
): string => {
|
|
193
|
+
const missing = (paramName: string): RouterError =>
|
|
194
|
+
new RouterError(
|
|
195
|
+
"ROUTER_MISSING_PARAM",
|
|
196
|
+
`Pattern "${pattern}" needs a non-empty "${paramName}" param.`,
|
|
197
|
+
{ pattern, param: paramName },
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
let out = "";
|
|
201
|
+
let lastIndex = 0;
|
|
202
|
+
|
|
203
|
+
for (const match of pattern.matchAll(PARAM_TOKEN)) {
|
|
204
|
+
const between = pattern.slice(lastIndex, match.index);
|
|
205
|
+
const singleName = match[1];
|
|
206
|
+
|
|
207
|
+
if (singleName !== undefined) {
|
|
208
|
+
const value = params[singleName];
|
|
209
|
+
|
|
210
|
+
if (typeof value !== "string" || value === "") throw missing(singleName);
|
|
211
|
+
|
|
212
|
+
out += between + encodeURIComponent(value);
|
|
213
|
+
} else {
|
|
214
|
+
const name = match[2] as string;
|
|
215
|
+
const optional = match[3] === "?";
|
|
216
|
+
const value = params[name];
|
|
217
|
+
|
|
218
|
+
// A catch-all takes the `string[]` run of segments; a missing or `string` value
|
|
219
|
+
// is the wrong shape (and narrows `value` to the array for the rest of the arm).
|
|
220
|
+
if (value === undefined || typeof value === "string") throw missing(name);
|
|
221
|
+
|
|
222
|
+
if (value.length === 0) {
|
|
223
|
+
if (!optional) throw missing(name);
|
|
224
|
+
|
|
225
|
+
// An empty optional catch-all contributes no segment: drop the preceding
|
|
226
|
+
// slash so the bare prefix stands (`/shop/*r?` → `/shop`), except at the root
|
|
227
|
+
// where the leading `/` is the path itself (`/*r?` → `/`).
|
|
228
|
+
out += between === "/" ? "/" : between.slice(0, -1);
|
|
229
|
+
} else if (value.some((segment) => segment === "")) {
|
|
230
|
+
// An empty element would emit a `//` the match regex rejects — refuse it, so
|
|
231
|
+
// the documented round-trip holds rather than shipping an unroutable link.
|
|
232
|
+
throw missing(name);
|
|
233
|
+
} else {
|
|
234
|
+
out += between + value.map((segment) => encodeURIComponent(segment)).join("/");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
lastIndex = match.index + match[0].length;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return out + pattern.slice(lastIndex);
|
|
242
|
+
};
|