@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 +21 -0
- package/README.md +418 -0
- package/dist/index.cjs +1515 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +128 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +1472 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
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
|