@ayepi/deno 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-deno.md +224 -0
  3. package/package.json +5 -4
package/README.md CHANGED
@@ -19,7 +19,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
19
19
 
20
20
  - [`ayepi-deno.md`](./ayepi-deno.md)
21
21
 
22
- They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/deno) and are **not** shipped in the npm tarball.
22
+ They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/deno).
23
23
 
24
24
  ## License
25
25
 
package/ayepi-deno.md ADDED
@@ -0,0 +1,224 @@
1
+ <!--
2
+ ayepi-deno.md — reference for `@ayepi/deno`, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/deno` (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/deno`
11
+
12
+ `@ayepi/deno` is a thin [Deno](https://deno.com) adapter that boots an
13
+ [`@ayepi/core`](./ayepi-core.md) `Server` on Deno's built-in HTTP and WebSocket
14
+ runtime. Deno is fetch-native and upgrades WebSockets with the built-in
15
+ `Deno.upgradeWebSocket`, so this adapter has **zero dependencies** — plain HTTP
16
+ requests go straight to `app.fetch(req)`, and an upgraded socket is wired to the
17
+ `app.ws.open` / `message` / `close` lifecycle hooks. Use it when your ayepi server
18
+ runs under Deno (Deploy, `deno run`, etc.). It does **not** define your API — you
19
+ build the `Server` with `@ayepi/core` and hand it to `serve`.
20
+
21
+ Import it under Deno via the `npm:` specifier (see the package README):
22
+
23
+ ```ts
24
+ import { serve } from 'npm:@ayepi/deno'
25
+ import { server } from 'npm:@ayepi/core'
26
+ ```
27
+
28
+ The `Deno` global is read at runtime, so this package typechecks under plain
29
+ `tsc` in a Node toolchain but must be **executed under Deno** (>= 1.40.0).
30
+
31
+ ## Public API
32
+
33
+ The package exports exactly two symbols: the `serve` function and its
34
+ `ServeOptions` type. Nothing else is public.
35
+
36
+ ### `serve(app, opts)`
37
+
38
+ ```ts
39
+ export function serve(app: Server<AnySpec>, opts: ServeOptions): () => Promise<void>
40
+ ```
41
+
42
+ Boots `app` on Deno's built-in HTTP + WebSocket server (`Deno.serve`). Throws
43
+ immediately if there is no global `Deno` (i.e. you are not running under Deno):
44
+
45
+ ```
46
+ @ayepi/deno: not running under Deno (no global `Deno`)
47
+ ```
48
+
49
+ - `app` — any `@ayepi/core` `Server` (`Server<AnySpec>`). See
50
+ [`ayepi-core.md`](./ayepi-core.md) for how to build one with `server(...)`.
51
+ - `opts` — a `ServeOptions` object (below).
52
+
53
+ **Returns** a `close()` function: `() => Promise<void>`. Calling it invokes the
54
+ underlying `Deno.serve` handle's `shutdown()` and resolves once the server has
55
+ finished shutting down. Use it for graceful shutdown.
56
+
57
+ ### `ServeOptions`
58
+
59
+ ```ts
60
+ export interface ServeOptions {
61
+ /** TCP port to listen on. */
62
+ readonly port: number
63
+ /** Interface to bind. */
64
+ readonly hostname?: string
65
+ /** Restrict WebSocket upgrades to this pathname (e.g. '/ws'). */
66
+ readonly path?: string
67
+ /** Called once the server is listening. */
68
+ readonly onListen?: (info: { port: number; hostname: string }) => void
69
+ }
70
+ ```
71
+
72
+ | Option | Type | Required | Behavior |
73
+ | ---------- | ----------------------------------------------------- | -------- | -------- |
74
+ | `port` | `number` | yes | TCP port passed to `Deno.serve`. Use `0` to let the OS pick a free port. |
75
+ | `hostname` | `string` | no | Interface to bind. Passed through to `Deno.serve`; Deno's default applies when omitted. |
76
+ | `path` | `string` | no | If set, only requests whose `URL.pathname` equals this value are upgraded to WebSocket; all other requests fall through to `app.fetch`. If omitted, **any** request carrying an `upgrade: websocket` header is upgraded, regardless of path. |
77
+ | `onListen` | `(info: { port: number; hostname: string }) => void` | no | Forwarded to `Deno.serve`'s `onListen`. Fires once when the server is listening; `info.port` reflects the actual bound port (useful with `port: 0`). |
78
+
79
+ ## Examples
80
+
81
+ ### Minimal serve
82
+
83
+ ```ts
84
+ import { serve } from 'npm:@ayepi/deno'
85
+ import { server, spec, endpoint, implement } from 'npm:@ayepi/core'
86
+ import { z } from 'npm:zod'
87
+
88
+ const api = spec({
89
+ endpoints: {
90
+ getUser: endpoint({
91
+ params: z.object({ id: z.string() }),
92
+ response: z.object({ id: z.string(), name: z.string() }),
93
+ }),
94
+ },
95
+ })
96
+
97
+ const app = server(api, [
98
+ implement(api).handlers({
99
+ getUser: ({ data }) => ({ id: data.id, name: `u-${data.id}` }),
100
+ }),
101
+ ])
102
+
103
+ serve(app, { port: 3000 })
104
+ ```
105
+
106
+ ### Options: bind a host, restrict WS path, log on listen
107
+
108
+ ```ts
109
+ serve(app, {
110
+ port: 8080,
111
+ hostname: '0.0.0.0',
112
+ path: '/ws', // only /ws upgrades; everything else → app.fetch
113
+ onListen: ({ hostname, port }) => {
114
+ console.log(`listening on http://${hostname}:${port}`)
115
+ },
116
+ })
117
+ ```
118
+
119
+ ### Ephemeral port
120
+
121
+ ```ts
122
+ serve(app, {
123
+ port: 0, // OS picks a free port
124
+ onListen: ({ port }) => console.log('bound to', port),
125
+ })
126
+ ```
127
+
128
+ ### Graceful shutdown
129
+
130
+ ```ts
131
+ const close = serve(app, { port: 3000, path: '/ws' })
132
+
133
+ // e.g. on SIGINT/SIGTERM
134
+ Deno.addSignalListener('SIGINT', async () => {
135
+ await close() // resolves once the server has fully shut down
136
+ Deno.exit(0)
137
+ })
138
+ ```
139
+
140
+ ### WebSocket
141
+
142
+ WebSocket handling is entirely automatic — there is no WS-specific API on the
143
+ adapter. Define your events/streams in the spec and connect from any client; the
144
+ adapter routes frames through `app.ws.*` for you:
145
+
146
+ ```ts
147
+ // server: just enable WS upgrades on a path
148
+ serve(app, { port: 3000, path: '/ws' })
149
+ ```
150
+
151
+ ```ts
152
+ // client (browser / Deno): connect to the same path
153
+ const ws = new WebSocket('ws://localhost:3000/ws')
154
+ // ayepi call frames are sent/received as JSON text frames; use the
155
+ // @ayepi/core client to drive this — see ayepi-core.md.
156
+ ```
157
+
158
+ ## How it works under the hood
159
+
160
+ `serve` is glue between Deno's runtime and the `@ayepi/core` `Server` interface
161
+ (`app.fetch` plus the `app.ws` lifecycle hooks). The full flow:
162
+
163
+ 1. **Reads the `Deno` global** at call time. If absent, it throws
164
+ `@ayepi/deno: not running under Deno`.
165
+ 2. **Registers one `Deno.serve` handler** with `{ port, hostname, onListen }`.
166
+ The `onListen` callback is forwarded straight through.
167
+ 3. **Per-request branch.** The handler inspects the incoming `Request`:
168
+ - It is treated as a WebSocket upgrade when the `upgrade` header equals
169
+ `websocket` (case-insensitive) **and** (`opts.path` is unset **or**
170
+ `new URL(req.url).pathname === opts.path`).
171
+ - Otherwise the request is forwarded to `app.fetch(req)`, which returns the
172
+ `Promise<Response>` (this is the entire HTTP surface — REST endpoints,
173
+ streaming responses, OpenAPI, etc., all handled by core).
174
+ 4. **WebSocket upgrade.** For an upgrade, it calls `Deno.upgradeWebSocket(req)`,
175
+ which yields `{ socket, response }`. The adapter returns `response` (the 101
176
+ switching-protocols response) and wires the native socket's events:
177
+ - `socket.onopen` → `conn = app.ws.open(send, req)`, where `send` writes a
178
+ frame back via `socket.send(frame)` — but only while
179
+ `socket.readyState === 1` (`WebSocket.OPEN`), so frames emitted after the
180
+ socket closes are silently dropped.
181
+ - `socket.onmessage` → `app.ws.message(conn, String(ev.data))`. The frame data
182
+ is coerced to a string; the returned promise is fire-and-forgotten (`void`).
183
+ - `socket.onclose` and `socket.onerror` → both call `app.ws.close(conn)`,
184
+ which tears down the connection's subscriptions in core. An error is treated
185
+ as a close.
186
+ - All four handlers guard on `conn` being non-null, so events that race ahead
187
+ of `onopen` are ignored.
188
+ 5. **Streaming.** The adapter does nothing special for streaming — it returns the
189
+ `Response` from `app.fetch` verbatim. Streaming response bodies (e.g.
190
+ `ReadableStream`) are produced by core and streamed by Deno's HTTP server
191
+ natively.
192
+ 6. **Shutdown.** The returned `close()` awaits the `Deno.serve` handle's
193
+ `shutdown()`.
194
+
195
+ ## Gotchas / constraints
196
+
197
+ - **Must run under Deno.** Importing the package is fine anywhere, but calling
198
+ `serve` without a global `Deno` throws. There is no Node fallback — use the
199
+ Node adapter for Node.
200
+ - **`npm:` specifier.** Under Deno, import as `npm:@ayepi/deno` (and
201
+ `npm:@ayepi/core`), not a bare specifier, unless you have an import map.
202
+ - **`path` is an exact pathname match.** `path: '/ws'` matches only `/ws`, not
203
+ `/ws/` or `/ws/foo`. Query strings are ignored (only `URL.pathname` is
204
+ compared). Omit `path` to upgrade any path that sends an upgrade header.
205
+ - **Upgrade detection is header-based.** A request is upgraded purely on the
206
+ `upgrade: websocket` header (plus the optional path filter). A request to your
207
+ WS path *without* that header falls through to `app.fetch`.
208
+ - **Text frames only.** Inbound frame data is passed through `String(...)`; the
209
+ adapter assumes ayepi's JSON text-frame protocol. Binary frames are stringified,
210
+ which is not meaningful for this protocol.
211
+ - **Post-close sends are dropped, not errors.** Because `send` checks
212
+ `readyState === OPEN`, a late `app.ws` emit after the socket closed is a no-op
213
+ rather than a throw.
214
+ - **`onerror` closes the connection.** A socket error is treated identically to a
215
+ clean close (`app.ws.close(conn)`); there is no separate error callback exposed.
216
+ - **No CORS/middleware here.** Those live in `@ayepi/core`'s `server(...)` options
217
+ — this adapter is transport only.
218
+
219
+ ## See also
220
+
221
+ - [`ayepi-core.md`](./ayepi-core.md) — the actual API: `spec`, `endpoint`,
222
+ `implement`, `server`, the `Server` interface (`fetch`, `ws`, `emit`,
223
+ `openapi`, `asyncapi`), and the WebSocket call-frame protocol. This adapter
224
+ only transports what core defines.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/deno",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Deno 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
  "deno": ">=1.40.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",