@dr-co/dsh-node-client 0.0.0-stage → 0.0.59
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 +276 -2
- package/bin/dshnc.mjs +337 -0
- package/lib/api.mjs +177 -0
- package/lib/approver.mjs +138 -0
- package/lib/ask.mjs +17 -0
- package/lib/autostart.mjs +311 -0
- package/lib/bash.mjs +168 -0
- package/lib/config.mjs +103 -0
- package/lib/i18n.mjs +511 -0
- package/lib/jobs.mjs +203 -0
- package/lib/onboard.mjs +199 -0
- package/lib/ping-policy.mjs +49 -0
- package/lib/plugins/files.mjs +327 -0
- package/lib/plugins/index.mjs +116 -0
- package/lib/plugins/job.mjs +111 -0
- package/lib/plugins/session.mjs +192 -0
- package/lib/plugins/shell.mjs +71 -0
- package/lib/presence.mjs +171 -0
- package/lib/runner.mjs +600 -0
- package/lib/sandbox.mjs +152 -0
- package/lib/sessions.mjs +148 -0
- package/lib/shell.mjs +154 -0
- package/lib/store.mjs +436 -0
- package/package.json +45 -4
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Встроенный плагин `session`: переключение рабочего каталога сессии.
|
|
3
|
+
*
|
|
4
|
+
* Это не внешний инструмент, а реконфиг сессии: модель говорит «дальше работаем
|
|
5
|
+
* в каталоге foobar», и все последующие команды этой сессии стартуют там. Нужно
|
|
6
|
+
* ровно для удобства — чтобы модель не повторяла `cd` в каждой команде.
|
|
7
|
+
*
|
|
8
|
+
* Каталог проверяется по списку `roots` из настроек узла: вне их переключение
|
|
9
|
+
* запрещено, иначе песочница потеряла бы смысл.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-node-client/plugins/session
|
|
12
|
+
*/
|
|
13
|
+
import { CLIENT_VERSION } from '../api.mjs'
|
|
14
|
+
import { preciseNow } from '../shell.mjs'
|
|
15
|
+
import { t } from '../i18n.mjs'
|
|
16
|
+
|
|
17
|
+
export function api () {
|
|
18
|
+
return {
|
|
19
|
+
name: 'session',
|
|
20
|
+
// контракт изменился (chdir стал строгим, добавился mkdir) — версия обязана
|
|
21
|
+
// вырасти, иначе узлы с разными схемами слипнутся в одну запись
|
|
22
|
+
// контракт снова вырос: добавлен метод ping
|
|
23
|
+
version: '4',
|
|
24
|
+
description: t('session.api.description'),
|
|
25
|
+
methods: [
|
|
26
|
+
{
|
|
27
|
+
method: 'chdir',
|
|
28
|
+
description: t('session.chdir.description'),
|
|
29
|
+
args: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
path: { type: 'string', description: t('session.chdir.arg.path') },
|
|
33
|
+
},
|
|
34
|
+
required: ['path'],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
method: 'mkdir',
|
|
40
|
+
description: t('session.mkdir.description'),
|
|
41
|
+
args: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
path: { type: 'string', description: t('session.mkdir.arg.path') },
|
|
45
|
+
mode: { type: 'string', description: t('session.mkdir.arg.mode') },
|
|
46
|
+
},
|
|
47
|
+
required: ['path'],
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
method: 'ping',
|
|
53
|
+
description: t('session.ping.description'),
|
|
54
|
+
args: { type: 'object', properties: {}, additionalProperties: false },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
method: 'approve',
|
|
58
|
+
description: t('session.approve.description'),
|
|
59
|
+
args: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
id: { type: 'string', description: t('session.approve.arg.id') },
|
|
63
|
+
text: { type: 'string', description: t('session.approve.arg.text') },
|
|
64
|
+
mode: { type: 'string', description: t('session.approve.arg.mode') },
|
|
65
|
+
},
|
|
66
|
+
required: ['id', 'text'],
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
method: 'cwd',
|
|
72
|
+
description: t('session.cwd.description'),
|
|
73
|
+
args: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
session: { type: 'string', description: t('session.arg.session') },
|
|
77
|
+
},
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Выполнить команду плагина.
|
|
87
|
+
* @param {{method: string, args: object}} command
|
|
88
|
+
* @param {{session: string, sessions?: object, roots?: string[], workdir?: string}} ctx
|
|
89
|
+
*/
|
|
90
|
+
export async function handle (command, ctx = {}) {
|
|
91
|
+
// Проверка связи не зависит ни от сессии, ни от каталогов: отвечаем раньше
|
|
92
|
+
// любых проверок, иначе `ping` на узле без сессий звучал бы как поломка.
|
|
93
|
+
if (command.method === 'ping') {
|
|
94
|
+
// Возвращаем отметку сервера обратно и добавляем свою: по этой паре
|
|
95
|
+
// сервер считает и время отклика, и разницу часов между машинами.
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
results: [{
|
|
99
|
+
type: 'text',
|
|
100
|
+
text: JSON.stringify({
|
|
101
|
+
pong: preciseNow(),
|
|
102
|
+
// Поле объявлено, но не заполняется: узел не знает, сколько занял
|
|
103
|
+
// раундтрип по часам сервера. Сервер подставит значение сам.
|
|
104
|
+
rttMs: null,
|
|
105
|
+
sentAt: command.issuedAt ?? null,
|
|
106
|
+
upSeconds: Math.round(process.uptime()),
|
|
107
|
+
platform: process.platform,
|
|
108
|
+
version: CLIENT_VERSION,
|
|
109
|
+
}),
|
|
110
|
+
}],
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const session = command.args?.session ?? ctx.session
|
|
115
|
+
const sessions = ctx.sessions
|
|
116
|
+
if (!sessions) {
|
|
117
|
+
return { ok: false, code: 'session_unavailable', message: t('session.noStore'), results: null }
|
|
118
|
+
}
|
|
119
|
+
if (!session) {
|
|
120
|
+
return { ok: false, code: 'bad_session', message: t('session.noSession'), results: null }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (command.method === 'cwd') {
|
|
124
|
+
try {
|
|
125
|
+
const dir = sessions.cwd(session, { workdir: ctx.workdir, roots: ctx.roots })
|
|
126
|
+
return { ok: true, code: 'cwd', message: dir, results: [{ type: 'text', text: dir }] }
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return { ok: false, code: error.code ?? 'workdir_invalid', message: error.message, results: null }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Апрув — внутренняя команда: она не ходит через `bash`, поэтому песочница её
|
|
133
|
+
// не касается и отказать ей некому. Спрашиваем человека инструментом из
|
|
134
|
+
// настроек узла; нет инструмента — честная ошибка, а не тишина.
|
|
135
|
+
if (command.method === 'approve') {
|
|
136
|
+
const tool = ctx.approver?.command
|
|
137
|
+
if (!tool) {
|
|
138
|
+
return { ok: false, code: 'no_local_approver', message: t('session.approve.noTool'), results: null }
|
|
139
|
+
}
|
|
140
|
+
const prompt = String(command.args.text ?? '')
|
|
141
|
+
// Идентификаторы для инструмента вопроса: по ним диалог может запомнить
|
|
142
|
+
// «отвечать да до конца вопроса» и «до конца сессии».
|
|
143
|
+
const payload = JSON.stringify({
|
|
144
|
+
id: command.args.id,
|
|
145
|
+
mode: command.args.mode ?? null,
|
|
146
|
+
text: prompt,
|
|
147
|
+
session: command.session ?? null,
|
|
148
|
+
question: command.question ?? null,
|
|
149
|
+
})
|
|
150
|
+
const started = Date.now()
|
|
151
|
+
try {
|
|
152
|
+
const verdict = await ctx.approver.run({ tool, prompt, payload, signal: ctx.signal, timeout: command.timeout })
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
code: verdict ? 'approved' : 'cancelled',
|
|
156
|
+
message: verdict ? 'ok' : 'cancel',
|
|
157
|
+
results: [{ type: 'text', text: JSON.stringify({ approved: Boolean(verdict), seconds: Math.round((Date.now() - started) / 1000) }) }],
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return { ok: false, code: error.code ?? 'approve_failed', message: error.message, results: null }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (command.method === 'mkdir') {
|
|
165
|
+
try {
|
|
166
|
+
const mode = typeof command.args.mode === 'string' ? Number.parseInt(command.args.mode, 8) : undefined
|
|
167
|
+
const result = sessions.mkdir(session, command.args.path, {
|
|
168
|
+
roots: ctx.roots,
|
|
169
|
+
mode: Number.isFinite(mode) ? mode : undefined,
|
|
170
|
+
})
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
code: 'mkdir',
|
|
174
|
+
message: result.dir,
|
|
175
|
+
results: [{ type: 'text', text: `${result.dir}${result.created ? t('session.created') : t('session.exists')}` }],
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return { ok: false, code: error.code ?? 'workdir_denied', message: error.message, results: null }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (command.method !== 'chdir') {
|
|
183
|
+
return { ok: false, code: 'unknown_method', message: `session: unknown method ${command.method}`, results: null }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const result = sessions.chdir(session, command.args.path, { roots: ctx.roots })
|
|
188
|
+
return { ok: true, code: 'chdir', message: result.dir, results: [{ type: 'text', text: result.dir }] }
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return { ok: false, code: error.code ?? 'workdir_denied', message: error.message, results: null }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Плагин `shell`: выполнить команду на узле и вернуть результат.
|
|
3
|
+
*
|
|
4
|
+
* Раньше API назывался `bash`, что было правдой только на POSIX: на Windows та
|
|
5
|
+
* же команда уходит в PowerShell. Имя `shell` честнее, а старое имя осталось
|
|
6
|
+
* отдельным плагином-алиасом, чтобы не ломать уже настроенные серверы.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-node-client/plugins/shell
|
|
9
|
+
*/
|
|
10
|
+
import { runBash } from '../bash.mjs'
|
|
11
|
+
import { t } from '../i18n.mjs'
|
|
12
|
+
|
|
13
|
+
/** Общее описание метода: один и тот же exec для shell и для старого bash. */
|
|
14
|
+
export function execMethod () {
|
|
15
|
+
return {
|
|
16
|
+
method: 'exec',
|
|
17
|
+
description: t('bash.exec.description'),
|
|
18
|
+
args: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
command: { type: 'string', description: t('bash.arg.command') },
|
|
22
|
+
cwd: { type: 'string', description: t('bash.arg.cwd') },
|
|
23
|
+
timeout: { type: 'number', description: t('bash.arg.timeout') },
|
|
24
|
+
stdin: { type: 'string', description: t('bash.arg.stdin') },
|
|
25
|
+
},
|
|
26
|
+
required: ['command'],
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function api () {
|
|
33
|
+
return {
|
|
34
|
+
name: 'shell',
|
|
35
|
+
version: '1',
|
|
36
|
+
description: t('bash.api.description'),
|
|
37
|
+
methods: [execMethod()],
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Исполнить команду.
|
|
43
|
+
* @param {{method: string, args: object, timeout?: number}} command
|
|
44
|
+
* @param {{shell?: string, signal?: AbortSignal}} ctx
|
|
45
|
+
*/
|
|
46
|
+
export async function handle (command, ctx = {}) {
|
|
47
|
+
if (command.method !== 'exec') {
|
|
48
|
+
return { ok: false, code: 'unknown_method', message: `shell: unknown method ${command.method}` }
|
|
49
|
+
}
|
|
50
|
+
const args = command.args ?? {}
|
|
51
|
+
// Приоритет: явный cwd в аргументах → каталог сессии → ничего (наследуем).
|
|
52
|
+
let cwd = args.cwd
|
|
53
|
+
if (!cwd && ctx.sessions && ctx.session) {
|
|
54
|
+
try {
|
|
55
|
+
cwd = ctx.sessions.cwd(ctx.session, { workdir: ctx.workdir, roots: ctx.roots })
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// каталог сессии не сделали — лучше сказать прямо, чем исполнить команду
|
|
58
|
+
// в чужом каталоге и сделать вид, что так и надо
|
|
59
|
+
return { ok: false, code: error.code ?? 'bad_session', message: error.message }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return runBash({
|
|
63
|
+
command: args.command,
|
|
64
|
+
cwd,
|
|
65
|
+
timeout: command.timeout ?? args.timeout,
|
|
66
|
+
stdin: args.stdin,
|
|
67
|
+
sandbox: ctx.sandbox ?? null,
|
|
68
|
+
shell: ctx.shell,
|
|
69
|
+
signal: ctx.signal,
|
|
70
|
+
})
|
|
71
|
+
}
|
package/lib/presence.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Присутствие клиента: WebSocket с `hello`, `alive` и кадром `state`.
|
|
3
|
+
*
|
|
4
|
+
* Токен едет подпротоколом — заголовком `Sec-WebSocket-Protocol`, а не в URL.
|
|
5
|
+
* Клиент не обязан держать соединение: без него он работает опросом, просто
|
|
6
|
+
* узнаёт о работе с задержкой до периода опроса.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-node-client/presence
|
|
9
|
+
*/
|
|
10
|
+
import { PROTO } from './api.mjs'
|
|
11
|
+
import { pingDelay } from './ping-policy.mjs'
|
|
12
|
+
import { t } from './i18n.mjs'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Открыть присутствие.
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {string} options.base адрес сервера, например `https://dsh.example/dsh-nodes`
|
|
18
|
+
* @param {string} options.token токен узла
|
|
19
|
+
* @param {string|null} options.epoch поколение, которое клиент помнит
|
|
20
|
+
* @param {number|null} options.known курсор узла
|
|
21
|
+
* @param {(event: object) => void} options.onEvent `state` | `hello-ok` | `closed`
|
|
22
|
+
* @param {number} [options.aliveMs] период `alive`
|
|
23
|
+
* @param {(level: string, message: string) => void} [options.log]
|
|
24
|
+
* @returns {{sendAlive: (known: number|null) => void, close: () => void, epoch: () => number|null}}
|
|
25
|
+
*/
|
|
26
|
+
export function openPresence ({ base, token, epoch = null, known = null, getKnown = null, onEvent, workdir = null,
|
|
27
|
+
access = null, allowEscalation = true, uptimeSeconds = null, approver = null, aliveMs = 20_000,
|
|
28
|
+
pingInterval = 0, maxPingIntervalFactor = 0, startMode = null, log = () => {} }) {
|
|
29
|
+
const wsUrl = base.replace(/^http/, 'ws') + '/v1/ws'
|
|
30
|
+
let serverEpoch = null
|
|
31
|
+
let closed = false
|
|
32
|
+
let socket
|
|
33
|
+
let aliveTimer
|
|
34
|
+
// Расписание прикладного пинга: см. lib/ping-policy.mjs. Здесь только
|
|
35
|
+
// исполнение — арифметика живёт в чистой функции и проверяется тестом.
|
|
36
|
+
let pingTimer = null
|
|
37
|
+
let pongWatchdog = null
|
|
38
|
+
let lastSeenAt = Date.now()
|
|
39
|
+
let ticksWithoutCommands = 0
|
|
40
|
+
let pingSeq = 0
|
|
41
|
+
let awaitingPong = null
|
|
42
|
+
let connected = false
|
|
43
|
+
/** Что клиент знает о последнем номере команды прямо сейчас. */
|
|
44
|
+
const currentKnown = () => (typeof getKnown === 'function' ? getKnown() : known) ?? null
|
|
45
|
+
|
|
46
|
+
const send = (payload) => {
|
|
47
|
+
if (closed || socket?.readyState !== 1) return
|
|
48
|
+
try {
|
|
49
|
+
socket.send(JSON.stringify(payload))
|
|
50
|
+
} catch (error) {
|
|
51
|
+
log('warn', t('presence.sendFailed', { message: error.message }))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
socket = new WebSocket(wsUrl, [token])
|
|
57
|
+
} catch (error) {
|
|
58
|
+
log('warn', t('presence.openFailed', { message: error.message }))
|
|
59
|
+
return { sendAlive: () => {}, close: () => {}, epoch: () => null }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Поставить пинг на паузу по правилам (арифметика — в ping-policy.mjs).
|
|
64
|
+
*
|
|
65
|
+
* Пауза отсчитывается от последнего успешного общения: если сервер только что
|
|
66
|
+
* ответил, пинг не нужен — таймер перезапустится.
|
|
67
|
+
*/
|
|
68
|
+
function schedulePing () {
|
|
69
|
+
if (pingTimer) clearTimeout(pingTimer)
|
|
70
|
+
const delay = pingDelay({ pingInterval, factor: maxPingIntervalFactor, ticks: ticksWithoutCommands })
|
|
71
|
+
if (!delay) return
|
|
72
|
+
const elapsed = Date.now() - lastSeenAt
|
|
73
|
+
pingTimer = setTimeout(() => {
|
|
74
|
+
if (Date.now() - lastSeenAt < delay) return schedulePing()
|
|
75
|
+
const id = ++pingSeq
|
|
76
|
+
awaitingPong = id
|
|
77
|
+
send({ t: 'ping', id })
|
|
78
|
+
pongWatchdog = setTimeout(() => {
|
|
79
|
+
log('warn', t('presence.pongTimeout', { seconds: Math.round(delay / 1000) }))
|
|
80
|
+
try {
|
|
81
|
+
socket.close(4002, 'нет понга')
|
|
82
|
+
} catch {}
|
|
83
|
+
}, delay)
|
|
84
|
+
if (typeof pongWatchdog.unref === 'function') pongWatchdog.unref()
|
|
85
|
+
}, Math.max(0, delay - elapsed))
|
|
86
|
+
if (typeof pingTimer.unref === 'function') pingTimer.unref()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Любое успешное общение с сервером сдвигает отсчёт пинга. */
|
|
90
|
+
function noteSeen () {
|
|
91
|
+
lastSeenAt = Date.now()
|
|
92
|
+
if (pongWatchdog) {
|
|
93
|
+
clearTimeout(pongWatchdog)
|
|
94
|
+
pongWatchdog = null
|
|
95
|
+
}
|
|
96
|
+
schedulePing()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Сервер прислал команды: растягивать паузу больше не за что. */
|
|
100
|
+
function noteCommands () {
|
|
101
|
+
ticksWithoutCommands = 0
|
|
102
|
+
noteSeen()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
socket.addEventListener('open', () => {
|
|
106
|
+
connected = true
|
|
107
|
+
// workdir едет в hello: сервер покажет его в админке и будет знать,
|
|
108
|
+
// где у узла корень рабочих каталогов сессий
|
|
109
|
+
send({ t: 'hello', epoch, known, proto: PROTO, workdir: workdir ?? null, access: access ?? null, allowEscalation: allowEscalation ?? true, uptimeSeconds: uptimeSeconds ?? null, approver: approver ?? null, base: startMode ?? null })
|
|
110
|
+
// номер берём в момент отправки: иначе клиент вечно рапортует то, что знал
|
|
111
|
+
// при подключении, и сервер каждый раз считает его отставшим
|
|
112
|
+
aliveTimer = setInterval(() => send({ t: 'check_state', known: currentKnown() }), aliveMs)
|
|
113
|
+
if (typeof aliveTimer.unref === 'function') aliveTimer.unref()
|
|
114
|
+
schedulePing()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
socket.addEventListener('message', (event) => {
|
|
118
|
+
let frame
|
|
119
|
+
try {
|
|
120
|
+
frame = JSON.parse(String(event.data))
|
|
121
|
+
} catch {
|
|
122
|
+
return log('warn', t('presence.badFrame'))
|
|
123
|
+
}
|
|
124
|
+
if (frame.t === 'hello-ok') {
|
|
125
|
+
serverEpoch = frame.epoch ?? null
|
|
126
|
+
// сразу заявляем ожидание: иначе сервер, у которого уже есть работа, не
|
|
127
|
+
// имеет права присылать кадр (он шлёт только ждущим)
|
|
128
|
+
send({ t: 'check_state', known: currentKnown() })
|
|
129
|
+
onEvent?.({ type: 'hello-ok', ...frame })
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
if (frame.t === 'pong') {
|
|
133
|
+
if (awaitingPong !== null && frame.id === awaitingPong) awaitingPong = null
|
|
134
|
+
ticksWithoutCommands += 1
|
|
135
|
+
noteSeen()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (frame.t === 'state') {
|
|
139
|
+
if (frame.epoch != null) serverEpoch = frame.epoch
|
|
140
|
+
noteSeen()
|
|
141
|
+
onEvent?.({ type: 'state', ...frame })
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
socket.addEventListener('close', (event) => {
|
|
146
|
+
closed = true
|
|
147
|
+
clearInterval(aliveTimer)
|
|
148
|
+
onEvent?.({ type: 'closed', code: event.code, reason: event.reason })
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
socket.addEventListener('error', () => {
|
|
152
|
+
// close всё равно придёт; здесь только чтобы не падало
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
sendAlive: (value) => send({ t: 'check_state', known: value ?? currentKnown() }),
|
|
157
|
+
online: () => connected,
|
|
158
|
+
// демон сообщает presence о жизни: после каждого удачного HTTP-ответа и
|
|
159
|
+
// после каждой пачки команд
|
|
160
|
+
noteSeen,
|
|
161
|
+
noteCommands,
|
|
162
|
+
close: () => {
|
|
163
|
+
closed = true
|
|
164
|
+
clearInterval(aliveTimer)
|
|
165
|
+
try {
|
|
166
|
+
socket.close(1000, 'bye')
|
|
167
|
+
} catch {}
|
|
168
|
+
},
|
|
169
|
+
epoch: () => serverEpoch,
|
|
170
|
+
}
|
|
171
|
+
}
|