naro-cli 0.20.0 → 0.21.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
@@ -97,7 +97,7 @@ import {
97
97
  usageMonthOf,
98
98
  userForAccessToken,
99
99
  writeProjectLink
100
- } from "./chunk-SAA66XLD.js";
100
+ } from "./chunk-FFPOVA7I.js";
101
101
  import "./chunk-K3NQKI34.js";
102
102
 
103
103
  // src/args.ts
@@ -1187,16 +1187,16 @@ var DISABLE_QUESTION = "Turn the backend off? In up to about 90 seconds your app
1187
1187
  var OFF_ADVICE = "Turned off: your app's /_naro/* requests get 404. The data, users and keys are kept \u2014 `naro backend enable` turns it back on with the same keys.";
1188
1188
  var TURNING_OFF_ADVICE = "Turning it off did not finish: your app's /_naro/* requests may still be answered. Run `naro backend disable` again to finish it \u2014 the control plane also retries every few minutes.";
1189
1189
  var TURNING_OFF_NOW_ADVICE = "Turning off: another run is removing the routes right now. Check again in a minute with `naro backend`.";
1190
- function leaseHeld(info, now) {
1190
+ function leaseHeld(info, now2) {
1191
1191
  if (info.leaseExpiresAt === null) return false;
1192
1192
  const until = Date.parse(info.leaseExpiresAt);
1193
- return !Number.isNaN(until) && until > now;
1193
+ return !Number.isNaN(until) && until > now2;
1194
1194
  }
1195
1195
  function sentence(text) {
1196
1196
  const line = printable(text).trim();
1197
1197
  return /[.!?…]$/.test(line) ? line : `${line}.`;
1198
1198
  }
1199
- function statusText(info, now) {
1199
+ function statusText(info, now2) {
1200
1200
  switch (info.status) {
1201
1201
  case "none":
1202
1202
  return "not provisioned";
@@ -1204,7 +1204,7 @@ function statusText(info, now) {
1204
1204
  return "ready";
1205
1205
  default: {
1206
1206
  const text = info.step ? `${info.status} (step: ${info.step})` : info.status;
1207
- return isStalledBackend(info, now) ? `${text}, stalled` : text;
1207
+ return isStalledBackend(info, now2) ? `${text}, stalled` : text;
1208
1208
  }
1209
1209
  }
1210
1210
  }
@@ -1214,10 +1214,10 @@ function switchText(info) {
1214
1214
  return info.disabledAt ? `turning off (since ${printable(info.disabledAt)})` : "turning off";
1215
1215
  return info.disabledAt ? `off since ${printable(info.disabledAt)}` : "off";
1216
1216
  }
1217
- function backendLines(info, now = Date.now()) {
1217
+ function backendLines(info, now2 = Date.now()) {
1218
1218
  const lines = [
1219
1219
  `Backend: ${switchText(info)}`,
1220
- `Status: ${statusText(info, now)}`,
1220
+ `Status: ${statusText(info, now2)}`,
1221
1221
  `URL: ${info.url}`
1222
1222
  ];
1223
1223
  if (info.anonKey) lines.push(`Anon key: ${info.anonKey}`);
@@ -1227,10 +1227,10 @@ function backendLines(info, now = Date.now()) {
1227
1227
  );
1228
1228
  return lines;
1229
1229
  }
1230
- function adviceLines(info, now) {
1230
+ function adviceLines(info, now2) {
1231
1231
  if (!info.enabled && info.status !== "none") {
1232
1232
  if (!info.turningOff) return [OFF_ADVICE];
1233
- return [leaseHeld(info, now) ? TURNING_OFF_NOW_ADVICE : TURNING_OFF_ADVICE];
1233
+ return [leaseHeld(info, now2) ? TURNING_OFF_NOW_ADVICE : TURNING_OFF_ADVICE];
1234
1234
  }
1235
1235
  switch (info.status) {
1236
1236
  case "none":
@@ -1241,7 +1241,7 @@ function adviceLines(info, now) {
1241
1241
  "Run `naro backend enable` again \u2014 it picks up where it stopped."
1242
1242
  ];
1243
1243
  case "provisioning":
1244
- return isStalledBackend(info, now) ? [
1244
+ return isStalledBackend(info, now2) ? [
1245
1245
  "No run is working on it: the last one stopped part-way. Run `naro backend enable` to pick it up."
1246
1246
  ] : [
1247
1247
  "Provisioning is running. Check again in a minute with `naro backend`."
@@ -1258,10 +1258,10 @@ async function runBackendInfo(context) {
1258
1258
  const client = await context.client();
1259
1259
  const projectId = await context.resolveProjectId();
1260
1260
  const backend = await client.backend.info(projectId);
1261
- const now = Date.now();
1261
+ const now2 = Date.now();
1262
1262
  context.output.result(
1263
- { projectId, backend, stalled: isStalledBackend(backend, now) },
1264
- [...backendLines(backend, now), ...adviceLines(backend, now)]
1263
+ { projectId, backend, stalled: isStalledBackend(backend, now2) },
1264
+ [...backendLines(backend, now2), ...adviceLines(backend, now2)]
1265
1265
  );
1266
1266
  }
1267
1267
  async function runBackendEnable(context) {
@@ -3186,10 +3186,10 @@ function truth(text) {
3186
3186
  var DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
3187
3187
  function exactNumber(text) {
3188
3188
  if (!DECIMAL.test(text)) return text;
3189
- const number = Number(text);
3190
- if (!text.includes(".")) return Number.isSafeInteger(number) ? number : text;
3189
+ const number2 = Number(text);
3190
+ if (!text.includes(".")) return Number.isSafeInteger(number2) ? number2 : text;
3191
3191
  const trimmed = text.replace(/\.?0+$/, "");
3192
- return String(number) === trimmed ? number : text;
3192
+ return String(number2) === trimmed ? number2 : text;
3193
3193
  }
3194
3194
  function quoteFilterValue(value) {
3195
3195
  const text = String(value);
@@ -3893,7 +3893,7 @@ async function runSchemaChange(context, operations, question) {
3893
3893
  const dryRun = context.flags.dryRun === true;
3894
3894
  if (question !== void 0 && !dryRun)
3895
3895
  await confirmOrAbort(context, question);
3896
- const sent = dryRun ? operations.map((operation) => {
3896
+ const sent2 = dryRun ? operations.map((operation) => {
3897
3897
  if (operation.op !== "dropTable" && operation.op !== "dropColumn")
3898
3898
  return operation;
3899
3899
  const { confirm: _confirm, ...rest } = operation;
@@ -3911,7 +3911,7 @@ async function runSchemaChange(context, operations, question) {
3911
3911
  [...local, ...ledger.map((row) => row.name)],
3912
3912
  schemaMigrationTitle(operations[0] ?? {})
3913
3913
  );
3914
- const result = await client.db.schema(projectId, sent, {
3914
+ const result = await client.db.schema(projectId, sent2, {
3915
3915
  ...dryRun ? { dryRun: true } : {},
3916
3916
  name
3917
3917
  });
@@ -5722,6 +5722,111 @@ async function runProtectionBypassRemove(context) {
5722
5722
  context.output.result({ projectId, ...state }, ["Bypass secret removed."]);
5723
5723
  }
5724
5724
 
5725
+ // src/commands/realtime.ts
5726
+ var ADD_USAGE = "Usage: naro realtime tables add <table>\u2026";
5727
+ var REMOVE_USAGE2 = "Usage: naro realtime tables remove <table>\u2026 [--yes]";
5728
+ var DATA_API_NOTE = "Changes made through the Data API are published; writes your app makes with env.DB are not.";
5729
+ var number = (value) => value.toLocaleString("en-US");
5730
+ function tableName(table3) {
5731
+ const name = printable(table3.name);
5732
+ return table3.exists ? name : `${name} (dropped)`;
5733
+ }
5734
+ function realtimeLines(info) {
5735
+ if (!info.enabled)
5736
+ return [
5737
+ "Realtime: not enabled",
5738
+ "Run `naro realtime enable` to turn it on (the project's backend must be on first)."
5739
+ ];
5740
+ const { live: live2 } = info;
5741
+ return [
5742
+ "Realtime: on (public channels: off)",
5743
+ info.tables.length === 0 ? "Published tables: none \u2014 naro realtime tables add <table>" : `Published tables: ${info.tables.map(tableName).join(", ")}`,
5744
+ live2 === null ? "Connections now: not known \u2014 the backend is off or not ready, or the hub did not answer" : `Connections now: ${number(live2.connections)} \xB7 channels ${number(live2.channels)} \xB7 presences ${number(live2.presences)}`
5745
+ ];
5746
+ }
5747
+ async function enableRealtime(client, projectId) {
5748
+ try {
5749
+ return await client.realtime.enable(projectId);
5750
+ } catch (error) {
5751
+ if (isNaroError(error) && error.code === "TIMEOUT")
5752
+ throw new NaroError(
5753
+ "TIMEOUT",
5754
+ "Turning Realtime on did not answer within 2 minutes. `naro realtime` shows where it got to, and `naro realtime enable` again picks up where it stopped.",
5755
+ { cause: error }
5756
+ );
5757
+ throw error;
5758
+ }
5759
+ }
5760
+ function tableNames2(context, usage) {
5761
+ if (context.positionals.length === 0)
5762
+ throw new NaroError("VALIDATION_FAILED", usage);
5763
+ return context.positionals;
5764
+ }
5765
+ async function runRealtimeStatus(context) {
5766
+ const client = await context.client();
5767
+ const projectId = await context.resolveProjectId();
5768
+ const realtime = await client.realtime.info(projectId);
5769
+ context.output.result({ projectId, realtime }, realtimeLines(realtime));
5770
+ }
5771
+ async function runRealtimeEnable(context) {
5772
+ const client = await context.client();
5773
+ const projectId = await context.resolveProjectId();
5774
+ const realtime = await enableRealtime(client, projectId);
5775
+ context.output.result({ projectId, realtime }, [
5776
+ "\u2705 Realtime is on. Publish a table with naro realtime tables add <table>.",
5777
+ ...realtimeLines(realtime)
5778
+ ]);
5779
+ }
5780
+ async function runRealtimeTablesList(context) {
5781
+ const client = await context.client();
5782
+ const projectId = await context.resolveProjectId();
5783
+ const tables = await client.realtime.listTables(projectId);
5784
+ context.output.result(
5785
+ { projectId, tables },
5786
+ tables.length === 0 ? [
5787
+ "No published tables. `naro realtime tables add <table>` publishes one."
5788
+ ] : tables.map(
5789
+ (table3) => table3.exists ? ` ${printable(table3.name)}` : ` ${printable(table3.name)} (dropped: no such table now)`
5790
+ )
5791
+ );
5792
+ }
5793
+ async function runRealtimeTablesAdd(context) {
5794
+ const names = tableNames2(context, ADD_USAGE);
5795
+ const client = await context.client();
5796
+ const projectId = await context.resolveProjectId();
5797
+ const tables = [];
5798
+ for (const name of names) {
5799
+ const published = await client.realtime.publishTable(projectId, name);
5800
+ tables.push(published);
5801
+ context.output.log(
5802
+ `\u2705 ${printable(published.table)} now publishes its changes (within about 10 seconds).`
5803
+ );
5804
+ for (const warning of published.warnings)
5805
+ if (warning !== DATA_API_NOTE)
5806
+ context.output.log(`\u26A0 ${printable(warning)}`);
5807
+ }
5808
+ context.output.result({ projectId, tables }, [`\u26A0 ${DATA_API_NOTE}`]);
5809
+ }
5810
+ async function runRealtimeTablesRemove(context) {
5811
+ const names = tableNames2(context, REMOVE_USAGE2);
5812
+ const shown = names.map(printable).join(", ");
5813
+ await confirmOrAbort(
5814
+ context,
5815
+ names.length === 1 ? `Stop publishing ${shown}? Subscribers stop receiving its changes within about 10 seconds.` : `Stop publishing ${shown}? Subscribers stop receiving their changes within about 10 seconds.`
5816
+ );
5817
+ const client = await context.client();
5818
+ const projectId = await context.resolveProjectId();
5819
+ const tables = [];
5820
+ for (const name of names) {
5821
+ const unpublished = await client.realtime.unpublishTable(projectId, name);
5822
+ tables.push(unpublished);
5823
+ context.output.log(
5824
+ `\u2705 ${printable(unpublished.table)} no longer publishes its changes.`
5825
+ );
5826
+ }
5827
+ context.output.result({ projectId, tables });
5828
+ }
5829
+
5725
5830
  // src/commands/storage-units.ts
5726
5831
  var SIZE_MESSAGE = "--file-size-limit takes a whole number of bytes, or a size like 5MB (1000s) or 5MiB (1024s), or none.";
5727
5832
  var UNITS = {
@@ -5760,7 +5865,7 @@ function count(n, noun) {
5760
5865
  var CREATE_USAGE3 = "Usage: naro storage bucket create <name> [--public] [--file-size-limit <size>] [--allowed-mime-types <t,\u2026>]";
5761
5866
  var UPDATE_USAGE = "Usage: naro storage bucket update <name> [--public | --private] [--file-size-limit <size> | none] [--allowed-mime-types <t,\u2026> | any]";
5762
5867
  var EMPTY_USAGE = "Usage: naro storage bucket empty <name> [--yes]";
5763
- var REMOVE_USAGE2 = "Usage: naro storage bucket remove <name> [--empty] [--yes]";
5868
+ var REMOVE_USAGE3 = "Usage: naro storage bucket remove <name> [--empty] [--yes]";
5764
5869
  var ENABLE_FIRST = "Storage is not enabled yet; enabling it first.";
5765
5870
  var DISABLE_QUESTION3 = "Turn Storage off for this project? Its Storage calls and file URLs answer 404 until naro storage enable; buckets and files are kept.";
5766
5871
  var NO_BUCKETS = "No buckets yet. `naro storage bucket create <name>` makes one.";
@@ -6028,11 +6133,11 @@ async function runStorageBucketEmpty(context) {
6028
6133
  ]);
6029
6134
  }
6030
6135
  async function runStorageBucketRemove(context) {
6031
- const name = bucketName(context, REMOVE_USAGE2);
6136
+ const name = bucketName(context, REMOVE_USAGE3);
6032
6137
  const empty = context.flags.empty === true;
6033
6138
  refuseSearch(
6034
6139
  context,
6035
- empty ? `bucket remove --empty deletes every object in the bucket before it removes the bucket. ${REMOVE_USAGE2}` : `bucket remove removes the whole bucket. ${REMOVE_USAGE2}`
6140
+ empty ? `bucket remove --empty deletes every object in the bucket before it removes the bucket. ${REMOVE_USAGE3}` : `bucket remove removes the whole bucket. ${REMOVE_USAGE3}`
6036
6141
  );
6037
6142
  const client = await context.client();
6038
6143
  const projectId = await context.resolveProjectId();
@@ -6132,6 +6237,105 @@ function cryptoUnavailable() {
6132
6237
  message: "This runtime has no crypto.subtle, which signing in with a provider needs (https or localhost)."
6133
6238
  };
6134
6239
  }
6240
+ 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.";
6241
+ function websocketUnavailable() {
6242
+ return {
6243
+ code: "WEBSOCKET_UNAVAILABLE",
6244
+ message: "This runtime has no WebSocket. Pass one to createClient({ realtime: { WebSocket } })."
6245
+ };
6246
+ }
6247
+ function realtimeTimedOut(ms) {
6248
+ const seconds = Math.max(1, Math.round(ms / 1e3));
6249
+ return {
6250
+ code: "TIMED_OUT",
6251
+ message: `The Naro realtime server did not answer within ${String(seconds)} seconds.`
6252
+ };
6253
+ }
6254
+ function realtimeClosed(code) {
6255
+ return {
6256
+ code: "NETWORK_ERROR",
6257
+ message: `The realtime connection to the Naro backend closed (${String(code)}). It reconnects on its own.`,
6258
+ details: { code }
6259
+ };
6260
+ }
6261
+ function realtimeDisconnected() {
6262
+ return {
6263
+ code: "ABORTED",
6264
+ message: "The realtime connection was closed by the app (it removed its last channel) before the Naro realtime server answered."
6265
+ };
6266
+ }
6267
+ function realtimeStopped(code, reason) {
6268
+ return {
6269
+ code: reason === "" ? "NETWORK_ERROR" : reason,
6270
+ message: `The realtime connection to the Naro backend closed (${String(code)}) and does not reconnect.`,
6271
+ details: { code }
6272
+ };
6273
+ }
6274
+ function socketRefused(error) {
6275
+ return {
6276
+ code: "NETWORK_ERROR",
6277
+ message: "The realtime connection to the Naro backend could not be opened.",
6278
+ details: { cause: error instanceof Error ? error.name : typeof error }
6279
+ };
6280
+ }
6281
+ function invalidChannel(reason) {
6282
+ return {
6283
+ code: "INVALID_CHANNEL",
6284
+ message: `The channel name is not valid: it ${reason}.`
6285
+ };
6286
+ }
6287
+ function invalidChannelConfig(path, reason) {
6288
+ return {
6289
+ code: "INVALID_CHANNEL_CONFIG",
6290
+ message: "The channel settings are not valid.",
6291
+ details: { path, reason }
6292
+ };
6293
+ }
6294
+ function serviceKeyOffServer() {
6295
+ return {
6296
+ code: "SERVICE_KEY_OFF_SERVER",
6297
+ message: SERVICE_KEY_OFF_SERVER_MESSAGE
6298
+ };
6299
+ }
6300
+ function notJoined(channel) {
6301
+ return {
6302
+ code: "NOT_JOINED",
6303
+ message: `Join ${channel} before sending to it.`
6304
+ };
6305
+ }
6306
+ function invalidBroadcast() {
6307
+ return {
6308
+ code: "INVALID_MESSAGE",
6309
+ message: "The message is not a valid Naro realtime frame.",
6310
+ details: { reason: "needs type broadcast" }
6311
+ };
6312
+ }
6313
+ function invalidPresence() {
6314
+ return {
6315
+ code: "INVALID_MESSAGE",
6316
+ message: "The message is not a valid Naro realtime frame.",
6317
+ details: { reason: "needs a presence payload object" }
6318
+ };
6319
+ }
6320
+ function frameTooLarge(limit) {
6321
+ return {
6322
+ code: "INVALID_MESSAGE",
6323
+ message: "The message is not a valid Naro realtime frame.",
6324
+ details: { reason: `is larger than ${String(limit)} bytes` }
6325
+ };
6326
+ }
6327
+ function clientFlooding(perSecond) {
6328
+ return {
6329
+ code: "CLIENT_FLOODING",
6330
+ message: `This connection is sending messages faster than a connection may (${String(perSecond)} a second), so this one was not sent. Send less often.`
6331
+ };
6332
+ }
6333
+ function channelClosed() {
6334
+ return {
6335
+ code: "CHANNEL_CLOSED",
6336
+ message: "The Naro realtime server closed this channel."
6337
+ };
6338
+ }
6135
6339
  function describe(cause) {
6136
6340
  if (cause instanceof Error) return `${cause.name}: ${cause.message}`;
6137
6341
  return String(cause);
@@ -6154,9 +6358,9 @@ function errorFromBody(status, body) {
6154
6358
  if (status >= 200 && status < 300) return null;
6155
6359
  return invalidResponse(status);
6156
6360
  }
6157
- var AUTH_PREFIX = "/auth/";
6361
+ var SESSION_PREFIXES = ["/auth/", "/realtime/"];
6158
6362
  function storeFor(config, path) {
6159
- return path.startsWith(AUTH_PREFIX) ? config.tokens : config.accessJwt ?? null;
6363
+ return SESSION_PREFIXES.some((prefix) => path.startsWith(prefix)) ? config.tokens : config.accessJwt ?? null;
6160
6364
  }
6161
6365
  async function receive(config, options, init) {
6162
6366
  const controller = options.timeoutMs !== void 0 && typeof AbortController === "function" ? new AbortController() : null;
@@ -6308,7 +6512,9 @@ var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
6308
6512
  // Storage (original spec §13): one object written twice within a second
6309
6513
  // (`x-retry-after: 1`), and the daily operation limit (3-1d).
6310
6514
  "OBJECT_RATE_LIMITED",
6311
- "STORAGE_OPS_QUOTA_EXCEEDED"
6515
+ "STORAGE_OPS_QUOTA_EXCEEDED",
6516
+ // Realtime's REST broadcast over the project's message budget (x-retry-after: 1).
6517
+ "TOO_MANY_MESSAGES"
6312
6518
  ]);
6313
6519
  function withRetryAfter(error, headers) {
6314
6520
  const header = headers.get("x-retry-after")?.trim() ?? "";
@@ -6887,7 +7093,7 @@ var MAX_FLOWS = 10;
6887
7093
  function isEntry(value) {
6888
7094
  return typeof value === "object" && value !== null && typeof value.verifier === "string" && typeof value.expiresAt === "number";
6889
7095
  }
6890
- function live(text, now) {
7096
+ function live(text, now2) {
6891
7097
  if (text === null) return {};
6892
7098
  let parsed;
6893
7099
  try {
@@ -6898,7 +7104,7 @@ function live(text, now) {
6898
7104
  if (typeof parsed !== "object" || parsed === null) return {};
6899
7105
  const entries = {};
6900
7106
  for (const [flow, entry] of Object.entries(parsed))
6901
- if (isEntry(entry) && entry.expiresAt > now) entries[flow] = entry;
7107
+ if (isEntry(entry) && entry.expiresAt > now2) entries[flow] = entry;
6902
7108
  return entries;
6903
7109
  }
6904
7110
  function pick(entries, oldest) {
@@ -6914,16 +7120,16 @@ function serialize(entries) {
6914
7120
  var FlowVerifiers = class {
6915
7121
  #store;
6916
7122
  #now;
6917
- constructor(store, now = Date.now) {
7123
+ constructor(store, now2 = Date.now) {
6918
7124
  this.#store = store;
6919
- this.#now = now;
7125
+ this.#now = now2;
6920
7126
  }
6921
7127
  /** Keeps `verifier` for the flow `flow`, for ten minutes. */
6922
7128
  async add(flow, verifier) {
6923
- const now = this.#now();
7129
+ const now2 = this.#now();
6924
7130
  await this.#store.update((text) => {
6925
- const entries = live(text, now);
6926
- entries[flow] = { verifier, expiresAt: now + FLOW_TTL_MS };
7131
+ const entries = live(text, now2);
7132
+ entries[flow] = { verifier, expiresAt: now2 + FLOW_TTL_MS };
6927
7133
  while (Object.keys(entries).length > MAX_FLOWS) {
6928
7134
  const oldest = pick(entries, true);
6929
7135
  if (oldest === void 0) break;
@@ -6938,10 +7144,10 @@ var FlowVerifiers = class {
6938
7144
  * expired.
6939
7145
  */
6940
7146
  async take(flow) {
6941
- const now = this.#now();
7147
+ const now2 = this.#now();
6942
7148
  let taken = null;
6943
7149
  await this.#store.update((text) => {
6944
- const entries = live(text, now);
7150
+ const entries = live(text, now2);
6945
7151
  const id = flow ?? pick(entries, false);
6946
7152
  if (id !== void 0 && Object.hasOwn(entries, id)) {
6947
7153
  taken = entries[id]?.verifier ?? null;
@@ -6966,9 +7172,9 @@ function parse(text) {
6966
7172
  var RecoveryToken = class {
6967
7173
  #store;
6968
7174
  #now;
6969
- constructor(store, now = () => Date.now()) {
7175
+ constructor(store, now2 = () => Date.now()) {
6970
7176
  this.#store = store;
6971
- this.#now = now;
7177
+ this.#now = now2;
6972
7178
  }
6973
7179
  /** Keeps the token a recovery link brought, for 15 minutes. */
6974
7180
  async keep(token) {
@@ -6977,11 +7183,11 @@ var RecoveryToken = class {
6977
7183
  }
6978
7184
  /** The token, while it lasts; an expired or unreadable one is cleared. */
6979
7185
  async current() {
6980
- const now = this.#now();
7186
+ const now2 = this.#now();
6981
7187
  let token = null;
6982
7188
  await this.#store.update((text) => {
6983
7189
  const kept = parse(text);
6984
- if (kept === null || kept.expiresAt <= now) return null;
7190
+ if (kept === null || kept.expiresAt <= now2) return null;
6985
7191
  token = kept.token;
6986
7192
  return text;
6987
7193
  });
@@ -7041,6 +7247,8 @@ var TokenStore = class {
7041
7247
  #fallback = null;
7042
7248
  /** Settles when the last queued operation has finished. */
7043
7249
  #queue = Promise.resolve();
7250
+ /** Called after each write; see `subscribe`. */
7251
+ #listeners = /* @__PURE__ */ new Set();
7044
7252
  constructor(storage, key) {
7045
7253
  this.#storage = storage;
7046
7254
  this.#key = key;
@@ -7049,21 +7257,31 @@ var TokenStore = class {
7049
7257
  return this.#serial(() => this.#get());
7050
7258
  }
7051
7259
  set(token) {
7052
- return this.#serial(() => this.#set(token));
7260
+ return this.#serial(async () => {
7261
+ await this.#set(token);
7262
+ this.#changed();
7263
+ });
7053
7264
  }
7054
7265
  clear() {
7055
- return this.#serial(() => this.#clear());
7266
+ return this.#serial(async () => {
7267
+ await this.#clear();
7268
+ this.#changed();
7269
+ });
7056
7270
  }
7057
7271
  /**
7058
- * Clears only if the stored token is still the one that was rejected. A
7059
- * request that left with an old token can come back after the user signed
7060
- * in again; its INVALID_SESSION must not sign the new session out. The
7061
- * read and the removal are one operation: with an async storage, a sign-in
7062
- * that lands between them would otherwise be the token removed.
7272
+ * Clears only if the stored token is still the one that was rejected, and
7273
+ * says whether it did. A request that left with an old token can come back
7274
+ * after the user signed in again; its INVALID_SESSION must not sign the
7275
+ * new session out. The read and the removal are one operation: with an
7276
+ * async storage, a sign-in that lands between them would otherwise be the
7277
+ * token removed.
7063
7278
  */
7064
7279
  clearIf(token) {
7065
7280
  return this.#serial(async () => {
7066
- if (await this.#get() === token) await this.#clear();
7281
+ if (await this.#get() !== token) return false;
7282
+ await this.#clear();
7283
+ this.#changed();
7284
+ return true;
7067
7285
  });
7068
7286
  }
7069
7287
  /**
@@ -7076,17 +7294,42 @@ var TokenStore = class {
7076
7294
  const next = transform(await this.#get());
7077
7295
  if (next === null) await this.#clear();
7078
7296
  else await this.#set(next);
7297
+ this.#changed();
7079
7298
  });
7080
7299
  }
7081
7300
  /** Stores `next` only if `expected` is still stored, as one operation. */
7082
7301
  replaceIf(expected, next) {
7083
7302
  return this.#serial(async () => {
7084
- if (await this.#get() === expected) await this.#set(next);
7303
+ if (await this.#get() !== expected) return;
7304
+ await this.#set(next);
7305
+ this.#changed();
7085
7306
  });
7086
7307
  }
7308
+ /**
7309
+ * Calls `listener` after every write of the token — a sign-in, a
7310
+ * sign-out, a renewed session, a cleared INVALID_SESSION — from inside the
7311
+ * write's turn, so a `get()` it starts reads the value just written — or
7312
+ * that of a write already queued behind it, which calls the listener
7313
+ * again. The Realtime connection (Realtime 3-2d) uses it to tell the hub.
7314
+ * What a listener throws is its own: the write has happened and stands.
7315
+ */
7316
+ subscribe(listener) {
7317
+ this.#listeners.add(listener);
7318
+ return () => {
7319
+ this.#listeners.delete(listener);
7320
+ };
7321
+ }
7322
+ #changed() {
7323
+ for (const listener of Array.from(this.#listeners)) {
7324
+ try {
7325
+ listener();
7326
+ } catch {
7327
+ }
7328
+ }
7329
+ }
7087
7330
  /**
7088
7331
  * Runs `operation` once every earlier one has finished. The operations
7089
- * below never reject, and none of them calls a public method (that would
7332
+ * below never reject, and none of them awaits a public method (that would
7090
7333
  * wait on itself).
7091
7334
  */
7092
7335
  #serial(operation) {
@@ -7487,10 +7730,10 @@ function truth2(text) {
7487
7730
  var DECIMAL3 = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
7488
7731
  function exactNumber2(text) {
7489
7732
  if (!DECIMAL3.test(text)) return text;
7490
- const number = Number(text);
7491
- if (!text.includes(".")) return Number.isSafeInteger(number) ? number : text;
7733
+ const number2 = Number(text);
7734
+ if (!text.includes(".")) return Number.isSafeInteger(number2) ? number2 : text;
7492
7735
  const trimmed = text.replace(/\.?0+$/, "");
7493
- return String(number) === trimmed ? number : text;
7736
+ return String(number2) === trimmed ? number2 : text;
7494
7737
  }
7495
7738
  function keyword2(text, at) {
7496
7739
  const word = text.toLowerCase();
@@ -7892,108 +8135,2161 @@ var TableBuilder = class {
7892
8135
  });
7893
8136
  }
7894
8137
  };
7895
- function fail(error) {
7896
- return { data: null, error };
8138
+ var BACKOFF_STEPS_MS = [1e3, 2e3, 5e3, 1e4];
8139
+ var RATE_LIMITED_MIN_MS = 5e3;
8140
+ var IP_LIMIT_WAIT_MS = 6e4;
8141
+ var RESTART_JITTER_MS = 1e4;
8142
+ var UNOPENED_SLOWDOWN_AFTER = 6;
8143
+ var UNOPENED_DELAY_MS = 6e4;
8144
+ var STOP_CODES = /* @__PURE__ */ new Set([
8145
+ 1e3,
8146
+ 4e3,
8147
+ 4001,
8148
+ 4003,
8149
+ 4004,
8150
+ 4410
8151
+ ]);
8152
+ function closeAction(code, reason) {
8153
+ if (STOP_CODES.has(code)) return { kind: "stop" };
8154
+ if (code === 4401) return { kind: "session" };
8155
+ if (code === 4503) return { kind: "restart" };
8156
+ if (code === 4029)
8157
+ return reason === "IP_RATE_LIMITED" ? { kind: "wait", ms: IP_LIMIT_WAIT_MS } : { kind: "retry", minMs: RATE_LIMITED_MIN_MS };
8158
+ return { kind: "retry", minMs: 0 };
8159
+ }
8160
+ function backoffMs(attempt22, random) {
8161
+ const step = BACKOFF_STEPS_MS[Math.min(attempt22, BACKOFF_STEPS_MS.length - 1)] ?? 1e4;
8162
+ return Math.round(step * (0.5 + random()));
8163
+ }
8164
+ function delayAfter(action, state, random) {
8165
+ if (action.kind === "restart")
8166
+ return Math.floor(random() * RESTART_JITTER_MS);
8167
+ if (action.kind === "wait")
8168
+ return action.ms + Math.floor(random() * RESTART_JITTER_MS);
8169
+ const base = state.unopened >= UNOPENED_SLOWDOWN_AFTER ? Math.round(UNOPENED_DELAY_MS * (0.5 + random())) : backoffMs(state.attempt, random);
8170
+ return Math.max(action.kind === "retry" ? action.minMs : 0, base);
8171
+ }
8172
+ function entryOf(meta) {
8173
+ if (!isRecord(meta) || typeof meta.phx_ref !== "string") return null;
8174
+ const { phx_ref: ref, naro_user_id: user, ...state } = meta;
8175
+ return {
8176
+ ...state,
8177
+ presence_ref: ref,
8178
+ naro_user_id: typeof user === "string" ? user : null
8179
+ };
7897
8180
  }
7898
- var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
7899
- function dataOf2(status, body, guard) {
7900
- if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
7901
- return { data: body.data, error: null };
7902
- return fail(invalidResponse(status));
8181
+ function entriesOf(value) {
8182
+ const read = /* @__PURE__ */ new Map();
8183
+ if (!isRecord(value)) return read;
8184
+ for (const [key, item] of Object.entries(value)) {
8185
+ if (!isRecord(item) || !Array.isArray(item.metas)) continue;
8186
+ const entries = item.metas.map(entryOf).filter((entry) => entry !== null);
8187
+ if (entries.length > 0) read.set(key, entries);
8188
+ }
8189
+ return read;
7903
8190
  }
7904
- async function readJson(response, guard) {
7905
- let text;
7906
- try {
7907
- text = await response.text();
7908
- } catch (error) {
7909
- return fail(networkError(error));
8191
+ function without(entries, gone) {
8192
+ return entries.filter(
8193
+ (entry) => !gone.some((left) => left.presence_ref === entry.presence_ref)
8194
+ );
8195
+ }
8196
+ function copies(entries) {
8197
+ return entries.map((entry) => ({ ...entry }));
8198
+ }
8199
+ var Presence = class {
8200
+ #state = /* @__PURE__ */ new Map();
8201
+ /** A presence_state came for the current join: diffs apply only after it. */
8202
+ #synced = false;
8203
+ state() {
8204
+ return Object.fromEntries(
8205
+ [...this.#state].map(([key, entries]) => [key, copies(entries)])
8206
+ );
7910
8207
  }
7911
- let body;
8208
+ /** A new join, or the connection dropped: what is here stays until its presence_state. */
8209
+ unsync() {
8210
+ this.#synced = false;
8211
+ }
8212
+ clear() {
8213
+ this.#state = /* @__PURE__ */ new Map();
8214
+ this.#synced = false;
8215
+ }
8216
+ /**
8217
+ * No presence_state will say what is here any more (a join that may not
8218
+ * read presence, one refused, the server's close, a stop — plan 정정
8219
+ * (k)): each key leaves, then sync — said only when someone was here. A
8220
+ * diff waits for the next presence_state.
8221
+ */
8222
+ leaveAll(emit) {
8223
+ if (this.#state.size > 0) this.applyState({}, emit);
8224
+ this.#synced = false;
8225
+ }
8226
+ applyState(payload, emit) {
8227
+ const before = this.#state;
8228
+ const next = entriesOf(payload);
8229
+ this.#state = next;
8230
+ this.#synced = true;
8231
+ for (const [key, entries] of next) {
8232
+ const held = before.get(key) ?? [];
8233
+ const added = without(entries, held);
8234
+ if (added.length > 0) emit.join(key, copies(added), copies(held));
8235
+ }
8236
+ for (const [key, entries] of before) {
8237
+ const left = without(entries, next.get(key) ?? []);
8238
+ if (left.length > 0)
8239
+ emit.leave(key, copies(left), copies(next.get(key) ?? []));
8240
+ }
8241
+ emit.sync();
8242
+ }
8243
+ applyDiff(payload, emit) {
8244
+ if (!this.#synced || !isRecord(payload)) return;
8245
+ for (const [key, added] of entriesOf(payload.joins)) {
8246
+ const held = this.#state.get(key) ?? [];
8247
+ this.#state.set(key, [...without(held, added), ...added]);
8248
+ emit.join(key, copies(added), copies(held));
8249
+ }
8250
+ for (const [key, left] of entriesOf(payload.leaves)) {
8251
+ const current = without(this.#state.get(key) ?? [], left);
8252
+ if (current.length > 0) this.#state.set(key, current);
8253
+ else this.#state.delete(key);
8254
+ emit.leave(key, copies(left), copies(current));
8255
+ }
8256
+ emit.sync();
8257
+ }
8258
+ };
8259
+ var REALTIME_PROTOCOL_VERSION = "1.0.0";
8260
+ var PHOENIX_TOPIC = "phoenix";
8261
+ var REALTIME_TOPIC_PREFIX = "realtime:";
8262
+ var HEARTBEAT_REQUEST = '{"topic":"phoenix","event":"heartbeat","payload":{},"ref":"hb"}';
8263
+ var HEARTBEAT_REF = "hb";
8264
+ var MAX_CHANNEL_NAME_LENGTH = 128;
8265
+ var CONNECTION_FRAMES_PER_SECOND = 10;
8266
+ var CONNECTION_FRAME_BURST = 120;
8267
+ var WARNED_CODES = /* @__PURE__ */ new Set([
8268
+ "TOO_MANY_JOINS",
8269
+ "TOO_MANY_MESSAGES",
8270
+ "TOO_MANY_PRESENCE_MESSAGES"
8271
+ ]);
8272
+ var FLOOD_WINDOW_MS = 6e4;
8273
+ var MAX_CHANGE_CHANNELS_PER_TABLE = 4;
8274
+ 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`;
8275
+ function readServerFrame(data) {
8276
+ if (typeof data !== "string") return null;
8277
+ let value;
7912
8278
  try {
7913
- body = JSON.parse(text);
8279
+ value = JSON.parse(data);
7914
8280
  } catch {
7915
- return fail(invalidResponse(response.status));
8281
+ return null;
7916
8282
  }
7917
- return dataOf2(response.status, body, guard);
8283
+ if (!isRecord(value)) return null;
8284
+ const { topic, event, payload, ref } = value;
8285
+ const joinRef = value.join_ref;
8286
+ if (typeof topic !== "string" || typeof event !== "string") return null;
8287
+ if (!isRecord(payload)) return null;
8288
+ return {
8289
+ topic,
8290
+ event,
8291
+ payload,
8292
+ ref: typeof ref === "string" ? ref : null,
8293
+ joinRef: typeof joinRef === "string" ? joinRef : null
8294
+ };
7918
8295
  }
7919
- var ROUTE = "/storage/upload/multipart";
7920
- var UPLOAD_TOKEN_HEADER = "x-naro-upload-token";
7921
- var CONCURRENCY = 4;
7922
- var ATTEMPTS = 4;
7923
- var BACKOFF_MS = [1e3, 2e3, 4e3];
7924
- var UPLOAD_ID = /^[\w-][\w.-]*$/;
7925
- var positive = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
7926
- var planFor = (size) => (value) => isRecord(value) && typeof value.uploadId === "string" && UPLOAD_ID.test(value.uploadId) && positive(value.partSize) && positive(value.partCount) && (value.partCount - 1) * value.partSize < size && value.partCount * value.partSize >= size;
7927
- var partAnswer = (n) => (value) => isRecord(value) && value.partNumber === n && typeof value.etag === "string" && value.etag !== "";
7928
- var retryable = (error, status) => error.code === "NETWORK_ERROR" || error.code === "OBJECT_RATE_LIMITED" || status !== null && status >= 500 && error.code !== "STORAGE_QUOTA_EXCEEDED";
7929
- function waitAfter(error, attempt22) {
7930
- const after = isRecord(error.details) ? error.details.retryAfter : void 0;
7931
- if (typeof after === "number") return after * 1e3;
7932
- if (error.code === "OBJECT_RATE_LIMITED") return 1e3;
7933
- return BACKOFF_MS[attempt22 - 1] ?? 4e3;
8296
+ function frameLimitOf(reason) {
8297
+ if (typeof reason !== "string") return null;
8298
+ const match = /^is larger than (\d+) bytes$/.exec(reason);
8299
+ return match === null ? null : Number(match[1]);
7934
8300
  }
7935
- var sleepFor = (ms, signal) => new Promise((done) => {
7936
- if (signal?.aborted) {
7937
- done();
7938
- return;
8301
+ function utf8Length(text) {
8302
+ let bytes = 0;
8303
+ for (let index = 0; index < text.length; index++) {
8304
+ const unit = text.charCodeAt(index);
8305
+ if (unit < 128) bytes += 1;
8306
+ else if (unit < 2048) bytes += 2;
8307
+ else if (unit >= 55296 && unit <= 56319 && index + 1 < text.length) {
8308
+ const next = text.charCodeAt(index + 1);
8309
+ if (next >= 56320 && next <= 57343) {
8310
+ bytes += 4;
8311
+ index++;
8312
+ } else bytes += 3;
8313
+ } else bytes += 3;
7939
8314
  }
7940
- const end = () => {
7941
- clearTimeout(timer);
7942
- signal?.removeEventListener("abort", end);
7943
- done();
7944
- };
7945
- const timer = setTimeout(end, ms);
7946
- signal?.addEventListener("abort", end);
7947
- });
7948
- async function uploadInParts(a) {
7949
- const { signal } = a;
7950
- const sleep2 = a.sleep ?? sleepFor;
7951
- const signed = a.uploadToken === void 0 ? {} : { [UPLOAD_TOKEN_HEADER]: a.uploadToken };
7952
- const withToken = a.uploadToken === void 0;
7953
- if (signal?.aborted) return fail(uploadAborted());
7954
- const started = await call(a.http, {
7955
- method: a.method,
7956
- path: ROUTE,
7957
- body: {
7958
- bucket: a.bucket,
7959
- path: a.path,
7960
- size: a.size,
7961
- contentType: a.contentType,
7962
- cacheControl: a.cacheControl,
7963
- // PUT always replaces: the worker refuses `upsert` on it.
7964
- ...a.method === "POST" && a.upsert ? { upsert: true } : {}
7965
- },
7966
- headers: signed,
7967
- withToken
7968
- });
7969
- if (started.error !== null) return fail(started.error);
7970
- const plan = dataOf2(
7971
- started.answer.status,
7972
- started.answer.body,
7973
- planFor(a.size)
8315
+ return bytes;
8316
+ }
8317
+ function clientFrame(topic, event, payload, ref, joinRef) {
8318
+ return JSON.stringify(
8319
+ joinRef === void 0 ? { topic, event, payload, ref } : { topic, event, payload, ref, join_ref: joinRef }
7974
8320
  );
7975
- if (plan.error !== null) return fail(plan.error);
7976
- const { uploadId, partSize, partCount } = plan.data;
7977
- const base = `${ROUTE}/${uploadId}`;
7978
- const progress = (sentBytes2) => {
7979
- if (signal?.aborted) return;
7980
- try {
7981
- const result = a.onProgress?.(sentBytes2, a.size);
7982
- if (isThenable(result)) result.then(void 0, () => void 0);
7983
- } catch {
7984
- }
7985
- };
7986
- const callOff = async () => {
7987
- await call(a.http, {
7988
- method: "DELETE",
7989
- path: base,
7990
- headers: signed,
7991
- withToken
7992
- }).catch(() => void 0);
8321
+ }
8322
+ function atMostCodePoints(text, max) {
8323
+ if (text.length <= max) return true;
8324
+ return text.length <= max * 2 && [...text].length <= max;
8325
+ }
8326
+ function hasControlCharacter(text) {
8327
+ for (let i = 0; i < text.length; i += 1) {
8328
+ const code = text.charCodeAt(i);
8329
+ if (code <= 31 || code >= 127 && code <= 159) return true;
8330
+ }
8331
+ return false;
8332
+ }
8333
+ function hasBidiControl(text) {
8334
+ for (let i = 0; i < text.length; i += 1) {
8335
+ const code = text.charCodeAt(i);
8336
+ if (code === 1564 || code === 8206 || code === 8207 || code >= 8234 && code <= 8238 || code >= 8294 && code <= 8297)
8337
+ return true;
8338
+ }
8339
+ return false;
8340
+ }
8341
+ function channelNameProblem(name) {
8342
+ if (name === "") return "is empty";
8343
+ if (!atMostCodePoints(name, MAX_CHANNEL_NAME_LENGTH))
8344
+ return `is longer than ${String(MAX_CHANNEL_NAME_LENGTH)} characters`;
8345
+ if (hasControlCharacter(name)) return "has a control character";
8346
+ if (hasBidiControl(name)) return "has a bidirectional control character";
8347
+ return void 0;
8348
+ }
8349
+ function socketUrl(baseUrl, apiKey) {
8350
+ const url = new URL("/_naro/v1/realtime/websocket", baseUrl);
8351
+ url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
8352
+ return `${url.href}?apikey=${encodeURIComponent(apiKey)}&vsn=${REALTIME_PROTOCOL_VERSION}`;
8353
+ }
8354
+ var HEARTBEAT_MS = 25e3;
8355
+ var TIMEOUT_MS = 1e4;
8356
+ var PACED_BURST = CONNECTION_FRAME_BURST - 20;
8357
+ var SDK_WARNING_ROOM = 6;
8358
+ var ignore = () => void 0;
8359
+ function now() {
8360
+ return typeof performance === "undefined" ? Date.now() : performance.now();
8361
+ }
8362
+ function detach(ws) {
8363
+ ws.onopen = null;
8364
+ ws.onmessage = null;
8365
+ ws.onclose = null;
8366
+ ws.onerror = ignore;
8367
+ }
8368
+ function closeQuietly(ws, code) {
8369
+ try {
8370
+ ws.close(code);
8371
+ } catch {
8372
+ }
8373
+ }
8374
+ function safely(callback) {
8375
+ try {
8376
+ callback();
8377
+ } catch (error) {
8378
+ queueMicrotask(() => {
8379
+ throw error;
8380
+ });
8381
+ }
8382
+ }
8383
+ function replyOf(payload) {
8384
+ if (payload.status === "ok") {
8385
+ const { response } = payload;
8386
+ return {
8387
+ ok: true,
8388
+ response: typeof response === "object" && response !== null && !Array.isArray(response) ? response : {}
8389
+ };
8390
+ }
8391
+ return {
8392
+ ok: false,
8393
+ error: asError(payload.response) ?? invalidResponse(101),
8394
+ lost: false
7993
8395
  };
7994
- const etags = [];
7995
- const failure = {
7996
- error: signal?.aborted ? uploadAborted() : null
8396
+ }
8397
+ var RealtimeSocket = class {
8398
+ #config;
8399
+ #heartbeatMs;
8400
+ #timeoutMs;
8401
+ #ws = null;
8402
+ #state = "idle";
8403
+ #generation = 0;
8404
+ /** Drops since the hub last answered: the next wait's step (결정 7). */
8405
+ #attempt = 0;
8406
+ /** Tries in a row that never opened (결정 9). */
8407
+ #unopened = 0;
8408
+ /** When the wait the last close or stop set ends: no connect() opens before it. */
8409
+ #resumeAt = 0;
8410
+ /**
8411
+ * The last close was 4008 (CLIENT_FLOODING), and no heartbeat was
8412
+ * answered since: a channel's word that the hub answered does not start
8413
+ * the backoff over — a connection that floods again must not come back
8414
+ * every second (PR #328 review).
8415
+ */
8416
+ #flooded = false;
8417
+ /**
8418
+ * The frames waiting their turn on this connection, in the order they
8419
+ * go: the first `#ahead` before the app's (`#enqueue`).
8420
+ */
8421
+ #queue = [];
8422
+ /** 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. */
8423
+ #ahead = 0;
8424
+ /** The app's frames in `#queue`: not in flight yet, so no room is kept for them (`#hasRoom`). */
8425
+ #appQueued = 0;
8426
+ /** The budget this connection has left (`PACED_BURST` at the open), as of `#tokensAt`. */
8427
+ #tokens = 0;
8428
+ #tokensAt = 0;
8429
+ #paceTimer;
8430
+ /** When the hub refused a frame on this connection with a warning (`WARNED_CODES`), oldest first. */
8431
+ #warnings = [];
8432
+ /** The SDK's own frames waiting for room, by who sends them, in the order they asked (`whenRoom()`). */
8433
+ #roomWaiters = /* @__PURE__ */ new Map();
8434
+ /** Due when the oldest warning leaves the window, while a frame waits for room. */
8435
+ #roomTimer;
8436
+ #draining = false;
8437
+ #ref = 0;
8438
+ #retryTimer;
8439
+ #openTimer;
8440
+ #heartbeat;
8441
+ #heartbeatPending = false;
8442
+ /** The connection-level `system` frame since the open: why it is about to close. */
8443
+ #said = null;
8444
+ /**
8445
+ * The size limit the hub named when it refused a frame on this
8446
+ * connection (`#refusedOverLimit`): a frame over it is answered so without
8447
+ * being sent. Forgotten with the connection — the next hub's plan may
8448
+ * differ.
8449
+ */
8450
+ #frameLimit = null;
8451
+ #replies = /* @__PURE__ */ new Map();
8452
+ constructor(config) {
8453
+ this.#config = config;
8454
+ this.#heartbeatMs = config.options.heartbeatIntervalMs ?? HEARTBEAT_MS;
8455
+ this.#timeoutMs = config.options.timeoutMs ?? TIMEOUT_MS;
8456
+ }
8457
+ get generation() {
8458
+ return this.#generation;
8459
+ }
8460
+ get timeoutMs() {
8461
+ return this.#timeoutMs;
8462
+ }
8463
+ isOpen() {
8464
+ return this.#state === "open";
8465
+ }
8466
+ nextRef() {
8467
+ this.#ref += 1;
8468
+ return String(this.#ref);
8469
+ }
8470
+ /**
8471
+ * Opens unless a connection is open, opening or due — once the wait the
8472
+ * last close or stop set is over. After the app's own close the counts
8473
+ * start over; after a stop they go on, as after a drop.
8474
+ */
8475
+ connect() {
8476
+ if (this.#state !== "idle" && this.#state !== "stopped") return;
8477
+ if (this.#state === "idle") {
8478
+ this.#attempt = 0;
8479
+ this.#unopened = 0;
8480
+ }
8481
+ const wait = this.#resumeAt - now();
8482
+ if (wait <= 0) return this.#open();
8483
+ this.#state = "waiting";
8484
+ this.#retryTimer = setTimeout(() => {
8485
+ this.#retryTimer = void 0;
8486
+ if (this.#state === "waiting") this.#open();
8487
+ }, wait);
8488
+ }
8489
+ /** The last channel went: close for good, with no event. */
8490
+ disconnect() {
8491
+ const ws = this.#ws;
8492
+ this.#ws = null;
8493
+ this.#clearTimers();
8494
+ this.#state = "idle";
8495
+ this.#said = null;
8496
+ this.#failReplies(realtimeDisconnected());
8497
+ if (ws !== null) {
8498
+ detach(ws);
8499
+ closeQuietly(ws, 1e3);
8500
+ }
8501
+ }
8502
+ /**
8503
+ * Lets go of the open connection and comes back as after a drop (1006),
8504
+ * the channels told: for a connection that holds an identity the app no
8505
+ * longer means and cannot change (identity.ts). Nothing unless open.
8506
+ */
8507
+ reconnect() {
8508
+ const ws = this.#ws;
8509
+ if (ws === null || this.#state !== "open") return;
8510
+ detach(ws);
8511
+ closeQuietly(ws, 1e3);
8512
+ this.#closed(ws, 1006, "", null);
8513
+ }
8514
+ /**
8515
+ * The hub answered this connection: the next drop waits from the first
8516
+ * step — unless the last close was 4008 and no heartbeat was answered
8517
+ * since (`#flooded`).
8518
+ */
8519
+ served() {
8520
+ if (!this.#flooded) this.#attempt = 0;
8521
+ }
8522
+ /**
8523
+ * A frame, in its `turn` (the app's unless said), and the `phx_reply`
8524
+ * with its `ref` within `timeoutMs` of when it went — or, for one over the
8525
+ * size limit the hub named on this connection, that refusal at once and
8526
+ * nothing sent; for an app's frame but a leave that would not go within
8527
+ * `timeoutMs`, CLIENT_FLOODING at once and nothing sent.
8528
+ */
8529
+ request(topic, event, payload, options = {}) {
8530
+ const ref = options.ref ?? this.nextRef();
8531
+ const turn = options.turn ?? "app";
8532
+ return new Promise((resolve7) => {
8533
+ const lost = {
8534
+ ok: false,
8535
+ error: realtimeClosed(1006),
8536
+ lost: true
8537
+ };
8538
+ if (!this.isOpen()) return resolve7(lost);
8539
+ let text;
8540
+ try {
8541
+ text = clientFrame(topic, event, payload, ref, options.joinRef);
8542
+ } catch {
8543
+ return resolve7(lost);
8544
+ }
8545
+ const bytes = utf8Length(text);
8546
+ const limit = this.#frameLimit;
8547
+ if (limit !== null && bytes > limit)
8548
+ return resolve7({ ok: false, error: frameTooLarge(limit), lost: false });
8549
+ if (turn === "app" && event !== "phx_leave" && !this.#goesInTime())
8550
+ return resolve7({
8551
+ ok: false,
8552
+ error: clientFlooding(CONNECTION_FRAMES_PER_SECOND),
8553
+ lost: false
8554
+ });
8555
+ this.#replies.set(ref, { resolve: resolve7, timer: void 0, bytes, turn });
8556
+ this.#enqueue({ text, bytes, ref, turn, presence: event === "presence" });
8557
+ });
8558
+ }
8559
+ /**
8560
+ * Whether an app's frame behind every frame waiting would go within
8561
+ * `timeoutMs`: past the budget left, the hub takes
8562
+ * `CONNECTION_FRAMES_PER_SECOND` a second. The SDK's own frames that come
8563
+ * later go before it, a few at most (`whenRoom()`).
8564
+ */
8565
+ #goesInTime() {
8566
+ this.#refill();
8567
+ const over = this.#queue.length + 1 - this.#tokens;
8568
+ return over <= CONNECTION_FRAMES_PER_SECOND * this.#timeoutMs / 1e3;
8569
+ }
8570
+ /**
8571
+ * Runs `go` once a frame the SDK sends of its own accord may go: at once
8572
+ * when nothing waits before it and there is room — the warnings of the
8573
+ * last minute and the requests in flight fewer than `SDK_WARNING_ROOM`
8574
+ * (the app's frames still waiting their turn are not in flight yet, and
8575
+ * go after the SDK's), or the warnings fewer and none of the SDK's own
8576
+ * unanswered — or else when there is, in the order asked. The
8577
+ * frame itself is sent with `turn: "sdk"`. `key` names the sender:
8578
+ * asked again before it ran, it keeps its place and runs the newer `go`,
8579
+ * which reads its own state again then. Nothing while the connection is
8580
+ * not open, and forgotten with it: the sender tries again when it is live.
8581
+ */
8582
+ whenRoom(key, go) {
8583
+ if (this.#state !== "open") return;
8584
+ if (this.#roomWaiters.size === 0 && this.#hasRoom()) return go();
8585
+ this.#roomWaiters.set(key, go);
8586
+ this.#armRoom();
8587
+ }
8588
+ /**
8589
+ * Room for one more frame of the SDK's own (`whenRoom()`): the requests
8590
+ * that count are those sent and not answered, and the SDK's own still to
8591
+ * go — not the app's waiting their turn, which the SDK's go before (PR
8592
+ * #328 re-review). And while none of the SDK's own is unanswered, one
8593
+ * goes whatever the app has in flight, if the warnings leave room (PR #328
8594
+ * re-review 2): an app sending 10 a second over a link of 0.7 s or more
8595
+ * always has six in flight, and a second channel's join would wait,
8596
+ * untold, for as long as it sends. A refusal of the app's is a warning
8597
+ * when it comes, so the SDK adds one at most.
8598
+ */
8599
+ #hasRoom() {
8600
+ if (this.#state !== "open") return false;
8601
+ const since = now() - FLOOD_WINDOW_MS;
8602
+ this.#warnings = this.#warnings.filter((at) => at > since);
8603
+ const warnings = this.#warnings.length;
8604
+ if (warnings >= SDK_WARNING_ROOM) return false;
8605
+ const inFlight2 = this.#replies.size - this.#appQueued;
8606
+ return warnings + inFlight2 < SDK_WARNING_ROOM || !this.#sdkUnanswered();
8607
+ }
8608
+ /** Whether a request of the SDK's own or the identity's waits for its answer, sent or still to go. */
8609
+ #sdkUnanswered() {
8610
+ for (const waiter of this.#replies.values())
8611
+ if (waiter.turn !== "app") return true;
8612
+ return false;
8613
+ }
8614
+ /** The frames waiting for room go while there is: after an answer, a time-out, and when a warning leaves the window. */
8615
+ #drainRoom() {
8616
+ if (this.#draining) return;
8617
+ this.#draining = true;
8618
+ try {
8619
+ for (const [key, go] of this.#roomWaiters) {
8620
+ if (!this.#hasRoom()) break;
8621
+ this.#roomWaiters.delete(key);
8622
+ safely(go);
8623
+ }
8624
+ } finally {
8625
+ this.#draining = false;
8626
+ }
8627
+ this.#armRoom();
8628
+ }
8629
+ /** While a frame waits for room the warnings hold, look again when the oldest leaves the window. */
8630
+ #armRoom() {
8631
+ clearTimeout(this.#roomTimer);
8632
+ this.#roomTimer = void 0;
8633
+ const oldest = this.#warnings[0];
8634
+ if (this.#roomWaiters.size === 0 || oldest === void 0) return;
8635
+ this.#roomTimer = setTimeout(
8636
+ () => {
8637
+ this.#roomTimer = void 0;
8638
+ this.#drainRoom();
8639
+ },
8640
+ Math.max(1, oldest + FLOOD_WINDOW_MS - now() + 1)
8641
+ );
8642
+ }
8643
+ /**
8644
+ * Puts `frame` in its turn and sends what may go. The app's go last; the
8645
+ * SDK's own and the identity's after the others before the app's. An
8646
+ * identity frame goes after the app's presence frames still to go, with
8647
+ * whatever waits before them: the hub reads a presence as the user it
8648
+ * holds when the frame comes, and would hold the last user's state for the
8649
+ * new one.
8650
+ */
8651
+ #enqueue(frame) {
8652
+ if (frame.turn === "app") {
8653
+ this.#queue.push(frame);
8654
+ this.#appQueued += 1;
8655
+ } else {
8656
+ if (frame.turn === "identity") this.#ahead = this.#pastPresence();
8657
+ this.#queue.splice(this.#ahead, 0, frame);
8658
+ this.#ahead += 1;
8659
+ }
8660
+ this.#flush();
8661
+ }
8662
+ /** Just past the app's last presence frame still to go — `#ahead` when none is. */
8663
+ #pastPresence() {
8664
+ for (let index = this.#queue.length - 1; index >= this.#ahead; index -= 1)
8665
+ if (this.#queue[index]?.presence === true) return index + 1;
8666
+ return this.#ahead;
8667
+ }
8668
+ /** The next frame to go, out of `#queue`. */
8669
+ #next() {
8670
+ const frame = this.#queue.shift();
8671
+ if (frame === void 0) return void 0;
8672
+ if (this.#ahead > 0) this.#ahead -= 1;
8673
+ if (frame.turn === "app") this.#appQueued -= 1;
8674
+ return frame;
8675
+ }
8676
+ /** `ref`'s frame out of `#queue`, if it is still there: answered before it went. */
8677
+ #unqueue(ref) {
8678
+ const index = this.#queue.findIndex((frame2) => frame2.ref === ref);
8679
+ if (index === -1) return;
8680
+ const [frame] = this.#queue.splice(index, 1);
8681
+ if (index < this.#ahead) this.#ahead -= 1;
8682
+ if (frame?.turn === "app") this.#appQueued -= 1;
8683
+ }
8684
+ /**
8685
+ * Sends the frames waiting their turn while the budget has room, in their
8686
+ * turn, and comes back when the next one is due. One whose frame the
8687
+ * socket cannot send is lost now — its close event follows.
8688
+ */
8689
+ #flush() {
8690
+ const ws = this.#ws;
8691
+ if (ws === null || this.#state !== "open") return;
8692
+ this.#refill();
8693
+ while (this.#queue.length > 0 && this.#tokens >= 1) {
8694
+ const frame = this.#next();
8695
+ const waiter = this.#replies.get(frame.ref);
8696
+ if (waiter === void 0) continue;
8697
+ const limit = this.#frameLimit;
8698
+ if (limit !== null && frame.bytes > limit) {
8699
+ this.#answer(frame.ref, {
8700
+ ok: false,
8701
+ error: frameTooLarge(limit),
8702
+ lost: false
8703
+ });
8704
+ continue;
8705
+ }
8706
+ try {
8707
+ ws.send(frame.text);
8708
+ } catch {
8709
+ this.#answer(frame.ref, {
8710
+ ok: false,
8711
+ error: realtimeClosed(1006),
8712
+ lost: true
8713
+ });
8714
+ continue;
8715
+ }
8716
+ this.#tokens -= 1;
8717
+ waiter.timer = this.#timeOut(frame.ref, waiter);
8718
+ }
8719
+ if (this.#queue.length === 0 || this.#paceTimer !== void 0) return;
8720
+ const due = Math.ceil(
8721
+ (1 - this.#tokens) * 1e3 / CONNECTION_FRAMES_PER_SECOND
8722
+ );
8723
+ this.#paceTimer = setTimeout(
8724
+ () => {
8725
+ this.#paceTimer = void 0;
8726
+ this.#flush();
8727
+ },
8728
+ Math.max(1, due)
8729
+ );
8730
+ }
8731
+ /** The budget refilled for the time since it was last looked at, as the hub's bucket refills. */
8732
+ #refill() {
8733
+ const at = now();
8734
+ if (at <= this.#tokensAt) return;
8735
+ this.#tokens = Math.min(
8736
+ PACED_BURST,
8737
+ this.#tokens + (at - this.#tokensAt) / 1e3 * CONNECTION_FRAMES_PER_SECOND
8738
+ );
8739
+ this.#tokensAt = at;
8740
+ }
8741
+ /**
8742
+ * `ref`'s request hears `reply`, now: one fewer in flight — or, answered
8743
+ * before its frame went (over a size limit the hub named), one fewer to
8744
+ * go, and nothing sent.
8745
+ */
8746
+ #answer(ref, reply) {
8747
+ const waiter = this.#replies.get(ref);
8748
+ if (waiter === void 0) return;
8749
+ this.#replies.delete(ref);
8750
+ clearTimeout(waiter.timer);
8751
+ if (waiter.timer === void 0) this.#unqueue(ref);
8752
+ waiter.resolve(reply);
8753
+ this.#drainRoom();
8754
+ }
8755
+ /** TIMED_OUT for `ref`'s request `timeoutMs` after its frame went. */
8756
+ #timeOut(ref, waiter) {
8757
+ return setTimeout(() => {
8758
+ if (this.#replies.get(ref) !== waiter) return;
8759
+ this.#answer(ref, {
8760
+ ok: false,
8761
+ error: realtimeTimedOut(this.#timeoutMs),
8762
+ lost: false
8763
+ });
8764
+ }, this.#timeoutMs);
8765
+ }
8766
+ #open() {
8767
+ const Impl = this.#config.options.WebSocket ?? globalThis.WebSocket;
8768
+ if (typeof Impl !== "function")
8769
+ return this.#stop(websocketUnavailable(), true);
8770
+ if (this.#config.service && !isKnownServer())
8771
+ return this.#stop(serviceKeyOffServer(), true);
8772
+ let ws;
8773
+ try {
8774
+ ws = new Impl(
8775
+ socketUrl(this.#config.baseUrl, this.#config.apiKey)
8776
+ );
8777
+ } catch (error) {
8778
+ return this.#stop(socketRefused(error), true);
8779
+ }
8780
+ this.#generation += 1;
8781
+ this.#ws = ws;
8782
+ this.#state = "connecting";
8783
+ this.#said = null;
8784
+ this.#frameLimit = null;
8785
+ ws.onopen = () => this.#opened(ws);
8786
+ ws.onmessage = (event) => this.#message(ws, event.data);
8787
+ ws.onclose = (event) => this.#closed(ws, event.code, event.reason ?? "", null);
8788
+ ws.onerror = ignore;
8789
+ this.#openTimer = setTimeout(
8790
+ () => this.#closed(ws, 1006, "", this.#timeoutMs),
8791
+ this.#timeoutMs
8792
+ );
8793
+ }
8794
+ #opened(ws) {
8795
+ if (this.#ws !== ws || this.#state !== "connecting") return;
8796
+ clearTimeout(this.#openTimer);
8797
+ this.#openTimer = void 0;
8798
+ this.#state = "open";
8799
+ this.#unopened = 0;
8800
+ this.#heartbeatPending = false;
8801
+ this.#queue = [];
8802
+ this.#ahead = 0;
8803
+ this.#appQueued = 0;
8804
+ this.#tokens = PACED_BURST;
8805
+ this.#tokensAt = now();
8806
+ this.#warnings = [];
8807
+ this.#heartbeat = setInterval(() => this.#beat(ws), this.#heartbeatMs);
8808
+ safely(() => this.#config.events.opened());
8809
+ }
8810
+ #beat(ws) {
8811
+ if (this.#ws !== ws) return;
8812
+ if (this.#heartbeatPending)
8813
+ return this.#closed(ws, 1e3, "", this.#heartbeatMs);
8814
+ this.#heartbeatPending = true;
8815
+ try {
8816
+ ws.send(HEARTBEAT_REQUEST);
8817
+ } catch {
8818
+ }
8819
+ }
8820
+ #message(ws, data) {
8821
+ if (this.#ws !== ws) return;
8822
+ const frame = readServerFrame(data);
8823
+ if (frame === null) return;
8824
+ if (frame.event === "phx_reply" && frame.ref !== null) {
8825
+ if (frame.ref === HEARTBEAT_REF) {
8826
+ this.#heartbeatPending = false;
8827
+ this.#flooded = false;
8828
+ this.#attempt = 0;
8829
+ return;
8830
+ }
8831
+ const reply = replyOf(frame.payload);
8832
+ if (!reply.ok && WARNED_CODES.has(reply.error.code))
8833
+ this.#warnings.push(now());
8834
+ if (this.#replies.has(frame.ref)) {
8835
+ this.#answer(frame.ref, reply);
8836
+ return;
8837
+ }
8838
+ }
8839
+ if (frame.topic === PHOENIX_TOPIC) {
8840
+ if (frame.event === "system") this.#said = asError(frame.payload);
8841
+ else if (frame.event === "phx_reply" && frame.ref === null)
8842
+ this.#refusedOverLimit(frame.payload);
8843
+ return;
8844
+ }
8845
+ safely(() => this.#config.events.frame(frame));
8846
+ }
8847
+ /**
8848
+ * The hub refused a frame over its size limit before reading it (final
8849
+ * review A M3, plan 정정 (m)): it names no `ref`, so no request waits for
8850
+ * it by name — it waited `timeoutMs` and answered TIMED_OUT. But the hub
8851
+ * refuses every frame over the limit, and only those, before anything
8852
+ * else: each request waiting whose frame is over it is one refused so, and
8853
+ * hears it now. The limit stays for the connection.
8854
+ */
8855
+ #refusedOverLimit(payload) {
8856
+ if (payload.status !== "error") return;
8857
+ const error = asError(payload.response);
8858
+ if (error?.code !== "INVALID_MESSAGE" || !isRecord(error.details)) return;
8859
+ const limit = frameLimitOf(error.details.reason);
8860
+ if (limit === null) return;
8861
+ this.#frameLimit = limit;
8862
+ for (const [ref, waiter] of this.#replies)
8863
+ if (waiter.bytes > limit)
8864
+ this.#answer(ref, {
8865
+ ok: false,
8866
+ error: frameTooLarge(limit),
8867
+ lost: false
8868
+ });
8869
+ }
8870
+ /**
8871
+ * `ws` ended: its close event (`waitedMs` null), or a heartbeat or an open
8872
+ * that had no answer in `waitedMs` — then it is let go at once, its
8873
+ * handlers first, since a dead TCP connection can take minutes to report
8874
+ * its close (결정 11).
8875
+ */
8876
+ #closed(ws, code, reason, waitedMs) {
8877
+ if (this.#ws !== ws) return;
8878
+ const wasOpen = this.#state === "open";
8879
+ const timedOut = waitedMs !== null;
8880
+ detach(ws);
8881
+ this.#ws = null;
8882
+ this.#clearTimers();
8883
+ if (timedOut) closeQuietly(ws, 1e3);
8884
+ if (!wasOpen) this.#unopened += 1;
8885
+ const said = this.#said;
8886
+ this.#said = null;
8887
+ if (!timedOut && code === 4008) this.#flooded = true;
8888
+ const action = timedOut ? { kind: "retry", minMs: 0 } : closeAction(code, said?.code ?? reason);
8889
+ if (action.kind === "stop")
8890
+ return this.#stop(said ?? realtimeStopped(code, reason));
8891
+ const error = timedOut ? realtimeTimedOut(waitedMs) : said ?? realtimeClosed(code);
8892
+ this.#failReplies(error);
8893
+ this.#state = "waiting";
8894
+ const delay = delayAfter(
8895
+ action,
8896
+ { attempt: this.#attempt, unopened: this.#unopened },
8897
+ Math.random
8898
+ );
8899
+ if (action.kind !== "restart" && action.kind !== "wait") this.#attempt += 1;
8900
+ this.#wait(action.kind === "session", delay);
8901
+ safely(
8902
+ () => this.#config.events.dropped(
8903
+ error,
8904
+ timedOut ? "TIMED_OUT" : "CHANNEL_ERROR"
8905
+ )
8906
+ );
8907
+ }
8908
+ /**
8909
+ * The next try, `delay` from now — after a 4401, from when its
8910
+ * conditional delete is done, or from `timeoutMs` on if it is not: an
8911
+ * app's `auth.storage` that never answers must not keep the connection
8912
+ * away for good.
8913
+ */
8914
+ #wait(session, delay) {
8915
+ const generation = this.#generation;
8916
+ this.#resumeAt = now() + delay;
8917
+ let due = false;
8918
+ const schedule = () => {
8919
+ if (due || this.#state !== "waiting" || this.#generation !== generation)
8920
+ return;
8921
+ due = true;
8922
+ this.#resumeAt = now() + delay;
8923
+ clearTimeout(this.#retryTimer);
8924
+ this.#retryTimer = setTimeout(() => {
8925
+ this.#retryTimer = void 0;
8926
+ if (this.#state === "waiting") this.#open();
8927
+ }, delay);
8928
+ };
8929
+ if (!session) return schedule();
8930
+ this.#retryTimer = setTimeout(schedule, this.#timeoutMs);
8931
+ safely(() => {
8932
+ void this.#config.events.sessionRefused().then(schedule, schedule);
8933
+ });
8934
+ }
8935
+ /**
8936
+ * Stopped until a connect(), which waits as after a drop: the stop counts
8937
+ * as one (`unopened` when no socket was made — the runtime cannot open).
8938
+ */
8939
+ #stop(error, unopened = false) {
8940
+ this.#clearTimers();
8941
+ this.#state = "stopped";
8942
+ this.#failReplies(error);
8943
+ if (unopened) this.#unopened += 1;
8944
+ this.#resumeAt = now() + delayAfter(
8945
+ { kind: "retry", minMs: 0 },
8946
+ { attempt: this.#attempt, unopened: this.#unopened },
8947
+ Math.random
8948
+ );
8949
+ this.#attempt += 1;
8950
+ safely(() => this.#config.events.stopped(error));
8951
+ }
8952
+ /** Every request still waiting hears `error`, as lost with the connection. */
8953
+ #failReplies(error) {
8954
+ const waiting = [...this.#replies.values()];
8955
+ this.#replies.clear();
8956
+ for (const waiter of waiting) {
8957
+ clearTimeout(waiter.timer);
8958
+ waiter.resolve({ ok: false, error, lost: true });
8959
+ }
8960
+ }
8961
+ /** Every timer of the connection, and the frames still to go with it. */
8962
+ #clearTimers() {
8963
+ clearTimeout(this.#retryTimer);
8964
+ clearTimeout(this.#openTimer);
8965
+ clearInterval(this.#heartbeat);
8966
+ clearTimeout(this.#paceTimer);
8967
+ clearTimeout(this.#roomTimer);
8968
+ this.#retryTimer = void 0;
8969
+ this.#openTimer = void 0;
8970
+ this.#heartbeat = void 0;
8971
+ this.#paceTimer = void 0;
8972
+ this.#roomTimer = void 0;
8973
+ this.#queue = [];
8974
+ this.#ahead = 0;
8975
+ this.#appQueued = 0;
8976
+ this.#roomWaiters.clear();
8977
+ }
8978
+ };
8979
+ function sent(reply) {
8980
+ return reply.ok ? { data: null, error: null, lost: false } : { data: null, error: reply.error, lost: reply.lost };
8981
+ }
8982
+ function unsent(error) {
8983
+ return { data: null, error, lost: false };
8984
+ }
8985
+ function jsonObject(value) {
8986
+ try {
8987
+ const copy = JSON.parse(JSON.stringify(value));
8988
+ return isRecord(copy) ? copy : null;
8989
+ } catch {
8990
+ return null;
8991
+ }
8992
+ }
8993
+ function presenceReadDenied(response) {
8994
+ const { access } = response;
8995
+ return isRecord(access) && isRecord(access.presence) && access.presence.read === false;
8996
+ }
8997
+ function tablesOf(changes) {
8998
+ return changes.map((binding) => binding.filter.table ?? "*");
8999
+ }
9000
+ var JOIN_RETRY_CODES = /* @__PURE__ */ new Set([
9001
+ "TOO_MANY_JOINS",
9002
+ "INTERNAL_ERROR",
9003
+ "TIMED_OUT"
9004
+ ]);
9005
+ var RETRACK_RETRY_CODES = /* @__PURE__ */ new Set([
9006
+ "TOO_MANY_PRESENCE_MESSAGES",
9007
+ "TOO_MANY_MESSAGES",
9008
+ "TOO_MANY_JOINS",
9009
+ "INTERNAL_ERROR",
9010
+ "TIMED_OUT"
9011
+ ]);
9012
+ var RETRACK_WARNED_RESENDS = 3;
9013
+ 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.";
9014
+ function isDevelopment() {
9015
+ const env = globalThis.process?.env;
9016
+ return env?.NODE_ENV !== "production";
9017
+ }
9018
+ function warnInDevelopment(message) {
9019
+ if (isDevelopment()) console.warn(message);
9020
+ }
9021
+ function isJson(value) {
9022
+ try {
9023
+ JSON.stringify(value);
9024
+ return true;
9025
+ } catch {
9026
+ return false;
9027
+ }
9028
+ }
9029
+ function changeSpec(filter) {
9030
+ const {
9031
+ event,
9032
+ schema,
9033
+ table: table3,
9034
+ filter: text,
9035
+ ...rest
9036
+ } = filter;
9037
+ return {
9038
+ ...rest,
9039
+ event,
9040
+ ...schema === void 0 ? {} : { schema },
9041
+ ...table3 === void 0 ? {} : { table: table3 },
9042
+ ...text === void 0 || text === "" ? {} : { filter: text }
9043
+ };
9044
+ }
9045
+ function changePayload(data) {
9046
+ return {
9047
+ schema: "public",
9048
+ table: typeof data.table === "string" ? data.table : "",
9049
+ commit_timestamp: typeof data.commit_timestamp === "string" ? data.commit_timestamp : "",
9050
+ eventType: data.type,
9051
+ new: isRecord(data.record) ? data.record : {},
9052
+ old: isRecord(data.old_record) ? data.old_record : {},
9053
+ errors: Array.isArray(data.errors) ? data.errors.filter((item) => typeof item === "string") : null
9054
+ };
9055
+ }
9056
+ function systemMessage(payload) {
9057
+ const { extension, code, message, channel } = payload;
9058
+ return {
9059
+ status: payload.status === "ok" ? "ok" : "error",
9060
+ extension: typeof extension === "string" ? extension : "system",
9061
+ code: typeof code === "string" ? code : "",
9062
+ message: typeof message === "string" ? message : "",
9063
+ channel: typeof channel === "string" ? channel : null
9064
+ };
9065
+ }
9066
+ var RealtimeChannel = class {
9067
+ name;
9068
+ topic;
9069
+ isPrivate;
9070
+ options;
9071
+ #context;
9072
+ #bindings = [];
9073
+ #presence = new Presence();
9074
+ #state = "closed";
9075
+ /** subscribe() was called and unsubscribe() was not: join it on every connection. */
9076
+ #wanted = false;
9077
+ /** The join sent and not left or replaced since — set when it is sent, before its answer. */
9078
+ #joinRef = null;
9079
+ /** The tables that join's row subscriptions are on (`*` for every table): its leave holds them. */
9080
+ #joinTables = [];
9081
+ /**
9082
+ * The join was refused a cap a leave owed on the connection still holds
9083
+ * a place under (`ChannelContext.owesLeaveOn`): it waits for that leave
9084
+ * rather than knock again (PR #328 review). Null otherwise.
9085
+ */
9086
+ #capWait = null;
9087
+ #onStatus = null;
9088
+ /** The app already heard of this trouble; reset at SUBSCRIBED. */
9089
+ #told = false;
9090
+ #attempt = 0;
9091
+ #retry;
9092
+ /** The `system` error sent before a `phx_close` (protocol.ts `closeChannel`). */
9093
+ #closeError = null;
9094
+ /** The last state tracked, and the token the hub had then (결정 17). */
9095
+ #tracked = null;
9096
+ /** Bumped when what is tracked is let go of: a track answered after that does not bring it back. */
9097
+ #letGo = 0;
9098
+ /** Bumped by the app's track() and untrack(): one in the SUBSCRIBED callback says what the join holds. */
9099
+ #presenceCalls = 0;
9100
+ /** A track sent again and refused for now goes again when this is due (`#retrack`). */
9101
+ #retrackTimer;
9102
+ /**
9103
+ * The app's presence calls and broadcasts waiting for the connection to
9104
+ * be live, in the order it made them (`#inTurn`): each sends, or answers
9105
+ * that its join went, when called.
9106
+ */
9107
+ #waiting = [];
9108
+ /** Inside subscribe(): a stop is told a microtask later (`#toldStop`). */
9109
+ #subscribing = false;
9110
+ /** Who waits for room on the connection (`whenRoom()`): this channel's join, and its track sent again. */
9111
+ #joinTurn = {};
9112
+ #retrackTurn = {};
9113
+ /**
9114
+ * Frames of the join in flight that came before its answer was handled
9115
+ * (final review A M1, plan 정정 (m)): Node's `ws` and undici hand over
9116
+ * the frames of one read in one callback, while the answer's handler runs
9117
+ * a microtask later — a change right behind the `ok` had no subscription
9118
+ * id yet. They are heard once the `ok` is (after SUBSCRIBED, as in a
9119
+ * browser, which runs a task per message), and dropped with a refusal.
9120
+ */
9121
+ #early = [];
9122
+ /** @internal `naro.channel()` makes channels. */
9123
+ constructor(name, options, context) {
9124
+ this.name = name;
9125
+ this.topic = `${REALTIME_TOPIC_PREFIX}${name}`;
9126
+ this.isPrivate = options.config?.private === true;
9127
+ this.options = options;
9128
+ this.#context = context;
9129
+ }
9130
+ get state() {
9131
+ return this.#state;
9132
+ }
9133
+ /** @internal The join sent and not left or replaced since (client.ts routes by it). */
9134
+ get joinRef() {
9135
+ return this.#joinRef;
9136
+ }
9137
+ on(type, filter, callback) {
9138
+ const listener = callback;
9139
+ const spec = isRecord(filter) ? filter : {};
9140
+ if ((type === "postgres_changes" || type === "presence") && this.#wanted)
9141
+ warnInDevelopment(LATE_LISTENER);
9142
+ if (type === "postgres_changes")
9143
+ this.#bindings.push({
9144
+ type,
9145
+ filter: spec,
9146
+ callback: listener,
9147
+ id: null
9148
+ });
9149
+ else if (type === "broadcast" || type === "presence")
9150
+ this.#bindings.push({
9151
+ type,
9152
+ event: String(spec.event),
9153
+ callback: listener
9154
+ });
9155
+ else if (type === "system")
9156
+ this.#bindings.push({ type, callback: listener });
9157
+ return this;
9158
+ }
9159
+ subscribe(callback) {
9160
+ if (callback !== void 0) this.#onStatus = callback;
9161
+ if (this.#wanted) return this;
9162
+ const problem = this.#nameError();
9163
+ if (problem !== null) {
9164
+ this.#state = "errored";
9165
+ queueMicrotask(() => this.#status("CHANNEL_ERROR", problem));
9166
+ return this;
9167
+ }
9168
+ this.#wanted = true;
9169
+ this.#state = "joining";
9170
+ this.#attempt = 0;
9171
+ this.#told = false;
9172
+ this.#capWait = null;
9173
+ this.#clearRetry();
9174
+ this.#subscribing = true;
9175
+ try {
9176
+ this.#context.connect();
9177
+ this.#joinIfDue();
9178
+ } finally {
9179
+ this.#subscribing = false;
9180
+ }
9181
+ return this;
9182
+ }
9183
+ async unsubscribe() {
9184
+ const ref = this.#joinRef;
9185
+ const held = this.#state === "joined" || this.#state === "joining";
9186
+ this.#wanted = false;
9187
+ this.#clearRetry();
9188
+ this.#letGoOfTrack();
9189
+ this.#joinRef = null;
9190
+ this.#state = "leaving";
9191
+ this.#releaseWaiting();
9192
+ let error = null;
9193
+ if (held && ref !== null) {
9194
+ const reply = await this.#context.leave(this, ref, this.#joinTables);
9195
+ if (!reply.ok && !reply.lost && reply.error.code !== "NOT_JOINED")
9196
+ error = reply.error;
9197
+ }
9198
+ if (this.#state === "leaving") {
9199
+ this.#state = "closed";
9200
+ this.#presence.clear();
9201
+ this.#status("CLOSED");
9202
+ }
9203
+ return { data: null, error };
9204
+ }
9205
+ /**
9206
+ * Broadcasts `message` on the join, or over HTTP before it: the server's
9207
+ * answer, `lost` when the connection went before it (RealtimeSendResult).
9208
+ */
9209
+ async send(message) {
9210
+ if (!isRecord(message) || message.type !== "broadcast")
9211
+ return unsent(invalidBroadcast());
9212
+ const problem = this.#nameError();
9213
+ if (problem !== null) return unsent(problem);
9214
+ const { event, payload } = message;
9215
+ if (!isJson({ event, payload }))
9216
+ return unsent(invalidQuery("", "is not JSON"));
9217
+ const joinRef = this.#joinRef;
9218
+ if (this.#state === "joined" && joinRef !== null) {
9219
+ const reply = await this.#inTurn(
9220
+ joinRef,
9221
+ !this.#context.live(),
9222
+ () => this.#context.socket.request(
9223
+ this.topic,
9224
+ "broadcast",
9225
+ { type: "broadcast", event, payload },
9226
+ { joinRef }
9227
+ )
9228
+ );
9229
+ if (reply !== null) return sent(reply);
9230
+ }
9231
+ await this.#context.auth.ready();
9232
+ const result = await call(this.#context.http, {
9233
+ method: "POST",
9234
+ path: "/realtime/broadcast",
9235
+ body: {
9236
+ messages: [
9237
+ { topic: this.topic, event, payload, private: this.isPrivate }
9238
+ ]
9239
+ },
9240
+ withToken: true
9241
+ });
9242
+ return unsent(result.error);
9243
+ }
9244
+ /**
9245
+ * Tracks `state` as this connection's presence here (original spec
9246
+ * §11.1): the hub's answer. Only on a join — before it, NOT_JOINED, and
9247
+ * nothing is kept for later (결정 18). It goes as its JSON reads back, and
9248
+ * that copy, not the app's object, is what a later join sends again.
9249
+ * While who the connection is is not settled (a new token in flight, a
9250
+ * retry), it waits until it is (정정 (k)): the hub may yet refuse the new
9251
+ * token for now and keep the last user, whose presence it would be.
9252
+ */
9253
+ async track(state) {
9254
+ const problem = this.#nameError();
9255
+ if (problem !== null) return unsent(problem);
9256
+ const joinRef = this.#joinRef;
9257
+ if (this.#state !== "joined" || joinRef === null)
9258
+ return unsent(notJoined(this.name));
9259
+ const payload = jsonObject(state);
9260
+ if (payload === null) return unsent(invalidPresence());
9261
+ this.#presenceCalls += 1;
9262
+ this.#clearRetrack();
9263
+ const letGo = this.#letGo;
9264
+ const reply = await this.#inTurn(
9265
+ joinRef,
9266
+ !this.#context.live(),
9267
+ () => this.#sendTrack(payload, joinRef, letGo, true)
9268
+ );
9269
+ if (reply === null) {
9270
+ if (letGo === this.#letGo) this.#letGoOfTrack();
9271
+ return unsent(notJoined(this.name));
9272
+ }
9273
+ return sent(reply);
9274
+ }
9275
+ /**
9276
+ * Takes this connection's presence off the channel: the hub's answer, or
9277
+ * none when not joined. After the app's tracks that wait, if any.
9278
+ */
9279
+ async untrack() {
9280
+ this.#presenceCalls += 1;
9281
+ this.#letGoOfTrack();
9282
+ const joinRef = this.#joinRef;
9283
+ if (this.#state !== "joined" || joinRef === null) return unsent(null);
9284
+ const reply = await this.#inTurn(
9285
+ joinRef,
9286
+ false,
9287
+ () => this.#context.socket.request(
9288
+ this.topic,
9289
+ "presence",
9290
+ { type: "presence", event: "untrack" },
9291
+ { joinRef }
9292
+ )
9293
+ );
9294
+ return reply === null ? unsent(null) : sent(reply);
9295
+ }
9296
+ /** The presences of the channel, by key (결정 16): a copy. */
9297
+ presenceState() {
9298
+ return this.#presence.state();
9299
+ }
9300
+ /**
9301
+ * @internal
9302
+ * The connection is live, or a leave owed is done (client.ts): join if
9303
+ * the app wants it and the channel holds no join and waits for no retry.
9304
+ * Called whenever either happens, for every channel, so a channel joined
9305
+ * or joining stays as it is.
9306
+ */
9307
+ rejoin() {
9308
+ if (this.#context.live()) this.#releaseWaiting();
9309
+ this.#joinIfDue();
9310
+ }
9311
+ /**
9312
+ * @internal
9313
+ * A leave of this name and privacy owed on the connection was refused, or
9314
+ * not answered in time, and goes again (client.ts): a join waiting on it
9315
+ * tells the app once a wait, as a refused join tried again does (결정 14,
9316
+ * 정정 (j)), and waits on.
9317
+ */
9318
+ leaveRefused(error) {
9319
+ if (this.#wanted && this.#joinRef === null)
9320
+ this.#tell(
9321
+ error.code === "TIMED_OUT" ? "TIMED_OUT" : "CHANNEL_ERROR",
9322
+ error
9323
+ );
9324
+ }
9325
+ /** @internal The connection dropped and comes back: tell the app once, rejoin when live. */
9326
+ dropped(error, status) {
9327
+ if (!this.#wanted) return;
9328
+ this.#clearRetry();
9329
+ this.#capWait = null;
9330
+ this.#joinRef = null;
9331
+ this.#state = "joining";
9332
+ this.#releaseWaiting();
9333
+ this.#presence.unsync();
9334
+ this.#tell(status, error);
9335
+ }
9336
+ /** @internal The connection stopped for good: the app must subscribe() again. */
9337
+ stopped(error) {
9338
+ if (this.#wanted) this.#stop(error);
9339
+ }
9340
+ /** @internal A frame of this channel's join (client.ts routes by join_ref). */
9341
+ receive(frame) {
9342
+ if (this.#state === "joining") {
9343
+ this.#early.push(frame);
9344
+ return;
9345
+ }
9346
+ switch (frame.event) {
9347
+ case "postgres_changes":
9348
+ return this.#change(frame.payload);
9349
+ case "broadcast":
9350
+ return this.#broadcast(frame.payload);
9351
+ case "presence_state":
9352
+ return this.#presence.applyState(frame.payload, this.#presenceEmit);
9353
+ case "presence_diff":
9354
+ return this.#presence.applyDiff(frame.payload, this.#presenceEmit);
9355
+ case "system":
9356
+ return this.#system(frame.payload);
9357
+ case "phx_close":
9358
+ return this.#serverClosed();
9359
+ default:
9360
+ return;
9361
+ }
9362
+ }
9363
+ /**
9364
+ * INVALID_CHANNEL for a name the hub would refuse, before anything is
9365
+ * sent — and before any error of ours quotes the name.
9366
+ */
9367
+ #nameError() {
9368
+ const problem = channelNameProblem(this.name);
9369
+ return problem === void 0 ? null : invalidChannel(problem);
9370
+ }
9371
+ #config(changes) {
9372
+ const config = this.options.config ?? {};
9373
+ const presence = config.presence ?? {};
9374
+ const key = presence.key ?? "";
9375
+ const listens = this.#bindings.some(
9376
+ (binding) => binding.type === "presence"
9377
+ );
9378
+ return {
9379
+ ...config,
9380
+ // ack is always on the wire, so send() always ends in the hub's answer (결정 6).
9381
+ broadcast: {
9382
+ ...config.broadcast,
9383
+ self: config.broadcast?.self ?? false,
9384
+ ack: true
9385
+ },
9386
+ presence: {
9387
+ ...presence,
9388
+ key,
9389
+ enabled: listens || presence.enabled === true || key !== ""
9390
+ },
9391
+ postgres_changes: changes.map((binding) => changeSpec(binding.filter)),
9392
+ private: this.isPrivate
9393
+ };
9394
+ }
9395
+ /**
9396
+ * Joins when due — once there is room on the connection for a frame the
9397
+ * SDK sends of its own accord (`whenRoom()`, PR #328 review), and still
9398
+ * due then.
9399
+ */
9400
+ #joinIfDue() {
9401
+ if (!this.#dueToJoin()) return;
9402
+ this.#context.socket.whenRoom(this.#joinTurn, () => {
9403
+ if (this.#dueToJoin()) this.#join();
9404
+ });
9405
+ }
9406
+ #dueToJoin() {
9407
+ return this.#wanted && this.#joinRef === null && this.#retry === void 0 && this.#context.live() && !this.#context.owesLeave(this) && !this.#heldByCap();
9408
+ }
9409
+ /** A cap refused the last join, and a leave owed still holds the place it needs. */
9410
+ #heldByCap() {
9411
+ const wait = this.#capWait;
9412
+ if (wait === null) return false;
9413
+ if (this.#context.owesLeaveOn(wait.table)) return true;
9414
+ this.#capWait = null;
9415
+ return false;
9416
+ }
9417
+ /**
9418
+ * The cap `error` names, when a leave owed on the connection holds a place
9419
+ * under it (PR #328 review): the hub still counts that join — 100
9420
+ * channels a connection (TOO_MANY_CHANNELS), or 4 on one table's changes
9421
+ * (INVALID_CHANNEL_CONFIG with the hub's `changeChannels` reason at
9422
+ * `config.postgres_changes[<i>].table`, the table of `changes[i]`) — and
9423
+ * lets go of it once the leave goes through. Null for any other refusal.
9424
+ */
9425
+ #capHeld(error, changes) {
9426
+ if (error.code === "TOO_MANY_CHANNELS")
9427
+ return this.#context.owesLeaveOn(null) ? { table: null } : null;
9428
+ if (error.code !== "INVALID_CHANNEL_CONFIG" || !isRecord(error.details))
9429
+ return null;
9430
+ const { path, reason } = error.details;
9431
+ if (reason !== CHANGE_CHANNELS_REASON || typeof path !== "string")
9432
+ return null;
9433
+ const index = /^config\.postgres_changes\[(\d+)\]\.table$/.exec(path)?.[1];
9434
+ const binding = index === void 0 ? void 0 : changes[Number(index)];
9435
+ if (binding === void 0) return null;
9436
+ const table3 = binding.filter.table ?? "*";
9437
+ return this.#context.owesLeaveOn(table3) ? { table: table3 } : null;
9438
+ }
9439
+ #join() {
9440
+ const changes = this.#bindings.filter(
9441
+ (b) => b.type === "postgres_changes"
9442
+ );
9443
+ const config = this.#config(changes);
9444
+ if (!isJson(config))
9445
+ return this.#stop(invalidChannelConfig("config", "is not JSON"));
9446
+ const socket = this.#context.socket;
9447
+ const ref = socket.nextRef();
9448
+ this.#joinRef = ref;
9449
+ this.#joinTables = tablesOf(changes);
9450
+ this.#capWait = null;
9451
+ this.#state = "joining";
9452
+ this.#closeError = null;
9453
+ this.#early = [];
9454
+ this.#presence.unsync();
9455
+ for (const binding of changes) binding.id = null;
9456
+ void socket.request(
9457
+ this.topic,
9458
+ "phx_join",
9459
+ { config },
9460
+ { joinRef: ref, ref, turn: "sdk" }
9461
+ ).then((reply) => this.#joined(ref, changes, reply));
9462
+ }
9463
+ #joined(ref, changes, reply) {
9464
+ if (this.#joinRef !== ref) return;
9465
+ const early = this.#early;
9466
+ this.#early = [];
9467
+ if (reply.ok) {
9468
+ const ids = Array.isArray(reply.response.postgres_changes) ? reply.response.postgres_changes : [];
9469
+ changes.forEach((binding, index) => {
9470
+ const entry = ids[index];
9471
+ binding.id = isRecord(entry) && typeof entry.id === "number" ? entry.id : null;
9472
+ });
9473
+ this.#state = "joined";
9474
+ this.#attempt = 0;
9475
+ this.#told = false;
9476
+ this.#context.socket.served();
9477
+ const calls = this.#presenceCalls;
9478
+ this.#status("SUBSCRIBED");
9479
+ if (this.#presenceCalls === calls) this.#retrack(ref);
9480
+ if (this.#joinRef === ref && presenceReadDenied(reply.response))
9481
+ this.#presence.leaveAll(this.#presenceEmit);
9482
+ for (const frame of early) {
9483
+ if (this.#joinRef !== ref || this.#state !== "joined") break;
9484
+ this.receive(frame);
9485
+ }
9486
+ return;
9487
+ }
9488
+ if (reply.lost) {
9489
+ this.#joinRef = null;
9490
+ return;
9491
+ }
9492
+ const { error } = reply;
9493
+ this.#joinRef = null;
9494
+ const capHeld = this.#capHeld(error, changes);
9495
+ if (capHeld !== null) {
9496
+ this.#capWait = capHeld;
9497
+ this.#state = "joining";
9498
+ this.#tell("CHANNEL_ERROR", error);
9499
+ return;
9500
+ }
9501
+ const meetsOld = error.code === "ALREADY_JOINED" && this.#context.owesLeave(this);
9502
+ if (!JOIN_RETRY_CODES.has(error.code) && !meetsOld) {
9503
+ this.#wanted = false;
9504
+ this.#state = "errored";
9505
+ this.#presence.leaveAll(this.#presenceEmit);
9506
+ this.#status("CHANNEL_ERROR", error);
9507
+ return;
9508
+ }
9509
+ if (error.code === "TIMED_OUT")
9510
+ void this.#context.leave(this, ref, tablesOf(changes));
9511
+ this.#tell(
9512
+ error.code === "TIMED_OUT" ? "TIMED_OUT" : "CHANNEL_ERROR",
9513
+ error
9514
+ );
9515
+ this.#state = "joining";
9516
+ const delay = backoffMs(this.#attempt, Math.random);
9517
+ this.#attempt += 1;
9518
+ this.#retry = setTimeout(() => {
9519
+ this.#retry = void 0;
9520
+ this.#joinIfDue();
9521
+ }, delay);
9522
+ }
9523
+ #change(payload) {
9524
+ const ids = Array.isArray(payload.ids) ? payload.ids : [];
9525
+ if (!isRecord(payload.data)) return;
9526
+ const change2 = changePayload(payload.data);
9527
+ for (const binding of this.#bindings)
9528
+ if (binding.type === "postgres_changes" && binding.id !== null && ids.includes(binding.id))
9529
+ safely(() => binding.callback(change2));
9530
+ }
9531
+ #broadcast(payload) {
9532
+ const { event } = payload;
9533
+ if (typeof event !== "string") return;
9534
+ const message = {
9535
+ type: "broadcast",
9536
+ event,
9537
+ payload: isRecord(payload.payload) ? payload.payload : {}
9538
+ };
9539
+ for (const binding of this.#bindings)
9540
+ if (binding.type === "broadcast" && binding.event === event)
9541
+ safely(() => binding.callback(message));
9542
+ }
9543
+ #system(payload) {
9544
+ const message = systemMessage(payload);
9545
+ if (message.status === "error" && message.extension === "system")
9546
+ this.#closeError = asError(payload);
9547
+ for (const binding of this.#bindings)
9548
+ if (binding.type === "system") safely(() => binding.callback(message));
9549
+ }
9550
+ /** The hub closed this join (lost access, its state full): stop until subscribe(). */
9551
+ #serverClosed() {
9552
+ this.#clearRetry();
9553
+ this.#joinRef = null;
9554
+ this.#wanted = false;
9555
+ this.#state = "errored";
9556
+ this.#letGoOfTrack();
9557
+ this.#releaseWaiting();
9558
+ this.#presence.leaveAll(this.#presenceEmit);
9559
+ this.#status("CHANNEL_ERROR", this.#closeError ?? channelClosed());
9560
+ }
9561
+ /** The listeners' payloads, as supabase-js's: each says which event it is (정정 (k)). */
9562
+ #presenceEmit = {
9563
+ join: (key, newPresences, currentPresences) => {
9564
+ const payload = {
9565
+ event: "join",
9566
+ key,
9567
+ newPresences,
9568
+ currentPresences
9569
+ };
9570
+ this.#presenceCall("join", payload);
9571
+ },
9572
+ leave: (key, leftPresences, currentPresences) => {
9573
+ const payload = {
9574
+ event: "leave",
9575
+ key,
9576
+ leftPresences,
9577
+ currentPresences
9578
+ };
9579
+ this.#presenceCall("leave", payload);
9580
+ },
9581
+ sync: () => this.#presenceCall("sync", void 0)
9582
+ };
9583
+ #presenceCall(event, value) {
9584
+ for (const binding of this.#bindings)
9585
+ if (binding.type === "presence" && binding.event === event)
9586
+ safely(() => binding.callback(value));
9587
+ }
9588
+ /**
9589
+ * One track frame on the join `joinRef`. Its `ok` makes `state` the one
9590
+ * tracked, with the token the hub judged it for — the one it holds once
9591
+ * the identity frames sent before it are handled (`heldToken()`, 정정 (j))
9592
+ * — unless what was tracked was let go of since `letGo` (the app's call,
9593
+ * or now): the hub had that untrack or leave after it. The app's own call
9594
+ * (`app`) is kept as well when no answer came — the connection went with
9595
+ * it, or none in time: the hub may hold it, and it is the state the app
9596
+ * last asked for, so the next join sends it rather than the one before
9597
+ * (PR #328 review). The app's call waits its turn behind the app's frames,
9598
+ * and may be refused for it (CLIENT_FLOODING, nothing kept); one sent
9599
+ * again goes in the SDK's (PR #328 re-review).
9600
+ */
9601
+ #sendTrack(state, joinRef, letGo = this.#letGo, app = false) {
9602
+ const token = this.#context.identity.heldToken();
9603
+ return this.#context.socket.request(
9604
+ this.topic,
9605
+ "presence",
9606
+ { type: "presence", event: "track", payload: state },
9607
+ { joinRef, turn: app ? "app" : "sdk" }
9608
+ ).then((reply) => {
9609
+ const kept = reply.ok || app && (reply.lost || reply.error.code === "TIMED_OUT");
9610
+ if (kept && letGo === this.#letGo) this.#tracked = { state, token };
9611
+ return reply;
9612
+ });
9613
+ }
9614
+ /**
9615
+ * After a join comes back, the last state goes again (original spec §11.1)
9616
+ * — only as the same session: the hub drops a presence when its user
9617
+ * changes (3-2a P10), and a new token here may be someone else (결정 17).
9618
+ * Who the hub reads it as is the token it will hold when the frame comes
9619
+ * (`heldToken()`): a sign-in whose token went after the join would make
9620
+ * the last user's state the new one's. And while who the connection is
9621
+ * is not settled (a store read, a retry), it is let go of (정정 (j)).
9622
+ * A refusal for now (`RETRACK_RETRY_CODES`) sends it again on this join
9623
+ * after the channel backoff, checked as above each time, until the app
9624
+ * tracks or untracks, the channel leaves, or the join goes — after one
9625
+ * the hub warned the connection for, three times at most, and then it
9626
+ * waits for the next join (`WARNED_CODES`); any other refusal lets go of
9627
+ * it; a connection lost before the answer keeps it for the next join.
9628
+ * Each send, the first too, waits its turn for room in the connection's
9629
+ * warnings (`whenRoom()`, PR #328 review).
9630
+ */
9631
+ #retrack(joinRef) {
9632
+ const tracked = this.#tracked;
9633
+ if (tracked === null) return;
9634
+ const calls = this.#presenceCalls;
9635
+ const current = () => this.#tracked === tracked && this.#joinRef === joinRef && this.#presenceCalls === calls;
9636
+ const go = (attempt22, warned) => {
9637
+ if (!current()) return;
9638
+ this.#context.socket.whenRoom(this.#retrackTurn, () => {
9639
+ send2(attempt22, warned);
9640
+ });
9641
+ };
9642
+ const send2 = (attempt22, warned) => {
9643
+ if (!current()) return;
9644
+ if (!this.#context.live() || tracked.token !== this.#context.identity.heldToken())
9645
+ return this.#letGoOfTrack();
9646
+ void this.#sendTrack(tracked.state, joinRef).then((reply) => {
9647
+ if (reply.ok || reply.lost || this.#tracked !== tracked) return;
9648
+ const { code } = reply.error;
9649
+ if (!RETRACK_RETRY_CODES.has(code)) {
9650
+ this.#tracked = null;
9651
+ return;
9652
+ }
9653
+ if (!current()) return;
9654
+ const warning = WARNED_CODES.has(code) ? 1 : 0;
9655
+ if (warned + warning > RETRACK_WARNED_RESENDS) return;
9656
+ const delay = backoffMs(attempt22, Math.random);
9657
+ this.#retrackTimer = setTimeout(() => {
9658
+ this.#retrackTimer = void 0;
9659
+ go(attempt22 + 1, warned + warning);
9660
+ }, delay);
9661
+ });
9662
+ };
9663
+ go(0, 0);
9664
+ }
9665
+ /**
9666
+ * `send` now — or, when `wait` (a track or a broadcast while the
9667
+ * connection is not live) or an earlier call of the app's waits, at
9668
+ * `#releaseWaiting()`, in the order the app made them: the hub must have
9669
+ * them in that order. Null when the join went before its turn: nothing
9670
+ * was sent.
9671
+ */
9672
+ #inTurn(joinRef, wait, send2) {
9673
+ if (!wait && this.#waiting.length === 0) return send2();
9674
+ return new Promise((resolve7) => {
9675
+ this.#waiting.push(
9676
+ () => resolve7(this.#joinRef === joinRef ? send2() : null)
9677
+ );
9678
+ });
9679
+ }
9680
+ /**
9681
+ * The calls that waited go now, in order and all in this turn — a call
9682
+ * made after them must not go first. Called when the connection is live
9683
+ * again, and when the join goes (they answer that it went).
9684
+ */
9685
+ #releaseWaiting() {
9686
+ const waiting = this.#waiting;
9687
+ this.#waiting = [];
9688
+ for (const go of waiting) go();
9689
+ }
9690
+ /** Nothing is tracked from now on, whatever answer comes for a track sent before. */
9691
+ #letGoOfTrack() {
9692
+ this.#tracked = null;
9693
+ this.#letGo += 1;
9694
+ this.#clearRetrack();
9695
+ }
9696
+ #clearRetrack() {
9697
+ clearTimeout(this.#retrackTimer);
9698
+ this.#retrackTimer = void 0;
9699
+ }
9700
+ /**
9701
+ * Stops until subscribe(): no join, no retry, and the app hears `error`.
9702
+ * No presence_state comes until then, so what the app saw goes, as for a
9703
+ * join refused or the server's close (정정 (k)); what was tracked stays,
9704
+ * for a join of the same session after subscribe().
9705
+ */
9706
+ #stop(error) {
9707
+ this.#clearRetry();
9708
+ this.#joinRef = null;
9709
+ this.#wanted = false;
9710
+ this.#state = "errored";
9711
+ this.#releaseWaiting();
9712
+ this.#presence.leaveAll(this.#presenceEmit);
9713
+ this.#toldStop(error);
9714
+ }
9715
+ /**
9716
+ * CHANNEL_ERROR for a stop. Inside subscribe() it comes a microtask later,
9717
+ * as for an invalid name: in `const ch = naro.channel(…).subscribe(cb)`,
9718
+ * `cb` may read `ch`, which is not set until subscribe() returns.
9719
+ */
9720
+ #toldStop(error) {
9721
+ if (this.#subscribing)
9722
+ queueMicrotask(() => this.#status("CHANNEL_ERROR", error));
9723
+ else this.#status("CHANNEL_ERROR", error);
9724
+ }
9725
+ #tell(status, error) {
9726
+ if (this.#told) return;
9727
+ this.#told = true;
9728
+ this.#status(status, error);
9729
+ }
9730
+ #status(status, error) {
9731
+ const callback = this.#onStatus;
9732
+ if (callback !== null) safely(() => callback(status, error));
9733
+ }
9734
+ /** No join retry waits — nor a track sent again on a join that is going. */
9735
+ #clearRetry() {
9736
+ clearTimeout(this.#retry);
9737
+ this.#retry = void 0;
9738
+ this.#clearRetrack();
9739
+ }
9740
+ };
9741
+ var RETRY_CODES = /* @__PURE__ */ new Set([
9742
+ "TOO_MANY_JOINS",
9743
+ "TIMED_OUT",
9744
+ "INTERNAL_ERROR"
9745
+ ]);
9746
+ function rejudgeWaitMs(privateChannels) {
9747
+ return Math.max(1, Math.ceil(privateChannels / 2)) * 1e3;
9748
+ }
9749
+ var Identity = class {
9750
+ #config;
9751
+ /** The token the hub answered `ok` for on this connection; null is anon. */
9752
+ #confirmed = null;
9753
+ /**
9754
+ * The last frame sent, until its own reply; null when none is in flight.
9755
+ * Matched by the object, not the token: an older frame of the same token
9756
+ * answered meanwhile is not this one.
9757
+ */
9758
+ #inFlight = null;
9759
+ /**
9760
+ * The frame the hub answered INVALID_SESSION: the 4401 that follows is
9761
+ * about its token. An object, since that token is null when the frame was
9762
+ * a sign-out — a refusal still, and not "nothing refused".
9763
+ */
9764
+ #refused = null;
9765
+ /**
9766
+ * A frame got no answer in time (TIMED_OUT), and no `ok` came since: the
9767
+ * hub may have taken it late, so what it holds is not known, and a read
9768
+ * with no frame in flight tells the stored token even when it is
9769
+ * `heldToken()` (정정 (k)). An `ok` says what it holds — replies come in
9770
+ * the order the frames went, so it is about a later frame.
9771
+ */
9772
+ #unsure = false;
9773
+ /** Bumped when a connection ends: a reply about an earlier one changes nothing. */
9774
+ #epoch = 0;
9775
+ #attempt = 0;
9776
+ #retry;
9777
+ /** The socket's generation opened() was called for; null once it ended. */
9778
+ #connection = null;
9779
+ /** changed() calls of this connection still reading the store. */
9780
+ #reading = 0;
9781
+ /** onLive was called, and the connection has been live since. */
9782
+ #announced = false;
9783
+ constructor(config) {
9784
+ this.#config = config;
9785
+ }
9786
+ /** The token the hub took on this connection; null for anon or before its answer. */
9787
+ confirmed() {
9788
+ return this.#confirmed;
9789
+ }
9790
+ /**
9791
+ * The token the hub will hold once what was sent is handled — the last
9792
+ * frame in flight, or else the one it took: the hub handles the
9793
+ * connection's frames in order, so a frame sent now is judged for it.
9794
+ * A frame in flight may yet be refused; the hub then keeps `confirmed()`.
9795
+ */
9796
+ heldToken() {
9797
+ return this.#inFlight === null ? this.#confirmed : this.#inFlight.token;
9798
+ }
9799
+ /**
9800
+ * Nothing about who the connection is is pending — no read, no frame in
9801
+ * flight, no retry, no refusal: a join sent now is judged for the identity
9802
+ * the hub settled on, the stored one unless it refused that for good on
9803
+ * this connection (결정 24).
9804
+ */
9805
+ isLive() {
9806
+ const socket = this.#config.socket();
9807
+ return this.#connection === socket.generation && socket.isOpen() && this.#reading === 0 && this.#inFlight === null && this.#retry === void 0 && this.#refused === null;
9808
+ }
9809
+ /**
9810
+ * A new connection: anon until told. Resolves once the stored token is
9811
+ * sent, or found to need no frame; `onLive` says when the hub took it.
9812
+ */
9813
+ async opened() {
9814
+ this.closed();
9815
+ this.#connection = this.#config.socket().generation;
9816
+ await this.changed();
9817
+ }
9818
+ /** The stored token may have changed: tell the open connection what is new. */
9819
+ async changed() {
9820
+ if (this.#config.service) return this.#settle();
9821
+ const epoch = this.#epoch;
9822
+ this.#reading += 1;
9823
+ this.#announced = false;
9824
+ try {
9825
+ await this.#config.ready();
9826
+ } catch {
9827
+ }
9828
+ const token = await this.#config.tokens.get();
9829
+ if (epoch === this.#epoch) this.#reading -= 1;
9830
+ if (this.#config.socket().isOpen()) {
9831
+ const unknown = this.#unsure && this.#inFlight === null;
9832
+ if (unknown || token !== this.heldToken()) this.#send(token);
9833
+ }
9834
+ this.#settle();
9835
+ }
9836
+ /**
9837
+ * A 4401: forget the stored session only if the hub answered
9838
+ * INVALID_SESSION to it by name, and it is still stored — a sign-in since
9839
+ * then keeps its new token (결정 3). A 4401 with no such answer clears
9840
+ * nothing (plan 2026-10-28 정정): the hub keeps the expiry it read when it
9841
+ * took the token (known-issues #183) while the backend's sign-in slides
9842
+ * the session's under the same token, so a tab open for days is closed
9843
+ * with a session that is still good. The next connection tells the token
9844
+ * again, and hears INVALID_SESSION — and clears it — if it really is
9845
+ * dead. The access JWT goes with the session it was minted for, so only
9846
+ * when that session went.
9847
+ */
9848
+ async refused() {
9849
+ const epoch = this.#epoch;
9850
+ const early = this.#refused;
9851
+ await Promise.resolve();
9852
+ const refusal = this.#epoch === epoch ? this.#refused : early;
9853
+ this.closed();
9854
+ if (refusal === null || refusal.token === null) return;
9855
+ if (await this.#config.tokens.clearIf(refusal.token))
9856
+ await this.#config.accessJwt?.clear();
9857
+ }
9858
+ /** The connection is gone: what it was told is forgotten. */
9859
+ closed() {
9860
+ clearTimeout(this.#retry);
9861
+ this.#retry = void 0;
9862
+ this.#epoch += 1;
9863
+ this.#confirmed = null;
9864
+ this.#inFlight = null;
9865
+ this.#refused = null;
9866
+ this.#unsure = false;
9867
+ this.#attempt = 0;
9868
+ this.#connection = null;
9869
+ this.#reading = 0;
9870
+ this.#announced = false;
9871
+ }
9872
+ /** Live, and not said yet since it last was not: say it. */
9873
+ #settle() {
9874
+ if (this.#announced || !this.isLive()) return;
9875
+ this.#announced = true;
9876
+ safely(() => this.#config.onLive());
9877
+ }
9878
+ #send(token) {
9879
+ clearTimeout(this.#retry);
9880
+ this.#retry = void 0;
9881
+ const sent2 = { token };
9882
+ this.#inFlight = sent2;
9883
+ const epoch = this.#epoch;
9884
+ void this.#config.socket().request(
9885
+ PHOENIX_TOPIC,
9886
+ "access_token",
9887
+ { access_token: token },
9888
+ { turn: "identity" }
9889
+ ).then((reply) => {
9890
+ if (epoch === this.#epoch) this.#heard(sent2, reply);
9891
+ });
9892
+ }
9893
+ #heard(sent2, reply) {
9894
+ if (this.#inFlight === sent2) this.#inFlight = null;
9895
+ if (reply.ok) {
9896
+ this.#confirmed = sent2.token;
9897
+ this.#unsure = false;
9898
+ this.#attempt = 0;
9899
+ return this.#settle();
9900
+ }
9901
+ if (reply.lost) return;
9902
+ if (reply.error.code === "TIMED_OUT") this.#unsure = true;
9903
+ if (reply.error.code === "INVALID_SESSION") {
9904
+ this.#refused = sent2;
9905
+ return;
9906
+ }
9907
+ if (this.#inFlight !== null) return;
9908
+ if (!RETRY_CODES.has(reply.error.code)) {
9909
+ if (this.#confirmed !== null && sent2.token !== this.#confirmed) {
9910
+ this.closed();
9911
+ this.#config.socket().reconnect();
9912
+ return;
9913
+ }
9914
+ return this.#settle();
9915
+ }
9916
+ this.#later(
9917
+ reply.error.code === "TOO_MANY_JOINS" ? rejudgeWaitMs(this.#config.privateChannels()) : 0
9918
+ );
9919
+ }
9920
+ /**
9921
+ * Reads the store again after the backoff (at least `minMs`), once there
9922
+ * is room on the connection for a frame the SDK sends of its own accord
9923
+ * (`whenRoom()`, PR #328 review). `#retry` stays set until then: the
9924
+ * connection is not live while it waits.
9925
+ */
9926
+ #later(minMs) {
9927
+ clearTimeout(this.#retry);
9928
+ const delay = Math.max(minMs, backoffMs(this.#attempt, Math.random));
9929
+ this.#attempt += 1;
9930
+ const epoch = this.#epoch;
9931
+ this.#retry = setTimeout(() => {
9932
+ this.#config.socket().whenRoom(this, () => {
9933
+ if (epoch !== this.#epoch) return;
9934
+ this.#retry = void 0;
9935
+ void this.changed();
9936
+ });
9937
+ }, delay);
9938
+ }
9939
+ };
9940
+ var LEAVE_RETRY_CODES = /* @__PURE__ */ new Set([
9941
+ "TOO_MANY_JOINS",
9942
+ "INTERNAL_ERROR",
9943
+ "CONNECTION_STATE_TOO_LARGE",
9944
+ "TIMED_OUT"
9945
+ ]);
9946
+ function sameSettings(a, b) {
9947
+ if (a === b) return true;
9948
+ try {
9949
+ return JSON.stringify(a) === JSON.stringify(b);
9950
+ } catch {
9951
+ return false;
9952
+ }
9953
+ }
9954
+ function sameJoin(a, b) {
9955
+ return a.name === b.name && a.isPrivate === b.isPrivate;
9956
+ }
9957
+ function foldTable(table3) {
9958
+ return table3.replaceAll(/[A-Z]/g, (letter) => letter.toLowerCase());
9959
+ }
9960
+ var RealtimeClient = class {
9961
+ #http;
9962
+ #socket;
9963
+ #identity;
9964
+ #context;
9965
+ #channels = [];
9966
+ /** The leaves owed on the open connection (정정 (i)); forgotten with it. */
9967
+ #owed = /* @__PURE__ */ new Set();
9968
+ #onStorage = null;
9969
+ constructor(input) {
9970
+ const { http, auth, service, options } = input;
9971
+ this.#http = http;
9972
+ this.#socket = new RealtimeSocket({
9973
+ baseUrl: http.baseUrl,
9974
+ apiKey: http.apiKey,
9975
+ service,
9976
+ options,
9977
+ events: {
9978
+ // Not awaited, and not live yet: onLive says when the hub took it.
9979
+ opened: () => {
9980
+ this.#watchStorage();
9981
+ void this.#identity.opened();
9982
+ },
9983
+ frame: (frame) => this.#route(frame),
9984
+ // Only the leaves and the channels: the identity is closed by the
9985
+ // next opened() (the 4401's refused() reads it after this turn —
9986
+ // 정정 (f)).
9987
+ dropped: (error, status) => {
9988
+ this.#forgetLeaves();
9989
+ this.#each((channel) => channel.dropped(error, status));
9990
+ },
9991
+ stopped: (error) => this.#stopped(error),
9992
+ sessionRefused: () => this.#identity.refused()
9993
+ }
9994
+ });
9995
+ this.#identity = new Identity({
9996
+ tokens: http.tokens,
9997
+ accessJwt: http.accessJwt,
9998
+ service,
9999
+ socket: () => this.#socket,
10000
+ privateChannels: () => this.#channels.filter(
10001
+ (channel) => channel.isPrivate && channel.state === "joined"
10002
+ ).length,
10003
+ onLive: () => this.#each((channel) => channel.rejoin()),
10004
+ // A sign-in the page URL carries goes before any join (final review A I1).
10005
+ ready: () => auth.ready()
10006
+ });
10007
+ this.#context = {
10008
+ socket: this.#socket,
10009
+ identity: this.#identity,
10010
+ http,
10011
+ auth,
10012
+ connect: () => this.#socket.connect(),
10013
+ live: () => this.#identity.isLive(),
10014
+ leave: (channel, joinRef, tables) => this.#leave(channel, joinRef, tables),
10015
+ owesLeave: (channel) => this.#owesLeave(channel),
10016
+ owesLeaveOn: (table3) => this.#owesLeaveOn(table3)
10017
+ };
10018
+ http.tokens.subscribe(() => void this.#identity.changed());
10019
+ }
10020
+ channel(name, options = {}) {
10021
+ const settings = isRecord(options) ? options : {};
10022
+ const isPrivate = settings.config?.private === true;
10023
+ const found = this.#channels.find(
10024
+ (c) => c.name === name && c.isPrivate === isPrivate
10025
+ );
10026
+ if (found !== void 0) {
10027
+ if (!sameSettings(found.options, settings))
10028
+ warnInDevelopment(
10029
+ `naro-js: channel(${JSON.stringify(name)}) already exists with other settings; it keeps the first ones until removeChannel().`
10030
+ );
10031
+ return found;
10032
+ }
10033
+ const channel = new RealtimeChannel(
10034
+ name,
10035
+ settings,
10036
+ this.#context
10037
+ );
10038
+ this.#channels.push(channel);
10039
+ return channel;
10040
+ }
10041
+ getChannels() {
10042
+ return [...this.#channels];
10043
+ }
10044
+ async removeChannel(channel) {
10045
+ this.#channels = this.#channels.filter((c) => c !== channel);
10046
+ return this.#closeIfLast(await channel.unsubscribe());
10047
+ }
10048
+ async removeAllChannels() {
10049
+ const channels = this.#channels;
10050
+ this.#channels = [];
10051
+ const results = await Promise.all(
10052
+ channels.map((channel) => channel.unsubscribe())
10053
+ );
10054
+ return this.#closeIfLast({
10055
+ data: null,
10056
+ error: results.find((result) => result.error !== null)?.error ?? null
10057
+ });
10058
+ }
10059
+ /**
10060
+ * No channel left (the app may have added one meanwhile): the connection
10061
+ * closes. That is the strongest leave — the hub forgets every join with
10062
+ * its connection — so a leave's refusal no longer stands (정정 (j)).
10063
+ */
10064
+ #closeIfLast(result) {
10065
+ if (this.#channels.length > 0) return result;
10066
+ this.#disconnect();
10067
+ return { data: null, error: null };
10068
+ }
10069
+ /**
10070
+ * Each channel in turn, over a copy (a callback may add or remove one);
10071
+ * what one throws does not keep the next from hearing (결정 22).
10072
+ */
10073
+ #each(action) {
10074
+ for (const channel of Array.from(this.#channels))
10075
+ safely(() => action(channel));
10076
+ }
10077
+ #route(frame) {
10078
+ if (frame.topic === PHOENIX_TOPIC || frame.joinRef === null) return;
10079
+ const channel = this.#channels.find(
10080
+ (c) => c.joinRef === frame.joinRef && c.topic === frame.topic
10081
+ );
10082
+ channel?.receive(frame);
10083
+ }
10084
+ #stopped(error) {
10085
+ this.#identity.closed();
10086
+ this.#forgetLeaves();
10087
+ this.#unwatchStorage();
10088
+ this.#each((channel) => channel.stopped(error));
10089
+ }
10090
+ #disconnect() {
10091
+ this.#socket.disconnect();
10092
+ this.#identity.closed();
10093
+ this.#forgetLeaves();
10094
+ this.#unwatchStorage();
10095
+ }
10096
+ #leave(channel, joinRef, tables) {
10097
+ const owed = {
10098
+ channel,
10099
+ joinRef,
10100
+ tables,
10101
+ attempt: 0,
10102
+ timer: void 0
10103
+ };
10104
+ this.#owed.add(owed);
10105
+ return this.#sendLeave(owed, "app");
10106
+ }
10107
+ /**
10108
+ * One `phx_leave` of `owed`; its answer decides whether it is done,
10109
+ * whoever else awaits it. The first goes in the app's turn, after the
10110
+ * frames the app sent on the join before it (and is never refused for
10111
+ * it); one tried again, in the SDK's (PR #328 re-review).
10112
+ */
10113
+ #sendLeave(owed, turn) {
10114
+ const { channel, joinRef } = owed;
10115
+ const reply = this.#socket.request(
10116
+ channel.topic,
10117
+ "phx_leave",
10118
+ {},
10119
+ { joinRef, turn }
10120
+ );
10121
+ void reply.then((answer) => this.#leaveAnswered(owed, answer));
10122
+ return reply;
10123
+ }
10124
+ #leaveAnswered(owed, reply) {
10125
+ if (!this.#owed.has(owed)) return;
10126
+ if (!reply.ok && !reply.lost && LEAVE_RETRY_CODES.has(reply.error.code)) {
10127
+ const delay = backoffMs(owed.attempt, Math.random);
10128
+ owed.attempt += 1;
10129
+ owed.timer = setTimeout(() => {
10130
+ owed.timer = void 0;
10131
+ this.#socket.whenRoom(owed, () => {
10132
+ if (this.#owed.has(owed)) void this.#sendLeave(owed, "sdk");
10133
+ });
10134
+ }, delay);
10135
+ const { error } = reply;
10136
+ this.#each((channel) => {
10137
+ if (sameJoin(channel, owed.channel)) channel.leaveRefused(error);
10138
+ });
10139
+ return;
10140
+ }
10141
+ this.#owed.delete(owed);
10142
+ this.#each((channel) => channel.rejoin());
10143
+ }
10144
+ #owesLeave(channel) {
10145
+ for (const owed of this.#owed)
10146
+ if (sameJoin(owed.channel, channel)) return true;
10147
+ return false;
10148
+ }
10149
+ /** `ChannelContext.owesLeaveOn`: the hub's caps count the joins whose leaves are owed. */
10150
+ #owesLeaveOn(table3) {
10151
+ for (const owed of this.#owed) {
10152
+ if (table3 === null) return true;
10153
+ const key = foldTable(table3);
10154
+ if (owed.tables.some(
10155
+ (held) => held === "*" || key === "*" || foldTable(held) === key
10156
+ ))
10157
+ return true;
10158
+ }
10159
+ return false;
10160
+ }
10161
+ #forgetLeaves() {
10162
+ for (const owed of this.#owed) clearTimeout(owed.timer);
10163
+ this.#owed.clear();
10164
+ }
10165
+ /**
10166
+ * Another tab signing in or out writes the same localStorage key: read the
10167
+ * store again while the connection lives (결정 5) — from its first open to
10168
+ * a stop or the last channel's removal, drops included. Reading again
10169
+ * sends nothing unless the stored token is not what the hub has, so a
10170
+ * storage of the app's own (`auth.storage`), whose writes this event
10171
+ * never reports, costs a read at most.
10172
+ */
10173
+ #watchStorage() {
10174
+ const target = globalThis;
10175
+ if (this.#onStorage !== null || !isBrowser() || typeof target.addEventListener !== "function")
10176
+ return;
10177
+ const key = storageKey(this.#http.baseUrl);
10178
+ this.#onStorage = (event) => {
10179
+ if (event.key === key || event.key === null)
10180
+ void this.#identity.changed();
10181
+ };
10182
+ target.addEventListener("storage", this.#onStorage);
10183
+ }
10184
+ #unwatchStorage() {
10185
+ const target = globalThis;
10186
+ if (this.#onStorage === null) return;
10187
+ target.removeEventListener?.("storage", this.#onStorage);
10188
+ this.#onStorage = null;
10189
+ }
10190
+ };
10191
+ function fail(error) {
10192
+ return { data: null, error };
10193
+ }
10194
+ var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
10195
+ function dataOf2(status, body, guard) {
10196
+ if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
10197
+ return { data: body.data, error: null };
10198
+ return fail(invalidResponse(status));
10199
+ }
10200
+ async function readJson(response, guard) {
10201
+ let text;
10202
+ try {
10203
+ text = await response.text();
10204
+ } catch (error) {
10205
+ return fail(networkError(error));
10206
+ }
10207
+ let body;
10208
+ try {
10209
+ body = JSON.parse(text);
10210
+ } catch {
10211
+ return fail(invalidResponse(response.status));
10212
+ }
10213
+ return dataOf2(response.status, body, guard);
10214
+ }
10215
+ var ROUTE = "/storage/upload/multipart";
10216
+ var UPLOAD_TOKEN_HEADER = "x-naro-upload-token";
10217
+ var CONCURRENCY = 4;
10218
+ var ATTEMPTS = 4;
10219
+ var BACKOFF_MS = [1e3, 2e3, 4e3];
10220
+ var UPLOAD_ID = /^[\w-][\w.-]*$/;
10221
+ var positive = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
10222
+ var planFor = (size) => (value) => isRecord(value) && typeof value.uploadId === "string" && UPLOAD_ID.test(value.uploadId) && positive(value.partSize) && positive(value.partCount) && (value.partCount - 1) * value.partSize < size && value.partCount * value.partSize >= size;
10223
+ var partAnswer = (n) => (value) => isRecord(value) && value.partNumber === n && typeof value.etag === "string" && value.etag !== "";
10224
+ var retryable = (error, status) => error.code === "NETWORK_ERROR" || error.code === "OBJECT_RATE_LIMITED" || status !== null && status >= 500 && error.code !== "STORAGE_QUOTA_EXCEEDED";
10225
+ function waitAfter(error, attempt22) {
10226
+ const after = isRecord(error.details) ? error.details.retryAfter : void 0;
10227
+ if (typeof after === "number") return after * 1e3;
10228
+ if (error.code === "OBJECT_RATE_LIMITED") return 1e3;
10229
+ return BACKOFF_MS[attempt22 - 1] ?? 4e3;
10230
+ }
10231
+ var sleepFor = (ms, signal) => new Promise((done) => {
10232
+ if (signal?.aborted) {
10233
+ done();
10234
+ return;
10235
+ }
10236
+ const end = () => {
10237
+ clearTimeout(timer);
10238
+ signal?.removeEventListener("abort", end);
10239
+ done();
10240
+ };
10241
+ const timer = setTimeout(end, ms);
10242
+ signal?.addEventListener("abort", end);
10243
+ });
10244
+ async function uploadInParts(a) {
10245
+ const { signal } = a;
10246
+ const sleep2 = a.sleep ?? sleepFor;
10247
+ const signed = a.uploadToken === void 0 ? {} : { [UPLOAD_TOKEN_HEADER]: a.uploadToken };
10248
+ const withToken = a.uploadToken === void 0;
10249
+ if (signal?.aborted) return fail(uploadAborted());
10250
+ const started = await call(a.http, {
10251
+ method: a.method,
10252
+ path: ROUTE,
10253
+ body: {
10254
+ bucket: a.bucket,
10255
+ path: a.path,
10256
+ size: a.size,
10257
+ contentType: a.contentType,
10258
+ cacheControl: a.cacheControl,
10259
+ // PUT always replaces: the worker refuses `upsert` on it.
10260
+ ...a.method === "POST" && a.upsert ? { upsert: true } : {}
10261
+ },
10262
+ headers: signed,
10263
+ withToken
10264
+ });
10265
+ if (started.error !== null) return fail(started.error);
10266
+ const plan = dataOf2(
10267
+ started.answer.status,
10268
+ started.answer.body,
10269
+ planFor(a.size)
10270
+ );
10271
+ if (plan.error !== null) return fail(plan.error);
10272
+ const { uploadId, partSize, partCount } = plan.data;
10273
+ const base = `${ROUTE}/${uploadId}`;
10274
+ const progress = (sentBytes2) => {
10275
+ if (signal?.aborted) return;
10276
+ try {
10277
+ const result = a.onProgress?.(sentBytes2, a.size);
10278
+ if (isThenable(result)) result.then(void 0, () => void 0);
10279
+ } catch {
10280
+ }
10281
+ };
10282
+ const callOff = async () => {
10283
+ await call(a.http, {
10284
+ method: "DELETE",
10285
+ path: base,
10286
+ headers: signed,
10287
+ withToken
10288
+ }).catch(() => void 0);
10289
+ };
10290
+ const etags = [];
10291
+ const failure = {
10292
+ error: signal?.aborted ? uploadAborted() : null
7997
10293
  };
7998
10294
  let next = 1;
7999
10295
  let sentBytes = 0;
@@ -8001,7 +10297,7 @@ async function uploadInParts(a) {
8001
10297
  failure.error ??= uploadAborted();
8002
10298
  };
8003
10299
  const tryPart = async (n, bytes) => {
8004
- const sent = await send(a.http, {
10300
+ const sent2 = await send(a.http, {
8005
10301
  method: "PUT",
8006
10302
  path: `${base}/${String(n)}`,
8007
10303
  headers: { "content-type": "application/octet-stream", ...signed },
@@ -8009,10 +10305,10 @@ async function uploadInParts(a) {
8009
10305
  withToken,
8010
10306
  signal
8011
10307
  });
8012
- if (sent.error !== null) return sent;
8013
- const read = await readJson(sent.response, partAnswer(n));
10308
+ if (sent2.error !== null) return sent2;
10309
+ const read = await readJson(sent2.response, partAnswer(n));
8014
10310
  if (read.error !== null)
8015
- return { error: read.error, status: sent.response.status };
10311
+ return { error: read.error, status: sent2.response.status };
8016
10312
  return { etag: read.data.etag };
8017
10313
  };
8018
10314
  const sendPart = async (n) => {
@@ -8299,7 +10595,7 @@ var StorageFileApi = class {
8299
10595
  "cache-control": cache
8300
10596
  };
8301
10597
  if (upsert) headers["x-upsert"] = "true";
8302
- const sent = await send(this.#http, {
10598
+ const sent2 = await send(this.#http, {
8303
10599
  method,
8304
10600
  path: target.path,
8305
10601
  headers,
@@ -8308,8 +10604,8 @@ var StorageFileApi = class {
8308
10604
  // The fetch stops with the body: the worker undoes its claim at once.
8309
10605
  signal
8310
10606
  });
8311
- if (sent.error !== null) return fail(sent.error);
8312
- return readJson(sent.response, isUploadResult);
10607
+ if (sent2.error !== null) return fail(sent2.error);
10608
+ return readJson(sent2.response, isUploadResult);
8313
10609
  }
8314
10610
  /**
8315
10611
  * A new object; with `upsert: true`, one of the same name is replaced.
@@ -8334,14 +10630,14 @@ var StorageFileApi = class {
8334
10630
  const target = this.#objectPath("", path);
8335
10631
  if (!target.ok) return fail(target.error);
8336
10632
  await this.#auth.ready();
8337
- const sent = await send(this.#http, {
10633
+ const sent2 = await send(this.#http, {
8338
10634
  method: "GET",
8339
10635
  path: target.path,
8340
10636
  withToken: true
8341
10637
  });
8342
- if (sent.error !== null) return fail(sent.error);
10638
+ if (sent2.error !== null) return fail(sent2.error);
8343
10639
  try {
8344
- return { data: await sent.response.blob(), error: null };
10640
+ return { data: await sent2.response.blob(), error: null };
8345
10641
  } catch (error) {
8346
10642
  return fail(networkError(error));
8347
10643
  }
@@ -8606,7 +10902,7 @@ var StorageFileApi = class {
8606
10902
  * is the permission. A failure's cause never carries the token.
8607
10903
  */
8608
10904
  async #putSigned(path, token, payload, cache, signal) {
8609
- const sent = await send(this.#http, {
10905
+ const sent2 = await send(this.#http, {
8610
10906
  method: "PUT",
8611
10907
  path: `${path}?token=${encodeURIComponent(token)}`,
8612
10908
  headers: { "content-type": payload.type, "cache-control": cache },
@@ -8614,8 +10910,8 @@ var StorageFileApi = class {
8614
10910
  withToken: false,
8615
10911
  signal
8616
10912
  });
8617
- if (sent.error !== null) return fail(withoutToken(sent.error, token));
8618
- const read = await readJson(sent.response, isUploadResult);
10913
+ if (sent2.error !== null) return fail(withoutToken(sent2.error, token));
10914
+ const read = await readJson(sent2.response, isUploadResult);
8619
10915
  return read.error === null ? read : fail(withoutToken(read.error, token));
8620
10916
  }
8621
10917
  /**
@@ -8655,10 +10951,36 @@ var CONFIG_MESSAGES = {
8655
10951
  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.",
8656
10952
  key: "createClient needs an anonKey (or, on a server, a serviceKey).",
8657
10953
  bothKeys: "Pass createClient an anonKey or a serviceKey, not both.",
8658
- 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.",
8659
- fetch: "This runtime has no fetch. Pass one to createClient({ fetch })."
10954
+ serviceKeyOffServer: SERVICE_KEY_OFF_SERVER_MESSAGE,
10955
+ fetch: "This runtime has no fetch. Pass one to createClient({ fetch }).",
10956
+ realtime: "createClient's realtime options take a WebSocket class, and heartbeatIntervalMs and timeoutMs in whole milliseconds from 1 to 2147483647."
8660
10957
  };
8661
10958
  var SERVICE_KEY_PREFIX = "naro_sk_";
10959
+ var MAX_TIMER_MS = 2147483647;
10960
+ function isMilliseconds(value) {
10961
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_TIMER_MS;
10962
+ }
10963
+ function realtimeOptionsOf(value) {
10964
+ if (value === void 0) return {};
10965
+ if (!isRecord(value)) throw new Error(CONFIG_MESSAGES.realtime);
10966
+ const { WebSocket, heartbeatIntervalMs, timeoutMs } = value;
10967
+ const options = {};
10968
+ if (WebSocket !== void 0) {
10969
+ if (typeof WebSocket !== "function")
10970
+ throw new Error(CONFIG_MESSAGES.realtime);
10971
+ options.WebSocket = WebSocket;
10972
+ }
10973
+ if (heartbeatIntervalMs !== void 0) {
10974
+ if (!isMilliseconds(heartbeatIntervalMs))
10975
+ throw new Error(CONFIG_MESSAGES.realtime);
10976
+ options.heartbeatIntervalMs = heartbeatIntervalMs;
10977
+ }
10978
+ if (timeoutMs !== void 0) {
10979
+ if (!isMilliseconds(timeoutMs)) throw new Error(CONFIG_MESSAGES.realtime);
10980
+ options.timeoutMs = timeoutMs;
10981
+ }
10982
+ return options;
10983
+ }
8662
10984
  function baseUrlOf(url) {
8663
10985
  if (typeof url !== "string") throw new Error(CONFIG_MESSAGES.url);
8664
10986
  const trimmed = url.trim();
@@ -8715,11 +11037,19 @@ var NaroClient2 = class {
8715
11037
  */
8716
11038
  storage;
8717
11039
  #run;
11040
+ #newRealtime;
11041
+ /**
11042
+ * Made by the first channel(), not by the constructor: it listens to every
11043
+ * write of the session token (to tell an open connection who signed in),
11044
+ * which an app that never uses Realtime should not pay for.
11045
+ */
11046
+ #realtime;
8718
11047
  constructor(options) {
8719
11048
  const baseUrl = baseUrlOf(options.url);
8720
11049
  const { key: apiKey, service } = apiKeyOf(options);
8721
11050
  if (options.fetch === void 0 && typeof globalThis.fetch !== "function")
8722
11051
  throw new Error(CONFIG_MESSAGES.fetch);
11052
+ const realtime = realtimeOptionsOf(options.realtime);
8723
11053
  const storage = options.auth?.storage ?? defaultStorage();
8724
11054
  const http = {
8725
11055
  baseUrl,
@@ -8741,11 +11071,46 @@ var NaroClient2 = class {
8741
11071
  });
8742
11072
  this.#run = queryRunner(http, this.auth);
8743
11073
  this.storage = new StorageClient(http, this.auth);
11074
+ this.#newRealtime = () => new RealtimeClient({
11075
+ http,
11076
+ auth: this.auth,
11077
+ service,
11078
+ options: realtime
11079
+ });
8744
11080
  }
8745
11081
  /** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
8746
11082
  from(table3) {
8747
11083
  return new TableBuilder(this.#run, table3);
8748
11084
  }
11085
+ /**
11086
+ * A Realtime channel (original spec §15): `.on()` listeners, then
11087
+ * `.subscribe()`. The same name and privacy give the same channel until
11088
+ * `removeChannel`. Nothing connects until the first `subscribe()`.
11089
+ */
11090
+ channel(name, options) {
11091
+ return this.#client().channel(name, options);
11092
+ }
11093
+ /** The channels made with `channel()` and not yet removed. */
11094
+ getChannels() {
11095
+ return this.#realtime?.getChannels() ?? [];
11096
+ }
11097
+ /**
11098
+ * Leaves the channel and forgets it, so `channel()` of its name makes a new
11099
+ * one; the connection closes with the last channel.
11100
+ */
11101
+ removeChannel(channel) {
11102
+ return this.#client().removeChannel(channel);
11103
+ }
11104
+ /** `removeChannel()` for every channel; the connection closes. */
11105
+ removeAllChannels() {
11106
+ if (this.#realtime === void 0)
11107
+ return Promise.resolve({ data: null, error: null });
11108
+ return this.#realtime.removeAllChannels();
11109
+ }
11110
+ #client() {
11111
+ this.#realtime ??= this.#newRealtime();
11112
+ return this.#realtime;
11113
+ }
8749
11114
  };
8750
11115
  function createClient(options) {
8751
11116
  return new NaroClient2(options);
@@ -9058,17 +11423,17 @@ function progressReporter(output, total) {
9058
11423
  let lastLength = 0;
9059
11424
  let open = false;
9060
11425
  return {
9061
- report(sent) {
9062
- const percent2 = Math.floor(sent / total * 100);
9063
- const line = `\u2191 ${binarySize(sent)} / ${binarySize(total)} (${String(percent2)}%)`;
11426
+ report(sent2) {
11427
+ const percent2 = Math.floor(sent2 / total * 100);
11428
+ const line = `\u2191 ${binarySize(sent2)} / ${binarySize(total)} (${String(percent2)}%)`;
9064
11429
  if (tty) {
9065
- open = sent !== total;
11430
+ open = sent2 !== total;
9066
11431
  output.progress(`\r${line.padEnd(lastLength)}${open ? "" : "\n"}`);
9067
11432
  lastLength = line.length;
9068
11433
  return;
9069
11434
  }
9070
11435
  const tenth = Math.floor(percent2 / 10);
9071
- if (tenth === lastTenth && sent !== total) return;
11436
+ if (tenth === lastTenth && sent2 !== total) return;
9072
11437
  lastTenth = tenth;
9073
11438
  output.progress(`${line}
9074
11439
  `);
@@ -9749,6 +12114,13 @@ Usage:
9749
12114
  Remove objects; --recursive removes what is under a folder (asks first):
9750
12115
  <path>/ is the folder alone, <path> also an object of that name
9751
12116
 
12117
+ naro realtime [status] Naro Realtime: on or not, published tables, connections now
12118
+ naro realtime enable Turn Realtime on: the project's hub, bound to its backend
12119
+ naro realtime tables [list] The tables whose Data API writes reach subscribers
12120
+ naro realtime tables add <table>\u2026 Publish tables' changes (within about 10 seconds)
12121
+ naro realtime tables remove <table>\u2026 [--yes]
12122
+ Stop publishing tables' changes (asks first)
12123
+
9752
12124
  naro promote <deployment-id> [--to <environment>]
9753
12125
  Publish a build to production, or to an environment with --to; it says what it will do, then asks.
9754
12126
  A build moving between an environment and another surface is built again from its commit with the
@@ -9967,6 +12339,38 @@ function routeStorage(words) {
9967
12339
  return null;
9968
12340
  }
9969
12341
  }
12342
+ function routeRealtime(words) {
12343
+ const [sub, action] = words;
12344
+ switch (sub) {
12345
+ case void 0:
12346
+ case "status":
12347
+ return words.length <= 1 ? { handler: runRealtimeStatus, positionals: [] } : null;
12348
+ case "enable":
12349
+ return words.length === 1 ? { handler: runRealtimeEnable, positionals: [] } : null;
12350
+ case "tables":
12351
+ switch (action) {
12352
+ case void 0:
12353
+ case "list":
12354
+ case "ls":
12355
+ return words.length <= 2 ? { handler: runRealtimeTablesList, positionals: [] } : null;
12356
+ case "add":
12357
+ return {
12358
+ handler: runRealtimeTablesAdd,
12359
+ positionals: words.slice(2)
12360
+ };
12361
+ case "remove":
12362
+ case "rm":
12363
+ return {
12364
+ handler: runRealtimeTablesRemove,
12365
+ positionals: words.slice(2)
12366
+ };
12367
+ default:
12368
+ return null;
12369
+ }
12370
+ default:
12371
+ return null;
12372
+ }
12373
+ }
9970
12374
  async function refuseTo() {
9971
12375
  throw new NaroError("VALIDATION_FAILED", "--to is only for naro promote.");
9972
12376
  }
@@ -10217,6 +12621,8 @@ function routeCommand(parsed) {
10217
12621
  }
10218
12622
  case "storage":
10219
12623
  return routeStorage(positionals);
12624
+ case "realtime":
12625
+ return routeRealtime(positionals);
10220
12626
  case "policy":
10221
12627
  switch (sub) {
10222
12628
  case void 0: