@gigamusic/checkout 4.7.0 → 4.8.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 +52 -0
- package/dist/index.d.ts +96 -8
- package/dist/index.js +128 -38
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/download.ts +72 -25
- package/src/filenames.ts +23 -0
- package/src/index.ts +1 -0
- package/src/types.ts +41 -0
- package/src/zip-stream.ts +44 -2
- package/src/zip.ts +132 -30
package/src/filenames.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filename helpers shared by the single-file download route and the zip
|
|
3
|
+
* routes. They live here rather than in either module because `download.ts`
|
|
4
|
+
* now serves cover art (and so needs `coverArtFilename`) while `zip.ts` needs
|
|
5
|
+
* `cleanDownloadFilename` — importing across the two directly would be a cycle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Trim stray leading/trailing whitespace from a download filename while keeping its extension intact. */
|
|
9
|
+
export function cleanDownloadFilename(name: string): string {
|
|
10
|
+
const dot = name.lastIndexOf(".");
|
|
11
|
+
if (dot <= 0) return name.trim();
|
|
12
|
+
return `${name.slice(0, dot).trim()}${name.slice(dot).trim()}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Strip path separators so a release/track name can't spawn unintended zip subfolders. */
|
|
16
|
+
export function sanitizeSegment(name: string): string {
|
|
17
|
+
return name.replace(/[/\\]+/g, "-").trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Zip filename for a release's cover art. */
|
|
21
|
+
export function coverArtFilename(releaseName: string): string {
|
|
22
|
+
return `${sanitizeSegment(releaseName)} - COVER ART.jpg`;
|
|
23
|
+
}
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -168,9 +168,50 @@ export interface WebhookDeps extends FulfillSessionDeps, PurchaseConfirmationDep
|
|
|
168
168
|
webhookSecret: string;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* One entry `createDownloadZipStreamHandler` could not stream into the
|
|
173
|
+
* archive. The customer still gets a zip — the entry is replaced by a
|
|
174
|
+
* `_FAILED_<name>.txt` placeholder so one bad object doesn't corrupt the rest
|
|
175
|
+
* — which is exactly why the failure needs surfacing somewhere the operator
|
|
176
|
+
* will see it: from the outside, a half-empty archive looks like a successful
|
|
177
|
+
* download.
|
|
178
|
+
*/
|
|
179
|
+
export interface ZipEntryFailure {
|
|
180
|
+
/** Download token whose bundle was being streamed — identifies the order. */
|
|
181
|
+
token: string;
|
|
182
|
+
/** Path of the entry inside the archive, e.g. `"Album/track.wav"`. */
|
|
183
|
+
fileName: string;
|
|
184
|
+
storageKey: string;
|
|
185
|
+
/** Message from the failed storage fetch or mid-stream error. */
|
|
186
|
+
reason: string;
|
|
187
|
+
/** Bytes received before the failure; 0 when the fetch never got going. */
|
|
188
|
+
bytesReceived: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
171
191
|
export interface DownloadDeps {
|
|
172
192
|
queries: Queries;
|
|
173
193
|
storage: StorageProvider;
|
|
194
|
+
/**
|
|
195
|
+
* Path of the single-file download route (`createDownloadHandler`) that
|
|
196
|
+
* `createDownloadZipHandler`'s manifest addresses its entries through, with
|
|
197
|
+
* `{token}` as the placeholder — e.g. `"/downloads/{token}/file"`.
|
|
198
|
+
*
|
|
199
|
+
* Defaults to the manifest route's own path minus its last segment, which
|
|
200
|
+
* resolves to `/download/[token]` under the layout `docs/setup.md`
|
|
201
|
+
* prescribes. Only set this if you mount the two routes such that the
|
|
202
|
+
* relationship doesn't hold.
|
|
203
|
+
*/
|
|
204
|
+
downloadPath?: string;
|
|
205
|
+
/**
|
|
206
|
+
* Called by `createDownloadZipStreamHandler` once per entry it fails to
|
|
207
|
+
* stream, so a broken bulk download can raise an alert instead of silently
|
|
208
|
+
* shipping a zip full of `_FAILED_*.txt` placeholders. Failures are also
|
|
209
|
+
* logged, with a per-request summary line carrying the failed/total count.
|
|
210
|
+
*
|
|
211
|
+
* Errors thrown here are swallowed — reporting must never take down a
|
|
212
|
+
* download that is otherwise still producing bytes.
|
|
213
|
+
*/
|
|
214
|
+
onZipEntryFailure?: (failure: ZipEntryFailure) => void;
|
|
174
215
|
/**
|
|
175
216
|
* Optional artist-name prefix prepended to zip filenames — both the SW
|
|
176
217
|
* manifest and the server-side stream output use it. Example: passing
|
package/src/zip-stream.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Readable, Transform } from "node:stream";
|
|
|
3
3
|
import { finished } from "node:stream/promises";
|
|
4
4
|
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
|
5
5
|
import archiver from "archiver";
|
|
6
|
-
import type { DownloadDeps } from "./types.js";
|
|
6
|
+
import type { DownloadDeps, ZipEntryFailure } from "./types.js";
|
|
7
7
|
import { resolveZipBundle } from "./zip.js";
|
|
8
8
|
|
|
9
9
|
interface RouteContext {
|
|
@@ -28,7 +28,18 @@ interface RouteContext {
|
|
|
28
28
|
*
|
|
29
29
|
* Per-file failure policy matches the SW: any failed storage fetch becomes
|
|
30
30
|
* a `_FAILED_<name>.txt` placeholder so one bad object doesn't taint the
|
|
31
|
-
* whole archive.
|
|
31
|
+
* whole archive. Because that turns a broken purchase into an
|
|
32
|
+
* apparently-successful download, every failure is reported through
|
|
33
|
+
* `deps.onZipEntryFailure` and summarised in a single log line at the end of
|
|
34
|
+
* the request.
|
|
35
|
+
*
|
|
36
|
+
* Unlike the manifest path, this handler presigns each file *inside* the loop,
|
|
37
|
+
* immediately before fetching it, so signature expiry can't bite. Its own
|
|
38
|
+
* ceiling is the platform's function timeout: audio bytes proxy through the
|
|
39
|
+
* function, so the whole archive has to be produced within `maxDuration`
|
|
40
|
+
* (Vercel: 300s by default, up to 800s on Fluid compute). Multi-gigabyte
|
|
41
|
+
* bundles need that raised in the consuming route — `export const maxDuration`
|
|
42
|
+
* — or they'll be cut off mid-stream.
|
|
32
43
|
*/
|
|
33
44
|
export function createDownloadZipStreamHandler(
|
|
34
45
|
deps: DownloadDeps,
|
|
@@ -63,6 +74,17 @@ export function createDownloadZipStreamHandler(
|
|
|
63
74
|
// Tear the pipeline down if the client disconnects mid-download.
|
|
64
75
|
req.signal.addEventListener("abort", () => archive.abort());
|
|
65
76
|
|
|
77
|
+
const failures: ZipEntryFailure[] = [];
|
|
78
|
+
/** Record one unusable entry and hand it to the consumer's reporter, never letting that reporter break the stream. */
|
|
79
|
+
const reportFailure = (failure: ZipEntryFailure) => {
|
|
80
|
+
failures.push(failure);
|
|
81
|
+
try {
|
|
82
|
+
deps.onZipEntryFailure?.(failure);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.warn("[zip-stream] onZipEntryFailure threw:", err);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
66
88
|
// Serialise storage fetches: only open file N's socket after archiver
|
|
67
89
|
// has fully consumed file N-1. The naive parallel approach (open every
|
|
68
90
|
// socket up front, let archiver drain them in order) leaves the later
|
|
@@ -115,6 +137,13 @@ export function createDownloadZipStreamHandler(
|
|
|
115
137
|
`[zip-stream] body stream error for ${file.fileName} ` +
|
|
116
138
|
`(storageKey=${file.storageKey}, bytesReceived=${bytesReceived}): ${detail}`,
|
|
117
139
|
);
|
|
140
|
+
reportFailure({
|
|
141
|
+
token,
|
|
142
|
+
fileName: file.fileName,
|
|
143
|
+
storageKey: file.storageKey,
|
|
144
|
+
reason: detail,
|
|
145
|
+
bytesReceived,
|
|
146
|
+
});
|
|
118
147
|
try {
|
|
119
148
|
archive.append(
|
|
120
149
|
`Streaming "${file.fileName}" from storage failed after ${bytesReceived} bytes: ${detail}.\n` +
|
|
@@ -132,6 +161,13 @@ export function createDownloadZipStreamHandler(
|
|
|
132
161
|
if (req.signal.aborted) return;
|
|
133
162
|
const detail = err instanceof Error ? err.message : String(err);
|
|
134
163
|
console.warn(`[zip-stream] fetch failed for ${file.fileName}:`, detail);
|
|
164
|
+
reportFailure({
|
|
165
|
+
token,
|
|
166
|
+
fileName: file.fileName,
|
|
167
|
+
storageKey: file.storageKey,
|
|
168
|
+
reason: detail,
|
|
169
|
+
bytesReceived: 0,
|
|
170
|
+
});
|
|
135
171
|
archive.append(
|
|
136
172
|
`Failed to download "${file.fileName}" from storage: ${detail}.\n` +
|
|
137
173
|
`Try downloading the track individually from your order page.\n`,
|
|
@@ -139,6 +175,12 @@ export function createDownloadZipStreamHandler(
|
|
|
139
175
|
);
|
|
140
176
|
}
|
|
141
177
|
}
|
|
178
|
+
if (failures.length > 0) {
|
|
179
|
+
console.error(
|
|
180
|
+
`[zip-stream] ${failures.length}/${resolution.files.length} entries failed ` +
|
|
181
|
+
`for token ${token}: ${failures.map((f) => f.fileName).join(", ")}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
142
184
|
await archive.finalize();
|
|
143
185
|
})().catch((err) => {
|
|
144
186
|
console.error("[zip-stream] pipeline error:", err);
|
package/src/zip.ts
CHANGED
|
@@ -7,17 +7,28 @@ import type {
|
|
|
7
7
|
} from "@gigamusic/db";
|
|
8
8
|
import type { StorageProvider } from "@gigamusic/storage";
|
|
9
9
|
import type { DownloadDeps } from "./types.js";
|
|
10
|
-
import { cleanDownloadFilename } from "./
|
|
10
|
+
import { cleanDownloadFilename, coverArtFilename, sanitizeSegment } from "./filenames.js";
|
|
11
11
|
|
|
12
12
|
interface RouteContext {
|
|
13
13
|
params: Promise<{ token: string }>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* How a zip entry can be re-addressed through `createDownloadHandler`, so the
|
|
18
|
+
* manifest can hand the service worker a same-origin URL that mints the R2
|
|
19
|
+
* signature at fetch time instead of a signature minted up front. Entries
|
|
20
|
+
* without a source fall back to a presigned URL (see `ZIP_MANIFEST_EXPIRES_IN_SECONDS`).
|
|
21
|
+
*/
|
|
22
|
+
export type ZipFileSource =
|
|
23
|
+
| { kind: "track"; trackId: number; format: string }
|
|
24
|
+
| { kind: "cover"; releaseId: number };
|
|
25
|
+
|
|
16
26
|
/** A file destined for the zip, paired with the storage object it streams from. */
|
|
17
27
|
export interface ZipFile {
|
|
18
28
|
fileName: string;
|
|
19
29
|
storageKey: string;
|
|
20
30
|
contentType: string;
|
|
31
|
+
source?: ZipFileSource;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
interface ZipManifest {
|
|
@@ -25,6 +36,26 @@ interface ZipManifest {
|
|
|
25
36
|
files: { fileName: string; url: string }[];
|
|
26
37
|
}
|
|
27
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Expiry for the presigned URLs the manifest falls back to when an entry has
|
|
41
|
+
* no `source` to re-address it by.
|
|
42
|
+
*
|
|
43
|
+
* The storage default is 5 minutes, measured from when the manifest is built —
|
|
44
|
+
* which is fatally short here. The service worker fetches manifest entries
|
|
45
|
+
* strictly sequentially (a zip is a sequential format; `client-zip` drains one
|
|
46
|
+
* body before opening the next), so a multi-gigabyte bundle is still working
|
|
47
|
+
* through the list long after minute five. Worse, R2's signature-failure
|
|
48
|
+
* responses carry no CORS headers, so the cross-origin fetch rejects with an
|
|
49
|
+
* opaque `NetworkError` rather than a readable 403 — and the SW's per-file
|
|
50
|
+
* catch turns each one into a `_FAILED_*.txt` placeholder, so the customer
|
|
51
|
+
* gets a plausible-looking zip with most of their music missing.
|
|
52
|
+
*
|
|
53
|
+
* 12 hours comfortably outlasts any realistic download. These URLs are
|
|
54
|
+
* shareable for that window, which is a small leak: the order link that
|
|
55
|
+
* produced them grants the same files and doesn't expire at all.
|
|
56
|
+
*/
|
|
57
|
+
export const ZIP_MANIFEST_EXPIRES_IN_SECONDS = 12 * 60 * 60;
|
|
58
|
+
|
|
28
59
|
/**
|
|
29
60
|
* Discriminated result of `resolveZipBundle` — the SW-manifest handler and
|
|
30
61
|
* the server-side streaming handler both call this so they produce
|
|
@@ -91,9 +122,21 @@ export async function resolveZipBundle(args: ResolveZipArgs): Promise<ResolveZip
|
|
|
91
122
|
/**
|
|
92
123
|
* Build the GET handler for the zip-manifest endpoint. Returns the JSON
|
|
93
124
|
* manifest the consumer-shipped service worker (`public/sw-zip.js`)
|
|
94
|
-
* consumes — the SW pipes
|
|
95
|
-
*
|
|
96
|
-
*
|
|
125
|
+
* consumes — the SW pipes each URL through `client-zip` and streams the
|
|
126
|
+
* archive straight from storage to the browser, with the serverless function
|
|
127
|
+
* never touching audio bytes.
|
|
128
|
+
*
|
|
129
|
+
* Manifest URLs point back at `createDownloadHandler` on this same origin
|
|
130
|
+
* rather than directly at presigned R2 URLs. That route 302s to a *freshly*
|
|
131
|
+
* signed URL, so the signature is minted at the moment the SW reaches that
|
|
132
|
+
* entry — however many hours into the archive that is. Signing everything up
|
|
133
|
+
* front meant every URL in a multi-gigabyte bundle died 5 minutes after the
|
|
134
|
+
* click; see `ZIP_MANIFEST_EXPIRES_IN_SECONDS`. The redirect adds one cheap
|
|
135
|
+
* function invocation per file and gives failures a server-side trace they
|
|
136
|
+
* never had while the SW talked straight to R2.
|
|
137
|
+
*
|
|
138
|
+
* The manifest shape (`{ zipName, files: [{ fileName, url }] }`) is unchanged,
|
|
139
|
+
* so an already-deployed service worker keeps working without modification.
|
|
97
140
|
*
|
|
98
141
|
* Query-param branches:
|
|
99
142
|
* - `trackIds` set → curated track list, flat layout
|
|
@@ -122,7 +165,11 @@ export function createDownloadZipHandler(
|
|
|
122
165
|
|
|
123
166
|
const manifest: ZipManifest = {
|
|
124
167
|
zipName: resolution.zipName,
|
|
125
|
-
files: await
|
|
168
|
+
files: await buildManifestFiles(
|
|
169
|
+
storage,
|
|
170
|
+
resolution.files,
|
|
171
|
+
downloadRouteUrl(url, token, deps.downloadPath),
|
|
172
|
+
),
|
|
126
173
|
};
|
|
127
174
|
return Response.json(manifest);
|
|
128
175
|
};
|
|
@@ -186,6 +233,7 @@ function resolveTrackList(
|
|
|
186
233
|
fileName: zipEntryPath(ctx.release?.name ?? null, file.fileName),
|
|
187
234
|
storageKey: file.storageKey,
|
|
188
235
|
contentType: audioContentType,
|
|
236
|
+
source: { kind: "track", trackId: id, format },
|
|
189
237
|
});
|
|
190
238
|
}
|
|
191
239
|
if (files.length === 0) {
|
|
@@ -221,19 +269,20 @@ function resolveSingleRelease(
|
|
|
221
269
|
}
|
|
222
270
|
|
|
223
271
|
const files: ZipFile[] = release.tracks
|
|
224
|
-
.map((track) => {
|
|
272
|
+
.map((track): ZipFile | null => {
|
|
225
273
|
const file = track.files.find((f) => f.format === format);
|
|
226
274
|
if (!file) return null;
|
|
227
275
|
return {
|
|
228
276
|
fileName: zipEntryPath(null, file.fileName),
|
|
229
277
|
storageKey: file.storageKey,
|
|
230
278
|
contentType: audioContentType,
|
|
231
|
-
|
|
279
|
+
source: { kind: "track", trackId: track.id, format },
|
|
280
|
+
};
|
|
232
281
|
})
|
|
233
282
|
.filter((f): f is ZipFile => f !== null);
|
|
234
283
|
|
|
235
284
|
if (files.length > 0 && release.coverImageUrl) {
|
|
236
|
-
files.push(...coverArtEntries(release
|
|
285
|
+
files.push(...coverArtEntries(release, release.coverImageUrl, "", files));
|
|
237
286
|
}
|
|
238
287
|
|
|
239
288
|
if (files.length === 0) {
|
|
@@ -271,12 +320,13 @@ function resolveWholeOrder(
|
|
|
271
320
|
fileName: zipEntryPath(release.name, file.fileName),
|
|
272
321
|
storageKey: file.storageKey,
|
|
273
322
|
contentType: audioContentType,
|
|
323
|
+
source: { kind: "track", trackId: track.id, format },
|
|
274
324
|
});
|
|
275
325
|
}
|
|
276
326
|
if (entries.length > 0 && release.coverImageUrl) {
|
|
277
327
|
entries.push(
|
|
278
328
|
...coverArtEntries(
|
|
279
|
-
release
|
|
329
|
+
release,
|
|
280
330
|
release.coverImageUrl,
|
|
281
331
|
`${sanitizeSegment(release.name)}/`,
|
|
282
332
|
entries,
|
|
@@ -293,6 +343,7 @@ function resolveWholeOrder(
|
|
|
293
343
|
fileName: zipEntryPath(release?.name ?? null, file.fileName),
|
|
294
344
|
storageKey: file.storageKey,
|
|
295
345
|
contentType: audioContentType,
|
|
346
|
+
source: { kind: "track", trackId: track.id, format },
|
|
296
347
|
});
|
|
297
348
|
}
|
|
298
349
|
}
|
|
@@ -339,11 +390,6 @@ function trackOwnership(order: OrderWithItems) {
|
|
|
339
390
|
};
|
|
340
391
|
}
|
|
341
392
|
|
|
342
|
-
/** Strip path separators so a release/track name can't spawn unintended zip subfolders. */
|
|
343
|
-
export function sanitizeSegment(name: string): string {
|
|
344
|
-
return name.replace(/[/\\]+/g, "-").trim();
|
|
345
|
-
}
|
|
346
|
-
|
|
347
393
|
/**
|
|
348
394
|
* Detects extended-mix tracks from their filename. The catalog marks them
|
|
349
395
|
* inconsistently ("Extended", "(Extended)", "[EXTENDED MIX]"), so a loose
|
|
@@ -368,51 +414,107 @@ export function zipEntryPath(releaseName: string | null, fileName: string): stri
|
|
|
368
414
|
return segments.join("/");
|
|
369
415
|
}
|
|
370
416
|
|
|
371
|
-
/** Zip filename for a release's cover art. */
|
|
372
|
-
export function coverArtFilename(releaseName: string): string {
|
|
373
|
-
return `${sanitizeSegment(releaseName)} - COVER ART.jpg`;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
417
|
/**
|
|
377
418
|
* Cover-art zip entries for a release: the artwork alongside the tracks, plus
|
|
378
419
|
* a duplicate inside the `Extended/` subfolder when any track entry uses one.
|
|
379
420
|
* `folder` is the release's `Name/` prefix for multi-release zips, or "" for
|
|
380
421
|
* flat single-release zips.
|
|
422
|
+
*
|
|
423
|
+
* Both entries carry the same `cover` source: cover art isn't track-keyed, so
|
|
424
|
+
* `createDownloadHandler` addresses it by release id instead.
|
|
381
425
|
*/
|
|
382
426
|
export function coverArtEntries(
|
|
383
|
-
|
|
427
|
+
release: Pick<ReleaseWithTracks, "id" | "name">,
|
|
384
428
|
coverImageUrl: string,
|
|
385
429
|
folder: string,
|
|
386
430
|
trackEntries: ZipFile[],
|
|
387
431
|
): ZipFile[] {
|
|
388
|
-
const file = coverArtFilename(
|
|
432
|
+
const file = coverArtFilename(release.name);
|
|
433
|
+
const source: ZipFileSource = { kind: "cover", releaseId: release.id };
|
|
389
434
|
const entries: ZipFile[] = [
|
|
390
|
-
{
|
|
435
|
+
{
|
|
436
|
+
fileName: `${folder}${file}`,
|
|
437
|
+
storageKey: coverImageUrl,
|
|
438
|
+
contentType: "image/jpeg",
|
|
439
|
+
source,
|
|
440
|
+
},
|
|
391
441
|
];
|
|
392
442
|
if (trackEntries.some((e) => e.fileName.split("/").includes("Extended"))) {
|
|
393
443
|
entries.push({
|
|
394
444
|
fileName: `${folder}Extended/${file}`,
|
|
395
445
|
storageKey: coverImageUrl,
|
|
396
446
|
contentType: "image/jpeg",
|
|
447
|
+
source,
|
|
397
448
|
});
|
|
398
449
|
}
|
|
399
450
|
return entries;
|
|
400
451
|
}
|
|
401
452
|
|
|
402
|
-
|
|
403
|
-
|
|
453
|
+
export { coverArtFilename, sanitizeSegment };
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Locate the single-file download route (`createDownloadHandler`) relative to
|
|
457
|
+
* the manifest route that's currently serving this request.
|
|
458
|
+
*
|
|
459
|
+
* `docs/setup.md` mounts the manifest at `download/[token]/zip`, one segment
|
|
460
|
+
* below `download/[token]`, so dropping the last path segment finds it. Doing
|
|
461
|
+
* it this way rather than hardcoding `/download/{token}` keeps working under a
|
|
462
|
+
* Next `basePath` or any other prefix; `downloadPath` is the escape hatch for
|
|
463
|
+
* consumers who mount the two routes somewhere else entirely.
|
|
464
|
+
*/
|
|
465
|
+
export function downloadRouteUrl(
|
|
466
|
+
manifestUrl: URL,
|
|
467
|
+
token: string,
|
|
468
|
+
downloadPath: string | undefined,
|
|
469
|
+
): URL {
|
|
470
|
+
if (downloadPath) {
|
|
471
|
+
return new URL(downloadPath.replace("{token}", encodeURIComponent(token)), manifestUrl);
|
|
472
|
+
}
|
|
473
|
+
const segments = manifestUrl.pathname.replace(/\/+$/, "").split("/");
|
|
474
|
+
segments.pop();
|
|
475
|
+
const url = new URL(manifestUrl.toString());
|
|
476
|
+
url.search = "";
|
|
477
|
+
url.hash = "";
|
|
478
|
+
url.pathname = segments.join("/") || "/";
|
|
479
|
+
return url;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Turn resolved zip entries into the `{ fileName, url }` manifest shape the
|
|
484
|
+
* service worker consumes. Entries carrying a `source` become same-origin
|
|
485
|
+
* `downloadUrl` links signed at fetch time; anything else falls back to a
|
|
486
|
+
* long-lived presigned URL.
|
|
487
|
+
*/
|
|
488
|
+
export async function buildManifestFiles(
|
|
404
489
|
storage: StorageProvider,
|
|
405
490
|
files: ZipFile[],
|
|
491
|
+
downloadUrl: URL,
|
|
406
492
|
): Promise<ZipManifest["files"]> {
|
|
407
493
|
return Promise.all(
|
|
408
|
-
files.map(async (
|
|
409
|
-
fileName:
|
|
410
|
-
url:
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
494
|
+
files.map(async (file) => ({
|
|
495
|
+
fileName: file.fileName,
|
|
496
|
+
url: file.source
|
|
497
|
+
? sourceUrl(downloadUrl, file.source)
|
|
498
|
+
: await storage.getPresignedDownloadUrl(file.storageKey, {
|
|
499
|
+
filename: file.fileName.split("/").pop() ?? file.fileName,
|
|
500
|
+
contentType: file.contentType,
|
|
501
|
+
expiresInSeconds: ZIP_MANIFEST_EXPIRES_IN_SECONDS,
|
|
502
|
+
}),
|
|
414
503
|
})),
|
|
415
504
|
);
|
|
416
505
|
}
|
|
417
506
|
|
|
507
|
+
/** Address one zip entry through `createDownloadHandler`'s query-param contract. */
|
|
508
|
+
function sourceUrl(downloadUrl: URL, source: ZipFileSource): string {
|
|
509
|
+
const url = new URL(downloadUrl.toString());
|
|
510
|
+
if (source.kind === "track") {
|
|
511
|
+
url.searchParams.set("trackId", String(source.trackId));
|
|
512
|
+
url.searchParams.set("format", source.format);
|
|
513
|
+
} else {
|
|
514
|
+
url.searchParams.set("asset", "cover");
|
|
515
|
+
url.searchParams.set("releaseId", String(source.releaseId));
|
|
516
|
+
}
|
|
517
|
+
return url.toString();
|
|
518
|
+
}
|
|
519
|
+
|
|
418
520
|
export type { Queries };
|