@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.1
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/CHANGELOG.md +836 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/bin/pi-mailbridge.mjs +31 -0
- package/config.example.json +54 -0
- package/dist/core/attachments.js +87 -0
- package/dist/core/authz.js +65 -0
- package/dist/core/commands.js +72 -0
- package/dist/core/config-freshness.js +24 -0
- package/dist/core/config.js +286 -0
- package/dist/core/deliveries.js +180 -0
- package/dist/core/discover.js +282 -0
- package/dist/core/header-encoding.js +70 -0
- package/dist/core/html.js +48 -0
- package/dist/core/inline-images.js +355 -0
- package/dist/core/mail-command.js +62 -0
- package/dist/core/mailer.js +126 -0
- package/dist/core/markdown.js +96 -0
- package/dist/core/oauth-device.js +119 -0
- package/dist/core/oauth-openid.js +48 -0
- package/dist/core/oauth.js +137 -0
- package/dist/core/prime.js +51 -0
- package/dist/core/reply-parse.js +134 -0
- package/dist/core/sent-index.js +135 -0
- package/dist/core/settle.js +122 -0
- package/dist/core/setup.js +100 -0
- package/dist/core/smoke.js +164 -0
- package/dist/core/status.js +56 -0
- package/dist/core/token-source.js +27 -0
- package/dist/core/token.js +196 -0
- package/dist/core/version.js +44 -0
- package/dist/daemon/api.js +46 -0
- package/dist/daemon/imapflow.js +75 -0
- package/dist/daemon/main.js +802 -0
- package/dist/daemon/process.js +270 -0
- package/dist/daemon/scan-queue.js +38 -0
- package/dist/daemon/script.js +44 -0
- package/dist/daemon/send-budget.js +30 -0
- package/dist/daemon/service.js +455 -0
- package/dist/daemon/source-proxy.js +64 -0
- package/dist/daemon/spawn.js +227 -0
- package/dist/daemon/watcher.js +308 -0
- package/dist/extension.js +1233 -0
- package/dist/store/journal.js +92 -0
- package/dist/store/mode-store.js +47 -0
- package/dist/store/oauth-store.js +50 -0
- package/dist/store/paths.js +33 -0
- package/dist/store/pending-store.js +212 -0
- package/dist/store/registry.js +61 -0
- package/dist/store/routes.js +223 -0
- package/dist/store/schema.js +93 -0
- package/dist/store/secret.js +70 -0
- package/dist/store/threads.js +145 -0
- package/dist/store/uid-cursor.js +112 -0
- package/docs/ARCHITECTURE.md +356 -0
- package/docs/MAPPING.md +201 -0
- package/docs/OAUTH.md +448 -0
- package/docs/SECURITY.md +273 -0
- package/docs/images/flow.svg +124 -0
- package/docs/images/notification.png +0 -0
- package/package.json +85 -4
- package/scripts/render-teaser.ts +56 -0
- package/src/core/attachments.ts +129 -0
- package/src/core/authz.ts +87 -0
- package/src/core/commands.ts +99 -0
- package/src/core/config-freshness.ts +33 -0
- package/src/core/config.ts +373 -0
- package/src/core/deliveries.ts +233 -0
- package/src/core/discover.ts +333 -0
- package/src/core/header-encoding.ts +75 -0
- package/src/core/html.ts +62 -0
- package/src/core/inline-images.ts +421 -0
- package/src/core/mail-command.ts +76 -0
- package/src/core/mailer.ts +193 -0
- package/src/core/markdown.ts +114 -0
- package/src/core/oauth-device.ts +195 -0
- package/src/core/oauth-openid.ts +61 -0
- package/src/core/oauth.ts +182 -0
- package/src/core/prime.ts +74 -0
- package/src/core/reply-parse.ts +153 -0
- package/src/core/sent-index.ts +164 -0
- package/src/core/settle.ts +179 -0
- package/src/core/setup.ts +139 -0
- package/src/core/smoke.ts +207 -0
- package/src/core/status.ts +75 -0
- package/src/core/token-source.ts +34 -0
- package/src/core/token.ts +240 -0
- package/src/core/version.ts +45 -0
- package/src/daemon/api.ts +52 -0
- package/src/daemon/imapflow.ts +101 -0
- package/src/daemon/main.ts +832 -0
- package/src/daemon/process.ts +348 -0
- package/src/daemon/scan-queue.ts +45 -0
- package/src/daemon/script.ts +45 -0
- package/src/daemon/send-budget.ts +36 -0
- package/src/daemon/service.ts +550 -0
- package/src/daemon/source-proxy.ts +88 -0
- package/src/daemon/spawn.ts +332 -0
- package/src/daemon/watcher.ts +427 -0
- package/src/extension.ts +1381 -0
- package/src/store/journal.ts +104 -0
- package/src/store/mode-store.ts +60 -0
- package/src/store/oauth-store.ts +55 -0
- package/src/store/paths.ts +38 -0
- package/src/store/pending-store.ts +282 -0
- package/src/store/registry.ts +91 -0
- package/src/store/routes.ts +267 -0
- package/src/store/schema.ts +104 -0
- package/src/store/secret.ts +69 -0
- package/src/store/threads.ts +177 -0
- package/src/store/uid-cursor.ts +135 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Daemon supervision from the extension side.
|
|
2
|
+
//
|
|
3
|
+
// The extension must never assume a daemon is running: a session can start on a
|
|
4
|
+
// freshly booted machine. `ensureDaemon` probes /health and, if nothing answers,
|
|
5
|
+
// spawns the daemon detached so it outlives this pi session.
|
|
6
|
+
//
|
|
7
|
+
// Detached, with output appended to daemon.log (not stdio:ignore: a silent daemon made the
|
|
8
|
+
// catch-up failures invisible). The daemon owns the mailbox and must outlive the
|
|
9
|
+
// process that started it so another session (or the phone) can still be served. It is not
|
|
10
|
+
// immortal: it exits once every session has stopped pinging.
|
|
11
|
+
|
|
12
|
+
import { spawn as nodeSpawn, type SpawnOptions } from "node:child_process"
|
|
13
|
+
import { chmodSync, closeSync, mkdirSync, openSync, renameSync, statSync } from "node:fs"
|
|
14
|
+
import { dirname } from "node:path"
|
|
15
|
+
|
|
16
|
+
import type { PendingRecord } from "../store/pending-store.ts"
|
|
17
|
+
import { API_VERSION, apiCompatible } from "./api.ts"
|
|
18
|
+
import { statePath } from "../store/paths.ts"
|
|
19
|
+
|
|
20
|
+
export interface ProbeOptions {
|
|
21
|
+
baseUrl: string
|
|
22
|
+
timeoutMs?: number
|
|
23
|
+
/** Injected for tests. Defaults to global fetch. */
|
|
24
|
+
fetchImpl?: typeof fetch
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when a daemon answers /health. Never throws. */
|
|
28
|
+
export async function probeHealth(options: ProbeOptions): Promise<boolean> {
|
|
29
|
+
const health = await probeHealthDetail(options)
|
|
30
|
+
return health.ok
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `/health`, with the protocol it speaks.
|
|
35
|
+
*
|
|
36
|
+
* `ok` means "something answered"; `apiVersion` says whether *this* build can use it. A daemon
|
|
37
|
+
* from another protocol is alive but unusable, and reusing it would make a replacement look
|
|
38
|
+
* successful while the mismatch stayed.
|
|
39
|
+
*/
|
|
40
|
+
export async function probeHealthDetail(options: ProbeOptions): Promise<{ ok: boolean; apiVersion?: number }> {
|
|
41
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
42
|
+
const controller = new AbortController()
|
|
43
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 750)
|
|
44
|
+
try {
|
|
45
|
+
const response = await doFetch(`${options.baseUrl}/health`, { signal: controller.signal })
|
|
46
|
+
if (!response.ok) return { ok: false }
|
|
47
|
+
const body = (await response.json().catch(() => undefined)) as { apiVersion?: unknown } | undefined
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
...(typeof body?.apiVersion === "number" ? { apiVersion: body.apiVersion } : {}),
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
return { ok: false }
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timer)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EnsureDaemonOptions {
|
|
60
|
+
baseUrl: string
|
|
61
|
+
/** Absolute path to the daemon entry (`.ts` or `.js`). */
|
|
62
|
+
scriptPath: string
|
|
63
|
+
/** Extra argv passed after the script path (e.g. none). */
|
|
64
|
+
args?: string[]
|
|
65
|
+
env?: NodeJS.ProcessEnv
|
|
66
|
+
/** How long to wait for the spawned daemon to answer /health. */
|
|
67
|
+
waitMs?: number
|
|
68
|
+
/** Injected for tests. */
|
|
69
|
+
fetchImpl?: typeof fetch
|
|
70
|
+
spawnImpl?: (
|
|
71
|
+
command: string,
|
|
72
|
+
args: string[],
|
|
73
|
+
options: SpawnOptions,
|
|
74
|
+
) => {
|
|
75
|
+
unref?: () => void
|
|
76
|
+
pid?: number
|
|
77
|
+
/** Needed to catch a fork failure; without a listener Node throws in *this* process. */
|
|
78
|
+
on?: (event: "error", listener: (error: Error) => void) => unknown
|
|
79
|
+
}
|
|
80
|
+
execPath?: string
|
|
81
|
+
sleep?: (ms: number) => Promise<void>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface EnsureDaemonResult {
|
|
85
|
+
/** True when this call spawned the process. */
|
|
86
|
+
started: boolean
|
|
87
|
+
/** True when the daemon answers /health afterwards, on this build's protocol. */
|
|
88
|
+
healthy: boolean
|
|
89
|
+
/** Something answered but spoke another protocol; it must be replaced, not reused. */
|
|
90
|
+
incompatible?: boolean
|
|
91
|
+
/** Why the spawn failed, when it did (EAGAIN, ENOENT, a sandbox refusal). */
|
|
92
|
+
error?: string
|
|
93
|
+
/** The protocol the answering daemon spoke, when one answered and was not reused. */
|
|
94
|
+
apiVersion?: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Make sure a daemon is running.
|
|
101
|
+
*
|
|
102
|
+
* Reuses an existing daemon rather than racing it: `daemon.lock` in the state directory is the
|
|
103
|
+
* lock (the port is a second guard), and a
|
|
104
|
+
* second daemon exits 0 on EADDRINUSE, so the worst case is a wasted spawn rather
|
|
105
|
+
* than two watchers on one mailbox.
|
|
106
|
+
*/
|
|
107
|
+
export async function ensureDaemon(options: EnsureDaemonOptions): Promise<EnsureDaemonResult> {
|
|
108
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
109
|
+
const found = await probeHealthDetail({ baseUrl: options.baseUrl, fetchImpl: doFetch })
|
|
110
|
+
if (found.ok) {
|
|
111
|
+
// Reuse only a daemon this build can talk to. An incompatible one is reported so the
|
|
112
|
+
// caller can replace it, instead of being adopted and failing on the first request.
|
|
113
|
+
if (apiCompatible(API_VERSION, found.apiVersion)) return { started: false, healthy: true }
|
|
114
|
+
return { started: false, healthy: false, incompatible: true, apiVersion: found.apiVersion }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const spawn = options.spawnImpl ?? nodeSpawn
|
|
118
|
+
const execPath = options.execPath ?? process.execPath
|
|
119
|
+
// The port this process probed, so the child cannot pick a different one from its own env -
|
|
120
|
+
// otherwise two daemons can end up on one state directory, which has no cross-process locks.
|
|
121
|
+
const probedPort = (() => {
|
|
122
|
+
try {
|
|
123
|
+
return new URL(options.baseUrl).port
|
|
124
|
+
} catch {
|
|
125
|
+
return ""
|
|
126
|
+
}
|
|
127
|
+
})()
|
|
128
|
+
// `--experimental-strip-types` only for the TypeScript entry: the published build runs
|
|
129
|
+
// `dist/daemon/main.js`, and Node refuses type stripping under `node_modules` anyway.
|
|
130
|
+
const args = [
|
|
131
|
+
...(options.scriptPath.endsWith(".ts") ? ["--experimental-strip-types"] : []),
|
|
132
|
+
options.scriptPath,
|
|
133
|
+
...(probedPort ? ["--port", probedPort] : []),
|
|
134
|
+
...(options.args ?? []),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
// Detached with output ignored would make failures invisible: a catch-up scan could
|
|
138
|
+
// fail on every boot with nothing to show for it. Append to a log file instead.
|
|
139
|
+
const env = options.env ?? process.env
|
|
140
|
+
const logPath = statePath(env, "daemon.log")
|
|
141
|
+
let logHandle: number | undefined
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(dirname(logPath), { recursive: true, mode: 0o700 })
|
|
144
|
+
// One rotation at 5 MB: the log grows only when mail is seen, but it grows forever.
|
|
145
|
+
try {
|
|
146
|
+
if (statSync(logPath).size > 5 * 1024 * 1024) renameSync(logPath, `${logPath}.1`)
|
|
147
|
+
} catch {
|
|
148
|
+
/* no log yet, or cannot rotate - appending is still better than nothing */
|
|
149
|
+
}
|
|
150
|
+
logHandle = openSync(logPath, "a", 0o600)
|
|
151
|
+
// An older build created it 0644; the log carries addresses and session names.
|
|
152
|
+
try {
|
|
153
|
+
chmodSync(logPath, 0o600)
|
|
154
|
+
} catch {
|
|
155
|
+
/* best effort */
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// A read-only state dir must not stop the daemon from starting.
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const child = spawn(execPath, args, {
|
|
162
|
+
detached: true,
|
|
163
|
+
stdio: logHandle === undefined ? "ignore" : ["ignore", logHandle, logHandle],
|
|
164
|
+
env,
|
|
165
|
+
} as SpawnOptions)
|
|
166
|
+
// A fork failure (EAGAIN under fd pressure, a sandbox denying exec) arrives as an `error`
|
|
167
|
+
// event. Without a listener Node treats it as an uncaught exception in *pi's* process: the
|
|
168
|
+
// session dies while the daemon it was trying to start never existed.
|
|
169
|
+
let spawnError: Error | undefined
|
|
170
|
+
child.on?.("error", (error) => {
|
|
171
|
+
spawnError = error
|
|
172
|
+
})
|
|
173
|
+
child.unref?.()
|
|
174
|
+
if (logHandle !== undefined) {
|
|
175
|
+
// The child holds its own copy of the descriptor.
|
|
176
|
+
try {
|
|
177
|
+
closeSync(logHandle)
|
|
178
|
+
} catch {
|
|
179
|
+
/* already closed */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const sleep = options.sleep ?? defaultSleep
|
|
184
|
+
const deadline = Date.now() + (options.waitMs ?? 5_000)
|
|
185
|
+
while (Date.now() < deadline) {
|
|
186
|
+
if (spawnError) return { started: true, healthy: false, error: spawnError.message }
|
|
187
|
+
await sleep(100)
|
|
188
|
+
if (await probeHealth({ baseUrl: options.baseUrl, fetchImpl: doFetch })) {
|
|
189
|
+
return { started: true, healthy: true }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { started: true, healthy: false }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Default loopback base URL for the configured port. */
|
|
196
|
+
export function daemonBaseUrl(port: number, host = "127.0.0.1"): string {
|
|
197
|
+
return `http://${host}:${port}`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** SMTP transactions with inlined images are slow; be generous rather than lose the mail. */
|
|
201
|
+
const SEND_TIMEOUT_MS = 120_000
|
|
202
|
+
/** Reload tears down and re-dials IMAP, which can involve a token fetch. */
|
|
203
|
+
const RELOAD_TIMEOUT_MS = 120_000
|
|
204
|
+
|
|
205
|
+
export interface DaemonClientOptions {
|
|
206
|
+
baseUrl: string
|
|
207
|
+
token: string
|
|
208
|
+
/** This machine's device name; /pending returns only this device's records. */
|
|
209
|
+
device?: string
|
|
210
|
+
fetchImpl?: typeof fetch
|
|
211
|
+
/**
|
|
212
|
+
* Per-request ceiling. Without one, a daemon that accepts a connection and never
|
|
213
|
+
* answers leaves the caller's promise unsettled forever - and the drain it is inside
|
|
214
|
+
* then wedges every later drain in the process.
|
|
215
|
+
*/
|
|
216
|
+
timeoutMs?: number
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Tiny typed client used by the extension and by `/mailbridge`. */
|
|
220
|
+
export type DaemonResult<T> = { ok: true; data: T } | { ok: false; status: number; code?: string }
|
|
221
|
+
|
|
222
|
+
export function createDaemonClient(options: DaemonClientOptions) {
|
|
223
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
224
|
+
const deviceName = options.device ?? ""
|
|
225
|
+
const headers = { authorization: `Bearer ${options.token}`, "content-type": "application/json" }
|
|
226
|
+
|
|
227
|
+
async function call<T>(
|
|
228
|
+
method: string,
|
|
229
|
+
path: string,
|
|
230
|
+
body?: unknown,
|
|
231
|
+
timeoutMs: number = options.timeoutMs ?? 15_000,
|
|
232
|
+
): Promise<DaemonResult<T>> {
|
|
233
|
+
const controller = new AbortController()
|
|
234
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
235
|
+
try {
|
|
236
|
+
const response = await doFetch(`${options.baseUrl}${path}`, {
|
|
237
|
+
method,
|
|
238
|
+
headers,
|
|
239
|
+
signal: controller.signal,
|
|
240
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
241
|
+
})
|
|
242
|
+
const text = await response.text()
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
let code: string | undefined
|
|
245
|
+
try {
|
|
246
|
+
code = (JSON.parse(text) as { error?: { code?: string } }).error?.code
|
|
247
|
+
} catch {
|
|
248
|
+
/* non-JSON error body */
|
|
249
|
+
}
|
|
250
|
+
return { ok: false, status: response.status, ...(code ? { code } : {}) }
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, data: (text ? JSON.parse(text) : {}) as T }
|
|
253
|
+
} catch (error) {
|
|
254
|
+
// A timeout is reported distinctly: the outcome is unknown, so callers must
|
|
255
|
+
// treat it like a network failure (retry later) rather than a definite "no".
|
|
256
|
+
if (error instanceof Error && error.name === "AbortError") return { ok: false, status: 0, code: "timeout" }
|
|
257
|
+
return { ok: false, status: 0, code: "network" }
|
|
258
|
+
} finally {
|
|
259
|
+
clearTimeout(timer)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
health: () =>
|
|
265
|
+
call<{
|
|
266
|
+
ok: boolean
|
|
267
|
+
mode: string
|
|
268
|
+
pending: number
|
|
269
|
+
lastScan?: {
|
|
270
|
+
at: number
|
|
271
|
+
examined: number
|
|
272
|
+
persisted: number
|
|
273
|
+
rejected: number
|
|
274
|
+
reasons: Record<string, number>
|
|
275
|
+
} | null
|
|
276
|
+
configLoadedAt?: number | null
|
|
277
|
+
/** The daemon's package version. Diagnostic only. */
|
|
278
|
+
version?: string
|
|
279
|
+
/** The wire contract it implements; absent on a build from before this field. */
|
|
280
|
+
apiVersion?: number
|
|
281
|
+
/** Additive features inside that contract. */
|
|
282
|
+
capabilities?: string[]
|
|
283
|
+
/** "connected" | "disconnected" | "unknown" */
|
|
284
|
+
imap?: string
|
|
285
|
+
}>("GET", "/health"),
|
|
286
|
+
pending: () =>
|
|
287
|
+
call<{ pending: PendingRecord[]; withheld?: number }>(
|
|
288
|
+
"GET",
|
|
289
|
+
`/pending?device=${encodeURIComponent(deviceName)}`,
|
|
290
|
+
),
|
|
291
|
+
mode: () => call<{ mode: string }>("GET", "/mode"),
|
|
292
|
+
setMode: (mode: "on" | "off") => call<{ mode: string }>("POST", "/mode", { mode }),
|
|
293
|
+
claim: (uid: number, instanceId: string, uidValidity?: number) =>
|
|
294
|
+
call<{ ok: boolean }>("POST", "/claim", {
|
|
295
|
+
uid,
|
|
296
|
+
instanceId,
|
|
297
|
+
...(uidValidity === undefined ? {} : { uidValidity }),
|
|
298
|
+
}),
|
|
299
|
+
ack: (uid: number, instanceId: string, sessionId?: string, uidValidity?: number) =>
|
|
300
|
+
call<{ ok: boolean }>("POST", "/ack", {
|
|
301
|
+
uid,
|
|
302
|
+
instanceId,
|
|
303
|
+
sessionId,
|
|
304
|
+
...(uidValidity === undefined ? {} : { uidValidity }),
|
|
305
|
+
}),
|
|
306
|
+
register: (sessionId: string) => call<{ alreadyPending: boolean; full: boolean }>("POST", "/register", { sessionId }),
|
|
307
|
+
release: (sessionId: string) => call<{ ok: boolean }>("DELETE", "/register", { sessionId }),
|
|
308
|
+
// A notification can carry a few hundred KB of inlined images and the daemon awaits
|
|
309
|
+
// the whole SMTP transaction, so a slow-but-healthy relay must not be cut off: the
|
|
310
|
+
// generic 15 s ceiling would drop the one mail this product exists to deliver.
|
|
311
|
+
send: (message: {
|
|
312
|
+
/** "settle" and "decision" are exempt from the daemon's notification budget. */
|
|
313
|
+
kind?: "settle" | "decision" | "notification"
|
|
314
|
+
to?: string
|
|
315
|
+
subject: string
|
|
316
|
+
text: string
|
|
317
|
+
cwd?: string
|
|
318
|
+
sessionId?: string
|
|
319
|
+
}) => call<{ ok: boolean }>("POST", "/send", message, SEND_TIMEOUT_MS),
|
|
320
|
+
/** Re-read config and restart the watcher (after setup or auth). */
|
|
321
|
+
// Includes an IMAP logout, an XOAUTH2 token fetch and a fresh dial.
|
|
322
|
+
reload: () => call<{ ok: boolean; warning?: string }>("POST", "/reload", undefined, RELOAD_TIMEOUT_MS),
|
|
323
|
+
/** Ask the daemon to exit, so a fresh process can load updated code. */
|
|
324
|
+
shutdown: () => call<{ ok: boolean }>("POST", "/shutdown"),
|
|
325
|
+
/** Liveness heartbeat; the daemon exits when every client stops pinging. */
|
|
326
|
+
ping: (instanceId: string) => call<{ ok: boolean; clients: number }>("POST", "/ping", { instanceId }),
|
|
327
|
+
/** Tell the daemon this session is gone. */
|
|
328
|
+
bye: (instanceId: string) => call<{ ok: boolean; clients: number }>("POST", "/bye", { instanceId }),
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export type DaemonClient = ReturnType<typeof createDaemonClient>
|