@nxtedition/shared 3.0.1 → 4.0.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/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,8 +53,8 @@ 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
  })
@@ -66,8 +66,8 @@ w.cork(() => {
66
66
  ```js
67
67
  const buf = Buffer.from('data')
68
68
  const ok = w.tryWrite(buf.length, (data) => {
69
- buf.copy(data.buffer, data.offset)
70
- return data.offset + buf.length
69
+ buf.copy(data.buffer, data.byteOffset)
70
+ return data.byteOffset + buf.length
71
71
  })
72
72
  if (!ok) {
73
73
  // Buffer is full — the reader hasn't caught up yet
@@ -78,28 +78,29 @@ if (!ok) {
78
78
 
79
79
  ```js
80
80
  // main.js
81
- import { alloc, writer } from '@nxtedition/shared'
81
+ import { State, Writer } from '@nxtedition/shared'
82
82
  import { Worker } from 'node:worker_threads'
83
83
 
84
- const { sharedState, sharedBuffer } = alloc(1024 * 1024)
84
+ const state = new State(1024 * 1024)
85
85
  const worker = new Worker('./reader-worker.js', {
86
- workerData: { sharedState, sharedBuffer },
86
+ workerData: state.buffer,
87
87
  })
88
88
 
89
- const w = writer({ sharedState, sharedBuffer })
89
+ const w = new Writer(state)
90
90
  // ... write messages
91
91
  ```
92
92
 
93
93
  ```js
94
94
  // reader-worker.js
95
- import { reader } from '@nxtedition/shared'
95
+ import { State, Reader } from '@nxtedition/shared'
96
96
  import { workerData } from 'node:worker_threads'
97
97
 
98
- const r = reader(workerData)
98
+ const state = new State(workerData)
99
+ const r = new Reader(state)
99
100
 
100
101
  function poll() {
101
102
  const count = r.readSome((data) => {
102
- // process data.buffer at data.offset..data.offset+data.length
103
+ // process data.buffer at data.byteOffset..data.byteOffset+data.byteLength
103
104
  })
104
105
  setImmediate(poll)
105
106
  }
@@ -108,31 +109,31 @@ poll()
108
109
 
109
110
  ## API
110
111
 
111
- ### `alloc(size: number): SharedBuffers`
112
+ ### `new State(size: number)` / `new State(buffer: SharedArrayBuffer)`
112
113
 
113
- Allocates the shared memory buffers for a ring buffer of the given byte size.
114
+ 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.
114
115
 
115
- - **size** — Buffer capacity in bytes (must be a positive integer, max ~2 GB)
116
- - Returns `{ sharedState: SharedArrayBuffer, sharedBuffer: SharedArrayBuffer }`
116
+ - **size** — Data capacity in bytes (must be a positive integer, max ~2 GB)
117
+ - **buffer** An existing `SharedArrayBuffer` to wrap
117
118
 
118
- ### `reader(buffers: SharedBuffers): Reader`
119
+ ### `new Reader(state: State)`
119
120
 
120
121
  Creates a reader for the ring buffer.
121
122
 
122
- #### `reader.readSome(next): number`
123
+ #### `reader.readSome(next)`
123
124
 
124
125
  Reads a batch of messages. Calls `next(data)` for each message, where `data` has:
125
126
 
126
127
  - `buffer: Buffer` — The underlying shared buffer
127
128
  - `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
129
+ - `byteOffset: number` — Start offset of the message payload
130
+ - `byteLength: number` — Length of the message payload in bytes
130
131
 
131
132
  Return `false` from the callback to stop reading early. Returns the number of messages processed.
132
133
 
133
134
  Messages are batched: up to 1024 items or 256 KiB per call.
134
135
 
135
- ### `writer(buffers: SharedBuffers, options?): Writer`
136
+ ### `new Writer(state: State, options?)`
136
137
 
137
138
  Creates a writer for the ring buffer.
138
139
 
@@ -141,22 +142,93 @@ Creates a writer for the ring buffer.
141
142
  - `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
143
  - `logger?: { warn(obj, msg): void }` — Logger for yield warnings (pino-compatible).
143
144
 
144
- #### `writer.writeSync(len, fn, timeout?): void`
145
+ #### `writer.writeSync(len, fn)`
145
146
 
146
147
  Synchronously writes a message. Blocks (via `Atomics.wait`) until buffer space is available.
147
148
 
148
149
  - **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.
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.
151
151
 
152
- #### `writer.tryWrite(len, fn): boolean`
152
+ Throws on timeout (default: 60000 ms).
153
+
154
+ #### `writer.tryWrite(len, fn)`
153
155
 
154
156
  Non-blocking write attempt. Returns `false` if the buffer is full. The `fn` callback follows the same contract as `writeSync`.
155
157
 
156
- #### `writer.cork(callback): T`
158
+ #### `writer.cork(callback)`
157
159
 
158
160
  Batches multiple writes within the callback. The write pointer is only published to the reader when `cork` returns, reducing atomic operation overhead.
159
161
 
162
+ ## Benchmarks
163
+
164
+ Measured on Apple M3 Pro (3.51 GHz), Node.js 25.6.1, 8 MiB ring buffer.
165
+
166
+ Each benchmark writes batches of fixed-size messages from the main thread and
167
+ 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).
170
+
171
+ ### Throughput
172
+
173
+ | Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
174
+ | -----: | --------------: | --------------: | -------------------: | -------------------: |
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 |
181
+
182
+ ### Message rate
183
+
184
+ | Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
185
+ | -----: | --------------: | --------------: | -------------------: | -------------------: |
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 |
203
+
204
+ ### Key findings
205
+
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.
211
+
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.
217
+
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.
221
+
222
+ - **Caveat:** The string benchmark uses ASCII-only content. Multi-byte UTF-8
223
+ strings will not hit V8's vectorized fast path and will be significantly slower.
224
+
225
+ ### Running the benchmark
226
+
227
+ ```sh
228
+ # Hardware counters require elevated privileges on macOS
229
+ sudo node --allow-natives-syntax packages/shared/src/bench.mjs
230
+ ```
231
+
160
232
  ## License
161
233
 
162
234
  MIT
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
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);
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, { 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.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.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.1",
3
+ "version": "4.0.0",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -26,5 +26,6 @@
26
26
  "oxlint-tsgolint": "^0.13.0",
27
27
  "rimraf": "^6.1.3",
28
28
  "typescript": "^5.9.3"
29
- }
29
+ },
30
+ "gitHead": "18d5bdc8855ad9012882b03cab144a9625b8c4b8"
30
31
  }