@sproutboat/assets 0.2.0 → 0.3.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/CHANGELOG.md +6 -0
- package/README.md +25 -2
- package/package.json +6 -1
- package/src/assets.test.ts +133 -0
- package/src/assets.ts +141 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @sproutboat/assets
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
3
9
|
## 0.2.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
# `@sproutboat/assets`
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutboat/assets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/baronunread/sproutboat-packages.git",
|
|
9
|
+
"directory": "packages/assets"
|
|
10
|
+
},
|
|
6
11
|
"main": "./src/index.ts",
|
|
7
12
|
"types": "./src/index.ts",
|
|
8
13
|
"scripts": {
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
matchRedirect,
|
|
10
|
+
parseHeaders,
|
|
11
|
+
parseRedirects,
|
|
12
|
+
readAssetRules,
|
|
13
|
+
walkAssets,
|
|
14
|
+
} from "./assets";
|
|
15
|
+
|
|
16
|
+
test("contentType maps known extensions, defaults to octet-stream", () => {
|
|
17
|
+
expect(contentType("index.html")).toBe("text/html; charset=utf-8");
|
|
18
|
+
expect(contentType("app.CSS")).toBe("text/css; charset=utf-8");
|
|
19
|
+
expect(contentType("logo.png")).toBe("image/png");
|
|
20
|
+
expect(contentType("archive.tar")).toBe("application/octet-stream");
|
|
21
|
+
expect(contentType("noext")).toBe("application/octet-stream");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("isSproutFirst: boolean short-circuits; patterns match with negation", () => {
|
|
25
|
+
expect(isSproutFirst(true, "/anything")).toBe(true);
|
|
26
|
+
expect(isSproutFirst(false, "/anything")).toBe(false);
|
|
27
|
+
expect(isSproutFirst(["/api/*"], "/api/users")).toBe(true);
|
|
28
|
+
expect(isSproutFirst(["/api/*"], "/index.html")).toBe(false);
|
|
29
|
+
expect(isSproutFirst(["/api/*", "!/api/docs/*"], "/api/docs/intro")).toBe(false);
|
|
30
|
+
expect(isSproutFirst(["/api/**"], "/api/a/b/c")).toBe(true);
|
|
31
|
+
expect(isSproutFirst(["/api/*"], "/api/a/b")).toBe(false); // single * stays in a segment
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("walkAssets hashes every file under a directory, posix keys", () => {
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "sb-walk-"));
|
|
36
|
+
mkdirSync(join(dir, "css"));
|
|
37
|
+
writeFileSync(join(dir, "index.html"), "<h1>hi</h1>");
|
|
38
|
+
writeFileSync(join(dir, "css", "app.css"), "body{}");
|
|
39
|
+
writeFileSync(join(dir, ".hidden"), "skip me");
|
|
40
|
+
const files = walkAssets(dir);
|
|
41
|
+
expect(Object.keys(files).sort()).toEqual(["/css/app.css", "/index.html"]);
|
|
42
|
+
expect(files["/index.html"]).toMatchObject({ size: 11, type: "text/html; charset=utf-8" });
|
|
43
|
+
expect(files["/index.html"].hash).toMatch(/^[0-9a-f]{64}$/);
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("walkAssets excludes _headers/_redirects at the root, but not nested files with those names", () => {
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), "sb-walk-"));
|
|
49
|
+
mkdirSync(join(dir, "sub"));
|
|
50
|
+
writeFileSync(join(dir, "_headers"), "/*\n X-Test: 1\n");
|
|
51
|
+
writeFileSync(join(dir, "_redirects"), "/a /b\n");
|
|
52
|
+
writeFileSync(join(dir, "sub", "_headers"), "not config, just a file");
|
|
53
|
+
const files = walkAssets(dir);
|
|
54
|
+
expect(Object.keys(files).sort()).toEqual(["/sub/_headers"]);
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("parseHeaders: set and unset lines, comments, and blank lines ending a block", () => {
|
|
59
|
+
const rules = parseHeaders(
|
|
60
|
+
[
|
|
61
|
+
"# a comment",
|
|
62
|
+
"/*",
|
|
63
|
+
" X-Frame-Options: DENY",
|
|
64
|
+
" Cache-Control: no-cache",
|
|
65
|
+
"",
|
|
66
|
+
"/assets/*",
|
|
67
|
+
" Cache-Control: public, max-age=31536000",
|
|
68
|
+
" ! X-Frame-Options",
|
|
69
|
+
].join("\n"),
|
|
70
|
+
);
|
|
71
|
+
expect(rules).toEqual([
|
|
72
|
+
{
|
|
73
|
+
pattern: "/*",
|
|
74
|
+
set: [
|
|
75
|
+
["X-Frame-Options", "DENY"],
|
|
76
|
+
["Cache-Control", "no-cache"],
|
|
77
|
+
],
|
|
78
|
+
unset: [],
|
|
79
|
+
},
|
|
80
|
+
{ pattern: "/assets/*", set: [["Cache-Control", "public, max-age=31536000"]], unset: ["X-Frame-Options"] },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("parseHeaders: an indented line with no preceding pattern is ignored", () => {
|
|
85
|
+
expect(parseHeaders(" X-Test: 1\n")).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("matchHeaders: returns rules whose pattern matches, in file order", () => {
|
|
89
|
+
const rules = parseHeaders("/*\n A: 1\n\n/assets/*\n B: 2\n");
|
|
90
|
+
expect(matchHeaders(rules, "/assets/app.js").map((r) => r.pattern)).toEqual(["/*", "/assets/*"]);
|
|
91
|
+
expect(matchHeaders(rules, "/index.html").map((r) => r.pattern)).toEqual(["/*"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("parseRedirects: from/to/status, default status, malformed lines dropped", () => {
|
|
95
|
+
expect(
|
|
96
|
+
parseRedirects(["# comment", "/old /new 301", "/a /b", "/bad-status /c 200", "just-one-token"].join("\n")),
|
|
97
|
+
).toEqual([
|
|
98
|
+
{ from: "/old", to: "/new", status: 301 },
|
|
99
|
+
{ from: "/a", to: "/b", status: 302 },
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("matchRedirect: exact path, no placeholders", () => {
|
|
104
|
+
const rule = { from: "/old", to: "/new", status: 301 };
|
|
105
|
+
expect(matchRedirect(rule, "/old")).toBe("/new");
|
|
106
|
+
expect(matchRedirect(rule, "/old/extra")).toBeNull();
|
|
107
|
+
expect(matchRedirect(rule, "/other")).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("matchRedirect: :name segments substitute into `to`", () => {
|
|
111
|
+
const rule = { from: "/user/:id", to: "/profile/:id", status: 302 };
|
|
112
|
+
expect(matchRedirect(rule, "/user/42")).toBe("/profile/42");
|
|
113
|
+
expect(matchRedirect(rule, "/user")).toBeNull();
|
|
114
|
+
expect(matchRedirect(rule, "/user/42/extra")).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("matchRedirect: a trailing * captures the rest of the path as :splat", () => {
|
|
118
|
+
const rule = { from: "/blog/*", to: "/articles/:splat", status: 301 };
|
|
119
|
+
expect(matchRedirect(rule, "/blog/2024/hello")).toBe("/articles/2024/hello");
|
|
120
|
+
expect(matchRedirect(rule, "/blog/")).toBe("/articles/");
|
|
121
|
+
expect(matchRedirect(rule, "/blog")).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("readAssetRules: empty when neither file exists, parsed when both do", () => {
|
|
125
|
+
const dir = mkdtempSync(join(tmpdir(), "sb-rules-"));
|
|
126
|
+
expect(readAssetRules(dir)).toEqual({ headers: [], redirects: [] });
|
|
127
|
+
writeFileSync(join(dir, "_headers"), "/*\n X: 1\n");
|
|
128
|
+
writeFileSync(join(dir, "_redirects"), "/a /b\n");
|
|
129
|
+
const rules = readAssetRules(dir);
|
|
130
|
+
expect(rules.headers).toHaveLength(1);
|
|
131
|
+
expect(rules.redirects).toHaveLength(1);
|
|
132
|
+
rmSync(dir, { recursive: true, force: true });
|
|
133
|
+
});
|
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,133 @@ 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
|
+
* 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.
|
|
217
|
+
*
|
|
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.
|
|
222
|
+
*/
|
|
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);
|
|
230
|
+
}
|
|
231
|
+
|
|
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;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let to = rule.to;
|
|
246
|
+
for (const [name, value] of Object.entries(params)) to = to.split(`:${name}`).join(value);
|
|
247
|
+
return to;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export type AssetRules = { headers: HeaderRule[]; redirects: RedirectRule[] };
|
|
251
|
+
|
|
252
|
+
/** Read and parse `_headers`/`_redirects` from the assets root, if present. */
|
|
253
|
+
export function readAssetRules(dir: string): AssetRules {
|
|
254
|
+
const headersPath = join(dir, "_headers");
|
|
255
|
+
const redirectsPath = join(dir, "_redirects");
|
|
256
|
+
return {
|
|
257
|
+
headers: existsSync(headersPath) ? parseHeaders(readFileSync(headersPath, "utf8")) : [],
|
|
258
|
+
redirects: existsSync(redirectsPath) ? parseRedirects(readFileSync(redirectsPath, "utf8")) : [],
|
|
259
|
+
};
|
|
260
|
+
}
|