@rome-os/node-core 0.1.0
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/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/daemon.js +2 -0
- package/dist/actions.d.ts +20 -0
- package/dist/actions.js +18 -0
- package/dist/auth.d.ts +3 -0
- package/dist/auth.js +76 -0
- package/dist/client.d.ts +28 -0
- package/dist/client.js +130 -0
- package/dist/cloud.d.ts +7 -0
- package/dist/cloud.js +56 -0
- package/dist/connect.d.ts +27 -0
- package/dist/connect.js +115 -0
- package/dist/connector.d.ts +26 -0
- package/dist/connector.js +155 -0
- package/dist/daemon-client.d.ts +37 -0
- package/dist/daemon-client.js +244 -0
- package/dist/daemon-control.d.ts +13 -0
- package/dist/daemon-control.js +151 -0
- package/dist/daemon-entry.d.ts +1 -0
- package/dist/daemon-entry.js +16 -0
- package/dist/daemon-protocol.d.ts +16 -0
- package/dist/daemon-protocol.js +15 -0
- package/dist/daemon-rpc.d.ts +34 -0
- package/dist/daemon-rpc.js +171 -0
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +235 -0
- package/dist/devices-status.d.ts +23 -0
- package/dist/devices-status.js +68 -0
- package/dist/executor.d.ts +5 -0
- package/dist/executor.js +105 -0
- package/dist/gateway-url.d.ts +1 -0
- package/dist/gateway-url.js +11 -0
- package/dist/host.d.ts +3 -0
- package/dist/host.js +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/local.d.ts +21 -0
- package/dist/local.js +47 -0
- package/dist/login.d.ts +6 -0
- package/dist/login.js +107 -0
- package/dist/protocol.d.ts +29 -0
- package/dist/protocol.js +39 -0
- package/dist/socket.d.ts +2 -0
- package/dist/socket.js +25 -0
- package/dist/storage.d.ts +8 -0
- package/dist/storage.js +55 -0
- package/package.json +64 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ActionResponse } from "./actions.js";
|
|
2
|
+
import { connectGateway, type ConnectionStatus } from "./client.js";
|
|
3
|
+
import type { CallerCredential } from "./local.js";
|
|
4
|
+
export interface DeviceConnectorOptions {
|
|
5
|
+
credential: CallerCredential;
|
|
6
|
+
connect?: typeof connectGateway;
|
|
7
|
+
waitMs?: number;
|
|
8
|
+
onStatus?(status: ConnectionStatus): void;
|
|
9
|
+
}
|
|
10
|
+
export declare class DeviceConnector {
|
|
11
|
+
private options;
|
|
12
|
+
private connection?;
|
|
13
|
+
private initializing?;
|
|
14
|
+
private status;
|
|
15
|
+
private stopped;
|
|
16
|
+
private pending;
|
|
17
|
+
private ready;
|
|
18
|
+
constructor(options: DeviceConnectorOptions);
|
|
19
|
+
getStatus(): ConnectionStatus;
|
|
20
|
+
private ensure;
|
|
21
|
+
private initialize;
|
|
22
|
+
list(): Promise<unknown>;
|
|
23
|
+
private receive;
|
|
24
|
+
run(target: string, action: string, args: unknown, waitMs?: number): Promise<ActionResponse>;
|
|
25
|
+
stop(): void;
|
|
26
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { actionError, isRecord, parseResponse, } from "./actions.js";
|
|
3
|
+
import { connectGateway } from "./client.js";
|
|
4
|
+
import { cloudRequest, CloudError, gatewayConfig } from "./cloud.js";
|
|
5
|
+
import { validId } from "./protocol.js";
|
|
6
|
+
import { createNodeSocket } from "./socket.js";
|
|
7
|
+
export class DeviceConnector {
|
|
8
|
+
options;
|
|
9
|
+
connection;
|
|
10
|
+
initializing;
|
|
11
|
+
status = "stopped";
|
|
12
|
+
stopped = false;
|
|
13
|
+
pending = new Map();
|
|
14
|
+
ready = new Set();
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
}
|
|
18
|
+
getStatus() {
|
|
19
|
+
return this.status;
|
|
20
|
+
}
|
|
21
|
+
async ensure() {
|
|
22
|
+
if (this.stopped || this.status === "superseded" || this.status === "revoked")
|
|
23
|
+
throw new Error("connector_stopped");
|
|
24
|
+
if (this.connection)
|
|
25
|
+
return;
|
|
26
|
+
if (this.initializing)
|
|
27
|
+
return this.initializing;
|
|
28
|
+
this.initializing = this.initialize();
|
|
29
|
+
try {
|
|
30
|
+
await this.initializing;
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
this.initializing = undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async initialize() {
|
|
37
|
+
const { cloudUrl, token } = this.options.credential;
|
|
38
|
+
const gatewayUrl = await gatewayConfig(cloudUrl, token);
|
|
39
|
+
if (this.stopped)
|
|
40
|
+
throw new Error("connector_stopped");
|
|
41
|
+
this.connection = (this.options.connect ?? connectGateway)({
|
|
42
|
+
gatewayUrl,
|
|
43
|
+
deviceToken: token,
|
|
44
|
+
createSocket: createNodeSocket,
|
|
45
|
+
beforeConnect: async () => {
|
|
46
|
+
try {
|
|
47
|
+
return await gatewayConfig(cloudUrl, token);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error instanceof CloudError && error.code === "invalid_device_session")
|
|
51
|
+
return null;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
onMessage: (message) => this.receive(message),
|
|
56
|
+
onStatus: (status) => {
|
|
57
|
+
this.status = status;
|
|
58
|
+
this.options.onStatus?.(status);
|
|
59
|
+
if (status !== "online") {
|
|
60
|
+
for (const pending of this.pending.values())
|
|
61
|
+
pending.finish(actionError("unknown_outcome", "The connection was lost. Execution may have occurred. Do not automatically retry."));
|
|
62
|
+
}
|
|
63
|
+
for (const wake of this.ready)
|
|
64
|
+
wake();
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async list() {
|
|
69
|
+
if (this.stopped)
|
|
70
|
+
throw new Error("connector_stopped");
|
|
71
|
+
const { cloudUrl, token } = this.options.credential;
|
|
72
|
+
const body = await cloudRequest(cloudUrl, "/api/account/devices", token);
|
|
73
|
+
if (!isRecord(body) || !Array.isArray(body.items))
|
|
74
|
+
throw new CloudError("invalid_response");
|
|
75
|
+
return { items: body.items };
|
|
76
|
+
}
|
|
77
|
+
receive(message) {
|
|
78
|
+
const pending = this.pending.get(message.id);
|
|
79
|
+
if (!pending)
|
|
80
|
+
return;
|
|
81
|
+
if ("type" in message) {
|
|
82
|
+
pending.finish(actionError(message.code, "The Gateway could not forward the request."));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (message.from !== pending.target)
|
|
86
|
+
return;
|
|
87
|
+
const response = parseResponse(message.payload);
|
|
88
|
+
if (response)
|
|
89
|
+
pending.finish(response);
|
|
90
|
+
}
|
|
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.");
|
|
94
|
+
try {
|
|
95
|
+
await this.ensure();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return actionError("gateway_unavailable", "The device connection is unavailable.");
|
|
99
|
+
}
|
|
100
|
+
if (this.status !== "online")
|
|
101
|
+
await new Promise((resolve) => {
|
|
102
|
+
const finish = () => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
this.ready.delete(wake);
|
|
105
|
+
resolve();
|
|
106
|
+
};
|
|
107
|
+
const wake = () => {
|
|
108
|
+
if (["online", "revoked", "superseded", "stopped"].includes(this.status))
|
|
109
|
+
finish();
|
|
110
|
+
};
|
|
111
|
+
const timer = setTimeout(finish, 10_000);
|
|
112
|
+
this.ready.add(wake);
|
|
113
|
+
wake();
|
|
114
|
+
});
|
|
115
|
+
if (this.status !== "online")
|
|
116
|
+
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.");
|
|
119
|
+
const id = randomUUID();
|
|
120
|
+
const envelope = {
|
|
121
|
+
id,
|
|
122
|
+
to: target,
|
|
123
|
+
payload: { type: "request", action, args },
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
JSON.stringify(envelope);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return actionError("invalid_request", "Action arguments must be JSON.");
|
|
130
|
+
}
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
const finish = (response) => {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
this.pending.delete(id);
|
|
135
|
+
resolve(response);
|
|
136
|
+
};
|
|
137
|
+
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 });
|
|
139
|
+
if (!this.connection?.send(envelope))
|
|
140
|
+
finish(actionError("unknown_outcome", "The request could not be submitted reliably. Do not automatically retry."));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
stop() {
|
|
144
|
+
this.stopped = true;
|
|
145
|
+
this.status = "stopped";
|
|
146
|
+
if (this.connection)
|
|
147
|
+
this.connection.stop();
|
|
148
|
+
else
|
|
149
|
+
this.options.onStatus?.("stopped");
|
|
150
|
+
for (const pending of this.pending.values())
|
|
151
|
+
pending.finish(actionError("unknown_outcome", "The caller is shutting down. Execution may have occurred."));
|
|
152
|
+
for (const wake of this.ready)
|
|
153
|
+
wake();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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";
|
|
4
|
+
import type { NodeConfig } from "./local.js";
|
|
5
|
+
export { createNodeConfig, nodeConfigFromEnvironment, readOptionalCallerCredential, CallerConfigurationError, type NodeConfig, type CallerCredential, } from "./local.js";
|
|
6
|
+
export { daemonStatus, startDaemon, stopDaemon } from "./daemon-control.js";
|
|
7
|
+
export { DaemonRequestError, DaemonVersionError } from "./daemon-rpc.js";
|
|
8
|
+
export type { DaemonStatus, ConnectionEvent } from "./daemon-protocol.js";
|
|
9
|
+
export type { DevicesStatus, DeviceStatus } from "./devices-status.js";
|
|
10
|
+
export type { ActionResponse } from "./actions.js";
|
|
11
|
+
export declare class DeviceActionError extends Error {
|
|
12
|
+
readonly response: Extract<ActionResponse, {
|
|
13
|
+
ok: false;
|
|
14
|
+
}>;
|
|
15
|
+
readonly code: string;
|
|
16
|
+
constructor(response: Extract<ActionResponse, {
|
|
17
|
+
ok: false;
|
|
18
|
+
}>);
|
|
19
|
+
}
|
|
20
|
+
export interface DeviceList {
|
|
21
|
+
items: Array<{
|
|
22
|
+
id: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export interface NodeClientOptions {
|
|
27
|
+
onListenerError?(error: unknown): void;
|
|
28
|
+
}
|
|
29
|
+
export declare function createNodeClient(config: NodeConfig, options?: NodeClientOptions): {
|
|
30
|
+
getConnectionStatus(): Promise<DaemonStatus | null>;
|
|
31
|
+
getDevicesStatus(): Promise<DevicesStatus | null>;
|
|
32
|
+
listDevices(): Promise<DeviceList>;
|
|
33
|
+
run: (deviceId: string, action: string, args?: unknown) => Promise<ActionResponse>;
|
|
34
|
+
describe: (deviceId: string) => Promise<ActionResponse>;
|
|
35
|
+
subscribe(topic: "connection", listener: (event: ConnectionEvent) => void): Promise<() => Promise<void>>;
|
|
36
|
+
disconnect(): void;
|
|
37
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { actionError, isRecord, parseResponse } from "./actions.js";
|
|
2
|
+
import { validId } from "./protocol.js";
|
|
3
|
+
import { parseDaemonStatus } from "./daemon-protocol.js";
|
|
4
|
+
import { DaemonRequestError, DaemonVersionError, RpcError, } from "./daemon-rpc.js";
|
|
5
|
+
import { ensureDaemon, findDaemon } from "./daemon-control.js";
|
|
6
|
+
export { createNodeConfig, nodeConfigFromEnvironment, readOptionalCallerCredential, CallerConfigurationError, } from "./local.js";
|
|
7
|
+
export { daemonStatus, startDaemon, stopDaemon } from "./daemon-control.js";
|
|
8
|
+
export { DaemonRequestError, DaemonVersionError } from "./daemon-rpc.js";
|
|
9
|
+
export class DeviceActionError extends Error {
|
|
10
|
+
response;
|
|
11
|
+
code;
|
|
12
|
+
constructor(response) {
|
|
13
|
+
super(response.error.message);
|
|
14
|
+
this.response = response;
|
|
15
|
+
this.code = response.error.code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function createNodeClient(config, options = {}) {
|
|
19
|
+
let closed = false;
|
|
20
|
+
let incompatible = false;
|
|
21
|
+
let connection;
|
|
22
|
+
let connecting;
|
|
23
|
+
const lifetime = new AbortController();
|
|
24
|
+
const observers = new Set();
|
|
25
|
+
let retry;
|
|
26
|
+
let attempt = 0;
|
|
27
|
+
const assertOpen = () => {
|
|
28
|
+
if (closed)
|
|
29
|
+
throw new Error("The caller client is closed.");
|
|
30
|
+
};
|
|
31
|
+
function emit(event, rpc) {
|
|
32
|
+
for (const observer of observers) {
|
|
33
|
+
observer.seen = rpc;
|
|
34
|
+
try {
|
|
35
|
+
observer.listener(event);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
options.onListenerError?.(error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function scheduleReconnect() {
|
|
43
|
+
if (closed || incompatible || !observers.size || retry || connection)
|
|
44
|
+
return;
|
|
45
|
+
retry = setTimeout(() => {
|
|
46
|
+
retry = undefined;
|
|
47
|
+
void connect(false)
|
|
48
|
+
.catch(() => { })
|
|
49
|
+
.finally(scheduleReconnect);
|
|
50
|
+
}, Math.min(5000, 250 * 2 ** Math.min(attempt++, 5)));
|
|
51
|
+
}
|
|
52
|
+
async function initialize(start) {
|
|
53
|
+
let owned;
|
|
54
|
+
try {
|
|
55
|
+
const found = await (start
|
|
56
|
+
? ensureDaemon(config, lifetime.signal)
|
|
57
|
+
: findDaemon(config, lifetime.signal));
|
|
58
|
+
if (!found)
|
|
59
|
+
return null;
|
|
60
|
+
const { rpc } = found;
|
|
61
|
+
owned = rpc;
|
|
62
|
+
if (closed || !rpc.open) {
|
|
63
|
+
rpc.close();
|
|
64
|
+
throw new DaemonRequestError();
|
|
65
|
+
}
|
|
66
|
+
connection = rpc;
|
|
67
|
+
rpc.onNotification = (method, params) => {
|
|
68
|
+
if (connection !== rpc || closed || method !== "events.connection")
|
|
69
|
+
return;
|
|
70
|
+
const status = parseDaemonStatus(params);
|
|
71
|
+
if (!status) {
|
|
72
|
+
rpc.close();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
emit({ transport: "connected", daemon: status }, rpc);
|
|
76
|
+
};
|
|
77
|
+
rpc.onClose = () => {
|
|
78
|
+
if (connection !== rpc)
|
|
79
|
+
return;
|
|
80
|
+
connection = undefined;
|
|
81
|
+
if (!closed) {
|
|
82
|
+
emit({ transport: "disconnected", daemon: null, reason: "connection_lost" });
|
|
83
|
+
scheduleReconnect();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
if (observers.size)
|
|
87
|
+
await rpc.request("events.subscribe", { topic: "connection" }, 5000);
|
|
88
|
+
attempt = 0;
|
|
89
|
+
return rpc;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
owned?.close();
|
|
93
|
+
if (error instanceof DaemonVersionError && !closed) {
|
|
94
|
+
incompatible = true;
|
|
95
|
+
emit({ transport: "disconnected", daemon: null, reason: "incompatible" });
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function connect(start) {
|
|
101
|
+
assertOpen();
|
|
102
|
+
if (incompatible)
|
|
103
|
+
throw new DaemonVersionError();
|
|
104
|
+
if (connection?.open && !connecting)
|
|
105
|
+
return connection;
|
|
106
|
+
if (connecting) {
|
|
107
|
+
const result = await connecting;
|
|
108
|
+
return result || !start ? result : connect(true);
|
|
109
|
+
}
|
|
110
|
+
connecting = initialize(start).finally(() => {
|
|
111
|
+
connecting = undefined;
|
|
112
|
+
});
|
|
113
|
+
return connecting;
|
|
114
|
+
}
|
|
115
|
+
async function call(method, params) {
|
|
116
|
+
const rpc = await connect(true);
|
|
117
|
+
assertOpen();
|
|
118
|
+
if (!rpc)
|
|
119
|
+
throw new DaemonRequestError();
|
|
120
|
+
return rpc.request(method, params);
|
|
121
|
+
}
|
|
122
|
+
async function run(deviceId, action, args = {}) {
|
|
123
|
+
assertOpen();
|
|
124
|
+
if (!validId(deviceId) || typeof action !== "string" || !action || action.length > 128)
|
|
125
|
+
return actionError("invalid_request", "A device ID and action are required.");
|
|
126
|
+
let body;
|
|
127
|
+
try {
|
|
128
|
+
body = JSON.parse(JSON.stringify({ deviceId, action, args }));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return actionError("invalid_request", "Action arguments must be JSON.");
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const response = parseResponse(await call("devices.run", body));
|
|
135
|
+
return (response ??
|
|
136
|
+
actionError("unknown_outcome", "The action response was invalid. Do not automatically retry."));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error instanceof DaemonRequestError)
|
|
140
|
+
return actionError(error.code, error.message);
|
|
141
|
+
if (error instanceof RpcError)
|
|
142
|
+
return actionError(error.code === -32602 ? "invalid_request" : "gateway_unavailable", error.message);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
async getConnectionStatus() {
|
|
148
|
+
const rpc = await connect(false);
|
|
149
|
+
if (!rpc)
|
|
150
|
+
return null;
|
|
151
|
+
const status = parseDaemonStatus(await rpc.request("daemon.status", {}, 5000));
|
|
152
|
+
if (!status)
|
|
153
|
+
throw new Error("Invalid caller daemon status.");
|
|
154
|
+
return status;
|
|
155
|
+
},
|
|
156
|
+
async getDevicesStatus() {
|
|
157
|
+
let body;
|
|
158
|
+
try {
|
|
159
|
+
const rpc = await connect(false);
|
|
160
|
+
if (!rpc)
|
|
161
|
+
return null;
|
|
162
|
+
body = await rpc.request("devices.status");
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error instanceof RpcError && error.code === -32601)
|
|
166
|
+
throw new DaemonVersionError();
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
if (!isRecord(body) ||
|
|
170
|
+
!Array.isArray(body.devices) ||
|
|
171
|
+
typeof body.checkedAt !== "string" ||
|
|
172
|
+
!["stopped", "connecting", "online", "retrying", "revoked", "superseded"].includes(String(body.connection)) ||
|
|
173
|
+
!body.devices.every((device) => isRecord(device) &&
|
|
174
|
+
typeof device.id === "string" &&
|
|
175
|
+
typeof device.name === "string" &&
|
|
176
|
+
(device.platform === null || typeof device.platform === "string") &&
|
|
177
|
+
["connected", "not_connected", "unknown", "revoked"].includes(String(device.status))))
|
|
178
|
+
throw new Error("Invalid devices status response.");
|
|
179
|
+
return body;
|
|
180
|
+
},
|
|
181
|
+
async listDevices() {
|
|
182
|
+
let body;
|
|
183
|
+
try {
|
|
184
|
+
body = await call("devices.list");
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error instanceof RpcError)
|
|
188
|
+
throw new DeviceActionError({
|
|
189
|
+
type: "response",
|
|
190
|
+
ok: false,
|
|
191
|
+
error: { code: "gateway_unavailable", message: error.message },
|
|
192
|
+
});
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
if (!isRecord(body) ||
|
|
196
|
+
!Array.isArray(body.items) ||
|
|
197
|
+
!body.items.every((item) => isRecord(item) && typeof item.id === "string"))
|
|
198
|
+
throw new Error("Invalid device list response.");
|
|
199
|
+
return body;
|
|
200
|
+
},
|
|
201
|
+
run,
|
|
202
|
+
describe: (deviceId) => run(deviceId, "system.info"),
|
|
203
|
+
async subscribe(topic, listener) {
|
|
204
|
+
assertOpen();
|
|
205
|
+
if (topic !== "connection")
|
|
206
|
+
throw new Error("Unknown event topic.");
|
|
207
|
+
const observer = { listener };
|
|
208
|
+
observers.add(observer);
|
|
209
|
+
try {
|
|
210
|
+
const rpc = await connect(true);
|
|
211
|
+
if (!rpc)
|
|
212
|
+
throw new DaemonRequestError();
|
|
213
|
+
if (observer.seen !== rpc)
|
|
214
|
+
await rpc.request("events.subscribe", { topic }, 5000);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
observers.delete(observer);
|
|
218
|
+
if (!observers.size) {
|
|
219
|
+
clearTimeout(retry);
|
|
220
|
+
retry = undefined;
|
|
221
|
+
}
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
return async () => {
|
|
225
|
+
if (!observers.delete(observer) || observers.size)
|
|
226
|
+
return;
|
|
227
|
+
clearTimeout(retry);
|
|
228
|
+
retry = undefined;
|
|
229
|
+
if (connection?.open)
|
|
230
|
+
await connection.request("events.unsubscribe", { topic }, 5000);
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
disconnect() {
|
|
234
|
+
if (closed)
|
|
235
|
+
return;
|
|
236
|
+
closed = true;
|
|
237
|
+
clearTimeout(retry);
|
|
238
|
+
observers.clear();
|
|
239
|
+
lifetime.abort();
|
|
240
|
+
connection?.close();
|
|
241
|
+
connection = undefined;
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DaemonStatus } from "./daemon-protocol.js";
|
|
2
|
+
import { type NodeConfig } from "./local.js";
|
|
3
|
+
export declare function findDaemon(config: NodeConfig, signal?: AbortSignal): Promise<{
|
|
4
|
+
rpc: import("./daemon-rpc.js").RpcConnection;
|
|
5
|
+
status: DaemonStatus;
|
|
6
|
+
} | null>;
|
|
7
|
+
export declare function daemonStatus(config: NodeConfig): Promise<DaemonStatus | null>;
|
|
8
|
+
export declare function ensureDaemon(config: NodeConfig, signal?: AbortSignal): Promise<{
|
|
9
|
+
rpc: import("./daemon-rpc.js").RpcConnection;
|
|
10
|
+
status: DaemonStatus;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function startDaemon(config: NodeConfig): Promise<DaemonStatus>;
|
|
13
|
+
export declare function stopDaemon(config: NodeConfig): Promise<void>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { isRecord } from "./actions.js";
|
|
5
|
+
import { DAEMON_PROTOCOL_VERSION } from "./daemon-protocol.js";
|
|
6
|
+
import { connectRpc, DaemonVersionError } from "./daemon-rpc.js";
|
|
7
|
+
import { daemonStatePath, readCallerCredential } from "./local.js";
|
|
8
|
+
import { readPrivateJson } from "./storage.js";
|
|
9
|
+
async function readDaemonState(config) {
|
|
10
|
+
const value = await readPrivateJson(daemonStatePath(config));
|
|
11
|
+
if (!isRecord(value) ||
|
|
12
|
+
typeof value.token !== "string" ||
|
|
13
|
+
!/^romenode_[A-Za-z0-9_-]{43}$/.test(value.token) ||
|
|
14
|
+
value.port !== config.port ||
|
|
15
|
+
typeof value.pid !== "number" ||
|
|
16
|
+
!Number.isSafeInteger(value.pid) ||
|
|
17
|
+
value.pid <= 0)
|
|
18
|
+
return null;
|
|
19
|
+
return {
|
|
20
|
+
token: value.token,
|
|
21
|
+
port: config.port,
|
|
22
|
+
pid: value.pid,
|
|
23
|
+
...(typeof value.protocolVersion === "number"
|
|
24
|
+
? { protocolVersion: value.protocolVersion }
|
|
25
|
+
: {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function legacyRequest(state, path) {
|
|
29
|
+
const response = await fetch(`http://127.0.0.1:${state.port}${path}`, {
|
|
30
|
+
method: path === "/stop" ? "POST" : "GET",
|
|
31
|
+
headers: { authorization: `Bearer ${state.token}` },
|
|
32
|
+
redirect: "error",
|
|
33
|
+
signal: AbortSignal.timeout(1000),
|
|
34
|
+
});
|
|
35
|
+
const value = await response.json();
|
|
36
|
+
return response.ok && isRecord(value) ? value : null;
|
|
37
|
+
}
|
|
38
|
+
export async function findDaemon(config, signal) {
|
|
39
|
+
signal?.throwIfAborted();
|
|
40
|
+
const state = await readDaemonState(config);
|
|
41
|
+
if (!state)
|
|
42
|
+
return null;
|
|
43
|
+
if (state.protocolVersion !== DAEMON_PROTOCOL_VERSION) {
|
|
44
|
+
const legacy = await legacyRequest(state, "/status").catch(() => null);
|
|
45
|
+
if (legacy?.pid === state.pid)
|
|
46
|
+
throw new DaemonVersionError();
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return await connectRpc(state, signal);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
signal?.throwIfAborted();
|
|
53
|
+
if (error instanceof DaemonVersionError)
|
|
54
|
+
throw error;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function daemonStatus(config) {
|
|
59
|
+
const found = await findDaemon(config);
|
|
60
|
+
if (!found)
|
|
61
|
+
return null;
|
|
62
|
+
found.rpc.close();
|
|
63
|
+
return found.status;
|
|
64
|
+
}
|
|
65
|
+
export async function ensureDaemon(config, signal) {
|
|
66
|
+
const current = await findDaemon(config, signal);
|
|
67
|
+
if (current)
|
|
68
|
+
return current;
|
|
69
|
+
await readCallerCredential(config);
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
let child;
|
|
72
|
+
let failed = false;
|
|
73
|
+
let nextLaunch = 0;
|
|
74
|
+
const deadline = Date.now() + 5000;
|
|
75
|
+
while (Date.now() < deadline && !failed) {
|
|
76
|
+
const current = await findDaemon(config, signal);
|
|
77
|
+
if (current)
|
|
78
|
+
return current;
|
|
79
|
+
signal?.throwIfAborted();
|
|
80
|
+
if (!child && Date.now() >= nextLaunch && Date.now() < deadline) {
|
|
81
|
+
const launched = spawn(process.execPath, [fileURLToPath(new URL("../bin/daemon.js", import.meta.url))], {
|
|
82
|
+
detached: true,
|
|
83
|
+
stdio: "ignore",
|
|
84
|
+
windowsHide: true,
|
|
85
|
+
env: {
|
|
86
|
+
...process.env,
|
|
87
|
+
ROME_NODE_CONFIG_DIR: config.directory,
|
|
88
|
+
ROME_NODE_DAEMON_PORT: String(config.port),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
child = launched;
|
|
92
|
+
nextLaunch = Date.now() + 250;
|
|
93
|
+
launched.once("error", () => {
|
|
94
|
+
failed = true;
|
|
95
|
+
});
|
|
96
|
+
launched.once("exit", () => {
|
|
97
|
+
if (child === launched)
|
|
98
|
+
child = undefined;
|
|
99
|
+
});
|
|
100
|
+
launched.unref();
|
|
101
|
+
}
|
|
102
|
+
await delay(50, undefined, { signal });
|
|
103
|
+
}
|
|
104
|
+
throw new Error("Could not start the caller daemon. Check the configuration directory and daemon port.");
|
|
105
|
+
}
|
|
106
|
+
export async function startDaemon(config) {
|
|
107
|
+
const { rpc, status } = await ensureDaemon(config);
|
|
108
|
+
rpc.close();
|
|
109
|
+
return status;
|
|
110
|
+
}
|
|
111
|
+
export async function stopDaemon(config) {
|
|
112
|
+
const state = await readDaemonState(config);
|
|
113
|
+
if (!state)
|
|
114
|
+
return;
|
|
115
|
+
if (state.protocolVersion !== DAEMON_PROTOCOL_VERSION) {
|
|
116
|
+
const legacy = await legacyRequest(state, "/status").catch(() => null);
|
|
117
|
+
if (legacy?.pid === state.pid) {
|
|
118
|
+
await legacyRequest(state, "/stop");
|
|
119
|
+
}
|
|
120
|
+
else
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const found = await findDaemon(config);
|
|
125
|
+
if (!found)
|
|
126
|
+
return;
|
|
127
|
+
try {
|
|
128
|
+
await found.rpc.request("daemon.stop", {}, 5000);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
found.rpc.close();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const deadline = Date.now() + 5000;
|
|
135
|
+
while (Date.now() < deadline) {
|
|
136
|
+
const found = await findDaemon(config).catch((error) => {
|
|
137
|
+
if (error instanceof DaemonVersionError)
|
|
138
|
+
return "legacy";
|
|
139
|
+
throw error;
|
|
140
|
+
});
|
|
141
|
+
if (!found)
|
|
142
|
+
return;
|
|
143
|
+
if (found !== "legacy") {
|
|
144
|
+
found.rpc.close();
|
|
145
|
+
if (found.status.pid !== state.pid)
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
await delay(50);
|
|
149
|
+
}
|
|
150
|
+
throw new Error("The caller daemon has not stopped yet.");
|
|
151
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { serveDaemon } from "./daemon.js";
|
|
2
|
+
import { nodeConfigFromEnvironment } from "./local.js";
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
const stop = () => controller.abort();
|
|
5
|
+
process.once("SIGINT", stop);
|
|
6
|
+
process.once("SIGTERM", stop);
|
|
7
|
+
try {
|
|
8
|
+
await serveDaemon(nodeConfigFromEnvironment(process.env), controller.signal);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
process.removeListener("SIGINT", stop);
|
|
15
|
+
process.removeListener("SIGTERM", stop);
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ConnectionStatus } from "./client.js";
|
|
2
|
+
export declare const DAEMON_PROTOCOL_VERSION = 2;
|
|
3
|
+
export interface DaemonStatus {
|
|
4
|
+
pid: number;
|
|
5
|
+
protocolVersion: number;
|
|
6
|
+
connection: ConnectionStatus;
|
|
7
|
+
}
|
|
8
|
+
export declare function parseDaemonStatus(value: unknown): DaemonStatus | null;
|
|
9
|
+
export type ConnectionEvent = {
|
|
10
|
+
transport: "connected";
|
|
11
|
+
daemon: DaemonStatus;
|
|
12
|
+
} | {
|
|
13
|
+
transport: "disconnected";
|
|
14
|
+
daemon: null;
|
|
15
|
+
reason: "connection_lost" | "incompatible";
|
|
16
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isRecord } from "./actions.js";
|
|
2
|
+
export const DAEMON_PROTOCOL_VERSION = 2;
|
|
3
|
+
export function parseDaemonStatus(value) {
|
|
4
|
+
if (!isRecord(value) ||
|
|
5
|
+
!Number.isSafeInteger(value.pid) ||
|
|
6
|
+
Number(value.pid) <= 0 ||
|
|
7
|
+
typeof value.protocolVersion !== "number" ||
|
|
8
|
+
!["stopped", "connecting", "online", "retrying", "revoked", "superseded"].includes(String(value.connection)))
|
|
9
|
+
return null;
|
|
10
|
+
return {
|
|
11
|
+
pid: value.pid,
|
|
12
|
+
protocolVersion: value.protocolVersion,
|
|
13
|
+
connection: value.connection,
|
|
14
|
+
};
|
|
15
|
+
}
|