@nxtedition/shared 3.0.2 → 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
@@ -12,7 +12,7 @@ Reads are zero-copy: the reader callback receives a `DataView` directly into the
12
12
 
13
13
  ## Platform Assumptions
14
14
 
15
- This library assumes that unaligned 32-bit reads and writes will not tear on the target platform. This holds true on x86/x64 and ARM64, which are the primary targets for Node.js.
15
+ All messages are aligned on 4-byte boundaries. Message length headers are read and written via `Int32Array` indexing rather than `DataView`, avoiding per-access endianness checks on the hot path.
16
16
 
17
17
  ## Install
18
18
 
@@ -23,25 +23,25 @@ npm install @nxtedition/shared
23
23
  ## Usage
24
24
 
25
25
  ```js
26
- import { alloc, reader, writer } from '@nxtedition/shared'
26
+ import { State, Reader, Writer } from '@nxtedition/shared'
27
27
 
28
- // Allocate shared memory (pass these buffers to a worker thread)
29
- const { sharedState, sharedBuffer } = alloc(1024 * 1024) // 1 MB ring buffer
28
+ // Allocate shared memory (pass state.buffer to a worker thread)
29
+ const state = new State(1024 * 1024) // 1 MB ring buffer
30
30
 
31
31
  // --- Writer side (e.g. main thread) ---
32
- const w = writer({ sharedState, sharedBuffer })
32
+ const w = new Writer(state)
33
33
 
34
34
  const payload = Buffer.from('hello world')
35
35
  w.writeSync(payload.length, (data) => {
36
- payload.copy(data.buffer, data.offset)
37
- return data.offset + payload.length
36
+ payload.copy(data.buffer, data.byteOffset)
37
+ return data.byteOffset + payload.length
38
38
  })
39
39
 
40
40
  // --- Reader side (e.g. worker thread) ---
41
- const r = reader({ sharedState, sharedBuffer })
41
+ const r = new Reader(state)
42
42
 
43
43
  r.readSome((data) => {
44
- const msg = data.buffer.subarray(data.offset, data.offset + data.length).toString()
44
+ const msg = data.buffer.subarray(data.byteOffset, data.byteOffset + data.byteLength).toString()
45
45
  console.log(msg) // 'hello world'
46
46
  })
47
47
  ```
@@ -53,21 +53,30 @@ w.cork(() => {
53
53
  for (const item of items) {
54
54
  const buf = Buffer.from(JSON.stringify(item))
55
55
  w.writeSync(buf.length, (data) => {
56
- buf.copy(data.buffer, data.offset)
57
- return data.offset + buf.length
56
+ buf.copy(data.buffer, data.byteOffset)
57
+ return data.byteOffset + buf.length
58
58
  })
59
59
  }
60
60
  })
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
67
76
  const buf = Buffer.from('data')
68
77
  const ok = w.tryWrite(buf.length, (data) => {
69
- buf.copy(data.buffer, data.offset)
70
- return data.offset + buf.length
78
+ buf.copy(data.buffer, data.byteOffset)
79
+ return data.byteOffset + buf.length
71
80
  })
72
81
  if (!ok) {
73
82
  // Buffer is full — the reader hasn't caught up yet
@@ -78,28 +87,28 @@ if (!ok) {
78
87
 
79
88
  ```js
80
89
  // main.js
81
- import { alloc, writer } from '@nxtedition/shared'
90
+ import { State, Writer } from '@nxtedition/shared'
82
91
  import { Worker } from 'node:worker_threads'
83
92
 
84
- const { sharedState, sharedBuffer } = alloc(1024 * 1024)
93
+ const state = new State(1024 * 1024)
85
94
  const worker = new Worker('./reader-worker.js', {
86
- workerData: { sharedState, sharedBuffer },
95
+ workerData: state.buffer,
87
96
  })
88
97
 
89
- const w = writer({ sharedState, sharedBuffer })
98
+ const w = new Writer(state)
90
99
  // ... write messages
91
100
  ```
92
101
 
93
102
  ```js
94
103
  // reader-worker.js
95
- import { reader } from '@nxtedition/shared'
104
+ import { Reader } from '@nxtedition/shared'
96
105
  import { workerData } from 'node:worker_threads'
97
106
 
98
- const r = reader(workerData)
107
+ const r = new Reader(workerData)
99
108
 
100
109
  function poll() {
101
110
  const count = r.readSome((data) => {
102
- // process data.buffer at data.offset..data.offset+data.length
111
+ // process data.buffer at data.byteOffset..data.byteOffset+data.byteLength
103
112
  })
104
113
  setImmediate(poll)
105
114
  }
@@ -108,113 +117,117 @@ poll()
108
117
 
109
118
  ## API
110
119
 
111
- ### `alloc(size: number): SharedBuffers`
120
+ ### `new State(size: number)` / `new State(buffer: SharedArrayBuffer)`
121
+
122
+ Allocates or wraps a shared memory buffer for the ring buffer. The first 128 bytes are reserved for state (read/write pointers); the rest is the data region.
112
123
 
113
- Allocates the shared memory buffers for a ring buffer of the given byte size.
124
+ - **size** Data capacity in bytes (must be a positive integer, max ~2 GB)
125
+ - **buffer** — An existing `SharedArrayBuffer` to wrap
114
126
 
115
- - **size** — Buffer capacity in bytes (must be a positive integer, max ~2 GB)
116
- - Returns `{ sharedState: SharedArrayBuffer, sharedBuffer: SharedArrayBuffer }`
127
+ #### `state.buffer`
117
128
 
118
- ### `reader(buffers: SharedBuffers): Reader`
129
+ The underlying `SharedArrayBuffer`. Pass this to a worker thread to share the ring buffer.
119
130
 
120
- Creates a reader for the ring buffer.
131
+ ### `new Reader(state: State | SharedArrayBuffer)`
121
132
 
122
- #### `reader.readSome(next): number`
133
+ Creates a reader for the ring buffer. Accepts a `State` instance or a `SharedArrayBuffer` directly (shorthand for `new Reader(new State(buf))`).
123
134
 
124
- Reads a batch of messages. Calls `next(data)` for each message, where `data` has:
135
+ #### `reader.readSome(next, opaque?)`
136
+
137
+ Reads a batch of messages. Calls `next(data, opaque)` for each message, where `data` has:
125
138
 
126
139
  - `buffer: Buffer` — The underlying shared buffer
127
140
  - `view: DataView` — A DataView over the shared buffer
128
- - `offset: number` — Start offset of the message payload
129
- - `length: number` — Length of the message payload in bytes
141
+ - `byteOffset: number` — Start offset of the message payload
142
+ - `byteLength: number` — Length of the message payload in bytes
143
+
144
+ - **opaque** — Optional user-provided context object passed through to the callback. Useful for avoiding closures on hot paths.
130
145
 
131
146
  Return `false` from the callback to stop reading early. Returns the number of messages processed.
132
147
 
133
148
  Messages are batched: up to 1024 items or 256 KiB per call.
134
149
 
135
- ### `writer(buffers: SharedBuffers, options?): Writer`
150
+ ### `new Writer(state: State | SharedArrayBuffer, options?)`
136
151
 
137
- 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))`).
138
153
 
139
154
  **Options:**
140
155
 
141
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.
142
157
  - `logger?: { warn(obj, msg): void }` — Logger for yield warnings (pino-compatible).
143
158
 
144
- #### `writer.writeSync(len, fn, timeout?): void`
159
+ #### `writer.writeSync(len, fn, opaque?)`
145
160
 
146
161
  Synchronously writes a message. Blocks (via `Atomics.wait`) until buffer space is available.
147
162
 
148
163
  - **len** — Maximum payload size in bytes. Writing beyond `len` bytes in the callback is undefined behavior.
149
- - **fn(data) → number** — Write callback. Write payload into `data.buffer` starting at `data.offset`. **Must return the end position** (`data.offset + bytesWritten`), not the byte count.
150
- - **timeout** — Max wait time in ms (default: 60000). Throws on timeout.
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.
166
+
167
+ Throws on timeout (default: 60000 ms).
168
+
169
+ #### `writer.tryWrite(len, fn, opaque?)`
151
170
 
152
- #### `writer.tryWrite(len, fn): boolean`
171
+ Non-blocking write attempt. Returns `false` if the buffer is full. The `fn` and `opaque` parameters follow the same contract as `writeSync`.
153
172
 
154
- Non-blocking write attempt. Returns `false` if the buffer is full. The `fn` callback follows the same contract as `writeSync`.
173
+ #### `writer.cork(callback?)`
155
174
 
156
- #### `writer.cork(callback): T`
175
+ Batches multiple writes. The write pointer is only published to the reader when the cork is released, reducing atomic operation overhead.
157
176
 
158
- 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.
159
186
 
160
187
  ## Benchmarks
161
188
 
162
- 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).
163
190
 
164
191
  Each benchmark writes batches of fixed-size messages from the main thread and
165
192
  reads them in a worker thread. The shared ring buffer is compared against
166
- Node.js `postMessage` (structured clone). Hardware performance counters were
167
- collected with [`@mitata/counters`](https://github.com/evanwashere/mitata).
193
+ Node.js `postMessage` (structured clone).
168
194
 
169
195
  ### Throughput
170
196
 
171
197
  | Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
172
198
  | -----: | --------------: | --------------: | -------------------: | -------------------: |
173
- | 64 B | **1.07 GiB/s** | 793 MiB/s | 93 MiB/s | 117 MiB/s |
174
- | 256 B | **2.98 GiB/s** | 2.56 GiB/s | 259 MiB/s | 391 MiB/s |
175
- | 1 KiB | 4.65 GiB/s | **7.52 GiB/s** | 1.24 GiB/s | 1.68 GiB/s |
176
- | 4 KiB | 4.94 GiB/s | **16.38 GiB/s** | 3.77 GiB/s | 4.84 GiB/s |
177
- | 16 KiB | 5.25 GiB/s | **22.33 GiB/s** | 8.54 GiB/s | 9.65 GiB/s |
178
- | 64 KiB | 5.53 GiB/s | **19.86 GiB/s** | 10.94 GiB/s | 12.25 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** |
179
205
 
180
206
  ### Message rate
181
207
 
182
208
  | Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
183
209
  | -----: | --------------: | --------------: | -------------------: | -------------------: |
184
- | 64 B | **17.99 M/s** | 12.99 M/s | 1.53 M/s | 1.92 M/s |
185
- | 256 B | **12.50 M/s** | 10.73 M/s | 1.06 M/s | 1.60 M/s |
186
- | 1 KiB | 4.87 M/s | **7.88 M/s** | 1.30 M/s | 1.76 M/s |
187
- | 4 KiB | 1.30 M/s | **4.29 M/s** | 989 K/s | 1.27 M/s |
188
- | 16 KiB | 344 K/s | **1.46 M/s** | 560 K/s | 632 K/s |
189
- | 64 KiB | 91 K/s | **325 K/s** | 179 K/s | 201 K/s |
190
-
191
- ### CPU efficiency (instructions per cycle)
192
-
193
- | Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
194
- | -----: | --------------: | --------------: | -------------------: | -------------------: |
195
- | 64 B | 4.80 | 5.79 | 3.91 | 3.37 |
196
- | 256 B | 4.46 | 5.98 | 3.48 | 3.06 |
197
- | 1 KiB | 4.17 | **6.29** | 3.63 | 3.15 |
198
- | 4 KiB | 3.75 | **6.72** | 3.38 | 2.83 |
199
- | 16 KiB | 3.80 | **6.03** | 2.74 | 2.86 |
200
- | 64 KiB | 3.96 | **4.57** | 2.43 | 2.93 |
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** |
201
216
 
202
217
  ### Key findings
203
218
 
204
- - **Small messages (64-256 B):** The shared ring buffer with `Buffer.copy` delivers
205
- up to **12x higher message rate** and **9x higher throughput** than `postMessage`.
206
- Per-message overhead dominates at these sizes, and avoiding structured cloning makes
207
- the biggest difference.
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.
208
223
 
209
- - **Large messages (1-64 KiB):** The shared ring buffer with string encoding
210
- (`Buffer.write`) reaches up to **22 GiB/s** — roughly **2-4x faster** than
211
- `postMessage`. V8's ASCII fast path for UTF-8 encoding is heavily vectorized
212
- (6-7 IPC on Apple M3 Pro), which explains why string writes outperform raw
213
- `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.
214
227
 
215
- - **CPU efficiency:** The shared ring buffer consistently achieves higher IPC
216
- (4-7) compared to `postMessage` (2-4), indicating less time spent stalled on
217
- 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.
218
231
 
219
232
  - **Caveat:** The string benchmark uses ASCII-only content. Multi-byte UTF-8
220
233
  strings will not hit V8's vectorized fast path and will be significantly slower.
@@ -222,8 +235,7 @@ collected with [`@mitata/counters`](https://github.com/evanwashere/mitata).
222
235
  ### Running the benchmark
223
236
 
224
237
  ```sh
225
- # Hardware counters require elevated privileges on macOS
226
- sudo node --allow-natives-syntax packages/shared/src/bench.mjs
238
+ node --allow-natives-syntax packages/shared/src/bench.mjs
227
239
  ```
228
240
 
229
241
  ## License
package/lib/index.d.ts CHANGED
@@ -1,44 +1,57 @@
1
- export interface SharedBuffers {
2
- sharedState: SharedArrayBuffer;
3
- sharedBuffer: SharedArrayBuffer;
4
- }
5
1
  export interface BufferRegion {
6
2
  buffer: Buffer;
7
3
  view: DataView;
8
- offset: number;
9
- length: number;
10
4
  byteOffset: number;
11
5
  byteLength: number;
12
6
  }
13
- /**
14
- * Allocates the shared memory buffers.
15
- */
16
- export declare function alloc(size: number): SharedBuffers;
17
- export interface Reader {
18
- readSome<U>(next: (data: BufferRegion, opaque: U) => void | boolean, opaque: U): number;
19
- readSome(next: (data: BufferRegion) => void | boolean): number;
20
- }
21
- /**
22
- * Creates a reader for the ring buffer.
23
- */
24
- export declare function reader({ sharedState, sharedBuffer }: SharedBuffers): Reader;
25
7
  export interface WriterOptions {
26
8
  yield?: () => void;
27
9
  logger?: {
28
10
  warn(obj: object, msg: string): void;
29
11
  };
30
12
  }
31
- export interface Writer {
32
- tryWrite(len: number, fn: (data: BufferRegion) => number): boolean;
33
- tryWrite<U>(len: number, fn: (data: BufferRegion, opaque: U) => number, opaque: U): boolean;
34
- writeSync(len: number, fn: (data: BufferRegion) => number): void;
35
- writeSync<U>(len: number, fn: (data: BufferRegion, opaque: U) => number, opaque: U): void;
36
- cork<T>(callback: () => T): T;
37
- cork(): void;
38
- uncork(): void;
39
- flushSync(): void;
13
+ /**
14
+ * Shared ring buffer state. Allocates or wraps a SharedArrayBuffer where the
15
+ * first 128 bytes are reserved for read/write pointers and the rest is the
16
+ * data region.
17
+ */
18
+ export declare class State {
19
+ readonly buffer: SharedArrayBuffer;
20
+ constructor(size: number);
21
+ constructor(buffer: SharedArrayBuffer);
22
+ }
23
+ /**
24
+ * Reader for the ring buffer.
25
+ */
26
+ export declare class Reader {
27
+ #private;
28
+ constructor(state: State | SharedArrayBuffer);
29
+ readSome<U>(next: (data: BufferRegion, opaque?: U) => void | boolean, opaque?: U): number;
40
30
  }
41
31
  /**
42
- * Creates a writer for the ring buffer.
32
+ * Writer for the ring buffer.
43
33
  */
44
- export declare function writer({ sharedState, sharedBuffer }: SharedBuffers, { yield: onYield, logger }?: WriterOptions): Writer;
34
+ export declare class Writer {
35
+ #private;
36
+ constructor(state: State | SharedArrayBuffer, { yield: onYield, logger }?: WriterOptions);
37
+ /**
38
+ * Synchronously writes a message. Blocks (via `Atomics.wait`) until buffer space is available.
39
+ * Writing more than "len" bytes in the callback will cause undefined behavior.
40
+ */
41
+ writeSync<U>(len: number, fn: (data: BufferRegion, opaque?: U) => number, opaque?: U): void;
42
+ /**
43
+ * Non-blocking write attempt. Returns `false` if the buffer is full.
44
+ * Writing more than "len" bytes in the callback will cause undefined behavior.
45
+ */
46
+ tryWrite<U>(len: number, fn: (data: BufferRegion, opaque?: U) => number, opaque?: U): boolean;
47
+ /**
48
+ * Batches multiple writes within the callback. The write pointer is only
49
+ * published to the reader when cork returns, reducing atomic operation overhead.
50
+ */
51
+ cork<T>(callback?: () => T): T | undefined;
52
+ /**
53
+ * Publishes the pending write position to the reader.
54
+ */
55
+ uncork(): void;
56
+ flushSync(): void;
57
+ }
package/lib/index.js CHANGED
@@ -7,90 +7,106 @@
7
7
  const WRITE_INDEX = 0
8
8
  const READ_INDEX = 16
9
9
 
10
+ // The first 128 bytes of the buffer are reserved for state (read/write pointers).
11
+ // Data starts at byte offset 128.
12
+ const STATE_BYTES = 128
13
+
10
14
  // High-Water Mark for batching operations to reduce the frequency
11
15
  // of expensive atomic writes.
12
16
  const HWM_BYTES = 256 * 1024 // 256 KiB
13
17
  const HWM_COUNT = 1024 // 1024 items
14
18
 
15
-
16
-
17
-
18
-
19
-
20
19
 
21
20
 
22
21
 
23
-
24
-
25
22
 
26
23
 
27
24
 
28
25
 
26
+
27
+
28
+
29
+
30
+
29
31
  /**
30
- * Allocates the shared memory buffers.
32
+ * Shared ring buffer state. Allocates or wraps a SharedArrayBuffer where the
33
+ * first 128 bytes are reserved for read/write pointers and the rest is the
34
+ * data region.
31
35
  */
32
- export function alloc(size ) {
33
- if (!Number.isInteger(size)) {
34
- throw new TypeError('size must be a positive integer')
35
- }
36
- if (size <= 0) {
37
- throw new RangeError('size must be a positive integer')
38
- }
39
- if (size >= 2 ** 31 - 8) {
40
- throw new RangeError('size exceeds maximum of 2GB minus header size')
41
- }
42
-
43
- return {
44
- // A small buffer for sharing state (read/write pointers).
45
- sharedState: new SharedArrayBuffer(128),
46
- // The main buffer for transferring data.
47
- // We need another 8 bytes for entry headers.
48
- sharedBuffer: new SharedArrayBuffer(size + 8),
36
+ export class State {
37
+ buffer
38
+
39
+
40
+
41
+ constructor(sizeOrBuffer ) {
42
+ if (sizeOrBuffer instanceof SharedArrayBuffer) {
43
+ if (sizeOrBuffer.byteLength < STATE_BYTES + 8) {
44
+ throw new RangeError('SharedArrayBuffer too small for ring buffer state')
45
+ }
46
+ if (sizeOrBuffer.byteLength >= 2 ** 31) {
47
+ throw new RangeError('Shared buffer size exceeds maximum of 2GB')
48
+ }
49
+ this.buffer = sizeOrBuffer
50
+ } else {
51
+ const size = sizeOrBuffer
52
+ if (!Number.isInteger(size)) {
53
+ throw new TypeError('size must be a positive integer')
54
+ }
55
+ if (size <= 0) {
56
+ throw new RangeError('size must be a positive integer')
57
+ }
58
+ if (size >= 2 ** 31 - 11) {
59
+ throw new RangeError('size exceeds maximum of 2GB minus header size')
60
+ }
61
+ // 128 bytes for state + data region (rounded up to 4-byte boundary for Int32Array).
62
+ this.buffer = new SharedArrayBuffer(STATE_BYTES + ((size + 8 + 3) & ~3))
63
+ }
49
64
  }
50
65
  }
51
66
 
52
-
53
-
54
-
55
-
56
-
57
67
  /**
58
- * Creates a reader for the ring buffer.
68
+ * Reader for the ring buffer.
59
69
  */
60
- export function reader({ sharedState, sharedBuffer } ) {
61
- if (!(sharedState instanceof SharedArrayBuffer)) {
62
- throw new TypeError('sharedState must be a SharedArrayBuffer')
63
- }
64
- if (!(sharedBuffer instanceof SharedArrayBuffer)) {
65
- throw new TypeError('sharedBuffer must be a SharedArrayBuffer')
66
- }
67
- if (sharedBuffer.byteLength >= 2 ** 31) {
68
- throw new RangeError('Shared buffer size exceeds maximum of 2GB')
70
+ export class Reader {
71
+ #state
72
+ #size
73
+ #int32
74
+ #data
75
+ #readPos
76
+
77
+ constructor(state ) {
78
+ const sharedBuffer = state instanceof SharedArrayBuffer ? state : state.buffer
79
+ const size = sharedBuffer.byteLength - STATE_BYTES
80
+
81
+ this.#state = new Int32Array(sharedBuffer, 0, STATE_BYTES >> 2)
82
+ this.#size = size
83
+ this.#int32 = new Int32Array(sharedBuffer, STATE_BYTES)
84
+
85
+ // This object is reused to avoid creating new objects in a hot path.
86
+ // This helps V8 maintain a stable hidden class for the object,
87
+ // which is a key optimization (zero-copy read).
88
+ this.#data = {
89
+ buffer: Buffer.from(sharedBuffer, STATE_BYTES, size),
90
+ view: new DataView(sharedBuffer, STATE_BYTES, size),
91
+ byteOffset: 0,
92
+ byteLength: 0,
93
+ }
94
+
95
+ // Local copy of the pointer. The `| 0` is a hint to the V8 JIT
96
+ // compiler that this is a 32-bit integer, enabling optimizations.
97
+ this.#readPos = Atomics.load(this.#state, READ_INDEX) | 0
69
98
  }
70
99
 
71
- const state = new Int32Array(sharedState)
72
- const size = sharedBuffer.byteLength
73
- const buffer = Buffer.from(sharedBuffer)
74
- const view = new DataView(sharedBuffer)
75
-
76
- // This object is reused to avoid creating new objects in a hot path.
77
- // This helps V8 maintain a stable hidden class for the object,
78
- // which is a key optimization (zero-copy read).
79
- const data = { buffer, view, offset: 0, length: 0, byteOffset: 0, byteLength: 0 }
80
-
81
- // Local copies of the pointers. The `| 0` is a hint to the V8 JIT
82
- // compiler that these are 32-bit integers, enabling optimizations.
83
- let readPos = Atomics.load(state, READ_INDEX) | 0
84
- let writePos = Atomics.load(state, WRITE_INDEX) | 0
85
-
86
- function readSome (
87
- next ,
88
- opaque ,
89
- ) {
100
+ readSome (next , opaque ) {
90
101
  let count = 0
91
102
  let bytes = 0
92
103
 
93
- writePos = state[WRITE_INDEX] | 0
104
+ const state = this.#state
105
+ const int32 = this.#int32
106
+ const size = this.#size
107
+ const data = this.#data
108
+ let readPos = this.#readPos
109
+ let writePos = state[WRITE_INDEX] | 0
94
110
 
95
111
  // First, check if the local writePos matches the readPos.
96
112
  // If so, refresh it from shared memory in case the writer has added data.
@@ -100,8 +116,8 @@ export function reader({ sharedState, sharedBuffer } ) {
100
116
 
101
117
  // Process messages in a batch to minimize loop and atomic operation overhead.
102
118
  while (count < HWM_COUNT && bytes < HWM_BYTES && readPos !== writePos) {
119
+ const dataLen = int32[readPos >> 2] | 0
103
120
  const dataPos = readPos + 4
104
- const dataLen = view.getInt32(dataPos - 4, true) | 0
105
121
 
106
122
  bytes += 4
107
123
 
@@ -120,16 +136,16 @@ export function reader({ sharedState, sharedBuffer } ) {
120
136
  throw new Error('Data exceeds buffer size')
121
137
  }
122
138
 
123
- readPos += 4 + dataLen
139
+ // Advance by aligned length so next header is on a 4-byte boundary.
140
+ const alignedLen = (dataLen + 3) & ~3
141
+ readPos += 4 + alignedLen
124
142
 
125
- bytes += dataLen
143
+ bytes += alignedLen
126
144
  count += 1
127
145
 
128
146
  // This is a "zero-copy" operation. We don't copy the data out.
129
147
  // Instead, we pass a "view" into the shared buffer.
130
- data.offset = dataPos
131
148
  data.byteOffset = dataPos
132
- data.length = dataLen
133
149
  data.byteLength = dataLen
134
150
 
135
151
  if (next(data, opaque) === false) {
@@ -138,6 +154,8 @@ export function reader({ sharedState, sharedBuffer } ) {
138
154
  }
139
155
  }
140
156
 
157
+ this.#readPos = readPos
158
+
141
159
  // IMPORTANT: The reader only updates its shared `readPos` after a batch
142
160
  // is processed. This significantly reduces atomic operation overhead.
143
161
  if (bytes > 0) {
@@ -146,190 +164,169 @@ export function reader({ sharedState, sharedBuffer } ) {
146
164
 
147
165
  return count
148
166
  }
149
-
150
- return { readSome }
151
167
  }
152
168
 
153
-
154
-
155
-
156
-
157
-
158
-
159
-
160
-
161
-
162
-
163
-
164
-
165
-
166
-
167
-
168
-
169
169
  /**
170
- * Creates a writer for the ring buffer.
170
+ * Writer for the ring buffer.
171
171
  */
172
- export function writer(
173
- { sharedState, sharedBuffer } ,
174
- { yield: onYield, logger } = {},
175
- ) {
176
- if (!(sharedState instanceof SharedArrayBuffer)) {
177
- throw new TypeError('sharedState must be a SharedArrayBuffer')
178
- }
179
- if (!(sharedBuffer instanceof SharedArrayBuffer)) {
180
- throw new TypeError('sharedBuffer must be a SharedArrayBuffer')
181
- }
182
- if (sharedBuffer.byteLength >= 2 ** 31) {
183
- throw new RangeError('Shared buffer size exceeds maximum of 2GB')
184
- }
185
-
186
- const state = new Int32Array(sharedState)
187
- const size = sharedBuffer.byteLength
188
- const buffer = Buffer.from(sharedBuffer)
189
- const view = new DataView(sharedBuffer)
172
+ export class Writer {
173
+ #state
174
+ #size
175
+ #int32
176
+ #data
177
+ #readPos
178
+ #writePos
179
+ #yielding
180
+ #corked
181
+ #pending
182
+ #onYield
183
+ #logger
184
+ #uncorkBound
185
+
186
+ constructor(state , { yield: onYield, logger } = {}) {
187
+ const sharedBuffer = state instanceof SharedArrayBuffer ? state : state.buffer
188
+
189
+ if (onYield != null && typeof onYield !== 'function') {
190
+ throw new TypeError('onYield must be a function')
191
+ }
190
192
 
191
- // This object is reused to avoid creating new objects in a hot path.
192
- // This helps V8 maintain a stable hidden class for the object,
193
- // which is a key optimization (zero-copy read).
194
- const data = { buffer, view, offset: 0, length: 0, byteOffset: 0, byteLength: 0 }
193
+ const size = sharedBuffer.byteLength - STATE_BYTES
195
194
 
196
- // Local copies of the pointers. The `| 0` is a hint to the V8 JIT
197
- // compiler that these are 32-bit integers, enabling optimizations.
198
- let readPos = Atomics.load(state, READ_INDEX) | 0
199
- let writePos = Atomics.load(state, WRITE_INDEX) | 0
195
+ this.#state = new Int32Array(sharedBuffer, 0, STATE_BYTES >> 2)
196
+ this.#size = size
197
+ this.#int32 = new Int32Array(sharedBuffer, STATE_BYTES)
200
198
 
201
- let yielding = 0
202
- let corked = 0
203
- let pending = 0
199
+ // This object is reused to avoid creating new objects in a hot path.
200
+ // This helps V8 maintain a stable hidden class for the object,
201
+ // which is a key optimization (zero-copy read).
202
+ this.#data = {
203
+ buffer: Buffer.from(sharedBuffer, STATE_BYTES, size),
204
+ view: new DataView(sharedBuffer, STATE_BYTES, size),
205
+ byteOffset: 0,
206
+ byteLength: 0,
207
+ }
204
208
 
205
- if (onYield != null && typeof onYield !== 'function') {
206
- throw new TypeError('onYield must be a function')
209
+ // Local copies of the pointers. The `| 0` is a hint to the V8 JIT
210
+ // compiler that these are 32-bit integers, enabling optimizations.
211
+ this.#readPos = Atomics.load(this.#state, READ_INDEX) | 0
212
+ this.#writePos = Atomics.load(this.#state, WRITE_INDEX) | 0
213
+
214
+ this.#yielding = 0
215
+ this.#corked = 0
216
+ this.#pending = 0
217
+ this.#onYield = onYield
218
+ this.#logger = logger
219
+ this.#uncorkBound = this.uncork.bind(this)
207
220
  }
208
221
 
209
222
  /**
210
223
  * Pauses the writer thread to wait for the reader to catch up.
211
224
  */
212
- function _yield(delay ) {
213
- if (yielding > 128) {
225
+ #yield(delay ) {
226
+ if (this.#yielding > 128) {
214
227
  throw new Error('Detected possible deadlock: writer yielding too many times')
215
228
  }
216
229
 
217
230
  // First, ensure the very latest write position is visible to the reader.
218
- _flush()
231
+ this.flushSync()
219
232
 
220
- if (onYield) {
221
- yielding += 1
233
+ if (this.#onYield) {
234
+ this.#yielding += 1
222
235
  try {
223
236
  // Call the user-provided yield function, if any. This can be important
224
237
  // if the writer is waiting for the reader to process data which would
225
238
  // otherwise deadlock.
226
- onYield()
239
+ this.#onYield()
227
240
  } finally {
228
- yielding -= 1
241
+ this.#yielding -= 1
229
242
  }
230
243
  }
231
244
 
232
245
  // Atomics.wait is the most efficient way to pause. It puts the thread
233
246
  // to sleep, consuming no CPU, until the reader changes the READ_INDEX.
234
247
  if (delay > 0) {
235
- Atomics.wait(state, READ_INDEX, readPos, delay)
248
+ Atomics.wait(this.#state, READ_INDEX, this.#readPos, delay)
236
249
  } else {
237
250
  // @ts-expect-error Atomics.pause is Stage 3, available in Node.js 25+
238
251
  Atomics.pause()
239
252
  }
240
253
 
241
254
  // After waking up, refresh the local view of the reader's position.
242
- readPos = Atomics.load(state, READ_INDEX) | 0
255
+ this.#readPos = Atomics.load(this.#state, READ_INDEX) | 0
243
256
  }
244
257
 
245
258
  /**
246
259
  * Tries to acquire enough space in the buffer for a new message.
247
260
  */
248
- function _acquire(len ) {
249
- // Total space required: payload + its 4-byte length header + a potential
261
+ #acquire(len ) {
262
+ // Total space required: aligned payload + its 4-byte length header + a potential
250
263
  // 4-byte header for the *next* message (for wrap-around check).
251
- const required = len + 4 + 4
264
+ const required = ((len + 3) & ~3) + 4 + 4
265
+ const size = this.#size
266
+ const state = this.#state
267
+ const int32 = this.#int32
252
268
 
253
- if (writePos >= readPos) {
269
+ if (this.#writePos >= this.#readPos) {
254
270
  // Case 1: The writer is ahead of the reader. [ 0 - R ... W - size ]
255
271
  // There is free space from W to the end (s) and from 0 to R.
256
272
 
257
- if (size - writePos >= required) {
273
+ if (size - this.#writePos >= required) {
258
274
  // Enough space at the end of the buffer.
259
275
  return true
260
276
  }
261
277
 
262
- readPos = state[READ_INDEX] | 0
263
- if (readPos === 0) {
264
- _yield(0)
278
+ this.#readPos = state[READ_INDEX] | 0
279
+ if (this.#readPos === 0) {
280
+ this.#yield(0)
265
281
  }
266
282
 
267
283
  // Not enough space at the end. Check if there's space at the beginning.
268
- if (readPos === 0) {
284
+ if (this.#readPos === 0) {
269
285
  // Reader is at the beginning, so no space to wrap around into.
270
286
  return false
271
287
  }
272
288
 
273
289
  // Mark the current position with a wrap-around signal (-1).
274
- view.setInt32(writePos, -1, true)
290
+ int32[this.#writePos >> 2] = -1
275
291
 
276
292
  // Reset writer position to the beginning.
277
- writePos = 0
293
+ this.#writePos = 0
278
294
 
279
- if (writePos + 4 > size) {
295
+ if (this.#writePos + 4 > size) {
280
296
  // assertion
281
- throw new Error(`Write position ${writePos} with next header exceeds buffer size ${size}`)
297
+ throw new Error(
298
+ `Write position ${this.#writePos} with next header exceeds buffer size ${size}`,
299
+ )
282
300
  }
283
- if (writePos === readPos) {
301
+ if (this.#writePos === this.#readPos) {
284
302
  // assertion
285
- throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
303
+ throw new Error(
304
+ `Write position ${this.#writePos} cannot equal read position ${this.#readPos}`,
305
+ )
286
306
  }
287
307
 
288
- Atomics.store(state, WRITE_INDEX, writePos)
308
+ Atomics.store(state, WRITE_INDEX, this.#writePos)
289
309
  }
290
310
 
291
311
  // Case 2: The writer has wrapped around. [ 0 ... W - R ... s ]
292
312
  // The only free space is between W and R.
293
313
 
294
- readPos = state[READ_INDEX] | 0
295
- if (readPos - writePos < required) {
296
- _yield(0)
297
- }
298
-
299
- return readPos - writePos >= required
300
- }
301
-
302
- /**
303
- * "Uncorks" the stream by publishing the pending write position.
304
- * This is called from a microtask to batch atomic stores.
305
- */
306
- function _uncork() {
307
- corked -= 1
308
- if (corked === 0) {
309
- _flush()
314
+ this.#readPos = state[READ_INDEX] | 0
315
+ if (this.#readPos - this.#writePos < required) {
316
+ this.#yield(0)
310
317
  }
311
- }
312
318
 
313
- function _flush() {
314
- if (pending > 0) {
315
- Atomics.store(state, WRITE_INDEX, writePos)
316
- pending = 0
317
- }
319
+ return this.#readPos - this.#writePos >= required
318
320
  }
319
321
 
320
322
  /**
321
323
  * Performs the actual write into the buffer after space has been acquired.
322
324
  */
323
- function _write (
324
- dataCap ,
325
- fn ,
326
- opaque ,
327
- ) {
328
- const dataPos = writePos + 4
329
-
330
- data.offset = dataPos
325
+ #write (dataCap , fn , opaque ) {
326
+ const dataPos = this.#writePos + 4
327
+ const data = this.#data
328
+
331
329
  data.byteOffset = dataPos
332
- data.length = dataCap
333
330
  data.byteLength = dataCap
334
331
 
335
332
  // The user-provided function writes the data and returns the final position.
@@ -347,56 +344,54 @@ export function writer(
347
344
  throw new RangeError(`"fn" returned a number ${dataLen} that exceeds capacity ${dataCap}`)
348
345
  }
349
346
 
347
+ const size = this.#size
350
348
  if (dataPos + dataLen > size) {
351
349
  // assertion
352
350
  throw new Error(`Data position ${dataPos} with length ${dataLen} exceeds buffer size ${size}`)
353
351
  }
354
352
 
355
- const nextPos = writePos + 4 + dataLen
353
+ const alignedLen = (dataLen + 3) & ~3
354
+ const nextPos = this.#writePos + 4 + alignedLen
356
355
 
357
356
  if (nextPos + 4 > size) {
358
357
  // assertion
359
358
  throw new Error(`Write position ${nextPos} with next header exceeds buffer size ${size}`)
360
359
  }
361
- if (nextPos === readPos) {
360
+ if (nextPos === this.#readPos) {
362
361
  // assertion
363
- throw new Error(`Write position ${nextPos} cannot equal read position ${readPos}`)
362
+ throw new Error(`Write position ${nextPos} cannot equal read position ${this.#readPos}`)
364
363
  }
365
364
 
366
365
  // Write the actual length of the data into the 4-byte header.
367
- view.setInt32(writePos, dataLen, true)
368
- writePos += 4 + dataLen
369
- pending += 4 + dataLen
366
+ this.#int32[this.#writePos >> 2] = dataLen
367
+ this.#writePos += 4 + alignedLen
368
+ this.#pending += 4 + alignedLen
370
369
 
371
370
  // This is the "corking" optimization. Instead of calling Atomics.store
372
371
  // on every write, we batch them. We either write when a certain
373
372
  // amount of data is pending (HWM_BYTES) or at the end of the current
374
373
  // event loop tick. This drastically reduces atomic operation overhead.
375
- if (pending >= HWM_BYTES) {
376
- Atomics.store(state, WRITE_INDEX, writePos)
377
- pending = 0
378
- } else if (corked === 0) {
379
- corked += 1
380
- setImmediate(_uncork)
374
+ if (this.#pending >= HWM_BYTES) {
375
+ Atomics.store(this.#state, WRITE_INDEX, this.#writePos)
376
+ this.#pending = 0
377
+ } else if (this.#corked === 0) {
378
+ this.#corked += 1
379
+ setImmediate(this.#uncorkBound)
381
380
  }
382
381
  }
383
382
 
384
383
  /**
385
- * Public write method. Acquires space and synchronously writes data with a timeout. Will
386
- * wait until space is available.
384
+ * Synchronously writes a message. Blocks (via `Atomics.wait`) until buffer space is available.
387
385
  * Writing more than "len" bytes in the callback will cause undefined behavior.
388
386
  */
389
- function writeSync (
390
- len ,
391
- fn ,
392
- opaque ,
393
- ) {
387
+ writeSync (len , fn , opaque ) {
394
388
  if (typeof len !== 'number') {
395
389
  throw new TypeError('"len" must be a non-negative number')
396
390
  }
397
391
  if (len < 0) {
398
392
  throw new RangeError(`"len" ${len} is negative`)
399
393
  }
394
+ const size = this.#size
400
395
  if (len >= 2 ** 31 || len > size - 8) {
401
396
  throw new Error(`"len" ${len} exceeds maximum allowed size ${size - 8}`)
402
397
  }
@@ -404,48 +399,53 @@ export function writer(
404
399
  throw new TypeError('"fn" must be a function')
405
400
  }
406
401
 
407
- if (!_acquire(len)) {
402
+ if (!this.#acquire(len)) {
408
403
  const startTime = performance.now()
409
404
  let yieldCount = 0
410
405
  let yieldTime = 0
411
- for (let n = 0; !_acquire(len); n++) {
406
+ for (let n = 0; !this.#acquire(len); n++) {
412
407
  if (performance.now() - startTime > 60e3) {
413
408
  throw new Error('Timeout while waiting for space in the buffer')
414
409
  }
415
- _yield(3)
410
+ this.#yield(3)
416
411
  yieldCount += 1
417
412
  yieldTime += 3
418
413
  }
419
414
  const elapsedTime = performance.now() - startTime
420
- logger?.warn(
421
- { yieldLength: len, readPos, writePos, elapsedTime, yieldCount, yieldTime },
415
+ this.#logger?.warn(
416
+ {
417
+ yieldLength: len,
418
+ readPos: this.#readPos,
419
+ writePos: this.#writePos,
420
+ elapsedTime,
421
+ yieldCount,
422
+ yieldTime,
423
+ },
422
424
  'yielded',
423
425
  )
424
426
  }
425
427
 
426
- _write(len, fn, opaque)
428
+ this.#write(len, fn, opaque)
427
429
 
428
- if (writePos === readPos) {
429
- throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
430
+ if (this.#writePos === this.#readPos) {
431
+ throw new Error(
432
+ `Write position ${this.#writePos} cannot equal read position ${this.#readPos}`,
433
+ )
430
434
  }
431
435
  }
432
436
 
433
437
  /**
434
- * Public write method. Acquires space and tries to write data.
438
+ * Non-blocking write attempt. Returns `false` if the buffer is full.
435
439
  * Writing more than "len" bytes in the callback will cause undefined behavior.
436
440
  */
437
-
438
- function tryWrite (
439
- len ,
440
- fn ,
441
- opaque ,
442
- ) {
441
+ tryWrite (len , fn , opaque ) {
443
442
  if (typeof len !== 'number') {
444
443
  throw new TypeError('"len" must be a non-negative number')
445
444
  }
446
445
  if (len < 0) {
447
446
  throw new RangeError(`"len" ${len} is negative`)
448
447
  }
448
+ const size = this.#size
449
449
  if (len >= 2 ** 31 || len > size - 8) {
450
450
  throw new Error(`"len" ${len} exceeds maximum allowed size ${size - 8}`)
451
451
  }
@@ -453,29 +453,54 @@ export function writer(
453
453
  throw new TypeError('"fn" must be a function')
454
454
  }
455
455
 
456
- if (!_acquire(len)) {
456
+ if (!this.#acquire(len)) {
457
457
  return false
458
458
  }
459
459
 
460
- _write(len, fn, opaque)
460
+ this.#write(len, fn, opaque)
461
461
 
462
- if (writePos === readPos) {
463
- throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
462
+ if (this.#writePos === this.#readPos) {
463
+ throw new Error(
464
+ `Write position ${this.#writePos} cannot equal read position ${this.#readPos}`,
465
+ )
464
466
  }
465
467
 
466
468
  return true
467
469
  }
468
470
 
469
- function cork (callback ) {
470
- corked += 1
471
+ /**
472
+ * Batches multiple writes within the callback. The write pointer is only
473
+ * published to the reader when cork returns, reducing atomic operation overhead.
474
+ */
475
+ cork (callback ) {
476
+ this.#corked += 1
471
477
  if (callback != null) {
472
478
  try {
473
479
  return callback()
474
480
  } finally {
475
- _uncork()
481
+ this.uncork()
476
482
  }
477
483
  }
478
484
  }
479
485
 
480
- return { tryWrite, writeSync, cork, uncork: _uncork, flushSync: _flush }
486
+ /**
487
+ * Publishes the pending write position to the reader.
488
+ */
489
+ uncork() {
490
+ if (this.#corked === 0) {
491
+ return
492
+ }
493
+
494
+ this.#corked -= 1
495
+ if (this.#corked === 0) {
496
+ this.flushSync()
497
+ }
498
+ }
499
+
500
+ flushSync() {
501
+ if (this.#pending > 0) {
502
+ Atomics.store(this.#state, WRITE_INDEX, this.#writePos)
503
+ this.#pending = 0
504
+ }
505
+ }
481
506
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/shared",
3
- "version": "3.0.2",
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": "3648df9e97a19a6ebdf497afb1845a01b5301460"
29
+ }
31
30
  }