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.
- package/README.md +233 -118
- package/dist/core/app.d.ts +36 -1
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +92 -20
- 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 +12 -0
- package/dist/core/compiler.d.ts.map +1 -1
- package/dist/core/compiler.js +33 -7
- 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 -7
- 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 +2 -0
- package/dist/core/mounted-group.d.ts.map +1 -1
- package/dist/core/mounted-group.js +55 -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 +5 -3
- package/dist/core/route-table.d.ts.map +1 -1
- package/dist/core/route-table.js +12 -7
- 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 +5 -0
- 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/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 +16 -0
- package/dist/utils/origin-match.d.ts.map +1 -1
- package/dist/utils/origin-match.js +24 -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
|
|
|
@@ -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)
|
|
161
|
-
@Schema(csv)
|
|
162
|
-
private findOr404() {}
|
|
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)
|
|
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);
|
|
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
|
|
243
|
-
|
|
244
|
-
| objeto, array, número, booleano, `null` | `application/json`
|
|
245
|
-
| `string`
|
|
246
|
-
| `Buffer` / `Uint8Array`
|
|
247
|
-
| `Readable` (stream)
|
|
248
|
-
| `undefined`
|
|
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
|
|
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`
|
|
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?)`
|
|
318
|
-
| `ctx.throw(status, message)`
|
|
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
|
|
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 {
|
|
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) => {
|
|
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 () => {
|
|
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 =
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
399
|
-
|
|
433
|
+
| Fastify | Sashiko |
|
|
434
|
+
| ------------------------- | ----------------------------------------------------------------- |
|
|
400
435
|
| `onRequest`, `preHandler` | middlewares (`app.use`, `mount({ middlewares })`, `@Middlewares`) |
|
|
401
|
-
| `onError`
|
|
402
|
-
| `onSend`
|
|
403
|
-
| `onResponse`
|
|
404
|
-
| `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
|
|
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
|
|
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,
|
|
446
|
-
guards: [isAdmin],
|
|
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
|
|
459
|
-
|
|
460
|
-
| Path (`/:id(^\\d+)`)
|
|
461
|
-
| `params`, `query`, `headers` | `parameters`, com `required`
|
|
462
|
-
| `body`
|
|
463
|
-
| `response`
|
|
464
|
-
| Erros
|
|
465
|
-
| Classe e método
|
|
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
|
|
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
|
|
477
|
-
| Timeouts
|
|
478
|
-
| Query
|
|
479
|
-
|
|
|
480
|
-
|
|
|
481
|
-
|
|
|
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 }));
|
|
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
|
|
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'`
|
|
503
|
-
| RegExp
|
|
504
|
-
| função
|
|
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
|
|
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,
|
|
603
|
-
onShutdown: async () => {
|
|
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
|
|
624
|
-
|
|
625
|
-
| `bodyLimit`
|
|
626
|
-
| `queryMaxKeys`
|
|
627
|
-
| `requireSchemas`
|
|
628
|
-
| `securityHeaders` | `'api'`
|
|
629
|
-
| `serializers`
|
|
630
|
-
| `shutdown`
|
|
631
|
-
| `
|
|
632
|
-
| `
|
|
633
|
-
| `
|
|
634
|
-
| `
|
|
635
|
-
| `
|
|
636
|
-
| `
|
|
637
|
-
| `
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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
|
|