@augmentd-labs/canvas-edge 0.0.0-stage → 0.4.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 +661 -0
- package/NOTICE +114 -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,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { io } from 'socket.io-client';
|
|
4
|
+
|
|
5
|
+
const CHUNK_SIZE = 256 * 1024;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* canvas-edge tunnel client.
|
|
9
|
+
*
|
|
10
|
+
* Dials out to a canvas-server instance, announces what this runtime hosts,
|
|
11
|
+
* and replays proxied requests into the local fastify app (`app.inject()`),
|
|
12
|
+
* so the tunnel and localhost serve identical APIs by construction.
|
|
13
|
+
* Reconnection/backoff and heartbeat come from socket.io.
|
|
14
|
+
*
|
|
15
|
+
* See docs/canvas-edge-protocol.md for the frame protocol.
|
|
16
|
+
*/
|
|
17
|
+
export default class EdgeClient {
|
|
18
|
+
#serverUrl;
|
|
19
|
+
#token;
|
|
20
|
+
#localApp;
|
|
21
|
+
#announce;
|
|
22
|
+
#socket = null;
|
|
23
|
+
#announced = false;
|
|
24
|
+
#forwarded = [];
|
|
25
|
+
|
|
26
|
+
constructor({ serverUrl, token, localApp, announce }) {
|
|
27
|
+
if (!serverUrl || !token || !localApp?.inject || !announce?.instanceId) {
|
|
28
|
+
throw new Error('EdgeClient requires serverUrl, token, localApp (fastify) and announce.instanceId');
|
|
29
|
+
}
|
|
30
|
+
this.#serverUrl = serverUrl.replace(/\/+$/, '');
|
|
31
|
+
this.#token = token;
|
|
32
|
+
this.#localApp = localApp;
|
|
33
|
+
this.#announce = announce;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One-time pairing: exchange a user API token for a device token via the
|
|
38
|
+
* existing device registration endpoint. Persist the result locally and
|
|
39
|
+
* never touch the user token again.
|
|
40
|
+
*/
|
|
41
|
+
static async pair({ serverUrl, userToken, name, type = 'edge', ...deviceInfo }) {
|
|
42
|
+
const res = await fetch(`${serverUrl.replace(/\/+$/, '')}/rest/v2/auth/devices/register`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userToken}` },
|
|
45
|
+
body: JSON.stringify({ name, type, ...deviceInfo }),
|
|
46
|
+
});
|
|
47
|
+
const json = await res.json().catch(() => null);
|
|
48
|
+
if (!res.ok || !json?.payload?.token) {
|
|
49
|
+
throw new Error(`Edge pairing failed: ${json?.message || res.statusText}`);
|
|
50
|
+
}
|
|
51
|
+
return { token: json.payload.token, deviceId: json.payload.deviceId || json.payload.id };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get connected() {
|
|
55
|
+
return this.#socket?.connected === true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
connect() {
|
|
59
|
+
if (this.#socket) return this;
|
|
60
|
+
this.#socket = io(this.#serverUrl, {
|
|
61
|
+
auth: { token: this.#token },
|
|
62
|
+
transports: ['websocket'],
|
|
63
|
+
});
|
|
64
|
+
// Announce on every (re)connect — announce is idempotent full state.
|
|
65
|
+
this.#socket.on('connect', () => this.#socket.emit('edge:announce', this.#announce));
|
|
66
|
+
this.#socket.on('edge:announced', () => { this.#announced = true; });
|
|
67
|
+
this.#socket.on('disconnect', () => { this.#announced = false; });
|
|
68
|
+
this.#socket.on('edge:req', (frame) => this.handleRequest(frame));
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Resolves once the server has acked the announce; rejects on timeout. */
|
|
73
|
+
waitForAnnounce(timeoutMs = 10_000) {
|
|
74
|
+
if (this.#announced) return Promise.resolve();
|
|
75
|
+
if (!this.#socket) return Promise.reject(new Error('not connected — call connect() first'));
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
this.#socket?.off('edge:announced', onAck);
|
|
79
|
+
reject(new Error(`edge announce not acked within ${timeoutMs}ms`));
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
const onAck = () => { clearTimeout(timer); resolve(); };
|
|
82
|
+
this.#socket.once('edge:announced', onAck);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Dispatch a proxied request into the local app and stream the answer back.
|
|
88
|
+
* `socket` is injectable for tests.
|
|
89
|
+
*/
|
|
90
|
+
async handleRequest(frame = {}, socket = this.#socket) {
|
|
91
|
+
const { id, method, path, headers = {}, body, bodyEncoding } = frame;
|
|
92
|
+
if (!id || !method || !path) return;
|
|
93
|
+
try {
|
|
94
|
+
const payload = body == null
|
|
95
|
+
? undefined
|
|
96
|
+
: (bodyEncoding === 'base64' ? Buffer.from(body, 'base64') : body);
|
|
97
|
+
const res = await this.#localApp.inject({ method, url: path, headers, payload });
|
|
98
|
+
socket.emit('edge:res', { id, status: res.statusCode, headers: res.headers });
|
|
99
|
+
const buf = res.rawPayload;
|
|
100
|
+
for (let offset = 0, seq = 0; offset < buf.length; offset += CHUNK_SIZE, seq++) {
|
|
101
|
+
socket.emit('edge:chunk', { id, seq, data: buf.subarray(offset, offset + CHUNK_SIZE).toString('base64') });
|
|
102
|
+
}
|
|
103
|
+
socket.emit('edge:end', { id });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
socket.emit('edge:err', { id, code: 'EDGE_DISPATCH_FAILED', message: err.message });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Relay all events from a local wildcard emitter (EventEmitter2) up the
|
|
111
|
+
* tunnel; the server re-emits them through its WorkspaceManager.
|
|
112
|
+
*/
|
|
113
|
+
forwardEvents(emitter) {
|
|
114
|
+
const socket = () => this.#socket;
|
|
115
|
+
const listener = function (payload) {
|
|
116
|
+
socket()?.emit('edge:event', { name: this.event, payload });
|
|
117
|
+
};
|
|
118
|
+
emitter.on('**', listener);
|
|
119
|
+
this.#forwarded.push([emitter, listener]);
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
close() {
|
|
124
|
+
for (const [emitter, listener] of this.#forwarded) emitter.off('**', listener);
|
|
125
|
+
this.#forwarded = [];
|
|
126
|
+
this.#socket?.disconnect();
|
|
127
|
+
this.#socket = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/control.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import Fastify from 'fastify';
|
|
5
|
+
import { EDGE_PATHS } from './env.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Local control API for the CLI (unix socket, 0600; a localhost port on
|
|
9
|
+
* Windows). Nothing here is reachable from the network.
|
|
10
|
+
*/
|
|
11
|
+
export async function startControl({ runtimes, reload, shutdown, logger }) {
|
|
12
|
+
const app = Fastify({ logger: false });
|
|
13
|
+
const byId = (id) => runtimes.get(id) || [...runtimes.values()].find((r) => r.status().workspace === id) || null;
|
|
14
|
+
|
|
15
|
+
// fuse units read `canvas-fuse status --json` (a process); refresh them before answering.
|
|
16
|
+
const snapshot = async () => {
|
|
17
|
+
for (const r of runtimes.values()) if (typeof r.refresh === 'function') await r.refresh().catch(() => null);
|
|
18
|
+
return [...runtimes.values()].map((r) => r.status());
|
|
19
|
+
};
|
|
20
|
+
app.get('/status', async () => ({ pid: process.pid, mirrors: await snapshot() }));
|
|
21
|
+
app.get('/mirrors', async () => snapshot());
|
|
22
|
+
app.post('/mirrors/:id/resync', async (req, reply) => {
|
|
23
|
+
const r = byId(req.params.id);
|
|
24
|
+
if (!r) return reply.code(404).send({ error: 'no such mirror' });
|
|
25
|
+
await r.resync();
|
|
26
|
+
return r.status();
|
|
27
|
+
});
|
|
28
|
+
app.post('/mirrors/:id/nudge', async (req, reply) => {
|
|
29
|
+
const r = byId(req.params.id);
|
|
30
|
+
if (!r) return reply.code(404).send({ error: 'no such mirror' });
|
|
31
|
+
r.nudge();
|
|
32
|
+
return { ok: true };
|
|
33
|
+
});
|
|
34
|
+
app.post('/reload', async () => { await reload(); return { ok: true, mirrors: runtimes.size }; });
|
|
35
|
+
app.post('/shutdown', async () => { setTimeout(() => shutdown(), 50); return { ok: true }; });
|
|
36
|
+
|
|
37
|
+
if (EDGE_PATHS.socket) {
|
|
38
|
+
fs.mkdirSync(EDGE_PATHS.run, { recursive: true });
|
|
39
|
+
try { fs.unlinkSync(EDGE_PATHS.socket); } catch { /* absent */ }
|
|
40
|
+
await app.listen({ path: EDGE_PATHS.socket });
|
|
41
|
+
fs.chmodSync(EDGE_PATHS.socket, 0o600);
|
|
42
|
+
logger.info({ socket: EDGE_PATHS.socket }, 'control socket ready');
|
|
43
|
+
} else {
|
|
44
|
+
await app.listen({ host: '127.0.0.1', port: EDGE_PATHS.port });
|
|
45
|
+
logger.info({ port: EDGE_PATHS.port }, 'control port ready');
|
|
46
|
+
}
|
|
47
|
+
return app;
|
|
48
|
+
}
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import pino from 'pino';
|
|
6
|
+
import { EDGE_PATHS, STATE_ROOT, daemonMirrors, deviceIdentity, ensureDeviceToken, ensureEnvConfig, fuseMirrors, hubFor } from './env.js';
|
|
7
|
+
import { MirrorRuntime } from './mirror-runtime.js';
|
|
8
|
+
import { FuseRuntime } from './fuse-runtime.js';
|
|
9
|
+
import { startControl } from './control.js';
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* canvas-edge: the device-side daemon for real-folder mirrors (the non-FUSE
|
|
13
|
+
* client of the sync protocol, for macOS/Windows and anyone who wants a plain
|
|
14
|
+
* folder). One process per device runs every `client: 'daemon'` mirror from
|
|
15
|
+
* ~/.canvas/config/mirrors.json; the CLI (`canvas mirror`) writes that file and
|
|
16
|
+
* talks to this process over the control socket.
|
|
17
|
+
*/
|
|
18
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
19
|
+
const foreground = argv.includes('--foreground') || argv.includes('-f');
|
|
20
|
+
fs.mkdirSync(path.dirname(EDGE_PATHS.log), { recursive: true });
|
|
21
|
+
const logger = foreground
|
|
22
|
+
? pino({ level: process.env.LOG_LEVEL || 'info' })
|
|
23
|
+
: pino({ level: process.env.LOG_LEVEL || 'info' }, pino.destination({ dest: EDGE_PATHS.log, sync: false }));
|
|
24
|
+
|
|
25
|
+
const runtimes = new Map();
|
|
26
|
+
|
|
27
|
+
// Container / env-driven setup: one remote + one mirror from the environment.
|
|
28
|
+
try {
|
|
29
|
+
const seeded = ensureEnvConfig();
|
|
30
|
+
if (seeded) logger.info({ mirror: seeded.id, folder: seeded.mountpoint, direction: seeded.direction, stateRoot: STATE_ROOT }, 'mirror configured from environment');
|
|
31
|
+
} catch (err) {
|
|
32
|
+
logger.error({ err: err?.message }, 'env-driven mirror config failed');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const load = async () => {
|
|
36
|
+
const wanted = daemonMirrors();
|
|
37
|
+
const fuse = fuseMirrors();
|
|
38
|
+
const wantedIds = new Set([...wanted, ...fuse].map((m) => m.id));
|
|
39
|
+
for (const [id, rt] of runtimes) {
|
|
40
|
+
if (!wantedIds.has(id)) { await rt.stop().catch(() => {}); runtimes.delete(id); logger.info({ mirror: id }, 'mirror stopped (removed from config)'); }
|
|
41
|
+
}
|
|
42
|
+
for (const mirror of wanted) {
|
|
43
|
+
if (runtimes.has(mirror.id)) continue;
|
|
44
|
+
let hub = hubFor(mirror.remote);
|
|
45
|
+
if (!hub?.token) { logger.warn({ mirror: mirror.id, remote: mirror.remote }, 'no credentials for hub — skipped'); continue; }
|
|
46
|
+
if (!hub.deviceId) {
|
|
47
|
+
try { hub = await ensureDeviceToken(mirror.remote, { logger }); }
|
|
48
|
+
catch (err) { logger.warn({ mirror: mirror.id, remote: mirror.remote, err: err?.message }, 'device registration failed — reporting under the user token'); }
|
|
49
|
+
}
|
|
50
|
+
const identity = deviceIdentity(hub);
|
|
51
|
+
const rt = new MirrorRuntime({ mirror, hub, identity, logger });
|
|
52
|
+
try {
|
|
53
|
+
await rt.start();
|
|
54
|
+
runtimes.set(mirror.id, rt);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
logger.error({ mirror: mirror.id, err: err?.message }, 'mirror failed to start');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// fuse units: canvas-fuse mounts this daemon supervises (one process per workspace).
|
|
60
|
+
for (const mirror of fuse) {
|
|
61
|
+
if (runtimes.has(mirror.id)) continue;
|
|
62
|
+
const rt = new FuseRuntime({ mirror, logger });
|
|
63
|
+
try {
|
|
64
|
+
await rt.start();
|
|
65
|
+
runtimes.set(mirror.id, rt);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
logger.error({ unit: mirror.id, err: err?.message }, 'fuse unit failed to start');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
let stopping = false;
|
|
73
|
+
const shutdown = async () => {
|
|
74
|
+
if (stopping) return;
|
|
75
|
+
stopping = true;
|
|
76
|
+
logger.info('shutting down');
|
|
77
|
+
for (const rt of runtimes.values()) await rt.stop().catch(() => {});
|
|
78
|
+
await control?.close().catch(() => {});
|
|
79
|
+
process.exit(0);
|
|
80
|
+
};
|
|
81
|
+
process.on('SIGTERM', shutdown);
|
|
82
|
+
process.on('SIGINT', shutdown);
|
|
83
|
+
process.on('SIGHUP', () => load().catch((err) => logger.error({ err: err?.message }, 'reload failed')));
|
|
84
|
+
|
|
85
|
+
const control = await startControl({ runtimes, reload: load, shutdown, logger });
|
|
86
|
+
await load();
|
|
87
|
+
logger.info({ device: deviceIdentity().deviceId, mirrors: runtimes.size, fuse: [...runtimes.values()].filter((r) => r.unit === 'fuse').length }, 'canvas-edge running');
|
|
88
|
+
}
|
package/src/env.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import EdgeClient from './EdgeClient.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* The daemon's own home. Deliberately NOT src/env.js: there CANVAS_USER_HOME
|
|
10
|
+
* means the server's users root, while for every client (cli, canvas-fuse,
|
|
11
|
+
* desktop) it is the per-user config home (~/.canvas). The daemon is a client.
|
|
12
|
+
*/
|
|
13
|
+
export const EDGE_HOME = process.env.CANVAS_EDGE_HOME
|
|
14
|
+
|| process.env.CANVAS_USER_HOME
|
|
15
|
+
|| (process.platform === 'win32' ? path.join(os.homedir(), 'Canvas') : path.join(os.homedir(), '.canvas'));
|
|
16
|
+
|
|
17
|
+
export const EDGE_PATHS = Object.freeze({
|
|
18
|
+
home: EDGE_HOME,
|
|
19
|
+
mirrors: path.join(EDGE_HOME, 'config', 'mirrors.json'),
|
|
20
|
+
remotes: path.join(EDGE_HOME, 'config', 'remotes.json'),
|
|
21
|
+
device: process.env.CANVAS_DEVICE_FILE || (process.platform === 'win32'
|
|
22
|
+
? path.join(os.homedir(), 'Canvas', 'device.json')
|
|
23
|
+
: path.join(os.homedir(), '.canvas', 'device.json')),
|
|
24
|
+
run: path.join(EDGE_HOME, 'run'),
|
|
25
|
+
socket: process.platform === 'win32' ? null : path.join(EDGE_HOME, 'run', 'edge.sock'),
|
|
26
|
+
port: Number(process.env.CANVAS_EDGE_PORT) || 8802,
|
|
27
|
+
log: path.join(EDGE_HOME, 'var', 'log', 'canvas-edge.log'),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export function readJson(file, fallback = null) {
|
|
31
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeJson(file, value) {
|
|
35
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* External state (docs/durable-workspaces.md, step 4). A mirror keeps its
|
|
41
|
+
* ledger, queue, cache, trash and conflicts under `<folder>/.workspace/` by
|
|
42
|
+
* default. A backup target (a NAS share) should hold user files only, so a
|
|
43
|
+
* mirror may name a `stateDir`, or the daemon may be given a root under
|
|
44
|
+
* which every mirror gets `<root>/<mirror id>` (the container sets
|
|
45
|
+
* CANVAS_EDGE_STATE_ROOT=/state).
|
|
46
|
+
*/
|
|
47
|
+
export const STATE_ROOT = process.env.CANVAS_EDGE_STATE_ROOT ? path.resolve(process.env.CANVAS_EDGE_STATE_ROOT) : null;
|
|
48
|
+
|
|
49
|
+
export function resolveStateDir(mirror, stateRoot = STATE_ROOT) {
|
|
50
|
+
if (mirror?.stateDir) return path.resolve(String(mirror.stateDir));
|
|
51
|
+
if (stateRoot && mirror?.id) return path.join(stateRoot, String(mirror.id).replace(/[^a-zA-Z0-9._@-]+/g, '_'));
|
|
52
|
+
return null; // null = inside the folder (`<folder>/.workspace`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* Env-driven single-mirror setup (the container's way in): with
|
|
57
|
+
* CANVAS_HUB_URL + CANVAS_HUB_TOKEN + CANVAS_WORKSPACE set, the daemon writes
|
|
58
|
+
* (or refreshes) one remote and one daemon mirror in its own config files
|
|
59
|
+
* before loading them. Idempotent: matched by id, only env-driven fields are
|
|
60
|
+
* overwritten, everything else the files carry is kept.
|
|
61
|
+
*
|
|
62
|
+
* CANVAS_HUB_URL https://canvas.example.org
|
|
63
|
+
* CANVAS_HUB_TOKEN user/API, device or workspace token
|
|
64
|
+
* CANVAS_HUB_ID remote id (default "hub")
|
|
65
|
+
* CANVAS_WORKSPACE workspace name (as in the hub URL)
|
|
66
|
+
* CANVAS_MIRROR_PATH folder to mirror (default /data)
|
|
67
|
+
* CANVAS_DIRECTION bi | pull | push (default bi)
|
|
68
|
+
* CANVAS_CONFLICTS prompt | rename CANVAS_DELETES propagate | keep
|
|
69
|
+
* CANVAS_PINS / CANVAS_IGNORE comma-separated globs
|
|
70
|
+
*/
|
|
71
|
+
export function ensureEnvConfig(env = process.env) {
|
|
72
|
+
const url = env.CANVAS_HUB_URL && String(env.CANVAS_HUB_URL).trim().replace(/\/+$/, '');
|
|
73
|
+
const token = env.CANVAS_HUB_TOKEN && String(env.CANVAS_HUB_TOKEN).trim();
|
|
74
|
+
const workspace = env.CANVAS_WORKSPACE && String(env.CANVAS_WORKSPACE).trim();
|
|
75
|
+
if (!url || !token || !workspace) return null;
|
|
76
|
+
const hubId = (env.CANVAS_HUB_ID && String(env.CANVAS_HUB_ID).trim()) || 'hub';
|
|
77
|
+
const list = (v) => String(v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
78
|
+
const oneOf = (v, allowed, fallback) => (allowed.includes(String(v ?? '').trim()) ? String(v).trim() : fallback);
|
|
79
|
+
|
|
80
|
+
const remotes = readJson(EDGE_PATHS.remotes, {}) || {};
|
|
81
|
+
const prior = remotes[hubId] || {};
|
|
82
|
+
// A device token minted for this remote stays authoritative; env only
|
|
83
|
+
// seeds the user/API token it started from.
|
|
84
|
+
remotes[hubId] = { ...prior, url, apiBase: prior.apiBase || '/rest/v2', auth: { ...(prior.auth || {}), token }, source: 'env' };
|
|
85
|
+
writeJson(EDGE_PATHS.remotes, remotes);
|
|
86
|
+
|
|
87
|
+
const cfg = readJson(EDGE_PATHS.mirrors, { version: 1, mirrors: [] }) || { version: 1, mirrors: [] };
|
|
88
|
+
const mirrors = Array.isArray(cfg.mirrors) ? cfg.mirrors : [];
|
|
89
|
+
const id = `${hubId}/${workspace.toLowerCase()}`;
|
|
90
|
+
const existing = mirrors.find((m) => m?.id === id) || {};
|
|
91
|
+
const mirror = {
|
|
92
|
+
...existing,
|
|
93
|
+
id,
|
|
94
|
+
remote: hubId,
|
|
95
|
+
workspaceId: existing.workspaceId || null,
|
|
96
|
+
workspaceName: workspace.toLowerCase(),
|
|
97
|
+
folderName: existing.folderName || workspace,
|
|
98
|
+
mountpoint: path.resolve(env.CANVAS_MIRROR_PATH ? String(env.CANVAS_MIRROR_PATH) : (existing.mountpoint || '/data')),
|
|
99
|
+
pins: env.CANVAS_PINS != null ? list(env.CANVAS_PINS) : (existing.pins || []),
|
|
100
|
+
ignore: env.CANVAS_IGNORE != null ? list(env.CANVAS_IGNORE) : (existing.ignore || []),
|
|
101
|
+
conflicts: oneOf(env.CANVAS_CONFLICTS, ['prompt', 'rename'], existing.conflicts || 'prompt'),
|
|
102
|
+
deletes: oneOf(env.CANVAS_DELETES, ['propagate', 'keep'], existing.deletes || 'propagate'),
|
|
103
|
+
direction: oneOf(env.CANVAS_DIRECTION, ['bi', 'pull', 'push'], existing.direction || 'bi'),
|
|
104
|
+
client: 'daemon',
|
|
105
|
+
managed: existing.managed || 'container',
|
|
106
|
+
paused: false,
|
|
107
|
+
createdAt: existing.createdAt || new Date().toISOString(),
|
|
108
|
+
source: 'env',
|
|
109
|
+
};
|
|
110
|
+
cfg.mirrors = [...mirrors.filter((m) => m?.id !== id), mirror];
|
|
111
|
+
writeJson(EDGE_PATHS.mirrors, { version: cfg.version || 1, ...cfg, mirrors: cfg.mirrors });
|
|
112
|
+
return mirror;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Who this daemon is towards a hub. A device token is minted for one device
|
|
117
|
+
* id, so when remotes.json holds one, THAT id is the identity (the hub rejects
|
|
118
|
+
* status reports under any other); device.json is the fallback for
|
|
119
|
+
* user/API tokens, then a stable host-derived id.
|
|
120
|
+
*/
|
|
121
|
+
export function deviceIdentity(hub = null) {
|
|
122
|
+
const rec = readJson(EDGE_PATHS.device, null);
|
|
123
|
+
const name = process.env.CANVAS_DEVICE_NAME || rec?.hostname || os.hostname();
|
|
124
|
+
if (hub?.deviceId) return { deviceId: String(hub.deviceId), deviceName: name };
|
|
125
|
+
// A container's hostname is random per run: pin the identity explicitly.
|
|
126
|
+
if (process.env.CANVAS_DEVICE_ID) return { deviceId: String(process.env.CANVAS_DEVICE_ID).replace(/[^a-zA-Z0-9._-]+/g, '-'), deviceName: name };
|
|
127
|
+
if (rec?.deviceId) return { deviceId: String(rec.deviceId), deviceName: name };
|
|
128
|
+
return { deviceId: `host-${os.hostname()}-${os.userInfo().username}`.replace(/[^a-zA-Z0-9._-]+/g, '-'), deviceName: name };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/*
|
|
132
|
+
* An env-seeded remote starts with a user/API token. The hub keys mirror
|
|
133
|
+
* status (and replica protection) by DEVICE, so on first contact the daemon
|
|
134
|
+
* registers itself (`POST /auth/devices/register`) under its CANVAS_DEVICE_ID
|
|
135
|
+
* and keeps the device token in remotes.json; from then on hubFor() presents
|
|
136
|
+
* that token and the hub sees one stable device. CLI-managed remotes already
|
|
137
|
+
* carry a device token and are left alone.
|
|
138
|
+
*/
|
|
139
|
+
export async function ensureDeviceToken(remoteId, { logger = null } = {}) {
|
|
140
|
+
const remotes = readJson(EDGE_PATHS.remotes, {}) || {};
|
|
141
|
+
const r = remotes[remoteId];
|
|
142
|
+
if (!r?.url || r.source !== 'env' || r.device?.token || !r.auth?.token) return hubFor(remoteId);
|
|
143
|
+
const identity = deviceIdentity(null);
|
|
144
|
+
const paired = await EdgeClient.pair({
|
|
145
|
+
serverUrl: r.url, userToken: r.auth.token, name: identity.deviceName, type: 'edge',
|
|
146
|
+
deviceId: identity.deviceId, hostname: os.hostname(), platform: process.platform, arch: process.arch,
|
|
147
|
+
});
|
|
148
|
+
remotes[remoteId] = { ...r, device: { deviceId: paired.deviceId || identity.deviceId, token: paired.token, registeredAt: new Date().toISOString() } };
|
|
149
|
+
writeJson(EDGE_PATHS.remotes, remotes);
|
|
150
|
+
logger?.info?.({ remote: remoteId, deviceId: remotes[remoteId].device.deviceId }, 'registered as a device on the hub');
|
|
151
|
+
return hubFor(remoteId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Hub url + token (+ the device id the token belongs to) for a remote id. */
|
|
155
|
+
export function hubFor(remoteId) {
|
|
156
|
+
const remotes = readJson(EDGE_PATHS.remotes, {}) || {};
|
|
157
|
+
const r = remotes[remoteId];
|
|
158
|
+
if (!r?.url) return null;
|
|
159
|
+
const deviceToken = r.device?.token || null;
|
|
160
|
+
const token = deviceToken || r.auth?.token || null;
|
|
161
|
+
return {
|
|
162
|
+
id: remoteId,
|
|
163
|
+
url: String(r.url).replace(/\/+$/, ''),
|
|
164
|
+
apiBase: r.apiBase || '/rest/v2',
|
|
165
|
+
token,
|
|
166
|
+
deviceId: deviceToken && r.device?.deviceId ? String(r.device.deviceId) : null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** FUSE mounts this daemon supervises: `client: 'fuse', managed: 'edge'` entries (docs/durable-workspaces.md step 5). */
|
|
171
|
+
export function fuseMirrors() {
|
|
172
|
+
const cfg = readJson(EDGE_PATHS.mirrors, { mirrors: [] }) || {};
|
|
173
|
+
return (Array.isArray(cfg.mirrors) ? cfg.mirrors : []).filter((m) => m && m.client === 'fuse' && m.managed === 'edge' && !m.paused);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Mirrors this daemon owns: `client: 'daemon'` entries of the CLI's mirrors.json. */
|
|
177
|
+
export function daemonMirrors() {
|
|
178
|
+
const cfg = readJson(EDGE_PATHS.mirrors, { mirrors: [] }) || {};
|
|
179
|
+
return (Array.isArray(cfg.mirrors) ? cfg.mirrors : [])
|
|
180
|
+
.filter((m) => m && m.client === 'daemon' && !m.paused)
|
|
181
|
+
.map((m) => ({ ...m, stateDir: resolveStateDir(m) }));
|
|
182
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { execFile, spawn } from 'node:child_process';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A `fuse` unit: one canvas-fuse mirror mount supervised by this daemon
|
|
13
|
+
* (docs/durable-workspaces.md, step 5). canvas-fuse stays a plain executable —
|
|
14
|
+
* the on-demand namespace, content cache, pins and LRU are its own; this class
|
|
15
|
+
* only spawns `canvas-fuse mount … --mirror` attached, restarts it with backoff
|
|
16
|
+
* when it dies, reads `canvas-fuse status --json` for its state and unmounts
|
|
17
|
+
* on stop. One process per workspace under a common root (`<root>/<folder>`),
|
|
18
|
+
* several workspaces = several units in mirrors.json (`client: 'fuse',
|
|
19
|
+
* managed: 'edge'`). Credentials never touch the command line: the mount
|
|
20
|
+
* resolves `--remote <id>` from ~/.canvas/config/remotes.json, like the CLI.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULT_RESTART_DELAYS_MS = [5_000, 10_000, 20_000, 40_000, 60_000];
|
|
24
|
+
const STATUS_TTL_MS = 5_000;
|
|
25
|
+
|
|
26
|
+
export function fuseBinary(env = process.env) {
|
|
27
|
+
const candidates = [env.CANVAS_FUSE_BIN, path.join(os.homedir(), '.cargo', 'bin', 'canvas-fuse')].filter(Boolean);
|
|
28
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
29
|
+
return 'canvas-fuse'; // PATH lookup at spawn time
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Command line for one mirror entry (attached: no `-d`, the daemon owns the lifecycle). */
|
|
33
|
+
export function fuseMountArgs(mirror) {
|
|
34
|
+
if (!mirror?.root) throw new Error(`fuse unit ${mirror?.id || '?'}: mirror.root is required`);
|
|
35
|
+
if (!mirror?.remote) throw new Error(`fuse unit ${mirror?.id || '?'}: mirror.remote is required`);
|
|
36
|
+
const args = ['mount', '-w', mirror.folderName || mirror.workspaceName, mirror.root, '--remote', mirror.remote, '--mirror'];
|
|
37
|
+
for (const pin of mirror.pins || []) args.push('--pin', pin);
|
|
38
|
+
for (const glob of mirror.ignore || []) args.push('--ignore', glob);
|
|
39
|
+
if (mirror.conflicts) args.push('--conflicts', mirror.conflicts);
|
|
40
|
+
if (mirror.deletes) args.push('--deletes', mirror.deletes);
|
|
41
|
+
if (mirror.cacheBudgetMb) args.push('--cache-budget-mb', String(mirror.cacheBudgetMb));
|
|
42
|
+
return args;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class FuseRuntime {
|
|
46
|
+
#mirror;
|
|
47
|
+
#logger;
|
|
48
|
+
#bin;
|
|
49
|
+
#child = null;
|
|
50
|
+
#stopping = false;
|
|
51
|
+
#restartTimer = null;
|
|
52
|
+
#restarts = 0;
|
|
53
|
+
#attempt = 0;
|
|
54
|
+
#startedAt = null;
|
|
55
|
+
#lastExit = null;
|
|
56
|
+
#restartDelays;
|
|
57
|
+
#statusCache = null;
|
|
58
|
+
|
|
59
|
+
constructor({ mirror, logger, binary = null, restartDelaysMs = DEFAULT_RESTART_DELAYS_MS }) {
|
|
60
|
+
this.#mirror = mirror;
|
|
61
|
+
this.#logger = logger;
|
|
62
|
+
this.#bin = binary || fuseBinary();
|
|
63
|
+
this.#restartDelays = restartDelaysMs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get id() { return this.#mirror.id; }
|
|
67
|
+
get unit() { return 'fuse'; }
|
|
68
|
+
get folder() { return this.#mirror.mountpoint || path.join(this.#mirror.root, this.#mirror.folderName || this.#mirror.workspaceName); }
|
|
69
|
+
get running() { return !!this.#child && this.#child.exitCode == null && !this.#child.killed; }
|
|
70
|
+
get pid() { return this.running ? this.#child.pid : null; }
|
|
71
|
+
|
|
72
|
+
async start() {
|
|
73
|
+
this.#stopping = false;
|
|
74
|
+
await this.#spawn();
|
|
75
|
+
this.#logger.info({ unit: this.id, mountpoint: this.folder, bin: this.#bin }, 'fuse unit started');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async #spawn() {
|
|
79
|
+
const args = fuseMountArgs(this.#mirror);
|
|
80
|
+
const child = spawn(this.#bin, args, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
|
|
81
|
+
this.#child = child;
|
|
82
|
+
this.#startedAt = Date.now();
|
|
83
|
+
const log = (level) => (chunk) => { for (const line of String(chunk).split('\n')) if (line.trim()) this.#logger[level]?.({ unit: this.id }, line.trim()); };
|
|
84
|
+
child.stdout?.on('data', log('debug'));
|
|
85
|
+
child.stderr?.on('data', log('debug'));
|
|
86
|
+
await new Promise((resolve, reject) => {
|
|
87
|
+
child.once('spawn', resolve);
|
|
88
|
+
child.once('error', (err) => reject(Object.assign(new Error(err.code === 'ENOENT' ? `canvas-fuse binary not found (${this.#bin}); install it or set CANVAS_FUSE_BIN` : err.message), { code: err.code })));
|
|
89
|
+
});
|
|
90
|
+
child.on('exit', (code, signal) => {
|
|
91
|
+
if (this.#child !== child) return;
|
|
92
|
+
this.#child = null;
|
|
93
|
+
this.#lastExit = { code, signal, at: Date.now() };
|
|
94
|
+
this.#statusCache = null;
|
|
95
|
+
if (this.#stopping) return;
|
|
96
|
+
// A mount that stayed up a while starts the backoff over.
|
|
97
|
+
if (Date.now() - this.#startedAt > 60_000) this.#attempt = 0;
|
|
98
|
+
const delay = this.#restartDelays[Math.min(this.#attempt, this.#restartDelays.length - 1)];
|
|
99
|
+
this.#attempt += 1;
|
|
100
|
+
this.#restarts += 1;
|
|
101
|
+
this.#logger.warn({ unit: this.id, code, signal, restartInMs: delay }, 'fuse mount exited — restarting');
|
|
102
|
+
this.#restartTimer = setTimeout(() => {
|
|
103
|
+
this.#restartTimer = null;
|
|
104
|
+
this.#spawn().catch((err) => this.#logger.error({ unit: this.id, err: err?.message }, 'fuse restart failed'));
|
|
105
|
+
}, delay);
|
|
106
|
+
this.#restartTimer.unref?.();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Graceful: SIGINT (canvas-fuse unmounts on it), then SIGTERM, then a best-effort `unmount`. */
|
|
111
|
+
async stop({ timeoutMs = 10_000 } = {}) {
|
|
112
|
+
this.#stopping = true;
|
|
113
|
+
if (this.#restartTimer) { clearTimeout(this.#restartTimer); this.#restartTimer = null; }
|
|
114
|
+
const child = this.#child;
|
|
115
|
+
if (child && child.exitCode == null) {
|
|
116
|
+
const exited = new Promise((resolve) => child.once('exit', resolve));
|
|
117
|
+
child.kill('SIGINT');
|
|
118
|
+
const timer = new Promise((resolve) => setTimeout(resolve, timeoutMs));
|
|
119
|
+
if (await Promise.race([exited.then(() => true), timer.then(() => false)]) === false) {
|
|
120
|
+
child.kill('SIGTERM');
|
|
121
|
+
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 3_000))]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
this.#child = null;
|
|
125
|
+
await this.#run(['unmount', this.folder], { timeout: 15_000 }).catch(() => null);
|
|
126
|
+
this.#logger.info({ unit: this.id, mountpoint: this.folder }, 'fuse unit stopped');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async #run(args, { timeout = 30_000 } = {}) {
|
|
130
|
+
const { stdout } = await execFileAsync(this.#bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
|
|
131
|
+
return String(stdout || '');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The mount's own account (`canvas-fuse status --json` row for this mountpoint), cached briefly. */
|
|
135
|
+
async mountStatus() {
|
|
136
|
+
if (this.#statusCache && Date.now() - this.#statusCache.at < STATUS_TTL_MS) return this.#statusCache.row;
|
|
137
|
+
let row = null;
|
|
138
|
+
try {
|
|
139
|
+
const parsed = JSON.parse(await this.#run(['status', '--json'], { timeout: 15_000 }) || '[]');
|
|
140
|
+
const rows = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.mounts) ? parsed.mounts : []);
|
|
141
|
+
const target = path.resolve(this.folder);
|
|
142
|
+
row = rows.find((r) => path.resolve(r?.mountpoint || '') === target) || null;
|
|
143
|
+
} catch { row = null; }
|
|
144
|
+
this.#statusCache = { at: Date.now(), row };
|
|
145
|
+
return row;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Sync snapshot; `status()` is what the control API and the CLI show. */
|
|
149
|
+
status() {
|
|
150
|
+
const row = this.#statusCache?.row || null;
|
|
151
|
+
return {
|
|
152
|
+
id: this.id,
|
|
153
|
+
unit: 'fuse',
|
|
154
|
+
workspace: this.#mirror.workspaceName,
|
|
155
|
+
hub: this.#mirror.remote,
|
|
156
|
+
folder: this.folder,
|
|
157
|
+
pins: this.#mirror.pins || [],
|
|
158
|
+
conflictsMode: this.#mirror.conflicts,
|
|
159
|
+
direction: 'bi',
|
|
160
|
+
pid: this.pid,
|
|
161
|
+
running: this.running,
|
|
162
|
+
restarts: this.#restarts,
|
|
163
|
+
startedAt: this.#startedAt,
|
|
164
|
+
lastExit: this.#lastExit,
|
|
165
|
+
mount: row ? (row.status || (row.mounted ? 'ok' : 'down')) : (this.running ? 'starting' : 'down'),
|
|
166
|
+
...(row?.mirror && typeof row.mirror === 'object' ? row.mirror : {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async refresh() { await this.mountStatus(); return this.status(); }
|
|
171
|
+
nudge() { this.#run(['sync', 'now', this.folder]).catch(() => null); }
|
|
172
|
+
async resync() { await this.#run(['sync', 'now', this.folder]).catch(() => null); return this.status(); }
|
|
173
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @augmentd-labs/canvas-edge — the device-side Canvas runtime.
|
|
5
|
+
*
|
|
6
|
+
* Two jobs today, one process (`bin/canvas-edge`):
|
|
7
|
+
* mirror — keep real folders in sync with hub workspaces (canvas-stored's
|
|
8
|
+
* Mirror engine; state under <folder>/.workspace/)
|
|
9
|
+
* tunnel — EdgeClient dials out to a canvas-server, announces what this
|
|
10
|
+
* runtime hosts and replays proxied requests into a local fastify
|
|
11
|
+
* app, so a remote workspace/agent behaves as if server-local
|
|
12
|
+
*
|
|
13
|
+
* Zero canvas-server imports on purpose: the same package backs `ws`,
|
|
14
|
+
* canvas-agentd and the desktop sidecar. The hub side of the tunnel
|
|
15
|
+
* (EdgeRegistry) lives in canvas-server. Protocol: canvas-server
|
|
16
|
+
* docs/canvas-edge-protocol.md; sync wire contract: docs/sync-protocol.md.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { default as EdgeClient } from './EdgeClient.js';
|
|
20
|
+
export { connectRemotes, buildAnnounce } from './runtime.js';
|
|
21
|
+
export { readWorkspaceConfig, listRemotes, saveRemote, removeRemote } from './remote-config.js';
|
|
22
|
+
export { EDGE_HOME, EDGE_PATHS, deviceIdentity, hubFor, daemonMirrors, fuseMirrors } from './env.js';
|
|
23
|
+
export { MirrorRuntime } from './mirror-runtime.js';
|
|
24
|
+
export { FuseRuntime, fuseMountArgs, fuseBinary } from './fuse-runtime.js';
|
|
25
|
+
export { startControl } from './control.js';
|
|
26
|
+
export { main } from './daemon.js';
|