@design-twin/dtwin 0.0.0-stage → 0.1.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 +959 -2
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +53 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +942 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- package/src/write-out.ts +1255 -0
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
// daemon.ts — hold the bridge open across many CLI invocations.
|
|
2
|
+
//
|
|
3
|
+
// WHY this shape. A one-shot `figma-pull` pays a full plugin reconnect every run, and only one
|
|
4
|
+
// process can hold port 8787 (server-core.ts exits on EADDRINUSE), so back-to-back pulls are both
|
|
5
|
+
// slow and mutually exclusive. The obvious fix — "a flag that opens the connection and leaves it
|
|
6
|
+
// open" — does NOT work on its own: a long-running CLI has no way to receive further commands, so it
|
|
7
|
+
// would occupy the port while being unreachable, blocking the MCP server and every other CLI call.
|
|
8
|
+
//
|
|
9
|
+
// So: ONE long-lived process owns the bridge (`--serve`), and every other invocation becomes a thin
|
|
10
|
+
// client that forwards its request over a unix socket and exits. Existing commands route through the
|
|
11
|
+
// daemon automatically when one is running, and fall back to opening their own bridge when it isn't —
|
|
12
|
+
// so nothing about the current usage changes, it just gets faster when a daemon is up.
|
|
13
|
+
//
|
|
14
|
+
// A unix socket, not a second TCP port: it gets filesystem permissions for free (no verifyClient
|
|
15
|
+
// equivalent to write), and it cannot be reached from a browser tab the way a loopback port can.
|
|
16
|
+
//
|
|
17
|
+
// Windows: a named pipe, and a handshake in place of the directory. node:net's IPC there is a named pipe,
|
|
18
|
+
// and Bun (libuv underneath) gives it the default security descriptor — Everyone may read it — with no way to
|
|
19
|
+
// narrow it, nor for a client to ask who created the server end: another local user could create the name
|
|
20
|
+
// first and answer with forged replies. Nothing there plays the part of the 0700 directory below. So on
|
|
21
|
+
// win32 the pipe's NAME is derived from the bridge token (daemon-auth.ts pipeName), and before any frame
|
|
22
|
+
// both ends prove they hold that token (daemon-auth.ts, which says what that does and does not stop). Every
|
|
23
|
+
// serve / stop / lookup below takes that path where its address says so (daemonAddress): win32 by default, and anywhere a
|
|
24
|
+
// caller asks (`handshake: true` — the POSIX suites drive the whole exchange that way).
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import net from "node:net";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { AUTH_TIMEOUT_MS, clientHandshake, ipcKey, pipeName, serverHandshake } from "./daemon-auth.ts";
|
|
29
|
+
import type { IpcAuth } from "./daemon-auth.ts";
|
|
30
|
+
import { errMsg, errCode } from "./errmsg.ts";
|
|
31
|
+
import { ifDefined, isRecord } from "./json-util.ts";
|
|
32
|
+
import { abortReason } from "./abort-reason.ts";
|
|
33
|
+
import { deadline, systemClock } from "./clock.ts";
|
|
34
|
+
import { replyShapeError } from "./commands.ts";
|
|
35
|
+
import type { Cmd, Commands } from "./commands.ts";
|
|
36
|
+
import type { ClientRow, ProgressTick } from "./server-core.ts";
|
|
37
|
+
import type { Sent } from "./route.ts";
|
|
38
|
+
import { exitFlushed } from "./stdio.ts";
|
|
39
|
+
import { CONTROL, createServedSession } from "./served-session.ts";
|
|
40
|
+
import type { DaemonActivity, DaemonBridge, DaemonCommandRequest, DaemonControlRequest, DaemonReply, DaemonStatus, SessionFrame } from "./served-session.ts";
|
|
41
|
+
import type { Clock } from "./clock.ts";
|
|
42
|
+
|
|
43
|
+
// ---- the daemon socket protocol (newline-delimited JSON, one request -> one reply per connection). The frames, how a
|
|
44
|
+
// request frame is checked and what the daemon answers are the served session's (served-session.ts), which reads them;
|
|
45
|
+
// this module is the socket around it (serve) and the client end (request / connect / stop / status).
|
|
46
|
+
export { daemonRequestError, isDaemonRequest } from "./served-session.ts";
|
|
47
|
+
export type { DaemonActivity, DaemonBridge, DaemonCommandRequest, DaemonControlRequest, DaemonRequest, DaemonStatus } from "./served-session.ts";
|
|
48
|
+
|
|
49
|
+
/** A client row as relayed over the socket: the one field routing needs (`connId`) is checked, the rest
|
|
50
|
+
* is read as the bridge's own describe() wrote it. */
|
|
51
|
+
function isClientRow(x: unknown): x is ClientRow {
|
|
52
|
+
return isRecord(x) && typeof x.connId === "string";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const finiteOrNull = (v: unknown): v is number | null => v === null || (typeof v === "number" && Number.isFinite(v));
|
|
56
|
+
const stringOrNull = (v: unknown): v is string | null => v === null || typeof v === "string";
|
|
57
|
+
/** The tick exactly as server-core's parseTick normalises it (every field present, null when absent).
|
|
58
|
+
* Foreign JSON from another process — possibly another build — so it is checked, never cast. */
|
|
59
|
+
function isProgressTick(x: unknown): x is ProgressTick {
|
|
60
|
+
if (!isRecord(x)) return false;
|
|
61
|
+
const p = x.page;
|
|
62
|
+
const pageOk = p === null || (isRecord(p) && typeof p.index === "number" && Number.isFinite(p.index) && typeof p.of === "number" && Number.isFinite(p.of) && stringOrNull(p.name));
|
|
63
|
+
return stringOrNull(x.phase) && pageOk && finiteOrNull(x.nodes) && finiteOrNull(x.assets);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Is this frame a progress frame at all: `progress` present and no `ok` (every reply has `ok`). */
|
|
67
|
+
const isProgressShaped = (x: unknown): x is Record<string, unknown> => isRecord(x) && !("ok" in x) && "progress" in x;
|
|
68
|
+
|
|
69
|
+
function parseDaemonReply(x: unknown): DaemonReply | null {
|
|
70
|
+
if (!isRecord(x)) return null;
|
|
71
|
+
if (x.ok === true) return { ok: true, result: x.result, ...(isClientRow(x.client) ? { client: x.client } : {}) };
|
|
72
|
+
if (x.ok === false) return { ok: false, error: typeof x.error === "string" ? x.error : "daemon error" };
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What status() hands back: `daemon: true` is the one field every daemon has sent; a daemon from an
|
|
77
|
+
* OLDER bridge may omit the newer ones (see staleness.ts daemonRowStalenessNote), so the rest is Partial. */
|
|
78
|
+
export type DaemonStatusView = Partial<DaemonStatus> & { daemon: true };
|
|
79
|
+
|
|
80
|
+
function isDaemonStatus(x: unknown): x is DaemonStatusView {
|
|
81
|
+
return isRecord(x) && x.daemon === true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ServeOptions {
|
|
85
|
+
log?: (m: string) => void;
|
|
86
|
+
/** The idle window in ms (0 = none): the holder passes its settings' (settings.ts idleMs). */
|
|
87
|
+
idleMs: number;
|
|
88
|
+
signals?: boolean;
|
|
89
|
+
/** Install installCrashHandlers for this process. Defaults to `signals`: a serve() that owns the
|
|
90
|
+
* process's signals owns its lifetime too (`dtwin serve`); a host with its own lifetime (the MCP
|
|
91
|
+
* server passes signals:false) installs its own, and an in-process test passes false. */
|
|
92
|
+
crashHandlers?: boolean;
|
|
93
|
+
/** What the session's idle clock runs on (clock.ts; systemClock when absent) — a suite's fake one steps it. */
|
|
94
|
+
clock?: Clock;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What a request through the daemon takes beside its frame (the frame carries the command, its budget, target
|
|
98
|
+
* and connect window). Every field optional.
|
|
99
|
+
* `guardMs`: the outer guard for a daemon that stopped answering entirely (route.ts sizes it, daemonGuardMs).
|
|
100
|
+
* While ticks arrive it is a SILENCE budget — each tick re-arms it (request() below says why). Absent: none.
|
|
101
|
+
* `onProgress` opts the request into progress frames and receives each tick, in order, before the promise
|
|
102
|
+
* settles. A daemon older than progress frames sends none; the request still answers.
|
|
103
|
+
* `signal`: the caller giving up. Aborting it closes this request's socket, which the daemon reads as its
|
|
104
|
+
* client leaving — a request still waiting for its plugin is never sent, a sent one gets the plugin a cancel
|
|
105
|
+
* frame (the served session's leave, served-session.ts), which drops it from the plugin's queue or stops it at its next safe point. Any
|
|
106
|
+
* daemon, old or new, treats a closed socket that way. */
|
|
107
|
+
export interface DaemonRequestOptions {
|
|
108
|
+
guardMs?: number | undefined;
|
|
109
|
+
onProgress?: ((tick: ProgressTick) => void) | undefined;
|
|
110
|
+
signal?: AbortSignal | undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** connect()'s answer when a daemon is live: its socket, and the one way to send a command through it — the
|
|
114
|
+
* reply, and the connected file the daemon's bridge used (null from an older daemon). */
|
|
115
|
+
export interface DaemonConnection {
|
|
116
|
+
sock: string;
|
|
117
|
+
request<C extends Cmd>(msg: DaemonCommandRequest<C>, o?: DaemonRequestOptions): Promise<Sent<C>>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- the address: where the daemon is reached, and keyed on what — resolved ONCE, from values
|
|
121
|
+
//
|
|
122
|
+
// The socket answers without a token, so on POSIX its DIRECTORY is the access control: only a directory that
|
|
123
|
+
// this user owns and nobody else can enter is safe. os.tmpdir() alone is not — on macOS it is a
|
|
124
|
+
// per-user 0700 directory, but on Linux it is usually /tmp (mode 1777, shared), where another local
|
|
125
|
+
// user could bind the socket name first: our CLI would then take forged replies from their process,
|
|
126
|
+
// and our `serve` could not remove their file (sticky bit).
|
|
127
|
+
//
|
|
128
|
+
// So, on POSIX: $XDG_RUNTIME_DIR when it is an absolute path to a directory that passes the check
|
|
129
|
+
// below (the XDG Base Directory spec requires it to be owned by the user with mode 0700); otherwise
|
|
130
|
+
// `<tmpdir>/designtwin-<uid>`, created 0700. An existing directory is used only if it is a real
|
|
131
|
+
// directory (not a symlink), owned by this uid, with no group/other permission bits. One that fails is
|
|
132
|
+
// REFUSED, never chmod-ed: we never create it looser than 0700, so a looser one was opened by someone,
|
|
133
|
+
// and whatever it already holds cannot be trusted. Inside a directory that passes, no other user can
|
|
134
|
+
// create, replace or remove an entry, so the socket file in it is ours; it is still checked (owner)
|
|
135
|
+
// before use, as a second line.
|
|
136
|
+
//
|
|
137
|
+
// The address is resolved once (daemonAddress); the directory and the socket file are checked again at
|
|
138
|
+
// EVERY use (checkedSock), because a long-lived process (the MCP server) resolves it once and uses it for
|
|
139
|
+
// hours: a directory someone makes or opens up after the resolution is never trusted unchecked.
|
|
140
|
+
//
|
|
141
|
+
// No POSIX uids (an injected uid of undefined): the tmpdir unchanged, and no mode bits to check.
|
|
142
|
+
// Windows: a named pipe named from the token (daemon-auth.ts pipeName) — no directory, no file, no length
|
|
143
|
+
// or owner check: a pipe is not a path in any filesystem, and goes away with the process that holds it.
|
|
144
|
+
//
|
|
145
|
+
// Length: the kernel limits a Unix socket address to sockaddr_un.sun_path — 108 bytes on Linux, 104 on
|
|
146
|
+
// macOS, each with its terminating NUL. Bun does not hand a longer path to the kernel as it is: on Linux
|
|
147
|
+
// it binds and connects through /proc/self/fd/<dir fd>/<name> (bun-usockets bsd.c), so any length works
|
|
148
|
+
// there. On macOS, only a recent Bun has the equivalent (fchdir into the directory, then the bare name);
|
|
149
|
+
// an older one fails with ENAMETOOLONG — and Node (before the Bun port) silently TRUNCATED the path and bound a
|
|
150
|
+
// different file. So on macOS a path over 103 bytes is refused here, with the fix named
|
|
151
|
+
// (MAX_SOCK_PATH_BYTES), rather than left to whichever of those the running Bun does. It takes a long
|
|
152
|
+
// TMPDIR to get there: a default macOS tmpdir is ~48 bytes, so `<tmpdir>/designtwin-<uid>/designtwin-<port>.sock`
|
|
153
|
+
// is ~84 bytes; /run/user/<uid>/designtwin-<port>.sock (Linux, XDG_RUNTIME_DIR) ~36.
|
|
154
|
+
|
|
155
|
+
/** What a daemon address is resolved from — values only: an entry passes its settings' (settings.ts
|
|
156
|
+
* daemonAddressOf), a suite whichever platform and uid it is checking. Nothing here reads the environment. */
|
|
157
|
+
export interface AddressInput {
|
|
158
|
+
/** the bridge port the daemon serves (keyed by port, so two bridges on two ports get two daemons) */
|
|
159
|
+
port: number;
|
|
160
|
+
/** the bridge token the pipe name and the handshake key on (settings.ts daemonTokenOf); null = none saved */
|
|
161
|
+
token: string | null;
|
|
162
|
+
/** win32 is a named pipe, and handshakes by default */
|
|
163
|
+
platform: NodeJS.Platform;
|
|
164
|
+
/** the owner the socket directory and file must have; undefined = no POSIX uids (Windows) */
|
|
165
|
+
uid: number | undefined;
|
|
166
|
+
/** the base for the fallback socket directory, and where an earlier build's socket lived (os.tmpdir()) */
|
|
167
|
+
tmpdir: string;
|
|
168
|
+
/** XDG_RUNTIME_DIR as the entry read it */
|
|
169
|
+
runtimeDir?: string | undefined;
|
|
170
|
+
/** prove the token before any frame (daemon-auth.ts) — on for win32 unless told otherwise, elsewhere only when
|
|
171
|
+
* asked (the POSIX suites drive the whole exchange that way). Both ends of one daemon must agree: a handshaking
|
|
172
|
+
* client never writes before the hello, and a handshaking server drops a frame that comes first. */
|
|
173
|
+
handshake?: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Where a daemon is reached: its socket or pipe and the handshake a connection starts with — or why there is
|
|
177
|
+
* none (daemonAddress). */
|
|
178
|
+
export type DaemonAddress = ReachableAddress | RefusedAddress;
|
|
179
|
+
|
|
180
|
+
export interface ReachableAddress {
|
|
181
|
+
readonly ok: true;
|
|
182
|
+
readonly port: number;
|
|
183
|
+
readonly platform: NodeJS.Platform;
|
|
184
|
+
/** the socket path (POSIX) or the pipe name (win32) */
|
|
185
|
+
readonly sock: string;
|
|
186
|
+
readonly pipe: boolean;
|
|
187
|
+
/** the handshake, or null where the socket's directory is the check */
|
|
188
|
+
readonly auth: IpcAuth | null;
|
|
189
|
+
/** POSIX with uids: the directory checked at every use, and whether this module makes it (the fallback under the
|
|
190
|
+
* tmpdir — never XDG_RUNTIME_DIR, which is the system's) */
|
|
191
|
+
readonly dir: { readonly path: string; readonly uid: number; readonly make: boolean } | null;
|
|
192
|
+
readonly legacy: LegacyPlace | null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Where a daemon from an earlier build listened (<tmpdir>/designtwin-<port>.sock) when that is not the current socket,
|
|
196
|
+
* and the owner a socket there must have to be ours. Kept on a refused address too (a current path too long): that
|
|
197
|
+
* old daemon may well be what holds the port. */
|
|
198
|
+
export interface LegacyPlace { readonly sock: string; readonly uid: number }
|
|
199
|
+
|
|
200
|
+
export interface RefusedAddress {
|
|
201
|
+
readonly ok: false;
|
|
202
|
+
readonly port: number;
|
|
203
|
+
readonly why: string;
|
|
204
|
+
/** refused for want of a token (a pipe, or a handshake, with none saved): no daemon of ours can be there */
|
|
205
|
+
readonly noToken: boolean;
|
|
206
|
+
readonly legacy: LegacyPlace | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Why a handshaking daemon cannot be named or served: no token to key it on. */
|
|
210
|
+
export const NO_DAEMON_TOKEN = "no bridge token is saved yet, so the daemon's pipe cannot be named — start a bridge once (any dtwin command that connects to Figma saves one) and try again";
|
|
211
|
+
|
|
212
|
+
/** THE resolution: the daemon's socket (POSIX) or pipe (win32) for `port`, and its handshake — or a refusal with
|
|
213
|
+
* its reason (no token to name a pipe or key a handshake; a socket path too long for the platform). The only
|
|
214
|
+
* filesystem look is whether XDG_RUNTIME_DIR is private now; the directory chosen is checked again at each use. */
|
|
215
|
+
export function daemonAddress(i: AddressInput): DaemonAddress {
|
|
216
|
+
const { port, token, platform, uid } = i;
|
|
217
|
+
const handshake = i.handshake ?? platform === "win32";
|
|
218
|
+
const refuse = (why: string, noToken = false, legacy: LegacyPlace | null = null): RefusedAddress => ({ ok: false, port, why, noToken, legacy });
|
|
219
|
+
if (token === null && (handshake || platform === "win32")) return refuse(NO_DAEMON_TOKEN, true);
|
|
220
|
+
const authFor = (sock: string): IpcAuth | null => (handshake && token !== null ? { key: ipcKey(token), name: sock } : null);
|
|
221
|
+
if (platform === "win32" && token !== null) {
|
|
222
|
+
const sock = pipeName(token, port);
|
|
223
|
+
return { ok: true, port, platform, sock, pipe: true, auth: authFor(sock), dir: null, legacy: null };
|
|
224
|
+
}
|
|
225
|
+
const name = `designtwin-${port}.sock`;
|
|
226
|
+
const xdg = i.runtimeDir;
|
|
227
|
+
const dir = uid === undefined ? null
|
|
228
|
+
: xdg && path.isAbsolute(xdg) && privateDirProblem(xdg, uid) === null ? { path: xdg, uid, make: false }
|
|
229
|
+
: { path: path.join(i.tmpdir, `designtwin-${uid}`), uid, make: true };
|
|
230
|
+
const sock = path.join(dir?.path ?? i.tmpdir, name);
|
|
231
|
+
// No build ever served a daemon on Windows before the pipe; without uids the current location IS the tmpdir.
|
|
232
|
+
const old = path.join(i.tmpdir, name);
|
|
233
|
+
const legacy = dir && old !== sock ? { sock: old, uid: dir.uid } : null;
|
|
234
|
+
const tooLong = sockLengthProblem(sock, platform);
|
|
235
|
+
if (tooLong) return refuse(tooLong, false, legacy);
|
|
236
|
+
return { ok: true, port, platform, sock, pipe: false, auth: authFor(sock), dir, legacy };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The refusal when something that cannot prove the token holds the daemon's name: not ours, and nothing
|
|
240
|
+
* was sent to it. ONE spelling (serve, and a request whose daemon fails the handshake). */
|
|
241
|
+
export function foreignHolder(sock: string): Error {
|
|
242
|
+
return new Error(`${sock} is held by a process that could not prove it knows your bridge token — not a dtwin daemon of yours, so nothing was sent to it. ` +
|
|
243
|
+
"If it outlives your own dtwin sessions, another user may be squatting the name: `dtwin --rotate-token` moves the daemon to a new one.");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Why `dir` is not safe to hold this user's socket, or null when it is. */
|
|
247
|
+
export function privateDirProblem(dir: string, uid: number): string | null {
|
|
248
|
+
let st: fs.Stats;
|
|
249
|
+
try { st = fs.lstatSync(dir); } catch (e) { return "cannot stat it (" + errMsg(e) + ")"; }
|
|
250
|
+
if (st.isSymbolicLink()) return "it is a symlink";
|
|
251
|
+
if (!st.isDirectory()) return "it is not a directory";
|
|
252
|
+
if (st.uid !== uid) return `it is owned by uid ${st.uid}, not ${uid}`;
|
|
253
|
+
if ((st.mode & 0o077) !== 0) return `its mode is ${(st.mode & 0o777).toString(8).padStart(4, "0")} (group/other may access it; it must be 0700)`;
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** What to do about a refused socket location — quoted by every refusal, and by `dtwin doctor`. */
|
|
258
|
+
export const SOCK_FIX = "Remove it (or have its owner remove it), or point XDG_RUNTIME_DIR or TMPDIR at a directory only you can access.";
|
|
259
|
+
|
|
260
|
+
/** Why the existing file at `sock` must not be used (connected to, or removed), or null — also null
|
|
261
|
+
* when there is no file yet. */
|
|
262
|
+
export function sockFileProblem(sock: string, uid: number): string | null {
|
|
263
|
+
let st: fs.Stats;
|
|
264
|
+
try { st = fs.lstatSync(sock); } catch { return null; }
|
|
265
|
+
return st.uid === uid ? null : `it is owned by uid ${st.uid}, not ${uid}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The longest socket path (bytes, without the NUL) `platform` is held to — see "Length" above. Only
|
|
269
|
+
* macOS has one: on Linux Bun reaches any length through /proc/self/fd. */
|
|
270
|
+
export const MAX_SOCK_PATH_BYTES: Partial<Record<NodeJS.Platform, number>> = { darwin: 103 };
|
|
271
|
+
|
|
272
|
+
/** Why `sock` is too long to be a socket path on `platform`, or null. */
|
|
273
|
+
export function sockLengthProblem(sock: string, platform: NodeJS.Platform = process.platform): string | null {
|
|
274
|
+
const max = MAX_SOCK_PATH_BYTES[platform];
|
|
275
|
+
const bytes = Buffer.byteLength(sock);
|
|
276
|
+
if (max === undefined || bytes <= max) return null;
|
|
277
|
+
return `refusing to use ${sock} as the dtwin daemon socket: it is ${bytes} bytes, and a Unix socket path on ${platform === "darwin" ? "macOS" : platform} is limited to ${max}. ` +
|
|
278
|
+
"Point XDG_RUNTIME_DIR or TMPDIR at a shorter directory only you can access.";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The socket of `a`, its directory checked NOW. `make` (serve, assertNoDaemon): the fallback directory is created
|
|
282
|
+
// if it is missing. A lookup (`make` false) creates nothing: a directory that does not exist yet is null — no
|
|
283
|
+
// daemon can be listening in it. Throws the refusal when the directory, or a socket file already in it, is not
|
|
284
|
+
// this user's own.
|
|
285
|
+
function checkedSock(a: ReachableAddress, make: boolean): string | null {
|
|
286
|
+
if (!a.dir) return a.sock;
|
|
287
|
+
const { path: dir, uid } = a.dir;
|
|
288
|
+
if (make && a.dir.make) {
|
|
289
|
+
// mode is narrowed further by the umask, never widened; an existing directory is left as it is and
|
|
290
|
+
// judged by the check below.
|
|
291
|
+
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (e) { if (errCode(e) !== "EEXIST") throw e; }
|
|
292
|
+
} else if (!make) {
|
|
293
|
+
try { fs.lstatSync(dir); } catch (e) { if (errCode(e) === "ENOENT") return null; }
|
|
294
|
+
}
|
|
295
|
+
const bad = privateDirProblem(dir, uid);
|
|
296
|
+
if (bad) throw new Error(`refusing to use ${dir} for the dtwin daemon socket: ${bad}. ${SOCK_FIX}`);
|
|
297
|
+
const file = sockFileProblem(a.sock, uid);
|
|
298
|
+
if (file) throw new Error(`refusing to use ${a.sock} as the dtwin daemon socket: ${file}. ${SOCK_FIX}`);
|
|
299
|
+
return a.sock;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The socket to serve at `a` — its directory created if needed, and checked (stop and the lookups create nothing).
|
|
303
|
+
* Throws the address's refusal, or the location's. */
|
|
304
|
+
export function sockPath(a: DaemonAddress): string {
|
|
305
|
+
if (!a.ok) throw new Error(a.why);
|
|
306
|
+
return checkedSock(a, true) ?? a.sock;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The directory the daemon socket lives in — created if needed, and checked (POSIX: a pipe has none, and throws). */
|
|
310
|
+
export function sockDir(a: DaemonAddress): string {
|
|
311
|
+
if (a.ok && a.pipe) throw new Error(`${a.sock} is a named pipe: it has no socket directory`);
|
|
312
|
+
return path.dirname(sockPath(a));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Why the daemon at `a` cannot be used, or null when it can — the refusal as a value, without creating anything.
|
|
316
|
+
* Null for a pipe (the handshake is its check) and for want of a token (no daemon of ours can be there). */
|
|
317
|
+
export function sockProblem(a: DaemonAddress): string | null {
|
|
318
|
+
if (!a.ok) return a.noToken ? null : a.why;
|
|
319
|
+
try { checkedSock(a, false); return null; } catch (e) { return errMsg(e); }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// A refused location must not take every command down with it. Anyone able to create
|
|
323
|
+
// <tmpdir>/designtwin-<uid> first (a shared /tmp) could otherwise lock this user out of dtwin
|
|
324
|
+
// entirely. So the "is a daemon running?" lookups (connect, status) read a refusal as "no daemon" —
|
|
325
|
+
// the one-shot path then runs exactly as it does without a daemon — and say so ONCE per process, on
|
|
326
|
+
// stderr (stdout is the MCP server's protocol channel, and a CLI's data). No token is no daemon, silently.
|
|
327
|
+
let refusalWarned = false;
|
|
328
|
+
function lookup(a: DaemonAddress): ReachableAddress | null {
|
|
329
|
+
const warn = (why: string): null => {
|
|
330
|
+
if (!refusalWarned) {
|
|
331
|
+
refusalWarned = true;
|
|
332
|
+
console.error("[dtwin] " + why + " Going on without a daemon.");
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
};
|
|
336
|
+
if (!a.ok) return a.noToken ? null : warn(a.why);
|
|
337
|
+
try { return checkedSock(a, false) === null ? null : a; } catch (e) { return warn(errMsg(e)); }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The socket of a daemon started by an earlier dtwin build, which listened directly in the tmpdir
|
|
341
|
+
* (<tmpdir>/designtwin-<port>.sock) — when a socket owned by this user is there and it is not the
|
|
342
|
+
* current location; otherwise null. Only ever used to stop that daemon or to name it in an error. */
|
|
343
|
+
export function legacySock(a: DaemonAddress): string | null {
|
|
344
|
+
if (!a.legacy) return null;
|
|
345
|
+
const { sock, uid } = a.legacy;
|
|
346
|
+
let st: fs.Stats;
|
|
347
|
+
try { st = fs.lstatSync(sock); } catch { return null; }
|
|
348
|
+
return st.isSocket() && st.uid === uid ? sock : null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Newline-delimited JSON. JSON.stringify escapes literal newlines, so a bare "\n" is an unambiguous
|
|
352
|
+
// frame terminator even for a 24MB export — but the frame can arrive in many chunks, so callers must
|
|
353
|
+
// buffer until they see one. Both sides use this to avoid two subtly different reassembly loops.
|
|
354
|
+
// Chunks are strings: every socket fed here has setEncoding("utf8"), whose decoder holds back a
|
|
355
|
+
// multi-byte character split across reads. Only the NEW chunk is searched for "\n", and the pieces
|
|
356
|
+
// of an unfinished frame are joined once, when its newline arrives: appending to one buffer and
|
|
357
|
+
// searching it from the start on every chunk is quadratic in the frame size (a 100 MB export in
|
|
358
|
+
// 64 KB reads spent tens of seconds just being split).
|
|
359
|
+
export function framer(onFrame: (line: string) => void): (chunk: string) => void {
|
|
360
|
+
let parts: string[] = [];
|
|
361
|
+
return (chunk) => {
|
|
362
|
+
let start = 0;
|
|
363
|
+
let i;
|
|
364
|
+
while ((i = chunk.indexOf("\n", start)) >= 0) {
|
|
365
|
+
const tail = chunk.slice(start, i);
|
|
366
|
+
start = i + 1;
|
|
367
|
+
let line = tail;
|
|
368
|
+
if (parts.length) { parts.push(tail); line = parts.join(""); parts = []; }
|
|
369
|
+
if (line) onFrame(line);
|
|
370
|
+
}
|
|
371
|
+
if (start < chunk.length) parts.push(start ? chunk.slice(start) : chunk);
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------- server
|
|
376
|
+
|
|
377
|
+
/** The refusal `--serve` gives when a daemon already holds this socket. ONE spelling: figma-pull.ts
|
|
378
|
+
* asks before it opens a bridge of its own, and serve() asks again right before listening. */
|
|
379
|
+
export function alreadyRunning(sock: string): Error {
|
|
380
|
+
return new Error(`a dtwin daemon is already running on ${sock} — stop it with --stop`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The socket path at `a` — or a throw: the address's refusal, a refused location, a LIVE daemon already answering on
|
|
384
|
+
* it, or (handshaking) something that cannot prove the token holding the name (foreignHolder). */
|
|
385
|
+
export async function assertNoDaemon(a: DaemonAddress): Promise<string> {
|
|
386
|
+
return (await unclaimed(a)).sock;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function unclaimed(a: DaemonAddress): Promise<ReachableAddress> {
|
|
390
|
+
if (!a.ok) throw new Error(a.why);
|
|
391
|
+
const sock = sockPath(a);
|
|
392
|
+
// A socket file left by a crashed daemon is NOT a running daemon. Probing it first (rather than
|
|
393
|
+
// unlinking unconditionally) is what keeps `--serve` from silently stealing a live daemon's socket.
|
|
394
|
+
const held = await probeAs(sock, a.auth);
|
|
395
|
+
if (held === "live") throw alreadyRunning(sock);
|
|
396
|
+
if (held === "foreign") throw foreignHolder(sock);
|
|
397
|
+
return a;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// The `exit` hook that removes the socket file when a `signals: false` host (the MCP server) goes
|
|
401
|
+
// away. Registered ONCE per process, over a set of live socket paths: figma-mcp re-serves after every
|
|
402
|
+
// `dtwin stop`, and a listener per serve() call would pile up (and warn) over a long session.
|
|
403
|
+
const exitUnlink = new Set<string>();
|
|
404
|
+
let exitHooked = false;
|
|
405
|
+
function unlinkOnExit(sock: string): () => void {
|
|
406
|
+
exitUnlink.add(sock);
|
|
407
|
+
if (!exitHooked) {
|
|
408
|
+
exitHooked = true;
|
|
409
|
+
process.on("exit", () => { for (const s of exitUnlink) { try { fs.unlinkSync(s); } catch { /* already gone */ } } });
|
|
410
|
+
}
|
|
411
|
+
return () => { exitUnlink.delete(sock); };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Last-resort handlers for the LONG-LIVED processes only (the `dtwin serve` daemon, the MCP server) —
|
|
415
|
+
// never a one-shot CLI run, where the runtime's default (print the stack, exit non-zero) is already right.
|
|
416
|
+
// Both log ONE line through errMsg, so the cause reaches stderr the process's own log format.
|
|
417
|
+
// - uncaughtException: log, run `cleanup` (synchronous only), then exit 1. The Node docs are explicit
|
|
418
|
+
// that carrying on is unsafe: "The correct use of 'uncaughtException' is to perform synchronous
|
|
419
|
+
// cleanup of allocated resources (e.g. file descriptors, handles, etc) before shutting down the
|
|
420
|
+
// process. It is not safe to resume normal operation after 'uncaughtException'."
|
|
421
|
+
// - unhandledRejection: log only. A stray rejected promise (a tool whose caller went away, a late
|
|
422
|
+
// socket write) must not take down the user's whole stdio MCP session or a daemon mid-export with it.
|
|
423
|
+
// Installed at most once per process: figma-mcp re-serves after every `dtwin stop`. Each call's `cleanup` is kept,
|
|
424
|
+
// though (the MCP server installs first, with its own; a serve() that installs too adds the daemon's shutdown), and all
|
|
425
|
+
// of them run, in order, each in its own try — the returned function drops that cleanup again (a stopped daemon).
|
|
426
|
+
// The cleanups are what release the PORT and the socket: Bun.serve's stop() and net.Server's close() stop listening
|
|
427
|
+
// synchronously, so by the time the exit waits on stderr the port is already free for the next bridge. That wait is
|
|
428
|
+
// capped short (CRASH_FLUSH_CAP_MS): a host restarting the server right away must not find the old process still
|
|
429
|
+
// holding 8787 — or still alive — because a reader stopped draining its stderr.
|
|
430
|
+
const CRASH_FLUSH_CAP_MS = 200;
|
|
431
|
+
// The first SIGINT / SIGTERM's wait for stderr (serve() below); a second one does not wait at all.
|
|
432
|
+
const SIGNAL_FLUSH_CAP_MS = 300;
|
|
433
|
+
let crashHandlersInstalled = false;
|
|
434
|
+
const crashCleanups = new Set<() => void>();
|
|
435
|
+
export function installCrashHandlers(log: (m: string) => void, cleanup?: () => void): () => void {
|
|
436
|
+
if (cleanup) crashCleanups.add(cleanup);
|
|
437
|
+
const forget = (): void => { if (cleanup) crashCleanups.delete(cleanup); };
|
|
438
|
+
if (crashHandlersInstalled) return forget;
|
|
439
|
+
crashHandlersInstalled = true;
|
|
440
|
+
process.on("unhandledRejection", (reason) => {
|
|
441
|
+
log("unhandled promise rejection (continuing): " + errMsg(reason));
|
|
442
|
+
});
|
|
443
|
+
process.on("uncaughtException", (e) => {
|
|
444
|
+
// Say why, release (synchronously: the port and socket are free before anything waits), then wait — briefly — for
|
|
445
|
+
// those lines to reach stderr.
|
|
446
|
+
log("uncaught exception — exiting: " + errMsg(e));
|
|
447
|
+
for (const c of [...crashCleanups]) { try { c(); } catch { /* exiting anyway */ } }
|
|
448
|
+
exitFlushed(1, CRASH_FLUSH_CAP_MS);
|
|
449
|
+
});
|
|
450
|
+
return forget;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** serve()'s handle: the socket it listens on, and the served session's own (served-session.ts) — `activity()` (what
|
|
454
|
+
* the socket has seen, which a host reads to decide whether OTHER processes use it), `idleOut(ms)` ((re)arms the idle
|
|
455
|
+
* shutdown with a window of `ms`, 0 disarms it, and checks once AT ONCE) and `shutdown()`. */
|
|
456
|
+
export interface ServeHandle {
|
|
457
|
+
sock: string;
|
|
458
|
+
shutdown: () => void;
|
|
459
|
+
activity(): DaemonActivity;
|
|
460
|
+
idleOut(ms: number): void;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// serve() is the socket adapter around the served session (served-session.ts): the net.Server, the listen race and
|
|
464
|
+
// takeover of a stale socket file, the handshake, and the process's signals and crash hooks. What a frame is, what the
|
|
465
|
+
// daemon answers, the idle decision and cancel-on-leave are the session's.
|
|
466
|
+
export function serve(bridge: DaemonBridge, at: DaemonAddress, { log, idleMs: idleWindow, signals = true, crashHandlers = signals, clock }: ServeOptions): Promise<ServeHandle> {
|
|
467
|
+
return unclaimed(at).then(({ sock, pipe, auth }) => {
|
|
468
|
+
// A pipe (win32) has no file: nothing stale to remove before listening, or after. `auth`: every client proves
|
|
469
|
+
// the token before its first frame is read (null: the directory is the check).
|
|
470
|
+
if (!pipe) { try { fs.unlinkSync(sock); } catch { /* nothing to clean up */ } }
|
|
471
|
+
|
|
472
|
+
let forgetExit: (() => void) | null = null;
|
|
473
|
+
let forgetCrash: (() => void) | null = null;
|
|
474
|
+
// The session's shutdown lets the socket go FIRST (release), synchronously: stop listening, remove the socket file,
|
|
475
|
+
// drop the process hooks — then it aborts its requests and closes the bridge.
|
|
476
|
+
const session = createServedSession(bridge, {
|
|
477
|
+
idleMs: idleWindow,
|
|
478
|
+
...ifDefined("clock", clock),
|
|
479
|
+
...ifDefined("log", log),
|
|
480
|
+
release: () => {
|
|
481
|
+
try { server.close(); } catch { /* already closed */ }
|
|
482
|
+
if (!pipe) { try { fs.unlinkSync(sock); } catch { /* already gone */ } }
|
|
483
|
+
if (forgetExit) { forgetExit(); forgetExit = null; }
|
|
484
|
+
if (forgetCrash) { forgetCrash(); forgetCrash = null; }
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
const shutdown = session.shutdown;
|
|
488
|
+
|
|
489
|
+
const server = net.createServer((conn) => {
|
|
490
|
+
conn.setEncoding("utf8");
|
|
491
|
+
const client = session.connect((frame) => { send(conn, frame); });
|
|
492
|
+
// 'end' counts too: net.Server's default allowHalfOpen:false ends our side as soon as the peer
|
|
493
|
+
// ends its own, so no reply could be written after it anyway.
|
|
494
|
+
conn.on("close", client.leave);
|
|
495
|
+
conn.on("end", client.leave);
|
|
496
|
+
// A client that dies mid-export must not take the daemon with it: the write in send() would emit
|
|
497
|
+
// EPIPE on a dead socket, which is an unhandled 'error' event => process exit.
|
|
498
|
+
conn.on("error", client.leave);
|
|
499
|
+
const onFrames = framer(client.line);
|
|
500
|
+
if (!auth) { conn.on("data", onFrames); return; }
|
|
501
|
+
// Handshaking: until the client has proved the token, its bytes go to the handshake (capped, timed),
|
|
502
|
+
// never to the frame reader — so no __ping, no __status and no request is read, and no activity
|
|
503
|
+
// counted, before that. A failure is a silent destroy: the peer learns only that the pipe closed.
|
|
504
|
+
const hs = serverHandshake(auth, {
|
|
505
|
+
write: (line) => { try { conn.write(line + "\n"); } catch { /* the client went away */ } },
|
|
506
|
+
sink: onFrames,
|
|
507
|
+
fail: () => { try { conn.destroy(); } catch { /* already gone */ } },
|
|
508
|
+
});
|
|
509
|
+
conn.on("data", hs.data);
|
|
510
|
+
conn.on("close", hs.stop);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Ctrl-C / kill must remove the socket file, or the next --serve refuses to start against a
|
|
514
|
+
// socket nobody is listening on. `as const`: process.on's overload wants NodeJS.Signals, not string.
|
|
515
|
+
// shutdown() releases the socket file, the socket and the port synchronously; the exit then waits for the log lines
|
|
516
|
+
// to reach stderr — briefly (SIGNAL_FLUSH_CAP_MS: Ctrl-C must feel instant, and a supervisor's SIGKILL follows its
|
|
517
|
+
// SIGTERM soon after). A SECOND signal is a person (or supervisor) insisting: exit at once, waiting on nothing.
|
|
518
|
+
if (signals) {
|
|
519
|
+
let signalled = false;
|
|
520
|
+
for (const sig of ["SIGINT", "SIGTERM"] as const) process.on(sig, () => {
|
|
521
|
+
if (signalled) { exitFlushed(0, 0); return; }
|
|
522
|
+
signalled = true;
|
|
523
|
+
shutdown();
|
|
524
|
+
exitFlushed(0, SIGNAL_FLUSH_CAP_MS);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
// A host that exits some other way (the MCP server ends when its stdio closes) still must not
|
|
528
|
+
// leave the socket file behind for the next probe to trip over. One process-level hook, and this
|
|
529
|
+
// socket is forgotten again on shutdown (the MCP server re-serves after every `dtwin stop`).
|
|
530
|
+
else if (!pipe) forgetExit = unlinkOnExit(sock);
|
|
531
|
+
// shutdown() is synchronous (closes the servers, unlinks the socket file), so it is exactly the
|
|
532
|
+
// "synchronous cleanup" the Node docs allow an uncaughtException handler before it exits.
|
|
533
|
+
if (crashHandlers) forgetCrash = installCrashHandlers(log ?? ((m) => console.error("[dtwin] " + m)), shutdown);
|
|
534
|
+
|
|
535
|
+
const handle: ServeHandle = { sock, shutdown, activity: session.activity, idleOut: session.idleOut };
|
|
536
|
+
return new Promise<ServeHandle>((resolve, reject) => {
|
|
537
|
+
const onListening = () => {
|
|
538
|
+
if (log) log(`daemon listening on ${sock} (pid ${process.pid}) — stop it with: dtwin --stop`);
|
|
539
|
+
resolve(handle);
|
|
540
|
+
};
|
|
541
|
+
// The probe-then-unlink-then-listen window is a race: another `--serve` can bind between our
|
|
542
|
+
// probe and our listen. EADDRINUSE from listen() is that case — re-probe, and either refuse
|
|
543
|
+
// (it is alive) or take over the stale file it left (it is not), exactly once.
|
|
544
|
+
// A pipe has no stale file to take over: a name that is taken is held by a live process, ours
|
|
545
|
+
// (alreadyRunning) or not (foreignHolder). And ANY listen error there is read as "taken, maybe":
|
|
546
|
+
// Bun has reported a taken pipe name as ERR_INVALID_ARG_TYPE, not EADDRINUSE, so the re-probe
|
|
547
|
+
// decides, and an error with nothing holding the name is passed on as it came.
|
|
548
|
+
const failed = (e: NodeJS.ErrnoException): void => {
|
|
549
|
+
if (!pipe && e.code !== "EADDRINUSE") return reject(e);
|
|
550
|
+
probeAs(sock, auth).then((held) => {
|
|
551
|
+
if (held === "live") return reject(alreadyRunning(sock));
|
|
552
|
+
if (held === "foreign") return reject(foreignHolder(sock));
|
|
553
|
+
if (pipe) return reject(e);
|
|
554
|
+
try { fs.unlinkSync(sock); } catch { /* gone already */ }
|
|
555
|
+
server.once("error", reject);
|
|
556
|
+
server.listen(sock, onListening);
|
|
557
|
+
}, reject);
|
|
558
|
+
};
|
|
559
|
+
server.once("error", failed);
|
|
560
|
+
// A listen that throws instead of emitting (a runtime's pipe-path bug) takes the same road.
|
|
561
|
+
try { server.listen(sock, onListening); } catch (e) { server.removeListener("error", failed); failed(e as NodeJS.ErrnoException); }
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function send(conn: net.Socket, obj: SessionFrame): void {
|
|
567
|
+
try { conn.write(JSON.stringify(obj) + "\n"); } catch { /* client went away mid-reply */ }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------------------------------------------------------------- client
|
|
571
|
+
|
|
572
|
+
// Is there a LIVE daemon? A stale socket file (crashed daemon) answers ECONNREFUSED, and must read as
|
|
573
|
+
// "no daemon" rather than hanging or erroring — otherwise one crash makes every later CLI run fail.
|
|
574
|
+
// `auth` (handshaking, the address's): the __ping goes out only once the daemon has proved the token.
|
|
575
|
+
export function probe(sock: string, timeoutMs = 1500, auth: IpcAuth | null = null): Promise<boolean> {
|
|
576
|
+
return probeAs(sock, auth, timeoutMs).then((held) => held === "live");
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** What holds `sock`: "live" — a daemon that answered (and, handshaking, proved the token); "none" —
|
|
580
|
+
* nothing, a stale file, or (not handshaking) something that never answered; "foreign" — handshaking,
|
|
581
|
+
* something accepted the connection but did not prove the token (a squatter, or a mute listener). */
|
|
582
|
+
export type ProbeResult = "live" | "none" | "foreign";
|
|
583
|
+
|
|
584
|
+
export function probeAs(sock: string, auth: IpcAuth | null, timeoutMs = 1500): Promise<ProbeResult> {
|
|
585
|
+
return ask(sock, auth, "__ping", timeoutMs).then((r) => r.held);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// One control frame (`__ping`, `__status`) on ONE connection, with the probe's reading of what holds the socket: the
|
|
589
|
+
// daemon's reply when it is "live". A status is its own probe — no second connection after a __ping.
|
|
590
|
+
function ask(sock: string, auth: IpcAuth | null, cmd: "__ping" | "__status", timeoutMs: number): Promise<{ held: ProbeResult; reply: DaemonReply | null }> {
|
|
591
|
+
return new Promise<{ held: ProbeResult; reply: DaemonReply | null }>((resolve) => {
|
|
592
|
+
let done = false;
|
|
593
|
+
let connected = false;
|
|
594
|
+
let stopHs = (): void => {};
|
|
595
|
+
let c: net.Socket | null = null;
|
|
596
|
+
// Handshaking, a peer that accepted and then stalled or failed holds the name: "foreign", not "none".
|
|
597
|
+
const silent = (): ProbeResult => (connected && auth ? "foreign" : "none");
|
|
598
|
+
const t = setTimeout(() => finish(silent()), timeoutMs);
|
|
599
|
+
function finish(v: ProbeResult, reply: DaemonReply | null = null): void {
|
|
600
|
+
if (done) return;
|
|
601
|
+
done = true;
|
|
602
|
+
clearTimeout(t);
|
|
603
|
+
stopHs();
|
|
604
|
+
try { if (v === "live") c?.end(); else c?.destroy(); } catch { /* already gone */ }
|
|
605
|
+
resolve({ held: v, reply });
|
|
606
|
+
}
|
|
607
|
+
try { c = net.createConnection(sock); } catch { return finish("none"); }
|
|
608
|
+
const conn = c;
|
|
609
|
+
conn.on("error", () => finish(silent()));
|
|
610
|
+
conn.setEncoding("utf8");
|
|
611
|
+
const ping = (): void => { try { conn.write(JSON.stringify({ cmd }) + "\n"); } catch { finish(silent()); } };
|
|
612
|
+
// Only a daemon REPLY is a daemon: a handshaking daemon's hello reaching a client that does not handshake
|
|
613
|
+
// (it wrote its __ping first) is not one, and neither is anything else that happens to write a line.
|
|
614
|
+
const onFrame = framer((line) => {
|
|
615
|
+
let parsed: unknown;
|
|
616
|
+
try { parsed = JSON.parse(line) as unknown; } catch { return finish(silent()); }
|
|
617
|
+
const reply = parseDaemonReply(parsed);
|
|
618
|
+
if (reply) finish("live", reply); else finish(silent());
|
|
619
|
+
});
|
|
620
|
+
if (!auth) {
|
|
621
|
+
conn.on("data", onFrame);
|
|
622
|
+
conn.on("connect", () => { connected = true; ping(); });
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const hs = clientHandshake(auth, {
|
|
626
|
+
write: (l) => { try { conn.write(l + "\n"); } catch { /* the error handler settles it */ } },
|
|
627
|
+
sink: onFrame,
|
|
628
|
+
ready: ping,
|
|
629
|
+
fail: () => finish("foreign"),
|
|
630
|
+
timeoutMs,
|
|
631
|
+
});
|
|
632
|
+
stopHs = hs.stop;
|
|
633
|
+
conn.on("data", hs.data);
|
|
634
|
+
conn.on("connect", () => { connected = true; });
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// One request through a running daemon. Rejects (rather than falling back) once connected: a daemon
|
|
639
|
+
// that answered __ping and then failed is a real error the caller should see, not a reason to
|
|
640
|
+
// silently open a second bridge that would then hit EADDRINUSE against the daemon itself.
|
|
641
|
+
//
|
|
642
|
+
// `onProgress` opts the request into progress frames (`progress: true` on the wire) and receives each
|
|
643
|
+
// well-formed tick. A progress frame is never the reply: it is dispatched and the reader reads on. A
|
|
644
|
+
// malformed one (a bad tick, or a frame this client never asked for) is DROPPED, not fatal — a tick is
|
|
645
|
+
// advisory, and losing one must never cost the caller its export. A throwing listener is its own
|
|
646
|
+
// failure, as in server-core's relay.
|
|
647
|
+
//
|
|
648
|
+
// Timeout: `timeoutMs` is the outer guard for a daemon that stopped answering entirely (the daemon's
|
|
649
|
+
// own per-command budget, msg.timeoutMs, still bounds the whole command on the bridge side). Every
|
|
650
|
+
// progress frame RE-ARMS it: a tick is proof the daemon and the plugin are both alive. So with progress
|
|
651
|
+
// flowing the guard is a silence budget; without progress it is the same total budget it always was. It is
|
|
652
|
+
// clock.ts's deadline — the rule the bridge's own budgets run on (pending-requests.ts), stepped by the suites.
|
|
653
|
+
//
|
|
654
|
+
// `signal` (optional): aborted → the socket is DESTROYED and the promise rejects at once, with the
|
|
655
|
+
// signal's reason when it is an Error of the caller's own, else "request aborted by the caller" — the
|
|
656
|
+
// same rule as server-core's request. Destroying (not ending) the socket is the cancel: the
|
|
657
|
+
// daemon sees its client go and abandons the request on the wire. Aborted before the call → nothing
|
|
658
|
+
// is connected or sent.
|
|
659
|
+
//
|
|
660
|
+
// `auth` (handshaking): nothing is written until the daemon has proved the token; one that cannot is
|
|
661
|
+
// refused with foreignHolder and never sees the request.
|
|
662
|
+
function request<C extends Cmd>(sock: string, msg: DaemonCommandRequest<C> | DaemonControlRequest, timeoutMs?: number, onProgress?: (tick: ProgressTick) => void, signal?: AbortSignal, auth: IpcAuth | null = null): Promise<{ result: unknown; client: ClientRow | null }> {
|
|
663
|
+
return new Promise<{ result: unknown; client: ClientRow | null }>((resolve, reject) => {
|
|
664
|
+
if (signal?.aborted) return reject(abortReason(signal, "request aborted before it was sent"));
|
|
665
|
+
const c = net.createConnection(sock);
|
|
666
|
+
c.setEncoding("utf8");
|
|
667
|
+
let settled = false;
|
|
668
|
+
let stopHs = (): void => {};
|
|
669
|
+
// Removed on settle, so a long-lived signal shared by many requests never accumulates listeners.
|
|
670
|
+
let onAbort: (() => void) | null = null;
|
|
671
|
+
const unlisten = () => { if (signal && onAbort) signal.removeEventListener("abort", onAbort); onAbort = null; };
|
|
672
|
+
const done = <V>(fn: (v: V) => void, v: V) => { if (!settled) { settled = true; unlisten(); stopHs(); try { c.end(); } catch { /* already gone */ } fn(v); } };
|
|
673
|
+
// The daemon's own per-command budget still applies; this is the outer guard for a daemon that
|
|
674
|
+
// stopped answering entirely. Generous by design — an --all-pages export legitimately runs long.
|
|
675
|
+
// The deadline rule (clock.ts) the bridge's own budgets run on: armed now, re-armed by every tick.
|
|
676
|
+
const guard = timeoutMs ? deadline(systemClock, timeoutMs, () => done(reject, new Error("daemon did not respond within " + Math.round(timeoutMs / 1000) + "s"))) : null;
|
|
677
|
+
const disarm = (): void => { guard?.cancel(); };
|
|
678
|
+
const arm = (): void => { guard?.rearm(); };
|
|
679
|
+
c.on("error", (e) => { disarm(); done(reject, e); });
|
|
680
|
+
c.on("close", () => { disarm(); done(reject, new Error("daemon closed the connection before replying")); });
|
|
681
|
+
const onFrames = framer((line) => {
|
|
682
|
+
if (settled) return;
|
|
683
|
+
let parsed: unknown;
|
|
684
|
+
try { parsed = JSON.parse(line) as unknown; } catch (e) { disarm(); return done(reject, new Error("bad reply frame: " + errMsg(e))); }
|
|
685
|
+
if (isProgressShaped(parsed)) {
|
|
686
|
+
arm(); // activity: the silence budget starts over (see above)
|
|
687
|
+
if (onProgress && isProgressTick(parsed.progress)) {
|
|
688
|
+
try { onProgress(parsed.progress); } catch { /* a listener's failure is its own; the request goes on */ }
|
|
689
|
+
}
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
disarm();
|
|
693
|
+
const r = parseDaemonReply(parsed);
|
|
694
|
+
if (!r) return done(reject, new Error("bad reply frame: not a daemon reply"));
|
|
695
|
+
if (r.ok) done(resolve, { result: r.result, client: r.client ?? null });
|
|
696
|
+
else done(reject, new Error(r.error));
|
|
697
|
+
});
|
|
698
|
+
// `progress: true` only with a listener: a request without one is byte-identical on the wire to
|
|
699
|
+
// what it always was, so no daemon ever has a reason to send it a progress frame.
|
|
700
|
+
const frame = onProgress && !CONTROL.has(msg.cmd) ? { ...msg, progress: true } : msg;
|
|
701
|
+
const send = (): void => { c.write(JSON.stringify(frame) + "\n"); };
|
|
702
|
+
if (!auth) {
|
|
703
|
+
c.on("data", onFrames);
|
|
704
|
+
c.on("connect", send);
|
|
705
|
+
} else {
|
|
706
|
+
const hs = clientHandshake(auth, {
|
|
707
|
+
write: (l) => { try { c.write(l + "\n"); } catch { /* the close handler settles it */ } },
|
|
708
|
+
sink: onFrames,
|
|
709
|
+
ready: send,
|
|
710
|
+
fail: () => { disarm(); done(reject, foreignHolder(sock)); try { c.destroy(); } catch { /* already gone */ } },
|
|
711
|
+
// A request follows a probe that already saw this daemon prove the token, so a slow answer here is a
|
|
712
|
+
// busy daemon, not a stranger: it gets the request's own budget, never less than the handshake's.
|
|
713
|
+
timeoutMs: Math.max(AUTH_TIMEOUT_MS, timeoutMs ?? 0),
|
|
714
|
+
});
|
|
715
|
+
stopHs = hs.stop;
|
|
716
|
+
c.on("data", hs.data);
|
|
717
|
+
}
|
|
718
|
+
if (signal) {
|
|
719
|
+
const sig = signal;
|
|
720
|
+
onAbort = () => {
|
|
721
|
+
if (settled) return;
|
|
722
|
+
disarm();
|
|
723
|
+
done(reject, abortReason(sig, "request aborted by the caller"));
|
|
724
|
+
try { c.destroy(); } catch { /* already gone */ }
|
|
725
|
+
};
|
|
726
|
+
sig.addEventListener("abort", onAbort, { once: true });
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// The routing decision every CLI command makes: use the daemon if one is live, otherwise report that
|
|
732
|
+
// there isn't one and let the caller open its own bridge exactly as it does without a daemon. The probe proves
|
|
733
|
+
// it is live; each request then opens its own connection (the protocol is one request per connection).
|
|
734
|
+
export async function connect(at: DaemonAddress): Promise<DaemonConnection | null> {
|
|
735
|
+
const a = lookup(at);
|
|
736
|
+
if (!a || !(await probe(a.sock, undefined, a.auth))) return null;
|
|
737
|
+
const { sock, auth } = a;
|
|
738
|
+
// The daemon's reply to a forwarded command is the plugin's own reply. The daemon's bridge already
|
|
739
|
+
// checked it against commands.ts on arrival, but that was another process (and possibly an older
|
|
740
|
+
// build), so it is checked again here — which is what makes the typed reply below honest rather
|
|
741
|
+
// than a guess about what came over the socket.
|
|
742
|
+
const send = async <C extends Cmd>(msg: DaemonCommandRequest<C>, o: DaemonRequestOptions = {}): Promise<Sent<C>> => {
|
|
743
|
+
const r = await request(sock, msg, o.guardMs, o.onProgress, o.signal, auth);
|
|
744
|
+
const bad = replyShapeError(msg.cmd, r.result);
|
|
745
|
+
if (bad) throw new Error(`the daemon relayed an unexpected shape for ${msg.cmd}: ${bad}`);
|
|
746
|
+
return { reply: r.result as Commands[C]["reply"], client: r.client };
|
|
747
|
+
};
|
|
748
|
+
return { sock, request: send };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Stops the live daemon at `at`: true when one was stopped, false when none was running. A refused socket
|
|
752
|
+
* location throws; no saved token (a pipe, or a handshake) means no daemon of ours to stop: false. */
|
|
753
|
+
export async function stop(at: DaemonAddress): Promise<boolean> {
|
|
754
|
+
if (!at.ok) {
|
|
755
|
+
if (at.noToken) return false;
|
|
756
|
+
throw new Error(at.why);
|
|
757
|
+
}
|
|
758
|
+
const sock = checkedSock(at, false);
|
|
759
|
+
if (sock === null || !(await probe(sock, undefined, at.auth))) return false;
|
|
760
|
+
await request(sock, { cmd: "__shutdown" }, 5000, undefined, undefined, at.auth);
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Stops a live daemon from an earlier build (legacySock); returns its socket, or null when there is
|
|
765
|
+
* none. For `--stop` after stop() found nothing at the current location. */
|
|
766
|
+
export async function stopLegacy(at: DaemonAddress): Promise<string | null> {
|
|
767
|
+
const sock = legacySock(at);
|
|
768
|
+
if (!sock || !(await probe(sock))) return null;
|
|
769
|
+
await request(sock, { cmd: "__shutdown" }, 5000);
|
|
770
|
+
return sock;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// The status probe's budget: a __status is answered by the daemon itself, never forwarded, but a daemon busy relaying
|
|
774
|
+
// a large export may take a moment to read it — the budget the separate request after a __ping used to get.
|
|
775
|
+
const STATUS_TIMEOUT_MS = 5000;
|
|
776
|
+
|
|
777
|
+
/** The live daemon's own __status, or null when none is running at `at` — one connection: the __status is the probe. */
|
|
778
|
+
export async function status(at: DaemonAddress): Promise<DaemonStatusView | null> {
|
|
779
|
+
const a = lookup(at);
|
|
780
|
+
if (!a) return null;
|
|
781
|
+
const { held, reply } = await ask(a.sock, a.auth, "__status", STATUS_TIMEOUT_MS);
|
|
782
|
+
if (held !== "live" || !reply) return null;
|
|
783
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
784
|
+
// `daemon: true` is checked, the rest is read as optional (a daemon from an OLDER bridge may omit newer
|
|
785
|
+
// fields — see staleness.ts daemonRowStalenessNote).
|
|
786
|
+
if (!isDaemonStatus(reply.result)) throw new Error("the daemon answered __status with an unexpected shape");
|
|
787
|
+
return reply.result;
|
|
788
|
+
}
|