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 +51 -0
- package/.vscode/settings.json +26 -0
- package/CallInstance.ts +323 -0
- package/SocketFunction.ts +131 -0
- package/SocketFunctionTypes.ts +80 -0
- package/args.ts +22 -0
- package/caching.ts +301 -0
- package/callManager.ts +108 -0
- package/index.ts +0 -0
- package/misc.ts +27 -0
- package/nodeAuthentication.ts +110 -0
- package/nodeCache.ts +79 -0
- package/nodeProxy.ts +36 -0
- package/package.json +17 -0
- package/socketServer.ts +74 -0
- package/spec.txt +104 -0
- package/storagePath.ts +11 -0
- package/test.ts +87 -0
- package/tsconfig.json +27 -0
- package/types.ts +9 -0
package/caching.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { arrayEqual } from "./misc";
|
|
2
|
+
import { AnyFunction, Args, canHaveChildren } from "./types";
|
|
3
|
+
|
|
4
|
+
export function lazy<T>(factory: () => T): () => T {
|
|
5
|
+
let value: { value: T }|undefined = undefined;
|
|
6
|
+
|
|
7
|
+
return () => {
|
|
8
|
+
if(!value) {
|
|
9
|
+
value = { value: factory() };
|
|
10
|
+
}
|
|
11
|
+
return value.value;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// NOTE: The reason we need to periodically clear, is because sometimes a very small
|
|
16
|
+
// part of a large payload (ex, persisted overrides) is cached, which then results
|
|
17
|
+
// in the whole payload being cached, which results in a lot of memory being used.
|
|
18
|
+
|
|
19
|
+
// IMPORTANT! The cleanup functions CANNOT close upon anything, or else they will cause leaks!
|
|
20
|
+
// All data they use should be in data.
|
|
21
|
+
interface CleanupFnc<T extends object> {
|
|
22
|
+
(data: T): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
// NOTE: Empty arrays are so common, that it is useful to represent them as the same
|
|
27
|
+
// emtpy array, to increase cache hit rates.
|
|
28
|
+
const emptyArray: any[] = [];
|
|
29
|
+
export function cacheEmptyArray<T>(array: T[]): T[] {
|
|
30
|
+
if (array.length === 0) return emptyArray;
|
|
31
|
+
return array;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function cache<Output, Key>(getValue: (key: Key) => Output): {
|
|
35
|
+
(key: Key): Output;
|
|
36
|
+
} {
|
|
37
|
+
let startingCalculating = new Set<Key>();
|
|
38
|
+
let values = new Map<Key, Output>();
|
|
39
|
+
function cache(input: Key) {
|
|
40
|
+
let key = input;
|
|
41
|
+
if (values.has(key)) {
|
|
42
|
+
return values.get(key) as any;
|
|
43
|
+
}
|
|
44
|
+
if (startingCalculating.has(key)) {
|
|
45
|
+
// TODO: Fix the types here, by throwing, and then for the cases
|
|
46
|
+
// that don't throw, make our output type include undefined
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
startingCalculating.add(key);
|
|
50
|
+
let value = getValue(input);
|
|
51
|
+
values.set(key, value);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
return cache;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
/** Makes a cache that limits the number of entries, allowing you to put arbitrary data in it
|
|
59
|
+
* without worrying about leaking memory
|
|
60
|
+
*/
|
|
61
|
+
export function cacheLimited<Output, Key>(
|
|
62
|
+
// NOTE: We can't calculate what limit should be based on comparing the evaluation time
|
|
63
|
+
// and the time to compare against the values. Because, even if finding a match takes far longer than
|
|
64
|
+
// calculating, keeping a consistent output can save (a considerable amount of) time in downstream caches.
|
|
65
|
+
maxCount: number,
|
|
66
|
+
getValue: (key: Key) => Output
|
|
67
|
+
): (key: Key) => Output {
|
|
68
|
+
let startingCalculating = new Set<Key>();
|
|
69
|
+
let values = new Map<Key, Output>();
|
|
70
|
+
return (input) => {
|
|
71
|
+
let key = input;
|
|
72
|
+
if (values.has(key)) {
|
|
73
|
+
return values.get(key) as any;
|
|
74
|
+
}
|
|
75
|
+
if (startingCalculating.has(key)) {
|
|
76
|
+
throw new Error(`Cyclic access in cache`);
|
|
77
|
+
}
|
|
78
|
+
startingCalculating.add(key);
|
|
79
|
+
|
|
80
|
+
// Clear when it gets too big. This is kind of like a worse
|
|
81
|
+
// least recently used cache, because entries that are accessed
|
|
82
|
+
// often will quickly get put back in. This is effective as long
|
|
83
|
+
// as accesses take similar amounts of time. If there is a very slow
|
|
84
|
+
// and very commonly accessed value, it could be evicted by many very
|
|
85
|
+
// fast accesses, which would be unfortunate.
|
|
86
|
+
if (values.size >= maxCount) {
|
|
87
|
+
values.clear();
|
|
88
|
+
startingCalculating.clear();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let value = getValue(input);
|
|
92
|
+
values.set(key, value);
|
|
93
|
+
return value;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function cacheWeak<Output, Key extends object>(getValue: (key: Key) => Output): (key: Key) => Output {
|
|
98
|
+
let state = {
|
|
99
|
+
startingCalculating: new WeakSet<Key>(),
|
|
100
|
+
values: new WeakMap<Key, Output>(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return (input) => {
|
|
104
|
+
let key = input;
|
|
105
|
+
if (state.values.has(key)) {
|
|
106
|
+
return state.values.get(key) as any;
|
|
107
|
+
}
|
|
108
|
+
if (state.startingCalculating.has(key)) {
|
|
109
|
+
throw new Error(`Cyclic access in cacheWeak`);
|
|
110
|
+
}
|
|
111
|
+
state.startingCalculating.add(key);
|
|
112
|
+
let value = getValue(input);
|
|
113
|
+
state.values.set(key, value);
|
|
114
|
+
return value;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A list cache, which... maybe faster than a Map?
|
|
119
|
+
export function cacheList<Value>(
|
|
120
|
+
getLength: () => number,
|
|
121
|
+
getValue: (index: number) => Value,
|
|
122
|
+
): { (index: number): Value; } {
|
|
123
|
+
let state = {
|
|
124
|
+
cache: [] as Value[],
|
|
125
|
+
length: undefined as undefined | number,
|
|
126
|
+
getLength,
|
|
127
|
+
};
|
|
128
|
+
function get(i: number) {
|
|
129
|
+
let cache = state.cache;
|
|
130
|
+
let length = state.length;
|
|
131
|
+
if (length === undefined) {
|
|
132
|
+
length = state.length = state.getLength();
|
|
133
|
+
}
|
|
134
|
+
if (i < 0 || i >= length) {
|
|
135
|
+
throw new Error(`Index out of bounds`);
|
|
136
|
+
}
|
|
137
|
+
if (!(i in cache)) {
|
|
138
|
+
cache[i] = getValue(i);
|
|
139
|
+
}
|
|
140
|
+
return cache[i];
|
|
141
|
+
};
|
|
142
|
+
return get;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cacheArrayEqualCleanup(state: any) {
|
|
146
|
+
state.cache = [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A cache half way between caching based on === and caching based on hash. Caches
|
|
150
|
+
* based on arrayEqual, which does === on all values in an array. Requires localized
|
|
151
|
+
* caching (as the comparisons don't scale with many candidates, unlike hashing),
|
|
152
|
+
* however works with non trival transformations (ex, resolving many persisted overrides
|
|
153
|
+
* to get a value), unlike cache().
|
|
154
|
+
* Also, limits itself, more of a performance optimization than memory optimization, as it scales
|
|
155
|
+
* very poorly with the number of candidates.
|
|
156
|
+
*
|
|
157
|
+
* TIMING: About 6us with limit = 100, array size = 294, and the cache being full.
|
|
158
|
+
*/
|
|
159
|
+
export function cacheArrayEqual<Input extends unknown[] | undefined, Output>(
|
|
160
|
+
map: (arrays: Input) => Output,
|
|
161
|
+
limit = 10
|
|
162
|
+
): {
|
|
163
|
+
(array: Input): Output;
|
|
164
|
+
clear(array: Input): void;
|
|
165
|
+
} {
|
|
166
|
+
let state: {
|
|
167
|
+
cache: {
|
|
168
|
+
input: Input;
|
|
169
|
+
output: Output;
|
|
170
|
+
}[]
|
|
171
|
+
} = { cache: [] };
|
|
172
|
+
function isMatch(lhs: Input, rhs: Input) {
|
|
173
|
+
if (lhs === rhs) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (lhs === undefined || rhs === undefined) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
if (arrayEqual(lhs, rhs)) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
return Object.assign(
|
|
185
|
+
(input: Input) => {
|
|
186
|
+
let cache = state.cache;
|
|
187
|
+
for (let obj of cache) {
|
|
188
|
+
if (isMatch(obj.input, input)) {
|
|
189
|
+
return obj.output;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
let output = map(input);
|
|
193
|
+
cache.unshift({ input, output });
|
|
194
|
+
while (cache.length > limit) {
|
|
195
|
+
cache.pop();
|
|
196
|
+
}
|
|
197
|
+
return output;
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
clear(array: Input) {
|
|
201
|
+
for (let i = state.cache.length - 1; i >= 0; i--) {
|
|
202
|
+
if (isMatch(state.cache[i].input, array)) {
|
|
203
|
+
state.cache.splice(i, 1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Caches when arguments are ===. See cacheArrayEqual */
|
|
212
|
+
export function cacheArgsEqual<Fnc extends AnyFunction>(
|
|
213
|
+
fnc: Fnc,
|
|
214
|
+
limit = 10
|
|
215
|
+
): Fnc & { clear(...args: Args<Fnc>): void } {
|
|
216
|
+
let cache = cacheArrayEqual((args: unknown[]) => {
|
|
217
|
+
return fnc(...args);
|
|
218
|
+
}, limit);
|
|
219
|
+
return Object.assign(
|
|
220
|
+
((...args: unknown[]) => {
|
|
221
|
+
return cache(args);
|
|
222
|
+
}) as Fnc,
|
|
223
|
+
{
|
|
224
|
+
clear(...args: unknown[]) {
|
|
225
|
+
cache.clear(args);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function cacheJSONArgsEqual<Fnc extends AnyFunction>(
|
|
232
|
+
fnc: Fnc,
|
|
233
|
+
limit = 10
|
|
234
|
+
): Fnc {
|
|
235
|
+
let cache = cacheLimited(limit, (argsJSON: string) => {
|
|
236
|
+
return fnc(...JSON.parse(argsJSON));
|
|
237
|
+
});
|
|
238
|
+
return ((...args: unknown[]) => {
|
|
239
|
+
return cache(JSON.stringify(args));
|
|
240
|
+
}) as Fnc;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function cacheShallowConfigArgEqual<Fnc extends AnyFunction>(
|
|
244
|
+
fnc: Fnc,
|
|
245
|
+
limit = 10
|
|
246
|
+
): Fnc & {
|
|
247
|
+
clear(...args: Args<Fnc>): void;
|
|
248
|
+
} {
|
|
249
|
+
let cache = cacheArrayEqual((kvpsFlat: unknown[]) => {
|
|
250
|
+
output.missCount++;
|
|
251
|
+
let arg: any;
|
|
252
|
+
if (kvpsFlat.length === 1) {
|
|
253
|
+
arg = kvpsFlat[0];
|
|
254
|
+
} else {
|
|
255
|
+
let kvps: [unknown, unknown][] = [];
|
|
256
|
+
for (let i = 0; i < kvpsFlat.length; i += 2) {
|
|
257
|
+
kvps.push([kvpsFlat[i], kvpsFlat[i + 1]]);
|
|
258
|
+
}
|
|
259
|
+
arg = Object.fromEntries(kvps);
|
|
260
|
+
}
|
|
261
|
+
return fnc(arg);
|
|
262
|
+
}, limit);
|
|
263
|
+
function getKVPs(configArg: object) {
|
|
264
|
+
if (!canHaveChildren(configArg) || Array.isArray(configArg)) {
|
|
265
|
+
return [configArg];
|
|
266
|
+
}
|
|
267
|
+
let keys = Object.keys(configArg);
|
|
268
|
+
keys.sort();
|
|
269
|
+
return keys.flatMap(key => [key, (configArg as any)[key]]);
|
|
270
|
+
}
|
|
271
|
+
let output = Object.assign(
|
|
272
|
+
((configArg: object) => {
|
|
273
|
+
output.callCount++;
|
|
274
|
+
return cache(getKVPs(configArg));
|
|
275
|
+
}) as Fnc,
|
|
276
|
+
{
|
|
277
|
+
clear(configArg: object) {
|
|
278
|
+
cache.clear(getKVPs(configArg));
|
|
279
|
+
},
|
|
280
|
+
callCount: 0,
|
|
281
|
+
missCount: 0,
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
return output;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
export function externalCache<Key, Value>(): {
|
|
289
|
+
get: (key: Key) => Value | undefined;
|
|
290
|
+
set: (key: Key, value: Value) => void;
|
|
291
|
+
} {
|
|
292
|
+
let values = new Map<Key, Value>();
|
|
293
|
+
return {
|
|
294
|
+
get: (key) => {
|
|
295
|
+
return values.get(key);
|
|
296
|
+
},
|
|
297
|
+
set: (key, value) => {
|
|
298
|
+
values.set(key, value);
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
package/callManager.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { CallContextType, CallerContext, CallType, ClientHookContext, HookContext, NetworkLocation, SocketExposedInterface, SocketExposedInterfaceClass, SocketExposedShape, SocketFunctionClientHook, SocketFunctionHook, SocketRegistered } from "./SocketFunctionTypes";
|
|
2
|
+
import { _setSocketContext } from "./SocketFunction";
|
|
3
|
+
|
|
4
|
+
let classes: {
|
|
5
|
+
[classGuid: string]: {
|
|
6
|
+
classType: SocketExposedInterfaceClass;
|
|
7
|
+
controller: SocketExposedInterface;
|
|
8
|
+
shape: SocketExposedShape;
|
|
9
|
+
}
|
|
10
|
+
} = {};
|
|
11
|
+
let exposedClasses = new Set<SocketExposedInterfaceClass>();
|
|
12
|
+
|
|
13
|
+
let globalHooks: SocketFunctionHook[] = [];
|
|
14
|
+
let globalClientHooks: SocketFunctionClientHook[] = [];
|
|
15
|
+
|
|
16
|
+
export async function performLocalCall(
|
|
17
|
+
config: {
|
|
18
|
+
call: CallType;
|
|
19
|
+
caller: CallerContext;
|
|
20
|
+
}
|
|
21
|
+
): Promise<unknown> {
|
|
22
|
+
const { call, caller } = config;
|
|
23
|
+
let classDef = classes[call.classGuid];
|
|
24
|
+
|
|
25
|
+
if (!classDef) {
|
|
26
|
+
throw new Error(`Class ${call.classGuid} not found`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!exposedClasses.has(classDef.classType)) {
|
|
30
|
+
throw new Error(`Class ${call.classGuid} not exposed`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let controller = classDef.controller;
|
|
34
|
+
let shape = classDef.shape;
|
|
35
|
+
let functionShape = shape[call.functionName];
|
|
36
|
+
if (!functionShape) {
|
|
37
|
+
throw new Error(`Function ${call.functionName} not exposed`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!controller[call.functionName]) {
|
|
41
|
+
throw new Error(`Function ${call.functionName} does not exist`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let curContext: CallContextType = {};
|
|
45
|
+
let serverContext = await runServerHooks(call, { caller, curContext }, shape);
|
|
46
|
+
if ("overrideResult" in serverContext) {
|
|
47
|
+
return serverContext.overrideResult;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// NOTE: We purposely don't await inside _setSocketContext, so the context is reset synchronously
|
|
51
|
+
let result = _setSocketContext(curContext, caller, () => {
|
|
52
|
+
return controller[call.functionName](...call.args);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return await result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function registerClass(classGuid: string, exposedClass: SocketExposedInterfaceClass, shape: SocketExposedShape) {
|
|
59
|
+
if (classes[classGuid]) {
|
|
60
|
+
throw new Error(`Class ${classGuid} already registered`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
classes[classGuid] = {
|
|
64
|
+
classType: exposedClass,
|
|
65
|
+
controller: new exposedClass() as SocketExposedInterface,
|
|
66
|
+
shape,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function exposeClass(exposedClass: SocketExposedInterfaceClass) {
|
|
71
|
+
exposedClasses.add(exposedClass);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function registerGlobalHook(hook: SocketFunctionHook) {
|
|
75
|
+
globalHooks.push(hook);
|
|
76
|
+
}
|
|
77
|
+
export function registerGlobalClientHook(hook: SocketFunctionClientHook) {
|
|
78
|
+
globalClientHooks.push(hook);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runClientHooks(
|
|
82
|
+
callType: CallType,
|
|
83
|
+
hooks: SocketExposedShape[""],
|
|
84
|
+
): Promise<ClientHookContext> {
|
|
85
|
+
let context: ClientHookContext = { call: callType };
|
|
86
|
+
for (let hook of globalClientHooks.concat(hooks.clientHooks || [])) {
|
|
87
|
+
await hook(context);
|
|
88
|
+
if ("overrideResult" in context) {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return context;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function runServerHooks(
|
|
96
|
+
callType: CallType,
|
|
97
|
+
context: SocketRegistered["context"],
|
|
98
|
+
hooks: SocketExposedShape[""],
|
|
99
|
+
): Promise<HookContext> {
|
|
100
|
+
let hookContext: HookContext = { call: callType, context };
|
|
101
|
+
for (let hook of globalHooks.concat(hooks.hooks || [])) {
|
|
102
|
+
await hook(hookContext);
|
|
103
|
+
if ("overrideResult" in hookContext) {
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return hookContext;
|
|
108
|
+
}
|
package/index.ts
ADDED
|
File without changes
|
package/misc.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
export function convertErrorStackToError(error: string): Error {
|
|
4
|
+
let errorObj = new Error();
|
|
5
|
+
errorObj.stack = String(error);
|
|
6
|
+
errorObj.message = String(error).split("\n")[0].slice("Error: ".length);
|
|
7
|
+
return errorObj;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function sha256Hash(buffer: Buffer) {
|
|
11
|
+
return crypto.createHash("sha256").update(buffer).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
export function arrayEqual(a: unknown[], b: unknown[]) {
|
|
16
|
+
if (a.length !== b.length) return false;
|
|
17
|
+
for (let i = 0; i < a.length; i++) {
|
|
18
|
+
if (a[i] !== b[i]) return false;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
// TODO: Find a better place for this...
|
|
25
|
+
process.on("unhandledRejection", async (reason: any, promise) => {
|
|
26
|
+
console.error(`Uncaught promise rejection: ${String(reason.stack || reason)}`);
|
|
27
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import ws from "ws";
|
|
2
|
+
import tls from "tls";
|
|
3
|
+
import { getAppFolder } from "./storagePath";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import child_process from "child_process";
|
|
6
|
+
import { cacheWeak, lazy } from "./caching";
|
|
7
|
+
import https from "https";
|
|
8
|
+
import debugbreak from "debugbreak";
|
|
9
|
+
import crypto from "crypto";
|
|
10
|
+
import { sha256Hash } from "./misc";
|
|
11
|
+
import { getArgs } from "./args";
|
|
12
|
+
|
|
13
|
+
export const getCertKeyPair = lazy((): { key: Buffer; cert: Buffer } => {
|
|
14
|
+
// TODO: Also get this working clientside...
|
|
15
|
+
// - Probably using node-forge, maybe using this as an example: https://github.com/jfromaniello/selfsigned/blob/master/index.js
|
|
16
|
+
|
|
17
|
+
// https://nodejs.org/en/knowledge/HTTP/servers/how-to-create-a-HTTPS-server/
|
|
18
|
+
|
|
19
|
+
let folder = getAppFolder();
|
|
20
|
+
let identityPrefix = getArgs().identity || "";
|
|
21
|
+
let keyPath = folder + identityPrefix + "key.pem";
|
|
22
|
+
let certPath = folder + identityPrefix + "cert.pem";
|
|
23
|
+
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
|
|
24
|
+
child_process.execSync(`openssl genrsa -out "${keyPath}"`);
|
|
25
|
+
child_process.execSync(`openssl req -new -key "${keyPath}" -out csr.pem -subj "/CN=notused"`);
|
|
26
|
+
child_process.execSync(`openssl x509 -req -days 9999 -in csr.pem -signkey "${keyPath}" -out "${certPath}"`);
|
|
27
|
+
fs.rmSync("csr.pem");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let key = fs.readFileSync(keyPath);
|
|
31
|
+
let cert = fs.readFileSync(certPath);
|
|
32
|
+
return { key, cert };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export function getTLSSocket(webSocket: ws.WebSocket) {
|
|
36
|
+
return (webSocket as any)._socket as tls.TLSSocket;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const getNodeId = cacheWeak(function (webSocket: ws.WebSocket): string {
|
|
40
|
+
let socket = getTLSSocket(webSocket);
|
|
41
|
+
let peerCert = socket.getPeerCertificate();
|
|
42
|
+
if (!peerCert) {
|
|
43
|
+
throw new Error("WebSocket connections must provided a peer certificate");
|
|
44
|
+
}
|
|
45
|
+
let pubkey = (peerCert as any).pubkey as Buffer | undefined;
|
|
46
|
+
if (!pubkey) {
|
|
47
|
+
throw new Error(`Peer certificate must use an RSA key or EC key (which should have a .pubkey property)`);
|
|
48
|
+
}
|
|
49
|
+
return sha256Hash(pubkey);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export function createWebsocket(address: string, port: number): ws.WebSocket {
|
|
53
|
+
let { key, cert } = getCertKeyPair();
|
|
54
|
+
console.log(`Connecting to ${address}:${port}`);
|
|
55
|
+
return new ws.WebSocket(`wss://${address}:${port}`, {
|
|
56
|
+
cert,
|
|
57
|
+
key,
|
|
58
|
+
rejectUnauthorized: false,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
const port = 2422;
|
|
66
|
+
let { key, cert } = getCertKeyPair();
|
|
67
|
+
console.log(process.argv);
|
|
68
|
+
if (process.argv.includes("--server")) {
|
|
69
|
+
|
|
70
|
+
let server = https.createServer({
|
|
71
|
+
key,
|
|
72
|
+
cert,
|
|
73
|
+
rejectUnauthorized: false,
|
|
74
|
+
requestCert: true
|
|
75
|
+
});
|
|
76
|
+
let listenPromise = new Promise<void>((resolve, error) => {
|
|
77
|
+
server.on("listening", () => {
|
|
78
|
+
resolve();
|
|
79
|
+
});
|
|
80
|
+
server.on("error", e => {
|
|
81
|
+
error(e);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
server.on("request", (request, response) => {
|
|
86
|
+
// TODO: Handle HTTP requests
|
|
87
|
+
// - HTTP CAN have a nodeId, simply through setting cookies
|
|
88
|
+
// - Cookies could always be set via a request before we open
|
|
89
|
+
// the websocket connection?
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const webSocketServer = new ws.Server({
|
|
93
|
+
noServer: true,
|
|
94
|
+
});
|
|
95
|
+
server.on("upgrade", (request, socket, upgradeHead) => {
|
|
96
|
+
webSocketServer.handleUpgrade(request, socket, upgradeHead, (ws) => {
|
|
97
|
+
console.log("peer", getTLSSocket(ws).getPeerCertificate()?.pubkey.toString("hex").slice(100));
|
|
98
|
+
console.log("cert", getTLSSocket(ws).getCertificate()?.pubkey.toString("hex").slice(100));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
server.listen(2422, "127.0.0.1");
|
|
103
|
+
} else {
|
|
104
|
+
let socket = new ws.WebSocket(`wss://127.0.0.1:${port}`, { rejectUnauthorized: false, cert, key });
|
|
105
|
+
socket.on("open", () => {
|
|
106
|
+
console.log("peer", getTLSSocket(socket).getPeerCertificate()?.pubkey.toString("hex").slice(100));
|
|
107
|
+
console.log("cert", getTLSSocket(socket).getCertificate()?.pubkey.toString("hex").slice(100));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
*/
|
package/nodeCache.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { callFactoryFromLocation, CallFactory } from "./CallInstance";
|
|
2
|
+
import { NetworkLocation } from "./SocketFunctionTypes";
|
|
3
|
+
import { MaybePromise } from "./types";
|
|
4
|
+
|
|
5
|
+
// TODO: Add CallInstanceFactory.isClosed, so nodeCache can clean up old entries.
|
|
6
|
+
// This is only needed for memory management, and not for correctness. Entries never
|
|
7
|
+
// need to be refreshed, because NetworkLocation.listeningPorts shouldn't really change.
|
|
8
|
+
// Either we will have listeningPorts and re-establish the connection, or we won't, and
|
|
9
|
+
// then it is a client, in which case we cannot re-establish the connection (and we just
|
|
10
|
+
// have to wait for the client to re-establish it). AND, if the listeningPorts change from
|
|
11
|
+
// a value to a new value... then they should be obtained using connect() anyway,
|
|
12
|
+
// and so whatever way the user got the NetworkLocation to begin with, they should use again.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
// nodeId =>
|
|
16
|
+
const nodeCache = new Map<string, {
|
|
17
|
+
callFactory: CallFactory;
|
|
18
|
+
}>();
|
|
19
|
+
const locationLookup = new Map<string, MaybePromise<string>>();
|
|
20
|
+
|
|
21
|
+
function getNetworkLocationHash(location: NetworkLocation): string {
|
|
22
|
+
return location.address + ":" + location.localPort + "=" + location.listeningPorts.join("|");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// NOTE: For client connections, at which point we have the nodeId, location and callFactory.
|
|
26
|
+
export function registerNodeClient(callFactory: CallFactory) {
|
|
27
|
+
let { nodeId } = callFactory;
|
|
28
|
+
// NOTE: We can always clobber the entry, AS, during client connection we give NetworkLocation information,
|
|
29
|
+
// so even if we already have this node with NetworkLocation.listeningPorts, this new values should
|
|
30
|
+
// be even newer, or the same.
|
|
31
|
+
// - AND, clobbering shouldn't happen often, if the other end connected to us they should have given us their
|
|
32
|
+
// nodeId. So they'll use the existing websocket when using that nodeId, instead of establishing a new connection,
|
|
33
|
+
// except for race conditions cases, in which case we just have an extra connection, which isn't so bad...
|
|
34
|
+
// - And of course, we have to use the newer connection, as it might be the case that the NetworkLocation has actually
|
|
35
|
+
// updated, and the old connection is now forever closed.
|
|
36
|
+
|
|
37
|
+
// Never go from listening ports to no listening ports. Worst case the listening ports are old
|
|
38
|
+
// and won't work, in which case... we won't be able to reconnect, which basically what
|
|
39
|
+
// we would do if there were no listening ports.
|
|
40
|
+
let prevListeningPorts = nodeCache.get(nodeId)?.callFactory.location.listeningPorts;
|
|
41
|
+
if (prevListeningPorts && !callFactory.location.listeningPorts.length) {
|
|
42
|
+
callFactory.location.listeningPorts = prevListeningPorts;
|
|
43
|
+
}
|
|
44
|
+
// TODO: Maybe even preserve the address in some cases, such as if it was a domain, and is now an ip?
|
|
45
|
+
nodeCache.set(nodeId, {
|
|
46
|
+
callFactory,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getCreateCallFactoryLocation(location: NetworkLocation): MaybePromise<string> {
|
|
51
|
+
let locationHash = getNetworkLocationHash(location);
|
|
52
|
+
let nodeId = locationLookup.get(locationHash);
|
|
53
|
+
if (nodeId !== undefined) {
|
|
54
|
+
return nodeId;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let callFactoryPromise = callFactoryFromLocation(location);
|
|
58
|
+
let nodeIdPromise = callFactoryPromise.then(x => x.nodeId);
|
|
59
|
+
locationLookup.set(locationHash, nodeIdPromise);
|
|
60
|
+
|
|
61
|
+
return callFactoryPromise.then(callFactory => {
|
|
62
|
+
let nodeId = callFactory.nodeId;
|
|
63
|
+
// TODO: Maybe warn if we just clobbered a nodeId?
|
|
64
|
+
let prevEntry = nodeCache.get(nodeId);
|
|
65
|
+
if (prevEntry) {
|
|
66
|
+
console.warn(`Clobbering nodeId ${nodeId}, with a new location ${locationHash}, was ${getNetworkLocationHash(prevEntry.callFactory.location)}. (This might indiciate multiple locations with the same nodeId, which could cause an issue. If this happens repeatedly it will cause stability issues).`);
|
|
67
|
+
}
|
|
68
|
+
nodeCache.set(nodeId, {
|
|
69
|
+
callFactory,
|
|
70
|
+
});
|
|
71
|
+
return nodeId;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
// TODO: Give a special error if the nodeId has been seen, but is only one-way (from HTTP requests).
|
|
77
|
+
export function getCallFactoryNodeId(nodeId: string): CallFactory|undefined {
|
|
78
|
+
return nodeCache.get(nodeId)?.callFactory;
|
|
79
|
+
}
|
package/nodeProxy.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { lazy } from "./caching";
|
|
2
|
+
import { SocketExposedInterface } from "./SocketFunctionTypes";
|
|
3
|
+
|
|
4
|
+
//todonext
|
|
5
|
+
// We need some sort of cache, but... maybe not here?
|
|
6
|
+
// Proxy => callback?
|
|
7
|
+
|
|
8
|
+
type CallProxyType = {
|
|
9
|
+
[controllerName: string]: SocketExposedInterface;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let proxyCache = new Map<string, CallProxyType>();
|
|
13
|
+
export function getCallProxy(id: string, callback: (controllerName: string, functionName: string, args: unknown[]) => Promise<unknown>): CallProxyType {
|
|
14
|
+
let value = proxyCache.get(id);
|
|
15
|
+
if(!value) {
|
|
16
|
+
let controllerCache = new Map<string, CallProxyType[""]>();
|
|
17
|
+
value = new Proxy(Object.create(null), {
|
|
18
|
+
get(target, controllerName) {
|
|
19
|
+
if(typeof controllerName !== "string") return undefined;
|
|
20
|
+
let controller = controllerCache.get(controllerName);
|
|
21
|
+
if(!controller) {
|
|
22
|
+
controller = new Proxy(Object.create(null), {
|
|
23
|
+
get(target, functionName) {
|
|
24
|
+
if (typeof functionName !== "string") return undefined;
|
|
25
|
+
return (...args: unknown[]) => callback(controllerName, functionName, args);
|
|
26
|
+
}
|
|
27
|
+
}) as CallProxyType[""];
|
|
28
|
+
controllerCache.set(controllerName, controller);
|
|
29
|
+
}
|
|
30
|
+
return controller;
|
|
31
|
+
},
|
|
32
|
+
}) as CallProxyType;
|
|
33
|
+
proxyCache.set(id, value);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "socket-function",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@types/node": "^18.0.0",
|
|
8
|
+
"@types/ws": "^8.5.3",
|
|
9
|
+
"debugbreak": "^0.6.3",
|
|
10
|
+
"typenode": "0.3.0",
|
|
11
|
+
"ws": "^8.8.0"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "yarn typenode ./nodeAuthentication.ts --server",
|
|
15
|
+
"type": "yarn tsc --noEmit"
|
|
16
|
+
}
|
|
17
|
+
}
|