eleveight-codebridge 0.1.2
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/dist/cli.js +382 -0
- package/package.json +28 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import chokidar from "chokidar";
|
|
5
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
function getArg(name, fallback) {
|
|
8
|
+
const index = args.indexOf(name);
|
|
9
|
+
if (index === -1)
|
|
10
|
+
return fallback;
|
|
11
|
+
return args[index + 1] ?? fallback;
|
|
12
|
+
}
|
|
13
|
+
function getFirstPositionalArg() {
|
|
14
|
+
return args.find((arg) => !arg.startsWith("-"));
|
|
15
|
+
}
|
|
16
|
+
function isWebSocketUrl(value) {
|
|
17
|
+
return value.startsWith("ws://") || value.startsWith("wss://");
|
|
18
|
+
}
|
|
19
|
+
function isLegacyManagedPath(pathValue) {
|
|
20
|
+
return /^NCL__?/.test(pathValue);
|
|
21
|
+
}
|
|
22
|
+
function stripNumericSuffixBeforeExtension(name) {
|
|
23
|
+
return name.replace(/_\d+(\.[^.]+)$/, "$1");
|
|
24
|
+
}
|
|
25
|
+
function normalizeLegacyManagedPath(pathValue) {
|
|
26
|
+
if (!isLegacyManagedPath(pathValue))
|
|
27
|
+
return pathValue;
|
|
28
|
+
const withoutPrefix = pathValue.replace(/^NCL__?/, "");
|
|
29
|
+
if (!withoutPrefix)
|
|
30
|
+
return pathValue;
|
|
31
|
+
const normalized = stripNumericSuffixBeforeExtension(withoutPrefix).replace(/__/g, "/");
|
|
32
|
+
return normalized || pathValue;
|
|
33
|
+
}
|
|
34
|
+
const positionalArg = getFirstPositionalArg();
|
|
35
|
+
const connectionArg = getArg("--connection") ??
|
|
36
|
+
(positionalArg && isWebSocketUrl(positionalArg) ? positionalArg : undefined);
|
|
37
|
+
const codeArg = getArg("--code") ??
|
|
38
|
+
getArg("--room") ??
|
|
39
|
+
(positionalArg && !isWebSocketUrl(positionalArg) ? positionalArg : undefined);
|
|
40
|
+
const relayArg = getArg("--relay");
|
|
41
|
+
const noRelayArg = args.includes("--no-relay");
|
|
42
|
+
const supportedExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".css"]);
|
|
43
|
+
let relay = relayArg ?? "ws://localhost:8787";
|
|
44
|
+
let code = codeArg;
|
|
45
|
+
if (connectionArg) {
|
|
46
|
+
try {
|
|
47
|
+
const url = new URL(connectionArg);
|
|
48
|
+
relay = connectionArg;
|
|
49
|
+
code = codeArg ?? url.searchParams.get("code") ?? url.searchParams.get("room") ?? undefined;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
console.error("Invalid --connection URL");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!code) {
|
|
57
|
+
console.error("Missing code. Use <code>, --code <id>, or --connection ws://host:port?code=<id>");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
const codeValue = code;
|
|
61
|
+
const rootDir = path.resolve(getArg("--dir", `./code-bridge-files/${codeValue}`));
|
|
62
|
+
const rootPrefix = `${rootDir}${path.sep}`;
|
|
63
|
+
const suppressedPaths = new Set();
|
|
64
|
+
const suppressedPathTimers = new Map();
|
|
65
|
+
const relayClients = new Map();
|
|
66
|
+
const relayRoomFiles = new Map();
|
|
67
|
+
function relaySend(ws, message) {
|
|
68
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
69
|
+
ws.send(JSON.stringify(message));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function relayError(ws, roomId, message) {
|
|
73
|
+
relaySend(ws, { v: 1, type: "error", roomId, message });
|
|
74
|
+
}
|
|
75
|
+
function relayAck(ws, roomId, message) {
|
|
76
|
+
relaySend(ws, { v: 1, type: "ack", roomId, message });
|
|
77
|
+
}
|
|
78
|
+
function relayBroadcast(roomId, payload, except) {
|
|
79
|
+
for (const [client, meta] of relayClients.entries()) {
|
|
80
|
+
if (client === except)
|
|
81
|
+
continue;
|
|
82
|
+
if (meta.roomId !== roomId)
|
|
83
|
+
continue;
|
|
84
|
+
relaySend(client, payload);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function relaySnapshotToMessage(roomId) {
|
|
88
|
+
const files = relayRoomFiles.get(roomId);
|
|
89
|
+
if (!files || files.size === 0)
|
|
90
|
+
return null;
|
|
91
|
+
return {
|
|
92
|
+
v: 1,
|
|
93
|
+
type: "full_sync",
|
|
94
|
+
roomId,
|
|
95
|
+
files: Array.from(files.entries()).map(([path, content]) => ({ path, content })),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function handleRelayMessage(ws, message) {
|
|
99
|
+
const meta = relayClients.get(ws);
|
|
100
|
+
if (!meta)
|
|
101
|
+
return;
|
|
102
|
+
if (message.type === "join") {
|
|
103
|
+
meta.roomId = message.roomId;
|
|
104
|
+
meta.actor = message.actor;
|
|
105
|
+
relayAck(ws, message.roomId, `Joined room as ${message.actor}`);
|
|
106
|
+
const snapshot = relaySnapshotToMessage(message.roomId);
|
|
107
|
+
if (snapshot)
|
|
108
|
+
relaySend(ws, snapshot);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!meta.roomId || !meta.actor) {
|
|
112
|
+
relayError(ws, message.roomId, "Send a join message first");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (message.roomId !== meta.roomId) {
|
|
116
|
+
relayError(ws, message.roomId, "roomId mismatch for connected client");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
switch (message.type) {
|
|
120
|
+
case "full_sync": {
|
|
121
|
+
const next = new Map();
|
|
122
|
+
for (const file of message.files)
|
|
123
|
+
next.set(file.path, file.content);
|
|
124
|
+
relayRoomFiles.set(message.roomId, next);
|
|
125
|
+
relayBroadcast(message.roomId, message, ws);
|
|
126
|
+
relayAck(ws, message.roomId, `Broadcast full sync (${message.files.length} files)`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
case "upsert_file": {
|
|
130
|
+
const files = relayRoomFiles.get(message.roomId) ?? new Map();
|
|
131
|
+
files.set(message.path, message.content);
|
|
132
|
+
relayRoomFiles.set(message.roomId, files);
|
|
133
|
+
relayBroadcast(message.roomId, message, ws);
|
|
134
|
+
relayAck(ws, message.roomId, `Upserted ${message.path}`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
case "delete_file": {
|
|
138
|
+
const files = relayRoomFiles.get(message.roomId);
|
|
139
|
+
if (files)
|
|
140
|
+
files.delete(message.path);
|
|
141
|
+
relayBroadcast(message.roomId, message, ws);
|
|
142
|
+
relayAck(ws, message.roomId, `Deleted ${message.path}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
case "ping": {
|
|
146
|
+
relaySend(ws, message);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function isLocalRelayUrl(value) {
|
|
152
|
+
try {
|
|
153
|
+
const url = new URL(value);
|
|
154
|
+
return (url.protocol === "ws:" &&
|
|
155
|
+
(url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function ensureEmbeddedRelayIfNeeded() {
|
|
162
|
+
if (noRelayArg)
|
|
163
|
+
return;
|
|
164
|
+
if (!isLocalRelayUrl(relay))
|
|
165
|
+
return;
|
|
166
|
+
const relayUrl = new URL(relay);
|
|
167
|
+
const relayPort = Number(relayUrl.port || "80");
|
|
168
|
+
await new Promise((resolve) => {
|
|
169
|
+
const wss = new WebSocketServer({ port: relayPort });
|
|
170
|
+
let settled = false;
|
|
171
|
+
const done = () => {
|
|
172
|
+
if (settled)
|
|
173
|
+
return;
|
|
174
|
+
settled = true;
|
|
175
|
+
resolve();
|
|
176
|
+
};
|
|
177
|
+
wss.on("connection", (ws) => {
|
|
178
|
+
relayClients.set(ws, {});
|
|
179
|
+
ws.on("message", (raw) => {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(raw.toString());
|
|
182
|
+
handleRelayMessage(ws, parsed);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
const meta = relayClients.get(ws);
|
|
186
|
+
if (meta?.roomId)
|
|
187
|
+
relayError(ws, meta.roomId, "Invalid JSON payload");
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
ws.on("close", () => {
|
|
191
|
+
relayClients.delete(ws);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
wss.on("listening", () => {
|
|
195
|
+
console.log(`[code-bridge] embedded relay listening on ws://localhost:${relayPort}`);
|
|
196
|
+
done();
|
|
197
|
+
});
|
|
198
|
+
wss.on("error", (error) => {
|
|
199
|
+
const code = error.code;
|
|
200
|
+
if (code === "EADDRINUSE") {
|
|
201
|
+
done();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
console.error(`[code-bridge] embedded relay failed: ${error.message}`);
|
|
205
|
+
done();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function toRoomPath(absolutePath) {
|
|
210
|
+
return path.relative(rootDir, absolutePath).split(path.sep).join("/");
|
|
211
|
+
}
|
|
212
|
+
function toAbsolutePath(roomPath) {
|
|
213
|
+
const normalized = normalizeLegacyManagedPath(roomPath).replace(/^\/+/, "");
|
|
214
|
+
const absolutePath = path.resolve(rootDir, normalized);
|
|
215
|
+
if (!(absolutePath === rootDir || absolutePath.startsWith(rootPrefix))) {
|
|
216
|
+
throw new Error(`Invalid path outside root: ${roomPath}`);
|
|
217
|
+
}
|
|
218
|
+
return absolutePath;
|
|
219
|
+
}
|
|
220
|
+
function markSuppressed(absolutePath) {
|
|
221
|
+
const existingTimer = suppressedPathTimers.get(absolutePath);
|
|
222
|
+
if (existingTimer) {
|
|
223
|
+
clearTimeout(existingTimer);
|
|
224
|
+
}
|
|
225
|
+
suppressedPaths.add(absolutePath);
|
|
226
|
+
const timer = setTimeout(() => {
|
|
227
|
+
suppressedPaths.delete(absolutePath);
|
|
228
|
+
suppressedPathTimers.delete(absolutePath);
|
|
229
|
+
}, 1500);
|
|
230
|
+
suppressedPathTimers.set(absolutePath, timer);
|
|
231
|
+
}
|
|
232
|
+
function isSupportedFile(absolutePath) {
|
|
233
|
+
return supportedExtensions.has(path.extname(absolutePath));
|
|
234
|
+
}
|
|
235
|
+
async function listFilesRecursively(dir) {
|
|
236
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
237
|
+
const files = await Promise.all(entries.map(async (entry) => {
|
|
238
|
+
const absolutePath = path.join(dir, entry.name);
|
|
239
|
+
if (entry.isDirectory())
|
|
240
|
+
return listFilesRecursively(absolutePath);
|
|
241
|
+
return [absolutePath];
|
|
242
|
+
}));
|
|
243
|
+
return files.flat();
|
|
244
|
+
}
|
|
245
|
+
async function loadInitialFiles() {
|
|
246
|
+
await fs.mkdir(rootDir, { recursive: true });
|
|
247
|
+
const all = await listFilesRecursively(rootDir);
|
|
248
|
+
const files = [];
|
|
249
|
+
for (const absolutePath of all) {
|
|
250
|
+
if (!isSupportedFile(absolutePath))
|
|
251
|
+
continue;
|
|
252
|
+
const roomPath = toRoomPath(absolutePath);
|
|
253
|
+
if (isLegacyManagedPath(roomPath))
|
|
254
|
+
continue;
|
|
255
|
+
const content = await fs.readFile(absolutePath, "utf8");
|
|
256
|
+
files.push({ path: roomPath, content });
|
|
257
|
+
}
|
|
258
|
+
return files;
|
|
259
|
+
}
|
|
260
|
+
await ensureEmbeddedRelayIfNeeded();
|
|
261
|
+
const socket = new WebSocket(relay);
|
|
262
|
+
function send(message) {
|
|
263
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
264
|
+
return;
|
|
265
|
+
socket.send(JSON.stringify(message));
|
|
266
|
+
}
|
|
267
|
+
async function sendUpsert(absolutePath) {
|
|
268
|
+
if (!isSupportedFile(absolutePath))
|
|
269
|
+
return;
|
|
270
|
+
const roomPath = toRoomPath(absolutePath);
|
|
271
|
+
if (isLegacyManagedPath(roomPath))
|
|
272
|
+
return;
|
|
273
|
+
const content = await fs.readFile(absolutePath, "utf8");
|
|
274
|
+
send({
|
|
275
|
+
v: 1,
|
|
276
|
+
type: "upsert_file",
|
|
277
|
+
roomId: codeValue,
|
|
278
|
+
path: roomPath,
|
|
279
|
+
content,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
async function applyUpsert(pathFromRelay, content) {
|
|
283
|
+
const absolutePath = toAbsolutePath(pathFromRelay);
|
|
284
|
+
if (!isSupportedFile(absolutePath))
|
|
285
|
+
return;
|
|
286
|
+
try {
|
|
287
|
+
const current = await fs.readFile(absolutePath, "utf8");
|
|
288
|
+
if (current === content)
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
catch { }
|
|
292
|
+
markSuppressed(absolutePath);
|
|
293
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
294
|
+
await fs.writeFile(absolutePath, content, "utf8");
|
|
295
|
+
console.log(`[relay:apply] upsert ${pathFromRelay}`);
|
|
296
|
+
}
|
|
297
|
+
async function applyDelete(pathFromRelay) {
|
|
298
|
+
const absolutePath = toAbsolutePath(pathFromRelay);
|
|
299
|
+
if (!isSupportedFile(absolutePath))
|
|
300
|
+
return;
|
|
301
|
+
markSuppressed(absolutePath);
|
|
302
|
+
await fs.rm(absolutePath, { force: true });
|
|
303
|
+
console.log(`[relay:apply] delete ${pathFromRelay}`);
|
|
304
|
+
}
|
|
305
|
+
socket.on("open", async () => {
|
|
306
|
+
send({ v: 1, type: "join", roomId: codeValue, actor: "watcher" });
|
|
307
|
+
const files = await loadInitialFiles();
|
|
308
|
+
send({ v: 1, type: "full_sync", roomId: codeValue, files });
|
|
309
|
+
console.log(`[code-bridge] watcher connected (${files.length} files synced) code=${codeValue}`);
|
|
310
|
+
});
|
|
311
|
+
socket.on("message", async (raw) => {
|
|
312
|
+
try {
|
|
313
|
+
const message = JSON.parse(raw.toString());
|
|
314
|
+
if (message.type === "ack") {
|
|
315
|
+
console.log(`[relay] ${message.message}`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (message.type === "error") {
|
|
319
|
+
console.error(`[relay:error] ${message.message}`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (message.type === "full_sync") {
|
|
323
|
+
for (const file of message.files) {
|
|
324
|
+
await applyUpsert(file.path, file.content);
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (message.type === "upsert_file") {
|
|
329
|
+
await applyUpsert(message.path, message.content);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (message.type === "delete_file") {
|
|
333
|
+
await applyDelete(message.path);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
console.error("[code-bridge] invalid relay message");
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
socket.on("close", () => {
|
|
341
|
+
console.log("[code-bridge] relay connection closed");
|
|
342
|
+
process.exit(0);
|
|
343
|
+
});
|
|
344
|
+
socket.on("error", (error) => {
|
|
345
|
+
console.error(`[code-bridge] relay connection failed: ${error.message}`);
|
|
346
|
+
process.exit(1);
|
|
347
|
+
});
|
|
348
|
+
const watcher = chokidar.watch(rootDir, { ignoreInitial: true });
|
|
349
|
+
watcher.on("add", async (absolutePath) => {
|
|
350
|
+
const normalizedPath = path.resolve(absolutePath);
|
|
351
|
+
if (suppressedPaths.has(normalizedPath)) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
await sendUpsert(normalizedPath);
|
|
355
|
+
console.log(`[watch:add] ${toRoomPath(normalizedPath)}`);
|
|
356
|
+
});
|
|
357
|
+
watcher.on("change", async (absolutePath) => {
|
|
358
|
+
const normalizedPath = path.resolve(absolutePath);
|
|
359
|
+
if (suppressedPaths.has(normalizedPath)) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
await sendUpsert(normalizedPath);
|
|
363
|
+
console.log(`[watch:change] ${toRoomPath(normalizedPath)}`);
|
|
364
|
+
});
|
|
365
|
+
watcher.on("unlink", (absolutePath) => {
|
|
366
|
+
const normalizedPath = path.resolve(absolutePath);
|
|
367
|
+
if (suppressedPaths.has(normalizedPath)) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (!isSupportedFile(normalizedPath))
|
|
371
|
+
return;
|
|
372
|
+
const roomPath = toRoomPath(normalizedPath);
|
|
373
|
+
if (isLegacyManagedPath(roomPath))
|
|
374
|
+
return;
|
|
375
|
+
send({
|
|
376
|
+
v: 1,
|
|
377
|
+
type: "delete_file",
|
|
378
|
+
roomId: codeValue,
|
|
379
|
+
path: roomPath,
|
|
380
|
+
});
|
|
381
|
+
console.log(`[watch:delete] ${roomPath}`);
|
|
382
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eleveight-codebridge",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"eleveight-codebridge": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "tsx src/cli.ts",
|
|
14
|
+
"build": "tsc -p tsconfig.build.json",
|
|
15
|
+
"start": "node dist/cli.js",
|
|
16
|
+
"prepack": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"chokidar": "^4.0.3",
|
|
20
|
+
"ws": "^8.18.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.10.2",
|
|
24
|
+
"@types/ws": "^8.5.13",
|
|
25
|
+
"tsx": "^4.19.2",
|
|
26
|
+
"typescript": "^5.6.3"
|
|
27
|
+
}
|
|
28
|
+
}
|