Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

HTTP & Networking

Perry natively implements HTTP servers, clients, and WebSocket support.

Node.js compatibility — node:http / node:https / node:http2

Perry exposes a faithful subset of Node.js’s stdlib HTTP server modules on top of hyper + rustls + tokio-tungstenite. The whole shape — handler signature, IncomingMessage / ServerResponse properties + methods, TLS opts, ALPN-negotiated HTTP/2, WebSocket upgrade dispatch — works unmodified, so unmodified Node servers (Express / Koa / Polka / hono via @hono/node-server / etc.) compile and run natively (issue #577).

http.createServer(handler)

// node:http server (issue #577). Drop-in for Node.js's `http.createServer`
// — same handler shape `(req, res) => …` and same property/method
// surface (`req.method`, `req.url`, `req.headers`, `res.statusCode`,
// `res.setHeader`, `res.end`, `res.write`, `res.writeHead`). The
// canonical Express body-collection pattern (`req.on('data', ...)`,
// `req.on('end', ...)`) works against a fully-buffered request body.
import { createServer } from "node:http"

const httpServer = createServer((req: any, res: any) => {
    if (req.method === "POST" && req.url === "/echo") {
        let chunks: string[] = []
        req.on("data", (chunk: string) => chunks.push(chunk))
        req.on("end", () => {
            const body = chunks.join("")
            res.statusCode = 200
            res.setHeader("Content-Type", "text/plain")
            res.end("got:" + body)
        })
        return
    }
    res.statusCode = 200
    res.setHeader("Content-Type", "application/json")
    res.end(`{"path":"${req.url}"}`)
})

httpServer.listen(3000, () => {
    console.log("[node:http] listening on http://0.0.0.0:3000")
})

Supported on IncomingMessage: .method, .url, .headers, .rawHeaders, .httpVersion, .complete, .aborted, .destroyed, .socket.remoteAddress, .socket.remotePort, .on('data'|'end'|'close'| 'error', cb), .read(), .pause(), .resume(), .destroy().

Supported on ServerResponse: .statusCode (get/set), .statusMessage (set), .setHeader/.getHeader/.removeHeader/.hasHeader/ .getHeaders/.getHeaderNames, .headersSent, .writableEnded, .writableFinished, .writeHead(status, msg?, headers?), .write(chunk), .end(chunk?), .flushHeaders(), .on('finish'|'close', cb). Auto Content-Length on .end() when no Transfer-Encoding was set.

https.createServer({ key, cert }, handler)

// node:https server (issue #577 Phase 2). Same handler surface as
// `node:http`, plus a `{ key, cert }` opts arg with PEM-encoded TLS
// material. rustls 0.23 underneath; the CryptoProvider is installed
// lazily on first `https.createServer` call. ALPN defaults to
// `http/1.1`; opt into HTTP/2 by passing
// `alpnProtocols: ["h2", "http/1.1"]` (or use `node:http2` directly).
import { createServer as createTlsServer } from "node:https"
import { readFileSync } from "node:fs"

const tlsServer = createTlsServer(
    {
        key: readFileSync("/tmp/perry-https-cert/key.pem", "utf8"),
        cert: readFileSync("/tmp/perry-https-cert/cert.pem", "utf8"),
    },
    (req: any, res: any) => {
        res.statusCode = 200
        res.setHeader("Content-Type", "application/json")
        res.end(`{"tls":"ok","path":"${req.url}"}`)
    }
)

tlsServer.listen(443)

Both key and cert are PEM strings (PKCS#8 / RSA / EC keys + multi-cert chains all parse). ALPN defaults to http/1.1 only — programs that want HTTP/2 should reach for node:http2’s createSecureServer (which always advertises [h2, http/1.1]).

http2.createSecureServer({ key, cert }, handler)

// node:http2 server (issue #577 Phase 3). `createSecureServer({ key, cert })`
// drives a hyper-util auto::Builder so HTTP/2 and HTTP/1.1 share a
// single port via ALPN auto-negotiation. The handler signature is
// the same as Phase 1 / Phase 2 — IncomingMessage / ServerResponse
// are reused as Http2ServerRequest / Http2ServerResponse since each
// `:path` request becomes a single buffered IncomingMessage.
import { createSecureServer } from "node:http2"

const h2Server = createSecureServer(
    {
        key: readFileSync("/tmp/perry-https-cert/key.pem", "utf8"),
        cert: readFileSync("/tmp/perry-https-cert/cert.pem", "utf8"),
    },
    (req: any, res: any) => {
        res.statusCode = 200
        res.setHeader("Content-Type", "application/json")
        res.end(`{"h2":"ok","path":"${req.url}","httpVersion":"${req.httpVersion}"}`)
    }
)

h2Server.listen(8443)

Driven through hyper-util’s auto::Builder, so an HTTP/1.1 client (curl without --http2) and an HTTP/2 client (curl with --http2) hit the same handler over the same port.

WebSocket upgrade — Server.on('upgrade', (req, wsId, head) => …)

// node:http + WebSocket upgrade (issue #577 Phase 4). The `'upgrade'`
// event fires once per WebSocket client; the `wsId` argument is
// already a fully-handshaked, perry-ext-ws-registered connection,
// so the usual `wsId.on('message', ...)` / `wsId.send(...)` /
// `wsId.close()` surface works without further plumbing. The
// IncomingMessage `req` carries the original upgrade request
// (URL, headers — useful for routing or auth).
const wsHttpServer = createServer((req: any, res: any) => {
    res.statusCode = 200
    res.end("perry node:http server with ws upgrade")
})

wsHttpServer.on("upgrade", (req: any, wsId: any, _head: any) => {
    wsId.on("message", (msg: string) => {
        wsId.send("echo:" + msg)
    })
    wsId.send("perry-hello")
})

wsHttpServer.listen(3001)

The HTTP/1.1 server detects Upgrade: websocket in the request, performs the handshake server-side (Sec-WebSocket-Accept derived via tungstenite’s derive_accept_key), then registers the upgraded stream in perry-ext-ws’s connection map. The TS-side wsId argument is already a fully-connected client — drive it via the standard wsId.on('message', cb) / wsId.send(msg) / wsId.close() surface that standalone WebSocketServer({ port }) clients use.

Hono

Hono is a runtime-agnostic web framework whose only required interface is app.fetch(req: Request) → Promise<Response>. Add it to perry.compilePackages and the entire app.fetch surface including middleware (hono/logger, hono/cors, hono/jwt), route groups, and JSON responses works unchanged (issues #421, #486, #487 closed). app.fetch is enough for testing, edge-runtime deployments (Cloudflare Workers / Vercel Edge / AWS Lambda / Deno Deploy — those runtimes call app.fetch themselves), and any scenario where some outer host hands you a Request.

import { Hono } from "hono"
import { logger } from "hono/logger"

const app = new Hono()
app.use("*", logger())
app.get("/", (c) => c.json({ message: "hello", ok: true }))

// app.fetch() works end-to-end — feed it a Request, get a Response.
const res = await app.fetch(new Request("http://localhost/"))
console.log(res.status, await res.text())

export default app  // for CF Workers / similar runtimes

package.json:

{
  "perry": {
    "compilePackages": ["hono"]
  }
}

Long-lived HTTP server (port-listening) — currently blocked

The canonical “deploy a hono app as a native binary on a Linux VM” pattern — serve({ fetch: app.fetch, port: 3000 }) via @hono/node-server, or a hand-rolled node:http adapter that drives app.fetch — currently fails to link because the Web Fetch FFIs (Headers / Response constructors) aren’t pulled in alongside perry-ext-http-server. Tracked at issue #589.

Workaround until #589 lands: deploy as an edge-runtime worker (CF Workers / Vercel Edge), or use perry’s Fastify binding with a single catch-all route delegating to app.fetch.

Fastify Server

import fastify from "fastify"

const app = fastify()

app.get("/", async (request: any, reply: any) => {
    return { hello: "world" }
})

app.get("/users/:id", async (request: any, reply: any) => {
    const id = request.params.id
    return { id, name: "User " + id }
})

app.post("/data", async (request: any, reply: any) => {
    const body = request.body
    reply.code(201)
    return { received: body }
})

app.listen({ port: 3000 }, () => {
    console.log("Server running on port 3000")
})

Perry’s Fastify implementation is API-compatible with the npm package. Routes, request/reply objects, params, query strings, and JSON body parsing all work.

Fetch API

async function fetchExamples(): Promise<void> {
    // GET request
    const response = await fetch("https://jsonplaceholder.typicode.com/posts/1")
    const data = await response.json()

    // POST request
    const result = await fetch("https://jsonplaceholder.typicode.com/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title: "hello", body: "world", userId: 1 }),
    })

    console.log(`fetch ok: ${data !== null} status=${result.status}`)
}

Axios

import axios from "axios"

async function axiosExamples(): Promise<void> {
    const getResp = await axios.get("https://jsonplaceholder.typicode.com/users/1")
    const data = getResp.data

    const response = await axios.post("https://jsonplaceholder.typicode.com/users", {
        name: "Perry",
        email: "perry@example.com",
    })

    console.log(`axios ok: ${data !== null} status=${response.status}`)
}

WebSocket

import { WebSocket } from "ws"

function wsExample(): void {
    const ws = new WebSocket("ws://localhost:8080")

    ws.on("open", () => {
        ws.send("Hello, server!")
    })

    ws.on("message", (data: any) => {
        console.log(`Received: ${data}`)
    })

    ws.on("close", () => {
        console.log("Connection closed")
    })
}

AWS S3 / S3-Compatible Object Storage

@bradenmacdonald/s3-lite-client is a zero-dependency, MIT-licensed S3 client (~1.9k LoC, derived from the official MinIO JS client without the lodash/async/xml2js baggage). It compiles natively under perry.compilePackages with no patches required — verified against a SigV4 presigned-URL byte-for-byte match with bun (issue #551).

{
  "perry": {
    "compilePackages": ["@bradenmacdonald/s3-lite-client"]
  }
}
import { S3Client } from "@bradenmacdonald/s3-lite-client"

const s3 = new S3Client({
    endPoint: "https://s3.us-east-1.amazonaws.com",
    region: "us-east-1",
    bucket: "my-bucket",
    accessKey: process.env.AWS_ACCESS_KEY_ID,
    secretKey: process.env.AWS_SECRET_ACCESS_KEY,
})

// Presigned GET URL (no network I/O — pure SigV4 signing)
const url = await s3.presignedGetObject("path/to/object.png", { expirySeconds: 3600 })
console.log(url)

// Upload bytes
await s3.putObject("path/to/object.txt", "hello world", {
    metadata: { "x-amz-acl": "public-read" },
})

// Stream a download — returns a standard fetch Response
const res = await s3.getObject("path/to/object.txt")
console.log(await res.text())

// Head / Delete / List
const meta = await s3.statObject("path/to/object.txt")
console.log(meta.size, meta.lastModified)

for await (const obj of s3.listObjects({ prefix: "path/to/" })) {
    console.log(obj.key, obj.size)
}

await s3.deleteObject("path/to/object.txt")

Same code works against any S3-compatible service — only endPoint changes:

ServiceendPoint
AWS S3https://s3.<region>.amazonaws.com
Cloudflare R2https://<account>.r2.cloudflarestorage.com
MinIOhttp://localhost:9000
Backblaze B2https://s3.<region>.backblazeb2.com
DigitalOcean Spaceshttps://<region>.digitaloceanspaces.com
Supabase Storagehttps://<project>.supabase.co/storage/v1/s3
LocalStack (testing)http://localhost:4566

The full SigV4 signing chain (Web Crypto HMAC-SHA-256 + SHA-256, TextEncoder, URLSearchParams, Headers iteration, typed-array byte marshalling) is exercised end-to-end. Read paths (getObject, statObject, deleteObject, listObjects, presignedGetObject, presignedPostObject) are verified byte-identical to bun against pinned test vectors and will authenticate against real S3.

Multipart uploads (putObject with a ReadableStream source large enough to chunk) exercise additional surface — WritableStream / TransformStream subclassing per #562 — that path compiles but isn’t independently verified against pinned vectors here.

For the AWS SDK v3 (@aws-sdk/client-s3): Perry currently can’t compile it. Its dependency tree pulls in @smithy/* and runtime middleware registration that uses Proxy and dynamic property assignment, neither of which is in Perry’s TypeScript subset. @bradenmacdonald/s3-lite-client covers the same surface (Put/Get/Head/Delete/List/presign + multipart) for almost every real-world need.

Next Steps