cmdzero 0.8.3 → 0.8.5
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/overlay/overlay.js +30 -8
- package/package.json +1 -1
- package/src/resolver.js +18 -11
- package/src/server.js +23 -3
package/overlay/overlay.js
CHANGED
|
@@ -1326,14 +1326,36 @@
|
|
|
1326
1326
|
for (const rec of saved) addTweak(rec, true); // chronological replay → newest ends at the bottom
|
|
1327
1327
|
})();
|
|
1328
1328
|
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1329
|
+
// A restarted daemon leaves the EventSource holding a socket to a process that
|
|
1330
|
+
// is gone: the browser keeps it in readyState OPEN, fires no error and never
|
|
1331
|
+
// reconnects, so alerts stop arriving while the edits behind them still land
|
|
1332
|
+
// on disk. Nothing surfaces that — the tray just quietly stops moving. The
|
|
1333
|
+
// daemon pings every 15s, so silence well past that means the stream is a
|
|
1334
|
+
// zombie; drop it and dial again.
|
|
1335
|
+
const SSE_SILENCE_MS = 45000;
|
|
1336
|
+
let es = null;
|
|
1337
|
+
let lastSeen = Date.now();
|
|
1338
|
+
function connectEvents() {
|
|
1339
|
+
try {
|
|
1340
|
+
if (es) es.close();
|
|
1341
|
+
es = new EventSource(`${ORIGIN}/api/events`);
|
|
1342
|
+
es.onopen = () => { lastSeen = Date.now(); };
|
|
1343
|
+
es.onmessage = (m) => {
|
|
1344
|
+
lastSeen = Date.now();
|
|
1345
|
+
const e = JSON.parse(m.data);
|
|
1346
|
+
if (e.type === 'tweak') addTweak(e);
|
|
1347
|
+
if (e.type === 'totals') showTotals(e.totals);
|
|
1348
|
+
// 'ping' needs no handling — arriving at all is the whole point.
|
|
1349
|
+
};
|
|
1350
|
+
} catch { /* daemon offline */ }
|
|
1351
|
+
}
|
|
1352
|
+
connectEvents();
|
|
1353
|
+
setInterval(() => {
|
|
1354
|
+
if (Date.now() - lastSeen > SSE_SILENCE_MS) {
|
|
1355
|
+
lastSeen = Date.now(); // reset first, so a daemon that is still down doesn't reconnect every tick
|
|
1356
|
+
connectEvents();
|
|
1357
|
+
}
|
|
1358
|
+
}, 5000);
|
|
1337
1359
|
fetch(`${ORIGIN}/api/health`)
|
|
1338
1360
|
.then((r) => r.json())
|
|
1339
1361
|
.then((h) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cmdzero",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"description": "Tweak your UI live in the browser and write the changes straight to source. Copy and Tailwind edits cost zero tokens; everything else routes to a right-sized model via headless claude.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/resolver.js
CHANGED
|
@@ -18,25 +18,32 @@ function walk(node, cb) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* loc format: "<file>:<line 1-based>:<col 0-based>" (matches the babel stamp).
|
|
23
|
+
*
|
|
24
|
+
* The file arrives in one of three shapes, depending on who stamped it: the
|
|
25
|
+
* babel plugin emits a root-relative path, and the @cmdzero/react dev runtime
|
|
26
|
+
* passes on whatever the bundler handed jsxDEV — absolute under webpack, but
|
|
27
|
+
* "[project]/components/Foo.tsx" under Turbopack. Normalize that prefix away
|
|
28
|
+
* here, at the boundary every caller shares: /api/nl parses locs itself and
|
|
29
|
+
* resolves them straight against the root, so normalizing further in (say, in
|
|
30
|
+
* loadTarget) leaves that lane reaching for <root>/[project]/... and dying on
|
|
31
|
+
* ENOENT while the copy and style lanes look fine.
|
|
32
|
+
*/
|
|
22
33
|
export function parseLoc(loc) {
|
|
23
34
|
const m = /^(.*):(\d+):(\d+)$/.exec(loc);
|
|
24
35
|
if (!m) throw new Error(`bad loc: ${loc}`);
|
|
25
|
-
return { file: m[1], line: Number(m[2]), col: Number(m[3]) };
|
|
36
|
+
return { file: m[1].replace(/^\[project\]\//, ''), line: Number(m[2]), col: Number(m[3]) };
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
export function loadTarget(root, loc) {
|
|
29
40
|
const parsed = parseLoc(loc);
|
|
30
41
|
const { line, col } = parsed;
|
|
31
|
-
//
|
|
32
|
-
// 0-based
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
const raw = parsed.file.replace(/^\[project\]\//, '');
|
|
37
|
-
// Resolve before the escape check: the guard has to see a normalized path,
|
|
38
|
-
// or a "foo/../../.." stamp walks straight past a leading-".." test.
|
|
39
|
-
const file = path.relative(root, path.resolve(root, raw));
|
|
42
|
+
// parseLoc has already normalized the path's shape; columns still come in two
|
|
43
|
+
// conventions (babel 0-based, the dev runtime 1-based) and are matched below.
|
|
44
|
+
// Resolve before the escape check: the guard has to see a normalized path, or
|
|
45
|
+
// a "foo/../../.." stamp walks straight past a leading-".." test.
|
|
46
|
+
const file = path.relative(root, path.resolve(root, parsed.file));
|
|
40
47
|
if (file.startsWith('..') || path.isAbsolute(file)) {
|
|
41
48
|
throw new Error('file outside root');
|
|
42
49
|
}
|
package/src/server.js
CHANGED
|
@@ -28,11 +28,18 @@ function detectTailwind(root) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function startServer({ root, port = 4100 }) {
|
|
31
|
+
export function startServer({ root, port = 4100, heartbeatMs = 15000 }) {
|
|
32
32
|
const undoStack = new Map(); // id -> { abs, before }
|
|
33
33
|
const running = new Map(); // id -> child process (for cancellation)
|
|
34
34
|
const sseClients = new Set();
|
|
35
|
-
|
|
35
|
+
// Seeded from the clock, not 1. The overlay persists its alert history in
|
|
36
|
+
// localStorage and keys each row by tweak id, and that history outlives the
|
|
37
|
+
// daemon — so a counter starting over at 1 hands a fresh tweak the id of a row
|
|
38
|
+
// hydrated from an earlier session. addTweak() updates a known id in place, so
|
|
39
|
+
// the newest alert would silently overwrite that old row wherever it sits
|
|
40
|
+
// instead of appending to the bottom of the tray. Still ascending within a
|
|
41
|
+
// session; just never reissued across a restart.
|
|
42
|
+
let nextId = Date.now();
|
|
36
43
|
const tailwind = detectTailwind(root);
|
|
37
44
|
const telemetry = initTelemetry({ version: VERSION, tailwind });
|
|
38
45
|
|
|
@@ -98,7 +105,20 @@ export function startServer({ root, port = 4100 }) {
|
|
|
98
105
|
});
|
|
99
106
|
res.write('\n');
|
|
100
107
|
sseClients.add(res);
|
|
101
|
-
|
|
108
|
+
// Idle streams have to say something. A browser can't tell a quiet
|
|
109
|
+
// daemon from a dead one: the EventSource stays in readyState OPEN,
|
|
110
|
+
// never fires error and never reconnects, so every alert after a
|
|
111
|
+
// restart is dropped while the writes still land on disk. A ping the
|
|
112
|
+
// client can actually see (a comment would not reach onmessage) lets it
|
|
113
|
+
// notice the silence and reconnect.
|
|
114
|
+
const beat = setInterval(() => {
|
|
115
|
+
res.write(`data: ${JSON.stringify({ type: 'ping' })}\n\n`);
|
|
116
|
+
}, heartbeatMs);
|
|
117
|
+
beat.unref?.(); // never hold the process open for a heartbeat
|
|
118
|
+
req.on('close', () => {
|
|
119
|
+
clearInterval(beat);
|
|
120
|
+
sseClients.delete(res);
|
|
121
|
+
});
|
|
102
122
|
return;
|
|
103
123
|
}
|
|
104
124
|
|