@linkshell/wire 0.0.0-stage → 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 +21 -0
- package/README.md +11 -2
- package/dist/crypto.d.ts +65 -0
- package/dist/crypto.js +253 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/model.d.ts +751 -0
- package/dist/model.js +176 -0
- package/dist/model.js.map +1 -0
- package/dist/peer.d.ts +42 -0
- package/dist/peer.js +143 -0
- package/dist/peer.js.map +1 -0
- package/dist/relay-client.d.ts +82 -0
- package/dist/relay-client.js +259 -0
- package/dist/relay-client.js.map +1 -0
- package/dist/relay.d.ts +481 -0
- package/dist/relay.js +129 -0
- package/dist/relay.js.map +1 -0
- package/dist/rpc.d.ts +4835 -0
- package/dist/rpc.js +357 -0
- package/dist/rpc.js.map +1 -0
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +62 -0
- package/dist/shell.js.map +1 -0
- package/dist/updates.d.ts +3325 -0
- package/dist/updates.js +210 -0
- package/dist/updates.js.map +1 -0
- package/package.json +46 -3
- package/src/crypto.ts +290 -0
- package/src/index.ts +8 -0
- package/src/model.ts +210 -0
- package/src/peer.ts +170 -0
- package/src/relay-client.ts +289 -0
- package/src/relay.ts +206 -0
- package/src/rpc.ts +389 -0
- package/src/shell.ts +53 -0
- package/src/updates.ts +233 -0
package/src/model.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How far LinkShell can take a given agent. The UI renders capabilities from
|
|
5
|
+
* this instead of guessing:
|
|
6
|
+
* - multi_client: desktop TUI and remote devices are live clients of one agent
|
|
7
|
+
* server (Codex app-server, OpenCode server).
|
|
8
|
+
* - handoff: the native TUI owns the session on the desktop; a remote device can
|
|
9
|
+
* take it over and the session continues headlessly under the same id.
|
|
10
|
+
* - remote: structured control only; the desktop uses LinkShell's own UI.
|
|
11
|
+
* - terminal: PTY only.
|
|
12
|
+
*/
|
|
13
|
+
export const agentTierSchema = z.enum(["multi_client", "handoff", "remote", "terminal"]);
|
|
14
|
+
export type AgentTier = z.infer<typeof agentTierSchema>;
|
|
15
|
+
|
|
16
|
+
export const agentCapabilitiesSchema = z.object({
|
|
17
|
+
interrupt: z.boolean(),
|
|
18
|
+
/** Inject a message into a running turn instead of queueing it. */
|
|
19
|
+
steer: z.boolean(),
|
|
20
|
+
permissions: z.boolean(),
|
|
21
|
+
images: z.boolean(),
|
|
22
|
+
fork: z.boolean(),
|
|
23
|
+
models: z.boolean(),
|
|
24
|
+
modes: z.boolean(),
|
|
25
|
+
});
|
|
26
|
+
export type AgentCapabilities = z.infer<typeof agentCapabilitiesSchema>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How the agent authenticates on this machine, as the agent itself reports it
|
|
30
|
+
* (e.g. `claude auth status`, `codex login status`). LinkShell never handles
|
|
31
|
+
* the credentials; it runs agents with the user's own environment and config.
|
|
32
|
+
*/
|
|
33
|
+
export const agentAuthSchema = z.object({
|
|
34
|
+
state: z.enum(["ok", "missing", "unknown"]),
|
|
35
|
+
/** e.g. "claude.ai", "api_key", "oauth_token", "chatgpt", "bedrock". */
|
|
36
|
+
method: z.string().optional(),
|
|
37
|
+
/** What the user should do when state is "missing". */
|
|
38
|
+
hint: z.string().optional(),
|
|
39
|
+
});
|
|
40
|
+
export type AgentAuth = z.infer<typeof agentAuthSchema>;
|
|
41
|
+
|
|
42
|
+
export const agentInfoSchema = z.object({
|
|
43
|
+
id: z.string().min(1),
|
|
44
|
+
label: z.string().min(1),
|
|
45
|
+
tier: agentTierSchema,
|
|
46
|
+
installed: z.boolean(),
|
|
47
|
+
version: z.string().optional(),
|
|
48
|
+
/** Human-readable reason when the agent is installed but unusable. */
|
|
49
|
+
problem: z.string().optional(),
|
|
50
|
+
auth: agentAuthSchema.optional(),
|
|
51
|
+
capabilities: agentCapabilitiesSchema,
|
|
52
|
+
});
|
|
53
|
+
export type AgentInfo = z.infer<typeof agentInfoSchema>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Who is driving a handoff session right now: the desktop's native UI, this
|
|
57
|
+
* host on behalf of a device, or nobody (either side can continue).
|
|
58
|
+
*/
|
|
59
|
+
export const sessionDriverSchema = z.enum(["desktop", "remote", "none"]);
|
|
60
|
+
export type SessionDriver = z.infer<typeof sessionDriverSchema>;
|
|
61
|
+
|
|
62
|
+
export const sessionStateSchema = z.enum(["idle", "running", "waiting", "error", "offline"]);
|
|
63
|
+
export type SessionState = z.infer<typeof sessionStateSchema>;
|
|
64
|
+
|
|
65
|
+
export const toolKindSchema = z.enum([
|
|
66
|
+
"read",
|
|
67
|
+
"edit",
|
|
68
|
+
"delete",
|
|
69
|
+
"move",
|
|
70
|
+
"search",
|
|
71
|
+
"execute",
|
|
72
|
+
"think",
|
|
73
|
+
"fetch",
|
|
74
|
+
"other",
|
|
75
|
+
]);
|
|
76
|
+
export type ToolKind = z.infer<typeof toolKindSchema>;
|
|
77
|
+
|
|
78
|
+
export const permissionOptionSchema = z.object({
|
|
79
|
+
optionId: z.string().min(1),
|
|
80
|
+
name: z.string().min(1),
|
|
81
|
+
kind: z.enum(["allow_once", "allow_always", "reject_once", "reject_always"]),
|
|
82
|
+
});
|
|
83
|
+
export type PermissionOption = z.infer<typeof permissionOptionSchema>;
|
|
84
|
+
|
|
85
|
+
/** What a running session is doing right now, for list rows. Live only. */
|
|
86
|
+
export const sessionActivitySchema = z.object({
|
|
87
|
+
kind: z.enum(["thinking", "responding", "tool"]),
|
|
88
|
+
title: z.string().optional(),
|
|
89
|
+
toolKind: toolKindSchema.optional(),
|
|
90
|
+
});
|
|
91
|
+
export type SessionActivity = z.infer<typeof sessionActivitySchema>;
|
|
92
|
+
|
|
93
|
+
/** The oldest unanswered permission request, so lists can offer approval inline. Live only. */
|
|
94
|
+
export const pendingPermissionSchema = z.object({
|
|
95
|
+
requestId: z.string(),
|
|
96
|
+
toolCallId: z.string().optional(),
|
|
97
|
+
title: z.string(),
|
|
98
|
+
detail: z.string().optional(),
|
|
99
|
+
options: z.array(permissionOptionSchema).min(1),
|
|
100
|
+
});
|
|
101
|
+
export type PendingPermissionSummary = z.infer<typeof pendingPermissionSchema>;
|
|
102
|
+
|
|
103
|
+
export const queuedMessageSchema = z.object({
|
|
104
|
+
clientMessageId: z.string(),
|
|
105
|
+
/** The message's text (images are counted, not sent). */
|
|
106
|
+
text: z.string(),
|
|
107
|
+
images: z.number().int().nonnegative(),
|
|
108
|
+
});
|
|
109
|
+
export type QueuedMessage = z.infer<typeof queuedMessageSchema>;
|
|
110
|
+
|
|
111
|
+
export const sessionSummarySchema = z.object({
|
|
112
|
+
/** `${agent}:${nativeId}` — stable across daemon restarts and rediscovery. */
|
|
113
|
+
id: z.string().min(1),
|
|
114
|
+
agent: z.string().min(1),
|
|
115
|
+
nativeId: z.string().min(1),
|
|
116
|
+
title: z.string().optional(),
|
|
117
|
+
preview: z.string().optional(),
|
|
118
|
+
cwd: z.string(),
|
|
119
|
+
state: sessionStateSchema,
|
|
120
|
+
driver: sessionDriverSchema.optional(),
|
|
121
|
+
pendingPermissions: z.number().int().nonnegative(),
|
|
122
|
+
model: z.string().optional(),
|
|
123
|
+
createdAt: z.number(),
|
|
124
|
+
updatedAt: z.number(),
|
|
125
|
+
lastSeq: z.number().int().nonnegative(),
|
|
126
|
+
archived: z.boolean(),
|
|
127
|
+
activity: sessionActivitySchema.optional(),
|
|
128
|
+
permission: pendingPermissionSchema.optional(),
|
|
129
|
+
/**
|
|
130
|
+
* Live only: messages waiting for the current turn to end, when the host
|
|
131
|
+
* holds them (agents that can't take input mid-turn). Oldest first.
|
|
132
|
+
*/
|
|
133
|
+
queue: z.array(queuedMessageSchema).optional(),
|
|
134
|
+
});
|
|
135
|
+
export type SessionSummary = z.infer<typeof sessionSummarySchema>;
|
|
136
|
+
|
|
137
|
+
export function sessionIdFor(agent: string, nativeId: string): string {
|
|
138
|
+
return `${agent}:${nativeId}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function parseSessionId(id: string): { agent: string; nativeId: string } | null {
|
|
142
|
+
const index = id.indexOf(":");
|
|
143
|
+
if (index <= 0 || index === id.length - 1) return null;
|
|
144
|
+
return { agent: id.slice(0, index), nativeId: id.slice(index + 1) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const projectSummarySchema = z.object({
|
|
148
|
+
cwd: z.string(),
|
|
149
|
+
name: z.string(),
|
|
150
|
+
lastActiveAt: z.number(),
|
|
151
|
+
sessionCount: z.number().int().nonnegative(),
|
|
152
|
+
});
|
|
153
|
+
export type ProjectSummary = z.infer<typeof projectSummarySchema>;
|
|
154
|
+
|
|
155
|
+
export const machineInfoSchema = z.object({
|
|
156
|
+
machineId: z.string().min(1),
|
|
157
|
+
hostname: z.string(),
|
|
158
|
+
platform: z.string(),
|
|
159
|
+
hostVersion: z.string(),
|
|
160
|
+
/** The host user's home directory, so apps can show paths as `~/…`. */
|
|
161
|
+
home: z.string().optional(),
|
|
162
|
+
agents: z.array(agentInfoSchema),
|
|
163
|
+
});
|
|
164
|
+
export type MachineInfo = z.infer<typeof machineInfoSchema>;
|
|
165
|
+
|
|
166
|
+
/** A server listening on the host, which a device can preview through the encrypted channel. */
|
|
167
|
+
export const portInfoSchema = z.object({
|
|
168
|
+
port: z.number().int().min(1).max(65535),
|
|
169
|
+
/** The listening process ("node", "python3.12"). */
|
|
170
|
+
process: z.string(),
|
|
171
|
+
pid: z.number().int().optional(),
|
|
172
|
+
/** The process's working directory: which project the server belongs to. */
|
|
173
|
+
cwd: z.string().optional(),
|
|
174
|
+
/** The page title, when the port answers HTTP with an HTML page. */
|
|
175
|
+
title: z.string().optional(),
|
|
176
|
+
/** Answers HTTP at all (a dev server, not a database). */
|
|
177
|
+
http: z.boolean(),
|
|
178
|
+
});
|
|
179
|
+
export type PortInfo = z.infer<typeof portInfoSchema>;
|
|
180
|
+
|
|
181
|
+
/** A shell the host keeps open, independent of any device connection. */
|
|
182
|
+
export const terminalInfoSchema = z.object({
|
|
183
|
+
id: z.string(),
|
|
184
|
+
/** What's running in the foreground (the shell or its current command). */
|
|
185
|
+
title: z.string(),
|
|
186
|
+
cwd: z.string(),
|
|
187
|
+
cols: z.number().int(),
|
|
188
|
+
rows: z.number().int(),
|
|
189
|
+
createdAt: z.number(),
|
|
190
|
+
/** Last output time. */
|
|
191
|
+
activeAt: z.number(),
|
|
192
|
+
/** The command it was started to run, if any (else it's a plain shell). */
|
|
193
|
+
command: z.string().optional(),
|
|
194
|
+
/** Set once the shell ended; the terminal and its output stay until deleted. */
|
|
195
|
+
exitCode: z.number().int().nullable().optional(),
|
|
196
|
+
/** Ended without an exit code: the host stopped while it ran. */
|
|
197
|
+
interrupted: z.boolean().optional(),
|
|
198
|
+
});
|
|
199
|
+
export type TerminalInfo = z.infer<typeof terminalInfoSchema>;
|
|
200
|
+
|
|
201
|
+
/** The host's link to a v2 gateway, for `linkshell status` and the app's computer page. */
|
|
202
|
+
export const gatewayStatusSchema = z.object({
|
|
203
|
+
url: z.string().optional(),
|
|
204
|
+
status: z.enum(["off", "connecting", "online", "offline"]),
|
|
205
|
+
error: z.object({ code: z.string(), message: z.string() }).optional(),
|
|
206
|
+
machine: z.object({ id: z.string(), name: z.string() }).optional(),
|
|
207
|
+
account: z.object({ userId: z.string(), email: z.string().optional() }).optional(),
|
|
208
|
+
devices: z.array(z.object({ id: z.string(), name: z.string(), pairedAt: z.number(), online: z.boolean() })),
|
|
209
|
+
});
|
|
210
|
+
export type GatewayStatus = z.infer<typeof gatewayStatusSchema>;
|
package/src/peer.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { RpcErrorObject, RpcId } from "./rpc.js";
|
|
2
|
+
import { RPC_APP_ERROR, RPC_METHOD_NOT_FOUND } from "./rpc.js";
|
|
3
|
+
|
|
4
|
+
/** An error that crosses the wire as a JSON-RPC error object. */
|
|
5
|
+
export class RpcError extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
readonly code: number,
|
|
8
|
+
message: string,
|
|
9
|
+
readonly data?: unknown,
|
|
10
|
+
) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "RpcError";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static app(appCode: string, message: string, extra?: Record<string, unknown>): RpcError {
|
|
16
|
+
return new RpcError(RPC_APP_ERROR, message, { code: appCode, ...extra });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The `data.code` of an application error, if any. */
|
|
20
|
+
get appCode(): string | undefined {
|
|
21
|
+
const data = this.data as { code?: unknown } | undefined;
|
|
22
|
+
return typeof data?.code === "string" ? data.code : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
toObject(): RpcErrorObject {
|
|
26
|
+
return this.data === undefined
|
|
27
|
+
? { code: this.code, message: this.message }
|
|
28
|
+
: { code: this.code, message: this.message, data: this.data };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Returned by a request handler to leave a request unanswered for good. */
|
|
33
|
+
export const ABANDON = Symbol("rpc.abandon");
|
|
34
|
+
|
|
35
|
+
export type RequestHandler = (
|
|
36
|
+
method: string,
|
|
37
|
+
params: unknown,
|
|
38
|
+
id: RpcId,
|
|
39
|
+
) => unknown | Promise<unknown>;
|
|
40
|
+
export type NotificationHandler = (method: string, params: unknown) => void;
|
|
41
|
+
|
|
42
|
+
export interface RpcPeerOptions {
|
|
43
|
+
/** Sends one serialized JSON-RPC message. */
|
|
44
|
+
send: (text: string) => void;
|
|
45
|
+
onRequest?: RequestHandler;
|
|
46
|
+
onNotification?: NotificationHandler;
|
|
47
|
+
/** Default timeout for outgoing requests; 0 disables it. */
|
|
48
|
+
requestTimeoutMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Pending {
|
|
52
|
+
resolve: (value: unknown) => void;
|
|
53
|
+
reject: (error: Error) => void;
|
|
54
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
55
|
+
method: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Transport-agnostic JSON-RPC 2.0 peer: both sides can issue requests and
|
|
60
|
+
* notifications. Feed incoming text frames to `receive()`.
|
|
61
|
+
*/
|
|
62
|
+
export class RpcPeer {
|
|
63
|
+
private nextId = 1;
|
|
64
|
+
private readonly pending = new Map<RpcId, Pending>();
|
|
65
|
+
private closed = false;
|
|
66
|
+
|
|
67
|
+
constructor(private readonly options: RpcPeerOptions) {}
|
|
68
|
+
|
|
69
|
+
request<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T> {
|
|
70
|
+
if (this.closed) return Promise.reject(new RpcError(RPC_APP_ERROR, `connection closed (${method})`));
|
|
71
|
+
const id = this.nextId++;
|
|
72
|
+
return new Promise<T>((resolve, reject) => {
|
|
73
|
+
const entry: Pending = { resolve: resolve as (v: unknown) => void, reject, method };
|
|
74
|
+
const limit = timeoutMs ?? this.options.requestTimeoutMs ?? 30_000;
|
|
75
|
+
if (limit > 0) {
|
|
76
|
+
entry.timer = setTimeout(() => {
|
|
77
|
+
this.pending.delete(id);
|
|
78
|
+
reject(new RpcError(RPC_APP_ERROR, `${method} timed out after ${limit}ms`, { code: "timeout" }));
|
|
79
|
+
}, limit);
|
|
80
|
+
}
|
|
81
|
+
this.pending.set(id, entry);
|
|
82
|
+
this.write({ jsonrpc: "2.0", id, method, params: params ?? {} });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
notify(method: string, params?: unknown): void {
|
|
87
|
+
if (this.closed) return;
|
|
88
|
+
this.write({ jsonrpc: "2.0", method, params: params ?? {} });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
receive(text: string): void {
|
|
92
|
+
for (const line of text.split("\n")) {
|
|
93
|
+
if (!line.trim()) continue;
|
|
94
|
+
let message: Record<string, unknown>;
|
|
95
|
+
try {
|
|
96
|
+
message = JSON.parse(line) as Record<string, unknown>;
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
this.dispatch(message);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Rejects every in-flight request; later calls fail immediately. */
|
|
105
|
+
close(reason = "connection closed"): void {
|
|
106
|
+
if (this.closed) return;
|
|
107
|
+
this.closed = true;
|
|
108
|
+
for (const [id, entry] of this.pending) {
|
|
109
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
110
|
+
entry.reject(new RpcError(RPC_APP_ERROR, `${reason} (${entry.method})`, { code: "closed" }));
|
|
111
|
+
this.pending.delete(id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get isClosed(): boolean {
|
|
116
|
+
return this.closed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private dispatch(message: Record<string, unknown>): void {
|
|
120
|
+
const id = message.id as RpcId | undefined;
|
|
121
|
+
const method = typeof message.method === "string" ? message.method : undefined;
|
|
122
|
+
|
|
123
|
+
if (method === undefined) {
|
|
124
|
+
if (id === undefined) return;
|
|
125
|
+
const entry = this.pending.get(id);
|
|
126
|
+
if (!entry) return;
|
|
127
|
+
this.pending.delete(id);
|
|
128
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
129
|
+
const error = message.error as RpcErrorObject | undefined;
|
|
130
|
+
if (error) entry.reject(new RpcError(error.code, error.message, error.data));
|
|
131
|
+
else entry.resolve(message.result);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (id === undefined) {
|
|
136
|
+
this.options.onNotification?.(method, message.params);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const handler = this.options.onRequest;
|
|
141
|
+
if (!handler) {
|
|
142
|
+
this.write({ jsonrpc: "2.0", id, error: { code: RPC_METHOD_NOT_FOUND, message: `no handler for ${method}` } });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
Promise.resolve()
|
|
146
|
+
.then(() => handler(method, message.params, id))
|
|
147
|
+
.then(
|
|
148
|
+
(result) => {
|
|
149
|
+
if (result === ABANDON) return;
|
|
150
|
+
this.write({ jsonrpc: "2.0", id, result: result ?? {} });
|
|
151
|
+
},
|
|
152
|
+
(error: unknown) => {
|
|
153
|
+
const rpcError =
|
|
154
|
+
error instanceof RpcError
|
|
155
|
+
? error
|
|
156
|
+
: new RpcError(RPC_APP_ERROR, error instanceof Error ? error.message : String(error), { code: "internal" });
|
|
157
|
+
this.write({ jsonrpc: "2.0", id, error: rpcError.toObject() });
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private write(message: Record<string, unknown>): void {
|
|
163
|
+
if (this.closed) return;
|
|
164
|
+
try {
|
|
165
|
+
this.options.send(JSON.stringify(message));
|
|
166
|
+
} catch {
|
|
167
|
+
this.close("send failed");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { gatewayChallengeMessage, publicIdentity, sign, type Identity, type PublicIdentity } from "./crypto.js";
|
|
2
|
+
import {
|
|
3
|
+
serverFrameSchema,
|
|
4
|
+
tunnelFrameSchema,
|
|
5
|
+
type ClientFrame,
|
|
6
|
+
type PeerRole,
|
|
7
|
+
type RelayEvents,
|
|
8
|
+
type RelayMethod,
|
|
9
|
+
type RelayMethods,
|
|
10
|
+
type TunnelFrame,
|
|
11
|
+
} from "./relay.js";
|
|
12
|
+
|
|
13
|
+
// One connection from a machine or device to a v2 gateway: authenticates,
|
|
14
|
+
// reconnects, makes control requests and carries tunnel frames. Runs on
|
|
15
|
+
// Node (ws) and in React Native (WebSocket) alike.
|
|
16
|
+
|
|
17
|
+
export interface RelaySocket {
|
|
18
|
+
readonly readyState: number;
|
|
19
|
+
send(data: string): void;
|
|
20
|
+
close(code?: number, reason?: string): void;
|
|
21
|
+
onopen: ((event: unknown) => void) | null;
|
|
22
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
23
|
+
onclose: ((event: { code?: number; reason?: string }) => void) | null;
|
|
24
|
+
onerror: ((event: unknown) => void) | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type RelayStatus = "connecting" | "online" | "offline" | "stopped";
|
|
28
|
+
|
|
29
|
+
export interface RelayClientOptions {
|
|
30
|
+
url: string;
|
|
31
|
+
identity: Identity;
|
|
32
|
+
role: PeerRole;
|
|
33
|
+
name: string;
|
|
34
|
+
platform?: string;
|
|
35
|
+
/** Account token, fetched fresh for each connection. */
|
|
36
|
+
token?: () => Promise<string | undefined> | string | undefined;
|
|
37
|
+
createSocket: (url: string) => RelaySocket;
|
|
38
|
+
minBackoffMs?: number;
|
|
39
|
+
maxBackoffMs?: number;
|
|
40
|
+
requestTimeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class RelayRequestError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
readonly code: string,
|
|
46
|
+
message: string,
|
|
47
|
+
) {
|
|
48
|
+
super(message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const OPEN = 1;
|
|
53
|
+
|
|
54
|
+
export class RelayClient {
|
|
55
|
+
private socket?: RelaySocket;
|
|
56
|
+
private statusValue: RelayStatus = "offline";
|
|
57
|
+
private attempt = 0;
|
|
58
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
59
|
+
private nextId = 1;
|
|
60
|
+
private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
61
|
+
private readonly statusListeners = new Set<(status: RelayStatus, error?: { code: string; message: string }) => void>();
|
|
62
|
+
private readonly frameListeners = new Set<(from: PublicIdentity, via: "paired" | "account", frame: TunnelFrame) => void>();
|
|
63
|
+
private readonly undeliverableListeners = new Set<(to: string, reason: string) => void>();
|
|
64
|
+
private readonly eventListeners = new Map<string, Set<(data: unknown) => void>>();
|
|
65
|
+
private readonly waiters = new Set<() => void>();
|
|
66
|
+
/** Account the gateway recognised on the last connection. */
|
|
67
|
+
account?: { userId: string; email?: string };
|
|
68
|
+
lastError?: { code: string; message: string };
|
|
69
|
+
|
|
70
|
+
constructor(private readonly options: RelayClientOptions) {}
|
|
71
|
+
|
|
72
|
+
get status(): RelayStatus {
|
|
73
|
+
return this.statusValue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get identity(): PublicIdentity {
|
|
77
|
+
return publicIdentity(this.options.identity);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onStatus(listener: (status: RelayStatus, error?: { code: string; message: string }) => void): () => void {
|
|
81
|
+
this.statusListeners.add(listener);
|
|
82
|
+
return () => this.statusListeners.delete(listener);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
onFrame(listener: (from: PublicIdentity, via: "paired" | "account", frame: TunnelFrame) => void): () => void {
|
|
86
|
+
this.frameListeners.add(listener);
|
|
87
|
+
return () => this.frameListeners.delete(listener);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onUndeliverable(listener: (to: string, reason: string) => void): () => void {
|
|
91
|
+
this.undeliverableListeners.add(listener);
|
|
92
|
+
return () => this.undeliverableListeners.delete(listener);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
on<N extends keyof RelayEvents>(name: N, listener: (data: RelayEvents[N]) => void): () => void {
|
|
96
|
+
let set = this.eventListeners.get(name);
|
|
97
|
+
if (!set) this.eventListeners.set(name, (set = new Set()));
|
|
98
|
+
const entry = listener as (data: unknown) => void;
|
|
99
|
+
set.add(entry);
|
|
100
|
+
return () => set.delete(entry);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
start(): void {
|
|
104
|
+
if (this.statusValue === "connecting" || this.statusValue === "online") return;
|
|
105
|
+
this.open();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
stop(): void {
|
|
109
|
+
this.setStatus("stopped");
|
|
110
|
+
clearTimeout(this.timer);
|
|
111
|
+
this.socket?.close(1000, "stopped");
|
|
112
|
+
this.socket = undefined;
|
|
113
|
+
this.failPending("stopped");
|
|
114
|
+
for (const wake of this.waiters) wake();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Reconnect now instead of waiting out the backoff (app foregrounded, network back). */
|
|
118
|
+
reconnectNow(): void {
|
|
119
|
+
if (this.statusValue === "stopped" || this.statusValue === "online" || this.statusValue === "connecting") return;
|
|
120
|
+
clearTimeout(this.timer);
|
|
121
|
+
this.attempt = 0;
|
|
122
|
+
this.open();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Waits (up to `timeoutMs`) for the gateway connection. */
|
|
126
|
+
waitOnline(timeoutMs: number): Promise<boolean> {
|
|
127
|
+
if (this.statusValue === "online") return Promise.resolve(true);
|
|
128
|
+
if (this.statusValue === "stopped") return Promise.resolve(false);
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
const done = () => {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
this.waiters.delete(done);
|
|
133
|
+
resolve(this.statusValue === "online");
|
|
134
|
+
};
|
|
135
|
+
const timer = setTimeout(done, timeoutMs);
|
|
136
|
+
this.waiters.add(done);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async request<M extends RelayMethod>(method: M, params: RelayMethods[M]["params"], timeoutMs?: number): Promise<RelayMethods[M]["result"]> {
|
|
141
|
+
const limit = timeoutMs ?? this.options.requestTimeoutMs ?? 20_000;
|
|
142
|
+
if (!(await this.waitOnline(limit))) throw new RelayRequestError("offline", "not connected to the gateway");
|
|
143
|
+
const id = this.nextId++;
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
this.pending.delete(id);
|
|
147
|
+
reject(new RelayRequestError("timeout", `${method} timed out`));
|
|
148
|
+
}, limit);
|
|
149
|
+
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
|
|
150
|
+
this.sendFrame({ t: "req", id, method, params });
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Sends a tunnel frame to a peer. Returns false when not connected. */
|
|
155
|
+
send(to: string, frame: TunnelFrame): boolean {
|
|
156
|
+
if (this.statusValue !== "online") return false;
|
|
157
|
+
return this.sendFrame({ t: "to", to, d: JSON.stringify(frame) });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── internals ─────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
private sendFrame(frame: ClientFrame): boolean {
|
|
163
|
+
if (!this.socket || this.socket.readyState !== OPEN) return false;
|
|
164
|
+
this.socket.send(JSON.stringify(frame));
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private setStatus(status: RelayStatus, error?: { code: string; message: string }): void {
|
|
169
|
+
if (error) this.lastError = error;
|
|
170
|
+
if (status === "online") this.lastError = undefined;
|
|
171
|
+
if (this.statusValue === status && !error) return;
|
|
172
|
+
this.statusValue = status;
|
|
173
|
+
for (const listener of this.statusListeners) listener(status, error);
|
|
174
|
+
if (status === "online" || status === "stopped") for (const wake of this.waiters) wake();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private failPending(reason: string): void {
|
|
178
|
+
for (const [id, entry] of this.pending) {
|
|
179
|
+
clearTimeout(entry.timer);
|
|
180
|
+
entry.reject(new RelayRequestError("offline", reason));
|
|
181
|
+
this.pending.delete(id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private open(): void {
|
|
186
|
+
this.setStatus("connecting");
|
|
187
|
+
let socket: RelaySocket;
|
|
188
|
+
try {
|
|
189
|
+
socket = this.options.createSocket(this.options.url);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
this.scheduleReconnect({ code: "connect_failed", message: error instanceof Error ? error.message : String(error) });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.socket = socket;
|
|
195
|
+
let fatal: { code: string; message: string } | undefined;
|
|
196
|
+
socket.onmessage = (event) => {
|
|
197
|
+
let frame;
|
|
198
|
+
try {
|
|
199
|
+
const parsed = serverFrameSchema.safeParse(JSON.parse(String(event.data)));
|
|
200
|
+
if (!parsed.success) return;
|
|
201
|
+
frame = parsed.data;
|
|
202
|
+
} catch {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
switch (frame.t) {
|
|
206
|
+
case "challenge":
|
|
207
|
+
void this.authenticate(frame.nonce);
|
|
208
|
+
return;
|
|
209
|
+
case "ready":
|
|
210
|
+
this.attempt = 0;
|
|
211
|
+
this.account = frame.userId ? { userId: frame.userId, email: frame.email } : undefined;
|
|
212
|
+
this.setStatus("online");
|
|
213
|
+
return;
|
|
214
|
+
case "res": {
|
|
215
|
+
const entry = this.pending.get(frame.id);
|
|
216
|
+
if (!entry) return;
|
|
217
|
+
clearTimeout(entry.timer);
|
|
218
|
+
this.pending.delete(frame.id);
|
|
219
|
+
if (frame.error) entry.reject(new RelayRequestError(frame.error.code, frame.error.message));
|
|
220
|
+
else entry.resolve(frame.result);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
case "event":
|
|
224
|
+
for (const listener of this.eventListeners.get(frame.name) ?? []) listener(frame.data);
|
|
225
|
+
return;
|
|
226
|
+
case "from": {
|
|
227
|
+
let inner: TunnelFrame;
|
|
228
|
+
try {
|
|
229
|
+
const parsed = tunnelFrameSchema.safeParse(JSON.parse(frame.d));
|
|
230
|
+
if (!parsed.success) return;
|
|
231
|
+
inner = parsed.data;
|
|
232
|
+
} catch {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
for (const listener of this.frameListeners) listener(frame.from, frame.via, inner);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
case "undeliverable":
|
|
239
|
+
for (const listener of this.undeliverableListeners) listener(frame.to, frame.reason);
|
|
240
|
+
return;
|
|
241
|
+
case "error":
|
|
242
|
+
fatal = { code: frame.code, message: frame.message };
|
|
243
|
+
return;
|
|
244
|
+
case "pong":
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
socket.onerror = () => {};
|
|
249
|
+
socket.onclose = (event) => {
|
|
250
|
+
if (this.socket !== socket) return;
|
|
251
|
+
this.socket = undefined;
|
|
252
|
+
this.failPending("connection closed");
|
|
253
|
+
if (this.statusValue === "stopped") return;
|
|
254
|
+
// Another connection with our identity took over: don't fight it.
|
|
255
|
+
if (event.code === 4000) {
|
|
256
|
+
this.setStatus("offline", { code: "replaced", message: "connected elsewhere with this identity" });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.scheduleReconnect(fatal ?? { code: "disconnected", message: event.reason || "connection lost" });
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async authenticate(nonce: string): Promise<void> {
|
|
264
|
+
let token: string | undefined;
|
|
265
|
+
try {
|
|
266
|
+
token = (await this.options.token?.()) ?? undefined;
|
|
267
|
+
} catch {
|
|
268
|
+
token = undefined;
|
|
269
|
+
}
|
|
270
|
+
this.sendFrame({
|
|
271
|
+
t: "auth",
|
|
272
|
+
role: this.options.role,
|
|
273
|
+
identity: publicIdentity(this.options.identity),
|
|
274
|
+
signature: sign(this.options.identity, gatewayChallengeMessage(nonce)),
|
|
275
|
+
name: this.options.name.slice(0, 120),
|
|
276
|
+
platform: this.options.platform,
|
|
277
|
+
token,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private scheduleReconnect(error: { code: string; message: string }): void {
|
|
282
|
+
this.setStatus("offline", error);
|
|
283
|
+
const min = this.options.minBackoffMs ?? 500;
|
|
284
|
+
const max = this.options.maxBackoffMs ?? 30_000;
|
|
285
|
+
const delay = Math.min(max, min * 2 ** this.attempt) * (0.75 + Math.random() * 0.5);
|
|
286
|
+
this.attempt += 1;
|
|
287
|
+
this.timer = setTimeout(() => this.open(), delay);
|
|
288
|
+
}
|
|
289
|
+
}
|