@carlos-tzin/tzin 0.1.9 → 0.1.11

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/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  > A pact between client and server, declared once.
7
7
 
8
8
  [![CI](https://github.com/Charly921/tzin/actions/workflows/ci.yml/badge.svg)](https://github.com/Charly921/tzin/actions/workflows/ci.yml)
9
+ [![npm](https://img.shields.io/npm/v/@carlos-tzin/tzin)](https://www.npmjs.com/package/@carlos-tzin/tzin)
9
10
 
10
11
  **Status: experimental, pre-1.0.** The core works end-to-end and the scaling thesis is
11
12
  measured (see [Benchmarks](#benchmarks)), but this is not yet production software.
@@ -24,6 +25,7 @@ npx create-tzin my-app
24
25
 
25
26
  - **[API Reference](docs/api-reference.md)** — all exports, types, and options
26
27
  - **[Architecture Guide](docs/architecture.md)** — request pipeline, design decisions, internals
28
+ - **[Roadmap](docs/roadmap.md)** — future features
27
29
 
28
30
  ## Why another framework?
29
31
 
@@ -41,7 +43,7 @@ tzin's answer: **declare a contract once**, get everything else for free.
41
43
 
42
44
  ```ts
43
45
  import { t } from '@carlos-tzin/tzin'
44
- import { contract, impl, createApp } from '@carlos-tzin/tzin'
46
+ import { contract, impl, createApp, listen } from '@carlos-tzin/tzin'
45
47
 
46
48
  const getUser = contract({
47
49
  method: 'GET',
@@ -58,234 +60,191 @@ export const getUserRoute = impl(getUser, async ({ params }) => {
58
60
  if (!user) throw new HttpError(404, 'user not found')
59
61
  return { status: 200, body: user }
60
62
  })
63
+
64
+ const app = createApp([getUserRoute])
65
+ listen(app, 3000)
61
66
  ```
62
67
 
63
68
  From that single declaration:
64
69
 
65
70
  - **Handler input is extracted, not guessed**: `{ params }` exists because you declared
66
71
  it; add `query`, `body`, `headers` or `cookies` to the contract and they appear,
67
- fully typed and validated per request (cookies are parsed from the header).
72
+ fully typed and validated per request.
68
73
  - **The compiler enforces your responses**: returning a shape that doesn't match the
69
74
  declared `200` body is a type error. Thrown `HttpError`s map to their status.
70
75
  - **OpenAPI 3.1 is free**: contracts are JSON Schema (TypeBox), so
71
76
  `generateOpenApi(routes)` needs no translation layer.
72
77
 
73
- ### Server
74
-
75
- Web Standards all the way down — an app is just `fetch(req): Promise<Response>`,
76
- testable without a socket, deployable on Node, Bun, or edge workers:
77
-
78
- ```ts
79
- const app = createApp([getUserRoute])
78
+ ## Features
80
79
 
81
- // Node
82
- listen(app, 3000)
83
- // Anywhere else
84
- export default { fetch: app.fetch }
85
- ```
80
+ ### Core
86
81
 
87
- ### Typed client
82
+ | Feature | Import | Description |
83
+ |---|---|---|
84
+ | Contracts | `@carlos-tzin/tzin` | Type-safe API contracts |
85
+ | Routes | `@carlos-tzin/tzin` | Bind contracts to handlers |
86
+ | Middleware | `@carlos-tzin/tzin` | Onion-style middleware |
87
+ | DI | `@carlos-tzin/tzin` | Typed dependency injection |
88
88
 
89
- One call, fully inferred — statuses are a discriminated union:
89
+ ### Runtime
90
90
 
91
- ```ts
92
- const api = client({ getUser }, 'https://api.example.com')
93
-
94
- const res = await api.getUser({ params: { id: 'u1' } })
95
- if (res.status === 200) {
96
- res.body.name // string
97
- } else {
98
- res.body.error // string
99
- }
100
- ```
91
+ | Feature | Import | Description |
92
+ |---|---|---|
93
+ | Node server | `@carlos-tzin/tzin` | `listen(app, port)` |
94
+ | Bun server | `@carlos-tzin/tzin` | `serveBun(app, port)` |
95
+ | Workers | `@carlos-tzin/tzin` | `toWorker(app)` |
96
+ | Dev server | CLI | `tzin dev` with hot reload |
101
97
 
102
- ## Benchmarks
98
+ ### Realtime
103
99
 
104
- 100 endpoints with distinct schemas, checked by `tsc --extendedDiagnostics`
105
- (generate with `node scripts/gen-fixtures.mjs <N>`):
100
+ | Feature | Import | Description |
101
+ |---|---|---|
102
+ | Channels | `@carlos-tzin/tzin` | SSE + POST channels |
103
+ | Presence | `@carlos-tzin/tzin` | Phoenix-style presence |
104
+ | WebSocket | `@carlos-tzin/tzin/ws` | Native WS channels |
105
+ | Browser client | `@carlos-tzin/tzin/client-browser` | Zero-dep browser client |
106
106
 
107
- | N routes | tzin: types | Hono: types | tzin: instantiations | Hono: instantiations | Check tzin / hono |
108
- |---|---|---|---|---|---|
109
- | 20 | 4,851 | 72,368 | 22,472 | 156,054 | 0.25s / 0.31s |
110
- | 100 | 15,779 | 111,952 | 93,128 | 240k | 0.53s / 0.55s |
111
- | 300 | 43,099 | 210,912 | 270k | 590k | 1.13s / 1.34s |
107
+ ### AI-Native
112
108
 
113
- tzin grows **strictly linearly** (~130 types/endpoint). Two structural findings about
114
- the chained-builder model this measures against:
109
+ | Feature | Import | Description |
110
+ |---|---|---|
111
+ | MCP Server | `@carlos-tzin/tzin` | Streamable HTTP + stdio |
112
+ | OpenAPI | `@carlos-tzin/tzin` | Auto-generated from contracts |
113
+ | LLMs.txt | `@carlos-tzin/tzin` | AI-readable API index |
115
114
 
116
- 1. Route chains must live in a **single expression** — write routes as separate
117
- statements and `typeof app` silently loses every route (intermediate types only
118
- flow through the chain).
119
- 2. Registering the same path twice **silently degrades client typing**.
115
+ ### Database
120
116
 
121
- A flat registry of plain objects has neither failure mode by construction.
117
+ | Feature | Import | Description |
118
+ |---|---|---|
119
+ | Models | `@carlos-tzin/tzin/db` | Type-safe ORM |
120
+ | Query builder | `@carlos-tzin/tzin/db` | Chainable queries |
121
+ | Store adapters | `@carlos-tzin/tzin/db` | Swappable backends |
122
122
 
123
- *Honest caveat: the Hono fixture returns constant JSON without validators; the
124
- documented blow-ups compound further when `zValidator` inference enters the chain.*
123
+ ### Auth
125
124
 
126
- ### Runtime throughput
125
+ | Feature | Import | Description |
126
+ |---|---|---|
127
+ | Bearer auth | `@carlos-tzin/tzin/auth` | JWT validation |
128
+ | Optional auth | `@carlos-tzin/tzin/auth` | Non-strict JWT |
129
+ | API key | `@carlos-tzin/tzin/auth` | Header-based auth |
130
+ | JWT utils | `@carlos-tzin/tzin/auth` | sign/verify |
127
131
 
128
- 100 routes · `GET /r50/item` · 64 connections · 5s (`npm run bench:http`, autocannon,
129
- each framework in its own process, 3 rotated rounds, median — order rotates so
130
- machine drift can't bias any variant; tzin runs from built `dist/`):
132
+ ### Jobs
131
133
 
132
- | Framework | req/s | p99 |
134
+ | Feature | Import | Description |
133
135
  |---|---|---|
134
- | raw node:http (floor) | ~42k | 2–3ms |
135
- | hono | ~34–36k | 3ms |
136
- | tzin | ~30–31k | 6ms |
137
- | express | ~15k | 7ms |
136
+ | Define jobs | `@carlos-tzin/tzin/jobs` | Background tasks |
137
+ | Enqueue | `@carlos-tzin/tzin/jobs` | Job queue |
138
+ | Retry | `@carlos-tzin/tzin/jobs` | Auto-retry on failure |
138
139
 
139
- The honest decomposition (`npm run bench:pipeline`, in-process `app.fetch` with
140
- identical request construction):
140
+ ### Logging
141
141
 
142
- | Metric | tzin | hono |
142
+ | Feature | Import | Description |
143
143
  |---|---|---|
144
- | in-process dispatch | ~127k req/s | ~118k req/s |
144
+ | Logger | `@carlos-tzin/tzin/log` | Structured logging |
145
+ | Child loggers | `@carlos-tzin/tzin/log` | Scoped context |
146
+ | Pretty/JSON | `@carlos-tzin/tzin/log` | Configurable output |
145
147
 
146
- Framework-side, tzin matches hono (routing is a radix trie at 9.1M lookups/s,
147
- full TypeBox validation costs ≈0.05µs/request). The Node adapter duck-types
148
- requests, memoizes route matches per `METHOD path`, keeps the abort signal lazy
149
- (wired only if something reads `ctx.signal`), and skips undici Response
150
- construction entirely on the JSON hot path via `app.dispatchRaw` — tzin lands
151
- at ~0.9x of hono over the network.
148
+ ### Testing
152
149
 
153
- ## AI-native: every API is an MCP server
150
+ | Feature | Import | Description |
151
+ |---|---|---|
152
+ | Test client | `@carlos-tzin/tzin/test` | API testing |
153
+ | Schema validation | `@carlos-tzin/tzin/test` | Type assertions |
154
+ | Mock data | `@carlos-tzin/tzin/test` | Generate mocks |
154
155
 
155
- tzin ships first-class [MCP](https://modelcontextprotocol.io) support — the same
156
- contracts that generate your OpenAPI document also expose your endpoints as tools
157
- for AI agents:
156
+ ## Quick Start
158
157
 
159
- ```ts
160
- import { startStdioMcp } from '@carlos-tzin/tzin'
158
+ ### 1. Scaffold
161
159
 
162
- const app = createApp(routes)
163
- startStdioMcp(app) // newline-delimited JSON-RPC on stdio
160
+ ```bash
161
+ npx create-tzin my-api
162
+ cd my-api
164
163
  ```
165
164
 
166
- - `tools/list` returns each endpoint with a real JSON Schema `inputSchema` assembled
167
- from its declared sections — **no conversion layer**, TypeBox already is JSON Schema.
168
- - `tools/call` dispatches **in-process** through the full app: validation, middleware
169
- and DI all apply; HTTP errors surface as `isError` results.
170
- - Contract-level `name` and `description` become the tool's identity; OpenAPI reuses
171
- them as `operationId`/`description`.
172
-
173
- Prefer HTTP? Enable the Streamable HTTP transport on the same app:
165
+ ### 2. Define a contract
174
166
 
175
167
  ```ts
176
- const app = createApp(routes, { mcp: true }) // POST /mcp speaks JSON-RPC
177
- ```
168
+ // src/routes/users.ts
169
+ import { t, contract, impl } from '@carlos-tzin/tzin'
178
170
 
179
- And give LLMs a map of your API straight from the contracts:
171
+ export const getUser = contract({
172
+ method: 'GET',
173
+ path: '/users/:id',
174
+ params: t.Object({ id: t.String() }),
175
+ responses: {
176
+ 200: t.Object({ id: t.String(), name: t.String() }),
177
+ 404: t.Object({ error: t.String() }),
178
+ },
179
+ })
180
180
 
181
- ```ts
182
- const app = createApp(routes, {
183
- llms: true,
184
- openapi: true,
185
- meta: { title: 'My API' },
181
+ export const getUserRoute = impl(getUser, async ({ params }) => {
182
+ return { status: 200, body: { id: params.id, name: 'Ada' } }
186
183
  })
187
- // GET /llms.txt — index of endpoints (method, path, name, description)
188
- // GET /llms-full.txt — same index plus every declared JSON Schema inline
189
- // GET /openapi.json — OpenAPI 3.1 document (TypeBox == JSON Schema == OpenAPI)
190
184
  ```
191
185
 
192
- ## Realtime channels with presence
193
-
194
- Phoenix-style channels, mounted as ordinary routes — SSE down, POST up, so it
195
- runs on every runtime including Workers:
186
+ ### 3. Create the app
196
187
 
197
188
  ```ts
198
- import { Hub, Presence, channelRoutes } from '@carlos-tzin/tzin'
199
-
200
- const hub = new Hub()
201
- const presence = new Presence(hub, 30_000)
189
+ // src/app.ts
190
+ import { createApp } from '@carlos-tzin/tzin'
191
+ import { getUserRoute } from './routes/users.js'
202
192
 
203
- const app = createApp(channelRoutes(hub, { presence }))
204
- // GET /channels/:topic?member=alice → SSE stream; presence joins
205
- // POST /channels/:topic → { event, data } broadcast
206
- // POST /channels/:topic/heartbeat → keep member listed (TTL-refreshed)
207
- // POST /channels/:topic/leave → presence_diff broadcast
193
+ export const app = createApp([getUserRoute], {
194
+ openapi: true,
195
+ mcp: true,
196
+ meta: { title: 'My API', version: '1.0.0' },
197
+ })
208
198
  ```
209
199
 
210
- Members that stop heartbeating are swept and announced via `presence_diff` —
211
- ghost clients disappear even after crashes. The in-memory `Hub` is one process;
212
- multi-node deployments wire hubs together over a message bus:
213
-
214
- ```ts
215
- import { Hub } from '@carlos-tzin/tzin'
216
- import { LocalBus, type MessageBus } from 'tzin/bus'
217
-
218
- // Any PUBLISH/SUBSCRIBE transport maps onto this 2-method interface:
219
- const bus: MessageBus = redisPubSubAdapter // Redis, Postgres LISTEN/NOTIFY...
200
+ ### 4. Run
220
201
 
221
- const nodeA = new Hub({ bus })
222
- const nodeB = new Hub({ bus })
223
- // A publish on node A reaches subscribers of every node; own frames are
224
- // ignored, so there is no echo. Bus subscriptions are per-topic and lazy.
202
+ ```bash
203
+ npx tzin dev # dev server at http://localhost:3000
204
+ npx tzin build # production build
225
205
  ```
226
206
 
227
- Presence replicates Phoenix-style: every node merges remote join/leave/sweep
228
- frames into its local view, so `presence_state` snapshots are complete
229
- cluster-wide, subscribers get the full roster on connect, and ghosts left by a
230
- dead node are expired by any surviving node's TTL sweep.
207
+ ## CLI
231
208
 
232
- A zero-dependency client ships for browsers (and Node >= 22 with any
233
- EventSource polyfill) auto-heartbeat, presence events and reconnection via
234
- the platform's EventSource:
235
-
236
- ```ts
237
- import { joinChannel } from 'tzin/client-browser'
238
-
239
- const chat = joinChannel('https://api.example.com', 'lobby', { member: 'ada' })
240
- chat.on('message', (data) => render(data))
241
- chat.on('presence_diff', (d) => updateRoster(d))
242
- await chat.push('message', { text: 'hello' })
209
+ ```bash
210
+ tzin dev [--port N] # dev server with hot reload
211
+ tzin build # build for production
212
+ tzin deploy --target node|workers # deploy
213
+ tzin generate route <name> # scaffold a route
214
+ tzin generate middleware <name> # scaffold middleware
215
+ tzin generate test <name> # scaffold a test
243
216
  ```
244
217
 
245
- ### Native WebSockets
246
-
247
- Prefer WebSockets over SSE+POST? The same Hub and Presence power a WS route —
248
- one protocol implementation, thin per-runtime adapters:
249
-
250
- ```ts
251
- import { wsChannels } from 'tzin/ws'
252
- import { attachChannels } from 'tzin/ws-node' // Node adapter (ws package)
253
- // Bun: serve(app, port, { wsRoutes: [wsChannels(hub, { presence })] })
218
+ ## Benchmarks
254
219
 
255
- const server = await listen(createApp(routes), 3000)
256
- attachChannels(server, [wsChannels(hub, { presence })])
257
- // ws://host/channels/:topic?member=ada — frames: {type:'push'|'heartbeat'}, receive {event,data}
258
- ```
220
+ 100 endpoints with distinct schemas, checked by `tsc --extendedDiagnostics`:
259
221
 
260
- On Cloudflare Workers, run the whole app inside a Durable Object so every
261
- connection shares one I/O context — cross-connection broadcast then behaves
262
- exactly like Node/Bun (workerd drops sends that come from another request's
263
- context, which is why plain fetch handlers can't relay between sockets):
222
+ | N routes | tzin: types | Hono: types | tzin: instantiations | Hono: instantiations |
223
+ |---|---|---|---|---|
224
+ | 20 | 4,851 | 72,368 | 22,472 | 156,054 |
225
+ | 100 | 15,779 | 111,952 | 93,128 | 240k |
226
+ | 300 | 43,099 | 210,912 | 270k | 590k |
264
227
 
265
- ```ts
266
- import { toDurableWorker, toWorker, TzinChannels } from '@carlos-tzin/tzin'
267
- import { Hub, Presence, channelRoutes, wsChannels, createApp } from '@carlos-tzin/tzin'
228
+ tzin grows **strictly linearly** (~130 types/endpoint).
268
229
 
269
- export { TzinChannels } // workerd discovers DO classes among exports
230
+ ### Runtime throughput
270
231
 
271
- export default toDurableWorker(() => {
272
- const hub = new Hub()
273
- const presence = new Presence(hub)
274
- const app = createApp([...routes, ...channelRoutes(hub, { presence })])
275
- return toWorker(app, { wsRoutes: [wsChannels(hub, { presence })] })
276
- })
277
- ```
232
+ | Framework | req/s | p99 |
233
+ |---|---|---|
234
+ | raw node:http (floor) | ~42k | 2–3ms |
235
+ | hono | ~34–36k | 3ms |
236
+ | tzin | ~30–31k | 6ms |
237
+ | express | ~15k | 7ms |
278
238
 
279
- ```jsonc
280
- // wrangler config additions:
281
- "durable_objects": {
282
- "bindings": [{ "name": "TZIN_APP", "class_name": "TzinChannels" }]
283
- },
284
- "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TzinChannels"] }]
285
- ```
239
+ ## Examples
286
240
 
287
- Verified against real workerd via miniflare (`npm run probe:workers`):
288
- HTTP ✓, WS upgrade ✓, roster ✓, broadcast across connections ✓, leave diff ✓.
241
+ | Example | Runtime | What it shows |
242
+ |---|---|---|
243
+ | `examples/node-demo.ts` | Node | Minimal HTTP server with contracts |
244
+ | `examples/bun-demo.ts` | Bun | Bun.serve with validation |
245
+ | `examples/todo-api.ts` | Node | Full CRUD: auth middleware, DI, OpenAPI, MCP |
246
+ | `examples/mcp-demo.ts` | Node | MCP server over stdio |
247
+ | `examples/ws-demo.ts` | Bun | WebSocket channels with presence |
289
248
 
290
249
  ## Design principles
291
250
 
@@ -297,8 +256,6 @@ HTTP ✓, WS upgrade ✓, roster ✓, broadcast across connections ✓, leave di
297
256
  - **Errors are control flow.** Throw typed errors; the response union already knows.
298
257
  - **Light DI without ceremony.** `provide(key, value)` at app level seeds typed
299
258
  singletons into every request's context — handlers just `ctx.require(db)`.
300
- Request-scoped middleware can override. No decorators, no reflection, no
301
- container configuration files.
302
259
 
303
260
  ## Development
304
261
 
@@ -308,33 +265,6 @@ npm test # vitest — runtime + end-to-end client + type assertions
308
265
  npm run typecheck # strict tsc across src/test/bench fixtures
309
266
  ```
310
267
 
311
- ### Dev server
312
-
313
- ```sh
314
- npx tsx src/cli.ts dev examples/node-demo.ts --port 3000
315
- ```
316
-
317
- Hot-reloading server (tsx watch) that prints your route table straight from the
318
- contracts on every reload:
319
-
320
- ```
321
- tzin dev · 2 routes
322
-
323
- GET /users/:id get_user Look up a user by id
324
- POST /users create_user
325
- ```
326
-
327
- ### Examples
328
-
329
- | Example | Runtime | What it shows |
330
- |---|---|---|
331
- | `examples/node-demo.ts` | Node | Minimal HTTP server with contracts |
332
- | `examples/bun-demo.ts` | Bun | Bun.serve with validation |
333
- | `examples/todo-api.ts` | Node | Full CRUD: auth middleware, DI, OpenAPI, MCP |
334
- | `examples/mcp-demo.ts` | Node | MCP server over stdio |
335
- | `examples/ws-demo.ts` | Bun | WebSocket channels with presence |
336
- | `examples/worker-channels.ts` | Workers | Cloudflare DO-backed channels |
337
-
338
268
  ## License
339
269
 
340
270
  MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,92 @@
1
+ import type { Middleware } from './middleware.js';
2
+ export interface CacheConfig {
3
+ /** Cache duration in ms (default: 60000 = 1 minute) */
4
+ ttl?: number;
5
+ /** Key generator function (default: URL + method) */
6
+ keyGenerator?: (req: Request) => string;
7
+ /** Only cache certain methods (default: GET, HEAD) */
8
+ methods?: string[];
9
+ /** Only cache certain status codes (default: 200) */
10
+ statuses?: number[];
11
+ /** Skip caching for certain requests */
12
+ skip?: (req: Request) => boolean;
13
+ /** Custom cache store */
14
+ store?: CacheStore;
15
+ /** Include cache headers in response */
16
+ headers?: boolean;
17
+ /** Vary headers for cache key */
18
+ vary?: string[];
19
+ }
20
+ export interface CacheStore {
21
+ get(key: string): CacheEntry | null;
22
+ set(key: string, entry: CacheEntry): void;
23
+ delete(key: string): void;
24
+ clear(): void;
25
+ }
26
+ export interface CacheEntry {
27
+ response: CachedResponse;
28
+ timestamp: number;
29
+ ttl: number;
30
+ }
31
+ export interface CachedResponse {
32
+ status: number;
33
+ headers: Record<string, string>;
34
+ body: string;
35
+ }
36
+ /**
37
+ * HTTP caching middleware.
38
+ *
39
+ * Caches responses based on URL and method. Supports TTL, cache invalidation,
40
+ * and cache headers.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * import { cache } from '@carlos-tzin/tzin/cache'
45
+ *
46
+ * const app = createApp(routes, {
47
+ * middleware: [cache({ ttl: 60000 })], // 1 minute cache
48
+ * })
49
+ * ```
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * // Per-route caching
54
+ * const app = createApp([
55
+ * impl(getUser, async (input) => {
56
+ * return { status: 200 as const, body: { id: '1', name: 'Ada' } }
57
+ * })
58
+ * ], {
59
+ * middleware: [cache({ ttl: 300000 })], // 5 minutes
60
+ * })
61
+ * ```
62
+ */
63
+ export declare function cache(config?: CacheConfig): Middleware;
64
+ /**
65
+ * Stale-while-revalidate caching.
66
+ *
67
+ * Returns stale cache immediately while revalidating in background.
68
+ */
69
+ export declare function staleWhileRevalidate(config?: CacheConfig & {
70
+ /** Stale duration in ms (how long to serve stale) */
71
+ staleTtl?: number;
72
+ }): Middleware;
73
+ /**
74
+ * Cache invalidation middleware.
75
+ *
76
+ * Invalidates cache entries based on patterns.
77
+ */
78
+ export declare function invalidateCache(config: {
79
+ /** Pattern to match keys for invalidation */
80
+ pattern?: RegExp;
81
+ /** Specific keys to invalidate */
82
+ keys?: string[];
83
+ /** Custom invalidation function */
84
+ invalidator?: (req: Request) => string[];
85
+ }): Middleware;
86
+ /**
87
+ * Get cache info from response headers.
88
+ */
89
+ export declare function getCacheInfo(response: Response): {
90
+ hit: boolean;
91
+ age?: number;
92
+ } | null;
package/dist/cache.js ADDED
@@ -0,0 +1,219 @@
1
+ // ── Memory Store ─────────────────────────────────────────────────────
2
+ class MemoryCacheStore {
3
+ store = new Map();
4
+ get(key) {
5
+ const entry = this.store.get(key);
6
+ if (!entry)
7
+ return null;
8
+ if (Date.now() > entry.timestamp + entry.ttl) {
9
+ this.store.delete(key);
10
+ return null;
11
+ }
12
+ return entry;
13
+ }
14
+ set(key, entry) {
15
+ this.store.set(key, entry);
16
+ }
17
+ delete(key) {
18
+ this.store.delete(key);
19
+ }
20
+ clear() {
21
+ this.store.clear();
22
+ }
23
+ }
24
+ // ── Default Key Generator ────────────────────────────────────────────
25
+ function defaultKeyGenerator(req) {
26
+ const url = new URL(req.url);
27
+ return `${req.method}:${url.pathname}${url.search}`;
28
+ }
29
+ // ── Cache Middleware ─────────────────────────────────────────────────
30
+ /**
31
+ * HTTP caching middleware.
32
+ *
33
+ * Caches responses based on URL and method. Supports TTL, cache invalidation,
34
+ * and cache headers.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * import { cache } from '@carlos-tzin/tzin/cache'
39
+ *
40
+ * const app = createApp(routes, {
41
+ * middleware: [cache({ ttl: 60000 })], // 1 minute cache
42
+ * })
43
+ * ```
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * // Per-route caching
48
+ * const app = createApp([
49
+ * impl(getUser, async (input) => {
50
+ * return { status: 200 as const, body: { id: '1', name: 'Ada' } }
51
+ * })
52
+ * ], {
53
+ * middleware: [cache({ ttl: 300000 })], // 5 minutes
54
+ * })
55
+ * ```
56
+ */
57
+ export function cache(config = {}) {
58
+ const { ttl = 60000, keyGenerator = defaultKeyGenerator, methods = ['GET', 'HEAD'], statuses = [200], headers = true, vary = [], } = config;
59
+ const store = config.store ?? new MemoryCacheStore();
60
+ return async ({ req, next }) => {
61
+ // Only cache specified methods
62
+ if (!methods.includes(req.method)) {
63
+ return next();
64
+ }
65
+ // Skip if configured
66
+ if (config.skip?.(req)) {
67
+ return next();
68
+ }
69
+ const key = keyGenerator(req);
70
+ // Check cache
71
+ const cached = store.get(key);
72
+ if (cached) {
73
+ const response = new Response(cached.response.body, {
74
+ status: cached.response.status,
75
+ headers: cached.response.headers,
76
+ });
77
+ if (headers) {
78
+ response.headers.set('X-Cache', 'HIT');
79
+ response.headers.set('X-Cache-Age', Math.floor((Date.now() - cached.timestamp) / 1000).toString());
80
+ }
81
+ return response;
82
+ }
83
+ // Execute request
84
+ const response = await next();
85
+ // Only cache specified status codes
86
+ if (!statuses.includes(response.status)) {
87
+ return response;
88
+ }
89
+ // Clone response to read body
90
+ const clonedResponse = response.clone();
91
+ const body = await clonedResponse.text();
92
+ // Build headers object
93
+ const responseHeaders = {};
94
+ response.headers.forEach((value, key) => {
95
+ responseHeaders[key] = value;
96
+ });
97
+ // Store in cache
98
+ store.set(key, {
99
+ response: {
100
+ status: response.status,
101
+ headers: responseHeaders,
102
+ body: body,
103
+ },
104
+ timestamp: Date.now(),
105
+ ttl,
106
+ });
107
+ // Add cache headers
108
+ if (headers) {
109
+ const newResponse = new Response(body, {
110
+ status: response.status,
111
+ headers: responseHeaders,
112
+ });
113
+ newResponse.headers.set('X-Cache', 'MISS');
114
+ newResponse.headers.set('Cache-Control', `public, max-age=${Math.floor(ttl / 1000)}`);
115
+ newResponse.headers.set('ETag', `"${hashString(key + body)}"`);
116
+ return newResponse;
117
+ }
118
+ return response;
119
+ };
120
+ }
121
+ /**
122
+ * Stale-while-revalidate caching.
123
+ *
124
+ * Returns stale cache immediately while revalidating in background.
125
+ */
126
+ export function staleWhileRevalidate(config = {}) {
127
+ const { staleTtl = 86400000, ...cacheConfig } = config;
128
+ const store = cacheConfig.store ?? new MemoryCacheStore();
129
+ return async ({ req, next }) => {
130
+ const key = (cacheConfig.keyGenerator ?? defaultKeyGenerator)(req);
131
+ const cached = store.get(key);
132
+ if (cached) {
133
+ const age = Date.now() - cached.timestamp;
134
+ const isStale = age > (cacheConfig.ttl ?? 60000);
135
+ if (isStale && age < staleTtl) {
136
+ // Serve stale immediately, revalidate in background
137
+ const response = new Response(cached.response.body, {
138
+ status: cached.response.status,
139
+ headers: cached.response.headers,
140
+ });
141
+ response.headers.set('X-Cache', 'STALE');
142
+ response.headers.set('Warning', '110 - "Response is stale"');
143
+ // Revalidate in background (fire and forget)
144
+ next().then((freshResponse) => {
145
+ if (freshResponse.ok) {
146
+ freshResponse.clone().text().then((freshBody) => {
147
+ const headers = {};
148
+ freshResponse.headers.forEach((v, k) => { headers[k] = v; });
149
+ store.set(key, {
150
+ response: { status: freshResponse.status, headers, body: freshBody },
151
+ timestamp: Date.now(),
152
+ ttl: cacheConfig.ttl ?? 60000,
153
+ });
154
+ });
155
+ }
156
+ }).catch(() => { });
157
+ return response;
158
+ }
159
+ }
160
+ // Normal cache flow
161
+ return cache(cacheConfig)({ req, ctx: undefined, next });
162
+ };
163
+ }
164
+ /**
165
+ * Cache invalidation middleware.
166
+ *
167
+ * Invalidates cache entries based on patterns.
168
+ */
169
+ export function invalidateCache(config) {
170
+ const store = config.pattern || config.keys ? new MemoryCacheStore() : null;
171
+ return async ({ req, next }) => {
172
+ // Check for cache invalidation headers
173
+ const invalidate = req.headers.get('X-Cache-Invalidate');
174
+ if (invalidate === 'true') {
175
+ store?.clear();
176
+ }
177
+ // Check for specific key invalidation
178
+ if (config.keys) {
179
+ for (const key of config.keys) {
180
+ store?.delete(key);
181
+ }
182
+ }
183
+ // Check for custom invalidation
184
+ if (config.invalidator) {
185
+ const keys = config.invalidator(req);
186
+ for (const key of keys) {
187
+ store?.delete(key);
188
+ }
189
+ }
190
+ return next();
191
+ };
192
+ }
193
+ // ── Utilities ────────────────────────────────────────────────────────
194
+ /**
195
+ * Get cache info from response headers.
196
+ */
197
+ export function getCacheInfo(response) {
198
+ const cacheHeader = response.headers.get('X-Cache');
199
+ if (!cacheHeader)
200
+ return null;
201
+ return {
202
+ hit: cacheHeader === 'HIT',
203
+ age: response.headers.get('X-Cache-Age')
204
+ ? parseInt(response.headers.get('X-Cache-Age'), 10)
205
+ : undefined,
206
+ };
207
+ }
208
+ /**
209
+ * Simple string hash function.
210
+ */
211
+ function hashString(str) {
212
+ let hash = 0;
213
+ for (let i = 0; i < str.length; i++) {
214
+ const char = str.charCodeAt(i);
215
+ hash = ((hash << 5) - hash) + char;
216
+ hash = hash & hash; // Convert to 32bit integer
217
+ }
218
+ return Math.abs(hash).toString(36);
219
+ }
package/dist/db.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Value } from '@sinclair/typebox/value';
1
2
  // ── Memory store (default) ──────────────────────────────────────────
2
3
  class MemoryStore {
3
4
  data = new Map();
@@ -99,11 +100,11 @@ export function defineModel(tableName, schema, config) {
99
100
  tableName,
100
101
  async findById(id) {
101
102
  const row = globalStore.findById(tableName, id, pk);
102
- return row ? schema.Decode(row) : null;
103
+ return row ? Value.Decode(schema, row) : null;
103
104
  },
104
105
  async findFirst(where) {
105
106
  const rows = globalStore.findAll(tableName).filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
106
- return rows[0] ? schema.Decode(rows[0]) : null;
107
+ return rows[0] ? Value.Decode(schema, rows[0]) : null;
107
108
  },
108
109
  findMany(where) {
109
110
  const filters = where
@@ -170,7 +171,7 @@ export function defineModel(tableName, schema, config) {
170
171
  },
171
172
  exec: async () => {
172
173
  const rows = applyFilters([], filters);
173
- return rows.map((r) => schema.Decode(r));
174
+ return rows.map((r) => Value.Decode(schema, r));
174
175
  },
175
176
  first: async () => {
176
177
  const rows = await builder.exec();
@@ -184,14 +185,14 @@ export function defineModel(tableName, schema, config) {
184
185
  },
185
186
  async create(data) {
186
187
  const row = globalStore.insert(tableName, data);
187
- return schema.Decode(row);
188
+ return Value.Decode(schema, row);
188
189
  },
189
190
  async createMany(data) {
190
191
  return Promise.all(data.map((d) => this.create(d)));
191
192
  },
192
193
  async update(id, data) {
193
194
  const row = globalStore.update(tableName, id, data, pk);
194
- return row ? schema.Decode(row) : null;
195
+ return row ? Value.Decode(schema, row) : null;
195
196
  },
196
197
  async delete(id) {
197
198
  return globalStore.delete(tableName, id, pk);
package/dist/log.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
2
+ export interface LogEntry {
3
+ level: LogLevel;
4
+ message: string;
5
+ data?: Record<string, unknown>;
6
+ timestamp: Date;
7
+ context?: string;
8
+ }
9
+ export interface Logger {
10
+ debug(message: string, data?: Record<string, unknown>): void;
11
+ info(message: string, data?: Record<string, unknown>): void;
12
+ warn(message: string, data?: Record<string, unknown>): void;
13
+ error(message: string, data?: Record<string, unknown>): void;
14
+ fatal(message: string, data?: Record<string, unknown>): void;
15
+ /** Create a child logger with a prefix */
16
+ child(prefix: string): Logger;
17
+ }
18
+ export interface LoggerConfig {
19
+ /** Minimum log level (default: 'info') */
20
+ level?: LogLevel;
21
+ /** Custom transport function */
22
+ transport?: (entry: LogEntry) => void;
23
+ /** Include timestamp in output (default: true) */
24
+ timestamp?: boolean;
25
+ /** Pretty print with colors (default: true in dev) */
26
+ pretty?: boolean;
27
+ }
28
+ /**
29
+ * Configure the global logger.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * import { configure } from '@carlos-tzin/tzin/log'
34
+ *
35
+ * configure({ level: 'debug', pretty: true })
36
+ * ```
37
+ */
38
+ export declare function configure(config: LoggerConfig): void;
39
+ /**
40
+ * Get the global logger.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * import { getLogger } from '@carlos-tzin/tzin/log'
45
+ *
46
+ * const log = getLogger()
47
+ * log.info('Server started', { port: 3000 })
48
+ * ```
49
+ */
50
+ export declare function getLogger(context?: string): Logger;
51
+ /** Convenience export - reads config dynamically */
52
+ export declare const log: Logger;
package/dist/log.js ADDED
@@ -0,0 +1,96 @@
1
+ // ── Types ────────────────────────────────────────────────────────────
2
+ // ── Log Level Order ──────────────────────────────────────────────────
3
+ const LOG_LEVELS = {
4
+ debug: 0,
5
+ info: 1,
6
+ warn: 2,
7
+ error: 3,
8
+ fatal: 4,
9
+ };
10
+ // ── Colors ───────────────────────────────────────────────────────────
11
+ const COLORS = {
12
+ debug: '\x1b[36m', // cyan
13
+ info: '\x1b[32m', // green
14
+ warn: '\x1b[33m', // yellow
15
+ error: '\x1b[31m', // red
16
+ fatal: '\x1b[35m', // magenta
17
+ };
18
+ const RESET = '\x1b[0m';
19
+ // ── Default Transport ────────────────────────────────────────────────
20
+ function defaultTransport(entry, config) {
21
+ const { level, message, data, timestamp, context } = entry;
22
+ const showTimestamp = config.timestamp !== false;
23
+ const pretty = config.pretty !== false;
24
+ if (pretty) {
25
+ const color = COLORS[level];
26
+ const prefix = context ? `[${context}]` : '';
27
+ const time = showTimestamp ? `${timestamp.toISOString()} ` : '';
28
+ const dataStr = data && Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : '';
29
+ console.log(`${color}${time}${prefix} ${level.toUpperCase()}${RESET} ${message}${dataStr}`);
30
+ }
31
+ else {
32
+ const json = { level, message, timestamp: timestamp.toISOString() };
33
+ if (context)
34
+ json.context = context;
35
+ if (data && Object.keys(data).length > 0)
36
+ json.data = data;
37
+ console.log(JSON.stringify(json));
38
+ }
39
+ }
40
+ // ── Logger Factory ───────────────────────────────────────────────────
41
+ function createLogger(context) {
42
+ function log(level, message, data) {
43
+ const config = globalConfig;
44
+ const minLevel = LOG_LEVELS[config.level ?? 'info'];
45
+ if (LOG_LEVELS[level] < minLevel)
46
+ return;
47
+ const transport = config.transport ?? ((entry) => defaultTransport(entry, config));
48
+ const entry = {
49
+ level,
50
+ message,
51
+ data,
52
+ timestamp: new Date(),
53
+ context,
54
+ };
55
+ transport(entry);
56
+ }
57
+ return {
58
+ debug: (msg, data) => log('debug', msg, data),
59
+ info: (msg, data) => log('info', msg, data),
60
+ warn: (msg, data) => log('warn', msg, data),
61
+ error: (msg, data) => log('error', msg, data),
62
+ fatal: (msg, data) => log('fatal', msg, data),
63
+ child: (prefix) => createLogger(context ? `${context}:${prefix}` : prefix),
64
+ };
65
+ }
66
+ // ── Global Logger ────────────────────────────────────────────────────
67
+ let globalConfig = {};
68
+ /**
69
+ * Configure the global logger.
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * import { configure } from '@carlos-tzin/tzin/log'
74
+ *
75
+ * configure({ level: 'debug', pretty: true })
76
+ * ```
77
+ */
78
+ export function configure(config) {
79
+ globalConfig = config;
80
+ }
81
+ /**
82
+ * Get the global logger.
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * import { getLogger } from '@carlos-tzin/tzin/log'
87
+ *
88
+ * const log = getLogger()
89
+ * log.info('Server started', { port: 3000 })
90
+ * ```
91
+ */
92
+ export function getLogger(context) {
93
+ return createLogger(context);
94
+ }
95
+ /** Convenience export - reads config dynamically */
96
+ export const log = createLogger();
@@ -0,0 +1,79 @@
1
+ import type { Middleware } from './middleware.js';
2
+ export interface RateLimitConfig {
3
+ /** Maximum requests per window (default: 100) */
4
+ max?: number;
5
+ /** Window duration in ms (default: 60000 = 1 minute) */
6
+ windowMs?: number;
7
+ /** Key function to identify clients (default: IP) */
8
+ keyGenerator?: (req: Request) => string;
9
+ /** Custom error response */
10
+ onLimitReached?: (req: Request, retryAfter: number) => Response;
11
+ /** Skip certain requests */
12
+ skip?: (req: Request) => boolean;
13
+ /** Custom store for distributed rate limiting */
14
+ store?: RateLimitStore;
15
+ /** Headers to include in response */
16
+ headers?: boolean;
17
+ }
18
+ export interface RateLimitStore {
19
+ get(key: string): {
20
+ count: number;
21
+ resetTime: number;
22
+ } | null;
23
+ increment(key: string, windowMs: number): {
24
+ count: number;
25
+ resetTime: number;
26
+ };
27
+ decrement(key: string): void;
28
+ reset(key: string): void;
29
+ }
30
+ export interface RateLimitInfo {
31
+ limit: number;
32
+ remaining: number;
33
+ resetTime: number;
34
+ retryAfter: number;
35
+ }
36
+ /**
37
+ * Rate limiting middleware.
38
+ *
39
+ * Limits the number of requests from a client within a time window.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * import { rateLimit } from '@carlos-tzin/tzin/rate-limit'
44
+ *
45
+ * const app = createApp(routes, {
46
+ * middleware: [rateLimit({ max: 100, windowMs: 60000 })],
47
+ * })
48
+ * ```
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * // Per-route rate limiting
53
+ * const app = createApp([
54
+ * impl(getUser, async (input) => {
55
+ * // This handler has its own rate limit
56
+ * return { status: 200 as const, body: { ok: true } }
57
+ * })
58
+ * ], {
59
+ * middleware: [rateLimit({ max: 10, windowMs: 60000 })],
60
+ * })
61
+ * ```
62
+ */
63
+ export declare function rateLimit(config?: RateLimitConfig): Middleware;
64
+ /**
65
+ * Strict rate limiting for sensitive endpoints (login, password reset).
66
+ * Uses sliding window for more accurate limiting.
67
+ */
68
+ export declare function strictRateLimit(config?: RateLimitConfig): Middleware;
69
+ /**
70
+ * Create a rate limiter with custom key generation.
71
+ */
72
+ export declare function createRateLimiter(config: RateLimitConfig & {
73
+ /** Endpoint name for logging */
74
+ endpoint?: string;
75
+ }): Middleware;
76
+ /**
77
+ * Get rate limit info from response headers.
78
+ */
79
+ export declare function getRateLimitInfo(response: Response): RateLimitInfo | null;
@@ -0,0 +1,188 @@
1
+ // ── Memory Store ─────────────────────────────────────────────────────
2
+ class MemoryStore {
3
+ store = new Map();
4
+ get(key) {
5
+ const entry = this.store.get(key);
6
+ if (!entry)
7
+ return null;
8
+ if (Date.now() > entry.resetTime) {
9
+ this.store.delete(key);
10
+ return null;
11
+ }
12
+ return entry;
13
+ }
14
+ increment(key, windowMs) {
15
+ const existing = this.get(key);
16
+ if (existing) {
17
+ existing.count++;
18
+ return existing;
19
+ }
20
+ const entry = { count: 1, resetTime: Date.now() + windowMs };
21
+ this.store.set(key, entry);
22
+ return entry;
23
+ }
24
+ decrement(key) {
25
+ const entry = this.store.get(key);
26
+ if (entry && entry.count > 0) {
27
+ entry.count--;
28
+ }
29
+ }
30
+ reset(key) {
31
+ this.store.delete(key);
32
+ }
33
+ }
34
+ // ── Sliding Window Counter Store ─────────────────────────────────────
35
+ class SlidingWindowStore {
36
+ store = new Map();
37
+ get(key) {
38
+ const entry = this.store.get(key);
39
+ if (!entry)
40
+ return null;
41
+ if (Date.now() > entry.resetTime) {
42
+ this.store.delete(key);
43
+ return null;
44
+ }
45
+ return entry;
46
+ }
47
+ increment(key, windowMs) {
48
+ const now = Date.now();
49
+ const existing = this.get(key);
50
+ if (existing) {
51
+ // Sliding window: keep the same reset time
52
+ existing.count++;
53
+ return existing;
54
+ }
55
+ const entry = { count: 1, resetTime: now + windowMs };
56
+ this.store.set(key, entry);
57
+ return entry;
58
+ }
59
+ decrement(key) {
60
+ const entry = this.store.get(key);
61
+ if (entry && entry.count > 0) {
62
+ entry.count--;
63
+ }
64
+ }
65
+ reset(key) {
66
+ this.store.delete(key);
67
+ }
68
+ }
69
+ // ── Default Key Generator ────────────────────────────────────────────
70
+ function defaultKeyGenerator(req) {
71
+ // Try to get IP from various headers
72
+ const forwarded = req.headers.get('x-forwarded-for');
73
+ if (forwarded) {
74
+ return forwarded.split(',')[0].trim();
75
+ }
76
+ const realIp = req.headers.get('x-real-ip');
77
+ if (realIp) {
78
+ return realIp;
79
+ }
80
+ // Fallback to a default (in real apps, this would be the socket IP)
81
+ return 'unknown';
82
+ }
83
+ // ── Rate Limit Middleware ────────────────────────────────────────────
84
+ /**
85
+ * Rate limiting middleware.
86
+ *
87
+ * Limits the number of requests from a client within a time window.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * import { rateLimit } from '@carlos-tzin/tzin/rate-limit'
92
+ *
93
+ * const app = createApp(routes, {
94
+ * middleware: [rateLimit({ max: 100, windowMs: 60000 })],
95
+ * })
96
+ * ```
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * // Per-route rate limiting
101
+ * const app = createApp([
102
+ * impl(getUser, async (input) => {
103
+ * // This handler has its own rate limit
104
+ * return { status: 200 as const, body: { ok: true } }
105
+ * })
106
+ * ], {
107
+ * middleware: [rateLimit({ max: 10, windowMs: 60000 })],
108
+ * })
109
+ * ```
110
+ */
111
+ export function rateLimit(config = {}) {
112
+ const { max = 100, windowMs = 60000, keyGenerator = defaultKeyGenerator, onLimitReached = defaultOnLimitReached, skip, headers = true, } = config;
113
+ const store = config.store ?? new MemoryStore();
114
+ return async ({ req, next }) => {
115
+ // Skip if configured
116
+ if (skip?.(req)) {
117
+ return next();
118
+ }
119
+ const key = keyGenerator(req);
120
+ const { count, resetTime } = store.increment(key, windowMs);
121
+ const remaining = Math.max(0, max - count);
122
+ const retryAfter = Math.ceil((resetTime - Date.now()) / 1000);
123
+ // Check if limit exceeded
124
+ if (count > max) {
125
+ store.decrement(key);
126
+ return onLimitReached(req, retryAfter);
127
+ }
128
+ const response = await next();
129
+ // Add rate limit headers
130
+ if (headers) {
131
+ const newResponse = new Response(response.body, response);
132
+ newResponse.headers.set('X-RateLimit-Limit', max.toString());
133
+ newResponse.headers.set('X-RateLimit-Remaining', remaining.toString());
134
+ newResponse.headers.set('X-RateLimit-Reset', Math.ceil(resetTime / 1000).toString());
135
+ return newResponse;
136
+ }
137
+ return response;
138
+ };
139
+ }
140
+ /**
141
+ * Strict rate limiting for sensitive endpoints (login, password reset).
142
+ * Uses sliding window for more accurate limiting.
143
+ */
144
+ export function strictRateLimit(config = {}) {
145
+ const store = new SlidingWindowStore();
146
+ return rateLimit({
147
+ ...config,
148
+ store,
149
+ max: config.max ?? 5,
150
+ windowMs: config.windowMs ?? 900000, // 15 minutes
151
+ });
152
+ }
153
+ /**
154
+ * Create a rate limiter with custom key generation.
155
+ */
156
+ export function createRateLimiter(config) {
157
+ return rateLimit(config);
158
+ }
159
+ // ── Helpers ──────────────────────────────────────────────────────────
160
+ function defaultOnLimitReached(_req, retryAfter) {
161
+ return new Response(JSON.stringify({
162
+ error: 'Too many requests',
163
+ retryAfter,
164
+ }), {
165
+ status: 429,
166
+ headers: {
167
+ 'content-type': 'application/json',
168
+ 'Retry-After': retryAfter.toString(),
169
+ },
170
+ });
171
+ }
172
+ // ── Utilities ────────────────────────────────────────────────────────
173
+ /**
174
+ * Get rate limit info from response headers.
175
+ */
176
+ export function getRateLimitInfo(response) {
177
+ const limit = response.headers.get('X-RateLimit-Limit');
178
+ const remaining = response.headers.get('X-RateLimit-Remaining');
179
+ const reset = response.headers.get('X-RateLimit-Reset');
180
+ if (!limit || !remaining || !reset)
181
+ return null;
182
+ return {
183
+ limit: parseInt(limit, 10),
184
+ remaining: parseInt(remaining, 10),
185
+ resetTime: parseInt(reset, 10) * 1000,
186
+ retryAfter: parseInt(response.headers.get('Retry-After') ?? '0', 10),
187
+ };
188
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlos-tzin/tzin",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Contract-first TypeScript framework. Types that scale, realtime channels with presence, and an MCP server for every API.",
5
5
  "license": "MIT",
6
6
  "author": "The tzin authors",
@@ -58,6 +58,18 @@
58
58
  "./jobs": {
59
59
  "types": "./dist/jobs.d.ts",
60
60
  "default": "./dist/jobs.js"
61
+ },
62
+ "./log": {
63
+ "types": "./dist/log.d.ts",
64
+ "default": "./dist/log.js"
65
+ },
66
+ "./rate-limit": {
67
+ "types": "./dist/rate-limit.d.ts",
68
+ "default": "./dist/rate-limit.js"
69
+ },
70
+ "./cache": {
71
+ "types": "./dist/cache.d.ts",
72
+ "default": "./dist/cache.js"
61
73
  }
62
74
  },
63
75
  "files": [