@protobase/server 0.51.2 → 0.51.3

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/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.51.2";
2
+ var version = "0.51.3";
3
3
  //#endregion
4
4
  export { version };
@@ -46,25 +46,33 @@ const decodeBroadcastPush = (frame) => {
46
46
  const fitsFrame = (text) => encoder.encode(text).length <= 255;
47
47
  /**
48
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.
49
+ * metadata as JSON, payload. A payload is JSON, or the bytes of a binary broadcast.
50
50
  */
51
- const encodeBroadcast = (topic, event, payload) => {
51
+ const encodeBroadcast = (topic, event, payload, meta) => {
52
52
  const topicBytes = encoder.encode(topic);
53
53
  const eventBytes = encoder.encode(event);
54
+ const metaBytes = encoder.encode(meta === void 0 ? "" : JSON.stringify(meta));
54
55
  const binary = payload instanceof ArrayBuffer;
55
56
  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);
57
+ if (topicBytes.length > 255 || eventBytes.length > 255 || metaBytes.length > 255) throw new Error("A broadcast topic, event and metadata are at most 255 bytes each");
58
+ const frame = new Uint8Array(5 + topicBytes.length + eventBytes.length + metaBytes.length + body.length);
58
59
  frame.set([
59
60
  userBroadcast,
60
61
  topicBytes.length,
61
62
  eventBytes.length,
62
- 0,
63
+ metaBytes.length,
63
64
  binary ? binaryEncoding : jsonEncoding
64
65
  ]);
65
- frame.set(topicBytes, 5);
66
- frame.set(eventBytes, 5 + topicBytes.length);
67
- frame.set(body, 5 + topicBytes.length + eventBytes.length);
66
+ let offset = 5;
67
+ for (const part of [
68
+ topicBytes,
69
+ eventBytes,
70
+ metaBytes,
71
+ body
72
+ ]) {
73
+ frame.set(part, offset);
74
+ offset += part.length;
75
+ }
68
76
  return frame.buffer;
69
77
  };
70
78
  //#endregion
@@ -1,5 +1,5 @@
1
- import { realtimeLimits } from "./realtime-limits.js";
2
1
  import { fixedWindows } from "./fixed-window.js";
2
+ import { realtimeLimits } from "./realtime-limits.js";
3
3
  //#region src/realtime/broadcast-hub.ts
4
4
  /**
5
5
  * The channels of a process by topic, which hear each other's broadcasts in-process: a cloud app runs as one process,
@@ -40,7 +40,7 @@ const broadcastHub = ({ now = () => Date.now() } = {}) => {
40
40
  const takes = /* @__PURE__ */ new Map();
41
41
  for (const { topic } of publications) takes.set(topic, (takes.get(topic) ?? 0) + 1);
42
42
  if (!rate.all(takes)) return false;
43
- for (const publication of publications) for (const member of topicsOf(publication.private).get(publication.topic) ?? []) member.receive(publication.event, publication.payload);
43
+ for (const publication of publications) for (const member of topicsOf(publication.private).get(publication.topic) ?? []) member.receive(publication.event, publication.payload, publication.meta);
44
44
  return true;
45
45
  }
46
46
  };
@@ -1,6 +1,6 @@
1
1
  import { HttpProblem } from "../problem.js";
2
- import { fitsFrame } from "./broadcast-frame.js";
3
2
  import { realtimeLimits } from "./realtime-limits.js";
3
+ import { fitsFrame } from "./broadcast-frame.js";
4
4
  //#region src/realtime/broadcast-request.ts
5
5
  /** A refused broadcast request; `message` too, which realtime-js's `httpSend` reads as the error. */
6
6
  const broadcastProblem = (status, slug, title, detail) => new HttpProblem(status, slug, title, detail, { message: detail });
@@ -0,0 +1,83 @@
1
+ import { writeTransaction } from "../transactions.js";
2
+ import { realtimeLimits } from "./realtime-limits.js";
3
+ import { fitsFrame } from "./broadcast-frame.js";
4
+ import { sql } from "kysely";
5
+ import { Buffer } from "node:buffer";
6
+ //#region src/realtime/database-broadcasts.ts
7
+ /** Messages taken in one transaction at most. */
8
+ const batchSize = 1e3;
9
+ /**
10
+ * The message as the channels joined on its topic hear it, with its id as `meta`, as Supabase sends it; `undefined` for
11
+ * one Supabase would skip, or whose topic or event does not fit a frame or whose payload is over a frame.
12
+ */
13
+ const publicationOfRow = (row) => {
14
+ if (row.topic === null || row.event === null || row.payload === null || row.private === null) return void 0;
15
+ const topic = `realtime:${row.topic}`;
16
+ if (!fitsFrame(topic) || !fitsFrame(row.event)) return void 0;
17
+ if (Buffer.byteLength(JSON.stringify(row.payload)) > realtimeLimits.maxFrameBytes) return void 0;
18
+ return {
19
+ topic,
20
+ event: row.event,
21
+ payload: row.payload,
22
+ private: row.private,
23
+ meta: { id: row.id }
24
+ };
25
+ };
26
+ const takeMessages = (db, statementTimeoutMs) => writeTransaction(db, statementTimeoutMs, async (trx) => {
27
+ const { rows } = await sql`with taken as (
28
+ delete from realtime.messages where id in (
29
+ select id from realtime.messages where extension = 'broadcast' order by inserted_at limit ${batchSize} for update skip locked
30
+ ) returning id, topic, event, payload, private, inserted_at
31
+ )
32
+ select id::text as id, topic, event, payload, private from taken order by inserted_at`.execute(trx);
33
+ return rows;
34
+ });
35
+ const tableExists = async (db) => (await sql`select to_regclass('realtime.messages') is not null as found`.execute(db)).rows[0].found;
36
+ /**
37
+ * Supabase's broadcast from the database: `realtime.send()` and `realtime.broadcast_changes()` insert a message into
38
+ * `realtime.messages` in the caller's transaction, so only a committed one is seen, and while a socket of this process
39
+ * is open the table is read every `intervalMs`, its messages taken out of it and sent to the channels joined on their
40
+ * topics, the private or the public ones, under the topic's broadcast rate. A message nobody hears is dropped, as on
41
+ * Supabase: the first read when a socket opens takes those sent while none was. A database without
42
+ * `realtime.messages` sends nothing, and is checked again at each read.
43
+ */
44
+ const databaseBroadcasts = ({ db, statementTimeoutMs, broadcasts, intervalMs = 1e3, report }) => {
45
+ let followers = 0;
46
+ let running = false;
47
+ const read = async () => {
48
+ if (!await tableExists(db)) return;
49
+ for (;;) {
50
+ const rows = await takeMessages(db, statementTimeoutMs);
51
+ for (const row of rows) {
52
+ const publication = publicationOfRow(row);
53
+ if (publication) broadcasts.publish([publication]);
54
+ }
55
+ if (rows.length < batchSize) return;
56
+ }
57
+ };
58
+ const tick = async () => {
59
+ if (followers === 0) {
60
+ running = false;
61
+ return;
62
+ }
63
+ await read().catch(report);
64
+ setTimeout(() => void tick(), intervalMs);
65
+ };
66
+ return {
67
+ /** Reads the table from now on, until the returned function is called; once for each call, however often. */
68
+ follow: () => {
69
+ followers += 1;
70
+ if (!running) {
71
+ running = true;
72
+ tick();
73
+ }
74
+ let followed = true;
75
+ return () => {
76
+ if (!followed) return;
77
+ followed = false;
78
+ followers -= 1;
79
+ };
80
+ } };
81
+ };
82
+ //#endregion
83
+ export { databaseBroadcasts, publicationOfRow };
@@ -1,12 +1,15 @@
1
+ import { broadcastHub } from "./broadcast-hub.js";
1
2
  import { changeChecker } from "./change-checker.js";
2
3
  import { changeHub } from "./change-hub.js";
3
4
  import { channelAccess } from "./channel-access.js";
5
+ import { databaseBroadcasts } from "./database-broadcasts.js";
4
6
  import { realtimeRoutes } from "./realtime-routes.js";
5
7
  //#region src/realtime/realtime-app.ts
6
8
  /**
7
9
  * `/api/sb/realtime/v1`: the `postgres_changes` of the live resources, each checked under the subscriber's role, claims and
8
10
  * the tables' RLS policies before it is sent, and broadcasts and presence between the channels of the process, on a
9
- * private channel as the policies on `realtime.messages` allow.
11
+ * private channel as the policies on `realtime.messages` allow, and the broadcasts the database sends with
12
+ * `realtime.send()`.
10
13
  */
11
14
  const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report }) => {
12
15
  const live = deps.registry.entries.filter((entry) => entry.model.live);
@@ -25,11 +28,20 @@ const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report }) => {
25
28
  db: deps.db,
26
29
  statementTimeoutMs: deps.statementTimeoutMs
27
30
  });
31
+ const broadcasts = broadcastHub();
32
+ const database = databaseBroadcasts({
33
+ db: deps.db,
34
+ statementTimeoutMs: deps.statementTimeoutMs,
35
+ broadcasts,
36
+ report
37
+ });
28
38
  return realtimeRoutes({
29
39
  upgradeWebSocket,
30
40
  caller,
31
41
  live,
32
42
  hub,
43
+ broadcasts,
44
+ database,
33
45
  access,
34
46
  report
35
47
  });
@@ -1,6 +1,5 @@
1
1
  import { HttpProblem } from "../problem.js";
2
2
  import { broadcastApi } from "./broadcast-api.js";
3
- import { broadcastHub } from "./broadcast-hub.js";
4
3
  import { bufferedOf } from "./buffered-amount.js";
5
4
  import { vsnOf } from "./phoenix-message.js";
6
5
  import { presenceHub } from "./presence-hub.js";
@@ -16,11 +15,11 @@ const keyRequest = (apikey, token) => new Request("http://realtime.invalid/", {
16
15
  * resources, and broadcasts and presence between the sockets of this process (see `realtimeSocket`). A missing or wrong
17
16
  * key is refused before the upgrade, with 401. A socket whose raw socket is not one of npm `ws`, such as Bun's own `ws`
18
17
  * shim's, is closed at once with 1011, since its unsent bytes cannot be counted (`realtimeLimits.maxUnsentBytes`).
19
- * `POST /api/sb/realtime/v1/api/broadcast` sends to the same channels without a socket (see `broadcastApi`).
18
+ * `POST /api/sb/realtime/v1/api/broadcast` sends to the same channels without a socket (see `broadcastApi`), and so
19
+ * does the database (see `databaseBroadcasts`).
20
20
  */
21
- const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, access, report }) => {
21
+ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, broadcasts, database, access, report }) => {
22
22
  const app = new Hono();
23
- const broadcasts = broadcastHub();
24
23
  const presences = presenceHub();
25
24
  let lastId = 0;
26
25
  const nextId = () => ++lastId;
@@ -37,12 +36,18 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, access, report })
37
36
  }, upgradeWebSocket((c) => {
38
37
  const apikey = c.req.query("apikey");
39
38
  let socket;
39
+ let unfollow = () => {};
40
+ const closed = () => {
41
+ unfollow();
42
+ socket?.closed();
43
+ };
40
44
  return {
41
45
  onOpen: (_event, ws) => {
42
46
  if (bufferedOf(ws.raw) === void 0) {
43
47
  report(/* @__PURE__ */ new Error("Not implemented: realtime needs WebSockets of npm ws, which count unsent bytes; on Bun, load npm ws by its path rather than Bun's ws shim, or run on Node"));
44
48
  return ws.close(1011);
45
49
  }
50
+ unfollow = database.follow();
46
51
  socket = realtimeSocket({
47
52
  vsn: vsnOf(c.req.query("vsn")),
48
53
  send: (frame) => ws.send(frame),
@@ -59,8 +64,8 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, access, report })
59
64
  });
60
65
  },
61
66
  onMessage: (event) => socket?.message(event.data),
62
- onError: () => socket?.closed(),
63
- onClose: () => socket?.closed()
67
+ onError: closed,
68
+ onClose: closed
64
69
  };
65
70
  }));
66
71
  return app;
@@ -1,8 +1,8 @@
1
1
  import { HttpProblem } from "../problem.js";
2
+ import { fixedWindow, fixedWindows } from "./fixed-window.js";
3
+ import { realtimeLimits } from "./realtime-limits.js";
2
4
  import { parseBindings } from "./postgres-changes.js";
3
5
  import { decodeBroadcastPush, encodeBroadcast, fitsFrame } from "./broadcast-frame.js";
4
- import { realtimeLimits } from "./realtime-limits.js";
5
- import { fixedWindow, fixedWindows } from "./fixed-window.js";
6
6
  import { decodeMessage, encodeMessage, reply } from "./phoenix-message.js";
7
7
  import { privateGate, publicGate, readRefusalText } from "./channel-gate.js";
8
8
  import { randomUUID } from "node:crypto";
@@ -72,14 +72,15 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
72
72
  event,
73
73
  payload
74
74
  });
75
- const hear = (topic, channel, event, payload) => {
75
+ const hear = (topic, channel, event, payload, meta) => {
76
76
  if (!channel.gate.hears("broadcast")) return;
77
- if (vsn === "2.0.0") return deliver(encodeBroadcast(topic, event, payload));
77
+ if (vsn === "2.0.0") return deliver(encodeBroadcast(topic, event, payload, meta));
78
78
  if (payload instanceof ArrayBuffer) return;
79
79
  toChannel(topic, channel, "broadcast", {
80
80
  type: "broadcast",
81
81
  event,
82
- payload
82
+ payload,
83
+ ...meta && { meta }
83
84
  });
84
85
  };
85
86
  const leave = (topic) => {
@@ -128,7 +129,7 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
128
129
  const member = {
129
130
  self: broadcast.self === true,
130
131
  private: config.private === true,
131
- receive: (event, payload) => hear(topic, channel, event, payload)
132
+ receive: (event, payload, meta) => hear(topic, channel, event, payload, meta)
132
133
  };
133
134
  const key = typeof presence.key === "string" && presence.key !== "" ? presence.key : randomUUID();
134
135
  const seat = presences.join(topic, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.51.2",
3
+ "version": "0.51.3",
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": {
@@ -40,9 +40,9 @@
40
40
  "@better-auth/passkey": "^1.7.12",
41
41
  "@better-auth/sso": "^1.7.12",
42
42
  "@modelcontextprotocol/server": "^2.3.1",
43
- "@protobase/layout": "0.51.2",
44
- "@protobase/query": "0.51.2",
45
- "@protobase/schema": "0.51.2",
43
+ "@protobase/layout": "0.51.3",
44
+ "@protobase/query": "0.51.3",
45
+ "@protobase/schema": "0.51.3",
46
46
  "@react-email/render": "^2.1.0",
47
47
  "@scalar/hono-api-reference": "^0.12.10",
48
48
  "@sentry/core": "^11.7.0",