@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 +1 -1
- package/ayepi-node.md +315 -0
- package/dist/index.d.cts +2 -604
- package/dist/index.d.ts +2 -604
- package/package.json +5 -4
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
|
|
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.
|