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 +58 -7
- package/dist/errors.d.ts +5 -0
- package/dist/http.d.ts +13 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +232 -45
- package/dist/storage/answer.d.ts +9 -0
- package/dist/storage/multipart.d.ts +23 -0
- package/dist/storage/path.d.ts +14 -3
- package/dist/storage/types.d.ts +19 -0
- package/package.json +1 -1
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,
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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`,
|
|
518
|
-
|
|
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 {
|
|
204
|
+
return {
|
|
205
|
+
error: signal?.aborted ? uploadAborted() : networkError(error2),
|
|
206
|
+
status: null
|
|
207
|
+
};
|
|
208
|
+
} finally {
|
|
209
|
+
signal?.removeEventListener("abort", stop);
|
|
193
210
|
}
|
|
194
|
-
|
|
195
|
-
|
|
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(
|
|
224
|
+
return { error: invalidResponse(status), status };
|
|
208
225
|
}
|
|
209
226
|
}
|
|
210
|
-
const error = errorFromBody(
|
|
211
|
-
return {
|
|
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
|
|
2001
|
-
if (!
|
|
2002
|
-
|
|
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
|
-
|
|
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>>;
|
package/dist/storage/path.d.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/storage/types.d.ts
CHANGED
|
@@ -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 = {
|