naro-js 0.3.0 → 0.4.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
@@ -492,11 +492,54 @@ const { data: link } = await naro.storage
492
492
  (`bucketId`, `createdAt`, `size`, `contentType`, …); naro-js's `info`
493
493
  answers the same snake_case `FileObject` as `list`, with the size and type
494
494
  in `metadata.size` and `metadata.mimetype`.
495
- - An upload sends the file's own bytes, 50 MiB at most, typed as the file
496
- is (`Blob.type`, or `application/octet-stream`; a string is
497
- `text/plain;charset=utf-8`). The body is a `Blob`, `File`, `ArrayBuffer`,
498
- typed array or string — not a stream or `FormData`, and not a buffer
499
- transferred away (detached), which has no bytes left: `INVALID_BODY`.
495
+ - An upload sends the file's own bytes, typed as the file is (`Blob.type`,
496
+ or `application/octet-stream`; a string is `text/plain;charset=utf-8`).
497
+ The body is a `Blob`, `File`, `ArrayBuffer`, typed array or string — not
498
+ a stream or `FormData`, and not a buffer transferred away (detached),
499
+ which has no bytes left: `INVALID_BODY`.
500
+ - One request carries 50 MiB. A larger body goes in parts by itself, up to
501
+ the plan's largest object: 16–64 MiB parts, four at a time, each tried
502
+ again up to three times after `NETWORK_ERROR`, a `5xx` or
503
+ `OBJECT_RATE_LIMITED` (waiting 1, 2, then 4 seconds, or the seconds the
504
+ 429 says) — not after `507 STORAGE_QUOTA_EXCEEDED`, which lasts until
505
+ files are deleted. A part that still fails fails the upload with its
506
+ error, and the upload is called off. The last step is never tried again: if its
507
+ answer is lost (`NETWORK_ERROR`), ask `info(path)` whether the file is
508
+ there; if the backend refuses it, the upload is called off too and the
509
+ refusal is the answer. A part answered `UPLOAD_NOT_FOUND` belongs to an
510
+ upload that has ended — one left unsent for about an hour has. An upload in
511
+ parts must finish within 24 hours of its start (R2 ends unfinished
512
+ multipart uploads after a day). Replacing a file in parts (`update`, or
513
+ `upload` with `upsert` over a file that is there) needs room in the
514
+ project's capacity for the old file and the new one until it completes: if
515
+ the two together are over it, the upload answers
516
+ `507 STORAGE_QUOTA_EXCEEDED` before any part is sent.
517
+ - `onProgress: (sentBytes, totalBytes) => void` is called after each part
518
+ of an upload over 50 MiB, the last time with both the same. An upload of
519
+ 50 MiB or less is one request and does not call it. A `File` is sliced
520
+ into the parts, never read whole first. What the callback throws — or an
521
+ `async` one rejects with — does not stop the upload.
522
+
523
+ - `signal: AbortSignal` stops an upload (`upload` and `update`): what has
524
+ not been answered yet is called off, and the upload answers `ABORTED`
525
+ ("The upload was stopped before it finished."). An upload in parts is
526
+ called off on the backend too — no part starts after the signal, and
527
+ `onProgress` is not called again — so its name and the space it reserved
528
+ are free at once, not after the hour its lock lasts. Its first step is
529
+ never cut short (a signal during it is acted on once it answers), and once
530
+ its last step is sent the signal stops nothing: that step's answer is the
531
+ upload's. A signal already aborted sends nothing. A page closed or a
532
+ network lost mid-upload sends no abort: the name waits for the lock.
533
+
534
+ ```ts
535
+ const stop = new AbortController();
536
+ cancelButton.onclick = () => stop.abort();
537
+ await naro.storage.from("videos").upload(`${user.id}/talk.mp4`, file, {
538
+ onProgress: (sent, total) => setPercent(Math.round((sent / total) * 100)),
539
+ signal: stop.signal,
540
+ });
541
+ ```
542
+
500
543
  - A `contentType` or `cacheControl` no header can carry (a CR, LF or NUL
501
544
  inside, or a character past U+00FF) is answered before a request, as the
502
545
  backend answers one it cannot read: `INVALID_CONTENT_TYPE`, and
@@ -512,7 +555,15 @@ const { data: link } = await naro.storage
512
555
  check.
513
556
  - `exists` answers `false` for `OBJECT_NOT_FOUND` and passes every other
514
557
  error on. `getPublicUrl` makes no request and answers `{ data, error }`.
558
+ - `objectRoute(bucket, path)` names an object the way the client does:
559
+ `{ ok: true, encoded }` with `<bucket>/<path>` as the object routes carry
560
+ it (each segment `encodeURIComponent`-ed), or `{ ok: false, error }` with
561
+ the client's own `INVALID_PATH` (type `ObjectRouteResult`). A tool that
562
+ streams an object itself builds `/_naro/v1/storage/object/<encoded>` from
563
+ it and gets the URL and the refusals the client would — `naro storage cp`
564
+ downloads that way.
515
565
  - `OBJECT_RATE_LIMITED` and `STORAGE_OPS_QUOTA_EXCEEDED` carry
516
566
  `details.retryAfter`, in seconds.
517
- - Not yet: `move`, `copy`, `createSignedUrls`, `list`'s `search`, files over
518
- 50 MiB, and bucket methods (buckets are made with `naro storage bucket`).
567
+ - Not yet: `move`, `copy`, `createSignedUrls`, `list`'s `search`, resuming
568
+ an upload in another session, and bucket methods (buckets are made with
569
+ `naro storage bucket`).
package/dist/errors.d.ts CHANGED
@@ -25,6 +25,11 @@ export declare function networkError(cause: unknown): NaroError;
25
25
  * code exchange a page runs on load (PR #68 review 10). It is called off.
26
26
  */
27
27
  export declare function requestTimeout(ms: number): NaroError;
28
+ /**
29
+ * An upload the caller's `signal` stopped (PR #111 review r1): what was not
30
+ * answered yet was called off. Never tried again.
31
+ */
32
+ export declare function uploadAborted(): NaroError;
28
33
  /** An answer, but not one a Naro worker gives: HTML, empty, or the wrong shape. */
29
34
  export declare function invalidResponse(status: number): NaroError;
30
35
  /**
package/dist/http.d.ts CHANGED
@@ -15,7 +15,7 @@ export type HttpConfig = {
15
15
  tokens: TokenStore;
16
16
  };
17
17
  export type CallOptions = {
18
- method: "GET" | "POST" | "DELETE";
18
+ method: "GET" | "POST" | "PUT" | "DELETE";
19
19
  /** Below `/_naro/v1`, e.g. `/data/query`. */
20
20
  path: string;
21
21
  /** Sent as JSON. */
@@ -60,9 +60,21 @@ export type SendOptions = {
60
60
  headers?: Record<string, string>;
61
61
  body?: BodyInit;
62
62
  withToken: boolean;
63
+ /**
64
+ * An upload's `signal` (PR #111 review r1): it calls the request off until
65
+ * its answer comes — ABORTED, not NETWORK_ERROR — and never the reading
66
+ * of an answer that came, which may be a success.
67
+ */
68
+ signal?: AbortSignal | undefined;
63
69
  };
70
+ /**
71
+ * A failure carries the answer's HTTP status, or null when nothing answered
72
+ * (NETWORK_ERROR): a multipart upload tries a part again on a 5xx, whatever
73
+ * the body said (Storage 3-1f, plan C7).
74
+ */
64
75
  export type SendResult = {
65
76
  error: NaroError;
77
+ status: number | null;
66
78
  } | {
67
79
  error: null;
68
80
  response: Response;
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export type { AuthStorage } from "./auth/storage.js";
5
5
  export type { NaroError } from "./errors.js";
6
6
  export type { FetchLike } from "./http.js";
7
7
  export type { StorageClient, StorageFileApi } from "./storage/client.js";
8
+ export { objectRoute, type Encoded as ObjectRouteResult, } from "./storage/path.js";
8
9
  export type { DownloadOption, FileObject, FolderEntry, ListOptions, UpdateOptions, UploadBody, UploadOptions, UploadResult, } from "./storage/types.js";
9
10
  export type { ArrayValues, FilterGroup, FilterGroupCallback, InsertBuilder, MatchQuery, MutationBuilder, RawFilterValue, SelectBuilder, TableBuilder, } from "./query/builder.js";
10
11
  export { quoteFilterValue, type FilterOperatorName } from "./query/filters.js";
package/dist/index.js CHANGED
@@ -14,6 +14,12 @@ function requestTimeout(ms) {
14
14
  message: `The Naro backend did not answer within ${String(Math.round(ms / 1e3))} seconds. Check the network and try again.`
15
15
  };
16
16
  }
17
+ function uploadAborted() {
18
+ return {
19
+ code: "ABORTED",
20
+ message: "The upload was stopped before it finished."
21
+ };
22
+ }
17
23
  function invalidResponse(status) {
18
24
  return {
19
25
  code: "INVALID_RESPONSE",
@@ -181,6 +187,12 @@ async function send(config, options) {
181
187
  credentials: "omit"
182
188
  };
183
189
  if (options.body !== void 0) init.body = options.body;
190
+ const { signal } = options;
191
+ if (signal?.aborted) return { error: uploadAborted(), status: null };
192
+ const controller = signal ? new AbortController() : null;
193
+ const stop = () => controller?.abort();
194
+ if (controller) init.signal = controller.signal;
195
+ signal?.addEventListener("abort", stop);
184
196
  let response;
185
197
  try {
186
198
  const fetchImpl = config.fetch ?? globalThis.fetch;
@@ -189,26 +201,34 @@ async function send(config, options) {
189
201
  init
190
202
  );
191
203
  } catch (error2) {
192
- return { error: networkError(error2) };
204
+ return {
205
+ error: signal?.aborted ? uploadAborted() : networkError(error2),
206
+ status: null
207
+ };
208
+ } finally {
209
+ signal?.removeEventListener("abort", stop);
193
210
  }
194
- if (response.status >= 200 && response.status < 300)
195
- return { error: null, response };
211
+ const { status } = response;
212
+ if (status >= 200 && status < 300) return { error: null, response };
196
213
  let text;
197
214
  try {
198
215
  text = await response.text();
199
216
  } catch (error2) {
200
- return { error: networkError(error2) };
217
+ return { error: networkError(error2), status: null };
201
218
  }
202
219
  let body;
203
220
  if (text.trim() !== "") {
204
221
  try {
205
222
  body = JSON.parse(text);
206
223
  } catch {
207
- return { error: invalidResponse(response.status) };
224
+ return { error: invalidResponse(status), status };
208
225
  }
209
226
  }
210
- const error = errorFromBody(response.status, body) ?? invalidResponse(response.status);
211
- return { error: await settled(config, error, response.headers, token) };
227
+ const error = errorFromBody(status, body) ?? invalidResponse(status);
228
+ return {
229
+ error: await settled(config, error, response.headers, token),
230
+ status
231
+ };
212
232
  }
213
233
  var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
214
234
  "RATE_LIMITED",
@@ -1806,11 +1826,181 @@ var TableBuilder = class {
1806
1826
  }
1807
1827
  };
1808
1828
 
1829
+ // src/storage/answer.ts
1830
+ function fail(error) {
1831
+ return { data: null, error };
1832
+ }
1833
+ var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
1834
+ function dataOf2(status, body, guard) {
1835
+ if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
1836
+ return { data: body.data, error: null };
1837
+ return fail(invalidResponse(status));
1838
+ }
1839
+ async function readJson(response, guard) {
1840
+ let text;
1841
+ try {
1842
+ text = await response.text();
1843
+ } catch (error) {
1844
+ return fail(networkError(error));
1845
+ }
1846
+ let body;
1847
+ try {
1848
+ body = JSON.parse(text);
1849
+ } catch {
1850
+ return fail(invalidResponse(response.status));
1851
+ }
1852
+ return dataOf2(response.status, body, guard);
1853
+ }
1854
+
1855
+ // src/storage/multipart.ts
1856
+ var ROUTE = "/storage/upload/multipart";
1857
+ var CONCURRENCY = 4;
1858
+ var ATTEMPTS = 4;
1859
+ var BACKOFF_MS = [1e3, 2e3, 4e3];
1860
+ var UPLOAD_ID = /^[\w-][\w.-]*$/;
1861
+ var positive = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
1862
+ var planFor = (size) => (value) => isRecord(value) && typeof value.uploadId === "string" && UPLOAD_ID.test(value.uploadId) && positive(value.partSize) && positive(value.partCount) && (value.partCount - 1) * value.partSize < size && value.partCount * value.partSize >= size;
1863
+ var partAnswer = (n) => (value) => isRecord(value) && value.partNumber === n && typeof value.etag === "string" && value.etag !== "";
1864
+ var retryable = (error, status) => error.code === "NETWORK_ERROR" || error.code === "OBJECT_RATE_LIMITED" || status !== null && status >= 500 && error.code !== "STORAGE_QUOTA_EXCEEDED";
1865
+ function waitAfter(error, attempt2) {
1866
+ const after = isRecord(error.details) ? error.details.retryAfter : void 0;
1867
+ if (typeof after === "number") return after * 1e3;
1868
+ if (error.code === "OBJECT_RATE_LIMITED") return 1e3;
1869
+ return BACKOFF_MS[attempt2 - 1] ?? 4e3;
1870
+ }
1871
+ var sleepFor = (ms, signal) => new Promise((done) => {
1872
+ if (signal?.aborted) {
1873
+ done();
1874
+ return;
1875
+ }
1876
+ const end = () => {
1877
+ clearTimeout(timer);
1878
+ signal?.removeEventListener("abort", end);
1879
+ done();
1880
+ };
1881
+ const timer = setTimeout(end, ms);
1882
+ signal?.addEventListener("abort", end);
1883
+ });
1884
+ async function uploadInParts(a) {
1885
+ const { signal } = a;
1886
+ const sleep = a.sleep ?? sleepFor;
1887
+ if (signal?.aborted) return fail(uploadAborted());
1888
+ const started = await call(a.http, {
1889
+ method: a.method,
1890
+ path: ROUTE,
1891
+ body: {
1892
+ bucket: a.bucket,
1893
+ path: a.path,
1894
+ size: a.size,
1895
+ contentType: a.contentType,
1896
+ cacheControl: a.cacheControl,
1897
+ // PUT always replaces: the worker refuses `upsert` on it.
1898
+ ...a.method === "POST" && a.upsert ? { upsert: true } : {}
1899
+ },
1900
+ withToken: true
1901
+ });
1902
+ if (started.error !== null) return fail(started.error);
1903
+ const plan = dataOf2(
1904
+ started.answer.status,
1905
+ started.answer.body,
1906
+ planFor(a.size)
1907
+ );
1908
+ if (plan.error !== null) return fail(plan.error);
1909
+ const { uploadId, partSize, partCount } = plan.data;
1910
+ const base = `${ROUTE}/${uploadId}`;
1911
+ const progress = (sentBytes2) => {
1912
+ if (signal?.aborted) return;
1913
+ try {
1914
+ const result = a.onProgress?.(sentBytes2, a.size);
1915
+ if (isThenable(result)) result.then(void 0, () => void 0);
1916
+ } catch {
1917
+ }
1918
+ };
1919
+ const callOff = async () => {
1920
+ await call(a.http, { method: "DELETE", path: base, withToken: true }).catch(
1921
+ () => void 0
1922
+ );
1923
+ };
1924
+ const etags = [];
1925
+ const failure = {
1926
+ error: signal?.aborted ? uploadAborted() : null
1927
+ };
1928
+ let next = 1;
1929
+ let sentBytes = 0;
1930
+ const onAbort = () => {
1931
+ failure.error ??= uploadAborted();
1932
+ };
1933
+ const tryPart = async (n, bytes) => {
1934
+ const sent = await send(a.http, {
1935
+ method: "PUT",
1936
+ path: `${base}/${String(n)}`,
1937
+ headers: { "content-type": "application/octet-stream" },
1938
+ body: bytes,
1939
+ withToken: true,
1940
+ signal
1941
+ });
1942
+ if (sent.error !== null) return sent;
1943
+ const read = await readJson(sent.response, partAnswer(n));
1944
+ if (read.error !== null)
1945
+ return { error: read.error, status: sent.response.status };
1946
+ return { etag: read.data.etag };
1947
+ };
1948
+ const sendPart = async (n) => {
1949
+ const from = (n - 1) * partSize;
1950
+ const to = Math.min(from + partSize, a.size);
1951
+ const bytes = a.body instanceof Uint8Array ? a.body.subarray(from, to) : a.body.slice(from, to);
1952
+ for (let attempt2 = 1; ; attempt2 += 1) {
1953
+ const tried = await tryPart(n, bytes);
1954
+ if ("etag" in tried) {
1955
+ etags[n - 1] = tried.etag;
1956
+ sentBytes += to - from;
1957
+ progress(sentBytes);
1958
+ return null;
1959
+ }
1960
+ if (attempt2 === ATTEMPTS || failure.error !== null || !retryable(tried.error, tried.status))
1961
+ return tried.error;
1962
+ await sleep(waitAfter(tried.error, attempt2), signal);
1963
+ if (failure.error !== null) return tried.error;
1964
+ }
1965
+ };
1966
+ const worker = async () => {
1967
+ while (failure.error === null && next <= partCount) {
1968
+ const n = next;
1969
+ next += 1;
1970
+ const error = await sendPart(n);
1971
+ if (error !== null) failure.error ??= error;
1972
+ }
1973
+ };
1974
+ signal?.addEventListener("abort", onAbort);
1975
+ await Promise.all(
1976
+ Array.from({ length: Math.min(CONCURRENCY, partCount) }, worker)
1977
+ );
1978
+ signal?.removeEventListener("abort", onAbort);
1979
+ if (failure.error !== null) {
1980
+ await callOff();
1981
+ return fail(failure.error);
1982
+ }
1983
+ const completed = await call(a.http, {
1984
+ method: "POST",
1985
+ path: `${base}/complete`,
1986
+ body: {
1987
+ parts: etags.map((etag, i) => ({ partNumber: i + 1, etag }))
1988
+ },
1989
+ withToken: true
1990
+ });
1991
+ if (completed.error !== null) {
1992
+ if (completed.answer !== null && completed.error.code !== "INVALID_RESPONSE")
1993
+ await callOff();
1994
+ return fail(completed.error);
1995
+ }
1996
+ return dataOf2(completed.answer.status, completed.answer.body, isUploadResult);
1997
+ }
1998
+ var isThenable = (value) => typeof value === "object" && value !== null && typeof value.then === "function";
1999
+
1809
2000
  // src/storage/path.ts
1810
2001
  var MAX_SINGLE_UPLOAD_BYTES = 52428800;
1811
2002
  var STORAGE_MESSAGES = {
1812
2003
  bucket: "The bucket name is not valid.",
1813
- tooLarge: "Uploads over 50 MiB are not supported yet.",
1814
2004
  request: "The storage request is not valid.",
1815
2005
  body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
1816
2006
  };
@@ -1835,9 +2025,6 @@ function invalidPath(reason) {
1835
2025
  message: `The object path is not valid: it ${reason}.`
1836
2026
  };
1837
2027
  }
1838
- function objectTooLarge() {
1839
- return { code: "OBJECT_TOO_LARGE", message: STORAGE_MESSAGES.tooLarge };
1840
- }
1841
2028
  function encodePath(path) {
1842
2029
  if (typeof path !== "string" || path === "")
1843
2030
  return { ok: false, error: invalidPath("is empty") };
@@ -1874,6 +2061,16 @@ function encodeBucket(bucket) {
1874
2061
  };
1875
2062
  return { ok: true, encoded: encodeURIComponent(bucket) };
1876
2063
  }
2064
+ function objectRoute(bucket, path) {
2065
+ const encodedBucket = encodeBucket(bucket);
2066
+ if (!encodedBucket.ok) return encodedBucket;
2067
+ const encodedPath = encodePath(path);
2068
+ if (!encodedPath.ok) return encodedPath;
2069
+ return {
2070
+ ok: true,
2071
+ encoded: `${encodedBucket.encoded}/${encodedPath.encoded}`
2072
+ };
2073
+ }
1877
2074
  var isArrayBuffer = (value) => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
1878
2075
  function isDetached(buffer) {
1879
2076
  if (!isArrayBuffer(buffer)) return false;
@@ -1956,36 +2153,12 @@ function payloadOf(body, contentType) {
1956
2153
 
1957
2154
  // src/storage/client.ts
1958
2155
  var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
1959
- function fail(error) {
1960
- return { data: null, error };
1961
- }
1962
- var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
1963
2156
  var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
1964
2157
  var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
1965
2158
  var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
1966
2159
  function listOf(guard) {
1967
2160
  return (value) => Array.isArray(value) && value.every(guard);
1968
2161
  }
1969
- function dataOf2(status, body, guard) {
1970
- if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
1971
- return { data: body.data, error: null };
1972
- return fail(invalidResponse(status));
1973
- }
1974
- async function readJson(response, guard) {
1975
- let text;
1976
- try {
1977
- text = await response.text();
1978
- } catch (error) {
1979
- return fail(networkError(error));
1980
- }
1981
- let body;
1982
- try {
1983
- body = JSON.parse(text);
1984
- } catch {
1985
- return fail(invalidResponse(response.status));
1986
- }
1987
- return dataOf2(response.status, body, guard);
1988
- }
1989
2162
  var StorageFileApi = class {
1990
2163
  #http;
1991
2164
  #auth;
@@ -1997,14 +2170,9 @@ var StorageFileApi = class {
1997
2170
  }
1998
2171
  /** `/storage/object/<route><bucket>/<path>`, or the refusal no request needs. */
1999
2172
  #objectPath(route, path) {
2000
- const bucket = encodeBucket(this.#bucket);
2001
- if (!bucket.ok) return bucket;
2002
- const name = encodePath(path);
2003
- if (!name.ok) return name;
2004
- return {
2005
- ok: true,
2006
- path: `/storage/object/${route}${bucket.encoded}/${name.encoded}`
2007
- };
2173
+ const target = objectRoute(this.#bucket, path);
2174
+ if (!target.ok) return target;
2175
+ return { ok: true, path: `/storage/object/${route}${target.encoded}` };
2008
2176
  }
2009
2177
  /** `/storage/object/<route><bucket>`, for the routes that take names in the body. */
2010
2178
  #bucketPath(route) {
@@ -2026,8 +2194,24 @@ var StorageFileApi = class {
2026
2194
  const cache = `max-age=${options.cacheControl ?? "3600"}`;
2027
2195
  const unreadable = headerProblem(options.contentType, cache);
2028
2196
  if (unreadable !== null) return fail(unreadable);
2029
- if (payload.size > MAX_SINGLE_UPLOAD_BYTES) return fail(objectTooLarge());
2197
+ const { signal } = options;
2198
+ if (signal?.aborted) return fail(uploadAborted());
2030
2199
  await this.#auth.ready();
2200
+ if (payload.size > MAX_SINGLE_UPLOAD_BYTES)
2201
+ return uploadInParts({
2202
+ http: this.#http,
2203
+ method,
2204
+ bucket: this.#bucket,
2205
+ path,
2206
+ // No copy: a Blob stays lazy, a Uint8Array is viewed, an ArrayBuffer wrapped.
2207
+ body: isArrayBuffer(payload.body) ? new Uint8Array(payload.body) : payload.body,
2208
+ size: payload.size,
2209
+ contentType: payload.type,
2210
+ cacheControl: cache,
2211
+ upsert,
2212
+ ...options.onProgress ? { onProgress: options.onProgress } : {},
2213
+ signal
2214
+ });
2031
2215
  const headers = {
2032
2216
  "content-type": payload.type,
2033
2217
  "cache-control": cache
@@ -2038,7 +2222,9 @@ var StorageFileApi = class {
2038
2222
  path: target.path,
2039
2223
  headers,
2040
2224
  body: payload.body,
2041
- withToken: true
2225
+ withToken: true,
2226
+ // The fetch stops with the body: the worker undoes its claim at once.
2227
+ signal
2042
2228
  });
2043
2229
  if (sent.error !== null) return fail(sent.error);
2044
2230
  return readJson(sent.response, isUploadResult);
@@ -2282,5 +2468,6 @@ function createClient(options) {
2282
2468
  export {
2283
2469
  NaroClient,
2284
2470
  createClient,
2471
+ objectRoute,
2285
2472
  quoteFilterValue
2286
2473
  };
@@ -0,0 +1,9 @@
1
+ import { type NaroError } from "../errors.js";
2
+ import type { NaroResponse } from "../types.js";
3
+ import type { UploadResult } from "./types.js";
4
+ export declare function fail<T>(error: NaroError): NaroResponse<T>;
5
+ export declare const isUploadResult: (value: unknown) => value is UploadResult;
6
+ /** `{ data }` of a worker's JSON answer that passes `guard`, else INVALID_RESPONSE. */
7
+ export declare function dataOf<T>(status: number, body: unknown, guard: (value: unknown) => value is T): NaroResponse<T>;
8
+ /** A 2xx answer `send` handed back unread, read as `dataOf` reads one. */
9
+ export declare function readJson<T>(response: Response, guard: (value: unknown) => value is T): Promise<NaroResponse<T>>;
@@ -0,0 +1,23 @@
1
+ import { type HttpConfig } from "../http.js";
2
+ import type { NaroResponse } from "../types.js";
3
+ import type { UploadResult } from "./types.js";
4
+ export type PartsArgs = {
5
+ http: HttpConfig;
6
+ /** POST for `upload` (a new name, or `upsert`), PUT for `update`. */
7
+ method: "POST" | "PUT";
8
+ bucket: string;
9
+ /** The object's name as given: the start carries it in JSON, not a URL. */
10
+ path: string;
11
+ body: Blob | Uint8Array<ArrayBuffer>;
12
+ size: number;
13
+ contentType: string;
14
+ /** As the upload's header says it: `max-age=<seconds>`. */
15
+ cacheControl: string;
16
+ upsert: boolean;
17
+ onProgress?: (sentBytes: number, totalBytes: number) => void;
18
+ /** The caller's: stops the upload until the complete is sent. */
19
+ signal?: AbortSignal | undefined;
20
+ /** How a retry waits — `ms`, or until the signal aborts; tests pass their own. */
21
+ sleep?: (ms: number, signal: AbortSignal | undefined) => Promise<void>;
22
+ };
23
+ export declare function uploadInParts(a: PartsArgs): Promise<NaroResponse<UploadResult>>;
@@ -3,13 +3,12 @@ import type { NaroError } from "../errors.js";
3
3
  * The most one upload request carries (original spec §9.1): 50 MiB.
4
4
  * data-core's MAX_SINGLE_UPLOAD_BYTES — storage/wire.test.ts holds them
5
5
  * equal. Over it, the edge cuts the body off and the browser often cannot
6
- * read the answer: the SDK refuses first.
6
+ * read the answer: the SDK sends a larger body in parts (Storage 3-1f).
7
7
  */
8
8
  export declare const MAX_SINGLE_UPLOAD_BYTES = 52428800;
9
9
  /** The worker's words (data-core's catalog) for what the SDK refuses itself. */
10
10
  export declare const STORAGE_MESSAGES: {
11
11
  readonly bucket: "The bucket name is not valid.";
12
- readonly tooLarge: "Uploads over 50 MiB are not supported yet.";
13
12
  readonly request: "The storage request is not valid.";
14
13
  readonly body: "upload takes a Blob, File, ArrayBuffer, typed array or string.";
15
14
  };
@@ -29,7 +28,6 @@ export type Encoded = {
29
28
  error: NaroError;
30
29
  };
31
30
  export declare function invalidPath(reason: string): NaroError;
32
- export declare function objectTooLarge(): NaroError;
33
31
  /**
34
32
  * An object path as a URL carries it, each segment encoded once and `/`
35
33
  * kept as the separator — or the worker's INVALID_PATH for the paths no
@@ -53,6 +51,19 @@ export declare function downloadQuery(download: boolean | string | undefined): E
53
51
  * empty, `.`, `..`, a lone surrogate and the routes' own words.
54
52
  */
55
53
  export declare function encodeBucket(bucket: string): Encoded;
54
+ /**
55
+ * `<bucket>/<path>` as the object routes carry it — the bucket by
56
+ * `encodeBucket`, the path by `encodePath` — or the INVALID_PATH either is
57
+ * refused with. Every object call builds its URL from it, and the package
58
+ * exports it for a tool that streams an object itself (naro-cli's
59
+ * download): it names the same URL, and is refused the same names.
60
+ */
61
+ export declare function objectRoute(bucket: string, path: string): Encoded;
62
+ /**
63
+ * `instanceof ArrayBuffer` is false for one made in another realm (an
64
+ * iframe, a vm context); the tag is the same in every realm.
65
+ */
66
+ export declare const isArrayBuffer: (value: unknown) => value is ArrayBuffer;
56
67
  /** Why the worker refuses a cache-control it cannot read (its upload.ts). */
57
68
  export declare const CACHE_CONTROL_REASON = "must be max-age=<seconds> from 0 to 31536000";
58
69
  /**
@@ -34,6 +34,25 @@ export type UploadOptions = {
34
34
  cacheControl?: string;
35
35
  /** Replace an object of the same name. Default false. */
36
36
  upsert?: boolean;
37
+ /**
38
+ * Called each time a part of an upload over 50 MiB is sent: the bytes
39
+ * sent so far and the body's size, ending with both the same. An upload
40
+ * of 50 MiB or less is one request and never calls it. What it throws is
41
+ * ignored: the upload goes on.
42
+ */
43
+ onProgress?: (sentBytes: number, totalBytes: number) => void;
44
+ /**
45
+ * Stops the upload: what has not been answered yet is called off, and the
46
+ * upload answers `ABORTED` ("The upload was stopped before it finished.").
47
+ * One over 50 MiB is called off on the backend too, so its name and the
48
+ * space it reserved are free at once — not after the hour its lock lasts;
49
+ * no part starts and `onProgress` is not called after the signal. Its
50
+ * first and last steps are never cut short: a signal during the first is
51
+ * acted on once it answers, and once the last is sent the signal stops
52
+ * nothing and that step's answer is the upload's. A signal already aborted
53
+ * sends nothing.
54
+ */
55
+ signal?: AbortSignal;
37
56
  };
38
57
  export type UpdateOptions = Omit<UploadOptions, "upsert">;
39
58
  export type UploadResult = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naro-js",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Auth, data and files for your Naro project, from the browser or the server.",
5
5
  "type": "module",
6
6
  "sideEffects": false,