@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/cli.js ADDED
@@ -0,0 +1,23 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ function usage() {
4
+ console.error(`tzin CLI
5
+
6
+ tzin dev <entry-file> [--port N] start dev server with hot reload
7
+ entry must default-export a tzin App`);
8
+ process.exit(1);
9
+ }
10
+ const [, , cmd, ...rest] = process.argv;
11
+ if (cmd !== 'dev' || rest.length === 0)
12
+ usage();
13
+ const entry = rest[0];
14
+ let port = '3000';
15
+ const portFlag = rest.indexOf('--port');
16
+ if (portFlag !== -1 && rest[portFlag + 1])
17
+ port = rest[portFlag + 1];
18
+ const devServer = fileURLToPath(new URL('./dev-server.ts', import.meta.url));
19
+ const child = spawn('npx', ['tsx', 'watch', '--clear-screen=false', devServer, entry, port], {
20
+ stdio: 'inherit',
21
+ });
22
+ process.on('SIGINT', () => child.kill('SIGINT'));
23
+ child.on('exit', (code) => process.exit(code ?? 0));
@@ -0,0 +1,47 @@
1
+ /**
2
+ * tzin channels client for browsers and Node >= 22.
3
+ *
4
+ * Zero dependencies: uses the platform's EventSource and fetch.
5
+ *
6
+ * import { joinChannel } from 'tzin/client-browser'
7
+ *
8
+ * const chat = joinChannel('https://api.example.com', 'lobby', { member: 'ada' })
9
+ * chat.on('message', (data) => console.log(data))
10
+ * await chat.push('message', { text: 'hello' })
11
+ */
12
+ interface SSELike {
13
+ addEventListener(type: string, listener: (ev: MessageEventLike) => void): void;
14
+ close(): void;
15
+ }
16
+ interface MessageEventLike {
17
+ data: unknown;
18
+ }
19
+ export interface JoinOptions {
20
+ /** Appear in presence under this name. */
21
+ member?: string;
22
+ /** Metadata attached to your presence entry. */
23
+ meta?: unknown;
24
+ /** Presence refresh interval. Must stay below the server's presence TTL. Default 15s. */
25
+ heartbeatMs?: number;
26
+ /**
27
+ * Custom EventSource implementation. Browsers provide one globally;
28
+ * Node >= 22 needs a polyfill (e.g. from the 'eventsource' package).
29
+ */
30
+ eventSource?: new (url: string) => SSELike;
31
+ }
32
+ export type Unsubscribe = () => void;
33
+ export interface Channel {
34
+ readonly topic: string;
35
+ /** Listen to a named event; returns an unsubscribe function. */
36
+ on(event: string, cb: (data: any) => void): Unsubscribe;
37
+ /** Broadcast to every subscriber of this channel. */
38
+ push(event: string, data?: unknown): Promise<{
39
+ delivered: number;
40
+ }>;
41
+ /** Refresh your presence immediately (the client also does this automatically). */
42
+ heartbeat(): void;
43
+ /** Close the subscription and leave presence. */
44
+ close(): void;
45
+ }
46
+ export declare function joinChannel(baseUrl: string, topic: string, options?: JoinOptions): Channel;
47
+ export {};
@@ -0,0 +1,87 @@
1
+ /**
2
+ * tzin channels client for browsers and Node >= 22.
3
+ *
4
+ * Zero dependencies: uses the platform's EventSource and fetch.
5
+ *
6
+ * import { joinChannel } from 'tzin/client-browser'
7
+ *
8
+ * const chat = joinChannel('https://api.example.com', 'lobby', { member: 'ada' })
9
+ * chat.on('message', (data) => console.log(data))
10
+ * await chat.push('message', { text: 'hello' })
11
+ */
12
+ function parseData(raw) {
13
+ try {
14
+ return typeof raw === 'string' ? JSON.parse(raw) : raw;
15
+ }
16
+ catch {
17
+ return raw;
18
+ }
19
+ }
20
+ export function joinChannel(baseUrl, topic, options = {}) {
21
+ const base = baseUrl.replace(/\/$/, '');
22
+ const { member, meta } = options;
23
+ const heartbeatMs = options.heartbeatMs ?? 15_000;
24
+ const EsCtor = options.eventSource ??
25
+ globalThis.EventSource;
26
+ if (!EsCtor)
27
+ throw new Error('EventSource is not available in this runtime');
28
+ const qs = member !== undefined ? `?member=${encodeURIComponent(member)}` : '';
29
+ const es = new EsCtor(`${base}/channels/${encodeURIComponent(topic)}${qs}`);
30
+ const post = async (path, body) => fetch(`${base}/channels/${encodeURIComponent(topic)}${path}`, {
31
+ method: 'POST',
32
+ headers: { 'content-type': 'application/json' },
33
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
34
+ });
35
+ const listeners = new Map();
36
+ const dispatch = (event, data) => {
37
+ const set = listeners.get(event);
38
+ if (set)
39
+ for (const cb of set)
40
+ cb(data);
41
+ };
42
+ // Known presence events plus any event registered later via .on().
43
+ es.addEventListener('presence_state', (ev) => dispatch('presence_state', parseData(ev.data)));
44
+ es.addEventListener('presence_diff', (ev) => dispatch('presence_diff', parseData(ev.data)));
45
+ es.addEventListener('message', (ev) => dispatch('message', parseData(ev.data)));
46
+ let timer;
47
+ if (member !== undefined && heartbeatMs > 0) {
48
+ timer = setInterval(() => {
49
+ void post('/heartbeat', { member, meta }).catch(() => { });
50
+ }, heartbeatMs);
51
+ }
52
+ return {
53
+ topic,
54
+ on(event, cb) {
55
+ let set = listeners.get(event);
56
+ if (!set) {
57
+ set = new Set();
58
+ listeners.set(event, set);
59
+ if (event !== 'message' && event !== 'presence_state' && event !== 'presence_diff') {
60
+ es.addEventListener(event, (ev) => dispatch(event, parseData(ev.data)));
61
+ }
62
+ }
63
+ set.add(cb);
64
+ return () => {
65
+ set.delete(cb);
66
+ };
67
+ },
68
+ async push(event, data) {
69
+ const res = await post('', { event, data });
70
+ if (!res.ok)
71
+ throw new Error(`push failed: HTTP ${res.status}`);
72
+ return (await res.json());
73
+ },
74
+ heartbeat() {
75
+ if (member === undefined)
76
+ return;
77
+ void post('/heartbeat', { member, meta }).catch(() => { });
78
+ },
79
+ close() {
80
+ if (timer !== undefined)
81
+ clearInterval(timer);
82
+ es.close();
83
+ if (member !== undefined)
84
+ void post('/leave', { member }).catch(() => { });
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,20 @@
1
+ import type { AnyContract, StaticOf, SectionsOf } from './contract.js';
2
+ /**
3
+ * One union member per declared response status: checking `res.status === 200`
4
+ * narrows `body` to exactly what the contract promised. The client trusts the
5
+ * contract completely — an undeclared status is a contract violation.
6
+ */
7
+ export type ClientResult<C extends AnyContract> = {
8
+ [K in keyof C['responses']]: {
9
+ status: K extends number ? K : K extends `${infer N extends number}` ? N : never;
10
+ body: StaticOf<C['responses'][K]>;
11
+ };
12
+ }[keyof C['responses']];
13
+ export type CallerFn<C extends AnyContract> = (input: SectionsOf<C> & {
14
+ fetchInit?: RequestInit;
15
+ }) => Promise<ClientResult<C>>;
16
+ /** Flat mapped type over a record of contracts: O(routes), no nesting. */
17
+ export type ClientOf<Routes extends Record<string, AnyContract>> = {
18
+ [K in keyof Routes]: CallerFn<Routes[K]>;
19
+ };
20
+ export declare function client<Routes extends Record<string, AnyContract>>(routes: Routes, baseUrl?: string): ClientOf<Routes>;
package/dist/client.js ADDED
@@ -0,0 +1,36 @@
1
+ export function client(routes, baseUrl = '') {
2
+ return new Proxy({}, {
3
+ get(_target, key) {
4
+ if (typeof key !== 'string' || !(key in routes))
5
+ return undefined;
6
+ const c = routes[key];
7
+ return async (input = {}) => {
8
+ let path = c.path.replace(/:([A-Za-z0-9_]+)/g, (_m, name) => encodeURIComponent(String(input.params?.[name] ?? `{${name}}`)));
9
+ if (input.query) {
10
+ const qs = new URLSearchParams(Object.entries(input.query).map(([k, v]) => [k, String(v)])).toString();
11
+ if (qs)
12
+ path += `?${qs}`;
13
+ }
14
+ const init = { method: c.method, ...input.fetchInit };
15
+ if ('body' in c && c.body)
16
+ init.body = JSON.stringify(input.body);
17
+ const headers = { ...input.headers };
18
+ if (input.cookies) {
19
+ headers.cookie = Object.entries(input.cookies)
20
+ .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
21
+ .join('; ');
22
+ }
23
+ init.headers = { ...init.headers, ...headers };
24
+ const res = await fetch(baseUrl + path, init);
25
+ let body;
26
+ try {
27
+ body = await res.json();
28
+ }
29
+ catch {
30
+ body = null;
31
+ }
32
+ return { status: res.status, body };
33
+ };
34
+ },
35
+ });
36
+ }
@@ -0,0 +1,26 @@
1
+ declare const brand: unique symbol;
2
+ /** Typed key for the request context bag. Create once per piece of data. */
3
+ export interface ContextKey<T> {
4
+ readonly name: string;
5
+ readonly [brand]?: T;
6
+ }
7
+ export declare function defineContext<T>(name: string): ContextKey<T>;
8
+ /**
9
+ * Per-request typed store. Middleware writes values; handlers read them.
10
+ * Keys carry their value type, so get/require are fully inferred.
11
+ */
12
+ export declare class Ctx {
13
+ #private;
14
+ /**
15
+ * Accepts a ready signal or a factory. Adapters pass a factory so the
16
+ * AbortController + close-listener wiring only happens when something
17
+ * actually reads ctx.signal (SSE, channels) — not on the JSON hot path.
18
+ */
19
+ constructor(signal?: AbortSignal | (() => AbortSignal | undefined), seed?: ReadonlyMap<ContextKey<never>, unknown>);
20
+ get signal(): AbortSignal | undefined;
21
+ get<T>(key: ContextKey<T>): T | undefined;
22
+ /** Read a mandatory value; a missing one is a server bug -> 500. */
23
+ require<T>(key: ContextKey<T>): T;
24
+ set<T>(key: ContextKey<T>, value: T): void;
25
+ }
26
+ export {};
@@ -0,0 +1,49 @@
1
+ import { HttpError } from './contract.js';
2
+ export function defineContext(name) {
3
+ return { name };
4
+ }
5
+ /**
6
+ * Per-request typed store. Middleware writes values; handlers read them.
7
+ * Keys carry their value type, so get/require are fully inferred.
8
+ */
9
+ export class Ctx {
10
+ /** Allocated on first use — most requests never touch the bag. */
11
+ #map;
12
+ #signal;
13
+ #getSignal;
14
+ /**
15
+ * Accepts a ready signal or a factory. Adapters pass a factory so the
16
+ * AbortController + close-listener wiring only happens when something
17
+ * actually reads ctx.signal (SSE, channels) — not on the JSON hot path.
18
+ */
19
+ constructor(signal, seed) {
20
+ if (typeof signal === 'function')
21
+ this.#getSignal = signal;
22
+ else
23
+ this.#signal = signal;
24
+ if (seed && seed.size > 0) {
25
+ this.#map = new Map(seed);
26
+ }
27
+ }
28
+ get signal() {
29
+ if (this.#signal === undefined && this.#getSignal) {
30
+ this.#signal = this.#getSignal();
31
+ this.#getSignal = undefined;
32
+ }
33
+ return this.#signal;
34
+ }
35
+ get(key) {
36
+ return this.#map?.get(key);
37
+ }
38
+ /** Read a mandatory value; a missing one is a server bug -> 500. */
39
+ require(key) {
40
+ const v = this.get(key);
41
+ if (v === undefined)
42
+ throw new HttpError(500, `Missing context '${key.name}'`);
43
+ return v;
44
+ }
45
+ set(key, value) {
46
+ ;
47
+ (this.#map ??= new Map()).set(key, value);
48
+ }
49
+ }
@@ -0,0 +1,71 @@
1
+ import type { Static, TSchema } from './schema.js';
2
+ import type { Ctx } from './context.js';
3
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
4
+ /**
5
+ * A Contract is the single source of truth for an endpoint.
6
+ * Plain object literal -> shallow types -> no deep instantiation.
7
+ */
8
+ export interface ContractDef {
9
+ method: HttpMethod;
10
+ path: string;
11
+ /** Stable identifier reused by OpenAPI operationId and MCP tool name. */
12
+ name?: string;
13
+ /** Human/agent-readable summary, surfaced in OpenAPI and MCP. */
14
+ description?: string;
15
+ params?: TSchema;
16
+ query?: TSchema;
17
+ body?: TSchema;
18
+ responses: Record<number, TSchema>;
19
+ }
20
+ /** Identity function with `const` generics: preserves literal method/path/status keys. */
21
+ export declare function contract<const C extends ContractDef>(def: C): C;
22
+ export type AnyContract = ContractDef;
23
+ export type StaticOf<S> = S extends TSchema ? Static<S> : never;
24
+ /** '/users/:id/posts/:postId' -> 'id' | 'postId' */
25
+ export type PathParamNames<P extends string> = P extends `${string}:${infer Name}/${infer Rest}` ? Name | PathParamNames<Rest> : P extends `${string}:${infer Name}` ? Name : never;
26
+ /**
27
+ * The request sections a contract declares. Shared by server input and
28
+ * client call args so both sides stay in sync.
29
+ */
30
+ export type SectionsOf<C extends AnyContract> = ('params' extends keyof C ? {
31
+ params: StaticOf<C['params']>;
32
+ } : {}) & ('query' extends keyof C ? {
33
+ query: StaticOf<C['query']>;
34
+ } : {}) & ('body' extends keyof C ? {
35
+ body: StaticOf<C['body']>;
36
+ } : {}) & ('headers' extends keyof C ? {
37
+ headers: StaticOf<C['headers']>;
38
+ } : {}) & ('cookies' extends keyof C ? {
39
+ cookies: StaticOf<C['cookies']>;
40
+ } : {});
41
+ /** Extractor-style handler input: declared sections + per-request context. */
42
+ export type HandlerInput<C extends AnyContract> = {
43
+ ctx: Ctx;
44
+ } & SectionsOf<C>;
45
+ /** Discriminated union of every declared success response. */
46
+ export type ResponseOf<C extends AnyContract> = {
47
+ [K in keyof C['responses']]: {
48
+ status: K extends number ? K : K extends `${infer N extends number}` ? N : never;
49
+ body: StaticOf<C['responses'][K]>;
50
+ };
51
+ }[keyof C['responses']];
52
+ export type Handler<C extends AnyContract> = (input: HandlerInput<C>) => ResponseOf<C> | RawResult | Promise<ResponseOf<C> | RawResult>;
53
+ /** Escape hatch: return a pre-built Response (streaming, files, proxies...). */
54
+ export interface RawResult {
55
+ readonly __tzin_raw: Response;
56
+ }
57
+ export declare function raw(res: Response): RawResult;
58
+ export declare function isRawResult(v: unknown): v is RawResult;
59
+ export interface RouteImpl<C extends AnyContract = AnyContract> {
60
+ contract: C;
61
+ handler: Handler<C>;
62
+ }
63
+ /** Bind an implementation to a contract. The compiler checks inputs AND outputs. */
64
+ export declare function impl<const C extends AnyContract>(c: C, handler: Handler<C>): RouteImpl<C>;
65
+ /** Thrown inside handlers -> converted to an HTTP error response. */
66
+ export declare class HttpError extends Error {
67
+ readonly status: number;
68
+ readonly details?: unknown | undefined;
69
+ readonly headers?: Record<string, string> | undefined;
70
+ constructor(status: number, message: string, details?: unknown | undefined, headers?: Record<string, string> | undefined);
71
+ }
@@ -0,0 +1,28 @@
1
+ /** Identity function with `const` generics: preserves literal method/path/status keys. */
2
+ export function contract(def) {
3
+ return def;
4
+ }
5
+ const RAW_MARKER = '__tzin_raw';
6
+ export function raw(res) {
7
+ return { [RAW_MARKER]: res };
8
+ }
9
+ export function isRawResult(v) {
10
+ return typeof v === 'object' && v !== null && RAW_MARKER in v;
11
+ }
12
+ /** Bind an implementation to a contract. The compiler checks inputs AND outputs. */
13
+ export function impl(c, handler) {
14
+ return { contract: c, handler };
15
+ }
16
+ /** Thrown inside handlers -> converted to an HTTP error response. */
17
+ export class HttpError extends Error {
18
+ status;
19
+ details;
20
+ headers;
21
+ constructor(status, message, details, headers) {
22
+ super(message);
23
+ this.status = status;
24
+ this.details = details;
25
+ this.headers = headers;
26
+ this.name = 'HttpError';
27
+ }
28
+ }
package/dist/cors.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { Middleware } from './middleware.js';
2
+ /**
3
+ * How Access-Control-Allow-Origin is computed for a request origin.
4
+ * - '*': literal wildcard (invalid alongside credentials:true)
5
+ * - true: reflect any request origin (credentials-safe)
6
+ * - string[]: allow-list; the matching entry is echoed back
7
+ */
8
+ export interface CorsOptions {
9
+ origin?: string | true | string[];
10
+ /** Default: GET, HEAD, PUT, PATCH, POST, DELETE. */
11
+ methods?: string[];
12
+ /** Echoed back when the browser asks via access-control-request-headers. */
13
+ allowHeaders?: string[];
14
+ /** Response headers the browser may read from JS. */
15
+ exposeHeaders?: string[];
16
+ credentials?: boolean;
17
+ /** Preflight cache lifetime in seconds. Default 24h. */
18
+ maxAge?: number;
19
+ }
20
+ /**
21
+ * CORS as onion middleware. Register outermost so preflight OPTIONS requests
22
+ * short-circuit before routing (a preflight never matches a route).
23
+ *
24
+ * A disallowed origin does not error: the response simply carries no
25
+ * Access-Control-Allow-Origin header and the browser enforces the block.
26
+ */
27
+ export declare function cors(options?: CorsOptions): Middleware;
package/dist/cors.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * CORS as onion middleware. Register outermost so preflight OPTIONS requests
3
+ * short-circuit before routing (a preflight never matches a route).
4
+ *
5
+ * A disallowed origin does not error: the response simply carries no
6
+ * Access-Control-Allow-Origin header and the browser enforces the block.
7
+ */
8
+ export function cors(options = {}) {
9
+ const { origin = '*', methods = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], allowHeaders, exposeHeaders, credentials = false, maxAge = 86400, } = options;
10
+ const methodsHeader = methods.join(', ');
11
+ function allowOriginFor(requestOrigin) {
12
+ if (Array.isArray(origin)) {
13
+ return requestOrigin && origin.includes(requestOrigin) ? requestOrigin : undefined;
14
+ }
15
+ if (origin === true)
16
+ return requestOrigin ?? undefined;
17
+ // origin is now '*' or an explicit string.
18
+ return credentials ? undefined : origin;
19
+ }
20
+ return async ({ req, next }) => {
21
+ const requestOrigin = req.headers.get('origin');
22
+ const requestedMethod = req.headers.get('access-control-request-method');
23
+ const isPreflight = req.method === 'OPTIONS' && requestedMethod !== null;
24
+ // Not a CORS request (same-origin GETs, curl, server-to-server): untouched.
25
+ if (!requestOrigin && !isPreflight)
26
+ return next();
27
+ const allowOrigin = allowOriginFor(requestOrigin);
28
+ const baseHeaders = {
29
+ vary: 'Origin',
30
+ ...(allowOrigin ? { 'access-control-allow-origin': allowOrigin } : {}),
31
+ ...(allowOrigin && credentials ? { 'access-control-allow-credentials': 'true' } : {}),
32
+ };
33
+ if (isPreflight) {
34
+ const requestedHeaders = req.headers.get('access-control-request-headers') ?? allowHeaders?.join(', ');
35
+ return new Response(null, {
36
+ status: 204,
37
+ headers: {
38
+ ...baseHeaders,
39
+ 'access-control-allow-methods': methodsHeader,
40
+ 'access-control-max-age': String(maxAge),
41
+ ...(requestedHeaders ? { 'access-control-allow-headers': requestedHeaders } : {}),
42
+ },
43
+ });
44
+ }
45
+ const res = await next();
46
+ for (const [k, v] of Object.entries(baseHeaders))
47
+ res.headers.set(k, v);
48
+ if (exposeHeaders?.length) {
49
+ res.headers.set('access-control-expose-headers', exposeHeaders.join(', '));
50
+ }
51
+ return res;
52
+ };
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { listen } from './node.js';
3
+ function pad(s, n) {
4
+ return s.length >= n ? s : s + ' '.repeat(n - s.length);
5
+ }
6
+ function printRouteTable(routes) {
7
+ console.log(`\ntzin dev · ${routes.length} routes\n`);
8
+ for (const { contract: c } of routes) {
9
+ const method = `\x1b[36m${pad(c.method, 7)}\x1b[0m`;
10
+ const name = c.name ? ` \x1b[2m${c.name}\x1b[0m` : '';
11
+ const desc = c.description ? ` \x1b[2m— ${c.description}\x1b[0m` : '';
12
+ console.log(` ${method} ${pad(c.path, 34)}${name}${desc}`);
13
+ }
14
+ console.log('');
15
+ }
16
+ const entry = process.argv[2];
17
+ const port = Number(process.argv[3] ?? 3000);
18
+ if (!entry) {
19
+ console.error('usage: dev-server <entry-file> [port]');
20
+ process.exit(1);
21
+ }
22
+ const mod = await import(pathToFileURL(entry).href);
23
+ const app = mod.default ?? mod.app;
24
+ if (!app || typeof app.fetch !== 'function' || !Array.isArray(app.routes)) {
25
+ console.error('entry file must default-export (or export `app`) a tzin App');
26
+ process.exit(1);
27
+ }
28
+ printRouteTable(app.routes);
29
+ listen(app, port);
30
+ console.log(`\x1b[32m➜\x1b[0m http://localhost:${port} (watching for changes)`);
package/dist/hub.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ export interface ChannelEvent {
2
+ topic: string;
3
+ event: string;
4
+ data: unknown;
5
+ }
6
+ export type Subscriber = (e: ChannelEvent) => void;
7
+ export interface BusLike {
8
+ publish(channel: string, message: string): void;
9
+ subscribe(channel: string, handler: (message: string) => void): () => void;
10
+ }
11
+ export interface HubOptions {
12
+ /**
13
+ * Wire this hub into a cluster: publishes fan out over the bus and remote
14
+ * frames are delivered to local subscribers (own frames are ignored).
15
+ * See src/bus.ts — Redis PUBLISH/SUBSCRIBE maps directly onto it.
16
+ */
17
+ bus?: BusLike;
18
+ channelPrefix?: string;
19
+ }
20
+ /**
21
+ * In-process pub/sub hub — the local delivery point of a realtime node.
22
+ * Standalone by default; pass `bus` to join a multi-node deployment.
23
+ */
24
+ export declare class Hub {
25
+ #private;
26
+ constructor(options?: HubOptions);
27
+ /**
28
+ * Infrastructure hook: invoked for every frame arriving from OTHER nodes
29
+ * over the dedicated infra channel, even when the topic has no local
30
+ * subscribers (e.g. presence replication). Returns an unsubscribe function.
31
+ */
32
+ onRemote(fn: Subscriber): () => void;
33
+ /** Broadcast an infrastructure event to every other node's onRemote listeners. */
34
+ emitRemote(e: ChannelEvent): void;
35
+ subscribe(topic: string, fn: Subscriber): () => void;
36
+ /** Deliver locally and, when clustered, broadcast to every other node. */
37
+ publish(topic: string, event: string, data: unknown): number;
38
+ subscriberCount(topic: string): number;
39
+ }