@augmentd-labs/canvas-edge 0.0.0-stage → 0.4.1
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 +661 -0
- package/NOTICE +112 -0
- package/README.md +76 -2
- package/bin/canvas-edge +2 -0
- package/bin/healthcheck.mjs +9 -0
- package/node_modules/canvas-stored/LICENSE +661 -0
- package/node_modules/canvas-stored/NOTICE +85 -0
- package/node_modules/canvas-stored/README.md +472 -0
- package/node_modules/canvas-stored/package.json +26 -0
- package/node_modules/canvas-stored/src/backends/BackendManager.js +60 -0
- package/node_modules/canvas-stored/src/backends/StorageBackend.js +72 -0
- package/node_modules/canvas-stored/src/backends/cacache/index.js +130 -0
- package/node_modules/canvas-stored/src/backends/canvas/index.js +722 -0
- package/node_modules/canvas-stored/src/backends/file/index.js +658 -0
- package/node_modules/canvas-stored/src/backends/gdrive/index.js +924 -0
- package/node_modules/canvas-stored/src/backends/http/index.js +47 -0
- package/node_modules/canvas-stored/src/backends/s3/index.js +25 -0
- package/node_modules/canvas-stored/src/cache/index.js +65 -0
- package/node_modules/canvas-stored/src/extractors/audio.js +53 -0
- package/node_modules/canvas-stored/src/extractors/image.js +90 -0
- package/node_modules/canvas-stored/src/extractors/index.js +41 -0
- package/node_modules/canvas-stored/src/extractors/text.js +65 -0
- package/node_modules/canvas-stored/src/index/index.js +360 -0
- package/node_modules/canvas-stored/src/index.js +2058 -0
- package/node_modules/canvas-stored/src/sync/JobQueue.js +0 -0
- package/node_modules/canvas-stored/src/sync/Ledger.js +138 -0
- package/node_modules/canvas-stored/src/sync/Mirror.js +995 -0
- package/node_modules/canvas-stored/src/sync/SyncQueue.js +111 -0
- package/node_modules/canvas-stored/src/sync/keys.js +140 -0
- package/node_modules/canvas-stored/src/sync/worker.js +52 -0
- package/node_modules/canvas-stored/src/utils/checksum.js +32 -0
- package/node_modules/canvas-stored/src/utils/common.js +37 -0
- package/node_modules/canvas-stored/src/utils/mime.js +107 -0
- package/node_modules/canvas-stored/src/utils/mount.js +99 -0
- package/package.json +49 -4
- package/src/EdgeClient.js +129 -0
- package/src/control.js +48 -0
- package/src/daemon.js +88 -0
- package/src/env.js +182 -0
- package/src/fuse-runtime.js +173 -0
- package/src/index.js +26 -0
- package/src/mirror-runtime.js +232 -0
- package/src/remote-config.js +69 -0
- package/src/runtime.js +61 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { io } from 'socket.io-client';
|
|
6
|
+
import Stored, { Mirror } from 'canvas-stored';
|
|
7
|
+
import { MIRROR_IGNORE_DEFAULTS } from 'canvas-stored/src/sync/keys.js';
|
|
8
|
+
import { DEFAULT_SYNC_EXCLUSIONS, WORKSPACE_INTERNAL_EXCLUSIONS } from '@augmentd-labs/canvas-protocol';
|
|
9
|
+
|
|
10
|
+
const STATUS_THROTTLE_MS = 5000;
|
|
11
|
+
const STATUS_HEARTBEAT_MS = 60000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One mirrored workspace folder run by the daemon: a canvas-stored instance
|
|
15
|
+
* whose `local` backend is the folder (chokidar-watched), a `canvas:hub`
|
|
16
|
+
* backend speaking the sync protocol to the hub, `trash`/`conflicts` side
|
|
17
|
+
* folders, and the Mirror engine reconciling between them. All state lives in
|
|
18
|
+
* `<folder>/.workspace/` (mirror.json, db/stored, cache, tmp, trash,
|
|
19
|
+
* conflicts) — the folder is self-describing and movable — unless the mirror
|
|
20
|
+
* names a `stateDir` (or the daemon a CANVAS_EDGE_STATE_ROOT), in which case
|
|
21
|
+
* the folder holds user files only and everything else lives there. Staging
|
|
22
|
+
* (temp) must share a filesystem with the folder for atomic placement, so
|
|
23
|
+
* it stays under the state dir only when that is on the same device and
|
|
24
|
+
* falls back to `<folder>/.stored-tmp` otherwise.
|
|
25
|
+
*
|
|
26
|
+
* `direction` (bi | pull | push, canvas-stored Mirror) comes from the mirror
|
|
27
|
+
* entry. Every status report carries the (docId, version) pairs agreed since
|
|
28
|
+
* the last report (`applied`); the first report after start sends the whole
|
|
29
|
+
* ledger as `full` so a hub that lost its replica table rebuilds it.
|
|
30
|
+
*/
|
|
31
|
+
export class MirrorRuntime {
|
|
32
|
+
#mirror;
|
|
33
|
+
#hub;
|
|
34
|
+
#identity;
|
|
35
|
+
#logger;
|
|
36
|
+
#stored = null;
|
|
37
|
+
#engine = null;
|
|
38
|
+
#socket = null;
|
|
39
|
+
#reporter = null;
|
|
40
|
+
#lastReport = 0;
|
|
41
|
+
#reportTimer = null;
|
|
42
|
+
#pendingApplied = [];
|
|
43
|
+
#needFull = true;
|
|
44
|
+
|
|
45
|
+
constructor({ mirror, hub, identity, logger }) {
|
|
46
|
+
this.#mirror = mirror;
|
|
47
|
+
this.#hub = hub;
|
|
48
|
+
this.#identity = identity;
|
|
49
|
+
this.#logger = logger;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get id() { return this.#mirror.id; }
|
|
53
|
+
get folder() { return this.#mirror.mountpoint; }
|
|
54
|
+
|
|
55
|
+
get stateDir() { return this.#mirror.stateDir ? path.resolve(this.#mirror.stateDir) : path.join(this.folder, '.workspace'); }
|
|
56
|
+
|
|
57
|
+
async start() {
|
|
58
|
+
const folder = this.folder;
|
|
59
|
+
const internal = this.stateDir;
|
|
60
|
+
const external = internal !== path.join(folder, '.workspace');
|
|
61
|
+
for (const d of ['db/stored', 'cache', 'trash', 'conflicts']) fs.mkdirSync(path.join(internal, d), { recursive: true });
|
|
62
|
+
let tempDir = path.join(internal, 'tmp');
|
|
63
|
+
if (external && !canRenameInto(path.join(internal, 'tmp'), folder)) {
|
|
64
|
+
// st_dev lies for bind mounts (one disk, two mounts, rename refused):
|
|
65
|
+
// only an actual rename tells. Staging on the folder's own mount
|
|
66
|
+
// keeps placement a single rename instead of copy + rename.
|
|
67
|
+
tempDir = path.join(folder, '.stored-tmp');
|
|
68
|
+
this.#logger.info({ mirror: this.id, stateDir: internal }, 'state dir is on another mount — staging under <folder>/.stored-tmp');
|
|
69
|
+
}
|
|
70
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
71
|
+
fs.writeFileSync(path.join(internal, 'mirror.json'), JSON.stringify({
|
|
72
|
+
version: 1, mirrorId: this.#mirror.id, hub: { id: this.#hub.id, url: this.#hub.url },
|
|
73
|
+
workspaceId: this.#mirror.workspaceId, workspaceName: this.#mirror.workspaceName, backend: 'workspace:home',
|
|
74
|
+
deviceId: this.#identity.deviceId, client: 'daemon', createdAt: new Date().toISOString(),
|
|
75
|
+
pins: this.#mirror.pins || [], ignore: this.#mirror.ignore || [], conflicts: this.#mirror.conflicts, deletes: this.#mirror.deletes,
|
|
76
|
+
direction: this.#mirror.direction || 'bi',
|
|
77
|
+
stateDir: external ? internal : null, tempDir,
|
|
78
|
+
}, null, 2));
|
|
79
|
+
|
|
80
|
+
const hubExclusions = await this.#fetchHubExclusions();
|
|
81
|
+
this.#stored = new Stored({ root: path.join(internal, 'db', 'stored'), cache: { path: path.join(internal, 'cache') }, checksums: ['sha256'] });
|
|
82
|
+
this.#stored.on('error', (err) => this.#logger.warn({ mirror: this.id, err: err?.message }, 'stored error'));
|
|
83
|
+
this.#stored.addBackend('local', {
|
|
84
|
+
driver: 'file', root: folder, watch: true, tempDir, followSymlinks: false, stabilityThreshold: 2000,
|
|
85
|
+
ignored: [...new Set([...WORKSPACE_INTERNAL_EXCLUSIONS, ...DEFAULT_SYNC_EXCLUSIONS, ...hubExclusions, ...MIRROR_IGNORE_DEFAULTS, ...(this.#mirror.ignore || [])])],
|
|
86
|
+
});
|
|
87
|
+
this.#stored.addBackend('trash', { driver: 'file', root: path.join(internal, 'trash'), watch: false });
|
|
88
|
+
this.#stored.addBackend('conflicts', { driver: 'file', root: path.join(internal, 'conflicts'), watch: false });
|
|
89
|
+
this.#stored.addBackend('canvas:hub', {
|
|
90
|
+
driver: 'canvas', url: this.#hub.url, apiBase: this.#hub.apiBase, token: this.#hub.token,
|
|
91
|
+
workspaceId: this.#mirror.workspaceId || this.#mirror.workspaceName, backend: 'workspace:home',
|
|
92
|
+
deviceId: this.#identity.deviceId, deviceName: this.#identity.deviceName, prefixes: this.#mirror.pins || [], pollInterval: 30000,
|
|
93
|
+
});
|
|
94
|
+
this.#engine = new Mirror(this.#stored, {
|
|
95
|
+
id: this.#mirror.id, local: 'local', remote: 'canvas:hub', trash: 'trash', conflicts: 'conflicts',
|
|
96
|
+
prefixes: this.#mirror.pins || [], ignore: this.#mirror.ignore || [],
|
|
97
|
+
deletes: this.#mirror.deletes || 'propagate', conflictMode: this.#mirror.conflicts || 'prompt',
|
|
98
|
+
direction: this.#mirror.direction || 'bi',
|
|
99
|
+
deviceId: this.#identity.deviceId, deviceName: this.#identity.deviceName,
|
|
100
|
+
});
|
|
101
|
+
this.#needFull = true;
|
|
102
|
+
this.#pendingApplied = [];
|
|
103
|
+
this.#engine.on('status', () => this.#scheduleReport());
|
|
104
|
+
this.#engine.on('job:failed', (j) => this.#logger.warn({ mirror: this.id, job: j?.kind, key: j?.key, attempts: j?.attempts, err: j?.error?.message }, 'sync job failed'));
|
|
105
|
+
this.#engine.on('revert', (e) => this.#logger.info({ mirror: this.id, key: e?.key, copy: e?.copy }, 'local edit reverted (pull mirror), copy kept'));
|
|
106
|
+
this.#engine.on('skip', (e) => this.#logger.info({ mirror: this.id, key: e?.key, reason: e?.reason }, 'key skipped'));
|
|
107
|
+
this.#engine.on('conflict', (c) => this.#logger.info({ mirror: this.id, key: c?.key }, 'conflict recorded'));
|
|
108
|
+
await this.#engine.start();
|
|
109
|
+
await this.#connectSocket();
|
|
110
|
+
this.#reporter = setInterval(() => this.#report().catch(() => {}), STATUS_HEARTBEAT_MS);
|
|
111
|
+
this.#reporter.unref?.();
|
|
112
|
+
this.#logger.info({ mirror: this.id, folder }, 'mirror started');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async stop() {
|
|
116
|
+
clearInterval(this.#reporter);
|
|
117
|
+
clearTimeout(this.#reportTimer);
|
|
118
|
+
this.#socket?.close();
|
|
119
|
+
this.#socket = null;
|
|
120
|
+
await this.#engine?.stop().catch(() => {});
|
|
121
|
+
await this.#stored?.stop().catch(() => {});
|
|
122
|
+
this.#engine = null;
|
|
123
|
+
this.#stored = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
status() {
|
|
127
|
+
const s = typeof this.#engine?.status === 'function' ? this.#engine.status() : {};
|
|
128
|
+
return { id: this.id, unit: 'mirror', workspace: this.#mirror.workspaceName, hub: this.#hub.id, folder: this.folder, stateDir: this.stateDir, pins: this.#mirror.pins || [], conflictsMode: this.#mirror.conflicts, direction: this.#mirror.direction || 'bi', ...s };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
nudge() { this.#engine?.nudge?.(); }
|
|
132
|
+
async resync() { return this.#engine?.reconcileAll?.(); }
|
|
133
|
+
|
|
134
|
+
async #fetchHubExclusions() {
|
|
135
|
+
try {
|
|
136
|
+
const res = await fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${encodeURIComponent(this.#mirror.workspaceName)}/backends/file/workspace%3Ahome`, {
|
|
137
|
+
headers: { authorization: `Bearer ${this.#hub.token}` },
|
|
138
|
+
});
|
|
139
|
+
const json = await res.json();
|
|
140
|
+
const list = json?.payload?.effectiveExclusions;
|
|
141
|
+
return Array.isArray(list) ? list.filter((p) => typeof p === 'string') : [];
|
|
142
|
+
} catch { return []; }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The relay matches subscriptions on the workspace uuid (and name, once
|
|
146
|
+
// the hub stamps it); resolve the uuid so nudges reach us either way.
|
|
147
|
+
async #resolveWorkspaceId() {
|
|
148
|
+
if (this.#mirror.workspaceId && /^[0-9a-f-]{36}$/i.test(this.#mirror.workspaceId)) return this.#mirror.workspaceId;
|
|
149
|
+
try {
|
|
150
|
+
const res = await fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${encodeURIComponent(this.#mirror.workspaceName)}`, { headers: { authorization: `Bearer ${this.#hub.token}` } });
|
|
151
|
+
const json = await res.json();
|
|
152
|
+
return json?.payload?.workspace?.id || json?.payload?.id || this.#mirror.workspaceId || this.#mirror.workspaceName;
|
|
153
|
+
} catch { return this.#mirror.workspaceId || this.#mirror.workspaceName; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #connectSocket() {
|
|
157
|
+
try {
|
|
158
|
+
const wsId = await this.#resolveWorkspaceId();
|
|
159
|
+
const socket = io(this.#hub.url, { auth: { token: this.#hub.token }, transports: ['websocket'], reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 30000 });
|
|
160
|
+
this.#socket = socket;
|
|
161
|
+
socket.on('connect', () => { socket.emit('subscribe', { channel: `workspace:${wsId}` }); this.resync().catch(() => {}); });
|
|
162
|
+
socket.on('backend.changed', (e) => { if (!e?.backend || e.backend === 'workspace:home') this.nudge(); });
|
|
163
|
+
socket.on('sync.conflict.resolved', () => this.nudge());
|
|
164
|
+
socket.on('connect_error', (err) => this.#logger.debug?.({ mirror: this.id, err: err?.message }, 'socket connect error'));
|
|
165
|
+
} catch (err) {
|
|
166
|
+
this.#logger.warn({ mirror: this.id, err: err?.message }, 'socket setup failed');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#scheduleReport() {
|
|
171
|
+
const due = STATUS_THROTTLE_MS - (Date.now() - this.#lastReport);
|
|
172
|
+
if (due <= 0) { this.#report().catch(() => {}); return; }
|
|
173
|
+
if (this.#reportTimer) return;
|
|
174
|
+
this.#reportTimer = setTimeout(() => { this.#reportTimer = null; this.#report().catch(() => {}); }, due);
|
|
175
|
+
this.#reportTimer.unref?.();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async #report() {
|
|
179
|
+
if (!this.#engine) return;
|
|
180
|
+
this.#lastReport = Date.now();
|
|
181
|
+
const s = this.status();
|
|
182
|
+
const ws = encodeURIComponent(this.#mirror.workspaceName);
|
|
183
|
+
// Protection evidence: what this replica holds. A failed report keeps
|
|
184
|
+
// the delta for the next one; a full snapshot supersedes any delta.
|
|
185
|
+
const engine = this.#engine;
|
|
186
|
+
const full = this.#needFull;
|
|
187
|
+
const delta = typeof engine.takeApplied === 'function' ? engine.takeApplied() : [];
|
|
188
|
+
this.#pendingApplied = full ? [] : mergePairs(this.#pendingApplied, delta);
|
|
189
|
+
const applied = full ? (typeof engine.appliedSnapshot === 'function' ? engine.appliedSnapshot() : []) : this.#pendingApplied;
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${ws}/mirrors/${encodeURIComponent(this.#identity.deviceId)}/status`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { authorization: `Bearer ${this.#hub.token}`, 'content-type': 'application/json' },
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
backend: 'workspace:home', client: 'daemon', path: this.folder, prefixes: this.#mirror.pins || [],
|
|
196
|
+
cursor: Number(s.cursor) || 0, pending: Number(s.pending) || 0, failed: Number(s.failed) || 0,
|
|
197
|
+
conflicts: Number(s.conflicts) || 0, reverted: Number(s.reverted) || 0, skipped: Number(s.skipped) || 0, state: String(s.state || 'idle'),
|
|
198
|
+
direction: s.direction || 'bi',
|
|
199
|
+
lastSync: s.lastSyncAt ? new Date(s.lastSyncAt).toISOString() : undefined, lastError: s.lastError || null, version: 'canvas-edge/2',
|
|
200
|
+
...(applied.length || full ? { applied, full } : {}),
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
if (res.ok) { this.#pendingApplied = []; if (full) this.#needFull = false; }
|
|
204
|
+
else if (!full) { /* keep the delta */ }
|
|
205
|
+
} catch {
|
|
206
|
+
// offline: the delta (or the need for a full snapshot) survives until the next report
|
|
207
|
+
if (!full) engine.restoreApplied?.(delta);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Can a file staged in `from` be renamed into `into`? Probes with a real
|
|
213
|
+
// rename of an empty file (the only honest answer for bind mounts).
|
|
214
|
+
function canRenameInto(from, into) {
|
|
215
|
+
try {
|
|
216
|
+
fs.mkdirSync(from, { recursive: true });
|
|
217
|
+
const probe = path.join(from, `.probe-${process.pid}-${Date.now()}`);
|
|
218
|
+
const target = path.join(into, `.stored-tmp-probe-${process.pid}-${Date.now()}`);
|
|
219
|
+
fs.writeFileSync(probe, '');
|
|
220
|
+
try { fs.renameSync(probe, target); fs.unlinkSync(target); return true; }
|
|
221
|
+
catch (err) { fs.rmSync(probe, { force: true }); if (err.code === 'EXDEV') return false; throw err; }
|
|
222
|
+
} catch { return false; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Newer version per docId wins; order is irrelevant to the hub.
|
|
226
|
+
function mergePairs(a, b) {
|
|
227
|
+
const best = new Map(a);
|
|
228
|
+
for (const [docId, version] of b) if (!(best.get(docId) > version)) best.set(docId, version);
|
|
229
|
+
return [...best.entries()];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Remote registration config, stored in the workspace's own workspace.json —
|
|
8
|
+
* a workspace is meant to be self-describing, and its remotes travel with it:
|
|
9
|
+
*
|
|
10
|
+
* "remotes": [
|
|
11
|
+
* { "url": "https://my.cnvs.ai", "token": "canvas-…", "enabled": true }
|
|
12
|
+
* ]
|
|
13
|
+
*
|
|
14
|
+
* `token` is opaque to this module — today a user API token or device token
|
|
15
|
+
* (both pass websocket auth); workspace-scoped share tokens can slot in later
|
|
16
|
+
* without a config change. This module has no dependency on core/ on purpose:
|
|
17
|
+
* the edge runtime reads workspace.json directly.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export function workspaceConfigPath(dir) {
|
|
21
|
+
return path.join(dir, 'workspace.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readWorkspaceConfig(dir) {
|
|
25
|
+
const file = workspaceConfigPath(dir);
|
|
26
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
27
|
+
return JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function listRemotes(config = {}) {
|
|
31
|
+
const remotes = Array.isArray(config.remotes) ? config.remotes : [];
|
|
32
|
+
return remotes
|
|
33
|
+
.filter((remote) => remote && typeof remote.url === 'string' && typeof remote.token === 'string')
|
|
34
|
+
.map((remote) => ({
|
|
35
|
+
url: remote.url.replace(/\/+$/, ''),
|
|
36
|
+
token: remote.token,
|
|
37
|
+
enabled: remote.enabled !== false,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Add or update (matched by url) a remote entry, persisting workspace.json. */
|
|
42
|
+
export function saveRemote(dir, { url, token, enabled = true }) {
|
|
43
|
+
if (!url || !token) throw new Error('remote requires url and token');
|
|
44
|
+
const file = workspaceConfigPath(dir);
|
|
45
|
+
const config = readWorkspaceConfig(dir);
|
|
46
|
+
const normalizedUrl = url.replace(/\/+$/, '');
|
|
47
|
+
const remotes = Array.isArray(config.remotes) ? config.remotes : [];
|
|
48
|
+
const existing = remotes.find((remote) => remote?.url?.replace(/\/+$/, '') === normalizedUrl);
|
|
49
|
+
if (existing) {
|
|
50
|
+
existing.url = normalizedUrl;
|
|
51
|
+
existing.token = token;
|
|
52
|
+
existing.enabled = enabled;
|
|
53
|
+
} else {
|
|
54
|
+
remotes.push({ url: normalizedUrl, token, enabled });
|
|
55
|
+
}
|
|
56
|
+
config.remotes = remotes;
|
|
57
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
|
58
|
+
return config;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function removeRemote(dir, url) {
|
|
62
|
+
const file = workspaceConfigPath(dir);
|
|
63
|
+
const config = readWorkspaceConfig(dir);
|
|
64
|
+
const normalizedUrl = url.replace(/\/+$/, '');
|
|
65
|
+
const before = Array.isArray(config.remotes) ? config.remotes : [];
|
|
66
|
+
config.remotes = before.filter((remote) => remote?.url?.replace(/\/+$/, '') !== normalizedUrl);
|
|
67
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
|
68
|
+
return before.length !== config.remotes.length;
|
|
69
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import EdgeClient from './EdgeClient.js';
|
|
4
|
+
import { readWorkspaceConfig, listRemotes } from './remote-config.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Edge runtime bootstrap for a single-workspace host (the `ws` binary):
|
|
8
|
+
* read the workspace's own workspace.json, and auto-register with every
|
|
9
|
+
* enabled remote canvas-server it lists.
|
|
10
|
+
*
|
|
11
|
+
* The workspace id doubles as the tunnel instanceId — a ws runtime hosts
|
|
12
|
+
* exactly one workspace, so its identity *is* the workspace's identity.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function buildAnnounce(config = {}, { runtime = 'ws', version = '0.0.1' } = {}) {
|
|
16
|
+
if (!config.id) throw new Error('workspace.json has no id — cannot announce');
|
|
17
|
+
return {
|
|
18
|
+
instanceId: config.id,
|
|
19
|
+
runtime,
|
|
20
|
+
version,
|
|
21
|
+
caps: ['proxy', 'streaming'],
|
|
22
|
+
exports: [
|
|
23
|
+
{ type: 'workspace', id: config.id, name: config.name || config.label || config.id },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Connect to all enabled remotes. Returns `{ clients, close }`.
|
|
30
|
+
*
|
|
31
|
+
* @param {Object} opts
|
|
32
|
+
* @param {string} opts.dir - workspace directory (contains workspace.json)
|
|
33
|
+
* @param {Object} opts.localApp - fastify instance serving the local API
|
|
34
|
+
* @param {Object} [opts.events] - wildcard emitter whose events relay upstream
|
|
35
|
+
* @param {Function} [opts.clientFactory] - injectable for tests
|
|
36
|
+
*/
|
|
37
|
+
export function connectRemotes({ dir, localApp, events, clientFactory } = {}) {
|
|
38
|
+
const config = readWorkspaceConfig(dir);
|
|
39
|
+
const announce = buildAnnounce(config);
|
|
40
|
+
const createClient = clientFactory || ((clientOpts) => new EdgeClient(clientOpts).connect());
|
|
41
|
+
|
|
42
|
+
const clients = listRemotes(config)
|
|
43
|
+
.filter((remote) => remote.enabled)
|
|
44
|
+
.map((remote) => {
|
|
45
|
+
const client = createClient({
|
|
46
|
+
serverUrl: remote.url,
|
|
47
|
+
token: remote.token,
|
|
48
|
+
localApp,
|
|
49
|
+
announce,
|
|
50
|
+
});
|
|
51
|
+
if (events && client.forwardEvents) client.forwardEvents(events);
|
|
52
|
+
return client;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
clients,
|
|
57
|
+
close() {
|
|
58
|
+
for (const client of clients) client.close?.();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|