@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/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lesto/router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Lesto's RESTful router — declare routes and named paths, resolve method+path to a controller#action.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"import": "./src/index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:cov": "vitest run --coverage",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"lint": "oxlint src test",
|
|
18
|
+
"format": "oxfmt src test",
|
|
19
|
+
"format:check": "oxfmt --check src test"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@lesto/errors": "workspace:*"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/lesto-run/lesto.git",
|
|
33
|
+
"directory": "packages/router"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://lesto.run",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/lesto-run/lesto/issues"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/compile.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiling a path pattern into a matcher.
|
|
3
|
+
*
|
|
4
|
+
* One `:param` segment names a capture and matches a single path segment
|
|
5
|
+
* (anything but `/`). A `*rest` catch-all names a capture too, but a GREEDY one:
|
|
6
|
+
* it swallows the whole remaining tail (one or more `/`-joined segments), and its
|
|
7
|
+
* optional twin `*rest?` swallows zero or more — so it must be the LAST token in
|
|
8
|
+
* the pattern. Static text is matched literally. The compiled form is a RegExp
|
|
9
|
+
* anchored end-to-end, the ordered list of param names, and the subset of those
|
|
10
|
+
* names that are catch-alls (so the matcher knows to split their capture into a
|
|
11
|
+
* `string[]`), all derived once at declaration time so matching at request time
|
|
12
|
+
* is a single `exec`.
|
|
13
|
+
*
|
|
14
|
+
* Shared by the legacy `Router` (`controller#action` targets) and the new
|
|
15
|
+
* generic {@link RouteTable} that the `lesto()` builder dispatches over, so both
|
|
16
|
+
* inherit the same ReDoS-safe compilation and never drift.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { RouterError } from "./errors";
|
|
20
|
+
|
|
21
|
+
// A `:param` segment names a capture; it matches one path segment (anything but "/").
|
|
22
|
+
// Kept exported (and catch-all-free) so reverse-routing and inspection of the
|
|
23
|
+
// single-segment params stay a one-liner; catch-alls have their own token below.
|
|
24
|
+
export const PARAM_SEGMENT = /:([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
25
|
+
|
|
26
|
+
// A param token: a single-segment `:name` OR a catch-all `*name` (with an optional
|
|
27
|
+
// trailing `?` marking the zero-or-more form). `match[1]` is a single name; `match[2]`
|
|
28
|
+
// is a catch-all name and `match[3]` is the `?` when it is optional. One regex so the
|
|
29
|
+
// compiler walks both kinds in pattern order with a single pass.
|
|
30
|
+
export const PARAM_TOKEN = /:([A-Za-z_][A-Za-z0-9_]*)|\*([A-Za-z_][A-Za-z0-9_]*)(\?)?/g;
|
|
31
|
+
|
|
32
|
+
// The greedy capture a catch-all compiles to: one or more non-empty `/`-joined
|
|
33
|
+
// segments. Anchoring each `[^/]+` to a following `/` or the end keeps the match
|
|
34
|
+
// linear (no nested-quantifier backtracking), so it is ReDoS-safe like `[^/]+`.
|
|
35
|
+
const CATCH_ALL_CAPTURE = "((?:[^/]+/)*[^/]+)";
|
|
36
|
+
|
|
37
|
+
// Characters that are literal in a path but special in a RegExp — escaped so a
|
|
38
|
+
// static segment like "/posts.json" never acts as a wildcard.
|
|
39
|
+
const REGEXP_SPECIALS = /[.*+?^${}()|[\]\\]/g;
|
|
40
|
+
|
|
41
|
+
export const escapeRegExp = (literal: string): string => literal.replace(REGEXP_SPECIALS, "\\$&");
|
|
42
|
+
|
|
43
|
+
/** A compiled pattern: the matcher RegExp, the param names, and which are catch-alls. */
|
|
44
|
+
export interface CompiledPattern {
|
|
45
|
+
regExp: RegExp;
|
|
46
|
+
|
|
47
|
+
/** The param names (`:name` and `*name` alike), in the order they appear. */
|
|
48
|
+
paramNames: ReadonlyArray<string>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The subset of {@link paramNames} that are catch-alls (`*name`/`*name?`). Their
|
|
52
|
+
* capture is a `/`-joined run of segments the matcher splits into a `string[]`,
|
|
53
|
+
* where a single-segment param's capture stays one `string`. An optional catch-all
|
|
54
|
+
* that matched zero segments has no capture, which the matcher reads as `[]`.
|
|
55
|
+
*/
|
|
56
|
+
catchAllParams: ReadonlySet<string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compile a path pattern into a RegExp, the ordered list of its param names, and
|
|
61
|
+
* which of those names are catch-alls.
|
|
62
|
+
*
|
|
63
|
+
* Static parts are escaped so they match literally; each `:param` becomes a
|
|
64
|
+
* `[^/]+` capture group, and a `*rest` catch-all a greedy {@link CATCH_ALL_CAPTURE}
|
|
65
|
+
* that spans the tail (its `*rest?` twin makes the whole trailing segment, slash
|
|
66
|
+
* and all, optional). The pattern is anchored end-to-end.
|
|
67
|
+
*
|
|
68
|
+
* Refuses a pattern that puts two params in one segment (`/:a-:b`): that compiles
|
|
69
|
+
* to adjacent `([^/]+)` groups separated by a literal, an ambiguous pattern whose
|
|
70
|
+
* backtracking is catastrophic on a long non-matching segment — the same ReDoS
|
|
71
|
+
* shape that bit `path-to-regexp` (CVE-2024-45296). And the request-handler
|
|
72
|
+
* deadline is no defense: a synchronous regex blocks the event loop, so its timer
|
|
73
|
+
* never fires. So we reject the shape at *declaration* time (fail fast, once)
|
|
74
|
+
* rather than risk it at request time.
|
|
75
|
+
*
|
|
76
|
+
* Refuses, too, a catch-all that is not the final segment (`/*rest/edit`,
|
|
77
|
+
* `/*a/:b`): it greedily eats the tail, so anything after it could never match —
|
|
78
|
+
* a coded `ROUTER_CATCHALL_NOT_LAST`. And a catch-all glued to a literal rather
|
|
79
|
+
* than occupying its own segment (`/shop*rest`) is `ROUTER_CATCHALL_NOT_SEGMENT`,
|
|
80
|
+
* since it would silently capture part of a path component.
|
|
81
|
+
*/
|
|
82
|
+
export const compile = (pattern: string): CompiledPattern => {
|
|
83
|
+
const paramNames: string[] = [];
|
|
84
|
+
const catchAllParams = new Set<string>();
|
|
85
|
+
|
|
86
|
+
let source = "";
|
|
87
|
+
let lastIndex = 0;
|
|
88
|
+
let sawParam = false;
|
|
89
|
+
let sawCatchAll = false;
|
|
90
|
+
|
|
91
|
+
for (const match of pattern.matchAll(PARAM_TOKEN)) {
|
|
92
|
+
// The static text between the previous token and this one is matched literally.
|
|
93
|
+
const between = pattern.slice(lastIndex, match.index);
|
|
94
|
+
|
|
95
|
+
// A catch-all swallows the rest of the path, so no token may follow it.
|
|
96
|
+
if (sawCatchAll) {
|
|
97
|
+
throw new RouterError(
|
|
98
|
+
"ROUTER_CATCHALL_NOT_LAST",
|
|
99
|
+
`Route "${pattern}" has a catch-all that is not the final segment — a "*rest" greedily matches the tail, so nothing can follow it. Move it to the end.`,
|
|
100
|
+
{ pattern },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const singleName = match[1];
|
|
105
|
+
|
|
106
|
+
if (singleName !== undefined) {
|
|
107
|
+
// No `/` since the previous param means this one shares its segment — two
|
|
108
|
+
// `[^/]+` captures in one segment, the ambiguous backtracking shape we refuse.
|
|
109
|
+
if (sawParam && !between.includes("/")) {
|
|
110
|
+
throw new RouterError(
|
|
111
|
+
"ROUTER_AMBIGUOUS_SEGMENT",
|
|
112
|
+
`Route "${pattern}" puts two params in one segment (":${singleName}" shares a segment with the param before it). Give each param its own "/" segment, or capture one param and split the value in the handler.`,
|
|
113
|
+
{ pattern, param: singleName },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
source += escapeRegExp(between);
|
|
118
|
+
source += "([^/]+)";
|
|
119
|
+
paramNames.push(singleName);
|
|
120
|
+
} else {
|
|
121
|
+
// A catch-all (`match[2]` the name, `match[3]` the `?` when optional). It must
|
|
122
|
+
// occupy a whole segment: the literal before it ends at a `/` boundary (or is
|
|
123
|
+
// the root `/`), else it would glue onto a literal and capture part of a path
|
|
124
|
+
// component rather than a clean run of segments.
|
|
125
|
+
const name = match[2] as string;
|
|
126
|
+
const optional = match[3] === "?";
|
|
127
|
+
|
|
128
|
+
if (!between.endsWith("/")) {
|
|
129
|
+
throw new RouterError(
|
|
130
|
+
"ROUTER_CATCHALL_NOT_SEGMENT",
|
|
131
|
+
`Route "${pattern}" has a catch-all "*${name}" glued to a literal — a catch-all must be its own "/"-delimited segment.`,
|
|
132
|
+
{ pattern, param: name },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (optional) {
|
|
137
|
+
// The whole trailing segment is optional — including the slash that precedes
|
|
138
|
+
// it. At the root the leading `/` is structural (every path has it) and IS the
|
|
139
|
+
// zero-segment path, so keep it and make only the segments optional; elsewhere
|
|
140
|
+
// peel the segment's own slash so the bare prefix (`/shop`) matches too.
|
|
141
|
+
source +=
|
|
142
|
+
between === "/"
|
|
143
|
+
? `/${CATCH_ALL_CAPTURE}?`
|
|
144
|
+
: `${escapeRegExp(between.slice(0, -1))}(?:/${CATCH_ALL_CAPTURE})?`;
|
|
145
|
+
} else {
|
|
146
|
+
source += escapeRegExp(between) + CATCH_ALL_CAPTURE;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
paramNames.push(name);
|
|
150
|
+
catchAllParams.add(name);
|
|
151
|
+
sawCatchAll = true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
lastIndex = match.index + match[0].length;
|
|
155
|
+
sawParam = true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Whatever trails the final token is literal — but a catch-all leaves nothing to
|
|
159
|
+
// trail, so a non-empty tail here is a literal after a catch-all (`/*rest/edit`).
|
|
160
|
+
const trailing = pattern.slice(lastIndex);
|
|
161
|
+
|
|
162
|
+
if (sawCatchAll && trailing !== "") {
|
|
163
|
+
throw new RouterError(
|
|
164
|
+
"ROUTER_CATCHALL_NOT_LAST",
|
|
165
|
+
`Route "${pattern}" has text after its catch-all — a "*rest" greedily matches the tail, so nothing can follow it. Move it to the end.`,
|
|
166
|
+
{ pattern },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
source += escapeRegExp(trailing);
|
|
171
|
+
|
|
172
|
+
return { regExp: new RegExp(`^${source}$`), paramNames, catchAllParams };
|
|
173
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors carry codes, not just prose.
|
|
3
|
+
*
|
|
4
|
+
* Every failure in Lesto surfaces a stable, machine-readable `code`. Logs,
|
|
5
|
+
* tests, API responses, and the MCP surface branch on the code — never on a
|
|
6
|
+
* message string, which is free to change for humans without breaking machines.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { LestoError } from "@lesto/errors";
|
|
10
|
+
|
|
11
|
+
export { LestoError };
|
|
12
|
+
|
|
13
|
+
export type RouterErrorCode =
|
|
14
|
+
| "ROUTER_AMBIGUOUS_SEGMENT"
|
|
15
|
+
| "ROUTER_CATCHALL_NOT_LAST"
|
|
16
|
+
| "ROUTER_CATCHALL_NOT_SEGMENT"
|
|
17
|
+
| "ROUTER_MALFORMED_PARAM"
|
|
18
|
+
| "ROUTER_MISSING_PARAM"
|
|
19
|
+
| "ROUTER_FILE_BAD_SEGMENT"
|
|
20
|
+
| "ROUTER_FILE_CATCHALL_POSITION"
|
|
21
|
+
| "ROUTER_FILE_DUPLICATE_ROUTE"
|
|
22
|
+
| "ROUTER_FILE_DUPLICATE_PARAM";
|
|
23
|
+
|
|
24
|
+
/** Anything the router can refuse to do. */
|
|
25
|
+
export class RouterError extends LestoError<RouterErrorCode> {
|
|
26
|
+
constructor(code: RouterErrorCode, message: string, details?: Record<string, unknown>) {
|
|
27
|
+
super(code, message, details);
|
|
28
|
+
|
|
29
|
+
this.name = "RouterError";
|
|
30
|
+
}
|
|
31
|
+
}
|