@nextclaw/remote 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NextClaw contributors
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,7 @@
1
+ # @nextclaw/remote
2
+
3
+ Remote access runtime for NextClaw.
4
+
5
+ This package contains the device registration flow, websocket relay connector,
6
+ remote request bridge, service-mode runtime module, and the CLI command facade
7
+ used by `nextclaw`.
@@ -0,0 +1,207 @@
1
+ import { Config } from '@nextclaw/core';
2
+ import { Command } from 'commander';
3
+
4
+ type RemoteConnectCommandOptions = {
5
+ apiBase?: string;
6
+ localOrigin?: string;
7
+ name?: string;
8
+ once?: boolean;
9
+ };
10
+ type RemoteEnableCommandOptions = {
11
+ apiBase?: string;
12
+ name?: string;
13
+ };
14
+ type RemoteStatusCommandOptions = {
15
+ json?: boolean;
16
+ };
17
+ type RemoteDoctorCommandOptions = {
18
+ json?: boolean;
19
+ };
20
+ type RemoteRuntimeState = {
21
+ enabled: boolean;
22
+ mode: "service" | "foreground";
23
+ state: "disabled" | "connecting" | "connected" | "disconnected" | "error";
24
+ deviceId?: string;
25
+ deviceName?: string;
26
+ platformBase?: string;
27
+ localOrigin?: string;
28
+ lastConnectedAt?: string | null;
29
+ lastError?: string | null;
30
+ updatedAt: string;
31
+ };
32
+ type RemoteStatusSnapshot = {
33
+ configuredEnabled: boolean;
34
+ runtime: RemoteRuntimeState | null;
35
+ };
36
+ type RemoteLogger = {
37
+ info: (message: string) => void;
38
+ warn: (message: string) => void;
39
+ error: (message: string) => void;
40
+ };
41
+ type RemoteServiceStateView = {
42
+ pid: number;
43
+ uiPort?: number;
44
+ };
45
+ type RegisteredRemoteDevice = {
46
+ id: string;
47
+ deviceInstallId: string;
48
+ displayName: string;
49
+ platform: string;
50
+ appVersion: string;
51
+ localOrigin: string;
52
+ status: "online" | "offline";
53
+ lastSeenAt: string;
54
+ createdAt: string;
55
+ updatedAt: string;
56
+ };
57
+ type RemoteStatusWriter = {
58
+ write: (next: Omit<RemoteRuntimeState, "mode" | "updatedAt">) => void;
59
+ };
60
+ type RemoteConnectorRunOptions = RemoteConnectCommandOptions & {
61
+ signal?: AbortSignal;
62
+ mode?: "foreground" | "service";
63
+ autoReconnect?: boolean;
64
+ statusStore?: RemoteStatusWriter;
65
+ };
66
+ type RemoteRunContext = {
67
+ config: Config;
68
+ platformBase: string;
69
+ token: string;
70
+ localOrigin: string;
71
+ displayName: string;
72
+ deviceInstallId: string;
73
+ autoReconnect: boolean;
74
+ };
75
+ type RemotePlatformClientDeps = {
76
+ loadConfig: () => Config;
77
+ getDataDir: () => string;
78
+ getPackageVersion: () => string;
79
+ resolvePlatformBase: (rawApiBase: string) => string;
80
+ readManagedServiceState?: () => RemoteServiceStateView | null;
81
+ isProcessRunning?: (pid: number) => boolean;
82
+ };
83
+
84
+ type RemoteCommandRuntime = {
85
+ enable: (opts?: RemoteEnableCommandOptions) => Promise<void>;
86
+ disable: () => Promise<void>;
87
+ status: (opts?: RemoteStatusCommandOptions) => Promise<void>;
88
+ doctor: (opts?: RemoteDoctorCommandOptions) => Promise<void>;
89
+ connect: (opts?: RemoteConnectCommandOptions) => Promise<void>;
90
+ };
91
+ declare function registerRemoteCommands(program: Command, runtime: RemoteCommandRuntime): void;
92
+
93
+ type RemoteConfigChange = {
94
+ changed: boolean;
95
+ config: Config;
96
+ };
97
+ type RemoteCommandDriver = {
98
+ connect: (opts?: RemoteConnectCommandOptions) => Promise<void>;
99
+ enableConfig: (opts?: RemoteEnableCommandOptions) => RemoteConfigChange;
100
+ disableConfig: () => RemoteConfigChange;
101
+ status: (opts?: RemoteStatusCommandOptions) => Promise<void>;
102
+ doctor: (opts?: RemoteDoctorCommandOptions) => Promise<void>;
103
+ };
104
+ declare class RemoteRuntimeActions {
105
+ private readonly deps;
106
+ constructor(deps: {
107
+ appName: string;
108
+ initAuto: (source: string) => Promise<void>;
109
+ remoteCommands: RemoteCommandDriver;
110
+ restartBackgroundService: (reason: string) => Promise<boolean>;
111
+ hasRunningManagedService: () => boolean;
112
+ });
113
+ connect(opts?: RemoteConnectCommandOptions): Promise<void>;
114
+ enable(opts?: RemoteEnableCommandOptions): Promise<void>;
115
+ disable(): Promise<void>;
116
+ status(opts?: RemoteStatusCommandOptions): Promise<void>;
117
+ doctor(opts?: RemoteDoctorCommandOptions): Promise<void>;
118
+ }
119
+
120
+ declare function normalizeOptionalString(value: unknown): string | undefined;
121
+ declare function delay(ms: number, signal?: AbortSignal): Promise<void>;
122
+ declare function redactWsUrl(url: string): string;
123
+ declare class RemotePlatformClient {
124
+ private readonly deps;
125
+ constructor(deps: RemotePlatformClientDeps);
126
+ private get remoteDir();
127
+ private get devicePath();
128
+ resolveRunContext(opts: RemoteConnectorRunOptions): RemoteRunContext;
129
+ registerDevice(params: {
130
+ platformBase: string;
131
+ token: string;
132
+ deviceInstallId: string;
133
+ displayName: string;
134
+ localOrigin: string;
135
+ }): Promise<RegisteredRemoteDevice>;
136
+ private ensureDeviceInstallId;
137
+ private resolvePlatformAccess;
138
+ private resolveLocalOrigin;
139
+ private resolveDisplayName;
140
+ }
141
+
142
+ type RelayRequestFrame = {
143
+ type: "request";
144
+ requestId: string;
145
+ method: string;
146
+ path: string;
147
+ headers: Array<[string, string]>;
148
+ bodyBase64?: string;
149
+ };
150
+ declare class RemoteRelayBridge {
151
+ private readonly localOrigin;
152
+ constructor(localOrigin: string);
153
+ ensureLocalUiHealthy(): Promise<void>;
154
+ forward(frame: RelayRequestFrame, socket: WebSocket): Promise<void>;
155
+ private createForwardHeaders;
156
+ private requestBridgeCookie;
157
+ private sendStreamingResponse;
158
+ }
159
+
160
+ declare class RemoteConnector {
161
+ private readonly deps;
162
+ constructor(deps: {
163
+ platformClient: RemotePlatformClient;
164
+ relayBridgeFactory?: (localOrigin: string) => RemoteRelayBridge;
165
+ logger?: RemoteLogger;
166
+ });
167
+ private get logger();
168
+ private connectOnce;
169
+ private handleSocketMessage;
170
+ private parseRelayFrame;
171
+ private ensureDevice;
172
+ private writeRemoteState;
173
+ private runCycle;
174
+ run(opts?: RemoteConnectorRunOptions): Promise<void>;
175
+ }
176
+
177
+ declare function buildConfiguredRemoteState(config: Config): RemoteRuntimeState;
178
+ declare function resolveRemoteStatusSnapshot(params: {
179
+ config: Config;
180
+ currentRemoteState?: RemoteRuntimeState | null;
181
+ fallbackDeviceName?: string;
182
+ }): RemoteStatusSnapshot;
183
+ declare class RemoteStatusStore implements RemoteStatusWriter {
184
+ private readonly mode;
185
+ private readonly deps;
186
+ constructor(mode: RemoteRuntimeState["mode"], deps: {
187
+ writeRemoteState: (next: RemoteRuntimeState) => void;
188
+ });
189
+ write(next: Omit<RemoteRuntimeState, "mode" | "updatedAt">): void;
190
+ }
191
+
192
+ declare class RemoteServiceModule {
193
+ private readonly deps;
194
+ private abortController;
195
+ private runTask;
196
+ constructor(deps: {
197
+ config: Config;
198
+ localOrigin: string;
199
+ statusStore: RemoteStatusWriter;
200
+ createConnector: (logger: RemoteLogger) => RemoteConnector;
201
+ logger?: RemoteLogger;
202
+ });
203
+ start(): Promise<void> | null;
204
+ stop(): Promise<void>;
205
+ }
206
+
207
+ export { type RegisteredRemoteDevice, type RelayRequestFrame, type RemoteConnectCommandOptions, RemoteConnector, type RemoteConnectorRunOptions, type RemoteDoctorCommandOptions, type RemoteEnableCommandOptions, type RemoteLogger, RemotePlatformClient, type RemotePlatformClientDeps, RemoteRelayBridge, type RemoteRunContext, RemoteRuntimeActions, type RemoteRuntimeState, RemoteServiceModule, type RemoteServiceStateView, type RemoteStatusCommandOptions, type RemoteStatusSnapshot, RemoteStatusStore, type RemoteStatusWriter, buildConfiguredRemoteState, delay, normalizeOptionalString, redactWsUrl, registerRemoteCommands, resolveRemoteStatusSnapshot };
package/dist/index.js ADDED
@@ -0,0 +1,658 @@
1
+ // src/register-remote-commands.ts
2
+ function registerRemoteCommands(program, runtime) {
3
+ const remote = program.command("remote").description("Manage remote access");
4
+ remote.command("enable").description("Enable service-managed remote access").option("--api-base <url>", "Platform API base (supports /v1 suffix)").option("--name <name>", "Device display name").action(async (opts) => runtime.enable(opts));
5
+ remote.command("disable").description("Disable service-managed remote access").action(async () => runtime.disable());
6
+ remote.command("status").description("Show remote access status").option("--json", "Print JSON").action(async (opts) => runtime.status(opts));
7
+ remote.command("doctor").description("Run remote access diagnostics").option("--json", "Print JSON").action(async (opts) => runtime.doctor(opts));
8
+ remote.command("connect").description("Foreground debug mode: register this machine and keep the connector online").option("--api-base <url>", "Platform API base (supports /v1 suffix)").option("--local-origin <url>", "Local NextClaw UI origin (default: active service or http://127.0.0.1:18791)").option("--name <name>", "Device display name").option("--once", "Connect once without auto-reconnect", false).action(async (opts) => runtime.connect(opts));
9
+ }
10
+
11
+ // src/remote-runtime-actions.ts
12
+ var RemoteRuntimeActions = class {
13
+ constructor(deps) {
14
+ this.deps = deps;
15
+ }
16
+ async connect(opts = {}) {
17
+ await this.deps.remoteCommands.connect(opts);
18
+ }
19
+ async enable(opts = {}) {
20
+ await this.deps.initAuto("remote enable");
21
+ const result = this.deps.remoteCommands.enableConfig(opts);
22
+ console.log("\u2713 Remote access enabled");
23
+ if (result.config.remote.deviceName.trim()) {
24
+ console.log(`Device: ${result.config.remote.deviceName.trim()}`);
25
+ }
26
+ if (result.config.remote.platformApiBase.trim()) {
27
+ console.log(`Platform: ${result.config.remote.platformApiBase.trim()}`);
28
+ }
29
+ if (this.deps.hasRunningManagedService()) {
30
+ await this.deps.restartBackgroundService("remote configuration updated");
31
+ console.log("\u2713 Applied remote settings to running background service");
32
+ return;
33
+ }
34
+ console.log(`Tip: Run "${this.deps.appName} start" to bring the managed remote connector online.`);
35
+ }
36
+ async disable() {
37
+ const result = this.deps.remoteCommands.disableConfig();
38
+ console.log(result.changed ? "\u2713 Remote access disabled" : "Remote access was already disabled");
39
+ if (this.deps.hasRunningManagedService()) {
40
+ await this.deps.restartBackgroundService("remote access disabled");
41
+ console.log("\u2713 Running background service restarted without remote access");
42
+ }
43
+ }
44
+ async status(opts = {}) {
45
+ await this.deps.remoteCommands.status(opts);
46
+ }
47
+ async doctor(opts = {}) {
48
+ await this.deps.remoteCommands.doctor(opts);
49
+ }
50
+ };
51
+
52
+ // src/remote-platform-client.ts
53
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
54
+ import { dirname, join } from "path";
55
+ import { hostname, platform as readPlatform } from "os";
56
+ function ensureDir(path) {
57
+ mkdirSync(path, { recursive: true });
58
+ }
59
+ function readJsonFile(path) {
60
+ if (!existsSync(path)) {
61
+ return null;
62
+ }
63
+ try {
64
+ return JSON.parse(readFileSync(path, "utf-8"));
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+ function writeJsonFile(path, value) {
70
+ ensureDir(dirname(path));
71
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}
72
+ `, "utf-8");
73
+ }
74
+ function maskToken(value) {
75
+ if (value.length <= 12) {
76
+ return "<redacted>";
77
+ }
78
+ return `${value.slice(0, 6)}...${value.slice(-4)}`;
79
+ }
80
+ function normalizeOptionalString(value) {
81
+ if (typeof value !== "string") {
82
+ return void 0;
83
+ }
84
+ const trimmed = value.trim();
85
+ return trimmed.length > 0 ? trimmed : void 0;
86
+ }
87
+ function delay(ms, signal) {
88
+ return new Promise((resolveDelay, rejectDelay) => {
89
+ const timer = setTimeout(() => {
90
+ signal?.removeEventListener("abort", onAbort);
91
+ resolveDelay();
92
+ }, ms);
93
+ const onAbort = () => {
94
+ clearTimeout(timer);
95
+ rejectDelay(new Error("Remote connector aborted."));
96
+ };
97
+ if (signal) {
98
+ signal.addEventListener("abort", onAbort, { once: true });
99
+ }
100
+ });
101
+ }
102
+ function redactWsUrl(url) {
103
+ try {
104
+ const parsed = new URL(url);
105
+ const token = parsed.searchParams.get("token");
106
+ if (token) {
107
+ parsed.searchParams.set("token", maskToken(token));
108
+ }
109
+ return parsed.toString();
110
+ } catch {
111
+ return url;
112
+ }
113
+ }
114
+ var RemotePlatformClient = class {
115
+ constructor(deps) {
116
+ this.deps = deps;
117
+ }
118
+ get remoteDir() {
119
+ return join(this.deps.getDataDir(), "remote");
120
+ }
121
+ get devicePath() {
122
+ return join(this.remoteDir, "device.json");
123
+ }
124
+ resolveRunContext(opts) {
125
+ const { platformBase, token, config } = this.resolvePlatformAccess(opts);
126
+ return {
127
+ config,
128
+ platformBase,
129
+ token,
130
+ localOrigin: this.resolveLocalOrigin(config, opts),
131
+ displayName: this.resolveDisplayName(config, opts),
132
+ deviceInstallId: this.ensureDeviceInstallId(),
133
+ autoReconnect: opts.once ? false : opts.autoReconnect ?? config.remote.autoReconnect
134
+ };
135
+ }
136
+ async registerDevice(params) {
137
+ const response = await fetch(`${params.platformBase}/platform/remote/devices/register`, {
138
+ method: "POST",
139
+ headers: {
140
+ "content-type": "application/json",
141
+ authorization: `Bearer ${params.token}`
142
+ },
143
+ body: JSON.stringify({
144
+ deviceInstallId: params.deviceInstallId,
145
+ displayName: params.displayName,
146
+ platform: readPlatform(),
147
+ appVersion: this.deps.getPackageVersion(),
148
+ localOrigin: params.localOrigin
149
+ })
150
+ });
151
+ const payload = await response.json();
152
+ if (!response.ok || !payload.ok || !payload.data?.device) {
153
+ throw new Error(payload.error?.message ?? `Failed to register remote device (${response.status}).`);
154
+ }
155
+ return payload.data.device;
156
+ }
157
+ ensureDeviceInstallId() {
158
+ const existing = readJsonFile(this.devicePath);
159
+ if (existing?.deviceInstallId?.trim()) {
160
+ return existing.deviceInstallId.trim();
161
+ }
162
+ const deviceInstallId = crypto.randomUUID();
163
+ ensureDir(this.remoteDir);
164
+ writeJsonFile(this.devicePath, { deviceInstallId });
165
+ return deviceInstallId;
166
+ }
167
+ resolvePlatformAccess(opts) {
168
+ const config = this.deps.loadConfig();
169
+ const providers = config.providers;
170
+ const nextclawProvider = providers.nextclaw;
171
+ const token = typeof nextclawProvider?.apiKey === "string" ? nextclawProvider.apiKey.trim() : "";
172
+ if (!token) {
173
+ throw new Error('NextClaw platform token is missing. Run "nextclaw login" first.');
174
+ }
175
+ const configuredApiBase = normalizeOptionalString(config.remote.platformApiBase) ?? (typeof nextclawProvider?.apiBase === "string" ? nextclawProvider.apiBase.trim() : "");
176
+ const rawApiBase = normalizeOptionalString(opts.apiBase) ?? configuredApiBase;
177
+ if (!rawApiBase) {
178
+ throw new Error("Platform API base is missing. Pass --api-base, run nextclaw login, or set remote.platformApiBase.");
179
+ }
180
+ const platformBase = this.deps.resolvePlatformBase(rawApiBase);
181
+ return { platformBase, token, config };
182
+ }
183
+ resolveLocalOrigin(config, opts) {
184
+ const explicitOrigin = normalizeOptionalString(opts.localOrigin);
185
+ if (explicitOrigin) {
186
+ return explicitOrigin.replace(/\/$/, "");
187
+ }
188
+ const state = this.deps.readManagedServiceState?.();
189
+ if (state && this.deps.isProcessRunning?.(state.pid) && Number.isFinite(state.uiPort)) {
190
+ return `http://127.0.0.1:${state.uiPort}`;
191
+ }
192
+ const configuredPort = typeof config.ui?.port === "number" && Number.isFinite(config.ui.port) ? config.ui.port : 18791;
193
+ return `http://127.0.0.1:${configuredPort}`;
194
+ }
195
+ resolveDisplayName(config, opts) {
196
+ return normalizeOptionalString(opts.name) ?? normalizeOptionalString(config.remote.deviceName) ?? hostname();
197
+ }
198
+ };
199
+
200
+ // src/remote-relay-bridge.ts
201
+ import { ensureUiBridgeSecret } from "@nextclaw/server";
202
+ function encodeBase64(bytes) {
203
+ return Buffer.from(bytes).toString("base64");
204
+ }
205
+ function decodeBase64(base64) {
206
+ if (!base64) {
207
+ return new Uint8Array();
208
+ }
209
+ return new Uint8Array(Buffer.from(base64, "base64"));
210
+ }
211
+ var RemoteRelayBridge = class {
212
+ constructor(localOrigin) {
213
+ this.localOrigin = localOrigin;
214
+ }
215
+ async ensureLocalUiHealthy() {
216
+ const response = await fetch(`${this.localOrigin}/api/health`);
217
+ if (!response.ok) {
218
+ throw new Error(`Local UI is not healthy at ${this.localOrigin}. Start NextClaw first.`);
219
+ }
220
+ }
221
+ async forward(frame, socket) {
222
+ const bridgeCookie = await this.requestBridgeCookie();
223
+ const url = new URL(frame.path, this.localOrigin);
224
+ const headers = this.createForwardHeaders(frame.headers, bridgeCookie);
225
+ const response = await fetch(url, {
226
+ method: frame.method,
227
+ headers,
228
+ body: frame.method === "GET" || frame.method === "HEAD" ? void 0 : decodeBase64(frame.bodyBase64)
229
+ });
230
+ const responseHeaders = Array.from(response.headers.entries()).filter(([key]) => {
231
+ const lower = key.toLowerCase();
232
+ return !["content-length", "connection", "transfer-encoding", "set-cookie"].includes(lower);
233
+ });
234
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
235
+ if (response.body && contentType.startsWith("text/event-stream")) {
236
+ await this.sendStreamingResponse({ frame, response, responseHeaders, socket });
237
+ return;
238
+ }
239
+ const responseBody = response.body ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array();
240
+ socket.send(JSON.stringify({
241
+ type: "response",
242
+ requestId: frame.requestId,
243
+ status: response.status,
244
+ headers: responseHeaders,
245
+ bodyBase64: encodeBase64(responseBody)
246
+ }));
247
+ }
248
+ createForwardHeaders(headersList, bridgeCookie) {
249
+ const headers = new Headers();
250
+ for (const [key, value] of headersList) {
251
+ const lower = key.toLowerCase();
252
+ if ([
253
+ "host",
254
+ "connection",
255
+ "content-length",
256
+ "cookie",
257
+ "x-forwarded-for",
258
+ "x-forwarded-proto",
259
+ "cf-connecting-ip"
260
+ ].includes(lower)) {
261
+ continue;
262
+ }
263
+ headers.set(key, value);
264
+ }
265
+ if (bridgeCookie) {
266
+ headers.set("cookie", bridgeCookie);
267
+ }
268
+ return headers;
269
+ }
270
+ async requestBridgeCookie() {
271
+ const response = await fetch(`${this.localOrigin}/api/auth/bridge`, {
272
+ method: "POST",
273
+ headers: {
274
+ "x-nextclaw-ui-bridge-secret": ensureUiBridgeSecret()
275
+ }
276
+ });
277
+ const payload = await response.json();
278
+ if (!response.ok || !payload.ok) {
279
+ throw new Error(payload.error?.message ?? `Failed to request local auth bridge (${response.status}).`);
280
+ }
281
+ return typeof payload.data?.cookie === "string" && payload.data.cookie.trim().length > 0 ? payload.data.cookie.trim() : null;
282
+ }
283
+ async sendStreamingResponse(params) {
284
+ params.socket.send(JSON.stringify({
285
+ type: "response.start",
286
+ requestId: params.frame.requestId,
287
+ status: params.response.status,
288
+ headers: params.responseHeaders
289
+ }));
290
+ const reader = params.response.body?.getReader();
291
+ if (!reader) {
292
+ params.socket.send(JSON.stringify({
293
+ type: "response.end",
294
+ requestId: params.frame.requestId
295
+ }));
296
+ return;
297
+ }
298
+ try {
299
+ while (true) {
300
+ const { value, done } = await reader.read();
301
+ if (done) {
302
+ break;
303
+ }
304
+ if (value && value.length > 0) {
305
+ params.socket.send(JSON.stringify({
306
+ type: "response.chunk",
307
+ requestId: params.frame.requestId,
308
+ bodyBase64: encodeBase64(value)
309
+ }));
310
+ }
311
+ }
312
+ } finally {
313
+ reader.releaseLock();
314
+ }
315
+ params.socket.send(JSON.stringify({
316
+ type: "response.end",
317
+ requestId: params.frame.requestId
318
+ }));
319
+ }
320
+ };
321
+
322
+ // src/remote-connector.ts
323
+ var RemoteConnector = class {
324
+ constructor(deps) {
325
+ this.deps = deps;
326
+ }
327
+ get logger() {
328
+ return this.deps.logger ?? console;
329
+ }
330
+ async connectOnce(params) {
331
+ return await new Promise((resolve, reject) => {
332
+ const socket = new WebSocket(params.wsUrl);
333
+ let settled = false;
334
+ let aborted = false;
335
+ const pingTimer = setInterval(() => {
336
+ if (socket.readyState === WebSocket.OPEN) {
337
+ socket.send(JSON.stringify({ type: "ping", at: (/* @__PURE__ */ new Date()).toISOString() }));
338
+ }
339
+ }, 15e3);
340
+ const cleanup = () => {
341
+ clearInterval(pingTimer);
342
+ params.signal?.removeEventListener("abort", onAbort);
343
+ };
344
+ const finishResolve = (value) => {
345
+ if (settled) {
346
+ return;
347
+ }
348
+ settled = true;
349
+ cleanup();
350
+ resolve(value);
351
+ };
352
+ const finishReject = (error) => {
353
+ if (settled) {
354
+ return;
355
+ }
356
+ settled = true;
357
+ cleanup();
358
+ reject(error);
359
+ };
360
+ const onAbort = () => {
361
+ aborted = true;
362
+ try {
363
+ socket.close(1e3, "Remote connector aborted");
364
+ } catch {
365
+ finishResolve("aborted");
366
+ }
367
+ };
368
+ if (params.signal) {
369
+ if (params.signal.aborted) {
370
+ onAbort();
371
+ } else {
372
+ params.signal.addEventListener("abort", onAbort, { once: true });
373
+ }
374
+ }
375
+ socket.addEventListener("open", () => {
376
+ params.statusStore?.write({
377
+ enabled: true,
378
+ state: "connected",
379
+ deviceId: params.deviceId,
380
+ deviceName: params.displayName,
381
+ platformBase: params.platformBase,
382
+ localOrigin: params.localOrigin,
383
+ lastConnectedAt: (/* @__PURE__ */ new Date()).toISOString(),
384
+ lastError: null
385
+ });
386
+ this.logger.info(`\u2713 Remote connector connected: ${redactWsUrl(params.wsUrl)}`);
387
+ });
388
+ socket.addEventListener("message", (event) => {
389
+ this.handleSocketMessage({ data: event.data, relayBridge: params.relayBridge, socket });
390
+ });
391
+ socket.addEventListener("close", () => {
392
+ finishResolve(aborted ? "aborted" : "closed");
393
+ });
394
+ socket.addEventListener("error", () => {
395
+ if (aborted) {
396
+ finishResolve("aborted");
397
+ return;
398
+ }
399
+ finishReject(new Error("Remote connector websocket failed."));
400
+ });
401
+ });
402
+ }
403
+ handleSocketMessage(params) {
404
+ void (async () => {
405
+ const frame = this.parseRelayFrame(params.data);
406
+ if (!frame) {
407
+ return;
408
+ }
409
+ try {
410
+ await params.relayBridge.forward(frame, params.socket);
411
+ } catch (error) {
412
+ params.socket.send(JSON.stringify({
413
+ type: "response.error",
414
+ requestId: frame.requestId,
415
+ message: error instanceof Error ? error.message : String(error)
416
+ }));
417
+ }
418
+ })();
419
+ }
420
+ parseRelayFrame(data) {
421
+ try {
422
+ const frame = JSON.parse(String(data ?? ""));
423
+ return frame.type === "request" ? frame : null;
424
+ } catch {
425
+ return null;
426
+ }
427
+ }
428
+ async ensureDevice(params) {
429
+ if (params.device) {
430
+ return params.device;
431
+ }
432
+ const device = await this.deps.platformClient.registerDevice({
433
+ platformBase: params.context.platformBase,
434
+ token: params.context.token,
435
+ deviceInstallId: params.context.deviceInstallId,
436
+ displayName: params.context.displayName,
437
+ localOrigin: params.context.localOrigin
438
+ });
439
+ this.logger.info(`\u2713 Remote device registered: ${device.displayName} (${device.id})`);
440
+ this.logger.info(`\u2713 Local origin: ${params.context.localOrigin}`);
441
+ this.logger.info(`\u2713 Platform: ${params.context.platformBase}`);
442
+ return device;
443
+ }
444
+ writeRemoteState(statusStore, next) {
445
+ statusStore?.write(next);
446
+ }
447
+ async runCycle(params) {
448
+ try {
449
+ this.writeRemoteState(params.opts.statusStore, {
450
+ enabled: true,
451
+ state: "connecting",
452
+ deviceId: params.device?.id,
453
+ deviceName: params.context.displayName,
454
+ platformBase: params.context.platformBase,
455
+ localOrigin: params.context.localOrigin,
456
+ lastError: null
457
+ });
458
+ const device = await this.ensureDevice({ device: params.device, context: params.context });
459
+ const wsUrl = `${params.context.platformBase.replace(/^http/i, "ws")}/platform/remote/connect?deviceId=${encodeURIComponent(device.id)}&token=${encodeURIComponent(params.context.token)}`;
460
+ const outcome = await this.connectOnce({
461
+ wsUrl,
462
+ relayBridge: params.relayBridge,
463
+ signal: params.opts.signal,
464
+ statusStore: params.opts.statusStore,
465
+ displayName: params.context.displayName,
466
+ deviceId: device.id,
467
+ platformBase: params.context.platformBase,
468
+ localOrigin: params.context.localOrigin
469
+ });
470
+ if (outcome !== "aborted") {
471
+ this.writeRemoteState(params.opts.statusStore, {
472
+ enabled: true,
473
+ state: "disconnected",
474
+ deviceId: device.id,
475
+ deviceName: params.context.displayName,
476
+ platformBase: params.context.platformBase,
477
+ localOrigin: params.context.localOrigin,
478
+ lastError: null
479
+ });
480
+ }
481
+ return { device, aborted: outcome === "aborted" };
482
+ } catch (error) {
483
+ const message = error instanceof Error ? error.message : String(error);
484
+ this.writeRemoteState(params.opts.statusStore, {
485
+ enabled: true,
486
+ state: "error",
487
+ deviceId: params.device?.id,
488
+ deviceName: params.context.displayName,
489
+ platformBase: params.context.platformBase,
490
+ localOrigin: params.context.localOrigin,
491
+ lastError: message
492
+ });
493
+ this.logger.error(`Remote connector error: ${message}`);
494
+ return { device: params.device, aborted: false };
495
+ }
496
+ }
497
+ async run(opts = {}) {
498
+ const context = this.deps.platformClient.resolveRunContext(opts);
499
+ const relayBridge = (this.deps.relayBridgeFactory ?? ((localOrigin) => new RemoteRelayBridge(localOrigin)))(
500
+ context.localOrigin
501
+ );
502
+ await relayBridge.ensureLocalUiHealthy();
503
+ let device = null;
504
+ while (!opts.signal?.aborted) {
505
+ const cycle = await this.runCycle({ device, context, relayBridge, opts });
506
+ device = cycle.device;
507
+ if (cycle.aborted || !context.autoReconnect || opts.signal?.aborted) {
508
+ break;
509
+ }
510
+ this.logger.warn("Remote connector disconnected. Reconnecting in 3s...");
511
+ try {
512
+ await delay(3e3, opts.signal);
513
+ } catch {
514
+ break;
515
+ }
516
+ }
517
+ this.writeRemoteState(opts.statusStore, {
518
+ enabled: opts.mode === "service" ? true : Boolean(context.config.remote.enabled),
519
+ state: opts.signal?.aborted ? "disconnected" : "disabled",
520
+ deviceId: device?.id,
521
+ deviceName: context.displayName,
522
+ platformBase: context.platformBase,
523
+ localOrigin: context.localOrigin,
524
+ lastError: null
525
+ });
526
+ }
527
+ };
528
+
529
+ // src/remote-status-store.ts
530
+ import { hostname as hostname2 } from "os";
531
+ function normalizeOptionalString2(value) {
532
+ if (typeof value !== "string") {
533
+ return void 0;
534
+ }
535
+ const trimmed = value.trim();
536
+ return trimmed.length > 0 ? trimmed : void 0;
537
+ }
538
+ function buildConfiguredRemoteState(config) {
539
+ const remote = config.remote;
540
+ return {
541
+ enabled: Boolean(remote.enabled),
542
+ mode: "service",
543
+ state: remote.enabled ? "disconnected" : "disabled",
544
+ ...normalizeOptionalString2(remote.deviceName) ? { deviceName: normalizeOptionalString2(remote.deviceName) } : {},
545
+ ...normalizeOptionalString2(remote.platformApiBase) ? { platformBase: normalizeOptionalString2(remote.platformApiBase) } : {},
546
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
547
+ };
548
+ }
549
+ function resolveRemoteStatusSnapshot(params) {
550
+ if (params.currentRemoteState) {
551
+ return {
552
+ configuredEnabled: Boolean(params.config.remote.enabled),
553
+ runtime: params.currentRemoteState
554
+ };
555
+ }
556
+ if (params.config.remote.enabled) {
557
+ return {
558
+ configuredEnabled: true,
559
+ runtime: {
560
+ ...buildConfiguredRemoteState(params.config),
561
+ deviceName: normalizeOptionalString2(params.config.remote.deviceName) ?? normalizeOptionalString2(params.fallbackDeviceName) ?? hostname2()
562
+ }
563
+ };
564
+ }
565
+ return {
566
+ configuredEnabled: false,
567
+ runtime: null
568
+ };
569
+ }
570
+ var RemoteStatusStore = class {
571
+ constructor(mode, deps) {
572
+ this.mode = mode;
573
+ this.deps = deps;
574
+ }
575
+ write(next) {
576
+ this.deps.writeRemoteState({
577
+ ...next,
578
+ mode: this.mode,
579
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
580
+ });
581
+ }
582
+ };
583
+
584
+ // src/remote-service-module.ts
585
+ var RemoteServiceModule = class {
586
+ constructor(deps) {
587
+ this.deps = deps;
588
+ }
589
+ abortController = null;
590
+ runTask = null;
591
+ start() {
592
+ if (!this.deps.config.remote.enabled) {
593
+ this.deps.statusStore.write({
594
+ enabled: false,
595
+ state: "disabled",
596
+ deviceName: void 0,
597
+ deviceId: void 0,
598
+ platformBase: void 0,
599
+ localOrigin: this.deps.localOrigin,
600
+ lastError: null,
601
+ lastConnectedAt: null
602
+ });
603
+ return null;
604
+ }
605
+ const logger = this.deps.logger ?? {
606
+ info: (message) => console.log(`[remote] ${message}`),
607
+ warn: (message) => console.warn(`[remote] ${message}`),
608
+ error: (message) => console.error(`[remote] ${message}`)
609
+ };
610
+ this.abortController = new AbortController();
611
+ const connector = this.deps.createConnector(logger);
612
+ this.runTask = connector.run({
613
+ mode: "service",
614
+ signal: this.abortController.signal,
615
+ autoReconnect: this.deps.config.remote.autoReconnect,
616
+ localOrigin: this.deps.localOrigin,
617
+ statusStore: this.deps.statusStore
618
+ });
619
+ void this.runTask.catch((error) => {
620
+ const message = error instanceof Error ? error.message : String(error);
621
+ this.deps.statusStore.write({
622
+ enabled: true,
623
+ state: "error",
624
+ deviceName: this.deps.config.remote.deviceName || void 0,
625
+ deviceId: void 0,
626
+ platformBase: this.deps.config.remote.platformApiBase || void 0,
627
+ localOrigin: this.deps.localOrigin,
628
+ lastError: message
629
+ });
630
+ logger.error(message);
631
+ });
632
+ return this.runTask;
633
+ }
634
+ async stop() {
635
+ this.abortController?.abort();
636
+ try {
637
+ await this.runTask;
638
+ } catch {
639
+ } finally {
640
+ this.abortController = null;
641
+ this.runTask = null;
642
+ }
643
+ }
644
+ };
645
+ export {
646
+ RemoteConnector,
647
+ RemotePlatformClient,
648
+ RemoteRelayBridge,
649
+ RemoteRuntimeActions,
650
+ RemoteServiceModule,
651
+ RemoteStatusStore,
652
+ buildConfiguredRemoteState,
653
+ delay,
654
+ normalizeOptionalString,
655
+ redactWsUrl,
656
+ registerRemoteCommands,
657
+ resolveRemoteStatusSnapshot
658
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@nextclaw/remote",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "description": "Remote access runtime for NextClaw device registration, relay bridging, and service-managed connectivity.",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/Peiiii/nextclaw.git",
11
+ "directory": "packages/nextclaw-remote"
12
+ },
13
+ "homepage": "https://github.com/Peiiii/nextclaw/tree/master/packages/nextclaw-remote",
14
+ "keywords": [
15
+ "nextclaw",
16
+ "remote",
17
+ "relay",
18
+ "cli"
19
+ ],
20
+ "exports": {
21
+ ".": {
22
+ "development": "./src/index.ts",
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "dependencies": {
31
+ "commander": "^12.1.0",
32
+ "@nextclaw/core": "0.9.5",
33
+ "@nextclaw/server": "0.10.5"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^20.17.6",
37
+ "prettier": "^3.3.3",
38
+ "tsup": "^8.3.5",
39
+ "typescript": "^5.6.3"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup src/index.ts --format esm --dts --out-dir dist",
43
+ "lint": "eslint .",
44
+ "tsc": "tsc -p tsconfig.json"
45
+ }
46
+ }