@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 +15 -0
- package/package.json +2 -2
- package/src/assets.test.ts +50 -0
- package/src/assets.ts +87 -26
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.
|
|
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": {
|
package/src/assets.test.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
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. */
|