naro-js 0.5.0 → 0.6.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/index.js CHANGED
@@ -67,6 +67,105 @@ function cryptoUnavailable() {
67
67
  message: "This runtime has no crypto.subtle, which signing in with a provider needs (https or localhost)."
68
68
  };
69
69
  }
70
+ var SERVICE_KEY_OFF_SERVER_MESSAGE = "A Naro service key (naro_sk_\u2026) skips every policy, so createClient accepts it only on a server (Node, Deno, Bun, Cloudflare Workers, an edge runtime), never in a browser, a web worker or a React Native app. Use the anon key (naro_pk_\u2026) here and keep the service key on your server.";
71
+ function websocketUnavailable() {
72
+ return {
73
+ code: "WEBSOCKET_UNAVAILABLE",
74
+ message: "This runtime has no WebSocket. Pass one to createClient({ realtime: { WebSocket } })."
75
+ };
76
+ }
77
+ function realtimeTimedOut(ms) {
78
+ const seconds = Math.max(1, Math.round(ms / 1e3));
79
+ return {
80
+ code: "TIMED_OUT",
81
+ message: `The Naro realtime server did not answer within ${String(seconds)} seconds.`
82
+ };
83
+ }
84
+ function realtimeClosed(code) {
85
+ return {
86
+ code: "NETWORK_ERROR",
87
+ message: `The realtime connection to the Naro backend closed (${String(code)}). It reconnects on its own.`,
88
+ details: { code }
89
+ };
90
+ }
91
+ function realtimeDisconnected() {
92
+ return {
93
+ code: "ABORTED",
94
+ message: "The realtime connection was closed by the app (it removed its last channel) before the Naro realtime server answered."
95
+ };
96
+ }
97
+ function realtimeStopped(code, reason) {
98
+ return {
99
+ code: reason === "" ? "NETWORK_ERROR" : reason,
100
+ message: `The realtime connection to the Naro backend closed (${String(code)}) and does not reconnect.`,
101
+ details: { code }
102
+ };
103
+ }
104
+ function socketRefused(error) {
105
+ return {
106
+ code: "NETWORK_ERROR",
107
+ message: "The realtime connection to the Naro backend could not be opened.",
108
+ details: { cause: error instanceof Error ? error.name : typeof error }
109
+ };
110
+ }
111
+ function invalidChannel(reason) {
112
+ return {
113
+ code: "INVALID_CHANNEL",
114
+ message: `The channel name is not valid: it ${reason}.`
115
+ };
116
+ }
117
+ function invalidChannelConfig(path, reason) {
118
+ return {
119
+ code: "INVALID_CHANNEL_CONFIG",
120
+ message: "The channel settings are not valid.",
121
+ details: { path, reason }
122
+ };
123
+ }
124
+ function serviceKeyOffServer() {
125
+ return {
126
+ code: "SERVICE_KEY_OFF_SERVER",
127
+ message: SERVICE_KEY_OFF_SERVER_MESSAGE
128
+ };
129
+ }
130
+ function notJoined(channel) {
131
+ return {
132
+ code: "NOT_JOINED",
133
+ message: `Join ${channel} before sending to it.`
134
+ };
135
+ }
136
+ function invalidBroadcast() {
137
+ return {
138
+ code: "INVALID_MESSAGE",
139
+ message: "The message is not a valid Naro realtime frame.",
140
+ details: { reason: "needs type broadcast" }
141
+ };
142
+ }
143
+ function invalidPresence() {
144
+ return {
145
+ code: "INVALID_MESSAGE",
146
+ message: "The message is not a valid Naro realtime frame.",
147
+ details: { reason: "needs a presence payload object" }
148
+ };
149
+ }
150
+ function frameTooLarge(limit) {
151
+ return {
152
+ code: "INVALID_MESSAGE",
153
+ message: "The message is not a valid Naro realtime frame.",
154
+ details: { reason: `is larger than ${String(limit)} bytes` }
155
+ };
156
+ }
157
+ function clientFlooding(perSecond) {
158
+ return {
159
+ code: "CLIENT_FLOODING",
160
+ message: `This connection is sending messages faster than a connection may (${String(perSecond)} a second), so this one was not sent. Send less often.`
161
+ };
162
+ }
163
+ function channelClosed() {
164
+ return {
165
+ code: "CHANNEL_CLOSED",
166
+ message: "The Naro realtime server closed this channel."
167
+ };
168
+ }
70
169
  function describe(cause) {
71
170
  if (cause instanceof Error) return `${cause.name}: ${cause.message}`;
72
171
  return String(cause);
@@ -91,9 +190,9 @@ function errorFromBody(status, body) {
91
190
  }
92
191
 
93
192
  // src/http.ts
94
- var AUTH_PREFIX = "/auth/";
193
+ var SESSION_PREFIXES = ["/auth/", "/realtime/"];
95
194
  function storeFor(config, path) {
96
- return path.startsWith(AUTH_PREFIX) ? config.tokens : config.accessJwt ?? null;
195
+ return SESSION_PREFIXES.some((prefix) => path.startsWith(prefix)) ? config.tokens : config.accessJwt ?? null;
97
196
  }
98
197
  async function receive(config, options, init) {
99
198
  const controller = options.timeoutMs !== void 0 && typeof AbortController === "function" ? new AbortController() : null;
@@ -245,7 +344,9 @@ var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
245
344
  // Storage (original spec §13): one object written twice within a second
246
345
  // (`x-retry-after: 1`), and the daily operation limit (3-1d).
247
346
  "OBJECT_RATE_LIMITED",
248
- "STORAGE_OPS_QUOTA_EXCEEDED"
347
+ "STORAGE_OPS_QUOTA_EXCEEDED",
348
+ // Realtime's REST broadcast over the project's message budget (x-retry-after: 1).
349
+ "TOO_MANY_MESSAGES"
249
350
  ]);
250
351
  function withRetryAfter(error, headers) {
251
352
  const header = headers.get("x-retry-after")?.trim() ?? "";
@@ -836,7 +937,7 @@ var MAX_FLOWS = 10;
836
937
  function isEntry(value) {
837
938
  return typeof value === "object" && value !== null && typeof value.verifier === "string" && typeof value.expiresAt === "number";
838
939
  }
839
- function live(text, now) {
940
+ function live(text, now2) {
840
941
  if (text === null) return {};
841
942
  let parsed;
842
943
  try {
@@ -847,7 +948,7 @@ function live(text, now) {
847
948
  if (typeof parsed !== "object" || parsed === null) return {};
848
949
  const entries = {};
849
950
  for (const [flow, entry] of Object.entries(parsed))
850
- if (isEntry(entry) && entry.expiresAt > now) entries[flow] = entry;
951
+ if (isEntry(entry) && entry.expiresAt > now2) entries[flow] = entry;
851
952
  return entries;
852
953
  }
853
954
  function pick(entries, oldest) {
@@ -863,16 +964,16 @@ function serialize(entries) {
863
964
  var FlowVerifiers = class {
864
965
  #store;
865
966
  #now;
866
- constructor(store, now = Date.now) {
967
+ constructor(store, now2 = Date.now) {
867
968
  this.#store = store;
868
- this.#now = now;
969
+ this.#now = now2;
869
970
  }
870
971
  /** Keeps `verifier` for the flow `flow`, for ten minutes. */
871
972
  async add(flow, verifier) {
872
- const now = this.#now();
973
+ const now2 = this.#now();
873
974
  await this.#store.update((text) => {
874
- const entries = live(text, now);
875
- entries[flow] = { verifier, expiresAt: now + FLOW_TTL_MS };
975
+ const entries = live(text, now2);
976
+ entries[flow] = { verifier, expiresAt: now2 + FLOW_TTL_MS };
876
977
  while (Object.keys(entries).length > MAX_FLOWS) {
877
978
  const oldest = pick(entries, true);
878
979
  if (oldest === void 0) break;
@@ -887,10 +988,10 @@ var FlowVerifiers = class {
887
988
  * expired.
888
989
  */
889
990
  async take(flow) {
890
- const now = this.#now();
991
+ const now2 = this.#now();
891
992
  let taken = null;
892
993
  await this.#store.update((text) => {
893
- const entries = live(text, now);
994
+ const entries = live(text, now2);
894
995
  const id = flow ?? pick(entries, false);
895
996
  if (id !== void 0 && Object.hasOwn(entries, id)) {
896
997
  taken = entries[id]?.verifier ?? null;
@@ -917,9 +1018,9 @@ function parse(text) {
917
1018
  var RecoveryToken = class {
918
1019
  #store;
919
1020
  #now;
920
- constructor(store, now = () => Date.now()) {
1021
+ constructor(store, now2 = () => Date.now()) {
921
1022
  this.#store = store;
922
- this.#now = now;
1023
+ this.#now = now2;
923
1024
  }
924
1025
  /** Keeps the token a recovery link brought, for 15 minutes. */
925
1026
  async keep(token) {
@@ -928,11 +1029,11 @@ var RecoveryToken = class {
928
1029
  }
929
1030
  /** The token, while it lasts; an expired or unreadable one is cleared. */
930
1031
  async current() {
931
- const now = this.#now();
1032
+ const now2 = this.#now();
932
1033
  let token = null;
933
1034
  await this.#store.update((text) => {
934
1035
  const kept = parse(text);
935
- if (kept === null || kept.expiresAt <= now) return null;
1036
+ if (kept === null || kept.expiresAt <= now2) return null;
936
1037
  token = kept.token;
937
1038
  return text;
938
1039
  });
@@ -994,6 +1095,8 @@ var TokenStore = class {
994
1095
  #fallback = null;
995
1096
  /** Settles when the last queued operation has finished. */
996
1097
  #queue = Promise.resolve();
1098
+ /** Called after each write; see `subscribe`. */
1099
+ #listeners = /* @__PURE__ */ new Set();
997
1100
  constructor(storage, key) {
998
1101
  this.#storage = storage;
999
1102
  this.#key = key;
@@ -1002,21 +1105,31 @@ var TokenStore = class {
1002
1105
  return this.#serial(() => this.#get());
1003
1106
  }
1004
1107
  set(token) {
1005
- return this.#serial(() => this.#set(token));
1108
+ return this.#serial(async () => {
1109
+ await this.#set(token);
1110
+ this.#changed();
1111
+ });
1006
1112
  }
1007
1113
  clear() {
1008
- return this.#serial(() => this.#clear());
1114
+ return this.#serial(async () => {
1115
+ await this.#clear();
1116
+ this.#changed();
1117
+ });
1009
1118
  }
1010
1119
  /**
1011
- * Clears only if the stored token is still the one that was rejected. A
1012
- * request that left with an old token can come back after the user signed
1013
- * in again; its INVALID_SESSION must not sign the new session out. The
1014
- * read and the removal are one operation: with an async storage, a sign-in
1015
- * that lands between them would otherwise be the token removed.
1120
+ * Clears only if the stored token is still the one that was rejected, and
1121
+ * says whether it did. A request that left with an old token can come back
1122
+ * after the user signed in again; its INVALID_SESSION must not sign the
1123
+ * new session out. The read and the removal are one operation: with an
1124
+ * async storage, a sign-in that lands between them would otherwise be the
1125
+ * token removed.
1016
1126
  */
1017
1127
  clearIf(token) {
1018
1128
  return this.#serial(async () => {
1019
- if (await this.#get() === token) await this.#clear();
1129
+ if (await this.#get() !== token) return false;
1130
+ await this.#clear();
1131
+ this.#changed();
1132
+ return true;
1020
1133
  });
1021
1134
  }
1022
1135
  /**
@@ -1029,17 +1142,42 @@ var TokenStore = class {
1029
1142
  const next = transform(await this.#get());
1030
1143
  if (next === null) await this.#clear();
1031
1144
  else await this.#set(next);
1145
+ this.#changed();
1032
1146
  });
1033
1147
  }
1034
1148
  /** Stores `next` only if `expected` is still stored, as one operation. */
1035
1149
  replaceIf(expected, next) {
1036
1150
  return this.#serial(async () => {
1037
- if (await this.#get() === expected) await this.#set(next);
1151
+ if (await this.#get() !== expected) return;
1152
+ await this.#set(next);
1153
+ this.#changed();
1038
1154
  });
1039
1155
  }
1156
+ /**
1157
+ * Calls `listener` after every write of the token — a sign-in, a
1158
+ * sign-out, a renewed session, a cleared INVALID_SESSION — from inside the
1159
+ * write's turn, so a `get()` it starts reads the value just written — or
1160
+ * that of a write already queued behind it, which calls the listener
1161
+ * again. The Realtime connection (Realtime 3-2d) uses it to tell the hub.
1162
+ * What a listener throws is its own: the write has happened and stands.
1163
+ */
1164
+ subscribe(listener) {
1165
+ this.#listeners.add(listener);
1166
+ return () => {
1167
+ this.#listeners.delete(listener);
1168
+ };
1169
+ }
1170
+ #changed() {
1171
+ for (const listener of Array.from(this.#listeners)) {
1172
+ try {
1173
+ listener();
1174
+ } catch {
1175
+ }
1176
+ }
1177
+ }
1040
1178
  /**
1041
1179
  * Runs `operation` once every earlier one has finished. The operations
1042
- * below never reject, and none of them calls a public method (that would
1180
+ * below never reject, and none of them awaits a public method (that would
1043
1181
  * wait on itself).
1044
1182
  */
1045
1183
  #serial(operation) {
@@ -1856,6 +1994,2073 @@ var TableBuilder = class {
1856
1994
  }
1857
1995
  };
1858
1996
 
1997
+ // src/realtime/close.ts
1998
+ var BACKOFF_STEPS_MS = [1e3, 2e3, 5e3, 1e4];
1999
+ var RATE_LIMITED_MIN_MS = 5e3;
2000
+ var IP_LIMIT_WAIT_MS = 6e4;
2001
+ var RESTART_JITTER_MS = 1e4;
2002
+ var UNOPENED_SLOWDOWN_AFTER = 6;
2003
+ var UNOPENED_DELAY_MS = 6e4;
2004
+ var STOP_CODES = /* @__PURE__ */ new Set([
2005
+ 1e3,
2006
+ 4e3,
2007
+ 4001,
2008
+ 4003,
2009
+ 4004,
2010
+ 4410
2011
+ ]);
2012
+ function closeAction(code, reason) {
2013
+ if (STOP_CODES.has(code)) return { kind: "stop" };
2014
+ if (code === 4401) return { kind: "session" };
2015
+ if (code === 4503) return { kind: "restart" };
2016
+ if (code === 4029)
2017
+ return reason === "IP_RATE_LIMITED" ? { kind: "wait", ms: IP_LIMIT_WAIT_MS } : { kind: "retry", minMs: RATE_LIMITED_MIN_MS };
2018
+ return { kind: "retry", minMs: 0 };
2019
+ }
2020
+ function backoffMs(attempt2, random) {
2021
+ const step = BACKOFF_STEPS_MS[Math.min(attempt2, BACKOFF_STEPS_MS.length - 1)] ?? 1e4;
2022
+ return Math.round(step * (0.5 + random()));
2023
+ }
2024
+ function delayAfter(action, state, random) {
2025
+ if (action.kind === "restart")
2026
+ return Math.floor(random() * RESTART_JITTER_MS);
2027
+ if (action.kind === "wait")
2028
+ return action.ms + Math.floor(random() * RESTART_JITTER_MS);
2029
+ const base = state.unopened >= UNOPENED_SLOWDOWN_AFTER ? Math.round(UNOPENED_DELAY_MS * (0.5 + random())) : backoffMs(state.attempt, random);
2030
+ return Math.max(action.kind === "retry" ? action.minMs : 0, base);
2031
+ }
2032
+
2033
+ // src/realtime/presence.ts
2034
+ function entryOf(meta) {
2035
+ if (!isRecord(meta) || typeof meta.phx_ref !== "string") return null;
2036
+ const { phx_ref: ref, naro_user_id: user, ...state } = meta;
2037
+ return {
2038
+ ...state,
2039
+ presence_ref: ref,
2040
+ naro_user_id: typeof user === "string" ? user : null
2041
+ };
2042
+ }
2043
+ function entriesOf(value) {
2044
+ const read = /* @__PURE__ */ new Map();
2045
+ if (!isRecord(value)) return read;
2046
+ for (const [key, item] of Object.entries(value)) {
2047
+ if (!isRecord(item) || !Array.isArray(item.metas)) continue;
2048
+ const entries = item.metas.map(entryOf).filter((entry) => entry !== null);
2049
+ if (entries.length > 0) read.set(key, entries);
2050
+ }
2051
+ return read;
2052
+ }
2053
+ function without(entries, gone) {
2054
+ return entries.filter(
2055
+ (entry) => !gone.some((left) => left.presence_ref === entry.presence_ref)
2056
+ );
2057
+ }
2058
+ function copies(entries) {
2059
+ return entries.map((entry) => ({ ...entry }));
2060
+ }
2061
+ var Presence = class {
2062
+ #state = /* @__PURE__ */ new Map();
2063
+ /** A presence_state came for the current join: diffs apply only after it. */
2064
+ #synced = false;
2065
+ state() {
2066
+ return Object.fromEntries(
2067
+ [...this.#state].map(([key, entries]) => [key, copies(entries)])
2068
+ );
2069
+ }
2070
+ /** A new join, or the connection dropped: what is here stays until its presence_state. */
2071
+ unsync() {
2072
+ this.#synced = false;
2073
+ }
2074
+ clear() {
2075
+ this.#state = /* @__PURE__ */ new Map();
2076
+ this.#synced = false;
2077
+ }
2078
+ /**
2079
+ * No presence_state will say what is here any more (a join that may not
2080
+ * read presence, one refused, the server's close, a stop — plan 정정
2081
+ * (k)): each key leaves, then sync — said only when someone was here. A
2082
+ * diff waits for the next presence_state.
2083
+ */
2084
+ leaveAll(emit) {
2085
+ if (this.#state.size > 0) this.applyState({}, emit);
2086
+ this.#synced = false;
2087
+ }
2088
+ applyState(payload, emit) {
2089
+ const before = this.#state;
2090
+ const next = entriesOf(payload);
2091
+ this.#state = next;
2092
+ this.#synced = true;
2093
+ for (const [key, entries] of next) {
2094
+ const held = before.get(key) ?? [];
2095
+ const added = without(entries, held);
2096
+ if (added.length > 0) emit.join(key, copies(added), copies(held));
2097
+ }
2098
+ for (const [key, entries] of before) {
2099
+ const left = without(entries, next.get(key) ?? []);
2100
+ if (left.length > 0)
2101
+ emit.leave(key, copies(left), copies(next.get(key) ?? []));
2102
+ }
2103
+ emit.sync();
2104
+ }
2105
+ applyDiff(payload, emit) {
2106
+ if (!this.#synced || !isRecord(payload)) return;
2107
+ for (const [key, added] of entriesOf(payload.joins)) {
2108
+ const held = this.#state.get(key) ?? [];
2109
+ this.#state.set(key, [...without(held, added), ...added]);
2110
+ emit.join(key, copies(added), copies(held));
2111
+ }
2112
+ for (const [key, left] of entriesOf(payload.leaves)) {
2113
+ const current = without(this.#state.get(key) ?? [], left);
2114
+ if (current.length > 0) this.#state.set(key, current);
2115
+ else this.#state.delete(key);
2116
+ emit.leave(key, copies(left), copies(current));
2117
+ }
2118
+ emit.sync();
2119
+ }
2120
+ };
2121
+
2122
+ // src/realtime/protocol.ts
2123
+ var REALTIME_PROTOCOL_VERSION = "1.0.0";
2124
+ var PHOENIX_TOPIC = "phoenix";
2125
+ var REALTIME_TOPIC_PREFIX = "realtime:";
2126
+ var HEARTBEAT_REQUEST = '{"topic":"phoenix","event":"heartbeat","payload":{},"ref":"hb"}';
2127
+ var HEARTBEAT_REF = "hb";
2128
+ var MAX_CHANNEL_NAME_LENGTH = 128;
2129
+ var CONNECTION_FRAMES_PER_SECOND = 10;
2130
+ var CONNECTION_FRAME_BURST = 120;
2131
+ var WARNED_CODES = /* @__PURE__ */ new Set([
2132
+ "TOO_MANY_JOINS",
2133
+ "TOO_MANY_MESSAGES",
2134
+ "TOO_MANY_PRESENCE_MESSAGES"
2135
+ ]);
2136
+ var FLOOD_WINDOW_MS = 6e4;
2137
+ var MAX_CHANGE_CHANNELS_PER_TABLE = 4;
2138
+ var CHANGE_CHANNELS_REASON = `would put more than ${String(MAX_CHANGE_CHANNELS_PER_TABLE)} channels of this connection on one table's changes (* counts for every table): add the subscription to a channel it already has`;
2139
+ function readServerFrame(data) {
2140
+ if (typeof data !== "string") return null;
2141
+ let value;
2142
+ try {
2143
+ value = JSON.parse(data);
2144
+ } catch {
2145
+ return null;
2146
+ }
2147
+ if (!isRecord(value)) return null;
2148
+ const { topic, event, payload, ref } = value;
2149
+ const joinRef = value.join_ref;
2150
+ if (typeof topic !== "string" || typeof event !== "string") return null;
2151
+ if (!isRecord(payload)) return null;
2152
+ return {
2153
+ topic,
2154
+ event,
2155
+ payload,
2156
+ ref: typeof ref === "string" ? ref : null,
2157
+ joinRef: typeof joinRef === "string" ? joinRef : null
2158
+ };
2159
+ }
2160
+ function frameLimitOf(reason) {
2161
+ if (typeof reason !== "string") return null;
2162
+ const match = /^is larger than (\d+) bytes$/.exec(reason);
2163
+ return match === null ? null : Number(match[1]);
2164
+ }
2165
+ function utf8Length(text) {
2166
+ let bytes = 0;
2167
+ for (let index = 0; index < text.length; index++) {
2168
+ const unit = text.charCodeAt(index);
2169
+ if (unit < 128) bytes += 1;
2170
+ else if (unit < 2048) bytes += 2;
2171
+ else if (unit >= 55296 && unit <= 56319 && index + 1 < text.length) {
2172
+ const next = text.charCodeAt(index + 1);
2173
+ if (next >= 56320 && next <= 57343) {
2174
+ bytes += 4;
2175
+ index++;
2176
+ } else bytes += 3;
2177
+ } else bytes += 3;
2178
+ }
2179
+ return bytes;
2180
+ }
2181
+ function clientFrame(topic, event, payload, ref, joinRef) {
2182
+ return JSON.stringify(
2183
+ joinRef === void 0 ? { topic, event, payload, ref } : { topic, event, payload, ref, join_ref: joinRef }
2184
+ );
2185
+ }
2186
+ function atMostCodePoints(text, max) {
2187
+ if (text.length <= max) return true;
2188
+ return text.length <= max * 2 && [...text].length <= max;
2189
+ }
2190
+ function hasControlCharacter(text) {
2191
+ for (let i = 0; i < text.length; i += 1) {
2192
+ const code = text.charCodeAt(i);
2193
+ if (code <= 31 || code >= 127 && code <= 159) return true;
2194
+ }
2195
+ return false;
2196
+ }
2197
+ function hasBidiControl(text) {
2198
+ for (let i = 0; i < text.length; i += 1) {
2199
+ const code = text.charCodeAt(i);
2200
+ if (code === 1564 || code === 8206 || code === 8207 || code >= 8234 && code <= 8238 || code >= 8294 && code <= 8297)
2201
+ return true;
2202
+ }
2203
+ return false;
2204
+ }
2205
+ function channelNameProblem(name) {
2206
+ if (name === "") return "is empty";
2207
+ if (!atMostCodePoints(name, MAX_CHANNEL_NAME_LENGTH))
2208
+ return `is longer than ${String(MAX_CHANNEL_NAME_LENGTH)} characters`;
2209
+ if (hasControlCharacter(name)) return "has a control character";
2210
+ if (hasBidiControl(name)) return "has a bidirectional control character";
2211
+ return void 0;
2212
+ }
2213
+ function socketUrl(baseUrl, apiKey) {
2214
+ const url = new URL("/_naro/v1/realtime/websocket", baseUrl);
2215
+ url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
2216
+ return `${url.href}?apikey=${encodeURIComponent(apiKey)}&vsn=${REALTIME_PROTOCOL_VERSION}`;
2217
+ }
2218
+
2219
+ // src/realtime/socket.ts
2220
+ var HEARTBEAT_MS = 25e3;
2221
+ var TIMEOUT_MS = 1e4;
2222
+ var PACED_BURST = CONNECTION_FRAME_BURST - 20;
2223
+ var SDK_WARNING_ROOM = 6;
2224
+ var ignore = () => void 0;
2225
+ function now() {
2226
+ return typeof performance === "undefined" ? Date.now() : performance.now();
2227
+ }
2228
+ function detach(ws) {
2229
+ ws.onopen = null;
2230
+ ws.onmessage = null;
2231
+ ws.onclose = null;
2232
+ ws.onerror = ignore;
2233
+ }
2234
+ function closeQuietly(ws, code) {
2235
+ try {
2236
+ ws.close(code);
2237
+ } catch {
2238
+ }
2239
+ }
2240
+ function safely(callback) {
2241
+ try {
2242
+ callback();
2243
+ } catch (error) {
2244
+ queueMicrotask(() => {
2245
+ throw error;
2246
+ });
2247
+ }
2248
+ }
2249
+ function replyOf(payload) {
2250
+ if (payload.status === "ok") {
2251
+ const { response } = payload;
2252
+ return {
2253
+ ok: true,
2254
+ response: typeof response === "object" && response !== null && !Array.isArray(response) ? response : {}
2255
+ };
2256
+ }
2257
+ return {
2258
+ ok: false,
2259
+ error: asError(payload.response) ?? invalidResponse(101),
2260
+ lost: false
2261
+ };
2262
+ }
2263
+ var RealtimeSocket = class {
2264
+ #config;
2265
+ #heartbeatMs;
2266
+ #timeoutMs;
2267
+ #ws = null;
2268
+ #state = "idle";
2269
+ #generation = 0;
2270
+ /** Drops since the hub last answered: the next wait's step (결정 7). */
2271
+ #attempt = 0;
2272
+ /** Tries in a row that never opened (결정 9). */
2273
+ #unopened = 0;
2274
+ /** When the wait the last close or stop set ends: no connect() opens before it. */
2275
+ #resumeAt = 0;
2276
+ /**
2277
+ * The last close was 4008 (CLIENT_FLOODING), and no heartbeat was
2278
+ * answered since: a channel's word that the hub answered does not start
2279
+ * the backoff over — a connection that floods again must not come back
2280
+ * every second (PR #328 review).
2281
+ */
2282
+ #flooded = false;
2283
+ /**
2284
+ * The frames waiting their turn on this connection, in the order they
2285
+ * go: the first `#ahead` before the app's (`#enqueue`).
2286
+ */
2287
+ #queue = [];
2288
+ /** How many at the front of `#queue` go before the app's frames: the SDK's own and the identity's, and the app's an identity frame must not pass. */
2289
+ #ahead = 0;
2290
+ /** The app's frames in `#queue`: not in flight yet, so no room is kept for them (`#hasRoom`). */
2291
+ #appQueued = 0;
2292
+ /** The budget this connection has left (`PACED_BURST` at the open), as of `#tokensAt`. */
2293
+ #tokens = 0;
2294
+ #tokensAt = 0;
2295
+ #paceTimer;
2296
+ /** When the hub refused a frame on this connection with a warning (`WARNED_CODES`), oldest first. */
2297
+ #warnings = [];
2298
+ /** The SDK's own frames waiting for room, by who sends them, in the order they asked (`whenRoom()`). */
2299
+ #roomWaiters = /* @__PURE__ */ new Map();
2300
+ /** Due when the oldest warning leaves the window, while a frame waits for room. */
2301
+ #roomTimer;
2302
+ #draining = false;
2303
+ #ref = 0;
2304
+ #retryTimer;
2305
+ #openTimer;
2306
+ #heartbeat;
2307
+ #heartbeatPending = false;
2308
+ /** The connection-level `system` frame since the open: why it is about to close. */
2309
+ #said = null;
2310
+ /**
2311
+ * The size limit the hub named when it refused a frame on this
2312
+ * connection (`#refusedOverLimit`): a frame over it is answered so without
2313
+ * being sent. Forgotten with the connection — the next hub's plan may
2314
+ * differ.
2315
+ */
2316
+ #frameLimit = null;
2317
+ #replies = /* @__PURE__ */ new Map();
2318
+ constructor(config) {
2319
+ this.#config = config;
2320
+ this.#heartbeatMs = config.options.heartbeatIntervalMs ?? HEARTBEAT_MS;
2321
+ this.#timeoutMs = config.options.timeoutMs ?? TIMEOUT_MS;
2322
+ }
2323
+ get generation() {
2324
+ return this.#generation;
2325
+ }
2326
+ get timeoutMs() {
2327
+ return this.#timeoutMs;
2328
+ }
2329
+ isOpen() {
2330
+ return this.#state === "open";
2331
+ }
2332
+ nextRef() {
2333
+ this.#ref += 1;
2334
+ return String(this.#ref);
2335
+ }
2336
+ /**
2337
+ * Opens unless a connection is open, opening or due — once the wait the
2338
+ * last close or stop set is over. After the app's own close the counts
2339
+ * start over; after a stop they go on, as after a drop.
2340
+ */
2341
+ connect() {
2342
+ if (this.#state !== "idle" && this.#state !== "stopped") return;
2343
+ if (this.#state === "idle") {
2344
+ this.#attempt = 0;
2345
+ this.#unopened = 0;
2346
+ }
2347
+ const wait = this.#resumeAt - now();
2348
+ if (wait <= 0) return this.#open();
2349
+ this.#state = "waiting";
2350
+ this.#retryTimer = setTimeout(() => {
2351
+ this.#retryTimer = void 0;
2352
+ if (this.#state === "waiting") this.#open();
2353
+ }, wait);
2354
+ }
2355
+ /** The last channel went: close for good, with no event. */
2356
+ disconnect() {
2357
+ const ws = this.#ws;
2358
+ this.#ws = null;
2359
+ this.#clearTimers();
2360
+ this.#state = "idle";
2361
+ this.#said = null;
2362
+ this.#failReplies(realtimeDisconnected());
2363
+ if (ws !== null) {
2364
+ detach(ws);
2365
+ closeQuietly(ws, 1e3);
2366
+ }
2367
+ }
2368
+ /**
2369
+ * Lets go of the open connection and comes back as after a drop (1006),
2370
+ * the channels told: for a connection that holds an identity the app no
2371
+ * longer means and cannot change (identity.ts). Nothing unless open.
2372
+ */
2373
+ reconnect() {
2374
+ const ws = this.#ws;
2375
+ if (ws === null || this.#state !== "open") return;
2376
+ detach(ws);
2377
+ closeQuietly(ws, 1e3);
2378
+ this.#closed(ws, 1006, "", null);
2379
+ }
2380
+ /**
2381
+ * The hub answered this connection: the next drop waits from the first
2382
+ * step — unless the last close was 4008 and no heartbeat was answered
2383
+ * since (`#flooded`).
2384
+ */
2385
+ served() {
2386
+ if (!this.#flooded) this.#attempt = 0;
2387
+ }
2388
+ /**
2389
+ * A frame, in its `turn` (the app's unless said), and the `phx_reply`
2390
+ * with its `ref` within `timeoutMs` of when it went — or, for one over the
2391
+ * size limit the hub named on this connection, that refusal at once and
2392
+ * nothing sent; for an app's frame but a leave that would not go within
2393
+ * `timeoutMs`, CLIENT_FLOODING at once and nothing sent.
2394
+ */
2395
+ request(topic, event, payload, options = {}) {
2396
+ const ref = options.ref ?? this.nextRef();
2397
+ const turn = options.turn ?? "app";
2398
+ return new Promise((resolve) => {
2399
+ const lost = {
2400
+ ok: false,
2401
+ error: realtimeClosed(1006),
2402
+ lost: true
2403
+ };
2404
+ if (!this.isOpen()) return resolve(lost);
2405
+ let text;
2406
+ try {
2407
+ text = clientFrame(topic, event, payload, ref, options.joinRef);
2408
+ } catch {
2409
+ return resolve(lost);
2410
+ }
2411
+ const bytes = utf8Length(text);
2412
+ const limit = this.#frameLimit;
2413
+ if (limit !== null && bytes > limit)
2414
+ return resolve({ ok: false, error: frameTooLarge(limit), lost: false });
2415
+ if (turn === "app" && event !== "phx_leave" && !this.#goesInTime())
2416
+ return resolve({
2417
+ ok: false,
2418
+ error: clientFlooding(CONNECTION_FRAMES_PER_SECOND),
2419
+ lost: false
2420
+ });
2421
+ this.#replies.set(ref, { resolve, timer: void 0, bytes, turn });
2422
+ this.#enqueue({ text, bytes, ref, turn, presence: event === "presence" });
2423
+ });
2424
+ }
2425
+ /**
2426
+ * Whether an app's frame behind every frame waiting would go within
2427
+ * `timeoutMs`: past the budget left, the hub takes
2428
+ * `CONNECTION_FRAMES_PER_SECOND` a second. The SDK's own frames that come
2429
+ * later go before it, a few at most (`whenRoom()`).
2430
+ */
2431
+ #goesInTime() {
2432
+ this.#refill();
2433
+ const over = this.#queue.length + 1 - this.#tokens;
2434
+ return over <= CONNECTION_FRAMES_PER_SECOND * this.#timeoutMs / 1e3;
2435
+ }
2436
+ /**
2437
+ * Runs `go` once a frame the SDK sends of its own accord may go: at once
2438
+ * when nothing waits before it and there is room — the warnings of the
2439
+ * last minute and the requests in flight fewer than `SDK_WARNING_ROOM`
2440
+ * (the app's frames still waiting their turn are not in flight yet, and
2441
+ * go after the SDK's), or the warnings fewer and none of the SDK's own
2442
+ * unanswered — or else when there is, in the order asked. The
2443
+ * frame itself is sent with `turn: "sdk"`. `key` names the sender:
2444
+ * asked again before it ran, it keeps its place and runs the newer `go`,
2445
+ * which reads its own state again then. Nothing while the connection is
2446
+ * not open, and forgotten with it: the sender tries again when it is live.
2447
+ */
2448
+ whenRoom(key, go) {
2449
+ if (this.#state !== "open") return;
2450
+ if (this.#roomWaiters.size === 0 && this.#hasRoom()) return go();
2451
+ this.#roomWaiters.set(key, go);
2452
+ this.#armRoom();
2453
+ }
2454
+ /**
2455
+ * Room for one more frame of the SDK's own (`whenRoom()`): the requests
2456
+ * that count are those sent and not answered, and the SDK's own still to
2457
+ * go — not the app's waiting their turn, which the SDK's go before (PR
2458
+ * #328 re-review). And while none of the SDK's own is unanswered, one
2459
+ * goes whatever the app has in flight, if the warnings leave room (PR #328
2460
+ * re-review 2): an app sending 10 a second over a link of 0.7 s or more
2461
+ * always has six in flight, and a second channel's join would wait,
2462
+ * untold, for as long as it sends. A refusal of the app's is a warning
2463
+ * when it comes, so the SDK adds one at most.
2464
+ */
2465
+ #hasRoom() {
2466
+ if (this.#state !== "open") return false;
2467
+ const since = now() - FLOOD_WINDOW_MS;
2468
+ this.#warnings = this.#warnings.filter((at) => at > since);
2469
+ const warnings = this.#warnings.length;
2470
+ if (warnings >= SDK_WARNING_ROOM) return false;
2471
+ const inFlight2 = this.#replies.size - this.#appQueued;
2472
+ return warnings + inFlight2 < SDK_WARNING_ROOM || !this.#sdkUnanswered();
2473
+ }
2474
+ /** Whether a request of the SDK's own or the identity's waits for its answer, sent or still to go. */
2475
+ #sdkUnanswered() {
2476
+ for (const waiter of this.#replies.values())
2477
+ if (waiter.turn !== "app") return true;
2478
+ return false;
2479
+ }
2480
+ /** The frames waiting for room go while there is: after an answer, a time-out, and when a warning leaves the window. */
2481
+ #drainRoom() {
2482
+ if (this.#draining) return;
2483
+ this.#draining = true;
2484
+ try {
2485
+ for (const [key, go] of this.#roomWaiters) {
2486
+ if (!this.#hasRoom()) break;
2487
+ this.#roomWaiters.delete(key);
2488
+ safely(go);
2489
+ }
2490
+ } finally {
2491
+ this.#draining = false;
2492
+ }
2493
+ this.#armRoom();
2494
+ }
2495
+ /** While a frame waits for room the warnings hold, look again when the oldest leaves the window. */
2496
+ #armRoom() {
2497
+ clearTimeout(this.#roomTimer);
2498
+ this.#roomTimer = void 0;
2499
+ const oldest = this.#warnings[0];
2500
+ if (this.#roomWaiters.size === 0 || oldest === void 0) return;
2501
+ this.#roomTimer = setTimeout(
2502
+ () => {
2503
+ this.#roomTimer = void 0;
2504
+ this.#drainRoom();
2505
+ },
2506
+ Math.max(1, oldest + FLOOD_WINDOW_MS - now() + 1)
2507
+ );
2508
+ }
2509
+ /**
2510
+ * Puts `frame` in its turn and sends what may go. The app's go last; the
2511
+ * SDK's own and the identity's after the others before the app's. An
2512
+ * identity frame goes after the app's presence frames still to go, with
2513
+ * whatever waits before them: the hub reads a presence as the user it
2514
+ * holds when the frame comes, and would hold the last user's state for the
2515
+ * new one.
2516
+ */
2517
+ #enqueue(frame) {
2518
+ if (frame.turn === "app") {
2519
+ this.#queue.push(frame);
2520
+ this.#appQueued += 1;
2521
+ } else {
2522
+ if (frame.turn === "identity") this.#ahead = this.#pastPresence();
2523
+ this.#queue.splice(this.#ahead, 0, frame);
2524
+ this.#ahead += 1;
2525
+ }
2526
+ this.#flush();
2527
+ }
2528
+ /** Just past the app's last presence frame still to go — `#ahead` when none is. */
2529
+ #pastPresence() {
2530
+ for (let index = this.#queue.length - 1; index >= this.#ahead; index -= 1)
2531
+ if (this.#queue[index]?.presence === true) return index + 1;
2532
+ return this.#ahead;
2533
+ }
2534
+ /** The next frame to go, out of `#queue`. */
2535
+ #next() {
2536
+ const frame = this.#queue.shift();
2537
+ if (frame === void 0) return void 0;
2538
+ if (this.#ahead > 0) this.#ahead -= 1;
2539
+ if (frame.turn === "app") this.#appQueued -= 1;
2540
+ return frame;
2541
+ }
2542
+ /** `ref`'s frame out of `#queue`, if it is still there: answered before it went. */
2543
+ #unqueue(ref) {
2544
+ const index = this.#queue.findIndex((frame2) => frame2.ref === ref);
2545
+ if (index === -1) return;
2546
+ const [frame] = this.#queue.splice(index, 1);
2547
+ if (index < this.#ahead) this.#ahead -= 1;
2548
+ if (frame?.turn === "app") this.#appQueued -= 1;
2549
+ }
2550
+ /**
2551
+ * Sends the frames waiting their turn while the budget has room, in their
2552
+ * turn, and comes back when the next one is due. One whose frame the
2553
+ * socket cannot send is lost now — its close event follows.
2554
+ */
2555
+ #flush() {
2556
+ const ws = this.#ws;
2557
+ if (ws === null || this.#state !== "open") return;
2558
+ this.#refill();
2559
+ while (this.#queue.length > 0 && this.#tokens >= 1) {
2560
+ const frame = this.#next();
2561
+ const waiter = this.#replies.get(frame.ref);
2562
+ if (waiter === void 0) continue;
2563
+ const limit = this.#frameLimit;
2564
+ if (limit !== null && frame.bytes > limit) {
2565
+ this.#answer(frame.ref, {
2566
+ ok: false,
2567
+ error: frameTooLarge(limit),
2568
+ lost: false
2569
+ });
2570
+ continue;
2571
+ }
2572
+ try {
2573
+ ws.send(frame.text);
2574
+ } catch {
2575
+ this.#answer(frame.ref, {
2576
+ ok: false,
2577
+ error: realtimeClosed(1006),
2578
+ lost: true
2579
+ });
2580
+ continue;
2581
+ }
2582
+ this.#tokens -= 1;
2583
+ waiter.timer = this.#timeOut(frame.ref, waiter);
2584
+ }
2585
+ if (this.#queue.length === 0 || this.#paceTimer !== void 0) return;
2586
+ const due = Math.ceil(
2587
+ (1 - this.#tokens) * 1e3 / CONNECTION_FRAMES_PER_SECOND
2588
+ );
2589
+ this.#paceTimer = setTimeout(
2590
+ () => {
2591
+ this.#paceTimer = void 0;
2592
+ this.#flush();
2593
+ },
2594
+ Math.max(1, due)
2595
+ );
2596
+ }
2597
+ /** The budget refilled for the time since it was last looked at, as the hub's bucket refills. */
2598
+ #refill() {
2599
+ const at = now();
2600
+ if (at <= this.#tokensAt) return;
2601
+ this.#tokens = Math.min(
2602
+ PACED_BURST,
2603
+ this.#tokens + (at - this.#tokensAt) / 1e3 * CONNECTION_FRAMES_PER_SECOND
2604
+ );
2605
+ this.#tokensAt = at;
2606
+ }
2607
+ /**
2608
+ * `ref`'s request hears `reply`, now: one fewer in flight — or, answered
2609
+ * before its frame went (over a size limit the hub named), one fewer to
2610
+ * go, and nothing sent.
2611
+ */
2612
+ #answer(ref, reply) {
2613
+ const waiter = this.#replies.get(ref);
2614
+ if (waiter === void 0) return;
2615
+ this.#replies.delete(ref);
2616
+ clearTimeout(waiter.timer);
2617
+ if (waiter.timer === void 0) this.#unqueue(ref);
2618
+ waiter.resolve(reply);
2619
+ this.#drainRoom();
2620
+ }
2621
+ /** TIMED_OUT for `ref`'s request `timeoutMs` after its frame went. */
2622
+ #timeOut(ref, waiter) {
2623
+ return setTimeout(() => {
2624
+ if (this.#replies.get(ref) !== waiter) return;
2625
+ this.#answer(ref, {
2626
+ ok: false,
2627
+ error: realtimeTimedOut(this.#timeoutMs),
2628
+ lost: false
2629
+ });
2630
+ }, this.#timeoutMs);
2631
+ }
2632
+ #open() {
2633
+ const Impl = this.#config.options.WebSocket ?? globalThis.WebSocket;
2634
+ if (typeof Impl !== "function")
2635
+ return this.#stop(websocketUnavailable(), true);
2636
+ if (this.#config.service && !isKnownServer())
2637
+ return this.#stop(serviceKeyOffServer(), true);
2638
+ let ws;
2639
+ try {
2640
+ ws = new Impl(
2641
+ socketUrl(this.#config.baseUrl, this.#config.apiKey)
2642
+ );
2643
+ } catch (error) {
2644
+ return this.#stop(socketRefused(error), true);
2645
+ }
2646
+ this.#generation += 1;
2647
+ this.#ws = ws;
2648
+ this.#state = "connecting";
2649
+ this.#said = null;
2650
+ this.#frameLimit = null;
2651
+ ws.onopen = () => this.#opened(ws);
2652
+ ws.onmessage = (event) => this.#message(ws, event.data);
2653
+ ws.onclose = (event) => this.#closed(ws, event.code, event.reason ?? "", null);
2654
+ ws.onerror = ignore;
2655
+ this.#openTimer = setTimeout(
2656
+ () => this.#closed(ws, 1006, "", this.#timeoutMs),
2657
+ this.#timeoutMs
2658
+ );
2659
+ }
2660
+ #opened(ws) {
2661
+ if (this.#ws !== ws || this.#state !== "connecting") return;
2662
+ clearTimeout(this.#openTimer);
2663
+ this.#openTimer = void 0;
2664
+ this.#state = "open";
2665
+ this.#unopened = 0;
2666
+ this.#heartbeatPending = false;
2667
+ this.#queue = [];
2668
+ this.#ahead = 0;
2669
+ this.#appQueued = 0;
2670
+ this.#tokens = PACED_BURST;
2671
+ this.#tokensAt = now();
2672
+ this.#warnings = [];
2673
+ this.#heartbeat = setInterval(() => this.#beat(ws), this.#heartbeatMs);
2674
+ safely(() => this.#config.events.opened());
2675
+ }
2676
+ #beat(ws) {
2677
+ if (this.#ws !== ws) return;
2678
+ if (this.#heartbeatPending)
2679
+ return this.#closed(ws, 1e3, "", this.#heartbeatMs);
2680
+ this.#heartbeatPending = true;
2681
+ try {
2682
+ ws.send(HEARTBEAT_REQUEST);
2683
+ } catch {
2684
+ }
2685
+ }
2686
+ #message(ws, data) {
2687
+ if (this.#ws !== ws) return;
2688
+ const frame = readServerFrame(data);
2689
+ if (frame === null) return;
2690
+ if (frame.event === "phx_reply" && frame.ref !== null) {
2691
+ if (frame.ref === HEARTBEAT_REF) {
2692
+ this.#heartbeatPending = false;
2693
+ this.#flooded = false;
2694
+ this.#attempt = 0;
2695
+ return;
2696
+ }
2697
+ const reply = replyOf(frame.payload);
2698
+ if (!reply.ok && WARNED_CODES.has(reply.error.code))
2699
+ this.#warnings.push(now());
2700
+ if (this.#replies.has(frame.ref)) {
2701
+ this.#answer(frame.ref, reply);
2702
+ return;
2703
+ }
2704
+ }
2705
+ if (frame.topic === PHOENIX_TOPIC) {
2706
+ if (frame.event === "system") this.#said = asError(frame.payload);
2707
+ else if (frame.event === "phx_reply" && frame.ref === null)
2708
+ this.#refusedOverLimit(frame.payload);
2709
+ return;
2710
+ }
2711
+ safely(() => this.#config.events.frame(frame));
2712
+ }
2713
+ /**
2714
+ * The hub refused a frame over its size limit before reading it (final
2715
+ * review A M3, plan 정정 (m)): it names no `ref`, so no request waits for
2716
+ * it by name — it waited `timeoutMs` and answered TIMED_OUT. But the hub
2717
+ * refuses every frame over the limit, and only those, before anything
2718
+ * else: each request waiting whose frame is over it is one refused so, and
2719
+ * hears it now. The limit stays for the connection.
2720
+ */
2721
+ #refusedOverLimit(payload) {
2722
+ if (payload.status !== "error") return;
2723
+ const error = asError(payload.response);
2724
+ if (error?.code !== "INVALID_MESSAGE" || !isRecord(error.details)) return;
2725
+ const limit = frameLimitOf(error.details.reason);
2726
+ if (limit === null) return;
2727
+ this.#frameLimit = limit;
2728
+ for (const [ref, waiter] of this.#replies)
2729
+ if (waiter.bytes > limit)
2730
+ this.#answer(ref, {
2731
+ ok: false,
2732
+ error: frameTooLarge(limit),
2733
+ lost: false
2734
+ });
2735
+ }
2736
+ /**
2737
+ * `ws` ended: its close event (`waitedMs` null), or a heartbeat or an open
2738
+ * that had no answer in `waitedMs` — then it is let go at once, its
2739
+ * handlers first, since a dead TCP connection can take minutes to report
2740
+ * its close (결정 11).
2741
+ */
2742
+ #closed(ws, code, reason, waitedMs) {
2743
+ if (this.#ws !== ws) return;
2744
+ const wasOpen = this.#state === "open";
2745
+ const timedOut = waitedMs !== null;
2746
+ detach(ws);
2747
+ this.#ws = null;
2748
+ this.#clearTimers();
2749
+ if (timedOut) closeQuietly(ws, 1e3);
2750
+ if (!wasOpen) this.#unopened += 1;
2751
+ const said = this.#said;
2752
+ this.#said = null;
2753
+ if (!timedOut && code === 4008) this.#flooded = true;
2754
+ const action = timedOut ? { kind: "retry", minMs: 0 } : closeAction(code, said?.code ?? reason);
2755
+ if (action.kind === "stop")
2756
+ return this.#stop(said ?? realtimeStopped(code, reason));
2757
+ const error = timedOut ? realtimeTimedOut(waitedMs) : said ?? realtimeClosed(code);
2758
+ this.#failReplies(error);
2759
+ this.#state = "waiting";
2760
+ const delay = delayAfter(
2761
+ action,
2762
+ { attempt: this.#attempt, unopened: this.#unopened },
2763
+ Math.random
2764
+ );
2765
+ if (action.kind !== "restart" && action.kind !== "wait") this.#attempt += 1;
2766
+ this.#wait(action.kind === "session", delay);
2767
+ safely(
2768
+ () => this.#config.events.dropped(
2769
+ error,
2770
+ timedOut ? "TIMED_OUT" : "CHANNEL_ERROR"
2771
+ )
2772
+ );
2773
+ }
2774
+ /**
2775
+ * The next try, `delay` from now — after a 4401, from when its
2776
+ * conditional delete is done, or from `timeoutMs` on if it is not: an
2777
+ * app's `auth.storage` that never answers must not keep the connection
2778
+ * away for good.
2779
+ */
2780
+ #wait(session, delay) {
2781
+ const generation = this.#generation;
2782
+ this.#resumeAt = now() + delay;
2783
+ let due = false;
2784
+ const schedule = () => {
2785
+ if (due || this.#state !== "waiting" || this.#generation !== generation)
2786
+ return;
2787
+ due = true;
2788
+ this.#resumeAt = now() + delay;
2789
+ clearTimeout(this.#retryTimer);
2790
+ this.#retryTimer = setTimeout(() => {
2791
+ this.#retryTimer = void 0;
2792
+ if (this.#state === "waiting") this.#open();
2793
+ }, delay);
2794
+ };
2795
+ if (!session) return schedule();
2796
+ this.#retryTimer = setTimeout(schedule, this.#timeoutMs);
2797
+ safely(() => {
2798
+ void this.#config.events.sessionRefused().then(schedule, schedule);
2799
+ });
2800
+ }
2801
+ /**
2802
+ * Stopped until a connect(), which waits as after a drop: the stop counts
2803
+ * as one (`unopened` when no socket was made — the runtime cannot open).
2804
+ */
2805
+ #stop(error, unopened = false) {
2806
+ this.#clearTimers();
2807
+ this.#state = "stopped";
2808
+ this.#failReplies(error);
2809
+ if (unopened) this.#unopened += 1;
2810
+ this.#resumeAt = now() + delayAfter(
2811
+ { kind: "retry", minMs: 0 },
2812
+ { attempt: this.#attempt, unopened: this.#unopened },
2813
+ Math.random
2814
+ );
2815
+ this.#attempt += 1;
2816
+ safely(() => this.#config.events.stopped(error));
2817
+ }
2818
+ /** Every request still waiting hears `error`, as lost with the connection. */
2819
+ #failReplies(error) {
2820
+ const waiting = [...this.#replies.values()];
2821
+ this.#replies.clear();
2822
+ for (const waiter of waiting) {
2823
+ clearTimeout(waiter.timer);
2824
+ waiter.resolve({ ok: false, error, lost: true });
2825
+ }
2826
+ }
2827
+ /** Every timer of the connection, and the frames still to go with it. */
2828
+ #clearTimers() {
2829
+ clearTimeout(this.#retryTimer);
2830
+ clearTimeout(this.#openTimer);
2831
+ clearInterval(this.#heartbeat);
2832
+ clearTimeout(this.#paceTimer);
2833
+ clearTimeout(this.#roomTimer);
2834
+ this.#retryTimer = void 0;
2835
+ this.#openTimer = void 0;
2836
+ this.#heartbeat = void 0;
2837
+ this.#paceTimer = void 0;
2838
+ this.#roomTimer = void 0;
2839
+ this.#queue = [];
2840
+ this.#ahead = 0;
2841
+ this.#appQueued = 0;
2842
+ this.#roomWaiters.clear();
2843
+ }
2844
+ };
2845
+
2846
+ // src/realtime/channel.ts
2847
+ function sent(reply) {
2848
+ return reply.ok ? { data: null, error: null, lost: false } : { data: null, error: reply.error, lost: reply.lost };
2849
+ }
2850
+ function unsent(error) {
2851
+ return { data: null, error, lost: false };
2852
+ }
2853
+ function jsonObject(value) {
2854
+ try {
2855
+ const copy = JSON.parse(JSON.stringify(value));
2856
+ return isRecord(copy) ? copy : null;
2857
+ } catch {
2858
+ return null;
2859
+ }
2860
+ }
2861
+ function presenceReadDenied(response) {
2862
+ const { access } = response;
2863
+ return isRecord(access) && isRecord(access.presence) && access.presence.read === false;
2864
+ }
2865
+ function tablesOf(changes) {
2866
+ return changes.map((binding) => binding.filter.table ?? "*");
2867
+ }
2868
+ var JOIN_RETRY_CODES = /* @__PURE__ */ new Set([
2869
+ "TOO_MANY_JOINS",
2870
+ "INTERNAL_ERROR",
2871
+ "TIMED_OUT"
2872
+ ]);
2873
+ var RETRACK_RETRY_CODES = /* @__PURE__ */ new Set([
2874
+ "TOO_MANY_PRESENCE_MESSAGES",
2875
+ "TOO_MANY_MESSAGES",
2876
+ "TOO_MANY_JOINS",
2877
+ "INTERNAL_ERROR",
2878
+ "TIMED_OUT"
2879
+ ]);
2880
+ var RETRACK_WARNED_RESENDS = 3;
2881
+ var LATE_LISTENER = "naro-js: postgres_changes and presence listeners added after subscribe() count from the channel's next join; call unsubscribe() and subscribe() again to add them now.";
2882
+ function isDevelopment() {
2883
+ const env = globalThis.process?.env;
2884
+ return env?.NODE_ENV !== "production";
2885
+ }
2886
+ function warnInDevelopment(message) {
2887
+ if (isDevelopment()) console.warn(message);
2888
+ }
2889
+ function isJson(value) {
2890
+ try {
2891
+ JSON.stringify(value);
2892
+ return true;
2893
+ } catch {
2894
+ return false;
2895
+ }
2896
+ }
2897
+ function changeSpec(filter) {
2898
+ const {
2899
+ event,
2900
+ schema,
2901
+ table,
2902
+ filter: text,
2903
+ ...rest
2904
+ } = filter;
2905
+ return {
2906
+ ...rest,
2907
+ event,
2908
+ ...schema === void 0 ? {} : { schema },
2909
+ ...table === void 0 ? {} : { table },
2910
+ ...text === void 0 || text === "" ? {} : { filter: text }
2911
+ };
2912
+ }
2913
+ function changePayload(data) {
2914
+ return {
2915
+ schema: "public",
2916
+ table: typeof data.table === "string" ? data.table : "",
2917
+ commit_timestamp: typeof data.commit_timestamp === "string" ? data.commit_timestamp : "",
2918
+ eventType: data.type,
2919
+ new: isRecord(data.record) ? data.record : {},
2920
+ old: isRecord(data.old_record) ? data.old_record : {},
2921
+ errors: Array.isArray(data.errors) ? data.errors.filter((item) => typeof item === "string") : null
2922
+ };
2923
+ }
2924
+ function systemMessage(payload) {
2925
+ const { extension, code, message, channel } = payload;
2926
+ return {
2927
+ status: payload.status === "ok" ? "ok" : "error",
2928
+ extension: typeof extension === "string" ? extension : "system",
2929
+ code: typeof code === "string" ? code : "",
2930
+ message: typeof message === "string" ? message : "",
2931
+ channel: typeof channel === "string" ? channel : null
2932
+ };
2933
+ }
2934
+ var RealtimeChannel = class {
2935
+ name;
2936
+ topic;
2937
+ isPrivate;
2938
+ options;
2939
+ #context;
2940
+ #bindings = [];
2941
+ #presence = new Presence();
2942
+ #state = "closed";
2943
+ /** subscribe() was called and unsubscribe() was not: join it on every connection. */
2944
+ #wanted = false;
2945
+ /** The join sent and not left or replaced since — set when it is sent, before its answer. */
2946
+ #joinRef = null;
2947
+ /** The tables that join's row subscriptions are on (`*` for every table): its leave holds them. */
2948
+ #joinTables = [];
2949
+ /**
2950
+ * The join was refused a cap a leave owed on the connection still holds
2951
+ * a place under (`ChannelContext.owesLeaveOn`): it waits for that leave
2952
+ * rather than knock again (PR #328 review). Null otherwise.
2953
+ */
2954
+ #capWait = null;
2955
+ #onStatus = null;
2956
+ /** The app already heard of this trouble; reset at SUBSCRIBED. */
2957
+ #told = false;
2958
+ #attempt = 0;
2959
+ #retry;
2960
+ /** The `system` error sent before a `phx_close` (protocol.ts `closeChannel`). */
2961
+ #closeError = null;
2962
+ /** The last state tracked, and the token the hub had then (결정 17). */
2963
+ #tracked = null;
2964
+ /** Bumped when what is tracked is let go of: a track answered after that does not bring it back. */
2965
+ #letGo = 0;
2966
+ /** Bumped by the app's track() and untrack(): one in the SUBSCRIBED callback says what the join holds. */
2967
+ #presenceCalls = 0;
2968
+ /** A track sent again and refused for now goes again when this is due (`#retrack`). */
2969
+ #retrackTimer;
2970
+ /**
2971
+ * The app's presence calls and broadcasts waiting for the connection to
2972
+ * be live, in the order it made them (`#inTurn`): each sends, or answers
2973
+ * that its join went, when called.
2974
+ */
2975
+ #waiting = [];
2976
+ /** Inside subscribe(): a stop is told a microtask later (`#toldStop`). */
2977
+ #subscribing = false;
2978
+ /** Who waits for room on the connection (`whenRoom()`): this channel's join, and its track sent again. */
2979
+ #joinTurn = {};
2980
+ #retrackTurn = {};
2981
+ /**
2982
+ * Frames of the join in flight that came before its answer was handled
2983
+ * (final review A M1, plan 정정 (m)): Node's `ws` and undici hand over
2984
+ * the frames of one read in one callback, while the answer's handler runs
2985
+ * a microtask later — a change right behind the `ok` had no subscription
2986
+ * id yet. They are heard once the `ok` is (after SUBSCRIBED, as in a
2987
+ * browser, which runs a task per message), and dropped with a refusal.
2988
+ */
2989
+ #early = [];
2990
+ /** @internal `naro.channel()` makes channels. */
2991
+ constructor(name, options, context) {
2992
+ this.name = name;
2993
+ this.topic = `${REALTIME_TOPIC_PREFIX}${name}`;
2994
+ this.isPrivate = options.config?.private === true;
2995
+ this.options = options;
2996
+ this.#context = context;
2997
+ }
2998
+ get state() {
2999
+ return this.#state;
3000
+ }
3001
+ /** @internal The join sent and not left or replaced since (client.ts routes by it). */
3002
+ get joinRef() {
3003
+ return this.#joinRef;
3004
+ }
3005
+ on(type, filter, callback) {
3006
+ const listener = callback;
3007
+ const spec = isRecord(filter) ? filter : {};
3008
+ if ((type === "postgres_changes" || type === "presence") && this.#wanted)
3009
+ warnInDevelopment(LATE_LISTENER);
3010
+ if (type === "postgres_changes")
3011
+ this.#bindings.push({
3012
+ type,
3013
+ filter: spec,
3014
+ callback: listener,
3015
+ id: null
3016
+ });
3017
+ else if (type === "broadcast" || type === "presence")
3018
+ this.#bindings.push({
3019
+ type,
3020
+ event: String(spec.event),
3021
+ callback: listener
3022
+ });
3023
+ else if (type === "system")
3024
+ this.#bindings.push({ type, callback: listener });
3025
+ return this;
3026
+ }
3027
+ subscribe(callback) {
3028
+ if (callback !== void 0) this.#onStatus = callback;
3029
+ if (this.#wanted) return this;
3030
+ const problem = this.#nameError();
3031
+ if (problem !== null) {
3032
+ this.#state = "errored";
3033
+ queueMicrotask(() => this.#status("CHANNEL_ERROR", problem));
3034
+ return this;
3035
+ }
3036
+ this.#wanted = true;
3037
+ this.#state = "joining";
3038
+ this.#attempt = 0;
3039
+ this.#told = false;
3040
+ this.#capWait = null;
3041
+ this.#clearRetry();
3042
+ this.#subscribing = true;
3043
+ try {
3044
+ this.#context.connect();
3045
+ this.#joinIfDue();
3046
+ } finally {
3047
+ this.#subscribing = false;
3048
+ }
3049
+ return this;
3050
+ }
3051
+ async unsubscribe() {
3052
+ const ref = this.#joinRef;
3053
+ const held = this.#state === "joined" || this.#state === "joining";
3054
+ this.#wanted = false;
3055
+ this.#clearRetry();
3056
+ this.#letGoOfTrack();
3057
+ this.#joinRef = null;
3058
+ this.#state = "leaving";
3059
+ this.#releaseWaiting();
3060
+ let error = null;
3061
+ if (held && ref !== null) {
3062
+ const reply = await this.#context.leave(this, ref, this.#joinTables);
3063
+ if (!reply.ok && !reply.lost && reply.error.code !== "NOT_JOINED")
3064
+ error = reply.error;
3065
+ }
3066
+ if (this.#state === "leaving") {
3067
+ this.#state = "closed";
3068
+ this.#presence.clear();
3069
+ this.#status("CLOSED");
3070
+ }
3071
+ return { data: null, error };
3072
+ }
3073
+ /**
3074
+ * Broadcasts `message` on the join, or over HTTP before it: the server's
3075
+ * answer, `lost` when the connection went before it (RealtimeSendResult).
3076
+ */
3077
+ async send(message) {
3078
+ if (!isRecord(message) || message.type !== "broadcast")
3079
+ return unsent(invalidBroadcast());
3080
+ const problem = this.#nameError();
3081
+ if (problem !== null) return unsent(problem);
3082
+ const { event, payload } = message;
3083
+ if (!isJson({ event, payload }))
3084
+ return unsent(invalidQuery("", "is not JSON"));
3085
+ const joinRef = this.#joinRef;
3086
+ if (this.#state === "joined" && joinRef !== null) {
3087
+ const reply = await this.#inTurn(
3088
+ joinRef,
3089
+ !this.#context.live(),
3090
+ () => this.#context.socket.request(
3091
+ this.topic,
3092
+ "broadcast",
3093
+ { type: "broadcast", event, payload },
3094
+ { joinRef }
3095
+ )
3096
+ );
3097
+ if (reply !== null) return sent(reply);
3098
+ }
3099
+ await this.#context.auth.ready();
3100
+ const result = await call(this.#context.http, {
3101
+ method: "POST",
3102
+ path: "/realtime/broadcast",
3103
+ body: {
3104
+ messages: [
3105
+ { topic: this.topic, event, payload, private: this.isPrivate }
3106
+ ]
3107
+ },
3108
+ withToken: true
3109
+ });
3110
+ return unsent(result.error);
3111
+ }
3112
+ /**
3113
+ * Tracks `state` as this connection's presence here (original spec
3114
+ * §11.1): the hub's answer. Only on a join — before it, NOT_JOINED, and
3115
+ * nothing is kept for later (결정 18). It goes as its JSON reads back, and
3116
+ * that copy, not the app's object, is what a later join sends again.
3117
+ * While who the connection is is not settled (a new token in flight, a
3118
+ * retry), it waits until it is (정정 (k)): the hub may yet refuse the new
3119
+ * token for now and keep the last user, whose presence it would be.
3120
+ */
3121
+ async track(state) {
3122
+ const problem = this.#nameError();
3123
+ if (problem !== null) return unsent(problem);
3124
+ const joinRef = this.#joinRef;
3125
+ if (this.#state !== "joined" || joinRef === null)
3126
+ return unsent(notJoined(this.name));
3127
+ const payload = jsonObject(state);
3128
+ if (payload === null) return unsent(invalidPresence());
3129
+ this.#presenceCalls += 1;
3130
+ this.#clearRetrack();
3131
+ const letGo = this.#letGo;
3132
+ const reply = await this.#inTurn(
3133
+ joinRef,
3134
+ !this.#context.live(),
3135
+ () => this.#sendTrack(payload, joinRef, letGo, true)
3136
+ );
3137
+ if (reply === null) {
3138
+ if (letGo === this.#letGo) this.#letGoOfTrack();
3139
+ return unsent(notJoined(this.name));
3140
+ }
3141
+ return sent(reply);
3142
+ }
3143
+ /**
3144
+ * Takes this connection's presence off the channel: the hub's answer, or
3145
+ * none when not joined. After the app's tracks that wait, if any.
3146
+ */
3147
+ async untrack() {
3148
+ this.#presenceCalls += 1;
3149
+ this.#letGoOfTrack();
3150
+ const joinRef = this.#joinRef;
3151
+ if (this.#state !== "joined" || joinRef === null) return unsent(null);
3152
+ const reply = await this.#inTurn(
3153
+ joinRef,
3154
+ false,
3155
+ () => this.#context.socket.request(
3156
+ this.topic,
3157
+ "presence",
3158
+ { type: "presence", event: "untrack" },
3159
+ { joinRef }
3160
+ )
3161
+ );
3162
+ return reply === null ? unsent(null) : sent(reply);
3163
+ }
3164
+ /** The presences of the channel, by key (결정 16): a copy. */
3165
+ presenceState() {
3166
+ return this.#presence.state();
3167
+ }
3168
+ /**
3169
+ * @internal
3170
+ * The connection is live, or a leave owed is done (client.ts): join if
3171
+ * the app wants it and the channel holds no join and waits for no retry.
3172
+ * Called whenever either happens, for every channel, so a channel joined
3173
+ * or joining stays as it is.
3174
+ */
3175
+ rejoin() {
3176
+ if (this.#context.live()) this.#releaseWaiting();
3177
+ this.#joinIfDue();
3178
+ }
3179
+ /**
3180
+ * @internal
3181
+ * A leave of this name and privacy owed on the connection was refused, or
3182
+ * not answered in time, and goes again (client.ts): a join waiting on it
3183
+ * tells the app once a wait, as a refused join tried again does (결정 14,
3184
+ * 정정 (j)), and waits on.
3185
+ */
3186
+ leaveRefused(error) {
3187
+ if (this.#wanted && this.#joinRef === null)
3188
+ this.#tell(
3189
+ error.code === "TIMED_OUT" ? "TIMED_OUT" : "CHANNEL_ERROR",
3190
+ error
3191
+ );
3192
+ }
3193
+ /** @internal The connection dropped and comes back: tell the app once, rejoin when live. */
3194
+ dropped(error, status) {
3195
+ if (!this.#wanted) return;
3196
+ this.#clearRetry();
3197
+ this.#capWait = null;
3198
+ this.#joinRef = null;
3199
+ this.#state = "joining";
3200
+ this.#releaseWaiting();
3201
+ this.#presence.unsync();
3202
+ this.#tell(status, error);
3203
+ }
3204
+ /** @internal The connection stopped for good: the app must subscribe() again. */
3205
+ stopped(error) {
3206
+ if (this.#wanted) this.#stop(error);
3207
+ }
3208
+ /** @internal A frame of this channel's join (client.ts routes by join_ref). */
3209
+ receive(frame) {
3210
+ if (this.#state === "joining") {
3211
+ this.#early.push(frame);
3212
+ return;
3213
+ }
3214
+ switch (frame.event) {
3215
+ case "postgres_changes":
3216
+ return this.#change(frame.payload);
3217
+ case "broadcast":
3218
+ return this.#broadcast(frame.payload);
3219
+ case "presence_state":
3220
+ return this.#presence.applyState(frame.payload, this.#presenceEmit);
3221
+ case "presence_diff":
3222
+ return this.#presence.applyDiff(frame.payload, this.#presenceEmit);
3223
+ case "system":
3224
+ return this.#system(frame.payload);
3225
+ case "phx_close":
3226
+ return this.#serverClosed();
3227
+ default:
3228
+ return;
3229
+ }
3230
+ }
3231
+ /**
3232
+ * INVALID_CHANNEL for a name the hub would refuse, before anything is
3233
+ * sent — and before any error of ours quotes the name.
3234
+ */
3235
+ #nameError() {
3236
+ const problem = channelNameProblem(this.name);
3237
+ return problem === void 0 ? null : invalidChannel(problem);
3238
+ }
3239
+ #config(changes) {
3240
+ const config = this.options.config ?? {};
3241
+ const presence = config.presence ?? {};
3242
+ const key = presence.key ?? "";
3243
+ const listens = this.#bindings.some(
3244
+ (binding) => binding.type === "presence"
3245
+ );
3246
+ return {
3247
+ ...config,
3248
+ // ack is always on the wire, so send() always ends in the hub's answer (결정 6).
3249
+ broadcast: {
3250
+ ...config.broadcast,
3251
+ self: config.broadcast?.self ?? false,
3252
+ ack: true
3253
+ },
3254
+ presence: {
3255
+ ...presence,
3256
+ key,
3257
+ enabled: listens || presence.enabled === true || key !== ""
3258
+ },
3259
+ postgres_changes: changes.map((binding) => changeSpec(binding.filter)),
3260
+ private: this.isPrivate
3261
+ };
3262
+ }
3263
+ /**
3264
+ * Joins when due — once there is room on the connection for a frame the
3265
+ * SDK sends of its own accord (`whenRoom()`, PR #328 review), and still
3266
+ * due then.
3267
+ */
3268
+ #joinIfDue() {
3269
+ if (!this.#dueToJoin()) return;
3270
+ this.#context.socket.whenRoom(this.#joinTurn, () => {
3271
+ if (this.#dueToJoin()) this.#join();
3272
+ });
3273
+ }
3274
+ #dueToJoin() {
3275
+ return this.#wanted && this.#joinRef === null && this.#retry === void 0 && this.#context.live() && !this.#context.owesLeave(this) && !this.#heldByCap();
3276
+ }
3277
+ /** A cap refused the last join, and a leave owed still holds the place it needs. */
3278
+ #heldByCap() {
3279
+ const wait = this.#capWait;
3280
+ if (wait === null) return false;
3281
+ if (this.#context.owesLeaveOn(wait.table)) return true;
3282
+ this.#capWait = null;
3283
+ return false;
3284
+ }
3285
+ /**
3286
+ * The cap `error` names, when a leave owed on the connection holds a place
3287
+ * under it (PR #328 review): the hub still counts that join — 100
3288
+ * channels a connection (TOO_MANY_CHANNELS), or 4 on one table's changes
3289
+ * (INVALID_CHANNEL_CONFIG with the hub's `changeChannels` reason at
3290
+ * `config.postgres_changes[<i>].table`, the table of `changes[i]`) — and
3291
+ * lets go of it once the leave goes through. Null for any other refusal.
3292
+ */
3293
+ #capHeld(error, changes) {
3294
+ if (error.code === "TOO_MANY_CHANNELS")
3295
+ return this.#context.owesLeaveOn(null) ? { table: null } : null;
3296
+ if (error.code !== "INVALID_CHANNEL_CONFIG" || !isRecord(error.details))
3297
+ return null;
3298
+ const { path, reason } = error.details;
3299
+ if (reason !== CHANGE_CHANNELS_REASON || typeof path !== "string")
3300
+ return null;
3301
+ const index = /^config\.postgres_changes\[(\d+)\]\.table$/.exec(path)?.[1];
3302
+ const binding = index === void 0 ? void 0 : changes[Number(index)];
3303
+ if (binding === void 0) return null;
3304
+ const table = binding.filter.table ?? "*";
3305
+ return this.#context.owesLeaveOn(table) ? { table } : null;
3306
+ }
3307
+ #join() {
3308
+ const changes = this.#bindings.filter(
3309
+ (b) => b.type === "postgres_changes"
3310
+ );
3311
+ const config = this.#config(changes);
3312
+ if (!isJson(config))
3313
+ return this.#stop(invalidChannelConfig("config", "is not JSON"));
3314
+ const socket = this.#context.socket;
3315
+ const ref = socket.nextRef();
3316
+ this.#joinRef = ref;
3317
+ this.#joinTables = tablesOf(changes);
3318
+ this.#capWait = null;
3319
+ this.#state = "joining";
3320
+ this.#closeError = null;
3321
+ this.#early = [];
3322
+ this.#presence.unsync();
3323
+ for (const binding of changes) binding.id = null;
3324
+ void socket.request(
3325
+ this.topic,
3326
+ "phx_join",
3327
+ { config },
3328
+ { joinRef: ref, ref, turn: "sdk" }
3329
+ ).then((reply) => this.#joined(ref, changes, reply));
3330
+ }
3331
+ #joined(ref, changes, reply) {
3332
+ if (this.#joinRef !== ref) return;
3333
+ const early = this.#early;
3334
+ this.#early = [];
3335
+ if (reply.ok) {
3336
+ const ids = Array.isArray(reply.response.postgres_changes) ? reply.response.postgres_changes : [];
3337
+ changes.forEach((binding, index) => {
3338
+ const entry = ids[index];
3339
+ binding.id = isRecord(entry) && typeof entry.id === "number" ? entry.id : null;
3340
+ });
3341
+ this.#state = "joined";
3342
+ this.#attempt = 0;
3343
+ this.#told = false;
3344
+ this.#context.socket.served();
3345
+ const calls = this.#presenceCalls;
3346
+ this.#status("SUBSCRIBED");
3347
+ if (this.#presenceCalls === calls) this.#retrack(ref);
3348
+ if (this.#joinRef === ref && presenceReadDenied(reply.response))
3349
+ this.#presence.leaveAll(this.#presenceEmit);
3350
+ for (const frame of early) {
3351
+ if (this.#joinRef !== ref || this.#state !== "joined") break;
3352
+ this.receive(frame);
3353
+ }
3354
+ return;
3355
+ }
3356
+ if (reply.lost) {
3357
+ this.#joinRef = null;
3358
+ return;
3359
+ }
3360
+ const { error } = reply;
3361
+ this.#joinRef = null;
3362
+ const capHeld = this.#capHeld(error, changes);
3363
+ if (capHeld !== null) {
3364
+ this.#capWait = capHeld;
3365
+ this.#state = "joining";
3366
+ this.#tell("CHANNEL_ERROR", error);
3367
+ return;
3368
+ }
3369
+ const meetsOld = error.code === "ALREADY_JOINED" && this.#context.owesLeave(this);
3370
+ if (!JOIN_RETRY_CODES.has(error.code) && !meetsOld) {
3371
+ this.#wanted = false;
3372
+ this.#state = "errored";
3373
+ this.#presence.leaveAll(this.#presenceEmit);
3374
+ this.#status("CHANNEL_ERROR", error);
3375
+ return;
3376
+ }
3377
+ if (error.code === "TIMED_OUT")
3378
+ void this.#context.leave(this, ref, tablesOf(changes));
3379
+ this.#tell(
3380
+ error.code === "TIMED_OUT" ? "TIMED_OUT" : "CHANNEL_ERROR",
3381
+ error
3382
+ );
3383
+ this.#state = "joining";
3384
+ const delay = backoffMs(this.#attempt, Math.random);
3385
+ this.#attempt += 1;
3386
+ this.#retry = setTimeout(() => {
3387
+ this.#retry = void 0;
3388
+ this.#joinIfDue();
3389
+ }, delay);
3390
+ }
3391
+ #change(payload) {
3392
+ const ids = Array.isArray(payload.ids) ? payload.ids : [];
3393
+ if (!isRecord(payload.data)) return;
3394
+ const change = changePayload(payload.data);
3395
+ for (const binding of this.#bindings)
3396
+ if (binding.type === "postgres_changes" && binding.id !== null && ids.includes(binding.id))
3397
+ safely(() => binding.callback(change));
3398
+ }
3399
+ #broadcast(payload) {
3400
+ const { event } = payload;
3401
+ if (typeof event !== "string") return;
3402
+ const message = {
3403
+ type: "broadcast",
3404
+ event,
3405
+ payload: isRecord(payload.payload) ? payload.payload : {}
3406
+ };
3407
+ for (const binding of this.#bindings)
3408
+ if (binding.type === "broadcast" && binding.event === event)
3409
+ safely(() => binding.callback(message));
3410
+ }
3411
+ #system(payload) {
3412
+ const message = systemMessage(payload);
3413
+ if (message.status === "error" && message.extension === "system")
3414
+ this.#closeError = asError(payload);
3415
+ for (const binding of this.#bindings)
3416
+ if (binding.type === "system") safely(() => binding.callback(message));
3417
+ }
3418
+ /** The hub closed this join (lost access, its state full): stop until subscribe(). */
3419
+ #serverClosed() {
3420
+ this.#clearRetry();
3421
+ this.#joinRef = null;
3422
+ this.#wanted = false;
3423
+ this.#state = "errored";
3424
+ this.#letGoOfTrack();
3425
+ this.#releaseWaiting();
3426
+ this.#presence.leaveAll(this.#presenceEmit);
3427
+ this.#status("CHANNEL_ERROR", this.#closeError ?? channelClosed());
3428
+ }
3429
+ /** The listeners' payloads, as supabase-js's: each says which event it is (정정 (k)). */
3430
+ #presenceEmit = {
3431
+ join: (key, newPresences, currentPresences) => {
3432
+ const payload = {
3433
+ event: "join",
3434
+ key,
3435
+ newPresences,
3436
+ currentPresences
3437
+ };
3438
+ this.#presenceCall("join", payload);
3439
+ },
3440
+ leave: (key, leftPresences, currentPresences) => {
3441
+ const payload = {
3442
+ event: "leave",
3443
+ key,
3444
+ leftPresences,
3445
+ currentPresences
3446
+ };
3447
+ this.#presenceCall("leave", payload);
3448
+ },
3449
+ sync: () => this.#presenceCall("sync", void 0)
3450
+ };
3451
+ #presenceCall(event, value) {
3452
+ for (const binding of this.#bindings)
3453
+ if (binding.type === "presence" && binding.event === event)
3454
+ safely(() => binding.callback(value));
3455
+ }
3456
+ /**
3457
+ * One track frame on the join `joinRef`. Its `ok` makes `state` the one
3458
+ * tracked, with the token the hub judged it for — the one it holds once
3459
+ * the identity frames sent before it are handled (`heldToken()`, 정정 (j))
3460
+ * — unless what was tracked was let go of since `letGo` (the app's call,
3461
+ * or now): the hub had that untrack or leave after it. The app's own call
3462
+ * (`app`) is kept as well when no answer came — the connection went with
3463
+ * it, or none in time: the hub may hold it, and it is the state the app
3464
+ * last asked for, so the next join sends it rather than the one before
3465
+ * (PR #328 review). The app's call waits its turn behind the app's frames,
3466
+ * and may be refused for it (CLIENT_FLOODING, nothing kept); one sent
3467
+ * again goes in the SDK's (PR #328 re-review).
3468
+ */
3469
+ #sendTrack(state, joinRef, letGo = this.#letGo, app = false) {
3470
+ const token = this.#context.identity.heldToken();
3471
+ return this.#context.socket.request(
3472
+ this.topic,
3473
+ "presence",
3474
+ { type: "presence", event: "track", payload: state },
3475
+ { joinRef, turn: app ? "app" : "sdk" }
3476
+ ).then((reply) => {
3477
+ const kept = reply.ok || app && (reply.lost || reply.error.code === "TIMED_OUT");
3478
+ if (kept && letGo === this.#letGo) this.#tracked = { state, token };
3479
+ return reply;
3480
+ });
3481
+ }
3482
+ /**
3483
+ * After a join comes back, the last state goes again (original spec §11.1)
3484
+ * — only as the same session: the hub drops a presence when its user
3485
+ * changes (3-2a P10), and a new token here may be someone else (결정 17).
3486
+ * Who the hub reads it as is the token it will hold when the frame comes
3487
+ * (`heldToken()`): a sign-in whose token went after the join would make
3488
+ * the last user's state the new one's. And while who the connection is
3489
+ * is not settled (a store read, a retry), it is let go of (정정 (j)).
3490
+ * A refusal for now (`RETRACK_RETRY_CODES`) sends it again on this join
3491
+ * after the channel backoff, checked as above each time, until the app
3492
+ * tracks or untracks, the channel leaves, or the join goes — after one
3493
+ * the hub warned the connection for, three times at most, and then it
3494
+ * waits for the next join (`WARNED_CODES`); any other refusal lets go of
3495
+ * it; a connection lost before the answer keeps it for the next join.
3496
+ * Each send, the first too, waits its turn for room in the connection's
3497
+ * warnings (`whenRoom()`, PR #328 review).
3498
+ */
3499
+ #retrack(joinRef) {
3500
+ const tracked = this.#tracked;
3501
+ if (tracked === null) return;
3502
+ const calls = this.#presenceCalls;
3503
+ const current = () => this.#tracked === tracked && this.#joinRef === joinRef && this.#presenceCalls === calls;
3504
+ const go = (attempt2, warned) => {
3505
+ if (!current()) return;
3506
+ this.#context.socket.whenRoom(this.#retrackTurn, () => {
3507
+ send2(attempt2, warned);
3508
+ });
3509
+ };
3510
+ const send2 = (attempt2, warned) => {
3511
+ if (!current()) return;
3512
+ if (!this.#context.live() || tracked.token !== this.#context.identity.heldToken())
3513
+ return this.#letGoOfTrack();
3514
+ void this.#sendTrack(tracked.state, joinRef).then((reply) => {
3515
+ if (reply.ok || reply.lost || this.#tracked !== tracked) return;
3516
+ const { code } = reply.error;
3517
+ if (!RETRACK_RETRY_CODES.has(code)) {
3518
+ this.#tracked = null;
3519
+ return;
3520
+ }
3521
+ if (!current()) return;
3522
+ const warning = WARNED_CODES.has(code) ? 1 : 0;
3523
+ if (warned + warning > RETRACK_WARNED_RESENDS) return;
3524
+ const delay = backoffMs(attempt2, Math.random);
3525
+ this.#retrackTimer = setTimeout(() => {
3526
+ this.#retrackTimer = void 0;
3527
+ go(attempt2 + 1, warned + warning);
3528
+ }, delay);
3529
+ });
3530
+ };
3531
+ go(0, 0);
3532
+ }
3533
+ /**
3534
+ * `send` now — or, when `wait` (a track or a broadcast while the
3535
+ * connection is not live) or an earlier call of the app's waits, at
3536
+ * `#releaseWaiting()`, in the order the app made them: the hub must have
3537
+ * them in that order. Null when the join went before its turn: nothing
3538
+ * was sent.
3539
+ */
3540
+ #inTurn(joinRef, wait, send2) {
3541
+ if (!wait && this.#waiting.length === 0) return send2();
3542
+ return new Promise((resolve) => {
3543
+ this.#waiting.push(
3544
+ () => resolve(this.#joinRef === joinRef ? send2() : null)
3545
+ );
3546
+ });
3547
+ }
3548
+ /**
3549
+ * The calls that waited go now, in order and all in this turn — a call
3550
+ * made after them must not go first. Called when the connection is live
3551
+ * again, and when the join goes (they answer that it went).
3552
+ */
3553
+ #releaseWaiting() {
3554
+ const waiting = this.#waiting;
3555
+ this.#waiting = [];
3556
+ for (const go of waiting) go();
3557
+ }
3558
+ /** Nothing is tracked from now on, whatever answer comes for a track sent before. */
3559
+ #letGoOfTrack() {
3560
+ this.#tracked = null;
3561
+ this.#letGo += 1;
3562
+ this.#clearRetrack();
3563
+ }
3564
+ #clearRetrack() {
3565
+ clearTimeout(this.#retrackTimer);
3566
+ this.#retrackTimer = void 0;
3567
+ }
3568
+ /**
3569
+ * Stops until subscribe(): no join, no retry, and the app hears `error`.
3570
+ * No presence_state comes until then, so what the app saw goes, as for a
3571
+ * join refused or the server's close (정정 (k)); what was tracked stays,
3572
+ * for a join of the same session after subscribe().
3573
+ */
3574
+ #stop(error) {
3575
+ this.#clearRetry();
3576
+ this.#joinRef = null;
3577
+ this.#wanted = false;
3578
+ this.#state = "errored";
3579
+ this.#releaseWaiting();
3580
+ this.#presence.leaveAll(this.#presenceEmit);
3581
+ this.#toldStop(error);
3582
+ }
3583
+ /**
3584
+ * CHANNEL_ERROR for a stop. Inside subscribe() it comes a microtask later,
3585
+ * as for an invalid name: in `const ch = naro.channel(…).subscribe(cb)`,
3586
+ * `cb` may read `ch`, which is not set until subscribe() returns.
3587
+ */
3588
+ #toldStop(error) {
3589
+ if (this.#subscribing)
3590
+ queueMicrotask(() => this.#status("CHANNEL_ERROR", error));
3591
+ else this.#status("CHANNEL_ERROR", error);
3592
+ }
3593
+ #tell(status, error) {
3594
+ if (this.#told) return;
3595
+ this.#told = true;
3596
+ this.#status(status, error);
3597
+ }
3598
+ #status(status, error) {
3599
+ const callback = this.#onStatus;
3600
+ if (callback !== null) safely(() => callback(status, error));
3601
+ }
3602
+ /** No join retry waits — nor a track sent again on a join that is going. */
3603
+ #clearRetry() {
3604
+ clearTimeout(this.#retry);
3605
+ this.#retry = void 0;
3606
+ this.#clearRetrack();
3607
+ }
3608
+ };
3609
+
3610
+ // src/realtime/identity.ts
3611
+ var RETRY_CODES = /* @__PURE__ */ new Set([
3612
+ "TOO_MANY_JOINS",
3613
+ "TIMED_OUT",
3614
+ "INTERNAL_ERROR"
3615
+ ]);
3616
+ function rejudgeWaitMs(privateChannels) {
3617
+ return Math.max(1, Math.ceil(privateChannels / 2)) * 1e3;
3618
+ }
3619
+ var Identity = class {
3620
+ #config;
3621
+ /** The token the hub answered `ok` for on this connection; null is anon. */
3622
+ #confirmed = null;
3623
+ /**
3624
+ * The last frame sent, until its own reply; null when none is in flight.
3625
+ * Matched by the object, not the token: an older frame of the same token
3626
+ * answered meanwhile is not this one.
3627
+ */
3628
+ #inFlight = null;
3629
+ /**
3630
+ * The frame the hub answered INVALID_SESSION: the 4401 that follows is
3631
+ * about its token. An object, since that token is null when the frame was
3632
+ * a sign-out — a refusal still, and not "nothing refused".
3633
+ */
3634
+ #refused = null;
3635
+ /**
3636
+ * A frame got no answer in time (TIMED_OUT), and no `ok` came since: the
3637
+ * hub may have taken it late, so what it holds is not known, and a read
3638
+ * with no frame in flight tells the stored token even when it is
3639
+ * `heldToken()` (정정 (k)). An `ok` says what it holds — replies come in
3640
+ * the order the frames went, so it is about a later frame.
3641
+ */
3642
+ #unsure = false;
3643
+ /** Bumped when a connection ends: a reply about an earlier one changes nothing. */
3644
+ #epoch = 0;
3645
+ #attempt = 0;
3646
+ #retry;
3647
+ /** The socket's generation opened() was called for; null once it ended. */
3648
+ #connection = null;
3649
+ /** changed() calls of this connection still reading the store. */
3650
+ #reading = 0;
3651
+ /** onLive was called, and the connection has been live since. */
3652
+ #announced = false;
3653
+ constructor(config) {
3654
+ this.#config = config;
3655
+ }
3656
+ /** The token the hub took on this connection; null for anon or before its answer. */
3657
+ confirmed() {
3658
+ return this.#confirmed;
3659
+ }
3660
+ /**
3661
+ * The token the hub will hold once what was sent is handled — the last
3662
+ * frame in flight, or else the one it took: the hub handles the
3663
+ * connection's frames in order, so a frame sent now is judged for it.
3664
+ * A frame in flight may yet be refused; the hub then keeps `confirmed()`.
3665
+ */
3666
+ heldToken() {
3667
+ return this.#inFlight === null ? this.#confirmed : this.#inFlight.token;
3668
+ }
3669
+ /**
3670
+ * Nothing about who the connection is is pending — no read, no frame in
3671
+ * flight, no retry, no refusal: a join sent now is judged for the identity
3672
+ * the hub settled on, the stored one unless it refused that for good on
3673
+ * this connection (결정 24).
3674
+ */
3675
+ isLive() {
3676
+ const socket = this.#config.socket();
3677
+ return this.#connection === socket.generation && socket.isOpen() && this.#reading === 0 && this.#inFlight === null && this.#retry === void 0 && this.#refused === null;
3678
+ }
3679
+ /**
3680
+ * A new connection: anon until told. Resolves once the stored token is
3681
+ * sent, or found to need no frame; `onLive` says when the hub took it.
3682
+ */
3683
+ async opened() {
3684
+ this.closed();
3685
+ this.#connection = this.#config.socket().generation;
3686
+ await this.changed();
3687
+ }
3688
+ /** The stored token may have changed: tell the open connection what is new. */
3689
+ async changed() {
3690
+ if (this.#config.service) return this.#settle();
3691
+ const epoch = this.#epoch;
3692
+ this.#reading += 1;
3693
+ this.#announced = false;
3694
+ try {
3695
+ await this.#config.ready();
3696
+ } catch {
3697
+ }
3698
+ const token = await this.#config.tokens.get();
3699
+ if (epoch === this.#epoch) this.#reading -= 1;
3700
+ if (this.#config.socket().isOpen()) {
3701
+ const unknown = this.#unsure && this.#inFlight === null;
3702
+ if (unknown || token !== this.heldToken()) this.#send(token);
3703
+ }
3704
+ this.#settle();
3705
+ }
3706
+ /**
3707
+ * A 4401: forget the stored session only if the hub answered
3708
+ * INVALID_SESSION to it by name, and it is still stored — a sign-in since
3709
+ * then keeps its new token (결정 3). A 4401 with no such answer clears
3710
+ * nothing (plan 2026-10-28 정정): the hub keeps the expiry it read when it
3711
+ * took the token (known-issues #183) while the backend's sign-in slides
3712
+ * the session's under the same token, so a tab open for days is closed
3713
+ * with a session that is still good. The next connection tells the token
3714
+ * again, and hears INVALID_SESSION — and clears it — if it really is
3715
+ * dead. The access JWT goes with the session it was minted for, so only
3716
+ * when that session went.
3717
+ */
3718
+ async refused() {
3719
+ const epoch = this.#epoch;
3720
+ const early = this.#refused;
3721
+ await Promise.resolve();
3722
+ const refusal = this.#epoch === epoch ? this.#refused : early;
3723
+ this.closed();
3724
+ if (refusal === null || refusal.token === null) return;
3725
+ if (await this.#config.tokens.clearIf(refusal.token))
3726
+ await this.#config.accessJwt?.clear();
3727
+ }
3728
+ /** The connection is gone: what it was told is forgotten. */
3729
+ closed() {
3730
+ clearTimeout(this.#retry);
3731
+ this.#retry = void 0;
3732
+ this.#epoch += 1;
3733
+ this.#confirmed = null;
3734
+ this.#inFlight = null;
3735
+ this.#refused = null;
3736
+ this.#unsure = false;
3737
+ this.#attempt = 0;
3738
+ this.#connection = null;
3739
+ this.#reading = 0;
3740
+ this.#announced = false;
3741
+ }
3742
+ /** Live, and not said yet since it last was not: say it. */
3743
+ #settle() {
3744
+ if (this.#announced || !this.isLive()) return;
3745
+ this.#announced = true;
3746
+ safely(() => this.#config.onLive());
3747
+ }
3748
+ #send(token) {
3749
+ clearTimeout(this.#retry);
3750
+ this.#retry = void 0;
3751
+ const sent2 = { token };
3752
+ this.#inFlight = sent2;
3753
+ const epoch = this.#epoch;
3754
+ void this.#config.socket().request(
3755
+ PHOENIX_TOPIC,
3756
+ "access_token",
3757
+ { access_token: token },
3758
+ { turn: "identity" }
3759
+ ).then((reply) => {
3760
+ if (epoch === this.#epoch) this.#heard(sent2, reply);
3761
+ });
3762
+ }
3763
+ #heard(sent2, reply) {
3764
+ if (this.#inFlight === sent2) this.#inFlight = null;
3765
+ if (reply.ok) {
3766
+ this.#confirmed = sent2.token;
3767
+ this.#unsure = false;
3768
+ this.#attempt = 0;
3769
+ return this.#settle();
3770
+ }
3771
+ if (reply.lost) return;
3772
+ if (reply.error.code === "TIMED_OUT") this.#unsure = true;
3773
+ if (reply.error.code === "INVALID_SESSION") {
3774
+ this.#refused = sent2;
3775
+ return;
3776
+ }
3777
+ if (this.#inFlight !== null) return;
3778
+ if (!RETRY_CODES.has(reply.error.code)) {
3779
+ if (this.#confirmed !== null && sent2.token !== this.#confirmed) {
3780
+ this.closed();
3781
+ this.#config.socket().reconnect();
3782
+ return;
3783
+ }
3784
+ return this.#settle();
3785
+ }
3786
+ this.#later(
3787
+ reply.error.code === "TOO_MANY_JOINS" ? rejudgeWaitMs(this.#config.privateChannels()) : 0
3788
+ );
3789
+ }
3790
+ /**
3791
+ * Reads the store again after the backoff (at least `minMs`), once there
3792
+ * is room on the connection for a frame the SDK sends of its own accord
3793
+ * (`whenRoom()`, PR #328 review). `#retry` stays set until then: the
3794
+ * connection is not live while it waits.
3795
+ */
3796
+ #later(minMs) {
3797
+ clearTimeout(this.#retry);
3798
+ const delay = Math.max(minMs, backoffMs(this.#attempt, Math.random));
3799
+ this.#attempt += 1;
3800
+ const epoch = this.#epoch;
3801
+ this.#retry = setTimeout(() => {
3802
+ this.#config.socket().whenRoom(this, () => {
3803
+ if (epoch !== this.#epoch) return;
3804
+ this.#retry = void 0;
3805
+ void this.changed();
3806
+ });
3807
+ }, delay);
3808
+ }
3809
+ };
3810
+
3811
+ // src/realtime/client.ts
3812
+ var LEAVE_RETRY_CODES = /* @__PURE__ */ new Set([
3813
+ "TOO_MANY_JOINS",
3814
+ "INTERNAL_ERROR",
3815
+ "CONNECTION_STATE_TOO_LARGE",
3816
+ "TIMED_OUT"
3817
+ ]);
3818
+ function sameSettings(a, b) {
3819
+ if (a === b) return true;
3820
+ try {
3821
+ return JSON.stringify(a) === JSON.stringify(b);
3822
+ } catch {
3823
+ return false;
3824
+ }
3825
+ }
3826
+ function sameJoin(a, b) {
3827
+ return a.name === b.name && a.isPrivate === b.isPrivate;
3828
+ }
3829
+ function foldTable(table) {
3830
+ return table.replaceAll(/[A-Z]/g, (letter) => letter.toLowerCase());
3831
+ }
3832
+ var RealtimeClient = class {
3833
+ #http;
3834
+ #socket;
3835
+ #identity;
3836
+ #context;
3837
+ #channels = [];
3838
+ /** The leaves owed on the open connection (정정 (i)); forgotten with it. */
3839
+ #owed = /* @__PURE__ */ new Set();
3840
+ #onStorage = null;
3841
+ constructor(input) {
3842
+ const { http, auth, service, options } = input;
3843
+ this.#http = http;
3844
+ this.#socket = new RealtimeSocket({
3845
+ baseUrl: http.baseUrl,
3846
+ apiKey: http.apiKey,
3847
+ service,
3848
+ options,
3849
+ events: {
3850
+ // Not awaited, and not live yet: onLive says when the hub took it.
3851
+ opened: () => {
3852
+ this.#watchStorage();
3853
+ void this.#identity.opened();
3854
+ },
3855
+ frame: (frame) => this.#route(frame),
3856
+ // Only the leaves and the channels: the identity is closed by the
3857
+ // next opened() (the 4401's refused() reads it after this turn —
3858
+ // 정정 (f)).
3859
+ dropped: (error, status) => {
3860
+ this.#forgetLeaves();
3861
+ this.#each((channel) => channel.dropped(error, status));
3862
+ },
3863
+ stopped: (error) => this.#stopped(error),
3864
+ sessionRefused: () => this.#identity.refused()
3865
+ }
3866
+ });
3867
+ this.#identity = new Identity({
3868
+ tokens: http.tokens,
3869
+ accessJwt: http.accessJwt,
3870
+ service,
3871
+ socket: () => this.#socket,
3872
+ privateChannels: () => this.#channels.filter(
3873
+ (channel) => channel.isPrivate && channel.state === "joined"
3874
+ ).length,
3875
+ onLive: () => this.#each((channel) => channel.rejoin()),
3876
+ // A sign-in the page URL carries goes before any join (final review A I1).
3877
+ ready: () => auth.ready()
3878
+ });
3879
+ this.#context = {
3880
+ socket: this.#socket,
3881
+ identity: this.#identity,
3882
+ http,
3883
+ auth,
3884
+ connect: () => this.#socket.connect(),
3885
+ live: () => this.#identity.isLive(),
3886
+ leave: (channel, joinRef, tables) => this.#leave(channel, joinRef, tables),
3887
+ owesLeave: (channel) => this.#owesLeave(channel),
3888
+ owesLeaveOn: (table) => this.#owesLeaveOn(table)
3889
+ };
3890
+ http.tokens.subscribe(() => void this.#identity.changed());
3891
+ }
3892
+ channel(name, options = {}) {
3893
+ const settings = isRecord(options) ? options : {};
3894
+ const isPrivate = settings.config?.private === true;
3895
+ const found = this.#channels.find(
3896
+ (c) => c.name === name && c.isPrivate === isPrivate
3897
+ );
3898
+ if (found !== void 0) {
3899
+ if (!sameSettings(found.options, settings))
3900
+ warnInDevelopment(
3901
+ `naro-js: channel(${JSON.stringify(name)}) already exists with other settings; it keeps the first ones until removeChannel().`
3902
+ );
3903
+ return found;
3904
+ }
3905
+ const channel = new RealtimeChannel(
3906
+ name,
3907
+ settings,
3908
+ this.#context
3909
+ );
3910
+ this.#channels.push(channel);
3911
+ return channel;
3912
+ }
3913
+ getChannels() {
3914
+ return [...this.#channels];
3915
+ }
3916
+ async removeChannel(channel) {
3917
+ this.#channels = this.#channels.filter((c) => c !== channel);
3918
+ return this.#closeIfLast(await channel.unsubscribe());
3919
+ }
3920
+ async removeAllChannels() {
3921
+ const channels = this.#channels;
3922
+ this.#channels = [];
3923
+ const results = await Promise.all(
3924
+ channels.map((channel) => channel.unsubscribe())
3925
+ );
3926
+ return this.#closeIfLast({
3927
+ data: null,
3928
+ error: results.find((result) => result.error !== null)?.error ?? null
3929
+ });
3930
+ }
3931
+ /**
3932
+ * No channel left (the app may have added one meanwhile): the connection
3933
+ * closes. That is the strongest leave — the hub forgets every join with
3934
+ * its connection — so a leave's refusal no longer stands (정정 (j)).
3935
+ */
3936
+ #closeIfLast(result) {
3937
+ if (this.#channels.length > 0) return result;
3938
+ this.#disconnect();
3939
+ return { data: null, error: null };
3940
+ }
3941
+ /**
3942
+ * Each channel in turn, over a copy (a callback may add or remove one);
3943
+ * what one throws does not keep the next from hearing (결정 22).
3944
+ */
3945
+ #each(action) {
3946
+ for (const channel of Array.from(this.#channels))
3947
+ safely(() => action(channel));
3948
+ }
3949
+ #route(frame) {
3950
+ if (frame.topic === PHOENIX_TOPIC || frame.joinRef === null) return;
3951
+ const channel = this.#channels.find(
3952
+ (c) => c.joinRef === frame.joinRef && c.topic === frame.topic
3953
+ );
3954
+ channel?.receive(frame);
3955
+ }
3956
+ #stopped(error) {
3957
+ this.#identity.closed();
3958
+ this.#forgetLeaves();
3959
+ this.#unwatchStorage();
3960
+ this.#each((channel) => channel.stopped(error));
3961
+ }
3962
+ #disconnect() {
3963
+ this.#socket.disconnect();
3964
+ this.#identity.closed();
3965
+ this.#forgetLeaves();
3966
+ this.#unwatchStorage();
3967
+ }
3968
+ #leave(channel, joinRef, tables) {
3969
+ const owed = {
3970
+ channel,
3971
+ joinRef,
3972
+ tables,
3973
+ attempt: 0,
3974
+ timer: void 0
3975
+ };
3976
+ this.#owed.add(owed);
3977
+ return this.#sendLeave(owed, "app");
3978
+ }
3979
+ /**
3980
+ * One `phx_leave` of `owed`; its answer decides whether it is done,
3981
+ * whoever else awaits it. The first goes in the app's turn, after the
3982
+ * frames the app sent on the join before it (and is never refused for
3983
+ * it); one tried again, in the SDK's (PR #328 re-review).
3984
+ */
3985
+ #sendLeave(owed, turn) {
3986
+ const { channel, joinRef } = owed;
3987
+ const reply = this.#socket.request(
3988
+ channel.topic,
3989
+ "phx_leave",
3990
+ {},
3991
+ { joinRef, turn }
3992
+ );
3993
+ void reply.then((answer) => this.#leaveAnswered(owed, answer));
3994
+ return reply;
3995
+ }
3996
+ #leaveAnswered(owed, reply) {
3997
+ if (!this.#owed.has(owed)) return;
3998
+ if (!reply.ok && !reply.lost && LEAVE_RETRY_CODES.has(reply.error.code)) {
3999
+ const delay = backoffMs(owed.attempt, Math.random);
4000
+ owed.attempt += 1;
4001
+ owed.timer = setTimeout(() => {
4002
+ owed.timer = void 0;
4003
+ this.#socket.whenRoom(owed, () => {
4004
+ if (this.#owed.has(owed)) void this.#sendLeave(owed, "sdk");
4005
+ });
4006
+ }, delay);
4007
+ const { error } = reply;
4008
+ this.#each((channel) => {
4009
+ if (sameJoin(channel, owed.channel)) channel.leaveRefused(error);
4010
+ });
4011
+ return;
4012
+ }
4013
+ this.#owed.delete(owed);
4014
+ this.#each((channel) => channel.rejoin());
4015
+ }
4016
+ #owesLeave(channel) {
4017
+ for (const owed of this.#owed)
4018
+ if (sameJoin(owed.channel, channel)) return true;
4019
+ return false;
4020
+ }
4021
+ /** `ChannelContext.owesLeaveOn`: the hub's caps count the joins whose leaves are owed. */
4022
+ #owesLeaveOn(table) {
4023
+ for (const owed of this.#owed) {
4024
+ if (table === null) return true;
4025
+ const key = foldTable(table);
4026
+ if (owed.tables.some(
4027
+ (held) => held === "*" || key === "*" || foldTable(held) === key
4028
+ ))
4029
+ return true;
4030
+ }
4031
+ return false;
4032
+ }
4033
+ #forgetLeaves() {
4034
+ for (const owed of this.#owed) clearTimeout(owed.timer);
4035
+ this.#owed.clear();
4036
+ }
4037
+ /**
4038
+ * Another tab signing in or out writes the same localStorage key: read the
4039
+ * store again while the connection lives (결정 5) — from its first open to
4040
+ * a stop or the last channel's removal, drops included. Reading again
4041
+ * sends nothing unless the stored token is not what the hub has, so a
4042
+ * storage of the app's own (`auth.storage`), whose writes this event
4043
+ * never reports, costs a read at most.
4044
+ */
4045
+ #watchStorage() {
4046
+ const target = globalThis;
4047
+ if (this.#onStorage !== null || !isBrowser() || typeof target.addEventListener !== "function")
4048
+ return;
4049
+ const key = storageKey(this.#http.baseUrl);
4050
+ this.#onStorage = (event) => {
4051
+ if (event.key === key || event.key === null)
4052
+ void this.#identity.changed();
4053
+ };
4054
+ target.addEventListener("storage", this.#onStorage);
4055
+ }
4056
+ #unwatchStorage() {
4057
+ const target = globalThis;
4058
+ if (this.#onStorage === null) return;
4059
+ target.removeEventListener?.("storage", this.#onStorage);
4060
+ this.#onStorage = null;
4061
+ }
4062
+ };
4063
+
1859
4064
  // src/storage/answer.ts
1860
4065
  function fail(error) {
1861
4066
  return { data: null, error };
@@ -1968,7 +4173,7 @@ async function uploadInParts(a) {
1968
4173
  failure.error ??= uploadAborted();
1969
4174
  };
1970
4175
  const tryPart = async (n, bytes) => {
1971
- const sent = await send(a.http, {
4176
+ const sent2 = await send(a.http, {
1972
4177
  method: "PUT",
1973
4178
  path: `${base}/${String(n)}`,
1974
4179
  headers: { "content-type": "application/octet-stream", ...signed },
@@ -1976,10 +4181,10 @@ async function uploadInParts(a) {
1976
4181
  withToken,
1977
4182
  signal
1978
4183
  });
1979
- if (sent.error !== null) return sent;
1980
- const read = await readJson(sent.response, partAnswer(n));
4184
+ if (sent2.error !== null) return sent2;
4185
+ const read = await readJson(sent2.response, partAnswer(n));
1981
4186
  if (read.error !== null)
1982
- return { error: read.error, status: sent.response.status };
4187
+ return { error: read.error, status: sent2.response.status };
1983
4188
  return { etag: read.data.etag };
1984
4189
  };
1985
4190
  const sendPart = async (n) => {
@@ -2208,6 +4413,7 @@ var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (
2208
4413
  var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
2209
4414
  var isSignedEntry = (value) => isRecord(value) && typeof value.path === "string" && (value.signedUrl === null || typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX)) && (value.error === null || typeof value.error === "string");
2210
4415
  var isSignedUpload = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_UPLOAD_PREFIX) && typeof value.token === "string" && value.token !== "" && typeof value.path === "string";
4416
+ var isMoved = (value) => isRecord(value) && typeof value.message === "string";
2211
4417
  var partsBody = (body) => isArrayBuffer(body) ? new Uint8Array(body) : body;
2212
4418
  function listOf(guard) {
2213
4419
  return (value) => Array.isArray(value) && value.every(guard);
@@ -2269,7 +4475,7 @@ var StorageFileApi = class {
2269
4475
  "cache-control": cache
2270
4476
  };
2271
4477
  if (upsert) headers["x-upsert"] = "true";
2272
- const sent = await send(this.#http, {
4478
+ const sent2 = await send(this.#http, {
2273
4479
  method,
2274
4480
  path: target.path,
2275
4481
  headers,
@@ -2278,8 +4484,8 @@ var StorageFileApi = class {
2278
4484
  // The fetch stops with the body: the worker undoes its claim at once.
2279
4485
  signal
2280
4486
  });
2281
- if (sent.error !== null) return fail(sent.error);
2282
- return readJson(sent.response, isUploadResult);
4487
+ if (sent2.error !== null) return fail(sent2.error);
4488
+ return readJson(sent2.response, isUploadResult);
2283
4489
  }
2284
4490
  /**
2285
4491
  * A new object; with `upsert: true`, one of the same name is replaced.
@@ -2304,14 +4510,14 @@ var StorageFileApi = class {
2304
4510
  const target = this.#objectPath("", path);
2305
4511
  if (!target.ok) return fail(target.error);
2306
4512
  await this.#auth.ready();
2307
- const sent = await send(this.#http, {
4513
+ const sent2 = await send(this.#http, {
2308
4514
  method: "GET",
2309
4515
  path: target.path,
2310
4516
  withToken: true
2311
4517
  });
2312
- if (sent.error !== null) return fail(sent.error);
4518
+ if (sent2.error !== null) return fail(sent2.error);
2313
4519
  try {
2314
- return { data: await sent.response.blob(), error: null };
4520
+ return { data: await sent2.response.blob(), error: null };
2315
4521
  } catch (error) {
2316
4522
  return fail(networkError(error));
2317
4523
  }
@@ -2363,9 +4569,76 @@ var StorageFileApi = class {
2363
4569
  listOf(isFileObject)
2364
4570
  );
2365
4571
  }
4572
+ /**
4573
+ * The body a move and a copy send (Storage 3-1i): the bucket and both
4574
+ * names, as supabase-js sends them. The names are not checked here — they
4575
+ * are in the body, and the backend answers a bad one `INVALID_REQUEST`
4576
+ * (`details.path` "sourceKey" or "destinationKey"), as `remove` does.
4577
+ */
4578
+ #transferBody(fromPath, toPath, options) {
4579
+ const { destinationBucket, upsert } = options;
4580
+ return {
4581
+ bucketId: this.#bucket,
4582
+ sourceKey: fromPath,
4583
+ destinationKey: toPath,
4584
+ ...destinationBucket === void 0 ? {} : { destinationBucket },
4585
+ ...upsert === true ? { upsert: true } : {}
4586
+ };
4587
+ }
4588
+ /**
4589
+ * Renames an object inside its bucket (Storage 3-1i): the same object — its
4590
+ * id and owner — under `toPath`, its old name gone. It never replaces an
4591
+ * object that is there (`OBJECT_EXISTS`), takes files up to 5 GiB − 5 MiB
4592
+ * (`OBJECT_TOO_LARGE`), and needs room in the project for a second copy
4593
+ * until it is done (`STORAGE_QUOTA_EXCEEDED`). Its signed links stop
4594
+ * working at the old name: a link is bound to the name, the object's id
4595
+ * and its bytes, so moving it back can open them again. A backend whose
4596
+ * Storage was turned on before this method came answers `NOT_FOUND`:
4597
+ * update it with `naro backend enable`.
4598
+ */
4599
+ async move(fromPath, toPath, options = {}) {
4600
+ const bucket = encodeBucket(this.#bucket);
4601
+ if (!bucket.ok) return fail(bucket.error);
4602
+ const { destinationBucket } = options ?? {};
4603
+ return this.#json(
4604
+ {
4605
+ method: "POST",
4606
+ path: "/storage/object/move",
4607
+ body: this.#transferBody(
4608
+ fromPath,
4609
+ toPath,
4610
+ destinationBucket === void 0 ? {} : { destinationBucket }
4611
+ )
4612
+ },
4613
+ isMoved
4614
+ );
4615
+ }
4616
+ /**
4617
+ * Copies an object inside its bucket (Storage 3-1i): the source's bytes,
4618
+ * type and cache-control under `toPath` — a new object of the caller's,
4619
+ * or with `upsert: true` the one that is there, which keeps its owner.
4620
+ * Files up to 5 GiB − 5 MiB, within the bucket's limits as they are now.
4621
+ * It answers `{ id, path, fullPath }` — `path` is the name inside the
4622
+ * bucket; supabase-js's copy puts the bucket in front of it. A backend
4623
+ * whose Storage was turned on before this method came answers
4624
+ * `NOT_FOUND`: update it with `naro backend enable`.
4625
+ */
4626
+ async copy(fromPath, toPath, options = {}) {
4627
+ const bucket = encodeBucket(this.#bucket);
4628
+ if (!bucket.ok) return fail(bucket.error);
4629
+ return this.#json(
4630
+ {
4631
+ method: "POST",
4632
+ path: "/storage/object/copy",
4633
+ body: this.#transferBody(fromPath, toPath, options ?? {})
4634
+ },
4635
+ isUploadResult
4636
+ );
4637
+ }
2366
4638
  /**
2367
4639
  * A link to the object for `expiresIn` seconds (1–604,800). It stops
2368
- * working once the object is replaced or removed.
4640
+ * working once the object is replaced or removed, and while the object is
4641
+ * moved away from `path` (moved back, it can open it again).
2369
4642
  */
2370
4643
  async createSignedUrl(path, expiresIn, options = {}) {
2371
4644
  const target = this.#objectPath("sign/", path);
@@ -2509,7 +4782,7 @@ var StorageFileApi = class {
2509
4782
  * is the permission. A failure's cause never carries the token.
2510
4783
  */
2511
4784
  async #putSigned(path, token, payload, cache, signal) {
2512
- const sent = await send(this.#http, {
4785
+ const sent2 = await send(this.#http, {
2513
4786
  method: "PUT",
2514
4787
  path: `${path}?token=${encodeURIComponent(token)}`,
2515
4788
  headers: { "content-type": payload.type, "cache-control": cache },
@@ -2517,8 +4790,8 @@ var StorageFileApi = class {
2517
4790
  withToken: false,
2518
4791
  signal
2519
4792
  });
2520
- if (sent.error !== null) return fail(withoutToken(sent.error, token));
2521
- const read = await readJson(sent.response, isUploadResult);
4793
+ if (sent2.error !== null) return fail(withoutToken(sent2.error, token));
4794
+ const read = await readJson(sent2.response, isUploadResult);
2522
4795
  return read.error === null ? read : fail(withoutToken(read.error, token));
2523
4796
  }
2524
4797
  /**
@@ -2560,10 +4833,36 @@ var CONFIG_MESSAGES = {
2560
4833
  urlNotOrigin: "createClient needs the project url alone, like https://my-app.naro.sh: no path, no ? or #, no user:password@. Naro serves its API at /_naro/v1 on that origin.",
2561
4834
  key: "createClient needs an anonKey (or, on a server, a serviceKey).",
2562
4835
  bothKeys: "Pass createClient an anonKey or a serviceKey, not both.",
2563
- serviceKeyOffServer: "A Naro service key (naro_sk_\u2026) skips every policy, so createClient accepts it only on a server (Node, Deno, Bun, Cloudflare Workers, an edge runtime), never in a browser, a web worker or a React Native app. Use the anon key (naro_pk_\u2026) here and keep the service key on your server.",
2564
- fetch: "This runtime has no fetch. Pass one to createClient({ fetch })."
4836
+ serviceKeyOffServer: SERVICE_KEY_OFF_SERVER_MESSAGE,
4837
+ fetch: "This runtime has no fetch. Pass one to createClient({ fetch }).",
4838
+ realtime: "createClient's realtime options take a WebSocket class, and heartbeatIntervalMs and timeoutMs in whole milliseconds from 1 to 2147483647."
2565
4839
  };
2566
4840
  var SERVICE_KEY_PREFIX = "naro_sk_";
4841
+ var MAX_TIMER_MS = 2147483647;
4842
+ function isMilliseconds(value) {
4843
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_TIMER_MS;
4844
+ }
4845
+ function realtimeOptionsOf(value) {
4846
+ if (value === void 0) return {};
4847
+ if (!isRecord(value)) throw new Error(CONFIG_MESSAGES.realtime);
4848
+ const { WebSocket, heartbeatIntervalMs, timeoutMs } = value;
4849
+ const options = {};
4850
+ if (WebSocket !== void 0) {
4851
+ if (typeof WebSocket !== "function")
4852
+ throw new Error(CONFIG_MESSAGES.realtime);
4853
+ options.WebSocket = WebSocket;
4854
+ }
4855
+ if (heartbeatIntervalMs !== void 0) {
4856
+ if (!isMilliseconds(heartbeatIntervalMs))
4857
+ throw new Error(CONFIG_MESSAGES.realtime);
4858
+ options.heartbeatIntervalMs = heartbeatIntervalMs;
4859
+ }
4860
+ if (timeoutMs !== void 0) {
4861
+ if (!isMilliseconds(timeoutMs)) throw new Error(CONFIG_MESSAGES.realtime);
4862
+ options.timeoutMs = timeoutMs;
4863
+ }
4864
+ return options;
4865
+ }
2567
4866
  function baseUrlOf(url) {
2568
4867
  if (typeof url !== "string") throw new Error(CONFIG_MESSAGES.url);
2569
4868
  const trimmed = url.trim();
@@ -2620,11 +4919,19 @@ var NaroClient = class {
2620
4919
  */
2621
4920
  storage;
2622
4921
  #run;
4922
+ #newRealtime;
4923
+ /**
4924
+ * Made by the first channel(), not by the constructor: it listens to every
4925
+ * write of the session token (to tell an open connection who signed in),
4926
+ * which an app that never uses Realtime should not pay for.
4927
+ */
4928
+ #realtime;
2623
4929
  constructor(options) {
2624
4930
  const baseUrl = baseUrlOf(options.url);
2625
4931
  const { key: apiKey, service } = apiKeyOf(options);
2626
4932
  if (options.fetch === void 0 && typeof globalThis.fetch !== "function")
2627
4933
  throw new Error(CONFIG_MESSAGES.fetch);
4934
+ const realtime = realtimeOptionsOf(options.realtime);
2628
4935
  const storage = options.auth?.storage ?? defaultStorage();
2629
4936
  const http = {
2630
4937
  baseUrl,
@@ -2646,11 +4953,46 @@ var NaroClient = class {
2646
4953
  });
2647
4954
  this.#run = queryRunner(http, this.auth);
2648
4955
  this.storage = new StorageClient(http, this.auth);
4956
+ this.#newRealtime = () => new RealtimeClient({
4957
+ http,
4958
+ auth: this.auth,
4959
+ service,
4960
+ options: realtime
4961
+ });
2649
4962
  }
2650
4963
  /** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
2651
4964
  from(table) {
2652
4965
  return new TableBuilder(this.#run, table);
2653
4966
  }
4967
+ /**
4968
+ * A Realtime channel (original spec §15): `.on()` listeners, then
4969
+ * `.subscribe()`. The same name and privacy give the same channel until
4970
+ * `removeChannel`. Nothing connects until the first `subscribe()`.
4971
+ */
4972
+ channel(name, options) {
4973
+ return this.#client().channel(name, options);
4974
+ }
4975
+ /** The channels made with `channel()` and not yet removed. */
4976
+ getChannels() {
4977
+ return this.#realtime?.getChannels() ?? [];
4978
+ }
4979
+ /**
4980
+ * Leaves the channel and forgets it, so `channel()` of its name makes a new
4981
+ * one; the connection closes with the last channel.
4982
+ */
4983
+ removeChannel(channel) {
4984
+ return this.#client().removeChannel(channel);
4985
+ }
4986
+ /** `removeChannel()` for every channel; the connection closes. */
4987
+ removeAllChannels() {
4988
+ if (this.#realtime === void 0)
4989
+ return Promise.resolve({ data: null, error: null });
4990
+ return this.#realtime.removeAllChannels();
4991
+ }
4992
+ #client() {
4993
+ this.#realtime ??= this.#newRealtime();
4994
+ return this.#realtime;
4995
+ }
2654
4996
  };
2655
4997
  function createClient(options) {
2656
4998
  return new NaroClient(options);