@mitway/sdk 0.2.2 → 0.2.4
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/index.cjs +615 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +304 -24
- package/dist/index.d.ts +304 -24
- package/dist/index.js +613 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.cjs
CHANGED
|
@@ -26,6 +26,8 @@ __export(index_exports, {
|
|
|
26
26
|
Logger: () => Logger,
|
|
27
27
|
MitwayBaasClient: () => MitwayBaasClient,
|
|
28
28
|
MitwayBaasError: () => MitwayBaasError,
|
|
29
|
+
Realtime: () => Realtime,
|
|
30
|
+
RealtimeChannel: () => RealtimeChannel,
|
|
29
31
|
TokenManager: () => TokenManager,
|
|
30
32
|
createClient: () => createClient,
|
|
31
33
|
default: () => index_default
|
|
@@ -888,18 +890,629 @@ var Database = class {
|
|
|
888
890
|
}
|
|
889
891
|
};
|
|
890
892
|
|
|
893
|
+
// src/modules/realtime.ts
|
|
894
|
+
var import_socket = require("socket.io-client");
|
|
895
|
+
var PRESENCE_HEARTBEAT_MS = 2e4;
|
|
896
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
897
|
+
var RealtimeChannel = class {
|
|
898
|
+
constructor(topic, realtime, options = {}) {
|
|
899
|
+
this.topic = topic;
|
|
900
|
+
this.realtime = realtime;
|
|
901
|
+
this.options = options;
|
|
902
|
+
}
|
|
903
|
+
topic;
|
|
904
|
+
realtime;
|
|
905
|
+
bindings = [];
|
|
906
|
+
state = "closed";
|
|
907
|
+
statusCallback = null;
|
|
908
|
+
/** Local presence state mirror — populated from `presence_state` /
|
|
909
|
+
* `presence_join` / `presence_leave` events. Read via `presenceState()`. */
|
|
910
|
+
presence = {};
|
|
911
|
+
/** Latest state this client has tracked. Non-null means the heartbeat
|
|
912
|
+
* timer is active and we'll re-emit this state every TTL/2. */
|
|
913
|
+
trackedState = null;
|
|
914
|
+
presenceHeartbeat = null;
|
|
915
|
+
/** Timestamp of the most recently received broadcast on this channel —
|
|
916
|
+
* used as the `since` anchor on replay after reconnect. ISO-8601. */
|
|
917
|
+
lastBroadcastTimestamp = null;
|
|
918
|
+
/** Configuration from `channel(topic, opts)`. Frozen at construction. */
|
|
919
|
+
options;
|
|
920
|
+
/** Whether this channel was opened as `private: true`. */
|
|
921
|
+
get isPrivate() {
|
|
922
|
+
return this.options.config?.private === true;
|
|
923
|
+
}
|
|
924
|
+
/** The user-supplied presence key, if any. */
|
|
925
|
+
get presenceKey() {
|
|
926
|
+
return this.options.config?.presence?.key;
|
|
927
|
+
}
|
|
928
|
+
/** Internal — exposed for Realtime to drive resubscription after a
|
|
929
|
+
* network hiccup. Returns the current lifecycle state. */
|
|
930
|
+
_state() {
|
|
931
|
+
return this.state;
|
|
932
|
+
}
|
|
933
|
+
/** Internal — called by `Realtime` when Socket.IO reconnects after a
|
|
934
|
+
* drop. Re-runs the registration flow; the backend assigns fresh
|
|
935
|
+
* subscription_ids and Socket.IO rejoins the per-subscription rooms,
|
|
936
|
+
* so events resume without developer intervention. The user-provided
|
|
937
|
+
* statusCallback (from the original subscribe()) fires again with
|
|
938
|
+
* 'SUBSCRIBED' or 'CHANNEL_ERROR' so the app can reflect state. */
|
|
939
|
+
async _rejoinAfterReconnect() {
|
|
940
|
+
if (this.state === "closed") {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
for (const b of this.bindings) {
|
|
944
|
+
if (b.type === "postgres_changes") {
|
|
945
|
+
b.subscriptionId = void 0;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
this.state = "joining";
|
|
949
|
+
try {
|
|
950
|
+
await this.registerAllBindings();
|
|
951
|
+
if (this.trackedState) {
|
|
952
|
+
const socket = this.realtime._getSocket();
|
|
953
|
+
const key = this.presenceKey;
|
|
954
|
+
socket?.emit(
|
|
955
|
+
"realtime:presence:track",
|
|
956
|
+
key !== void 0 ? { channel: this.topic, state: this.trackedState, key } : { channel: this.topic, state: this.trackedState }
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
if (this.lastBroadcastTimestamp && this.bindings.some((b) => b.type === "broadcast")) {
|
|
960
|
+
void this.replay({ since: this.lastBroadcastTimestamp }).catch(() => void 0);
|
|
961
|
+
}
|
|
962
|
+
this.state = "joined";
|
|
963
|
+
this.statusCallback?.("SUBSCRIBED");
|
|
964
|
+
} catch (err) {
|
|
965
|
+
this.state = "errored";
|
|
966
|
+
this.statusCallback?.("CHANNEL_ERROR", {
|
|
967
|
+
code: "REJOIN_FAILED",
|
|
968
|
+
message: err instanceof Error ? err.message : String(err)
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
on(type, filter, callback) {
|
|
973
|
+
if (type === "postgres_changes") {
|
|
974
|
+
this.bindings.push({
|
|
975
|
+
type: "postgres_changes",
|
|
976
|
+
filter,
|
|
977
|
+
callback
|
|
978
|
+
});
|
|
979
|
+
} else if (type === "broadcast") {
|
|
980
|
+
this.bindings.push({
|
|
981
|
+
type: "broadcast",
|
|
982
|
+
filter,
|
|
983
|
+
callback
|
|
984
|
+
});
|
|
985
|
+
} else {
|
|
986
|
+
this.bindings.push({
|
|
987
|
+
type: "presence",
|
|
988
|
+
filter,
|
|
989
|
+
callback
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
return this;
|
|
993
|
+
}
|
|
994
|
+
// -------------------------------------------------------------------------
|
|
995
|
+
// Presence — track / untrack / state accessor
|
|
996
|
+
// -------------------------------------------------------------------------
|
|
997
|
+
/**
|
|
998
|
+
* Register or refresh this client's presence entry on the channel. Safe
|
|
999
|
+
* to call many times — state replaces (not merges). Starts a heartbeat
|
|
1000
|
+
* timer at TTL/2 so the entry stays alive while the socket is open.
|
|
1001
|
+
* The channel must be `subscribe()`d first (the server enforces this).
|
|
1002
|
+
*/
|
|
1003
|
+
async track(state) {
|
|
1004
|
+
const socket = this.realtime._getSocket();
|
|
1005
|
+
if (!socket) {
|
|
1006
|
+
throw new MitwayBaasError("Socket not connected", 503, "NOT_CONNECTED");
|
|
1007
|
+
}
|
|
1008
|
+
this.trackedState = state;
|
|
1009
|
+
const key = this.presenceKey;
|
|
1010
|
+
socket.emit(
|
|
1011
|
+
"realtime:presence:track",
|
|
1012
|
+
key !== void 0 ? { channel: this.topic, state, key } : { channel: this.topic, state }
|
|
1013
|
+
);
|
|
1014
|
+
if (!this.presenceHeartbeat) {
|
|
1015
|
+
this.presenceHeartbeat = setInterval(() => {
|
|
1016
|
+
const s = this.realtime._getSocket();
|
|
1017
|
+
if (s && this.trackedState) {
|
|
1018
|
+
s.emit(
|
|
1019
|
+
"realtime:presence:track",
|
|
1020
|
+
key !== void 0 ? { channel: this.topic, state: this.trackedState, key } : { channel: this.topic, state: this.trackedState }
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
}, PRESENCE_HEARTBEAT_MS);
|
|
1024
|
+
const h = this.presenceHeartbeat;
|
|
1025
|
+
h.unref?.();
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Remove this client's presence entry immediately, stopping the
|
|
1030
|
+
* heartbeat. Safe if the client never called `track`.
|
|
1031
|
+
*/
|
|
1032
|
+
untrack() {
|
|
1033
|
+
this.trackedState = null;
|
|
1034
|
+
if (this.presenceHeartbeat) {
|
|
1035
|
+
clearInterval(this.presenceHeartbeat);
|
|
1036
|
+
this.presenceHeartbeat = null;
|
|
1037
|
+
}
|
|
1038
|
+
const socket = this.realtime._getSocket();
|
|
1039
|
+
socket?.emit("realtime:presence:untrack", { channel: this.topic });
|
|
1040
|
+
}
|
|
1041
|
+
/** Snapshot of the current presence state on this channel. Keys are
|
|
1042
|
+
* opaque client identifiers (server-assigned). Re-read inside your
|
|
1043
|
+
* `.on('presence', { event: 'sync' }, ...)` handler. */
|
|
1044
|
+
presenceState() {
|
|
1045
|
+
return this.presence;
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Register all bindings with the server:
|
|
1049
|
+
* * For each `broadcast` binding, ensure we're subscribed to the topic
|
|
1050
|
+
* (one `realtime:subscribe` for the channel as a whole).
|
|
1051
|
+
* * For each `postgres_changes` binding, emit
|
|
1052
|
+
* `realtime:postgres_changes:subscribe` and record the assigned
|
|
1053
|
+
* subscription_id.
|
|
1054
|
+
*
|
|
1055
|
+
* `statusCallback` is invoked with `'SUBSCRIBED'` when every binding
|
|
1056
|
+
* has ack'd, or with `'CHANNEL_ERROR' | 'TIMED_OUT'` on failure.
|
|
1057
|
+
*/
|
|
1058
|
+
subscribe(statusCallback) {
|
|
1059
|
+
this.statusCallback = statusCallback ?? null;
|
|
1060
|
+
if (this.state === "joining" || this.state === "joined") {
|
|
1061
|
+
return this;
|
|
1062
|
+
}
|
|
1063
|
+
this.state = "joining";
|
|
1064
|
+
void this.realtime.connect().then(
|
|
1065
|
+
async () => {
|
|
1066
|
+
try {
|
|
1067
|
+
await this.registerAllBindings();
|
|
1068
|
+
this.state = "joined";
|
|
1069
|
+
this.statusCallback?.("SUBSCRIBED");
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
this.state = "errored";
|
|
1072
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1073
|
+
this.statusCallback?.("CHANNEL_ERROR", {
|
|
1074
|
+
code: "SUBSCRIBE_FAILED",
|
|
1075
|
+
message
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
},
|
|
1079
|
+
(err) => {
|
|
1080
|
+
this.state = "errored";
|
|
1081
|
+
this.statusCallback?.("CHANNEL_ERROR", {
|
|
1082
|
+
code: "CONNECT_FAILED",
|
|
1083
|
+
message: err.message
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
);
|
|
1087
|
+
return this;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Tear down every binding: unsubscribe from the broadcast topic (if
|
|
1091
|
+
* any broadcast bindings exist) and remove every postgres_changes
|
|
1092
|
+
* subscription by id. Safe to call when state is already closed.
|
|
1093
|
+
*/
|
|
1094
|
+
async unsubscribe() {
|
|
1095
|
+
if (this.state === "closed") {
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const socket = this.realtime._getSocket();
|
|
1099
|
+
if (this.presenceHeartbeat) {
|
|
1100
|
+
clearInterval(this.presenceHeartbeat);
|
|
1101
|
+
this.presenceHeartbeat = null;
|
|
1102
|
+
}
|
|
1103
|
+
if (!socket) {
|
|
1104
|
+
this.trackedState = null;
|
|
1105
|
+
this.state = "closed";
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (this.trackedState) {
|
|
1109
|
+
socket.emit("realtime:presence:untrack", { channel: this.topic });
|
|
1110
|
+
this.trackedState = null;
|
|
1111
|
+
}
|
|
1112
|
+
const pcBindings = this.bindings.filter(
|
|
1113
|
+
(b) => b.type === "postgres_changes"
|
|
1114
|
+
);
|
|
1115
|
+
for (const b of pcBindings) {
|
|
1116
|
+
if (b.subscriptionId) {
|
|
1117
|
+
socket.emit("realtime:postgres_changes:unsubscribe", {
|
|
1118
|
+
subscription_id: b.subscriptionId
|
|
1119
|
+
});
|
|
1120
|
+
b.subscriptionId = void 0;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (this.bindings.some((b) => b.type === "broadcast" || b.type === "presence")) {
|
|
1124
|
+
socket.emit("realtime:unsubscribe", { channel: this.topic });
|
|
1125
|
+
}
|
|
1126
|
+
this.realtime._detachChannel(this);
|
|
1127
|
+
this.state = "closed";
|
|
1128
|
+
this.statusCallback?.("CLOSED");
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Publish a broadcast event to the topic. Broadcasts are always
|
|
1132
|
+
* ephemeral — the server fans out to every subscribed socket in memory,
|
|
1133
|
+
* with no DB persistence or webhook fan-out. For audited / durable
|
|
1134
|
+
* events, write to your own application table and enable
|
|
1135
|
+
* `postgres_changes` on it; the SDK will surface the INSERT as a
|
|
1136
|
+
* `postgres_changes` event without a separate channel.send call.
|
|
1137
|
+
*/
|
|
1138
|
+
async send(args) {
|
|
1139
|
+
if (args.type !== "broadcast") {
|
|
1140
|
+
throw new MitwayBaasError(
|
|
1141
|
+
'Only "broadcast" sends are supported \u2014 DB changes flow via your DB writes, not channel.send()',
|
|
1142
|
+
400,
|
|
1143
|
+
"UNSUPPORTED_SEND_TYPE"
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
const RESERVED_BROADCAST_NAMES = /* @__PURE__ */ new Set([
|
|
1147
|
+
"postgres_changes",
|
|
1148
|
+
"presence_state",
|
|
1149
|
+
"presence_join",
|
|
1150
|
+
"presence_leave"
|
|
1151
|
+
]);
|
|
1152
|
+
if (RESERVED_BROADCAST_NAMES.has(args.event)) {
|
|
1153
|
+
throw new MitwayBaasError(
|
|
1154
|
+
`"${args.event}" is a reserved event name \u2014 pick a different name for broadcast events`,
|
|
1155
|
+
400,
|
|
1156
|
+
"RESERVED_EVENT_NAME"
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
const socket = this.realtime._getSocket();
|
|
1160
|
+
if (!socket) {
|
|
1161
|
+
throw new MitwayBaasError("Socket not connected", 503, "NOT_CONNECTED");
|
|
1162
|
+
}
|
|
1163
|
+
const broadcastCfg = this.options.config?.broadcast;
|
|
1164
|
+
const wantAck = broadcastCfg?.ack === true;
|
|
1165
|
+
const self = broadcastCfg?.self;
|
|
1166
|
+
const wirePayload = {
|
|
1167
|
+
channel: this.topic,
|
|
1168
|
+
event: args.event,
|
|
1169
|
+
payload: args.payload
|
|
1170
|
+
};
|
|
1171
|
+
if (self === false) {
|
|
1172
|
+
wirePayload.self = false;
|
|
1173
|
+
}
|
|
1174
|
+
if (!wantAck) {
|
|
1175
|
+
socket.emit("realtime:publish", wirePayload);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
return await new Promise((resolve) => {
|
|
1179
|
+
socket.emit(
|
|
1180
|
+
"realtime:publish",
|
|
1181
|
+
wirePayload,
|
|
1182
|
+
(ack) => {
|
|
1183
|
+
resolve(ack);
|
|
1184
|
+
}
|
|
1185
|
+
);
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Replay SQL-originated broadcasts on this topic since the given
|
|
1190
|
+
* timestamp. Delivered only to this socket (same envelope format as
|
|
1191
|
+
* live broadcasts; the SDK routes them through `.on('broadcast', ...)`
|
|
1192
|
+
* bindings just like the real-time path). Backend caps the window at
|
|
1193
|
+
* 24 h and the limit at 1000.
|
|
1194
|
+
*/
|
|
1195
|
+
async replay(args) {
|
|
1196
|
+
const socket = this.realtime._getSocket();
|
|
1197
|
+
if (!socket) {
|
|
1198
|
+
throw new MitwayBaasError("Socket not connected", 503, "NOT_CONNECTED");
|
|
1199
|
+
}
|
|
1200
|
+
socket.emit("realtime:broadcast:replay", {
|
|
1201
|
+
channel: this.topic,
|
|
1202
|
+
since: args.since,
|
|
1203
|
+
limit: args.limit,
|
|
1204
|
+
private: this.isPrivate
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
/** Internal — called by Realtime's event router on every incoming event. */
|
|
1208
|
+
_dispatch(event, envelope) {
|
|
1209
|
+
if (event === "postgres_changes") {
|
|
1210
|
+
const pcEvent = envelope;
|
|
1211
|
+
for (const b of this.bindings) {
|
|
1212
|
+
if (b.type !== "postgres_changes") {
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
if (!b.subscriptionId || !pcEvent.ids.includes(b.subscriptionId)) {
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
const matchesEvent = b.filter.event === "*" || b.filter.event === pcEvent.data.type;
|
|
1219
|
+
if (!matchesEvent) {
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
try {
|
|
1223
|
+
b.callback(pcEvent.data);
|
|
1224
|
+
} catch {
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (event === "presence_state" || event === "presence_join" || event === "presence_leave") {
|
|
1230
|
+
const e = envelope;
|
|
1231
|
+
if (e.channel !== this.topic) {
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (event === "presence_state" && e.state) {
|
|
1235
|
+
this.presence = { ...e.state };
|
|
1236
|
+
this.firePresence({ event: "sync", state: this.presence });
|
|
1237
|
+
} else if (event === "presence_join" && e.joins) {
|
|
1238
|
+
Object.assign(this.presence, e.joins);
|
|
1239
|
+
this.firePresence({ event: "join", joins: e.joins });
|
|
1240
|
+
} else if (event === "presence_leave" && e.leaves) {
|
|
1241
|
+
for (const key of Object.keys(e.leaves)) {
|
|
1242
|
+
delete this.presence[key];
|
|
1243
|
+
}
|
|
1244
|
+
this.firePresence({ event: "leave", leaves: e.leaves });
|
|
1245
|
+
}
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
const meta = envelope.meta;
|
|
1249
|
+
if (meta?.timestamp) {
|
|
1250
|
+
if (!this.lastBroadcastTimestamp || meta.timestamp > this.lastBroadcastTimestamp) {
|
|
1251
|
+
this.lastBroadcastTimestamp = meta.timestamp;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
for (const b of this.bindings) {
|
|
1255
|
+
if (b.type !== "broadcast") {
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
if (b.filter.event !== event) {
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
const { meta: _meta, ...payload } = envelope;
|
|
1262
|
+
try {
|
|
1263
|
+
b.callback({ type: "broadcast", event, payload });
|
|
1264
|
+
} catch {
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
firePresence(payload) {
|
|
1269
|
+
for (const b of this.bindings) {
|
|
1270
|
+
if (b.type !== "presence") {
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
if (b.filter.event !== payload.event) {
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
try {
|
|
1277
|
+
b.callback(payload);
|
|
1278
|
+
} catch {
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
async registerAllBindings() {
|
|
1283
|
+
const socket = this.realtime._getSocket();
|
|
1284
|
+
if (!socket) {
|
|
1285
|
+
throw new Error("Socket not available");
|
|
1286
|
+
}
|
|
1287
|
+
const needsRoomJoin = this.bindings.some(
|
|
1288
|
+
(b) => b.type === "broadcast" || b.type === "presence"
|
|
1289
|
+
);
|
|
1290
|
+
if (needsRoomJoin) {
|
|
1291
|
+
await new Promise((resolve, reject) => {
|
|
1292
|
+
socket.emit(
|
|
1293
|
+
"realtime:subscribe",
|
|
1294
|
+
{ channel: this.topic, private: this.isPrivate },
|
|
1295
|
+
(ack) => {
|
|
1296
|
+
if (ack.ok) {
|
|
1297
|
+
resolve();
|
|
1298
|
+
} else {
|
|
1299
|
+
reject(new Error(ack.error?.message ?? "subscribe failed"));
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
);
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
const pcBindings = this.bindings.filter(
|
|
1306
|
+
(b) => b.type === "postgres_changes"
|
|
1307
|
+
);
|
|
1308
|
+
await Promise.all(
|
|
1309
|
+
pcBindings.map(
|
|
1310
|
+
(b) => new Promise((resolve, reject) => {
|
|
1311
|
+
socket.emit(
|
|
1312
|
+
"realtime:postgres_changes:subscribe",
|
|
1313
|
+
{
|
|
1314
|
+
event: b.filter.event,
|
|
1315
|
+
schema: b.filter.schema ?? "public",
|
|
1316
|
+
table: b.filter.table,
|
|
1317
|
+
filter: b.filter.filter
|
|
1318
|
+
},
|
|
1319
|
+
(ack) => {
|
|
1320
|
+
if (ack.ok && ack.subscription_id) {
|
|
1321
|
+
b.subscriptionId = ack.subscription_id;
|
|
1322
|
+
resolve();
|
|
1323
|
+
} else {
|
|
1324
|
+
reject(
|
|
1325
|
+
new Error(ack.error?.message ?? "postgres_changes subscribe failed")
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
);
|
|
1330
|
+
})
|
|
1331
|
+
)
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
var Realtime = class {
|
|
1336
|
+
socket = null;
|
|
1337
|
+
baseUrl;
|
|
1338
|
+
options;
|
|
1339
|
+
anonKey;
|
|
1340
|
+
tokenManager;
|
|
1341
|
+
channels = /* @__PURE__ */ new Map();
|
|
1342
|
+
connecting = null;
|
|
1343
|
+
/** Flips to `true` once the initial handshake resolves. Differentiates
|
|
1344
|
+
* the first `connect` event (part of `openSocket`) from subsequent
|
|
1345
|
+
* reconnect events (which should trigger auto-resubscribe). */
|
|
1346
|
+
firstConnected = false;
|
|
1347
|
+
constructor(baseUrl, tokenManager, anonKey, options = {}) {
|
|
1348
|
+
this.baseUrl = baseUrl;
|
|
1349
|
+
this.tokenManager = tokenManager;
|
|
1350
|
+
this.anonKey = anonKey;
|
|
1351
|
+
this.options = options;
|
|
1352
|
+
}
|
|
1353
|
+
get isConnected() {
|
|
1354
|
+
return this.socket?.connected === true;
|
|
1355
|
+
}
|
|
1356
|
+
get socketId() {
|
|
1357
|
+
return this.socket?.id;
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Get (or create) a channel for `topic`. Channels are cached so multiple
|
|
1361
|
+
* `.channel('same')` calls return the same instance.
|
|
1362
|
+
*
|
|
1363
|
+
* The optional `opts` argument lets the caller configure the channel:
|
|
1364
|
+
* * `config.private` — enable subscribe-side authorization against
|
|
1365
|
+
* `realtime.authorize_subscribe(...)` on the tenant DB.
|
|
1366
|
+
* * `config.broadcast.ack` — `channel.send()` resolves with the
|
|
1367
|
+
* server's ack (message_id) instead of fire-and-forget.
|
|
1368
|
+
* * `config.broadcast.self` — `false` excludes the sender from the
|
|
1369
|
+
* fan-out (defaults to `true`).
|
|
1370
|
+
* * `config.presence.key` — stable presence key to group multiple
|
|
1371
|
+
* tabs of the same user under one entry.
|
|
1372
|
+
*
|
|
1373
|
+
* Options are locked in when the channel is first created; subsequent
|
|
1374
|
+
* `.channel('same')` calls with different opts are ignored. Pass a
|
|
1375
|
+
* different topic to get a different-configured channel.
|
|
1376
|
+
*/
|
|
1377
|
+
channel(topic, opts) {
|
|
1378
|
+
const existing = this.channels.get(topic);
|
|
1379
|
+
if (existing) {
|
|
1380
|
+
return existing;
|
|
1381
|
+
}
|
|
1382
|
+
const channel = new RealtimeChannel(topic, this, opts);
|
|
1383
|
+
this.channels.set(topic, channel);
|
|
1384
|
+
return channel;
|
|
1385
|
+
}
|
|
1386
|
+
connect() {
|
|
1387
|
+
if (this.isConnected) {
|
|
1388
|
+
return Promise.resolve();
|
|
1389
|
+
}
|
|
1390
|
+
if (this.connecting) {
|
|
1391
|
+
return this.connecting;
|
|
1392
|
+
}
|
|
1393
|
+
this.connecting = this.openSocket();
|
|
1394
|
+
return this.connecting;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Close the socket. Channels are left as-is so they can re-subscribe
|
|
1398
|
+
* on the next `connect()` — useful for auth token refresh flows.
|
|
1399
|
+
*/
|
|
1400
|
+
disconnect() {
|
|
1401
|
+
if (!this.socket) {
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
this.socket.disconnect();
|
|
1405
|
+
this.socket = null;
|
|
1406
|
+
this.firstConnected = false;
|
|
1407
|
+
}
|
|
1408
|
+
// -------------------------------------------------------------------------
|
|
1409
|
+
// internals used by RealtimeChannel
|
|
1410
|
+
// -------------------------------------------------------------------------
|
|
1411
|
+
/* istanbul ignore next — tested via channel integration */
|
|
1412
|
+
_getSocket() {
|
|
1413
|
+
return this.socket;
|
|
1414
|
+
}
|
|
1415
|
+
_detachChannel(channel) {
|
|
1416
|
+
this.channels.delete(channel.topic);
|
|
1417
|
+
}
|
|
1418
|
+
openSocket() {
|
|
1419
|
+
const token = this.tokenManager.getAccessToken() ?? this.anonKey;
|
|
1420
|
+
if (!token) {
|
|
1421
|
+
const err = new MitwayBaasError(
|
|
1422
|
+
"Realtime requires an access token or anonKey",
|
|
1423
|
+
401,
|
|
1424
|
+
"AUTH_INVALID_API_KEY"
|
|
1425
|
+
);
|
|
1426
|
+
this.connecting = null;
|
|
1427
|
+
return Promise.reject(err);
|
|
1428
|
+
}
|
|
1429
|
+
const timeoutMs = this.options.timeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
1430
|
+
const socket = (0, import_socket.io)(this.baseUrl, {
|
|
1431
|
+
path: this.options.path,
|
|
1432
|
+
transports: this.options.transports ?? ["websocket"],
|
|
1433
|
+
auth: { token, ...this.options.extraAuth ?? {} },
|
|
1434
|
+
reconnection: true,
|
|
1435
|
+
timeout: timeoutMs
|
|
1436
|
+
});
|
|
1437
|
+
this.socket = socket;
|
|
1438
|
+
socket.onAny((event, ...args) => this.dispatch(event, args));
|
|
1439
|
+
socket.on("connect", () => {
|
|
1440
|
+
if (!this.firstConnected) {
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
for (const channel of this.channels.values()) {
|
|
1444
|
+
const state = channel._state();
|
|
1445
|
+
if (state === "joined" || state === "errored") {
|
|
1446
|
+
void channel._rejoinAfterReconnect();
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
return new Promise((resolve, reject) => {
|
|
1451
|
+
const timer = setTimeout(() => {
|
|
1452
|
+
socket.off("connect", onConnect);
|
|
1453
|
+
socket.off("connect_error", onConnectError);
|
|
1454
|
+
this.connecting = null;
|
|
1455
|
+
reject(
|
|
1456
|
+
new MitwayBaasError(
|
|
1457
|
+
`Realtime connection timeout after ${timeoutMs}ms`,
|
|
1458
|
+
408,
|
|
1459
|
+
"CONNECTION_TIMEOUT"
|
|
1460
|
+
)
|
|
1461
|
+
);
|
|
1462
|
+
}, timeoutMs);
|
|
1463
|
+
const clear = () => {
|
|
1464
|
+
clearTimeout(timer);
|
|
1465
|
+
socket.off("connect", onConnect);
|
|
1466
|
+
socket.off("connect_error", onConnectError);
|
|
1467
|
+
};
|
|
1468
|
+
const onConnect = () => {
|
|
1469
|
+
clear();
|
|
1470
|
+
this.connecting = null;
|
|
1471
|
+
this.firstConnected = true;
|
|
1472
|
+
resolve();
|
|
1473
|
+
};
|
|
1474
|
+
const onConnectError = (err) => {
|
|
1475
|
+
clear();
|
|
1476
|
+
this.connecting = null;
|
|
1477
|
+
reject(new MitwayBaasError(err.message, 0, "CONNECTION_FAILED"));
|
|
1478
|
+
};
|
|
1479
|
+
socket.once("connect", onConnect);
|
|
1480
|
+
socket.once("connect_error", onConnectError);
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
dispatch(event, args) {
|
|
1484
|
+
if (event === "postgres_changes") {
|
|
1485
|
+
const envelope2 = args[0] ?? {};
|
|
1486
|
+
this.channels.forEach((ch) => ch._dispatch("postgres_changes", envelope2));
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (event === "connect" || event === "disconnect" || event === "connect_error" || event === "error" || event === "realtime:error" || event === "realtime:shutdown") {
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const envelope = args[0] ?? {};
|
|
1493
|
+
this.channels.forEach((ch) => ch._dispatch(event, envelope));
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
|
|
891
1497
|
// src/client.ts
|
|
892
1498
|
var MitwayBaasClient = class {
|
|
893
1499
|
http;
|
|
894
1500
|
tokenManager;
|
|
895
1501
|
auth;
|
|
896
1502
|
database;
|
|
1503
|
+
realtime;
|
|
897
1504
|
constructor(config = {}) {
|
|
898
1505
|
const logger = new Logger(config.debug);
|
|
899
1506
|
this.tokenManager = new TokenManager();
|
|
900
1507
|
this.http = new HttpClient(config, this.tokenManager, logger);
|
|
901
1508
|
this.auth = new Auth(this.http, this.tokenManager);
|
|
902
1509
|
this.database = new Database(this.http, this.tokenManager, config.anonKey);
|
|
1510
|
+
this.realtime = new Realtime(
|
|
1511
|
+
this.http.baseUrl,
|
|
1512
|
+
this.tokenManager,
|
|
1513
|
+
config.anonKey,
|
|
1514
|
+
config.realtime
|
|
1515
|
+
);
|
|
903
1516
|
}
|
|
904
1517
|
/**
|
|
905
1518
|
* Escape hatch for callers that need to make custom requests against the
|
|
@@ -923,6 +1536,8 @@ var index_default = MitwayBaasClient;
|
|
|
923
1536
|
Logger,
|
|
924
1537
|
MitwayBaasClient,
|
|
925
1538
|
MitwayBaasError,
|
|
1539
|
+
Realtime,
|
|
1540
|
+
RealtimeChannel,
|
|
926
1541
|
TokenManager,
|
|
927
1542
|
createClient
|
|
928
1543
|
});
|