@nxtedition/shared 3.0.0 → 3.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/lib/index.js ADDED
@@ -0,0 +1,481 @@
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
+
29
+ /**
30
+ * Allocates the shared memory buffers.
31
+ */
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),
49
+ }
50
+ }
51
+
52
+
53
+
54
+
55
+
56
+
57
+ /**
58
+ * Creates a reader for the ring buffer.
59
+ */
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')
69
+ }
70
+
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
+ ) {
90
+ let count = 0
91
+ let bytes = 0
92
+
93
+ writePos = state[WRITE_INDEX] | 0
94
+
95
+ // First, check if the local writePos matches the readPos.
96
+ // If so, refresh it from shared memory in case the writer has added data.
97
+ if (readPos === writePos) {
98
+ writePos = Atomics.load(state, WRITE_INDEX) | 0
99
+ }
100
+
101
+ // Process messages in a batch to minimize loop and atomic operation overhead.
102
+ while (count < HWM_COUNT && bytes < HWM_BYTES && readPos !== writePos) {
103
+ const dataPos = readPos + 4
104
+ const dataLen = view.getInt32(dataPos - 4, true) | 0
105
+
106
+ bytes += 4
107
+
108
+ // A length of -1 is a special marker indicating the writer has
109
+ // wrapped around to the beginning of the buffer.
110
+ if (dataLen === -1) {
111
+ readPos = 0
112
+ // After wrapping, we must re-check against the writer's position.
113
+ // It's possible the writer is now at a position > 0.
114
+ writePos = Atomics.load(state, WRITE_INDEX) | 0
115
+ } else {
116
+ if (dataLen < 0) {
117
+ throw new Error('Invalid data length')
118
+ }
119
+ if (dataPos + dataLen > size) {
120
+ throw new Error('Data exceeds buffer size')
121
+ }
122
+
123
+ readPos += 4 + dataLen
124
+
125
+ bytes += dataLen
126
+ count += 1
127
+
128
+ // This is a "zero-copy" operation. We don't copy the data out.
129
+ // Instead, we pass a "view" into the shared buffer.
130
+ data.offset = dataPos
131
+ data.byteOffset = dataPos
132
+ data.length = dataLen
133
+ data.byteLength = dataLen
134
+
135
+ if (next(data, opaque) === false) {
136
+ break
137
+ }
138
+ }
139
+ }
140
+
141
+ // IMPORTANT: The reader only updates its shared `readPos` after a batch
142
+ // is processed. This significantly reduces atomic operation overhead.
143
+ if (bytes > 0) {
144
+ Atomics.store(state, READ_INDEX, readPos)
145
+ }
146
+
147
+ return count
148
+ }
149
+
150
+ return { readSome }
151
+ }
152
+
153
+
154
+
155
+
156
+
157
+
158
+
159
+
160
+
161
+
162
+
163
+
164
+
165
+
166
+
167
+
168
+
169
+ /**
170
+ * Creates a writer for the ring buffer.
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)
190
+
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 }
195
+
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
200
+
201
+ let yielding = 0
202
+ let corked = 0
203
+ let pending = 0
204
+
205
+ if (onYield != null && typeof onYield !== 'function') {
206
+ throw new TypeError('onYield must be a function')
207
+ }
208
+
209
+ /**
210
+ * Pauses the writer thread to wait for the reader to catch up.
211
+ */
212
+ function _yield(delay ) {
213
+ if (yielding > 128) {
214
+ throw new Error('Detected possible deadlock: writer yielding too many times')
215
+ }
216
+
217
+ // First, ensure the very latest write position is visible to the reader.
218
+ _flush()
219
+
220
+ if (onYield) {
221
+ yielding += 1
222
+ try {
223
+ // Call the user-provided yield function, if any. This can be important
224
+ // if the writer is waiting for the reader to process data which would
225
+ // otherwise deadlock.
226
+ onYield()
227
+ } finally {
228
+ yielding -= 1
229
+ }
230
+ }
231
+
232
+ // Atomics.wait is the most efficient way to pause. It puts the thread
233
+ // to sleep, consuming no CPU, until the reader changes the READ_INDEX.
234
+ if (delay > 0) {
235
+ Atomics.wait(state, READ_INDEX, readPos, delay)
236
+ } else {
237
+ // @ts-expect-error Atomics.pause is Stage 3, available in Node.js 25+
238
+ Atomics.pause()
239
+ }
240
+
241
+ // After waking up, refresh the local view of the reader's position.
242
+ readPos = Atomics.load(state, READ_INDEX) | 0
243
+ }
244
+
245
+ /**
246
+ * Tries to acquire enough space in the buffer for a new message.
247
+ */
248
+ function _acquire(len ) {
249
+ // Total space required: payload + its 4-byte length header + a potential
250
+ // 4-byte header for the *next* message (for wrap-around check).
251
+ const required = len + 4 + 4
252
+
253
+ if (writePos >= readPos) {
254
+ // Case 1: The writer is ahead of the reader. [ 0 - R ... W - size ]
255
+ // There is free space from W to the end (s) and from 0 to R.
256
+
257
+ if (size - writePos >= required) {
258
+ // Enough space at the end of the buffer.
259
+ return true
260
+ }
261
+
262
+ readPos = state[READ_INDEX] | 0
263
+ if (readPos === 0) {
264
+ _yield(0)
265
+ }
266
+
267
+ // Not enough space at the end. Check if there's space at the beginning.
268
+ if (readPos === 0) {
269
+ // Reader is at the beginning, so no space to wrap around into.
270
+ return false
271
+ }
272
+
273
+ // Mark the current position with a wrap-around signal (-1).
274
+ view.setInt32(writePos, -1, true)
275
+
276
+ // Reset writer position to the beginning.
277
+ writePos = 0
278
+
279
+ if (writePos + 4 > size) {
280
+ // assertion
281
+ throw new Error(`Write position ${writePos} with next header exceeds buffer size ${size}`)
282
+ }
283
+ if (writePos === readPos) {
284
+ // assertion
285
+ throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
286
+ }
287
+
288
+ Atomics.store(state, WRITE_INDEX, writePos)
289
+ }
290
+
291
+ // Case 2: The writer has wrapped around. [ 0 ... W - R ... s ]
292
+ // The only free space is between W and R.
293
+
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()
310
+ }
311
+ }
312
+
313
+ function _flush() {
314
+ if (pending > 0) {
315
+ Atomics.store(state, WRITE_INDEX, writePos)
316
+ pending = 0
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Performs the actual write into the buffer after space has been acquired.
322
+ */
323
+ function _write (
324
+ dataCap ,
325
+ fn ,
326
+ opaque ,
327
+ ) {
328
+ const dataPos = writePos + 4
329
+
330
+ data.offset = dataPos
331
+ data.byteOffset = dataPos
332
+ data.length = dataCap
333
+ data.byteLength = dataCap
334
+
335
+ // The user-provided function writes the data and returns the final position.
336
+ // We calculate the actual bytes written from that.
337
+ // NOTE: This is unsafe as the user function can write beyond the reserved length.
338
+ const dataLen = fn(data, opaque) - dataPos
339
+
340
+ if (!Number.isFinite(dataLen)) {
341
+ throw new TypeError('"fn" must return the number of bytes written')
342
+ }
343
+ if (dataLen < 0) {
344
+ throw new RangeError(`"fn" returned a negative number ${dataLen}`)
345
+ }
346
+ if (dataLen > dataCap) {
347
+ throw new RangeError(`"fn" returned a number ${dataLen} that exceeds capacity ${dataCap}`)
348
+ }
349
+
350
+ if (dataPos + dataLen > size) {
351
+ // assertion
352
+ throw new Error(`Data position ${dataPos} with length ${dataLen} exceeds buffer size ${size}`)
353
+ }
354
+
355
+ const nextPos = writePos + 4 + dataLen
356
+
357
+ if (nextPos + 4 > size) {
358
+ // assertion
359
+ throw new Error(`Write position ${nextPos} with next header exceeds buffer size ${size}`)
360
+ }
361
+ if (nextPos === readPos) {
362
+ // assertion
363
+ throw new Error(`Write position ${nextPos} cannot equal read position ${readPos}`)
364
+ }
365
+
366
+ // 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
370
+
371
+ // This is the "corking" optimization. Instead of calling Atomics.store
372
+ // on every write, we batch them. We either write when a certain
373
+ // amount of data is pending (HWM_BYTES) or at the end of the current
374
+ // 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)
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Public write method. Acquires space and synchronously writes data with a timeout. Will
386
+ * wait until space is available.
387
+ * Writing more than "len" bytes in the callback will cause undefined behavior.
388
+ */
389
+ function writeSync (
390
+ len ,
391
+ fn ,
392
+ opaque ,
393
+ ) {
394
+ if (typeof len !== 'number') {
395
+ throw new TypeError('"len" must be a non-negative number')
396
+ }
397
+ if (len < 0) {
398
+ throw new RangeError(`"len" ${len} is negative`)
399
+ }
400
+ if (len >= 2 ** 31 || len > size - 8) {
401
+ throw new Error(`"len" ${len} exceeds maximum allowed size ${size - 8}`)
402
+ }
403
+ if (typeof fn !== 'function') {
404
+ throw new TypeError('"fn" must be a function')
405
+ }
406
+
407
+ if (!_acquire(len)) {
408
+ const startTime = performance.now()
409
+ let yieldCount = 0
410
+ let yieldTime = 0
411
+ for (let n = 0; !_acquire(len); n++) {
412
+ if (performance.now() - startTime > 60e3) {
413
+ throw new Error('Timeout while waiting for space in the buffer')
414
+ }
415
+ _yield(3)
416
+ yieldCount += 1
417
+ yieldTime += 3
418
+ }
419
+ const elapsedTime = performance.now() - startTime
420
+ logger?.warn(
421
+ { yieldLength: len, readPos, writePos, elapsedTime, yieldCount, yieldTime },
422
+ 'yielded',
423
+ )
424
+ }
425
+
426
+ _write(len, fn, opaque)
427
+
428
+ if (writePos === readPos) {
429
+ throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Public write method. Acquires space and tries to write data.
435
+ * Writing more than "len" bytes in the callback will cause undefined behavior.
436
+ */
437
+
438
+ function tryWrite (
439
+ len ,
440
+ fn ,
441
+ opaque ,
442
+ ) {
443
+ if (typeof len !== 'number') {
444
+ throw new TypeError('"len" must be a non-negative number')
445
+ }
446
+ if (len < 0) {
447
+ throw new RangeError(`"len" ${len} is negative`)
448
+ }
449
+ if (len >= 2 ** 31 || len > size - 8) {
450
+ throw new Error(`"len" ${len} exceeds maximum allowed size ${size - 8}`)
451
+ }
452
+ if (typeof fn !== 'function') {
453
+ throw new TypeError('"fn" must be a function')
454
+ }
455
+
456
+ if (!_acquire(len)) {
457
+ return false
458
+ }
459
+
460
+ _write(len, fn, opaque)
461
+
462
+ if (writePos === readPos) {
463
+ throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
464
+ }
465
+
466
+ return true
467
+ }
468
+
469
+ function cork (callback ) {
470
+ corked += 1
471
+ if (callback != null) {
472
+ try {
473
+ return callback()
474
+ } finally {
475
+ _uncork()
476
+ }
477
+ }
478
+ }
479
+
480
+ return { tryWrite, writeSync, cork, uncork: _uncork, flushSync: _flush }
481
+ }
package/package.json CHANGED
@@ -1,66 +1,30 @@
1
1
  {
2
2
  "name": "@nxtedition/shared",
3
- "version": "3.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": "3.0.1",
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"
62
- },
63
- "scripts": {
64
- "prepare": "husky install"
24
+ "@types/node": "^25.2.3",
25
+ "amaroc": "^1.0.1",
26
+ "oxlint-tsgolint": "^0.13.0",
27
+ "rimraf": "^6.1.3",
28
+ "typescript": "^5.9.3"
65
29
  }
66
30
  }
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