socket-function 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.js ADDED
@@ -0,0 +1,51 @@
1
+ module.exports = {
2
+ "root": true,
3
+ "env": {
4
+ "browser": true,
5
+ "es6": true,
6
+ "node": true
7
+ },
8
+ "extends": [],
9
+ "globals": {
10
+ "Atomics": "readonly",
11
+ "SharedArrayBuffer": "readonly"
12
+ },
13
+ "overrides": [
14
+ {
15
+ "files": [
16
+ "**/*.ts",
17
+ "**/*.tsx"
18
+ ]
19
+ }
20
+ ],
21
+ "parser": "@typescript-eslint/parser",
22
+ "parserOptions": {
23
+ "ecmaVersion": 2018,
24
+ "sourceType": "module",
25
+ "project": "./tsconfig.json",
26
+ "tsconfigRootDir": __dirname,
27
+ },
28
+ "plugins": [
29
+ "@typescript-eslint"
30
+ ],
31
+ "rules": {
32
+ "@typescript-eslint/unbound-method": [
33
+ "error",
34
+ {
35
+ "ignoreStatic": true
36
+ }
37
+ ],
38
+ "@typescript-eslint/no-floating-promises": [
39
+ "error"
40
+ ],
41
+ "quotes": [
42
+ "error",
43
+ "double",
44
+ {
45
+ "allowTemplateLiterals": true
46
+ }
47
+ ],
48
+ "eqeqeq": "error",
49
+ "semi": "error"
50
+ }
51
+ };
@@ -0,0 +1,26 @@
1
+ {
2
+ // Quentin: DON'T commit { debug.node.autoAttach: "on" }. This setting (while useful), has serious performance ramifications.
3
+ // "debug.node.autoAttach": "on",
4
+ "files.watcherExclude": {
5
+ "**/.git/objects/**": true,
6
+ "**/.git/subtree-cache/**": true,
7
+ "**/node_modules/*/**": true,
8
+ "**/.hg/store/**": true
9
+ },
10
+ "[typescript]": {
11
+ "editor.formatOnSave": true,
12
+ },
13
+ "[typescriptreact]": {
14
+ "editor.formatOnSave": true,
15
+ },
16
+ "[javascript]": {
17
+ "editor.formatOnSave": true,
18
+ },
19
+ "typescript.tsdk": "node_modules/typescript/lib",
20
+ "search.exclude": {
21
+ "**/node_modules": true,
22
+ "**/bower_components": true,
23
+ "**/*.code-search": true,
24
+ "**.cache": true
25
+ }
26
+ }
@@ -0,0 +1,323 @@
1
+ import { CallerContext, CallType, NetworkLocation } from "./SocketFunctionTypes";
2
+ import type * as ws from "ws";
3
+ import type * as net from "net";
4
+ import { performLocalCall } from "./callManager";
5
+ import { convertErrorStackToError } from "./misc";
6
+ import { createWebsocket, getNodeId, getTLSSocket } from "./nodeAuthentication";
7
+ import debugbreak from "debugbreak";
8
+
9
+ const retryInterval = 2000;
10
+
11
+ type InternalCallType = CallType & {
12
+ seqNum: number;
13
+ isReturn: false;
14
+ }
15
+
16
+ type InternalReturnType = {
17
+ isReturn: true;
18
+ result: unknown;
19
+ error?: string;
20
+ seqNum: number;
21
+ };
22
+
23
+
24
+ export interface CallFactory {
25
+ nodeId: string;
26
+ location: NetworkLocation;
27
+ // NOTE: May or may not have reconnection or retry logic inside of performCall.
28
+ // Trigger performLocalCall on the other side of the connection
29
+ performCall(call: CallType): Promise<unknown>;
30
+ }
31
+
32
+
33
+ export async function callFactoryFromLocation(
34
+ location: NetworkLocation
35
+ ): Promise<CallFactory> {
36
+ if (location.localPort !== 0) {
37
+ throw new Error(`Expected localPort to be 0, but it was ${location.localPort}`);
38
+ }
39
+
40
+ let listeningPort = location.listeningPorts[0];
41
+ if (typeof listeningPort !== "number") {
42
+ throw new Error(`Expected listeningPorts to be provided, but it was empty`);
43
+ }
44
+
45
+ return await createCallFactory(undefined, location);
46
+ }
47
+
48
+ export async function callFactoryFromWS(
49
+ webSocket: ws.WebSocket
50
+ ): Promise<CallFactory> {
51
+ let socket = getTLSSocket(webSocket);
52
+ let remoteAddress = socket.remoteAddress;
53
+ let remotePort = socket.remotePort;
54
+ if (!remoteAddress) {
55
+ throw new Error("No remote address?");
56
+ }
57
+ if (!remotePort) {
58
+ throw new Error("No remote port?");
59
+ }
60
+
61
+ // NOTE: We COULD reconnect to clients, but... chances are... when they go down,
62
+ // their process is dead, and is going to stay dead.
63
+ let location: NetworkLocation = {
64
+ address: remoteAddress,
65
+ localPort: remotePort,
66
+ listeningPorts: [],
67
+ };
68
+
69
+ return await createCallFactory(webSocket, location);
70
+ }
71
+
72
+ async function createCallFactory(
73
+ webSocketBase: ws.WebSocket | undefined,
74
+ location: NetworkLocation,
75
+ ): Promise<CallFactory> {
76
+
77
+ let closedForever = false;
78
+
79
+ let niceConnectionName = `${location.address}:${location.localPort}`;
80
+
81
+ let retriesEnabled = location.listeningPorts.length === 0;
82
+
83
+ let lastReceivedSeqNum = 0;
84
+
85
+ let reconnectingPromise: Promise<void> | undefined;
86
+ let reconnectAttempts = 0;
87
+
88
+
89
+ let pendingCalls: Map<number, {
90
+ data: string;
91
+ call: InternalCallType;
92
+ reconnectTimeout: number | undefined;
93
+ callback: (result: InternalReturnType) => void;
94
+ }> = new Map();
95
+ // NOTE: It is important to make this as random as possible, to prevent
96
+ // reconnections dues to a process being reset causing seqNum collisions
97
+ // in return calls.
98
+ let nextSeqNum = Math.random();
99
+
100
+ const pendingNodeId = "PENDING";
101
+ let callerContext: CallerContext = { location, nodeId: pendingNodeId };
102
+ let webSocket!: ws.WebSocket;
103
+ if (!webSocketBase) {
104
+ await tryToReconnect();
105
+ } else {
106
+ webSocket = webSocketBase;
107
+ setupWebsocket(webSocketBase);
108
+ }
109
+ callerContext.nodeId = getNodeId(webSocket);
110
+
111
+ niceConnectionName = `${niceConnectionName} (${callerContext.nodeId})`;
112
+
113
+ async function sendWithRetry(reconnectTimeout: number | undefined, data: string) {
114
+ if (!retriesEnabled) {
115
+ webSocket.send(data);
116
+ return;
117
+ }
118
+
119
+ while (true) {
120
+ if (reconnectingPromise) {
121
+ if (reconnectTimeout) {
122
+ await Promise.race([
123
+ reconnectingPromise,
124
+ new Promise<ws.WebSocket>(resolve =>
125
+ setTimeout(() => {
126
+ retriesEnabled = false;
127
+ resolve(webSocket);
128
+ }, reconnectTimeout)
129
+ )
130
+ ]);
131
+ } else {
132
+ await reconnectingPromise;
133
+ }
134
+ }
135
+
136
+ if (!retriesEnabled) {
137
+ webSocket.send(data);
138
+ break;
139
+ }
140
+
141
+ try {
142
+ webSocket.send(data);
143
+ break;
144
+ } catch (e) {
145
+ // Ignore errors, as we will catch them synchronously in the next loop.
146
+ void (tryToReconnect());
147
+ }
148
+ }
149
+ }
150
+ function tryToReconnect(): Promise<void> {
151
+ if (reconnectingPromise) return reconnectingPromise;
152
+ return reconnectingPromise = (async () => {
153
+ while (true) {
154
+ let ports = location.listeningPorts;
155
+
156
+ if (ports.length === 0) {
157
+ closedForever = true;
158
+ console.log(`No ports to reconnect for ${niceConnectionName}, pendingCall count: ${pendingCalls.size}`);
159
+ for (let call of pendingCalls.values()) {
160
+ call.callback({
161
+ isReturn: true,
162
+ result: undefined,
163
+ error: `Connection lost to ${location.address}:${location.localPort}`,
164
+ seqNum: call.call.seqNum,
165
+ });
166
+ }
167
+ return;
168
+ }
169
+
170
+ let port = ports[reconnectAttempts % ports.length];
171
+ let newWebSocket = createWebsocket(location.address, port);
172
+
173
+ setupWebsocket(newWebSocket);
174
+
175
+ let connectError = await new Promise<string | undefined>(resolve => {
176
+ newWebSocket.once("open", () => {
177
+ resolve(undefined);
178
+ });
179
+ newWebSocket.once("close", () => {
180
+ resolve("Connection closed for non-error reason?");
181
+ });
182
+ newWebSocket.once("error", e => {
183
+ resolve(String(e.stack));
184
+ });
185
+ });
186
+
187
+ if (!connectError) {
188
+ console.log(`Reconnected to ${location.address}:${port}`);
189
+
190
+ let newNodeId = getNodeId(newWebSocket);
191
+
192
+ let prevNodeId = callerContext.nodeId;
193
+ if (prevNodeId === pendingNodeId) {
194
+ callerContext.nodeId = newNodeId;
195
+ } else {
196
+ if (newNodeId !== prevNodeId) {
197
+ throw new Error(`Connection lost to at ${niceConnectionName} ("${prevNodeId}"), but then re-established, however it is now "${newNodeId}"!`);
198
+ }
199
+ }
200
+
201
+ // I'm not sure if we should clear reconnectAttempts? All the ports should be the same, and actually...
202
+ // why would there even be a bad port?
203
+ //reconnectAttempts = 0;
204
+ reconnectingPromise = undefined;
205
+
206
+ webSocket = newWebSocket;
207
+
208
+ for (let call of pendingCalls.values()) {
209
+ sendWithRetry(call.reconnectTimeout, call.data).catch(e => {
210
+ call.callback({
211
+ isReturn: true,
212
+ result: undefined,
213
+ error: String(e),
214
+ seqNum: call.call.seqNum,
215
+ });
216
+ });
217
+ }
218
+ return;
219
+ }
220
+
221
+ console.error(`Connection retry to ${location.address}:${port} failed, retrying in ${retryInterval}ms`);
222
+ reconnectAttempts++;
223
+ await new Promise(resolve => setTimeout(resolve, retryInterval));
224
+ }
225
+ })();
226
+ }
227
+
228
+ function setupWebsocket(webSocket: ws.WebSocket) {
229
+ webSocket.on("error", e => {
230
+ console.log(`Websocket error for ${niceConnectionName}`, e);
231
+ });
232
+
233
+ webSocket.on("close", async () => {
234
+ console.log(`Websocket closed ${niceConnectionName}`);
235
+ await tryToReconnect();
236
+ });
237
+
238
+ webSocket.on("message", onMessage);
239
+ }
240
+
241
+
242
+ async function onMessage(message: ws.RawData) {
243
+ try {
244
+ if (message instanceof Buffer) {
245
+ let call = JSON.parse(message.toString()) as InternalCallType | InternalReturnType;
246
+ if (call.isReturn) {
247
+ let callbackObj = pendingCalls.get(call.seqNum);
248
+ if (!callbackObj) {
249
+ console.log(`Got return for unknown call ${call.seqNum}`);
250
+ return;
251
+ }
252
+ callbackObj.callback(call);
253
+ } else {
254
+ if (call.seqNum <= lastReceivedSeqNum) {
255
+ console.log(`Received out of sequence call ${call.seqNum}`);
256
+ return;
257
+ }
258
+ lastReceivedSeqNum = call.seqNum;
259
+
260
+ let response: InternalReturnType;
261
+ try {
262
+ let result = await performLocalCall({ call, caller: callerContext });
263
+ response = {
264
+ isReturn: true,
265
+ result,
266
+ seqNum: call.seqNum,
267
+ };
268
+ } catch (e: any) {
269
+ response = {
270
+ isReturn: true,
271
+ result: undefined,
272
+ seqNum: call.seqNum,
273
+ error: e.stack,
274
+ };
275
+ }
276
+
277
+ await sendWithRetry(call.reconnectTimeout, JSON.stringify(response));
278
+ }
279
+ return;
280
+ }
281
+ debugbreak(1);
282
+ debugger;
283
+ throw new Error(`Unhandled data type ${typeof message}`);
284
+ } catch (e: any) {
285
+ console.error(e.stack);
286
+ }
287
+ }
288
+
289
+ return {
290
+ nodeId: callerContext.nodeId,
291
+ location,
292
+ async performCall(call: CallType) {
293
+ if (closedForever) {
294
+ throw new Error(`Connection lost to ${location.address}:${location.localPort}`);
295
+ }
296
+
297
+ let seqNum = nextSeqNum++;
298
+ let fullCall: InternalCallType = {
299
+ isReturn: false,
300
+ args: call.args,
301
+ classGuid: call.classGuid,
302
+ functionName: call.functionName,
303
+ seqNum,
304
+ };
305
+ let data = JSON.stringify(fullCall);
306
+ let resultPromise = new Promise((resolve, reject) => {
307
+ let callback = (result: InternalReturnType) => {
308
+ pendingCalls.delete(seqNum);
309
+ if (result.error) {
310
+ reject(convertErrorStackToError(result.error));
311
+ } else {
312
+ resolve(result.result);
313
+ }
314
+ };
315
+ pendingCalls.set(seqNum, { callback, data, call: fullCall, reconnectTimeout: call.reconnectTimeout });
316
+ });
317
+
318
+ await sendWithRetry(call.reconnectTimeout, data);
319
+
320
+ return await resultPromise;
321
+ }
322
+ };
323
+ }
@@ -0,0 +1,131 @@
1
+ import { SocketExposedInterface, CallContextType, SocketFunctionHook, SocketFunctionClientHook, SocketExposedShape, SocketRegistered, NetworkLocation, CallerContext, SocketExposedInterfaceClass, CallType } from "./SocketFunctionTypes";
2
+ import { exposeClass, registerClass, registerGlobalClientHook, registerGlobalHook, runClientHooks } from "./callManager";
3
+ import { SocketServerConfig, startSocketServer } from "./socketServer";
4
+ import { getCallFactoryNodeId, getCreateCallFactoryLocation } from "./nodeCache";
5
+ import { getCallProxy } from "./nodeProxy";
6
+
7
+
8
+ type ExtractShape<ClassType, Shape> = {
9
+ [key in keyof Shape]: (
10
+ key extends keyof ClassType
11
+ ? ClassType[key] extends SocketExposedInterface[""]
12
+ ? ClassType[key]
13
+ : ClassType[key] extends Function ? "All exposed function must be async (or return a Promise)" : never
14
+ : "Function is in shape, but not in class"
15
+ );
16
+ };
17
+ // https://stackoverflow.com/a/69756175/1117119
18
+ type PickByType<T, Value> = {
19
+ [P in keyof T as T[P] extends Value | undefined ? P : never]: T[P]
20
+ };
21
+
22
+ export class SocketFunction {
23
+ public static register<
24
+ ClassType extends SocketExposedInterfaceClass,
25
+ Shape extends SocketExposedShape<SocketExposedInterface, CallContext>,
26
+ CallContext extends CallContextType
27
+ >(
28
+ classGuid: string,
29
+ classType: ClassType,
30
+ shape: Shape
31
+ ): (
32
+ // Essentially just returns SocketRegistered
33
+ ExtractShape<ClassType["prototype"], Shape> extends SocketExposedInterface
34
+ ? SocketRegistered<ExtractShape<ClassType["prototype"], Shape>, CallContext>
35
+ : {
36
+ error: "invalid shape";
37
+ } & PickByType<ExtractShape<ClassType["prototype"], Shape>, string>
38
+ ) {
39
+ registerClass(classGuid, classType, shape as any as SocketExposedShape);
40
+
41
+ let nodeProxy = getCallProxy(classGuid, async (nodeId, functionName, args) => {
42
+ let callFactory = getCallFactoryNodeId(nodeId);
43
+ if (!callFactory) {
44
+ throw new Error(`Cannot reach node ${nodeId}. Either it was established via an HTTP call, or was incorrect provided to us via another node, which should have provided us a NetworkLocation instead.`);
45
+ }
46
+
47
+ let shapeObj = shape[functionName];
48
+ if (!shapeObj) {
49
+ throw new Error(`Function ${functionName} is not in shape`);
50
+ }
51
+
52
+ let call: CallType = {
53
+ classGuid,
54
+ args,
55
+ functionName,
56
+ };
57
+
58
+ let hookResult = await runClientHooks(call, shapeObj as SocketExposedShape[""]);
59
+
60
+ if ("overrideResult" in hookResult) {
61
+ return hookResult.overrideResult;
62
+ }
63
+
64
+ return await callFactory.performCall(call);
65
+ });
66
+
67
+ let output: SocketRegistered = {
68
+ context: curSocketContext,
69
+ nodes: nodeProxy,
70
+ };
71
+
72
+ return output as any;
73
+ }
74
+
75
+ /** Expose should be called before your mounting occurs. It mostly just exists to ensure you include the class type,
76
+ * so the class type's module construction runs, which should trigger register. Otherwise you would have
77
+ * to add additional imports to ensure the register call runs.
78
+ */
79
+ public static expose(classType: SocketExposedInterfaceClass) {
80
+ exposeClass(classType);
81
+ }
82
+
83
+ public static async mount(config: SocketServerConfig) {
84
+ await startSocketServer(config);
85
+ }
86
+
87
+ public static async connect(location: NetworkLocation | { address: string; port: number }): Promise<string> {
88
+ if (!("localPort" in location)) {
89
+ location = {
90
+ address: location.address,
91
+ listeningPorts: [location.port],
92
+ localPort: 0,
93
+ };
94
+ }
95
+ return await getCreateCallFactoryLocation(location);
96
+ }
97
+
98
+
99
+ public static addGlobalHook<CallContext extends CallContextType>(hook: SocketFunctionHook<SocketExposedInterface, CallContext>) {
100
+ registerGlobalHook(hook as SocketFunctionHook);
101
+ }
102
+ public static addGlobalClientHook<CallContext extends CallContextType>(hook: SocketFunctionClientHook<SocketExposedInterface, CallContext>) {
103
+ registerGlobalClientHook(hook as SocketFunctionClientHook);
104
+ }
105
+ }
106
+
107
+
108
+ const curSocketContext: SocketRegistered["context"] = {
109
+ curContext: undefined,
110
+ caller: undefined,
111
+ };
112
+ let socketContextSeqNum = 1;
113
+
114
+ export function _setSocketContext<T>(
115
+ callContext: CallContextType,
116
+ caller: CallerContext,
117
+ code: () => T,
118
+ ) {
119
+ socketContextSeqNum++;
120
+ let seqNum = socketContextSeqNum;
121
+ curSocketContext.curContext = callContext;
122
+ curSocketContext.caller = caller;
123
+ try {
124
+ return code();
125
+ } finally {
126
+ if (seqNum === socketContextSeqNum) {
127
+ curSocketContext.curContext = undefined;
128
+ curSocketContext.caller = undefined;
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,80 @@
1
+ export const socket = Symbol.for("socket");
2
+
3
+ export type SocketExposedInterface = {
4
+ [functionName: string]: (...args: any[]) => Promise<unknown>;
5
+ };
6
+ export type SocketExposedInterfaceClass = {
7
+ //new(): SocketExposedInterface;
8
+ new(): unknown;
9
+ prototype: unknown;
10
+ };
11
+ export interface SocketExposedShape<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> {
12
+ [functionName: string]: {
13
+ hooks?: SocketFunctionHook<ExposedType, CallContext>[];
14
+ clientHooks?: SocketFunctionClientHook<ExposedType, CallContext>[];
15
+ };
16
+ }
17
+
18
+ export interface CallType {
19
+ classGuid: string;
20
+ functionName: string;
21
+ args: unknown[];
22
+ // NOTE: When making calls this needs to be set in the client hook.
23
+ // To set a timeout on returns, you can set it in the server hook.
24
+ reconnectTimeout?: number;
25
+ }
26
+
27
+ export interface SocketFunctionHook<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> {
28
+ (config: HookContext<ExposedType, CallContext>): Promise<void>;
29
+ }
30
+ export type HookContext<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> = {
31
+ call: CallType;
32
+ context: SocketRegistered["context"];
33
+ // If the result is overriden, we continue evaluating hooks and perform the final call
34
+ overrideResult?: unknown;
35
+ };
36
+
37
+ export type ClientHookContext<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> = {
38
+ call: CallType;
39
+ // If the result is overriden, we continue evaluating hooks and perform the final call
40
+ overrideResult?: unknown;
41
+ };
42
+ export interface SocketFunctionClientHook<ExposedType extends SocketExposedInterface = SocketExposedInterface, CallContext extends CallContextType = CallContextType> {
43
+ (config: ClientHookContext<ExposedType, CallContext>): Promise<void>;
44
+ }
45
+
46
+ export type CallContextType = {
47
+ [key: string]: unknown;
48
+ };
49
+
50
+ export interface SocketRegistered<ExposedType extends SocketExposedInterface = SocketExposedInterface, DynamicCallContext extends CallContextType = CallContextType> {
51
+ nodes: {
52
+ // NOTE: Don't pass around nodeId to other nodes, instead pass around NetworkLocation (which they
53
+ // then turn into a nodeId, which they can then check permissions on themself).
54
+ [nodeId: string]: ExposedType;
55
+ };
56
+ context: {
57
+ // If undefined we are not synchronously in a call
58
+ curContext: DynamicCallContext | undefined;
59
+ caller: CallerContext | undefined;
60
+ };
61
+ }
62
+ export type CallerContext = {
63
+ // IMPORTANT! Do not pass nodeId to other nodes with the intention of having
64
+ // them call functions directly using nodeId. Instead pass location, and have them use connect.
65
+ // - nodeId SHOULD be used to identify users though, as it cannot be impersonated
66
+ nodeId: string;
67
+ location: NetworkLocation;
68
+ };
69
+
70
+ // IMPORTANT! Nodes at the same network location may vary, so you cannot store NetworkLocation
71
+ // in a list of allowed users, otherwise they can be impersonated!
72
+ export interface NetworkLocation {
73
+ address: string;
74
+ /** localPort is the port the connection was made from, which is often uninteresting.
75
+ * It is useful internally, to help reduce collisions when a process restarts and
76
+ * reconnects (as it may have the same source address, but the localPort will likely change).
77
+ */
78
+ localPort: number;
79
+ listeningPorts: number[];
80
+ }
package/args.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { lazy } from "./caching";
2
+
3
+ export const getArgs = lazy(() => {
4
+ let args = process.argv.slice(2);
5
+ let argObj: { [key: string]: string | undefined } = {};
6
+ for (let arg of args) {
7
+ if (arg.startsWith("-")) {
8
+ arg = arg.slice(1);
9
+ }
10
+ if (arg.startsWith("-")) {
11
+ arg = arg.slice(1);
12
+ }
13
+ if (arg.includes("=")) {
14
+ let key = arg.split("=")[0];
15
+ let value = arg.split("=").slice(1).join("=");
16
+ argObj[key] = value;
17
+ } else {
18
+ argObj[arg] = "true";
19
+ }
20
+ }
21
+ return argObj;
22
+ });