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.
@@ -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 exposedClasses: Set<string>;
41
- static callerContext: CallerContext | undefined;
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 mountedNodeId: string;
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
- private static hasMounted;
89
- private static onMountCallback;
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
- private static onMountCallbacks = new Map<string, (() => MaybePromise<void>)[]>();
93
- public static exposedClasses = new Set<string>();
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
- private static socketCache = new Map<string, SocketRegistered>();
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
- private static ignoreExposeCount = 0;
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
- this.ignoreExposeCount++;
383
+ SocketFunction.ignoreExposeCount.get().count++;
365
384
  try {
366
385
  return await code();
367
386
  } finally {
368
- this.ignoreExposeCount--;
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 (this.ignoreExposeCount > 0) return;
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 (this.hasMounted) {
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
- public static mountedNodeId: string = "";
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: string = "";
398
- private static hasMounted = false;
399
- private static onMountCallback: () => void = () => { };
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
- this.hasMounted = true;
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
- this.onMountCallback();
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
- socketContextSeqNum++;
494
- let seqNum = socketContextSeqNum;
495
- SocketFunction.callerContext = caller;
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 === socketContextSeqNum) {
500
- SocketFunction.callerContext = undefined;
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 exposedClasses: Set<string>;
50
- static callerContext: CallerContext | undefined;
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 mountedNodeId: string;
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
- private static hasMounted;
98
- private static onMountCallback;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socket-function",
3
- "version": "1.1.40",
3
+ "version": "1.1.42",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "dependencies": {
@@ -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
- let defaultHTTPCall: CallType | undefined;
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
- let requests = new Map<CallerContext, http.IncomingMessage>();
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
- if (defaultHTTPCall) {
143
- classGuid = defaultHTTPCall.classGuid;
144
- functionName = defaultHTTPCall.functionName;
145
- args = defaultHTTPCall.args;
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
  }
@@ -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
- let classes: {
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
- let exposedClasses = new Set<string>();
17
+ })).get();
18
+ const exposedClasses = createSingleton("callManager.exposedClasses", 1, () => new Set<string>()).get();
16
19
 
17
- let globalHooks: SocketFunctionHook[] = [];
18
- let globalClientHooks: SocketFunctionClientHook[] = [];
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
- throw new Error(`Class ${classGuid} already registered`);
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
- const nodeCache = new Map<string, MaybePromise<CallFactory>>();
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).