@nxtedition/shared 4.0.0 → 4.0.1

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/README.md CHANGED
@@ -61,6 +61,15 @@ w.cork(() => {
61
61
  // All writes flushed atomically when cork returns
62
62
  ```
63
63
 
64
+ Or manually:
65
+
66
+ ```js
67
+ w.cork()
68
+ w.writeSync(buf1.length, writeFn, buf1)
69
+ w.writeSync(buf2.length, writeFn, buf2)
70
+ w.uncork() // publishes all writes to the reader
71
+ ```
72
+
64
73
  ### Non-blocking writes with tryWrite
65
74
 
66
75
  ```js
@@ -92,11 +101,10 @@ const w = new Writer(state)
92
101
 
93
102
  ```js
94
103
  // reader-worker.js
95
- import { State, Reader } from '@nxtedition/shared'
104
+ import { Reader } from '@nxtedition/shared'
96
105
  import { workerData } from 'node:worker_threads'
97
106
 
98
- const state = new State(workerData)
99
- const r = new Reader(state)
107
+ const r = new Reader(workerData)
100
108
 
101
109
  function poll() {
102
110
  const count = r.readSome((data) => {
@@ -116,108 +124,110 @@ Allocates or wraps a shared memory buffer for the ring buffer. The first 128 byt
116
124
  - **size** — Data capacity in bytes (must be a positive integer, max ~2 GB)
117
125
  - **buffer** — An existing `SharedArrayBuffer` to wrap
118
126
 
119
- ### `new Reader(state: State)`
127
+ #### `state.buffer`
120
128
 
121
- Creates a reader for the ring buffer.
129
+ The underlying `SharedArrayBuffer`. Pass this to a worker thread to share the ring buffer.
122
130
 
123
- #### `reader.readSome(next)`
131
+ ### `new Reader(state: State | SharedArrayBuffer)`
124
132
 
125
- Reads a batch of messages. Calls `next(data)` for each message, where `data` has:
133
+ Creates a reader for the ring buffer. Accepts a `State` instance or a `SharedArrayBuffer` directly (shorthand for `new Reader(new State(buf))`).
134
+
135
+ #### `reader.readSome(next, opaque?)`
136
+
137
+ Reads a batch of messages. Calls `next(data, opaque)` for each message, where `data` has:
126
138
 
127
139
  - `buffer: Buffer` — The underlying shared buffer
128
140
  - `view: DataView` — A DataView over the shared buffer
129
141
  - `byteOffset: number` — Start offset of the message payload
130
142
  - `byteLength: number` — Length of the message payload in bytes
131
143
 
144
+ - **opaque** — Optional user-provided context object passed through to the callback. Useful for avoiding closures on hot paths.
145
+
132
146
  Return `false` from the callback to stop reading early. Returns the number of messages processed.
133
147
 
134
148
  Messages are batched: up to 1024 items or 256 KiB per call.
135
149
 
136
- ### `new Writer(state: State, options?)`
150
+ ### `new Writer(state: State | SharedArrayBuffer, options?)`
137
151
 
138
- Creates a writer for the ring buffer.
152
+ Creates a writer for the ring buffer. Accepts a `State` instance or a `SharedArrayBuffer` directly (shorthand for `new Writer(new State(buf))`).
139
153
 
140
154
  **Options:**
141
155
 
142
156
  - `yield?: () => void` — Called when the writer must wait for the reader to catch up. Useful to prevent deadlocks when the writer thread also drives the reader.
143
157
  - `logger?: { warn(obj, msg): void }` — Logger for yield warnings (pino-compatible).
144
158
 
145
- #### `writer.writeSync(len, fn)`
159
+ #### `writer.writeSync(len, fn, opaque?)`
146
160
 
147
161
  Synchronously writes a message. Blocks (via `Atomics.wait`) until buffer space is available.
148
162
 
149
163
  - **len** — Maximum payload size in bytes. Writing beyond `len` bytes in the callback is undefined behavior.
150
- - **fn(data) → number** — Write callback. Write payload into `data.buffer` starting at `data.byteOffset`. **Must return the end position** (`data.byteOffset + bytesWritten`), not the byte count.
164
+ - **fn(data, opaque) → number** — Write callback. Write payload into `data.buffer` starting at `data.byteOffset`. **Must return the end position** (`data.byteOffset + bytesWritten`), not the byte count.
165
+ - **opaque** — Optional user-provided context object passed through to the callback. Useful for avoiding closures on hot paths.
151
166
 
152
167
  Throws on timeout (default: 60000 ms).
153
168
 
154
- #### `writer.tryWrite(len, fn)`
169
+ #### `writer.tryWrite(len, fn, opaque?)`
170
+
171
+ Non-blocking write attempt. Returns `false` if the buffer is full. The `fn` and `opaque` parameters follow the same contract as `writeSync`.
155
172
 
156
- Non-blocking write attempt. Returns `false` if the buffer is full. The `fn` callback follows the same contract as `writeSync`.
173
+ #### `writer.cork(callback?)`
157
174
 
158
- #### `writer.cork(callback)`
175
+ Batches multiple writes. The write pointer is only published to the reader when the cork is released, reducing atomic operation overhead.
159
176
 
160
- Batches multiple writes within the callback. The write pointer is only published to the reader when `cork` returns, reducing atomic operation overhead.
177
+ When called with a callback, uncork is called automatically when the callback returns. When called without a callback, you must call `uncork()` manually.
178
+
179
+ #### `writer.uncork()`
180
+
181
+ Decrements the cork counter. When it reaches zero, publishes the pending write position to the reader. Safe to call when not corked (no-op).
182
+
183
+ #### `writer.flushSync()`
184
+
185
+ Immediately publishes the pending write position to the reader. Unlike `uncork`, this does not interact with the cork counter — it forces a flush regardless.
161
186
 
162
187
  ## Benchmarks
163
188
 
164
- Measured on Apple M3 Pro (3.51 GHz), Node.js 25.6.1, 8 MiB ring buffer.
189
+ Measured on AMD EPYC 9355P (4.28 GHz), Node.js 25.6.0, 8 MiB ring buffer, Docker (x64-linux).
165
190
 
166
191
  Each benchmark writes batches of fixed-size messages from the main thread and
167
192
  reads them in a worker thread. The shared ring buffer is compared against
168
- Node.js `postMessage` (structured clone). Hardware performance counters were
169
- collected with [`@mitata/counters`](https://github.com/evanwashere/mitata).
193
+ Node.js `postMessage` (structured clone).
170
194
 
171
195
  ### Throughput
172
196
 
173
197
  | Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
174
198
  | -----: | --------------: | --------------: | -------------------: | -------------------: |
175
- | 64 B | **1.64 GiB/s** | 753 MiB/s | 96 MiB/s | 120 MiB/s |
176
- | 256 B | **3.13 GiB/s** | 2.46 GiB/s | 360 MiB/s | 464 MiB/s |
177
- | 1 KiB | 4.81 GiB/s | **7.56 GiB/s** | 1.31 GiB/s | 1.68 GiB/s |
178
- | 4 KiB | 5.04 GiB/s | **16.44 GiB/s** | 3.91 GiB/s | 5.13 GiB/s |
179
- | 16 KiB | 5.10 GiB/s | **22.24 GiB/s** | 9.07 GiB/s | 7.60 GiB/s |
180
- | 64 KiB | 5.33 GiB/s | **12.42 GiB/s** | 8.85 GiB/s | 13.20 GiB/s |
199
+ | 64 B | **901 MiB/s** | 410 MiB/s | 25 MiB/s | 42 MiB/s |
200
+ | 256 B | **2.67 GiB/s** | 896 MiB/s | 88 MiB/s | 158 MiB/s |
201
+ | 1 KiB | **4.88 GiB/s** | 1.26 GiB/s | 328 MiB/s | 498 MiB/s |
202
+ | 4 KiB | **9.22 GiB/s** | 1.50 GiB/s | 1.14 GiB/s | 1.70 GiB/s |
203
+ | 16 KiB | **10.90 GiB/s** | 1.56 GiB/s | 4.29 GiB/s | 6.27 GiB/s |
204
+ | 64 KiB | 13.03 GiB/s | 1.55 GiB/s | 10.10 GiB/s | **15.18 GiB/s** |
181
205
 
182
206
  ### Message rate
183
207
 
184
208
  | Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
185
209
  | -----: | --------------: | --------------: | -------------------: | -------------------: |
186
- | 64 B | **27.53 M/s** | 12.33 M/s | 1.57 M/s | 1.97 M/s |
187
- | 256 B | **13.14 M/s** | 10.31 M/s | 1.47 M/s | 1.90 M/s |
188
- | 1 KiB | 5.04 M/s | **7.93 M/s** | 1.38 M/s | 1.77 M/s |
189
- | 4 KiB | 1.32 M/s | **4.31 M/s** | 1.02 M/s | 1.35 M/s |
190
- | 16 KiB | 334 K/s | **1.46 M/s** | 594 K/s | 498 K/s |
191
- | 64 KiB | 87 K/s | **203 K/s** | 145 K/s | 216 K/s |
192
-
193
- ### CPU efficiency (instructions per cycle)
194
-
195
- | Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
196
- | -----: | --------------: | --------------: | -------------------: | -------------------: |
197
- | 64 B | 5.45 | 5.62 | 3.89 | 3.35 |
198
- | 256 B | 4.52 | 5.76 | 3.82 | 3.18 |
199
- | 1 KiB | 4.18 | **6.25** | 3.65 | 3.16 |
200
- | 4 KiB | 3.79 | **6.65** | 3.51 | 2.92 |
201
- | 16 KiB | 3.73 | **6.12** | 2.91 | 2.63 |
202
- | 64 KiB | 3.93 | **4.30** | 2.44 | 2.92 |
210
+ | 64 B | **14.76 M/s** | 6.72 M/s | 405 K/s | 688 K/s |
211
+ | 256 B | **11.20 M/s** | 3.67 M/s | 360 K/s | 648 K/s |
212
+ | 1 KiB | **5.12 M/s** | 1.32 M/s | 336 K/s | 510 K/s |
213
+ | 4 KiB | **2.42 M/s** | 394 K/s | 298 K/s | 445 K/s |
214
+ | 16 KiB | **714 K/s** | 102 K/s | 281 K/s | 411 K/s |
215
+ | 64 KiB | 213 K/s | 25 K/s | 165 K/s | **249 K/s** |
203
216
 
204
217
  ### Key findings
205
218
 
206
- - **Small messages (64-256 B):** The shared ring buffer with `Buffer.copy` delivers
207
- up to **18x higher message rate** and **14x higher throughput** than `postMessage`.
208
- Per-message overhead dominates at these sizes, and avoiding structured cloning makes
209
- the biggest difference. The `Int32Array` meta path is especially effective here —
210
- 27.5 M msg/s at 64 B with 5.45 IPC.
219
+ - **Small messages (64256 B):** The shared ring buffer with `Buffer.set` delivers
220
+ **14.8–11.2 M msg/s** up to **36x faster** than `postMessage` (buffer) and
221
+ **21x faster** than `postMessage` (string). Per-message overhead dominates at
222
+ these sizes, and avoiding structured cloning makes the biggest difference.
211
223
 
212
- - **Large messages (1-64 KiB):** The shared ring buffer with string encoding
213
- (`Buffer.write`) reaches up to **22 GiB/s** — roughly **2-4x faster** than
214
- `postMessage`. V8's ASCII fast path for UTF-8 encoding is heavily vectorized
215
- (6-7 IPC on Apple M3 Pro), which explains why string writes outperform raw
216
- `Buffer.copy` at larger sizes.
224
+ - **Medium to large messages (1–16 KiB):** `Buffer.set` via the ring buffer
225
+ maintains its lead, reaching **10.9 GiB/s** at 16 KiB — **1.7–5.4x faster**
226
+ than the best `postMessage` variant.
217
227
 
218
- - **CPU efficiency:** The shared ring buffer consistently achieves higher IPC
219
- (4-7) compared to `postMessage` (2-4), indicating less time spent stalled on
220
- memory or synchronization.
228
+ - **Very large messages (64 KiB):** `postMessage` (string) overtakes the shared
229
+ buffer at **15.2 GiB/s** vs **13.0 GiB/s**. At this size, structured cloning
230
+ overhead is amortized and the kernel's optimized `memcpy` dominates.
221
231
 
222
232
  - **Caveat:** The string benchmark uses ASCII-only content. Multi-byte UTF-8
223
233
  strings will not hit V8's vectorized fast path and will be significantly slower.
@@ -225,8 +235,7 @@ collected with [`@mitata/counters`](https://github.com/evanwashere/mitata).
225
235
  ### Running the benchmark
226
236
 
227
237
  ```sh
228
- # Hardware counters require elevated privileges on macOS
229
- sudo node --allow-natives-syntax packages/shared/src/bench.mjs
238
+ node --allow-natives-syntax packages/shared/src/bench.mjs
230
239
  ```
231
240
 
232
241
  ## License
package/lib/index.d.ts CHANGED
@@ -25,7 +25,7 @@ export declare class State {
25
25
  */
26
26
  export declare class Reader {
27
27
  #private;
28
- constructor(state: State);
28
+ constructor(state: State | SharedArrayBuffer);
29
29
  readSome<U>(next: (data: BufferRegion, opaque?: U) => void | boolean, opaque?: U): number;
30
30
  }
31
31
  /**
@@ -33,7 +33,7 @@ export declare class Reader {
33
33
  */
34
34
  export declare class Writer {
35
35
  #private;
36
- constructor(state: State, { yield: onYield, logger }?: WriterOptions);
36
+ constructor(state: State | SharedArrayBuffer, { yield: onYield, logger }?: WriterOptions);
37
37
  /**
38
38
  * Synchronously writes a message. Blocks (via `Atomics.wait`) until buffer space is available.
39
39
  * Writing more than "len" bytes in the callback will cause undefined behavior.
package/lib/index.js CHANGED
@@ -74,8 +74,8 @@ export class Reader {
74
74
  #data
75
75
  #readPos
76
76
 
77
- constructor(state ) {
78
- const sharedBuffer = state.buffer
77
+ constructor(state ) {
78
+ const sharedBuffer = state instanceof SharedArrayBuffer ? state : state.buffer
79
79
  const size = sharedBuffer.byteLength - STATE_BYTES
80
80
 
81
81
  this.#state = new Int32Array(sharedBuffer, 0, STATE_BYTES >> 2)
@@ -183,8 +183,8 @@ export class Writer {
183
183
  #logger
184
184
  #uncorkBound
185
185
 
186
- constructor(state , { yield: onYield, logger } = {}) {
187
- const sharedBuffer = state.buffer
186
+ constructor(state , { yield: onYield, logger } = {}) {
187
+ const sharedBuffer = state instanceof SharedArrayBuffer ? state : state.buffer
188
188
 
189
189
  if (onYield != null && typeof onYield !== 'function') {
190
190
  throw new TypeError('onYield must be a function')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/shared",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -26,6 +26,5 @@
26
26
  "oxlint-tsgolint": "^0.13.0",
27
27
  "rimraf": "^6.1.3",
28
28
  "typescript": "^5.9.3"
29
- },
30
- "gitHead": "18d5bdc8855ad9012882b03cab144a9625b8c4b8"
29
+ }
31
30
  }