@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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. 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 };