@dmop/puru 0.1.4 → 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 +208 -0
- package/README.md +70 -7
- package/dist/index.cjs +137 -108
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +376 -6
- package/dist/index.d.ts +376 -6
- package/dist/index.js +135 -106
- package/dist/index.js.map +1 -1
- package/llms-full.txt +286 -0
- package/llms.txt +45 -0
- package/package.json +5 -2
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()
|
|
232
|
-
const settled = await wg.waitSettled() //
|
|
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
|