@dmop/puru 0.1.3 → 0.1.5

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/AGENTS.md ADDED
@@ -0,0 +1,208 @@
1
+ # puru — Guide for AI Assistants
2
+
3
+ puru is a thread pool library for JavaScript with Go-style concurrency primitives (channels, WaitGroup, select). It runs functions off the main thread with no worker files and no boilerplate.
4
+
5
+ Full API reference: https://raw.githubusercontent.com/dmop/puru/main/llms-full.txt
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @dmop/puru
11
+ # or
12
+ bun add @dmop/puru
13
+ ```
14
+
15
+ ## The Most Important Rule
16
+
17
+ Functions passed to `spawn()` are serialized via `.toString()` and sent to a worker thread. **They cannot access variables from the outer scope.**
18
+
19
+ ```typescript
20
+ // WRONG — closes over `data`, will throw ReferenceError at runtime
21
+ const data = { id: 1 }
22
+ spawn(() => processData(data))
23
+
24
+ // WRONG — closes over `processData` imported in the outer file
25
+ import { processData } from './utils'
26
+ spawn(() => processData({ id: 1 }))
27
+
28
+ // RIGHT — inline everything the function needs
29
+ spawn(() => {
30
+ function processData(d: { id: number }) {
31
+ return d.id * 2
32
+ }
33
+ return processData({ id: 1 })
34
+ })
35
+
36
+ // RIGHT — inline the data as a literal
37
+ spawn(() => {
38
+ const data = { id: 1 }
39
+ return data.id * 2
40
+ })
41
+ ```
42
+
43
+ Helper functions used inside `spawn()` must also be defined inside the function body:
44
+
45
+ ```typescript
46
+ // WRONG
47
+ function fibonacci(n: number): number {
48
+ if (n <= 1) return n
49
+ return fibonacci(n - 1) + fibonacci(n - 2)
50
+ }
51
+ spawn(() => fibonacci(40)) // ReferenceError: fibonacci is not defined
52
+
53
+ // RIGHT
54
+ spawn(() => {
55
+ function fibonacci(n: number): number {
56
+ if (n <= 1) return n
57
+ return fibonacci(n - 1) + fibonacci(n - 2)
58
+ }
59
+ return fibonacci(40)
60
+ })
61
+ ```
62
+
63
+ ## Common Patterns
64
+
65
+ ### CPU-bound work (exclusive mode)
66
+
67
+ ```typescript
68
+ import { spawn } from '@dmop/puru'
69
+
70
+ const { result } = spawn(() => {
71
+ // define everything you need inside
72
+ function crunch(n: number) {
73
+ let sum = 0
74
+ for (let i = 0; i < n; i++) sum += i
75
+ return sum
76
+ }
77
+ return crunch(1_000_000)
78
+ })
79
+
80
+ console.log(await result)
81
+ ```
82
+
83
+ ### Multiple tasks in parallel (WaitGroup)
84
+
85
+ ```typescript
86
+ import { WaitGroup } from '@dmop/puru'
87
+
88
+ const items = [1, 2, 3, 4]
89
+ const wg = new WaitGroup()
90
+
91
+ for (const item of items) {
92
+ const value = item // capture as a literal for each iteration
93
+ wg.spawn(() => {
94
+ // `value` is NOT captured from closure — this won't work
95
+ // you must inline or use register()/run()
96
+ })
97
+ }
98
+ ```
99
+
100
+ To pass per-task data, use `register`/`run`:
101
+
102
+ ```typescript
103
+ import { register, run, WaitGroup } from '@dmop/puru'
104
+
105
+ // Register once at startup
106
+ register('processItem', (item: number) => item * 2)
107
+
108
+ const items = [1, 2, 3, 4]
109
+ const wg = new WaitGroup()
110
+ for (const item of items) {
111
+ wg.spawn(() => run('processItem', item))
112
+ }
113
+ const results = await wg.wait()
114
+ ```
115
+
116
+ ### Concurrent I/O (concurrent mode)
117
+
118
+ ```typescript
119
+ import { WaitGroup } from '@dmop/puru'
120
+
121
+ const urls = ['https://...', 'https://...']
122
+ const wg = new WaitGroup()
123
+
124
+ for (const url of urls) {
125
+ wg.spawn(() => run('fetchUrl', url), { concurrent: true })
126
+ }
127
+
128
+ register('fetchUrl', (url: string) => fetch(url).then(r => r.json()))
129
+ const results = await wg.wait()
130
+ ```
131
+
132
+ ### Cancel on first error (ErrGroup)
133
+
134
+ ```typescript
135
+ import { ErrGroup, register } from '@dmop/puru'
136
+
137
+ register('fetchUser', (id: number) => fetch(`/api/users/${id}`).then(r => r.json()))
138
+ register('fetchOrders', (id: number) => fetch(`/api/orders/${id}`).then(r => r.json()))
139
+
140
+ const eg = new ErrGroup()
141
+ eg.spawn(() => run('fetchUser', 1))
142
+ eg.spawn(() => run('fetchOrders', 1))
143
+
144
+ const [user, orders] = await eg.wait() // throws on first error, cancels the rest
145
+ ```
146
+
147
+ ### Cross-thread channels (fan-out)
148
+
149
+ ```typescript
150
+ import { chan, spawn } from '@dmop/puru'
151
+
152
+ const input = chan<number>(50)
153
+ const output = chan<number>(50)
154
+
155
+ // 4 worker threads pulling from the same channel
156
+ for (let i = 0; i < 4; i++) {
157
+ spawn(async ({ input, output }) => {
158
+ for await (const n of input) {
159
+ await output.send(n * 2)
160
+ }
161
+ }, { channels: { input, output } })
162
+ }
163
+
164
+ // Producer
165
+ for (let i = 0; i < 100; i++) await input.send(i)
166
+ input.close()
167
+
168
+ // Consume results
169
+ for await (const result of output) {
170
+ console.log(result)
171
+ }
172
+ ```
173
+
174
+ ## What Can Be Sent Through Channels
175
+
176
+ Channel values must be **structured-cloneable**:
177
+
178
+ ```typescript
179
+ // OK
180
+ ch.send(42)
181
+ ch.send('hello')
182
+ ch.send({ id: 1, name: 'foo' })
183
+ ch.send([1, 2, 3])
184
+
185
+ // NOT OK — will throw
186
+ ch.send(() => {}) // functions
187
+ ch.send(Symbol('x')) // symbols
188
+ ch.send(new WeakRef({})) // WeakRefs
189
+ ch.send(null) // null is the "closed" sentinel — use undefined instead
190
+ ```
191
+
192
+ ## Testing
193
+
194
+ Use the inline adapter so tests run on the main thread without real workers:
195
+
196
+ ```typescript
197
+ import { configure } from '@dmop/puru'
198
+
199
+ // In your test setup file
200
+ configure({ adapter: 'inline' })
201
+ ```
202
+
203
+ ## Runtimes
204
+
205
+ - Node.js >= 18: full support
206
+ - Bun: full support
207
+ - Deno: planned
208
+ - Cloudflare Workers / Vercel Edge: not supported (no thread API)
package/README.md CHANGED
@@ -137,6 +137,73 @@ const results = await wg.wait()
137
137
  | Pool management | Manual | Automatic | Automatic | **Automatic** |
138
138
  | Bun support | No | No | No | **Yes** |
139
139
 
140
+ ### puru vs Node.js Cluster
141
+
142
+ These solve different problems and are meant to be used together in production.
143
+
144
+ **Node Cluster** copies your entire app into N processes. The OS load-balances incoming connections across them. The goal is request throughput — use all cores to handle more concurrent HTTP requests.
145
+
146
+ **puru** manages a thread pool inside a single process. Heavy tasks are offloaded off the main event loop to worker threads. The goal is CPU task isolation — use all cores without blocking the event loop.
147
+
148
+ ```text
149
+ Node Cluster (4 processes):
150
+
151
+ OS / Load Balancer
152
+ ┌─────────┬─────────┬─────────┐
153
+ ▼ ▼ ▼ ▼
154
+ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
155
+ │Process │ │Process │ │Process │ │Process │
156
+ │full app│ │full app│ │full app│ │full app│
157
+ │own DB │ │own DB │ │own DB │ │own DB │
158
+ │~100MB │ │~100MB │ │~100MB │ │~100MB │
159
+ └────────┘ └────────┘ └────────┘ └────────┘
160
+
161
+ puru (1 process, thread pool):
162
+
163
+ ┌──────────────────────────────────────┐
164
+ │ Your App (1 process) │
165
+ │ │
166
+ │ Main thread — handles HTTP, DB, I/O │
167
+ │ │
168
+ │ ┌──────────┐ ┌──────────┐ │
169
+ │ │ Thread 1 │ │ Thread 2 │ ... │
170
+ │ │ CPU task │ │ CPU task │ │
171
+ │ └──────────┘ └──────────┘ │
172
+ │ shared memory, one DB pool │
173
+ └──────────────────────────────────────┘
174
+ ```
175
+
176
+ What happens without puru, even with Cluster:
177
+
178
+ ```text
179
+ Request 1 → Process 1 → resize image (2s) → Process 1 event loop FROZEN
180
+ Request 2 → Process 2 → handles fine ✓
181
+ Request 3 → Process 3 → handles fine ✓
182
+ (other processes still work, but each process still blocks on heavy tasks)
183
+
184
+ With puru inside each process:
185
+ Request 1 → spawn(resizeImage) → worker thread, main thread free ✓
186
+ Request 2 → main thread handles instantly ✓
187
+ Request 3 → main thread handles instantly ✓
188
+ ```
189
+
190
+ In production, use both:
191
+
192
+ ```text
193
+ PM2 / Cluster (4 processes) ← maximise request throughput
194
+ └── each process runs puru ← keep each event loop unblocked
195
+ ```
196
+
197
+ | | Cluster | puru |
198
+ | --- | --- | --- |
199
+ | Unit | Process | Thread |
200
+ | Memory | ~100MB per copy | Shared, much lower |
201
+ | Shared state | Needs Redis/IPC | Same process |
202
+ | Solves | Request throughput | CPU task offloading |
203
+ | Event loop | Still blocks per process | Never blocks |
204
+ | DB connections | One pool per process | One pool total |
205
+ | Bun support | No cluster module | Yes |
206
+
140
207
  ## API
141
208
 
142
209
  ### `spawn(fn, opts?)`
@@ -228,8 +295,8 @@ const wg = new WaitGroup()
228
295
  wg.spawn(() => cpuWork()) // exclusive
229
296
  wg.spawn(() => fetchData(), { concurrent: true }) // concurrent
230
297
 
231
- const results = await wg.wait() // like Promise.all
232
- const settled = await wg.waitSettled() // like Promise.allSettled
298
+ const results = await wg.wait() // resolves when all tasks succeed
299
+ const settled = await wg.waitSettled() // resolves when all tasks settle
233
300
 
234
301
  wg.cancel() // cancel all tasks
235
302
  ```
@@ -402,12 +469,11 @@ npm run bench:bun # all benchmarks (Bun)
402
469
 
403
470
  ### Concurrent Async
404
471
 
405
- 100 async tasks with simulated I/O + CPU:
472
+ 100 async tasks with simulated I/O + CPU, running off the main thread:
406
473
 
407
474
  | Approach | Time | vs Sequential |
408
475
  | --- | --: | --: |
409
476
  | Sequential | 1,140 ms | baseline |
410
- | Promise.all (main thread) | 20 ms | 58x faster |
411
477
  | **puru concurrent** | **16 ms** | **73x faster** |
412
478
 
413
479
  <details>
@@ -416,13 +482,10 @@ npm run bench:bun # all benchmarks (Bun)
416
482
  | Approach | Time | vs Sequential |
417
483
  | --- | --: | --: |
418
484
  | Sequential | 1,110 ms | baseline |
419
- | Promise.all (main thread) | 16 ms | 68x faster |
420
485
  | **puru concurrent** | **13 ms** | **87x faster** |
421
486
 
422
487
  </details>
423
488
 
424
- Both Promise.all and puru concurrent are fast — but puru runs everything **off the main thread**, keeping your server responsive under load.
425
-
426
489
  > Spawn overhead is ~0.1-0.5 ms. Use `spawn` for tasks > 5ms. For trivial operations, call directly.
427
490
 
428
491
  ## Runtimes