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:
| Service | endPoint |
|---|---|
| AWS S3 | https://s3.<region>.amazonaws.com |
| Cloudflare R2 | https://<account>.r2.cloudflarestorage.com |
| MinIO | http://localhost:9000 |
| Backblaze B2 | https://s3.<region>.backblazeb2.com |
| DigitalOcean Spaces | https://<region>.digitaloceanspaces.com |
| Supabase Storage | https://<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.