@carlos-tzin/tzin 0.1.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 (58) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +348 -0
  4. package/dist/bun.d.ts +9 -0
  5. package/dist/bun.js +46 -0
  6. package/dist/bus.d.ts +27 -0
  7. package/dist/bus.js +42 -0
  8. package/dist/channels.d.ts +14 -0
  9. package/dist/channels.js +99 -0
  10. package/dist/cli.d.ts +1 -0
  11. package/dist/cli.js +23 -0
  12. package/dist/client-browser.d.ts +47 -0
  13. package/dist/client-browser.js +87 -0
  14. package/dist/client.d.ts +20 -0
  15. package/dist/client.js +36 -0
  16. package/dist/context.d.ts +26 -0
  17. package/dist/context.js +49 -0
  18. package/dist/contract.d.ts +71 -0
  19. package/dist/contract.js +28 -0
  20. package/dist/cors.d.ts +27 -0
  21. package/dist/cors.js +53 -0
  22. package/dist/dev-server.d.ts +1 -0
  23. package/dist/dev-server.js +30 -0
  24. package/dist/hub.d.ts +39 -0
  25. package/dist/hub.js +130 -0
  26. package/dist/index.d.ts +22 -0
  27. package/dist/index.js +22 -0
  28. package/dist/llms.d.ts +10 -0
  29. package/dist/llms.js +45 -0
  30. package/dist/mcp.d.ts +17 -0
  31. package/dist/mcp.js +166 -0
  32. package/dist/mcp_stdio.d.ts +10 -0
  33. package/dist/mcp_stdio.js +33 -0
  34. package/dist/middleware.d.ts +18 -0
  35. package/dist/middleware.js +22 -0
  36. package/dist/node.d.ts +18 -0
  37. package/dist/node.js +126 -0
  38. package/dist/openapi.d.ts +9 -0
  39. package/dist/openapi.js +85 -0
  40. package/dist/presence.d.ts +30 -0
  41. package/dist/presence.js +115 -0
  42. package/dist/provide.d.ts +13 -0
  43. package/dist/provide.js +3 -0
  44. package/dist/router.d.ts +23 -0
  45. package/dist/router.js +60 -0
  46. package/dist/schema.d.ts +4 -0
  47. package/dist/schema.js +17 -0
  48. package/dist/server.d.ts +45 -0
  49. package/dist/server.js +296 -0
  50. package/dist/sse.d.ts +11 -0
  51. package/dist/sse.js +48 -0
  52. package/dist/workers.d.ts +55 -0
  53. package/dist/workers.js +130 -0
  54. package/dist/ws-node.d.ts +3 -0
  55. package/dist/ws-node.js +36 -0
  56. package/dist/ws.d.ts +27 -0
  57. package/dist/ws.js +49 -0
  58. package/package.json +86 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — first public release
4
+
5
+ Contract-first TypeScript framework: declare a contract once, get the typed
6
+ handler, the typed client, OpenAPI 3.1, MCP tools and realtime channels.
7
+
8
+ ### Core
9
+
10
+ - `contract()` / `impl()` — flat route registry with O(1) inference per endpoint
11
+ - Extractor-style handler input: `params`, `query`, `body`, `headers`, `cookies`
12
+ (declared sections only, validated per request via TypeBox)
13
+ - Status-discriminated response unions enforced by the compiler
14
+ - `HttpError` → mapped to declared statuses; middleware can catch and transform
15
+ - Onion-style middleware with typed per-request context (`defineContext`/`ctx.require`)
16
+ - Light DI: `provide(key, value)` seeds typed singletons into request context
17
+ - Radix-trie router (9.1M lookups/s), static beats param, order-stable tiebreak
18
+
19
+ ### Runtimes
20
+
21
+ - Node (`listen()` — optimized adapter with streaming SSE + WebSockets)
22
+ - Bun (`serveBun()` — native `Bun.serve` websockets)
23
+ - Cloudflare Workers (`toWorker()`, channels inside a Durable Object via
24
+ `TzinChannels`; verified against real workerd through miniflare)
25
+
26
+ ### Realtime
27
+
28
+ - Channels mounted as ordinary routes: SSE down / POST up on every runtime,
29
+ or native WebSockets (`wsChannels` + `attachChannels` / Bun / Workers DO)
30
+ - Presence with TTL, heartbeats and sweep — ghosts disappear even after crashes
31
+ - Multi-node: wire hubs over any `MessageBus` (Redis PUBLISH/SUBSCRIBE,
32
+ Postgres LISTEN/NOTIFY, Durable Objects); no echo, lazy per-topic subscriptions
33
+ - Zero-dependency browser client: `joinChannel` with auto-heartbeat
34
+
35
+ ### AI-native
36
+
37
+ - MCP server over stdio (`startStdioMcp`) and Streamable HTTP (`{ mcp: true }`)
38
+ - `tools/list` / `tools/call` dispatch in-process through validation, middleware
39
+ and DI; HTTP errors surface as `isError` results
40
+ - `/llms.txt` + `/llms-full.txt` generated from contracts (`{ llms: true }`)
41
+ - OpenAPI 3.1 generation with zero schema conversion (TypeBox is JSON Schema)
42
+
43
+ ### Developer experience
44
+
45
+ - Typed client with status narrowing: `if (res.status === 200) res.body...`
46
+ - Dev server with hot reload printing the route table from contracts
47
+ - CORS as onion middleware: wildcard or reflected origins, allow-lists,
48
+ credentials-safe (never combines `*` with credentials), preflight
49
+ short-circuit before routing
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The tzin authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,348 @@
1
+ # tzin
2
+
3
+ **Contract-first TypeScript framework. Types that scale. Realtime built in. AI-native from day one.**
4
+
5
+ > `tzin` — from Nahuatl *-tzin*, an honorific suffix for what is valued and beloved.
6
+ > A pact between client and server, declared once.
7
+
8
+ [![CI](https://github.com/Charly921/tzin/actions/workflows/ci.yml/badge.svg)](https://github.com/Charly921/tzin/actions/workflows/ci.yml)
9
+
10
+ **Status: experimental, pre-1.0.** The core works end-to-end and the scaling thesis is
11
+ measured (see [Benchmarks](#benchmarks)), but this is not yet production software.
12
+
13
+ ```sh
14
+ npm install tzin
15
+ ```
16
+
17
+ ## Why another framework?
18
+
19
+ The TypeScript backend landscape is crowded — and still leaves real gaps:
20
+
21
+ | Gap | Evidence |
22
+ |---|---|
23
+ | Type inference collapses at scale | Hono issues [#2399](https://github.com/honojs/hono/issues/2399), [#3869](https://github.com/honojs/hono/issues/3869); chained route builders force one giant expression per app |
24
+ | No architecture in lightweight frameworks | Hono issue [#4121](https://github.com/honojs/hono/issues/4121) |
25
+ | Extractors don't exist in TS | Only Rust's Axum gets handler input right |
26
+ | OpenAPI is a bolt-on | Every TS framework translates its own schema DSL to JSON Schema at runtime or via codegen |
27
+ | AI-native toolchain | One framework ships an MCP server; the window is closing |
28
+
29
+ tzin's answer: **declare a contract once**, get everything else for free.
30
+
31
+ ```ts
32
+ import { t } from 'tzin'
33
+ import { contract, impl, createApp } from 'tzin'
34
+
35
+ const getUser = contract({
36
+ method: 'GET',
37
+ path: '/users/:id',
38
+ params: t.Object({ id: t.String() }),
39
+ responses: {
40
+ 200: t.Object({ id: t.String(), name: t.String(), tags: t.Array(t.String()) }),
41
+ 404: t.Object({ error: t.String() }),
42
+ },
43
+ })
44
+
45
+ export const getUserRoute = impl(getUser, async ({ params }) => {
46
+ const user = await findUser(params.id)
47
+ if (!user) throw new HttpError(404, 'user not found')
48
+ return { status: 200, body: user }
49
+ })
50
+ ```
51
+
52
+ From that single declaration:
53
+
54
+ - **Handler input is extracted, not guessed**: `{ params }` exists because you declared
55
+ it; add `query`, `body`, `headers` or `cookies` to the contract and they appear,
56
+ fully typed and validated per request (cookies are parsed from the header).
57
+ - **The compiler enforces your responses**: returning a shape that doesn't match the
58
+ declared `200` body is a type error. Thrown `HttpError`s map to their status.
59
+ - **OpenAPI 3.1 is free**: contracts are JSON Schema (TypeBox), so
60
+ `generateOpenApi(routes)` needs no translation layer.
61
+
62
+ ### Server
63
+
64
+ Web Standards all the way down — an app is just `fetch(req): Promise<Response>`,
65
+ testable without a socket, deployable on Node, Bun, or edge workers:
66
+
67
+ ```ts
68
+ const app = createApp([getUserRoute])
69
+
70
+ // Node
71
+ listen(app, 3000)
72
+ // Anywhere else
73
+ export default { fetch: app.fetch }
74
+ ```
75
+
76
+ ### Typed client
77
+
78
+ One call, fully inferred — statuses are a discriminated union:
79
+
80
+ ```ts
81
+ const api = client({ getUser }, 'https://api.example.com')
82
+
83
+ const res = await api.getUser({ params: { id: 'u1' } })
84
+ if (res.status === 200) {
85
+ res.body.name // string
86
+ } else {
87
+ res.body.error // string
88
+ }
89
+ ```
90
+
91
+ ## Benchmarks
92
+
93
+ 100 endpoints with distinct schemas, checked by `tsc --extendedDiagnostics`
94
+ (generate with `node scripts/gen-fixtures.mjs <N>`):
95
+
96
+ | N routes | tzin: types | Hono: types | tzin: instantiations | Hono: instantiations | Check tzin / hono |
97
+ |---|---|---|---|---|---|
98
+ | 20 | 4,851 | 72,368 | 22,472 | 156,054 | 0.25s / 0.31s |
99
+ | 100 | 15,779 | 111,952 | 93,128 | 240k | 0.53s / 0.55s |
100
+ | 300 | 43,099 | 210,912 | 270k | 590k | 1.13s / 1.34s |
101
+
102
+ tzin grows **strictly linearly** (~130 types/endpoint). Two structural findings about
103
+ the chained-builder model this measures against:
104
+
105
+ 1. Route chains must live in a **single expression** — write routes as separate
106
+ statements and `typeof app` silently loses every route (intermediate types only
107
+ flow through the chain).
108
+ 2. Registering the same path twice **silently degrades client typing**.
109
+
110
+ A flat registry of plain objects has neither failure mode by construction.
111
+
112
+ *Honest caveat: the Hono fixture returns constant JSON without validators; the
113
+ documented blow-ups compound further when `zValidator` inference enters the chain.*
114
+
115
+ ### Runtime throughput
116
+
117
+ 100 routes · `GET /r50/item` · 64 connections · 5s (`npm run bench:http`, autocannon,
118
+ each framework in its own process, 3 rotated rounds, median — order rotates so
119
+ machine drift can't bias any variant; tzin runs from built `dist/`):
120
+
121
+ | Framework | req/s | p99 |
122
+ |---|---|---|
123
+ | raw node:http (floor) | ~42k | 2–3ms |
124
+ | hono | ~34–36k | 3ms |
125
+ | tzin | ~30–31k | 6ms |
126
+ | express | ~15k | 7ms |
127
+
128
+ The honest decomposition (`npm run bench:pipeline`, in-process `app.fetch` with
129
+ identical request construction):
130
+
131
+ | Metric | tzin | hono |
132
+ |---|---|---|
133
+ | in-process dispatch | ~127k req/s | ~118k req/s |
134
+
135
+ Framework-side, tzin matches hono (routing is a radix trie at 9.1M lookups/s,
136
+ full TypeBox validation costs ≈0.05µs/request). The Node adapter duck-types
137
+ requests, memoizes route matches per `METHOD path`, keeps the abort signal lazy
138
+ (wired only if something reads `ctx.signal`), and skips undici Response
139
+ construction entirely on the JSON hot path via `app.dispatchRaw` — tzin lands
140
+ at ~0.9x of hono over the network.
141
+
142
+ ## AI-native: every API is an MCP server
143
+
144
+ tzin ships first-class [MCP](https://modelcontextprotocol.io) support — the same
145
+ contracts that generate your OpenAPI document also expose your endpoints as tools
146
+ for AI agents:
147
+
148
+ ```ts
149
+ import { startStdioMcp } from 'tzin'
150
+
151
+ const app = createApp(routes)
152
+ startStdioMcp(app) // newline-delimited JSON-RPC on stdio
153
+ ```
154
+
155
+ - `tools/list` returns each endpoint with a real JSON Schema `inputSchema` assembled
156
+ from its declared sections — **no conversion layer**, TypeBox already is JSON Schema.
157
+ - `tools/call` dispatches **in-process** through the full app: validation, middleware
158
+ and DI all apply; HTTP errors surface as `isError` results.
159
+ - Contract-level `name` and `description` become the tool's identity; OpenAPI reuses
160
+ them as `operationId`/`description`.
161
+
162
+ Prefer HTTP? Enable the Streamable HTTP transport on the same app:
163
+
164
+ ```ts
165
+ const app = createApp(routes, { mcp: true }) // POST /mcp speaks JSON-RPC
166
+ ```
167
+
168
+ And give LLMs a map of your API straight from the contracts:
169
+
170
+ ```ts
171
+ const app = createApp(routes, {
172
+ llms: true,
173
+ openapi: true,
174
+ meta: { title: 'My API' },
175
+ })
176
+ // GET /llms.txt — index of endpoints (method, path, name, description)
177
+ // GET /llms-full.txt — same index plus every declared JSON Schema inline
178
+ // GET /openapi.json — OpenAPI 3.1 document (TypeBox == JSON Schema == OpenAPI)
179
+ ```
180
+
181
+ ## Realtime channels with presence
182
+
183
+ Phoenix-style channels, mounted as ordinary routes — SSE down, POST up, so it
184
+ runs on every runtime including Workers:
185
+
186
+ ```ts
187
+ import { Hub, Presence, channelRoutes } from 'tzin'
188
+
189
+ const hub = new Hub()
190
+ const presence = new Presence(hub, 30_000)
191
+
192
+ const app = createApp(channelRoutes(hub, { presence }))
193
+ // GET /channels/:topic?member=alice → SSE stream; presence joins
194
+ // POST /channels/:topic → { event, data } broadcast
195
+ // POST /channels/:topic/heartbeat → keep member listed (TTL-refreshed)
196
+ // POST /channels/:topic/leave → presence_diff broadcast
197
+ ```
198
+
199
+ Members that stop heartbeating are swept and announced via `presence_diff` —
200
+ ghost clients disappear even after crashes. The in-memory `Hub` is one process;
201
+ multi-node deployments wire hubs together over a message bus:
202
+
203
+ ```ts
204
+ import { Hub } from 'tzin'
205
+ import { LocalBus, type MessageBus } from 'tzin/bus'
206
+
207
+ // Any PUBLISH/SUBSCRIBE transport maps onto this 2-method interface:
208
+ const bus: MessageBus = redisPubSubAdapter // Redis, Postgres LISTEN/NOTIFY...
209
+
210
+ const nodeA = new Hub({ bus })
211
+ const nodeB = new Hub({ bus })
212
+ // A publish on node A reaches subscribers of every node; own frames are
213
+ // ignored, so there is no echo. Bus subscriptions are per-topic and lazy.
214
+ ```
215
+
216
+ Presence replicates Phoenix-style: every node merges remote join/leave/sweep
217
+ frames into its local view, so `presence_state` snapshots are complete
218
+ cluster-wide, subscribers get the full roster on connect, and ghosts left by a
219
+ dead node are expired by any surviving node's TTL sweep.
220
+
221
+ A zero-dependency client ships for browsers (and Node >= 22 with any
222
+ EventSource polyfill) — auto-heartbeat, presence events and reconnection via
223
+ the platform's EventSource:
224
+
225
+ ```ts
226
+ import { joinChannel } from 'tzin/client-browser'
227
+
228
+ const chat = joinChannel('https://api.example.com', 'lobby', { member: 'ada' })
229
+ chat.on('message', (data) => render(data))
230
+ chat.on('presence_diff', (d) => updateRoster(d))
231
+ await chat.push('message', { text: 'hello' })
232
+ ```
233
+
234
+ ### Native WebSockets
235
+
236
+ Prefer WebSockets over SSE+POST? The same Hub and Presence power a WS route —
237
+ one protocol implementation, thin per-runtime adapters:
238
+
239
+ ```ts
240
+ import { wsChannels } from 'tzin/ws'
241
+ import { attachChannels } from 'tzin/ws-node' // Node adapter (ws package)
242
+ // Bun: serve(app, port, { wsRoutes: [wsChannels(hub, { presence })] })
243
+
244
+ const server = await listen(createApp(routes), 3000)
245
+ attachChannels(server, [wsChannels(hub, { presence })])
246
+ // ws://host/channels/:topic?member=ada — frames: {type:'push'|'heartbeat'}, receive {event,data}
247
+ ```
248
+
249
+ On Cloudflare Workers, run the whole app inside a Durable Object so every
250
+ connection shares one I/O context — cross-connection broadcast then behaves
251
+ exactly like Node/Bun (workerd drops sends that come from another request's
252
+ context, which is why plain fetch handlers can't relay between sockets):
253
+
254
+ ```ts
255
+ import { toDurableWorker, toWorker, TzinChannels } from 'tzin'
256
+ import { Hub, Presence, channelRoutes, wsChannels, createApp } from 'tzin'
257
+
258
+ export { TzinChannels } // workerd discovers DO classes among exports
259
+
260
+ export default toDurableWorker(() => {
261
+ const hub = new Hub()
262
+ const presence = new Presence(hub)
263
+ const app = createApp([...routes, ...channelRoutes(hub, { presence })])
264
+ return toWorker(app, { wsRoutes: [wsChannels(hub, { presence })] })
265
+ })
266
+ ```
267
+
268
+ ```jsonc
269
+ // wrangler config additions:
270
+ "durable_objects": {
271
+ "bindings": [{ "name": "TZIN_APP", "class_name": "TzinChannels" }]
272
+ },
273
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TzinChannels"] }]
274
+ ```
275
+
276
+ Verified against real workerd via miniflare (`npm run probe:workers`):
277
+ HTTP ✓, WS upgrade ✓, roster ✓, broadcast across connections ✓, leave diff ✓.
278
+
279
+ ## Design principles
280
+
281
+ - **Contract-first, flat registry.** A route is data (`contract({...})`), not a link
282
+ in a builder chain. Inference cost stays O(1) per endpoint.
283
+ - **JSON Schema native.** TypeBox schemas double as OpenAPI with zero conversion.
284
+ - **Web Standards runtime.** `Request` in, `Response` out; adapters stay thin.
285
+ - **Extractors over magic.** Handlers receive exactly what the contract declares.
286
+ - **Errors are control flow.** Throw typed errors; the response union already knows.
287
+ - **Light DI without ceremony.** `provide(key, value)` at app level seeds typed
288
+ singletons into every request's context — handlers just `ctx.require(db)`.
289
+ Request-scoped middleware can override. No decorators, no reflection, no
290
+ container configuration files.
291
+
292
+ ## Roadmap
293
+
294
+ - [x] Spike: contracts, router, server, typed client, OpenAPI generation
295
+ - [x] Middleware composition (onion-style) with typed per-request context
296
+ - [x] Adapters: Node, Bun (verified e2e), Workers (HTTP + DO-backed WebSockets, verified via miniflare/workerd)
297
+ - [x] Streaming/SSE (`sse()` helper + `raw()` escape hatch)
298
+ - [x] Realtime: Hub (pub/sub), Presence (TTL + diffs), mountable channel routes
299
+ - [x] Native WebSockets for channels: Node + Bun verified, Workers via Durable Objects
300
+ - [x] Multi-node realtime over a `MessageBus` (Redis/Postgres/Durable Objects map onto it)
301
+ - [x] Typed client with status-discriminated unions + zero-dep browser channel client
302
+ - [x] Optional light DI layer (`provide()` → typed singleton seeds in request context)
303
+ - [x] AI-native toolchain: MCP (stdio + Streamable HTTP), OpenAPI 3.1, `/llms.txt`
304
+ - [x] Batteries started: CORS middleware, bearer-auth pattern (see `examples/todo-api.ts`)
305
+ - [ ] Phoenix-style presence replication across nodes
306
+ - [ ] `create-tzin` scaffolding
307
+
308
+ ## Development
309
+
310
+ ```sh
311
+ npm install
312
+ npm test # vitest — runtime + end-to-end client + type assertions
313
+ npm run typecheck # strict tsc across src/test/bench fixtures
314
+ ```
315
+
316
+ ### Production-shaped example
317
+
318
+ `examples/todo-api.ts` is the full story in one runnable file: bearer-token auth
319
+ as onion middleware, typed context DI between middleware and handlers,
320
+ app-scoped injected store, ownership checks, query coercion, and the generated
321
+ `/openapi.json` + `/llms.txt` + `POST /mcp` surface from the same contracts.
322
+
323
+ ```sh
324
+ npx tsx examples/todo-api.ts
325
+ TOKEN=$(curl -s localhost:4644/auth/login -H 'content-type: application/json' \
326
+ -d '{"username":"ada","password":"lovelace"}' | node -pe 'JSON.parse(require("fs").readFileSync(0)).token')
327
+ curl -s localhost:4644/todos -H "authorization: Bearer $TOKEN"
328
+ ```
329
+
330
+ ### Dev server
331
+
332
+ ```sh
333
+ npx tsx src/cli.ts dev examples/node-demo.ts --port 3000
334
+ ```
335
+
336
+ Hot-reloading server (tsx watch) that prints your route table straight from the
337
+ contracts on every reload:
338
+
339
+ ```
340
+ tzin dev · 2 routes
341
+
342
+ GET /users/:id get_user Look up a user by id
343
+ POST /users create_user
344
+ ```
345
+
346
+ ## License
347
+
348
+ MIT — see [LICENSE](./LICENSE).
package/dist/bun.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Bun adapter. NOTE: authored against Bun's documented `Bun.serve` API;
3
+ * verified locally on Bun 1.4.0 for plain HTTP (examples/bun-demo.ts).
4
+ */
5
+ import type { App } from './server.js';
6
+ import type { WsRoute } from './ws.js';
7
+ export declare function serve(app: App, port?: number, options?: {
8
+ wsRoutes?: WsRoute[];
9
+ }): void;
package/dist/bun.js ADDED
@@ -0,0 +1,46 @@
1
+ import { createMatcher } from './router.js';
2
+ export function serve(app, port = 3000, options = {}) {
3
+ const wsRoutes = options.wsRoutes ?? [];
4
+ const matcher = wsRoutes.length
5
+ ? createMatcher(wsRoutes.map((r) => ({ method: 'GET', path: r.path, route: r })))
6
+ : undefined;
7
+ Bun.serve({
8
+ port,
9
+ fetch: (req, server) => {
10
+ if (matcher && req.headers.get('upgrade')?.toLowerCase() === 'websocket' && server) {
11
+ const url = new URL(req.url);
12
+ const hit = matcher('GET', url.pathname);
13
+ if (hit && 'route' in hit) {
14
+ const upgraded = server.upgrade(req, { data: { route: hit.route, url } });
15
+ if (upgraded)
16
+ return undefined;
17
+ }
18
+ return new Response('WebSocket upgrade failed', { status: 400 });
19
+ }
20
+ return app.fetch(req);
21
+ },
22
+ ...(wsRoutes.length
23
+ ? {
24
+ websocket: {
25
+ open(ws) {
26
+ const { route, url } = ws.data;
27
+ if (!route || !url)
28
+ return;
29
+ ws.data.state = route.open((frame) => ws.send(JSON.stringify(frame)), url);
30
+ },
31
+ message(ws, message) {
32
+ const { route } = ws.data;
33
+ if (route && ws.data.state !== undefined) {
34
+ route.message(ws.data.state, String(message));
35
+ }
36
+ },
37
+ close(ws) {
38
+ const { route } = ws.data;
39
+ if (route && ws.data.state !== undefined)
40
+ route.close(ws.data.state);
41
+ },
42
+ },
43
+ }
44
+ : {}),
45
+ });
46
+ }
package/dist/bus.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Message bus abstraction for multi-node realtime.
3
+ *
4
+ * A bus moves serialized channel frames between nodes. The in-memory Hub
5
+ * stays the local delivery point on every node; the bus is what makes two
6
+ * processes behave like one deployment. Redis (PUBLISH/SUBSCRIBE), Postgres
7
+ * LISTEN/NOTIFY and Cloudflare Durable Objects all map onto this interface.
8
+ */
9
+ import { Hub } from './hub.js';
10
+ export interface MessageBus {
11
+ /** Fire-and-forget broadcast to every node subscribed to the channel. */
12
+ publish(channel: string, message: string): void;
13
+ /** Receive messages published on any node; returns an unsubscribe fn. */
14
+ subscribe(channel: string, handler: (message: string) => void): () => void;
15
+ }
16
+ /** In-process bus: wires hubs together inside one runtime (tests, single node). */
17
+ export declare class LocalBus implements MessageBus {
18
+ #private;
19
+ publish(channel: string, message: string): void;
20
+ subscribe(channel: string, handler: (message: string) => void): () => void;
21
+ }
22
+ /**
23
+ * Create `count` Hubs that behave as one logical deployment over `bus`.
24
+ * A publish on any hub reaches subscribers of every other hub, exactly as
25
+ * it would across Redis pub/sub — only the transport differs.
26
+ */
27
+ export declare function clusterHubs(bus: MessageBus, count?: number): Hub[];
package/dist/bus.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Message bus abstraction for multi-node realtime.
3
+ *
4
+ * A bus moves serialized channel frames between nodes. The in-memory Hub
5
+ * stays the local delivery point on every node; the bus is what makes two
6
+ * processes behave like one deployment. Redis (PUBLISH/SUBSCRIBE), Postgres
7
+ * LISTEN/NOTIFY and Cloudflare Durable Objects all map onto this interface.
8
+ */
9
+ import { Hub } from './hub.js';
10
+ /** In-process bus: wires hubs together inside one runtime (tests, single node). */
11
+ export class LocalBus {
12
+ #channels = new Map();
13
+ publish(channel, message) {
14
+ const set = this.#channels.get(channel);
15
+ if (!set)
16
+ return;
17
+ for (const handler of [...set])
18
+ handler(message);
19
+ }
20
+ subscribe(channel, handler) {
21
+ let set = this.#channels.get(channel);
22
+ if (!set) {
23
+ set = new Set();
24
+ this.#channels.set(channel, set);
25
+ }
26
+ set.add(handler);
27
+ return () => {
28
+ set.delete(handler);
29
+ if (set.size === 0)
30
+ this.#channels.delete(channel);
31
+ };
32
+ }
33
+ }
34
+ /**
35
+ * Create `count` Hubs that behave as one logical deployment over `bus`.
36
+ * A publish on any hub reaches subscribers of every other hub, exactly as
37
+ * it would across Redis pub/sub — only the transport differs.
38
+ */
39
+ export function clusterHubs(bus, count = 2) {
40
+ const prefix = 'tzin:ch:';
41
+ return Array.from({ length: count }, () => new Hub({ bus, channelPrefix: prefix }));
42
+ }
@@ -0,0 +1,14 @@
1
+ import { type RouteImpl } from './contract.js';
2
+ import type { Hub } from './hub.js';
3
+ import type { Presence } from './presence.js';
4
+ export interface ChannelOptions {
5
+ presence?: Presence;
6
+ }
7
+ /**
8
+ * Batteries-included realtime endpoints:
9
+ * GET /channels/:topic subscribe (SSE); ?member= enables presence
10
+ * POST /channels/:topic publish { event, data? }
11
+ * POST /channels/:topic/heartbeat presence join/refresh { member, meta? }
12
+ * POST /channels/:topic/leave presence leave { member }
13
+ */
14
+ export declare function channelRoutes(hub: Hub, options?: ChannelOptions): RouteImpl<any>[];
@@ -0,0 +1,99 @@
1
+ import { t } from './schema.js';
2
+ import { contract, impl } from './contract.js';
3
+ import { sse } from './sse.js';
4
+ /**
5
+ * Batteries-included realtime endpoints:
6
+ * GET /channels/:topic subscribe (SSE); ?member= enables presence
7
+ * POST /channels/:topic publish { event, data? }
8
+ * POST /channels/:topic/heartbeat presence join/refresh { member, meta? }
9
+ * POST /channels/:topic/leave presence leave { member }
10
+ */
11
+ export function channelRoutes(hub, options = {}) {
12
+ const presence = options.presence;
13
+ const subscribe = contract({
14
+ name: 'subscribe_channel',
15
+ description: 'Subscribe to a channel via SSE. Pass ?member=NAME to appear in presence.',
16
+ method: 'GET',
17
+ path: '/channels/:topic',
18
+ params: t.Object({ topic: t.String() }),
19
+ query: t.Object({ member: t.Optional(t.String()) }),
20
+ responses: {
21
+ 200: t.Object({ ok: t.Boolean() }),
22
+ 404: t.Object({ error: t.String() }),
23
+ },
24
+ });
25
+ const publish = contract({
26
+ name: 'publish_channel',
27
+ description: 'Broadcast an event to every subscriber of a channel.',
28
+ method: 'POST',
29
+ path: '/channels/:topic',
30
+ params: t.Object({ topic: t.String() }),
31
+ body: t.Object({ event: t.String(), data: t.Optional(t.Unknown()) }),
32
+ responses: { 200: t.Object({ delivered: t.Number() }) },
33
+ });
34
+ const heartbeat = contract({
35
+ name: 'channel_heartbeat',
36
+ description: 'Join a channel or refresh presence for a member.',
37
+ method: 'POST',
38
+ path: '/channels/:topic/heartbeat',
39
+ params: t.Object({ topic: t.String() }),
40
+ body: t.Object({ member: t.String(), meta: t.Optional(t.Unknown()) }),
41
+ responses: { 200: t.Object({ ok: t.Boolean() }) },
42
+ });
43
+ const leave = contract({
44
+ name: 'channel_leave',
45
+ description: 'Leave a channel; triggers a presence_diff broadcast.',
46
+ method: 'POST',
47
+ path: '/channels/:topic/leave',
48
+ params: t.Object({ topic: t.String() }),
49
+ body: t.Object({ member: t.String() }),
50
+ responses: { 200: t.Object({ ok: t.Boolean() }) },
51
+ });
52
+ const routes = [
53
+ impl(subscribe, ({ params, query, ctx }) => {
54
+ const topic = params.topic;
55
+ const member = query?.member;
56
+ return sse(async (send) => {
57
+ send.comment(`subscribed ${topic}`);
58
+ const unsub = hub.subscribe(topic, (e) => send.event(e.event, e.data));
59
+ if (member && presence)
60
+ presence.join(topic, member);
61
+ // Initial full view (Phoenix parity): anonymous subscribers get the
62
+ // room state here; members just got their join broadcast above.
63
+ if (presence)
64
+ send.event('presence_state', { members: presence.snapshot(topic) });
65
+ const cleanup = () => {
66
+ unsub();
67
+ if (member && presence)
68
+ presence.leave(topic, member);
69
+ };
70
+ const signal = ctx.signal;
71
+ if (!signal) {
72
+ return;
73
+ }
74
+ if (signal.aborted) {
75
+ cleanup();
76
+ return;
77
+ }
78
+ await new Promise((resolve) => signal.addEventListener('abort', () => {
79
+ cleanup();
80
+ resolve();
81
+ }, { once: true }));
82
+ }, ctx.signal);
83
+ }),
84
+ impl(publish, ({ params, body }) => ({
85
+ status: 200,
86
+ body: { delivered: hub.publish(params.topic, body.event, body.data ?? null) },
87
+ })),
88
+ ];
89
+ if (presence) {
90
+ routes.push(impl(heartbeat, ({ params, body }) => {
91
+ presence.heartbeat(params.topic, body.member, body.meta);
92
+ return { status: 200, body: { ok: true } };
93
+ }), impl(leave, ({ params, body }) => {
94
+ presence.leave(params.topic, body.member);
95
+ return { status: 200, body: { ok: true } };
96
+ }));
97
+ }
98
+ return routes;
99
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};