@protobase/server 0.51.1 → 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 +1 -1
- package/dist/realtime/broadcast-api.js +66 -0
- package/dist/realtime/broadcast-frame.js +16 -8
- package/dist/realtime/broadcast-hub.js +18 -3
- package/dist/realtime/broadcast-request.js +43 -0
- package/dist/realtime/database-broadcasts.js +83 -0
- package/dist/realtime/fixed-window.js +5 -4
- package/dist/realtime/presence-hub.js +4 -2
- package/dist/realtime/realtime-app.js +13 -1
- package/dist/realtime/realtime-routes.js +18 -5
- package/dist/realtime/realtime-socket.js +9 -6
- package/package.json +4 -4
package/dist/package.js
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isDatabaseError } from "../error-handler.js";
|
|
2
|
+
import { realtimeLimits } from "./realtime-limits.js";
|
|
3
|
+
import { batchOf, broadcastProblem, publicationOf } from "./broadcast-request.js";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { bodyLimit } from "hono/body-limit";
|
|
6
|
+
//#region src/realtime/broadcast-api.ts
|
|
7
|
+
const tooFast = () => broadcastProblem(429, "too-many-broadcasts", "Too Many Requests", "Too many messages per second on this topic");
|
|
8
|
+
const typeOf = (c) => c.req.header("content-type")?.split(";")[0]?.trim().toLowerCase();
|
|
9
|
+
const parsedJson = async (c) => {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(await c.req.text());
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error instanceof SyntaxError) throw broadcastProblem(400, "invalid-json", "Bad Request", "The body is not JSON");
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* `POST /api/broadcast` and `POST /api/broadcast/:topic/events/:event`, Supabase Realtime's broadcast API, which
|
|
19
|
+
* supabase-js's `send()` on a channel that has not joined and `httpSend()` call: they send to the channels joined on a
|
|
20
|
+
* topic in this process, as a broadcast sent on the socket, under the same topic rate. A request is admitted by the
|
|
21
|
+
* app's key and the token in its bearer, as the socket is, and its body is at most a socket's frame. A private message
|
|
22
|
+
* reaches private channels only, and only when the caller may send broadcasts on its topic by the policies on
|
|
23
|
+
* `realtime.messages`: the batch drops one it may not, as Supabase does, and the single message is refused with 403.
|
|
24
|
+
*/
|
|
25
|
+
const broadcastApi = ({ caller, broadcasts, access, report }) => {
|
|
26
|
+
const app = new Hono();
|
|
27
|
+
const admitted = async (c, next) => {
|
|
28
|
+
c.set("caller", await caller(c.req.raw));
|
|
29
|
+
await next();
|
|
30
|
+
};
|
|
31
|
+
const limited = bodyLimit({
|
|
32
|
+
maxSize: realtimeLimits.maxFrameBytes,
|
|
33
|
+
onError: () => {
|
|
34
|
+
throw broadcastProblem(413, "too-large", "Payload Too Large", `A broadcast request is at most ${realtimeLimits.maxFrameBytes} bytes`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
const mayWrite = (who, topic) => access.write(who, topic.replace(/^realtime:/, ""), "broadcast").catch((error) => {
|
|
38
|
+
if (!isDatabaseError(error)) throw error;
|
|
39
|
+
report(error);
|
|
40
|
+
return false;
|
|
41
|
+
});
|
|
42
|
+
const allowed = async (who, publications) => {
|
|
43
|
+
const topics = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const { topic } of publications.filter((publication) => publication.private)) if (!topics.has(topic)) topics.set(topic, await mayWrite(who, topic));
|
|
45
|
+
return publications.filter((publication) => !publication.private || topics.get(publication.topic));
|
|
46
|
+
};
|
|
47
|
+
app.post("/", admitted, limited, async (c) => {
|
|
48
|
+
if (typeOf(c) !== "application/json") throw broadcastProblem(415, "json-only", "Unsupported Media Type", "Send the messages as application/json");
|
|
49
|
+
const publications = await allowed(c.var.caller, batchOf(await parsedJson(c)));
|
|
50
|
+
if (!broadcasts.publish(publications)) throw tooFast();
|
|
51
|
+
return c.body(null, 202);
|
|
52
|
+
});
|
|
53
|
+
app.post("/:topic/events/:event", admitted, limited, async (c) => {
|
|
54
|
+
const type = typeOf(c);
|
|
55
|
+
if (type !== "application/json" && type !== "application/octet-stream") throw broadcastProblem(415, "unsupported-payload", "Unsupported Media Type", "Send the payload as application/json or application/octet-stream");
|
|
56
|
+
const payload = type === "application/json" ? await parsedJson(c) : await c.req.arrayBuffer();
|
|
57
|
+
if (type === "application/json" && (typeof payload !== "object" || payload === null || Array.isArray(payload))) throw broadcastProblem(422, "invalid-broadcast", "Unprocessable Content", "Payload must be a JSON object");
|
|
58
|
+
const publication = publicationOf(c.req.param("topic"), c.req.param("event"), payload, c.req.query("private") === "true");
|
|
59
|
+
if ((await allowed(c.var.caller, [publication])).length === 0) throw broadcastProblem(403, "unauthorized", "Forbidden", "Unauthorized");
|
|
60
|
+
if (!broadcasts.publish([publication])) throw tooFast();
|
|
61
|
+
return c.body(null, 202);
|
|
62
|
+
});
|
|
63
|
+
return app;
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
export { broadcastApi };
|
|
@@ -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
|
|
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
|
-
|
|
63
|
+
metaBytes.length,
|
|
63
64
|
binary ? binaryEncoding : jsonEncoding
|
|
64
65
|
]);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
@@ -3,15 +3,19 @@ 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,
|
|
6
|
-
* so channels of other processes are not reached.
|
|
6
|
+
* so channels of other processes are not reached. Private and public channels of a topic do not hear each other, as on
|
|
7
|
+
* Supabase, so a public channel cannot reach past the policies of a private one. A topic takes `realtimeLimits.topicBroadcastsPerSecond`, whoever sends
|
|
7
8
|
* them; its window is kept here, so a topic left empty and joined again does not start a new one.
|
|
8
9
|
*/
|
|
9
10
|
const broadcastHub = ({ now = () => Date.now() } = {}) => {
|
|
10
|
-
const
|
|
11
|
+
const publicTopics = /* @__PURE__ */ new Map();
|
|
12
|
+
const privateTopics = /* @__PURE__ */ new Map();
|
|
13
|
+
const topicsOf = (isPrivate) => isPrivate ? privateTopics : publicTopics;
|
|
11
14
|
const rate = fixedWindows(realtimeLimits.topicBroadcastsPerSecond, 1e3, now);
|
|
12
15
|
return {
|
|
13
16
|
/** Puts the member on the topic until the returned function takes it off. */
|
|
14
17
|
join: (topic, member) => {
|
|
18
|
+
const topics = topicsOf(member.private);
|
|
15
19
|
const members = topics.get(topic) ?? /* @__PURE__ */ new Set();
|
|
16
20
|
topics.set(topic, members.add(member));
|
|
17
21
|
return () => {
|
|
@@ -25,7 +29,18 @@ const broadcastHub = ({ now = () => Date.now() } = {}) => {
|
|
|
25
29
|
*/
|
|
26
30
|
send: (topic, from, event, payload) => {
|
|
27
31
|
if (!rate(topic)) return false;
|
|
28
|
-
for (const member of
|
|
32
|
+
for (const member of topicsOf(from.private).get(topic) ?? []) if (member !== from || member.self) member.receive(event, payload);
|
|
33
|
+
return true;
|
|
34
|
+
},
|
|
35
|
+
/**
|
|
36
|
+
* Sends broadcasts that no channel sends, all of them or, when a topic would take more than its broadcasts for this
|
|
37
|
+
* second, none: `false` then.
|
|
38
|
+
*/
|
|
39
|
+
publish: (publications) => {
|
|
40
|
+
const takes = /* @__PURE__ */ new Map();
|
|
41
|
+
for (const { topic } of publications) takes.set(topic, (takes.get(topic) ?? 0) + 1);
|
|
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, publication.meta);
|
|
29
44
|
return true;
|
|
30
45
|
}
|
|
31
46
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { realtimeLimits } from "./realtime-limits.js";
|
|
3
|
+
import { fitsFrame } from "./broadcast-frame.js";
|
|
4
|
+
//#region src/realtime/broadcast-request.ts
|
|
5
|
+
/** A refused broadcast request; `message` too, which realtime-js's `httpSend` reads as the error. */
|
|
6
|
+
const broadcastProblem = (status, slug, title, detail) => new HttpProblem(status, slug, title, detail, { message: detail });
|
|
7
|
+
const invalid = (detail) => broadcastProblem(422, "invalid-broadcast", "Unprocessable Content", detail);
|
|
8
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
/**
|
|
10
|
+
* A message as the channels hear it, on the topic they joined: realtime-js names the topic without the `realtime:` its
|
|
11
|
+
* channels join with. A topic and an event are at most 255 bytes, as on the socket.
|
|
12
|
+
*/
|
|
13
|
+
const publicationOf = (topic, event, payload, isPrivate) => {
|
|
14
|
+
const channelTopic = `realtime:${topic}`;
|
|
15
|
+
if (!fitsFrame(channelTopic) || !fitsFrame(event)) throw invalid("A broadcast topic and event are at most 255 bytes each");
|
|
16
|
+
return {
|
|
17
|
+
topic: channelTopic,
|
|
18
|
+
event,
|
|
19
|
+
payload,
|
|
20
|
+
private: isPrivate
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The body of `POST /api/broadcast`, Supabase's `{ messages: [{ topic, event, payload, private }] }`: each message has a
|
|
25
|
+
* topic, an event and an object payload, and is private when `private` is `true`. A batch holds at most as many messages
|
|
26
|
+
* as a socket sends in a second, refused with 429 as Supabase refuses a batch over its rate.
|
|
27
|
+
*/
|
|
28
|
+
const batchOf = (body) => {
|
|
29
|
+
const messages = isRecord(body) ? body.messages : void 0;
|
|
30
|
+
if (!Array.isArray(messages) || messages.length === 0) throw invalid("Send the broadcasts as a list of messages");
|
|
31
|
+
if (messages.length > realtimeLimits.eventsPerSecond) throw broadcastProblem(429, "too-many-broadcasts", "Too Many Requests", `Too many messages to broadcast, send at most ${realtimeLimits.eventsPerSecond} at once`);
|
|
32
|
+
return messages.map((message) => {
|
|
33
|
+
if (!isRecord(message)) throw invalid("Each message is an object");
|
|
34
|
+
const { topic, event, payload } = message;
|
|
35
|
+
if (typeof topic !== "string" || topic === "") throw invalid("Each message needs a topic");
|
|
36
|
+
if (typeof event !== "string" || event === "") throw invalid("Each message needs an event");
|
|
37
|
+
if (!isRecord(payload)) throw invalid("Each message needs a payload object");
|
|
38
|
+
if (message.private !== void 0 && message.private !== null && typeof message.private !== "boolean") throw invalid("A message's private is true or false");
|
|
39
|
+
return publicationOf(topic, event, payload, message.private === true);
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { batchOf, broadcastProblem, publicationOf };
|
|
@@ -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 };
|
|
@@ -7,17 +7,18 @@
|
|
|
7
7
|
const fixedWindows = (max, windowMs, now = () => Date.now()) => {
|
|
8
8
|
let start = -Infinity;
|
|
9
9
|
let counts = /* @__PURE__ */ new Map();
|
|
10
|
-
|
|
10
|
+
/** Takes as many as each key names, only when they all fit: whether they did. */
|
|
11
|
+
const all = (takes) => {
|
|
11
12
|
const at = now();
|
|
12
13
|
if (at - start >= windowMs) {
|
|
13
14
|
start = at;
|
|
14
15
|
counts = /* @__PURE__ */ new Map();
|
|
15
16
|
}
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
counts.set(key, count + 1);
|
|
17
|
+
for (const [key, n] of takes) if ((counts.get(key) ?? 0) + n > max) return false;
|
|
18
|
+
for (const [key, n] of takes) counts.set(key, (counts.get(key) ?? 0) + n);
|
|
19
19
|
return true;
|
|
20
20
|
};
|
|
21
|
+
return Object.assign((key) => all(/* @__PURE__ */ new Map([[key, 1]])), { all });
|
|
21
22
|
};
|
|
22
23
|
/** `fixedWindows` for one key. */
|
|
23
24
|
const fixedWindow = (max, windowMs, now = () => Date.now()) => {
|
|
@@ -8,10 +8,11 @@ const stateOf = (tracked) => {
|
|
|
8
8
|
* The presences of a process by topic, in-process like broadcasts: each joined channel tracks at most one, under its
|
|
9
9
|
* key, and channels that listen hear every join and leave on their topic, their own included, as Phoenix Presence
|
|
10
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.
|
|
11
|
+
* in the process. Private and public channels of a topic do not see each other, as with broadcasts.
|
|
12
12
|
*/
|
|
13
13
|
const presenceHub = () => {
|
|
14
|
-
const
|
|
14
|
+
const publicTopics = /* @__PURE__ */ new Map();
|
|
15
|
+
const privateTopics = /* @__PURE__ */ new Map();
|
|
15
16
|
let lastRef = 0;
|
|
16
17
|
const tell = (members, diff) => {
|
|
17
18
|
for (const member of members.keys()) if (member.listens) member.receive(diff);
|
|
@@ -19,6 +20,7 @@ const presenceHub = () => {
|
|
|
19
20
|
return {
|
|
20
21
|
/** Puts the member on the topic, with what it may do there until it leaves. */
|
|
21
22
|
join: (topic, member) => {
|
|
23
|
+
const topics = member.private ? privateTopics : publicTopics;
|
|
22
24
|
const members = topics.get(topic) ?? /* @__PURE__ */ new Map();
|
|
23
25
|
topics.set(topic, members.set(member, void 0));
|
|
24
26
|
const untrack = () => {
|
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
import { HttpProblem } from "../problem.js";
|
|
2
|
-
import {
|
|
2
|
+
import { broadcastApi } from "./broadcast-api.js";
|
|
3
3
|
import { bufferedOf } from "./buffered-amount.js";
|
|
4
4
|
import { vsnOf } from "./phoenix-message.js";
|
|
5
5
|
import { presenceHub } from "./presence-hub.js";
|
|
@@ -15,13 +15,20 @@ const keyRequest = (apikey, token) => new Request("http://realtime.invalid/", {
|
|
|
15
15
|
* resources, and broadcasts and presence between the sockets of this process (see `realtimeSocket`). A missing or wrong
|
|
16
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`
|
|
17
17
|
* shim's, is closed at once with 1011, since its unsent bytes cannot be counted (`realtimeLimits.maxUnsentBytes`).
|
|
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`).
|
|
18
20
|
*/
|
|
19
|
-
const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, access, report }) => {
|
|
21
|
+
const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, broadcasts, database, access, report }) => {
|
|
20
22
|
const app = new Hono();
|
|
21
|
-
const broadcasts = broadcastHub();
|
|
22
23
|
const presences = presenceHub();
|
|
23
24
|
let lastId = 0;
|
|
24
25
|
const nextId = () => ++lastId;
|
|
26
|
+
app.route("/api/broadcast", broadcastApi({
|
|
27
|
+
caller,
|
|
28
|
+
broadcasts,
|
|
29
|
+
access,
|
|
30
|
+
report
|
|
31
|
+
}));
|
|
25
32
|
app.get("/websocket", async (c, next) => {
|
|
26
33
|
if (!vsnOf(c.req.query("vsn"))) throw new HttpProblem(400, "invalid-request", "Bad Request", "Use vsn 1.0.0 or 2.0.0");
|
|
27
34
|
await caller(keyRequest(c.req.query("apikey") ?? ""));
|
|
@@ -29,12 +36,18 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, access, report })
|
|
|
29
36
|
}, upgradeWebSocket((c) => {
|
|
30
37
|
const apikey = c.req.query("apikey");
|
|
31
38
|
let socket;
|
|
39
|
+
let unfollow = () => {};
|
|
40
|
+
const closed = () => {
|
|
41
|
+
unfollow();
|
|
42
|
+
socket?.closed();
|
|
43
|
+
};
|
|
32
44
|
return {
|
|
33
45
|
onOpen: (_event, ws) => {
|
|
34
46
|
if (bufferedOf(ws.raw) === void 0) {
|
|
35
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"));
|
|
36
48
|
return ws.close(1011);
|
|
37
49
|
}
|
|
50
|
+
unfollow = database.follow();
|
|
38
51
|
socket = realtimeSocket({
|
|
39
52
|
vsn: vsnOf(c.req.query("vsn")),
|
|
40
53
|
send: (frame) => ws.send(frame),
|
|
@@ -51,8 +64,8 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, access, report })
|
|
|
51
64
|
});
|
|
52
65
|
},
|
|
53
66
|
onMessage: (event) => socket?.message(event.data),
|
|
54
|
-
onError:
|
|
55
|
-
onClose:
|
|
67
|
+
onError: closed,
|
|
68
|
+
onClose: closed
|
|
56
69
|
};
|
|
57
70
|
}));
|
|
58
71
|
return app;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { HttpProblem } from "../problem.js";
|
|
2
|
-
import { parseBindings } from "./postgres-changes.js";
|
|
3
2
|
import { fixedWindow, fixedWindows } from "./fixed-window.js";
|
|
4
3
|
import { realtimeLimits } from "./realtime-limits.js";
|
|
5
|
-
import {
|
|
4
|
+
import { parseBindings } from "./postgres-changes.js";
|
|
6
5
|
import { decodeBroadcastPush, encodeBroadcast, fitsFrame } from "./broadcast-frame.js";
|
|
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";
|
|
9
9
|
import { Buffer } from "node:buffer";
|
|
@@ -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) => {
|
|
@@ -127,11 +128,13 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
|
|
|
127
128
|
const { topic } = message;
|
|
128
129
|
const member = {
|
|
129
130
|
self: broadcast.self === true,
|
|
130
|
-
|
|
131
|
+
private: config.private === true,
|
|
132
|
+
receive: (event, payload, meta) => hear(topic, channel, event, payload, meta)
|
|
131
133
|
};
|
|
132
134
|
const key = typeof presence.key === "string" && presence.key !== "" ? presence.key : randomUUID();
|
|
133
135
|
const seat = presences.join(topic, {
|
|
134
136
|
key,
|
|
137
|
+
private: config.private === true,
|
|
135
138
|
get listens() {
|
|
136
139
|
return listens && gate.hears("presence");
|
|
137
140
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.51.
|
|
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.
|
|
44
|
-
"@protobase/query": "0.51.
|
|
45
|
-
"@protobase/schema": "0.51.
|
|
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",
|