@ayepi/bun 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/ayepi-bun.md +227 -0
  3. 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 live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/bun) and are **not** shipped in the npm tarball.
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.1.0",
3
+ "version": "0.2.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.1.0"
41
+ "@ayepi/core": "^0.2.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.1.0"
49
+ "@ayepi/core": "0.2.0"
49
50
  },
50
51
  "keywords": [
51
52
  "ayepi",