@sproutboat/assets 0.3.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,20 @@
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
+
3
18
  ## 0.3.0
4
19
 
5
20
  ### Minor Changes
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@sproutboat/assets",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
- "type": "module",
6
5
  "repository": {
7
6
  "type": "git",
8
7
  "url": "git+https://github.com/baronunread/sproutboat-packages.git",
9
8
  "directory": "packages/assets"
10
9
  },
10
+ "type": "module",
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "scripts": {
@@ -6,9 +6,11 @@ import {
6
6
  contentType,
7
7
  isSproutFirst,
8
8
  matchHeaders,
9
+ findRedirect,
9
10
  matchRedirect,
10
11
  parseHeaders,
11
12
  parseRedirects,
13
+ resolveAssetKey,
12
14
  readAssetRules,
13
15
  walkAssets,
14
16
  } from "./assets";
@@ -121,6 +123,54 @@ test("matchRedirect: a trailing * captures the rest of the path as :splat", () =
121
123
  expect(matchRedirect(rule, "/blog")).toBeNull();
122
124
  });
123
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
+
124
174
  test("readAssetRules: empty when neither file exists, parsed when both do", () => {
125
175
  const dir = mkdtempSync(join(tmpdir(), "sb-rules-"));
126
176
  expect(readAssetRules(dir)).toEqual({ headers: [], redirects: [] });
package/src/assets.ts CHANGED
@@ -211,42 +211,103 @@ export function parseRedirects(text: string): RedirectRule[] {
211
211
  }
212
212
 
213
213
  /**
214
- * Match `pathname` against one `_redirects` rule's `from`, substituting
215
- * `:name` segments and a trailing `*` (as `:splat`) into `to`. `null` if the
216
- * rule doesn't match.
214
+ * Cloudflare's `_redirects` matching (workers-sdk, `asset-worker/src/utils/rules-engine.ts`), on the request pathname:
217
215
  *
218
- * The splat case matches on the literal string prefix up to `*`, not a
219
- * segment array — `pathname.split("/").filter(Boolean)` would collapse
220
- * `/blog` and `/blog/` to the same `["blog"]`, wrongly matching `/blog/*`
221
- * against a path that never reaches the `/` the wildcard starts after.
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.
222
225
  */
223
- export function matchRedirect(rule: RedirectRule, pathname: string): string | null {
224
- const starIndex = rule.from.indexOf("*");
225
- if (starIndex !== -1) {
226
- const prefix = rule.from.slice(0, starIndex);
227
- if (!pathname.startsWith(prefix)) return null;
228
- const splat = pathname.slice(prefix.length);
229
- return rule.to.split(":splat").join(splat);
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;
230
253
  }
254
+ flush();
255
+ return tokens;
256
+ }
231
257
 
232
- const fromSegments = rule.from.split("/").filter(Boolean);
233
- const pathSegments = pathname.split("/").filter(Boolean);
234
- if (fromSegments.length !== pathSegments.length) return null;
235
- const params: Record<string, string> = {};
236
- for (let i = 0; i < fromSegments.length; i++) {
237
- const segment = fromSegments[i];
238
- if (segment.startsWith(":")) {
239
- params[segment.slice(1)] = pathSegments[i];
240
- continue;
241
- }
242
- if (pathSegments[i] !== segment) return null;
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();
243
271
  }
272
+ return false;
273
+ }
274
+
275
+ const isStaticRedirect = (from: string): boolean => !from.includes("*") && !/:[A-Za-z]/.test(from);
244
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;
245
283
  let to = rule.to;
246
- for (const [name, value] of Object.entries(params)) to = to.split(`:${name}`).join(value);
284
+ for (const [name, value] of groups) to = to.split(`:${name}`).join(value);
247
285
  return to;
248
286
  }
249
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
+
250
311
  export type AssetRules = { headers: HeaderRule[]; redirects: RedirectRule[] };
251
312
 
252
313
  /** Read and parse `_headers`/`_redirects` from the assets root, if present. */