socket-function 0.12.16 → 0.13.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/SocketFunction.ts CHANGED
@@ -91,6 +91,13 @@ export class SocketFunction {
91
91
  /** @noAutoExpose If true SocketFunction.expose(Controller) must be called explicitly. */
92
92
  noAutoExpose?: boolean;
93
93
  statics?: Statics;
94
+ /** Skip timing functions calls. Useful if a lot of functions have wait time that
95
+ is unrelated to processing, and therefore their timings won't be useful.
96
+ - Also useful if our auto function wrapping code is breaking functionality,
97
+ such as if you have a singleton function which you compare with ===,
98
+ which will breaks because we replaced it with a wrapped measure function.
99
+ */
100
+ noFunctionMeasure?: boolean;
94
101
  }
95
102
  ): SocketRegistered<ExtractShape<ClassInstance, Shape>> & Statics {
96
103
  let getDefaultHooks = defaultHooksFnc && lazy(defaultHooksFnc);
@@ -101,14 +108,20 @@ export class SocketFunction {
101
108
  for (let value of Object.values(shape)) {
102
109
  if (!value) continue;
103
110
  value.clientHooks = [...(defaultHooks?.clientHooks || []), ...(value.clientHooks || [])];
104
- value.hooks = [...(defaultHooks?.hooks || []), ...(value.hooks || [])];
111
+ if (value.noDefaultHooks) {
112
+ value.hooks = [...(value.hooks || [])];
113
+ } else {
114
+ value.hooks = [...(defaultHooks?.hooks || []), ...(value.hooks || [])];
115
+ }
105
116
  value.dataImmutable = defaultHooks?.dataImmutable ?? value.dataImmutable;
106
117
  }
107
118
  return shape as any as SocketExposedShape;
108
119
  });
109
120
 
110
121
  void Promise.resolve().then(() => {
111
- registerClass(classGuid, instance as SocketExposedInterface, getShape());
122
+ registerClass(classGuid, instance as SocketExposedInterface, getShape(), {
123
+ noFunctionMeasure: config?.noFunctionMeasure,
124
+ });
112
125
  });
113
126
 
114
127
  let nodeProxy = getCallProxy(classGuid, async (call) => {
@@ -22,15 +22,22 @@ export type SocketExposedInterfaceClass = {
22
22
  new(): unknown;
23
23
  prototype: unknown;
24
24
  };
25
+ export type FunctionFlags = {
26
+ compress?: boolean;
27
+
28
+ /** Indicates with the same input, we give the same output, forever,
29
+ * independent of code changes. This only works for data storage.
30
+ */
31
+ dataImmutable?: boolean;
32
+
33
+ /** Allows overriding SocketFunction.MAX_MESSAGE_SIZE for responses from this function. */
34
+ responseLimit?: number;
35
+ };
25
36
  export type SocketExposedShape<ExposedType extends SocketExposedInterface = SocketExposedInterface> = {
26
- [functionName in keyof ExposedType]?: {
27
- compress?: boolean;
28
- /** Indicates with the same input, we give the same output, forever,
29
- * independent of code changes. This only works for data storage.
30
- */
31
- dataImmutable?: boolean;
37
+ [functionName in keyof ExposedType]?: FunctionFlags & {
32
38
  hooks?: SocketFunctionHook<ExposedType>[];
33
39
  clientHooks?: SocketFunctionClientHook<ExposedType>[];
40
+ noDefaultHooks?: boolean;
34
41
  };
35
42
  };
36
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socket-function",
3
- "version": "0.12.16",
3
+ "version": "0.13.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -14,7 +14,7 @@
14
14
  "pako": "^2.1.0",
15
15
  "preact": "^10.10.6",
16
16
  "typenode": "^5.4.1",
17
- "ws": "^8.8.0"
17
+ "ws": "^8.17.1"
18
18
  },
19
19
  "optionalDependencies": {
20
20
  "rdtsc-now": "^0.4.2"
@@ -1,7 +1,7 @@
1
1
  import { CallerContext, CallerContextBase, CallType, FullCallType } from "../SocketFunctionTypes";
2
2
  import * as ws from "ws";
3
- import { performLocalCall, shouldCompressCall } from "./callManager";
4
- import { convertErrorStackToError, formatNumberSuffixed, isNode, list } from "./misc";
3
+ import { getCallFlags, performLocalCall, shouldCompressCall } from "./callManager";
4
+ import { convertErrorStackToError, formatNumberSuffixed, isBufferType, isNode, list } from "./misc";
5
5
  import { createWebsocketFactory, getTLSSocket } from "./websocketFactory";
6
6
  import { SocketFunction } from "../SocketFunction";
7
7
  import * as tls from "tls";
@@ -12,8 +12,11 @@ import { red, yellow } from "./formatting/logColors";
12
12
  import { isSplitableArray, markArrayAsSplitable } from "./fixLargeNetworkCalls";
13
13
  import { delay, runInSerial } from "./batching";
14
14
  import { formatNumber, formatTime } from "./formatting/format";
15
+ import zlib from "zlib";
15
16
  import pako from "pako";
16
17
  import { setFlag } from "../require/compileFlags";
18
+ import { measureFnc, measureWrap } from "./profiling/measure";
19
+ import { MaybePromise } from "./types";
17
20
  setFlag(require, "pako", "allowclient", true);
18
21
 
19
22
  // NOTE: If it is too low, and too many servers disconnect, we can easily spend 100% of our time
@@ -51,7 +54,7 @@ export interface CallFactory {
51
54
  export interface SenderInterface {
52
55
  nodeId?: string;
53
56
  // Only set AFTER "open" (if set at all, as in the browser we don't have access to the socket).
54
- socket?: tls.TLSSocket;
57
+ _socket?: tls.TLSSocket;
55
58
 
56
59
  send(data: string | Buffer): void;
57
60
 
@@ -201,8 +204,11 @@ export async function createCallFactory(
201
204
  console.log(`SIZE\t${(formatNumberSuffixed(resultSize) + "B").padEnd(4, " ")}\tREMOTE CALL\t${call.classGuid}.${call.functionName}${fncHack} at ${Date.now()}`);
202
205
  }
203
206
  }
204
- await send(data);
205
-
207
+ // If sending OR resultPromise throws, we want to error out. This solves some issues with resultPromise
208
+ // erroring out first, which is before we await it, which makes NodeJS angry (unhandled promise rejection).
209
+ // Also, technically, we could receive the result before we finish sending, in which case, we might
210
+ // as well return it immediately.
211
+ await Promise.race([send(data), resultPromise]);
206
212
  return await resultPromise;
207
213
  }
208
214
  };
@@ -246,12 +252,12 @@ export async function createCallFactory(
246
252
  // NOTE: No more logging, as we throw, so the caller should be logging the
247
253
  // error (or swallowing it, if that is what it wants to do).
248
254
  //console.log(`Websocket error for ${niceConnectionName}`, e.message);
249
- onClose(`Connection error for ${niceConnectionName}: ${e.message}`);
255
+ onClose(new Error(`Connection error for ${niceConnectionName}: ${e.message}`).stack!);
250
256
  });
251
257
 
252
258
  newWebSocket.addEventListener("close", async () => {
253
259
  //console.log(`Websocket closed ${niceConnectionName}`);
254
- onClose(`Connection closed to ${niceConnectionName}`);
260
+ onClose(new Error(`Connection closed to ${niceConnectionName}`).stack!);
255
261
  });
256
262
 
257
263
  newWebSocket.addEventListener("message", onMessage);
@@ -269,9 +275,10 @@ export async function createCallFactory(
269
275
  newWebSocket.addEventListener("close", () => resolve());
270
276
  newWebSocket.addEventListener("error", () => resolve());
271
277
  });
272
- } else if (newWebSocket.readyState !== 1 /* OPEN */) {
273
- onClose(`Websocket received in closed state`);
278
+ } else if (newWebSocket.readyState === 1 /* OPEN */) {
274
279
  callFactory.isConnected = true;
280
+ } else {
281
+ onClose(new Error(`Websocket received in closed state`).stack!);
275
282
  }
276
283
  }
277
284
 
@@ -285,6 +292,7 @@ export async function createCallFactory(
285
292
  bufferLengths?: number[];
286
293
  metadata: Omit<InternalReturnType, "result">;
287
294
  };
295
+ let sendInSerial = runInSerial(async (val: () => Promise<void>) => val());
288
296
  async function sendRaw(data: (string | Buffer)[]) {
289
297
  if (!webSocketPromise) {
290
298
  if (canReconnect) {
@@ -294,9 +302,30 @@ export async function createCallFactory(
294
302
  }
295
303
  }
296
304
  let webSocket = await webSocketPromise;
297
- for (let d of data) {
298
- webSocket.send(d);
299
- }
305
+ await sendInSerial(async () => {
306
+ for (let d of data) {
307
+ if (d.length > 1000 * 1000 * 10) {
308
+ console.log(`Sending large packet ${formatNumber(d.length)}B to ${nodeId} at ${Date.now()}`);
309
+ }
310
+
311
+ // NOTE: If our latency is 500ms, with 10MB/s, then we need a high water
312
+ // mark of at least 5MB, otherwise our connection is slowed down.
313
+ // - Using the actual high water mark is too difficult, as we receive incoming connections.
314
+ // This is also easier to configure, and we can dynamically change it if we have to.
315
+ // NOTE: In practice we only hit this when sending large Buffers (~30MB), so low values
316
+ // are equivalent to waiting for drain. We want to avoid waiting for drain, so we use a high value.
317
+ const maxWriteBuffer = 128 * 1024 * 1024;
318
+ webSocket.send(d);
319
+
320
+ let socket = webSocket._socket;
321
+ if (socket) {
322
+ while (socket.writableLength > maxWriteBuffer) {
323
+ // NOTE: Waiting 1ms probably waits more like 16ms.
324
+ await new Promise(r => setTimeout(r, 1));
325
+ }
326
+ }
327
+ }
328
+ });
300
329
  }
301
330
  async function send(data: Buffer[]) {
302
331
  await sendRaw([
@@ -321,6 +350,7 @@ export async function createCallFactory(
321
350
  }
322
351
  }
323
352
  data = fitBuffers;
353
+ header.bufferCount = fitBuffers.length;
324
354
  } else {
325
355
  throw new Error(`Cannot send large amounts of data unless we are returning Buffer or Buffer[]`);
326
356
  }
@@ -366,7 +396,7 @@ export async function createCallFactory(
366
396
  let lenLeft = len;
367
397
  let buffers: Buffer[] = [];
368
398
  while (lenLeft > 0) {
369
- let buf = currentBuffers.pop();
399
+ let buf = currentBuffers.shift();
370
400
  if (!buf) {
371
401
  throw new Error(`Not enough buffers received.`);
372
402
  }
@@ -413,7 +443,7 @@ export async function createCallFactory(
413
443
  if (call.isReturn) {
414
444
  let callbackObj = pendingCalls.get(call.seqNum);
415
445
  if (time > SocketFunction.WIRE_WARN_TIME) {
416
- console.log(red(`Slow parse, took ${time}ms to parse ${resultSize} bytes, for receieving result of call to ${callbackObj?.call.classGuid}.${callbackObj?.call.functionName}`));
446
+ console.log(red(`Slow parse, took ${time}ms to parse ${resultSize} bytes, for receiving result of call to ${callbackObj?.call.classGuid}.${callbackObj?.call.functionName}`));
417
447
  }
418
448
  if (!callbackObj) {
419
449
  console.log(`Got return for unknown call ${call.seqNum} (created at time ${new Date(call.seqNum)})`);
@@ -468,14 +498,15 @@ export async function createCallFactory(
468
498
  let { result, ...remaining } = response;
469
499
  await sendWithHeader(result, { type: "Buffer[]", bufferCount: result.length, metadata: remaining });
470
500
  } else {
501
+ const LIMIT = getCallFlags(call)?.responseLimit || SocketFunction.MAX_MESSAGE_SIZE * 1.5;
471
502
  let result: Buffer[] = await SocketFunction.WIRE_SERIALIZER.serialize(response);
472
503
  let totalResultSize = result.map(x => x.length).reduce((a, b) => a + b, 0);
473
- if (totalResultSize > SocketFunction.MAX_MESSAGE_SIZE * 1.5) {
504
+ if (totalResultSize > LIMIT) {
474
505
  response = {
475
506
  isReturn: true,
476
507
  result: undefined,
477
508
  seqNum: call.seqNum,
478
- error: new Error(`Response too large to send (${call.classGuid}.${call.functionName}, size: ${formatNumber(totalResultSize)} > ${formatNumber(SocketFunction.MAX_MESSAGE_SIZE)}). If you need to handle very large static data use some external service, such as Backblaze B2 or AWS S3. Or consider fragmenting data at an application level, because sending large data will cause large lag spikes for other clients using this server. Or, if absolutely required, set SocketFunction.MAX_MESSAGE_SIZE to a higher value.`).stack,
509
+ error: new Error(`Response too large to send. Return Buffer[] to exceed the limits, or set responseLimit when registering the collection. ${call.classGuid}.${call.functionName}, size: ${formatNumber(totalResultSize)} > ${formatNumber(SocketFunction.MAX_MESSAGE_SIZE)}. If you need to handle very large static data use some external service, such as Backblaze B2 or AWS S3. Or, if absolutely required, set SocketFunction.MAX_MESSAGE_SIZE to a higher value.`).stack,
479
510
  };
480
511
  result = await SocketFunction.WIRE_SERIALIZER.serialize(response);
481
512
  }
@@ -490,14 +521,18 @@ export async function createCallFactory(
490
521
  if (typeof message === "object" && "data" in message) {
491
522
  message = message.data;
492
523
  }
524
+ // Extra clienside parsing is required
493
525
  if (!isNode()) {
494
- if (message instanceof Blob) {
495
- // We need to force the results to be in serial, otherwise strings leapfrog
496
- // ahead of buffers, which breaks things.
497
- message = Buffer.from(await clientsideSerial(message.arrayBuffer()));
498
- } else {
499
- await clientsideSerial(Promise.resolve());
500
- }
526
+ // Immediately start the arrayBuffer conversion. This should be fast, but...
527
+ // maybe we will add more here, and so doing it in parallel might be useful.
528
+ let fixMessageBlob = (async () => {
529
+ if (message instanceof Blob) {
530
+ message = Buffer.from(await message.arrayBuffer());
531
+ }
532
+ })();
533
+ // We need to force the results to be in serial, otherwise strings leapfrog
534
+ // ahead of buffers, which breaks things.
535
+ await clientsideSerial(fixMessageBlob);
501
536
  }
502
537
  if (typeof message === "string") {
503
538
  if (message.startsWith("{")) {
@@ -530,6 +565,9 @@ export async function createCallFactory(
530
565
  return;
531
566
  }
532
567
  if (message instanceof Buffer) {
568
+ if (message.byteLength > 1000 * 1000 * 10) {
569
+ console.log(`Received large packet ${formatNumber(message.byteLength)}B at ${Date.now()}`);
570
+ }
533
571
  if (!pendingCall) {
534
572
  throw new Error(`Received data without size`);
535
573
  }
@@ -543,11 +581,13 @@ export async function createCallFactory(
543
581
  }
544
582
  throw new Error(`Unhandled data type ${typeof message}`);
545
583
  } catch (e: any) {
546
- let message = e.stack || e.message || e;
584
+ let err = e.stack || e.message || e;
547
585
  // NOTE: I'm looking for all types of errors here (specifically, .send errors), in case
548
586
  // there are errors I should be handling.
549
- if (message.startsWith("Error: Cannot send data to") && message.includes("as the connection has closed")) {
587
+ if (err.startsWith("Error: Cannot send data to") && err.includes("as the connection has closed")) {
550
588
  // This is fine, just ignore it
589
+ } else if (err.includes("The requested file could not be read, typically due to permission problems that have occurred after a reference to a file was acquired.")) {
590
+ console.error(`WebSocket data was dropped by the browser due to exceeding the Blob limit. Either you are about to run out of memory, or you hit the much lower Incognito Blob limit. This will likely break the application. To reset the memory you must close all tabs of this site. This is a bug/feature in chrome.`);
551
591
  } else {
552
592
  debugbreak(2);
553
593
  debugger;
@@ -559,16 +599,64 @@ export async function createCallFactory(
559
599
  return callFactory;
560
600
  }
561
601
 
602
+ async function doStream(stream: GenericTransformStream, buffer: Buffer): Promise<Buffer> {
603
+ let reader = stream.readable.getReader();
604
+ let writer = stream.writable.getWriter();
605
+ let writePromise = writer.write(buffer);
606
+ let closePromise = writer.close();
607
+
608
+ let outputBuffers: Buffer[] = [];
609
+ while (true) {
610
+ let { value, done } = await reader.read();
611
+ if (done) {
612
+ await writePromise;
613
+ await closePromise;
614
+ return Buffer.concat(outputBuffers);
615
+ }
616
+ outputBuffers.push(Buffer.from(value));
617
+ }
618
+ }
619
+ async function unzipBase(buffer: Buffer): Promise<Buffer> {
620
+ if (isNode()) {
621
+ return new Promise((resolve, reject) => {
622
+ zlib.gunzip(buffer, (err: any, result: Buffer) => {
623
+ if (err) reject(err);
624
+ else resolve(result);
625
+ });
626
+ });
627
+ } else {
628
+ // NOTE: pako seems to be faster, at least clientside.
629
+ // TIMING: 700ms vs 1200ms
630
+ // - This might just be faster for small files.
631
+ return Buffer.from(pako.inflate(buffer));
632
+ // @ts-ignore
633
+ // return await doStream(new DecompressionStream("gzip"), buffer);
634
+ }
635
+ }
636
+ async function zipBase(buffer: Buffer, level?: number): Promise<Buffer> {
637
+ if (isNode()) {
638
+ return new Promise((resolve, reject) => {
639
+ zlib.gzip(buffer, { level }, (err: any, result: Buffer) => {
640
+ if (err) reject(err);
641
+ else resolve(result);
642
+ });
643
+ });
644
+ } else {
645
+ // @ts-ignore
646
+ return await doStream(new CompressionStream("gzip"), buffer);
647
+ }
648
+ }
562
649
 
563
- async function compressObj(obj: unknown): Promise<Buffer> {
650
+ const compressObj = measureWrap(async function wireCallCompress(obj: unknown): Promise<Buffer> {
564
651
  let buffers = await SocketFunction.WIRE_SERIALIZER.serialize(obj);
565
652
  let lengthBuffer = Buffer.from((new Float64Array(buffers.map(x => x.length))).buffer);
566
653
  let buffer = Buffer.concat([lengthBuffer, ...buffers]);
567
- return Buffer.from(pako.gzip(buffer));
568
- }
569
- async function decompressObj(obj: Buffer): Promise<unknown> {
654
+ let result = await zipBase(buffer);
655
+ return result;
656
+ });
657
+ const decompressObj = measureWrap(async function wireCallDecompress(obj: Buffer): Promise<unknown> {
570
658
  try {
571
- let buffer = Buffer.from(pako.ungzip(obj));
659
+ let buffer = await unzipBase(obj);
572
660
  let lengthBuffer = buffer.slice(0, 8);
573
661
  let lengths = new Float64Array(lengthBuffer.buffer, lengthBuffer.byteOffset, lengthBuffer.byteLength / 8);
574
662
  let buffers: Buffer[] = [];
@@ -586,4 +674,4 @@ async function decompressObj(obj: Buffer): Promise<unknown> {
586
674
  debugger;
587
675
  throw e;
588
676
  }
589
- }
677
+ });