@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.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +348 -0
- package/dist/bun.d.ts +9 -0
- package/dist/bun.js +46 -0
- package/dist/bus.d.ts +27 -0
- package/dist/bus.js +42 -0
- package/dist/channels.d.ts +14 -0
- package/dist/channels.js +99 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +23 -0
- package/dist/client-browser.d.ts +47 -0
- package/dist/client-browser.js +87 -0
- package/dist/client.d.ts +20 -0
- package/dist/client.js +36 -0
- package/dist/context.d.ts +26 -0
- package/dist/context.js +49 -0
- package/dist/contract.d.ts +71 -0
- package/dist/contract.js +28 -0
- package/dist/cors.d.ts +27 -0
- package/dist/cors.js +53 -0
- package/dist/dev-server.d.ts +1 -0
- package/dist/dev-server.js +30 -0
- package/dist/hub.d.ts +39 -0
- package/dist/hub.js +130 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +22 -0
- package/dist/llms.d.ts +10 -0
- package/dist/llms.js +45 -0
- package/dist/mcp.d.ts +17 -0
- package/dist/mcp.js +166 -0
- package/dist/mcp_stdio.d.ts +10 -0
- package/dist/mcp_stdio.js +33 -0
- package/dist/middleware.d.ts +18 -0
- package/dist/middleware.js +22 -0
- package/dist/node.d.ts +18 -0
- package/dist/node.js +126 -0
- package/dist/openapi.d.ts +9 -0
- package/dist/openapi.js +85 -0
- package/dist/presence.d.ts +30 -0
- package/dist/presence.js +115 -0
- package/dist/provide.d.ts +13 -0
- package/dist/provide.js +3 -0
- package/dist/router.d.ts +23 -0
- package/dist/router.js +60 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +17 -0
- package/dist/server.d.ts +45 -0
- package/dist/server.js +296 -0
- package/dist/sse.d.ts +11 -0
- package/dist/sse.js +48 -0
- package/dist/workers.d.ts +55 -0
- package/dist/workers.js +130 -0
- package/dist/ws-node.d.ts +3 -0
- package/dist/ws-node.js +36 -0
- package/dist/ws.d.ts +27 -0
- package/dist/ws.js +49 -0
- 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
|
+
[](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>[];
|
package/dist/channels.js
ADDED
|
@@ -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 {};
|