@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 +127 -197
- package/dist/cache.d.ts +92 -0
- package/dist/cache.js +219 -0
- package/dist/db.js +6 -5
- package/dist/log.d.ts +52 -0
- package/dist/log.js +96 -0
- package/dist/rate-limit.d.ts +79 -0
- package/dist/rate-limit.js +188 -0
- package/package.json +13 -1
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
> A pact between client and server, declared once.
|
|
7
7
|
|
|
8
8
|
[](https://github.com/Charly921/tzin/actions/workflows/ci.yml)
|
|
9
|
+
[](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
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
listen(app, 3000)
|
|
83
|
-
// Anywhere else
|
|
84
|
-
export default { fetch: app.fetch }
|
|
85
|
-
```
|
|
80
|
+
### Core
|
|
86
81
|
|
|
87
|
-
|
|
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
|
-
|
|
89
|
+
### Runtime
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
98
|
+
### Realtime
|
|
103
99
|
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
documented blow-ups compound further when `zValidator` inference enters the chain.*
|
|
123
|
+
### Auth
|
|
125
124
|
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
134
|
+
| Feature | Import | Description |
|
|
133
135
|
|---|---|---|
|
|
134
|
-
|
|
|
135
|
-
|
|
|
136
|
-
|
|
|
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
|
-
|
|
140
|
-
identical request construction):
|
|
140
|
+
### Logging
|
|
141
141
|
|
|
142
|
-
|
|
|
142
|
+
| Feature | Import | Description |
|
|
143
143
|
|---|---|---|
|
|
144
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
contracts that generate your OpenAPI document also expose your endpoints as tools
|
|
157
|
-
for AI agents:
|
|
156
|
+
## Quick Start
|
|
158
157
|
|
|
159
|
-
|
|
160
|
-
import { startStdioMcp } from '@carlos-tzin/tzin'
|
|
158
|
+
### 1. Scaffold
|
|
161
159
|
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
```bash
|
|
161
|
+
npx create-tzin my-api
|
|
162
|
+
cd my-api
|
|
164
163
|
```
|
|
165
164
|
|
|
166
|
-
|
|
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
|
-
|
|
177
|
-
|
|
168
|
+
// src/routes/users.ts
|
|
169
|
+
import { t, contract, impl } from '@carlos-tzin/tzin'
|
|
178
170
|
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
+
### Runtime throughput
|
|
270
231
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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).
|
package/dist/cache.d.ts
ADDED
|
@@ -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 ?
|
|
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] ?
|
|
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) =>
|
|
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
|
|
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 ?
|
|
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.
|
|
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": [
|