@spfn/core 0.2.0-beta.6 → 0.2.0-beta.9
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 +260 -1175
- package/dist/codegen/index.d.ts +47 -2
- package/dist/codegen/index.js +143 -5
- package/dist/codegen/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +35 -3
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +60 -14
- package/dist/nextjs/server.js +97 -32
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +136 -2
- package/dist/route/index.js +209 -11
- package/dist/route/index.js.map +1 -1
- package/dist/server/index.d.ts +71 -0
- package/dist/server/index.js +41 -0
- package/dist/server/index.js.map +1 -1
- package/dist/{types-D_N_U-Py.d.ts → types-BOPTApC2.d.ts} +15 -0
- package/docs/cache.md +133 -0
- package/docs/codegen.md +74 -0
- package/docs/database.md +346 -0
- package/docs/entity.md +539 -0
- package/docs/env.md +477 -0
- package/docs/errors.md +319 -0
- package/docs/event.md +116 -0
- package/docs/file-upload.md +717 -0
- package/docs/job.md +131 -0
- package/docs/logger.md +108 -0
- package/docs/middleware.md +337 -0
- package/docs/nextjs.md +241 -0
- package/docs/repository.md +496 -0
- package/docs/route.md +497 -0
- package/docs/server.md +307 -0
- package/package.json +1 -1
package/docs/route.md
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# Route
|
|
2
|
+
|
|
3
|
+
Type-safe route definition with automatic validation and tRPC-style developer experience.
|
|
4
|
+
|
|
5
|
+
## Basic Usage
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { route } from '@spfn/core/route';
|
|
9
|
+
import { Type } from '@sinclair/typebox';
|
|
10
|
+
|
|
11
|
+
export const getUser = route.get('/users/:id')
|
|
12
|
+
.input({
|
|
13
|
+
params: Type.Object({ id: Type.String() })
|
|
14
|
+
})
|
|
15
|
+
.handler(async (c) => {
|
|
16
|
+
const { params } = await c.data();
|
|
17
|
+
return { id: params.id, name: 'John' };
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## HTTP Methods
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
route.get('/path') // GET
|
|
25
|
+
route.post('/path') // POST
|
|
26
|
+
route.put('/path') // PUT
|
|
27
|
+
route.patch('/path') // PATCH
|
|
28
|
+
route.delete('/path') // DELETE
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Input Definition
|
|
32
|
+
|
|
33
|
+
### Path Parameters
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
route.get('/users/:id/posts/:postId')
|
|
37
|
+
.input({
|
|
38
|
+
params: Type.Object({
|
|
39
|
+
id: Type.String(),
|
|
40
|
+
postId: Type.String()
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
.handler(async (c) => {
|
|
44
|
+
const { params } = await c.data();
|
|
45
|
+
// params.id, params.postId
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Query Parameters
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
route.get('/users')
|
|
53
|
+
.input({
|
|
54
|
+
query: Type.Object({
|
|
55
|
+
page: Type.Number({ default: 1 }),
|
|
56
|
+
limit: Type.Number({ default: 20 }),
|
|
57
|
+
search: Type.Optional(Type.String())
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
.handler(async (c) => {
|
|
61
|
+
const { query } = await c.data();
|
|
62
|
+
// query.page, query.limit, query.search
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Request Body
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
route.post('/users')
|
|
70
|
+
.input({
|
|
71
|
+
body: Type.Object({
|
|
72
|
+
email: Type.String({ format: 'email' }),
|
|
73
|
+
name: Type.String({ minLength: 1, maxLength: 100 }),
|
|
74
|
+
role: Type.Optional(Type.Union([
|
|
75
|
+
Type.Literal('admin'),
|
|
76
|
+
Type.Literal('user')
|
|
77
|
+
]))
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
.handler(async (c) => {
|
|
81
|
+
const { body } = await c.data();
|
|
82
|
+
// body.email, body.name, body.role
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Headers
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
route.get('/protected')
|
|
90
|
+
.input({
|
|
91
|
+
headers: Type.Object({
|
|
92
|
+
authorization: Type.String()
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
.handler(async (c) => {
|
|
96
|
+
const { headers } = await c.data();
|
|
97
|
+
// headers.authorization
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Cookies
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
route.get('/session')
|
|
105
|
+
.input({
|
|
106
|
+
cookies: Type.Object({
|
|
107
|
+
sessionId: Type.String()
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
.handler(async (c) => {
|
|
111
|
+
const { cookies } = await c.data();
|
|
112
|
+
// cookies.sessionId
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Form Data (File Upload)
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import { route, FileSchema, FileArraySchema } from '@spfn/core/route';
|
|
120
|
+
|
|
121
|
+
// Single file
|
|
122
|
+
route.post('/upload')
|
|
123
|
+
.input({
|
|
124
|
+
formData: Type.Object({
|
|
125
|
+
file: FileSchema,
|
|
126
|
+
description: Type.Optional(Type.String())
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
.handler(async (c) => {
|
|
130
|
+
const { formData } = await c.data();
|
|
131
|
+
const file = formData.file as File;
|
|
132
|
+
// file.name, file.size, file.type
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Multiple files
|
|
136
|
+
route.post('/upload-multiple')
|
|
137
|
+
.input({
|
|
138
|
+
formData: Type.Object({
|
|
139
|
+
files: FileArraySchema
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
.handler(async (c) => {
|
|
143
|
+
const { formData } = await c.data();
|
|
144
|
+
const files = formData.files as File[];
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
> **Note:** For detailed file upload patterns including validation, storage, and security, see [File Upload Guide](./file-upload.md).
|
|
149
|
+
|
|
150
|
+
### Combined Input
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
route.patch('/users/:id')
|
|
154
|
+
.input({
|
|
155
|
+
params: Type.Object({ id: Type.String() }),
|
|
156
|
+
query: Type.Object({ notify: Type.Optional(Type.Boolean()) }),
|
|
157
|
+
body: Type.Object({ name: Type.String() })
|
|
158
|
+
})
|
|
159
|
+
.handler(async (c) => {
|
|
160
|
+
const { params, query, body } = await c.data();
|
|
161
|
+
// All inputs are typed and validated
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Response Patterns
|
|
168
|
+
|
|
169
|
+
### Direct Return (Recommended)
|
|
170
|
+
|
|
171
|
+
Simply return data from handler - automatic JSON response:
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
route.get('/users/:id')
|
|
175
|
+
.handler(async (c) => {
|
|
176
|
+
const user = await userRepo.findById(id);
|
|
177
|
+
return user; // Automatic c.json(user)
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Response Helpers
|
|
182
|
+
|
|
183
|
+
For custom status codes and headers:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
route.post('/users')
|
|
187
|
+
.handler(async (c) => {
|
|
188
|
+
const user = await userRepo.create(data);
|
|
189
|
+
|
|
190
|
+
// 201 Created with Location header
|
|
191
|
+
return c.created(user, `/users/${user.id}`);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
route.delete('/users/:id')
|
|
195
|
+
.handler(async (c) => {
|
|
196
|
+
await userRepo.delete(id);
|
|
197
|
+
|
|
198
|
+
// 204 No Content
|
|
199
|
+
return c.noContent();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
route.put('/users/:id')
|
|
203
|
+
.handler(async (c) => {
|
|
204
|
+
// Custom status code
|
|
205
|
+
return c.json({ updated: true }, 202);
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Available Helpers:**
|
|
210
|
+
|
|
211
|
+
| Helper | Status | Description |
|
|
212
|
+
|--------|--------|-------------|
|
|
213
|
+
| `c.json(data, status?)` | Custom | JSON with optional status |
|
|
214
|
+
| `c.created(data, location?)` | 201 | Created with Location header |
|
|
215
|
+
| `c.accepted(data?)` | 202 | Accepted |
|
|
216
|
+
| `c.noContent()` | 204 | No Content |
|
|
217
|
+
| `c.notModified()` | 304 | Not Modified |
|
|
218
|
+
| `c.paginated(items, page, limit, total)` | 200 | Paginated response |
|
|
219
|
+
|
|
220
|
+
### Paginated Response
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
route.get('/users')
|
|
224
|
+
.input({
|
|
225
|
+
query: Type.Object({
|
|
226
|
+
page: Type.Number({ default: 1 }),
|
|
227
|
+
limit: Type.Number({ default: 20 })
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
.handler(async (c) => {
|
|
231
|
+
const { query } = await c.data();
|
|
232
|
+
const { items, total } = await userRepo.findPaginated(query);
|
|
233
|
+
|
|
234
|
+
return c.paginated(items, query.page, query.limit, total);
|
|
235
|
+
// Response: { items: [...], pagination: { page, limit, total, totalPages } }
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Middleware
|
|
242
|
+
|
|
243
|
+
### Using Middleware
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
import { Transactional } from '@spfn/core/db';
|
|
247
|
+
import { authMiddleware } from './middlewares/auth';
|
|
248
|
+
|
|
249
|
+
route.post('/users')
|
|
250
|
+
.use([Transactional(), authMiddleware])
|
|
251
|
+
.handler(async (c) => {
|
|
252
|
+
// Runs after middleware chain
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Skip Global Middleware
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
// Skip specific middlewares
|
|
260
|
+
route.get('/public')
|
|
261
|
+
.skip(['auth', 'rateLimit'])
|
|
262
|
+
.handler(async (c) => { ... });
|
|
263
|
+
|
|
264
|
+
// Skip all global middlewares
|
|
265
|
+
route.get('/health')
|
|
266
|
+
.skip('*')
|
|
267
|
+
.handler(async (c) => { ... });
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Router Composition
|
|
273
|
+
|
|
274
|
+
### Define Router
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
import { defineRouter } from '@spfn/core/route';
|
|
278
|
+
|
|
279
|
+
// Flat structure
|
|
280
|
+
export const appRouter = defineRouter({
|
|
281
|
+
getUser,
|
|
282
|
+
createUser,
|
|
283
|
+
updateUser,
|
|
284
|
+
deleteUser
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Nested structure
|
|
288
|
+
export const appRouter = defineRouter({
|
|
289
|
+
users: defineRouter({
|
|
290
|
+
get: getUser,
|
|
291
|
+
create: createUser
|
|
292
|
+
}),
|
|
293
|
+
posts: defineRouter({
|
|
294
|
+
list: getPosts,
|
|
295
|
+
create: createPost
|
|
296
|
+
})
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Spread Pattern
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
import * as userRoutes from './routes/users';
|
|
304
|
+
import * as postRoutes from './routes/posts';
|
|
305
|
+
|
|
306
|
+
export const appRouter = defineRouter({
|
|
307
|
+
...userRoutes,
|
|
308
|
+
...postRoutes
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Error Handling
|
|
315
|
+
|
|
316
|
+
### Throwing Errors
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
route.get('/users/:id')
|
|
320
|
+
.handler(async (c) => {
|
|
321
|
+
const user = await userRepo.findById(id);
|
|
322
|
+
|
|
323
|
+
if (!user)
|
|
324
|
+
{
|
|
325
|
+
throw new Error('User not found');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return user;
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Using HttpError
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
import { HttpError } from '@spfn/core/errors';
|
|
336
|
+
|
|
337
|
+
route.get('/protected')
|
|
338
|
+
.handler(async (c) => {
|
|
339
|
+
if (!isAuthenticated)
|
|
340
|
+
{
|
|
341
|
+
throw new HttpError(401, 'Unauthorized');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { data: 'secret' };
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Validation Errors
|
|
349
|
+
|
|
350
|
+
Validation errors are automatically thrown when input doesn't match schema:
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
// POST /users with { email: "invalid" }
|
|
354
|
+
// → 400 Bad Request
|
|
355
|
+
// → { error: "Validation failed", fields: [{ path: "/email", message: "Invalid email format" }] }
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## TypeBox Schema Reference
|
|
361
|
+
|
|
362
|
+
### Basic Types
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
import { Type } from '@sinclair/typebox';
|
|
366
|
+
|
|
367
|
+
Type.String() // string
|
|
368
|
+
Type.Number() // number
|
|
369
|
+
Type.Integer() // integer
|
|
370
|
+
Type.Boolean() // boolean
|
|
371
|
+
Type.Null() // null
|
|
372
|
+
Type.Array(Type.String()) // string[]
|
|
373
|
+
Type.Object({ key: Type.String() }) // { key: string }
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### String Constraints
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
Type.String({ format: 'email' })
|
|
380
|
+
Type.String({ format: 'uri' })
|
|
381
|
+
Type.String({ format: 'uuid' })
|
|
382
|
+
Type.String({ minLength: 1, maxLength: 100 })
|
|
383
|
+
Type.String({ pattern: '^[a-z]+$' })
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### Number Constraints
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
Type.Number({ minimum: 0, maximum: 100 })
|
|
390
|
+
Type.Integer({ minimum: 1 })
|
|
391
|
+
Type.Number({ default: 20 })
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### Optional & Nullable
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
import { Nullable, OptionalNullable } from '@spfn/core/route';
|
|
398
|
+
|
|
399
|
+
Type.Optional(Type.String()) // string | undefined
|
|
400
|
+
Nullable(Type.String()) // string | null
|
|
401
|
+
OptionalNullable(Type.String()) // string | null | undefined
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Union & Literal
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
// Enum-like
|
|
408
|
+
Type.Union([
|
|
409
|
+
Type.Literal('draft'),
|
|
410
|
+
Type.Literal('published'),
|
|
411
|
+
Type.Literal('archived')
|
|
412
|
+
])
|
|
413
|
+
|
|
414
|
+
// Multiple types
|
|
415
|
+
Type.Union([Type.String(), Type.Number()])
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## Raw Context Access
|
|
421
|
+
|
|
422
|
+
For advanced Hono features:
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
route.get('/advanced')
|
|
426
|
+
.handler(async (c) => {
|
|
427
|
+
// Access raw Hono context
|
|
428
|
+
const raw = c.raw;
|
|
429
|
+
|
|
430
|
+
// Get custom header
|
|
431
|
+
const customHeader = raw.req.header('x-custom');
|
|
432
|
+
|
|
433
|
+
// Set response header
|
|
434
|
+
raw.header('x-response', 'value');
|
|
435
|
+
|
|
436
|
+
// Get context variable (set by middleware)
|
|
437
|
+
const user = raw.get('user');
|
|
438
|
+
|
|
439
|
+
return { data: 'ok' };
|
|
440
|
+
});
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
## Best Practices
|
|
446
|
+
|
|
447
|
+
### Do
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
// 1. Keep handlers thin - delegate to repository
|
|
451
|
+
route.post('/users')
|
|
452
|
+
.handler(async (c) => {
|
|
453
|
+
const { body } = await c.data();
|
|
454
|
+
return userRepo.create(body); // Simple delegation
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// 2. Use Transactional for write operations
|
|
458
|
+
route.post('/users')
|
|
459
|
+
.use([Transactional()])
|
|
460
|
+
.handler(async (c) => { ... });
|
|
461
|
+
|
|
462
|
+
// 3. Define reusable schemas
|
|
463
|
+
const UserIdParams = Type.Object({ id: Type.String() });
|
|
464
|
+
|
|
465
|
+
route.get('/users/:id').input({ params: UserIdParams })...
|
|
466
|
+
route.patch('/users/:id').input({ params: UserIdParams })...
|
|
467
|
+
route.delete('/users/:id').input({ params: UserIdParams })...
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### Don't
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
// 1. Don't put business logic in handlers
|
|
474
|
+
route.post('/users')
|
|
475
|
+
.handler(async (c) => {
|
|
476
|
+
const { body } = await c.data();
|
|
477
|
+
|
|
478
|
+
// Bad - business logic in handler
|
|
479
|
+
const existing = await db.select().from(users).where(eq(users.email, body.email));
|
|
480
|
+
if (existing.length > 0) throw new Error('Email exists');
|
|
481
|
+
|
|
482
|
+
return db.insert(users).values(body);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// 2. Don't forget Transactional for writes
|
|
486
|
+
route.post('/users')
|
|
487
|
+
.handler(async (c) => { // Missing Transactional!
|
|
488
|
+
return userRepo.create(body);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// 3. Don't access database directly in routes
|
|
492
|
+
route.get('/users')
|
|
493
|
+
.handler(async (c) => {
|
|
494
|
+
// Bad - use repository instead
|
|
495
|
+
return db.select().from(users);
|
|
496
|
+
});
|
|
497
|
+
```
|