@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.
@@ -1,11 +1,14 @@
1
- import { actionError, isRecord, parseResponse } from "./actions.js";
1
+ import { actionError, isRecord, parseResponse, } from "./actions.js";
2
2
  import { validId } from "./protocol.js";
3
- import { parseDaemonStatus } from "./daemon-protocol.js";
3
+ import { encodeMeta, frameFits, isUuid, MAX_FRAME_BYTES } from "./frame.js";
4
+ import { parseDaemonStatus, } from "./daemon-protocol.js";
5
+ import { isAbsolute } from "node:path";
4
6
  import { DaemonRequestError, DaemonVersionError, RpcError, } from "./daemon-rpc.js";
5
7
  import { ensureDaemon, findDaemon } from "./daemon-control.js";
6
8
  export { createNodeConfig, nodeConfigFromEnvironment, readOptionalCallerCredential, CallerConfigurationError, } from "./local.js";
7
9
  export { daemonStatus, startDaemon, stopDaemon } from "./daemon-control.js";
8
10
  export { DaemonRequestError, DaemonVersionError } from "./daemon-rpc.js";
11
+ export { openConsoleBridge, MAX_CONSOLE_MESSAGE_BYTES, } from "./console.js";
9
12
  export class DeviceActionError extends Error {
10
13
  response;
11
14
  code;
@@ -143,6 +146,86 @@ export function createNodeClient(config, options = {}) {
143
146
  throw error;
144
147
  }
145
148
  }
149
+ async function runBinary(deviceId, action, args = {}, body = new Uint8Array()) {
150
+ assertOpen();
151
+ const empty = new Uint8Array();
152
+ const failure = (code, message) => ({
153
+ response: actionError(code, message),
154
+ body: empty,
155
+ });
156
+ if (!isUuid(deviceId) || typeof action !== "string" || !action || action.length > 128)
157
+ return failure("invalid_request", "A device UUID and action are required.");
158
+ let params;
159
+ try {
160
+ params = JSON.parse(JSON.stringify({ deviceId, action, args }));
161
+ }
162
+ catch {
163
+ return failure("invalid_request", "Action arguments must be JSON.");
164
+ }
165
+ const meta = encodeMeta({ type: "request", action, args: params.args ?? {} });
166
+ if (!frameFits(meta.byteLength, body.byteLength))
167
+ return failure("message_too_large", `The input does not fit in one ${MAX_FRAME_BYTES / 1024 / 1024} MiB frame. Nothing was sent. Use rome-node cp for large files.`);
168
+ try {
169
+ const rpc = await connect(true);
170
+ assertOpen();
171
+ if (!rpc)
172
+ throw new DaemonRequestError();
173
+ const reply = await rpc.requestBinary("devices.runBinary", params, body);
174
+ const response = parseResponse(reply.result);
175
+ if (!response)
176
+ return failure("unknown_outcome", "The action response was invalid. Do not automatically retry.");
177
+ return { response, body: response.ok ? reply.body : empty };
178
+ }
179
+ catch (error) {
180
+ if (error instanceof DaemonRequestError)
181
+ return failure(error.code, error.message);
182
+ if (error instanceof RpcError)
183
+ return failure(error.code === -32602 ? "invalid_request" : "gateway_unavailable", error.message);
184
+ throw error;
185
+ }
186
+ }
187
+ async function copy(request, options = {}) {
188
+ assertOpen();
189
+ const failure = (code, message) => new DeviceActionError({ type: "response", ok: false, error: { code, message } });
190
+ if ((request.direction !== "push" && request.direction !== "pull") ||
191
+ typeof request.localPath !== "string" ||
192
+ !isAbsolute(request.localPath) ||
193
+ !isUuid(request.deviceId) ||
194
+ typeof request.remotePath !== "string" ||
195
+ !request.remotePath)
196
+ throw failure("invalid_request", "A copy needs direction push or pull, an absolute localPath, a device UUID, and remotePath.");
197
+ const { direction, localPath, deviceId, remotePath } = request;
198
+ let value;
199
+ try {
200
+ const rpc = await connect(true);
201
+ assertOpen();
202
+ if (!rpc)
203
+ throw new DaemonRequestError();
204
+ value = await rpc.request("files.copy", { direction, localPath, deviceId, remotePath }, Number.POSITIVE_INFINITY, (params) => {
205
+ if (typeof params.bytes === "number" && typeof params.total === "number")
206
+ options.onProgress?.({ bytes: params.bytes, total: params.total });
207
+ });
208
+ }
209
+ catch (error) {
210
+ if (error instanceof DaemonRequestError)
211
+ throw failure(error.code, error.message);
212
+ if (error instanceof RpcError)
213
+ throw failure(error.code === -32602 ? "invalid_request" : "gateway_unavailable", error.message);
214
+ throw error;
215
+ }
216
+ const response = parseResponse(value);
217
+ if (!response)
218
+ throw failure("unknown_outcome", "The copy response was invalid. Run the copy again.");
219
+ if (!response.ok)
220
+ throw new DeviceActionError(response);
221
+ const summary = response.result;
222
+ if (!isRecord(summary) ||
223
+ typeof summary.bytes !== "number" ||
224
+ typeof summary.ms !== "number" ||
225
+ typeof summary.sha256 !== "string")
226
+ throw failure("unknown_outcome", "The copy response was invalid. Run the copy again.");
227
+ return { bytes: summary.bytes, ms: summary.ms, sha256: summary.sha256 };
228
+ }
146
229
  return {
147
230
  async getConnectionStatus() {
148
231
  const rpc = await connect(false);
@@ -199,6 +282,8 @@ export function createNodeClient(config, options = {}) {
199
282
  return body;
200
283
  },
201
284
  run,
285
+ runBinary,
286
+ copy,
202
287
  describe: (deviceId) => run(deviceId, "system.info"),
203
288
  async subscribe(topic, listener) {
204
289
  assertOpen();
@@ -117,8 +117,19 @@ export async function stopDaemon(config) {
117
117
  if (legacy?.pid === state.pid) {
118
118
  await legacyRequest(state, "/stop");
119
119
  }
120
- else
121
- return;
120
+ else {
121
+ const older = typeof state.protocolVersion === "number"
122
+ ? await connectRpc(state, undefined, state.protocolVersion).catch(() => null)
123
+ : null;
124
+ if (!older)
125
+ return;
126
+ try {
127
+ await older.rpc.request("daemon.stop", {}, 5000);
128
+ }
129
+ finally {
130
+ older.rpc.close();
131
+ }
132
+ }
122
133
  }
123
134
  else {
124
135
  const found = await findDaemon(config);
@@ -1,5 +1,6 @@
1
1
  import type { ConnectionStatus } from "./client.js";
2
- export declare const DAEMON_PROTOCOL_VERSION = 2;
2
+ export declare const DAEMON_PROTOCOL_VERSION = 4;
3
+ export declare const MAX_LOCAL_MESSAGE_BYTES: number;
3
4
  export interface DaemonStatus {
4
5
  pid: number;
5
6
  protocolVersion: number;
@@ -14,3 +15,23 @@ export type ConnectionEvent = {
14
15
  daemon: null;
15
16
  reason: "connection_lost" | "incompatible";
16
17
  };
18
+ export declare function encodeBinaryMessage(message: unknown, body: Uint8Array): Buffer;
19
+ export declare function parseBinaryMessage(data: Buffer): {
20
+ message: unknown;
21
+ body: Uint8Array;
22
+ } | null;
23
+ export interface CopyRequest {
24
+ direction: "push" | "pull";
25
+ localPath: string;
26
+ deviceId: string;
27
+ remotePath: string;
28
+ }
29
+ export interface CopySummary {
30
+ bytes: number;
31
+ ms: number;
32
+ sha256: string;
33
+ }
34
+ export interface CopyProgress {
35
+ bytes: number;
36
+ total: number;
37
+ }
@@ -1,5 +1,6 @@
1
1
  import { isRecord } from "./actions.js";
2
- export const DAEMON_PROTOCOL_VERSION = 2;
2
+ export const DAEMON_PROTOCOL_VERSION = 4;
3
+ export const MAX_LOCAL_MESSAGE_BYTES = 64 * 1024 * 1024;
3
4
  export function parseDaemonStatus(value) {
4
5
  if (!isRecord(value) ||
5
6
  !Number.isSafeInteger(value.pid) ||
@@ -13,3 +14,23 @@ export function parseDaemonStatus(value) {
13
14
  connection: value.connection,
14
15
  };
15
16
  }
17
+ export function encodeBinaryMessage(message, body) {
18
+ const json = Buffer.from(JSON.stringify(message));
19
+ const header = Buffer.alloc(4);
20
+ header.writeUInt32BE(json.byteLength);
21
+ return Buffer.concat([header, json, body]);
22
+ }
23
+ export function parseBinaryMessage(data) {
24
+ if (data.byteLength < 4)
25
+ return null;
26
+ const end = 4 + data.readUInt32BE(0);
27
+ if (end > data.byteLength)
28
+ return null;
29
+ try {
30
+ const message = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(data.subarray(4, end)));
31
+ return { message, body: data.subarray(end) };
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
@@ -25,10 +25,15 @@ export declare class RpcConnection {
25
25
  onClose?: () => void;
26
26
  constructor(socket: WebSocket);
27
27
  get open(): boolean;
28
- request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
28
+ request(method: string, params?: unknown, timeoutMs?: number, progress?: (params: Record<string, unknown>) => void): Promise<unknown>;
29
+ requestBinary(method: string, params: unknown, body: Uint8Array, timeoutMs?: number): Promise<{
30
+ result: unknown;
31
+ body: Uint8Array;
32
+ }>;
33
+ private submit;
29
34
  close(): void;
30
35
  }
31
- export declare function connectRpc(state: DaemonState, signal?: AbortSignal): Promise<{
36
+ export declare function connectRpc(state: DaemonState, signal?: AbortSignal, protocolVersion?: number): Promise<{
32
37
  rpc: RpcConnection;
33
38
  status: DaemonStatus;
34
39
  }>;
@@ -1,6 +1,6 @@
1
1
  import WebSocket from "ws";
2
2
  import { isRecord } from "./actions.js";
3
- import { DAEMON_PROTOCOL_VERSION, parseDaemonStatus, } from "./daemon-protocol.js";
3
+ import { DAEMON_PROTOCOL_VERSION, encodeBinaryMessage, MAX_LOCAL_MESSAGE_BYTES, parseBinaryMessage, parseDaemonStatus, } from "./daemon-protocol.js";
4
4
  export class DaemonVersionError extends Error {
5
5
  constructor() {
6
6
  super("The caller daemon uses an incompatible protocol. Stop it explicitly before restarting with this version.");
@@ -52,26 +52,47 @@ export class RpcConnection {
52
52
  });
53
53
  socket.on("message", (data, binary) => {
54
54
  let message;
55
- try {
56
- message = JSON.parse(data.toString());
55
+ let body = new Uint8Array();
56
+ if (binary) {
57
+ const parsed = parseBinaryMessage(Buffer.isBuffer(data)
58
+ ? data
59
+ : Array.isArray(data)
60
+ ? Buffer.concat(data)
61
+ : Buffer.from(data));
62
+ if (!parsed) {
63
+ this.close();
64
+ return;
65
+ }
66
+ ({ message, body } = parsed);
57
67
  }
58
- catch {
59
- this.close();
60
- return;
68
+ else {
69
+ try {
70
+ message = JSON.parse(data.toString());
71
+ }
72
+ catch {
73
+ this.close();
74
+ return;
75
+ }
61
76
  }
62
- if (binary || !isRecord(message) || message.jsonrpc !== "2.0") {
77
+ if (!isRecord(message) || message.jsonrpc !== "2.0") {
63
78
  this.close();
64
79
  return;
65
80
  }
66
81
  if (typeof message.method === "string" && !Object.hasOwn(message, "id")) {
67
- this.onNotification?.(message.method, message.params);
82
+ const params = message.params;
83
+ if (message.method === "events.transfer") {
84
+ if (isRecord(params) && typeof params.requestId === "string")
85
+ this.pending.get(params.requestId)?.progress?.(params);
86
+ return;
87
+ }
88
+ this.onNotification?.(message.method, params);
68
89
  return;
69
90
  }
70
91
  const pending = typeof message.id === "string" ? this.pending.get(message.id) : undefined;
71
92
  if (!pending)
72
93
  return;
73
94
  if (Object.hasOwn(message, "result") && !Object.hasOwn(message, "error"))
74
- pending.resolve(message.result);
95
+ pending.resolve(message.result, body);
75
96
  else if (!Object.hasOwn(message, "result") &&
76
97
  isRecord(message.error) &&
77
98
  Number.isInteger(message.error.code) &&
@@ -84,11 +105,18 @@ export class RpcConnection {
84
105
  get open() {
85
106
  return this.socket.readyState === WebSocket.OPEN;
86
107
  }
87
- request(method, params = {}, timeoutMs = 85_000) {
108
+ async request(method, params = {}, timeoutMs = 85_000, progress) {
109
+ return (await this.submit(method, params, undefined, timeoutMs, progress)).result;
110
+ }
111
+ async requestBinary(method, params, body, timeoutMs = 85_000) {
112
+ return this.submit(method, params, body, timeoutMs);
113
+ }
114
+ submit(method, params, body, timeoutMs, progress) {
88
115
  if (!this.open)
89
116
  return Promise.reject(new DaemonRequestError());
90
117
  const id = String(++this.nextId);
91
- const text = JSON.stringify({ jsonrpc: "2.0", id, method, params });
118
+ const message = { jsonrpc: "2.0", id, method, params };
119
+ const data = body ? encodeBinaryMessage(message, body) : JSON.stringify(message);
92
120
  return new Promise((resolve, reject) => {
93
121
  const finish = (error, value) => {
94
122
  clearTimeout(timer);
@@ -98,13 +126,16 @@ export class RpcConnection {
98
126
  else
99
127
  resolve(value);
100
128
  };
101
- const timer = setTimeout(() => finish(new DaemonRequestError()), timeoutMs);
129
+ const timer = Number.isFinite(timeoutMs)
130
+ ? setTimeout(() => finish(new DaemonRequestError()), timeoutMs)
131
+ : undefined;
102
132
  this.pending.set(id, {
103
- resolve: (value) => finish(undefined, value),
133
+ resolve: (result, body) => finish(undefined, { result, body }),
104
134
  reject: (error) => finish(error),
135
+ progress,
105
136
  });
106
137
  try {
107
- this.socket.send(text, (error) => {
138
+ this.socket.send(data, { binary: Boolean(body) }, (error) => {
108
139
  if (error)
109
140
  finish(new DaemonRequestError());
110
141
  });
@@ -118,12 +149,13 @@ export class RpcConnection {
118
149
  this.socket.terminate();
119
150
  }
120
151
  }
121
- export async function connectRpc(state, signal) {
152
+ export async function connectRpc(state, signal, protocolVersion = DAEMON_PROTOCOL_VERSION) {
122
153
  signal?.throwIfAborted();
123
154
  const socket = new WebSocket(`ws://127.0.0.1:${state.port}/rpc`, {
124
155
  headers: { authorization: `Bearer ${state.token}` },
125
156
  handshakeTimeout: 1500,
126
157
  perMessageDeflate: false,
158
+ maxPayload: MAX_LOCAL_MESSAGE_BYTES,
127
159
  followRedirects: false,
128
160
  });
129
161
  const rpc = new RpcConnection(socket);
@@ -150,8 +182,8 @@ export async function connectRpc(state, signal) {
150
182
  if (signal?.aborted)
151
183
  abort();
152
184
  });
153
- const value = await rpc.request("daemon.hello", { protocolVersion: DAEMON_PROTOCOL_VERSION }, 1500);
154
- if (isRecord(value) && value.protocolVersion !== DAEMON_PROTOCOL_VERSION)
185
+ const value = await rpc.request("daemon.hello", { protocolVersion }, 1500);
186
+ if (isRecord(value) && value.protocolVersion !== protocolVersion)
155
187
  throw new DaemonVersionError();
156
188
  const status = parseDaemonStatus(value);
157
189
  if (!status || status.pid !== state.pid)