@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 CHANGED
@@ -14,6 +14,58 @@ export const POST = createCheckoutHandler({
14
14
  });
15
15
  ```
16
16
 
17
+ ## Bulk downloads and URL expiry
18
+
19
+ `createDownloadZipHandler` returns a manifest of `{ fileName, url }` for the
20
+ service worker to stream through `client-zip`. Those URLs point back at
21
+ **`createDownloadHandler` on the same origin**, not directly at presigned
22
+ storage URLs — the 302 mints the signature at the moment the SW reaches that
23
+ entry.
24
+
25
+ That matters because the SW fetches entries strictly sequentially (a zip is a
26
+ sequential format; `client-zip` drains one body before opening the next), so a
27
+ multi-gigabyte bundle is still working through the list long after the click.
28
+ Signing every URL up front gave each one the storage default of 5 minutes;
29
+ everything past minute five 403'd, and because R2's signature failures carry no
30
+ CORS headers the SW saw an opaque `NetworkError` rather than a readable status
31
+ and wrote a `_FAILED_*.txt` placeholder — a broken download that looks
32
+ successful.
33
+
34
+ Two things follow for consumers:
35
+
36
+ - **`/download/[token]` must be mounted** for bulk downloads to work. It always
37
+ was, for the per-track buttons; it's now load-bearing for zips too. If the
38
+ manifest route isn't a direct child of it, set `downloadPath` (e.g.
39
+ `"/files/{token}/get"`).
40
+ - Cover art is addressed as `?asset=cover&releaseId=…`, tracks as
41
+ `?trackId=…&format=…`. Both check the token grant.
42
+
43
+ The manifest shape is unchanged, so an already-deployed service worker needs no
44
+ edits.
45
+
46
+ `createDownloadZipStreamHandler` (the server-side fallback) always signed
47
+ per-file inside its loop, so it never had the expiry bug. Its ceiling is the
48
+ platform function timeout instead — audio proxies through the function, so
49
+ large bundles need `export const maxDuration` raised in the route.
50
+
51
+ ### Knowing when a bulk download broke
52
+
53
+ The per-file `_FAILED_<name>.txt` policy keeps one bad object from corrupting
54
+ the whole archive, at the cost of making a broken purchase look like a
55
+ successful download. Pass `onZipEntryFailure` to hear about them:
56
+
57
+ ```ts
58
+ export const GET = createDownloadZipStreamHandler({
59
+ queries,
60
+ storage,
61
+ onZipEntryFailure: ({ token, fileName, reason }) =>
62
+ alerting.warn(`zip entry failed: ${fileName} (${token}): ${reason}`),
63
+ });
64
+ ```
65
+
66
+ Failures are also logged individually plus an `n/total entries failed` summary
67
+ line per request, so they're greppable without wiring anything up.
68
+
17
69
  ## Bundles — discounted subsets of the catalog
18
70
 
19
71
  `catalogPurchase` is all-or-nothing. For a curated pack ("the remix EPs", "2024
package/dist/index.d.ts CHANGED
@@ -158,9 +158,49 @@ interface WebhookDeps extends FulfillSessionDeps, PurchaseConfirmationDeps {
158
158
  stripeSecret?: string;
159
159
  webhookSecret: string;
160
160
  }
161
+ /**
162
+ * One entry `createDownloadZipStreamHandler` could not stream into the
163
+ * archive. The customer still gets a zip — the entry is replaced by a
164
+ * `_FAILED_<name>.txt` placeholder so one bad object doesn't corrupt the rest
165
+ * — which is exactly why the failure needs surfacing somewhere the operator
166
+ * will see it: from the outside, a half-empty archive looks like a successful
167
+ * download.
168
+ */
169
+ interface ZipEntryFailure {
170
+ /** Download token whose bundle was being streamed — identifies the order. */
171
+ token: string;
172
+ /** Path of the entry inside the archive, e.g. `"Album/track.wav"`. */
173
+ fileName: string;
174
+ storageKey: string;
175
+ /** Message from the failed storage fetch or mid-stream error. */
176
+ reason: string;
177
+ /** Bytes received before the failure; 0 when the fetch never got going. */
178
+ bytesReceived: number;
179
+ }
161
180
  interface DownloadDeps {
162
181
  queries: Queries;
163
182
  storage: StorageProvider;
183
+ /**
184
+ * Path of the single-file download route (`createDownloadHandler`) that
185
+ * `createDownloadZipHandler`'s manifest addresses its entries through, with
186
+ * `{token}` as the placeholder — e.g. `"/downloads/{token}/file"`.
187
+ *
188
+ * Defaults to the manifest route's own path minus its last segment, which
189
+ * resolves to `/download/[token]` under the layout `docs/setup.md`
190
+ * prescribes. Only set this if you mount the two routes such that the
191
+ * relationship doesn't hold.
192
+ */
193
+ downloadPath?: string;
194
+ /**
195
+ * Called by `createDownloadZipStreamHandler` once per entry it fails to
196
+ * stream, so a broken bulk download can raise an alert instead of silently
197
+ * shipping a zip full of `_FAILED_*.txt` placeholders. Failures are also
198
+ * logged, with a per-request summary line carrying the failed/total count.
199
+ *
200
+ * Errors thrown here are swallowed — reporting must never take down a
201
+ * download that is otherwise still producing bytes.
202
+ */
203
+ onZipEntryFailure?: (failure: ZipEntryFailure) => void;
164
204
  /**
165
205
  * Optional artist-name prefix prepended to zip filenames — both the SW
166
206
  * manifest and the server-side stream output use it. Example: passing
@@ -284,9 +324,34 @@ interface RouteContext$2 {
284
324
  * URL — the storage provider has `Content-Disposition: attachment` baked in,
285
325
  * so the browser triggers a same-tab download.
286
326
  *
287
- * Order of checks: locate the file in the catalog first (so a missing-file
288
- * is 404), *then* enforce the token-track grant (a wrong trackId for the
289
- * given token is 403, not 404).
327
+ * Order of checks: locate the file first (so a missing-file is 404), *then*
328
+ * enforce the token-track grant (a wrong trackId for the given token is 403,
329
+ * not 404).
330
+ *
331
+ * One extra job beyond serving the order page's per-track buttons, in service
332
+ * of `createDownloadZipHandler`, whose manifest points every zip entry back
333
+ * here so the R2 signature is minted when the service worker actually reaches
334
+ * that file (see `zip.ts`): `?asset=cover&releaseId=…` serves a release's
335
+ * cover art, which isn't track-keyed and so has no `trackId` to address it by.
336
+ * That branch checks `queries.tokenGrantsRelease` instead.
337
+ *
338
+ * ### Lookups are keyed, not scanned
339
+ *
340
+ * Both branches resolve their target with a single keyed query
341
+ * (`getTrackFile` / `getReleaseById`). They must not go back to
342
+ * `listPublishedReleases()`: that pulls every published release with its
343
+ * tracks and files, and because zip manifests fan every entry through this
344
+ * route, one bulk download would issue that read once per file — dozens of
345
+ * full-catalog scans to serve a single archive, against the most expensive
346
+ * resource these sites have.
347
+ *
348
+ * Neither keyed query filters on `isPublished`, which is deliberate and is
349
+ * also what makes them a complete replacement for the catalog walk rather
350
+ * than a narrowing of it. Ownership is decided by the grant check below, not
351
+ * by catalog membership, so a release unpublished after purchase and an
352
+ * à-la-carte track belonging to no release both stay reachable to the customer
353
+ * who bought them — exactly the cases the zip resolver lists, since it reads
354
+ * the order rather than the catalog.
290
355
  */
291
356
  declare function createDownloadHandler(deps: DownloadDeps): (req: NextRequest, ctx: RouteContext$2) => Promise<Response>;
292
357
 
@@ -298,9 +363,21 @@ interface RouteContext$1 {
298
363
  /**
299
364
  * Build the GET handler for the zip-manifest endpoint. Returns the JSON
300
365
  * manifest the consumer-shipped service worker (`public/sw-zip.js`)
301
- * consumes — the SW pipes presigned R2 URLs through
302
- * `client-zip` and streams the archive straight from R2 to the browser, with
303
- * the Vercel function never touching audio bytes.
366
+ * consumes — the SW pipes each URL through `client-zip` and streams the
367
+ * archive straight from storage to the browser, with the serverless function
368
+ * never touching audio bytes.
369
+ *
370
+ * Manifest URLs point back at `createDownloadHandler` on this same origin
371
+ * rather than directly at presigned R2 URLs. That route 302s to a *freshly*
372
+ * signed URL, so the signature is minted at the moment the SW reaches that
373
+ * entry — however many hours into the archive that is. Signing everything up
374
+ * front meant every URL in a multi-gigabyte bundle died 5 minutes after the
375
+ * click; see `ZIP_MANIFEST_EXPIRES_IN_SECONDS`. The redirect adds one cheap
376
+ * function invocation per file and gives failures a server-side trace they
377
+ * never had while the SW talked straight to R2.
378
+ *
379
+ * The manifest shape (`{ zipName, files: [{ fileName, url }] }`) is unchanged,
380
+ * so an already-deployed service worker keeps working without modification.
304
381
  *
305
382
  * Query-param branches:
306
383
  * - `trackIds` set → curated track list, flat layout
@@ -332,7 +409,18 @@ interface RouteContext {
332
409
  *
333
410
  * Per-file failure policy matches the SW: any failed storage fetch becomes
334
411
  * a `_FAILED_<name>.txt` placeholder so one bad object doesn't taint the
335
- * whole archive.
412
+ * whole archive. Because that turns a broken purchase into an
413
+ * apparently-successful download, every failure is reported through
414
+ * `deps.onZipEntryFailure` and summarised in a single log line at the end of
415
+ * the request.
416
+ *
417
+ * Unlike the manifest path, this handler presigns each file *inside* the loop,
418
+ * immediately before fetching it, so signature expiry can't bite. Its own
419
+ * ceiling is the platform's function timeout: audio bytes proxy through the
420
+ * function, so the whole archive has to be produced within `maxDuration`
421
+ * (Vercel: 300s by default, up to 800s on Fluid compute). Multi-gigabyte
422
+ * bundles need that raised in the consuming route — `export const maxDuration`
423
+ * — or they'll be cut off mid-stream.
336
424
  */
337
425
  declare function createDownloadZipStreamHandler(deps: DownloadDeps): (req: NextRequest, ctx: RouteContext) => Promise<Response>;
338
426
 
@@ -353,4 +441,4 @@ declare function createDownloadZipStreamHandler(deps: DownloadDeps): (req: NextR
353
441
  */
354
442
  declare function createSwZipFallbackHandler(): () => Response;
355
443
 
356
- export { type CheckoutCartItem, type CheckoutDeps, type DownloadDeps, type FulfillSessionDeps, type FulfillSessionResult, type PurchaseConfirmationDeps, type ResolvedBundle, type WebhookDeps, createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler, fulfillCheckoutSession, sendPurchaseConfirmation };
444
+ export { type CheckoutCartItem, type CheckoutDeps, type DownloadDeps, type FulfillSessionDeps, type FulfillSessionResult, type PurchaseConfirmationDeps, type ResolvedBundle, type WebhookDeps, type ZipEntryFailure, createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler, fulfillCheckoutSession, sendPurchaseConfirmation };
package/dist/index.js CHANGED
@@ -433,14 +433,30 @@ function createStripeWebhookHandler(deps) {
433
433
  };
434
434
  }
435
435
 
436
+ // src/filenames.ts
437
+ function cleanDownloadFilename(name) {
438
+ const dot = name.lastIndexOf(".");
439
+ if (dot <= 0) return name.trim();
440
+ return `${name.slice(0, dot).trim()}${name.slice(dot).trim()}`;
441
+ }
442
+ function sanitizeSegment(name) {
443
+ return name.replace(/[/\\]+/g, "-").trim();
444
+ }
445
+ function coverArtFilename(releaseName) {
446
+ return `${sanitizeSegment(releaseName)} - COVER ART.jpg`;
447
+ }
448
+
436
449
  // src/download.ts
437
450
  function createDownloadHandler(deps) {
438
451
  const { queries, storage } = deps;
439
452
  return async (req, ctx) => {
440
453
  const { token } = await ctx.params;
441
454
  const requestUrl = new URL(req.url);
442
- const trackIdRaw = requestUrl.searchParams.get("trackId");
443
455
  const format = requestUrl.searchParams.get("format") ?? "mp3";
456
+ if (requestUrl.searchParams.get("asset") === "cover") {
457
+ return handleCoverArt(deps, token, requestUrl.searchParams.get("releaseId"));
458
+ }
459
+ const trackIdRaw = requestUrl.searchParams.get("trackId");
444
460
  if (!trackIdRaw) {
445
461
  return Response.json({ error: "Missing trackId" }, { status: 400 });
446
462
  }
@@ -452,7 +468,7 @@ function createDownloadHandler(deps) {
452
468
  if (!downloadToken) {
453
469
  return Response.json({ error: "Invalid download link" }, { status: 404 });
454
470
  }
455
- const file = await findTrackFileInCatalog(queries, trackId, format);
471
+ const file = await queries.getTrackFile(trackId, format);
456
472
  if (!file) {
457
473
  return Response.json({ error: "File not found" }, { status: 404 });
458
474
  }
@@ -467,23 +483,36 @@ function createDownloadHandler(deps) {
467
483
  return Response.redirect(url, 302);
468
484
  };
469
485
  }
470
- async function findTrackFileInCatalog(queries, trackId, format) {
471
- const releases = await queries.listPublishedReleases();
472
- for (const release of releases) {
473
- const track = release.tracks.find((t) => t.id === trackId);
474
- if (!track) continue;
475
- const file = track.files.find((f) => f.format === format);
476
- return file ?? null;
486
+ async function handleCoverArt(deps, token, releaseIdRaw) {
487
+ const { queries, storage } = deps;
488
+ if (!releaseIdRaw) {
489
+ return Response.json({ error: "Missing releaseId" }, { status: 400 });
477
490
  }
478
- return null;
479
- }
480
- function cleanDownloadFilename(name) {
481
- const dot = name.lastIndexOf(".");
482
- if (dot <= 0) return name.trim();
483
- return `${name.slice(0, dot).trim()}${name.slice(dot).trim()}`;
491
+ const releaseId = Number(releaseIdRaw);
492
+ if (!Number.isFinite(releaseId)) {
493
+ return Response.json({ error: "Invalid releaseId" }, { status: 400 });
494
+ }
495
+ const downloadToken = await queries.getDownloadToken(token);
496
+ if (!downloadToken) {
497
+ return Response.json({ error: "Invalid download link" }, { status: 404 });
498
+ }
499
+ const release = await queries.getReleaseById(releaseId);
500
+ if (!release?.coverImageUrl) {
501
+ return Response.json({ error: "File not found" }, { status: 404 });
502
+ }
503
+ const ok = await queries.tokenGrantsRelease(token, releaseId);
504
+ if (!ok) {
505
+ return Response.json({ error: "Release not in order" }, { status: 403 });
506
+ }
507
+ const url = await storage.getPresignedDownloadUrl(release.coverImageUrl, {
508
+ filename: coverArtFilename(release.name),
509
+ contentType: "image/jpeg"
510
+ });
511
+ return Response.redirect(url, 302);
484
512
  }
485
513
 
486
514
  // src/zip.ts
515
+ var ZIP_MANIFEST_EXPIRES_IN_SECONDS = 12 * 60 * 60;
487
516
  function applyZipNamePrefix(prefix, base) {
488
517
  const trimmed = prefix?.trim();
489
518
  return trimmed ? `${trimmed} - ${base}` : base;
@@ -529,7 +558,11 @@ function createDownloadZipHandler(deps) {
529
558
  }
530
559
  const manifest = {
531
560
  zipName: resolution.zipName,
532
- files: await presignZipFiles(storage, resolution.files)
561
+ files: await buildManifestFiles(
562
+ storage,
563
+ resolution.files,
564
+ downloadRouteUrl(url, token, deps.downloadPath)
565
+ )
533
566
  };
534
567
  return Response.json(manifest);
535
568
  };
@@ -565,7 +598,8 @@ function resolveTrackList(order, trackIdsParam, format, audioContentType, fmt, z
565
598
  files.push({
566
599
  fileName: zipEntryPath(ctx.release?.name ?? null, file.fileName),
567
600
  storageKey: file.storageKey,
568
- contentType: audioContentType
601
+ contentType: audioContentType,
602
+ source: { kind: "track", trackId: id, format }
569
603
  });
570
604
  }
571
605
  if (files.length === 0) {
@@ -596,11 +630,12 @@ function resolveSingleRelease(order, releaseId, format, audioContentType, fmt, z
596
630
  return {
597
631
  fileName: zipEntryPath(null, file.fileName),
598
632
  storageKey: file.storageKey,
599
- contentType: audioContentType
633
+ contentType: audioContentType,
634
+ source: { kind: "track", trackId: track.id, format }
600
635
  };
601
636
  }).filter((f) => f !== null);
602
637
  if (files.length > 0 && release.coverImageUrl) {
603
- files.push(...coverArtEntries(release.name, release.coverImageUrl, "", files));
638
+ files.push(...coverArtEntries(release, release.coverImageUrl, "", files));
604
639
  }
605
640
  if (files.length === 0) {
606
641
  return {
@@ -628,13 +663,14 @@ function resolveWholeOrder(order, format, audioContentType, fmt, zipNamePrefix)
628
663
  entries.push({
629
664
  fileName: zipEntryPath(release.name, file.fileName),
630
665
  storageKey: file.storageKey,
631
- contentType: audioContentType
666
+ contentType: audioContentType,
667
+ source: { kind: "track", trackId: track.id, format }
632
668
  });
633
669
  }
634
670
  if (entries.length > 0 && release.coverImageUrl) {
635
671
  entries.push(
636
672
  ...coverArtEntries(
637
- release.name,
673
+ release,
638
674
  release.coverImageUrl,
639
675
  `${sanitizeSegment(release.name)}/`,
640
676
  entries
@@ -650,7 +686,8 @@ function resolveWholeOrder(order, format, audioContentType, fmt, zipNamePrefix)
650
686
  aLaCarteFiles.push({
651
687
  fileName: zipEntryPath(release?.name ?? null, file.fileName),
652
688
  storageKey: file.storageKey,
653
- contentType: audioContentType
689
+ contentType: audioContentType,
690
+ source: { kind: "track", trackId: track.id, format }
654
691
  });
655
692
  }
656
693
  }
@@ -688,9 +725,6 @@ function trackOwnership(order) {
688
725
  locate: (id) => byId.get(id) ?? null
689
726
  };
690
727
  }
691
- function sanitizeSegment(name) {
692
- return name.replace(/[/\\]+/g, "-").trim();
693
- }
694
728
  function isExtendedMix(fileName) {
695
729
  return /\bextended\b/i.test(fileName);
696
730
  }
@@ -702,34 +736,62 @@ function zipEntryPath(releaseName, fileName) {
702
736
  segments.push(clean);
703
737
  return segments.join("/");
704
738
  }
705
- function coverArtFilename(releaseName) {
706
- return `${sanitizeSegment(releaseName)} - COVER ART.jpg`;
707
- }
708
- function coverArtEntries(releaseName, coverImageUrl, folder, trackEntries) {
709
- const file = coverArtFilename(releaseName);
739
+ function coverArtEntries(release, coverImageUrl, folder, trackEntries) {
740
+ const file = coverArtFilename(release.name);
741
+ const source = { kind: "cover", releaseId: release.id };
710
742
  const entries = [
711
- { fileName: `${folder}${file}`, storageKey: coverImageUrl, contentType: "image/jpeg" }
743
+ {
744
+ fileName: `${folder}${file}`,
745
+ storageKey: coverImageUrl,
746
+ contentType: "image/jpeg",
747
+ source
748
+ }
712
749
  ];
713
750
  if (trackEntries.some((e) => e.fileName.split("/").includes("Extended"))) {
714
751
  entries.push({
715
752
  fileName: `${folder}Extended/${file}`,
716
753
  storageKey: coverImageUrl,
717
- contentType: "image/jpeg"
754
+ contentType: "image/jpeg",
755
+ source
718
756
  });
719
757
  }
720
758
  return entries;
721
759
  }
722
- async function presignZipFiles(storage, files) {
760
+ function downloadRouteUrl(manifestUrl, token, downloadPath) {
761
+ if (downloadPath) {
762
+ return new URL(downloadPath.replace("{token}", encodeURIComponent(token)), manifestUrl);
763
+ }
764
+ const segments = manifestUrl.pathname.replace(/\/+$/, "").split("/");
765
+ segments.pop();
766
+ const url = new URL(manifestUrl.toString());
767
+ url.search = "";
768
+ url.hash = "";
769
+ url.pathname = segments.join("/") || "/";
770
+ return url;
771
+ }
772
+ async function buildManifestFiles(storage, files, downloadUrl) {
723
773
  return Promise.all(
724
- files.map(async (t) => ({
725
- fileName: t.fileName,
726
- url: await storage.getPresignedDownloadUrl(t.storageKey, {
727
- filename: t.fileName.split("/").pop() ?? t.fileName,
728
- contentType: t.contentType
774
+ files.map(async (file) => ({
775
+ fileName: file.fileName,
776
+ url: file.source ? sourceUrl(downloadUrl, file.source) : await storage.getPresignedDownloadUrl(file.storageKey, {
777
+ filename: file.fileName.split("/").pop() ?? file.fileName,
778
+ contentType: file.contentType,
779
+ expiresInSeconds: ZIP_MANIFEST_EXPIRES_IN_SECONDS
729
780
  })
730
781
  }))
731
782
  );
732
783
  }
784
+ function sourceUrl(downloadUrl, source) {
785
+ const url = new URL(downloadUrl.toString());
786
+ if (source.kind === "track") {
787
+ url.searchParams.set("trackId", String(source.trackId));
788
+ url.searchParams.set("format", source.format);
789
+ } else {
790
+ url.searchParams.set("asset", "cover");
791
+ url.searchParams.set("releaseId", String(source.releaseId));
792
+ }
793
+ return url.toString();
794
+ }
733
795
  function createDownloadZipStreamHandler(deps) {
734
796
  const { queries, storage } = deps;
735
797
  return async (req, ctx) => {
@@ -751,6 +813,15 @@ function createDownloadZipStreamHandler(deps) {
751
813
  console.error("[zip-stream] archiver error:", err);
752
814
  });
753
815
  req.signal.addEventListener("abort", () => archive.abort());
816
+ const failures = [];
817
+ const reportFailure = (failure) => {
818
+ failures.push(failure);
819
+ try {
820
+ deps.onZipEntryFailure?.(failure);
821
+ } catch (err) {
822
+ console.warn("[zip-stream] onZipEntryFailure threw:", err);
823
+ }
824
+ };
754
825
  (async () => {
755
826
  for (const file of resolution.files) {
756
827
  if (req.signal.aborted) return;
@@ -783,6 +854,13 @@ function createDownloadZipStreamHandler(deps) {
783
854
  console.error(
784
855
  `[zip-stream] body stream error for ${file.fileName} (storageKey=${file.storageKey}, bytesReceived=${bytesReceived}): ${detail}`
785
856
  );
857
+ reportFailure({
858
+ token,
859
+ fileName: file.fileName,
860
+ storageKey: file.storageKey,
861
+ reason: detail,
862
+ bytesReceived
863
+ });
786
864
  try {
787
865
  archive.append(
788
866
  `Streaming "${file.fileName}" from storage failed after ${bytesReceived} bytes: ${detail}.
@@ -801,6 +879,13 @@ Try downloading the track individually from your order page.
801
879
  if (req.signal.aborted) return;
802
880
  const detail = err instanceof Error ? err.message : String(err);
803
881
  console.warn(`[zip-stream] fetch failed for ${file.fileName}:`, detail);
882
+ reportFailure({
883
+ token,
884
+ fileName: file.fileName,
885
+ storageKey: file.storageKey,
886
+ reason: detail,
887
+ bytesReceived: 0
888
+ });
804
889
  archive.append(
805
890
  `Failed to download "${file.fileName}" from storage: ${detail}.
806
891
  Try downloading the track individually from your order page.
@@ -809,6 +894,11 @@ Try downloading the track individually from your order page.
809
894
  );
810
895
  }
811
896
  }
897
+ if (failures.length > 0) {
898
+ console.error(
899
+ `[zip-stream] ${failures.length}/${resolution.files.length} entries failed for token ${token}: ${failures.map((f) => f.fileName).join(", ")}`
900
+ );
901
+ }
812
902
  await archive.finalize();
813
903
  })().catch((err) => {
814
904
  console.error("[zip-stream] pipeline error:", err);