@ibanzajoe/uploader 0.2.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,6 +64,57 @@ Inline drop zone that can be embedded in a form.
64
64
 
65
65
  Headless hook — returns `{ open, uploading, files, errors }`.
66
66
 
67
+ ## Theming
68
+
69
+ The picker is styled entirely from `--uploader-*` CSS custom properties, so a
70
+ third-party app can restyle it to match its own brand. There are two ways to do
71
+ it — pick whichever fits your stack.
72
+
73
+ ### 1. The `theme` prop (scoped, per-instance)
74
+
75
+ Pass your design tokens as a plain object. They are applied as inline CSS
76
+ variables on that component's root, so the override is **scoped to that
77
+ instance** — two pickers on one page can carry different themes and nothing
78
+ leaks to the rest of your app. Only the keys you set change; everything else
79
+ falls back to the shipped defaults.
80
+
81
+ ```tsx
82
+ import { PickerOverlay } from '@uploader/react'
83
+ import '@uploader/react/styles.css'
84
+
85
+ <PickerOverlay
86
+ apikey="pk_…"
87
+ open={open}
88
+ onClose={() => setOpen(false)}
89
+ theme={{
90
+ accent: '#4f46e5', // your brand color
91
+ accentSoft: '#eef2ff', // tinted hover/icon background
92
+ accentRing: 'rgba(79,70,229,.28)',
93
+ radius: '12px',
94
+ font: "'Inter', sans-serif",
95
+ }}
96
+ />
97
+ ```
98
+
99
+ `DropPane` takes the same `theme` prop. See the `UploaderTheme` type for the
100
+ full token list (accent set, surfaces, text, borders, semantic colors, shadows,
101
+ radius, font, transition). A `themeToVars(theme)` helper is also exported if you
102
+ want to apply the same variables to your own wrapper element.
103
+
104
+ ### 2. CSS variables (global / page-level)
105
+
106
+ Override the variables in your own stylesheet to theme every instance at once:
107
+
108
+ ```css
109
+ :root {
110
+ --uploader-accent: #4f46e5;
111
+ --uploader-accent-soft: #eef2ff;
112
+ --uploader-radius: 12px;
113
+ }
114
+ ```
115
+
116
+ Dark mode is handled automatically when any ancestor has `data-theme="dark"`.
117
+
67
118
  ## Camera capture
68
119
 
69
120
  Both `<PickerOverlay>` and `<DropPane>` can capture a photo straight from the
@@ -30,6 +30,8 @@ function planChunks(file, chunkSize = DEFAULT_CHUNK_SIZE) {
30
30
  // src/core/client.ts
31
31
  var MAX_RETRIES = 3;
32
32
  var RETRY_BASE_MS = 200;
33
+ var MAX_DIRECT_PUT_BYTES = 5 * 1024 * 1024 * 1024;
34
+ var SHA256_MAX_BYTES = 64 * 1024 * 1024;
33
35
  function sleep(ms, signal) {
34
36
  return new Promise((resolve, reject) => {
35
37
  if (signal?.aborted) {
@@ -91,14 +93,153 @@ async function fetchWithRetry(url, init, signal, maxRetries = MAX_RETRIES) {
91
93
  }
92
94
  throw lastErr;
93
95
  }
96
+ async function sha256Hex(blob) {
97
+ try {
98
+ const c = globalThis.crypto;
99
+ if (!c?.subtle || blob.size > SHA256_MAX_BYTES) return void 0;
100
+ const digest = await c.subtle.digest("SHA-256", await blob.arrayBuffer());
101
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
102
+ } catch {
103
+ return void 0;
104
+ }
105
+ }
106
+ function xhrPut(url, body, opts) {
107
+ return new Promise((resolve, reject) => {
108
+ if (opts.signal?.aborted) {
109
+ reject(new UploaderError("ABORTED", "Upload aborted"));
110
+ return;
111
+ }
112
+ const xhr = new XMLHttpRequest();
113
+ xhr.open("PUT", url);
114
+ xhr.setRequestHeader("Content-Type", opts.contentType);
115
+ if (opts.onProgress) {
116
+ xhr.upload.onprogress = (e) => {
117
+ if (e.lengthComputable) {
118
+ opts.onProgress(Math.round(e.loaded / e.total * 95));
119
+ }
120
+ };
121
+ }
122
+ xhr.onload = () => {
123
+ if (xhr.status >= 200 && xhr.status < 300) {
124
+ resolve();
125
+ } else {
126
+ const code = xhr.status >= 400 && xhr.status < 500 ? "CLIENT_ERROR" : "SERVER_ERROR";
127
+ reject(new UploaderError(code, `Bucket PUT failed: HTTP ${xhr.status}`, xhr.status));
128
+ }
129
+ };
130
+ xhr.onerror = () => reject(new UploaderError("NETWORK_ERROR", "Bucket PUT network error"));
131
+ xhr.onabort = () => reject(new UploaderError("ABORTED", "Upload aborted"));
132
+ if (opts.signal) {
133
+ opts.signal.addEventListener("abort", () => xhr.abort(), { once: true });
134
+ }
135
+ xhr.send(body);
136
+ });
137
+ }
94
138
  var UploaderClient = class {
95
139
  apikey;
96
140
  apiUrl;
97
141
  security;
142
+ #directUploadOption;
143
+ /** Client-level default per-file delivery protection (docs/13). */
144
+ #deliveryProtection;
145
+ /** Client-level default per-file allowed origins (docs/13). */
146
+ #allowedOrigins;
147
+ /** Memoized capability probe — one request per client, shared across uploads. */
148
+ #capsPromise = null;
98
149
  constructor(options) {
99
150
  this.apikey = options.apikey;
100
151
  this.apiUrl = (options.apiUrl ?? "https://api.uploaderhq.io").replace(/\/$/, "");
101
152
  this.security = options.security;
153
+ this.#directUploadOption = options.directUpload;
154
+ this.#deliveryProtection = options.deliveryProtection;
155
+ this.#allowedOrigins = options.allowedOrigins;
156
+ }
157
+ /**
158
+ * Resolve the effective per-file protection for one upload: a per-upload value
159
+ * overrides the client-level default (docs/13). Returns an object carrying ONLY
160
+ * the keys that are set, so callers spread it into the request body and unset
161
+ * fields are omitted entirely — an old server ignores them and the file inherits
162
+ * the account mode (`null`).
163
+ */
164
+ #resolveProtection(opts) {
165
+ const out = {};
166
+ const mode = opts.deliveryProtection ?? this.#deliveryProtection;
167
+ if (mode !== void 0) out.deliveryProtection = mode;
168
+ const origins = opts.allowedOrigins ?? this.#allowedOrigins;
169
+ if (origins !== void 0) out.allowedOrigins = origins;
170
+ return out;
171
+ }
172
+ // ─── Capability negotiation ─────────────────────────────────────────────────
173
+ /**
174
+ * Probe GET /api/capabilities once per client and cache the result. Fails OPEN
175
+ * to the proxied flow ({ directUpload: false }) on any error/timeout, so a
176
+ * flaky probe never blocks uploads and old servers (404) are handled.
177
+ */
178
+ #getCapabilities() {
179
+ if (this.#directUploadOption === false) {
180
+ return Promise.resolve({ directUpload: false });
181
+ }
182
+ if (!this.#capsPromise) {
183
+ this.#capsPromise = fetch(`${this.apiUrl}/api/capabilities`, {
184
+ headers: this.#authHeaders()
185
+ }).then(async (res) => {
186
+ if (!res.ok) return { directUpload: false };
187
+ const body = await res.json().catch(() => ({}));
188
+ return { directUpload: body["directUpload"] === true };
189
+ }).catch(() => ({ directUpload: false }));
190
+ }
191
+ return this.#capsPromise;
192
+ }
193
+ // ─── Direct-to-bucket upload ────────────────────────────────────────────────
194
+ /**
195
+ * Presign → PUT-to-bucket → confirm. Bytes go browser → bucket directly; our
196
+ * server only signs and records. Used when the account has the directUpload
197
+ * capability and the file fits a single PUT.
198
+ */
199
+ async #uploadDirect(file, opts) {
200
+ const { onProgress, filename, signal } = opts;
201
+ const name = filename ?? (file instanceof File ? file.name : "upload");
202
+ const contentType = file instanceof File && file.type ? file.type : "application/octet-stream";
203
+ onProgress?.(0);
204
+ checkAbort(signal);
205
+ const protection = this.#resolveProtection(opts);
206
+ const presign = async () => {
207
+ const res = await fetchWithRetry(
208
+ `${this.apiUrl}/api/uploads/presign`,
209
+ {
210
+ method: "POST",
211
+ headers: { ...this.#authHeaders(), "Content-Type": "application/json" },
212
+ body: JSON.stringify({ filename: name, contentType, size: file.size, ...protection })
213
+ },
214
+ signal
215
+ );
216
+ return await res.json();
217
+ };
218
+ let signed = await presign();
219
+ const checksum = await sha256Hex(file);
220
+ try {
221
+ await xhrPut(signed.putUrl, file, { contentType: signed.contentType, onProgress, signal });
222
+ } catch (err) {
223
+ if (err instanceof UploaderError && err.statusCode === 403) {
224
+ signed = await presign();
225
+ await xhrPut(signed.putUrl, file, { contentType: signed.contentType, onProgress, signal });
226
+ } else {
227
+ throw err;
228
+ }
229
+ }
230
+ checkAbort(signal);
231
+ const confirmRes = await fetchWithRetry(
232
+ `${this.apiUrl}/api/uploads/confirm`,
233
+ {
234
+ method: "POST",
235
+ headers: { ...this.#authHeaders(), "Content-Type": "application/json" },
236
+ body: JSON.stringify({ handle: signed.handle, checksum })
237
+ },
238
+ signal
239
+ );
240
+ const body = await confirmRes.json();
241
+ onProgress?.(100);
242
+ return this.#parseFileResult(body);
102
243
  }
103
244
  // ─── Auth headers ──────────────────────────────────────────────────────────
104
245
  /**
@@ -126,6 +267,9 @@ var UploaderClient = class {
126
267
  const form = new FormData();
127
268
  form.append("file", file, filename ?? (file instanceof File ? file.name : "upload"));
128
269
  if (filename) form.append("filename", filename);
270
+ const protection = this.#resolveProtection(opts);
271
+ if (protection.deliveryProtection) form.append("deliveryProtection", protection.deliveryProtection);
272
+ if (protection.allowedOrigins) form.append("allowedOrigins", JSON.stringify(protection.allowedOrigins));
129
273
  const res = await fetchWithRetry(
130
274
  `${this.apiUrl}/api/store`,
131
275
  {
@@ -149,12 +293,13 @@ var UploaderClient = class {
149
293
  const mime = file instanceof File ? file.type : "application/octet-stream";
150
294
  onProgress?.(0);
151
295
  checkAbort(signal);
296
+ const protection = this.#resolveProtection(opts);
152
297
  const startRes = await fetchWithRetry(
153
298
  `${this.apiUrl}/api/upload/start`,
154
299
  {
155
300
  method: "POST",
156
301
  headers: { ...this.#authHeaders(), "Content-Type": "application/json" },
157
- body: JSON.stringify({ filename: name, mimetype: mime, size: file.size })
302
+ body: JSON.stringify({ filename: name, mimetype: mime, size: file.size, ...protection })
158
303
  },
159
304
  signal
160
305
  );
@@ -217,6 +362,10 @@ var UploaderClient = class {
217
362
  * CLIENT_ERROR | INVALID_RESPONSE
218
363
  */
219
364
  async upload(file, opts = {}) {
365
+ const caps = await this.#getCapabilities();
366
+ if (caps.directUpload && file.size <= MAX_DIRECT_PUT_BYTES) {
367
+ return this.#uploadDirect(file, opts);
368
+ }
220
369
  const plan = planChunks(file, opts.chunkSize);
221
370
  if (plan.mode === "single") {
222
371
  return this.#uploadSingleShot(file, opts);
@@ -296,6 +445,10 @@ function transformUrl({ handle, ops, apiUrl = "" }) {
296
445
  const chain = ops.map(serializeOp).join("/");
297
446
  return chain ? `${base}/${chain}/${handle}` : `${base}/${handle}`;
298
447
  }
448
+ function withSignedPolicy(url, security) {
449
+ const sep = url.includes("?") ? "&" : "?";
450
+ return `${url}${sep}policy=${encodeURIComponent(security.policy)}&signature=${encodeURIComponent(security.signature)}`;
451
+ }
299
452
 
300
453
  export {
301
454
  UploaderError,
@@ -310,6 +463,7 @@ export {
310
463
  flop,
311
464
  quality,
312
465
  output,
313
- transformUrl
466
+ transformUrl,
467
+ withSignedPolicy
314
468
  };
315
- //# sourceMappingURL=chunk-EM6NZZSU.js.map
469
+ //# sourceMappingURL=chunk-QRUZUI5L.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/errors.ts","../src/core/chunk.ts","../src/core/client.ts","../src/core/transform.ts"],"sourcesContent":["/**\n * Typed errors for UploaderClient.\n *\n * UploaderError is thrown by upload / uploadAll on any non-retried failure.\n * The caller can narrow on `err.code` for structured handling.\n */\n\nexport type UploaderErrorCode =\n | 'ABORTED' // AbortSignal fired\n | 'NETWORK_ERROR' // fetch() threw (no response)\n | 'SERVER_ERROR' // 5xx after all retries exhausted\n | 'CLIENT_ERROR' // 4xx (not retried)\n | 'INVALID_RESPONSE' // response body did not match expected shape\n\nexport class UploaderError extends Error {\n readonly code: UploaderErrorCode\n /** HTTP status code when available (undefined for ABORTED / NETWORK_ERROR). */\n readonly statusCode?: number\n\n constructor(code: UploaderErrorCode, message: string, statusCode?: number) {\n super(message)\n this.name = 'UploaderError'\n this.code = code\n this.statusCode = statusCode\n }\n}\n","/**\n * Chunk planner for multipart uploads.\n *\n * Decides single-shot vs multipart by comparing the file size against\n * MULTIPART_THRESHOLD. For multipart files, slices the Blob into parts of\n * `chunkSize` bytes.\n */\n\n/** Files ≤ this size use single-shot POST /api/store. */\nexport const MULTIPART_THRESHOLD = 5 * 1024 * 1024 // 5 MB\n\n/** Default part size for multipart uploads. */\nexport const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 // 5 MB\n\nexport type ChunkPlan =\n | { mode: 'single' }\n | { mode: 'multipart'; parts: Blob[]; partSize: number }\n\n/**\n * Build a chunk plan for a file.\n *\n * @param file The File or Blob to upload.\n * @param chunkSize Desired part size in bytes (default DEFAULT_CHUNK_SIZE).\n * @returns A plan describing whether to use single-shot or multipart.\n */\nexport function planChunks(file: File | Blob, chunkSize = DEFAULT_CHUNK_SIZE): ChunkPlan {\n if (file.size <= MULTIPART_THRESHOLD) {\n return { mode: 'single' }\n }\n\n const parts: Blob[] = []\n let offset = 0\n while (offset < file.size) {\n parts.push(file.slice(offset, offset + chunkSize))\n offset += chunkSize\n }\n return { mode: 'multipart', parts, partSize: chunkSize }\n}\n","/**\n * UploaderClient — headless upload client.\n *\n * Chooses single-shot (POST /api/store, multipart/form-data) vs multipart\n * (start/part/complete) based on file size relative to MULTIPART_THRESHOLD\n * (5 MB). Parts are retried individually with exponential backoff. Progress\n * is emitted as a 0–100 integer. AbortSignal cancels in-flight work.\n */\n\nimport type {\n FileResult,\n UploadStartResponse,\n UploadPartResponse,\n UploaderClientOptions,\n UploadAllOptions,\n UploadOptions,\n} from './types.js'\nimport { UploaderError } from './errors.js'\nimport { planChunks } from './chunk.js'\n\n// ─── Constants ────────────────────────────────────────────────────────────────\n\nconst MAX_RETRIES = 3\nconst RETRY_BASE_MS = 200\n\n/**\n * Single presigned PUT ceiling (mirrors the server's Phase-A limit). Files above\n * this fall back to the proxied multipart flow.\n */\nconst MAX_DIRECT_PUT_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB\n\n/**\n * Skip the advisory client checksum above this size — SubtleCrypto has no\n * streaming API, so hashing would load the whole file into memory a second time.\n * The checksum is advisory only (the server can't verify it anyway).\n */\nconst SHA256_MAX_BYTES = 64 * 1024 * 1024 // 64 MB\n\n// ─── Internal helpers ─────────────────────────────────────────────────────────\n\n/** Sleep for `ms` milliseconds, resolving early if signal fires. */\nfunction sleep(ms: number, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(new UploaderError('ABORTED', 'Upload aborted'))\n return\n }\n const timer = setTimeout(resolve, ms)\n signal?.addEventListener('abort', () => {\n clearTimeout(timer)\n reject(new UploaderError('ABORTED', 'Upload aborted'))\n }, { once: true })\n })\n}\n\n/** Throw UploaderError(ABORTED) if signal has already fired. */\nfunction checkAbort(signal?: AbortSignal): void {\n if (signal?.aborted) {\n throw new UploaderError('ABORTED', 'Upload aborted')\n }\n}\n\n/**\n * Fetch with retry on network errors and 5xx responses.\n * 4xx responses are not retried — they surface immediately as CLIENT_ERROR.\n */\nasync function fetchWithRetry(\n url: string,\n init: RequestInit,\n signal?: AbortSignal,\n maxRetries = MAX_RETRIES,\n): Promise<Response> {\n let lastErr: unknown\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n checkAbort(signal)\n try {\n const res = await fetch(url, { ...init, signal })\n if (res.status >= 400 && res.status < 500) {\n // Client error — do not retry\n const body = await res.text().catch(() => '')\n throw new UploaderError(\n 'CLIENT_ERROR',\n `HTTP ${res.status}: ${body}`,\n res.status,\n )\n }\n if (res.status >= 500) {\n // Server error — retry with backoff\n lastErr = new UploaderError(\n 'SERVER_ERROR',\n `HTTP ${res.status}`,\n res.status,\n )\n if (attempt < maxRetries - 1) {\n await sleep(RETRY_BASE_MS * 2 ** attempt, signal)\n }\n continue\n }\n return res\n } catch (err) {\n if (err instanceof UploaderError) {\n if (err.code === 'CLIENT_ERROR' || err.code === 'ABORTED') throw err\n lastErr = err\n } else {\n // fetch() threw (network failure, CORS, etc.)\n lastErr = new UploaderError(\n 'NETWORK_ERROR',\n err instanceof Error ? err.message : String(err),\n )\n }\n if (attempt < maxRetries - 1) {\n await sleep(RETRY_BASE_MS * 2 ** attempt, signal)\n }\n }\n }\n throw lastErr\n}\n\n/**\n * Best-effort SHA-256 (hex) of a Blob for the advisory checksum sent at confirm.\n * Returns undefined when SubtleCrypto is unavailable (insecure context / older\n * runtime) or the file is large — never throws. The server treats this as a hint\n * only, so skipping it is safe.\n */\nasync function sha256Hex(blob: Blob): Promise<string | undefined> {\n try {\n const c = (globalThis as { crypto?: Crypto }).crypto\n if (!c?.subtle || blob.size > SHA256_MAX_BYTES) return undefined\n const digest = await c.subtle.digest('SHA-256', await blob.arrayBuffer())\n return Array.from(new Uint8Array(digest))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n } catch {\n return undefined\n }\n}\n\n/**\n * PUT a body straight to a presigned bucket URL via XMLHttpRequest.\n *\n * XHR (not fetch) so real upload progress is reported (fetch cannot). The\n * Content-Type header MUST equal the value the server signed, or S3/R2 reject\n * the signature. Bytes go browser → bucket; our server never sees them.\n */\nfunction xhrPut(\n url: string,\n body: Blob,\n opts: { contentType: string; onProgress?: (percent: number) => void; signal?: AbortSignal },\n): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n if (opts.signal?.aborted) {\n reject(new UploaderError('ABORTED', 'Upload aborted'))\n return\n }\n const xhr = new XMLHttpRequest()\n xhr.open('PUT', url)\n xhr.setRequestHeader('Content-Type', opts.contentType)\n\n if (opts.onProgress) {\n xhr.upload.onprogress = (e: ProgressEvent) => {\n if (e.lengthComputable) {\n // Reserve the last 5% for the confirm round-trip.\n opts.onProgress!(Math.round((e.loaded / e.total) * 95))\n }\n }\n }\n xhr.onload = () => {\n if (xhr.status >= 200 && xhr.status < 300) {\n resolve()\n } else {\n const code = xhr.status >= 400 && xhr.status < 500 ? 'CLIENT_ERROR' : 'SERVER_ERROR'\n reject(new UploaderError(code, `Bucket PUT failed: HTTP ${xhr.status}`, xhr.status))\n }\n }\n xhr.onerror = () => reject(new UploaderError('NETWORK_ERROR', 'Bucket PUT network error'))\n xhr.onabort = () => reject(new UploaderError('ABORTED', 'Upload aborted'))\n\n if (opts.signal) {\n opts.signal.addEventListener('abort', () => xhr.abort(), { once: true })\n }\n xhr.send(body)\n })\n}\n\n/** Shape returned by POST /api/uploads/presign. */\ntype PresignResponse = { handle: string; storageKey: string; putUrl: string; contentType: string }\n\n// ─── UploaderClient ────────────────────────────────────────────────────────────\n\nexport class UploaderClient {\n readonly apikey: string\n readonly apiUrl: string\n readonly security: UploaderClientOptions['security']\n readonly #directUploadOption?: boolean\n /** Client-level default per-file delivery protection (docs/13). */\n readonly #deliveryProtection?: UploaderClientOptions['deliveryProtection']\n /** Client-level default per-file allowed origins (docs/13). */\n readonly #allowedOrigins?: string[]\n\n /** Memoized capability probe — one request per client, shared across uploads. */\n #capsPromise: Promise<{ directUpload: boolean }> | null = null\n\n constructor(options: UploaderClientOptions) {\n this.apikey = options.apikey\n this.apiUrl = (options.apiUrl ?? 'https://api.uploaderhq.io').replace(/\\/$/, '')\n this.security = options.security\n this.#directUploadOption = options.directUpload\n this.#deliveryProtection = options.deliveryProtection\n this.#allowedOrigins = options.allowedOrigins\n }\n\n /**\n * Resolve the effective per-file protection for one upload: a per-upload value\n * overrides the client-level default (docs/13). Returns an object carrying ONLY\n * the keys that are set, so callers spread it into the request body and unset\n * fields are omitted entirely — an old server ignores them and the file inherits\n * the account mode (`null`).\n */\n #resolveProtection(opts: UploadOptions): {\n deliveryProtection?: UploaderClientOptions['deliveryProtection']\n allowedOrigins?: string[]\n } {\n const out: { deliveryProtection?: UploaderClientOptions['deliveryProtection']; allowedOrigins?: string[] } = {}\n const mode = opts.deliveryProtection ?? this.#deliveryProtection\n if (mode !== undefined) out.deliveryProtection = mode\n const origins = opts.allowedOrigins ?? this.#allowedOrigins\n if (origins !== undefined) out.allowedOrigins = origins\n return out\n }\n\n // ─── Capability negotiation ─────────────────────────────────────────────────\n\n /**\n * Probe GET /api/capabilities once per client and cache the result. Fails OPEN\n * to the proxied flow ({ directUpload: false }) on any error/timeout, so a\n * flaky probe never blocks uploads and old servers (404) are handled.\n */\n #getCapabilities(): Promise<{ directUpload: boolean }> {\n // Explicit opt-out — never probe, never use direct upload.\n if (this.#directUploadOption === false) {\n return Promise.resolve({ directUpload: false })\n }\n if (!this.#capsPromise) {\n this.#capsPromise = fetch(`${this.apiUrl}/api/capabilities`, {\n headers: this.#authHeaders(),\n })\n .then(async (res) => {\n if (!res.ok) return { directUpload: false }\n const body = (await res.json().catch(() => ({}))) as Record<string, unknown>\n return { directUpload: body['directUpload'] === true }\n })\n .catch(() => ({ directUpload: false }))\n }\n return this.#capsPromise\n }\n\n // ─── Direct-to-bucket upload ────────────────────────────────────────────────\n\n /**\n * Presign → PUT-to-bucket → confirm. Bytes go browser → bucket directly; our\n * server only signs and records. Used when the account has the directUpload\n * capability and the file fits a single PUT.\n */\n async #uploadDirect(file: File | Blob, opts: UploadOptions): Promise<FileResult> {\n const { onProgress, filename, signal } = opts\n const name = filename ?? (file instanceof File ? file.name : 'upload')\n // Must match the Content-Type we send on the PUT (the server signs it).\n const contentType = file instanceof File && file.type ? file.type : 'application/octet-stream'\n\n onProgress?.(0)\n checkAbort(signal)\n\n const protection = this.#resolveProtection(opts)\n const presign = async (): Promise<PresignResponse> => {\n const res = await fetchWithRetry(\n `${this.apiUrl}/api/uploads/presign`,\n {\n method: 'POST',\n headers: { ...this.#authHeaders(), 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename: name, contentType, size: file.size, ...protection }),\n },\n signal,\n )\n return (await res.json()) as PresignResponse\n }\n\n let signed = await presign()\n\n // Advisory checksum (best-effort; unverifiable server-side).\n const checksum = await sha256Hex(file)\n\n // PUT straight to the bucket. A 403 means the short-lived signature lapsed\n // (or a stale key) — re-presign once and retry before giving up.\n try {\n await xhrPut(signed.putUrl, file, { contentType: signed.contentType, onProgress, signal })\n } catch (err) {\n if (err instanceof UploaderError && err.statusCode === 403) {\n signed = await presign()\n await xhrPut(signed.putUrl, file, { contentType: signed.contentType, onProgress, signal })\n } else {\n throw err\n }\n }\n\n // Confirm — server verifies the object exists, records usage, enqueues.\n checkAbort(signal)\n const confirmRes = await fetchWithRetry(\n `${this.apiUrl}/api/uploads/confirm`,\n {\n method: 'POST',\n headers: { ...this.#authHeaders(), 'Content-Type': 'application/json' },\n body: JSON.stringify({ handle: signed.handle, checksum }),\n },\n signal,\n )\n const body = (await confirmRes.json()) as unknown\n onProgress?.(100)\n return this.#parseFileResult(body)\n }\n\n // ─── Auth headers ──────────────────────────────────────────────────────────\n\n /**\n * Build the auth headers shared by all requests.\n * Attaches the API key and, when present, the signed policy pair.\n */\n #authHeaders(): Record<string, string> {\n const headers: Record<string, string> = {\n 'X-Uploader-Key': this.apikey,\n }\n if (this.security) {\n headers['X-Uploader-Policy'] = this.security.policy\n headers['X-Uploader-Signature'] = this.security.signature\n }\n return headers\n }\n\n // ─── Single-shot upload ────────────────────────────────────────────────────\n\n /**\n * POST /api/store — multipart/form-data for files ≤ MULTIPART_THRESHOLD.\n */\n async #uploadSingleShot(\n file: File | Blob,\n opts: UploadOptions,\n ): Promise<FileResult> {\n const { onProgress, filename, signal } = opts\n\n onProgress?.(0)\n checkAbort(signal)\n\n const form = new FormData()\n form.append('file', file, filename ?? (file instanceof File ? file.name : 'upload'))\n if (filename) form.append('filename', filename)\n // Per-file delivery protection (docs/13) — sent as form fields; allowedOrigins\n // is JSON-encoded to match the server's parse. Omitted when unset (inherit).\n const protection = this.#resolveProtection(opts)\n if (protection.deliveryProtection) form.append('deliveryProtection', protection.deliveryProtection)\n if (protection.allowedOrigins) form.append('allowedOrigins', JSON.stringify(protection.allowedOrigins))\n\n const res = await fetchWithRetry(\n `${this.apiUrl}/api/store`,\n {\n method: 'POST',\n headers: this.#authHeaders(),\n body: form,\n },\n signal,\n )\n\n const body = await res.json() as unknown\n onProgress?.(100)\n return this.#parseFileResult(body)\n }\n\n // ─── Multipart upload ──────────────────────────────────────────────────────\n\n /**\n * start → parts (parallel with progress) → complete for files > MULTIPART_THRESHOLD.\n */\n async #uploadMultipart(\n file: File | Blob,\n parts: Blob[],\n opts: UploadOptions,\n ): Promise<FileResult> {\n const { onProgress, filename, signal } = opts\n const name = filename ?? (file instanceof File ? file.name : 'upload')\n const mime = file instanceof File ? file.type : 'application/octet-stream'\n\n onProgress?.(0)\n checkAbort(signal)\n\n // 1. Start\n const protection = this.#resolveProtection(opts)\n const startRes = await fetchWithRetry(\n `${this.apiUrl}/api/upload/start`,\n {\n method: 'POST',\n headers: { ...this.#authHeaders(), 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename: name, mimetype: mime, size: file.size, ...protection }),\n },\n signal,\n )\n const { uploadId } = await startRes.json() as UploadStartResponse\n\n // 2. Upload parts sequentially (retried individually)\n const etags: { partNumber: number; etag: string }[] = []\n let uploadedBytes = 0\n\n for (let i = 0; i < parts.length; i++) {\n checkAbort(signal)\n const partBlob = parts[i]!\n const partNumber = i + 1\n\n const partForm = new FormData()\n partForm.append('uploadId', uploadId)\n partForm.append('partNumber', String(partNumber))\n partForm.append('part', partBlob)\n\n const partRes = await fetchWithRetry(\n `${this.apiUrl}/api/upload/part`,\n {\n method: 'POST',\n headers: this.#authHeaders(),\n body: partForm,\n },\n signal,\n )\n const { etag } = await partRes.json() as UploadPartResponse\n\n etags.push({ partNumber, etag })\n uploadedBytes += partBlob.size\n onProgress?.(Math.round((uploadedBytes / file.size) * 95)) // reserve 5% for complete\n }\n\n // 3. Complete\n checkAbort(signal)\n const completeRes = await fetchWithRetry(\n `${this.apiUrl}/api/upload/complete`,\n {\n method: 'POST',\n headers: { ...this.#authHeaders(), 'Content-Type': 'application/json' },\n body: JSON.stringify({ uploadId, parts: etags }),\n },\n signal,\n )\n const body = await completeRes.json() as unknown\n onProgress?.(100)\n return this.#parseFileResult(body)\n }\n\n // ─── Response parser ───────────────────────────────────────────────────────\n\n #parseFileResult(body: unknown): FileResult {\n if (\n typeof body !== 'object' ||\n body === null ||\n typeof (body as Record<string, unknown>)['handle'] !== 'string' ||\n typeof (body as Record<string, unknown>)['url'] !== 'string'\n ) {\n throw new UploaderError('INVALID_RESPONSE', 'Unexpected response shape from upload API')\n }\n return body as FileResult\n }\n\n // ─── Public API ───────────────────────────────────────────────────────────\n\n /**\n * Upload a single file.\n *\n * Automatically selects single-shot vs multipart upload based on file size.\n * Emits progress via `opts.onProgress` (0–100). Respects `opts.signal` for\n * cancellation. Retries network errors and 5xx responses up to 3 times with\n * exponential backoff; 4xx errors are surfaced immediately.\n *\n * @throws {UploaderError} with code ABORTED | NETWORK_ERROR | SERVER_ERROR |\n * CLIENT_ERROR | INVALID_RESPONSE\n */\n async upload(\n file: File | Blob,\n opts: UploadOptions = {},\n ): Promise<FileResult> {\n // Prefer direct-to-bucket when the account supports it and the file fits a\n // single PUT. The probe fails open, so an old server or a disabled account\n // transparently uses the proxied flow below.\n const caps = await this.#getCapabilities()\n if (caps.directUpload && file.size <= MAX_DIRECT_PUT_BYTES) {\n return this.#uploadDirect(file, opts)\n }\n\n const plan = planChunks(file, opts.chunkSize)\n if (plan.mode === 'single') {\n return this.#uploadSingleShot(file, opts)\n }\n return this.#uploadMultipart(file, plan.parts, opts)\n }\n\n /**\n * Upload multiple files with concurrency limiting.\n *\n * Resolves once all uploads settle (fulfilled or rejected). The returned\n * array preserves input order. `opts.concurrency` caps simultaneous\n * in-flight uploads (default 3). Each file shares the same opts\n * (including onProgress — the callback fires per-file, not aggregate).\n *\n * @returns Array of PromiseSettledResult in input order.\n */\n async uploadAll(\n files: Array<File | Blob>,\n opts: UploadAllOptions = {},\n ): Promise<PromiseSettledResult<FileResult>[]> {\n const concurrency = opts.concurrency ?? 3\n const results: PromiseSettledResult<FileResult>[] = new Array(files.length)\n\n let index = 0\n\n async function worker(client: UploaderClient): Promise<void> {\n while (index < files.length) {\n const i = index++\n const file = files[i]!\n try {\n results[i] = { status: 'fulfilled', value: await client.upload(file, opts) }\n } catch (err) {\n results[i] = { status: 'rejected', reason: err }\n }\n }\n }\n\n const workers = Array.from({ length: Math.min(concurrency, files.length) }, () =>\n worker(this),\n )\n await Promise.all(workers)\n return results\n }\n}\n","/**\n * Delivery / transform URL builder.\n *\n * The API serves transformed derivatives at `GET /<chain>/<handle>`, where\n * <chain> is a slash-joined list of ops (e.g. `resize=w:200,h:200,fit:crop`).\n * These op builders + `transformUrl()` construct that URL from an uploaded file\n * handle, so consumers render an image at any size/format/quality without\n * hand-assembling URL strings.\n *\n * The op/param types and serialization mirror @uploader/shared EXACTLY — the\n * API's `parseTransformChain` is the other half of this contract, and\n * `contract.conformance.ts` asserts at typecheck time that they stay in sync.\n */\n\n// ─── Op vocabulary (mirrors @uploader/shared) ────────────────────────────────\n\nexport type TransformOpName =\n | 'resize'\n | 'crop'\n | 'rotate'\n | 'flip'\n | 'flop'\n | 'quality'\n | 'output'\n\n/** Parameters for the resize operation. */\nexport type ResizeParams = {\n w?: number\n h?: number\n fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside' | 'crop'\n}\n\n/** Parameters for the crop operation. */\nexport type CropParams = {\n /** \"x,y,w,h\" notation. */\n dim: string\n x?: number\n y?: number\n w?: number\n h?: number\n}\n\n/** Parameters for the rotate operation. */\nexport type RotateParams = { deg: number }\n\n/** Parameters for the quality operation. */\nexport type QualityParams = { n: number }\n\n/** Parameters for the output operation. */\nexport type OutputParams = { format: string }\n\n/** A single transform operation with its typed params. */\nexport type TransformOp =\n | { name: 'resize'; params: ResizeParams }\n | { name: 'crop'; params: CropParams }\n | { name: 'rotate'; params: RotateParams }\n | { name: 'flip'; params: Record<string, never> }\n | { name: 'flop'; params: Record<string, never> }\n | { name: 'quality'; params: QualityParams }\n | { name: 'output'; params: OutputParams }\n\n/** An ordered list of transform operations. */\nexport type TransformChain = { ops: TransformOp[] }\n\n// ─── Op builders ─────────────────────────────────────────────────────────────\n\n/** Resize to a width and/or height with an optional fit mode. */\nexport const resize = (params: ResizeParams): TransformOp => ({ name: 'resize', params })\n\n/** Crop a rectangular region: origin (x, y) and size w×h, in source pixels. */\nexport const crop = (rect: { x: number; y: number; w: number; h: number }): TransformOp => ({\n name: 'crop',\n params: { dim: `${rect.x},${rect.y},${rect.w},${rect.h}`, ...rect },\n})\n\n/** Rotate clockwise by `deg` degrees. */\nexport const rotate = (params: RotateParams): TransformOp => ({ name: 'rotate', params })\n\n/** Flip vertically (mirror top↔bottom). */\nexport const flip = (): TransformOp => ({ name: 'flip', params: {} })\n\n/** Flop horizontally (mirror left↔right). */\nexport const flop = (): TransformOp => ({ name: 'flop', params: {} })\n\n/** Set output quality 1–100 (ignored by lossless formats). */\nexport const quality = (params: QualityParams): TransformOp => ({ name: 'quality', params })\n\n/** Convert the output format, e.g. `{ format: 'webp' }`. */\nexport const output = (params: OutputParams): TransformOp => ({ name: 'output', params })\n\n// ─── Serialization (vendored from @uploader/shared, parser-compatible) ────────\n\nfunction serializeOp(op: TransformOp): string {\n switch (op.name) {\n case 'resize': {\n const parts: string[] = []\n if (op.params.w !== undefined) parts.push(`w:${op.params.w}`)\n if (op.params.h !== undefined) parts.push(`h:${op.params.h}`)\n if (op.params.fit !== undefined) parts.push(`fit:${op.params.fit}`)\n return `resize=${parts.join(',')}`\n }\n case 'crop':\n return `crop=dim:${op.params.dim}`\n case 'rotate':\n return `rotate=deg:${op.params.deg}`\n case 'flip':\n return 'flip'\n case 'flop':\n return 'flop'\n case 'quality':\n return `quality=n:${op.params.n}`\n case 'output':\n return `output=format:${op.params.format}`\n }\n}\n\nexport type TransformUrlOptions = {\n /** File handle from an upload (`FileResult.handle`). */\n handle: string\n /** Ordered transform ops to apply. Must contain at least one op. */\n ops: TransformOp[]\n /**\n * Base API URL. Defaults to '' so the URL is relative — `/<chain>/<handle>` —\n * and resolves same-origin (e.g. proxied to the API). Pass an absolute URL to\n * point at a remote API directly.\n */\n apiUrl?: string\n}\n\n/**\n * Build a delivery URL that applies `ops` to the file identified by `handle`.\n *\n * @example\n * transformUrl({\n * handle: file.handle,\n * ops: [resize({ w: 200, h: 200, fit: 'crop' }), output({ format: 'webp' })],\n * })\n * // => \"/resize=w:200,h:200,fit:crop/output=format:webp/<handle>\"\n */\nexport function transformUrl({ handle, ops, apiUrl = '' }: TransformUrlOptions): string {\n const base = apiUrl.replace(/\\/$/, '')\n const chain = ops.map(serializeOp).join('/')\n // The API's transform route requires at least one op segment before the\n // handle; for the unmodified original, use FileResult.url instead.\n return chain ? `${base}/${chain}/${handle}` : `${base}/${handle}`\n}\n\n/**\n * Append a signed read policy to a delivery/transform URL for accounts on the\n * `signed` delivery-protection tier (Private plan, docs/12).\n *\n * The `policy` + `signature` pair is produced SERVER-SIDE by the customer's\n * backend (HMAC over the account's api-key secret, via @uploader/shared). This\n * helper only assembles the URL — the SDK never signs and never sees the secret.\n *\n * @example\n * const url = withSignedPolicy(\n * transformUrl({ handle, ops: [resize({ w: 400 })], apiUrl }),\n * { policy, signature }, // from your backend\n * )\n */\nexport function withSignedPolicy(\n url: string,\n security: { policy: string; signature: string },\n): string {\n const sep = url.includes('?') ? '&' : '?'\n return `${url}${sep}policy=${encodeURIComponent(security.policy)}&signature=${encodeURIComponent(security.signature)}`\n}\n"],"mappings":"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","names":[]}
package/dist/core.cjs CHANGED
@@ -32,7 +32,8 @@ __export(core_exports, {
32
32
  quality: () => quality,
33
33
  resize: () => resize,
34
34
  rotate: () => rotate,
35
- transformUrl: () => transformUrl
35
+ transformUrl: () => transformUrl,
36
+ withSignedPolicy: () => withSignedPolicy
36
37
  });
37
38
  module.exports = __toCommonJS(core_exports);
38
39
 
@@ -68,6 +69,8 @@ function planChunks(file, chunkSize = DEFAULT_CHUNK_SIZE) {
68
69
  // src/core/client.ts
69
70
  var MAX_RETRIES = 3;
70
71
  var RETRY_BASE_MS = 200;
72
+ var MAX_DIRECT_PUT_BYTES = 5 * 1024 * 1024 * 1024;
73
+ var SHA256_MAX_BYTES = 64 * 1024 * 1024;
71
74
  function sleep(ms, signal) {
72
75
  return new Promise((resolve, reject) => {
73
76
  if (signal?.aborted) {
@@ -129,14 +132,153 @@ async function fetchWithRetry(url, init, signal, maxRetries = MAX_RETRIES) {
129
132
  }
130
133
  throw lastErr;
131
134
  }
135
+ async function sha256Hex(blob) {
136
+ try {
137
+ const c = globalThis.crypto;
138
+ if (!c?.subtle || blob.size > SHA256_MAX_BYTES) return void 0;
139
+ const digest = await c.subtle.digest("SHA-256", await blob.arrayBuffer());
140
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
141
+ } catch {
142
+ return void 0;
143
+ }
144
+ }
145
+ function xhrPut(url, body, opts) {
146
+ return new Promise((resolve, reject) => {
147
+ if (opts.signal?.aborted) {
148
+ reject(new UploaderError("ABORTED", "Upload aborted"));
149
+ return;
150
+ }
151
+ const xhr = new XMLHttpRequest();
152
+ xhr.open("PUT", url);
153
+ xhr.setRequestHeader("Content-Type", opts.contentType);
154
+ if (opts.onProgress) {
155
+ xhr.upload.onprogress = (e) => {
156
+ if (e.lengthComputable) {
157
+ opts.onProgress(Math.round(e.loaded / e.total * 95));
158
+ }
159
+ };
160
+ }
161
+ xhr.onload = () => {
162
+ if (xhr.status >= 200 && xhr.status < 300) {
163
+ resolve();
164
+ } else {
165
+ const code = xhr.status >= 400 && xhr.status < 500 ? "CLIENT_ERROR" : "SERVER_ERROR";
166
+ reject(new UploaderError(code, `Bucket PUT failed: HTTP ${xhr.status}`, xhr.status));
167
+ }
168
+ };
169
+ xhr.onerror = () => reject(new UploaderError("NETWORK_ERROR", "Bucket PUT network error"));
170
+ xhr.onabort = () => reject(new UploaderError("ABORTED", "Upload aborted"));
171
+ if (opts.signal) {
172
+ opts.signal.addEventListener("abort", () => xhr.abort(), { once: true });
173
+ }
174
+ xhr.send(body);
175
+ });
176
+ }
132
177
  var UploaderClient = class {
133
178
  apikey;
134
179
  apiUrl;
135
180
  security;
181
+ #directUploadOption;
182
+ /** Client-level default per-file delivery protection (docs/13). */
183
+ #deliveryProtection;
184
+ /** Client-level default per-file allowed origins (docs/13). */
185
+ #allowedOrigins;
186
+ /** Memoized capability probe — one request per client, shared across uploads. */
187
+ #capsPromise = null;
136
188
  constructor(options) {
137
189
  this.apikey = options.apikey;
138
190
  this.apiUrl = (options.apiUrl ?? "https://api.uploaderhq.io").replace(/\/$/, "");
139
191
  this.security = options.security;
192
+ this.#directUploadOption = options.directUpload;
193
+ this.#deliveryProtection = options.deliveryProtection;
194
+ this.#allowedOrigins = options.allowedOrigins;
195
+ }
196
+ /**
197
+ * Resolve the effective per-file protection for one upload: a per-upload value
198
+ * overrides the client-level default (docs/13). Returns an object carrying ONLY
199
+ * the keys that are set, so callers spread it into the request body and unset
200
+ * fields are omitted entirely — an old server ignores them and the file inherits
201
+ * the account mode (`null`).
202
+ */
203
+ #resolveProtection(opts) {
204
+ const out = {};
205
+ const mode = opts.deliveryProtection ?? this.#deliveryProtection;
206
+ if (mode !== void 0) out.deliveryProtection = mode;
207
+ const origins = opts.allowedOrigins ?? this.#allowedOrigins;
208
+ if (origins !== void 0) out.allowedOrigins = origins;
209
+ return out;
210
+ }
211
+ // ─── Capability negotiation ─────────────────────────────────────────────────
212
+ /**
213
+ * Probe GET /api/capabilities once per client and cache the result. Fails OPEN
214
+ * to the proxied flow ({ directUpload: false }) on any error/timeout, so a
215
+ * flaky probe never blocks uploads and old servers (404) are handled.
216
+ */
217
+ #getCapabilities() {
218
+ if (this.#directUploadOption === false) {
219
+ return Promise.resolve({ directUpload: false });
220
+ }
221
+ if (!this.#capsPromise) {
222
+ this.#capsPromise = fetch(`${this.apiUrl}/api/capabilities`, {
223
+ headers: this.#authHeaders()
224
+ }).then(async (res) => {
225
+ if (!res.ok) return { directUpload: false };
226
+ const body = await res.json().catch(() => ({}));
227
+ return { directUpload: body["directUpload"] === true };
228
+ }).catch(() => ({ directUpload: false }));
229
+ }
230
+ return this.#capsPromise;
231
+ }
232
+ // ─── Direct-to-bucket upload ────────────────────────────────────────────────
233
+ /**
234
+ * Presign → PUT-to-bucket → confirm. Bytes go browser → bucket directly; our
235
+ * server only signs and records. Used when the account has the directUpload
236
+ * capability and the file fits a single PUT.
237
+ */
238
+ async #uploadDirect(file, opts) {
239
+ const { onProgress, filename, signal } = opts;
240
+ const name = filename ?? (file instanceof File ? file.name : "upload");
241
+ const contentType = file instanceof File && file.type ? file.type : "application/octet-stream";
242
+ onProgress?.(0);
243
+ checkAbort(signal);
244
+ const protection = this.#resolveProtection(opts);
245
+ const presign = async () => {
246
+ const res = await fetchWithRetry(
247
+ `${this.apiUrl}/api/uploads/presign`,
248
+ {
249
+ method: "POST",
250
+ headers: { ...this.#authHeaders(), "Content-Type": "application/json" },
251
+ body: JSON.stringify({ filename: name, contentType, size: file.size, ...protection })
252
+ },
253
+ signal
254
+ );
255
+ return await res.json();
256
+ };
257
+ let signed = await presign();
258
+ const checksum = await sha256Hex(file);
259
+ try {
260
+ await xhrPut(signed.putUrl, file, { contentType: signed.contentType, onProgress, signal });
261
+ } catch (err) {
262
+ if (err instanceof UploaderError && err.statusCode === 403) {
263
+ signed = await presign();
264
+ await xhrPut(signed.putUrl, file, { contentType: signed.contentType, onProgress, signal });
265
+ } else {
266
+ throw err;
267
+ }
268
+ }
269
+ checkAbort(signal);
270
+ const confirmRes = await fetchWithRetry(
271
+ `${this.apiUrl}/api/uploads/confirm`,
272
+ {
273
+ method: "POST",
274
+ headers: { ...this.#authHeaders(), "Content-Type": "application/json" },
275
+ body: JSON.stringify({ handle: signed.handle, checksum })
276
+ },
277
+ signal
278
+ );
279
+ const body = await confirmRes.json();
280
+ onProgress?.(100);
281
+ return this.#parseFileResult(body);
140
282
  }
141
283
  // ─── Auth headers ──────────────────────────────────────────────────────────
142
284
  /**
@@ -164,6 +306,9 @@ var UploaderClient = class {
164
306
  const form = new FormData();
165
307
  form.append("file", file, filename ?? (file instanceof File ? file.name : "upload"));
166
308
  if (filename) form.append("filename", filename);
309
+ const protection = this.#resolveProtection(opts);
310
+ if (protection.deliveryProtection) form.append("deliveryProtection", protection.deliveryProtection);
311
+ if (protection.allowedOrigins) form.append("allowedOrigins", JSON.stringify(protection.allowedOrigins));
167
312
  const res = await fetchWithRetry(
168
313
  `${this.apiUrl}/api/store`,
169
314
  {
@@ -187,12 +332,13 @@ var UploaderClient = class {
187
332
  const mime = file instanceof File ? file.type : "application/octet-stream";
188
333
  onProgress?.(0);
189
334
  checkAbort(signal);
335
+ const protection = this.#resolveProtection(opts);
190
336
  const startRes = await fetchWithRetry(
191
337
  `${this.apiUrl}/api/upload/start`,
192
338
  {
193
339
  method: "POST",
194
340
  headers: { ...this.#authHeaders(), "Content-Type": "application/json" },
195
- body: JSON.stringify({ filename: name, mimetype: mime, size: file.size })
341
+ body: JSON.stringify({ filename: name, mimetype: mime, size: file.size, ...protection })
196
342
  },
197
343
  signal
198
344
  );
@@ -255,6 +401,10 @@ var UploaderClient = class {
255
401
  * CLIENT_ERROR | INVALID_RESPONSE
256
402
  */
257
403
  async upload(file, opts = {}) {
404
+ const caps = await this.#getCapabilities();
405
+ if (caps.directUpload && file.size <= MAX_DIRECT_PUT_BYTES) {
406
+ return this.#uploadDirect(file, opts);
407
+ }
258
408
  const plan = planChunks(file, opts.chunkSize);
259
409
  if (plan.mode === "single") {
260
410
  return this.#uploadSingleShot(file, opts);
@@ -334,6 +484,10 @@ function transformUrl({ handle, ops, apiUrl = "" }) {
334
484
  const chain = ops.map(serializeOp).join("/");
335
485
  return chain ? `${base}/${chain}/${handle}` : `${base}/${handle}`;
336
486
  }
487
+ function withSignedPolicy(url, security) {
488
+ const sep = url.includes("?") ? "&" : "?";
489
+ return `${url}${sep}policy=${encodeURIComponent(security.policy)}&signature=${encodeURIComponent(security.signature)}`;
490
+ }
337
491
  // Annotate the CommonJS export names for ESM import in node:
338
492
  0 && (module.exports = {
339
493
  DEFAULT_CHUNK_SIZE,
@@ -348,6 +502,7 @@ function transformUrl({ handle, ops, apiUrl = "" }) {
348
502
  quality,
349
503
  resize,
350
504
  rotate,
351
- transformUrl
505
+ transformUrl,
506
+ withSignedPolicy
352
507
  });
353
508
  //# sourceMappingURL=core.cjs.map