@protobase/server 0.26.0 → 0.26.2

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.
@@ -270,27 +270,25 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
270
270
  app.route("/", batchRoute(deps, authenticate, meta));
271
271
  app.route("/", fileRoutes(deps, authenticate));
272
272
  if (forms) app.route(`${systemPath}/forms`, formRoutes(deps, forms));
273
- if (appModels.some((model) => model.live)) {
274
- const report = options.onUnhandledError ?? ((error) => console.error(error));
275
- const feed = tableFeed({
276
- db,
277
- schema: changes,
273
+ const report = options.onUnhandledError ?? ((error) => console.error(error));
274
+ const feed = tableFeed({
275
+ db,
276
+ schema: changes,
277
+ report
278
+ });
279
+ if (appModels.some((model) => model.live)) app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
280
+ if (upgradeWebSocket && (apiKeys.publishable || apiKeys.secret)) {
281
+ const verify = verifyToken ?? (auth ? jwtVerifier(betterAuthTokens({ auth })) : () => Promise.reject(unauthorized("This app signs no user tokens")));
282
+ app.route("/realtime/v1", realtimeApp({
283
+ deps,
284
+ feed,
285
+ upgradeWebSocket,
286
+ caller: apiKeyCaller({
287
+ keys: apiKeys,
288
+ verify
289
+ }),
278
290
  report
279
- });
280
- app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
281
- if (upgradeWebSocket && (apiKeys.publishable || apiKeys.secret)) {
282
- const verify = verifyToken ?? (auth ? jwtVerifier(betterAuthTokens({ auth })) : () => Promise.reject(unauthorized("This app signs no user tokens")));
283
- app.route("/realtime/v1", realtimeApp({
284
- deps,
285
- feed,
286
- upgradeWebSocket,
287
- caller: apiKeyCaller({
288
- keys: apiKeys,
289
- verify
290
- }),
291
- report
292
- }));
293
- }
291
+ }));
294
292
  }
295
293
  app.route(basePath, api);
296
294
  app.route(`${deps.systemPath}/meta`, metaApp);
@@ -0,0 +1,71 @@
1
+ //#region src/realtime/broadcast-frame.ts
2
+ const userBroadcastPush = 3;
3
+ const userBroadcast = 4;
4
+ const binaryEncoding = 0;
5
+ const jsonEncoding = 1;
6
+ const decoder = new TextDecoder();
7
+ const encoder = new TextEncoder();
8
+ /**
9
+ * A binary frame as the `broadcast` push it carries: a header of kind, the byte lengths of join ref, ref, topic, user
10
+ * event and metadata, and the payload's encoding, then those fields and the payload. The metadata is dropped, as only
11
+ * keys the server knows go in it. `undefined` for a frame of another kind or one cut short.
12
+ */
13
+ const decodeBroadcastPush = (frame) => {
14
+ const bytes = new Uint8Array(frame);
15
+ if (bytes.length < 7 || bytes[0] !== userBroadcastPush) return void 0;
16
+ const [joinRefSize, refSize, topicSize, eventSize, metadataSize, encoding] = bytes.subarray(1, 7);
17
+ let offset = 7;
18
+ const field = (size) => decoder.decode(bytes.subarray(offset, offset += size));
19
+ const joinRef = field(joinRefSize);
20
+ const ref = field(refSize);
21
+ const topic = field(topicSize);
22
+ const event = field(eventSize);
23
+ offset += metadataSize;
24
+ if (offset > bytes.length) return void 0;
25
+ const body = frame.slice(offset);
26
+ let payload = body;
27
+ if (encoding === jsonEncoding) try {
28
+ payload = JSON.parse(decoder.decode(body));
29
+ } catch (error) {
30
+ if (error instanceof SyntaxError) return void 0;
31
+ throw error;
32
+ }
33
+ return {
34
+ joinRef: joinRef || null,
35
+ ref: ref || null,
36
+ topic,
37
+ event: "broadcast",
38
+ payload: {
39
+ type: "broadcast",
40
+ event,
41
+ payload
42
+ }
43
+ };
44
+ };
45
+ /** Whether a topic or event fits a binary frame's one-byte length. */
46
+ const fitsFrame = (text) => encoder.encode(text).length <= 255;
47
+ /**
48
+ * A broadcast as the binary frame realtime-js reads: kind, topic, user event and metadata lengths, encoding, fields,
49
+ * payload. A payload is JSON, or the bytes of a binary broadcast.
50
+ */
51
+ const encodeBroadcast = (topic, event, payload) => {
52
+ const topicBytes = encoder.encode(topic);
53
+ const eventBytes = encoder.encode(event);
54
+ const binary = payload instanceof ArrayBuffer;
55
+ const body = binary ? new Uint8Array(payload) : encoder.encode(JSON.stringify(payload ?? {}));
56
+ if (topicBytes.length > 255 || eventBytes.length > 255) throw new Error("A broadcast topic and event are at most 255 bytes each");
57
+ const frame = new Uint8Array(5 + topicBytes.length + eventBytes.length + body.length);
58
+ frame.set([
59
+ userBroadcast,
60
+ topicBytes.length,
61
+ eventBytes.length,
62
+ 0,
63
+ binary ? binaryEncoding : jsonEncoding
64
+ ]);
65
+ frame.set(topicBytes, 5);
66
+ frame.set(eventBytes, 5 + topicBytes.length);
67
+ frame.set(body, 5 + topicBytes.length + eventBytes.length);
68
+ return frame.buffer;
69
+ };
70
+ //#endregion
71
+ export { decodeBroadcastPush, encodeBroadcast, fitsFrame };
@@ -0,0 +1,25 @@
1
+ //#region src/realtime/broadcast-hub.ts
2
+ /**
3
+ * The channels of a process by topic, which hear each other's broadcasts in-process: a cloud app runs as one process,
4
+ * so channels of other processes are not reached.
5
+ */
6
+ const broadcastHub = () => {
7
+ const topics = /* @__PURE__ */ new Map();
8
+ return {
9
+ /** Puts the member on the topic until the returned function takes it off. */
10
+ join: (topic, member) => {
11
+ const members = topics.get(topic) ?? /* @__PURE__ */ new Set();
12
+ topics.set(topic, members.add(member));
13
+ return () => {
14
+ members.delete(member);
15
+ if (members.size === 0 && topics.get(topic) === members) topics.delete(topic);
16
+ };
17
+ },
18
+ /** Sends a broadcast to the topic's members, the sender only when it hears its own. */
19
+ send: (topic, from, event, payload) => {
20
+ for (const member of topics.get(topic) ?? []) if (member !== from || member.self) member.receive(event, payload);
21
+ }
22
+ };
23
+ };
24
+ //#endregion
25
+ export { broadcastHub };
@@ -0,0 +1,59 @@
1
+ //#region src/realtime/presence-hub.ts
2
+ const stateOf = (tracked) => {
3
+ const state = Object.create(null);
4
+ for (const [key, meta] of tracked) (state[key] ??= { metas: [] }).metas.push(meta);
5
+ return state;
6
+ };
7
+ /**
8
+ * The presences of a process by topic, in-process like broadcasts: each joined channel tracks at most one, under its
9
+ * key, and channels that listen hear every join and leave on their topic, their own included, as Phoenix Presence
10
+ * sends them. Tracking again replaces the presence: a leave of the old one and a join of the new. Refs count up, unique
11
+ * in the process.
12
+ */
13
+ const presenceHub = () => {
14
+ const topics = /* @__PURE__ */ new Map();
15
+ let lastRef = 0;
16
+ const tell = (members, diff) => {
17
+ for (const member of members.keys()) if (member.listens) member.receive(diff);
18
+ };
19
+ return {
20
+ /** Puts the member on the topic, with what it may do there until it leaves. */
21
+ join: (topic, member) => {
22
+ const members = topics.get(topic) ?? /* @__PURE__ */ new Map();
23
+ topics.set(topic, members.set(member, void 0));
24
+ const untrack = () => {
25
+ const old = members.get(member);
26
+ if (!old) return;
27
+ members.set(member, void 0);
28
+ tell(members, {
29
+ joins: {},
30
+ leaves: stateOf([[member.key, old]])
31
+ });
32
+ };
33
+ return {
34
+ /** The topic's presences now, for `presence_state`. */
35
+ state: () => stateOf([...members].flatMap(([{ key }, meta]) => meta ? [[key, meta]] : [])),
36
+ track: (payload) => {
37
+ const old = members.get(member);
38
+ const meta = {
39
+ ...payload,
40
+ phx_ref: String(++lastRef),
41
+ ...old && { phx_ref_prev: old.phx_ref }
42
+ };
43
+ members.set(member, meta);
44
+ tell(members, {
45
+ joins: stateOf([[member.key, meta]]),
46
+ leaves: old ? stateOf([[member.key, old]]) : {}
47
+ });
48
+ },
49
+ untrack,
50
+ leave: () => {
51
+ untrack();
52
+ members.delete(member);
53
+ if (members.size === 0 && topics.get(topic) === members) topics.delete(topic);
54
+ }
55
+ };
56
+ } };
57
+ };
58
+ //#endregion
59
+ export { presenceHub };
@@ -4,7 +4,7 @@ import { realtimeRoutes } from "./realtime-routes.js";
4
4
  //#region src/realtime/realtime-app.ts
5
5
  /**
6
6
  * `/realtime/v1`: the `postgres_changes` of the live resources, each checked under the subscriber's role, claims and
7
- * the tables' RLS policies before it is sent.
7
+ * the tables' RLS policies before it is sent, and broadcasts and presence between the channels of the process.
8
8
  */
9
9
  const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report }) => {
10
10
  const live = deps.registry.entries.filter((entry) => entry.model.live);
@@ -1,5 +1,7 @@
1
1
  import { HttpProblem } from "../problem.js";
2
+ import { broadcastHub } from "./broadcast-hub.js";
2
3
  import { vsnOf } from "./phoenix-message.js";
4
+ import { presenceHub } from "./presence-hub.js";
3
5
  import { realtimeSocket } from "./realtime-socket.js";
4
6
  import { Hono } from "hono";
5
7
  //#region src/realtime/realtime-routes.ts
@@ -9,10 +11,13 @@ const keyRequest = (apikey, token) => new Request("http://realtime.invalid/", {
9
11
  } });
10
12
  /**
11
13
  * `GET /realtime/v1/websocket?apikey=…&vsn=…`: Supabase Realtime's socket, for `postgres_changes` of the live
12
- * resources (see `realtimeSocket`). A missing or wrong key is refused before the upgrade, with 401.
14
+ * resources, and broadcasts and presence between the sockets of this process (see `realtimeSocket`). A missing or wrong
15
+ * key is refused before the upgrade, with 401.
13
16
  */
14
17
  const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
15
18
  const app = new Hono();
19
+ const broadcasts = broadcastHub();
20
+ const presences = presenceHub();
16
21
  let lastId = 0;
17
22
  const nextId = () => ++lastId;
18
23
  app.get("/websocket", async (c, next) => {
@@ -31,12 +36,14 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
31
36
  resolve: (token) => caller(keyRequest(apikey, token)),
32
37
  live,
33
38
  hub,
39
+ broadcasts,
40
+ presences,
34
41
  nextId,
35
42
  report
36
43
  });
37
44
  },
38
45
  onMessage: (event) => {
39
- if (typeof event.data === "string") socket?.message(event.data);
46
+ if (typeof event.data === "string" || event.data instanceof ArrayBuffer) socket?.message(event.data);
40
47
  },
41
48
  onClose: () => socket?.closed()
42
49
  };
@@ -1,6 +1,8 @@
1
1
  import { HttpProblem } from "../problem.js";
2
2
  import { parseBindings } from "./postgres-changes.js";
3
3
  import { decodeMessage, encodeMessage, reply } from "./phoenix-message.js";
4
+ import { decodeBroadcastPush, encodeBroadcast, fitsFrame } from "./broadcast-frame.js";
5
+ import { randomUUID } from "node:crypto";
4
6
  //#region src/realtime/realtime-socket.ts
5
7
  const tokenOf = (payload) => typeof payload.access_token === "string" && payload.access_token !== "" ? payload.access_token : void 0;
6
8
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -10,11 +12,11 @@ const refusal = (error) => {
10
12
  };
11
13
  /**
12
14
  * One WebSocket of `/realtime/v1`, speaking Phoenix channels as realtime-js expects: heartbeats, channel joins with
13
- * `postgres_changes` bindings and an access token, token refreshes on an open channel, and leaves. A channel's token
14
- * ends it when it expires, unless a newer one came first. Messages are handled one at a time, in order. Broadcast,
15
- * presence and private channels are refused for now.
15
+ * `postgres_changes` bindings and an access token, token refreshes on an open channel, broadcasts with their `self`
16
+ * and `ack` options, presence tracking, and leaves. A channel's token ends it when it expires, unless a newer one came
17
+ * first. Messages are handled one at a time, in order. Private channels are refused for now.
16
18
  */
17
- const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report, idleMs = 6e4, now = Date.now }) => {
19
+ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, broadcasts, presences, nextId, report, idleMs = 6e4, now = Date.now }) => {
18
20
  const channels = /* @__PURE__ */ new Map();
19
21
  let queue = Promise.resolve();
20
22
  let idle;
@@ -26,6 +28,15 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report,
26
28
  event,
27
29
  payload
28
30
  });
31
+ const hear = (topic, channel, event, payload) => {
32
+ if (vsn === "2.0.0") return send(encodeBroadcast(topic, event, payload));
33
+ if (payload instanceof ArrayBuffer) return;
34
+ toChannel(topic, channel, "broadcast", {
35
+ type: "broadcast",
36
+ event,
37
+ payload
38
+ });
39
+ };
29
40
  const leave = (topic) => {
30
41
  const channel = channels.get(topic);
31
42
  if (!channel) return;
@@ -54,24 +65,46 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report,
54
65
  leave(message.topic);
55
66
  const config = isRecord(message.payload.config) ? message.payload.config : {};
56
67
  if (config.private === true) return push(reply(message, "error", { reason: "Not implemented: private channels" }));
57
- if (isRecord(config.presence) && config.presence.enabled === true) return push(reply(message, "error", { reason: "Not implemented: presence" }));
68
+ const broadcast = isRecord(config.broadcast) ? config.broadcast : {};
69
+ const presence = isRecord(config.presence) ? config.presence : {};
58
70
  const caller = await resolve(tokenOf(message.payload)).catch(refusal);
59
71
  if (typeof caller === "string") return push(reply(message, "error", { reason: caller }));
60
72
  const parsed = parseBindings(config.postgres_changes, live, nextId);
61
73
  if (typeof parsed === "string") return push(reply(message, "error", { reason: parsed }));
74
+ const { topic } = message;
75
+ const member = {
76
+ self: broadcast.self === true,
77
+ receive: (event, payload) => hear(topic, channel, event, payload)
78
+ };
79
+ const key = typeof presence.key === "string" && presence.key !== "" ? presence.key : randomUUID();
80
+ const listens = presence.enabled === true;
81
+ const seat = presences.join(topic, {
82
+ key,
83
+ listens,
84
+ receive: (diff) => toChannel(topic, channel, "presence_diff", diff)
85
+ });
86
+ const offBroadcast = broadcasts.join(topic, member);
87
+ const offChanges = parsed.bindings.length > 0 ? hub.add({
88
+ caller: () => channel.caller,
89
+ bindings: parsed.bindings,
90
+ send: (payload) => toChannel(topic, channel, "postgres_changes", payload)
91
+ }) : () => {};
62
92
  const channel = {
63
93
  joinRef: message.joinRef,
64
94
  caller,
65
- remove: () => {}
95
+ ack: broadcast.ack === true,
96
+ member,
97
+ presence: seat,
98
+ remove: () => {
99
+ offChanges();
100
+ offBroadcast();
101
+ seat.leave();
102
+ }
66
103
  };
67
- if (parsed.bindings.length > 0) channel.remove = hub.add({
68
- caller: () => channel.caller,
69
- bindings: parsed.bindings,
70
- send: (payload) => toChannel(message.topic, channel, "postgres_changes", payload)
71
- });
72
- channels.set(message.topic, channel);
73
- expireAt(message.topic, channel);
104
+ channels.set(topic, channel);
105
+ expireAt(topic, channel);
74
106
  push(reply(message, "ok", { postgres_changes: parsed.replies }));
107
+ if (listens) toChannel(topic, channel, "presence_state", seat.state());
75
108
  if (parsed.bindings.length > 0) toChannel(message.topic, channel, "system", {
76
109
  extension: "postgres_changes",
77
110
  status: "ok",
@@ -87,14 +120,31 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report,
87
120
  channel.caller = caller;
88
121
  expireAt(message.topic, channel);
89
122
  };
123
+ const sendBroadcast = (message, channel) => {
124
+ const { event, payload } = message.payload;
125
+ if (typeof event !== "string") return push(reply(message, "error", { reason: "A broadcast needs an event" }));
126
+ if (!fitsFrame(message.topic) || !fitsFrame(event)) return push(reply(message, "error", { reason: "A broadcast topic and event are at most 255 bytes each" }));
127
+ broadcasts.send(message.topic, channel.member, event, payload);
128
+ if (channel.ack) push(reply(message, "ok", {}));
129
+ };
130
+ const updatePresence = (message, channel) => {
131
+ const { event, payload } = message.payload;
132
+ if (event === "track" && isRecord(payload)) channel.presence.track(payload);
133
+ else if (event === "untrack") channel.presence.untrack();
134
+ else return push(reply(message, "error", { reason: "A presence message tracks an object or untracks" }));
135
+ push(reply(message, "ok", {}));
136
+ };
90
137
  const handle = async (message) => {
91
138
  if (message.topic === "phoenix") {
92
139
  if (message.event === "heartbeat") push(reply(message, "ok", {}));
93
140
  return;
94
141
  }
95
142
  if (message.event === "phx_join") return join(message);
96
- if (!channels.has(message.topic)) return push(reply(message, "error", { reason: "unmatched topic" }));
143
+ const channel = channels.get(message.topic);
144
+ if (!channel) return push(reply(message, "error", { reason: "unmatched topic" }));
97
145
  if (message.event === "access_token") return refresh(message);
146
+ if (message.event === "broadcast") return sendBroadcast(message, channel);
147
+ if (message.event === "presence") return updatePresence(message, channel);
98
148
  if (message.event === "phx_leave") {
99
149
  leave(message.topic);
100
150
  push(reply(message, "ok", {}));
@@ -106,7 +156,7 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report,
106
156
  payload: {}
107
157
  });
108
158
  }
109
- push(reply(message, "error", { reason: `Not implemented: ${message.event}; this server has postgres_changes only` }));
159
+ push(reply(message, "error", { reason: `Not implemented: ${message.event}` }));
110
160
  };
111
161
  const stayAwake = () => {
112
162
  clearTimeout(idle);
@@ -114,10 +164,10 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report,
114
164
  };
115
165
  stayAwake();
116
166
  return {
117
- /** Handles a text frame after those before it. */
167
+ /** Handles a frame after those before it: text, or binary for a broadcast. */
118
168
  message: (frame) => {
119
169
  stayAwake();
120
- const message = decodeMessage(vsn, frame);
170
+ const message = typeof frame === "string" ? decodeMessage(vsn, frame) : decodeBroadcastPush(frame);
121
171
  if (!message) return;
122
172
  queue = queue.then(() => handle(message)).catch((error) => {
123
173
  report(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -32,9 +32,9 @@
32
32
  "dependencies": {
33
33
  "@better-auth/passkey": "^1.7.7",
34
34
  "@better-auth/sso": "^1.7.7",
35
- "@protobase/layout": "0.26.0",
36
- "@protobase/query": "0.26.0",
37
- "@protobase/schema": "0.26.0",
35
+ "@protobase/layout": "0.26.2",
36
+ "@protobase/query": "0.26.2",
37
+ "@protobase/schema": "0.26.2",
38
38
  "@react-email/render": "^2.1.0",
39
39
  "@scalar/hono-api-reference": "^0.12.10",
40
40
  "@sentry/core": "^11.7.0",