---
slug: agents/node-and-bun
title: Node.js and Bun services
kind: reference
surface: agents
summary: The export shapes a Node.js or Bun service agent can give Aetherfy — an Express app, a Hono app or a bare fetch handler, and a WebSocket server exported beside the app — each with a complete project you can deploy as is, and the one shape Aetherfy refuses.
sources:
  - aetherfy-control-plane:orchestrator/image_generator.py
  - aetherfy-control-plane:orchestrator/service_export_check.py
  - aetherfy-control-plane:orchestrator/fly_builder.py
  - aetherfy-control-plane:shared/config_parser.py
  - aetherfy-control-plane:supervisor/service.go
  - aetherfy-control-plane:shared/module_load_probe.py
---

# 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](/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`:

```typescript
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`:

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

`aetherfy.yaml`:

```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:

```bash
bun install
afy deploy --create
```

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

```bash
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`:

```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`:

```json
{
  "name": "fetch-agent",
  "private": true
}
```

`aetherfy.yaml`:

```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:

```bash
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`:

```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`:

```json
{
  "name": "express-agent",
  "private": true,
  "dependencies": {
    "express": "^5.2.1"
  }
}
```

`aetherfy.yaml`:

```yaml
name: express-agent
runtime: node22
type: service
memory_mb: 256
```

```bash
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`:

```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`:

```json
{
  "name": "ws-agent",
  "private": true,
  "dependencies": {
    "express": "^5.2.1",
    "ws": "^8.22.0"
  }
}
```

`aetherfy.yaml`:

```yaml
name: ws-agent
runtime: node22
type: service
memory_mb: 256
```

```bash
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`.

## Related Aetherfy pages

| Topic | Page |
|---|---|
| The service contract for every runtime, and the first deploy | [/agents/quickstart](/agents/quickstart) |
| Entrypoint defaults, lockfiles and every `aetherfy.yaml` field | [/agents/aetherfy-yaml](/agents/aetherfy-yaml) |
| Owning the whole container instead | [/agents/dockerfile](/agents/dockerfile) |
