@sproutboat/assets 0.2.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 +7 -0
- package/README.md +4 -0
- package/package.json +11 -0
- package/src/assets.ts +121 -0
- package/src/index.ts +5 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @sproutboat/assets
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c45098e: Adopt broker, JSON utilities, assets, wrapper, source validation, both transports and the prelude from sproutboat-cli (moved verbatim). First publishable versions; consumed via `file:` links until the npm publish flow lands.
|
package/README.md
ADDED
package/package.json
ADDED
package/src/assets.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-asset manifest. `sproutboat build` copies the project's `assets`
|
|
3
|
+
* directory next to the artifact and writes `assets.json` (this manifest); the
|
|
4
|
+
* edge serves matching files directly (assets-first, like Cloudflare), and the
|
|
5
|
+
* broker's `assets.get` op backs `env.<ASSETS>.fetch(request)` for the paths
|
|
6
|
+
* the sprout chooses to serve itself.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join, posix } from "node:path";
|
|
11
|
+
|
|
12
|
+
export type AssetEntry = { hash: string; size: number; type: string };
|
|
13
|
+
/** `/absolute/posix/path` -> entry. Serialised into `assets.json`. */
|
|
14
|
+
export type AssetFiles = { [path: string]: AssetEntry };
|
|
15
|
+
export type AssetManifest = {
|
|
16
|
+
/** Fallback for requests that match no file, applied by the broker. */
|
|
17
|
+
notFound: "none" | "single-page-application" | "404-page";
|
|
18
|
+
/** `true` = every path to the sprout first; string[] = selective (leading `!` negates). */
|
|
19
|
+
runSproutFirst: boolean | string[];
|
|
20
|
+
files: AssetFiles;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const TYPES = new Map<string, string>([
|
|
24
|
+
["html", "text/html; charset=utf-8"],
|
|
25
|
+
["css", "text/css; charset=utf-8"],
|
|
26
|
+
["js", "text/javascript; charset=utf-8"],
|
|
27
|
+
["mjs", "text/javascript; charset=utf-8"],
|
|
28
|
+
["json", "application/json; charset=utf-8"],
|
|
29
|
+
["map", "application/json; charset=utf-8"],
|
|
30
|
+
["txt", "text/plain; charset=utf-8"],
|
|
31
|
+
["xml", "application/xml; charset=utf-8"],
|
|
32
|
+
["svg", "image/svg+xml"],
|
|
33
|
+
["png", "image/png"],
|
|
34
|
+
["jpg", "image/jpeg"],
|
|
35
|
+
["jpeg", "image/jpeg"],
|
|
36
|
+
["gif", "image/gif"],
|
|
37
|
+
["webp", "image/webp"],
|
|
38
|
+
["avif", "image/avif"],
|
|
39
|
+
["ico", "image/x-icon"],
|
|
40
|
+
["woff2", "font/woff2"],
|
|
41
|
+
["woff", "font/woff"],
|
|
42
|
+
["ttf", "font/ttf"],
|
|
43
|
+
["wasm", "application/wasm"],
|
|
44
|
+
["webmanifest", "application/manifest+json"],
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export function contentType(name: string): string {
|
|
48
|
+
const dot = name.lastIndexOf(".");
|
|
49
|
+
return (dot >= 0 ? TYPES.get(name.slice(dot + 1).toLowerCase()) : undefined) ?? "application/octet-stream";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a request path to a manifest key the way a static host does:
|
|
54
|
+
* - an exact hit wins;
|
|
55
|
+
* - a directory path (`/docs/`) tries `/docs/index.html`;
|
|
56
|
+
* - an extensionless path (`/docs`) tries `/docs.html`, then `/docs/index.html`.
|
|
57
|
+
* Returns the matched key, or `null`. This only picks which file to serve — no
|
|
58
|
+
* canonical redirects, and the caller still owns not-found handling. Mirrors
|
|
59
|
+
* Cloudflare's `html_handling: "auto-trailing-slash"` minus the 3xx responses.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveAssetKey(path: string, has: (key: string) => boolean): string | null {
|
|
62
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
63
|
+
if (path.endsWith("/")) {
|
|
64
|
+
const index = `${path}index.html`;
|
|
65
|
+
return has(index) ? index : null;
|
|
66
|
+
}
|
|
67
|
+
if (has(path)) return path;
|
|
68
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
69
|
+
if (!base.includes(".")) {
|
|
70
|
+
if (has(`${path}.html`)) return `${path}.html`;
|
|
71
|
+
if (has(`${path}/index.html`)) return `${path}/index.html`;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Walk `dir` recursively, returning `{ "/path": {hash,size,type} }`. */
|
|
77
|
+
export function walkAssets(dir: string) {
|
|
78
|
+
const out: AssetFiles = {};
|
|
79
|
+
const recurse = (abs: string, rel: string): void => {
|
|
80
|
+
for (const ent of readdirSync(abs, { withFileTypes: true })) {
|
|
81
|
+
if (ent.name.startsWith(".")) continue;
|
|
82
|
+
const childAbs = join(abs, ent.name);
|
|
83
|
+
const childRel = posix.join(rel, ent.name);
|
|
84
|
+
if (ent.isDirectory()) {
|
|
85
|
+
recurse(childAbs, childRel);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!ent.isFile()) continue;
|
|
89
|
+
const body = readFileSync(childAbs);
|
|
90
|
+
out[`/${childRel}`] = {
|
|
91
|
+
hash: createHash("sha256").update(body).digest("hex"),
|
|
92
|
+
size: body.byteLength,
|
|
93
|
+
type: contentType(ent.name),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
recurse(dir, "");
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Does `pathname` hit the sprout before assets, given a `runSproutFirst` spec? */
|
|
102
|
+
export function isSproutFirst(spec: boolean | string[], pathname: string): boolean {
|
|
103
|
+
if (!Array.isArray(spec)) return spec;
|
|
104
|
+
let matched = false;
|
|
105
|
+
for (const raw of spec) {
|
|
106
|
+
const negate = raw.startsWith("!");
|
|
107
|
+
const pattern = negate ? raw.slice(1) : raw;
|
|
108
|
+
if (globMatch(pattern, pathname)) matched = !negate;
|
|
109
|
+
}
|
|
110
|
+
return matched;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `*` matches within a segment, `**` across segments. Anchored both ends. */
|
|
114
|
+
function globMatch(pattern: string, path: string): boolean {
|
|
115
|
+
const rx = pattern
|
|
116
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
117
|
+
.replace(/\*\*/g, " ")
|
|
118
|
+
.replace(/\*/g, "[^/]*")
|
|
119
|
+
.replace(/ /g, ".*");
|
|
120
|
+
return new RegExp(`^${rx}$`).test(path);
|
|
121
|
+
}
|