@design-twin/dtwin 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +959 -2
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +53 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +942 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- package/src/write-out.ts +1255 -0
package/src/file-lock.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// One writer at a time for a shared file (a run's live status, a plan): an exclusive lock file, held only for a
|
|
2
|
+
// short synchronous read-check-write. Shared by bridge/ and design-to-code/ (the layer may import bridge/src; bridge
|
|
3
|
+
// cannot import the layer).
|
|
4
|
+
//
|
|
5
|
+
// The lock (`<file>.lock` beside it, or a path the caller names) is created with open(2)'s O_CREAT|O_EXCL (fs flag
|
|
6
|
+
// "wx": "fails if the path exists"; on Windows the same flags map to CreateFileW's CREATE_NEW), so exactly one process
|
|
7
|
+
// creates it. It holds {pid, host, at, token} (+ ns on Linux: the holder's pid namespace), and the creator holds it
|
|
8
|
+
// only once it reads those bytes back after writing them: a lock judged dead while still empty (the instant between
|
|
9
|
+
// the create and the write) and removed is never taken for its own. A waiter polls every POLL_MS
|
|
10
|
+
// with Atomics.wait on a private SharedArrayBuffer (a synchronous sleep: these writes are synchronous, and the runtime lets
|
|
11
|
+
// the main thread block) and gives up after `waitMs` with LockBusy. Every turn of that loop that takes nothing sleeps
|
|
12
|
+
// and counts against `waitMs` — only a turn that removed a dead holder's lock tries again at once.
|
|
13
|
+
//
|
|
14
|
+
// When a lock is a dead holder's:
|
|
15
|
+
// - same machine (host, and on Linux the pid namespace): its pid no longer exists — process.kill(pid, 0) throws
|
|
16
|
+
// for a pid that does not exist and is "a platform independent way to test for the existence of a process";
|
|
17
|
+
// EPERM means it exists under another user. A live holder is waited for however long it holds, up to
|
|
18
|
+
// LIVE_STALE_MS (a dead holder's pid taken by an unrelated process);
|
|
19
|
+
// - another machine (its pid means nothing here): older than `staleMs`;
|
|
20
|
+
// - no readable holder (killed between the create and the write, which are microseconds apart): the file's mtime
|
|
21
|
+
// older than UNWRITTEN_STALE_MS.
|
|
22
|
+
// Breaking one: waiters that judged the same lock dead take turns under a second exclusive file, `<lock>.break`,
|
|
23
|
+
// claimed the same way as the lock (the breaker's stamp, read back); under it the lock is read and judged again and
|
|
24
|
+
// removed only when its bytes are still the ones judged dead, it is still dead (an empty lock's mtime is new again
|
|
25
|
+
// when another process has just created it) and the guard is still this breaker's. Nothing can take the lock while
|
|
26
|
+
// those bytes are there, and only a breaker removes them, so no breaker removes a lock another waiter has taken since.
|
|
27
|
+
// A breaker killed inside its turn leaves its guard, which is judged like a lock: cleared when its breaker is dead
|
|
28
|
+
// (same machine), older than `staleMs` (another host) or never written past UNWRITTEN_STALE_MS — never for its age
|
|
29
|
+
// alone while its breaker lives, so a breaker that is merely descheduled (stopped, swapped out, a sleeping laptop)
|
|
30
|
+
// keeps its turn. A holder whose lock was nevertheless taken (it stalled past LIVE_STALE_MS, or a holder on another
|
|
31
|
+
// host past `staleMs`) finds out with stillMine() before its write; its release never removes the new holder's lock.
|
|
32
|
+
// Not re-entrant: a nested lock of the same file in one process waits for itself until LockBusy. The exclusive
|
|
33
|
+
// flag might not work on a network filesystem (Node's fs docs) — there the lock narrows the window, not closes it.
|
|
34
|
+
import crypto from "node:crypto";
|
|
35
|
+
import fs from "node:fs";
|
|
36
|
+
import os from "node:os";
|
|
37
|
+
import path from "node:path";
|
|
38
|
+
import { errCode } from "./errmsg.ts";
|
|
39
|
+
|
|
40
|
+
export interface FileLockOpts {
|
|
41
|
+
/** a lock from another host older than this is a dead holder's (default 30 s — every locked section is a few small
|
|
42
|
+
* file writes) */
|
|
43
|
+
staleMs?: number;
|
|
44
|
+
/** how long to wait for a live holder before LockBusy (default 10 s) */
|
|
45
|
+
waitMs?: number;
|
|
46
|
+
}
|
|
47
|
+
const STALE_MS = 30_000, WAIT_MS = 10_000, POLL_MS = 25;
|
|
48
|
+
/** A same-machine holder whose pid is alive is waited for up to this age; past it the pid is taken for a reused one
|
|
49
|
+
* (Windows reuses pids soon). A locked section is a few small file writes and a stalled holder is caught by
|
|
50
|
+
* assertMine before its write, so minutes is ample. */
|
|
51
|
+
const LIVE_STALE_MS = 2 * 60_000;
|
|
52
|
+
/** A lock or guard with no readable holder past this age was left by a process killed between its create and its write. */
|
|
53
|
+
const UNWRITTEN_STALE_MS = 2_000;
|
|
54
|
+
/** How long a run of Windows create refusals that can be transient (a lock still delete-pending, an antivirus scan)
|
|
55
|
+
* is retried, counted from the first refusal of the run. */
|
|
56
|
+
const TRANSIENT_MS = 2_000;
|
|
57
|
+
const TRANSIENT_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
58
|
+
|
|
59
|
+
/** The lock of `file`: `<file>.lock` beside it. */
|
|
60
|
+
export const lockFileOf = (file: string): string => file + ".lock";
|
|
61
|
+
/** The guard breakers of `lockFile` take turns under. */
|
|
62
|
+
export const breakGuardOf = (lockFile: string): string => lockFile + ".break";
|
|
63
|
+
|
|
64
|
+
/** The lock stayed held by a live holder for the whole wait — or was taken from this holder (LockLost). */
|
|
65
|
+
export class LockBusy extends Error {
|
|
66
|
+
readonly lockFile: string;
|
|
67
|
+
constructor(lockFile: string, message: string) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = "LockBusy";
|
|
70
|
+
this.lockFile = lockFile;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** The lock was broken and taken by another writer while this one held it (it held past the stale age). */
|
|
74
|
+
export class LockLost extends LockBusy {
|
|
75
|
+
constructor(lockFile: string) {
|
|
76
|
+
super(lockFile, `${lockFile} was taken over by another writer while this one held it — nothing more written; retry`);
|
|
77
|
+
this.name = "LockLost";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A taken lock. */
|
|
82
|
+
export interface HeldLock {
|
|
83
|
+
/** remove the lock — only while it is still this holder's */
|
|
84
|
+
release(): void;
|
|
85
|
+
/** whether the lock on disk is still this holder's */
|
|
86
|
+
stillMine(): boolean;
|
|
87
|
+
/** throws LockLost when it is not — called right before a write the lock guards */
|
|
88
|
+
assertMine(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The pid namespace of this process on Linux (`pid:[<inode>]`), "" elsewhere: two processes with the same hostname
|
|
92
|
+
* in different namespaces (a container on the host's network) cannot see each other's pids. */
|
|
93
|
+
export const PID_NS = ((): string => {
|
|
94
|
+
if (process.platform !== "linux") return "";
|
|
95
|
+
try { return fs.readlinkSync("/proc/self/ns/pid"); } catch { return ""; }
|
|
96
|
+
})();
|
|
97
|
+
|
|
98
|
+
interface Holder { pid: number; host: string; at: number; token: string; ns: string }
|
|
99
|
+
function holderOf(text: string): Holder | null {
|
|
100
|
+
try {
|
|
101
|
+
const v: unknown = JSON.parse(text);
|
|
102
|
+
if (v === null || typeof v !== "object" || !("pid" in v) || !("host" in v) || !("at" in v) || !("token" in v)) return null;
|
|
103
|
+
const { pid, host, at, token } = v;
|
|
104
|
+
const ns = "ns" in v ? v.ns : "";
|
|
105
|
+
// a pid of 0 or below names a process GROUP to process.kill — never probed
|
|
106
|
+
return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && typeof host === "string" && typeof at === "number" && typeof token === "string" && typeof ns === "string"
|
|
107
|
+
? { pid, host, at, token, ns } : null;
|
|
108
|
+
} catch { return null; }
|
|
109
|
+
}
|
|
110
|
+
/** process.kill(pid, 0): no throw → it exists; EPERM → it exists, owned by another user; anything else (ESRCH) → gone. */
|
|
111
|
+
function pidAlive(pid: number): boolean {
|
|
112
|
+
try { process.kill(pid, 0); return true; } catch (e) { return errCode(e) === "EPERM"; }
|
|
113
|
+
}
|
|
114
|
+
/** Whether this process can probe the holder's pid: same host, same pid namespace. */
|
|
115
|
+
const sameMachine = (h: Pick<Holder, "host" | "ns">): boolean => h.host === os.hostname() && h.ns === PID_NS;
|
|
116
|
+
|
|
117
|
+
/** A process as a lock stamp names it: pid, host and (on Linux) its pid namespace. */
|
|
118
|
+
export interface ProcessStamp { pid: number; host: string; ns?: string }
|
|
119
|
+
/** This process's stamp — for a record that outlives a lock and must say whether its writer still runs (a probe's own run). */
|
|
120
|
+
export const thisProcess = (): ProcessStamp => ({ pid: process.pid, host: os.hostname(), ...(PID_NS ? { ns: PID_NS } : {}) });
|
|
121
|
+
/** The process a stamp names, judged as a lock holder is: "gone" (same machine, the pid no longer exists), "alive" (same
|
|
122
|
+
* machine, the pid exists — or is reused), "unknown" (another host or pid namespace, or no usable pid: nothing to probe). */
|
|
123
|
+
export function processState(p: ProcessStamp): "gone" | "alive" | "unknown" {
|
|
124
|
+
if (!Number.isInteger(p.pid) || p.pid <= 0 || !sameMachine({ host: p.host, ns: p.ns ?? "" })) return "unknown";
|
|
125
|
+
return pidAlive(p.pid) ? "alive" : "gone";
|
|
126
|
+
}
|
|
127
|
+
const read = (file: string): string | null => { try { return fs.readFileSync(file, "utf8"); } catch { return null; } };
|
|
128
|
+
/** How far `t` (ms since the epoch) is from now, either way: a clock that jumped back must not keep a dead lock forever. */
|
|
129
|
+
const ageOf = (t: number): number => Math.abs(Date.now() - t);
|
|
130
|
+
const describeHolder = (text: string | null): string => {
|
|
131
|
+
const h = text === null ? null : holderOf(text);
|
|
132
|
+
return h ? `process ${h.pid}${h.host !== os.hostname() ? ` on ${h.host}` : ""} since ${new Date(h.at).toISOString()}` : "another process";
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Whether the lock or break guard whose bytes are `text` is a dead holder's (see the header). */
|
|
136
|
+
function isStale(file: string, text: string, staleMs: number): boolean {
|
|
137
|
+
const h = holderOf(text);
|
|
138
|
+
if (h === null) {
|
|
139
|
+
try { return ageOf(fs.statSync(file).mtimeMs) > Math.min(staleMs, UNWRITTEN_STALE_MS); } catch { return false; } // gone: the next open takes it
|
|
140
|
+
}
|
|
141
|
+
const state = processState(h);
|
|
142
|
+
if (state !== "unknown") return state === "gone" || ageOf(h.at) > Math.max(staleMs, LIVE_STALE_MS);
|
|
143
|
+
return ageOf(h.at) > staleMs;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** This process's stamp for a lock or guard it creates. `at` is the moment it is written: the age a waiter compares. */
|
|
147
|
+
const stampOf = (token: string): string => JSON.stringify({ ...thisProcess(), at: Date.now(), token });
|
|
148
|
+
/**
|
|
149
|
+
* Create `file` exclusively and write this process's stamp into it. Returns the stamp when the file on disk holds it
|
|
150
|
+
* once written, null when it does not: the file was judged a dead holder's while still empty and removed (another
|
|
151
|
+
* process may have created it again since) — then it is not this process's and nothing is removed. Throws what the
|
|
152
|
+
* create throws (EEXIST: someone else's), and what the write throws (the file removed first: never one naming no one).
|
|
153
|
+
*/
|
|
154
|
+
function claim(file: string, token: string): string | null {
|
|
155
|
+
const fd = fs.openSync(file, "wx");
|
|
156
|
+
const mine = stampOf(token);
|
|
157
|
+
try { fs.writeSync(fd, mine); } catch (e) {
|
|
158
|
+
fs.closeSync(fd);
|
|
159
|
+
fs.rmSync(file, { force: true });
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
fs.closeSync(fd);
|
|
163
|
+
return read(file) === mine ? mine : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Remove the dead holder's lock `lockFile` whose bytes were `seen` — under its break guard, and only when the bytes are
|
|
168
|
+
* still `seen` then, still a dead holder's, and the guard still this breaker's. True when it removed it; false when the
|
|
169
|
+
* guard is another breaker's (a dead breaker's is cleared for the next turn) or the lock changed since (released, or
|
|
170
|
+
* taken and released again, or an empty one just created again). Throws what creating the guard or removing the lock
|
|
171
|
+
* throws (EACCES in a directory this process may not write). Exported for its test.
|
|
172
|
+
*/
|
|
173
|
+
export function breakStale(lockFile: string, seen: string, staleMs: number = STALE_MS): boolean {
|
|
174
|
+
const guard = breakGuardOf(lockFile);
|
|
175
|
+
let mine: string | null;
|
|
176
|
+
try { mine = claim(guard, crypto.randomBytes(8).toString("hex")); } catch (e) {
|
|
177
|
+
if (errCode(e) !== "EEXIST") throw e;
|
|
178
|
+
// another breaker's turn: cleared only when that breaker is dead, and only while the guard still holds the bytes
|
|
179
|
+
// judged dead (a guard cleared and claimed again by a live breaker meanwhile is left)
|
|
180
|
+
const g = read(guard);
|
|
181
|
+
if (g !== null && isStale(guard, g, staleMs) && read(guard) === g) try { fs.unlinkSync(guard); } catch { /* gone, or cleared by another waiter */ }
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
if (mine === null) return false; // the guard was cleared while still empty: not this breaker's turn
|
|
185
|
+
try {
|
|
186
|
+
if (read(lockFile) !== seen || !isStale(lockFile, seen, staleMs)) return false;
|
|
187
|
+
// the guard is judged like a lock, so a breaker stalled past it may have lost its turn: then another breaker can
|
|
188
|
+
// be inside its own, and this one leaves the lock to it
|
|
189
|
+
if (read(guard) !== mine) return false;
|
|
190
|
+
try { fs.unlinkSync(lockFile); } catch (e) { if (errCode(e) === "ENOENT") return false; throw e; }
|
|
191
|
+
return true;
|
|
192
|
+
} finally {
|
|
193
|
+
// only this breaker's own guard: one cleared and claimed by another breaker is theirs
|
|
194
|
+
if (read(guard) === mine) try { fs.rmSync(guard, { force: true }); } catch { /* left: cleared once its breaker is gone */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Whether `code`, refusing a create, can be transient: on Windows a file removed while another process still has it
|
|
199
|
+
* open stays delete-pending until that handle closes, and creating it again meanwhile is refused
|
|
200
|
+
* (ERROR_ACCESS_DENIED → EPERM); an antivirus scan can do the same. */
|
|
201
|
+
const maybeTransient = (code: string | undefined): boolean => process.platform === "win32" && code !== undefined && TRANSIENT_CODES.has(code);
|
|
202
|
+
|
|
203
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
204
|
+
const sleepSync = (ms: number): void => { Atomics.wait(sleeper, 0, 0, ms); };
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Take the lock `lockFile` (its directory is created when missing). Throws LockBusy when a live holder keeps it for
|
|
208
|
+
* `waitMs`; when the lock was a dead holder's that could not be removed for the whole wait, what removing it threw
|
|
209
|
+
* (EACCES in a read-only directory…); any other error creating it (EACCES, EROFS…) as it is — on Windows a refusal
|
|
210
|
+
* that can be transient only once a run of them has lasted TRANSIENT_MS (a wait that ends sooner is LockBusy).
|
|
211
|
+
*/
|
|
212
|
+
export function acquireFileLock(lockFile: string, opts: FileLockOpts = {}): HeldLock {
|
|
213
|
+
const staleMs = opts.staleMs ?? STALE_MS, waitMs = opts.waitMs ?? WAIT_MS;
|
|
214
|
+
const token = crypto.randomBytes(8).toString("hex");
|
|
215
|
+
fs.mkdirSync(path.dirname(path.resolve(lockFile)), { recursive: true });
|
|
216
|
+
const t0 = Date.now();
|
|
217
|
+
/** when the current run of possibly transient refusals (creating the lock or its break guard) began */
|
|
218
|
+
let firstTransientAt: number | null = null;
|
|
219
|
+
/** true while a possibly transient refusal has not yet lasted TRANSIENT_MS: retried like a held lock */
|
|
220
|
+
const transientYet = (e: unknown): boolean => {
|
|
221
|
+
if (!maybeTransient(errCode(e))) return false;
|
|
222
|
+
firstTransientAt ??= Date.now();
|
|
223
|
+
return Date.now() - firstTransientAt < TRANSIENT_MS;
|
|
224
|
+
};
|
|
225
|
+
for (;;) {
|
|
226
|
+
let mine: string | null = null, text: string | null = null, transient = false;
|
|
227
|
+
/** why this turn took nothing other than a live holder: thrown instead of LockBusy when the wait runs out */
|
|
228
|
+
let refused: unknown = null;
|
|
229
|
+
try { mine = claim(lockFile, token); } catch (e) {
|
|
230
|
+
const code = errCode(e);
|
|
231
|
+
if (code === "EEXIST") {
|
|
232
|
+
text = read(lockFile); // null: released since, or unreadable — waited on like a live holder
|
|
233
|
+
if (text !== null && isStale(lockFile, text, staleMs)) {
|
|
234
|
+
try { if (breakStale(lockFile, text, staleMs)) continue; } catch (be) {
|
|
235
|
+
if (transientYet(be)) transient = true;
|
|
236
|
+
else refused = be;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} else if (transientYet(e)) transient = true;
|
|
240
|
+
else throw e;
|
|
241
|
+
}
|
|
242
|
+
if (!transient) firstTransientAt = null;
|
|
243
|
+
if (mine !== null) {
|
|
244
|
+
const held = mine;
|
|
245
|
+
const stillMine = (): boolean => read(lockFile) === held;
|
|
246
|
+
return {
|
|
247
|
+
// only our own lock: one broken as stale and taken by another holder is theirs now
|
|
248
|
+
release: () => { if (stillMine()) try { fs.rmSync(lockFile, { force: true }); } catch { /* left: stale once this process is gone */ } },
|
|
249
|
+
stillMine,
|
|
250
|
+
assertMine: () => { if (!stillMine()) throw new LockLost(lockFile); },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const waited = Date.now() - t0;
|
|
254
|
+
if (waited >= waitMs) {
|
|
255
|
+
if (refused !== null) throw refused;
|
|
256
|
+
if (transient) throw new LockBusy(lockFile, `${lockFile} could not be created for ${Math.round((Date.now() - (firstTransientAt ?? t0)) / 100) / 10}s (a lock just released is still being removed, or a scan holds it); retry`);
|
|
257
|
+
throw new LockBusy(lockFile, `${lockFile} is held by ${describeHolder(text)} — another write was still in progress after ${Math.round(waited / 100) / 10}s; retry once it is done`);
|
|
258
|
+
}
|
|
259
|
+
sleepSync(Math.min(POLL_MS, waitMs - waited));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Run `fn` (synchronous) holding the lock `lockFile`; the lock is released however `fn` ends. `fn` gets the lock, to
|
|
264
|
+
* check it is still its own right before the write it guards. */
|
|
265
|
+
export function withFileLock<T>(lockFile: string, fn: (lock: HeldLock) => T, opts: FileLockOpts = {}): T {
|
|
266
|
+
const lock = acquireFileLock(lockFile, opts);
|
|
267
|
+
try { return fn(lock); } finally { lock.release(); }
|
|
268
|
+
}
|
package/src/file-memo.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// file-memo.ts — a value computed from a file's contents, remembered across pulls in one process while the file
|
|
2
|
+
// is provably unchanged. A process that pulls many screens (the daemon, the MCP server, a multi-screen CLI run)
|
|
3
|
+
// would otherwise re-read every file a pull consults on every pull: the shared assets/ directory
|
|
4
|
+
// (asset-compare.ts FileKeyCache) and every other screen's .assets.json (write-out.ts otherScreenTrees). The
|
|
5
|
+
// memo lives in memory only: a sidecar file in the export would have to be trusted across processes and dtwin
|
|
6
|
+
// versions (a changed rule would silently keep old values), and the one-shot CLI pulls few screens per process.
|
|
7
|
+
//
|
|
8
|
+
// A remembered value is used only while the file's identity and status are unchanged: size, mtime, ctime, inode
|
|
9
|
+
// and device all equal to what they were when the value was computed. ctime is set by the kernel on every write,
|
|
10
|
+
// rename-over or utimes call and cannot be set back by a user, so a file rewritten with the same size and a
|
|
11
|
+
// preserved or restored mtime still misses; a file replaced by rename (atomic-write.ts) has a new inode as well.
|
|
12
|
+
// Timestamp precision is platform specific (whole seconds on some file systems, coarse ctime steps on Windows),
|
|
13
|
+
// so a rewrite within the same tick could keep every field equal; a value is therefore stored only for a file
|
|
14
|
+
// whose mtime and ctime are both at least `recentMs` (default 2 s) in the past — a file still being written to,
|
|
15
|
+
// or written in the last tick, is always read again. The stat is read as bigint: a JavaScript number holds
|
|
16
|
+
// integers exactly only up to 2^53, and inode numbers above that (NTFS file ids, some NFS and exFAT volumes) would
|
|
17
|
+
// round, so two different files could compare as one; nanosecond times come with it.
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
|
|
20
|
+
interface Entry<V> { size: bigint; mtimeNs: bigint; ctimeNs: bigint; ino: bigint; dev: bigint; value: V }
|
|
21
|
+
export interface FileMemoOptions { recentMs?: number; maxEntries?: number; now?: () => number }
|
|
22
|
+
|
|
23
|
+
export class FileMemo<V> {
|
|
24
|
+
private readonly entries = new Map<string, Entry<V>>();
|
|
25
|
+
private readonly recentMs: number;
|
|
26
|
+
private readonly maxEntries: number;
|
|
27
|
+
private readonly now: () => number;
|
|
28
|
+
constructor(o: FileMemoOptions = {}) {
|
|
29
|
+
this.recentMs = o.recentMs ?? 2000;
|
|
30
|
+
this.maxEntries = o.maxEntries ?? 50_000;
|
|
31
|
+
this.now = o.now ?? Date.now;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get size(): number { return this.entries.size; }
|
|
35
|
+
clear(): void { this.entries.clear(); }
|
|
36
|
+
|
|
37
|
+
/** `compute()` for `file` — it reads the file itself — or the value it gave while the file is unchanged.
|
|
38
|
+
* undefined when `file` cannot be stat'ed (compute is not called) or when compute returns undefined (nothing is
|
|
39
|
+
* stored). A path that is not a regular file is computed every time and never stored. */
|
|
40
|
+
get(file: string, compute: () => V | undefined): V | undefined {
|
|
41
|
+
// The clock is read before the stat, so the window is measured from no later than the moment the file was seen.
|
|
42
|
+
const now = this.now();
|
|
43
|
+
let st: fs.BigIntStats;
|
|
44
|
+
try { st = fs.statSync(file, { bigint: true }); } catch { return undefined; }
|
|
45
|
+
if (!st.isFile()) return compute();
|
|
46
|
+
const hit = this.entries.get(file);
|
|
47
|
+
if (hit && hit.size === st.size && hit.mtimeNs === st.mtimeNs && hit.ctimeNs === st.ctimeNs && hit.ino === st.ino && hit.dev === st.dev) {
|
|
48
|
+
this.entries.delete(file); this.entries.set(file, hit); // most recently used last
|
|
49
|
+
return hit.value;
|
|
50
|
+
}
|
|
51
|
+
this.entries.delete(file);
|
|
52
|
+
const value = compute();
|
|
53
|
+
if (value === undefined) return undefined;
|
|
54
|
+
// `st` is from before the read: a change between the two moves ctime past it, so the next stat misses.
|
|
55
|
+
const changedNs = st.mtimeNs > st.ctimeNs ? st.mtimeNs : st.ctimeNs;
|
|
56
|
+
if (changedNs <= BigInt(Math.floor(now - this.recentMs)) * 1_000_000n) {
|
|
57
|
+
this.entries.set(file, { size: st.size, mtimeNs: st.mtimeNs, ctimeNs: st.ctimeNs, ino: st.ino, dev: st.dev, value });
|
|
58
|
+
for (const oldest of this.entries.keys()) { if (this.entries.size <= this.maxEntries) break; this.entries.delete(oldest); }
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Hex digests, shared by bridge/ and design-to-code/ (the layer may import bridge/src; bridge cannot import
|
|
2
|
+
// the layer). sha1Hex, the asset-content hash, lives with the asset comparison in asset-compare.ts.
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
/** sha256 of a string (hashed as UTF-8) or of bytes, as lowercase hex. */
|
|
6
|
+
export const sha256Hex = (data: string | Uint8Array): string => crypto.createHash("sha256").update(data).digest("hex");
|
package/src/hex-color.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The hex colour a figma_write op carries (`fill`, `color`): validated at both ends — the MCP
|
|
2
|
+
// schema (figma-mcp.ts) and the plugin's write executor (figma-plugin/src/writes.ts) — so a value that is not a
|
|
3
|
+
// colour is refused instead of being painted as a wrong one (a lenient parse turns "red" into #rreedd
|
|
4
|
+
// and every unparseable channel into 0). Also the one hex FORMATTER (formatHex), used by the plugin for every
|
|
5
|
+
// exported colour and by design-to-code/color.ts. Dependency-free: the plugin bundle imports it too, and
|
|
6
|
+
// design-to-code imports it from here (bridge/src never imports design-to-code).
|
|
7
|
+
|
|
8
|
+
/** `#` then 3, 4, 6 or 8 hex digits (#rgb, #rgba, #rrggbb, #rrggbbaa). The `#` is required: "bad" or
|
|
9
|
+
* "fade" are words as often as colours. */
|
|
10
|
+
export const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
11
|
+
|
|
12
|
+
/** What an error says the value should look like. */
|
|
13
|
+
export const HEX_COLOR_HINT = "a hex colour like #1A2B3C (#rgb, #rgba, #rrggbb or #rrggbbaa)";
|
|
14
|
+
|
|
15
|
+
/** Channels 0..1; `a` is present only when the value carried an alpha digit pair. */
|
|
16
|
+
export interface HexColor { r: number; g: number; b: number; a?: number }
|
|
17
|
+
|
|
18
|
+
/** Parse a hex colour (see HEX_COLOR_RE), or null when the value is anything else. Shorthand doubles each digit. */
|
|
19
|
+
export function parseHexColor(value: unknown): HexColor | null {
|
|
20
|
+
if (typeof value !== "string" || !HEX_COLOR_RE.test(value)) return null;
|
|
21
|
+
let h = value.slice(1);
|
|
22
|
+
if (h.length <= 4) h = h.split("").map((c) => c + c).join("");
|
|
23
|
+
const chan = (i: number): number => parseInt(h.slice(i, i + 2), 16) / 255;
|
|
24
|
+
const c: HexColor = { r: chan(0), g: chan(2), b: chan(4) };
|
|
25
|
+
if (h.length === 8) c.a = chan(6);
|
|
26
|
+
return c;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 0–255 channels plus alpha 0–1 — the shape a colour is formatted from. */
|
|
30
|
+
export interface Rgba { r: number; g: number; b: number; a: number }
|
|
31
|
+
|
|
32
|
+
/** An Rgba as "#rrggbb" (alpha rounds to 255) or "#rrggbbaa", lowercase — channels clamped and rounded to
|
|
33
|
+
* 0–255. The one hex formatter: the plugin writes every exported colour with it, design-to-code (color.ts)
|
|
34
|
+
* every colour it computes, so an opaque colour is always six digits whoever wrote it. */
|
|
35
|
+
export function formatHex(c: Rgba): string {
|
|
36
|
+
const to = (x: number): string => Math.round(Math.min(255, Math.max(0, x))).toString(16).padStart(2, "0");
|
|
37
|
+
const a = Math.round(c.a * 255);
|
|
38
|
+
return "#" + to(c.r) + to(c.g) + to(c.b) + (a < 255 ? to(a) : "");
|
|
39
|
+
}
|
package/src/human-ms.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// human-ms.ts — a duration for a log line. A leaf (no imports), so the daemon's served session can say how long it
|
|
2
|
+
// idled without importing settings.ts, which imports the daemon module.
|
|
3
|
+
|
|
4
|
+
/** An idle window for a log line: whole minutes from one minute up, else whole seconds. */
|
|
5
|
+
export function humanMs(ms: number): string {
|
|
6
|
+
return ms >= 60000 ? `${Math.round(ms / 60000)} min` : `${Math.round(ms / 1000)}s`;
|
|
7
|
+
}
|