@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 +35 -1
- package/dist/actions.d.ts +4 -0
- package/dist/client.d.ts +4 -1
- package/dist/client.js +30 -1
- package/dist/connect.js +2 -3
- package/dist/connector.d.ts +10 -1
- package/dist/connector.js +129 -7
- package/dist/daemon-client.d.ts +8 -4
- package/dist/daemon-client.js +86 -2
- package/dist/daemon-control.js +13 -2
- package/dist/daemon-protocol.d.ts +22 -1
- package/dist/daemon-protocol.js +22 -1
- package/dist/daemon-rpc.d.ts +7 -2
- package/dist/daemon-rpc.js +49 -17
- package/dist/daemon.js +167 -21
- package/dist/executor.d.ts +4 -1
- package/dist/executor.js +180 -66
- package/dist/frame.d.ts +24 -0
- package/dist/frame.js +64 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/transfer.d.ts +102 -0
- package/dist/transfer.js +518 -0
- package/package.json +1 -1
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
|
|
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
|
|
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")
|
package/dist/connector.d.ts
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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";
|
package/dist/daemon-client.d.ts
CHANGED
|
@@ -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;
|
package/dist/daemon-client.js
CHANGED
|
@@ -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 {
|
|
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();
|
package/dist/daemon-control.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
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
|
+
}
|
package/dist/daemon-protocol.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isRecord } from "./actions.js";
|
|
2
|
-
export const DAEMON_PROTOCOL_VERSION =
|
|
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
|
+
}
|
package/dist/daemon-rpc.d.ts
CHANGED
|
@@ -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
|
}>;
|