Skip to Content
Agent computeNode.js and Bun services
Raw

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 exportExampleHow Aetherfy serves it
A request handler (req, res) — an Express appmodule.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 appEach request becomes a Web Request; the Response you return is written back, streamed
An app plus the http.Server a WebSocket library is attached tomodule.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 app

package.json:

{ "name": "hono-agent", "private": true, "dependencies": { "hono": "^4.13.10" } }

aetherfy.yaml:

name: hono-agent runtime: bun type: service memory_mb: 256

Aetherfy requires a lockfile beside package.json, so run bun install once to write bun.lock, then deploy:

bun install afy deploy --create

Watch the events arrive one a second on the agent’s address, which afy list shows:

curl -N https://hono-agent-k3m7x2.aetherfy.dev/events

A 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: 256

The project has no dependencies, but a package.json still needs its lockfile on Aetherfy — npm install writes one:

npm install afy deploy --create

On 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: 256
npm install afy deploy --create

WebSockets 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: 256
npm install afy deploy --create

The 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.

TopicPage
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
Last updated on