@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 John Henry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,486 @@
1
+ # Packfile
2
+
3
+ [![npm version](https://img.shields.io/npm/v/%40johnhenry%2Fpackfile.svg)](https://www.npmjs.com/package/@johnhenry/packfile)
4
+ [![CI](https://github.com/johnhenry/packfile/actions/workflows/ci.yml/badge.svg)](https://github.com/johnhenry/packfile/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/%40johnhenry%2Fpackfile.svg)](LICENSE)
6
+
7
+ Full documentation: [opensource.johnhenry.me/packfile](https://opensource.johnhenry.me/packfile/)
8
+
9
+ > Previously developed as `lemem`, never published under that name. Now
10
+ > `@johnhenry/packfile`, starting at `0.0.0`.
11
+
12
+ Static file compiler and server. Compresses directories into archives --
13
+ gzip(`application/webbundle`), the format Chrome's Isolated Web Apps are
14
+ built on, via the real [`wbn`](https://github.com/WICG/webpackage/tree/main/js/bundle)
15
+ package -- and serves them as HTTP responses via the `(Request) => Response`
16
+ handler pattern.
17
+
18
+ ## Contents
19
+
20
+ - [Installation](#installation)
21
+ - [Usage](#usage)
22
+ - [Compress a folder](#compress-a-folder)
23
+ - [Decompress a file](#decompress-a-file)
24
+ - [Serve a compiled file](#serve-a-compiled-file)
25
+ - [Examples](#examples)
26
+ - [Node.js API](#nodejs-api)
27
+ - [`fromDirectory(path, options?)`](#fromdirectorypath-options)
28
+ - [`fromDirectoryLazy(path, options?)`](#fromdirectorylazypath-options)
29
+ - [`toArchive(map, options?)`](#toarchivemap-options)
30
+ - [`fromArchive(buffer, options?)`](#fromarchivebuffer-options)
31
+ - [`createRouter(files, options?)`](#createrouterfiles-options)
32
+ - [`hashBuffer(buffer)` / `hashStream(stream)`](#hashbufferbuffer--hashstreamstream)
33
+ - [`compileDirectory(path, options?)` / `decompileDirectory(data, outputPath)`](#compiledirectorypath-options--decompiledirectorydata-outputpath)
34
+ - [HTTP Caching Middleware](#http-caching-middleware)
35
+ - [Browser Usage](#browser-usage)
36
+ - [Blob Preview (host packaged content in a browser tab/iframe, no server)](#blob-preview-host-packaged-content-in-a-browser-tabiframe-no-server)
37
+ - [What this does and does not solve](#what-this-does-and-does-not-solve)
38
+ - [Web Bundle / Isolated Web App primitives (`./web-bundle`)](#web-bundle--isolated-web-app-primitives-web-bundle)
39
+ - [Direct Imports](#direct-imports)
40
+ - [Exports](#exports)
41
+ - [Security model](#security-model)
42
+ - [Family](#family)
43
+ - [Internal formats](#internal-formats)
44
+ - [License](#license)
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ npm install @johnhenry/packfile
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ You can use the `packfile` CLI with the following commands:
55
+
56
+ ### Compress a folder
57
+
58
+ ```bash
59
+ npx packfile compress <path-to-folder> <path-to-file>
60
+ ```
61
+
62
+ This command compresses the contents of `<path-to-folder>` and saves the compressed data to `<path-to-file>`.
63
+
64
+ ### Decompress a file
65
+
66
+ ```bash
67
+ npx packfile decompress <path-to-file> <path-to-folder>
68
+ ```
69
+
70
+ This command decompresses the contents of `<path-to-file>` and saves the decompressed files to `<path-to-folder>`.
71
+
72
+ ### Serve a compiled file
73
+
74
+ ```bash
75
+ npx packfile serve <path-to-file> [port]
76
+ ```
77
+
78
+ This command serves the compiled file at `<path-to-file>` on the specified `[port]` (default is 3000).
79
+
80
+ ## Examples
81
+
82
+ 1. Compress a folder:
83
+ ```bash
84
+ npx packfile compress ./static ./compiled.wbn
85
+ ```
86
+
87
+ 2. Decompress a file:
88
+ ```bash
89
+ npx packfile decompress ./compiled.wbn ./decompressed
90
+ ```
91
+
92
+ 3. Serve a compiled file:
93
+ ```bash
94
+ npx packfile serve ./compiled.wbn 8080
95
+ ```
96
+
97
+ ## Node.js API
98
+
99
+ ```js
100
+ import {
101
+ fromDirectory, fromDirectoryLazy, fromArchive, toArchive,
102
+ createRouter, hashBuffer, hashStream,
103
+ compileDirectory, decompileDirectory
104
+ } from '@johnhenry/packfile';
105
+ ```
106
+
107
+ ### `fromDirectory(path, options?)`
108
+
109
+ Reads all files from a directory into a `Map<string, { data, size, hash }>`.
110
+
111
+ ```js
112
+ const files = await fromDirectory('./static');
113
+ ```
114
+
115
+ ### `fromDirectoryLazy(path, options?)`
116
+
117
+ Returns a `LazyFileMap` that reads files on demand (useful for development).
118
+
119
+ ```js
120
+ const files = await fromDirectoryLazy('./static');
121
+ const entry = await files.get('index.html'); // reads from disk
122
+ ```
123
+
124
+ ### `toArchive(map, options?)`
125
+
126
+ Serializes a file Map to a compressed archive buffer -- `gzip(application/webbundle)`, via the real `wbn` package.
127
+
128
+ ```js
129
+ const buffer = await toArchive(files);
130
+ ```
131
+
132
+ ### `fromArchive(buffer, options?)`
133
+
134
+ Deserializes an archive back to a file Map. Validates paths — entries with path traversal (`../`) or absolute paths are rejected.
135
+
136
+ ```js
137
+ const files = await fromArchive(buffer);
138
+ ```
139
+
140
+ ### `createRouter(files, options?)`
141
+
142
+ Returns a `(Request) => Promise<Response>` handler that serves files. Auto-sets `Content-Type`, `Cache-Control`, and `ETag` headers. Works with both `Map` and `LazyFileMap`.
143
+
144
+ **Options:**
145
+
146
+ - `alias` — Object mapping paths (e.g. `{ "/": "index.html" }`)
147
+ - `tryExtensions` — Array of extensions to try (e.g. `[".html"]`)
148
+ - `fallback` — Fallback handler for unmatched routes
149
+
150
+ ```js
151
+ const handler = createRouter(files, {
152
+ alias: { "/": "index.html" },
153
+ tryExtensions: [".html"],
154
+ });
155
+
156
+ const response = await handler(new Request("http://localhost/"));
157
+ ```
158
+
159
+ ### `hashBuffer(buffer)` / `hashStream(stream)`
160
+
161
+ SHA-256 hashing utilities. Returns a hex digest string.
162
+
163
+ ```js
164
+ const hash = hashBuffer(myBuffer); // sync
165
+ const hash2 = await hashStream(myStream); // async
166
+ ```
167
+
168
+ ### `compileDirectory(path, options?)` / `decompileDirectory(data, outputPath)`
169
+
170
+ Convenience wrappers: `fromDirectory → toArchive` and `fromArchive → writeFile`.
171
+
172
+ ```js
173
+ const compiled = await compileDirectory('./static');
174
+ await decompileDirectory(compiled, './output');
175
+ ```
176
+
177
+ ## HTTP Caching Middleware
178
+
179
+ ```js
180
+ import { withCache } from '@johnhenry/packfile/cache';
181
+ ```
182
+
183
+ Wraps any `(Request) => Response` handler with automatic ETag generation and `304 Not Modified` negotiation:
184
+
185
+ ```js
186
+ const cachedHandler = withCache(myHandler, {
187
+ cacheControl: 'public, max-age=3600', // default
188
+ weak: false, // use strong ETags (default)
189
+ });
190
+ ```
191
+
192
+ Uses SHA-256 hashing (same as file ETags) for consistent cache keys.
193
+
194
+ ## Browser Usage
195
+
196
+ ```js
197
+ import { fromArchive, toArchive, createRouter } from '@johnhenry/packfile/browser';
198
+ ```
199
+
200
+ The browser bundle provides `fromArchive`, `toArchive`, and `createRouter`, using the `wbn` package directly (no Node APIs required) and Web Crypto for hashing -- same wire format as the Node entrypoint, so an archive built by one is directly readable by the other.
201
+
202
+ ```js
203
+ const archive = await fetch('/app.wbn').then(r => r.arrayBuffer());
204
+ const files = await fromArchive(archive);
205
+ const router = createRouter(files);
206
+ ```
207
+
208
+ ## Blob Preview (host packaged content in a browser tab/iframe, no server)
209
+
210
+ ```js
211
+ import { createBlobPreview } from '@johnhenry/packfile/blob-preview';
212
+ ```
213
+
214
+ `createRouter()` needs something to call it — a real server, a Service
215
+ Worker, or (in `@johnhenry/packfile/browser`) at least a `fetch`-shaped handler wired up
216
+ to something. `createBlobPreview()` is for the case where you don't want
217
+ any of that: you have a `FilesMap` (from `fromDirectory()`/`fromArchive()`,
218
+ same input `createRouter()` takes) and you just want to point an
219
+ `<iframe>` at it and have it render, entirely client-side.
220
+
221
+ It mints one `blob:` URL per file and rewrites HTML (`href`, `src`,
222
+ `srcset`, `poster`, `formaction`) and CSS (`url(...)`, `@import`)
223
+ references so they resolve to the right file's blob URL instead of
224
+ 404ing. JS module resolution (`import`/`import()` between `.js`/`.mjs`
225
+ files) is delegated to the sibling `@johnhenry/andbox` package's
226
+ `createVirtualModuleRegistry()` rather than reimplemented here.
227
+
228
+ ```js
229
+ import { fromDirectory } from '@johnhenry/packfile';
230
+ import { createBlobPreview } from '@johnhenry/packfile/blob-preview';
231
+
232
+ const files = await fromDirectory('./static');
233
+ const preview = await createBlobPreview(files, { rootPath: 'index.html' });
234
+
235
+ document.querySelector('iframe').src = preview.entryUrl;
236
+
237
+ // later, once the iframe/tab is gone:
238
+ preview.dispose(); // revokes every blob: URL it minted
239
+ ```
240
+
241
+ **API:**
242
+
243
+ ```ts
244
+ function createBlobPreview(
245
+ files: FilesMap,
246
+ options?: {
247
+ rootPath?: string; // default: "index.html"
248
+ strict?: boolean; // throw instead of warning on an unresolved reference
249
+ onUnresolvedReference?: (info: {
250
+ reason: "missing" | "cycle";
251
+ targetPath: string;
252
+ fromPath: string;
253
+ }) => void; // called instead of the default console.warn
254
+ }
255
+ ): Promise<{
256
+ entryUrl: string; // blob: URL for rootPath
257
+ resolve(path: string): string | null;
258
+ dispose(): void; // revokes every blob: URL this call minted
259
+ registry: VirtualModuleRegistry; // the underlying andbox registry, for advanced JS-specifier resolution
260
+ }>
261
+ ```
262
+
263
+ ### What this does and does not solve
264
+
265
+ This is the lighter-weight of two designs considered for hosting
266
+ packfile-packaged content client-side. It is **good enough for trusted, your
267
+ own content** — not a general solution for arbitrary/untrusted content,
268
+ and it does not attempt to solve everything a real HTTP origin gives you
269
+ for free:
270
+
271
+ - **Solved**: relative HTML/CSS references between packaged files
272
+ (including root-relative `/path` references, resolved against the
273
+ `FilesMap`'s own root — there's no real server, but the root is
274
+ perfectly knowable at rewrite time, so this is handled), one blob URL
275
+ per path shared consistently across HTML/CSS/JS wiring, and graceful
276
+ degradation (left unrewritten + reported via `onUnresolvedReference`,
277
+ never a stale/broken blob) for the one case that's structurally
278
+ unsolvable with immutable blob content: a genuine reference **cycle**
279
+ (two pages linking to each other, or a page linking to itself) — one
280
+ edge in the cycle can't know the other's final blob URL before its own
281
+ content is frozen, so it's left as the original path rather than
282
+ pointing at something wrong.
283
+ - **Not solved, and not attempted**: absolute-path references
284
+ *constructed at runtime* (e.g. `fetch('/api/data')` inside a script —
285
+ JS source is never rewritten by this module), `pushState`-based
286
+ client-side routing (there is no real origin for the router to reason
287
+ about), and Service-Worker registration from within the served content
288
+ (a `blob:` document has no meaningful scope to register one against).
289
+ These are fundamental limitations of `blob:` URLs themselves, not
290
+ implementation gaps — a real Service-Worker-based hosting mode
291
+ ("Approach A" in the design this was compared against) is deferred and
292
+ tracked as [`andbox#14`](https://github.com/johnhenry/andbox/issues/14)
293
+ for anyone who needs real isolation or full HTTP-shaped semantics.
294
+ - Inline `<script>`/`<style>` block *contents* are left untouched (only
295
+ attribute references and standalone `.css`/`.js` files are rewritten).
296
+ - A `<script src="...">` pointing at a `.js` file loads that file's
297
+ **original, unmodified** source — nested relative `import`s inside it
298
+ are not rewritten, because `blob:` URLs can't be used as a relative-
299
+ resolution base at all (confirmed directly: `new URL("./x.js", blobUrl)`
300
+ throws `Invalid URL`), so a raw multi-file ESM graph loaded this way
301
+ won't resolve its own imports in a real browser. `registry.resolveSpecifier()`
302
+ is exposed for callers who want to do their own resolution; otherwise,
303
+ pre-bundle multi-file JS into one file before packaging with packfile.
304
+
305
+ ## Web Bundle / Isolated Web App primitives (`./web-bundle`)
306
+
307
+ `toArchive()`/`fromArchive()` at the main `.` entrypoint (and `createRouter()`)
308
+ already use `application/webbundle` under the hood -- see "Node.js API"
309
+ above. This subpath is for callers who want the lower-level control those
310
+ two deliberately hide: a real, resolvable `baseURL` (rather than the fixed
311
+ internal one `toArchive`/`fromArchive` use), custom per-file `headers()`,
312
+ signing via `wbn-sign` for actual Isolated Web App deployment, and a router
313
+ that serves a bundle's own real headers verbatim.
314
+
315
+ ```js
316
+ import { toWebBundle, fromWebBundle } from '@johnhenry/packfile/web-bundle';
317
+ import { fromDirectory } from '@johnhenry/packfile';
318
+
319
+ const files = await fromDirectory('./static');
320
+ const bundle = toWebBundle(files, { baseURL: 'https://example.com/' });
321
+ // bundle is a Uint8Array, directly loadable/parseable by `wbn`'s own
322
+ // Bundle class, or `<script type=webbundle>` in a supporting browser.
323
+
324
+ const recovered = fromWebBundle(bundle, { baseURL: 'https://example.com/' });
325
+ // back to a FilesMap, e.g. to hand to a different consumer.
326
+ ```
327
+
328
+ **Serving a bundle**: `createRouter(fromWebBundle(bundle, { baseURL }))`
329
+ already works today, no new code needed -- `fromWebBundle()`'s return value
330
+ is a real `FilesMap`. But that path only keeps `data`/`size`/`hash`, so
331
+ `createRouter()` resynthesizes Content-Type/Cache-Control/ETag from
332
+ scratch rather than serving whatever headers were actually baked into the
333
+ bundle. `createWebBundleRouter(bundle, options)` serves a parsed
334
+ `wbn.Bundle` directly instead -- same `(input, ctx?) => Promise<Response>`
335
+ router contract (`alias`, `tryExtensions`, `fallback`, a real `Request` or a
336
+ bare path string, `.fetch`), but every response's actual status/headers are
337
+ served verbatim:
338
+
339
+ ```js
340
+ import * as wbn from 'wbn';
341
+ import { toWebBundle, createWebBundleRouter } from '@johnhenry/packfile/web-bundle';
342
+
343
+ const bytes = toWebBundle(files, {
344
+ baseURL: 'https://example.com/',
345
+ headers: () => ({ 'Cache-Control': 'max-age=600, immutable' }),
346
+ });
347
+ const bundle = new wbn.Bundle(bytes); // parse once, reuse across requests --
348
+ // wbn decodes the WHOLE bundle eagerly
349
+ // in the constructor, there's no lazy/
350
+ // streaming read path like
351
+ // fromDirectoryLazy()'s LazyFileMap.
352
+ const router = createWebBundleRouter(bundle, { baseURL: 'https://example.com/' });
353
+ const response = await router('index.html'); // Cache-Control is the real, baked-in header
354
+ ```
355
+
356
+ **Why this exists**: packfile was originally built with `wbn` in mind, then
357
+ moved to a bespoke gzip+CBOR format when `wbn`/Web Bundles looked
358
+ effectively abandoned. IWA gave the format new, active life, and the
359
+ archive format was migrated wholesale onto it -- `lib/to-archive.mjs`/
360
+ `lib/from-archive.mjs` are now thin wrappers around `toWebBundle()`/
361
+ `fromWebBundle()` below, with a fixed internal `baseURL`. See FORMATS.md
362
+ §2 for the full migration writeup (what changed, what it fixed as a side
363
+ effect, what's still platform-specific between Node and the browser build).
364
+
365
+ **What this subpath adds beyond `toArchive`/`fromArchive`**: a Web Bundle
366
+ models full HTTP *exchanges* (absolute URL + status + headers + body), not
367
+ just a flat path -> bytes map -- `FileEntry` carries none of that, so
368
+ `toWebBundle()` synthesizes it (`Content-Type` inferred by extension, same
369
+ as `createRouter()`'s own responses; status always `200`) unless a real
370
+ `baseURL`/`headers()` is supplied, which `toArchive()` doesn't expose at
371
+ all. `fromWebBundle()` recomputes `hash` via `hashBuffer()` on the way
372
+ back, since Web Bundles don't carry a content hash of their own.
373
+
374
+ **Verified against real interop, not just internal round-tripping**:
375
+ `test.mjs`'s "toWebBundle / fromWebBundle" section cross-checks
376
+ `toWebBundle()`'s output against `wbn`'s own `Bundle` parser directly, and
377
+ signs a real bundle with `wbn-sign`'s `SignedWebBundle` using a real
378
+ generated Ed25519 key pair -- the actual packages Chrome/IWA tooling
379
+ itself uses.
380
+
381
+ **Still open, for actual IWA deployment (not for the archive-format use
382
+ this subpath already covers)**: how to choose/compute a `baseURL` when
383
+ targeting `isolated-app://<web-bundle-id>/` specifically -- that origin is
384
+ derived from the signing key itself via `wbn-sign`'s `WebBundleId`, not
385
+ chosen freely, so a real IWA build needs to sign first and set `baseURL`
386
+ from the result, a different order than the examples above.
387
+
388
+ ## Direct Imports
389
+
390
+ ```js
391
+ import { hashBuffer, hashStream } from '@johnhenry/packfile/hash';
392
+ import { compressObject, deCompressObject } from '@johnhenry/packfile/compression';
393
+ ```
394
+
395
+ ## Exports
396
+
397
+ | Export | File | Description |
398
+ |--------|------|-------------|
399
+ | `.` | `index.mjs` | Full API: `fromDirectory`, `fromArchive`, `toArchive`, `createRouter`, `hashBuffer`, etc. |
400
+ | `./browser` | `browser.mjs` | Browser-compatible: `fromArchive`, `toArchive`, `createRouter` |
401
+ | `./cache` | `cache.mjs` | `withCache` — HTTP caching middleware |
402
+ | `./hash` | `lib/hash.mjs` | `hashBuffer`, `hashStream` |
403
+ | `./compression` | `lib/compression.mjs` | `compressObject`, `deCompressObject` |
404
+ | `./compat` | `compat.mjs` | `compileDirectory`, `decompileDirectory` |
405
+ | `./blob-preview` | `lib/blob-preview.mjs` | `createBlobPreview` — host a `FilesMap` client-side via `blob:` URLs, no server |
406
+ | `./web-bundle` | `lib/web-bundle.mjs` | Lower-level Web Bundle primitives: `toWebBundle`, `fromWebBundle`, `createWebBundleRouter` — the engine `toArchive`/`fromArchive` are built on, with a real `baseURL`/headers/IWA-signing exposed |
407
+
408
+ ## Security model
409
+
410
+ **What packfile guarantees:**
411
+
412
+ - **Decoding an archive never writes outside the archive root.** `fromArchive()`
413
+ (and `fromWebBundle()`, which it's built on) rejects any entry whose path
414
+ would escape the archive's own base -- a leading slash or backslash, a
415
+ `..` segment anywhere in the path, a NUL byte, or an empty/`.` path -- via
416
+ `isSafePath()` in `lib/web-bundle.mjs`. A rejected entry is silently
417
+ skipped rather than written, so an archive built to escape its extraction
418
+ root (a "zip-slip"-shaped attack) cannot use `fromArchive()`/
419
+ `decompileDirectory()` to do it.
420
+ - **`createRouter()` never serves outside the file map it was given.** It
421
+ only ever resolves against the in-memory `FilesMap` (or `LazyFileMap`)
422
+ built by `fromDirectory()`/`fromArchive()`/your own code -- there is no
423
+ filesystem access at request time, so a crafted request path cannot read
424
+ anything not already present in that map.
425
+
426
+ **What is still yours:**
427
+
428
+ - **`createBlobPreview()` is for trusted, your-own content only** -- not a
429
+ general solution for arbitrary/untrusted content (see "What this does and
430
+ does not solve" above). It rewrites references so packaged content
431
+ renders correctly in an `<iframe>`, but does not sandbox or sanitize that
432
+ content -- treat a `FilesMap` built from an untrusted source the same as
433
+ you would any other untrusted HTML/CSS/JS you're about to render.
434
+ - **`createRouter()`'s `alias`/`tryExtensions`/`fallback` are caller-supplied
435
+ and unvalidated.** A `fallback` handler that itself reads from the
436
+ filesystem or the network based on the unmatched path reintroduces
437
+ exactly the kind of path-controlled access this package's own
438
+ `isSafePath()` guards against on decode -- that's the fallback's
439
+ responsibility, not this package's.
440
+ - **`toWebBundle()`/`createWebBundleRouter()`'s `baseURL` is not authenticated.**
441
+ Signing a bundle with `wbn-sign` (for real Isolated Web App deployment)
442
+ is a separate step this package exposes but does not perform for you --
443
+ an unsigned bundle carries no origin guarantee at all.
444
+
445
+ ## Family
446
+
447
+ packfile isn't just a standalone compiler/server -- it's the designed
448
+ consumer of one sibling package's archive output, and a drop-in handler for
449
+ another's router.
450
+
451
+ - **[`@johnhenry/fileable`](https://github.com/johnhenry/fileable)** --
452
+ fileable's `<Dir encode="wbn">` renders a subtree to a
453
+ `gzip(application/webbundle)` archive, via the same `wbn` package packfile
454
+ itself depends on directly (a real dependency on `wbn`, **not** on
455
+ `@johnhenry/packfile` -- fileable produces byte-for-byte the same archive
456
+ format without needing this package as an intermediate, and isn't even
457
+ published to npm). The resulting `.wbn` file is directly readable by this
458
+ package's own `fromArchive()` (back into a flat path -> content map) and
459
+ servable via `createRouter()`/`createWebBundleRouter()` -- no unpacking to
460
+ disk needed.
461
+ - **[`@johnhenry/servable`](https://github.com/johnhenry/servable)** --
462
+ `createRouter()` already returns a `(Request | path, ctx?) => Response`
463
+ handler (it even aliases itself as `.fetch`), the exact shape servable's
464
+ `Route`'s `handler` prop accepts -- mounting a packfile-served directory
465
+ inside a servable app is just passing a function, no new integration
466
+ surface needed. See `examples/08-mount-packfile` in the servable repo for
467
+ a real, running mount (`Route path="/*"` inside a `Group prefix="/mem"`,
468
+ forwarding the wildcard-captured path straight to the router).
469
+
470
+ ## Internal formats
471
+
472
+ packfile's data passes through several distinct shapes on its way from a
473
+ directory on disk to an HTTP response: the in-memory `FileEntry`/`FilesMap`
474
+ table (eager `Map` or lazy `LazyFileMap`), the gzip(Web Bundle) archive byte
475
+ format, the SHA-256 hash used for both content identity and ETags, two
476
+ separate (Node/browser) gzip implementations, and the `Response` bridge the
477
+ router builds from all of the above. [`FORMATS.md`](./FORMATS.md) documents
478
+ each one precisely -- exact fields/encoding, which function produces it,
479
+ which consumes it, and why it's separate from the others where that's
480
+ evident from the code -- along with a few real inconsistencies found while
481
+ writing it up (e.g. `browser.mjs`'s archive logic having quietly diverged
482
+ from the Node implementation it duplicates).
483
+
484
+ ## License
485
+
486
+ This project is licensed under the MIT License.
package/browser.mjs ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Browser build of `toArchive`/`fromArchive`. Same gzip(Web Bundle) wire
3
+ * format as the Node entrypoint (`lib/to-archive.mjs`/`lib/from-archive.mjs`
4
+ * -- same `wbn` package, same fixed `ARCHIVE_BASE_URL`), so an archive built
5
+ * in one environment is directly readable in the other. Previously this
6
+ * file hand-rolled its own, independently-maintained CBOR-object
7
+ * encode/decode against a globally-loaded `cbor` library (or injected
8
+ * `encode`/`decode` options) -- a real, documented divergence from the Node
9
+ * implementation it duplicated (see FORMATS.md).
10
+ *
11
+ * `wbn` itself needs no Node APIs (confirmed: its own 0.0.8 release notes
12
+ * removed its last one) and is imported directly here, same as the Node
13
+ * side -- but the actual glue code below is a deliberate, separate copy of
14
+ * `lib/web-bundle.mjs`'s logic, not a shared import of it: that module also
15
+ * imports `lib/hash.mjs`, which imports `node:crypto` -- a bare Node
16
+ * built-in specifier with no browser resolution at all, and pulling it in
17
+ * transitively (ESM has no way to import only *part* of a module) would
18
+ * break this file in an actual browser. Hashing uses Web Crypto's
19
+ * `crypto.subtle.digest` (async-only) here instead of Node's synchronous
20
+ * `crypto.createHash`; compression uses `CompressionStream`/
21
+ * `DecompressionStream` here instead of Node's `zlib` -- both were already
22
+ * platform-specific before this change and stay that way.
23
+ */
24
+ import { deCompressObject, compressObject } from "./lib/compression.browser.mjs";
25
+ import { getContentType } from "./lib/mime.mjs";
26
+ import * as wbn from "wbn";
27
+
28
+ export { createRouter } from "./lib/create-router.mjs";
29
+
30
+ const ARCHIVE_BASE_URL = "https://packfile.invalid/";
31
+
32
+ // Same coarse, pure-string check as lib/web-bundle.mjs's own (duplicated,
33
+ // not imported -- that module also pulls in lib/hash.mjs's Node-only
34
+ // `node:crypto` import, which has no browser resolution at all).
35
+ const isSafePath = (p) => {
36
+ if (p.startsWith("/") || p.startsWith("\\")) return false;
37
+ if (p.includes("..")) return false;
38
+ if (p.includes("\0")) return false;
39
+ if (p === "" || p === ".") return false;
40
+ return true;
41
+ };
42
+
43
+ const stripBaseURL = (url, baseURL) => {
44
+ const base = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
45
+ return url.startsWith(base) ? url.slice(base.length) : url;
46
+ };
47
+
48
+ const sha256Hex = async (data) => {
49
+ const digest = await crypto.subtle.digest("SHA-256", data);
50
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
51
+ };
52
+
53
+ export const fromArchive = async (buffer, opts = {}) => {
54
+ const { compressed = true } = opts;
55
+
56
+ if (compressed) {
57
+ buffer = await deCompressObject(buffer);
58
+ }
59
+
60
+ const bundle = new wbn.Bundle(buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer);
61
+ const files = new Map();
62
+
63
+ for (const url of bundle.urls) {
64
+ const relativeKey = stripBaseURL(url, ARCHIVE_BASE_URL);
65
+ if (!isSafePath(relativeKey)) continue;
66
+
67
+ const response = bundle.getResponse(url);
68
+ files.set(relativeKey, {
69
+ data: response.body,
70
+ size: response.body.byteLength,
71
+ hash: await sha256Hex(response.body),
72
+ });
73
+ }
74
+
75
+ return files;
76
+ };
77
+
78
+ export const toArchive = async (map, opts = {}) => {
79
+ const { compressed = true } = opts;
80
+
81
+ const builder = new wbn.BundleBuilder();
82
+ for (const [path, entry] of map) {
83
+ const url = new URL(path, ARCHIVE_BASE_URL).toString();
84
+ builder.addExchange(url, 200, { "Content-Type": getContentType(path) }, entry.data);
85
+ }
86
+ builder.setPrimaryURL(ARCHIVE_BASE_URL);
87
+
88
+ let buffer = builder.createBundle();
89
+ if (compressed) {
90
+ buffer = await compressObject(buffer);
91
+ }
92
+ return buffer;
93
+ };
package/cache.mjs ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * HTTP caching middleware for leserve handlers.
3
+ * Leverages packfile's SHA-256 hashing for automatic ETag generation.
4
+ *
5
+ * Usage:
6
+ * import { withCache } from "@johnhenry/packfile/cache";
7
+ *
8
+ * const handler = withCache(myHandler);
9
+ * // Responses now include ETag, Cache-Control, and 304 negotiation.
10
+ */
11
+ import { hashBuffer } from "./lib/hash.mjs";
12
+
13
+ /**
14
+ * Wrap a handler with automatic ETag and Cache-Control headers.
15
+ * Handles If-None-Match negotiation (returns 304 when matched).
16
+ *
17
+ * @param {Function} handler - (Request) => Response
18
+ * @param {Object} [options]
19
+ * @param {string} [options.cacheControl="public, max-age=3600"] - Cache-Control header value
20
+ * @param {boolean} [options.weak=false] - Use weak ETags (W/"...")
21
+ * @returns {Function} Wrapped handler
22
+ */
23
+ export const withCache = (handler, options = {}) => {
24
+ const { cacheControl = "public, max-age=3600", weak = false } = options;
25
+
26
+ return async (request, ctx) => {
27
+ const response = await handler(request, ctx);
28
+
29
+ // Only cache successful GET/HEAD responses
30
+ if (request.method !== "GET" && request.method !== "HEAD") return response;
31
+ if (response.status !== 200) return response;
32
+
33
+ // Skip if handler already set an ETag
34
+ if (response.headers.has("etag")) return response;
35
+
36
+ const body = await response.arrayBuffer();
37
+ const hash = hashBuffer(Buffer.from(body));
38
+ const etag = weak ? `W/"${hash}"` : `"${hash}"`;
39
+
40
+ // 304 negotiation
41
+ const ifNoneMatch = request.headers.get("if-none-match");
42
+ if (ifNoneMatch && ifNoneMatch === etag) {
43
+ return new Response(null, {
44
+ status: 304,
45
+ headers: { etag, "cache-control": cacheControl },
46
+ });
47
+ }
48
+
49
+ const headers = new Headers(response.headers);
50
+ headers.set("etag", etag);
51
+ if (!headers.has("cache-control")) {
52
+ headers.set("cache-control", cacheControl);
53
+ }
54
+
55
+ return new Response(body, {
56
+ status: response.status,
57
+ statusText: response.statusText,
58
+ headers,
59
+ });
60
+ };
61
+ };
package/compat.mjs ADDED
@@ -0,0 +1,25 @@
1
+ import { fromDirectory } from "./lib/from-directory.mjs";
2
+ import { toArchive } from "./lib/to-archive.mjs";
3
+ import { fromArchive } from "./lib/from-archive.mjs";
4
+ import { writeFile, mkdir } from "node:fs/promises";
5
+ import { join, dirname } from "node:path";
6
+
7
+ export const compileDirectory = async (directoryPath, options = {}) => {
8
+ const {
9
+ compress = true,
10
+ compressionLevel,
11
+ ignorePatterns = [],
12
+ maxFileSize,
13
+ } = options;
14
+ const map = await fromDirectory(directoryPath, { ignorePatterns, maxFileSize });
15
+ return toArchive(map, { compress, compressionLevel });
16
+ };
17
+
18
+ export const decompileDirectory = async (compiledData, outputPath, compressed = true) => {
19
+ const map = await fromArchive(compiledData, { compressed });
20
+ for (const [relativePath, entry] of map) {
21
+ const fullPath = join(outputPath, relativePath);
22
+ await mkdir(dirname(fullPath), { recursive: true });
23
+ await writeFile(fullPath, entry.data);
24
+ }
25
+ };