@rome-os/node-core 0.1.1 → 0.1.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.
package/README.md CHANGED
@@ -27,6 +27,18 @@ const result = await client.run("device-id", "exec", {
27
27
  args: ["status"],
28
28
  cwd: "/workspace",
29
29
  });
30
+ const { response, body } = await client.runBinary(
31
+ "device-id",
32
+ "exec",
33
+ { command: "gzip", args: ["-c"] },
34
+ input,
35
+ );
36
+ const copied = await client.copy({
37
+ direction: "push",
38
+ localPath: "/data/video.mp4",
39
+ deviceId: "device-id",
40
+ remotePath: "/tmp/video.mp4",
41
+ });
30
42
  client.disconnect();
31
43
  ```
32
44
 
@@ -36,6 +48,27 @@ Gateway connection. `connection` describes the caller connection to Gateway,
36
48
  not the online state of remote devices. `listDevices()` returns authorized
37
49
  devices without asserting that they are online.
38
50
 
51
+ `runBinary(deviceId, action, args?, body?)` resolves to `{ response, body }`.
52
+ It sends `body` as input bytes in a binary frame. For `exec`, the input is the
53
+ program's stdin and the reply `body` is its raw stdout. Neither side is JSON or
54
+ base64 encoded. `response` is the same `ActionResponse` as `run()`, and the exec
55
+ result has no `stdout` field. Failures have an empty `body`. The device ID must
56
+ be a UUID. The request must fit in one 32 MiB Gateway frame, header and meta
57
+ included. Larger input fails with `message_too_large` before anything is sent.
58
+ Output that cannot fit fails with `output_too_large` after the program ran.
59
+ A host version without binary frame support
60
+ does not reply, and the call returns `unknown_outcome` after the wait limit.
61
+ See [binary frames](../../docs/rome-node.md#binary-frames).
62
+
63
+ `copy({ direction, localPath, deviceId, remotePath }, { onProgress? })` copies
64
+ one file between the daemon's computer and a device. `direction` is `push` or
65
+ `pull`, and `localPath` must be absolute. It resolves to `{ bytes, ms, sha256 }`
66
+ after both sides verified the size and checksum. It throws `DeviceActionError`
67
+ otherwise, and the destination stays unchanged. `onProgress` receives
68
+ `{ bytes, total }` about four times per second. The copy has no total time limit
69
+ and aborts after 60 seconds without progress or when the client disconnects.
70
+ See [copying files](../../docs/rome-node.md#copying-files).
71
+
39
72
  `getDevicesStatus()` checks device reachability through read-only `system.info`
40
73
  requests through an existing daemon and returns `{ connection, checkedAt, devices }`.
41
74
  It returns `null` when no daemon runs and never starts or restarts one. Each device has
@@ -144,7 +177,8 @@ where `expiresIn` is in seconds. It returns a `DeviceSession` after approval and
144
177
  rejects on denial, expiry, cancellation, or a terminal error. It opens no browser
145
178
  or callback listener. The callback receives neither the device code nor the access token.
146
179
 
147
- The host exposes `system.info` and `exec`. Programs run as the application's OS
180
+ The host exposes `system.info` and `exec`, in text envelopes and in binary frames.
181
+ It also accepts the internal file transfers that `copy()` uses. Programs run as the application's OS
148
182
  user. Connection loss terminates direct child processes on a best-effort basis.
149
183
  A local caller timeout does not cancel remote execution.
150
184
 
package/dist/actions.d.ts CHANGED
@@ -15,6 +15,10 @@ export type ActionResponse = {
15
15
  message: string;
16
16
  };
17
17
  };
18
+ export interface BinaryActionResult {
19
+ response: ActionResponse;
20
+ body: Uint8Array;
21
+ }
18
22
  export declare function actionError(code: string, message: string): ActionResponse;
19
23
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
20
24
  export declare function parseResponse(value: unknown): ActionResponse | null;
package/dist/client.d.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import { type Frame } from "./frame.js";
1
2
  import { type GatewayMessage, type OutboundEnvelope } from "./protocol.js";
2
3
  export interface ClientSocket {
3
4
  readyState: number;
4
- send(data: string): void;
5
+ send(data: string | Uint8Array): void;
5
6
  close(code?: number, reason?: string): void;
6
7
  addEventListener(type: "open", listener: () => void): void;
7
8
  addEventListener(type: "message", listener: (event: {
@@ -18,11 +19,13 @@ export interface GatewayClientOptions {
18
19
  deviceToken: string;
19
20
  createSocket(url: string, authorization: string): ClientSocket;
20
21
  onMessage(message: GatewayMessage): void;
22
+ onFrame?(frame: Frame): void;
21
23
  onStatus?(status: ConnectionStatus): void;
22
24
  random?(): number;
23
25
  beforeConnect?(): Promise<string | null>;
24
26
  }
25
27
  export declare function connectGateway(options: GatewayClientOptions): {
26
28
  send(envelope: OutboundEnvelope): boolean;
29
+ sendFrame(frame: Frame): boolean;
27
30
  stop(): void;
28
31
  };
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { encodeFrame, FRAME_TYPE, frameFits, isUuid, parseFrame } from "./frame.js";
1
2
  import { parseGatewayUrl } from "./gateway-url.js";
2
3
  import { CLOSE, gatewayMessage, outboundEnvelope, } from "./protocol.js";
3
4
  export function connectGateway(options) {
@@ -76,8 +77,19 @@ export function connectGateway(options) {
76
77
  status("online");
77
78
  });
78
79
  current.addEventListener("message", ({ data }) => {
79
- if (stopped || socket !== current || typeof data !== "string")
80
+ if (stopped || socket !== current)
80
81
  return;
82
+ if (typeof data !== "string") {
83
+ const bytes = data instanceof Uint8Array
84
+ ? data
85
+ : data instanceof ArrayBuffer
86
+ ? new Uint8Array(data)
87
+ : null;
88
+ const frame = bytes && parseFrame(bytes);
89
+ if (frame)
90
+ options.onFrame?.(frame);
91
+ return;
92
+ }
81
93
  let message;
82
94
  try {
83
95
  message = gatewayMessage(JSON.parse(data));
@@ -116,6 +128,23 @@ export function connectGateway(options) {
116
128
  return false;
117
129
  }
118
130
  },
131
+ sendFrame(frame) {
132
+ if (stopped || !socket || socket.readyState !== 1)
133
+ return false;
134
+ if (!frameFits(frame.meta.byteLength, frame.body.byteLength))
135
+ return false;
136
+ if (frame.type !== FRAME_TYPE.request && frame.type !== FRAME_TYPE.response)
137
+ return false;
138
+ if (!isUuid(frame.id) || !isUuid(frame.peer))
139
+ return false;
140
+ try {
141
+ socket.send(encodeFrame(frame));
142
+ return true;
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ },
119
148
  stop() {
120
149
  stopped = true;
121
150
  clearTimeout(timer);
package/dist/connect.js CHANGED
@@ -59,9 +59,7 @@ export async function connectHost(options) {
59
59
  const active = session;
60
60
  options.onEvent?.({ type: "device", name: active.name, deviceId: active.deviceId });
61
61
  await new Promise((resolve, reject) => {
62
- const executor = createExecutor(active.name, (message) => connection.send(message), [
63
- active.token,
64
- ]);
62
+ const executor = createExecutor(active.name, (message) => connection.send(message), [active.token], (frame) => connection.sendFrame(frame));
65
63
  let finished = false;
66
64
  const finish = (error) => {
67
65
  if (finished)
@@ -94,6 +92,7 @@ export async function connectHost(options) {
94
92
  if ("from" in message)
95
93
  void executor.receive(message);
96
94
  },
95
+ onFrame: (frame) => void executor.receiveFrame(frame),
97
96
  onStatus: (status) => {
98
97
  options.onEvent?.({ type: "connection", status });
99
98
  if (status !== "online" && status !== "connecting")
@@ -1,6 +1,7 @@
1
- import { type ActionResponse } from "./actions.js";
1
+ import { type ActionResponse, type BinaryActionResult } from "./actions.js";
2
2
  import { connectGateway, type ConnectionStatus } from "./client.js";
3
3
  import type { CallerCredential } from "./local.js";
4
+ import type { ChannelEvents, TransferChannel } from "./transfer.js";
4
5
  export interface DeviceConnectorOptions {
5
6
  credential: CallerCredential;
6
7
  connect?: typeof connectGateway;
@@ -15,12 +16,20 @@ export declare class DeviceConnector {
15
16
  private stopped;
16
17
  private pending;
17
18
  private ready;
19
+ private channels;
18
20
  constructor(options: DeviceConnectorOptions);
19
21
  getStatus(): ConnectionStatus;
20
22
  private ensure;
21
23
  private initialize;
22
24
  list(): Promise<unknown>;
23
25
  private receive;
26
+ private receiveFrame;
27
+ private admit;
28
+ private full;
24
29
  run(target: string, action: string, args: unknown, waitMs?: number): Promise<ActionResponse>;
30
+ runBinary(target: string, action: string, args: unknown, body: Uint8Array, waitMs?: number): Promise<BinaryActionResult>;
31
+ openChannel(target: string, events: ChannelEvents): Promise<(TransferChannel & {
32
+ id: string;
33
+ }) | ActionResponse>;
25
34
  stop(): void;
26
35
  }
package/dist/connector.js CHANGED
@@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto";
2
2
  import { actionError, isRecord, parseResponse, } from "./actions.js";
3
3
  import { connectGateway } from "./client.js";
4
4
  import { cloudRequest, CloudError, gatewayConfig } from "./cloud.js";
5
+ import { decodeMeta, encodeMeta, FRAME_TYPE, frameFits, isUuid, MAX_FRAME_BYTES, } from "./frame.js";
5
6
  import { validId } from "./protocol.js";
6
7
  import { createNodeSocket } from "./socket.js";
8
+ const MAX_PENDING = 128;
7
9
  export class DeviceConnector {
8
10
  options;
9
11
  connection;
@@ -12,6 +14,7 @@ export class DeviceConnector {
12
14
  stopped = false;
13
15
  pending = new Map();
14
16
  ready = new Set();
17
+ channels = new Map();
15
18
  constructor(options) {
16
19
  this.options = options;
17
20
  }
@@ -53,12 +56,15 @@ export class DeviceConnector {
53
56
  }
54
57
  },
55
58
  onMessage: (message) => this.receive(message),
59
+ onFrame: (frame) => this.receiveFrame(frame),
56
60
  onStatus: (status) => {
57
61
  this.status = status;
58
62
  this.options.onStatus?.(status);
59
63
  if (status !== "online") {
60
64
  for (const pending of this.pending.values())
61
65
  pending.finish(actionError("unknown_outcome", "The connection was lost. Execution may have occurred. Do not automatically retry."));
66
+ for (const channel of [...this.channels.values()])
67
+ channel.events.lost();
62
68
  }
63
69
  for (const wake of this.ready)
64
70
  wake();
@@ -76,7 +82,7 @@ export class DeviceConnector {
76
82
  }
77
83
  receive(message) {
78
84
  const pending = this.pending.get(message.id);
79
- if (!pending)
85
+ if (!pending || pending.binary)
80
86
  return;
81
87
  if ("type" in message) {
82
88
  pending.finish(actionError(message.code, "The Gateway could not forward the request."));
@@ -88,9 +94,34 @@ export class DeviceConnector {
88
94
  if (response)
89
95
  pending.finish(response);
90
96
  }
91
- async run(target, action, args, waitMs = this.options.waitMs ?? 60_000) {
92
- if (!validId(target) || !action || action.length > 128)
93
- return actionError("invalid_request", "A device ID and action are required.");
97
+ receiveFrame(frame) {
98
+ const channel = this.channels.get(frame.id);
99
+ if (channel) {
100
+ if (frame.peer !== channel.target)
101
+ return;
102
+ if (frame.type === FRAME_TYPE.response)
103
+ channel.events.frame(decodeMeta(frame.meta), frame.body);
104
+ else if (frame.type === FRAME_TYPE.routeError) {
105
+ const meta = decodeMeta(frame.meta);
106
+ channel.events.frame(actionError(isRecord(meta) && typeof meta.code === "string" ? meta.code : "target_unavailable", "The Gateway could not forward the request."), new Uint8Array());
107
+ }
108
+ return;
109
+ }
110
+ const pending = this.pending.get(frame.id);
111
+ if (!pending?.binary || frame.peer !== pending.target)
112
+ return;
113
+ if (frame.type === FRAME_TYPE.routeError) {
114
+ const meta = decodeMeta(frame.meta);
115
+ pending.finish(actionError(isRecord(meta) && typeof meta.code === "string" ? meta.code : "target_unavailable", "The Gateway could not forward the request."));
116
+ return;
117
+ }
118
+ if (frame.type !== FRAME_TYPE.response)
119
+ return;
120
+ const response = parseResponse(decodeMeta(frame.meta));
121
+ if (response)
122
+ pending.finish(response, frame.body);
123
+ }
124
+ async admit() {
94
125
  try {
95
126
  await this.ensure();
96
127
  }
@@ -114,8 +145,19 @@ export class DeviceConnector {
114
145
  });
115
146
  if (this.status !== "online")
116
147
  return actionError("gateway_unavailable", "The device connection is offline. No operation was sent.");
117
- if (this.pending.size >= 128)
118
- return actionError("busy", "Too many device requests are pending.");
148
+ return null;
149
+ }
150
+ full() {
151
+ return this.pending.size + this.channels.size >= MAX_PENDING
152
+ ? actionError("busy", "Too many device requests are pending.")
153
+ : null;
154
+ }
155
+ async run(target, action, args, waitMs = this.options.waitMs ?? 60_000) {
156
+ if (!validId(target) || !action || action.length > 128)
157
+ return actionError("invalid_request", "A device ID and action are required.");
158
+ const refused = await this.admit();
159
+ if (refused)
160
+ return refused;
119
161
  const id = randomUUID();
120
162
  const envelope = {
121
163
  id,
@@ -128,6 +170,9 @@ export class DeviceConnector {
128
170
  catch {
129
171
  return actionError("invalid_request", "Action arguments must be JSON.");
130
172
  }
173
+ const busy = this.full();
174
+ if (busy)
175
+ return busy;
131
176
  return new Promise((resolve) => {
132
177
  const finish = (response) => {
133
178
  clearTimeout(timer);
@@ -135,11 +180,88 @@ export class DeviceConnector {
135
180
  resolve(response);
136
181
  };
137
182
  const timer = setTimeout(() => finish(actionError("unknown_outcome", "The local wait limit was reached. The remote program was not canceled. Do not automatically retry.")), waitMs);
138
- this.pending.set(id, { target, finish });
183
+ this.pending.set(id, { target, binary: false, finish });
139
184
  if (!this.connection?.send(envelope))
140
185
  finish(actionError("unknown_outcome", "The request could not be submitted reliably. Do not automatically retry."));
141
186
  });
142
187
  }
188
+ async runBinary(target, action, args, body, waitMs = this.options.waitMs ?? 60_000) {
189
+ const empty = new Uint8Array();
190
+ if (!isUuid(target) || !action || action.length > 128)
191
+ return {
192
+ response: actionError("invalid_request", "A device UUID and action are required."),
193
+ body: empty,
194
+ };
195
+ let meta;
196
+ try {
197
+ meta = encodeMeta({ type: "request", action, args });
198
+ }
199
+ catch {
200
+ return {
201
+ response: actionError("invalid_request", "Action arguments must be JSON."),
202
+ body: empty,
203
+ };
204
+ }
205
+ if (!frameFits(meta.byteLength, body.byteLength))
206
+ return {
207
+ response: actionError("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.`),
208
+ body: empty,
209
+ };
210
+ const refused = await this.admit();
211
+ if (refused)
212
+ return { response: refused, body: empty };
213
+ const busy = this.full();
214
+ if (busy)
215
+ return { response: busy, body: empty };
216
+ const id = randomUUID();
217
+ const peer = target.toLowerCase();
218
+ return new Promise((resolve) => {
219
+ const finish = (response, output = empty) => {
220
+ clearTimeout(timer);
221
+ this.pending.delete(id);
222
+ resolve({ response, body: response.ok ? output : empty });
223
+ };
224
+ const timer = setTimeout(() => finish(actionError("unknown_outcome", "The local wait limit was reached. The remote program was not canceled. Do not automatically retry.")), waitMs);
225
+ this.pending.set(id, { target: peer, binary: true, finish });
226
+ if (!this.connection?.sendFrame({ type: FRAME_TYPE.request, id, peer, meta, body }))
227
+ finish(actionError("unknown_outcome", "The request could not be submitted reliably. Do not automatically retry."));
228
+ });
229
+ }
230
+ async openChannel(target, events) {
231
+ if (!isUuid(target))
232
+ return actionError("invalid_request", "A device UUID is required.");
233
+ const refused = await this.admit();
234
+ if (refused)
235
+ return refused;
236
+ const busy = this.full();
237
+ if (busy)
238
+ return busy;
239
+ const id = randomUUID();
240
+ const peer = target.toLowerCase();
241
+ this.channels.set(id, { target: peer, events });
242
+ return {
243
+ id,
244
+ send: (meta, body = new Uint8Array()) => {
245
+ if (!this.channels.has(id))
246
+ return false;
247
+ try {
248
+ return (this.connection?.sendFrame({
249
+ type: FRAME_TYPE.request,
250
+ id,
251
+ peer,
252
+ meta: encodeMeta(meta),
253
+ body,
254
+ }) ?? false);
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ },
260
+ close: () => {
261
+ this.channels.delete(id);
262
+ },
263
+ };
264
+ }
143
265
  stop() {
144
266
  this.stopped = true;
145
267
  this.status = "stopped";
@@ -1,13 +1,13 @@
1
1
  import type { DevicesStatus } from "./devices-status.js";
2
- import { type ActionResponse } from "./actions.js";
3
- import { type DaemonStatus, type ConnectionEvent } from "./daemon-protocol.js";
2
+ import { type ActionResponse, type BinaryActionResult } from "./actions.js";
3
+ import { type DaemonStatus, type ConnectionEvent, type CopyProgress, type CopyRequest, type CopySummary } from "./daemon-protocol.js";
4
4
  import type { NodeConfig } from "./local.js";
5
5
  export { createNodeConfig, nodeConfigFromEnvironment, readOptionalCallerCredential, CallerConfigurationError, type NodeConfig, type CallerCredential, } from "./local.js";
6
6
  export { daemonStatus, startDaemon, stopDaemon } from "./daemon-control.js";
7
7
  export { DaemonRequestError, DaemonVersionError } from "./daemon-rpc.js";
8
- export type { DaemonStatus, ConnectionEvent } from "./daemon-protocol.js";
8
+ export type { DaemonStatus, ConnectionEvent, CopyProgress, CopyRequest, CopySummary, } from "./daemon-protocol.js";
9
9
  export type { DevicesStatus, DeviceStatus } from "./devices-status.js";
10
- export type { ActionResponse } from "./actions.js";
10
+ export type { ActionResponse, BinaryActionResult } from "./actions.js";
11
11
  export declare class DeviceActionError extends Error {
12
12
  readonly response: Extract<ActionResponse, {
13
13
  ok: false;
@@ -31,6 +31,10 @@ export declare function createNodeClient(config: NodeConfig, options?: NodeClien
31
31
  getDevicesStatus(): Promise<DevicesStatus | null>;
32
32
  listDevices(): Promise<DeviceList>;
33
33
  run: (deviceId: string, action: string, args?: unknown) => Promise<ActionResponse>;
34
+ runBinary: (deviceId: string, action: string, args?: unknown, body?: Uint8Array) => Promise<BinaryActionResult>;
35
+ copy: (request: CopyRequest, options?: {
36
+ onProgress?(progress: CopyProgress): void;
37
+ }) => Promise<CopySummary>;
34
38
  describe: (deviceId: string) => Promise<ActionResponse>;
35
39
  subscribe(topic: "connection", listener: (event: ConnectionEvent) => void): Promise<() => Promise<void>>;
36
40
  disconnect(): void;
@@ -1,6 +1,8 @@
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";
@@ -143,6 +145,86 @@ export function createNodeClient(config, options = {}) {
143
145
  throw error;
144
146
  }
145
147
  }
148
+ async function runBinary(deviceId, action, args = {}, body = new Uint8Array()) {
149
+ assertOpen();
150
+ const empty = new Uint8Array();
151
+ const failure = (code, message) => ({
152
+ response: actionError(code, message),
153
+ body: empty,
154
+ });
155
+ if (!isUuid(deviceId) || typeof action !== "string" || !action || action.length > 128)
156
+ return failure("invalid_request", "A device UUID and action are required.");
157
+ let params;
158
+ try {
159
+ params = JSON.parse(JSON.stringify({ deviceId, action, args }));
160
+ }
161
+ catch {
162
+ return failure("invalid_request", "Action arguments must be JSON.");
163
+ }
164
+ const meta = encodeMeta({ type: "request", action, args: params.args ?? {} });
165
+ if (!frameFits(meta.byteLength, body.byteLength))
166
+ 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.`);
167
+ try {
168
+ const rpc = await connect(true);
169
+ assertOpen();
170
+ if (!rpc)
171
+ throw new DaemonRequestError();
172
+ const reply = await rpc.requestBinary("devices.runBinary", params, body);
173
+ const response = parseResponse(reply.result);
174
+ if (!response)
175
+ return failure("unknown_outcome", "The action response was invalid. Do not automatically retry.");
176
+ return { response, body: response.ok ? reply.body : empty };
177
+ }
178
+ catch (error) {
179
+ if (error instanceof DaemonRequestError)
180
+ return failure(error.code, error.message);
181
+ if (error instanceof RpcError)
182
+ return failure(error.code === -32602 ? "invalid_request" : "gateway_unavailable", error.message);
183
+ throw error;
184
+ }
185
+ }
186
+ async function copy(request, options = {}) {
187
+ assertOpen();
188
+ const failure = (code, message) => new DeviceActionError({ type: "response", ok: false, error: { code, message } });
189
+ if ((request.direction !== "push" && request.direction !== "pull") ||
190
+ typeof request.localPath !== "string" ||
191
+ !isAbsolute(request.localPath) ||
192
+ !isUuid(request.deviceId) ||
193
+ typeof request.remotePath !== "string" ||
194
+ !request.remotePath)
195
+ throw failure("invalid_request", "A copy needs direction push or pull, an absolute localPath, a device UUID, and remotePath.");
196
+ const { direction, localPath, deviceId, remotePath } = request;
197
+ let value;
198
+ try {
199
+ const rpc = await connect(true);
200
+ assertOpen();
201
+ if (!rpc)
202
+ throw new DaemonRequestError();
203
+ value = await rpc.request("files.copy", { direction, localPath, deviceId, remotePath }, Number.POSITIVE_INFINITY, (params) => {
204
+ if (typeof params.bytes === "number" && typeof params.total === "number")
205
+ options.onProgress?.({ bytes: params.bytes, total: params.total });
206
+ });
207
+ }
208
+ catch (error) {
209
+ if (error instanceof DaemonRequestError)
210
+ throw failure(error.code, error.message);
211
+ if (error instanceof RpcError)
212
+ throw failure(error.code === -32602 ? "invalid_request" : "gateway_unavailable", error.message);
213
+ throw error;
214
+ }
215
+ const response = parseResponse(value);
216
+ if (!response)
217
+ throw failure("unknown_outcome", "The copy response was invalid. Run the copy again.");
218
+ if (!response.ok)
219
+ throw new DeviceActionError(response);
220
+ const summary = response.result;
221
+ if (!isRecord(summary) ||
222
+ typeof summary.bytes !== "number" ||
223
+ typeof summary.ms !== "number" ||
224
+ typeof summary.sha256 !== "string")
225
+ throw failure("unknown_outcome", "The copy response was invalid. Run the copy again.");
226
+ return { bytes: summary.bytes, ms: summary.ms, sha256: summary.sha256 };
227
+ }
146
228
  return {
147
229
  async getConnectionStatus() {
148
230
  const rpc = await connect(false);
@@ -199,6 +281,8 @@ export function createNodeClient(config, options = {}) {
199
281
  return body;
200
282
  },
201
283
  run,
284
+ runBinary,
285
+ copy,
202
286
  describe: (deviceId) => run(deviceId, "system.info"),
203
287
  async subscribe(topic, listener) {
204
288
  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 = 3;
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 = 3;
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
  }>;