@protobase/server 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.
- package/dist/channels/channel-bounds.js +17 -0
- package/dist/channels/channel-name.d.ts +1 -0
- package/dist/channels/channel-name.js +30 -0
- package/dist/channels/channel-protocol.js +33 -0
- package/dist/channels/channel-refusals.js +26 -0
- package/dist/channels/channel-routes.js +98 -0
- package/dist/channels/channel-sender.d.ts +20 -0
- package/dist/channels/channel-sender.js +39 -0
- package/dist/channels/channel-socket.js +253 -0
- package/dist/channels/presence-entries.js +24 -0
- package/dist/channels/record-readable.js +28 -0
- package/dist/channels/sent.js +8 -0
- package/dist/create-admin.js +22 -2
- package/dist/functions/define-function.d.ts +3 -0
- package/dist/functions/routes.js +3 -1
- package/dist/index.d.ts +2 -1
- package/dist/package.js +1 -1
- package/dist/realtime/broadcast-hub.js +1 -1
- package/dist/realtime/broadcast-request.js +1 -1
- package/dist/realtime/database-broadcasts.js +1 -1
- package/dist/realtime/realtime-app.js +2 -18
- package/dist/realtime/realtime-hubs.js +30 -0
- package/dist/realtime/realtime-socket.js +2 -2
- package/dist/realtime/relay/realtime-options.d.ts +1 -1
- package/dist/realtime/relay/realtime-relay.js +2 -2
- package/dist/realtime/relay/relay-frame-sql.js +7 -4
- package/dist/realtime/relay/relay-presence-sql.js +13 -13
- package/dist/realtime/relay/relay-schema-sql.js +11 -2
- package/dist/realtime/relay/relay-schema.d.ts +1 -1
- package/dist/realtime/relay/relay-schema.js +4 -2
- package/dist/realtime/relay/relayed-hubs.js +63 -40
- package/dist/webhooks/attempt.js +1 -0
- package/dist/webhooks/webhooks.js +2 -1
- package/package.json +4 -4
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/channels/channel-bounds.ts
|
|
2
|
+
/**
|
|
3
|
+
* The bounds of `/api/channels` that its limits (`realtimeLimits`, the same as `/api/sb/realtime/v1`'s) do not give,
|
|
4
|
+
* each no looser than its counterpart there.
|
|
5
|
+
*/
|
|
6
|
+
const channelBounds = {
|
|
7
|
+
/** How often the client says it is there; `@protobase/client` sends a ping this often. */
|
|
8
|
+
pingMs: 5e3,
|
|
9
|
+
/** How long a connection may stay silent before it is closed and its presences leave: 60 s on `/api/sb/realtime/v1`. */
|
|
10
|
+
idleMs: 12e3,
|
|
11
|
+
/** How often a connection's session, and its read access to each channel's record, are checked again. */
|
|
12
|
+
recheckMs: 1e4,
|
|
13
|
+
/** How long a send trusts the last check that the caller may read the record. */
|
|
14
|
+
readCacheMs: 5e3
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { channelBounds };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "../registry.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { fitsFrame } from "../realtime/broadcast-frame.js";
|
|
2
|
+
import { decodeKey, encodeKey } from "@protobase/schema/internal";
|
|
3
|
+
//#region src/channels/channel-name.ts
|
|
4
|
+
/**
|
|
5
|
+
* The record `resource` and `key` name, with the key's own text in the name, so `01` and `1` of an integer key name
|
|
6
|
+
* one channel. `undefined` for a resource the app does not have or a key that is not one of its keys, which a join
|
|
7
|
+
* refuses as it refuses a record the caller may not read; `'too-long'` for a name over 255 bytes.
|
|
8
|
+
*/
|
|
9
|
+
const channelRecord = (registry, resource, key) => {
|
|
10
|
+
const entry = registry.find(resource);
|
|
11
|
+
if (!entry) return;
|
|
12
|
+
const parsed = decodedKey(entry, key);
|
|
13
|
+
if (!parsed) return;
|
|
14
|
+
const name = `${resource}/${encodeKey(parsed)}`;
|
|
15
|
+
return fitsFrame(name) ? {
|
|
16
|
+
entry,
|
|
17
|
+
key: parsed,
|
|
18
|
+
name
|
|
19
|
+
} : "too-long";
|
|
20
|
+
};
|
|
21
|
+
const decodedKey = (entry, key) => {
|
|
22
|
+
try {
|
|
23
|
+
const decoded = decodeKey(key, entry.keyTypes);
|
|
24
|
+
return Array.isArray(decoded) ? decoded : [decoded];
|
|
25
|
+
} catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { channelRecord };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region src/channels/channel-protocol.ts
|
|
2
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
const text = (value) => typeof value === "string";
|
|
4
|
+
/** The message a text frame carries, or `undefined` for one that is not JSON or not a message. */
|
|
5
|
+
const decodeClientMessage = (frame) => {
|
|
6
|
+
const message = parsed(frame);
|
|
7
|
+
if (!isRecord(message)) return;
|
|
8
|
+
if (message.ref !== void 0 && !text(message.ref)) return;
|
|
9
|
+
const { type } = message;
|
|
10
|
+
if (type === "ping") return { type };
|
|
11
|
+
if (type === "token") return text(message.token) ? {
|
|
12
|
+
type,
|
|
13
|
+
token: message.token
|
|
14
|
+
} : void 0;
|
|
15
|
+
if (type === "join") return text(message.resource) && text(message.key) ? message : void 0;
|
|
16
|
+
if (!text(message.channel)) return;
|
|
17
|
+
if (type === "leave" || type === "untrack") return message;
|
|
18
|
+
if (type === "track") return "state" in message ? message : void 0;
|
|
19
|
+
if (type !== "send" || !text(message.event) || !("payload" in message)) return;
|
|
20
|
+
if (message.self !== void 0 && typeof message.self !== "boolean") return;
|
|
21
|
+
if (message.ack !== void 0 && typeof message.ack !== "boolean") return;
|
|
22
|
+
return message;
|
|
23
|
+
};
|
|
24
|
+
const parsed = (frame) => {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(frame);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof SyntaxError) return;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { decodeClientMessage };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { realtimeLimits } from "../realtime/realtime-limits.js";
|
|
2
|
+
//#region src/channels/channel-refusals.ts
|
|
3
|
+
const refusal = (code, message) => ({
|
|
4
|
+
code,
|
|
5
|
+
message
|
|
6
|
+
});
|
|
7
|
+
/**
|
|
8
|
+
* Every refusal of `/api/channels`. A record the caller may not read is refused as one that does not exist, so a
|
|
9
|
+
* channel never tells which records there are.
|
|
10
|
+
*/
|
|
11
|
+
const channelRefusals = {
|
|
12
|
+
notFound: refusal("not-found", "There is no such record"),
|
|
13
|
+
invalidMessage: refusal("invalid-message", "Send a JSON text frame of a ping, token, join, leave, track, untrack or send"),
|
|
14
|
+
notJoined: refusal("not-joined", "Join the channel first"),
|
|
15
|
+
alreadyJoined: refusal("already-joined", "This connection has joined the channel already"),
|
|
16
|
+
nameTooLong: refusal("name-too-long", "A channel name, its resource, a slash and its key, is at most 255 bytes"),
|
|
17
|
+
eventTooLong: refusal("event-too-long", "An event name is at most 255 bytes"),
|
|
18
|
+
tooManyJoins: refusal("too-many-joins", `Too many joins: at most ${realtimeLimits.joinsPerSecond} a second`),
|
|
19
|
+
tooManyChannels: refusal("too-many-channels", `Too many channels: at most ${realtimeLimits.channelsPerSocket} at once on a connection`),
|
|
20
|
+
tooManyMessages: refusal("too-many-messages", `Too many messages: at most ${realtimeLimits.eventsPerSecond} broadcasts and presence updates a second`),
|
|
21
|
+
channelTooBusy: refusal("channel-too-busy", `Too many broadcasts on this channel: at most ${realtimeLimits.topicBroadcastsPerSecond} a second, every sender's together`),
|
|
22
|
+
tooManyPresenceUpdates: refusal("too-many-presence-updates", `Too many presence updates: at most ${realtimeLimits.presencePerWindow} every ${realtimeLimits.presenceWindowMs / 1e3} seconds on a channel`),
|
|
23
|
+
presenceTooLarge: refusal("presence-too-large", `A tracked state is at most ${realtimeLimits.maxPresenceBytes} bytes of JSON`)
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { channelRefusals };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { HttpProblem, notFound } from "../problem.js";
|
|
2
|
+
import { realtimeLimits } from "../realtime/realtime-limits.js";
|
|
3
|
+
import { bufferedOf } from "../realtime/buffered-amount.js";
|
|
4
|
+
import { channelRecord } from "./channel-name.js";
|
|
5
|
+
import { broadcastRecord, publishTo } from "./channel-sender.js";
|
|
6
|
+
import { channelSocket } from "./channel-socket.js";
|
|
7
|
+
import { recordReadable } from "./record-readable.js";
|
|
8
|
+
import { Hono } from "hono";
|
|
9
|
+
import { bodyLimit } from "hono/body-limit";
|
|
10
|
+
//#region src/channels/channel-routes.ts
|
|
11
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
const bearerOf = (request) => request.headers.get("sec-websocket-protocol")?.split(",").map((part) => part.trim()).find((part) => part.startsWith("bearer."))?.slice(7);
|
|
13
|
+
const withBearer = (request, token) => {
|
|
14
|
+
if (token === void 0 || request.headers.has("authorization")) return request;
|
|
15
|
+
const headers = new Headers(request.headers);
|
|
16
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
17
|
+
return new Request(request, { headers });
|
|
18
|
+
};
|
|
19
|
+
const invalidBody = () => new HttpProblem(422, "invalid-broadcast", "Unprocessable Content", "Send { \"event\": \"…\", \"payload\": … } as application/json");
|
|
20
|
+
/**
|
|
21
|
+
* `GET /channels`, a WebSocket of channels named by records (see `channelSocket`), refused before the upgrade like
|
|
22
|
+
* any request its caller cannot make. `POST /channels/:resource/:key/broadcast` sends one broadcast without a socket,
|
|
23
|
+
* as the caller, when they may read the record.
|
|
24
|
+
*/
|
|
25
|
+
const channelRoutes = ({ deps, upgradeWebSocket, authenticate, authenticateUpgrade, onError, hubs, follow, report }) => {
|
|
26
|
+
const app = new Hono();
|
|
27
|
+
app.onError(onError);
|
|
28
|
+
const readable = (session, record) => recordReadable(deps, session, record);
|
|
29
|
+
app.get("/channels", async (c, next) => {
|
|
30
|
+
c.set("session", await authenticateUpgrade(withBearer(c.req.raw, bearerOf(c.req.raw))));
|
|
31
|
+
await next();
|
|
32
|
+
}, upgradeWebSocket((c) => {
|
|
33
|
+
const request = c.req.raw;
|
|
34
|
+
const opened = bearerOf(request);
|
|
35
|
+
let socket;
|
|
36
|
+
let unfollow = () => {};
|
|
37
|
+
const closed = () => {
|
|
38
|
+
socket?.closed();
|
|
39
|
+
unfollow();
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
onOpen: (_event, ws) => {
|
|
43
|
+
if (bufferedOf(ws.raw) === void 0) {
|
|
44
|
+
report(/* @__PURE__ */ new Error("Not implemented: channels need 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"));
|
|
45
|
+
return ws.close(1011);
|
|
46
|
+
}
|
|
47
|
+
unfollow = follow();
|
|
48
|
+
socket = channelSocket({
|
|
49
|
+
send: (frame) => ws.send(frame),
|
|
50
|
+
buffered: () => bufferedOf(ws.raw),
|
|
51
|
+
close: (code) => ws.close(code),
|
|
52
|
+
session: c.get("session"),
|
|
53
|
+
reauthenticate: async (token) => authenticateUpgrade(withBearer(request, token ?? opened)),
|
|
54
|
+
record: (resource, key) => channelRecord(deps.registry, resource, key),
|
|
55
|
+
readable,
|
|
56
|
+
broadcasts: hubs.broadcasts,
|
|
57
|
+
presences: hubs.presences,
|
|
58
|
+
report
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
onMessage: (event) => socket?.message(event.data),
|
|
62
|
+
onError: closed,
|
|
63
|
+
onClose: closed
|
|
64
|
+
};
|
|
65
|
+
}));
|
|
66
|
+
app.post("/channels/:resource/:key/broadcast", async (c, next) => {
|
|
67
|
+
c.set("session", await authenticate(c.req.raw));
|
|
68
|
+
await next();
|
|
69
|
+
}, bodyLimit({
|
|
70
|
+
maxSize: realtimeLimits.maxFrameBytes,
|
|
71
|
+
onError: () => {
|
|
72
|
+
throw new HttpProblem(413, "too-large", "Payload Too Large", `A broadcast request is at most ${realtimeLimits.maxFrameBytes} bytes`);
|
|
73
|
+
}
|
|
74
|
+
}), async (c) => {
|
|
75
|
+
const body = await c.req.json().catch((error) => {
|
|
76
|
+
if (error instanceof SyntaxError) throw invalidBody();
|
|
77
|
+
throw error;
|
|
78
|
+
});
|
|
79
|
+
if (!isRecord(body) || typeof body.event !== "string" || !("payload" in body)) throw invalidBody();
|
|
80
|
+
const message = {
|
|
81
|
+
resource: c.req.param("resource"),
|
|
82
|
+
key: c.req.param("key"),
|
|
83
|
+
event: body.event,
|
|
84
|
+
payload: body.payload
|
|
85
|
+
};
|
|
86
|
+
const record = broadcastRecord(deps.registry, message);
|
|
87
|
+
const session = c.get("session");
|
|
88
|
+
if (!await recordReadable(deps, session, record)) throw notFound("There is no such record");
|
|
89
|
+
await publishTo(hubs.broadcasts, record, message.event, {
|
|
90
|
+
user: session.anonymous ? null : session.user.id,
|
|
91
|
+
payload: message.payload
|
|
92
|
+
});
|
|
93
|
+
return c.body(null, 202);
|
|
94
|
+
});
|
|
95
|
+
return app;
|
|
96
|
+
};
|
|
97
|
+
//#endregion
|
|
98
|
+
export { channelRoutes };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import "../registry.js";
|
|
2
|
+
import "./channel-name.js";
|
|
3
|
+
//#region src/channels/channel-sender.d.ts
|
|
4
|
+
/** A broadcast to the channel of a record, from outside a connection. */
|
|
5
|
+
export type ChannelBroadcast = {
|
|
6
|
+
resource: string;
|
|
7
|
+
key: string;
|
|
8
|
+
event: string;
|
|
9
|
+
payload: unknown;
|
|
10
|
+
};
|
|
11
|
+
/** What the app's own code broadcasts to record channels with, such as a function's `channels`. */
|
|
12
|
+
export type ChannelSender = {
|
|
13
|
+
/**
|
|
14
|
+
* Sends to every connection on the record's channel, of every process, as from the server (`server: true`, no
|
|
15
|
+
* user); nothing is stored, so with nobody on it, it reaches nobody. Rejects with a 404 problem for a record name
|
|
16
|
+
* the app does not have, 422 for an event over 255 bytes or a payload over a frame, 429 over the channel's rate.
|
|
17
|
+
*/
|
|
18
|
+
broadcast: (message: ChannelBroadcast) => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { HttpProblem, notFound } from "../problem.js";
|
|
2
|
+
import { fitsFrame } from "../realtime/broadcast-frame.js";
|
|
3
|
+
import { realtimeLimits } from "../realtime/realtime-limits.js";
|
|
4
|
+
import { channelRecord } from "./channel-name.js";
|
|
5
|
+
import { channelRefusals } from "./channel-refusals.js";
|
|
6
|
+
import { Buffer } from "node:buffer";
|
|
7
|
+
//#region src/channels/channel-sender.ts
|
|
8
|
+
const invalid = (refusal) => new HttpProblem(422, refusal.code, "Unprocessable Content", refusal.message);
|
|
9
|
+
/** The record a broadcast names, its event and payload checked, or a problem. */
|
|
10
|
+
const broadcastRecord = (registry, { resource, key, event, payload }) => {
|
|
11
|
+
const found = channelRecord(registry, resource, key);
|
|
12
|
+
if (found === "too-long") throw invalid(channelRefusals.nameTooLong);
|
|
13
|
+
if (!found) throw notFound(channelRefusals.notFound.message);
|
|
14
|
+
if (!fitsFrame(event)) throw invalid(channelRefusals.eventTooLong);
|
|
15
|
+
const json = JSON.stringify(payload);
|
|
16
|
+
if (json === void 0 || Buffer.byteLength(json) > realtimeLimits.maxFrameBytes) throw invalid({
|
|
17
|
+
code: "invalid-payload",
|
|
18
|
+
message: `A payload is JSON of at most ${realtimeLimits.maxFrameBytes} bytes`
|
|
19
|
+
});
|
|
20
|
+
return found;
|
|
21
|
+
};
|
|
22
|
+
/** Publishes a checked broadcast, refused with 429 when the channel took its broadcasts for this second. */
|
|
23
|
+
const publishTo = async (broadcasts, record, event, sent) => {
|
|
24
|
+
if (await broadcasts.publish([{
|
|
25
|
+
topic: record.name,
|
|
26
|
+
event,
|
|
27
|
+
payload: sent,
|
|
28
|
+
private: false
|
|
29
|
+
}])) return;
|
|
30
|
+
throw new HttpProblem(429, channelRefusals.channelTooBusy.code, "Too Many Requests", channelRefusals.channelTooBusy.message);
|
|
31
|
+
};
|
|
32
|
+
/** The server's own sender over the record channels' hub. */
|
|
33
|
+
const channelSender = (registry, broadcasts) => ({ broadcast: async (message) => publishTo(broadcasts, broadcastRecord(registry, message), message.event, {
|
|
34
|
+
user: null,
|
|
35
|
+
server: true,
|
|
36
|
+
payload: message.payload
|
|
37
|
+
}) });
|
|
38
|
+
//#endregion
|
|
39
|
+
export { broadcastRecord, channelSender, publishTo };
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { fitsFrame } from "../realtime/broadcast-frame.js";
|
|
2
|
+
import { realtimeLimits } from "../realtime/realtime-limits.js";
|
|
3
|
+
import { fixedWindow, fixedWindows } from "../realtime/fixed-window.js";
|
|
4
|
+
import { channelRefusals } from "./channel-refusals.js";
|
|
5
|
+
import { channelBounds } from "./channel-bounds.js";
|
|
6
|
+
import { decodeClientMessage } from "./channel-protocol.js";
|
|
7
|
+
import { entriesOf, entryDiff } from "./presence-entries.js";
|
|
8
|
+
import { senderOf } from "./sent.js";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { Buffer } from "node:buffer";
|
|
11
|
+
//#region src/channels/channel-socket.ts
|
|
12
|
+
const messageTooBig = 1009;
|
|
13
|
+
const policyViolation = 1008;
|
|
14
|
+
const frameBytes = (frame) => {
|
|
15
|
+
if (typeof frame === "string") return Buffer.byteLength(frame);
|
|
16
|
+
if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) return frame.byteLength;
|
|
17
|
+
if (frame instanceof Blob) return frame.size;
|
|
18
|
+
return NaN;
|
|
19
|
+
};
|
|
20
|
+
const userOf = (session) => session.anonymous ? null : session.user.id;
|
|
21
|
+
/**
|
|
22
|
+
* One WebSocket of `/api/channels`: channels named by records, which a caller joins only while it may read the record,
|
|
23
|
+
* with presence and broadcasts between the connections on each. Messages are handled one at a time, in order. Every
|
|
24
|
+
* presence and broadcast carries the session's user, never one the client names. The session and each channel's read
|
|
25
|
+
* access are checked again every `channelBounds.recheckMs`, and a send or track trusts the last check for
|
|
26
|
+
* `channelBounds.readCacheMs`: a channel whose record the caller may no longer read is closed, and a session that signs
|
|
27
|
+
* in nobody, or someone else, closes the socket. A socket silent for `channelBounds.idleMs` is closed, and a close
|
|
28
|
+
* leaves every channel, its presences with it. The limits are `realtimeLimits`, as on `/api/sb/realtime/v1`.
|
|
29
|
+
*/
|
|
30
|
+
const channelSocket = ({ send, buffered, close, session: first, reauthenticate, record, readable, broadcasts, presences, report, now = Date.now }) => {
|
|
31
|
+
const channels = /* @__PURE__ */ new Map();
|
|
32
|
+
let session = first;
|
|
33
|
+
let token;
|
|
34
|
+
let queue = Promise.resolve();
|
|
35
|
+
let queuedMessages = 0;
|
|
36
|
+
let queuedBytes = 0;
|
|
37
|
+
let idle;
|
|
38
|
+
let open = true;
|
|
39
|
+
const events = fixedWindow(realtimeLimits.eventsPerSecond, 1e3, now);
|
|
40
|
+
const joins = fixedWindow(realtimeLimits.joinsPerSecond, 1e3, now);
|
|
41
|
+
const presenceRate = fixedWindows(realtimeLimits.presencePerWindow, realtimeLimits.presenceWindowMs, now);
|
|
42
|
+
const leaveAll = () => {
|
|
43
|
+
open = false;
|
|
44
|
+
clearTimeout(idle);
|
|
45
|
+
clearInterval(recheck);
|
|
46
|
+
for (const name of [...channels.keys()]) leave(name);
|
|
47
|
+
};
|
|
48
|
+
const shut = (code) => {
|
|
49
|
+
if (!open) return;
|
|
50
|
+
open = false;
|
|
51
|
+
clearTimeout(idle);
|
|
52
|
+
clearInterval(recheck);
|
|
53
|
+
close(code);
|
|
54
|
+
Promise.resolve().then(leaveAll).catch(report);
|
|
55
|
+
};
|
|
56
|
+
const push = (message) => {
|
|
57
|
+
if (!open) return;
|
|
58
|
+
if (!(buffered() <= realtimeLimits.maxUnsentBytes)) return shut(policyViolation);
|
|
59
|
+
send(JSON.stringify(message));
|
|
60
|
+
};
|
|
61
|
+
const ok = (ref, channel) => {
|
|
62
|
+
if (ref === void 0) return;
|
|
63
|
+
push({
|
|
64
|
+
type: "reply",
|
|
65
|
+
ref,
|
|
66
|
+
ok: true,
|
|
67
|
+
...channel !== void 0 && { channel }
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
const refuse = (ref, error) => {
|
|
71
|
+
if (ref === void 0) return push({
|
|
72
|
+
type: "error",
|
|
73
|
+
error
|
|
74
|
+
});
|
|
75
|
+
push({
|
|
76
|
+
type: "reply",
|
|
77
|
+
ref,
|
|
78
|
+
ok: false,
|
|
79
|
+
error
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
const leave = (name) => {
|
|
83
|
+
channels.get(name)?.remove();
|
|
84
|
+
channels.delete(name);
|
|
85
|
+
};
|
|
86
|
+
const end = (name, error) => {
|
|
87
|
+
if (!channels.has(name)) return;
|
|
88
|
+
leave(name);
|
|
89
|
+
push({
|
|
90
|
+
type: "closed",
|
|
91
|
+
channel: name,
|
|
92
|
+
error
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
const join = async (message) => {
|
|
96
|
+
if (!joins()) return refuse(message.ref, channelRefusals.tooManyJoins);
|
|
97
|
+
const found = record(message.resource, message.key);
|
|
98
|
+
if (found === "too-long") return refuse(message.ref, channelRefusals.nameTooLong);
|
|
99
|
+
if (found && channels.has(found.name)) return refuse(message.ref, channelRefusals.alreadyJoined);
|
|
100
|
+
if (channels.size >= realtimeLimits.channelsPerSocket) return refuse(message.ref, channelRefusals.tooManyChannels);
|
|
101
|
+
if (!found || !await readable(session, found)) return refuse(message.ref, channelRefusals.notFound);
|
|
102
|
+
await presences.ready();
|
|
103
|
+
if (!open || channels.has(found.name)) return;
|
|
104
|
+
const { name } = found;
|
|
105
|
+
const member = {
|
|
106
|
+
self: false,
|
|
107
|
+
private: false,
|
|
108
|
+
receive: (event, payload) => hear(name, event, payload)
|
|
109
|
+
};
|
|
110
|
+
const seat = presences.join(name, {
|
|
111
|
+
key: randomUUID(),
|
|
112
|
+
private: false,
|
|
113
|
+
listens: true,
|
|
114
|
+
receive: (diff) => push({
|
|
115
|
+
type: "presence_diff",
|
|
116
|
+
channel: name,
|
|
117
|
+
...entryDiff(diff)
|
|
118
|
+
})
|
|
119
|
+
});
|
|
120
|
+
const offBroadcast = broadcasts.join(name, member);
|
|
121
|
+
channels.set(name, {
|
|
122
|
+
record: found,
|
|
123
|
+
member,
|
|
124
|
+
seat,
|
|
125
|
+
checkedAt: now(),
|
|
126
|
+
remove: () => (offBroadcast(), seat.leave())
|
|
127
|
+
});
|
|
128
|
+
ok(message.ref, name);
|
|
129
|
+
push({
|
|
130
|
+
type: "presence",
|
|
131
|
+
channel: name,
|
|
132
|
+
entries: entriesOf(seat.state())
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
const hear = (name, event, sent) => {
|
|
136
|
+
if (!channels.has(name)) return;
|
|
137
|
+
push({
|
|
138
|
+
type: "broadcast",
|
|
139
|
+
channel: name,
|
|
140
|
+
event,
|
|
141
|
+
payload: sent.payload,
|
|
142
|
+
...senderOf(sent)
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
const stillReadable = async (name, channel) => {
|
|
146
|
+
if (now() - channel.checkedAt < channelBounds.readCacheMs) return true;
|
|
147
|
+
const allowed = await readable(session, channel.record);
|
|
148
|
+
if (channels.get(name) !== channel) return false;
|
|
149
|
+
if (!allowed) {
|
|
150
|
+
end(name, channelRefusals.notFound);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
channel.checkedAt = now();
|
|
154
|
+
return true;
|
|
155
|
+
};
|
|
156
|
+
const sendBroadcast = async (message, channel) => {
|
|
157
|
+
if (!events()) return refuse(message.ref, channelRefusals.tooManyMessages);
|
|
158
|
+
if (!fitsFrame(message.event)) return refuse(message.ref, channelRefusals.eventTooLong);
|
|
159
|
+
if (!await stillReadable(message.channel, channel)) return refuse(message.ref, channelRefusals.notFound);
|
|
160
|
+
const sent = {
|
|
161
|
+
user: userOf(session),
|
|
162
|
+
payload: message.payload
|
|
163
|
+
};
|
|
164
|
+
if (!await broadcasts.send(message.channel, channel.member, message.event, sent)) return refuse(message.ref, channelRefusals.channelTooBusy);
|
|
165
|
+
if (message.self === true) hear(message.channel, message.event, sent);
|
|
166
|
+
if (message.ack === true) ok(message.ref);
|
|
167
|
+
};
|
|
168
|
+
const updatePresence = async (message, channel) => {
|
|
169
|
+
if (!events()) return refuse(message.ref, channelRefusals.tooManyMessages);
|
|
170
|
+
if (!presenceRate(message.channel)) return refuse(message.ref, channelRefusals.tooManyPresenceUpdates);
|
|
171
|
+
if (message.type === "untrack") {
|
|
172
|
+
channel.seat.untrack();
|
|
173
|
+
return ok(message.ref);
|
|
174
|
+
}
|
|
175
|
+
if (Buffer.byteLength(JSON.stringify(message.state) ?? "") > realtimeLimits.maxPresenceBytes) return refuse(message.ref, channelRefusals.presenceTooLarge);
|
|
176
|
+
if (!await stillReadable(message.channel, channel)) return refuse(message.ref, channelRefusals.notFound);
|
|
177
|
+
channel.seat.track({
|
|
178
|
+
user: userOf(session),
|
|
179
|
+
state: message.state
|
|
180
|
+
});
|
|
181
|
+
ok(message.ref);
|
|
182
|
+
};
|
|
183
|
+
const handle = async (message) => {
|
|
184
|
+
if (!open) return;
|
|
185
|
+
if (message.type === "ping") return push({ type: "pong" });
|
|
186
|
+
if (message.type === "token") {
|
|
187
|
+
token = message.token;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (message.type === "join") return join(message);
|
|
191
|
+
const channel = channels.get(message.channel);
|
|
192
|
+
if (!channel) return refuse(message.ref, channelRefusals.notJoined);
|
|
193
|
+
if (message.type === "send") return sendBroadcast(message, channel);
|
|
194
|
+
if (message.type === "track" || message.type === "untrack") return updatePresence(message, channel);
|
|
195
|
+
leave(message.channel);
|
|
196
|
+
ok(message.ref);
|
|
197
|
+
};
|
|
198
|
+
const checkAgain = async () => {
|
|
199
|
+
const again = await reauthenticate(token).then((next) => next, () => void 0);
|
|
200
|
+
if (!open) return;
|
|
201
|
+
if (!again || userOf(again) !== userOf(session)) return shut(policyViolation);
|
|
202
|
+
session = again;
|
|
203
|
+
for (const [name, channel] of [...channels]) {
|
|
204
|
+
const allowed = await readable(session, channel.record);
|
|
205
|
+
if (channels.get(name) !== channel) continue;
|
|
206
|
+
if (!allowed) end(name, channelRefusals.notFound);
|
|
207
|
+
else channel.checkedAt = now();
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
let rechecking = false;
|
|
211
|
+
const recheck = setInterval(() => {
|
|
212
|
+
if (rechecking || !open) return;
|
|
213
|
+
rechecking = true;
|
|
214
|
+
checkAgain().catch(fail).finally(() => rechecking = false);
|
|
215
|
+
}, channelBounds.recheckMs);
|
|
216
|
+
const stayAwake = () => {
|
|
217
|
+
clearTimeout(idle);
|
|
218
|
+
idle = setTimeout(() => shut(), channelBounds.idleMs);
|
|
219
|
+
};
|
|
220
|
+
stayAwake();
|
|
221
|
+
const fail = (error) => {
|
|
222
|
+
report(error);
|
|
223
|
+
shut();
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
/**
|
|
227
|
+
* Handles a text frame after those before it; a binary one, or one that is not a message, gets an error. A frame
|
|
228
|
+
* over the size cap, or of a kind whose size is unknown, closes the socket.
|
|
229
|
+
*/
|
|
230
|
+
message: (frame) => {
|
|
231
|
+
if (!open) return;
|
|
232
|
+
const size = frameBytes(frame);
|
|
233
|
+
if (!(size <= realtimeLimits.maxFrameBytes)) return shut(messageTooBig);
|
|
234
|
+
stayAwake();
|
|
235
|
+
const message = typeof frame === "string" ? decodeClientMessage(frame) : void 0;
|
|
236
|
+
if (!message) return push({
|
|
237
|
+
type: "error",
|
|
238
|
+
error: channelRefusals.invalidMessage
|
|
239
|
+
});
|
|
240
|
+
queuedMessages += 1;
|
|
241
|
+
queuedBytes += size;
|
|
242
|
+
if (queuedMessages > realtimeLimits.maxQueuedMessages || queuedBytes > realtimeLimits.maxQueuedBytes) return shut(policyViolation);
|
|
243
|
+
queue = queue.then(() => handle(message)).catch(fail).finally(() => {
|
|
244
|
+
queuedMessages -= 1;
|
|
245
|
+
queuedBytes -= size;
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
/** Ends every channel of a socket that closed. */
|
|
249
|
+
closed: leaveAll
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
//#endregion
|
|
253
|
+
export { channelSocket };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/channels/presence-entries.ts
|
|
2
|
+
/** The hub's presences as entries: each key is one connection on the channel, so it holds one presence at most. */
|
|
3
|
+
const entriesOf = (state) => Object.entries(state).flatMap(([id, { metas }]) => metas.map(({ user, state: tracked }) => ({
|
|
4
|
+
id,
|
|
5
|
+
user,
|
|
6
|
+
state: tracked
|
|
7
|
+
})));
|
|
8
|
+
/**
|
|
9
|
+
* A hub diff as joins, updates and leaves: a key that left and joined in one diff tracked again, an update. A key
|
|
10
|
+
* that joins without the leave of its old state, as from another process whose leave this one never saw, is a join.
|
|
11
|
+
*/
|
|
12
|
+
const entryDiff = (diff) => {
|
|
13
|
+
const joined = entriesOf(diff.joins);
|
|
14
|
+
const left = entriesOf(diff.leaves);
|
|
15
|
+
const joinedIds = new Set(joined.map((entry) => entry.id));
|
|
16
|
+
const leftIds = new Set(left.map((entry) => entry.id));
|
|
17
|
+
return {
|
|
18
|
+
joins: joined.filter((entry) => !leftIds.has(entry.id)),
|
|
19
|
+
updates: joined.filter((entry) => leftIds.has(entry.id)),
|
|
20
|
+
leaves: left.filter((entry) => !joinedIds.has(entry.id))
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
//#endregion
|
|
24
|
+
export { entriesOf, entryDiff };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { requestAccess } from "../request-access.js";
|
|
3
|
+
import { readTransaction } from "../transactions.js";
|
|
4
|
+
import { fetchReadable } from "../fetch-readable.js";
|
|
5
|
+
//#region src/channels/record-readable.ts
|
|
6
|
+
const refusedStatuses = /* @__PURE__ */ new Set([
|
|
7
|
+
401,
|
|
8
|
+
403,
|
|
9
|
+
404
|
|
10
|
+
]);
|
|
11
|
+
/**
|
|
12
|
+
* Whether the caller may read the record, as `GET /api/<resource>/<key>` decides it: the resource's read access, the
|
|
13
|
+
* tenant, the row filter, the parents read and a record-level read rule. A soft-deleted record is not readable.
|
|
14
|
+
*/
|
|
15
|
+
const recordReadable = async (deps, session, { entry, key }) => {
|
|
16
|
+
const read = async () => {
|
|
17
|
+
const access = await requestAccess(deps, entry, session);
|
|
18
|
+
if (!access.resolved.operations.read) return false;
|
|
19
|
+
const primary = access.full.fields[access.full.primaryKey[0]];
|
|
20
|
+
return await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchReadable(trx, access, key, "hide", { fields: [primary] })) !== void 0;
|
|
21
|
+
};
|
|
22
|
+
return read().catch((error) => {
|
|
23
|
+
if (error instanceof HttpProblem && refusedStatuses.has(error.status)) return false;
|
|
24
|
+
throw error;
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { recordReadable };
|
package/dist/create-admin.js
CHANGED
|
@@ -16,8 +16,11 @@ import { apiCors, readCorsOrigins, withMobileOrigins } from "./api-cors.js";
|
|
|
16
16
|
import { apiKeySettings } from "./api-keys/api-key-settings.js";
|
|
17
17
|
import { apiKeyCaller } from "./api-keys/resolve-caller.js";
|
|
18
18
|
import { jwtVerifier } from "./auth/jwt.js";
|
|
19
|
-
import { relayed } from "./realtime/relay/realtime-options.js";
|
|
20
19
|
import { realtimeApp } from "./realtime/realtime-app.js";
|
|
20
|
+
import { relayed } from "./realtime/relay/realtime-options.js";
|
|
21
|
+
import { realtimeHubs } from "./realtime/realtime-hubs.js";
|
|
22
|
+
import { channelSender } from "./channels/channel-sender.js";
|
|
23
|
+
import { channelRoutes } from "./channels/channel-routes.js";
|
|
21
24
|
import { startRealtimePrune } from "./realtime/realtime-prune.js";
|
|
22
25
|
import { restRoutes } from "./rest/rest-routes.js";
|
|
23
26
|
import { storageRoutes } from "./storage/storage-routes.js";
|
|
@@ -376,6 +379,8 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
376
379
|
}
|
|
377
380
|
});
|
|
378
381
|
const decryptField = fieldDecrypter(deps);
|
|
382
|
+
const hubs = realtimeHubs(deps, realtime, report);
|
|
383
|
+
const channels = channelSender(registry, hubs.records.broadcasts);
|
|
379
384
|
app.route("/", functionRoutes({
|
|
380
385
|
basePath: `${systemPath}/functions`,
|
|
381
386
|
functions: apiFunctions,
|
|
@@ -383,6 +388,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
383
388
|
access: deps.accessOptions,
|
|
384
389
|
db,
|
|
385
390
|
records: (session) => callerRecords(deps, session),
|
|
391
|
+
channels,
|
|
386
392
|
decryptField,
|
|
387
393
|
onError,
|
|
388
394
|
wake: () => wakeDispatch(deps),
|
|
@@ -391,6 +397,19 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
391
397
|
...calls && { calls },
|
|
392
398
|
report
|
|
393
399
|
}));
|
|
400
|
+
if (upgradeWebSocket) {
|
|
401
|
+
const visitors = (caller) => withAnonymous(caller, options.roles);
|
|
402
|
+
app.route(systemPath || "/", channelRoutes({
|
|
403
|
+
deps,
|
|
404
|
+
upgradeWebSocket,
|
|
405
|
+
authenticate: visitors(authenticate),
|
|
406
|
+
authenticateUpgrade: visitors(upgradeAuthenticate),
|
|
407
|
+
onError,
|
|
408
|
+
hubs: hubs.records,
|
|
409
|
+
follow: hubs.follow,
|
|
410
|
+
report
|
|
411
|
+
}));
|
|
412
|
+
}
|
|
394
413
|
const verifyGoTrue = auth ? jwtVerifier(goTrueTokens({ auth })) : () => Promise.reject(unauthorized("This app signs no user tokens"));
|
|
395
414
|
app.route("/", supabaseFunctionRoutes({
|
|
396
415
|
functions: apiFunctions,
|
|
@@ -435,7 +454,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
435
454
|
upgradeWebSocket,
|
|
436
455
|
caller,
|
|
437
456
|
report,
|
|
438
|
-
|
|
457
|
+
hubs
|
|
439
458
|
}));
|
|
440
459
|
if (files?.storage) {
|
|
441
460
|
const storage = {
|
|
@@ -462,6 +481,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
462
481
|
deps,
|
|
463
482
|
subscriptions,
|
|
464
483
|
functions: apiFunctions,
|
|
484
|
+
channels,
|
|
465
485
|
schema: changes,
|
|
466
486
|
log: (line) => console.log(line),
|
|
467
487
|
...table && { table }
|
|
@@ -3,6 +3,7 @@ import { Session } from "../types.js";
|
|
|
3
3
|
import { Caller } from "../api-keys/resolve-caller.js";
|
|
4
4
|
import { SocketEvents } from "./function-socket.js";
|
|
5
5
|
import { CallsAccess } from "../calls/calls-access.js";
|
|
6
|
+
import { ChannelSender } from "../channels/channel-sender.js";
|
|
6
7
|
import { FunctionRecords } from "./records.js";
|
|
7
8
|
import { Capability } from "@protobase/schema";
|
|
8
9
|
import { Db } from "@protobase/query";
|
|
@@ -13,6 +14,8 @@ export type PublicFunctionContext = {
|
|
|
13
14
|
name: string;
|
|
14
15
|
/** The database itself, with no access rules, row filters or tenant scope: privileged work only. */
|
|
15
16
|
db: Db;
|
|
17
|
+
/** Broadcasts to the channel of a record, as the server, to whoever is on it (see `/api/channels`). */
|
|
18
|
+
channels: ChannelSender;
|
|
16
19
|
/** The caller, through the app's own `authenticate`: rejects with a 401 problem when the request carries no valid token. */
|
|
17
20
|
caller: () => Promise<FunctionCaller>;
|
|
18
21
|
/**
|
package/dist/functions/routes.js
CHANGED
|
@@ -24,7 +24,7 @@ const isUpgrade = (request) => request.headers.get("upgrade")?.toLowerCase() ===
|
|
|
24
24
|
* A function may answer an upgrade with a socket, only once it returned. While it is open, its caller is checked again
|
|
25
25
|
* every minute: a protected function's always, a public one's once `caller()` resolved.
|
|
26
26
|
*/
|
|
27
|
-
const functionRoutes = ({ basePath, functions, authenticate, access, db, records, decryptField, onError, wake, upgradeWebSocket, upgradeAuthenticate = authenticate, calls: callsOptions, report }) => {
|
|
27
|
+
const functionRoutes = ({ basePath, functions, authenticate, access, db, records, channels, decryptField, onError, wake, upgradeWebSocket, upgradeAuthenticate = authenticate, calls: callsOptions, report }) => {
|
|
28
28
|
const calls = callsAccess(callsOptions);
|
|
29
29
|
const app = new Hono().basePath(basePath);
|
|
30
30
|
app.onError(onError);
|
|
@@ -68,6 +68,7 @@ const functionRoutes = ({ basePath, functions, authenticate, access, db, records
|
|
|
68
68
|
const context = {
|
|
69
69
|
name,
|
|
70
70
|
db,
|
|
71
|
+
channels,
|
|
71
72
|
caller: resolveCaller,
|
|
72
73
|
acceptWebSocket: acceptor.accept,
|
|
73
74
|
calls
|
|
@@ -95,6 +96,7 @@ const functionRoutes = ({ basePath, functions, authenticate, access, db, records
|
|
|
95
96
|
const context = {
|
|
96
97
|
name,
|
|
97
98
|
db,
|
|
99
|
+
channels,
|
|
98
100
|
...caller,
|
|
99
101
|
caller: async () => caller,
|
|
100
102
|
decryptField: decryptField(reader),
|
package/dist/index.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { FunctionSocket, SocketEvents } from "./functions/function-socket.js";
|
|
|
32
32
|
import { CallMember } from "./calls/call-member.js";
|
|
33
33
|
import { CallsAccess } from "./calls/calls-access.js";
|
|
34
34
|
import { ResourceSource, configExports } from "./resource-source.js";
|
|
35
|
+
import { ChannelBroadcast, ChannelSender } from "./channels/channel-sender.js";
|
|
35
36
|
import { ErrorReportingOptions, ErrorReportingSettings, errorReportingSettings, errorReportingVariable } from "./error-reporting/error-reporting-settings.js";
|
|
36
37
|
import { ChatMessage, ChatTextPart, Completion, CompletionOptions, ModelError, ModelSettings, ReasoningDetail, ReasoningEffort, ToolCall, ToolSpec, streamChatCompletion } from "./assistant/chat-completions.js";
|
|
37
38
|
import { CleanupEntry, CleanupSchedule, ScheduledDelete, storageCleanupSchedule } from "./files/cleanup-schedule.js";
|
|
@@ -174,4 +175,4 @@ import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/ru
|
|
|
174
175
|
import { jobsDrainApp } from "./jobs/drain-route.js";
|
|
175
176
|
import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
|
|
176
177
|
import { AdminAuth, CreateAuthOptions, UserCreatedHook, normalizeRoles } from "./better-auth/create-auth.js";
|
|
177
|
-
export { type Admin, type AdminAuth, type AdminOptions, type AggregateParams, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type ArgumentsPayload, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type AuthHooks, type AuthSettings, type AuthenticateSettings, type Authenticator, type BaseToolContext, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type CallBackend, type CallMember, type Caller, type CallerOptions, type CallsAccess, type CallsOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentToolsInput, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DecryptField, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueJob, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldDecrypter, type FieldReader, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileProviderSettings, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryColumn, type HistoryOptions, type HistoryPosition, type HistoryStore, HistoryStoreError, type HookContext, HttpProblem, type IssueTokenOptions, type JobContext, type JobDeclaration, type JobDefinition, type JobName, type JobOptions, type JobPayload, type JobRun, type JobsRuntime, type JsonObject, type JsonValue, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LiveKitBackend, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpApprovals, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, type Membership, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewDevice, type NewNotification, type NewOrganization, type NewUser, type NotificationRecipients, NotificationSendError, type NotificationsOptions, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type PushAnswer, type PushMessage, PushMessageError, type PushSender, type QueryOptions, type QueryResult, type ReadableResources, type RealtimeOptions, type RealtimePrune, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordGroups, type RecordKey, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type SendNotification, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredNotification, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolComment, type ToolCommentPage, type ToolComments, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type Urgency, type User, type UserCreatedHook, type VapidSettings, type ViewSource, type WebPushFields, type WebPushOptions, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, refusalOf, refused, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
|
|
178
|
+
export { type Admin, type AdminAuth, type AdminOptions, type AggregateParams, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type ArgumentsPayload, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type AuthHooks, type AuthSettings, type AuthenticateSettings, type Authenticator, type BaseToolContext, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type CallBackend, type CallMember, type Caller, type CallerOptions, type CallsAccess, type CallsOptions, type CapturedRow, type CascadingKey, type ChannelBroadcast, type ChannelSender, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentToolsInput, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DecryptField, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueJob, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldDecrypter, type FieldReader, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileProviderSettings, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryColumn, type HistoryOptions, type HistoryPosition, type HistoryStore, HistoryStoreError, type HookContext, HttpProblem, type IssueTokenOptions, type JobContext, type JobDeclaration, type JobDefinition, type JobName, type JobOptions, type JobPayload, type JobRun, type JobsRuntime, type JsonObject, type JsonValue, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LiveKitBackend, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpApprovals, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, type Membership, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewDevice, type NewNotification, type NewOrganization, type NewUser, type NotificationRecipients, NotificationSendError, type NotificationsOptions, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type PushAnswer, type PushMessage, PushMessageError, type PushSender, type QueryOptions, type QueryResult, type ReadableResources, type RealtimeOptions, type RealtimePrune, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordGroups, type RecordKey, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type SendNotification, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredNotification, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolComment, type ToolCommentPage, type ToolComments, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type Urgency, type User, type UserCreatedHook, type VapidSettings, type ViewSource, type WebPushFields, type WebPushOptions, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, refusalOf, refused, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
|
package/dist/package.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { fixedWindows } from "./fixed-window.js";
|
|
2
1
|
import { realtimeLimits } from "./realtime-limits.js";
|
|
2
|
+
import { fixedWindows } from "./fixed-window.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; with several processes,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { HttpProblem } from "../problem.js";
|
|
2
|
-
import { realtimeLimits } from "./realtime-limits.js";
|
|
3
2
|
import { fitsFrame } from "./broadcast-frame.js";
|
|
3
|
+
import { realtimeLimits } from "./realtime-limits.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 });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { writeTransaction } from "../transactions.js";
|
|
2
|
-
import { realtimeLimits } from "./realtime-limits.js";
|
|
3
2
|
import { fitsFrame } from "./broadcast-frame.js";
|
|
3
|
+
import { realtimeLimits } from "./realtime-limits.js";
|
|
4
4
|
import { sql } from "kysely";
|
|
5
5
|
import { Buffer } from "node:buffer";
|
|
6
6
|
//#region src/realtime/database-broadcasts.ts
|
|
@@ -1,26 +1,10 @@
|
|
|
1
|
-
import { broadcastHub } from "./broadcast-hub.js";
|
|
2
1
|
import { changeChecker } from "./change-checker.js";
|
|
3
2
|
import { changeHub } from "./change-hub.js";
|
|
4
3
|
import { channelAccess } from "./channel-access.js";
|
|
5
4
|
import { filterCheck } from "./filter-check.js";
|
|
6
5
|
import { databaseBroadcasts } from "./database-broadcasts.js";
|
|
7
|
-
import { presenceHub } from "./presence-hub.js";
|
|
8
6
|
import { realtimeRoutes } from "./realtime-routes.js";
|
|
9
|
-
import { relayed } from "./relay/realtime-options.js";
|
|
10
|
-
import { relayedHubs } from "./relay/relayed-hubs.js";
|
|
11
7
|
//#region src/realtime/realtime-app.ts
|
|
12
|
-
const hubsOf = (deps, options, report) => {
|
|
13
|
-
if (relayed(options)) return relayedHubs({
|
|
14
|
-
db: deps.db,
|
|
15
|
-
statementTimeoutMs: deps.statementTimeoutMs,
|
|
16
|
-
report
|
|
17
|
-
});
|
|
18
|
-
return {
|
|
19
|
-
broadcasts: broadcastHub(),
|
|
20
|
-
presences: presenceHub(),
|
|
21
|
-
follow: () => () => {}
|
|
22
|
-
};
|
|
23
|
-
};
|
|
24
8
|
/**
|
|
25
9
|
* `/api/sb/realtime/v1`: the `postgres_changes` of the live resources, each checked under the subscriber's role, claims and
|
|
26
10
|
* the tables' RLS policies before it is sent, and broadcasts and presence between the channels on a topic, of this
|
|
@@ -28,7 +12,7 @@ const hubsOf = (deps, options, report) => {
|
|
|
28
12
|
* private channel as the policies on `realtime.messages` allow, and the broadcasts the database sends with
|
|
29
13
|
* `realtime.send()`.
|
|
30
14
|
*/
|
|
31
|
-
const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report,
|
|
15
|
+
const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report, hubs }) => {
|
|
32
16
|
const live = deps.registry.entries.filter((entry) => entry.model.live);
|
|
33
17
|
const check = changeChecker({
|
|
34
18
|
db: deps.db,
|
|
@@ -49,7 +33,7 @@ const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report, options })
|
|
|
49
33
|
db: deps.db,
|
|
50
34
|
statementTimeoutMs: deps.statementTimeoutMs
|
|
51
35
|
});
|
|
52
|
-
const { broadcasts, presences, follow } =
|
|
36
|
+
const { topics: { broadcasts, presences }, follow } = hubs;
|
|
53
37
|
const database = databaseBroadcasts({
|
|
54
38
|
db: deps.db,
|
|
55
39
|
statementTimeoutMs: deps.statementTimeoutMs,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { broadcastHub } from "./broadcast-hub.js";
|
|
2
|
+
import { presenceHub } from "./presence-hub.js";
|
|
3
|
+
import { relayed } from "./relay/realtime-options.js";
|
|
4
|
+
import { relayedHubs } from "./relay/relayed-hubs.js";
|
|
5
|
+
//#region src/realtime/realtime-hubs.ts
|
|
6
|
+
/**
|
|
7
|
+
* The broadcast and presence hubs of a process: those of the topic channels of `/api/sb/realtime/v1`, and apart from
|
|
8
|
+
* them those of the record channels of `/api/channels`, so neither hears the other. With `processes: 'many'` both go
|
|
9
|
+
* through the one relay, which `follow` reads while a socket of either is open.
|
|
10
|
+
*/
|
|
11
|
+
const realtimeHubs = (deps, options, report) => {
|
|
12
|
+
if (relayed(options)) return relayedHubs({
|
|
13
|
+
db: deps.db,
|
|
14
|
+
statementTimeoutMs: deps.statementTimeoutMs,
|
|
15
|
+
report
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
topics: {
|
|
19
|
+
broadcasts: broadcastHub(),
|
|
20
|
+
presences: presenceHub()
|
|
21
|
+
},
|
|
22
|
+
records: {
|
|
23
|
+
broadcasts: broadcastHub(),
|
|
24
|
+
presences: presenceHub()
|
|
25
|
+
},
|
|
26
|
+
follow: () => () => {}
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { realtimeHubs };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { HttpProblem } from "../problem.js";
|
|
2
|
-
import { fixedWindow, fixedWindows } from "./fixed-window.js";
|
|
3
|
-
import { realtimeLimits } from "./realtime-limits.js";
|
|
4
2
|
import { callerSettings } from "../rest/caller-settings.js";
|
|
5
3
|
import { parseBindings } from "./postgres-changes.js";
|
|
6
4
|
import "./filter-check.js";
|
|
7
5
|
import { decodeBroadcastPush, encodeBroadcast, fitsFrame } from "./broadcast-frame.js";
|
|
6
|
+
import { realtimeLimits } from "./realtime-limits.js";
|
|
8
7
|
import { decodeMessage, encodeMessage, reply } from "./phoenix-message.js";
|
|
9
8
|
import { privateGate, publicGate, readRefusalText } from "./channel-gate.js";
|
|
9
|
+
import { fixedWindow, fixedWindows } from "./fixed-window.js";
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
11
|
import { Buffer } from "node:buffer";
|
|
12
12
|
//#region src/realtime/realtime-socket.ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/realtime/relay/realtime-options.d.ts
|
|
2
|
-
/** How `/api/sb/realtime/v1`
|
|
2
|
+
/** How `/api/channels` and `/api/sb/realtime/v1` are served (`realtime` in `protobase.config.ts`). */
|
|
3
3
|
export type RealtimeOptions = {
|
|
4
4
|
/**
|
|
5
5
|
* `'one'`, the default, when one process serves the app: broadcasts and presence go between its channels in memory.
|
|
@@ -95,9 +95,9 @@ const realtimeRelay = ({ db, statementTimeoutMs, heard, loaded, tracked, report,
|
|
|
95
95
|
/** Resolves once the other processes' presences are read, or their snapshot failed. */
|
|
96
96
|
ready: () => run?.ready ?? Promise.resolve(),
|
|
97
97
|
/** Counts and adds the broadcasts as frames, all of them or, over a topic's rate, none: `false` then. */
|
|
98
|
-
broadcast: (publications) => admitBroadcasts(db, statementTimeoutMs, self, publications),
|
|
98
|
+
broadcast: (space, publications) => admitBroadcasts(db, statementTimeoutMs, self, space, publications),
|
|
99
99
|
/** Writes a change of this process's presences, after those before it. */
|
|
100
|
-
changed: (change) => void inTurn(() => writePresence(db, self, change))
|
|
100
|
+
changed: (space, change) => void inTurn(() => writePresence(db, self, space, change))
|
|
101
101
|
};
|
|
102
102
|
};
|
|
103
103
|
//#endregion
|
|
@@ -12,7 +12,7 @@ const jsonOf = (payload) => payload instanceof ArrayBuffer ? null : JSON.stringi
|
|
|
12
12
|
* Counts the broadcasts into each topic's second, every process's together, and adds them as frames, all of them or,
|
|
13
13
|
* when a topic would take more than its broadcasts for this second, none: `false` then.
|
|
14
14
|
*/
|
|
15
|
-
const admitBroadcasts = async (db, statementTimeoutMs, self, publications) => {
|
|
15
|
+
const admitBroadcasts = async (db, statementTimeoutMs, self, space, publications) => {
|
|
16
16
|
if (publications.length === 0) return true;
|
|
17
17
|
const takes = /* @__PURE__ */ new Map();
|
|
18
18
|
for (const { topic } of publications) takes.set(topic, (takes.get(topic) ?? 0) + 1);
|
|
@@ -24,8 +24,8 @@ const admitBroadcasts = async (db, statementTimeoutMs, self, publications) => {
|
|
|
24
24
|
where r.second <> excluded.second or r.count + excluded.count <= ${realtimeLimits.topicBroadcastsPerSecond}
|
|
25
25
|
returning topic`.execute(trx);
|
|
26
26
|
if (rows.length < takes.size) throw new Refused();
|
|
27
|
-
await sql`insert into realtime_relay.frames (process, kind, topic, private, event, payload, bytes, meta)
|
|
28
|
-
select ${self}::uuid, 'broadcast', topic, private, event, payload::json, decode(bytes, 'base64'), meta::json
|
|
27
|
+
await sql`insert into realtime_relay.frames (process, kind, space, topic, private, event, payload, bytes, meta)
|
|
28
|
+
select ${self}::uuid, 'broadcast', ${space}::text, topic, private, event, payload::json, decode(bytes, 'base64'), meta::json
|
|
29
29
|
from unnest(${publications.map((p) => p.topic)}::text[], ${publications.map((p) => p.private)}::boolean[], ${publications.map((p) => p.event)}::text[],
|
|
30
30
|
${publications.map((p) => jsonOf(p.payload))}::text[], ${publications.map((p) => bytesOf(p.payload))}::text[], ${publications.map((p) => p.meta ? JSON.stringify(p.meta) : null)}::text[])
|
|
31
31
|
with ordinality as f(topic, private, event, payload, bytes, meta, position)
|
|
@@ -38,8 +38,10 @@ const admitBroadcasts = async (db, statementTimeoutMs, self, publications) => {
|
|
|
38
38
|
};
|
|
39
39
|
const frameOf = (row) => {
|
|
40
40
|
const id = BigInt(row.id);
|
|
41
|
+
const { space } = row;
|
|
41
42
|
if (row.kind === "presence") return {
|
|
42
43
|
id,
|
|
44
|
+
space,
|
|
43
45
|
kind: "presence",
|
|
44
46
|
topic: row.topic,
|
|
45
47
|
private: row.private,
|
|
@@ -48,6 +50,7 @@ const frameOf = (row) => {
|
|
|
48
50
|
const payload = row.bytes ? new Uint8Array(row.bytes).buffer : row.payload;
|
|
49
51
|
return {
|
|
50
52
|
id,
|
|
53
|
+
space,
|
|
51
54
|
kind: "broadcast",
|
|
52
55
|
publication: {
|
|
53
56
|
topic: row.topic,
|
|
@@ -63,7 +66,7 @@ const frameOf = (row) => {
|
|
|
63
66
|
* other processes' frames, to deliver.
|
|
64
67
|
*/
|
|
65
68
|
const readFrames = async (db, self, after, gaps) => {
|
|
66
|
-
const { rows } = await sql`select id::text as id, process = ${self}::uuid as own, kind, topic, private, event,
|
|
69
|
+
const { rows } = await sql`select id::text as id, process = ${self}::uuid as own, kind, space, topic, private, event,
|
|
67
70
|
case when process <> ${self}::uuid then payload end as payload, case when process <> ${self}::uuid then bytes end as bytes, meta
|
|
68
71
|
from realtime_relay.frames where id > ${String(after)}::bigint or id = any(${gaps.map(String)}::bigint[])
|
|
69
72
|
order by id limit ${frameBatch}`.execute(db);
|
|
@@ -12,23 +12,23 @@ const joinFrame = sql.raw(`json_build_object('joins', json_build_object(key, jso
|
|
|
12
12
|
* Keeps the change in `presences` and adds its frame, in one statement: a leave alone is sent only when the presence
|
|
13
13
|
* was there, as a process that was taken for stopped had its presences removed already.
|
|
14
14
|
*/
|
|
15
|
-
const writePresence = async (db, self, change) => {
|
|
15
|
+
const writePresence = async (db, self, space, change) => {
|
|
16
16
|
const ref = change.joined?.phx_ref ?? null;
|
|
17
17
|
await sql`with gone as (delete from realtime_relay.presences where process = ${self}::uuid and ref = ${change.left?.phx_ref ?? null}::text returning ref),
|
|
18
|
-
added as (insert into realtime_relay.presences (process, ref, topic, private, key, meta)
|
|
19
|
-
select ${self}::uuid, ${ref}::text, ${change.topic}::text, ${change.private}::boolean, ${change.key}::text, ${JSON.stringify(change.joined ?? null)}::json where ${ref}::text is not null)
|
|
20
|
-
insert into realtime_relay.frames (process, kind, topic, private, payload)
|
|
21
|
-
select ${self}::uuid, 'presence', ${change.topic}::text, ${change.private}::boolean, ${JSON.stringify(diffOf(change))}::json
|
|
18
|
+
added as (insert into realtime_relay.presences (process, ref, space, topic, private, key, meta)
|
|
19
|
+
select ${self}::uuid, ${ref}::text, ${space}::text, ${change.topic}::text, ${change.private}::boolean, ${change.key}::text, ${JSON.stringify(change.joined ?? null)}::json where ${ref}::text is not null)
|
|
20
|
+
insert into realtime_relay.frames (process, kind, space, topic, private, payload)
|
|
21
|
+
select ${self}::uuid, 'presence', ${space}::text, ${change.topic}::text, ${change.private}::boolean, ${JSON.stringify(diffOf(change))}::json
|
|
22
22
|
where ${ref}::text is not null or exists (select from gone)`.execute(db);
|
|
23
23
|
};
|
|
24
24
|
/** Adds the presences back, with their joins, for a process that was taken for stopped while it was not. */
|
|
25
25
|
const restorePresences = async (db, self, tracked) => {
|
|
26
26
|
if (tracked.length === 0) return;
|
|
27
|
-
await sql`with added as (insert into realtime_relay.presences (process, ref, topic, private, key, meta)
|
|
28
|
-
select ${self}::uuid, ref, topic, private, key, meta::json from unnest(${tracked.map((t) => t.meta.phx_ref)}::text[], ${tracked.map((t) => t.topic)}::text[],
|
|
29
|
-
${tracked.map((t) => t.private)}::boolean[], ${tracked.map((t) => t.key)}::text[], ${tracked.map((t) => JSON.stringify(t.meta))}::text[]) as t(ref, topic, private, key, meta)
|
|
30
|
-
on conflict do nothing returning process, topic, private, key, meta)
|
|
31
|
-
insert into realtime_relay.frames (process, kind, topic, private, payload) select process, 'presence', topic, private, ${joinFrame} from added`.execute(db);
|
|
27
|
+
await sql`with added as (insert into realtime_relay.presences (process, ref, space, topic, private, key, meta)
|
|
28
|
+
select ${self}::uuid, ref, space, topic, private, key, meta::json from unnest(${tracked.map((t) => t.meta.phx_ref)}::text[], ${tracked.map((t) => t.space)}::text[], ${tracked.map((t) => t.topic)}::text[],
|
|
29
|
+
${tracked.map((t) => t.private)}::boolean[], ${tracked.map((t) => t.key)}::text[], ${tracked.map((t) => JSON.stringify(t.meta))}::text[]) as t(ref, space, topic, private, key, meta)
|
|
30
|
+
on conflict do nothing returning process, space, topic, private, key, meta)
|
|
31
|
+
insert into realtime_relay.frames (process, kind, space, topic, private, payload) select process, 'presence', space, topic, private, ${joinFrame} from added`.execute(db);
|
|
32
32
|
};
|
|
33
33
|
/**
|
|
34
34
|
* Marks this process as running, by the database's clock: `true` when its row was not there, as at its start or once
|
|
@@ -41,8 +41,8 @@ const beat = async (db, self) => {
|
|
|
41
41
|
};
|
|
42
42
|
const retire = (db, dead) => sql`with dead as (delete from realtime_relay.processes where ${dead} returning id),
|
|
43
43
|
gone as (delete from realtime_relay.presences p where p.process in (select id from dead) or not exists (select from realtime_relay.processes q where q.id = p.process)
|
|
44
|
-
returning process, topic, private, key, meta)
|
|
45
|
-
insert into realtime_relay.frames (process, kind, topic, private, payload) select process, 'presence', topic, private, ${leaveFrame} from gone`.execute(db);
|
|
44
|
+
returning process, space, topic, private, key, meta)
|
|
45
|
+
insert into realtime_relay.frames (process, kind, space, topic, private, payload) select process, 'presence', space, topic, private, ${leaveFrame} from gone`.execute(db);
|
|
46
46
|
/** Removes the processes that have not beaten for `staleSeconds`, and their presences. */
|
|
47
47
|
const retireStale = async (db, staleSeconds) => {
|
|
48
48
|
await retire(db, sql`seen_at < now() - make_interval(secs => ${staleSeconds})`);
|
|
@@ -57,7 +57,7 @@ const retireSelf = async (db, self) => {
|
|
|
57
57
|
*/
|
|
58
58
|
const presenceSnapshot = (db, statementTimeoutMs, self) => db.transaction().setIsolationLevel("repeatable read").execute(async (trx) => {
|
|
59
59
|
await sql`select set_config('statement_timeout', ${String(statementTimeoutMs)}, true)`.execute(trx);
|
|
60
|
-
const presences = await sql`select topic, private, key, meta from realtime_relay.presences where process <> ${self}::uuid`.execute(trx);
|
|
60
|
+
const presences = await sql`select space, topic, private, key, meta from realtime_relay.presences where process <> ${self}::uuid`.execute(trx);
|
|
61
61
|
const newest = await sql`select coalesce(max(id), 0)::text as id from realtime_relay.frames`.execute(trx);
|
|
62
62
|
const recent = await sql`select id::text as id from realtime_relay.frames
|
|
63
63
|
where id >= (select min(id) from realtime_relay.frames where at > now() - interval '2 seconds') order by id`.execute(trx);
|
|
@@ -8,8 +8,12 @@ const relayTables = [
|
|
|
8
8
|
"processes",
|
|
9
9
|
"rates"
|
|
10
10
|
];
|
|
11
|
+
/** The tables with a `space` column, which a relay schema written before it lacks. */
|
|
12
|
+
const spacedTables = ["frames", "presences"];
|
|
11
13
|
/**
|
|
12
14
|
* - `frames`: what a process sends, a broadcast or a presence diff, read by the others and pruned after a minute.
|
|
15
|
+
* - `space` in `frames` and `presences`: the channels they are of (`RelaySpace`), `topics` for a row written without
|
|
16
|
+
* one, as by a process of a version before the record channels.
|
|
13
17
|
* - `presences`: what each process's channels track, which a process that starts reads.
|
|
14
18
|
* - `processes`: each process's last heartbeat; a process silent too long has its presences removed.
|
|
15
19
|
* - `rates`: each topic's broadcasts in the current second, every process's together.
|
|
@@ -22,6 +26,7 @@ create table if not exists ${relaySchema}.frames (
|
|
|
22
26
|
kind text not null check (kind in ('broadcast', 'presence')),
|
|
23
27
|
topic text not null,
|
|
24
28
|
private boolean not null,
|
|
29
|
+
space text not null default 'topics' check (space in ('topics', 'records')),
|
|
25
30
|
event text,
|
|
26
31
|
payload json,
|
|
27
32
|
bytes bytea,
|
|
@@ -41,6 +46,7 @@ create table if not exists ${relaySchema}.presences (
|
|
|
41
46
|
ref text not null,
|
|
42
47
|
topic text not null,
|
|
43
48
|
private boolean not null,
|
|
49
|
+
space text not null default 'topics' check (space in ('topics', 'records')),
|
|
44
50
|
key text not null,
|
|
45
51
|
meta json not null,
|
|
46
52
|
primary key (process, ref)
|
|
@@ -50,6 +56,9 @@ create table if not exists ${relaySchema}.rates (
|
|
|
50
56
|
topic text primary key,
|
|
51
57
|
second bigint not null,
|
|
52
58
|
count integer not null
|
|
53
|
-
)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
-- A relay schema written before the record channels gets the column too.
|
|
62
|
+
${spacedTables.map((table) => `alter table ${relaySchema}.${table} add column if not exists space text not null default 'topics' check (space in ('topics', 'records'));`).join("\n")}`;
|
|
54
63
|
//#endregion
|
|
55
|
-
export { relaySchema, relaySchemaSql, relayTables };
|
|
64
|
+
export { relaySchema, relaySchemaSql, relayTables, spacedTables };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Db } from "@protobase/query";
|
|
2
2
|
//#region src/realtime/relay/relay-schema.d.ts
|
|
3
3
|
/** The SQL that adds the `realtime_relay` schema `realtime: { processes: 'many' }` needs; `undefined` when it is there. */
|
|
4
|
-
export declare const realtimeRelayMigration: (db: Db) => Promise<
|
|
4
|
+
export declare const realtimeRelayMigration: (db: Db) => Promise<string | undefined>;
|
|
5
5
|
/** Refuses to go on while the `realtime_relay` schema is missing; the project's migrations add it. */
|
|
6
6
|
export declare const checkRealtimeRelay: (db: Db) => Promise<void>;
|
|
7
7
|
//#endregion
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { migrationCommand } from "../../changes/capture-schema.js";
|
|
2
|
-
import { relaySchema, relaySchemaSql, relayTables } from "./relay-schema-sql.js";
|
|
2
|
+
import { relaySchema, relaySchemaSql, relayTables, spacedTables } from "./relay-schema-sql.js";
|
|
3
3
|
import { sql } from "kysely";
|
|
4
4
|
//#region src/realtime/relay/relay-schema.ts
|
|
5
5
|
const relayCurrent = async (db) => {
|
|
6
6
|
const names = relayTables.map((table) => `${relaySchema}.${table}`);
|
|
7
7
|
const { rows } = await sql`select bool_and(to_regclass(name) is not null) as found from unnest(${names}::text[]) as name`.execute(db);
|
|
8
|
-
|
|
8
|
+
if (!rows[0].found) return false;
|
|
9
|
+
return (await sql`select count(*)::int as count from information_schema.columns
|
|
10
|
+
where table_schema = ${relaySchema} and table_name = any(${[...spacedTables]}::text[]) and column_name = 'space'`.execute(db)).rows[0].count === spacedTables.length;
|
|
9
11
|
};
|
|
10
12
|
/** The SQL that adds the `realtime_relay` schema `realtime: { processes: 'many' }` needs; `undefined` when it is there. */
|
|
11
13
|
const realtimeRelayMigration = async (db) => await relayCurrent(db) ? void 0 : relaySchemaSql;
|
|
@@ -4,56 +4,79 @@ import { presenceReplica } from "./presence-replica.js";
|
|
|
4
4
|
import { realtimeRelay } from "./realtime-relay.js";
|
|
5
5
|
//#region src/realtime/relay/relayed-hubs.ts
|
|
6
6
|
/**
|
|
7
|
-
* The broadcast and presence hubs of `realtime: { processes: 'many' }
|
|
8
|
-
* process, and every broadcast and presence change written to the relay for
|
|
9
|
-
* channels here as the relay reads them
|
|
10
|
-
*
|
|
11
|
-
* relay while a socket is open.
|
|
7
|
+
* The broadcast and presence hubs of `realtime: { processes: 'many' }`, one pair for each space (`RelaySpace`): this
|
|
8
|
+
* process's channels in memory, as with one process, and every broadcast and presence change written to the relay for
|
|
9
|
+
* the other processes, whose own reach the channels here as the relay reads them, in the space they were sent in. A
|
|
10
|
+
* topic's private and public channels stay apart across processes too. A broadcast reaches this process's channels
|
|
11
|
+
* once the relay admitted it under the topic's rate. `follow` reads the relay while a socket is open.
|
|
12
12
|
*/
|
|
13
13
|
const relayedHubs = ({ db, statementTimeoutMs, report }) => {
|
|
14
|
-
const
|
|
15
|
-
const replica = presenceReplica();
|
|
14
|
+
const spaces = /* @__PURE__ */ new Map();
|
|
16
15
|
const relay = realtimeRelay({
|
|
17
16
|
db,
|
|
18
17
|
statementTimeoutMs,
|
|
19
18
|
report,
|
|
20
19
|
heard: (frame) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (
|
|
20
|
+
const space = spaces.get(frame.space);
|
|
21
|
+
if (!space) return;
|
|
22
|
+
if (frame.kind === "broadcast") return space.local.deliver(frame.publication);
|
|
23
|
+
const diff = space.replica.apply(frame.topic, frame.private, frame.diff);
|
|
24
|
+
if (diff) space.presences.hear(frame.topic, frame.private, diff);
|
|
24
25
|
},
|
|
25
|
-
loaded:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
changed: relay.changed,
|
|
33
|
-
ready: relay.ready
|
|
34
|
-
}
|
|
26
|
+
loaded: (tracked) => {
|
|
27
|
+
for (const [name, space] of spaces) space.replica.load(tracked.filter((presence) => presence.space === name));
|
|
28
|
+
},
|
|
29
|
+
tracked: () => [...spaces].flatMap(([name, space]) => space.presences.tracked().map((presence) => ({
|
|
30
|
+
space: name,
|
|
31
|
+
...presence
|
|
32
|
+
})))
|
|
35
33
|
});
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
34
|
+
const spaceOf = (name) => {
|
|
35
|
+
const local = broadcastHub();
|
|
36
|
+
const replica = presenceReplica();
|
|
37
|
+
return {
|
|
38
|
+
local,
|
|
39
|
+
replica,
|
|
40
|
+
presences: presenceHub({
|
|
41
|
+
refPrefix: `${relay.id}:`,
|
|
42
|
+
remote: {
|
|
43
|
+
state: replica.state,
|
|
44
|
+
changed: (change) => relay.changed(name, change),
|
|
45
|
+
ready: relay.ready
|
|
46
|
+
}
|
|
47
|
+
}),
|
|
48
|
+
broadcasts: {
|
|
49
|
+
join: local.join,
|
|
50
|
+
send: async (topic, from, event, payload) => {
|
|
51
|
+
const publication = {
|
|
52
|
+
topic,
|
|
53
|
+
event,
|
|
54
|
+
payload,
|
|
55
|
+
private: from.private
|
|
56
|
+
};
|
|
57
|
+
if (!await relay.broadcast(name, [publication])) return false;
|
|
58
|
+
local.deliver(publication, from);
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
publish: async (publications) => {
|
|
62
|
+
if (!await relay.broadcast(name, publications)) return false;
|
|
63
|
+
for (const publication of publications) local.deliver(publication);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
54
66
|
}
|
|
55
|
-
}
|
|
56
|
-
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
for (const name of ["topics", "records"]) spaces.set(name, spaceOf(name));
|
|
70
|
+
const hubsOf = (name) => {
|
|
71
|
+
const { broadcasts, presences } = spaces.get(name);
|
|
72
|
+
return {
|
|
73
|
+
broadcasts,
|
|
74
|
+
presences
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
topics: hubsOf("topics"),
|
|
79
|
+
records: hubsOf("records"),
|
|
57
80
|
follow: relay.follow
|
|
58
81
|
};
|
|
59
82
|
};
|
package/dist/webhooks/attempt.js
CHANGED
|
@@ -53,6 +53,7 @@ const callFunction = async (name, subscription, event, options, context, signal)
|
|
|
53
53
|
const functionContext = {
|
|
54
54
|
name,
|
|
55
55
|
db: context.db,
|
|
56
|
+
channels: context.channels,
|
|
56
57
|
caller: async () => caller,
|
|
57
58
|
acceptWebSocket,
|
|
58
59
|
calls: { join: async () => acceptWebSocket() }
|
|
@@ -60,7 +60,7 @@ const deliveryGroup = ({ subscription, change }) => `${subscription.name}:${chan
|
|
|
60
60
|
*
|
|
61
61
|
* No jobs without a resource with events or history; the subscriptions must name declared events.
|
|
62
62
|
*/
|
|
63
|
-
const createWebhooks = ({ deps, subscriptions, functions, schema, log, table }) => {
|
|
63
|
+
const createWebhooks = ({ deps, subscriptions, functions, channels, schema, log, table }) => {
|
|
64
64
|
const entries = deps.registry.entries;
|
|
65
65
|
const models = entries.map((entry) => entry.model);
|
|
66
66
|
const problems = subscriptionProblems(subscriptions, models, Object.keys(functions));
|
|
@@ -82,6 +82,7 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
|
|
|
82
82
|
db,
|
|
83
83
|
functions,
|
|
84
84
|
records,
|
|
85
|
+
channels,
|
|
85
86
|
attempt: tried,
|
|
86
87
|
signal,
|
|
87
88
|
...checkUrl && { checkUrl }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
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": {
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"@better-auth/sso": "^1.7.12",
|
|
43
43
|
"@livekit/protocol": "1.53.1",
|
|
44
44
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
45
|
-
"@protobase/layout": "0.
|
|
46
|
-
"@protobase/query": "0.
|
|
47
|
-
"@protobase/schema": "0.
|
|
45
|
+
"@protobase/layout": "0.59.0",
|
|
46
|
+
"@protobase/query": "0.59.0",
|
|
47
|
+
"@protobase/schema": "0.59.0",
|
|
48
48
|
"@react-email/render": "^2.1.0",
|
|
49
49
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
50
50
|
"@sentry/core": "^11.7.0",
|