@void/edge 0.26.1 → 0.28.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/dist/index.d.mts +18 -35
- package/dist/index.mjs +287 -132
- package/package.json +2 -2
package/dist/index.d.mts
CHANGED
|
@@ -57,17 +57,23 @@ export declare function applyHeaderRules(response: Response, pathname: string, r
|
|
|
57
57
|
//#endregion
|
|
58
58
|
//#region src/pattern.d.ts
|
|
59
59
|
type CompiledPattern = Readonly<{
|
|
60
|
+
/**
|
|
61
|
+
* @deprecated Built on first read for older callers; Void does not use it.
|
|
62
|
+
* It can backtrack and fails past engine size limits. Use `matchPattern` or
|
|
63
|
+
* `matchPatternCaptures`.
|
|
64
|
+
*/
|
|
60
65
|
regex: RegExp;
|
|
61
66
|
paramNames: ReadonlyArray<string>;
|
|
62
67
|
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Parse a capture pattern. Throws a SyntaxError for a malformed one: a repeated
|
|
70
|
+
* name (`/:id/:id`, `/:splat/*`) or a name that starts with a digit (`/:1`).
|
|
71
|
+
*/
|
|
63
72
|
export declare function compilePattern(source: string): CompiledPattern;
|
|
64
73
|
/**
|
|
65
74
|
* Match a pattern against a pathname. Returns captured params, or null.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* e.g. multiple `*` producing duplicate `splat` capture names. Framework and
|
|
69
|
-
* API ingest both reject those, but a hand-crafted manifest could still reach
|
|
70
|
-
* the edge, and a single bad rule shouldn't 500 unrelated traffic.
|
|
75
|
+
* A malformed pattern (e.g. a repeated name like `/:splat/*`) also returns
|
|
76
|
+
* null, so one bad rule cannot fail unrelated requests.
|
|
71
77
|
*/
|
|
72
78
|
export declare function matchPatternCaptures(pattern: string, pathname: string): Record<string, string> | null;
|
|
73
79
|
/**
|
|
@@ -87,36 +93,13 @@ export declare function matchPattern(pattern: string, pathname: string): boolean
|
|
|
87
93
|
*/
|
|
88
94
|
export declare function matchLiteralPattern(pattern: string, pathname: string): boolean;
|
|
89
95
|
/**
|
|
90
|
-
* Match a
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* matches ONE-OR-MORE characters that MAY cross `/` (`.+`, so `/docs/a` and
|
|
98
|
-
* `/docs/a/b` both match, but bare `/docs` / `/docs/` do NOT). Only the exact
|
|
99
|
-
* `{.+}` constraint widens — a REGEX-constrained param like `:path{[0-9]+}`
|
|
100
|
-
* keeps its single-segment `[^/]+` meaning. `*` still expands to a catch-all
|
|
101
|
-
* that crosses `/`. A mid-segment `:` (e.g. `/api/v1:health`,
|
|
102
|
-
* `/api/documents:batchGet`) stays a LITERAL — only whole-segment `:name` /
|
|
103
|
-
* `:name{.+}` anchors.
|
|
104
|
-
*
|
|
105
|
-
* This is for `routing.revalidate` keys ONLY. Those keys are machine-generated
|
|
106
|
-
* from page routes on the deploy write side (`[slug]` → `/blog/:slug`,
|
|
107
|
-
* `[...path]` → `/docs/:path{.+}`), so the `:name` here is a real route param
|
|
108
|
-
* and MUST bind the same way the framework's own client route matcher
|
|
109
|
-
* (`patternToRegex` in `pages/manifest.ts`) binds it.
|
|
110
|
-
*
|
|
111
|
-
* It is intentionally NOT used for user-authored header rules
|
|
112
|
-
* (`applyHeaderRules`) or `revalidateQueryAllowlist` keys
|
|
113
|
-
* (`buildVariantOriginalUrl`), where `:name` must stay a literal — those keep
|
|
114
|
-
* using `matchLiteralPattern`. Sharing the segment behaviour there would make a
|
|
115
|
-
* user rule like `/:batchGet` over-match every single-segment path.
|
|
116
|
-
*
|
|
117
|
-
* NOTE: this matcher has its OWN regex cache — the same pattern string compiles
|
|
118
|
-
* to a DIFFERENT regex than `matchLiteralPattern` produces, so the caches must
|
|
119
|
-
* not be shared or they would cross-contaminate.
|
|
96
|
+
* Match a `routing.revalidate` key or page pattern against a pathname. Like
|
|
97
|
+
* `matchLiteralPattern`, except for whole-segment params:
|
|
98
|
+
* `:name` — exactly one non-empty path segment (any non-`/` name)
|
|
99
|
+
* `:name{.+}` — one or more characters that may cross `/`
|
|
100
|
+
* A mid-segment `:` (`/api/v1:health`) stays literal, and any other `{...}`
|
|
101
|
+
* constraint keeps the single-segment meaning. Has its own cache because the
|
|
102
|
+
* same source parses differently here.
|
|
120
103
|
*/
|
|
121
104
|
export declare function matchSegmentPattern(pattern: string, pathname: string): boolean;
|
|
122
105
|
/**
|
package/dist/index.mjs
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
//#region src/pattern.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Path-pattern matchers shared by routing, header, and revalidate rules.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Each pattern is parsed once into tokens, and matching walks those tokens
|
|
6
|
+
* over the pathname. No RegExp is built from pattern text, so a pattern cannot
|
|
7
|
+
* hit engine size limits or backtrack: a match costs at most
|
|
8
|
+
* (steps) x (path length - pattern minimum + 1).
|
|
9
|
+
*
|
|
10
|
+
* `compilePattern` syntax:
|
|
6
11
|
* `:name` — single path segment, captured as `name`
|
|
7
12
|
* `:name*` — zero or more path segments, captured as `name`
|
|
8
13
|
* `*` — zero or more path segments, captured as `splat` (legacy)
|
|
9
14
|
*
|
|
10
15
|
* When a legacy pattern contains multiple anonymous `*`, the first captures
|
|
11
|
-
* as `splat` and later wildcards are non-capturing.
|
|
12
|
-
* backward compatible for existing header/revalidate patterns.
|
|
16
|
+
* as `splat` and later wildcards are non-capturing.
|
|
13
17
|
*/
|
|
14
18
|
const MAX_CACHE = 256;
|
|
15
|
-
const compileCache = /* @__PURE__ */ new Map();
|
|
16
|
-
const REGEX_SPECIAL = /[.+?^${}()|[\]\\]/;
|
|
17
|
-
const PARAM_CHAR = /[A-Za-z0-9_]/;
|
|
18
19
|
/** LRU read: re-inserting a hit moves it to the most-recently-used end of Map order. */
|
|
19
20
|
function lruGet(cache, key) {
|
|
20
21
|
const value = cache.get(key);
|
|
@@ -32,84 +33,289 @@ function lruSet(cache, key, value) {
|
|
|
32
33
|
}
|
|
33
34
|
cache.set(key, value);
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
/** `[A-Za-z0-9_]` */
|
|
37
|
+
function isNameChar(code) {
|
|
38
|
+
return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || code === 95;
|
|
39
|
+
}
|
|
40
|
+
/** Whether a wildcard of this kind may take the code unit. */
|
|
41
|
+
function accepts(kind, code) {
|
|
42
|
+
if (kind === "seg") return code !== 47;
|
|
43
|
+
return code !== 10 && code !== 13 && code !== 8232 && code !== 8233;
|
|
44
|
+
}
|
|
45
|
+
function pushText(tokens, text) {
|
|
46
|
+
if (!text) return;
|
|
47
|
+
const last = tokens[tokens.length - 1];
|
|
48
|
+
if (last?.kind === "text") last.text += text;
|
|
49
|
+
else tokens.push({
|
|
50
|
+
kind: "text",
|
|
51
|
+
text
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** `compilePattern` syntax, read left to right. */
|
|
55
|
+
function parseCapture(source) {
|
|
56
|
+
const tokens = [];
|
|
38
57
|
const paramNames = [];
|
|
39
|
-
let
|
|
40
|
-
let pattern = "";
|
|
58
|
+
let splatTaken = false;
|
|
41
59
|
let index = 0;
|
|
42
60
|
while (index < source.length) {
|
|
43
61
|
const character = source[index];
|
|
44
62
|
if (character === ":") {
|
|
45
|
-
let
|
|
46
|
-
while (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
pattern += "\\:";
|
|
63
|
+
let end = index + 1;
|
|
64
|
+
while (end < source.length && isNameChar(source.charCodeAt(end))) end++;
|
|
65
|
+
if (end === index + 1) {
|
|
66
|
+
pushText(tokens, ":");
|
|
50
67
|
index++;
|
|
51
68
|
continue;
|
|
52
69
|
}
|
|
70
|
+
const name = source.slice(index + 1, end);
|
|
53
71
|
paramNames.push(name);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
72
|
+
const rest = source[end] === "*";
|
|
73
|
+
tokens.push({
|
|
74
|
+
kind: rest ? "any" : "seg",
|
|
75
|
+
name
|
|
76
|
+
});
|
|
77
|
+
index = rest ? end + 1 : end;
|
|
61
78
|
} else if (character === "*") {
|
|
62
|
-
if (
|
|
79
|
+
if (splatTaken) tokens.push({ kind: "any" });
|
|
63
80
|
else {
|
|
64
81
|
paramNames.push("splat");
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
tokens.push({
|
|
83
|
+
kind: "any",
|
|
84
|
+
name: "splat"
|
|
85
|
+
});
|
|
86
|
+
splatTaken = true;
|
|
67
87
|
}
|
|
68
88
|
index++;
|
|
69
89
|
} else {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
index
|
|
90
|
+
let end = index + 1;
|
|
91
|
+
while (end < source.length && source[end] !== ":" && source[end] !== "*") end++;
|
|
92
|
+
pushText(tokens, source.slice(index, end));
|
|
93
|
+
index = end;
|
|
73
94
|
}
|
|
74
95
|
}
|
|
75
|
-
|
|
76
|
-
|
|
96
|
+
return {
|
|
97
|
+
tokens,
|
|
77
98
|
paramNames
|
|
78
99
|
};
|
|
100
|
+
}
|
|
101
|
+
/** Text in which only `*` is special. */
|
|
102
|
+
function parseStars(tokens, text) {
|
|
103
|
+
const parts = text.split("*");
|
|
104
|
+
pushText(tokens, parts[0]);
|
|
105
|
+
for (let k = 1; k < parts.length; k++) {
|
|
106
|
+
tokens.push({ kind: "any" });
|
|
107
|
+
pushText(tokens, parts[k]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** `routing.revalidate` / page syntax: params are whole segments. */
|
|
111
|
+
function parseSegments(source) {
|
|
112
|
+
const tokens = [];
|
|
113
|
+
const segments = source.split("/");
|
|
114
|
+
for (let k = 0; k < segments.length; k++) {
|
|
115
|
+
const segment = segments[k];
|
|
116
|
+
if (k > 0) pushText(tokens, "/");
|
|
117
|
+
const name = segment.slice(1, -4);
|
|
118
|
+
if (segment[0] !== ":" || segment.length < 2) parseStars(tokens, segment);
|
|
119
|
+
else if (segment.length > 5 && segment.endsWith("{.+}") && !name.includes("{") && !name.includes("}")) tokens.push({ kind: "plus" });
|
|
120
|
+
else tokens.push({ kind: "seg" });
|
|
121
|
+
}
|
|
122
|
+
return tokens;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Drop an `any` that directly follows another `any`: the first takes every
|
|
126
|
+
* unit the pair could take, so the second always matches ''. Every other step
|
|
127
|
+
* needs at least one unit, so a path of length m faces at most 2m + 1 steps.
|
|
128
|
+
*/
|
|
129
|
+
function prepare(tokens) {
|
|
130
|
+
const steps = [];
|
|
131
|
+
for (const token of tokens) if (!(token.kind === "any" && steps[steps.length - 1]?.kind === "any")) steps.push(token);
|
|
132
|
+
const starts = new Int32Array(steps.length + 1);
|
|
133
|
+
steps.forEach((step, i) => {
|
|
134
|
+
const shortest = step.kind === "text" ? step.text.length : step.kind === "any" ? 0 : 1;
|
|
135
|
+
starts[i + 1] = starts[i] + shortest;
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
steps,
|
|
139
|
+
starts,
|
|
140
|
+
minLength: starts[steps.length]
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function stepBack(step, path, start, next, row) {
|
|
144
|
+
let found = 0;
|
|
145
|
+
if (step.kind === "text") {
|
|
146
|
+
for (let d = row.length - 1; d >= 0; d--) {
|
|
147
|
+
const value = next[d] === 1 && path.startsWith(step.text, start + d) ? 1 : 0;
|
|
148
|
+
row[d] = value;
|
|
149
|
+
found |= value;
|
|
150
|
+
}
|
|
151
|
+
return found === 1;
|
|
152
|
+
}
|
|
153
|
+
const kind = step.kind;
|
|
154
|
+
let after = 0;
|
|
155
|
+
for (let d = row.length - 1; d >= 0; d--) {
|
|
156
|
+
const value = kind === "any" ? next[d] === 1 || after === 1 && accepts(kind, path.charCodeAt(start + d)) : (next[d] === 1 || after === 1) && accepts(kind, path.charCodeAt(start + d));
|
|
157
|
+
row[d] = value ? 1 : 0;
|
|
158
|
+
after = row[d];
|
|
159
|
+
found |= after;
|
|
160
|
+
}
|
|
161
|
+
return found === 1;
|
|
162
|
+
}
|
|
163
|
+
/** The row after the last step: only the end of the path (d = slack) finishes. */
|
|
164
|
+
function endRow(width) {
|
|
165
|
+
const row = new Uint8Array(width);
|
|
166
|
+
row[width - 1] = 1;
|
|
167
|
+
return row;
|
|
168
|
+
}
|
|
169
|
+
/** Checks that reject most paths before any row is built. */
|
|
170
|
+
function quickReject({ steps, minLength }, path) {
|
|
171
|
+
const first = steps[0];
|
|
172
|
+
const last = steps[steps.length - 1];
|
|
173
|
+
return path.length < minLength || first?.kind === "text" && !path.startsWith(first.text) || last?.kind === "text" && !path.endsWith(last.text);
|
|
174
|
+
}
|
|
175
|
+
/** Whether the steps match the whole path. Keeps two rows. */
|
|
176
|
+
function matches(prepared, path) {
|
|
177
|
+
if (quickReject(prepared, path)) return false;
|
|
178
|
+
const { steps, starts, minLength } = prepared;
|
|
179
|
+
let next = endRow(path.length - minLength + 1);
|
|
180
|
+
let row = new Uint8Array(next.length);
|
|
181
|
+
const first = steps[0]?.kind === "text" ? 1 : 0;
|
|
182
|
+
for (let i = steps.length - 1; i >= first; i--) {
|
|
183
|
+
if (!stepBack(steps[i], path, starts[i], next, row)) return false;
|
|
184
|
+
const done = next;
|
|
185
|
+
next = row;
|
|
186
|
+
row = done;
|
|
187
|
+
}
|
|
188
|
+
return next[0] === 1;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Where a step that begins at p (row index d) ends: the longest choice after
|
|
192
|
+
* which the rest still matches. `next` is the row of the following step.
|
|
193
|
+
*/
|
|
194
|
+
function greedyEnd(step, path, p, d, next) {
|
|
195
|
+
if (step.kind === "text") return p + step.text.length;
|
|
196
|
+
const shortest = step.kind === "any" ? 0 : 1;
|
|
197
|
+
const longest = next.length - 1 - d + shortest;
|
|
198
|
+
let k = 0;
|
|
199
|
+
while (k < longest && accepts(step.kind, path.charCodeAt(p + k))) k++;
|
|
200
|
+
while (k > shortest && next[d + k - shortest] !== 1) k--;
|
|
201
|
+
return p + k;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* End positions of steps 0..upTo in the match that JS backtracking returns
|
|
205
|
+
* (each wildcard, left to right, takes the longest run that still lets the
|
|
206
|
+
* rest match), or null. The backward pass keeps a copy of every `size`-th
|
|
207
|
+
* row; the forward pass rebuilds one block of rows at a time into a fixed
|
|
208
|
+
* pool. About 2 * sqrt(n) rows are alive at once, and each row is computed at
|
|
209
|
+
* most twice.
|
|
210
|
+
*/
|
|
211
|
+
function greedyEnds(prepared, path, upTo) {
|
|
212
|
+
if (quickReject(prepared, path)) return null;
|
|
213
|
+
const { steps, starts, minLength } = prepared;
|
|
214
|
+
const n = steps.length;
|
|
215
|
+
const width = path.length - minLength + 1;
|
|
216
|
+
const size = n * width <= 65536 ? 1 : Math.ceil(Math.sqrt(n));
|
|
217
|
+
const saved = /* @__PURE__ */ new Map([[n, endRow(width)]]);
|
|
218
|
+
let next = endRow(width);
|
|
219
|
+
let row = new Uint8Array(width);
|
|
220
|
+
const first = steps[0]?.kind === "text" ? 1 : 0;
|
|
221
|
+
for (let i = n - 1; i >= first; i--) {
|
|
222
|
+
if (!stepBack(steps[i], path, starts[i], next, row)) return null;
|
|
223
|
+
if (i % size === 0) saved.set(i, row.slice());
|
|
224
|
+
const done = next;
|
|
225
|
+
next = row;
|
|
226
|
+
row = done;
|
|
227
|
+
}
|
|
228
|
+
if (next[0] !== 1) return null;
|
|
229
|
+
const pool = Array.from({ length: size }, () => new Uint8Array(width));
|
|
230
|
+
let top = 0;
|
|
231
|
+
const rowAt = (r) => r === top ? saved.get(r) : pool[r % size];
|
|
232
|
+
const ends = new Int32Array(upTo + 1);
|
|
233
|
+
let p = 0;
|
|
234
|
+
for (let i = 0; i <= upTo; i++) {
|
|
235
|
+
if (i + 1 > top) {
|
|
236
|
+
top = Math.min(n, Math.ceil((i + 1) / size) * size);
|
|
237
|
+
for (let r = top - 1; r > i; r--) stepBack(steps[r], path, starts[r], rowAt(r + 1), pool[r % size]);
|
|
238
|
+
}
|
|
239
|
+
p = greedyEnd(steps[i], path, p, p - starts[i], rowAt(i + 1));
|
|
240
|
+
ends[i] = p;
|
|
241
|
+
}
|
|
242
|
+
return ends;
|
|
243
|
+
}
|
|
244
|
+
/** The regex the earlier string-building compiler produced, for the deprecated `regex` view. */
|
|
245
|
+
function toRegExp(tokens) {
|
|
246
|
+
let body = "";
|
|
247
|
+
for (const token of tokens) if (token.kind === "text") body += token.text.replace(/[.+?^${}()|[\]\\:]/g, "\\$&");
|
|
248
|
+
else if (token.kind === "seg") body += `(?<${token.name}>[^/]+)`;
|
|
249
|
+
else body += token.name ? `(?<${token.name}>.*)` : ".*";
|
|
250
|
+
return new RegExp(`^${body}$`);
|
|
251
|
+
}
|
|
252
|
+
const compileCache = /* @__PURE__ */ new Map();
|
|
253
|
+
function compile(source) {
|
|
254
|
+
const cached = lruGet(compileCache, source);
|
|
255
|
+
if (cached) return cached;
|
|
256
|
+
const { tokens, paramNames } = parseCapture(source);
|
|
257
|
+
let error;
|
|
258
|
+
const seen = /* @__PURE__ */ new Set();
|
|
259
|
+
for (const name of paramNames) {
|
|
260
|
+
if (seen.has(name)) error ??= `Duplicate capture group name "${name}"`;
|
|
261
|
+
else if (name.charCodeAt(0) >= 48 && name.charCodeAt(0) <= 57) error ??= `Invalid capture group name "${name}"`;
|
|
262
|
+
seen.add(name);
|
|
263
|
+
}
|
|
264
|
+
const prepared = prepare(tokens);
|
|
265
|
+
let lastNamed = -1;
|
|
266
|
+
prepared.steps.forEach((step, i) => {
|
|
267
|
+
if (step.kind !== "text" && step.name) lastNamed = i;
|
|
268
|
+
});
|
|
269
|
+
let regex;
|
|
270
|
+
const compiled = {
|
|
271
|
+
...prepared,
|
|
272
|
+
paramNames,
|
|
273
|
+
error,
|
|
274
|
+
lastNamed,
|
|
275
|
+
get regex() {
|
|
276
|
+
return regex ??= toRegExp(parseCapture(source).tokens);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
79
279
|
lruSet(compileCache, source, compiled);
|
|
80
280
|
return compiled;
|
|
81
281
|
}
|
|
82
282
|
/**
|
|
283
|
+
* Parse a capture pattern. Throws a SyntaxError for a malformed one: a repeated
|
|
284
|
+
* name (`/:id/:id`, `/:splat/*`) or a name that starts with a digit (`/:1`).
|
|
285
|
+
*/
|
|
286
|
+
function compilePattern(source) {
|
|
287
|
+
const compiled = compile(source);
|
|
288
|
+
if (compiled.error) throw new SyntaxError(compiled.error);
|
|
289
|
+
return compiled;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
83
292
|
* Match a pattern against a pathname. Returns captured params, or null.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* e.g. multiple `*` producing duplicate `splat` capture names. Framework and
|
|
87
|
-
* API ingest both reject those, but a hand-crafted manifest could still reach
|
|
88
|
-
* the edge, and a single bad rule shouldn't 500 unrelated traffic.
|
|
293
|
+
* A malformed pattern (e.g. a repeated name like `/:splat/*`) also returns
|
|
294
|
+
* null, so one bad rule cannot fail unrelated requests.
|
|
89
295
|
*/
|
|
90
296
|
function matchPatternCaptures(pattern, pathname) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
297
|
+
const compiled = compile(pattern);
|
|
298
|
+
if (compiled.error) return null;
|
|
299
|
+
const { steps, lastNamed } = compiled;
|
|
300
|
+
const ends = lastNamed === -1 ? void 0 : greedyEnds(compiled, pathname, lastNamed);
|
|
301
|
+
if (ends === null || ends === void 0 && !matches(compiled, pathname)) return null;
|
|
302
|
+
const params = Object.create(null);
|
|
303
|
+
for (const name of compiled.paramNames) params[name] = "";
|
|
304
|
+
for (let i = 0; ends && i <= lastNamed; i++) {
|
|
305
|
+
const step = steps[i];
|
|
306
|
+
if (step.kind !== "text" && step.name) params[step.name] = pathname.slice(i === 0 ? 0 : ends[i - 1], ends[i]);
|
|
96
307
|
}
|
|
97
|
-
|
|
98
|
-
if (!match) return null;
|
|
99
|
-
return match.groups ?? {};
|
|
308
|
+
return params;
|
|
100
309
|
}
|
|
101
310
|
/**
|
|
102
311
|
* Match a pattern against a pathname (boolean form). Malformed patterns yield
|
|
103
312
|
* `false` rather than throwing (see `matchPatternCaptures`).
|
|
104
313
|
*/
|
|
105
314
|
function matchPattern(pattern, pathname) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
} catch {
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
315
|
+
const compiled = compile(pattern);
|
|
316
|
+
return compiled.error === void 0 && matches(compiled, pathname);
|
|
111
317
|
}
|
|
112
|
-
const
|
|
318
|
+
const literalCache = /* @__PURE__ */ new Map();
|
|
113
319
|
/**
|
|
114
320
|
* Match a pattern against a pathname using LITERAL semantics — no named
|
|
115
321
|
* captures. Only `*` is special (matches any characters including `/`); every
|
|
@@ -121,71 +327,32 @@ const literalRegexCache = /* @__PURE__ */ new Map();
|
|
|
121
327
|
* (`redirects`/`rewrites`/`fallbacks`) use the capture-aware `compilePattern`.
|
|
122
328
|
*/
|
|
123
329
|
function matchLiteralPattern(pattern, pathname) {
|
|
124
|
-
let
|
|
125
|
-
if (!
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
lruSet(literalRegexCache, pattern, regex);
|
|
133
|
-
}
|
|
134
|
-
try {
|
|
135
|
-
return regex.test(pathname);
|
|
136
|
-
} catch {
|
|
137
|
-
return false;
|
|
330
|
+
let prepared = lruGet(literalCache, pattern);
|
|
331
|
+
if (!prepared) {
|
|
332
|
+
const tokens = [];
|
|
333
|
+
parseStars(tokens, pattern);
|
|
334
|
+
prepared = prepare(tokens);
|
|
335
|
+
lruSet(literalCache, pattern, prepared);
|
|
138
336
|
}
|
|
337
|
+
return matches(prepared, pathname);
|
|
139
338
|
}
|
|
140
|
-
const
|
|
339
|
+
const segmentCache = /* @__PURE__ */ new Map();
|
|
141
340
|
/**
|
|
142
|
-
* Match a
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* matches ONE-OR-MORE characters that MAY cross `/` (`.+`, so `/docs/a` and
|
|
150
|
-
* `/docs/a/b` both match, but bare `/docs` / `/docs/` do NOT). Only the exact
|
|
151
|
-
* `{.+}` constraint widens — a REGEX-constrained param like `:path{[0-9]+}`
|
|
152
|
-
* keeps its single-segment `[^/]+` meaning. `*` still expands to a catch-all
|
|
153
|
-
* that crosses `/`. A mid-segment `:` (e.g. `/api/v1:health`,
|
|
154
|
-
* `/api/documents:batchGet`) stays a LITERAL — only whole-segment `:name` /
|
|
155
|
-
* `:name{.+}` anchors.
|
|
156
|
-
*
|
|
157
|
-
* This is for `routing.revalidate` keys ONLY. Those keys are machine-generated
|
|
158
|
-
* from page routes on the deploy write side (`[slug]` → `/blog/:slug`,
|
|
159
|
-
* `[...path]` → `/docs/:path{.+}`), so the `:name` here is a real route param
|
|
160
|
-
* and MUST bind the same way the framework's own client route matcher
|
|
161
|
-
* (`patternToRegex` in `pages/manifest.ts`) binds it.
|
|
162
|
-
*
|
|
163
|
-
* It is intentionally NOT used for user-authored header rules
|
|
164
|
-
* (`applyHeaderRules`) or `revalidateQueryAllowlist` keys
|
|
165
|
-
* (`buildVariantOriginalUrl`), where `:name` must stay a literal — those keep
|
|
166
|
-
* using `matchLiteralPattern`. Sharing the segment behaviour there would make a
|
|
167
|
-
* user rule like `/:batchGet` over-match every single-segment path.
|
|
168
|
-
*
|
|
169
|
-
* NOTE: this matcher has its OWN regex cache — the same pattern string compiles
|
|
170
|
-
* to a DIFFERENT regex than `matchLiteralPattern` produces, so the caches must
|
|
171
|
-
* not be shared or they would cross-contaminate.
|
|
341
|
+
* Match a `routing.revalidate` key or page pattern against a pathname. Like
|
|
342
|
+
* `matchLiteralPattern`, except for whole-segment params:
|
|
343
|
+
* `:name` — exactly one non-empty path segment (any non-`/` name)
|
|
344
|
+
* `:name{.+}` — one or more characters that may cross `/`
|
|
345
|
+
* A mid-segment `:` (`/api/v1:health`) stays literal, and any other `{...}`
|
|
346
|
+
* constraint keeps the single-segment meaning. Has its own cache because the
|
|
347
|
+
* same source parses differently here.
|
|
172
348
|
*/
|
|
173
349
|
function matchSegmentPattern(pattern, pathname) {
|
|
174
|
-
let
|
|
175
|
-
if (!
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
regex = new RegExp(`^${escaped}$`);
|
|
179
|
-
} catch {
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
lruSet(segmentRegexCache, pattern, regex);
|
|
183
|
-
}
|
|
184
|
-
try {
|
|
185
|
-
return regex.test(pathname);
|
|
186
|
-
} catch {
|
|
187
|
-
return false;
|
|
350
|
+
let prepared = lruGet(segmentCache, pattern);
|
|
351
|
+
if (!prepared) {
|
|
352
|
+
prepared = prepare(parseSegments(pattern));
|
|
353
|
+
lruSet(segmentCache, pattern, prepared);
|
|
188
354
|
}
|
|
355
|
+
return matches(prepared, pathname);
|
|
189
356
|
}
|
|
190
357
|
/**
|
|
191
358
|
* Substitute `:name` and `:name*` tokens in a destination string with the
|
|
@@ -202,20 +369,10 @@ function substituteParams(destination, params) {
|
|
|
202
369
|
//#endregion
|
|
203
370
|
//#region src/headers.ts
|
|
204
371
|
/**
|
|
205
|
-
* Headers that
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* `Clear-Site-Data` is the other cookie trust-boundary header, but it is NOT a
|
|
211
|
-
* blanket block — see {@link sanitizeClearSiteData}: only its cookie-clearing
|
|
212
|
-
* directives are stripped, since its other directives are origin-scoped.
|
|
213
|
-
*
|
|
214
|
-
* HSTS and other per-host headers are safe — they don't cross subdomain
|
|
215
|
-
* boundaries (includeSubDomains scopes to the responding host's subtree).
|
|
216
|
-
*
|
|
217
|
-
* TODO: Once void.app is registered on the Public Suffix List, these defenses
|
|
218
|
-
* can be removed — browsers will scope cross-subdomain cookies automatically.
|
|
372
|
+
* Headers that rules may never set on a shared domain. void.app is not on the
|
|
373
|
+
* Public Suffix List, so a `Set-Cookie` with `Domain=.void.app` reaches every
|
|
374
|
+
* tenant. `Clear-Site-Data` is filtered per directive by
|
|
375
|
+
* {@link sanitizeClearSiteData}; per-host headers such as HSTS stay allowed.
|
|
219
376
|
*/
|
|
220
377
|
const BLOCKED_HEADERS = /* @__PURE__ */ new Set(["set-cookie"]);
|
|
221
378
|
/**
|
|
@@ -344,16 +501,15 @@ function applyRoutingRules(pathname, rules, requestHost) {
|
|
|
344
501
|
if (!rules?.length) return null;
|
|
345
502
|
for (const rule of rules) {
|
|
346
503
|
if (rule.host && rule.host !== requestHost) continue;
|
|
347
|
-
let regex;
|
|
348
504
|
try {
|
|
349
|
-
|
|
505
|
+
compilePattern(rule.source);
|
|
350
506
|
} catch (err) {
|
|
351
507
|
console.warn(`[dispatch] skipping routing rule with invalid source ${JSON.stringify(rule.source)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
352
508
|
continue;
|
|
353
509
|
}
|
|
354
|
-
const
|
|
355
|
-
if (!
|
|
356
|
-
const destination = substituteParams(rule.destination,
|
|
510
|
+
const params = matchPatternCaptures(rule.source, pathname);
|
|
511
|
+
if (!params) continue;
|
|
512
|
+
const destination = substituteParams(rule.destination, params);
|
|
357
513
|
if (rule.status === 200) return {
|
|
358
514
|
type: "rewrite",
|
|
359
515
|
destination
|
|
@@ -439,10 +595,9 @@ function rewriteUrl(url, destination) {
|
|
|
439
595
|
return rewritten;
|
|
440
596
|
}
|
|
441
597
|
function rewriteRequest(request, url, originalUrl) {
|
|
442
|
-
const headers = new Headers(request.headers);
|
|
443
|
-
headers.set("X-Void-Original-URL", originalUrl);
|
|
444
598
|
const rewritten = new Request(url.toString(), request);
|
|
445
|
-
|
|
599
|
+
rewritten.headers.set("X-Void-Original-URL", originalUrl);
|
|
600
|
+
return rewritten;
|
|
446
601
|
}
|
|
447
602
|
//#endregion
|
|
448
603
|
//#region src/static.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@void/edge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Routing, headers, and static asset helpers for Void runtimes.",
|
|
5
5
|
"homepage": "https://void.cloud",
|
|
6
6
|
"bugs": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@void/deploy-core": "0.
|
|
29
|
+
"@void/deploy-core": "0.28.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@cloudflare/workers-types": "^5.20261009.1",
|