@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/dist/hub.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * In-process pub/sub hub — the local delivery point of a realtime node.
3
+ * Standalone by default; pass `bus` to join a multi-node deployment.
4
+ */
5
+ export class Hub {
6
+ #topics = new Map();
7
+ #bus;
8
+ #prefix;
9
+ #id = Math.random().toString(36).slice(2);
10
+ #busOffs = new Map();
11
+ #remoteListeners = new Set();
12
+ #infraChannel;
13
+ #infraOff;
14
+ constructor(options = {}) {
15
+ this.#bus = options.bus;
16
+ this.#prefix = options.channelPrefix ?? 'tzin:ch:';
17
+ this.#infraChannel = `${this.#prefix}__infra__`;
18
+ }
19
+ /**
20
+ * Infrastructure hook: invoked for every frame arriving from OTHER nodes
21
+ * over the dedicated infra channel, even when the topic has no local
22
+ * subscribers (e.g. presence replication). Returns an unsubscribe function.
23
+ */
24
+ onRemote(fn) {
25
+ this.#remoteListeners.add(fn);
26
+ if (this.#bus && !this.#infraOff) {
27
+ this.#infraOff = this.#bus.subscribe(this.#infraChannel, (m) => this.#deliverInfra(m));
28
+ }
29
+ return () => {
30
+ this.#remoteListeners.delete(fn);
31
+ if (this.#remoteListeners.size === 0 && this.#infraOff) {
32
+ this.#infraOff();
33
+ this.#infraOff = undefined;
34
+ }
35
+ };
36
+ }
37
+ /** Broadcast an infrastructure event to every other node's onRemote listeners. */
38
+ emitRemote(e) {
39
+ if (!this.#bus || this.#remoteListeners.size === 0)
40
+ return;
41
+ this.#bus.publish(this.#infraChannel, JSON.stringify({ topic: e.topic, event: e.event, data: e.data, from: this.#id }));
42
+ }
43
+ #deliverInfra(message) {
44
+ let frame;
45
+ try {
46
+ frame = JSON.parse(message);
47
+ }
48
+ catch {
49
+ return;
50
+ }
51
+ if (frame.from === this.#id)
52
+ return; // own echo
53
+ const e = { topic: frame.topic, event: frame.event, data: frame.data };
54
+ for (const fn of [...this.#remoteListeners]) {
55
+ try {
56
+ fn(e);
57
+ }
58
+ catch { }
59
+ }
60
+ }
61
+ subscribe(topic, fn) {
62
+ let set = this.#topics.get(topic);
63
+ if (!set) {
64
+ set = new Set();
65
+ this.#topics.set(topic, set);
66
+ }
67
+ set.add(fn);
68
+ // First local subscriber on the topic pulls remote frames for it.
69
+ const channel = this.#prefix + topic;
70
+ if (this.#bus && !this.#busOffs.has(channel)) {
71
+ this.#busOffs.set(channel, this.#bus.subscribe(channel, (message) => this.#deliverRemote(topic, message)));
72
+ }
73
+ return () => {
74
+ set.delete(fn);
75
+ if (set.size === 0) {
76
+ this.#topics.delete(topic);
77
+ // Last one out releases the bus subscription.
78
+ this.#busOffs.get(channel)?.();
79
+ this.#busOffs.delete(channel);
80
+ }
81
+ };
82
+ }
83
+ /** Deliver locally and, when clustered, broadcast to every other node. */
84
+ publish(topic, event, data) {
85
+ if (this.#bus) {
86
+ this.#bus.publish(this.#prefix + topic, JSON.stringify({ topic, event, data, from: this.#id }));
87
+ }
88
+ const set = this.#topics.get(topic);
89
+ if (!set)
90
+ return 0;
91
+ const e = { topic, event, data };
92
+ for (const fn of [...set]) {
93
+ try {
94
+ fn(e);
95
+ }
96
+ catch { }
97
+ }
98
+ return set.size;
99
+ }
100
+ subscriberCount(topic) {
101
+ return this.#topics.get(topic)?.size ?? 0;
102
+ }
103
+ #deliverRemote(topic, message) {
104
+ let frame;
105
+ try {
106
+ frame = JSON.parse(message);
107
+ }
108
+ catch {
109
+ return;
110
+ }
111
+ if (frame.from === this.#id)
112
+ return; // own echo
113
+ const e = { topic: frame.topic, event: frame.event, data: frame.data };
114
+ for (const fn of [...this.#remoteListeners]) {
115
+ try {
116
+ fn(e);
117
+ }
118
+ catch { }
119
+ }
120
+ const set = this.#topics.get(frame.topic);
121
+ if (!set)
122
+ return;
123
+ for (const fn of [...set]) {
124
+ try {
125
+ fn(e);
126
+ }
127
+ catch { }
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,22 @@
1
+ export { contract, impl, HttpError, type HttpMethod, type ContractDef, type AnyContract, type PathParamNames, type HandlerInput, type SectionsOf, type ResponseOf, type Handler, type RouteImpl, } from './contract.js';
2
+ export { createApp, type App } from './server.js';
3
+ export { client, type ClientOf, type ClientResult, type CallerFn } from './client.js';
4
+ export { generateOpenApi } from './openapi.js';
5
+ export { listen } from './node.js';
6
+ export { t, Value } from './schema.js';
7
+ export { defineContext, Ctx, type ContextKey } from './context.js';
8
+ export { middleware, compose, type MiddlewareInput, type Middleware, type Next, type Dispatch } from './middleware.js';
9
+ export { raw, isRawResult, type RawResult } from './contract.js';
10
+ export { provide, type ProvidedEntry } from './provide.js';
11
+ export { handleMcpMessage, listTools, toTool, type RpcRequest, } from './mcp.js';
12
+ export { startStdioMcp, startStdioMcpFromStreams } from './mcp_stdio.js';
13
+ export { Hub, type ChannelEvent, type Subscriber } from './hub.js';
14
+ export { Presence, type MemberInfo } from './presence.js';
15
+ export { channelRoutes, type ChannelOptions } from './channels.js';
16
+ export { sse, type SseSender } from './sse.js';
17
+ export { serve as serveBun } from './bun.js';
18
+ export { toWorker, toDurableWorker, TzinChannels, DEFAULT_DO_BINDING, DEFAULT_DO_CLASS, type WorkerOptions, type DurableWorkerOptions, } from './workers.js';
19
+ export { wsChannels, type WsRoute, type WsSend, type WsChannelOptions } from './ws.js';
20
+ export { attachChannels } from './ws-node.js';
21
+ export { LocalBus, clusterHubs, type MessageBus } from './bus.js';
22
+ export { cors, type CorsOptions } from './cors.js';
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ export { contract, impl, HttpError, } from './contract.js';
2
+ export { createApp } from './server.js';
3
+ export { client } from './client.js';
4
+ export { generateOpenApi } from './openapi.js';
5
+ export { listen } from './node.js';
6
+ export { t, Value } from './schema.js';
7
+ export { defineContext, Ctx } from './context.js';
8
+ export { middleware, compose } from './middleware.js';
9
+ export { raw, isRawResult } from './contract.js';
10
+ export { provide } from './provide.js';
11
+ export { handleMcpMessage, listTools, toTool, } from './mcp.js';
12
+ export { startStdioMcp, startStdioMcpFromStreams } from './mcp_stdio.js';
13
+ export { Hub } from './hub.js';
14
+ export { Presence } from './presence.js';
15
+ export { channelRoutes } from './channels.js';
16
+ export { sse } from './sse.js';
17
+ export { serve as serveBun } from './bun.js';
18
+ export { toWorker, toDurableWorker, TzinChannels, DEFAULT_DO_BINDING, DEFAULT_DO_CLASS, } from './workers.js';
19
+ export { wsChannels } from './ws.js';
20
+ export { attachChannels } from './ws-node.js';
21
+ export { LocalBus, clusterHubs } from './bus.js';
22
+ export { cors } from './cors.js';
package/dist/llms.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { RouteImpl } from './contract.js';
2
+ export interface ApiMeta {
3
+ title?: string;
4
+ description?: string;
5
+ version?: string;
6
+ }
7
+ /** llms.txt: the discoverable, human-readable index of the API. */
8
+ export declare function renderLlmsTxt(routes: RouteImpl<any>[], meta?: ApiMeta): string;
9
+ /** llms-full.txt: same index plus each endpoint's declared JSON Schemas. */
10
+ export declare function renderLlmsFullTxt(routes: RouteImpl<any>[], meta?: ApiMeta): string;
package/dist/llms.js ADDED
@@ -0,0 +1,45 @@
1
+ function endpointLines(routes) {
2
+ return routes.map(({ contract: c }) => {
3
+ const name = c.name ?? c.path.replace(/[^A-Za-z0-9]+/g, '_');
4
+ const label = `${c.method} ${c.path}`;
5
+ const desc = c.description ? `: ${c.description}` : '';
6
+ return `- [\`${label}\`](${name})${desc}`;
7
+ });
8
+ }
9
+ /** llms.txt: the discoverable, human-readable index of the API. */
10
+ export function renderLlmsTxt(routes, meta = {}) {
11
+ const title = meta.title ?? 'API';
12
+ const summary = meta.description ?? `${routes.length} typed endpoints over a shared contract layer.`;
13
+ return [
14
+ `# ${title}`,
15
+ '',
16
+ `> ${summary}`,
17
+ '',
18
+ '## Endpoints',
19
+ '',
20
+ ...endpointLines(routes),
21
+ '',
22
+ ].join('\n');
23
+ }
24
+ /** llms-full.txt: same index plus each endpoint's declared JSON Schemas. */
25
+ export function renderLlmsFullTxt(routes, meta = {}) {
26
+ const head = renderLlmsTxt(routes, meta);
27
+ const blocks = routes.map(({ contract: c }) => {
28
+ const name = c.name ?? c.path.replace(/[^A-Za-z0-9]+/g, '_');
29
+ const lines = [
30
+ `### ${c.method} ${c.path} (${name})`,
31
+ '',
32
+ ...(c.description ? [`${c.description}`, ''] : []),
33
+ ];
34
+ for (const section of ['params', 'query', 'headers', 'cookies', 'body']) {
35
+ if (section in c && c[section]) {
36
+ lines.push(`\`${section}\` schema:`, '', '```json', JSON.stringify(c[section]), '```', '');
37
+ }
38
+ }
39
+ for (const [status, schema] of Object.entries(c.responses)) {
40
+ lines.push(`response ${status} schema:`, '', '```json', JSON.stringify(schema), '```', '');
41
+ }
42
+ return lines.join('\n');
43
+ });
44
+ return `${head}\n## Endpoint details\n\n${blocks.join('\n')}`;
45
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { App } from './server.js';
2
+ import type { RouteImpl } from './contract.js';
3
+ /**
4
+ * One endpoint = one MCP tool.
5
+ * The inputSchema is assembled from the contract's declared sections —
6
+ * TypeBox schemas are already JSON Schema, so there is no conversion layer.
7
+ */
8
+ export declare function toTool(route: RouteImpl<any>): Record<string, unknown>;
9
+ export declare function listTools(routes: RouteImpl<any>[]): Record<string, unknown>[];
10
+ export interface RpcRequest {
11
+ jsonrpc?: string;
12
+ id?: string | number | null;
13
+ method?: string;
14
+ params?: Record<string, unknown>;
15
+ }
16
+ /** Handle one MCP JSON-RPC message; returns null for notifications. */
17
+ export declare function handleMcpMessage(app: App, msg: RpcRequest): Promise<Record<string, unknown> | null>;
package/dist/mcp.js ADDED
@@ -0,0 +1,166 @@
1
+ const PROTOCOL_VERSION = '2025-06-18';
2
+ const SERVER_INFO = { name: 'tzin', version: '0.1.0' };
3
+ /** '/users/:id' -> 'get_users_id' fallback tool name. */
4
+ function defaultToolName(c) {
5
+ return `${c.method.toLowerCase()}_${c.path.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')}`;
6
+ }
7
+ /**
8
+ * One endpoint = one MCP tool.
9
+ * The inputSchema is assembled from the contract's declared sections —
10
+ * TypeBox schemas are already JSON Schema, so there is no conversion layer.
11
+ */
12
+ export function toTool(route) {
13
+ const c = route.contract;
14
+ const properties = {};
15
+ const required = [];
16
+ for (const section of ['params', 'query', 'body']) {
17
+ if (section in c && c[section]) {
18
+ properties[section] = c[section];
19
+ if (section !== 'query')
20
+ required.push(section);
21
+ }
22
+ }
23
+ // Path placeholders without a declared params schema still surface in the
24
+ // tool schema (as strings) so clients know to send them.
25
+ if (!('params' in c && c.params)) {
26
+ const placeholders = [...c.path.matchAll(/:([A-Za-z0-9_]+)/g)].map((m) => m[1]);
27
+ if (placeholders.length) {
28
+ properties.params = {
29
+ type: 'object',
30
+ properties: Object.fromEntries(placeholders.map((p) => [p, { type: 'string' }])),
31
+ required: placeholders,
32
+ };
33
+ required.push('params');
34
+ }
35
+ }
36
+ const responseLines = Object.entries(c.responses)
37
+ .map(([status, schema]) => {
38
+ const desc = schema.description ?? '';
39
+ return `- ${status}${desc ? `: ${desc}` : ''}`;
40
+ })
41
+ .join('\n');
42
+ return {
43
+ name: c.name ?? defaultToolName(c),
44
+ ...(c.description ? { description: c.description } : {}),
45
+ inputSchema: { type: 'object', properties, ...(required.length ? { required } : {}) },
46
+ annotations: {
47
+ 'x-tzin-method': c.method,
48
+ 'x-tzin-path': c.path,
49
+ 'x-tzin-responses': responseLines,
50
+ },
51
+ };
52
+ }
53
+ export function listTools(routes) {
54
+ return routes.map(toTool);
55
+ }
56
+ function sectionProperties(schema) {
57
+ const props = schema.properties;
58
+ return props ? Object.keys(props) : [];
59
+ }
60
+ /**
61
+ * Tool arguments arrive nested ({params:{id}}) or flattened at the top level
62
+ * ({id}) — the common MCP client convention. Resolve either shape against the
63
+ * section's declared property names.
64
+ */
65
+ function resolveSection(args, section, schema) {
66
+ const nested = args[section];
67
+ if (nested !== undefined) {
68
+ return typeof nested === 'object' && nested !== null ? nested : undefined;
69
+ }
70
+ const flat = {};
71
+ let found = false;
72
+ for (const key of sectionProperties(schema)) {
73
+ if (key in args) {
74
+ flat[key] = args[key];
75
+ found = true;
76
+ }
77
+ }
78
+ return found ? flat : undefined;
79
+ }
80
+ async function callTool(app, name, args) {
81
+ const routes = app.routes;
82
+ const route = routes.find((r) => (r.contract.name ?? defaultToolName(r.contract)) === name);
83
+ if (!route) {
84
+ return {
85
+ content: [{ type: 'text', text: JSON.stringify({ error: `Unknown tool '${name}'` }) }],
86
+ isError: true,
87
+ };
88
+ }
89
+ const c = route.contract;
90
+ const rawArgs = (args ?? {});
91
+ const paramsSchema = 'params' in c ? c.params : undefined;
92
+ const querySchema = 'query' in c ? c.query : undefined;
93
+ const bodySchema = 'body' in c ? c.body : undefined;
94
+ // Path params may arrive even without a declared params schema — resolve
95
+ // them against the route's own :placeholders so the tool stays callable.
96
+ const requiredParams = [...c.path.matchAll(/:([A-Za-z0-9_]+)/g)].map((m) => m[1]);
97
+ const params = paramsSchema
98
+ ? resolveSection(rawArgs, 'params', paramsSchema)
99
+ : requiredParams.some((k) => k in rawArgs)
100
+ ? Object.fromEntries(requiredParams.filter((k) => k in rawArgs).map((k) => [k, rawArgs[k]]))
101
+ : undefined;
102
+ const missing = requiredParams.filter((k) => params?.[k] === undefined);
103
+ if (missing.length) {
104
+ return {
105
+ content: [
106
+ { type: 'text', text: `Missing argument(s): ${missing.map((k) => `params.${k}`).join(', ')}` },
107
+ ],
108
+ isError: true,
109
+ };
110
+ }
111
+ const path = c.path.replace(/:([A-Za-z0-9_]+)/g, (_m, key) => encodeURIComponent(String(params[key])));
112
+ const query = querySchema ? resolveSection(rawArgs, 'query', querySchema) : undefined;
113
+ const qs = query
114
+ ? new URLSearchParams(Object.entries(query).map(([k, v]) => [k, String(v)])).toString()
115
+ : '';
116
+ const fullPath = qs ? `${path}?${qs}` : path;
117
+ const body = bodySchema ? resolveSection(rawArgs, 'body', bodySchema) : undefined;
118
+ const init = { method: c.method };
119
+ if (body !== undefined)
120
+ init.body = JSON.stringify(body);
121
+ // In-process dispatch: validation, middleware and DI all apply.
122
+ const res = await app.fetch(new Request(`http://mcp.local${fullPath}`, init));
123
+ const text = await res.text();
124
+ if (!res.ok) {
125
+ return {
126
+ content: [{ type: 'text', text: `HTTP ${res.status}: ${text}` }],
127
+ isError: true,
128
+ };
129
+ }
130
+ return { content: [{ type: 'text', text }] };
131
+ }
132
+ /** Handle one MCP JSON-RPC message; returns null for notifications. */
133
+ export async function handleMcpMessage(app, msg) {
134
+ const reply = (result) => ({
135
+ jsonrpc: '2.0',
136
+ id: msg.id ?? null,
137
+ result,
138
+ });
139
+ const error = (code, message) => ({
140
+ jsonrpc: '2.0',
141
+ id: msg.id ?? null,
142
+ error: { code, message },
143
+ });
144
+ switch (msg.method) {
145
+ case 'initialize':
146
+ return reply({
147
+ protocolVersion: PROTOCOL_VERSION,
148
+ capabilities: { tools: {} },
149
+ serverInfo: SERVER_INFO,
150
+ });
151
+ case 'tools/list':
152
+ return reply({ tools: listTools(app.routes) });
153
+ case 'tools/call': {
154
+ const name = String(msg.params?.name ?? '');
155
+ const result = await callTool(app, name, msg.params?.arguments);
156
+ return reply(result);
157
+ }
158
+ case 'notifications/initialized':
159
+ return null;
160
+ case undefined:
161
+ default:
162
+ if (msg.method?.startsWith('notifications/'))
163
+ return null;
164
+ return error(-32601, `Method not found: ${String(msg.method)}`);
165
+ }
166
+ }
@@ -0,0 +1,10 @@
1
+ import type { Readable, Writable } from 'node:stream';
2
+ import type { App } from './server.js';
3
+ /**
4
+ * Minimal MCP stdio transport: newline-delimited JSON-RPC on an input stream,
5
+ * responses written as newline-delimited JSON-RPC on an output stream.
6
+ * Streams are injectable for tests; startStdioMcp wires the real ones.
7
+ */
8
+ export declare function startStdioMcpFromStreams(app: App, input: Readable, out: Writable): void;
9
+ /** Point an MCP client at a small entryfile that builds the app and calls this. */
10
+ export declare function startStdioMcp(app: App): void;
@@ -0,0 +1,33 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { handleMcpMessage } from './mcp.js';
3
+ /**
4
+ * Minimal MCP stdio transport: newline-delimited JSON-RPC on an input stream,
5
+ * responses written as newline-delimited JSON-RPC on an output stream.
6
+ * Streams are injectable for tests; startStdioMcp wires the real ones.
7
+ */
8
+ export function startStdioMcpFromStreams(app, input, out) {
9
+ const rl = createInterface({ input });
10
+ rl.on('line', async (line) => {
11
+ if (!line.trim())
12
+ return;
13
+ let msg;
14
+ try {
15
+ msg = JSON.parse(line);
16
+ }
17
+ catch {
18
+ out.write(JSON.stringify({
19
+ jsonrpc: '2.0',
20
+ id: null,
21
+ error: { code: -32700, message: 'Parse error' },
22
+ }) + '\n');
23
+ return;
24
+ }
25
+ const res = await handleMcpMessage(app, msg);
26
+ if (res)
27
+ out.write(JSON.stringify(res) + '\n');
28
+ });
29
+ }
30
+ /** Point an MCP client at a small entryfile that builds the app and calls this. */
31
+ export function startStdioMcp(app) {
32
+ startStdioMcpFromStreams(app, process.stdin, process.stdout);
33
+ }
@@ -0,0 +1,18 @@
1
+ import type { Ctx } from './context.js';
2
+ export interface Next {
3
+ (): Promise<Response>;
4
+ }
5
+ export interface MiddlewareInput {
6
+ req: Request;
7
+ ctx: Ctx;
8
+ next: Next;
9
+ }
10
+ export type Middleware = (input: MiddlewareInput) => Promise<Response>;
11
+ export declare function middleware(fn: Middleware): Middleware;
12
+ export type Dispatch = (req: Request, ctx: Ctx) => Promise<Response>;
13
+ /**
14
+ * Onion composition: first middleware = outermost layer.
15
+ * Each middleware may run code before/after next(), short-circuit by not
16
+ * calling it, or replace the response entirely. `tail` is the innermost layer.
17
+ */
18
+ export declare function compose(mws: Middleware[], tail: Dispatch): Dispatch;
@@ -0,0 +1,22 @@
1
+ export function middleware(fn) {
2
+ return fn;
3
+ }
4
+ /**
5
+ * Onion composition: first middleware = outermost layer.
6
+ * Each middleware may run code before/after next(), short-circuit by not
7
+ * calling it, or replace the response entirely. `tail` is the innermost layer.
8
+ */
9
+ export function compose(mws, tail) {
10
+ return async (req, ctx) => {
11
+ let index = -1;
12
+ const run = (i) => {
13
+ if (i <= index)
14
+ return Promise.reject(new Error('next() called multiple times'));
15
+ index = i;
16
+ if (i === mws.length)
17
+ return tail(req, ctx);
18
+ return mws[i]({ req, ctx, next: () => run(i + 1) });
19
+ };
20
+ return run(0);
21
+ };
22
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { type Server } from 'node:http';
2
+ import type { App } from './server.js';
3
+ /**
4
+ * Minimal Node adapter. Production version: streaming, WebSockets, graceful shutdown.
5
+ *
6
+ * Hot-path notes (measured with autocannon layer isolation):
7
+ * - Requests are duck-typed instead of constructed via `new Request()` — the
8
+ * undici constructor costs ~13µs/request under load.
9
+ * - Replies come from app.dispatchRaw(): tzin-built responses never allocate
10
+ * an undici Response (~19µs/request under load). raw()/SSE still arrive as
11
+ * a Response and take the streaming branch below.
12
+ * - `headers` on the duck request is a lazy getter: undici Headers allocation
13
+ * is wasted work for contracts without a headers/cookies section.
14
+ * - The abort signal is lazy too: AbortController + close listener are only
15
+ * wired when ctx.signal is actually read (SSE, channels), never on the
16
+ * JSON hot path.
17
+ */
18
+ export declare function listen(app: App, port?: number): Promise<Server>;
package/dist/node.js ADDED
@@ -0,0 +1,126 @@
1
+ import { createServer } from 'node:http';
2
+ /**
3
+ * Minimal Node adapter. Production version: streaming, WebSockets, graceful shutdown.
4
+ *
5
+ * Hot-path notes (measured with autocannon layer isolation):
6
+ * - Requests are duck-typed instead of constructed via `new Request()` — the
7
+ * undici constructor costs ~13µs/request under load.
8
+ * - Replies come from app.dispatchRaw(): tzin-built responses never allocate
9
+ * an undici Response (~19µs/request under load). raw()/SSE still arrive as
10
+ * a Response and take the streaming branch below.
11
+ * - `headers` on the duck request is a lazy getter: undici Headers allocation
12
+ * is wasted work for contracts without a headers/cookies section.
13
+ * - The abort signal is lazy too: AbortController + close listener are only
14
+ * wired when ctx.signal is actually read (SSE, channels), never on the
15
+ * JSON hot path.
16
+ */
17
+ export function listen(app, port = 3000) {
18
+ const dispatch = app.dispatchRaw?.bind(app) ?? defaultDispatch(app);
19
+ const server = createServer(async (nodeReq, nodeRes) => {
20
+ try {
21
+ // Only drain the request stream when a body can actually be present.
22
+ let bodyBuf;
23
+ if (Number(nodeReq.headers['content-length'] ?? 0) > 0 ||
24
+ nodeReq.headers['transfer-encoding'] !== undefined) {
25
+ const chunks = [];
26
+ for await (const chunk of nodeReq)
27
+ chunks.push(chunk);
28
+ bodyBuf = Buffer.concat(chunks);
29
+ }
30
+ // Signal wiring is deferred: the AbortController + close-listener only
31
+ // materialize if something reads ctx.signal (SSE, channels). JSON
32
+ // requests never pay for it.
33
+ let cachedSignal;
34
+ const signalFactory = () => {
35
+ if (cachedSignal === undefined) {
36
+ const ac = new AbortController();
37
+ nodeRes.on('close', () => {
38
+ if (!nodeRes.writableEnded)
39
+ ac.abort();
40
+ });
41
+ cachedSignal = ac.signal;
42
+ }
43
+ return cachedSignal;
44
+ };
45
+ let headers;
46
+ const req = {
47
+ url: `http://${nodeReq.headers.host}${nodeReq.url}`,
48
+ method: nodeReq.method,
49
+ get headers() {
50
+ return (headers ??= new Headers(nodeReq.headers));
51
+ },
52
+ get signal() {
53
+ return signalFactory();
54
+ },
55
+ __tzin_signal_factory: signalFactory,
56
+ body: null,
57
+ json: async () => {
58
+ if (bodyBuf === undefined)
59
+ throw new SyntaxError('Unexpected end of JSON input');
60
+ return JSON.parse(bodyBuf.toString('utf8'));
61
+ },
62
+ text: async () => (bodyBuf ?? Buffer.alloc(0)).toString('utf8'),
63
+ arrayBuffer: async () => (bodyBuf ?? Buffer.alloc(0)).buffer,
64
+ };
65
+ const reply = await dispatch(req);
66
+ if (reply.response) {
67
+ const res = reply.response;
68
+ nodeRes.writeHead(res.status, resHeadersSlow(res));
69
+ // Streaming response (SSE, raw()): pump with backpressure instead of
70
+ // buffering — text()/arrayBuffer() would wait for the stream to close,
71
+ // which for event streams is never.
72
+ if (!res.body) {
73
+ nodeRes.end();
74
+ return;
75
+ }
76
+ const reader = res.body.getReader();
77
+ try {
78
+ for (;;) {
79
+ const { done, value } = await reader.read();
80
+ if (done)
81
+ break;
82
+ if (!nodeRes.write(value)) {
83
+ await new Promise((resolve) => nodeRes.once('drain', resolve));
84
+ }
85
+ }
86
+ nodeRes.end();
87
+ }
88
+ catch {
89
+ nodeRes.destroy();
90
+ }
91
+ return;
92
+ }
93
+ nodeRes.writeHead(reply.status, reply.headers);
94
+ nodeRes.end(reply.text);
95
+ }
96
+ catch (err) {
97
+ if (!nodeRes.headersSent) {
98
+ nodeRes.statusCode = 500;
99
+ nodeRes.end('Internal Server Error');
100
+ }
101
+ else {
102
+ nodeRes.end();
103
+ }
104
+ console.error(err);
105
+ }
106
+ });
107
+ // Nagle off once per socket, not per request.
108
+ server.on('connection', (socket) => socket.setNoDelay(true));
109
+ return new Promise((resolve) => {
110
+ server.listen(port, () => resolve(server));
111
+ });
112
+ }
113
+ function resHeadersSlow(res) {
114
+ const headers = {};
115
+ res.headers.forEach((value, key) => {
116
+ headers[key] = key === 'set-cookie' ? res.headers.getSetCookie() : value;
117
+ });
118
+ return headers;
119
+ }
120
+ /** Fallback for App implementations without dispatchRaw (e.g. wrappers). */
121
+ function defaultDispatch(app) {
122
+ return async (req) => {
123
+ const res = await app.fetch(req);
124
+ return { status: res.status, response: res };
125
+ };
126
+ }
@@ -0,0 +1,9 @@
1
+ import type { RouteImpl } from './contract.js';
2
+ /**
3
+ * OpenAPI 3.1 document generated from contracts.
4
+ * Zero conversion layer: TypeBox == JSON Schema == OpenAPI component vocabulary.
5
+ */
6
+ export declare function generateOpenApi(routes: RouteImpl<any>[], info?: {
7
+ title: string;
8
+ version: string;
9
+ }): Record<string, unknown>;