@nxtedition/shared 1.0.0 → 1.0.10

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2022 nxtedition
3
+ Copyright (c) nxtedition
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,50 +1,162 @@
1
- # shared
1
+ # @nxtedition/shared
2
2
 
3
- Ring Buffer for NodeJS cross Worker communication.
3
+ A high-performance, thread-safe ring buffer for inter-thread communication in Node.js using `SharedArrayBuffer`.
4
+
5
+ ## Why
6
+
7
+ Passing data between worker threads in Node.js typically involves structured cloning or transferring `ArrayBuffer` ownership. Structured cloning copies every byte — fine for occasional messages, but a bottleneck when streaming megabytes per second between threads. Transferable objects avoid the copy, but ownership transfer means the sender loses access, which complicates protocols that need to retain the data.
8
+
9
+ This ring buffer avoids both problems. A single `SharedArrayBuffer` is mapped into both threads. The writer appends messages by advancing a write pointer; the reader consumes them by advancing a read pointer. No copies, no ownership transfers, no cloning overhead. The pointers are coordinated with `Atomics` operations, and cache-line-aligned to prevent false sharing between CPU cores.
10
+
11
+ Reads are zero-copy: the reader callback receives a `DataView` directly into the shared buffer, so parsing can happen in-place without allocating intermediate buffers. Writes are batched — the write pointer is only published to the reader after a high-water mark is reached or the current event loop tick ends, drastically reducing the frequency of expensive atomic stores.
12
+
13
+ ## Platform Assumptions
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.
4
16
 
5
17
  ## Install
6
18
 
7
- ```
8
- npm i @nxtedition/shared
19
+ ```sh
20
+ npm install @nxtedition/shared
9
21
  ```
10
22
 
11
- ## Quick Start
23
+ ## Usage
12
24
 
13
25
  ```js
14
- // index.js
26
+ import { alloc, reader, writer } from '@nxtedition/shared'
27
+
28
+ // Allocate shared memory (pass these buffers to a worker thread)
29
+ const { sharedState, sharedBuffer } = alloc(1024 * 1024) // 1 MB ring buffer
30
+
31
+ // --- Writer side (e.g. main thread) ---
32
+ const w = writer({ sharedState, sharedBuffer })
15
33
 
16
- import * as shared from '@nxtedition/shared'
17
- import tp from 'timers/promise'
34
+ const payload = Buffer.from('hello world')
35
+ w.writeSync(payload.length, (data) => {
36
+ payload.copy(data.buffer, data.offset)
37
+ return data.offset + payload.length
38
+ })
18
39
 
19
- const writer = shared.alloc(16 * 1024 * 1024)
20
- const reader = shared.alloc(16 * 1024 * 1024)
40
+ // --- Reader side (e.g. worker thread) ---
41
+ const r = reader({ sharedState, sharedBuffer })
21
42
 
22
- const worker = new Worker(new URL('worker.js', import.meta.url), {
23
- workerData: { reader, writer },
43
+ r.readSome((data) => {
44
+ const msg = data.buffer.subarray(data.offset, data.offset + data.length).toString()
45
+ console.log(msg) // 'hello world'
24
46
  })
47
+ ```
48
+
49
+ ### Batching writes with cork
25
50
 
26
- const writeToWorker = shared.writer(reader)
51
+ ```js
52
+ w.cork(() => {
53
+ for (const item of items) {
54
+ const buf = Buffer.from(JSON.stringify(item))
55
+ w.writeSync(buf.length, (data) => {
56
+ buf.copy(data.buffer, data.offset)
57
+ return data.offset + buf.length
58
+ })
59
+ }
60
+ })
61
+ // All writes flushed atomically when cork returns
62
+ ```
27
63
 
28
- writeToWorker(Buffer.from('ping'))
64
+ ### Non-blocking writes with tryWrite
29
65
 
30
- for await (const buffer of shared.reader(writer)) {
31
- console.log(`From worker ${buffer}`)
32
- await tp.setTimeout(1e3) // Backpressure
33
- writeToWorker(Buffer.from('pong'))
66
+ ```js
67
+ const buf = Buffer.from('data')
68
+ const ok = w.tryWrite(buf.length, (data) => {
69
+ buf.copy(data.buffer, data.offset)
70
+ return data.offset + buf.length
71
+ })
72
+ if (!ok) {
73
+ // Buffer is full — the reader hasn't caught up yet
34
74
  }
35
75
  ```
36
76
 
77
+ ### Cross-thread usage
78
+
37
79
  ```js
38
- // worker.js
80
+ // main.js
81
+ import { alloc, writer } from '@nxtedition/shared'
82
+ import { Worker } from 'node:worker_threads'
39
83
 
40
- import * as shared from '@nxtedition/shared'
41
- import tp from 'timers/promise'
84
+ const { sharedState, sharedBuffer } = alloc(1024 * 1024)
85
+ const worker = new Worker('./reader-worker.js', {
86
+ workerData: { sharedState, sharedBuffer },
87
+ })
88
+
89
+ const w = writer({ sharedState, sharedBuffer })
90
+ // ... write messages
91
+ ```
42
92
 
43
- const writeToParent = shared.writer(workerData.writer)
93
+ ```js
94
+ // reader-worker.js
95
+ import { reader } from '@nxtedition/shared'
96
+ import { workerData } from 'node:worker_threads'
97
+
98
+ const r = reader(workerData)
44
99
 
45
- for await (const buffer of shared.reader(workerData.reader)) {
46
- console.log(`From parent ${buffer}`)
47
- await tp.setTimeout(1e3) // Backpressure
48
- writeToWorker(Buffer.from('pong'))
100
+ function poll() {
101
+ const count = r.readSome((data) => {
102
+ // process data.buffer at data.offset..data.offset+data.length
103
+ })
104
+ setImmediate(poll)
49
105
  }
106
+ poll()
50
107
  ```
108
+
109
+ ## API
110
+
111
+ ### `alloc(size: number): SharedBuffers`
112
+
113
+ Allocates the shared memory buffers for a ring buffer of the given byte size.
114
+
115
+ - **size** — Buffer capacity in bytes (must be a positive integer, max ~2 GB)
116
+ - Returns `{ sharedState: SharedArrayBuffer, sharedBuffer: SharedArrayBuffer }`
117
+
118
+ ### `reader(buffers: SharedBuffers): Reader`
119
+
120
+ Creates a reader for the ring buffer.
121
+
122
+ #### `reader.readSome(next): number`
123
+
124
+ Reads a batch of messages. Calls `next(data)` for each message, where `data` has:
125
+
126
+ - `buffer: Buffer` — The underlying shared buffer
127
+ - `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
130
+
131
+ Return `false` from the callback to stop reading early. Returns the number of messages processed.
132
+
133
+ Messages are batched: up to 1024 items or 256 KiB per call.
134
+
135
+ ### `writer(buffers: SharedBuffers, options?): Writer`
136
+
137
+ Creates a writer for the ring buffer.
138
+
139
+ **Options:**
140
+
141
+ - `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
+ - `logger?: { warn(obj, msg): void }` — Logger for yield warnings (pino-compatible).
143
+
144
+ #### `writer.writeSync(len, fn, timeout?): void`
145
+
146
+ Synchronously writes a message. Blocks (via `Atomics.wait`) until buffer space is available.
147
+
148
+ - **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.
151
+
152
+ #### `writer.tryWrite(len, fn): boolean`
153
+
154
+ Non-blocking write attempt. Returns `false` if the buffer is full. The `fn` callback follows the same contract as `writeSync`.
155
+
156
+ #### `writer.cork(callback): T`
157
+
158
+ Batches multiple writes within the callback. The write pointer is only published to the reader when `cork` returns, reducing atomic operation overhead.
159
+
160
+ ## License
161
+
162
+ MIT
package/lib/index.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ export interface SharedBuffers {
2
+ sharedState: SharedArrayBuffer;
3
+ sharedBuffer: SharedArrayBuffer;
4
+ }
5
+ export interface BufferRegion {
6
+ buffer: Buffer;
7
+ view: DataView;
8
+ offset: number;
9
+ length: number;
10
+ }
11
+ /**
12
+ * Allocates the shared memory buffers.
13
+ */
14
+ export declare function alloc(size: number): SharedBuffers;
15
+ export interface Reader {
16
+ readSome(next: (data: BufferRegion) => void | boolean): number;
17
+ }
18
+ /**
19
+ * Creates a reader for the ring buffer.
20
+ */
21
+ export declare function reader({ sharedState, sharedBuffer }: SharedBuffers): Reader;
22
+ export interface WriterOptions {
23
+ yield?: () => void;
24
+ logger?: {
25
+ warn(obj: object, msg: string): void;
26
+ };
27
+ }
28
+ export interface Writer {
29
+ tryWrite(len: number, fn: (data: BufferRegion) => number): boolean;
30
+ writeSync(len: number, fn: (data: BufferRegion) => number, timeout?: number): void;
31
+ cork<T>(callback: () => T): T;
32
+ }
33
+ /**
34
+ * Creates a writer for the ring buffer.
35
+ */
36
+ export declare function writer({ sharedState, sharedBuffer }: SharedBuffers, { yield: onYield, logger }?: WriterOptions): Writer;
package/lib/index.js ADDED
@@ -0,0 +1,461 @@
1
+ // By placing the read and write indices far apart (multiples of a common
2
+ // cache line size, 64 bytes), we prevent "false sharing". This is a
3
+ // low-level CPU optimization where two cores writing to different variables
4
+ // that happen to be on the same cache line would otherwise constantly
5
+ // invalidate each other's caches, hurting performance.
6
+ // Int32 is 4 bytes, so an index of 16 means 16 * 4 = 64 bytes offset.
7
+ const WRITE_INDEX = 0
8
+ const READ_INDEX = 16
9
+
10
+ // High-Water Mark for batching operations to reduce the frequency
11
+ // of expensive atomic writes.
12
+ const HWM_BYTES = 256 * 1024 // 256 KiB
13
+ const HWM_COUNT = 1024 // 1024 items
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+ /**
28
+ * Allocates the shared memory buffers.
29
+ */
30
+ export function alloc(size ) {
31
+ if (!Number.isInteger(size)) {
32
+ throw new TypeError('size must be a positive integer')
33
+ }
34
+ if (size <= 0) {
35
+ throw new RangeError('size must be a positive integer')
36
+ }
37
+ if (size >= 2 ** 31 - 8) {
38
+ throw new RangeError('size exceeds maximum of 2GB minus header size')
39
+ }
40
+
41
+ return {
42
+ // A small buffer for sharing state (read/write pointers).
43
+ sharedState: new SharedArrayBuffer(128),
44
+ // The main buffer for transferring data.
45
+ // We need another 8 bytes for entry headers.
46
+ sharedBuffer: new SharedArrayBuffer(size + 8),
47
+ }
48
+ }
49
+
50
+
51
+
52
+
53
+
54
+ /**
55
+ * Creates a reader for the ring buffer.
56
+ */
57
+ export function reader({ sharedState, sharedBuffer } ) {
58
+ if (!(sharedState instanceof SharedArrayBuffer)) {
59
+ throw new TypeError('sharedState must be a SharedArrayBuffer')
60
+ }
61
+ if (!(sharedBuffer instanceof SharedArrayBuffer)) {
62
+ throw new TypeError('sharedBuffer must be a SharedArrayBuffer')
63
+ }
64
+ if (sharedBuffer.byteLength >= 2 ** 31) {
65
+ throw new RangeError('Shared buffer size exceeds maximum of 2GB')
66
+ }
67
+
68
+ const state = new Int32Array(sharedState)
69
+ const size = sharedBuffer.byteLength
70
+ const buffer = Buffer.from(sharedBuffer)
71
+ const view = new DataView(sharedBuffer)
72
+
73
+ // This object is reused to avoid creating new objects in a hot path.
74
+ // This helps V8 maintain a stable hidden class for the object,
75
+ // which is a key optimization (zero-copy read).
76
+ const data = { buffer, view, offset: 0, length: 0 }
77
+
78
+ // Local copies of the pointers. The `| 0` is a hint to the V8 JIT
79
+ // compiler that these are 32-bit integers, enabling optimizations.
80
+ let readPos = Atomics.load(state, READ_INDEX) | 0
81
+ let writePos = Atomics.load(state, WRITE_INDEX) | 0
82
+
83
+ function readSome(next ) {
84
+ let count = 0
85
+ let bytes = 0
86
+
87
+ writePos = state[WRITE_INDEX] | 0
88
+
89
+ // First, check if the local writePos matches the readPos.
90
+ // If so, refresh it from shared memory in case the writer has added data.
91
+ if (readPos === writePos) {
92
+ writePos = Atomics.load(state, WRITE_INDEX) | 0
93
+ }
94
+
95
+ // Process messages in a batch to minimize loop and atomic operation overhead.
96
+ while (count < HWM_COUNT && bytes < HWM_BYTES && readPos !== writePos) {
97
+ const dataPos = readPos + 4
98
+ const dataLen = view.getInt32(dataPos - 4, true) | 0
99
+
100
+ bytes += 4
101
+
102
+ // A length of -1 is a special marker indicating the writer has
103
+ // wrapped around to the beginning of the buffer.
104
+ if (dataLen === -1) {
105
+ readPos = 0
106
+ // After wrapping, we must re-check against the writer's position.
107
+ // It's possible the writer is now at a position > 0.
108
+ writePos = Atomics.load(state, WRITE_INDEX) | 0
109
+ } else {
110
+ if (dataLen < 0) {
111
+ throw new Error('Invalid data length')
112
+ }
113
+ if (dataPos + dataLen > size) {
114
+ throw new Error('Data exceeds buffer size')
115
+ }
116
+
117
+ readPos += 4 + dataLen
118
+
119
+ bytes += dataLen
120
+ count += 1
121
+
122
+ // This is a "zero-copy" operation. We don't copy the data out.
123
+ // Instead, we pass a "view" into the shared buffer.
124
+ data.offset = dataPos
125
+ data.length = dataLen
126
+
127
+ if (next(data) === false) {
128
+ break
129
+ }
130
+ }
131
+ }
132
+
133
+ // IMPORTANT: The reader only updates its shared `readPos` after a batch
134
+ // is processed. This significantly reduces atomic operation overhead.
135
+ if (bytes > 0) {
136
+ Atomics.store(state, READ_INDEX, readPos)
137
+ }
138
+
139
+ return count
140
+ }
141
+
142
+ return { readSome }
143
+ }
144
+
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+
156
+ /**
157
+ * Creates a writer for the ring buffer.
158
+ */
159
+ export function writer(
160
+ { sharedState, sharedBuffer } ,
161
+ { yield: onYield, logger } = {},
162
+ ) {
163
+ if (!(sharedState instanceof SharedArrayBuffer)) {
164
+ throw new TypeError('sharedState must be a SharedArrayBuffer')
165
+ }
166
+ if (!(sharedBuffer instanceof SharedArrayBuffer)) {
167
+ throw new TypeError('sharedBuffer must be a SharedArrayBuffer')
168
+ }
169
+ if (sharedBuffer.byteLength >= 2 ** 31) {
170
+ throw new RangeError('Shared buffer size exceeds maximum of 2GB')
171
+ }
172
+
173
+ const state = new Int32Array(sharedState)
174
+ const size = sharedBuffer.byteLength
175
+ const buffer = Buffer.from(sharedBuffer)
176
+ const view = new DataView(sharedBuffer)
177
+
178
+ // This object is reused to avoid creating new objects in a hot path.
179
+ // This helps V8 maintain a stable hidden class for the object,
180
+ // which is a key optimization (zero-copy read).
181
+ const data = { buffer, view, offset: 0, length: 0 }
182
+
183
+ // Local copies of the pointers. The `| 0` is a hint to the V8 JIT
184
+ // compiler that these are 32-bit integers, enabling optimizations.
185
+ let readPos = Atomics.load(state, READ_INDEX) | 0
186
+ let writePos = Atomics.load(state, WRITE_INDEX) | 0
187
+
188
+ let yielding = 0
189
+ let corked = 0
190
+ let pending = 0
191
+
192
+ if (onYield != null && typeof onYield !== 'function') {
193
+ throw new TypeError('onYield must be a function')
194
+ }
195
+
196
+ /**
197
+ * Pauses the writer thread to wait for the reader to catch up.
198
+ */
199
+ function _yield(delay ) {
200
+ if (yielding > 128) {
201
+ throw new Error('Detected possible deadlock: writer yielding too many times')
202
+ }
203
+
204
+ // First, ensure the very latest write position is visible to the reader.
205
+ _flush()
206
+
207
+ if (onYield) {
208
+ yielding += 1
209
+ try {
210
+ // Call the user-provided yield function, if any. This can be important
211
+ // if the writer is waiting for the reader to process data which would
212
+ // otherwise deadlock.
213
+ onYield()
214
+ } finally {
215
+ yielding -= 1
216
+ }
217
+ }
218
+
219
+ // Atomics.wait is the most efficient way to pause. It puts the thread
220
+ // to sleep, consuming no CPU, until the reader changes the READ_INDEX.
221
+ if (delay > 0) {
222
+ Atomics.wait(state, READ_INDEX, readPos, delay)
223
+ } else {
224
+ // @ts-expect-error Atomics.pause is Stage 3, available in Node.js 25+
225
+ Atomics.pause()
226
+ }
227
+
228
+ // After waking up, refresh the local view of the reader's position.
229
+ readPos = Atomics.load(state, READ_INDEX) | 0
230
+ }
231
+
232
+ /**
233
+ * Tries to acquire enough space in the buffer for a new message.
234
+ */
235
+ function _acquire(len ) {
236
+ // Total space required: payload + its 4-byte length header + a potential
237
+ // 4-byte header for the *next* message (for wrap-around check).
238
+ const required = len + 4 + 4
239
+
240
+ if (writePos >= readPos) {
241
+ // Case 1: The writer is ahead of the reader. [ 0 - R ... W - size ]
242
+ // There is free space from W to the end (s) and from 0 to R.
243
+
244
+ if (size - writePos >= required) {
245
+ // Enough space at the end of the buffer.
246
+ return true
247
+ }
248
+
249
+ readPos = state[READ_INDEX] | 0
250
+ if (readPos === 0) {
251
+ _yield(0)
252
+ }
253
+
254
+ // Not enough space at the end. Check if there's space at the beginning.
255
+ if (readPos === 0) {
256
+ // Reader is at the beginning, so no space to wrap around into.
257
+ return false
258
+ }
259
+
260
+ // Mark the current position with a wrap-around signal (-1).
261
+ view.setInt32(writePos, -1, true)
262
+
263
+ // Reset writer position to the beginning.
264
+ writePos = 0
265
+
266
+ if (writePos + 4 > size) {
267
+ // assertion
268
+ throw new Error(`Write position ${writePos} with next header exceeds buffer size ${size}`)
269
+ }
270
+ if (writePos === readPos) {
271
+ // assertion
272
+ throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
273
+ }
274
+
275
+ Atomics.store(state, WRITE_INDEX, writePos)
276
+ }
277
+
278
+ // Case 2: The writer has wrapped around. [ 0 ... W - R ... s ]
279
+ // The only free space is between W and R.
280
+
281
+ readPos = state[READ_INDEX] | 0
282
+ if (readPos - writePos < required) {
283
+ _yield(0)
284
+ }
285
+
286
+ return readPos - writePos >= required
287
+ }
288
+
289
+ /**
290
+ * "Uncorks" the stream by publishing the pending write position.
291
+ * This is called from a microtask to batch atomic stores.
292
+ */
293
+ function _uncork() {
294
+ corked -= 1
295
+ if (corked === 0) {
296
+ _flush()
297
+ }
298
+ }
299
+
300
+ function _flush() {
301
+ if (pending > 0) {
302
+ Atomics.store(state, WRITE_INDEX, writePos)
303
+ pending = 0
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Performs the actual write into the buffer after space has been acquired.
309
+ */
310
+ function _write(dataCap , fn ) {
311
+ const dataPos = writePos + 4
312
+
313
+ data.offset = dataPos
314
+ data.length = dataCap
315
+
316
+ // The user-provided function writes the data and returns the final position.
317
+ // We calculate the actual bytes written from that.
318
+ // NOTE: This is unsafe as the user function can write beyond the reserved length.
319
+ const dataLen = fn(data) - dataPos
320
+
321
+ if (typeof dataLen !== 'number') {
322
+ throw new TypeError('"fn" must return the number of bytes written')
323
+ }
324
+ if (dataLen < 0) {
325
+ throw new RangeError(`"fn" returned a negative number ${dataLen}`)
326
+ }
327
+ if (dataLen > dataCap) {
328
+ throw new RangeError(`"fn" returned a number ${dataLen} that exceeds capacity ${dataCap}`)
329
+ }
330
+
331
+ if (dataPos + dataLen > size) {
332
+ // assertion
333
+ throw new Error(`Data position ${dataPos} with length ${dataLen} exceeds buffer size ${size}`)
334
+ }
335
+
336
+ const nextPos = writePos + 4 + dataLen
337
+
338
+ if (nextPos + 4 > size) {
339
+ // assertion
340
+ throw new Error(`Write position ${nextPos} with next header exceeds buffer size ${size}`)
341
+ }
342
+ if (nextPos === readPos) {
343
+ // assertion
344
+ throw new Error(`Write position ${nextPos} cannot equal read position ${readPos}`)
345
+ }
346
+
347
+ // Write the actual length of the data into the 4-byte header.
348
+ view.setInt32(writePos, dataLen, true)
349
+ writePos += 4 + dataLen
350
+ pending += 4 + dataLen
351
+
352
+ // This is the "corking" optimization. Instead of calling Atomics.store
353
+ // on every write, we batch them. We either write when a certain
354
+ // amount of data is pending (HWM_BYTES) or at the end of the current
355
+ // event loop tick. This drastically reduces atomic operation overhead.
356
+ if (pending >= HWM_BYTES) {
357
+ Atomics.store(state, WRITE_INDEX, writePos)
358
+ pending = 0
359
+ } else if (corked === 0) {
360
+ corked += 1
361
+ setImmediate(_uncork)
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Public write method. Acquires space and synchronously writes data with a timeout. Will
367
+ * wait until space is available.
368
+ * Writing more than "len" bytes in the callback will cause undefined behavior.
369
+ */
370
+ function writeSync(
371
+ len ,
372
+ fn ,
373
+ timeout = 60e3,
374
+ ) {
375
+ if (typeof len !== 'number') {
376
+ throw new TypeError('"len" must be a non-negative number')
377
+ }
378
+ if (len < 0) {
379
+ throw new RangeError(`"len" ${len} is negative`)
380
+ }
381
+ if (len >= 2 ** 31 || len > size - 8) {
382
+ throw new Error(`"len" ${len} exceeds maximum allowed size ${size - 8}`)
383
+ }
384
+ if (typeof fn !== 'function') {
385
+ throw new TypeError('"fn" must be a function')
386
+ }
387
+ if (typeof timeout !== 'number') {
388
+ throw new TypeError('"timeout" must be a non-negative number')
389
+ }
390
+ if (timeout < 0) {
391
+ throw new RangeError('"timeout" must be a non-negative number')
392
+ }
393
+
394
+ if (!_acquire(len)) {
395
+ const startTime = performance.now()
396
+ let yieldCount = 0
397
+ let yieldTime = 0
398
+ for (let n = 0; !_acquire(len); n++) {
399
+ if (performance.now() - startTime > timeout) {
400
+ throw new Error('Timeout while waiting for space in the buffer')
401
+ }
402
+ _yield(3)
403
+ yieldCount += 1
404
+ yieldTime += 3
405
+ }
406
+ const elapsedTime = performance.now() - startTime
407
+ logger?.warn(
408
+ { yieldLength: len, readPos, writePos, elapsedTime, yieldCount, yieldTime },
409
+ 'yielded',
410
+ )
411
+ }
412
+
413
+ _write(len, fn)
414
+
415
+ if (writePos === readPos) {
416
+ throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Public write method. Acquires space and tries to write data.
422
+ * Writing more than "len" bytes in the callback will cause undefined behavior.
423
+ */
424
+ function tryWrite(len , fn ) {
425
+ if (typeof len !== 'number') {
426
+ throw new TypeError('"len" must be a non-negative number')
427
+ }
428
+ if (len < 0) {
429
+ throw new RangeError(`"len" ${len} is negative`)
430
+ }
431
+ if (len >= 2 ** 31 || len > size - 8) {
432
+ throw new Error(`"len" ${len} exceeds maximum allowed size ${size - 8}`)
433
+ }
434
+ if (typeof fn !== 'function') {
435
+ throw new TypeError('"fn" must be a function')
436
+ }
437
+
438
+ if (!_acquire(len)) {
439
+ return false
440
+ }
441
+
442
+ _write(len, fn)
443
+
444
+ if (writePos === readPos) {
445
+ throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
446
+ }
447
+
448
+ return true
449
+ }
450
+
451
+ function cork (callback ) {
452
+ corked += 1
453
+ try {
454
+ return callback()
455
+ } finally {
456
+ _uncork()
457
+ }
458
+ }
459
+
460
+ return { tryWrite, writeSync, cork }
461
+ }
package/package.json CHANGED
@@ -1,66 +1,31 @@
1
1
  {
2
2
  "name": "@nxtedition/shared",
3
- "version": "1.0.0",
4
- "description": "Ring Buffer for NodeJS cross Worker communication",
5
- "main": "index.js",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/nxtedition/shared.git"
9
- },
10
- "author": "Robert Nagy <ronagy@icloud.com>",
11
- "license": "MIT License",
12
- "bugs": {
13
- "url": "https://github.com/nxtedition/shared/issues"
14
- },
3
+ "version": "1.0.10",
15
4
  "type": "module",
16
- "homepage": "https://github.com/nxtedition/shared#readme",
17
- "lint-staged": {
18
- "*.{js,jsx,ts}": [
19
- "eslint",
20
- "prettier --write"
21
- ]
22
- },
23
- "prettier": {
24
- "printWidth": 100,
25
- "semi": false,
26
- "singleQuote": true,
27
- "jsxSingleQuote": true
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "license": "MIT",
13
+ "publishConfig": {
14
+ "access": "public"
28
15
  },
29
- "eslintConfig": {
30
- "root": true,
31
- "parserOptions": {
32
- "ecmaFeatures": {
33
- "ecmaVersion": 2020
34
- }
35
- },
36
- "extends": [
37
- "standard",
38
- "prettier",
39
- "prettier/prettier"
40
- ],
41
- "rules": {
42
- "quotes": [
43
- "error",
44
- "single",
45
- {
46
- "avoidEscape": true,
47
- "allowTemplateLiterals": true
48
- }
49
- ]
50
- }
16
+ "scripts": {
17
+ "build": "rimraf lib && tsc && amaroc ./src/index.ts && mv src/index.js lib/",
18
+ "prepublishOnly": "yarn build",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "node --test",
21
+ "test:ci": "node --test"
51
22
  },
52
23
  "devDependencies": {
53
- "eslint": "^8.12.0",
54
- "eslint-config-prettier": "^8.4.0",
55
- "eslint-config-standard": "^16.0.3",
56
- "eslint-plugin-import": "^2.25.4",
57
- "eslint-plugin-node": "^11.1.0",
58
- "eslint-plugin-promise": "^6.0.0",
59
- "husky": "^7.0.4",
60
- "lint-staged": "^12.3.7",
61
- "prettier": "^2.6.2"
24
+ "@types/node": "^25.2.3",
25
+ "amaroc": "^1.0.1",
26
+ "oxlint-tsgolint": "^0.12.2",
27
+ "rimraf": "^6.1.2",
28
+ "typescript": "^5.9.3"
62
29
  },
63
- "scripts": {
64
- "prepare": "husky install"
65
- }
30
+ "gitHead": "43180e101ef8e6b579c60f24ab5329577627134e"
66
31
  }
package/.editorconfig DELETED
@@ -1,12 +0,0 @@
1
- root = true
2
-
3
- [*]
4
- indent_style = space
5
- indent_size = 2
6
- end_of_line = lf
7
- charset = utf-8
8
- trim_trailing_whitespace = true
9
- insert_final_newline = true
10
-
11
- [*.md]
12
- trim_trailing_whitespace = false
package/.husky/pre-commit DELETED
@@ -1,3 +0,0 @@
1
- #!/bin/sh
2
- . "$(dirname "$0")/_/husky.sh"
3
- npx lint-staged
package/index.js DELETED
@@ -1,125 +0,0 @@
1
- const WRITE_INDEX = 0
2
- const READ_INDEX = 1
3
- const END_OF_PACKET = -1
4
-
5
- export function alloc(size) {
6
- return {
7
- sharedState: new SharedArrayBuffer(8),
8
- sharedBuffer: new SharedArrayBuffer(size),
9
- }
10
- }
11
-
12
- async function* _reader({ sharedState, sharedBuffer }, cb) {
13
- const state = new Int32Array(sharedState)
14
- const buffer = Buffer.from(sharedBuffer)
15
-
16
- let readPos = 0
17
- let writePos = 0
18
-
19
- while (true) {
20
- const { async, value } = Atomics.waitAsync(state, WRITE_INDEX, writePos)
21
- if (async) {
22
- await value
23
- }
24
- writePos = Atomics.load(state, WRITE_INDEX)
25
-
26
- while (readPos !== writePos) {
27
- const len = buffer.readInt32LE(readPos)
28
-
29
- if (len === END_OF_PACKET) {
30
- readPos = 0
31
- } else {
32
- const raw = buffer.slice(readPos + 4, readPos + len)
33
- readPos += len
34
- if (cb) {
35
- cb(raw)
36
- } else {
37
- yield raw
38
- }
39
- }
40
-
41
- Atomics.store(state, READ_INDEX, readPos)
42
- }
43
-
44
- Atomics.notify(state, READ_INDEX)
45
- }
46
- }
47
-
48
- export function reader(options, cb) {
49
- if (cb) {
50
- _reader(options, cb).next()
51
- } else {
52
- return _reader(options)
53
- }
54
- }
55
-
56
- export function writer({ sharedState, sharedBuffer }) {
57
- const state = new Int32Array(sharedState)
58
- const buffer = Buffer.from(sharedBuffer)
59
- const size = buffer.byteLength
60
- const queue = []
61
-
62
- let readPos = 0
63
- let writePos = 0
64
- let flushing = null
65
-
66
- function tryWrite(...raw) {
67
- readPos = Atomics.load(state, READ_INDEX)
68
-
69
- const len = raw.reduce((len, buf) => len + buf.byteLength, 4)
70
-
71
- if (size - writePos < len + 4) {
72
- if (readPos < len + 4) {
73
- return false
74
- }
75
-
76
- buffer.writeInt32LE(-1, writePos)
77
- writePos = 0
78
- } else {
79
- const available = writePos >= readPos ? size - writePos : readPos - writePos
80
-
81
- if (available < len + 4) {
82
- return false
83
- }
84
- }
85
-
86
- buffer.writeInt32LE(len, writePos)
87
- writePos += 4
88
-
89
- for (const buf of raw) {
90
- buffer.set(buf, writePos)
91
- writePos += buf.byteLength
92
- }
93
-
94
- Atomics.store(state, WRITE_INDEX, writePos)
95
- Atomics.notify(state, WRITE_INDEX)
96
-
97
- return true
98
- }
99
-
100
- async function flush() {
101
- while (queue.length) {
102
- while (!tryWrite(queue[0])) {
103
- const { async, value } = Atomics.waitAsync(state, READ_INDEX, readPos)
104
- if (async) {
105
- await value
106
- }
107
- }
108
- queue.shift()
109
- }
110
-
111
- flushing = null
112
- }
113
-
114
- function write(...raw) {
115
- if (!queue.length && tryWrite(...raw)) {
116
- return
117
- }
118
-
119
- queue.push(Buffer.concat(raw))
120
-
121
- return (flushing ??= flush())
122
- }
123
-
124
- return write
125
- }