Node.js and Bun services
A service agent on a Node.js or Bun runtime exports an app, and Aetherfy
serves it. This page lists every shape of export Aetherfy accepts, gives a
complete project for each, and names the one shape it refuses.
How Aetherfy serves a Node.js or Bun service
Aetherfy loads your entrypoint as a module, takes the export named app or,
failing that, the module’s default export, and serves it on port 8080 behind
its own GET /health. Your code never starts a server: do not call
app.listen(), server.listen() or Bun.serve().
The runtimes node20, node22, node20-ts, node22-ts and bun all serve
through the same Node.js http server — Bun runs it through its own
implementation of node:http. That is why a WebSocket library behaves the same
on every one of them, and why one Bun-specific form is refused (below).
| What you export | Example | How Aetherfy serves it |
|---|---|---|
A request handler (req, res) — an Express app | module.exports = { app } | As the server’s request handler |
An object with a fetch method that takes a Request — a Hono app, or a bare { fetch } | export default app | Each request becomes a Web Request; the Response you return is written back, streamed |
An app plus the http.Server a WebSocket library is attached to | module.exports = { app, server } | Aetherfy listens on your server, and the request handler you created it with serves HTTP |
A fetch-style app gets the whole Web API contract: the request body is a
stream, a streamed response body — server-sent events included — reaches the
client as you write it rather than when the response ends, every Set-Cookie
you append is sent as its own header, and an error thrown from fetch becomes
a 500 with the error in your agent’s logs. Aetherfy passes fetch the request
only; the Bun server argument and a Cloudflare-style env and ctx are not
provided.
An export of any other shape is refused when the agent starts, so the deploy fails with the reason instead of going live and never answering:
Exported app is neither a function nor an object with a fetch() method (got object), so it cannot serve a request. Export an Express-style (req, res) handler, or an object with a fetch(request) method such as a Hono app or { fetch }The per-runtime default entrypoints are on
/agents/aetherfy-yaml: index.js for node20 and
node22, index.ts for the -ts variants, and main.ts for bun.
Hono on Bun, with server-sent events, on Aetherfy
Three files. main.ts:
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
const app = new Hono()
app.get('/', (c) => c.json({ message: 'hello from Hono on Aetherfy' }))
// Reads the whole request body, however large, and reports its size.
app.post('/upload', async (c) => {
const body = await c.req.arrayBuffer()
return c.json({ received_bytes: body.byteLength })
})
// Server-sent events: each event reaches the client as it is written.
app.get('/events', (c) =>
streamSSE(c, async (stream) => {
for (let n = 1; n <= 5; n++) {
await stream.writeSSE({ event: 'tick', data: JSON.stringify({ n }) })
await stream.sleep(1000)
}
})
)
// Aetherfy serves this object. Do not call Bun.serve() yourself.
export default apppackage.json:
{
"name": "hono-agent",
"private": true,
"dependencies": {
"hono": "^4.13.10"
}
}aetherfy.yaml:
name: hono-agent
runtime: bun
type: service
memory_mb: 256Aetherfy requires a lockfile beside package.json, so run bun install once to
write bun.lock, then deploy:
bun install
afy deploy --createWatch the events arrive one a second on the agent’s address, which afy list
shows:
curl -N https://hono-agent-k3m7x2.aetherfy.dev/eventsA Hono app deploys the same way on node20 or node22: name the file
index.js, change runtime, and run npm install instead of bun install so
the lockfile is package-lock.json.
A bare fetch handler on Node.js or Bun, on Aetherfy
No framework: the default export is an object with a fetch method, the form
Bun, Deno and Cloudflare Workers take. index.js:
// No framework: a Web-standard fetch handler. The same file runs on Bun.
export default {
async fetch(request) {
const url = new URL(request.url)
if (request.method === 'POST' && url.pathname === '/echo') {
// The request body, streamed straight back as the response body.
return new Response(request.body, {
headers: { 'content-type': request.headers.get('content-type') ?? 'text/plain' },
})
}
return Response.json({ message: 'hello from a fetch handler on Aetherfy', path: url.pathname })
},
}package.json:
{
"name": "fetch-agent",
"private": true
}aetherfy.yaml:
name: fetch-agent
runtime: node22
type: service
memory_mb: 256The project has no dependencies, but a package.json still needs its lockfile
on Aetherfy — npm install writes one:
npm install
afy deploy --createOn bun, save the same code as main.ts and set runtime: bun. The same
package.json deploys as it is: Bun writes no lockfile for a project with no
dependencies, and Aetherfy asks for one only when package.json declares a
package.
"type": "module" in package.json is fine on every runtime, and so is
leaving it out: Aetherfy loads an entrypoint written with export default
either way, so a project that follows Hono’s Node.js setup deploys unchanged.
An Express app on Aetherfy
The request-handler shape. index.js:
const express = require('express')
const app = express()
app.use(express.json())
app.get('/', (req, res) => {
res.json({ message: 'hello from Express on Aetherfy' })
})
app.post('/echo', (req, res) => {
res.json({ you_sent: req.body })
})
// Aetherfy serves this object. Do not call app.listen() yourself.
module.exports = { app }package.json:
{
"name": "express-agent",
"private": true,
"dependencies": {
"express": "^5.2.1"
}
}aetherfy.yaml:
name: express-agent
runtime: node22
type: service
memory_mb: 256npm install
afy deploy --createWebSockets on Aetherfy, and Bun’s websocket form
WebSockets work by exporting the http.Server your WebSocket library is
attached to, beside the app. Aetherfy listens on that server, so the upgrade
handler the library installed is the one that answers. The same pattern works
for ws and socket.io. index.js:
const http = require('http')
const express = require('express')
const { WebSocketServer } = require('ws')
const app = express()
app.get('/', (req, res) => {
res.json({ message: 'connect a WebSocket to /ws' })
})
// ws attaches to an http.Server, so create one around the app...
const server = http.createServer(app)
const wss = new WebSocketServer({ server, path: '/ws' })
wss.on('connection', (socket) => {
socket.on('message', (data) => socket.send('echo: ' + data))
})
// ...and export it beside the app. Aetherfy listens on THIS server, so the
// upgrade handler ws attached to it is the one that answers. Do not call
// server.listen() yourself.
module.exports = { app, server }package.json:
{
"name": "ws-agent",
"private": true,
"dependencies": {
"express": "^5.2.1",
"ws": "^8.22.0"
}
}aetherfy.yaml:
name: ws-agent
runtime: node22
type: service
memory_mb: 256npm install
afy deploy --createThe agent’s serves_websocket field, and the WebSocket badge in the dashboard,
come from that server having an upgrade handler. A fetch-style app with no
exported server reports serves_websocket: false, because nothing in it can
accept an upgrade.
Bun’s { fetch, websocket } export is refused on Aetherfy
Bun.serve accepts an object with a websocket handler beside fetch. Its
handlers are called by Bun.serve and nothing else, and Aetherfy serves through
the Node.js http server on every runtime, so those WebSockets could never
connect. Rather than deploy an app whose WebSockets are silently dead, Aetherfy
refuses the export when the agent starts, and the deploy fails with this line:
Exported app has a `websocket` handler (Bun.serve's { fetch, websocket } form), which cannot run here: Aetherfy serves through node's http server, not Bun.serve. For WebSockets, attach ws or socket.io to an http.Server and export it: module.exports = { app, server };To keep WebSockets on Bun, use the pattern above — ws runs on Bun’s
node:http — with runtime: bun and the file saved as main.ts.
Related Aetherfy pages
| Topic | Page |
|---|---|
| The service contract for every runtime, and the first deploy | /agents/quickstart |
Entrypoint defaults, lockfiles and every aetherfy.yaml field | /agents/aetherfy-yaml |
| Owning the whole container instead | /agents/dockerfile |