sashiko 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +262 -118
- package/dist/core/app.d.ts +35 -0
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +67 -8
- package/dist/core/app.js.map +1 -1
- package/dist/core/body-parser.d.ts +2 -1
- package/dist/core/body-parser.d.ts.map +1 -1
- package/dist/core/body-parser.js +7 -3
- package/dist/core/body-parser.js.map +1 -1
- package/dist/core/compiler.d.ts.map +1 -1
- package/dist/core/compiler.js +9 -5
- package/dist/core/compiler.js.map +1 -1
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/health.d.ts +26 -0
- package/dist/core/health.d.ts.map +1 -0
- package/dist/core/health.js +62 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/index.d.ts +8 -6
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +5 -5
- package/dist/core/index.js.map +1 -1
- package/dist/core/infer.d.ts +1 -3
- package/dist/core/infer.d.ts.map +1 -1
- package/dist/core/inject.d.ts.map +1 -1
- package/dist/core/inject.js.map +1 -1
- package/dist/core/logger.d.ts +17 -1
- package/dist/core/logger.d.ts.map +1 -1
- package/dist/core/logger.js +71 -3
- package/dist/core/logger.js.map +1 -1
- package/dist/core/metadata.js.map +1 -1
- package/dist/core/mounted-group.d.ts +8 -0
- package/dist/core/mounted-group.d.ts.map +1 -1
- package/dist/core/mounted-group.js +58 -18
- package/dist/core/mounted-group.js.map +1 -1
- package/dist/core/request-validation.d.ts.map +1 -1
- package/dist/core/request-validation.js +4 -1
- package/dist/core/request-validation.js.map +1 -1
- package/dist/core/response.d.ts.map +1 -1
- package/dist/core/response.js +4 -4
- package/dist/core/response.js.map +1 -1
- package/dist/core/route-table.d.ts.map +1 -1
- package/dist/core/route-table.js +1 -2
- package/dist/core/route-table.js.map +1 -1
- package/dist/core/shutdown.d.ts +5 -0
- package/dist/core/shutdown.d.ts.map +1 -1
- package/dist/core/shutdown.js.map +1 -1
- package/dist/decorators/http.d.ts.map +1 -1
- package/dist/decorators/http.js +2 -1
- package/dist/decorators/http.js.map +1 -1
- package/dist/decorators/log.d.ts.map +1 -1
- package/dist/decorators/log.js +1 -1
- package/dist/decorators/log.js.map +1 -1
- package/dist/decorators/middlewares.d.ts.map +1 -1
- package/dist/decorators/middlewares.js +1 -1
- package/dist/decorators/middlewares.js.map +1 -1
- package/dist/decorators/router.d.ts.map +1 -1
- package/dist/decorators/router.js +2 -1
- package/dist/decorators/router.js.map +1 -1
- package/dist/decorators/schema.d.ts.map +1 -1
- package/dist/decorators/schema.js +2 -1
- package/dist/decorators/schema.js.map +1 -1
- package/dist/middlewares/auth.d.ts +41 -0
- package/dist/middlewares/auth.d.ts.map +1 -0
- package/dist/middlewares/auth.js +74 -0
- package/dist/middlewares/auth.js.map +1 -0
- package/dist/middlewares/cors.d.ts.map +1 -1
- package/dist/middlewares/cors.js +5 -1
- package/dist/middlewares/cors.js.map +1 -1
- package/dist/middlewares/index.d.ts +1 -0
- package/dist/middlewares/index.d.ts.map +1 -1
- package/dist/middlewares/index.js +1 -0
- package/dist/middlewares/index.js.map +1 -1
- package/dist/openapi/generate.d.ts.map +1 -1
- package/dist/openapi/generate.js +5 -10
- package/dist/openapi/generate.js.map +1 -1
- package/dist/schema/validation.d.ts.map +1 -1
- package/dist/schema/validation.js +2 -0
- package/dist/schema/validation.js.map +1 -1
- package/dist/server/tls.d.ts +48 -0
- package/dist/server/tls.d.ts.map +1 -0
- package/dist/server/tls.js +60 -0
- package/dist/server/tls.js.map +1 -0
- package/dist/utils/content-type.d.ts.map +1 -1
- package/dist/utils/content-type.js +5 -2
- package/dist/utils/content-type.js.map +1 -1
- package/dist/utils/cookie.d.ts.map +1 -1
- package/dist/utils/cookie.js +1 -0
- package/dist/utils/cookie.js.map +1 -1
- package/dist/utils/origin-match.d.ts.map +1 -1
- package/dist/utils/origin-match.js +1 -2
- package/dist/utils/origin-match.js.map +1 -1
- package/dist/utils/path.d.ts.map +1 -1
- package/dist/utils/path.js.map +1 -1
- package/dist/utils/request-target.d.ts.map +1 -1
- package/dist/utils/request-target.js +4 -1
- package/dist/utils/request-target.js.map +1 -1
- package/dist/utils/safe-json.d.ts +19 -0
- package/dist/utils/safe-json.d.ts.map +1 -0
- package/dist/utils/safe-json.js +52 -0
- package/dist/utils/safe-json.js.map +1 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ await app.listen({ port: 3000 });
|
|
|
42
42
|
- [Segurança](#segurança)
|
|
43
43
|
- [Logs e request id](#logs-e-request-id)
|
|
44
44
|
- [Ciclo de vida e testes](#ciclo-de-vida-e-testes)
|
|
45
|
+
- [Deploy em produção](#deploy-em-produção)
|
|
45
46
|
- [Referência: `createApp(options)`](#referência-createappoptions)
|
|
46
47
|
- [Princípios](#princípios)
|
|
47
48
|
|
|
@@ -54,7 +55,7 @@ await app.listen({ port: 3000 });
|
|
|
54
55
|
```bash
|
|
55
56
|
pnpm add sashiko
|
|
56
57
|
```
|
|
57
|
-
|
|
58
|
+
_(O TypeBox já vem incluído e re-exportado como `Type`, `Static` e `TSchema`)_
|
|
58
59
|
|
|
59
60
|
`tsconfig.json` mínimo:
|
|
60
61
|
|
|
@@ -77,12 +78,16 @@ As rotas ficam em classes marcadas com `@Router`, a "permissão" para o app usar
|
|
|
77
78
|
```ts
|
|
78
79
|
@Router('/users')
|
|
79
80
|
class UserRoutes {
|
|
80
|
-
@Get('/')
|
|
81
|
-
@Get('/:id')
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
@
|
|
85
|
-
@
|
|
81
|
+
@Get('/') list() {}
|
|
82
|
+
@Get('/:id') get(ctx: Context) {
|
|
83
|
+
return { id: ctx.params.id };
|
|
84
|
+
}
|
|
85
|
+
@Post('/') create() {}
|
|
86
|
+
@Put('/:id') replace() {}
|
|
87
|
+
@Patch('/:id') update() {}
|
|
88
|
+
@Delete('/:id') remove(ctx: Context) {
|
|
89
|
+
ctx.status(204);
|
|
90
|
+
}
|
|
86
91
|
}
|
|
87
92
|
```
|
|
88
93
|
|
|
@@ -100,21 +105,22 @@ const userRoutes = new UserRoutes(repo);
|
|
|
100
105
|
|
|
101
106
|
app
|
|
102
107
|
.mount(HealthRoutes)
|
|
103
|
-
.mount(userRoutes)
|
|
104
|
-
.mount(userRoutes, { prefix: '/v2/users' })
|
|
108
|
+
.mount(userRoutes) // como declarado
|
|
109
|
+
.mount(userRoutes, { prefix: '/v2/users' }) // a mesma instância em outro prefixo
|
|
105
110
|
.mount(userRoutes, { prefix: '/public/users', only: (r) => [r.list, r.get] }) // exposição parcial
|
|
106
111
|
.mount(new AdminRoutes(repo), { middlewares: [authenticate], guards: [isAdmin] }); // regras para o grupo
|
|
107
112
|
```
|
|
108
113
|
|
|
109
|
-
| Opção
|
|
110
|
-
|
|
111
|
-
| `prefix`
|
|
114
|
+
| Opção | Efeito |
|
|
115
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
116
|
+
| `prefix` | Substitui o prefixo do `@Router` |
|
|
112
117
|
| `only` / `except` | Registra só os métodos selecionados, ou todos menos esses: `(r) => [r.list, r.get]`. O seletor é checado pelo TypeScript; usar os dois juntos gera erro |
|
|
113
|
-
| `auto`
|
|
114
|
-
| `middlewares`
|
|
115
|
-
| `guards`
|
|
118
|
+
| `auto` | `false` registra só as [rotas manuais](#mapeamento-manual). Padrão: `true` |
|
|
119
|
+
| `middlewares` | Valem para todas as rotas do grupo; rodam antes dos `@Middlewares` da classe |
|
|
120
|
+
| `guards` | Valem para todas as rotas do grupo; rodam antes dos `@Guards` da classe |
|
|
116
121
|
|
|
117
122
|
Erros de registro aparecem na inicialização, com mensagem clara:
|
|
123
|
+
|
|
118
124
|
- classe sem `@Router`;
|
|
119
125
|
- seletor em `only`/`except` que não aponta para um método de rota;
|
|
120
126
|
- a mesma classe registrada duas vezes com o mesmo prefixo.
|
|
@@ -126,9 +132,7 @@ Erros de registro aparecem na inicialização, com mensagem clara:
|
|
|
126
132
|
Para aplicar prefixo, middlewares e guards a **várias** classes de uma vez:
|
|
127
133
|
|
|
128
134
|
```ts
|
|
129
|
-
app.group({ prefix: '/v2', middlewares: [authenticate], guards: [isAdmin] }, (g) =>
|
|
130
|
-
g.mount(UserRoutes).mount(new OrderRoutes(db)),
|
|
131
|
-
);
|
|
135
|
+
app.group({ prefix: '/v2', middlewares: [authenticate], guards: [isAdmin] }, (g) => g.mount(UserRoutes).mount(new OrderRoutes(db)));
|
|
132
136
|
// GET /v2/users, GET /v2/orders…
|
|
133
137
|
```
|
|
134
138
|
|
|
@@ -142,9 +146,7 @@ app.group({ prefix: '/v2', middlewares: [authenticate], guards: [isAdmin] }, (g)
|
|
|
142
146
|
|
|
143
147
|
```ts
|
|
144
148
|
export function audit(app: SashikoApp, opts: { store: AuditStore }) {
|
|
145
|
-
return app
|
|
146
|
-
.group({ prefix: '/audit', guards: [isAdmin] }, (g) => g.mount(new AuditRoutes(opts.store)))
|
|
147
|
-
.onClose(() => opts.store.flush());
|
|
149
|
+
return app.group({ prefix: '/audit', guards: [isAdmin] }, (g) => g.mount(new AuditRoutes(opts.store))).onClose(() => opts.store.flush());
|
|
148
150
|
}
|
|
149
151
|
|
|
150
152
|
audit(app, { store });
|
|
@@ -157,16 +159,16 @@ Depois do `mount`, os verbos `.get()`, `.post()`, `.put()`, `.patch()` e `.delet
|
|
|
157
159
|
```ts
|
|
158
160
|
@Router('/users')
|
|
159
161
|
class UserRoutes {
|
|
160
|
-
@Get('/:id', byId)
|
|
161
|
-
@Schema(csv)
|
|
162
|
-
private findOr404() {}
|
|
162
|
+
@Get('/:id', byId) getUser(ctx: Context<typeof byId>) {}
|
|
163
|
+
@Schema(csv) exportCsv(ctx: Context<typeof csv>) {} // sem verbo: só por mapeamento manual
|
|
164
|
+
private findOr404() {} // sem decorator: nunca pode ser exposto
|
|
163
165
|
}
|
|
164
166
|
|
|
165
167
|
app
|
|
166
168
|
.mount(new UserRoutes(repo))
|
|
167
|
-
.get('/legacy/:id', (r) => r.getUser)
|
|
169
|
+
.get('/legacy/:id', (r) => r.getUser) // GET /users/legacy/:id
|
|
168
170
|
.get('/export.csv', (r) => r.exportCsv, { middlewares: [cache()], guards: [isAuditor] })
|
|
169
|
-
.mount(HealthRoutes);
|
|
171
|
+
.mount(HealthRoutes); // o encadeamento continua
|
|
170
172
|
```
|
|
171
173
|
|
|
172
174
|
- **Seletor:** funciona com instância (inclusive inline, `mount(new X(repo))`) e com a classe. "Ir para a definição" e renomear no editor funcionam.
|
|
@@ -207,11 +209,13 @@ list(ctx: Context<typeof list>) {
|
|
|
207
209
|
```
|
|
208
210
|
|
|
209
211
|
**Path e `params` conferidos**
|
|
212
|
+
|
|
210
213
|
- **Em compilação:** com schema de `params` no verbo, todo parâmetro do path precisa estar no schema. `@Get('/:userId', { params: Type.Object({ id: Type.String() }) })` é erro de tipo (`Missing in params schema: "userId"`).
|
|
211
214
|
- **No registro:** o `app.mount()` confere path x schema nos dois sentidos, já com o prefixo do `@Router`/`mount` (que pode ter parâmetros, como `'/orgs/:org'`), também para `@Schema` separado e rotas manuais. Também recusa path malformado: `/:`, nome repetido, regex sem fechamento, `*` fora do fim.
|
|
212
215
|
- **Sem schema:** `Context<'/:org/users/:id'>` tipa `ctx.params` pelo path (`{ org: string; id: string }`). O utilitário `PathParams<'/:a/:b'>` dá `'a' | 'b'`.
|
|
213
216
|
|
|
214
217
|
**Validação**
|
|
218
|
+
|
|
215
219
|
- **Tipagem:** com `Context<typeof schemas>`, `ctx.params`, `ctx.query` e `ctx.body` são tipados a partir do schema. Sem o genérico, `Context` usa tipos genéricos.
|
|
216
220
|
- **Coerção:** params, query e headers chegam como texto e são convertidos para o tipo declarado. O **body não sofre coerção**: `"30"` não vira `30`.
|
|
217
221
|
- **Defaults:** valores `default` são aplicados.
|
|
@@ -228,6 +232,7 @@ list(ctx: Context<typeof list>) {
|
|
|
228
232
|
- **Campos extras:** use `additionalProperties: false` nos bodies para rejeitar campos não previstos. É a proteção contra mass assignment.
|
|
229
233
|
|
|
230
234
|
**Resposta**
|
|
235
|
+
|
|
231
236
|
- **Filtro:** o schema de `response` serializa a saída com `fast-json-stringify`, e **campos não declarados não saem**, por exemplo `password`.
|
|
232
237
|
- **Status padrão:** se só existe um 2xx declarado (como `201`), ele é o status padrão; não é preciso chamar `ctx.status(201)`.
|
|
233
238
|
- **Status sem schema:** se a rota declara schemas de resposta e o handler devolve um status sem schema, a resposta é **500**, com o motivo no log, e não o objeto sem filtro.
|
|
@@ -239,13 +244,13 @@ list(ctx: Context<typeof list>) {
|
|
|
239
244
|
|
|
240
245
|
O valor retornado pelo handler vira a resposta. Sem schema de resposta, o formato vem do tipo do valor:
|
|
241
246
|
|
|
242
|
-
| Retorno
|
|
243
|
-
|
|
244
|
-
| objeto, array, número, booleano, `null` | `application/json`
|
|
245
|
-
| `string`
|
|
246
|
-
| `Buffer` / `Uint8Array`
|
|
247
|
-
| `Readable` (stream)
|
|
248
|
-
| `undefined`
|
|
247
|
+
| Retorno | Resposta |
|
|
248
|
+
| --------------------------------------- | -------------------------------------- |
|
|
249
|
+
| objeto, array, número, booleano, `null` | `application/json` |
|
|
250
|
+
| `string` | `text/plain; charset=utf-8`, sem aspas |
|
|
251
|
+
| `Buffer` / `Uint8Array` | `application/octet-stream` |
|
|
252
|
+
| `Readable` (stream) | enviado em stream |
|
|
253
|
+
| `undefined` | sem corpo |
|
|
249
254
|
|
|
250
255
|
Um `Content-Type` definido no handler (`ctx.header('Content-Type', ...)`) sempre prevalece.
|
|
251
256
|
|
|
@@ -295,6 +300,7 @@ run(ctx: Context<typeof job>) {
|
|
|
295
300
|
**O `status()` tipa o corpo.** Com schemas de resposta, `ctx.status()` só aceita os status declarados, e o `json()`/`send()` seguinte exige o corpo daquele status. `ctx.status(202).json({ result })` e `ctx.status(201)` são erros de compilação no exemplo acima.
|
|
296
301
|
|
|
297
302
|
Estilo recomendado:
|
|
303
|
+
|
|
298
304
|
- `return` para o caso comum;
|
|
299
305
|
- `ctx.status(x).json(body)` quando o status é decidido em runtime;
|
|
300
306
|
- `throw` para erros.
|
|
@@ -303,23 +309,23 @@ Os schemas de resposta valem para o que o **handler** envia. Middlewares e guard
|
|
|
303
309
|
|
|
304
310
|
### Helpers do `ctx`
|
|
305
311
|
|
|
306
|
-
| Método
|
|
307
|
-
|
|
308
|
-
| `ctx.status(code)`
|
|
309
|
-
| `ctx.header(name, value)`
|
|
310
|
-
| `ctx.get(name)`
|
|
311
|
-
| `ctx.json(data)`
|
|
312
|
-
| `ctx.text(str)`
|
|
313
|
-
| `ctx.send(body, contentType?)`
|
|
314
|
-
| `ctx.redirect(url, status?)`
|
|
315
|
-
| `ctx.cookies`
|
|
312
|
+
| Método | Uso |
|
|
313
|
+
| ----------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
314
|
+
| `ctx.status(code)` | Define o status |
|
|
315
|
+
| `ctx.header(name, value)` | Define um header da resposta |
|
|
316
|
+
| `ctx.get(name)` | Lê um header da requisição |
|
|
317
|
+
| `ctx.json(data)` | Envia JSON pelo pipeline (schema, filtro, status padrão) |
|
|
318
|
+
| `ctx.text(str)` | `text/plain; charset=utf-8`, pelo pipeline |
|
|
319
|
+
| `ctx.send(body, contentType?)` | Qualquer corpo pelo pipeline: string, bytes, stream ou objeto (negociado pelo `Accept`) |
|
|
320
|
+
| `ctx.redirect(url, status?)` | 302 por padrão; aceita 301, 303, 307, 308 |
|
|
321
|
+
| `ctx.cookies` | Cookies da requisição (objeto sem protótipo) |
|
|
316
322
|
| `ctx.setCookie(name, value, opts?)` | Adiciona um `Set-Cookie`, com `HttpOnly`, `Secure`, `SameSite=Lax` e `Path=/` por padrão |
|
|
317
|
-
| `ctx.clearCookie(name, opts?)`
|
|
318
|
-
| `ctx.throw(status, message)`
|
|
323
|
+
| `ctx.clearCookie(name, opts?)` | Remove um cookie (use o mesmo `path` e `domain` da criação) |
|
|
324
|
+
| `ctx.throw(status, message)` | Lança um `HttpError` |
|
|
319
325
|
|
|
320
326
|
Outros campos: `ctx.params`, `ctx.query`, `ctx.body`, `ctx.state`, `ctx.id`, `ctx.log`, `ctx.sentBody`, `ctx.sentType`, `ctx.ip`, `ctx.host`, `ctx.hostname`, `ctx.protocol`, `ctx.method`, `ctx.path`, `ctx.url`, `ctx.req` e `ctx.res`.
|
|
321
327
|
|
|
322
|
-
> ⚠️ `ctx.send` não faz escape de HTML; escape o conteúdo dinâmico antes. `ctx.redirect` com uma URL vinda do usuário abre um
|
|
328
|
+
> ⚠️ `ctx.send` não faz escape de HTML; escape o conteúdo dinâmico antes. `ctx.redirect` com uma URL vinda do usuário abre um _open redirect_; valide o destino.
|
|
323
329
|
|
|
324
330
|
## Middlewares e guards
|
|
325
331
|
|
|
@@ -337,7 +343,9 @@ Por classe ou por método, use `@Middlewares` e `@Guards`; para um grupo inteiro
|
|
|
337
343
|
|
|
338
344
|
```ts
|
|
339
345
|
declare module 'sashiko' {
|
|
340
|
-
interface ContextState {
|
|
346
|
+
interface ContextState {
|
|
347
|
+
user?: { id: string; role: 'admin' | 'user' };
|
|
348
|
+
} // ctx.state tipado
|
|
341
349
|
}
|
|
342
350
|
|
|
343
351
|
const authenticate: Middleware = async (ctx, next) => {
|
|
@@ -373,9 +381,13 @@ Para o que acontece **antes** do handler (autenticação, rate limit, preparar o
|
|
|
373
381
|
|
|
374
382
|
```ts
|
|
375
383
|
app
|
|
376
|
-
.onSend((ctx, payload) => {
|
|
384
|
+
.onSend((ctx, payload) => {
|
|
385
|
+
ctx.header('ETag', etag(payload.body));
|
|
386
|
+
}) // antes de escrever (síncrono)
|
|
377
387
|
.onResponse((ctx, { status, ms }) => metrics.observe(ctx.path, status, ms)) // depois do envio
|
|
378
|
-
.onClose(async () => {
|
|
388
|
+
.onClose(async () => {
|
|
389
|
+
await db.close();
|
|
390
|
+
}); // em qualquer close()
|
|
379
391
|
```
|
|
380
392
|
|
|
381
393
|
- **`onSend`:** roda imediatamente antes de escrever uma resposta com corpo (exceto streams), inclusive erros e 404. É **síncrono**, como a escrita do pipeline. Pode alterar headers ou devolver `{ body, contentType }` novos. Se lançar, o erro é logado e a resposta original é enviada.
|
|
@@ -385,23 +397,25 @@ app
|
|
|
385
397
|
**Cache com middleware.** Depois do `await next()`, `ctx.sentBody` e `ctx.sentType` contêm o corpo exatamente como foi enviado, já filtrado pelo schema:
|
|
386
398
|
|
|
387
399
|
```ts
|
|
388
|
-
const cache =
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
400
|
+
const cache =
|
|
401
|
+
(ttlMs: number): Middleware =>
|
|
402
|
+
async (ctx, next) => {
|
|
403
|
+
const hit = store.get(ctx.url.href);
|
|
404
|
+
if (hit) return void ctx.header('X-Cache', 'HIT').send(hit.body, hit.type);
|
|
405
|
+
ctx.header('X-Cache', 'MISS'); // headers antes do next(): depois dele a resposta já foi enviada
|
|
406
|
+
await next();
|
|
407
|
+
if (ctx.res.statusCode === 200 && ctx.sentBody) store.set(ctx.url.href, { body: ctx.sentBody, type: ctx.sentType! });
|
|
408
|
+
};
|
|
395
409
|
app.mount(products, { middlewares: [cache(60_000)] });
|
|
396
410
|
```
|
|
397
411
|
|
|
398
|
-
| Fastify
|
|
399
|
-
|
|
412
|
+
| Fastify | Sashiko |
|
|
413
|
+
| ------------------------- | ----------------------------------------------------------------- |
|
|
400
414
|
| `onRequest`, `preHandler` | middlewares (`app.use`, `mount({ middlewares })`, `@Middlewares`) |
|
|
401
|
-
| `onError`
|
|
402
|
-
| `onSend`
|
|
403
|
-
| `onResponse`
|
|
404
|
-
| `onClose`
|
|
415
|
+
| `onError` | `app.onError` |
|
|
416
|
+
| `onSend` | `app.onSend` (síncrono) |
|
|
417
|
+
| `onResponse` | `app.onResponse` |
|
|
418
|
+
| `onClose` | `app.onClose` |
|
|
405
419
|
|
|
406
420
|
## Erros
|
|
407
421
|
|
|
@@ -420,14 +434,14 @@ Todos os erros, inclusive 404, 405, validação e `Host` inválido, saem no form
|
|
|
420
434
|
{ "type": "about:blank", "title": "Not Found", "status": 404, "detail": "user not found", "code": "NOT_FOUND" }
|
|
421
435
|
```
|
|
422
436
|
|
|
423
|
-
| Campo
|
|
424
|
-
|
|
425
|
-
| `type`
|
|
426
|
-
| `title`
|
|
427
|
-
| `status`
|
|
428
|
-
| `detail`
|
|
429
|
-
| `code`
|
|
430
|
-
| `details` | Dados extras: os erros de validação, `allowed` no 405, `available` no 406
|
|
437
|
+
| Campo | Conteúdo |
|
|
438
|
+
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
439
|
+
| `type` | URI do tipo do problema (opção `type` do `HttpError`); `about:blank` por padrão |
|
|
440
|
+
| `title` | Frase do status (`Not Found`) |
|
|
441
|
+
| `status` | Status HTTP |
|
|
442
|
+
| `detail` | A mensagem do erro; omitido quando igual ao título ou em 5xx não expostos |
|
|
443
|
+
| `code` | Código estável para o cliente tratar: `VALIDATION_ERROR`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `NOT_FOUND`, `METHOD_NOT_ALLOWED`, `NOT_ACCEPTABLE`, `INVALID_HOST`, `FORBIDDEN`, `RATE_LIMITED`, `CSRF_ORIGIN`, `CSRF_TOKEN`… |
|
|
444
|
+
| `details` | Dados extras: os erros de validação, `allowed` no 405, `available` no 406 |
|
|
431
445
|
|
|
432
446
|
- **Erros 4xx:** a mensagem vai para o cliente.
|
|
433
447
|
- **Erros 5xx e erros comuns** (`throw new Error(...)`): a resposta é sempre `{ "type": "about:blank", "title": "Internal Server Error", "status": 500 }`. O erro completo vai para o log, com `reqId`. Para mostrar uma mensagem de 5xx ao cliente, use `expose: true`.
|
|
@@ -442,8 +456,8 @@ createApp()
|
|
|
442
456
|
.openapi({
|
|
443
457
|
info: { title: 'Minha API', version: '1.0.0' },
|
|
444
458
|
servers: [{ url: 'https://api.exemplo.dev' }],
|
|
445
|
-
docs: true,
|
|
446
|
-
guards: [isAdmin],
|
|
459
|
+
docs: true, // Swagger UI em /docs (desligado por padrão)
|
|
460
|
+
guards: [isAdmin], // só para /openapi.json e /docs
|
|
447
461
|
});
|
|
448
462
|
```
|
|
449
463
|
|
|
@@ -455,14 +469,14 @@ createApp()
|
|
|
455
469
|
- **`app.openapiDocument()`:** devolve o objeto, para gravar em arquivo, gerar clientes ou testar no CI.
|
|
456
470
|
- **O que entra no documento:**
|
|
457
471
|
|
|
458
|
-
| Origem
|
|
459
|
-
|
|
460
|
-
| Path (`/:id(^\\d+)`)
|
|
461
|
-
| `params`, `query`, `headers` | `parameters`, com `required`
|
|
462
|
-
| `body`
|
|
463
|
-
| `response`
|
|
464
|
-
| Erros
|
|
465
|
-
| Classe e método
|
|
472
|
+
| Origem | Vira |
|
|
473
|
+
| ---------------------------- | ----------------------------------------------------------------------------------- |
|
|
474
|
+
| Path (`/:id(^\\d+)`) | `/{id}` |
|
|
475
|
+
| `params`, `query`, `headers` | `parameters`, com `required` |
|
|
476
|
+
| `body` | `requestBody` em JSON |
|
|
477
|
+
| `response` | uma resposta por status, com os `contentType` declarados |
|
|
478
|
+
| Erros | resposta `default` com o schema `ProblemDetails`; com `formatError`, só a descrição |
|
|
479
|
+
| Classe e método | `tags` e `operationId` (`UserRoutes.getUser`) |
|
|
466
480
|
|
|
467
481
|
- **Limites:** só schemas JSON Schema (TypeBox) entram como estão. Rotas sem schema de resposta aparecem com um `200` genérico. Em produção, proteja as rotas com `guards`/`middlewares` ou gere só o arquivo com `openapiDocument()`.
|
|
468
482
|
|
|
@@ -470,15 +484,17 @@ createApp()
|
|
|
470
484
|
|
|
471
485
|
Ligado por padrão:
|
|
472
486
|
|
|
473
|
-
| Proteção
|
|
474
|
-
|
|
487
|
+
| Proteção | Detalhe |
|
|
488
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
475
489
|
| Headers de segurança | `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin`. HSTS é opt-in. Para servir páginas, use `securityHeaders: 'web'` (CSP `default-src 'self'`, `X-Frame-Options: SAMEORIGIN`). |
|
|
476
|
-
| Limite de body
|
|
477
|
-
| Timeouts
|
|
478
|
-
| Query
|
|
479
|
-
|
|
|
480
|
-
|
|
|
481
|
-
|
|
|
490
|
+
| Limite de body | 1 MiB (`bodyLimit`). Recusado já pelo `Content-Length`, com a conexão fechada. |
|
|
491
|
+
| Timeouts | `requestTimeout` 30s, `headersTimeout` 10s, `keepAliveTimeout` 5s (contra Slowloris). |
|
|
492
|
+
| Query | Até 1000 pares (`queryMaxKeys`); `__proto__`, `constructor` e `prototype` são descartados. |
|
|
493
|
+
| Body JSON | `__proto__` e `constructor.prototype` em qualquer nível, inclusive com escapes `\u`, recebem 400 (`PROTO_POISONING`). Com `protoPoisoning: 'remove'`, as chaves são descartadas e a requisição segue. |
|
|
494
|
+
| Logs | `authorization`, `cookie`, `password`, `token`, `apiKey` e afins saem como `[REDACTED]` no logger padrão. |
|
|
495
|
+
| Erros 5xx | Sem detalhes para o cliente. |
|
|
496
|
+
| Request id | Ids recebidos só são aceitos se forem seguros (contra log injection). |
|
|
497
|
+
| Host e path | `Host` malformado → 400. O path vem do request-target com base fixa: `//evil.com/x` continua sendo esse path e não muda o host. `ctx.url` usa o host validado. |
|
|
482
498
|
|
|
483
499
|
Opt-in:
|
|
484
500
|
|
|
@@ -490,22 +506,31 @@ createApp({
|
|
|
490
506
|
securityHeaders: { strictTransportSecurity: true }, // só atrás de HTTPS
|
|
491
507
|
})
|
|
492
508
|
.use(cors({ origin: ['https://app.exemplo.com', 'https://*.exemplo.dev'], credentials: true }))
|
|
493
|
-
.use(rateLimit({ max: 100, windowMs: 60_000 }));
|
|
509
|
+
.use(rateLimit({ max: 100, windowMs: 60_000 })); // 429 + Retry-After
|
|
494
510
|
```
|
|
495
511
|
|
|
496
512
|
- **`cors()`:** a origem é obrigatória, sem default permissivo. Responde ao preflight com 204 e envia `Vary: Origin`. Combinar `origin: '*'` com `credentials: true` gera erro.
|
|
497
513
|
- **Regras de origem e de host, seguras por padrão** (as mesmas para `cors()` e `allowedHosts`):
|
|
498
514
|
|
|
499
|
-
| Regra
|
|
500
|
-
|
|
515
|
+
| Regra | Casa com | Não casa com |
|
|
516
|
+
| --------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------- |
|
|
501
517
|
| `'https://app.exemplo.com'` | a mesma origem, depois de normalizar (maiúsculas, porta padrão, barra final) | `http://…`, outra porta, `app.exemplo.com.atacante.com` |
|
|
502
|
-
| `'https://*.exemplo.dev'`
|
|
503
|
-
| RegExp
|
|
504
|
-
| função
|
|
505
|
-
|
|
518
|
+
| `'https://*.exemplo.dev'` | qualquer subdomínio | `exemplo.dev` em si, `exemplo.dev.atacante.com` |
|
|
519
|
+
| RegExp | a string **inteira**, como se tivesse `^…$` | qualquer casamento parcial |
|
|
520
|
+
| função | o que ela decidir, sob sua responsabilidade | — |
|
|
506
521
|
- **RegExp sem âncora não vira brecha:** `/app\.exemplo\.com/` não aceita `app.exemplo.com.atacante.com`. As flags `g`, `y` e `m` são descartadas, então o resultado não muda entre requisições.
|
|
507
522
|
- **Prefira o curinga à RegExp:** uma regex mal escrita pode ser lenta com uma entrada maliciosa (ReDoS).
|
|
508
523
|
- **Regras inválidas falham na inicialização,** por exemplo uma origem sem esquema (`'app.exemplo.com'`).
|
|
524
|
+
- **Autenticação: `bearerAuth()` e `basicAuth()`.** São guards prontos. A verificação é sua: JWT com a biblioteca que preferir, sessão no banco, lista de chaves. O usuário devolvido vai para `ctx.state.user`.
|
|
525
|
+
|
|
526
|
+
```ts
|
|
527
|
+
const auth = bearerAuth({ verify: async (token) => (await sessions.find(token)) ?? false });
|
|
528
|
+
app.group({ guards: [auth] }, (g) => g.mount(UserRoutes));
|
|
529
|
+
|
|
530
|
+
const painel = basicAuth({ verify: (user, pass) => safeEqual(user, 'admin') && safeEqual(pass, process.env.ADMIN_PASS!) });
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Credencial ausente, malformada ou recusada recebe **o mesmo** 401, com `WWW-Authenticate` e sem dizer o motivo. Para comparar segredos, use `safeEqual()`, que tem tempo constante e também não revela o tamanho. Use o `basicAuth` só sobre HTTPS.
|
|
509
534
|
- **`rateLimit()`:** guarda os contadores em memória por padrão. Para várias instâncias, implemente `RateLimitStore` (Redis etc.).
|
|
510
535
|
- **Cookies:** `ctx.setCookie()` usa `HttpOnly`, `Secure`, `SameSite=Lax` e `Path=/` por padrão (os navegadores aceitam `Secure` em `http://localhost`). Nome, `path` e `domain` são validados contra injeção de atributos, e os prefixos `__Host-` e `__Secure-` têm as regras conferidas.
|
|
511
536
|
- **`csrf()`: ative sempre que a autenticação usar cookies.** Métodos que alteram estado (todos menos GET, HEAD e OPTIONS) vindos de outra origem recebem 403. A checagem usa o `Sec-Fetch-Site` e, na falta dele, compara o `Origin` com a própria origem. Requisições sem esses headers (curl, servidor a servidor) passam, porque não há navegador para anexar os cookies da vítima.
|
|
@@ -515,6 +540,7 @@ createApp({
|
|
|
515
540
|
app.use(csrf({ trustedOrigins: ['https://app.exemplo.com'] })); // front em outra origem
|
|
516
541
|
app.use(csrf({ token: true })); // + double-submit token
|
|
517
542
|
```
|
|
543
|
+
|
|
518
544
|
Com `token: true`, o middleware cria o cookie `__Host-csrf` (legível pelo JS, `SameSite=Strict`) e o expõe em `ctx.state.csrfToken`; o cliente o devolve no header `X-CSRF-Token`. Rotas GET nunca devem alterar estado.
|
|
519
545
|
- **Atrás de proxy:** `ctx.ip`, `ctx.host` e `ctx.protocol` usam o socket e o `Host` por padrão. Com `trustProxy: 1` (o número de proxies à frente do app) ou `true` (toda a cadeia), passam a usar o `X-Forwarded-For`, o `-Host` e o `-Proto`. Só ligue isso atrás de um proxy que reescreve esses headers, porque o cliente pode forjá-los.
|
|
520
546
|
- **`allowedHosts`:** com `allowedHosts: ['api.exemplo.com', '*.exemplo.dev']`, qualquer outro `Host` recebe 400. Protege contra host header injection e DNS rebinding. Um host sem porta aceita qualquer porta; com porta (`'api.exemplo.com:8443'`), exige a porta exata.
|
|
@@ -528,11 +554,17 @@ import pino from 'pino';
|
|
|
528
554
|
createApp({ logger: pino(), logRequests: true });
|
|
529
555
|
```
|
|
530
556
|
|
|
531
|
-
- **Logger:** compatível com o pino (`info(obj, msg)`, `child()`). O padrão, `consoleLogger({ level })`, usa o `console
|
|
557
|
+
- **Logger:** compatível com o pino (`info(obj, msg)`, `child()`). O padrão, `consoleLogger({ level, format, redact })`, usa o `console`: `format: 'json'` gera uma linha JSON por evento, para coletores de log. `logger: false` desliga tudo.
|
|
532
558
|
- **Request id:** cada requisição recebe `ctx.id`, também devolvido no header `X-Request-Id`. Um id recebido nesse header é reaproveitado se for seguro; isso é configurável em `requestId`.
|
|
533
559
|
- **`ctx.log`:** já inclui o `reqId`: `ctx.log.info({ userId }, 'user created')`.
|
|
534
560
|
- **`logRequests: true`:** registra método, path, status e duração de cada requisição.
|
|
535
|
-
- **Dados sensíveis:** headers e body nunca são logados automaticamente.
|
|
561
|
+
- **Dados sensíveis:** headers e body nunca são logados automaticamente. O que você logar passa pelo `redact` do logger padrão. As chaves `authorization`, `cookie`, `set-cookie`, `x-api-key`, `password`, `secret`, `token`, `access_token`, `refresh_token` e afins, em qualquer nível, saem como `[REDACTED]`. A comparação ignora maiúsculas, `-` e `_`.
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
createApp({ logger: consoleLogger({ redact: ['cpf', 'cardNumber'] }) }); // soma às chaves padrão; false desliga
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Com o pino, configure o [`redact`](https://getpino.io/#/docs/redaction) dele.
|
|
536
568
|
|
|
537
569
|
## Ciclo de vida e testes
|
|
538
570
|
|
|
@@ -541,17 +573,69 @@ const app = createApp().mount(UserRoutes);
|
|
|
541
573
|
|
|
542
574
|
await app.listen({ port: 3000, host: '0.0.0.0' }); // resolve quando está ouvindo; SIGINT/SIGTERM já tratados
|
|
543
575
|
|
|
544
|
-
|
|
576
|
+
await app.listen({ port: 443, host: '0.0.0.0', tls: { key, cert } }); // HTTPS, com o mesmo ciclo de vida
|
|
577
|
+
|
|
578
|
+
// Servidor próprio (http2 em modo compat, outro transporte…): aqui o ciclo de vida é seu
|
|
545
579
|
https.createServer(tlsOptions, app.callback()).listen(443);
|
|
546
580
|
```
|
|
547
581
|
|
|
582
|
+
### HTTPS
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
import { readFileSync } from 'node:fs';
|
|
586
|
+
|
|
587
|
+
await app.listen({
|
|
588
|
+
port: 443,
|
|
589
|
+
host: '0.0.0.0',
|
|
590
|
+
tls: {
|
|
591
|
+
key: readFileSync('/etc/ssl/privkey.pem'),
|
|
592
|
+
cert: readFileSync('/etc/ssl/fullchain.pem'),
|
|
593
|
+
// pfx + passphrase também funcionam; minVersion: 'TLSv1.3' para exigir TLS 1.3
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
- **Mesmo ciclo de vida do HTTP:** timeouts contra Slowloris, encerramento gracioso nos sinais, `close()` e `onClose`. `ctx.protocol` vira `https`, e os cookies `Secure` funcionam.
|
|
599
|
+
- **TLS 1.2 é o mínimo.** SSLv3, TLS 1.0 e 1.1 não são aceitos, nem por configuração.
|
|
600
|
+
- **Certificados inválidos falham no `listen()`,** e não no primeiro cliente.
|
|
601
|
+
- **Vários domínios (SNI):** cada domínio recebe o seu certificado no handshake.
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
tls: {
|
|
605
|
+
key, cert, // principal: domínios sem entrada própria
|
|
606
|
+
sni: {
|
|
607
|
+
'api.exemplo.com': { key: apiKey, cert: apiCert },
|
|
608
|
+
'*.exemplo.dev': { key: devKey, cert: devCert }, // um nível: a.exemplo.dev, não a.b.exemplo.dev
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Sem certificado principal, um domínio fora da lista não completa o handshake. Para recusar o domínio também na camada HTTP, combine com `allowedHosts`.
|
|
614
|
+
- **Renovação sem reiniciar:** `app.reloadTLS({ key, cert, sni })` troca os certificados. As conexões novas usam os novos, e as abertas continuam com os antigos. Um certificado inválido lança erro e mantém os atuais. É o ponto de integração para um cliente ACME externo, como Let's Encrypt via certbot:
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
watch('/etc/ssl/fullchain.pem', () => app.reloadTLS({ key: readFileSync(keyPath), cert: readFileSync(certPath) }));
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
**TLS no app ou no proxy?** Na maioria dos deploys, o TLS termina no proxy ou no load balancer (Caddy, Nginx, Traefik). Nesse caso, use `app.listen()` em HTTP na rede interna, com `trustProxy: 1` e `allowedHosts`. Use `listen({ tls })` quando não houver proxy à frente ou quando o TLS precisar ir até o processo.
|
|
621
|
+
|
|
622
|
+
**Fora do core, por decisão:** emissão e renovação automática de certificados (ACME) e gerenciamento de DNS. São responsabilidades de ferramentas próprias, integradas pelo `reloadTLS()`.
|
|
623
|
+
|
|
624
|
+
### Planos futuros
|
|
625
|
+
|
|
626
|
+
- **HTTP/2** (com fallback para HTTP/1.1 via ALPN). Ainda não é suportado pelo `listen()`. O modo de compatibilidade do Node muda partes do ciclo de vida: o shutdown usa GOAWAY, e não `Connection: close`; não existem `requestTimeout` nem `headersTimeout`; os limites de streams precisam de padrão seguro. Por isso, entra numa versão própria. Até lá, o proxy à frente pode falar HTTP/2 com o cliente e HTTP/1.1 com o app.
|
|
627
|
+
- **Virtual hosts:** `mount(X, { host })` e `group({ host })`, para servir domínios diferentes no mesmo app.
|
|
628
|
+
- **mTLS:** certificado do cliente (`requestCert`) e `ctx.clientCertificate`.
|
|
629
|
+
|
|
548
630
|
**Encerramento gracioso, sem código.** Com `listen()`, um SIGINT ou SIGTERM faz o app parar de aceitar conexões, esperar as requisições em andamento (com prazo) e sair com código 0:
|
|
549
631
|
|
|
550
632
|
```ts
|
|
551
633
|
createApp({
|
|
552
634
|
shutdown: {
|
|
553
|
-
timeoutMs: 10_000,
|
|
554
|
-
onShutdown: async () => {
|
|
635
|
+
timeoutMs: 10_000, // prazo para as requisições em andamento (padrão)
|
|
636
|
+
onShutdown: async () => {
|
|
637
|
+
await db.close();
|
|
638
|
+
}, // depois que o servidor fecha
|
|
555
639
|
},
|
|
556
640
|
});
|
|
557
641
|
```
|
|
@@ -560,6 +644,28 @@ createApp({
|
|
|
560
644
|
- **Vários apps no mesmo processo:** compartilham um único listener por sinal e fecham todos juntos.
|
|
561
645
|
- **Limpeza:** os listeners são removidos no `close()`, então testes com vários `listen(0)` não acumulam listeners. O `inject()` e o `callback()` não registram nada.
|
|
562
646
|
- **Opções:** `signals` muda os sinais tratados, `exit: false` fecha sem chamar `process.exit`, e `shutdown: false` desliga tudo (por exemplo, se outra ferramenta controla o processo).
|
|
647
|
+
- **Drain para load balancers:** com `shutdown: { drainDelayMs: 5000 }`, o app espera esse tempo antes de parar de aceitar conexões, já com o `/readyz` em 503, para o balanceador tirar a instância de rotação sem cortar requisições.
|
|
648
|
+
|
|
649
|
+
### Healthchecks
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
app.health({
|
|
653
|
+
checks: {
|
|
654
|
+
db: () => db.query('select 1'), // lançar ou devolver false = falha
|
|
655
|
+
cache: () => redis.ping(),
|
|
656
|
+
},
|
|
657
|
+
timeoutMs: 2000, // por check (padrão)
|
|
658
|
+
});
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
| Rota | Responde | Uso |
|
|
662
|
+
| ------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
|
663
|
+
| `GET /livez` | 200 `{ "status": "ok" }` enquanto o processo estiver de pé | liveness: reiniciar o processo travado |
|
|
664
|
+
| `GET /readyz` | 200 com o resultado de cada check; 503 se algum falhar ou estourar o tempo, e durante o encerramento | readiness: tirar a instância do balanceamento |
|
|
665
|
+
|
|
666
|
+
- **Os checks rodam em paralelo.** O motivo da falha vai só para o log, nunca para a resposta, porque pode conter host, usuário ou URL do banco.
|
|
667
|
+
- **As rotas são servidas antes dos middlewares globais,** já que sondas não têm credenciais nem devem consumir rate limit. Para restringir, use `guards`/`middlewares` no próprio `health()`.
|
|
668
|
+
- **Opções:** `live: '/health'` muda o path, e `ready: false` desliga a rota.
|
|
563
669
|
|
|
564
670
|
**Testes sem rede.** `app.inject()` executa a requisição em memória:
|
|
565
671
|
|
|
@@ -569,25 +675,60 @@ assert.equal(res.status, 201);
|
|
|
569
675
|
assert.equal(res.json<{ name: string }>().name, 'Ana');
|
|
570
676
|
```
|
|
571
677
|
|
|
678
|
+
## Deploy em produção
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
const app = createApp({
|
|
682
|
+
logger: consoleLogger({ format: 'json' }), // uma linha JSON por evento, com redact
|
|
683
|
+
trustProxy: 1, // atrás de 1 proxy/load balancer
|
|
684
|
+
allowedHosts: ['api.exemplo.com'],
|
|
685
|
+
securityHeaders: { strictTransportSecurity: true }, // HSTS: só com HTTPS na frente
|
|
686
|
+
shutdown: { drainDelayMs: 5_000, timeoutMs: 10_000 },
|
|
687
|
+
})
|
|
688
|
+
.use(rateLimit({ max: 100, windowMs: 60_000 }))
|
|
689
|
+
.mount(UserRoutes)
|
|
690
|
+
.health({ checks: { db: () => db.ping() } });
|
|
691
|
+
|
|
692
|
+
await app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' });
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
Checklist:
|
|
696
|
+
|
|
697
|
+
| Item | Como |
|
|
698
|
+
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
699
|
+
| Escutar fora do container | `host: '0.0.0.0'` (o padrão é `127.0.0.1`), porta por `PORT` |
|
|
700
|
+
| TLS | No proxy ou load balancer (recomendado), ou `listen({ tls })` |
|
|
701
|
+
| Proxy à frente | `trustProxy` com o número **exato** de proxies; nunca `true` se o app for acessível direto |
|
|
702
|
+
| Domínios | `allowedHosts` com os domínios servidos |
|
|
703
|
+
| HSTS | `securityHeaders: { strictTransportSecurity: true }`, só quando todo acesso for HTTPS |
|
|
704
|
+
| Logs | `consoleLogger({ format: 'json' })` ou pino; campos sensíveis já mascarados |
|
|
705
|
+
| Healthchecks | `/livez` (liveness) e `/readyz` (readiness) com `app.health()` |
|
|
706
|
+
| Encerramento | SIGTERM já tratado; `drainDelayMs` maior que o intervalo da sonda de readiness |
|
|
707
|
+
| Rate limit com várias instâncias | `RateLimitStore` compartilhado (Redis), porque o padrão é em memória por processo |
|
|
708
|
+
| Autenticação por cookie | `csrf()` ativo |
|
|
709
|
+
| Documentação da API | `openapi({ guards })`, ou gerar só o arquivo com `openapiDocument()` |
|
|
710
|
+
| Processo | Rode `node dist/server.js` direto (não via `npm start`), para o SIGTERM chegar ao Node; em container, `--init` ou `tini` como PID 1 |
|
|
711
|
+
|
|
572
712
|
## Referência: `createApp(options)`
|
|
573
713
|
|
|
574
|
-
| Opção
|
|
575
|
-
|
|
576
|
-
| `bodyLimit`
|
|
577
|
-
| `queryMaxKeys`
|
|
578
|
-
| `requireSchemas`
|
|
579
|
-
| `securityHeaders` | `'api'`
|
|
580
|
-
| `serializers`
|
|
581
|
-
| `shutdown`
|
|
582
|
-
| `
|
|
583
|
-
| `
|
|
584
|
-
| `
|
|
585
|
-
| `
|
|
586
|
-
| `
|
|
587
|
-
| `
|
|
588
|
-
| `
|
|
589
|
-
|
|
590
|
-
|
|
714
|
+
| Opção | Padrão | Descrição |
|
|
715
|
+
| ----------------- | --------------------- | ------------------------------------------------------------------------------------------ |
|
|
716
|
+
| `bodyLimit` | `1048576` | Tamanho máximo do body JSON, em bytes |
|
|
717
|
+
| `queryMaxKeys` | `1000` | Máximo de pares lidos da query string |
|
|
718
|
+
| `requireSchemas` | `false` | Exige schemas de body e de resposta no registro das rotas |
|
|
719
|
+
| `securityHeaders` | `'api'` | `'api'`, `'web'`, um objeto de opções, ou `false` para desligar |
|
|
720
|
+
| `serializers` | — | Serializadores por content type, para responder em outros formatos |
|
|
721
|
+
| `shutdown` | SIGINT/SIGTERM, 10s | `signals`, `timeoutMs`, `drainDelayMs`, `onShutdown`, `exit`; `false` desliga |
|
|
722
|
+
| `protoPoisoning` | `'error'` | Body com `__proto__`/`constructor.prototype`: `'error'` (400) ou `'remove'` |
|
|
723
|
+
| `trustProxy` | `false` | `true` ou o número de proxies: `ctx.ip`/`host`/`protocol` passam a usar os `X-Forwarded-*` |
|
|
724
|
+
| `allowedHosts` | qualquer host válido | Lista de hosts (string ou RegExp); os demais recebem 400 |
|
|
725
|
+
| `formatError` | Problem Details | `(problem, ctx) => corpo`, para personalizar o corpo de todos os erros |
|
|
726
|
+
| `timeouts` | 30s / 10s / 5s | `requestTimeout`, `headersTimeout`, `keepAliveTimeout` |
|
|
727
|
+
| `logger` | `consoleLogger()` | Logger compatível com pino; `false` desliga |
|
|
728
|
+
| `logRequests` | `false` | Log de cada requisição concluída |
|
|
729
|
+
| `requestId` | header `x-request-id` | `header`, `trustIncoming`, `generate` |
|
|
730
|
+
|
|
731
|
+
Métodos do app: `use()`, `mount()`, `group()`, `health()`, `openapi()`, `openapiDocument()`, `onError()`, `onSend()`, `onResponse()`, `onClose()`, `listen()`, `close()`, `callback()`, `inject()`, `log`. O `mount()` devolve um `MountedGroup`, com os verbos do mapeamento manual e os mesmos métodos do app, para continuar o encadeamento.
|
|
591
732
|
|
|
592
733
|
O body só é lido como JSON quando o `Content-Type` é `application/json` ou `application/*+json`.
|
|
593
734
|
|
|
@@ -620,10 +761,13 @@ pnpm install
|
|
|
620
761
|
pnpm test # node:test + tsx
|
|
621
762
|
pnpm test:coverage # com limites mínimos de cobertura
|
|
622
763
|
pnpm typecheck
|
|
764
|
+
pnpm check # lint + formatação (Biome); pnpm format corrige a formatação
|
|
623
765
|
pnpm build # dist/
|
|
624
766
|
```
|
|
625
767
|
|
|
626
|
-
|
|
768
|
+
Para o `git blame` ignorar o commit de formatação: `git config blame.ignoreRevsFile .git-blame-ignore-revs`.
|
|
769
|
+
|
|
770
|
+
Mudanças notáveis estão no [CHANGELOG](CHANGELOG.md). Para reportar uma vulnerabilidade e conhecer a política de dependências, veja o [SECURITY.md](SECURITY.md).
|
|
627
771
|
|
|
628
772
|
## Licença
|
|
629
773
|
|