@design-twin/dtwin 0.0.0-stage → 0.1.0-alpha.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +949 -2
  3. package/package.json +49 -4
  4. package/src/abort-reason.ts +12 -0
  5. package/src/asset-compare.ts +175 -0
  6. package/src/asset-owners.ts +280 -0
  7. package/src/atomic-write.ts +79 -0
  8. package/src/bridge-holder.ts +294 -0
  9. package/src/cli-commands.ts +427 -0
  10. package/src/client-presence.ts +146 -0
  11. package/src/clock.ts +45 -0
  12. package/src/commands.ts +561 -0
  13. package/src/daemon-auth.ts +174 -0
  14. package/src/daemon.ts +788 -0
  15. package/src/delivery.ts +85 -0
  16. package/src/design-system-layout.ts +232 -0
  17. package/src/doc-types.ts +1054 -0
  18. package/src/doctor.ts +602 -0
  19. package/src/errmsg.ts +14 -0
  20. package/src/export-reader.ts +409 -0
  21. package/src/export-verdict.ts +258 -0
  22. package/src/figma-mcp.ts +940 -0
  23. package/src/figma-pull.ts +1265 -0
  24. package/src/file-lock.ts +268 -0
  25. package/src/file-memo.ts +62 -0
  26. package/src/hash.ts +6 -0
  27. package/src/hex-color.ts +39 -0
  28. package/src/human-ms.ts +7 -0
  29. package/src/init.ts +424 -0
  30. package/src/instance-overrides.ts +7 -0
  31. package/src/json-file.ts +79 -0
  32. package/src/json-size.ts +108 -0
  33. package/src/json-util.ts +51 -0
  34. package/src/library-layout.ts +193 -0
  35. package/src/node-id.ts +69 -0
  36. package/src/pages-layout.ts +483 -0
  37. package/src/pending-requests.ts +187 -0
  38. package/src/plugin-frames.ts +32 -0
  39. package/src/png.ts +196 -0
  40. package/src/ports.ts +25 -0
  41. package/src/project-layout.ts +189 -0
  42. package/src/quick-keys.ts +68 -0
  43. package/src/read-json.ts +67 -0
  44. package/src/read-opts.ts +125 -0
  45. package/src/read-plan.ts +105 -0
  46. package/src/read-run.ts +119 -0
  47. package/src/route.ts +243 -0
  48. package/src/seed-components.ts +229 -0
  49. package/src/served-session.ts +347 -0
  50. package/src/server-core.ts +1093 -0
  51. package/src/settings.ts +156 -0
  52. package/src/source-stamp.ts +16 -0
  53. package/src/staleness.ts +63 -0
  54. package/src/stdio.ts +156 -0
  55. package/src/svg-normalize.ts +205 -0
  56. package/src/timeouts.ts +76 -0
  57. package/src/timings.ts +118 -0
  58. package/src/token-store.ts +261 -0
  59. package/src/variables-merge.ts +337 -0
  60. package/src/verbs.ts +140 -0
  61. package/src/write-out.ts +1255 -0
@@ -0,0 +1,294 @@
1
+ // bridge-holder.ts — find the bridge's owner, or take the bridge and share it: one module for the three ways a
2
+ // process reaches the plugin.
3
+ //
4
+ // Port 8787 has one owner. Every front-end used to work out for itself whether a daemon already held it, when to
5
+ // open a bridge of its own, whether to publish that bridge on the daemon socket, and how to let go — three times,
6
+ // with three startup orders and teardowns (the CLI's one-shot session, `dtwin serve`, and the MCP server, whose
7
+ // version kept five module globals, an in-flight memo against a double bind, and read the daemon's frame counters
8
+ // across the seam to decide whether it could exit). Here it is once. A front-end picks a LIFETIME and supplies its
9
+ // events; the holder hides probe-or-bind, publishing on the daemon socket, whether others used it, the idle
10
+ // window, and releasing exactly once.
11
+ //
12
+ // one-shot a CLI command: the running daemon when there is one, else a bridge of its own for this command only
13
+ // (never shared — a process that finds the port held exits on it, as it always did). release() closes it.
14
+ // serve `dtwin serve`: must be the owner (a live daemon is refused, naming --stop), publishes its bridge on the
15
+ // daemon socket with the settings' idle window, and owns the process's signals (daemon.ts serve).
16
+ // session the MCP server: lives as long as its MCP client. Re-resolves the owner whenever it does not hold the
17
+ // bridge itself (a daemon it routed through may have gone — that session closed, `dtwin stop`), so it
18
+ // takes the bridge over when the owner goes. Holding it, it publishes it with no idle window; when the
19
+ // client leaves (clientLeft) it releases at once unless OTHER processes have used the socket, and then
20
+ // keeps serving them with the settings' idle window. A held port is the caller's error, never an exit:
21
+ // the process is the user's stdio session.
22
+ //
23
+ // What the front-ends keep: their own events (the CLI's command finishing, the MCP server's stdin / stdout errors
24
+ // / signals) turned into release() or clientLeft(), and what to do once `released` settles (exit).
25
+ import { daemonUpstream, ownUpstream, servedBridge } from "./route.ts";
26
+ import type { HeldBridge, Upstream } from "./route.ts";
27
+ import type { DaemonBridge, DaemonConnection, DaemonStatusView, ServeHandle } from "./daemon.ts";
28
+ import * as daemon from "./daemon.ts";
29
+ import { commitToken, daemonAddressOf, humanMs } from "./settings.ts";
30
+ import type { BridgeSettings } from "./settings.ts";
31
+ import type { Bridge, BridgeOptions } from "./server-core.ts";
32
+ import { errMsg } from "./errmsg.ts";
33
+
34
+ export type Lifetime = "one-shot" | "serve" | "session";
35
+
36
+ /** What the holder needs of a bridge it opens: what the route's adapters use (HeldBridge) and when it is bound.
37
+ * server-core's createBridge result is one; a suite passes a fake. It is given the settings with their token
38
+ * committed (commitToken): it only admits with it. */
39
+ export type OwnBridge = HeldBridge & Pick<Bridge, "listening">;
40
+ export type BridgeFactory = (settings: BridgeSettings, opts: BridgeOptions) => OwnBridge;
41
+
42
+ /** What the holder needs of the daemon socket, bound to one resolved address (daemonSide). */
43
+ export interface DaemonSide {
44
+ /** the live daemon's connection, or null */
45
+ connect(): Promise<DaemonConnection | null>;
46
+ status(): Promise<DaemonStatusView | null>;
47
+ /** throws when a live daemon (or a foreign holder) already has the socket */
48
+ assertNoDaemon(): Promise<string>;
49
+ serve(bridge: DaemonBridge, o: { idleMs: number; signals: boolean; log: (m: string) => void }): Promise<ServeHandle>;
50
+ }
51
+
52
+ /** The daemon module, bound to the address `settings` resolve to (settings.ts daemonAddressOf) — resolved here, once. */
53
+ export function daemonSide(settings: BridgeSettings): DaemonSide {
54
+ const at = daemonAddressOf(settings);
55
+ return {
56
+ connect: () => daemon.connect(at),
57
+ status: () => daemon.status(at),
58
+ assertNoDaemon: () => daemon.assertNoDaemon(at),
59
+ serve: (bridge, o) => daemon.serve(bridge, at, o),
60
+ };
61
+ }
62
+
63
+ export interface HolderDeps {
64
+ settings: BridgeSettings;
65
+ createBridge: BridgeFactory;
66
+ /** the daemon side for the settings as they are NOW (daemonSide): a token this process mints and saves keys every
67
+ * later lookup, so a first-run session still finds the daemon a later `dtwin serve` names from that token */
68
+ daemon: (settings: BridgeSettings) => DaemonSide;
69
+ /** the front-end's log line (stderr) */
70
+ log: (m: string) => void;
71
+ }
72
+
73
+ /** Where this process is in relation to the bridge, for a status line. */
74
+ export type Held =
75
+ | { kind: "none" }
76
+ | { kind: "daemon"; sock: string }
77
+ | { kind: "own"; port: number; sock: string | null };
78
+
79
+ export interface Holder {
80
+ /** Where requests go now: a daemon's socket, or the bridge this process holds — resolved as the lifetime says.
81
+ * Concurrent calls share one resolution (two first calls never both bind). */
82
+ upstream(): Promise<Upstream>;
83
+ /** session: the client has gone. Releases now unless other processes have used the shared socket; then serves
84
+ * them on, and releases after the settings' idle window without one. Once only; a no-op for other lifetimes. */
85
+ clientLeft(how: string): void;
86
+ /** Let go of everything this process holds, exactly once: the served socket (its forwarded requests aborted, its
87
+ * file unlinked) and the bridge (a cancel frame for every request still in flight). `why` is logged when given. */
88
+ release(why?: string): void;
89
+ /** Settles with the reason once the holder has let go — release(), clientLeft, `dtwin stop`, the idle shutdown. */
90
+ readonly released: Promise<string>;
91
+ held(): Held;
92
+ }
93
+
94
+ const RELEASED = "the bridge has been released";
95
+
96
+ export function bridgeHolder(lifetime: Lifetime, deps: HolderDeps): Holder {
97
+ const { log } = deps;
98
+ // The settings, with their token committed before our first bind (commitToken): a token minted then is saved from
99
+ // then on, and every later bridge and daemon lookup uses it as saved — a rebind must not write it over a file rotated
100
+ // since, nor print it again. Committed once, even when that first bind then fails (a held port).
101
+ let settings = deps.settings;
102
+ let committed = false;
103
+ // The daemon side for the settings as they are now: resolved once per settings value — again only after a commit
104
+ // changed the token it is keyed on.
105
+ let sideOf: { settings: BridgeSettings; side: DaemonSide } | null = null;
106
+ const side = (): DaemonSide => {
107
+ if (sideOf?.settings !== settings) sideOf = { settings, side: deps.daemon(settings) };
108
+ return sideOf.side;
109
+ };
110
+ // The bridge this process holds, its adapter, and the socket it is published on (null until serve() bound it).
111
+ let own: { bridge: OwnBridge; up: Upstream; close: () => void } | null = null;
112
+ let served: ServeHandle | null = null;
113
+ let resolving: Promise<Upstream> | null = null;
114
+ let viaDaemon: string | null = null;
115
+ let gone = false; // session: the client has left
116
+ let done = false;
117
+ let settle: (why: string) => void = () => {};
118
+ const released = new Promise<string>((r) => { settle = r; });
119
+ const finish = (why: string): void => {
120
+ if (done) return;
121
+ done = true;
122
+ settle(why);
123
+ };
124
+
125
+ // Open a bridge of our own. `survive`: a held port is this call's error (session), not an exit (one-shot, serve
126
+ // — server-core prints the fix and exits 1 when no onListenError is given).
127
+ async function bind(survive: boolean): Promise<OwnBridge> {
128
+ if (!committed) {
129
+ committed = true;
130
+ const c = commitToken(settings);
131
+ settings = c.settings;
132
+ for (const line of c.notice) log(line);
133
+ }
134
+ let failed: (e: Error) => void = () => {};
135
+ const bindFailed = new Promise<never>((_, reject) => { failed = reject; });
136
+ const b = deps.createBridge(settings, survive ? { onListenError: (e) => failed(e) } : {});
137
+ try {
138
+ await (survive ? Promise.race([b.listening, bindFailed]) : b.listening);
139
+ } catch (e) {
140
+ b.close();
141
+ throw e;
142
+ }
143
+ // Released while it was binding (the command finished, a signal, the client left): nobody is left to use it.
144
+ if (done) { b.close(); throw new Error(RELEASED); }
145
+ return b;
146
+ }
147
+
148
+ // Hold `b`: its adapter, and a close that forgets it — so a session re-resolves instead of talking to a closed
149
+ // server. Closed from outside (`dtwin stop`, the idle shutdown) after the client has gone, nothing is left to do.
150
+ function hold(b: OwnBridge): NonNullable<typeof own> {
151
+ const up = ownUpstream(b);
152
+ let closed = false;
153
+ const close = (): void => {
154
+ if (closed) return;
155
+ closed = true;
156
+ own = null;
157
+ served = null;
158
+ resolving = null;
159
+ b.close();
160
+ if (lifetime === "serve") finish("the daemon shut down");
161
+ else if (gone) release("the shared bridge was stopped after the MCP client went away — exiting");
162
+ };
163
+ own = { bridge: b, up, close };
164
+ return own;
165
+ }
166
+
167
+ // Publish the held bridge on the daemon socket. `idleMs` 0 for a session while its client is here.
168
+ function publish(b: OwnBridge, close: () => void, idleMs: number, signals: boolean): Promise<ServeHandle> {
169
+ return side().serve(servedBridge(b, close), { idleMs, signals, log });
170
+ }
171
+
172
+ // A daemon found: its adapter (listings from its status, at the place it was found).
173
+ async function viaDaemonOr(): Promise<Upstream | null> {
174
+ const d = side();
175
+ const via = await d.connect();
176
+ if (done) throw new Error(RELEASED);
177
+ if (!via) { viaDaemon = null; return null; }
178
+ viaDaemon = via.sock;
179
+ return daemonUpstream(via, () => d.status());
180
+ }
181
+
182
+ async function resolveSession(): Promise<Upstream> {
183
+ if (own) return own.up;
184
+ const via = await viaDaemonOr();
185
+ if (via) return via;
186
+ let b: OwnBridge;
187
+ try {
188
+ b = await bind(true);
189
+ } catch (e) {
190
+ throw gone && errMsg(e) === RELEASED ? new Error("the MCP client went away before the bridge was up") : e;
191
+ }
192
+ const mine = hold(b);
193
+ publish(b, mine.close, 0, false).then(
194
+ (h) => {
195
+ // Gone (or this bridge stopped) while the socket was binding: take it down again at once.
196
+ if (own !== mine || done) { h.shutdown(); return; }
197
+ served = h;
198
+ },
199
+ (e) => log("not sharing the bridge with other sessions: " + errMsg(e)),
200
+ );
201
+ return mine.up;
202
+ }
203
+
204
+ async function resolveOneShot(): Promise<Upstream> {
205
+ return (await viaDaemonOr()) ?? hold(await bind(false)).up;
206
+ }
207
+
208
+ async function resolveServe(): Promise<Upstream> {
209
+ // The already-running probe FIRST: binding with a daemon already up is an EADDRINUSE exit naming "another dtwin
210
+ // bridge" — the right answer is the daemon's own refusal, which names --stop. serve() probes again right before
211
+ // listening (the race is handled there). No saved token yet (Windows, first run): nothing can be serving a pipe
212
+ // named from a token nobody saved, and bind() commits the one it mints — the socket is then named from it.
213
+ try { await side().assertNoDaemon(); } catch (e) { if (errMsg(e) !== daemon.NO_DAEMON_TOKEN) throw e; }
214
+ if (done) throw new Error(RELEASED);
215
+ const b = await bind(false);
216
+ const mine = hold(b);
217
+ let h: ServeHandle;
218
+ try {
219
+ h = await publish(b, mine.close, settings.idleMs, true);
220
+ } catch (e) {
221
+ // Another daemon won the race to the socket: this bridge serves nobody. Let it go without "the daemon shut down".
222
+ own = null;
223
+ b.close();
224
+ throw e;
225
+ }
226
+ // Released while the socket was binding: release() closed the bridge (hold()'s close); take the socket down too.
227
+ if (done) { h.shutdown(); throw new Error(RELEASED); }
228
+ served = h;
229
+ return mine.up;
230
+ }
231
+
232
+ const resolveFor = { "one-shot": resolveOneShot, serve: resolveServe, session: resolveSession }[lifetime];
233
+
234
+ function release(why?: string): void {
235
+ if (done) return;
236
+ if (why) log(why);
237
+ // finish() first: the shutdown below closes the bridge through hold()'s close, which must not log a second reason.
238
+ finish(why ?? "released");
239
+ const s = served;
240
+ served = null;
241
+ const o = own;
242
+ if (s) s.shutdown(); // aborts its forwarded requests, unlinks the socket, closes the bridge (hold()'s close)
243
+ if (o) o.close();
244
+ }
245
+
246
+ function clientLeft(how: string): void {
247
+ if (lifetime !== "session" || gone || done) return;
248
+ gone = true;
249
+ const s = served;
250
+ if (!own || !s) return release(`the MCP client went away (${how}) — exiting`);
251
+ const a = s.activity();
252
+ if (a.frames === 0) return release(`the MCP client went away (${how}) and no other dtwin command or session used the shared bridge — closing it and exiting`);
253
+ const ms = settings.idleMs;
254
+ // Already quiet for the whole window (and nothing in flight): idleOut's check-at-once would shut it down right
255
+ // away, so say that instead of "still serving".
256
+ if (ms && a.inFlight === 0 && a.idleForMs >= ms) {
257
+ return release(`the MCP client went away (${how}); other dtwin commands/sessions used this shared bridge (${a.frames} request(s)), but none in the last ${humanMs(ms)} (FIGMA_DAEMON_IDLE_MIN) — closing it and exiting`);
258
+ }
259
+ const used = `other dtwin commands/sessions use this shared bridge (${a.frames} request(s) so far)`;
260
+ log(ms
261
+ ? `the MCP client went away (${how}); ${used} — still serving it, shutting down after ${humanMs(ms)} without one (FIGMA_DAEMON_IDLE_MIN)`
262
+ : `the MCP client went away (${how}); ${used} — still serving it, and it never shuts down on its own (${idleOffWhy(settings.idleRaw)}) — stop it with dtwin stop`);
263
+ // Its shutdown closes the bridge through hold()'s close, which releases.
264
+ s.idleOut(ms);
265
+ }
266
+
267
+ return {
268
+ upstream() {
269
+ if (done) return Promise.reject(new Error(RELEASED));
270
+ // One resolution shared by every caller while it is in flight, so concurrent first calls never both bind.
271
+ // one-shot and serve keep it for good. A session keeps it only while it holds the bridge (hold()'s close drops
272
+ // it): a daemon route or a failure is re-resolved on the next call — that owner may have gone since.
273
+ if (lifetime !== "session") return (resolving ??= resolveFor());
274
+ if (own) return Promise.resolve(own.up);
275
+ return (resolving ??= resolveFor().finally(() => { if (!own) resolving = null; }));
276
+ },
277
+ clientLeft,
278
+ release,
279
+ released,
280
+ held(): Held {
281
+ if (own) return { kind: "own", port: own.bridge.port, sock: served?.sock ?? null };
282
+ return viaDaemon ? { kind: "daemon", sock: viaDaemon } : { kind: "none" };
283
+ },
284
+ };
285
+ }
286
+
287
+ // Why the idle window is 0: the variable says 0, or it is not a usable number of minutes (idleMsFromEnv turns an
288
+ // unparsable or negative value into 0 = off, as it always has) — name which, with the raw value.
289
+ function idleOffWhy(raw: string | undefined): string {
290
+ const v = (raw ?? "").trim();
291
+ return v !== "" && Number(v) === 0
292
+ ? `FIGMA_DAEMON_IDLE_MIN=${v}`
293
+ : `FIGMA_DAEMON_IDLE_MIN=${JSON.stringify(raw ?? "")} is not a number of minutes above 0, which turns the idle shutdown off`;
294
+ }