@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,1093 @@
|
|
|
1
|
+
// Shared bridge core: hosts a localhost WebSocket server that the Figma plugin's
|
|
2
|
+
// hidden UI iframe connects to, and correlates request/response by id.
|
|
3
|
+
// Used by both figma-pull (CLI, read) and figma-mcp (write server).
|
|
4
|
+
//
|
|
5
|
+
// The plugin is always the WebSocket CLIENT (a browser iframe cannot listen);
|
|
6
|
+
// this process is the SERVER. Loopback only — no internet exposure.
|
|
7
|
+
//
|
|
8
|
+
// The server is Bun's own (Bun.serve with a `websocket` handler), not the `ws` package: under Bun,
|
|
9
|
+
// `import "ws"` resolves to Bun's built-in compatibility shim, which IGNORES ws's `maxPayload` and
|
|
10
|
+
// enforces a hard 16 MiB frame cap instead — a real asset-heavy export (~60–120 MB in one reply)
|
|
11
|
+
// then dies as a bare close 1006 "Received too big message" with no error event, and
|
|
12
|
+
// FIGMA_BRIDGE_MAX_PAYLOAD_MB could not raise it. Bun.serve honours `maxPayloadLength`.
|
|
13
|
+
|
|
14
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import { EventEmitter } from "node:events";
|
|
17
|
+
import * as tokenStore from "./token-store.ts";
|
|
18
|
+
import { errCode, errMsg } from "./errmsg.ts";
|
|
19
|
+
import { ifDefined, isRecord } from "./json-util.ts";
|
|
20
|
+
import { abortReason } from "./abort-reason.ts";
|
|
21
|
+
import { systemClock } from "./clock.ts";
|
|
22
|
+
import type { Clock } from "./clock.ts";
|
|
23
|
+
import { createPendingRequests } from "./pending-requests.ts";
|
|
24
|
+
import { TIMEOUTS, HEARTBEAT_MS, NAMED_CLIENT_WAIT_MS, SETTLE_MS, MAX_TIMER_MS } from "./timeouts.ts";
|
|
25
|
+
// The ONLY ports the plugin can reach (its manifest's allowedDomains) — shared with doctor.ts.
|
|
26
|
+
import { ALLOWED_PORTS } from "./ports.ts";
|
|
27
|
+
import { daemonAddressOf } from "./settings.ts";
|
|
28
|
+
import type { BridgeSettings } from "./settings.ts";
|
|
29
|
+
import { legacySock } from "./daemon.ts";
|
|
30
|
+
import { isConnId, connectionFor } from "./route.ts";
|
|
31
|
+
import type { UpstreamRequest } from "./route.ts";
|
|
32
|
+
// The command/reply contract shared with the plugin: `request()` is typed per command from it, and
|
|
33
|
+
// every reply is checked against it once, at the point it enters this process (see the message handler).
|
|
34
|
+
import { replyShapeError } from "./commands.ts";
|
|
35
|
+
import type { Cmd, Commands } from "./commands.ts";
|
|
36
|
+
|
|
37
|
+
// Version reporting lives in staleness.ts so the CLI's pure helpers and doctor.ts can use it without
|
|
38
|
+
// loading this module; callers import it from there.
|
|
39
|
+
import { pluginStalenessNote } from "./staleness.ts";
|
|
40
|
+
import { exitFlushed } from "./stdio.ts";
|
|
41
|
+
|
|
42
|
+
// The handshake check keeps the CALLBACK shape of ws's async verifyClient — done(ok, code, message) —
|
|
43
|
+
// that it was written against: the check runs in Bun.serve's fetch() (see createBridge), and its unit
|
|
44
|
+
// tests drive it through exactly that shape.
|
|
45
|
+
type VerifyClientCallback = (ok: boolean, code?: number, message?: string) => void;
|
|
46
|
+
|
|
47
|
+
// Nothing here runs at load: the port, the token and the limits a bridge uses are a BridgeSettings value its
|
|
48
|
+
// process resolved once at its entry (settings.ts) and hands to createBridge.
|
|
49
|
+
//
|
|
50
|
+
// The token is the REAL access control: loopback binding and the Origin check below are both defeatable — a
|
|
51
|
+
// sandboxed attacker iframe on any site the user visits also sends "Origin: null" — so without the token any
|
|
52
|
+
// local page/process could drive the plugin. See AgentSeal's Figma-MCP CSWSH writeup. token-store.ts owns
|
|
53
|
+
// everything ABOUT the token that isn't this handshake (precedence, per-OS path, the file's 0600 mode); settings.ts
|
|
54
|
+
// resolves it without persisting, the bridge holder commits it (settings.ts commitToken) before its first bind, and
|
|
55
|
+
// createBridge only admits with it.
|
|
56
|
+
|
|
57
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
58
|
+
|
|
59
|
+
// Compare the SHA-256 digests, not the raw tokens. timingSafeEqual throws on unequal lengths, so the
|
|
60
|
+
// previous `ba.length === bb.length && ...` guard short-circuited before the constant-time compare
|
|
61
|
+
// and leaked the token's LENGTH through timing. Hashing first makes both sides a fixed 32 bytes, so
|
|
62
|
+
// every comparison takes the same path regardless of what was presented.
|
|
63
|
+
// Strings only: an earlier `unknown` signature ran String() on both sides, so a null TOKEN (a failed
|
|
64
|
+
// resolution) would have compared equal to the literal token "null". The expected token is narrowed
|
|
65
|
+
// to a real string once, in createBridge, and threaded through verifyClientWith below.
|
|
66
|
+
function safeEqual(a: string, b: string): boolean {
|
|
67
|
+
const ha = crypto.createHash("sha256").update(a).digest();
|
|
68
|
+
const hb = crypto.createHash("sha256").update(b).digest();
|
|
69
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Failed-handshake accounting. Two jobs, one counter:
|
|
73
|
+
//
|
|
74
|
+
// 1. DIAGNOSIS. The plugin caches its token in figma.clientStorage and retries on its own, so a token
|
|
75
|
+
// that does not match any more (usually: someone ran --rotate-token and didn't re-paste) becomes a
|
|
76
|
+
// silent reconnect loop. The bridge is the only side that can see WHY, so it says so —
|
|
77
|
+
// once, not sixty times a minute.
|
|
78
|
+
// 2. THROTTLE. Nothing here is a serious brute-force defence (the token is 192 bits; a local
|
|
79
|
+
// attacker guessing it is not the threat model — see the Origin note above). It exists so a
|
|
80
|
+
// runaway client can't spin the handshake path, and so the log stays readable.
|
|
81
|
+
let authFailures = 0;
|
|
82
|
+
let lastAuthLog = 0;
|
|
83
|
+
// The most recent refused token, by fingerprint only — what `dtwin doctor` reads to tell "no plugin is
|
|
84
|
+
// running" apart from "a plugin is running with the WRONG token", which look identical from outside.
|
|
85
|
+
let lastBadToken: { at: number; fingerprint: string | null } | null = null;
|
|
86
|
+
const AUTH_LOG_INTERVAL_MS = 30000;
|
|
87
|
+
|
|
88
|
+
function rejectBadToken(presented: string, expected: string): string {
|
|
89
|
+
authFailures++;
|
|
90
|
+
lastBadToken = { at: Date.now(), fingerprint: presented ? tokenStore.fingerprint(presented) : null };
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
if (now - lastAuthLog > AUTH_LOG_INTERVAL_MS) {
|
|
93
|
+
lastAuthLog = now;
|
|
94
|
+
const detail = presented
|
|
95
|
+
? `presented ${tokenStore.fingerprint(presented)}, expected ${tokenStore.fingerprint(expected)}`
|
|
96
|
+
: "no token presented";
|
|
97
|
+
console.error(
|
|
98
|
+
`[bridge] rejected a connection: ${detail}` +
|
|
99
|
+
(authFailures > 1 ? ` (${authFailures} failures so far)` : "") +
|
|
100
|
+
". Re-paste the plugin's \"Bridge token\" field — `dtwin --show-token` prints the current one."
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return "bad or missing token";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The failed-handshake record (process-wide, like the counters above) and the fingerprint `token` is checked against. */
|
|
107
|
+
const authStatsFor = (token: string) => ({ failures: authFailures, lastBadToken, expected: tokenStore.fingerprint(token) });
|
|
108
|
+
|
|
109
|
+
// The handshake fields the check reads (the shape of ws's verifyClient `info`, which it was written
|
|
110
|
+
// against): createBridge's fetch() builds one from the upgrade Request, and the test suite drives the
|
|
111
|
+
// check with hand-built ones. `| undefined` on url/host: an absent header is absent, not "".
|
|
112
|
+
interface HandshakeInfo {
|
|
113
|
+
origin?: string;
|
|
114
|
+
req: { url?: string | undefined; headers: { host?: string | undefined } };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The handshake check, bound to the ONE expected token. */
|
|
118
|
+
type VerifyClient = (info: HandshakeInfo, done: VerifyClientCallback) => void;
|
|
119
|
+
|
|
120
|
+
// Runs during the WS handshake, before the connection is accepted. A factory, so the expected token
|
|
121
|
+
// is a real string by construction (createBridge narrows it once) rather than a module-level
|
|
122
|
+
// `string | null` every comparison has to trust.
|
|
123
|
+
function verifyClientWith(token: string): VerifyClient {
|
|
124
|
+
return (info, done) => verifyClientAgainst(token, info, done);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function verifyClientAgainst(expected: string, info: HandshakeInfo, done: VerifyClientCallback): void {
|
|
128
|
+
const req = info.req;
|
|
129
|
+
// Origin: the plugin iframe sends "null" (sandboxed). Block real websites as cheap
|
|
130
|
+
// defense-in-depth — but a sandboxed attacker iframe ALSO sends "null", so the token
|
|
131
|
+
// is what actually authenticates.
|
|
132
|
+
const origin = info.origin;
|
|
133
|
+
const originOk = !origin || origin === "null" || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
|
|
134
|
+
if (!originOk) return done(false, 403, "forbidden origin");
|
|
135
|
+
// Host header must be loopback (mitigates DNS-rebinding, where Origin looks fine). Parsed by URL, not
|
|
136
|
+
// split on ":" — splitting "[::1]:8787" yielded "[", stripped to "", and an empty host SKIPPED the
|
|
137
|
+
// check, so any bracketed IPv6 Host passed. URL strips the port and lowercases; its hostname keeps
|
|
138
|
+
// IPv6 brackets ("[::1]"), dropped here to match LOOPBACK_HOSTS. FAIL-CLOSED: a missing or
|
|
139
|
+
// unparseable Host is refused — HTTP/1.1 requires one (RFC 7230 §5.4) and every WS client sends it.
|
|
140
|
+
let host = "";
|
|
141
|
+
try {
|
|
142
|
+
host = new URL("http://" + (req.headers.host || "")).hostname.replace(/^\[|\]$/g, "");
|
|
143
|
+
} catch { /* unparseable: stays "", refused below */ }
|
|
144
|
+
if (!host || !LOOPBACK_HOSTS.has(host)) return done(false, 403, "forbidden host");
|
|
145
|
+
// Token — the actual authentication.
|
|
146
|
+
let token = "";
|
|
147
|
+
try {
|
|
148
|
+
token = new URL(String(req.url), "http://127.0.0.1").searchParams.get("token") || "";
|
|
149
|
+
} catch { /* unparseable URL: no token presented */ }
|
|
150
|
+
if (!safeEqual(token, expected)) return done(false, 401, rejectBadToken(token, expected));
|
|
151
|
+
authFailures = 0; // a success clears the backoff — a mis-paste then a fix shouldn't stay penalised
|
|
152
|
+
done(true);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A browser WebSocket can never see an HTTP status: a handshake refused with 401 surfaces in the
|
|
156
|
+
// plugin as an opaque close 1006, byte-identical to "nothing is listening". So the plugin could not
|
|
157
|
+
// tell "start the bridge" from "re-paste the token", and showed one useless "offline" for both.
|
|
158
|
+
//
|
|
159
|
+
// For the plugin ONLY (its sandboxed iframe sends Origin "null"; a CLI/server client sends none and CAN
|
|
160
|
+
// read the 401), a handshake that passed Origin + Host and failed on the TOKEN alone is admitted, flagged,
|
|
161
|
+
// and closed at once with an application close code the iframe can read. It is never registered as a
|
|
162
|
+
// client and gets no message handler, so it can neither receive a command nor send a result — what
|
|
163
|
+
// an unauthenticated caller gains over the 401 is one bit it already had ("a bridge is here").
|
|
164
|
+
// Origin/Host failures are still refused outright at the HTTP layer.
|
|
165
|
+
const CLOSE_BAD_TOKEN = 4401; // 4000-4999 is the range RFC 6455 reserves for applications
|
|
166
|
+
|
|
167
|
+
// ---- the plugin <-> bridge protocol on the wire: plugin-frames.ts, the one module both sides type against.
|
|
168
|
+
export type { BridgeFrame, CancelFrame, CommandFrame, HelloFrame, PluginWireFrame, ProgressFrame, ReplyFrame } from "./plugin-frames.ts";
|
|
169
|
+
import type { BridgeFrame } from "./plugin-frames.ts";
|
|
170
|
+
|
|
171
|
+
// ---- the plugin <-> bridge protocol, as this module sees it.
|
|
172
|
+
|
|
173
|
+
/** A plugin progress tick as the plugin's relay (figma-plugin/src/relay.ts) sends it: `{type:"progress", phase, page, nodes, assets}` — the
|
|
174
|
+
* counters figma-plugin/src/progress.ts emits, each absent when the phase has none. Read defensively:
|
|
175
|
+
* it is foreign JSON, and only a human-readable progress message is ever built from it. */
|
|
176
|
+
export interface ProgressTick {
|
|
177
|
+
phase: string | null;
|
|
178
|
+
page: { index: number; of: number; name: string | null } | null;
|
|
179
|
+
nodes: number | null;
|
|
180
|
+
assets: number | null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** One frame from the plugin (its relay, figma-plugin/src/relay.ts), after parsePluginFrame: a `hello` identity
|
|
184
|
+
* announcement, an unsolicited `progress` tick, or the reply to one command (`{id, ok, result, error}`).
|
|
185
|
+
* Anything else — a non-object, a reply whose id is not a string — is dropped (null). */
|
|
186
|
+
type PluginFrame =
|
|
187
|
+
| { type: "progress"; tick: ProgressTick; requestId: string | null }
|
|
188
|
+
| { type: "hello"; instanceId: string | null; file: string | null; fileKey: string | null; page: string | null; pluginVersion: string | null }
|
|
189
|
+
| { type: "reply"; id: string; ok: boolean; result: unknown; error: unknown };
|
|
190
|
+
|
|
191
|
+
const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
|
|
192
|
+
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
193
|
+
|
|
194
|
+
function parseTick(m: Record<string, unknown>): ProgressTick {
|
|
195
|
+
const p = isRecord(m.page) ? m.page : null;
|
|
196
|
+
const index = p ? num(p.index) : null;
|
|
197
|
+
const of = p ? num(p.of) : null;
|
|
198
|
+
return {
|
|
199
|
+
phase: str(m.phase),
|
|
200
|
+
page: p && index !== null && of !== null ? { index, of, name: str(p.name) } : null,
|
|
201
|
+
nodes: num(m.nodes),
|
|
202
|
+
assets: num(m.assets),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parsePluginFrame(parsed: unknown): PluginFrame | null {
|
|
207
|
+
// JSON.parse can return a non-object (null, number, string) — guard before reading .id so a
|
|
208
|
+
// malformed frame (e.g. the literal `null`) can't throw an uncaught TypeError and kill the process.
|
|
209
|
+
if (!isRecord(parsed)) return null;
|
|
210
|
+
const m = parsed;
|
|
211
|
+
if (m.type === "progress") return { type: "progress", tick: parseTick(m), requestId: str(m.requestId) };
|
|
212
|
+
if (m.type === "hello") {
|
|
213
|
+
return { type: "hello", instanceId: str(m.instanceId), file: str(m.file), fileKey: str(m.fileKey), page: str(m.page), pluginVersion: str(m.pluginVersion) };
|
|
214
|
+
}
|
|
215
|
+
// Request ids are always strings ("r<nonce>-<N>"), so a frame whose id is anything else matches nothing.
|
|
216
|
+
if (typeof m.id !== "string") return null;
|
|
217
|
+
return { type: "reply", id: m.id, ok: !!m.ok, result: m.result, error: m.error };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The part of a socket the registry reads: whether it is still open. A Bun ServerWebSocket in the
|
|
221
|
+
* bridge; the test suite drives the registry with plain objects of this shape. */
|
|
222
|
+
interface RegistrySocket {
|
|
223
|
+
readyState: number;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** WebSocket readyState OPEN (RFC 6455 / WHATWG: CONNECTING 0, OPEN 1, CLOSING 2, CLOSED 3) — the same
|
|
227
|
+
* number on a ServerWebSocket and on the registry's test doubles. */
|
|
228
|
+
const WS_OPEN = 1;
|
|
229
|
+
|
|
230
|
+
/** What each server socket carries (Bun.serve's per-socket `data`, set at upgrade): `badToken` marks a
|
|
231
|
+
* plugin admitted only to be closed with CLOSE_BAD_TOKEN (see `admit`); `entry` is its registry entry
|
|
232
|
+
* once open() has registered it — never for a bad-token socket. */
|
|
233
|
+
interface SocketData {
|
|
234
|
+
badToken: boolean;
|
|
235
|
+
entry: ClientEntry | null;
|
|
236
|
+
}
|
|
237
|
+
type BridgeSocket = ServerWebSocket<SocketData>;
|
|
238
|
+
|
|
239
|
+
/** One registry entry: a connected plugin socket plus what its `hello` said. */
|
|
240
|
+
interface ClientEntry<S extends RegistrySocket = BridgeSocket> {
|
|
241
|
+
ws: S;
|
|
242
|
+
connId: string;
|
|
243
|
+
/** wall clock (Date.now), for display: the rows' connectedAt / uptimeMs */
|
|
244
|
+
connectedAt: number;
|
|
245
|
+
/** monotonic clock (performance.now), for the waits: the identification grace counts from it */
|
|
246
|
+
connectedMono: number;
|
|
247
|
+
instanceId: string | null;
|
|
248
|
+
file: string | null;
|
|
249
|
+
fileKey: string | null;
|
|
250
|
+
page: string | null;
|
|
251
|
+
pluginVersion: string | null;
|
|
252
|
+
/** heartbeat: cleared before each ping, set again by the pong (the ws README's "How to detect and
|
|
253
|
+
* close broken connections?" recipe). Still false at the next tick = the peer is gone, and it is terminated. */
|
|
254
|
+
isAlive: boolean;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** One connection as `describe()` presents it — the rows of listClients(), figma_list_clients and `dtwin list clients`. */
|
|
258
|
+
export interface ClientRow {
|
|
259
|
+
connId: string;
|
|
260
|
+
file: string | null;
|
|
261
|
+
fileKey: string | null;
|
|
262
|
+
page: string | null;
|
|
263
|
+
instanceId: string | null;
|
|
264
|
+
connectedAt: number;
|
|
265
|
+
uptimeMs: number;
|
|
266
|
+
identified: boolean;
|
|
267
|
+
pluginVersion: string | null;
|
|
268
|
+
pluginStale: string | null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** connectionInfo(): the socket half of `whoami`. */
|
|
272
|
+
export interface ConnectionInfo {
|
|
273
|
+
connId: string | null;
|
|
274
|
+
connected: boolean;
|
|
275
|
+
connectedAt: number | null;
|
|
276
|
+
connectionUptimeMs: number;
|
|
277
|
+
connectionsThisRun: number;
|
|
278
|
+
clientsConnected: number;
|
|
279
|
+
clients: ClientRow[];
|
|
280
|
+
takeovers: number;
|
|
281
|
+
lastTakeoverAt: number | null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** What a request resolved with, plus WHICH connected file answered it (the client resolveClient picked). */
|
|
285
|
+
export interface RequestOutcome<C extends Cmd> {
|
|
286
|
+
reply: Commands[C]["reply"];
|
|
287
|
+
client: ClientRow;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The admission decision for one upgrade request: let it through (`badToken`: only to be closed with
|
|
291
|
+
* CLOSE_BAD_TOKEN), or refuse it with an HTTP status and body. */
|
|
292
|
+
type Admission = { admit: true; badToken: boolean } | { admit: false; code: number; message: string };
|
|
293
|
+
|
|
294
|
+
function admitWith(token: string): (info: HandshakeInfo) => Admission {
|
|
295
|
+
const verify = verifyClientWith(token);
|
|
296
|
+
return (info) => {
|
|
297
|
+
// verify answers synchronously, through the callback shape it keeps (see VerifyClientCallback).
|
|
298
|
+
let out: Admission = { admit: false, code: 401, message: "" };
|
|
299
|
+
verify(info, (ok, code, msg) => {
|
|
300
|
+
if (ok) out = { admit: true, badToken: false };
|
|
301
|
+
else if (code === 401 && info.origin === "null") out = { admit: true, badToken: true };
|
|
302
|
+
else out = { admit: false, code: code || 401, message: msg || "" };
|
|
303
|
+
});
|
|
304
|
+
return out;
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// The status texts the bridge answers with. A refusal's body is its message, or this text without one —
|
|
309
|
+
// the status line, headers and body ws's own abortHandshake wrote for a verifyClient refusal, so a client
|
|
310
|
+
// sees exactly the 401/403 it saw when ws ran the check.
|
|
311
|
+
const STATUS_TEXT: Record<number, string> = { 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 426: "Upgrade Required" };
|
|
312
|
+
function refuseUpgrade(code: number, message?: string): Response {
|
|
313
|
+
return new Response(message || STATUS_TEXT[code] || "", { status: code, headers: { Connection: "close", "Content-Type": "text/html" } });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The bind refusal when `port` is already held (EADDRINUSE). A daemon from an earlier dtwin build listens on
|
|
317
|
+
* a socket this build does not look at, so neither routing through it nor `dtwin --stop`'s first look finds
|
|
318
|
+
* it — the text names it instead (`legacy`, daemon.ts legacySock). On Windows the daemon's pipe is named from the bridge
|
|
319
|
+
* token (daemon.ts daemonAddress), so one started under an EARLIER token is unreachable by name in the same way — the text says so. */
|
|
320
|
+
function portInUseMessage(port: number, platform: NodeJS.Platform = process.platform, legacy: string | null = null): string {
|
|
321
|
+
const others = `set FIGMA_BRIDGE_PORT to one of the other allowed ports (${ALLOWED_PORTS.filter((p) => p !== port).join(", ")}) — the plugin walks all three.`;
|
|
322
|
+
if (platform === "win32") {
|
|
323
|
+
return `[bridge] port ${port} is already in use — another dtwin bridge or MCP server is running. Stop it first, or ${others} ` +
|
|
324
|
+
"A dtwin daemon started under a different bridge token cannot be found by this one (its pipe is named from the token), so `dtwin --stop` does not reach it — end that process instead.";
|
|
325
|
+
}
|
|
326
|
+
const old = legacy ? ` A dtwin daemon from an earlier version may be holding it (socket ${legacy}) — \`dtwin --stop\` stops it.` : "";
|
|
327
|
+
return `[bridge] port ${port} is already in use — another dtwin bridge or MCP server is running. Stop it first, or ${others}${old}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---- the client registry: which plugin sockets are connected, what each `hello` said, and who addresses which.
|
|
331
|
+
|
|
332
|
+
/** How long a connected client that has not sent its `hello` still holds a wait that would otherwise resolve
|
|
333
|
+
* without its file name (an unaddressed request, a listing, an ambiguity). The plugin sends it a beat after the socket opens (ui.html, on every open); a client that never does
|
|
334
|
+
* (a bundle that predates `hello`) is taken as it is once this passes, so it cannot hold a whole connect window. */
|
|
335
|
+
const IDENTIFY_GRACE_MS = 3000;
|
|
336
|
+
|
|
337
|
+
type Hello = Extract<PluginFrame, { type: "hello" }>;
|
|
338
|
+
|
|
339
|
+
/** createRegistry options. `settleMs` is timeouts.ts SETTLE_MS (the test suites shorten it, or pass 0). */
|
|
340
|
+
interface RegistryOptions {
|
|
341
|
+
settleMs?: number;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The client registry, kept apart from the sockets so the test suite can drive it with plain objects. Every
|
|
346
|
+
* change (a client added, identified or removed, the registry cleared) is announced on one event, which
|
|
347
|
+
* is what awaitClient waits on. Its waits run on the monotonic clock (performance.now), so a wall-clock
|
|
348
|
+
* step cannot end or stretch one.
|
|
349
|
+
*/
|
|
350
|
+
function createRegistry<S extends RegistrySocket = BridgeSocket>(opts: RegistryOptions = {}) {
|
|
351
|
+
const clients = new Map<string, ClientEntry<S>>(); // connId -> { ws, connId, connectedAt, instanceId, file, fileKey, page }
|
|
352
|
+
const settleMs = opts.settleMs !== undefined && Number.isFinite(opts.settleMs) && opts.settleMs >= 0 ? opts.settleMs : SETTLE_MS;
|
|
353
|
+
const changes = new EventEmitter();
|
|
354
|
+
// One listener per waiting request. Several tool calls can wait at once, which is not a leak — but
|
|
355
|
+
// hundreds would be, so the warning stays armed above any plausible number of concurrent waits.
|
|
356
|
+
changes.setMaxListeners(64);
|
|
357
|
+
let connSeq = 0;
|
|
358
|
+
let closed = false;
|
|
359
|
+
// When the settle window started (monotonic): the registry went from no live client to one, or a client
|
|
360
|
+
// left while others stayed. The window counts from it.
|
|
361
|
+
let liveSince: number | null = null;
|
|
362
|
+
const changed = (): void => { changes.emit("change"); };
|
|
363
|
+
|
|
364
|
+
function add(ws: S): ClientEntry<S> {
|
|
365
|
+
const connId = "c" + ++connSeq;
|
|
366
|
+
const now = performance.now();
|
|
367
|
+
if (!live().length) liveSince = now;
|
|
368
|
+
const entry: ClientEntry<S> = { ws, connId, connectedAt: Date.now(), connectedMono: now, instanceId: null, file: null, fileKey: null, page: null, pluginVersion: null, isAlive: true };
|
|
369
|
+
clients.set(connId, entry);
|
|
370
|
+
changed();
|
|
371
|
+
return entry;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function identify(entry: ClientEntry<S>, hello: Hello): void {
|
|
375
|
+
entry.instanceId = hello.instanceId;
|
|
376
|
+
entry.file = hello.file;
|
|
377
|
+
entry.fileKey = hello.fileKey;
|
|
378
|
+
entry.page = hello.page;
|
|
379
|
+
// The plugin's own build version (figma-plugin/package.json, baked in at build
|
|
380
|
+
// time — see figma-plugin/build.ts). `null` for a plugin bundle old enough to predate this
|
|
381
|
+
// field entirely, which is itself a useful signal (definitely stale).
|
|
382
|
+
entry.pluginVersion = hello.pluginVersion;
|
|
383
|
+
changed();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Only forget THIS entry. A connId is never reused, but the check keeps a late close of a socket the
|
|
387
|
+
// heartbeat already dropped from touching anything else.
|
|
388
|
+
function remove(entry: ClientEntry<S>): void {
|
|
389
|
+
if (clients.get(entry.connId) !== entry) return;
|
|
390
|
+
clients.delete(entry.connId);
|
|
391
|
+
// A window that leaves usually comes back a redial later (a reload, a Figma restart). Until then the
|
|
392
|
+
// clients that remain are not the whole picture, so an unaddressed request waits the settle again.
|
|
393
|
+
if (live().length) liveSince = performance.now();
|
|
394
|
+
changed();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// The bridge is closing: nothing will connect any more, so every wait ends now instead of at its window.
|
|
398
|
+
function clear(): void {
|
|
399
|
+
clients.clear();
|
|
400
|
+
closed = true;
|
|
401
|
+
changed();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const isLive = (e: ClientEntry<S> | undefined): e is ClientEntry<S> => !!e && !!e.ws && e.ws.readyState === WS_OPEN;
|
|
405
|
+
const live = (): ClientEntry<S>[] => [...clients.values()].filter(isLive);
|
|
406
|
+
|
|
407
|
+
// One connection, described for a human or an agent choosing between them. `describe` is what a
|
|
408
|
+
// listing shows and what an ambiguity error quotes, so the two can never disagree about what a
|
|
409
|
+
// client is called.
|
|
410
|
+
const describe = (e: ClientEntry<S>): ClientRow => ({
|
|
411
|
+
connId: e.connId,
|
|
412
|
+
file: e.file,
|
|
413
|
+
fileKey: e.fileKey,
|
|
414
|
+
page: e.page,
|
|
415
|
+
instanceId: e.instanceId,
|
|
416
|
+
connectedAt: e.connectedAt,
|
|
417
|
+
uptimeMs: Date.now() - e.connectedAt,
|
|
418
|
+
identified: !!e.instanceId,
|
|
419
|
+
pluginVersion: e.pluginVersion || null,
|
|
420
|
+
pluginStale: e.instanceId ? pluginStalenessNote(e.pluginVersion) : null,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Which connection does a command go to? The rules, in order:
|
|
424
|
+
// no target + exactly one client -> that one (the overwhelmingly common case: one file open)
|
|
425
|
+
// no target + several clients -> REFUSE, and list them. Silently picking one would send an
|
|
426
|
+
// export to whichever file happened to connect first, write it
|
|
427
|
+
// to the caller's outDir, and look completely successful. adb
|
|
428
|
+
// makes the same call ("more than one device") for the same
|
|
429
|
+
// reason: guessing is worse than asking.
|
|
430
|
+
// target -> exact connId, then exact fileKey, then case-insensitive
|
|
431
|
+
// substring of the file name. A substring matching several
|
|
432
|
+
// files is refused rather than resolved to the first.
|
|
433
|
+
// The target rules above, as data: which live entries a target names. resolveClient turns the
|
|
434
|
+
// outcome into the routed entry or the error; awaitClient waits on it (a `none` may become a `one`
|
|
435
|
+
// while a second plugin window is still redialling).
|
|
436
|
+
function matchClient(entries: ClientEntry<S>[], t: string): ClientEntry<S>[] {
|
|
437
|
+
const byId = entries.find((e) => e.connId === t);
|
|
438
|
+
if (byId) return [byId];
|
|
439
|
+
const byKey = entries.find((e) => e.fileKey && e.fileKey === t);
|
|
440
|
+
if (byKey) return [byKey];
|
|
441
|
+
const lower = t.toLowerCase();
|
|
442
|
+
return entries.filter((e) => e.file && e.file.toLowerCase().includes(lower));
|
|
443
|
+
}
|
|
444
|
+
function resolveClient(target?: unknown): ClientEntry<S> {
|
|
445
|
+
const entries = live();
|
|
446
|
+
if (!entries.length) {
|
|
447
|
+
throw new Error("Figma plugin not connected. Open the file in Figma and run the plugin.");
|
|
448
|
+
}
|
|
449
|
+
const list = () => entries.map((e) => ` ${e.connId} ${JSON.stringify(e.file || "(unidentified)")}${e.fileKey ? " fileKey " + e.fileKey : ""}`).join("\n");
|
|
450
|
+
if (target === undefined || target === null || target === "") {
|
|
451
|
+
const [only] = entries;
|
|
452
|
+
if (entries.length === 1 && only !== undefined) return only; // only is always set when length is 1
|
|
453
|
+
throw new Error(
|
|
454
|
+
`${entries.length} Figma files are connected to the bridge — say which one to use.\n${list()}\n` +
|
|
455
|
+
`Pass the connId, the fileKey, or part of the file name (CLI: --client <id|name>; MCP: client: "<id|name>").`
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
const t = String(target);
|
|
459
|
+
const matched = matchClient(entries, t);
|
|
460
|
+
const [hit] = matched;
|
|
461
|
+
if (matched.length === 1 && hit !== undefined) return hit; // hit is always set when length is 1
|
|
462
|
+
if (matched.length > 1) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`'${t}' matches ${matched.length} connected files — be more specific, or use the connId.\n${list()}`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
throw new Error(`no connected Figma file matches '${t}'. Connected:\n${list()}`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// How much of the settle window is left: 0 once it has passed, and 0 with nothing connected (there is
|
|
471
|
+
// nothing to route yet, so nothing to hold — the request's own error says so at once).
|
|
472
|
+
function settleLeft(): number {
|
|
473
|
+
if (liveSince === null || !live().length) return 0;
|
|
474
|
+
return Math.max(0, liveSince + settleMs - performance.now());
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Wait, bounded, until `target` can be routed — the one wait every front-end uses before a request
|
|
479
|
+
* (figma-pull's own bridge, the daemon for a request that carries a connect window, the MCP server for
|
|
480
|
+
* a named `client`, and any request while the bridge is still settling). Resolves when:
|
|
481
|
+
* - a NAMED target (file name or fileKey, not a connId) matches: at once for a single match by fileKey,
|
|
482
|
+
* or for several matches once no connected client is still waiting to identify itself (the ambiguity
|
|
483
|
+
* is resolveClient's to report, and its list then names every file); a single match by file name
|
|
484
|
+
* only once the bridge has settled and no connected client is still waiting to identify itself
|
|
485
|
+
* (below). File names are not unique and a name matches as a substring, so one match is not yet the
|
|
486
|
+
* only candidate. Every open plugin window redials on its own clock (figma-plugin ui.html), so with
|
|
487
|
+
* two files open whichever lands first is all there is for a moment (seen live: `--client "Design
|
|
488
|
+
* System"` failed with only the other file listed, and that file connected two seconds later). And a
|
|
489
|
+
* socket that is open is not yet identified: its `hello`, which carries the file name, arrives a beat
|
|
490
|
+
* later ("one run in four failed");
|
|
491
|
+
* - a connId: that connection is live (it needs no identification), or every live client has identified
|
|
492
|
+
* itself (or been connected IDENTIFY_GRACE_MS without doing so), so a connId that is not there fails
|
|
493
|
+
* with the connected list, every file in it named, instead of holding the window;
|
|
494
|
+
* - no target: every live client has identified itself, or has been connected for IDENTIFY_GRACE_MS
|
|
495
|
+
* without doing so — and the bridge has settled or several clients are already connected (resolveClient
|
|
496
|
+
* then refuses to guess). So a listing (--list-clients) right after a window's socket opens waits the
|
|
497
|
+
* few milliseconds to its `hello` and shows the file name; a client that never sends one is listed
|
|
498
|
+
* unidentified once its grace has passed;
|
|
499
|
+
* - the window runs out: `waitMs` (Infinity: no end) for a first live client; once one is live, a
|
|
500
|
+
* named target gets min(waitMs, NAMED_CLIENT_WAIT_MS) from that moment, so a name that never
|
|
501
|
+
* matches fails with the connected list instead of holding the whole connect window. A window (> 0)
|
|
502
|
+
* that ends before the settle does is stretched to it for a target that is not a connId, since
|
|
503
|
+
* routing at the window would take the first file to land;
|
|
504
|
+
* - the CAP: once some client has been live without a gap since moment L, the wait ends by
|
|
505
|
+
* L + max(min(waitMs, NAMED_CLIENT_WAIT_MS), settleMs) + IDENTIFY_GRACE_MS (18 s at most with the
|
|
506
|
+
* defaults — the CLI's connect window is longer than the 15 s cap), whatever the settle and the graces say — a
|
|
507
|
+
* socket that opens and closes without a hello faster than the settle restarts it on every leave and
|
|
508
|
+
* brings a fresh grace each time, and would otherwise hold the wait for good. With nothing live, only
|
|
509
|
+
* the window ends it (Infinity still waits for a first plugin, or, with no target or a connId, for a
|
|
510
|
+
* client that stays connected long enough to say hello or outlast its grace — as before the cap);
|
|
511
|
+
* - `signal` aborts, or the registry is cleared (the bridge closed).
|
|
512
|
+
* SETTLED: settleMs (timeouts.ts SETTLE_MS) have passed since the settle window started — the registry
|
|
513
|
+
* went from no live client to one, or a client left while others stayed (it is probably redialling).
|
|
514
|
+
* Until then a target that is not routed by connId or fileKey could go to the first file to land while
|
|
515
|
+
* another open file's window is a redial behind — a silent wrong-file export — so it waits. A bridge
|
|
516
|
+
* whose plugins have been connected longer pays nothing.
|
|
517
|
+
* Never rejects: resolveClient, run afterwards, says what is wrong with whatever is connected then.
|
|
518
|
+
* Woken by the registry's change event (a connect, a `hello`, a close) — no polling; the one timer is the
|
|
519
|
+
* next moment the answer can change without an event (the window, the cap, the settle, an identification grace),
|
|
520
|
+
* unref'd so a wait never keeps a process alive by itself, and cleared on every exit, as is the listener.
|
|
521
|
+
* `namedCapMs` is NAMED_CLIENT_WAIT_MS (the test suite shortens it).
|
|
522
|
+
*/
|
|
523
|
+
function awaitClient(target: string | null | undefined, waitMs: number, signal?: AbortSignal, namedCapMs: number = NAMED_CLIENT_WAIT_MS): Promise<void> {
|
|
524
|
+
const t = target ?? "";
|
|
525
|
+
const connId = t !== "" && isConnId(t);
|
|
526
|
+
const named = t !== "" && !connId;
|
|
527
|
+
const windowMs = waitMs > 0 ? waitMs : 0; // NaN, 0 and below: no wait; Infinity: no end
|
|
528
|
+
const capMs = namedCapMs >= 0 ? namedCapMs : NAMED_CLIENT_WAIT_MS; // NaN or negative: the default, not a timer that never settles
|
|
529
|
+
// Only an id names one socket: file names repeat ("Untitled") and match as substrings ("App" in "App (Copy)").
|
|
530
|
+
const exact = (e: ClientEntry<S>): boolean => e.connId === t || (e.fileKey !== null && e.fileKey === t);
|
|
531
|
+
const start = performance.now();
|
|
532
|
+
let firstLiveAt: number | null = null;
|
|
533
|
+
let liveFrom: number | null = null; // since when some client has been live without a gap (null: none now)
|
|
534
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
535
|
+
let done = false;
|
|
536
|
+
return new Promise<void>((resolve) => {
|
|
537
|
+
const finish = (): void => {
|
|
538
|
+
// emit() still calls a listener removed during that same emit (nodejs.org/api/events.html), hence `done`.
|
|
539
|
+
if (done) return;
|
|
540
|
+
done = true;
|
|
541
|
+
changes.off("change", check);
|
|
542
|
+
signal?.removeEventListener("abort", finish);
|
|
543
|
+
if (timer) clearTimeout(timer);
|
|
544
|
+
timer = null;
|
|
545
|
+
resolve();
|
|
546
|
+
};
|
|
547
|
+
function check(): void {
|
|
548
|
+
if (done) return;
|
|
549
|
+
if (closed || signal?.aborted) return finish();
|
|
550
|
+
const now = performance.now();
|
|
551
|
+
const entries = live();
|
|
552
|
+
if (entries.length && firstLiveAt === null) firstLiveAt = now;
|
|
553
|
+
const settleAt = liveSince === null ? now : liveSince + settleMs;
|
|
554
|
+
const settled = now >= settleAt;
|
|
555
|
+
// A client that has not sent its hello yet may be another window of the file being asked for.
|
|
556
|
+
const identifying = entries.some((e) => !e.instanceId && now - e.connectedMono < IDENTIFY_GRACE_MS);
|
|
557
|
+
// Every outcome that does not route to one matched file — a refusal or a "no match" that lists the
|
|
558
|
+
// connected files, a no-target request with several, `--list-clients` — waits out a hello still due,
|
|
559
|
+
// so a window whose socket opened a moment ago is listed by its file name, not as unidentified.
|
|
560
|
+
let ready: boolean;
|
|
561
|
+
if (named) {
|
|
562
|
+
const matched = matchClient(entries, t);
|
|
563
|
+
const [only] = matched;
|
|
564
|
+
ready = (matched.length > 1 && !identifying) || (only !== undefined && (exact(only) || (settled && !identifying)));
|
|
565
|
+
} else {
|
|
566
|
+
const there = connId && entries.some((e) => e.connId === t); // a connId names one socket: identification adds nothing
|
|
567
|
+
ready = there || (entries.length > 0 && !identifying && (connId || settled || entries.length > 1));
|
|
568
|
+
}
|
|
569
|
+
if (ready) return finish();
|
|
570
|
+
let deadline = named && firstLiveAt !== null ? firstLiveAt + Math.min(windowMs, capMs) : start + windowMs;
|
|
571
|
+
// A window that ends inside the settle would route to whichever file has landed so far.
|
|
572
|
+
if (!connId && windowMs > 0 && entries.length && !settled) deadline = Math.max(deadline, settleAt);
|
|
573
|
+
// The settle restarts on every leave and every new socket brings its own grace, so a socket that
|
|
574
|
+
// keeps opening and closing without a hello could push both forever: the cap counts from the moment
|
|
575
|
+
// this wait last saw a client go live and nothing moves it.
|
|
576
|
+
if (!entries.length) liveFrom = null;
|
|
577
|
+
else if (liveFrom === null) liveFrom = now;
|
|
578
|
+
const capAt = liveFrom === null ? Number.POSITIVE_INFINITY : liveFrom + Math.max(Math.min(windowMs, capMs), settleMs) + IDENTIFY_GRACE_MS;
|
|
579
|
+
if (now >= deadline || now >= capAt) return finish();
|
|
580
|
+
// The next moment the answer can change without an event: the window, the cap, the settle, or an
|
|
581
|
+
// unidentified client's grace.
|
|
582
|
+
let wake = Math.min(deadline, capAt);
|
|
583
|
+
if (!connId && entries.length && !settled) wake = Math.min(wake, settleAt);
|
|
584
|
+
for (const e of entries) if (!e.instanceId && now - e.connectedMono < IDENTIFY_GRACE_MS) wake = Math.min(wake, e.connectedMono + IDENTIFY_GRACE_MS);
|
|
585
|
+
if (timer) clearTimeout(timer);
|
|
586
|
+
timer = setTimeout(check, Math.min(Math.max(Math.ceil(wake - now), 1), MAX_TIMER_MS));
|
|
587
|
+
timer.unref();
|
|
588
|
+
}
|
|
589
|
+
changes.on("change", check);
|
|
590
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
591
|
+
check();
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
clients, add, identify, remove, clear, live, describe, resolveClient, awaitClient, settleLeft,
|
|
597
|
+
/** connections registered so far (bad-token sockets are never registered) */
|
|
598
|
+
connections: (): number => connSeq,
|
|
599
|
+
/** test seam: how many waits are listening for registry changes right now */
|
|
600
|
+
waiting: (): number => changes.listenerCount("change"),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** createBridge options. `onListenError` is for a caller that must SURVIVE a failed bind (the MCP
|
|
605
|
+
* server: a held port is a tool error there, not a reason to kill the stdio session). Without it, a
|
|
606
|
+
* held port prints the fix and exits 1 — the CLI behaviour, unchanged. `heartbeatMs` overrides the
|
|
607
|
+
* ping interval (timeouts.ts HEARTBEAT_MS); the test suite shortens it, or passes 0 = no interval at
|
|
608
|
+
* all, and steps the returned `heartbeatTick()` itself. */
|
|
609
|
+
interface BridgeOptions {
|
|
610
|
+
onListenError?: (e: Error) => void;
|
|
611
|
+
heartbeatMs?: number;
|
|
612
|
+
/** the registry's settle window (timeouts.ts SETTLE_MS); the test suite passes 0 where it is not under test */
|
|
613
|
+
settleMs?: number;
|
|
614
|
+
/** the clock the pending requests' budgets and stall checks run on (clock.ts; systemClock when absent) */
|
|
615
|
+
clock?: Clock;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function createBridge(settings: BridgeSettings, opts: BridgeOptions = {}) {
|
|
619
|
+
const port = settings.port;
|
|
620
|
+
// A token that could not be resolved (a mistyped --token-file) surfaces HERE, where the caller's error handling
|
|
621
|
+
// turns it into a one-line message. Before the server is even constructed, so a failed start leaves nothing
|
|
622
|
+
// listening. The token is admitted as given: saving a minted one and saying which is in play is the commit before a
|
|
623
|
+
// bridge first binds (settings.ts commitToken, run by the bridge holder), not a side effect of constructing one.
|
|
624
|
+
if (!settings.token.ok) throw settings.token.error;
|
|
625
|
+
const token = settings.token.resolved.token;
|
|
626
|
+
|
|
627
|
+
// The per-frame cap (settings.ts maxPayloadFromEnv): FIGMA_BRIDGE_MAX_PAYLOAD_MB, or 128 MB.
|
|
628
|
+
const payload = settings.maxPayload;
|
|
629
|
+
if (payload.warning) console.error("[bridge] " + payload.warning);
|
|
630
|
+
const { mb: maxPayloadMb, bytes: maxPayloadBytes } = payload;
|
|
631
|
+
const admit = admitWith(token);
|
|
632
|
+
|
|
633
|
+
// MULTI-CLIENT registry. Every connected plugin instance gets an entry, keyed by a server-minted
|
|
634
|
+
// `connId` — one Figma file per entry, so a design file and the library file it draws on can be
|
|
635
|
+
// driven from the same bridge instead of stealing the socket from each other.
|
|
636
|
+
//
|
|
637
|
+
// Why the server mints the key rather than trusting the plugin: `figma.fileKey` is gated to private
|
|
638
|
+
// plugins (undefined for an ordinary local import — `--whoami` reports which case you are in) and
|
|
639
|
+
// `figma.root.name` is a human-editable display string that duplicated files share, so neither is a
|
|
640
|
+
// dependable identity on its own. Both are RECORDED (they are what a human recognises in a listing,
|
|
641
|
+
// and fileKey is genuinely unique when present) but the routing key is always ours. This mirrors the
|
|
642
|
+
// Chrome DevTools Protocol shape — discover targets, then address a server-assigned id — rather than
|
|
643
|
+
// the user-typed "channel name" other Figma bridges use, which has no uniqueness guarantee and
|
|
644
|
+
// misroutes silently on a typo or a duplicate.
|
|
645
|
+
const registry = createRegistry(ifDefined("settleMs", opts.settleMs));
|
|
646
|
+
const clients = registry.clients;
|
|
647
|
+
// Every command sent and not yet settled, and the rules for how each settles (pending-requests.ts): the handlers
|
|
648
|
+
// below feed it what each socket says, request() opens an entry as it sends, and a request given up on is cancelled
|
|
649
|
+
// on its own socket.
|
|
650
|
+
const pending = createPendingRequests<ClientEntry, ProgressTick>({ clock: opts.clock ?? systemClock, sendCancel: cancelOnWire });
|
|
651
|
+
// Request ids are `r<nonce>-<seq>`, the nonce minted once per bridge. A bare per-process counter
|
|
652
|
+
// restarted at r1 in every bridge, so a plugin still finishing a run for a bridge that has since
|
|
653
|
+
// gone (a one-shot CLI that stalled out and exited) posted ITS r1 to whatever socket was current —
|
|
654
|
+
// and the next bridge's own r1 (a daemon's first forwarded request) settled with that stale run's
|
|
655
|
+
// reply: "export cancelled: … abandoned" for a request nobody had abandoned (seen live), or
|
|
656
|
+
// another node's tree had the old run succeeded. With the nonce such a reply names an id nobody here
|
|
657
|
+
// knows, and is dropped. Opaque to the plugin, which only ever echoes it back.
|
|
658
|
+
const nonce = crypto.randomBytes(3).toString("hex");
|
|
659
|
+
let seq = 0;
|
|
660
|
+
let takeovers = 0; // kept ONLY for the historical whoami field; nothing displaces anything.
|
|
661
|
+
let lastTakeoverAt = 0;
|
|
662
|
+
|
|
663
|
+
// The socket handlers Bun.serve calls (its `websocket` option, below). A socket's registry entry rides
|
|
664
|
+
// on its own `data`, set by open(); a bad-token socket never gets one, so every handler after open()
|
|
665
|
+
// ignores it — it can neither receive a command nor send a result.
|
|
666
|
+
function onOpen(ws: BridgeSocket): void {
|
|
667
|
+
// Admitted only to be told why it is refused (see `admit`). Before anything else: no registry
|
|
668
|
+
// entry. terminate() backs the close up in case the peer never completes it.
|
|
669
|
+
if (ws.data.badToken) {
|
|
670
|
+
ws.close(CLOSE_BAD_TOKEN, "bad token");
|
|
671
|
+
setTimeout(() => { try { ws.terminate(); } catch { /* already gone */ } }, 2000).unref();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
// Every connection is kept. The previous single-socket rule terminated the incumbent here, which
|
|
675
|
+
// made two open Figma files fight: the displaced plugin's auto-reconnect immediately stole the
|
|
676
|
+
// bridge back, and the two ping-ponged forever (observed live). Admitting both removes the
|
|
677
|
+
// contention rather than arbitrating it.
|
|
678
|
+
const entry = registry.add(ws);
|
|
679
|
+
ws.data.entry = entry;
|
|
680
|
+
console.error(`[bridge] plugin connected: ${entry.connId} (${clients.size} connected).`);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function onMessage(ws: BridgeSocket, data: string | Buffer): void {
|
|
684
|
+
const entry = ws.data.entry;
|
|
685
|
+
if (!entry) return;
|
|
686
|
+
const connId = entry.connId;
|
|
687
|
+
// The plugin only ever sends text frames (JSON.stringify in ui.html); Bun hands a text frame over
|
|
688
|
+
// as a string and a binary one as a Buffer, which is nothing the protocol has.
|
|
689
|
+
if (typeof data !== "string") return;
|
|
690
|
+
let parsed: unknown;
|
|
691
|
+
try {
|
|
692
|
+
parsed = JSON.parse(data) as unknown;
|
|
693
|
+
} catch {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const msg = parsePluginFrame(parsed);
|
|
697
|
+
if (!msg) return;
|
|
698
|
+
// ANY message from this client is a sign of life — heard unconditionally, before the
|
|
699
|
+
// type-specific handling below, so a request's stall check (pending-requests.ts) can tell "the plugin
|
|
700
|
+
// is genuinely walking a big file and periodically reporting progress" from "nothing has been
|
|
701
|
+
// heard from this socket since the command was sent", which a plain reply-or-timeout wait
|
|
702
|
+
// cannot: a real pull measured a 300s/908s silent wait, on a bridge that WAS connected, for
|
|
703
|
+
// work that took 7.5-8.8s once a daemon kept the connection warm.
|
|
704
|
+
pending.heard(connId);
|
|
705
|
+
// An unsolicited progress frame relayed from the plugin's own UI (figma-plugin/src/progress.ts
|
|
706
|
+
// posts these to the iframe DOM; ui.html forwards a bridge-triggered run's frames over this
|
|
707
|
+
// socket too). Is never a reply (a `requestId` on it only routes it, below) and needs no reply; it exists
|
|
708
|
+
// to show life during a long walk — and to feed the progress listener of any
|
|
709
|
+
// request in flight on THIS connection (the MCP server forwards them as notifications/progress).
|
|
710
|
+
// A frame that names its request (`requestId`: the plugin's `start` / `queued` for one run) goes to
|
|
711
|
+
// that request only — a request executing must not hear that another one is queued. An id-less frame
|
|
712
|
+
// (page ticks, an older plugin) keeps the fan-out; an id this connection has no pending request for
|
|
713
|
+
// feeds nothing (it already counted as life above).
|
|
714
|
+
// A request's OWN `start` (named by id — an id-less one could be anyone's) re-arms its budget (pending-requests.ts BUDGET).
|
|
715
|
+
if (msg.type === "progress") return pending.progress(connId, msg.requestId, msg.tick, msg.tick.phase === "start");
|
|
716
|
+
// Unsolicited identity announcement, sent by the plugin UI on connect AND on every reconnect.
|
|
717
|
+
// Re-announcing is the whole point: identity is DERIVED from the environment each time rather
|
|
718
|
+
// than issued by us and replayed, so a plugin that Figma tore down and re-ran comes back
|
|
719
|
+
// correctly labelled without any resumable-session machinery. A `hello` carries no request id,
|
|
720
|
+
// so it can never be confused with a reply.
|
|
721
|
+
if (msg.type === "hello") {
|
|
722
|
+
registry.identify(entry, msg);
|
|
723
|
+
const stalenessNote = pluginStalenessNote(entry.pluginVersion);
|
|
724
|
+
console.error(`[bridge] ${connId} identified: ${JSON.stringify(entry.file || "(unnamed file)")}` +
|
|
725
|
+
(entry.fileKey ? ` [fileKey ${entry.fileKey}]` : " [no fileKey — private-plugin API not in effect]") +
|
|
726
|
+
` [plugin v${entry.pluginVersion || "unknown"}]` + (stalenessNote ? ` — ${stalenessNote}` : ""));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
// Settled only by the connection it was SENT to (pending-requests.ts REPLY); its shape is checked in dispatch.
|
|
730
|
+
pending.reply(connId, msg.id, msg.ok ? { ok: true, result: msg.result } : { ok: false, error: new Error(String(msg.error || "plugin error")) });
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// The close code and reason are the whole diagnosis of a mid-export disconnect: Bun.serve reports no
|
|
734
|
+
// per-socket error event (ws did, and that error was where "max payload size exceeded" showed up).
|
|
735
|
+
// A frame over maxPayloadLength closes the socket with 1009, or — what Bun 1.4 actually reports to
|
|
736
|
+
// this handler — 1006 "Received too big message"; a peer that closes with 1009 itself says the same.
|
|
737
|
+
function onClose(ws: BridgeSocket, code: number, reason: string): void {
|
|
738
|
+
const entry = ws.data.entry;
|
|
739
|
+
if (!entry) return; // a bad-token socket: never registered, nothing in flight
|
|
740
|
+
const connId = entry.connId;
|
|
741
|
+
// Only forget THIS connection. A single-socket guard (`socket === ws`)
|
|
742
|
+
// would do the same job by accident; with a registry it has to be explicit, or one file closing
|
|
743
|
+
// its plugin window would drop the entry a different file is actively using.
|
|
744
|
+
registry.remove(entry);
|
|
745
|
+
const label = entry.file ? ` (${entry.file})` : "";
|
|
746
|
+
console.error(`[bridge] plugin disconnected: ${connId}${label} (${clients.size} still connected).`);
|
|
747
|
+
let why = `Figma plugin disconnected before replying (close ${code || "?"}${reason ? ": " + reason : ""}).`;
|
|
748
|
+
// 1009 is the one a caller can actually act on, and the likeliest failure on a big file:
|
|
749
|
+
// the export outgrew the frame limit, so name the knob instead of making them find it.
|
|
750
|
+
if (code === 1009 || /max payload|too large|too big/i.test(reason)) {
|
|
751
|
+
why += ` The export exceeded the ${maxPayloadMb} MB frame limit — raise FIGMA_BRIDGE_MAX_PAYLOAD_MB,` +
|
|
752
|
+
` or pull less at once (a single page instead of --all-pages).`;
|
|
753
|
+
} else {
|
|
754
|
+
// Anything else => the peer went away on its own: a plugin-side crash/OOM, or the window was
|
|
755
|
+
// closed. That surfaces in Figma's console, not here — say so.
|
|
756
|
+
why += ` No socket error was reported, so the plugin itself likely stopped (crash/OOM, or the` +
|
|
757
|
+
` window was closed). Check Plugins → Development → Open Console in Figma.`;
|
|
758
|
+
}
|
|
759
|
+
// Fail in-flight requests fast instead of hanging until their timeout — but ONLY the ones sent to
|
|
760
|
+
// the socket that just died. Rejecting the whole map (as a single-client bridge could, when the
|
|
761
|
+
// whole map could only ever belong to one socket) would abort a long export running happily
|
|
762
|
+
// in ANOTHER file because an unrelated one closed its plugin window.
|
|
763
|
+
pending.lost(connId, () => new Error(why));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Heartbeat, as in the ws README's "How to detect and close broken connections?": "Sometimes, the
|
|
767
|
+
// link between the server and the client can be interrupted in a way that keeps both the server and
|
|
768
|
+
// the client unaware of the broken state of the connection ... In these cases, ping messages can be
|
|
769
|
+
// used as a means to verify that the remote endpoint is still responsive." A client still not alive
|
|
770
|
+
// at the next tick missed the last pong: it leaves the registry at once (so no command is routed to
|
|
771
|
+
// it) and is terminated, whose close event fails its in-flight requests like every other disconnect.
|
|
772
|
+
// unref'd: the heartbeat must never be what keeps a one-shot CLI process alive.
|
|
773
|
+
const heartbeatTick = (): void => {
|
|
774
|
+
for (const [id, e] of clients) {
|
|
775
|
+
if (!e.isAlive) {
|
|
776
|
+
registry.remove(e);
|
|
777
|
+
console.error(`[bridge] ${id} did not answer a heartbeat ping — terminating it.`);
|
|
778
|
+
try { e.ws.terminate(); } catch { /* already gone */ }
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
e.isAlive = false;
|
|
782
|
+
try { e.ws.ping(); } catch { /* not open any more; its close handler removes it */ }
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
|
|
786
|
+
const heartbeat = heartbeatMs > 0 ? setInterval(heartbeatTick, heartbeatMs) : undefined;
|
|
787
|
+
heartbeat?.unref();
|
|
788
|
+
|
|
789
|
+
// The server. Bound to 127.0.0.1: strictly loopback. fetch() runs the admission (Origin + Host +
|
|
790
|
+
// shared token, fail-closed, and the plugin's null-origin bad-token admit-then-4401) on every upgrade
|
|
791
|
+
// request, and either upgrades it or refuses it with the 401/403 ws used to write. A plain
|
|
792
|
+
// (non-upgrade) request is answered 426, as ws's internal server did — `dtwin doctor` reads that 426
|
|
793
|
+
// as "a websocket server holds this port".
|
|
794
|
+
// The websocket options, each against a Bun default that would change what the bridge does:
|
|
795
|
+
// sendPings false, idleTimeout 0 — no automatic pings and no idle cut-off: the heartbeat above is the
|
|
796
|
+
// one liveness rule (a plugin can legitimately sit idle for hours between commands), and its tests
|
|
797
|
+
// count the pings exactly;
|
|
798
|
+
// perMessageDeflate false — ws's server default too; compressing a 100 MB export costs more than the
|
|
799
|
+
// loopback copy it saves;
|
|
800
|
+
// backpressureLimit = maxPayloadBytes — the bridge's own frames are small (commands, cancels), but a
|
|
801
|
+
// send past the limit is DROPPED (send() returns 0), so the limit is never below a frame the bridge
|
|
802
|
+
// would accept; sendFrame() reports a drop instead of leaving the request to time out.
|
|
803
|
+
// TEST SEAM: DT_TEST_EPHEMERAL_PORT=1 binds an ephemeral port instead of `port` and prints the real one
|
|
804
|
+
// on stderr (`[bridge] DT_TEST_EPHEMERAL_PORT: <port> -> <real port>`), so a test that spawns a server
|
|
805
|
+
// never takes 8787–8789 from a live Figma session or another test run. `port` keeps naming everything
|
|
806
|
+
// else (the daemon socket, status output). No user-facing path sets it.
|
|
807
|
+
const ephemeral = settings.ephemeralPort;
|
|
808
|
+
let server: Server<SocketData> | null = null;
|
|
809
|
+
let bindError: Error | null = null;
|
|
810
|
+
try {
|
|
811
|
+
server = Bun.serve<SocketData>({
|
|
812
|
+
hostname: "127.0.0.1",
|
|
813
|
+
port: ephemeral ? 0 : port,
|
|
814
|
+
// reusePort false, stated, never left to the default: Bun 1.4 turns SO_REUSEPORT ON whenever
|
|
815
|
+
// `development` is false — set here, and also Bun's own default under NODE_ENV=production — and two
|
|
816
|
+
// bridges would then BOTH bind 8787, with the kernel dealing plugin connections between them at
|
|
817
|
+
// random, instead of the second getting EADDRINUSE (the port-in-use refusal, and the daemon
|
|
818
|
+
// routing every front-end relies on). Found by test/cli-pull.test.ts: a second one-shot command
|
|
819
|
+
// "bound" the port a first one held.
|
|
820
|
+
reusePort: false,
|
|
821
|
+
development: false,
|
|
822
|
+
fetch(req, srv) {
|
|
823
|
+
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
|
|
824
|
+
return new Response(STATUS_TEXT[426], { status: 426, headers: { "Content-Type": "text/plain" } });
|
|
825
|
+
}
|
|
826
|
+
const origin = req.headers.get("origin");
|
|
827
|
+
const host = req.headers.get("host");
|
|
828
|
+
const a = admit({ ...(origin !== null ? { origin } : {}), req: { url: req.url, headers: { host: host ?? undefined } } });
|
|
829
|
+
if (!a.admit) return refuseUpgrade(a.code, a.message);
|
|
830
|
+
// false: not a valid WebSocket handshake after all (no Sec-WebSocket-Key, a version other than 13).
|
|
831
|
+
return srv.upgrade(req, { data: { badToken: a.badToken, entry: null } }) ? undefined : refuseUpgrade(400);
|
|
832
|
+
},
|
|
833
|
+
websocket: {
|
|
834
|
+
maxPayloadLength: maxPayloadBytes,
|
|
835
|
+
backpressureLimit: maxPayloadBytes,
|
|
836
|
+
sendPings: false,
|
|
837
|
+
idleTimeout: 0,
|
|
838
|
+
perMessageDeflate: false,
|
|
839
|
+
open: onOpen,
|
|
840
|
+
message: onMessage,
|
|
841
|
+
pong(ws) { if (ws.data.entry) ws.data.entry.isAlive = true; },
|
|
842
|
+
close: onClose,
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
if (ephemeral) console.error(`[bridge] DT_TEST_EPHEMERAL_PORT: ${port} -> ${server.port}`);
|
|
846
|
+
} catch (e) {
|
|
847
|
+
bindError = e instanceof Error ? e : new Error(errMsg(e));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Resolves once the port is bound. Never rejects: a bind failure goes to onListenError (or exits),
|
|
851
|
+
// so a caller that only wants "is it up yet" cannot leak an unhandled rejection.
|
|
852
|
+
// Bun.serve binds synchronously (it throws on a held port), but the failure is still reported a turn
|
|
853
|
+
// later, as node:http's 'error' event was: createBridge() returns first, so a caller that keeps the
|
|
854
|
+
// bridge (figma-mcp's resolveHolder) has it in hand when onListenError runs, and can close() it.
|
|
855
|
+
const listening = server ? Promise.resolve() : new Promise<void>(() => {});
|
|
856
|
+
if (bindError) {
|
|
857
|
+
const e = bindError;
|
|
858
|
+
setImmediate(() => {
|
|
859
|
+
const err = errCode(e) === "EADDRINUSE" ? new Error(portInUseMessage(port, process.platform, legacySock(daemonAddressOf(settings, port)))) : e;
|
|
860
|
+
if (opts.onListenError) return opts.onListenError(err);
|
|
861
|
+
console.error(errCode(e) === "EADDRINUSE" ? err.message : `[bridge] could not listen on 127.0.0.1:${port}: ${errMsg(e)}`);
|
|
862
|
+
// Off the CLI's call stack (a turn later), and the caller is awaiting `listening`, which never settles: an
|
|
863
|
+
// explicit exit, once that line is out.
|
|
864
|
+
exitFlushed(1);
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// A frame to one plugin socket. Bun's send() answers -1 (queued behind backpressure; it goes out on
|
|
869
|
+
// drain), 0 (DROPPED: the socket is closed, or its buffer is past backpressureLimit) or the bytes
|
|
870
|
+
// written. Only a drop is a failure: false, so the caller can say so instead of waiting on a reply to
|
|
871
|
+
// a frame that never left.
|
|
872
|
+
const sendFrame = (ws: BridgeSocket, frame: BridgeFrame): boolean => ws.send(JSON.stringify(frame)) !== 0;
|
|
873
|
+
|
|
874
|
+
const liveClients = registry.live;
|
|
875
|
+
const describe = registry.describe;
|
|
876
|
+
const resolveClient = registry.resolveClient;
|
|
877
|
+
const awaitClient = registry.awaitClient;
|
|
878
|
+
|
|
879
|
+
function isConnected(): boolean {
|
|
880
|
+
return liveClients().length > 0;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function listClients(): ClientRow[] {
|
|
884
|
+
return liveClients().map(describe);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Server-side half of the whoami probe. The plugin reports who IT is (instanceId, file, fileKey);
|
|
888
|
+
// this reports what the SOCKETS did. `takeovers` is retained and always 0 since connections
|
|
889
|
+
// coexist — kept rather than removed so an older reader of this field sees "no displacement is
|
|
890
|
+
// happening" instead of the key vanishing.
|
|
891
|
+
// `connId`: describe THAT connection (the one a request was addressed to) instead of the
|
|
892
|
+
// first live one; see connectionFor. Omitted = the first live connection.
|
|
893
|
+
function connectionInfo(connId?: string): ConnectionInfo {
|
|
894
|
+
const live = liveClients();
|
|
895
|
+
const first = live[0];
|
|
896
|
+
const info: ConnectionInfo = {
|
|
897
|
+
connId: first ? first.connId : null,
|
|
898
|
+
connected: live.length > 0,
|
|
899
|
+
connectedAt: first ? first.connectedAt : null,
|
|
900
|
+
connectionUptimeMs: first ? Date.now() - first.connectedAt : 0,
|
|
901
|
+
connectionsThisRun: registry.connections(),
|
|
902
|
+
clientsConnected: live.length,
|
|
903
|
+
clients: live.map(describe),
|
|
904
|
+
takeovers,
|
|
905
|
+
lastTakeoverAt: lastTakeoverAt || null,
|
|
906
|
+
};
|
|
907
|
+
return connectionFor(info, connId);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// `stallMs` (opt-in — undefined leaves the behaviour untouched for every other caller, including the
|
|
911
|
+
// daemon and MCP paths) is a SEPARATE, SHORTER wait for any sign of life from this specific client,
|
|
912
|
+
// checked independently of the real per-command timeout (pending-requests.ts STALL): a real pull measured
|
|
913
|
+
// a 300s/908s silent wait — on a connected, identified bridge — for work that takes
|
|
914
|
+
// 7.5-8.8s once a daemon keeps the plugin warm. A stalled one-shot connection (the shape of both
|
|
915
|
+
// measurements: no `dtwin serve` running) shows NO activity at all on the socket — not even a
|
|
916
|
+
// progress frame — for the whole wait, where a genuinely large/slow export shows life through the
|
|
917
|
+
// progress relay (the message handler above). Only
|
|
918
|
+
// figma-pull.ts's own one-shot bridge (no daemon) opts into this, and only for export-class
|
|
919
|
+
// commands — a cheap `whoami`/`list` finishing in under a second never needs it, and the daemon path
|
|
920
|
+
// deliberately does NOT pass it: a persistent connection is exactly the case that is
|
|
921
|
+
// already fast, so there is nothing here worth protecting against on that path.
|
|
922
|
+
// The clock starts at SEND, after the caller's connect + identify waits, so a stall never measures
|
|
923
|
+
// the plugin's reconnect. A current plugin answers within moments of the send even when it cannot
|
|
924
|
+
// start yet: a `queued` frame when its run waits behind another, a `start` frame when the run begins
|
|
925
|
+
// (figma-plugin state.ts / ui.html). So silence here means a plugin too busy to dispatch the frame
|
|
926
|
+
// at all (a long synchronous stretch of an earlier run) — or an older plugin with neither frame.
|
|
927
|
+
//
|
|
928
|
+
// CANCEL. Whenever this side gives up on a request it has already sent —
|
|
929
|
+
// the per-command timeout, the stall check, close(), or the caller's `signal` — the plugin is told,
|
|
930
|
+
// or it keeps walking the export for nobody and queues every later command behind it. The frame is
|
|
931
|
+
// `{ type: "cancel", id }` (no `cmd`): best-effort, sent only on an OPEN socket, and nothing is ever
|
|
932
|
+
// awaited back. A plugin that already finished, or never heard of `id`, ignores it; so does an older
|
|
933
|
+
// plugin bundle that predates the frame (it reads a frame without `cmd` as nothing to run). A
|
|
934
|
+
// duplicate for the same id is equally harmless. A socket that CLOSES is itself a cancel on the
|
|
935
|
+
// plugin side, which is why the one-shot CLI (whose exit closes the socket) needs nothing more.
|
|
936
|
+
function cancelOnWire(client: ClientEntry, id: string): void {
|
|
937
|
+
if (client.ws.readyState !== WS_OPEN) return;
|
|
938
|
+
try { sendFrame(client.ws, { type: "cancel", id }); } catch { /* socket went away — closing it is a cancel too */ }
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// ONE way to send a command, in the request shape every caller already speaks (route.ts UpstreamRequest): the
|
|
942
|
+
// route's adapters, the daemon's forwarding and the suites pass their options straight through. Typed per
|
|
943
|
+
// command from commands.ts: `args` is what the plugin reads for `cmd`, and the reply is what it sends back —
|
|
944
|
+
// checked structurally on arrival (the message handler above), so the type here is a promise the runtime keeps
|
|
945
|
+
// rather than a cast. It answers WHICH connected file the command went to too — the client resolveClient picked
|
|
946
|
+
// for `target`, described exactly as listClients() would (figma-pull.ts stamps it onto a screen export as
|
|
947
|
+
// `sourceFile` without re-implementing the matching rules).
|
|
948
|
+
//
|
|
949
|
+
// timeoutMs the per-command budget (TIMEOUTS.command when absent) — see dispatch for how it is armed.
|
|
950
|
+
// target connId / fileKey / file-name substring; absent = the only connected file.
|
|
951
|
+
// waitMs > 0: wait for `target` first, with that connect window (awaitClient) — the MCP server passes it
|
|
952
|
+
// for a named `client`, the daemon for a request that carries a connect window. Without it the
|
|
953
|
+
// target is resolved against whatever is connected now — once the bridge has settled.
|
|
954
|
+
// stallMs > 0: the stall check (see STALL_POLL_MS above) — the one-shot CLI's own bridge only.
|
|
955
|
+
// onProgress every progress tick the chosen connection sends while this request is in flight.
|
|
956
|
+
// signal the caller giving up. Already aborted, or aborted during the wait → rejects with the signal's
|
|
957
|
+
// reason when that is an Error of the caller's own, else "request aborted before it was sent",
|
|
958
|
+
// and nothing is sent; aborted while pending → the cancel frame goes to the plugin, the request
|
|
959
|
+
// is forgotten, and the promise rejects with the signal's reason when that is an Error of the
|
|
960
|
+
// caller's own (the daemon passes one naming its departed client), else "request aborted by the
|
|
961
|
+
// caller".
|
|
962
|
+
function request<C extends Cmd>(cmd: C, args: Commands[C]["args"], o: UpstreamRequest = {}): Promise<RequestOutcome<C>> {
|
|
963
|
+
const { timeoutMs = TIMEOUTS.command, target, stallMs, onProgress, signal } = o;
|
|
964
|
+
if (signal?.aborted) return Promise.reject(abortReason(signal, "request aborted before it was sent"));
|
|
965
|
+
// A bridge that is still settling holds every request for the rest of that window (awaitClient routes
|
|
966
|
+
// a connId or fileKey at once), so a fresh bridge never routes a vague one to the first file to land.
|
|
967
|
+
// A connect window shorter than the rest of the settle is stretched to it inside awaitClient.
|
|
968
|
+
const asked = o.waitMs ?? 0;
|
|
969
|
+
const waitMs = Math.max(asked > 0 ? asked : 0, registry.settleLeft());
|
|
970
|
+
if (!(waitMs > 0)) return dispatch(cmd, args, timeoutMs, target, stallMs, onProgress, signal);
|
|
971
|
+
return awaitClient(target, waitMs, signal).then(() => {
|
|
972
|
+
if (signal?.aborted) throw abortReason(signal, "request aborted before it was sent");
|
|
973
|
+
return dispatch(cmd, args, timeoutMs, target, stallMs, onProgress, signal);
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// What a stalled request says. Only reachable after connect + identify (the clock starts at send), so the plugin IS
|
|
978
|
+
// connected: say that, and name what keeps a connected plugin silent. Built only when the stall fires — `args` is
|
|
979
|
+
// what the caller sent, and the daemon forwards a frame's absent args as absent.
|
|
980
|
+
function stalled<C extends Cmd>(cmd: C, args: Commands[C]["args"], stallMs: number): Error {
|
|
981
|
+
const nodeId = "nodeId" in args && typeof args.nodeId === "string" ? args.nodeId : "";
|
|
982
|
+
const node = nodeId ? ` (node ${nodeId})` : "";
|
|
983
|
+
return new Error(
|
|
984
|
+
`the plugin is connected but sent nothing for '${cmd}'${node} in ${Math.round(stallMs / 1000)}s — it is busy (an earlier export ` +
|
|
985
|
+
`still running in that file, possibly one this command's predecessor abandoned, or a long cold first read of a large page). ` +
|
|
986
|
+
`Retry; a warm plugin (\`dtwin serve\` in another terminal) answers in seconds; if it keeps happening, check the plugin window.`
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// request() after its wait: resolve the target now, open its entry, send, and settle on the reply.
|
|
991
|
+
function dispatch<C extends Cmd>(cmd: C, args: Commands[C]["args"], timeoutMs: number, target: string | null | undefined, stallMs: number | undefined, onProgress: ((tick: ProgressTick) => void) | undefined, signal: AbortSignal | undefined): Promise<RequestOutcome<C>> {
|
|
992
|
+
let client: ClientEntry;
|
|
993
|
+
try {
|
|
994
|
+
client = resolveClient(target);
|
|
995
|
+
} catch (e) {
|
|
996
|
+
return Promise.reject(e);
|
|
997
|
+
}
|
|
998
|
+
const conn = client;
|
|
999
|
+
const id = "r" + nonce + "-" + ++seq;
|
|
1000
|
+
// The budget and the stall check are pending-requests.ts's (BUDGET, STALL); what each says when it runs out is here.
|
|
1001
|
+
const settled = pending.open({
|
|
1002
|
+
id, conn, signal, onProgress,
|
|
1003
|
+
budgetMs: timeoutMs,
|
|
1004
|
+
// We only get here AFTER isConnected() passed and the command was sent, so the socket is
|
|
1005
|
+
// up and the file IS open — never blame those. The real causes are a slow export (big
|
|
1006
|
+
// file / --all-pages) or a plugin-side throw, which surfaces in the plugin window, not here.
|
|
1007
|
+
timedOut: () => new Error(
|
|
1008
|
+
`the Figma plugin connected but did not answer '${cmd}' within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
1009
|
+
`A large file (especially --all-pages) can legitimately take longer — from the dtwin CLI, ` +
|
|
1010
|
+
`retry with --timeout <seconds>; from MCP, export one page at a time (page:[id]) rather than the whole file. ` +
|
|
1011
|
+
`If it never finishes, check the plugin window for a red error.`
|
|
1012
|
+
),
|
|
1013
|
+
stall: typeof stallMs === "number" && stallMs > 0 ? {
|
|
1014
|
+
ms: stallMs,
|
|
1015
|
+
error: () => stalled(cmd, args, stallMs),
|
|
1016
|
+
} : undefined,
|
|
1017
|
+
});
|
|
1018
|
+
// Not pending right after open only when its signal was already aborted (open refused it): then nothing is sent.
|
|
1019
|
+
// A send that throws (args that do not serialise) rejects the request as the throw, as it always did.
|
|
1020
|
+
if (pending.has(id)) {
|
|
1021
|
+
let dropped = false;
|
|
1022
|
+
try { dropped = !sendFrame(conn.ws, { id, cmd, args }); } catch (e) { pending.fail(id, e instanceof Error ? e : new Error(errMsg(e))); }
|
|
1023
|
+
// Dropped (see sendFrame): nothing will ever answer it, so fail it now rather than at its timeout.
|
|
1024
|
+
if (dropped) {
|
|
1025
|
+
pending.fail(id, new Error(
|
|
1026
|
+
`could not send '${cmd}' to the Figma plugin (${conn.connId}): its socket is closing, or its send buffer is past ` +
|
|
1027
|
+
`the ${maxPayloadMb} MB limit (FIGMA_BRIDGE_MAX_PAYLOAD_MB). Retry once the plugin has reconnected.`
|
|
1028
|
+
));
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return settled.then((result) => {
|
|
1032
|
+
// The ONE place a reply enters this process, so the ONE place its shape is checked against the
|
|
1033
|
+
// contract (commands.ts). A plugin that answers `listPages` with something that has no `pages`
|
|
1034
|
+
// — an older bundle, a half-migrated command — fails HERE, named, instead of as a TypeError in
|
|
1035
|
+
// whichever reader touches the missing field first. Checked, the typed reply is a promise the
|
|
1036
|
+
// runtime kept: the one assertion below is that hand-off, not a guess about the plugin.
|
|
1037
|
+
const bad = replyShapeError(cmd, result);
|
|
1038
|
+
if (bad) throw new Error(`plugin replied with an unexpected shape for ${cmd}: ${bad}`);
|
|
1039
|
+
return { reply: result as Commands[C]["reply"], client: describe(conn) };
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Shut the server down so the process can exit ON ITS OWN. Without this a caller's only way out was
|
|
1044
|
+
// process.exit(), which on a PIPE discards whatever stdout hasn't flushed — stdout is async for pipes
|
|
1045
|
+
// (seen under Node, before the Bun port), so a large `--list` payload was silently cut at one 64KB
|
|
1046
|
+
// pipe buffer (measured: 369,799 bytes written, 65,536 delivered). Letting the event loop drain instead is the only fix
|
|
1047
|
+
// that keeps the whole payload; the WS server is what was holding the loop open, so it has to go.
|
|
1048
|
+
function close(): void {
|
|
1049
|
+
clearInterval(heartbeat);
|
|
1050
|
+
// Cancel frames FIRST, while the sockets are still open: every request still pending here is work
|
|
1051
|
+
// the plugin would otherwise finish for nobody (see cancelOnWire). The socket close below is a
|
|
1052
|
+
// cancel on the plugin side too; the explicit frame names the exact request and costs nothing.
|
|
1053
|
+
pending.close(() => new Error("bridge closed"));
|
|
1054
|
+
// Close EVERY client, not just one — otherwise a second connected
|
|
1055
|
+
// file would hold the event loop open and the process would never exit on its own, which is the
|
|
1056
|
+
// whole reason this function exists.
|
|
1057
|
+
// close() starts a closing handshake that waits for the peer to answer it; a plugin iframe that is
|
|
1058
|
+
// busy (or gone) can hold this process open meanwhile. terminate() after a grace period drops the
|
|
1059
|
+
// socket regardless — the same backstop the bad-token path uses.
|
|
1060
|
+
for (const e of clients.values()) {
|
|
1061
|
+
try { e.ws.close(); } catch { /* already gone */ }
|
|
1062
|
+
setTimeout(() => { try { e.ws.terminate(); } catch { /* already gone */ } }, 2000).unref();
|
|
1063
|
+
}
|
|
1064
|
+
registry.clear(); // and every wait for a client ends now
|
|
1065
|
+
// Stop listening. stop(false) leaves the sockets just closed above to finish their handshake (or be
|
|
1066
|
+
// terminated); its promise settles when they have, which nothing here needs to wait for.
|
|
1067
|
+
if (server) { const s = server; server = null; void s.stop(false).catch(() => { /* already stopped */ }); }
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** The port actually bound — `port` itself, except under createBridge(0) or DT_TEST_EPHEMERAL_PORT
|
|
1071
|
+
* (an ephemeral one); null when the bind failed or after close(). Test seam: a suite binds port 0 and
|
|
1072
|
+
* dials this, so it never collides with a live bridge or a concurrent run. */
|
|
1073
|
+
const boundPort = (): number | null => server?.port ?? null;
|
|
1074
|
+
|
|
1075
|
+
return {
|
|
1076
|
+
request, isConnected, awaitClient, connectionInfo, listClients, resolveClient, close, port, listening, boundPort,
|
|
1077
|
+
/** the failed-handshake record, and the fingerprint of the token this bridge checks against (doctor reads both) */
|
|
1078
|
+
authStats: () => authStatsFor(token),
|
|
1079
|
+
/** test seam: one heartbeat round, run by hand — with `heartbeatMs: 0` it is the only one. */
|
|
1080
|
+
heartbeatTick,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// The object createBridge() returns — what route.ts's own adapter (and through it the daemon) drives.
|
|
1085
|
+
export type Bridge = ReturnType<typeof createBridge>;
|
|
1086
|
+
export type { BridgeOptions };
|
|
1087
|
+
|
|
1088
|
+
// verifyClientWith/safeEqual are exported for the test suite (test/bridge.test.ts). They are the bridge's
|
|
1089
|
+
// ONLY real access control, so they get direct unit coverage rather than being reachable only through
|
|
1090
|
+
// a live WebSocket handshake.
|
|
1091
|
+
// isConnId and connectionFor live in route.ts (which takes only types from this module); re-exported for the suites
|
|
1092
|
+
// that test them here.
|
|
1093
|
+
export { createBridge, createRegistry, connectionFor, isConnId, verifyClientWith, CLOSE_BAD_TOKEN, safeEqual, portInUseMessage, IDENTIFY_GRACE_MS };
|