zip-peek 0.2.2 → 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 CHANGED
@@ -1,34 +1,256 @@
1
1
  # zip-peek
2
2
 
3
- `zip-peek` lets browser applications load files from a remote `.zip` archive by only requesting the needed range of bytes without downloading and extracting the full ZIP file.
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
- It registers a service worker that intercepts requests like:
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
- ```text
8
- https://cdn.example.com/packages/session.zip/path/to/asset.png
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
- The service worker fetches only the byte range needed for the requested ZIP entry, inflates the file when required, caches the extracted response, and returns it to the browser as a normal asset response.
109
+ ---
12
110
 
13
- ## When Is This Package Useful?
111
+ ## What `zip-peek` does for our web app
14
112
 
15
- Use this package when your app receives a base path and later appends folders and file names to fetch assets from that path.
113
+ `zip-peek` registers a **service worker** that intercepts requests like:
16
114
 
17
- For example, your app may normally treat the base path as a directory:
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/image.png`;
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
- If the same assets are sometimes delivered as a ZIP file instead:
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 basePath = "https://cdn.example.com/packages/session.zip";
28
- const imageUrl = `${basePath}/slides/slide-1/image.png`;
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
- `zip-peek` lets the app keep using the same URL-building pattern. The app does not need to know whether the base path points to a normal directory or a ZIP file, and the ZIP-specific work stays inside the service worker.
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 only the byte range needed for the requested file.
47
- 6. Inflates deflated files using `fflate`.
48
- 7. Returns the extracted file bytes to the browser request as a normal `Response`.
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, and zip-peek transparently passes matching requests for ZIP URLs outside the list.
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
- - the parsed ZIP manifest
381
- - extracted full assets
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 how the service worker intercepts requests, parses ZIP files, and handles caching, see the [Zip Service Worker Flow documentation](./docs/ZIP_SERVICE_WORKER_FLOW.md).
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;
@@ -36,7 +76,7 @@ function postWorkerMessage(message) {
36
76
  resolve();
37
77
  }
38
78
  else {
39
- reject(new Error(response.error ?? "zip-peek: service worker message failed."));
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
- const normalizedAllowedZipUrls = normalizeAllowedZipUrls(allowedZipUrls);
61
- if (cacheClearingStrategy === "keep-allowed-urls" &&
62
- !normalizedAllowedZipUrls) {
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
- if (!("serviceWorker" in navigator)) {
66
- console.warn("zip-peek: Service Worker API not supported.");
67
- return { reloaded: false, initialized: false };
68
- }
69
- const reloaded = await (0, registerZipServiceWorker_1.ensureZipServiceWorkerRegistered)({
70
- workerUrl,
71
- scopeUrl,
72
- reloadOnFirstInstall,
73
- });
74
- if (reloaded) {
75
- return { reloaded: true, initialized: false };
76
- }
77
- await postWorkerMessage({
78
- type: "ZIP_SW_CONFIG",
79
- config: {
80
- zipAssetCacheName,
81
- assetCacheTtlMs,
82
- logPrefix,
83
- allowedZipUrls: normalizedAllowedZipUrls ?? null,
84
- },
85
- });
86
- if (cacheClearingStrategy === "clear-all") {
87
- await postWorkerMessage({
88
- type: "CLEAR_ALL_ZIP_ASSETS",
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
- else if (cacheClearingStrategy === "keep-allowed-urls" &&
101
- normalizedAllowedZipUrls) {
102
- await postWorkerMessage({
103
- type: "CLEAR_ZIP_ASSETS_EXCEPT_ALLOWED",
104
- allowedZipUrls: normalizedAllowedZipUrls,
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
- if (normalizedAllowedZipUrls) {
108
- await postWorkerMessage({
109
- type: "PRELOAD_ZIP_MANIFESTS",
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 (!('serviceWorker' in navigator)) {
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('Zip service worker registered successfully');
76
+ console.log("Zip service worker registered successfully");
77
77
  }
78
78
  else {
79
- console.log('Zip service worker already registered; skipping register().');
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
  };