Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/codspeed.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
<a href="https://www.npmjs.com/package/@standardserver/core">
<img alt="weekly downloads" src="https://img.shields.io/npm/dw/%40standardserver%2Fcore?logo=npm" />
</a>
<a href="https://app.codspeed.io/middleapi/standardserver?utm_source=badge">
<img src="https://img.shields.io/endpoint?url=https://codspeed.io/badge.json" alt="CodSpeed" />
</a>
<a href="https://github.com/middleapi/standardserver/blob/main/LICENSE">
<img alt="MIT License" src="https://img.shields.io/github/license/middleapi/standardserver?logo=open-source-initiative" />
</a>
Expand Down
52 changes: 52 additions & 0 deletions bench/__shared__/client-server.hono-fetch.ts
Original file line number Diff line number Diff line change
@@ -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
}
52 changes: 52 additions & 0 deletions bench/__shared__/client-server.node-http.ts
Original file line number Diff line number Diff line change
@@ -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
}
108 changes: 108 additions & 0 deletions bench/__shared__/client-server.node-ws.ts
Original file line number Diff line number Diff line change
@@ -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<string | Uint8Array<ArrayBuffer>> {
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<ArrayBuffer>
}

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<void>((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
}
81 changes: 81 additions & 0 deletions bench/__shared__/client-server.ts
Original file line number Diff line number Diff line change
@@ -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<StandardResponse>
request: (request: StandardRequest) => Promise<StandardLazyResponse>
}

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<void> {
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<void> {
const response = await clientServer.request(request)
await drainBody(await response.resolveBody())
}
Loading
Loading