@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/README.md +59 -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/console.d.ts +12 -0
- package/dist/console.js +101 -0
- package/dist/daemon-client.d.ts +10 -4
- package/dist/daemon-client.js +87 -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 +345 -29
- 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/observe-protocol.d.ts +100 -0
- package/dist/observe-protocol.js +13 -0
- package/dist/observe.d.ts +67 -0
- package/dist/observe.js +457 -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
|
|
@@ -88,6 +121,30 @@ same environment to `nodeConfigFromEnvironment()` as the CLI to honor
|
|
|
88
121
|
`ROME_NODE_CONFIG_DIR`, `ROME_NODE_DAEMON_PORT`, and platform defaults.
|
|
89
122
|
The library does not select an environment implicitly.
|
|
90
123
|
|
|
124
|
+
## Activity console
|
|
125
|
+
|
|
126
|
+
The daemon records the actions it sends in `observe.db`. Read them with
|
|
127
|
+
`node.status`, `log.query`, `log.stats`, and the `node` event topic of the
|
|
128
|
+
[local protocol](../../docs/rome-node.md#activity-log).
|
|
129
|
+
|
|
130
|
+
A backend that serves a browser console puts `openConsoleBridge` between the
|
|
131
|
+
browser socket and the daemon:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { nodeConfigFromEnvironment, openConsoleBridge } from "@rome-os/node-core/client";
|
|
135
|
+
|
|
136
|
+
const bridge = openConsoleBridge(nodeConfigFromEnvironment(process.env), {
|
|
137
|
+
send: (text) => socket.send(text),
|
|
138
|
+
close: () => socket.close(),
|
|
139
|
+
});
|
|
140
|
+
socket.on("message", (data) => bridge.receive(data.toString()));
|
|
141
|
+
socket.on("close", () => bridge.close());
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The bridge never starts the daemon. It forwards only the observation methods.
|
|
145
|
+
When no daemon answers, it sends `console.daemon` with state `not_configured`,
|
|
146
|
+
`not_running`, `incompatible`, or `disconnected`, then closes.
|
|
147
|
+
|
|
91
148
|
## Caller authorization
|
|
92
149
|
|
|
93
150
|
```ts
|
|
@@ -144,7 +201,8 @@ where `expiresIn` is in seconds. It returns a `DeviceSession` after approval and
|
|
|
144
201
|
rejects on denial, expiry, cancellation, or a terminal error. It opens no browser
|
|
145
202
|
or callback listener. The callback receives neither the device code nor the access token.
|
|
146
203
|
|
|
147
|
-
The host exposes `system.info` and `exec
|
|
204
|
+
The host exposes `system.info` and `exec`, in text envelopes and in binary frames.
|
|
205
|
+
It also accepts the internal file transfers that `copy()` uses. Programs run as the application's OS
|
|
148
206
|
user. Connection loss terminates direct child processes on a best-effort basis.
|
|
149
207
|
A local caller timeout does not cancel remote execution.
|
|
150
208
|
|
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";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type NodeConfig } from "./local.js";
|
|
2
|
+
export declare const MAX_CONSOLE_MESSAGE_BYTES: number;
|
|
3
|
+
export type ConsoleDaemonState = "not_configured" | "not_running" | "incompatible" | "disconnected";
|
|
4
|
+
export interface ConsoleTransport {
|
|
5
|
+
send(text: string): void;
|
|
6
|
+
close(): void;
|
|
7
|
+
}
|
|
8
|
+
export interface ConsoleBridge {
|
|
9
|
+
receive(text: string): void;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function openConsoleBridge(config: NodeConfig, transport: ConsoleTransport): ConsoleBridge;
|
package/dist/console.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { isRecord } from "./actions.js";
|
|
2
|
+
import { findDaemon } from "./daemon-control.js";
|
|
3
|
+
import { DaemonVersionError, RpcError } from "./daemon-rpc.js";
|
|
4
|
+
import { readOptionalCallerCredential } from "./local.js";
|
|
5
|
+
export const MAX_CONSOLE_MESSAGE_BYTES = 64 * 1024;
|
|
6
|
+
const READ_METHODS = new Set(["node.status", "log.query", "log.stats"]);
|
|
7
|
+
export function openConsoleBridge(config, transport) {
|
|
8
|
+
let closed = false;
|
|
9
|
+
let rpc;
|
|
10
|
+
const queued = [];
|
|
11
|
+
const lifetime = new AbortController();
|
|
12
|
+
const finish = (state) => {
|
|
13
|
+
if (closed)
|
|
14
|
+
return;
|
|
15
|
+
closed = true;
|
|
16
|
+
lifetime.abort();
|
|
17
|
+
if (state)
|
|
18
|
+
transport.send(JSON.stringify({ jsonrpc: "2.0", method: "console.daemon", params: { state } }));
|
|
19
|
+
rpc?.close();
|
|
20
|
+
transport.close();
|
|
21
|
+
};
|
|
22
|
+
const reply = (id, body) => {
|
|
23
|
+
if (!closed)
|
|
24
|
+
transport.send(JSON.stringify({ jsonrpc: "2.0", id, ...body }));
|
|
25
|
+
};
|
|
26
|
+
function forward(text) {
|
|
27
|
+
let message;
|
|
28
|
+
try {
|
|
29
|
+
message = JSON.parse(text);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
reply(null, { error: { code: -32700, message: "Parse error" } });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!isRecord(message) ||
|
|
36
|
+
message.jsonrpc !== "2.0" ||
|
|
37
|
+
typeof message.method !== "string" ||
|
|
38
|
+
(typeof message.id !== "string" && typeof message.id !== "number")) {
|
|
39
|
+
reply(null, { error: { code: -32600, message: "Invalid Request" } });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const { id, method } = message;
|
|
43
|
+
const params = message.params ?? {};
|
|
44
|
+
const allowed = READ_METHODS.has(method) ||
|
|
45
|
+
((method === "events.subscribe" || method === "events.unsubscribe") &&
|
|
46
|
+
isRecord(params) &&
|
|
47
|
+
params.topic === "node");
|
|
48
|
+
if (!allowed) {
|
|
49
|
+
reply(id, { error: { code: -32601, message: "Method not found" } });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
rpc.request(method, params, 15_000).then((result) => reply(id, { result }), (error) => reply(id, {
|
|
53
|
+
error: error instanceof RpcError
|
|
54
|
+
? { code: error.code, message: error.message }
|
|
55
|
+
: { code: -32000, message: "The rome-node daemon did not answer." },
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
void (async () => {
|
|
59
|
+
try {
|
|
60
|
+
const found = await findDaemon(config, lifetime.signal);
|
|
61
|
+
if (closed) {
|
|
62
|
+
found?.rpc.close();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!found) {
|
|
66
|
+
finish((await readOptionalCallerCredential(config)) ? "not_running" : "not_configured");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
rpc = found.rpc;
|
|
70
|
+
rpc.onNotification = (method, params) => {
|
|
71
|
+
if (method === "events.node" && !closed)
|
|
72
|
+
transport.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
|
|
73
|
+
};
|
|
74
|
+
rpc.onClose = () => finish("disconnected");
|
|
75
|
+
for (const text of queued.splice(0))
|
|
76
|
+
forward(text);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (closed)
|
|
80
|
+
return;
|
|
81
|
+
finish(error instanceof DaemonVersionError ? "incompatible" : "not_running");
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
return {
|
|
85
|
+
receive(text) {
|
|
86
|
+
if (closed)
|
|
87
|
+
return;
|
|
88
|
+
if (Buffer.byteLength(text) > MAX_CONSOLE_MESSAGE_BYTES) {
|
|
89
|
+
finish();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (rpc)
|
|
93
|
+
forward(text);
|
|
94
|
+
else if (queued.length < 16)
|
|
95
|
+
queued.push(text);
|
|
96
|
+
else
|
|
97
|
+
finish();
|
|
98
|
+
},
|
|
99
|
+
close: () => finish(),
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/daemon-client.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
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
|
|
10
|
+
export { openConsoleBridge, MAX_CONSOLE_MESSAGE_BYTES, type ConsoleBridge, type ConsoleDaemonState, type ConsoleTransport, } from "./console.js";
|
|
11
|
+
export type { ActionRow, LogQuery, LogResult, NodeEvent, NodeStatus, Outcome, RoleStatus, } from "./observe-protocol.js";
|
|
12
|
+
export type { ActionResponse, BinaryActionResult } from "./actions.js";
|
|
11
13
|
export declare class DeviceActionError extends Error {
|
|
12
14
|
readonly response: Extract<ActionResponse, {
|
|
13
15
|
ok: false;
|
|
@@ -31,6 +33,10 @@ export declare function createNodeClient(config: NodeConfig, options?: NodeClien
|
|
|
31
33
|
getDevicesStatus(): Promise<DevicesStatus | null>;
|
|
32
34
|
listDevices(): Promise<DeviceList>;
|
|
33
35
|
run: (deviceId: string, action: string, args?: unknown) => Promise<ActionResponse>;
|
|
36
|
+
runBinary: (deviceId: string, action: string, args?: unknown, body?: Uint8Array) => Promise<BinaryActionResult>;
|
|
37
|
+
copy: (request: CopyRequest, options?: {
|
|
38
|
+
onProgress?(progress: CopyProgress): void;
|
|
39
|
+
}) => Promise<CopySummary>;
|
|
34
40
|
describe: (deviceId: string) => Promise<ActionResponse>;
|
|
35
41
|
subscribe(topic: "connection", listener: (event: ConnectionEvent) => void): Promise<() => Promise<void>>;
|
|
36
42
|
disconnect(): void;
|