@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.
@@ -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
+ };
@@ -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
+ }