@protobase/client 0.58.8 → 0.59.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.
@@ -0,0 +1,13 @@
1
+ //#region src/channel-connection.d.ts
2
+ /** The part of the browser's `WebSocket` a connection uses, which a test hands its own of. */
3
+ export type SocketLike = {
4
+ readonly readyState: number;
5
+ send(data: string): void;
6
+ close(code?: number): void;
7
+ onopen: ((event: unknown) => void) | null;
8
+ onmessage: ((event: {
9
+ data: unknown;
10
+ }) => void) | null;
11
+ onclose: ((event: unknown) => void) | null;
12
+ };
13
+ //#endregion
@@ -0,0 +1,113 @@
1
+ import { ChannelRefusal } from "./channel-types.js";
2
+ //#region src/channel-connection.ts
3
+ /** How often the client pings, the server's `channelBounds.pingMs`; no pong in twice that drops the connection. */
4
+ const pingMs = 5e3;
5
+ const openState = 1;
6
+ const protocol = "protobase.channels";
7
+ /**
8
+ * The one socket of a client's channels: opened on `start`, connected again after a pause that grows with each failure
9
+ * while it is wanted, closed on `stop`. It pings every `pingMs`, hands the server a token that changed, and drops a
10
+ * socket that answered no ping in twice that, as one whose network went away.
11
+ */
12
+ const channelConnection = ({ url, token, socket: open, retryDelay, onOpen, onDown, onMessage }) => {
13
+ let socket;
14
+ let wanted = false;
15
+ let failures = 0;
16
+ let lastRef = 0;
17
+ let sentToken;
18
+ let heardAt = 0;
19
+ let ping;
20
+ let retry;
21
+ const pending = /* @__PURE__ */ new Map();
22
+ const dropPending = () => {
23
+ for (const { reject } of pending.values()) reject(new ChannelRefusal("disconnected", "The connection dropped before the server answered"));
24
+ pending.clear();
25
+ };
26
+ const handover = async (current) => {
27
+ const fresh = await token?.();
28
+ if (socket !== current || current.readyState !== openState || fresh === void 0 || fresh === sentToken) return;
29
+ sentToken = fresh;
30
+ current.send(JSON.stringify({
31
+ type: "token",
32
+ token: fresh
33
+ }));
34
+ };
35
+ const connect = async () => {
36
+ const offered = await token?.();
37
+ if (!wanted || socket) return;
38
+ sentToken = offered;
39
+ const current = open(url(), offered === void 0 ? [protocol] : [protocol, `bearer.${offered}`]);
40
+ socket = current;
41
+ current.onopen = () => {
42
+ failures = 0;
43
+ heardAt = Date.now();
44
+ ping = setInterval(() => {
45
+ if (Date.now() - heardAt > 1e4) return current.close();
46
+ current.send(JSON.stringify({ type: "ping" }));
47
+ handover(current);
48
+ }, pingMs);
49
+ onOpen();
50
+ };
51
+ current.onmessage = ({ data }) => {
52
+ heardAt = Date.now();
53
+ if (typeof data !== "string") return;
54
+ const message = JSON.parse(data);
55
+ if (message.type !== "reply") return onMessage(message);
56
+ const waiting = pending.get(message.ref);
57
+ pending.delete(message.ref);
58
+ if (message.ok) waiting?.resolve(message);
59
+ else waiting?.reject(new ChannelRefusal(message.error.code, message.error.message));
60
+ };
61
+ current.onclose = () => {
62
+ if (socket !== current) return;
63
+ clearInterval(ping);
64
+ socket = void 0;
65
+ dropPending();
66
+ if (!wanted) return;
67
+ onDown();
68
+ retry = setTimeout(() => {
69
+ retry = void 0;
70
+ connect();
71
+ }, retryDelay(failures++));
72
+ };
73
+ };
74
+ const sendNow = (message) => {
75
+ if (socket?.readyState !== openState) return false;
76
+ socket.send(JSON.stringify(message));
77
+ return true;
78
+ };
79
+ return {
80
+ /** Opens the socket, unless it is open or opening. */
81
+ start: () => {
82
+ wanted = true;
83
+ if (!socket && retry === void 0) connect();
84
+ },
85
+ /** Closes the socket for good, until the next `start`. */
86
+ stop: () => {
87
+ wanted = false;
88
+ clearTimeout(retry);
89
+ retry = void 0;
90
+ clearInterval(ping);
91
+ const current = socket;
92
+ socket = void 0;
93
+ dropPending();
94
+ current?.close(1e3);
95
+ },
96
+ /** Sends a message with a ref, and resolves with its reply, or rejects with its refusal. */
97
+ request: (message) => new Promise((resolve, reject) => {
98
+ const ref = String(++lastRef);
99
+ if (!sendNow({
100
+ ...message,
101
+ ref
102
+ })) return reject(new ChannelRefusal("disconnected", "The connection is down"));
103
+ pending.set(ref, {
104
+ resolve,
105
+ reject
106
+ });
107
+ }),
108
+ /** Sends a message that gets no reply; `false` when the socket is not open. */
109
+ post: sendNow
110
+ };
111
+ };
112
+ //#endregion
113
+ export { channelConnection, pingMs };
@@ -0,0 +1,44 @@
1
+ //#region src/channel-types.d.ts
2
+ /** Who sent a broadcast or tracks a presence, stamped by the server from their session: a user's id, `null` for a visitor. */
3
+ export type ChannelUser = string | number | null;
4
+ /** One connection's presence on a channel: its id, the same while it is tracked, its user and the state it tracks. */
5
+ export type PresenceEntry = {
6
+ id: string;
7
+ user: ChannelUser;
8
+ state: unknown;
9
+ };
10
+ /** What changed in a channel's presence: connections that started tracking, changed their state, or left. */
11
+ export type PresenceChange = {
12
+ joins: PresenceEntry[];
13
+ updates: PresenceEntry[];
14
+ leaves: PresenceEntry[];
15
+ };
16
+ /** Why the server refused a message or ended a channel, such as `not-found` for a record the caller may not read. */
17
+ export type ChannelError = {
18
+ code: string;
19
+ message: string;
20
+ };
21
+ /** A broadcast as a channel hears it: with `server`, the app's own code sent it, and `user` is `null`. */
22
+ export type ChannelMessage = {
23
+ event: string;
24
+ payload: unknown;
25
+ user: ChannelUser;
26
+ server?: true;
27
+ };
28
+ /**
29
+ * Where a channel stands: `joining`, also while the connection is down; `joined`; `refused`, never joined, as for a
30
+ * record there is none of or the caller may not read; `closed`, left by the server, as once the caller may no longer
31
+ * read the record. A refused or closed channel is not joined again: leave it and take a new one.
32
+ */
33
+ export type ChannelStatus = {
34
+ state: 'joining' | 'joined';
35
+ } | {
36
+ state: 'refused' | 'closed';
37
+ error: ChannelError;
38
+ };
39
+ /** A refusal of the server, or `disconnected` for a message whose connection dropped before its reply. */
40
+ export declare class ChannelRefusal extends Error {
41
+ readonly code: string;
42
+ constructor(code: string, message: string);
43
+ }
44
+ //#endregion
@@ -0,0 +1,12 @@
1
+ //#region src/channel-types.ts
2
+ /** A refusal of the server, or `disconnected` for a message whose connection dropped before its reply. */
3
+ var ChannelRefusal = class extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = "ChannelRefusal";
9
+ }
10
+ };
11
+ //#endregion
12
+ export { ChannelRefusal };
@@ -0,0 +1,29 @@
1
+ import { ChannelMessage, ChannelStatus, PresenceChange, PresenceEntry } from "./channel-types.js";
2
+ import { KeyValue } from "@protobase/schema";
3
+ //#region src/channels.d.ts
4
+ /** A record's channel as one caller of `client.channel` holds it; `leave` lets it go. */
5
+ export type Channel = {
6
+ status: () => ChannelStatus;
7
+ /** Follows the status until the returned function is called. */
8
+ onStatus: (listener: (status: ChannelStatus) => void) => () => void;
9
+ /** The channel's presence now: every connection on it that tracks a state, this one included. */
10
+ presence: () => PresenceEntry[];
11
+ /** Follows the presence until the returned function is called: the whole of it, and what changed when a diff did it. */
12
+ onPresence: (listener: (entries: PresenceEntry[], change?: PresenceChange) => void) => () => void;
13
+ /** Tracks this connection's state on the channel, or replaces it; applied once joined, and again after a reconnect. */
14
+ track: (state: unknown) => Promise<void>;
15
+ untrack: () => Promise<void>;
16
+ /**
17
+ * Broadcasts to the others on the channel; with `self` this connection hears it too. With `ack` it resolves once the
18
+ * server took it, or rejects with its `ChannelRefusal`; without, once it was sent.
19
+ */
20
+ send: (event: string, payload: unknown, options?: {
21
+ self?: boolean;
22
+ ack?: boolean;
23
+ }) => Promise<void>;
24
+ /** Hears the channel's broadcasts of `event` until the returned function is called. */
25
+ on: (event: string, listener: (message: ChannelMessage) => void) => () => void;
26
+ /** Lets the channel go; the connection leaves it once every holder did, and closes once it holds no channel. */
27
+ leave: () => void;
28
+ };
29
+ //#endregion
@@ -0,0 +1,204 @@
1
+ import { ChannelRefusal } from "./channel-types.js";
2
+ import { channelConnection } from "./channel-connection.js";
3
+ import { encodeKey } from "@protobase/schema/internal";
4
+ //#region src/channels.ts
5
+ const unlessDropped = (error) => {
6
+ if (error instanceof ChannelRefusal && error.code === "disconnected") return;
7
+ throw error;
8
+ };
9
+ const notJoined = () => new ChannelRefusal("not-joined", "The channel is not joined yet");
10
+ /**
11
+ * The client's channels of records (`/api/channels`), all on one socket, opened with the first channel and closed
12
+ * after the last. A dropped socket connects again by itself, joins every channel again and tracks their states again.
13
+ */
14
+ const channelsClient = (options) => {
15
+ const joined = /* @__PURE__ */ new Map();
16
+ const byName = /* @__PURE__ */ new Map();
17
+ const setStatus = (channel, status) => {
18
+ channel.status = status;
19
+ for (const listener of channel.statusListeners) listener(status);
20
+ };
21
+ const tellPresence = (channel, change) => {
22
+ const entries = [...channel.presence.values()];
23
+ for (const listener of channel.presenceListeners) listener(entries, change);
24
+ };
25
+ const join = (channel) => {
26
+ connection.request({
27
+ type: "join",
28
+ resource: channel.resource,
29
+ key: channel.key
30
+ }).then((reply) => {
31
+ if (joined.get(`${channel.resource}/${channel.key}`) !== channel) return;
32
+ channel.name = reply.channel;
33
+ byName.set(channel.name, channel);
34
+ setStatus(channel, { state: "joined" });
35
+ if (channel.tracked) connection.request({
36
+ type: "track",
37
+ channel: channel.name,
38
+ state: channel.tracked.state
39
+ }).catch(unlessDropped);
40
+ }).catch((error) => {
41
+ if (!(error instanceof ChannelRefusal) || error.code === "disconnected") return unlessDropped(error);
42
+ setStatus(channel, {
43
+ state: "refused",
44
+ error: {
45
+ code: error.code,
46
+ message: error.message
47
+ }
48
+ });
49
+ });
50
+ };
51
+ const hear = (message) => {
52
+ if (message.type === "pong" || message.type === "reply" || message.type === "error") return;
53
+ const channel = byName.get(message.channel);
54
+ if (!channel) return;
55
+ if (message.type === "presence") {
56
+ channel.presence = new Map(message.entries.map((entry) => [entry.id, entry]));
57
+ return tellPresence(channel);
58
+ }
59
+ if (message.type === "presence_diff") {
60
+ for (const entry of message.leaves) channel.presence.delete(entry.id);
61
+ for (const entry of [...message.joins, ...message.updates]) channel.presence.set(entry.id, entry);
62
+ return tellPresence(channel, {
63
+ joins: message.joins,
64
+ updates: message.updates,
65
+ leaves: message.leaves
66
+ });
67
+ }
68
+ if (message.type === "broadcast") {
69
+ const { event, payload, user, server } = message;
70
+ for (const listener of channel.eventListeners.get(event) ?? []) listener({
71
+ event,
72
+ payload,
73
+ user,
74
+ ...server && { server }
75
+ });
76
+ return;
77
+ }
78
+ byName.delete(message.channel);
79
+ channel.presence.clear();
80
+ tellPresence(channel);
81
+ setStatus(channel, {
82
+ state: "closed",
83
+ error: message.error
84
+ });
85
+ };
86
+ const connection = channelConnection({
87
+ ...options,
88
+ onOpen: () => {
89
+ for (const channel of joined.values()) if (channel.status.state === "joining") join(channel);
90
+ },
91
+ onDown: () => {
92
+ byName.clear();
93
+ for (const channel of joined.values()) if (channel.status.state === "joined") setStatus(channel, { state: "joining" });
94
+ },
95
+ onMessage: hear
96
+ });
97
+ const release = (id, channel) => {
98
+ channel.holders -= 1;
99
+ if (channel.holders > 0) return;
100
+ joined.delete(id);
101
+ if (channel.name !== void 0) {
102
+ byName.delete(channel.name);
103
+ connection.post({
104
+ type: "leave",
105
+ channel: channel.name
106
+ });
107
+ }
108
+ if (joined.size === 0) connection.stop();
109
+ };
110
+ const handleOf = (id, channel) => {
111
+ let left = false;
112
+ const own = /* @__PURE__ */ new Set();
113
+ const listen = (set, listener) => {
114
+ set.add(listener);
115
+ const off = () => {
116
+ set.delete(listener);
117
+ own.delete(off);
118
+ };
119
+ own.add(off);
120
+ return off;
121
+ };
122
+ const joinedName = () => channel.status.state === "joined" ? channel.name : void 0;
123
+ return {
124
+ status: () => channel.status,
125
+ onStatus: (listener) => listen(channel.statusListeners, listener),
126
+ presence: () => [...channel.presence.values()],
127
+ onPresence: (listener) => listen(channel.presenceListeners, listener),
128
+ track: async (state) => {
129
+ channel.tracked = { state };
130
+ const name = joinedName();
131
+ if (name !== void 0) await connection.request({
132
+ type: "track",
133
+ channel: name,
134
+ state
135
+ });
136
+ },
137
+ untrack: async () => {
138
+ channel.tracked = void 0;
139
+ const name = joinedName();
140
+ if (name !== void 0) await connection.request({
141
+ type: "untrack",
142
+ channel: name
143
+ });
144
+ },
145
+ send: async (event, payload, { self, ack } = {}) => {
146
+ const name = joinedName();
147
+ if (name === void 0) throw notJoined();
148
+ const message = {
149
+ type: "send",
150
+ channel: name,
151
+ event,
152
+ payload,
153
+ ...self && { self }
154
+ };
155
+ if (ack) {
156
+ await connection.request({
157
+ ...message,
158
+ ack
159
+ });
160
+ return;
161
+ }
162
+ if (!connection.post(message)) throw new ChannelRefusal("disconnected", "The connection is down");
163
+ },
164
+ on: (event, listener) => {
165
+ const listeners = channel.eventListeners.get(event) ?? /* @__PURE__ */ new Set();
166
+ channel.eventListeners.set(event, listeners);
167
+ return listen(listeners, listener);
168
+ },
169
+ leave: () => {
170
+ if (left) return;
171
+ left = true;
172
+ for (const off of [...own]) off();
173
+ release(id, channel);
174
+ }
175
+ };
176
+ };
177
+ return {
178
+ /** The channel of the record with this key, joined as soon as the socket is open. */
179
+ channel: (resource, key) => {
180
+ const encoded = encodeKey(key);
181
+ const id = `${resource}/${encoded}`;
182
+ const existing = joined.get(id);
183
+ if (existing) {
184
+ existing.holders += 1;
185
+ return handleOf(id, existing);
186
+ }
187
+ const channel = {
188
+ resource,
189
+ key: encoded,
190
+ status: { state: "joining" },
191
+ presence: /* @__PURE__ */ new Map(),
192
+ holders: 1,
193
+ statusListeners: /* @__PURE__ */ new Set(),
194
+ presenceListeners: /* @__PURE__ */ new Set(),
195
+ eventListeners: /* @__PURE__ */ new Map()
196
+ };
197
+ joined.set(id, channel);
198
+ connection.start();
199
+ join(channel);
200
+ return handleOf(id, channel);
201
+ } };
202
+ };
203
+ //#endregion
204
+ export { channelsClient };
package/dist/client.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Channel } from "./channels.js";
1
2
  import { ClientOptions } from "./transport.js";
2
3
  import { RegisteredDevice, WebDeviceRegistration } from "./devices.js";
3
4
  import { InboxPage } from "./notification-history.js";
@@ -97,6 +98,7 @@ export declare const createClient: (options?: ClientOptions) => {
97
98
  formChallenge: (name: string) => Promise<FormChallenge>;
98
99
  submitForm: (name: string, values: Record<string, unknown>, proof: FormProof) => Promise<void>;
99
100
  live: (handlers: LiveHandlers) => () => void;
101
+ channel: (resource: string, key: KeyValue | readonly KeyValue[]) => Channel;
100
102
  devices: {
101
103
  register: (device: WebDeviceRegistration) => Promise<RegisteredDevice>;
102
104
  remove: (id: string) => Promise<void>;
package/dist/client.js CHANGED
@@ -3,11 +3,18 @@ import { originOf, photosWithOrigin, withFileOrigin } from "./file-urls.js";
3
3
  import { createTransport } from "./transport.js";
4
4
  import { fetchWithProgress } from "./upload-request.js";
5
5
  import { followLive } from "./live.js";
6
+ import { sharedLive } from "./shared-live.js";
7
+ import { channelsClient } from "./channels.js";
6
8
  import { devicesClient } from "./devices.js";
7
9
  import { notificationHistoryClient } from "./notification-history.js";
8
10
  import { encodeKey } from "@protobase/schema/internal";
9
11
  //#region src/client.ts
10
12
  const nameOf = (resource) => typeof resource === "string" ? resource : resource.toModel().name;
13
+ const channelsUrl = (baseUrl = "/api/v1") => {
14
+ const url = new URL(`${baseUrl.replace(/\/[^/]*$/, "")}/channels`, globalThis.location?.href);
15
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
16
+ return url.toString();
17
+ };
11
18
  const keyPath = (resource, key) => `/${nameOf(resource)}/${encodeKey(key)}`;
12
19
  const toPage = (body, response, origin) => ({
13
20
  items: body.items.map((item) => withFileOrigin(item, origin)),
@@ -412,7 +419,17 @@ const createClient = (options = {}) => {
412
419
  * Hears which records of the `.live()` resources the caller may read change, as they do, until the returned function
413
420
  * is called (`GET /api/live`); it connects again by itself.
414
421
  */
415
- const live = (handlers) => followLive(send, handlers);
422
+ const live = sharedLive((handlers) => followLive(send, handlers));
423
+ /**
424
+ * The channel of a record (`/api/channels`): who else is on it and what they track (presence), and the events they
425
+ * send (broadcasts), for whoever may read the record. Every channel of the client shares one socket.
426
+ */
427
+ const { channel } = channelsClient({
428
+ url: () => channelsUrl(options.baseUrl),
429
+ ...options.token && { token: () => options.token({ refresh: false }) },
430
+ socket: options.webSocket ?? ((url, protocols) => new WebSocket(url, protocols)),
431
+ retryDelay: (attempt) => Math.min(3e4, 1e3 * 2 ** attempt)
432
+ });
416
433
  return {
417
434
  list,
418
435
  search,
@@ -441,6 +458,7 @@ const createClient = (options = {}) => {
441
458
  formChallenge,
442
459
  submitForm,
443
460
  live,
461
+ channel,
444
462
  devices: devicesClient(send),
445
463
  notifications: notificationHistoryClient(send)
446
464
  };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { ChannelError, ChannelMessage, ChannelRefusal, ChannelStatus, ChannelUser, PresenceChange, PresenceEntry } from "./channel-types.js";
2
+ import { SocketLike } from "./channel-connection.js";
3
+ import { Channel } from "./channels.js";
1
4
  import { ClientOptions } from "./transport.js";
2
5
  import { DevicesClient, RegisteredDevice, WebDeviceRegistration } from "./devices.js";
3
6
  import { InboxNotification, InboxPage, NotificationHistoryClient } from "./notification-history.js";
@@ -24,4 +27,4 @@ import { AuthSession, AuthSessionOptions, createAuthSession, tokenExpiry } from
24
27
  import { createStaticSession } from "./static-session.js";
25
28
  import { AssistantClient, AssistantClientOptions, AssistantConnection, createAssistantClient } from "./assistant.js";
26
29
  import { RuntimeClient, RuntimeClientOptions, RuntimeLastUpdate, RuntimePolicy, RuntimeStatus, RuntimeUpdateStage, createRuntimeClient } from "./runtime.js";
27
- export { type AccountSecurity, type AccountSignIn, type ActiveRole, type Aggregate, type AggregateGroup, type AggregateParams, type AggregateSplitTotal, ApiError, type AppUser, type AssistantClient, type AssistantClientOptions, type AssistantConnection, AuthError, type AuthSession, type AuthSessionOptions, type AuthUser, type AuthenticatorSetup, type BatchOp, type BatchRef, type BatchResult, type Client, type ClientOptions, type CommentPage, type CommentPerson, type ConnectClient, type ConnectedApp, type ConnectingClient, type CurrentOrganization, type DeviceSession, type DevicesClient, type Facet, type FilterInput, type FormChallenge, type FormProof, type Histogram, type HistogramBucket, type HistoryChange, type HistoryPage, type InboxNotification, type InboxPage, type InvitationPreview, type InvitationSent, type InvokeOptions, type ListPage, type ListParams, type LiveChange, type LiveHandlers, type MapBounds, type MapBucket, type MapLayerModel, type MapParams, type MapResult, type MentionableUser, type Meta, type MetaResult, type NotificationHistoryClient, type Organization, type OrganizationInvitation, type OrganizationMember, type OrganizationPerson, type OrganizationRole, type OrganizationsClient, type OrganizationsFound, type Passkey, type PlatformSignIn, type PluginRef, PreconditionFailedError, type ProblemDetails, type ProblemError, type PublicForm, type RecordComment, type RecordOf, type RecordPermissions, type RecordPhotos, type RegisteredDevice, type RelatedLabels, type RequiredSetup, type ResourcePermissions, type ResourceRef, type RoleLabel, type RoleOption, type RolesClient, type RuntimeClient, type RuntimeClientOptions, type RuntimeLastUpdate, type RuntimePolicy, type RuntimeStatus, type RuntimeUpdateStage, type SamlSignIn, type Series, type SeriesParams, type SeriesPoint, type SetupStatus, type SignInMethod, type SignInPolicy, type SignInPolicyClient, type SignInPolicyState, type SignInResult, type SignInRule, type SourceMeta, type SourceRef, type StaffAccessRule, type StaffSignIn, type StaffSignInClient, type Stored, type StoredFile, type StoredFileVariant, type TwoFactorMethod, type UploadOptions, type UploadProgress, type UploadResult, type UserInvitation, type UserInvitationSent, type UsersClient, type UsersFound, type WebDeviceRegistration, createAssistantClient, createAuthSession, createClient, createRuntimeClient, createStaticSession, fileUrl, filterText, isApiError, listWire, routedClient, sourceMeta, tokenExpiry };
30
+ export { type AccountSecurity, type AccountSignIn, type ActiveRole, type Aggregate, type AggregateGroup, type AggregateParams, type AggregateSplitTotal, ApiError, type AppUser, type AssistantClient, type AssistantClientOptions, type AssistantConnection, AuthError, type AuthSession, type AuthSessionOptions, type AuthUser, type AuthenticatorSetup, type BatchOp, type BatchRef, type BatchResult, type Channel, type ChannelError, type ChannelMessage, ChannelRefusal, type ChannelStatus, type ChannelUser, type Client, type ClientOptions, type CommentPage, type CommentPerson, type ConnectClient, type ConnectedApp, type ConnectingClient, type CurrentOrganization, type DeviceSession, type DevicesClient, type Facet, type FilterInput, type FormChallenge, type FormProof, type Histogram, type HistogramBucket, type HistoryChange, type HistoryPage, type InboxNotification, type InboxPage, type InvitationPreview, type InvitationSent, type InvokeOptions, type ListPage, type ListParams, type LiveChange, type LiveHandlers, type MapBounds, type MapBucket, type MapLayerModel, type MapParams, type MapResult, type MentionableUser, type Meta, type MetaResult, type NotificationHistoryClient, type Organization, type OrganizationInvitation, type OrganizationMember, type OrganizationPerson, type OrganizationRole, type OrganizationsClient, type OrganizationsFound, type Passkey, type PlatformSignIn, type PluginRef, PreconditionFailedError, type PresenceChange, type PresenceEntry, type ProblemDetails, type ProblemError, type PublicForm, type RecordComment, type RecordOf, type RecordPermissions, type RecordPhotos, type RegisteredDevice, type RelatedLabels, type RequiredSetup, type ResourcePermissions, type ResourceRef, type RoleLabel, type RoleOption, type RolesClient, type RuntimeClient, type RuntimeClientOptions, type RuntimeLastUpdate, type RuntimePolicy, type RuntimeStatus, type RuntimeUpdateStage, type SamlSignIn, type Series, type SeriesParams, type SeriesPoint, type SetupStatus, type SignInMethod, type SignInPolicy, type SignInPolicyClient, type SignInPolicyState, type SignInResult, type SignInRule, type SocketLike, type SourceMeta, type SourceRef, type StaffAccessRule, type StaffSignIn, type StaffSignInClient, type Stored, type StoredFile, type StoredFileVariant, type TwoFactorMethod, type UploadOptions, type UploadProgress, type UploadResult, type UserInvitation, type UserInvitationSent, type UsersClient, type UsersFound, type WebDeviceRegistration, createAssistantClient, createAuthSession, createClient, createRuntimeClient, createStaticSession, fileUrl, filterText, isApiError, listWire, routedClient, sourceMeta, tokenExpiry };
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { filterText, listWire } from "./query-string.js";
2
2
  import { fileUrl } from "./file-urls.js";
3
3
  import { ApiError, PreconditionFailedError, isApiError } from "./problem.js";
4
+ import { ChannelRefusal } from "./channel-types.js";
4
5
  import { createClient } from "./client.js";
5
6
  import { routedClient } from "./routed-client.js";
6
7
  import { sourceMeta } from "./source-meta.js";
@@ -9,4 +10,4 @@ import { createAuthSession, tokenExpiry } from "./auth.js";
9
10
  import { createStaticSession } from "./static-session.js";
10
11
  import { createAssistantClient } from "./assistant.js";
11
12
  import { createRuntimeClient } from "./runtime.js";
12
- export { ApiError, AuthError, PreconditionFailedError, createAssistantClient, createAuthSession, createClient, createRuntimeClient, createStaticSession, fileUrl, filterText, isApiError, listWire, routedClient, sourceMeta, tokenExpiry };
13
+ export { ApiError, AuthError, ChannelRefusal, PreconditionFailedError, createAssistantClient, createAuthSession, createClient, createRuntimeClient, createStaticSession, fileUrl, filterText, isApiError, listWire, routedClient, sourceMeta, tokenExpiry };
@@ -40,6 +40,7 @@ const routedClient = (own, sourceOf) => {
40
40
  formChallenge: (name) => own.formChallenge(name),
41
41
  submitForm: (name, values, proof) => own.submitForm(name, values, proof),
42
42
  live: (handlers) => own.live(handlers),
43
+ channel: (resource, key) => own.channel(resource, key),
43
44
  devices: own.devices,
44
45
  notifications: own.notifications
45
46
  };
@@ -0,0 +1,29 @@
1
+ //#region src/shared-live.ts
2
+ /**
3
+ * `client.live` for every caller of one client on one stream: the first caller opens it, each hears every event, and
4
+ * the last to stop closes it. A page with many live views then holds one connection however many there are, which a
5
+ * browser's six per host under HTTP/1.1 need.
6
+ */
7
+ const sharedLive = (follow) => {
8
+ const subscribers = /* @__PURE__ */ new Set();
9
+ let stop;
10
+ const each = (call) => {
11
+ for (const handlers of [...subscribers]) call(handlers);
12
+ };
13
+ return (handlers) => {
14
+ const subscriber = { ...handlers };
15
+ subscribers.add(subscriber);
16
+ stop ??= follow({
17
+ onChanges: (changes) => each((one) => one.onChanges(changes)),
18
+ onResync: () => each((one) => one.onResync()),
19
+ onRetry: (error) => each((one) => one.onRetry?.(error))
20
+ });
21
+ return () => {
22
+ if (!subscribers.delete(subscriber) || subscribers.size > 0) return;
23
+ stop?.();
24
+ stop = void 0;
25
+ };
26
+ };
27
+ };
28
+ //#endregion
29
+ export { sharedLive };
@@ -1,3 +1,4 @@
1
+ import { SocketLike } from "./channel-connection.js";
1
2
  //#region src/transport.d.ts
2
3
  export type ClientOptions = {
3
4
  /** Where `/api/v1` lives, without a trailing slash. Defaults to `/api/v1` on the current origin. */
@@ -15,6 +16,8 @@ export type ClientOptions = {
15
16
  }) => Promise<string | undefined>;
16
17
  /** Called when a request is still 401 after the retry: the session is over. */
17
18
  onUnauthenticated?: () => void;
19
+ /** Opens the socket of `client.channel`; the global `WebSocket` by default. */
20
+ webSocket?: (url: string, protocols: string[]) => SocketLike;
18
21
  };
19
22
  export type Request = {
20
23
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/client",
3
- "version": "0.58.8",
3
+ "version": "0.59.0",
4
4
  "description": "Typed fetch client for the Protobase REST API.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -31,8 +31,8 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@better-auth/passkey": "^1.7.12",
34
- "@protobase/layout": "0.58.8",
35
- "@protobase/schema": "0.58.8",
34
+ "@protobase/layout": "0.59.0",
35
+ "@protobase/schema": "0.59.0",
36
36
  "@simplewebauthn/browser": "^13.3.0",
37
37
  "better-auth": "^1.7.12"
38
38
  }