Skip to content

Commit 32b01db

Browse files
committed
feat(worker): add optional worker pool for CPU-bound work ⚡
- Add Worker Pool docs (en, id) and sidebar; CHANGELOG and README - Add Worker pool (createPool, run), export and Types (WorkerPoolOptions, WorkerRunHandle) - Add benchmark main-worker, test-cpu and test-worker routes; update benchmark README - Handler sets ctx.state.worker when configured; Router forwards worker option - Add Worker tests and echo/error fixtures; Handler/Router worker tests; reorder validateModule tests - Remove bench task from deno.json; sort Types.ts A–Z, JSDoc on Worker.ts
1 parent a9db24b commit 32b01db

20 files changed

Lines changed: 662 additions & 136 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,17 @@ Format inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
## [Unreleased]
1010

11-
(Nothing yet.)
11+
### Added
12+
13+
- **feat(worker):** Worker pool for CPU-bound tasks; optional `worker` option on Router with `scriptURL` and `poolSize`; `ctx.state.worker.run(payload)` in routes when enabled
14+
- **docs(worker):** Worker Pool docs (en + id) under Core Concepts, marked Unreleased; VitePress sidebar updated
15+
- **benchmark(worker):** `main-worker.ts`, `/test-worker` and `/test-cpu` routes, benchmark README in English
16+
- **test(worker):** Worker pool tests and fixtures (`echo_worker.ts`, `error_worker.ts`)
17+
18+
### Changed
19+
20+
- **refactor(src):** Clear naming and A–Z sort in `Worker.ts`; JSDoc (ts-js-jsdoc) and constructor docs; Types.ts interfaces/properties sorted A–Z
21+
- **docs(benchmark):** Benchmark README — one Indonesian sentence translated to English
1222

1323
---
1424

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Build HTTP server effortlessly with zero configuration for productivity.
1616
- **Middleware** — Global, path-specific. CORS, SecHeaders, Body Limit, Basic Auth, Session, WebSocket.
1717
- **Static Files**`router.static(urlPath, options)` with optional etag and cache-control.
1818
- **Error Handling** — Pluggable error response builder and error middleware; default HTML/JSON by `Accept`.
19+
- **Worker Pool** — Optional worker pool for CPU-bound work; enable via `worker: { scriptURL, poolSize }`.
1920
- **Frontend Optional** — Use any stack (Vite, React, etc.); Deserve stays the server.
2021

2122
## Installation
@@ -40,6 +41,12 @@ import { Router } from 'jsr:@neabyte/deserve'
4041
// Create router and point to your routes directory
4142
const router = new Router({ routesDir: './routes' })
4243

44+
// Optional: enable worker pool for CPU-bound work (ctx.state.worker.run(payload) in routes)
45+
// const router = new Router({
46+
// routesDir: './routes',
47+
// worker: { scriptURL: import.meta.resolve('./worker.ts'), poolSize: 4 }
48+
// })
49+
4350
// Start server on port 8000
4451
await router.serve(8000)
4552
```

benchmark/README.md

Lines changed: 54 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,18 @@
77

88
## How to Run
99

10-
**1. Start the server** (from repo root):
10+
**1. Start the server** (from repo root). Pick one:
1111

12-
```bash
13-
deno task bench
14-
```
12+
- **Without worker**`/test` and `/test-cpu` only; `/test-worker` returns 503:
1513

16-
Or:
14+
```bash
15+
deno run --allow-net --allow-read benchmark/main.ts
16+
```
1717

18-
```bash
19-
deno run --allow-net --allow-read benchmark/main.ts
20-
```
18+
- **With worker** — all routes including `/test-worker`:
19+
```bash
20+
deno run --allow-net --allow-read benchmark/main-worker.ts
21+
```
2122

2223
**2. Run autocannon** (in another terminal; requires Node/npx):
2324

@@ -27,95 +28,59 @@ npx autocannon http://localhost:8000/test -c 500 -p 10 -d 30
2728

2829
## Files
2930

30-
- **`benchmark/main.ts`** — Router with `routesDir: 'benchmark/routes'`, serves on port 8000.
31-
- **`benchmark/routes/test.ts`** — GET handler returning `{ hello: 'world!' }` via `ctx.send.json()`.
31+
- **`benchmark/main.ts`** — Router without worker. Serves `/test`, `/test-cpu`.
32+
- **`benchmark/main-worker.ts`** — Router with worker pool (inline CPU loop).
33+
- **`benchmark/routes/test.ts`** — GET `/test`: baseline, JSON only (no CPU work).
34+
- **`benchmark/routes/test-cpu.ts`** — GET `/test-cpu`: same CPU work (50k sqrt loop) on **main thread**.
35+
- **`benchmark/routes/test-worker.ts`** — GET `/test-worker`: same CPU work offloaded to **worker**.
3236

33-
## Test Behavior
37+
## Routes for comparison
3438

35-
- **Method:** GET
36-
- **Route:** `/test`
37-
- **Response:** `{ "hello": "world!" }` (JSON)
38-
- **File-based routing:** `benchmark/routes/test.ts` → pattern `/test`
39-
- **API:** `Context` + `ctx.send.json()`
39+
| Route | Description | Use case |
40+
| -------------- | ---------------------------- | -------------------- |
41+
| `/test` | JSON only, no CPU | Baseline throughput |
42+
| `/test-cpu` | 50k sqrt loop on main thread | Main-thread CPU cost |
43+
| `/test-worker` | Same loop in worker pool | Worker offload cost |
4044

41-
## Previous Benchmark Results
45+
Run autocannon against each route (in another terminal):
4246

43-
Below: autocannon runs with the older API (MacBook Pro M3 Pro, 11 cores, 18GB RAM). Re-run with the steps above to get numbers for the current codebase.
47+
```bash
48+
# Baseline (no CPU)
49+
npx autocannon http://localhost:8000/test -c 500 -p 10 -d 30
4450

45-
### Test Run 1
51+
# CPU on main thread (blocks event loop)
52+
npx autocannon http://localhost:8000/test-cpu -c 500 -p 10 -d 30
4653

47-
```
48-
Running 30s test @ http://localhost:8000/test
49-
500 connections with 10 pipelining factor
50-
51-
52-
┌─────────┬───────┬───────┬───────┬───────┬──────────┬─────────┬────────┐
53-
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
54-
├─────────┼───────┼───────┼───────┼───────┼──────────┼─────────┼────────┤
55-
│ Latency │ 13 ms │ 24 ms │ 33 ms │ 42 ms │ 24.77 ms │ 5.78 ms │ 138 ms │
56-
└─────────┴───────┴───────┴───────┴───────┴──────────┴─────────┴────────┘
57-
┌───────────┬─────────┬─────────┬─────────┬─────────┬────────────┬───────────┬─────────┐
58-
│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │
59-
├───────────┼─────────┼─────────┼─────────┼─────────┼────────────┼───────────┼─────────┤
60-
│ Req/Sec │ 166,527 │ 166,527 │ 203,519 │ 208,127 │ 199,249.07 │ 10,504.62 │ 166,415 │
61-
├───────────┼─────────┼─────────┼─────────┼─────────┼────────────┼───────────┼─────────┤
62-
│ Bytes/Sec │ 24.8 MB │ 24.8 MB │ 30.3 MB │ 31 MB │ 29.7 MB │ 1.56 MB │ 24.8 MB │
63-
└───────────┴─────────┴─────────┴─────────┴─────────┴────────────┴───────────┴─────────┘
64-
65-
Req/Bytes counts sampled once per second.
66-
# of samples: 30
67-
68-
5983k requests in 30.23s, 891 MB read
54+
# CPU in worker (non-blocking)
55+
npx autocannon http://localhost:8000/test-worker -c 500 -p 10 -d 30
6956
```
7057

71-
### Test Run 2
58+
## Latest benchmark results (30s, 500 conn, pipelining 10)
7259

73-
```
74-
Running 30s test @ http://localhost:8000/test
75-
500 connections with 10 pipelining factor
76-
77-
78-
┌─────────┬───────┬───────┬───────┬───────┬──────────┬─────────┬────────┐
79-
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
80-
├─────────┼───────┼───────┼───────┼───────┼──────────┼─────────┼────────┤
81-
│ Latency │ 18 ms │ 23 ms │ 38 ms │ 42 ms │ 24.92 ms │ 6.56 ms │ 158 ms │
82-
└─────────┴───────┴───────┴───────┴───────┴──────────┴─────────┴────────┘
83-
┌───────────┬─────────┬─────────┬─────────┬─────────┬────────────┬───────────┬─────────┐
84-
│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │
85-
├───────────┼─────────┼─────────┼─────────┼─────────┼────────────┼───────────┼─────────┤
86-
│ Req/Sec │ 162,431 │ 162,431 │ 200,063 │ 208,127 │ 197,789.87 │ 10,004.33 │ 162,409 │
87-
├───────────┼─────────┼─────────┼─────────┼─────────┼────────────┼───────────┼─────────┤
88-
│ Bytes/Sec │ 24.2 MB │ 24.2 MB │ 29.8 MB │ 31 MB │ 29.5 MB │ 1.49 MB │ 24.2 MB │
89-
└───────────┴─────────┴─────────┴─────────┴─────────┴────────────┴───────────┴─────────┘
90-
91-
Req/Bytes counts sampled once per second.
92-
# of samples: 30
93-
94-
5939k requests in 30.2s, 884 MB read
95-
```
60+
**Non-worker**`main.ts`:
9661

97-
### Test Run 3
62+
| Route | Test 1 | Test 2 | Test 3 | Req/Sec (avg) | Latency (avg) | Total (avg) |
63+
| -------------- | ------- | ------- | ------- | ------------- | ------------- | ----------- |
64+
| `/test` | 175,343 | 170,567 | 164,515 | 170,142 | 29 ms | 5109k |
65+
| `/test-cpu` | 25,009 | 24,949 | 24,927 | 24,962 | 199 ms | 754k |
66+
| `/test-worker` | 150,151 | 148,683 | 148,061 | 148,965 | 33 ms | 4474k |
9867

99-
```
100-
Running 30s test @ http://localhost:8000/test
101-
500 connections with 10 pipelining factor
102-
103-
104-
┌─────────┬───────┬───────┬───────┬───────┬──────────┬─────────┬────────┐
105-
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
106-
├─────────┼───────┼───────┼───────┼───────┼──────────┼─────────┼────────┤
107-
│ Latency │ 18 ms │ 23 ms │ 37 ms │ 40 ms │ 24.35 ms │ 5.94 ms │ 160 ms │
108-
└─────────┴───────┴───────┴───────┴───────┴──────────┴─────────┴────────┘
109-
┌───────────┬─────────┬─────────┬─────────┬─────────┬────────────┬──────────┬─────────┐
110-
│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │
111-
├───────────┼─────────┼─────────┼─────────┼─────────┼────────────┼──────────┼─────────┤
112-
│ Req/Sec │ 184,191 │ 184,191 │ 203,391 │ 211,199 │ 202,628.27 │ 5,672.56 │ 184,142 │
113-
├───────────┼─────────┼─────────┼─────────┼─────────┼────────────┼──────────┼─────────┤
114-
│ Bytes/Sec │ 27.4 MB │ 27.4 MB │ 30.3 MB │ 31.5 MB │ 30.2 MB │ 846 kB │ 27.4 MB │
115-
└───────────┴─────────┴─────────┴─────────┴─────────┴────────────┴──────────┴─────────┘
116-
117-
Req/Bytes counts sampled once per second.
118-
# of samples: 30
119-
120-
6083k requests in 30.24s, 906 MB read
121-
```
68+
`/test-worker` without worker returns 503 (no pool).
69+
70+
**With worker**`main-worker.ts` (worker pool, poolSize 4).
71+
72+
| Route | Test 1 | Test 2 | Test 3 | Req/Sec (avg) | Latency (avg) | Total (avg) |
73+
| -------------- | ------- | ------- | ------- | ------------- | ------------- | ----------- |
74+
| `/test` | 174,259 | 178,487 | 170,834 | 174,527 | 28 ms | 5.24M |
75+
| `/test-cpu` | 25,133 | 24,833 | 24,773 | 24,912 | 199 ms | 752k |
76+
| `/test-worker` | 69,265 | 69,063 | 68,810 | 69,046 | 72 ms | 2076k |
77+
78+
**Conclusion (test-cpu vs test-worker).** Both routes run the same CPU-bound workload (50k sqrt loop). `/test-cpu` runs it on the main thread and blocks the event loop (~25k req/s, ~199 ms). `/test-worker` offloads the work to a worker pool (~69k req/s, ~72 ms). For CPU-bound tasks, using the worker yields roughly 2.8× higher throughput and ~2.8× lower latency because the main thread stays free to accept and dispatch requests.
79+
80+
## Test Behavior (baseline)
81+
82+
- **Method:** GET
83+
- **Route:** `/test`
84+
- **Response:** `{ "hello": "world!" }` (JSON)
85+
- **File-based routing:** `benchmark/routes/test.ts` → pattern `/test`
86+
- **API:** `Context` + `ctx.send.json()`

benchmark/main-worker.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Router } from '@app/index.ts'
2+
3+
const workerCode = `
4+
const defaultIterations = 50000
5+
self.onmessage = (e) => {
6+
const data = e.data || {}
7+
const n = Math.max(0, Number(data.iterations) || defaultIterations)
8+
let value = 0
9+
for (let i = 0; i < n; i++) value += Math.sqrt(i)
10+
self.postMessage({ done: true, value })
11+
}
12+
export {}
13+
`
14+
15+
const workerScriptUrl = URL.createObjectURL(
16+
new Blob([workerCode], { type: 'application/javascript' })
17+
)
18+
19+
const router = new Router({
20+
routesDir: 'benchmark/routes',
21+
worker: { scriptURL: workerScriptUrl, poolSize: 4 }
22+
})
23+
24+
await router.serve(8000)

benchmark/routes/test-cpu.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Context } from '@app/index.ts'
2+
3+
export function GET(_ctx: Context) {
4+
let value = 0
5+
for (let i = 0; i < 50_000; i++) {
6+
value += Math.sqrt(i)
7+
}
8+
return _ctx.send.json({ hello: 'cpu', value })
9+
}

benchmark/routes/test-worker.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Context } from '@app/index.ts'
2+
3+
export async function GET(ctx: Context) {
4+
const worker = ctx.state['worker'] as { run: <T>(p: unknown) => Promise<T> } | undefined
5+
if (!worker?.run) {
6+
return ctx.send.json({ error: 'worker not enabled' }, { status: 503 })
7+
}
8+
const result = await worker.run<{ done: boolean; value: number }>({ iterations: 50_000 })
9+
return ctx.send.json({ hello: 'worker', value: result?.value })
10+
}

deno.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
"lock": true,
5959
"nodeModulesDir": "auto",
6060
"tasks": {
61-
"bench": "deno run --allow-net --allow-read benchmark/main.ts",
6261
"check": "deno fmt src/ && deno lint src/ && deno check src/",
6362
"test": "deno test tests/ --allow-read"
6463
},

docs/.vitepress/config.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ export default withMermaid(
7676
{ text: 'File-based Routing', link: '/en/core-concepts/file-based-routing' },
7777
{ text: 'Route Patterns', link: '/en/core-concepts/route-patterns' },
7878
{ text: 'Context Object', link: '/en/core-concepts/context-object' },
79-
{ text: 'Request Handling', link: '/en/core-concepts/request-handling' }
79+
{ text: 'Request Handling', link: '/en/core-concepts/request-handling' },
80+
{ text: 'Worker Pool', link: '/en/core-concepts/worker-pool' }
8081
]
8182
},
8283
{
@@ -149,7 +150,8 @@ export default withMermaid(
149150
{ text: 'File-based Routing', link: '/en/core-concepts/file-based-routing' },
150151
{ text: 'Route Patterns', link: '/en/core-concepts/route-patterns' },
151152
{ text: 'Context Object', link: '/en/core-concepts/context-object' },
152-
{ text: 'Request Handling', link: '/en/core-concepts/request-handling' }
153+
{ text: 'Request Handling', link: '/en/core-concepts/request-handling' },
154+
{ text: 'Worker Pool', link: '/en/core-concepts/worker-pool' }
153155
]
154156
},
155157
{
@@ -274,7 +276,8 @@ export default withMermaid(
274276
},
275277
{ text: 'Pola Rute', link: '/id/core-concepts/route-patterns' },
276278
{ text: 'Objek Konteks', link: '/id/core-concepts/context-object' },
277-
{ text: 'Penanganan Request', link: '/id/core-concepts/request-handling' }
279+
{ text: 'Penanganan Request', link: '/id/core-concepts/request-handling' },
280+
{ text: 'Worker Pool', link: '/id/core-concepts/worker-pool' }
278281
]
279282
},
280283
{

0 commit comments

Comments
 (0)