linkshell-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/dist/cli/src/commands/doctor.d.ts +1 -0
- package/dist/cli/src/commands/doctor.js +112 -0
- package/dist/cli/src/commands/doctor.js.map +1 -0
- package/dist/cli/src/commands/setup.d.ts +1 -0
- package/dist/cli/src/commands/setup.js +48 -0
- package/dist/cli/src/commands/setup.js.map +1 -0
- package/dist/cli/src/config.d.ts +12 -0
- package/dist/cli/src/config.js +23 -0
- package/dist/cli/src/config.js.map +1 -0
- package/dist/cli/src/index.d.ts +2 -0
- package/dist/cli/src/index.js +108 -0
- package/dist/cli/src/index.js.map +1 -0
- package/dist/cli/src/providers.d.ts +12 -0
- package/dist/cli/src/providers.js +61 -0
- package/dist/cli/src/providers.js.map +1 -0
- package/dist/cli/src/runtime/bridge-session.d.ts +40 -0
- package/dist/cli/src/runtime/bridge-session.js +317 -0
- package/dist/cli/src/runtime/bridge-session.js.map +1 -0
- package/dist/cli/src/runtime/scrollback.d.ts +11 -0
- package/dist/cli/src/runtime/scrollback.js +33 -0
- package/dist/cli/src/runtime/scrollback.js.map +1 -0
- package/dist/cli/src/utils/lan-ip.d.ts +5 -0
- package/dist/cli/src/utils/lan-ip.js +20 -0
- package/dist/cli/src/utils/lan-ip.js.map +1 -0
- package/dist/cli/tsconfig.tsbuildinfo +1 -0
- package/dist/shared-protocol/src/index.d.ts +380 -0
- package/dist/shared-protocol/src/index.js +158 -0
- package/dist/shared-protocol/src/index.js.map +1 -0
- package/package.json +49 -0
- package/src/commands/doctor.ts +119 -0
- package/src/commands/setup.ts +65 -0
- package/src/config.ts +34 -0
- package/src/index.ts +139 -0
- package/src/providers.ts +91 -0
- package/src/runtime/bridge-session.ts +407 -0
- package/src/runtime/scrollback.ts +43 -0
- package/src/types/qrcode-terminal.d.ts +7 -0
- package/src/utils/lan-ip.ts +19 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import * as pty from "node-pty";
|
|
2
|
+
import WebSocket from "ws";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import {
|
|
5
|
+
createEnvelope,
|
|
6
|
+
parseEnvelope,
|
|
7
|
+
parseTypedPayload,
|
|
8
|
+
serializeEnvelope,
|
|
9
|
+
PROTOCOL_VERSION,
|
|
10
|
+
} from "@linkshell/protocol";
|
|
11
|
+
import type { Envelope } from "@linkshell/protocol";
|
|
12
|
+
import type { ProviderConfig } from "../providers.js";
|
|
13
|
+
import { ScrollbackBuffer } from "./scrollback.js";
|
|
14
|
+
|
|
15
|
+
export interface BridgeSessionOptions {
|
|
16
|
+
gatewayUrl: string;
|
|
17
|
+
gatewayHttpUrl: string;
|
|
18
|
+
pairingGateway?: string;
|
|
19
|
+
sessionId?: string;
|
|
20
|
+
cols: number;
|
|
21
|
+
rows: number;
|
|
22
|
+
clientName: string;
|
|
23
|
+
verbose?: boolean;
|
|
24
|
+
providerConfig: ProviderConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HEARTBEAT_INTERVAL = 15_000;
|
|
28
|
+
const RECONNECT_BASE_DELAY = 1_000;
|
|
29
|
+
const RECONNECT_MAX_DELAY = 30_000;
|
|
30
|
+
const RECONNECT_MAX_ATTEMPTS = 20;
|
|
31
|
+
|
|
32
|
+
function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(gatewayHttpUrl);
|
|
35
|
+
const hostname = url.hostname.trim().toLowerCase();
|
|
36
|
+
if (
|
|
37
|
+
hostname === "localhost" ||
|
|
38
|
+
hostname === "127.0.0.1" ||
|
|
39
|
+
hostname === "0.0.0.0" ||
|
|
40
|
+
hostname === "::1"
|
|
41
|
+
) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return url.toString().replace(/\/+$/, "");
|
|
45
|
+
} catch {
|
|
46
|
+
return gatewayHttpUrl.replace(/\/+$/, "") || undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolvePairingGateway(
|
|
51
|
+
gatewayHttpUrl: string,
|
|
52
|
+
pairingGateway?: string,
|
|
53
|
+
): string | undefined {
|
|
54
|
+
const override = pairingGateway?.trim();
|
|
55
|
+
if (!override) {
|
|
56
|
+
return getPairingGatewayParam(gatewayHttpUrl);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const absoluteUrl = new URL(override);
|
|
61
|
+
return absoluteUrl.toString().replace(/\/+$/, "");
|
|
62
|
+
} catch {
|
|
63
|
+
try {
|
|
64
|
+
const baseUrl = new URL(gatewayHttpUrl);
|
|
65
|
+
const normalizedHost = override
|
|
66
|
+
.replace(/^https?:\/\//i, "")
|
|
67
|
+
.replace(/\/.*$/, "")
|
|
68
|
+
.trim();
|
|
69
|
+
|
|
70
|
+
if (!normalizedHost) {
|
|
71
|
+
return getPairingGatewayParam(gatewayHttpUrl);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
baseUrl.hostname = normalizedHost;
|
|
75
|
+
return baseUrl.toString().replace(/\/+$/, "");
|
|
76
|
+
} catch {
|
|
77
|
+
return override.replace(/\/+$/, "") || undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class BridgeSession {
|
|
83
|
+
private readonly options: BridgeSessionOptions;
|
|
84
|
+
private socket: WebSocket | undefined;
|
|
85
|
+
private terminal: pty.IPty | undefined;
|
|
86
|
+
private outputSeq = 0;
|
|
87
|
+
private lastAckedSeq = -1;
|
|
88
|
+
private scrollback = new ScrollbackBuffer(1000);
|
|
89
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
90
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
91
|
+
private reconnectAttempts = 0;
|
|
92
|
+
private reconnecting = false;
|
|
93
|
+
private sessionId = "";
|
|
94
|
+
private exited = false;
|
|
95
|
+
private stopped = false;
|
|
96
|
+
|
|
97
|
+
constructor(options: BridgeSessionOptions) {
|
|
98
|
+
this.options = options;
|
|
99
|
+
this.sessionId = options.sessionId ?? "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private log(msg: string): void {
|
|
103
|
+
if (this.options.verbose) {
|
|
104
|
+
process.stderr.write(`[bridge:verbose] ${msg}\n`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async start(): Promise<void> {
|
|
109
|
+
this.log(`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`);
|
|
110
|
+
if (!this.sessionId) {
|
|
111
|
+
await this.createPairing();
|
|
112
|
+
}
|
|
113
|
+
this.spawnTerminal();
|
|
114
|
+
this.connectGateway();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async createPairing(): Promise<void> {
|
|
118
|
+
const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "content-type": "application/json" },
|
|
121
|
+
body: JSON.stringify({}),
|
|
122
|
+
});
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
throw new Error(`Failed to create pairing: ${res.status}`);
|
|
125
|
+
}
|
|
126
|
+
const body = (await res.json()) as {
|
|
127
|
+
sessionId: string;
|
|
128
|
+
pairingCode: string;
|
|
129
|
+
expiresAt: string;
|
|
130
|
+
};
|
|
131
|
+
this.sessionId = body.sessionId;
|
|
132
|
+
|
|
133
|
+
const pairingGateway = resolvePairingGateway(
|
|
134
|
+
this.options.gatewayHttpUrl,
|
|
135
|
+
this.options.pairingGateway,
|
|
136
|
+
);
|
|
137
|
+
const deepLink = pairingGateway
|
|
138
|
+
? `linkshell://pair?code=${body.pairingCode}&gateway=${encodeURIComponent(pairingGateway)}`
|
|
139
|
+
: `linkshell://pair?code=${body.pairingCode}`;
|
|
140
|
+
|
|
141
|
+
process.stderr.write(
|
|
142
|
+
`\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`,
|
|
143
|
+
);
|
|
144
|
+
process.stderr.write(` Session: ${body.sessionId}\n`);
|
|
145
|
+
process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
|
|
146
|
+
if (!pairingGateway) {
|
|
147
|
+
process.stderr.write(
|
|
148
|
+
" Note: QR will use the app's current gateway because the CLI is pointed at a local-only address.\n\n",
|
|
149
|
+
);
|
|
150
|
+
} else if (this.options.pairingGateway) {
|
|
151
|
+
process.stderr.write(` Pairing gateway: ${pairingGateway}\n\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Show QR code for mobile scanning
|
|
155
|
+
try {
|
|
156
|
+
const qrModule = await import("qrcode-terminal");
|
|
157
|
+
const qrDriver = qrModule.default ?? qrModule;
|
|
158
|
+
if (typeof qrDriver.generate === "function") {
|
|
159
|
+
qrDriver.generate(deepLink, { small: true }, (code: string) => {
|
|
160
|
+
process.stderr.write(` Scan to connect:\n`);
|
|
161
|
+
for (const line of code.split("\n")) {
|
|
162
|
+
process.stderr.write(` ${line}\n`);
|
|
163
|
+
}
|
|
164
|
+
process.stderr.write(`\n`);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// qrcode-terminal not available, skip
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
process.stderr.write(` Deep link: ${deepLink}\n\n`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private connectGateway(): void {
|
|
175
|
+
if (this.stopped) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const url = new URL(this.options.gatewayUrl);
|
|
180
|
+
url.searchParams.set("sessionId", this.sessionId);
|
|
181
|
+
url.searchParams.set("role", "host");
|
|
182
|
+
|
|
183
|
+
this.socket = new WebSocket(url);
|
|
184
|
+
|
|
185
|
+
this.socket.on("open", () => {
|
|
186
|
+
process.stderr.write(
|
|
187
|
+
this.reconnectAttempts > 0
|
|
188
|
+
? "[bridge] gateway reconnected\n"
|
|
189
|
+
: "[bridge] gateway connected\n",
|
|
190
|
+
);
|
|
191
|
+
this.reconnectAttempts = 0;
|
|
192
|
+
this.reconnecting = false;
|
|
193
|
+
this.send(
|
|
194
|
+
createEnvelope({
|
|
195
|
+
type: "session.connect",
|
|
196
|
+
sessionId: this.sessionId,
|
|
197
|
+
payload: {
|
|
198
|
+
role: "host" as const,
|
|
199
|
+
clientName: this.options.clientName,
|
|
200
|
+
provider: this.options.providerConfig.provider,
|
|
201
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
202
|
+
hostname: hostname(),
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
this.startHeartbeat();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
this.socket.on("message", (data) => {
|
|
210
|
+
const envelope = parseEnvelope(data.toString());
|
|
211
|
+
this.log(`recv ${envelope.type}${envelope.seq !== undefined ? ` seq=${envelope.seq}` : ""}`);
|
|
212
|
+
this.handleMessage(envelope);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
this.socket.on("close", (code, reasonBuffer) => {
|
|
216
|
+
this.stopHeartbeat();
|
|
217
|
+
this.socket = undefined;
|
|
218
|
+
const reason = reasonBuffer.toString();
|
|
219
|
+
process.stderr.write(
|
|
220
|
+
`[bridge] gateway connection closed (code=${code}${reason ? `, reason=${reason}` : ""})\n`,
|
|
221
|
+
);
|
|
222
|
+
if (!this.exited) {
|
|
223
|
+
this.scheduleReconnect();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
this.socket.on("error", (error) => {
|
|
228
|
+
process.stderr.write(`[bridge] gateway error: ${error.message}\n`);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private handleMessage(envelope: Envelope): void {
|
|
233
|
+
switch (envelope.type) {
|
|
234
|
+
case "terminal.input": {
|
|
235
|
+
const p = parseTypedPayload("terminal.input", envelope.payload);
|
|
236
|
+
this.terminal?.write(p.data);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case "terminal.resize": {
|
|
240
|
+
const p = parseTypedPayload("terminal.resize", envelope.payload);
|
|
241
|
+
this.terminal?.resize(p.cols, p.rows);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case "session.ack": {
|
|
245
|
+
const p = parseTypedPayload("session.ack", envelope.payload);
|
|
246
|
+
this.lastAckedSeq = Math.max(this.lastAckedSeq, p.seq);
|
|
247
|
+
this.scrollback.trimUpTo(this.lastAckedSeq);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case "session.resume": {
|
|
251
|
+
const p = parseTypedPayload("session.resume", envelope.payload);
|
|
252
|
+
this.replayFrom(p.lastAckedSeq);
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
case "session.heartbeat":
|
|
256
|
+
break;
|
|
257
|
+
default:
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private replayFrom(seq: number): void {
|
|
263
|
+
const messages = this.scrollback.replayFrom(seq);
|
|
264
|
+
for (const msg of messages) {
|
|
265
|
+
const payload = msg.payload as {
|
|
266
|
+
stream: string;
|
|
267
|
+
data: string;
|
|
268
|
+
encoding: string;
|
|
269
|
+
isReplay: boolean;
|
|
270
|
+
isFinal: boolean;
|
|
271
|
+
};
|
|
272
|
+
this.send(
|
|
273
|
+
createEnvelope({
|
|
274
|
+
type: "terminal.output",
|
|
275
|
+
sessionId: this.sessionId,
|
|
276
|
+
seq: msg.seq,
|
|
277
|
+
payload: { ...payload, isReplay: true },
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private spawnTerminal(): void {
|
|
284
|
+
// Filter out undefined env values — node-pty's native posix_spawnp chokes on them
|
|
285
|
+
const cleanEnv: Record<string, string> = {};
|
|
286
|
+
for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
|
|
287
|
+
if (v !== undefined) cleanEnv[k] = v;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
this.terminal = pty.spawn(
|
|
291
|
+
this.options.providerConfig.command,
|
|
292
|
+
this.options.providerConfig.args,
|
|
293
|
+
{
|
|
294
|
+
name: "xterm-256color",
|
|
295
|
+
cols: this.options.cols,
|
|
296
|
+
rows: this.options.rows,
|
|
297
|
+
cwd: process.cwd(),
|
|
298
|
+
env: cleanEnv,
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
this.terminal.onData((data) => {
|
|
303
|
+
const seq = this.outputSeq++;
|
|
304
|
+
const envelope = createEnvelope({
|
|
305
|
+
type: "terminal.output",
|
|
306
|
+
sessionId: this.sessionId,
|
|
307
|
+
seq,
|
|
308
|
+
payload: {
|
|
309
|
+
stream: "stdout" as const,
|
|
310
|
+
data,
|
|
311
|
+
encoding: "utf8" as const,
|
|
312
|
+
isReplay: false,
|
|
313
|
+
isFinal: false,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
this.scrollback.push(envelope);
|
|
317
|
+
this.send(envelope);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
this.terminal.onExit(({ exitCode, signal }) => {
|
|
321
|
+
this.exited = true;
|
|
322
|
+
const envelope = createEnvelope({
|
|
323
|
+
type: "terminal.exit",
|
|
324
|
+
sessionId: this.sessionId,
|
|
325
|
+
payload: { exitCode, signal },
|
|
326
|
+
});
|
|
327
|
+
this.send(envelope);
|
|
328
|
+
setTimeout(() => {
|
|
329
|
+
this.stopHeartbeat();
|
|
330
|
+
this.socket?.close();
|
|
331
|
+
}, 500);
|
|
332
|
+
process.exitCode = exitCode ?? 0;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private send(message: Envelope): void {
|
|
337
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
this.socket.send(serializeEnvelope(message));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private startHeartbeat(): void {
|
|
344
|
+
this.stopHeartbeat();
|
|
345
|
+
this.heartbeatTimer = setInterval(() => {
|
|
346
|
+
this.send(
|
|
347
|
+
createEnvelope({
|
|
348
|
+
type: "session.heartbeat",
|
|
349
|
+
sessionId: this.sessionId,
|
|
350
|
+
payload: { ts: Date.now() },
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
}, HEARTBEAT_INTERVAL);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private stopHeartbeat(): void {
|
|
357
|
+
if (this.heartbeatTimer) {
|
|
358
|
+
clearInterval(this.heartbeatTimer);
|
|
359
|
+
this.heartbeatTimer = undefined;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private scheduleReconnect(): void {
|
|
364
|
+
if (this.reconnecting || this.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
|
|
365
|
+
process.stderr.write(
|
|
366
|
+
"[bridge] max reconnect attempts reached, stopping bridge session\n",
|
|
367
|
+
);
|
|
368
|
+
this.stop(1);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
this.reconnecting = true;
|
|
372
|
+
const delay = Math.min(
|
|
373
|
+
RECONNECT_BASE_DELAY * 2 ** this.reconnectAttempts,
|
|
374
|
+
RECONNECT_MAX_DELAY,
|
|
375
|
+
);
|
|
376
|
+
this.reconnectAttempts++;
|
|
377
|
+
process.stderr.write(
|
|
378
|
+
`[bridge] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})\n`,
|
|
379
|
+
);
|
|
380
|
+
this.reconnectTimer = setTimeout(() => {
|
|
381
|
+
this.reconnectTimer = undefined;
|
|
382
|
+
if (this.stopped || this.exited) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
this.reconnecting = false;
|
|
386
|
+
this.connectGateway();
|
|
387
|
+
}, delay);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
stop(exitCode = 0): void {
|
|
391
|
+
if (this.stopped) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
this.stopped = true;
|
|
396
|
+
this.exited = true;
|
|
397
|
+
this.stopHeartbeat();
|
|
398
|
+
if (this.reconnectTimer) {
|
|
399
|
+
clearTimeout(this.reconnectTimer);
|
|
400
|
+
this.reconnectTimer = undefined;
|
|
401
|
+
}
|
|
402
|
+
this.socket?.close();
|
|
403
|
+
this.socket = undefined;
|
|
404
|
+
this.terminal?.kill();
|
|
405
|
+
process.exitCode = exitCode;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Envelope } from "@linkshell/protocol";
|
|
2
|
+
|
|
3
|
+
export class ScrollbackBuffer {
|
|
4
|
+
private buffer: Envelope[] = [];
|
|
5
|
+
private readonly capacity: number;
|
|
6
|
+
|
|
7
|
+
constructor(capacity = 1000) {
|
|
8
|
+
this.capacity = capacity;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
push(envelope: Envelope): void {
|
|
12
|
+
if (this.buffer.length >= this.capacity) {
|
|
13
|
+
this.buffer.shift();
|
|
14
|
+
}
|
|
15
|
+
this.buffer.push(envelope);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
replayFrom(seq: number): Envelope[] {
|
|
19
|
+
return this.buffer.filter(
|
|
20
|
+
(e) => e.seq !== undefined && e.seq > seq,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
trimUpTo(seq: number): void {
|
|
25
|
+
const idx = this.buffer.findIndex(
|
|
26
|
+
(e) => e.seq !== undefined && e.seq > seq,
|
|
27
|
+
);
|
|
28
|
+
if (idx > 0) {
|
|
29
|
+
this.buffer.splice(0, idx);
|
|
30
|
+
} else if (idx === -1) {
|
|
31
|
+
this.buffer = [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get size(): number {
|
|
36
|
+
return this.buffer.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get lastSeq(): number {
|
|
40
|
+
const last = this.buffer[this.buffer.length - 1];
|
|
41
|
+
return last?.seq ?? -1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { networkInterfaces } from "node:os";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get the first non-internal IPv4 address (LAN IP).
|
|
5
|
+
* Falls back to 127.0.0.1 if none found.
|
|
6
|
+
*/
|
|
7
|
+
export function getLanIp(): string {
|
|
8
|
+
const nets = networkInterfaces();
|
|
9
|
+
for (const name of Object.keys(nets)) {
|
|
10
|
+
const addrs = nets[name];
|
|
11
|
+
if (!addrs) continue;
|
|
12
|
+
for (const addr of addrs) {
|
|
13
|
+
if (addr.family === "IPv4" && !addr.internal) {
|
|
14
|
+
return addr.address;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return "127.0.0.1";
|
|
19
|
+
}
|