@gigamusic/checkout 4.6.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 +77 -8
- package/dist/index.d.ts +117 -15
- package/dist/index.js +152 -52
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/checkout.ts +50 -26
- package/src/download.ts +72 -25
- package/src/filenames.ts +23 -0
- package/src/index.ts +1 -0
- package/src/types.ts +62 -7
- package/src/zip-stream.ts +44 -2
- package/src/zip.ts +132 -30
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 };
|