@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
@@ -0,0 +1,85 @@
1
+ /** '/users/:id' -> '/users/{id}' (OpenAPI syntax) */
2
+ function toOpenApiPath(path) {
3
+ return path.replace(/:([A-Za-z0-9_]+)/g, '{$1}');
4
+ }
5
+ function schemaRefOrInline(schema) {
6
+ // TypeBox schemas ARE JSON Schema; strip non-JSON symbol-keyed internals via JSON round-trip.
7
+ return JSON.parse(JSON.stringify(schema));
8
+ }
9
+ /**
10
+ * OpenAPI 3.1 document generated from contracts.
11
+ * Zero conversion layer: TypeBox == JSON Schema == OpenAPI component vocabulary.
12
+ */
13
+ export function generateOpenApi(routes, info = { title: 'API', version: '0.0.0' }) {
14
+ const paths = {};
15
+ for (const { contract: c } of routes) {
16
+ const oaPath = toOpenApiPath(c.path);
17
+ const operation = {
18
+ operationId: c.name ?? `${c.method.toLowerCase()}_${oaPath.replace(/[^A-Za-z0-9]+/g, '_')}`,
19
+ ...(c.description ? { description: c.description } : {}),
20
+ responses: Object.fromEntries(Object.entries(c.responses).map(([status, schema]) => [
21
+ status,
22
+ {
23
+ description: `Response ${status}`,
24
+ content: { 'application/json': { schema: schemaRefOrInline(schema) } },
25
+ },
26
+ ])),
27
+ };
28
+ const parameters = [];
29
+ if ('params' in c && c.params) {
30
+ const shape = c.params.properties ?? {};
31
+ for (const [name, schema] of Object.entries(shape)) {
32
+ parameters.push({
33
+ name,
34
+ in: 'path',
35
+ required: true,
36
+ schema: schemaRefOrInline(schema),
37
+ });
38
+ }
39
+ }
40
+ if ('query' in c && c.query) {
41
+ const shape = c.query.properties ?? {};
42
+ for (const [name, schema] of Object.entries(shape)) {
43
+ parameters.push({ name, in: 'query', required: false, schema: schemaRefOrInline(schema) });
44
+ }
45
+ }
46
+ const isOptionalSchema = (schema) => schema[Symbol.for('TypeBox.Optional')] === 'Optional';
47
+ if ('headers' in c && c.headers) {
48
+ const shape = c.headers.properties ?? {};
49
+ for (const [name, schema] of Object.entries(shape)) {
50
+ parameters.push({
51
+ name,
52
+ in: 'header',
53
+ required: !isOptionalSchema(schema),
54
+ schema: schemaRefOrInline(schema),
55
+ });
56
+ }
57
+ }
58
+ if ('cookies' in c && c.cookies) {
59
+ const shape = c.cookies.properties ?? {};
60
+ for (const [name, schema] of Object.entries(shape)) {
61
+ parameters.push({
62
+ name,
63
+ in: 'cookie',
64
+ required: !isOptionalSchema(schema),
65
+ schema: schemaRefOrInline(schema),
66
+ });
67
+ }
68
+ }
69
+ if (parameters.length)
70
+ operation.parameters = parameters;
71
+ if ('body' in c && c.body) {
72
+ operation.requestBody = {
73
+ required: true,
74
+ content: { 'application/json': { schema: schemaRefOrInline(c.body) } },
75
+ };
76
+ }
77
+ paths[oaPath] ??= {};
78
+ paths[oaPath][c.method.toLowerCase()] = operation;
79
+ }
80
+ return {
81
+ openapi: '3.1.0',
82
+ info,
83
+ paths,
84
+ };
85
+ }
@@ -0,0 +1,30 @@
1
+ import type { Hub } from './hub.js';
2
+ export interface MemberInfo {
3
+ member: string;
4
+ meta?: unknown;
5
+ onlineAt: number;
6
+ lastSeen: number;
7
+ }
8
+ /**
9
+ * Phoenix-style presence tracking over a Hub.
10
+ * Members heartbeat to stay listed; a TTL sweep removes ghosts and
11
+ * broadcasts `presence_diff`. Full `presence_state` is broadcast on joins.
12
+ *
13
+ * Cluster-aware: frames arriving from other nodes (via the hub's bus) are
14
+ * merged silently into the local map, so every node holds the full view —
15
+ * clients already receive the state/diff events through normal delivery.
16
+ * Any node's sweep can therefore expire ghosts left behind by a dead node.
17
+ */
18
+ export declare class Presence {
19
+ #private;
20
+ private readonly hub;
21
+ private readonly ttlMs;
22
+ constructor(hub: Hub, ttlMs?: number);
23
+ startSweeping(intervalMs?: number): void;
24
+ stopSweeping(): void;
25
+ join(topic: string, member: string, meta?: unknown): void;
26
+ heartbeat(topic: string, member: string, meta?: unknown): void;
27
+ leave(topic: string, member: string): void;
28
+ snapshot(topic: string): MemberInfo[];
29
+ sweep(): void;
30
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Phoenix-style presence tracking over a Hub.
3
+ * Members heartbeat to stay listed; a TTL sweep removes ghosts and
4
+ * broadcasts `presence_diff`. Full `presence_state` is broadcast on joins.
5
+ *
6
+ * Cluster-aware: frames arriving from other nodes (via the hub's bus) are
7
+ * merged silently into the local map, so every node holds the full view —
8
+ * clients already receive the state/diff events through normal delivery.
9
+ * Any node's sweep can therefore expire ghosts left behind by a dead node.
10
+ */
11
+ export class Presence {
12
+ hub;
13
+ ttlMs;
14
+ #topics = new Map();
15
+ #sweeper;
16
+ constructor(hub, ttlMs = 30_000) {
17
+ this.hub = hub;
18
+ this.ttlMs = ttlMs;
19
+ hub.onRemote((e) => this.#applyRemote(e));
20
+ }
21
+ startSweeping(intervalMs = this.ttlMs) {
22
+ if (this.#sweeper)
23
+ return;
24
+ this.#sweeper = setInterval(() => this.sweep(), intervalMs);
25
+ this.#sweeper.unref?.();
26
+ }
27
+ stopSweeping() {
28
+ if (this.#sweeper)
29
+ clearInterval(this.#sweeper);
30
+ this.#sweeper = undefined;
31
+ }
32
+ join(topic, member, meta) {
33
+ const now = Date.now();
34
+ const members = this.#members(topic);
35
+ const existing = members.get(member);
36
+ members.set(member, {
37
+ member,
38
+ ...(meta !== undefined ? { meta } : {}),
39
+ onlineAt: existing?.onlineAt ?? now,
40
+ lastSeen: now,
41
+ });
42
+ this.hub.publish(topic, 'presence_state', { members: this.snapshot(topic) });
43
+ this.hub.emitRemote({ topic, event: 'presence_state', data: { members: this.snapshot(topic) } });
44
+ }
45
+ heartbeat(topic, member, meta) {
46
+ this.join(topic, member, meta);
47
+ }
48
+ leave(topic, member) {
49
+ const members = this.#topics.get(topic);
50
+ if (!members?.delete(member))
51
+ return;
52
+ if (members.size === 0)
53
+ this.#topics.delete(topic);
54
+ this.hub.publish(topic, 'presence_diff', { leaves: [member] });
55
+ this.hub.emitRemote({ topic, event: 'presence_diff', data: { leaves: [member] } });
56
+ }
57
+ snapshot(topic) {
58
+ return [...(this.#topics.get(topic)?.values() ?? [])].map((m) => ({ ...m }));
59
+ }
60
+ sweep() {
61
+ const now = Date.now();
62
+ for (const [topic, members] of [...this.#topics]) {
63
+ const expired = [...members.values()].filter((m) => now - m.lastSeen > this.ttlMs);
64
+ for (const m of expired)
65
+ members.delete(m.member);
66
+ if (expired.length) {
67
+ if (members.size === 0)
68
+ this.#topics.delete(topic);
69
+ this.hub.publish(topic, 'presence_diff', { leaves: expired.map((m) => m.member) });
70
+ this.hub.emitRemote({
71
+ topic,
72
+ event: 'presence_diff',
73
+ data: { leaves: expired.map((m) => m.member) },
74
+ });
75
+ }
76
+ }
77
+ }
78
+ #members(topic) {
79
+ let m = this.#topics.get(topic);
80
+ if (!m) {
81
+ m = new Map();
82
+ this.#topics.set(topic, m);
83
+ }
84
+ return m;
85
+ }
86
+ /**
87
+ * Merge a frame from another node without re-publishing (no loops):
88
+ * state frames add/refresh replicas; leaf diffs remove them. `lastSeen`
89
+ * is stamped with the local clock so sweeps work despite node clock skew.
90
+ */
91
+ #applyRemote(e) {
92
+ if (e.event === 'presence_state') {
93
+ const members = e.data.members ?? [];
94
+ const map = this.#members(e.topic);
95
+ const now = Date.now();
96
+ for (const m of members) {
97
+ const known = map.get(m.member);
98
+ if (known)
99
+ known.lastSeen = now;
100
+ else
101
+ map.set(m.member, { ...m, lastSeen: now });
102
+ }
103
+ }
104
+ else if (e.event === 'presence_diff') {
105
+ const leaves = e.data.leaves ?? [];
106
+ const map = this.#topics.get(e.topic);
107
+ if (!map)
108
+ return;
109
+ for (const member of leaves)
110
+ map.delete(member);
111
+ if (map.size === 0)
112
+ this.#topics.delete(e.topic);
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,13 @@
1
+ import type { ContextKey } from './context.js';
2
+ /**
3
+ * App-scoped dependency registration. A provided value is seeded into every
4
+ * request's Ctx, so handlers and middleware read it with ctx.require(key)
5
+ * and get the same instance each time (singleton lifetime).
6
+ *
7
+ * Request-scoped values (set by middleware at runtime) may overwrite seeds.
8
+ */
9
+ export interface ProvidedEntry {
10
+ readonly key: ContextKey<never>;
11
+ readonly value: unknown;
12
+ }
13
+ export declare function provide<K extends ContextKey<unknown>>(key: K, value: K extends ContextKey<infer V> ? V : never): ProvidedEntry;
@@ -0,0 +1,3 @@
1
+ export function provide(key, value) {
2
+ return { key: key, value };
3
+ }
@@ -0,0 +1,23 @@
1
+ export interface RouteMatch<T> {
2
+ route: T;
3
+ params: Record<string, string>;
4
+ }
5
+ /** The path resolves to a registered endpoint but the method differs (405). */
6
+ export interface MethodMismatch {
7
+ allow: string[];
8
+ }
9
+ export type LookupResult<T> = RouteMatch<T> | MethodMismatch | null;
10
+ export interface MatcherEntry<T> {
11
+ method: string;
12
+ path: string;
13
+ route: T;
14
+ }
15
+ /**
16
+ * Radix-trie matcher built once at app creation: lookup cost is O(path depth),
17
+ * independent of route count. Static segments win over dynamic ones; ties on
18
+ * the same terminal node resolve by insertion order (first registration wins).
19
+ *
20
+ * A resolved terminal node with handlers but none for the requested method
21
+ * yields a MethodMismatch (405 + Allow); an unresolved path yields null (404).
22
+ */
23
+ export declare function createMatcher<T>(entries: MatcherEntry<T>[]): (method: string, pathname: string) => LookupResult<T>;
package/dist/router.js ADDED
@@ -0,0 +1,60 @@
1
+ function emptyNode() {
2
+ return { static: new Map(), handlers: new Map() };
3
+ }
4
+ function segments(pathname) {
5
+ const s = pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
6
+ return s.split('/').filter(Boolean);
7
+ }
8
+ /**
9
+ * Radix-trie matcher built once at app creation: lookup cost is O(path depth),
10
+ * independent of route count. Static segments win over dynamic ones; ties on
11
+ * the same terminal node resolve by insertion order (first registration wins).
12
+ *
13
+ * A resolved terminal node with handlers but none for the requested method
14
+ * yields a MethodMismatch (405 + Allow); an unresolved path yields null (404).
15
+ */
16
+ export function createMatcher(entries) {
17
+ const root = emptyNode();
18
+ let order = 0;
19
+ for (const entry of entries) {
20
+ let node = root;
21
+ for (const seg of segments(entry.path)) {
22
+ if (seg.startsWith(':')) {
23
+ const name = seg.slice(1);
24
+ if (!node.param) {
25
+ node.param = { name, node: emptyNode() };
26
+ }
27
+ node = node.param.node;
28
+ }
29
+ else {
30
+ let child = node.static.get(seg);
31
+ if (!child) {
32
+ child = emptyNode();
33
+ node.static.set(seg, child);
34
+ }
35
+ node = child;
36
+ }
37
+ }
38
+ if (!node.handlers.has(entry.method)) {
39
+ node.handlers.set(entry.method, { route: entry.route, order: order++ });
40
+ }
41
+ }
42
+ return (method, pathname) => {
43
+ let node = root;
44
+ const params = {};
45
+ for (const seg of segments(pathname)) {
46
+ const next = node.static.get(seg) ?? node.param?.node;
47
+ if (!next)
48
+ return null;
49
+ if (!node.static.has(seg))
50
+ params[node.param.name] = decodeURIComponent(seg);
51
+ node = next;
52
+ }
53
+ const hit = node.handlers.get(method);
54
+ if (hit)
55
+ return { route: hit.route, params };
56
+ if (node.handlers.size > 0)
57
+ return { allow: [...node.handlers.keys()] };
58
+ return null;
59
+ };
60
+ }
@@ -0,0 +1,4 @@
1
+ import { Value as TbValue } from '@sinclair/typebox/value';
2
+ export declare const t: import("@sinclair/typebox").JavaScriptTypeBuilder;
3
+ export declare const Value: typeof TbValue;
4
+ export type { Static, TSchema } from '@sinclair/typebox';
package/dist/schema.js ADDED
@@ -0,0 +1,17 @@
1
+ import { Type as TbType, FormatRegistry } from '@sinclair/typebox';
2
+ import { Value as TbValue } from '@sinclair/typebox/value';
3
+ export const t = TbType;
4
+ export const Value = TbValue;
5
+ /* Sensible default formats. Users can extend via FormatRegistry. */
6
+ const formats = {
7
+ email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
8
+ uuid: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
9
+ 'date-time': /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/,
10
+ date: /^\d{4}-\d{2}-\d{2}$/,
11
+ uri: /^https?:\/\/[^\s]+$/,
12
+ ipv4: /^(?:\d{1,3}\.){3}\d{1,3}$/,
13
+ };
14
+ for (const [name, pattern] of Object.entries(formats)) {
15
+ if (!FormatRegistry.Has(name))
16
+ FormatRegistry.Set(name, (value) => pattern.test(value));
17
+ }
@@ -0,0 +1,45 @@
1
+ import { type RouteImpl } from './contract.js';
2
+ import { type Middleware } from './middleware.js';
3
+ import type { ProvidedEntry } from './provide.js';
4
+ import { type ApiMeta } from './llms.js';
5
+ export interface App {
6
+ routes: RouteImpl<any>[];
7
+ fetch(req: Request): Promise<Response>;
8
+ /**
9
+ * Same dispatch as fetch(), without constructing undici Responses for
10
+ * tzin-built replies (~19µs/request under load). Adapters that can write
11
+ * status/text/headers natively should prefer it.
12
+ */
13
+ dispatchRaw?(req: Request): Promise<RawReply>;
14
+ }
15
+ /**
16
+ * A reply that hasn't been wrapped in an undici Response yet. Either fully
17
+ * materialized (text + headers) or a pass-through Response (raw()/SSE).
18
+ */
19
+ export interface RawReply {
20
+ status: number;
21
+ /** Pre-serialized body; absent means empty body (e.g. 202). */
22
+ text?: string;
23
+ headers?: Record<string, string>;
24
+ /** Streaming/raw escape hatch — adapters fall back to Response handling. */
25
+ response?: Response;
26
+ }
27
+ export interface AppOptions {
28
+ middleware?: Middleware[];
29
+ provides?: ProvidedEntry[];
30
+ /** Serve the MCP Streamable HTTP transport at POST /mcp. */
31
+ mcp?: boolean;
32
+ /** Serve /llms.txt and /llms-full.txt generated from contracts. */
33
+ llms?: boolean;
34
+ /** Serve the OpenAPI 3.1 document at /openapi.json, generated from contracts. */
35
+ openapi?: boolean;
36
+ meta?: ApiMeta;
37
+ }
38
+ export declare function fastResponseText(res: Response): string | undefined;
39
+ export declare function fastResponseHeaders(res: Response): Record<string, string> | undefined;
40
+ /**
41
+ * Adapters may attach a signal factory instead of a real signal so Ctx can
42
+ * stay lazy; real Request objects carry a plain signal.
43
+ */
44
+ export declare function signalSource(req: Request): AbortSignal | (() => AbortSignal | undefined) | undefined;
45
+ export declare function createApp(routes: RouteImpl<any>[], options?: AppOptions): App;