@ayepi/node 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.
package/README.md CHANGED
@@ -31,7 +31,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
31
31
 
32
32
  - [`ayepi-node.md`](./ayepi-node.md)
33
33
 
34
- They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/node) and are **not** shipped in the npm tarball.
34
+ They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/node).
35
35
 
36
36
  ## License
37
37
 
package/ayepi-node.md ADDED
@@ -0,0 +1,315 @@
1
+ <!--
2
+ ayepi-node.md — reference for `@ayepi/node`, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/node` (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/node`
11
+
12
+ `@ayepi/node` is a thin Node.js adapter for an [`@ayepi/core`](./ayepi-core.md)
13
+ `Server`. It bridges the `node:http` `IncomingMessage`/`ServerResponse` world to
14
+ the web-standard `Request`/`Response` that ayepi core speaks, and serves
15
+ WebSocket upgrades via the [`ws`](https://github.com/websockets/ws) package wired
16
+ to `app.ws.open`/`message`/`close`. Reach for it when you have an ayepi app
17
+ (built with `server(...)` from core) and want to run it on a real Node HTTP port
18
+ — with streaming bodies, backpressure, client-disconnect aborts, and WebSocket
19
+ transport all handled for you. This doc only covers **serving on Node**; for the
20
+ API itself (`spec`, `endpoint`, `server`, `client`, the `Server` surface), see
21
+ [`ayepi-core.md`](./ayepi-core.md).
22
+
23
+ ```sh
24
+ pnpm add @ayepi/node @ayepi/core ws
25
+ ```
26
+
27
+ `ws` is a direct runtime dependency of `@ayepi/node` (it ships in its
28
+ `dependencies`), so `pnpm add @ayepi/node` already pulls it in transitively.
29
+ Listing `ws` explicitly is only needed if your own code imports it (the tests do,
30
+ e.g. `import WebSocket from 'ws'` for a client). `@ayepi/core` is a peer
31
+ dependency you must install. Requires Node `>=18`. HTTP/1.1 only for v0.
32
+
33
+ ## Public API
34
+
35
+ The package exports three functions and one options interface. Everything below
36
+ is the complete public surface — internal bridging helpers (`toRequest`,
37
+ `sendResponse`, `whenWritable`) are not exported.
38
+
39
+ ### `serve(app, opts)`
40
+
41
+ Boot an ayepi app on a real HTTP + WebSocket port. This is the one function most
42
+ apps need.
43
+
44
+ ```ts
45
+ function serve(app: Server<AnySpec>, opts: ServeOptions): () => Promise<void>
46
+ ```
47
+
48
+ - `app` — an ayepi `Server` (the return value of `server(spec, handlers)` from
49
+ `@ayepi/core`).
50
+ - `opts` — see [`ServeOptions`](#serveoptions) below.
51
+ - **Returns** a `close()` function: `() => Promise<void>`. Calling it stops
52
+ accepting new connections, **terminates** every live WebSocket
53
+ (`ws.terminate()`), closes the `WebSocketServer`, then closes the HTTP server,
54
+ and resolves once shutdown completes (rejects if `server.close` errors).
55
+
56
+ Internally `serve` wires `createRequestListener(app)` as the HTTP listener and
57
+ `handleUpgrade(app, server, opts.path)` for WebSocket upgrades, then calls
58
+ `server.listen(opts.port, hostname, …)`.
59
+
60
+ ### `ServeOptions`
61
+
62
+ ```ts
63
+ interface ServeOptions {
64
+ /** TCP port to listen on. */
65
+ readonly port: number;
66
+ /** Interface to bind (default: all interfaces). */
67
+ readonly hostname?: string;
68
+ /**
69
+ * Restrict WebSocket upgrades to this pathname (e.g. '/ws'). When omitted,
70
+ * upgrades are accepted on any path.
71
+ */
72
+ readonly path?: string;
73
+ /** Called once the server is listening. */
74
+ readonly onListen?: (info: { port: number; hostname: string }) => void;
75
+ }
76
+ ```
77
+
78
+ | Option | Type | Required | Default | Notes |
79
+ |------------|-------------------------------------------------------|----------|----------------------|-------|
80
+ | `port` | `number` | yes | — | Pass `0` to let the OS pick a free port; read the real port from `onListen`. |
81
+ | `hostname` | `string` | no | `'0.0.0.0'` | The interface to bind. `'127.0.0.1'` for localhost-only. |
82
+ | `path` | `string` | no | (any path) | When set, only WebSocket upgrades on this exact pathname are accepted; others have their socket destroyed. Does **not** affect HTTP routing. |
83
+ | `onListen` | `(info: { port: number; hostname: string }) => void` | no | — | Fires once the server is listening. `info.port` is the **actual** bound port (resolved from `server.address()`), which matters when `port: 0`. `info.hostname` echoes the bound hostname. |
84
+
85
+ ### `createRequestListener(app)`
86
+
87
+ Create a `node:http` request listener for an ayepi app — useful for mounting on
88
+ an existing server, behind a proxy, or alongside other (non-ayepi) routes.
89
+
90
+ ```ts
91
+ function createRequestListener(
92
+ app: Server<AnySpec>,
93
+ ): (req: http.IncomingMessage, res: http.ServerResponse) => void
94
+ ```
95
+
96
+ Each request gets an `AbortController` whose signal aborts when the client
97
+ disconnects before the response finishes; that signal is the `signal` your
98
+ handlers receive. On a thrown error it responds `500` with
99
+ `{ error: { code: 'INTERNAL', message } }` if headers haven't been sent yet,
100
+ otherwise it just ends the (already-started) response.
101
+
102
+ ### `handleUpgrade(app, server, path?)`
103
+
104
+ Attach an ayepi app's WebSocket handling to an existing `http.Server`'s
105
+ `upgrade` event.
106
+
107
+ ```ts
108
+ function handleUpgrade(
109
+ app: Server<AnySpec>,
110
+ server: http.Server,
111
+ path?: string,
112
+ ): WebSocketServer
113
+ ```
114
+
115
+ - Creates a `WebSocketServer({ noServer: true })` and listens on the server's
116
+ `'upgrade'` event.
117
+ - `path` — when set, only upgrades whose pathname equals `path` are accepted;
118
+ others have their socket destroyed.
119
+ - **Returns** the underlying `ws` `WebSocketServer` — call `.close()` to stop
120
+ accepting upgrades.
121
+
122
+ Use `createRequestListener` + `handleUpgrade` together when you need to build the
123
+ `http.Server` yourself (custom TLS, mounting alongside other routes, etc.); use
124
+ `serve` when you don't.
125
+
126
+ ## Examples
127
+
128
+ ### Minimal serve
129
+
130
+ ```ts
131
+ import { serve } from '@ayepi/node'
132
+ import { app } from './app' // your `server(spec, handlers)` from @ayepi/core
133
+
134
+ const close = serve(app, { port: 3000 })
135
+ ```
136
+
137
+ ### Custom port, hostname, and a WebSocket path
138
+
139
+ ```ts
140
+ const close = serve(app, {
141
+ port: 8080,
142
+ hostname: '127.0.0.1', // localhost only
143
+ path: '/ws', // WS upgrades only on /ws
144
+ onListen: ({ port, hostname }) => console.log(`listening on http://${hostname}:${port}`),
145
+ })
146
+ ```
147
+
148
+ ### Ephemeral port (let the OS choose)
149
+
150
+ ```ts
151
+ serve(app, {
152
+ port: 0,
153
+ onListen: ({ port }) => {
154
+ // `port` here is the real bound port, not 0
155
+ console.log(`http://127.0.0.1:${port}`)
156
+ },
157
+ })
158
+ ```
159
+
160
+ ### Graceful shutdown
161
+
162
+ ```ts
163
+ const close = serve(app, { port: 3000, path: '/ws' })
164
+ process.on('SIGTERM', () => void close())
165
+ process.on('SIGINT', () => void close())
166
+ ```
167
+
168
+ `close()` returns a promise, so you can `await` it before exiting:
169
+
170
+ ```ts
171
+ process.on('SIGTERM', async () => {
172
+ await close()
173
+ process.exit(0)
174
+ })
175
+ ```
176
+
177
+ ### Mount on an existing `http.Server`
178
+
179
+ ```ts
180
+ import http from 'node:http'
181
+ import { createRequestListener, handleUpgrade } from '@ayepi/node'
182
+
183
+ const server = http.createServer(createRequestListener(app))
184
+ const wss = handleUpgrade(app, server, '/ws')
185
+ server.listen(3000)
186
+
187
+ // to stop:
188
+ // wss.close(); server.close()
189
+ ```
190
+
191
+ ### Talking to it over WebSocket
192
+
193
+ The upgraded socket carries ayepi's own wire protocol. With the core client +
194
+ `wsTransport`, point it at the same `path`:
195
+
196
+ ```ts
197
+ import { client, wsTransport } from '@ayepi/core'
198
+
199
+ const sdk = client<typeof api>({
200
+ baseUrl: 'http://127.0.0.1:3000',
201
+ manifest: api,
202
+ ws: wsTransport('ws://127.0.0.1:3000/ws'),
203
+ })
204
+
205
+ await sdk.call('getUser', { id: 'u9' }, { transport: 'ws' })
206
+ ```
207
+
208
+ Raw frames work too — a unary call frame looks like
209
+ `{ id, type: '/getUser/:id', method: 'POST', data: { id: 'u1' } }` and the reply
210
+ echoes the `id`. See the core docs for the full WS protocol.
211
+
212
+ ## How it works under the hood
213
+
214
+ ### `IncomingMessage` → `Request`
215
+
216
+ `toRequest(req, signal)` builds a web `Request`:
217
+
218
+ - **URL** is reconstructed as `${proto}://${host}${req.url}`, where `proto` is
219
+ `https` if the socket is encrypted (`socket.encrypted`) else `http`, and `host`
220
+ comes from the `host` header (falling back to `localhost`).
221
+ - **Headers** are copied into a `Headers` object; array-valued Node headers are
222
+ `append`ed entry-by-entry so repeats survive.
223
+ - **Body** is attached only for methods other than `GET`/`HEAD`. It's wired as a
224
+ streaming `ReadableStream` via `Readable.toWeb(req)`, with `duplex: 'half'`
225
+ set on the `RequestInit` (required by undici when streaming a request body).
226
+ This means request bodies are **not buffered** — they stream into your handler
227
+ as the client sends them (e.g. a `MediaRecorder` → `fetch` audio upload, or an
228
+ NDJSON frame stream).
229
+ - The per-request **`AbortSignal`** is passed through, so a client disconnect
230
+ surfaces as the `signal` your handler sees.
231
+
232
+ ### `Response` → `ServerResponse`
233
+
234
+ `sendResponse(res, response)` streams the fetch `Response` back out:
235
+
236
+ - Sets `res.statusCode` from `response.status`.
237
+ - Copies response headers, with special handling for **`set-cookie`**: it uses
238
+ `response.headers.getSetCookie()` so multiple `Set-Cookie` values are preserved
239
+ as separate headers rather than being folded into one comma-joined value.
240
+ - If there's no body, calls `res.end()` immediately.
241
+ - Otherwise reads `response.body` chunk-by-chunk and writes each to `res`,
242
+ honoring **backpressure**: when `res.write(value)` returns `false`, it awaits
243
+ `whenWritable(res)` (which resolves on the next `'drain'` or on socket
244
+ `'close'`) before continuing. This keeps memory flat on large streamed
245
+ downloads.
246
+ - If the socket closes mid-stream, the reader is cancelled
247
+ (`res.once('close', …) => reader.cancel()`). If the upstream stream throws
248
+ mid-flight, the response is truncated (the error is swallowed and `res.end()`
249
+ is called in `finally`).
250
+
251
+ Because the body is streamed, all of core's streaming response shapes work over
252
+ the wire: NDJSON (`application/x-ndjson`) item streams, SSE (`text/event-stream`),
253
+ raw byte downloads with `Content-Length`/`Content-Disposition`, and HTTP `Range`
254
+ → `206` partial responses — all handled by core; the adapter just pumps bytes.
255
+
256
+ ### Client-disconnect → abort
257
+
258
+ In `createRequestListener`, each request gets an `AbortController`. A
259
+ `res.on('close')` listener fires `ac.abort()` **only if** the response didn't
260
+ finish normally (`!res.writableFinished`). That aborted signal is the same
261
+ `signal` core hands your handler, so a handler awaiting a long operation (or a
262
+ generator producing a stream) can observe the disconnect and stop.
263
+
264
+ ### WebSocket upgrades (`ws`)
265
+
266
+ `handleUpgrade` listens on the HTTP server's `'upgrade'` event:
267
+
268
+ - If `path` is set and the request pathname doesn't match, the socket is
269
+ `destroy()`ed (the upgrade is refused).
270
+ - Otherwise `wss.handleUpgrade(...)` completes the handshake, and for each opened
271
+ socket:
272
+ - The upgrade request is converted with `toRequest(req, …)` and handed to
273
+ `app.ws.open(send, request)`. Because the **upgrade `Request` (with its
274
+ headers) is passed through**, subscription guards / auth in core can
275
+ authenticate from it.
276
+ - `send` writes a frame back via `ws.send(frame)` only when
277
+ `ws.readyState === ws.OPEN`.
278
+ - Inbound `'message'` frames are stringified (`String(data)`) and forwarded to
279
+ `app.ws.message(conn, …)`.
280
+ - `'close'` and `'error'` both call `app.ws.close(conn)`.
281
+
282
+ WebSocket inbound frames are treated as **text** (`String(data)`) — the protocol
283
+ is JSON text frames, not binary.
284
+
285
+ ## Gotchas / constraints
286
+
287
+ - **HTTP/1.1 only** for v0. No HTTP/2 / HTTP/3.
288
+ - **`ws` is required for WebSockets.** Node is the one runtime without a built-in
289
+ WebSocket server, which is why this adapter depends on `ws`. It ships as a
290
+ direct dependency, so you don't normally install it yourself.
291
+ - **Default bind is `0.0.0.0`** (all interfaces). For localhost-only, set
292
+ `hostname: '127.0.0.1'` explicitly.
293
+ - **`path` only gates WebSocket upgrades**, not HTTP routing. HTTP requests are
294
+ always routed by core regardless of `path`.
295
+ - **`close()` is hard on WebSockets**: it calls `ws.terminate()` on every live
296
+ client (immediate, no close handshake), not `ws.close()`. In-flight WS work is
297
+ cut off.
298
+ - **`port: 0` picks an ephemeral port** — read the actual port from
299
+ `onListen({ port })`, not from your config. `serve` resolves it via
300
+ `server.address()`.
301
+ - **Request bodies are skipped for `GET`/`HEAD`** (per spec). Any other method
302
+ gets a streaming body with `duplex: 'half'`.
303
+ - **Errors after headers are sent** can't become a `500` — the response is
304
+ already in flight, so the adapter just ends it (truncating). Only pre-header
305
+ errors produce the `{ error: { code: 'INTERNAL', … } }` body.
306
+ - **`set-cookie` relies on `Headers.getSetCookie()`** (Node 18.14+/undici). On
307
+ the supported Node `>=18` range this is available; on very old 18.x patch
308
+ versions multiple cookies could fold incorrectly.
309
+
310
+ ## See also
311
+
312
+ - [`ayepi-core.md`](./ayepi-core.md) — the API itself: `spec`, `endpoint`,
313
+ `implement`, `server`, `client`, `wsTransport`, the `Server` surface
314
+ (`fetch`, `ws.open`/`message`/`close`, `emit`), streaming, and the WS wire
315
+ protocol. This `@ayepi/node` doc is only about running that `Server` on Node.