@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,280 @@
1
+ // asset-owners.ts — who owns an exported graphic, and which hidden graphic can reuse a visible twin's file.
2
+ //
3
+ // Pure: plain IR trees in, plain answers out (no fs) — write-out.ts does the reading and writing, so every
4
+ // rule here is testable offline on hand-built trees.
5
+ //
6
+ // 1. Owners. An asset file is named after its layer, and a layer is very often called
7
+ // `Vector`, `Icon` or `Group_N`. What it IS lives on the nearest component instance above it (or on the
8
+ // node itself): `Checkbox / Status=Checked`. One walk gives every `asset` pointer its uses — node id,
9
+ // layer name, hidden or not, owner, the instance above the owner (`context`, which names a self-owned
10
+ // icon component such as `Glyph 12` inside `Nav item / Type=Settings`) — and the `.assets.json`
11
+ // row carries them, so a builder searches by component instead of by file name. File names never change.
12
+ //
13
+ // 2. Hidden reuse. The plugin does not export a hidden graphic (`assetSkipped:"hidden"`): its
14
+ // exporter has nothing to render. When a VISIBLE node in the same pull (or in another screen already
15
+ // pulled) is the same graphic — the same main component, the same source node inside it, the same
16
+ // chain of components and variants above the owner, the same non-boolean props, the same effective
17
+ // variable modes, the same size and transform, and no paint override (nor a shape/visibility override
18
+ // on a sublayer of the graphic) on either side — the hidden
19
+ // node points at that file instead (`asset` + `assetFrom`). An owner's booleans are left out of the key
20
+ // because they are the visibility toggles that hid the node; a SELF-owned icon's own booleans are its
21
+ // internal toggles (`Show badge`), so they stay in. Variant props are covered by the main
22
+ // component id. The modes are in because a bound colour renders per mode.
23
+ // The chain is in the key because a parent variant may recolour a nested icon in its own DEFINITION
24
+ // (`Button / Type=Danger` drawing its icon red), and Figma's `overrides` list only what is overridden
25
+ // directly on an instance, never what it inherits — so no override would block that twin.
26
+ import type { InstanceOverride, IrNode } from "./doc-types.ts";
27
+ import { OVERRIDE_CAP } from "./instance-overrides.ts";
28
+
29
+ /** The nearest INSTANCE ancestor-or-self that names its main component. */
30
+ export interface AssetOwner {
31
+ /** the component set's name, else the component's own */
32
+ component: string;
33
+ variant?: string;
34
+ componentId?: string;
35
+ componentKey?: string;
36
+ /** the owning instance's node id */
37
+ instance: string;
38
+ /** the node IS the owning instance (a self-owned icon component) */
39
+ self: boolean;
40
+ /** a paint override, a shape/visibility override on a sublayer (or a truncated override list) on this
41
+ * use — never reused across, either way */
42
+ paintOverrides?: true;
43
+ }
44
+ /** The next INSTANCE above the owner. */
45
+ export interface AssetContext { component: string; variant?: string }
46
+
47
+ /** One node in a tree that holds an exported graphic (`asset`) or a hidden one that was not (`assetSkipped:"hidden"`). */
48
+ export interface Graphic {
49
+ node: IrNode;
50
+ /** the node or an ancestor is hidden */
51
+ hidden: boolean;
52
+ owner?: AssetOwner;
53
+ context?: AssetContext;
54
+ /** see reuseKeyOf; absent without an owner that names a component id or key */
55
+ reuseKey?: string;
56
+ /** a paint override, a sublayer shape/visibility override or a truncated override list blocks reuse */
57
+ blocked: boolean;
58
+ }
59
+
60
+ // Figma's overriddenFields that change how a graphic LOOKS. Anything else (a text, a swapped instance's
61
+ // visibility, a name) leaves the exported pixels of an icon alone.
62
+ const PAINT_FIELD_RE = /^(?:fills|strokes|strokeWeight|sto?ke\w*Weight|opacity|effects|effectStyleId|blendMode|fillStyleId|strokeStyleId|boundVariables|explicitVariableModes|vectorNetwork)$/;
63
+ // On a SUBLAYER of the graphic (inside the exported file), any field but these changes the drawing: a
64
+ // `visible` override drops a layer, a size/corner/transform one reshapes it.
65
+ const INERT_SUBLAYER_FIELD_RE = /^(?:name|locked|pluginData|reactions|description|exportSettings|expanded|autoRename|hyperlink|characters|styledTextSegments|textStyleId|fontName)$/;
66
+
67
+ const lastSegment = (id: string): string => { const i = id.lastIndexOf(";"); return i >= 0 ? id.slice(i + 1) : id; };
68
+ const isOwnerInstance = (n: IrNode): boolean => n.type === "INSTANCE" && !!n.mainComponent;
69
+ const overridesOf = (n: IrNode): readonly InstanceOverride[] => (Array.isArray(n.overrides) ? n.overrides : []);
70
+ // The plugin cuts the list at OVERRIDE_CAP (and warns): at that length, what was cut is unknown.
71
+ const truncated = (n: IrNode): boolean => overridesOf(n).length >= OVERRIDE_CAP;
72
+ const paintEntry = (o: InstanceOverride): boolean => !!o && Array.isArray(o.fields) && o.fields.some((f) => typeof f === "string" && PAINT_FIELD_RE.test(f));
73
+
74
+ // (iii) of the reuse guard. On every instance from the root down to the graphic, a paint entry ABOUT the
75
+ // graphic or one of its sublayers counts (a sublayer id is `<id>;…`, or `I<id>;…` under a top-level
76
+ // instance): an outer instance records a recolour of a nested vector on ITS own `overrides`, not on the
77
+ // vector's owner. For a graphic that is not itself an instance, each paint entry on its owner counts
78
+ // as well — the override's target cannot always be tied to the node more precisely than that from the tree.
79
+ // On a STRICT sublayer (`<id>;…`, inside the exported file) any field but an inert one blocks: a `visible`
80
+ // override drops a layer, a size/corner one reshapes it. The graphic's own `visible` is what hid it.
81
+ // A truncated list on any of those instances blocks too: what was cut is unknown.
82
+ function hasPaintOverride(node: IrNode, instances: readonly IrNode[], owner: IrNode | undefined): boolean {
83
+ const id = typeof node.id === "string" ? node.id : "";
84
+ const prefixes = [id + ";", "I" + id + ";"];
85
+ const about = (o: InstanceOverride): boolean => !!o && typeof o.id === "string" && (o.id === id || prefixes.some((p) => o.id.startsWith(p)));
86
+ const sub = (o: InstanceOverride): boolean => !!o && typeof o.id === "string" && o.id !== id && prefixes.some((p) => o.id.startsWith(p));
87
+ const reshapes = (o: InstanceOverride): boolean => Array.isArray(o.fields) && o.fields.some((f) => typeof f === "string" && !INERT_SUBLAYER_FIELD_RE.test(f));
88
+ if (instances.some((inst) => truncated(inst) || overridesOf(inst).some((o) => (about(o) && paintEntry(o)) || (sub(o) && reshapes(o))))) return true;
89
+ if (node.type === "INSTANCE" || !owner) return false;
90
+ return overridesOf(owner).some(paintEntry);
91
+ }
92
+
93
+ const sortedProps = (n: IrNode, booleans: boolean): Array<[string, unknown]> => {
94
+ const props = n.props && typeof n.props === "object" ? Object.entries(n.props) : [];
95
+ return props.filter(([, v]) => booleans || typeof v !== "boolean").sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
96
+ };
97
+
98
+ /** `<componentKey||componentId>|<self|last id segment>|<sorted props JSON>|<chain JSON>[|<modes JSON>]`, or
99
+ * undefined. The props drop booleans unless the node IS the owner; `modes` is the effective
100
+ * `[collection, mode]` list, sorted (collectGraphics), left off when empty. `chain` is the main component (key, else id — which names the variant; `?` when unnamed) of
101
+ * every INSTANCE above the owner, outermost first: `instances` is the root-down list of instances above
102
+ * `node` (the owner included, when it is an ancestor). The whole chain, not only the next instance up: an
103
+ * outer component's definition can recolour the nested icon just as the parent variant's can.
104
+ * Not in the key: WHERE in the parent the owner sits. The full id path would keep a leading and a trailing
105
+ * icon of one button variant apart, but on the real exports it refused every same-screen reuse — list
106
+ * rows inside an instance carry document ids in that path, different per row for the very same icon. */
107
+ export function reuseKeyOf(node: IrNode, owner: IrNode | undefined, instances: readonly IrNode[] = [], modes = ""): string | undefined {
108
+ const mc = owner && owner.mainComponent;
109
+ const comp = mc && (mc.key || mc.id);
110
+ if (!owner || !comp || typeof node.id !== "string") return undefined;
111
+ const at = instances.indexOf(owner);
112
+ const above = at >= 0 ? instances.slice(0, at) : instances.filter((i) => i !== owner);
113
+ const chain = above.map((i) => (i.mainComponent && (i.mainComponent.key || i.mainComponent.id)) || "?");
114
+ return comp + "|" + (node === owner ? "self" : lastSegment(node.id)) + "|" + JSON.stringify(sortedProps(owner, node === owner)) + "|" + JSON.stringify(chain) + (modes ? "|" + modes : "");
115
+ }
116
+
117
+ function ownerRef(inst: IrNode, node: IrNode, blocked: boolean): AssetOwner | undefined {
118
+ const mc = inst.mainComponent;
119
+ if (!mc) return undefined;
120
+ const o: AssetOwner = { component: mc.setName ?? mc.name, instance: inst.id, self: inst === node };
121
+ if (mc.variant) o.variant = mc.variant;
122
+ if (mc.id) o.componentId = mc.id;
123
+ if (mc.key) o.componentKey = mc.key;
124
+ if (blocked) o.paintOverrides = true;
125
+ return o;
126
+ }
127
+
128
+ /** Every graphic node (`asset` string, or `assetSkipped:"hidden"`) in tree order, with its owner and reuse key. */
129
+ export function collectGraphics(roots: readonly IrNode[] | null | undefined): Graphic[] {
130
+ const out: Graphic[] = [];
131
+ // instances: every INSTANCE from the root down (paint-override scope); owners: the ones naming a main component.
132
+ // modes: the effective variable mode per collection (the root's resolvedModes, then every variableModes
133
+ // pinned on the way down) — a bound colour renders per mode, so a Dark section never reuses a Light file.
134
+ const visit = (n: IrNode, instances: readonly IrNode[], owners: readonly IrNode[], hiddenAbove: boolean, modesAbove: ReadonlyMap<string, string>): void => {
135
+ if (!n || typeof n !== "object") return;
136
+ let modes = modesAbove;
137
+ for (const pinned of [n.resolvedModes, n.variableModes]) {
138
+ if (!pinned || typeof pinned !== "object") continue;
139
+ const next = new Map(modes);
140
+ for (const [c, m] of Object.entries(pinned)) if (typeof m === "string") next.set(c, m);
141
+ modes = next;
142
+ }
143
+ const insts = n.type === "INSTANCE" ? [...instances, n] : instances;
144
+ const owns = isOwnerInstance(n) ? [...owners, n] : owners;
145
+ const hidden = hiddenAbove || n.hidden === true;
146
+ if (typeof n.asset === "string" || n.assetSkipped === "hidden") {
147
+ const owner = owns[owns.length - 1];
148
+ const above = owns[owns.length - 2];
149
+ const blocked = hasPaintOverride(n, insts, owner);
150
+ const g: Graphic = { node: n, hidden, blocked };
151
+ const o = owner && ownerRef(owner, n, blocked);
152
+ if (o) g.owner = o;
153
+ const ac = above && above.mainComponent;
154
+ if (ac) g.context = ac.variant ? { component: ac.setName ?? ac.name, variant: ac.variant } : { component: ac.setName ?? ac.name };
155
+ const mk = [...modes].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
156
+ const key = reuseKeyOf(n, owner, insts, mk.length ? JSON.stringify(mk) : "");
157
+ if (key !== undefined) g.reuseKey = key;
158
+ out.push(g);
159
+ }
160
+ // An unrelated screen's JSON on disk may be malformed (`children: {}`): skipped, never a throw.
161
+ if (Array.isArray(n.children)) for (const c of n.children) visit(c, insts, owns, hidden, modes);
162
+ };
163
+ for (const r of roots || []) visit(r, [], [], false, new Map());
164
+ return out;
165
+ }
166
+
167
+ /** pointer (`assets/<name>`) → its uses in tree order. */
168
+ export function usesByPointer(graphics: readonly Graphic[]): Map<string, Graphic[]> {
169
+ const map = new Map<string, Graphic[]>();
170
+ for (const g of graphics) {
171
+ const p = g.node.asset;
172
+ if (typeof p !== "string") continue;
173
+ let list = map.get(p);
174
+ if (!list) { list = []; map.set(p, list); }
175
+ list.push(g);
176
+ }
177
+ return map;
178
+ }
179
+
180
+ // (ii): the transform Figma drew into the file. A visible asset leaf carries it as `sourceTransform`; an older
181
+ // export, and the hidden node before reuse, carry it bare.
182
+ function transformOf(n: IrNode): { rotation: number; flipped: boolean; skew: number } {
183
+ const t = n.sourceTransform;
184
+ return t
185
+ ? { rotation: t.rotation ?? 0, flipped: t.flipped === true, skew: t.skew ?? 0 }
186
+ : { rotation: n.rotation ?? 0, flipped: n.flipped === true, skew: n.skew ?? 0 };
187
+ }
188
+ const BOX_TOL = 0.5;
189
+ const ANGLE_TOL = 0.01;
190
+ function sameLook(hidden: IrNode, twin: IrNode): boolean {
191
+ const a = hidden.box, b = twin.box;
192
+ // `!(d <= tol)`, not `d > tol`: a non-number size (a malformed tree on disk) is NaN and must refuse.
193
+ if (!a || !b || !(Math.abs(a.w - b.w) <= BOX_TOL) || !(Math.abs(a.h - b.h) <= BOX_TOL)) return false;
194
+ const x = transformOf(hidden), y = transformOf(twin);
195
+ return x.flipped === y.flipped && Math.abs(x.rotation - y.rotation) <= ANGLE_TOL && Math.abs(x.skew - y.skew) <= ANGLE_TOL;
196
+ }
197
+
198
+ // A twin's pointer must be the plugin's own shape, `assets/<name>`: a hand-edited or foreign sibling JSON
199
+ // naming `../x.svg` or another dir would hand that path on, and the reusing screen's `.assets.json` would
200
+ // read the file's bytes.
201
+ const ASSET_POINTER_RE = /^assets\/(?!\.\.?$)[^/\\]+$/;
202
+ const twinOk = (g: Graphic): boolean => !g.hidden && !g.blocked && typeof g.node.asset === "string" && ASSET_POINTER_RE.test(g.node.asset) && g.node.assetFrom === undefined && g.reuseKey !== undefined;
203
+
204
+ export interface ReuseOptions {
205
+ /** Trees of OTHER screens that may hold a twin for one of `keys` (writeScreen: from their `.assets.json`
206
+ * `reuseKey`). Called at most once, and only when a candidate is left unmatched in its own tree. */
207
+ otherTrees?: (keys: ReadonlySet<string>) => Iterable<readonly IrNode[]>;
208
+ /** The twin's pointer names a file on disk — a reuse must never create a dangling pointer. */
209
+ pointerExists?: (pointer: string) => boolean;
210
+ }
211
+ export interface ReuseResult {
212
+ /** `assetSkipped:"hidden"` nodes seen */
213
+ hidden: number;
214
+ /** of which now point at a twin's file */
215
+ reused: number;
216
+ /** of which the twin was in another screen */
217
+ crossScreen: number;
218
+ }
219
+
220
+ // The transform move: the reused file already has the twin's transform drawn in, so the bare fields go under
221
+ // `sourceTransform` — a builder that applies `rotation` would otherwise rotate it twice.
222
+ function moveTransform(n: IrNode): void {
223
+ const t: NonNullable<IrNode["sourceTransform"]> = {};
224
+ if (typeof n.rotation === "number") t.rotation = n.rotation;
225
+ if (n.flipped === true) t.flipped = true;
226
+ if (typeof n.skew === "number") t.skew = n.skew;
227
+ delete n.rotation; delete n.flipped; delete n.skew;
228
+ if (Object.keys(t).length) n.sourceTransform = t;
229
+ }
230
+
231
+ /** Point every hidden graphic that has a visible twin at the twin's file. Mutates `roots` in place. */
232
+ export function reuseHiddenAssets(roots: readonly IrNode[] | null | undefined, opts: ReuseOptions = {}): ReuseResult {
233
+ const graphics = collectGraphics(roots);
234
+ const candidates = graphics.filter((g) => g.node.assetSkipped === "hidden");
235
+ const result: ReuseResult = { hidden: candidates.length, reused: 0, crossScreen: 0 };
236
+ if (!candidates.length) return result;
237
+ const exists = opts.pointerExists || (() => true);
238
+ const pool = (list: readonly Graphic[]): Map<string, Graphic[]> => {
239
+ const m = new Map<string, Graphic[]>();
240
+ for (const g of list) {
241
+ if (!twinOk(g) || g.reuseKey === undefined) continue;
242
+ let l = m.get(g.reuseKey);
243
+ if (!l) { l = []; m.set(g.reuseKey, l); }
244
+ l.push(g);
245
+ }
246
+ return m;
247
+ };
248
+ const pick = (c: Graphic, twins: Map<string, Graphic[]>): Graphic | undefined =>
249
+ c.reuseKey === undefined ? undefined : (twins.get(c.reuseKey) || []).find((t) => typeof t.node.asset === "string" && exists(t.node.asset) && sameLook(c.node, t.node));
250
+ const apply = (c: Graphic, t: Graphic): void => {
251
+ const n = c.node, file = t.node.asset;
252
+ if (typeof file !== "string") return; // pick() only returns twins with a pointer
253
+ n.asset = file;
254
+ n.assetFrom = t.node.id;
255
+ delete n.assetSkipped;
256
+ moveTransform(n);
257
+ };
258
+
259
+ const local = pool(graphics);
260
+ const left: Graphic[] = [];
261
+ for (const c of candidates) {
262
+ if (c.blocked || c.reuseKey === undefined) continue;
263
+ const t = pick(c, local);
264
+ if (t) { apply(c, t); result.reused++; } else left.push(c);
265
+ }
266
+ if (left.length && opts.otherTrees) {
267
+ const keys = new Set<string>();
268
+ for (const c of left) if (c.reuseKey !== undefined) keys.add(c.reuseKey);
269
+ const other: Graphic[] = [];
270
+ for (const tree of opts.otherTrees(keys)) {
271
+ try { other.push(...collectGraphics(tree)); } catch { /* a malformed other screen holds no twin */ }
272
+ }
273
+ const far = pool(other);
274
+ for (const c of left) {
275
+ const t = pick(c, far);
276
+ if (t) { apply(c, t); result.reused++; result.crossScreen++; }
277
+ }
278
+ }
279
+ return result;
280
+ }
@@ -0,0 +1,79 @@
1
+ // The one atomic file write: a tmp file beside the target, then a rename over it. Shared by bridge/ and
2
+ // design-to-code/ (the layer may import bridge/src; bridge cannot import the layer).
3
+ //
4
+ // rename(2) replaces the destination atomically on POSIX, so a run killed mid-write (Ctrl-C, OOM) leaves the
5
+ // PREVIOUS file whole instead of a truncated one every later reader fails on. The tmp file sits in the target's
6
+ // own directory so the rename never crosses a filesystem. There is no fsync by default: these files are
7
+ // re-creatable exports, plans and reports, and the guarantee wanted is "never half a file after a killed process",
8
+ // which rename gives; surviving a power cut would cost a sync per write on hundreds of layer files. A file that has
9
+ // to outlive a power cut as well (a migration's journal) passes `sync`. `sync` is fsyncSync (node:fs). On macOS a
10
+ // plain fsync does not flush the drive's write cache (that takes fcntl F_FULLFSYNC); which one Bun issues is unverified.
11
+ import crypto from "node:crypto";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { errCode } from "./errmsg.ts";
15
+
16
+ /** A `.tmp-<pid>-<random>` suffix: unique per call, so two writers (two processes, or two writes in one) never
17
+ * share a tmp name, and a stale tmp from a killed run is never reused. */
18
+ export const tmpSuffix = (): string => `.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
19
+
20
+ // On Windows a rename over a file that another process has open — a reader polling it (verify-screen --wait reads the live
21
+ // status every --interval while the verifier writes it), an antivirus scan or an indexer — is refused
22
+ // (ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION → EPERM, EACCES, EBUSY) until that handle closes, which is a moment: the
23
+ // rename is retried for up to RENAME_RETRY_MS (as graceful-fs does for npm) before the refusal stands. POSIX renames over
24
+ // an open file at once; there the first error is final — as it is for a target that is a directory (a refusal no handle
25
+ // closing will lift).
26
+ const RENAME_RETRY_MS = 2_000;
27
+ const RENAME_TRANSIENT = new Set(["EPERM", "EACCES", "EBUSY"]);
28
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
29
+ /** fs.renameSync(from, to), retried on Windows while the refusal can be another process's open handle (see above). */
30
+ export function renameOver(from: string, to: string): void {
31
+ const t0 = Date.now();
32
+ for (let delay = 5; ; delay = Math.min(delay * 2, 100)) {
33
+ try { fs.renameSync(from, to); return; } catch (e) {
34
+ const code = errCode(e);
35
+ if (process.platform !== "win32" || code === undefined || !RENAME_TRANSIENT.has(code) || Date.now() - t0 >= RENAME_RETRY_MS) throw e;
36
+ let dir = false;
37
+ try { dir = fs.statSync(to).isDirectory(); } catch { /* gone or unreadable: retried */ }
38
+ if (dir) throw e;
39
+ Atomics.wait(sleeper, 0, 0, delay);
40
+ }
41
+ }
42
+ }
43
+
44
+ /** The directory's entries flushed to disk (a rename into it, made durable). Not on Windows, where a directory
45
+ * cannot be opened for it; a filesystem that refuses it keeps the rename as it is. */
46
+ function syncDir(dir: string): void {
47
+ if (process.platform === "win32") return;
48
+ try {
49
+ const fd = fs.openSync(dir, "r");
50
+ try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
51
+ } catch { /* no directory fsync here: the rename stands, only its durability is the filesystem's */ }
52
+ }
53
+
54
+ /** Writes `data` to `file` atomically: a reader sees the old file or the new one, never half of either. The parent
55
+ * directory is created when missing. On any error the tmp file is removed, the old file is left as it was and the
56
+ * error is rethrown. `mode`: the new file's permission bits (a secret passes 0o600) — applied to the tmp file
57
+ * with open(2)'s mode AND chmod, because the umask masks the first. Without `mode` the umask default applies.
58
+ * `sync`: the bytes are fsynced before the rename and the directory after it, so the new file survives a power
59
+ * cut too. */
60
+ export function writeFileAtomic(file: string, data: string | Uint8Array, opts: { mode?: number; sync?: boolean } = {}): void {
61
+ const dir = path.dirname(path.resolve(file));
62
+ fs.mkdirSync(dir, { recursive: true });
63
+ const tmp = file + tmpSuffix();
64
+ try {
65
+ // "wx": exclusive create — never follows a symlink planted at the tmp name
66
+ if (opts.sync) {
67
+ const fd = fs.openSync(tmp, "wx", opts.mode);
68
+ try { fs.writeFileSync(fd, data); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
69
+ } else {
70
+ fs.writeFileSync(tmp, data, { flag: "wx", ...(opts.mode === undefined ? {} : { mode: opts.mode }) });
71
+ }
72
+ if (opts.mode !== undefined) fs.chmodSync(tmp, opts.mode);
73
+ renameOver(tmp, file);
74
+ } catch (e) {
75
+ try { fs.rmSync(tmp, { force: true }); } catch { /* best effort */ }
76
+ throw e;
77
+ }
78
+ if (opts.sync) syncDir(dir);
79
+ }
@@ -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
+ }