bun-shared-memory 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dennis Dudek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,311 @@
1
- # Temporary Holding Version
1
+ # bun-shared-memory
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A TypeScript library for the **Bun** runtime that enables sharing data between workers/threads using `SharedArrayBuffer`.
4
+
5
+ > **Requires Bun ≥ 1.4.0.** The worker integration (M4) relies on the
6
+ > `process.on("worker")` handshake, which only works reliably from Bun 1.4.0
7
+ > onward. Core shared-memory operations (including `SharedArrayBuffer.grow()`)
8
+ > work on earlier versions, but the automatic worker buffer-sharing does not.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ bun add bun-shared-memory
14
+ ```
15
+
16
+ See [SPEC.md](./SPEC.md) for the full specification.
17
+
18
+ ## Status
19
+
20
+ ✅ **M3 — Synchronization**: `Atomics`-based locking (`Mutex`) and notifications (`Notifier`) between workers, integrated into all shared-memory operations.
21
+
22
+ ✅ **Read cache**: reads are cached per worker and invalidated cross-worker via a per-key version counter.
23
+
24
+ ✅ **Reference counting & defragmentation**: workers acquire/release references to shared objects/arrays (released on GC), the main thread tracks which worker holds what, and a defragmentation pass reclaims dead space when free space is low.
25
+
26
+ ✅ **M4 — Worker integration**: a worker can simply call `Shared()` and automatically share the same buffers as the main thread. Both sides call `init()` — the main thread answers buffer requests, each worker requests the shared buffers — no manual buffer serialization needed.
27
+
28
+ ## Data Types
29
+
30
+ - **Allowed**: primitives (`number`, `string`, `boolean`), `bigint`, and arrays of these (mixed arrays allowed).
31
+ - **BigInts** of any size are supported: values that fit in 64 bits are stored as a fast int64 slot; larger values are stored as their decimal string in the StringTable.
32
+ - **Objects** have a **fixed shape**: once created, properties may not be added or removed — only their values can change.
33
+ - **Not allowed**: nested objects, nested arrays, `null`, `undefined`.
34
+
35
+ ## Usage
36
+
37
+ ```ts
38
+ import { Shared } from "bun-shared-memory";
39
+
40
+ // Get a wrapper to a named value in shared memory.
41
+ // If it doesn't exist, create it with the provided initial value.
42
+ const user = Shared<User>("user-1", { name: "Alice", age: 30, tags: ["a"] });
43
+
44
+ // Access object properties directly via getters/setters
45
+ user.name; // "Alice"
46
+ user.name = "Bob"; // writes to shared memory
47
+ user.age = 31;
48
+
49
+ // Arrays are proxied too: index access and mutating methods work
50
+ user.tags.push("b");
51
+ user.tags[0] = "x";
52
+ user.tags.pop();
53
+
54
+ // Top-level arrays work the same way
55
+ const arr = Shared<number[]>("arr-1", [1, 2, 3]);
56
+ arr.push(4);
57
+ arr[0] = 99;
58
+
59
+ // Primitives use read()/write()/update()
60
+ const counter = Shared<number>("counter-1", 0);
61
+ counter.write(42);
62
+ counter.update((n) => n + 1);
63
+
64
+ // Or create it with a loader function (sync or async)
65
+ const user2 = await Shared<User>("user-2", async () => {
66
+ return await fetchUser();
67
+ });
68
+ ```
69
+
70
+ ## Synchronization
71
+
72
+ All shared-memory reads and writes are protected by an `Atomics`-based mutex, so concurrent access from multiple workers is safe.
73
+
74
+ ### Read cache
75
+
76
+ Reads are cached per worker to avoid re-decoding the value on every access.
77
+ Each key has a version cell (an `Int32`) in shared memory that is incremented
78
+ on every write. A read does a single lock-free `Atomics.load` on the version
79
+ cell; if it matches the cached version, the cached value is returned without
80
+ taking the lock or decoding. Because the version cell is shared, a write in
81
+ any worker invalidates the cache in every other worker automatically.
82
+
83
+ `read()` returns a copy, so mutating the returned object cannot corrupt the
84
+ cache. Property access through the proxy (e.g. `user.name`) reads from the
85
+ cache directly.
86
+
87
+ ### Offset cache
88
+
89
+ Because object properties may not be added or removed, an object's layout
90
+ (total DataTable offset + each property's slot offset) is static for the
91
+ lifetime of the object. The layout is built once (under the lock) and cached
92
+ per worker, so a property read decodes only the requested slot instead of the
93
+ whole object. The decoded property value is cached per property and
94
+ invalidated by the same per-key version cell, so repeated reads of the same
95
+ property are cheap. If a future GC/defragmentation moves an object block, the
96
+ layout cache must be invalidated (e.g. via a layout version cell).
97
+
98
+ ### Mutex
99
+
100
+ A re-entrant cross-worker mutex. Share the same buffer across workers.
101
+
102
+ ```ts
103
+ import { Mutex } from "bun-shared-memory";
104
+
105
+ const mutex = new Mutex();
106
+ // pass mutex.buffer to other workers, then:
107
+ mutex.lock();
108
+ try {
109
+ // critical section
110
+ } finally {
111
+ mutex.unlock();
112
+ }
113
+ // or
114
+ mutex.withLock(() => {
115
+ // critical section
116
+ });
117
+ ```
118
+
119
+ ### Notifier
120
+
121
+ A cross-worker notification primitive built on `Atomics.wait`/`Atomics.notify`.
122
+
123
+ ```ts
124
+ import { Notifier } from "bun-shared-memory";
125
+
126
+ const notifier = new Notifier();
127
+ // pass notifier.buffer to other workers, then:
128
+ notifier.wait(); // block until notified
129
+ notifier.notify(1); // wake one waiting worker
130
+ ```
131
+
132
+ ## Reference counting & memory reclamation
133
+
134
+ The shared buffers are append-only, so dead space (overwritten strings,
135
+ abandoned array blocks, replaced objects) is never reclaimed on its own. A
136
+ reference-counting system tracks which workers hold references to which
137
+ objects/arrays, and a **defragmentation** pass reclaims memory when free space
138
+ runs low.
139
+
140
+ When you use the worker integration (`init()`), this is wired up automatically:
141
+ `init()` starts a `RefCountCoordinator` on the main thread (auto-maintenance +
142
+ auto-registration of workers) and a `RefCountTracker` in each worker, so
143
+ `read()` acquires references that are released on GC and dead space is
144
+ defragmented — no manual setup needed.
145
+
146
+ ```ts
147
+ import { Shared, init } from "bun-shared-memory";
148
+
149
+ // Main thread: init() starts the coordinator automatically.
150
+ init();
151
+ const user = Shared("user-1", { name: "Alice" });
152
+ const copy = user.read(); // acquires a ref; released when `copy` is GC'd
153
+ ```
154
+
155
+ You can also wire it up manually (e.g. when not using `init()`):
156
+
157
+ ```ts
158
+ import {
159
+ SharedMemory,
160
+ RefCountCoordinator,
161
+ RefCountTracker,
162
+ RefCountBuffer,
163
+ } from "bun-shared-memory";
164
+
165
+ // Main thread: coordinate refcounts and auto-register workers.
166
+ const mem = new SharedMemory();
167
+ const coord = new RefCountCoordinator(mem); // auto-maintenance starts automatically
168
+ coord.autoRegister(); // track workers via process.on("worker")
169
+ // The coordinator periodically drains acquire/release events and defragments
170
+ // when free space runs low — no manual calls needed.
171
+
172
+ // Worker: track references acquired by read().
173
+ const tracker = new RefCountTracker(new RefCountBuffer(refBuf), workerId);
174
+ const user = Shared(
175
+ "user-1",
176
+ { name: "Alice" },
177
+ { memory: mem, refCount: tracker },
178
+ );
179
+ const copy = user.read(); // acquires a ref; released when `copy` is GC'd
180
+ ```
181
+
182
+ See [REFCOUNT.md](./REFCOUNT.md) for the full design.
183
+
184
+ ## Worker integration
185
+
186
+ A worker can simply call `Shared()` and automatically share the same buffers
187
+ as the main thread — no manual buffer serialization needed. Both sides call
188
+ `init()` to set up the handshake: the main thread answers buffer requests from
189
+ workers, and each worker requests the shared buffers from the main thread.
190
+
191
+ ```ts
192
+ // main.ts
193
+ import { Shared, init } from "bun-shared-memory";
194
+
195
+ const user = Shared("user-1", { name: "Alice", age: 30 });
196
+ init(); // answer buffer requests from every new worker
197
+ ```
198
+
199
+ ```ts
200
+ // worker.ts
201
+ import { Shared, init, onMessage } from "bun-shared-memory";
202
+
203
+ await init(); // request the shared buffers from the main thread
204
+
205
+ // Register your own message handler via onMessage(); messages are buffered
206
+ // until the buffers have arrived.
207
+ onMessage((e) => {
208
+ const user = Shared("user-1", { name: "x" }); // reads the main thread's value
209
+ self.postMessage(user.read());
210
+ });
211
+ ```
212
+
213
+ `init()` is safe to call on both sides: in the main thread it registers the
214
+ buffer-request listener and starts the refcount coordinator (auto-maintenance
215
+
216
+ - worker auto-registration), and in a worker it performs the request/response
217
+ handshake, reconstructs the shared buffers, and creates a refcount tracker —
218
+ so reference counting works automatically. `Shared()` never overwrites a value
219
+ that already exists in shared memory, so a worker reading a key created by the
220
+ main thread gets the existing value rather than its own initial value.
221
+
222
+ > **Note:** a worker must call `await init()` before using `Shared()`. Calling
223
+ > `Shared()` in a worker without `init()` throws an error rather than silently
224
+ > creating a separate, unshared memory.
225
+
226
+ A complete runnable example is in [`examples/worker/`](examples/worker/).
227
+
228
+ ## Memory budget
229
+
230
+ Shared memory is split across several growable `SharedArrayBuffer`s (the
231
+ index, data, string, and array tables; each key's version counter lives in its
232
+ index entry). A single `SharedArrayBuffer` can be at most **4 GiB**. By default
233
+ each buffer grows independently up to its own `maxByteLength`, so the _total_
234
+ memory can be several times that.
235
+
236
+ To cap the **total** memory across all buffers, pass `maxTotalBytes` when
237
+ creating the memory (or to `init()`):
238
+
239
+ ```ts
240
+ import { SharedMemory, init } from "bun-shared-memory";
241
+
242
+ // Total budget of 4 GiB shared across all buffers. Each buffer's own
243
+ // maxByteLength is min(4 GiB, maxTotalBytes), and the sum of all buffers'
244
+ // physical sizes is additionally capped at maxTotalBytes.
245
+ const mem = new SharedMemory({ maxTotalBytes: 4 * 1024 * 1024 * 1024 });
246
+
247
+ // Or set it when initializing the shared-memory handshake:
248
+ await init({ maxTotalBytes: 4 * 1024 * 1024 * 1024 });
249
+ ```
250
+
251
+ The budget is shared dynamically — buffers grow as needed, but only while the
252
+ sum of all buffers stays within `maxTotalBytes`. If a write would exceed the
253
+ budget, a `RangeError` is thrown.
254
+
255
+ ## Array capacity
256
+
257
+ By default an array is stored with exactly as many slots as it has elements,
258
+ so the first `push` must relocate the array (with doubled capacity) to make
259
+ room. To avoid that, reserve extra capacity up front with `arrayCapacity`:
260
+
261
+ ```ts
262
+ import { Shared } from "bun-shared-memory";
263
+
264
+ // Reserve 100 slots so the first ~90 pushes append in place without moving.
265
+ const arr = Shared<number[]>("arr-cap", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], {
266
+ arrayCapacity: 100,
267
+ });
268
+ ```
269
+
270
+ `arrayCapacity` is the initial capacity (in elements) reserved for arrays when
271
+ they are first stored. `push` appends in place until the capacity is
272
+ exhausted, then the array is relocated with doubled capacity. It defaults to
273
+ the array's length.
274
+
275
+ ## Development
276
+
277
+ ```bash
278
+ bun install # install dependencies
279
+ bun run typecheck # type check (bun check)
280
+ bun test # run tests
281
+ bun fmt # format code
282
+ bun run bench # run benchmarks
283
+ ```
284
+
285
+ ## Benchmarks
286
+
287
+ `bun run bench` compares shared-memory access against native JS access for
288
+ every supported data type and its arrays:
289
+
290
+ - **Primitives**: `number`, `string`, `boolean`, `bigint` (read + write)
291
+ - **Arrays**: `number[]`, `string[]`, `boolean[]`, `bigint[]`, `mixed[]` (index read, index write)
292
+ - **Objects**: property read/write and array-property access
293
+
294
+ Each benchmark reports ops/sec for the native and shared paths, plus the
295
+ relative slowdown. Results are in `bench/`.
296
+
297
+ With the read cache and offset cache, **reads** are typically 5–12× slower than
298
+ native (the remaining cost is the proxy dispatch). **Writes** are slower because
299
+ they must serialize, write, and bump the version cell. Object property and array
300
+ index writes are O(1) in-place updates (only the affected slot is written).
301
+ Array `push`/`pop` are O(1) amortized in-place operations: arrays reserve
302
+ capacity padding in the ArrayTable, `push` writes into the padding until it is
303
+ exhausted (then the array is relocated with doubled capacity), and `pop` just
304
+ decrements the length.
305
+
306
+ **String interning**: repeated writes of the same string reuse a cached
307
+ StringTable reference instead of re-encoding and re-copying the bytes. The
308
+ per-worker LRU cache grows dynamically (configurable via `internCapacity` /
309
+ `internMax` on `SharedMemory`) and evicts least-recently-used entries once it
310
+ reaches capacity. Because the StringTable is append-only, cached references stay
311
+ valid for the lifetime of the buffer.
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./src/index.ts";
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "bun-shared-memory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Share data between Bun workers/threads using SharedArrayBuffer (zero-copy, 4-buffer architecture, Atomics-based synchronization).",
5
+ "type": "module",
6
+ "module": "index.ts",
7
+ "types": "index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.ts",
11
+ "default": "./index.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "index.ts",
16
+ "src/"
17
+ ],
18
+ "scripts": {
19
+ "typecheck": "bun check",
20
+ "test": "bun test",
21
+ "bench": "bun bench/bench.ts"
22
+ },
23
+ "keywords": [
24
+ "bun",
25
+ "shared-memory",
26
+ "sharedarraybuffer",
27
+ "worker",
28
+ "threads",
29
+ "atomics",
30
+ "zero-copy"
31
+ ],
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/Kapsonfire/bun-shared-memory.git"
36
+ },
37
+ "engines": {
38
+ "bun": ">=1.4.0"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "devDependencies": {
44
+ "@types/bun": "latest"
45
+ },
46
+ "peerDependencies": {
47
+ "typescript": "^7"
48
+ }
49
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Shared memory budget shared across multiple GrowableBuffers.
3
+ *
4
+ * Tracks the sum of the physical sizes of all participating buffers so a
5
+ * caller can cap the *total* memory used by a set of buffers, rather than
6
+ * capping each buffer independently. Growth is only allowed if the new total
7
+ * stays within the budget.
8
+ */
9
+ export class SharedBudget {
10
+ private total = 0;
11
+
12
+ constructor(private readonly max: number) {}
13
+
14
+ /** Record a buffer's initial physical size. */
15
+ add(size: number): void {
16
+ this.total += size;
17
+ if (this.total > this.max) {
18
+ throw new RangeError(
19
+ `Shared memory budget exceeded: initial buffers total ${this.total} bytes but the budget is ${this.max}`,
20
+ );
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Attempt to grow a buffer from `oldSize` to `newSize`. Returns true and
26
+ * updates the total if the new total stays within the budget, false
27
+ * otherwise (the caller must not grow).
28
+ */
29
+ tryGrow(oldSize: number, newSize: number): boolean {
30
+ const newTotal = this.total - oldSize + newSize;
31
+ if (newTotal > this.max) return false;
32
+ this.total = newTotal;
33
+ return true;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Growable SharedArrayBuffer helper.
39
+ *
40
+ * SharedArrayBuffer supports `grow(newLength)` when created with
41
+ * `maxByteLength`. Growth preserves existing data, can only grow up to
42
+ * `maxByteLength`, and cannot shrink. All workers sharing the buffer see the
43
+ * growth immediately.
44
+ */
45
+ export class GrowableBuffer {
46
+ readonly buffer: SharedArrayBuffer;
47
+ private view: DataView;
48
+ private readonly maxByteLength: number;
49
+ private readonly budget?: SharedBudget;
50
+ private used = 0;
51
+
52
+ constructor(
53
+ initialSizeOrBuffer: number | SharedArrayBuffer,
54
+ maxByteLength: number,
55
+ budget?: SharedBudget,
56
+ ) {
57
+ this.buffer =
58
+ typeof initialSizeOrBuffer === "number"
59
+ ? new SharedArrayBuffer(initialSizeOrBuffer, { maxByteLength })
60
+ : initialSizeOrBuffer;
61
+ this.view = new DataView(this.buffer);
62
+ this.maxByteLength = maxByteLength;
63
+ this.budget = budget;
64
+ this.budget?.add(this.buffer.byteLength);
65
+ }
66
+
67
+ /** Number of bytes written so far (used length, not buffer capacity). */
68
+ get byteLength(): number {
69
+ return this.used;
70
+ }
71
+
72
+ /** Ensure the buffer has room for at least `needed` bytes. */
73
+ ensureCapacity(needed: number): void {
74
+ if (needed <= this.buffer.byteLength) return;
75
+ if (needed > this.maxByteLength) {
76
+ throw new RangeError(
77
+ `Shared buffer capacity exceeded: need ${needed} bytes but max is ${this.maxByteLength}`,
78
+ );
79
+ }
80
+ let newSize = this.buffer.byteLength * 2;
81
+ while (newSize < needed) newSize *= 2;
82
+ if (newSize > this.maxByteLength) newSize = this.maxByteLength;
83
+ if (this.budget && !this.budget.tryGrow(this.buffer.byteLength, newSize)) {
84
+ throw new RangeError(
85
+ `Shared memory budget exceeded: growing to ${newSize} bytes would exceed the total budget`,
86
+ );
87
+ }
88
+ this.buffer.grow(newSize);
89
+ this.view = new DataView(this.buffer);
90
+ }
91
+
92
+ /** Mark `n` bytes as used, after writing them at the current offset. */
93
+ advance(n: number): void {
94
+ this.used += n;
95
+ }
96
+
97
+ /**
98
+ * Reset the used length to `n` bytes. Used by defragmentation after
99
+ * compacting live data to the front of the buffer. The underlying
100
+ * SharedArrayBuffer is not shrunk (it cannot be), but the tail beyond `n`
101
+ * is treated as free space for future writes.
102
+ */
103
+ reset(n: number): void {
104
+ this.used = n;
105
+ }
106
+
107
+ getView(): DataView {
108
+ return this.view;
109
+ }
110
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ export { Shared } from "./shared.ts";
2
+ export type { SharedRef, SharedOptions } from "./shared.ts";
3
+ export { SharedMemory } from "./memory.ts";
4
+ export type { SharedMemoryOptions } from "./memory.ts";
5
+ export { Mutex } from "./mutex.ts";
6
+ export { Notifier } from "./notifier.ts";
7
+ export {
8
+ RefCountBuffer,
9
+ RefCountCoordinator,
10
+ RefCountTracker,
11
+ } from "./refcount.ts";
12
+ export type { ObjectId, RefEvent } from "./refcount.ts";
13
+ export {
14
+ getDefaultMemory,
15
+ setDefaultMemory,
16
+ hasDefaultMemory,
17
+ getDefaultTracker,
18
+ init,
19
+ stopInit,
20
+ onMessage,
21
+ } from "./worker.ts";