@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/checkout.ts
CHANGED
|
@@ -32,20 +32,24 @@ function resolveUnitAmount(
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* Split a bundle's charged total across its
|
|
36
|
-
*
|
|
35
|
+
* Split a bundle's charged total across its members, proportional to list
|
|
36
|
+
* price, using largest-remainder allocation.
|
|
37
|
+
*
|
|
38
|
+
* Members are keyed by their `amounts`-metadata key (`r<id>` / `t<id>`) rather
|
|
39
|
+
* than a bare numeric id, so a bundle holding tracks can't have a track id
|
|
40
|
+
* collide with a release id.
|
|
37
41
|
*
|
|
38
42
|
* The returned values sum to `totalCents` exactly. That's load-bearing: they
|
|
39
43
|
* become the `amounts` metadata `fulfillCheckoutSession` turns into
|
|
40
44
|
* `order_items.price` rows, and `sum(order_items) === orders.amountTotal` is
|
|
41
45
|
* what refunds and admin order views rely on. Ties break deterministically
|
|
42
|
-
* (remainder desc, price desc,
|
|
46
|
+
* (remainder desc, price desc, key asc) so a retry apportions identically.
|
|
43
47
|
*/
|
|
44
48
|
function apportion(
|
|
45
49
|
totalCents: number,
|
|
46
|
-
members: {
|
|
47
|
-
): Map<
|
|
48
|
-
const result = new Map<
|
|
50
|
+
members: { key: string; price: number }[],
|
|
51
|
+
): Map<string, number> {
|
|
52
|
+
const result = new Map<string, number>();
|
|
49
53
|
if (members.length === 0) return result;
|
|
50
54
|
|
|
51
55
|
const total = Math.max(0, Math.round(totalCents));
|
|
@@ -61,8 +65,8 @@ function apportion(
|
|
|
61
65
|
let remainder = total - base.reduce((s, v) => s + v, 0);
|
|
62
66
|
|
|
63
67
|
const order = members
|
|
64
|
-
.map((m, i) => ({ i, frac: exact[i]! - base[i]!, price: m.price,
|
|
65
|
-
.sort((a, b) => b.frac - a.frac || b.price - a.price || a.
|
|
68
|
+
.map((m, i) => ({ i, frac: exact[i]! - base[i]!, price: m.price, key: m.key }))
|
|
69
|
+
.sort((a, b) => b.frac - a.frac || b.price - a.price || a.key.localeCompare(b.key));
|
|
66
70
|
|
|
67
71
|
for (const { i } of order) {
|
|
68
72
|
if (remainder <= 0) break;
|
|
@@ -70,7 +74,7 @@ function apportion(
|
|
|
70
74
|
remainder -= 1;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
members.forEach((m, i) => result.set(m.
|
|
77
|
+
members.forEach((m, i) => result.set(m.key, base[i]!));
|
|
74
78
|
return result;
|
|
75
79
|
}
|
|
76
80
|
|
|
@@ -284,20 +288,29 @@ export function createCheckoutHandler(
|
|
|
284
288
|
});
|
|
285
289
|
|
|
286
290
|
// Bundles resolve against the same published-release snapshot as the
|
|
287
|
-
// loose lines, so member prices, names, and covers stay consistent.
|
|
291
|
+
// loose lines, so member prices, names, and covers stay consistent. A
|
|
292
|
+
// bundle may hold releases, tracks, or both — the package doesn't care
|
|
293
|
+
// which; the consumer decides what a bundle is allowed to contain.
|
|
288
294
|
const bundleLineItems: typeof releaseLineItems = [];
|
|
289
|
-
const
|
|
295
|
+
const bundleReleaseMemberIds: number[] = [];
|
|
296
|
+
const bundleTrackMemberIds: number[] = [];
|
|
290
297
|
const unavailableBundleIds: string[] = [];
|
|
291
298
|
|
|
292
299
|
for (const bundleId of bundleIds) {
|
|
293
300
|
const resolved = await bundlePurchase!(bundleId);
|
|
294
|
-
const
|
|
301
|
+
const releaseMembers = (resolved?.releaseIds ?? [])
|
|
295
302
|
.map((id) => releaseById.get(id))
|
|
296
303
|
.filter((r): r is ReleaseWithTracks => r !== undefined);
|
|
304
|
+
const trackMembers = (resolved?.trackIds ?? [])
|
|
305
|
+
.map((id) => trackContext.get(id))
|
|
306
|
+
.filter(
|
|
307
|
+
(c): c is { track: TrackWithFiles; release: ReleaseWithTracks } =>
|
|
308
|
+
c !== undefined,
|
|
309
|
+
);
|
|
297
310
|
|
|
298
311
|
// No such bundle, or nothing in it is published any more. Charging for
|
|
299
312
|
// the latter would produce a paid order with zero items.
|
|
300
|
-
if (!resolved ||
|
|
313
|
+
if (!resolved || releaseMembers.length + trackMembers.length === 0) {
|
|
301
314
|
unavailableBundleIds.push(bundleId);
|
|
302
315
|
continue;
|
|
303
316
|
}
|
|
@@ -306,7 +319,11 @@ export function createCheckoutHandler(
|
|
|
306
319
|
STRIPE_MIN_CHARGE_CENTS,
|
|
307
320
|
Math.round(resolved.totalCents),
|
|
308
321
|
);
|
|
309
|
-
|
|
322
|
+
// A track carries no cover of its own, so a track-only bundle falls
|
|
323
|
+
// back to the cover of the release the first member belongs to.
|
|
324
|
+
const cover =
|
|
325
|
+
releaseMembers.find((m) => m.coverImageUrl)?.coverImageUrl ??
|
|
326
|
+
trackMembers.find((c) => c.release.coverImageUrl)?.release.coverImageUrl;
|
|
310
327
|
bundleLineItems.push({
|
|
311
328
|
price_data: {
|
|
312
329
|
currency,
|
|
@@ -319,13 +336,16 @@ export function createCheckoutHandler(
|
|
|
319
336
|
quantity: 1 as const,
|
|
320
337
|
});
|
|
321
338
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
339
|
+
// Apportioned jointly across both member kinds — the total has to land
|
|
340
|
+
// on the members as one set, not split per kind and rounded twice.
|
|
341
|
+
for (const [key, cents] of apportion(total, [
|
|
342
|
+
...releaseMembers.map((m) => ({ key: `r${m.id}`, price: m.price })),
|
|
343
|
+
...trackMembers.map(({ track }) => ({ key: `t${track.id}`, price: track.price })),
|
|
344
|
+
])) {
|
|
345
|
+
chargedAmounts[key] = (chargedAmounts[key] ?? 0) + cents;
|
|
328
346
|
}
|
|
347
|
+
bundleReleaseMemberIds.push(...releaseMembers.map((m) => m.id));
|
|
348
|
+
bundleTrackMemberIds.push(...trackMembers.map(({ track }) => track.id));
|
|
329
349
|
}
|
|
330
350
|
|
|
331
351
|
if (unavailableBundleIds.length > 0) {
|
|
@@ -340,17 +360,21 @@ export function createCheckoutHandler(
|
|
|
340
360
|
return jsonError("No valid items found", 400);
|
|
341
361
|
}
|
|
342
362
|
|
|
343
|
-
// Deduped: a release reachable from both a bundle and a loose
|
|
344
|
-
// yields one order item, whose price is the summed `amounts`
|
|
363
|
+
// Deduped: a release or track reachable from both a bundle and a loose
|
|
364
|
+
// line still yields one order item, whose price is the summed `amounts`
|
|
365
|
+
// entry.
|
|
345
366
|
const resolvedReleaseIds = [
|
|
346
367
|
...new Set([
|
|
347
368
|
...(releaseLineItems.length ? releaseIds.filter((id) => releaseById.has(id)) : []),
|
|
348
|
-
...
|
|
369
|
+
...bundleReleaseMemberIds,
|
|
370
|
+
]),
|
|
371
|
+
];
|
|
372
|
+
const resolvedTrackIds = [
|
|
373
|
+
...new Set([
|
|
374
|
+
...(trackLineItems.length ? trackIds.filter((id) => trackContext.has(id)) : []),
|
|
375
|
+
...bundleTrackMemberIds,
|
|
349
376
|
]),
|
|
350
377
|
];
|
|
351
|
-
const resolvedTrackIds = trackLineItems.length
|
|
352
|
-
? [...new Set(trackIds.filter((id) => trackContext.has(id)))]
|
|
353
|
-
: [];
|
|
354
378
|
|
|
355
379
|
const session = await stripe.checkout.sessions.create({
|
|
356
380
|
mode: "payment",
|
package/src/download.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { NextRequest } from "next/server";
|
|
2
|
-
import type { Queries
|
|
2
|
+
import type { Queries } from "@gigamusic/db";
|
|
3
3
|
import type { DownloadDeps } from "./types.js";
|
|
4
|
+
import { cleanDownloadFilename, coverArtFilename } from "./filenames.js";
|
|
4
5
|
|
|
5
6
|
interface RouteContext {
|
|
6
7
|
params: Promise<{ token: string }>;
|
|
@@ -13,9 +14,34 @@ interface RouteContext {
|
|
|
13
14
|
* URL — the storage provider has `Content-Disposition: attachment` baked in,
|
|
14
15
|
* so the browser triggers a same-tab download.
|
|
15
16
|
*
|
|
16
|
-
* Order of checks: locate the file
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* Order of checks: locate the file first (so a missing-file is 404), *then*
|
|
18
|
+
* enforce the token-track grant (a wrong trackId for the given token is 403,
|
|
19
|
+
* not 404).
|
|
20
|
+
*
|
|
21
|
+
* One extra job beyond serving the order page's per-track buttons, in service
|
|
22
|
+
* of `createDownloadZipHandler`, whose manifest points every zip entry back
|
|
23
|
+
* here so the R2 signature is minted when the service worker actually reaches
|
|
24
|
+
* that file (see `zip.ts`): `?asset=cover&releaseId=…` serves a release's
|
|
25
|
+
* cover art, which isn't track-keyed and so has no `trackId` to address it by.
|
|
26
|
+
* That branch checks `queries.tokenGrantsRelease` instead.
|
|
27
|
+
*
|
|
28
|
+
* ### Lookups are keyed, not scanned
|
|
29
|
+
*
|
|
30
|
+
* Both branches resolve their target with a single keyed query
|
|
31
|
+
* (`getTrackFile` / `getReleaseById`). They must not go back to
|
|
32
|
+
* `listPublishedReleases()`: that pulls every published release with its
|
|
33
|
+
* tracks and files, and because zip manifests fan every entry through this
|
|
34
|
+
* route, one bulk download would issue that read once per file — dozens of
|
|
35
|
+
* full-catalog scans to serve a single archive, against the most expensive
|
|
36
|
+
* resource these sites have.
|
|
37
|
+
*
|
|
38
|
+
* Neither keyed query filters on `isPublished`, which is deliberate and is
|
|
39
|
+
* also what makes them a complete replacement for the catalog walk rather
|
|
40
|
+
* than a narrowing of it. Ownership is decided by the grant check below, not
|
|
41
|
+
* by catalog membership, so a release unpublished after purchase and an
|
|
42
|
+
* à-la-carte track belonging to no release both stay reachable to the customer
|
|
43
|
+
* who bought them — exactly the cases the zip resolver lists, since it reads
|
|
44
|
+
* the order rather than the catalog.
|
|
19
45
|
*/
|
|
20
46
|
export function createDownloadHandler(
|
|
21
47
|
deps: DownloadDeps,
|
|
@@ -25,9 +51,13 @@ export function createDownloadHandler(
|
|
|
25
51
|
return async (req: NextRequest, ctx: RouteContext): Promise<Response> => {
|
|
26
52
|
const { token } = await ctx.params;
|
|
27
53
|
const requestUrl = new URL(req.url);
|
|
28
|
-
const trackIdRaw = requestUrl.searchParams.get("trackId");
|
|
29
54
|
const format = requestUrl.searchParams.get("format") ?? "mp3";
|
|
30
55
|
|
|
56
|
+
if (requestUrl.searchParams.get("asset") === "cover") {
|
|
57
|
+
return handleCoverArt(deps, token, requestUrl.searchParams.get("releaseId"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const trackIdRaw = requestUrl.searchParams.get("trackId");
|
|
31
61
|
if (!trackIdRaw) {
|
|
32
62
|
return Response.json({ error: "Missing trackId" }, { status: 400 });
|
|
33
63
|
}
|
|
@@ -41,7 +71,7 @@ export function createDownloadHandler(
|
|
|
41
71
|
return Response.json({ error: "Invalid download link" }, { status: 404 });
|
|
42
72
|
}
|
|
43
73
|
|
|
44
|
-
const file = await
|
|
74
|
+
const file = await queries.getTrackFile(trackId, format);
|
|
45
75
|
if (!file) {
|
|
46
76
|
return Response.json({ error: "File not found" }, { status: 404 });
|
|
47
77
|
}
|
|
@@ -59,27 +89,44 @@ export function createDownloadHandler(
|
|
|
59
89
|
};
|
|
60
90
|
}
|
|
61
91
|
|
|
62
|
-
/**
|
|
63
|
-
async function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
): Promise<
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
92
|
+
/** Serve `?asset=cover&releaseId=…` — the release-keyed twin of the track branch above. */
|
|
93
|
+
async function handleCoverArt(
|
|
94
|
+
deps: DownloadDeps,
|
|
95
|
+
token: string,
|
|
96
|
+
releaseIdRaw: string | null,
|
|
97
|
+
): Promise<Response> {
|
|
98
|
+
const { queries, storage } = deps;
|
|
99
|
+
|
|
100
|
+
if (!releaseIdRaw) {
|
|
101
|
+
return Response.json({ error: "Missing releaseId" }, { status: 400 });
|
|
102
|
+
}
|
|
103
|
+
const releaseId = Number(releaseIdRaw);
|
|
104
|
+
if (!Number.isFinite(releaseId)) {
|
|
105
|
+
return Response.json({ error: "Invalid releaseId" }, { status: 400 });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const downloadToken = await queries.getDownloadToken(token);
|
|
109
|
+
if (!downloadToken) {
|
|
110
|
+
return Response.json({ error: "Invalid download link" }, { status: 404 });
|
|
74
111
|
}
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
112
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
113
|
+
const release = await queries.getReleaseById(releaseId);
|
|
114
|
+
if (!release?.coverImageUrl) {
|
|
115
|
+
return Response.json({ error: "File not found" }, { status: 404 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ok = await queries.tokenGrantsRelease(token, releaseId);
|
|
119
|
+
if (!ok) {
|
|
120
|
+
return Response.json({ error: "Release not in order" }, { status: 403 });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const url = await storage.getPresignedDownloadUrl(release.coverImageUrl, {
|
|
124
|
+
filename: coverArtFilename(release.name),
|
|
125
|
+
contentType: "image/jpeg",
|
|
126
|
+
});
|
|
127
|
+
return Response.redirect(url, 302);
|
|
83
128
|
}
|
|
84
129
|
|
|
130
|
+
export { cleanDownloadFilename };
|
|
131
|
+
|
|
85
132
|
export type { Queries };
|
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
|
@@ -74,9 +74,10 @@ export interface CheckoutDeps {
|
|
|
74
74
|
*
|
|
75
75
|
* Called once per distinct `bundleId` in the cart. As with the catalog, the
|
|
76
76
|
* consumer owns pricing math: hand back the final `totalCents` and the
|
|
77
|
-
*
|
|
78
|
-
* members (proportional to list price,
|
|
79
|
-
* order items sum to exactly what Stripe
|
|
77
|
+
* members (`releaseIds`, `trackIds`, or both), and the handler apportions
|
|
78
|
+
* the total across those members (proportional to list price,
|
|
79
|
+
* largest-remainder) so the recorded order items sum to exactly what Stripe
|
|
80
|
+
* charged.
|
|
80
81
|
*
|
|
81
82
|
* Resolving to `null`/`undefined` means "no such bundle" — the handler
|
|
82
83
|
* replies 409 `{ error: "bundle-unavailable", bundleIds }` so the cart can
|
|
@@ -88,7 +89,14 @@ export interface CheckoutDeps {
|
|
|
88
89
|
) => Promise<ResolvedBundle | null | undefined> | ResolvedBundle | null | undefined;
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* What `bundlePurchase` returns for a bundle the consumer recognises.
|
|
94
|
+
*
|
|
95
|
+
* A bundle's members are `releaseIds`, `trackIds`, or both — the package
|
|
96
|
+
* imposes no rule about mixing them, so a consumer is free to model bundles as
|
|
97
|
+
* releases-only, singles-only, or a combination. Supply at least one member
|
|
98
|
+
* list; a bundle that resolves to no surviving member is reported unavailable.
|
|
99
|
+
*/
|
|
92
100
|
export interface ResolvedBundle {
|
|
93
101
|
/** Final charged price for the whole bundle, in cents. Clamped up to the Stripe card minimum. */
|
|
94
102
|
totalCents: number;
|
|
@@ -96,10 +104,16 @@ export interface ResolvedBundle {
|
|
|
96
104
|
productName: string;
|
|
97
105
|
/**
|
|
98
106
|
* Member release ids. Resolved against `listPublishedReleases()` — ids that
|
|
99
|
-
* aren't published are dropped, and a bundle left with
|
|
100
|
-
* unavailable rather than charging for an order with no items.
|
|
107
|
+
* aren't published are dropped, and a bundle left with no members at all is
|
|
108
|
+
* treated as unavailable rather than charging for an order with no items.
|
|
109
|
+
*/
|
|
110
|
+
releaseIds?: number[];
|
|
111
|
+
/**
|
|
112
|
+
* Member track ids — individual songs, for a "bundle of singles". Resolved
|
|
113
|
+
* against the tracks of `listPublishedReleases()`, so a track whose release
|
|
114
|
+
* is unpublished is dropped on the same terms as an unpublished release.
|
|
101
115
|
*/
|
|
102
|
-
|
|
116
|
+
trackIds?: number[];
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
/** What `fulfillCheckoutSession` needs to turn a paid Stripe session into an order row. */
|
|
@@ -154,9 +168,50 @@ export interface WebhookDeps extends FulfillSessionDeps, PurchaseConfirmationDep
|
|
|
154
168
|
webhookSecret: string;
|
|
155
169
|
}
|
|
156
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
|
+
|
|
157
191
|
export interface DownloadDeps {
|
|
158
192
|
queries: Queries;
|
|
159
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;
|
|
160
215
|
/**
|
|
161
216
|
* Optional artist-name prefix prepended to zip filenames — both the SW
|
|
162
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);
|