socket-function 0.8.35 → 0.8.37

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/SocketFunction.ts CHANGED
@@ -1,10 +1,14 @@
1
- import { SocketExposedInterface, CallContextType, SocketFunctionHook, SocketFunctionClientHook, SocketExposedShape, SocketRegistered, CallerContext, FullCallType } from "./SocketFunctionTypes";
1
+ /// <reference path="./require/RequireController.ts" />
2
+
3
+ import { SocketExposedInterface, SocketFunctionHook, SocketFunctionClientHook, SocketExposedShape, SocketRegistered, CallerContext, FullCallType } from "./SocketFunctionTypes";
2
4
  import { exposeClass, registerClass, registerGlobalClientHook, registerGlobalHook, runClientHooks } from "./src/callManager";
3
5
  import { SocketServerConfig, startSocketServer } from "./src/webSocketServer";
4
- import { getCreateCallFactoryLocation, getNodeId, getNodeIdLocation } from "./src/nodeCache";
6
+ import { getCallFactory, getCreateCallFactory, getNodeId, getNodeIdLocation } from "./src/nodeCache";
5
7
  import { getCallProxy } from "./src/nodeProxy";
6
- import { Args } from "./src/types";
8
+ import { Args, MaybePromise } from "./src/types";
7
9
  import { setDefaultHTTPCall } from "./src/callHTTPHandler";
10
+ import debugbreak from "debugbreak";
11
+ import { lazy } from "./src/caching";
8
12
 
9
13
  module.allowclient = true;
10
14
 
@@ -29,27 +33,46 @@ export class SocketFunction {
29
33
  };
30
34
  public static httpETagCache = false;
31
35
 
36
+ private static onMountCallbacks = new Map<string, (() => MaybePromise<void>)[]>();
37
+ public static exposedClasses = new Set<string>();
38
+
39
+ public static callerContext: CallerContext | undefined;
40
+ public static getCaller(): CallerContext {
41
+ const caller = SocketFunction.callerContext;
42
+ if (!caller) throw new Error(`Tried to access caller when not in the synchronous phase of a function call`);
43
+ return caller;
44
+ }
45
+
46
+ // NOTE: We use callbacks we don't run into issues with cyclic dependencies
47
+ // (ex, using a hook in a controller where the hook also calls the controller).
32
48
  public static register<
33
49
  ClassInstance extends object,
34
- Shape extends SocketExposedShape<SocketExposedInterface, CallContext>,
35
- CallContext extends CallContextType
50
+ Shape extends SocketExposedShape<SocketExposedInterface>,
36
51
  >(
37
52
  classGuid: string,
38
53
  instance: ClassInstance,
39
- shape: Shape,
40
- defaultHooks?: SocketExposedShape[""]
41
- ):
42
- (
43
- SocketRegistered<ExtractShape<ClassInstance, Shape>, CallContext>
44
- ) {
45
-
46
- for (let value of Object.values(shape)) {
47
- if (!value) continue;
48
- value.clientHooks = [...(defaultHooks?.clientHooks || []), ...(value.clientHooks || [])];
49
- value.hooks = [...(defaultHooks?.hooks || []), ...(value.hooks || [])];
50
- value.dataImmutable = defaultHooks?.dataImmutable ?? value.dataImmutable;
54
+ shapeFnc: () => Shape,
55
+ defaultHooksFnc?: () => SocketExposedShape[""] & {
56
+ onMount?: () => MaybePromise<void>;
51
57
  }
52
- registerClass(classGuid, instance as SocketExposedInterface, shape as any as SocketExposedShape);
58
+ ): SocketRegistered<ExtractShape<ClassInstance, Shape>> {
59
+ let getDefaultHooks = defaultHooksFnc && lazy(defaultHooksFnc);
60
+ const getShape = lazy(() => {
61
+ let shape = shapeFnc();
62
+ let defaultHooks = getDefaultHooks?.();
63
+
64
+ for (let value of Object.values(shape)) {
65
+ if (!value) continue;
66
+ value.clientHooks = [...(defaultHooks?.clientHooks || []), ...(value.clientHooks || [])];
67
+ value.hooks = [...(defaultHooks?.hooks || []), ...(value.hooks || [])];
68
+ value.dataImmutable = defaultHooks?.dataImmutable ?? value.dataImmutable;
69
+ }
70
+ return shape as any as SocketExposedShape;
71
+ });
72
+
73
+ setImmediate(() => {
74
+ registerClass(classGuid, instance as SocketExposedInterface, getShape());
75
+ });
53
76
 
54
77
  let nodeProxy = getCallProxy(classGuid, async (call) => {
55
78
  let nodeId = call.nodeId;
@@ -59,9 +82,9 @@ export class SocketFunction {
59
82
  console.log(`START\t\t\t${classGuid}.${functionName}`);
60
83
  }
61
84
  try {
62
- let callFactory = await getCreateCallFactoryLocation(nodeId, SocketFunction.mountedNodeId);
85
+ let callFactory = await getCreateCallFactory(nodeId, SocketFunction.mountedNodeId);
63
86
 
64
- let shapeObj = shape[functionName];
87
+ let shapeObj = getShape()[functionName];
65
88
  if (!shapeObj) {
66
89
  throw new Error(`Function ${functionName} is not in shape`);
67
90
  }
@@ -72,20 +95,6 @@ export class SocketFunction {
72
95
  return hookResult.overrideResult;
73
96
  }
74
97
 
75
- if (hookResult.callTimeout !== undefined) {
76
- let timeout = hookResult.callTimeout;
77
- let time = Date.now();
78
- let timeoutPromise = new Promise((resolve, reject) => {
79
- setTimeout(() => {
80
- reject(new Error(`Call timed out after ${Date.now() - time}ms`));
81
- }, timeout);
82
- });
83
- return await Promise.race([
84
- callFactory.performCall(call),
85
- timeoutPromise,
86
- ]);
87
- }
88
-
89
98
  return await callFactory.performCall(call);
90
99
  } finally {
91
100
  time = Date.now() - time;
@@ -96,14 +105,59 @@ export class SocketFunction {
96
105
  });
97
106
 
98
107
  let output: SocketRegistered = {
99
- context: curSocketContext,
100
108
  nodes: nodeProxy,
101
109
  _classGuid: classGuid,
102
110
  };
103
111
 
112
+ setImmediate(() => {
113
+ let onMount = getDefaultHooks?.().onMount;
114
+ if (onMount) {
115
+ let callbacks = SocketFunction.onMountCallbacks.get(classGuid);
116
+ if (!callbacks) {
117
+ callbacks = [];
118
+ SocketFunction.onMountCallbacks.set(classGuid, callbacks);
119
+ }
120
+ callbacks.push(onMount);
121
+ }
122
+ });
123
+
104
124
  return output as any;
105
125
  }
106
126
 
127
+ public static onNextDisconnect(nodeId: string, callback: () => void) {
128
+ (async () => {
129
+ let factory = await getCallFactory(nodeId);
130
+ if (!factory) {
131
+ callback();
132
+ return;
133
+ }
134
+
135
+ factory.onNextDisconnect(callback);
136
+ })().catch(() => {
137
+ callback();
138
+ });
139
+ }
140
+ public static getLastDisconnectTime(nodeId: string): number | undefined {
141
+ let factory = getCallFactory(nodeId);
142
+ if (!factory) {
143
+ return undefined;
144
+ }
145
+ if (factory instanceof Promise) {
146
+ return undefined;
147
+ }
148
+ return factory.lastClosed;
149
+ }
150
+ public static isNodeConnected(nodeId: string): boolean {
151
+ let factory = getCallFactory(nodeId);
152
+ if (!factory) {
153
+ return false;
154
+ }
155
+ if (factory instanceof Promise) {
156
+ return false;
157
+ }
158
+ return !!factory.isConnected;
159
+ }
160
+
107
161
  /** NOTE: Only works if the nodeIs used is from SocketFunction.connect (we can't convert arbitrary nodeIds into urls,
108
162
  * as we have no way of knowing how to contain a nodeId).
109
163
  * */
@@ -125,15 +179,33 @@ export class SocketFunction {
125
179
  */
126
180
  public static expose(socketRegistered: SocketRegistered) {
127
181
  exposeClass(socketRegistered);
182
+ SocketFunction.exposedClasses.add(socketRegistered._classGuid);
183
+
184
+ if (this.hasMounted) {
185
+ let mountCallbacks = SocketFunction.onMountCallbacks.get(socketRegistered._classGuid);
186
+ for (let onMount of mountCallbacks || []) {
187
+ Promise.resolve(onMount()).catch(e => {
188
+ console.error("Error in onMount callback exposed after mount", e);
189
+ });
190
+ }
191
+ }
128
192
  }
129
193
 
130
194
  public static mountedNodeId: string = "NOTMOUNTED";
131
- public static async mount(config: SocketServerConfig & { nodeId: string }) {
195
+ private static hasMounted = false;
196
+ public static async mount(config: SocketServerConfig) {
132
197
  if (this.mountedNodeId !== "NOTMOUNTED") {
133
198
  throw new Error("SocketFunction already mounted, mounting twice in one thread is not allowed.");
134
199
  }
135
- this.mountedNodeId = config.nodeId;
136
- await startSocketServer(config);
200
+ this.mountedNodeId = await startSocketServer(config);
201
+ this.hasMounted = true;
202
+ for (let classGuid of SocketFunction.exposedClasses) {
203
+ let callbacks = SocketFunction.onMountCallbacks.get(classGuid);
204
+ if (!callbacks) continue;
205
+ for (let callback of callbacks) {
206
+ await callback();
207
+ }
208
+ }
137
209
  return this.mountedNodeId;
138
210
  }
139
211
 
@@ -157,41 +229,29 @@ export class SocketFunction {
157
229
  return getNodeId(location.address, location.port);
158
230
  }
159
231
 
160
- public static addGlobalHook<CallContext extends CallContextType>(hook: SocketFunctionHook<SocketExposedInterface, CallContext>) {
232
+ public static addGlobalHook(hook: SocketFunctionHook<SocketExposedInterface>) {
161
233
  registerGlobalHook(hook as SocketFunctionHook);
162
234
  }
163
- public static addGlobalClientHook<CallContext extends CallContextType>(hook: SocketFunctionClientHook<SocketExposedInterface, CallContext>) {
235
+ public static addGlobalClientHook(hook: SocketFunctionClientHook<SocketExposedInterface>) {
164
236
  registerGlobalClientHook(hook as SocketFunctionClientHook);
165
237
  }
166
238
  }
167
239
 
168
240
 
169
- const curSocketContext: SocketRegistered["context"] = {
170
- curContext: undefined,
171
- caller: undefined,
172
- getCaller() {
173
- const caller = curSocketContext.caller;
174
- if (!caller) throw new Error(`Tried to access caller when not in the synchronous phase of a function call`);
175
- return caller;
176
- }
177
- };
178
241
  let socketContextSeqNum = 1;
179
242
 
180
243
  export function _setSocketContext<T>(
181
- callContext: CallContextType,
182
244
  caller: CallerContext,
183
245
  code: () => T,
184
246
  ) {
185
247
  socketContextSeqNum++;
186
248
  let seqNum = socketContextSeqNum;
187
- curSocketContext.curContext = callContext;
188
- curSocketContext.caller = caller;
249
+ SocketFunction.callerContext = caller;
189
250
  try {
190
251
  return code();
191
252
  } finally {
192
253
  if (seqNum === socketContextSeqNum) {
193
- curSocketContext.curContext = undefined;
194
- curSocketContext.caller = undefined;
254
+ SocketFunction.callerContext = undefined;
195
255
  }
196
256
  }
197
257
  }
@@ -1,5 +1,8 @@
1
+ /// <reference path="./require/RequireController.ts" />
2
+
1
3
  module.allowclient = true;
2
4
 
5
+ import { SocketFunction } from "./SocketFunction";
3
6
  import { getCallObj } from "./src/nodeProxy";
4
7
  import { Args, MaybePromise } from "./src/types";
5
8
 
@@ -19,14 +22,14 @@ export type SocketExposedInterfaceClass = {
19
22
  new(): unknown;
20
23
  prototype: unknown;
21
24
  };
22
- export interface SocketExposedShape<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> {
25
+ export interface SocketExposedShape<ExposedType extends SocketExposedInterface = SocketExposedInterface> {
23
26
  [functionName: string]: {
24
27
  /** Indicates with the same input, we give the same output, forever,
25
28
  * independent of code changes. This only works for data storage.
26
29
  */
27
30
  dataImmutable?: boolean;
28
- hooks?: SocketFunctionHook<ExposedType, CallContext>[];
29
- clientHooks?: SocketFunctionClientHook<ExposedType, CallContext>[];
31
+ hooks?: SocketFunctionHook<ExposedType>[];
32
+ clientHooks?: SocketFunctionClientHook<ExposedType>[];
30
33
  };
31
34
  }
32
35
 
@@ -34,42 +37,32 @@ export interface CallType {
34
37
  classGuid: string;
35
38
  functionName: string;
36
39
  args: unknown[];
37
- // NOTE: When making calls this needs to be set in the client hook.
38
- // To set a timeout on returns, you can set it in the server hook.
39
- reconnectTimeout?: number;
40
40
  }
41
41
  export interface FullCallType extends CallType {
42
42
  nodeId: string;
43
43
  }
44
44
 
45
- export interface SocketFunctionHook<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> {
46
- (config: HookContext<ExposedType, CallContext>): MaybePromise<void>;
45
+ export interface SocketFunctionHook<ExposedType extends SocketExposedInterface = SocketExposedInterface> {
46
+ (config: HookContext<ExposedType>): MaybePromise<void>;
47
+ /** NOTE: This is useful when we need a clientside hook to set up state specifically for our serverside hook. */
48
+ clientHook?: SocketFunctionClientHook<ExposedType>;
47
49
  }
48
- export type HookContext<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> = {
50
+ export type HookContext<ExposedType extends SocketExposedInterface = SocketExposedInterface> = {
49
51
  call: FullCallType;
50
- context: SocketRegistered<ExposedType, CallContext>["context"];
51
52
  // If the result is overriden, we continue evaluating hooks BUT DO NOT perform the final call
52
53
  overrideResult?: unknown;
53
54
  };
54
55
 
55
- export type ClientHookContext<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> = {
56
+ export type ClientHookContext<ExposedType extends SocketExposedInterface = SocketExposedInterface> = {
56
57
  call: FullCallType;
57
- /** If the calls takes longer than this (for ANY reason), we return with an error.
58
- * - Different from reconnectTimeout, which only errors if we lose the connection.
59
- */
60
- callTimeout?: number;
61
58
  // If the result is overriden, we continue evaluating hooks BUT DO NOT perform the final call
62
59
  overrideResult?: unknown;
63
60
  };
64
- export interface SocketFunctionClientHook<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> {
65
- (config: ClientHookContext<ExposedType, CallContext>): MaybePromise<void>;
61
+ export interface SocketFunctionClientHook<ExposedType extends SocketExposedInterface = SocketExposedInterface> {
62
+ (config: ClientHookContext<ExposedType>): MaybePromise<void>;
66
63
  }
67
64
 
68
- export type CallContextType = {
69
- [key: string]: unknown;
70
- };
71
-
72
- export interface SocketRegistered<ExposedType = any, DynamicCallContext extends CallContextType = CallContextType> {
65
+ export interface SocketRegistered<ExposedType = any> {
73
66
  nodes: {
74
67
  // NOTE: Don't pass around nodeId to other nodes, instead pass around NetworkLocation (which they
75
68
  // then turn into a nodeId, which they can then check permissions on themself).
@@ -79,12 +72,6 @@ export interface SocketRegistered<ExposedType = any, DynamicCallContext extends
79
72
  }
80
73
  };
81
74
  };
82
- context: {
83
- // If undefined we are not synchronously in a call
84
- curContext: DynamicCallContext | undefined;
85
- caller: CallerContext | undefined;
86
- getCaller(): CallerContext;
87
- };
88
75
  _classGuid: string;
89
76
  }
90
77
  export type CallerContext = Readonly<CallerContextBase>;
@@ -64,8 +64,8 @@ class HotReloadControllerBase {
64
64
  export const HotReloadController = SocketFunction.register(
65
65
  "HotReloadController-032b2250-3aac-4187-8c95-75412742b8f5",
66
66
  new HotReloadControllerBase(),
67
- {
67
+ () => ({
68
68
  watchFiles: {},
69
69
  fileUpdated: {}
70
- }
70
+ })
71
71
  );
package/package.json CHANGED
@@ -1,14 +1,10 @@
1
1
  {
2
2
  "name": "socket-function",
3
- "version": "0.8.35",
3
+ "version": "0.8.37",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
7
7
  "dependencies": {
8
- "@types/cookie": "^0.5.1",
9
- "@types/node": "^18.0.0",
10
- "@types/node-forge": "^1.3.1",
11
- "@types/ws": "^8.5.3",
12
8
  "cookie": "^0.5.0",
13
9
  "mobx": "^6.6.2",
14
10
  "node-forge": "https://github.com/sliftist/forge#name",
@@ -21,7 +17,10 @@
21
17
  "type": "yarn tsc --noEmit"
22
18
  },
23
19
  "devDependencies": {
20
+ "@types/cookie": "^0.5.1",
21
+ "@types/node-forge": "^1.3.1",
22
+ "@types/ws": "^8.5.3",
24
23
  "debugbreak": "^0.6.5",
25
- "typedev": "^0.1.0"
24
+ "typedev": "^0.1.1"
26
25
  }
27
26
  }
@@ -1,3 +1,4 @@
1
+ /// <reference path=".//RequireController.ts" />
1
2
  import debugbreak from "debugbreak";
2
3
  import { compileTransformBefore } from "typenode";
3
4
 
@@ -1,3 +1,4 @@
1
+ /// <reference path="../../typenode/index.d.ts" />
1
2
  import debugbreak from "debugbreak";
2
3
  import fs from "fs";
3
4
  import { SocketFunction } from "../SocketFunction";
@@ -9,7 +10,7 @@ module.allowclient = true;
9
10
  declare global {
10
11
  namespace NodeJS {
11
12
  interface Module {
12
- /** Indiciates the module is allowed clientside. */
13
+ /** Indicates the module is allowed clientside. */
13
14
  allowclient?: boolean;
14
15
 
15
16
  /** Indicates the module is definitely not allowed clientside */
@@ -237,10 +238,10 @@ export function setRequireBootRequire(path: string) {
237
238
  export const RequireController = SocketFunction.register(
238
239
  "RequireController-e2f811f3-14b8-4759-b0d6-73f14516cf1d",
239
240
  baseController,
240
- {
241
+ () => ({
241
242
  getModules: {},
242
243
  requireHTML: {},
243
244
  bufferJS: {},
244
245
  requireJS: {},
245
- }
246
+ })
246
247
  );
@@ -1,3 +1,4 @@
1
+ /// <reference path="./RequireController.ts" />
1
2
  module.allowclient = true;
2
3
 
3
4
  /**
@@ -2,13 +2,15 @@ import { CallerContext, CallerContextBase, CallType, FullCallType } from "../Soc
2
2
  import * as ws from "ws";
3
3
  import { performLocalCall } from "./callManager";
4
4
  import { convertErrorStackToError, formatNumberSuffixed, isNode } from "./misc";
5
- import { createWebsocketFactory, getTLSSocket } from "./nodeAuthentication";
5
+ import { createWebsocketFactory, getTLSSocket } from "./websocketFactory";
6
6
  import { SocketFunction } from "../SocketFunction";
7
7
  import { gzip } from "zlib";
8
8
  import * as tls from "tls";
9
9
  import { getClientNodeId, getNodeIdLocation, registerNodeClient } from "./nodeCache";
10
+ import debugbreak from "debugbreak";
11
+ import { lazy } from "./caching";
10
12
 
11
- const retryInterval = 2000;
13
+ const MIN_RETRY_DELAY = 1000;
12
14
 
13
15
  type InternalCallType = FullCallType & {
14
16
  seqNum: number;
@@ -28,10 +30,13 @@ type InternalReturnType = {
28
30
 
29
31
  export interface CallFactory {
30
32
  nodeId: string;
33
+ lastClosed: number;
34
+ closedForever?: boolean;
35
+ isConnected?: boolean;
31
36
  // NOTE: May or may not have reconnection or retry logic inside of performCall.
32
37
  // Trigger performLocalCall on the other side of the connection
33
38
  performCall(call: CallType): Promise<unknown>;
34
- closedForever: boolean;
39
+ onNextDisconnect(callback: () => void): void;
35
40
  }
36
41
 
37
42
  export interface SenderInterface {
@@ -45,6 +50,8 @@ export interface SenderInterface {
45
50
  addEventListener(event: "close", listener: () => void): void;
46
51
  addEventListener(event: "error", listener: (err: { message: string }) => void): void;
47
52
  addEventListener(event: "message", listener: (data: ws.RawData | ws.MessageEvent | string) => void): void;
53
+
54
+ readyState: number;
48
55
  }
49
56
 
50
57
  export async function createCallFactory(
@@ -55,19 +62,15 @@ export async function createCallFactory(
55
62
  let niceConnectionName = nodeId;
56
63
 
57
64
  const createWebsocket = createWebsocketFactory();
65
+ const registerOnce = lazy(() => registerNodeClient(callFactory));
58
66
 
59
- let retriesEnabled = !!getNodeIdLocation(nodeId);
67
+ const canReconnect = !!getNodeIdLocation(nodeId);
60
68
 
61
69
  let lastReceivedSeqNum = 0;
62
70
 
63
- let reconnectingPromise: Promise<void> | undefined;
64
- let reconnectAttempts = 0;
65
-
66
-
67
71
  let pendingCalls: Map<number, {
68
72
  data: Buffer;
69
73
  call: InternalCallType;
70
- reconnectTimeout: number | undefined;
71
74
  callback: (resultJSON: InternalReturnType) => void;
72
75
  }> = new Map();
73
76
  // NOTE: It is important to make this as random as possible, to prevent
@@ -75,19 +78,23 @@ export async function createCallFactory(
75
78
  // in return calls.
76
79
  let nextSeqNum = Math.random();
77
80
 
81
+ let lastConnectionAttempt = 0;
82
+
78
83
  let callerContext: CallerContextBase = {
79
84
  nodeId,
80
85
  localNodeId
81
86
  };
82
87
 
88
+ let disconnectCallbacks: (() => void)[] = [];
89
+ function onNextDisconnect(callback: () => void): void {
90
+ disconnectCallbacks.push(callback);
91
+ }
92
+
83
93
  let callFactory: CallFactory = {
84
94
  nodeId,
85
- closedForever: false,
95
+ lastClosed: 0,
96
+ onNextDisconnect,
86
97
  async performCall(call: CallType) {
87
- if (callFactory.closedForever) {
88
- throw new Error(`Connection lost to ${niceConnectionName}`);
89
- }
90
-
91
98
  let seqNum = nextSeqNum++;
92
99
  let fullCall: InternalCallType = {
93
100
  nodeId,
@@ -111,144 +118,105 @@ export async function createCallFactory(
111
118
  resolve(result.result);
112
119
  }
113
120
  };
114
- pendingCalls.set(seqNum, { callback, data, call: fullCall, reconnectTimeout: call.reconnectTimeout });
121
+ pendingCalls.set(seqNum, { callback, data, call: fullCall });
115
122
  });
116
123
 
117
- await sendWithRetry(call.reconnectTimeout, data);
124
+ await send(data);
118
125
 
119
126
  return await resultPromise;
120
127
  }
121
128
  };
122
129
 
123
- let webSocket!: SenderInterface;
124
- if (!webSocketBase) {
125
- await tryToReconnect();
126
- } else {
127
- webSocket = webSocketBase;
128
- setupWebsocket(webSocketBase);
130
+ let webSocketPromise: Promise<SenderInterface> | undefined;
131
+ if (webSocketBase) {
132
+ webSocketPromise = Promise.resolve(webSocketBase);
133
+ await initializeWebsocket(webSocketBase);
129
134
  }
130
135
 
131
- niceConnectionName = `${niceConnectionName} (${callerContext.nodeId})`;
132
-
133
- async function sendWithRetry(reconnectTimeout: number | undefined, data: Buffer) {
134
- if (!retriesEnabled) {
135
- webSocket.send(data);
136
- return;
137
- }
136
+ async function initializeWebsocket(newWebSocket: SenderInterface) {
137
+ registerOnce();
138
138
 
139
- while (true) {
140
- if (reconnectingPromise) {
141
- if (reconnectTimeout) {
142
- await Promise.race([
143
- reconnectingPromise,
144
- new Promise<SenderInterface>(resolve =>
145
- setTimeout(() => {
146
- retriesEnabled = false;
147
- resolve(webSocket);
148
- }, reconnectTimeout)
149
- )
150
- ]);
151
- } else {
152
- await reconnectingPromise;
153
- }
139
+ function onClose(error: string) {
140
+ callFactory.lastClosed = Date.now();
141
+ webSocketPromise = undefined;
142
+ if (!canReconnect) {
143
+ callFactory.closedForever = true;
154
144
  }
155
-
156
- if (!retriesEnabled) {
157
- webSocket.send(data);
158
- break;
145
+ for (let [key, call] of pendingCalls) {
146
+ pendingCalls.delete(key);
147
+ call.callback({
148
+ isReturn: true,
149
+ result: undefined,
150
+ error: error,
151
+ seqNum: call.call.seqNum,
152
+ resultSize: 0,
153
+ compressed: false,
154
+ });
159
155
  }
160
156
 
161
- try {
162
- webSocket.send(data);
163
- break;
164
- } catch (e) {
165
- // Ignore errors, as we will catch them synchronously in the next loop.
166
- void (tryToReconnect());
157
+ let callbacks = disconnectCallbacks;
158
+ disconnectCallbacks = [];
159
+ for (let callback of callbacks) {
160
+ try {
161
+ callback();
162
+ } catch { }
167
163
  }
168
164
  }
169
- }
170
- function tryToReconnect(): Promise<void> {
171
- if (reconnectingPromise) return reconnectingPromise;
172
- return reconnectingPromise = (async () => {
173
- while (true) {
174
- if (!retriesEnabled) {
175
- callFactory.closedForever = true;
176
- console.log(`Cannot reconnect to ${niceConnectionName}, aborting pendingCalls: ${pendingCalls.size}`);
177
- for (let call of pendingCalls.values()) {
178
- call.callback({
179
- isReturn: true,
180
- result: undefined,
181
- error: `Connection lost to ${niceConnectionName}`,
182
- seqNum: call.call.seqNum,
183
- resultSize: 0,
184
- compressed: false,
185
- });
186
- }
187
- return;
188
- }
189
-
190
- let newWebSocket = createWebsocket(nodeId);
191
-
192
- let connectError = await new Promise<string | undefined>(resolve => {
193
- newWebSocket.addEventListener("open", () => {
194
- resolve(undefined);
195
- });
196
- newWebSocket.addEventListener("close", () => {
197
- resolve("Connection closed for non-error reason?");
198
- });
199
- newWebSocket.addEventListener("error", e => {
200
- resolve(String(e.message));
201
- });
202
- });
203
165
 
204
- setupWebsocket(newWebSocket);
166
+ newWebSocket.addEventListener("error", e => {
167
+ // NOTE: No more logging, as we throw, so the caller should be logging the
168
+ // error (or swallowing it, if that is what it wants to do).
169
+ //console.log(`Websocket error for ${niceConnectionName}`, e.message);
170
+ onClose(`Connection error for ${niceConnectionName}: ${e.message}`);
171
+ });
205
172
 
206
- if (!connectError) {
207
- console.log(`Reconnected to ${niceConnectionName}`);
173
+ newWebSocket.addEventListener("close", async () => {
174
+ //console.log(`Websocket closed ${niceConnectionName}`);
175
+ onClose(`Connection closed to ${niceConnectionName}`);
176
+ });
208
177
 
209
- // I'm not sure if we should clear reconnectAttempts? Maybe if we eventually have a max reconnectAttempts?
210
- //reconnectAttempts = 0;
211
- reconnectingPromise = undefined;
178
+ newWebSocket.addEventListener("message", onMessage);
212
179
 
213
- webSocket = newWebSocket;
214
180
 
215
- for (let call of pendingCalls.values()) {
216
- sendWithRetry(call.reconnectTimeout, call.data).catch(e => {
217
- call.callback({
218
- isReturn: true,
219
- result: undefined,
220
- error: String(e),
221
- seqNum: call.call.seqNum,
222
- resultSize: 0,
223
- compressed: false,
224
- });
225
- });
226
- }
227
- return;
228
- }
181
+ if (newWebSocket.readyState === 0 /* CONNECTING */) {
182
+ await new Promise<void>(resolve => {
183
+ newWebSocket.addEventListener("open", () => {
184
+ console.log(`Connection established to ${niceConnectionName}`);
185
+ callFactory.isConnected = true;
186
+ resolve();
187
+ });
188
+ newWebSocket.addEventListener("close", () => resolve());
189
+ newWebSocket.addEventListener("error", () => resolve());
190
+ });
191
+ } else if (newWebSocket.readyState !== 1 /* OPEN */) {
192
+ onClose(`Websocket received in closed state`);
193
+ callFactory.isConnected = true;
194
+ }
195
+ }
229
196
 
230
- reconnectAttempts++;
231
- console.error(`Connection retry to ${niceConnectionName} failed (attempt ${reconnectAttempts}), retrying in ${retryInterval}ms, error: ${JSON.stringify(connectError)}`);
232
- await new Promise(resolve => setTimeout(resolve, retryInterval));
197
+ async function send(data: Buffer) {
198
+ if (!webSocketPromise) {
199
+ if (canReconnect) {
200
+ webSocketPromise = tryToReconnect();
201
+ } else {
202
+ throw new Error(`Cannot send data to ${niceConnectionName} as the connection has closed`);
233
203
  }
234
- })();
204
+ }
205
+ let webSocket = await webSocketPromise;
206
+ webSocket.send(data);
235
207
  }
208
+ async function tryToReconnect(): Promise<SenderInterface> {
209
+ // Don't try to reconnect too often!
210
+ let timeSinceLastAttempt = Date.now() - lastConnectionAttempt;
211
+ if (timeSinceLastAttempt < MIN_RETRY_DELAY) {
212
+ await new Promise(r => setTimeout(r, MIN_RETRY_DELAY - timeSinceLastAttempt));
213
+ }
214
+ lastConnectionAttempt = Date.now();
236
215
 
237
- function setupWebsocket(webSocket: SenderInterface) {
238
- registerNodeClient(callFactory);
239
-
240
- webSocket.addEventListener("error", e => {
241
- console.log(`Websocket error for ${niceConnectionName}`, e);
242
- });
243
-
244
- webSocket.addEventListener("close", async () => {
245
- console.log(`Websocket closed ${niceConnectionName}`);
246
- if (retriesEnabled) {
247
- await tryToReconnect();
248
- }
249
- });
216
+ let newWebSocket = createWebsocket(nodeId);
217
+ await initializeWebsocket(newWebSocket);
250
218
 
251
- webSocket.addEventListener("message", onMessage);
219
+ return newWebSocket;
252
220
  }
253
221
 
254
222
 
@@ -328,7 +296,7 @@ export async function createCallFactory(
328
296
  } else {
329
297
  result = Buffer.from(JSON.stringify(response));
330
298
  }
331
- await sendWithRetry(call.reconnectTimeout, result);
299
+ await send(result);
332
300
  }
333
301
  return;
334
302
  }
package/src/caching.ts CHANGED
@@ -33,6 +33,7 @@ export function cacheEmptyArray<T>(array: T[]): T[] {
33
33
 
34
34
  export function cache<Output, Key>(getValue: (key: Key) => Output): {
35
35
  (key: Key): Output;
36
+ clear(key: Key): void;
36
37
  } {
37
38
  let startingCalculating = new Set<Key>();
38
39
  let values = new Map<Key, Output>();
@@ -51,6 +52,9 @@ export function cache<Output, Key>(getValue: (key: Key) => Output): {
51
52
  values.set(key, value);
52
53
  return value;
53
54
  }
55
+ cache.clear = (key: Key) => {
56
+ values.delete(key);
57
+ };
54
58
  return cache;
55
59
  }
56
60
 
@@ -1,5 +1,7 @@
1
- import { CallContextType, CallerContext, CallType, ClientHookContext, FullCallType, HookContext, SocketExposedInterface, SocketExposedInterfaceClass, SocketExposedShape, SocketFunctionClientHook, SocketFunctionHook, SocketRegistered } from "../SocketFunctionTypes";
1
+ import { CallerContext, CallType, ClientHookContext, FullCallType, HookContext, SocketExposedInterface, SocketExposedInterfaceClass, SocketExposedShape, SocketFunctionClientHook, SocketFunctionHook, SocketRegistered } from "../SocketFunctionTypes";
2
2
  import { _setSocketContext } from "../SocketFunction";
3
+ import { isNode } from "./misc";
4
+ import debugbreak from "debugbreak";
3
5
 
4
6
  let classes: {
5
7
  [classGuid: string]: {
@@ -39,14 +41,13 @@ export async function performLocalCall(
39
41
  throw new Error(`Function ${call.functionName} does not exist`);
40
42
  }
41
43
 
42
- let curContext: CallContextType = {};
43
- let serverContext = await runServerHooks(call, { caller, curContext, getCaller: () => caller }, functionShape);
44
+ let serverContext = await runServerHooks(call, caller, functionShape);
44
45
  if ("overrideResult" in serverContext) {
45
46
  return serverContext.overrideResult;
46
47
  }
47
48
 
48
49
  // NOTE: We purposely don't await inside _setSocketContext, so the context is reset synchronously
49
- let result = _setSocketContext(curContext, caller, () => {
50
+ let result = _setSocketContext(caller, () => {
50
51
  return controller[call.functionName](...call.args);
51
52
  });
52
53
 
@@ -96,7 +97,17 @@ export async function runClientHooks(
96
97
  hooks: SocketExposedShape[""],
97
98
  ): Promise<ClientHookContext> {
98
99
  let context: ClientHookContext = { call: callType };
99
- for (let hook of globalClientHooks.concat(hooks.clientHooks || [])) {
100
+
101
+ let clientHooks = (
102
+ globalClientHooks
103
+ .concat(hooks.clientHooks || [])
104
+ );
105
+ for (let otherClientHook of globalHooks.concat(hooks.hooks || []).map(x => x.clientHook)) {
106
+ if (otherClientHook) {
107
+ clientHooks.push(otherClientHook);
108
+ }
109
+ }
110
+ for (let hook of clientHooks) {
100
111
  await hook(context);
101
112
  if ("overrideResult" in context) {
102
113
  break;
@@ -107,12 +118,12 @@ export async function runClientHooks(
107
118
 
108
119
  async function runServerHooks(
109
120
  callType: FullCallType,
110
- context: SocketRegistered["context"],
121
+ caller: CallerContext,
111
122
  hooks: SocketExposedShape[""],
112
123
  ): Promise<HookContext> {
113
- let hookContext: HookContext = { call: callType, context };
124
+ let hookContext: HookContext = { call: callType };
114
125
  for (let hook of globalHooks.concat(hooks.hooks || [])) {
115
- await hook(hookContext);
126
+ await _setSocketContext(caller, () => hook(hookContext));
116
127
  if ("overrideResult" in hookContext) {
117
128
  break;
118
129
  }
package/src/misc.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import * as crypto from "crypto";
2
+ import { MaybePromise } from "./types";
3
+
4
+ export type Watchable<T> = (callback: (value: T) => void) => MaybePromise<void>;
2
5
 
3
6
  export function convertErrorStackToError(error: string): Error {
4
7
  let errorObj = new Error();
package/src/nodeCache.ts CHANGED
@@ -19,11 +19,21 @@ export function getNodeId(domain: string, port: number): string {
19
19
 
20
20
  /** A nodeId not available for reconnecting. */
21
21
  export function getClientNodeId(address: string): string {
22
- return `client_${address}:${Date.now()}:${Math.random()}`;
22
+ return `client:${address}:${Date.now()}:${Math.random()}`;
23
+ }
24
+ /** Will always be available, even if getNodeIdLocation is not (as we don't always have the port,
25
+ * but we should always have an address).
26
+ * - Rarely used, as for logging you can just log the nodeId. ALSO, it isn't sufficient to reconnect, as the port is also needed!
27
+ * */
28
+ export function getNodeIdIP(nodeId: string): string {
29
+ if (nodeId.startsWith("client:")) {
30
+ return nodeId.split(":")[1];
31
+ }
32
+ return getNodeIdLocation(nodeId)!.address;
23
33
  }
24
34
 
25
35
  export function getNodeIdLocation(nodeId: string): { address: string, port: number; } | undefined {
26
- if (nodeId.startsWith("client_")) {
36
+ if (nodeId.startsWith("client:")) {
27
37
  return undefined;
28
38
  }
29
39
  let [address, port] = nodeId.split(":");
@@ -49,7 +59,7 @@ export function registerNodeClient(callFactory: CallFactory) {
49
59
  startCleanupLoop();
50
60
  }
51
61
 
52
- export function getCreateCallFactoryLocation(nodeId: string, mountedNodeId: string): MaybePromise<CallFactory> {
62
+ export function getCreateCallFactory(nodeId: string, mountedNodeId: string): MaybePromise<CallFactory> {
53
63
  let callFactory = nodeCache.get(nodeId);
54
64
  if (callFactory === undefined) {
55
65
  callFactory = createCallFactory(undefined, nodeId, mountedNodeId);
@@ -57,6 +67,9 @@ export function getCreateCallFactoryLocation(nodeId: string, mountedNodeId: stri
57
67
  }
58
68
  return callFactory;
59
69
  }
70
+ export function getCallFactory(nodeId: string): MaybePromise<CallFactory | undefined> {
71
+ return nodeCache.get(nodeId);
72
+ }
60
73
 
61
74
  const startCleanupLoop = lazy(() => {
62
75
  (async () => {
@@ -9,15 +9,18 @@ import { getTrustedCertificates, watchTrustedCertificates } from "./certStore";
9
9
  import { createCallFactory } from "./CallFactory";
10
10
  import { parseSNIExtension, parseTLSHello, SNIType } from "./tlsParsing";
11
11
  import debugbreak from "debugbreak";
12
+ import { getNodeId } from "./nodeCache";
13
+ import crypto from "crypto";
14
+ import { Watchable } from "./misc";
12
15
 
13
16
  export type SocketServerConfig = (
14
17
  https.ServerOptions & {
15
- nodeId?: string;
16
-
17
18
  key: string | Buffer;
18
19
  cert: string | Buffer;
19
20
 
20
21
  port: number;
22
+ /** You can also set `port: 0` if you don't care what port you want at all. */
23
+ useAvailablePortIfPortInUse?: boolean;
21
24
 
22
25
  // public sets ip to "0.0.0.0", otherwise it defaults to "127.0.0.1", which
23
26
  // causes the server to only accept local connections.
@@ -26,24 +29,38 @@ export type SocketServerConfig = (
26
29
 
27
30
  /** If the SNI matches this domain, we use a different key/cert. */
28
31
  SNICerts?: {
29
- [domain: string]: https.ServerOptions;
32
+ [domain: string]: Watchable<https.ServerOptions>;
30
33
  };
31
34
  }
32
35
  );
33
36
 
34
37
  export async function startSocketServer(
35
38
  config: SocketServerConfig
36
- ): Promise<void> {
39
+ ): Promise<string> {
37
40
 
38
41
  const webSocketServer = new ws.Server({
39
42
  noServer: true,
40
43
  });
41
44
 
42
- function setupHTTPSServer(options: https.ServerOptions) {
43
- let httpsServer = https.createServer(options);
45
+ async function setupHTTPSServer(watchOptions: Watchable<https.ServerOptions>) {
46
+ let httpsServerLast: https.Server | undefined;
47
+ let onHttpServer: (server: https.Server) => void;
48
+ let httpServerPromise = new Promise<https.Server>(r => onHttpServer = r);
49
+ let lastOptions!: https.ServerOptions;
50
+ await watchOptions(value => {
51
+ lastOptions = { ...value, ca: getTrustedCertificates() };
52
+ if (!httpsServerLast) {
53
+ httpsServerLast = https.createServer(lastOptions);
54
+ } else {
55
+ httpsServerLast.setSecureContext(lastOptions);
56
+ }
57
+ onHttpServer(httpsServerLast);
58
+ });
59
+ let httpsServer = await httpServerPromise;
60
+
44
61
  watchTrustedCertificates(() => {
45
- options.ca = getTrustedCertificates();
46
- httpsServer.setSecureContext(options);
62
+ lastOptions.ca = getTrustedCertificates();
63
+ httpsServer.setSecureContext(lastOptions);
47
64
  });
48
65
 
49
66
  httpsServer.on("connection", socket => {
@@ -97,11 +114,17 @@ export async function startSocketServer(
97
114
  let options: https.ServerOptions = {
98
115
  ...config,
99
116
  };
117
+ if (!config.cert) {
118
+ throw new Error("No cert specified");
119
+ }
120
+ if (!config.key) {
121
+ throw new Error("No key specified");
122
+ }
100
123
 
101
- const mainHTTPSServer = setupHTTPSServer(options);
124
+ const mainHTTPSServer = await setupHTTPSServer(callback => callback(options));
102
125
  let sniServers = new Map<string, https.Server>();
103
126
  for (let [domain, obj] of Object.entries(config.SNICerts || {})) {
104
- sniServers.set(domain, setupHTTPSServer(obj));
127
+ sniServers.set(domain, await setupHTTPSServer(obj));
105
128
  }
106
129
 
107
130
  let httpServer = http.createServer({}, async function (req, res) {
@@ -130,6 +153,7 @@ export async function startSocketServer(
130
153
  } else {
131
154
  let data = parseTLSHello(buffer);
132
155
  let sni = data.extensions.filter(x => x.type === SNIType).flatMap(x => parseSNIExtension(x.data))[0];
156
+ console.log(`Received TCP connection with SNI ${JSON.stringify(sni)}`);
133
157
  server = sniServers.get(sni) || mainHTTPSServer;
134
158
  }
135
159
 
@@ -158,12 +182,43 @@ export async function startSocketServer(
158
182
  host = "0.0.0.0";
159
183
  }
160
184
 
161
- console.log(`Trying to listening on ${host}:${config.port}`);
162
- realServer.listen(config.port, host);
185
+ let port = config.port;
186
+ if (config.useAvailablePortIfPortInUse && port) {
187
+ async function isPortInUse(port: number): Promise<boolean> {
188
+ return new Promise<boolean>((resolve, reject) => {
189
+ let server = net.createServer();
190
+ server.listen(port, host)
191
+ .on("listening", function () {
192
+ server.close();
193
+ resolve(false);
194
+ }).on("close", function () {
195
+ resolve(true);
196
+ }).on("error", function (e) {
197
+ resolve(true);
198
+ });
199
+ });
200
+ }
201
+ if (await isPortInUse(port)) {
202
+ port = 0;
203
+ }
204
+ }
205
+
206
+ console.log(`Trying to listening on ${host}:${port}`);
207
+ realServer.listen(port, host);
163
208
 
164
209
  await listenPromise;
165
210
 
166
- let port = (realServer.address() as net.AddressInfo).port;
211
+ port = (realServer.address() as net.AddressInfo).port;
212
+ let nodeId = getNodeId(getCommonName(config.cert), port);
213
+ console.log(`Started Listening on ${nodeId}`);
214
+
215
+ return nodeId;
216
+ }
167
217
 
168
- console.log(`Started Listening on ${config.nodeId || host}:${port}`);
218
+ function getCommonName(cert: Buffer | string) {
219
+ let subject = new crypto.X509Certificate(cert).subject;
220
+ let subjectKVPs = new Map(subject.split(",").map(x => x.trim().split("=")).map(x => [x[0], x.slice(1).join("=")]));
221
+ let commonName = subjectKVPs.get("CN");
222
+ if (!commonName) throw new Error(`No common name in subject: ${subject}`);
223
+ return commonName;
169
224
  }
@@ -4,6 +4,7 @@ import { isNode } from "./misc";
4
4
  import { SenderInterface } from "./CallFactory";
5
5
  import { getTrustedCertificates } from "./certStore";
6
6
  import { getNodeIdLocation } from "./nodeCache";
7
+ import debugbreak from "debugbreak";
7
8
 
8
9
 
9
10
  export function getTLSSocket(webSocket: ws.WebSocket) {
package/test/client.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /// <reference path="../require/RequireController.ts" />
2
+
1
3
  // https://letx.ca:2542/?classGuid=RequireController-e2f811f3-14b8-4759-b0d6-73f14516cf1d&functionName=requireHTML&args=[%22./test/test%22]
2
4
 
3
5
  //import typescript from "typescript";
@@ -24,8 +26,6 @@ void main();
24
26
  async function main() {
25
27
  if (isNode()) return;
26
28
 
27
- SocketFunction.rejectUnauthorized = false;
28
-
29
29
  SocketFunction.expose(Test);
30
30
 
31
31
  console.log("cool");
package/test/shared.ts CHANGED
@@ -41,7 +41,7 @@ class TestBase {
41
41
  export const Test = SocketFunction.register(
42
42
  "80d9f328-72df-4baa-8be8-019c1003d4a2",
43
43
  new TestBase(),
44
- {
44
+ () => ({
45
45
  add: {
46
46
  // hooks: [
47
47
  // async (config) => {
@@ -49,17 +49,11 @@ export const Test = SocketFunction.register(
49
49
  // }
50
50
  // ]
51
51
  },
52
- callMe: {
53
- clientHooks: [
54
- async (config) => {
55
- config.call.reconnectTimeout = 2000;
56
- }
57
- ]
58
- },
52
+ callMe: {},
59
53
  callBack: {
60
54
 
61
55
  },
62
56
  //fncNotAsync: {},
63
57
  //notAFnc: {},
64
- }
58
+ })
65
59
  );