naro-cli 0.19.1 → 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/README.md +179 -5
- package/dist/{chunk-ZO2BEC46.js → chunk-FFPOVA7I.js} +1296 -597
- package/dist/deploy.js +1 -1
- package/dist/index.js +2668 -122
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
policiesForTypes,
|
|
64
64
|
policiesLostByReset,
|
|
65
65
|
projectPath,
|
|
66
|
+
promoteTargetUnsupported,
|
|
66
67
|
promptForMissingEnv,
|
|
67
68
|
pullEnv,
|
|
68
69
|
quoteIdent,
|
|
@@ -96,7 +97,7 @@ import {
|
|
|
96
97
|
usageMonthOf,
|
|
97
98
|
userForAccessToken,
|
|
98
99
|
writeProjectLink
|
|
99
|
-
} from "./chunk-
|
|
100
|
+
} from "./chunk-FFPOVA7I.js";
|
|
100
101
|
import "./chunk-K3NQKI34.js";
|
|
101
102
|
|
|
102
103
|
// src/args.ts
|
|
@@ -195,7 +196,8 @@ var STRING_FLAGS = {
|
|
|
195
196
|
"--search": "search",
|
|
196
197
|
"--copy-env": "copyEnv",
|
|
197
198
|
"--database": "database",
|
|
198
|
-
"--protection": "protection"
|
|
199
|
+
"--protection": "protection",
|
|
200
|
+
"--to": "to"
|
|
199
201
|
};
|
|
200
202
|
var SECRET_FLAGS = {
|
|
201
203
|
"--api-key": "--api-key-stdin",
|
|
@@ -1185,16 +1187,16 @@ var DISABLE_QUESTION = "Turn the backend off? In up to about 90 seconds your app
|
|
|
1185
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.";
|
|
1186
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.";
|
|
1187
1189
|
var TURNING_OFF_NOW_ADVICE = "Turning off: another run is removing the routes right now. Check again in a minute with `naro backend`.";
|
|
1188
|
-
function leaseHeld(info,
|
|
1190
|
+
function leaseHeld(info, now2) {
|
|
1189
1191
|
if (info.leaseExpiresAt === null) return false;
|
|
1190
1192
|
const until = Date.parse(info.leaseExpiresAt);
|
|
1191
|
-
return !Number.isNaN(until) && until >
|
|
1193
|
+
return !Number.isNaN(until) && until > now2;
|
|
1192
1194
|
}
|
|
1193
1195
|
function sentence(text) {
|
|
1194
1196
|
const line = printable(text).trim();
|
|
1195
1197
|
return /[.!?…]$/.test(line) ? line : `${line}.`;
|
|
1196
1198
|
}
|
|
1197
|
-
function statusText(info,
|
|
1199
|
+
function statusText(info, now2) {
|
|
1198
1200
|
switch (info.status) {
|
|
1199
1201
|
case "none":
|
|
1200
1202
|
return "not provisioned";
|
|
@@ -1202,7 +1204,7 @@ function statusText(info, now) {
|
|
|
1202
1204
|
return "ready";
|
|
1203
1205
|
default: {
|
|
1204
1206
|
const text = info.step ? `${info.status} (step: ${info.step})` : info.status;
|
|
1205
|
-
return isStalledBackend(info,
|
|
1207
|
+
return isStalledBackend(info, now2) ? `${text}, stalled` : text;
|
|
1206
1208
|
}
|
|
1207
1209
|
}
|
|
1208
1210
|
}
|
|
@@ -1212,10 +1214,10 @@ function switchText(info) {
|
|
|
1212
1214
|
return info.disabledAt ? `turning off (since ${printable(info.disabledAt)})` : "turning off";
|
|
1213
1215
|
return info.disabledAt ? `off since ${printable(info.disabledAt)}` : "off";
|
|
1214
1216
|
}
|
|
1215
|
-
function backendLines(info,
|
|
1217
|
+
function backendLines(info, now2 = Date.now()) {
|
|
1216
1218
|
const lines = [
|
|
1217
1219
|
`Backend: ${switchText(info)}`,
|
|
1218
|
-
`Status: ${statusText(info,
|
|
1220
|
+
`Status: ${statusText(info, now2)}`,
|
|
1219
1221
|
`URL: ${info.url}`
|
|
1220
1222
|
];
|
|
1221
1223
|
if (info.anonKey) lines.push(`Anon key: ${info.anonKey}`);
|
|
@@ -1225,10 +1227,10 @@ function backendLines(info, now = Date.now()) {
|
|
|
1225
1227
|
);
|
|
1226
1228
|
return lines;
|
|
1227
1229
|
}
|
|
1228
|
-
function adviceLines(info,
|
|
1230
|
+
function adviceLines(info, now2) {
|
|
1229
1231
|
if (!info.enabled && info.status !== "none") {
|
|
1230
1232
|
if (!info.turningOff) return [OFF_ADVICE];
|
|
1231
|
-
return [leaseHeld(info,
|
|
1233
|
+
return [leaseHeld(info, now2) ? TURNING_OFF_NOW_ADVICE : TURNING_OFF_ADVICE];
|
|
1232
1234
|
}
|
|
1233
1235
|
switch (info.status) {
|
|
1234
1236
|
case "none":
|
|
@@ -1239,7 +1241,7 @@ function adviceLines(info, now) {
|
|
|
1239
1241
|
"Run `naro backend enable` again \u2014 it picks up where it stopped."
|
|
1240
1242
|
];
|
|
1241
1243
|
case "provisioning":
|
|
1242
|
-
return isStalledBackend(info,
|
|
1244
|
+
return isStalledBackend(info, now2) ? [
|
|
1243
1245
|
"No run is working on it: the last one stopped part-way. Run `naro backend enable` to pick it up."
|
|
1244
1246
|
] : [
|
|
1245
1247
|
"Provisioning is running. Check again in a minute with `naro backend`."
|
|
@@ -1256,10 +1258,10 @@ async function runBackendInfo(context) {
|
|
|
1256
1258
|
const client = await context.client();
|
|
1257
1259
|
const projectId = await context.resolveProjectId();
|
|
1258
1260
|
const backend = await client.backend.info(projectId);
|
|
1259
|
-
const
|
|
1261
|
+
const now2 = Date.now();
|
|
1260
1262
|
context.output.result(
|
|
1261
|
-
{ projectId, backend, stalled: isStalledBackend(backend,
|
|
1262
|
-
[...backendLines(backend,
|
|
1263
|
+
{ projectId, backend, stalled: isStalledBackend(backend, now2) },
|
|
1264
|
+
[...backendLines(backend, now2), ...adviceLines(backend, now2)]
|
|
1263
1265
|
);
|
|
1264
1266
|
}
|
|
1265
1267
|
async function runBackendEnable(context) {
|
|
@@ -3184,10 +3186,10 @@ function truth(text) {
|
|
|
3184
3186
|
var DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
3185
3187
|
function exactNumber(text) {
|
|
3186
3188
|
if (!DECIMAL.test(text)) return text;
|
|
3187
|
-
const
|
|
3188
|
-
if (!text.includes(".")) return Number.isSafeInteger(
|
|
3189
|
+
const number2 = Number(text);
|
|
3190
|
+
if (!text.includes(".")) return Number.isSafeInteger(number2) ? number2 : text;
|
|
3189
3191
|
const trimmed = text.replace(/\.?0+$/, "");
|
|
3190
|
-
return String(
|
|
3192
|
+
return String(number2) === trimmed ? number2 : text;
|
|
3191
3193
|
}
|
|
3192
3194
|
function quoteFilterValue(value) {
|
|
3193
3195
|
const text = String(value);
|
|
@@ -3891,7 +3893,7 @@ async function runSchemaChange(context, operations, question) {
|
|
|
3891
3893
|
const dryRun = context.flags.dryRun === true;
|
|
3892
3894
|
if (question !== void 0 && !dryRun)
|
|
3893
3895
|
await confirmOrAbort(context, question);
|
|
3894
|
-
const
|
|
3896
|
+
const sent2 = dryRun ? operations.map((operation) => {
|
|
3895
3897
|
if (operation.op !== "dropTable" && operation.op !== "dropColumn")
|
|
3896
3898
|
return operation;
|
|
3897
3899
|
const { confirm: _confirm, ...rest } = operation;
|
|
@@ -3909,7 +3911,7 @@ async function runSchemaChange(context, operations, question) {
|
|
|
3909
3911
|
[...local, ...ledger.map((row) => row.name)],
|
|
3910
3912
|
schemaMigrationTitle(operations[0] ?? {})
|
|
3911
3913
|
);
|
|
3912
|
-
const result = await client.db.schema(projectId,
|
|
3914
|
+
const result = await client.db.schema(projectId, sent2, {
|
|
3913
3915
|
...dryRun ? { dryRun: true } : {},
|
|
3914
3916
|
name
|
|
3915
3917
|
});
|
|
@@ -5523,17 +5525,27 @@ async function runProjectInspect(context) {
|
|
|
5523
5525
|
}
|
|
5524
5526
|
|
|
5525
5527
|
// src/commands/promote.ts
|
|
5526
|
-
|
|
5528
|
+
var PROMOTE_USAGE = "Usage: naro promote <deployment-id> [--to <environment>]";
|
|
5529
|
+
var ROLLBACK_USAGE = "Usage: naro rollback [deployment-id]";
|
|
5530
|
+
var REBUILD_TIMEOUT_MS = 15 * 6e4;
|
|
5531
|
+
var REUPLOAD_TIMEOUT_MS = 5 * 6e4;
|
|
5532
|
+
async function promoteAndWait(context, client, project, sourceDeploymentId, action, target) {
|
|
5527
5533
|
const promoted = await client.deployments.promote(
|
|
5528
5534
|
project.id,
|
|
5529
|
-
sourceDeploymentId
|
|
5535
|
+
sourceDeploymentId,
|
|
5536
|
+
target !== void 0 ? { target } : void 0
|
|
5530
5537
|
);
|
|
5538
|
+
const rebuild = promoted.mode === "rebuild";
|
|
5531
5539
|
context.output.log(
|
|
5532
|
-
`\u25B8 ${action === "promote" ? "promoting" : "rolling back to"} ${sourceDeploymentId}`
|
|
5540
|
+
`\u25B8 ${action === "promote" ? "promoting" : "rolling back to"} ${sourceDeploymentId}${target !== void 0 ? ` to ${target} (${promoted.mode})` : ""}`
|
|
5541
|
+
);
|
|
5542
|
+
const detail = await client.deployments.wait(
|
|
5543
|
+
promoted.id,
|
|
5544
|
+
rebuild ? {
|
|
5545
|
+
timeoutMs: REBUILD_TIMEOUT_MS,
|
|
5546
|
+
onLog: (log) => context.output.log(formatBuildLog(log))
|
|
5547
|
+
} : { timeoutMs: REUPLOAD_TIMEOUT_MS }
|
|
5533
5548
|
);
|
|
5534
|
-
const detail = await client.deployments.wait(promoted.id, {
|
|
5535
|
-
timeoutMs: 5 * 6e4
|
|
5536
|
-
});
|
|
5537
5549
|
if (detail.deployment.status !== "READY") {
|
|
5538
5550
|
throw new NaroError(
|
|
5539
5551
|
detail.deployment.errorCode ?? "DEPLOY_FAILED",
|
|
@@ -5541,42 +5553,104 @@ async function promoteAndWait(context, client, project, sourceDeploymentId, acti
|
|
|
5541
5553
|
{ details: { deploymentId: promoted.id } }
|
|
5542
5554
|
);
|
|
5543
5555
|
}
|
|
5556
|
+
const serving = target === void 0 || target === "production" ? "Production" : target;
|
|
5544
5557
|
context.output.result(
|
|
5545
5558
|
{
|
|
5546
5559
|
deploymentId: promoted.id,
|
|
5547
5560
|
sourceDeploymentId,
|
|
5548
5561
|
projectId: project.id,
|
|
5562
|
+
// Both say the mode the control plane answered (MCP's rollback does
|
|
5563
|
+
// too). Only a promotion has a target: a rollback is production's.
|
|
5564
|
+
...action === "promote" ? { target: target ?? null } : {},
|
|
5565
|
+
mode: promoted.mode,
|
|
5549
5566
|
status: detail.deployment.status,
|
|
5550
5567
|
url: detail.deployment.url
|
|
5551
5568
|
},
|
|
5552
5569
|
[
|
|
5553
|
-
`\u2705
|
|
5570
|
+
`\u2705 ${serving} now serves ${rebuild ? "a rebuild of " : ""}${sourceDeploymentId}`,
|
|
5554
5571
|
"",
|
|
5555
5572
|
detail.deployment.url ?? ""
|
|
5556
5573
|
]
|
|
5557
5574
|
);
|
|
5558
5575
|
}
|
|
5576
|
+
function promoteTarget(flags) {
|
|
5577
|
+
if (flags.to === void 0) return void 0;
|
|
5578
|
+
const target = flags.to.trim();
|
|
5579
|
+
if (target === "" || target === "preview")
|
|
5580
|
+
throw new NaroError(
|
|
5581
|
+
"VALIDATION_FAILED",
|
|
5582
|
+
"--to must be production or an environment name."
|
|
5583
|
+
);
|
|
5584
|
+
return target;
|
|
5585
|
+
}
|
|
5586
|
+
function refuseWhatIsNotRead(context, usage, envMessage) {
|
|
5587
|
+
if (context.positionals.length > 1)
|
|
5588
|
+
throw new NaroError("VALIDATION_FAILED", usage);
|
|
5589
|
+
if (context.flags.env !== void 0)
|
|
5590
|
+
throw new NaroError("VALIDATION_FAILED", envMessage);
|
|
5591
|
+
}
|
|
5592
|
+
async function previewPromotion(context, client, projectId, sourceDeploymentId, target) {
|
|
5593
|
+
let check;
|
|
5594
|
+
try {
|
|
5595
|
+
check = await client.deployments.promoteCheck(
|
|
5596
|
+
projectId,
|
|
5597
|
+
sourceDeploymentId,
|
|
5598
|
+
target
|
|
5599
|
+
);
|
|
5600
|
+
} catch (error) {
|
|
5601
|
+
if (!isNaroError(error) || error.status !== 405) throw error;
|
|
5602
|
+
if (target !== void 0 && target !== "production")
|
|
5603
|
+
throw promoteTargetUnsupported(error);
|
|
5604
|
+
return;
|
|
5605
|
+
}
|
|
5606
|
+
context.output.log(
|
|
5607
|
+
check.mode === "rebuild" ? `\u25B8 rebuild: builds the same commit again with ${target ?? "production"}'s variables` : "\u25B8 reupload: publishes the stored build"
|
|
5608
|
+
);
|
|
5609
|
+
for (const warning of check.warnings)
|
|
5610
|
+
for (const line of warningLines(warning)) context.output.log(line);
|
|
5611
|
+
}
|
|
5559
5612
|
async function runPromote(context) {
|
|
5560
|
-
const [deploymentId] = context.positionalOrThrow(
|
|
5561
|
-
|
|
5562
|
-
|
|
5613
|
+
const [deploymentId] = context.positionalOrThrow(1, PROMOTE_USAGE);
|
|
5614
|
+
refuseWhatIsNotRead(
|
|
5615
|
+
context,
|
|
5616
|
+
PROMOTE_USAGE,
|
|
5617
|
+
"--env is not an option of naro promote: use --to <environment>."
|
|
5563
5618
|
);
|
|
5619
|
+
const target = promoteTarget(context.flags);
|
|
5564
5620
|
const client = await context.client();
|
|
5565
5621
|
const projectId = await context.resolveProjectId();
|
|
5566
5622
|
const project = await client.projects.get(projectId);
|
|
5623
|
+
await previewPromotion(
|
|
5624
|
+
context,
|
|
5625
|
+
client,
|
|
5626
|
+
projectId,
|
|
5627
|
+
deploymentId,
|
|
5628
|
+
target
|
|
5629
|
+
);
|
|
5567
5630
|
await confirmOrAbort(
|
|
5568
5631
|
context,
|
|
5569
|
-
`Promote ${String(deploymentId)} to production (${project.subdomain})?`
|
|
5632
|
+
target === void 0 || target === "production" ? `Promote ${String(deploymentId)} to production (${project.subdomain})?` : `Promote ${String(deploymentId)} to ${target} (${project.subdomain}--${target})?`
|
|
5570
5633
|
);
|
|
5571
5634
|
await promoteAndWait(
|
|
5572
5635
|
context,
|
|
5573
5636
|
client,
|
|
5574
5637
|
project,
|
|
5575
5638
|
deploymentId,
|
|
5576
|
-
"promote"
|
|
5639
|
+
"promote",
|
|
5640
|
+
target
|
|
5577
5641
|
);
|
|
5578
5642
|
}
|
|
5579
5643
|
async function runRollback(context) {
|
|
5644
|
+
if (context.flags.to !== void 0)
|
|
5645
|
+
throw new NaroError(
|
|
5646
|
+
"VALIDATION_FAILED",
|
|
5647
|
+
"--to is for naro promote: naro rollback always rolls production back."
|
|
5648
|
+
);
|
|
5649
|
+
refuseWhatIsNotRead(
|
|
5650
|
+
context,
|
|
5651
|
+
ROLLBACK_USAGE,
|
|
5652
|
+
"--env is not an option of naro rollback: naro rollback always rolls production back."
|
|
5653
|
+
);
|
|
5580
5654
|
const client = await context.client();
|
|
5581
5655
|
const projectId = await context.resolveProjectId();
|
|
5582
5656
|
const project = await client.projects.get(projectId);
|
|
@@ -5648,6 +5722,111 @@ async function runProtectionBypassRemove(context) {
|
|
|
5648
5722
|
context.output.result({ projectId, ...state }, ["Bypass secret removed."]);
|
|
5649
5723
|
}
|
|
5650
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
|
+
|
|
5651
5830
|
// src/commands/storage-units.ts
|
|
5652
5831
|
var SIZE_MESSAGE = "--file-size-limit takes a whole number of bytes, or a size like 5MB (1000s) or 5MiB (1024s), or none.";
|
|
5653
5832
|
var UNITS = {
|
|
@@ -5686,8 +5865,9 @@ function count(n, noun) {
|
|
|
5686
5865
|
var CREATE_USAGE3 = "Usage: naro storage bucket create <name> [--public] [--file-size-limit <size>] [--allowed-mime-types <t,\u2026>]";
|
|
5687
5866
|
var UPDATE_USAGE = "Usage: naro storage bucket update <name> [--public | --private] [--file-size-limit <size> | none] [--allowed-mime-types <t,\u2026> | any]";
|
|
5688
5867
|
var EMPTY_USAGE = "Usage: naro storage bucket empty <name> [--yes]";
|
|
5689
|
-
var
|
|
5868
|
+
var REMOVE_USAGE3 = "Usage: naro storage bucket remove <name> [--empty] [--yes]";
|
|
5690
5869
|
var ENABLE_FIRST = "Storage is not enabled yet; enabling it first.";
|
|
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.";
|
|
5691
5871
|
var NO_BUCKETS = "No buckets yet. `naro storage bucket create <name>` makes one.";
|
|
5692
5872
|
var IDLE_PAGES = 3;
|
|
5693
5873
|
function bucketLine(bucket) {
|
|
@@ -5708,6 +5888,12 @@ function utcClock(iso) {
|
|
|
5708
5888
|
const at = new Date(iso);
|
|
5709
5889
|
return Number.isNaN(at.getTime()) ? null : at.toISOString().slice(11, 16);
|
|
5710
5890
|
}
|
|
5891
|
+
function utcMinute(iso) {
|
|
5892
|
+
const at = new Date(iso);
|
|
5893
|
+
if (Number.isNaN(at.getTime())) return printable(iso);
|
|
5894
|
+
const text = at.toISOString();
|
|
5895
|
+
return `${text.slice(0, 10)} ${text.slice(11, 16)} UTC`;
|
|
5896
|
+
}
|
|
5711
5897
|
function warnedPercent(value, limit) {
|
|
5712
5898
|
if (limit === null || !(limit > 0)) return null;
|
|
5713
5899
|
const percent2 = Math.floor(value * 100 / limit);
|
|
@@ -5749,6 +5935,13 @@ function meterLines(info) {
|
|
|
5749
5935
|
return lines;
|
|
5750
5936
|
}
|
|
5751
5937
|
function storageLines(info) {
|
|
5938
|
+
if (info.disabledAt !== null)
|
|
5939
|
+
return [
|
|
5940
|
+
`Storage: off since ${utcMinute(info.disabledAt)} \u2014 buckets and files are kept`,
|
|
5941
|
+
"Run `naro storage enable` to turn it on again: the same buckets and files come back.",
|
|
5942
|
+
info.buckets.length === 0 ? "Buckets: none" : `Buckets: ${String(info.buckets.length)}`,
|
|
5943
|
+
...info.buckets.map(bucketLine)
|
|
5944
|
+
];
|
|
5752
5945
|
if (!info.enabled)
|
|
5753
5946
|
return [
|
|
5754
5947
|
"Storage: off",
|
|
@@ -5777,6 +5970,19 @@ async function enableStorage(client, projectId) {
|
|
|
5777
5970
|
throw error;
|
|
5778
5971
|
}
|
|
5779
5972
|
}
|
|
5973
|
+
async function disableStorage(client, projectId) {
|
|
5974
|
+
try {
|
|
5975
|
+
return await client.storage.disable(projectId);
|
|
5976
|
+
} catch (error) {
|
|
5977
|
+
if (isNaroError(error) && error.code === "TIMEOUT")
|
|
5978
|
+
throw new NaroError(
|
|
5979
|
+
"TIMEOUT",
|
|
5980
|
+
"Turning Storage off did not answer within 2 minutes. `naro storage` shows where it got to, and `naro storage disable` again picks up where it stopped.",
|
|
5981
|
+
{ cause: error }
|
|
5982
|
+
);
|
|
5983
|
+
throw error;
|
|
5984
|
+
}
|
|
5985
|
+
}
|
|
5780
5986
|
function bucketName(context, usage) {
|
|
5781
5987
|
if (context.positionals.length !== 1)
|
|
5782
5988
|
throw new NaroError("VALIDATION_FAILED", usage);
|
|
@@ -5813,6 +6019,24 @@ async function runStorageEnable(context) {
|
|
|
5813
6019
|
...storageLines(storage)
|
|
5814
6020
|
]);
|
|
5815
6021
|
}
|
|
6022
|
+
async function runStorageDisable(context) {
|
|
6023
|
+
const client = await context.client();
|
|
6024
|
+
const projectId = await context.resolveProjectId();
|
|
6025
|
+
const current = await client.storage.info(projectId);
|
|
6026
|
+
if (current.bucket === null) {
|
|
6027
|
+
context.output.result({ projectId, storage: current }, [
|
|
6028
|
+
"Storage is already off."
|
|
6029
|
+
]);
|
|
6030
|
+
return;
|
|
6031
|
+
}
|
|
6032
|
+
if (current.disabledAt === null)
|
|
6033
|
+
await confirmOrAbort(context, DISABLE_QUESTION3);
|
|
6034
|
+
const storage = await disableStorage(client, projectId);
|
|
6035
|
+
context.output.result({ projectId, storage }, [
|
|
6036
|
+
"\u2705 Storage is off. Buckets and files are kept; naro storage enable turns it on again.",
|
|
6037
|
+
...storageLines(storage)
|
|
6038
|
+
]);
|
|
6039
|
+
}
|
|
5816
6040
|
async function runStorageBucketList(context) {
|
|
5817
6041
|
const client = await context.client();
|
|
5818
6042
|
const projectId = await context.resolveProjectId();
|
|
@@ -5909,11 +6133,11 @@ async function runStorageBucketEmpty(context) {
|
|
|
5909
6133
|
]);
|
|
5910
6134
|
}
|
|
5911
6135
|
async function runStorageBucketRemove(context) {
|
|
5912
|
-
const name = bucketName(context,
|
|
6136
|
+
const name = bucketName(context, REMOVE_USAGE3);
|
|
5913
6137
|
const empty = context.flags.empty === true;
|
|
5914
6138
|
refuseSearch(
|
|
5915
6139
|
context,
|
|
5916
|
-
empty ? `bucket remove --empty deletes every object in the bucket before it removes the bucket. ${
|
|
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}`
|
|
5917
6141
|
);
|
|
5918
6142
|
const client = await context.client();
|
|
5919
6143
|
const projectId = await context.resolveProjectId();
|
|
@@ -6013,6 +6237,105 @@ function cryptoUnavailable() {
|
|
|
6013
6237
|
message: "This runtime has no crypto.subtle, which signing in with a provider needs (https or localhost)."
|
|
6014
6238
|
};
|
|
6015
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
|
+
}
|
|
6016
6339
|
function describe(cause) {
|
|
6017
6340
|
if (cause instanceof Error) return `${cause.name}: ${cause.message}`;
|
|
6018
6341
|
return String(cause);
|
|
@@ -6035,9 +6358,9 @@ function errorFromBody(status, body) {
|
|
|
6035
6358
|
if (status >= 200 && status < 300) return null;
|
|
6036
6359
|
return invalidResponse(status);
|
|
6037
6360
|
}
|
|
6038
|
-
var
|
|
6361
|
+
var SESSION_PREFIXES = ["/auth/", "/realtime/"];
|
|
6039
6362
|
function storeFor(config, path) {
|
|
6040
|
-
return path.startsWith(
|
|
6363
|
+
return SESSION_PREFIXES.some((prefix) => path.startsWith(prefix)) ? config.tokens : config.accessJwt ?? null;
|
|
6041
6364
|
}
|
|
6042
6365
|
async function receive(config, options, init) {
|
|
6043
6366
|
const controller = options.timeoutMs !== void 0 && typeof AbortController === "function" ? new AbortController() : null;
|
|
@@ -6189,7 +6512,9 @@ var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
|
|
|
6189
6512
|
// Storage (original spec §13): one object written twice within a second
|
|
6190
6513
|
// (`x-retry-after: 1`), and the daily operation limit (3-1d).
|
|
6191
6514
|
"OBJECT_RATE_LIMITED",
|
|
6192
|
-
"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"
|
|
6193
6518
|
]);
|
|
6194
6519
|
function withRetryAfter(error, headers) {
|
|
6195
6520
|
const header = headers.get("x-retry-after")?.trim() ?? "";
|
|
@@ -6768,7 +7093,7 @@ var MAX_FLOWS = 10;
|
|
|
6768
7093
|
function isEntry(value) {
|
|
6769
7094
|
return typeof value === "object" && value !== null && typeof value.verifier === "string" && typeof value.expiresAt === "number";
|
|
6770
7095
|
}
|
|
6771
|
-
function live(text,
|
|
7096
|
+
function live(text, now2) {
|
|
6772
7097
|
if (text === null) return {};
|
|
6773
7098
|
let parsed;
|
|
6774
7099
|
try {
|
|
@@ -6779,7 +7104,7 @@ function live(text, now) {
|
|
|
6779
7104
|
if (typeof parsed !== "object" || parsed === null) return {};
|
|
6780
7105
|
const entries = {};
|
|
6781
7106
|
for (const [flow, entry] of Object.entries(parsed))
|
|
6782
|
-
if (isEntry(entry) && entry.expiresAt >
|
|
7107
|
+
if (isEntry(entry) && entry.expiresAt > now2) entries[flow] = entry;
|
|
6783
7108
|
return entries;
|
|
6784
7109
|
}
|
|
6785
7110
|
function pick(entries, oldest) {
|
|
@@ -6795,16 +7120,16 @@ function serialize(entries) {
|
|
|
6795
7120
|
var FlowVerifiers = class {
|
|
6796
7121
|
#store;
|
|
6797
7122
|
#now;
|
|
6798
|
-
constructor(store,
|
|
7123
|
+
constructor(store, now2 = Date.now) {
|
|
6799
7124
|
this.#store = store;
|
|
6800
|
-
this.#now =
|
|
7125
|
+
this.#now = now2;
|
|
6801
7126
|
}
|
|
6802
7127
|
/** Keeps `verifier` for the flow `flow`, for ten minutes. */
|
|
6803
7128
|
async add(flow, verifier) {
|
|
6804
|
-
const
|
|
7129
|
+
const now2 = this.#now();
|
|
6805
7130
|
await this.#store.update((text) => {
|
|
6806
|
-
const entries = live(text,
|
|
6807
|
-
entries[flow] = { verifier, expiresAt:
|
|
7131
|
+
const entries = live(text, now2);
|
|
7132
|
+
entries[flow] = { verifier, expiresAt: now2 + FLOW_TTL_MS };
|
|
6808
7133
|
while (Object.keys(entries).length > MAX_FLOWS) {
|
|
6809
7134
|
const oldest = pick(entries, true);
|
|
6810
7135
|
if (oldest === void 0) break;
|
|
@@ -6819,10 +7144,10 @@ var FlowVerifiers = class {
|
|
|
6819
7144
|
* expired.
|
|
6820
7145
|
*/
|
|
6821
7146
|
async take(flow) {
|
|
6822
|
-
const
|
|
7147
|
+
const now2 = this.#now();
|
|
6823
7148
|
let taken = null;
|
|
6824
7149
|
await this.#store.update((text) => {
|
|
6825
|
-
const entries = live(text,
|
|
7150
|
+
const entries = live(text, now2);
|
|
6826
7151
|
const id = flow ?? pick(entries, false);
|
|
6827
7152
|
if (id !== void 0 && Object.hasOwn(entries, id)) {
|
|
6828
7153
|
taken = entries[id]?.verifier ?? null;
|
|
@@ -6847,9 +7172,9 @@ function parse(text) {
|
|
|
6847
7172
|
var RecoveryToken = class {
|
|
6848
7173
|
#store;
|
|
6849
7174
|
#now;
|
|
6850
|
-
constructor(store,
|
|
7175
|
+
constructor(store, now2 = () => Date.now()) {
|
|
6851
7176
|
this.#store = store;
|
|
6852
|
-
this.#now =
|
|
7177
|
+
this.#now = now2;
|
|
6853
7178
|
}
|
|
6854
7179
|
/** Keeps the token a recovery link brought, for 15 minutes. */
|
|
6855
7180
|
async keep(token) {
|
|
@@ -6858,11 +7183,11 @@ var RecoveryToken = class {
|
|
|
6858
7183
|
}
|
|
6859
7184
|
/** The token, while it lasts; an expired or unreadable one is cleared. */
|
|
6860
7185
|
async current() {
|
|
6861
|
-
const
|
|
7186
|
+
const now2 = this.#now();
|
|
6862
7187
|
let token = null;
|
|
6863
7188
|
await this.#store.update((text) => {
|
|
6864
7189
|
const kept = parse(text);
|
|
6865
|
-
if (kept === null || kept.expiresAt <=
|
|
7190
|
+
if (kept === null || kept.expiresAt <= now2) return null;
|
|
6866
7191
|
token = kept.token;
|
|
6867
7192
|
return text;
|
|
6868
7193
|
});
|
|
@@ -6922,6 +7247,8 @@ var TokenStore = class {
|
|
|
6922
7247
|
#fallback = null;
|
|
6923
7248
|
/** Settles when the last queued operation has finished. */
|
|
6924
7249
|
#queue = Promise.resolve();
|
|
7250
|
+
/** Called after each write; see `subscribe`. */
|
|
7251
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
6925
7252
|
constructor(storage, key) {
|
|
6926
7253
|
this.#storage = storage;
|
|
6927
7254
|
this.#key = key;
|
|
@@ -6930,21 +7257,31 @@ var TokenStore = class {
|
|
|
6930
7257
|
return this.#serial(() => this.#get());
|
|
6931
7258
|
}
|
|
6932
7259
|
set(token) {
|
|
6933
|
-
return this.#serial(() =>
|
|
7260
|
+
return this.#serial(async () => {
|
|
7261
|
+
await this.#set(token);
|
|
7262
|
+
this.#changed();
|
|
7263
|
+
});
|
|
6934
7264
|
}
|
|
6935
7265
|
clear() {
|
|
6936
|
-
return this.#serial(() =>
|
|
7266
|
+
return this.#serial(async () => {
|
|
7267
|
+
await this.#clear();
|
|
7268
|
+
this.#changed();
|
|
7269
|
+
});
|
|
6937
7270
|
}
|
|
6938
7271
|
/**
|
|
6939
|
-
* Clears only if the stored token is still the one that was rejected
|
|
6940
|
-
* request that left with an old token can come back
|
|
6941
|
-
* in again; its INVALID_SESSION must not sign the
|
|
6942
|
-
* read and the removal are one operation: with an
|
|
6943
|
-
* that lands between them would otherwise be the
|
|
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.
|
|
6944
7278
|
*/
|
|
6945
7279
|
clearIf(token) {
|
|
6946
7280
|
return this.#serial(async () => {
|
|
6947
|
-
if (await this.#get()
|
|
7281
|
+
if (await this.#get() !== token) return false;
|
|
7282
|
+
await this.#clear();
|
|
7283
|
+
this.#changed();
|
|
7284
|
+
return true;
|
|
6948
7285
|
});
|
|
6949
7286
|
}
|
|
6950
7287
|
/**
|
|
@@ -6957,17 +7294,42 @@ var TokenStore = class {
|
|
|
6957
7294
|
const next = transform(await this.#get());
|
|
6958
7295
|
if (next === null) await this.#clear();
|
|
6959
7296
|
else await this.#set(next);
|
|
7297
|
+
this.#changed();
|
|
6960
7298
|
});
|
|
6961
7299
|
}
|
|
6962
7300
|
/** Stores `next` only if `expected` is still stored, as one operation. */
|
|
6963
7301
|
replaceIf(expected, next) {
|
|
6964
7302
|
return this.#serial(async () => {
|
|
6965
|
-
if (await this.#get()
|
|
7303
|
+
if (await this.#get() !== expected) return;
|
|
7304
|
+
await this.#set(next);
|
|
7305
|
+
this.#changed();
|
|
6966
7306
|
});
|
|
6967
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
|
+
}
|
|
6968
7330
|
/**
|
|
6969
7331
|
* Runs `operation` once every earlier one has finished. The operations
|
|
6970
|
-
* below never reject, and none of them
|
|
7332
|
+
* below never reject, and none of them awaits a public method (that would
|
|
6971
7333
|
* wait on itself).
|
|
6972
7334
|
*/
|
|
6973
7335
|
#serial(operation) {
|
|
@@ -7368,10 +7730,10 @@ function truth2(text) {
|
|
|
7368
7730
|
var DECIMAL3 = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
7369
7731
|
function exactNumber2(text) {
|
|
7370
7732
|
if (!DECIMAL3.test(text)) return text;
|
|
7371
|
-
const
|
|
7372
|
-
if (!text.includes(".")) return Number.isSafeInteger(
|
|
7733
|
+
const number2 = Number(text);
|
|
7734
|
+
if (!text.includes(".")) return Number.isSafeInteger(number2) ? number2 : text;
|
|
7373
7735
|
const trimmed = text.replace(/\.?0+$/, "");
|
|
7374
|
-
return String(
|
|
7736
|
+
return String(number2) === trimmed ? number2 : text;
|
|
7375
7737
|
}
|
|
7376
7738
|
function keyword2(text, at) {
|
|
7377
7739
|
const word = text.toLowerCase();
|
|
@@ -7773,54 +8135,2107 @@ var TableBuilder = class {
|
|
|
7773
8135
|
});
|
|
7774
8136
|
}
|
|
7775
8137
|
};
|
|
7776
|
-
|
|
7777
|
-
|
|
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
|
+
};
|
|
7778
8180
|
}
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
if (isRecord(
|
|
7782
|
-
|
|
7783
|
-
|
|
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;
|
|
7784
8190
|
}
|
|
7785
|
-
|
|
7786
|
-
|
|
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
|
+
);
|
|
8207
|
+
}
|
|
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;
|
|
7787
8278
|
try {
|
|
7788
|
-
|
|
7789
|
-
} catch
|
|
7790
|
-
return
|
|
8279
|
+
value = JSON.parse(data);
|
|
8280
|
+
} catch {
|
|
8281
|
+
return null;
|
|
7791
8282
|
}
|
|
7792
|
-
|
|
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
|
+
};
|
|
8295
|
+
}
|
|
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]);
|
|
8300
|
+
}
|
|
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;
|
|
8314
|
+
}
|
|
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 }
|
|
8320
|
+
);
|
|
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) {
|
|
7793
8369
|
try {
|
|
7794
|
-
|
|
8370
|
+
ws.close(code);
|
|
7795
8371
|
} catch {
|
|
7796
|
-
return fail(invalidResponse(response.status));
|
|
7797
8372
|
}
|
|
7798
|
-
return dataOf2(response.status, body, guard);
|
|
7799
8373
|
}
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
var partAnswer = (n) => (value) => isRecord(value) && value.partNumber === n && typeof value.etag === "string" && value.etag !== "";
|
|
7809
|
-
var retryable = (error, status) => error.code === "NETWORK_ERROR" || error.code === "OBJECT_RATE_LIMITED" || status !== null && status >= 500 && error.code !== "STORAGE_QUOTA_EXCEEDED";
|
|
7810
|
-
function waitAfter(error, attempt22) {
|
|
7811
|
-
const after = isRecord(error.details) ? error.details.retryAfter : void 0;
|
|
7812
|
-
if (typeof after === "number") return after * 1e3;
|
|
7813
|
-
if (error.code === "OBJECT_RATE_LIMITED") return 1e3;
|
|
7814
|
-
return BACKOFF_MS[attempt22 - 1] ?? 4e3;
|
|
8374
|
+
function safely(callback) {
|
|
8375
|
+
try {
|
|
8376
|
+
callback();
|
|
8377
|
+
} catch (error) {
|
|
8378
|
+
queueMicrotask(() => {
|
|
8379
|
+
throw error;
|
|
8380
|
+
});
|
|
8381
|
+
}
|
|
7815
8382
|
}
|
|
7816
|
-
|
|
7817
|
-
if (
|
|
7818
|
-
|
|
7819
|
-
return
|
|
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
|
+
};
|
|
7820
8390
|
}
|
|
7821
|
-
|
|
7822
|
-
|
|
7823
|
-
|
|
8391
|
+
return {
|
|
8392
|
+
ok: false,
|
|
8393
|
+
error: asError(payload.response) ?? invalidResponse(101),
|
|
8394
|
+
lost: false
|
|
8395
|
+
};
|
|
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);
|
|
7824
10239
|
done();
|
|
7825
10240
|
};
|
|
7826
10241
|
const timer = setTimeout(end, ms);
|
|
@@ -7882,7 +10297,7 @@ async function uploadInParts(a) {
|
|
|
7882
10297
|
failure.error ??= uploadAborted();
|
|
7883
10298
|
};
|
|
7884
10299
|
const tryPart = async (n, bytes) => {
|
|
7885
|
-
const
|
|
10300
|
+
const sent2 = await send(a.http, {
|
|
7886
10301
|
method: "PUT",
|
|
7887
10302
|
path: `${base}/${String(n)}`,
|
|
7888
10303
|
headers: { "content-type": "application/octet-stream", ...signed },
|
|
@@ -7890,10 +10305,10 @@ async function uploadInParts(a) {
|
|
|
7890
10305
|
withToken,
|
|
7891
10306
|
signal
|
|
7892
10307
|
});
|
|
7893
|
-
if (
|
|
7894
|
-
const read = await readJson(
|
|
10308
|
+
if (sent2.error !== null) return sent2;
|
|
10309
|
+
const read = await readJson(sent2.response, partAnswer(n));
|
|
7895
10310
|
if (read.error !== null)
|
|
7896
|
-
return { error: read.error, status:
|
|
10311
|
+
return { error: read.error, status: sent2.response.status };
|
|
7897
10312
|
return { etag: read.data.etag };
|
|
7898
10313
|
};
|
|
7899
10314
|
const sendPart = async (n) => {
|
|
@@ -8180,7 +10595,7 @@ var StorageFileApi = class {
|
|
|
8180
10595
|
"cache-control": cache
|
|
8181
10596
|
};
|
|
8182
10597
|
if (upsert) headers["x-upsert"] = "true";
|
|
8183
|
-
const
|
|
10598
|
+
const sent2 = await send(this.#http, {
|
|
8184
10599
|
method,
|
|
8185
10600
|
path: target.path,
|
|
8186
10601
|
headers,
|
|
@@ -8189,8 +10604,8 @@ var StorageFileApi = class {
|
|
|
8189
10604
|
// The fetch stops with the body: the worker undoes its claim at once.
|
|
8190
10605
|
signal
|
|
8191
10606
|
});
|
|
8192
|
-
if (
|
|
8193
|
-
return readJson(
|
|
10607
|
+
if (sent2.error !== null) return fail(sent2.error);
|
|
10608
|
+
return readJson(sent2.response, isUploadResult);
|
|
8194
10609
|
}
|
|
8195
10610
|
/**
|
|
8196
10611
|
* A new object; with `upsert: true`, one of the same name is replaced.
|
|
@@ -8215,14 +10630,14 @@ var StorageFileApi = class {
|
|
|
8215
10630
|
const target = this.#objectPath("", path);
|
|
8216
10631
|
if (!target.ok) return fail(target.error);
|
|
8217
10632
|
await this.#auth.ready();
|
|
8218
|
-
const
|
|
10633
|
+
const sent2 = await send(this.#http, {
|
|
8219
10634
|
method: "GET",
|
|
8220
10635
|
path: target.path,
|
|
8221
10636
|
withToken: true
|
|
8222
10637
|
});
|
|
8223
|
-
if (
|
|
10638
|
+
if (sent2.error !== null) return fail(sent2.error);
|
|
8224
10639
|
try {
|
|
8225
|
-
return { data: await
|
|
10640
|
+
return { data: await sent2.response.blob(), error: null };
|
|
8226
10641
|
} catch (error) {
|
|
8227
10642
|
return fail(networkError(error));
|
|
8228
10643
|
}
|
|
@@ -8487,7 +10902,7 @@ var StorageFileApi = class {
|
|
|
8487
10902
|
* is the permission. A failure's cause never carries the token.
|
|
8488
10903
|
*/
|
|
8489
10904
|
async #putSigned(path, token, payload, cache, signal) {
|
|
8490
|
-
const
|
|
10905
|
+
const sent2 = await send(this.#http, {
|
|
8491
10906
|
method: "PUT",
|
|
8492
10907
|
path: `${path}?token=${encodeURIComponent(token)}`,
|
|
8493
10908
|
headers: { "content-type": payload.type, "cache-control": cache },
|
|
@@ -8495,8 +10910,8 @@ var StorageFileApi = class {
|
|
|
8495
10910
|
withToken: false,
|
|
8496
10911
|
signal
|
|
8497
10912
|
});
|
|
8498
|
-
if (
|
|
8499
|
-
const read = await readJson(
|
|
10913
|
+
if (sent2.error !== null) return fail(withoutToken(sent2.error, token));
|
|
10914
|
+
const read = await readJson(sent2.response, isUploadResult);
|
|
8500
10915
|
return read.error === null ? read : fail(withoutToken(read.error, token));
|
|
8501
10916
|
}
|
|
8502
10917
|
/**
|
|
@@ -8536,10 +10951,36 @@ var CONFIG_MESSAGES = {
|
|
|
8536
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.",
|
|
8537
10952
|
key: "createClient needs an anonKey (or, on a server, a serviceKey).",
|
|
8538
10953
|
bothKeys: "Pass createClient an anonKey or a serviceKey, not both.",
|
|
8539
|
-
serviceKeyOffServer:
|
|
8540
|
-
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."
|
|
8541
10957
|
};
|
|
8542
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
|
+
}
|
|
8543
10984
|
function baseUrlOf(url) {
|
|
8544
10985
|
if (typeof url !== "string") throw new Error(CONFIG_MESSAGES.url);
|
|
8545
10986
|
const trimmed = url.trim();
|
|
@@ -8596,11 +11037,19 @@ var NaroClient2 = class {
|
|
|
8596
11037
|
*/
|
|
8597
11038
|
storage;
|
|
8598
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;
|
|
8599
11047
|
constructor(options) {
|
|
8600
11048
|
const baseUrl = baseUrlOf(options.url);
|
|
8601
11049
|
const { key: apiKey, service } = apiKeyOf(options);
|
|
8602
11050
|
if (options.fetch === void 0 && typeof globalThis.fetch !== "function")
|
|
8603
11051
|
throw new Error(CONFIG_MESSAGES.fetch);
|
|
11052
|
+
const realtime = realtimeOptionsOf(options.realtime);
|
|
8604
11053
|
const storage = options.auth?.storage ?? defaultStorage();
|
|
8605
11054
|
const http = {
|
|
8606
11055
|
baseUrl,
|
|
@@ -8622,11 +11071,46 @@ var NaroClient2 = class {
|
|
|
8622
11071
|
});
|
|
8623
11072
|
this.#run = queryRunner(http, this.auth);
|
|
8624
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
|
+
});
|
|
8625
11080
|
}
|
|
8626
11081
|
/** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
|
|
8627
11082
|
from(table3) {
|
|
8628
11083
|
return new TableBuilder(this.#run, table3);
|
|
8629
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
|
+
}
|
|
8630
11114
|
};
|
|
8631
11115
|
function createClient(options) {
|
|
8632
11116
|
return new NaroClient2(options);
|
|
@@ -8639,6 +11123,7 @@ var MV_USAGE = "Usage: naro storage mv naro://<bucket>/<from> naro://<bucket>/<t
|
|
|
8639
11123
|
var RM_USAGE = "Usage: naro storage rm naro://<bucket>/<path>\u2026 [--recursive] [--yes]";
|
|
8640
11124
|
var LS_USAGE = "Usage: naro storage ls [naro://<bucket>[/<prefix>]] [--recursive] [--search <text>]";
|
|
8641
11125
|
var BACKEND_OFF_MESSAGE = "The backend is turned off for this project. Turn it on with `naro backend enable` first.";
|
|
11126
|
+
var STORAGE_OFF_MESSAGE = "Storage is turned off for this project. Turn it on with `naro storage enable` first.";
|
|
8642
11127
|
var STORAGE_OUTDATED_MESSAGE = "The project backend is older than this Storage command. Run `naro backend enable` to update it, then try again.";
|
|
8643
11128
|
var OLD_SEARCH_REASON = "search is not supported yet";
|
|
8644
11129
|
function isOldSearchRefusal(error) {
|
|
@@ -8731,6 +11216,9 @@ async function objects(context) {
|
|
|
8731
11216
|
"This project has no backend yet. Run `naro backend enable`, then `naro storage enable`."
|
|
8732
11217
|
);
|
|
8733
11218
|
if (!backend.enabled) throw new NaroError("BACKEND_OFF", BACKEND_OFF_MESSAGE);
|
|
11219
|
+
const state = await client.storage.info(projectId);
|
|
11220
|
+
if (state.disabledAt !== null)
|
|
11221
|
+
throw new NaroError("STORAGE_OFF", STORAGE_OFF_MESSAGE);
|
|
8734
11222
|
let serviceKey = process.env.NARO_SERVICE_KEY?.trim() ?? "";
|
|
8735
11223
|
if (serviceKey !== "" && !ONE_KEY.test(serviceKey))
|
|
8736
11224
|
throw new NaroError(
|
|
@@ -8935,17 +11423,17 @@ function progressReporter(output, total) {
|
|
|
8935
11423
|
let lastLength = 0;
|
|
8936
11424
|
let open = false;
|
|
8937
11425
|
return {
|
|
8938
|
-
report(
|
|
8939
|
-
const percent2 = Math.floor(
|
|
8940
|
-
const line = `\u2191 ${binarySize(
|
|
11426
|
+
report(sent2) {
|
|
11427
|
+
const percent2 = Math.floor(sent2 / total * 100);
|
|
11428
|
+
const line = `\u2191 ${binarySize(sent2)} / ${binarySize(total)} (${String(percent2)}%)`;
|
|
8941
11429
|
if (tty) {
|
|
8942
|
-
open =
|
|
11430
|
+
open = sent2 !== total;
|
|
8943
11431
|
output.progress(`\r${line.padEnd(lastLength)}${open ? "" : "\n"}`);
|
|
8944
11432
|
lastLength = line.length;
|
|
8945
11433
|
return;
|
|
8946
11434
|
}
|
|
8947
11435
|
const tenth = Math.floor(percent2 / 10);
|
|
8948
|
-
if (tenth === lastTenth &&
|
|
11436
|
+
if (tenth === lastTenth && sent2 !== total) return;
|
|
8949
11437
|
lastTenth = tenth;
|
|
8950
11438
|
output.progress(`${line}
|
|
8951
11439
|
`);
|
|
@@ -9600,6 +12088,8 @@ Usage:
|
|
|
9600
12088
|
naro storage [status] Naro Storage: on or off, limits, what is stored, today's operations, the buckets
|
|
9601
12089
|
naro storage enable Turn Storage on: the project's R2 bucket, bound to its backend
|
|
9602
12090
|
(safe to re-run; applies a plan change's limits)
|
|
12091
|
+
naro storage disable [--yes] Turn Storage off and keep its files: its calls and file URLs answer 404
|
|
12092
|
+
until naro storage enable binds the same bucket again (asks first)
|
|
9603
12093
|
naro storage bucket list The buckets: public or private, objects, bytes, limits
|
|
9604
12094
|
naro storage bucket create <name> [--public] [--file-size-limit <size>] [--allowed-mime-types <t,\u2026>]
|
|
9605
12095
|
<size>: bytes, 5MB (1000s) or 5MiB (1024s); turns Storage on first if needed
|
|
@@ -9624,7 +12114,17 @@ Usage:
|
|
|
9624
12114
|
Remove objects; --recursive removes what is under a folder (asks first):
|
|
9625
12115
|
<path>/ is the folder alone, <path> also an object of that name
|
|
9626
12116
|
|
|
9627
|
-
naro
|
|
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
|
+
|
|
12124
|
+
naro promote <deployment-id> [--to <environment>]
|
|
12125
|
+
Publish a build to production, or to an environment with --to; it says what it will do, then asks.
|
|
12126
|
+
A build moving between an environment and another surface is built again from its commit with the
|
|
12127
|
+
target's variables, which takes up to 15 minutes; any other build is published as stored.
|
|
9628
12128
|
naro rollback [deployment-id] Roll production back to the previous build
|
|
9629
12129
|
|
|
9630
12130
|
Global options:
|
|
@@ -9648,6 +12148,7 @@ Global options:
|
|
|
9648
12148
|
--device <d> Only this device: desktop, mobile or tablet (naro analytics, naro insights)
|
|
9649
12149
|
--browser <b> Only this browser: chrome, edge, firefox, opera, safari or other (naro analytics, naro insights)
|
|
9650
12150
|
--os <o> Only this operating system: android, ios, linux, macos, windows or other (naro analytics, naro insights)
|
|
12151
|
+
--to <environment> production (the default) or an environment name: where naro promote publishes the build (naro promote)
|
|
9651
12152
|
--env <e> production or preview (naro logs, naro insights: production by default; naro env pull, naro deploy: preview by default, or an environment name)
|
|
9652
12153
|
--branch <b> naro environments create, set: the branch whose pushes deploy to that environment. naro deploy: the default
|
|
9653
12154
|
branch of a project this deploy creates or updates (the deploy's own branch follows where it goes)
|
|
@@ -9792,6 +12293,8 @@ function routeStorage(words) {
|
|
|
9792
12293
|
return words.length <= 1 ? { handler: runStorageStatus, positionals: [] } : null;
|
|
9793
12294
|
case "enable":
|
|
9794
12295
|
return words.length === 1 ? { handler: runStorageEnable, positionals: [] } : null;
|
|
12296
|
+
case "disable":
|
|
12297
|
+
return words.length === 1 ? { handler: runStorageDisable, positionals: [] } : null;
|
|
9795
12298
|
case "bucket":
|
|
9796
12299
|
case "buckets":
|
|
9797
12300
|
switch (action) {
|
|
@@ -9836,7 +12339,48 @@ function routeStorage(words) {
|
|
|
9836
12339
|
return null;
|
|
9837
12340
|
}
|
|
9838
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
|
+
}
|
|
12374
|
+
async function refuseTo() {
|
|
12375
|
+
throw new NaroError("VALIDATION_FAILED", "--to is only for naro promote.");
|
|
12376
|
+
}
|
|
9839
12377
|
function route(parsed) {
|
|
12378
|
+
const routed = routeCommand(parsed);
|
|
12379
|
+
if (routed && parsed.flags.to !== void 0 && parsed.command !== "promote" && parsed.command !== "rollback")
|
|
12380
|
+
return { handler: refuseTo, positionals: routed.positionals };
|
|
12381
|
+
return routed;
|
|
12382
|
+
}
|
|
12383
|
+
function routeCommand(parsed) {
|
|
9840
12384
|
const { command, positionals } = parsed;
|
|
9841
12385
|
const sub = positionals[0];
|
|
9842
12386
|
switch (command) {
|
|
@@ -10077,6 +12621,8 @@ function route(parsed) {
|
|
|
10077
12621
|
}
|
|
10078
12622
|
case "storage":
|
|
10079
12623
|
return routeStorage(positionals);
|
|
12624
|
+
case "realtime":
|
|
12625
|
+
return routeRealtime(positionals);
|
|
10080
12626
|
case "policy":
|
|
10081
12627
|
switch (sub) {
|
|
10082
12628
|
case void 0:
|