gm-pantry 0.2.0 → 0.3.1

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/dist/s3.js ADDED
@@ -0,0 +1,453 @@
1
+ // Publishing a repo to S3-compatible storage (AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi): `gm-pantry/s3`.
2
+ // Browser-safe: SigV4 by aws4fetch, `fetch` for transport, no Node APIs.
3
+ import { AwsClient } from "aws4fetch";
4
+ export class S3Error extends Error {
5
+ status;
6
+ code;
7
+ constructor(status, code, message) {
8
+ super(`${status} ${code}: ${message}`);
9
+ this.status = status;
10
+ this.code = code;
11
+ }
12
+ }
13
+ /** repo.json changed under the publisher (or did not match the ETag it was loaded with). */
14
+ export class ConflictError extends Error {
15
+ }
16
+ const MiB = 1024 * 1024;
17
+ const MAX_PARTS = 10_000;
18
+ const encoder = new TextEncoder();
19
+ const xmlEscape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20
+ const xmlUnescape = (s) => s.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
21
+ const tags = (xml, name) => [...xml.matchAll(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, "g"))].map((m) => xmlUnescape(m[1]));
22
+ export const choosePartSize = (size, requested = 16 * MiB) => Math.max(5 * MiB, requested, Math.ceil(size / MAX_PARTS));
23
+ const CONTENT_TYPES = { zip: "application/zip", "7z": "application/x-7z-compressed", rar: "application/vnd.rar", json: "application/json" };
24
+ export const contentTypeOf = (key) => CONTENT_TYPES[key.split(".").pop().toLowerCase()] ?? "application/octet-stream";
25
+ export class S3Store {
26
+ aws;
27
+ fetchImpl;
28
+ prefix;
29
+ config;
30
+ constructor(config) {
31
+ this.config = config;
32
+ this.aws = new AwsClient({ accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, sessionToken: config.sessionToken, service: "s3", region: config.region });
33
+ this.fetchImpl = config.fetch ?? ((...a) => fetch(...a));
34
+ this.prefix = (config.prefix ?? "").replace(/^\/+|\/+$/g, "");
35
+ if (this.prefix)
36
+ this.prefix += "/";
37
+ }
38
+ /** The URL of a key under the prefix (`""` is the bucket itself). */
39
+ url(key = "", query = {}) {
40
+ const { endpoint, bucket, forcePathStyle } = this.config;
41
+ const base = new URL(endpoint);
42
+ const pathStyle = forcePathStyle ?? !base.hostname.endsWith(".amazonaws.com");
43
+ const path = key === "" ? "" : this.prefix + key;
44
+ const encoded = path.split("/").map((s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase())).join("/");
45
+ const host = pathStyle ? base.origin : `${base.protocol}//${bucket}.${base.host}`;
46
+ const url = new URL(`${host}${pathStyle ? "/" + bucket : ""}/${encoded}`);
47
+ for (const [k, v] of Object.entries(query))
48
+ url.searchParams.set(k, v);
49
+ return url;
50
+ }
51
+ async send(method, key, o = {}) {
52
+ const headers = new Headers(o.headers);
53
+ // aws4fetch can only hash in-memory bodies; a Blob goes out unsigned-payload (standard for S3 over TLS).
54
+ if (o.body instanceof Blob)
55
+ headers.set("x-amz-content-sha256", "UNSIGNED-PAYLOAD");
56
+ const req = await this.aws.sign(this.url(key, o.query), { method, headers, body: o.body, signal: o.signal });
57
+ const res = await this.fetchImpl(req);
58
+ if (res.ok)
59
+ return res;
60
+ const text = method === "HEAD" ? "" : await res.text().catch(() => "");
61
+ throw new S3Error(res.status, tags(text, "Code")[0] ?? res.statusText ?? "Error", tags(text, "Message")[0] ?? "");
62
+ }
63
+ meta(headers, o = {}) {
64
+ const h = { ...headers };
65
+ if (o.contentType)
66
+ h["content-type"] = o.contentType;
67
+ if (o.cacheControl)
68
+ h["cache-control"] = o.cacheControl;
69
+ if (o.ifMatch)
70
+ h["if-match"] = o.ifMatch;
71
+ if (o.ifNoneMatch)
72
+ h["if-none-match"] = o.ifNoneMatch;
73
+ for (const [k, v] of Object.entries(o.metadata ?? {}))
74
+ h[`x-amz-meta-${k.toLowerCase()}`] = v;
75
+ return h;
76
+ }
77
+ async putObject(key, body, o = {}) {
78
+ const res = await this.send("PUT", key, { headers: this.meta({}, o), body: typeof body === "string" ? body : body, signal: o.signal });
79
+ return { etag: res.headers.get("etag") ?? "" };
80
+ }
81
+ /** The object's bytes and ETag, or undefined when it does not exist. */
82
+ async getObject(key, signal) {
83
+ try {
84
+ const res = await this.send("GET", key, { signal });
85
+ return { bytes: new Uint8Array(await res.arrayBuffer()), etag: res.headers.get("etag") ?? "" };
86
+ }
87
+ catch (e) {
88
+ if (e instanceof S3Error && e.status === 404)
89
+ return undefined;
90
+ throw e;
91
+ }
92
+ }
93
+ async headObject(key, signal) {
94
+ try {
95
+ const res = await this.send("HEAD", key, { signal });
96
+ const metadata = {};
97
+ res.headers.forEach((v, k) => k.startsWith("x-amz-meta-") && (metadata[k.slice(11)] = v));
98
+ return { etag: res.headers.get("etag") ?? "", size: Number(res.headers.get("content-length")), metadata };
99
+ }
100
+ catch (e) {
101
+ if (e instanceof S3Error && e.status === 404)
102
+ return undefined;
103
+ throw e;
104
+ }
105
+ }
106
+ async deleteObject(key, signal) {
107
+ await this.send("DELETE", key, { signal });
108
+ }
109
+ /** One page of keys under `sub` (relative to the store prefix). */
110
+ async listKeys(sub = "", maxKeys = 1000) {
111
+ const res = await this.send("GET", "", { query: { "list-type": "2", prefix: this.prefix + sub, "max-keys": String(maxKeys) } });
112
+ return tags(await res.text(), "Key").map((k) => k.slice(this.prefix.length));
113
+ }
114
+ // --- multipart ---
115
+ async createUpload(key, o) {
116
+ const res = await this.send("POST", key, { query: { uploads: "" }, headers: this.meta({}, o), signal: o.signal });
117
+ const id = tags(await res.text(), "UploadId")[0];
118
+ if (!id)
119
+ throw new S3Error(200, "NoUploadId", "CreateMultipartUpload returned no UploadId");
120
+ return id;
121
+ }
122
+ /** Parts the service already holds for an upload; undefined when the upload no longer exists. */
123
+ async listParts(key, uploadId, signal) {
124
+ const parts = [];
125
+ let marker = "0";
126
+ try {
127
+ for (;;) {
128
+ const xml = await (await this.send("GET", key, { query: { uploadId, "part-number-marker": marker }, signal })).text();
129
+ for (const block of tags(xml, "Part")) {
130
+ parts.push({ partNumber: Number(tags(block, "PartNumber")[0]), etag: tags(block, "ETag")[0] });
131
+ }
132
+ const next = tags(xml, "NextPartNumberMarker")[0];
133
+ if (tags(xml, "IsTruncated")[0] !== "true" || !next)
134
+ return parts;
135
+ marker = next;
136
+ }
137
+ }
138
+ catch (e) {
139
+ if (e instanceof S3Error && e.status === 404)
140
+ return undefined;
141
+ throw e;
142
+ }
143
+ }
144
+ async abortUpload(key, uploadId) {
145
+ await this.send("DELETE", key, { query: { uploadId } });
146
+ }
147
+ /** Multipart uploads started and never completed under `sub`. */
148
+ async listUploads(sub = "") {
149
+ const xml = await (await this.send("GET", "", { query: { uploads: "", prefix: this.prefix + sub } })).text();
150
+ return tags(xml, "Upload").map((u) => ({ key: tags(u, "Key")[0].slice(this.prefix.length), uploadId: tags(u, "UploadId")[0] }));
151
+ }
152
+ /** Aborts every incomplete multipart upload under `sub` (they are billed as storage until aborted). Returns how many. */
153
+ async abortIncompleteUploads(sub = "") {
154
+ const uploads = await this.listUploads(sub);
155
+ for (const u of uploads)
156
+ await this.abortUpload(u.key, u.uploadId);
157
+ return uploads.length;
158
+ }
159
+ /**
160
+ * Uploads a Blob or File: one PUT when it fits in a part, else a resumable multipart upload.
161
+ * Aborting through `signal` leaves the upload in place so `resume` can continue it; `abortUpload` discards it.
162
+ */
163
+ async uploadFile(key, blob, o = {}) {
164
+ const partSize = choosePartSize(blob.size, o.partSize);
165
+ if (blob.size <= partSize) {
166
+ const { etag } = await this.putObject(key, blob, o);
167
+ o.onProgress?.(blob.size, blob.size);
168
+ return { etag, multipart: false };
169
+ }
170
+ let state;
171
+ let done = new Map();
172
+ if (o.resume?.size === blob.size && o.resume.partSize === partSize) {
173
+ const held = await this.listParts(key, o.resume.uploadId, o.signal);
174
+ if (held) {
175
+ state = o.resume;
176
+ done = new Map(held.map((p) => [p.partNumber, p.etag]));
177
+ }
178
+ }
179
+ if (!state) {
180
+ state = { uploadId: await this.createUpload(key, o), size: blob.size, partSize, parts: [] };
181
+ o.onState?.({ ...state });
182
+ }
183
+ const total = Math.ceil(blob.size / partSize);
184
+ const pending = Array.from({ length: total }, (_, i) => i + 1).filter((n) => !done.has(n));
185
+ let uploaded = [...done.keys()].reduce((sum, n) => sum + Math.min(partSize, blob.size - (n - 1) * partSize), 0);
186
+ o.onProgress?.(uploaded, blob.size);
187
+ const { uploadId } = state;
188
+ let failed = false; // one part giving up stops the others from starting new parts
189
+ const worker = async () => {
190
+ for (let n = pending.shift(); n !== undefined && !failed; n = pending.shift()) {
191
+ o.signal?.throwIfAborted();
192
+ const part = blob.slice((n - 1) * partSize, Math.min(n * partSize, blob.size));
193
+ try {
194
+ done.set(n, await this.retrying(() => this.uploadPart(key, uploadId, n, part, o.signal), o.signal));
195
+ }
196
+ catch (e) {
197
+ failed = true;
198
+ throw e;
199
+ }
200
+ uploaded += part.size;
201
+ o.onState?.({ ...state, parts: [...done].map(([partNumber, etag]) => ({ partNumber, etag })) });
202
+ o.onProgress?.(uploaded, blob.size);
203
+ }
204
+ };
205
+ await Promise.all(Array.from({ length: Math.min(o.concurrency ?? 3, pending.length) }, worker));
206
+ const body = `<CompleteMultipartUpload>${[...done].sort((a, b) => a[0] - b[0]).map(([n, etag]) => `<Part><PartNumber>${n}</PartNumber><ETag>${xmlEscape(etag)}</ETag></Part>`).join("")}</CompleteMultipartUpload>`;
207
+ const res = await this.send("POST", key, { query: { uploadId }, headers: { "content-type": "application/xml" }, body, signal: o.signal });
208
+ const xml = await res.text();
209
+ // Complete can answer 200 and still carry an <Error>.
210
+ if (xml.includes("<Error>"))
211
+ throw new S3Error(200, tags(xml, "Code")[0] ?? "Error", tags(xml, "Message")[0] ?? "");
212
+ return { etag: tags(xml, "ETag")[0] ?? "", multipart: true };
213
+ }
214
+ async uploadPart(key, uploadId, n, part, signal) {
215
+ const res = await this.send("PUT", key, { query: { partNumber: String(n), uploadId }, body: part, signal });
216
+ const etag = res.headers.get("etag");
217
+ if (!etag)
218
+ throw new S3Error(200, "NoETag", "no ETag on the part response; the bucket CORS must expose ETag");
219
+ return etag;
220
+ }
221
+ async retrying(fn, signal, attempts = 3) {
222
+ for (let i = 1;; i++) {
223
+ try {
224
+ return await fn();
225
+ }
226
+ catch (e) {
227
+ const transient = e instanceof TypeError || (e instanceof S3Error && e.status >= 500);
228
+ if (!transient || i >= attempts || signal?.aborted)
229
+ throw e;
230
+ await new Promise((r) => setTimeout(r, (this.config.retryDelayMs ?? 500) * 2 ** (i - 1)));
231
+ }
232
+ }
233
+ }
234
+ /** Reports, one check each, whether this key can list, write and delete. Distinguishes bad credentials, a missing bucket, a wrong region and missing permissions. */
235
+ async testConnection() {
236
+ const checks = [];
237
+ const add = (name, status, message) => checks.push({ name, status, message });
238
+ const report = () => ({ ok: !checks.some((c) => c.status === "fail"), checks });
239
+ const fatal = (name, e) => {
240
+ if (!(e instanceof S3Error))
241
+ return add(name, "fail", `no response (network, wrong endpoint, or CORS not set on the bucket): ${e.message}`), true;
242
+ if (["InvalidAccessKeyId", "SignatureDoesNotMatch", "InvalidToken", "ExpiredToken"].includes(e.code))
243
+ return add(name, "fail", `credentials rejected (${e.code})`), true;
244
+ if (e.code === "NoSuchBucket")
245
+ return add(name, "fail", "the bucket does not exist"), true;
246
+ if (["AuthorizationHeaderMalformed", "PermanentRedirect", "IllegalLocationConstraintException"].includes(e.code) || e.status === 301)
247
+ return add(name, "fail", `wrong region or endpoint (${e.code}): ${e.message}`), true;
248
+ return false;
249
+ };
250
+ try {
251
+ await this.listKeys("", 1);
252
+ add("list", "pass", "ListObjectsV2 allowed");
253
+ }
254
+ catch (e) {
255
+ if (fatal("list", e))
256
+ return report();
257
+ add("list", "warn", `listing denied (${e.code}); publishing does not need it, testing and cleaning up incomplete uploads do`);
258
+ }
259
+ const probe = `.gm-pantry-probe-${Math.random().toString(36).slice(2)}`;
260
+ try {
261
+ await this.putObject(probe, "probe", { contentType: "text/plain" });
262
+ add("write", "pass", "PutObject allowed");
263
+ }
264
+ catch (e) {
265
+ if (!fatal("write", e))
266
+ add("write", "fail", `PutObject denied (${e.code}): the key needs s3:PutObject on ${this.prefix || "the bucket"}*`);
267
+ return report();
268
+ }
269
+ try {
270
+ await this.deleteObject(probe);
271
+ add("delete", "pass", "DeleteObject allowed");
272
+ }
273
+ catch (e) {
274
+ add("delete", "fail", `DeleteObject denied (${e.code}); the probe object ${probe} is left behind and old shards could not be cleaned up`);
275
+ }
276
+ return report();
277
+ }
278
+ /** PutBucketCors. Only usable where CORS is not enforced (Node, the CLI, a desktop app): a browser cannot call it before a CORS rule exists. Replaces the bucket's whole CORS configuration. */
279
+ async setCors(origins = ["*"]) {
280
+ const r = corsRule(origins);
281
+ const xml = `<CORSConfiguration><CORSRule>${r.origins.map((o) => `<AllowedOrigin>${xmlEscape(o)}</AllowedOrigin>`).join("")}${r.methods.map((m) => `<AllowedMethod>${m}</AllowedMethod>`).join("")}${r.allowedHeaders.map((h) => `<AllowedHeader>${h}</AllowedHeader>`).join("")}${r.expose.map((h) => `<ExposeHeader>${h}</ExposeHeader>`).join("")}<MaxAgeSeconds>${r.maxAge}</MaxAgeSeconds></CORSRule></CORSConfiguration>`;
282
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(xml));
283
+ // S3 wants Content-MD5 or a checksum; Web Crypto has no MD5, so SHA-256.
284
+ const checksum = btoa(String.fromCharCode(...new Uint8Array(digest)));
285
+ await this.send("PUT", "", { query: { cors: "" }, headers: { "content-type": "application/xml", "x-amz-checksum-sha256": checksum, "x-amz-sdk-checksum-algorithm": "SHA256" }, body: xml });
286
+ }
287
+ }
288
+ const GENERATED_SHARD = /^shards\/[0-9a-f]{16}\.json$/;
289
+ const keyOf = (url) => decodeURIComponent(url);
290
+ /**
291
+ * repo.json with its guard. A 412 is a real conflict only if the object's ETag is no longer the expected one:
292
+ * some services answer 412 for an ETag they handed out themselves (quoted versus bare), so when the ETag still
293
+ * matches, retry once with the bare form, then without a guard (the ETag was checked just now).
294
+ */
295
+ async function putGuarded(store, body, put, guard, expected, result) {
296
+ const conflict = () => new ConflictError("repo.json changed while publishing; reload and apply the edit again");
297
+ try {
298
+ await store.putObject("repo.json", body, { ...put, ...guard });
299
+ return;
300
+ }
301
+ catch (e) {
302
+ if (!(e instanceof S3Error) || e.status !== 412)
303
+ throw e;
304
+ if (!expected || (await store.headObject("repo.json"))?.etag !== expected)
305
+ throw conflict();
306
+ }
307
+ const bare = expected.replace(/^W\//, "").replace(/^"(.*)"$/, "$1");
308
+ try {
309
+ await store.putObject("repo.json", body, { ...put, ifMatch: bare });
310
+ }
311
+ catch (e) {
312
+ if (!(e instanceof S3Error) || e.status !== 412)
313
+ throw e;
314
+ if ((await store.headObject("repo.json"))?.etag !== expected)
315
+ throw conflict();
316
+ result.conditional = false;
317
+ await store.putObject("repo.json", body, put);
318
+ }
319
+ }
320
+ /**
321
+ * Uploads archives and new shards first and repo.json last, so a reader never sees a repo.json that points at missing files;
322
+ * then deletes the shards nothing lists any more. repo.json is written with `If-Match` (or `If-None-Match: *`) where the service supports it.
323
+ */
324
+ export async function publishRepo(store, input, o = {}) {
325
+ const result = { uploaded: [], skipped: [], deleted: [], conditional: true };
326
+ const current = await store.getObject("repo.json");
327
+ const expected = o.ifMatch === undefined ? (current?.etag ?? null) : o.ifMatch;
328
+ if ((current?.etag ?? null) !== expected)
329
+ throw new ConflictError("repo.json changed since it was loaded; reload and apply the edit again");
330
+ for (const a of o.archives ?? []) {
331
+ const key = keyOf(a.url);
332
+ const head = a.sha256 ? await store.headObject(key) : undefined;
333
+ if (head && head.size === a.body.size && head.metadata.sha256 === a.sha256) {
334
+ result.skipped.push(a.url);
335
+ continue;
336
+ }
337
+ await store.uploadFile(key, a.body, {
338
+ ...o.upload,
339
+ contentType: contentTypeOf(key),
340
+ metadata: a.sha256 ? { sha256: a.sha256 } : undefined,
341
+ resume: o.resume?.(a.url),
342
+ onState: (s) => o.onState?.(a.url, s),
343
+ onProgress: (u, t) => o.onProgress?.(a.url, u, t),
344
+ });
345
+ result.uploaded.push(a.url);
346
+ }
347
+ for (const [url, content] of input.shards) {
348
+ const key = keyOf(url);
349
+ const size = encoder.encode(content).byteLength;
350
+ if ((await store.headObject(key))?.size === size) {
351
+ result.skipped.push(url);
352
+ continue;
353
+ }
354
+ await store.putObject(key, content, { contentType: "application/json", cacheControl: "public, max-age=31536000, immutable" });
355
+ result.uploaded.push(url);
356
+ }
357
+ const guard = expected ? { ifMatch: expected } : { ifNoneMatch: "*" };
358
+ const put = { contentType: "application/json", cacheControl: "no-cache" };
359
+ try {
360
+ await putGuarded(store, input.repoJson, put, guard, expected, result);
361
+ }
362
+ catch (e) {
363
+ if (!(e instanceof S3Error) || (e.status !== 501 && e.code !== "NotImplemented"))
364
+ throw e;
365
+ result.conditional = false;
366
+ await store.putObject("repo.json", input.repoJson, put);
367
+ }
368
+ result.uploaded.push("repo.json");
369
+ let previous = [];
370
+ try {
371
+ previous = (JSON.parse(new TextDecoder().decode(current?.bytes)).shards ?? []).map((s) => s.url);
372
+ }
373
+ catch { /* no usable previous repo.json: nothing to compare */ }
374
+ const orphans = new Set([...(input.orphans ?? []), ...previous].filter((u) => GENERATED_SHARD.test(u) && !input.shards.has(u)));
375
+ for (const url of orphans) {
376
+ try {
377
+ await store.deleteObject(keyOf(url));
378
+ result.deleted.push(url);
379
+ }
380
+ catch { /* an orphan that stays is only wasted space */ }
381
+ }
382
+ return result;
383
+ }
384
+ // --- CORS ---
385
+ const corsRule = (origins) => ({
386
+ origins,
387
+ methods: ["GET", "HEAD", "PUT", "POST", "DELETE"],
388
+ allowedHeaders: ["*"],
389
+ expose: ["ETag", "Content-Length", "Content-Range"],
390
+ maxAge: 3600,
391
+ });
392
+ /** Ready-to-paste CORS configuration for the services that matter. `origin` is the web app that will publish ("*" for any). */
393
+ export function corsSnippets(origin = "*") {
394
+ const r = corsRule([origin]);
395
+ const s3 = JSON.stringify({ CORSRules: [{ AllowedOrigins: r.origins, AllowedMethods: r.methods, AllowedHeaders: r.allowedHeaders, ExposeHeaders: r.expose, MaxAgeSeconds: r.maxAge }] }, null, 2);
396
+ const r2 = JSON.stringify([{ AllowedOrigins: r.origins, AllowedMethods: r.methods, AllowedHeaders: r.allowedHeaders, ExposeHeaders: r.expose, MaxAgeSeconds: r.maxAge }], null, 2);
397
+ const r2Wrangler = JSON.stringify({ rules: [{ allowed: { origins: r.origins, methods: r.methods, headers: r.allowedHeaders }, exposeHeaders: r.expose, maxAgeSeconds: r.maxAge }] }, null, 2);
398
+ const b2 = JSON.stringify([{ corsRuleName: "gm-pantry", allowedOrigins: r.origins, allowedOperations: ["s3_get", "s3_head", "s3_put", "s3_post", "s3_delete"], allowedHeaders: r.allowedHeaders, exposeHeaders: ["ETag", "Content-Length", "Content-Range"], maxAgeSeconds: r.maxAge }]);
399
+ return {
400
+ s3: { json: s3, command: "aws s3api put-bucket-cors --bucket BUCKET --cors-configuration file://cors.json" },
401
+ r2: { json: r2, wranglerJson: r2Wrangler, command: "npx wrangler r2 bucket cors set BUCKET --file cors.json" },
402
+ b2: { json: b2, command: `b2 bucket update --cors-rules '${b2}' BUCKET allPrivate` },
403
+ };
404
+ }
405
+ const PUT_HEADERS = ["authorization", "cache-control", "content-type", "if-match", "if-none-match", "x-amz-content-sha256", "x-amz-date", "x-amz-meta-sha256", "x-amz-security-token"];
406
+ /**
407
+ * What a browser needs from the bucket: repo.json readable with CORS, ETag exposed, Range, and a preflight that allows the PUT, POST and DELETE a publisher sends.
408
+ * `publicBaseUrl` is where the repo is served; the preflight goes to `opts.writeUrl` (the S3 API endpoint the browser uploads to) when that differs.
409
+ * Run from a browser to see exactly what the browser sees; run from Node, `origin` is sent as the Origin header.
410
+ */
411
+ export async function checkCors(publicBaseUrl, origin = "https://example.com", opts = {}) {
412
+ const f = opts.fetch ?? ((...a) => fetch(...a));
413
+ const checks = [];
414
+ const add = (name, status, message) => checks.push({ name, status, message });
415
+ const repoUrl = publicBaseUrl.endsWith(".json") ? publicBaseUrl : publicBaseUrl.replace(/\/?$/, "/") + "repo.json";
416
+ const allowsOrigin = (h) => { const v = h.get("access-control-allow-origin"); return v === "*" || v === origin; };
417
+ try {
418
+ const res = await f(repoUrl, { headers: { Origin: origin } });
419
+ await res.body?.cancel();
420
+ add("repo.json read", res.ok ? "pass" : res.status === 404 ? "warn" : "fail", res.status === 404 ? "no repo.json yet (fine before the first publish)" : `HTTP ${res.status}`);
421
+ add("allow-origin", allowsOrigin(res.headers) ? "pass" : "fail", `Access-Control-Allow-Origin: ${res.headers.get("access-control-allow-origin") ?? "missing"}`);
422
+ const expose = (res.headers.get("access-control-expose-headers") ?? "").toLowerCase();
423
+ add("expose ETag", /(^|[\s,])(etag|\*)($|[\s,])/.test(expose) ? "pass" : "fail", `Access-Control-Expose-Headers: ${expose || "missing"}; ETag is needed to upload large files and to guard repo.json writes`);
424
+ }
425
+ catch (e) {
426
+ add("repo.json read", "fail", `fetch failed (CORS missing or unreachable): ${e.message}`);
427
+ }
428
+ try {
429
+ const res = await f(repoUrl, { headers: { Origin: origin, Range: "bytes=0-0" } });
430
+ await res.body?.cancel();
431
+ if (res.status !== 404)
432
+ add("range", res.status === 206 ? "pass" : "warn", res.status === 206 ? "206 Partial Content" : `Range ignored (HTTP ${res.status}); optional`);
433
+ }
434
+ catch (e) {
435
+ add("range", "fail", `fetch failed: ${e.message}`);
436
+ }
437
+ const writeUrl = opts.writeUrl ?? repoUrl;
438
+ for (const method of ["PUT", "POST", "DELETE"]) {
439
+ try {
440
+ const res = await f(writeUrl, { method: "OPTIONS", headers: { Origin: origin, "Access-Control-Request-Method": method, "Access-Control-Request-Headers": PUT_HEADERS.join(",") } });
441
+ await res.body?.cancel();
442
+ const list = (h) => (res.headers.get(h) ?? "").toLowerCase().split(/[\s,]+/);
443
+ const has = (l, x) => l.includes("*") || l.includes(x.toLowerCase());
444
+ const missing = res.ok ? [...(has(list("access-control-allow-methods"), method) ? [] : [method]), ...PUT_HEADERS.filter((h) => !has(list("access-control-allow-headers"), h))] : [];
445
+ const ok = res.ok && allowsOrigin(res.headers) && missing.length === 0;
446
+ add(`preflight ${method}`, ok ? "pass" : "fail", ok ? "allowed" : `HTTP ${res.status}${res.ok || allowsOrigin(res.headers) ? "" : `, refused: the origin, ${method} or a header is not in the bucket's CORS rule`}${missing.length ? `, not allowed: ${missing.join(", ")}` : ""}`);
447
+ }
448
+ catch (e) {
449
+ add(`preflight ${method}`, "fail", `fetch failed: ${e.message}`);
450
+ }
451
+ }
452
+ return { ok: !checks.some((c) => c.status === "fail"), checks, snippets: corsSnippets(origin === "https://example.com" ? "*" : origin) };
453
+ }
package/dist/verify.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type HashOptions } from "./hash.ts";
1
2
  import type { ArchiveFile } from "./types.ts";
2
3
  export type VerifyResult = {
3
4
  ok: true;
@@ -9,6 +10,6 @@ export type VerifyResult = {
9
10
  };
10
11
  /**
11
12
  * Checks bytes against a `file` entry: size first, then sha256. A consumer discards the archive on `ok: false`.
12
- * shortcut: a stream is buffered whole (Web Crypto has no incremental digest), upgrade when archives outgrow memory.
13
+ * A Blob or stream is hashed chunk by chunk, never buffered whole.
13
14
  */
14
- export declare function verifyArchive(data: Uint8Array | ArrayBuffer | ReadableStream<Uint8Array>, file: Pick<ArchiveFile, "sha256" | "size">): Promise<VerifyResult>;
15
+ export declare function verifyArchive(data: Uint8Array | ArrayBuffer | Blob | ReadableStream<Uint8Array>, file: Pick<ArchiveFile, "sha256" | "size">, options?: HashOptions): Promise<VerifyResult>;
package/dist/verify.js CHANGED
@@ -1,15 +1,21 @@
1
- // SPEC.md §6: the sha256 check on a downloaded archive. Web Crypto only, so it runs in a browser.
2
- const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
1
+ // SPEC.md §6: the sha256 check on a downloaded archive. Runs in a browser.
2
+ import { hashStream, sha256Hex } from "./hash.js";
3
3
  /**
4
4
  * Checks bytes against a `file` entry: size first, then sha256. A consumer discards the archive on `ok: false`.
5
- * shortcut: a stream is buffered whole (Web Crypto has no incremental digest), upgrade when archives outgrow memory.
5
+ * A Blob or stream is hashed chunk by chunk, never buffered whole.
6
6
  */
7
- export async function verifyArchive(data, file) {
8
- const bytes = data instanceof ReadableStream
9
- ? new Uint8Array(await new Response(data).arrayBuffer())
10
- : new Uint8Array(data);
11
- if (bytes.byteLength !== file.size)
12
- return { ok: false, reason: "size", expected: file.size, actual: bytes.byteLength };
13
- const actual = hex(await crypto.subtle.digest("SHA-256", bytes));
14
- return actual === file.sha256 ? { ok: true } : { ok: false, reason: "sha256", expected: file.sha256, actual };
7
+ export async function verifyArchive(data, file, options) {
8
+ let actual;
9
+ if (data instanceof ReadableStream || data instanceof Blob) {
10
+ actual = await hashStream(data instanceof Blob ? data.stream() : data, options);
11
+ }
12
+ else {
13
+ const bytes = new Uint8Array(data);
14
+ if (bytes.byteLength !== file.size)
15
+ return { ok: false, reason: "size", expected: file.size, actual: bytes.byteLength };
16
+ actual = { sha256: await sha256Hex(bytes), size: bytes.byteLength };
17
+ }
18
+ if (actual.size !== file.size)
19
+ return { ok: false, reason: "size", expected: file.size, actual: actual.size };
20
+ return actual.sha256 === file.sha256 ? { ok: true } : { ok: false, reason: "sha256", expected: file.sha256, actual: actual.sha256 };
15
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-pantry",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Game repositories: static directories of game archives with a repo.json, plus a parser, validator and CLI.",
5
5
  "keywords": [
6
6
  "gamemaker",
@@ -26,6 +26,10 @@
26
26
  ".": {
27
27
  "types": "./dist/index.d.ts",
28
28
  "default": "./dist/index.js"
29
+ },
30
+ "./s3": {
31
+ "types": "./dist/s3.d.ts",
32
+ "default": "./dist/s3.js"
29
33
  }
30
34
  },
31
35
  "bin": {
@@ -40,6 +44,10 @@
40
44
  "test": "node --test test/*.test.ts scripts/*.test.mjs",
41
45
  "prepublishOnly": "npm run build"
42
46
  },
47
+ "dependencies": {
48
+ "aws4fetch": "^1.0.20",
49
+ "hash-wasm": "^4.13.0"
50
+ },
43
51
  "devDependencies": {
44
52
  "@types/node": "^24.0.0",
45
53
  "typescript": "^5.9.0"