socket-function 1.1.29 → 1.1.31

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.
@@ -22,7 +22,6 @@ export declare class SocketFunction {
22
22
  static HTTP_ETAG_CACHE: boolean;
23
23
  static silent: boolean;
24
24
  static HTTP_COMPRESS: boolean;
25
- static LEGACY_INITIALIZE: boolean;
26
25
  static COEP: string;
27
26
  static COOP: string;
28
27
  static TOTAL_CALLS: number;
@@ -64,8 +63,10 @@ export declare class SocketFunction {
64
63
  private static socketCache;
65
64
  static rehydrateSocketCaller<Controller>(socketRegistered: SocketRegisterType<Controller>, shapeFnc?: () => SocketExposedShape): SocketRegistered<Controller>;
66
65
  private static callFromGuid;
67
- /** Will dedupe callbacks, so if you call with the same callback it won't call it multiple times (otherwise it's difficult to manage this, as this only calls on the NEXT callback). */
68
- static onNextDisconnect(nodeId: string, callback: () => void): void;
66
+ /** Will dedupe callbacks, so if you call with the same callback it won't call it multiple times (otherwise it's difficult to manage this, as this only calls on the NEXT callback).
67
+ IMPORTANT! Client node ids will NEVER reconnect, so this can full cleanup. However full nodeIds might if we try to use that nodeId again, so this cannot fully clean them up.
68
+ */
69
+ static onNextDisconnect(nodeId: string, callback: () => void, noServerNodeIdWarning?: "iKnowThatServerNodeIdsMayReconnect_andDontPermanentlyCleanThemUp"): void;
69
70
  static getLastDisconnectTime(nodeId: string): number | undefined;
70
71
  static isNodeConnected(nodeId: string): boolean;
71
72
  /** NOTE: Only works if the nodeIs used is from SocketFunction.connect (we can't convert arbitrary nodeIds into urls,
package/SocketFunction.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { SocketExposedInterface, SocketFunctionHook, SocketFunctionClientHook, SocketExposedShape, SocketRegistered, CallerContext, FullCallType, CallType, FncType, SocketRegisterType } from "./SocketFunctionTypes";
4
4
  import { exposeClass, registerClass, registerGlobalClientHook, registerGlobalHook, runClientHooks } from "./src/callManager";
5
5
  import { SocketServerConfig, startSocketServer } from "./src/webSocketServer";
6
- import { getCallFactory, getCreateCallFactory, getNodeId, getNodeIdLocation } from "./src/nodeCache";
6
+ import { getCallFactory, getCreateCallFactory, getNodeId, getNodeIdLocation, isClientNodeId } from "./src/nodeCache";
7
7
  import { getCallProxy } from "./src/nodeProxy";
8
8
  import { Args, MaybePromise } from "./src/types";
9
9
  import { setDefaultHTTPCall } from "./src/callHTTPHandler";
@@ -61,8 +61,6 @@ export class SocketFunction {
61
61
 
62
62
  public static HTTP_COMPRESS = false;
63
63
 
64
- public static LEGACY_INITIALIZE = false;
65
-
66
64
  // If you have HTTP resources that require cookies you might to set `SocketFunction.COEP = "require-corp"`
67
65
  // - Cross-origin-resource-policy.
68
66
  public static COEP = "credentialless";
@@ -289,8 +287,18 @@ export class SocketFunction {
289
287
  }
290
288
  }
291
289
 
292
- /** Will dedupe callbacks, so if you call with the same callback it won't call it multiple times (otherwise it's difficult to manage this, as this only calls on the NEXT callback). */
293
- public static onNextDisconnect(nodeId: string, callback: () => void) {
290
+ /** Will dedupe callbacks, so if you call with the same callback it won't call it multiple times (otherwise it's difficult to manage this, as this only calls on the NEXT callback).
291
+ IMPORTANT! Client node ids will NEVER reconnect, so this can full cleanup. However full nodeIds might if we try to use that nodeId again, so this cannot fully clean them up.
292
+ */
293
+ public static onNextDisconnect(
294
+ nodeId: string,
295
+ callback: () => void,
296
+ // NOTE: It's important to know that unlike client ids, server ids (a nodeId YOU connect to, instead of connecting to you), might be alive again, and so you need some kind of logic to try it again or in some way reconnect. For clients you don't need to, as it's their job to reconnect to you, and they will reconnect with a NEW nodeId.
297
+ noServerNodeIdWarning?: "iKnowThatServerNodeIdsMayReconnect_andDontPermanentlyCleanThemUp"
298
+ ) {
299
+ if (!isClientNodeId(nodeId) && !noServerNodeIdWarning) {
300
+ console.warn(`Watching for disconnections of ${nodeId}. This is a server nodeId and may be alive again after disconnection. Please set the noServerNodeIdWarning flag in this argument to confirm you are handling reconnecting if the server becomes available again.`);
301
+ }
294
302
  (async () => {
295
303
  let factory = await getCallFactory(nodeId);
296
304
  if (!factory) {
package/index.d.ts CHANGED
@@ -31,7 +31,6 @@ declare module "socket-function/SocketFunction" {
31
31
  static HTTP_ETAG_CACHE: boolean;
32
32
  static silent: boolean;
33
33
  static HTTP_COMPRESS: boolean;
34
- static LEGACY_INITIALIZE: boolean;
35
34
  static COEP: string;
36
35
  static COOP: string;
37
36
  static TOTAL_CALLS: number;
@@ -73,8 +72,10 @@ declare module "socket-function/SocketFunction" {
73
72
  private static socketCache;
74
73
  static rehydrateSocketCaller<Controller>(socketRegistered: SocketRegisterType<Controller>, shapeFnc?: () => SocketExposedShape): SocketRegistered<Controller>;
75
74
  private static callFromGuid;
76
- /** Will dedupe callbacks, so if you call with the same callback it won't call it multiple times (otherwise it's difficult to manage this, as this only calls on the NEXT callback). */
77
- static onNextDisconnect(nodeId: string, callback: () => void): void;
75
+ /** Will dedupe callbacks, so if you call with the same callback it won't call it multiple times (otherwise it's difficult to manage this, as this only calls on the NEXT callback).
76
+ IMPORTANT! Client node ids will NEVER reconnect, so this can full cleanup. However full nodeIds might if we try to use that nodeId again, so this cannot fully clean them up.
77
+ */
78
+ static onNextDisconnect(nodeId: string, callback: () => void, noServerNodeIdWarning?: "iKnowThatServerNodeIdsMayReconnect_andDontPermanentlyCleanThemUp"): void;
78
79
  static getLastDisconnectTime(nodeId: string): number | undefined;
79
80
  static isNodeConnected(nodeId: string): boolean;
80
81
  /** NOTE: Only works if the nodeIs used is from SocketFunction.connect (we can't convert arbitrary nodeIds into urls,
@@ -472,6 +473,7 @@ declare module "socket-function/src/CallFactory" {
472
473
  closedForever?: boolean;
473
474
  isConnected?: boolean;
474
475
  receivedInitializeState?: InitializeState;
476
+ protocolNegotiated?: boolean;
475
477
  performCall(call: CallType): Promise<unknown>;
476
478
  onNextDisconnect(callback: () => void): void;
477
479
  disconnect(): void;
@@ -482,6 +484,7 @@ declare module "socket-function/src/CallFactory" {
482
484
  export interface SenderInterface {
483
485
  nodeId?: string;
484
486
  _socket?: tls.TLSSocket;
487
+ protocol?: string;
485
488
  send(data: string | Buffer): void;
486
489
  close(): void;
487
490
  addEventListener(event: "open", listener: () => void): void;
@@ -1079,11 +1082,6 @@ declare module "socket-function/src/nodeCache" {
1079
1082
  export declare function getNodeIdDomain(nodeId: string): string;
1080
1083
  export declare function getNodeIdDomainMaybeUndefined(nodeId: string): string | undefined;
1081
1084
  export declare function registerNodeClient(callFactory: CallFactory): void;
1082
- export declare function changeNodeId(config: {
1083
- originalNodeId: string;
1084
- newNodeId: string;
1085
- callFactory: CallFactory;
1086
- }): boolean;
1087
1085
  export declare function getCreateCallFactory(nodeId: string): MaybePromise<CallFactory>;
1088
1086
  export declare function getCallFactory(nodeId: string): MaybePromise<CallFactory | undefined>;
1089
1087
  export declare function debugGetAllCallFactories(): CallFactory[];
@@ -1283,6 +1281,25 @@ declare module "socket-function/src/promiseRace" {
1283
1281
 
1284
1282
  }
1285
1283
 
1284
+ declare module "socket-function/src/protocolNegotiation" {
1285
+ export type ConnectionFlags = {
1286
+ clientLZ4: boolean;
1287
+ serverLZ4: boolean;
1288
+ };
1289
+ export type DecodedProtocol = {
1290
+ target: string;
1291
+ flags: ConnectionFlags;
1292
+ };
1293
+ export declare function decodeProtocol(hex: string): DecodedProtocol | undefined;
1294
+ export declare function proposeProtocols(target: string | undefined, clientCapabilities: {
1295
+ lz4: boolean;
1296
+ }): string[];
1297
+ export declare function chooseProtocol(proposed: string[], serverNodeId: string, serverCapabilities: {
1298
+ lz4: boolean;
1299
+ }): string | undefined;
1300
+
1301
+ }
1302
+
1286
1303
  declare module "socket-function/src/runPromise" {
1287
1304
  export declare const runAsync: typeof runPromise;
1288
1305
  export declare function runPromise(command: string, config?: {
@@ -1419,10 +1436,11 @@ declare module "socket-function/src/websocketFactory" {
1419
1436
  import { SenderInterface } from "socket-function/src/CallFactory";
1420
1437
  import type * as ws from "ws";
1421
1438
  export declare function getTLSSocket(webSocket: ws.WebSocket): tls.TLSSocket;
1439
+ export type WebsocketFactory = (nodeId: string, proposedProtocols?: string[]) => SenderInterface;
1422
1440
  /** NOTE: We create a factory, which embeds the key/cert information. Otherwise retries might use
1423
1441
  * a different key/cert context.
1424
1442
  */
1425
- export declare function createWebsocketFactory(): (nodeId: string) => SenderInterface;
1443
+ export declare function createWebsocketFactory(): WebsocketFactory;
1426
1444
 
1427
1445
  }
1428
1446
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socket-function",
3
- "version": "1.1.29",
3
+ "version": "1.1.31",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "dependencies": {
@@ -11,6 +11,7 @@ export interface CallFactory {
11
11
  closedForever?: boolean;
12
12
  isConnected?: boolean;
13
13
  receivedInitializeState?: InitializeState;
14
+ protocolNegotiated?: boolean;
14
15
  performCall(call: CallType): Promise<unknown>;
15
16
  onNextDisconnect(callback: () => void): void;
16
17
  disconnect(): void;
@@ -21,6 +22,7 @@ export interface CallFactory {
21
22
  export interface SenderInterface {
22
23
  nodeId?: string;
23
24
  _socket?: tls.TLSSocket;
25
+ protocol?: string;
24
26
  send(data: string | Buffer): void;
25
27
  close(): void;
26
28
  addEventListener(event: "open", listener: () => void): void;
@@ -5,7 +5,7 @@ import { convertErrorStackToError, formatNumberSuffixed, isBufferType, isNode, l
5
5
  import { createWebsocketFactory, getTLSSocket } from "./websocketFactory";
6
6
  import { SocketFunction } from "../SocketFunction";
7
7
  import * as tls from "tls";
8
- import { changeNodeId, getClientNodeId, getNodeIdLocation, registerNodeClient } from "./nodeCache";
8
+ import { getClientNodeId, getNodeIdLocation, registerNodeClient } from "./nodeCache";
9
9
  import debugbreak from "debugbreak";
10
10
  import { lazy } from "./caching";
11
11
  import { blue, green, red, yellow } from "./formatting/logColors";
@@ -18,7 +18,8 @@ import { setFlag } from "../require/compileFlags";
18
18
  import { measureFnc, measureWrap, registerMeasureInfo } from "./profiling/measure";
19
19
  import { MaybePromise } from "./types";
20
20
  import { Zip } from "./Zip";
21
- import { LZ4 } from "./lz4/LZ4";
21
+ import { decodeProtocol, proposeProtocols } from "./protocolNegotiation";
22
+ setImmediate(() => import("./lz4/LZ4"));
22
23
 
23
24
  setFlag(require, "pako", "allowclient", true);
24
25
 
@@ -49,6 +50,9 @@ export interface CallFactory {
49
50
  closedForever?: boolean;
50
51
  isConnected?: boolean;
51
52
  receivedInitializeState?: InitializeState;
53
+ // True if the connection was established with a negotiated
54
+ // Sec-WebSocket-Protocol (so the legacy initialize packet should be skipped).
55
+ protocolNegotiated?: boolean;
52
56
  // NOTE: May or may not have reconnection or retry logic inside of performCall.
53
57
  // Trigger performLocalCall on the other side of the connection
54
58
  performCall(call: CallType): Promise<unknown>;
@@ -62,6 +66,9 @@ export interface SenderInterface {
62
66
  nodeId?: string;
63
67
  // Only set AFTER "open" (if set at all, as in the browser we don't have access to the socket).
64
68
  _socket?: tls.TLSSocket;
69
+ // The chosen Sec-WebSocket-Protocol value (set after "open" by both Node `ws`
70
+ // and the browser WebSocket). Empty string means none was negotiated.
71
+ protocol?: string;
65
72
 
66
73
  send(data: string | Buffer): void;
67
74
  close(): void;
@@ -368,6 +375,23 @@ export async function createCallFactory(
368
375
  } else {
369
376
  onClose(new Error(`Websocket received in closed state`).stack!);
370
377
  }
378
+
379
+ if (callFactory.lastClosed && callFactory.isConnected) {
380
+ console.log(`Successfully reconnected to ${nodeId}, which has been closed for ${formatTime(Date.now() - callFactory.lastClosed)}`, { nodeId });
381
+ }
382
+
383
+ if (callFactory.isConnected && newWebSocket.protocol) {
384
+ let decoded = decodeProtocol(newWebSocket.protocol);
385
+ if (decoded) {
386
+ callFactory.receivedInitializeState = {
387
+ supportsLZ4: decoded.flags.serverLZ4,
388
+ };
389
+ callFactory.protocolNegotiated = true;
390
+ if (SocketFunction.logMessages) {
391
+ console.log(green(`Negotiated protocol with ${niceConnectionName}: target=${decoded.target}, clientLZ4=${decoded.flags.clientLZ4}, serverLZ4=${decoded.flags.serverLZ4}`));
392
+ }
393
+ }
394
+ }
371
395
  }
372
396
 
373
397
  const BASE_LENGTH_OFFSET = 324_432_461_592_612;
@@ -450,6 +474,7 @@ export async function createCallFactory(
450
474
  ...data,
451
475
  ]);
452
476
  }
477
+ // IMPORTANT! NEVER allow for reconnection of client ids. A lot of code depends on the fact that clients will reconnect with a new client node id when they disconnect!
453
478
  async function tryToReconnect(): Promise<SenderInterface> {
454
479
  // Don't try to reconnect too often!
455
480
  let timeSinceLastAttempt = Date.now() - lastConnectionAttempt;
@@ -458,12 +483,12 @@ export async function createCallFactory(
458
483
  }
459
484
  lastConnectionAttempt = Date.now();
460
485
 
461
- // Try alternates, and if any work, use them
486
+ // Try alternates, and if any work, use them.
462
487
  try {
463
488
  let alternates = await SocketFunction.GET_ALTERNATE_NODE_IDS(nodeId);
464
489
  if (alternates) {
465
490
  for (let alternateNodeId of alternates) {
466
- let newWebSocket = createWebsocket(alternateNodeId);
491
+ let newWebSocket = createWebsocket(alternateNodeId, proposeProtocols(isNode() ? nodeId : undefined, { lz4: true }));
467
492
  await initializeWebsocket(newWebSocket, true);
468
493
 
469
494
  if (callFactory.isConnected) {
@@ -475,7 +500,7 @@ export async function createCallFactory(
475
500
  console.error("Error getting alternate node IDs", e);
476
501
  }
477
502
 
478
- let newWebSocket = createWebsocket(nodeId);
503
+ let newWebSocket = createWebsocket(nodeId, proposeProtocols(isNode() ? nodeId : undefined, { lz4: true }));
479
504
  await initializeWebsocket(newWebSocket);
480
505
 
481
506
  return newWebSocket;
@@ -556,11 +581,10 @@ export async function createCallFactory(
556
581
  };
557
582
 
558
583
  if (call.isReturn) {
559
- if (!SocketFunction.LEGACY_INITIALIZE && call.seqNum === INITIALIZE_STATE_SEQ_NUM) {
560
- callFactory.receivedInitializeState = call.result as InitializeState;
561
- if (SocketFunction.logMessages) {
562
- console.log(green(`Received initialize state from ${callFactory.realNodeId} (for ${nodeId}) at ${Date.now()}`));
563
- }
584
+ // Tolerate the legacy initialize packet from old peers — silently
585
+ // ignore so it doesn't get logged as an unknown call. Our actual
586
+ // initialize state comes from Sec-WebSocket-Protocol negotiation.
587
+ if (call.seqNum === INITIALIZE_STATE_SEQ_NUM) {
564
588
  return;
565
589
  }
566
590
  let callbackObj = pendingCalls.get(call.seqNum);
@@ -770,25 +794,6 @@ export async function createCallFactory(
770
794
  }
771
795
 
772
796
 
773
- if (!SocketFunction.LEGACY_INITIALIZE) {
774
- let initState: InitializeState = {
775
- supportsLZ4: true,
776
- };
777
- let initReturn: InternalReturnType = {
778
- isReturn: true,
779
- result: initState,
780
- seqNum: INITIALIZE_STATE_SEQ_NUM,
781
- };
782
- if (SocketFunction.logMessages) {
783
- console.log(`Sending initialize state to ${nodeId}`);
784
- }
785
- let data = await SocketFunction.WIRE_SERIALIZER.serialize(initReturn);
786
- await send(data);
787
- if (SocketFunction.logMessages) {
788
- console.log(`Sent initialize state to ${nodeId}`);
789
- }
790
- }
791
-
792
797
  return callFactory;
793
798
  }
794
799
 
@@ -850,6 +855,7 @@ const decompressObj = measureWrap(async function wireCallDecompress(obj: Buffer,
850
855
  });
851
856
 
852
857
  const compressObjLZ4 = measureWrap(async function wireCallCompressLZ4(obj: unknown, stats: CompressionStats): Promise<Buffer[]> {
858
+ const { LZ4 } = await import("./lz4/LZ4");
853
859
  let headerParts: number[];
854
860
  let dataBuffers: Buffer[];
855
861
 
@@ -952,6 +958,8 @@ const compressObjLZ4 = measureWrap(async function wireCallCompressLZ4(obj: unkno
952
958
  });
953
959
 
954
960
  const decompressObjLZ4 = measureWrap(async function wireCallDecompressLZ4(obj: Buffer[], stats: CompressionStats): Promise<unknown> {
961
+
962
+ const { LZ4 } = await import("./lz4/LZ4");
955
963
  stats.compressedSize += obj.reduce((sum, buf) => sum + buf.length, 0);
956
964
 
957
965
  let decompressed: Buffer[] = [];
@@ -18,11 +18,6 @@ export declare function getNodeIdLocation(nodeId: string): {
18
18
  export declare function getNodeIdDomain(nodeId: string): string;
19
19
  export declare function getNodeIdDomainMaybeUndefined(nodeId: string): string | undefined;
20
20
  export declare function registerNodeClient(callFactory: CallFactory): void;
21
- export declare function changeNodeId(config: {
22
- originalNodeId: string;
23
- newNodeId: string;
24
- callFactory: CallFactory;
25
- }): boolean;
26
21
  export declare function getCreateCallFactory(nodeId: string): MaybePromise<CallFactory>;
27
22
  export declare function getCallFactory(nodeId: string): MaybePromise<CallFactory | undefined>;
28
23
  export declare function debugGetAllCallFactories(): CallFactory[];
package/src/nodeCache.ts CHANGED
@@ -77,20 +77,6 @@ export function registerNodeClient(callFactory: CallFactory) {
77
77
  startCleanupLoop();
78
78
  }
79
79
 
80
- export function changeNodeId(config: {
81
- originalNodeId: string;
82
- newNodeId: string;
83
- callFactory: CallFactory;
84
- }) {
85
- if (nodeCache.has(config.newNodeId)) {
86
- console.warn(`Received connection we already have. Likely we and them tried to connect at the same time. Other nodeId: ${config.newNodeId}`);
87
- return false;
88
- }
89
- nodeCache.delete(config.originalNodeId);
90
- nodeCache.set(config.newNodeId, config.callFactory);
91
- return true;
92
- }
93
-
94
80
  export function getCreateCallFactory(nodeId: string): MaybePromise<CallFactory> {
95
81
  let callFactory = nodeCache.get(nodeId);
96
82
  if (callFactory === undefined) {
@@ -0,0 +1,15 @@
1
+ export type ConnectionFlags = {
2
+ clientLZ4: boolean;
3
+ serverLZ4: boolean;
4
+ };
5
+ export type DecodedProtocol = {
6
+ target: string;
7
+ flags: ConnectionFlags;
8
+ };
9
+ export declare function decodeProtocol(hex: string): DecodedProtocol | undefined;
10
+ export declare function proposeProtocols(target: string | undefined, clientCapabilities: {
11
+ lz4: boolean;
12
+ }): string[];
13
+ export declare function chooseProtocol(proposed: string[], serverNodeId: string, serverCapabilities: {
14
+ lz4: boolean;
15
+ }): string | undefined;
@@ -0,0 +1,108 @@
1
+ // Negotiates connection-level flags via Sec-WebSocket-Protocol on the WebSocket
2
+ // upgrade handshake. The client proposes every flag combination it accepts (hex-
3
+ // encoded so the value is a valid HTTP token). The server picks the first value
4
+ // whose target nodeId matches its own, then returns it verbatim. If none match,
5
+ // the server returns no protocol and the handshake fails — which is exactly the
6
+ // rejection semantics we want (indistinguishable from "node not reachable").
7
+
8
+ const PROTOCOL_VERSION = "v1";
9
+
10
+ export type ConnectionFlags = {
11
+ // Client supports receiving LZ4-compressed frames
12
+ clientLZ4: boolean;
13
+ // Server supports receiving LZ4-compressed frames (i.e. server can accept LZ4)
14
+ serverLZ4: boolean;
15
+ };
16
+
17
+ export type DecodedProtocol = {
18
+ target: string;
19
+ flags: ConnectionFlags;
20
+ };
21
+
22
+ function hexEncode(s: string): string {
23
+ return Buffer.from(s, "utf8").toString("hex");
24
+ }
25
+ function hexDecode(s: string): string {
26
+ return Buffer.from(s, "hex").toString("utf8");
27
+ }
28
+
29
+ function encodeFlagBit(b: boolean): string { return b ? "1" : "0"; }
30
+
31
+ // An empty-string target encodes "match any server nodeId" — used for
32
+ // browser clients which don't know our internal nodeId because they're
33
+ // connecting through a Let's Encrypt cert on a public domain.
34
+ const WILDCARD_TARGET = "";
35
+
36
+ function encodeOne(target: string, flags: ConnectionFlags): string {
37
+ let plain = `${PROTOCOL_VERSION}|${target}|clz4=${encodeFlagBit(flags.clientLZ4)}|slz4=${encodeFlagBit(flags.serverLZ4)}`;
38
+ return hexEncode(plain);
39
+ }
40
+
41
+ export function decodeProtocol(hex: string): DecodedProtocol | undefined {
42
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return undefined;
43
+ let plain: string;
44
+ try {
45
+ plain = hexDecode(hex);
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ let parts = plain.split("|");
50
+ if (parts.length < 2) return undefined;
51
+ if (parts[0] !== PROTOCOL_VERSION) return undefined;
52
+ let target = parts[1];
53
+ let flags: ConnectionFlags = { clientLZ4: false, serverLZ4: false };
54
+ for (let i = 2; i < parts.length; i++) {
55
+ let [k, v] = parts[i].split("=");
56
+ if (k === "clz4") flags.clientLZ4 = v === "1";
57
+ else if (k === "slz4") flags.serverLZ4 = v === "1";
58
+ }
59
+ return { target, flags };
60
+ }
61
+
62
+ // Build the list of subprotocol values the client wants to propose. We enumerate
63
+ // every flag combination the client accepts — the server will pick whichever
64
+ // one it can serve (matching its own flag support). Caller is responsible for
65
+ // not proposing flags it can't handle.
66
+ //
67
+ // `target` is the nodeId the client wants to reach. Pass undefined for browser
68
+ // clients (or any context where the client doesn't know the server's nodeId,
69
+ // because it's reaching the server through a public DNS name + Let's Encrypt
70
+ // cert). The server then accepts the connection regardless of its identity,
71
+ // while still negotiating flags.
72
+ export function proposeProtocols(target: string | undefined, clientCapabilities: { lz4: boolean }): string[] {
73
+ let out: string[] = [];
74
+ let clientLZ4Options = clientCapabilities.lz4 ? [true, false] : [false];
75
+ let serverLZ4Options = [true, false];
76
+ let encodedTarget = target ?? WILDCARD_TARGET;
77
+ for (let clientLZ4 of clientLZ4Options) {
78
+ for (let serverLZ4 of serverLZ4Options) {
79
+ out.push(encodeOne(encodedTarget, { clientLZ4, serverLZ4 }));
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // Server-side: given the proposed (hex-encoded) subprotocol values from the
86
+ // client, the server's own nodeId, and the server's capabilities — pick the
87
+ // first value matching this server with a flag combo we can support. Returns
88
+ // the chosen hex string verbatim (so the server echoes it back), or undefined
89
+ // to signal no match → reject the handshake.
90
+ //
91
+ // A proposal with target === WILDCARD_TARGET ("") matches any server (used
92
+ // by browsers that don't know our internal nodeId).
93
+ export function chooseProtocol(
94
+ proposed: string[],
95
+ serverNodeId: string,
96
+ serverCapabilities: { lz4: boolean }
97
+ ): string | undefined {
98
+ for (let hex of proposed) {
99
+ let decoded = decodeProtocol(hex);
100
+ if (!decoded) continue;
101
+ if (decoded.target !== WILDCARD_TARGET && decoded.target !== serverNodeId) continue;
102
+ // Server capability check: if the proposal asks the server to receive
103
+ // LZ4 (slz4=1) but the server doesn't support it, skip.
104
+ if (decoded.flags.serverLZ4 && !serverCapabilities.lz4) continue;
105
+ return hex;
106
+ }
107
+ return undefined;
108
+ }
@@ -3,6 +3,7 @@ import http from "http";
3
3
  import net from "net";
4
4
  import tls from "tls";
5
5
  import { getNodeIdsFromRequest, httpCallHandler } from "./callHTTPHandler";
6
+ import { chooseProtocol, decodeProtocol } from "./protocolNegotiation";
6
7
  import { SocketFunction } from "../SocketFunction";
7
8
  import { getTrustedCertificates, watchTrustedCertificates } from "./certStore";
8
9
  import { createCallFactory } from "./CallFactory";
@@ -63,6 +64,26 @@ export async function startSocketServer(
63
64
 
64
65
  const webSocketServer = new ws.Server({
65
66
  noServer: true,
67
+ // Negotiate connection-level flags via Sec-WebSocket-Protocol. The
68
+ // client proposes hex-encoded values that include the target nodeId;
69
+ // we accept only those whose target matches OUR identity
70
+ // (SocketFunction.mountedNodeId — not the address the client used to
71
+ // reach us). If none match we return false, which rejects the
72
+ // handshake — exactly the semantics we want (indistinguishable from
73
+ // "node not reachable"). If the client sent no Sec-WebSocket-Protocol
74
+ // at all, this callback isn't invoked and the handshake proceeds as
75
+ // a legacy client.
76
+ handleProtocols: (protocols, request) => {
77
+ const ourNodeId = SocketFunction.mountedNodeId;
78
+ const proposed = Array.from(protocols);
79
+ const chosen = chooseProtocol(proposed, ourNodeId, { lz4: true });
80
+ if (!chosen) {
81
+ const proposedDecoded = proposed.map(p => decodeProtocol(p) ?? `<undecodable: ${p}>`);
82
+ console.log(`Rejecting handshake on ${ourNodeId}: none of the ${proposed.length} proposed protocols target us`, { ourNodeId, proposedDecoded });
83
+ return false;
84
+ }
85
+ return chosen;
86
+ },
66
87
  });
67
88
 
68
89
  async function setupHTTPSServer(watchOptions: Watchable<https.ServerOptions>) {
@@ -3,7 +3,8 @@ import tls from "tls";
3
3
  import { SenderInterface } from "./CallFactory";
4
4
  import type * as ws from "ws";
5
5
  export declare function getTLSSocket(webSocket: ws.WebSocket): tls.TLSSocket;
6
+ export type WebsocketFactory = (nodeId: string, proposedProtocols?: string[]) => SenderInterface;
6
7
  /** NOTE: We create a factory, which embeds the key/cert information. Otherwise retries might use
7
8
  * a different key/cert context.
8
9
  */
9
- export declare function createWebsocketFactory(): (nodeId: string) => SenderInterface;
10
+ export declare function createWebsocketFactory(): WebsocketFactory;
@@ -1,52 +1,56 @@
1
- import tls from "tls";
2
- import { isNode } from "./misc";
3
- import { SenderInterface } from "./CallFactory";
4
- import { getTrustedCertificates } from "./certStore";
5
- import { getNodeIdLocation } from "./nodeCache";
6
- import debugbreak from "debugbreak";
7
- import { SocketFunction } from "../SocketFunction";
8
- import type * as ws from "ws";
9
-
10
- export function getTLSSocket(webSocket: ws.WebSocket) {
11
- return (webSocket as any)._socket as tls.TLSSocket;
12
- }
13
-
14
- /** NOTE: We create a factory, which embeds the key/cert information. Otherwise retries might use
15
- * a different key/cert context.
16
- */
17
- export function createWebsocketFactory(): (nodeId: string) => SenderInterface {
18
-
19
- if (!isNode()) {
20
- return (nodeId: string) => {
21
- let location = getNodeIdLocation(nodeId);
22
- if (!location) throw new Error(`Cannot connect to ${nodeId}, no address known`);
23
- let { address, port } = location;
24
-
25
- if (!SocketFunction.silent) {
26
- console.log(`Connecting to ${address}:${port}`);
27
- }
28
- return new WebSocket(`wss://${address}:${port}`);
29
- };
30
- } else {
31
- return (nodeId: string) => {
32
- let location = getNodeIdLocation(nodeId);
33
- if (!location) throw new Error(`Cannot connect to ${nodeId}, no address known`);
34
- let { address, port } = location;
35
-
36
- if (!SocketFunction.silent) {
37
- console.log(`Connecting to ${address}:${port}`);
38
- }
39
- const ws = require("ws") as typeof import("ws");
40
- let webSocket = new ws.WebSocket(`wss://${address}:${port}`, undefined, {
41
- ca: getTrustedCertificates(),
42
- });
43
-
44
- // NOTE: Little setup is done here, because Sometimes websockets are created here,
45
- // and sometimes via incoming connections, We should do most setup in
46
- // CallFactory.ts:initializeWebsocket
47
-
48
- return webSocket;
49
- };
50
- }
51
- }
52
-
1
+ import tls from "tls";
2
+ import { isNode } from "./misc";
3
+ import { SenderInterface } from "./CallFactory";
4
+ import { getTrustedCertificates } from "./certStore";
5
+ import { getNodeIdLocation } from "./nodeCache";
6
+ import debugbreak from "debugbreak";
7
+ import { SocketFunction } from "../SocketFunction";
8
+ import type * as ws from "ws";
9
+
10
+ export function getTLSSocket(webSocket: ws.WebSocket) {
11
+ return (webSocket as any)._socket as tls.TLSSocket;
12
+ }
13
+
14
+ export type WebsocketFactory = (nodeId: string, proposedProtocols?: string[]) => SenderInterface;
15
+
16
+ /** NOTE: We create a factory, which embeds the key/cert information. Otherwise retries might use
17
+ * a different key/cert context.
18
+ */
19
+ export function createWebsocketFactory(): WebsocketFactory {
20
+
21
+ if (!isNode()) {
22
+ return (nodeId: string, proposedProtocols?: string[]) => {
23
+ let location = getNodeIdLocation(nodeId);
24
+ if (!location) throw new Error(`Cannot connect to ${nodeId}, no address known`);
25
+ let { address, port } = location;
26
+
27
+ if (!SocketFunction.silent) {
28
+ console.log(`Connecting to ${address}:${port}`);
29
+ }
30
+ if (proposedProtocols && proposedProtocols.length > 0) {
31
+ return new WebSocket(`wss://${address}:${port}`, proposedProtocols);
32
+ }
33
+ return new WebSocket(`wss://${address}:${port}`);
34
+ };
35
+ } else {
36
+ return (nodeId: string, proposedProtocols?: string[]) => {
37
+ let location = getNodeIdLocation(nodeId);
38
+ if (!location) throw new Error(`Cannot connect to ${nodeId}, no address known`);
39
+ let { address, port } = location;
40
+
41
+ if (!SocketFunction.silent) {
42
+ console.log(`Connecting to ${address}:${port}`);
43
+ }
44
+ const ws = require("ws") as typeof import("ws");
45
+ let webSocket = new ws.WebSocket(`wss://${address}:${port}`, proposedProtocols, {
46
+ ca: getTrustedCertificates(),
47
+ });
48
+
49
+ // NOTE: Little setup is done here, because Sometimes websockets are created here,
50
+ // and sometimes via incoming connections, We should do most setup in
51
+ // CallFactory.ts:initializeWebsocket
52
+
53
+ return webSocket;
54
+ };
55
+ }
56
+ }