fastify-ata 0.9.2 → 0.9.4
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/.github/dependabot.yml +18 -0
- package/README.md +67 -11
- package/bench-realtime.js +131 -0
- package/bench-realwork.js +59 -0
- package/bench-standalone-vs.js +30 -5
- package/bench-startup.js +79 -29
- package/bench-turbo.js +118 -0
- package/compat/COMPATIBILITY.md +2 -2
- package/package.json +2 -2
- package/profile-fastify.mjs +87 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
# ata-validator moves faster than this package does. A bump PR lands here on
|
|
4
|
+
# its own so the pin never silently falls behind what is published.
|
|
5
|
+
- package-ecosystem: "npm"
|
|
6
|
+
directory: "/"
|
|
7
|
+
schedule:
|
|
8
|
+
interval: "daily"
|
|
9
|
+
open-pull-requests-limit: 5
|
|
10
|
+
commit-message:
|
|
11
|
+
prefix: "chore"
|
|
12
|
+
|
|
13
|
+
- package-ecosystem: "github-actions"
|
|
14
|
+
directory: "/"
|
|
15
|
+
schedule:
|
|
16
|
+
interval: "weekly"
|
|
17
|
+
commit-message:
|
|
18
|
+
prefix: "chore"
|
package/README.md
CHANGED
|
@@ -227,26 +227,82 @@ Works with Fastify v5's Standard Schema support, tRPC, TanStack Form, Drizzle OR
|
|
|
227
227
|
|
|
228
228
|
All numbers below are reproducible on M4 Pro / Node 25 with the benchmarks in this repo and in `ata-validator/benchmark`. Run-to-run noise is roughly +/- 5% at these scales.
|
|
229
229
|
|
|
230
|
-
###
|
|
230
|
+
### Throughput: not a reason to switch
|
|
231
231
|
|
|
232
|
-
|
|
232
|
+
`bench-realtime.js` reports about 15% more requests per second than ajv on a
|
|
233
|
+
50-route app whose handlers do nothing. That number is reproducible and it is also
|
|
234
|
+
misleading, so here is the whole picture. Sweeping the route count, echo handlers,
|
|
235
|
+
50 connections:
|
|
236
|
+
|
|
237
|
+
| Routes | ajv req/s | ata req/s | delta |
|
|
238
|
+
|---|---|---|---|
|
|
239
|
+
| 1 | 75,053 | 75,360 | +0.4% |
|
|
240
|
+
| 10 | 64,333 | 74,259 | +15.4% |
|
|
241
|
+
| 50 | 64,554 | 74,643 | +15.6% |
|
|
242
|
+
| 200 | 66,486 | 65,491 | -1.5% |
|
|
243
|
+
|
|
244
|
+
The gap is not a property of the validator, it is a dispatch effect that appears
|
|
245
|
+
once there are enough distinct compiled validators to spoil V8's call-site caching
|
|
246
|
+
and disappears again once there are many. Give the handler real work and it goes
|
|
247
|
+
away entirely, one route, 50 to 200 connections:
|
|
248
|
+
|
|
249
|
+
| Per-request work | ajv req/s | ata req/s | delta |
|
|
233
250
|
|---|---|---|---|
|
|
234
|
-
|
|
|
235
|
-
|
|
|
236
|
-
|
|
|
251
|
+
| none | 75,142 | 74,989 | -0.2% |
|
|
252
|
+
| 1 ms | 70,342 | 71,418 | +1.5% |
|
|
253
|
+
| 5 ms | 33,040 | 33,016 | -0.1% |
|
|
254
|
+
| 20 ms | 9,310 | 9,236 | -0.8% |
|
|
237
255
|
|
|
238
|
-
|
|
256
|
+
`profile-fastify.mjs` explains why: in one request `JSON.parse` is 92% of the cost
|
|
257
|
+
and validation 7%. If your handler touches a database, the validator is not what
|
|
258
|
+
decides your throughput. Switch for the boot numbers below, or for the errors,
|
|
259
|
+
types and portability. Do not switch for requests per second.
|
|
239
260
|
|
|
240
261
|
### Where ata-validator moves the needle
|
|
241
262
|
|
|
263
|
+
Schema compilation at boot, from `bench-startup.js`. Each figure is the median of
|
|
264
|
+
nine process-isolated runs with a no-route Fastify baseline subtracted, so what is
|
|
265
|
+
left is the cost of compiling the route schemas and nothing else.
|
|
266
|
+
|
|
267
|
+
| Routes | ajv | ata | delta |
|
|
268
|
+
|---|---|---|---|
|
|
269
|
+
| 50 | 41.6 ms | 1.4 ms | **30x faster** |
|
|
270
|
+
| 100 | 70.0 ms | 4.2 ms | **17x faster** |
|
|
271
|
+
| 250 | 142.7 ms | 11.4 ms | **13x faster** |
|
|
272
|
+
| 500 | 263.0 ms | 18.8 ms | **14x faster** |
|
|
273
|
+
| 1000 | 548.4 ms | 41.2 ms | **13x faster** |
|
|
274
|
+
|
|
275
|
+
Total boot time, baseline included, is 83.6 ms against 583.9 ms at 1000 routes. Below
|
|
276
|
+
about 20 routes the difference is under measurement noise and not worth quoting.
|
|
277
|
+
|
|
278
|
+
### Against the standalone build step (`bench-standalone-vs.js`)
|
|
279
|
+
|
|
280
|
+
Fastify's documented route to the fastest startup is
|
|
281
|
+
[`@fastify/ajv-compiler` in standalone mode](https://backend.cafe/how-to-unlock-the-fastest-fastify-server-startup):
|
|
282
|
+
compile every route schema to a file at build time, load the files at boot. The
|
|
283
|
+
table is process start to `app.ready()`, five schema types reused across routes,
|
|
284
|
+
median of three runs.
|
|
285
|
+
|
|
286
|
+
| Routes | ajv default | ajv standalone | ata, no build step | ata precompiled |
|
|
287
|
+
|---|---|---|---|---|
|
|
288
|
+
| 50 | 62 ms | 40 ms | 43 ms | 37 ms |
|
|
289
|
+
| 100 | 77 ms | 45 ms | 47 ms | 40 ms |
|
|
290
|
+
| 200 | 111 ms | 55 ms | 50 ms | 41 ms |
|
|
291
|
+
| 500 | 182 ms | 81 ms | **59 ms** | 46 ms |
|
|
292
|
+
|
|
293
|
+
Past about 200 routes, installing ata and doing nothing else boots faster than ajv
|
|
294
|
+
with the build step in place: 59 ms against 81 ms at 500 routes. Precompiling with
|
|
295
|
+
`fastify-ata/standalone` takes another 20% off, but it is an optimization rather
|
|
296
|
+
than the thing that makes the difference. Under 100 routes the four are close
|
|
297
|
+
enough that boot time should not decide anything.
|
|
298
|
+
|
|
242
299
|
| Scenario | ajv | ata | delta |
|
|
243
300
|
|---|---|---|---|
|
|
244
|
-
| **Serverless cold start** (10 routes, first request) | 12.4 ms | 0.5 ms | **24x faster** |
|
|
245
|
-
| **Startup** (200 routes) | 7.0 ms | 2.4 ms | **2.9x faster** |
|
|
246
|
-
| **Invalid validation** (with abortEarly) | ~15 ns/op | 3.7 ns/op | **4x faster** |
|
|
247
301
|
| **ReDoS pattern** `^(a+)+$` | 765 ms | 0.3 ms | **immune (RE2)** |
|
|
248
302
|
|
|
249
|
-
|
|
303
|
+
Boot cost is the scenario that matters for Vercel, Fly.io and similar platforms, where
|
|
304
|
+
a process starts far more often than a long-running box does. On a box that stays up,
|
|
305
|
+
the gap is paid once.
|
|
250
306
|
|
|
251
307
|
### Build-time compile (optional)
|
|
252
308
|
|
|
@@ -273,7 +329,7 @@ Generated file has zero runtime dependency on `ata-validator`. `isValid` is emit
|
|
|
273
329
|
- **Multi-core** - `countValid(ndjsonBuf)` validates many messages in one native call
|
|
274
330
|
- **Standard Schema V1** - native support, works with Fastify v5, tRPC, TanStack Form, Drizzle
|
|
275
331
|
- **Draft 2020-12 and Draft 7** - every applicable draft 2020-12 case in the official JSON Schema Test Suite passes with the native engine installed (1190/1190); 99.8% pure JS
|
|
276
|
-
- **Fastify's own suite** -
|
|
332
|
+
- **Fastify's own suite** - 178 of 184 tests pass with ata as the default validator; the remaining six test the default validator's private extension API rather than validation behaviour. See [compat/COMPATIBILITY.md](compat/COMPATIBILITY.md)
|
|
277
333
|
|
|
278
334
|
## License
|
|
279
335
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Real-time Fastify simulation: actual HTTP requests with autocannon
|
|
4
|
+
// Shows: startup time + request throughput + latency
|
|
5
|
+
|
|
6
|
+
const { writeFileSync, unlinkSync } = require('fs')
|
|
7
|
+
const { fork, execSync } = require('child_process')
|
|
8
|
+
const path = require('path')
|
|
9
|
+
|
|
10
|
+
const ROUTES = 50
|
|
11
|
+
const DURATION = 5 // seconds
|
|
12
|
+
const CONNECTIONS = 50
|
|
13
|
+
|
|
14
|
+
function makeRoutes(count) {
|
|
15
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
16
|
+
path: `/api/v1/resource${i}`,
|
|
17
|
+
schema: {
|
|
18
|
+
body: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
id: { type: 'integer', minimum: 1 },
|
|
22
|
+
name: { type: 'string', minLength: 1, maxLength: 100 },
|
|
23
|
+
email: { type: 'string', format: 'email' },
|
|
24
|
+
age: { type: 'integer', minimum: 0, maximum: 150 },
|
|
25
|
+
active: { type: 'boolean' },
|
|
26
|
+
role: { enum: ['admin', 'user', 'moderator'] },
|
|
27
|
+
},
|
|
28
|
+
required: ['id', 'name', 'email', 'active', 'role'],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const routes = makeRoutes(ROUTES)
|
|
35
|
+
const payload = JSON.stringify({
|
|
36
|
+
id: 42, name: 'Mert', email: 'mert@example.com',
|
|
37
|
+
age: 26, active: true, role: 'admin'
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function serverScript(useAta) {
|
|
41
|
+
return `
|
|
42
|
+
'use strict'
|
|
43
|
+
const t0 = process.hrtime.bigint()
|
|
44
|
+
const fastify = require('fastify')()
|
|
45
|
+
${useAta ? "fastify.register(require('./index'))" : ''}
|
|
46
|
+
const routes = ${JSON.stringify(routes)}
|
|
47
|
+
for (const r of routes) {
|
|
48
|
+
fastify.post(r.path, { schema: r.schema }, (req, reply) => {
|
|
49
|
+
reply.send({ ok: true, id: req.body.id })
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
fastify.listen({ port: 0 }).then(() => {
|
|
53
|
+
const startupMs = Number(process.hrtime.bigint() - t0) / 1e6
|
|
54
|
+
const port = fastify.server.address().port
|
|
55
|
+
process.send({ port, startupMs })
|
|
56
|
+
})
|
|
57
|
+
`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function runTest(label, useAta) {
|
|
61
|
+
const scriptPath = path.join(__dirname, '_realtime_server.js')
|
|
62
|
+
writeFileSync(scriptPath, serverScript(useAta))
|
|
63
|
+
|
|
64
|
+
const child = fork(scriptPath, { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] })
|
|
65
|
+
const { port, startupMs } = await new Promise(resolve => {
|
|
66
|
+
child.on('message', msg => resolve(msg))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Pick a random route for each request
|
|
70
|
+
const targetRoute = routes[Math.floor(Math.random() * routes.length)].path
|
|
71
|
+
const escaped = payload.replace(/'/g, "'\\''")
|
|
72
|
+
|
|
73
|
+
let result
|
|
74
|
+
try {
|
|
75
|
+
const raw = execSync(
|
|
76
|
+
`npx autocannon -c ${CONNECTIONS} -d ${DURATION} -j http://localhost:${port}${targetRoute} -m POST -H "content-type: application/json" -b '${escaped}'`,
|
|
77
|
+
{ cwd: __dirname, timeout: 30000 }
|
|
78
|
+
).toString()
|
|
79
|
+
result = JSON.parse(raw)
|
|
80
|
+
} catch (e) {
|
|
81
|
+
child.kill()
|
|
82
|
+
try { unlinkSync(scriptPath) } catch {}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
child.kill()
|
|
87
|
+
try { unlinkSync(scriptPath) } catch {}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
label,
|
|
91
|
+
startupMs,
|
|
92
|
+
reqPerSec: result.requests.average,
|
|
93
|
+
latencyAvg: result.latency.average,
|
|
94
|
+
latencyP99: result.latency.p99,
|
|
95
|
+
throughputMB: (result.throughput.average / 1024 / 1024).toFixed(1),
|
|
96
|
+
errors: result.errors,
|
|
97
|
+
timeouts: result.timeouts,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function main() {
|
|
102
|
+
console.log('\n' + '='.repeat(60))
|
|
103
|
+
console.log(' Real-Time Fastify Simulation')
|
|
104
|
+
console.log(` ${ROUTES} routes, ${CONNECTIONS} connections, ${DURATION}s duration`)
|
|
105
|
+
console.log(' POST with 6-field validated body')
|
|
106
|
+
console.log('='.repeat(60) + '\n')
|
|
107
|
+
|
|
108
|
+
const ata = await runTest('fastify-ata', true)
|
|
109
|
+
const ajv = await runTest('fastify (ajv)', false)
|
|
110
|
+
|
|
111
|
+
if (!ata || !ajv) { console.log('Benchmark failed'); return }
|
|
112
|
+
|
|
113
|
+
console.log(' fastify-ata fastify (ajv)')
|
|
114
|
+
console.log(' ─────────────────────────────────────────────────')
|
|
115
|
+
console.log(` Startup ${ata.startupMs.toFixed(1).padStart(8)} ms ${ajv.startupMs.toFixed(1).padStart(8)} ms`)
|
|
116
|
+
console.log(` Requests/sec ${ata.reqPerSec.toLocaleString().padStart(8)} ${ajv.reqPerSec.toLocaleString().padStart(8)}`)
|
|
117
|
+
console.log(` Latency (avg) ${ata.latencyAvg.toFixed(2).padStart(8)} ms ${ajv.latencyAvg.toFixed(2).padStart(8)} ms`)
|
|
118
|
+
console.log(` Latency (p99) ${ata.latencyP99.toFixed(2).padStart(8)} ms ${ajv.latencyP99.toFixed(2).padStart(8)} ms`)
|
|
119
|
+
console.log(` Throughput ${ata.throughputMB.padStart(8)} MB/s ${ajv.throughputMB.padStart(8)} MB/s`)
|
|
120
|
+
console.log(` Errors ${String(ata.errors).padStart(8)} ${String(ajv.errors).padStart(8)}`)
|
|
121
|
+
console.log(` Timeouts ${String(ata.timeouts).padStart(8)} ${String(ajv.timeouts).padStart(8)}`)
|
|
122
|
+
console.log()
|
|
123
|
+
|
|
124
|
+
const startupRatio = ajv.startupMs / ata.startupMs
|
|
125
|
+
const rpsRatio = ata.reqPerSec / ajv.reqPerSec
|
|
126
|
+
console.log(` Startup: ata ${startupRatio.toFixed(1)}x faster`)
|
|
127
|
+
console.log(` Throughput: ata ${rpsRatio.toFixed(2)}x ${rpsRatio >= 1 ? 'faster' : 'slower'}`)
|
|
128
|
+
console.log()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
main().catch(err => { console.error(err); process.exit(1) })
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// How much of the ata-vs-ajv throughput difference survives once a route does
|
|
3
|
+
// real work? Echo handlers make the validator look decisive. A handler that
|
|
4
|
+
// awaits anything at all does not. This is the benchmark behind the second
|
|
5
|
+
// table in the README's throughput section.
|
|
6
|
+
const { fork } = require('child_process')
|
|
7
|
+
const { writeFileSync, unlinkSync } = require('fs')
|
|
8
|
+
const path = require('path')
|
|
9
|
+
const autocannon = require('autocannon')
|
|
10
|
+
|
|
11
|
+
const schema = { type:'object', properties:{
|
|
12
|
+
id:{type:'integer'}, name:{type:'string'}, email:{type:'string'},
|
|
13
|
+
age:{type:'integer',minimum:0}, active:{type:'boolean'}, role:{type:'string',enum:['admin','user']} },
|
|
14
|
+
required:['id','name','email','age','active','role'] }
|
|
15
|
+
|
|
16
|
+
function server(useAta, workMs, port) {
|
|
17
|
+
return `
|
|
18
|
+
'use strict'
|
|
19
|
+
const fastify = require('fastify')()
|
|
20
|
+
${useAta ? `fastify.register(require(${JSON.stringify(path.resolve(__dirname,'index.js'))}))` : ''}
|
|
21
|
+
const schema = ${JSON.stringify(schema)}
|
|
22
|
+
const WORK = ${workMs}
|
|
23
|
+
fastify.post('/u', { schema: { body: schema } }, async (req, reply) => {
|
|
24
|
+
if (WORK > 0) await new Promise(r => setTimeout(r, WORK))
|
|
25
|
+
return { ok: true }
|
|
26
|
+
})
|
|
27
|
+
fastify.listen({ port: ${port} }).then(() => process.send({ up: true }))
|
|
28
|
+
`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function start(src) {
|
|
32
|
+
return new Promise((res) => {
|
|
33
|
+
const f = path.join(__dirname, '_srv_' + Math.random().toString(36).slice(2) + '.js')
|
|
34
|
+
writeFileSync(f, src)
|
|
35
|
+
const child = fork(f, { silent: true })
|
|
36
|
+
child.on('message', () => res({ child, f }))
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function bench(port, connections) {
|
|
41
|
+
const r = await autocannon({ url: 'http://127.0.0.1:' + port + '/u', method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ id: 1, name: 'Ada', email: 'a@e.com', age: 36, active: true, role: 'admin' }),
|
|
44
|
+
connections, duration: 5 })
|
|
45
|
+
return r.requests.average
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
;(async () => {
|
|
49
|
+
console.log('per-request work ajv req/s ata req/s delta')
|
|
50
|
+
let port = 3310
|
|
51
|
+
for (const workMs of [0, 1, 5, 20]) {
|
|
52
|
+
const conns = workMs === 0 ? 50 : 200
|
|
53
|
+
const a = await start(server(false, workMs, port)); const ajv = await bench(port, conns); a.child.kill(); unlinkSync(a.f); port++
|
|
54
|
+
const b = await start(server(true, workMs, port)); const ata = await bench(port, conns); b.child.kill(); unlinkSync(b.f); port++
|
|
55
|
+
const label = workMs === 0 ? 'none (echo)' : workMs + ' ms async'
|
|
56
|
+
console.log(label.padEnd(18), String(Math.round(ajv)).padStart(9), String(Math.round(ata)).padStart(12),
|
|
57
|
+
(' ' + (((ata - ajv) / ajv) * 100).toFixed(1) + '%').padStart(8))
|
|
58
|
+
}
|
|
59
|
+
})()
|
package/bench-standalone-vs.js
CHANGED
|
@@ -114,6 +114,23 @@ app.ready().then(() => {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
// ============================================================
|
|
117
|
+
// 0. ata plain: just register the plugin, no build step, no precompile
|
|
118
|
+
// ============================================================
|
|
119
|
+
function ataPlainScript(routes) {
|
|
120
|
+
return `
|
|
121
|
+
'use strict'
|
|
122
|
+
const start = performance.now()
|
|
123
|
+
const fastify = require('fastify')()
|
|
124
|
+
fastify.register(require(${JSON.stringify(path.resolve(__dirname, 'index.js'))}))
|
|
125
|
+
const routes = ${JSON.stringify(routes)}
|
|
126
|
+
routes.forEach(r => { fastify.post(r.url, { schema: { body: r.schema } }, (req, reply) => reply.send({ ok: true })) })
|
|
127
|
+
fastify.ready().then(() => {
|
|
128
|
+
process.send({ ms: performance.now() - start })
|
|
129
|
+
process.exit()
|
|
130
|
+
})
|
|
131
|
+
`
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
// 3. ata compact standalone
|
|
118
135
|
// ============================================================
|
|
119
136
|
function ataStandaloneScript(routes, bundlePath) {
|
|
@@ -205,13 +222,21 @@ async function main() {
|
|
|
205
222
|
const ataStandalone = times3[1]
|
|
206
223
|
try { unlinkSync(bundlePath) } catch {}
|
|
207
224
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
225
|
+
// 0b. ata plain, no build step at all
|
|
226
|
+
const times0 = []
|
|
227
|
+
for (let r = 0; r < 3; r++) times0.push(await runScript(ataPlainScript(routes)))
|
|
228
|
+
times0.sort((a, b) => a - b)
|
|
229
|
+
const ataPlain = times0[1]
|
|
230
|
+
|
|
231
|
+
console.log(` ajv default: ${ajvDefault.toFixed(0)}ms`)
|
|
232
|
+
console.log(` ajv standalone: ${ajvStandalone >= 0 ? ajvStandalone.toFixed(0) + 'ms' : 'FAILED'}`)
|
|
233
|
+
console.log(` ata plain: ${ataPlain.toFixed(0)}ms (no build step)`)
|
|
234
|
+
console.log(` ata compact: ${ataStandalone.toFixed(0)}ms (precompiled bundle)`)
|
|
211
235
|
if (ajvStandalone > 0) {
|
|
212
|
-
console.log(` ata vs ajv standalone:
|
|
236
|
+
console.log(` ata plain vs ajv standalone: ${(ajvStandalone / ataPlain).toFixed(1)}x faster`)
|
|
237
|
+
console.log(` ata compact vs ajv standalone: ${(ajvStandalone / ataStandalone).toFixed(1)}x faster`)
|
|
213
238
|
}
|
|
214
|
-
console.log(` ata vs ajv default:
|
|
239
|
+
console.log(` ata plain vs ajv default: ${(ajvDefault / ataPlain).toFixed(1)}x faster`)
|
|
215
240
|
console.log()
|
|
216
241
|
}
|
|
217
242
|
}
|
package/bench-startup.js
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Fastify startup cost, split into the part that is Fastify booting and the
|
|
4
|
+
// part that is compiling route schemas.
|
|
5
|
+
//
|
|
6
|
+
// Total boot time hides the interesting number: a bare Fastify instance costs
|
|
7
|
+
// tens of milliseconds before any schema is involved, so comparing totals
|
|
8
|
+
// understates the difference between validators. Subtracting a no-schema
|
|
9
|
+
// baseline leaves the schema compilation cost on its own.
|
|
10
|
+
//
|
|
11
|
+
// TypeBox is deliberately not in the comparison. It builds schemas, it does not
|
|
12
|
+
// compile them; under Fastify a TypeBox schema is still compiled by whichever
|
|
13
|
+
// validator is installed, so it has no separate compile cost to measure.
|
|
5
14
|
|
|
6
15
|
const { writeFileSync, unlinkSync } = require('fs')
|
|
16
|
+
const { fork } = require('child_process')
|
|
7
17
|
const path = require('path')
|
|
8
18
|
|
|
19
|
+
const ROUTE_COUNTS = [0, 10, 50, 100, 250, 500, 1000]
|
|
20
|
+
const RUNS = 9
|
|
21
|
+
|
|
9
22
|
function makeRouteSchemas(count) {
|
|
10
23
|
const routes = []
|
|
11
24
|
for (let i = 0; i < count; i++) {
|
|
@@ -38,63 +51,100 @@ function makeRouteSchemas(count) {
|
|
|
38
51
|
|
|
39
52
|
function serverScript(useAta, routeCount) {
|
|
40
53
|
const routes = makeRouteSchemas(routeCount)
|
|
54
|
+
const register = useAta
|
|
55
|
+
? `fastify.register(require(${JSON.stringify(path.join(__dirname, 'index.js'))}))`
|
|
56
|
+
: ''
|
|
41
57
|
return `
|
|
42
58
|
'use strict'
|
|
43
59
|
const t0 = process.hrtime.bigint()
|
|
44
60
|
const fastify = require('fastify')()
|
|
45
|
-
${
|
|
61
|
+
${register}
|
|
46
62
|
const routes = ${JSON.stringify(routes)}
|
|
47
63
|
for (const r of routes) {
|
|
48
|
-
fastify.post(r.path, { schema: r.schema }, (req, reply) => {
|
|
49
|
-
reply.send({ ok: true })
|
|
50
|
-
})
|
|
64
|
+
fastify.post(r.path, { schema: r.schema }, (req, reply) => reply.send({ ok: true }))
|
|
51
65
|
}
|
|
52
66
|
fastify.ready().then(() => {
|
|
53
|
-
|
|
54
|
-
process.send({ startupMs: dt })
|
|
67
|
+
process.send({ startupMs: Number(process.hrtime.bigint() - t0) / 1e6 })
|
|
55
68
|
process.exit(0)
|
|
56
69
|
})
|
|
57
70
|
`
|
|
58
71
|
}
|
|
59
72
|
|
|
60
|
-
async function
|
|
61
|
-
const scriptPath = path.join(
|
|
73
|
+
async function measure(useAta, routeCount, runs) {
|
|
74
|
+
const scriptPath = path.join(
|
|
75
|
+
__dirname,
|
|
76
|
+
`_startup_bench_${useAta ? 'ata' : 'ajv'}_${routeCount}.js`,
|
|
77
|
+
)
|
|
78
|
+
writeFileSync(scriptPath, serverScript(useAta, routeCount))
|
|
62
79
|
const times = []
|
|
63
80
|
|
|
64
81
|
for (let r = 0; r < runs; r++) {
|
|
65
|
-
writeFileSync(scriptPath, serverScript(useAta, routeCount))
|
|
66
|
-
const { fork } = require('child_process')
|
|
67
82
|
const child = fork(scriptPath, { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] })
|
|
68
83
|
const ms = await new Promise((resolve, reject) => {
|
|
69
|
-
child.on('message', msg => resolve(msg.startupMs))
|
|
84
|
+
child.on('message', (msg) => resolve(msg.startupMs))
|
|
70
85
|
child.on('error', reject)
|
|
71
|
-
setTimeout(() => {
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
child.kill()
|
|
88
|
+
reject(new Error('timeout'))
|
|
89
|
+
}, 30000)
|
|
72
90
|
})
|
|
73
91
|
times.push(ms)
|
|
74
92
|
}
|
|
75
93
|
|
|
76
|
-
try {
|
|
94
|
+
try {
|
|
95
|
+
unlinkSync(scriptPath)
|
|
96
|
+
} catch {}
|
|
77
97
|
times.sort((a, b) => a - b)
|
|
78
|
-
return
|
|
98
|
+
return {
|
|
99
|
+
median: times[Math.floor(times.length / 2)],
|
|
100
|
+
min: times[0],
|
|
101
|
+
max: times[times.length - 1],
|
|
102
|
+
}
|
|
79
103
|
}
|
|
80
104
|
|
|
105
|
+
const fmt = (n) => n.toFixed(1).padStart(7)
|
|
106
|
+
|
|
81
107
|
async function main() {
|
|
82
|
-
console.log(
|
|
83
|
-
console.log(
|
|
84
|
-
|
|
85
|
-
|
|
108
|
+
console.log(`\nFastify startup, median of ${RUNS} process-isolated runs`)
|
|
109
|
+
console.log(`node ${process.version}\n`)
|
|
110
|
+
|
|
111
|
+
const baseline = { ata: null, ajv: null }
|
|
112
|
+
const rows = []
|
|
86
113
|
|
|
87
|
-
for (const count of
|
|
88
|
-
|
|
114
|
+
for (const count of ROUTE_COUNTS) {
|
|
115
|
+
const ata = await measure(true, count, RUNS)
|
|
116
|
+
const ajv = await measure(false, count, RUNS)
|
|
89
117
|
|
|
90
|
-
|
|
91
|
-
|
|
118
|
+
if (count === 0) {
|
|
119
|
+
baseline.ata = ata.median
|
|
120
|
+
baseline.ajv = ajv.median
|
|
121
|
+
console.log('Baseline, no routes and no schemas:')
|
|
122
|
+
console.log(` with ata plugin ${fmt(ata.median)} ms (${fmt(ata.min)} to ${fmt(ata.max)})`)
|
|
123
|
+
console.log(` stock fastify ${fmt(ajv.median)} ms (${fmt(ajv.min)} to ${fmt(ajv.max)})`)
|
|
124
|
+
console.log('\nSchema compilation cost, baseline subtracted:\n')
|
|
125
|
+
console.log(' routes ata ajv ratio')
|
|
126
|
+
console.log(' ------------------------------------------')
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
92
129
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
console.log(
|
|
130
|
+
const ataCost = ata.median - baseline.ata
|
|
131
|
+
const ajvCost = ajv.median - baseline.ajv
|
|
132
|
+
rows.push({ count, ataCost, ajvCost, ataTotal: ata.median, ajvTotal: ajv.median })
|
|
133
|
+
console.log(
|
|
134
|
+
` ${String(count).padStart(5)} ${fmt(ataCost)} ms ${fmt(ajvCost)} ms ${(ajvCost / ataCost).toFixed(1)}x`,
|
|
135
|
+
)
|
|
97
136
|
}
|
|
137
|
+
|
|
138
|
+
console.log('\nTotal boot time for reference:\n')
|
|
139
|
+
console.log(' routes ata ajv')
|
|
140
|
+
console.log(' ------------------------------')
|
|
141
|
+
for (const r of rows) {
|
|
142
|
+
console.log(` ${String(r.count).padStart(5)} ${fmt(r.ataTotal)} ms ${fmt(r.ajvTotal)} ms`)
|
|
143
|
+
}
|
|
144
|
+
console.log()
|
|
98
145
|
}
|
|
99
146
|
|
|
100
|
-
main().catch(err => {
|
|
147
|
+
main().catch((err) => {
|
|
148
|
+
console.error(err)
|
|
149
|
+
process.exit(1)
|
|
150
|
+
})
|
package/bench-turbo.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process')
|
|
4
|
+
const { writeFileSync, unlinkSync } = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
|
|
7
|
+
const schema = {
|
|
8
|
+
body: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
users: {
|
|
12
|
+
type: 'array',
|
|
13
|
+
items: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
id: { type: 'integer', minimum: 1 },
|
|
17
|
+
name: { type: 'string', minLength: 1 },
|
|
18
|
+
email: { type: 'string', format: 'email' },
|
|
19
|
+
age: { type: 'integer', minimum: 0, maximum: 150 },
|
|
20
|
+
active: { type: 'boolean' },
|
|
21
|
+
role: { enum: ['admin', 'user', 'moderator'] },
|
|
22
|
+
},
|
|
23
|
+
required: ['id', 'name', 'email', 'active', 'role'],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
metadata: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
total: { type: 'integer' },
|
|
30
|
+
page: { type: 'integer', minimum: 1 },
|
|
31
|
+
},
|
|
32
|
+
required: ['total', 'page'],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: ['users', 'metadata'],
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makePayload(n) {
|
|
40
|
+
const users = []
|
|
41
|
+
for (let i = 0; i < n; i++) {
|
|
42
|
+
users.push({
|
|
43
|
+
id: i + 1,
|
|
44
|
+
name: `User ${i}`,
|
|
45
|
+
email: `user${i}@example.com`,
|
|
46
|
+
age: 25,
|
|
47
|
+
active: true,
|
|
48
|
+
role: 'user',
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
return JSON.stringify({ users, metadata: { total: n, page: 1 } })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function serverScript(turbo) {
|
|
55
|
+
return `
|
|
56
|
+
'use strict'
|
|
57
|
+
const fastify = require('fastify')()
|
|
58
|
+
fastify.register(require('./index'), { turbo: ${turbo} })
|
|
59
|
+
const schema = ${JSON.stringify(schema)}
|
|
60
|
+
fastify.post('/users', { schema }, (req, reply) => {
|
|
61
|
+
reply.send({ ok: true, count: req.body.users.length })
|
|
62
|
+
})
|
|
63
|
+
fastify.listen({ port: 0 }).then(() => {
|
|
64
|
+
process.send({ port: fastify.server.address().port })
|
|
65
|
+
})
|
|
66
|
+
`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function bench(label, turbo, payload) {
|
|
70
|
+
const scriptPath = path.join(__dirname, '_bench_turbo_server.js')
|
|
71
|
+
writeFileSync(scriptPath, serverScript(turbo))
|
|
72
|
+
|
|
73
|
+
const { fork } = require('child_process')
|
|
74
|
+
const child = fork(scriptPath, { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] })
|
|
75
|
+
const port = await new Promise((resolve) => {
|
|
76
|
+
child.on('message', (msg) => resolve(msg.port))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const escaped = payload.replace(/'/g, "'\\''")
|
|
80
|
+
const result = execSync(
|
|
81
|
+
`npx autocannon -c 10 -d 5 -j http://localhost:${port}/users -m POST -H "content-type: application/json" -b '${escaped}'`,
|
|
82
|
+
{ cwd: __dirname, timeout: 30000 },
|
|
83
|
+
).toString()
|
|
84
|
+
|
|
85
|
+
child.kill()
|
|
86
|
+
try { unlinkSync(scriptPath) } catch {}
|
|
87
|
+
|
|
88
|
+
const parsed = JSON.parse(result)
|
|
89
|
+
return parsed.requests.average
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function main() {
|
|
93
|
+
console.log('\n==============================================')
|
|
94
|
+
console.log(' Turbo Mode Benchmark: normal vs turbo')
|
|
95
|
+
console.log(' 10 connections, 5 seconds, real HTTP')
|
|
96
|
+
console.log('==============================================\n')
|
|
97
|
+
|
|
98
|
+
for (const count of [1, 10, 50, 100]) {
|
|
99
|
+
const payload = makePayload(count)
|
|
100
|
+
console.log(`--- ${count} users (${(payload.length / 1024).toFixed(1)} KB) ---`)
|
|
101
|
+
|
|
102
|
+
const normal = await bench('normal', false, payload)
|
|
103
|
+
const turbo = await bench('turbo', true, payload)
|
|
104
|
+
|
|
105
|
+
console.log(` normal: ${normal.toLocaleString().padStart(10)} req/sec`)
|
|
106
|
+
console.log(` turbo: ${turbo.toLocaleString().padStart(10)} req/sec`)
|
|
107
|
+
|
|
108
|
+
const ratio = turbo / normal
|
|
109
|
+
if (ratio >= 1) console.log(` >>> turbo ${ratio.toFixed(2)}x faster`)
|
|
110
|
+
else console.log(` >>> normal ${(1 / ratio).toFixed(2)}x faster`)
|
|
111
|
+
console.log()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main().catch((err) => {
|
|
116
|
+
console.error(err)
|
|
117
|
+
process.exit(1)
|
|
118
|
+
})
|
package/compat/COMPATIBILITY.md
CHANGED
|
@@ -12,7 +12,7 @@ The preload swaps the single `require('@fastify/ajv-compiler')` inside Fastify's
|
|
|
12
12
|
|
|
13
13
|
## Current score
|
|
14
14
|
|
|
15
|
-
**
|
|
15
|
+
**178 of 184 tests pass** (Fastify main at dce0d47a, ata-validator 1.11.0, fastify-ata 0.9.4).
|
|
16
16
|
|
|
17
17
|
Everything Fastify's suite asserts about validation behavior passes: type checks and coercion (including the `array` coercion mode), defaults, `removeAdditional`, required and enum handling, cross-schema `$ref` through `addSchema`, draft-07 `$id` anchors, `nullable`, `oneOf`/`anyOf` branching, custom error messages via `errorMessage`, `$merge`/`$patch` keywords, fail-fast startup errors for unresolvable references, encapsulation scoping, error shape (exact default error object layout, mutable for plugins like ajv-i18n), the error paths in `validation-error-handling`, and shared-schema `$ref` into `/definitions` across the validator AND the serializer (ata never mutates caller-provided schema objects, so fast-json-stringify sees them untouched).
|
|
18
18
|
|
|
@@ -33,4 +33,4 @@ All six are AJV-identity tests. The former seventh (ajv-errors message ordering)
|
|
|
33
33
|
|
|
34
34
|
## Reading the number honestly
|
|
35
35
|
|
|
36
|
-
The suite
|
|
36
|
+
The suite grew around AJV, so six of its 184 tests assert that the validator is AJV rather than that validation is correct. A perfect drop-in that is not AJV tops out at 178. ata passes all 178. If you find a behavior difference not covered here, that is a bug in fastify-ata or ata: please open an issue.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify-ata",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "Fastify plugin for ata-validator. Runtime-competitive with the default, 24x faster serverless cold start, Standard Schema V1.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"homepage": "https://github.com/ata-core/fastify-ata#readme",
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"ata-validator": "^1.
|
|
29
|
+
"ata-validator": "^1.11.0",
|
|
30
30
|
"fastify-plugin": "^5.1.0",
|
|
31
31
|
"sanitize-filename": "^1.6.4"
|
|
32
32
|
},
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Profile validation overhead in Fastify context
|
|
4
|
+
import { createRequire } from 'module'
|
|
5
|
+
const require = createRequire(import.meta.url)
|
|
6
|
+
|
|
7
|
+
const { Validator } = require('ata-validator')
|
|
8
|
+
|
|
9
|
+
// Simulate what Fastify does: JSON.parse + validate
|
|
10
|
+
const schema = {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
users: {
|
|
14
|
+
type: 'array',
|
|
15
|
+
items: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
id: { type: 'integer', minimum: 1 },
|
|
19
|
+
name: { type: 'string', minLength: 1 },
|
|
20
|
+
email: { type: 'string', format: 'email' },
|
|
21
|
+
age: { type: 'integer', minimum: 0, maximum: 150 },
|
|
22
|
+
active: { type: 'boolean' },
|
|
23
|
+
role: { enum: ['admin', 'user', 'moderator'] },
|
|
24
|
+
},
|
|
25
|
+
required: ['id', 'name', 'email', 'active', 'role'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
metadata: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
total: { type: 'integer' },
|
|
32
|
+
page: { type: 'integer', minimum: 1 },
|
|
33
|
+
},
|
|
34
|
+
required: ['total', 'page'],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ['users', 'metadata'],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makePayload(n) {
|
|
41
|
+
const users = []
|
|
42
|
+
for (let i = 0; i < n; i++) {
|
|
43
|
+
users.push({ id: i+1, name: `User ${i}`, email: `u${i}@x.com`, age: 25, active: true, role: 'user' })
|
|
44
|
+
}
|
|
45
|
+
return JSON.stringify({ users, metadata: { total: n, page: 1 } })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const v = new Validator(schema)
|
|
49
|
+
const payloads = [1, 10, 50, 100].map(n => ({ n, json: makePayload(n) }))
|
|
50
|
+
|
|
51
|
+
// Warmup
|
|
52
|
+
for (const p of payloads) {
|
|
53
|
+
for (let i = 0; i < 1000; i++) {
|
|
54
|
+
const obj = JSON.parse(p.json)
|
|
55
|
+
v.validate(obj)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log('Breakdown: JSON.parse vs validate vs total\n')
|
|
60
|
+
|
|
61
|
+
const N = 100000
|
|
62
|
+
for (const p of payloads) {
|
|
63
|
+
// JSON.parse only
|
|
64
|
+
let t = process.hrtime.bigint()
|
|
65
|
+
for (let i = 0; i < N; i++) JSON.parse(p.json)
|
|
66
|
+
const dtParse = Number(process.hrtime.bigint() - t) / 1e6
|
|
67
|
+
|
|
68
|
+
// validate only (pre-parsed)
|
|
69
|
+
const obj = JSON.parse(p.json)
|
|
70
|
+
t = process.hrtime.bigint()
|
|
71
|
+
for (let i = 0; i < N; i++) v.validate(obj)
|
|
72
|
+
const dtValidate = Number(process.hrtime.bigint() - t) / 1e6
|
|
73
|
+
|
|
74
|
+
// parse + validate
|
|
75
|
+
t = process.hrtime.bigint()
|
|
76
|
+
for (let i = 0; i < N; i++) { const o = JSON.parse(p.json); v.validate(o) }
|
|
77
|
+
const dtTotal = Number(process.hrtime.bigint() - t) / 1e6
|
|
78
|
+
|
|
79
|
+
const parsePct = (dtParse / dtTotal * 100).toFixed(0)
|
|
80
|
+
const valPct = (dtValidate / dtTotal * 100).toFixed(0)
|
|
81
|
+
|
|
82
|
+
console.log(`${p.n} users (${(p.json.length/1024).toFixed(1)}KB):`)
|
|
83
|
+
console.log(` JSON.parse: ${(dtParse/N*1000).toFixed(1)} us (${parsePct}%)`)
|
|
84
|
+
console.log(` validate: ${(dtValidate/N*1000).toFixed(1)} us (${valPct}%)`)
|
|
85
|
+
console.log(` total: ${(dtTotal/N*1000).toFixed(1)} us`)
|
|
86
|
+
console.log()
|
|
87
|
+
}
|