@augmentd-labs/canvas-edge 0.5.0 → 0.6.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.
|
@@ -1,25 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canvas-stored",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"author": "Jozef Melich <me@idnc.sk>",
|
|
8
8
|
"license": "AGPL-3.0-or-later",
|
|
9
|
-
"dependencies": {
|
|
10
|
-
"cacache": "^20.0.4",
|
|
11
|
-
"chokidar": "^5.0.0",
|
|
12
|
-
"debug": "^4.4.3",
|
|
13
|
-
"eventemitter2": "^6.4.9",
|
|
14
|
-
"exifr": "^7.1.3",
|
|
15
|
-
"file-type": "^21.3.3",
|
|
16
|
-
"fs-extra": "^11.3.4",
|
|
17
|
-
"image-size": "^2.0.2",
|
|
18
|
-
"imap": "^0.8.19",
|
|
19
|
-
"lmdb": "^3.5.2",
|
|
20
|
-
"music-metadata": "^11.9.0",
|
|
21
|
-
"picomatch": "^2.3.1"
|
|
22
|
-
},
|
|
9
|
+
"dependencies": {},
|
|
23
10
|
"engines": {
|
|
24
11
|
"node": ">=20.0.0"
|
|
25
12
|
}
|
|
@@ -288,6 +288,29 @@ export default class FileBackend extends StorageBackend {
|
|
|
288
288
|
return { key, size: stats.size };
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// Create-only placement: link(2) fails with EEXIST even for an unindexed
|
|
292
|
+
// file, directory or dangling symlink. Never use rename-over for this path.
|
|
293
|
+
async createFrom(key, srcPath) {
|
|
294
|
+
const dest = this.#resolvePath(key);
|
|
295
|
+
await fs.ensureDir(path.dirname(dest));
|
|
296
|
+
try {
|
|
297
|
+
await fs.link(srcPath, dest);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error.code !== 'EXDEV') throw error;
|
|
300
|
+
// Stage on the destination filesystem, then publish atomically.
|
|
301
|
+
const sibling = path.join(path.dirname(dest), `${TMP_DIR}-${process.pid}-${Math.random().toString(16).slice(2)}`);
|
|
302
|
+
try {
|
|
303
|
+
await fs.copyFile(srcPath, sibling);
|
|
304
|
+
await fs.link(sibling, dest);
|
|
305
|
+
} finally {
|
|
306
|
+
await fs.remove(sibling).catch(() => {});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const stat = await this.stat(key);
|
|
310
|
+
if (!stat) throw new Error(`Upload destination disappeared: ${key}`);
|
|
311
|
+
return stat;
|
|
312
|
+
}
|
|
313
|
+
|
|
291
314
|
// ── Retention ─────────────────────────────────────────────────────────
|
|
292
315
|
//
|
|
293
316
|
// Bytes an overwrite or delete is about to displace are kept for a while
|
|
@@ -399,8 +422,11 @@ export default class FileBackend extends StorageBackend {
|
|
|
399
422
|
|
|
400
423
|
async stat(key) {
|
|
401
424
|
const filePath = this.#resolvePath(key);
|
|
402
|
-
|
|
403
|
-
|
|
425
|
+
let stats;
|
|
426
|
+
try { stats = await fs.stat(filePath); } catch (error) {
|
|
427
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
404
430
|
if (!stats.isFile()) return null;
|
|
405
431
|
// dev/ino feed rename matching (same inode at a new path) and the
|
|
406
432
|
// liveness checks — stored keeps them in location metadata.
|
|
@@ -413,7 +439,6 @@ export default class FileBackend extends StorageBackend {
|
|
|
413
439
|
async *list(options = {}) {
|
|
414
440
|
const { prefix = '', recursive = true, onError = null } = options;
|
|
415
441
|
const searchPath = this.#resolvePath(prefix);
|
|
416
|
-
if (!await fs.pathExists(searchPath)) return;
|
|
417
442
|
|
|
418
443
|
let entries;
|
|
419
444
|
try {
|
|
@@ -426,7 +451,13 @@ export default class FileBackend extends StorageBackend {
|
|
|
426
451
|
const relativePath = path.join(prefix, entry.name);
|
|
427
452
|
if (this.#isIgnored(relativePath)) continue;
|
|
428
453
|
if (entry.isFile()) {
|
|
429
|
-
|
|
454
|
+
try {
|
|
455
|
+
const stat = await this.stat(relativePath);
|
|
456
|
+
if (stat) yield stat;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (typeof onError !== 'function') throw error;
|
|
459
|
+
onError(relativePath, error);
|
|
460
|
+
}
|
|
430
461
|
} else if (entry.isDirectory() && recursive) {
|
|
431
462
|
yield* this.list({ ...options, prefix: relativePath });
|
|
432
463
|
}
|
|
@@ -417,7 +417,14 @@ export default class Stored extends EventEmitter2 {
|
|
|
417
417
|
try {
|
|
418
418
|
if (current) await this.#retain(backendName, destKey, current);
|
|
419
419
|
let placed = null;
|
|
420
|
-
if (
|
|
420
|
+
if (String(options.ifNoneMatch || '').trim() === '*') {
|
|
421
|
+
if (typeof backend.createFrom !== 'function') return { ok: false, reason: 'unsupported-backend' };
|
|
422
|
+
try { placed = await backend.createFrom(destKey, tempPath); }
|
|
423
|
+
catch (error) {
|
|
424
|
+
if (error.code === 'EEXIST') return { ok: false, reason: 'precondition-failed', current: null };
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
} else if (typeof backend.renameFrom === 'function') {
|
|
421
428
|
try { placed = await backend.renameFrom(destKey, tempPath); }
|
|
422
429
|
catch (err) { if (err.code !== 'EXDEV') throw err; }
|
|
423
430
|
}
|
|
@@ -968,6 +975,11 @@ export default class Stored extends EventEmitter2 {
|
|
|
968
975
|
const existing = this.#index.get(id);
|
|
969
976
|
const locations = existing?.locations || [];
|
|
970
977
|
const match = locations.find(l => l.backend === file.backend && l.key === file.key);
|
|
978
|
+
// Avoid LMDB/index/change-feed churn on warm scans. Still emit
|
|
979
|
+
// the row to consumers: their document index may need repair.
|
|
980
|
+
if (match && match.synced === true && match.size === file.size && match.mtime === file.modified
|
|
981
|
+
&& (file.ino == null || (match.ino === file.ino && match.dev === file.dev))
|
|
982
|
+
&& existing.size === file.size && existing.mimeType === file.mimeType) return;
|
|
971
983
|
if (match) {
|
|
972
984
|
match.size = file.size;
|
|
973
985
|
match.mtime = file.modified;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@augmentd-labs/canvas-edge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Canvas device runtime: folder mirrors (canvas-stored Mirror engine) + edge tunnel client (dist artifact)",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"node": ">=20.18.0"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@augmentd-labs/canvas-protocol": "^0.
|
|
24
|
-
"canvas-stored": "1.9.
|
|
23
|
+
"@augmentd-labs/canvas-protocol": "^0.5.0",
|
|
24
|
+
"canvas-stored": "1.9.3",
|
|
25
25
|
"cacache": "^20.0.4",
|
|
26
26
|
"chokidar": "^5.0.0",
|
|
27
27
|
"debug": "^4.4.3",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"fastify": "^4.29.1",
|
|
38
38
|
"pino": "^10.3.1",
|
|
39
39
|
"socket.io-client": "^4.8.3",
|
|
40
|
-
"@augmentd-labs/canvas-api-client": "^0.4.
|
|
40
|
+
"@augmentd-labs/canvas-api-client": "^0.4.1"
|
|
41
41
|
},
|
|
42
42
|
"repository": {
|
|
43
43
|
"type": "git",
|
|
@@ -47,6 +47,6 @@
|
|
|
47
47
|
"bundleDependencies": [
|
|
48
48
|
"canvas-stored"
|
|
49
49
|
],
|
|
50
|
-
"canvasRev": "
|
|
50
|
+
"canvasRev": "52fc07d",
|
|
51
51
|
"canvasSource": "runtimes/edge"
|
|
52
52
|
}
|
package/src/EdgeClient.js
CHANGED
|
@@ -1,137 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { createTlsTransport, resolveTls } from '@augmentd-labs/canvas-api-client/tls';
|
|
4
|
-
import { io } from 'socket.io-client';
|
|
5
|
-
|
|
6
|
-
const CHUNK_SIZE = 256 * 1024;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* canvas-edge tunnel client.
|
|
10
|
-
*
|
|
11
|
-
* Dials out to a canvas-server instance, announces what this runtime hosts,
|
|
12
|
-
* and replays proxied requests into the local fastify app (`app.inject()`),
|
|
13
|
-
* so the tunnel and localhost serve identical APIs by construction.
|
|
14
|
-
* Reconnection/backoff and heartbeat come from socket.io.
|
|
15
|
-
*
|
|
16
|
-
* See docs/canvas-edge-protocol.md for the frame protocol.
|
|
17
|
-
*/
|
|
18
|
-
export default class EdgeClient {
|
|
19
|
-
#transport;
|
|
20
|
-
#serverUrl;
|
|
21
|
-
#token;
|
|
22
|
-
#localApp;
|
|
23
|
-
#announce;
|
|
24
|
-
#socket = null;
|
|
25
|
-
#announced = false;
|
|
26
|
-
#forwarded = [];
|
|
27
|
-
|
|
28
|
-
constructor({ serverUrl, token, localApp, announce, tls }) {
|
|
29
|
-
if (!serverUrl || !token || !localApp?.inject || !announce?.instanceId) {
|
|
30
|
-
throw new Error('EdgeClient requires serverUrl, token, localApp (fastify) and announce.instanceId');
|
|
31
|
-
}
|
|
32
|
-
this.#transport = createTlsTransport(serverUrl, resolveTls(tls));
|
|
33
|
-
this.#serverUrl = serverUrl.replace(/\/+$/, '');
|
|
34
|
-
this.#token = token;
|
|
35
|
-
this.#localApp = localApp;
|
|
36
|
-
this.#announce = announce;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* One-time pairing: exchange a user API token for a device token via the
|
|
41
|
-
* existing device registration endpoint. Persist the result locally and
|
|
42
|
-
* never touch the user token again.
|
|
43
|
-
*/
|
|
44
|
-
static async pair({ serverUrl, userToken, name, type = 'edge', tls, ...deviceInfo }) {
|
|
45
|
-
const transport = createTlsTransport(serverUrl, resolveTls(tls));
|
|
46
|
-
try {
|
|
47
|
-
const res = await transport.fetch(`${serverUrl.replace(/\/+$/, '')}/rest/v2/auth/devices/register`, {
|
|
48
|
-
method: 'POST',
|
|
49
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userToken}` },
|
|
50
|
-
body: JSON.stringify({ name, type, ...deviceInfo }),
|
|
51
|
-
});
|
|
52
|
-
const json = await res.json().catch(() => null);
|
|
53
|
-
if (!res.ok || !json?.payload?.token) {
|
|
54
|
-
throw new Error(`Edge pairing failed: ${json?.message || res.statusText}`);
|
|
55
|
-
}
|
|
56
|
-
return { token: json.payload.token, deviceId: json.payload.deviceId || json.payload.id };
|
|
57
|
-
} finally { await transport.dispose(); }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
get connected() {
|
|
61
|
-
return this.#socket?.connected === true;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
connect() {
|
|
65
|
-
if (this.#socket) return this;
|
|
66
|
-
this.#socket = io(this.#serverUrl, {
|
|
67
|
-
...this.#transport.socketOptions,
|
|
68
|
-
auth: { token: this.#token },
|
|
69
|
-
transports: this.#transport.socketOptions.transports || ['websocket'],
|
|
70
|
-
});
|
|
71
|
-
// Announce on every (re)connect — announce is idempotent full state.
|
|
72
|
-
this.#socket.on('connect', () => this.#socket.emit('edge:announce', this.#announce));
|
|
73
|
-
this.#socket.on('edge:announced', () => { this.#announced = true; });
|
|
74
|
-
this.#socket.on('disconnect', () => { this.#announced = false; });
|
|
75
|
-
this.#socket.on('edge:req', (frame) => this.handleRequest(frame));
|
|
76
|
-
return this;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Resolves once the server has acked the announce; rejects on timeout. */
|
|
80
|
-
waitForAnnounce(timeoutMs = 10_000) {
|
|
81
|
-
if (this.#announced) return Promise.resolve();
|
|
82
|
-
if (!this.#socket) return Promise.reject(new Error('not connected — call connect() first'));
|
|
83
|
-
return new Promise((resolve, reject) => {
|
|
84
|
-
const timer = setTimeout(() => {
|
|
85
|
-
this.#socket?.off('edge:announced', onAck);
|
|
86
|
-
reject(new Error(`edge announce not acked within ${timeoutMs}ms`));
|
|
87
|
-
}, timeoutMs);
|
|
88
|
-
const onAck = () => { clearTimeout(timer); resolve(); };
|
|
89
|
-
this.#socket.once('edge:announced', onAck);
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Dispatch a proxied request into the local app and stream the answer back.
|
|
95
|
-
* `socket` is injectable for tests.
|
|
96
|
-
*/
|
|
97
|
-
async handleRequest(frame = {}, socket = this.#socket) {
|
|
98
|
-
const { id, method, path, headers = {}, body, bodyEncoding } = frame;
|
|
99
|
-
if (!id || !method || !path) return;
|
|
100
|
-
try {
|
|
101
|
-
const payload = body == null
|
|
102
|
-
? undefined
|
|
103
|
-
: (bodyEncoding === 'base64' ? Buffer.from(body, 'base64') : body);
|
|
104
|
-
const res = await this.#localApp.inject({ method, url: path, headers, payload });
|
|
105
|
-
socket.emit('edge:res', { id, status: res.statusCode, headers: res.headers });
|
|
106
|
-
const buf = res.rawPayload;
|
|
107
|
-
for (let offset = 0, seq = 0; offset < buf.length; offset += CHUNK_SIZE, seq++) {
|
|
108
|
-
socket.emit('edge:chunk', { id, seq, data: buf.subarray(offset, offset + CHUNK_SIZE).toString('base64') });
|
|
109
|
-
}
|
|
110
|
-
socket.emit('edge:end', { id });
|
|
111
|
-
} catch (err) {
|
|
112
|
-
socket.emit('edge:err', { id, code: 'EDGE_DISPATCH_FAILED', message: err.message });
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Relay all events from a local wildcard emitter (EventEmitter2) up the
|
|
118
|
-
* tunnel; the server re-emits them through its WorkspaceManager.
|
|
119
|
-
*/
|
|
120
|
-
forwardEvents(emitter) {
|
|
121
|
-
const socket = () => this.#socket;
|
|
122
|
-
const listener = function (payload) {
|
|
123
|
-
socket()?.emit('edge:event', { name: this.event, payload });
|
|
124
|
-
};
|
|
125
|
-
emitter.on('**', listener);
|
|
126
|
-
this.#forwarded.push([emitter, listener]);
|
|
127
|
-
return this;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
close() {
|
|
131
|
-
void this.#transport.dispose().catch(() => {});
|
|
132
|
-
for (const [emitter, listener] of this.#forwarded) emitter.off('**', listener);
|
|
133
|
-
this.#forwarded = [];
|
|
134
|
-
this.#socket?.disconnect();
|
|
135
|
-
this.#socket = null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
1
|
+
export { default } from '@augmentd-labs/canvas-api-client/edge';
|
package/src/remote-config.js
CHANGED
|
@@ -19,7 +19,8 @@ import path from 'node:path';
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
export function workspaceConfigPath(dir) {
|
|
22
|
-
|
|
22
|
+
const home = path.join(dir, '.workspace', 'workspace.json');
|
|
23
|
+
return fs.existsSync(home) ? home : path.join(dir, 'workspace.json');
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function readWorkspaceConfig(dir) {
|