zip-peek 0.2.1 → 0.2.3-alpha.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/README.md +254 -22
- package/dist/index.d.ts +2 -2
- package/dist/index.js +111 -47
- package/dist/registerZipServiceWorker.js +4 -4
- package/dist/types.d.ts +3 -0
- package/dist/zip-utils.d.ts +9 -5
- package/dist/zip-utils.js +11 -9
- package/dist/zipServiceWorker.js +1 -1
- package/dist/zipServiceWorker.js.map +1 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,34 +1,256 @@
|
|
|
1
1
|
# zip-peek
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Load individual files from a remote `.zip` in the browser using HTTP range requests—no full download, no client-side unzip loop. A service worker intercepts URLs like `https://cdn.example.com/pkg.zip/path/to/asset.png` and returns normal responses.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
*We thought browsers could never use a ZIP bucket without killing performance. We were wrong—and zip-peek is how we proved it.*
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The setup every platform team knows
|
|
10
|
+
|
|
11
|
+
At our company, we serve the same packaged content to two clients: a **mobile app** and a **web app**. For a long time, that meant **two identical buckets** on our CDN—same assets, same structure, duplicated storage and ops:
|
|
12
|
+
|
|
13
|
+
| Bucket | Consumer | Why it existed |
|
|
14
|
+
|--------|----------|----------------|
|
|
15
|
+
| **Zipped** | Mobile | Users can download the package once and open it offline later. A single archive is the right shape for “take this home.” |
|
|
16
|
+
| **Unzipped** | Web | Loose files under a normal folder path—`base/slides/slide-1/image.png`—because that’s what browsers and our frontend already expected. |
|
|
17
|
+
|
|
18
|
+
Same content. Two pipelines. Two bills. Two places for drift to creep in (“did we deploy the zip *and* the folder?”).
|
|
19
|
+
|
|
20
|
+
Nobody loved it, but it felt *correct*. Mobile gets archives. Web gets directories. That’s just how the web works, isn’t it?
|
|
21
|
+
|
|
22
|
+
### Before → After
|
|
23
|
+
|
|
24
|
+
| **Before** | **After** |
|
|
25
|
+
|------------|-----------|
|
|
26
|
+
| Mobile → `session.zip` | Mobile → `session.zip` |
|
|
27
|
+
| Web → `session/` (unzipped) | Web → `session.zip` (same bucket) |
|
|
28
|
+
| Duplicate storage & deploys | One source of truth |
|
|
29
|
+
|
|
30
|
+
```mermaid
|
|
31
|
+
flowchart LR
|
|
32
|
+
subgraph before [Before]
|
|
33
|
+
M1[Mobile] --> Z[session.zip]
|
|
34
|
+
W1[Web] --> U[session/]
|
|
35
|
+
end
|
|
36
|
+
subgraph after [After]
|
|
37
|
+
M2[Mobile] --> Z2[session.zip]
|
|
38
|
+
W2[Web] --> Z2
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## What we believed about browsers and ZIP files
|
|
45
|
+
|
|
46
|
+
When my manager said we should **remove the unzipped bucket** and have the **web app use the zipped bucket**, my first reaction was honest: *this is madness.*
|
|
47
|
+
|
|
48
|
+
The mental model was simple and brutal:
|
|
49
|
+
|
|
50
|
+
1. To use a file inside a ZIP in the browser, you’d have to **download the whole archive**.
|
|
51
|
+
2. Then **unzip it in JavaScript**—CPU, memory, time on the main thread or a worker.
|
|
52
|
+
3. For a large session package (slides, images, audio), that would **destroy first load**, **hurt low-end devices**, and **waste bandwidth** on assets the user never opens.
|
|
53
|
+
|
|
54
|
+
Mobile can download `session.zip`, stash it, and read entries locally. The web, we assumed, **cannot** download a ZIP and unzip it without a performance cliff—so we **had** to keep the unzipped bucket for the web app.
|
|
55
|
+
|
|
56
|
+
That belief wasn’t lazy. It matched how most teams ship: ZIP on mobile, folder tree on CDN for web. It’s the industry default.
|
|
57
|
+
|
|
58
|
+
So when the task landed on my desk—*make the web app work from the zip bucket, performantly*—it felt like being asked to disprove physics.
|
|
59
|
+
|
|
60
|
+
**Spoiler alert: it can be done wonderfully.** But not by downloading and unpacking the whole ZIP in the page. By changing *where* the work happens.
|
|
61
|
+
|
|
62
|
+
| ❌ Naive approach | ✓ zip-peek |
|
|
63
|
+
|-------------------|------------|
|
|
64
|
+
| Download entire 200 MB ZIP | Range: ~40 KB for one PNG |
|
|
65
|
+
| Unzip in JS — CPU + memory | One asset at a time |
|
|
66
|
+
| Browser chokes | Fast, normal responses |
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## The insight: browsers don’t need the whole ZIP—only the bytes for one file
|
|
71
|
+
|
|
72
|
+
ZIP isn’t a black box you must swallow whole. At the end of every archive is a **central directory**: an index of filenames, compressed sizes, and byte offsets. With **HTTP range requests**, you can:
|
|
73
|
+
|
|
74
|
+
1. Fetch a small slice from the **end** of the file to find that index.
|
|
75
|
+
2. Fetch only the **central directory** bytes.
|
|
76
|
+
3. For each asset request, fetch **only the byte range** for that one entry.
|
|
77
|
+
4. Inflate that chunk if it’s deflated, return a normal `Response`, and **cache** the result.
|
|
78
|
+
|
|
79
|
+
No 200 MB download for a 40 KB PNG. No app-wide unzip loop. No rewriting every screen to understand “archive semantics.”
|
|
80
|
+
|
|
81
|
+
The web doesn’t need a second bucket. It needs something in front of the zip bucket that speaks **URL paths** on the outside and **byte ranges** on the inside.
|
|
82
|
+
|
|
83
|
+
That something is what I built: **`zip-peek`**.
|
|
84
|
+
|
|
85
|
+
**What happens (in order):**
|
|
86
|
+
|
|
87
|
+
1. App requests `session.zip/slides/hero.png`
|
|
88
|
+
2. Service worker intercepts — not a normal loose file
|
|
89
|
+
3. Range: last 64 KB of ZIP → locate End of Central Directory
|
|
90
|
+
4. Range: fetch central directory only (the index)
|
|
91
|
+
5. Range: fetch bytes for `hero.png` only → inflate if needed
|
|
92
|
+
6. Cache extracted asset; return `200 image/png` to the app
|
|
93
|
+
|
|
94
|
+
```mermaid
|
|
95
|
+
sequenceDiagram
|
|
96
|
+
participant App
|
|
97
|
+
participant SW as zip-peek SW
|
|
98
|
+
participant CDN
|
|
99
|
+
App->>SW: GET session.zip/.../hero.png
|
|
100
|
+
SW->>CDN: Range tail 64KB
|
|
101
|
+
CDN-->>SW: 206
|
|
102
|
+
SW->>CDN: Range central directory
|
|
103
|
+
CDN-->>SW: 206
|
|
104
|
+
SW->>CDN: Range entry bytes only
|
|
105
|
+
CDN-->>SW: 206
|
|
106
|
+
SW-->>App: 200 image/png
|
|
9
107
|
```
|
|
10
108
|
|
|
11
|
-
|
|
109
|
+
---
|
|
12
110
|
|
|
13
|
-
##
|
|
111
|
+
## What `zip-peek` does for our web app
|
|
14
112
|
|
|
15
|
-
|
|
113
|
+
`zip-peek` registers a **service worker** that intercepts requests like:
|
|
16
114
|
|
|
17
|
-
|
|
115
|
+
```text
|
|
116
|
+
https://cdn.example.com/packages/session.zip/slides/slide-1/hero.png
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Our web app **keeps the same URL-building pattern** it used for the unzipped bucket—only the base path changes from a folder to a `.zip` URL:
|
|
18
120
|
|
|
19
121
|
```ts
|
|
122
|
+
// Before (unzipped bucket)
|
|
20
123
|
const basePath = "https://cdn.example.com/packages/session";
|
|
21
|
-
const imageUrl = `${basePath}/slides/slide-1/
|
|
124
|
+
const imageUrl = `${basePath}/slides/slide-1/hero.png`;
|
|
125
|
+
|
|
126
|
+
// After (zip bucket — same pattern)
|
|
127
|
+
const basePath = "https://cdn.example.com/packages/session.zip";
|
|
128
|
+
const imageUrl = `${basePath}/slides/slide-1/hero.png`;
|
|
22
129
|
```
|
|
23
130
|
|
|
24
|
-
|
|
131
|
+
`fetch(imageUrl)` and `<img src={imageUrl}>` still work. The service worker:
|
|
132
|
+
|
|
133
|
+
- Parses the ZIP index from the remote archive (range requests on the tail, then the central directory).
|
|
134
|
+
- Resolves the inner path (`slides/slide-1/hero.png`).
|
|
135
|
+
- Fetches **only** that entry’s bytes.
|
|
136
|
+
- Decompresses when needed (stored or deflated entries).
|
|
137
|
+
- Serves a normal cached response to the browser.
|
|
138
|
+
|
|
139
|
+
From product code’s perspective, the zip bucket **behaves like a virtual folder**. The “impossible” migration was mostly: point `basePath` at `.zip`, initialize the worker, ensure the CDN supports `Accept-Ranges: bytes`.
|
|
140
|
+
|
|
141
|
+
**Simple flow:**
|
|
142
|
+
|
|
143
|
+
1. **Your app** — `fetch()` or `<img src>` — no ZIP code.
|
|
144
|
+
2. **zip-peek (service worker)** — sees a path into a `.zip` file, fetches only that asset from the archive.
|
|
145
|
+
3. **CDN** — sends a small byte range from `session.zip`, not the whole file.
|
|
146
|
+
4. **Back to your app** — a normal image response, as if the PNG lived in a folder.
|
|
147
|
+
|
|
148
|
+
*The URL looks like a folder path. The service worker makes it work.*
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## The real win: shared engines, zero code changes
|
|
153
|
+
|
|
154
|
+
The performance story mattered—but the reason this package was **so** valuable on our stack is structural.
|
|
155
|
+
|
|
156
|
+
We run **engines** that power both the web app and the mobile app. They are the same code paths: load a package, resolve assets, render slides, play audio, and so on. The host passes in a **`packageBasePath`**, and the engine builds URLs the same way every time:
|
|
25
157
|
|
|
26
158
|
```ts
|
|
27
|
-
const
|
|
28
|
-
|
|
159
|
+
const imageUrl = `${packageBasePath}/slides/slide-1/image.png`;
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The engine does not branch on web vs mobile. It **`fetch`es** what looks like an ordinary absolute URL and expects the platform to return bytes.
|
|
163
|
+
|
|
164
|
+
**Mobile and web prepare that base differently**—but the engine never sees the difference in code:
|
|
165
|
+
|
|
166
|
+
| Host | How the host prepares assets | What the engine receives as `packageBasePath` |
|
|
167
|
+
|------|------------------------------|-----------------------------------------------|
|
|
168
|
+
| **Mobile** | Download `session.zip` → uncompress on device → **localhost** server over the extracted folder | `http://127.0.0.1:3847/` |
|
|
169
|
+
| **Web (before)** | Unzipped CDN bucket | `https://cdn.example.com/packages/session` |
|
|
170
|
+
| **Web (after zip-peek)** | Zip on CDN + `initZipPeek()` | `https://cdn.example.com/packages/session.zip` |
|
|
171
|
+
|
|
172
|
+
On mobile, the engine always talked to a **real folder**—just served locally:
|
|
173
|
+
|
|
174
|
+
```text
|
|
175
|
+
http://127.0.0.1:3847/slides/slide-1/image.png
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Mobile never passed the zip URL into the engine. The app shell downloaded the archive, unpacked it, stood up localhost, and handed the engine an unzipped base path.
|
|
179
|
+
|
|
180
|
+
**After `zip-peek`**, web could use the **same zip bucket** mobile downloads from. Web passes:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
https://cdn.example.com/packages/session.zip/slides/slide-1/image.png
|
|
29
184
|
```
|
|
30
185
|
|
|
31
|
-
|
|
186
|
+
Only the **service worker** knows that URL points *into* a remote ZIP.
|
|
187
|
+
|
|
188
|
+
- **Mobile:** unchanged (download zip → unzip → localhost → directory `packageBasePath`).
|
|
189
|
+
- **Web:** inject the zip URL and wire up `initZipPeek()` once.
|
|
190
|
+
- **Engine:** zero changes.
|
|
191
|
+
|
|
192
|
+
```mermaid
|
|
193
|
+
flowchart TB
|
|
194
|
+
subgraph engine["Shared engine — unchanged"]
|
|
195
|
+
U["`${packageBasePath}/asset.png`"]
|
|
196
|
+
F[fetch]
|
|
197
|
+
U --> F
|
|
198
|
+
end
|
|
199
|
+
subgraph mobile["Mobile host"]
|
|
200
|
+
CDN1[(CDN session.zip)]
|
|
201
|
+
DL[Download and unzip]
|
|
202
|
+
LH[Localhost server]
|
|
203
|
+
PM["packageBasePath = http://127.0.0.1:…/"]
|
|
204
|
+
CDN1 --> DL --> LH --> PM --> engine
|
|
205
|
+
end
|
|
206
|
+
subgraph web["Web host"]
|
|
207
|
+
CDN2[(CDN session.zip)]
|
|
208
|
+
P["packageBasePath = …/session.zip"]
|
|
209
|
+
SW[zip-peek service worker]
|
|
210
|
+
CDN2 --> P --> engine
|
|
211
|
+
F -. intercepted .-> SW
|
|
212
|
+
SW --> CDN2
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## From “pure madness” to one source of truth
|
|
219
|
+
|
|
220
|
+
| Before | After |
|
|
221
|
+
|--------|--------|
|
|
222
|
+
| Two buckets, same content | **One zip bucket** for mobile *and* web |
|
|
223
|
+
| Web tied to unzipped CDN layout | Web uses **path-under-zip** URLs |
|
|
224
|
+
| Fear of full-archive download + JS unzip | **Range-based peek** per asset + Cache API |
|
|
225
|
+
| “Browsers can’t do ZIP performantly” | **Browsers can**, with the right network layer |
|
|
226
|
+
|
|
227
|
+
Mobile still downloads the zip, uncompresses it, and serves it on localhost for offline—that workflow unchanged. Web now reads from the **same zip on the CDN**, on demand via zip-peek, without downloading the full archive.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## What had to be true on the infrastructure side
|
|
232
|
+
|
|
233
|
+
- The zip origin must support **HTTP byte ranges** (`Accept-Ranges: bytes`, `206 Partial Content`).
|
|
234
|
+
- The service worker script must be served **same-origin** (with `Service-Worker-Allowed` when scope is wider than the worker path).
|
|
235
|
+
- Package URLs should end with **`.zip`**.
|
|
236
|
+
|
|
237
|
+
We also support **presigned URLs**, **allow-lists** for zip URLs, and **cache TTL** for extracted assets. See the [README](../README.md#documentation) for details.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## A note to the next person who hears “use the zip bucket on web”
|
|
242
|
+
|
|
243
|
+
If your gut says *browsers will choke*, you’re half right: **browsers will choke if you download and unzip the whole archive in the client.**
|
|
244
|
+
|
|
245
|
+
If your gut says *therefore we need a parallel unzipped bucket forever*, that’s the part we overturned.
|
|
246
|
+
|
|
247
|
+
The performant model is **peek, don’t gulp**: index the archive remotely, pull one entry at a time, cache what you’ve already extracted.
|
|
248
|
+
|
|
249
|
+
**It can be done wonderfully.**
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Documentation
|
|
32
254
|
|
|
33
255
|
## What It Does
|
|
34
256
|
|
|
@@ -43,10 +265,9 @@ At runtime, the package:
|
|
|
43
265
|
2. Reloads once after first service worker install when needed, so the page becomes controlled.
|
|
44
266
|
3. Intercepts matching `.zip/...` asset requests within the service worker scope.
|
|
45
267
|
4. Loads and caches the ZIP central directory on first use, or during init when `allowedZipUrls` is provided.
|
|
46
|
-
5. Fetches
|
|
47
|
-
6.
|
|
48
|
-
7.
|
|
49
|
-
8. Caches extracted assets in the browser Cache API.
|
|
268
|
+
5. Fetches the entry byte span (`offset` through `nextOffset − 1`), parses the local file header, and inflates deflated entries using `fflate`.
|
|
269
|
+
6. Returns the extracted bytes as a normal `Response` (or `206` when the client sent a `Range` header).
|
|
270
|
+
7. Caches manifests and extracted assets in the browser Cache API.
|
|
50
271
|
|
|
51
272
|
## Installation
|
|
52
273
|
|
|
@@ -161,7 +382,7 @@ Defaults to `'keep-all'`. Controls which zip-peek cache entries are cleared duri
|
|
|
161
382
|
|
|
162
383
|
`allowedZipUrls`
|
|
163
384
|
|
|
164
|
-
Restricts zip-peek to a known set of ZIP package URLs. When omitted, zip-peek lazily serves any matching `.zip/...` request inside the service worker scope. When provided, the array must contain at least one ZIP URL
|
|
385
|
+
Restricts zip-peek to a known set of ZIP package URLs. When omitted, zip-peek lazily serves any matching `.zip/...` request inside the service worker scope. When provided, the array must contain at least one ZIP URL; requests to ZIP URLs outside the list receive **403** from the service worker.
|
|
165
386
|
|
|
166
387
|
The allow-list also acts as a warmup list: zip-peek loads and caches each allowed ZIP manifest during initialization.
|
|
167
388
|
|
|
@@ -377,10 +598,11 @@ If your app is served from a subpath, adjust both `workerUrl` and `scopeUrl` to
|
|
|
377
598
|
|
|
378
599
|
The service worker stores:
|
|
379
600
|
|
|
380
|
-
-
|
|
381
|
-
- extracted
|
|
601
|
+
- parsed ZIP manifests (in `zip-cache-v1` by default, keyed per ZIP URL)
|
|
602
|
+
- fully extracted and decompressed assets (with `X-ZipSW-Cached-At` TTL metadata)
|
|
603
|
+
- persisted `allowedZipUrls` in a separate config cache (`zip-peek-config-v1`)
|
|
382
604
|
|
|
383
|
-
The default cache bucket is:
|
|
605
|
+
The default asset/manifest cache bucket is:
|
|
384
606
|
|
|
385
607
|
```text
|
|
386
608
|
zip-cache-v1
|
|
@@ -392,7 +614,7 @@ Use `cacheClearingStrategy` to control initialization-time cleanup. `keep-all` l
|
|
|
392
614
|
|
|
393
615
|
## Internal Flow
|
|
394
616
|
|
|
395
|
-
For a detailed explanation of
|
|
617
|
+
For a detailed explanation of the modular service worker (`src/zipServiceWorker.ts` + `src/worker/*`), request interception, manifest fetching, entry extraction, allow-list behavior, and caching, see the [Zip Service Worker Flow documentation](./docs/ZIP_SERVICE_WORKER_FLOW.md).
|
|
396
618
|
|
|
397
619
|
## Limitations
|
|
398
620
|
|
|
@@ -402,4 +624,14 @@ For a detailed explanation of how the service worker intercepts requests, parses
|
|
|
402
624
|
- The service worker can only intercept requests inside its registered scope.
|
|
403
625
|
- Supported ZIP compression methods are stored (`0`) and deflated (`8`).
|
|
404
626
|
- ZIP64 is not currently supported.
|
|
627
|
+
- Ambiguous suffix path matches (e.g. two manifest keys ending in `/icon.png`) return 404.
|
|
628
|
+
- Multi-range client requests are not supported (416).
|
|
405
629
|
- The package is browser-only and depends on Service Worker, Cache API, and HTTP range request support.
|
|
630
|
+
|
|
631
|
+
---
|
|
632
|
+
|
|
633
|
+
## Further reading
|
|
634
|
+
|
|
635
|
+
- [Medium-style article (HTML)](./docs/MEDIUM_POST.html) — animated diagrams
|
|
636
|
+
- [Article (Markdown)](./docs/MEDIUM_POST.md) — same story as Markdown
|
|
637
|
+
- [Zip service worker flow](./docs/ZIP_SERVICE_WORKER_FLOW.md)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { InitZipPeekOptions, InitZipPeekResult } from "./types";
|
|
2
2
|
import { isZipPackage } from "./zip-utils";
|
|
3
|
-
export type { InitZipPeekOptions, InitZipPeekResult, ZipCacheClearingStrategy, ZipWorkerConfig, } from "./types";
|
|
3
|
+
export type { InitZipPeekOptions, InitZipPeekResult, ZipCacheClearingStrategy, ZipPeekErrorHandler, ZipWorkerConfig, } from "./types";
|
|
4
4
|
/**
|
|
5
5
|
* Registers the zip-peek service worker and configures lazy ZIP asset loading.
|
|
6
6
|
*
|
|
@@ -11,5 +11,5 @@ export type { InitZipPeekOptions, InitZipPeekResult, ZipCacheClearingStrategy, Z
|
|
|
11
11
|
* `cacheClearingStrategy` to keep existing cache entries, keep only allowed
|
|
12
12
|
* ZIP URLs, or clear all zip-peek cache entries before use.
|
|
13
13
|
*/
|
|
14
|
-
export declare function initZipPeek({ workerUrl, scopeUrl, reloadOnFirstInstall, cacheClearingStrategy, allowedZipUrls, zipAssetCacheName, assetCacheTtlMs, logPrefix, }: InitZipPeekOptions): Promise<InitZipPeekResult>;
|
|
14
|
+
export declare function initZipPeek({ workerUrl, scopeUrl, reloadOnFirstInstall, cacheClearingStrategy, allowedZipUrls, zipAssetCacheName, assetCacheTtlMs, logPrefix, onError, }: InitZipPeekOptions): Promise<InitZipPeekResult>;
|
|
15
15
|
export { isZipPackage };
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,46 @@ exports.initZipPeek = initZipPeek;
|
|
|
5
5
|
const registerZipServiceWorker_1 = require("./registerZipServiceWorker");
|
|
6
6
|
const zip_utils_1 = require("./zip-utils");
|
|
7
7
|
Object.defineProperty(exports, "isZipPackage", { enumerable: true, get: function () { return zip_utils_1.isZipPackage; } });
|
|
8
|
+
let currentOnError;
|
|
9
|
+
let clientErrorListenerInstalled = false;
|
|
10
|
+
function toError(error) {
|
|
11
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
12
|
+
}
|
|
13
|
+
function toZipPeekError(error, info) {
|
|
14
|
+
const err = toError(error);
|
|
15
|
+
if (info) {
|
|
16
|
+
err.zipPeekInfo = info;
|
|
17
|
+
}
|
|
18
|
+
return err;
|
|
19
|
+
}
|
|
20
|
+
function reportZipPeekError(onError, error, info) {
|
|
21
|
+
const err = toError(error);
|
|
22
|
+
onError?.(err, info ?? err.zipPeekInfo);
|
|
23
|
+
}
|
|
24
|
+
function errorFromWorkerResponse(response) {
|
|
25
|
+
const error = new Error(response.error ?? "zip-peek: service worker message failed.");
|
|
26
|
+
if (response.errorStack) {
|
|
27
|
+
error.stack = response.errorStack;
|
|
28
|
+
}
|
|
29
|
+
return error;
|
|
30
|
+
}
|
|
31
|
+
function ensureClientErrorListener() {
|
|
32
|
+
if (clientErrorListenerInstalled || !("serviceWorker" in navigator)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
clientErrorListenerInstalled = true;
|
|
36
|
+
navigator.serviceWorker.addEventListener("message", (event) => {
|
|
37
|
+
const data = event.data;
|
|
38
|
+
if (!data || data.type !== "ZIP_SW_ERROR" || !currentOnError) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const error = new Error(data.error?.message ?? "zip-peek: service worker error");
|
|
42
|
+
if (data.error?.stack) {
|
|
43
|
+
error.stack = data.error.stack;
|
|
44
|
+
}
|
|
45
|
+
currentOnError(error, data.info);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
8
48
|
function normalizeAllowedZipUrls(allowedZipUrls) {
|
|
9
49
|
if (allowedZipUrls === undefined) {
|
|
10
50
|
return undefined;
|
|
@@ -18,7 +58,7 @@ function normalizeAllowedZipUrls(allowedZipUrls) {
|
|
|
18
58
|
if (!(0, zip_utils_1.isZipPackage)(resolvedZipUrl)) {
|
|
19
59
|
throw new Error(`zip-peek: allowedZipUrls contains a non-ZIP URL: ${zipUrl}`);
|
|
20
60
|
}
|
|
21
|
-
normalized.add((0, zip_utils_1.
|
|
61
|
+
normalized.add((0, zip_utils_1.normalizeZipUrl)(resolvedZipUrl));
|
|
22
62
|
}
|
|
23
63
|
return Array.from(normalized);
|
|
24
64
|
}
|
|
@@ -36,7 +76,7 @@ function postWorkerMessage(message) {
|
|
|
36
76
|
resolve();
|
|
37
77
|
}
|
|
38
78
|
else {
|
|
39
|
-
reject(
|
|
79
|
+
reject(errorFromWorkerResponse(response));
|
|
40
80
|
}
|
|
41
81
|
};
|
|
42
82
|
channel.port1.onmessageerror = () => {
|
|
@@ -46,6 +86,14 @@ function postWorkerMessage(message) {
|
|
|
46
86
|
controller.postMessage(message, [channel.port2]);
|
|
47
87
|
});
|
|
48
88
|
}
|
|
89
|
+
async function postWorkerMessageOrThrow(message, info) {
|
|
90
|
+
try {
|
|
91
|
+
await postWorkerMessage(message);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw toZipPeekError(error, info);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
49
97
|
/**
|
|
50
98
|
* Registers the zip-peek service worker and configures lazy ZIP asset loading.
|
|
51
99
|
*
|
|
@@ -56,38 +104,36 @@ function postWorkerMessage(message) {
|
|
|
56
104
|
* `cacheClearingStrategy` to keep existing cache entries, keep only allowed
|
|
57
105
|
* ZIP URLs, or clear all zip-peek cache entries before use.
|
|
58
106
|
*/
|
|
59
|
-
async function initZipPeek({ workerUrl, scopeUrl, reloadOnFirstInstall = true, cacheClearingStrategy = "keep-all", allowedZipUrls, zipAssetCacheName, assetCacheTtlMs, logPrefix, }) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
throw new Error('zip-peek: cacheClearingStrategy "keep-allowed-urls" requires a non-empty allowedZipUrls allow-list.');
|
|
107
|
+
async function initZipPeek({ workerUrl, scopeUrl, reloadOnFirstInstall = true, cacheClearingStrategy = "keep-all", allowedZipUrls, zipAssetCacheName, assetCacheTtlMs, logPrefix, onError, }) {
|
|
108
|
+
if (onError) {
|
|
109
|
+
currentOnError = onError;
|
|
110
|
+
ensureClientErrorListener();
|
|
64
111
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
await postWorkerMessage({
|
|
112
|
+
try {
|
|
113
|
+
const normalizedAllowedZipUrls = normalizeAllowedZipUrls(allowedZipUrls);
|
|
114
|
+
if (cacheClearingStrategy === "keep-allowed-urls" &&
|
|
115
|
+
!normalizedAllowedZipUrls) {
|
|
116
|
+
throw new Error('zip-peek: cacheClearingStrategy "keep-allowed-urls" requires a non-empty allowedZipUrls allow-list.');
|
|
117
|
+
}
|
|
118
|
+
if (!("serviceWorker" in navigator)) {
|
|
119
|
+
console.warn("zip-peek: Service Worker API not supported.");
|
|
120
|
+
return { reloaded: false, initialized: false };
|
|
121
|
+
}
|
|
122
|
+
let reloaded;
|
|
123
|
+
try {
|
|
124
|
+
reloaded = await (0, registerZipServiceWorker_1.ensureZipServiceWorkerRegistered)({
|
|
125
|
+
workerUrl,
|
|
126
|
+
scopeUrl,
|
|
127
|
+
reloadOnFirstInstall,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
throw toZipPeekError(error, "zip-peek: service worker registration failed");
|
|
132
|
+
}
|
|
133
|
+
if (reloaded) {
|
|
134
|
+
return { reloaded: true, initialized: false };
|
|
135
|
+
}
|
|
136
|
+
await postWorkerMessageOrThrow({
|
|
91
137
|
type: "ZIP_SW_CONFIG",
|
|
92
138
|
config: {
|
|
93
139
|
zipAssetCacheName,
|
|
@@ -95,20 +141,38 @@ async function initZipPeek({ workerUrl, scopeUrl, reloadOnFirstInstall = true, c
|
|
|
95
141
|
logPrefix,
|
|
96
142
|
allowedZipUrls: normalizedAllowedZipUrls ?? null,
|
|
97
143
|
},
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
144
|
+
}, "zip-peek: failed to configure service worker");
|
|
145
|
+
if (cacheClearingStrategy === "clear-all") {
|
|
146
|
+
await postWorkerMessageOrThrow({
|
|
147
|
+
type: "CLEAR_ALL_ZIP_ASSETS",
|
|
148
|
+
}, "zip-peek: failed to clear zip assets");
|
|
149
|
+
await postWorkerMessageOrThrow({
|
|
150
|
+
type: "ZIP_SW_CONFIG",
|
|
151
|
+
config: {
|
|
152
|
+
zipAssetCacheName,
|
|
153
|
+
assetCacheTtlMs,
|
|
154
|
+
logPrefix,
|
|
155
|
+
allowedZipUrls: normalizedAllowedZipUrls ?? null,
|
|
156
|
+
},
|
|
157
|
+
}, "zip-peek: failed to reconfigure service worker after cache clear");
|
|
158
|
+
}
|
|
159
|
+
else if (cacheClearingStrategy === "keep-allowed-urls" &&
|
|
160
|
+
normalizedAllowedZipUrls) {
|
|
161
|
+
await postWorkerMessageOrThrow({
|
|
162
|
+
type: "CLEAR_ZIP_ASSETS_EXCEPT_ALLOWED",
|
|
163
|
+
allowedZipUrls: normalizedAllowedZipUrls,
|
|
164
|
+
}, "zip-peek: failed to clear disallowed zip assets");
|
|
165
|
+
}
|
|
166
|
+
if (normalizedAllowedZipUrls) {
|
|
167
|
+
await postWorkerMessageOrThrow({
|
|
168
|
+
type: "PRELOAD_ZIP_MANIFESTS",
|
|
169
|
+
allowedZipUrls: normalizedAllowedZipUrls,
|
|
170
|
+
}, "zip-peek: failed to preload zip manifests");
|
|
171
|
+
}
|
|
172
|
+
return { reloaded: false, initialized: true };
|
|
106
173
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
allowedZipUrls: normalizedAllowedZipUrls,
|
|
111
|
-
});
|
|
174
|
+
catch (error) {
|
|
175
|
+
reportZipPeekError(onError, error);
|
|
176
|
+
throw error;
|
|
112
177
|
}
|
|
113
|
-
return { reloaded: false, initialized: true };
|
|
114
178
|
}
|
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ensureZipServiceWorkerRegistered = ensureZipServiceWorkerRegistered;
|
|
4
4
|
function normalizeScopePath(href) {
|
|
5
5
|
let p = new URL(href).pathname;
|
|
6
|
-
if (p.length > 1 && p.endsWith(
|
|
6
|
+
if (p.length > 1 && p.endsWith("/")) {
|
|
7
7
|
p = p.slice(0, -1);
|
|
8
8
|
}
|
|
9
9
|
return p;
|
|
@@ -62,7 +62,7 @@ async function unregisterMismatchedScopeZipServiceWorkerIfNeeded(expectedWorkerU
|
|
|
62
62
|
* @returns true if the page is reloading and callers should abort init.
|
|
63
63
|
*/
|
|
64
64
|
async function ensureZipServiceWorkerRegistered({ workerUrl, scopeUrl, reloadOnFirstInstall = true, }) {
|
|
65
|
-
if (!(
|
|
65
|
+
if (!("serviceWorker" in navigator)) {
|
|
66
66
|
return false;
|
|
67
67
|
}
|
|
68
68
|
await unregisterMismatchedScopeZipServiceWorkerIfNeeded(workerUrl, scopeUrl);
|
|
@@ -73,10 +73,10 @@ async function ensureZipServiceWorkerRegistered({ workerUrl, scopeUrl, reloadOnF
|
|
|
73
73
|
zipServiceWorkerScriptMatches(existing.installing?.scriptURL, workerUrl));
|
|
74
74
|
if (!alreadyRegistered) {
|
|
75
75
|
await navigator.serviceWorker.register(workerUrl, { scope: scopeUrl });
|
|
76
|
-
console.log(
|
|
76
|
+
console.log("Zip service worker registered successfully");
|
|
77
77
|
}
|
|
78
78
|
else {
|
|
79
|
-
console.log(
|
|
79
|
+
console.log("Zip service worker already registered; skipping register().");
|
|
80
80
|
}
|
|
81
81
|
await navigator.serviceWorker.ready;
|
|
82
82
|
if (reloadOnFirstInstall && !navigator.serviceWorker.controller) {
|
package/dist/types.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export type ZipWorkerConfig = {
|
|
|
4
4
|
logPrefix?: string;
|
|
5
5
|
};
|
|
6
6
|
export type ZipCacheClearingStrategy = 'keep-all' | 'keep-allowed-urls' | 'clear-all';
|
|
7
|
+
export type ZipPeekErrorHandler = (error: Error, info?: string) => void;
|
|
7
8
|
export type InitZipPeekOptions = {
|
|
8
9
|
workerUrl: string;
|
|
9
10
|
scopeUrl: string;
|
|
@@ -13,6 +14,7 @@ export type InitZipPeekOptions = {
|
|
|
13
14
|
zipAssetCacheName?: string;
|
|
14
15
|
assetCacheTtlMs?: number;
|
|
15
16
|
logPrefix?: string;
|
|
17
|
+
onError?: ZipPeekErrorHandler;
|
|
16
18
|
};
|
|
17
19
|
export type InitZipPeekResult = {
|
|
18
20
|
reloaded: boolean;
|
|
@@ -21,4 +23,5 @@ export type InitZipPeekResult = {
|
|
|
21
23
|
export type WorkerMessageResponse = {
|
|
22
24
|
ok: boolean;
|
|
23
25
|
error?: string;
|
|
26
|
+
errorStack?: string;
|
|
24
27
|
};
|
package/dist/zip-utils.d.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/** True when the URL points at a `.zip` package (path ends with `.zip`). */
|
|
2
2
|
export declare function isZipPackage(url: string): boolean;
|
|
3
|
+
export type NormalizeZipUrlOptions = {
|
|
4
|
+
/** Remove cache-buster `t` query param (default: true). */
|
|
5
|
+
stripCacheBuster?: boolean;
|
|
6
|
+
/** Drop fragment `#...` (default: true). */
|
|
7
|
+
stripHash?: boolean;
|
|
8
|
+
};
|
|
3
9
|
/**
|
|
4
|
-
*
|
|
10
|
+
* Normalize a ZIP package URL for cache keys, allow-lists, and manifests.
|
|
5
11
|
* Avoids `URL` / `URLSearchParams` so SigV4 values (e.g. `%2F` in credentials)
|
|
6
12
|
* are not re-encoded.
|
|
7
13
|
*/
|
|
8
|
-
export declare function
|
|
9
|
-
/** Same key the service worker uses for manifests / purge (presigned query kept). */
|
|
10
|
-
export declare const normalizeZipUrlForSw: typeof stripTParam;
|
|
14
|
+
export declare function normalizeZipUrl(urlString: string, options?: NormalizeZipUrlOptions): string;
|
|
11
15
|
/** Align zip entry names with URL paths under `*.zip/…`. */
|
|
12
16
|
export declare function normalizeZipEntryPath(name: string): string;
|
|
13
17
|
export type ParsedZipAssetRequest = {
|
|
@@ -24,5 +28,5 @@ export type ParsedZipAssetRequest = {
|
|
|
24
28
|
* Returns `null` for bare `.zip` GETs (e.g. manifest bootstrap).
|
|
25
29
|
*/
|
|
26
30
|
export declare function parseZipAssetRequest(urlString: string): ParsedZipAssetRequest | null;
|
|
27
|
-
/** Cache key for a zip asset (decoded manifest key + zip URL
|
|
31
|
+
/** Cache key for a zip asset (decoded manifest key + normalized zip URL). */
|
|
28
32
|
export declare function canonicalZipAssetRequestUrl(zipUrl: string, manifestKey: string): string;
|