@enbox/local-node 0.0.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.
@@ -0,0 +1,513 @@
1
+ import type { LocalNodePairingSessionStore } from './pairing-session-store.js';
2
+ import type { LocalNodeServerConfigOptions } from './local-node-config.js';
3
+ import type { PairingBroker } from './pairing-broker.js';
4
+ import type { DwnDiscoveryFile, DwnDiscoveryRecord } from '@enbox/agent';
5
+ import type {
6
+ DwnServerConfig,
7
+ LocalNodePairingManager,
8
+ LocalNodePairingRequestView,
9
+ LocalNodePairingSessionRecord,
10
+ } from '@enbox/dwn-server';
11
+
12
+ import {
13
+ DwnDiscoveryFile as DefaultDwnDiscoveryFile,
14
+ localDwnPortCandidates,
15
+ localDwnServerName,
16
+ } from '@enbox/agent';
17
+ import { LocalNodePairingManager as DefaultLocalNodePairingManager, DwnServer } from '@enbox/dwn-server';
18
+
19
+ import { createLocalNodeDwnServerConfig } from './local-node-config.js';
20
+ import { LocalNodePairingSessionFile } from './pairing-session-store.js';
21
+
22
+ export type LocalNodeState = 'stopped' | 'running';
23
+
24
+ export type LocalNodeDwnServer = {
25
+ dwn?: { close(): Promise<void> };
26
+ localNodePairingManager: LocalNodePairingManager;
27
+ revokeLocalNodePairingToken?(token: string): Promise<boolean>;
28
+ start(): Promise<void>;
29
+ stop(): Promise<void>;
30
+ };
31
+
32
+ export type LocalNodeStartResult = {
33
+ discoveryFilePath : string;
34
+ endpoint : string;
35
+ localNodeToken : string;
36
+ pid : number;
37
+ port : number;
38
+ };
39
+
40
+ export type LocalNodeStatus = {
41
+ discoveryFilePath : string;
42
+ endpoint? : string;
43
+ localNodeToken? : string;
44
+ pendingPairingRequests : LocalNodePairingRequestView[];
45
+ pid : number;
46
+ port? : number;
47
+ state : LocalNodeState;
48
+ webSocketSupport : boolean;
49
+ };
50
+
51
+ export type ExistingLocalNode = {
52
+ endpoint : string;
53
+ pid : number;
54
+ };
55
+
56
+ type LocalNodeInfoResponse = {
57
+ localNode?: boolean;
58
+ server?: string;
59
+ };
60
+
61
+ export type LocalNodeOptions = Omit<LocalNodeServerConfigOptions, 'port'> & {
62
+ createServer? : (config: DwnServerConfig, pairingManager: LocalNodePairingManager) => LocalNodeDwnServer;
63
+ discoveryFile? : DwnDiscoveryFile;
64
+ fetch? : typeof fetch;
65
+ pairingBroker? : PairingBroker;
66
+ pairingPollIntervalMs? : number;
67
+ pairingManager? : LocalNodePairingManager;
68
+ pairingSessionStore? : LocalNodePairingSessionStore;
69
+ pid? : number;
70
+ portCandidates? : readonly number[];
71
+ };
72
+
73
+ export class LocalNodeAlreadyRunningError extends Error {
74
+ public readonly endpoint: string;
75
+ public readonly pid: number;
76
+
77
+ public constructor(existingNode: ExistingLocalNode) {
78
+ super(`LocalNode: local node is already running at ${existingNode.endpoint}.`);
79
+ this.name = 'LocalNodeAlreadyRunningError';
80
+ this.endpoint = existingNode.endpoint;
81
+ this.pid = existingNode.pid;
82
+ }
83
+ }
84
+
85
+ export class LocalNode {
86
+ readonly #allowedOrigins: string[];
87
+ readonly #configOptions: Omit<LocalNodeServerConfigOptions, 'allowedOrigins' | 'port'>;
88
+ readonly #createServer: (config: DwnServerConfig, pairingManager: LocalNodePairingManager) => LocalNodeDwnServer;
89
+ readonly #discoveryFile: DwnDiscoveryFile;
90
+ readonly #fetch: typeof fetch;
91
+ readonly #pairingBroker: PairingBroker | undefined;
92
+ readonly #pairingManager: LocalNodePairingManager;
93
+ readonly #pairingPollIntervalMs: number;
94
+ readonly #pairingSessionStore: LocalNodePairingSessionStore;
95
+ readonly #pid: number;
96
+ readonly #portCandidates: readonly number[];
97
+
98
+ #endpoint: string | undefined;
99
+ #localNodeToken: string | undefined;
100
+ #pairingBrokerLoopFailure: { error: unknown } | undefined;
101
+ #pairingBrokerProcessing: Promise<void> | undefined;
102
+ #pairingBrokerTimer: ReturnType<typeof setInterval> | undefined;
103
+ #pairingSessionChangeUnsubscribe: (() => void) | undefined;
104
+ #pairingSessionWriteFailure: { error: unknown } | undefined;
105
+ #pairingSessionWriteQueue: Promise<void> = Promise.resolve();
106
+ #port: number | undefined;
107
+ #server: LocalNodeDwnServer | undefined;
108
+ #state: LocalNodeState = 'stopped';
109
+
110
+ public constructor(options: LocalNodeOptions = {}) {
111
+ this.#allowedOrigins = options.allowedOrigins ?? [];
112
+ this.#configOptions = {
113
+ baseUrl : options.baseUrl,
114
+ dataStore : options.dataStore,
115
+ deliveryEnabled : options.deliveryEnabled,
116
+ eventBusPluginPath : options.eventBusPluginPath,
117
+ forwardingEnabled : options.forwardingEnabled,
118
+ hostname : options.hostname,
119
+ logLevel : options.logLevel,
120
+ maxRecordDataSize : options.maxRecordDataSize,
121
+ messageStore : options.messageStore,
122
+ packageJsonPath : options.packageJsonPath,
123
+ registrationStoreUrl : options.registrationStoreUrl,
124
+ resumableTaskStore : options.resumableTaskStore,
125
+ serverName : options.serverName,
126
+ storageUrl : options.storageUrl,
127
+ termsOfServiceFilePath : options.termsOfServiceFilePath,
128
+ ttlCacheUrl : options.ttlCacheUrl,
129
+ webSocketSupport : options.webSocketSupport,
130
+ };
131
+ this.#createServer = options.createServer ?? LocalNode.#createDwnServer;
132
+ this.#discoveryFile = options.discoveryFile ?? new DefaultDwnDiscoveryFile();
133
+ this.#fetch = options.fetch ?? fetch;
134
+ this.#pairingBroker = options.pairingBroker;
135
+ this.#pairingManager = options.pairingManager ?? new DefaultLocalNodePairingManager();
136
+ this.#pairingPollIntervalMs = options.pairingPollIntervalMs ?? 500;
137
+ this.#pairingSessionStore = options.pairingSessionStore ?? new LocalNodePairingSessionFile();
138
+ this.#pid = options.pid ?? process.pid;
139
+ this.#portCandidates = options.portCandidates ?? localDwnPortCandidates;
140
+ }
141
+
142
+ public get discoveryFilePath(): string {
143
+ return this.#discoveryFile.path;
144
+ }
145
+
146
+ public get endpoint(): string | undefined {
147
+ return this.#endpoint;
148
+ }
149
+
150
+ public get pairingManager(): LocalNodePairingManager {
151
+ return this.#pairingManager;
152
+ }
153
+
154
+ public get state(): LocalNodeState {
155
+ return this.#state;
156
+ }
157
+
158
+ public async start(): Promise<LocalNodeStartResult> {
159
+ if (this.#state === 'running') {
160
+ return this.#getStartResult();
161
+ }
162
+
163
+ const existingNode = await this.findExistingNode();
164
+ if (existingNode !== undefined) {
165
+ throw new LocalNodeAlreadyRunningError(existingNode);
166
+ }
167
+
168
+ await this.#loadPairingSessions();
169
+ const localNodeToken = this.#pairingManager.createSession(undefined);
170
+ this.#subscribeToPairingSessionChanges();
171
+
172
+ try {
173
+ const startResult = await this.#startFirstAvailableServer();
174
+ this.#server = startResult.server;
175
+ this.#port = startResult.port;
176
+ this.#endpoint = startResult.endpoint;
177
+ this.#localNodeToken = localNodeToken;
178
+ this.#state = 'running';
179
+
180
+ await this.#writeDiscoveryFile();
181
+ this.#startPairingBrokerLoop();
182
+ } catch (error) {
183
+ await this.#cleanupStartedServer();
184
+ throw error;
185
+ }
186
+
187
+ return this.#getStartResult();
188
+ }
189
+
190
+ public async stop(): Promise<void> {
191
+ if (this.#state === 'stopped') {
192
+ return;
193
+ }
194
+
195
+ this.#stopPairingBrokerLoop();
196
+ let stopFailure: { error: unknown } | undefined;
197
+
198
+ try {
199
+ await this.#discoveryFile.remove();
200
+ } catch (error) {
201
+ stopFailure = { error };
202
+ }
203
+
204
+ try {
205
+ await this.#server?.stop();
206
+ } catch (error) {
207
+ stopFailure ??= { error };
208
+ }
209
+
210
+ this.#unsubscribeFromPairingSessionChanges();
211
+ try {
212
+ await this.#flushPairingSessionWrites();
213
+ } catch (error) {
214
+ stopFailure ??= { error };
215
+ }
216
+
217
+ stopFailure ??= this.#pairingBrokerLoopFailure;
218
+ this.#endpoint = undefined;
219
+ this.#localNodeToken = undefined;
220
+ this.#pairingBrokerLoopFailure = undefined;
221
+ this.#port = undefined;
222
+ this.#server = undefined;
223
+ this.#state = 'stopped';
224
+
225
+ if (stopFailure !== undefined) {
226
+ throw stopFailure.error;
227
+ }
228
+ }
229
+
230
+ public async findExistingNode(): Promise<ExistingLocalNode | undefined> {
231
+ const record = await this.#discoveryFile.read();
232
+ if (record === undefined) {
233
+ return undefined;
234
+ }
235
+
236
+ const isLive = await this.#isLiveLocalNode(record.endpoint);
237
+ if (!isLive) {
238
+ await this.#discoveryFile.remove();
239
+ return undefined;
240
+ }
241
+
242
+ return {
243
+ endpoint : record.endpoint,
244
+ pid : record.pid,
245
+ };
246
+ }
247
+
248
+ public getStatus(): LocalNodeStatus {
249
+ return {
250
+ discoveryFilePath : this.#discoveryFile.path,
251
+ endpoint : this.#endpoint,
252
+ localNodeToken : this.#localNodeToken,
253
+ pendingPairingRequests : this.#pairingManager.listPendingRequests(),
254
+ pid : this.#pid,
255
+ port : this.#port,
256
+ state : this.#state,
257
+ webSocketSupport : this.#configOptions.webSocketSupport ?? true,
258
+ };
259
+ }
260
+
261
+ public async revokePairingToken(token: string): Promise<boolean> {
262
+ const revoked = this.#server?.revokeLocalNodePairingToken !== undefined
263
+ ? await this.#server.revokeLocalNodePairingToken(token)
264
+ : this.#pairingManager.revokeToken(token);
265
+ if (!revoked) {
266
+ return false;
267
+ }
268
+
269
+ await this.#flushPairingSessionWrites();
270
+ return true;
271
+ }
272
+
273
+ public async processPendingPairingRequests(): Promise<void> {
274
+ if (this.#pairingBroker === undefined) {
275
+ return;
276
+ }
277
+
278
+ if (this.#pairingBrokerProcessing !== undefined) {
279
+ await this.#pairingBrokerProcessing;
280
+ return;
281
+ }
282
+
283
+ const processing = this.#processPendingPairingRequests();
284
+ this.#pairingBrokerProcessing = processing;
285
+
286
+ try {
287
+ await processing;
288
+ } finally {
289
+ this.#pairingBrokerProcessing = undefined;
290
+ }
291
+ }
292
+
293
+ async #processPendingPairingRequests(): Promise<void> {
294
+ const pendingRequests = this.#pairingManager.listPendingRequests();
295
+ for (const request of pendingRequests) {
296
+ await this.#processPairingRequest(request);
297
+ }
298
+ }
299
+
300
+ static #createDwnServer(config: DwnServerConfig, pairingManager: LocalNodePairingManager): LocalNodeDwnServer {
301
+ return new DwnServer({
302
+ config,
303
+ localNodePairingManager: pairingManager,
304
+ });
305
+ }
306
+
307
+ async #startFirstAvailableServer(): Promise<{ endpoint: string; port: number; server: LocalNodeDwnServer }> {
308
+ for (const port of this.#portCandidates) {
309
+ const config = createLocalNodeDwnServerConfig({
310
+ ...this.#configOptions,
311
+ allowedOrigins: this.#allowedOrigins,
312
+ port,
313
+ });
314
+ const server = this.#createServer(config, this.#pairingManager);
315
+
316
+ try {
317
+ await server.start();
318
+ return {
319
+ endpoint: config.baseUrl,
320
+ port,
321
+ server,
322
+ };
323
+ } catch (error) {
324
+ await LocalNode.#closeFailedServer(server);
325
+ if (!isAddressInUseError(error) || this.#portCandidates.length === 1) {
326
+ throw error;
327
+ }
328
+ }
329
+ }
330
+
331
+ throw new Error(`LocalNode: no available port found in ${Array.from(this.#portCandidates).join(', ')}.`);
332
+ }
333
+
334
+ async #writeDiscoveryFile(): Promise<void> {
335
+ if (this.#endpoint === undefined || this.#localNodeToken === undefined) {
336
+ throw new Error('LocalNode: cannot write discovery file before the node has started.');
337
+ }
338
+
339
+ const capabilities = this.#configOptions.webSocketSupport === false ? ['http'] : ['http', 'ws'];
340
+ const record: DwnDiscoveryRecord = {
341
+ capabilities,
342
+ endpoint : this.#endpoint,
343
+ localNodeToken : this.#localNodeToken,
344
+ pid : this.#pid,
345
+ };
346
+
347
+ await this.#discoveryFile.write(record);
348
+ }
349
+
350
+ #startPairingBrokerLoop(): void {
351
+ if (this.#pairingBroker === undefined || this.#pairingBrokerTimer !== undefined) {
352
+ return;
353
+ }
354
+
355
+ void this.processPendingPairingRequests().catch((error: unknown): void => {
356
+ this.#pairingBrokerLoopFailure ??= { error };
357
+ });
358
+ this.#pairingBrokerTimer = setInterval((): void => {
359
+ void this.processPendingPairingRequests().catch((error: unknown): void => {
360
+ this.#pairingBrokerLoopFailure ??= { error };
361
+ });
362
+ }, this.#pairingPollIntervalMs);
363
+ }
364
+
365
+ #stopPairingBrokerLoop(): void {
366
+ if (this.#pairingBrokerTimer === undefined) {
367
+ return;
368
+ }
369
+
370
+ clearInterval(this.#pairingBrokerTimer);
371
+ this.#pairingBrokerTimer = undefined;
372
+ }
373
+
374
+ async #processPairingRequest(request: LocalNodePairingRequestView): Promise<void> {
375
+ const decision = await this.#pairingBroker!.decidePairingRequest(request);
376
+ if (decision === 'approve') {
377
+ const approved = this.#pairingManager.approveRequest(request.id);
378
+ if (approved) {
379
+ await this.#flushPairingSessionWrites();
380
+ }
381
+ } else {
382
+ this.#pairingManager.denyRequest(request.id);
383
+ }
384
+ }
385
+
386
+ async #isLiveLocalNode(endpoint: string): Promise<boolean> {
387
+ try {
388
+ const endpointWithTrailingSlash = endpoint.endsWith('/') ? endpoint : `${endpoint}/`;
389
+ const response = await this.#fetch(new URL('info', endpointWithTrailingSlash).toString());
390
+ if (!response.ok) {
391
+ return false;
392
+ }
393
+
394
+ const serverInfo = await response.json() as LocalNodeInfoResponse;
395
+ return serverInfo.server === localDwnServerName && serverInfo.localNode === true;
396
+ } catch {
397
+ return false;
398
+ }
399
+ }
400
+
401
+ async #loadPairingSessions(): Promise<void> {
402
+ const sessions = await this.#pairingSessionStore.read();
403
+ this.#pairingManager.importSessions(sessions);
404
+ }
405
+
406
+ #subscribeToPairingSessionChanges(): void {
407
+ this.#pairingSessionChangeUnsubscribe = this.#pairingManager.onSessionsChanged(
408
+ (sessions: LocalNodePairingSessionRecord[]): void => {
409
+ const browserSessions = sessions
410
+ .filter((session: LocalNodePairingSessionRecord): boolean => session.origin !== undefined);
411
+
412
+ this.#pairingSessionWriteQueue = this.#pairingSessionWriteQueue.then(async (): Promise<void> => {
413
+ try {
414
+ await this.#pairingSessionStore.write(browserSessions);
415
+ } catch (error) {
416
+ this.#pairingSessionWriteFailure ??= { error };
417
+ }
418
+ });
419
+ },
420
+ );
421
+ }
422
+
423
+ #unsubscribeFromPairingSessionChanges(): void {
424
+ this.#pairingSessionChangeUnsubscribe?.();
425
+ this.#pairingSessionChangeUnsubscribe = undefined;
426
+ }
427
+
428
+ async #flushPairingSessionWrites(): Promise<void> {
429
+ let pendingWrites: Promise<void>;
430
+ do {
431
+ pendingWrites = this.#pairingSessionWriteQueue;
432
+ await pendingWrites;
433
+ } while (pendingWrites !== this.#pairingSessionWriteQueue);
434
+
435
+ const failure = this.#pairingSessionWriteFailure;
436
+ this.#pairingSessionWriteFailure = undefined;
437
+ if (failure !== undefined) {
438
+ throw failure.error;
439
+ }
440
+ }
441
+
442
+ async #waitForPairingSessionWrites(): Promise<void> {
443
+ let pendingWrites: Promise<void>;
444
+ do {
445
+ pendingWrites = this.#pairingSessionWriteQueue;
446
+ await pendingWrites;
447
+ } while (pendingWrites !== this.#pairingSessionWriteQueue);
448
+ }
449
+
450
+ #getStartResult(): LocalNodeStartResult {
451
+ if (this.#endpoint === undefined || this.#localNodeToken === undefined || this.#port === undefined) {
452
+ throw new Error('LocalNode: node is not running.');
453
+ }
454
+
455
+ return {
456
+ discoveryFilePath : this.#discoveryFile.path,
457
+ endpoint : this.#endpoint,
458
+ localNodeToken : this.#localNodeToken,
459
+ pid : this.#pid,
460
+ port : this.#port,
461
+ };
462
+ }
463
+
464
+ static async #closeFailedServer(server: LocalNodeDwnServer): Promise<void> {
465
+ try {
466
+ await server.stop();
467
+ } catch {
468
+ // Best-effort cleanup for partially-started servers.
469
+ }
470
+
471
+ try {
472
+ await server.dwn?.close();
473
+ } catch {
474
+ // Best-effort cleanup for partially-created DWN instances.
475
+ }
476
+ }
477
+
478
+ async #cleanupStartedServer(): Promise<void> {
479
+ this.#stopPairingBrokerLoop();
480
+
481
+ try {
482
+ await this.#server?.stop();
483
+ } finally {
484
+ this.#unsubscribeFromPairingSessionChanges();
485
+ await this.#waitForPairingSessionWrites();
486
+ this.#endpoint = undefined;
487
+ this.#localNodeToken = undefined;
488
+ this.#pairingBrokerLoopFailure = undefined;
489
+ this.#port = undefined;
490
+ this.#server = undefined;
491
+ this.#state = 'stopped';
492
+ }
493
+ }
494
+ }
495
+
496
+ function isAddressInUseError(error: unknown): boolean {
497
+ if (!error || typeof error !== 'object') {
498
+ return false;
499
+ }
500
+
501
+ const maybeCode = (error as { code?: unknown }).code;
502
+ if (maybeCode === 'EADDRINUSE') {
503
+ return true;
504
+ }
505
+
506
+ const maybeMessage = (error as { message?: unknown }).message;
507
+ if (typeof maybeMessage === 'string' && maybeMessage.toLowerCase().includes('address already in use')) {
508
+ return true;
509
+ }
510
+
511
+ const maybeCause = (error as { cause?: unknown }).cause;
512
+ return isAddressInUseError(maybeCause);
513
+ }
@@ -0,0 +1,64 @@
1
+ import type { LocalNodePairingRequestView } from '@enbox/dwn-server';
2
+ import type { Readable, Writable } from 'node:stream';
3
+
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { stdin as defaultInput, stdout as defaultOutput } from 'node:process';
6
+
7
+ type TtyReadable = Readable & { isTTY?: boolean };
8
+ type TtyWritable = Writable & { isTTY?: boolean };
9
+
10
+ export type PairingDecision = 'approve' | 'deny';
11
+
12
+ export interface PairingBroker {
13
+ decidePairingRequest(request: LocalNodePairingRequestView): Promise<PairingDecision>;
14
+ }
15
+
16
+ export class AllowOriginPairingBroker implements PairingBroker {
17
+ readonly #allowedOrigins: Set<string>;
18
+ readonly #fallback: PairingBroker | undefined;
19
+
20
+ public constructor(allowedOrigins: string[] = [], fallback?: PairingBroker) {
21
+ this.#allowedOrigins = new Set(allowedOrigins);
22
+ this.#fallback = fallback;
23
+ }
24
+
25
+ public async decidePairingRequest(request: LocalNodePairingRequestView): Promise<PairingDecision> {
26
+ if (this.#allowedOrigins.has(request.origin)) {
27
+ return 'approve';
28
+ }
29
+
30
+ if (this.#fallback !== undefined) {
31
+ return this.#fallback.decidePairingRequest(request);
32
+ }
33
+
34
+ return 'deny';
35
+ }
36
+ }
37
+
38
+ export class TtyPairingBroker implements PairingBroker {
39
+ readonly #input: TtyReadable;
40
+ readonly #output: TtyWritable;
41
+
42
+ public constructor(options: { input?: TtyReadable; output?: TtyWritable } = {}) {
43
+ this.#input = options.input ?? defaultInput;
44
+ this.#output = options.output ?? defaultOutput;
45
+ }
46
+
47
+ public async decidePairingRequest(request: LocalNodePairingRequestView): Promise<PairingDecision> {
48
+ if (this.#input.isTTY !== true || this.#output.isTTY !== true) {
49
+ return 'deny';
50
+ }
51
+
52
+ const readline = createInterface({
53
+ input : this.#input,
54
+ output : this.#output,
55
+ });
56
+
57
+ try {
58
+ const answer = await readline.question(`Approve local DWN pairing for ${request.origin}? [y/N] `);
59
+ return /^(?:y|yes)$/i.test(answer.trim()) ? 'approve' : 'deny';
60
+ } finally {
61
+ readline.close();
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,132 @@
1
+ import type { LocalNodePairingSessionRecord } from '@enbox/dwn-server';
2
+
3
+ import { homedir } from 'node:os';
4
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ export const LOCAL_NODE_PAIRING_SESSIONS_FILENAME = 'local-node-sessions.json';
8
+
9
+ const localNodePairingSessionsVersion = 1;
10
+
11
+ export interface LocalNodePairingSessionStore {
12
+ readonly path: string;
13
+ read(): Promise<LocalNodePairingSessionRecord[]>;
14
+ remove(): Promise<void>;
15
+ write(sessions: LocalNodePairingSessionRecord[]): Promise<void>;
16
+ }
17
+
18
+ export class LocalNodePairingSessionFile implements LocalNodePairingSessionStore {
19
+ readonly #filePath: string;
20
+
21
+ public constructor(filePath?: string) {
22
+ this.#filePath = filePath ?? join(homedir(), '.enbox', LOCAL_NODE_PAIRING_SESSIONS_FILENAME);
23
+ }
24
+
25
+ public get path(): string {
26
+ return this.#filePath;
27
+ }
28
+
29
+ public async read(): Promise<LocalNodePairingSessionRecord[]> {
30
+ let raw: string;
31
+ try {
32
+ raw = await readFile(this.#filePath, 'utf-8');
33
+ } catch (error) {
34
+ if (isMissingFileError(error)) {
35
+ return [];
36
+ }
37
+ throw error;
38
+ }
39
+
40
+ let parsed: unknown;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ } catch {
44
+ await this.remove();
45
+ return [];
46
+ }
47
+
48
+ if (!isValidPairingSessionsFile(parsed)) {
49
+ await this.remove();
50
+ return [];
51
+ }
52
+
53
+ return parsed.sessions.map((session: SerializedPairingSession): LocalNodePairingSessionRecord => ({
54
+ createdAt : session.createdAt,
55
+ origin : session.origin,
56
+ token : session.token,
57
+ }));
58
+ }
59
+
60
+ public async write(sessions: LocalNodePairingSessionRecord[]): Promise<void> {
61
+ const browserSessions = sessions.filter((session): session is SerializedPairingSession => session.origin !== undefined);
62
+ const serialized = {
63
+ sessions : browserSessions,
64
+ version : localNodePairingSessionsVersion,
65
+ };
66
+ const json = JSON.stringify(serialized, null, 2);
67
+ const tempPath = `${this.#filePath}.${process.pid}.tmp`;
68
+
69
+ await mkdir(dirname(this.#filePath), { recursive: true });
70
+ await writeFile(tempPath, json, { encoding: 'utf-8', mode: 0o600 });
71
+ await rename(tempPath, this.#filePath);
72
+ await chmod(this.#filePath, 0o600);
73
+ }
74
+
75
+ public async remove(): Promise<void> {
76
+ await rm(this.#filePath, { force: true });
77
+ }
78
+ }
79
+
80
+ type SerializedPairingSession = {
81
+ createdAt : number;
82
+ origin : string;
83
+ token : string;
84
+ };
85
+
86
+ type SerializedPairingSessionsFile = {
87
+ sessions : SerializedPairingSession[];
88
+ version : typeof localNodePairingSessionsVersion;
89
+ };
90
+
91
+ function isMissingFileError(error: unknown): boolean {
92
+ return Boolean(error)
93
+ && typeof error === 'object'
94
+ && (error as { code?: unknown }).code === 'ENOENT';
95
+ }
96
+
97
+ function isValidPairingSessionsFile(value: unknown): value is SerializedPairingSessionsFile {
98
+ if (typeof value !== 'object' || value === null) {
99
+ return false;
100
+ }
101
+
102
+ const file = value as Record<string, unknown>;
103
+ return file.version === localNodePairingSessionsVersion
104
+ && Array.isArray(file.sessions)
105
+ && file.sessions.every(isValidPairingSession);
106
+ }
107
+
108
+ function isValidPairingSession(value: unknown): value is SerializedPairingSession {
109
+ if (typeof value !== 'object' || value === null) {
110
+ return false;
111
+ }
112
+
113
+ const session = value as Record<string, unknown>;
114
+ const createdAt = session.createdAt;
115
+ return typeof createdAt === 'number'
116
+ && Number.isInteger(createdAt)
117
+ && createdAt > 0
118
+ && typeof session.token === 'string'
119
+ && session.token.length > 0
120
+ && typeof session.origin === 'string'
121
+ && isValidHttpOrigin(session.origin);
122
+ }
123
+
124
+ function isValidHttpOrigin(origin: string): boolean {
125
+ try {
126
+ const url = new URL(origin);
127
+ return (url.protocol === 'http:' || url.protocol === 'https:')
128
+ && url.origin === origin;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }