@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.
@@ -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
@@ -16,4 +16,5 @@ export type {
16
16
  PurchaseConfirmationDeps,
17
17
  ResolvedBundle,
18
18
  WebhookDeps,
19
+ ZipEntryFailure,
19
20
  } from "./types.js";
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 "./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 };