naro-cli 0.20.0 → 0.22.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 +114 -2
- package/dist/{chunk-SAA66XLD.js → chunk-MBSO3K6L.js} +1603 -564
- package/dist/deploy.js +1 -1
- package/dist/index.js +3062 -228
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
deployDirectory,
|
|
33
33
|
detectGitInfo,
|
|
34
34
|
ensureGitignoreHasNaro,
|
|
35
|
+
environmentGuidance,
|
|
36
|
+
environmentSlugProblem,
|
|
35
37
|
formatRedirectEntry,
|
|
36
38
|
generateTypes,
|
|
37
39
|
indexesFromRows,
|
|
@@ -97,7 +99,7 @@ import {
|
|
|
97
99
|
usageMonthOf,
|
|
98
100
|
userForAccessToken,
|
|
99
101
|
writeProjectLink
|
|
100
|
-
} from "./chunk-
|
|
102
|
+
} from "./chunk-MBSO3K6L.js";
|
|
101
103
|
import "./chunk-K3NQKI34.js";
|
|
102
104
|
|
|
103
105
|
// src/args.ts
|
|
@@ -543,6 +545,37 @@ import { readFileSync as readFileSync6 } from "fs";
|
|
|
543
545
|
import { dirname as dirname4, join as join6 } from "path";
|
|
544
546
|
import { fileURLToPath } from "url";
|
|
545
547
|
|
|
548
|
+
// src/data-environment.ts
|
|
549
|
+
var DATA_ENV_MESSAGES = {
|
|
550
|
+
name: "--env must be production or an environment name.",
|
|
551
|
+
preview: "--env preview has no data of its own: preview deployments use production's. Drop --env, or name an environment.",
|
|
552
|
+
projectWide: (command) => `--env is not an option of ${command}: turning the backend on or off applies to every environment.`
|
|
553
|
+
};
|
|
554
|
+
function dataEnvironment(flags) {
|
|
555
|
+
const value = flags.env;
|
|
556
|
+
if (value === void 0 || value === "production") return null;
|
|
557
|
+
if (value === "preview")
|
|
558
|
+
throw new NaroError("VALIDATION_FAILED", DATA_ENV_MESSAGES.preview);
|
|
559
|
+
if (environmentSlugProblem(value) !== null)
|
|
560
|
+
throw new NaroError("VALIDATION_FAILED", DATA_ENV_MESSAGES.name);
|
|
561
|
+
return value;
|
|
562
|
+
}
|
|
563
|
+
async function dataClient(context) {
|
|
564
|
+
return (await context.client()).inEnvironment(dataEnvironment(context.flags));
|
|
565
|
+
}
|
|
566
|
+
function databaseName(environment) {
|
|
567
|
+
return environment === null ? "the project database" : `the ${environment} environment's database`;
|
|
568
|
+
}
|
|
569
|
+
function enableBackendCommand(environment) {
|
|
570
|
+
return environment === null ? "naro backend enable" : `naro environments provision ${environment}`;
|
|
571
|
+
}
|
|
572
|
+
function envFlag(environment) {
|
|
573
|
+
return environment === null ? "" : ` --env ${environment}`;
|
|
574
|
+
}
|
|
575
|
+
function forEnvironment(text, environment) {
|
|
576
|
+
return environmentGuidance(text, environment);
|
|
577
|
+
}
|
|
578
|
+
|
|
546
579
|
// src/commands/migration.ts
|
|
547
580
|
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
548
581
|
import { join, relative } from "path";
|
|
@@ -641,7 +674,7 @@ async function runMigrationNew(context, deps = processStdin) {
|
|
|
641
674
|
]);
|
|
642
675
|
}
|
|
643
676
|
async function runMigrationList(context) {
|
|
644
|
-
const client = await context
|
|
677
|
+
const client = await dataClient(context);
|
|
645
678
|
const projectId = await context.resolveProjectId();
|
|
646
679
|
const dir = migrationsDir(context);
|
|
647
680
|
const local = listMigrationFiles(dir);
|
|
@@ -721,7 +754,7 @@ async function runMigrationRepair(context) {
|
|
|
721
754
|
"VALIDATION_FAILED",
|
|
722
755
|
"Pass --status applied or --status reverted."
|
|
723
756
|
);
|
|
724
|
-
const client = await context
|
|
757
|
+
const client = await dataClient(context);
|
|
725
758
|
const projectId = await context.resolveProjectId();
|
|
726
759
|
const ledger = await appliedMigrations(client, projectId);
|
|
727
760
|
const recorded = ledger.some(
|
|
@@ -735,7 +768,7 @@ async function runMigrationRepair(context) {
|
|
|
735
768
|
}
|
|
736
769
|
await confirmOrAbort(
|
|
737
770
|
context,
|
|
738
|
-
status === "applied" ? `Mark ${key} as applied in
|
|
771
|
+
status === "applied" ? `Mark ${key} as applied in ${databaseName(dataEnvironment(context.flags))}'s ledger? Its SQL is not run.` : `Remove ${key} from ${databaseName(dataEnvironment(context.flags))}'s ledger? Its SQL is not rolled back.`
|
|
739
772
|
);
|
|
740
773
|
if (status === "applied" && ledger.length === 0)
|
|
741
774
|
await client.db.query(projectId, { sql: LEDGER_DDL });
|
|
@@ -754,7 +787,7 @@ async function runMigrationRepair(context) {
|
|
|
754
787
|
]);
|
|
755
788
|
}
|
|
756
789
|
async function runMigrationUp(context) {
|
|
757
|
-
const client = await context
|
|
790
|
+
const client = await dataClient(context);
|
|
758
791
|
const projectId = await context.resolveProjectId();
|
|
759
792
|
const dir = migrationsDir(context);
|
|
760
793
|
const local = listMigrationFiles(dir);
|
|
@@ -782,7 +815,7 @@ async function runMigrationUp(context) {
|
|
|
782
815
|
for (const name of pending) context.output.log(` ${printable(name)}`);
|
|
783
816
|
await confirmOrAbort(
|
|
784
817
|
context,
|
|
785
|
-
`Apply ${String(pending.length)} migration(s) to
|
|
818
|
+
`Apply ${String(pending.length)} migration(s) to ${databaseName(dataEnvironment(context.flags))}? Each file is applied all-or-nothing; a failure stops the run.`
|
|
786
819
|
);
|
|
787
820
|
const { applied, warnings } = await applyMigrationFiles(
|
|
788
821
|
context,
|
|
@@ -1187,16 +1220,16 @@ var DISABLE_QUESTION = "Turn the backend off? In up to about 90 seconds your app
|
|
|
1187
1220
|
var OFF_ADVICE = "Turned off: your app's /_naro/* requests get 404. The data, users and keys are kept \u2014 `naro backend enable` turns it back on with the same keys.";
|
|
1188
1221
|
var TURNING_OFF_ADVICE = "Turning it off did not finish: your app's /_naro/* requests may still be answered. Run `naro backend disable` again to finish it \u2014 the control plane also retries every few minutes.";
|
|
1189
1222
|
var TURNING_OFF_NOW_ADVICE = "Turning off: another run is removing the routes right now. Check again in a minute with `naro backend`.";
|
|
1190
|
-
function leaseHeld(info,
|
|
1223
|
+
function leaseHeld(info, now2) {
|
|
1191
1224
|
if (info.leaseExpiresAt === null) return false;
|
|
1192
1225
|
const until = Date.parse(info.leaseExpiresAt);
|
|
1193
|
-
return !Number.isNaN(until) && until >
|
|
1226
|
+
return !Number.isNaN(until) && until > now2;
|
|
1194
1227
|
}
|
|
1195
1228
|
function sentence(text) {
|
|
1196
1229
|
const line = printable(text).trim();
|
|
1197
1230
|
return /[.!?…]$/.test(line) ? line : `${line}.`;
|
|
1198
1231
|
}
|
|
1199
|
-
function statusText(info,
|
|
1232
|
+
function statusText(info, now2) {
|
|
1200
1233
|
switch (info.status) {
|
|
1201
1234
|
case "none":
|
|
1202
1235
|
return "not provisioned";
|
|
@@ -1204,7 +1237,7 @@ function statusText(info, now) {
|
|
|
1204
1237
|
return "ready";
|
|
1205
1238
|
default: {
|
|
1206
1239
|
const text = info.step ? `${info.status} (step: ${info.step})` : info.status;
|
|
1207
|
-
return isStalledBackend(info,
|
|
1240
|
+
return isStalledBackend(info, now2) ? `${text}, stalled` : text;
|
|
1208
1241
|
}
|
|
1209
1242
|
}
|
|
1210
1243
|
}
|
|
@@ -1214,10 +1247,10 @@ function switchText(info) {
|
|
|
1214
1247
|
return info.disabledAt ? `turning off (since ${printable(info.disabledAt)})` : "turning off";
|
|
1215
1248
|
return info.disabledAt ? `off since ${printable(info.disabledAt)}` : "off";
|
|
1216
1249
|
}
|
|
1217
|
-
function backendLines(info,
|
|
1250
|
+
function backendLines(info, now2 = Date.now()) {
|
|
1218
1251
|
const lines = [
|
|
1219
1252
|
`Backend: ${switchText(info)}`,
|
|
1220
|
-
`Status: ${statusText(info,
|
|
1253
|
+
`Status: ${statusText(info, now2)}`,
|
|
1221
1254
|
`URL: ${info.url}`
|
|
1222
1255
|
];
|
|
1223
1256
|
if (info.anonKey) lines.push(`Anon key: ${info.anonKey}`);
|
|
@@ -1227,21 +1260,27 @@ function backendLines(info, now = Date.now()) {
|
|
|
1227
1260
|
);
|
|
1228
1261
|
return lines;
|
|
1229
1262
|
}
|
|
1230
|
-
|
|
1263
|
+
var ENVIRONMENT_TURNING_OFF_ADVICE = "Turning it off did not finish: your app's /_naro/* requests may still be answered. The control plane finishes it within a few minutes: the backend's switch is production's, and every environment follows it.";
|
|
1264
|
+
function adviceLines(info, now2, environment = null) {
|
|
1231
1265
|
if (!info.enabled && info.status !== "none") {
|
|
1232
1266
|
if (!info.turningOff) return [OFF_ADVICE];
|
|
1233
|
-
|
|
1267
|
+
if (leaseHeld(info, now2)) return [TURNING_OFF_NOW_ADVICE];
|
|
1268
|
+
return [
|
|
1269
|
+
environment === null ? TURNING_OFF_ADVICE : ENVIRONMENT_TURNING_OFF_ADVICE
|
|
1270
|
+
];
|
|
1234
1271
|
}
|
|
1235
1272
|
switch (info.status) {
|
|
1236
1273
|
case "none":
|
|
1237
|
-
return [
|
|
1274
|
+
return [
|
|
1275
|
+
environment === null ? "Run `naro backend enable` to turn it on." : `This environment has no backend yet: \`naro environments provision ${environment}\` makes it while production's backend is on \u2014 turn that on with \`naro backend enable\`.`
|
|
1276
|
+
];
|
|
1238
1277
|
case "error":
|
|
1239
1278
|
return [
|
|
1240
1279
|
...info.error ? [`Error: ${printable(info.error)}`] : [],
|
|
1241
1280
|
"Run `naro backend enable` again \u2014 it picks up where it stopped."
|
|
1242
1281
|
];
|
|
1243
1282
|
case "provisioning":
|
|
1244
|
-
return isStalledBackend(info,
|
|
1283
|
+
return isStalledBackend(info, now2) ? [
|
|
1245
1284
|
"No run is working on it: the last one stopped part-way. Run `naro backend enable` to pick it up."
|
|
1246
1285
|
] : [
|
|
1247
1286
|
"Provisioning is running. Check again in a minute with `naro backend`."
|
|
@@ -1255,17 +1294,21 @@ function stoppedAt(info) {
|
|
|
1255
1294
|
return `stopped at ${step}${info.error ? `: ${sentence(info.error)}` : "."}`;
|
|
1256
1295
|
}
|
|
1257
1296
|
async function runBackendInfo(context) {
|
|
1258
|
-
const client = await context
|
|
1297
|
+
const client = await dataClient(context);
|
|
1259
1298
|
const projectId = await context.resolveProjectId();
|
|
1260
1299
|
const backend = await client.backend.info(projectId);
|
|
1261
|
-
const
|
|
1300
|
+
const now2 = Date.now();
|
|
1301
|
+
const environment = dataEnvironment(context.flags);
|
|
1262
1302
|
context.output.result(
|
|
1263
|
-
{ projectId, backend, stalled: isStalledBackend(backend,
|
|
1264
|
-
[
|
|
1303
|
+
{ projectId, backend, stalled: isStalledBackend(backend, now2) },
|
|
1304
|
+
[
|
|
1305
|
+
...backendLines(backend, now2),
|
|
1306
|
+
...adviceLines(backend, now2, environment)
|
|
1307
|
+
].map((line) => forEnvironment(line, environment))
|
|
1265
1308
|
);
|
|
1266
1309
|
}
|
|
1267
1310
|
async function runBackendEnable(context) {
|
|
1268
|
-
const client = await context
|
|
1311
|
+
const client = await dataClient(context);
|
|
1269
1312
|
const projectId = await context.resolveProjectId();
|
|
1270
1313
|
let backend;
|
|
1271
1314
|
try {
|
|
@@ -1309,7 +1352,7 @@ async function runBackendEnable(context) {
|
|
|
1309
1352
|
}
|
|
1310
1353
|
async function runBackendDisable(context) {
|
|
1311
1354
|
await confirmOrAbort(context, DISABLE_QUESTION);
|
|
1312
|
-
const client = await context
|
|
1355
|
+
const client = await dataClient(context);
|
|
1313
1356
|
const projectId = await context.resolveProjectId();
|
|
1314
1357
|
const backend = await client.backend.disable(projectId);
|
|
1315
1358
|
if (backend.status === "none") {
|
|
@@ -1326,7 +1369,8 @@ async function runBackendDisable(context) {
|
|
|
1326
1369
|
]);
|
|
1327
1370
|
}
|
|
1328
1371
|
async function runBackendKeys(context) {
|
|
1329
|
-
const
|
|
1372
|
+
const environment = dataEnvironment(context.flags);
|
|
1373
|
+
const client = await dataClient(context);
|
|
1330
1374
|
const projectId = await context.resolveProjectId();
|
|
1331
1375
|
const keys = await client.backend.keys(projectId);
|
|
1332
1376
|
if (!context.flags.reveal) {
|
|
@@ -1339,7 +1383,10 @@ async function runBackendKeys(context) {
|
|
|
1339
1383
|
[
|
|
1340
1384
|
`Anon key: ${keys.anonKey}`,
|
|
1341
1385
|
`Service key: ${keys.serviceKeyPrefix}\u2026 (hidden)`,
|
|
1342
|
-
|
|
1386
|
+
forEnvironment(
|
|
1387
|
+
"`naro backend keys --reveal` prints the service key (needs database write access).",
|
|
1388
|
+
environment
|
|
1389
|
+
)
|
|
1343
1390
|
]
|
|
1344
1391
|
);
|
|
1345
1392
|
return;
|
|
@@ -1359,19 +1406,19 @@ function rotateKind(context) {
|
|
|
1359
1406
|
function keyLabel(kind) {
|
|
1360
1407
|
return kind === "anon" ? "Anon key: " : "Service key: ";
|
|
1361
1408
|
}
|
|
1362
|
-
function rotationWarning(kind, backend) {
|
|
1409
|
+
function rotationWarning(kind, backend, environment = null) {
|
|
1363
1410
|
if (backend.status === "error")
|
|
1364
|
-
return `\u26A0 The ${kind} key is replaced, but provisioning ${stoppedAt(backend)} Run \`
|
|
1411
|
+
return `\u26A0 The ${kind} key is replaced, but provisioning ${stoppedAt(backend)} Run \`${enableBackendCommand(environment)}\` to finish.`;
|
|
1365
1412
|
if (backend.status !== "ready")
|
|
1366
|
-
return `\u26A0 The ${kind} key is replaced; another provisioning run is finishing the backend. Check it with \`naro backend\`.`;
|
|
1413
|
+
return `\u26A0 The ${kind} key is replaced; another provisioning run is finishing the backend. Check it with \`naro backend${envFlag(environment)}\`.`;
|
|
1367
1414
|
return null;
|
|
1368
1415
|
}
|
|
1369
|
-
function checkKeysSentence(kind) {
|
|
1416
|
+
function checkKeysSentence(kind, environment = null) {
|
|
1370
1417
|
const keys = kind === "service" ? "naro backend keys --reveal" : "naro backend keys";
|
|
1371
|
-
return `Run \`${keys}\` to see which key is live, then
|
|
1418
|
+
return `Run \`${keys}${envFlag(environment)}\` to see which key is live, then \`${enableBackendCommand(environment)}\`.`;
|
|
1372
1419
|
}
|
|
1373
|
-
function rotationHint(kind) {
|
|
1374
|
-
return `The rotation may have gone through anyway. ${checkKeysSentence(kind)}`;
|
|
1420
|
+
function rotationHint(kind, environment = null) {
|
|
1421
|
+
return `The rotation may have gone through anyway. ${checkKeysSentence(kind, environment)}`;
|
|
1375
1422
|
}
|
|
1376
1423
|
function outcomeUnknown(error) {
|
|
1377
1424
|
return error.code === "BACKEND_ROTATION_FAILED" || error.code === "INTERNAL_ERROR" || error.code === "NETWORK_ERROR" || error.status !== void 0 && error.status >= 500;
|
|
@@ -1382,18 +1429,19 @@ async function runBackendKeysRotate(context) {
|
|
|
1382
1429
|
context,
|
|
1383
1430
|
`Replace the ${kind} key? Every client still sending the old one gets 401 INVALID_API_KEY as soon as the new key is live.`
|
|
1384
1431
|
);
|
|
1385
|
-
const client = await context
|
|
1432
|
+
const client = await dataClient(context);
|
|
1433
|
+
const environment = dataEnvironment(context.flags);
|
|
1386
1434
|
const projectId = await context.resolveProjectId();
|
|
1387
1435
|
let rotated;
|
|
1388
1436
|
try {
|
|
1389
1437
|
rotated = await client.backend.rotateKey(projectId, kind);
|
|
1390
1438
|
} catch (error) {
|
|
1391
1439
|
if (!isNaroError(error)) throw error;
|
|
1392
|
-
const hint = rotationHint(kind);
|
|
1440
|
+
const hint = rotationHint(kind, environment);
|
|
1393
1441
|
if (error.code === "TIMEOUT")
|
|
1394
1442
|
throw new NaroError(
|
|
1395
1443
|
"TIMEOUT",
|
|
1396
|
-
`Rotating the key did not answer within 2 minutes, so whether it happened is not known. ${checkKeysSentence(kind)}`,
|
|
1444
|
+
`Rotating the key did not answer within 2 minutes, so whether it happened is not known. ${checkKeysSentence(kind, environment)}`,
|
|
1397
1445
|
{ details: { hint }, cause: error }
|
|
1398
1446
|
);
|
|
1399
1447
|
if (!outcomeUnknown(error)) throw error;
|
|
@@ -1405,7 +1453,7 @@ async function runBackendKeysRotate(context) {
|
|
|
1405
1453
|
});
|
|
1406
1454
|
}
|
|
1407
1455
|
const { backend, key } = rotated;
|
|
1408
|
-
const warning = rotationWarning(kind, backend);
|
|
1456
|
+
const warning = rotationWarning(kind, backend, environment);
|
|
1409
1457
|
if (kind === "service") context.output.warn(SERVICE_KEY_WARNING);
|
|
1410
1458
|
if (warning) context.output.warn(warning);
|
|
1411
1459
|
context.output.result(
|
|
@@ -1442,14 +1490,21 @@ function sentence2(text) {
|
|
|
1442
1490
|
const line = printable(text).trim();
|
|
1443
1491
|
return /[.!?…]$/.test(line) ? line : `${line}.`;
|
|
1444
1492
|
}
|
|
1445
|
-
function stoppedMessage(backend) {
|
|
1493
|
+
function stoppedMessage(backend, environment = null) {
|
|
1494
|
+
const enable = enableBackendCommand(environment);
|
|
1446
1495
|
const step = backend.step ?? "worker";
|
|
1447
1496
|
const reason = backend.error?.trim() ? `: ${sentence2(backend.error)}` : ".";
|
|
1448
1497
|
if (BACKEND_STEPS.indexOf(step) > BACKEND_STEPS.indexOf("worker"))
|
|
1449
|
-
return `Saved and live on the Naro worker, but updating the backend stopped after the upload, at the ${step} step${reason} Run
|
|
1450
|
-
return `Saved, but not live yet: updating the Naro worker stopped at the ${step} step${reason} The worker keeps its previous settings until
|
|
1498
|
+
return `Saved and live on the Naro worker, but updating the backend stopped after the upload, at the ${step} step${reason} Run \`${enable}\` to finish.`;
|
|
1499
|
+
return `Saved, but not live yet: updating the Naro worker stopped at the ${step} step${reason} The worker keeps its previous settings until \`${enable}\` puts the saved ones on it.`;
|
|
1451
1500
|
}
|
|
1452
1501
|
var NOT_APPLIED_WARNING = "\u26A0 These are the saved settings. The Naro worker's last update did not get past its upload, so it may still run older ones \u2014 run `naro backend enable` to put these on it.";
|
|
1502
|
+
function notAppliedWarning(environment) {
|
|
1503
|
+
return environment === null ? NOT_APPLIED_WARNING : NOT_APPLIED_WARNING.replace(
|
|
1504
|
+
"`naro backend enable`",
|
|
1505
|
+
`\`${enableBackendCommand(environment)}\``
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1453
1508
|
function authLines(auth) {
|
|
1454
1509
|
const lines = [
|
|
1455
1510
|
auth.email ? `Email: ${auth.email.provider} \u2014 ${printable(auth.email.from)} (API key ${printable(auth.email.apiKeyPrefix)}\u2026)` : "Email: not set up"
|
|
@@ -1495,14 +1550,18 @@ function adviceLines2(auth) {
|
|
|
1495
1550
|
return lines;
|
|
1496
1551
|
}
|
|
1497
1552
|
async function runBackendAuthStatus(context) {
|
|
1498
|
-
const client = await context
|
|
1553
|
+
const client = await dataClient(context);
|
|
1499
1554
|
const projectId = await context.resolveProjectId();
|
|
1500
1555
|
const auth = await client.backend.auth(projectId);
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1556
|
+
const environment = dataEnvironment(context.flags);
|
|
1557
|
+
if (auth.applied === false)
|
|
1558
|
+
context.output.warn(notAppliedWarning(environment));
|
|
1559
|
+
context.output.result(
|
|
1560
|
+
{ projectId, auth },
|
|
1561
|
+
[...authLines(auth), ...adviceLines2(auth)].map(
|
|
1562
|
+
(line) => forEnvironment(line, environment)
|
|
1563
|
+
)
|
|
1564
|
+
);
|
|
1506
1565
|
}
|
|
1507
1566
|
async function readSecret(context, input, given, flag, what, usage) {
|
|
1508
1567
|
if (!given)
|
|
@@ -1518,6 +1577,7 @@ async function readSecret(context, input, given, flag, what, usage) {
|
|
|
1518
1577
|
}
|
|
1519
1578
|
async function change(context, send2, done, after) {
|
|
1520
1579
|
const projectId = await context.resolveProjectId();
|
|
1580
|
+
const environment = dataEnvironment(context.flags);
|
|
1521
1581
|
let answer;
|
|
1522
1582
|
try {
|
|
1523
1583
|
answer = await send2(projectId);
|
|
@@ -1525,13 +1585,13 @@ async function change(context, send2, done, after) {
|
|
|
1525
1585
|
if (isNaroError(error) && error.code === "TIMEOUT")
|
|
1526
1586
|
throw new NaroError(
|
|
1527
1587
|
"TIMEOUT",
|
|
1528
|
-
|
|
1588
|
+
`The change did not answer within 2 minutes. \`naro backend auth${envFlag(environment)}\` shows what is saved, and \`${enableBackendCommand(environment)}\` finishes putting it on the Naro worker.`,
|
|
1529
1589
|
{ cause: error }
|
|
1530
1590
|
);
|
|
1531
1591
|
const saved = isNaroError(error) ? error.details?.auth : void 0;
|
|
1532
1592
|
if (isNaroError(error) && error.code === "BACKEND_PROVISION_FAILED" && typeof saved === "object" && saved !== null) {
|
|
1533
1593
|
for (const line of authLines(saved))
|
|
1534
|
-
context.output.log(line);
|
|
1594
|
+
context.output.log(forEnvironment(line, environment));
|
|
1535
1595
|
throw new NaroError(error.code, error.message, {
|
|
1536
1596
|
details: { projectId, ...error.details }
|
|
1537
1597
|
});
|
|
@@ -1540,20 +1600,29 @@ async function change(context, send2, done, after) {
|
|
|
1540
1600
|
}
|
|
1541
1601
|
const { auth, backend } = answer;
|
|
1542
1602
|
if (backend.status === "error") {
|
|
1543
|
-
for (const line of authLines(auth))
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1603
|
+
for (const line of authLines(auth))
|
|
1604
|
+
context.output.log(forEnvironment(line, environment));
|
|
1605
|
+
throw new NaroError(
|
|
1606
|
+
"BACKEND_PROVISION_FAILED",
|
|
1607
|
+
stoppedMessage(backend, environment),
|
|
1608
|
+
{
|
|
1609
|
+
details: { projectId, auth, backend }
|
|
1610
|
+
}
|
|
1611
|
+
);
|
|
1547
1612
|
}
|
|
1548
1613
|
if (backend.status !== "ready")
|
|
1549
1614
|
context.output.warn(
|
|
1550
|
-
|
|
1615
|
+
forEnvironment(
|
|
1616
|
+
"\u26A0 Saved; another provisioning run is finishing the backend. Check it with `naro backend`.",
|
|
1617
|
+
environment
|
|
1618
|
+
)
|
|
1551
1619
|
);
|
|
1552
|
-
context.output.result(
|
|
1553
|
-
|
|
1554
|
-
...after ? after(auth) : [],
|
|
1555
|
-
|
|
1556
|
-
|
|
1620
|
+
context.output.result(
|
|
1621
|
+
{ projectId, auth, backend },
|
|
1622
|
+
[...done, ...after ? after(auth) : [], ...authLines(auth)].map(
|
|
1623
|
+
(line) => forEnvironment(line, environment)
|
|
1624
|
+
)
|
|
1625
|
+
);
|
|
1557
1626
|
}
|
|
1558
1627
|
async function runBackendAuthEmailSet(context, input = processStdin2) {
|
|
1559
1628
|
const { provider, from } = context.flags;
|
|
@@ -1572,7 +1641,7 @@ async function runBackendAuthEmailSet(context, input = processStdin2) {
|
|
|
1572
1641
|
"Resend API key",
|
|
1573
1642
|
AUTH_USAGE.email
|
|
1574
1643
|
);
|
|
1575
|
-
const client = await context
|
|
1644
|
+
const client = await dataClient(context);
|
|
1576
1645
|
await change(
|
|
1577
1646
|
context,
|
|
1578
1647
|
(projectId) => client.backend.setAuth(projectId, {
|
|
@@ -1588,7 +1657,7 @@ async function runBackendAuthEmailRemove(context) {
|
|
|
1588
1657
|
context,
|
|
1589
1658
|
"Remove email? Confirmation and password reset emails stop at once."
|
|
1590
1659
|
);
|
|
1591
|
-
const client = await context
|
|
1660
|
+
const client = await dataClient(context);
|
|
1592
1661
|
await change(
|
|
1593
1662
|
context,
|
|
1594
1663
|
(projectId) => client.backend.removeAuth(projectId, "email"),
|
|
@@ -1614,7 +1683,7 @@ async function runBackendAuthOAuthSet(context, input = processStdin2) {
|
|
|
1614
1683
|
`${LABELS[provider]} client secret`,
|
|
1615
1684
|
AUTH_USAGE.oauth
|
|
1616
1685
|
);
|
|
1617
|
-
const client = await context
|
|
1686
|
+
const client = await dataClient(context);
|
|
1618
1687
|
const patch = {
|
|
1619
1688
|
oauth: { [provider]: { clientId, clientSecret } }
|
|
1620
1689
|
};
|
|
@@ -1632,7 +1701,7 @@ async function runBackendAuthOAuthRemove(context) {
|
|
|
1632
1701
|
context,
|
|
1633
1702
|
`Remove ${LABELS[provider]} sign-in? Users who signed in with ${LABELS[provider]} cannot sign in with it until it is set up again.`
|
|
1634
1703
|
);
|
|
1635
|
-
const client = await context
|
|
1704
|
+
const client = await dataClient(context);
|
|
1636
1705
|
await change(
|
|
1637
1706
|
context,
|
|
1638
1707
|
(projectId) => client.backend.removeAuth(projectId, provider),
|
|
@@ -1640,7 +1709,7 @@ async function runBackendAuthOAuthRemove(context) {
|
|
|
1640
1709
|
);
|
|
1641
1710
|
}
|
|
1642
1711
|
async function runBackendAuthRedirectsList(context) {
|
|
1643
|
-
const client = await context
|
|
1712
|
+
const client = await dataClient(context);
|
|
1644
1713
|
const projectId = await context.resolveProjectId();
|
|
1645
1714
|
const auth = await client.backend.auth(projectId);
|
|
1646
1715
|
const production = productionOrigins(auth);
|
|
@@ -1657,7 +1726,10 @@ async function runBackendAuthRedirectsList(context) {
|
|
|
1657
1726
|
),
|
|
1658
1727
|
...auth.redirectOrigins,
|
|
1659
1728
|
...auth.redirectOrigins.length === 0 ? [
|
|
1660
|
-
|
|
1729
|
+
forEnvironment(
|
|
1730
|
+
"Only the project's own origin may be sent back to. Add another with `naro backend auth redirects add <url>` \u2014 an origin, or only the pages under a path like https://app.example.com/auth/.",
|
|
1731
|
+
dataEnvironment(context.flags)
|
|
1732
|
+
)
|
|
1661
1733
|
] : []
|
|
1662
1734
|
]
|
|
1663
1735
|
);
|
|
@@ -1670,7 +1742,7 @@ function originOf(context) {
|
|
|
1670
1742
|
}
|
|
1671
1743
|
async function runBackendAuthRedirectsAdd(context) {
|
|
1672
1744
|
const origin = originOf(context);
|
|
1673
|
-
const client = await context
|
|
1745
|
+
const client = await dataClient(context);
|
|
1674
1746
|
await change(
|
|
1675
1747
|
context,
|
|
1676
1748
|
(projectId) => client.backend.setAuth(projectId, {
|
|
@@ -1684,7 +1756,7 @@ function allowing(list, origin) {
|
|
|
1684
1756
|
}
|
|
1685
1757
|
async function runBackendAuthRedirectsRemove(context) {
|
|
1686
1758
|
const given = originOf(context);
|
|
1687
|
-
const client = await context
|
|
1759
|
+
const client = await dataClient(context);
|
|
1688
1760
|
const projectId = await context.resolveProjectId();
|
|
1689
1761
|
const parsed = parseRedirectEntry(given);
|
|
1690
1762
|
if (parsed) {
|
|
@@ -1730,7 +1802,7 @@ async function runBackendAuthRequireVerification(context) {
|
|
|
1730
1802
|
if (value !== "on" && value !== "off")
|
|
1731
1803
|
throw new NaroError("VALIDATION_FAILED", AUTH_USAGE.verification);
|
|
1732
1804
|
const on = value === "on";
|
|
1733
|
-
const client = await context
|
|
1805
|
+
const client = await dataClient(context);
|
|
1734
1806
|
await change(
|
|
1735
1807
|
context,
|
|
1736
1808
|
(projectId) => client.backend.setAuth(projectId, { requireEmailVerification: on }),
|
|
@@ -1744,7 +1816,7 @@ async function runBackendAuthSignups(context) {
|
|
|
1744
1816
|
if (value !== "on" && value !== "off")
|
|
1745
1817
|
throw new NaroError("VALIDATION_FAILED", AUTH_USAGE.signups);
|
|
1746
1818
|
const open = value === "on";
|
|
1747
|
-
const client = await context
|
|
1819
|
+
const client = await dataClient(context);
|
|
1748
1820
|
await change(
|
|
1749
1821
|
context,
|
|
1750
1822
|
(projectId) => client.backend.setAuth(projectId, { signupsEnabled: open }),
|
|
@@ -1763,7 +1835,7 @@ async function runBackendAuthPassword(context) {
|
|
|
1763
1835
|
"VALIDATION_FAILED",
|
|
1764
1836
|
`--min-length must be a whole number from ${String(MIN_LENGTH_FLOOR)} to ${String(MIN_LENGTH_CEILING)}. ${AUTH_USAGE.password}`
|
|
1765
1837
|
);
|
|
1766
|
-
const client = await context
|
|
1838
|
+
const client = await dataClient(context);
|
|
1767
1839
|
await change(
|
|
1768
1840
|
context,
|
|
1769
1841
|
(projectId) => client.backend.setAuth(projectId, { passwordMinLength: length }),
|
|
@@ -1835,13 +1907,16 @@ function userTable(users) {
|
|
|
1835
1907
|
);
|
|
1836
1908
|
}
|
|
1837
1909
|
async function runBackendUsersList(context) {
|
|
1838
|
-
const client = await context
|
|
1910
|
+
const client = await dataClient(context);
|
|
1839
1911
|
const projectId = await context.resolveProjectId();
|
|
1840
1912
|
const { users, total } = await allUsers(client, projectId);
|
|
1841
1913
|
context.output.result(
|
|
1842
1914
|
{ projectId, users, total },
|
|
1843
1915
|
users.length === 0 ? [
|
|
1844
|
-
|
|
1916
|
+
forEnvironment(
|
|
1917
|
+
"No users yet. Create one with `naro backend users create --username <name> --password-stdin`.",
|
|
1918
|
+
dataEnvironment(context.flags)
|
|
1919
|
+
)
|
|
1845
1920
|
] : [
|
|
1846
1921
|
`${String(total)} ${total === 1 ? "user" : "users"}, newest first:`,
|
|
1847
1922
|
...userTable(users)
|
|
@@ -1885,7 +1960,7 @@ async function runBackendUsersCreate(context, input = processStdin3) {
|
|
|
1885
1960
|
if (username === void 0 && email === void 0 || context.positionals.length > 0)
|
|
1886
1961
|
throw new NaroError("VALIDATION_FAILED", USERS_USAGE.create);
|
|
1887
1962
|
const password = await passwordOf(context, input);
|
|
1888
|
-
const client = await context
|
|
1963
|
+
const client = await dataClient(context);
|
|
1889
1964
|
const projectId = await context.resolveProjectId();
|
|
1890
1965
|
const body = { password };
|
|
1891
1966
|
if (username !== void 0) body.username = username;
|
|
@@ -1911,7 +1986,7 @@ async function runBackendUsersDelete(context) {
|
|
|
1911
1986
|
const [given] = context.positionals;
|
|
1912
1987
|
if (given === void 0 || context.positionals.length > 1)
|
|
1913
1988
|
throw new NaroError("VALIDATION_FAILED", USERS_USAGE.delete);
|
|
1914
|
-
const client = await context
|
|
1989
|
+
const client = await dataClient(context);
|
|
1915
1990
|
const projectId = await context.resolveProjectId();
|
|
1916
1991
|
const { users } = await allUsers(client, projectId);
|
|
1917
1992
|
const user = find(users, given);
|
|
@@ -2018,7 +2093,7 @@ function usageLines(info) {
|
|
|
2018
2093
|
}
|
|
2019
2094
|
return lines;
|
|
2020
2095
|
}
|
|
2021
|
-
function describeLines(info) {
|
|
2096
|
+
function describeLines(info, environment = null) {
|
|
2022
2097
|
const lines = [
|
|
2023
2098
|
`Provider: ${info.provider}`,
|
|
2024
2099
|
`Status: ${info.status}`,
|
|
@@ -2039,17 +2114,22 @@ function describeLines(info) {
|
|
|
2039
2114
|
);
|
|
2040
2115
|
lines.push(...usageLines(info));
|
|
2041
2116
|
if (info.provider === "none")
|
|
2042
|
-
lines.push(
|
|
2117
|
+
lines.push(
|
|
2118
|
+
environment === null ? "Run `naro db enable` to create the database." : `The ${environment} environment has no database yet. Run \`naro environments provision ${environment}\` to make it.`
|
|
2119
|
+
);
|
|
2043
2120
|
return lines;
|
|
2044
2121
|
}
|
|
2045
2122
|
async function runDbInfo(context) {
|
|
2046
|
-
const client = await context
|
|
2123
|
+
const client = await dataClient(context);
|
|
2047
2124
|
const projectId = await context.resolveProjectId();
|
|
2048
2125
|
const database = await client.db.info(projectId);
|
|
2049
|
-
context.output.result(
|
|
2126
|
+
context.output.result(
|
|
2127
|
+
{ projectId, database },
|
|
2128
|
+
describeLines(database, dataEnvironment(context.flags))
|
|
2129
|
+
);
|
|
2050
2130
|
}
|
|
2051
2131
|
async function runDbEnable(context) {
|
|
2052
|
-
const client = await context
|
|
2132
|
+
const client = await dataClient(context);
|
|
2053
2133
|
const projectId = await context.resolveProjectId();
|
|
2054
2134
|
const database = await client.db.enable(projectId);
|
|
2055
2135
|
context.output.result({ projectId, database }, [
|
|
@@ -2080,7 +2160,7 @@ async function readSql(context, deps) {
|
|
|
2080
2160
|
}
|
|
2081
2161
|
async function runDbQuery(context, deps = processStdin4) {
|
|
2082
2162
|
const sql = await readSql(context, deps);
|
|
2083
|
-
const client = await context
|
|
2163
|
+
const client = await dataClient(context);
|
|
2084
2164
|
const projectId = await context.resolveProjectId();
|
|
2085
2165
|
const result = await client.db.query(projectId, { sql });
|
|
2086
2166
|
const lines = [];
|
|
@@ -2115,14 +2195,16 @@ var defaults = {
|
|
|
2115
2195
|
writeFileSync2(path, data, { flag: "wx" });
|
|
2116
2196
|
}
|
|
2117
2197
|
};
|
|
2118
|
-
|
|
2198
|
+
function exportNotice(environment) {
|
|
2199
|
+
return `Exporting ${databaseName(environment)}. Cloudflare holds other queries to this database until the export finishes.`;
|
|
2200
|
+
}
|
|
2119
2201
|
function reasonOf(error) {
|
|
2120
2202
|
return error instanceof Error ? error.message : String(error);
|
|
2121
2203
|
}
|
|
2122
2204
|
function withoutUrl(text, url) {
|
|
2123
2205
|
return text.split(url).join("the signed URL").replace(/\S*X-Amz-Signature=\S*/gi, "the signed URL");
|
|
2124
2206
|
}
|
|
2125
|
-
function downloadFailed(url, reason, original) {
|
|
2207
|
+
function downloadFailed(url, reason, original, again = "naro db dump") {
|
|
2126
2208
|
const scrubbed = withoutUrl(reason, url);
|
|
2127
2209
|
let cause;
|
|
2128
2210
|
if (original !== void 0) {
|
|
@@ -2135,7 +2217,7 @@ function downloadFailed(url, reason, original) {
|
|
|
2135
2217
|
}
|
|
2136
2218
|
return new NaroError(
|
|
2137
2219
|
"DATABASE_UNAVAILABLE",
|
|
2138
|
-
`Could not download the dump (${scrubbed}). Run
|
|
2220
|
+
`Could not download the dump (${scrubbed}). Run ${again} again.`,
|
|
2139
2221
|
cause === void 0 ? {} : { cause }
|
|
2140
2222
|
);
|
|
2141
2223
|
}
|
|
@@ -2153,17 +2235,22 @@ var DownloadStreamError = class extends Error {
|
|
|
2153
2235
|
this.name = "DownloadStreamError";
|
|
2154
2236
|
}
|
|
2155
2237
|
};
|
|
2156
|
-
async function openDownload(deps, url) {
|
|
2238
|
+
async function openDownload(deps, url, again) {
|
|
2157
2239
|
let response;
|
|
2158
2240
|
try {
|
|
2159
2241
|
response = await deps.fetch(url);
|
|
2160
2242
|
} catch (error) {
|
|
2161
|
-
throw downloadFailed(url, reasonOf(error), error);
|
|
2243
|
+
throw downloadFailed(url, reasonOf(error), error, again);
|
|
2162
2244
|
}
|
|
2163
2245
|
if (!response.ok || response.body === null) {
|
|
2164
2246
|
await response.body?.cancel().catch(() => {
|
|
2165
2247
|
});
|
|
2166
|
-
throw downloadFailed(
|
|
2248
|
+
throw downloadFailed(
|
|
2249
|
+
url,
|
|
2250
|
+
`HTTP ${String(response.status)}`,
|
|
2251
|
+
void 0,
|
|
2252
|
+
again
|
|
2253
|
+
);
|
|
2167
2254
|
}
|
|
2168
2255
|
return response.body;
|
|
2169
2256
|
}
|
|
@@ -2198,8 +2285,8 @@ async function streamInto(body, destination, end) {
|
|
|
2198
2285
|
await pipeline(chunksOf(body), counter, destination, { end });
|
|
2199
2286
|
return bytes;
|
|
2200
2287
|
}
|
|
2201
|
-
function streamFailed(error, url, shown) {
|
|
2202
|
-
return error instanceof DownloadStreamError ? downloadFailed(url, reasonOf(error.original), error.original) : writeFailed(shown, error);
|
|
2288
|
+
function streamFailed(error, url, shown, again) {
|
|
2289
|
+
return error instanceof DownloadStreamError ? downloadFailed(url, reasonOf(error.original), error.original, again) : writeFailed(shown, error);
|
|
2203
2290
|
}
|
|
2204
2291
|
function refuseDirectory(path, shown) {
|
|
2205
2292
|
let isDirectory = false;
|
|
@@ -2220,8 +2307,8 @@ function makeOutputDirectory(path, shown) {
|
|
|
2220
2307
|
throw writeFailed(shown, error);
|
|
2221
2308
|
}
|
|
2222
2309
|
}
|
|
2223
|
-
async function downloadToFile(deps, url, path, shown) {
|
|
2224
|
-
const body = await openDownload(deps, url);
|
|
2310
|
+
async function downloadToFile(deps, url, path, shown, again) {
|
|
2311
|
+
const body = await openDownload(deps, url, again);
|
|
2225
2312
|
const partial = join2(
|
|
2226
2313
|
dirname(path),
|
|
2227
2314
|
`.${basename(path)}.${String(process.pid)}.part`
|
|
@@ -2235,15 +2322,15 @@ async function downloadToFile(deps, url, path, shown) {
|
|
|
2235
2322
|
rmSync(partial, { force: true });
|
|
2236
2323
|
} catch {
|
|
2237
2324
|
}
|
|
2238
|
-
throw streamFailed(error, url, shown);
|
|
2325
|
+
throw streamFailed(error, url, shown, again);
|
|
2239
2326
|
}
|
|
2240
2327
|
}
|
|
2241
|
-
async function downloadToStdout(deps, url) {
|
|
2242
|
-
const body = await openDownload(deps, url);
|
|
2328
|
+
async function downloadToStdout(deps, url, again) {
|
|
2329
|
+
const body = await openDownload(deps, url, again);
|
|
2243
2330
|
try {
|
|
2244
2331
|
return await streamInto(body, deps.stdout, false);
|
|
2245
2332
|
} catch (error) {
|
|
2246
|
-
throw streamFailed(error, url, "stdout");
|
|
2333
|
+
throw streamFailed(error, url, "stdout", again);
|
|
2247
2334
|
}
|
|
2248
2335
|
}
|
|
2249
2336
|
async function runDbDump(context, overrides = {}) {
|
|
@@ -2272,10 +2359,12 @@ async function runDbDump(context, overrides = {}) {
|
|
|
2272
2359
|
};
|
|
2273
2360
|
const output = flags.output === void 0 ? null : { path: resolve2(context.cwd, flags.output), shown: flags.output };
|
|
2274
2361
|
if (output) refuseDirectory(output.path, output.shown);
|
|
2275
|
-
const client = await context
|
|
2362
|
+
const client = await dataClient(context);
|
|
2276
2363
|
const projectId = await context.resolveProjectId();
|
|
2277
2364
|
if (output) makeOutputDirectory(output.path, output.shown);
|
|
2278
|
-
context.
|
|
2365
|
+
const environment = dataEnvironment(context.flags);
|
|
2366
|
+
const again = `naro db dump${envFlag(environment)}`;
|
|
2367
|
+
context.output.warn(exportNotice(environment));
|
|
2279
2368
|
const step = await client.db.export(projectId, options, {
|
|
2280
2369
|
sleep: deps.sleep
|
|
2281
2370
|
});
|
|
@@ -2283,16 +2372,16 @@ async function runDbDump(context, overrides = {}) {
|
|
|
2283
2372
|
if (file === null)
|
|
2284
2373
|
throw new NaroError(
|
|
2285
2374
|
"DATABASE_UNAVAILABLE",
|
|
2286
|
-
|
|
2375
|
+
`Could not download the dump (Cloudflare finished the export without a file). Run ${again} again.`
|
|
2287
2376
|
);
|
|
2288
|
-
const bytes = output === null ? await downloadToStdout(deps, file.url) : await downloadToFile(deps, file.url, output.path, output.shown);
|
|
2377
|
+
const bytes = output === null ? await downloadToStdout(deps, file.url, again) : await downloadToFile(deps, file.url, output.path, output.shown, again);
|
|
2289
2378
|
const bookmark = step.atBookmark;
|
|
2290
2379
|
if (!context.output.json) {
|
|
2291
2380
|
context.output.warn(
|
|
2292
2381
|
`\u2713 Wrote ${flags.output ?? "stdout"} (${humanSize(bytes)}) at bookmark ${bookmark}.`
|
|
2293
2382
|
);
|
|
2294
2383
|
context.output.warn(
|
|
2295
|
-
` naro db restore --bookmark ${bookmark} returns the database to this point.`
|
|
2384
|
+
` naro db restore --bookmark ${bookmark}${envFlag(dataEnvironment(context.flags))} returns the database to this point.`
|
|
2296
2385
|
);
|
|
2297
2386
|
}
|
|
2298
2387
|
context.output.result({ output: flags.output, bytes, atBookmark: bookmark });
|
|
@@ -2313,7 +2402,7 @@ function withWayBack(context, error, before) {
|
|
|
2313
2402
|
return error;
|
|
2314
2403
|
if (!context.output.json)
|
|
2315
2404
|
context.output.warn(
|
|
2316
|
-
`Before this attempt the database was at bookmark ${before}. If the restore ran anyway, naro db restore --bookmark ${before} undoes it.`
|
|
2405
|
+
`Before this attempt the database was at bookmark ${before}. If the restore ran anyway, naro db restore --bookmark ${before}${envFlag(dataEnvironment(context.flags))} undoes it.`
|
|
2317
2406
|
);
|
|
2318
2407
|
return new NaroError(error.code, error.message, {
|
|
2319
2408
|
...error.status === void 0 ? {} : { status: error.status },
|
|
@@ -2331,7 +2420,7 @@ async function runDbRestore(context) {
|
|
|
2331
2420
|
);
|
|
2332
2421
|
if ("bookmark" in given && !isBookmark(given.bookmark))
|
|
2333
2422
|
throw new NaroError("VALIDATION_FAILED", BOOKMARK_FORMAT_MESSAGE);
|
|
2334
|
-
const client = await context
|
|
2423
|
+
const client = await dataClient(context);
|
|
2335
2424
|
const projectId = await context.resolveProjectId();
|
|
2336
2425
|
let target;
|
|
2337
2426
|
let at = null;
|
|
@@ -2358,7 +2447,7 @@ async function runDbRestore(context) {
|
|
|
2358
2447
|
}
|
|
2359
2448
|
await confirmOrAbort(
|
|
2360
2449
|
context,
|
|
2361
|
-
`Restore
|
|
2450
|
+
`Restore ${databaseName(dataEnvironment(context.flags))} to ${label3}? Everything written after that point is rolled back \u2014 migration history included \u2014 and queries running right now are cancelled.`
|
|
2362
2451
|
);
|
|
2363
2452
|
const before = await bookmarkNow(context, client, projectId);
|
|
2364
2453
|
let restored;
|
|
@@ -2373,8 +2462,8 @@ async function runDbRestore(context) {
|
|
|
2373
2462
|
previousBookmark: restored.previousBookmark
|
|
2374
2463
|
},
|
|
2375
2464
|
[
|
|
2376
|
-
`\u2713 Restored
|
|
2377
|
-
` To undo: naro db restore --bookmark ${restored.previousBookmark}`
|
|
2465
|
+
`\u2713 Restored ${databaseName(dataEnvironment(context.flags))} to bookmark ${restored.bookmark}.`,
|
|
2466
|
+
` To undo: naro db restore --bookmark ${restored.previousBookmark}${envFlag(dataEnvironment(context.flags))}`
|
|
2378
2467
|
]
|
|
2379
2468
|
);
|
|
2380
2469
|
}
|
|
@@ -2390,7 +2479,7 @@ async function runDbPull(context, overrides = {}) {
|
|
|
2390
2479
|
const name = nextMigrationFileName(existing, "remote_schema");
|
|
2391
2480
|
const path = join2(dir, name);
|
|
2392
2481
|
const shown = relativeToCwd(context, path);
|
|
2393
|
-
const client = await context
|
|
2482
|
+
const client = await dataClient(context);
|
|
2394
2483
|
const projectId = await context.resolveProjectId();
|
|
2395
2484
|
await confirmOrAbort(
|
|
2396
2485
|
context,
|
|
@@ -2524,7 +2613,7 @@ function issueLines(issues, tables) {
|
|
|
2524
2613
|
];
|
|
2525
2614
|
}
|
|
2526
2615
|
async function runDbLint(context) {
|
|
2527
|
-
const client = await context
|
|
2616
|
+
const client = await dataClient(context);
|
|
2528
2617
|
const projectId = await context.resolveProjectId();
|
|
2529
2618
|
const tables = await client.db.tables(projectId);
|
|
2530
2619
|
const answer = await client.db.query(projectId, { sql: DIAGNOSTICS_SQL });
|
|
@@ -2730,7 +2819,7 @@ function resetStopped(context, error, bookmark, lead, message) {
|
|
|
2730
2819
|
const naroError = isNaroError(error) ? error : toNaroError(error);
|
|
2731
2820
|
if (!context.output.json)
|
|
2732
2821
|
context.output.warn(
|
|
2733
|
-
bookmark === null ? lead : `${lead} The database was at bookmark ${bookmark} \u2014 naro db restore --bookmark ${bookmark} undoes the reset.`
|
|
2822
|
+
bookmark === null ? lead : `${lead} The database was at bookmark ${bookmark} \u2014 naro db restore --bookmark ${bookmark}${envFlag(dataEnvironment(context.flags))} undoes the reset.`
|
|
2734
2823
|
);
|
|
2735
2824
|
return new NaroError(naroError.code, message(naroError.message), {
|
|
2736
2825
|
...naroError.status === void 0 ? {} : { status: naroError.status },
|
|
@@ -2757,7 +2846,7 @@ ${problemLines.join("\n")}`,
|
|
|
2757
2846
|
{ details: { problems } }
|
|
2758
2847
|
);
|
|
2759
2848
|
const texts = files.map((name) => readFileSync3(join3(dir, name), "utf8"));
|
|
2760
|
-
const client = await context
|
|
2849
|
+
const client = await dataClient(context);
|
|
2761
2850
|
const projectId = await context.resolveProjectId();
|
|
2762
2851
|
const bookmark = context.flags.dryRun ? null : await bookmarkNow(context, client, projectId, "resetting");
|
|
2763
2852
|
const { policyTable: policyTable2, storageTable, ...input } = await readSchema(
|
|
@@ -2831,11 +2920,11 @@ ${problemLines.join("\n")}`,
|
|
|
2831
2920
|
}
|
|
2832
2921
|
if (bookmark !== null)
|
|
2833
2922
|
context.output.log(
|
|
2834
|
-
`The database is at bookmark ${bookmark} \u2014 naro db restore --bookmark ${bookmark} undoes the reset.`
|
|
2923
|
+
`The database is at bookmark ${bookmark} \u2014 naro db restore --bookmark ${bookmark}${envFlag(dataEnvironment(context.flags))} undoes the reset.`
|
|
2835
2924
|
);
|
|
2836
2925
|
await confirmOrAbort(
|
|
2837
2926
|
context,
|
|
2838
|
-
`Reset
|
|
2927
|
+
`Reset ${databaseName(dataEnvironment(context.flags))}? This drops ${counts}, clears the migration ledger, then re-applies ${String(files.length)} migration file(s) from ${shownDir}.`
|
|
2839
2928
|
);
|
|
2840
2929
|
let done = 0;
|
|
2841
2930
|
const total = plan.batches.length;
|
|
@@ -2887,8 +2976,10 @@ ${problemLines.join("\n")}`,
|
|
|
2887
2976
|
warnings
|
|
2888
2977
|
},
|
|
2889
2978
|
[
|
|
2890
|
-
`\u2713 Reset
|
|
2891
|
-
...bookmark === null ? [] : [
|
|
2979
|
+
`\u2713 Reset ${databaseName(dataEnvironment(context.flags))}: dropped ${String(dropped)} object(s), re-applied ${String(reapplied.length)} migration(s).`,
|
|
2980
|
+
...bookmark === null ? [] : [
|
|
2981
|
+
` To undo: naro db restore --bookmark ${bookmark}${envFlag(dataEnvironment(context.flags))}`
|
|
2982
|
+
]
|
|
2892
2983
|
]
|
|
2893
2984
|
);
|
|
2894
2985
|
}
|
|
@@ -3186,10 +3277,10 @@ function truth(text) {
|
|
|
3186
3277
|
var DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
3187
3278
|
function exactNumber(text) {
|
|
3188
3279
|
if (!DECIMAL.test(text)) return text;
|
|
3189
|
-
const
|
|
3190
|
-
if (!text.includes(".")) return Number.isSafeInteger(
|
|
3280
|
+
const number2 = Number(text);
|
|
3281
|
+
if (!text.includes(".")) return Number.isSafeInteger(number2) ? number2 : text;
|
|
3191
3282
|
const trimmed = text.replace(/\.?0+$/, "");
|
|
3192
|
-
return String(
|
|
3283
|
+
return String(number2) === trimmed ? number2 : text;
|
|
3193
3284
|
}
|
|
3194
3285
|
function quoteFilterValue(value) {
|
|
3195
3286
|
const text = String(value);
|
|
@@ -3524,6 +3615,12 @@ function policyTable(policies) {
|
|
|
3524
3615
|
];
|
|
3525
3616
|
}
|
|
3526
3617
|
var UNSHOWN = "has characters that cannot be shown here";
|
|
3618
|
+
function hintsFor(lines, environment) {
|
|
3619
|
+
if (environment === null) return lines;
|
|
3620
|
+
return lines.map(
|
|
3621
|
+
(line) => line.includes(UNSHOWN) || line.startsWith("Add one with ") ? forEnvironment(line, environment) : line
|
|
3622
|
+
);
|
|
3623
|
+
}
|
|
3527
3624
|
function statementLine(policy) {
|
|
3528
3625
|
if (policy.invalid !== null)
|
|
3529
3626
|
return printable(
|
|
@@ -3556,7 +3653,7 @@ function policyStatements(policies) {
|
|
|
3556
3653
|
}
|
|
3557
3654
|
async function runPolicyList(context) {
|
|
3558
3655
|
const table3 = context.flags.table;
|
|
3559
|
-
const client = await context
|
|
3656
|
+
const client = await dataClient(context);
|
|
3560
3657
|
const projectId = await context.resolveProjectId();
|
|
3561
3658
|
const policies = await client.backend.rlsPolicies(
|
|
3562
3659
|
projectId,
|
|
@@ -3574,7 +3671,10 @@ async function runPolicyList(context) {
|
|
|
3574
3671
|
"No policies yet: every table denies anon and signed-in requests.",
|
|
3575
3672
|
'Add one with `naro policy create "CREATE POLICY \u2026"` or `naro policy set <table> --select public`.'
|
|
3576
3673
|
];
|
|
3577
|
-
context.output.result(
|
|
3674
|
+
context.output.result(
|
|
3675
|
+
{ projectId, policies },
|
|
3676
|
+
hintsFor(lines, dataEnvironment(context.flags))
|
|
3677
|
+
);
|
|
3578
3678
|
}
|
|
3579
3679
|
function statementText(context) {
|
|
3580
3680
|
const [positional] = context.positionals;
|
|
@@ -3648,7 +3748,7 @@ async function runStatements(client, projectId, statements, allowUnindexed) {
|
|
|
3648
3748
|
}
|
|
3649
3749
|
async function runPolicyCreate(context) {
|
|
3650
3750
|
const statements = policyStatementsOf(statementText(context));
|
|
3651
|
-
const client = await context
|
|
3751
|
+
const client = await dataClient(context);
|
|
3652
3752
|
const projectId = await context.resolveProjectId();
|
|
3653
3753
|
const applied = await runStatements(
|
|
3654
3754
|
client,
|
|
@@ -3669,7 +3769,7 @@ async function runPolicyDrop(context) {
|
|
|
3669
3769
|
context,
|
|
3670
3770
|
`Drop policy ${printable(name)} on ${printable(table3)}? It stops applying within about 10 seconds.`
|
|
3671
3771
|
);
|
|
3672
|
-
const client = await context
|
|
3772
|
+
const client = await dataClient(context);
|
|
3673
3773
|
const projectId = await context.resolveProjectId();
|
|
3674
3774
|
const dropped = await client.backend.dropRlsPolicy(
|
|
3675
3775
|
projectId,
|
|
@@ -3683,7 +3783,7 @@ async function runPolicyDrop(context) {
|
|
|
3683
3783
|
async function runPolicySet(context) {
|
|
3684
3784
|
const [table3] = context.positionalOrThrow(1, SET_USAGE);
|
|
3685
3785
|
const patch = policyPatch(context.flags, context, table3);
|
|
3686
|
-
const client = await context
|
|
3786
|
+
const client = await dataClient(context);
|
|
3687
3787
|
const projectId = await context.resolveProjectId();
|
|
3688
3788
|
const policy = await client.backend.setPolicy(
|
|
3689
3789
|
projectId,
|
|
@@ -3700,11 +3800,14 @@ async function runPolicySet(context) {
|
|
|
3700
3800
|
}
|
|
3701
3801
|
context.output.result(
|
|
3702
3802
|
{ projectId, policy, ...named === void 0 ? {} : { policies: named } },
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3803
|
+
hintsFor(
|
|
3804
|
+
[
|
|
3805
|
+
`\u2705 Saved the policy for ${printable(policy.table)}. ${TAKES_EFFECT}`,
|
|
3806
|
+
...policyTable([policy]),
|
|
3807
|
+
...named === void 0 || named.length === 0 ? [] : ["", ...policyStatements(named)]
|
|
3808
|
+
],
|
|
3809
|
+
dataEnvironment(context.flags)
|
|
3810
|
+
)
|
|
3708
3811
|
);
|
|
3709
3812
|
}
|
|
3710
3813
|
async function runPolicyRemove(context) {
|
|
@@ -3713,7 +3816,7 @@ async function runPolicyRemove(context) {
|
|
|
3713
3816
|
context,
|
|
3714
3817
|
`Remove every policy on ${printable(table3)}? Anon and signed-in requests to it are denied from then on.`
|
|
3715
3818
|
);
|
|
3716
|
-
const client = await context
|
|
3819
|
+
const client = await dataClient(context);
|
|
3717
3820
|
const projectId = await context.resolveProjectId();
|
|
3718
3821
|
const policy = await client.backend.removePolicy(projectId, table3);
|
|
3719
3822
|
const names = policy.policies ?? [];
|
|
@@ -3846,7 +3949,7 @@ function ruleText(value) {
|
|
|
3846
3949
|
return "invalid" in value ? "invalid" : "condition";
|
|
3847
3950
|
}
|
|
3848
3951
|
var SUGAR_NAME = /^naro_(?:select|insert|update|delete)$/;
|
|
3849
|
-
function removedLines(policy, dryRun) {
|
|
3952
|
+
function removedLines(policy, dryRun, environment) {
|
|
3850
3953
|
const verb = dryRun ? "Would remove" : "\u2713 Removed";
|
|
3851
3954
|
const names = policy.policies ?? [];
|
|
3852
3955
|
let line;
|
|
@@ -3884,7 +3987,11 @@ function removedLines(policy, dryRun) {
|
|
|
3884
3987
|
lines.push(
|
|
3885
3988
|
` As a command, for the owner ${one ? "rule" : "rules"} (${owners.map((owner) => printable(owner.name)).join(", ")}): CREATE POLICY has no owner column, so naro policy create cannot make ${one ? "it" : "them"} again.`,
|
|
3886
3989
|
// The server's bytes, or not shown: a name may hold a line break.
|
|
3887
|
-
|
|
3990
|
+
// Under --env, the command acts on the environment (PR #343 review 2).
|
|
3991
|
+
typeof command !== "string" ? forEnvironment(
|
|
3992
|
+
" naro policy set <table> --<action> owner --owner <column> sets one.",
|
|
3993
|
+
environment
|
|
3994
|
+
) : isPrintable(command) ? forEnvironment(command, environment) : " The command has characters that cannot be shown here: --json has the exact text."
|
|
3888
3995
|
);
|
|
3889
3996
|
}
|
|
3890
3997
|
return lines;
|
|
@@ -3893,13 +4000,13 @@ async function runSchemaChange(context, operations, question) {
|
|
|
3893
4000
|
const dryRun = context.flags.dryRun === true;
|
|
3894
4001
|
if (question !== void 0 && !dryRun)
|
|
3895
4002
|
await confirmOrAbort(context, question);
|
|
3896
|
-
const
|
|
4003
|
+
const sent2 = dryRun ? operations.map((operation) => {
|
|
3897
4004
|
if (operation.op !== "dropTable" && operation.op !== "dropColumn")
|
|
3898
4005
|
return operation;
|
|
3899
4006
|
const { confirm: _confirm, ...rest } = operation;
|
|
3900
4007
|
return rest;
|
|
3901
4008
|
}) : operations;
|
|
3902
|
-
const client = await context
|
|
4009
|
+
const client = await dataClient(context);
|
|
3903
4010
|
const projectId = await context.resolveProjectId();
|
|
3904
4011
|
const dir = migrationsDir(context);
|
|
3905
4012
|
const local = listMigrationFiles(dir);
|
|
@@ -3911,7 +4018,7 @@ async function runSchemaChange(context, operations, question) {
|
|
|
3911
4018
|
[...local, ...ledger.map((row) => row.name)],
|
|
3912
4019
|
schemaMigrationTitle(operations[0] ?? {})
|
|
3913
4020
|
);
|
|
3914
|
-
const result = await client.db.schema(projectId,
|
|
4021
|
+
const result = await client.db.schema(projectId, sent2, {
|
|
3915
4022
|
...dryRun ? { dryRun: true } : {},
|
|
3916
4023
|
name
|
|
3917
4024
|
});
|
|
@@ -3924,7 +4031,9 @@ async function runSchemaChange(context, operations, question) {
|
|
|
3924
4031
|
...sqlLines,
|
|
3925
4032
|
`Dry run \u2014 nothing was applied. It would be recorded as ${printable(result.name)}.`,
|
|
3926
4033
|
...result.policies.map((policy) => policyLine(policy, true)),
|
|
3927
|
-
...removed.flatMap(
|
|
4034
|
+
...removed.flatMap(
|
|
4035
|
+
(policy) => removedLines(policy, true, dataEnvironment(context.flags))
|
|
4036
|
+
)
|
|
3928
4037
|
]);
|
|
3929
4038
|
return;
|
|
3930
4039
|
}
|
|
@@ -3946,7 +4055,10 @@ async function runSchemaChange(context, operations, question) {
|
|
|
3946
4055
|
}
|
|
3947
4056
|
}
|
|
3948
4057
|
for (const warning of allWarnings)
|
|
3949
|
-
context.output.warn(
|
|
4058
|
+
context.output.warn(
|
|
4059
|
+
`\u26A0 ${printable(forEnvironment(warning, dataEnvironment(context.flags)))}`
|
|
4060
|
+
);
|
|
4061
|
+
fileWarning &&= forEnvironment(fileWarning, dataEnvironment(context.flags));
|
|
3950
4062
|
if (fileWarning) context.output.warn(`\u26A0 ${printable(fileWarning)}`);
|
|
3951
4063
|
context.output.result(
|
|
3952
4064
|
{
|
|
@@ -3960,7 +4072,9 @@ async function runSchemaChange(context, operations, question) {
|
|
|
3960
4072
|
...sqlLines,
|
|
3961
4073
|
file === null ? `\u2705 Applied ${printable(result.name)}.` : `\u2705 Applied ${printable(result.name)} and wrote ${printable(file)}.`,
|
|
3962
4074
|
...result.policies.map((policy) => policyLine(policy)),
|
|
3963
|
-
...removed.flatMap(
|
|
4075
|
+
...removed.flatMap(
|
|
4076
|
+
(policy) => removedLines(policy, false, dataEnvironment(context.flags))
|
|
4077
|
+
)
|
|
3964
4078
|
]
|
|
3965
4079
|
);
|
|
3966
4080
|
}
|
|
@@ -4011,24 +4125,29 @@ async function runDbTableRename(context) {
|
|
|
4011
4125
|
if (!table3 || !to) throw new NaroError("VALIDATION_FAILED", USAGE2.rename);
|
|
4012
4126
|
await runSchemaChange(context, [{ op: "renameTable", table: table3, to }]);
|
|
4013
4127
|
}
|
|
4128
|
+
function inDatabase(environment) {
|
|
4129
|
+
return environment === null ? "" : ` in ${databaseName(environment)}`;
|
|
4130
|
+
}
|
|
4014
4131
|
async function runDbTableDropColumn(context) {
|
|
4015
4132
|
const [table3, column] = context.positionals;
|
|
4016
4133
|
if (!table3 || !column)
|
|
4017
4134
|
throw new NaroError("VALIDATION_FAILED", USAGE2.dropColumn);
|
|
4018
4135
|
const target = `${table3}.${column}`;
|
|
4136
|
+
const env = dataEnvironment(context.flags);
|
|
4019
4137
|
await runSchemaChange(
|
|
4020
4138
|
context,
|
|
4021
4139
|
[{ op: "dropColumn", table: table3, column, confirm: target }],
|
|
4022
|
-
`Drop column ${printable(target)} and its value in every row? naro db restore can bring it back (Time Travel, 30 days).`
|
|
4140
|
+
`Drop column ${printable(target)}${inDatabase(env)} and its value in every row? naro db restore${envFlag(env)} can bring it back (Time Travel, 30 days).`
|
|
4023
4141
|
);
|
|
4024
4142
|
}
|
|
4025
4143
|
async function runDbTableDrop(context) {
|
|
4026
4144
|
const [table3] = context.positionals;
|
|
4027
4145
|
if (!table3) throw new NaroError("VALIDATION_FAILED", USAGE2.drop);
|
|
4146
|
+
const env = dataEnvironment(context.flags);
|
|
4028
4147
|
await runSchemaChange(
|
|
4029
4148
|
context,
|
|
4030
4149
|
[{ op: "dropTable", table: table3, confirm: table3 }],
|
|
4031
|
-
`Drop table ${printable(table3)} and every row in it? naro db restore can bring it back (Time Travel, 30 days).`
|
|
4150
|
+
`Drop table ${printable(table3)}${inDatabase(env)} and every row in it? naro db restore${envFlag(env)} can bring it back (Time Travel, 30 days).`
|
|
4032
4151
|
);
|
|
4033
4152
|
}
|
|
4034
4153
|
async function runDbIndexCreate(context) {
|
|
@@ -5103,6 +5222,7 @@ ${serializeDotenv(vars)}`;
|
|
|
5103
5222
|
var CREATE_USAGE2 = "Usage: naro environments create <slug> [--branch <branch>] [--copy-env preview|production|none] [--database schema|empty]";
|
|
5104
5223
|
var SET_USAGE2 = "Usage: naro environments set <slug> [--branch <branch> | --no-branch] [--protection on|off]";
|
|
5105
5224
|
var DELETE_USAGE = "Usage: naro environments delete <slug> [--yes]";
|
|
5225
|
+
var PROVISION_USAGE = "Usage: naro environments provision <slug>";
|
|
5106
5226
|
var NO_BRANCH = "\u2014";
|
|
5107
5227
|
function parseCopyEnv(value) {
|
|
5108
5228
|
if (value === void 0) return void 0;
|
|
@@ -5137,14 +5257,26 @@ function table2(rows) {
|
|
|
5137
5257
|
).join(" ")
|
|
5138
5258
|
);
|
|
5139
5259
|
}
|
|
5260
|
+
function dataCell(environment) {
|
|
5261
|
+
const status = environment.data?.status ?? "none";
|
|
5262
|
+
return status === "none" ? NO_BRANCH : status;
|
|
5263
|
+
}
|
|
5140
5264
|
function environmentRow(environment) {
|
|
5141
5265
|
return [
|
|
5142
5266
|
environment.slug,
|
|
5143
5267
|
environment.url,
|
|
5144
5268
|
environment.branch ?? NO_BRANCH,
|
|
5145
|
-
environment.protectionEnabled ? "on" : "off"
|
|
5269
|
+
environment.protectionEnabled ? "on" : "off",
|
|
5270
|
+
dataCell(environment)
|
|
5146
5271
|
];
|
|
5147
5272
|
}
|
|
5273
|
+
function dataErrorLines(environments) {
|
|
5274
|
+
return environments.flatMap(
|
|
5275
|
+
(environment) => environment.data?.status === "error" ? [
|
|
5276
|
+
`\u26A0 ${environment.slug}: ${environment.data.error ?? "its data could not be made."} Run \`naro environments provision ${environment.slug}\` to try again.`
|
|
5277
|
+
] : []
|
|
5278
|
+
);
|
|
5279
|
+
}
|
|
5148
5280
|
async function runEnvironmentsList(context) {
|
|
5149
5281
|
const projectId = await context.resolveProjectId();
|
|
5150
5282
|
const client = await context.client();
|
|
@@ -5152,9 +5284,10 @@ async function runEnvironmentsList(context) {
|
|
|
5152
5284
|
const count2 = String(environments.length);
|
|
5153
5285
|
context.output.result({ projectId, environments, limit }, [
|
|
5154
5286
|
...environments.length === 0 ? ["No environments. Create one with `naro environments create qa`."] : table2([
|
|
5155
|
-
["slug", "url", "branch", "protection"],
|
|
5287
|
+
["slug", "url", "branch", "protection", "data"],
|
|
5156
5288
|
...environments.map(environmentRow)
|
|
5157
5289
|
]),
|
|
5290
|
+
...dataErrorLines(environments),
|
|
5158
5291
|
limit === null ? `${count2} environment${environments.length === 1 ? "" : "s"}` : `${count2} of ${String(limit)} environments`
|
|
5159
5292
|
]);
|
|
5160
5293
|
}
|
|
@@ -5217,7 +5350,7 @@ async function runEnvironmentsDelete(context) {
|
|
|
5217
5350
|
const projectId = await context.resolveProjectId();
|
|
5218
5351
|
await confirmOrAbort(
|
|
5219
5352
|
context,
|
|
5220
|
-
`Delete environment ${slug}? Its worker
|
|
5353
|
+
`Delete environment ${slug}? Its worker, protection key and data \u2014 database, backend and Storage files \u2014 are removed.`
|
|
5221
5354
|
);
|
|
5222
5355
|
const client = await context.client();
|
|
5223
5356
|
const found = await client.environments.get(projectId, slug);
|
|
@@ -5226,6 +5359,16 @@ async function runEnvironmentsDelete(context) {
|
|
|
5226
5359
|
`\u2705 Deleted environment ${slug}`
|
|
5227
5360
|
]);
|
|
5228
5361
|
}
|
|
5362
|
+
async function runEnvironmentsProvision(context) {
|
|
5363
|
+
const [slug] = context.positionalOrThrow(1, PROVISION_USAGE);
|
|
5364
|
+
const projectId = await context.resolveProjectId();
|
|
5365
|
+
const client = await context.client();
|
|
5366
|
+
const found = await client.environments.get(projectId, slug);
|
|
5367
|
+
const environment = await client.environments.provision(projectId, found.id);
|
|
5368
|
+
context.output.result({ projectId, environment }, [
|
|
5369
|
+
(environment.data?.status ?? "none") === "none" ? `Environment ${slug} has no data to make: the project uses neither a Naro database nor a backend.` : `\u2705 Making environment ${slug}'s data \u2014 \`naro environments\` shows how it goes.`
|
|
5370
|
+
]);
|
|
5371
|
+
}
|
|
5229
5372
|
|
|
5230
5373
|
// src/commands/gen.ts
|
|
5231
5374
|
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
|
|
@@ -5235,7 +5378,7 @@ async function runGenTypes(context) {
|
|
|
5235
5378
|
const { lang } = context.flags;
|
|
5236
5379
|
if (lang !== void 0 && lang !== "typescript")
|
|
5237
5380
|
throw new NaroError("VALIDATION_FAILED", LANG_MESSAGE);
|
|
5238
|
-
const client = await context
|
|
5381
|
+
const client = await dataClient(context);
|
|
5239
5382
|
const projectId = await context.resolveProjectId();
|
|
5240
5383
|
const tables = await client.db.tables(projectId);
|
|
5241
5384
|
const read = await policiesForTypes(client, projectId);
|
|
@@ -5722,6 +5865,127 @@ async function runProtectionBypassRemove(context) {
|
|
|
5722
5865
|
context.output.result({ projectId, ...state }, ["Bypass secret removed."]);
|
|
5723
5866
|
}
|
|
5724
5867
|
|
|
5868
|
+
// src/commands/realtime.ts
|
|
5869
|
+
var ADD_USAGE = "Usage: naro realtime tables add <table>\u2026";
|
|
5870
|
+
var REMOVE_USAGE2 = "Usage: naro realtime tables remove <table>\u2026 [--yes]";
|
|
5871
|
+
var DATA_API_NOTE = "Changes made through the Data API are published; writes your app makes with env.DB are not.";
|
|
5872
|
+
var number = (value) => value.toLocaleString("en-US");
|
|
5873
|
+
function tableName(table3) {
|
|
5874
|
+
const name = printable(table3.name);
|
|
5875
|
+
return table3.exists ? name : `${name} (dropped)`;
|
|
5876
|
+
}
|
|
5877
|
+
function linesFor(lines, environment) {
|
|
5878
|
+
return lines.map((line) => forEnvironment(line, environment));
|
|
5879
|
+
}
|
|
5880
|
+
function realtimeLines(info) {
|
|
5881
|
+
if (!info.enabled)
|
|
5882
|
+
return [
|
|
5883
|
+
"Realtime: not enabled",
|
|
5884
|
+
"Run `naro realtime enable` to turn it on (the project's backend must be on first)."
|
|
5885
|
+
];
|
|
5886
|
+
const { live: live2 } = info;
|
|
5887
|
+
return [
|
|
5888
|
+
"Realtime: on (public channels: off)",
|
|
5889
|
+
info.tables.length === 0 ? "Published tables: none \u2014 naro realtime tables add <table>" : `Published tables: ${info.tables.map(tableName).join(", ")}`,
|
|
5890
|
+
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)}`
|
|
5891
|
+
];
|
|
5892
|
+
}
|
|
5893
|
+
async function enableRealtime(client, projectId, environment) {
|
|
5894
|
+
try {
|
|
5895
|
+
return await client.realtime.enable(projectId);
|
|
5896
|
+
} catch (error) {
|
|
5897
|
+
if (isNaroError(error) && error.code === "TIMEOUT")
|
|
5898
|
+
throw new NaroError(
|
|
5899
|
+
"TIMEOUT",
|
|
5900
|
+
`Turning Realtime on did not answer within 2 minutes. \`naro realtime${envFlag(environment)}\` shows where it got to, and \`naro realtime enable${envFlag(environment)}\` again picks up where it stopped.`,
|
|
5901
|
+
{ cause: error }
|
|
5902
|
+
);
|
|
5903
|
+
throw error;
|
|
5904
|
+
}
|
|
5905
|
+
}
|
|
5906
|
+
function tableNames2(context, usage) {
|
|
5907
|
+
if (context.positionals.length === 0)
|
|
5908
|
+
throw new NaroError("VALIDATION_FAILED", usage);
|
|
5909
|
+
return context.positionals;
|
|
5910
|
+
}
|
|
5911
|
+
async function runRealtimeStatus(context) {
|
|
5912
|
+
const client = await dataClient(context);
|
|
5913
|
+
const projectId = await context.resolveProjectId();
|
|
5914
|
+
const realtime = await client.realtime.info(projectId);
|
|
5915
|
+
context.output.result(
|
|
5916
|
+
{ projectId, realtime },
|
|
5917
|
+
linesFor(realtimeLines(realtime), dataEnvironment(context.flags))
|
|
5918
|
+
);
|
|
5919
|
+
}
|
|
5920
|
+
async function runRealtimeEnable(context) {
|
|
5921
|
+
const client = await dataClient(context);
|
|
5922
|
+
const projectId = await context.resolveProjectId();
|
|
5923
|
+
const environment = dataEnvironment(context.flags);
|
|
5924
|
+
const realtime = await enableRealtime(client, projectId, environment);
|
|
5925
|
+
context.output.result(
|
|
5926
|
+
{ projectId, realtime },
|
|
5927
|
+
linesFor(
|
|
5928
|
+
[
|
|
5929
|
+
"\u2705 Realtime is on. Publish a table with naro realtime tables add <table>.",
|
|
5930
|
+
...realtimeLines(realtime)
|
|
5931
|
+
],
|
|
5932
|
+
environment
|
|
5933
|
+
)
|
|
5934
|
+
);
|
|
5935
|
+
}
|
|
5936
|
+
async function runRealtimeTablesList(context) {
|
|
5937
|
+
const client = await dataClient(context);
|
|
5938
|
+
const projectId = await context.resolveProjectId();
|
|
5939
|
+
const tables = await client.realtime.listTables(projectId);
|
|
5940
|
+
context.output.result(
|
|
5941
|
+
{ projectId, tables },
|
|
5942
|
+
tables.length === 0 ? [
|
|
5943
|
+
forEnvironment(
|
|
5944
|
+
"No published tables. `naro realtime tables add <table>` publishes one.",
|
|
5945
|
+
dataEnvironment(context.flags)
|
|
5946
|
+
)
|
|
5947
|
+
] : tables.map(
|
|
5948
|
+
(table3) => table3.exists ? ` ${printable(table3.name)}` : ` ${printable(table3.name)} (dropped: no such table now)`
|
|
5949
|
+
)
|
|
5950
|
+
);
|
|
5951
|
+
}
|
|
5952
|
+
async function runRealtimeTablesAdd(context) {
|
|
5953
|
+
const names = tableNames2(context, ADD_USAGE);
|
|
5954
|
+
const client = await dataClient(context);
|
|
5955
|
+
const projectId = await context.resolveProjectId();
|
|
5956
|
+
const tables = [];
|
|
5957
|
+
for (const name of names) {
|
|
5958
|
+
const published = await client.realtime.publishTable(projectId, name);
|
|
5959
|
+
tables.push(published);
|
|
5960
|
+
context.output.log(
|
|
5961
|
+
`\u2705 ${printable(published.table)} now publishes its changes (within about 10 seconds).`
|
|
5962
|
+
);
|
|
5963
|
+
for (const warning of published.warnings)
|
|
5964
|
+
if (warning !== DATA_API_NOTE)
|
|
5965
|
+
context.output.log(`\u26A0 ${printable(warning)}`);
|
|
5966
|
+
}
|
|
5967
|
+
context.output.result({ projectId, tables }, [`\u26A0 ${DATA_API_NOTE}`]);
|
|
5968
|
+
}
|
|
5969
|
+
async function runRealtimeTablesRemove(context) {
|
|
5970
|
+
const names = tableNames2(context, REMOVE_USAGE2);
|
|
5971
|
+
const shown = names.map(printable).join(", ");
|
|
5972
|
+
await confirmOrAbort(
|
|
5973
|
+
context,
|
|
5974
|
+
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.`
|
|
5975
|
+
);
|
|
5976
|
+
const client = await dataClient(context);
|
|
5977
|
+
const projectId = await context.resolveProjectId();
|
|
5978
|
+
const tables = [];
|
|
5979
|
+
for (const name of names) {
|
|
5980
|
+
const unpublished = await client.realtime.unpublishTable(projectId, name);
|
|
5981
|
+
tables.push(unpublished);
|
|
5982
|
+
context.output.log(
|
|
5983
|
+
`\u2705 ${printable(unpublished.table)} no longer publishes its changes.`
|
|
5984
|
+
);
|
|
5985
|
+
}
|
|
5986
|
+
context.output.result({ projectId, tables });
|
|
5987
|
+
}
|
|
5988
|
+
|
|
5725
5989
|
// src/commands/storage-units.ts
|
|
5726
5990
|
var SIZE_MESSAGE = "--file-size-limit takes a whole number of bytes, or a size like 5MB (1000s) or 5MiB (1024s), or none.";
|
|
5727
5991
|
var UNITS = {
|
|
@@ -5760,7 +6024,7 @@ function count(n, noun) {
|
|
|
5760
6024
|
var CREATE_USAGE3 = "Usage: naro storage bucket create <name> [--public] [--file-size-limit <size>] [--allowed-mime-types <t,\u2026>]";
|
|
5761
6025
|
var UPDATE_USAGE = "Usage: naro storage bucket update <name> [--public | --private] [--file-size-limit <size> | none] [--allowed-mime-types <t,\u2026> | any]";
|
|
5762
6026
|
var EMPTY_USAGE = "Usage: naro storage bucket empty <name> [--yes]";
|
|
5763
|
-
var
|
|
6027
|
+
var REMOVE_USAGE3 = "Usage: naro storage bucket remove <name> [--empty] [--yes]";
|
|
5764
6028
|
var ENABLE_FIRST = "Storage is not enabled yet; enabling it first.";
|
|
5765
6029
|
var DISABLE_QUESTION3 = "Turn Storage off for this project? Its Storage calls and file URLs answer 404 until naro storage enable; buckets and files are kept.";
|
|
5766
6030
|
var NO_BUCKETS = "No buckets yet. `naro storage bucket create <name>` makes one.";
|
|
@@ -5829,6 +6093,9 @@ function meterLines(info) {
|
|
|
5829
6093
|
);
|
|
5830
6094
|
return lines;
|
|
5831
6095
|
}
|
|
6096
|
+
function linesFor2(lines, environment) {
|
|
6097
|
+
return lines.map((line) => forEnvironment(line, environment));
|
|
6098
|
+
}
|
|
5832
6099
|
function storageLines(info) {
|
|
5833
6100
|
if (info.disabledAt !== null)
|
|
5834
6101
|
return [
|
|
@@ -5852,27 +6119,27 @@ function storageLines(info) {
|
|
|
5852
6119
|
...info.buckets.map(bucketLine)
|
|
5853
6120
|
];
|
|
5854
6121
|
}
|
|
5855
|
-
async function enableStorage(client, projectId) {
|
|
6122
|
+
async function enableStorage(client, projectId, environment = null) {
|
|
5856
6123
|
try {
|
|
5857
6124
|
return await client.storage.enable(projectId);
|
|
5858
6125
|
} catch (error) {
|
|
5859
6126
|
if (isNaroError(error) && error.code === "TIMEOUT")
|
|
5860
6127
|
throw new NaroError(
|
|
5861
6128
|
"TIMEOUT",
|
|
5862
|
-
|
|
6129
|
+
`Turning Storage on did not answer within 2 minutes. \`naro storage${envFlag(environment)}\` shows where it got to, and \`naro storage enable${envFlag(environment)}\` again picks up where it stopped.`,
|
|
5863
6130
|
{ cause: error }
|
|
5864
6131
|
);
|
|
5865
6132
|
throw error;
|
|
5866
6133
|
}
|
|
5867
6134
|
}
|
|
5868
|
-
async function disableStorage(client, projectId) {
|
|
6135
|
+
async function disableStorage(client, projectId, environment = null) {
|
|
5869
6136
|
try {
|
|
5870
6137
|
return await client.storage.disable(projectId);
|
|
5871
6138
|
} catch (error) {
|
|
5872
6139
|
if (isNaroError(error) && error.code === "TIMEOUT")
|
|
5873
6140
|
throw new NaroError(
|
|
5874
6141
|
"TIMEOUT",
|
|
5875
|
-
|
|
6142
|
+
`Turning Storage off did not answer within 2 minutes. \`naro storage${envFlag(environment)}\` shows where it got to, and \`naro storage disable${envFlag(environment)}\` again picks up where it stopped.`,
|
|
5876
6143
|
{ cause: error }
|
|
5877
6144
|
);
|
|
5878
6145
|
throw error;
|
|
@@ -5899,23 +6166,34 @@ function bucketFields(flags) {
|
|
|
5899
6166
|
return fields;
|
|
5900
6167
|
}
|
|
5901
6168
|
async function runStorageStatus(context) {
|
|
5902
|
-
const client = await context
|
|
6169
|
+
const client = await dataClient(context);
|
|
5903
6170
|
const projectId = await context.resolveProjectId();
|
|
5904
6171
|
const storage = await client.storage.info(projectId);
|
|
5905
|
-
context.output.result(
|
|
6172
|
+
context.output.result(
|
|
6173
|
+
{ projectId, storage },
|
|
6174
|
+
linesFor2(storageLines(storage), dataEnvironment(context.flags))
|
|
6175
|
+
);
|
|
5906
6176
|
}
|
|
5907
6177
|
async function runStorageEnable(context) {
|
|
5908
|
-
const client = await context
|
|
6178
|
+
const client = await dataClient(context);
|
|
5909
6179
|
const projectId = await context.resolveProjectId();
|
|
5910
|
-
const
|
|
6180
|
+
const environment = dataEnvironment(context.flags);
|
|
6181
|
+
const storage = await enableStorage(client, projectId, environment);
|
|
5911
6182
|
context.output.result({ projectId, storage }, [
|
|
5912
6183
|
`\u2705 Storage is on: R2 bucket ${printable(storage.bucket ?? "unknown")}.`,
|
|
5913
|
-
...
|
|
5914
|
-
|
|
6184
|
+
...linesFor2(
|
|
6185
|
+
[
|
|
6186
|
+
...storage.usage === null ? [
|
|
6187
|
+
"\u23F3 The backend is still finishing; check `naro backend` in a minute."
|
|
6188
|
+
] : [],
|
|
6189
|
+
...storageLines(storage)
|
|
6190
|
+
],
|
|
6191
|
+
environment
|
|
6192
|
+
)
|
|
5915
6193
|
]);
|
|
5916
6194
|
}
|
|
5917
6195
|
async function runStorageDisable(context) {
|
|
5918
|
-
const client = await context
|
|
6196
|
+
const client = await dataClient(context);
|
|
5919
6197
|
const projectId = await context.resolveProjectId();
|
|
5920
6198
|
const current = await client.storage.info(projectId);
|
|
5921
6199
|
if (current.bucket === null) {
|
|
@@ -5924,27 +6202,34 @@ async function runStorageDisable(context) {
|
|
|
5924
6202
|
]);
|
|
5925
6203
|
return;
|
|
5926
6204
|
}
|
|
6205
|
+
const environment = dataEnvironment(context.flags);
|
|
5927
6206
|
if (current.disabledAt === null)
|
|
5928
|
-
await confirmOrAbort(
|
|
5929
|
-
|
|
6207
|
+
await confirmOrAbort(
|
|
6208
|
+
context,
|
|
6209
|
+
forEnvironment(DISABLE_QUESTION3, environment)
|
|
6210
|
+
);
|
|
6211
|
+
const storage = await disableStorage(client, projectId, environment);
|
|
5930
6212
|
context.output.result({ projectId, storage }, [
|
|
5931
|
-
|
|
5932
|
-
...storageLines(storage)
|
|
6213
|
+
`\u2705 Storage is off. Buckets and files are kept; naro storage enable${envFlag(environment)} turns it on again.`,
|
|
6214
|
+
...linesFor2(storageLines(storage), environment)
|
|
5933
6215
|
]);
|
|
5934
6216
|
}
|
|
5935
6217
|
async function runStorageBucketList(context) {
|
|
5936
|
-
const client = await context
|
|
6218
|
+
const client = await dataClient(context);
|
|
5937
6219
|
const projectId = await context.resolveProjectId();
|
|
5938
6220
|
const buckets = await client.storage.listBuckets(projectId);
|
|
5939
6221
|
context.output.result(
|
|
5940
6222
|
{ projectId, buckets },
|
|
5941
|
-
|
|
6223
|
+
linesFor2(
|
|
6224
|
+
buckets.length === 0 ? [NO_BUCKETS] : buckets.map(bucketLine),
|
|
6225
|
+
dataEnvironment(context.flags)
|
|
6226
|
+
)
|
|
5942
6227
|
);
|
|
5943
6228
|
}
|
|
5944
6229
|
async function runStorageBucketCreate(context) {
|
|
5945
6230
|
const name = bucketName(context, CREATE_USAGE3);
|
|
5946
6231
|
const input = { name, ...bucketFields(context.flags) };
|
|
5947
|
-
const client = await context
|
|
6232
|
+
const client = await dataClient(context);
|
|
5948
6233
|
const projectId = await context.resolveProjectId();
|
|
5949
6234
|
let bucket;
|
|
5950
6235
|
try {
|
|
@@ -5953,12 +6238,12 @@ async function runStorageBucketCreate(context) {
|
|
|
5953
6238
|
if (!isNaroError(error) || error.code !== "STORAGE_NOT_ENABLED")
|
|
5954
6239
|
throw error;
|
|
5955
6240
|
context.output.log(ENABLE_FIRST);
|
|
5956
|
-
await enableStorage(client, projectId);
|
|
6241
|
+
await enableStorage(client, projectId, dataEnvironment(context.flags));
|
|
5957
6242
|
bucket = await client.storage.createBucket(projectId, input);
|
|
5958
6243
|
}
|
|
5959
6244
|
context.output.result({ projectId, bucket }, [
|
|
5960
6245
|
`\u2705 Created bucket ${printable(bucket.name)}.`,
|
|
5961
|
-
bucketLine(bucket)
|
|
6246
|
+
forEnvironment(bucketLine(bucket), dataEnvironment(context.flags))
|
|
5962
6247
|
]);
|
|
5963
6248
|
}
|
|
5964
6249
|
async function runStorageBucketUpdate(context) {
|
|
@@ -5969,13 +6254,13 @@ async function runStorageBucketUpdate(context) {
|
|
|
5969
6254
|
context,
|
|
5970
6255
|
`Make bucket ${name} public? Anyone with an object's URL can download it.`
|
|
5971
6256
|
);
|
|
5972
|
-
const client = await context
|
|
6257
|
+
const client = await dataClient(context);
|
|
5973
6258
|
const projectId = await context.resolveProjectId();
|
|
5974
6259
|
const bucket = await client.storage.updateBucket(projectId, name, patch);
|
|
5975
6260
|
const takesEffect = patch.public === false ? "It takes effect within about 10 seconds; copies Naro's edge already holds can be served for up to 5 minutes." : "It takes effect within about 10 seconds.";
|
|
5976
6261
|
context.output.result({ projectId, bucket }, [
|
|
5977
6262
|
`\u2705 Updated bucket ${printable(bucket.name)}. ${takesEffect}`,
|
|
5978
|
-
bucketLine(bucket)
|
|
6263
|
+
forEnvironment(bucketLine(bucket), dataEnvironment(context.flags))
|
|
5979
6264
|
]);
|
|
5980
6265
|
}
|
|
5981
6266
|
function refuseSearch(context, why) {
|
|
@@ -6011,7 +6296,7 @@ async function runStorageBucketEmpty(context) {
|
|
|
6011
6296
|
context,
|
|
6012
6297
|
`bucket empty deletes every object in the bucket. ${EMPTY_USAGE}`
|
|
6013
6298
|
);
|
|
6014
|
-
const client = await context
|
|
6299
|
+
const client = await dataClient(context);
|
|
6015
6300
|
const projectId = await context.resolveProjectId();
|
|
6016
6301
|
const bucket = await client.storage.getBucket(projectId, name);
|
|
6017
6302
|
await confirmOrAbort(
|
|
@@ -6028,13 +6313,13 @@ async function runStorageBucketEmpty(context) {
|
|
|
6028
6313
|
]);
|
|
6029
6314
|
}
|
|
6030
6315
|
async function runStorageBucketRemove(context) {
|
|
6031
|
-
const name = bucketName(context,
|
|
6316
|
+
const name = bucketName(context, REMOVE_USAGE3);
|
|
6032
6317
|
const empty = context.flags.empty === true;
|
|
6033
6318
|
refuseSearch(
|
|
6034
6319
|
context,
|
|
6035
|
-
empty ? `bucket remove --empty deletes every object in the bucket before it removes the bucket. ${
|
|
6320
|
+
empty ? `bucket remove --empty deletes every object in the bucket before it removes the bucket. ${REMOVE_USAGE3}` : `bucket remove removes the whole bucket. ${REMOVE_USAGE3}`
|
|
6036
6321
|
);
|
|
6037
|
-
const client = await context
|
|
6322
|
+
const client = await dataClient(context);
|
|
6038
6323
|
const projectId = await context.resolveProjectId();
|
|
6039
6324
|
let question = `Remove bucket ${name}?`;
|
|
6040
6325
|
if (empty) {
|
|
@@ -6132,6 +6417,105 @@ function cryptoUnavailable() {
|
|
|
6132
6417
|
message: "This runtime has no crypto.subtle, which signing in with a provider needs (https or localhost)."
|
|
6133
6418
|
};
|
|
6134
6419
|
}
|
|
6420
|
+
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.";
|
|
6421
|
+
function websocketUnavailable() {
|
|
6422
|
+
return {
|
|
6423
|
+
code: "WEBSOCKET_UNAVAILABLE",
|
|
6424
|
+
message: "This runtime has no WebSocket. Pass one to createClient({ realtime: { WebSocket } })."
|
|
6425
|
+
};
|
|
6426
|
+
}
|
|
6427
|
+
function realtimeTimedOut(ms) {
|
|
6428
|
+
const seconds = Math.max(1, Math.round(ms / 1e3));
|
|
6429
|
+
return {
|
|
6430
|
+
code: "TIMED_OUT",
|
|
6431
|
+
message: `The Naro realtime server did not answer within ${String(seconds)} seconds.`
|
|
6432
|
+
};
|
|
6433
|
+
}
|
|
6434
|
+
function realtimeClosed(code) {
|
|
6435
|
+
return {
|
|
6436
|
+
code: "NETWORK_ERROR",
|
|
6437
|
+
message: `The realtime connection to the Naro backend closed (${String(code)}). It reconnects on its own.`,
|
|
6438
|
+
details: { code }
|
|
6439
|
+
};
|
|
6440
|
+
}
|
|
6441
|
+
function realtimeDisconnected() {
|
|
6442
|
+
return {
|
|
6443
|
+
code: "ABORTED",
|
|
6444
|
+
message: "The realtime connection was closed by the app (it removed its last channel) before the Naro realtime server answered."
|
|
6445
|
+
};
|
|
6446
|
+
}
|
|
6447
|
+
function realtimeStopped(code, reason) {
|
|
6448
|
+
return {
|
|
6449
|
+
code: reason === "" ? "NETWORK_ERROR" : reason,
|
|
6450
|
+
message: `The realtime connection to the Naro backend closed (${String(code)}) and does not reconnect.`,
|
|
6451
|
+
details: { code }
|
|
6452
|
+
};
|
|
6453
|
+
}
|
|
6454
|
+
function socketRefused(error) {
|
|
6455
|
+
return {
|
|
6456
|
+
code: "NETWORK_ERROR",
|
|
6457
|
+
message: "The realtime connection to the Naro backend could not be opened.",
|
|
6458
|
+
details: { cause: error instanceof Error ? error.name : typeof error }
|
|
6459
|
+
};
|
|
6460
|
+
}
|
|
6461
|
+
function invalidChannel(reason) {
|
|
6462
|
+
return {
|
|
6463
|
+
code: "INVALID_CHANNEL",
|
|
6464
|
+
message: `The channel name is not valid: it ${reason}.`
|
|
6465
|
+
};
|
|
6466
|
+
}
|
|
6467
|
+
function invalidChannelConfig(path, reason) {
|
|
6468
|
+
return {
|
|
6469
|
+
code: "INVALID_CHANNEL_CONFIG",
|
|
6470
|
+
message: "The channel settings are not valid.",
|
|
6471
|
+
details: { path, reason }
|
|
6472
|
+
};
|
|
6473
|
+
}
|
|
6474
|
+
function serviceKeyOffServer() {
|
|
6475
|
+
return {
|
|
6476
|
+
code: "SERVICE_KEY_OFF_SERVER",
|
|
6477
|
+
message: SERVICE_KEY_OFF_SERVER_MESSAGE
|
|
6478
|
+
};
|
|
6479
|
+
}
|
|
6480
|
+
function notJoined(channel) {
|
|
6481
|
+
return {
|
|
6482
|
+
code: "NOT_JOINED",
|
|
6483
|
+
message: `Join ${channel} before sending to it.`
|
|
6484
|
+
};
|
|
6485
|
+
}
|
|
6486
|
+
function invalidBroadcast() {
|
|
6487
|
+
return {
|
|
6488
|
+
code: "INVALID_MESSAGE",
|
|
6489
|
+
message: "The message is not a valid Naro realtime frame.",
|
|
6490
|
+
details: { reason: "needs type broadcast" }
|
|
6491
|
+
};
|
|
6492
|
+
}
|
|
6493
|
+
function invalidPresence() {
|
|
6494
|
+
return {
|
|
6495
|
+
code: "INVALID_MESSAGE",
|
|
6496
|
+
message: "The message is not a valid Naro realtime frame.",
|
|
6497
|
+
details: { reason: "needs a presence payload object" }
|
|
6498
|
+
};
|
|
6499
|
+
}
|
|
6500
|
+
function frameTooLarge(limit) {
|
|
6501
|
+
return {
|
|
6502
|
+
code: "INVALID_MESSAGE",
|
|
6503
|
+
message: "The message is not a valid Naro realtime frame.",
|
|
6504
|
+
details: { reason: `is larger than ${String(limit)} bytes` }
|
|
6505
|
+
};
|
|
6506
|
+
}
|
|
6507
|
+
function clientFlooding(perSecond) {
|
|
6508
|
+
return {
|
|
6509
|
+
code: "CLIENT_FLOODING",
|
|
6510
|
+
message: `This connection is sending messages faster than a connection may (${String(perSecond)} a second), so this one was not sent. Send less often.`
|
|
6511
|
+
};
|
|
6512
|
+
}
|
|
6513
|
+
function channelClosed() {
|
|
6514
|
+
return {
|
|
6515
|
+
code: "CHANNEL_CLOSED",
|
|
6516
|
+
message: "The Naro realtime server closed this channel."
|
|
6517
|
+
};
|
|
6518
|
+
}
|
|
6135
6519
|
function describe(cause) {
|
|
6136
6520
|
if (cause instanceof Error) return `${cause.name}: ${cause.message}`;
|
|
6137
6521
|
return String(cause);
|
|
@@ -6154,9 +6538,119 @@ function errorFromBody(status, body) {
|
|
|
6154
6538
|
if (status >= 200 && status < 300) return null;
|
|
6155
6539
|
return invalidResponse(status);
|
|
6156
6540
|
}
|
|
6157
|
-
var
|
|
6541
|
+
var SESSION_PREFIXES = ["/auth/", "/realtime/"];
|
|
6158
6542
|
function storeFor(config, path) {
|
|
6159
|
-
return path.startsWith(
|
|
6543
|
+
return SESSION_PREFIXES.some((prefix) => path.startsWith(prefix)) ? config.tokens : config.accessJwt ?? null;
|
|
6544
|
+
}
|
|
6545
|
+
var ACCESS_JWT_MARGIN_MS = 6e4;
|
|
6546
|
+
function timesOf(jwt) {
|
|
6547
|
+
const payload = jwt.split(".")[1];
|
|
6548
|
+
if (payload === void 0 || payload === "") return null;
|
|
6549
|
+
try {
|
|
6550
|
+
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
6551
|
+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
|
6552
|
+
const claims = JSON.parse(atob(padded));
|
|
6553
|
+
if (!isRecord(claims)) return null;
|
|
6554
|
+
const { exp, iat } = claims;
|
|
6555
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
|
|
6556
|
+
return {
|
|
6557
|
+
exp: exp * 1e3,
|
|
6558
|
+
iat: typeof iat === "number" && Number.isFinite(iat) ? iat * 1e3 : null
|
|
6559
|
+
};
|
|
6560
|
+
} catch {
|
|
6561
|
+
return null;
|
|
6562
|
+
}
|
|
6563
|
+
}
|
|
6564
|
+
function expiryOf(jwt) {
|
|
6565
|
+
return timesOf(jwt)?.exp ?? null;
|
|
6566
|
+
}
|
|
6567
|
+
var skews = /* @__PURE__ */ new WeakMap();
|
|
6568
|
+
function localExpiry(config, jwt) {
|
|
6569
|
+
const exp = expiryOf(jwt);
|
|
6570
|
+
return exp === null ? null : exp - (skews.get(config.tokens) ?? 0);
|
|
6571
|
+
}
|
|
6572
|
+
var SESSION_GONE_CODES = /* @__PURE__ */ new Set([
|
|
6573
|
+
"UNAUTHORIZED",
|
|
6574
|
+
"INVALID_SESSION",
|
|
6575
|
+
"FAILED_TO_GET_SESSION"
|
|
6576
|
+
]);
|
|
6577
|
+
var renewing = /* @__PURE__ */ new WeakMap();
|
|
6578
|
+
function renewAccessJwt(config, jwtStore, session) {
|
|
6579
|
+
const pending = renewing.get(config.tokens);
|
|
6580
|
+
if (pending?.session === session) return pending.done;
|
|
6581
|
+
const done = (async () => {
|
|
6582
|
+
const result = await callWith(
|
|
6583
|
+
config,
|
|
6584
|
+
{ method: "GET", path: "/auth/token", withToken: true },
|
|
6585
|
+
void 0,
|
|
6586
|
+
session
|
|
6587
|
+
);
|
|
6588
|
+
if (result.error !== null) {
|
|
6589
|
+
if (result.answer?.status !== 401 || !SESSION_GONE_CODES.has(result.error.code))
|
|
6590
|
+
return result.error;
|
|
6591
|
+
await config.tokens.clearIf(session);
|
|
6592
|
+
await config.tokens.whileIs(null, () => jwtStore.clear());
|
|
6593
|
+
return invalidSession();
|
|
6594
|
+
}
|
|
6595
|
+
const { body, status } = result.answer;
|
|
6596
|
+
const jwt = isRecord(body) ? body.token : void 0;
|
|
6597
|
+
if (typeof jwt !== "string" || jwt === "") return invalidResponse(status);
|
|
6598
|
+
const arrived = Date.now();
|
|
6599
|
+
await config.tokens.whileIs(session, async () => {
|
|
6600
|
+
const iat = timesOf(jwt)?.iat;
|
|
6601
|
+
if (iat !== void 0 && iat !== null)
|
|
6602
|
+
skews.set(config.tokens, iat - arrived);
|
|
6603
|
+
await jwtStore.set(jwt);
|
|
6604
|
+
});
|
|
6605
|
+
return null;
|
|
6606
|
+
})();
|
|
6607
|
+
const entry = { session, done };
|
|
6608
|
+
renewing.set(config.tokens, entry);
|
|
6609
|
+
void done.finally(() => {
|
|
6610
|
+
if (renewing.get(config.tokens) === entry) renewing.delete(config.tokens);
|
|
6611
|
+
});
|
|
6612
|
+
return done;
|
|
6613
|
+
}
|
|
6614
|
+
async function accessJwtFor(config, jwtStore) {
|
|
6615
|
+
for (let round = 0; round < 2; round += 1) {
|
|
6616
|
+
const session = await config.tokens.get();
|
|
6617
|
+
const jwt = await jwtStore.get();
|
|
6618
|
+
if (session === null || config.service) return { token: jwt, session };
|
|
6619
|
+
const expiry = jwt === null ? null : localExpiry(config, jwt);
|
|
6620
|
+
if (jwt !== null && (expiry === null || expiry - ACCESS_JWT_MARGIN_MS > Date.now()))
|
|
6621
|
+
return { token: jwt, session };
|
|
6622
|
+
const error = await renewAccessJwt(config, jwtStore, session);
|
|
6623
|
+
const stored = await config.tokens.get();
|
|
6624
|
+
if (stored !== session) {
|
|
6625
|
+
if (stored === null) return error ? { error } : { token: null, session };
|
|
6626
|
+
continue;
|
|
6627
|
+
}
|
|
6628
|
+
if (error === null) {
|
|
6629
|
+
const renewed = await jwtStore.get();
|
|
6630
|
+
return renewed === null ? { error: invalidSession() } : { token: renewed, session };
|
|
6631
|
+
}
|
|
6632
|
+
if (jwt !== null && expiry !== null && expiry > Date.now())
|
|
6633
|
+
return { token: jwt, session };
|
|
6634
|
+
return { error };
|
|
6635
|
+
}
|
|
6636
|
+
return { error: invalidSession() };
|
|
6637
|
+
}
|
|
6638
|
+
async function bearerFor(config, path, withToken) {
|
|
6639
|
+
if (!withToken) return { token: null, session: null };
|
|
6640
|
+
const store = storeFor(config, path);
|
|
6641
|
+
if (store === null) return { token: null, session: null };
|
|
6642
|
+
if (store === config.tokens) {
|
|
6643
|
+
const token = await store.get();
|
|
6644
|
+
return { token, session: token };
|
|
6645
|
+
}
|
|
6646
|
+
return accessJwtFor(config, store);
|
|
6647
|
+
}
|
|
6648
|
+
async function retryBearer(config, path, error, first) {
|
|
6649
|
+
if (error?.code !== "INVALID_SESSION" || first.token === null || first.session === null || config.accessJwt === void 0 || storeFor(config, path) !== config.accessJwt || await config.tokens.get() !== first.session)
|
|
6650
|
+
return null;
|
|
6651
|
+
const renewed = await bearerFor(config, path, true);
|
|
6652
|
+
if ("error" in renewed) return renewed;
|
|
6653
|
+
return renewed.token === null || renewed.token === first.token || renewed.session !== first.session ? null : renewed;
|
|
6160
6654
|
}
|
|
6161
6655
|
async function receive(config, options, init) {
|
|
6162
6656
|
const controller = options.timeoutMs !== void 0 && typeof AbortController === "function" ? new AbortController() : null;
|
|
@@ -6201,8 +6695,16 @@ async function call(config, options) {
|
|
|
6201
6695
|
return { error: invalidQuery("", "is not JSON"), answer: null };
|
|
6202
6696
|
}
|
|
6203
6697
|
}
|
|
6698
|
+
const bearer = await bearerFor(config, options.path, options.withToken);
|
|
6699
|
+
if ("error" in bearer) return { error: bearer.error, answer: null };
|
|
6700
|
+
const result = await callWith(config, options, payload, bearer.token);
|
|
6701
|
+
const again = await retryBearer(config, options.path, result.error, bearer);
|
|
6702
|
+
if (again === null) return result;
|
|
6703
|
+
if ("error" in again) return { error: again.error, answer: null };
|
|
6704
|
+
return callWith(config, options, payload, again.token);
|
|
6705
|
+
}
|
|
6706
|
+
async function callWith(config, options, payload, token) {
|
|
6204
6707
|
const store = storeFor(config, options.path);
|
|
6205
|
-
const token = options.withToken && store ? await store.get() : null;
|
|
6206
6708
|
const headers = {
|
|
6207
6709
|
...options.headers,
|
|
6208
6710
|
apikey: config.apiKey
|
|
@@ -6246,8 +6748,48 @@ async function settled(store, error, headers, token) {
|
|
|
6246
6748
|
return RETRY_AFTER_CODES.has(error.code) ? withRetryAfter(error, headers) : error;
|
|
6247
6749
|
}
|
|
6248
6750
|
async function send(config, options) {
|
|
6751
|
+
const bearer = await untilAborted(
|
|
6752
|
+
bearerFor(config, options.path, options.withToken),
|
|
6753
|
+
options.signal
|
|
6754
|
+
);
|
|
6755
|
+
if (bearer === ABORTED) return { error: uploadAborted(), status: null };
|
|
6756
|
+
if ("error" in bearer) return { error: bearer.error, status: null };
|
|
6757
|
+
const result = await sendWith(config, options, bearer.token);
|
|
6758
|
+
if (result.error?.code !== "INVALID_SESSION" || isStream(options.body))
|
|
6759
|
+
return result;
|
|
6760
|
+
const again = await untilAborted(
|
|
6761
|
+
retryBearer(config, options.path, result.error, bearer),
|
|
6762
|
+
options.signal
|
|
6763
|
+
);
|
|
6764
|
+
if (again === ABORTED) return { error: uploadAborted(), status: null };
|
|
6765
|
+
if (again === null) return result;
|
|
6766
|
+
if ("error" in again) return { error: again.error, status: null };
|
|
6767
|
+
return sendWith(config, options, again.token);
|
|
6768
|
+
}
|
|
6769
|
+
var ABORTED = Symbol("aborted");
|
|
6770
|
+
function untilAborted(promise, signal) {
|
|
6771
|
+
if (!signal) return promise;
|
|
6772
|
+
if (signal.aborted) return Promise.resolve(ABORTED);
|
|
6773
|
+
return new Promise((resolve7, reject) => {
|
|
6774
|
+
const stop = () => resolve7(ABORTED);
|
|
6775
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
6776
|
+
promise.then(
|
|
6777
|
+
(value) => {
|
|
6778
|
+
signal.removeEventListener("abort", stop);
|
|
6779
|
+
resolve7(value);
|
|
6780
|
+
},
|
|
6781
|
+
(error) => {
|
|
6782
|
+
signal.removeEventListener("abort", stop);
|
|
6783
|
+
reject(error);
|
|
6784
|
+
}
|
|
6785
|
+
);
|
|
6786
|
+
});
|
|
6787
|
+
}
|
|
6788
|
+
function isStream(body) {
|
|
6789
|
+
return typeof ReadableStream === "function" && body instanceof ReadableStream;
|
|
6790
|
+
}
|
|
6791
|
+
async function sendWith(config, options, token) {
|
|
6249
6792
|
const store = storeFor(config, options.path);
|
|
6250
|
-
const token = options.withToken && store ? await store.get() : null;
|
|
6251
6793
|
const headers = {
|
|
6252
6794
|
...options.headers,
|
|
6253
6795
|
apikey: config.apiKey
|
|
@@ -6308,7 +6850,9 @@ var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
|
|
|
6308
6850
|
// Storage (original spec §13): one object written twice within a second
|
|
6309
6851
|
// (`x-retry-after: 1`), and the daily operation limit (3-1d).
|
|
6310
6852
|
"OBJECT_RATE_LIMITED",
|
|
6311
|
-
"STORAGE_OPS_QUOTA_EXCEEDED"
|
|
6853
|
+
"STORAGE_OPS_QUOTA_EXCEEDED",
|
|
6854
|
+
// Realtime's REST broadcast over the project's message budget (x-retry-after: 1).
|
|
6855
|
+
"TOO_MANY_MESSAGES"
|
|
6312
6856
|
]);
|
|
6313
6857
|
function withRetryAfter(error, headers) {
|
|
6314
6858
|
const header = headers.get("x-retry-after")?.trim() ?? "";
|
|
@@ -6887,7 +7431,7 @@ var MAX_FLOWS = 10;
|
|
|
6887
7431
|
function isEntry(value) {
|
|
6888
7432
|
return typeof value === "object" && value !== null && typeof value.verifier === "string" && typeof value.expiresAt === "number";
|
|
6889
7433
|
}
|
|
6890
|
-
function live(text,
|
|
7434
|
+
function live(text, now2) {
|
|
6891
7435
|
if (text === null) return {};
|
|
6892
7436
|
let parsed;
|
|
6893
7437
|
try {
|
|
@@ -6898,7 +7442,7 @@ function live(text, now) {
|
|
|
6898
7442
|
if (typeof parsed !== "object" || parsed === null) return {};
|
|
6899
7443
|
const entries = {};
|
|
6900
7444
|
for (const [flow, entry] of Object.entries(parsed))
|
|
6901
|
-
if (isEntry(entry) && entry.expiresAt >
|
|
7445
|
+
if (isEntry(entry) && entry.expiresAt > now2) entries[flow] = entry;
|
|
6902
7446
|
return entries;
|
|
6903
7447
|
}
|
|
6904
7448
|
function pick(entries, oldest) {
|
|
@@ -6914,16 +7458,16 @@ function serialize(entries) {
|
|
|
6914
7458
|
var FlowVerifiers = class {
|
|
6915
7459
|
#store;
|
|
6916
7460
|
#now;
|
|
6917
|
-
constructor(store,
|
|
7461
|
+
constructor(store, now2 = Date.now) {
|
|
6918
7462
|
this.#store = store;
|
|
6919
|
-
this.#now =
|
|
7463
|
+
this.#now = now2;
|
|
6920
7464
|
}
|
|
6921
7465
|
/** Keeps `verifier` for the flow `flow`, for ten minutes. */
|
|
6922
7466
|
async add(flow, verifier) {
|
|
6923
|
-
const
|
|
7467
|
+
const now2 = this.#now();
|
|
6924
7468
|
await this.#store.update((text) => {
|
|
6925
|
-
const entries = live(text,
|
|
6926
|
-
entries[flow] = { verifier, expiresAt:
|
|
7469
|
+
const entries = live(text, now2);
|
|
7470
|
+
entries[flow] = { verifier, expiresAt: now2 + FLOW_TTL_MS };
|
|
6927
7471
|
while (Object.keys(entries).length > MAX_FLOWS) {
|
|
6928
7472
|
const oldest = pick(entries, true);
|
|
6929
7473
|
if (oldest === void 0) break;
|
|
@@ -6938,10 +7482,10 @@ var FlowVerifiers = class {
|
|
|
6938
7482
|
* expired.
|
|
6939
7483
|
*/
|
|
6940
7484
|
async take(flow) {
|
|
6941
|
-
const
|
|
7485
|
+
const now2 = this.#now();
|
|
6942
7486
|
let taken = null;
|
|
6943
7487
|
await this.#store.update((text) => {
|
|
6944
|
-
const entries = live(text,
|
|
7488
|
+
const entries = live(text, now2);
|
|
6945
7489
|
const id = flow ?? pick(entries, false);
|
|
6946
7490
|
if (id !== void 0 && Object.hasOwn(entries, id)) {
|
|
6947
7491
|
taken = entries[id]?.verifier ?? null;
|
|
@@ -6966,9 +7510,9 @@ function parse(text) {
|
|
|
6966
7510
|
var RecoveryToken = class {
|
|
6967
7511
|
#store;
|
|
6968
7512
|
#now;
|
|
6969
|
-
constructor(store,
|
|
7513
|
+
constructor(store, now2 = () => Date.now()) {
|
|
6970
7514
|
this.#store = store;
|
|
6971
|
-
this.#now =
|
|
7515
|
+
this.#now = now2;
|
|
6972
7516
|
}
|
|
6973
7517
|
/** Keeps the token a recovery link brought, for 15 minutes. */
|
|
6974
7518
|
async keep(token) {
|
|
@@ -6977,11 +7521,11 @@ var RecoveryToken = class {
|
|
|
6977
7521
|
}
|
|
6978
7522
|
/** The token, while it lasts; an expired or unreadable one is cleared. */
|
|
6979
7523
|
async current() {
|
|
6980
|
-
const
|
|
7524
|
+
const now2 = this.#now();
|
|
6981
7525
|
let token = null;
|
|
6982
7526
|
await this.#store.update((text) => {
|
|
6983
7527
|
const kept = parse(text);
|
|
6984
|
-
if (kept === null || kept.expiresAt <=
|
|
7528
|
+
if (kept === null || kept.expiresAt <= now2) return null;
|
|
6985
7529
|
token = kept.token;
|
|
6986
7530
|
return text;
|
|
6987
7531
|
});
|
|
@@ -7041,6 +7585,8 @@ var TokenStore = class {
|
|
|
7041
7585
|
#fallback = null;
|
|
7042
7586
|
/** Settles when the last queued operation has finished. */
|
|
7043
7587
|
#queue = Promise.resolve();
|
|
7588
|
+
/** Called after each write; see `subscribe`. */
|
|
7589
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
7044
7590
|
constructor(storage, key) {
|
|
7045
7591
|
this.#storage = storage;
|
|
7046
7592
|
this.#key = key;
|
|
@@ -7049,21 +7595,31 @@ var TokenStore = class {
|
|
|
7049
7595
|
return this.#serial(() => this.#get());
|
|
7050
7596
|
}
|
|
7051
7597
|
set(token) {
|
|
7052
|
-
return this.#serial(() =>
|
|
7598
|
+
return this.#serial(async () => {
|
|
7599
|
+
await this.#set(token);
|
|
7600
|
+
this.#changed();
|
|
7601
|
+
});
|
|
7053
7602
|
}
|
|
7054
7603
|
clear() {
|
|
7055
|
-
return this.#serial(() =>
|
|
7604
|
+
return this.#serial(async () => {
|
|
7605
|
+
await this.#clear();
|
|
7606
|
+
this.#changed();
|
|
7607
|
+
});
|
|
7056
7608
|
}
|
|
7057
7609
|
/**
|
|
7058
|
-
* Clears only if the stored token is still the one that was rejected
|
|
7059
|
-
* request that left with an old token can come back
|
|
7060
|
-
* in again; its INVALID_SESSION must not sign the
|
|
7061
|
-
* read and the removal are one operation: with an
|
|
7062
|
-
* that lands between them would otherwise be the
|
|
7610
|
+
* Clears only if the stored token is still the one that was rejected, and
|
|
7611
|
+
* says whether it did. A request that left with an old token can come back
|
|
7612
|
+
* after the user signed in again; its INVALID_SESSION must not sign the
|
|
7613
|
+
* new session out. The read and the removal are one operation: with an
|
|
7614
|
+
* async storage, a sign-in that lands between them would otherwise be the
|
|
7615
|
+
* token removed.
|
|
7063
7616
|
*/
|
|
7064
7617
|
clearIf(token) {
|
|
7065
7618
|
return this.#serial(async () => {
|
|
7066
|
-
if (await this.#get()
|
|
7619
|
+
if (await this.#get() !== token) return false;
|
|
7620
|
+
await this.#clear();
|
|
7621
|
+
this.#changed();
|
|
7622
|
+
return true;
|
|
7067
7623
|
});
|
|
7068
7624
|
}
|
|
7069
7625
|
/**
|
|
@@ -7076,17 +7632,59 @@ var TokenStore = class {
|
|
|
7076
7632
|
const next = transform(await this.#get());
|
|
7077
7633
|
if (next === null) await this.#clear();
|
|
7078
7634
|
else await this.#set(next);
|
|
7635
|
+
this.#changed();
|
|
7079
7636
|
});
|
|
7080
7637
|
}
|
|
7081
7638
|
/** Stores `next` only if `expected` is still stored, as one operation. */
|
|
7082
7639
|
replaceIf(expected, next) {
|
|
7083
7640
|
return this.#serial(async () => {
|
|
7084
|
-
if (await this.#get()
|
|
7641
|
+
if (await this.#get() !== expected) return;
|
|
7642
|
+
await this.#set(next);
|
|
7643
|
+
this.#changed();
|
|
7085
7644
|
});
|
|
7086
7645
|
}
|
|
7087
7646
|
/**
|
|
7088
|
-
* Runs `operation`
|
|
7089
|
-
*
|
|
7647
|
+
* Runs `operation` only while `expected` is the stored token (null: none
|
|
7648
|
+
* is stored), inside this
|
|
7649
|
+
* store's queue, and says whether it ran: no write to this store lands
|
|
7650
|
+
* between the check and the end of the operation. A refreshed access JWT
|
|
7651
|
+
* is kept this way (`http.ts`), so one minted for a session that a
|
|
7652
|
+
* sign-in replaced meanwhile never lands on top of the new session's.
|
|
7653
|
+
* `operation` must not call this store's public methods (it would wait
|
|
7654
|
+
* on itself); another store's are fine.
|
|
7655
|
+
*/
|
|
7656
|
+
whileIs(expected, operation) {
|
|
7657
|
+
return this.#serial(async () => {
|
|
7658
|
+
if (await this.#get() !== expected) return false;
|
|
7659
|
+
await operation();
|
|
7660
|
+
return true;
|
|
7661
|
+
});
|
|
7662
|
+
}
|
|
7663
|
+
/**
|
|
7664
|
+
* Calls `listener` after every write of the token — a sign-in, a
|
|
7665
|
+
* sign-out, a renewed session, a cleared INVALID_SESSION — from inside the
|
|
7666
|
+
* write's turn, so a `get()` it starts reads the value just written — or
|
|
7667
|
+
* that of a write already queued behind it, which calls the listener
|
|
7668
|
+
* again. The Realtime connection (Realtime 3-2d) uses it to tell the hub.
|
|
7669
|
+
* What a listener throws is its own: the write has happened and stands.
|
|
7670
|
+
*/
|
|
7671
|
+
subscribe(listener) {
|
|
7672
|
+
this.#listeners.add(listener);
|
|
7673
|
+
return () => {
|
|
7674
|
+
this.#listeners.delete(listener);
|
|
7675
|
+
};
|
|
7676
|
+
}
|
|
7677
|
+
#changed() {
|
|
7678
|
+
for (const listener of Array.from(this.#listeners)) {
|
|
7679
|
+
try {
|
|
7680
|
+
listener();
|
|
7681
|
+
} catch {
|
|
7682
|
+
}
|
|
7683
|
+
}
|
|
7684
|
+
}
|
|
7685
|
+
/**
|
|
7686
|
+
* Runs `operation` once every earlier one has finished. The operations
|
|
7687
|
+
* below never reject, and none of them awaits a public method (that would
|
|
7090
7688
|
* wait on itself).
|
|
7091
7689
|
*/
|
|
7092
7690
|
#serial(operation) {
|
|
@@ -7487,10 +8085,10 @@ function truth2(text) {
|
|
|
7487
8085
|
var DECIMAL3 = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
7488
8086
|
function exactNumber2(text) {
|
|
7489
8087
|
if (!DECIMAL3.test(text)) return text;
|
|
7490
|
-
const
|
|
7491
|
-
if (!text.includes(".")) return Number.isSafeInteger(
|
|
8088
|
+
const number2 = Number(text);
|
|
8089
|
+
if (!text.includes(".")) return Number.isSafeInteger(number2) ? number2 : text;
|
|
7492
8090
|
const trimmed = text.replace(/\.?0+$/, "");
|
|
7493
|
-
return String(
|
|
8091
|
+
return String(number2) === trimmed ? number2 : text;
|
|
7494
8092
|
}
|
|
7495
8093
|
function keyword2(text, at) {
|
|
7496
8094
|
const word = text.toLowerCase();
|
|
@@ -7892,6 +8490,2059 @@ var TableBuilder = class {
|
|
|
7892
8490
|
});
|
|
7893
8491
|
}
|
|
7894
8492
|
};
|
|
8493
|
+
var BACKOFF_STEPS_MS = [1e3, 2e3, 5e3, 1e4];
|
|
8494
|
+
var RATE_LIMITED_MIN_MS = 5e3;
|
|
8495
|
+
var IP_LIMIT_WAIT_MS = 6e4;
|
|
8496
|
+
var RESTART_JITTER_MS = 1e4;
|
|
8497
|
+
var UNOPENED_SLOWDOWN_AFTER = 6;
|
|
8498
|
+
var UNOPENED_DELAY_MS = 6e4;
|
|
8499
|
+
var STOP_CODES = /* @__PURE__ */ new Set([
|
|
8500
|
+
1e3,
|
|
8501
|
+
4e3,
|
|
8502
|
+
4001,
|
|
8503
|
+
4003,
|
|
8504
|
+
4004,
|
|
8505
|
+
4410
|
|
8506
|
+
]);
|
|
8507
|
+
function closeAction(code, reason) {
|
|
8508
|
+
if (STOP_CODES.has(code)) return { kind: "stop" };
|
|
8509
|
+
if (code === 4401) return { kind: "session" };
|
|
8510
|
+
if (code === 4503) return { kind: "restart" };
|
|
8511
|
+
if (code === 4029)
|
|
8512
|
+
return reason === "IP_RATE_LIMITED" ? { kind: "wait", ms: IP_LIMIT_WAIT_MS } : { kind: "retry", minMs: RATE_LIMITED_MIN_MS };
|
|
8513
|
+
return { kind: "retry", minMs: 0 };
|
|
8514
|
+
}
|
|
8515
|
+
function backoffMs(attempt22, random) {
|
|
8516
|
+
const step = BACKOFF_STEPS_MS[Math.min(attempt22, BACKOFF_STEPS_MS.length - 1)] ?? 1e4;
|
|
8517
|
+
return Math.round(step * (0.5 + random()));
|
|
8518
|
+
}
|
|
8519
|
+
function delayAfter(action, state, random) {
|
|
8520
|
+
if (action.kind === "restart")
|
|
8521
|
+
return Math.floor(random() * RESTART_JITTER_MS);
|
|
8522
|
+
if (action.kind === "wait")
|
|
8523
|
+
return action.ms + Math.floor(random() * RESTART_JITTER_MS);
|
|
8524
|
+
const base = state.unopened >= UNOPENED_SLOWDOWN_AFTER ? Math.round(UNOPENED_DELAY_MS * (0.5 + random())) : backoffMs(state.attempt, random);
|
|
8525
|
+
return Math.max(action.kind === "retry" ? action.minMs : 0, base);
|
|
8526
|
+
}
|
|
8527
|
+
function entryOf(meta) {
|
|
8528
|
+
if (!isRecord(meta) || typeof meta.phx_ref !== "string") return null;
|
|
8529
|
+
const { phx_ref: ref, naro_user_id: user, ...state } = meta;
|
|
8530
|
+
return {
|
|
8531
|
+
...state,
|
|
8532
|
+
presence_ref: ref,
|
|
8533
|
+
naro_user_id: typeof user === "string" ? user : null
|
|
8534
|
+
};
|
|
8535
|
+
}
|
|
8536
|
+
function entriesOf(value) {
|
|
8537
|
+
const read = /* @__PURE__ */ new Map();
|
|
8538
|
+
if (!isRecord(value)) return read;
|
|
8539
|
+
for (const [key, item] of Object.entries(value)) {
|
|
8540
|
+
if (!isRecord(item) || !Array.isArray(item.metas)) continue;
|
|
8541
|
+
const entries = item.metas.map(entryOf).filter((entry) => entry !== null);
|
|
8542
|
+
if (entries.length > 0) read.set(key, entries);
|
|
8543
|
+
}
|
|
8544
|
+
return read;
|
|
8545
|
+
}
|
|
8546
|
+
function without(entries, gone) {
|
|
8547
|
+
return entries.filter(
|
|
8548
|
+
(entry) => !gone.some((left) => left.presence_ref === entry.presence_ref)
|
|
8549
|
+
);
|
|
8550
|
+
}
|
|
8551
|
+
function copies(entries) {
|
|
8552
|
+
return entries.map((entry) => ({ ...entry }));
|
|
8553
|
+
}
|
|
8554
|
+
var Presence = class {
|
|
8555
|
+
#state = /* @__PURE__ */ new Map();
|
|
8556
|
+
/** A presence_state came for the current join: diffs apply only after it. */
|
|
8557
|
+
#synced = false;
|
|
8558
|
+
state() {
|
|
8559
|
+
return Object.fromEntries(
|
|
8560
|
+
[...this.#state].map(([key, entries]) => [key, copies(entries)])
|
|
8561
|
+
);
|
|
8562
|
+
}
|
|
8563
|
+
/** A new join, or the connection dropped: what is here stays until its presence_state. */
|
|
8564
|
+
unsync() {
|
|
8565
|
+
this.#synced = false;
|
|
8566
|
+
}
|
|
8567
|
+
clear() {
|
|
8568
|
+
this.#state = /* @__PURE__ */ new Map();
|
|
8569
|
+
this.#synced = false;
|
|
8570
|
+
}
|
|
8571
|
+
/**
|
|
8572
|
+
* No presence_state will say what is here any more (a join that may not
|
|
8573
|
+
* read presence, one refused, the server's close, a stop — plan 정정
|
|
8574
|
+
* (k)): each key leaves, then sync — said only when someone was here. A
|
|
8575
|
+
* diff waits for the next presence_state.
|
|
8576
|
+
*/
|
|
8577
|
+
leaveAll(emit) {
|
|
8578
|
+
if (this.#state.size > 0) this.applyState({}, emit);
|
|
8579
|
+
this.#synced = false;
|
|
8580
|
+
}
|
|
8581
|
+
applyState(payload, emit) {
|
|
8582
|
+
const before = this.#state;
|
|
8583
|
+
const next = entriesOf(payload);
|
|
8584
|
+
this.#state = next;
|
|
8585
|
+
this.#synced = true;
|
|
8586
|
+
for (const [key, entries] of next) {
|
|
8587
|
+
const held = before.get(key) ?? [];
|
|
8588
|
+
const added = without(entries, held);
|
|
8589
|
+
if (added.length > 0) emit.join(key, copies(added), copies(held));
|
|
8590
|
+
}
|
|
8591
|
+
for (const [key, entries] of before) {
|
|
8592
|
+
const left = without(entries, next.get(key) ?? []);
|
|
8593
|
+
if (left.length > 0)
|
|
8594
|
+
emit.leave(key, copies(left), copies(next.get(key) ?? []));
|
|
8595
|
+
}
|
|
8596
|
+
emit.sync();
|
|
8597
|
+
}
|
|
8598
|
+
applyDiff(payload, emit) {
|
|
8599
|
+
if (!this.#synced || !isRecord(payload)) return;
|
|
8600
|
+
for (const [key, added] of entriesOf(payload.joins)) {
|
|
8601
|
+
const held = this.#state.get(key) ?? [];
|
|
8602
|
+
this.#state.set(key, [...without(held, added), ...added]);
|
|
8603
|
+
emit.join(key, copies(added), copies(held));
|
|
8604
|
+
}
|
|
8605
|
+
for (const [key, left] of entriesOf(payload.leaves)) {
|
|
8606
|
+
const current = without(this.#state.get(key) ?? [], left);
|
|
8607
|
+
if (current.length > 0) this.#state.set(key, current);
|
|
8608
|
+
else this.#state.delete(key);
|
|
8609
|
+
emit.leave(key, copies(left), copies(current));
|
|
8610
|
+
}
|
|
8611
|
+
emit.sync();
|
|
8612
|
+
}
|
|
8613
|
+
};
|
|
8614
|
+
var REALTIME_PROTOCOL_VERSION = "1.0.0";
|
|
8615
|
+
var PHOENIX_TOPIC = "phoenix";
|
|
8616
|
+
var REALTIME_TOPIC_PREFIX = "realtime:";
|
|
8617
|
+
var HEARTBEAT_REQUEST = '{"topic":"phoenix","event":"heartbeat","payload":{},"ref":"hb"}';
|
|
8618
|
+
var HEARTBEAT_REF = "hb";
|
|
8619
|
+
var MAX_CHANNEL_NAME_LENGTH = 128;
|
|
8620
|
+
var CONNECTION_FRAMES_PER_SECOND = 10;
|
|
8621
|
+
var CONNECTION_FRAME_BURST = 120;
|
|
8622
|
+
var WARNED_CODES = /* @__PURE__ */ new Set([
|
|
8623
|
+
"TOO_MANY_JOINS",
|
|
8624
|
+
"TOO_MANY_MESSAGES",
|
|
8625
|
+
"TOO_MANY_PRESENCE_MESSAGES"
|
|
8626
|
+
]);
|
|
8627
|
+
var FLOOD_WINDOW_MS = 6e4;
|
|
8628
|
+
var MAX_CHANGE_CHANNELS_PER_TABLE = 4;
|
|
8629
|
+
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`;
|
|
8630
|
+
function readServerFrame(data) {
|
|
8631
|
+
if (typeof data !== "string") return null;
|
|
8632
|
+
let value;
|
|
8633
|
+
try {
|
|
8634
|
+
value = JSON.parse(data);
|
|
8635
|
+
} catch {
|
|
8636
|
+
return null;
|
|
8637
|
+
}
|
|
8638
|
+
if (!isRecord(value)) return null;
|
|
8639
|
+
const { topic, event, payload, ref } = value;
|
|
8640
|
+
const joinRef = value.join_ref;
|
|
8641
|
+
if (typeof topic !== "string" || typeof event !== "string") return null;
|
|
8642
|
+
if (!isRecord(payload)) return null;
|
|
8643
|
+
return {
|
|
8644
|
+
topic,
|
|
8645
|
+
event,
|
|
8646
|
+
payload,
|
|
8647
|
+
ref: typeof ref === "string" ? ref : null,
|
|
8648
|
+
joinRef: typeof joinRef === "string" ? joinRef : null
|
|
8649
|
+
};
|
|
8650
|
+
}
|
|
8651
|
+
function frameLimitOf(reason) {
|
|
8652
|
+
if (typeof reason !== "string") return null;
|
|
8653
|
+
const match = /^is larger than (\d+) bytes$/.exec(reason);
|
|
8654
|
+
return match === null ? null : Number(match[1]);
|
|
8655
|
+
}
|
|
8656
|
+
function utf8Length(text) {
|
|
8657
|
+
let bytes = 0;
|
|
8658
|
+
for (let index = 0; index < text.length; index++) {
|
|
8659
|
+
const unit = text.charCodeAt(index);
|
|
8660
|
+
if (unit < 128) bytes += 1;
|
|
8661
|
+
else if (unit < 2048) bytes += 2;
|
|
8662
|
+
else if (unit >= 55296 && unit <= 56319 && index + 1 < text.length) {
|
|
8663
|
+
const next = text.charCodeAt(index + 1);
|
|
8664
|
+
if (next >= 56320 && next <= 57343) {
|
|
8665
|
+
bytes += 4;
|
|
8666
|
+
index++;
|
|
8667
|
+
} else bytes += 3;
|
|
8668
|
+
} else bytes += 3;
|
|
8669
|
+
}
|
|
8670
|
+
return bytes;
|
|
8671
|
+
}
|
|
8672
|
+
function clientFrame(topic, event, payload, ref, joinRef) {
|
|
8673
|
+
return JSON.stringify(
|
|
8674
|
+
joinRef === void 0 ? { topic, event, payload, ref } : { topic, event, payload, ref, join_ref: joinRef }
|
|
8675
|
+
);
|
|
8676
|
+
}
|
|
8677
|
+
function atMostCodePoints(text, max) {
|
|
8678
|
+
if (text.length <= max) return true;
|
|
8679
|
+
return text.length <= max * 2 && [...text].length <= max;
|
|
8680
|
+
}
|
|
8681
|
+
function hasControlCharacter(text) {
|
|
8682
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
8683
|
+
const code = text.charCodeAt(i);
|
|
8684
|
+
if (code <= 31 || code >= 127 && code <= 159) return true;
|
|
8685
|
+
}
|
|
8686
|
+
return false;
|
|
8687
|
+
}
|
|
8688
|
+
function hasBidiControl(text) {
|
|
8689
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
8690
|
+
const code = text.charCodeAt(i);
|
|
8691
|
+
if (code === 1564 || code === 8206 || code === 8207 || code >= 8234 && code <= 8238 || code >= 8294 && code <= 8297)
|
|
8692
|
+
return true;
|
|
8693
|
+
}
|
|
8694
|
+
return false;
|
|
8695
|
+
}
|
|
8696
|
+
function channelNameProblem(name) {
|
|
8697
|
+
if (name === "") return "is empty";
|
|
8698
|
+
if (!atMostCodePoints(name, MAX_CHANNEL_NAME_LENGTH))
|
|
8699
|
+
return `is longer than ${String(MAX_CHANNEL_NAME_LENGTH)} characters`;
|
|
8700
|
+
if (hasControlCharacter(name)) return "has a control character";
|
|
8701
|
+
if (hasBidiControl(name)) return "has a bidirectional control character";
|
|
8702
|
+
return void 0;
|
|
8703
|
+
}
|
|
8704
|
+
function socketUrl(baseUrl, apiKey) {
|
|
8705
|
+
const url = new URL("/_naro/v1/realtime/websocket", baseUrl);
|
|
8706
|
+
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
|
|
8707
|
+
return `${url.href}?apikey=${encodeURIComponent(apiKey)}&vsn=${REALTIME_PROTOCOL_VERSION}`;
|
|
8708
|
+
}
|
|
8709
|
+
var HEARTBEAT_MS = 25e3;
|
|
8710
|
+
var TIMEOUT_MS = 1e4;
|
|
8711
|
+
var PACED_BURST = CONNECTION_FRAME_BURST - 20;
|
|
8712
|
+
var SDK_WARNING_ROOM = 6;
|
|
8713
|
+
var ignore = () => void 0;
|
|
8714
|
+
function now() {
|
|
8715
|
+
return typeof performance === "undefined" ? Date.now() : performance.now();
|
|
8716
|
+
}
|
|
8717
|
+
function detach(ws) {
|
|
8718
|
+
ws.onopen = null;
|
|
8719
|
+
ws.onmessage = null;
|
|
8720
|
+
ws.onclose = null;
|
|
8721
|
+
ws.onerror = ignore;
|
|
8722
|
+
}
|
|
8723
|
+
function closeQuietly(ws, code) {
|
|
8724
|
+
try {
|
|
8725
|
+
ws.close(code);
|
|
8726
|
+
} catch {
|
|
8727
|
+
}
|
|
8728
|
+
}
|
|
8729
|
+
function safely(callback) {
|
|
8730
|
+
try {
|
|
8731
|
+
callback();
|
|
8732
|
+
} catch (error) {
|
|
8733
|
+
queueMicrotask(() => {
|
|
8734
|
+
throw error;
|
|
8735
|
+
});
|
|
8736
|
+
}
|
|
8737
|
+
}
|
|
8738
|
+
function replyOf(payload) {
|
|
8739
|
+
if (payload.status === "ok") {
|
|
8740
|
+
const { response } = payload;
|
|
8741
|
+
return {
|
|
8742
|
+
ok: true,
|
|
8743
|
+
response: typeof response === "object" && response !== null && !Array.isArray(response) ? response : {}
|
|
8744
|
+
};
|
|
8745
|
+
}
|
|
8746
|
+
return {
|
|
8747
|
+
ok: false,
|
|
8748
|
+
error: asError(payload.response) ?? invalidResponse(101),
|
|
8749
|
+
lost: false
|
|
8750
|
+
};
|
|
8751
|
+
}
|
|
8752
|
+
var RealtimeSocket = class {
|
|
8753
|
+
#config;
|
|
8754
|
+
#heartbeatMs;
|
|
8755
|
+
#timeoutMs;
|
|
8756
|
+
#ws = null;
|
|
8757
|
+
#state = "idle";
|
|
8758
|
+
#generation = 0;
|
|
8759
|
+
/** Drops since the hub last answered: the next wait's step (결정 7). */
|
|
8760
|
+
#attempt = 0;
|
|
8761
|
+
/** Tries in a row that never opened (결정 9). */
|
|
8762
|
+
#unopened = 0;
|
|
8763
|
+
/** When the wait the last close or stop set ends: no connect() opens before it. */
|
|
8764
|
+
#resumeAt = 0;
|
|
8765
|
+
/**
|
|
8766
|
+
* The last close was 4008 (CLIENT_FLOODING), and no heartbeat was
|
|
8767
|
+
* answered since: a channel's word that the hub answered does not start
|
|
8768
|
+
* the backoff over — a connection that floods again must not come back
|
|
8769
|
+
* every second (PR #328 review).
|
|
8770
|
+
*/
|
|
8771
|
+
#flooded = false;
|
|
8772
|
+
/**
|
|
8773
|
+
* The frames waiting their turn on this connection, in the order they
|
|
8774
|
+
* go: the first `#ahead` before the app's (`#enqueue`).
|
|
8775
|
+
*/
|
|
8776
|
+
#queue = [];
|
|
8777
|
+
/** 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. */
|
|
8778
|
+
#ahead = 0;
|
|
8779
|
+
/** The app's frames in `#queue`: not in flight yet, so no room is kept for them (`#hasRoom`). */
|
|
8780
|
+
#appQueued = 0;
|
|
8781
|
+
/** The budget this connection has left (`PACED_BURST` at the open), as of `#tokensAt`. */
|
|
8782
|
+
#tokens = 0;
|
|
8783
|
+
#tokensAt = 0;
|
|
8784
|
+
#paceTimer;
|
|
8785
|
+
/** When the hub refused a frame on this connection with a warning (`WARNED_CODES`), oldest first. */
|
|
8786
|
+
#warnings = [];
|
|
8787
|
+
/** The SDK's own frames waiting for room, by who sends them, in the order they asked (`whenRoom()`). */
|
|
8788
|
+
#roomWaiters = /* @__PURE__ */ new Map();
|
|
8789
|
+
/** Due when the oldest warning leaves the window, while a frame waits for room. */
|
|
8790
|
+
#roomTimer;
|
|
8791
|
+
#draining = false;
|
|
8792
|
+
#ref = 0;
|
|
8793
|
+
#retryTimer;
|
|
8794
|
+
#openTimer;
|
|
8795
|
+
#heartbeat;
|
|
8796
|
+
#heartbeatPending = false;
|
|
8797
|
+
/** The connection-level `system` frame since the open: why it is about to close. */
|
|
8798
|
+
#said = null;
|
|
8799
|
+
/**
|
|
8800
|
+
* The size limit the hub named when it refused a frame on this
|
|
8801
|
+
* connection (`#refusedOverLimit`): a frame over it is answered so without
|
|
8802
|
+
* being sent. Forgotten with the connection — the next hub's plan may
|
|
8803
|
+
* differ.
|
|
8804
|
+
*/
|
|
8805
|
+
#frameLimit = null;
|
|
8806
|
+
#replies = /* @__PURE__ */ new Map();
|
|
8807
|
+
constructor(config) {
|
|
8808
|
+
this.#config = config;
|
|
8809
|
+
this.#heartbeatMs = config.options.heartbeatIntervalMs ?? HEARTBEAT_MS;
|
|
8810
|
+
this.#timeoutMs = config.options.timeoutMs ?? TIMEOUT_MS;
|
|
8811
|
+
}
|
|
8812
|
+
get generation() {
|
|
8813
|
+
return this.#generation;
|
|
8814
|
+
}
|
|
8815
|
+
get timeoutMs() {
|
|
8816
|
+
return this.#timeoutMs;
|
|
8817
|
+
}
|
|
8818
|
+
isOpen() {
|
|
8819
|
+
return this.#state === "open";
|
|
8820
|
+
}
|
|
8821
|
+
nextRef() {
|
|
8822
|
+
this.#ref += 1;
|
|
8823
|
+
return String(this.#ref);
|
|
8824
|
+
}
|
|
8825
|
+
/**
|
|
8826
|
+
* Opens unless a connection is open, opening or due — once the wait the
|
|
8827
|
+
* last close or stop set is over. After the app's own close the counts
|
|
8828
|
+
* start over; after a stop they go on, as after a drop.
|
|
8829
|
+
*/
|
|
8830
|
+
connect() {
|
|
8831
|
+
if (this.#state !== "idle" && this.#state !== "stopped") return;
|
|
8832
|
+
if (this.#state === "idle") {
|
|
8833
|
+
this.#attempt = 0;
|
|
8834
|
+
this.#unopened = 0;
|
|
8835
|
+
}
|
|
8836
|
+
const wait = this.#resumeAt - now();
|
|
8837
|
+
if (wait <= 0) return this.#open();
|
|
8838
|
+
this.#state = "waiting";
|
|
8839
|
+
this.#retryTimer = setTimeout(() => {
|
|
8840
|
+
this.#retryTimer = void 0;
|
|
8841
|
+
if (this.#state === "waiting") this.#open();
|
|
8842
|
+
}, wait);
|
|
8843
|
+
}
|
|
8844
|
+
/** The last channel went: close for good, with no event. */
|
|
8845
|
+
disconnect() {
|
|
8846
|
+
const ws = this.#ws;
|
|
8847
|
+
this.#ws = null;
|
|
8848
|
+
this.#clearTimers();
|
|
8849
|
+
this.#state = "idle";
|
|
8850
|
+
this.#said = null;
|
|
8851
|
+
this.#failReplies(realtimeDisconnected());
|
|
8852
|
+
if (ws !== null) {
|
|
8853
|
+
detach(ws);
|
|
8854
|
+
closeQuietly(ws, 1e3);
|
|
8855
|
+
}
|
|
8856
|
+
}
|
|
8857
|
+
/**
|
|
8858
|
+
* Lets go of the open connection and comes back as after a drop (1006),
|
|
8859
|
+
* the channels told: for a connection that holds an identity the app no
|
|
8860
|
+
* longer means and cannot change (identity.ts). Nothing unless open.
|
|
8861
|
+
*/
|
|
8862
|
+
reconnect() {
|
|
8863
|
+
const ws = this.#ws;
|
|
8864
|
+
if (ws === null || this.#state !== "open") return;
|
|
8865
|
+
detach(ws);
|
|
8866
|
+
closeQuietly(ws, 1e3);
|
|
8867
|
+
this.#closed(ws, 1006, "", null);
|
|
8868
|
+
}
|
|
8869
|
+
/**
|
|
8870
|
+
* The hub answered this connection: the next drop waits from the first
|
|
8871
|
+
* step — unless the last close was 4008 and no heartbeat was answered
|
|
8872
|
+
* since (`#flooded`).
|
|
8873
|
+
*/
|
|
8874
|
+
served() {
|
|
8875
|
+
if (!this.#flooded) this.#attempt = 0;
|
|
8876
|
+
}
|
|
8877
|
+
/**
|
|
8878
|
+
* A frame, in its `turn` (the app's unless said), and the `phx_reply`
|
|
8879
|
+
* with its `ref` within `timeoutMs` of when it went — or, for one over the
|
|
8880
|
+
* size limit the hub named on this connection, that refusal at once and
|
|
8881
|
+
* nothing sent; for an app's frame but a leave that would not go within
|
|
8882
|
+
* `timeoutMs`, CLIENT_FLOODING at once and nothing sent.
|
|
8883
|
+
*/
|
|
8884
|
+
request(topic, event, payload, options = {}) {
|
|
8885
|
+
const ref = options.ref ?? this.nextRef();
|
|
8886
|
+
const turn = options.turn ?? "app";
|
|
8887
|
+
return new Promise((resolve7) => {
|
|
8888
|
+
const lost = {
|
|
8889
|
+
ok: false,
|
|
8890
|
+
error: realtimeClosed(1006),
|
|
8891
|
+
lost: true
|
|
8892
|
+
};
|
|
8893
|
+
if (!this.isOpen()) return resolve7(lost);
|
|
8894
|
+
let text;
|
|
8895
|
+
try {
|
|
8896
|
+
text = clientFrame(topic, event, payload, ref, options.joinRef);
|
|
8897
|
+
} catch {
|
|
8898
|
+
return resolve7(lost);
|
|
8899
|
+
}
|
|
8900
|
+
const bytes = utf8Length(text);
|
|
8901
|
+
const limit = this.#frameLimit;
|
|
8902
|
+
if (limit !== null && bytes > limit)
|
|
8903
|
+
return resolve7({ ok: false, error: frameTooLarge(limit), lost: false });
|
|
8904
|
+
if (turn === "app" && event !== "phx_leave" && !this.#goesInTime())
|
|
8905
|
+
return resolve7({
|
|
8906
|
+
ok: false,
|
|
8907
|
+
error: clientFlooding(CONNECTION_FRAMES_PER_SECOND),
|
|
8908
|
+
lost: false
|
|
8909
|
+
});
|
|
8910
|
+
this.#replies.set(ref, { resolve: resolve7, timer: void 0, bytes, turn });
|
|
8911
|
+
this.#enqueue({ text, bytes, ref, turn, presence: event === "presence" });
|
|
8912
|
+
});
|
|
8913
|
+
}
|
|
8914
|
+
/**
|
|
8915
|
+
* Whether an app's frame behind every frame waiting would go within
|
|
8916
|
+
* `timeoutMs`: past the budget left, the hub takes
|
|
8917
|
+
* `CONNECTION_FRAMES_PER_SECOND` a second. The SDK's own frames that come
|
|
8918
|
+
* later go before it, a few at most (`whenRoom()`).
|
|
8919
|
+
*/
|
|
8920
|
+
#goesInTime() {
|
|
8921
|
+
this.#refill();
|
|
8922
|
+
const over = this.#queue.length + 1 - this.#tokens;
|
|
8923
|
+
return over <= CONNECTION_FRAMES_PER_SECOND * this.#timeoutMs / 1e3;
|
|
8924
|
+
}
|
|
8925
|
+
/**
|
|
8926
|
+
* Runs `go` once a frame the SDK sends of its own accord may go: at once
|
|
8927
|
+
* when nothing waits before it and there is room — the warnings of the
|
|
8928
|
+
* last minute and the requests in flight fewer than `SDK_WARNING_ROOM`
|
|
8929
|
+
* (the app's frames still waiting their turn are not in flight yet, and
|
|
8930
|
+
* go after the SDK's), or the warnings fewer and none of the SDK's own
|
|
8931
|
+
* unanswered — or else when there is, in the order asked. The
|
|
8932
|
+
* frame itself is sent with `turn: "sdk"`. `key` names the sender:
|
|
8933
|
+
* asked again before it ran, it keeps its place and runs the newer `go`,
|
|
8934
|
+
* which reads its own state again then. Nothing while the connection is
|
|
8935
|
+
* not open, and forgotten with it: the sender tries again when it is live.
|
|
8936
|
+
*/
|
|
8937
|
+
whenRoom(key, go) {
|
|
8938
|
+
if (this.#state !== "open") return;
|
|
8939
|
+
if (this.#roomWaiters.size === 0 && this.#hasRoom()) return go();
|
|
8940
|
+
this.#roomWaiters.set(key, go);
|
|
8941
|
+
this.#armRoom();
|
|
8942
|
+
}
|
|
8943
|
+
/**
|
|
8944
|
+
* Room for one more frame of the SDK's own (`whenRoom()`): the requests
|
|
8945
|
+
* that count are those sent and not answered, and the SDK's own still to
|
|
8946
|
+
* go — not the app's waiting their turn, which the SDK's go before (PR
|
|
8947
|
+
* #328 re-review). And while none of the SDK's own is unanswered, one
|
|
8948
|
+
* goes whatever the app has in flight, if the warnings leave room (PR #328
|
|
8949
|
+
* re-review 2): an app sending 10 a second over a link of 0.7 s or more
|
|
8950
|
+
* always has six in flight, and a second channel's join would wait,
|
|
8951
|
+
* untold, for as long as it sends. A refusal of the app's is a warning
|
|
8952
|
+
* when it comes, so the SDK adds one at most.
|
|
8953
|
+
*/
|
|
8954
|
+
#hasRoom() {
|
|
8955
|
+
if (this.#state !== "open") return false;
|
|
8956
|
+
const since = now() - FLOOD_WINDOW_MS;
|
|
8957
|
+
this.#warnings = this.#warnings.filter((at) => at > since);
|
|
8958
|
+
const warnings = this.#warnings.length;
|
|
8959
|
+
if (warnings >= SDK_WARNING_ROOM) return false;
|
|
8960
|
+
const inFlight2 = this.#replies.size - this.#appQueued;
|
|
8961
|
+
return warnings + inFlight2 < SDK_WARNING_ROOM || !this.#sdkUnanswered();
|
|
8962
|
+
}
|
|
8963
|
+
/** Whether a request of the SDK's own or the identity's waits for its answer, sent or still to go. */
|
|
8964
|
+
#sdkUnanswered() {
|
|
8965
|
+
for (const waiter of this.#replies.values())
|
|
8966
|
+
if (waiter.turn !== "app") return true;
|
|
8967
|
+
return false;
|
|
8968
|
+
}
|
|
8969
|
+
/** The frames waiting for room go while there is: after an answer, a time-out, and when a warning leaves the window. */
|
|
8970
|
+
#drainRoom() {
|
|
8971
|
+
if (this.#draining) return;
|
|
8972
|
+
this.#draining = true;
|
|
8973
|
+
try {
|
|
8974
|
+
for (const [key, go] of this.#roomWaiters) {
|
|
8975
|
+
if (!this.#hasRoom()) break;
|
|
8976
|
+
this.#roomWaiters.delete(key);
|
|
8977
|
+
safely(go);
|
|
8978
|
+
}
|
|
8979
|
+
} finally {
|
|
8980
|
+
this.#draining = false;
|
|
8981
|
+
}
|
|
8982
|
+
this.#armRoom();
|
|
8983
|
+
}
|
|
8984
|
+
/** While a frame waits for room the warnings hold, look again when the oldest leaves the window. */
|
|
8985
|
+
#armRoom() {
|
|
8986
|
+
clearTimeout(this.#roomTimer);
|
|
8987
|
+
this.#roomTimer = void 0;
|
|
8988
|
+
const oldest = this.#warnings[0];
|
|
8989
|
+
if (this.#roomWaiters.size === 0 || oldest === void 0) return;
|
|
8990
|
+
this.#roomTimer = setTimeout(
|
|
8991
|
+
() => {
|
|
8992
|
+
this.#roomTimer = void 0;
|
|
8993
|
+
this.#drainRoom();
|
|
8994
|
+
},
|
|
8995
|
+
Math.max(1, oldest + FLOOD_WINDOW_MS - now() + 1)
|
|
8996
|
+
);
|
|
8997
|
+
}
|
|
8998
|
+
/**
|
|
8999
|
+
* Puts `frame` in its turn and sends what may go. The app's go last; the
|
|
9000
|
+
* SDK's own and the identity's after the others before the app's. An
|
|
9001
|
+
* identity frame goes after the app's presence frames still to go, with
|
|
9002
|
+
* whatever waits before them: the hub reads a presence as the user it
|
|
9003
|
+
* holds when the frame comes, and would hold the last user's state for the
|
|
9004
|
+
* new one.
|
|
9005
|
+
*/
|
|
9006
|
+
#enqueue(frame) {
|
|
9007
|
+
if (frame.turn === "app") {
|
|
9008
|
+
this.#queue.push(frame);
|
|
9009
|
+
this.#appQueued += 1;
|
|
9010
|
+
} else {
|
|
9011
|
+
if (frame.turn === "identity") this.#ahead = this.#pastPresence();
|
|
9012
|
+
this.#queue.splice(this.#ahead, 0, frame);
|
|
9013
|
+
this.#ahead += 1;
|
|
9014
|
+
}
|
|
9015
|
+
this.#flush();
|
|
9016
|
+
}
|
|
9017
|
+
/** Just past the app's last presence frame still to go — `#ahead` when none is. */
|
|
9018
|
+
#pastPresence() {
|
|
9019
|
+
for (let index = this.#queue.length - 1; index >= this.#ahead; index -= 1)
|
|
9020
|
+
if (this.#queue[index]?.presence === true) return index + 1;
|
|
9021
|
+
return this.#ahead;
|
|
9022
|
+
}
|
|
9023
|
+
/** The next frame to go, out of `#queue`. */
|
|
9024
|
+
#next() {
|
|
9025
|
+
const frame = this.#queue.shift();
|
|
9026
|
+
if (frame === void 0) return void 0;
|
|
9027
|
+
if (this.#ahead > 0) this.#ahead -= 1;
|
|
9028
|
+
if (frame.turn === "app") this.#appQueued -= 1;
|
|
9029
|
+
return frame;
|
|
9030
|
+
}
|
|
9031
|
+
/** `ref`'s frame out of `#queue`, if it is still there: answered before it went. */
|
|
9032
|
+
#unqueue(ref) {
|
|
9033
|
+
const index = this.#queue.findIndex((frame2) => frame2.ref === ref);
|
|
9034
|
+
if (index === -1) return;
|
|
9035
|
+
const [frame] = this.#queue.splice(index, 1);
|
|
9036
|
+
if (index < this.#ahead) this.#ahead -= 1;
|
|
9037
|
+
if (frame?.turn === "app") this.#appQueued -= 1;
|
|
9038
|
+
}
|
|
9039
|
+
/**
|
|
9040
|
+
* Sends the frames waiting their turn while the budget has room, in their
|
|
9041
|
+
* turn, and comes back when the next one is due. One whose frame the
|
|
9042
|
+
* socket cannot send is lost now — its close event follows.
|
|
9043
|
+
*/
|
|
9044
|
+
#flush() {
|
|
9045
|
+
const ws = this.#ws;
|
|
9046
|
+
if (ws === null || this.#state !== "open") return;
|
|
9047
|
+
this.#refill();
|
|
9048
|
+
while (this.#queue.length > 0 && this.#tokens >= 1) {
|
|
9049
|
+
const frame = this.#next();
|
|
9050
|
+
const waiter = this.#replies.get(frame.ref);
|
|
9051
|
+
if (waiter === void 0) continue;
|
|
9052
|
+
const limit = this.#frameLimit;
|
|
9053
|
+
if (limit !== null && frame.bytes > limit) {
|
|
9054
|
+
this.#answer(frame.ref, {
|
|
9055
|
+
ok: false,
|
|
9056
|
+
error: frameTooLarge(limit),
|
|
9057
|
+
lost: false
|
|
9058
|
+
});
|
|
9059
|
+
continue;
|
|
9060
|
+
}
|
|
9061
|
+
try {
|
|
9062
|
+
ws.send(frame.text);
|
|
9063
|
+
} catch {
|
|
9064
|
+
this.#answer(frame.ref, {
|
|
9065
|
+
ok: false,
|
|
9066
|
+
error: realtimeClosed(1006),
|
|
9067
|
+
lost: true
|
|
9068
|
+
});
|
|
9069
|
+
continue;
|
|
9070
|
+
}
|
|
9071
|
+
this.#tokens -= 1;
|
|
9072
|
+
waiter.timer = this.#timeOut(frame.ref, waiter);
|
|
9073
|
+
}
|
|
9074
|
+
if (this.#queue.length === 0 || this.#paceTimer !== void 0) return;
|
|
9075
|
+
const due = Math.ceil(
|
|
9076
|
+
(1 - this.#tokens) * 1e3 / CONNECTION_FRAMES_PER_SECOND
|
|
9077
|
+
);
|
|
9078
|
+
this.#paceTimer = setTimeout(
|
|
9079
|
+
() => {
|
|
9080
|
+
this.#paceTimer = void 0;
|
|
9081
|
+
this.#flush();
|
|
9082
|
+
},
|
|
9083
|
+
Math.max(1, due)
|
|
9084
|
+
);
|
|
9085
|
+
}
|
|
9086
|
+
/** The budget refilled for the time since it was last looked at, as the hub's bucket refills. */
|
|
9087
|
+
#refill() {
|
|
9088
|
+
const at = now();
|
|
9089
|
+
if (at <= this.#tokensAt) return;
|
|
9090
|
+
this.#tokens = Math.min(
|
|
9091
|
+
PACED_BURST,
|
|
9092
|
+
this.#tokens + (at - this.#tokensAt) / 1e3 * CONNECTION_FRAMES_PER_SECOND
|
|
9093
|
+
);
|
|
9094
|
+
this.#tokensAt = at;
|
|
9095
|
+
}
|
|
9096
|
+
/**
|
|
9097
|
+
* `ref`'s request hears `reply`, now: one fewer in flight — or, answered
|
|
9098
|
+
* before its frame went (over a size limit the hub named), one fewer to
|
|
9099
|
+
* go, and nothing sent.
|
|
9100
|
+
*/
|
|
9101
|
+
#answer(ref, reply) {
|
|
9102
|
+
const waiter = this.#replies.get(ref);
|
|
9103
|
+
if (waiter === void 0) return;
|
|
9104
|
+
this.#replies.delete(ref);
|
|
9105
|
+
clearTimeout(waiter.timer);
|
|
9106
|
+
if (waiter.timer === void 0) this.#unqueue(ref);
|
|
9107
|
+
waiter.resolve(reply);
|
|
9108
|
+
this.#drainRoom();
|
|
9109
|
+
}
|
|
9110
|
+
/** TIMED_OUT for `ref`'s request `timeoutMs` after its frame went. */
|
|
9111
|
+
#timeOut(ref, waiter) {
|
|
9112
|
+
return setTimeout(() => {
|
|
9113
|
+
if (this.#replies.get(ref) !== waiter) return;
|
|
9114
|
+
this.#answer(ref, {
|
|
9115
|
+
ok: false,
|
|
9116
|
+
error: realtimeTimedOut(this.#timeoutMs),
|
|
9117
|
+
lost: false
|
|
9118
|
+
});
|
|
9119
|
+
}, this.#timeoutMs);
|
|
9120
|
+
}
|
|
9121
|
+
#open() {
|
|
9122
|
+
const Impl = this.#config.options.WebSocket ?? globalThis.WebSocket;
|
|
9123
|
+
if (typeof Impl !== "function")
|
|
9124
|
+
return this.#stop(websocketUnavailable(), true);
|
|
9125
|
+
if (this.#config.service && !isKnownServer())
|
|
9126
|
+
return this.#stop(serviceKeyOffServer(), true);
|
|
9127
|
+
let ws;
|
|
9128
|
+
try {
|
|
9129
|
+
ws = new Impl(
|
|
9130
|
+
socketUrl(this.#config.baseUrl, this.#config.apiKey)
|
|
9131
|
+
);
|
|
9132
|
+
} catch (error) {
|
|
9133
|
+
return this.#stop(socketRefused(error), true);
|
|
9134
|
+
}
|
|
9135
|
+
this.#generation += 1;
|
|
9136
|
+
this.#ws = ws;
|
|
9137
|
+
this.#state = "connecting";
|
|
9138
|
+
this.#said = null;
|
|
9139
|
+
this.#frameLimit = null;
|
|
9140
|
+
ws.onopen = () => this.#opened(ws);
|
|
9141
|
+
ws.onmessage = (event) => this.#message(ws, event.data);
|
|
9142
|
+
ws.onclose = (event) => this.#closed(ws, event.code, event.reason ?? "", null);
|
|
9143
|
+
ws.onerror = ignore;
|
|
9144
|
+
this.#openTimer = setTimeout(
|
|
9145
|
+
() => this.#closed(ws, 1006, "", this.#timeoutMs),
|
|
9146
|
+
this.#timeoutMs
|
|
9147
|
+
);
|
|
9148
|
+
}
|
|
9149
|
+
#opened(ws) {
|
|
9150
|
+
if (this.#ws !== ws || this.#state !== "connecting") return;
|
|
9151
|
+
clearTimeout(this.#openTimer);
|
|
9152
|
+
this.#openTimer = void 0;
|
|
9153
|
+
this.#state = "open";
|
|
9154
|
+
this.#unopened = 0;
|
|
9155
|
+
this.#heartbeatPending = false;
|
|
9156
|
+
this.#queue = [];
|
|
9157
|
+
this.#ahead = 0;
|
|
9158
|
+
this.#appQueued = 0;
|
|
9159
|
+
this.#tokens = PACED_BURST;
|
|
9160
|
+
this.#tokensAt = now();
|
|
9161
|
+
this.#warnings = [];
|
|
9162
|
+
this.#heartbeat = setInterval(() => this.#beat(ws), this.#heartbeatMs);
|
|
9163
|
+
safely(() => this.#config.events.opened());
|
|
9164
|
+
}
|
|
9165
|
+
#beat(ws) {
|
|
9166
|
+
if (this.#ws !== ws) return;
|
|
9167
|
+
if (this.#heartbeatPending)
|
|
9168
|
+
return this.#closed(ws, 1e3, "", this.#heartbeatMs);
|
|
9169
|
+
this.#heartbeatPending = true;
|
|
9170
|
+
try {
|
|
9171
|
+
ws.send(HEARTBEAT_REQUEST);
|
|
9172
|
+
} catch {
|
|
9173
|
+
}
|
|
9174
|
+
}
|
|
9175
|
+
#message(ws, data) {
|
|
9176
|
+
if (this.#ws !== ws) return;
|
|
9177
|
+
const frame = readServerFrame(data);
|
|
9178
|
+
if (frame === null) return;
|
|
9179
|
+
if (frame.event === "phx_reply" && frame.ref !== null) {
|
|
9180
|
+
if (frame.ref === HEARTBEAT_REF) {
|
|
9181
|
+
this.#heartbeatPending = false;
|
|
9182
|
+
this.#flooded = false;
|
|
9183
|
+
this.#attempt = 0;
|
|
9184
|
+
return;
|
|
9185
|
+
}
|
|
9186
|
+
const reply = replyOf(frame.payload);
|
|
9187
|
+
if (!reply.ok && WARNED_CODES.has(reply.error.code))
|
|
9188
|
+
this.#warnings.push(now());
|
|
9189
|
+
if (this.#replies.has(frame.ref)) {
|
|
9190
|
+
this.#answer(frame.ref, reply);
|
|
9191
|
+
return;
|
|
9192
|
+
}
|
|
9193
|
+
}
|
|
9194
|
+
if (frame.topic === PHOENIX_TOPIC) {
|
|
9195
|
+
if (frame.event === "system") this.#said = asError(frame.payload);
|
|
9196
|
+
else if (frame.event === "phx_reply" && frame.ref === null)
|
|
9197
|
+
this.#refusedOverLimit(frame.payload);
|
|
9198
|
+
return;
|
|
9199
|
+
}
|
|
9200
|
+
safely(() => this.#config.events.frame(frame));
|
|
9201
|
+
}
|
|
9202
|
+
/**
|
|
9203
|
+
* The hub refused a frame over its size limit before reading it (final
|
|
9204
|
+
* review A M3, plan 정정 (m)): it names no `ref`, so no request waits for
|
|
9205
|
+
* it by name — it waited `timeoutMs` and answered TIMED_OUT. But the hub
|
|
9206
|
+
* refuses every frame over the limit, and only those, before anything
|
|
9207
|
+
* else: each request waiting whose frame is over it is one refused so, and
|
|
9208
|
+
* hears it now. The limit stays for the connection.
|
|
9209
|
+
*/
|
|
9210
|
+
#refusedOverLimit(payload) {
|
|
9211
|
+
if (payload.status !== "error") return;
|
|
9212
|
+
const error = asError(payload.response);
|
|
9213
|
+
if (error?.code !== "INVALID_MESSAGE" || !isRecord(error.details)) return;
|
|
9214
|
+
const limit = frameLimitOf(error.details.reason);
|
|
9215
|
+
if (limit === null) return;
|
|
9216
|
+
this.#frameLimit = limit;
|
|
9217
|
+
for (const [ref, waiter] of this.#replies)
|
|
9218
|
+
if (waiter.bytes > limit)
|
|
9219
|
+
this.#answer(ref, {
|
|
9220
|
+
ok: false,
|
|
9221
|
+
error: frameTooLarge(limit),
|
|
9222
|
+
lost: false
|
|
9223
|
+
});
|
|
9224
|
+
}
|
|
9225
|
+
/**
|
|
9226
|
+
* `ws` ended: its close event (`waitedMs` null), or a heartbeat or an open
|
|
9227
|
+
* that had no answer in `waitedMs` — then it is let go at once, its
|
|
9228
|
+
* handlers first, since a dead TCP connection can take minutes to report
|
|
9229
|
+
* its close (결정 11).
|
|
9230
|
+
*/
|
|
9231
|
+
#closed(ws, code, reason, waitedMs) {
|
|
9232
|
+
if (this.#ws !== ws) return;
|
|
9233
|
+
const wasOpen = this.#state === "open";
|
|
9234
|
+
const timedOut = waitedMs !== null;
|
|
9235
|
+
detach(ws);
|
|
9236
|
+
this.#ws = null;
|
|
9237
|
+
this.#clearTimers();
|
|
9238
|
+
if (timedOut) closeQuietly(ws, 1e3);
|
|
9239
|
+
if (!wasOpen) this.#unopened += 1;
|
|
9240
|
+
const said = this.#said;
|
|
9241
|
+
this.#said = null;
|
|
9242
|
+
if (!timedOut && code === 4008) this.#flooded = true;
|
|
9243
|
+
const action = timedOut ? { kind: "retry", minMs: 0 } : closeAction(code, said?.code ?? reason);
|
|
9244
|
+
if (action.kind === "stop")
|
|
9245
|
+
return this.#stop(said ?? realtimeStopped(code, reason));
|
|
9246
|
+
const error = timedOut ? realtimeTimedOut(waitedMs) : said ?? realtimeClosed(code);
|
|
9247
|
+
this.#failReplies(error);
|
|
9248
|
+
this.#state = "waiting";
|
|
9249
|
+
const delay = delayAfter(
|
|
9250
|
+
action,
|
|
9251
|
+
{ attempt: this.#attempt, unopened: this.#unopened },
|
|
9252
|
+
Math.random
|
|
9253
|
+
);
|
|
9254
|
+
if (action.kind !== "restart" && action.kind !== "wait") this.#attempt += 1;
|
|
9255
|
+
this.#wait(action.kind === "session", delay);
|
|
9256
|
+
safely(
|
|
9257
|
+
() => this.#config.events.dropped(
|
|
9258
|
+
error,
|
|
9259
|
+
timedOut ? "TIMED_OUT" : "CHANNEL_ERROR"
|
|
9260
|
+
)
|
|
9261
|
+
);
|
|
9262
|
+
}
|
|
9263
|
+
/**
|
|
9264
|
+
* The next try, `delay` from now — after a 4401, from when its
|
|
9265
|
+
* conditional delete is done, or from `timeoutMs` on if it is not: an
|
|
9266
|
+
* app's `auth.storage` that never answers must not keep the connection
|
|
9267
|
+
* away for good.
|
|
9268
|
+
*/
|
|
9269
|
+
#wait(session, delay) {
|
|
9270
|
+
const generation = this.#generation;
|
|
9271
|
+
this.#resumeAt = now() + delay;
|
|
9272
|
+
let due = false;
|
|
9273
|
+
const schedule = () => {
|
|
9274
|
+
if (due || this.#state !== "waiting" || this.#generation !== generation)
|
|
9275
|
+
return;
|
|
9276
|
+
due = true;
|
|
9277
|
+
this.#resumeAt = now() + delay;
|
|
9278
|
+
clearTimeout(this.#retryTimer);
|
|
9279
|
+
this.#retryTimer = setTimeout(() => {
|
|
9280
|
+
this.#retryTimer = void 0;
|
|
9281
|
+
if (this.#state === "waiting") this.#open();
|
|
9282
|
+
}, delay);
|
|
9283
|
+
};
|
|
9284
|
+
if (!session) return schedule();
|
|
9285
|
+
this.#retryTimer = setTimeout(schedule, this.#timeoutMs);
|
|
9286
|
+
safely(() => {
|
|
9287
|
+
void this.#config.events.sessionRefused().then(schedule, schedule);
|
|
9288
|
+
});
|
|
9289
|
+
}
|
|
9290
|
+
/**
|
|
9291
|
+
* Stopped until a connect(), which waits as after a drop: the stop counts
|
|
9292
|
+
* as one (`unopened` when no socket was made — the runtime cannot open).
|
|
9293
|
+
*/
|
|
9294
|
+
#stop(error, unopened = false) {
|
|
9295
|
+
this.#clearTimers();
|
|
9296
|
+
this.#state = "stopped";
|
|
9297
|
+
this.#failReplies(error);
|
|
9298
|
+
if (unopened) this.#unopened += 1;
|
|
9299
|
+
this.#resumeAt = now() + delayAfter(
|
|
9300
|
+
{ kind: "retry", minMs: 0 },
|
|
9301
|
+
{ attempt: this.#attempt, unopened: this.#unopened },
|
|
9302
|
+
Math.random
|
|
9303
|
+
);
|
|
9304
|
+
this.#attempt += 1;
|
|
9305
|
+
safely(() => this.#config.events.stopped(error));
|
|
9306
|
+
}
|
|
9307
|
+
/** Every request still waiting hears `error`, as lost with the connection. */
|
|
9308
|
+
#failReplies(error) {
|
|
9309
|
+
const waiting = [...this.#replies.values()];
|
|
9310
|
+
this.#replies.clear();
|
|
9311
|
+
for (const waiter of waiting) {
|
|
9312
|
+
clearTimeout(waiter.timer);
|
|
9313
|
+
waiter.resolve({ ok: false, error, lost: true });
|
|
9314
|
+
}
|
|
9315
|
+
}
|
|
9316
|
+
/** Every timer of the connection, and the frames still to go with it. */
|
|
9317
|
+
#clearTimers() {
|
|
9318
|
+
clearTimeout(this.#retryTimer);
|
|
9319
|
+
clearTimeout(this.#openTimer);
|
|
9320
|
+
clearInterval(this.#heartbeat);
|
|
9321
|
+
clearTimeout(this.#paceTimer);
|
|
9322
|
+
clearTimeout(this.#roomTimer);
|
|
9323
|
+
this.#retryTimer = void 0;
|
|
9324
|
+
this.#openTimer = void 0;
|
|
9325
|
+
this.#heartbeat = void 0;
|
|
9326
|
+
this.#paceTimer = void 0;
|
|
9327
|
+
this.#roomTimer = void 0;
|
|
9328
|
+
this.#queue = [];
|
|
9329
|
+
this.#ahead = 0;
|
|
9330
|
+
this.#appQueued = 0;
|
|
9331
|
+
this.#roomWaiters.clear();
|
|
9332
|
+
}
|
|
9333
|
+
};
|
|
9334
|
+
function sent(reply) {
|
|
9335
|
+
return reply.ok ? { data: null, error: null, lost: false } : { data: null, error: reply.error, lost: reply.lost };
|
|
9336
|
+
}
|
|
9337
|
+
function unsent(error) {
|
|
9338
|
+
return { data: null, error, lost: false };
|
|
9339
|
+
}
|
|
9340
|
+
function jsonObject(value) {
|
|
9341
|
+
try {
|
|
9342
|
+
const copy = JSON.parse(JSON.stringify(value));
|
|
9343
|
+
return isRecord(copy) ? copy : null;
|
|
9344
|
+
} catch {
|
|
9345
|
+
return null;
|
|
9346
|
+
}
|
|
9347
|
+
}
|
|
9348
|
+
function presenceReadDenied(response) {
|
|
9349
|
+
const { access } = response;
|
|
9350
|
+
return isRecord(access) && isRecord(access.presence) && access.presence.read === false;
|
|
9351
|
+
}
|
|
9352
|
+
function tablesOf(changes) {
|
|
9353
|
+
return changes.map((binding) => binding.filter.table ?? "*");
|
|
9354
|
+
}
|
|
9355
|
+
var JOIN_RETRY_CODES = /* @__PURE__ */ new Set([
|
|
9356
|
+
"TOO_MANY_JOINS",
|
|
9357
|
+
"INTERNAL_ERROR",
|
|
9358
|
+
"TIMED_OUT"
|
|
9359
|
+
]);
|
|
9360
|
+
var RETRACK_RETRY_CODES = /* @__PURE__ */ new Set([
|
|
9361
|
+
"TOO_MANY_PRESENCE_MESSAGES",
|
|
9362
|
+
"TOO_MANY_MESSAGES",
|
|
9363
|
+
"TOO_MANY_JOINS",
|
|
9364
|
+
"INTERNAL_ERROR",
|
|
9365
|
+
"TIMED_OUT"
|
|
9366
|
+
]);
|
|
9367
|
+
var RETRACK_WARNED_RESENDS = 3;
|
|
9368
|
+
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.";
|
|
9369
|
+
function isDevelopment() {
|
|
9370
|
+
const env = globalThis.process?.env;
|
|
9371
|
+
return env?.NODE_ENV !== "production";
|
|
9372
|
+
}
|
|
9373
|
+
function warnInDevelopment(message) {
|
|
9374
|
+
if (isDevelopment()) console.warn(message);
|
|
9375
|
+
}
|
|
9376
|
+
function isJson(value) {
|
|
9377
|
+
try {
|
|
9378
|
+
JSON.stringify(value);
|
|
9379
|
+
return true;
|
|
9380
|
+
} catch {
|
|
9381
|
+
return false;
|
|
9382
|
+
}
|
|
9383
|
+
}
|
|
9384
|
+
function changeSpec(filter) {
|
|
9385
|
+
const {
|
|
9386
|
+
event,
|
|
9387
|
+
schema,
|
|
9388
|
+
table: table3,
|
|
9389
|
+
filter: text,
|
|
9390
|
+
...rest
|
|
9391
|
+
} = filter;
|
|
9392
|
+
return {
|
|
9393
|
+
...rest,
|
|
9394
|
+
event,
|
|
9395
|
+
...schema === void 0 ? {} : { schema },
|
|
9396
|
+
...table3 === void 0 ? {} : { table: table3 },
|
|
9397
|
+
...text === void 0 || text === "" ? {} : { filter: text }
|
|
9398
|
+
};
|
|
9399
|
+
}
|
|
9400
|
+
function changePayload(data) {
|
|
9401
|
+
return {
|
|
9402
|
+
schema: "public",
|
|
9403
|
+
table: typeof data.table === "string" ? data.table : "",
|
|
9404
|
+
commit_timestamp: typeof data.commit_timestamp === "string" ? data.commit_timestamp : "",
|
|
9405
|
+
eventType: data.type,
|
|
9406
|
+
new: isRecord(data.record) ? data.record : {},
|
|
9407
|
+
old: isRecord(data.old_record) ? data.old_record : {},
|
|
9408
|
+
errors: Array.isArray(data.errors) ? data.errors.filter((item) => typeof item === "string") : null
|
|
9409
|
+
};
|
|
9410
|
+
}
|
|
9411
|
+
function systemMessage(payload) {
|
|
9412
|
+
const { extension, code, message, channel } = payload;
|
|
9413
|
+
return {
|
|
9414
|
+
status: payload.status === "ok" ? "ok" : "error",
|
|
9415
|
+
extension: typeof extension === "string" ? extension : "system",
|
|
9416
|
+
code: typeof code === "string" ? code : "",
|
|
9417
|
+
message: typeof message === "string" ? message : "",
|
|
9418
|
+
channel: typeof channel === "string" ? channel : null
|
|
9419
|
+
};
|
|
9420
|
+
}
|
|
9421
|
+
var RealtimeChannel = class {
|
|
9422
|
+
name;
|
|
9423
|
+
topic;
|
|
9424
|
+
isPrivate;
|
|
9425
|
+
options;
|
|
9426
|
+
#context;
|
|
9427
|
+
#bindings = [];
|
|
9428
|
+
#presence = new Presence();
|
|
9429
|
+
#state = "closed";
|
|
9430
|
+
/** subscribe() was called and unsubscribe() was not: join it on every connection. */
|
|
9431
|
+
#wanted = false;
|
|
9432
|
+
/** The join sent and not left or replaced since — set when it is sent, before its answer. */
|
|
9433
|
+
#joinRef = null;
|
|
9434
|
+
/** The tables that join's row subscriptions are on (`*` for every table): its leave holds them. */
|
|
9435
|
+
#joinTables = [];
|
|
9436
|
+
/**
|
|
9437
|
+
* The join was refused a cap a leave owed on the connection still holds
|
|
9438
|
+
* a place under (`ChannelContext.owesLeaveOn`): it waits for that leave
|
|
9439
|
+
* rather than knock again (PR #328 review). Null otherwise.
|
|
9440
|
+
*/
|
|
9441
|
+
#capWait = null;
|
|
9442
|
+
#onStatus = null;
|
|
9443
|
+
/** The app already heard of this trouble; reset at SUBSCRIBED. */
|
|
9444
|
+
#told = false;
|
|
9445
|
+
#attempt = 0;
|
|
9446
|
+
#retry;
|
|
9447
|
+
/** The `system` error sent before a `phx_close` (protocol.ts `closeChannel`). */
|
|
9448
|
+
#closeError = null;
|
|
9449
|
+
/** The last state tracked, and the token the hub had then (결정 17). */
|
|
9450
|
+
#tracked = null;
|
|
9451
|
+
/** Bumped when what is tracked is let go of: a track answered after that does not bring it back. */
|
|
9452
|
+
#letGo = 0;
|
|
9453
|
+
/** Bumped by the app's track() and untrack(): one in the SUBSCRIBED callback says what the join holds. */
|
|
9454
|
+
#presenceCalls = 0;
|
|
9455
|
+
/** A track sent again and refused for now goes again when this is due (`#retrack`). */
|
|
9456
|
+
#retrackTimer;
|
|
9457
|
+
/**
|
|
9458
|
+
* The app's presence calls and broadcasts waiting for the connection to
|
|
9459
|
+
* be live, in the order it made them (`#inTurn`): each sends, or answers
|
|
9460
|
+
* that its join went, when called.
|
|
9461
|
+
*/
|
|
9462
|
+
#waiting = [];
|
|
9463
|
+
/** Inside subscribe(): a stop is told a microtask later (`#toldStop`). */
|
|
9464
|
+
#subscribing = false;
|
|
9465
|
+
/** Who waits for room on the connection (`whenRoom()`): this channel's join, and its track sent again. */
|
|
9466
|
+
#joinTurn = {};
|
|
9467
|
+
#retrackTurn = {};
|
|
9468
|
+
/**
|
|
9469
|
+
* Frames of the join in flight that came before its answer was handled
|
|
9470
|
+
* (final review A M1, plan 정정 (m)): Node's `ws` and undici hand over
|
|
9471
|
+
* the frames of one read in one callback, while the answer's handler runs
|
|
9472
|
+
* a microtask later — a change right behind the `ok` had no subscription
|
|
9473
|
+
* id yet. They are heard once the `ok` is (after SUBSCRIBED, as in a
|
|
9474
|
+
* browser, which runs a task per message), and dropped with a refusal.
|
|
9475
|
+
*/
|
|
9476
|
+
#early = [];
|
|
9477
|
+
/** @internal `naro.channel()` makes channels. */
|
|
9478
|
+
constructor(name, options, context) {
|
|
9479
|
+
this.name = name;
|
|
9480
|
+
this.topic = `${REALTIME_TOPIC_PREFIX}${name}`;
|
|
9481
|
+
this.isPrivate = options.config?.private === true;
|
|
9482
|
+
this.options = options;
|
|
9483
|
+
this.#context = context;
|
|
9484
|
+
}
|
|
9485
|
+
get state() {
|
|
9486
|
+
return this.#state;
|
|
9487
|
+
}
|
|
9488
|
+
/** @internal The join sent and not left or replaced since (client.ts routes by it). */
|
|
9489
|
+
get joinRef() {
|
|
9490
|
+
return this.#joinRef;
|
|
9491
|
+
}
|
|
9492
|
+
on(type, filter, callback) {
|
|
9493
|
+
const listener = callback;
|
|
9494
|
+
const spec = isRecord(filter) ? filter : {};
|
|
9495
|
+
if ((type === "postgres_changes" || type === "presence") && this.#wanted)
|
|
9496
|
+
warnInDevelopment(LATE_LISTENER);
|
|
9497
|
+
if (type === "postgres_changes")
|
|
9498
|
+
this.#bindings.push({
|
|
9499
|
+
type,
|
|
9500
|
+
filter: spec,
|
|
9501
|
+
callback: listener,
|
|
9502
|
+
id: null
|
|
9503
|
+
});
|
|
9504
|
+
else if (type === "broadcast" || type === "presence")
|
|
9505
|
+
this.#bindings.push({
|
|
9506
|
+
type,
|
|
9507
|
+
event: String(spec.event),
|
|
9508
|
+
callback: listener
|
|
9509
|
+
});
|
|
9510
|
+
else if (type === "system")
|
|
9511
|
+
this.#bindings.push({ type, callback: listener });
|
|
9512
|
+
return this;
|
|
9513
|
+
}
|
|
9514
|
+
subscribe(callback) {
|
|
9515
|
+
if (callback !== void 0) this.#onStatus = callback;
|
|
9516
|
+
if (this.#wanted) return this;
|
|
9517
|
+
const problem = this.#nameError();
|
|
9518
|
+
if (problem !== null) {
|
|
9519
|
+
this.#state = "errored";
|
|
9520
|
+
queueMicrotask(() => this.#status("CHANNEL_ERROR", problem));
|
|
9521
|
+
return this;
|
|
9522
|
+
}
|
|
9523
|
+
this.#wanted = true;
|
|
9524
|
+
this.#state = "joining";
|
|
9525
|
+
this.#attempt = 0;
|
|
9526
|
+
this.#told = false;
|
|
9527
|
+
this.#capWait = null;
|
|
9528
|
+
this.#clearRetry();
|
|
9529
|
+
this.#subscribing = true;
|
|
9530
|
+
try {
|
|
9531
|
+
this.#context.connect();
|
|
9532
|
+
this.#joinIfDue();
|
|
9533
|
+
} finally {
|
|
9534
|
+
this.#subscribing = false;
|
|
9535
|
+
}
|
|
9536
|
+
return this;
|
|
9537
|
+
}
|
|
9538
|
+
async unsubscribe() {
|
|
9539
|
+
const ref = this.#joinRef;
|
|
9540
|
+
const held = this.#state === "joined" || this.#state === "joining";
|
|
9541
|
+
this.#wanted = false;
|
|
9542
|
+
this.#clearRetry();
|
|
9543
|
+
this.#letGoOfTrack();
|
|
9544
|
+
this.#joinRef = null;
|
|
9545
|
+
this.#state = "leaving";
|
|
9546
|
+
this.#releaseWaiting();
|
|
9547
|
+
let error = null;
|
|
9548
|
+
if (held && ref !== null) {
|
|
9549
|
+
const reply = await this.#context.leave(this, ref, this.#joinTables);
|
|
9550
|
+
if (!reply.ok && !reply.lost && reply.error.code !== "NOT_JOINED")
|
|
9551
|
+
error = reply.error;
|
|
9552
|
+
}
|
|
9553
|
+
if (this.#state === "leaving") {
|
|
9554
|
+
this.#state = "closed";
|
|
9555
|
+
this.#presence.clear();
|
|
9556
|
+
this.#status("CLOSED");
|
|
9557
|
+
}
|
|
9558
|
+
return { data: null, error };
|
|
9559
|
+
}
|
|
9560
|
+
/**
|
|
9561
|
+
* Broadcasts `message` on the join, or over HTTP before it: the server's
|
|
9562
|
+
* answer, `lost` when the connection went before it (RealtimeSendResult).
|
|
9563
|
+
*/
|
|
9564
|
+
async send(message) {
|
|
9565
|
+
if (!isRecord(message) || message.type !== "broadcast")
|
|
9566
|
+
return unsent(invalidBroadcast());
|
|
9567
|
+
const problem = this.#nameError();
|
|
9568
|
+
if (problem !== null) return unsent(problem);
|
|
9569
|
+
const { event, payload } = message;
|
|
9570
|
+
if (!isJson({ event, payload }))
|
|
9571
|
+
return unsent(invalidQuery("", "is not JSON"));
|
|
9572
|
+
const joinRef = this.#joinRef;
|
|
9573
|
+
if (this.#state === "joined" && joinRef !== null) {
|
|
9574
|
+
const reply = await this.#inTurn(
|
|
9575
|
+
joinRef,
|
|
9576
|
+
!this.#context.live(),
|
|
9577
|
+
() => this.#context.socket.request(
|
|
9578
|
+
this.topic,
|
|
9579
|
+
"broadcast",
|
|
9580
|
+
{ type: "broadcast", event, payload },
|
|
9581
|
+
{ joinRef }
|
|
9582
|
+
)
|
|
9583
|
+
);
|
|
9584
|
+
if (reply !== null) return sent(reply);
|
|
9585
|
+
}
|
|
9586
|
+
await this.#context.auth.ready();
|
|
9587
|
+
const result = await call(this.#context.http, {
|
|
9588
|
+
method: "POST",
|
|
9589
|
+
path: "/realtime/broadcast",
|
|
9590
|
+
body: {
|
|
9591
|
+
messages: [
|
|
9592
|
+
{ topic: this.topic, event, payload, private: this.isPrivate }
|
|
9593
|
+
]
|
|
9594
|
+
},
|
|
9595
|
+
withToken: true
|
|
9596
|
+
});
|
|
9597
|
+
return unsent(result.error);
|
|
9598
|
+
}
|
|
9599
|
+
/**
|
|
9600
|
+
* Tracks `state` as this connection's presence here (original spec
|
|
9601
|
+
* §11.1): the hub's answer. Only on a join — before it, NOT_JOINED, and
|
|
9602
|
+
* nothing is kept for later (결정 18). It goes as its JSON reads back, and
|
|
9603
|
+
* that copy, not the app's object, is what a later join sends again.
|
|
9604
|
+
* While who the connection is is not settled (a new token in flight, a
|
|
9605
|
+
* retry), it waits until it is (정정 (k)): the hub may yet refuse the new
|
|
9606
|
+
* token for now and keep the last user, whose presence it would be.
|
|
9607
|
+
*/
|
|
9608
|
+
async track(state) {
|
|
9609
|
+
const problem = this.#nameError();
|
|
9610
|
+
if (problem !== null) return unsent(problem);
|
|
9611
|
+
const joinRef = this.#joinRef;
|
|
9612
|
+
if (this.#state !== "joined" || joinRef === null)
|
|
9613
|
+
return unsent(notJoined(this.name));
|
|
9614
|
+
const payload = jsonObject(state);
|
|
9615
|
+
if (payload === null) return unsent(invalidPresence());
|
|
9616
|
+
this.#presenceCalls += 1;
|
|
9617
|
+
this.#clearRetrack();
|
|
9618
|
+
const letGo = this.#letGo;
|
|
9619
|
+
const reply = await this.#inTurn(
|
|
9620
|
+
joinRef,
|
|
9621
|
+
!this.#context.live(),
|
|
9622
|
+
() => this.#sendTrack(payload, joinRef, letGo, true)
|
|
9623
|
+
);
|
|
9624
|
+
if (reply === null) {
|
|
9625
|
+
if (letGo === this.#letGo) this.#letGoOfTrack();
|
|
9626
|
+
return unsent(notJoined(this.name));
|
|
9627
|
+
}
|
|
9628
|
+
return sent(reply);
|
|
9629
|
+
}
|
|
9630
|
+
/**
|
|
9631
|
+
* Takes this connection's presence off the channel: the hub's answer, or
|
|
9632
|
+
* none when not joined. After the app's tracks that wait, if any.
|
|
9633
|
+
*/
|
|
9634
|
+
async untrack() {
|
|
9635
|
+
this.#presenceCalls += 1;
|
|
9636
|
+
this.#letGoOfTrack();
|
|
9637
|
+
const joinRef = this.#joinRef;
|
|
9638
|
+
if (this.#state !== "joined" || joinRef === null) return unsent(null);
|
|
9639
|
+
const reply = await this.#inTurn(
|
|
9640
|
+
joinRef,
|
|
9641
|
+
false,
|
|
9642
|
+
() => this.#context.socket.request(
|
|
9643
|
+
this.topic,
|
|
9644
|
+
"presence",
|
|
9645
|
+
{ type: "presence", event: "untrack" },
|
|
9646
|
+
{ joinRef }
|
|
9647
|
+
)
|
|
9648
|
+
);
|
|
9649
|
+
return reply === null ? unsent(null) : sent(reply);
|
|
9650
|
+
}
|
|
9651
|
+
/** The presences of the channel, by key (결정 16): a copy. */
|
|
9652
|
+
presenceState() {
|
|
9653
|
+
return this.#presence.state();
|
|
9654
|
+
}
|
|
9655
|
+
/**
|
|
9656
|
+
* @internal
|
|
9657
|
+
* The connection is live, or a leave owed is done (client.ts): join if
|
|
9658
|
+
* the app wants it and the channel holds no join and waits for no retry.
|
|
9659
|
+
* Called whenever either happens, for every channel, so a channel joined
|
|
9660
|
+
* or joining stays as it is.
|
|
9661
|
+
*/
|
|
9662
|
+
rejoin() {
|
|
9663
|
+
if (this.#context.live()) this.#releaseWaiting();
|
|
9664
|
+
this.#joinIfDue();
|
|
9665
|
+
}
|
|
9666
|
+
/**
|
|
9667
|
+
* @internal
|
|
9668
|
+
* A leave of this name and privacy owed on the connection was refused, or
|
|
9669
|
+
* not answered in time, and goes again (client.ts): a join waiting on it
|
|
9670
|
+
* tells the app once a wait, as a refused join tried again does (결정 14,
|
|
9671
|
+
* 정정 (j)), and waits on.
|
|
9672
|
+
*/
|
|
9673
|
+
leaveRefused(error) {
|
|
9674
|
+
if (this.#wanted && this.#joinRef === null)
|
|
9675
|
+
this.#tell(
|
|
9676
|
+
error.code === "TIMED_OUT" ? "TIMED_OUT" : "CHANNEL_ERROR",
|
|
9677
|
+
error
|
|
9678
|
+
);
|
|
9679
|
+
}
|
|
9680
|
+
/** @internal The connection dropped and comes back: tell the app once, rejoin when live. */
|
|
9681
|
+
dropped(error, status) {
|
|
9682
|
+
if (!this.#wanted) return;
|
|
9683
|
+
this.#clearRetry();
|
|
9684
|
+
this.#capWait = null;
|
|
9685
|
+
this.#joinRef = null;
|
|
9686
|
+
this.#state = "joining";
|
|
9687
|
+
this.#releaseWaiting();
|
|
9688
|
+
this.#presence.unsync();
|
|
9689
|
+
this.#tell(status, error);
|
|
9690
|
+
}
|
|
9691
|
+
/** @internal The connection stopped for good: the app must subscribe() again. */
|
|
9692
|
+
stopped(error) {
|
|
9693
|
+
if (this.#wanted) this.#stop(error);
|
|
9694
|
+
}
|
|
9695
|
+
/** @internal A frame of this channel's join (client.ts routes by join_ref). */
|
|
9696
|
+
receive(frame) {
|
|
9697
|
+
if (this.#state === "joining") {
|
|
9698
|
+
this.#early.push(frame);
|
|
9699
|
+
return;
|
|
9700
|
+
}
|
|
9701
|
+
switch (frame.event) {
|
|
9702
|
+
case "postgres_changes":
|
|
9703
|
+
return this.#change(frame.payload);
|
|
9704
|
+
case "broadcast":
|
|
9705
|
+
return this.#broadcast(frame.payload);
|
|
9706
|
+
case "presence_state":
|
|
9707
|
+
return this.#presence.applyState(frame.payload, this.#presenceEmit);
|
|
9708
|
+
case "presence_diff":
|
|
9709
|
+
return this.#presence.applyDiff(frame.payload, this.#presenceEmit);
|
|
9710
|
+
case "system":
|
|
9711
|
+
return this.#system(frame.payload);
|
|
9712
|
+
case "phx_close":
|
|
9713
|
+
return this.#serverClosed();
|
|
9714
|
+
default:
|
|
9715
|
+
return;
|
|
9716
|
+
}
|
|
9717
|
+
}
|
|
9718
|
+
/**
|
|
9719
|
+
* INVALID_CHANNEL for a name the hub would refuse, before anything is
|
|
9720
|
+
* sent — and before any error of ours quotes the name.
|
|
9721
|
+
*/
|
|
9722
|
+
#nameError() {
|
|
9723
|
+
const problem = channelNameProblem(this.name);
|
|
9724
|
+
return problem === void 0 ? null : invalidChannel(problem);
|
|
9725
|
+
}
|
|
9726
|
+
#config(changes) {
|
|
9727
|
+
const config = this.options.config ?? {};
|
|
9728
|
+
const presence = config.presence ?? {};
|
|
9729
|
+
const key = presence.key ?? "";
|
|
9730
|
+
const listens = this.#bindings.some(
|
|
9731
|
+
(binding) => binding.type === "presence"
|
|
9732
|
+
);
|
|
9733
|
+
return {
|
|
9734
|
+
...config,
|
|
9735
|
+
// ack is always on the wire, so send() always ends in the hub's answer (결정 6).
|
|
9736
|
+
broadcast: {
|
|
9737
|
+
...config.broadcast,
|
|
9738
|
+
self: config.broadcast?.self ?? false,
|
|
9739
|
+
ack: true
|
|
9740
|
+
},
|
|
9741
|
+
presence: {
|
|
9742
|
+
...presence,
|
|
9743
|
+
key,
|
|
9744
|
+
enabled: listens || presence.enabled === true || key !== ""
|
|
9745
|
+
},
|
|
9746
|
+
postgres_changes: changes.map((binding) => changeSpec(binding.filter)),
|
|
9747
|
+
private: this.isPrivate
|
|
9748
|
+
};
|
|
9749
|
+
}
|
|
9750
|
+
/**
|
|
9751
|
+
* Joins when due — once there is room on the connection for a frame the
|
|
9752
|
+
* SDK sends of its own accord (`whenRoom()`, PR #328 review), and still
|
|
9753
|
+
* due then.
|
|
9754
|
+
*/
|
|
9755
|
+
#joinIfDue() {
|
|
9756
|
+
if (!this.#dueToJoin()) return;
|
|
9757
|
+
this.#context.socket.whenRoom(this.#joinTurn, () => {
|
|
9758
|
+
if (this.#dueToJoin()) this.#join();
|
|
9759
|
+
});
|
|
9760
|
+
}
|
|
9761
|
+
#dueToJoin() {
|
|
9762
|
+
return this.#wanted && this.#joinRef === null && this.#retry === void 0 && this.#context.live() && !this.#context.owesLeave(this) && !this.#heldByCap();
|
|
9763
|
+
}
|
|
9764
|
+
/** A cap refused the last join, and a leave owed still holds the place it needs. */
|
|
9765
|
+
#heldByCap() {
|
|
9766
|
+
const wait = this.#capWait;
|
|
9767
|
+
if (wait === null) return false;
|
|
9768
|
+
if (this.#context.owesLeaveOn(wait.table)) return true;
|
|
9769
|
+
this.#capWait = null;
|
|
9770
|
+
return false;
|
|
9771
|
+
}
|
|
9772
|
+
/**
|
|
9773
|
+
* The cap `error` names, when a leave owed on the connection holds a place
|
|
9774
|
+
* under it (PR #328 review): the hub still counts that join — 100
|
|
9775
|
+
* channels a connection (TOO_MANY_CHANNELS), or 4 on one table's changes
|
|
9776
|
+
* (INVALID_CHANNEL_CONFIG with the hub's `changeChannels` reason at
|
|
9777
|
+
* `config.postgres_changes[<i>].table`, the table of `changes[i]`) — and
|
|
9778
|
+
* lets go of it once the leave goes through. Null for any other refusal.
|
|
9779
|
+
*/
|
|
9780
|
+
#capHeld(error, changes) {
|
|
9781
|
+
if (error.code === "TOO_MANY_CHANNELS")
|
|
9782
|
+
return this.#context.owesLeaveOn(null) ? { table: null } : null;
|
|
9783
|
+
if (error.code !== "INVALID_CHANNEL_CONFIG" || !isRecord(error.details))
|
|
9784
|
+
return null;
|
|
9785
|
+
const { path, reason } = error.details;
|
|
9786
|
+
if (reason !== CHANGE_CHANNELS_REASON || typeof path !== "string")
|
|
9787
|
+
return null;
|
|
9788
|
+
const index = /^config\.postgres_changes\[(\d+)\]\.table$/.exec(path)?.[1];
|
|
9789
|
+
const binding = index === void 0 ? void 0 : changes[Number(index)];
|
|
9790
|
+
if (binding === void 0) return null;
|
|
9791
|
+
const table3 = binding.filter.table ?? "*";
|
|
9792
|
+
return this.#context.owesLeaveOn(table3) ? { table: table3 } : null;
|
|
9793
|
+
}
|
|
9794
|
+
#join() {
|
|
9795
|
+
const changes = this.#bindings.filter(
|
|
9796
|
+
(b) => b.type === "postgres_changes"
|
|
9797
|
+
);
|
|
9798
|
+
const config = this.#config(changes);
|
|
9799
|
+
if (!isJson(config))
|
|
9800
|
+
return this.#stop(invalidChannelConfig("config", "is not JSON"));
|
|
9801
|
+
const socket = this.#context.socket;
|
|
9802
|
+
const ref = socket.nextRef();
|
|
9803
|
+
this.#joinRef = ref;
|
|
9804
|
+
this.#joinTables = tablesOf(changes);
|
|
9805
|
+
this.#capWait = null;
|
|
9806
|
+
this.#state = "joining";
|
|
9807
|
+
this.#closeError = null;
|
|
9808
|
+
this.#early = [];
|
|
9809
|
+
this.#presence.unsync();
|
|
9810
|
+
for (const binding of changes) binding.id = null;
|
|
9811
|
+
void socket.request(
|
|
9812
|
+
this.topic,
|
|
9813
|
+
"phx_join",
|
|
9814
|
+
{ config },
|
|
9815
|
+
{ joinRef: ref, ref, turn: "sdk" }
|
|
9816
|
+
).then((reply) => this.#joined(ref, changes, reply));
|
|
9817
|
+
}
|
|
9818
|
+
#joined(ref, changes, reply) {
|
|
9819
|
+
if (this.#joinRef !== ref) return;
|
|
9820
|
+
const early = this.#early;
|
|
9821
|
+
this.#early = [];
|
|
9822
|
+
if (reply.ok) {
|
|
9823
|
+
const ids = Array.isArray(reply.response.postgres_changes) ? reply.response.postgres_changes : [];
|
|
9824
|
+
changes.forEach((binding, index) => {
|
|
9825
|
+
const entry = ids[index];
|
|
9826
|
+
binding.id = isRecord(entry) && typeof entry.id === "number" ? entry.id : null;
|
|
9827
|
+
});
|
|
9828
|
+
this.#state = "joined";
|
|
9829
|
+
this.#attempt = 0;
|
|
9830
|
+
this.#told = false;
|
|
9831
|
+
this.#context.socket.served();
|
|
9832
|
+
const calls = this.#presenceCalls;
|
|
9833
|
+
this.#status("SUBSCRIBED");
|
|
9834
|
+
if (this.#presenceCalls === calls) this.#retrack(ref);
|
|
9835
|
+
if (this.#joinRef === ref && presenceReadDenied(reply.response))
|
|
9836
|
+
this.#presence.leaveAll(this.#presenceEmit);
|
|
9837
|
+
for (const frame of early) {
|
|
9838
|
+
if (this.#joinRef !== ref || this.#state !== "joined") break;
|
|
9839
|
+
this.receive(frame);
|
|
9840
|
+
}
|
|
9841
|
+
return;
|
|
9842
|
+
}
|
|
9843
|
+
if (reply.lost) {
|
|
9844
|
+
this.#joinRef = null;
|
|
9845
|
+
return;
|
|
9846
|
+
}
|
|
9847
|
+
const { error } = reply;
|
|
9848
|
+
this.#joinRef = null;
|
|
9849
|
+
const capHeld = this.#capHeld(error, changes);
|
|
9850
|
+
if (capHeld !== null) {
|
|
9851
|
+
this.#capWait = capHeld;
|
|
9852
|
+
this.#state = "joining";
|
|
9853
|
+
this.#tell("CHANNEL_ERROR", error);
|
|
9854
|
+
return;
|
|
9855
|
+
}
|
|
9856
|
+
const meetsOld = error.code === "ALREADY_JOINED" && this.#context.owesLeave(this);
|
|
9857
|
+
if (!JOIN_RETRY_CODES.has(error.code) && !meetsOld) {
|
|
9858
|
+
this.#wanted = false;
|
|
9859
|
+
this.#state = "errored";
|
|
9860
|
+
this.#presence.leaveAll(this.#presenceEmit);
|
|
9861
|
+
this.#status("CHANNEL_ERROR", error);
|
|
9862
|
+
return;
|
|
9863
|
+
}
|
|
9864
|
+
if (error.code === "TIMED_OUT")
|
|
9865
|
+
void this.#context.leave(this, ref, tablesOf(changes));
|
|
9866
|
+
this.#tell(
|
|
9867
|
+
error.code === "TIMED_OUT" ? "TIMED_OUT" : "CHANNEL_ERROR",
|
|
9868
|
+
error
|
|
9869
|
+
);
|
|
9870
|
+
this.#state = "joining";
|
|
9871
|
+
const delay = backoffMs(this.#attempt, Math.random);
|
|
9872
|
+
this.#attempt += 1;
|
|
9873
|
+
this.#retry = setTimeout(() => {
|
|
9874
|
+
this.#retry = void 0;
|
|
9875
|
+
this.#joinIfDue();
|
|
9876
|
+
}, delay);
|
|
9877
|
+
}
|
|
9878
|
+
#change(payload) {
|
|
9879
|
+
const ids = Array.isArray(payload.ids) ? payload.ids : [];
|
|
9880
|
+
if (!isRecord(payload.data)) return;
|
|
9881
|
+
const change2 = changePayload(payload.data);
|
|
9882
|
+
for (const binding of this.#bindings)
|
|
9883
|
+
if (binding.type === "postgres_changes" && binding.id !== null && ids.includes(binding.id))
|
|
9884
|
+
safely(() => binding.callback(change2));
|
|
9885
|
+
}
|
|
9886
|
+
#broadcast(payload) {
|
|
9887
|
+
const { event } = payload;
|
|
9888
|
+
if (typeof event !== "string") return;
|
|
9889
|
+
const message = {
|
|
9890
|
+
type: "broadcast",
|
|
9891
|
+
event,
|
|
9892
|
+
payload: isRecord(payload.payload) ? payload.payload : {}
|
|
9893
|
+
};
|
|
9894
|
+
for (const binding of this.#bindings)
|
|
9895
|
+
if (binding.type === "broadcast" && binding.event === event)
|
|
9896
|
+
safely(() => binding.callback(message));
|
|
9897
|
+
}
|
|
9898
|
+
#system(payload) {
|
|
9899
|
+
const message = systemMessage(payload);
|
|
9900
|
+
if (message.status === "error" && message.extension === "system")
|
|
9901
|
+
this.#closeError = asError(payload);
|
|
9902
|
+
for (const binding of this.#bindings)
|
|
9903
|
+
if (binding.type === "system") safely(() => binding.callback(message));
|
|
9904
|
+
}
|
|
9905
|
+
/** The hub closed this join (lost access, its state full): stop until subscribe(). */
|
|
9906
|
+
#serverClosed() {
|
|
9907
|
+
this.#clearRetry();
|
|
9908
|
+
this.#joinRef = null;
|
|
9909
|
+
this.#wanted = false;
|
|
9910
|
+
this.#state = "errored";
|
|
9911
|
+
this.#letGoOfTrack();
|
|
9912
|
+
this.#releaseWaiting();
|
|
9913
|
+
this.#presence.leaveAll(this.#presenceEmit);
|
|
9914
|
+
this.#status("CHANNEL_ERROR", this.#closeError ?? channelClosed());
|
|
9915
|
+
}
|
|
9916
|
+
/** The listeners' payloads, as supabase-js's: each says which event it is (정정 (k)). */
|
|
9917
|
+
#presenceEmit = {
|
|
9918
|
+
join: (key, newPresences, currentPresences) => {
|
|
9919
|
+
const payload = {
|
|
9920
|
+
event: "join",
|
|
9921
|
+
key,
|
|
9922
|
+
newPresences,
|
|
9923
|
+
currentPresences
|
|
9924
|
+
};
|
|
9925
|
+
this.#presenceCall("join", payload);
|
|
9926
|
+
},
|
|
9927
|
+
leave: (key, leftPresences, currentPresences) => {
|
|
9928
|
+
const payload = {
|
|
9929
|
+
event: "leave",
|
|
9930
|
+
key,
|
|
9931
|
+
leftPresences,
|
|
9932
|
+
currentPresences
|
|
9933
|
+
};
|
|
9934
|
+
this.#presenceCall("leave", payload);
|
|
9935
|
+
},
|
|
9936
|
+
sync: () => this.#presenceCall("sync", void 0)
|
|
9937
|
+
};
|
|
9938
|
+
#presenceCall(event, value) {
|
|
9939
|
+
for (const binding of this.#bindings)
|
|
9940
|
+
if (binding.type === "presence" && binding.event === event)
|
|
9941
|
+
safely(() => binding.callback(value));
|
|
9942
|
+
}
|
|
9943
|
+
/**
|
|
9944
|
+
* One track frame on the join `joinRef`. Its `ok` makes `state` the one
|
|
9945
|
+
* tracked, with the token the hub judged it for — the one it holds once
|
|
9946
|
+
* the identity frames sent before it are handled (`heldToken()`, 정정 (j))
|
|
9947
|
+
* — unless what was tracked was let go of since `letGo` (the app's call,
|
|
9948
|
+
* or now): the hub had that untrack or leave after it. The app's own call
|
|
9949
|
+
* (`app`) is kept as well when no answer came — the connection went with
|
|
9950
|
+
* it, or none in time: the hub may hold it, and it is the state the app
|
|
9951
|
+
* last asked for, so the next join sends it rather than the one before
|
|
9952
|
+
* (PR #328 review). The app's call waits its turn behind the app's frames,
|
|
9953
|
+
* and may be refused for it (CLIENT_FLOODING, nothing kept); one sent
|
|
9954
|
+
* again goes in the SDK's (PR #328 re-review).
|
|
9955
|
+
*/
|
|
9956
|
+
#sendTrack(state, joinRef, letGo = this.#letGo, app = false) {
|
|
9957
|
+
const token = this.#context.identity.heldToken();
|
|
9958
|
+
return this.#context.socket.request(
|
|
9959
|
+
this.topic,
|
|
9960
|
+
"presence",
|
|
9961
|
+
{ type: "presence", event: "track", payload: state },
|
|
9962
|
+
{ joinRef, turn: app ? "app" : "sdk" }
|
|
9963
|
+
).then((reply) => {
|
|
9964
|
+
const kept = reply.ok || app && (reply.lost || reply.error.code === "TIMED_OUT");
|
|
9965
|
+
if (kept && letGo === this.#letGo) this.#tracked = { state, token };
|
|
9966
|
+
return reply;
|
|
9967
|
+
});
|
|
9968
|
+
}
|
|
9969
|
+
/**
|
|
9970
|
+
* After a join comes back, the last state goes again (original spec §11.1)
|
|
9971
|
+
* — only as the same session: the hub drops a presence when its user
|
|
9972
|
+
* changes (3-2a P10), and a new token here may be someone else (결정 17).
|
|
9973
|
+
* Who the hub reads it as is the token it will hold when the frame comes
|
|
9974
|
+
* (`heldToken()`): a sign-in whose token went after the join would make
|
|
9975
|
+
* the last user's state the new one's. And while who the connection is
|
|
9976
|
+
* is not settled (a store read, a retry), it is let go of (정정 (j)).
|
|
9977
|
+
* A refusal for now (`RETRACK_RETRY_CODES`) sends it again on this join
|
|
9978
|
+
* after the channel backoff, checked as above each time, until the app
|
|
9979
|
+
* tracks or untracks, the channel leaves, or the join goes — after one
|
|
9980
|
+
* the hub warned the connection for, three times at most, and then it
|
|
9981
|
+
* waits for the next join (`WARNED_CODES`); any other refusal lets go of
|
|
9982
|
+
* it; a connection lost before the answer keeps it for the next join.
|
|
9983
|
+
* Each send, the first too, waits its turn for room in the connection's
|
|
9984
|
+
* warnings (`whenRoom()`, PR #328 review).
|
|
9985
|
+
*/
|
|
9986
|
+
#retrack(joinRef) {
|
|
9987
|
+
const tracked = this.#tracked;
|
|
9988
|
+
if (tracked === null) return;
|
|
9989
|
+
const calls = this.#presenceCalls;
|
|
9990
|
+
const current = () => this.#tracked === tracked && this.#joinRef === joinRef && this.#presenceCalls === calls;
|
|
9991
|
+
const go = (attempt22, warned) => {
|
|
9992
|
+
if (!current()) return;
|
|
9993
|
+
this.#context.socket.whenRoom(this.#retrackTurn, () => {
|
|
9994
|
+
send2(attempt22, warned);
|
|
9995
|
+
});
|
|
9996
|
+
};
|
|
9997
|
+
const send2 = (attempt22, warned) => {
|
|
9998
|
+
if (!current()) return;
|
|
9999
|
+
if (!this.#context.live() || tracked.token !== this.#context.identity.heldToken())
|
|
10000
|
+
return this.#letGoOfTrack();
|
|
10001
|
+
void this.#sendTrack(tracked.state, joinRef).then((reply) => {
|
|
10002
|
+
if (reply.ok || reply.lost || this.#tracked !== tracked) return;
|
|
10003
|
+
const { code } = reply.error;
|
|
10004
|
+
if (!RETRACK_RETRY_CODES.has(code)) {
|
|
10005
|
+
this.#tracked = null;
|
|
10006
|
+
return;
|
|
10007
|
+
}
|
|
10008
|
+
if (!current()) return;
|
|
10009
|
+
const warning = WARNED_CODES.has(code) ? 1 : 0;
|
|
10010
|
+
if (warned + warning > RETRACK_WARNED_RESENDS) return;
|
|
10011
|
+
const delay = backoffMs(attempt22, Math.random);
|
|
10012
|
+
this.#retrackTimer = setTimeout(() => {
|
|
10013
|
+
this.#retrackTimer = void 0;
|
|
10014
|
+
go(attempt22 + 1, warned + warning);
|
|
10015
|
+
}, delay);
|
|
10016
|
+
});
|
|
10017
|
+
};
|
|
10018
|
+
go(0, 0);
|
|
10019
|
+
}
|
|
10020
|
+
/**
|
|
10021
|
+
* `send` now — or, when `wait` (a track or a broadcast while the
|
|
10022
|
+
* connection is not live) or an earlier call of the app's waits, at
|
|
10023
|
+
* `#releaseWaiting()`, in the order the app made them: the hub must have
|
|
10024
|
+
* them in that order. Null when the join went before its turn: nothing
|
|
10025
|
+
* was sent.
|
|
10026
|
+
*/
|
|
10027
|
+
#inTurn(joinRef, wait, send2) {
|
|
10028
|
+
if (!wait && this.#waiting.length === 0) return send2();
|
|
10029
|
+
return new Promise((resolve7) => {
|
|
10030
|
+
this.#waiting.push(
|
|
10031
|
+
() => resolve7(this.#joinRef === joinRef ? send2() : null)
|
|
10032
|
+
);
|
|
10033
|
+
});
|
|
10034
|
+
}
|
|
10035
|
+
/**
|
|
10036
|
+
* The calls that waited go now, in order and all in this turn — a call
|
|
10037
|
+
* made after them must not go first. Called when the connection is live
|
|
10038
|
+
* again, and when the join goes (they answer that it went).
|
|
10039
|
+
*/
|
|
10040
|
+
#releaseWaiting() {
|
|
10041
|
+
const waiting = this.#waiting;
|
|
10042
|
+
this.#waiting = [];
|
|
10043
|
+
for (const go of waiting) go();
|
|
10044
|
+
}
|
|
10045
|
+
/** Nothing is tracked from now on, whatever answer comes for a track sent before. */
|
|
10046
|
+
#letGoOfTrack() {
|
|
10047
|
+
this.#tracked = null;
|
|
10048
|
+
this.#letGo += 1;
|
|
10049
|
+
this.#clearRetrack();
|
|
10050
|
+
}
|
|
10051
|
+
#clearRetrack() {
|
|
10052
|
+
clearTimeout(this.#retrackTimer);
|
|
10053
|
+
this.#retrackTimer = void 0;
|
|
10054
|
+
}
|
|
10055
|
+
/**
|
|
10056
|
+
* Stops until subscribe(): no join, no retry, and the app hears `error`.
|
|
10057
|
+
* No presence_state comes until then, so what the app saw goes, as for a
|
|
10058
|
+
* join refused or the server's close (정정 (k)); what was tracked stays,
|
|
10059
|
+
* for a join of the same session after subscribe().
|
|
10060
|
+
*/
|
|
10061
|
+
#stop(error) {
|
|
10062
|
+
this.#clearRetry();
|
|
10063
|
+
this.#joinRef = null;
|
|
10064
|
+
this.#wanted = false;
|
|
10065
|
+
this.#state = "errored";
|
|
10066
|
+
this.#releaseWaiting();
|
|
10067
|
+
this.#presence.leaveAll(this.#presenceEmit);
|
|
10068
|
+
this.#toldStop(error);
|
|
10069
|
+
}
|
|
10070
|
+
/**
|
|
10071
|
+
* CHANNEL_ERROR for a stop. Inside subscribe() it comes a microtask later,
|
|
10072
|
+
* as for an invalid name: in `const ch = naro.channel(…).subscribe(cb)`,
|
|
10073
|
+
* `cb` may read `ch`, which is not set until subscribe() returns.
|
|
10074
|
+
*/
|
|
10075
|
+
#toldStop(error) {
|
|
10076
|
+
if (this.#subscribing)
|
|
10077
|
+
queueMicrotask(() => this.#status("CHANNEL_ERROR", error));
|
|
10078
|
+
else this.#status("CHANNEL_ERROR", error);
|
|
10079
|
+
}
|
|
10080
|
+
#tell(status, error) {
|
|
10081
|
+
if (this.#told) return;
|
|
10082
|
+
this.#told = true;
|
|
10083
|
+
this.#status(status, error);
|
|
10084
|
+
}
|
|
10085
|
+
#status(status, error) {
|
|
10086
|
+
const callback = this.#onStatus;
|
|
10087
|
+
if (callback !== null) safely(() => callback(status, error));
|
|
10088
|
+
}
|
|
10089
|
+
/** No join retry waits — nor a track sent again on a join that is going. */
|
|
10090
|
+
#clearRetry() {
|
|
10091
|
+
clearTimeout(this.#retry);
|
|
10092
|
+
this.#retry = void 0;
|
|
10093
|
+
this.#clearRetrack();
|
|
10094
|
+
}
|
|
10095
|
+
};
|
|
10096
|
+
var RETRY_CODES = /* @__PURE__ */ new Set([
|
|
10097
|
+
"TOO_MANY_JOINS",
|
|
10098
|
+
"TIMED_OUT",
|
|
10099
|
+
"INTERNAL_ERROR"
|
|
10100
|
+
]);
|
|
10101
|
+
function rejudgeWaitMs(privateChannels) {
|
|
10102
|
+
return Math.max(1, Math.ceil(privateChannels / 2)) * 1e3;
|
|
10103
|
+
}
|
|
10104
|
+
var Identity = class {
|
|
10105
|
+
#config;
|
|
10106
|
+
/** The token the hub answered `ok` for on this connection; null is anon. */
|
|
10107
|
+
#confirmed = null;
|
|
10108
|
+
/**
|
|
10109
|
+
* The last frame sent, until its own reply; null when none is in flight.
|
|
10110
|
+
* Matched by the object, not the token: an older frame of the same token
|
|
10111
|
+
* answered meanwhile is not this one.
|
|
10112
|
+
*/
|
|
10113
|
+
#inFlight = null;
|
|
10114
|
+
/**
|
|
10115
|
+
* The frame the hub answered INVALID_SESSION: the 4401 that follows is
|
|
10116
|
+
* about its token. An object, since that token is null when the frame was
|
|
10117
|
+
* a sign-out — a refusal still, and not "nothing refused".
|
|
10118
|
+
*/
|
|
10119
|
+
#refused = null;
|
|
10120
|
+
/**
|
|
10121
|
+
* A frame got no answer in time (TIMED_OUT), and no `ok` came since: the
|
|
10122
|
+
* hub may have taken it late, so what it holds is not known, and a read
|
|
10123
|
+
* with no frame in flight tells the stored token even when it is
|
|
10124
|
+
* `heldToken()` (정정 (k)). An `ok` says what it holds — replies come in
|
|
10125
|
+
* the order the frames went, so it is about a later frame.
|
|
10126
|
+
*/
|
|
10127
|
+
#unsure = false;
|
|
10128
|
+
/** Bumped when a connection ends: a reply about an earlier one changes nothing. */
|
|
10129
|
+
#epoch = 0;
|
|
10130
|
+
#attempt = 0;
|
|
10131
|
+
#retry;
|
|
10132
|
+
/** The socket's generation opened() was called for; null once it ended. */
|
|
10133
|
+
#connection = null;
|
|
10134
|
+
/** changed() calls of this connection still reading the store. */
|
|
10135
|
+
#reading = 0;
|
|
10136
|
+
/** onLive was called, and the connection has been live since. */
|
|
10137
|
+
#announced = false;
|
|
10138
|
+
constructor(config) {
|
|
10139
|
+
this.#config = config;
|
|
10140
|
+
}
|
|
10141
|
+
/** The token the hub took on this connection; null for anon or before its answer. */
|
|
10142
|
+
confirmed() {
|
|
10143
|
+
return this.#confirmed;
|
|
10144
|
+
}
|
|
10145
|
+
/**
|
|
10146
|
+
* The token the hub will hold once what was sent is handled — the last
|
|
10147
|
+
* frame in flight, or else the one it took: the hub handles the
|
|
10148
|
+
* connection's frames in order, so a frame sent now is judged for it.
|
|
10149
|
+
* A frame in flight may yet be refused; the hub then keeps `confirmed()`.
|
|
10150
|
+
*/
|
|
10151
|
+
heldToken() {
|
|
10152
|
+
return this.#inFlight === null ? this.#confirmed : this.#inFlight.token;
|
|
10153
|
+
}
|
|
10154
|
+
/**
|
|
10155
|
+
* Nothing about who the connection is is pending — no read, no frame in
|
|
10156
|
+
* flight, no retry, no refusal: a join sent now is judged for the identity
|
|
10157
|
+
* the hub settled on, the stored one unless it refused that for good on
|
|
10158
|
+
* this connection (결정 24).
|
|
10159
|
+
*/
|
|
10160
|
+
isLive() {
|
|
10161
|
+
const socket = this.#config.socket();
|
|
10162
|
+
return this.#connection === socket.generation && socket.isOpen() && this.#reading === 0 && this.#inFlight === null && this.#retry === void 0 && this.#refused === null;
|
|
10163
|
+
}
|
|
10164
|
+
/**
|
|
10165
|
+
* A new connection: anon until told. Resolves once the stored token is
|
|
10166
|
+
* sent, or found to need no frame; `onLive` says when the hub took it.
|
|
10167
|
+
*/
|
|
10168
|
+
async opened() {
|
|
10169
|
+
this.closed();
|
|
10170
|
+
this.#connection = this.#config.socket().generation;
|
|
10171
|
+
await this.changed();
|
|
10172
|
+
}
|
|
10173
|
+
/** The stored token may have changed: tell the open connection what is new. */
|
|
10174
|
+
async changed() {
|
|
10175
|
+
if (this.#config.service) return this.#settle();
|
|
10176
|
+
const epoch = this.#epoch;
|
|
10177
|
+
this.#reading += 1;
|
|
10178
|
+
this.#announced = false;
|
|
10179
|
+
try {
|
|
10180
|
+
await this.#config.ready();
|
|
10181
|
+
} catch {
|
|
10182
|
+
}
|
|
10183
|
+
const token = await this.#config.tokens.get();
|
|
10184
|
+
if (epoch === this.#epoch) this.#reading -= 1;
|
|
10185
|
+
if (this.#config.socket().isOpen()) {
|
|
10186
|
+
const unknown = this.#unsure && this.#inFlight === null;
|
|
10187
|
+
if (unknown || token !== this.heldToken()) this.#send(token);
|
|
10188
|
+
}
|
|
10189
|
+
this.#settle();
|
|
10190
|
+
}
|
|
10191
|
+
/**
|
|
10192
|
+
* A 4401: forget the stored session only if the hub answered
|
|
10193
|
+
* INVALID_SESSION to it by name, and it is still stored — a sign-in since
|
|
10194
|
+
* then keeps its new token (결정 3). A 4401 with no such answer clears
|
|
10195
|
+
* nothing (plan 2026-10-28 정정): the hub keeps the expiry it read when it
|
|
10196
|
+
* took the token (known-issues #183) while the backend's sign-in slides
|
|
10197
|
+
* the session's under the same token, so a tab open for days is closed
|
|
10198
|
+
* with a session that is still good. The next connection tells the token
|
|
10199
|
+
* again, and hears INVALID_SESSION — and clears it — if it really is
|
|
10200
|
+
* dead. The access JWT goes with the session it was minted for, so only
|
|
10201
|
+
* when that session went.
|
|
10202
|
+
*/
|
|
10203
|
+
async refused() {
|
|
10204
|
+
const epoch = this.#epoch;
|
|
10205
|
+
const early = this.#refused;
|
|
10206
|
+
await Promise.resolve();
|
|
10207
|
+
const refusal = this.#epoch === epoch ? this.#refused : early;
|
|
10208
|
+
this.closed();
|
|
10209
|
+
if (refusal === null || refusal.token === null) return;
|
|
10210
|
+
if (await this.#config.tokens.clearIf(refusal.token))
|
|
10211
|
+
await this.#config.accessJwt?.clear();
|
|
10212
|
+
}
|
|
10213
|
+
/** The connection is gone: what it was told is forgotten. */
|
|
10214
|
+
closed() {
|
|
10215
|
+
clearTimeout(this.#retry);
|
|
10216
|
+
this.#retry = void 0;
|
|
10217
|
+
this.#epoch += 1;
|
|
10218
|
+
this.#confirmed = null;
|
|
10219
|
+
this.#inFlight = null;
|
|
10220
|
+
this.#refused = null;
|
|
10221
|
+
this.#unsure = false;
|
|
10222
|
+
this.#attempt = 0;
|
|
10223
|
+
this.#connection = null;
|
|
10224
|
+
this.#reading = 0;
|
|
10225
|
+
this.#announced = false;
|
|
10226
|
+
}
|
|
10227
|
+
/** Live, and not said yet since it last was not: say it. */
|
|
10228
|
+
#settle() {
|
|
10229
|
+
if (this.#announced || !this.isLive()) return;
|
|
10230
|
+
this.#announced = true;
|
|
10231
|
+
safely(() => this.#config.onLive());
|
|
10232
|
+
}
|
|
10233
|
+
#send(token) {
|
|
10234
|
+
clearTimeout(this.#retry);
|
|
10235
|
+
this.#retry = void 0;
|
|
10236
|
+
const sent2 = { token };
|
|
10237
|
+
this.#inFlight = sent2;
|
|
10238
|
+
const epoch = this.#epoch;
|
|
10239
|
+
void this.#config.socket().request(
|
|
10240
|
+
PHOENIX_TOPIC,
|
|
10241
|
+
"access_token",
|
|
10242
|
+
{ access_token: token },
|
|
10243
|
+
{ turn: "identity" }
|
|
10244
|
+
).then((reply) => {
|
|
10245
|
+
if (epoch === this.#epoch) this.#heard(sent2, reply);
|
|
10246
|
+
});
|
|
10247
|
+
}
|
|
10248
|
+
#heard(sent2, reply) {
|
|
10249
|
+
if (this.#inFlight === sent2) this.#inFlight = null;
|
|
10250
|
+
if (reply.ok) {
|
|
10251
|
+
this.#confirmed = sent2.token;
|
|
10252
|
+
this.#unsure = false;
|
|
10253
|
+
this.#attempt = 0;
|
|
10254
|
+
return this.#settle();
|
|
10255
|
+
}
|
|
10256
|
+
if (reply.lost) return;
|
|
10257
|
+
if (reply.error.code === "TIMED_OUT") this.#unsure = true;
|
|
10258
|
+
if (reply.error.code === "INVALID_SESSION") {
|
|
10259
|
+
this.#refused = sent2;
|
|
10260
|
+
return;
|
|
10261
|
+
}
|
|
10262
|
+
if (this.#inFlight !== null) return;
|
|
10263
|
+
if (!RETRY_CODES.has(reply.error.code)) {
|
|
10264
|
+
if (this.#confirmed !== null && sent2.token !== this.#confirmed) {
|
|
10265
|
+
this.closed();
|
|
10266
|
+
this.#config.socket().reconnect();
|
|
10267
|
+
return;
|
|
10268
|
+
}
|
|
10269
|
+
return this.#settle();
|
|
10270
|
+
}
|
|
10271
|
+
this.#later(
|
|
10272
|
+
reply.error.code === "TOO_MANY_JOINS" ? rejudgeWaitMs(this.#config.privateChannels()) : 0
|
|
10273
|
+
);
|
|
10274
|
+
}
|
|
10275
|
+
/**
|
|
10276
|
+
* Reads the store again after the backoff (at least `minMs`), once there
|
|
10277
|
+
* is room on the connection for a frame the SDK sends of its own accord
|
|
10278
|
+
* (`whenRoom()`, PR #328 review). `#retry` stays set until then: the
|
|
10279
|
+
* connection is not live while it waits.
|
|
10280
|
+
*/
|
|
10281
|
+
#later(minMs) {
|
|
10282
|
+
clearTimeout(this.#retry);
|
|
10283
|
+
const delay = Math.max(minMs, backoffMs(this.#attempt, Math.random));
|
|
10284
|
+
this.#attempt += 1;
|
|
10285
|
+
const epoch = this.#epoch;
|
|
10286
|
+
this.#retry = setTimeout(() => {
|
|
10287
|
+
this.#config.socket().whenRoom(this, () => {
|
|
10288
|
+
if (epoch !== this.#epoch) return;
|
|
10289
|
+
this.#retry = void 0;
|
|
10290
|
+
void this.changed();
|
|
10291
|
+
});
|
|
10292
|
+
}, delay);
|
|
10293
|
+
}
|
|
10294
|
+
};
|
|
10295
|
+
var LEAVE_RETRY_CODES = /* @__PURE__ */ new Set([
|
|
10296
|
+
"TOO_MANY_JOINS",
|
|
10297
|
+
"INTERNAL_ERROR",
|
|
10298
|
+
"CONNECTION_STATE_TOO_LARGE",
|
|
10299
|
+
"TIMED_OUT"
|
|
10300
|
+
]);
|
|
10301
|
+
function sameSettings(a, b) {
|
|
10302
|
+
if (a === b) return true;
|
|
10303
|
+
try {
|
|
10304
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
10305
|
+
} catch {
|
|
10306
|
+
return false;
|
|
10307
|
+
}
|
|
10308
|
+
}
|
|
10309
|
+
function sameJoin(a, b) {
|
|
10310
|
+
return a.name === b.name && a.isPrivate === b.isPrivate;
|
|
10311
|
+
}
|
|
10312
|
+
function foldTable(table3) {
|
|
10313
|
+
return table3.replaceAll(/[A-Z]/g, (letter) => letter.toLowerCase());
|
|
10314
|
+
}
|
|
10315
|
+
var RealtimeClient = class {
|
|
10316
|
+
#http;
|
|
10317
|
+
#socket;
|
|
10318
|
+
#identity;
|
|
10319
|
+
#context;
|
|
10320
|
+
#channels = [];
|
|
10321
|
+
/** The leaves owed on the open connection (정정 (i)); forgotten with it. */
|
|
10322
|
+
#owed = /* @__PURE__ */ new Set();
|
|
10323
|
+
#onStorage = null;
|
|
10324
|
+
constructor(input) {
|
|
10325
|
+
const { http, auth, service, options } = input;
|
|
10326
|
+
this.#http = http;
|
|
10327
|
+
this.#socket = new RealtimeSocket({
|
|
10328
|
+
baseUrl: http.baseUrl,
|
|
10329
|
+
apiKey: http.apiKey,
|
|
10330
|
+
service,
|
|
10331
|
+
options,
|
|
10332
|
+
events: {
|
|
10333
|
+
// Not awaited, and not live yet: onLive says when the hub took it.
|
|
10334
|
+
opened: () => {
|
|
10335
|
+
this.#watchStorage();
|
|
10336
|
+
void this.#identity.opened();
|
|
10337
|
+
},
|
|
10338
|
+
frame: (frame) => this.#route(frame),
|
|
10339
|
+
// Only the leaves and the channels: the identity is closed by the
|
|
10340
|
+
// next opened() (the 4401's refused() reads it after this turn —
|
|
10341
|
+
// 정정 (f)).
|
|
10342
|
+
dropped: (error, status) => {
|
|
10343
|
+
this.#forgetLeaves();
|
|
10344
|
+
this.#each((channel) => channel.dropped(error, status));
|
|
10345
|
+
},
|
|
10346
|
+
stopped: (error) => this.#stopped(error),
|
|
10347
|
+
sessionRefused: () => this.#identity.refused()
|
|
10348
|
+
}
|
|
10349
|
+
});
|
|
10350
|
+
this.#identity = new Identity({
|
|
10351
|
+
tokens: http.tokens,
|
|
10352
|
+
accessJwt: http.accessJwt,
|
|
10353
|
+
service,
|
|
10354
|
+
socket: () => this.#socket,
|
|
10355
|
+
privateChannels: () => this.#channels.filter(
|
|
10356
|
+
(channel) => channel.isPrivate && channel.state === "joined"
|
|
10357
|
+
).length,
|
|
10358
|
+
onLive: () => this.#each((channel) => channel.rejoin()),
|
|
10359
|
+
// A sign-in the page URL carries goes before any join (final review A I1).
|
|
10360
|
+
ready: () => auth.ready()
|
|
10361
|
+
});
|
|
10362
|
+
this.#context = {
|
|
10363
|
+
socket: this.#socket,
|
|
10364
|
+
identity: this.#identity,
|
|
10365
|
+
http,
|
|
10366
|
+
auth,
|
|
10367
|
+
connect: () => this.#socket.connect(),
|
|
10368
|
+
live: () => this.#identity.isLive(),
|
|
10369
|
+
leave: (channel, joinRef, tables) => this.#leave(channel, joinRef, tables),
|
|
10370
|
+
owesLeave: (channel) => this.#owesLeave(channel),
|
|
10371
|
+
owesLeaveOn: (table3) => this.#owesLeaveOn(table3)
|
|
10372
|
+
};
|
|
10373
|
+
http.tokens.subscribe(() => void this.#identity.changed());
|
|
10374
|
+
}
|
|
10375
|
+
channel(name, options = {}) {
|
|
10376
|
+
const settings = isRecord(options) ? options : {};
|
|
10377
|
+
const isPrivate = settings.config?.private === true;
|
|
10378
|
+
const found = this.#channels.find(
|
|
10379
|
+
(c) => c.name === name && c.isPrivate === isPrivate
|
|
10380
|
+
);
|
|
10381
|
+
if (found !== void 0) {
|
|
10382
|
+
if (!sameSettings(found.options, settings))
|
|
10383
|
+
warnInDevelopment(
|
|
10384
|
+
`naro-js: channel(${JSON.stringify(name)}) already exists with other settings; it keeps the first ones until removeChannel().`
|
|
10385
|
+
);
|
|
10386
|
+
return found;
|
|
10387
|
+
}
|
|
10388
|
+
const channel = new RealtimeChannel(
|
|
10389
|
+
name,
|
|
10390
|
+
settings,
|
|
10391
|
+
this.#context
|
|
10392
|
+
);
|
|
10393
|
+
this.#channels.push(channel);
|
|
10394
|
+
return channel;
|
|
10395
|
+
}
|
|
10396
|
+
getChannels() {
|
|
10397
|
+
return [...this.#channels];
|
|
10398
|
+
}
|
|
10399
|
+
async removeChannel(channel) {
|
|
10400
|
+
this.#channels = this.#channels.filter((c) => c !== channel);
|
|
10401
|
+
return this.#closeIfLast(await channel.unsubscribe());
|
|
10402
|
+
}
|
|
10403
|
+
async removeAllChannels() {
|
|
10404
|
+
const channels = this.#channels;
|
|
10405
|
+
this.#channels = [];
|
|
10406
|
+
const results = await Promise.all(
|
|
10407
|
+
channels.map((channel) => channel.unsubscribe())
|
|
10408
|
+
);
|
|
10409
|
+
return this.#closeIfLast({
|
|
10410
|
+
data: null,
|
|
10411
|
+
error: results.find((result) => result.error !== null)?.error ?? null
|
|
10412
|
+
});
|
|
10413
|
+
}
|
|
10414
|
+
/**
|
|
10415
|
+
* No channel left (the app may have added one meanwhile): the connection
|
|
10416
|
+
* closes. That is the strongest leave — the hub forgets every join with
|
|
10417
|
+
* its connection — so a leave's refusal no longer stands (정정 (j)).
|
|
10418
|
+
*/
|
|
10419
|
+
#closeIfLast(result) {
|
|
10420
|
+
if (this.#channels.length > 0) return result;
|
|
10421
|
+
this.#disconnect();
|
|
10422
|
+
return { data: null, error: null };
|
|
10423
|
+
}
|
|
10424
|
+
/**
|
|
10425
|
+
* Each channel in turn, over a copy (a callback may add or remove one);
|
|
10426
|
+
* what one throws does not keep the next from hearing (결정 22).
|
|
10427
|
+
*/
|
|
10428
|
+
#each(action) {
|
|
10429
|
+
for (const channel of Array.from(this.#channels))
|
|
10430
|
+
safely(() => action(channel));
|
|
10431
|
+
}
|
|
10432
|
+
#route(frame) {
|
|
10433
|
+
if (frame.topic === PHOENIX_TOPIC || frame.joinRef === null) return;
|
|
10434
|
+
const channel = this.#channels.find(
|
|
10435
|
+
(c) => c.joinRef === frame.joinRef && c.topic === frame.topic
|
|
10436
|
+
);
|
|
10437
|
+
channel?.receive(frame);
|
|
10438
|
+
}
|
|
10439
|
+
#stopped(error) {
|
|
10440
|
+
this.#identity.closed();
|
|
10441
|
+
this.#forgetLeaves();
|
|
10442
|
+
this.#unwatchStorage();
|
|
10443
|
+
this.#each((channel) => channel.stopped(error));
|
|
10444
|
+
}
|
|
10445
|
+
#disconnect() {
|
|
10446
|
+
this.#socket.disconnect();
|
|
10447
|
+
this.#identity.closed();
|
|
10448
|
+
this.#forgetLeaves();
|
|
10449
|
+
this.#unwatchStorage();
|
|
10450
|
+
}
|
|
10451
|
+
#leave(channel, joinRef, tables) {
|
|
10452
|
+
const owed = {
|
|
10453
|
+
channel,
|
|
10454
|
+
joinRef,
|
|
10455
|
+
tables,
|
|
10456
|
+
attempt: 0,
|
|
10457
|
+
timer: void 0
|
|
10458
|
+
};
|
|
10459
|
+
this.#owed.add(owed);
|
|
10460
|
+
return this.#sendLeave(owed, "app");
|
|
10461
|
+
}
|
|
10462
|
+
/**
|
|
10463
|
+
* One `phx_leave` of `owed`; its answer decides whether it is done,
|
|
10464
|
+
* whoever else awaits it. The first goes in the app's turn, after the
|
|
10465
|
+
* frames the app sent on the join before it (and is never refused for
|
|
10466
|
+
* it); one tried again, in the SDK's (PR #328 re-review).
|
|
10467
|
+
*/
|
|
10468
|
+
#sendLeave(owed, turn) {
|
|
10469
|
+
const { channel, joinRef } = owed;
|
|
10470
|
+
const reply = this.#socket.request(
|
|
10471
|
+
channel.topic,
|
|
10472
|
+
"phx_leave",
|
|
10473
|
+
{},
|
|
10474
|
+
{ joinRef, turn }
|
|
10475
|
+
);
|
|
10476
|
+
void reply.then((answer) => this.#leaveAnswered(owed, answer));
|
|
10477
|
+
return reply;
|
|
10478
|
+
}
|
|
10479
|
+
#leaveAnswered(owed, reply) {
|
|
10480
|
+
if (!this.#owed.has(owed)) return;
|
|
10481
|
+
if (!reply.ok && !reply.lost && LEAVE_RETRY_CODES.has(reply.error.code)) {
|
|
10482
|
+
const delay = backoffMs(owed.attempt, Math.random);
|
|
10483
|
+
owed.attempt += 1;
|
|
10484
|
+
owed.timer = setTimeout(() => {
|
|
10485
|
+
owed.timer = void 0;
|
|
10486
|
+
this.#socket.whenRoom(owed, () => {
|
|
10487
|
+
if (this.#owed.has(owed)) void this.#sendLeave(owed, "sdk");
|
|
10488
|
+
});
|
|
10489
|
+
}, delay);
|
|
10490
|
+
const { error } = reply;
|
|
10491
|
+
this.#each((channel) => {
|
|
10492
|
+
if (sameJoin(channel, owed.channel)) channel.leaveRefused(error);
|
|
10493
|
+
});
|
|
10494
|
+
return;
|
|
10495
|
+
}
|
|
10496
|
+
this.#owed.delete(owed);
|
|
10497
|
+
this.#each((channel) => channel.rejoin());
|
|
10498
|
+
}
|
|
10499
|
+
#owesLeave(channel) {
|
|
10500
|
+
for (const owed of this.#owed)
|
|
10501
|
+
if (sameJoin(owed.channel, channel)) return true;
|
|
10502
|
+
return false;
|
|
10503
|
+
}
|
|
10504
|
+
/** `ChannelContext.owesLeaveOn`: the hub's caps count the joins whose leaves are owed. */
|
|
10505
|
+
#owesLeaveOn(table3) {
|
|
10506
|
+
for (const owed of this.#owed) {
|
|
10507
|
+
if (table3 === null) return true;
|
|
10508
|
+
const key = foldTable(table3);
|
|
10509
|
+
if (owed.tables.some(
|
|
10510
|
+
(held) => held === "*" || key === "*" || foldTable(held) === key
|
|
10511
|
+
))
|
|
10512
|
+
return true;
|
|
10513
|
+
}
|
|
10514
|
+
return false;
|
|
10515
|
+
}
|
|
10516
|
+
#forgetLeaves() {
|
|
10517
|
+
for (const owed of this.#owed) clearTimeout(owed.timer);
|
|
10518
|
+
this.#owed.clear();
|
|
10519
|
+
}
|
|
10520
|
+
/**
|
|
10521
|
+
* Another tab signing in or out writes the same localStorage key: read the
|
|
10522
|
+
* store again while the connection lives (결정 5) — from its first open to
|
|
10523
|
+
* a stop or the last channel's removal, drops included. Reading again
|
|
10524
|
+
* sends nothing unless the stored token is not what the hub has, so a
|
|
10525
|
+
* storage of the app's own (`auth.storage`), whose writes this event
|
|
10526
|
+
* never reports, costs a read at most.
|
|
10527
|
+
*/
|
|
10528
|
+
#watchStorage() {
|
|
10529
|
+
const target = globalThis;
|
|
10530
|
+
if (this.#onStorage !== null || !isBrowser() || typeof target.addEventListener !== "function")
|
|
10531
|
+
return;
|
|
10532
|
+
const key = storageKey(this.#http.baseUrl);
|
|
10533
|
+
this.#onStorage = (event) => {
|
|
10534
|
+
if (event.key === key || event.key === null)
|
|
10535
|
+
void this.#identity.changed();
|
|
10536
|
+
};
|
|
10537
|
+
target.addEventListener("storage", this.#onStorage);
|
|
10538
|
+
}
|
|
10539
|
+
#unwatchStorage() {
|
|
10540
|
+
const target = globalThis;
|
|
10541
|
+
if (this.#onStorage === null) return;
|
|
10542
|
+
target.removeEventListener?.("storage", this.#onStorage);
|
|
10543
|
+
this.#onStorage = null;
|
|
10544
|
+
}
|
|
10545
|
+
};
|
|
7895
10546
|
function fail(error) {
|
|
7896
10547
|
return { data: null, error };
|
|
7897
10548
|
}
|
|
@@ -8001,7 +10652,7 @@ async function uploadInParts(a) {
|
|
|
8001
10652
|
failure.error ??= uploadAborted();
|
|
8002
10653
|
};
|
|
8003
10654
|
const tryPart = async (n, bytes) => {
|
|
8004
|
-
const
|
|
10655
|
+
const sent2 = await send(a.http, {
|
|
8005
10656
|
method: "PUT",
|
|
8006
10657
|
path: `${base}/${String(n)}`,
|
|
8007
10658
|
headers: { "content-type": "application/octet-stream", ...signed },
|
|
@@ -8009,10 +10660,10 @@ async function uploadInParts(a) {
|
|
|
8009
10660
|
withToken,
|
|
8010
10661
|
signal
|
|
8011
10662
|
});
|
|
8012
|
-
if (
|
|
8013
|
-
const read = await readJson(
|
|
10663
|
+
if (sent2.error !== null) return sent2;
|
|
10664
|
+
const read = await readJson(sent2.response, partAnswer(n));
|
|
8014
10665
|
if (read.error !== null)
|
|
8015
|
-
return { error: read.error, status:
|
|
10666
|
+
return { error: read.error, status: sent2.response.status };
|
|
8016
10667
|
return { etag: read.data.etag };
|
|
8017
10668
|
};
|
|
8018
10669
|
const sendPart = async (n) => {
|
|
@@ -8299,7 +10950,7 @@ var StorageFileApi = class {
|
|
|
8299
10950
|
"cache-control": cache
|
|
8300
10951
|
};
|
|
8301
10952
|
if (upsert) headers["x-upsert"] = "true";
|
|
8302
|
-
const
|
|
10953
|
+
const sent2 = await send(this.#http, {
|
|
8303
10954
|
method,
|
|
8304
10955
|
path: target.path,
|
|
8305
10956
|
headers,
|
|
@@ -8308,8 +10959,8 @@ var StorageFileApi = class {
|
|
|
8308
10959
|
// The fetch stops with the body: the worker undoes its claim at once.
|
|
8309
10960
|
signal
|
|
8310
10961
|
});
|
|
8311
|
-
if (
|
|
8312
|
-
return readJson(
|
|
10962
|
+
if (sent2.error !== null) return fail(sent2.error);
|
|
10963
|
+
return readJson(sent2.response, isUploadResult);
|
|
8313
10964
|
}
|
|
8314
10965
|
/**
|
|
8315
10966
|
* A new object; with `upsert: true`, one of the same name is replaced.
|
|
@@ -8334,14 +10985,14 @@ var StorageFileApi = class {
|
|
|
8334
10985
|
const target = this.#objectPath("", path);
|
|
8335
10986
|
if (!target.ok) return fail(target.error);
|
|
8336
10987
|
await this.#auth.ready();
|
|
8337
|
-
const
|
|
10988
|
+
const sent2 = await send(this.#http, {
|
|
8338
10989
|
method: "GET",
|
|
8339
10990
|
path: target.path,
|
|
8340
10991
|
withToken: true
|
|
8341
10992
|
});
|
|
8342
|
-
if (
|
|
10993
|
+
if (sent2.error !== null) return fail(sent2.error);
|
|
8343
10994
|
try {
|
|
8344
|
-
return { data: await
|
|
10995
|
+
return { data: await sent2.response.blob(), error: null };
|
|
8345
10996
|
} catch (error) {
|
|
8346
10997
|
return fail(networkError(error));
|
|
8347
10998
|
}
|
|
@@ -8606,7 +11257,7 @@ var StorageFileApi = class {
|
|
|
8606
11257
|
* is the permission. A failure's cause never carries the token.
|
|
8607
11258
|
*/
|
|
8608
11259
|
async #putSigned(path, token, payload, cache, signal) {
|
|
8609
|
-
const
|
|
11260
|
+
const sent2 = await send(this.#http, {
|
|
8610
11261
|
method: "PUT",
|
|
8611
11262
|
path: `${path}?token=${encodeURIComponent(token)}`,
|
|
8612
11263
|
headers: { "content-type": payload.type, "cache-control": cache },
|
|
@@ -8614,8 +11265,8 @@ var StorageFileApi = class {
|
|
|
8614
11265
|
withToken: false,
|
|
8615
11266
|
signal
|
|
8616
11267
|
});
|
|
8617
|
-
if (
|
|
8618
|
-
const read = await readJson(
|
|
11268
|
+
if (sent2.error !== null) return fail(withoutToken(sent2.error, token));
|
|
11269
|
+
const read = await readJson(sent2.response, isUploadResult);
|
|
8619
11270
|
return read.error === null ? read : fail(withoutToken(read.error, token));
|
|
8620
11271
|
}
|
|
8621
11272
|
/**
|
|
@@ -8655,10 +11306,36 @@ var CONFIG_MESSAGES = {
|
|
|
8655
11306
|
urlNotOrigin: "createClient needs the project url alone, like https://my-app.naro.sh: no path, no ? or #, no user:password@. Naro serves its API at /_naro/v1 on that origin.",
|
|
8656
11307
|
key: "createClient needs an anonKey (or, on a server, a serviceKey).",
|
|
8657
11308
|
bothKeys: "Pass createClient an anonKey or a serviceKey, not both.",
|
|
8658
|
-
serviceKeyOffServer:
|
|
8659
|
-
fetch: "This runtime has no fetch. Pass one to createClient({ fetch })."
|
|
11309
|
+
serviceKeyOffServer: SERVICE_KEY_OFF_SERVER_MESSAGE,
|
|
11310
|
+
fetch: "This runtime has no fetch. Pass one to createClient({ fetch }).",
|
|
11311
|
+
realtime: "createClient's realtime options take a WebSocket class, and heartbeatIntervalMs and timeoutMs in whole milliseconds from 1 to 2147483647."
|
|
8660
11312
|
};
|
|
8661
11313
|
var SERVICE_KEY_PREFIX = "naro_sk_";
|
|
11314
|
+
var MAX_TIMER_MS = 2147483647;
|
|
11315
|
+
function isMilliseconds(value) {
|
|
11316
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_TIMER_MS;
|
|
11317
|
+
}
|
|
11318
|
+
function realtimeOptionsOf(value) {
|
|
11319
|
+
if (value === void 0) return {};
|
|
11320
|
+
if (!isRecord(value)) throw new Error(CONFIG_MESSAGES.realtime);
|
|
11321
|
+
const { WebSocket, heartbeatIntervalMs, timeoutMs } = value;
|
|
11322
|
+
const options = {};
|
|
11323
|
+
if (WebSocket !== void 0) {
|
|
11324
|
+
if (typeof WebSocket !== "function")
|
|
11325
|
+
throw new Error(CONFIG_MESSAGES.realtime);
|
|
11326
|
+
options.WebSocket = WebSocket;
|
|
11327
|
+
}
|
|
11328
|
+
if (heartbeatIntervalMs !== void 0) {
|
|
11329
|
+
if (!isMilliseconds(heartbeatIntervalMs))
|
|
11330
|
+
throw new Error(CONFIG_MESSAGES.realtime);
|
|
11331
|
+
options.heartbeatIntervalMs = heartbeatIntervalMs;
|
|
11332
|
+
}
|
|
11333
|
+
if (timeoutMs !== void 0) {
|
|
11334
|
+
if (!isMilliseconds(timeoutMs)) throw new Error(CONFIG_MESSAGES.realtime);
|
|
11335
|
+
options.timeoutMs = timeoutMs;
|
|
11336
|
+
}
|
|
11337
|
+
return options;
|
|
11338
|
+
}
|
|
8662
11339
|
function baseUrlOf(url) {
|
|
8663
11340
|
if (typeof url !== "string") throw new Error(CONFIG_MESSAGES.url);
|
|
8664
11341
|
const trimmed = url.trim();
|
|
@@ -8715,18 +11392,27 @@ var NaroClient2 = class {
|
|
|
8715
11392
|
*/
|
|
8716
11393
|
storage;
|
|
8717
11394
|
#run;
|
|
11395
|
+
#newRealtime;
|
|
11396
|
+
/**
|
|
11397
|
+
* Made by the first channel(), not by the constructor: it listens to every
|
|
11398
|
+
* write of the session token (to tell an open connection who signed in),
|
|
11399
|
+
* which an app that never uses Realtime should not pay for.
|
|
11400
|
+
*/
|
|
11401
|
+
#realtime;
|
|
8718
11402
|
constructor(options) {
|
|
8719
11403
|
const baseUrl = baseUrlOf(options.url);
|
|
8720
11404
|
const { key: apiKey, service } = apiKeyOf(options);
|
|
8721
11405
|
if (options.fetch === void 0 && typeof globalThis.fetch !== "function")
|
|
8722
11406
|
throw new Error(CONFIG_MESSAGES.fetch);
|
|
11407
|
+
const realtime = realtimeOptionsOf(options.realtime);
|
|
8723
11408
|
const storage = options.auth?.storage ?? defaultStorage();
|
|
8724
11409
|
const http = {
|
|
8725
11410
|
baseUrl,
|
|
8726
11411
|
apiKey,
|
|
8727
11412
|
fetch: options.fetch,
|
|
8728
11413
|
tokens: new TokenStore(storage, storageKey(baseUrl)),
|
|
8729
|
-
accessJwt: new TokenStore(storage, accessJwtKey(baseUrl))
|
|
11414
|
+
accessJwt: new TokenStore(storage, accessJwtKey(baseUrl)),
|
|
11415
|
+
service
|
|
8730
11416
|
};
|
|
8731
11417
|
this.auth = new AuthClient(http, {
|
|
8732
11418
|
verifiers: new FlowVerifiers(
|
|
@@ -8741,11 +11427,46 @@ var NaroClient2 = class {
|
|
|
8741
11427
|
});
|
|
8742
11428
|
this.#run = queryRunner(http, this.auth);
|
|
8743
11429
|
this.storage = new StorageClient(http, this.auth);
|
|
11430
|
+
this.#newRealtime = () => new RealtimeClient({
|
|
11431
|
+
http,
|
|
11432
|
+
auth: this.auth,
|
|
11433
|
+
service,
|
|
11434
|
+
options: realtime
|
|
11435
|
+
});
|
|
8744
11436
|
}
|
|
8745
11437
|
/** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
|
|
8746
11438
|
from(table3) {
|
|
8747
11439
|
return new TableBuilder(this.#run, table3);
|
|
8748
11440
|
}
|
|
11441
|
+
/**
|
|
11442
|
+
* A Realtime channel (original spec §15): `.on()` listeners, then
|
|
11443
|
+
* `.subscribe()`. The same name and privacy give the same channel until
|
|
11444
|
+
* `removeChannel`. Nothing connects until the first `subscribe()`.
|
|
11445
|
+
*/
|
|
11446
|
+
channel(name, options) {
|
|
11447
|
+
return this.#client().channel(name, options);
|
|
11448
|
+
}
|
|
11449
|
+
/** The channels made with `channel()` and not yet removed. */
|
|
11450
|
+
getChannels() {
|
|
11451
|
+
return this.#realtime?.getChannels() ?? [];
|
|
11452
|
+
}
|
|
11453
|
+
/**
|
|
11454
|
+
* Leaves the channel and forgets it, so `channel()` of its name makes a new
|
|
11455
|
+
* one; the connection closes with the last channel.
|
|
11456
|
+
*/
|
|
11457
|
+
removeChannel(channel) {
|
|
11458
|
+
return this.#client().removeChannel(channel);
|
|
11459
|
+
}
|
|
11460
|
+
/** `removeChannel()` for every channel; the connection closes. */
|
|
11461
|
+
removeAllChannels() {
|
|
11462
|
+
if (this.#realtime === void 0)
|
|
11463
|
+
return Promise.resolve({ data: null, error: null });
|
|
11464
|
+
return this.#realtime.removeAllChannels();
|
|
11465
|
+
}
|
|
11466
|
+
#client() {
|
|
11467
|
+
this.#realtime ??= this.#newRealtime();
|
|
11468
|
+
return this.#realtime;
|
|
11469
|
+
}
|
|
8749
11470
|
};
|
|
8750
11471
|
function createClient(options) {
|
|
8751
11472
|
return new NaroClient2(options);
|
|
@@ -8842,18 +11563,27 @@ function unwrap(result) {
|
|
|
8842
11563
|
}
|
|
8843
11564
|
var ONE_KEY = /^[\x21-\x7e]+$/;
|
|
8844
11565
|
async function objects(context) {
|
|
8845
|
-
const client = await context
|
|
11566
|
+
const client = await dataClient(context);
|
|
8846
11567
|
const projectId = await context.resolveProjectId();
|
|
8847
11568
|
const backend = await client.backend.info(projectId);
|
|
8848
|
-
|
|
11569
|
+
const environment = dataEnvironment(context.flags);
|
|
11570
|
+
if (backend.status === "none") {
|
|
8849
11571
|
throw new NaroError(
|
|
8850
11572
|
"BACKEND_NOT_READY",
|
|
8851
|
-
"This project has no backend yet. Run `naro backend enable`, then `naro storage enable`."
|
|
11573
|
+
environment === null ? "This project has no backend yet. Run `naro backend enable`, then `naro storage enable`." : `The ${environment} environment has no backend yet. Run \`naro environments provision ${environment}\` to make it.`
|
|
11574
|
+
);
|
|
11575
|
+
}
|
|
11576
|
+
if (!backend.enabled)
|
|
11577
|
+
throw new NaroError(
|
|
11578
|
+
"BACKEND_OFF",
|
|
11579
|
+
forEnvironment(BACKEND_OFF_MESSAGE, environment)
|
|
8852
11580
|
);
|
|
8853
|
-
if (!backend.enabled) throw new NaroError("BACKEND_OFF", BACKEND_OFF_MESSAGE);
|
|
8854
11581
|
const state = await client.storage.info(projectId);
|
|
8855
11582
|
if (state.disabledAt !== null)
|
|
8856
|
-
throw new NaroError(
|
|
11583
|
+
throw new NaroError(
|
|
11584
|
+
"STORAGE_OFF",
|
|
11585
|
+
forEnvironment(STORAGE_OFF_MESSAGE, environment)
|
|
11586
|
+
);
|
|
8857
11587
|
let serviceKey = process.env.NARO_SERVICE_KEY?.trim() ?? "";
|
|
8858
11588
|
if (serviceKey !== "" && !ONE_KEY.test(serviceKey))
|
|
8859
11589
|
throw new NaroError(
|
|
@@ -8874,7 +11604,10 @@ async function objects(context) {
|
|
|
8874
11604
|
} catch (error) {
|
|
8875
11605
|
throw new NaroError(
|
|
8876
11606
|
"BACKEND_NOT_READY",
|
|
8877
|
-
|
|
11607
|
+
forEnvironment(
|
|
11608
|
+
`The project's backend url cannot be used (${printable(backend.url)}). Run \`naro backend\` to see where the backend got to.`,
|
|
11609
|
+
environment
|
|
11610
|
+
),
|
|
8878
11611
|
{ cause: error }
|
|
8879
11612
|
);
|
|
8880
11613
|
}
|
|
@@ -8948,7 +11681,7 @@ async function storageLs(context) {
|
|
|
8948
11681
|
"VALIDATION_FAILED",
|
|
8949
11682
|
`--search needs a naro:// path. ${LS_USAGE}`
|
|
8950
11683
|
);
|
|
8951
|
-
const client = await context
|
|
11684
|
+
const client = await dataClient(context);
|
|
8952
11685
|
const projectId2 = await context.resolveProjectId();
|
|
8953
11686
|
const buckets = await client.storage.listBuckets(projectId2);
|
|
8954
11687
|
context.output.result(
|
|
@@ -9058,17 +11791,17 @@ function progressReporter(output, total) {
|
|
|
9058
11791
|
let lastLength = 0;
|
|
9059
11792
|
let open = false;
|
|
9060
11793
|
return {
|
|
9061
|
-
report(
|
|
9062
|
-
const percent2 = Math.floor(
|
|
9063
|
-
const line = `\u2191 ${binarySize(
|
|
11794
|
+
report(sent2) {
|
|
11795
|
+
const percent2 = Math.floor(sent2 / total * 100);
|
|
11796
|
+
const line = `\u2191 ${binarySize(sent2)} / ${binarySize(total)} (${String(percent2)}%)`;
|
|
9064
11797
|
if (tty) {
|
|
9065
|
-
open =
|
|
11798
|
+
open = sent2 !== total;
|
|
9066
11799
|
output.progress(`\r${line.padEnd(lastLength)}${open ? "" : "\n"}`);
|
|
9067
11800
|
lastLength = line.length;
|
|
9068
11801
|
return;
|
|
9069
11802
|
}
|
|
9070
11803
|
const tenth = Math.floor(percent2 / 10);
|
|
9071
|
-
if (tenth === lastTenth &&
|
|
11804
|
+
if (tenth === lastTenth && sent2 !== total) return;
|
|
9072
11805
|
lastTenth = tenth;
|
|
9073
11806
|
output.progress(`${line}
|
|
9074
11807
|
`);
|
|
@@ -9605,12 +12338,14 @@ Usage:
|
|
|
9605
12338
|
naro env pull [file] Write env vars to .env.local
|
|
9606
12339
|
target is production, preview, all or an environment name; add and import use all when none is given
|
|
9607
12340
|
|
|
9608
|
-
naro environments List the project's environments: address, branch, protection, and how many the plan allows
|
|
12341
|
+
naro environments List the project's environments: address, branch, protection, data, and how many the plan allows
|
|
9609
12342
|
naro environments create <slug> [--branch <b>] [--copy-env preview|production|none] [--database schema|empty]
|
|
9610
12343
|
A QA or staging surface served at <subdomain>--<slug>; starts with preview's variables
|
|
9611
12344
|
naro environments set <slug> [--branch <b> | --no-branch] [--protection on|off]
|
|
9612
12345
|
Change the branch whose pushes deploy here, or who can open it (protection off asks first; --yes in CI)
|
|
9613
|
-
naro environments delete <slug> Delete it with its worker
|
|
12346
|
+
naro environments delete <slug> Delete it with its worker, protection key and data (asks first; --yes in CI)
|
|
12347
|
+
naro environments provision <slug>
|
|
12348
|
+
Make its data \u2014 database, backend, Storage \u2014 or go on after a stop
|
|
9614
12349
|
|
|
9615
12350
|
naro domains list | add <host> [--no-www] | remove <host>
|
|
9616
12351
|
add example.com also connects www.example.com and redirects the apex to
|
|
@@ -9619,6 +12354,9 @@ Usage:
|
|
|
9619
12354
|
naro domains verify [host] [--wait] Re-check DNS and print what is missing
|
|
9620
12355
|
exits non-zero while a domain is not live yet
|
|
9621
12356
|
|
|
12357
|
+
The data commands below (db, migration, gen, backend, policy, storage, realtime) take --env <name>:
|
|
12358
|
+
a named environment's data instead of production's, which preview shares
|
|
12359
|
+
(naro-cli 0.21.0 and older ignore --env on them and act on production)
|
|
9622
12360
|
naro db enable Create the project's database (Cloudflare D1)
|
|
9623
12361
|
naro db info Database status, size, table count and limits
|
|
9624
12362
|
and today's writes against the daily write limit (UTC)
|
|
@@ -9749,6 +12487,13 @@ Usage:
|
|
|
9749
12487
|
Remove objects; --recursive removes what is under a folder (asks first):
|
|
9750
12488
|
<path>/ is the folder alone, <path> also an object of that name
|
|
9751
12489
|
|
|
12490
|
+
naro realtime [status] Naro Realtime: on or not, published tables, connections now
|
|
12491
|
+
naro realtime enable Turn Realtime on: the project's hub, bound to its backend
|
|
12492
|
+
naro realtime tables [list] The tables whose Data API writes reach subscribers
|
|
12493
|
+
naro realtime tables add <table>\u2026 Publish tables' changes (within about 10 seconds)
|
|
12494
|
+
naro realtime tables remove <table>\u2026 [--yes]
|
|
12495
|
+
Stop publishing tables' changes (asks first)
|
|
12496
|
+
|
|
9752
12497
|
naro promote <deployment-id> [--to <environment>]
|
|
9753
12498
|
Publish a build to production, or to an environment with --to; it says what it will do, then asks.
|
|
9754
12499
|
A build moving between an environment and another surface is built again from its commit with the
|
|
@@ -9777,7 +12522,8 @@ Global options:
|
|
|
9777
12522
|
--browser <b> Only this browser: chrome, edge, firefox, opera, safari or other (naro analytics, naro insights)
|
|
9778
12523
|
--os <o> Only this operating system: android, ios, linux, macos, windows or other (naro analytics, naro insights)
|
|
9779
12524
|
--to <environment> production (the default) or an environment name: where naro promote publishes the build (naro promote)
|
|
9780
|
-
--env <e> production or preview (naro logs, naro insights: production by default; naro env pull, naro deploy: preview by default, or an environment name)
|
|
12525
|
+
--env <e> production or preview (naro logs, naro insights: production by default; naro env pull, naro deploy: preview by default, or an environment name);
|
|
12526
|
+
production or an environment name for the data commands (naro db, migration, gen, backend, policy, storage, realtime: production by default)
|
|
9781
12527
|
--branch <b> naro environments create, set: the branch whose pushes deploy to that environment. naro deploy: the default
|
|
9782
12528
|
branch of a project this deploy creates or updates (the deploy's own branch follows where it goes)
|
|
9783
12529
|
--no-branch No branch deploys to the environment any more (naro environments set; not with --branch)
|
|
@@ -9967,13 +12713,94 @@ function routeStorage(words) {
|
|
|
9967
12713
|
return null;
|
|
9968
12714
|
}
|
|
9969
12715
|
}
|
|
12716
|
+
function routeRealtime(words) {
|
|
12717
|
+
const [sub, action] = words;
|
|
12718
|
+
switch (sub) {
|
|
12719
|
+
case void 0:
|
|
12720
|
+
case "status":
|
|
12721
|
+
return words.length <= 1 ? { handler: runRealtimeStatus, positionals: [] } : null;
|
|
12722
|
+
case "enable":
|
|
12723
|
+
return words.length === 1 ? { handler: runRealtimeEnable, positionals: [] } : null;
|
|
12724
|
+
case "tables":
|
|
12725
|
+
switch (action) {
|
|
12726
|
+
case void 0:
|
|
12727
|
+
case "list":
|
|
12728
|
+
case "ls":
|
|
12729
|
+
return words.length <= 2 ? { handler: runRealtimeTablesList, positionals: [] } : null;
|
|
12730
|
+
case "add":
|
|
12731
|
+
return {
|
|
12732
|
+
handler: runRealtimeTablesAdd,
|
|
12733
|
+
positionals: words.slice(2)
|
|
12734
|
+
};
|
|
12735
|
+
case "remove":
|
|
12736
|
+
case "rm":
|
|
12737
|
+
return {
|
|
12738
|
+
handler: runRealtimeTablesRemove,
|
|
12739
|
+
positionals: words.slice(2)
|
|
12740
|
+
};
|
|
12741
|
+
default:
|
|
12742
|
+
return null;
|
|
12743
|
+
}
|
|
12744
|
+
default:
|
|
12745
|
+
return null;
|
|
12746
|
+
}
|
|
12747
|
+
}
|
|
9970
12748
|
async function refuseTo() {
|
|
9971
12749
|
throw new NaroError("VALIDATION_FAILED", "--to is only for naro promote.");
|
|
9972
12750
|
}
|
|
12751
|
+
var DATA_COMMANDS = /* @__PURE__ */ new Set([
|
|
12752
|
+
"db",
|
|
12753
|
+
"migration",
|
|
12754
|
+
"gen",
|
|
12755
|
+
"backend",
|
|
12756
|
+
"policy",
|
|
12757
|
+
"storage",
|
|
12758
|
+
"realtime"
|
|
12759
|
+
]);
|
|
12760
|
+
var PROJECT_WIDE_BACKEND = /* @__PURE__ */ new Set(["enable", "provision", "disable"]);
|
|
12761
|
+
function dataEnvRefusal(parsed) {
|
|
12762
|
+
if (parsed.flags.env === void 0 || !DATA_COMMANDS.has(parsed.command ?? ""))
|
|
12763
|
+
return null;
|
|
12764
|
+
const sub = parsed.positionals[0];
|
|
12765
|
+
if (parsed.command === "backend" && sub !== void 0 && PROJECT_WIDE_BACKEND.has(sub) && parsed.flags.env !== "production")
|
|
12766
|
+
return new NaroError(
|
|
12767
|
+
"VALIDATION_FAILED",
|
|
12768
|
+
DATA_ENV_MESSAGES.projectWide(`naro backend ${sub}`)
|
|
12769
|
+
);
|
|
12770
|
+
try {
|
|
12771
|
+
dataEnvironment(parsed.flags);
|
|
12772
|
+
return null;
|
|
12773
|
+
} catch (error) {
|
|
12774
|
+
return error;
|
|
12775
|
+
}
|
|
12776
|
+
}
|
|
12777
|
+
function guidedError(error, parsed) {
|
|
12778
|
+
if (!isNaroError(error) || !DATA_COMMANDS.has(parsed.command ?? ""))
|
|
12779
|
+
return error;
|
|
12780
|
+
if (dataEnvRefusal(parsed) !== null) return error;
|
|
12781
|
+
let environment;
|
|
12782
|
+
try {
|
|
12783
|
+
environment = dataEnvironment(parsed.flags);
|
|
12784
|
+
} catch {
|
|
12785
|
+
return error;
|
|
12786
|
+
}
|
|
12787
|
+
if (environment === null) return error;
|
|
12788
|
+
const usage = error.message.indexOf("Usage: ");
|
|
12789
|
+
error.message = usage === -1 ? forEnvironment(error.message, environment) : `${forEnvironment(error.message.slice(0, usage), environment)}${error.message.slice(usage)}`;
|
|
12790
|
+
return error;
|
|
12791
|
+
}
|
|
9973
12792
|
function route(parsed) {
|
|
9974
12793
|
const routed = routeCommand(parsed);
|
|
9975
12794
|
if (routed && parsed.flags.to !== void 0 && parsed.command !== "promote" && parsed.command !== "rollback")
|
|
9976
12795
|
return { handler: refuseTo, positionals: routed.positionals };
|
|
12796
|
+
const refusal = routed ? dataEnvRefusal(parsed) : null;
|
|
12797
|
+
if (routed && refusal)
|
|
12798
|
+
return {
|
|
12799
|
+
handler: async () => {
|
|
12800
|
+
throw refusal;
|
|
12801
|
+
},
|
|
12802
|
+
positionals: routed.positionals
|
|
12803
|
+
};
|
|
9977
12804
|
return routed;
|
|
9978
12805
|
}
|
|
9979
12806
|
function routeCommand(parsed) {
|
|
@@ -10095,6 +12922,11 @@ function routeCommand(parsed) {
|
|
|
10095
12922
|
handler: runEnvironmentsDelete,
|
|
10096
12923
|
positionals: positionals.slice(1)
|
|
10097
12924
|
};
|
|
12925
|
+
case "provision":
|
|
12926
|
+
return {
|
|
12927
|
+
handler: runEnvironmentsProvision,
|
|
12928
|
+
positionals: positionals.slice(1)
|
|
12929
|
+
};
|
|
10098
12930
|
default:
|
|
10099
12931
|
return null;
|
|
10100
12932
|
}
|
|
@@ -10217,6 +13049,8 @@ function routeCommand(parsed) {
|
|
|
10217
13049
|
}
|
|
10218
13050
|
case "storage":
|
|
10219
13051
|
return routeStorage(positionals);
|
|
13052
|
+
case "realtime":
|
|
13053
|
+
return routeRealtime(positionals);
|
|
10220
13054
|
case "policy":
|
|
10221
13055
|
switch (sub) {
|
|
10222
13056
|
case void 0:
|
|
@@ -10319,7 +13153,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
10319
13153
|
try {
|
|
10320
13154
|
await routed.handler(context);
|
|
10321
13155
|
} catch (error) {
|
|
10322
|
-
context.output.fail(error);
|
|
13156
|
+
context.output.fail(guidedError(error, parsed));
|
|
10323
13157
|
}
|
|
10324
13158
|
}
|
|
10325
13159
|
void main();
|