@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/daemon.js +2 -0
  4. package/dist/actions.d.ts +20 -0
  5. package/dist/actions.js +18 -0
  6. package/dist/auth.d.ts +3 -0
  7. package/dist/auth.js +76 -0
  8. package/dist/client.d.ts +28 -0
  9. package/dist/client.js +130 -0
  10. package/dist/cloud.d.ts +7 -0
  11. package/dist/cloud.js +56 -0
  12. package/dist/connect.d.ts +27 -0
  13. package/dist/connect.js +115 -0
  14. package/dist/connector.d.ts +26 -0
  15. package/dist/connector.js +155 -0
  16. package/dist/daemon-client.d.ts +37 -0
  17. package/dist/daemon-client.js +244 -0
  18. package/dist/daemon-control.d.ts +13 -0
  19. package/dist/daemon-control.js +151 -0
  20. package/dist/daemon-entry.d.ts +1 -0
  21. package/dist/daemon-entry.js +16 -0
  22. package/dist/daemon-protocol.d.ts +16 -0
  23. package/dist/daemon-protocol.js +15 -0
  24. package/dist/daemon-rpc.d.ts +34 -0
  25. package/dist/daemon-rpc.js +171 -0
  26. package/dist/daemon.d.ts +2 -0
  27. package/dist/daemon.js +235 -0
  28. package/dist/devices-status.d.ts +23 -0
  29. package/dist/devices-status.js +68 -0
  30. package/dist/executor.d.ts +5 -0
  31. package/dist/executor.js +105 -0
  32. package/dist/gateway-url.d.ts +1 -0
  33. package/dist/gateway-url.js +11 -0
  34. package/dist/host.d.ts +3 -0
  35. package/dist/host.js +3 -0
  36. package/dist/index.d.ts +4 -0
  37. package/dist/index.js +4 -0
  38. package/dist/local.d.ts +21 -0
  39. package/dist/local.js +47 -0
  40. package/dist/login.d.ts +6 -0
  41. package/dist/login.js +107 -0
  42. package/dist/protocol.d.ts +29 -0
  43. package/dist/protocol.js +39 -0
  44. package/dist/socket.d.ts +2 -0
  45. package/dist/socket.js +25 -0
  46. package/dist/storage.d.ts +8 -0
  47. package/dist/storage.js +55 -0
  48. package/package.json +64 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rome OS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # Rome Node Core
2
+
3
+ `@rome-os/node-core` provides JavaScript APIs for a shared caller daemon and an
4
+ embedded computer host. It runs on Node.js 24 or later, including an Electron
5
+ main process. The CLI is published separately as `@rome-os/node`.
6
+
7
+ ## Shared caller
8
+
9
+ A Web Server and CLI using the same configuration directory and daemon port
10
+ share one caller daemon. The daemon owns the communication credential, Gateway
11
+ connection, and pending requests. Each client uses one local WebSocket for
12
+ requests and event subscriptions. The client entry does not load the host executor.
13
+
14
+ ```ts
15
+ import {
16
+ createNodeClient,
17
+ nodeConfigFromEnvironment,
18
+ } from "@rome-os/node-core/client";
19
+
20
+ const client = createNodeClient(nodeConfigFromEnvironment(process.env));
21
+ const status = await client.getConnectionStatus();
22
+ const devices = await client.listDevices();
23
+ const connections = await client.getDevicesStatus();
24
+ const info = await client.describe("device-id");
25
+ const result = await client.run("device-id", "exec", {
26
+ command: "git",
27
+ args: ["status"],
28
+ cwd: "/workspace",
29
+ });
30
+ client.disconnect();
31
+ ```
32
+
33
+ `getConnectionStatus()` returns `null` when no daemon runs. Otherwise it returns
34
+ `{ pid, protocolVersion, connection }`. It does not start a daemon or open a
35
+ Gateway connection. `connection` describes the caller connection to Gateway,
36
+ not the online state of remote devices. `listDevices()` returns authorized
37
+ devices without asserting that they are online.
38
+
39
+ `getDevicesStatus()` checks device reachability through read-only `system.info`
40
+ requests through an existing daemon and returns `{ connection, checkedAt, devices }`.
41
+ It returns `null` when no daemon runs and never starts or restarts one. Each device has
42
+ `id`, `name`, `platform`, and status `connected`, `not_connected`, `unknown`, or
43
+ `revoked`. The daemon shares concurrent checks and caches snapshots for ten
44
+ seconds. A timeout is unknown, not proof that a device is offline. This call
45
+ opens the shared Gateway connection when there are active devices to check.
46
+
47
+ Device operations start the daemon on demand. Only remote actions open its
48
+ Gateway connection. The daemon starts from this package's own entry point and
49
+ does not require an installed CLI. Concurrent launches elect one owner through
50
+ the OS listener bind before writing discovery state or connecting to Gateway.
51
+
52
+ `disconnect()` closes the local client handle. It does not stop the shared daemon,
53
+ cancel submitted operations, or interrupt other clients. `startDaemon(config)`
54
+ and `stopDaemon(config)` explicitly manage the shared service. An incompatible
55
+ protocol requires an explicit stop before restart. Requests are never replayed
56
+ after a lost response.
57
+
58
+ Subscribe to caller connection changes:
59
+
60
+ ```ts
61
+ const unsubscribe = await client.subscribe("connection", (event) => {
62
+ if (event.transport === "connected") {
63
+ updateConnection(event.daemon.connection);
64
+ } else {
65
+ clearConnection();
66
+ }
67
+ });
68
+
69
+ await unsubscribe();
70
+ client.disconnect();
71
+ ```
72
+
73
+ `updateConnection` and `clearConnection` are application callbacks. Subscription
74
+ starts the daemon if needed and delivers a snapshot before resolving. It does
75
+ not open a Gateway connection. Events describe the daemon's caller connection;
76
+ they do not report remote device presence. A local connection loss delivers
77
+ `{ transport: "disconnected", daemon: null, reason: "connection_lost" }`.
78
+
79
+ While subscribed, the client reconnects with backoff and restores subscriptions
80
+ with a fresh snapshot. It does not restart a stopped daemon or replay missed
81
+ events. Incompatible protocols deliver reason `incompatible` and stop reconnection.
82
+ Listener exceptions can be handled with `createNodeClient(config, { onListenerError })`.
83
+ Unsubscribe removes one listener; other listeners and clients remain subscribed.
84
+ Close the client with `disconnect()` when its owning application shuts down.
85
+
86
+ Use `createNodeConfig({ directory, port? })` for an explicit location. Pass the
87
+ same environment to `nodeConfigFromEnvironment()` as the CLI to honor
88
+ `ROME_NODE_CONFIG_DIR`, `ROME_NODE_DAEMON_PORT`, and platform defaults.
89
+ The library does not select an environment implicitly.
90
+
91
+ ## Caller authorization
92
+
93
+ ```ts
94
+ import { authorizeServer } from "@rome-os/node-core/auth";
95
+ import { nodeConfigFromEnvironment } from "@rome-os/node-core/client";
96
+
97
+ await authorizeServer(cloudOrigin, instanceToken, nodeConfigFromEnvironment(process.env));
98
+ ```
99
+
100
+ Authorization exchanges the Instance Token with Cloud and stores only the
101
+ communication credential. It does not start the daemon. Existing credentials
102
+ for the selected Cloud origin are reused. `configureCaller(origin, token, config)`
103
+ accepts an issued communication credential instead. The optional fourth argument
104
+ to `authorizeServer` receives retry notifications without terminal output.
105
+
106
+ ## Embedded host
107
+
108
+ The host runs in the application process, independently of the caller daemon.
109
+ The application supplies credential storage, authorization interaction, and an
110
+ abort signal. It receives structured events instead of terminal output.
111
+
112
+ ```ts
113
+ import {
114
+ connectHost,
115
+ createFileCredentialStore,
116
+ loginDevice,
117
+ } from "@rome-os/node-core/host";
118
+
119
+ const controller = new AbortController();
120
+ const running = connectHost({
121
+ cloudUrl: "https://romeos.cc",
122
+ name: "My computer",
123
+ signal: controller.signal,
124
+ credentials: createFileCredentialStore("/private/app-data/credential.json"),
125
+ authorize: (origin, name, signal) =>
126
+ loginDevice(origin, name, signal, (url) => showAuthorizationUrl(url)),
127
+ onEvent: (event) => updateApplicationState(event),
128
+ });
129
+
130
+ // At application shutdown, abort and await the host connection's completion.
131
+ controller.abort();
132
+ await running;
133
+ ```
134
+
135
+ `showAuthorizationUrl` and `updateApplicationState` are application callbacks.
136
+ A custom credential store implements `load`, `save`, and `clear`. The built-in
137
+ login adapter uses a temporary loopback callback listener. Applications can
138
+ supply their own authorization function returning a device session.
139
+
140
+ The host exposes `system.info` and `exec`. Programs run as the application's OS
141
+ user. Connection loss terminates direct child processes on a best-effort basis.
142
+ A local caller timeout does not cancel remote execution.
143
+
144
+ See [the architecture and operating guide](../../docs/rome-node.md) for protocol,
145
+ credential, and failure semantics.
package/bin/daemon.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/daemon-entry.js";
@@ -0,0 +1,20 @@
1
+ export interface ActionRequest {
2
+ type: "request";
3
+ action: string;
4
+ args: unknown;
5
+ }
6
+ export type ActionResponse = {
7
+ type: "response";
8
+ ok: true;
9
+ result: unknown;
10
+ } | {
11
+ type: "response";
12
+ ok: false;
13
+ error: {
14
+ code: string;
15
+ message: string;
16
+ };
17
+ };
18
+ export declare function actionError(code: string, message: string): ActionResponse;
19
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
20
+ export declare function parseResponse(value: unknown): ActionResponse | null;
@@ -0,0 +1,18 @@
1
+ export function actionError(code, message) {
2
+ return { type: "response", ok: false, error: { code, message } };
3
+ }
4
+ export function isRecord(value) {
5
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+ export function parseResponse(value) {
8
+ if (!isRecord(value) || value.type !== "response")
9
+ return null;
10
+ if (value.ok === true && Object.hasOwn(value, "result"))
11
+ return { type: "response", ok: true, result: value.result };
12
+ if (value.ok === false &&
13
+ isRecord(value.error) &&
14
+ typeof value.error.code === "string" &&
15
+ typeof value.error.message === "string")
16
+ return actionError(value.error.code, value.error.message);
17
+ return null;
18
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { type NodeConfig } from "./local.js";
2
+ export declare function configureCaller(origin: string, token: string, config: NodeConfig): Promise<void>;
3
+ export declare function authorizeServer(origin: string, instanceToken: string | undefined, config: NodeConfig, onRetry?: () => void): Promise<void>;
package/dist/auth.js ADDED
@@ -0,0 +1,76 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { isRecord } from "./actions.js";
3
+ import { CloudError, cloudOrigin, gatewayConfig } from "./cloud.js";
4
+ import { daemonStatus } from "./daemon-client.js";
5
+ import { callerCredentialPath, readOptionalCallerCredential, CallerConfigurationError, } from "./local.js";
6
+ import { writePrivateJson } from "./storage.js";
7
+ const communicationToken = /^romedev_[A-Za-z0-9_-]{43}$/;
8
+ class ServerAuthError extends Error {
9
+ retryable;
10
+ constructor(message, retryable = false) {
11
+ super(message);
12
+ this.retryable = retryable;
13
+ }
14
+ }
15
+ export async function configureCaller(origin, token, config) {
16
+ if (!communicationToken.test(token))
17
+ throw new Error("Invalid communication token.");
18
+ if (await daemonStatus(config))
19
+ throw new CallerConfigurationError("daemon_running", "Stop the caller daemon before changing credentials.");
20
+ await gatewayConfig(origin, token);
21
+ await writePrivateJson(callerCredentialPath(config), { cloudUrl: origin, token });
22
+ }
23
+ async function exchangeInstanceToken(origin, token) {
24
+ let response;
25
+ try {
26
+ response = await fetch(new URL("/api/instance/gateway-credential", origin), {
27
+ method: "POST",
28
+ headers: { Authorization: `Bearer ${token}` },
29
+ redirect: "error",
30
+ cache: "no-store",
31
+ signal: AbortSignal.timeout(10_000),
32
+ });
33
+ }
34
+ catch {
35
+ throw new ServerAuthError("Could not reach Rome Cloud to authorize this server.", true);
36
+ }
37
+ if (!response.ok)
38
+ throw new ServerAuthError(`Rome Cloud rejected server authorization (HTTP ${response.status}).`, response.status === 429 || response.status >= 500);
39
+ const body = await response.json().catch(() => null);
40
+ if (!isRecord(body) ||
41
+ typeof body.deviceToken !== "string" ||
42
+ !communicationToken.test(body.deviceToken))
43
+ throw new ServerAuthError("Rome Cloud returned an invalid communication credential.");
44
+ return body.deviceToken;
45
+ }
46
+ export async function authorizeServer(origin, instanceToken, config, onRetry) {
47
+ const cloudUrl = cloudOrigin(origin);
48
+ const existing = await readOptionalCallerCredential(config);
49
+ if (existing) {
50
+ if (existing.cloudUrl !== cloudUrl)
51
+ throw new Error("Caller credentials belong to another Cloud origin. Configure auth manually.");
52
+ return;
53
+ }
54
+ const token = instanceToken?.trim();
55
+ if (!token || !/^romeinst_[A-Za-z0-9_-]+$/.test(token))
56
+ throw new CallerConfigurationError("instance_token_required", "Server authorization requires an Instance Token.");
57
+ if (await daemonStatus(config))
58
+ throw new CallerConfigurationError("daemon_running", "Stop the caller daemon before configuring credentials.");
59
+ let deviceToken;
60
+ for (let attempt = 0; attempt < 3; attempt++) {
61
+ if (attempt > 0)
62
+ await delay(attempt === 1 ? 1000 : 5000);
63
+ try {
64
+ deviceToken ??= await exchangeInstanceToken(cloudUrl, token);
65
+ await configureCaller(cloudUrl, deviceToken, config);
66
+ return;
67
+ }
68
+ catch (error) {
69
+ const retryable = (error instanceof ServerAuthError && error.retryable) ||
70
+ (error instanceof CloudError && error.code === "cloud_unavailable");
71
+ if (!retryable || attempt === 2)
72
+ throw error;
73
+ onRetry?.();
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,28 @@
1
+ import { type GatewayMessage, type OutboundEnvelope } from "./protocol.js";
2
+ export interface ClientSocket {
3
+ readyState: number;
4
+ send(data: string): void;
5
+ close(code?: number, reason?: string): void;
6
+ addEventListener(type: "open", listener: () => void): void;
7
+ addEventListener(type: "message", listener: (event: {
8
+ data: unknown;
9
+ }) => void): void;
10
+ addEventListener(type: "close", listener: (event: {
11
+ code: number;
12
+ }) => void): void;
13
+ addEventListener(type: "error", listener: () => void): void;
14
+ }
15
+ export type ConnectionStatus = "connecting" | "online" | "retrying" | "revoked" | "superseded" | "stopped";
16
+ export interface GatewayClientOptions {
17
+ gatewayUrl: string;
18
+ deviceToken: string;
19
+ createSocket(url: string, authorization: string): ClientSocket;
20
+ onMessage(message: GatewayMessage): void;
21
+ onStatus?(status: ConnectionStatus): void;
22
+ random?(): number;
23
+ beforeConnect?(): Promise<string | null>;
24
+ }
25
+ export declare function connectGateway(options: GatewayClientOptions): {
26
+ send(envelope: OutboundEnvelope): boolean;
27
+ stop(): void;
28
+ };
package/dist/client.js ADDED
@@ -0,0 +1,130 @@
1
+ import { parseGatewayUrl } from "./gateway-url.js";
2
+ import { CLOSE, gatewayMessage, outboundEnvelope, } from "./protocol.js";
3
+ export function connectGateway(options) {
4
+ let url = parseGatewayUrl(options.gatewayUrl);
5
+ if (!/^(?:romedev_|romemob_)[A-Za-z0-9_-]{43}$/.test(options.deviceToken))
6
+ throw new Error("Invalid device token format");
7
+ let stopped = false;
8
+ let socket = null;
9
+ let attempt = 0;
10
+ let timer;
11
+ let connectingTimer;
12
+ let stableTimer;
13
+ const status = (value) => options.onStatus?.(value);
14
+ const schedule = () => {
15
+ if (stopped || timer)
16
+ return;
17
+ status("retrying");
18
+ const delay = Math.min(60_000, 1000 * 2 ** Math.min(attempt++, 6));
19
+ timer = setTimeout(() => {
20
+ timer = undefined;
21
+ void open();
22
+ }, delay * (0.75 + (options.random ?? Math.random)() * 0.5));
23
+ };
24
+ const open = async () => {
25
+ if (stopped)
26
+ return;
27
+ status("connecting");
28
+ let current;
29
+ try {
30
+ if (options.beforeConnect) {
31
+ const next = await options.beforeConnect();
32
+ if (stopped)
33
+ return;
34
+ if (next === null) {
35
+ stopped = true;
36
+ status("revoked");
37
+ return;
38
+ }
39
+ url = parseGatewayUrl(next);
40
+ }
41
+ current = options.createSocket(url.toString(), `Bearer ${options.deviceToken}`);
42
+ }
43
+ catch {
44
+ schedule();
45
+ return;
46
+ }
47
+ socket = current;
48
+ let finished = false;
49
+ const finish = (code) => {
50
+ if (finished || socket !== current)
51
+ return;
52
+ finished = true;
53
+ clearTimeout(connectingTimer);
54
+ clearTimeout(stableTimer);
55
+ socket = null;
56
+ if (code === CLOSE.revoked || code === CLOSE.superseded) {
57
+ stopped = true;
58
+ status(code === CLOSE.revoked ? "revoked" : "superseded");
59
+ }
60
+ else
61
+ schedule();
62
+ };
63
+ connectingTimer = setTimeout(() => {
64
+ finish();
65
+ current.close();
66
+ }, 10_000);
67
+ current.addEventListener("open", () => {
68
+ if (stopped || socket !== current) {
69
+ current.close();
70
+ return;
71
+ }
72
+ clearTimeout(connectingTimer);
73
+ stableTimer = setTimeout(() => {
74
+ attempt = 0;
75
+ }, 30_000);
76
+ status("online");
77
+ });
78
+ current.addEventListener("message", ({ data }) => {
79
+ if (stopped || socket !== current || typeof data !== "string")
80
+ return;
81
+ let message;
82
+ try {
83
+ message = gatewayMessage(JSON.parse(data));
84
+ }
85
+ catch {
86
+ return;
87
+ }
88
+ if (message)
89
+ options.onMessage(message);
90
+ });
91
+ current.addEventListener("close", ({ code }) => finish(code));
92
+ current.addEventListener("error", () => {
93
+ finish();
94
+ current.close();
95
+ });
96
+ };
97
+ void open();
98
+ return {
99
+ send(envelope) {
100
+ if (stopped || !socket || socket.readyState !== 1 || !outboundEnvelope(envelope))
101
+ return false;
102
+ let text;
103
+ try {
104
+ text = JSON.stringify(envelope);
105
+ if (!outboundEnvelope(JSON.parse(text)))
106
+ return false;
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ try {
112
+ socket.send(text);
113
+ return true;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ },
119
+ stop() {
120
+ stopped = true;
121
+ clearTimeout(timer);
122
+ clearTimeout(connectingTimer);
123
+ clearTimeout(stableTimer);
124
+ const previous = socket;
125
+ socket = null;
126
+ previous?.close(1000, "client stopped");
127
+ status("stopped");
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,7 @@
1
+ export declare class CloudError extends Error {
2
+ readonly code: string;
3
+ constructor(code: string);
4
+ }
5
+ export declare function cloudOrigin(value: string): string;
6
+ export declare function cloudRequest(origin: string, path: string, token: string, method?: string): Promise<unknown>;
7
+ export declare function gatewayConfig(origin: string, token: string): Promise<string>;
package/dist/cloud.js ADDED
@@ -0,0 +1,56 @@
1
+ import { isRecord } from "./actions.js";
2
+ import { parseGatewayUrl } from "./gateway-url.js";
3
+ export class CloudError extends Error {
4
+ code;
5
+ constructor(code) {
6
+ super(code === "invalid_device_session"
7
+ ? "Device authorization is invalid."
8
+ : "Rome Cloud is unavailable. Retry later.");
9
+ this.code = code;
10
+ }
11
+ }
12
+ export function cloudOrigin(value) {
13
+ const url = new URL(value);
14
+ if (url.username ||
15
+ url.password ||
16
+ url.search ||
17
+ url.hash ||
18
+ url.pathname !== "/" ||
19
+ (url.protocol !== "https:" &&
20
+ !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))))
21
+ throw new Error("Use an HTTPS Cloud origin. HTTP is allowed only on loopback.");
22
+ return url.origin;
23
+ }
24
+ export async function cloudRequest(origin, path, token, method = "GET") {
25
+ let response;
26
+ try {
27
+ response = await fetch(new URL(path, cloudOrigin(origin)), {
28
+ method,
29
+ headers: { Authorization: `Bearer ${token}` },
30
+ redirect: "error",
31
+ signal: AbortSignal.timeout(10_000),
32
+ cache: "no-store",
33
+ });
34
+ }
35
+ catch {
36
+ throw new CloudError("cloud_unavailable");
37
+ }
38
+ const body = await response.json().catch(() => null);
39
+ if (!response.ok) {
40
+ if (response.status === 401 && isRecord(body) && body.error === "invalid_device_session")
41
+ throw new CloudError("invalid_device_session");
42
+ throw new CloudError("cloud_unavailable");
43
+ }
44
+ return body;
45
+ }
46
+ export async function gatewayConfig(origin, token) {
47
+ const body = await cloudRequest(origin, "/v1/gateway/config", token);
48
+ if (!isRecord(body) || typeof body.gatewayUrl !== "string")
49
+ throw new CloudError("invalid_response");
50
+ try {
51
+ return parseGatewayUrl(body.gatewayUrl).toString();
52
+ }
53
+ catch {
54
+ throw new CloudError("invalid_response");
55
+ }
56
+ }
@@ -0,0 +1,27 @@
1
+ import type { DeviceSession } from "./login.js";
2
+ import type { CredentialStore } from "./storage.js";
3
+ export type HostCredential = DeviceSession & {
4
+ name: string;
5
+ };
6
+ export type HostEvent = {
7
+ type: "authorization_required";
8
+ } | {
9
+ type: "retrying";
10
+ } | {
11
+ type: "device";
12
+ name: string;
13
+ deviceId: string;
14
+ } | {
15
+ type: "connection";
16
+ status: import("./client.js").ConnectionStatus;
17
+ };
18
+ export interface HostOptions {
19
+ cloudUrl: string;
20
+ name: string;
21
+ signal: AbortSignal;
22
+ credentials: CredentialStore<HostCredential>;
23
+ authorize(cloudUrl: string, name: string, signal: AbortSignal): Promise<DeviceSession>;
24
+ onEvent?(event: HostEvent): void;
25
+ }
26
+ export declare function connectHost(options: HostOptions): Promise<void>;
27
+ export declare const defaultDeviceName: () => string;
@@ -0,0 +1,115 @@
1
+ import { hostname } from "node:os";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import { isRecord } from "./actions.js";
4
+ import { cloudOrigin, cloudRequest, CloudError, gatewayConfig } from "./cloud.js";
5
+ import { connectGateway } from "./client.js";
6
+ import { createNodeSocket } from "./socket.js";
7
+ import { createExecutor } from "./executor.js";
8
+ import { validId } from "./protocol.js";
9
+ export async function connectHost(options) {
10
+ const { name, signal, credentials } = options;
11
+ if (signal.aborted)
12
+ return;
13
+ const cloudUrl = cloudOrigin(options.cloudUrl);
14
+ const stored = await credentials.load();
15
+ let session = isRecord(stored) &&
16
+ stored.cloudUrl === cloudUrl &&
17
+ typeof stored.token === "string" &&
18
+ /^romedev_[A-Za-z0-9_-]{43}$/.test(stored.token) &&
19
+ validId(stored.deviceId) &&
20
+ typeof stored.name === "string"
21
+ ? { cloudUrl, token: stored.token, deviceId: stored.deviceId, name: stored.name }
22
+ : null;
23
+ let gatewayUrl = "";
24
+ let attempt = 0;
25
+ while (!signal.aborted) {
26
+ if (!session) {
27
+ options.onEvent?.({ type: "authorization_required" });
28
+ session = { ...(await options.authorize(cloudUrl, name, signal)), name };
29
+ await credentials.save(session);
30
+ }
31
+ try {
32
+ gatewayUrl = await gatewayConfig(cloudUrl, session.token);
33
+ break;
34
+ }
35
+ catch (error) {
36
+ if (error instanceof CloudError && error.code === "invalid_device_session") {
37
+ await credentials.clear();
38
+ session = null;
39
+ continue;
40
+ }
41
+ options.onEvent?.({ type: "retrying" });
42
+ await delay(Math.min(60_000, 1000 * 2 ** Math.min(attempt++, 6)), undefined, {
43
+ signal,
44
+ }).catch(() => { });
45
+ }
46
+ }
47
+ if (signal.aborted || !session)
48
+ return;
49
+ const devices = await cloudRequest(cloudUrl, "/api/account/devices", session.token).catch(() => null);
50
+ if (isRecord(devices) && Array.isArray(devices.items)) {
51
+ const device = devices.items.find((item) => isRecord(item) && item.id === session?.deviceId);
52
+ if (isRecord(device) && typeof device.device_name === "string") {
53
+ session.name = device.device_name;
54
+ await credentials.save(session);
55
+ }
56
+ }
57
+ if (signal.aborted)
58
+ return;
59
+ const active = session;
60
+ options.onEvent?.({ type: "device", name: active.name, deviceId: active.deviceId });
61
+ await new Promise((resolve, reject) => {
62
+ const executor = createExecutor(active.name, (message) => connection.send(message), [
63
+ active.token,
64
+ ]);
65
+ let finished = false;
66
+ const finish = (error) => {
67
+ if (finished)
68
+ return;
69
+ finished = true;
70
+ signal.removeEventListener("abort", abort);
71
+ executor.disconnect();
72
+ connection.stop();
73
+ if (error)
74
+ reject(error);
75
+ else
76
+ resolve();
77
+ };
78
+ const abort = () => finish();
79
+ const connection = connectGateway({
80
+ gatewayUrl,
81
+ deviceToken: active.token,
82
+ createSocket: createNodeSocket,
83
+ beforeConnect: async () => {
84
+ try {
85
+ return await gatewayConfig(cloudUrl, active.token);
86
+ }
87
+ catch (error) {
88
+ if (error instanceof CloudError && error.code === "invalid_device_session")
89
+ return null;
90
+ throw error;
91
+ }
92
+ },
93
+ onMessage: (message) => {
94
+ if ("from" in message)
95
+ void executor.receive(message);
96
+ },
97
+ onStatus: (status) => {
98
+ options.onEvent?.({ type: "connection", status });
99
+ if (status !== "online" && status !== "connecting")
100
+ executor.disconnect();
101
+ if (status === "revoked" || status === "superseded") {
102
+ queueMicrotask(() => finish(new Error(status === "revoked"
103
+ ? "Authorization revoked. Explicit authorization is required."
104
+ : "Another connection replaced this device. This process has stopped.")));
105
+ }
106
+ },
107
+ });
108
+ signal.addEventListener("abort", abort, { once: true });
109
+ if (signal.aborted)
110
+ abort();
111
+ });
112
+ }
113
+ export const defaultDeviceName = () => hostname()
114
+ .replace(/[\u0000-\u001f\u007f]/g, "")
115
+ .slice(0, 80) || "Computer";