gm-pantry 0.1.0 → 0.3.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 +111 -8
- package/dist/build.d.ts +12 -16
- package/dist/build.js +50 -48
- package/dist/builder.d.ts +68 -0
- package/dist/builder.js +139 -0
- package/dist/cli.js +123 -32
- package/dist/hash.d.ts +15 -0
- package/dist/hash.js +31 -0
- package/dist/hosting.js +20 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/load.d.ts +19 -0
- package/dist/load.js +61 -0
- package/dist/parse.d.ts +8 -2
- package/dist/parse.js +54 -8
- package/dist/s3.d.ts +177 -0
- package/dist/s3.js +425 -0
- package/dist/types.d.ts +12 -0
- package/dist/verify.d.ts +3 -2
- package/dist/verify.js +17 -11
- package/package.json +9 -1
package/dist/s3.d.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { Check } from "./hosting.ts";
|
|
2
|
+
export interface S3Config {
|
|
3
|
+
endpoint: string;
|
|
4
|
+
region: string;
|
|
5
|
+
bucket: string;
|
|
6
|
+
accessKeyId: string;
|
|
7
|
+
secretAccessKey: string;
|
|
8
|
+
sessionToken?: string;
|
|
9
|
+
/** Key prefix inside the bucket (the repo lives under it). */
|
|
10
|
+
prefix?: string;
|
|
11
|
+
/** `endpoint/bucket/key` instead of `bucket.endpoint/key`. Default: true unless the endpoint is amazonaws.com. */
|
|
12
|
+
forcePathStyle?: boolean;
|
|
13
|
+
/** Transport; defaults to the global `fetch`. */
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
/** Wait before the first retry of a failed part (doubles each time). Default 500. */
|
|
16
|
+
retryDelayMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare class S3Error extends Error {
|
|
19
|
+
status: number;
|
|
20
|
+
code: string;
|
|
21
|
+
constructor(status: number, code: string, message: string);
|
|
22
|
+
}
|
|
23
|
+
/** repo.json changed under the publisher (or did not match the ETag it was loaded with). */
|
|
24
|
+
export declare class ConflictError extends Error {
|
|
25
|
+
}
|
|
26
|
+
export interface UploadState {
|
|
27
|
+
uploadId: string;
|
|
28
|
+
size: number;
|
|
29
|
+
partSize: number;
|
|
30
|
+
parts: {
|
|
31
|
+
partNumber: number;
|
|
32
|
+
etag: string;
|
|
33
|
+
}[];
|
|
34
|
+
}
|
|
35
|
+
export interface PutOptions {
|
|
36
|
+
contentType?: string;
|
|
37
|
+
cacheControl?: string;
|
|
38
|
+
/** Stored as `x-amz-meta-<key>`. */
|
|
39
|
+
metadata?: Record<string, string>;
|
|
40
|
+
/** Only write when the current ETag matches. */
|
|
41
|
+
ifMatch?: string;
|
|
42
|
+
/** `"*"`: only write when the key does not exist. */
|
|
43
|
+
ifNoneMatch?: string;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}
|
|
46
|
+
export interface UploadOptions extends Omit<PutOptions, "ifMatch" | "ifNoneMatch"> {
|
|
47
|
+
/** Default 16 MiB; raised so the file fits in 10 000 parts, never below the 5 MiB S3 minimum. */
|
|
48
|
+
partSize?: number;
|
|
49
|
+
/** Parts in flight at once. Default 3. */
|
|
50
|
+
concurrency?: number;
|
|
51
|
+
onProgress?: (uploaded: number, total: number) => void;
|
|
52
|
+
/** Called after the upload is created and after every part: persist it, and pass it back as `resume` after a crash or abort. */
|
|
53
|
+
onState?: (state: UploadState) => void;
|
|
54
|
+
resume?: UploadState;
|
|
55
|
+
}
|
|
56
|
+
export declare const choosePartSize: (size: number, requested?: number) => number;
|
|
57
|
+
export declare const contentTypeOf: (key: string) => string;
|
|
58
|
+
export declare class S3Store {
|
|
59
|
+
private aws;
|
|
60
|
+
private fetchImpl;
|
|
61
|
+
private prefix;
|
|
62
|
+
config: S3Config;
|
|
63
|
+
constructor(config: S3Config);
|
|
64
|
+
/** The URL of a key under the prefix (`""` is the bucket itself). */
|
|
65
|
+
url(key?: string, query?: Record<string, string>): URL;
|
|
66
|
+
private send;
|
|
67
|
+
private meta;
|
|
68
|
+
putObject(key: string, body: string | Uint8Array | Blob, o?: PutOptions): Promise<{
|
|
69
|
+
etag: string;
|
|
70
|
+
}>;
|
|
71
|
+
/** The object's bytes and ETag, or undefined when it does not exist. */
|
|
72
|
+
getObject(key: string, signal?: AbortSignal): Promise<{
|
|
73
|
+
bytes: Uint8Array;
|
|
74
|
+
etag: string;
|
|
75
|
+
} | undefined>;
|
|
76
|
+
headObject(key: string, signal?: AbortSignal): Promise<{
|
|
77
|
+
etag: string;
|
|
78
|
+
size: number;
|
|
79
|
+
metadata: Record<string, string>;
|
|
80
|
+
} | undefined>;
|
|
81
|
+
deleteObject(key: string, signal?: AbortSignal): Promise<void>;
|
|
82
|
+
/** One page of keys under `sub` (relative to the store prefix). */
|
|
83
|
+
listKeys(sub?: string, maxKeys?: number): Promise<string[]>;
|
|
84
|
+
private createUpload;
|
|
85
|
+
/** Parts the service already holds for an upload; undefined when the upload no longer exists. */
|
|
86
|
+
private listParts;
|
|
87
|
+
abortUpload(key: string, uploadId: string): Promise<void>;
|
|
88
|
+
/** Multipart uploads started and never completed under `sub`. */
|
|
89
|
+
listUploads(sub?: string): Promise<{
|
|
90
|
+
key: string;
|
|
91
|
+
uploadId: string;
|
|
92
|
+
}[]>;
|
|
93
|
+
/** Aborts every incomplete multipart upload under `sub` (they are billed as storage until aborted). Returns how many. */
|
|
94
|
+
abortIncompleteUploads(sub?: string): Promise<number>;
|
|
95
|
+
/**
|
|
96
|
+
* Uploads a Blob or File: one PUT when it fits in a part, else a resumable multipart upload.
|
|
97
|
+
* Aborting through `signal` leaves the upload in place so `resume` can continue it; `abortUpload` discards it.
|
|
98
|
+
*/
|
|
99
|
+
uploadFile(key: string, blob: Blob, o?: UploadOptions): Promise<{
|
|
100
|
+
etag: string;
|
|
101
|
+
multipart: boolean;
|
|
102
|
+
}>;
|
|
103
|
+
private uploadPart;
|
|
104
|
+
private retrying;
|
|
105
|
+
/** Reports, one check each, whether this key can list, write and delete. Distinguishes bad credentials, a missing bucket, a wrong region and missing permissions. */
|
|
106
|
+
testConnection(): Promise<{
|
|
107
|
+
ok: boolean;
|
|
108
|
+
checks: Check[];
|
|
109
|
+
}>;
|
|
110
|
+
/** 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. */
|
|
111
|
+
setCors(origins?: string[]): Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
export interface PublishInput {
|
|
114
|
+
/** The bytes of repo.json, from `serialize`. */
|
|
115
|
+
repoJson: string;
|
|
116
|
+
/** Shard files by repo-relative url, from `serialize`. */
|
|
117
|
+
shards: Map<string, string>;
|
|
118
|
+
/** Shards to delete once repo.json is live, from `serialize`; the ones the remote repo.json lists and the new one does not are added. */
|
|
119
|
+
orphans?: string[];
|
|
120
|
+
}
|
|
121
|
+
export interface PublishOptions {
|
|
122
|
+
/** Archives to upload before repo.json; `url` is the repo-relative url they are listed under. With `sha256` set, an archive the bucket already holds (same size and recorded hash) is skipped. */
|
|
123
|
+
archives?: {
|
|
124
|
+
url: string;
|
|
125
|
+
body: Blob;
|
|
126
|
+
sha256?: string;
|
|
127
|
+
}[];
|
|
128
|
+
/** The ETag of the repo.json this edit started from (what the caller loaded); null: there was none. Default: whatever is there now. */
|
|
129
|
+
ifMatch?: string | null;
|
|
130
|
+
upload?: Omit<UploadOptions, "resume" | "onState" | "onProgress" | "metadata" | "contentType">;
|
|
131
|
+
/** Persist and return the multipart state per archive url. */
|
|
132
|
+
resume?: (url: string) => UploadState | undefined;
|
|
133
|
+
onState?: (url: string, state: UploadState) => void;
|
|
134
|
+
onProgress?: (url: string, uploaded: number, total: number) => void;
|
|
135
|
+
}
|
|
136
|
+
export interface PublishResult {
|
|
137
|
+
uploaded: string[];
|
|
138
|
+
skipped: string[];
|
|
139
|
+
deleted: string[];
|
|
140
|
+
/** False: the service ignored conditional writes, so a concurrent publisher could have been overwritten (last writer wins). */
|
|
141
|
+
conditional: boolean;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Uploads archives and new shards first and repo.json last, so a reader never sees a repo.json that points at missing files;
|
|
145
|
+
* then deletes the shards nothing lists any more. repo.json is written with `If-Match` (or `If-None-Match: *`) where the service supports it.
|
|
146
|
+
*/
|
|
147
|
+
export declare function publishRepo(store: S3Store, input: PublishInput, o?: PublishOptions): Promise<PublishResult>;
|
|
148
|
+
/** Ready-to-paste CORS configuration for the services that matter. `origin` is the web app that will publish ("*" for any). */
|
|
149
|
+
export declare function corsSnippets(origin?: string): {
|
|
150
|
+
s3: {
|
|
151
|
+
json: string;
|
|
152
|
+
command: string;
|
|
153
|
+
};
|
|
154
|
+
r2: {
|
|
155
|
+
json: string;
|
|
156
|
+
wranglerJson: string;
|
|
157
|
+
command: string;
|
|
158
|
+
};
|
|
159
|
+
b2: {
|
|
160
|
+
json: string;
|
|
161
|
+
command: string;
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
export interface CorsReport {
|
|
165
|
+
ok: boolean;
|
|
166
|
+
checks: Check[];
|
|
167
|
+
snippets: ReturnType<typeof corsSnippets>;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* 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.
|
|
171
|
+
* `publicBaseUrl` is where the repo is served; the preflight goes to `opts.writeUrl` (the S3 API endpoint the browser uploads to) when that differs.
|
|
172
|
+
* Run from a browser to see exactly what the browser sees; run from Node, `origin` is sent as the Origin header.
|
|
173
|
+
*/
|
|
174
|
+
export declare function checkCors(publicBaseUrl: string, origin?: string, opts?: {
|
|
175
|
+
writeUrl?: string;
|
|
176
|
+
fetch?: typeof fetch;
|
|
177
|
+
}): Promise<CorsReport>;
|
package/dist/s3.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
20
|
+
const xmlUnescape = (s) => s.replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'").replace(/&/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
|
+
* Uploads archives and new shards first and repo.json last, so a reader never sees a repo.json that points at missing files;
|
|
292
|
+
* then deletes the shards nothing lists any more. repo.json is written with `If-Match` (or `If-None-Match: *`) where the service supports it.
|
|
293
|
+
*/
|
|
294
|
+
export async function publishRepo(store, input, o = {}) {
|
|
295
|
+
const result = { uploaded: [], skipped: [], deleted: [], conditional: true };
|
|
296
|
+
const current = await store.getObject("repo.json");
|
|
297
|
+
const expected = o.ifMatch === undefined ? (current?.etag ?? null) : o.ifMatch;
|
|
298
|
+
if ((current?.etag ?? null) !== expected)
|
|
299
|
+
throw new ConflictError("repo.json changed since it was loaded; reload and apply the edit again");
|
|
300
|
+
for (const a of o.archives ?? []) {
|
|
301
|
+
const key = keyOf(a.url);
|
|
302
|
+
const head = a.sha256 ? await store.headObject(key) : undefined;
|
|
303
|
+
if (head && head.size === a.body.size && head.metadata.sha256 === a.sha256) {
|
|
304
|
+
result.skipped.push(a.url);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
await store.uploadFile(key, a.body, {
|
|
308
|
+
...o.upload,
|
|
309
|
+
contentType: contentTypeOf(key),
|
|
310
|
+
metadata: a.sha256 ? { sha256: a.sha256 } : undefined,
|
|
311
|
+
resume: o.resume?.(a.url),
|
|
312
|
+
onState: (s) => o.onState?.(a.url, s),
|
|
313
|
+
onProgress: (u, t) => o.onProgress?.(a.url, u, t),
|
|
314
|
+
});
|
|
315
|
+
result.uploaded.push(a.url);
|
|
316
|
+
}
|
|
317
|
+
for (const [url, content] of input.shards) {
|
|
318
|
+
const key = keyOf(url);
|
|
319
|
+
const size = encoder.encode(content).byteLength;
|
|
320
|
+
if ((await store.headObject(key))?.size === size) {
|
|
321
|
+
result.skipped.push(url);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
await store.putObject(key, content, { contentType: "application/json", cacheControl: "public, max-age=31536000, immutable" });
|
|
325
|
+
result.uploaded.push(url);
|
|
326
|
+
}
|
|
327
|
+
const guard = expected ? { ifMatch: expected } : { ifNoneMatch: "*" };
|
|
328
|
+
const put = { contentType: "application/json", cacheControl: "no-cache" };
|
|
329
|
+
try {
|
|
330
|
+
await store.putObject("repo.json", input.repoJson, { ...put, ...guard });
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
if (e instanceof S3Error && e.status === 412)
|
|
334
|
+
throw new ConflictError("repo.json changed while publishing; reload and apply the edit again");
|
|
335
|
+
if (!(e instanceof S3Error) || (e.status !== 501 && e.code !== "NotImplemented"))
|
|
336
|
+
throw e;
|
|
337
|
+
result.conditional = false;
|
|
338
|
+
await store.putObject("repo.json", input.repoJson, put);
|
|
339
|
+
}
|
|
340
|
+
result.uploaded.push("repo.json");
|
|
341
|
+
let previous = [];
|
|
342
|
+
try {
|
|
343
|
+
previous = (JSON.parse(new TextDecoder().decode(current?.bytes)).shards ?? []).map((s) => s.url);
|
|
344
|
+
}
|
|
345
|
+
catch { /* no usable previous repo.json: nothing to compare */ }
|
|
346
|
+
const orphans = new Set([...(input.orphans ?? []), ...previous].filter((u) => GENERATED_SHARD.test(u) && !input.shards.has(u)));
|
|
347
|
+
for (const url of orphans) {
|
|
348
|
+
try {
|
|
349
|
+
await store.deleteObject(keyOf(url));
|
|
350
|
+
result.deleted.push(url);
|
|
351
|
+
}
|
|
352
|
+
catch { /* an orphan that stays is only wasted space */ }
|
|
353
|
+
}
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
// --- CORS ---
|
|
357
|
+
const corsRule = (origins) => ({
|
|
358
|
+
origins,
|
|
359
|
+
methods: ["GET", "HEAD", "PUT", "POST", "DELETE"],
|
|
360
|
+
allowedHeaders: ["*"],
|
|
361
|
+
expose: ["ETag", "Content-Length", "Content-Range"],
|
|
362
|
+
maxAge: 3600,
|
|
363
|
+
});
|
|
364
|
+
/** Ready-to-paste CORS configuration for the services that matter. `origin` is the web app that will publish ("*" for any). */
|
|
365
|
+
export function corsSnippets(origin = "*") {
|
|
366
|
+
const r = corsRule([origin]);
|
|
367
|
+
const s3 = JSON.stringify({ CORSRules: [{ AllowedOrigins: r.origins, AllowedMethods: r.methods, AllowedHeaders: r.allowedHeaders, ExposeHeaders: r.expose, MaxAgeSeconds: r.maxAge }] }, null, 2);
|
|
368
|
+
const r2 = JSON.stringify([{ AllowedOrigins: r.origins, AllowedMethods: r.methods, AllowedHeaders: r.allowedHeaders, ExposeHeaders: r.expose, MaxAgeSeconds: r.maxAge }], null, 2);
|
|
369
|
+
const r2Wrangler = JSON.stringify({ rules: [{ allowed: { origins: r.origins, methods: r.methods, headers: r.allowedHeaders }, exposeHeaders: r.expose, maxAgeSeconds: r.maxAge }] }, null, 2);
|
|
370
|
+
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 }]);
|
|
371
|
+
return {
|
|
372
|
+
s3: { json: s3, command: "aws s3api put-bucket-cors --bucket BUCKET --cors-configuration file://cors.json" },
|
|
373
|
+
r2: { json: r2, wranglerJson: r2Wrangler, command: "npx wrangler r2 bucket cors set BUCKET --file cors.json" },
|
|
374
|
+
b2: { json: b2, command: `b2 bucket update --cors-rules '${b2}' BUCKET allPrivate` },
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
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"];
|
|
378
|
+
/**
|
|
379
|
+
* 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.
|
|
380
|
+
* `publicBaseUrl` is where the repo is served; the preflight goes to `opts.writeUrl` (the S3 API endpoint the browser uploads to) when that differs.
|
|
381
|
+
* Run from a browser to see exactly what the browser sees; run from Node, `origin` is sent as the Origin header.
|
|
382
|
+
*/
|
|
383
|
+
export async function checkCors(publicBaseUrl, origin = "https://example.com", opts = {}) {
|
|
384
|
+
const f = opts.fetch ?? ((...a) => fetch(...a));
|
|
385
|
+
const checks = [];
|
|
386
|
+
const add = (name, status, message) => checks.push({ name, status, message });
|
|
387
|
+
const repoUrl = publicBaseUrl.endsWith(".json") ? publicBaseUrl : publicBaseUrl.replace(/\/?$/, "/") + "repo.json";
|
|
388
|
+
const allowsOrigin = (h) => { const v = h.get("access-control-allow-origin"); return v === "*" || v === origin; };
|
|
389
|
+
try {
|
|
390
|
+
const res = await f(repoUrl, { headers: { Origin: origin } });
|
|
391
|
+
await res.body?.cancel();
|
|
392
|
+
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}`);
|
|
393
|
+
add("allow-origin", allowsOrigin(res.headers) ? "pass" : "fail", `Access-Control-Allow-Origin: ${res.headers.get("access-control-allow-origin") ?? "missing"}`);
|
|
394
|
+
const expose = (res.headers.get("access-control-expose-headers") ?? "").toLowerCase();
|
|
395
|
+
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`);
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
add("repo.json read", "fail", `fetch failed (CORS missing or unreachable): ${e.message}`);
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
const res = await f(repoUrl, { headers: { Origin: origin, Range: "bytes=0-0" } });
|
|
402
|
+
await res.body?.cancel();
|
|
403
|
+
if (res.status !== 404)
|
|
404
|
+
add("range", res.status === 206 ? "pass" : "warn", res.status === 206 ? "206 Partial Content" : `Range ignored (HTTP ${res.status}); optional`);
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
add("range", "fail", `fetch failed: ${e.message}`);
|
|
408
|
+
}
|
|
409
|
+
const writeUrl = opts.writeUrl ?? repoUrl;
|
|
410
|
+
for (const method of ["PUT", "POST", "DELETE"]) {
|
|
411
|
+
try {
|
|
412
|
+
const res = await f(writeUrl, { method: "OPTIONS", headers: { Origin: origin, "Access-Control-Request-Method": method, "Access-Control-Request-Headers": PUT_HEADERS.join(",") } });
|
|
413
|
+
await res.body?.cancel();
|
|
414
|
+
const list = (h) => (res.headers.get(h) ?? "").toLowerCase().split(/[\s,]+/);
|
|
415
|
+
const has = (l, x) => l.includes("*") || l.includes(x.toLowerCase());
|
|
416
|
+
const missing = res.ok ? [...(has(list("access-control-allow-methods"), method) ? [] : [method]), ...PUT_HEADERS.filter((h) => !has(list("access-control-allow-headers"), h))] : [];
|
|
417
|
+
const ok = res.ok && allowsOrigin(res.headers) && missing.length === 0;
|
|
418
|
+
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(", ")}` : ""}`);
|
|
419
|
+
}
|
|
420
|
+
catch (e) {
|
|
421
|
+
add(`preflight ${method}`, "fail", `fetch failed: ${e.message}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return { ok: !checks.some((c) => c.status === "fail"), checks, snippets: corsSnippets(origin === "https://example.com" ? "*" : origin) };
|
|
425
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -35,13 +35,25 @@ export interface Game {
|
|
|
35
35
|
/** Non-empty, newest first. */
|
|
36
36
|
versions: Version[];
|
|
37
37
|
}
|
|
38
|
+
/** A file of games listed by repo.json. Content-addressed: its bytes must hash to `sha256`. */
|
|
39
|
+
export interface Shard {
|
|
40
|
+
url: string;
|
|
41
|
+
/** Lowercase hex SHA-256 of the shard file, exactly as stored. */
|
|
42
|
+
sha256: string;
|
|
43
|
+
/** Bytes. */
|
|
44
|
+
size: number;
|
|
45
|
+
/** Number of games in it; advisory. */
|
|
46
|
+
count?: number;
|
|
47
|
+
}
|
|
38
48
|
export interface Repo {
|
|
39
49
|
schema: 1;
|
|
40
50
|
name: string;
|
|
41
51
|
description?: string;
|
|
42
52
|
homepage?: string;
|
|
43
53
|
updated: string;
|
|
54
|
+
/** Inline games; with `shards`, these come first. */
|
|
44
55
|
games: Game[];
|
|
56
|
+
shards?: Shard[];
|
|
45
57
|
}
|
|
46
58
|
/** Something skipped or dropped while parsing; `path` is a JSON path such as `games[3].versions[0]`. */
|
|
47
59
|
export interface Warning {
|
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
|
-
*
|
|
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"
|
|
15
|
+
export declare function verifyArchive(data: Uint8Array | ArrayBuffer | Blob | ReadableStream<Uint8Array>, file: Pick<ArchiveFile, "sha256" | "size">, options?: HashOptions): Promise<VerifyResult>;
|