naro-js 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -483,20 +483,129 @@ await avatars.remove([`${user.id}/old.png`]);
483
483
  const { data: link } = await naro.storage
484
484
  .from("docs")
485
485
  .createSignedUrl("team-1/plan.pdf", 600);
486
+ const { data: links } = await naro.storage
487
+ .from("docs")
488
+ .createSignedUrls(["team-1/a.pdf", "team-1/b.pdf"], 600);
489
+ const { data: plans } = await naro.storage
490
+ .from("docs")
491
+ .list("team-1", { search: "plan" });
486
492
  ```
487
493
 
488
494
  - `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 }`.
495
+ `createSignedUrl`, `createSignedUrls` and `getPublicUrl` take
496
+ supabase-js's arguments and answer its shapes (`FileObject` in
497
+ snake_case), as `{ data, error }`.
491
498
  One exception: supabase-js's `info` answers a camelCase object
492
499
  (`bucketId`, `createdAt`, `size`, `contentType`, …); naro-js's `info`
493
500
  answers the same snake_case `FileObject` as `list`, with the size and type
494
501
  in `metadata.size` and `metadata.mimetype`.
495
- - An upload sends the file's own bytes, 50 MiB at most, typed as the file
496
- is (`Blob.type`, or `application/octet-stream`; a string is
497
- `text/plain;charset=utf-8`). The body is a `Blob`, `File`, `ArrayBuffer`,
498
- typed array or string — not a stream or `FormData`, and not a buffer
499
- transferred away (detached), which has no bytes left: `INVALID_BODY`.
502
+ - `createSignedUrls(paths, expiresIn, { download })` signs 1 to 100 paths in
503
+ one request (naro-js 0.5.0) and answers one `{ path, signedUrl, error }`
504
+ per path, in the order given — a path given twice is answered twice. A
505
+ file that is not there, or that the caller cannot read, has
506
+ `signedUrl: null` and the `error`
507
+ `Object <path> was not found in bucket <bucket>.` — the same words for
508
+ both. `signedUrl` is absolute, as `createSignedUrl`'s. The paths go in the
509
+ body unchecked: a bad one fails the call with `INVALID_REQUEST`
510
+ (`details.path` `paths[<i>]`). Unlike supabase-js, `download` goes in the
511
+ request — the token covers it — so adding `&download` to a URL afterwards
512
+ answers `400 INVALID_SIGNATURE`.
513
+ - `list(prefix, { search })` keeps the names directly below `prefix` that
514
+ start with `search`. As in Supabase, `%` and `_` are plain characters, not
515
+ wildcards. Unlike Supabase, only ASCII letters ignore case (Postgres folds
516
+ case by the database's locale) and `search` takes up to 256 characters. A
517
+ folder shows when a name under it starts with `search`.
518
+ - A backend whose Storage was turned on before these two came answers
519
+ `createSignedUrls` with `404 NOT_FOUND` and a `search` with
520
+ `400 INVALID_REQUEST` (`details.path` `search`, `details.reason`
521
+ `search is not supported yet`): update it once with `naro backend enable`.
522
+ - A public file is kept at Naro's edge for up to 5 minutes (or for its own
523
+ `cacheControl`, when that is shorter): after it is deleted, its bucket is
524
+ made private or it is replaced, `getPublicUrl`'s URL can still serve the
525
+ old bytes that long. Put a version in names that change
526
+ (`avatar-<time>.png`) or keep `cacheControl` short. Signed URLs are not
527
+ kept at the edge: replacing or deleting a file ends its signed links at
528
+ once.
529
+ - An upload sends the file's own bytes, typed as the file is (`Blob.type`,
530
+ or `application/octet-stream`; a string is `text/plain;charset=utf-8`).
531
+ The body is a `Blob`, `File`, `ArrayBuffer`, typed array or string — not
532
+ a stream or `FormData`, and not a buffer transferred away (detached),
533
+ which has no bytes left: `INVALID_BODY`.
534
+ - One request carries 50 MiB. A larger body goes in parts by itself, up to
535
+ the plan's largest object: 16–64 MiB parts, four at a time, each tried
536
+ again up to three times after `NETWORK_ERROR`, a `5xx` or
537
+ `OBJECT_RATE_LIMITED` (waiting 1, 2, then 4 seconds, or the seconds the
538
+ 429 says) — not after `507 STORAGE_QUOTA_EXCEEDED`, which lasts until
539
+ files are deleted. A part that still fails fails the upload with its
540
+ error, and the upload is called off. The last step is never tried again: if its
541
+ answer is lost (`NETWORK_ERROR`), ask `info(path)` whether the file is
542
+ there; if the backend refuses it, the upload is called off too and the
543
+ refusal is the answer. A part answered `UPLOAD_NOT_FOUND` belongs to an
544
+ upload that has ended — one left unsent for about an hour has. An upload in
545
+ parts must finish within 24 hours of its start (R2 ends unfinished
546
+ multipart uploads after a day). Replacing a file in parts (`update`, or
547
+ `upload` with `upsert` over a file that is there) needs room in the
548
+ project's capacity for the old file and the new one until it completes: if
549
+ the two together are over it, the upload answers
550
+ `507 STORAGE_QUOTA_EXCEEDED` before any part is sent.
551
+ - `onProgress: (sentBytes, totalBytes) => void` is called after each part
552
+ of an upload over 50 MiB, the last time with both the same. An upload of
553
+ 50 MiB or less is one request and does not call it. A `File` is sliced
554
+ into the parts, never read whole first. What the callback throws — or an
555
+ `async` one rejects with — does not stop the upload.
556
+
557
+ - `signal: AbortSignal` stops an upload (`upload` and `update`): what has
558
+ not been answered yet is called off, and the upload answers `ABORTED`
559
+ ("The upload was stopped before it finished."). An upload in parts is
560
+ called off on the backend too — no part starts after the signal, and
561
+ `onProgress` is not called again — so its name and the space it reserved
562
+ are free at once, not after the hour its lock lasts. Its first step is
563
+ never cut short (a signal during it is acted on once it answers), and once
564
+ its last step is sent the signal stops nothing: that step's answer is the
565
+ upload's. A signal already aborted sends nothing. A page closed or a
566
+ network lost mid-upload sends no abort: the name waits for the lock.
567
+
568
+ ```ts
569
+ const stop = new AbortController();
570
+ cancelButton.onclick = () => stop.abort();
571
+ await naro.storage.from("videos").upload(`${user.id}/talk.mp4`, file, {
572
+ onProgress: (sent, total) => setPercent(Math.round((sent / total) * 100)),
573
+ signal: stop.signal,
574
+ });
575
+ ```
576
+
577
+ - `createSignedUploadUrl(path, { upsert })` and
578
+ `uploadToSignedUrl(path, token, body, options)` are supabase-js's signed
579
+ upload (naro-js 0.5.0). A server with the service key — or anyone the
580
+ policies let upload there, anon or a signed-in user — makes a token for
581
+ one path; a page with only the anon key uploads by it. The token stands
582
+ in for the policies; the bucket's and the plan's limits and the
583
+ project's usage stops still apply. It works for 2 hours. Made with
584
+ `upsert: true` it may replace the file until then; without it a second
585
+ upload answers `409 OBJECT_EXISTS`.
586
+ `uploadToSignedUrl` sends no session, takes `contentType`,
587
+ `cacheControl`, `onProgress` and `signal` as `upload` does, and answers
588
+ `{ path, fullPath }`. Over 50 MiB it goes in parts with the token on
589
+ every step (the `x-naro-upload-token` header), and once started may
590
+ finish after the 2 hours. A token that is not the path's answers
591
+ `400 INVALID_SIGNATURE` (one no URL can carry is refused so before a
592
+ request), an expired one `400 SIGNED_URL_EXPIRED`. A token is neither
593
+ used up nor revocable: until its 2 hours end it can create its file again
594
+ after the file is deleted, and it keeps working if the user it was made
595
+ for is deleted or banned.
596
+
597
+ ```ts
598
+ // On the server, with the service key:
599
+ const { data, error } = await admin.storage
600
+ .from("avatars")
601
+ .createSignedUploadUrl(`${userId}/me.png`, { upsert: true });
602
+ if (error) throw new Error(error.message);
603
+ // In the browser, with the anon key:
604
+ await naro.storage
605
+ .from("avatars")
606
+ .uploadToSignedUrl(data.path, data.token, file);
607
+ ```
608
+
500
609
  - A `contentType` or `cacheControl` no header can carry (a CR, LF or NUL
501
610
  inside, or a character past U+00FF) is answered before a request, as the
502
611
  backend answers one it cannot read: `INVALID_CONTENT_TYPE`, and
@@ -512,7 +621,14 @@ const { data: link } = await naro.storage
512
621
  check.
513
622
  - `exists` answers `false` for `OBJECT_NOT_FOUND` and passes every other
514
623
  error on. `getPublicUrl` makes no request and answers `{ data, error }`.
624
+ - `objectRoute(bucket, path)` names an object the way the client does:
625
+ `{ ok: true, encoded }` with `<bucket>/<path>` as the object routes carry
626
+ it (each segment `encodeURIComponent`-ed), or `{ ok: false, error }` with
627
+ the client's own `INVALID_PATH` (type `ObjectRouteResult`). A tool that
628
+ streams an object itself builds `/_naro/v1/storage/object/<encoded>` from
629
+ it and gets the URL and the refusals the client would — `naro storage cp`
630
+ downloads that way.
515
631
  - `OBJECT_RATE_LIMITED` and `STORAGE_OPS_QUOTA_EXCEEDED` carry
516
632
  `details.retryAfter`, in seconds.
517
- - Not yet: `move`, `copy`, `createSignedUrls`, `list`'s `search`, files over
518
- 50 MiB, and bucket methods (buckets are made with `naro storage bucket`).
633
+ - Not yet: `move`, `copy`, resuming an upload in another session, and bucket
634
+ methods (buckets are made with `naro storage bucket`).
@@ -18,6 +18,8 @@ export declare function memoryStorage(): AuthStorage;
18
18
  export declare function defaultStorage(): AuthStorage;
19
19
  /** One token per project URL, so two clients on one page do not collide. */
20
20
  export declare function storageKey(baseUrl: string): string;
21
+ /** The access JWT of the signed-in session, beside the session token. */
22
+ export declare function accessJwtKey(baseUrl: string): string;
21
23
  /**
22
24
  * The PKCE verifiers of the provider sign-ins this browser began, one per
23
25
  * flow (P2-6 §6, `flows.ts`), kept where the token is.
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
@@ -13,13 +13,24 @@ export type HttpConfig = {
13
13
  apiKey: string;
14
14
  fetch: FetchLike | undefined;
15
15
  tokens: TokenStore;
16
+ /**
17
+ * The short-lived access JWT the sign-in answers carry. Data and Storage
18
+ * requests send it; the auth routes keep the session token in `tokens`.
19
+ * Without it (not signed in) those requests go as anon.
20
+ */
21
+ accessJwt?: TokenStore;
16
22
  };
17
23
  export type CallOptions = {
18
- method: "GET" | "POST" | "DELETE";
24
+ method: "GET" | "POST" | "PUT" | "DELETE";
19
25
  /** Below `/_naro/v1`, e.g. `/data/query`. */
20
26
  path: string;
21
27
  /** Sent as JSON. */
22
28
  body?: unknown;
29
+ /**
30
+ * Headers beside `apikey` and the JSON type — a signed upload's
31
+ * `x-naro-upload-token` (Storage 3-1e-2). `apikey` is always the client's.
32
+ */
33
+ headers?: Record<string, string>;
23
34
  /** Whether to send the stored session token. */
24
35
  withToken: boolean;
25
36
  /**
@@ -60,9 +71,21 @@ export type SendOptions = {
60
71
  headers?: Record<string, string>;
61
72
  body?: BodyInit;
62
73
  withToken: boolean;
74
+ /**
75
+ * An upload's `signal` (PR #111 review r1): it calls the request off until
76
+ * its answer comes — ABORTED, not NETWORK_ERROR — and never the reading
77
+ * of an answer that came, which may be a success.
78
+ */
79
+ signal?: AbortSignal | undefined;
63
80
  };
81
+ /**
82
+ * A failure carries the answer's HTTP status, or null when nothing answered
83
+ * (NETWORK_ERROR): a multipart upload tries a part again on a 5xx, whatever
84
+ * the body said (Storage 3-1f, plan C7).
85
+ */
64
86
  export type SendResult = {
65
87
  error: NaroError;
88
+ status: number | null;
66
89
  } | {
67
90
  error: null;
68
91
  response: Response;
package/dist/index.d.ts CHANGED
@@ -5,7 +5,8 @@ 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 type { DownloadOption, FileObject, FolderEntry, ListOptions, UpdateOptions, UploadBody, UploadOptions, UploadResult, } from "./storage/types.js";
8
+ export { objectRoute, type Encoded as ObjectRouteResult, } from "./storage/path.js";
9
+ export type { CreateSignedUploadUrlOptions, DownloadOption, FileObject, FolderEntry, ListOptions, SignedUploadResult, SignedUploadUrl, SignedUrlEntry, 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";
11
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",
@@ -85,6 +91,10 @@ function errorFromBody(status, body) {
85
91
  }
86
92
 
87
93
  // src/http.ts
94
+ var AUTH_PREFIX = "/auth/";
95
+ function storeFor(config, path) {
96
+ return path.startsWith(AUTH_PREFIX) ? config.tokens : config.accessJwt ?? null;
97
+ }
88
98
  async function receive(config, options, init) {
89
99
  const controller = options.timeoutMs !== void 0 && typeof AbortController === "function" ? new AbortController() : null;
90
100
  if (controller) init.signal = controller.signal;
@@ -128,8 +138,12 @@ async function call(config, options) {
128
138
  return { error: invalidQuery("", "is not JSON"), answer: null };
129
139
  }
130
140
  }
131
- const token = options.withToken ? await config.tokens.get() : null;
132
- const headers = { apikey: config.apiKey };
141
+ const store = storeFor(config, options.path);
142
+ const token = options.withToken && store ? await store.get() : null;
143
+ const headers = {
144
+ ...options.headers,
145
+ apikey: config.apiKey
146
+ };
133
147
  if (token !== null) headers.Authorization = `Bearer ${token}`;
134
148
  const init = {
135
149
  method: options.method,
@@ -159,17 +173,18 @@ async function call(config, options) {
159
173
  const error = errorFromBody(status, body);
160
174
  if (error === null) return { error: null, answer };
161
175
  return {
162
- error: await settled(config, error, responseHeaders, token),
176
+ error: await settled(store, error, responseHeaders, token),
163
177
  answer
164
178
  };
165
179
  }
166
- async function settled(config, error, headers, token) {
167
- if (error.code === "INVALID_SESSION" && token !== null)
168
- await config.tokens.clearIf(token);
180
+ async function settled(store, error, headers, token) {
181
+ if (error.code === "INVALID_SESSION" && token !== null && store)
182
+ await store.clearIf(token);
169
183
  return RETRY_AFTER_CODES.has(error.code) ? withRetryAfter(error, headers) : error;
170
184
  }
171
185
  async function send(config, options) {
172
- const token = options.withToken ? await config.tokens.get() : null;
186
+ const store = storeFor(config, options.path);
187
+ const token = options.withToken && store ? await store.get() : null;
173
188
  const headers = {
174
189
  ...options.headers,
175
190
  apikey: config.apiKey
@@ -181,6 +196,12 @@ async function send(config, options) {
181
196
  credentials: "omit"
182
197
  };
183
198
  if (options.body !== void 0) init.body = options.body;
199
+ const { signal } = options;
200
+ if (signal?.aborted) return { error: uploadAborted(), status: null };
201
+ const controller = signal ? new AbortController() : null;
202
+ const stop = () => controller?.abort();
203
+ if (controller) init.signal = controller.signal;
204
+ signal?.addEventListener("abort", stop);
184
205
  let response;
185
206
  try {
186
207
  const fetchImpl = config.fetch ?? globalThis.fetch;
@@ -189,26 +210,34 @@ async function send(config, options) {
189
210
  init
190
211
  );
191
212
  } catch (error2) {
192
- return { error: networkError(error2) };
213
+ return {
214
+ error: signal?.aborted ? uploadAborted() : networkError(error2),
215
+ status: null
216
+ };
217
+ } finally {
218
+ signal?.removeEventListener("abort", stop);
193
219
  }
194
- if (response.status >= 200 && response.status < 300)
195
- return { error: null, response };
220
+ const { status } = response;
221
+ if (status >= 200 && status < 300) return { error: null, response };
196
222
  let text;
197
223
  try {
198
224
  text = await response.text();
199
225
  } catch (error2) {
200
- return { error: networkError(error2) };
226
+ return { error: networkError(error2), status: null };
201
227
  }
202
228
  let body;
203
229
  if (text.trim() !== "") {
204
230
  try {
205
231
  body = JSON.parse(text);
206
232
  } catch {
207
- return { error: invalidResponse(response.status) };
233
+ return { error: invalidResponse(status), status };
208
234
  }
209
235
  }
210
- const error = errorFromBody(response.status, body) ?? invalidResponse(response.status);
211
- return { error: await settled(config, error, response.headers, token) };
236
+ const error = errorFromBody(status, body) ?? invalidResponse(status);
237
+ return {
238
+ error: await settled(store, error, response.headers, token),
239
+ status
240
+ };
212
241
  }
213
242
  var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
214
243
  "RATE_LIMITED",
@@ -401,6 +430,7 @@ async function createPkcePair() {
401
430
 
402
431
  // src/auth/client.ts
403
432
  var TOKEN_HEADER = "set-auth-token";
433
+ var JWT_HEADER = "set-auth-jwt";
404
434
  var FRAGMENT_PREFIX = "naro_";
405
435
  var OAUTH_PROVIDERS = ["google", "github"];
406
436
  var FLOW_TYPES = [
@@ -419,6 +449,9 @@ function nonEmpty(value) {
419
449
  function tokenFrom(answer) {
420
450
  return nonEmpty(answer.headers.get(TOKEN_HEADER));
421
451
  }
452
+ function jwtFrom(answer) {
453
+ return nonEmpty(answer.headers.get(JWT_HEADER));
454
+ }
422
455
  function userFrom(body) {
423
456
  return isRecord(body) && isRecord(body.user) ? body.user : null;
424
457
  }
@@ -581,7 +614,7 @@ var AuthClient = class {
581
614
  const user = userFrom(body);
582
615
  if (token === null || user === null || !isRecord(body) || !isRecord(body.session))
583
616
  return { data: signedOut, error: invalidResponse(result.answer.status) };
584
- await this.#http.tokens.set(token);
617
+ await this.#storeSession(token, jwtFrom(result.answer));
585
618
  await this.#flow.recovery.forget();
586
619
  return { data: { session: body.session, user }, error: null };
587
620
  }
@@ -617,6 +650,7 @@ var AuthClient = class {
617
650
  }
618
651
  if (type === "recovery" && fragment.naro_token) {
619
652
  await this.#http.tokens.clear();
653
+ await this.#http.accessJwt?.clear();
620
654
  await this.#flow.recovery.keep(fragment.naro_token);
621
655
  return { data: none, error: null };
622
656
  }
@@ -707,6 +741,7 @@ var AuthClient = class {
707
741
  withToken: true
708
742
  });
709
743
  await tokens.clearIf(result.answer?.token ?? token);
744
+ await this.#http.accessJwt?.clear();
710
745
  return { data: null, error: result.error };
711
746
  }
712
747
  /**
@@ -728,7 +763,10 @@ var AuthClient = class {
728
763
  if (result.error !== null) return { data: signedOut, error: result.error };
729
764
  const { body, token } = result.answer;
730
765
  if (body === null) {
731
- if (token !== null) await tokens.clearIf(token);
766
+ if (token !== null) {
767
+ await tokens.clearIf(token);
768
+ await this.#http.accessJwt?.clear();
769
+ }
732
770
  return { data: signedOut, error: invalidSession() };
733
771
  }
734
772
  const user = userFrom(body);
@@ -759,6 +797,15 @@ var AuthClient = class {
759
797
  async #pendingUrl() {
760
798
  if (this.#fromUrl) await this.#fromUrl;
761
799
  }
800
+ /**
801
+ * Keeps a new session: its token, and the access JWT beside it. An answer
802
+ * without a JWT drops the old one — it belonged to the previous session.
803
+ */
804
+ async #storeSession(token, jwt) {
805
+ await this.#http.tokens.set(token);
806
+ if (jwt !== null) await this.#http.accessJwt?.set(jwt);
807
+ else await this.#http.accessJwt?.clear();
808
+ }
762
809
  async #signIn(path, body, needsToken) {
763
810
  await this.#flow.recovery.forget();
764
811
  const result = await call(this.#http, {
@@ -778,7 +825,7 @@ var AuthClient = class {
778
825
  data: { user: null },
779
826
  error: invalidResponse(result.answer.status)
780
827
  };
781
- if (token !== null) await this.#http.tokens.set(token);
828
+ if (token !== null) await this.#storeSession(token, jwtFrom(result.answer));
782
829
  return { data: { user }, error: null };
783
830
  }
784
831
  };
@@ -932,6 +979,9 @@ function defaultStorage() {
932
979
  function storageKey(baseUrl) {
933
980
  return `naro-auth-token:${baseUrl}`;
934
981
  }
982
+ function accessJwtKey(baseUrl) {
983
+ return `naro-auth-jwt:${baseUrl}`;
984
+ }
935
985
  function verifierKey(baseUrl) {
936
986
  return `naro-auth-code-verifiers:${baseUrl}`;
937
987
  }
@@ -1806,13 +1856,192 @@ var TableBuilder = class {
1806
1856
  }
1807
1857
  };
1808
1858
 
1859
+ // src/storage/answer.ts
1860
+ function fail(error) {
1861
+ return { data: null, error };
1862
+ }
1863
+ var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
1864
+ function dataOf2(status, body, guard) {
1865
+ if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
1866
+ return { data: body.data, error: null };
1867
+ return fail(invalidResponse(status));
1868
+ }
1869
+ async function readJson(response, guard) {
1870
+ let text;
1871
+ try {
1872
+ text = await response.text();
1873
+ } catch (error) {
1874
+ return fail(networkError(error));
1875
+ }
1876
+ let body;
1877
+ try {
1878
+ body = JSON.parse(text);
1879
+ } catch {
1880
+ return fail(invalidResponse(response.status));
1881
+ }
1882
+ return dataOf2(response.status, body, guard);
1883
+ }
1884
+
1885
+ // src/storage/multipart.ts
1886
+ var ROUTE = "/storage/upload/multipart";
1887
+ var UPLOAD_TOKEN_HEADER = "x-naro-upload-token";
1888
+ var CONCURRENCY = 4;
1889
+ var ATTEMPTS = 4;
1890
+ var BACKOFF_MS = [1e3, 2e3, 4e3];
1891
+ var UPLOAD_ID = /^[\w-][\w.-]*$/;
1892
+ var positive = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
1893
+ 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;
1894
+ var partAnswer = (n) => (value) => isRecord(value) && value.partNumber === n && typeof value.etag === "string" && value.etag !== "";
1895
+ var retryable = (error, status) => error.code === "NETWORK_ERROR" || error.code === "OBJECT_RATE_LIMITED" || status !== null && status >= 500 && error.code !== "STORAGE_QUOTA_EXCEEDED";
1896
+ function waitAfter(error, attempt2) {
1897
+ const after = isRecord(error.details) ? error.details.retryAfter : void 0;
1898
+ if (typeof after === "number") return after * 1e3;
1899
+ if (error.code === "OBJECT_RATE_LIMITED") return 1e3;
1900
+ return BACKOFF_MS[attempt2 - 1] ?? 4e3;
1901
+ }
1902
+ var sleepFor = (ms, signal) => new Promise((done) => {
1903
+ if (signal?.aborted) {
1904
+ done();
1905
+ return;
1906
+ }
1907
+ const end = () => {
1908
+ clearTimeout(timer);
1909
+ signal?.removeEventListener("abort", end);
1910
+ done();
1911
+ };
1912
+ const timer = setTimeout(end, ms);
1913
+ signal?.addEventListener("abort", end);
1914
+ });
1915
+ async function uploadInParts(a) {
1916
+ const { signal } = a;
1917
+ const sleep = a.sleep ?? sleepFor;
1918
+ const signed = a.uploadToken === void 0 ? {} : { [UPLOAD_TOKEN_HEADER]: a.uploadToken };
1919
+ const withToken = a.uploadToken === void 0;
1920
+ if (signal?.aborted) return fail(uploadAborted());
1921
+ const started = await call(a.http, {
1922
+ method: a.method,
1923
+ path: ROUTE,
1924
+ body: {
1925
+ bucket: a.bucket,
1926
+ path: a.path,
1927
+ size: a.size,
1928
+ contentType: a.contentType,
1929
+ cacheControl: a.cacheControl,
1930
+ // PUT always replaces: the worker refuses `upsert` on it.
1931
+ ...a.method === "POST" && a.upsert ? { upsert: true } : {}
1932
+ },
1933
+ headers: signed,
1934
+ withToken
1935
+ });
1936
+ if (started.error !== null) return fail(started.error);
1937
+ const plan = dataOf2(
1938
+ started.answer.status,
1939
+ started.answer.body,
1940
+ planFor(a.size)
1941
+ );
1942
+ if (plan.error !== null) return fail(plan.error);
1943
+ const { uploadId, partSize, partCount } = plan.data;
1944
+ const base = `${ROUTE}/${uploadId}`;
1945
+ const progress = (sentBytes2) => {
1946
+ if (signal?.aborted) return;
1947
+ try {
1948
+ const result = a.onProgress?.(sentBytes2, a.size);
1949
+ if (isThenable(result)) result.then(void 0, () => void 0);
1950
+ } catch {
1951
+ }
1952
+ };
1953
+ const callOff = async () => {
1954
+ await call(a.http, {
1955
+ method: "DELETE",
1956
+ path: base,
1957
+ headers: signed,
1958
+ withToken
1959
+ }).catch(() => void 0);
1960
+ };
1961
+ const etags = [];
1962
+ const failure = {
1963
+ error: signal?.aborted ? uploadAborted() : null
1964
+ };
1965
+ let next = 1;
1966
+ let sentBytes = 0;
1967
+ const onAbort = () => {
1968
+ failure.error ??= uploadAborted();
1969
+ };
1970
+ const tryPart = async (n, bytes) => {
1971
+ const sent = await send(a.http, {
1972
+ method: "PUT",
1973
+ path: `${base}/${String(n)}`,
1974
+ headers: { "content-type": "application/octet-stream", ...signed },
1975
+ body: bytes,
1976
+ withToken,
1977
+ signal
1978
+ });
1979
+ if (sent.error !== null) return sent;
1980
+ const read = await readJson(sent.response, partAnswer(n));
1981
+ if (read.error !== null)
1982
+ return { error: read.error, status: sent.response.status };
1983
+ return { etag: read.data.etag };
1984
+ };
1985
+ const sendPart = async (n) => {
1986
+ const from = (n - 1) * partSize;
1987
+ const to = Math.min(from + partSize, a.size);
1988
+ const bytes = a.body instanceof Uint8Array ? a.body.subarray(from, to) : a.body.slice(from, to);
1989
+ for (let attempt2 = 1; ; attempt2 += 1) {
1990
+ const tried = await tryPart(n, bytes);
1991
+ if ("etag" in tried) {
1992
+ etags[n - 1] = tried.etag;
1993
+ sentBytes += to - from;
1994
+ progress(sentBytes);
1995
+ return null;
1996
+ }
1997
+ if (attempt2 === ATTEMPTS || failure.error !== null || !retryable(tried.error, tried.status))
1998
+ return tried.error;
1999
+ await sleep(waitAfter(tried.error, attempt2), signal);
2000
+ if (failure.error !== null) return tried.error;
2001
+ }
2002
+ };
2003
+ const worker = async () => {
2004
+ while (failure.error === null && next <= partCount) {
2005
+ const n = next;
2006
+ next += 1;
2007
+ const error = await sendPart(n);
2008
+ if (error !== null) failure.error ??= error;
2009
+ }
2010
+ };
2011
+ signal?.addEventListener("abort", onAbort);
2012
+ await Promise.all(
2013
+ Array.from({ length: Math.min(CONCURRENCY, partCount) }, worker)
2014
+ );
2015
+ signal?.removeEventListener("abort", onAbort);
2016
+ if (failure.error !== null) {
2017
+ await callOff();
2018
+ return fail(failure.error);
2019
+ }
2020
+ const completed = await call(a.http, {
2021
+ method: "POST",
2022
+ path: `${base}/complete`,
2023
+ body: {
2024
+ parts: etags.map((etag, i) => ({ partNumber: i + 1, etag }))
2025
+ },
2026
+ headers: signed,
2027
+ withToken
2028
+ });
2029
+ if (completed.error !== null) {
2030
+ if (completed.answer !== null && completed.error.code !== "INVALID_RESPONSE")
2031
+ await callOff();
2032
+ return fail(completed.error);
2033
+ }
2034
+ return dataOf2(completed.answer.status, completed.answer.body, isUploadResult);
2035
+ }
2036
+ var isThenable = (value) => typeof value === "object" && value !== null && typeof value.then === "function";
2037
+
1809
2038
  // src/storage/path.ts
1810
2039
  var MAX_SINGLE_UPLOAD_BYTES = 52428800;
1811
2040
  var STORAGE_MESSAGES = {
1812
2041
  bucket: "The bucket name is not valid.",
1813
- tooLarge: "Uploads over 50 MiB are not supported yet.",
1814
2042
  request: "The storage request is not valid.",
1815
- body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
2043
+ body: "upload takes a Blob, File, ArrayBuffer, typed array or string.",
2044
+ signature: "The signed URL is not valid."
1816
2045
  };
1817
2046
  var RESERVED_BUCKETS = /* @__PURE__ */ new Set([
1818
2047
  "authenticated",
@@ -1835,9 +2064,6 @@ function invalidPath(reason) {
1835
2064
  message: `The object path is not valid: it ${reason}.`
1836
2065
  };
1837
2066
  }
1838
- function objectTooLarge() {
1839
- return { code: "OBJECT_TOO_LARGE", message: STORAGE_MESSAGES.tooLarge };
1840
- }
1841
2067
  function encodePath(path) {
1842
2068
  if (typeof path !== "string" || path === "")
1843
2069
  return { ok: false, error: invalidPath("is empty") };
@@ -1874,6 +2100,16 @@ function encodeBucket(bucket) {
1874
2100
  };
1875
2101
  return { ok: true, encoded: encodeURIComponent(bucket) };
1876
2102
  }
2103
+ function objectRoute(bucket, path) {
2104
+ const encodedBucket = encodeBucket(bucket);
2105
+ if (!encodedBucket.ok) return encodedBucket;
2106
+ const encodedPath = encodePath(path);
2107
+ if (!encodedPath.ok) return encodedPath;
2108
+ return {
2109
+ ok: true,
2110
+ encoded: `${encodedBucket.encoded}/${encodedPath.encoded}`
2111
+ };
2112
+ }
1877
2113
  var isArrayBuffer = (value) => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
1878
2114
  function isDetached(buffer) {
1879
2115
  if (!isArrayBuffer(buffer)) return false;
@@ -1956,36 +2192,26 @@ function payloadOf(body, contentType) {
1956
2192
 
1957
2193
  // src/storage/client.ts
1958
2194
  var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
1959
- function fail(error) {
1960
- return { data: null, error };
2195
+ var SIGNED_UPLOAD_PREFIX = "/_naro/v1/storage/object/upload/sign/";
2196
+ var TOKEN_TEXT = /^[\x21-\x7e]+$/;
2197
+ var TOKEN_PARAMETER = /([?&]token=)[^&#\s"'<>]*/g;
2198
+ function withoutToken(error, token) {
2199
+ const { details } = error;
2200
+ if (!isRecord(details) || typeof details.cause !== "string") return error;
2201
+ let cause = details.cause.replace(TOKEN_PARAMETER, "$1[redacted]");
2202
+ for (const form of [encodeURIComponent(token), token])
2203
+ cause = cause.replaceAll(form, "[redacted]");
2204
+ return { ...error, details: { ...details, cause } };
1961
2205
  }
1962
- var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
1963
2206
  var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
1964
2207
  var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
1965
2208
  var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
2209
+ var isSignedEntry = (value) => isRecord(value) && typeof value.path === "string" && (value.signedUrl === null || typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX)) && (value.error === null || typeof value.error === "string");
2210
+ var isSignedUpload = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_UPLOAD_PREFIX) && typeof value.token === "string" && value.token !== "" && typeof value.path === "string";
2211
+ var partsBody = (body) => isArrayBuffer(body) ? new Uint8Array(body) : body;
1966
2212
  function listOf(guard) {
1967
2213
  return (value) => Array.isArray(value) && value.every(guard);
1968
2214
  }
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
2215
  var StorageFileApi = class {
1990
2216
  #http;
1991
2217
  #auth;
@@ -1997,14 +2223,9 @@ var StorageFileApi = class {
1997
2223
  }
1998
2224
  /** `/storage/object/<route><bucket>/<path>`, or the refusal no request needs. */
1999
2225
  #objectPath(route, path) {
2000
- const bucket = encodeBucket(this.#bucket);
2001
- if (!bucket.ok) return bucket;
2002
- const name = encodePath(path);
2003
- if (!name.ok) return name;
2004
- return {
2005
- ok: true,
2006
- path: `/storage/object/${route}${bucket.encoded}/${name.encoded}`
2007
- };
2226
+ const target = objectRoute(this.#bucket, path);
2227
+ if (!target.ok) return target;
2228
+ return { ok: true, path: `/storage/object/${route}${target.encoded}` };
2008
2229
  }
2009
2230
  /** `/storage/object/<route><bucket>`, for the routes that take names in the body. */
2010
2231
  #bucketPath(route) {
@@ -2026,8 +2247,23 @@ var StorageFileApi = class {
2026
2247
  const cache = `max-age=${options.cacheControl ?? "3600"}`;
2027
2248
  const unreadable = headerProblem(options.contentType, cache);
2028
2249
  if (unreadable !== null) return fail(unreadable);
2029
- if (payload.size > MAX_SINGLE_UPLOAD_BYTES) return fail(objectTooLarge());
2250
+ const { signal } = options;
2251
+ if (signal?.aborted) return fail(uploadAborted());
2030
2252
  await this.#auth.ready();
2253
+ if (payload.size > MAX_SINGLE_UPLOAD_BYTES)
2254
+ return uploadInParts({
2255
+ http: this.#http,
2256
+ method,
2257
+ bucket: this.#bucket,
2258
+ path,
2259
+ body: partsBody(payload.body),
2260
+ size: payload.size,
2261
+ contentType: payload.type,
2262
+ cacheControl: cache,
2263
+ upsert,
2264
+ ...options.onProgress ? { onProgress: options.onProgress } : {},
2265
+ signal
2266
+ });
2031
2267
  const headers = {
2032
2268
  "content-type": payload.type,
2033
2269
  "cache-control": cache
@@ -2038,7 +2274,9 @@ var StorageFileApi = class {
2038
2274
  path: target.path,
2039
2275
  headers,
2040
2276
  body: payload.body,
2041
- withToken: true
2277
+ withToken: true,
2278
+ // The fetch stops with the body: the worker undoes its claim at once.
2279
+ signal
2042
2280
  });
2043
2281
  if (sent.error !== null) return fail(sent.error);
2044
2282
  return readJson(sent.response, isUploadResult);
@@ -2098,7 +2336,11 @@ var StorageFileApi = class {
2098
2336
  }
2099
2337
  /**
2100
2338
  * What is directly below `prefix` (the whole bucket by default): folders
2101
- * first, then files. The options go to the worker as they are given.
2339
+ * first, then files. `search` keeps the names below the prefix that start
2340
+ * with it, ASCII case ignored (Storage 3-1g); a backend whose Storage was
2341
+ * turned on before it answers `INVALID_REQUEST` (`details.path`
2342
+ * `search`) — update it with `naro backend enable`. The options go to the
2343
+ * worker as they are given.
2102
2344
  */
2103
2345
  async list(prefix, options = {}) {
2104
2346
  const target = this.#bucketPath("list/");
@@ -2146,6 +2388,139 @@ var StorageFileApi = class {
2146
2388
  error: null
2147
2389
  };
2148
2390
  }
2391
+ /**
2392
+ * Links to several objects at once (Storage 3-1g): up to 100 paths, each
2393
+ * for `expiresIn` seconds (1–604,800), with the same `download`. One entry
2394
+ * per path, in the order given — a path given twice is answered twice.
2395
+ * A path that is not there, or that the caller cannot read, has
2396
+ * `signedUrl: null` and `error` "Object <path> was not found in bucket
2397
+ * <bucket>." — the same words for both. The paths go in the body, so the
2398
+ * backend checks them: a bad one fails the call with `INVALID_REQUEST`
2399
+ * (`details.path` `paths[<i>]`). A backend whose Storage was turned on
2400
+ * before this method came answers `NOT_FOUND`: update it with
2401
+ * `naro backend enable`.
2402
+ */
2403
+ async createSignedUrls(paths, expiresIn, options = {}) {
2404
+ const target = this.#bucketPath("sign/");
2405
+ if (!target.ok) return fail(target.error);
2406
+ const { download } = options ?? {};
2407
+ const signed = await this.#json(
2408
+ {
2409
+ method: "POST",
2410
+ path: target.path,
2411
+ body: {
2412
+ expiresIn,
2413
+ paths,
2414
+ ...download === void 0 || download === false ? {} : { download }
2415
+ }
2416
+ },
2417
+ listOf(isSignedEntry)
2418
+ );
2419
+ if (signed.error !== null) return signed;
2420
+ return {
2421
+ data: signed.data.map((entry) => ({
2422
+ path: entry.path,
2423
+ signedUrl: entry.signedUrl === null ? null : `${this.#http.baseUrl}${entry.signedUrl}`,
2424
+ error: entry.error
2425
+ })),
2426
+ error: null
2427
+ };
2428
+ }
2429
+ /**
2430
+ * A token that lets whoever holds it upload `path` for two hours (Storage
2431
+ * 3-1e-2), as supabase-js's: made where the key is — a server with the
2432
+ * service key for any path, anon or a signed-in user only where the
2433
+ * policies let them upload. With `upsert: true` it may replace a file
2434
+ * that is there.
2435
+ * The token goes to `uploadToSignedUrl`; `signedUrl` is where a client
2436
+ * without naro-js PUTs the bytes.
2437
+ */
2438
+ async createSignedUploadUrl(path, options = {}) {
2439
+ const target = this.#objectPath("upload/sign/", path);
2440
+ if (!target.ok) return fail(target.error);
2441
+ const upsert = (options ?? {}).upsert === true;
2442
+ const signed = await this.#json(
2443
+ {
2444
+ method: "POST",
2445
+ path: target.path,
2446
+ body: upsert ? { upsert: true } : {}
2447
+ },
2448
+ isSignedUpload
2449
+ );
2450
+ if (signed.error !== null) return signed;
2451
+ return {
2452
+ data: {
2453
+ signedUrl: `${this.#http.baseUrl}${signed.data.signedUrl}`,
2454
+ token: signed.data.token,
2455
+ path: signed.data.path
2456
+ },
2457
+ error: null
2458
+ };
2459
+ }
2460
+ /**
2461
+ * Uploads `body` to `path` with a token from `createSignedUploadUrl`
2462
+ * (Storage 3-1e-2). No session is sent, or needed: the token is the
2463
+ * permission, so a page's anon-key client uploads where only a server
2464
+ * could sign. It works until the token expires, two hours from when it
2465
+ * was made; one made without `upsert` uploads one new file
2466
+ * (`OBJECT_EXISTS` for a name that is there). A body over 50 MiB goes in
2467
+ * parts with the token on every step, and once started may finish after
2468
+ * the two hours. `contentType`, `cacheControl`, `onProgress` and `signal`
2469
+ * are `upload`'s.
2470
+ */
2471
+ async uploadToSignedUrl(path, token, body, options = {}) {
2472
+ const given = options ?? {};
2473
+ const target = this.#objectPath("upload/sign/", path);
2474
+ if (!target.ok) return fail(target.error);
2475
+ if (typeof token !== "string" || !TOKEN_TEXT.test(token))
2476
+ return fail({
2477
+ code: "INVALID_SIGNATURE",
2478
+ message: STORAGE_MESSAGES.signature
2479
+ });
2480
+ const payload = payloadOf(body, given.contentType);
2481
+ if (!payload.ok) return fail(payload.error);
2482
+ const cache = `max-age=${given.cacheControl ?? "3600"}`;
2483
+ const unreadable = headerProblem(given.contentType, cache);
2484
+ if (unreadable !== null) return fail(unreadable);
2485
+ const { signal } = given;
2486
+ if (signal?.aborted) return fail(uploadAborted());
2487
+ const uploaded = payload.size > MAX_SINGLE_UPLOAD_BYTES ? await uploadInParts({
2488
+ http: this.#http,
2489
+ method: "POST",
2490
+ bucket: this.#bucket,
2491
+ path,
2492
+ body: partsBody(payload.body),
2493
+ size: payload.size,
2494
+ contentType: payload.type,
2495
+ cacheControl: cache,
2496
+ upsert: false,
2497
+ uploadToken: token,
2498
+ ...given.onProgress ? { onProgress: given.onProgress } : {},
2499
+ signal
2500
+ }) : await this.#putSigned(target.path, token, payload, cache, signal);
2501
+ if (uploaded.error !== null) return fail(uploaded.error);
2502
+ return {
2503
+ data: { path: uploaded.data.path, fullPath: uploaded.data.fullPath },
2504
+ error: null
2505
+ };
2506
+ }
2507
+ /**
2508
+ * The one-request signed upload: the token in the query, no session — it
2509
+ * is the permission. A failure's cause never carries the token.
2510
+ */
2511
+ async #putSigned(path, token, payload, cache, signal) {
2512
+ const sent = await send(this.#http, {
2513
+ method: "PUT",
2514
+ path: `${path}?token=${encodeURIComponent(token)}`,
2515
+ headers: { "content-type": payload.type, "cache-control": cache },
2516
+ body: payload.body,
2517
+ withToken: false,
2518
+ signal
2519
+ });
2520
+ if (sent.error !== null) return fail(withoutToken(sent.error, token));
2521
+ const read = await readJson(sent.response, isUploadResult);
2522
+ return read.error === null ? read : fail(withoutToken(read.error, token));
2523
+ }
2149
2524
  /**
2150
2525
  * A public bucket's URL for the object. No request is made, so whether
2151
2526
  * the bucket is public is not checked (as in supabase-js); a path no URL
@@ -2255,7 +2630,8 @@ var NaroClient = class {
2255
2630
  baseUrl,
2256
2631
  apiKey,
2257
2632
  fetch: options.fetch,
2258
- tokens: new TokenStore(storage, storageKey(baseUrl))
2633
+ tokens: new TokenStore(storage, storageKey(baseUrl)),
2634
+ accessJwt: new TokenStore(storage, accessJwtKey(baseUrl))
2259
2635
  };
2260
2636
  this.auth = new AuthClient(http, {
2261
2637
  verifiers: new FlowVerifiers(
@@ -2282,5 +2658,6 @@ function createClient(options) {
2282
2658
  export {
2283
2659
  NaroClient,
2284
2660
  createClient,
2661
+ objectRoute,
2285
2662
  quoteFilterValue
2286
2663
  };
@@ -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>>;
@@ -1,7 +1,7 @@
1
1
  import type { AuthClient } from "../auth/client.js";
2
2
  import { type HttpConfig } from "../http.js";
3
3
  import type { NaroResponse } from "../types.js";
4
- import type { DownloadOption, FileObject, FolderEntry, ListOptions, UpdateOptions, UploadBody, UploadOptions, UploadResult } from "./types.js";
4
+ import type { CreateSignedUploadUrlOptions, DownloadOption, FileObject, FolderEntry, ListOptions, SignedUploadResult, SignedUploadUrl, SignedUrlEntry, UpdateOptions, UploadBody, UploadOptions, UploadResult } from "./types.js";
5
5
  /** One bucket's objects: supabase-js's StorageFileApi. */
6
6
  export declare class StorageFileApi {
7
7
  #private;
@@ -31,7 +31,11 @@ export declare class StorageFileApi {
31
31
  exists(path: string): Promise<NaroResponse<boolean>>;
32
32
  /**
33
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.
34
+ * first, then files. `search` keeps the names below the prefix that start
35
+ * with it, ASCII case ignored (Storage 3-1g); a backend whose Storage was
36
+ * turned on before it answers `INVALID_REQUEST` (`details.path`
37
+ * `search`) — update it with `naro backend enable`. The options go to the
38
+ * worker as they are given.
35
39
  */
36
40
  list(prefix?: string, options?: ListOptions): Promise<NaroResponse<(FileObject | FolderEntry)[]>>;
37
41
  /** Removes the objects named — 1 to 1,000 — and answers the ones it removed. */
@@ -43,6 +47,41 @@ export declare class StorageFileApi {
43
47
  createSignedUrl(path: string, expiresIn: number, options?: DownloadOption): Promise<NaroResponse<{
44
48
  signedUrl: string;
45
49
  }>>;
50
+ /**
51
+ * Links to several objects at once (Storage 3-1g): up to 100 paths, each
52
+ * for `expiresIn` seconds (1–604,800), with the same `download`. One entry
53
+ * per path, in the order given — a path given twice is answered twice.
54
+ * A path that is not there, or that the caller cannot read, has
55
+ * `signedUrl: null` and `error` "Object <path> was not found in bucket
56
+ * <bucket>." — the same words for both. The paths go in the body, so the
57
+ * backend checks them: a bad one fails the call with `INVALID_REQUEST`
58
+ * (`details.path` `paths[<i>]`). A backend whose Storage was turned on
59
+ * before this method came answers `NOT_FOUND`: update it with
60
+ * `naro backend enable`.
61
+ */
62
+ createSignedUrls(paths: string[], expiresIn: number, options?: DownloadOption): Promise<NaroResponse<SignedUrlEntry[]>>;
63
+ /**
64
+ * A token that lets whoever holds it upload `path` for two hours (Storage
65
+ * 3-1e-2), as supabase-js's: made where the key is — a server with the
66
+ * service key for any path, anon or a signed-in user only where the
67
+ * policies let them upload. With `upsert: true` it may replace a file
68
+ * that is there.
69
+ * The token goes to `uploadToSignedUrl`; `signedUrl` is where a client
70
+ * without naro-js PUTs the bytes.
71
+ */
72
+ createSignedUploadUrl(path: string, options?: CreateSignedUploadUrlOptions): Promise<NaroResponse<SignedUploadUrl>>;
73
+ /**
74
+ * Uploads `body` to `path` with a token from `createSignedUploadUrl`
75
+ * (Storage 3-1e-2). No session is sent, or needed: the token is the
76
+ * permission, so a page's anon-key client uploads where only a server
77
+ * could sign. It works until the token expires, two hours from when it
78
+ * was made; one made without `upsert` uploads one new file
79
+ * (`OBJECT_EXISTS` for a name that is there). A body over 50 MiB goes in
80
+ * parts with the token on every step, and once started may finish after
81
+ * the two hours. `contentType`, `cacheControl`, `onProgress` and `signal`
82
+ * are `upload`'s.
83
+ */
84
+ uploadToSignedUrl(path: string, token: string, body: UploadBody, options?: UpdateOptions): Promise<NaroResponse<SignedUploadResult>>;
46
85
  /**
47
86
  * A public bucket's URL for the object. No request is made, so whether
48
87
  * the bucket is public is not checked (as in supabase-js); a path no URL
@@ -0,0 +1,28 @@
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
+ /**
18
+ * A signed upload's token (Storage 3-1e-2): every step carries it and
19
+ * none carries the session.
20
+ */
21
+ uploadToken?: string | undefined;
22
+ onProgress?: (sentBytes: number, totalBytes: number) => void;
23
+ /** The caller's: stops the upload until the complete is sent. */
24
+ signal?: AbortSignal | undefined;
25
+ /** How a retry waits — `ms`, or until the signal aborts; tests pass their own. */
26
+ sleep?: (ms: number, signal: AbortSignal | undefined) => Promise<void>;
27
+ };
28
+ export declare function uploadInParts(a: PartsArgs): Promise<NaroResponse<UploadResult>>;
@@ -3,15 +3,15 @@ import type { NaroError } from "../errors.js";
3
3
  * The most one upload request carries (original spec §9.1): 50 MiB.
4
4
  * data-core's MAX_SINGLE_UPLOAD_BYTES — storage/wire.test.ts holds them
5
5
  * equal. Over it, the edge cuts the body off and the browser often cannot
6
- * read the answer: the SDK refuses first.
6
+ * read the answer: the SDK sends a larger body in parts (Storage 3-1f).
7
7
  */
8
8
  export declare const MAX_SINGLE_UPLOAD_BYTES = 52428800;
9
9
  /** The worker's words (data-core's catalog) for what the SDK refuses itself. */
10
10
  export declare const STORAGE_MESSAGES: {
11
11
  readonly bucket: "The bucket name is not valid.";
12
- readonly tooLarge: "Uploads over 50 MiB are not supported yet.";
13
12
  readonly request: "The storage request is not valid.";
14
13
  readonly body: "upload takes a Blob, File, ArrayBuffer, typed array or string.";
14
+ readonly signature: "The signed URL is not valid.";
15
15
  };
16
16
  /**
17
17
  * The words the worker's routes take as the first segment after `/object/`
@@ -29,7 +29,6 @@ export type Encoded = {
29
29
  error: NaroError;
30
30
  };
31
31
  export declare function invalidPath(reason: string): NaroError;
32
- export declare function objectTooLarge(): NaroError;
33
32
  /**
34
33
  * An object path as a URL carries it, each segment encoded once and `/`
35
34
  * kept as the separator — or the worker's INVALID_PATH for the paths no
@@ -53,6 +52,19 @@ export declare function downloadQuery(download: boolean | string | undefined): E
53
52
  * empty, `.`, `..`, a lone surrogate and the routes' own words.
54
53
  */
55
54
  export declare function encodeBucket(bucket: string): Encoded;
55
+ /**
56
+ * `<bucket>/<path>` as the object routes carry it — the bucket by
57
+ * `encodeBucket`, the path by `encodePath` — or the INVALID_PATH either is
58
+ * refused with. Every object call builds its URL from it, and the package
59
+ * exports it for a tool that streams an object itself (naro-cli's
60
+ * download): it names the same URL, and is refused the same names.
61
+ */
62
+ export declare function objectRoute(bucket: string, path: string): Encoded;
63
+ /**
64
+ * `instanceof ArrayBuffer` is false for one made in another realm (an
65
+ * iframe, a vm context); the tag is the same in every realm.
66
+ */
67
+ export declare const isArrayBuffer: (value: unknown) => value is ArrayBuffer;
56
68
  /** Why the worker refuses a cache-control it cannot read (its upload.ts). */
57
69
  export declare const CACHE_CONTROL_REASON = "must be max-age=<seconds> from 0 to 31536000";
58
70
  /**
@@ -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 = {
@@ -51,8 +70,48 @@ export type ListOptions = {
51
70
  column?: "name" | "created_at" | "updated_at";
52
71
  order?: "asc" | "desc";
53
72
  };
73
+ /**
74
+ * Names below the prefix that start with it, ASCII case ignored; % and _
75
+ * are plain characters. Up to 256 characters. A folder shows when a name
76
+ * under it starts with it. A backend whose Storage was turned on before
77
+ * search came answers INVALID_REQUEST (`details.path` "search"): update
78
+ * it with `naro backend enable`.
79
+ */
80
+ search?: string;
54
81
  };
55
82
  /** `true` downloads under the object's own name, a string under that name. */
56
83
  export type DownloadOption = {
57
84
  download?: boolean | string;
58
85
  };
86
+ /** `createSignedUploadUrl`'s options (Storage 3-1e-2). */
87
+ export type CreateSignedUploadUrlOptions = {
88
+ /** Let the token replace a file of the same name. Default false: a second upload is `OBJECT_EXISTS`. */
89
+ upsert?: boolean;
90
+ };
91
+ /**
92
+ * A signed upload's token (Storage 3-1e-2): `token` for `uploadToSignedUrl`,
93
+ * and `signedUrl` — on the project's URL — where a client without naro-js
94
+ * PUTs the file's bytes with no key. It works for two hours from when it
95
+ * was made.
96
+ */
97
+ export type SignedUploadUrl = {
98
+ signedUrl: string;
99
+ token: string;
100
+ path: string;
101
+ };
102
+ /** What `uploadToSignedUrl` answers: supabase-js's shape. */
103
+ export type SignedUploadResult = {
104
+ path: string;
105
+ fullPath: string;
106
+ };
107
+ /**
108
+ * One path's answer of `createSignedUrls` (Storage 3-1g): supabase-js's
109
+ * shape without its `signedURL` copy. `signedUrl` is null, and `error`
110
+ * says `Object <path> was not found in bucket <bucket>.`, for a file that
111
+ * is not there or that the caller cannot read — the same words for both.
112
+ */
113
+ export type SignedUrlEntry = {
114
+ path: string;
115
+ signedUrl: string | null;
116
+ error: string | null;
117
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naro-js",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Auth, data and files for your Naro project, from the browser or the server.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -23,8 +23,8 @@
23
23
  "tsup": "8.5.0",
24
24
  "typescript": "7.0.2",
25
25
  "vitest": "3.2.7",
26
- "@next-deploy/backend-worker": "0.1.0",
27
26
  "@next-deploy/config": "0.1.0",
27
+ "@next-deploy/backend-worker": "0.1.0",
28
28
  "@next-deploy/data-core": "0.1.0",
29
29
  "@next-deploy/sdk": "0.1.0"
30
30
  },