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 @@ weekly downloads + + CodSpeed + MIT License 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 @@ weekly downloads + + CodSpeed + MIT License 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 @@ weekly downloads + + CodSpeed + MIT License 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 @@ weekly downloads + + CodSpeed + MIT License 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 @@ weekly downloads + + CodSpeed + MIT License 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 @@ weekly downloads + + CodSpeed + MIT License 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'], + }, }, }, ],