@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/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 "./download.js";
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 presigned R2 URLs through
95
- * `client-zip` and streams the archive straight from R2 to the browser, with
96
- * the Vercel function never touching audio bytes.
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 presignZipFiles(storage, resolution.files),
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
- } satisfies ZipFile;
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.name, release.coverImageUrl, "", files));
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.name,
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
- releaseName: string,
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(releaseName);
432
+ const file = coverArtFilename(release.name);
433
+ const source: ZipFileSource = { kind: "cover", releaseId: release.id };
389
434
  const entries: ZipFile[] = [
390
- { fileName: `${folder}${file}`, storageKey: coverImageUrl, contentType: "image/jpeg" },
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
- /** Presign every zip entry into the `{ fileName, url }` manifest shape the service worker consumes. */
403
- async function presignZipFiles(
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 (t) => ({
409
- fileName: t.fileName,
410
- url: await storage.getPresignedDownloadUrl(t.storageKey, {
411
- filename: t.fileName.split("/").pop() ?? t.fileName,
412
- contentType: t.contentType,
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 };