@encharm/cws 4.10.0 → 4.11.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## Released 4.11.2
2
+ * microdeflate, output unchanged: match symbols write the bit accumulator unconditionally (their sizes vary, so the flush branch mispredicted), literals keep the predictable conditional flush; the 32 KB distance-code table is replaced by a computed code. Profiled with hardware counters on the production EPYC 9454P: 1.84 -> 1.72 µs/KB on the RPC capture (1.58 -> 1.48 on 24 KB messages), 1.13 -> 1.00 on Apple M; incompressible input unchanged. Tried and rejected with measurements: a packed 32 KB hash table (gcc emits partial-register masks on Zen), a single-branch candidate check (an unconditional random load costs more than the mispredicts it saves), `__restrict`, and smaller or larger tables.
3
+
4
+ ## Released 4.11.1
5
+ * microdeflate speed-ups, output unchanged: matches extend 8 bytes per step, the bit writer stores 8 bytes at a time, length/distance codes go out with their extra bits in one write, and each hash slot carries an 8-bit tag so a stale candidate is rejected without touching the input. Measured on the RPC capture: 1.74 -> 1.20 µs/KB (1.45x), byte-identical output. Incompressible input (random bytes) now falls back to stored blocks: 3x faster and 0.999 instead of 0.948 ratio.
6
+
7
+ ## Released 4.11.0
8
+ * Prepared messages for fan-out: `new PreparedMessage(bytes)` copies a payload into native memory once; `ws.send(prepared, { prefix })` sends `prefix + payload` as one frame without copying or compressing the payload per socket. Uncompressed, the payload goes out as a second gather buffer. Compressed (shared compressor mode), the prefix is emitted as a DEFLATE stored block ahead of the payload's deflate blocks, which are built once on the first compressed send and cached on the handle; standard inflaters accept the result unchanged. Sockets with context takeover and client sockets take the regular copying path. Measured with 300 subscribers and a 3 KB payload: 0.9 -> 0.2 µs of JS-thread time per socket, and no per-socket compression at all.
9
+ * `send()` accepts any `ArrayBufferView` (typed arrays are sent from their own offset, no `Buffer.from` copy needed).
10
+
1
11
  ## Released 4.10.0
2
12
  * microdeflate: a built-in ~150-line raw-DEFLATE encoder (fixed Huffman, greedy LZ77, no stream state, no per-message hash reset) now compresses independent messages, i.e. the shared-compressor mode. Measured on a real BSON RPC stream: same ratio as zlib-ng level 1 (2.84 vs 2.85) at ~1.7x its speed; a compressed 2 KB message costs 4.6 µs of worker CPU instead of 6.0. Output is standard DEFLATE (round-trip tested against zlib's inflater). `CWS_MICRO_DEFLATE=0` falls back to zlib-ng; `zlibBackend` reports `+ microdeflate` when active. Dedicated windows (context takeover) and inflate stay on zlib-ng.
3
13
  * Compression moved to the send worker: `send()` of a compressed message queues the raw payload and the worker deflates + frames it. Main-thread cost of a compressed 2 KB RPC message drops from ~6 µs to ~0.7 µs (Linux, per-thread measurement); wire output is byte-identical. Main-thread write paths (same-tick terminate, full worker queue, drain loop after a short write) deflate pending messages themselves first.
package/CLAUDE.md CHANGED
@@ -25,7 +25,7 @@ Tests bind ports 3000 (ws) and 3001 (wss, certs in `tests/certs/`). The test fil
25
25
 
26
26
  1. Download official Node header tarballs for one pinned version per supported major into `targets/` (`VER_115`=Node 20, `VER_127`=Node 22, `VER_137`=Node 24, `VER_147`=Node 26; the number is the Node ABI / `process.versions.modules`).
27
27
  2. Compile `src/*.cpp` once per ABI with `g++`/`cl` directly, with `-I src/headers/$V` for the matching Node major.
28
- 3. Build the vendored zlib-ng (`deps/zlib-ng`, native `zng_` API) once per OS/arch: CMake into `deps/zlib-ng/build-<OS>-<arch>/` on macOS/Linux, `nmake -f win32\Makefile.msc` on Windows. The bindings are compiled with `-DCWS_ZLIB_NG` and link it statically. `src/MicroDeflate.h` is a self-contained fixed-Huffman DEFLATE encoder used for independent messages (shared mode) via `zlib::deflateIndependent`; `src/Zlib.cpp` is the only file that includes a zlib header; without the define (node-gyp fallback) it uses Node's zlib.
28
+ 3. Build the vendored zlib-ng (`deps/zlib-ng`, native `zng_` API) once per OS/arch: CMake into `deps/zlib-ng/build-<OS>-<arch>/` on macOS/Linux, `nmake -f win32\Makefile.msc` on Windows. The bindings are compiled with `-DCWS_ZLIB_NG` and link it statically. `src/MicroDeflate.h` is a self-contained fixed-Huffman DEFLATE encoder used for independent messages (shared mode) via `zlib::deflateIndependent` (greedy LZ77 over a tagged 13-bit hash table, 8-byte match extension, computed distance codes, stored-block fallback when the input does not compress; output must stay byte-identical across speed work, and speed claims need the production EPYC, not only Apple silicon: gcc/Zen and clang/M-series disagreed on every layout change in 4.11.2, only the hybrid bit-writer flush won on both); Measured and rejected on the RPC capture, so do not retry without new data: dynamic Huffman trees per block (+9.4% ratio at 2.4x the time on EPYC), two candidates per hash slot (+2.5% at 1.5x), 3-byte minimum matches (worse ratio), packed 32 KB table with 16-bit tags (gcc partial-register stalls on Zen), single-branch candidate check (unconditional random load), `__restrict`, 12/14-bit tables. libdeflate level 1 reaches 3.28 vs our 2.86 at 2.6x the time; `src/Zlib.cpp` is the only file that includes a zlib header; without the define (node-gyp fallback) it uses Node's zlib.
29
29
  4. Emit `dist/bindings/cws_<platform>_<arch>_node<ABI>.node`.
30
30
 
31
31
  ```sh
@@ -76,6 +76,10 @@ The client side never touches Node sockets: `native.connect(clientGroup, url, ws
76
76
 
77
77
  `SendWorker` (`src/SendWorker.cpp`) starts one `std::thread` at addon load when corking is enabled. `Socket::uncork` moves up to 512 queued frames into a `Socket::SendOp` (ownership moves with them) and hands it over through `deps/readerwriterqueue` (blocking SPSC main→worker, plain SPSC worker→main plus a `uv_async`). The worker only calls `sendmsg`/`WSASend` and fills `result`/`error`; `Socket::sendComplete` on the main thread pops what was sent, requeues the rest at the head, resubmits if more queued, arms `UV_WRITABLE` for the classic drain loop on a short write, runs send callbacks, and calls `endCb` (= `STATE::onEnd`, set in `setState`) on a hard error. While an op is in flight `write()` appends to the queue and the drain loop stays out. `closeSocket` orphans an in-flight op (`socket = nullptr`, `closeFd = true`) and lets the completion close the fd, so a reused fd number can never receive the old socket's bytes. SSL sockets never use the worker. `CWS_SEND_THREAD=0` disables. Compressed sends: `WebSocket::send` queues the raw payload as a `compressPending` message (`enqueueCompressPending`); `performSend` deflates + frames it on the worker with the socket's window (`op->deflateWindow`) or the worker's own shared compressor, then builds the iovecs. Any main-thread write path calls `materializePending()` first (`WebSocket::materialize` via `materializeCb`, using the hub's compressor). A socket closed mid-flight hands its window to the op (`destroyWindow`, `workerOwnsWindow`) so `onEnd` does not free it under the worker.
78
78
 
79
+ ### Prepared (shared) messages
80
+
81
+ `cWS::SharedPayload` (`src/WebSocket.h`) holds one payload for many recipients: the raw bytes plus, after the first compressed send, their deflate blocks (`Hub::deflate` with no window, i.e. the shared compressor; sync-flush tail stripped like every compressed message). `WebSocket::sendShared(prefix, payload, ...)` queues one frame as two `Queue::Message`s: an owned one with the frame header and the prefix, and a borrowed one (`ownsData = false`) pointing into the payload whose completion callback (`sharedSent`) drops the payload reference and then runs the user's callback. Compressed, the prefix becomes DEFLATE stored blocks (`00 LEN NLEN`, BFINAL=0) ahead of the cached blocks; that is only valid without context takeover, so a socket with a `slidingDeflateWindow`, the client role (masking) and an empty payload fall back to a contiguous `send()`. JS: `PreparedMessage` (`lib/prepared.ts`, native handle released through a `FinalizationRegistry`) and `WebSocket.send` routes a `PreparedMessage` (with the optional `prefix` option) to `native.sendShared`.
82
+
79
83
  ### Things that are easy to get wrong
80
84
 
81
85
  - `dist/*.js` and `dist/bindings/*.node` are committed. Rebuild TS with `npm run build-ts` and commit the output; binaries must be rebuilt on each platform when C++ changes.
package/README.md CHANGED
@@ -294,6 +294,21 @@ With permessage-deflate enabled, compression of outgoing messages also runs on t
294
294
 
295
295
  Set `CWS_SEND_THREAD=0` to disable it (sends and compression then happen on the main thread at the end of the tick). The `sendThread` export reports `'active'` or the reason it is not. TLS sockets always send on the main thread.
296
296
 
297
+ ### Prepared messages (fan-out)
298
+
299
+ When one payload goes to many sockets with only a small per-socket prefix in front of it (an RPC header, a subscription id), prepare it once:
300
+
301
+ ```js
302
+ const { PreparedMessage } = require('@encharm/cws');
303
+
304
+ const prepared = new PreparedMessage(payloadBytes); // copied into native memory once
305
+ for (const ws of subscribers) {
306
+ ws.send(prepared, { prefix: headerFor(ws) }); // one frame: header + payload
307
+ }
308
+ ```
309
+
310
+ `send` accepts a `PreparedMessage` wherever it accepts a buffer, plus a `prefix` option (a per-socket header, string or bytes) spliced in front of the payload inside the same frame. `binary` defaults to `true` for prepared messages; `compress` defaults to the server's `threshold` applied to the total length. The payload is never copied or compressed per socket: without deflate it is a second gather buffer in the end-of-tick write; with the shared compressor it is deflated once, on the first compressed send, and the prefix is spliced in front as a DEFLATE stored block. Sockets negotiated with context takeover (`serverNoContextTakeover: false`) and client sockets fall back to the regular path. Handles are released when garbage collected; sends in flight keep their own reference.
311
+
297
312
  ### Secure WebSocket
298
313
  You can use `wss://` with `cws` by providing `https` server to `cws` and setting `secureProtocol` on https options:
299
314
 
package/dist/client.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /// <reference types="node" />
2
2
  import { WebSocketServer } from './server';
3
3
  import { SocketAddress, ServerConfigs } from './index';
4
+ import { PreparedMessage } from './prepared';
4
5
  export declare class WebSocket {
5
6
  url: string;
6
7
  private options;
@@ -28,9 +29,10 @@ export declare class WebSocket {
28
29
  on(event: 'error', listener: (err: Error) => void): void;
29
30
  on(event: 'message', listener: (message: string | any) => void): void;
30
31
  on(event: 'close', listener: (code?: number, reason?: string) => void): void;
31
- send(message: string | Buffer, options?: {
32
+ send(message: string | Buffer | ArrayBufferView | PreparedMessage, options?: {
32
33
  binary?: boolean;
33
34
  compress?: boolean;
35
+ prefix?: string | Buffer | ArrayBufferView;
34
36
  }, cb?: (err?: Error) => void): void;
35
37
  ping(message?: string | Buffer): void;
36
38
  close(code?: number, reason?: string): void;
package/dist/client.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WebSocket = void 0;
4
4
  const server_1 = require("./server");
5
5
  const shared_1 = require("./shared");
6
+ const prepared_1 = require("./prepared");
6
7
  const clientGroup = shared_1.native.client.group.create(0, shared_1.DEFAULT_PAYLOAD_LIMIT);
7
8
  function messageByteLength(message) {
8
9
  if (typeof message === 'string') {
@@ -78,6 +79,7 @@ class WebSocket {
78
79
  }
79
80
  send(message, options, cb) {
80
81
  if (this.external) {
82
+ const prepared = message instanceof prepared_1.PreparedMessage;
81
83
  let opCode = typeof message === 'string' ? shared_1.OPCODE_TEXT : shared_1.OPCODE_BINARY;
82
84
  if (options && options.binary === false) {
83
85
  opCode = shared_1.OPCODE_TEXT;
@@ -90,9 +92,15 @@ class WebSocket {
90
92
  compress = !!options.compress;
91
93
  }
92
94
  else if (this.compressThreshold !== undefined) {
93
- compress = messageByteLength(message) >= this.compressThreshold;
95
+ compress = messageByteLength(message) + (options && options.prefix ? messageByteLength(options.prefix) : 0) >= this.compressThreshold;
96
+ }
97
+ const callback = cb ? () => process.nextTick(cb) : null;
98
+ if (prepared) {
99
+ this.nativeApi.sendShared(this.external, options && options.prefix, message.external, opCode, callback, compress);
100
+ }
101
+ else {
102
+ this.nativeApi.send(this.external, message, opCode, callback, compress);
94
103
  }
95
- this.nativeApi.send(this.external, message, opCode, cb ? () => process.nextTick(cb) : null, compress);
96
104
  }
97
105
  else if (cb) {
98
106
  cb(new Error('Socket not connected'));
package/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export declare type ServerConfigs = {
30
30
  verifyClient?: (info: ConnectionInfo, next: VerifyClientNext) => void;
31
31
  };
32
32
  export { WebSocket } from './client';
33
+ export { PreparedMessage } from './prepared';
33
34
  export { WebSocketServer } from './server';
34
35
  export declare const secureProtocol: string;
35
36
  export { zlibBackend, sendThread } from './shared';
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.secureProtocol = void 0;
4
4
  var client_1 = require("./client");
5
5
  Object.defineProperty(exports, "WebSocket", { enumerable: true, get: function () { return client_1.WebSocket; } });
6
+ var prepared_1 = require("./prepared");
7
+ Object.defineProperty(exports, "PreparedMessage", { enumerable: true, get: function () { return prepared_1.PreparedMessage; } });
6
8
  var server_1 = require("./server");
7
9
  Object.defineProperty(exports, "WebSocketServer", { enumerable: true, get: function () { return server_1.WebSocketServer; } });
8
10
  exports.secureProtocol = 'TLSv1_2_method';
@@ -0,0 +1,6 @@
1
+ /// <reference types="node" />
2
+ export declare class PreparedMessage {
3
+ readonly byteLength: number;
4
+ readonly external: any;
5
+ constructor(data: Buffer | ArrayBufferView | ArrayBuffer);
6
+ }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PreparedMessage = void 0;
4
+ const shared_1 = require("./shared");
5
+ const preparedRegistry = typeof global.FinalizationRegistry === 'function'
6
+ ? new global.FinalizationRegistry((external) => shared_1.native.server.releaseShared(external))
7
+ : undefined;
8
+ class PreparedMessage {
9
+ constructor(data) {
10
+ this.byteLength = data.byteLength;
11
+ this.external = shared_1.native.server.prepareShared(data);
12
+ if (preparedRegistry) {
13
+ preparedRegistry.register(this, this.external);
14
+ }
15
+ }
16
+ }
17
+ exports.PreparedMessage = PreparedMessage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@encharm/cws",
3
- "version": "4.10.0",
3
+ "version": "4.11.2",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "description": "cWS - fast C++ WebSocket implementation for Node.js",
package/src/Addon.h CHANGED
@@ -644,6 +644,44 @@ void sendPrepared(const FunctionCallbackInfo<Value> &args) {
644
644
  ->Value());
645
645
  }
646
646
 
647
+ template <bool isServer>
648
+ void prepareShared(const FunctionCallbackInfo<Value> &args) {
649
+ NativeString nativeString(args.GetIsolate(), args[0]);
650
+ args.GetReturnValue().Set(External::New(
651
+ args.GetIsolate(),
652
+ cWS::WebSocket<isServer>::prepareShared(nativeString.getData(),
653
+ nativeString.getLength())));
654
+ }
655
+
656
+ template <bool isServer>
657
+ void releaseShared(const FunctionCallbackInfo<Value> &args) {
658
+ cWS::SharedPayload::unref(
659
+ (cWS::SharedPayload *)args[0].As<External>()->Value());
660
+ }
661
+
662
+ // (socket, prefix, payload, opCode, callback, compress)
663
+ template <bool isServer>
664
+ void sendShared(const FunctionCallbackInfo<Value> &args) {
665
+ NativeString prefix(args.GetIsolate(), args[1]);
666
+ cWS::SharedPayload *payload =
667
+ (cWS::SharedPayload *)args[2].As<External>()->Value();
668
+ cWS::OpCode opCode = (cWS::OpCode)args[3].As<Integer>()->Value();
669
+
670
+ SendCallbackData *sc = nullptr;
671
+ void (*callback)(cWS::WebSocket<isServer> *, void *, bool, void *) = nullptr;
672
+ if (args[4]->IsFunction()) {
673
+ callback = sendCallback;
674
+ sc = new SendCallbackData;
675
+ sc->jsCallback.Reset(args.GetIsolate(), Local<Function>::Cast(args[4]));
676
+ sc->isolate = args.GetIsolate();
677
+ }
678
+ bool compress = args[5].As<Boolean>()->Value();
679
+
680
+ unwrapSocket<isServer>(args[0].As<External>())
681
+ ->sendShared(prefix.getData(), prefix.getLength(), payload, opCode,
682
+ compress, callback, sc);
683
+ }
684
+
647
685
  template <bool isServer>
648
686
  void finalizeMessage(const FunctionCallbackInfo<Value> &args) {
649
687
  cWS::WebSocket<isServer>::finalizeMessage(
@@ -723,6 +761,9 @@ struct Namespace {
723
761
  NODE_SET_METHOD(object, "prepareMessage", prepareMessage<isServer>);
724
762
  NODE_SET_METHOD(object, "sendPrepared", sendPrepared<isServer>);
725
763
  NODE_SET_METHOD(object, "finalizeMessage", finalizeMessage<isServer>);
764
+ NODE_SET_METHOD(object, "prepareShared", prepareShared<isServer>);
765
+ NODE_SET_METHOD(object, "sendShared", sendShared<isServer>);
766
+ NODE_SET_METHOD(object, "releaseShared", releaseShared<isServer>);
726
767
 
727
768
  Local<Object> group = Object::New(isolate);
728
769
  NODE_SET_METHOD(group, "onConnection", onConnection<isServer>);
@@ -15,11 +15,15 @@
15
15
  #include <cstdint>
16
16
  #include <cstring>
17
17
  #include <cstddef>
18
+ #ifdef _MSC_VER
19
+ #include <intrin.h>
20
+ #endif
18
21
 
19
22
  namespace cWS {
20
23
  namespace microdeflate {
21
24
 
22
- // Worst case output for `length` input bytes (all literals at 9 bits + block overhead).
25
+ // Worst case output for `length` input bytes (all literals at 9 bits + block overhead),
26
+ // plus slack for the 8-byte stores of the bit writer.
23
27
  inline size_t bound(size_t length) {
24
28
  return length + length / 8 + 32;
25
29
  }
@@ -27,6 +31,9 @@ inline size_t bound(size_t length) {
27
31
  class Encoder {
28
32
  static const int HASH_BITS = 13;
29
33
  uint32_t table[1 << HASH_BITS];
34
+ // 8 more hash bits per slot: a candidate whose tag differs cannot match, and rejecting it
35
+ // here saves the dependent random load into the input that dominated the literal path.
36
+ uint8_t tags[1 << HASH_BITS];
30
37
 
31
38
  static const uint16_t *lenBase() { static const uint16_t t[29] = {3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258}; return t; }
32
39
  static const uint8_t *lenExtra() { static const uint8_t t[29] = {0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0}; return t; }
@@ -36,7 +43,6 @@ class Encoder {
36
43
  struct Tables {
37
44
  uint16_t litCode[288]; uint8_t litBits[288];
38
45
  uint8_t lenCode[259];
39
- uint8_t distCode[32769];
40
46
  uint8_t distCodeRev[30];
41
47
  static uint32_t rev(uint32_t v, int n) { uint32_t r = 0; for (int i = 0; i < n; i++) { r = (r << 1) | (v & 1); v >>= 1; } return r; }
42
48
  Tables() {
@@ -49,21 +55,85 @@ class Encoder {
49
55
  litCode[i] = (uint16_t) rev(code, bits); litBits[i] = (uint8_t) bits;
50
56
  }
51
57
  for (int c = 0; c < 29; c++) for (int l = lenBase()[c]; l < (c < 28 ? lenBase()[c + 1] : 259); l++) lenCode[l] = (uint8_t) c;
52
- for (int c = 0; c < 30; c++) { for (int d = distBase()[c]; d < (c < 29 ? distBase()[c + 1] : 32769); d++) distCode[d] = (uint8_t) c; distCodeRev[c] = (uint8_t) rev(c, 5); }
58
+ for (int c = 0; c < 30; c++) distCodeRev[c] = (uint8_t) rev(c, 5);
53
59
  }
54
60
  };
55
61
  static const Tables &tables() { static const Tables t; return t; }
56
62
 
63
+ // 64-bit accumulator flushed 8 bytes at a time with one unaligned store (the output
64
+ // buffer has slack for the over-write, see bound()). Literals (8 or 9 bits) drain the
65
+ // accumulator conditionally: their flush pattern repeats every few symbols and predicts
66
+ // well, and an unconditional store per literal made incompressible input store-bound on
67
+ // Zen 4 (+58%). Match symbols vary in size, so their flush branch mispredicted; they
68
+ // store unconditionally instead (-7% on the RPC capture on Zen 4, -10% on Apple M).
57
69
  struct BitWriter {
58
70
  uint8_t *out; size_t pos = 0; uint64_t acc = 0; int n = 0;
59
- inline void put(uint32_t v, int bits) { acc |= (uint64_t) v << n; n += bits; while (n >= 8) { out[pos++] = (uint8_t) acc; acc >>= 8; n -= 8; } }
60
- inline void flushByte() { if (n) { out[pos++] = (uint8_t) acc; acc = 0; n = 0; } }
71
+ inline void put(uint32_t v, int bits) {
72
+ acc |= (uint64_t) v << n; n += bits;
73
+ memcpy(out + pos, &acc, 8); pos += n >> 3; acc >>= n & ~7; n &= 7;
74
+ }
75
+ inline void putLit(uint32_t v, int bits) {
76
+ acc |= (uint64_t) v << n; n += bits;
77
+ if (n >= 32) { memcpy(out + pos, &acc, 8); pos += n >> 3; acc >>= n & ~7; n &= 7; }
78
+ }
79
+ inline void flushByte() { while (n > 0) { out[pos++] = (uint8_t) acc; acc >>= 8; n -= 8; } acc = 0; n = 0; }
61
80
  };
62
81
 
63
- static inline uint32_t hash4(const uint8_t *p) { uint32_t v; memcpy(&v, p, 4); return (v * 2654435761u) >> (32 - HASH_BITS); }
82
+ static inline uint32_t load32(const uint8_t *p) { uint32_t v; memcpy(&v, p, 4); return v; }
83
+ static inline uint64_t load64(const uint8_t *p) { uint64_t v; memcpy(&v, p, 8); return v; }
84
+ static inline uint32_t hash32(const uint8_t *p) { return load32(p) * 2654435761u; }
85
+ static inline uint32_t slot(uint32_t h) { return h >> (32 - HASH_BITS); }
86
+ static inline uint8_t tag(uint32_t h) { return (uint8_t) (h >> (32 - HASH_BITS - 8)); }
87
+ // Distance code from the highest set bit of dist-1: codes 0-3 are exact, then two codes
88
+ // per power of two, the lower one chosen by the bit below the msb. Replaces a 32 KB table.
89
+ static inline int distCode(uint32_t dist) {
90
+ uint32_t d = dist - 1;
91
+ if (d < 4) return (int) d;
92
+ #ifdef _MSC_VER
93
+ unsigned long msb; _BitScanReverse(&msb, d);
94
+ #else
95
+ int msb = 31 - __builtin_clz(d);
96
+ #endif
97
+ return ((int) msb << 1) | (int) ((d >> (msb - 1)) & 1);
98
+ }
99
+ static inline int ctz64(uint64_t v) {
100
+ #ifdef _MSC_VER
101
+ unsigned long r; _BitScanForward64(&r, v); return (int) r;
102
+ #else
103
+ return __builtin_ctzll(v);
104
+ #endif
105
+ }
106
+
107
+ // Length of the common prefix of a and b, at most `max`: 8 bytes per step while
108
+ // both sides have 8 bytes left (little-endian: the first differing byte is the
109
+ // lowest set byte of the xor), then byte by byte.
110
+ static inline size_t matchLength(const uint8_t *a, const uint8_t *b, size_t max) {
111
+ size_t m = 0;
112
+ while (m + 8 <= max) {
113
+ uint64_t x = load64(a + m) ^ load64(b + m);
114
+ if (x) return m + (ctz64(x) >> 3);
115
+ m += 8;
116
+ }
117
+ while (m < max && a[m] == b[m]) m++;
118
+ return m;
119
+ }
64
120
 
65
121
  public:
66
- Encoder() { memset(table, 0xff, sizeof(table)); tables(); }
122
+ Encoder() { memset(table, 0xff, sizeof(table)); memset(tags, 0, sizeof(tags)); tables(); }
123
+
124
+ // Stored blocks: what an incompressible message costs, 5 bytes per 65535 plus the tail.
125
+ static size_t storeBlocks(const uint8_t *in, size_t length, uint8_t *out) {
126
+ size_t pos = 0;
127
+ for (size_t i = 0; i < length; ) {
128
+ size_t n = length - i > 65535 ? 65535 : length - i;
129
+ out[pos++] = 0; // BFINAL=0, BTYPE=00
130
+ out[pos++] = (uint8_t) n; out[pos++] = (uint8_t) (n >> 8);
131
+ out[pos++] = (uint8_t) ~n; out[pos++] = (uint8_t) (~n >> 8);
132
+ memcpy(out + pos, in + i, n); pos += n; i += n;
133
+ }
134
+ out[pos++] = 0; // empty stored block header, byte aligned; the 00 00 ff ff tail is implied
135
+ return pos;
136
+ }
67
137
 
68
138
  // `out` must have room for bound(length) bytes. Returns the compressed length
69
139
  // (without the 4-byte sync-flush tail).
@@ -74,30 +144,34 @@ public:
74
144
  size_t i = 0;
75
145
  const size_t limit = length >= 4 ? length - 4 : 0;
76
146
  while (i < limit) {
77
- uint32_t h = hash4(in + i);
78
- uint32_t cand = table[h];
79
- table[h] = (uint32_t) i;
80
- if (cand < i && i - cand <= 32768 && memcmp(in + cand, in + i, 4) == 0) {
147
+ uint32_t h = hash32(in + i), s = slot(h);
148
+ uint8_t g = tag(h);
149
+ uint32_t cand = table[s];
150
+ bool tagged = tags[s] == g;
151
+ table[s] = (uint32_t) i;
152
+ tags[s] = g;
153
+ if (tagged && cand < i && i - cand <= 32768 && load32(in + cand) == load32(in + i)) {
81
154
  size_t maxLen = length - i; if (maxLen > 258) maxLen = 258;
82
- size_t m = 4;
83
- while (m < maxLen && in[cand + m] == in[i + m]) m++;
155
+ size_t m = 4 + matchLength(in + cand + 4, in + i + 4, maxLen - 4);
84
156
  uint32_t dist = (uint32_t) (i - cand);
85
157
  int lc = t.lenCode[m];
86
- w.put(t.litCode[257 + lc], t.litBits[257 + lc]);
87
- if (lenExtra()[lc]) w.put((uint32_t) (m - lenBase()[lc]), lenExtra()[lc]);
88
- int dc = t.distCode[dist];
89
- w.put(t.distCodeRev[dc], 5);
90
- if (distExtra()[dc]) w.put(dist - distBase()[dc], distExtra()[dc]);
91
- if (i + 1 < limit) table[hash4(in + i + 1)] = (uint32_t) (i + 1);
158
+ // length code + its extra bits in one put (at most 8 + 5 bits), same for distance (5 + 13)
159
+ w.put(t.litCode[257 + lc] | ((uint32_t) (m - lenBase()[lc]) << t.litBits[257 + lc]), t.litBits[257 + lc] + lenExtra()[lc]);
160
+ int dc = distCode(dist);
161
+ w.put(t.distCodeRev[dc] | ((dist - distBase()[dc]) << 5), 5 + distExtra()[dc]);
162
+ if (i + 1 < limit) { uint32_t h1 = hash32(in + i + 1); table[slot(h1)] = (uint32_t) (i + 1); tags[slot(h1)] = tag(h1); }
92
163
  i += m;
93
164
  } else {
94
- w.put(t.litCode[in[i]], t.litBits[in[i]]);
165
+ w.putLit(t.litCode[in[i]], t.litBits[in[i]]);
95
166
  i++;
96
167
  }
97
168
  }
98
- for (; i < length; i++) w.put(t.litCode[in[i]], t.litBits[in[i]]);
99
- w.put(t.litCode[256], t.litBits[256]); // end of block
100
- w.put(0, 1); w.put(0, 2); w.flushByte(); // empty stored block: BFINAL=0, BTYPE=00, then byte-align
169
+ for (; i < length; i++) w.putLit(t.litCode[in[i]], t.litBits[in[i]]);
170
+ w.putLit(t.litCode[256], t.litBits[256]); // end of block
171
+ w.putLit(0, 1); w.putLit(0, 2); w.flushByte(); // empty stored block: BFINAL=0, BTYPE=00, then byte-align
172
+ if (w.pos > length + 5 * (length / 65535 + 1) + 1) {
173
+ return storeBlocks(in, length, out); // incompressible: stored blocks are smaller
174
+ }
101
175
  out[w.pos++] = 0; out[w.pos++] = 0; out[w.pos++] = 0xff; out[w.pos++] = 0xff;
102
176
  return w.pos - 4;
103
177
  }
package/src/WebSocket.cpp CHANGED
@@ -82,6 +82,155 @@ void WebSocket<isServer>::materialize(cS::Socket *s, cS::Socket::Queue::Message
82
82
  cS::Socket::materializeOnMain(s, m, stream, !webSocket->slidingDeflateWindow, hub->zlibBuffer, Hub::LARGE_BUFFER_SIZE, hub->dynamicZlibBuffer);
83
83
  }
84
84
 
85
+ template <bool isServer>
86
+ SharedPayload *WebSocket<isServer>::prepareShared(const char *data, size_t length) {
87
+ SharedPayload *payload = new SharedPayload;
88
+ payload->raw.assign(data, length);
89
+ return payload;
90
+ }
91
+
92
+ namespace {
93
+ struct SharedCallback {
94
+ void (*callback)(void *socket, void *data, bool cancelled, void *reserved);
95
+ void *data;
96
+ };
97
+
98
+ // Completion of the shared part of a sendShared() frame: drops the payload reference,
99
+ // then runs the caller's callback. `socket` is null when the send was orphaned by a close.
100
+ void sharedSent(void *socket, void *data, bool cancelled, void *reserved) {
101
+ SharedPayload::unref((SharedPayload *) data);
102
+ if (reserved) {
103
+ SharedCallback *c = (SharedCallback *) reserved;
104
+ c->callback(socket, c->data, cancelled, nullptr);
105
+ delete c;
106
+ }
107
+ }
108
+ }
109
+
110
+ /*
111
+ * Sends prefix + payload as one frame. The frame is queued as two messages: an owned
112
+ * one with the header and the prefix, and a borrowed one pointing into the payload, so
113
+ * nothing is copied or compressed per recipient.
114
+ *
115
+ * Compressed: the prefix goes out as DEFLATE stored blocks in front of the payload's
116
+ * pre-built blocks. That is valid because those blocks were produced from the payload
117
+ * alone, so no match reaches back before their start. It needs a socket without context
118
+ * takeover (no sliding window): with takeover the per-socket window would not contain
119
+ * these bytes, so that case, like the client role (masking rewrites every byte), takes
120
+ * the regular copying path.
121
+ */
122
+ template <bool isServer>
123
+ void WebSocket<isServer>::sendShared(const char *prefix, size_t prefixLength, SharedPayload *payload, OpCode opCode, bool compress,
124
+ void(*callback)(WebSocket<isServer> *webSocket, void *data, bool cancelled, void *reserved), void *callbackData) {
125
+ bool deflate = compress && compressionStatus == WebSocket<isServer>::CompressionStatus::ENABLED && opCode < 3;
126
+ if (!isServer || (deflate && slidingDeflateWindow) || payload->raw.empty()) {
127
+ std::string whole;
128
+ whole.reserve(prefixLength + payload->raw.size());
129
+ whole.append(prefix, prefixLength).append(payload->raw);
130
+ send(whole.data(), whole.size(), opCode, callback, callbackData, compress);
131
+ return;
132
+ }
133
+ if (isClosed()) {
134
+ if (callback) {
135
+ callback(this, callbackData, true, nullptr);
136
+ }
137
+ return;
138
+ }
139
+
140
+ const char *body;
141
+ size_t bodyLength;
142
+ if (deflate) {
143
+ if (!payload->deflatedReady) {
144
+ size_t length = payload->raw.size();
145
+ char *out = Group<isServer>::from(this)->hub->deflate((char *) payload->raw.data(), length, nullptr);
146
+ payload->deflated.assign(out, length);
147
+ payload->deflatedReady = true;
148
+ }
149
+ body = payload->deflated.data();
150
+ bodyLength = payload->deflated.size();
151
+ } else {
152
+ body = payload->raw.data();
153
+ bodyLength = payload->raw.size();
154
+ }
155
+
156
+ // a stored block carries at most 65535 bytes: 1 byte header, LEN, NLEN
157
+ const size_t STORED_MAX = 65535;
158
+ size_t storedBlocks = deflate ? (prefixLength + STORED_MAX - 1) / STORED_MAX : 0;
159
+ size_t prefixPart = prefixLength + storedBlocks * 5;
160
+ const size_t MAX_HEADER = 14;
161
+
162
+ Queue::Message *first = allocMessage(MAX_HEADER + prefixPart);
163
+ char *dst = (char *) first->data;
164
+ char *p = dst + WebSocketProtocol<isServer, WebSocket<isServer>>::formatMessage(dst, dst, 0, opCode, prefixPart + bodyLength, deflate);
165
+ if (deflate) {
166
+ const char *src = prefix;
167
+ for (size_t remaining = prefixLength; remaining; ) {
168
+ size_t n = remaining > STORED_MAX ? STORED_MAX : remaining;
169
+ *p++ = 0; // BFINAL=0, BTYPE=00, already byte aligned
170
+ *p++ = (char) (n & 0xff);
171
+ *p++ = (char) (n >> 8);
172
+ *p++ = (char) (~n & 0xff);
173
+ *p++ = (char) ((~n >> 8) & 0xff);
174
+ memcpy(p, src, n);
175
+ p += n;
176
+ src += n;
177
+ remaining -= n;
178
+ }
179
+ } else {
180
+ memcpy(p, prefix, prefixLength);
181
+ p += prefixLength;
182
+ }
183
+ first->length = p - dst;
184
+
185
+ int memoryIndex = nodeData->getMemoryBlockIndex(sizeof(Queue::Message));
186
+ Queue::Message *second = (Queue::Message *) nodeData->getSmallMemoryBlock(memoryIndex);
187
+ second->data = body;
188
+ second->length = bodyLength;
189
+ second->nextMessage = nullptr;
190
+ second->poolIndex = memoryIndex;
191
+ second->ownsData = false;
192
+ second->compressPending = false;
193
+ second->opCode = 0;
194
+ second->callback = sharedSent;
195
+ second->callbackData = payload;
196
+ second->reserved = callback ? new SharedCallback{(void(*)(void *, void *, bool, void *)) callback, callbackData} : nullptr;
197
+ payload->references++;
198
+
199
+ if (corkActive()) {
200
+ enqueue(first);
201
+ enqueue(second);
202
+ if (!corkPending) {
203
+ corkPending = true;
204
+ nodeData->corkState->pending.push_back(this);
205
+ }
206
+ return;
207
+ }
208
+
209
+ // no write corking (CWS_CORK=0, TLS): write now, queue what does not fit, keep order
210
+ bool wasTransferred;
211
+ if (!write(first, wasTransferred)) {
212
+ freeMessage(first);
213
+ void *reserved = second->reserved;
214
+ nodeData->freeSmallMemoryBlock((char *) second, memoryIndex);
215
+ sharedSent(this, payload, true, reserved);
216
+ return;
217
+ }
218
+ if (!wasTransferred) {
219
+ freeMessage(first);
220
+ }
221
+ if (!write(second, wasTransferred)) {
222
+ void *reserved = second->reserved;
223
+ nodeData->freeSmallMemoryBlock((char *) second, memoryIndex);
224
+ sharedSent(this, payload, true, reserved);
225
+ return;
226
+ }
227
+ if (!wasTransferred) {
228
+ void *reserved = second->reserved;
229
+ nodeData->freeSmallMemoryBlock((char *) second, memoryIndex);
230
+ sharedSent(this, payload, false, reserved);
231
+ }
232
+ }
233
+
85
234
  /*
86
235
  * Prepares a single message for use with sendPrepared.
87
236
  *
package/src/WebSocket.h CHANGED
@@ -12,6 +12,23 @@ struct Group;
12
12
  template <bool isServer>
13
13
  struct HttpSocket;
14
14
 
15
+ // A payload prepared once and sent to many sockets with sendShared() (fan-out). Holds the
16
+ // raw bytes and, after the first compressed send, their DEFLATE blocks: the same bytes a
17
+ // regular compressed send would carry (sync-flush tail stripped). Reference counted: the
18
+ // creator holds one reference, every queued message holds one until it completes.
19
+ struct SharedPayload {
20
+ std::string raw;
21
+ std::string deflated;
22
+ bool deflatedReady = false;
23
+ int references = 1;
24
+
25
+ static void unref(SharedPayload *payload) {
26
+ if (!--payload->references) {
27
+ delete payload;
28
+ }
29
+ }
30
+ };
31
+
15
32
  template <const bool isServer>
16
33
  struct WIN32_EXPORT WebSocket : cS::Socket, WebSocketState<isServer> {
17
34
  protected:
@@ -74,6 +91,10 @@ public:
74
91
  void ping(const char *message) {send(message, OpCode::PING);}
75
92
  void send(const char *message, OpCode opCode = OpCode::TEXT) {send(message, strlen(message), opCode);}
76
93
  void send(const char *message, size_t length, OpCode opCode, void(*callback)(WebSocket<isServer> *webSocket, void *data, bool cancelled, void *reserved) = nullptr, void *callbackData = nullptr, bool compress = false);
94
+ static SharedPayload *prepareShared(const char *data, size_t length);
95
+ // One frame carrying prefix + payload without copying or re-compressing the payload. Not thread safe.
96
+ void sendShared(const char *prefix, size_t prefixLength, SharedPayload *payload, OpCode opCode, bool compress,
97
+ void(*callback)(WebSocket<isServer> *webSocket, void *data, bool cancelled, void *reserved) = nullptr, void *callbackData = nullptr);
77
98
  static PreparedMessage *prepareMessage(char *data, size_t length, OpCode opCode, bool compressed, void(*callback)(WebSocket<isServer> *webSocket, void *data, bool cancelled, void *reserved) = nullptr);
78
99
  static PreparedMessage *prepareMessageBatch(std::vector<std::string> &messages, std::vector<int> &excludedMessages,
79
100
  OpCode opCode, bool compressed, void(*callback)(WebSocket<isServer> *webSocket, void *data, bool cancelled, void *reserved) = nullptr);