@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
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// served-session.ts — the daemon's served session, apart from its socket: what a frame from a client is, what the
|
|
2
|
+
// daemon answers, and the session's own lifetime around the bridge it serves (DaemonBridge).
|
|
3
|
+
//
|
|
4
|
+
// daemon.ts serve() is the adapter around it: the net.Server, the listen race and takeover of a stale socket file, the
|
|
5
|
+
// handshake (daemon-auth.ts) and the process's signals and crash hooks. Each accepted connection becomes one
|
|
6
|
+
// `connect(write)` here, fed the connection's lines; everything after that is this module's, on a Clock (clock.ts), so
|
|
7
|
+
// the suites drive it with a fake one (test/served-session.test.ts) instead of sockets and wall-clock sleeps:
|
|
8
|
+
//
|
|
9
|
+
// FRAMES every line from a client is activity, and counted (`activity().frames`: a host reads it to decide whether
|
|
10
|
+
// OTHER processes use the socket — bridge-holder.ts). A line that is not a well-formed request frame is
|
|
11
|
+
// answered "bad request frame: …", never forwarded.
|
|
12
|
+
// CONTROL `__ping` / `__status` / `__shutdown` are answered here, never forwarded.
|
|
13
|
+
// FORWARD every other request goes to the bridge AS IT ARRIVES (the note above createServedSession), with progress
|
|
14
|
+
// frames ahead of the reply for a client that asked.
|
|
15
|
+
// IDLE a daemon outlives the terminal that started it, so a quiet one shuts itself down (see armIdle).
|
|
16
|
+
// CANCEL a client that leaves before its reply aborts its own requests' signals — a sent one gets the plugin a
|
|
17
|
+
// cancel frame (server-core), a waiting one is never sent — and shutdown() aborts every one.
|
|
18
|
+
// SHUTDOWN once, in order: the transport lets go (`release`), the requests are aborted, the bridge closes. A frame
|
|
19
|
+
// after it is refused.
|
|
20
|
+
|
|
21
|
+
import { errMsg } from "./errmsg.ts";
|
|
22
|
+
import { ifDefined, isRecord } from "./json-util.ts";
|
|
23
|
+
import { isCmd } from "./commands.ts";
|
|
24
|
+
import type { Cmd, Commands } from "./commands.ts";
|
|
25
|
+
import { humanMs } from "./human-ms.ts";
|
|
26
|
+
import { systemClock } from "./clock.ts";
|
|
27
|
+
import type { Clock } from "./clock.ts";
|
|
28
|
+
import type { ClientRow, ConnectionInfo, ProgressTick } from "./server-core.ts";
|
|
29
|
+
import type { Sent, UpstreamRequest } from "./route.ts";
|
|
30
|
+
|
|
31
|
+
// ---- the daemon socket protocol (newline-delimited JSON, one request -> one reply per connection)
|
|
32
|
+
//
|
|
33
|
+
// Progress (opt-in). A command frame that carries `progress: true` may be answered with any number of
|
|
34
|
+
// PROGRESS frames `{ id?, progress: ProgressTick }` before its one reply frame `{ ok, id?, … }` — the
|
|
35
|
+
// plugin's ticks as server-core hands them to the bridge request's `onProgress` listener. A progress
|
|
36
|
+
// frame has NO `ok` field, so it can never be mistaken for a reply; and it is only ever sent to a
|
|
37
|
+
// client that asked, because a client from before this field reads every frame as the reply and would
|
|
38
|
+
// reject a progress frame ("bad reply frame: not a daemon reply"). A daemon from before this field
|
|
39
|
+
// ignores `progress: true` and never sends one; the client (daemon.ts) copes (it simply sees the reply).
|
|
40
|
+
// Why: an MCP export routed through a daemon owes its caller notifications/progress (MCP spec
|
|
41
|
+
// 2025-06-18, basic/utilities/progress) exactly as one that holds the bridge does.
|
|
42
|
+
|
|
43
|
+
/** A bridge command to forward, typed per command from commands.ts (`switch (msg.cmd)` narrows `args`). */
|
|
44
|
+
export type DaemonCommandRequest<C extends Cmd = Cmd> = {
|
|
45
|
+
[K in C]: {
|
|
46
|
+
/** optional correlation id, echoed back on the reply */
|
|
47
|
+
id?: string;
|
|
48
|
+
cmd: K;
|
|
49
|
+
args?: Commands[K]["args"];
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
/** routing target, forwarded verbatim to the bridge (connId / fileKey / file-name substring) */
|
|
52
|
+
client?: string | null;
|
|
53
|
+
/** ms to wait for a plugin to connect before sending */
|
|
54
|
+
waitForConnection?: number;
|
|
55
|
+
/** opt in to progress frames before the reply (the protocol note above). Set by the client when
|
|
56
|
+
* the caller passes an `onProgress` listener — never needed by hand. */
|
|
57
|
+
progress?: boolean;
|
|
58
|
+
};
|
|
59
|
+
}[C];
|
|
60
|
+
|
|
61
|
+
/** One of the daemon's own commands — answered here, never forwarded. */
|
|
62
|
+
export interface DaemonControlRequest {
|
|
63
|
+
id?: string;
|
|
64
|
+
cmd: "__ping" | "__status" | "__shutdown";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A request frame: a bridge command to forward, or one of the daemon's own `__ping`/`__status`/`__shutdown`. */
|
|
68
|
+
export type DaemonRequest = DaemonCommandRequest | DaemonControlRequest;
|
|
69
|
+
|
|
70
|
+
/** The daemon's own command names (a client sends them without `progress`). */
|
|
71
|
+
export const CONTROL: ReadonlySet<string> = new Set<string>(["__ping", "__status", "__shutdown"]);
|
|
72
|
+
const isControl = (m: DaemonRequest): m is DaemonControlRequest => CONTROL.has(m.cmd);
|
|
73
|
+
|
|
74
|
+
/** Why `x` is not a well-formed request frame, or null when it is one. A `{}` frame would be forwarded
|
|
75
|
+
* with `cmd: undefined`, and `timeoutMs: "x"` would reach setTimeout as NaN. Both are refused here. */
|
|
76
|
+
export function daemonRequestError(x: unknown): string | null {
|
|
77
|
+
if (!isRecord(x)) return "not an object";
|
|
78
|
+
if (typeof x.cmd !== "string") return "`cmd` is missing";
|
|
79
|
+
if (x.id !== undefined && typeof x.id !== "string") return "`id` is not a string";
|
|
80
|
+
if (CONTROL.has(x.cmd)) return null;
|
|
81
|
+
if (!isCmd(x.cmd)) return `unknown cmd '${x.cmd}'`;
|
|
82
|
+
if (x.args !== undefined && !isRecord(x.args)) return "`args` is not an object";
|
|
83
|
+
if (x.timeoutMs !== undefined && !(typeof x.timeoutMs === "number" && Number.isFinite(x.timeoutMs) && x.timeoutMs > 0)) return "`timeoutMs` is not a positive number";
|
|
84
|
+
if (x.client !== undefined && x.client !== null && typeof x.client !== "string") return "`client` is not a string";
|
|
85
|
+
if (x.waitForConnection !== undefined && !(typeof x.waitForConnection === "number" && Number.isFinite(x.waitForConnection) && x.waitForConnection >= 0)) return "`waitForConnection` is not a number";
|
|
86
|
+
if (x.progress !== undefined && typeof x.progress !== "boolean") return "`progress` is not a boolean";
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isDaemonRequest(x: unknown): x is DaemonRequest {
|
|
91
|
+
return daemonRequestError(x) === null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A reply frame. `client` (on a forwarded command's success) is the connected file the bridge used —
|
|
95
|
+
* absent from a daemon older than this field. */
|
|
96
|
+
export type DaemonReply =
|
|
97
|
+
| { ok: true; id?: string; result: unknown; client?: ClientRow }
|
|
98
|
+
| { ok: false; id?: string; error: string };
|
|
99
|
+
|
|
100
|
+
/** A progress frame: one plugin tick, relayed while request `id` is in flight (opt-in, see above). */
|
|
101
|
+
export type DaemonProgressFrame = { id?: string; progress: ProgressTick };
|
|
102
|
+
|
|
103
|
+
/** What the session writes to one client: replies, and progress frames ahead of a reply. */
|
|
104
|
+
export type SessionFrame = DaemonReply | DaemonProgressFrame;
|
|
105
|
+
|
|
106
|
+
/** `__status`'s result — what `dtwin --daemon-status`, doctor and `list clients` read. */
|
|
107
|
+
export interface DaemonStatus {
|
|
108
|
+
daemon: true;
|
|
109
|
+
pid: number;
|
|
110
|
+
port: number;
|
|
111
|
+
pluginConnected: boolean;
|
|
112
|
+
clients: ClientRow[];
|
|
113
|
+
connection: ConnectionInfo | null;
|
|
114
|
+
idleMs: number | null;
|
|
115
|
+
idleForMs: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What a session serves: a bridge this process holds (route.ts servedBridge builds one from server-core's
|
|
119
|
+
* createBridge() result; the test suites pass fakes). `request` is the own adapter's (route.ts Upstream):
|
|
120
|
+
* the frame's fields as they arrived — `target` its `client`, `waitMs` its `waitForConnection` (the bridge
|
|
121
|
+
* waits that long for the target first, server-core awaitClient), `onProgress` receives every tick the
|
|
122
|
+
* chosen connection sends while the request is in flight (the source of progress frames), and `signal` is
|
|
123
|
+
* aborted when the session's client leaves (or the session shuts down) before the reply — server-core
|
|
124
|
+
* then sends the plugin a cancel frame for the request. It answers WHICH client the command went to (null
|
|
125
|
+
* when a fake does not say). The session never passes `stallMs`. `isConnected`, `listClients` and
|
|
126
|
+
* `connectionInfo` are read synchronously, for `__status`. */
|
|
127
|
+
export interface DaemonBridge {
|
|
128
|
+
port: number;
|
|
129
|
+
isConnected(): boolean;
|
|
130
|
+
// Method syntax, and `args` as the frame carries it (absent is forwarded as absent, as it always was).
|
|
131
|
+
request<C extends Cmd>(cmd: C, args: Commands[C]["args"] | undefined, o: UpstreamRequest): Promise<Sent<C>>;
|
|
132
|
+
close(): void;
|
|
133
|
+
listClients?: () => ClientRow[];
|
|
134
|
+
connectionInfo?: () => ConnectionInfo;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** What the session has seen so far — read by a host that has to decide whether OTHER processes use it
|
|
138
|
+
* (bridge-holder.ts, after its own client went away). `frames` counts every frame received since the session
|
|
139
|
+
* started, control frames (`__ping`/`__status`) included; `inFlight` is forwarded requests not yet answered;
|
|
140
|
+
* `idleForMs` is how long since the last frame or finished request. */
|
|
141
|
+
export interface DaemonActivity { frames: number; inFlight: number; idleForMs: number }
|
|
142
|
+
|
|
143
|
+
/** One client connection, as the session sees it: the lines it sent, one at a time, and its leaving (close, end or
|
|
144
|
+
* error — any of them, any number of times). */
|
|
145
|
+
export interface SessionClient {
|
|
146
|
+
line(text: string): void;
|
|
147
|
+
leave(): void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ServedSessionOptions {
|
|
151
|
+
/** the idle window in ms (0 = none); `idleOut` changes it later */
|
|
152
|
+
idleMs: number;
|
|
153
|
+
/** what the idle clock and its check run on (systemClock when absent) */
|
|
154
|
+
clock?: Clock;
|
|
155
|
+
log?: (m: string) => void;
|
|
156
|
+
/** the process id `__ping` / `__status` report (process.pid when absent) */
|
|
157
|
+
pid?: number;
|
|
158
|
+
/** the transport letting go, run FIRST by shutdown(): stop listening, remove the socket file, drop the process hooks */
|
|
159
|
+
release?: () => void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Requests are FORWARDED AS THEY ARRIVE, never ordered here. Which commands may run alongside which is a
|
|
163
|
+
// property of the command (commands.ts runKind), and the plugin's run scheduler enforces it for every caller
|
|
164
|
+
// (figma-plugin/src/state.ts scheduleRun) — this socket's clients, the MCP server that holds the bridge, and
|
|
165
|
+
// the plugin window's own buttons alike. A queue here could only see this socket's share of a file's runs, held
|
|
166
|
+
// one file's reads behind another file's export, and moved to the next request when an abandoned one's cancel
|
|
167
|
+
// was SENT, while the plugin was still unwinding it. The plugin's own chain moves when the run has ENDED.
|
|
168
|
+
|
|
169
|
+
export function createServedSession(bridge: DaemonBridge, o: ServedSessionOptions) {
|
|
170
|
+
const clock = o.clock ?? systemClock;
|
|
171
|
+
const log = o.log;
|
|
172
|
+
const pid = o.pid ?? process.pid;
|
|
173
|
+
let stopped = false;
|
|
174
|
+
let idleMs = o.idleMs;
|
|
175
|
+
let lastActivity = clock.now();
|
|
176
|
+
let inFlight = 0;
|
|
177
|
+
let frames = 0;
|
|
178
|
+
const touch = (): void => { lastActivity = clock.now(); };
|
|
179
|
+
|
|
180
|
+
// Idle shutdown. A daemon outlives the terminal that started it, so without this an orphan holds
|
|
181
|
+
// port 8787 until someone remembers --stop — and the next thing to want the bridge (a pull, or
|
|
182
|
+
// the MCP server) fails with EADDRINUSE pointing at a process the user forgot about.
|
|
183
|
+
//
|
|
184
|
+
// Deliberately generous: this exists to reap ABANDONED daemons, not to punish thinking time. The
|
|
185
|
+
// clock resets on every frame and when a request finishes, and a request in flight holds it off entirely
|
|
186
|
+
// (a 15-minute --all-pages export must never be shot in the back by its own daemon). 0 disables it.
|
|
187
|
+
// `idleMs` 0 lets a host that has its OWN lifetime (the MCP server lives as long as its Claude
|
|
188
|
+
// session) serve the socket without being reaped from under that session.
|
|
189
|
+
// `idleOut` may change the window later: the bridge holder switches an MCP session's from 0
|
|
190
|
+
// to the settings' window once its own client has gone and only others use the socket (bridge-holder.ts).
|
|
191
|
+
let stopIdle: (() => void) | null = null;
|
|
192
|
+
const check = (): void => {
|
|
193
|
+
if (stopped || !idleMs) return;
|
|
194
|
+
if (inFlight === 0 && clock.now() - lastActivity >= idleMs) {
|
|
195
|
+
if (log) log(`idle for ${humanMs(idleMs)} — shutting down (set FIGMA_DAEMON_IDLE_MIN=0 to disable).`);
|
|
196
|
+
shutdown();
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const armIdle = (ms: number): void => {
|
|
200
|
+
if (stopIdle) stopIdle();
|
|
201
|
+
stopIdle = null;
|
|
202
|
+
idleMs = ms;
|
|
203
|
+
if (!ms || stopped) return;
|
|
204
|
+
// Checked on a fixed beat rather than a deadline re-armed around every frame: one timer, and `inFlight` is
|
|
205
|
+
// consulted at check time instead of having to cancel/re-arm around every request. Never coarser than the
|
|
206
|
+
// window itself, so a short window (a test's, or the MCP server's after its client left) is honoured.
|
|
207
|
+
// unref: the idle CHECK must not be the thing keeping the process alive — the socket server and
|
|
208
|
+
// the WS server are. Otherwise a daemon whose servers closed would linger for the beat.
|
|
209
|
+
const every = Math.min(30000, ms);
|
|
210
|
+
const beat = (): void => { stopIdle = clock.after(every, beat, { unref: true }); check(); };
|
|
211
|
+
stopIdle = clock.after(every, beat, { unref: true });
|
|
212
|
+
};
|
|
213
|
+
armIdle(idleMs);
|
|
214
|
+
|
|
215
|
+
// CANCEL: every forwarded request, waiting for its client or in flight, has an AbortController here AND in its
|
|
216
|
+
// client's own set (connect below). shutdown() aborts all of them; a client that leaves, its own.
|
|
217
|
+
const outstanding = new Set<AbortController>();
|
|
218
|
+
|
|
219
|
+
/** `__status`, as of now. Guarded because `bridge` is any object with the bridge shape (the test suites pass
|
|
220
|
+
* minimal fakes): a status call must never be the thing that kills the daemon. */
|
|
221
|
+
function status(): DaemonStatus {
|
|
222
|
+
return {
|
|
223
|
+
daemon: true, pid, port: bridge.port, pluginConnected: bridge.isConnected(),
|
|
224
|
+
// The daemon owns the bridge, so it is the only side that can see WHICH files are
|
|
225
|
+
// connected — a CLI process routing through it has no socket of its own to ask.
|
|
226
|
+
clients: typeof bridge.listClients === "function" ? bridge.listClients() : [],
|
|
227
|
+
// The socket stats too. `dtwin whoami` documents "socket uptime, and how many times a
|
|
228
|
+
// new connection displaced an earlier one", but with a daemon in front the CLI has no
|
|
229
|
+
// bridge of its own to read it from, so it could only tell the reader to run a different
|
|
230
|
+
// command. The daemon has it. One extra field and the documented output is real.
|
|
231
|
+
connection: typeof bridge.connectionInfo === "function" ? bridge.connectionInfo() : null,
|
|
232
|
+
// Reported in ms, not rounded minutes: a sub-minute window (used by the tests, and a
|
|
233
|
+
// legitimate choice) rounded to "0" reads as "disabled", which is the opposite of true.
|
|
234
|
+
idleMs: idleMs || null,
|
|
235
|
+
idleForMs: clock.now() - lastActivity,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** One client connection. `write` sends one frame to it (and must not throw: a client that went away mid-reply is
|
|
240
|
+
* the transport's to swallow). */
|
|
241
|
+
function connect(write: (frame: SessionFrame) => void): SessionClient {
|
|
242
|
+
const mine = new Set<AbortController>();
|
|
243
|
+
const leave = (): void => {
|
|
244
|
+
for (const ac of mine) ac.abort(new Error("the daemon's client disconnected before the reply"));
|
|
245
|
+
mine.clear();
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// The daemon's own three commands — answered here, never forwarded to the bridge.
|
|
249
|
+
function control(msg: DaemonControlRequest): void {
|
|
250
|
+
if (msg.cmd === "__ping") return write({ ok: true, ...ifDefined("id", msg.id), result: { daemon: true, pid, port: bridge.port } });
|
|
251
|
+
if (msg.cmd === "__status") return write({ ok: true, ...ifDefined("id", msg.id), result: status() });
|
|
252
|
+
// "__shutdown"
|
|
253
|
+
write({ ok: true, ...ifDefined("id", msg.id), result: { stopped: true } });
|
|
254
|
+
shutdown();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function forward(msg: DaemonCommandRequest): void {
|
|
258
|
+
inFlight++; // work in flight counts as activity, so a long export can't be reaped as idleness.
|
|
259
|
+
const ac = new AbortController();
|
|
260
|
+
mine.add(ac);
|
|
261
|
+
outstanding.add(ac);
|
|
262
|
+
// Never throws (every path ends in a reply). An abort before the send (the client left during the
|
|
263
|
+
// connect wait) is the bridge's to honour: it rejects with the signal's reason and sends nothing.
|
|
264
|
+
const run = async (): Promise<void> => {
|
|
265
|
+
try {
|
|
266
|
+
// msg.waitForConnection is the caller's connect window (route.ts: the CLI's own window, at least
|
|
267
|
+
// NAMED_CLIENT_WAIT_MS for a named client): the bridge waits for the target first, a named
|
|
268
|
+
// one capped at NAMED_CLIENT_WAIT_MS once a plugin is connected (server-core awaitClient), so
|
|
269
|
+
// `--client <name>` does not fail on the first file that happened to reconnect after a Figma
|
|
270
|
+
// restart. A window that runs out is not the error: the request then fails in the bridge's own
|
|
271
|
+
// resolveClient ("Figma plugin not connected. Open the file in Figma and run the plugin.") —
|
|
272
|
+
// the text every other path prints. A client that leaves during the wait ends it (ac.signal).
|
|
273
|
+
// msg.client is the routing target (connId / fileKey / file-name substring). Forwarded
|
|
274
|
+
// verbatim: the bridge owns the matching rules, so the daemon never has to know them.
|
|
275
|
+
// The client the bridge picked rides back on the reply (a fake bridge that answers null
|
|
276
|
+
// simply doesn't say).
|
|
277
|
+
// Progress frames only for a client that opted in (`progress: true`) — an older client
|
|
278
|
+
// reads every frame as the reply. Written to the same client ahead of the reply, so it
|
|
279
|
+
// sees the ticks in the order they arrived, and all of them before the reply.
|
|
280
|
+
const id = msg.id;
|
|
281
|
+
const onProgress = msg.progress === true
|
|
282
|
+
? (tick: ProgressTick) => { write(id === undefined ? { progress: tick } : { id, progress: tick }); }
|
|
283
|
+
: undefined;
|
|
284
|
+
const r = await bridge.request(msg.cmd, msg.args, { timeoutMs: msg.timeoutMs, target: msg.client, onProgress, signal: ac.signal, waitMs: msg.waitForConnection });
|
|
285
|
+
write({ ok: true, ...ifDefined("id", msg.id), result: r.reply, ...(r.client ? { client: r.client } : {}) });
|
|
286
|
+
} catch (e) {
|
|
287
|
+
write({ ok: false, ...ifDefined("id", msg.id), error: errMsg(e) });
|
|
288
|
+
} finally {
|
|
289
|
+
mine.delete(ac);
|
|
290
|
+
outstanding.delete(ac);
|
|
291
|
+
inFlight--;
|
|
292
|
+
touch(); // the idle clock starts when work FINISHES, not when it was requested
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
void run();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function line(text: string): void {
|
|
299
|
+
touch(); // any client contact counts as activity, including the probe
|
|
300
|
+
frames++; // …and is counted: every frame here comes from ANOTHER process (activity())
|
|
301
|
+
let parsed: unknown;
|
|
302
|
+
try { parsed = JSON.parse(text) as unknown; } catch (e) { return write({ ok: false, error: "bad request frame: " + errMsg(e) }); }
|
|
303
|
+
// The frame is checked BEFORE anything reads it: a `{}` would forward `cmd: undefined` to
|
|
304
|
+
// the bridge, and a `timeoutMs: "x"` would reach setTimeout as NaN.
|
|
305
|
+
const bad = daemonRequestError(parsed);
|
|
306
|
+
if (bad !== null || !isDaemonRequest(parsed)) return write({ ok: false, error: "bad request frame: " + (bad ?? "malformed") });
|
|
307
|
+
const msg = parsed;
|
|
308
|
+
// A frame that lands after shutdown() (an idle shutdown, `dtwin stop`, the MCP host leaving) on a
|
|
309
|
+
// connection opened before it: closing the server does not end live connections, and the bridge is
|
|
310
|
+
// closed — refuse it here instead of forwarding it to a closed bridge.
|
|
311
|
+
if (stopped) return write({ ok: false, ...ifDefined("id", msg.id), error: "the shared bridge daemon has shut down — run the command again (it starts or finds a bridge of its own)" });
|
|
312
|
+
if (isControl(msg)) return control(msg);
|
|
313
|
+
// Forwarded now: the plugin orders it against every other run in its file (see the note above).
|
|
314
|
+
forward(msg);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { line, leave };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Idempotent: the idle check, `__shutdown`, a signal and a host's own shutdown can all race.
|
|
321
|
+
function shutdown(): void {
|
|
322
|
+
if (stopped) return;
|
|
323
|
+
stopped = true;
|
|
324
|
+
if (stopIdle) stopIdle();
|
|
325
|
+
stopIdle = null;
|
|
326
|
+
if (o.release) o.release();
|
|
327
|
+
// Abort every outstanding request BEFORE the bridge closes. This fires first: each in-flight
|
|
328
|
+
// abort makes server-core send its cancel frame and drop the request from its pending table, so
|
|
329
|
+
// bridge.close() below finds nothing left to cancel for it (were one sent twice, the plugin
|
|
330
|
+
// ignores the second). The reason keeps the text a caller saw before: "bridge closed".
|
|
331
|
+
for (const ac of outstanding) ac.abort(new Error("bridge closed"));
|
|
332
|
+
outstanding.clear();
|
|
333
|
+
bridge.close();
|
|
334
|
+
if (log) log("daemon stopped.");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
connect, status, shutdown,
|
|
339
|
+
activity: (): DaemonActivity => ({ frames, inFlight, idleForMs: clock.now() - lastActivity }),
|
|
340
|
+
/** (Re)arms the idle shutdown with a window of `ms` (0 disarms it), and checks once AT ONCE: a session already
|
|
341
|
+
* quiet that long (and with nothing in flight) shuts down now. */
|
|
342
|
+
idleOut(ms: number): void { armIdle(Math.max(0, ms) || 0); check(); },
|
|
343
|
+
get stopped(): boolean { return stopped; },
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export type ServedSession = ReturnType<typeof createServedSession>;
|