@johnhenry/packfile 0.0.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/LICENSE +21 -0
- package/README.md +486 -0
- package/browser.mjs +93 -0
- package/cache.mjs +61 -0
- package/compat.mjs +25 -0
- package/index.mjs +7 -0
- package/lib/blob-preview.mjs +447 -0
- package/lib/compression.browser.mjs +13 -0
- package/lib/compression.mjs +16 -0
- package/lib/create-router.mjs +79 -0
- package/lib/from-archive.mjs +23 -0
- package/lib/from-directory-lazy.mjs +61 -0
- package/lib/from-directory.mjs +79 -0
- package/lib/hash.mjs +14 -0
- package/lib/lazy-file-map.mjs +62 -0
- package/lib/mime.mjs +74 -0
- package/lib/response.mjs +41 -0
- package/lib/safe-symlink.mjs +15 -0
- package/lib/to-archive.mjs +27 -0
- package/lib/web-bundle.mjs +195 -0
- package/package.json +68 -0
- package/packfile.mjs +89 -0
- package/types.d.ts +84 -0
- package/types.ts +65 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFile, readdir, stat, realpath } from "node:fs/promises";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { hashBuffer } from "./hash.mjs";
|
|
4
|
+
import { isWithinRoot } from "./safe-symlink.mjs";
|
|
5
|
+
|
|
6
|
+
// path.relative() returns platform-native separators -- backslash on
|
|
7
|
+
// Windows. FilesMap keys (and everything that looks them up: createRouter(),
|
|
8
|
+
// toArchive()/fromArchive() round-tripping, a consumer's own path.join()
|
|
9
|
+
// calls) all assume the single POSIX-style "/" convention used everywhere
|
|
10
|
+
// else in this codebase (see FORMATS.md), so a Windows-produced FilesMap
|
|
11
|
+
// with un-normalized "docs\index.html"-style keys silently fails every
|
|
12
|
+
// forward-slash lookup. Found via real Windows CI, not by inspection.
|
|
13
|
+
const toPosixPath = (path) => (sep === "/" ? path : path.split(sep).join("/"));
|
|
14
|
+
|
|
15
|
+
export const fromDirectory = async (directoryPath, options = {}) => {
|
|
16
|
+
const {
|
|
17
|
+
ignorePatterns = [],
|
|
18
|
+
maxFileSize = Infinity,
|
|
19
|
+
} = options;
|
|
20
|
+
|
|
21
|
+
const files = new Map();
|
|
22
|
+
const rootRealPath = await realpath(directoryPath);
|
|
23
|
+
|
|
24
|
+
// Tracks the real path of every directory from the root down to the one
|
|
25
|
+
// currently being walked, so a symlink that points back at an ancestor
|
|
26
|
+
// (e.g. `ln -s . loop`, or a mutual A<->B symlink cycle) is detected and
|
|
27
|
+
// skipped instead of recursing until the OS's ELOOP limit crashes the
|
|
28
|
+
// whole fromDirectory() call.
|
|
29
|
+
const walk = async (currentPath, currentRealPath, ancestors) => {
|
|
30
|
+
const entries = await readdir(currentPath, { withFileTypes: true });
|
|
31
|
+
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
const fullPath = join(currentPath, entry.name);
|
|
34
|
+
const relativePath = toPosixPath(relative(directoryPath, fullPath));
|
|
35
|
+
|
|
36
|
+
if (ignorePatterns.some((pattern) => new RegExp(pattern).test(relativePath))) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let childRealPath = join(currentRealPath, entry.name);
|
|
41
|
+
|
|
42
|
+
if (entry.isSymbolicLink()) {
|
|
43
|
+
// A symlink inside the target directory can point anywhere on disk.
|
|
44
|
+
// Resolve its real path and skip it if it escapes the directory
|
|
45
|
+
// being archived — otherwise fromDirectory() would silently read
|
|
46
|
+
// and package up arbitrary files from outside the target tree.
|
|
47
|
+
let realTarget;
|
|
48
|
+
try {
|
|
49
|
+
realTarget = await realpath(fullPath);
|
|
50
|
+
} catch {
|
|
51
|
+
continue; // broken symlink or too many levels of symlinks (ELOOP)
|
|
52
|
+
}
|
|
53
|
+
if (!isWithinRoot(rootRealPath, realTarget)) continue;
|
|
54
|
+
if (ancestors.has(realTarget)) continue; // symlink cycle
|
|
55
|
+
childRealPath = realTarget;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolve the actual type via stat (follows symlinks that passed the
|
|
59
|
+
// containment/cycle checks above).
|
|
60
|
+
const stats = await stat(fullPath);
|
|
61
|
+
|
|
62
|
+
if (stats.isDirectory()) {
|
|
63
|
+
await walk(fullPath, childRealPath, new Set(ancestors).add(childRealPath));
|
|
64
|
+
} else if (stats.isFile()) {
|
|
65
|
+
if (stats.size > maxFileSize) continue;
|
|
66
|
+
const data = await readFile(fullPath);
|
|
67
|
+
const hash = hashBuffer(data);
|
|
68
|
+
files.set(relativePath, {
|
|
69
|
+
data: new Uint8Array(data),
|
|
70
|
+
size: stats.size,
|
|
71
|
+
hash,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
await walk(directoryPath, rootRealPath, new Set([rootRealPath]));
|
|
78
|
+
return files;
|
|
79
|
+
};
|
package/lib/hash.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const hashBuffer = (buffer) => {
|
|
4
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const hashStream = (stream) => {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const hash = createHash("sha256");
|
|
10
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
11
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
12
|
+
stream.on("error", reject);
|
|
13
|
+
});
|
|
14
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { hashBuffer } from "./hash.mjs";
|
|
4
|
+
|
|
5
|
+
export class LazyFileMap {
|
|
6
|
+
#basePath;
|
|
7
|
+
#paths;
|
|
8
|
+
|
|
9
|
+
constructor(basePath, paths) {
|
|
10
|
+
this.#basePath = basePath;
|
|
11
|
+
this.#paths = paths;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
has(key) {
|
|
15
|
+
return this.#paths.has(key);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async get(key) {
|
|
19
|
+
if (!this.#paths.has(key)) return undefined;
|
|
20
|
+
const fullPath = join(this.#basePath, key);
|
|
21
|
+
const [data, stats] = await Promise.all([
|
|
22
|
+
readFile(fullPath),
|
|
23
|
+
stat(fullPath),
|
|
24
|
+
]);
|
|
25
|
+
const hash = hashBuffer(data);
|
|
26
|
+
return {
|
|
27
|
+
data: new Uint8Array(data),
|
|
28
|
+
size: stats.size,
|
|
29
|
+
hash,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
keys() {
|
|
34
|
+
return this.#paths.keys();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
*values() {
|
|
38
|
+
for (const key of this.#paths) {
|
|
39
|
+
yield this.get(key);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
*entries() {
|
|
44
|
+
for (const key of this.#paths) {
|
|
45
|
+
yield [key, this.get(key)];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
forEach(callback, thisArg) {
|
|
50
|
+
for (const key of this.#paths) {
|
|
51
|
+
callback.call(thisArg, this.get(key), key, this);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
[Symbol.iterator]() {
|
|
56
|
+
return this.entries();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get size() {
|
|
60
|
+
return this.#paths.size;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/lib/mime.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const MIME_TYPES = {
|
|
2
|
+
// Text
|
|
3
|
+
html: "text/html",
|
|
4
|
+
htm: "text/html",
|
|
5
|
+
css: "text/css",
|
|
6
|
+
csv: "text/csv",
|
|
7
|
+
txt: "text/plain",
|
|
8
|
+
xml: "text/xml",
|
|
9
|
+
markdown: "text/markdown",
|
|
10
|
+
md: "text/markdown",
|
|
11
|
+
|
|
12
|
+
// JavaScript / JSON
|
|
13
|
+
js: "application/javascript",
|
|
14
|
+
mjs: "application/javascript",
|
|
15
|
+
cjs: "application/javascript",
|
|
16
|
+
json: "application/json",
|
|
17
|
+
jsonld: "application/ld+json",
|
|
18
|
+
map: "application/json",
|
|
19
|
+
|
|
20
|
+
// Images
|
|
21
|
+
png: "image/png",
|
|
22
|
+
jpg: "image/jpeg",
|
|
23
|
+
jpeg: "image/jpeg",
|
|
24
|
+
gif: "image/gif",
|
|
25
|
+
svg: "image/svg+xml",
|
|
26
|
+
webp: "image/webp",
|
|
27
|
+
avif: "image/avif",
|
|
28
|
+
ico: "image/x-icon",
|
|
29
|
+
bmp: "image/bmp",
|
|
30
|
+
tiff: "image/tiff",
|
|
31
|
+
tif: "image/tiff",
|
|
32
|
+
|
|
33
|
+
// Fonts
|
|
34
|
+
woff: "font/woff",
|
|
35
|
+
woff2: "font/woff2",
|
|
36
|
+
ttf: "font/ttf",
|
|
37
|
+
otf: "font/otf",
|
|
38
|
+
eot: "application/vnd.ms-fontobject",
|
|
39
|
+
|
|
40
|
+
// Audio / Video
|
|
41
|
+
mp3: "audio/mpeg",
|
|
42
|
+
ogg: "audio/ogg",
|
|
43
|
+
wav: "audio/wav",
|
|
44
|
+
mp4: "video/mp4",
|
|
45
|
+
webm: "video/webm",
|
|
46
|
+
|
|
47
|
+
// Application
|
|
48
|
+
pdf: "application/pdf",
|
|
49
|
+
zip: "application/zip",
|
|
50
|
+
gz: "application/gzip",
|
|
51
|
+
tar: "application/x-tar",
|
|
52
|
+
wasm: "application/wasm",
|
|
53
|
+
bin: "application/octet-stream",
|
|
54
|
+
|
|
55
|
+
// Web
|
|
56
|
+
manifest: "application/manifest+json",
|
|
57
|
+
webmanifest: "application/manifest+json",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const getContentType = (filePath, customMimeTypes) => {
|
|
61
|
+
const extension = filePath.split(".").pop()?.toLowerCase() ?? "";
|
|
62
|
+
// Plain-object lookups on an attacker/user-influenced key (the file
|
|
63
|
+
// extension) must use an own-property check: MIME_TYPES/customMimeTypes
|
|
64
|
+
// are ordinary object literals, so extensions like "constructor",
|
|
65
|
+
// "__proto__", or "toString" would otherwise resolve to inherited
|
|
66
|
+
// Object.prototype values instead of falling back to the default type.
|
|
67
|
+
if (customMimeTypes && Object.hasOwn(customMimeTypes, extension)) {
|
|
68
|
+
return customMimeTypes[extension];
|
|
69
|
+
}
|
|
70
|
+
if (Object.hasOwn(MIME_TYPES, extension)) {
|
|
71
|
+
return MIME_TYPES[extension];
|
|
72
|
+
}
|
|
73
|
+
return "application/octet-stream";
|
|
74
|
+
};
|
package/lib/response.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getContentType } from "./mime.mjs";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CACHE_CONTROL = "max-age=3600";
|
|
4
|
+
|
|
5
|
+
export const buildFileResponse = (request, filePath, entry, opts = {}) => {
|
|
6
|
+
const {
|
|
7
|
+
cacheControl = DEFAULT_CACHE_CONTROL,
|
|
8
|
+
mimeTypes,
|
|
9
|
+
} = opts;
|
|
10
|
+
|
|
11
|
+
const etag = `"${entry.hash}"`;
|
|
12
|
+
|
|
13
|
+
// 304 Not Modified
|
|
14
|
+
if (request) {
|
|
15
|
+
const ifNoneMatch = typeof request.headers?.get === "function"
|
|
16
|
+
? request.headers.get("if-none-match")
|
|
17
|
+
: request.headers?.["if-none-match"];
|
|
18
|
+
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
19
|
+
return new Response(null, {
|
|
20
|
+
status: 304,
|
|
21
|
+
headers: { ETag: etag },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const contentType = getContentType(filePath, mimeTypes);
|
|
27
|
+
const headers = new Headers({
|
|
28
|
+
"Content-Type": contentType,
|
|
29
|
+
"Content-Length": String(entry.size),
|
|
30
|
+
"Cache-Control": cacheControl,
|
|
31
|
+
ETag: etag,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// HEAD — no body
|
|
35
|
+
const method = request?.method ?? "GET";
|
|
36
|
+
if (method === "HEAD") {
|
|
37
|
+
return new Response(null, { status: 200, headers });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return new Response(entry.data, { status: 200, headers });
|
|
41
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { relative, isAbsolute } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns true if `target` (an absolute, already-resolved path) is equal to
|
|
5
|
+
* or nested inside `root` (an absolute, already-resolved path).
|
|
6
|
+
*
|
|
7
|
+
* Used to keep symlink resolution from escaping the directory being walked:
|
|
8
|
+
* a symlink inside a directory can point anywhere on disk, and following it
|
|
9
|
+
* blindly would let `fromDirectory`/`fromDirectoryLazy` read and package up
|
|
10
|
+
* arbitrary files from outside the target tree.
|
|
11
|
+
*/
|
|
12
|
+
export const isWithinRoot = (root, target) => {
|
|
13
|
+
const rel = relative(root, target);
|
|
14
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
15
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes a `FilesMap` to this package's archive format: gzip(Web
|
|
3
|
+
* Bundle), via `toWebBundle()` (`lib/web-bundle.mjs`). `ARCHIVE_BASE_URL`
|
|
4
|
+
* is purely an internal implementation detail -- `toArchive()`/
|
|
5
|
+
* `fromArchive()` keep the same flat path -> content contract callers
|
|
6
|
+
* already had with the previous CBOR format; nothing about the Web Bundle
|
|
7
|
+
* format's own absolute-URL requirement is exposed here. It uses the
|
|
8
|
+
* IANA/RFC 2606 `.invalid` TLD, guaranteed to never resolve to a real
|
|
9
|
+
* origin, since this URL is never meant to be dereferenced -- only ever
|
|
10
|
+
* built and immediately stripped back off by `fromArchive()`.
|
|
11
|
+
*/
|
|
12
|
+
import { compressObject } from "./compression.mjs";
|
|
13
|
+
import { toWebBundle } from "./web-bundle.mjs";
|
|
14
|
+
|
|
15
|
+
const ARCHIVE_BASE_URL = "https://packfile.invalid/";
|
|
16
|
+
|
|
17
|
+
export const toArchive = async (map, opts = {}) => {
|
|
18
|
+
const { compress = true, compressionLevel } = opts;
|
|
19
|
+
|
|
20
|
+
let buffer = toWebBundle(map, { baseURL: ARCHIVE_BASE_URL });
|
|
21
|
+
|
|
22
|
+
if (compress) {
|
|
23
|
+
buffer = await compressObject(buffer, compressionLevel);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return buffer;
|
|
27
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Web Bundle engine underlying this package's archive format.
|
|
3
|
+
* `lib/to-archive.mjs`/`lib/from-archive.mjs` (the main, stable
|
|
4
|
+
* `toArchive()`/`fromArchive()` at the `.` entrypoint) are thin wrappers
|
|
5
|
+
* around `toWebBundle()`/`fromWebBundle()` below, with a fixed internal
|
|
6
|
+
* `baseURL` and the existing gzip layer -- packfile moved from a bespoke
|
|
7
|
+
* flat-object CBOR format to `application/webbundle` (`.wbn`, the format
|
|
8
|
+
* Chrome's Isolated Web Apps are built on) wholesale, via the real,
|
|
9
|
+
* Google-maintained `wbn` package (build/parse) and, for signing,
|
|
10
|
+
* `wbn-sign` -- not a reimplementation of the format here.
|
|
11
|
+
*
|
|
12
|
+
* This module itself is also reachable directly via the
|
|
13
|
+
* `@johnhenry/packfile/web-bundle` subpath, for callers who want lower-level
|
|
14
|
+
* control `toArchive()`/`fromArchive()` deliberately hide: a real `baseURL`
|
|
15
|
+
* (for producing a bundle with real, resolvable exchange URLs -- IWA's
|
|
16
|
+
* `isolated-app://` origin, or a real `https://` one), custom per-file
|
|
17
|
+
* `headers()`, and `createWebBundleRouter()` for serving a bundle's own
|
|
18
|
+
* baked-in headers verbatim instead of `createRouter()`'s synthesized ones.
|
|
19
|
+
*
|
|
20
|
+
* The two shapes involved: `FilesMap` (`Map<path, {data,size,hash}>`, this
|
|
21
|
+
* package's own, as produced by `fromDirectory()`) has no HTTP semantics at
|
|
22
|
+
* all; a Web Bundle is a set of full HTTP *exchanges* -- absolute URL +
|
|
23
|
+
* status + headers + body per entry. `toWebBundle()` synthesizes the
|
|
24
|
+
* exchange (absolute URL by resolving each relative path against `baseURL`;
|
|
25
|
+
* status always 200; `Content-Type` inferred the same way `createRouter()`'s
|
|
26
|
+
* own responses already are, via `getContentType()`) since `FileEntry`
|
|
27
|
+
* itself carries none of that. `fromWebBundle()` does the reverse and also
|
|
28
|
+
* drops back to `FileEntry`'s own `hash` field by recomputing it from the
|
|
29
|
+
* response body with `hashBuffer()` -- Web Bundles don't carry a content
|
|
30
|
+
* hash of their own, so there's nothing to read instead.
|
|
31
|
+
*/
|
|
32
|
+
import * as wbn from "wbn";
|
|
33
|
+
import { getContentType } from "./mime.mjs";
|
|
34
|
+
import { hashBuffer } from "./hash.mjs";
|
|
35
|
+
|
|
36
|
+
// Deliberately the same coarse, pure-string check `browser.mjs` already used
|
|
37
|
+
// on its own (previously divergent) archive-reading path -- not Node's
|
|
38
|
+
// `path.normalize()`/`isAbsolute()` (what the old CBOR-based from-archive.mjs
|
|
39
|
+
// used), so this same logic works identically for a browser build with no
|
|
40
|
+
// `node:path` available. Any ".." substring is rejected outright rather than
|
|
41
|
+
// only a normalized leading "..", which is stricter than strictly necessary
|
|
42
|
+
// (rejects a harmless "a..b" filename too) but avoids re-implementing real
|
|
43
|
+
// path normalization twice for two platforms.
|
|
44
|
+
const isSafePath = (p) => {
|
|
45
|
+
if (p.startsWith("/") || p.startsWith("\\")) return false;
|
|
46
|
+
if (p.includes("..")) return false;
|
|
47
|
+
if (p.includes("\0")) return false;
|
|
48
|
+
if (p === "" || p === ".") return false;
|
|
49
|
+
return true;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {Map<string, import("../types.js").FileEntry>} map
|
|
54
|
+
* @param {{ baseURL: string, primaryURL?: string, formatVersion?: "b1"|"b2", mimeTypes?: Record<string,string>, headers?: (path: string) => Record<string,string> }} options
|
|
55
|
+
* @returns {Uint8Array}
|
|
56
|
+
*/
|
|
57
|
+
export const toWebBundle = (map, options = {}) => {
|
|
58
|
+
const { baseURL, primaryURL, formatVersion = "b2", mimeTypes, headers } = options;
|
|
59
|
+
if (!baseURL) {
|
|
60
|
+
throw new Error('toWebBundle() requires a `baseURL` (e.g. "https://example.com/") to resolve each relative path into an absolute exchange URL');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const builder = new wbn.BundleBuilder(formatVersion);
|
|
64
|
+
for (const [path, entry] of map) {
|
|
65
|
+
const url = new URL(path, baseURL).toString();
|
|
66
|
+
const responseHeaders = {
|
|
67
|
+
"Content-Type": getContentType(path, mimeTypes),
|
|
68
|
+
...(headers ? headers(path) : {}),
|
|
69
|
+
};
|
|
70
|
+
builder.addExchange(url, 200, responseHeaders, entry.data);
|
|
71
|
+
}
|
|
72
|
+
builder.setPrimaryURL(primaryURL ?? baseURL);
|
|
73
|
+
return builder.createBundle();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {Uint8Array} buffer
|
|
78
|
+
* @param {{ baseURL?: string }} options -- if `baseURL` is passed, each
|
|
79
|
+
* exchange's absolute URL is stripped back down to a `baseURL`-relative
|
|
80
|
+
* path (the inverse of `toWebBundle()`'s own resolution) so the result is
|
|
81
|
+
* directly usable as a `FilesMap` key, e.g. handed straight to
|
|
82
|
+
* `toArchive()`. Without it, the map is keyed by the exchange's full URL
|
|
83
|
+
* instead -- still a real `FilesMap`, just not path-shaped. Whenever
|
|
84
|
+
* `baseURL` is given, a resulting relative key that would escape it (a
|
|
85
|
+
* leading slash, `..`, a NUL byte, or the empty/"." path itself) is
|
|
86
|
+
* silently skipped rather than included -- the same defense-in-depth the
|
|
87
|
+
* old CBOR-based `fromArchive()` applied before handing entries to
|
|
88
|
+
* `decompileDirectory()`'s `writeFile()` calls.
|
|
89
|
+
* @returns {Map<string, import("../types.js").FileEntry>}
|
|
90
|
+
*/
|
|
91
|
+
export const fromWebBundle = (buffer, options = {}) => {
|
|
92
|
+
const { baseURL } = options;
|
|
93
|
+
const bundle = new wbn.Bundle(buffer);
|
|
94
|
+
const files = new Map();
|
|
95
|
+
for (const url of bundle.urls) {
|
|
96
|
+
const relativeKey = baseURL ? stripBaseURL(url, baseURL) : url;
|
|
97
|
+
if (baseURL && !isSafePath(relativeKey)) continue;
|
|
98
|
+
const response = bundle.getResponse(url);
|
|
99
|
+
files.set(relativeKey, {
|
|
100
|
+
data: response.body,
|
|
101
|
+
size: response.body.byteLength,
|
|
102
|
+
hash: hashBuffer(response.body),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return files;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const stripBaseURL = (url, baseURL) => {
|
|
109
|
+
const base = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
|
|
110
|
+
return url.startsWith(base) ? url.slice(base.length) : url;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Serves a parsed `wbn.Bundle` directly -- same `(input, ctx?) =>
|
|
115
|
+
* Promise<Response>` (+ `.fetch`) contract as `createRouter()`, but returns
|
|
116
|
+
* each exchange's OWN baked-in status/headers verbatim instead of
|
|
117
|
+
* resynthesizing them the way `createRouter()` does for a `FileEntry`
|
|
118
|
+
* (Content-Type/Cache-Control/ETag there are packfile's own invention;
|
|
119
|
+
* here, whatever `toWebBundle()`'s `headers()` callback -- or an external
|
|
120
|
+
* bundle -- actually set into the response is what gets served). Prefer
|
|
121
|
+
* this over `createRouter(fromWebBundle(...))` whenever the bundle's own
|
|
122
|
+
* headers matter; use the `fromWebBundle()` + `createRouter()` path when a
|
|
123
|
+
* plain `FilesMap` (e.g. to feed into `toArchive()`) is what's actually
|
|
124
|
+
* wanted.
|
|
125
|
+
*
|
|
126
|
+
* `new wbn.Bundle(buffer)` decodes the ENTIRE bundle up front (verified by
|
|
127
|
+
* reading `wbn`'s own decoder: the constructor eagerly walks every response
|
|
128
|
+
* in the `responses` section) -- unlike `fromDirectoryLazy()`'s
|
|
129
|
+
* `LazyFileMap`, there is no on-demand/streaming read path in `wbn` itself,
|
|
130
|
+
* so "serving" a large bundle still means holding the whole parsed thing in
|
|
131
|
+
* memory. Parse once (e.g. at process startup) and reuse the same `Bundle`
|
|
132
|
+
* instance across requests; re-parsing per request would repeat that full
|
|
133
|
+
* decode for no reason.
|
|
134
|
+
*
|
|
135
|
+
* @param {InstanceType<typeof wbn.Bundle>} bundle
|
|
136
|
+
* @param {{ baseURL: string, alias?: Record<string,string>, tryExtensions?: string[], fallback?: (input: string|Request, ctx?: unknown) => Response|Promise<Response> }} options
|
|
137
|
+
*/
|
|
138
|
+
export const createWebBundleRouter = (bundle, options = {}) => {
|
|
139
|
+
const { baseURL, alias = {}, tryExtensions = [], fallback } = options;
|
|
140
|
+
if (!baseURL) {
|
|
141
|
+
throw new Error("createWebBundleRouter() requires the same `baseURL` used to build the bundle, to resolve an incoming request path back to its absolute exchange URL");
|
|
142
|
+
}
|
|
143
|
+
const knownURLs = new Set(bundle.urls);
|
|
144
|
+
|
|
145
|
+
const notFound = (input, ctx) => {
|
|
146
|
+
if (fallback) return fallback(input, ctx);
|
|
147
|
+
return new Response("Not Found", { status: 404 });
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const resolve = (path) => {
|
|
151
|
+
const direct = new URL(path, baseURL).toString();
|
|
152
|
+
if (knownURLs.has(direct)) return direct;
|
|
153
|
+
for (const ext of tryExtensions) {
|
|
154
|
+
const candidate = new URL(path + ext, baseURL).toString();
|
|
155
|
+
if (knownURLs.has(candidate)) return candidate;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const handler = async (input, ctx) => {
|
|
161
|
+
let method, filePath, request;
|
|
162
|
+
if (typeof input === "string") {
|
|
163
|
+
method = "GET";
|
|
164
|
+
filePath = input;
|
|
165
|
+
request = null;
|
|
166
|
+
} else {
|
|
167
|
+
request = input;
|
|
168
|
+
method = request.method;
|
|
169
|
+
filePath = new URL(request.url).pathname;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
173
|
+
if (fallback) return fallback(input, ctx);
|
|
174
|
+
return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET, HEAD" } });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const [aliasPath, targetPath] of Object.entries(alias)) {
|
|
178
|
+
if (filePath === aliasPath) {
|
|
179
|
+
filePath = targetPath;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const resolved = resolve(filePath.replace(/^\//, ""));
|
|
185
|
+
if (!resolved) return notFound(input, ctx);
|
|
186
|
+
|
|
187
|
+
const response = bundle.getResponse(resolved);
|
|
188
|
+
const headers = new Headers(response.headers);
|
|
189
|
+
if (method === "HEAD") return new Response(null, { status: response.status, headers });
|
|
190
|
+
return new Response(response.body, { status: response.status, headers });
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
handler.fetch = handler;
|
|
194
|
+
return handler;
|
|
195
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@johnhenry/packfile",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.mjs",
|
|
6
|
+
"types": "./types.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"packfile": "packfile.mjs"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public",
|
|
12
|
+
"provenance": true
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/johnhenry/packfile.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/johnhenry/packfile/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://opensource.johnhenry.me/packfile/",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=26.0.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test test.mjs",
|
|
27
|
+
"demo:compile": "node demo/compile.mjs",
|
|
28
|
+
"demo:server": "node demo/server.mjs",
|
|
29
|
+
"demo:browser": "npx serve ."
|
|
30
|
+
},
|
|
31
|
+
"keywords": [],
|
|
32
|
+
"author": "John Henry",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"description": "Content-addressed static-file archiver and in-memory HTTP router",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@johnhenry/andbox": "^0.0.2",
|
|
37
|
+
"wbn": "^0.0.9"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"theres-waldo": "^0.0.0",
|
|
41
|
+
"wbn-sign": "^0.3.1"
|
|
42
|
+
},
|
|
43
|
+
"exports": {
|
|
44
|
+
".": {
|
|
45
|
+
"types": "./types.d.ts",
|
|
46
|
+
"default": "./index.mjs"
|
|
47
|
+
},
|
|
48
|
+
"./browser": "./browser.mjs",
|
|
49
|
+
"./compat": "./compat.mjs",
|
|
50
|
+
"./cache": "./cache.mjs",
|
|
51
|
+
"./hash": "./lib/hash.mjs",
|
|
52
|
+
"./compression": "./lib/compression.mjs",
|
|
53
|
+
"./blob-preview": "./lib/blob-preview.mjs",
|
|
54
|
+
"./web-bundle": "./lib/web-bundle.mjs"
|
|
55
|
+
},
|
|
56
|
+
"files": [
|
|
57
|
+
"index.mjs",
|
|
58
|
+
"browser.mjs",
|
|
59
|
+
"compat.mjs",
|
|
60
|
+
"cache.mjs",
|
|
61
|
+
"packfile.mjs",
|
|
62
|
+
"lib/",
|
|
63
|
+
"types.d.ts",
|
|
64
|
+
"types.ts",
|
|
65
|
+
"README.md",
|
|
66
|
+
"LICENSE"
|
|
67
|
+
]
|
|
68
|
+
}
|
package/packfile.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { constants } from "node:zlib";
|
|
6
|
+
import { fromDirectory } from "./lib/from-directory.mjs";
|
|
7
|
+
import { toArchive } from "./lib/to-archive.mjs";
|
|
8
|
+
import { fromArchive } from "./lib/from-archive.mjs";
|
|
9
|
+
import { createRouter } from "./lib/create-router.mjs";
|
|
10
|
+
import { decompileDirectory } from "./compat.mjs";
|
|
11
|
+
|
|
12
|
+
function log(level, message) {
|
|
13
|
+
const timestamp = new Date().toISOString();
|
|
14
|
+
console[level](`[${timestamp}] ${level.toUpperCase()}: ${message}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const [, , command, ...args] = process.argv;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
switch (command) {
|
|
21
|
+
case "compress": {
|
|
22
|
+
if (args.length < 2 || args.length > 3) {
|
|
23
|
+
throw new Error("Usage: packfile compress <path-to-folder> <path-to-file> [compression-level]");
|
|
24
|
+
}
|
|
25
|
+
let compressionLevel;
|
|
26
|
+
if (args[2] !== undefined) {
|
|
27
|
+
compressionLevel = parseInt(args[2]);
|
|
28
|
+
if (isNaN(compressionLevel) || compressionLevel < 0 || compressionLevel > 9) {
|
|
29
|
+
throw new Error("Compression level must be a number between 0 and 9");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
log("info", `Starting compression of folder ${args[0]}`);
|
|
33
|
+
const map = await fromDirectory(args[0]);
|
|
34
|
+
const buffer = await toArchive(map, { compress: true, compressionLevel });
|
|
35
|
+
await writeFile(args[1], buffer);
|
|
36
|
+
log("info", `Successfully compressed folder ${args[0]} to ${args[1]}`);
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case "decompress": {
|
|
40
|
+
if (args.length !== 2) {
|
|
41
|
+
throw new Error("Usage: packfile decompress <path-to-file> <path-to-folder>");
|
|
42
|
+
}
|
|
43
|
+
log("info", `Starting decompression of file ${args[0]}`);
|
|
44
|
+
const data = await readFile(args[0]);
|
|
45
|
+
await decompileDirectory(data, args[1], true);
|
|
46
|
+
log("info", `Successfully decompressed file ${args[0]} to folder ${args[1]}`);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case "serve": {
|
|
50
|
+
if (args.length < 1 || args.length > 2) {
|
|
51
|
+
throw new Error("Usage: packfile serve <path-to-file> [port]");
|
|
52
|
+
}
|
|
53
|
+
const port = args[1] ? parseInt(args[1]) : 3000;
|
|
54
|
+
log("info", `Starting server for file ${args[0]} on port ${port}`);
|
|
55
|
+
const archiveData = await readFile(args[0]);
|
|
56
|
+
const files = await fromArchive(archiveData, { compressed: true });
|
|
57
|
+
const router = createRouter(files, { alias: { "/": "index.html" } });
|
|
58
|
+
|
|
59
|
+
const server = createServer(async (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
62
|
+
const request = new Request(url, { method: req.method, headers: req.headers });
|
|
63
|
+
const response = await router(request);
|
|
64
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
65
|
+
if (response.body) {
|
|
66
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
67
|
+
res.end(buf);
|
|
68
|
+
} else {
|
|
69
|
+
res.end();
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
log("error", `Failed to serve: ${error.message}`);
|
|
73
|
+
res.writeHead(500);
|
|
74
|
+
res.end("Internal Server Error");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
server.listen(port, () => {
|
|
79
|
+
log("info", `Server running at http://localhost:${port}`);
|
|
80
|
+
});
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
default:
|
|
84
|
+
throw new Error("Unknown command. Available commands: compress, decompress, serve");
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
log("error", error.message);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|