sashiko 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +233 -118
  2. package/dist/core/app.d.ts +36 -1
  3. package/dist/core/app.d.ts.map +1 -1
  4. package/dist/core/app.js +92 -20
  5. package/dist/core/app.js.map +1 -1
  6. package/dist/core/body-parser.d.ts +2 -1
  7. package/dist/core/body-parser.d.ts.map +1 -1
  8. package/dist/core/body-parser.js +7 -3
  9. package/dist/core/body-parser.js.map +1 -1
  10. package/dist/core/compiler.d.ts +12 -0
  11. package/dist/core/compiler.d.ts.map +1 -1
  12. package/dist/core/compiler.js +33 -7
  13. package/dist/core/compiler.js.map +1 -1
  14. package/dist/core/context.d.ts.map +1 -1
  15. package/dist/core/health.d.ts +26 -0
  16. package/dist/core/health.d.ts.map +1 -0
  17. package/dist/core/health.js +62 -0
  18. package/dist/core/health.js.map +1 -0
  19. package/dist/core/index.d.ts +8 -7
  20. package/dist/core/index.d.ts.map +1 -1
  21. package/dist/core/index.js +5 -5
  22. package/dist/core/index.js.map +1 -1
  23. package/dist/core/infer.d.ts +1 -3
  24. package/dist/core/infer.d.ts.map +1 -1
  25. package/dist/core/inject.d.ts.map +1 -1
  26. package/dist/core/inject.js.map +1 -1
  27. package/dist/core/logger.d.ts +17 -1
  28. package/dist/core/logger.d.ts.map +1 -1
  29. package/dist/core/logger.js +71 -3
  30. package/dist/core/logger.js.map +1 -1
  31. package/dist/core/metadata.js.map +1 -1
  32. package/dist/core/mounted-group.d.ts +2 -0
  33. package/dist/core/mounted-group.d.ts.map +1 -1
  34. package/dist/core/mounted-group.js +55 -18
  35. package/dist/core/mounted-group.js.map +1 -1
  36. package/dist/core/request-validation.d.ts.map +1 -1
  37. package/dist/core/request-validation.js +4 -1
  38. package/dist/core/request-validation.js.map +1 -1
  39. package/dist/core/response.d.ts.map +1 -1
  40. package/dist/core/response.js +4 -4
  41. package/dist/core/response.js.map +1 -1
  42. package/dist/core/route-table.d.ts +5 -3
  43. package/dist/core/route-table.d.ts.map +1 -1
  44. package/dist/core/route-table.js +12 -7
  45. package/dist/core/route-table.js.map +1 -1
  46. package/dist/core/shutdown.d.ts +5 -0
  47. package/dist/core/shutdown.d.ts.map +1 -1
  48. package/dist/core/shutdown.js.map +1 -1
  49. package/dist/decorators/http.d.ts.map +1 -1
  50. package/dist/decorators/http.js +2 -1
  51. package/dist/decorators/http.js.map +1 -1
  52. package/dist/decorators/log.d.ts.map +1 -1
  53. package/dist/decorators/log.js +1 -1
  54. package/dist/decorators/log.js.map +1 -1
  55. package/dist/decorators/middlewares.d.ts.map +1 -1
  56. package/dist/decorators/middlewares.js +1 -1
  57. package/dist/decorators/middlewares.js.map +1 -1
  58. package/dist/decorators/router.d.ts.map +1 -1
  59. package/dist/decorators/router.js +2 -1
  60. package/dist/decorators/router.js.map +1 -1
  61. package/dist/decorators/schema.d.ts.map +1 -1
  62. package/dist/decorators/schema.js +2 -1
  63. package/dist/decorators/schema.js.map +1 -1
  64. package/dist/middlewares/auth.d.ts +41 -0
  65. package/dist/middlewares/auth.d.ts.map +1 -0
  66. package/dist/middlewares/auth.js +74 -0
  67. package/dist/middlewares/auth.js.map +1 -0
  68. package/dist/middlewares/cors.d.ts.map +1 -1
  69. package/dist/middlewares/cors.js +5 -1
  70. package/dist/middlewares/cors.js.map +1 -1
  71. package/dist/middlewares/index.d.ts +1 -0
  72. package/dist/middlewares/index.d.ts.map +1 -1
  73. package/dist/middlewares/index.js +1 -0
  74. package/dist/middlewares/index.js.map +1 -1
  75. package/dist/openapi/generate.d.ts +5 -0
  76. package/dist/openapi/generate.d.ts.map +1 -1
  77. package/dist/openapi/generate.js +5 -10
  78. package/dist/openapi/generate.js.map +1 -1
  79. package/dist/schema/validation.d.ts.map +1 -1
  80. package/dist/schema/validation.js +2 -0
  81. package/dist/schema/validation.js.map +1 -1
  82. package/dist/utils/content-type.d.ts.map +1 -1
  83. package/dist/utils/content-type.js +5 -2
  84. package/dist/utils/content-type.js.map +1 -1
  85. package/dist/utils/cookie.d.ts.map +1 -1
  86. package/dist/utils/cookie.js +1 -0
  87. package/dist/utils/cookie.js.map +1 -1
  88. package/dist/utils/origin-match.d.ts +16 -0
  89. package/dist/utils/origin-match.d.ts.map +1 -1
  90. package/dist/utils/origin-match.js +24 -2
  91. package/dist/utils/origin-match.js.map +1 -1
  92. package/dist/utils/path.d.ts.map +1 -1
  93. package/dist/utils/path.js.map +1 -1
  94. package/dist/utils/request-target.d.ts.map +1 -1
  95. package/dist/utils/request-target.js +4 -1
  96. package/dist/utils/request-target.js.map +1 -1
  97. package/dist/utils/safe-json.d.ts +19 -0
  98. package/dist/utils/safe-json.d.ts.map +1 -0
  99. package/dist/utils/safe-json.js +52 -0
  100. package/dist/utils/safe-json.js.map +1 -0
  101. 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
- *(O TypeBox já vem incluído e re-exportado como `Type`, `Static` e `TSchema`)*
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('/') list() {}
81
- @Get('/:id') get(ctx: Context) { return { id: ctx.params.id }; }
82
- @Post('/') create() {}
83
- @Put('/:id') replace() {}
84
- @Patch('/:id') update() {}
85
- @Delete('/:id') remove(ctx: Context) { ctx.status(204); }
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) // como declarado
104
- .mount(userRoutes, { prefix: '/v2/users' }) // a mesma instância em outro prefixo
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 | Efeito |
110
- |---|---|
111
- | `prefix` | Substitui o prefixo do `@Router` |
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` | `false` registra só as [rotas manuais](#mapeamento-manual). Padrão: `true` |
114
- | `middlewares` | Valem para todas as rotas do grupo; rodam antes dos `@Middlewares` da classe |
115
- | `guards` | Valem para todas as rotas do grupo; rodam antes dos `@Guards` da classe |
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
 
@@ -138,13 +142,32 @@ app.group({ prefix: '/v2', middlewares: [authenticate], guards: [isAdmin] }, (g)
138
142
  - **O callback é síncrono.** Faça o trabalho assíncrono antes (`const db = await connect()`).
139
143
  - **Fora do escopo:** middlewares globais (`app.use()`) e hooks (`onError`, `onSend`…) continuam no app.
140
144
 
145
+ ### Virtual hosts
146
+
147
+ Domínios diferentes no mesmo app, cada um com as suas rotas:
148
+
149
+ ```ts
150
+ createApp({ allowedHosts: ['api.exemplo.com', 'admin.exemplo.com', '*.clientes.exemplo.com'] })
151
+ .group({ host: 'admin.exemplo.com', guards: [isAdmin] }, (g) => g.mount(AdminRoutes).mount(ReportsRoutes))
152
+ .mount(PublicApi, { host: 'api.exemplo.com' })
153
+ .mount(TenantRoutes, { host: '*.clientes.exemplo.com' }) // acme.clientes…, a.b.clientes…
154
+ .mount(HealthRoutes); // sem host: vale para todos
155
+ ```
156
+
157
+ - **Regras:** as mesmas do `allowedHosts`. Uma string casa com o hostname exato, sem porta e sem diferenciar maiúsculas. `'*.dominio'` vale para subdomínios de qualquer profundidade, nunca para o domínio em si. Uma RegExp precisa casar o hostname inteiro. Também é possível passar uma lista: `host: ['api.exemplo.com', 'api.exemplo.dev']`.
158
+ - **O host usado é o validado** (`ctx.hostname`). Com `trustProxy`, vem do `X-Forwarded-Host` confiável, nunca do header cru.
159
+ - **Prioridade:** host exato → curinga ou RegExp → rota sem host, que serve de fallback para qualquer domínio.
160
+ - **Isolamento:** as rotas de um host não respondem em outro. O 404 e o 405 (com o `Allow`) são calculados por host.
161
+ - **Onde declarar:** no `mount()` ou no `group()`, e não no `@Router`, porque o domínio é uma decisão de deploy. Declarar `host` num grupo e de novo dentro dele gera erro.
162
+ - **Alcançável:** se o app tem `allowedHosts`, um `host` de rota fora da lista falha no registro, porque essa rota nunca seria alcançada. `createApp({ unreachableHosts: 'warn' })` só avisa no log, e `'off'` não confere. Regras RegExp não são conferidas.
163
+ - **OpenAPI por host:** o `/openapi.json` documenta as rotas do host da requisição mais as rotas sem host, sem misturar domínios. Para gerar o arquivo, use `app.openapiDocument({ host: 'api.exemplo.com' })`; sem `host`, saem só as rotas sem host.
164
+ - **Com HTTPS:** o `tls.sni` escolhe o certificado no handshake, e o `host` escolhe as rotas na requisição. As duas configurações se complementam.
165
+
141
166
  **Pacotes reutilizáveis, sem sistema de plugins.** Uma função que recebe o app basta, sem API nova, registro de nomes nem ciclo de vida próprio:
142
167
 
143
168
  ```ts
144
169
  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());
170
+ return app.group({ prefix: '/audit', guards: [isAdmin] }, (g) => g.mount(new AuditRoutes(opts.store))).onClose(() => opts.store.flush());
148
171
  }
149
172
 
150
173
  audit(app, { store });
@@ -157,16 +180,16 @@ Depois do `mount`, os verbos `.get()`, `.post()`, `.put()`, `.patch()` e `.delet
157
180
  ```ts
158
181
  @Router('/users')
159
182
  class UserRoutes {
160
- @Get('/:id', byId) getUser(ctx: Context<typeof byId>) {}
161
- @Schema(csv) exportCsv(ctx: Context<typeof csv>) {} // sem verbo: só por mapeamento manual
162
- private findOr404() {} // sem decorator: nunca pode ser exposto
183
+ @Get('/:id', byId) getUser(ctx: Context<typeof byId>) {}
184
+ @Schema(csv) exportCsv(ctx: Context<typeof csv>) {} // sem verbo: só por mapeamento manual
185
+ private findOr404() {} // sem decorator: nunca pode ser exposto
163
186
  }
164
187
 
165
188
  app
166
189
  .mount(new UserRoutes(repo))
167
- .get('/legacy/:id', (r) => r.getUser) // GET /users/legacy/:id
190
+ .get('/legacy/:id', (r) => r.getUser) // GET /users/legacy/:id
168
191
  .get('/export.csv', (r) => r.exportCsv, { middlewares: [cache()], guards: [isAuditor] })
169
- .mount(HealthRoutes); // o encadeamento continua
192
+ .mount(HealthRoutes); // o encadeamento continua
170
193
  ```
171
194
 
172
195
  - **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 +230,13 @@ list(ctx: Context<typeof list>) {
207
230
  ```
208
231
 
209
232
  **Path e `params` conferidos**
233
+
210
234
  - **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
235
  - **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
236
  - **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
237
 
214
238
  **Validação**
239
+
215
240
  - **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
241
  - **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
242
  - **Defaults:** valores `default` são aplicados.
@@ -228,6 +253,7 @@ list(ctx: Context<typeof list>) {
228
253
  - **Campos extras:** use `additionalProperties: false` nos bodies para rejeitar campos não previstos. É a proteção contra mass assignment.
229
254
 
230
255
  **Resposta**
256
+
231
257
  - **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
258
  - **Status padrão:** se só existe um 2xx declarado (como `201`), ele é o status padrão; não é preciso chamar `ctx.status(201)`.
233
259
  - **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 +265,13 @@ list(ctx: Context<typeof list>) {
239
265
 
240
266
  O valor retornado pelo handler vira a resposta. Sem schema de resposta, o formato vem do tipo do valor:
241
267
 
242
- | Retorno | Resposta |
243
- |---|---|
244
- | objeto, array, número, booleano, `null` | `application/json` |
245
- | `string` | `text/plain; charset=utf-8`, sem aspas |
246
- | `Buffer` / `Uint8Array` | `application/octet-stream` |
247
- | `Readable` (stream) | enviado em stream |
248
- | `undefined` | sem corpo |
268
+ | Retorno | Resposta |
269
+ | --------------------------------------- | -------------------------------------- |
270
+ | objeto, array, número, booleano, `null` | `application/json` |
271
+ | `string` | `text/plain; charset=utf-8`, sem aspas |
272
+ | `Buffer` / `Uint8Array` | `application/octet-stream` |
273
+ | `Readable` (stream) | enviado em stream |
274
+ | `undefined` | sem corpo |
249
275
 
250
276
  Um `Content-Type` definido no handler (`ctx.header('Content-Type', ...)`) sempre prevalece.
251
277
 
@@ -295,6 +321,7 @@ run(ctx: Context<typeof job>) {
295
321
  **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
322
 
297
323
  Estilo recomendado:
324
+
298
325
  - `return` para o caso comum;
299
326
  - `ctx.status(x).json(body)` quando o status é decidido em runtime;
300
327
  - `throw` para erros.
@@ -303,23 +330,23 @@ Os schemas de resposta valem para o que o **handler** envia. Middlewares e guard
303
330
 
304
331
  ### Helpers do `ctx`
305
332
 
306
- | Método | Uso |
307
- |---|---|
308
- | `ctx.status(code)` | Define o status |
309
- | `ctx.header(name, value)` | Define um header da resposta |
310
- | `ctx.get(name)` | Lê um header da requisição |
311
- | `ctx.json(data)` | Envia JSON pelo pipeline (schema, filtro, status padrão) |
312
- | `ctx.text(str)` | `text/plain; charset=utf-8`, pelo pipeline |
313
- | `ctx.send(body, contentType?)` | Qualquer corpo pelo pipeline: string, bytes, stream ou objeto (negociado pelo `Accept`) |
314
- | `ctx.redirect(url, status?)` | 302 por padrão; aceita 301, 303, 307, 308 |
315
- | `ctx.cookies` | Cookies da requisição (objeto sem protótipo) |
333
+ | Método | Uso |
334
+ | ----------------------------------- | ---------------------------------------------------------------------------------------- |
335
+ | `ctx.status(code)` | Define o status |
336
+ | `ctx.header(name, value)` | Define um header da resposta |
337
+ | `ctx.get(name)` | Lê um header da requisição |
338
+ | `ctx.json(data)` | Envia JSON pelo pipeline (schema, filtro, status padrão) |
339
+ | `ctx.text(str)` | `text/plain; charset=utf-8`, pelo pipeline |
340
+ | `ctx.send(body, contentType?)` | Qualquer corpo pelo pipeline: string, bytes, stream ou objeto (negociado pelo `Accept`) |
341
+ | `ctx.redirect(url, status?)` | 302 por padrão; aceita 301, 303, 307, 308 |
342
+ | `ctx.cookies` | Cookies da requisição (objeto sem protótipo) |
316
343
  | `ctx.setCookie(name, value, opts?)` | Adiciona um `Set-Cookie`, com `HttpOnly`, `Secure`, `SameSite=Lax` e `Path=/` por padrão |
317
- | `ctx.clearCookie(name, opts?)` | Remove um cookie (use o mesmo `path` e `domain` da criação) |
318
- | `ctx.throw(status, message)` | Lança um `HttpError` |
344
+ | `ctx.clearCookie(name, opts?)` | Remove um cookie (use o mesmo `path` e `domain` da criação) |
345
+ | `ctx.throw(status, message)` | Lança um `HttpError` |
319
346
 
320
347
  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
348
 
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 *open redirect*; valide o destino.
349
+ > ⚠️ `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
350
 
324
351
  ## Middlewares e guards
325
352
 
@@ -337,7 +364,9 @@ Por classe ou por método, use `@Middlewares` e `@Guards`; para um grupo inteiro
337
364
 
338
365
  ```ts
339
366
  declare module 'sashiko' {
340
- interface ContextState { user?: { id: string; role: 'admin' | 'user' } } // ctx.state tipado
367
+ interface ContextState {
368
+ user?: { id: string; role: 'admin' | 'user' };
369
+ } // ctx.state tipado
341
370
  }
342
371
 
343
372
  const authenticate: Middleware = async (ctx, next) => {
@@ -373,9 +402,13 @@ Para o que acontece **antes** do handler (autenticação, rate limit, preparar o
373
402
 
374
403
  ```ts
375
404
  app
376
- .onSend((ctx, payload) => { ctx.header('ETag', etag(payload.body)); }) // antes de escrever (síncrono)
405
+ .onSend((ctx, payload) => {
406
+ ctx.header('ETag', etag(payload.body));
407
+ }) // antes de escrever (síncrono)
377
408
  .onResponse((ctx, { status, ms }) => metrics.observe(ctx.path, status, ms)) // depois do envio
378
- .onClose(async () => { await db.close(); }); // em qualquer close()
409
+ .onClose(async () => {
410
+ await db.close();
411
+ }); // em qualquer close()
379
412
  ```
380
413
 
381
414
  - **`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 +418,25 @@ app
385
418
  **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
419
 
387
420
  ```ts
388
- const cache = (ttlMs: number): Middleware => async (ctx, next) => {
389
- const hit = store.get(ctx.url.href);
390
- if (hit) return void ctx.header('X-Cache', 'HIT').send(hit.body, hit.type);
391
- ctx.header('X-Cache', 'MISS'); // headers antes do next(): depois dele a resposta já foi enviada
392
- await next();
393
- if (ctx.res.statusCode === 200 && ctx.sentBody) store.set(ctx.url.href, { body: ctx.sentBody, type: ctx.sentType! });
394
- };
421
+ const cache =
422
+ (ttlMs: number): Middleware =>
423
+ async (ctx, next) => {
424
+ const hit = store.get(ctx.url.href);
425
+ if (hit) return void ctx.header('X-Cache', 'HIT').send(hit.body, hit.type);
426
+ ctx.header('X-Cache', 'MISS'); // headers antes do next(): depois dele a resposta já foi enviada
427
+ await next();
428
+ if (ctx.res.statusCode === 200 && ctx.sentBody) store.set(ctx.url.href, { body: ctx.sentBody, type: ctx.sentType! });
429
+ };
395
430
  app.mount(products, { middlewares: [cache(60_000)] });
396
431
  ```
397
432
 
398
- | Fastify | Sashiko |
399
- |---|---|
433
+ | Fastify | Sashiko |
434
+ | ------------------------- | ----------------------------------------------------------------- |
400
435
  | `onRequest`, `preHandler` | middlewares (`app.use`, `mount({ middlewares })`, `@Middlewares`) |
401
- | `onError` | `app.onError` |
402
- | `onSend` | `app.onSend` (síncrono) |
403
- | `onResponse` | `app.onResponse` |
404
- | `onClose` | `app.onClose` |
436
+ | `onError` | `app.onError` |
437
+ | `onSend` | `app.onSend` (síncrono) |
438
+ | `onResponse` | `app.onResponse` |
439
+ | `onClose` | `app.onClose` |
405
440
 
406
441
  ## Erros
407
442
 
@@ -420,14 +455,14 @@ Todos os erros, inclusive 404, 405, validação e `Host` inválido, saem no form
420
455
  { "type": "about:blank", "title": "Not Found", "status": 404, "detail": "user not found", "code": "NOT_FOUND" }
421
456
  ```
422
457
 
423
- | Campo | Conteúdo |
424
- |---|---|
425
- | `type` | URI do tipo do problema (opção `type` do `HttpError`); `about:blank` por padrão |
426
- | `title` | Frase do status (`Not Found`) |
427
- | `status` | Status HTTP |
428
- | `detail` | A mensagem do erro; omitido quando igual ao título ou em 5xx não expostos |
429
- | `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`… |
430
- | `details` | Dados extras: os erros de validação, `allowed` no 405, `available` no 406 |
458
+ | Campo | Conteúdo |
459
+ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
460
+ | `type` | URI do tipo do problema (opção `type` do `HttpError`); `about:blank` por padrão |
461
+ | `title` | Frase do status (`Not Found`) |
462
+ | `status` | Status HTTP |
463
+ | `detail` | A mensagem do erro; omitido quando igual ao título ou em 5xx não expostos |
464
+ | `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`… |
465
+ | `details` | Dados extras: os erros de validação, `allowed` no 405, `available` no 406 |
431
466
 
432
467
  - **Erros 4xx:** a mensagem vai para o cliente.
433
468
  - **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 +477,8 @@ createApp()
442
477
  .openapi({
443
478
  info: { title: 'Minha API', version: '1.0.0' },
444
479
  servers: [{ url: 'https://api.exemplo.dev' }],
445
- docs: true, // Swagger UI em /docs (desligado por padrão)
446
- guards: [isAdmin], // só para /openapi.json e /docs
480
+ docs: true, // Swagger UI em /docs (desligado por padrão)
481
+ guards: [isAdmin], // só para /openapi.json e /docs
447
482
  });
448
483
  ```
449
484
 
@@ -455,14 +490,14 @@ createApp()
455
490
  - **`app.openapiDocument()`:** devolve o objeto, para gravar em arquivo, gerar clientes ou testar no CI.
456
491
  - **O que entra no documento:**
457
492
 
458
- | Origem | Vira |
459
- |---|---|
460
- | Path (`/:id(^\\d+)`) | `/{id}` |
461
- | `params`, `query`, `headers` | `parameters`, com `required` |
462
- | `body` | `requestBody` em JSON |
463
- | `response` | uma resposta por status, com os `contentType` declarados |
464
- | Erros | resposta `default` com o schema `ProblemDetails`; com `formatError`, só a descrição |
465
- | Classe e método | `tags` e `operationId` (`UserRoutes.getUser`) |
493
+ | Origem | Vira |
494
+ | ---------------------------- | ----------------------------------------------------------------------------------- |
495
+ | Path (`/:id(^\\d+)`) | `/{id}` |
496
+ | `params`, `query`, `headers` | `parameters`, com `required` |
497
+ | `body` | `requestBody` em JSON |
498
+ | `response` | uma resposta por status, com os `contentType` declarados |
499
+ | Erros | resposta `default` com o schema `ProblemDetails`; com `formatError`, só a descrição |
500
+ | Classe e método | `tags` e `operationId` (`UserRoutes.getUser`) |
466
501
 
467
502
  - **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
503
 
@@ -470,15 +505,17 @@ createApp()
470
505
 
471
506
  Ligado por padrão:
472
507
 
473
- | Proteção | Detalhe |
474
- |---|---|
508
+ | Proteção | Detalhe |
509
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
475
510
  | 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 | 1 MiB (`bodyLimit`). Recusado já pelo `Content-Length`, com a conexão fechada. |
477
- | Timeouts | `requestTimeout` 30s, `headersTimeout` 10s, `keepAliveTimeout` 5s (contra Slowloris). |
478
- | Query | Até 1000 pares (`queryMaxKeys`); `__proto__`, `constructor` e `prototype` são descartados. |
479
- | Erros 5xx | Sem detalhes para o cliente. |
480
- | Request id | Ids recebidos só são aceitos se forem seguros (contra log injection). |
481
- | 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. |
511
+ | Limite de body | 1 MiB (`bodyLimit`). Recusado já pelo `Content-Length`, com a conexão fechada. |
512
+ | Timeouts | `requestTimeout` 30s, `headersTimeout` 10s, `keepAliveTimeout` 5s (contra Slowloris). |
513
+ | Query | Até 1000 pares (`queryMaxKeys`); `__proto__`, `constructor` e `prototype` são descartados. |
514
+ | 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. |
515
+ | Logs | `authorization`, `cookie`, `password`, `token`, `apiKey` e afins saem como `[REDACTED]` no logger padrão. |
516
+ | Erros 5xx | Sem detalhes para o cliente. |
517
+ | Request id | Ids recebidos só são aceitos se forem seguros (contra log injection). |
518
+ | 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
519
 
483
520
  Opt-in:
484
521
 
@@ -490,22 +527,31 @@ createApp({
490
527
  securityHeaders: { strictTransportSecurity: true }, // só atrás de HTTPS
491
528
  })
492
529
  .use(cors({ origin: ['https://app.exemplo.com', 'https://*.exemplo.dev'], credentials: true }))
493
- .use(rateLimit({ max: 100, windowMs: 60_000 })); // 429 + Retry-After
530
+ .use(rateLimit({ max: 100, windowMs: 60_000 })); // 429 + Retry-After
494
531
  ```
495
532
 
496
533
  - **`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
534
  - **Regras de origem e de host, seguras por padrão** (as mesmas para `cors()` e `allowedHosts`):
498
535
 
499
- | Regra | Casa com | Não casa com |
500
- |---|---|---|
536
+ | Regra | Casa com | Não casa com |
537
+ | --------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------- |
501
538
  | `'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'` | qualquer subdomínio | `exemplo.dev` em si, `exemplo.dev.atacante.com` |
503
- | RegExp | a string **inteira**, como se tivesse `^…$` | qualquer casamento parcial |
504
- | função | o que ela decidir, sob sua responsabilidade | — |
505
-
539
+ | `'https://*.exemplo.dev'` | qualquer subdomínio | `exemplo.dev` em si, `exemplo.dev.atacante.com` |
540
+ | RegExp | a string **inteira**, como se tivesse `^…$` | qualquer casamento parcial |
541
+ | função | o que ela decidir, sob sua responsabilidade | — |
506
542
  - **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
543
  - **Prefira o curinga à RegExp:** uma regex mal escrita pode ser lenta com uma entrada maliciosa (ReDoS).
508
544
  - **Regras inválidas falham na inicialização,** por exemplo uma origem sem esquema (`'app.exemplo.com'`).
545
+ - **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`.
546
+
547
+ ```ts
548
+ const auth = bearerAuth({ verify: async (token) => (await sessions.find(token)) ?? false });
549
+ app.group({ guards: [auth] }, (g) => g.mount(UserRoutes));
550
+
551
+ const painel = basicAuth({ verify: (user, pass) => safeEqual(user, 'admin') && safeEqual(pass, process.env.ADMIN_PASS!) });
552
+ ```
553
+
554
+ 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
555
  - **`rateLimit()`:** guarda os contadores em memória por padrão. Para várias instâncias, implemente `RateLimitStore` (Redis etc.).
510
556
  - **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
557
  - **`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 +561,7 @@ createApp({
515
561
  app.use(csrf({ trustedOrigins: ['https://app.exemplo.com'] })); // front em outra origem
516
562
  app.use(csrf({ token: true })); // + double-submit token
517
563
  ```
564
+
518
565
  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
566
  - **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
567
  - **`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 +575,17 @@ import pino from 'pino';
528
575
  createApp({ logger: pino(), logRequests: true });
529
576
  ```
530
577
 
531
- - **Logger:** compatível com o pino (`info(obj, msg)`, `child()`). O padrão, `consoleLogger({ level })`, usa o `console`. `logger: false` desliga tudo.
578
+ - **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
579
  - **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
580
  - **`ctx.log`:** já inclui o `reqId`: `ctx.log.info({ userId }, 'user created')`.
534
581
  - **`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.
582
+ - **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 `_`.
583
+
584
+ ```ts
585
+ createApp({ logger: consoleLogger({ redact: ['cpf', 'cardNumber'] }) }); // soma às chaves padrão; false desliga
586
+ ```
587
+
588
+ Com o pino, configure o [`redact`](https://getpino.io/#/docs/redaction) dele.
536
589
 
537
590
  ## Ciclo de vida e testes
538
591
 
@@ -577,6 +630,7 @@ await app.listen({
577
630
  },
578
631
  }
579
632
  ```
633
+
580
634
  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`.
581
635
  - **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:
582
636
 
@@ -591,7 +645,6 @@ await app.listen({
591
645
  ### Planos futuros
592
646
 
593
647
  - **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.
594
- - **Virtual hosts:** `mount(X, { host })` e `group({ host })`, para servir domínios diferentes no mesmo app.
595
648
  - **mTLS:** certificado do cliente (`requestCert`) e `ctx.clientCertificate`.
596
649
 
597
650
  **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:
@@ -599,8 +652,10 @@ await app.listen({
599
652
  ```ts
600
653
  createApp({
601
654
  shutdown: {
602
- timeoutMs: 10_000, // prazo para as requisições em andamento (padrão)
603
- onShutdown: async () => { await db.close(); }, // depois que o servidor fecha
655
+ timeoutMs: 10_000, // prazo para as requisições em andamento (padrão)
656
+ onShutdown: async () => {
657
+ await db.close();
658
+ }, // depois que o servidor fecha
604
659
  },
605
660
  });
606
661
  ```
@@ -609,6 +664,28 @@ createApp({
609
664
  - **Vários apps no mesmo processo:** compartilham um único listener por sinal e fecham todos juntos.
610
665
  - **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.
611
666
  - **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).
667
+ - **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.
668
+
669
+ ### Healthchecks
670
+
671
+ ```ts
672
+ app.health({
673
+ checks: {
674
+ db: () => db.query('select 1'), // lançar ou devolver false = falha
675
+ cache: () => redis.ping(),
676
+ },
677
+ timeoutMs: 2000, // por check (padrão)
678
+ });
679
+ ```
680
+
681
+ | Rota | Responde | Uso |
682
+ | ------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------- |
683
+ | `GET /livez` | 200 `{ "status": "ok" }` enquanto o processo estiver de pé | liveness: reiniciar o processo travado |
684
+ | `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 |
685
+
686
+ - **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.
687
+ - **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()`.
688
+ - **Opções:** `live: '/health'` muda o path, e `ready: false` desliga a rota.
612
689
 
613
690
  **Testes sem rede.** `app.inject()` executa a requisição em memória:
614
691
 
@@ -618,25 +695,60 @@ assert.equal(res.status, 201);
618
695
  assert.equal(res.json<{ name: string }>().name, 'Ana');
619
696
  ```
620
697
 
698
+ ## Deploy em produção
699
+
700
+ ```ts
701
+ const app = createApp({
702
+ logger: consoleLogger({ format: 'json' }), // uma linha JSON por evento, com redact
703
+ trustProxy: 1, // atrás de 1 proxy/load balancer
704
+ allowedHosts: ['api.exemplo.com'],
705
+ securityHeaders: { strictTransportSecurity: true }, // HSTS: só com HTTPS na frente
706
+ shutdown: { drainDelayMs: 5_000, timeoutMs: 10_000 },
707
+ })
708
+ .use(rateLimit({ max: 100, windowMs: 60_000 }))
709
+ .mount(UserRoutes)
710
+ .health({ checks: { db: () => db.ping() } });
711
+
712
+ await app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' });
713
+ ```
714
+
715
+ Checklist:
716
+
717
+ | Item | Como |
718
+ | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
719
+ | Escutar fora do container | `host: '0.0.0.0'` (o padrão é `127.0.0.1`), porta por `PORT` |
720
+ | TLS | No proxy ou load balancer (recomendado), ou `listen({ tls })` |
721
+ | Proxy à frente | `trustProxy` com o número **exato** de proxies; nunca `true` se o app for acessível direto |
722
+ | Domínios | `allowedHosts` com os domínios servidos |
723
+ | HSTS | `securityHeaders: { strictTransportSecurity: true }`, só quando todo acesso for HTTPS |
724
+ | Logs | `consoleLogger({ format: 'json' })` ou pino; campos sensíveis já mascarados |
725
+ | Healthchecks | `/livez` (liveness) e `/readyz` (readiness) com `app.health()` |
726
+ | Encerramento | SIGTERM já tratado; `drainDelayMs` maior que o intervalo da sonda de readiness |
727
+ | Rate limit com várias instâncias | `RateLimitStore` compartilhado (Redis), porque o padrão é em memória por processo |
728
+ | Autenticação por cookie | `csrf()` ativo |
729
+ | Documentação da API | `openapi({ guards })`, ou gerar só o arquivo com `openapiDocument()` |
730
+ | 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 |
731
+
621
732
  ## Referência: `createApp(options)`
622
733
 
623
- | Opção | Padrão | Descrição |
624
- |---|---|---|
625
- | `bodyLimit` | `1048576` | Tamanho máximo do body JSON, em bytes |
626
- | `queryMaxKeys` | `1000` | Máximo de pares lidos da query string |
627
- | `requireSchemas` | `false` | Exige schemas de body e de resposta no registro das rotas |
628
- | `securityHeaders` | `'api'` | `'api'`, `'web'`, um objeto de opções, ou `false` para desligar |
629
- | `serializers` | — | Serializadores por content type, para responder em outros formatos |
630
- | `shutdown` | SIGINT/SIGTERM, 10s | `signals`, `timeoutMs`, `onShutdown`, `exit`; `false` desliga |
631
- | `trustProxy` | `false` | `true` ou o número de proxies: `ctx.ip`/`host`/`protocol` passam a usar os `X-Forwarded-*` |
632
- | `allowedHosts` | qualquer host válido | Lista de hosts (string ou RegExp); os demais recebem 400 |
633
- | `formatError` | Problem Details | `(problem, ctx) => corpo`, para personalizar o corpo de todos os erros |
634
- | `timeouts` | 30s / 10s / 5s | `requestTimeout`, `headersTimeout`, `keepAliveTimeout` |
635
- | `logger` | `consoleLogger()` | Logger compatível com pino; `false` desliga |
636
- | `logRequests` | `false` | Log de cada requisição concluída |
637
- | `requestId` | header `x-request-id` | `header`, `trustIncoming`, `generate` |
638
-
639
- Métodos do app: `use()`, `mount()`, `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.
734
+ | Opção | Padrão | Descrição |
735
+ | ----------------- | --------------------- | ------------------------------------------------------------------------------------------ |
736
+ | `bodyLimit` | `1048576` | Tamanho máximo do body JSON, em bytes |
737
+ | `queryMaxKeys` | `1000` | Máximo de pares lidos da query string |
738
+ | `requireSchemas` | `false` | Exige schemas de body e de resposta no registro das rotas |
739
+ | `securityHeaders` | `'api'` | `'api'`, `'web'`, um objeto de opções, ou `false` para desligar |
740
+ | `serializers` | — | Serializadores por content type, para responder em outros formatos |
741
+ | `shutdown` | SIGINT/SIGTERM, 10s | `signals`, `timeoutMs`, `drainDelayMs`, `onShutdown`, `exit`; `false` desliga |
742
+ | `protoPoisoning` | `'error'` | Body com `__proto__`/`constructor.prototype`: `'error'` (400) ou `'remove'` |
743
+ | `trustProxy` | `false` | `true` ou o número de proxies: `ctx.ip`/`host`/`protocol` passam a usar os `X-Forwarded-*` |
744
+ | `allowedHosts` | qualquer host válido | Lista de hosts (string ou RegExp); os demais recebem 400 |
745
+ | `formatError` | Problem Details | `(problem, ctx) => corpo`, para personalizar o corpo de todos os erros |
746
+ | `timeouts` | 30s / 10s / 5s | `requestTimeout`, `headersTimeout`, `keepAliveTimeout` |
747
+ | `logger` | `consoleLogger()` | Logger compatível com pino; `false` desliga |
748
+ | `logRequests` | `false` | Log de cada requisição concluída |
749
+ | `requestId` | header `x-request-id` | `header`, `trustIncoming`, `generate` |
750
+
751
+ 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.
640
752
 
641
753
  O body só é lido como JSON quando o `Content-Type` é `application/json` ou `application/*+json`.
642
754
 
@@ -669,10 +781,13 @@ pnpm install
669
781
  pnpm test # node:test + tsx
670
782
  pnpm test:coverage # com limites mínimos de cobertura
671
783
  pnpm typecheck
784
+ pnpm check # lint + formatação (Biome); pnpm format corrige a formatação
672
785
  pnpm build # dist/
673
786
  ```
674
787
 
675
- Mudanças notáveis estão no [CHANGELOG](CHANGELOG.md).
788
+ Para o `git blame` ignorar o commit de formatação: `git config blame.ignoreRevsFile .git-blame-ignore-revs`.
789
+
790
+ 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).
676
791
 
677
792
  ## Licença
678
793