@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
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
|
+
[](https://www.npmjs.com/package/@johnhenry/packfile)
|
|
4
|
+
[](https://github.com/johnhenry/packfile/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|
+
};
|