@broberg/s3 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,83 @@
1
- # Temporary Holding Version
1
+ # @broberg/s3
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Upload and download files of **any size** directly between the browser and S3-compatible
4
+ storage — Cloudflare **R2 in the EU** by default; Tigris, AWS or MinIO by `endpoint`.
5
+ The site's server only issues short-lived, one-object signatures. **Bytes never pass
6
+ through it**, and the storage key never leaves it.
7
+
8
+ ```bash
9
+ npm i @broberg/s3
10
+ ```
11
+
12
+ ## Server (Hono / Bun)
13
+
14
+ ```ts
15
+ import { createS3Handler, createS3SignerFromEnv } from "@broberg/s3";
16
+
17
+ const s3 = createS3Handler(createS3SignerFromEnv(), {
18
+ // YOUR check — e.g. BID session + allowlist. Return null to refuse.
19
+ authorize: async (req) => {
20
+ const user = await getSession(req);
21
+ return user && ALLOWED.has(user.email) ? { prefix: `uploads/${user.sub}` } : null;
22
+ },
23
+ maxBytes: 20 * 1024 ** 3, // default 20 GiB; checked at start AND at complete
24
+ allowedTypes: ["video/*"], // declared type, not sniffed
25
+ });
26
+
27
+ app.post("/api/s3", (c) => s3(c.req.raw));
28
+ ```
29
+
30
+ Next.js: `export const POST = (req: Request) => s3(req);`
31
+
32
+ Environment (nothing set → every call answers **503 not_configured**; half set → throws at boot):
33
+
34
+ | R2 (EU by default) | Any S3-compatible |
35
+ |---|---|
36
+ | `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, optional `R2_JURISDICTION=eu\|default` | `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET`, optional `S3_REGION` |
37
+
38
+ ## Browser
39
+
40
+ ```ts
41
+ import { uploadFile, cancelUpload, downloadUrl } from "@broberg/s3/client";
42
+
43
+ const ctl = new AbortController();
44
+ const { key, size } = await uploadFile(file, {
45
+ endpoint: "/api/s3",
46
+ signal: ctl.signal, // abort = PAUSE; calling uploadFile again on the same file resumes
47
+ onProgress: (p) => render(p.percent, p.bytesPerSecond, p.secondsLeft),
48
+ });
49
+ const link = await downloadUrl(key, { endpoint: "/api/s3", filename: "video.mp4" });
50
+ await cancelUpload(file, { endpoint: "/api/s3" }); // discard an interrupted upload for good
51
+ ```
52
+
53
+ Preact: `import { useUpload } from "@broberg/s3/preact"` → `{ status, progress, error, result, start, pause, cancel }`.
54
+ **There is no visual component** — the site owns its design (and its `data-testid`s).
55
+
56
+ ## Security — what is enforced
57
+
58
+ | | |
59
+ |---|---|
60
+ | No `authorize` → | **401**, nothing reaches the bucket |
61
+ | Key outside the user's `prefix/` (incl. `..`, `%2e`, `//`) → | **403**, every action, upload and download |
62
+ | Larger than `maxBytes` → | **413** at start; and at complete the real size is read from the bucket — a lie is discarded |
63
+ | Type not in `allowedTypes` → | **415** |
64
+ | Not `content-type: application/json` (a cross-site form post) → | **415**, nothing happens |
65
+ | Not configured → | **503** |
66
+ | Signed URL lifetime | ≤ **15 min**, one object (and one part) |
67
+ | The secret key | never in any response (tested) |
68
+
69
+ ## Bucket setup (once, by whoever creates the bucket)
70
+
71
+ **CORS** — without `ExposeHeaders: ETag` the browser cannot finish a multipart upload
72
+ (the client says so in its error):
73
+
74
+ ```json
75
+ [{ "AllowedOrigins": ["https://your.site"], "AllowedMethods": ["PUT", "GET"],
76
+ "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3600 }]
77
+ ```
78
+
79
+ **Lifecycle** — abort incomplete multipart uploads after 1–7 days, so abandoned parts do not
80
+ cost storage forever.
81
+
82
+ Part size: 16 MiB by default, grown automatically so a file never needs more than 10,000
83
+ parts (an 8 GB video = 512 parts; 1 TB fits).
@@ -0,0 +1,144 @@
1
+ // src/client.ts
2
+ var UploadError = class extends Error {
3
+ constructor(message, code, status) {
4
+ super(message);
5
+ this.code = code;
6
+ this.status = status;
7
+ this.name = "UploadError";
8
+ }
9
+ code;
10
+ status;
11
+ };
12
+ var defaultStorage = () => {
13
+ try {
14
+ return globalThis.localStorage ?? null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ };
19
+ var resumeId = (endpoint, file) => `broberg-s3:${endpoint}:${file.name ?? "blob"}:${file.size}:${file.lastModified ?? 0}`;
20
+ async function api(opts, body) {
21
+ const f = opts.fetch ?? fetch;
22
+ const res = await f(opts.endpoint, {
23
+ method: "POST",
24
+ credentials: "same-origin",
25
+ headers: { "content-type": "application/json", ...opts.headers },
26
+ body: JSON.stringify(body),
27
+ signal: opts.signal
28
+ });
29
+ const data = await res.json().catch(() => ({}));
30
+ if (!res.ok) throw new UploadError(data.error?.message ?? `upload service answered ${res.status}`, data.error?.code ?? "http_error", res.status);
31
+ return data;
32
+ }
33
+ var sleep = (ms, signal) => new Promise((resolve, reject) => {
34
+ const t = setTimeout(resolve, ms);
35
+ signal?.addEventListener("abort", () => {
36
+ clearTimeout(t);
37
+ reject(signal.reason);
38
+ }, { once: true });
39
+ });
40
+ async function uploadFile(file, opts) {
41
+ const storage = opts.storage === void 0 ? defaultStorage() : opts.storage;
42
+ const id = resumeId(opts.endpoint, file);
43
+ const save = (r2) => {
44
+ try {
45
+ storage?.setItem(id, JSON.stringify(r2));
46
+ } catch {
47
+ }
48
+ };
49
+ const f = opts.fetch ?? fetch;
50
+ let rec = null;
51
+ const raw = storage?.getItem(id);
52
+ if (raw) {
53
+ try {
54
+ const old = JSON.parse(raw);
55
+ const { parts: parts2 } = await api(opts, { action: "list", key: old.key, uploadId: old.uploadId });
56
+ rec = { ...old, parts: Object.fromEntries(parts2.map((p) => [p.partNumber, p.etag])) };
57
+ } catch (e) {
58
+ if (opts.signal?.aborted) throw e;
59
+ storage?.removeItem(id);
60
+ }
61
+ }
62
+ if (!rec) {
63
+ const s = await api(opts, {
64
+ action: "start",
65
+ filename: file.name ?? "file",
66
+ size: file.size,
67
+ type: file.type || "application/octet-stream"
68
+ });
69
+ rec = { ...s, parts: {} };
70
+ save(rec);
71
+ }
72
+ const r = rec;
73
+ const partBytes = (n) => Math.min(r.partSize, file.size - (n - 1) * r.partSize);
74
+ let sent = Object.keys(r.parts).reduce((s, n) => s + partBytes(Number(n)), 0);
75
+ const samples = [[Date.now(), sent]];
76
+ const report = () => {
77
+ const now = Date.now();
78
+ samples.push([now, sent]);
79
+ while (samples.length > 2 && now - samples[0][0] > 1e4) samples.shift();
80
+ const [t0, b0] = samples[0];
81
+ const bps = now > t0 ? (sent - b0) * 1e3 / (now - t0) : 0;
82
+ opts.onProgress?.({
83
+ sentBytes: sent,
84
+ totalBytes: file.size,
85
+ percent: file.size ? sent / file.size * 100 : 100,
86
+ bytesPerSecond: bps,
87
+ secondsLeft: bps > 0 ? (file.size - sent) / bps : null,
88
+ partsDone: Object.keys(r.parts).length,
89
+ partsTotal: r.partCount
90
+ });
91
+ };
92
+ report();
93
+ const queue = Array.from({ length: r.partCount }, (_, i) => i + 1).filter((n) => !r.parts[n]);
94
+ const retries = opts.retries ?? 5;
95
+ async function sendPart(n) {
96
+ for (let attempt = 1; ; attempt++) {
97
+ opts.signal?.throwIfAborted();
98
+ try {
99
+ const { urls } = await api(opts, { action: "sign", key: r.key, uploadId: r.uploadId, partNumbers: [n] });
100
+ const start = (n - 1) * r.partSize;
101
+ const res = await f(urls[0].url, { method: "PUT", body: file.slice(start, start + partBytes(n)), signal: opts.signal });
102
+ if (!res.ok) throw new UploadError(`storage answered ${res.status} for part ${n}`, "part_failed", res.status);
103
+ const etag = res.headers.get("etag");
104
+ if (!etag) throw new UploadError("the bucket did not expose the ETag header \u2014 its CORS rule needs ExposeHeaders: ETag", "cors_etag", 0);
105
+ r.parts[n] = etag;
106
+ sent += partBytes(n);
107
+ save(r);
108
+ report();
109
+ return;
110
+ } catch (e) {
111
+ if (opts.signal?.aborted) throw e;
112
+ if (e instanceof UploadError && (e.code === "cors_etag" || e.status !== void 0 && e.status >= 400 && e.status < 500 && e.code !== "part_failed")) throw e;
113
+ if (attempt >= retries) throw e;
114
+ await sleep(Math.min(3e4, 500 * 2 ** attempt), opts.signal);
115
+ }
116
+ }
117
+ }
118
+ const workers = Array.from({ length: Math.max(1, Math.min(opts.concurrency ?? 4, queue.length)) }, async () => {
119
+ for (let n = queue.shift(); n !== void 0; n = queue.shift()) await sendPart(n);
120
+ });
121
+ await Promise.all(workers);
122
+ opts.signal?.throwIfAborted();
123
+ const parts = Object.entries(r.parts).map(([n, etag]) => ({ partNumber: Number(n), etag })).sort((a, b) => a.partNumber - b.partNumber);
124
+ const done = await api(opts, { action: "complete", key: r.key, uploadId: r.uploadId, parts });
125
+ storage?.removeItem(id);
126
+ return done;
127
+ }
128
+ async function cancelUpload(file, opts) {
129
+ const storage = opts.storage === void 0 ? defaultStorage() : opts.storage;
130
+ const id = resumeId(opts.endpoint, file);
131
+ const raw = storage?.getItem(id);
132
+ if (!raw) return;
133
+ const rec = JSON.parse(raw);
134
+ storage?.removeItem(id);
135
+ await api(opts, { action: "abort", key: rec.key, uploadId: rec.uploadId });
136
+ }
137
+ async function downloadUrl(key, opts) {
138
+ const { url } = await api(opts, { action: "download", key, filename: opts.filename });
139
+ return url;
140
+ }
141
+
142
+ export { UploadError, cancelUpload, downloadUrl, resumeId, uploadFile };
143
+ //# sourceMappingURL=chunk-HBRUN65D.js.map
144
+ //# sourceMappingURL=chunk-HBRUN65D.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts"],"names":["r","parts"],"mappings":"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","file":"chunk-HBRUN65D.js","sourcesContent":["// F111.2 — the browser half. Sends the file in parts DIRECTLY to the bucket using\n// URLs the site's server signed; the site's server never sees a byte. Remembers\n// finished parts so a dropped connection resumes instead of starting over.\n\nexport interface UploadProgress {\n sentBytes: number;\n totalBytes: number;\n /** 0–100 */\n percent: number;\n /** Measured over the last ~10 s. */\n bytesPerSecond: number;\n /** null until there is a speed to estimate from. */\n secondsLeft: number | null;\n partsDone: number;\n partsTotal: number;\n}\n\nexport interface UploadOptions {\n /** The site's route that runs createS3Handler, e.g. \"/api/s3\". */\n endpoint: string;\n /** Parts in flight at once. Default 4. */\n concurrency?: number;\n /** Attempts per part before giving up. Default 5. */\n retries?: number;\n onProgress?(p: UploadProgress): void;\n /** Aborting PAUSES: finished parts are kept and the next uploadFile on the same file resumes. */\n signal?: AbortSignal;\n /** Extra headers for calls to `endpoint` (e.g. a CSRF token). Cookies are sent (same-origin). */\n headers?: Record<string, string>;\n /** Where resume state lives. Default localStorage when present; null disables resume. */\n storage?: Pick<Storage, \"getItem\" | \"setItem\" | \"removeItem\"> | null;\n /** Injected for tests. */\n fetch?: typeof fetch;\n}\n\nexport interface UploadResult { key: string; size: number }\n\nexport class UploadError extends Error {\n constructor(message: string, readonly code: string, readonly status?: number) {\n super(message);\n this.name = \"UploadError\";\n }\n}\n\ntype FileLike = Blob & { name?: string; lastModified?: number };\n\ninterface ResumeRecord { key: string; uploadId: string; partSize: number; partCount: number; parts: Record<number, string> }\n\nconst defaultStorage = () => {\n try { return (globalThis as { localStorage?: Storage }).localStorage ?? null; } catch { return null; }\n};\n\nexport const resumeId = (endpoint: string, file: FileLike) =>\n `broberg-s3:${endpoint}:${file.name ?? \"blob\"}:${file.size}:${file.lastModified ?? 0}`;\n\nasync function api<T>(opts: UploadOptions, body: Record<string, unknown>): Promise<T> {\n const f = opts.fetch ?? fetch;\n const res = await f(opts.endpoint, {\n method: \"POST\",\n credentials: \"same-origin\",\n headers: { \"content-type\": \"application/json\", ...opts.headers },\n body: JSON.stringify(body),\n signal: opts.signal,\n });\n const data = (await res.json().catch(() => ({}))) as { error?: { code: string; message: string } };\n if (!res.ok) throw new UploadError(data.error?.message ?? `upload service answered ${res.status}`, data.error?.code ?? \"http_error\", res.status);\n return data as T;\n}\n\nconst sleep = (ms: number, signal?: AbortSignal) =>\n new Promise<void>((resolve, reject) => {\n const t = setTimeout(resolve, ms);\n signal?.addEventListener(\"abort\", () => { clearTimeout(t); reject(signal.reason); }, { once: true });\n });\n\n/** Upload `file`; resumes automatically if an earlier attempt on the same file was interrupted. */\nexport async function uploadFile(file: FileLike, opts: UploadOptions): Promise<UploadResult> {\n const storage = opts.storage === undefined ? defaultStorage() : opts.storage;\n const id = resumeId(opts.endpoint, file);\n const save = (r: ResumeRecord) => { try { storage?.setItem(id, JSON.stringify(r)); } catch { /* quota: resume is best-effort, upload continues */ } };\n const f = opts.fetch ?? fetch;\n\n let rec: ResumeRecord | null = null;\n const raw = storage?.getItem(id);\n if (raw) {\n try {\n const old = JSON.parse(raw) as ResumeRecord;\n // The bucket is the truth: ask which parts it actually holds.\n const { parts } = await api<{ parts: { partNumber: number; etag: string }[] }>(opts, { action: \"list\", key: old.key, uploadId: old.uploadId });\n rec = { ...old, parts: Object.fromEntries(parts.map((p) => [p.partNumber, p.etag])) };\n } catch (e) {\n if (opts.signal?.aborted) throw e;\n storage?.removeItem(id); // expired or foreign upload: start fresh\n }\n }\n if (!rec) {\n const s = await api<{ key: string; uploadId: string; partSize: number; partCount: number }>(opts, {\n action: \"start\", filename: file.name ?? \"file\", size: file.size, type: file.type || \"application/octet-stream\",\n });\n rec = { ...s, parts: {} };\n save(rec);\n }\n const r = rec;\n\n const partBytes = (n: number) => Math.min(r.partSize, file.size - (n - 1) * r.partSize);\n let sent = Object.keys(r.parts).reduce((s, n) => s + partBytes(Number(n)), 0);\n const samples: Array<[number, number]> = [[Date.now(), sent]];\n const report = () => {\n const now = Date.now();\n samples.push([now, sent]);\n while (samples.length > 2 && now - samples[0]![0] > 10_000) samples.shift();\n const [t0, b0] = samples[0]!;\n const bps = now > t0 ? ((sent - b0) * 1000) / (now - t0) : 0;\n opts.onProgress?.({\n sentBytes: sent, totalBytes: file.size, percent: file.size ? (sent / file.size) * 100 : 100,\n bytesPerSecond: bps, secondsLeft: bps > 0 ? (file.size - sent) / bps : null,\n partsDone: Object.keys(r.parts).length, partsTotal: r.partCount,\n });\n };\n report();\n\n const queue = Array.from({ length: r.partCount }, (_, i) => i + 1).filter((n) => !r.parts[n]);\n const retries = opts.retries ?? 5;\n\n async function sendPart(n: number) {\n for (let attempt = 1; ; attempt++) {\n opts.signal?.throwIfAborted(); // pause is honoured here even if a transport ignores the signal\n try {\n const { urls } = await api<{ urls: { partNumber: number; url: string }[] }>(opts, { action: \"sign\", key: r.key, uploadId: r.uploadId, partNumbers: [n] });\n const start = (n - 1) * r.partSize;\n const res = await f(urls[0]!.url, { method: \"PUT\", body: file.slice(start, start + partBytes(n)), signal: opts.signal });\n if (!res.ok) throw new UploadError(`storage answered ${res.status} for part ${n}`, \"part_failed\", res.status);\n const etag = res.headers.get(\"etag\");\n if (!etag) throw new UploadError(\"the bucket did not expose the ETag header — its CORS rule needs ExposeHeaders: ETag\", \"cors_etag\", 0);\n r.parts[n] = etag;\n sent += partBytes(n);\n save(r);\n report();\n return;\n } catch (e) {\n if (opts.signal?.aborted) throw e;\n if (e instanceof UploadError && (e.code === \"cors_etag\" || (e.status !== undefined && e.status >= 400 && e.status < 500 && e.code !== \"part_failed\"))) throw e;\n if (attempt >= retries) throw e;\n await sleep(Math.min(30_000, 500 * 2 ** attempt), opts.signal);\n }\n }\n }\n\n const workers = Array.from({ length: Math.max(1, Math.min(opts.concurrency ?? 4, queue.length)) }, async () => {\n for (let n = queue.shift(); n !== undefined; n = queue.shift()) await sendPart(n);\n });\n await Promise.all(workers);\n opts.signal?.throwIfAborted();\n\n const parts = Object.entries(r.parts).map(([n, etag]) => ({ partNumber: Number(n), etag })).sort((a, b) => a.partNumber - b.partNumber);\n const done = await api<UploadResult>(opts, { action: \"complete\", key: r.key, uploadId: r.uploadId, parts });\n storage?.removeItem(id);\n return done;\n}\n\n/** Stop an interrupted upload for good: the bucket discards its parts and resume state is cleared. */\nexport async function cancelUpload(file: FileLike, opts: Omit<UploadOptions, \"signal\">): Promise<void> {\n const storage = opts.storage === undefined ? defaultStorage() : opts.storage;\n const id = resumeId(opts.endpoint, file);\n const raw = storage?.getItem(id);\n if (!raw) return;\n const rec = JSON.parse(raw) as ResumeRecord;\n storage?.removeItem(id);\n await api(opts, { action: \"abort\", key: rec.key, uploadId: rec.uploadId });\n}\n\n/** A short-lived signed link to download `key` (which must be under your prefix). */\nexport async function downloadUrl(key: string, opts: Pick<UploadOptions, \"endpoint\" | \"headers\" | \"fetch\"> & { filename?: string }): Promise<string> {\n const { url } = await api<{ url: string }>(opts, { action: \"download\", key, filename: opts.filename });\n return url;\n}\n"]}
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ // src/client.ts
4
+ var UploadError = class extends Error {
5
+ constructor(message, code, status) {
6
+ super(message);
7
+ this.code = code;
8
+ this.status = status;
9
+ this.name = "UploadError";
10
+ }
11
+ code;
12
+ status;
13
+ };
14
+ var defaultStorage = () => {
15
+ try {
16
+ return globalThis.localStorage ?? null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ };
21
+ var resumeId = (endpoint, file) => `broberg-s3:${endpoint}:${file.name ?? "blob"}:${file.size}:${file.lastModified ?? 0}`;
22
+ async function api(opts, body) {
23
+ const f = opts.fetch ?? fetch;
24
+ const res = await f(opts.endpoint, {
25
+ method: "POST",
26
+ credentials: "same-origin",
27
+ headers: { "content-type": "application/json", ...opts.headers },
28
+ body: JSON.stringify(body),
29
+ signal: opts.signal
30
+ });
31
+ const data = await res.json().catch(() => ({}));
32
+ if (!res.ok) throw new UploadError(data.error?.message ?? `upload service answered ${res.status}`, data.error?.code ?? "http_error", res.status);
33
+ return data;
34
+ }
35
+ var sleep = (ms, signal) => new Promise((resolve, reject) => {
36
+ const t = setTimeout(resolve, ms);
37
+ signal?.addEventListener("abort", () => {
38
+ clearTimeout(t);
39
+ reject(signal.reason);
40
+ }, { once: true });
41
+ });
42
+ async function uploadFile(file, opts) {
43
+ const storage = opts.storage === void 0 ? defaultStorage() : opts.storage;
44
+ const id = resumeId(opts.endpoint, file);
45
+ const save = (r2) => {
46
+ try {
47
+ storage?.setItem(id, JSON.stringify(r2));
48
+ } catch {
49
+ }
50
+ };
51
+ const f = opts.fetch ?? fetch;
52
+ let rec = null;
53
+ const raw = storage?.getItem(id);
54
+ if (raw) {
55
+ try {
56
+ const old = JSON.parse(raw);
57
+ const { parts: parts2 } = await api(opts, { action: "list", key: old.key, uploadId: old.uploadId });
58
+ rec = { ...old, parts: Object.fromEntries(parts2.map((p) => [p.partNumber, p.etag])) };
59
+ } catch (e) {
60
+ if (opts.signal?.aborted) throw e;
61
+ storage?.removeItem(id);
62
+ }
63
+ }
64
+ if (!rec) {
65
+ const s = await api(opts, {
66
+ action: "start",
67
+ filename: file.name ?? "file",
68
+ size: file.size,
69
+ type: file.type || "application/octet-stream"
70
+ });
71
+ rec = { ...s, parts: {} };
72
+ save(rec);
73
+ }
74
+ const r = rec;
75
+ const partBytes = (n) => Math.min(r.partSize, file.size - (n - 1) * r.partSize);
76
+ let sent = Object.keys(r.parts).reduce((s, n) => s + partBytes(Number(n)), 0);
77
+ const samples = [[Date.now(), sent]];
78
+ const report = () => {
79
+ const now = Date.now();
80
+ samples.push([now, sent]);
81
+ while (samples.length > 2 && now - samples[0][0] > 1e4) samples.shift();
82
+ const [t0, b0] = samples[0];
83
+ const bps = now > t0 ? (sent - b0) * 1e3 / (now - t0) : 0;
84
+ opts.onProgress?.({
85
+ sentBytes: sent,
86
+ totalBytes: file.size,
87
+ percent: file.size ? sent / file.size * 100 : 100,
88
+ bytesPerSecond: bps,
89
+ secondsLeft: bps > 0 ? (file.size - sent) / bps : null,
90
+ partsDone: Object.keys(r.parts).length,
91
+ partsTotal: r.partCount
92
+ });
93
+ };
94
+ report();
95
+ const queue = Array.from({ length: r.partCount }, (_, i) => i + 1).filter((n) => !r.parts[n]);
96
+ const retries = opts.retries ?? 5;
97
+ async function sendPart(n) {
98
+ for (let attempt = 1; ; attempt++) {
99
+ opts.signal?.throwIfAborted();
100
+ try {
101
+ const { urls } = await api(opts, { action: "sign", key: r.key, uploadId: r.uploadId, partNumbers: [n] });
102
+ const start = (n - 1) * r.partSize;
103
+ const res = await f(urls[0].url, { method: "PUT", body: file.slice(start, start + partBytes(n)), signal: opts.signal });
104
+ if (!res.ok) throw new UploadError(`storage answered ${res.status} for part ${n}`, "part_failed", res.status);
105
+ const etag = res.headers.get("etag");
106
+ if (!etag) throw new UploadError("the bucket did not expose the ETag header \u2014 its CORS rule needs ExposeHeaders: ETag", "cors_etag", 0);
107
+ r.parts[n] = etag;
108
+ sent += partBytes(n);
109
+ save(r);
110
+ report();
111
+ return;
112
+ } catch (e) {
113
+ if (opts.signal?.aborted) throw e;
114
+ if (e instanceof UploadError && (e.code === "cors_etag" || e.status !== void 0 && e.status >= 400 && e.status < 500 && e.code !== "part_failed")) throw e;
115
+ if (attempt >= retries) throw e;
116
+ await sleep(Math.min(3e4, 500 * 2 ** attempt), opts.signal);
117
+ }
118
+ }
119
+ }
120
+ const workers = Array.from({ length: Math.max(1, Math.min(opts.concurrency ?? 4, queue.length)) }, async () => {
121
+ for (let n = queue.shift(); n !== void 0; n = queue.shift()) await sendPart(n);
122
+ });
123
+ await Promise.all(workers);
124
+ opts.signal?.throwIfAborted();
125
+ const parts = Object.entries(r.parts).map(([n, etag]) => ({ partNumber: Number(n), etag })).sort((a, b) => a.partNumber - b.partNumber);
126
+ const done = await api(opts, { action: "complete", key: r.key, uploadId: r.uploadId, parts });
127
+ storage?.removeItem(id);
128
+ return done;
129
+ }
130
+ async function cancelUpload(file, opts) {
131
+ const storage = opts.storage === void 0 ? defaultStorage() : opts.storage;
132
+ const id = resumeId(opts.endpoint, file);
133
+ const raw = storage?.getItem(id);
134
+ if (!raw) return;
135
+ const rec = JSON.parse(raw);
136
+ storage?.removeItem(id);
137
+ await api(opts, { action: "abort", key: rec.key, uploadId: rec.uploadId });
138
+ }
139
+ async function downloadUrl(key, opts) {
140
+ const { url } = await api(opts, { action: "download", key, filename: opts.filename });
141
+ return url;
142
+ }
143
+
144
+ exports.UploadError = UploadError;
145
+ exports.cancelUpload = cancelUpload;
146
+ exports.downloadUrl = downloadUrl;
147
+ exports.resumeId = resumeId;
148
+ exports.uploadFile = uploadFile;
149
+ //# sourceMappingURL=client.cjs.map
150
+ //# sourceMappingURL=client.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts"],"names":["r","parts"],"mappings":"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","file":"client.cjs","sourcesContent":["// F111.2 — the browser half. Sends the file in parts DIRECTLY to the bucket using\n// URLs the site's server signed; the site's server never sees a byte. Remembers\n// finished parts so a dropped connection resumes instead of starting over.\n\nexport interface UploadProgress {\n sentBytes: number;\n totalBytes: number;\n /** 0–100 */\n percent: number;\n /** Measured over the last ~10 s. */\n bytesPerSecond: number;\n /** null until there is a speed to estimate from. */\n secondsLeft: number | null;\n partsDone: number;\n partsTotal: number;\n}\n\nexport interface UploadOptions {\n /** The site's route that runs createS3Handler, e.g. \"/api/s3\". */\n endpoint: string;\n /** Parts in flight at once. Default 4. */\n concurrency?: number;\n /** Attempts per part before giving up. Default 5. */\n retries?: number;\n onProgress?(p: UploadProgress): void;\n /** Aborting PAUSES: finished parts are kept and the next uploadFile on the same file resumes. */\n signal?: AbortSignal;\n /** Extra headers for calls to `endpoint` (e.g. a CSRF token). Cookies are sent (same-origin). */\n headers?: Record<string, string>;\n /** Where resume state lives. Default localStorage when present; null disables resume. */\n storage?: Pick<Storage, \"getItem\" | \"setItem\" | \"removeItem\"> | null;\n /** Injected for tests. */\n fetch?: typeof fetch;\n}\n\nexport interface UploadResult { key: string; size: number }\n\nexport class UploadError extends Error {\n constructor(message: string, readonly code: string, readonly status?: number) {\n super(message);\n this.name = \"UploadError\";\n }\n}\n\ntype FileLike = Blob & { name?: string; lastModified?: number };\n\ninterface ResumeRecord { key: string; uploadId: string; partSize: number; partCount: number; parts: Record<number, string> }\n\nconst defaultStorage = () => {\n try { return (globalThis as { localStorage?: Storage }).localStorage ?? null; } catch { return null; }\n};\n\nexport const resumeId = (endpoint: string, file: FileLike) =>\n `broberg-s3:${endpoint}:${file.name ?? \"blob\"}:${file.size}:${file.lastModified ?? 0}`;\n\nasync function api<T>(opts: UploadOptions, body: Record<string, unknown>): Promise<T> {\n const f = opts.fetch ?? fetch;\n const res = await f(opts.endpoint, {\n method: \"POST\",\n credentials: \"same-origin\",\n headers: { \"content-type\": \"application/json\", ...opts.headers },\n body: JSON.stringify(body),\n signal: opts.signal,\n });\n const data = (await res.json().catch(() => ({}))) as { error?: { code: string; message: string } };\n if (!res.ok) throw new UploadError(data.error?.message ?? `upload service answered ${res.status}`, data.error?.code ?? \"http_error\", res.status);\n return data as T;\n}\n\nconst sleep = (ms: number, signal?: AbortSignal) =>\n new Promise<void>((resolve, reject) => {\n const t = setTimeout(resolve, ms);\n signal?.addEventListener(\"abort\", () => { clearTimeout(t); reject(signal.reason); }, { once: true });\n });\n\n/** Upload `file`; resumes automatically if an earlier attempt on the same file was interrupted. */\nexport async function uploadFile(file: FileLike, opts: UploadOptions): Promise<UploadResult> {\n const storage = opts.storage === undefined ? defaultStorage() : opts.storage;\n const id = resumeId(opts.endpoint, file);\n const save = (r: ResumeRecord) => { try { storage?.setItem(id, JSON.stringify(r)); } catch { /* quota: resume is best-effort, upload continues */ } };\n const f = opts.fetch ?? fetch;\n\n let rec: ResumeRecord | null = null;\n const raw = storage?.getItem(id);\n if (raw) {\n try {\n const old = JSON.parse(raw) as ResumeRecord;\n // The bucket is the truth: ask which parts it actually holds.\n const { parts } = await api<{ parts: { partNumber: number; etag: string }[] }>(opts, { action: \"list\", key: old.key, uploadId: old.uploadId });\n rec = { ...old, parts: Object.fromEntries(parts.map((p) => [p.partNumber, p.etag])) };\n } catch (e) {\n if (opts.signal?.aborted) throw e;\n storage?.removeItem(id); // expired or foreign upload: start fresh\n }\n }\n if (!rec) {\n const s = await api<{ key: string; uploadId: string; partSize: number; partCount: number }>(opts, {\n action: \"start\", filename: file.name ?? \"file\", size: file.size, type: file.type || \"application/octet-stream\",\n });\n rec = { ...s, parts: {} };\n save(rec);\n }\n const r = rec;\n\n const partBytes = (n: number) => Math.min(r.partSize, file.size - (n - 1) * r.partSize);\n let sent = Object.keys(r.parts).reduce((s, n) => s + partBytes(Number(n)), 0);\n const samples: Array<[number, number]> = [[Date.now(), sent]];\n const report = () => {\n const now = Date.now();\n samples.push([now, sent]);\n while (samples.length > 2 && now - samples[0]![0] > 10_000) samples.shift();\n const [t0, b0] = samples[0]!;\n const bps = now > t0 ? ((sent - b0) * 1000) / (now - t0) : 0;\n opts.onProgress?.({\n sentBytes: sent, totalBytes: file.size, percent: file.size ? (sent / file.size) * 100 : 100,\n bytesPerSecond: bps, secondsLeft: bps > 0 ? (file.size - sent) / bps : null,\n partsDone: Object.keys(r.parts).length, partsTotal: r.partCount,\n });\n };\n report();\n\n const queue = Array.from({ length: r.partCount }, (_, i) => i + 1).filter((n) => !r.parts[n]);\n const retries = opts.retries ?? 5;\n\n async function sendPart(n: number) {\n for (let attempt = 1; ; attempt++) {\n opts.signal?.throwIfAborted(); // pause is honoured here even if a transport ignores the signal\n try {\n const { urls } = await api<{ urls: { partNumber: number; url: string }[] }>(opts, { action: \"sign\", key: r.key, uploadId: r.uploadId, partNumbers: [n] });\n const start = (n - 1) * r.partSize;\n const res = await f(urls[0]!.url, { method: \"PUT\", body: file.slice(start, start + partBytes(n)), signal: opts.signal });\n if (!res.ok) throw new UploadError(`storage answered ${res.status} for part ${n}`, \"part_failed\", res.status);\n const etag = res.headers.get(\"etag\");\n if (!etag) throw new UploadError(\"the bucket did not expose the ETag header — its CORS rule needs ExposeHeaders: ETag\", \"cors_etag\", 0);\n r.parts[n] = etag;\n sent += partBytes(n);\n save(r);\n report();\n return;\n } catch (e) {\n if (opts.signal?.aborted) throw e;\n if (e instanceof UploadError && (e.code === \"cors_etag\" || (e.status !== undefined && e.status >= 400 && e.status < 500 && e.code !== \"part_failed\"))) throw e;\n if (attempt >= retries) throw e;\n await sleep(Math.min(30_000, 500 * 2 ** attempt), opts.signal);\n }\n }\n }\n\n const workers = Array.from({ length: Math.max(1, Math.min(opts.concurrency ?? 4, queue.length)) }, async () => {\n for (let n = queue.shift(); n !== undefined; n = queue.shift()) await sendPart(n);\n });\n await Promise.all(workers);\n opts.signal?.throwIfAborted();\n\n const parts = Object.entries(r.parts).map(([n, etag]) => ({ partNumber: Number(n), etag })).sort((a, b) => a.partNumber - b.partNumber);\n const done = await api<UploadResult>(opts, { action: \"complete\", key: r.key, uploadId: r.uploadId, parts });\n storage?.removeItem(id);\n return done;\n}\n\n/** Stop an interrupted upload for good: the bucket discards its parts and resume state is cleared. */\nexport async function cancelUpload(file: FileLike, opts: Omit<UploadOptions, \"signal\">): Promise<void> {\n const storage = opts.storage === undefined ? defaultStorage() : opts.storage;\n const id = resumeId(opts.endpoint, file);\n const raw = storage?.getItem(id);\n if (!raw) return;\n const rec = JSON.parse(raw) as ResumeRecord;\n storage?.removeItem(id);\n await api(opts, { action: \"abort\", key: rec.key, uploadId: rec.uploadId });\n}\n\n/** A short-lived signed link to download `key` (which must be under your prefix). */\nexport async function downloadUrl(key: string, opts: Pick<UploadOptions, \"endpoint\" | \"headers\" | \"fetch\"> & { filename?: string }): Promise<string> {\n const { url } = await api<{ url: string }>(opts, { action: \"download\", key, filename: opts.filename });\n return url;\n}\n"]}
@@ -0,0 +1,53 @@
1
+ interface UploadProgress {
2
+ sentBytes: number;
3
+ totalBytes: number;
4
+ /** 0–100 */
5
+ percent: number;
6
+ /** Measured over the last ~10 s. */
7
+ bytesPerSecond: number;
8
+ /** null until there is a speed to estimate from. */
9
+ secondsLeft: number | null;
10
+ partsDone: number;
11
+ partsTotal: number;
12
+ }
13
+ interface UploadOptions {
14
+ /** The site's route that runs createS3Handler, e.g. "/api/s3". */
15
+ endpoint: string;
16
+ /** Parts in flight at once. Default 4. */
17
+ concurrency?: number;
18
+ /** Attempts per part before giving up. Default 5. */
19
+ retries?: number;
20
+ onProgress?(p: UploadProgress): void;
21
+ /** Aborting PAUSES: finished parts are kept and the next uploadFile on the same file resumes. */
22
+ signal?: AbortSignal;
23
+ /** Extra headers for calls to `endpoint` (e.g. a CSRF token). Cookies are sent (same-origin). */
24
+ headers?: Record<string, string>;
25
+ /** Where resume state lives. Default localStorage when present; null disables resume. */
26
+ storage?: Pick<Storage, "getItem" | "setItem" | "removeItem"> | null;
27
+ /** Injected for tests. */
28
+ fetch?: typeof fetch;
29
+ }
30
+ interface UploadResult {
31
+ key: string;
32
+ size: number;
33
+ }
34
+ declare class UploadError extends Error {
35
+ readonly code: string;
36
+ readonly status?: number | undefined;
37
+ constructor(message: string, code: string, status?: number | undefined);
38
+ }
39
+ type FileLike = Blob & {
40
+ name?: string;
41
+ lastModified?: number;
42
+ };
43
+ declare const resumeId: (endpoint: string, file: FileLike) => string;
44
+ /** Upload `file`; resumes automatically if an earlier attempt on the same file was interrupted. */
45
+ declare function uploadFile(file: FileLike, opts: UploadOptions): Promise<UploadResult>;
46
+ /** Stop an interrupted upload for good: the bucket discards its parts and resume state is cleared. */
47
+ declare function cancelUpload(file: FileLike, opts: Omit<UploadOptions, "signal">): Promise<void>;
48
+ /** A short-lived signed link to download `key` (which must be under your prefix). */
49
+ declare function downloadUrl(key: string, opts: Pick<UploadOptions, "endpoint" | "headers" | "fetch"> & {
50
+ filename?: string;
51
+ }): Promise<string>;
52
+
53
+ export { UploadError, type UploadOptions, type UploadProgress, type UploadResult, cancelUpload, downloadUrl, resumeId, uploadFile };
@@ -0,0 +1,53 @@
1
+ interface UploadProgress {
2
+ sentBytes: number;
3
+ totalBytes: number;
4
+ /** 0–100 */
5
+ percent: number;
6
+ /** Measured over the last ~10 s. */
7
+ bytesPerSecond: number;
8
+ /** null until there is a speed to estimate from. */
9
+ secondsLeft: number | null;
10
+ partsDone: number;
11
+ partsTotal: number;
12
+ }
13
+ interface UploadOptions {
14
+ /** The site's route that runs createS3Handler, e.g. "/api/s3". */
15
+ endpoint: string;
16
+ /** Parts in flight at once. Default 4. */
17
+ concurrency?: number;
18
+ /** Attempts per part before giving up. Default 5. */
19
+ retries?: number;
20
+ onProgress?(p: UploadProgress): void;
21
+ /** Aborting PAUSES: finished parts are kept and the next uploadFile on the same file resumes. */
22
+ signal?: AbortSignal;
23
+ /** Extra headers for calls to `endpoint` (e.g. a CSRF token). Cookies are sent (same-origin). */
24
+ headers?: Record<string, string>;
25
+ /** Where resume state lives. Default localStorage when present; null disables resume. */
26
+ storage?: Pick<Storage, "getItem" | "setItem" | "removeItem"> | null;
27
+ /** Injected for tests. */
28
+ fetch?: typeof fetch;
29
+ }
30
+ interface UploadResult {
31
+ key: string;
32
+ size: number;
33
+ }
34
+ declare class UploadError extends Error {
35
+ readonly code: string;
36
+ readonly status?: number | undefined;
37
+ constructor(message: string, code: string, status?: number | undefined);
38
+ }
39
+ type FileLike = Blob & {
40
+ name?: string;
41
+ lastModified?: number;
42
+ };
43
+ declare const resumeId: (endpoint: string, file: FileLike) => string;
44
+ /** Upload `file`; resumes automatically if an earlier attempt on the same file was interrupted. */
45
+ declare function uploadFile(file: FileLike, opts: UploadOptions): Promise<UploadResult>;
46
+ /** Stop an interrupted upload for good: the bucket discards its parts and resume state is cleared. */
47
+ declare function cancelUpload(file: FileLike, opts: Omit<UploadOptions, "signal">): Promise<void>;
48
+ /** A short-lived signed link to download `key` (which must be under your prefix). */
49
+ declare function downloadUrl(key: string, opts: Pick<UploadOptions, "endpoint" | "headers" | "fetch"> & {
50
+ filename?: string;
51
+ }): Promise<string>;
52
+
53
+ export { UploadError, type UploadOptions, type UploadProgress, type UploadResult, cancelUpload, downloadUrl, resumeId, uploadFile };
package/dist/client.js ADDED
@@ -0,0 +1,3 @@
1
+ export { UploadError, cancelUpload, downloadUrl, resumeId, uploadFile } from './chunk-HBRUN65D.js';
2
+ //# sourceMappingURL=client.js.map
3
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"client.js"}