zip-peek 0.5.0 → 0.6.1
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 +21 -261
- package/dist/index.d.ts +13 -3
- package/dist/index.js +133 -2
- package/dist/types.d.ts +12 -0
- package/dist/zip-utils.d.ts +6 -0
- package/dist/zip-utils.js +56 -0
- package/dist/zipServiceWorker.js +1 -1
- package/dist/zipServiceWorker.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,257 +1,34 @@
|
|
|
1
1
|
# zip-peek
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
It registers a service worker that intercepts requests like:
|
|
6
6
|
|
|
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
|
|
7
|
+
```text
|
|
8
|
+
https://cdn.example.com/packages/session.zip/path/to/asset.png
|
|
107
9
|
```
|
|
108
10
|
|
|
109
|
-
|
|
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.
|
|
110
12
|
|
|
111
|
-
##
|
|
13
|
+
## When Is This Package Useful?
|
|
112
14
|
|
|
113
|
-
|
|
15
|
+
Use this package when your app receives a base path and later appends folders and file names to fetch assets from that path.
|
|
114
16
|
|
|
115
|
-
|
|
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:
|
|
17
|
+
For example, your app may normally treat the base path as a directory:
|
|
120
18
|
|
|
121
19
|
```ts
|
|
122
|
-
// Before (unzipped bucket)
|
|
123
20
|
const basePath = "https://cdn.example.com/packages/session";
|
|
124
|
-
const imageUrl = `${basePath}/slides/slide-1/
|
|
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`;
|
|
21
|
+
const imageUrl = `${basePath}/slides/slide-1/image.png`;
|
|
129
22
|
```
|
|
130
23
|
|
|
131
|
-
|
|
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`, and configure **CORS** (S3 `ExposeHeaders` for `Content-Range` / `Accept-Ranges`, plus CloudFront `GET`/`HEAD`/`OPTIONS` with CORS preflight and **CORS-S3Origin** when applicable).
|
|
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:
|
|
24
|
+
If the same assets are sometimes delivered as a ZIP file instead:
|
|
157
25
|
|
|
158
26
|
```ts
|
|
159
|
-
const
|
|
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
|
|
184
|
-
```
|
|
185
|
-
|
|
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
|
|
27
|
+
const basePath = "https://cdn.example.com/packages/session.zip";
|
|
28
|
+
const imageUrl = `${basePath}/slides/slide-1/image.png`;
|
|
214
29
|
```
|
|
215
30
|
|
|
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
|
-
- **CORS** must allow cross-origin `Range` requests: S3 `ExposeHeaders` must include **`Content-Range`** and **`Accept-Ranges`**; CloudFront behaviors must allow **`GET`**, **`HEAD`**, and **`OPTIONS`**, use **CORS-S3Origin**, and enable **CORS with preflight** on the response headers policy.
|
|
235
|
-
- The service worker script must be served **same-origin** (with `Service-Worker-Allowed` when scope is wider than the worker path).
|
|
236
|
-
- Package URLs should end with **`.zip`**.
|
|
237
|
-
|
|
238
|
-
We also support **presigned URLs**, **allow-lists** for zip URLs, and **cache TTL** for extracted assets. See the [README](../README.md#documentation) for details.
|
|
239
|
-
|
|
240
|
-
---
|
|
241
|
-
|
|
242
|
-
## A note to the next person who hears “use the zip bucket on web”
|
|
243
|
-
|
|
244
|
-
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.**
|
|
245
|
-
|
|
246
|
-
If your gut says *therefore we need a parallel unzipped bucket forever*, that’s the part we overturned.
|
|
247
|
-
|
|
248
|
-
The performant model is **peek, don’t gulp**: index the archive remotely, pull one entry at a time, cache what you’ve already extracted.
|
|
249
|
-
|
|
250
|
-
**It can be done wonderfully.**
|
|
251
|
-
|
|
252
|
-
---
|
|
253
|
-
|
|
254
|
-
## Documentation
|
|
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.
|
|
255
32
|
|
|
256
33
|
## What It Does
|
|
257
34
|
|
|
@@ -479,25 +256,19 @@ In the S3 console, open the ZIP bucket → **Permissions** → **Cross-origin re
|
|
|
479
256
|
{
|
|
480
257
|
"AllowedHeaders": ["*"],
|
|
481
258
|
"AllowedMethods": ["GET", "HEAD"],
|
|
482
|
-
"AllowedOrigins": [
|
|
483
|
-
|
|
484
|
-
"http://localhost:3000"
|
|
485
|
-
],
|
|
486
|
-
"ExposeHeaders": [
|
|
487
|
-
"Content-Range",
|
|
488
|
-
"Accept-Ranges"
|
|
489
|
-
]
|
|
259
|
+
"AllowedOrigins": ["https://your-app.example.com", "http://localhost:3000"],
|
|
260
|
+
"ExposeHeaders": ["Content-Range", "Accept-Ranges"]
|
|
490
261
|
}
|
|
491
262
|
]
|
|
492
263
|
```
|
|
493
264
|
|
|
494
265
|
**Required for zip-peek:**
|
|
495
266
|
|
|
496
|
-
| Setting
|
|
497
|
-
|
|
498
|
-
| `AllowedHeaders: ["*"]`
|
|
499
|
-
| `AllowedMethods`: `GET`, `HEAD`
|
|
500
|
-
| `AllowedOrigins`
|
|
267
|
+
| Setting | Why |
|
|
268
|
+
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
269
|
+
| `AllowedHeaders: ["*"]` | Allows the `Range` request header (triggers preflight). |
|
|
270
|
+
| `AllowedMethods`: `GET`, `HEAD` | Manifest and asset reads. |
|
|
271
|
+
| `AllowedOrigins` | Must include your app origin(s). Use `*` only for quick local testing. |
|
|
501
272
|
| `ExposeHeaders`: **`Content-Range`**, **`Accept-Ranges`** | The service worker reads `Content-Range` when parsing the ZIP index. These two headers are **required** in `ExposeHeaders`. |
|
|
502
273
|
|
|
503
274
|
S3 answers `OPTIONS` preflight automatically when CORS is configured; you do not list `OPTIONS` in `AllowedMethods`.
|
|
@@ -638,10 +409,7 @@ For example, after resolving the emitted filename:
|
|
|
638
409
|
|
|
639
410
|
```ts
|
|
640
411
|
await initZipPeek({
|
|
641
|
-
workerUrl: new URL(
|
|
642
|
-
`/assets/${zipServiceWorkerFilename}`,
|
|
643
|
-
window.location.origin,
|
|
644
|
-
).href,
|
|
412
|
+
workerUrl: new URL(`/assets/${zipServiceWorkerFilename}`, window.location.origin).href,
|
|
645
413
|
scopeUrl: new URL("/", window.location.origin).href,
|
|
646
414
|
});
|
|
647
415
|
```
|
|
@@ -718,11 +486,3 @@ For a detailed explanation of the modular service worker (`src/zipServiceWorker.
|
|
|
718
486
|
- Manifest entry fallback prepends the ZIP basename only (e.g. `session/icon.png` for `session.zip`); generic `*/filename` suffix matching is not used.
|
|
719
487
|
- Multi-range client requests are not supported (416).
|
|
720
488
|
- The package is browser-only and depends on Service Worker, Cache API, and HTTP range request support.
|
|
721
|
-
|
|
722
|
-
---
|
|
723
|
-
|
|
724
|
-
## Further reading
|
|
725
|
-
|
|
726
|
-
- [Medium-style article (HTML)](./docs/MEDIUM_POST.html) — animated diagrams
|
|
727
|
-
- [Article (Markdown)](./docs/MEDIUM_POST.md) — same story as Markdown
|
|
728
|
-
- [Zip service worker flow](./docs/ZIP_SERVICE_WORKER_FLOW.md)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { InitZipPeekOptions, InitZipPeekResult } from "./types";
|
|
1
|
+
import type { InitZipPeekOptions, InitZipPeekResult, RenewPresignedUrlOptions } from "./types";
|
|
2
2
|
import { isZipPackage } from "./zip-utils";
|
|
3
|
-
export type { InitZipPeekOptions, InitZipPeekResult, ZipCacheClearingStrategy, ZipPeekErrorHandler, ZipWorkerConfig, } from "./types";
|
|
3
|
+
export type { InitZipPeekOptions, InitZipPeekResult, RenewPresignedUrlOptions, ZipCacheClearingStrategy, ZipPeekErrorHandler, ZipPeekUrlExpiryHandler, ZipWorkerConfig, } from "./types";
|
|
4
|
+
export { getPresignedUrlExpiryMs } from "./zip-utils";
|
|
4
5
|
/**
|
|
5
6
|
* Registers the zip-peek service worker and configures lazy ZIP asset loading.
|
|
6
7
|
*
|
|
@@ -10,6 +11,15 @@ export type { InitZipPeekOptions, InitZipPeekResult, ZipCacheClearingStrategy, Z
|
|
|
10
11
|
* package URLs and warm their manifests during initialization. Use
|
|
11
12
|
* `cacheClearingStrategy` to keep existing cache entries, keep only allowed
|
|
12
13
|
* ZIP URLs, or clear all zip-peek cache entries before use.
|
|
14
|
+
*
|
|
15
|
+
* When `onUrlExpiry` is provided and an allowed URL is a recognized presigned
|
|
16
|
+
* URL, zip-peek invokes the handler shortly before expiry and re-checks on tab
|
|
17
|
+
* wake (`visibilitychange`, `pageshow`, `focus`) after sleep or backgrounding.
|
|
18
|
+
*/
|
|
19
|
+
export declare function initZipPeek({ workerUrl, scopeUrl, reloadOnFirstInstall, cacheClearingStrategy, allowedZipUrls, requireExactManifestPath, zipAssetCacheName, assetCacheTtlMs, logPrefix, onError, onUrlExpiry, }: InitZipPeekOptions): Promise<InitZipPeekResult>;
|
|
20
|
+
/**
|
|
21
|
+
* Replace a presigned ZIP package URL after renewal so the service worker
|
|
22
|
+
* continue serving assets with the new credentials.
|
|
13
23
|
*/
|
|
14
|
-
export declare function
|
|
24
|
+
export declare function renewPresignedUrl({ previousUrl, nextUrl }: RenewPresignedUrlOptions): Promise<void>;
|
|
15
25
|
export { isZipPackage };
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.isZipPackage = void 0;
|
|
3
|
+
exports.isZipPackage = exports.getPresignedUrlExpiryMs = void 0;
|
|
4
4
|
exports.initZipPeek = initZipPeek;
|
|
5
|
+
exports.renewPresignedUrl = renewPresignedUrl;
|
|
5
6
|
const types_1 = require("./types");
|
|
6
7
|
const zip_utils_1 = require("./zip-utils");
|
|
7
8
|
Object.defineProperty(exports, "isZipPackage", { enumerable: true, get: function () { return zip_utils_1.isZipPackage; } });
|
|
9
|
+
var zip_utils_2 = require("./zip-utils");
|
|
10
|
+
Object.defineProperty(exports, "getPresignedUrlExpiryMs", { enumerable: true, get: function () { return zip_utils_2.getPresignedUrlExpiryMs; } });
|
|
11
|
+
/** Default lead time before presigned URL expiry when scheduling renewal. */
|
|
12
|
+
const URL_EXPIRY_LEAD_MS = 10000;
|
|
8
13
|
let currentOnError;
|
|
14
|
+
let currentOnUrlExpiry;
|
|
9
15
|
let clientMessageListenerInstalled = false;
|
|
16
|
+
let expiryWakeListenersInstalled = false;
|
|
17
|
+
/** Timers keyed by normalized zip URL. */
|
|
18
|
+
const urlExpiryTimers = new Map();
|
|
19
|
+
/** Original resolved URLs for expiry parsing, keyed by normalized zip URL. */
|
|
20
|
+
const trackedZipUrls = new Map();
|
|
21
|
+
/** Guard concurrent onUrlExpiry invocations per zip URL. */
|
|
22
|
+
const urlExpiryInFlight = new Set();
|
|
10
23
|
const sessionManifests = new Map();
|
|
11
24
|
function normalizeScopePath(href) {
|
|
12
25
|
let p = new URL(href).pathname;
|
|
@@ -101,6 +114,77 @@ function errorFromWorkerResponse(response) {
|
|
|
101
114
|
}
|
|
102
115
|
return error;
|
|
103
116
|
}
|
|
117
|
+
function clearUrlExpiryTimer(normalizedZipUrl) {
|
|
118
|
+
const existing = urlExpiryTimers.get(normalizedZipUrl);
|
|
119
|
+
if (existing !== undefined) {
|
|
120
|
+
clearTimeout(existing);
|
|
121
|
+
urlExpiryTimers.delete(normalizedZipUrl);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function scheduleUrlExpiry(normalizedZipUrl, originalUrlForExpiry) {
|
|
125
|
+
clearUrlExpiryTimer(normalizedZipUrl);
|
|
126
|
+
trackedZipUrls.set(normalizedZipUrl, originalUrlForExpiry);
|
|
127
|
+
if (!currentOnUrlExpiry) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const expiryMs = (0, zip_utils_1.getPresignedUrlExpiryMs)(originalUrlForExpiry);
|
|
131
|
+
if (expiryMs == null) {
|
|
132
|
+
trackedZipUrls.delete(normalizedZipUrl);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const delayMs = Math.max(0, expiryMs - Date.now() - URL_EXPIRY_LEAD_MS);
|
|
136
|
+
const timerId = setTimeout(() => {
|
|
137
|
+
urlExpiryTimers.delete(normalizedZipUrl);
|
|
138
|
+
void invokeUrlExpiry(normalizedZipUrl);
|
|
139
|
+
}, delayMs);
|
|
140
|
+
urlExpiryTimers.set(normalizedZipUrl, timerId);
|
|
141
|
+
}
|
|
142
|
+
function isZipUrlDueForRenewal(originalUrl) {
|
|
143
|
+
const expiryMs = (0, zip_utils_1.getPresignedUrlExpiryMs)(originalUrl);
|
|
144
|
+
if (expiryMs == null) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
return expiryMs - Date.now() <= URL_EXPIRY_LEAD_MS;
|
|
148
|
+
}
|
|
149
|
+
function checkExpiryOnWake() {
|
|
150
|
+
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!currentOnUrlExpiry) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
for (const [normalized, original] of trackedZipUrls) {
|
|
157
|
+
if (isZipUrlDueForRenewal(original)) {
|
|
158
|
+
void invokeUrlExpiry(normalized);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
scheduleUrlExpiry(normalized, original);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function ensureExpiryWakeListeners() {
|
|
165
|
+
if (expiryWakeListenersInstalled || typeof document === "undefined") {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
expiryWakeListenersInstalled = true;
|
|
169
|
+
document.addEventListener("visibilitychange", checkExpiryOnWake);
|
|
170
|
+
window.addEventListener("pageshow", checkExpiryOnWake);
|
|
171
|
+
window.addEventListener("focus", checkExpiryOnWake);
|
|
172
|
+
}
|
|
173
|
+
async function invokeUrlExpiry(normalizedZipUrl) {
|
|
174
|
+
if (!currentOnUrlExpiry || urlExpiryInFlight.has(normalizedZipUrl)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
urlExpiryInFlight.add(normalizedZipUrl);
|
|
178
|
+
try {
|
|
179
|
+
await currentOnUrlExpiry();
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
reportZipPeekError(currentOnError, error, "onUrlExpiry handler failed");
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
urlExpiryInFlight.delete(normalizedZipUrl);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
104
188
|
function ensureClientMessageListener() {
|
|
105
189
|
if (clientMessageListenerInstalled || !("serviceWorker" in navigator)) {
|
|
106
190
|
return;
|
|
@@ -194,11 +278,18 @@ async function postWorkerMessageOrThrow(message, info) {
|
|
|
194
278
|
* package URLs and warm their manifests during initialization. Use
|
|
195
279
|
* `cacheClearingStrategy` to keep existing cache entries, keep only allowed
|
|
196
280
|
* ZIP URLs, or clear all zip-peek cache entries before use.
|
|
281
|
+
*
|
|
282
|
+
* When `onUrlExpiry` is provided and an allowed URL is a recognized presigned
|
|
283
|
+
* URL, zip-peek invokes the handler shortly before expiry and re-checks on tab
|
|
284
|
+
* wake (`visibilitychange`, `pageshow`, `focus`) after sleep or backgrounding.
|
|
197
285
|
*/
|
|
198
|
-
async function initZipPeek({ workerUrl, scopeUrl, reloadOnFirstInstall = true, cacheClearingStrategy = "keep-all", allowedZipUrls, requireExactManifestPath = false, zipAssetCacheName, assetCacheTtlMs, logPrefix, onError, }) {
|
|
286
|
+
async function initZipPeek({ workerUrl, scopeUrl, reloadOnFirstInstall = true, cacheClearingStrategy = "keep-all", allowedZipUrls, requireExactManifestPath = false, zipAssetCacheName, assetCacheTtlMs, logPrefix, onError, onUrlExpiry, }) {
|
|
199
287
|
if (onError) {
|
|
200
288
|
currentOnError = onError;
|
|
201
289
|
}
|
|
290
|
+
if (onUrlExpiry) {
|
|
291
|
+
currentOnUrlExpiry = onUrlExpiry;
|
|
292
|
+
}
|
|
202
293
|
try {
|
|
203
294
|
const normalizedAllowedZipUrls = normalizeAllowedZipUrls(allowedZipUrls);
|
|
204
295
|
if (cacheClearingStrategy === "keep-allowed-urls" && !normalizedAllowedZipUrls) {
|
|
@@ -260,6 +351,17 @@ async function initZipPeek({ workerUrl, scopeUrl, reloadOnFirstInstall = true, c
|
|
|
260
351
|
allowedZipUrls: normalizedAllowedZipUrls,
|
|
261
352
|
}, "Failed to preload ZIP manifests");
|
|
262
353
|
}
|
|
354
|
+
if (onUrlExpiry && allowedZipUrls) {
|
|
355
|
+
ensureExpiryWakeListeners();
|
|
356
|
+
// Schedule from the original (pre-set-dedupe) URLs so query params used
|
|
357
|
+
// for expiry parsing are retained; timers are keyed by normalized form.
|
|
358
|
+
for (const zipUrl of allowedZipUrls) {
|
|
359
|
+
const resolvedZipUrl = new URL(zipUrl, window.location.href).href;
|
|
360
|
+
if ((0, zip_utils_1.isZipPackage)(resolvedZipUrl)) {
|
|
361
|
+
scheduleUrlExpiry((0, zip_utils_1.normalizeZipUrl)(resolvedZipUrl), resolvedZipUrl);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
263
365
|
return { reloaded: false, initialized: true };
|
|
264
366
|
}
|
|
265
367
|
catch (error) {
|
|
@@ -267,3 +369,32 @@ async function initZipPeek({ workerUrl, scopeUrl, reloadOnFirstInstall = true, c
|
|
|
267
369
|
throw error;
|
|
268
370
|
}
|
|
269
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* Replace a presigned ZIP package URL after renewal so the service worker
|
|
374
|
+
* continue serving assets with the new credentials.
|
|
375
|
+
*/
|
|
376
|
+
async function renewPresignedUrl({ previousUrl, nextUrl }) {
|
|
377
|
+
if (!("serviceWorker" in navigator)) {
|
|
378
|
+
throw (0, types_1.createZipPeekError)("Service Worker API not supported in this browser.");
|
|
379
|
+
}
|
|
380
|
+
const resolvedPrevious = new URL(previousUrl, window.location.href).href;
|
|
381
|
+
const resolvedNext = new URL(nextUrl, window.location.href).href;
|
|
382
|
+
if (!(0, zip_utils_1.isZipPackage)(resolvedPrevious) || !(0, zip_utils_1.isZipPackage)(resolvedNext)) {
|
|
383
|
+
throw (0, types_1.createZipPeekError)("renewPresignedUrl requires both previousUrl and nextUrl to be ZIP package URLs.");
|
|
384
|
+
}
|
|
385
|
+
const previous = (0, zip_utils_1.normalizeZipUrl)(resolvedPrevious);
|
|
386
|
+
const next = (0, zip_utils_1.normalizeZipUrl)(resolvedNext);
|
|
387
|
+
await postWorkerMessageOrThrow({
|
|
388
|
+
type: "RENEW_ZIP_URL",
|
|
389
|
+
previousUrl: previous,
|
|
390
|
+
nextUrl: next,
|
|
391
|
+
}, "Failed to renew ZIP URL in service worker");
|
|
392
|
+
const files = sessionManifests.get(previous);
|
|
393
|
+
if (files) {
|
|
394
|
+
sessionManifests.set(next, files);
|
|
395
|
+
sessionManifests.delete(previous);
|
|
396
|
+
}
|
|
397
|
+
trackedZipUrls.delete(previous);
|
|
398
|
+
clearUrlExpiryTimer(previous);
|
|
399
|
+
scheduleUrlExpiry(next, resolvedNext);
|
|
400
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -5,6 +5,11 @@ export type ZipWorkerConfig = {
|
|
|
5
5
|
};
|
|
6
6
|
export type ZipCacheClearingStrategy = 'keep-all' | 'keep-allowed-urls' | 'clear-all';
|
|
7
7
|
export type ZipPeekErrorHandler = (error: Error, info?: string) => void;
|
|
8
|
+
export type ZipPeekUrlExpiryHandler = () => void | Promise<void>;
|
|
9
|
+
export type RenewPresignedUrlOptions = {
|
|
10
|
+
previousUrl: string;
|
|
11
|
+
nextUrl: string;
|
|
12
|
+
};
|
|
8
13
|
export type InitZipPeekOptions = {
|
|
9
14
|
workerUrl: string;
|
|
10
15
|
scopeUrl: string;
|
|
@@ -16,6 +21,13 @@ export type InitZipPeekOptions = {
|
|
|
16
21
|
assetCacheTtlMs?: number;
|
|
17
22
|
logPrefix?: string;
|
|
18
23
|
onError?: ZipPeekErrorHandler;
|
|
24
|
+
/**
|
|
25
|
+
* Called shortly before a recognized presigned URL expires (CloudFront
|
|
26
|
+
* `Expires` or S3 `X-Amz-Date` + `X-Amz-Expires`). Also invoked after tab
|
|
27
|
+
* wake when a timer was missed due to sleep or background throttling.
|
|
28
|
+
* Not scheduled for non-presigned ZIP URLs.
|
|
29
|
+
*/
|
|
30
|
+
onUrlExpiry?: ZipPeekUrlExpiryHandler;
|
|
19
31
|
};
|
|
20
32
|
export type InitZipPeekResult = {
|
|
21
33
|
reloaded: boolean;
|
package/dist/zip-utils.d.ts
CHANGED
|
@@ -32,3 +32,9 @@ export type ParsedZipAssetRequest = {
|
|
|
32
32
|
export declare function parseZipAssetRequest(urlString: string): ParsedZipAssetRequest | null;
|
|
33
33
|
/** Cache key for a zip asset (decoded manifest key + normalized zip URL). */
|
|
34
34
|
export declare function canonicalZipAssetRequestUrl(zipUrl: string, manifestKey: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Parse CloudFront `Expires` or S3 `X-Amz-Date` + `X-Amz-Expires` into an
|
|
37
|
+
* absolute expiry time in ms. Returns `null` when the URL is not recognized
|
|
38
|
+
* as a time-limited presigned URL.
|
|
39
|
+
*/
|
|
40
|
+
export declare function getPresignedUrlExpiryMs(url: string): number | null;
|
package/dist/zip-utils.js
CHANGED
|
@@ -6,6 +6,7 @@ exports.zipBasenameWithoutExtension = zipBasenameWithoutExtension;
|
|
|
6
6
|
exports.normalizeZipEntryPath = normalizeZipEntryPath;
|
|
7
7
|
exports.parseZipAssetRequest = parseZipAssetRequest;
|
|
8
8
|
exports.canonicalZipAssetRequestUrl = canonicalZipAssetRequestUrl;
|
|
9
|
+
exports.getPresignedUrlExpiryMs = getPresignedUrlExpiryMs;
|
|
9
10
|
/** True when the URL points at a `.zip` package (path ends with `.zip`). */
|
|
10
11
|
function isZipPackage(url) {
|
|
11
12
|
try {
|
|
@@ -116,3 +117,58 @@ function parseZipAssetRequest(urlString) {
|
|
|
116
117
|
function canonicalZipAssetRequestUrl(zipUrl, manifestKey) {
|
|
117
118
|
return zipUrl + "/" + encodeURIComponent(manifestKey);
|
|
118
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Read a raw query param value without `URLSearchParams` so SigV4 `%2F`
|
|
122
|
+
* credentials are not re-encoded.
|
|
123
|
+
*/
|
|
124
|
+
function getRawQueryParam(urlString, name) {
|
|
125
|
+
const qIdx = urlString.indexOf("?");
|
|
126
|
+
if (qIdx === -1)
|
|
127
|
+
return null;
|
|
128
|
+
const hashIdx = urlString.indexOf("#", qIdx);
|
|
129
|
+
const querySection = hashIdx === -1
|
|
130
|
+
? urlString.substring(qIdx + 1)
|
|
131
|
+
: urlString.substring(qIdx + 1, hashIdx);
|
|
132
|
+
// Presigned ZIP asset requests put the inner path after the query
|
|
133
|
+
// (`pkg.zip?sig…/asset.png`). Stop at the first raw `/`.
|
|
134
|
+
const slashIdx = querySection.indexOf("/");
|
|
135
|
+
const signedQuery = slashIdx === -1 ? querySection : querySection.substring(0, slashIdx);
|
|
136
|
+
for (const part of signedQuery.split("&")) {
|
|
137
|
+
const eqIdx = part.indexOf("=");
|
|
138
|
+
const k = eqIdx === -1 ? part : part.substring(0, eqIdx);
|
|
139
|
+
if (k === name) {
|
|
140
|
+
return eqIdx === -1 ? "" : part.substring(eqIdx + 1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Parse CloudFront `Expires` or S3 `X-Amz-Date` + `X-Amz-Expires` into an
|
|
147
|
+
* absolute expiry time in ms. Returns `null` when the URL is not recognized
|
|
148
|
+
* as a time-limited presigned URL.
|
|
149
|
+
*/
|
|
150
|
+
function getPresignedUrlExpiryMs(url) {
|
|
151
|
+
const expiresRaw = getRawQueryParam(url, "Expires");
|
|
152
|
+
if (expiresRaw != null && expiresRaw !== "") {
|
|
153
|
+
const expiresSec = Number(expiresRaw);
|
|
154
|
+
if (Number.isFinite(expiresSec) && expiresSec > 0) {
|
|
155
|
+
return expiresSec * 1000;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const amzDate = getRawQueryParam(url, "X-Amz-Date");
|
|
159
|
+
const amzExpires = getRawQueryParam(url, "X-Amz-Expires");
|
|
160
|
+
if (amzDate && amzExpires) {
|
|
161
|
+
// X-Amz-Date is YYYYMMDD'T'HHMMSS'Z'
|
|
162
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(amzDate);
|
|
163
|
+
if (!match)
|
|
164
|
+
return null;
|
|
165
|
+
const [, y, mo, d, h, mi, s] = match;
|
|
166
|
+
const startMs = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
|
|
167
|
+
const durationSec = Number(amzExpires);
|
|
168
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(durationSec) || durationSec <= 0) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return startMs + durationSec * 1000;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|