@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 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
- * Returns null (rather than throwing) when the pattern itself is malformed —
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 REVALIDATE ROUTE pattern against a pathname using SEGMENT-ANCHORED
91
- * semantics. Like `matchLiteralPattern` except a WHOLE-SEGMENT `:name` (a `:`
92
- * right after `/` or at the start of the pattern, whose name is any non-empty
93
- * run of non-`/` characters — NOT just JS-identifier chars, so `:post-id`,
94
- * `:post.id`, etc. all anchor — running up to the next `/` or end of string)
95
- * matches exactly ONE non-empty path segment (`[^/]+`). A whole-segment NAMED
96
- * CATCH-ALL `:name{.+}` (a named catch-all page `[...path]` → `/docs/:path{.+}`)
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
- * Shared path-pattern compiler used by header rules and routing rules.
3
+ * Path-pattern matchers shared by routing, header, and revalidate rules.
4
4
  *
5
- * Syntax:
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. Boolean matching remains
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
- function compilePattern(source) {
36
- const cached = lruGet(compileCache, source);
37
- if (cached) return cached;
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 legacySplatCaptured = false;
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 nameEnd = index + 1;
46
- while (nameEnd < source.length && PARAM_CHAR.test(source[nameEnd])) nameEnd++;
47
- const name = source.slice(index + 1, nameEnd);
48
- if (!name) {
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
- if (source[nameEnd] === "*") {
55
- pattern += `(?<${name}>.*)`;
56
- index = nameEnd + 1;
57
- } else {
58
- pattern += `(?<${name}>[^/]+)`;
59
- index = nameEnd;
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 (legacySplatCaptured) pattern += `.*`;
79
+ if (splatTaken) tokens.push({ kind: "any" });
63
80
  else {
64
81
  paramNames.push("splat");
65
- pattern += `(?<splat>.*)`;
66
- legacySplatCaptured = true;
82
+ tokens.push({
83
+ kind: "any",
84
+ name: "splat"
85
+ });
86
+ splatTaken = true;
67
87
  }
68
88
  index++;
69
89
  } else {
70
- if (REGEX_SPECIAL.test(character)) pattern += "\\";
71
- pattern += character;
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
- const compiled = {
76
- regex: new RegExp(`^${pattern}$`),
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
- * Returns null (rather than throwing) when the pattern itself is malformed —
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
- let compiled;
92
- try {
93
- compiled = compilePattern(pattern);
94
- } catch {
95
- return null;
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
- const match = compiled.regex.exec(pathname);
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
- try {
107
- return compilePattern(pattern).regex.test(pathname);
108
- } catch {
109
- return false;
110
- }
315
+ const compiled = compile(pattern);
316
+ return compiled.error === void 0 && matches(compiled, pathname);
111
317
  }
112
- const literalRegexCache = /* @__PURE__ */ new Map();
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 regex = lruGet(literalRegexCache, pattern);
125
- if (!regex) {
126
- try {
127
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
128
- regex = new RegExp(`^${escaped}$`);
129
- } catch {
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 segmentRegexCache = /* @__PURE__ */ new Map();
339
+ const segmentCache = /* @__PURE__ */ new Map();
141
340
  /**
142
- * Match a REVALIDATE ROUTE pattern against a pathname using SEGMENT-ANCHORED
143
- * semantics. Like `matchLiteralPattern` except a WHOLE-SEGMENT `:name` (a `:`
144
- * right after `/` or at the start of the pattern, whose name is any non-empty
145
- * run of non-`/` characters — NOT just JS-identifier chars, so `:post-id`,
146
- * `:post.id`, etc. all anchor — running up to the next `/` or end of string)
147
- * matches exactly ONE non-empty path segment (`[^/]+`). A whole-segment NAMED
148
- * CATCH-ALL `:name{.+}` (a named catch-all page `[...path]` → `/docs/:path{.+}`)
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 regex = lruGet(segmentRegexCache, pattern);
175
- if (!regex) {
176
- try {
177
- const escaped = pattern.replace(/(^|\/):[^/{}]+\{\.\+\}(?=\/|$)/g, "$1").replace(/(^|\/):[^/]+(?=\/|$)/g, "$1").replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*").replaceAll("", "[^/]+").replaceAll("", ".+");
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 user header rules may not set at all, because they are always a
206
- * cookie operation. On the shared *.void.app domain (not on the PSL) void.app
207
- * is the registrable domain, so a `Set-Cookie` with `Domain=.void.app` is
208
- * readable by every tenant's subdomain. Dropped wholesale.
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
- ({regex} = compilePattern(rule.source));
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 match = regex.exec(pathname);
355
- if (!match) continue;
356
- const destination = substituteParams(rule.destination, match.groups ?? {});
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
- return new Request(rewritten, { headers });
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.26.1",
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.26.1"
29
+ "@void/deploy-core": "0.28.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@cloudflare/workers-types": "^5.20261009.1",