@rome-os/node-core 0.1.0 → 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 +42 -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/device-authorization.d.ts +8 -0
- package/dist/device-authorization.js +156 -0
- package/dist/device-session.d.ts +7 -0
- package/dist/device-session.js +16 -0
- 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/host.d.ts +1 -0
- package/dist/host.js +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/login.d.ts +2 -5
- package/dist/login.js +5 -8
- package/dist/transfer.d.ts +102 -0
- package/dist/transfer.js +518 -0
- package/package.json +1 -1
package/dist/daemon-rpc.js
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
else {
|
|
69
|
+
try {
|
|
70
|
+
message = JSON.parse(data.toString());
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
this.close();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
61
76
|
}
|
|
62
|
-
if (
|
|
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
|
-
|
|
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
|
|
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 =
|
|
129
|
+
const timer = Number.isFinite(timeoutMs)
|
|
130
|
+
? setTimeout(() => finish(new DaemonRequestError()), timeoutMs)
|
|
131
|
+
: undefined;
|
|
102
132
|
this.pending.set(id, {
|
|
103
|
-
resolve: (
|
|
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(
|
|
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
|
|
154
|
-
if (isRecord(value) && value.protocolVersion !==
|
|
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)
|
package/dist/daemon.js
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import WebSocket, { WebSocketServer } from "ws";
|
|
4
|
-
import { isRecord } from "./actions.js";
|
|
4
|
+
import { actionError, isRecord } from "./actions.js";
|
|
5
5
|
import { DeviceConnector } from "./connector.js";
|
|
6
6
|
import { daemonStatePath, readCallerCredential } from "./local.js";
|
|
7
7
|
import { writePrivateJson } from "./storage.js";
|
|
8
|
-
import { DAEMON_PROTOCOL_VERSION } from "./daemon-protocol.js";
|
|
8
|
+
import { DAEMON_PROTOCOL_VERSION, encodeBinaryMessage, MAX_LOCAL_MESSAGE_BYTES, parseBinaryMessage, } from "./daemon-protocol.js";
|
|
9
9
|
import { DeviceStatusReader } from "./devices-status.js";
|
|
10
10
|
import { RpcError } from "./daemon-rpc.js";
|
|
11
|
+
import { isAbsolute } from "node:path";
|
|
12
|
+
import { isUuid } from "./frame.js";
|
|
13
|
+
import { copyWithDevice, TRANSFER_VERSION, TransferError } from "./transfer.js";
|
|
14
|
+
function copyRequest(params) {
|
|
15
|
+
const { direction, localPath, deviceId, remotePath } = params;
|
|
16
|
+
if ((direction !== "push" && direction !== "pull") ||
|
|
17
|
+
typeof localPath !== "string" ||
|
|
18
|
+
!isAbsolute(localPath) ||
|
|
19
|
+
localPath.includes("\0") ||
|
|
20
|
+
!isUuid(deviceId) ||
|
|
21
|
+
typeof remotePath !== "string" ||
|
|
22
|
+
!remotePath ||
|
|
23
|
+
remotePath.includes("\0"))
|
|
24
|
+
return null;
|
|
25
|
+
return { direction, localPath, deviceId: deviceId.toLowerCase(), remotePath };
|
|
26
|
+
}
|
|
11
27
|
export async function serveDaemon(config, signal) {
|
|
12
28
|
if (signal.aborted)
|
|
13
29
|
return;
|
|
@@ -44,7 +60,11 @@ export async function serveDaemon(config, signal) {
|
|
|
44
60
|
res.writeHead(authorized(req) ? 426 : 401, { "cache-control": "no-store" }).end();
|
|
45
61
|
});
|
|
46
62
|
server.headersTimeout = 10_000;
|
|
47
|
-
const sockets = new WebSocketServer({
|
|
63
|
+
const sockets = new WebSocketServer({
|
|
64
|
+
noServer: true,
|
|
65
|
+
perMessageDeflate: false,
|
|
66
|
+
maxPayload: MAX_LOCAL_MESSAGE_BYTES,
|
|
67
|
+
});
|
|
48
68
|
server.on("upgrade", (req, socket, head) => {
|
|
49
69
|
if (closing || req.url !== "/rpc" || req.headers.origin || !authorized(req)) {
|
|
50
70
|
socket.end("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
@@ -60,12 +80,46 @@ export async function serveDaemon(config, signal) {
|
|
|
60
80
|
peer.socket.terminate();
|
|
61
81
|
});
|
|
62
82
|
}
|
|
83
|
+
function deliver(peer, reply) {
|
|
84
|
+
if (!reply.body) {
|
|
85
|
+
send(peer, reply.message);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (peer.socket.readyState !== WebSocket.OPEN)
|
|
89
|
+
return;
|
|
90
|
+
let data = encodeBinaryMessage(reply.message, reply.body);
|
|
91
|
+
if (data.byteLength > MAX_LOCAL_MESSAGE_BYTES) {
|
|
92
|
+
const id = isRecord(reply.message) ? reply.message.id : null;
|
|
93
|
+
data = encodeBinaryMessage({
|
|
94
|
+
jsonrpc: "2.0",
|
|
95
|
+
id,
|
|
96
|
+
result: actionError("message_too_large", "The response exceeds the local message limit. The action may have completed."),
|
|
97
|
+
}, new Uint8Array());
|
|
98
|
+
}
|
|
99
|
+
peer.socket.send(data, { binary: true }, (error) => {
|
|
100
|
+
if (error)
|
|
101
|
+
peer.socket.terminate();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
63
104
|
function notify(peer) {
|
|
64
|
-
if (peer.
|
|
105
|
+
if (peer.eventBytes > 1024 * 1024) {
|
|
65
106
|
peer.socket.terminate();
|
|
66
107
|
return;
|
|
67
108
|
}
|
|
68
|
-
|
|
109
|
+
if (peer.socket.readyState !== WebSocket.OPEN)
|
|
110
|
+
return;
|
|
111
|
+
const text = JSON.stringify({
|
|
112
|
+
jsonrpc: "2.0",
|
|
113
|
+
method: "events.connection",
|
|
114
|
+
params: snapshot(),
|
|
115
|
+
});
|
|
116
|
+
const size = Buffer.byteLength(text);
|
|
117
|
+
peer.eventBytes += size;
|
|
118
|
+
peer.socket.send(text, (error) => {
|
|
119
|
+
peer.eventBytes -= size;
|
|
120
|
+
if (error)
|
|
121
|
+
peer.socket.terminate();
|
|
122
|
+
});
|
|
69
123
|
}
|
|
70
124
|
const close = () => {
|
|
71
125
|
if (closing)
|
|
@@ -86,7 +140,7 @@ export async function serveDaemon(config, signal) {
|
|
|
86
140
|
function error(id, code, message) {
|
|
87
141
|
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
88
142
|
}
|
|
89
|
-
async function dispatch(peer, value) {
|
|
143
|
+
async function dispatch(peer, value, input) {
|
|
90
144
|
if (!isRecord(value) ||
|
|
91
145
|
value.jsonrpc !== "2.0" ||
|
|
92
146
|
typeof value.method !== "string" ||
|
|
@@ -94,7 +148,7 @@ export async function serveDaemon(config, signal) {
|
|
|
94
148
|
typeof value.id !== "string" &&
|
|
95
149
|
typeof value.id !== "number" &&
|
|
96
150
|
value.id !== null))
|
|
97
|
-
return error(null, -32600, "Invalid Request");
|
|
151
|
+
return { message: error(null, -32600, "Invalid Request") };
|
|
98
152
|
const hasId = Object.hasOwn(value, "id");
|
|
99
153
|
const id = value.id;
|
|
100
154
|
if (hasId && id !== null && peer.pending.has(id)) {
|
|
@@ -109,7 +163,10 @@ export async function serveDaemon(config, signal) {
|
|
|
109
163
|
const params = Object.hasOwn(value, "params") ? value.params : {};
|
|
110
164
|
if (!isRecord(params))
|
|
111
165
|
throw new RpcError(-32602, "Invalid params");
|
|
166
|
+
if ((value.method === "devices.runBinary") !== Boolean(input))
|
|
167
|
+
throw new RpcError(-32600, "Only devices.runBinary uses binary messages, and it requires one.");
|
|
112
168
|
let result;
|
|
169
|
+
let body;
|
|
113
170
|
if (value.method === "daemon.hello") {
|
|
114
171
|
if (params.protocolVersion !== DAEMON_PROTOCOL_VERSION)
|
|
115
172
|
throw new RpcError(-32002, "Incompatible daemon protocol.");
|
|
@@ -138,6 +195,68 @@ export async function serveDaemon(config, signal) {
|
|
|
138
195
|
throw new RpcError(-32602, "A device ID and action are required.");
|
|
139
196
|
result = await connector.run(params.deviceId, params.action, params.args ?? {});
|
|
140
197
|
break;
|
|
198
|
+
case "devices.runBinary": {
|
|
199
|
+
if (typeof params.deviceId !== "string" || typeof params.action !== "string")
|
|
200
|
+
throw new RpcError(-32602, "A device ID and action are required.");
|
|
201
|
+
const reply = await connector.runBinary(params.deviceId, params.action, params.args ?? {}, input ?? new Uint8Array());
|
|
202
|
+
result = reply.response;
|
|
203
|
+
body = reply.body;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case "files.copy": {
|
|
207
|
+
const request = copyRequest(params);
|
|
208
|
+
if (!request)
|
|
209
|
+
throw new RpcError(-32602, "files.copy requires direction push or pull, an absolute localPath, a device UUID, and remotePath.");
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
peer.copies.add(controller);
|
|
212
|
+
if (peer.socket.readyState !== WebSocket.OPEN)
|
|
213
|
+
controller.abort();
|
|
214
|
+
const canceled = () => actionError("canceled", "The client disconnected. The copy did not start.");
|
|
215
|
+
let last = 0;
|
|
216
|
+
try {
|
|
217
|
+
if (controller.signal.aborted) {
|
|
218
|
+
result = canceled();
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
const info = await connector.run(request.deviceId, "system.info", {});
|
|
222
|
+
if (controller.signal.aborted) {
|
|
223
|
+
result = canceled();
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
if (!info.ok) {
|
|
227
|
+
result = info;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
if (!isRecord(info.result) || info.result.transferVersion !== TRANSFER_VERSION) {
|
|
231
|
+
result = actionError("unsupported_device", "The device runs a rome-node version without file transfers. Update rome-node on that device.");
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
const summary = await copyWithDevice((events) => connector.openChannel(request.deviceId, events), request, {
|
|
235
|
+
signal: controller.signal,
|
|
236
|
+
onProgress: ({ bytes, total }) => {
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
if (now - last < 250 && bytes !== total)
|
|
239
|
+
return;
|
|
240
|
+
last = now;
|
|
241
|
+
send(peer, {
|
|
242
|
+
jsonrpc: "2.0",
|
|
243
|
+
method: "events.transfer",
|
|
244
|
+
params: { requestId: id, bytes, total },
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
result = { type: "response", ok: true, result: summary };
|
|
249
|
+
}
|
|
250
|
+
catch (cause) {
|
|
251
|
+
if (!(cause instanceof TransferError))
|
|
252
|
+
throw cause;
|
|
253
|
+
result = actionError(cause.code, cause.message);
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
peer.copies.delete(controller);
|
|
257
|
+
}
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
141
260
|
case "events.subscribe":
|
|
142
261
|
case "events.unsubscribe":
|
|
143
262
|
if (params.topic !== "connection")
|
|
@@ -151,14 +270,16 @@ export async function serveDaemon(config, signal) {
|
|
|
151
270
|
throw new RpcError(-32601, "Method not found");
|
|
152
271
|
}
|
|
153
272
|
}
|
|
154
|
-
return hasId ? { jsonrpc: "2.0", id, result } : undefined;
|
|
273
|
+
return hasId ? { message: { jsonrpc: "2.0", id, result }, body } : undefined;
|
|
155
274
|
}
|
|
156
275
|
catch (cause) {
|
|
157
276
|
if (!hasId)
|
|
158
277
|
return;
|
|
159
|
-
return
|
|
160
|
-
|
|
161
|
-
|
|
278
|
+
return {
|
|
279
|
+
message: cause instanceof RpcError
|
|
280
|
+
? error(id, cause.code, cause.message)
|
|
281
|
+
: error(id, -32000, "The device service is unavailable."),
|
|
282
|
+
};
|
|
162
283
|
}
|
|
163
284
|
finally {
|
|
164
285
|
if (hasId && id !== null)
|
|
@@ -166,14 +287,41 @@ export async function serveDaemon(config, signal) {
|
|
|
166
287
|
}
|
|
167
288
|
}
|
|
168
289
|
sockets.on("connection", (socket) => {
|
|
169
|
-
const peer = {
|
|
290
|
+
const peer = {
|
|
291
|
+
socket,
|
|
292
|
+
ready: false,
|
|
293
|
+
subscribed: false,
|
|
294
|
+
alive: true,
|
|
295
|
+
pending: new Set(),
|
|
296
|
+
eventBytes: 0,
|
|
297
|
+
copies: new Set(),
|
|
298
|
+
};
|
|
170
299
|
peers.add(peer);
|
|
171
300
|
socket.on("error", () => socket.terminate());
|
|
172
|
-
socket.on("close", () =>
|
|
301
|
+
socket.on("close", () => {
|
|
302
|
+
peers.delete(peer);
|
|
303
|
+
for (const copy of peer.copies)
|
|
304
|
+
copy.abort();
|
|
305
|
+
});
|
|
173
306
|
socket.on("pong", () => {
|
|
174
307
|
peer.alive = true;
|
|
175
308
|
});
|
|
176
309
|
socket.on("message", (data, binary) => {
|
|
310
|
+
if (binary) {
|
|
311
|
+
const parsed = parseBinaryMessage(Buffer.isBuffer(data)
|
|
312
|
+
? data
|
|
313
|
+
: Array.isArray(data)
|
|
314
|
+
? Buffer.concat(data)
|
|
315
|
+
: Buffer.from(data));
|
|
316
|
+
if (!parsed) {
|
|
317
|
+
send(peer, error(null, -32600, "Invalid Request"));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
void dispatch(peer, parsed.message, parsed.body)
|
|
321
|
+
.then((reply) => reply && deliver(peer, reply))
|
|
322
|
+
.catch(() => socket.terminate());
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
177
325
|
let request;
|
|
178
326
|
try {
|
|
179
327
|
request = JSON.parse(data.toString());
|
|
@@ -182,20 +330,18 @@ export async function serveDaemon(config, signal) {
|
|
|
182
330
|
send(peer, error(null, -32700, "Parse error"));
|
|
183
331
|
return;
|
|
184
332
|
}
|
|
185
|
-
if (binary) {
|
|
186
|
-
send(peer, error(null, -32600, "Invalid Request"));
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
333
|
void (async () => {
|
|
190
334
|
if (Array.isArray(request) && request.length > 0) {
|
|
191
|
-
const responses = (await Promise.all(request.map((item) => dispatch(peer, item))))
|
|
335
|
+
const responses = (await Promise.all(request.map((item) => dispatch(peer, item))))
|
|
336
|
+
.filter((reply) => reply !== undefined)
|
|
337
|
+
.map((reply) => reply.message);
|
|
192
338
|
if (responses.length)
|
|
193
339
|
send(peer, responses);
|
|
194
340
|
}
|
|
195
341
|
else {
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
send(peer,
|
|
342
|
+
const reply = await dispatch(peer, request);
|
|
343
|
+
if (reply !== undefined)
|
|
344
|
+
send(peer, reply.message);
|
|
199
345
|
}
|
|
200
346
|
})().catch(() => socket.terminate());
|
|
201
347
|
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type DeviceSession } from "./device-session.js";
|
|
2
|
+
export interface DeviceAuthorizationPrompt {
|
|
3
|
+
verificationUri: string;
|
|
4
|
+
verificationUriComplete: string;
|
|
5
|
+
userCode: string;
|
|
6
|
+
expiresIn: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function loginDeviceCode(cloudUrl: string, name: string, signal: AbortSignal, onAuthorizationRequired: (prompt: DeviceAuthorizationPrompt) => void): Promise<DeviceSession>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { isRecord } from "./actions.js";
|
|
2
|
+
import { cloudOrigin } from "./cloud.js";
|
|
3
|
+
import { devicePlatform, deviceSessionFromToken } from "./device-session.js";
|
|
4
|
+
class DeviceAuthorizationError extends Error {
|
|
5
|
+
}
|
|
6
|
+
class RetryableRequestError extends Error {
|
|
7
|
+
}
|
|
8
|
+
function seconds(value) {
|
|
9
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 2_147_483;
|
|
10
|
+
}
|
|
11
|
+
function verificationUrl(value, origin) {
|
|
12
|
+
if (typeof value !== "string")
|
|
13
|
+
throw new DeviceAuthorizationError("Invalid device authorization response.");
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(value);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new DeviceAuthorizationError("Invalid device authorization response.");
|
|
20
|
+
}
|
|
21
|
+
if (url.origin !== origin || url.username || url.password)
|
|
22
|
+
throw new DeviceAuthorizationError("Invalid device authorization response.");
|
|
23
|
+
return url.href;
|
|
24
|
+
}
|
|
25
|
+
async function post(origin, path, body, signal) {
|
|
26
|
+
let response;
|
|
27
|
+
try {
|
|
28
|
+
response = await fetch(new URL(path, origin), {
|
|
29
|
+
method: "POST",
|
|
30
|
+
body,
|
|
31
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
|
32
|
+
redirect: "error",
|
|
33
|
+
cache: "no-store",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new RetryableRequestError();
|
|
38
|
+
}
|
|
39
|
+
if (response.status === 429 || response.status >= 500) {
|
|
40
|
+
await response.body?.cancel().catch(() => { });
|
|
41
|
+
throw new RetryableRequestError();
|
|
42
|
+
}
|
|
43
|
+
let result;
|
|
44
|
+
try {
|
|
45
|
+
result = await response.json();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error instanceof SyntaxError)
|
|
49
|
+
throw new DeviceAuthorizationError("Invalid device authorization response.");
|
|
50
|
+
throw new RetryableRequestError();
|
|
51
|
+
}
|
|
52
|
+
return { response, result };
|
|
53
|
+
}
|
|
54
|
+
function wait(milliseconds, signal) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
signal.throwIfAborted();
|
|
57
|
+
const abort = () => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
reject(signal.reason);
|
|
60
|
+
};
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
signal.removeEventListener("abort", abort);
|
|
63
|
+
resolve();
|
|
64
|
+
}, milliseconds);
|
|
65
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export async function loginDeviceCode(cloudUrl, name, signal, onAuthorizationRequired) {
|
|
69
|
+
const origin = cloudOrigin(cloudUrl);
|
|
70
|
+
const expiry = new AbortController();
|
|
71
|
+
const pollingSignal = AbortSignal.any([signal, expiry.signal]);
|
|
72
|
+
let timer;
|
|
73
|
+
try {
|
|
74
|
+
signal.throwIfAborted();
|
|
75
|
+
const { response, result } = await post(origin, "/oauth2/device_authorization", new URLSearchParams({
|
|
76
|
+
client_id: "rome-computer",
|
|
77
|
+
display_name: name,
|
|
78
|
+
platform: devicePlatform(),
|
|
79
|
+
}), signal);
|
|
80
|
+
if (!response.ok ||
|
|
81
|
+
!isRecord(result) ||
|
|
82
|
+
typeof result.device_code !== "string" ||
|
|
83
|
+
!result.device_code ||
|
|
84
|
+
typeof result.user_code !== "string" ||
|
|
85
|
+
!/^[A-Za-z0-9-]{1,64}$/.test(result.user_code) ||
|
|
86
|
+
!seconds(result.expires_in) ||
|
|
87
|
+
!seconds(result.interval))
|
|
88
|
+
throw new DeviceAuthorizationError("Invalid device authorization response.");
|
|
89
|
+
const prompt = {
|
|
90
|
+
verificationUri: verificationUrl(result.verification_uri, origin),
|
|
91
|
+
verificationUriComplete: verificationUrl(result.verification_uri_complete, origin),
|
|
92
|
+
userCode: result.user_code,
|
|
93
|
+
expiresIn: result.expires_in,
|
|
94
|
+
};
|
|
95
|
+
const deadline = Date.now() + result.expires_in * 1000;
|
|
96
|
+
timer = setTimeout(() => expiry.abort(), result.expires_in * 1000);
|
|
97
|
+
onAuthorizationRequired(prompt);
|
|
98
|
+
let interval = result.interval * 1000;
|
|
99
|
+
while (true) {
|
|
100
|
+
await wait(Math.min(interval, Math.max(0, deadline - Date.now())), pollingSignal);
|
|
101
|
+
if (Date.now() >= deadline)
|
|
102
|
+
expiry.abort();
|
|
103
|
+
pollingSignal.throwIfAborted();
|
|
104
|
+
let polled;
|
|
105
|
+
try {
|
|
106
|
+
polled = await post(origin, "/oauth2/token", new URLSearchParams({
|
|
107
|
+
client_id: "rome-computer",
|
|
108
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
109
|
+
device_code: result.device_code,
|
|
110
|
+
}), pollingSignal);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
pollingSignal.throwIfAborted();
|
|
114
|
+
if (!(error instanceof RetryableRequestError))
|
|
115
|
+
throw error;
|
|
116
|
+
interval = Math.max(interval, Math.min(interval * 2, 60_000));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const { response, result: token } = polled;
|
|
120
|
+
pollingSignal.throwIfAborted();
|
|
121
|
+
if (response.ok) {
|
|
122
|
+
const session = deviceSessionFromToken(token, origin);
|
|
123
|
+
if (!session)
|
|
124
|
+
throw new DeviceAuthorizationError("Invalid token response.");
|
|
125
|
+
return session;
|
|
126
|
+
}
|
|
127
|
+
if (response.status === 400 && isRecord(token)) {
|
|
128
|
+
if (token.error === "authorization_pending")
|
|
129
|
+
continue;
|
|
130
|
+
if (token.error === "slow_down") {
|
|
131
|
+
interval += 5000;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (token.error === "access_denied")
|
|
135
|
+
throw new DeviceAuthorizationError("Device authorization denied.");
|
|
136
|
+
if (token.error === "expired_token")
|
|
137
|
+
throw new DeviceAuthorizationError("Device authorization expired.");
|
|
138
|
+
if (token.error === "invalid_grant")
|
|
139
|
+
throw new DeviceAuthorizationError("Device authorization is invalid.");
|
|
140
|
+
}
|
|
141
|
+
throw new DeviceAuthorizationError("Device authorization rejected.");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (signal.aborted)
|
|
146
|
+
throw new DeviceAuthorizationError("Authorization canceled.");
|
|
147
|
+
if (expiry.signal.aborted)
|
|
148
|
+
throw new DeviceAuthorizationError("Device authorization expired.");
|
|
149
|
+
if (error instanceof DeviceAuthorizationError)
|
|
150
|
+
throw error;
|
|
151
|
+
throw new DeviceAuthorizationError("Could not complete device authorization. Try connecting again.");
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface DeviceSession {
|
|
2
|
+
cloudUrl: string;
|
|
3
|
+
token: string;
|
|
4
|
+
deviceId: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function devicePlatform(): "macos" | "windows" | "linux";
|
|
7
|
+
export declare function deviceSessionFromToken(body: unknown, origin: string): DeviceSession | null;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { platform } from "node:os";
|
|
2
|
+
import { isRecord } from "./actions.js";
|
|
3
|
+
import { validId } from "./protocol.js";
|
|
4
|
+
export function devicePlatform() {
|
|
5
|
+
const value = platform();
|
|
6
|
+
return value === "darwin" ? "macos" : value === "win32" ? "windows" : "linux";
|
|
7
|
+
}
|
|
8
|
+
export function deviceSessionFromToken(body, origin) {
|
|
9
|
+
if (!isRecord(body) ||
|
|
10
|
+
typeof body.access_token !== "string" ||
|
|
11
|
+
!/^romedev_[A-Za-z0-9_-]{43}$/.test(body.access_token) ||
|
|
12
|
+
body.token_type !== "Bearer" ||
|
|
13
|
+
!validId(body.device_id))
|
|
14
|
+
return null;
|
|
15
|
+
return { cloudUrl: origin, token: body.access_token, deviceId: body.device_id };
|
|
16
|
+
}
|
package/dist/executor.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { type Frame } from "./frame.js";
|
|
1
2
|
import type { InboundEnvelope, OutboundEnvelope } from "./protocol.js";
|
|
2
|
-
export declare function
|
|
3
|
+
export declare function redactBytes(data: Buffer, secrets: string[]): Buffer;
|
|
4
|
+
export declare function createExecutor(name: string, send: (message: OutboundEnvelope) => boolean, secrets?: string[], sendFrame?: (frame: Frame) => boolean): {
|
|
3
5
|
receive(message: InboundEnvelope): Promise<void>;
|
|
6
|
+
receiveFrame(frame: Frame): Promise<void>;
|
|
4
7
|
disconnect(): void;
|
|
5
8
|
};
|