socket-function 1.1.40 → 1.1.42
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.d.ts +10 -7
- package/SocketFunction.ts +55 -23
- package/index.d.ts +33 -7
- package/package.json +1 -1
- package/src/callHTTPHandler.ts +13 -7
- package/src/callManager.ts +10 -6
- package/src/createSingleton.d.ts +19 -0
- package/src/createSingleton.ts +76 -0
- package/src/nodeCache.ts +5 -2
package/SocketFunction.d.ts
CHANGED
|
@@ -37,8 +37,10 @@ export declare class SocketFunction {
|
|
|
37
37
|
static GET_ALTERNATE_NODE_IDS: (nodeId: string) => MaybePromise<string[] | undefined>;
|
|
38
38
|
static WIRE_WARN_TIME: number;
|
|
39
39
|
private static onMountCallbacks;
|
|
40
|
-
static
|
|
41
|
-
static
|
|
40
|
+
private static exposedClassesSingleton;
|
|
41
|
+
static get exposedClasses(): Set<string>;
|
|
42
|
+
static get callerContext(): CallerContext | undefined;
|
|
43
|
+
static set callerContext(value: CallerContext | undefined);
|
|
42
44
|
static getCaller(): CallerContext;
|
|
43
45
|
static harvestFailedCallCount: () => number;
|
|
44
46
|
static getPendingCallCount: () => number;
|
|
@@ -82,12 +84,13 @@ export declare class SocketFunction {
|
|
|
82
84
|
* to add additional imports to ensure the register call runs.
|
|
83
85
|
*/
|
|
84
86
|
static expose(socketRegistered: SocketRegistered): void;
|
|
85
|
-
static
|
|
87
|
+
private static mountState;
|
|
88
|
+
static get mountedNodeId(): string;
|
|
89
|
+
static set mountedNodeId(value: string);
|
|
86
90
|
static isMounted(): boolean;
|
|
87
|
-
static mountedIP: string;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
static mountPromise: Promise<void>;
|
|
91
|
+
static get mountedIP(): string;
|
|
92
|
+
static set mountedIP(value: string);
|
|
93
|
+
static get mountPromise(): Promise<void>;
|
|
91
94
|
static mount(config: SocketServerConfig): Promise<string>;
|
|
92
95
|
/** Sets the default call when an http request is made, but no classGuid is set.
|
|
93
96
|
* NOTE: All other calls should be endpoint calls, even if those endpoints return a static file with an HTML content type.
|
package/SocketFunction.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { Args, MaybePromise } from "./src/types";
|
|
|
9
9
|
import { setDefaultHTTPCall } from "./src/callHTTPHandler";
|
|
10
10
|
import debugbreak from "debugbreak";
|
|
11
11
|
import { lazy } from "./src/caching";
|
|
12
|
+
import { createSingleton } from "./src/createSingleton";
|
|
12
13
|
import { delay } from "./src/batching";
|
|
13
14
|
import { blue, magenta } from "./src/formatting/logColors";
|
|
14
15
|
import { JSONLACKS } from "./src/JSONLACKS/JSONLACKS";
|
|
@@ -36,6 +37,14 @@ if (isNode()) {
|
|
|
36
37
|
|
|
37
38
|
module.allowclient = true;
|
|
38
39
|
|
|
40
|
+
// The active call's caller, plus a sequence number used to reset it synchronously (see
|
|
41
|
+
// _setSocketContext). Shared across copies of this package, so SocketFunction.getCaller()
|
|
42
|
+
// returns the right caller even when the call was set up by a different copy. See createSingleton.
|
|
43
|
+
const socketContext = createSingleton("SocketFunction.socketContext", 1, () => ({
|
|
44
|
+
seqNum: 1,
|
|
45
|
+
caller: undefined as CallerContext | undefined,
|
|
46
|
+
}));
|
|
47
|
+
|
|
39
48
|
type ExtractShape<ClassType, Shape> = {
|
|
40
49
|
[key in keyof ClassType]: (
|
|
41
50
|
key extends keyof Shape
|
|
@@ -89,10 +98,16 @@ export class SocketFunction {
|
|
|
89
98
|
|
|
90
99
|
public static WIRE_WARN_TIME = 100;
|
|
91
100
|
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
// Shared across copies of this package, so onMount callbacks registered through one copy still
|
|
102
|
+
// fire when another copy mounts. See createSingleton.
|
|
103
|
+
private static onMountCallbacks = createSingleton("SocketFunction.onMountCallbacks", 1, () => new Map<string, (() => MaybePromise<void>)[]>()).get();
|
|
104
|
+
// Shared across copies of this package, so the set of exposed classes is consistent regardless
|
|
105
|
+
// of which copy a class was exposed through. See createSingleton.
|
|
106
|
+
private static exposedClassesSingleton = createSingleton("SocketFunction.exposedClasses", 1, () => new Set<string>());
|
|
107
|
+
public static get exposedClasses() { return SocketFunction.exposedClassesSingleton.get(); }
|
|
94
108
|
|
|
95
|
-
public static callerContext: CallerContext | undefined;
|
|
109
|
+
public static get callerContext(): CallerContext | undefined { return socketContext.get().caller; }
|
|
110
|
+
public static set callerContext(value: CallerContext | undefined) { socketContext.get().caller = value; }
|
|
96
111
|
public static getCaller(): CallerContext {
|
|
97
112
|
const caller = SocketFunction.callerContext;
|
|
98
113
|
if (!caller) throw new Error(`Tried to access caller when not in the synchronous phase of a function call`);
|
|
@@ -215,7 +230,9 @@ export class SocketFunction {
|
|
|
215
230
|
return Object.assign(socketCaller, config?.statics) as any;
|
|
216
231
|
}
|
|
217
232
|
|
|
218
|
-
|
|
233
|
+
// Shared across copies of this package, so a given classGuid resolves to one canonical caller
|
|
234
|
+
// regardless of which copy rehydrated it. See createSingleton.
|
|
235
|
+
private static socketCache = createSingleton("SocketFunction.socketCache", 1, () => new Map<string, SocketRegistered>()).get();
|
|
219
236
|
public static rehydrateSocketCaller<Controller>(
|
|
220
237
|
socketRegistered: SocketRegisterType<Controller>,
|
|
221
238
|
// Shape is required for client hooks.
|
|
@@ -359,13 +376,15 @@ export class SocketFunction {
|
|
|
359
376
|
return url.toString();
|
|
360
377
|
}
|
|
361
378
|
|
|
362
|
-
|
|
379
|
+
// Shared across copies of this package, so suppressing expose calls in one copy also suppresses
|
|
380
|
+
// them in the others. See createSingleton.
|
|
381
|
+
private static ignoreExposeCount = createSingleton("SocketFunction.ignoreExposeCount", 1, () => ({ count: 0 }));
|
|
363
382
|
public static async ignoreExposeCalls<T>(code: () => Promise<T>) {
|
|
364
|
-
|
|
383
|
+
SocketFunction.ignoreExposeCount.get().count++;
|
|
365
384
|
try {
|
|
366
385
|
return await code();
|
|
367
386
|
} finally {
|
|
368
|
-
|
|
387
|
+
SocketFunction.ignoreExposeCount.get().count--;
|
|
369
388
|
}
|
|
370
389
|
}
|
|
371
390
|
|
|
@@ -374,7 +393,7 @@ export class SocketFunction {
|
|
|
374
393
|
* to add additional imports to ensure the register call runs.
|
|
375
394
|
*/
|
|
376
395
|
public static expose(socketRegistered: SocketRegistered) {
|
|
377
|
-
if (
|
|
396
|
+
if (SocketFunction.ignoreExposeCount.get().count > 0) return;
|
|
378
397
|
if (!socketRegistered._classGuid) {
|
|
379
398
|
throw new Error("SocketFunction.expose must be called with a classGuid");
|
|
380
399
|
}
|
|
@@ -382,7 +401,7 @@ export class SocketFunction {
|
|
|
382
401
|
exposeClass(socketRegistered);
|
|
383
402
|
this.exposedClasses.add(socketRegistered._classGuid);
|
|
384
403
|
|
|
385
|
-
if (
|
|
404
|
+
if (SocketFunction.mountState.get().hasMounted) {
|
|
386
405
|
let mountCallbacks = SocketFunction.onMountCallbacks.get(socketRegistered._classGuid);
|
|
387
406
|
for (let onMount of mountCallbacks || []) {
|
|
388
407
|
Promise.resolve(onMount()).catch(e => {
|
|
@@ -392,12 +411,26 @@ export class SocketFunction {
|
|
|
392
411
|
}
|
|
393
412
|
}
|
|
394
413
|
|
|
395
|
-
|
|
414
|
+
// All mount state is shared across copies of this package, so a second mount (even from a
|
|
415
|
+
// different copy) is rejected, and onMount/mountPromise observers fire once across them all.
|
|
416
|
+
// See createSingleton.
|
|
417
|
+
private static mountState = createSingleton("SocketFunction.mountState", 1, () => {
|
|
418
|
+
let mountResolve!: () => void;
|
|
419
|
+
let mountPromise = new Promise<void>(resolve => mountResolve = resolve);
|
|
420
|
+
return {
|
|
421
|
+
hasMounted: false,
|
|
422
|
+
mountedNodeId: "",
|
|
423
|
+
mountedIP: "",
|
|
424
|
+
mountResolve,
|
|
425
|
+
mountPromise,
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
public static get mountedNodeId() { return SocketFunction.mountState.get().mountedNodeId; }
|
|
429
|
+
public static set mountedNodeId(value: string) { SocketFunction.mountState.get().mountedNodeId = value; }
|
|
396
430
|
public static isMounted() { return !!this.mountedNodeId; }
|
|
397
|
-
public static mountedIP
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
public static mountPromise: Promise<void> = new Promise(r => this.onMountCallback = r);
|
|
431
|
+
public static get mountedIP() { return SocketFunction.mountState.get().mountedIP; }
|
|
432
|
+
public static set mountedIP(value: string) { SocketFunction.mountState.get().mountedIP = value; }
|
|
433
|
+
public static get mountPromise() { return SocketFunction.mountState.get().mountPromise; }
|
|
401
434
|
public static async mount(config: SocketServerConfig) {
|
|
402
435
|
if (this.mountedNodeId) {
|
|
403
436
|
throw new Error("SocketFunction already mounted, mounting twice in one thread is not allowed.");
|
|
@@ -416,7 +449,7 @@ export class SocketFunction {
|
|
|
416
449
|
await delay("immediate");
|
|
417
450
|
|
|
418
451
|
this.mountedNodeId = await startSocketServer(config);
|
|
419
|
-
|
|
452
|
+
SocketFunction.mountState.get().hasMounted = true;
|
|
420
453
|
for (let classGuid of SocketFunction.exposedClasses) {
|
|
421
454
|
let callbacks = SocketFunction.onMountCallbacks.get(classGuid);
|
|
422
455
|
if (!callbacks) continue;
|
|
@@ -424,7 +457,7 @@ export class SocketFunction {
|
|
|
424
457
|
await callback();
|
|
425
458
|
}
|
|
426
459
|
}
|
|
427
|
-
|
|
460
|
+
SocketFunction.mountState.get().mountResolve();
|
|
428
461
|
return this.mountedNodeId;
|
|
429
462
|
}
|
|
430
463
|
|
|
@@ -484,20 +517,19 @@ function getBootedEdgeNode() {
|
|
|
484
517
|
}
|
|
485
518
|
|
|
486
519
|
|
|
487
|
-
let socketContextSeqNum = 1;
|
|
488
|
-
|
|
489
520
|
export function _setSocketContext<T>(
|
|
490
521
|
caller: CallerContext,
|
|
491
522
|
code: () => T,
|
|
492
523
|
) {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
524
|
+
let ctx = socketContext.get();
|
|
525
|
+
ctx.seqNum++;
|
|
526
|
+
let seqNum = ctx.seqNum;
|
|
527
|
+
ctx.caller = caller;
|
|
496
528
|
try {
|
|
497
529
|
return code();
|
|
498
530
|
} finally {
|
|
499
|
-
if (seqNum ===
|
|
500
|
-
|
|
531
|
+
if (seqNum === ctx.seqNum) {
|
|
532
|
+
ctx.caller = undefined;
|
|
501
533
|
}
|
|
502
534
|
}
|
|
503
535
|
}
|
package/index.d.ts
CHANGED
|
@@ -46,8 +46,10 @@ declare module "socket-function/SocketFunction" {
|
|
|
46
46
|
static GET_ALTERNATE_NODE_IDS: (nodeId: string) => MaybePromise<string[] | undefined>;
|
|
47
47
|
static WIRE_WARN_TIME: number;
|
|
48
48
|
private static onMountCallbacks;
|
|
49
|
-
static
|
|
50
|
-
static
|
|
49
|
+
private static exposedClassesSingleton;
|
|
50
|
+
static get exposedClasses(): Set<string>;
|
|
51
|
+
static get callerContext(): CallerContext | undefined;
|
|
52
|
+
static set callerContext(value: CallerContext | undefined);
|
|
51
53
|
static getCaller(): CallerContext;
|
|
52
54
|
static harvestFailedCallCount: () => number;
|
|
53
55
|
static getPendingCallCount: () => number;
|
|
@@ -91,12 +93,13 @@ declare module "socket-function/SocketFunction" {
|
|
|
91
93
|
* to add additional imports to ensure the register call runs.
|
|
92
94
|
*/
|
|
93
95
|
static expose(socketRegistered: SocketRegistered): void;
|
|
94
|
-
static
|
|
96
|
+
private static mountState;
|
|
97
|
+
static get mountedNodeId(): string;
|
|
98
|
+
static set mountedNodeId(value: string);
|
|
95
99
|
static isMounted(): boolean;
|
|
96
|
-
static mountedIP: string;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
static mountPromise: Promise<void>;
|
|
100
|
+
static get mountedIP(): string;
|
|
101
|
+
static set mountedIP(value: string);
|
|
102
|
+
static get mountPromise(): Promise<void>;
|
|
100
103
|
static mount(config: SocketServerConfig): Promise<string>;
|
|
101
104
|
/** Sets the default call when an http request is made, but no classGuid is set.
|
|
102
105
|
* NOTE: All other calls should be endpoint calls, even if those endpoints return a static file with an HTML content type.
|
|
@@ -816,6 +819,29 @@ declare module "socket-function/src/corsCheck" {
|
|
|
816
819
|
|
|
817
820
|
}
|
|
818
821
|
|
|
822
|
+
declare module "socket-function/src/createSingleton" {
|
|
823
|
+
export interface Singleton<T> {
|
|
824
|
+
get(): T;
|
|
825
|
+
set(value: T): void;
|
|
826
|
+
}
|
|
827
|
+
/** Stores a value on globalThis so it is shared across every copy of socket-function loaded in
|
|
828
|
+
* the process, provided each copy passes the same name and version. Use this only for state
|
|
829
|
+
* that MUST be process-global to stay correct when multiple compatible versions are installed
|
|
830
|
+
* at once - ex, the registry of exposed classes, the node connection cache, and mount state.
|
|
831
|
+
* Regular config and caches that are fine to keep per-copy should NOT use this.
|
|
832
|
+
* - version is part of the identity. Bump it only when the shape of the stored value changes in a
|
|
833
|
+
* way that makes an old and a new copy unable to safely share it. Copies that disagree on the
|
|
834
|
+
* version get separate slots (so they will NOT share), which is the correct outcome, as they
|
|
835
|
+
* can't interoperate - and we warn so the situation is visible.
|
|
836
|
+
* - getDefault runs at most once per name+version, lazily, the first time get() is called.
|
|
837
|
+
* - For a value that is mutated in place (a Map/Set/array/object), just call get() once and keep
|
|
838
|
+
* the reference. For a value that gets reassigned (a primitive, or a whole-object swap), use
|
|
839
|
+
* get()/set() at each access so every copy sees the latest.
|
|
840
|
+
*/
|
|
841
|
+
export declare function createSingleton<T>(name: string, version: string | number, getDefault: () => T): Singleton<T>;
|
|
842
|
+
|
|
843
|
+
}
|
|
844
|
+
|
|
819
845
|
declare module "socket-function/src/fixLargeNetworkCalls" {
|
|
820
846
|
export declare function markArrayAsSplitable<T>(data: T[]): T[];
|
|
821
847
|
export declare function isSplitableArray<T>(data: T): data is T & (unknown[]);
|
package/package.json
CHANGED
package/src/callHTTPHandler.ts
CHANGED
|
@@ -7,11 +7,14 @@ import { gzip } from "zlib";
|
|
|
7
7
|
import zlib from "zlib";
|
|
8
8
|
import { formatNumberSuffixed, getRootDomain, sha256Hash } from "./misc";
|
|
9
9
|
import { getClientNodeId, getNodeId } from "./nodeCache";
|
|
10
|
+
import { createSingleton } from "./createSingleton";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
// Shared across copies of this package, so the default HTTP call set through one copy is used by
|
|
13
|
+
// the HTTP handler of any other copy. See createSingleton.
|
|
14
|
+
const defaultHTTPCall = createSingleton("callHTTPHandler.defaultHTTPCall", 1, () => ({ call: undefined as CallType | undefined }));
|
|
12
15
|
|
|
13
16
|
export function setDefaultHTTPCall(call: CallType) {
|
|
14
|
-
defaultHTTPCall = call;
|
|
17
|
+
defaultHTTPCall.get().call = call;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export function getServerLocationFromRequest(request: http.IncomingMessage) {
|
|
@@ -49,7 +52,9 @@ export function getNodeIdsFromRequest(request: http.IncomingMessage) {
|
|
|
49
52
|
return { nodeId, localNodeId };
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
// Shared across copies of this package, so getCurrentHTTPRequest works even when the call was set
|
|
56
|
+
// up by a different copy's HTTP handler. See createSingleton.
|
|
57
|
+
const requests = createSingleton("callHTTPHandler.requests", 1, () => new Map<CallerContext, http.IncomingMessage>()).get();
|
|
53
58
|
export function getCurrentHTTPRequest(): http.IncomingMessage | undefined {
|
|
54
59
|
return requests.get(SocketFunction.getCaller());
|
|
55
60
|
}
|
|
@@ -139,10 +144,11 @@ export async function httpCallHandler(request: http.IncomingMessage, response: h
|
|
|
139
144
|
let args: string | unknown[] | null = urlObj.searchParams.get("args");
|
|
140
145
|
|
|
141
146
|
if (!classGuid) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
147
|
+
let defaultCall = defaultHTTPCall.get().call;
|
|
148
|
+
if (defaultCall) {
|
|
149
|
+
classGuid = defaultCall.classGuid;
|
|
150
|
+
functionName = defaultCall.functionName;
|
|
151
|
+
args = defaultCall.args;
|
|
146
152
|
} else {
|
|
147
153
|
throw new Error("Missing classGuid in URL query");
|
|
148
154
|
}
|
package/src/callManager.ts
CHANGED
|
@@ -5,17 +5,20 @@ import { entries, isNode } from "./misc";
|
|
|
5
5
|
import debugbreak from "debugbreak";
|
|
6
6
|
import { measureBlock, measureWrap } from "./profiling/measure";
|
|
7
7
|
import { formatTime } from "./formatting/format";
|
|
8
|
+
import { createSingleton } from "./createSingleton";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
// Shared across copies of this package, so a call routed through one copy can find a class
|
|
11
|
+
// (and its hooks) registered through another copy. See createSingleton.
|
|
12
|
+
const classes = createSingleton("callManager.classes", 1, () => ({} as {
|
|
10
13
|
[classGuid: string]: {
|
|
11
14
|
controller: SocketExposedInterface;
|
|
12
15
|
shape: SocketExposedShape;
|
|
13
16
|
}
|
|
14
|
-
}
|
|
15
|
-
|
|
17
|
+
})).get();
|
|
18
|
+
const exposedClasses = createSingleton("callManager.exposedClasses", 1, () => new Set<string>()).get();
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
const globalHooks = createSingleton("callManager.globalHooks", 1, () => [] as SocketFunctionHook[]).get();
|
|
21
|
+
const globalClientHooks = createSingleton("callManager.globalClientHooks", 1, () => [] as SocketFunctionClientHook[]).get();
|
|
19
22
|
|
|
20
23
|
export function getCallFlags(call: CallType): FunctionFlags | undefined {
|
|
21
24
|
return classes[call.classGuid]?.shape[call.functionName];
|
|
@@ -83,7 +86,8 @@ export function registerClass(classGuid: string, controller: SocketExposedInterf
|
|
|
83
86
|
noFunctionMeasure?: boolean;
|
|
84
87
|
}) {
|
|
85
88
|
if (!globalThis.isHotReloading?.() && classes[classGuid]) {
|
|
86
|
-
|
|
89
|
+
console.warn(`Class ${classGuid} already registered. This is normal if you are using multiple copies of socket-function at once.`);
|
|
90
|
+
return;
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
if (!config?.noFunctionMeasure) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Singleton<T> {
|
|
2
|
+
get(): T;
|
|
3
|
+
set(value: T): void;
|
|
4
|
+
}
|
|
5
|
+
/** Stores a value on globalThis so it is shared across every copy of socket-function loaded in
|
|
6
|
+
* the process, provided each copy passes the same name and version. Use this only for state
|
|
7
|
+
* that MUST be process-global to stay correct when multiple compatible versions are installed
|
|
8
|
+
* at once - ex, the registry of exposed classes, the node connection cache, and mount state.
|
|
9
|
+
* Regular config and caches that are fine to keep per-copy should NOT use this.
|
|
10
|
+
* - version is part of the identity. Bump it only when the shape of the stored value changes in a
|
|
11
|
+
* way that makes an old and a new copy unable to safely share it. Copies that disagree on the
|
|
12
|
+
* version get separate slots (so they will NOT share), which is the correct outcome, as they
|
|
13
|
+
* can't interoperate - and we warn so the situation is visible.
|
|
14
|
+
* - getDefault runs at most once per name+version, lazily, the first time get() is called.
|
|
15
|
+
* - For a value that is mutated in place (a Map/Set/array/object), just call get() once and keep
|
|
16
|
+
* the reference. For a value that gets reassigned (a primitive, or a whole-object swap), use
|
|
17
|
+
* get()/set() at each access so every copy sees the latest.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createSingleton<T>(name: string, version: string | number, getDefault: () => T): Singleton<T>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// A namespace on globalThis that holds state shared across every copy of this package that
|
|
2
|
+
// happens to be loaded in the same process. When two (type-compatible) versions of
|
|
3
|
+
// socket-function end up installed at once - ex, as nested dependencies - each gets its own
|
|
4
|
+
// set of module instances, so module-level registries (exposed classes, the node connection
|
|
5
|
+
// cache, mount state, ...) would otherwise be split into disconnected copies, and a call
|
|
6
|
+
// routed through one copy wouldn't see a class registered through the other. Every copy of
|
|
7
|
+
// this file references the same string literal below, so they all read and write the same
|
|
8
|
+
// underlying store, letting compatible versions share that state instead.
|
|
9
|
+
const GLOBAL_KEY = "__socketFunctionSingletons__";
|
|
10
|
+
|
|
11
|
+
interface SingletonStore {
|
|
12
|
+
values: { [key: string]: unknown };
|
|
13
|
+
// name => the set of versions that have been requested, so we can warn when incompatible
|
|
14
|
+
// versions are loaded together (which is the situation that makes sharing impossible).
|
|
15
|
+
versions: { [name: string]: Set<string> };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getStore(): SingletonStore {
|
|
19
|
+
let store = (globalThis as any)[GLOBAL_KEY] as SingletonStore | undefined;
|
|
20
|
+
if (!store) {
|
|
21
|
+
store = { values: Object.create(null), versions: Object.create(null) };
|
|
22
|
+
(globalThis as any)[GLOBAL_KEY] = store;
|
|
23
|
+
}
|
|
24
|
+
return store;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Singleton<T> {
|
|
28
|
+
get(): T;
|
|
29
|
+
set(value: T): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Stores a value on globalThis so it is shared across every copy of socket-function loaded in
|
|
33
|
+
* the process, provided each copy passes the same name and version. Use this only for state
|
|
34
|
+
* that MUST be process-global to stay correct when multiple compatible versions are installed
|
|
35
|
+
* at once - ex, the registry of exposed classes, the node connection cache, and mount state.
|
|
36
|
+
* Regular config and caches that are fine to keep per-copy should NOT use this.
|
|
37
|
+
* - version is part of the identity. Bump it only when the shape of the stored value changes in a
|
|
38
|
+
* way that makes an old and a new copy unable to safely share it. Copies that disagree on the
|
|
39
|
+
* version get separate slots (so they will NOT share), which is the correct outcome, as they
|
|
40
|
+
* can't interoperate - and we warn so the situation is visible.
|
|
41
|
+
* - getDefault runs at most once per name+version, lazily, the first time get() is called.
|
|
42
|
+
* - For a value that is mutated in place (a Map/Set/array/object), just call get() once and keep
|
|
43
|
+
* the reference. For a value that gets reassigned (a primitive, or a whole-object swap), use
|
|
44
|
+
* get()/set() at each access so every copy sees the latest.
|
|
45
|
+
*/
|
|
46
|
+
export function createSingleton<T>(
|
|
47
|
+
name: string,
|
|
48
|
+
version: string | number,
|
|
49
|
+
getDefault: () => T,
|
|
50
|
+
): Singleton<T> {
|
|
51
|
+
const store = getStore();
|
|
52
|
+
const versionStr = String(version);
|
|
53
|
+
const key = `${name}@${versionStr}`;
|
|
54
|
+
|
|
55
|
+
let seenVersions = store.versions[name];
|
|
56
|
+
if (!seenVersions) {
|
|
57
|
+
seenVersions = store.versions[name] = new Set();
|
|
58
|
+
}
|
|
59
|
+
seenVersions.add(versionStr);
|
|
60
|
+
if (seenVersions.size > 1) {
|
|
61
|
+
console.warn(`socket-function: singleton ${JSON.stringify(name)} requested at multiple versions (${Array.from(seenVersions).join(", ")}). These copies will NOT share state, which usually means incompatible versions of a package are loaded at once.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
get(): T {
|
|
66
|
+
const values = store.values;
|
|
67
|
+
if (!(key in values)) {
|
|
68
|
+
values[key] = getDefault();
|
|
69
|
+
}
|
|
70
|
+
return values[key] as T;
|
|
71
|
+
},
|
|
72
|
+
set(value: T) {
|
|
73
|
+
store.values[key] = value;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/nodeCache.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { MaybePromise } from "./types";
|
|
|
3
3
|
import { lazy } from "./caching";
|
|
4
4
|
import { SocketFunction } from "../SocketFunction";
|
|
5
5
|
import { isNode } from "./misc";
|
|
6
|
+
import { createSingleton } from "./createSingleton";
|
|
6
7
|
|
|
7
8
|
// TODO: Add CallInstanceFactory.isClosed, so nodeCache can clean up old entries.
|
|
8
9
|
// This is only needed for memory management, and not for correctness. Entries never
|
|
@@ -67,8 +68,10 @@ export function getNodeIdDomainMaybeUndefined(nodeId: string): string | undefine
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
// NOTE: CallFactory turns into an actual CallFactory when registerNodeClient is called
|
|
70
|
-
// nodeId =>
|
|
71
|
-
|
|
71
|
+
// nodeId =>
|
|
72
|
+
// Shared across copies of this package, so connections established through one copy are reused
|
|
73
|
+
// by the others (instead of each copy opening its own duplicate connection). See createSingleton.
|
|
74
|
+
const nodeCache = createSingleton("nodeCache", 1, () => new Map<string, MaybePromise<CallFactory>>()).get();
|
|
72
75
|
|
|
73
76
|
// NOTE: Should be called directly inside call factory constructor whenever
|
|
74
77
|
// their nodeId changes (and on construction).
|