@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/dist/server.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { Value } from './schema.js';
|
|
2
|
+
import { HttpError, isRawResult } from './contract.js';
|
|
3
|
+
import { createMatcher } from './router.js';
|
|
4
|
+
import { Ctx } from './context.js';
|
|
5
|
+
import { compose } from './middleware.js';
|
|
6
|
+
import { handleMcpMessage } from './mcp.js';
|
|
7
|
+
import { renderLlmsTxt, renderLlmsFullTxt } from './llms.js';
|
|
8
|
+
import { generateOpenApi } from './openapi.js';
|
|
9
|
+
function validationError(section, schema, value) {
|
|
10
|
+
const errors = [...Value.Errors(schema, value)].map((e) => `${e.path || '/'}: ${e.message}`);
|
|
11
|
+
return new HttpError(400, `Invalid ${section}`, errors);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Responses tzin builds itself carry their serialized text alongside, so
|
|
15
|
+
* adapters can write them without draining the undici body stream — the
|
|
16
|
+
* single most expensive step of the over-network path (~7k req/s marginal).
|
|
17
|
+
* User-created responses (raw()) simply fall back to res.text().
|
|
18
|
+
*/
|
|
19
|
+
const FAST_TEXT = '__tzin_text';
|
|
20
|
+
export function fastResponseText(res) {
|
|
21
|
+
return res[FAST_TEXT];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Responses tzin builds itself also carry their headers as a plain object,
|
|
25
|
+
* so adapters skip undici's Headers iteration entirely on the hot path.
|
|
26
|
+
*/
|
|
27
|
+
const FAST_HEADERS = '__tzin_headers';
|
|
28
|
+
export function fastResponseHeaders(res) {
|
|
29
|
+
return res[FAST_HEADERS];
|
|
30
|
+
}
|
|
31
|
+
/** HTTP statuses that MUST NOT carry a body (1xx, 204, 304). */
|
|
32
|
+
function hasBody(status) {
|
|
33
|
+
return status !== 204 && status !== 304 && !(status >= 100 && status < 200);
|
|
34
|
+
}
|
|
35
|
+
const JSON_HEADERS = { 'content-type': 'application/json' };
|
|
36
|
+
function jsonFast(body, status) {
|
|
37
|
+
const text = hasBody(status) ? JSON.stringify(body) : undefined;
|
|
38
|
+
const res = new Response(text ?? null, {
|
|
39
|
+
status,
|
|
40
|
+
...(text === undefined ? {} : { headers: JSON_HEADERS }),
|
|
41
|
+
});
|
|
42
|
+
if (text !== undefined) {
|
|
43
|
+
const duck = res;
|
|
44
|
+
duck[FAST_TEXT] = text;
|
|
45
|
+
duck[FAST_HEADERS] = JSON_HEADERS;
|
|
46
|
+
}
|
|
47
|
+
return res;
|
|
48
|
+
}
|
|
49
|
+
function check(section, schema, value) {
|
|
50
|
+
if (!Value.Check(schema, value))
|
|
51
|
+
throw validationError(section, schema, value);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* URL search params are always strings; declared boolean/number query fields
|
|
55
|
+
* are coerced before validation so contracts can speak real types.
|
|
56
|
+
* Unrecognized values are left as-is and fail validation with a clear error.
|
|
57
|
+
*/
|
|
58
|
+
function coerceQuery(schema, raw) {
|
|
59
|
+
const props = schema.properties;
|
|
60
|
+
if (!props)
|
|
61
|
+
return raw;
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
64
|
+
const types = props[key]?.type;
|
|
65
|
+
const list = Array.isArray(types) ? types : types ? [types] : [];
|
|
66
|
+
if (typeof value === 'string' && list.includes('boolean')) {
|
|
67
|
+
out[key] = value === 'true' ? true : value === 'false' ? false : value;
|
|
68
|
+
}
|
|
69
|
+
else if (typeof value === 'string' && (list.includes('number') || list.includes('integer'))) {
|
|
70
|
+
const n = Number(value);
|
|
71
|
+
out[key] = Number.isNaN(n) ? value : n;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
out[key] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** Unwrap a Response produced through the middleware path into a RawReply. */
|
|
80
|
+
function toRawReply(res) {
|
|
81
|
+
const fast = fastResponseText(res);
|
|
82
|
+
if (fast === undefined)
|
|
83
|
+
return { status: res.status, response: res };
|
|
84
|
+
// The FAST_HEADERS snapshot predates any header mutations middleware made
|
|
85
|
+
// after construction (CORS vary/ACAO, rate-limit footers...), so read the
|
|
86
|
+
// live Headers here. Only apps with middleware pay this iteration; the
|
|
87
|
+
// middleware-free hot path never enters this function.
|
|
88
|
+
const headers = {};
|
|
89
|
+
res.headers.forEach((value, key) => {
|
|
90
|
+
headers[key] = value;
|
|
91
|
+
});
|
|
92
|
+
return { status: res.status, text: fast, headers };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Adapters may attach a signal factory instead of a real signal so Ctx can
|
|
96
|
+
* stay lazy; real Request objects carry a plain signal.
|
|
97
|
+
*/
|
|
98
|
+
export function signalSource(req) {
|
|
99
|
+
const duck = req;
|
|
100
|
+
return duck.__tzin_signal_factory ?? req.signal;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Runtime-agnostic app: pure (req) => res over Web Standards.
|
|
104
|
+
* Testable without a server (app.fetch), adaptable to Node/Bun/Deno/Workers.
|
|
105
|
+
*/
|
|
106
|
+
function pathnameOf(url) {
|
|
107
|
+
const path = url.slice(url.indexOf('/', url.indexOf('//') + 2));
|
|
108
|
+
const end = path.search(/[?#]/);
|
|
109
|
+
return end === -1 ? path : path.slice(0, end);
|
|
110
|
+
}
|
|
111
|
+
export function createApp(routes, options = {}) {
|
|
112
|
+
const matchRoute = createMatcher(routes.map((r) => ({ method: r.contract.method, path: r.contract.path, route: r })));
|
|
113
|
+
/** Shared validation + handler invocation. Single source for both exits. */
|
|
114
|
+
async function runRoute(hit, req, ctx) {
|
|
115
|
+
const c = hit.route.contract;
|
|
116
|
+
const input = { ctx };
|
|
117
|
+
// Params are always injected (handler types derive them from the path
|
|
118
|
+
// string); validation applies only when a params schema is declared.
|
|
119
|
+
if ('params' in hit && hit.params !== undefined) {
|
|
120
|
+
input.params = hit.params;
|
|
121
|
+
if (c.params)
|
|
122
|
+
check('path params', c.params, hit.params);
|
|
123
|
+
}
|
|
124
|
+
if ('query' in c && c.query) {
|
|
125
|
+
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
|
|
126
|
+
const rawQuery = Object.fromEntries(new URLSearchParams(qs));
|
|
127
|
+
const query = coerceQuery(c.query, rawQuery);
|
|
128
|
+
check('query', c.query, query);
|
|
129
|
+
input.query = query;
|
|
130
|
+
}
|
|
131
|
+
if ('headers' in c && c.headers) {
|
|
132
|
+
const raw = {};
|
|
133
|
+
req.headers.forEach((v, k) => {
|
|
134
|
+
raw[k] = v;
|
|
135
|
+
});
|
|
136
|
+
check('headers', c.headers, raw);
|
|
137
|
+
input.headers = raw;
|
|
138
|
+
}
|
|
139
|
+
if ('cookies' in c && c.cookies) {
|
|
140
|
+
const rawCookies = {};
|
|
141
|
+
for (const part of (req.headers.get('cookie') ?? '').split(';')) {
|
|
142
|
+
const i = part.indexOf('=');
|
|
143
|
+
if (i > 0)
|
|
144
|
+
rawCookies[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
145
|
+
}
|
|
146
|
+
check('cookies', c.cookies, rawCookies);
|
|
147
|
+
input.cookies = rawCookies;
|
|
148
|
+
}
|
|
149
|
+
if ('body' in c && c.body) {
|
|
150
|
+
let json;
|
|
151
|
+
try {
|
|
152
|
+
json = await req.json();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
throw new HttpError(400, 'Malformed JSON body');
|
|
156
|
+
}
|
|
157
|
+
check('body', c.body, json);
|
|
158
|
+
input.body = json;
|
|
159
|
+
}
|
|
160
|
+
const result = await hit.route.handler(input);
|
|
161
|
+
if (isRawResult(result))
|
|
162
|
+
return { kind: 'raw', response: result.__tzin_raw };
|
|
163
|
+
return { kind: 'json', status: result.status, body: result.body };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Route lookup memoized by "METHOD pathname". Real traffic repeats URLs
|
|
167
|
+
* constantly (keep-alive clients, polls), so the trie walk + path split +
|
|
168
|
+
* param decode collapse to a Map hit. Bounded: cleared when it exceeds
|
|
169
|
+
* ROUTE_CACHE_MAX so abusive unique-URL traffic can't grow it unbounded.
|
|
170
|
+
*/
|
|
171
|
+
const routeCache = new Map();
|
|
172
|
+
function cachedMatch(method, pathname) {
|
|
173
|
+
const key = `${method} ${pathname}`;
|
|
174
|
+
let hit = routeCache.get(key);
|
|
175
|
+
if (hit === undefined) {
|
|
176
|
+
hit = matchRoute(method, pathname);
|
|
177
|
+
if (routeCache.size >= 10_000)
|
|
178
|
+
routeCache.clear();
|
|
179
|
+
routeCache.set(key, hit);
|
|
180
|
+
}
|
|
181
|
+
return hit;
|
|
182
|
+
}
|
|
183
|
+
async function dispatch(req, ctx) {
|
|
184
|
+
const pathname = pathnameOf(req.url);
|
|
185
|
+
const hit = cachedMatch(req.method, pathname);
|
|
186
|
+
if (!hit)
|
|
187
|
+
throw new HttpError(404, 'Not Found');
|
|
188
|
+
if (!('route' in hit)) {
|
|
189
|
+
throw new HttpError(405, 'Method Not Allowed', undefined, { Allow: hit.allow.join(', ') });
|
|
190
|
+
}
|
|
191
|
+
const out = await runRoute(hit, req, ctx);
|
|
192
|
+
return out.kind === 'raw' ? out.response : jsonFast(out.body, out.status);
|
|
193
|
+
}
|
|
194
|
+
async function rawDispatch(req, ctx, pathname) {
|
|
195
|
+
const hit = cachedMatch(req.method, pathname);
|
|
196
|
+
if (!hit)
|
|
197
|
+
throw new HttpError(404, 'Not Found');
|
|
198
|
+
if (!('route' in hit)) {
|
|
199
|
+
throw new HttpError(405, 'Method Not Allowed', undefined, { Allow: hit.allow.join(', ') });
|
|
200
|
+
}
|
|
201
|
+
const out = await runRoute(hit, req, ctx);
|
|
202
|
+
if (out.kind === 'raw')
|
|
203
|
+
return { status: out.response.status, response: out.response };
|
|
204
|
+
if (!hasBody(out.status))
|
|
205
|
+
return { status: out.status };
|
|
206
|
+
return { status: out.status, text: JSON.stringify(out.body), headers: JSON_HEADERS };
|
|
207
|
+
}
|
|
208
|
+
const middleware = options.middleware ?? [];
|
|
209
|
+
const handle = middleware.length
|
|
210
|
+
? compose(middleware, dispatch)
|
|
211
|
+
: dispatch;
|
|
212
|
+
const seed = new Map(options.provides?.map((e) => [e.key, e.value]));
|
|
213
|
+
const hasSeed = seed.size > 0;
|
|
214
|
+
async function dispatchRaw(req) {
|
|
215
|
+
const pathname = pathnameOf(req.url);
|
|
216
|
+
try {
|
|
217
|
+
if (options.mcp && pathname === '/mcp') {
|
|
218
|
+
if (req.method !== 'POST') {
|
|
219
|
+
throw new HttpError(405, 'MCP HTTP transport accepts POST only');
|
|
220
|
+
}
|
|
221
|
+
let msg;
|
|
222
|
+
try {
|
|
223
|
+
msg = await req.json();
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return {
|
|
227
|
+
status: 400,
|
|
228
|
+
text: JSON.stringify({
|
|
229
|
+
jsonrpc: '2.0',
|
|
230
|
+
id: null,
|
|
231
|
+
error: { code: -32700, message: 'Parse error' },
|
|
232
|
+
}),
|
|
233
|
+
headers: JSON_HEADERS,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const reply = await handleMcpMessage(app, msg);
|
|
237
|
+
if (!reply)
|
|
238
|
+
return { status: 202 };
|
|
239
|
+
return { status: 200, text: JSON.stringify(reply), headers: JSON_HEADERS };
|
|
240
|
+
}
|
|
241
|
+
if (options.llms && pathname === '/llms.txt') {
|
|
242
|
+
return {
|
|
243
|
+
status: 200,
|
|
244
|
+
text: renderLlmsTxt(routes, options.meta),
|
|
245
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (options.llms && pathname === '/llms-full.txt') {
|
|
249
|
+
return {
|
|
250
|
+
status: 200,
|
|
251
|
+
text: renderLlmsFullTxt(routes, options.meta),
|
|
252
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (options.openapi && pathname === '/openapi.json') {
|
|
256
|
+
return {
|
|
257
|
+
status: 200,
|
|
258
|
+
text: JSON.stringify(generateOpenApi(routes, {
|
|
259
|
+
title: options.meta?.title ?? 'API',
|
|
260
|
+
version: options.meta?.version ?? '0.0.0',
|
|
261
|
+
})),
|
|
262
|
+
headers: JSON_HEADERS,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const ctx = new Ctx(signalSource(req), hasSeed ? seed : undefined);
|
|
266
|
+
if (middleware.length)
|
|
267
|
+
return toRawReply(await handle(req, ctx));
|
|
268
|
+
return await rawDispatch(req, ctx, pathname);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
if (err instanceof HttpError) {
|
|
272
|
+
return {
|
|
273
|
+
status: err.status,
|
|
274
|
+
text: JSON.stringify({ error: err.message, details: err.details }),
|
|
275
|
+
headers: err.headers ? { ...JSON_HEADERS, ...err.headers } : JSON_HEADERS,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
console.error(err);
|
|
279
|
+
return { status: 500, text: JSON.stringify({ error: 'Internal Server Error' }), headers: JSON_HEADERS };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function fetch(req) {
|
|
283
|
+
const r = await dispatchRaw(req);
|
|
284
|
+
if (r.response)
|
|
285
|
+
return r.response;
|
|
286
|
+
const res = new Response(r.text ?? null, { status: r.status, headers: r.headers });
|
|
287
|
+
if (r.text !== undefined) {
|
|
288
|
+
const duck = res;
|
|
289
|
+
duck[FAST_TEXT] = r.text;
|
|
290
|
+
duck[FAST_HEADERS] = r.headers;
|
|
291
|
+
}
|
|
292
|
+
return res;
|
|
293
|
+
}
|
|
294
|
+
const app = { routes, fetch, dispatchRaw };
|
|
295
|
+
return app;
|
|
296
|
+
}
|
package/dist/sse.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events helper. Returns a raw Response streaming
|
|
3
|
+
* `text/event-stream`; closes when the producer finishes or the
|
|
4
|
+
* client disconnects (request abort signal).
|
|
5
|
+
*/
|
|
6
|
+
import { type RawResult } from './contract.js';
|
|
7
|
+
export interface SseSender {
|
|
8
|
+
event(name: string, data: unknown): void;
|
|
9
|
+
comment(text: string): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function sse(produce: (send: SseSender) => Promise<void>, signal?: AbortSignal): RawResult;
|
package/dist/sse.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events helper. Returns a raw Response streaming
|
|
3
|
+
* `text/event-stream`; closes when the producer finishes or the
|
|
4
|
+
* client disconnects (request abort signal).
|
|
5
|
+
*/
|
|
6
|
+
import { raw } from './contract.js';
|
|
7
|
+
export function sse(produce, signal) {
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
const stream = new ReadableStream({
|
|
10
|
+
start(controller) {
|
|
11
|
+
let closed = false;
|
|
12
|
+
const write = (chunk) => {
|
|
13
|
+
if (!closed)
|
|
14
|
+
controller.enqueue(encoder.encode(chunk));
|
|
15
|
+
};
|
|
16
|
+
const send = {
|
|
17
|
+
event: (name, data) => write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`),
|
|
18
|
+
comment: (text) => write(`: ${text}\n\n`),
|
|
19
|
+
};
|
|
20
|
+
const close = () => {
|
|
21
|
+
if (closed)
|
|
22
|
+
return;
|
|
23
|
+
closed = true;
|
|
24
|
+
try {
|
|
25
|
+
controller.close();
|
|
26
|
+
}
|
|
27
|
+
catch { }
|
|
28
|
+
};
|
|
29
|
+
if (signal) {
|
|
30
|
+
if (signal.aborted) {
|
|
31
|
+
close();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
signal.addEventListener('abort', close, { once: true });
|
|
35
|
+
}
|
|
36
|
+
produce(send)
|
|
37
|
+
.catch(() => { })
|
|
38
|
+
.finally(close);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
return raw(new Response(stream, {
|
|
42
|
+
headers: {
|
|
43
|
+
'content-type': 'text/event-stream',
|
|
44
|
+
'cache-control': 'no-cache',
|
|
45
|
+
connection: 'keep-alive',
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers adapters.
|
|
3
|
+
*
|
|
4
|
+
* - `toWorker(app)`: plain HTTP passthrough. WebSockets work per-connection,
|
|
5
|
+
* but workerd ties each socket to its request's I/O context, so one
|
|
6
|
+
* connection CANNOT broadcast into another (sends from foreign contexts
|
|
7
|
+
* are silently dropped).
|
|
8
|
+
* - `toDurableWorker(factory)`: the fix — the whole app (one Hub, Presence,
|
|
9
|
+
* SSE + WS + POST) lives inside a single Durable Object, so every
|
|
10
|
+
* connection shares one context exactly like Node/Bun. Requires declaring
|
|
11
|
+
* the DO in your wrangler config:
|
|
12
|
+
*
|
|
13
|
+
* "durable_objects": {
|
|
14
|
+
* "bindings": [{ "name": "TZIN_APP", "class_name": "TzinChannels" }]
|
|
15
|
+
* },
|
|
16
|
+
* "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TzinChannels"] }]
|
|
17
|
+
*/
|
|
18
|
+
import type { App } from './server.js';
|
|
19
|
+
import type { WsRoute } from './ws.js';
|
|
20
|
+
interface DoNamespace {
|
|
21
|
+
idFromName(name: string): unknown;
|
|
22
|
+
get(id: unknown): {
|
|
23
|
+
fetch(req: Request): Promise<Response> | Response;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
type Fetcher = {
|
|
27
|
+
fetch(req: Request): Promise<Response> | Response;
|
|
28
|
+
};
|
|
29
|
+
export interface WorkerOptions {
|
|
30
|
+
/** Native WebSocket endpoints served via WebSocketPair upgrades. */
|
|
31
|
+
wsRoutes?: WsRoute[];
|
|
32
|
+
}
|
|
33
|
+
export declare function toWorker(app: App, options?: WorkerOptions): {
|
|
34
|
+
fetch: (req: Request) => Promise<Response> | Response;
|
|
35
|
+
};
|
|
36
|
+
/** Default names; override via toDurableWorker options. */
|
|
37
|
+
export declare const DEFAULT_DO_BINDING = "TZIN_APP";
|
|
38
|
+
export declare const DEFAULT_DO_CLASS = "TzinChannels";
|
|
39
|
+
/**
|
|
40
|
+
* The whole app runs here — a single I/O context shared by every WebSocket,
|
|
41
|
+
* SSE stream and HTTP request, so cross-connection broadcast just works.
|
|
42
|
+
*/
|
|
43
|
+
export declare class TzinChannels {
|
|
44
|
+
fetch(req: Request): Promise<Response>;
|
|
45
|
+
}
|
|
46
|
+
export interface DurableWorkerOptions extends WorkerOptions {
|
|
47
|
+
/** DO class name exported by this script (default "TzinChannels"). */
|
|
48
|
+
className?: string;
|
|
49
|
+
/** Env binding name declared in wrangler config (default "TZIN_APP"). */
|
|
50
|
+
binding?: string;
|
|
51
|
+
}
|
|
52
|
+
export declare function toDurableWorker(factory: () => Fetcher, options?: DurableWorkerOptions): {
|
|
53
|
+
fetch: (req: Request, env?: Record<string, DoNamespace>) => Promise<Response> | Response;
|
|
54
|
+
};
|
|
55
|
+
export {};
|
package/dist/workers.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createMatcher } from './router.js';
|
|
2
|
+
/* ------------------------------------------------------------------ */
|
|
3
|
+
/* Direct mode */
|
|
4
|
+
/* ------------------------------------------------------------------ */
|
|
5
|
+
export function toWorker(app, options = {}) {
|
|
6
|
+
const matcher = bindMatcher(options.wsRoutes);
|
|
7
|
+
return {
|
|
8
|
+
fetch: (req) => {
|
|
9
|
+
if (isUpgrade(req)) {
|
|
10
|
+
if (!matcher)
|
|
11
|
+
return new Response('No WebSocket routes configured', { status: 404 });
|
|
12
|
+
return upgrade(req, matcher);
|
|
13
|
+
}
|
|
14
|
+
return app.fetch(req);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/* ------------------------------------------------------------------ */
|
|
19
|
+
/* Durable Object mode: one context for every connection */
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
/** Default names; override via toDurableWorker options. */
|
|
22
|
+
export const DEFAULT_DO_BINDING = 'TZIN_APP';
|
|
23
|
+
export const DEFAULT_DO_CLASS = 'TzinChannels';
|
|
24
|
+
/** Factories registered by toDurableWorker(), consumed by the DO class. */
|
|
25
|
+
const factories = new Map();
|
|
26
|
+
const apps = new Map();
|
|
27
|
+
function appFor(className) {
|
|
28
|
+
let app = apps.get(className);
|
|
29
|
+
if (!app) {
|
|
30
|
+
const f = factories.get(className);
|
|
31
|
+
if (!f)
|
|
32
|
+
throw new Error(`No app factory registered for DO class "${className}"`);
|
|
33
|
+
app = f();
|
|
34
|
+
apps.set(className, app);
|
|
35
|
+
}
|
|
36
|
+
return app;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The whole app runs here — a single I/O context shared by every WebSocket,
|
|
40
|
+
* SSE stream and HTTP request, so cross-connection broadcast just works.
|
|
41
|
+
*/
|
|
42
|
+
export class TzinChannels {
|
|
43
|
+
async fetch(req) {
|
|
44
|
+
return appFor(DEFAULT_DO_CLASS).fetch(req);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function toDurableWorker(factory, options = {}) {
|
|
48
|
+
const className = options.className ?? DEFAULT_DO_CLASS;
|
|
49
|
+
const binding = options.binding ?? DEFAULT_DO_BINDING;
|
|
50
|
+
factories.set(className, factory);
|
|
51
|
+
return {
|
|
52
|
+
fetch: (req, env) => {
|
|
53
|
+
const ns = env?.[binding];
|
|
54
|
+
if (!ns) {
|
|
55
|
+
return new Response(`Missing Durable Object binding "${binding}"`, { status: 500 });
|
|
56
|
+
}
|
|
57
|
+
// Singleton: every connection lands in the same DO instance/context.
|
|
58
|
+
return ns.get(ns.idFromName('tzin-app')).fetch(req);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/* ------------------------------------------------------------------ */
|
|
63
|
+
/* Shared internals */
|
|
64
|
+
/* ------------------------------------------------------------------ */
|
|
65
|
+
function isUpgrade(req) {
|
|
66
|
+
return req.headers.get('upgrade')?.toLowerCase() === 'websocket';
|
|
67
|
+
}
|
|
68
|
+
function bindMatcher(wsRoutes) {
|
|
69
|
+
const routes = wsRoutes ?? [];
|
|
70
|
+
return routes.length
|
|
71
|
+
? createMatcher(routes.map((r) => ({ method: 'GET', path: r.path, route: r })))
|
|
72
|
+
: undefined;
|
|
73
|
+
}
|
|
74
|
+
function pairCtor() {
|
|
75
|
+
return globalThis.WebSocketPair;
|
|
76
|
+
}
|
|
77
|
+
function upgrade(req, matcher) {
|
|
78
|
+
const Pair = pairCtor();
|
|
79
|
+
if (!Pair)
|
|
80
|
+
return new Response('WebSockets require a Workers-compatible runtime', { status: 500 });
|
|
81
|
+
let url;
|
|
82
|
+
try {
|
|
83
|
+
url = new URL(req.url);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return new Response('Bad request', { status: 400 });
|
|
87
|
+
}
|
|
88
|
+
const hit = matcher('GET', url.pathname);
|
|
89
|
+
if (!hit || !('route' in hit)) {
|
|
90
|
+
return new Response('WebSocket upgrade failed', { status: 400 });
|
|
91
|
+
}
|
|
92
|
+
const route = hit.route;
|
|
93
|
+
const pair = new Pair();
|
|
94
|
+
const client = pair[0];
|
|
95
|
+
const server = pair[1];
|
|
96
|
+
// accept() must precede any send(); early sends are buffered until the
|
|
97
|
+
// 101 response reaches the runtime.
|
|
98
|
+
server.accept();
|
|
99
|
+
const send = (frame) => {
|
|
100
|
+
try {
|
|
101
|
+
server.send(JSON.stringify(frame));
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
};
|
|
105
|
+
let state;
|
|
106
|
+
try {
|
|
107
|
+
state = route.open(send, url);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
server.close(1011, 'handler error');
|
|
111
|
+
return new Response('WebSocket handler failed', { status: 500 });
|
|
112
|
+
}
|
|
113
|
+
server.addEventListener('message', (ev) => {
|
|
114
|
+
const text = typeof ev.data === 'string' ? ev.data : String(ev.data);
|
|
115
|
+
try {
|
|
116
|
+
route.message(state, text);
|
|
117
|
+
}
|
|
118
|
+
catch { }
|
|
119
|
+
});
|
|
120
|
+
const closeAll = () => {
|
|
121
|
+
try {
|
|
122
|
+
route.close(state);
|
|
123
|
+
}
|
|
124
|
+
catch { }
|
|
125
|
+
};
|
|
126
|
+
server.addEventListener('close', closeAll);
|
|
127
|
+
server.addEventListener('error', closeAll);
|
|
128
|
+
// The response carries the CLIENT side back to the runtime (101 required).
|
|
129
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
130
|
+
}
|
package/dist/ws-node.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node WebSocket adapter: attaches WsRoutes to an http.Server via the `ws`
|
|
3
|
+
* package. Route matching reuses the same radix trie as HTTP dispatch.
|
|
4
|
+
*/
|
|
5
|
+
import { WebSocketServer } from 'ws';
|
|
6
|
+
import { createMatcher } from './router.js';
|
|
7
|
+
export function attachChannels(httpServer, routes) {
|
|
8
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
9
|
+
const matcher = createMatcher(routes.map((r) => ({ method: 'GET', path: r.path, route: r })));
|
|
10
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(req.url ?? '/', 'http://local');
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
socket.destroy();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const hit = matcher('GET', url.pathname);
|
|
20
|
+
if (!hit || !('route' in hit)) {
|
|
21
|
+
socket.destroy();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const route = hit.route;
|
|
25
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
26
|
+
const send = (frame) => {
|
|
27
|
+
if (ws.readyState === ws.OPEN)
|
|
28
|
+
ws.send(JSON.stringify(frame));
|
|
29
|
+
};
|
|
30
|
+
const state = route.open(send, url);
|
|
31
|
+
ws.on('message', (data) => route.message(state, data.toString()));
|
|
32
|
+
ws.on('close', () => route.close(state));
|
|
33
|
+
ws.on('error', () => ws.close());
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
package/dist/ws.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-neutral WebSocket routing: one protocol implementation, thin
|
|
3
|
+
* adapters per platform (src/ws-node.ts with the `ws` package, src/bun.ts
|
|
4
|
+
* with Bun's native websockets).
|
|
5
|
+
*
|
|
6
|
+
* Channel protocol over WS (mirrors channelRoutes' SSE+POST semantics):
|
|
7
|
+
* server -> client: { event, data } broadcasts + presence_state/presence_diff
|
|
8
|
+
* client -> server: { type: 'push', event, data?, id? }
|
|
9
|
+
* { type: 'heartbeat', meta? }
|
|
10
|
+
*/
|
|
11
|
+
import type { Hub } from './hub.js';
|
|
12
|
+
import type { Presence } from './presence.js';
|
|
13
|
+
export type WsSend = (frame: unknown) => void;
|
|
14
|
+
export interface WsRoute {
|
|
15
|
+
/** Path pattern with :params, same syntax as HTTP contracts. */
|
|
16
|
+
readonly path: string;
|
|
17
|
+
/** Connection established; return per-connection state. */
|
|
18
|
+
open(send: WsSend, url: URL): unknown;
|
|
19
|
+
/** A text frame arrived. */
|
|
20
|
+
message(state: unknown, text: string): void;
|
|
21
|
+
/** Connection closed (client disconnect or server shutdown). */
|
|
22
|
+
close(state: unknown): void;
|
|
23
|
+
}
|
|
24
|
+
export interface WsChannelOptions {
|
|
25
|
+
presence?: Presence;
|
|
26
|
+
}
|
|
27
|
+
export declare function wsChannels(hub: Hub, options?: WsChannelOptions): WsRoute;
|
package/dist/ws.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export function wsChannels(hub, options = {}) {
|
|
2
|
+
const presence = options.presence;
|
|
3
|
+
return {
|
|
4
|
+
path: '/channels/:topic',
|
|
5
|
+
open(send, url) {
|
|
6
|
+
const topic = decodeURIComponent(url.pathname.split('/')[2] ?? '');
|
|
7
|
+
const member = url.searchParams.get('member') ?? undefined;
|
|
8
|
+
const unsub = hub.subscribe(topic, (e) => send({ event: e.event, data: e.data }));
|
|
9
|
+
if (member && presence)
|
|
10
|
+
presence.join(topic, member);
|
|
11
|
+
// Initial full view on connect (parity with the SSE channel routes).
|
|
12
|
+
if (presence)
|
|
13
|
+
send({ event: 'presence_state', data: { members: presence.snapshot(topic) } });
|
|
14
|
+
return { topic, member, unsub, __send: send };
|
|
15
|
+
},
|
|
16
|
+
message(state, text) {
|
|
17
|
+
const s = state;
|
|
18
|
+
let frame;
|
|
19
|
+
try {
|
|
20
|
+
frame = JSON.parse(text);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
s.__send({ error: 'invalid JSON frame' });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
switch (frame.type) {
|
|
27
|
+
case 'push':
|
|
28
|
+
if (!frame.event) {
|
|
29
|
+
s.__send({ error: 'push requires event' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
hub.publish(s.topic, frame.event, frame.data ?? null);
|
|
33
|
+
break;
|
|
34
|
+
case 'heartbeat':
|
|
35
|
+
if (s.member && presence)
|
|
36
|
+
presence.heartbeat(s.topic, s.member, frame.meta);
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
s.__send({ error: `unknown frame type: ${String(frame.type)}` });
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
close(state) {
|
|
43
|
+
const s = state;
|
|
44
|
+
s.unsub();
|
|
45
|
+
if (s.member && presence)
|
|
46
|
+
presence.leave(s.topic, s.member);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|