@dmop/puru 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Danilo Pedrosa
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 ADDED
@@ -0,0 +1,418 @@
1
+ # puru (プール)
2
+
3
+ Goroutine-style concurrency for JavaScript with an **M:N scheduler** — multiplexes thousands of tasks onto a small pool of OS threads, just like Go.
4
+
5
+ Works on **Node.js** and **Bun**. No separate worker files, no manual message passing.
6
+
7
+ *puru (プール) means "pool" in Japanese.*
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install puru
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```typescript
18
+ import { spawn, chan, WaitGroup, select, after } from 'puru'
19
+
20
+ // CPU work — runs in a dedicated worker thread
21
+ const { result } = spawn(() => fibonacci(40))
22
+ console.log(await result)
23
+
24
+ // I/O work — many tasks share worker threads (M:N scheduling)
25
+ const wg = new WaitGroup()
26
+ for (const url of urls) {
27
+ wg.spawn(() => fetch(url).then(r => r.json()), { concurrent: true })
28
+ }
29
+ const results = await wg.wait()
30
+ ```
31
+
32
+ ## How It Works
33
+
34
+ puru uses an **M:N scheduler** — M tasks are multiplexed onto N OS threads:
35
+
36
+ ```text
37
+ puru scheduler
38
+ ┌──────────────────────────────┐
39
+ │ │
40
+ │ Task 1 ─┐ │
41
+ │ Task 2 ─┤──► Thread 1 │
42
+ │ Task 3 ─┘ (shared) │
43
+ │ │
44
+ │ Task 4 ────► Thread 2 │ N threads
45
+ │ (exclusive) │ (os.availableParallelism)
46
+ │ │
47
+ │ Task 5 ─┐ │
48
+ │ Task 6 ─┤──► Thread 3 │
49
+ │ Task 7 ─┘ (shared) │
50
+ │ │
51
+ └──────────────────────────────┘
52
+ ```
53
+
54
+ **Two modes:**
55
+
56
+ | Mode | Flag | Best for | How it works |
57
+ | --- | --- | --- | --- |
58
+ | **Exclusive** (default) | `spawn(fn)` | CPU-bound work | 1 task per thread, full core usage |
59
+ | **Concurrent** | `spawn(fn, { concurrent: true })` | I/O-bound / async work | Many tasks share a thread's event loop |
60
+
61
+ This is the same model Go uses: goroutines (M) are scheduled onto OS threads (N). CPU-bound work gets a dedicated thread. I/O-bound work shares threads efficiently.
62
+
63
+ ## Why puru
64
+
65
+ Same task, four ways — process 4 items in parallel:
66
+
67
+ **worker_threads** — 2 files, 15 lines, manual everything:
68
+
69
+ ```typescript
70
+ // worker.js (separate file required)
71
+ const { parentPort } = require('worker_threads')
72
+ parentPort.on('message', (data) => {
73
+ parentPort.postMessage(heavyWork(data))
74
+ })
75
+
76
+ // main.js
77
+ import { Worker } from 'worker_threads'
78
+ const results = await Promise.all(items.map(item =>
79
+ new Promise((resolve, reject) => {
80
+ const w = new Worker('./worker.js')
81
+ w.postMessage(item)
82
+ w.on('message', resolve)
83
+ w.on('error', reject)
84
+ })
85
+ ))
86
+ ```
87
+
88
+ **Tinypool** — still needs a separate file:
89
+
90
+ ```typescript
91
+ // worker.js (separate file required)
92
+ export default function(data) { return heavyWork(data) }
93
+
94
+ // main.js
95
+ import Tinypool from 'tinypool'
96
+ const pool = new Tinypool({ filename: './worker.js' })
97
+ const results = await Promise.all(items.map(item => pool.run(item)))
98
+ ```
99
+
100
+ **Piscina** — same pattern, separate file:
101
+
102
+ ```typescript
103
+ // worker.js (separate file required)
104
+ module.exports = function(data) { return heavyWork(data) }
105
+
106
+ // main.js
107
+ import Piscina from 'piscina'
108
+ const pool = new Piscina({ filename: './worker.js' })
109
+ const results = await Promise.all(items.map(item => pool.run(item)))
110
+ ```
111
+
112
+ **puru** — one file, 4 lines:
113
+
114
+ ```typescript
115
+ import { WaitGroup } from 'puru'
116
+ const wg = new WaitGroup()
117
+ for (const item of items) wg.spawn(() => heavyWork(item))
118
+ const results = await wg.wait()
119
+ ```
120
+
121
+ | Feature | worker_threads | Tinypool | Piscina | **puru** |
122
+ | --- | --- | --- | --- | --- |
123
+ | Separate worker file | Required | Required | Required | **Not needed** |
124
+ | Inline functions | No | No | No | **Yes** |
125
+ | M:N scheduler | No | No | No | **Yes** |
126
+ | Concurrent mode (I/O) | No | No | No | **Yes** |
127
+ | Channels (cross-thread) | No | No | No | **Yes** |
128
+ | Cancellation | No | No | No | **Yes** |
129
+ | WaitGroup / ErrGroup | No | No | No | **Yes** |
130
+ | select (with default) | No | No | No | **Yes** |
131
+ | Mutex / Once | No | No | No | **Yes** |
132
+ | Ticker | No | No | No | **Yes** |
133
+ | Backpressure | No | No | No | **Yes** |
134
+ | Priority scheduling | No | No | Yes | **Yes** |
135
+ | Pool management | Manual | Automatic | Automatic | **Automatic** |
136
+ | Bun support | No | No | No | **Yes** |
137
+
138
+ ## API
139
+
140
+ ### `spawn(fn, opts?)`
141
+
142
+ Run a function in a worker thread. Returns `{ result: Promise<T>, cancel: () => void }`.
143
+
144
+ ```typescript
145
+ // CPU-bound — exclusive mode (default)
146
+ const { result } = spawn(() => fibonacci(40))
147
+
148
+ // I/O-bound — concurrent mode (many tasks per thread)
149
+ const { result } = spawn(() => fetch(url), { concurrent: true })
150
+
151
+ // With priority
152
+ const { result } = spawn(() => criticalWork(), { priority: 'high' })
153
+
154
+ // Cancel
155
+ const { result, cancel } = spawn(() => longTask())
156
+ setTimeout(cancel, 5000)
157
+ ```
158
+
159
+ **Exclusive mode** (default): the function gets a dedicated thread. Use for CPU-heavy work.
160
+
161
+ **Concurrent mode** (`{ concurrent: true }`): multiple tasks share a thread's event loop. Use for async/I/O work where you want to run thousands of tasks without thousands of threads.
162
+
163
+ Functions must be self-contained — they cannot capture variables from the enclosing scope:
164
+
165
+ ```typescript
166
+ const x = 42
167
+ spawn(() => x + 1) // ReferenceError: x is not defined
168
+ spawn(() => 42 + 1) // works
169
+ ```
170
+
171
+ ### `chan(capacity?)`
172
+
173
+ Create a channel for communicating between async tasks — including across worker threads.
174
+
175
+ ```typescript
176
+ const ch = chan<number>(10) // buffered, capacity 10
177
+ const ch = chan<string>() // unbuffered, capacity 0
178
+
179
+ await ch.send(42)
180
+ const value = await ch.recv() // 42
181
+
182
+ ch.close()
183
+ await ch.recv() // null (closed)
184
+
185
+ // Async iteration
186
+ for await (const value of ch) {
187
+ process(value)
188
+ }
189
+ ```
190
+
191
+ **Channels in workers** — pass channels to `spawn()` and use them from worker threads, just like Go:
192
+
193
+ ```typescript
194
+ const ch = chan<number>(10)
195
+
196
+ // Producer worker
197
+ spawn(async ({ ch }) => {
198
+ for (let i = 0; i < 100; i++) await ch.send(i)
199
+ ch.close()
200
+ }, { channels: { ch } })
201
+
202
+ // Consumer worker
203
+ spawn(async ({ ch }) => {
204
+ for await (const item of ch) process(item)
205
+ }, { channels: { ch } })
206
+
207
+ // Fan-out: multiple workers pulling from the same channel
208
+ const input = chan<Job>(50)
209
+ const output = chan<Result>(50)
210
+
211
+ for (let i = 0; i < 4; i++) {
212
+ spawn(async ({ input, output }) => {
213
+ for await (const job of input) {
214
+ await output.send(processJob(job))
215
+ }
216
+ }, { channels: { input, output } })
217
+ }
218
+ ```
219
+
220
+ ### `WaitGroup`
221
+
222
+ Structured concurrency. Spawn multiple tasks, wait for all.
223
+
224
+ ```typescript
225
+ const wg = new WaitGroup()
226
+ wg.spawn(() => cpuWork()) // exclusive
227
+ wg.spawn(() => fetchData(), { concurrent: true }) // concurrent
228
+
229
+ const results = await wg.wait() // like Promise.all
230
+ const settled = await wg.waitSettled() // like Promise.allSettled
231
+
232
+ wg.cancel() // cancel all tasks
233
+ ```
234
+
235
+ ### `ErrGroup`
236
+
237
+ Like `WaitGroup`, but cancels all remaining tasks on first error. The Go standard for production code (`golang.org/x/sync/errgroup`).
238
+
239
+ ```typescript
240
+ const eg = new ErrGroup()
241
+ eg.spawn(() => fetchUser(id))
242
+ eg.spawn(() => fetchOrders(id))
243
+ eg.spawn(() => fetchAnalytics(id))
244
+
245
+ try {
246
+ const [user, orders, analytics] = await eg.wait()
247
+ } catch (err) {
248
+ // First error — all other tasks were cancelled
249
+ console.error('Failed:', err)
250
+ }
251
+ ```
252
+
253
+ ### `Mutex`
254
+
255
+ Async mutual exclusion. Serialize access to shared resources under concurrency.
256
+
257
+ ```typescript
258
+ const mu = new Mutex()
259
+
260
+ // withLock — recommended (auto-unlocks on error)
261
+ const result = await mu.withLock(async () => {
262
+ return await db.query('UPDATE ...')
263
+ })
264
+
265
+ // Manual lock/unlock
266
+ await mu.lock()
267
+ try { /* critical section */ }
268
+ finally { mu.unlock() }
269
+ ```
270
+
271
+ ### `Once<T>`
272
+
273
+ Run a function exactly once, even if called concurrently. All callers get the same result.
274
+
275
+ ```typescript
276
+ const once = new Once<DBConnection>()
277
+ const conn = await once.do(() => createExpensiveConnection())
278
+ // Subsequent calls return the cached result
279
+ ```
280
+
281
+ ### `select(cases, opts?)`
282
+
283
+ Wait for the first of multiple promises to resolve, like Go's `select`.
284
+
285
+ ```typescript
286
+ // Blocking — waits for first ready
287
+ await select([
288
+ [ch.recv(), (value) => console.log('received', value)],
289
+ [after(5000), () => console.log('timeout')],
290
+ ])
291
+
292
+ // Non-blocking — returns immediately if nothing is ready (Go's select with default)
293
+ await select(
294
+ [[ch.recv(), (value) => process(value)]],
295
+ { default: () => console.log('channel not ready') },
296
+ )
297
+ ```
298
+
299
+ ### `after(ms)` / `ticker(ms)`
300
+
301
+ Timers for use with `select` and async iteration.
302
+
303
+ ```typescript
304
+ await after(1000) // one-shot: resolves after 1 second
305
+
306
+ // Repeating: tick every 500ms
307
+ const t = ticker(500)
308
+ for await (const _ of t) {
309
+ console.log('tick')
310
+ if (shouldStop) t.stop()
311
+ }
312
+ ```
313
+
314
+ ### `register(name, fn)` / `run(name, ...args)`
315
+
316
+ Named task registry. Register functions by name, call them by name.
317
+
318
+ ```typescript
319
+ register('resize', (buffer, w, h) => sharp(buffer).resize(w, h).toBuffer())
320
+ const resized = await run('resize', imageBuffer, 800, 600)
321
+ ```
322
+
323
+ ### `configure(opts?)`
324
+
325
+ Optional global configuration. Must be called before the first `spawn()`.
326
+
327
+ ```typescript
328
+ configure({
329
+ maxThreads: 4, // default: os.availableParallelism()
330
+ concurrency: 64, // max concurrent tasks per shared worker (default: 64)
331
+ idleTimeout: 30_000, // kill idle workers after 30s (default)
332
+ adapter: 'auto', // 'auto' | 'node' | 'bun' | 'inline'
333
+ })
334
+ ```
335
+
336
+ ### `stats()` / `resize(n)`
337
+
338
+ ```typescript
339
+ const s = stats() // { totalWorkers, idleWorkers, busyWorkers, queuedTasks, ... }
340
+ resize(8) // scale pool up/down at runtime
341
+ ```
342
+
343
+ ### `detectRuntime()` / `detectCapability()`
344
+
345
+ ```typescript
346
+ detectRuntime() // 'node' | 'bun' | 'deno' | 'browser'
347
+ detectCapability() // 'full-threads' | 'single-thread'
348
+ ```
349
+
350
+ ## Benchmarks
351
+
352
+ Apple M1 Pro (8 cores), 16 GB RAM. Median of 5 runs after warmup.
353
+
354
+ ```bash
355
+ npm run bench # all benchmarks (Node.js)
356
+ npm run bench:bun # all benchmarks (Bun)
357
+ ```
358
+
359
+ ### CPU-Bound Parallelism (Node.js)
360
+
361
+ | Benchmark | Without puru | With puru | Speedup |
362
+ | --- | --: | --: | --: |
363
+ | Fibonacci (fib(38) x8) | 4,345 ms | 2,131 ms | **2.0x** |
364
+ | Prime counting (2M range) | 335 ms | 77 ms | **4.4x** |
365
+ | Matrix multiply (200x200 x8) | 140 ms | 39 ms | **3.6x** |
366
+ | Data processing (100K items x8) | 221 ms | 67 ms | **3.3x** |
367
+
368
+ ### Channels Fan-Out Pipeline (Node.js)
369
+
370
+ 200 items with CPU-heavy transform, 4 parallel transform workers:
371
+
372
+ | Approach | Time | vs Sequential |
373
+ | --- | --: | --: |
374
+ | Sequential (no channels) | 176 ms | baseline |
375
+ | Main-thread channels only | 174 ms | 1.0x |
376
+ | **puru fan-out (4 workers)** | **51 ms** | **3.4x faster** |
377
+
378
+ ### M:N Concurrent Async (Node.js)
379
+
380
+ 100 async tasks with simulated I/O + CPU:
381
+
382
+ | Approach | Time | vs Sequential |
383
+ | --- | --: | --: |
384
+ | Sequential | 1,140 ms | baseline |
385
+ | Promise.all (main thread) | 20 ms | 58x faster |
386
+ | **puru concurrent (M:N)** | **16 ms** | **73x faster** |
387
+
388
+ Both Promise.all and puru concurrent are fast — but puru runs everything **off the main thread**, keeping your server responsive under load.
389
+
390
+ > Spawn overhead is ~0.1-0.5 ms. Use `spawn` for tasks > 5ms. For trivial operations, call directly.
391
+
392
+ ## Runtimes
393
+
394
+ | Runtime | Support | How |
395
+ | --- | --- | --- |
396
+ | Node.js >= 18 | Full | `worker_threads` |
397
+ | Bun | Full | Web Workers (file-based) |
398
+ | Cloudflare Workers | Error | No thread support |
399
+ | Vercel Edge | Error | No thread support |
400
+
401
+ ## Testing
402
+
403
+ ```typescript
404
+ import { configure } from 'puru'
405
+ configure({ adapter: 'inline' }) // runs tasks in main thread, no real workers
406
+ ```
407
+
408
+ ## Limitations
409
+
410
+ - Functions passed to `spawn()` cannot capture variables from the enclosing scope
411
+ - Channel values must be structured-cloneable (no functions, symbols, or WeakRefs)
412
+ - `null` cannot be sent through a channel (it's the "closed" sentinel)
413
+ - `register()`/`run()` args must be JSON-serializable
414
+ - Channel operations from workers have ~0.1-0.5ms RPC overhead per send/recv (fine for coarse-grained coordination, not for per-item micro-operations)
415
+
416
+ ## License
417
+
418
+ MIT