@sproutboat/assets 0.2.0 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @sproutboat/assets
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - d4bbcd5: `_redirects` matching follows Cloudflare's rules. The whole request path must match, so a trailing slash is significant: `/compare /compare/ 308` redirects `/compare` once, and `/compare/` is then served (or matched by a rule of its own) instead of redirecting to itself forever. Before, a rule's `from` was compared segment by segment with the empty ones dropped, so `/compare` also matched `/compare/` and `/user/:id` matched `/user/42/`.
8
+
9
+ What matches, as in Cloudflare's rules engine:
10
+
11
+ - `:name` matches one or more characters up to the next `/`, may sit inside a segment (`/files/:name.html`), and may appear once per rule;
12
+ - `*` matches anything including `/` and the empty string, is `:splat` in the target, and may sit in the middle of the path (`/a/*/c`); a rule has at most one;
13
+ - a rule with a repeated name or two splats never matches (Cloudflare drops it);
14
+ - a static rule (no `:name`, no `*`) beats a dynamic one whatever the order; among rules of one kind the first in the file wins. `findRedirect(rules, pathname)` is the new entry point; `matchRedirect(rule, pathname)` still answers for one rule.
15
+
16
+ This changes what an existing `_redirects` file does for paths that differ only by a trailing slash, with no compatibility date: the edge serves assets before any app code runs and cannot see the date, and the old matching was what looped. Run `bun run test:native-asset-redirects` to check the compiled matcher against the same cases as the portable one.
17
+
18
+ ## 0.3.0
19
+
20
+ ### Minor Changes
21
+
22
+ - 6145afe: `_headers`/`_redirects` support (baronunread/sproutboat#61). `AssetManifest` gains optional `headers`/`redirects` arrays, parsed at build time from `_headers`/`_redirects` files at the assets root by the new `readAssetRules()` (`walkAssets()` now excludes those two filenames from the servable file list, root only). `matchHeaders()`/`matchRedirect()` apply them at request time: `_headers` uses Netlify/Pages-style greedy `*` matching (distinct from `run_sprout_first`'s single-segment `globMatch`), `_redirects` supports `:name` segments and a trailing `*` substituted as `:splat`. Pure parsing/matching in this package; wiring them into the edge/broker request path is a separate change downstream.
23
+
3
24
  ## 0.2.0
4
25
 
5
26
  ### Minor Changes
package/README.md CHANGED
@@ -1,4 +1,27 @@
1
1
  # `@sproutboat/assets`
2
2
 
3
- Single source of truth for static assets: manifests, key resolution, and
4
- the sprout-first routing rules shared by the edge asset server and the CLI.
3
+ Static asset manifests, key resolution, and sprout-first routing rules,
4
+ shared by the edge asset server and the CLI.
5
+
6
+ ```sh
7
+ bun add @sproutboat/assets
8
+ ```
9
+
10
+ ```ts
11
+ import { resolveAssetKey, walkAssets } from "@sproutboat/assets";
12
+
13
+ const files = walkAssets("web/dist");
14
+ const key = resolveAssetKey("/docs", (k) => k in files);
15
+ ```
16
+
17
+ ## API
18
+
19
+ - `walkAssets(directory)` inventories an asset directory into the file
20
+ table (paths, hashes, sizes).
21
+ - `resolveAssetKey(path, has)` maps a request path to an asset key,
22
+ honoring directory defaults and index files.
23
+ - `isSproutFirst(spec, pathname)` reports whether the handler sees a
24
+ request before the asset server does, given the assets
25
+ `runSproutFirst` spec.
26
+ - `contentType(name)` maps extensions to content types.
27
+ - Types: `AssetManifest`, `AssetFiles`, `AssetEntry`.
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@sproutboat/assets",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/baronunread/sproutboat-packages.git",
8
+ "directory": "packages/assets"
9
+ },
5
10
  "type": "module",
6
11
  "main": "./src/index.ts",
7
12
  "types": "./src/index.ts",
@@ -0,0 +1,183 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ contentType,
7
+ isSproutFirst,
8
+ matchHeaders,
9
+ findRedirect,
10
+ matchRedirect,
11
+ parseHeaders,
12
+ parseRedirects,
13
+ resolveAssetKey,
14
+ readAssetRules,
15
+ walkAssets,
16
+ } from "./assets";
17
+
18
+ test("contentType maps known extensions, defaults to octet-stream", () => {
19
+ expect(contentType("index.html")).toBe("text/html; charset=utf-8");
20
+ expect(contentType("app.CSS")).toBe("text/css; charset=utf-8");
21
+ expect(contentType("logo.png")).toBe("image/png");
22
+ expect(contentType("archive.tar")).toBe("application/octet-stream");
23
+ expect(contentType("noext")).toBe("application/octet-stream");
24
+ });
25
+
26
+ test("isSproutFirst: boolean short-circuits; patterns match with negation", () => {
27
+ expect(isSproutFirst(true, "/anything")).toBe(true);
28
+ expect(isSproutFirst(false, "/anything")).toBe(false);
29
+ expect(isSproutFirst(["/api/*"], "/api/users")).toBe(true);
30
+ expect(isSproutFirst(["/api/*"], "/index.html")).toBe(false);
31
+ expect(isSproutFirst(["/api/*", "!/api/docs/*"], "/api/docs/intro")).toBe(false);
32
+ expect(isSproutFirst(["/api/**"], "/api/a/b/c")).toBe(true);
33
+ expect(isSproutFirst(["/api/*"], "/api/a/b")).toBe(false); // single * stays in a segment
34
+ });
35
+
36
+ test("walkAssets hashes every file under a directory, posix keys", () => {
37
+ const dir = mkdtempSync(join(tmpdir(), "sb-walk-"));
38
+ mkdirSync(join(dir, "css"));
39
+ writeFileSync(join(dir, "index.html"), "<h1>hi</h1>");
40
+ writeFileSync(join(dir, "css", "app.css"), "body{}");
41
+ writeFileSync(join(dir, ".hidden"), "skip me");
42
+ const files = walkAssets(dir);
43
+ expect(Object.keys(files).sort()).toEqual(["/css/app.css", "/index.html"]);
44
+ expect(files["/index.html"]).toMatchObject({ size: 11, type: "text/html; charset=utf-8" });
45
+ expect(files["/index.html"].hash).toMatch(/^[0-9a-f]{64}$/);
46
+ rmSync(dir, { recursive: true, force: true });
47
+ });
48
+
49
+ test("walkAssets excludes _headers/_redirects at the root, but not nested files with those names", () => {
50
+ const dir = mkdtempSync(join(tmpdir(), "sb-walk-"));
51
+ mkdirSync(join(dir, "sub"));
52
+ writeFileSync(join(dir, "_headers"), "/*\n X-Test: 1\n");
53
+ writeFileSync(join(dir, "_redirects"), "/a /b\n");
54
+ writeFileSync(join(dir, "sub", "_headers"), "not config, just a file");
55
+ const files = walkAssets(dir);
56
+ expect(Object.keys(files).sort()).toEqual(["/sub/_headers"]);
57
+ rmSync(dir, { recursive: true, force: true });
58
+ });
59
+
60
+ test("parseHeaders: set and unset lines, comments, and blank lines ending a block", () => {
61
+ const rules = parseHeaders(
62
+ [
63
+ "# a comment",
64
+ "/*",
65
+ " X-Frame-Options: DENY",
66
+ " Cache-Control: no-cache",
67
+ "",
68
+ "/assets/*",
69
+ " Cache-Control: public, max-age=31536000",
70
+ " ! X-Frame-Options",
71
+ ].join("\n"),
72
+ );
73
+ expect(rules).toEqual([
74
+ {
75
+ pattern: "/*",
76
+ set: [
77
+ ["X-Frame-Options", "DENY"],
78
+ ["Cache-Control", "no-cache"],
79
+ ],
80
+ unset: [],
81
+ },
82
+ { pattern: "/assets/*", set: [["Cache-Control", "public, max-age=31536000"]], unset: ["X-Frame-Options"] },
83
+ ]);
84
+ });
85
+
86
+ test("parseHeaders: an indented line with no preceding pattern is ignored", () => {
87
+ expect(parseHeaders(" X-Test: 1\n")).toEqual([]);
88
+ });
89
+
90
+ test("matchHeaders: returns rules whose pattern matches, in file order", () => {
91
+ const rules = parseHeaders("/*\n A: 1\n\n/assets/*\n B: 2\n");
92
+ expect(matchHeaders(rules, "/assets/app.js").map((r) => r.pattern)).toEqual(["/*", "/assets/*"]);
93
+ expect(matchHeaders(rules, "/index.html").map((r) => r.pattern)).toEqual(["/*"]);
94
+ });
95
+
96
+ test("parseRedirects: from/to/status, default status, malformed lines dropped", () => {
97
+ expect(
98
+ parseRedirects(["# comment", "/old /new 301", "/a /b", "/bad-status /c 200", "just-one-token"].join("\n")),
99
+ ).toEqual([
100
+ { from: "/old", to: "/new", status: 301 },
101
+ { from: "/a", to: "/b", status: 302 },
102
+ ]);
103
+ });
104
+
105
+ test("matchRedirect: exact path, no placeholders", () => {
106
+ const rule = { from: "/old", to: "/new", status: 301 };
107
+ expect(matchRedirect(rule, "/old")).toBe("/new");
108
+ expect(matchRedirect(rule, "/old/extra")).toBeNull();
109
+ expect(matchRedirect(rule, "/other")).toBeNull();
110
+ });
111
+
112
+ test("matchRedirect: :name segments substitute into `to`", () => {
113
+ const rule = { from: "/user/:id", to: "/profile/:id", status: 302 };
114
+ expect(matchRedirect(rule, "/user/42")).toBe("/profile/42");
115
+ expect(matchRedirect(rule, "/user")).toBeNull();
116
+ expect(matchRedirect(rule, "/user/42/extra")).toBeNull();
117
+ });
118
+
119
+ test("matchRedirect: a trailing * captures the rest of the path as :splat", () => {
120
+ const rule = { from: "/blog/*", to: "/articles/:splat", status: 301 };
121
+ expect(matchRedirect(rule, "/blog/2024/hello")).toBe("/articles/2024/hello");
122
+ expect(matchRedirect(rule, "/blog/")).toBe("/articles/");
123
+ expect(matchRedirect(rule, "/blog")).toBeNull();
124
+ });
125
+
126
+ test("matchRedirect: the whole path must match, so a trailing slash is significant", () => {
127
+ const add = { from: "/compare", to: "/compare/", status: 308 };
128
+ expect(matchRedirect(add, "/compare")).toBe("/compare/");
129
+ expect(matchRedirect(add, "/compare/")).toBeNull(); // the loop: this used to match again
130
+ const drop = { from: "/compare/", to: "/compare", status: 308 };
131
+ expect(matchRedirect(drop, "/compare/")).toBe("/compare");
132
+ expect(matchRedirect(drop, "/compare")).toBeNull();
133
+ const named = { from: "/user/:id", to: "/profile/:id", status: 302 };
134
+ expect(matchRedirect(named, "/user/42/")).toBeNull();
135
+ expect(matchRedirect(named, "/user/")).toBeNull();
136
+ const splat = { from: "/blog/*", to: "/posts/:splat", status: 301 };
137
+ expect(matchRedirect(splat, "/blog/a/")).toBe("/posts/a/");
138
+ });
139
+
140
+ test("matchRedirect: a name is one or more characters, backtracks around text, and the splat may sit in the middle", () => {
141
+ expect(matchRedirect({ from: "/files/:name.html", to: "/pages/:name", status: 302 }, "/files/about.html")).toBe(
142
+ "/pages/about",
143
+ );
144
+ expect(matchRedirect({ from: "/:a-:b", to: "/x/:b/:a", status: 302 }, "/one-two")).toBe("/x/two/one");
145
+ expect(matchRedirect({ from: "/a/*/c", to: "/x/:splat", status: 302 }, "/a/b/b2/c")).toBe("/x/b/b2");
146
+ expect(matchRedirect({ from: "/a/*/c", to: "/x/:splat", status: 302 }, "/a/c")).toBeNull();
147
+ expect(matchRedirect({ from: "/a:1", to: "/b", status: 302 }, "/a:1")).toBe("/b"); // a colon with no letter is text
148
+ });
149
+
150
+ test("matchRedirect: a rule with a repeated name or two splats never matches, as on Cloudflare", () => {
151
+ expect(matchRedirect({ from: "/:a/:a", to: "/x", status: 302 }, "/p/p")).toBeNull();
152
+ expect(matchRedirect({ from: "/*/*", to: "/x", status: 302 }, "/p/q")).toBeNull();
153
+ });
154
+
155
+ test("findRedirect: a static rule beats a dynamic one above it; within a kind the first rule wins", () => {
156
+ const rules = parseRedirects(
157
+ "/blog/* /dynamic/:splat\n/blog/old /static\n/blog/old /static-2\n/x/:a /first\n/x/:b /second",
158
+ );
159
+ expect(findRedirect(rules, "/blog/old")).toEqual({ rule: rules[1], location: "/static" });
160
+ expect(findRedirect(rules, "/blog/new")).toEqual({ rule: rules[0], location: "/dynamic/new" });
161
+ expect(findRedirect(rules, "/x/1")).toEqual({ rule: rules[3], location: "/first" });
162
+ expect(findRedirect(rules, "/nothing")).toBeNull();
163
+ });
164
+
165
+ test("a trailing-slash rule is one hop against the asset resolver too", () => {
166
+ const rules = parseRedirects("/compare /compare/ 308");
167
+ const files = new Set(["/compare/index.html"]);
168
+ const first = findRedirect(rules, "/compare");
169
+ expect(first?.location).toBe("/compare/");
170
+ expect(findRedirect(rules, first!.location)).toBeNull();
171
+ expect(resolveAssetKey(first!.location, (key) => files.has(key))).toBe("/compare/index.html");
172
+ });
173
+
174
+ test("readAssetRules: empty when neither file exists, parsed when both do", () => {
175
+ const dir = mkdtempSync(join(tmpdir(), "sb-rules-"));
176
+ expect(readAssetRules(dir)).toEqual({ headers: [], redirects: [] });
177
+ writeFileSync(join(dir, "_headers"), "/*\n X: 1\n");
178
+ writeFileSync(join(dir, "_redirects"), "/a /b\n");
179
+ const rules = readAssetRules(dir);
180
+ expect(rules.headers).toHaveLength(1);
181
+ expect(rules.redirects).toHaveLength(1);
182
+ rmSync(dir, { recursive: true, force: true });
183
+ });
package/src/assets.ts CHANGED
@@ -6,18 +6,26 @@
6
6
  * the sprout chooses to serve itself.
7
7
  */
8
8
  import { createHash } from "node:crypto";
9
- import { readdirSync, readFileSync } from "node:fs";
9
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
10
  import { join, posix } from "node:path";
11
11
 
12
12
  export type AssetEntry = { hash: string; size: number; type: string };
13
13
  /** `/absolute/posix/path` -> entry. Serialised into `assets.json`. */
14
14
  export type AssetFiles = { [path: string]: AssetEntry };
15
+ /** A `_headers` block: `set` applied in order, `unset` (`! Name`) removed. */
16
+ export type HeaderRule = { pattern: string; set: [string, string][]; unset: string[] };
17
+ /** A `_redirects` line: `from to [status]`, `:name` segments and a trailing `*` -> `:splat`. */
18
+ export type RedirectRule = { from: string; to: string; status: number };
15
19
  export type AssetManifest = {
16
20
  /** Fallback for requests that match no file, applied by the broker. */
17
21
  notFound: "none" | "single-page-application" | "404-page";
18
22
  /** `true` = every path to the sprout first; string[] = selective (leading `!` negates). */
19
23
  runSproutFirst: boolean | string[];
20
24
  files: AssetFiles;
25
+ /** Parsed from `_headers` at the assets root (#61). Omitted when there is none. */
26
+ headers?: HeaderRule[];
27
+ /** Parsed from `_redirects` at the assets root (#61). Omitted when there is none. */
28
+ redirects?: RedirectRule[];
21
29
  };
22
30
 
23
31
  const TYPES = new Map<string, string>([
@@ -73,12 +81,13 @@ export function resolveAssetKey(path: string, has: (key: string) => boolean): st
73
81
  return null;
74
82
  }
75
83
 
76
- /** Walk `dir` recursively, returning `{ "/path": {hash,size,type} }`. */
84
+ /** Walk `dir` recursively, returning `{ "/path": {hash,size,type} }`. `_headers`/`_redirects` at the root are config, not servable files (#61) — see `readAssetRules`. */
77
85
  export function walkAssets(dir: string) {
78
86
  const out: AssetFiles = {};
79
87
  const recurse = (abs: string, rel: string): void => {
80
88
  for (const ent of readdirSync(abs, { withFileTypes: true })) {
81
89
  if (ent.name.startsWith(".")) continue;
90
+ if (rel === "" && (ent.name === "_headers" || ent.name === "_redirects")) continue;
82
91
  const childAbs = join(abs, ent.name);
83
92
  const childRel = posix.join(rel, ent.name);
84
93
  if (ent.isDirectory()) {
@@ -119,3 +128,194 @@ function globMatch(pattern: string, path: string): boolean {
119
128
  .replace(/ /g, ".*");
120
129
  return new RegExp(`^${rx}$`).test(path);
121
130
  }
131
+
132
+ const MAX_HEADER_RULES = 2000;
133
+ const MAX_REDIRECT_RULES = 2000;
134
+
135
+ /**
136
+ * Parse a Pages/Netlify-style `_headers` file: a path pattern at column 0,
137
+ * followed by indented `Name: value` lines (`! Name` strips a header
138
+ * downstream). A blank line or a new pattern line ends the current block.
139
+ */
140
+ export function parseHeaders(text: string): HeaderRule[] {
141
+ const rules: HeaderRule[] = [];
142
+ let current: HeaderRule | null = null;
143
+ for (const rawLine of text.split("\n")) {
144
+ if (rawLine.trim() === "" || /^\s*#/.test(rawLine)) {
145
+ current = null;
146
+ continue;
147
+ }
148
+ if (!/^\s/.test(rawLine)) {
149
+ if (rules.length >= MAX_HEADER_RULES) {
150
+ current = null;
151
+ continue;
152
+ }
153
+ current = { pattern: rawLine.trim(), set: [], unset: [] };
154
+ rules.push(current);
155
+ continue;
156
+ }
157
+ if (!current) continue;
158
+ const line = rawLine.trim();
159
+ if (line.startsWith("!")) {
160
+ current.unset.push(line.slice(1).trim());
161
+ continue;
162
+ }
163
+ const colon = line.indexOf(":");
164
+ if (colon === -1) continue;
165
+ current.set.push([line.slice(0, colon).trim(), line.slice(colon + 1).trim()]);
166
+ }
167
+ return rules;
168
+ }
169
+
170
+ /**
171
+ * `_headers` patterns follow Netlify/Pages semantics, not `globMatch`'s
172
+ * (`run_sprout_first` deliberately keeps a bare `*` inside one segment) — a
173
+ * trailing `*` here is greedy across `/`, so `/assets/*` catches
174
+ * `/assets/deeply/nested.js` the way a real `_headers` file expects.
175
+ */
176
+ function headerPatternMatches(pattern: string, pathname: string): boolean {
177
+ const patternSegments = pattern.split("/").filter(Boolean);
178
+ const pathSegments = pathname.split("/").filter(Boolean);
179
+ for (let i = 0; i < patternSegments.length; i++) {
180
+ if (patternSegments[i] === "*") return true;
181
+ if (pathSegments[i] !== patternSegments[i]) return false;
182
+ }
183
+ return patternSegments.length === pathSegments.length;
184
+ }
185
+
186
+ /** Every `_headers` rule whose pattern matches `pathname`, in file order (a later rule can override or unset an earlier one when applied). */
187
+ export function matchHeaders(rules: readonly HeaderRule[], pathname: string): HeaderRule[] {
188
+ return rules.filter((rule) => headerPatternMatches(rule.pattern, pathname));
189
+ }
190
+
191
+ /**
192
+ * Parse a Pages/Netlify-style `_redirects` file: `from to [status]` per
193
+ * line, whitespace-separated. `status` must be a 3xx (defaults to 302 when
194
+ * omitted); anything else is dropped as malformed rather than silently
195
+ * treated as a rewrite.
196
+ */
197
+ export function parseRedirects(text: string): RedirectRule[] {
198
+ const rules: RedirectRule[] = [];
199
+ for (const rawLine of text.split("\n")) {
200
+ const line = rawLine.trim();
201
+ if (!line || line.startsWith("#")) continue;
202
+ const parts = line.split(/\s+/);
203
+ if (parts.length < 2) continue;
204
+ const [from, to, statusText] = parts;
205
+ const status = statusText ? Number(statusText) : 302;
206
+ if (!Number.isInteger(status) || status < 300 || status > 399) continue;
207
+ rules.push({ from, to, status });
208
+ if (rules.length >= MAX_REDIRECT_RULES) break;
209
+ }
210
+ return rules;
211
+ }
212
+
213
+ /**
214
+ * Cloudflare's `_redirects` matching (workers-sdk, `asset-worker/src/utils/rules-engine.ts`), on the request pathname:
215
+ *
216
+ * - the whole pathname must match, so a trailing slash is significant: `/compare` does not match `/compare/`, and
217
+ * `/compare /compare/ 308` is one hop, not a loop;
218
+ * - `:name` matches one or more characters up to the next `/` (letters, digits and `_` after a letter name it);
219
+ * a name may appear once per rule;
220
+ * - `*` matches anything, including `/` and nothing, and is `:splat` in `to`; a rule has at most one;
221
+ * - a rule that breaks those limits never matches (Cloudflare drops it).
222
+ *
223
+ * A rule with no `:name` and no `*` is static: a static rule beats a dynamic one whatever the order (`findRedirect`);
224
+ * among rules of one kind the first in the file wins.
225
+ */
226
+ type RedirectToken = { kind: "text"; text: string } | { kind: "name"; name: string } | { kind: "splat" };
227
+
228
+ function redirectTokens(from: string): RedirectToken[] | null {
229
+ const tokens: RedirectToken[] = [];
230
+ const names = new Set<string>();
231
+ let text = "";
232
+ const flush = () => {
233
+ if (text !== "") tokens.push({ kind: "text", text });
234
+ text = "";
235
+ };
236
+ for (let i = 0; i < from.length; i++) {
237
+ const c = from[i]!;
238
+ if (c === "*") {
239
+ flush();
240
+ if (names.has("splat")) return null;
241
+ names.add("splat");
242
+ tokens.push({ kind: "splat" });
243
+ } else if (c === ":" && /[A-Za-z]/.test(from[i + 1] ?? "")) {
244
+ let end = i + 2;
245
+ while (end < from.length && /\w/.test(from[end]!)) end++;
246
+ const name = from.slice(i + 1, end);
247
+ if (names.has(name)) return null;
248
+ names.add(name);
249
+ flush();
250
+ tokens.push({ kind: "name", name });
251
+ i = end - 1;
252
+ } else text += c;
253
+ }
254
+ flush();
255
+ return tokens;
256
+ }
257
+
258
+ function matchTokens(tokens: RedirectToken[], t: number, path: string, p: number, groups: [string, string][]): boolean {
259
+ const token = tokens[t];
260
+ if (token === undefined) return p === path.length;
261
+ if (token.kind === "text")
262
+ return path.startsWith(token.text, p) && matchTokens(tokens, t + 1, path, p + token.text.length, groups);
263
+ const slash = path.indexOf("/", p);
264
+ const limit = token.kind === "splat" || slash === -1 ? path.length : slash;
265
+ const name = token.kind === "splat" ? "splat" : token.name;
266
+ // Longest first, as the regular expression `[^/]+` / `.*` is greedy; a name needs at least one character.
267
+ for (let q = limit; q >= (token.kind === "splat" ? p : p + 1); q--) {
268
+ groups.push([name, path.slice(p, q)]);
269
+ if (matchTokens(tokens, t + 1, path, q, groups)) return true;
270
+ groups.pop();
271
+ }
272
+ return false;
273
+ }
274
+
275
+ const isStaticRedirect = (from: string): boolean => !from.includes("*") && !/:[A-Za-z]/.test(from);
276
+
277
+ function redirectLocation(rule: RedirectRule, pathname: string): string | null {
278
+ if (isStaticRedirect(rule.from)) return rule.from === pathname ? rule.to : null;
279
+ const tokens = redirectTokens(rule.from);
280
+ if (tokens === null) return null;
281
+ const groups: [string, string][] = [];
282
+ if (!matchTokens(tokens, 0, pathname, 0, groups)) return null;
283
+ let to = rule.to;
284
+ for (const [name, value] of groups) to = to.split(`:${name}`).join(value);
285
+ return to;
286
+ }
287
+
288
+ /**
289
+ * Where one `_redirects` rule sends `pathname`, with `:name` and `:splat` substituted into `to`; `null` if the rule does
290
+ * not match. See the notes above for what matches.
291
+ */
292
+ export function matchRedirect(rule: RedirectRule, pathname: string): string | null {
293
+ return redirectLocation(rule, pathname);
294
+ }
295
+
296
+ /** The rule that redirects `pathname`, and where to: the first static rule that matches, else the first dynamic one. */
297
+ export function findRedirect(
298
+ rules: readonly RedirectRule[],
299
+ pathname: string,
300
+ ): { rule: RedirectRule; location: string } | null {
301
+ for (const rule of rules)
302
+ if (isStaticRedirect(rule.from) && rule.from === pathname) return { rule, location: rule.to };
303
+ for (const rule of rules) {
304
+ if (isStaticRedirect(rule.from)) continue;
305
+ const location = redirectLocation(rule, pathname);
306
+ if (location !== null) return { rule, location };
307
+ }
308
+ return null;
309
+ }
310
+
311
+ export type AssetRules = { headers: HeaderRule[]; redirects: RedirectRule[] };
312
+
313
+ /** Read and parse `_headers`/`_redirects` from the assets root, if present. */
314
+ export function readAssetRules(dir: string): AssetRules {
315
+ const headersPath = join(dir, "_headers");
316
+ const redirectsPath = join(dir, "_redirects");
317
+ return {
318
+ headers: existsSync(headersPath) ? parseHeaders(readFileSync(headersPath, "utf8")) : [],
319
+ redirects: existsSync(redirectsPath) ? parseRedirects(readFileSync(redirectsPath, "utf8")) : [],
320
+ };
321
+ }