naro-js 0.2.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 +155 -2
- package/dist/client.d.ts +7 -0
- package/dist/errors.d.ts +5 -0
- package/dist/http.d.ts +39 -5
- package/dist/index.d.ts +3 -0
- package/dist/index.js +621 -6
- package/dist/storage/answer.d.ts +9 -0
- package/dist/storage/client.d.ts +65 -0
- package/dist/storage/multipart.d.ts +23 -0
- package/dist/storage/path.d.ts +87 -0
- package/dist/storage/types.d.ts +77 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# naro-js
|
|
2
2
|
|
|
3
|
-
Auth and
|
|
3
|
+
Auth, data and files for your [Naro](https://naro.sh) project, from the browser or
|
|
4
4
|
the server. A project's backend brings its own database; this client signs
|
|
5
5
|
users in and reads and writes it through the project's policies. No SQL, no
|
|
6
6
|
database credentials, no dependencies.
|
|
@@ -78,6 +78,45 @@ await naro.from("posts").delete().eq("id", 10);
|
|
|
78
78
|
- Admin accounts, closed sign-ups, sign-in by username and the password
|
|
79
79
|
length — see [Admin accounts and closed sign-ups](#admin-accounts-and-closed-sign-ups).
|
|
80
80
|
- Email and social sign-in — see [Sign-in flows](#sign-in-flows).
|
|
81
|
+
- Files — see [Storage](#storage).
|
|
82
|
+
|
|
83
|
+
## Policies
|
|
84
|
+
|
|
85
|
+
What a request may do is decided by the table's policies — Postgres's
|
|
86
|
+
row-level security, as in Supabase. A table without a policy refuses every
|
|
87
|
+
anon and signed-in request (only the service key gets through), so write a
|
|
88
|
+
table's policies in the migration that creates it: `create policy` after the
|
|
89
|
+
`create table`, in the same file. `naro db push` applies them with the table,
|
|
90
|
+
all or nothing.
|
|
91
|
+
|
|
92
|
+
```sql
|
|
93
|
+
-- migrations/0002_todos.sql: each signed-in user reads and changes their own rows.
|
|
94
|
+
create table todos (
|
|
95
|
+
id integer primary key,
|
|
96
|
+
user_id uuid not null,
|
|
97
|
+
title text not null
|
|
98
|
+
);
|
|
99
|
+
create policy "own rows" on todos for all to authenticated
|
|
100
|
+
using ((select auth.uid()) = user_id)
|
|
101
|
+
with check ((select auth.uid()) = user_id);
|
|
102
|
+
|
|
103
|
+
-- migrations/0003_posts.sql: anyone reads, signed in or not.
|
|
104
|
+
create table posts (id integer primary key, title text not null);
|
|
105
|
+
create policy "read" on posts for select to anon, authenticated using (true);
|
|
106
|
+
|
|
107
|
+
-- migrations/0004_reports.sql: an app only its admins sign in to. First run
|
|
108
|
+
-- naro backend auth signups off and create the admin accounts: authenticated
|
|
109
|
+
-- then means exactly those accounts.
|
|
110
|
+
create table reports (id integer primary key, body text not null);
|
|
111
|
+
create policy "admins" on reports for all to authenticated using (true) with check (true);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A request no policy lets through, and a written row that fails a check,
|
|
115
|
+
answer `403 PERMISSION_DENIED`; rows a policy's `using` does not pass are left
|
|
116
|
+
out without an error. The `naro-cli` README lists what a
|
|
117
|
+
migration accepts and refuses (`DISABLE ROW LEVEL SECURITY`, `GRANT`,
|
|
118
|
+
`REVOKE` and `ALTER POLICY` are refused), the warnings it answers with, and
|
|
119
|
+
`naro policy set`, a shorthand for the common rules.
|
|
81
120
|
|
|
82
121
|
## Admin accounts and closed sign-ups
|
|
83
122
|
|
|
@@ -413,4 +452,118 @@ await naro.from("posts").select().contains("tags", ["a", "b"]);
|
|
|
413
452
|
- Limits: groups nest 4 deep (`not` counts); one request holds at most 100
|
|
414
453
|
conditions, 4 of them `contains` / `containedBy` / `overlaps`; one
|
|
415
454
|
statement binds at most 100 values (a select's limit and offset are two of
|
|
416
|
-
them) and compiles to at most 100,000 bytes of SQL.
|
|
455
|
+
them) and compiles to at most 100,000 bytes of SQL. A `like` or `ilike`
|
|
456
|
+
pattern is at most 50 bytes of UTF-8, which is D1's limit; in `like`, a
|
|
457
|
+
literal `?` or `[` counts as 3. A longer one is `400 INVALID_QUERY` before
|
|
458
|
+
the database is asked.
|
|
459
|
+
|
|
460
|
+
## Storage
|
|
461
|
+
|
|
462
|
+
Files your users upload — avatars, attachments, documents — go in the
|
|
463
|
+
project's Storage: Cloudflare R2 behind the same URL, under policies on
|
|
464
|
+
`storage.objects` written as in Supabase. Turn it on with
|
|
465
|
+
`naro storage enable` (the backend must be on), make buckets with
|
|
466
|
+
`naro storage bucket create avatars --public`, and write the policies in a
|
|
467
|
+
migration that `naro db push` applies:
|
|
468
|
+
|
|
469
|
+
```sql
|
|
470
|
+
create policy "avatars are readable" on storage.objects
|
|
471
|
+
for select to public using (bucket_id = 'avatars');
|
|
472
|
+
create policy "upload into own folder" on storage.objects
|
|
473
|
+
for insert to authenticated
|
|
474
|
+
with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
const avatars = naro.storage.from("avatars");
|
|
479
|
+
await avatars.upload(`${user.id}/me.png`, file, { upsert: true });
|
|
480
|
+
const { data: image } = avatars.getPublicUrl(`${user.id}/me.png`); // no request
|
|
481
|
+
const { data: files } = await avatars.list(user.id, { limit: 20 });
|
|
482
|
+
await avatars.remove([`${user.id}/old.png`]);
|
|
483
|
+
const { data: link } = await naro.storage
|
|
484
|
+
.from("docs")
|
|
485
|
+
.createSignedUrl("team-1/plan.pdf", 600);
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
- `upload`, `update`, `download`, `info`, `exists`, `list`, `remove`,
|
|
489
|
+
`createSignedUrl` and `getPublicUrl` take supabase-js's arguments and
|
|
490
|
+
answer its shapes (`FileObject` in snake_case), as `{ data, error }`.
|
|
491
|
+
One exception: supabase-js's `info` answers a camelCase object
|
|
492
|
+
(`bucketId`, `createdAt`, `size`, `contentType`, …); naro-js's `info`
|
|
493
|
+
answers the same snake_case `FileObject` as `list`, with the size and type
|
|
494
|
+
in `metadata.size` and `metadata.mimetype`.
|
|
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
|
+
|
|
543
|
+
- A `contentType` or `cacheControl` no header can carry (a CR, LF or NUL
|
|
544
|
+
inside, or a character past U+00FF) is answered before a request, as the
|
|
545
|
+
backend answers one it cannot read: `INVALID_CONTENT_TYPE`, and
|
|
546
|
+
`INVALID_REQUEST` with `details.path` `cache-control`.
|
|
547
|
+
- supabase-js's `upload(path, file)` sends `multipart/form-data`, which Naro
|
|
548
|
+
refuses with `400 INVALID_REQUEST`. Without naro-js, send the file's bytes
|
|
549
|
+
as the body of `POST /_naro/v1/storage/object/<bucket>/<path>`.
|
|
550
|
+
- Refused before a request, with the backend's own words (`INVALID_PATH`):
|
|
551
|
+
an empty path, a `/` at either end, `//`, a `.` or `..` segment, a lone
|
|
552
|
+
surrogate (`is not valid UTF-8`: no URL can carry it), and a bucket named
|
|
553
|
+
`""`, `.`, `..`, with a lone surrogate or a word the storage routes use
|
|
554
|
+
(`The bucket name is not valid.`). Everything else is the backend's to
|
|
555
|
+
check.
|
|
556
|
+
- `exists` answers `false` for `OBJECT_NOT_FOUND` and passes every other
|
|
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.
|
|
565
|
+
- `OBJECT_RATE_LIMITED` and `STORAGE_OPS_QUOTA_EXCEEDED` carry
|
|
566
|
+
`details.retryAfter`, in seconds.
|
|
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/client.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { AuthClient } from "./auth/client.js";
|
|
|
2
2
|
import { type AuthStorage } from "./auth/storage.js";
|
|
3
3
|
import { type FetchLike } from "./http.js";
|
|
4
4
|
import { TableBuilder } from "./query/builder.js";
|
|
5
|
+
import { StorageClient } from "./storage/client.js";
|
|
5
6
|
import type { DatabaseShape, InsertOf, RowOf, TableName, TablesOf, UntypedDatabase, UpdateOf } from "./types.js";
|
|
6
7
|
type CommonOptions = {
|
|
7
8
|
/**
|
|
@@ -51,6 +52,12 @@ export declare class NaroClient<Database extends DatabaseShape<Database>> {
|
|
|
51
52
|
* service key, `auth.admin` manages users.
|
|
52
53
|
*/
|
|
53
54
|
readonly auth: AuthClient;
|
|
55
|
+
/**
|
|
56
|
+
* Files in the project's Naro Storage (first-release design 2026-10-06
|
|
57
|
+
* §8.1): `storage.from(bucket)` uploads, downloads, lists and removes
|
|
58
|
+
* them, as supabase-js does.
|
|
59
|
+
*/
|
|
60
|
+
readonly storage: StorageClient;
|
|
54
61
|
constructor(options: ClientOptions);
|
|
55
62
|
/** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
|
|
56
63
|
from<Table extends TableName<Database>>(table: Table): TableBuilder<RowOf<TablesOf<Database>[Table]>, InsertOf<TablesOf<Database>[Table]>, UpdateOf<TablesOf<Database>[Table]>>;
|
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. */
|
|
@@ -53,10 +53,44 @@ export type CallResult = {
|
|
|
53
53
|
* §11.1), but only if it is still the token this request carried.
|
|
54
54
|
*/
|
|
55
55
|
export declare function call(config: HttpConfig, options: CallOptions): Promise<CallResult>;
|
|
56
|
+
export type SendOptions = {
|
|
57
|
+
method: "GET" | "POST" | "PUT";
|
|
58
|
+
/** Below `/_naro/v1`, e.g. `/storage/object/avatars/me.png`. */
|
|
59
|
+
path: string;
|
|
60
|
+
headers?: Record<string, string>;
|
|
61
|
+
body?: BodyInit;
|
|
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;
|
|
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
|
+
*/
|
|
75
|
+
export type SendResult = {
|
|
76
|
+
error: NaroError;
|
|
77
|
+
status: number | null;
|
|
78
|
+
} | {
|
|
79
|
+
error: null;
|
|
80
|
+
response: Response;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* A request whose body or answer is not JSON (Storage, first-release design
|
|
84
|
+
* 2026-10-06 §8.1): an upload's bytes go as they are, a download's come
|
|
85
|
+
* back unread. The same `apikey`, bearer token and no cookies as `call`;
|
|
86
|
+
* a failing answer is read the way `call` reads one — the worker's error
|
|
87
|
+
* envelope, or INVALID_RESPONSE — and every case resolves.
|
|
88
|
+
*/
|
|
89
|
+
export declare function send(config: HttpConfig, options: SendOptions): Promise<SendResult>;
|
|
56
90
|
/**
|
|
57
|
-
* The worker's
|
|
58
|
-
* limiter; `WRITE_QUOTA_EXCEEDED` (PR C) is the
|
|
59
|
-
* database write limit — Data API writes, sign-up
|
|
60
|
-
* users wait for 00:00 UTC, reads still work.
|
|
91
|
+
* The worker's 429s that say how long to wait. `RATE_LIMITED` is the
|
|
92
|
+
* sign-in routes' per-address limiter; `WRITE_QUOTA_EXCEEDED` (PR C) is the
|
|
93
|
+
* project over its daily database write limit — Data API writes, sign-up
|
|
94
|
+
* and creating or deleting users wait for 00:00 UTC, reads still work.
|
|
61
95
|
*/
|
|
62
96
|
export declare const RETRY_AFTER_CODES: ReadonlySet<string>;
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ export type { AuthClient } from "./auth/client.js";
|
|
|
4
4
|
export type { AuthStorage } from "./auth/storage.js";
|
|
5
5
|
export type { NaroError } from "./errors.js";
|
|
6
6
|
export type { FetchLike } from "./http.js";
|
|
7
|
+
export type { StorageClient, StorageFileApi } from "./storage/client.js";
|
|
8
|
+
export { objectRoute, type Encoded as ObjectRouteResult, } from "./storage/path.js";
|
|
9
|
+
export type { DownloadOption, FileObject, FolderEntry, ListOptions, UpdateOptions, UploadBody, UploadOptions, UploadResult, } from "./storage/types.js";
|
|
7
10
|
export type { ArrayValues, FilterGroup, FilterGroupCallback, InsertBuilder, MatchQuery, MutationBuilder, RawFilterValue, SelectBuilder, TableBuilder, } from "./query/builder.js";
|
|
8
11
|
export { quoteFilterValue, type FilterOperatorName } from "./query/filters.js";
|
|
9
12
|
export type { AdminUser, AdminUserAttributes, AdminUserListResponse, AdminUserResponse, AuthActionResponse, AuthErrorCode, AuthFlowType, AuthResponse, DatabaseShape, EmailRedirectOptions, OAuthProvider, OAuthResponse, SignInWithOAuthOptions, UrlSessionResponse, UserAttributes, GenericTable, GenericView, NaroResponse, OrderOptions, PageParams, Session, SessionResponse, SignInCredentials, SignOutResponse, SignUpCredentials, UntypedDatabase, User, UserResponse, } from "./types.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",
|
|
@@ -158,15 +164,79 @@ async function call(config, options) {
|
|
|
158
164
|
const answer = { status, headers: responseHeaders, body, token };
|
|
159
165
|
const error = errorFromBody(status, body);
|
|
160
166
|
if (error === null) return { error: null, answer };
|
|
167
|
+
return {
|
|
168
|
+
error: await settled(config, error, responseHeaders, token),
|
|
169
|
+
answer
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async function settled(config, error, headers, token) {
|
|
161
173
|
if (error.code === "INVALID_SESSION" && token !== null)
|
|
162
174
|
await config.tokens.clearIf(token);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
175
|
+
return RETRY_AFTER_CODES.has(error.code) ? withRetryAfter(error, headers) : error;
|
|
176
|
+
}
|
|
177
|
+
async function send(config, options) {
|
|
178
|
+
const token = options.withToken ? await config.tokens.get() : null;
|
|
179
|
+
const headers = {
|
|
180
|
+
...options.headers,
|
|
181
|
+
apikey: config.apiKey
|
|
182
|
+
};
|
|
183
|
+
if (token !== null) headers.Authorization = `Bearer ${token}`;
|
|
184
|
+
const init = {
|
|
185
|
+
method: options.method,
|
|
186
|
+
headers,
|
|
187
|
+
credentials: "omit"
|
|
188
|
+
};
|
|
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);
|
|
196
|
+
let response;
|
|
197
|
+
try {
|
|
198
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
199
|
+
response = await fetchImpl(
|
|
200
|
+
`${config.baseUrl}/_naro/v1${options.path}`,
|
|
201
|
+
init
|
|
202
|
+
);
|
|
203
|
+
} catch (error2) {
|
|
204
|
+
return {
|
|
205
|
+
error: signal?.aborted ? uploadAborted() : networkError(error2),
|
|
206
|
+
status: null
|
|
207
|
+
};
|
|
208
|
+
} finally {
|
|
209
|
+
signal?.removeEventListener("abort", stop);
|
|
210
|
+
}
|
|
211
|
+
const { status } = response;
|
|
212
|
+
if (status >= 200 && status < 300) return { error: null, response };
|
|
213
|
+
let text;
|
|
214
|
+
try {
|
|
215
|
+
text = await response.text();
|
|
216
|
+
} catch (error2) {
|
|
217
|
+
return { error: networkError(error2), status: null };
|
|
218
|
+
}
|
|
219
|
+
let body;
|
|
220
|
+
if (text.trim() !== "") {
|
|
221
|
+
try {
|
|
222
|
+
body = JSON.parse(text);
|
|
223
|
+
} catch {
|
|
224
|
+
return { error: invalidResponse(status), status };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const error = errorFromBody(status, body) ?? invalidResponse(status);
|
|
228
|
+
return {
|
|
229
|
+
error: await settled(config, error, response.headers, token),
|
|
230
|
+
status
|
|
231
|
+
};
|
|
166
232
|
}
|
|
167
233
|
var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
|
|
168
234
|
"RATE_LIMITED",
|
|
169
|
-
"WRITE_QUOTA_EXCEEDED"
|
|
235
|
+
"WRITE_QUOTA_EXCEEDED",
|
|
236
|
+
// Storage (original spec §13): one object written twice within a second
|
|
237
|
+
// (`x-retry-after: 1`), and the daily operation limit (3-1d).
|
|
238
|
+
"OBJECT_RATE_LIMITED",
|
|
239
|
+
"STORAGE_OPS_QUOTA_EXCEEDED"
|
|
170
240
|
]);
|
|
171
241
|
function withRetryAfter(error, headers) {
|
|
172
242
|
const header = headers.get("x-retry-after")?.trim() ?? "";
|
|
@@ -1756,6 +1826,545 @@ var TableBuilder = class {
|
|
|
1756
1826
|
}
|
|
1757
1827
|
};
|
|
1758
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
|
+
|
|
2000
|
+
// src/storage/path.ts
|
|
2001
|
+
var MAX_SINGLE_UPLOAD_BYTES = 52428800;
|
|
2002
|
+
var STORAGE_MESSAGES = {
|
|
2003
|
+
bucket: "The bucket name is not valid.",
|
|
2004
|
+
request: "The storage request is not valid.",
|
|
2005
|
+
body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
|
|
2006
|
+
};
|
|
2007
|
+
var RESERVED_BUCKETS = /* @__PURE__ */ new Set([
|
|
2008
|
+
"authenticated",
|
|
2009
|
+
"copy",
|
|
2010
|
+
"info",
|
|
2011
|
+
"list",
|
|
2012
|
+
"move",
|
|
2013
|
+
"public",
|
|
2014
|
+
"render",
|
|
2015
|
+
"sign",
|
|
2016
|
+
"upload"
|
|
2017
|
+
]);
|
|
2018
|
+
var DOWNLOAD_NAME_REASON = "must be a file name of up to 255 bytes";
|
|
2019
|
+
var LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
2020
|
+
var OCTET_STREAM = "application/octet-stream";
|
|
2021
|
+
var UTF8_TEXT = "text/plain;charset=utf-8";
|
|
2022
|
+
function invalidPath(reason) {
|
|
2023
|
+
return {
|
|
2024
|
+
code: "INVALID_PATH",
|
|
2025
|
+
message: `The object path is not valid: it ${reason}.`
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
function encodePath(path) {
|
|
2029
|
+
if (typeof path !== "string" || path === "")
|
|
2030
|
+
return { ok: false, error: invalidPath("is empty") };
|
|
2031
|
+
if (LONE_SURROGATE.test(path))
|
|
2032
|
+
return { ok: false, error: invalidPath("is not valid UTF-8") };
|
|
2033
|
+
if (path.startsWith("/") || path.endsWith("/"))
|
|
2034
|
+
return { ok: false, error: invalidPath("starts or ends with /") };
|
|
2035
|
+
if (path.includes("//"))
|
|
2036
|
+
return { ok: false, error: invalidPath("has an empty segment (//)") };
|
|
2037
|
+
const segments = path.split("/");
|
|
2038
|
+
if (segments.some((segment) => segment === "." || segment === ".."))
|
|
2039
|
+
return { ok: false, error: invalidPath("has a . or .. segment") };
|
|
2040
|
+
return { ok: true, encoded: segments.map(encodeURIComponent).join("/") };
|
|
2041
|
+
}
|
|
2042
|
+
function downloadQuery(download) {
|
|
2043
|
+
if (download === true) return { ok: true, encoded: "?download" };
|
|
2044
|
+
if (typeof download !== "string") return { ok: true, encoded: "" };
|
|
2045
|
+
if (LONE_SURROGATE.test(download))
|
|
2046
|
+
return {
|
|
2047
|
+
ok: false,
|
|
2048
|
+
error: {
|
|
2049
|
+
code: "INVALID_REQUEST",
|
|
2050
|
+
message: STORAGE_MESSAGES.request,
|
|
2051
|
+
details: { path: "download", reason: DOWNLOAD_NAME_REASON }
|
|
2052
|
+
}
|
|
2053
|
+
};
|
|
2054
|
+
return { ok: true, encoded: `?download=${encodeURIComponent(download)}` };
|
|
2055
|
+
}
|
|
2056
|
+
function encodeBucket(bucket) {
|
|
2057
|
+
if (typeof bucket !== "string" || bucket === "" || bucket === "." || bucket === ".." || LONE_SURROGATE.test(bucket) || RESERVED_BUCKETS.has(bucket))
|
|
2058
|
+
return {
|
|
2059
|
+
ok: false,
|
|
2060
|
+
error: { code: "INVALID_PATH", message: STORAGE_MESSAGES.bucket }
|
|
2061
|
+
};
|
|
2062
|
+
return { ok: true, encoded: encodeURIComponent(bucket) };
|
|
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
|
+
}
|
|
2074
|
+
var isArrayBuffer = (value) => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
|
|
2075
|
+
function isDetached(buffer) {
|
|
2076
|
+
if (!isArrayBuffer(buffer)) return false;
|
|
2077
|
+
const flag = buffer.detached;
|
|
2078
|
+
if (typeof flag === "boolean") return flag;
|
|
2079
|
+
if (buffer.byteLength > 0) return false;
|
|
2080
|
+
try {
|
|
2081
|
+
buffer.slice(0);
|
|
2082
|
+
return false;
|
|
2083
|
+
} catch {
|
|
2084
|
+
return true;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
var CACHE_CONTROL_REASON = "must be max-age=<seconds> from 0 to 31536000";
|
|
2088
|
+
var MAX_TYPE_IN_MESSAGE = 256;
|
|
2089
|
+
function unsendable(value) {
|
|
2090
|
+
return /[\0\r\n]|[^\0-\xff]/.test(
|
|
2091
|
+
value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "")
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
function headerProblem(contentType, cacheControl) {
|
|
2095
|
+
if (typeof contentType === "string" && unsendable(contentType)) {
|
|
2096
|
+
let shown = contentType;
|
|
2097
|
+
if (shown.length > MAX_TYPE_IN_MESSAGE) {
|
|
2098
|
+
shown = shown.slice(0, MAX_TYPE_IN_MESSAGE);
|
|
2099
|
+
if (/[\ud800-\udbff]$/.test(shown)) shown = shown.slice(0, -1);
|
|
2100
|
+
}
|
|
2101
|
+
return {
|
|
2102
|
+
code: "INVALID_CONTENT_TYPE",
|
|
2103
|
+
message: `Content-Type ${shown} is not a media type like image/png.`
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
if (unsendable(cacheControl))
|
|
2107
|
+
return {
|
|
2108
|
+
code: "INVALID_REQUEST",
|
|
2109
|
+
message: STORAGE_MESSAGES.request,
|
|
2110
|
+
details: { path: "cache-control", reason: CACHE_CONTROL_REASON }
|
|
2111
|
+
};
|
|
2112
|
+
return null;
|
|
2113
|
+
}
|
|
2114
|
+
function payloadOf(body, contentType) {
|
|
2115
|
+
if (typeof body === "string") {
|
|
2116
|
+
const bytes = new TextEncoder().encode(body);
|
|
2117
|
+
return {
|
|
2118
|
+
ok: true,
|
|
2119
|
+
body: bytes,
|
|
2120
|
+
size: bytes.byteLength,
|
|
2121
|
+
type: contentType ?? UTF8_TEXT
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
if (typeof Blob === "function" && body instanceof Blob)
|
|
2125
|
+
return {
|
|
2126
|
+
ok: true,
|
|
2127
|
+
body,
|
|
2128
|
+
size: body.size,
|
|
2129
|
+
type: contentType ?? (body.type === "" ? OCTET_STREAM : body.type)
|
|
2130
|
+
};
|
|
2131
|
+
if (isArrayBuffer(body) && !isDetached(body))
|
|
2132
|
+
return {
|
|
2133
|
+
ok: true,
|
|
2134
|
+
body,
|
|
2135
|
+
size: body.byteLength,
|
|
2136
|
+
type: contentType ?? OCTET_STREAM
|
|
2137
|
+
};
|
|
2138
|
+
if (ArrayBuffer.isView(body) && !isDetached(body.buffer)) {
|
|
2139
|
+
const { buffer, byteOffset, byteLength } = body;
|
|
2140
|
+
const bytes = isArrayBuffer(buffer) ? new Uint8Array(buffer, byteOffset, byteLength) : Uint8Array.from(new Uint8Array(buffer, byteOffset, byteLength));
|
|
2141
|
+
return {
|
|
2142
|
+
ok: true,
|
|
2143
|
+
body: bytes,
|
|
2144
|
+
size: byteLength,
|
|
2145
|
+
type: contentType ?? OCTET_STREAM
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
return {
|
|
2149
|
+
ok: false,
|
|
2150
|
+
error: { code: "INVALID_BODY", message: STORAGE_MESSAGES.body }
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// src/storage/client.ts
|
|
2155
|
+
var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
|
|
2156
|
+
var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
|
|
2157
|
+
var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
|
|
2158
|
+
var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
|
|
2159
|
+
function listOf(guard) {
|
|
2160
|
+
return (value) => Array.isArray(value) && value.every(guard);
|
|
2161
|
+
}
|
|
2162
|
+
var StorageFileApi = class {
|
|
2163
|
+
#http;
|
|
2164
|
+
#auth;
|
|
2165
|
+
#bucket;
|
|
2166
|
+
constructor(http, auth, bucket) {
|
|
2167
|
+
this.#http = http;
|
|
2168
|
+
this.#auth = auth;
|
|
2169
|
+
this.#bucket = bucket;
|
|
2170
|
+
}
|
|
2171
|
+
/** `/storage/object/<route><bucket>/<path>`, or the refusal no request needs. */
|
|
2172
|
+
#objectPath(route, path) {
|
|
2173
|
+
const target = objectRoute(this.#bucket, path);
|
|
2174
|
+
if (!target.ok) return target;
|
|
2175
|
+
return { ok: true, path: `/storage/object/${route}${target.encoded}` };
|
|
2176
|
+
}
|
|
2177
|
+
/** `/storage/object/<route><bucket>`, for the routes that take names in the body. */
|
|
2178
|
+
#bucketPath(route) {
|
|
2179
|
+
const bucket = encodeBucket(this.#bucket);
|
|
2180
|
+
if (!bucket.ok) return bucket;
|
|
2181
|
+
return { ok: true, path: `/storage/object/${route}${bucket.encoded}` };
|
|
2182
|
+
}
|
|
2183
|
+
async #json(request, guard) {
|
|
2184
|
+
await this.#auth.ready();
|
|
2185
|
+
const result = await call(this.#http, { ...request, withToken: true });
|
|
2186
|
+
if (result.error !== null) return fail(result.error);
|
|
2187
|
+
return dataOf2(result.answer.status, result.answer.body, guard);
|
|
2188
|
+
}
|
|
2189
|
+
async #write(method, path, body, options, upsert) {
|
|
2190
|
+
const target = this.#objectPath("", path);
|
|
2191
|
+
if (!target.ok) return fail(target.error);
|
|
2192
|
+
const payload = payloadOf(body, options.contentType);
|
|
2193
|
+
if (!payload.ok) return fail(payload.error);
|
|
2194
|
+
const cache = `max-age=${options.cacheControl ?? "3600"}`;
|
|
2195
|
+
const unreadable = headerProblem(options.contentType, cache);
|
|
2196
|
+
if (unreadable !== null) return fail(unreadable);
|
|
2197
|
+
const { signal } = options;
|
|
2198
|
+
if (signal?.aborted) return fail(uploadAborted());
|
|
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
|
+
});
|
|
2215
|
+
const headers = {
|
|
2216
|
+
"content-type": payload.type,
|
|
2217
|
+
"cache-control": cache
|
|
2218
|
+
};
|
|
2219
|
+
if (upsert) headers["x-upsert"] = "true";
|
|
2220
|
+
const sent = await send(this.#http, {
|
|
2221
|
+
method,
|
|
2222
|
+
path: target.path,
|
|
2223
|
+
headers,
|
|
2224
|
+
body: payload.body,
|
|
2225
|
+
withToken: true,
|
|
2226
|
+
// The fetch stops with the body: the worker undoes its claim at once.
|
|
2227
|
+
signal
|
|
2228
|
+
});
|
|
2229
|
+
if (sent.error !== null) return fail(sent.error);
|
|
2230
|
+
return readJson(sent.response, isUploadResult);
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* A new object; with `upsert: true`, one of the same name is replaced.
|
|
2234
|
+
* `null` options, as a JS caller may pass, are no options.
|
|
2235
|
+
*/
|
|
2236
|
+
upload(path, body, options = {}) {
|
|
2237
|
+
const given = options ?? {};
|
|
2238
|
+
return this.#write("POST", path, body, given, given.upsert === true);
|
|
2239
|
+
}
|
|
2240
|
+
/** Replaces an object that is there (`OBJECT_NOT_FOUND` otherwise). */
|
|
2241
|
+
update(path, body, options = {}) {
|
|
2242
|
+
return this.#write("PUT", path, body, options ?? {}, false);
|
|
2243
|
+
}
|
|
2244
|
+
/**
|
|
2245
|
+
* The object's bytes, as a Blob of the type the worker serves it under:
|
|
2246
|
+
* the stored type for the images, audio, video, PDF, plain text, CSV,
|
|
2247
|
+
* JSON and SVG it serves as themselves, `application/octet-stream` for
|
|
2248
|
+
* anything else (HTML, scripts, archives, unknown types — `info` and
|
|
2249
|
+
* `list` hold the stored type).
|
|
2250
|
+
*/
|
|
2251
|
+
async download(path) {
|
|
2252
|
+
const target = this.#objectPath("", path);
|
|
2253
|
+
if (!target.ok) return fail(target.error);
|
|
2254
|
+
await this.#auth.ready();
|
|
2255
|
+
const sent = await send(this.#http, {
|
|
2256
|
+
method: "GET",
|
|
2257
|
+
path: target.path,
|
|
2258
|
+
withToken: true
|
|
2259
|
+
});
|
|
2260
|
+
if (sent.error !== null) return fail(sent.error);
|
|
2261
|
+
try {
|
|
2262
|
+
return { data: await sent.response.blob(), error: null };
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
return fail(networkError(error));
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
async info(path) {
|
|
2268
|
+
const target = this.#objectPath("info/", path);
|
|
2269
|
+
if (!target.ok) return fail(target.error);
|
|
2270
|
+
return this.#json({ method: "GET", path: target.path }, isFileObject);
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* Whether the caller can see the object: `OBJECT_NOT_FOUND` is false,
|
|
2274
|
+
* every other error comes back as it is (plan C1 — asked with `info`,
|
|
2275
|
+
* not HEAD, whose answer has no body to tell a missing bucket from a
|
|
2276
|
+
* missing object).
|
|
2277
|
+
*/
|
|
2278
|
+
async exists(path) {
|
|
2279
|
+
const found = await this.info(path);
|
|
2280
|
+
if (found.error === null) return { data: true, error: null };
|
|
2281
|
+
if (found.error.code === "OBJECT_NOT_FOUND")
|
|
2282
|
+
return { data: false, error: null };
|
|
2283
|
+
return fail(found.error);
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* What is directly below `prefix` (the whole bucket by default): folders
|
|
2287
|
+
* first, then files. The options go to the worker as they are given.
|
|
2288
|
+
*/
|
|
2289
|
+
async list(prefix, options = {}) {
|
|
2290
|
+
const target = this.#bucketPath("list/");
|
|
2291
|
+
if (!target.ok) return fail(target.error);
|
|
2292
|
+
return this.#json(
|
|
2293
|
+
{
|
|
2294
|
+
method: "POST",
|
|
2295
|
+
path: target.path,
|
|
2296
|
+
body: { ...prefix === void 0 ? {} : { prefix }, ...options }
|
|
2297
|
+
},
|
|
2298
|
+
listOf(isEntry2)
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
/** Removes the objects named — 1 to 1,000 — and answers the ones it removed. */
|
|
2302
|
+
async remove(paths) {
|
|
2303
|
+
const target = this.#bucketPath("");
|
|
2304
|
+
if (!target.ok) return fail(target.error);
|
|
2305
|
+
return this.#json(
|
|
2306
|
+
{ method: "DELETE", path: target.path, body: { prefixes: paths } },
|
|
2307
|
+
listOf(isFileObject)
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
2310
|
+
/**
|
|
2311
|
+
* A link to the object for `expiresIn` seconds (1–604,800). It stops
|
|
2312
|
+
* working once the object is replaced or removed.
|
|
2313
|
+
*/
|
|
2314
|
+
async createSignedUrl(path, expiresIn, options = {}) {
|
|
2315
|
+
const target = this.#objectPath("sign/", path);
|
|
2316
|
+
if (!target.ok) return fail(target.error);
|
|
2317
|
+
const { download } = options ?? {};
|
|
2318
|
+
const signed = await this.#json(
|
|
2319
|
+
{
|
|
2320
|
+
method: "POST",
|
|
2321
|
+
path: target.path,
|
|
2322
|
+
body: {
|
|
2323
|
+
expiresIn,
|
|
2324
|
+
...download === void 0 || download === false ? {} : { download }
|
|
2325
|
+
}
|
|
2326
|
+
},
|
|
2327
|
+
isSigned
|
|
2328
|
+
);
|
|
2329
|
+
if (signed.error !== null) return signed;
|
|
2330
|
+
return {
|
|
2331
|
+
data: { signedUrl: `${this.#http.baseUrl}${signed.data.signedUrl}` },
|
|
2332
|
+
error: null
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
/**
|
|
2336
|
+
* A public bucket's URL for the object. No request is made, so whether
|
|
2337
|
+
* the bucket is public is not checked (as in supabase-js); a path no URL
|
|
2338
|
+
* can carry is INVALID_PATH (plan C3), and a download name none can carry
|
|
2339
|
+
* is the worker's INVALID_REQUEST for it.
|
|
2340
|
+
*/
|
|
2341
|
+
getPublicUrl(path, options = {}) {
|
|
2342
|
+
const target = this.#objectPath("public/", path);
|
|
2343
|
+
if (!target.ok) return fail(target.error);
|
|
2344
|
+
const { download } = options ?? {};
|
|
2345
|
+
const query = downloadQuery(download);
|
|
2346
|
+
if (!query.ok) return fail(query.error);
|
|
2347
|
+
return {
|
|
2348
|
+
data: {
|
|
2349
|
+
publicUrl: `${this.#http.baseUrl}/_naro/v1${target.path}${query.encoded}`
|
|
2350
|
+
},
|
|
2351
|
+
error: null
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
var StorageClient = class {
|
|
2356
|
+
#http;
|
|
2357
|
+
#auth;
|
|
2358
|
+
constructor(http, auth) {
|
|
2359
|
+
this.#http = http;
|
|
2360
|
+
this.#auth = auth;
|
|
2361
|
+
}
|
|
2362
|
+
/** The objects of one bucket. */
|
|
2363
|
+
from(bucket) {
|
|
2364
|
+
return new StorageFileApi(this.#http, this.#auth, bucket);
|
|
2365
|
+
}
|
|
2366
|
+
};
|
|
2367
|
+
|
|
1759
2368
|
// src/client.ts
|
|
1760
2369
|
var CONFIG_MESSAGES = {
|
|
1761
2370
|
url: "createClient needs the project url, like https://my-app.naro.sh.",
|
|
@@ -1815,9 +2424,13 @@ var NaroClient = class {
|
|
|
1815
2424
|
* service key, `auth.admin` manages users.
|
|
1816
2425
|
*/
|
|
1817
2426
|
auth;
|
|
2427
|
+
/**
|
|
2428
|
+
* Files in the project's Naro Storage (first-release design 2026-10-06
|
|
2429
|
+
* §8.1): `storage.from(bucket)` uploads, downloads, lists and removes
|
|
2430
|
+
* them, as supabase-js does.
|
|
2431
|
+
*/
|
|
2432
|
+
storage;
|
|
1818
2433
|
#run;
|
|
1819
|
-
// naro.storage — Phase 3 (spec §18): R2 storage lands here, beside `auth`
|
|
1820
|
-
// and `from()`. Deliberately absent in Phase 1: not implemented, not typed.
|
|
1821
2434
|
constructor(options) {
|
|
1822
2435
|
const baseUrl = baseUrlOf(options.url);
|
|
1823
2436
|
const { key: apiKey, service } = apiKeyOf(options);
|
|
@@ -1842,6 +2455,7 @@ var NaroClient = class {
|
|
|
1842
2455
|
serviceKey: service
|
|
1843
2456
|
});
|
|
1844
2457
|
this.#run = queryRunner(http, this.auth);
|
|
2458
|
+
this.storage = new StorageClient(http, this.auth);
|
|
1845
2459
|
}
|
|
1846
2460
|
/** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
|
|
1847
2461
|
from(table) {
|
|
@@ -1854,5 +2468,6 @@ function createClient(options) {
|
|
|
1854
2468
|
export {
|
|
1855
2469
|
NaroClient,
|
|
1856
2470
|
createClient,
|
|
2471
|
+
objectRoute,
|
|
1857
2472
|
quoteFilterValue
|
|
1858
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,65 @@
|
|
|
1
|
+
import type { AuthClient } from "../auth/client.js";
|
|
2
|
+
import { type HttpConfig } from "../http.js";
|
|
3
|
+
import type { NaroResponse } from "../types.js";
|
|
4
|
+
import type { DownloadOption, FileObject, FolderEntry, ListOptions, UpdateOptions, UploadBody, UploadOptions, UploadResult } from "./types.js";
|
|
5
|
+
/** One bucket's objects: supabase-js's StorageFileApi. */
|
|
6
|
+
export declare class StorageFileApi {
|
|
7
|
+
#private;
|
|
8
|
+
constructor(http: HttpConfig, auth: AuthClient, bucket: string);
|
|
9
|
+
/**
|
|
10
|
+
* A new object; with `upsert: true`, one of the same name is replaced.
|
|
11
|
+
* `null` options, as a JS caller may pass, are no options.
|
|
12
|
+
*/
|
|
13
|
+
upload(path: string, body: UploadBody, options?: UploadOptions): Promise<NaroResponse<UploadResult>>;
|
|
14
|
+
/** Replaces an object that is there (`OBJECT_NOT_FOUND` otherwise). */
|
|
15
|
+
update(path: string, body: UploadBody, options?: UpdateOptions): Promise<NaroResponse<UploadResult>>;
|
|
16
|
+
/**
|
|
17
|
+
* The object's bytes, as a Blob of the type the worker serves it under:
|
|
18
|
+
* the stored type for the images, audio, video, PDF, plain text, CSV,
|
|
19
|
+
* JSON and SVG it serves as themselves, `application/octet-stream` for
|
|
20
|
+
* anything else (HTML, scripts, archives, unknown types — `info` and
|
|
21
|
+
* `list` hold the stored type).
|
|
22
|
+
*/
|
|
23
|
+
download(path: string): Promise<NaroResponse<Blob>>;
|
|
24
|
+
info(path: string): Promise<NaroResponse<FileObject>>;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the caller can see the object: `OBJECT_NOT_FOUND` is false,
|
|
27
|
+
* every other error comes back as it is (plan C1 — asked with `info`,
|
|
28
|
+
* not HEAD, whose answer has no body to tell a missing bucket from a
|
|
29
|
+
* missing object).
|
|
30
|
+
*/
|
|
31
|
+
exists(path: string): Promise<NaroResponse<boolean>>;
|
|
32
|
+
/**
|
|
33
|
+
* What is directly below `prefix` (the whole bucket by default): folders
|
|
34
|
+
* first, then files. The options go to the worker as they are given.
|
|
35
|
+
*/
|
|
36
|
+
list(prefix?: string, options?: ListOptions): Promise<NaroResponse<(FileObject | FolderEntry)[]>>;
|
|
37
|
+
/** Removes the objects named — 1 to 1,000 — and answers the ones it removed. */
|
|
38
|
+
remove(paths: string[]): Promise<NaroResponse<FileObject[]>>;
|
|
39
|
+
/**
|
|
40
|
+
* A link to the object for `expiresIn` seconds (1–604,800). It stops
|
|
41
|
+
* working once the object is replaced or removed.
|
|
42
|
+
*/
|
|
43
|
+
createSignedUrl(path: string, expiresIn: number, options?: DownloadOption): Promise<NaroResponse<{
|
|
44
|
+
signedUrl: string;
|
|
45
|
+
}>>;
|
|
46
|
+
/**
|
|
47
|
+
* A public bucket's URL for the object. No request is made, so whether
|
|
48
|
+
* the bucket is public is not checked (as in supabase-js); a path no URL
|
|
49
|
+
* can carry is INVALID_PATH (plan C3), and a download name none can carry
|
|
50
|
+
* is the worker's INVALID_REQUEST for it.
|
|
51
|
+
*/
|
|
52
|
+
getPublicUrl(path: string, options?: DownloadOption): NaroResponse<{
|
|
53
|
+
publicUrl: string;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `naro.storage`. Buckets are made with `naro storage bucket create` or the
|
|
58
|
+
* MCP server, never from a client (original spec §6).
|
|
59
|
+
*/
|
|
60
|
+
export declare class StorageClient {
|
|
61
|
+
#private;
|
|
62
|
+
constructor(http: HttpConfig, auth: AuthClient);
|
|
63
|
+
/** The objects of one bucket. */
|
|
64
|
+
from(bucket: string): StorageFileApi;
|
|
65
|
+
}
|
|
@@ -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>>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { NaroError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The most one upload request carries (original spec §9.1): 50 MiB.
|
|
4
|
+
* data-core's MAX_SINGLE_UPLOAD_BYTES — storage/wire.test.ts holds them
|
|
5
|
+
* equal. Over it, the edge cuts the body off and the browser often cannot
|
|
6
|
+
* read the answer: the SDK sends a larger body in parts (Storage 3-1f).
|
|
7
|
+
*/
|
|
8
|
+
export declare const MAX_SINGLE_UPLOAD_BYTES = 52428800;
|
|
9
|
+
/** The worker's words (data-core's catalog) for what the SDK refuses itself. */
|
|
10
|
+
export declare const STORAGE_MESSAGES: {
|
|
11
|
+
readonly bucket: "The bucket name is not valid.";
|
|
12
|
+
readonly request: "The storage request is not valid.";
|
|
13
|
+
readonly body: "upload takes a Blob, File, ArrayBuffer, typed array or string.";
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The words the worker's routes take as the first segment after `/object/`
|
|
17
|
+
* (data-core's STORAGE_RESERVED_WORDS): as a bucket, the URL would name
|
|
18
|
+
* another route. No bucket can have one of these names.
|
|
19
|
+
*/
|
|
20
|
+
export declare const RESERVED_BUCKETS: ReadonlySet<string>;
|
|
21
|
+
/** Why the worker refuses a `download` name (serve.ts's DOWNLOAD_NAME_REASON). */
|
|
22
|
+
export declare const DOWNLOAD_NAME_REASON = "must be a file name of up to 255 bytes";
|
|
23
|
+
export type Encoded = {
|
|
24
|
+
ok: true;
|
|
25
|
+
encoded: string;
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
error: NaroError;
|
|
29
|
+
};
|
|
30
|
+
export declare function invalidPath(reason: string): NaroError;
|
|
31
|
+
/**
|
|
32
|
+
* An object path as a URL carries it, each segment encoded once and `/`
|
|
33
|
+
* kept as the separator — or the worker's INVALID_PATH for the paths no
|
|
34
|
+
* URL can carry as they are (original spec §13, plan C2): an empty name, a
|
|
35
|
+
* `/` at either end, `//`, a `.` or `..` segment (WHATWG URL parsing folds
|
|
36
|
+
* `..` and `%2E%2E` alike, so the worker would get another path), and a
|
|
37
|
+
* lone surrogate (`encodeURIComponent` throws on one). Every other rule is
|
|
38
|
+
* the worker's alone — one set of rules, in one place.
|
|
39
|
+
*/
|
|
40
|
+
export declare function encodePath(path: string): Encoded;
|
|
41
|
+
/**
|
|
42
|
+
* `?download` for the object's own name, `?download=<name>` for another, or
|
|
43
|
+
* the worker's own INVALID_REQUEST for a name `encodeURIComponent` throws on
|
|
44
|
+
* (a lone surrogate: the sign route refuses the same name in its body). Every
|
|
45
|
+
* other rule on the name is the worker's alone.
|
|
46
|
+
*/
|
|
47
|
+
export declare function downloadQuery(download: boolean | string | undefined): Encoded;
|
|
48
|
+
/**
|
|
49
|
+
* A bucket name as the URL's segment — encoded (a valid name is unchanged
|
|
50
|
+
* by it), or INVALID_PATH for the names that would not reach a bucket:
|
|
51
|
+
* empty, `.`, `..`, a lone surrogate and the routes' own words.
|
|
52
|
+
*/
|
|
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;
|
|
67
|
+
/** Why the worker refuses a cache-control it cannot read (its upload.ts). */
|
|
68
|
+
export declare const CACHE_CONTROL_REASON = "must be max-age=<seconds> from 0 to 31536000";
|
|
69
|
+
/**
|
|
70
|
+
* The upload's `contentType` and `cacheControl` as headers, or the worker's
|
|
71
|
+
* answer for a value it cannot read when no header can carry it (PR #90
|
|
72
|
+
* review 1): INVALID_CONTENT_TYPE with data-core's words (the type cut at
|
|
73
|
+
* 256 UTF-16 units, as `storageName` cuts it), and INVALID_REQUEST
|
|
74
|
+
* `cache-control`. Every other value is the worker's to judge.
|
|
75
|
+
*/
|
|
76
|
+
export declare function headerProblem(contentType: string | undefined, cacheControl: string): NaroError | null;
|
|
77
|
+
export type Payload = {
|
|
78
|
+
ok: true;
|
|
79
|
+
body: Blob | ArrayBuffer | Uint8Array<ArrayBuffer>;
|
|
80
|
+
size: number;
|
|
81
|
+
type: string;
|
|
82
|
+
} | {
|
|
83
|
+
ok: false;
|
|
84
|
+
error: NaroError;
|
|
85
|
+
};
|
|
86
|
+
/** The upload's body, its bytes and its type — or INVALID_BODY. */
|
|
87
|
+
export declare function payloadOf(body: unknown, contentType: string | undefined): Payload;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** An object as the worker answers it. */
|
|
2
|
+
export type FileObject = {
|
|
3
|
+
id: string;
|
|
4
|
+
/** The full path from `info` and `remove`; the name below the prefix in `list`. */
|
|
5
|
+
name: string;
|
|
6
|
+
bucket_id: string;
|
|
7
|
+
/** Who uploaded it; null for the anon or the service key. */
|
|
8
|
+
owner: string | null;
|
|
9
|
+
owner_id: string | null;
|
|
10
|
+
/** The bytes' etag. */
|
|
11
|
+
version: string;
|
|
12
|
+
created_at: string;
|
|
13
|
+
updated_at: string;
|
|
14
|
+
/** `eTag`, `size`, `mimetype`, `cacheControl`, `lastModified`, `contentLength`, `httpStatusCode`. */
|
|
15
|
+
metadata: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
/** A folder in a listing: a name and nothing else. Folders come first. */
|
|
18
|
+
export type FolderEntry = {
|
|
19
|
+
name: string;
|
|
20
|
+
id: null;
|
|
21
|
+
created_at: null;
|
|
22
|
+
updated_at: null;
|
|
23
|
+
metadata: null;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* What an upload sends: bytes whose length `fetch` knows, so it sets
|
|
27
|
+
* `Content-Length`. A string goes as UTF-8. Not a stream, not `FormData`.
|
|
28
|
+
*/
|
|
29
|
+
export type UploadBody = Blob | ArrayBuffer | ArrayBufferView | string;
|
|
30
|
+
export type UploadOptions = {
|
|
31
|
+
/** Default: the Blob's type, `text/plain;charset=utf-8` for a string, else `application/octet-stream`. */
|
|
32
|
+
contentType?: string;
|
|
33
|
+
/** Seconds a browser may keep it, as text. Default `"3600"`. */
|
|
34
|
+
cacheControl?: string;
|
|
35
|
+
/** Replace an object of the same name. Default false. */
|
|
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;
|
|
56
|
+
};
|
|
57
|
+
export type UpdateOptions = Omit<UploadOptions, "upsert">;
|
|
58
|
+
export type UploadResult = {
|
|
59
|
+
id: string;
|
|
60
|
+
path: string;
|
|
61
|
+
fullPath: string;
|
|
62
|
+
};
|
|
63
|
+
export type ListOptions = {
|
|
64
|
+
/** 1–1,000; 100 by default. */
|
|
65
|
+
limit?: number;
|
|
66
|
+
/** 0–100,000. */
|
|
67
|
+
offset?: number;
|
|
68
|
+
/** Folders come first whatever the sort. Default name ascending. */
|
|
69
|
+
sortBy?: {
|
|
70
|
+
column?: "name" | "created_at" | "updated_at";
|
|
71
|
+
order?: "asc" | "desc";
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/** `true` downloads under the object's own name, a string under that name. */
|
|
75
|
+
export type DownloadOption = {
|
|
76
|
+
download?: boolean | string;
|
|
77
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "naro-js",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Auth and
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Auth, data and files for your Naro project, from the browser or the server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"files": [
|