@3minapi/cli 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +82 -2
- package/dist/bin.js +54 -0
- package/dist/cli.js +241 -0
- package/dist/commands/auth.js +573 -0
- package/dist/commands/common.js +48 -0
- package/dist/commands/dashboard.js +298 -0
- package/dist/commands/delete.js +167 -0
- package/dist/commands/doctor.js +326 -0
- package/dist/commands/guide.js +84 -0
- package/dist/commands/index.js +28 -0
- package/dist/commands/init.js +248 -0
- package/dist/commands/logs.js +154 -0
- package/dist/commands/new.js +109 -0
- package/dist/commands/paths.js +64 -0
- package/dist/commands/reload.js +32 -0
- package/dist/commands/results.js +445 -0
- package/dist/commands/start.js +443 -0
- package/dist/commands/stop.js +68 -0
- package/dist/commands/tasks.js +93 -0
- package/dist/commands/worker.js +178 -0
- package/dist/core/api.js +132 -0
- package/dist/core/config.js +188 -0
- package/dist/core/control-db.js +462 -0
- package/dist/core/credentials.js +75 -0
- package/dist/core/domain/constants.js +121 -0
- package/dist/core/domain/contract.js +102 -0
- package/dist/core/domain/errors.js +57 -0
- package/dist/core/domain/index.js +3 -0
- package/dist/core/domain/types.js +1 -0
- package/dist/core/error-report.js +103 -0
- package/dist/core/host/detach.js +49 -0
- package/dist/core/host/live-connection.js +270 -0
- package/dist/core/host/live-receiver.js +264 -0
- package/dist/core/host/module.js +1 -0
- package/dist/core/host/poller.js +113 -0
- package/dist/core/host/pull-step.js +54 -0
- package/dist/core/host/receiver.js +1 -0
- package/dist/core/host/reload.js +34 -0
- package/dist/core/host/runner-lock.js +245 -0
- package/dist/core/host/session.js +84 -0
- package/dist/core/host/usage.js +20 -0
- package/dist/core/log-read.js +59 -0
- package/dist/core/logger.js +206 -0
- package/dist/core/names.js +27 -0
- package/dist/core/network.js +10 -0
- package/dist/core/open-browser.js +26 -0
- package/dist/core/output.js +68 -0
- package/dist/core/paths.js +26 -0
- package/dist/core/project.js +85 -0
- package/dist/core/prompt.js +140 -0
- package/dist/core/redact.js +45 -0
- package/dist/core/secure-fs.js +50 -0
- package/dist/core/text-file.js +12 -0
- package/dist/core/text.js +27 -0
- package/dist/core/time.js +84 -0
- package/dist/core/update-notice.js +142 -0
- package/dist/core/version.js +16 -0
- package/dist/core/warnings.js +10 -0
- package/dist/help.js +144 -0
- package/dist/modules/dashboard/assets.js +411 -0
- package/dist/modules/dashboard/data.js +565 -0
- package/dist/modules/dashboard/guard.js +75 -0
- package/dist/modules/dashboard/lock.js +93 -0
- package/dist/modules/dashboard/server.js +295 -0
- package/dist/modules/dashboard/views/html.js +114 -0
- package/dist/modules/dashboard/views/layout.js +59 -0
- package/dist/modules/dashboard/views/logs.js +122 -0
- package/dist/modules/dashboard/views/overview.js +316 -0
- package/dist/modules/dashboard/views/runs.js +486 -0
- package/dist/modules/dashboard/views/workers.js +157 -0
- package/dist/modules/workers/builtin/host-identity.js +59 -0
- package/dist/modules/workers/builtin/results-worker.js +37 -0
- package/dist/modules/workers/local-server.js +244 -0
- package/dist/modules/workers/module.js +98 -0
- package/dist/modules/workers/reload-result.js +16 -0
- package/dist/modules/workers/repeat-scheduler.js +165 -0
- package/dist/modules/workers/results/missing-paths.js +66 -0
- package/dist/modules/workers/results/query.js +175 -0
- package/dist/modules/workers/results/reader-input.js +134 -0
- package/dist/modules/workers/results/reader.js +430 -0
- package/dist/modules/workers/results/results-child.js +64 -0
- package/dist/modules/workers/results/shape.js +212 -0
- package/dist/modules/workers/results/store.js +336 -0
- package/dist/modules/workers/results/substitute.js +30 -0
- package/dist/modules/workers/runtime/env.js +43 -0
- package/dist/modules/workers/runtime/error-chain.js +109 -0
- package/dist/modules/workers/runtime/executor.js +289 -0
- package/dist/modules/workers/runtime/inspect.js +53 -0
- package/dist/modules/workers/runtime/protocol.js +1 -0
- package/dist/modules/workers/runtime/resolve.js +30 -0
- package/dist/modules/workers/runtime/schema.js +85 -0
- package/dist/modules/workers/runtime/thread.js +117 -0
- package/dist/modules/workers/start.js +265 -0
- package/dist/modules/workers/sync.js +241 -0
- package/dist/modules/workers/task-runner.js +892 -0
- package/dist/modules/workers/template.js +100 -0
- package/dist/modules/workers/worker-set.js +122 -0
- package/dist/modules/workers/workers.js +66 -0
- package/package.json +48 -5
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export const STOP_CODES = [
|
|
2
|
+
'host_token_invalid',
|
|
3
|
+
'no_subscription',
|
|
4
|
+
'quota_exhausted',
|
|
5
|
+
'cli_outdated'
|
|
6
|
+
];
|
|
7
|
+
export const TASK_CODES = ['task_not_found', 'task_terminal', 'lease_superseded'];
|
|
8
|
+
export const OTHER_CODES = [
|
|
9
|
+
'poll_too_soon',
|
|
10
|
+
'invalid_request',
|
|
11
|
+
'worker_not_found',
|
|
12
|
+
'payload_too_large',
|
|
13
|
+
'unsupported_media_type',
|
|
14
|
+
'service_unavailable'
|
|
15
|
+
];
|
|
16
|
+
export function isStopCode(code) {
|
|
17
|
+
return typeof code === 'string' && STOP_CODES.includes(code);
|
|
18
|
+
}
|
|
19
|
+
export const SYNC_REJECT_CODES = [
|
|
20
|
+
'invalid_worker_name',
|
|
21
|
+
'description_too_long',
|
|
22
|
+
'worker_name_taken'
|
|
23
|
+
];
|
|
24
|
+
export const HOST_TOKEN_PREFIX = 'tm_host_';
|
|
25
|
+
export const PULL_MAX_DEFAULT = 10;
|
|
26
|
+
export const PULL_MAX_LIMIT = 50;
|
|
27
|
+
export const POLL_MIN_INTERVAL_MS = 5_000;
|
|
28
|
+
export const LEASE_MS = 60_000;
|
|
29
|
+
export const MAX_DELIVERIES = 6;
|
|
30
|
+
export const REPORT_MAX_BYTES = 100 * 1024;
|
|
31
|
+
export const STATUS_MESSAGE_MAX_CHARS = 256;
|
|
32
|
+
export const LEASE_EXPIRED_REASON = 'lease expired without acknowledgement';
|
|
33
|
+
export const TASK_ERROR_CODE = -32000;
|
|
34
|
+
export const RECEIVE_MODES = ['live', 'poll'];
|
|
35
|
+
export const LIVE_TOPICS = ['workers', 'host'];
|
|
36
|
+
export const LIVE_RESERVED_TOPICS = ['update', 'config'];
|
|
37
|
+
export function isLiveTopic(topic) {
|
|
38
|
+
return typeof topic === 'string' && LIVE_TOPICS.includes(topic);
|
|
39
|
+
}
|
|
40
|
+
export const LIVE_PING = 'ping';
|
|
41
|
+
export const LIVE_PONG = 'pong';
|
|
42
|
+
export const LIVE_PING_INTERVAL_MS = 30_000;
|
|
43
|
+
export const LIVE_PONG_TIMEOUT_MS = 10_000;
|
|
44
|
+
export const LIVE_CLOSE = {
|
|
45
|
+
REPLACED: 4000,
|
|
46
|
+
CHECK_HOST: 4001
|
|
47
|
+
};
|
|
48
|
+
export const LIVE_CLOSE_REASON = {
|
|
49
|
+
[LIVE_CLOSE.REPLACED]: 'replaced',
|
|
50
|
+
[LIVE_CLOSE.CHECK_HOST]: 'check_host'
|
|
51
|
+
};
|
|
52
|
+
export const LIVE_RECONNECT_FIRST_JITTER_MS = 30_000;
|
|
53
|
+
export const LIVE_RECONNECT_MAX_MS = 300_000;
|
|
54
|
+
export const LIVE_RECONNECT_STABLE_MS = 60_000;
|
|
55
|
+
export const LIVE_RETRY_MS = 60_000;
|
|
56
|
+
export const LIVE_URL_REFRESH_AFTER_MS = 30 * 60_000;
|
|
57
|
+
const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost', '[::1]'];
|
|
58
|
+
export function isTrustedLiveUrl(liveUrl, apiUrl) {
|
|
59
|
+
let live;
|
|
60
|
+
let api;
|
|
61
|
+
try {
|
|
62
|
+
live = new URL(liveUrl);
|
|
63
|
+
api = new URL(apiUrl);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const secure = api.protocol === 'https:' && live.protocol === 'wss:';
|
|
69
|
+
const loopback = api.protocol === 'http:' &&
|
|
70
|
+
live.protocol === 'ws:' &&
|
|
71
|
+
LOOPBACK_HOSTNAMES.includes(api.hostname);
|
|
72
|
+
if (!secure && !loopback)
|
|
73
|
+
return false;
|
|
74
|
+
return (live.hostname === api.hostname &&
|
|
75
|
+
live.port === api.port &&
|
|
76
|
+
live.username === '' &&
|
|
77
|
+
live.password === '');
|
|
78
|
+
}
|
|
79
|
+
export const SYNC_WORKERS_MAX = 200;
|
|
80
|
+
export const WORKER_DESCRIPTION_MAX_CHARS = 500;
|
|
81
|
+
export const PULL_WORKER_NAME = /^[a-z0-9_-]{1,50}$/;
|
|
82
|
+
export const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
|
|
83
|
+
export function isPullWorkerName(name) {
|
|
84
|
+
return (typeof name === 'string' && PULL_WORKER_NAME.test(name) && !WINDOWS_RESERVED_NAME.test(name));
|
|
85
|
+
}
|
|
86
|
+
export const RESERVED_NAME_PREFIX = '_';
|
|
87
|
+
export const BUILTIN_RESULTS_PREFIX = '_results_';
|
|
88
|
+
export const BUILTIN_HOST_SUFFIX_CHARS = 8;
|
|
89
|
+
export function builtinResultsName(hostId) {
|
|
90
|
+
return (BUILTIN_RESULTS_PREFIX +
|
|
91
|
+
hostId.replace(/-/g, '').toLowerCase().slice(-BUILTIN_HOST_SUFFIX_CHARS));
|
|
92
|
+
}
|
|
93
|
+
export function isReservedWorkerName(name) {
|
|
94
|
+
return name.startsWith(RESERVED_NAME_PREFIX);
|
|
95
|
+
}
|
|
96
|
+
export function isSyncableWorkerName(name, hostId) {
|
|
97
|
+
if (!isPullWorkerName(name))
|
|
98
|
+
return false;
|
|
99
|
+
return !isReservedWorkerName(name) || name === builtinResultsName(hostId);
|
|
100
|
+
}
|
|
101
|
+
export const REPEAT_INTERVAL_MIN_S = 10;
|
|
102
|
+
export const REPEAT_INTERVAL_MAX_S = 604_800;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const ExitCode = {
|
|
2
|
+
Ok: 0,
|
|
3
|
+
Failure: 1,
|
|
4
|
+
Usage: 2,
|
|
5
|
+
Environment: 3,
|
|
6
|
+
AuthStopped: 4,
|
|
7
|
+
QuotaStopped: 5,
|
|
8
|
+
UpdateRequired: 6,
|
|
9
|
+
Interrupted: 130
|
|
10
|
+
};
|
|
11
|
+
export class CliError extends Error {
|
|
12
|
+
exitCode;
|
|
13
|
+
constructor(message, exitCode = ExitCode.Failure) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'CliError';
|
|
16
|
+
this.exitCode = exitCode;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class UsageError extends CliError {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message, ExitCode.Usage);
|
|
22
|
+
this.name = 'UsageError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class ConfigError extends CliError {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message, ExitCode.Environment);
|
|
28
|
+
this.name = 'ConfigError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class NewerSchemaError extends ConfigError {
|
|
32
|
+
constructor(file, found, known) {
|
|
33
|
+
super(`${file} was created by a newer version of 3minapi (schema ${found}, this CLI knows ${known}). ` +
|
|
34
|
+
'Update 3minapi.');
|
|
35
|
+
this.name = 'NewerSchemaError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function stopExitCode(reason) {
|
|
39
|
+
switch (reason) {
|
|
40
|
+
case 'quota_exhausted':
|
|
41
|
+
return ExitCode.QuotaStopped;
|
|
42
|
+
case 'cli_outdated':
|
|
43
|
+
return ExitCode.UpdateRequired;
|
|
44
|
+
default:
|
|
45
|
+
return ExitCode.AuthStopped;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export class StopError extends CliError {
|
|
49
|
+
reason;
|
|
50
|
+
resetAt;
|
|
51
|
+
constructor(reason, message, resetAt) {
|
|
52
|
+
super(message, stopExitCode(reason));
|
|
53
|
+
this.name = 'StopError';
|
|
54
|
+
this.reason = reason;
|
|
55
|
+
this.resetAt = resetAt;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ApiError } from "./api.js";
|
|
2
|
+
import { BIN, CliError, ConfigError, ExitCode, StopError, UsageError } from "./domain/index.js";
|
|
3
|
+
const NETWORK_HINT = `Check the network, or run '${BIN} doctor' (it checks the API URL and proxy settings).`;
|
|
4
|
+
export function describeError(err) {
|
|
5
|
+
if (err instanceof StopError) {
|
|
6
|
+
return {
|
|
7
|
+
kind: 'stop',
|
|
8
|
+
message: err.message,
|
|
9
|
+
exitCode: err.exitCode,
|
|
10
|
+
code: err.reason,
|
|
11
|
+
...(err.resetAt ? { resetAt: err.resetAt } : {})
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
if (err instanceof ApiError) {
|
|
15
|
+
if (err.status === null) {
|
|
16
|
+
return {
|
|
17
|
+
kind: 'network',
|
|
18
|
+
message: `${err.message}\n${NETWORK_HINT}`,
|
|
19
|
+
exitCode: err.exitCode
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
kind: 'server',
|
|
24
|
+
message: err.message,
|
|
25
|
+
exitCode: err.exitCode,
|
|
26
|
+
...(err.code ? { code: err.code } : {})
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (err instanceof UsageError)
|
|
30
|
+
return { kind: 'usage', message: err.message, exitCode: err.exitCode };
|
|
31
|
+
if (err instanceof ConfigError)
|
|
32
|
+
return { kind: 'config', message: err.message, exitCode: err.exitCode };
|
|
33
|
+
if (err instanceof CliError)
|
|
34
|
+
return { kind: 'failure', message: err.message, exitCode: err.exitCode };
|
|
35
|
+
const fs = fsFailure(err);
|
|
36
|
+
if (fs)
|
|
37
|
+
return fs;
|
|
38
|
+
const e = err;
|
|
39
|
+
return {
|
|
40
|
+
kind: 'bug',
|
|
41
|
+
message: `unexpected error: ${firstLine(e?.message ?? String(err))}`,
|
|
42
|
+
exitCode: ExitCode.Failure,
|
|
43
|
+
stack: e?.stack ?? String(err)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const ERRNO = {
|
|
47
|
+
EACCES: 'permission denied — check the owner and permissions of that folder or file',
|
|
48
|
+
EPERM: 'operation not permitted — check the permissions, or close a program holding it (antivirus, indexer)',
|
|
49
|
+
EROFS: 'the file system is read-only — run it in a folder you can write to',
|
|
50
|
+
ENOSPC: `the disk is full — free some space (logs: logMaxBytes, results: '${BIN} worker results prune', history: '${BIN} tasks prune')`,
|
|
51
|
+
EDQUOT: 'the disk quota is used up — free some space',
|
|
52
|
+
EBUSY: 'it is in use by another program — close it and try again',
|
|
53
|
+
EMFILE: 'too many open files — close some programs and try again',
|
|
54
|
+
ENFILE: 'too many open files — close some programs and try again',
|
|
55
|
+
ENOTDIR: 'a part of the path is a file, not a folder — check the path',
|
|
56
|
+
EISDIR: 'it is a folder, not a file — check the path',
|
|
57
|
+
ENOENT: 'it does not exist — check the path',
|
|
58
|
+
EEXIST: 'it already exists — check the path'
|
|
59
|
+
};
|
|
60
|
+
const SQLITE = {
|
|
61
|
+
5: ['SQLITE_BUSY', 'the database is locked by another program — close it and try again'],
|
|
62
|
+
8: ['SQLITE_READONLY', 'the database file is read-only — check its permissions'],
|
|
63
|
+
13: ['SQLITE_FULL', 'the disk is full — free some space'],
|
|
64
|
+
14: [
|
|
65
|
+
'SQLITE_CANTOPEN',
|
|
66
|
+
'the database file cannot be opened — check the folder and its permissions'
|
|
67
|
+
]
|
|
68
|
+
};
|
|
69
|
+
function fsFailure(err) {
|
|
70
|
+
const e = err;
|
|
71
|
+
if (!e || typeof e !== 'object')
|
|
72
|
+
return null;
|
|
73
|
+
if (e.code === 'ERR_SQLITE_ERROR' && typeof e.errcode === 'number') {
|
|
74
|
+
const known = SQLITE[e.errcode & 0xff];
|
|
75
|
+
if (!known)
|
|
76
|
+
return null;
|
|
77
|
+
return {
|
|
78
|
+
kind: 'fs',
|
|
79
|
+
message: `${firstLine(e.message)}: ${known[1]}`,
|
|
80
|
+
exitCode: ExitCode.Environment,
|
|
81
|
+
code: known[0]
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (typeof e.code !== 'string' || typeof e.path !== 'string')
|
|
85
|
+
return null;
|
|
86
|
+
const reason = ERRNO[e.code];
|
|
87
|
+
if (!reason)
|
|
88
|
+
return null;
|
|
89
|
+
const action = typeof e.syscall === 'string' ? e.syscall : 'use';
|
|
90
|
+
return {
|
|
91
|
+
kind: 'fs',
|
|
92
|
+
message: `cannot ${action} ${e.path}: ${reason}`,
|
|
93
|
+
exitCode: ExitCode.Environment,
|
|
94
|
+
code: e.code
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function firstLine(text) {
|
|
98
|
+
return text.split('\n')[0];
|
|
99
|
+
}
|
|
100
|
+
export function errorDocument(report) {
|
|
101
|
+
const { stack: _stack, ...error } = report;
|
|
102
|
+
return { error };
|
|
103
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, openSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { ENV, OUT_FILE_MAX_BYTES, SECRET_FILE_MODE } from "../domain/index.js";
|
|
6
|
+
import { ensurePrivateDir } from "../secure-fs.js";
|
|
7
|
+
export function spawnDetached(opts) {
|
|
8
|
+
ensurePrivateDir(path.dirname(opts.outFile));
|
|
9
|
+
rotateIfLarge(opts.outFile);
|
|
10
|
+
const fd = openSync(opts.outFile, 'a', SECRET_FILE_MODE);
|
|
11
|
+
try {
|
|
12
|
+
return spawn(process.execPath, [...proxyNodeArgs(), binPath(), ...opts.args], {
|
|
13
|
+
cwd: opts.cwd,
|
|
14
|
+
env: { ...opts.env, [ENV.detached]: '1' },
|
|
15
|
+
detached: true,
|
|
16
|
+
stdio: ['ignore', fd, fd],
|
|
17
|
+
windowsHide: true
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
closeSync(fd);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function rotateIfLarge(file) {
|
|
25
|
+
try {
|
|
26
|
+
const size = statSync(file, { throwIfNoEntry: false })?.size ?? 0;
|
|
27
|
+
if (size <= OUT_FILE_MAX_BYTES)
|
|
28
|
+
return;
|
|
29
|
+
rmSync(`${file}.1`, { force: true });
|
|
30
|
+
renameSync(file, `${file}.1`);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function binPath() {
|
|
36
|
+
return fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../../bin.ts' : '../../bin.js', import.meta.url));
|
|
37
|
+
}
|
|
38
|
+
export function outputTail(file) {
|
|
39
|
+
try {
|
|
40
|
+
const text = readFileSync(file, 'utf8').trimEnd().split('\n').slice(-10).join('\n');
|
|
41
|
+
return text ? `Last output (${file}):\n${text}` : `See ${file}`;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return `See ${file}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function proxyNodeArgs() {
|
|
48
|
+
return process.execArgv.filter((a) => a === '--use-env-proxy');
|
|
49
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { LIVE_CLOSE, LIVE_CONNECT_TIMEOUT_MS, LIVE_PING, LIVE_PING_INTERVAL_MS, LIVE_PONG, LIVE_PONG_TIMEOUT_MS, LIVE_RECONNECT_FIRST_JITTER_MS, LIVE_RECONNECT_MAX_MS, LIVE_RECONNECT_STABLE_MS, LIVE_URL_REFRESH_AFTER_MS } from "../domain/index.js";
|
|
2
|
+
import { stripControl } from "../text.js";
|
|
3
|
+
export const LIVE_TIMING = {
|
|
4
|
+
pingIntervalMs: LIVE_PING_INTERVAL_MS,
|
|
5
|
+
pongTimeoutMs: LIVE_PONG_TIMEOUT_MS,
|
|
6
|
+
connectTimeoutMs: LIVE_CONNECT_TIMEOUT_MS,
|
|
7
|
+
firstJitterMs: LIVE_RECONNECT_FIRST_JITTER_MS,
|
|
8
|
+
maxBackoffMs: LIVE_RECONNECT_MAX_MS,
|
|
9
|
+
stableMs: LIVE_RECONNECT_STABLE_MS,
|
|
10
|
+
refreshAfterMs: LIVE_URL_REFRESH_AFTER_MS
|
|
11
|
+
};
|
|
12
|
+
const WebSocketWithHeaders = WebSocket;
|
|
13
|
+
export class LiveConnection {
|
|
14
|
+
opts;
|
|
15
|
+
timing;
|
|
16
|
+
now;
|
|
17
|
+
random;
|
|
18
|
+
url;
|
|
19
|
+
ws = null;
|
|
20
|
+
gen = 0;
|
|
21
|
+
closed = false;
|
|
22
|
+
attempt = 0;
|
|
23
|
+
attempts = 0;
|
|
24
|
+
openedAt = null;
|
|
25
|
+
failingSince = null;
|
|
26
|
+
lastRefreshAt = Number.NEGATIVE_INFINITY;
|
|
27
|
+
pingTimer = null;
|
|
28
|
+
pongTimer = null;
|
|
29
|
+
connectTimer = null;
|
|
30
|
+
reconnectTimer = null;
|
|
31
|
+
state = {
|
|
32
|
+
connected: false,
|
|
33
|
+
since: null,
|
|
34
|
+
reconnects: 0,
|
|
35
|
+
everConnected: false,
|
|
36
|
+
lastError: null
|
|
37
|
+
};
|
|
38
|
+
constructor(opts) {
|
|
39
|
+
this.opts = opts;
|
|
40
|
+
this.timing = { ...LIVE_TIMING, ...opts.timing };
|
|
41
|
+
this.now = opts.now ?? Date.now;
|
|
42
|
+
this.random = opts.random ?? Math.random;
|
|
43
|
+
this.url = opts.url;
|
|
44
|
+
}
|
|
45
|
+
get connected() {
|
|
46
|
+
return this.state.connected;
|
|
47
|
+
}
|
|
48
|
+
get liveState() {
|
|
49
|
+
return { ...this.state };
|
|
50
|
+
}
|
|
51
|
+
start() {
|
|
52
|
+
this.connect();
|
|
53
|
+
}
|
|
54
|
+
setUrl(url) {
|
|
55
|
+
if (url === this.url)
|
|
56
|
+
return;
|
|
57
|
+
this.url = url;
|
|
58
|
+
this.opts.logger.info('live url updated');
|
|
59
|
+
}
|
|
60
|
+
close() {
|
|
61
|
+
this.closed = true;
|
|
62
|
+
this.gen++;
|
|
63
|
+
this.clearTimers();
|
|
64
|
+
if (this.reconnectTimer)
|
|
65
|
+
clearTimeout(this.reconnectTimer);
|
|
66
|
+
this.reconnectTimer = null;
|
|
67
|
+
this.abandon(1000);
|
|
68
|
+
}
|
|
69
|
+
connect() {
|
|
70
|
+
if (this.closed)
|
|
71
|
+
return;
|
|
72
|
+
const gen = ++this.gen;
|
|
73
|
+
if (this.attempts++ > 0)
|
|
74
|
+
this.setState({ reconnects: this.state.reconnects + 1 });
|
|
75
|
+
let ws;
|
|
76
|
+
try {
|
|
77
|
+
ws = new WebSocketWithHeaders(this.url, {
|
|
78
|
+
headers: { authorization: `Bearer ${this.opts.token}` }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
this.opts.logger.warn('cannot open the live connection', { message: err.message });
|
|
83
|
+
void this.lost('not_opened', 'invalid live url');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
ws.binaryType = 'arraybuffer';
|
|
87
|
+
this.ws = ws;
|
|
88
|
+
let opened = false;
|
|
89
|
+
this.connectTimer = setTimeout(() => {
|
|
90
|
+
if (gen !== this.gen || opened)
|
|
91
|
+
return;
|
|
92
|
+
this.abandon();
|
|
93
|
+
void this.lost('not_opened', 'timed out');
|
|
94
|
+
}, this.timing.connectTimeoutMs);
|
|
95
|
+
ws.addEventListener('open', () => {
|
|
96
|
+
if (gen !== this.gen)
|
|
97
|
+
return;
|
|
98
|
+
opened = true;
|
|
99
|
+
this.clearTimer('connectTimer');
|
|
100
|
+
this.openedAt = this.now();
|
|
101
|
+
this.failingSince = null;
|
|
102
|
+
this.setState({
|
|
103
|
+
connected: true,
|
|
104
|
+
since: new Date(this.openedAt).toISOString(),
|
|
105
|
+
everConnected: true,
|
|
106
|
+
lastError: null
|
|
107
|
+
});
|
|
108
|
+
this.opts.logger.info('live connected');
|
|
109
|
+
this.pingTimer = setInterval(() => this.ping(gen), this.timing.pingIntervalMs);
|
|
110
|
+
this.pingTimer.unref();
|
|
111
|
+
this.opts.onOpen();
|
|
112
|
+
});
|
|
113
|
+
ws.addEventListener('message', (ev) => {
|
|
114
|
+
if (gen === this.gen)
|
|
115
|
+
this.onMessage(ev.data);
|
|
116
|
+
});
|
|
117
|
+
ws.addEventListener('error', () => {
|
|
118
|
+
if (gen === this.gen)
|
|
119
|
+
this.opts.logger.debug('live socket error');
|
|
120
|
+
});
|
|
121
|
+
ws.addEventListener('close', (ev) => {
|
|
122
|
+
if (gen !== this.gen)
|
|
123
|
+
return;
|
|
124
|
+
this.ws = null;
|
|
125
|
+
if (!opened)
|
|
126
|
+
return void this.lost('not_opened', null);
|
|
127
|
+
const reason = stripControl(String(ev.reason ?? '')).slice(0, 123);
|
|
128
|
+
this.opts.logger.info('live disconnected', { code: ev.code, reason });
|
|
129
|
+
if (ev.code === LIVE_CLOSE.REPLACED)
|
|
130
|
+
return void this.lost('replaced', null);
|
|
131
|
+
if (ev.code === LIVE_CLOSE.CHECK_HOST)
|
|
132
|
+
return void this.lost('check_host', null);
|
|
133
|
+
void this.lost('dropped', `closed (${ev.code})`);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
ping(gen) {
|
|
137
|
+
if (gen !== this.gen || !this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
138
|
+
return;
|
|
139
|
+
this.ws.send(LIVE_PING);
|
|
140
|
+
if (this.pongTimer)
|
|
141
|
+
return;
|
|
142
|
+
this.pongTimer = setTimeout(() => {
|
|
143
|
+
if (gen !== this.gen)
|
|
144
|
+
return;
|
|
145
|
+
this.opts.logger.warn('no pong from the live connection; reconnecting');
|
|
146
|
+
this.abandon();
|
|
147
|
+
void this.lost('no_pong', null);
|
|
148
|
+
}, this.timing.pongTimeoutMs);
|
|
149
|
+
this.pongTimer.unref();
|
|
150
|
+
}
|
|
151
|
+
onMessage(data) {
|
|
152
|
+
if (typeof data !== 'string') {
|
|
153
|
+
this.opts.logger.debug('ignored a non-text live frame');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (data === LIVE_PONG) {
|
|
157
|
+
this.clearTimer('pongTimer');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let parsed;
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(data);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
this.opts.logger.debug('ignored a live frame that is not JSON');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const topic = parsed?.topic;
|
|
169
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed) || typeof topic !== 'string') {
|
|
170
|
+
this.opts.logger.debug('ignored a live frame without a topic');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.opts.logger.debug('live signal', { topic: stripControl(topic).slice(0, 32) });
|
|
174
|
+
this.opts.onSignal(topic);
|
|
175
|
+
}
|
|
176
|
+
async lost(kind, detail) {
|
|
177
|
+
this.clearTimers();
|
|
178
|
+
const now = this.now();
|
|
179
|
+
const upFor = this.openedAt === null ? 0 : now - this.openedAt;
|
|
180
|
+
this.openedAt = null;
|
|
181
|
+
this.setState({
|
|
182
|
+
connected: false,
|
|
183
|
+
since: this.state.connected || this.state.since === null
|
|
184
|
+
? new Date(now).toISOString()
|
|
185
|
+
: this.state.since,
|
|
186
|
+
lastError: describe(kind, detail)
|
|
187
|
+
});
|
|
188
|
+
if (this.closed)
|
|
189
|
+
return;
|
|
190
|
+
if (upFor >= this.timing.stableMs && kind !== 'check_host')
|
|
191
|
+
this.attempt = 0;
|
|
192
|
+
if (kind === 'not_opened' && this.state.everConnected) {
|
|
193
|
+
this.failingSince ??= now;
|
|
194
|
+
if (now - this.failingSince >= this.timing.refreshAfterMs &&
|
|
195
|
+
now - this.lastRefreshAt >= this.timing.refreshAfterMs) {
|
|
196
|
+
this.lastRefreshAt = now;
|
|
197
|
+
await this.call(() => this.opts.onRefreshUrl());
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (kind === 'check_host')
|
|
201
|
+
await this.call(() => this.opts.onCheckHost());
|
|
202
|
+
if (this.closed)
|
|
203
|
+
return;
|
|
204
|
+
const delay = kind === 'replaced'
|
|
205
|
+
? this.timing.maxBackoffMs
|
|
206
|
+
: this.random() *
|
|
207
|
+
Math.min(this.timing.maxBackoffMs, this.timing.firstJitterMs * 2 ** this.attempt);
|
|
208
|
+
this.attempt++;
|
|
209
|
+
this.opts.logger.info('live reconnecting', { in_ms: Math.round(delay), kind });
|
|
210
|
+
this.reconnectTimer = setTimeout(() => {
|
|
211
|
+
this.reconnectTimer = null;
|
|
212
|
+
this.connect();
|
|
213
|
+
}, delay);
|
|
214
|
+
}
|
|
215
|
+
async call(fn) {
|
|
216
|
+
try {
|
|
217
|
+
await fn();
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
this.opts.logger.warn('live callback failed', { message: err.message });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
abandon(code) {
|
|
224
|
+
const ws = this.ws;
|
|
225
|
+
this.ws = null;
|
|
226
|
+
this.gen++;
|
|
227
|
+
if (!ws)
|
|
228
|
+
return;
|
|
229
|
+
try {
|
|
230
|
+
ws.close(code);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
clearTimers() {
|
|
236
|
+
this.clearTimer('pingTimer');
|
|
237
|
+
this.clearTimer('pongTimer');
|
|
238
|
+
this.clearTimer('connectTimer');
|
|
239
|
+
}
|
|
240
|
+
clearTimer(name) {
|
|
241
|
+
const t = this[name];
|
|
242
|
+
if (!t)
|
|
243
|
+
return;
|
|
244
|
+
clearTimeout(t);
|
|
245
|
+
this[name] = null;
|
|
246
|
+
}
|
|
247
|
+
setState(patch) {
|
|
248
|
+
const next = { ...this.state, ...patch };
|
|
249
|
+
if (JSON.stringify(next) === JSON.stringify(this.state))
|
|
250
|
+
return;
|
|
251
|
+
this.state = next;
|
|
252
|
+
this.opts.onState?.({ ...next });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function describe(kind, detail) {
|
|
256
|
+
switch (kind) {
|
|
257
|
+
case 'replaced':
|
|
258
|
+
return 'replaced by a newer connection for this host (another 3minapi start?)';
|
|
259
|
+
case 'check_host':
|
|
260
|
+
return 'closed by the server to check this host';
|
|
261
|
+
case 'no_pong':
|
|
262
|
+
return 'no reply to keepalive (connection lost or buffered by a proxy)';
|
|
263
|
+
case 'not_opened':
|
|
264
|
+
return detail === 'timed out'
|
|
265
|
+
? 'could not connect: timed out'
|
|
266
|
+
: 'could not connect (network, proxy, firewall or server — the cause is not visible)';
|
|
267
|
+
case 'dropped':
|
|
268
|
+
return `connection lost: ${detail ?? 'closed'}`;
|
|
269
|
+
}
|
|
270
|
+
}
|