claude-simple 0.2.17 → 0.2.18
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/client.js +104 -7
- package/dist/{app.0.2.17.min.js → app.0.2.18.min.js} +71 -71
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/public/chat.js +33 -0
- package/public/index.html +2 -2
- package/public/sw.js +3 -3
- /package/dist/{style.0.2.17.min.css → style.0.2.18.min.css} +0 -0
package/client.js
CHANGED
|
@@ -4,16 +4,97 @@ import { startAutoUpdate } from './src/auto-update.js';
|
|
|
4
4
|
import { WebSocket } from 'ws';
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import crypto from 'crypto';
|
|
7
|
+
import { promises as fs } from 'fs';
|
|
8
|
+
import path from 'path';
|
|
7
9
|
|
|
8
10
|
const GATEWAY_URL = process.env.GATEWAY_URL; // e.g. wss://your-server.com/machine-connect
|
|
9
11
|
const MACHINE_ID = process.env.MACHINE_ID || os.hostname();
|
|
10
12
|
const MACHINE_TOKEN = process.env.MACHINE_TOKEN; // shared secret with gateway
|
|
11
13
|
const LOCAL_PORT = parseInt(process.env.LOCAL_PORT || '13000');
|
|
14
|
+
const SYNC_DIR = process.env.EDGE_SYNC_DIR || path.join(os.homedir(), '.claude-simple-edge');
|
|
15
|
+
const SYNC_FILE = path.join(SYNC_DIR, 'hub-stream-outbox.json');
|
|
12
16
|
|
|
13
17
|
if (!GATEWAY_URL) { console.error('GATEWAY_URL is required'); process.exit(1); }
|
|
14
18
|
if (!MACHINE_TOKEN) { console.error('MACHINE_TOKEN is required'); process.exit(1); }
|
|
15
19
|
|
|
20
|
+
// Hub ACKed stream outbox. One sequence represents one already-batched UI
|
|
21
|
+
// frame, not an individual token, keeping both radio traffic and local disk
|
|
22
|
+
// use bounded.
|
|
23
|
+
let syncState = { version: 1, next: {}, pending: [] };
|
|
24
|
+
let syncWrite = Promise.resolve();
|
|
25
|
+
let syncSent = new Set();
|
|
26
|
+
|
|
27
|
+
async function loadSyncState() {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(await fs.readFile(SYNC_FILE, 'utf8'));
|
|
30
|
+
if (parsed && typeof parsed === 'object') {
|
|
31
|
+
syncState = { version: 1, next: parsed.next || {}, pending: Array.isArray(parsed.pending) ? parsed.pending.slice(-20000) : [] };
|
|
32
|
+
}
|
|
33
|
+
} catch { /* first boot */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function persistSyncState() {
|
|
37
|
+
const snapshot = JSON.stringify(syncState);
|
|
38
|
+
syncWrite = syncWrite.then(async () => {
|
|
39
|
+
await fs.mkdir(SYNC_DIR, { recursive: true, mode: 0o700 });
|
|
40
|
+
await fs.writeFile(`${SYNC_FILE}.tmp`, snapshot, { mode: 0o600 });
|
|
41
|
+
await fs.rename(`${SYNC_FILE}.tmp`, SYNC_FILE);
|
|
42
|
+
}).catch(e => console.error('[sync] persist failed:', e.message));
|
|
43
|
+
return syncWrite;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function syncKey(sessionId, agent) { return `${MACHINE_ID}:${agent}:${sessionId}`; }
|
|
47
|
+
|
|
48
|
+
function queueSyncFrame({ sessionId, agent, cwd, frame }) {
|
|
49
|
+
if (!sessionId || !agent || !frame) return 0;
|
|
50
|
+
const key = syncKey(sessionId, agent);
|
|
51
|
+
const seq = (Number(syncState.next[key]) || 0) + 1;
|
|
52
|
+
syncState.next[key] = seq;
|
|
53
|
+
syncState.pending.push({ sessionId, agent, cwd: cwd || '', seq, frame, ts: Date.now() });
|
|
54
|
+
if (syncState.pending.length > 20000) syncState.pending.splice(0, syncState.pending.length - 20000);
|
|
55
|
+
void persistSyncState().then(flushSyncOutbox);
|
|
56
|
+
return seq;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function flushSyncOutbox() {
|
|
60
|
+
if (controlWs?.readyState !== WebSocket.OPEN) return;
|
|
61
|
+
for (const event of syncState.pending) {
|
|
62
|
+
const id = `${event.agent}:${event.sessionId}:${event.seq}`;
|
|
63
|
+
if (syncSent.has(id)) continue;
|
|
64
|
+
syncSent.add(id);
|
|
65
|
+
sendControl({ type: 'sync-event', data: JSON.stringify(event) });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function acknowledgeSync(data) {
|
|
70
|
+
const upto = Number(data?.upto) || 0;
|
|
71
|
+
if (!data?.sessionId || !data?.agent || !upto) return;
|
|
72
|
+
const before = syncState.pending.length;
|
|
73
|
+
syncState.pending = syncState.pending.filter(e => !(e.sessionId === data.sessionId && e.agent === data.agent && e.seq <= upto));
|
|
74
|
+
if (before !== syncState.pending.length) void persistSyncState();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stampRuntimeFrame(tunnel, text) {
|
|
78
|
+
if (tunnel?.path !== '/ws/chat') return text;
|
|
79
|
+
let frame;
|
|
80
|
+
try { frame = JSON.parse(text); } catch { return text; }
|
|
81
|
+
if (frame.type === 'ping' || frame.type === 'pong' || frame.type === 'hub-turn-finished') return text;
|
|
82
|
+
const inspect = (value) => {
|
|
83
|
+
if (!value || typeof value !== 'object') return;
|
|
84
|
+
if (value.type === 'batch' && Array.isArray(value.items)) { value.items.forEach(inspect); return; }
|
|
85
|
+
if (value.sessionId) tunnel.sessionId = value.sessionId;
|
|
86
|
+
if (value.agent) tunnel.agent = value.agent;
|
|
87
|
+
};
|
|
88
|
+
inspect(frame);
|
|
89
|
+
if (!tunnel.sessionId || !tunnel.agent) return text;
|
|
90
|
+
const seq = queueSyncFrame({ sessionId: tunnel.sessionId, agent: tunnel.agent, cwd: tunnel.cwd, frame: text });
|
|
91
|
+
if (!seq) return text;
|
|
92
|
+
frame.hubSeq = seq;
|
|
93
|
+
return JSON.stringify(frame);
|
|
94
|
+
}
|
|
95
|
+
|
|
16
96
|
// ─── Start local app (only binds to 127.0.0.1) ───────────────────────────────
|
|
97
|
+
await loadSyncState();
|
|
17
98
|
await init();
|
|
18
99
|
const { server } = createApp();
|
|
19
100
|
await new Promise(resolve => server.listen(LOCAL_PORT, '127.0.0.1', resolve));
|
|
@@ -23,7 +104,7 @@ console.log(`[client] Local app listening on 127.0.0.1:${LOCAL_PORT}`);
|
|
|
23
104
|
// Requires global install + systemd Restart= (or equivalent) for full apply.
|
|
24
105
|
startAutoUpdate({ role: 'edge-client', isBusy: hasBusyAgent });
|
|
25
106
|
|
|
26
|
-
// ─── Active WS tunnels:
|
|
107
|
+
// ─── Active WS tunnels: socket + session identity for resumable frames ───────
|
|
27
108
|
const tunnels = new Map();
|
|
28
109
|
|
|
29
110
|
// ─── Gateway connection ───────────────────────────────────────────────────────
|
|
@@ -69,18 +150,25 @@ function connect() {
|
|
|
69
150
|
|
|
70
151
|
controlWs.on('open', () => {
|
|
71
152
|
reconnectDelay = 1000;
|
|
153
|
+
syncSent = new Set();
|
|
72
154
|
console.log(`[client] Connected. Registering as "${MACHINE_ID}"`);
|
|
73
155
|
sendControl({
|
|
74
156
|
type: 'register',
|
|
75
157
|
machineId: MACHINE_ID,
|
|
76
158
|
meta: { hostname: os.hostname(), platform: os.platform() },
|
|
77
159
|
});
|
|
160
|
+
flushSyncOutbox();
|
|
78
161
|
});
|
|
79
162
|
|
|
80
163
|
controlWs.on('message', async (raw) => {
|
|
81
164
|
let msg;
|
|
82
165
|
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
83
166
|
|
|
167
|
+
if (msg.type === 'sync-ack') {
|
|
168
|
+
try { acknowledgeSync(JSON.parse(msg.data || '{}')); } catch { /* malformed ack */ }
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
84
172
|
// ── HTTP proxy ────────────────────────────────────────────────────────────
|
|
85
173
|
if (msg.type === 'http-req') {
|
|
86
174
|
try {
|
|
@@ -170,14 +258,22 @@ function connect() {
|
|
|
170
258
|
const { tunnelId, path, query } = msg;
|
|
171
259
|
const localUrl = `ws://127.0.0.1:${LOCAL_PORT}${path}${query || ''}`;
|
|
172
260
|
const localWs = new WebSocket(localUrl);
|
|
261
|
+
const params = new URLSearchParams(String(query || '').replace(/^\?/, ''));
|
|
262
|
+
const tunnel = {
|
|
263
|
+
ws: localWs,
|
|
264
|
+
path,
|
|
265
|
+
sessionId: params.get('sessionId') || '',
|
|
266
|
+
agent: params.get('agent') || '',
|
|
267
|
+
cwd: params.get('cwd') || '',
|
|
268
|
+
};
|
|
173
269
|
|
|
174
270
|
localWs.on('open', () => {
|
|
175
|
-
tunnels.set(tunnelId,
|
|
271
|
+
tunnels.set(tunnelId, tunnel);
|
|
176
272
|
sendControl({ type: 'ws-ready', tunnelId });
|
|
177
273
|
});
|
|
178
274
|
|
|
179
275
|
localWs.on('message', (data) => {
|
|
180
|
-
sendControl({ type: 'ws-msg', tunnelId, data: data.toString() });
|
|
276
|
+
sendControl({ type: 'ws-msg', tunnelId, data: stampRuntimeFrame(tunnel, data.toString()) });
|
|
181
277
|
});
|
|
182
278
|
|
|
183
279
|
localWs.on('close', () => {
|
|
@@ -195,16 +291,16 @@ function connect() {
|
|
|
195
291
|
|
|
196
292
|
// ── WS tunnel message (gateway → local) ───────────────────────────────────
|
|
197
293
|
if (msg.type === 'ws-msg') {
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
294
|
+
const tunnel = tunnels.get(msg.tunnelId);
|
|
295
|
+
if (tunnel?.ws?.readyState === WebSocket.OPEN) tunnel.ws.send(msg.data);
|
|
200
296
|
return;
|
|
201
297
|
}
|
|
202
298
|
|
|
203
299
|
// ── WS tunnel close (gateway side closed) ─────────────────────────────────
|
|
204
300
|
if (msg.type === 'ws-close') {
|
|
205
|
-
const
|
|
301
|
+
const tunnel = tunnels.get(msg.tunnelId);
|
|
206
302
|
tunnels.delete(msg.tunnelId);
|
|
207
|
-
if (
|
|
303
|
+
if (tunnel?.ws?.readyState === WebSocket.OPEN) tunnel.ws.close();
|
|
208
304
|
return;
|
|
209
305
|
}
|
|
210
306
|
|
|
@@ -214,6 +310,7 @@ function connect() {
|
|
|
214
310
|
});
|
|
215
311
|
|
|
216
312
|
controlWs.on('close', () => {
|
|
313
|
+
syncSent = new Set();
|
|
217
314
|
console.log(`[client] Disconnected. Reconnecting in ${reconnectDelay}ms ...`);
|
|
218
315
|
// Close all open tunnels
|
|
219
316
|
for (const localWs of tunnels.values()) {
|