@ayepi/bun 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/ayepi-bun.md +227 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
|
|
|
22
22
|
|
|
23
23
|
- [`ayepi-bun.md`](./ayepi-bun.md)
|
|
24
24
|
|
|
25
|
-
They
|
|
25
|
+
They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/bun).
|
|
26
26
|
|
|
27
27
|
## License
|
|
28
28
|
|
package/ayepi-bun.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ayepi-bun.md — reference for `@ayepi/bun`, written for coding agents.
|
|
3
|
+
|
|
4
|
+
Copy this file into any project that depends on `@ayepi/bun` (e.g. into your repo's
|
|
5
|
+
`docs/` or `.claude/` directory) and reference it from your agents and slash commands.
|
|
6
|
+
It documents the public API, the patterns the package expects, and how it works under the
|
|
7
|
+
hood, with copy-pasteable examples. Keep it in sync with the installed package version.
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# `@ayepi/bun`
|
|
11
|
+
|
|
12
|
+
A thin [Bun](https://bun.sh) adapter that boots an [`@ayepi/core`](./ayepi-core.md) `Server`
|
|
13
|
+
on Bun's built-in HTTP + WebSocket server. Bun is fetch-native and ships a native WebSocket
|
|
14
|
+
server, so this adapter has **zero dependencies**: HTTP requests go straight to `app.fetch`,
|
|
15
|
+
and Bun's `websocket` handlers are wired to `app.ws.open` / `message` / `close`. Use it when
|
|
16
|
+
your runtime is Bun and you want the smallest possible glue between an ayepi app and a live
|
|
17
|
+
listening socket. The app itself (endpoints, events, validation) is defined entirely with
|
|
18
|
+
`@ayepi/core` — see [`ayepi-core.md`](./ayepi-core.md); this package only owns the transport.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bun add @ayepi/bun @ayepi/core zod
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { serve } from '@ayepi/bun'
|
|
26
|
+
|
|
27
|
+
const close = serve(app, { port: 3000, path: '/ws' })
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Public API
|
|
31
|
+
|
|
32
|
+
The package exports exactly two symbols: the `serve` function and its `ServeOptions` type.
|
|
33
|
+
|
|
34
|
+
### `serve(app, opts)`
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
export function serve(app: Server<AnySpec>, opts: ServeOptions): () => void
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- **`app`** — any `@ayepi/core` `Server` (i.e. `Server<AnySpec>`), the value returned by
|
|
41
|
+
core's `server(...)`. The adapter only touches its `fetch` and `ws` surface.
|
|
42
|
+
- **`opts`** — see [`ServeOptions`](#serveoptions) below.
|
|
43
|
+
- **Returns** a `close()` function. Calling it stops the server via
|
|
44
|
+
`server.stop(true)` — i.e. Bun is told to close **active connections** too, not just stop
|
|
45
|
+
accepting new ones. Call it for graceful shutdown.
|
|
46
|
+
|
|
47
|
+
`serve` reads the ambient `Bun` global at runtime. If it is missing (you are not running
|
|
48
|
+
under Bun), it throws immediately:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
@ayepi/bun: not running under Bun (no global `Bun`)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### `ServeOptions`
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
export interface ServeOptions {
|
|
58
|
+
/** TCP port to listen on. */
|
|
59
|
+
readonly port: number;
|
|
60
|
+
/** Interface to bind. */
|
|
61
|
+
readonly hostname?: string;
|
|
62
|
+
/** Restrict WebSocket upgrades to this pathname (e.g. `'/ws'`). */
|
|
63
|
+
readonly path?: string;
|
|
64
|
+
/** Called once the server is listening. */
|
|
65
|
+
readonly onListen?: (info: { port: number; hostname: string }) => void;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Option | Type | Required | Meaning |
|
|
70
|
+
| ---------- | ------------------------------------------------------ | -------- | ------- |
|
|
71
|
+
| `port` | `number` | yes | TCP port to listen on. Use `0` to let the OS pick a free port (the real port is reported to `onListen` and is the `port` field of the value Bun returns internally). |
|
|
72
|
+
| `hostname` | `string` | no | Interface to bind (e.g. `'0.0.0.0'`, `'127.0.0.1'`). Defaults to Bun's default when omitted. |
|
|
73
|
+
| `path` | `string` | no | Restrict WebSocket upgrades to this exact pathname (e.g. `'/ws'`). When omitted, **any** path with an `Upgrade: websocket` header is upgraded. Non-WS requests are never affected by this. |
|
|
74
|
+
| `onListen` | `(info: { port: number; hostname: string }) => void` | no | Invoked once, synchronously, right after the server starts. Receives the **actual** bound `port` and `hostname` (resolved from Bun, so a `port: 0` request reports the chosen port). |
|
|
75
|
+
|
|
76
|
+
## Examples
|
|
77
|
+
|
|
78
|
+
### Minimal serve
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { serve } from '@ayepi/bun'
|
|
82
|
+
import { app } from './app' // your @ayepi/core Server
|
|
83
|
+
|
|
84
|
+
serve(app, { port: 3000 })
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Options: bind address, fixed WS path, listen callback
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
serve(app, {
|
|
91
|
+
port: 8080,
|
|
92
|
+
hostname: '0.0.0.0',
|
|
93
|
+
path: '/ws',
|
|
94
|
+
onListen: ({ port, hostname }) => console.log(`listening on ${hostname}:${port}`),
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Ephemeral port + graceful shutdown
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
let actualPort = 0
|
|
102
|
+
const close = serve(app, {
|
|
103
|
+
port: 0,
|
|
104
|
+
onListen: ({ port }) => { actualPort = port },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// later, e.g. on SIGTERM:
|
|
108
|
+
process.on('SIGTERM', () => close()) // stops the server and closes active connections
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### WebSocket
|
|
112
|
+
|
|
113
|
+
There is no extra wiring for WebSockets here — define the events on the core `spec` and the
|
|
114
|
+
adapter forwards frames automatically. Just point clients at the upgrade path:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
serve(app, { port: 3000, path: '/ws' })
|
|
118
|
+
|
|
119
|
+
// browser / Bun client:
|
|
120
|
+
const ws = new WebSocket('ws://localhost:3000/ws')
|
|
121
|
+
ws.onmessage = (e) => console.log(e.data) // core call/event frames (JSON strings)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The frame protocol (call frames, event subscriptions, reply shape) is defined and validated
|
|
125
|
+
by `@ayepi/core` — see [`ayepi-core.md`](./ayepi-core.md). The adapter is transport-only.
|
|
126
|
+
|
|
127
|
+
## How it works under the hood
|
|
128
|
+
|
|
129
|
+
`serve` calls `Bun.serve({ port, hostname, fetch, websocket })` once and returns
|
|
130
|
+
`() => server.stop(true)`.
|
|
131
|
+
|
|
132
|
+
### `fetch` — HTTP and the upgrade gate
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
fetch(req, srv) {
|
|
136
|
+
const isWs = req.headers.get('upgrade')?.toLowerCase() === 'websocket';
|
|
137
|
+
if (isWs && (opts.path === undefined || new URL(req.url).pathname === opts.path)) {
|
|
138
|
+
const data: ConnData = { req };
|
|
139
|
+
if (srv.upgrade(req, { data })) { return undefined; } // upgraded — Bun takes over
|
|
140
|
+
return new Response('WebSocket upgrade failed', { status: 400 });
|
|
141
|
+
}
|
|
142
|
+
return app.fetch(req);
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- A request is treated as a WebSocket upgrade only when the `Upgrade` header equals
|
|
147
|
+
`websocket` (case-insensitive) **and** the path matches `opts.path` (or `opts.path` is
|
|
148
|
+
unset). When `opts.path` is set, the comparison is against `new URL(req.url).pathname` —
|
|
149
|
+
an **exact** string match, no prefix or pattern matching.
|
|
150
|
+
- On a match it calls `srv.upgrade(req, { data })`, attaching a per-connection `ConnData`
|
|
151
|
+
object (`{ req, conn? }`) that carries the original upgrade `Request` into the WS handlers.
|
|
152
|
+
- If `upgrade` returns `true`, the handler returns `undefined` and Bun owns the socket.
|
|
153
|
+
- If `upgrade` returns `false`, the handler returns `400 WebSocket upgrade failed`.
|
|
154
|
+
- Anything else (normal HTTP, or a `websocket` upgrade on a non-matching path) is handed
|
|
155
|
+
straight to `app.fetch(req)` and the resulting `Response` is returned unchanged. Streaming
|
|
156
|
+
responses produced by core flow through untouched — the adapter never buffers or rewrites
|
|
157
|
+
the body.
|
|
158
|
+
|
|
159
|
+
### `websocket` — Bun's native handler object
|
|
160
|
+
|
|
161
|
+
The adapter supplies the three Bun WebSocket lifecycle handlers and bridges them to
|
|
162
|
+
`app.ws.*`. Each upgraded socket carries the `ConnData` set at upgrade time as `ws.data`.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
websocket: {
|
|
166
|
+
open(ws) {
|
|
167
|
+
const data = ws.data as ConnData;
|
|
168
|
+
data.conn = app.ws.open((frame) => {
|
|
169
|
+
if (ws.readyState === WS_OPEN) { ws.send(frame); } // WS_OPEN === 1
|
|
170
|
+
}, data.req);
|
|
171
|
+
},
|
|
172
|
+
message(ws, message) {
|
|
173
|
+
const data = ws.data as ConnData;
|
|
174
|
+
if (data.conn) {
|
|
175
|
+
void app.ws.message(
|
|
176
|
+
data.conn,
|
|
177
|
+
typeof message === 'string' ? message : new TextDecoder().decode(message),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
close(ws) {
|
|
182
|
+
const data = ws.data as ConnData;
|
|
183
|
+
if (data.conn) { app.ws.close(data.conn); }
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- **`open`** registers the connection with core via `app.ws.open(send, req)`, passing the
|
|
189
|
+
original upgrade `Request` so core can read headers/URL for auth or context. The `send`
|
|
190
|
+
callback writes a core frame back to the socket, but only when `ws.readyState === 1`
|
|
191
|
+
(`WebSocket.OPEN`), guarding against sends to a closing/closed socket. The returned
|
|
192
|
+
`WsConn` is stashed on `data.conn`.
|
|
193
|
+
- **`message`** forwards each inbound frame to `app.ws.message(conn, raw)`. Bun may deliver
|
|
194
|
+
the payload as a `string`, `ArrayBuffer`, or `Uint8Array`; binary payloads are decoded to a
|
|
195
|
+
UTF-8 string with `new TextDecoder()` before being handed to core, which always works on
|
|
196
|
+
text frames. `app.ws.message` is async; its promise is intentionally fire-and-forget
|
|
197
|
+
(`void`).
|
|
198
|
+
- **`close`** tears the connection down via `app.ws.close(conn)`, which cleans up its
|
|
199
|
+
subscriptions in core. Both `message` and `close` no-op if `data.conn` was never set (i.e.
|
|
200
|
+
a frame arrived before/without a successful `open`).
|
|
201
|
+
|
|
202
|
+
## Gotchas / constraints
|
|
203
|
+
|
|
204
|
+
- **Bun only.** `serve` throws synchronously if the `Bun` global is absent. Running it under
|
|
205
|
+
Node, Deno, or the browser fails fast with `not running under Bun`. The package's own
|
|
206
|
+
TypeScript typechecks under plain `tsc` (it declares minimal structural interfaces for the
|
|
207
|
+
Bun APIs), but it only *runs* under Bun. (Tests mock the `Bun` global to exercise the glue
|
|
208
|
+
under vitest.)
|
|
209
|
+
- **`path` is an exact match.** `path: '/ws'` upgrades only `/ws`, not `/ws/foo` or `/ws?x=1`
|
|
210
|
+
query-stripped beyond the pathname. Omit `path` to accept upgrades on any route.
|
|
211
|
+
- **`onListen` is synchronous and one-shot.** It fires immediately after `Bun.serve` returns.
|
|
212
|
+
There is no error callback; bind failures surface as a thrown error from `Bun.serve`.
|
|
213
|
+
- **`close()` is forceful.** It calls `server.stop(true)`, which closes active connections
|
|
214
|
+
(including open WebSockets) rather than draining them. There is no "stop accepting, drain
|
|
215
|
+
in-flight" mode exposed.
|
|
216
|
+
- **No options pass-through.** Only `port`, `hostname`, `path`, and `onListen` are accepted.
|
|
217
|
+
Bun-specific `Bun.serve` features (TLS, `unix` sockets, `idleTimeout`, custom
|
|
218
|
+
`websocket.perMessageDeflate`, etc.) are not surfaced by this adapter.
|
|
219
|
+
- **Binary frames are coerced to text.** Inbound binary WS messages are UTF-8 decoded before
|
|
220
|
+
reaching core; core's frame protocol is text/JSON, so binary payloads must be valid UTF-8
|
|
221
|
+
text frames.
|
|
222
|
+
|
|
223
|
+
## See also
|
|
224
|
+
|
|
225
|
+
- [`ayepi-core.md`](./ayepi-core.md) — the framework itself: `spec`, `endpoint`, `server`,
|
|
226
|
+
the `Server` interface (`fetch`, `ws`, `emit`, `manifest`, `openapi`, `asyncapi`), the
|
|
227
|
+
WebSocket frame protocol, and validation. This adapter documents only the Bun transport.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ayepi/bun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Bun adapter for @ayepi/core — fetch-native HTTP + native WebSocket, zero dependencies",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"type": "module",
|
|
19
19
|
"sideEffects": false,
|
|
20
20
|
"files": [
|
|
21
|
-
"dist"
|
|
21
|
+
"dist",
|
|
22
|
+
"ayepi-*.md"
|
|
22
23
|
],
|
|
23
24
|
"exports": {
|
|
24
25
|
".": {
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
"bun": ">=1.0.0"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
|
-
"@ayepi/core": "^0.
|
|
41
|
+
"@ayepi/core": "^0.3.0"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@vitest/coverage-v8": "^2.1.8",
|
|
@@ -45,7 +46,7 @@
|
|
|
45
46
|
"tsdown": "^0.12.0",
|
|
46
47
|
"vitest": "^2.1.8",
|
|
47
48
|
"zod": "^4.4.3",
|
|
48
|
-
"@ayepi/core": "0.
|
|
49
|
+
"@ayepi/core": "0.3.0"
|
|
49
50
|
},
|
|
50
51
|
"keywords": [
|
|
51
52
|
"ayepi",
|