diff --git a/.github/workflows/codspeed.yaml b/.github/workflows/codspeed.yaml
new file mode 100644
index 0000000..eda27aa
--- /dev/null
+++ b/.github/workflows/codspeed.yaml
@@ -0,0 +1,29 @@
+name: CodSpeed Benchmarks
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ # `workflow_dispatch` allows CodSpeed to trigger backtest
+ # performance analysis in order to generate initial data.
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ benchmarks:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: pnpm/action-setup@v6
+
+ - run: pnpm i
+
+ - uses: CodSpeedHQ/action@v4
+ with:
+ mode: walltime
+ run: pnpm run bench
diff --git a/README.md b/README.md
index 534c7c1..2452737 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,9 @@
+
+
+
diff --git a/bench/__shared__/client-server.hono-fetch.ts b/bench/__shared__/client-server.hono-fetch.ts
new file mode 100644
index 0000000..7dc5718
--- /dev/null
+++ b/bench/__shared__/client-server.hono-fetch.ts
@@ -0,0 +1,52 @@
+import type { AddressInfo } from 'node:net'
+import type { ClientServer } from './client-server'
+import { serve } from '@hono/node-server'
+import { toFetchBody, toFetchHeaders, toFetchResponse, toStandardLazyRequest, toStandardLazyResponse } from '@standardserver/fetch'
+
+/**
+ * Real Hono/node-server + fetch adapter round-trip.
+ */
+export function createHonoFetchClientServer(): ClientServer {
+ const clientServer: ClientServer = {
+ handler: async () => ({ status: 404, body: 'Not Found', headers: {} }),
+ request: async () => {
+ throw new Error('client-server not ready')
+ },
+ }
+
+ const server = serve({
+ fetch: async (request: Request) => {
+ const standardRequest = toStandardLazyRequest(request)
+ const standardResponse = await clientServer.handler(standardRequest)
+ return toFetchResponse(standardResponse)
+ },
+ port: 0,
+ })
+
+ afterAll(() => {
+ server.close()
+ })
+
+ const addressInfo = server.address() as AddressInfo
+
+ clientServer.request = async (standardRequest) => {
+ const [body, standardHeaders] = toFetchBody(standardRequest.body, standardRequest.headers)
+
+ const init: RequestInit = {
+ method: standardRequest.method,
+ signal: standardRequest.signal ?? null,
+ headers: toFetchHeaders(standardHeaders),
+ body: body ?? null,
+ }
+
+ if (body instanceof ReadableStream) {
+ init.duplex = 'half'
+ }
+
+ const response = await fetch(`http://localhost:${addressInfo.port}${standardRequest.url}`, init)
+
+ return toStandardLazyResponse(response)
+ }
+
+ return clientServer
+}
diff --git a/bench/__shared__/client-server.node-http.ts b/bench/__shared__/client-server.node-http.ts
new file mode 100644
index 0000000..8bd9760
--- /dev/null
+++ b/bench/__shared__/client-server.node-http.ts
@@ -0,0 +1,52 @@
+import type { AddressInfo } from 'node:net'
+import type { ClientServer } from './client-server'
+import * as http from 'node:http'
+import { toFetchBody, toFetchHeaders, toStandardLazyResponse } from '@standardserver/fetch'
+import { sendStandardResponse, toStandardLazyRequest } from '@standardserver/node'
+
+/**
+ * Real Node.js `http` server + `fetch` client (node adapter on the server side).
+ */
+export function createNodeHttpClientServer(): ClientServer {
+ const clientServer: ClientServer = {
+ handler: async () => ({ status: 404, body: 'Not Found', headers: {} }),
+ request: async () => {
+ throw new Error('client-server not ready')
+ },
+ }
+
+ const server = http.createServer(async (req, res) => {
+ const standardRequest = toStandardLazyRequest(req, res)
+ const standardResponse = await clientServer.handler(standardRequest)
+ await sendStandardResponse(res, standardResponse)
+ })
+
+ server.listen(0)
+
+ afterAll(() => {
+ server.close()
+ })
+
+ const addressInfo = server.address() as AddressInfo
+
+ clientServer.request = async (standardRequest) => {
+ const [body, standardHeaders] = toFetchBody(standardRequest.body, standardRequest.headers)
+
+ const init: RequestInit = {
+ method: standardRequest.method,
+ headers: toFetchHeaders(standardHeaders),
+ body: body ?? null,
+ signal: standardRequest.signal ?? null,
+ }
+
+ if (body instanceof ReadableStream) {
+ init.duplex = 'half'
+ }
+
+ const response = await fetch(`http://localhost:${addressInfo.port}${standardRequest.url}`, init)
+
+ return toStandardLazyResponse(response)
+ }
+
+ return clientServer
+}
diff --git a/bench/__shared__/client-server.node-ws.ts b/bench/__shared__/client-server.node-ws.ts
new file mode 100644
index 0000000..8083375
--- /dev/null
+++ b/bench/__shared__/client-server.node-ws.ts
@@ -0,0 +1,108 @@
+/* eslint-disable ban/ban */
+import type { PeerMessage } from '@standardserver/peer'
+import type { BlobPart } from 'node:buffer'
+import type { ClientServer } from './client-server'
+import { ClientPeer, decodePeerMessage, encodePeerMessage, isClientPeerSendMessage, isServerPeerSendMessage, ServerPeer } from '@standardserver/peer'
+import { WebSocket, WebSocketServer } from 'ws'
+
+async function encode(message: PeerMessage) {
+ return encodePeerMessage(message)
+}
+
+async function normalizeWsData(data: unknown): Promise> {
+ if (typeof data === 'string') {
+ return data
+ }
+
+ if (data instanceof ArrayBuffer) {
+ return new Uint8Array(data)
+ }
+
+ if (Array.isArray(data)) {
+ return await new Blob(data as BlobPart[]).bytes() as Uint8Array
+ }
+
+ if (data instanceof Uint8Array) {
+ return new Uint8Array(data.buffer as ArrayBuffer, data.byteOffset, data.byteLength)
+ }
+
+ // Node.js Buffer
+ const buffer = data as { buffer: ArrayBuffer, byteOffset: number, byteLength: number }
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
+}
+
+/**
+ * Peer adapter over a real WebSocket server/client (`ws`).
+ */
+export function createNodeWsClientServer(): ClientServer {
+ const wss = new WebSocketServer({ port: 0 })
+ const address = wss.address() as WebSocket.AddressInfo
+ const wsc = new WebSocket(`ws://localhost:${address.port}`)
+
+ afterAll(() => {
+ wss.close()
+ wsc.close()
+ })
+
+ const untilReady = new Promise((resolve) => {
+ if (wsc.readyState === WebSocket.OPEN) {
+ resolve()
+ return
+ }
+
+ wsc.addEventListener('open', () => {
+ resolve()
+ })
+ })
+
+ const clientPeer = new ClientPeer(async (message) => {
+ await untilReady
+ wsc.send(await encode(message))
+ })
+
+ wsc.addEventListener('message', async (event) => {
+ const encoded = await normalizeWsData(event.data)
+ const { matched, message } = decodePeerMessage(encoded)
+
+ if (!matched || !isServerPeerSendMessage(message)) {
+ return
+ }
+
+ await clientPeer.message(message)
+ })
+
+ const clientServer: ClientServer = {
+ handler: async () => ({ status: 404, body: 'Not Found', headers: {} }),
+ request: async (standardRequest) => {
+ await untilReady
+ return clientPeer.request(standardRequest)
+ },
+ }
+
+ let sendServer: (data: string | Uint8Array) => void = () => {
+ throw new Error('websocket server not connected')
+ }
+
+ const serverPeer = new ServerPeer(async (message) => {
+ sendServer(await encode(message) as string | Uint8Array)
+ })
+
+ wss.on('connection', (ws) => {
+ sendServer = (data) => {
+ ws.send(data)
+ }
+
+ ws.addEventListener('message', async (event) => {
+ const encoded = await normalizeWsData(event.data)
+ const { matched, message } = decodePeerMessage(encoded)
+
+ if (!matched || !isClientPeerSendMessage(message)) {
+ return
+ }
+
+ await serverPeer.message(message, async request => clientServer.handler(request))
+ })
+ })
+
+ return clientServer
+}
diff --git a/bench/__shared__/client-server.ts b/bench/__shared__/client-server.ts
new file mode 100644
index 0000000..8716013
--- /dev/null
+++ b/bench/__shared__/client-server.ts
@@ -0,0 +1,81 @@
+import type { StandardLazyRequest, StandardLazyResponse, StandardRequest, StandardResponse } from '@standardserver/core'
+import { isAsyncIteratorObject } from '@standardserver/shared'
+
+export interface ClientServer {
+ handler: (request: StandardLazyRequest) => Promise
+ request: (request: StandardRequest) => Promise
+}
+
+export const echoHandler: ClientServer['handler'] = async (request) => {
+ const body = await request.resolveBody()
+
+ return {
+ status: 200,
+ headers: {
+ 'x-from': 'server',
+ },
+ body,
+ }
+}
+
+/**
+ * Fully consume a response body so streaming / lazy adapters are measured end-to-end.
+ */
+async function drainBody(body: unknown): Promise {
+ if (body === undefined || body === null) {
+ return
+ }
+
+ if (isAsyncIteratorObject(body)) {
+ const iterator = body as AsyncGenerator
+ while (true) {
+ const result = await iterator.next()
+ if (result.done) {
+ break
+ }
+ }
+ return
+ }
+
+ if (body instanceof ReadableStream) {
+ const reader = body.getReader()
+ try {
+ while (true) {
+ const { done } = await reader.read()
+ if (done) {
+ break
+ }
+ }
+ }
+ finally {
+ reader.releaseLock()
+ }
+ return
+ }
+
+ if (body instanceof Blob) {
+ await body.arrayBuffer()
+ return
+ }
+
+ if (body instanceof FormData) {
+ for (const value of body.values()) {
+ if (value instanceof Blob) {
+ await value.arrayBuffer()
+ }
+ }
+ return
+ }
+
+ if (body instanceof URLSearchParams) {
+ body.toString()
+ }
+}
+
+export async function roundTrip(
+ clientServer: ClientServer,
+ request: StandardRequest,
+): Promise {
+ const response = await clientServer.request(request)
+ await drainBody(await response.resolveBody())
+}
diff --git a/bench/__shared__/payloads.ts b/bench/__shared__/payloads.ts
new file mode 100644
index 0000000..40e08fa
--- /dev/null
+++ b/bench/__shared__/payloads.ts
@@ -0,0 +1,286 @@
+import assert from 'node:assert/strict'
+import { stringifyJSON } from '@standardserver/shared'
+
+const KB = 1024
+const SIZES = { '1KB': KB, '100KB': 100 * KB, '5MB': 50 * 100 * KB } as const
+type SizeLabel = keyof typeof SIZES
+export const BODY_SIZE_ENTRIES = Object.entries(SIZES) as [SizeLabel, number][]
+
+function rng(seed: number): () => number {
+ let s = seed >>> 0
+ return () => {
+ s = (s + 0x6D2B79F5) | 0
+ let t = Math.imul(s ^ (s >>> 15), 1 | s)
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+ }
+}
+
+const ALNUM = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+const TEXT = `${ALNUM} .,;:!?@#&()[]{}/+_-`
+const UNI = ['café', '東京', '🚀', 'São Paulo', '日本語', 'привет', '€99'] as const
+
+function pick(r: () => number, xs: readonly T[]): T {
+ return xs[Math.floor(r() * xs.length)]!
+}
+
+function alnum(r: () => number, n: number): string {
+ if (n <= 0)
+ return ''
+ if (n > KB) {
+ const u = alnum(r, KB)
+ return u.repeat(Math.floor(n / KB)) + u.slice(0, n % KB)
+ }
+ let s = ''
+ for (let i = 0; i < n; i++) s += ALNUM[Math.floor(r() * ALNUM.length)]!
+ return s
+}
+
+function text(r: () => number, n: number): string {
+ if (n <= 0)
+ return ''
+ if (n > KB) {
+ const u = text(r, KB)
+ return u.repeat(Math.floor(n / KB)) + u.slice(0, n % KB)
+ }
+ let s = ''
+ while (s.length < n) s += r() < 0.1 ? pick(r, UNI) : TEXT[Math.floor(r() * TEXT.length)]!
+ return s.slice(0, n)
+}
+
+function bytes(n: number, seed: number): Uint8Array {
+ const r = rng(seed)
+ const b = new Uint8Array(n) as Uint8Array
+ for (let i = 0; i < n; i++) {
+ b[i] = r() < 0.15 && i > 0 ? b[i - 1]! : r() < 0.3 ? 0x20 + Math.floor(r() * 95) : Math.floor(r() * 256)
+ }
+ return b
+}
+
+function repeatBytes(unit: Uint8Array, times: number): Uint8Array {
+ const out = new Uint8Array(unit.byteLength * times) as Uint8Array
+ for (let i = 0; i < times; i++) out.set(unit, i * unit.byteLength)
+ return out
+}
+
+function jlen(v: unknown): number {
+ return stringifyJSON(v)!.length
+}
+
+function padJson(value: object, size: number, seed: number): object {
+ const r = rng(seed)
+ const base = jlen({ ...value, _pad: '' })
+ assert.ok(base <= size, `json core ${base} > ${size}`)
+ return { ...value, _pad: alnum(r, size - base) }
+}
+
+function record(seed: number) {
+ const r = rng(seed)
+ return {
+ id: `rec_${alnum(r, 10)}`,
+ type: pick(r, ['user', 'order', 'event'] as const),
+ active: r() > 0.4,
+ score: Math.fround(r() * 100),
+ tags: [pick(r, UNI), alnum(r, 6)],
+ meta: { locale: pick(r, ['en-US', 'fr-FR', 'ja-JP']), region: pick(r, ['us-east-1', 'eu-west-1']), v: 1 + Math.floor(r() * 5) },
+ profile: {
+ name: text(r, 12),
+ email: `${alnum(r, 8)}@example.com`,
+ bio: text(r, 32),
+ },
+ payload: { kind: pick(r, ['snapshot', 'delta']), values: [r(), r(), r()], note: text(r, 20) },
+ }
+}
+
+function split(buf: Uint8Array, parts: number): Uint8Array[] {
+ const base = Math.floor(buf.byteLength / parts)
+ let rem = buf.byteLength - base * parts
+ const out: Uint8Array[] = []
+ let off = 0
+ for (let i = 0; i < parts; i++) {
+ const n = base + (rem-- > 0 ? 1 : 0)
+ out.push(buf.subarray(off, off + n) as Uint8Array)
+ off += n
+ }
+ return out
+}
+
+function formSize(form: FormData): number {
+ let n = 0
+ for (const v of form.values()) n += typeof v === 'string' ? new TextEncoder().encode(v).byteLength : v.size
+ return n
+}
+
+function makeForm(size: number, pool: Uint8Array, seed: number): FormData {
+ const r = rng(seed)
+ const form = new FormData()
+ form.append('userId', `user_${alnum(r, 8)}`)
+ form.append('action', pick(r, ['create', 'update', 'import']))
+ form.append('meta', stringifyJSON({ tags: [pick(r, UNI), pick(r, UNI)], dryRun: r() > 0.5 })!)
+ form.append('note', text(r, 40))
+ form.append('thumb', new File([bytes(32, seed ^ 1)], 't.png', { type: 'image/png' }))
+ const fileSize = size - formSize(form)
+ assert.ok(fileSize > 0 && fileSize <= pool.byteLength)
+ form.append('file', new File([pool.subarray(0, fileSize)], 'data.bin', { type: 'application/octet-stream' }))
+ return form
+}
+
+function makeParams(size: number, seed: number): URLSearchParams {
+ const r = rng(seed)
+ const p = new URLSearchParams({
+ 'page': String(1 + Math.floor(r() * 20)),
+ 'sort': pick(r, ['created_at', 'score']),
+ 'q': alnum(r, 12),
+ 'filter[status]': pick(r, ['active', 'pending']),
+ 'cursor': alnum(r, 16),
+ 'data': '',
+ })
+ const base = p.toString().length
+ assert.ok(base <= size)
+ p.set('data', alnum(r, size - base))
+ const d = size - p.toString().length
+ if (d)
+ p.set('data', (p.get('data') ?? '') + alnum(r, d))
+ return p
+}
+
+interface Event { type: string, [k: string]: unknown }
+
+function eventSize(parts: readonly Event[]): number {
+ return parts.reduce((n, p) => n + jlen(p), 0)
+}
+
+function makeEvents(size: number, seed: number): Event[] {
+ const r = rng(seed)
+ const parts: Event[] = [
+ { type: 'heartbeat', ts: 1_700_000_000_000 + Math.floor(r() * 1e6), seq: Math.floor(r() * 1e6) },
+ { type: 'progress', jobId: alnum(r, 8), percent: Math.floor(r() * 100), stage: pick(r, ['running', 'done']) },
+ { type: 'message', id: alnum(r, 8), text: text(r, 20), tags: [pick(r, UNI)] },
+ { type: 'message', id: 'pad', channel: 'pad', text: '', tags: ['pad'] },
+ ]
+ const base = eventSize(parts)
+ assert.ok(base <= size, `event core ${base} > ${size}`)
+ parts[parts.length - 1]!.text = alnum(r, size - base)
+ return parts
+}
+
+function fitJson(unit: object, size: number, seed: number): object {
+ for (let n = 100; n > 0; n--) {
+ const core = { n, items: Array.from({ length: n }, (_, i) => ({ i, data: unit })) }
+ if (jlen({ ...core, _pad: '' }) <= size)
+ return padJson(core, size, seed)
+ }
+ throw new Error(`cannot fit json under ${size}`)
+}
+
+function fitEvents(unit: object, size: number, seed: number): Event[] {
+ const r = rng(seed)
+ const parts: Event[] = []
+ for (let i = 0; i < 100; i++) {
+ const next = [...parts, { type: 'batch', i, data: unit }, { type: 'message', id: 'pad', text: '', tags: ['pad'] }]
+ if (eventSize(next) > size)
+ break
+ parts.push({ type: 'batch', i, data: unit })
+ }
+ assert.ok(parts.length > 0)
+ parts.push({ type: 'message', id: 'pad', text: '', tags: ['pad'] })
+ parts[parts.length - 1]!.text = alnum(r, size - eventSize(parts))
+ return parts
+}
+
+const BYTES1KB = bytes(SIZES['1KB'], 0xB001)
+assert.equal(BYTES1KB.byteLength, SIZES['1KB'])
+const BYTES100KB = repeatBytes(BYTES1KB, 100)
+assert.equal(BYTES100KB.byteLength, SIZES['100KB'])
+const BYTES5MB = repeatBytes(BYTES100KB, 50)
+assert.equal(BYTES5MB.byteLength, SIZES['5MB'])
+
+const JSON1KB = padJson(record(0xA001), SIZES['1KB'], 0xA101)
+assert.equal(jlen(JSON1KB), SIZES['1KB'])
+const JSON100KB = padJson(
+ { items: Array.from({ length: 100 }, (_, i) => ({ i, item: record(0xA200 + i) })) },
+ SIZES['100KB'],
+ 0xA201,
+)
+assert.equal(jlen(JSON100KB), SIZES['100KB'])
+const JSON5MB = fitJson(JSON100KB, SIZES['5MB'], 0xA301)
+assert.equal(jlen(JSON5MB), SIZES['5MB'])
+
+const BLOB1KB = new Blob([BYTES1KB], { type: 'application/octet-stream' })
+assert.equal(BLOB1KB.size, SIZES['1KB'])
+const BLOB100KB = new Blob([BYTES100KB], { type: 'application/octet-stream' })
+assert.equal(BLOB100KB.size, SIZES['100KB'])
+const BLOB5MB = new Blob([BYTES5MB], { type: 'application/octet-stream' })
+assert.equal(BLOB5MB.size, SIZES['5MB'])
+
+const FORM1KB = makeForm(SIZES['1KB'], BYTES1KB, 0xF001)
+assert.equal(formSize(FORM1KB), SIZES['1KB'])
+const FORM100KB = makeForm(SIZES['100KB'], BYTES100KB, 0xF101)
+assert.equal(formSize(FORM100KB), SIZES['100KB'])
+const FORM5MB = makeForm(SIZES['5MB'], BYTES5MB, 0xF201)
+assert.equal(formSize(FORM5MB), SIZES['5MB'])
+
+const USP1KB = makeParams(SIZES['1KB'], 0xC001)
+assert.equal(USP1KB.toString().length, SIZES['1KB'])
+const USP100KB = makeParams(SIZES['100KB'], 0xC101)
+assert.equal(USP100KB.toString().length, SIZES['100KB'])
+const USP5MB = makeParams(SIZES['5MB'], 0xC201)
+assert.equal(USP5MB.toString().length, SIZES['5MB'])
+
+const EVENTS1KB = makeEvents(SIZES['1KB'], 0xE001)
+assert.equal(eventSize(EVENTS1KB), SIZES['1KB'])
+const EVENTS100KB = Array.from({ length: 100 }, (_, i) => i === 0 ? EVENTS1KB : makeEvents(SIZES['1KB'], 0xE100 + i)).flat()
+assert.equal(eventSize(EVENTS100KB), SIZES['100KB'])
+const EVENTS5MB = fitEvents(JSON100KB, SIZES['5MB'], 0xE201)
+assert.equal(eventSize(EVENTS5MB), SIZES['5MB'])
+
+const OCTET1KB = split(BYTES1KB, 8)
+assert.equal(OCTET1KB.reduce((n, c) => n + c.byteLength, 0), SIZES['1KB'])
+const OCTET100KB = split(BYTES100KB, 32)
+assert.equal(OCTET100KB.reduce((n, c) => n + c.byteLength, 0), SIZES['100KB'])
+const OCTET5MB = split(BYTES5MB, 64)
+assert.equal(OCTET5MB.reduce((n, c) => n + c.byteLength, 0), SIZES['5MB'])
+
+export function asEventStream(parts: readonly Event[]): AsyncGenerator {
+ return (async function* () {
+ for (const p of parts) yield p
+ }())
+}
+
+export function asOctetStream(parts: readonly Uint8Array[]): ReadableStream> {
+ let i = 0
+ return new ReadableStream({
+ pull(c) {
+ if (i >= parts.length)
+ c.close()
+ else c.enqueue(parts[i++]!)
+ },
+ })
+}
+
+export const BODY_PAYLOADS = {
+ '1KB': {
+ json: JSON1KB,
+ blob: BLOB1KB,
+ formData: FORM1KB,
+ urlSearchParams: USP1KB,
+ eventParts: EVENTS1KB,
+ octetParts: OCTET1KB,
+ },
+ '100KB': {
+ json: JSON100KB,
+ blob: BLOB100KB,
+ formData: FORM100KB,
+ urlSearchParams: USP100KB,
+ eventParts: EVENTS100KB,
+ octetParts: OCTET100KB,
+ },
+ '5MB': {
+ json: JSON5MB,
+ blob: BLOB5MB,
+ formData: FORM5MB,
+ urlSearchParams: USP5MB,
+ eventParts: EVENTS5MB,
+ octetParts: OCTET5MB,
+ },
+} as const
diff --git a/bench/e2e.bench.ts b/bench/e2e.bench.ts
new file mode 100644
index 0000000..65c6566
--- /dev/null
+++ b/bench/e2e.bench.ts
@@ -0,0 +1,76 @@
+import { bench, describe } from 'vitest'
+import { echoHandler, roundTrip } from './__shared__/client-server'
+import { createHonoFetchClientServer } from './__shared__/client-server.hono-fetch'
+import { createNodeHttpClientServer } from './__shared__/client-server.node-http'
+import { createNodeWsClientServer } from './__shared__/client-server.node-ws'
+import { asEventStream, asOctetStream, BODY_PAYLOADS, BODY_SIZE_ENTRIES } from './__shared__/payloads'
+
+const adapters = [
+ ['node-http', createNodeHttpClientServer],
+ ['hono-fetch', createHonoFetchClientServer],
+ ['node-ws', createNodeWsClientServer],
+] as const
+
+describe.each(adapters)('e2e client-server: $0', async (_, create) => {
+ const clientServer = create()
+ clientServer.handler = echoHandler
+ await roundTrip(clientServer, { method: 'GET', url: '/', headers: {} })
+
+ describe.each(BODY_SIZE_ENTRIES)('body size $0', (label) => {
+ const payloads = BODY_PAYLOADS[label]
+
+ bench('json', async () => {
+ await roundTrip(clientServer, {
+ method: 'POST',
+ url: `/${label}/json`,
+ headers: {},
+ body: payloads.json,
+ })
+ })
+
+ bench('blob', async () => {
+ await roundTrip(clientServer, {
+ method: 'POST',
+ url: `/${label}/blob`,
+ headers: {},
+ body: payloads.blob,
+ })
+ })
+
+ bench('form data', async () => {
+ await roundTrip(clientServer, {
+ method: 'POST',
+ url: `/${label}/form-data`,
+ headers: {},
+ body: payloads.formData,
+ })
+ })
+
+ bench('url search params', async () => {
+ await roundTrip(clientServer, {
+ method: 'POST',
+ url: `/${label}/url-search-params`,
+ headers: {},
+ body: payloads.urlSearchParams,
+ })
+ })
+
+ bench('event stream', async () => {
+ await roundTrip(clientServer, {
+ method: 'POST',
+ url: `/${label}/event-stream`,
+ headers: {},
+ body: asEventStream(payloads.eventParts),
+ })
+ })
+
+ bench('octet stream', async () => {
+ await roundTrip(clientServer, {
+ method: 'POST',
+ url: `/${label}/octet-stream`,
+ headers: {},
+ body: asOctetStream(payloads.octetParts),
+ })
+ })
+ })
+})
diff --git a/package.json b/package.json
index c973210..0ec9780 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"type:check": "pnpm -r run type:check && tsc",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
+ "bench": "vitest bench --run",
"lint": "eslint --max-warnings=0 .",
"lint:fix": "eslint --max-warnings=0 --fix .",
"repo:fix": "sherif --fix",
@@ -17,6 +18,7 @@
},
"devDependencies": {
"@antfu/eslint-config": "^6.7.3",
+ "@codspeed/vitest-plugin": "^5.7.1",
"@hono/node-server": "^2.0.1",
"@remix-run/node-fetch-server": "^0.13.0",
"@standardserver/core": "workspace:*",
diff --git a/packages/core/README.md b/packages/core/README.md
index cd7dfeb..b2e5b8d 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -7,6 +7,9 @@
+
+
+
diff --git a/packages/fetch/README.md b/packages/fetch/README.md
index 18daec3..400e82e 100644
--- a/packages/fetch/README.md
+++ b/packages/fetch/README.md
@@ -7,6 +7,9 @@
+
+
+
diff --git a/packages/node/README.md b/packages/node/README.md
index 99cd58d..6a4fb4c 100644
--- a/packages/node/README.md
+++ b/packages/node/README.md
@@ -7,6 +7,9 @@
+
+
+
diff --git a/packages/peer/README.md b/packages/peer/README.md
index 2da18a0..0abfe66 100644
--- a/packages/peer/README.md
+++ b/packages/peer/README.md
@@ -7,6 +7,9 @@
+
+
+
diff --git a/packages/shared/README.md b/packages/shared/README.md
index a37e122..5d7e63f 100644
--- a/packages/shared/README.md
+++ b/packages/shared/README.md
@@ -7,6 +7,9 @@
+
+
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2dec20a..dff06c6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,9 @@ importers:
'@antfu/eslint-config':
specifier: ^6.7.3
version: 6.7.3(@vue/compiler-sfc@3.5.25)(eslint-plugin-format@1.1.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3)(vitest@4.1.5)
+ '@codspeed/vitest-plugin':
+ specifier: ^5.7.1
+ version: 5.7.1(tinybench@2.9.0)(vite@7.3.0(@types/node@26.0.0)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.5)
'@hono/node-server':
specifier: ^2.0.1
version: 2.0.1(hono@4.11.1)
@@ -269,6 +272,16 @@ packages:
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
+ '@codspeed/core@5.7.1':
+ resolution: {integrity: sha512-7mrFkqaY14rXu3XHv3o7bK3btLK0LFRxfeK3bQC8dLduI/Ty9eq8Hv8rx04FAY/onQbRTx3rUUQg5xprYHpATQ==}
+
+ '@codspeed/vitest-plugin@5.7.1':
+ resolution: {integrity: sha512-yKNqaYVxZZPJYkhVHpz30Oem4TUgcI+/IRDD9YHvcAgi0P42CL6TB/+qYjuFOWghFzTPrm3TUbNFoBsevSTbUA==}
+ peerDependencies:
+ tinybench: '>=2.9.0'
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ vitest: ^3.2 || ^4
+
'@dprint/formatter@0.3.0':
resolution: {integrity: sha512-N9fxCxbaBOrDkteSOzaCqwWjso5iAe+WJPsHC021JfHNj2ThInPNEF13ORDKta3llq5D1TlclODCvOvipH7bWQ==}
@@ -1360,6 +1373,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ agent-base@6.0.2:
+ resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+ engines: {node: '>= 6.0.0'}
+
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@@ -1417,6 +1434,9 @@ packages:
peerDependencies:
postcss: ^8.1.0
+ axios@1.18.1:
+ resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -2109,6 +2129,10 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
+ find-up@6.3.0:
+ resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
@@ -2119,6 +2143,15 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
+ follow-redirects@1.16.0:
+ resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
@@ -2247,6 +2280,10 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+ https-proxy-agent@5.0.1:
+ resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+ engines: {node: '>= 6'}
+
human-signals@8.0.1:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
@@ -2428,6 +2465,10 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
+ locate-path@7.2.0:
+ resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
@@ -2692,6 +2733,10 @@ packages:
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
+ node-gyp-build@4.8.4:
+ resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
+ hasBin: true
+
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@@ -2753,10 +2798,18 @@ packages:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
+ p-limit@4.0.0:
+ resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ p-locate@6.0.0:
+ resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
@@ -2782,6 +2835,10 @@ packages:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
+ path-exists@5.0.0:
+ resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -3042,6 +3099,10 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
+ proxy-from-env@2.1.0:
+ resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
+ engines: {node: '>=10'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -3261,6 +3322,10 @@ packages:
engines: {node: '>=20.16.0'}
hasBin: true
+ stack-trace@1.0.0-pre2:
+ resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==}
+ engines: {node: '>=16'}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -3603,6 +3668,10 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ yocto-queue@1.2.2:
+ resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
+ engines: {node: '>=12.20'}
+
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
@@ -3698,6 +3767,27 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
+ '@codspeed/core@5.7.1':
+ dependencies:
+ axios: 1.18.1
+ find-up: 6.3.0
+ form-data: 4.0.5
+ node-gyp-build: 4.8.4
+ stack-trace: 1.0.0-pre2
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
+ '@codspeed/vitest-plugin@5.7.1(tinybench@2.9.0)(vite@7.3.0(@types/node@26.0.0)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.5)':
+ dependencies:
+ '@codspeed/core': 5.7.1
+ tinybench: 2.9.0
+ vite: 7.3.0(@types/node@26.0.0)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0)
+ vitest: 4.1.5(@types/node@26.0.0)(@vitest/coverage-v8@4.1.5)(vite@7.3.0(@types/node@26.0.0)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0))
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
'@dprint/formatter@0.3.0': {}
'@dprint/markdown@0.17.8': {}
@@ -4507,6 +4597,12 @@ snapshots:
acorn@8.15.0: {}
+ agent-base@6.0.2:
+ dependencies:
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -4561,6 +4657,16 @@ snapshots:
postcss: 8.5.6
postcss-value-parser: 4.2.0
+ axios@1.18.1:
+ dependencies:
+ follow-redirects: 1.16.0
+ form-data: 4.0.5
+ https-proxy-agent: 5.0.1
+ proxy-from-env: 2.1.0
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
balanced-match@1.0.2: {}
baseline-browser-mapping@2.9.11: {}
@@ -5437,6 +5543,11 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
+ find-up@6.3.0:
+ dependencies:
+ locate-path: 7.2.0
+ path-exists: 5.0.0
+
fix-dts-default-cjs-exports@1.0.1:
dependencies:
magic-string: 0.30.21
@@ -5450,6 +5561,8 @@ snapshots:
flatted@3.3.3: {}
+ follow-redirects@1.16.0: {}
+
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@@ -5567,6 +5680,13 @@ snapshots:
html-escaper@2.0.2: {}
+ https-proxy-agent@5.0.1:
+ dependencies:
+ agent-base: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
human-signals@8.0.1: {}
ignore@5.3.2: {}
@@ -5721,6 +5841,10 @@ snapshots:
dependencies:
p-locate: 5.0.0
+ locate-path@7.2.0:
+ dependencies:
+ p-locate: 6.0.0
+
lodash.memoize@4.1.2: {}
lodash.merge@4.6.2: {}
@@ -6149,6 +6273,8 @@ snapshots:
node-fetch-native@1.6.7: {}
+ node-gyp-build@4.8.4: {}
+
node-releases@2.0.27: {}
normalize-path@3.0.0: {}
@@ -6217,10 +6343,18 @@ snapshots:
dependencies:
yocto-queue: 0.1.0
+ p-limit@4.0.0:
+ dependencies:
+ yocto-queue: 1.2.2
+
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
+ p-locate@6.0.0:
+ dependencies:
+ p-limit: 4.0.0
+
package-manager-detector@1.6.0: {}
parent-module@1.0.1:
@@ -6239,6 +6373,8 @@ snapshots:
path-exists@4.0.0: {}
+ path-exists@5.0.0: {}
+
path-key@3.1.1: {}
path-key@4.0.0: {}
@@ -6464,6 +6600,8 @@ snapshots:
dependencies:
parse-ms: 4.0.0
+ proxy-from-env@2.1.0: {}
+
punycode@2.3.1: {}
qs@6.14.0:
@@ -6703,6 +6841,8 @@ snapshots:
srvx@0.10.0: {}
+ stack-trace@1.0.0-pre2: {}
+
stackback@0.0.2: {}
std-env@3.10.0: {}
@@ -7031,6 +7171,8 @@ snapshots:
yocto-queue@0.1.0: {}
+ yocto-queue@1.2.2: {}
+
yoctocolors@2.1.2: {}
zwitch@2.0.4: {}
diff --git a/tsconfig.json b/tsconfig.json
index 5de5884..e7e7b6d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,6 +8,7 @@
"playground",
"scripts",
"tests",
+ "bench",
"*",
"*/*/src/**/*.bench.*",
@@ -19,6 +20,7 @@
"*/*/playground",
"*/*/scripts",
"*/*/tests",
+ "*/*/bench",
"*/*/*"
],
"exclude": [
diff --git a/vitest.config.ts b/vitest.config.ts
index 0c0db1a..495c301 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,16 +1,21 @@
+import codspeedPlugin from '@codspeed/vitest-plugin'
import { defineConfig } from 'vitest/config'
export default defineConfig(() => ({
test: {
coverage: {
include: ['packages/*/src/**'],
- exclude: ['**.test-d.*', '**.test.*'],
+ exclude: ['**.test-d.*', '**.test.*', '**/*.bench.ts'],
},
projects: [
{
+ plugins: [codspeedPlugin()],
test: {
globals: true,
include: ['**/*.test.ts'],
+ benchmark: {
+ include: ['**/*.bench.ts'],
+ },
},
},
],