@rome-os/node-core 0.1.1 → 0.1.3

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/daemon.js CHANGED
@@ -1,13 +1,78 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
+ import { createRequire } from "node:module";
4
+ import { join } from "node:path";
3
5
  import WebSocket, { WebSocketServer } from "ws";
4
- import { isRecord } from "./actions.js";
6
+ import { actionError, isRecord } from "./actions.js";
7
+ import { ObserveStore, QueryError } from "./observe.js";
5
8
  import { DeviceConnector } from "./connector.js";
6
9
  import { daemonStatePath, readCallerCredential } from "./local.js";
7
10
  import { writePrivateJson } from "./storage.js";
8
- import { DAEMON_PROTOCOL_VERSION } from "./daemon-protocol.js";
11
+ import { DAEMON_PROTOCOL_VERSION, encodeBinaryMessage, MAX_LOCAL_MESSAGE_BYTES, parseBinaryMessage, } from "./daemon-protocol.js";
9
12
  import { DeviceStatusReader } from "./devices-status.js";
10
13
  import { RpcError } from "./daemon-rpc.js";
14
+ import { isAbsolute } from "node:path";
15
+ import { isUuid } from "./frame.js";
16
+ import { copyWithDevice, TRANSFER_VERSION, TransferError } from "./transfer.js";
17
+ const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
18
+ const { version: NODE_CORE_VERSION } = createRequire(import.meta.url)("../package.json");
19
+ function copyRequest(params) {
20
+ const { direction, localPath, deviceId, remotePath } = params;
21
+ if ((direction !== "push" && direction !== "pull") ||
22
+ typeof localPath !== "string" ||
23
+ !isAbsolute(localPath) ||
24
+ localPath.includes("\0") ||
25
+ !isUuid(deviceId) ||
26
+ typeof remotePath !== "string" ||
27
+ !remotePath ||
28
+ remotePath.includes("\0"))
29
+ return null;
30
+ return { direction, localPath, deviceId: deviceId.toLowerCase(), remotePath };
31
+ }
32
+ function byteCount(value) {
33
+ return typeof value === "string" ? Buffer.byteLength(value) : 0;
34
+ }
35
+ function actionStart(deviceId, action, args, mode, stdinBytes = 0) {
36
+ const exec = action === "exec" && isRecord(args) && typeof args.command === "string";
37
+ let text = null;
38
+ if (!exec)
39
+ try {
40
+ text = JSON.stringify(args ?? {});
41
+ }
42
+ catch { }
43
+ return {
44
+ direction: "out",
45
+ peer: deviceId.toLowerCase(),
46
+ action,
47
+ mode,
48
+ command: exec ? args.command : null,
49
+ args: exec ? (Array.isArray(args.args) ? args.args.map(String) : []) : text,
50
+ cwd: exec && typeof args.cwd === "string" ? args.cwd : null,
51
+ stdinBytes,
52
+ };
53
+ }
54
+ function actionEnd(response, stdoutBytes) {
55
+ if (!response.ok) {
56
+ const code = response.error.code;
57
+ return {
58
+ outcome: code === "unknown_outcome" ? "unknown_outcome" : code === "canceled" ? "aborted" : "error",
59
+ errorCode: code,
60
+ };
61
+ }
62
+ const result = isRecord(response.result) ? response.result : {};
63
+ return {
64
+ outcome: "ok",
65
+ exitCode: Number.isSafeInteger(result.exitCode) ? result.exitCode : null,
66
+ signal: typeof result.signal === "string" ? result.signal : null,
67
+ stdoutBytes: stdoutBytes ?? byteCount(result.stdout),
68
+ stderrBytes: byteCount(result.stderr),
69
+ };
70
+ }
71
+ function logQuery(params) {
72
+ if (params.kind !== "actions")
73
+ throw new RpcError(-32602, "log.query requires kind actions.");
74
+ return params;
75
+ }
11
76
  export async function serveDaemon(config, signal) {
12
77
  if (signal.aborted)
13
78
  return;
@@ -19,14 +84,37 @@ export async function serveDaemon(config, signal) {
19
84
  protocolVersion: DAEMON_PROTOCOL_VERSION,
20
85
  token: `romenode_${randomBytes(32).toString("base64url")}`,
21
86
  };
87
+ const startedAt = Date.now();
22
88
  let closing = false;
89
+ const store = new ObserveStore({
90
+ path: join(config.directory, "observe.db"),
91
+ secrets: [credential.token, state.token],
92
+ });
93
+ const caller = {
94
+ connection: "stopped",
95
+ since: startedAt,
96
+ onlineOnce: false,
97
+ reconnects: 0,
98
+ running: 0,
99
+ };
23
100
  const connector = new DeviceConnector({
24
101
  credential,
25
- onStatus: () => {
102
+ onStatus: (status) => {
26
103
  deviceStatus.invalidate();
27
104
  for (const peer of peers)
28
105
  if (peer.subscribed)
29
106
  notify(peer);
107
+ if (status === caller.connection)
108
+ return;
109
+ const at = Date.now();
110
+ caller.connection = status;
111
+ caller.since = at;
112
+ if (status === "online") {
113
+ if (caller.onlineOnce)
114
+ caller.reconnects++;
115
+ caller.onlineOnce = true;
116
+ }
117
+ broadcast({ type: "connection", role: "caller", status, at });
30
118
  },
31
119
  });
32
120
  const deviceStatus = new DeviceStatusReader(connector);
@@ -35,6 +123,39 @@ export async function serveDaemon(config, signal) {
35
123
  protocolVersion: DAEMON_PROTOCOL_VERSION,
36
124
  connection: connector.getStatus(),
37
125
  });
126
+ const nodeStatus = () => ({
127
+ version: NODE_CORE_VERSION,
128
+ startedAt,
129
+ roles: {
130
+ caller: {
131
+ auth: caller.connection === "revoked" ? "revoked" : "authorized",
132
+ connection: caller.connection,
133
+ since: caller.since,
134
+ reconnects: caller.reconnects,
135
+ running: caller.running,
136
+ },
137
+ },
138
+ });
139
+ async function observed(start, work, end) {
140
+ if (!start)
141
+ return work();
142
+ caller.running++;
143
+ const row = store.startAction(start);
144
+ if (row)
145
+ broadcast({ type: "action", id: row.id, phase: "start", row });
146
+ let ended = { outcome: "error", errorCode: "daemon_error" };
147
+ try {
148
+ const value = await work();
149
+ ended = end(value);
150
+ return value;
151
+ }
152
+ finally {
153
+ caller.running--;
154
+ const done = row && store.endAction(row.id, ended);
155
+ if (done)
156
+ broadcast({ type: "action", id: done.id, phase: "end", row: done });
157
+ }
158
+ }
38
159
  function authorized(req) {
39
160
  const actual = Buffer.from(req.headers.authorization ?? "");
40
161
  const expected = Buffer.from(`Bearer ${state.token}`);
@@ -44,7 +165,11 @@ export async function serveDaemon(config, signal) {
44
165
  res.writeHead(authorized(req) ? 426 : 401, { "cache-control": "no-store" }).end();
45
166
  });
46
167
  server.headersTimeout = 10_000;
47
- const sockets = new WebSocketServer({ noServer: true, perMessageDeflate: false });
168
+ const sockets = new WebSocketServer({
169
+ noServer: true,
170
+ perMessageDeflate: false,
171
+ maxPayload: MAX_LOCAL_MESSAGE_BYTES,
172
+ });
48
173
  server.on("upgrade", (req, socket, head) => {
49
174
  if (closing || req.url !== "/rpc" || req.headers.origin || !authorized(req)) {
50
175
  socket.end("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
@@ -60,12 +185,50 @@ export async function serveDaemon(config, signal) {
60
185
  peer.socket.terminate();
61
186
  });
62
187
  }
188
+ function deliver(peer, reply) {
189
+ if (!reply.body) {
190
+ send(peer, reply.message);
191
+ return;
192
+ }
193
+ if (peer.socket.readyState !== WebSocket.OPEN)
194
+ return;
195
+ let data = encodeBinaryMessage(reply.message, reply.body);
196
+ if (data.byteLength > MAX_LOCAL_MESSAGE_BYTES) {
197
+ const id = isRecord(reply.message) ? reply.message.id : null;
198
+ data = encodeBinaryMessage({
199
+ jsonrpc: "2.0",
200
+ id,
201
+ result: actionError("message_too_large", "The response exceeds the local message limit. The action may have completed."),
202
+ }, new Uint8Array());
203
+ }
204
+ peer.socket.send(data, { binary: true }, (error) => {
205
+ if (error)
206
+ peer.socket.terminate();
207
+ });
208
+ }
63
209
  function notify(peer) {
64
- if (peer.socket.bufferedAmount > 1024 * 1024) {
210
+ emit(peer, "events.connection", snapshot());
211
+ }
212
+ function broadcast(event) {
213
+ for (const peer of peers)
214
+ if (peer.observing)
215
+ emit(peer, "events.node", event);
216
+ }
217
+ function emit(peer, method, params) {
218
+ if (peer.eventBytes > 1024 * 1024) {
65
219
  peer.socket.terminate();
66
220
  return;
67
221
  }
68
- send(peer, { jsonrpc: "2.0", method: "events.connection", params: snapshot() });
222
+ if (peer.socket.readyState !== WebSocket.OPEN)
223
+ return;
224
+ const text = JSON.stringify({ jsonrpc: "2.0", method, params });
225
+ const size = Buffer.byteLength(text);
226
+ peer.eventBytes += size;
227
+ peer.socket.send(text, (error) => {
228
+ peer.eventBytes -= size;
229
+ if (error)
230
+ peer.socket.terminate();
231
+ });
69
232
  }
70
233
  const close = () => {
71
234
  if (closing)
@@ -83,10 +246,52 @@ export async function serveDaemon(config, signal) {
83
246
  }, 1000);
84
247
  timer.unref();
85
248
  };
249
+ async function copyFile(peer, id, request) {
250
+ const controller = new AbortController();
251
+ peer.copies.add(controller);
252
+ if (peer.socket.readyState !== WebSocket.OPEN)
253
+ controller.abort();
254
+ const canceled = () => actionError("canceled", "The client disconnected. The copy did not start.");
255
+ let last = 0;
256
+ try {
257
+ if (controller.signal.aborted)
258
+ return canceled();
259
+ const info = await connector.run(request.deviceId, "system.info", {});
260
+ if (controller.signal.aborted)
261
+ return canceled();
262
+ if (!info.ok)
263
+ return info;
264
+ if (!isRecord(info.result) || info.result.transferVersion !== TRANSFER_VERSION)
265
+ return actionError("unsupported_device", "The device runs a rome-node version without file transfers. Update rome-node on that device.");
266
+ const summary = await copyWithDevice((events) => connector.openChannel(request.deviceId, events), request, {
267
+ signal: controller.signal,
268
+ onProgress: ({ bytes, total }) => {
269
+ const now = Date.now();
270
+ if (now - last < 250 && bytes !== total)
271
+ return;
272
+ last = now;
273
+ send(peer, {
274
+ jsonrpc: "2.0",
275
+ method: "events.transfer",
276
+ params: { requestId: id, bytes, total },
277
+ });
278
+ },
279
+ });
280
+ return { type: "response", ok: true, result: summary };
281
+ }
282
+ catch (cause) {
283
+ if (!(cause instanceof TransferError))
284
+ throw cause;
285
+ return actionError(cause.code, cause.message);
286
+ }
287
+ finally {
288
+ peer.copies.delete(controller);
289
+ }
290
+ }
86
291
  function error(id, code, message) {
87
292
  return { jsonrpc: "2.0", id, error: { code, message } };
88
293
  }
89
- async function dispatch(peer, value) {
294
+ async function dispatch(peer, value, input) {
90
295
  if (!isRecord(value) ||
91
296
  value.jsonrpc !== "2.0" ||
92
297
  typeof value.method !== "string" ||
@@ -94,7 +299,7 @@ export async function serveDaemon(config, signal) {
94
299
  typeof value.id !== "string" &&
95
300
  typeof value.id !== "number" &&
96
301
  value.id !== null))
97
- return error(null, -32600, "Invalid Request");
302
+ return { message: error(null, -32600, "Invalid Request") };
98
303
  const hasId = Object.hasOwn(value, "id");
99
304
  const id = value.id;
100
305
  if (hasId && id !== null && peer.pending.has(id)) {
@@ -109,7 +314,10 @@ export async function serveDaemon(config, signal) {
109
314
  const params = Object.hasOwn(value, "params") ? value.params : {};
110
315
  if (!isRecord(params))
111
316
  throw new RpcError(-32602, "Invalid params");
317
+ if ((value.method === "devices.runBinary") !== Boolean(input))
318
+ throw new RpcError(-32600, "Only devices.runBinary uses binary messages, and it requires one.");
112
319
  let result;
320
+ let body;
113
321
  if (value.method === "daemon.hello") {
114
322
  if (params.protocolVersion !== DAEMON_PROTOCOL_VERSION)
115
323
  throw new RpcError(-32002, "Incompatible daemon protocol.");
@@ -127,6 +335,29 @@ export async function serveDaemon(config, signal) {
127
335
  result = { stopped: true };
128
336
  setImmediate(close);
129
337
  break;
338
+ case "node.status":
339
+ result = nodeStatus();
340
+ break;
341
+ case "log.query":
342
+ try {
343
+ result = store.query(logQuery(params));
344
+ }
345
+ catch (cause) {
346
+ if (cause instanceof QueryError)
347
+ throw new RpcError(-32602, cause.message);
348
+ throw cause;
349
+ }
350
+ break;
351
+ case "log.stats":
352
+ try {
353
+ result = store.stats(params);
354
+ }
355
+ catch (cause) {
356
+ if (cause instanceof QueryError)
357
+ throw new RpcError(-32602, cause.message);
358
+ throw cause;
359
+ }
360
+ break;
130
361
  case "devices.status":
131
362
  result = await deviceStatus.read();
132
363
  break;
@@ -136,29 +367,83 @@ export async function serveDaemon(config, signal) {
136
367
  case "devices.run":
137
368
  if (typeof params.deviceId !== "string" || typeof params.action !== "string")
138
369
  throw new RpcError(-32602, "A device ID and action are required.");
139
- result = await connector.run(params.deviceId, params.action, params.args ?? {});
370
+ {
371
+ const { deviceId, action } = params;
372
+ const args = params.args ?? {};
373
+ result = await observed(action === "system.info" ? null : actionStart(deviceId, action, args, "json"), () => connector.run(deviceId, action, args), (response) => actionEnd(response));
374
+ }
375
+ break;
376
+ case "devices.runBinary": {
377
+ if (typeof params.deviceId !== "string" || typeof params.action !== "string")
378
+ throw new RpcError(-32602, "A device ID and action are required.");
379
+ const { deviceId, action } = params;
380
+ const args = params.args ?? {};
381
+ const stdin = input ?? new Uint8Array();
382
+ const reply = await observed(actionStart(deviceId, action, args, "binary", stdin.byteLength), () => connector.runBinary(deviceId, action, args, stdin), ({ response, body }) => actionEnd(response, body.byteLength));
383
+ result = reply.response;
384
+ body = reply.body;
140
385
  break;
386
+ }
387
+ case "files.copy": {
388
+ const request = copyRequest(params);
389
+ if (!request)
390
+ throw new RpcError(-32602, "files.copy requires direction push or pull, an absolute localPath, a device UUID, and remotePath.");
391
+ result = await observed({
392
+ direction: "out",
393
+ peer: request.deviceId,
394
+ action: "copy",
395
+ mode: "copy",
396
+ transfer: {
397
+ direction: request.direction,
398
+ localPath: request.localPath,
399
+ remotePath: request.remotePath,
400
+ },
401
+ }, () => copyFile(peer, id, request), (response) => {
402
+ const summary = response.ok && isRecord(response.result) ? response.result : {};
403
+ return {
404
+ ...actionEnd(response),
405
+ transferBytes: typeof summary.bytes === "number" ? summary.bytes : null,
406
+ sha256: typeof summary.sha256 === "string" ? summary.sha256 : null,
407
+ };
408
+ });
409
+ break;
410
+ }
141
411
  case "events.subscribe":
142
- case "events.unsubscribe":
143
- if (params.topic !== "connection")
412
+ case "events.unsubscribe": {
413
+ const on = value.method === "events.subscribe";
414
+ if (params.topic === "connection") {
415
+ peer.subscribed = on;
416
+ if (on)
417
+ notify(peer);
418
+ }
419
+ else if (params.topic === "node") {
420
+ peer.observing = on;
421
+ if (on)
422
+ emit(peer, "events.node", {
423
+ type: "snapshot",
424
+ status: nodeStatus(),
425
+ running: store.running(),
426
+ });
427
+ }
428
+ else
144
429
  throw new RpcError(-32602, "Unknown event topic.");
145
- peer.subscribed = value.method === "events.subscribe";
146
- if (peer.subscribed)
147
- notify(peer);
148
- result = { subscribed: peer.subscribed };
430
+ result = { subscribed: on };
149
431
  break;
432
+ }
150
433
  default:
151
434
  throw new RpcError(-32601, "Method not found");
152
435
  }
153
436
  }
154
- return hasId ? { jsonrpc: "2.0", id, result } : undefined;
437
+ return hasId ? { message: { jsonrpc: "2.0", id, result }, body } : undefined;
155
438
  }
156
439
  catch (cause) {
157
440
  if (!hasId)
158
441
  return;
159
- return cause instanceof RpcError
160
- ? error(id, cause.code, cause.message)
161
- : error(id, -32000, "The device service is unavailable.");
442
+ return {
443
+ message: cause instanceof RpcError
444
+ ? error(id, cause.code, cause.message)
445
+ : error(id, -32000, "The device service is unavailable."),
446
+ };
162
447
  }
163
448
  finally {
164
449
  if (hasId && id !== null)
@@ -166,14 +451,42 @@ export async function serveDaemon(config, signal) {
166
451
  }
167
452
  }
168
453
  sockets.on("connection", (socket) => {
169
- const peer = { socket, ready: false, subscribed: false, alive: true, pending: new Set() };
454
+ const peer = {
455
+ socket,
456
+ ready: false,
457
+ subscribed: false,
458
+ observing: false,
459
+ alive: true,
460
+ pending: new Set(),
461
+ eventBytes: 0,
462
+ copies: new Set(),
463
+ };
170
464
  peers.add(peer);
171
465
  socket.on("error", () => socket.terminate());
172
- socket.on("close", () => peers.delete(peer));
466
+ socket.on("close", () => {
467
+ peers.delete(peer);
468
+ for (const copy of peer.copies)
469
+ copy.abort();
470
+ });
173
471
  socket.on("pong", () => {
174
472
  peer.alive = true;
175
473
  });
176
474
  socket.on("message", (data, binary) => {
475
+ if (binary) {
476
+ const parsed = parseBinaryMessage(Buffer.isBuffer(data)
477
+ ? data
478
+ : Array.isArray(data)
479
+ ? Buffer.concat(data)
480
+ : Buffer.from(data));
481
+ if (!parsed) {
482
+ send(peer, error(null, -32600, "Invalid Request"));
483
+ return;
484
+ }
485
+ void dispatch(peer, parsed.message, parsed.body)
486
+ .then((reply) => reply && deliver(peer, reply))
487
+ .catch(() => socket.terminate());
488
+ return;
489
+ }
177
490
  let request;
178
491
  try {
179
492
  request = JSON.parse(data.toString());
@@ -182,20 +495,18 @@ export async function serveDaemon(config, signal) {
182
495
  send(peer, error(null, -32700, "Parse error"));
183
496
  return;
184
497
  }
185
- if (binary) {
186
- send(peer, error(null, -32600, "Invalid Request"));
187
- return;
188
- }
189
498
  void (async () => {
190
499
  if (Array.isArray(request) && request.length > 0) {
191
- const responses = (await Promise.all(request.map((item) => dispatch(peer, item)))).filter((value) => value !== undefined);
500
+ const responses = (await Promise.all(request.map((item) => dispatch(peer, item))))
501
+ .filter((reply) => reply !== undefined)
502
+ .map((reply) => reply.message);
192
503
  if (responses.length)
193
504
  send(peer, responses);
194
505
  }
195
506
  else {
196
- const response = await dispatch(peer, request);
197
- if (response !== undefined)
198
- send(peer, response);
507
+ const reply = await dispatch(peer, request);
508
+ if (reply !== undefined)
509
+ send(peer, reply.message);
199
510
  }
200
511
  })().catch(() => socket.terminate());
201
512
  });
@@ -207,6 +518,9 @@ export async function serveDaemon(config, signal) {
207
518
  resolve();
208
519
  });
209
520
  });
521
+ store.open();
522
+ const pruner = setInterval(() => store.prune(), PRUNE_INTERVAL_MS);
523
+ pruner.unref();
210
524
  const heartbeat = setInterval(() => {
211
525
  for (const peer of peers) {
212
526
  if (!peer.alive) {
@@ -230,6 +544,8 @@ export async function serveDaemon(config, signal) {
230
544
  finally {
231
545
  close();
232
546
  clearInterval(heartbeat);
547
+ clearInterval(pruner);
548
+ store.close();
233
549
  signal.removeEventListener("abort", close);
234
550
  }
235
551
  }
@@ -1,5 +1,8 @@
1
+ import { type Frame } from "./frame.js";
1
2
  import type { InboundEnvelope, OutboundEnvelope } from "./protocol.js";
2
- export declare function createExecutor(name: string, send: (message: OutboundEnvelope) => boolean, secrets?: string[]): {
3
+ export declare function redactBytes(data: Buffer, secrets: string[]): Buffer;
4
+ export declare function createExecutor(name: string, send: (message: OutboundEnvelope) => boolean, secrets?: string[], sendFrame?: (frame: Frame) => boolean): {
3
5
  receive(message: InboundEnvelope): Promise<void>;
6
+ receiveFrame(frame: Frame): Promise<void>;
4
7
  disconnect(): void;
5
8
  };