naro-js 0.4.0 → 0.6.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 +305 -12
- package/dist/auth/storage.d.ts +18 -6
- package/dist/client.d.ts +24 -0
- package/dist/errors.d.ts +70 -0
- package/dist/http.d.ts +12 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2740 -208
- package/dist/realtime/channel.d.ts +64 -0
- package/dist/realtime/client.d.ts +18 -0
- package/dist/realtime/close.d.ts +44 -0
- package/dist/realtime/identity.d.ts +68 -0
- package/dist/realtime/presence.d.ts +27 -0
- package/dist/realtime/protocol.d.ts +78 -0
- package/dist/realtime/socket.d.ts +96 -0
- package/dist/realtime/types.d.ts +89 -0
- package/dist/storage/client.d.ts +66 -3
- package/dist/storage/multipart.d.ts +5 -0
- package/dist/storage/path.d.ts +1 -0
- package/dist/storage/types.d.ts +59 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,6 +79,7 @@ await naro.from("posts").delete().eq("id", 10);
|
|
|
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
81
|
- Files — see [Storage](#storage).
|
|
82
|
+
- Live changes, broadcast and presence — see [Realtime](#realtime).
|
|
82
83
|
|
|
83
84
|
## Policies
|
|
84
85
|
|
|
@@ -271,12 +272,14 @@ await naro.auth.updateUser({ password: newPassword });
|
|
|
271
272
|
redirects off them. `getSessionFromUrl()` never shows text from the URL:
|
|
272
273
|
its errors carry the SDK's own messages.
|
|
273
274
|
- A provider email the provider has not verified is refused
|
|
274
|
-
(`OAUTH_PROVIDER_EMAIL_NOT_VERIFIED
|
|
275
|
+
(`OAUTH_PROVIDER_EMAIL_NOT_VERIFIED`, or `OAUTH_ACCOUNT_NOT_LINKED` when an
|
|
276
|
+
account with that address already exists): verify it with the provider first.
|
|
275
277
|
An account with the same email is linked only when both sides have it
|
|
276
278
|
confirmed (`OAUTH_ACCOUNT_NOT_LINKED` otherwise).
|
|
277
279
|
- A confirmation link opens no session. If someone else signed up with the
|
|
278
280
|
address before its owner confirmed it — or the owner's Google or GitHub
|
|
279
|
-
sign-in was refused for that
|
|
281
|
+
sign-in, with an address the provider verified, was refused for that
|
|
282
|
+
unconfirmed account
|
|
280
283
|
(`OAUTH_ACCOUNT_NOT_LINKED`) — confirming clears that password
|
|
281
284
|
(`PASSWORD_RESET_REQUIRED`): set one with `resetPasswordForEmail`.
|
|
282
285
|
- `updateUser({ password })` sets a new password in the recovery flow only
|
|
@@ -483,15 +486,78 @@ await avatars.remove([`${user.id}/old.png`]);
|
|
|
483
486
|
const { data: link } = await naro.storage
|
|
484
487
|
.from("docs")
|
|
485
488
|
.createSignedUrl("team-1/plan.pdf", 600);
|
|
489
|
+
const { data: links } = await naro.storage
|
|
490
|
+
.from("docs")
|
|
491
|
+
.createSignedUrls(["team-1/a.pdf", "team-1/b.pdf"], 600);
|
|
492
|
+
const { data: plans } = await naro.storage
|
|
493
|
+
.from("docs")
|
|
494
|
+
.list("team-1", { search: "plan" });
|
|
495
|
+
const { data: copied } = await naro.storage
|
|
496
|
+
.from("docs")
|
|
497
|
+
.copy("team-1/plan.pdf", "team-2/plan.pdf", { upsert: true });
|
|
498
|
+
await naro.storage
|
|
499
|
+
.from("docs")
|
|
500
|
+
.move("team-1/plan.pdf", "team-1/archive/plan.pdf");
|
|
486
501
|
```
|
|
487
502
|
|
|
488
|
-
- `upload`, `update`, `download`, `info`, `exists`, `list`, `remove`,
|
|
489
|
-
`createSignedUrl` and `getPublicUrl` take
|
|
490
|
-
answer its shapes (`FileObject` in
|
|
491
|
-
|
|
492
|
-
(`bucketId`, `createdAt`, `size`,
|
|
493
|
-
|
|
494
|
-
in `metadata.size` and
|
|
503
|
+
- `upload`, `update`, `download`, `info`, `exists`, `list`, `remove`, `move`,
|
|
504
|
+
`copy`, `createSignedUrl`, `createSignedUrls` and `getPublicUrl` take
|
|
505
|
+
supabase-js's arguments and answer its shapes (`FileObject` in
|
|
506
|
+
snake_case), as `{ data, error }`, with two exceptions. supabase-js's
|
|
507
|
+
`info` answers a camelCase object (`bucketId`, `createdAt`, `size`,
|
|
508
|
+
`contentType`, …); naro-js's `info` answers the same snake_case
|
|
509
|
+
`FileObject` as `list`, with the size and type in `metadata.size` and
|
|
510
|
+
`metadata.mimetype`. supabase-js's `copy` answers `{ path }` with the
|
|
511
|
+
bucket in front of the name; naro-js's `copy` answers
|
|
512
|
+
`{ id, path, fullPath }`, as `upload` does, with `path` the name inside
|
|
513
|
+
the bucket.
|
|
514
|
+
- `createSignedUrls(paths, expiresIn, { download })` signs 1 to 100 paths in
|
|
515
|
+
one request (naro-js 0.5.0) and answers one `{ path, signedUrl, error }`
|
|
516
|
+
per path, in the order given — a path given twice is answered twice. A
|
|
517
|
+
file that is not there, or that the caller cannot read, has
|
|
518
|
+
`signedUrl: null` and the `error`
|
|
519
|
+
`Object <path> was not found in bucket <bucket>.` — the same words for
|
|
520
|
+
both. `signedUrl` is absolute, as `createSignedUrl`'s. The paths go in the
|
|
521
|
+
body unchecked: a bad one fails the call with `INVALID_REQUEST`
|
|
522
|
+
(`details.path` `paths[<i>]`). Unlike supabase-js, `download` goes in the
|
|
523
|
+
request — the token covers it — so adding `&download` to a URL afterwards
|
|
524
|
+
answers `400 INVALID_SIGNATURE`.
|
|
525
|
+
- `list(prefix, { search })` keeps the names directly below `prefix` that
|
|
526
|
+
start with `search`. As in Supabase, `%` and `_` are plain characters, not
|
|
527
|
+
wildcards. Unlike Supabase, only ASCII letters ignore case (Postgres folds
|
|
528
|
+
case by the database's locale) and `search` takes up to 256 characters. A
|
|
529
|
+
folder shows when a name under it starts with `search`.
|
|
530
|
+
- `move(from, to)` and `copy(from, to, { upsert })` work inside one bucket
|
|
531
|
+
(naro-js 0.6.0), on files up to 5 GiB − 5 MiB (`413 OBJECT_TOO_LARGE`
|
|
532
|
+
`Objects over 5,363,466,240 bytes (5 GiB − 5 MiB) cannot be copied or moved.`).
|
|
533
|
+
A move keeps the file — its `id` and owner — under the new name and never
|
|
534
|
+
replaces a file that is there (`409 OBJECT_EXISTS`); the file's signed
|
|
535
|
+
links stop working at its old name (a link is bound to the name, the
|
|
536
|
+
file's `id` and its bytes, so moving it back can open them again). A copy
|
|
537
|
+
to a new name is a new file of the caller's, held to the bucket's limits
|
|
538
|
+
as they are now; `upsert: true` replaces one that is there, which keeps
|
|
539
|
+
its owner. Both are
|
|
540
|
+
judged by the policies as in Supabase: a move needs SELECT and UPDATE on the
|
|
541
|
+
file, and SELECT and the UPDATE check on its new name; a copy needs SELECT
|
|
542
|
+
on the source and INSERT on the copy (with `upsert: true`, replacing a file
|
|
543
|
+
takes what an upload's `upsert` takes). While a file is moved, or copied to
|
|
544
|
+
a new name, the project needs room for one more copy of it (a copy that
|
|
545
|
+
replaces a file with `upsert: true` needs only the growth), so in a project
|
|
546
|
+
that is almost full they can answer `507 STORAGE_QUOTA_EXCEEDED`. `copy`
|
|
547
|
+
answers `{ id, path, fullPath }`, not supabase-js's `{ path }` (see
|
|
548
|
+
above). Unlike supabase-js, a `destinationBucket` other than the bucket
|
|
549
|
+
answers `400 INVALID_REQUEST`.
|
|
550
|
+
- A backend whose Storage was turned on before these came answers
|
|
551
|
+
`createSignedUrls`, `move` and `copy` with `404 NOT_FOUND` and a `search`
|
|
552
|
+
with `400 INVALID_REQUEST` (`details.path` `search`, `details.reason`
|
|
553
|
+
`search is not supported yet`): update it once with `naro backend enable`.
|
|
554
|
+
- A public file is kept at Naro's edge for up to 5 minutes (or for its own
|
|
555
|
+
`cacheControl`, when that is shorter): after it is deleted, moved or
|
|
556
|
+
replaced, or its bucket is made private, `getPublicUrl`'s URL (for a moved
|
|
557
|
+
file, the old name's) can still serve the old bytes that long. Put a
|
|
558
|
+
version in names that change (`avatar-<time>.png`) or keep `cacheControl`
|
|
559
|
+
short. Signed URLs are not kept at the edge: replacing or deleting a file
|
|
560
|
+
ends its signed links at once, and moving it ends them at its old name.
|
|
495
561
|
- An upload sends the file's own bytes, typed as the file is (`Blob.type`,
|
|
496
562
|
or `application/octet-stream`; a string is `text/plain;charset=utf-8`).
|
|
497
563
|
The body is a `Blob`, `File`, `ArrayBuffer`, typed array or string — not
|
|
@@ -540,6 +606,38 @@ await naro.storage.from("videos").upload(`${user.id}/talk.mp4`, file, {
|
|
|
540
606
|
});
|
|
541
607
|
```
|
|
542
608
|
|
|
609
|
+
- `createSignedUploadUrl(path, { upsert })` and
|
|
610
|
+
`uploadToSignedUrl(path, token, body, options)` are supabase-js's signed
|
|
611
|
+
upload (naro-js 0.5.0). A server with the service key — or anyone the
|
|
612
|
+
policies let upload there, anon or a signed-in user — makes a token for
|
|
613
|
+
one path; a page with only the anon key uploads by it. The token stands
|
|
614
|
+
in for the policies; the bucket's and the plan's limits and the
|
|
615
|
+
project's usage stops still apply. It works for 2 hours. Made with
|
|
616
|
+
`upsert: true` it may replace the file until then; without it a second
|
|
617
|
+
upload answers `409 OBJECT_EXISTS`.
|
|
618
|
+
`uploadToSignedUrl` sends no session, takes `contentType`,
|
|
619
|
+
`cacheControl`, `onProgress` and `signal` as `upload` does, and answers
|
|
620
|
+
`{ path, fullPath }`. Over 50 MiB it goes in parts with the token on
|
|
621
|
+
every step (the `x-naro-upload-token` header), and once started may
|
|
622
|
+
finish after the 2 hours. A token that is not the path's answers
|
|
623
|
+
`400 INVALID_SIGNATURE` (one no URL can carry is refused so before a
|
|
624
|
+
request), an expired one `400 SIGNED_URL_EXPIRED`. A token is neither
|
|
625
|
+
used up nor revocable: until its 2 hours end it can create its file again
|
|
626
|
+
after the file is deleted, and it keeps working if the user it was made
|
|
627
|
+
for is deleted or banned.
|
|
628
|
+
|
|
629
|
+
```ts
|
|
630
|
+
// On the server, with the service key:
|
|
631
|
+
const { data, error } = await admin.storage
|
|
632
|
+
.from("avatars")
|
|
633
|
+
.createSignedUploadUrl(`${userId}/me.png`, { upsert: true });
|
|
634
|
+
if (error) throw new Error(error.message);
|
|
635
|
+
// In the browser, with the anon key:
|
|
636
|
+
await naro.storage
|
|
637
|
+
.from("avatars")
|
|
638
|
+
.uploadToSignedUrl(data.path, data.token, file);
|
|
639
|
+
```
|
|
640
|
+
|
|
543
641
|
- A `contentType` or `cacheControl` no header can carry (a CR, LF or NUL
|
|
544
642
|
inside, or a character past U+00FF) is answered before a request, as the
|
|
545
643
|
backend answers one it cannot read: `INVALID_CONTENT_TYPE`, and
|
|
@@ -564,6 +662,201 @@ await naro.storage.from("videos").upload(`${user.id}/talk.mp4`, file, {
|
|
|
564
662
|
downloads that way.
|
|
565
663
|
- `OBJECT_RATE_LIMITED` and `STORAGE_OPS_QUOTA_EXCEEDED` carry
|
|
566
664
|
`details.retryAfter`, in seconds.
|
|
567
|
-
- Not yet:
|
|
568
|
-
|
|
569
|
-
|
|
665
|
+
- Not yet: resuming an upload in another session, and bucket methods
|
|
666
|
+
(buckets are made with `naro storage bucket`).
|
|
667
|
+
|
|
668
|
+
## Realtime
|
|
669
|
+
|
|
670
|
+
Row changes, broadcast and presence over one WebSocket per page, called the
|
|
671
|
+
way supabase-js's `channel()` is (naro-js 0.6.0). Turn it on for the project
|
|
672
|
+
(the backend must be on) and publish the tables whose changes subscribers
|
|
673
|
+
get:
|
|
674
|
+
|
|
675
|
+
```sh
|
|
676
|
+
naro realtime enable
|
|
677
|
+
naro realtime tables add messages
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
const room = naro.channel("room:1", {
|
|
682
|
+
config: { private: true, presence: { key: user.id } },
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
room
|
|
686
|
+
.on(
|
|
687
|
+
"postgres_changes",
|
|
688
|
+
{ event: "INSERT", table: "messages", filter: "room_id=eq.1" },
|
|
689
|
+
(change) => append(change.new),
|
|
690
|
+
)
|
|
691
|
+
.on("broadcast", { event: "cursor" }, ({ payload }) => moveCursor(payload))
|
|
692
|
+
.on("presence", { event: "sync" }, () => render(room.presenceState()))
|
|
693
|
+
.on("system", {}, (notice) => {
|
|
694
|
+
// BULK_CHANGE, EVENTS_DROPPED: some changes did not come as rows.
|
|
695
|
+
if (notice.extension === "postgres_changes") refetchMessages();
|
|
696
|
+
})
|
|
697
|
+
.subscribe(async (status, error) => {
|
|
698
|
+
if (status === "SUBSCRIBED") {
|
|
699
|
+
refetchMessages(); // what changed while it was away does not come again
|
|
700
|
+
await room.track({ online_at: new Date().toISOString() });
|
|
701
|
+
}
|
|
702
|
+
if (status === "CHANNEL_ERROR") console.warn(error?.code, error?.message);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
await room.send({
|
|
706
|
+
type: "broadcast",
|
|
707
|
+
event: "cursor",
|
|
708
|
+
payload: { x: 10, y: 20 },
|
|
709
|
+
});
|
|
710
|
+
await naro.removeChannel(room);
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
- What becomes an event: Realtime publishes changes made through the Data
|
|
714
|
+
API (naro-js, any key). Writes your app makes with env.DB, naro db query
|
|
715
|
+
and the table editor are not published; send them with the realtime
|
|
716
|
+
broadcast API if clients need to know. Nor are the changes a trigger or a
|
|
717
|
+
cascade makes to other rows. A table publishes within about 10 seconds of
|
|
718
|
+
`naro realtime tables add`; a channel joined under a table's old name
|
|
719
|
+
receives nothing after a rename — join again with the new one.
|
|
720
|
+
- A subscriber receives a row change only if its select policies on the
|
|
721
|
+
table let it read the row (for a `DELETE`, the row as it was); without a
|
|
722
|
+
select policy only the service key does. Broadcast and presence need a
|
|
723
|
+
private channel (`{ private: true }`) and policies on `realtime.messages`:
|
|
724
|
+
`select` to join and receive, `insert` to send and track, with
|
|
725
|
+
`realtime.topic()` as the channel's name. A policy that reads another
|
|
726
|
+
table — a members table — reads it through that table's own select
|
|
727
|
+
policies, so that table needs one too, or every private join is refused.
|
|
728
|
+
Declare a type (`TEXT`, `INTEGER`, `REAL`) on every column a select policy
|
|
729
|
+
reads.
|
|
730
|
+
- Nothing that changed while the connection was away comes again: read again
|
|
731
|
+
when the channel says `SUBSCRIBED`. `BULK_CHANGE` (one request changed over
|
|
732
|
+
1,000 rows or 8 MiB) and `EVENTS_DROPPED` reach `.on("system", {}, …)` with
|
|
733
|
+
`extension: "postgres_changes"`, and both mean: read the table again.
|
|
734
|
+
Changes can arrive out of order, even two writes of one client: order by
|
|
735
|
+
`commit_timestamp`.
|
|
736
|
+
- Every call returns `{ data, error }` and none throws. `subscribe(callback)`
|
|
737
|
+
hears `SUBSCRIBED`, `CHANNEL_ERROR` (with the error), `TIMED_OUT` and
|
|
738
|
+
`CLOSED`. The connection opens at the first `subscribe()` and closes with
|
|
739
|
+
the last channel; a join goes once the server has taken who the connection
|
|
740
|
+
is — on a page a sign-in link or provider sent back to, after that sign-in
|
|
741
|
+
has landed — and a sign-in, a sign-out or a renewed session reaches the
|
|
742
|
+
open connection at once.
|
|
743
|
+
- Make one client per page and share it: a sign-in or sign-out through one
|
|
744
|
+
`createClient()` reaches only its own connection, and another client of the
|
|
745
|
+
same project keeps who it was until its next connection.
|
|
746
|
+
- `send()` on a joined channel waits, as `track()` does, while a sign-in or
|
|
747
|
+
sign-out is on its way, and goes over HTTP if the join went meanwhile. A
|
|
748
|
+
payload over the plan's limit answers `PAYLOAD_TOO_LARGE`; a message whose
|
|
749
|
+
frame is over that limit plus 16 KiB answers `INVALID_MESSAGE`
|
|
750
|
+
(`is larger than N bytes`), and later ones over N on that connection get
|
|
751
|
+
the same answer without being sent.
|
|
752
|
+
- Close codes: 1000 (a plain close, from the server or a proxy), 4000, 4001,
|
|
753
|
+
4003, 4004 and 4410 stop it — call `subscribe()` again; it connects after
|
|
754
|
+
the backoff, counted from the stop, so a `subscribe()` in the
|
|
755
|
+
`CHANNEL_ERROR` callback does not spin. 4029 comes back after 5 seconds at
|
|
756
|
+
least (60 to 70 for `IP_RATE_LIMITED`); 4503 after 0 to 10 seconds, every
|
|
757
|
+
time; anything else after 1, 2, 5 and then 10 seconds, each times 0.5 to
|
|
758
|
+
1.5 — after a 4008 the steps start over only once the connection outlived
|
|
759
|
+
a heartbeat. Removing the last channel and subscribing again keeps the wait
|
|
760
|
+
the server asked for. A 4401 signs out
|
|
761
|
+
only when the server refused the session it sent with `INVALID_SESSION`
|
|
762
|
+
and that session is still the one stored; one without that answer — a tab
|
|
763
|
+
open for days — keeps the session and connects again with it.
|
|
764
|
+
- `unsubscribe()` and `removeChannel()` return the server's first answer to
|
|
765
|
+
the leave. A leave refused for now (`TOO_MANY_JOINS`, `INTERNAL_ERROR`,
|
|
766
|
+
`CONNECTION_STATE_TOO_LARGE`, `TIMED_OUT`) is sent again until the server
|
|
767
|
+
takes it, and a `subscribe()` of the same name and privacy joins after
|
|
768
|
+
that. While such a leave holds its place under the server's caps (4
|
|
769
|
+
channels of a connection on one table's changes, 100 a connection), a join
|
|
770
|
+
of another name refused by one (`INVALID_CHANNEL_CONFIG`,
|
|
771
|
+
`TOO_MANY_CHANNELS`) waits for it and joins, instead of stopping. That can
|
|
772
|
+
happen right after a `removeChannel()`: while your sends wait their turn
|
|
773
|
+
(below), your leave waits behind them, but another channel's join goes
|
|
774
|
+
first, so that join may hear the cap once (`CHANNEL_ERROR`) before it
|
|
775
|
+
joins. Removing the last channel closes the connection: `removeChannel()`
|
|
776
|
+
then answers `{ data: null, error: null }`, and a `send()` or `track()`
|
|
777
|
+
still waiting gets `ABORTED`
|
|
778
|
+
(`The realtime connection was closed by the app (it removed its last channel) before the Naro realtime server answered.`).
|
|
779
|
+
Do not use a removed channel again: `channel()` makes a new one.
|
|
780
|
+
- Presence: `track()` works on a joined channel (`NOT_JOINED` before), with a
|
|
781
|
+
JSON object of up to 10 keys and 1 KiB and no key starting with `naro_` or
|
|
782
|
+
`phx_`: the server answers a bigger one, or one with such a key,
|
|
783
|
+
`PRESENCE_TOO_LARGE`
|
|
784
|
+
(`Presence state must be a JSON object with up to 10 keys and 1 KiB, and no key starting with naro_ or phx_.`),
|
|
785
|
+
and naro-js refuses what is not a JSON object before it is sent
|
|
786
|
+
(`INVALID_MESSAGE`, `needs a presence payload object`). Each entry carries
|
|
787
|
+
`naro_user_id`, the one value the server sets. After a reconnect the last
|
|
788
|
+
state is tracked again for the same session only: after a sign-in, a
|
|
789
|
+
sign-out or a renewed session, call `track()` again in the `SUBSCRIBED`
|
|
790
|
+
callback. The last state is the last `track()` called, even one the
|
|
791
|
+
connection dropped before its answer or that had none in time; a
|
|
792
|
+
`track()` whose join went before it was sent (`NOT_JOINED`) leaves none. While a sign-in or sign-out is on its way, `track()` waits for
|
|
793
|
+
the server to take it, and an `untrack()` behind such a call waits its
|
|
794
|
+
turn, so they go in the order called (if the join goes meanwhile,
|
|
795
|
+
`track()` answers `NOT_JOINED`). The `join` and
|
|
796
|
+
`leave` callbacks get `event`, `key`, `newPresences` or `leftPresences`
|
|
797
|
+
and `currentPresences`, as in supabase-js; a join's `currentPresences` are
|
|
798
|
+
the key's presences before it. A channel that stops lets go of its presences
|
|
799
|
+
with a `leave` for each key and a `sync` before `CHANNEL_ERROR`; only
|
|
800
|
+
`unsubscribe()` clears them without a word. A channel stopped by a close
|
|
801
|
+
that does not reconnect or a join refused for good keeps what it tracked
|
|
802
|
+
for a `subscribe()` again in the same session; one the server closed lets
|
|
803
|
+
go of it — track again in `SUBSCRIBED`.
|
|
804
|
+
- One connection sends 100 frames at once and then 10 a second (the server
|
|
805
|
+
closes one past 120 at once or 10 a second with 4008): more wait their
|
|
806
|
+
turn, so 70 presence channels coming back — a join and a track each —
|
|
807
|
+
take about 4 seconds. The server also closes a connection it refused 10
|
|
808
|
+
times in a minute (`TOO_MANY_JOINS`, `TOO_MANY_MESSAGES`,
|
|
809
|
+
`TOO_MANY_PRESENCE_MESSAGES`): the joins, the tracks sent again and the
|
|
810
|
+
retries naro-js makes of its own go only while those refusals in the last
|
|
811
|
+
minute and the requests sent and still waiting for an answer are fewer
|
|
812
|
+
than 6 — or, while none of naro-js's own waits for an answer, one at a
|
|
813
|
+
time while those refusals alone are fewer than 6, so your requests in
|
|
814
|
+
flight on a slow link do not keep another channel from joining. Your own
|
|
815
|
+
`send()`, `track()`, `untrack()` and `unsubscribe()` are not held for
|
|
816
|
+
refusals; they go after naro-js's own frames, which do not wait behind
|
|
817
|
+
them. A sign-in or sign-out goes before them too, but not before your
|
|
818
|
+
`track()` and `untrack()` still waiting their turn (the server would hold
|
|
819
|
+
that presence for the new user), so behind those it can wait up to
|
|
820
|
+
`timeoutMs`. One that would not go within `timeoutMs` — an app sending
|
|
821
|
+
faster than 10 a second, a cursor on every mousemove — is refused at once
|
|
822
|
+
and not sent: `CLIENT_FLOODING`
|
|
823
|
+
(`This connection is sending messages faster than a connection may (10 a second), so this one was not sent. Send less often.`)
|
|
824
|
+
with `lost: false`; send it again later. A leave is never refused.
|
|
825
|
+
`send()`, `track()` and `untrack()` also answer `lost`: `true` when the
|
|
826
|
+
connection closed before the server answered, so the message may have
|
|
827
|
+
been delivered, or not. When the server closes a connection that sent too
|
|
828
|
+
fast (4008), the calls still waiting answer `CLIENT_FLOODING`
|
|
829
|
+
(`This connection sent messages faster than a connection may.`) with
|
|
830
|
+
`lost: true`: sent again, such a message may arrive twice. Keep such sends
|
|
831
|
+
to 10 a second for all of a client's channels together.
|
|
832
|
+
- Unlike supabase-js: broadcast and presence listeners match one event name
|
|
833
|
+
exactly, case included — `{ event: "*" }` hears nothing; `DELETE` events
|
|
834
|
+
pass the select policies too; a subscription takes one filter (`eq`, `neq`,
|
|
835
|
+
`lt`, `lte`, `gt`, `gte`, `in`); settings naro does not take
|
|
836
|
+
(`broadcast.replay`) are refused with `INVALID_CHANNEL_CONFIG`; `send()` and
|
|
837
|
+
the rest answer `{ data, error }` (`send()`, `track()` and `untrack()` with
|
|
838
|
+
`lost` too), not `'ok' | 'timed out' | 'error'`.
|
|
839
|
+
- The service key receives every change of a published table, whatever the
|
|
840
|
+
policies say: keep it on your server (`createClient` refuses it in a
|
|
841
|
+
browser). A server that writes with `env.DB` broadcasts with
|
|
842
|
+
`POST /_naro/v1/realtime/broadcast` and the API key: up to 100 messages and
|
|
843
|
+
1 MiB, all sent or none (202).
|
|
844
|
+
- `createClient({ realtime: { WebSocket, heartbeatIntervalMs, timeoutMs } })`
|
|
845
|
+
— a heartbeat every 25,000 ms and answers within 10,000 ms by default, each
|
|
846
|
+
in whole milliseconds from 1 to 2,147,483,647 (the longest a timer waits).
|
|
847
|
+
`timeoutMs` counts from when a frame goes; your own frame waits its turn
|
|
848
|
+
for about as long at most, or is refused at once (`CLIENT_FLOODING`).
|
|
849
|
+
Browsers, Deno, Bun, Cloudflare Workers and Node 22 or later have a
|
|
850
|
+
WebSocket; a runtime without one (Node 20) reports `WEBSOCKET_UNAVAILABLE`
|
|
851
|
+
until you pass `ws`'s. naro-js listens for the `error` `ws` emits when a
|
|
852
|
+
connection is closed before it opens, so that close does not end the
|
|
853
|
+
process.
|
|
854
|
+
|
|
855
|
+
```ts
|
|
856
|
+
import WebSocket from "ws";
|
|
857
|
+
|
|
858
|
+
const naro = createClient({ url, anonKey, realtime: { WebSocket } });
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
The whole guide, with the policies, the close codes and the limits:
|
|
862
|
+
https://naro.sh/docs/realtime
|
package/dist/auth/storage.d.ts
CHANGED
|
@@ -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.
|
|
@@ -43,13 +45,14 @@ export declare class TokenStore {
|
|
|
43
45
|
set(token: string): Promise<void>;
|
|
44
46
|
clear(): Promise<void>;
|
|
45
47
|
/**
|
|
46
|
-
* Clears only if the stored token is still the one that was rejected
|
|
47
|
-
* request that left with an old token can come back
|
|
48
|
-
* in again; its INVALID_SESSION must not sign the
|
|
49
|
-
* read and the removal are one operation: with an
|
|
50
|
-
* that lands between them would otherwise be the
|
|
48
|
+
* Clears only if the stored token is still the one that was rejected, and
|
|
49
|
+
* says whether it did. A request that left with an old token can come back
|
|
50
|
+
* after the user signed in again; its INVALID_SESSION must not sign the
|
|
51
|
+
* new session out. The read and the removal are one operation: with an
|
|
52
|
+
* async storage, a sign-in that lands between them would otherwise be the
|
|
53
|
+
* token removed.
|
|
51
54
|
*/
|
|
52
|
-
clearIf(token: string): Promise<
|
|
55
|
+
clearIf(token: string): Promise<boolean>;
|
|
53
56
|
/**
|
|
54
57
|
* Reads the value, hands it to `transform` and stores what that returns
|
|
55
58
|
* (null clears it), as one operation — the per-flow verifiers
|
|
@@ -58,4 +61,13 @@ export declare class TokenStore {
|
|
|
58
61
|
update(transform: (current: string | null) => string | null): Promise<void>;
|
|
59
62
|
/** Stores `next` only if `expected` is still stored, as one operation. */
|
|
60
63
|
replaceIf(expected: string, next: string): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Calls `listener` after every write of the token — a sign-in, a
|
|
66
|
+
* sign-out, a renewed session, a cleared INVALID_SESSION — from inside the
|
|
67
|
+
* write's turn, so a `get()` it starts reads the value just written — or
|
|
68
|
+
* that of a write already queued behind it, which calls the listener
|
|
69
|
+
* again. The Realtime connection (Realtime 3-2d) uses it to tell the hub.
|
|
70
|
+
* What a listener throws is its own: the write has happened and stands.
|
|
71
|
+
*/
|
|
72
|
+
subscribe(listener: () => void): () => void;
|
|
61
73
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ 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 type { RealtimeChannel } from "./realtime/channel.js";
|
|
6
|
+
import type { RealtimeChannelOptions, RealtimeOptions, RealtimeResult } from "./realtime/types.js";
|
|
5
7
|
import { StorageClient } from "./storage/client.js";
|
|
6
8
|
import type { DatabaseShape, InsertOf, RowOf, TableName, TablesOf, UntypedDatabase, UpdateOf } from "./types.js";
|
|
7
9
|
type CommonOptions = {
|
|
@@ -23,6 +25,12 @@ type CommonOptions = {
|
|
|
23
25
|
};
|
|
24
26
|
/** Replaces the global fetch (tests, custom runtimes). */
|
|
25
27
|
fetch?: FetchLike;
|
|
28
|
+
/**
|
|
29
|
+
* Realtime (first-release design 2026-10-16 §9.1, original spec §15):
|
|
30
|
+
* the WebSocket class where the runtime has none, and the heartbeat and
|
|
31
|
+
* answer timings. Optional everywhere a WebSocket is global.
|
|
32
|
+
*/
|
|
33
|
+
realtime?: RealtimeOptions;
|
|
26
34
|
};
|
|
27
35
|
export type ClientOptions = CommonOptions & ({
|
|
28
36
|
anonKey: string;
|
|
@@ -44,6 +52,7 @@ export declare const CONFIG_MESSAGES: {
|
|
|
44
52
|
readonly bothKeys: "Pass createClient an anonKey or a serviceKey, not both.";
|
|
45
53
|
readonly serviceKeyOffServer: "A Naro service key (naro_sk_…) skips every policy, so createClient accepts it only on a server (Node, Deno, Bun, Cloudflare Workers, an edge runtime), never in a browser, a web worker or a React Native app. Use the anon key (naro_pk_…) here and keep the service key on your server.";
|
|
46
54
|
readonly fetch: "This runtime has no fetch. Pass one to createClient({ fetch }).";
|
|
55
|
+
readonly realtime: "createClient's realtime options take a WebSocket class, and heartbeatIntervalMs and timeoutMs in whole milliseconds from 1 to 2147483647.";
|
|
47
56
|
};
|
|
48
57
|
export declare class NaroClient<Database extends DatabaseShape<Database>> {
|
|
49
58
|
#private;
|
|
@@ -61,6 +70,21 @@ export declare class NaroClient<Database extends DatabaseShape<Database>> {
|
|
|
61
70
|
constructor(options: ClientOptions);
|
|
62
71
|
/** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
|
|
63
72
|
from<Table extends TableName<Database>>(table: Table): TableBuilder<RowOf<TablesOf<Database>[Table]>, InsertOf<TablesOf<Database>[Table]>, UpdateOf<TablesOf<Database>[Table]>>;
|
|
73
|
+
/**
|
|
74
|
+
* A Realtime channel (original spec §15): `.on()` listeners, then
|
|
75
|
+
* `.subscribe()`. The same name and privacy give the same channel until
|
|
76
|
+
* `removeChannel`. Nothing connects until the first `subscribe()`.
|
|
77
|
+
*/
|
|
78
|
+
channel(name: string, options?: RealtimeChannelOptions): RealtimeChannel<Database>;
|
|
79
|
+
/** The channels made with `channel()` and not yet removed. */
|
|
80
|
+
getChannels(): RealtimeChannel<Database>[];
|
|
81
|
+
/**
|
|
82
|
+
* Leaves the channel and forgets it, so `channel()` of its name makes a new
|
|
83
|
+
* one; the connection closes with the last channel.
|
|
84
|
+
*/
|
|
85
|
+
removeChannel(channel: RealtimeChannel<Database>): Promise<RealtimeResult>;
|
|
86
|
+
/** `removeChannel()` for every channel; the connection closes. */
|
|
87
|
+
removeAllChannels(): Promise<RealtimeResult>;
|
|
64
88
|
}
|
|
65
89
|
/**
|
|
66
90
|
* The one entry point (spec §15). Throws only for a configuration mistake —
|
package/dist/errors.d.ts
CHANGED
|
@@ -54,7 +54,77 @@ export declare function invalidProvider(): NaroError;
|
|
|
54
54
|
export declare function serviceKeyRequired(): NaroError;
|
|
55
55
|
/** No `crypto.subtle`: an http page that is not localhost. */
|
|
56
56
|
export declare function cryptoUnavailable(): NaroError;
|
|
57
|
+
/** The service key off a server (spec §7); `CONFIG_MESSAGES.serviceKeyOffServer` is this. */
|
|
58
|
+
export declare const SERVICE_KEY_OFF_SERVER_MESSAGE = "A Naro service key (naro_sk_\u2026) skips every policy, so createClient accepts it only on a server (Node, Deno, Bun, Cloudflare Workers, an edge runtime), never in a browser, a web worker or a React Native app. Use the anon key (naro_pk_\u2026) here and keep the service key on your server.";
|
|
59
|
+
/** No WebSocket in this runtime, and none passed to createClient. */
|
|
60
|
+
export declare function websocketUnavailable(): NaroError;
|
|
61
|
+
/**
|
|
62
|
+
* The server did not answer a join, a request or the opening in `ms`. Whole
|
|
63
|
+
* seconds, never fewer than one: a `timeoutMs` under 500 must not read
|
|
64
|
+
* "within 0 seconds".
|
|
65
|
+
*/
|
|
66
|
+
export declare function realtimeTimedOut(ms: number): NaroError;
|
|
67
|
+
/** A drop the connection answers by connecting again. */
|
|
68
|
+
export declare function realtimeClosed(code: number): NaroError;
|
|
69
|
+
/**
|
|
70
|
+
* The app's own close: naro-js closes the connection when its last channel
|
|
71
|
+
* is removed, and what still waited for an answer hears this. Called off,
|
|
72
|
+
* as an upload's `signal` calls it off — not a drop, and nothing reconnects.
|
|
73
|
+
*/
|
|
74
|
+
export declare function realtimeDisconnected(): NaroError;
|
|
75
|
+
/**
|
|
76
|
+
* A close the connection does not answer by connecting again, when the
|
|
77
|
+
* server's `system` frame did not say why first: the reason is the close's
|
|
78
|
+
* own or, with none, NETWORK_ERROR.
|
|
79
|
+
*/
|
|
80
|
+
export declare function realtimeStopped(code: number, reason: string): NaroError;
|
|
81
|
+
/** The WebSocket constructor threw: its message quotes the URL, so only its name is kept. */
|
|
82
|
+
export declare function socketRefused(error: unknown): NaroError;
|
|
83
|
+
/**
|
|
84
|
+
* The hub's INVALID_CHANNEL, raised before sending (`reason` is
|
|
85
|
+
* channelNameProblem's): its words, copied from data-core's catalog.
|
|
86
|
+
*/
|
|
87
|
+
export declare function invalidChannel(reason: string): NaroError;
|
|
88
|
+
/**
|
|
89
|
+
* The hub's INVALID_CHANNEL_CONFIG, raised before sending: for a join
|
|
90
|
+
* config JSON cannot carry (a BigInt, a cycle), which no frame could hold.
|
|
91
|
+
*/
|
|
92
|
+
export declare function invalidChannelConfig(path: string, reason: string): NaroError;
|
|
93
|
+
/** `createClient`'s refusal of a service key off a server, for the call that reaches the socket late. */
|
|
94
|
+
export declare function serviceKeyOffServer(): NaroError;
|
|
95
|
+
/**
|
|
96
|
+
* The hub's NOT_JOINED, raised before sending. A name that passed
|
|
97
|
+
* channelNameProblem has at most 128 code points, which the hub quotes
|
|
98
|
+
* whole (its `realtimeName` cuts at 128), so the two read the same.
|
|
99
|
+
*/
|
|
100
|
+
export declare function notJoined(channel: string): NaroError;
|
|
101
|
+
/** The hub's INVALID_MESSAGE for a `send` that is not a broadcast. */
|
|
102
|
+
export declare function invalidBroadcast(): NaroError;
|
|
103
|
+
/**
|
|
104
|
+
* The hub's INVALID_MESSAGE for a track whose state is not a JSON object,
|
|
105
|
+
* raised before sending: one JSON cannot carry (a BigInt, a cycle) would
|
|
106
|
+
* otherwise read as a connection that could not send.
|
|
107
|
+
*/
|
|
108
|
+
export declare function invalidPresence(): NaroError;
|
|
109
|
+
/**
|
|
110
|
+
* The hub's INVALID_MESSAGE for a frame over its size limit — the plan's
|
|
111
|
+
* broadcast payload plus 16 KiB, in UTF-8 bytes — which it refuses before
|
|
112
|
+
* reading the frame's `ref` (data-core `tooLargeReason`).
|
|
113
|
+
*/
|
|
114
|
+
export declare function frameTooLarge(limit: number): NaroError;
|
|
115
|
+
/**
|
|
116
|
+
* A frame of the app's that would not go within `timeoutMs`, at the rate
|
|
117
|
+
* the hub takes frames from one connection (`perSecond`), behind those
|
|
118
|
+
* still to go: refused here, and not sent (PR #328 re-review). The hub's own
|
|
119
|
+
* word for a connection over that rate is its 4008 close, CLIENT_FLOODING;
|
|
120
|
+
* an app that sends faster than that for long hears this instead.
|
|
121
|
+
*/
|
|
122
|
+
export declare function clientFlooding(perSecond: number): NaroError;
|
|
123
|
+
/** The server closed a channel (`phx_close`) without saying why first. */
|
|
124
|
+
export declare function channelClosed(): NaroError;
|
|
57
125
|
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
126
|
+
/** `{ code, message, details? }` with nothing else; null if it is not one. */
|
|
127
|
+
export declare function asError(value: unknown): NaroError | null;
|
|
58
128
|
/**
|
|
59
129
|
* The error a response carries, or null when it carries none. Every error
|
|
60
130
|
* under `/_naro/v1` comes as `{ data: null, error: { code, message,
|
package/dist/http.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ 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 and Realtime routes send the session token
|
|
19
|
+
* in `tokens` (`storeFor`).
|
|
20
|
+
* Without it (not signed in) those requests go as anon.
|
|
21
|
+
*/
|
|
22
|
+
accessJwt?: TokenStore;
|
|
16
23
|
};
|
|
17
24
|
export type CallOptions = {
|
|
18
25
|
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
@@ -20,6 +27,11 @@ export type CallOptions = {
|
|
|
20
27
|
path: string;
|
|
21
28
|
/** Sent as JSON. */
|
|
22
29
|
body?: unknown;
|
|
30
|
+
/**
|
|
31
|
+
* Headers beside `apikey` and the JSON type — a signed upload's
|
|
32
|
+
* `x-naro-upload-token` (Storage 3-1e-2). `apikey` is always the client's.
|
|
33
|
+
*/
|
|
34
|
+
headers?: Record<string, string>;
|
|
23
35
|
/** Whether to send the stored session token. */
|
|
24
36
|
withToken: boolean;
|
|
25
37
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,9 @@ 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
8
|
export { objectRoute, type Encoded as ObjectRouteResult, } from "./storage/path.js";
|
|
9
|
-
export type {
|
|
9
|
+
export type { RealtimeChannel } from "./realtime/channel.js";
|
|
10
|
+
export type { PostgresChangesEvent, PostgresChangesFilter, RealtimeBroadcastMessage, RealtimeChannelOptions, RealtimeChannelState, RealtimeOptions, RealtimePostgresChangesPayload, RealtimePresenceEntry, RealtimePresenceJoinPayload, RealtimePresenceLeavePayload, RealtimePresenceState, RealtimeResult, RealtimeSendResult, RealtimeStatus, RealtimeStatusCallback, RealtimeSystemMessage, WebSocketConstructor, } from "./realtime/types.js";
|
|
11
|
+
export type { CopyOptions, CreateSignedUploadUrlOptions, DownloadOption, FileObject, FolderEntry, ListOptions, MoveOptions, MoveResult, SignedUploadResult, SignedUploadUrl, SignedUrlEntry, UpdateOptions, UploadBody, UploadOptions, UploadResult, } from "./storage/types.js";
|
|
10
12
|
export type { ArrayValues, FilterGroup, FilterGroupCallback, InsertBuilder, MatchQuery, MutationBuilder, RawFilterValue, SelectBuilder, TableBuilder, } from "./query/builder.js";
|
|
11
13
|
export { quoteFilterValue, type FilterOperatorName } from "./query/filters.js";
|
|
12
14
|
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";
|