sashiko 0.0.0-stage → 0.2.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 (139) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +398 -2
  3. package/dist/core/app.d.ts +104 -0
  4. package/dist/core/app.d.ts.map +1 -0
  5. package/dist/core/app.js +202 -0
  6. package/dist/core/app.js.map +1 -0
  7. package/dist/core/body-parser.d.ts +4 -0
  8. package/dist/core/body-parser.d.ts.map +1 -0
  9. package/dist/core/body-parser.js +38 -0
  10. package/dist/core/body-parser.js.map +1 -0
  11. package/dist/core/compiler.d.ts +52 -0
  12. package/dist/core/compiler.d.ts.map +1 -0
  13. package/dist/core/compiler.js +74 -0
  14. package/dist/core/compiler.js.map +1 -0
  15. package/dist/core/compose.d.ts +4 -0
  16. package/dist/core/compose.d.ts.map +1 -0
  17. package/dist/core/compose.js +11 -0
  18. package/dist/core/compose.js.map +1 -0
  19. package/dist/core/context.d.ts +84 -0
  20. package/dist/core/context.d.ts.map +1 -0
  21. package/dist/core/context.js +121 -0
  22. package/dist/core/context.js.map +1 -0
  23. package/dist/core/errors.d.ts +24 -0
  24. package/dist/core/errors.d.ts.map +1 -0
  25. package/dist/core/errors.js +23 -0
  26. package/dist/core/errors.js.map +1 -0
  27. package/dist/core/guard.d.ts +10 -0
  28. package/dist/core/guard.d.ts.map +1 -0
  29. package/dist/core/guard.js +12 -0
  30. package/dist/core/guard.js.map +1 -0
  31. package/dist/core/index.d.ts +15 -0
  32. package/dist/core/index.d.ts.map +1 -0
  33. package/dist/core/index.js +8 -0
  34. package/dist/core/index.js.map +1 -0
  35. package/dist/core/infer.d.ts +36 -0
  36. package/dist/core/infer.d.ts.map +1 -0
  37. package/dist/core/infer.js +2 -0
  38. package/dist/core/infer.js.map +1 -0
  39. package/dist/core/inject.d.ts +27 -0
  40. package/dist/core/inject.d.ts.map +1 -0
  41. package/dist/core/inject.js +72 -0
  42. package/dist/core/inject.js.map +1 -0
  43. package/dist/core/logger.d.ts +28 -0
  44. package/dist/core/logger.d.ts.map +1 -0
  45. package/dist/core/logger.js +27 -0
  46. package/dist/core/logger.js.map +1 -0
  47. package/dist/core/metadata.d.ts +59 -0
  48. package/dist/core/metadata.d.ts.map +1 -0
  49. package/dist/core/metadata.js +83 -0
  50. package/dist/core/metadata.js.map +1 -0
  51. package/dist/core/mounted-group.d.ts +41 -0
  52. package/dist/core/mounted-group.d.ts.map +1 -0
  53. package/dist/core/mounted-group.js +43 -0
  54. package/dist/core/mounted-group.js.map +1 -0
  55. package/dist/core/request-validation.d.ts +11 -0
  56. package/dist/core/request-validation.d.ts.map +1 -0
  57. package/dist/core/request-validation.js +30 -0
  58. package/dist/core/request-validation.js.map +1 -0
  59. package/dist/core/response.d.ts +16 -0
  60. package/dist/core/response.d.ts.map +1 -0
  61. package/dist/core/response.js +49 -0
  62. package/dist/core/response.js.map +1 -0
  63. package/dist/core/route-table.d.ts +29 -0
  64. package/dist/core/route-table.d.ts.map +1 -0
  65. package/dist/core/route-table.js +41 -0
  66. package/dist/core/route-table.js.map +1 -0
  67. package/dist/decorators/http.d.ts +16 -0
  68. package/dist/decorators/http.d.ts.map +1 -0
  69. package/dist/decorators/http.js +27 -0
  70. package/dist/decorators/http.js.map +1 -0
  71. package/dist/decorators/index.d.ts +6 -0
  72. package/dist/decorators/index.d.ts.map +1 -0
  73. package/dist/decorators/index.js +6 -0
  74. package/dist/decorators/index.js.map +1 -0
  75. package/dist/decorators/log.d.ts +10 -0
  76. package/dist/decorators/log.d.ts.map +1 -0
  77. package/dist/decorators/log.js +32 -0
  78. package/dist/decorators/log.js.map +1 -0
  79. package/dist/decorators/middlewares.d.ts +28 -0
  80. package/dist/decorators/middlewares.d.ts.map +1 -0
  81. package/dist/decorators/middlewares.js +40 -0
  82. package/dist/decorators/middlewares.js.map +1 -0
  83. package/dist/decorators/router.d.ts +12 -0
  84. package/dist/decorators/router.d.ts.map +1 -0
  85. package/dist/decorators/router.js +19 -0
  86. package/dist/decorators/router.js.map +1 -0
  87. package/dist/decorators/schema.d.ts +8 -0
  88. package/dist/decorators/schema.d.ts.map +1 -0
  89. package/dist/decorators/schema.js +13 -0
  90. package/dist/decorators/schema.js.map +1 -0
  91. package/dist/index.d.ts +5 -0
  92. package/dist/index.d.ts.map +1 -0
  93. package/dist/index.js +7 -0
  94. package/dist/index.js.map +1 -0
  95. package/dist/middlewares/cors.d.ts +24 -0
  96. package/dist/middlewares/cors.d.ts.map +1 -0
  97. package/dist/middlewares/cors.js +63 -0
  98. package/dist/middlewares/cors.js.map +1 -0
  99. package/dist/middlewares/index.d.ts +4 -0
  100. package/dist/middlewares/index.d.ts.map +1 -0
  101. package/dist/middlewares/index.js +4 -0
  102. package/dist/middlewares/index.js.map +1 -0
  103. package/dist/middlewares/rate-limit.d.ts +35 -0
  104. package/dist/middlewares/rate-limit.d.ts.map +1 -0
  105. package/dist/middlewares/rate-limit.js +45 -0
  106. package/dist/middlewares/rate-limit.js.map +1 -0
  107. package/dist/middlewares/security-headers.d.ts +26 -0
  108. package/dist/middlewares/security-headers.d.ts.map +1 -0
  109. package/dist/middlewares/security-headers.js +34 -0
  110. package/dist/middlewares/security-headers.js.map +1 -0
  111. package/dist/schema/serialization.d.ts +2 -0
  112. package/dist/schema/serialization.d.ts.map +1 -0
  113. package/dist/schema/serialization.js +11 -0
  114. package/dist/schema/serialization.js.map +1 -0
  115. package/dist/schema/validation.d.ts +11 -0
  116. package/dist/schema/validation.d.ts.map +1 -0
  117. package/dist/schema/validation.js +48 -0
  118. package/dist/schema/validation.js.map +1 -0
  119. package/dist/shims/symbol-metadata.d.ts +2 -0
  120. package/dist/shims/symbol-metadata.d.ts.map +1 -0
  121. package/dist/shims/symbol-metadata.js +8 -0
  122. package/dist/shims/symbol-metadata.js.map +1 -0
  123. package/dist/utils/content-type.d.ts +5 -0
  124. package/dist/utils/content-type.d.ts.map +1 -0
  125. package/dist/utils/content-type.js +10 -0
  126. package/dist/utils/content-type.js.map +1 -0
  127. package/dist/utils/index.d.ts +4 -0
  128. package/dist/utils/index.d.ts.map +1 -0
  129. package/dist/utils/index.js +4 -0
  130. package/dist/utils/index.js.map +1 -0
  131. package/dist/utils/path.d.ts +3 -0
  132. package/dist/utils/path.d.ts.map +1 -0
  133. package/dist/utils/path.js +7 -0
  134. package/dist/utils/path.js.map +1 -0
  135. package/dist/utils/query.d.ts +13 -0
  136. package/dist/utils/query.d.ts.map +1 -0
  137. package/dist/utils/query.js +30 -0
  138. package/dist/utils/query.js.map +1 -0
  139. package/package.json +74 -3
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Caio Fernandes
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,399 @@
1
- # Temporary Holding Version
1
+ # Sashiko
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Micro-framework HTTP para Node.js com **decorators nativos (TC39)**, **schema-first** e **seguro por padrão**.
4
+
5
+ > **Sashiko** (刺し子) é uma técnica japonesa de bordado em que os pontos decorativos também **reforçam** o tecido. É a ideia do framework: os decorators organizam a API e, ao mesmo tempo, trazem validação e proteções seguras por padrão.
6
+
7
+ Sashiko combina a flexibilidade do **Fastify** e do **Express** (middlewares simples, partes plugáveis, pouca cerimônia) com a organização em classes do **NestJS**, mantendo o núcleo pequeno: `node:http` direto e só quatro dependências de runtime.
8
+
9
+ ```ts
10
+ import { Type } from '@sinclair/typebox';
11
+ import { type Context, createApp, Post, Router, type RouteSchemas } from 'sashiko';
12
+
13
+ const User = Type.Object({ id: Type.String(), name: Type.String() });
14
+ const create = {
15
+ body: Type.Object({ name: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
16
+ response: { 201: User },
17
+ } satisfies RouteSchemas;
18
+
19
+ @Router('/users')
20
+ class UserRoutes {
21
+ @Post('/', create)
22
+ create(ctx: Context<typeof create>) {
23
+ return { id: crypto.randomUUID(), name: ctx.body.name }; // ctx.body tipado; responde 201
24
+ }
25
+ }
26
+
27
+ const app = createApp().mount(UserRoutes);
28
+ await app.listen({ port: 3000 });
29
+ ```
30
+
31
+ ## Sumário
32
+
33
+ - [Requisitos e instalação](#requisitos-e-instalação)
34
+ - [Rotas](#rotas)
35
+ - [Registro e composição: `app.mount()`](#registro-e-composição-appmount)
36
+ - [Mapeamento manual](#mapeamento-manual)
37
+ - [Schemas: validação, serialização e tipos](#schemas-validação-serialização-e-tipos)
38
+ - [Respostas e o `ctx`](#respostas-e-o-ctx)
39
+ - [Middlewares e guards](#middlewares-e-guards)
40
+ - [Erros](#erros)
41
+ - [Segurança](#segurança)
42
+ - [Logs e request id](#logs-e-request-id)
43
+ - [Ciclo de vida e testes](#ciclo-de-vida-e-testes)
44
+ - [Referência: `createApp(options)`](#referência-createappoptions)
45
+ - [Princípios](#princípios)
46
+
47
+ ## Requisitos e instalação
48
+
49
+ - **Node.js 22+**
50
+ - **TypeScript 5.2+**, com decorators **padrão**. **Não** use `experimentalDecorators` nem `emitDecoratorMetadata`: se estiverem ligados, o Sashiko lança um erro dizendo como corrigir. Por isso ele não convive no mesmo projeto com bibliotecas que exigem decorators legados (NestJS, TypeORM…).
51
+ - ESM (`"type": "module"`).
52
+
53
+ ```bash
54
+ pnpm add sashiko @sinclair/typebox
55
+ ```
56
+
57
+ `tsconfig.json` mínimo:
58
+
59
+ ```jsonc
60
+ {
61
+ "compilerOptions": {
62
+ "target": "es2022",
63
+ "module": "nodenext",
64
+ "strict": true
65
+ }
66
+ }
67
+ ```
68
+
69
+ Importe `sashiko` **antes** de declarar as classes de rotas: o pacote instala o shim de `Symbol.metadata`, necessário para os decorators guardarem as rotas. Com `import { Router } from 'sashiko'` isso já acontece.
70
+
71
+ ## Rotas
72
+
73
+ As rotas ficam em classes marcadas com `@Router`, a "permissão" para o app usar a classe, que também define o prefixo. Verbo e path ficam no método:
74
+
75
+ ```ts
76
+ @Router('/users')
77
+ class UserRoutes {
78
+ @Get('/') list() {}
79
+ @Get('/:id') get(ctx: Context) { return { id: ctx.params.id }; }
80
+ @Post('/') create() {}
81
+ @Put('/:id') replace() {}
82
+ @Patch('/:id') update() {}
83
+ @Delete('/:id') remove(ctx: Context) { ctx.status(204); }
84
+ }
85
+ ```
86
+
87
+ - **HEAD e OPTIONS automáticos:** toda rota `GET` responde a `HEAD`, sem corpo. `OPTIONS` responde 204 com o header `Allow`.
88
+ - **405 e 404:** um método não suportado num path existente recebe 405 com `Allow`; um path inexistente recebe 404.
89
+ - **Herança:** uma subclasse (com seu próprio `@Router`) herda as rotas, os middlewares e os guards do pai, com o próprio prefixo, e pode sobrescrever handlers. A classe base não é alterada.
90
+
91
+ ## Registro e composição: `app.mount()`
92
+
93
+ `app.mount()` é a única porta de entrada. Ele aceita a classe (com construtor sem argumentos) ou uma **instância**, que é como as dependências chegam, sem container de DI. No **ponto de composição** (`server.ts`) você decide como expor cada classe, sem mexer nela:
94
+
95
+ ```ts
96
+ const repo = new UserRepository(db);
97
+ const userRoutes = new UserRoutes(repo);
98
+
99
+ app
100
+ .mount(HealthRoutes)
101
+ .mount(userRoutes) // como declarado
102
+ .mount(userRoutes, { prefix: '/v2/users' }) // a mesma instância em outro prefixo
103
+ .mount(userRoutes, { prefix: '/public/users', only: (r) => [r.list, r.get] }) // exposição parcial
104
+ .mount(new AdminRoutes(repo), { middlewares: [authenticate], guards: [isAdmin] }); // regras para o grupo
105
+ ```
106
+
107
+ | Opção | Efeito |
108
+ |---|---|
109
+ | `prefix` | Substitui o prefixo do `@Router` |
110
+ | `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 |
111
+ | `auto` | `false` registra só as [rotas manuais](#mapeamento-manual). Padrão: `true` |
112
+ | `middlewares` | Valem para todas as rotas do grupo; rodam antes dos `@Middlewares` da classe |
113
+ | `guards` | Valem para todas as rotas do grupo; rodam antes dos `@Guards` da classe |
114
+
115
+ Erros de registro aparecem na inicialização, com mensagem clara:
116
+ - classe sem `@Router`;
117
+ - seletor em `only`/`except` que não aponta para um método de rota;
118
+ - a mesma classe registrada duas vezes com o mesmo prefixo.
119
+
120
+ > **Por que `mount` e não `register`?** O método monta um grupo de rotas sob um prefixo, quantas vezes for preciso, como o `koa-mount` e os routers do Express. No Fastify, `register` significa plugin encapsulado, e esse nome fica reservado para um futuro sistema de plugins.
121
+
122
+ ## Mapeamento manual
123
+
124
+ Depois do `mount`, os verbos `.get()`, `.post()`, `.put()`, `.patch()` e `.delete()` mapeiam um método da classe para um path que ela não declara: um alias, um endpoint legado, um export. O path é relativo ao prefixo do grupo, e o método é escolhido por um **seletor**, checado pelo TypeScript:
125
+
126
+ ```ts
127
+ @Router('/users')
128
+ class UserRoutes {
129
+ @Get('/:id', byId) getUser(ctx: Context<typeof byId>) {}
130
+ @Schema(csv) exportCsv(ctx: Context<typeof csv>) {} // sem verbo: só por mapeamento manual
131
+ private findOr404() {} // sem decorator: nunca pode ser exposto
132
+ }
133
+
134
+ app
135
+ .mount(new UserRoutes(repo))
136
+ .get('/legacy/:id', (r) => r.getUser) // GET /users/legacy/:id
137
+ .get('/export.csv', (r) => r.exportCsv, { middlewares: [cache()], guards: [isAuditor] })
138
+ .mount(HealthRoutes); // o encadeamento continua
139
+ ```
140
+
141
+ - **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.
142
+ - **Permissão por método:** só podem ser mapeados métodos com algum decorator do Sashiko (verbo, `@Schema`, `@Middlewares` ou `@Guards`). Um método sem decorator gera erro no registro.
143
+ - **Regras herdadas:** a rota manual usa os schemas e os `@Middlewares`/`@Guards` do método, e as regras do grupo.
144
+ - **Opções da rota:** `{ middlewares, guards }` em objeto, e não uma lista de funções, para que middleware, guard e seletor nunca se confundam.
145
+ - **Só mapeamento manual:** `mount(x, { auto: false })` não registra as rotas declaradas na classe.
146
+
147
+ ## Schemas: validação, serialização e tipos
148
+
149
+ Os schemas da rota (`params`, `query`, `headers`, `body` e `response`) são escritos em [TypeBox](https://github.com/sinclairzx81/typebox) (JSON Schema) e podem ser declarados de duas formas equivalentes:
150
+
151
+ ```ts
152
+ @Get('/:id', byId) // enxuta: no próprio verbo
153
+ getUser(ctx: Context<typeof byId>) {}
154
+
155
+ @Get('/:id') // verbosa: em um decorator separado
156
+ @Schema(byId)
157
+ getUser(ctx: Context<typeof byId>) {}
158
+ ```
159
+
160
+ As duas formas podem conviver no mesmo método quando declaram partes diferentes (por exemplo, `params` no verbo e `response` no `@Schema`). Declarar a mesma parte nos dois lugares gera erro.
161
+
162
+ ```ts
163
+ const list = {
164
+ query: Type.Object({
165
+ limit: Type.Integer({ minimum: 1, maximum: 100, default: 20 }),
166
+ tag: Type.Optional(Type.Array(Type.String())),
167
+ }),
168
+ response: { 200: Type.Array(User) },
169
+ } satisfies RouteSchemas;
170
+
171
+ @Get('/', list)
172
+ list(ctx: Context<typeof list>) {
173
+ ctx.query.limit; // number (já convertido de "20" e com default)
174
+ ctx.query.tag; // string[] | undefined (?tag=a&tag=b ou ?tag=a)
175
+ }
176
+ ```
177
+
178
+ **Validação**
179
+ - **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.
180
+ - **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`.
181
+ - **Defaults:** valores `default` são aplicados.
182
+ - **Ordem:** headers → params → query → body. O primeiro erro gera um 400 com os detalhes:
183
+
184
+ ```json
185
+ { "error": "Invalid body", "details": [{ "path": "/email", "message": "must match format \"email\"" }] }
186
+ ```
187
+
188
+ - **Campos extras:** use `additionalProperties: false` nos bodies para rejeitar campos não previstos. É a proteção contra mass assignment.
189
+
190
+ **Resposta**
191
+ - **Filtro:** o schema de `response` serializa a saída com `fast-json-stringify`, e **campos não declarados não saem**, por exemplo `password`.
192
+ - **Status padrão:** se só existe um 2xx declarado (como `201`), ele é o status padrão; não é preciso chamar `ctx.status(201)`.
193
+ - **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.
194
+ - **Sem corpo:** retornar `undefined` (por exemplo, num 204) dispensa schema.
195
+
196
+ **Modo estrito.** `createApp({ requireSchemas: true })` falha na inicialização se alguma rota não tiver schema de `response`, ou se um POST/PUT/PATCH não tiver schema de `body`.
197
+
198
+ ## Respostas e o `ctx`
199
+
200
+ O valor retornado pelo handler vira a resposta JSON. Para outros formatos:
201
+
202
+ | Método | Uso |
203
+ |---|---|
204
+ | `ctx.status(code)` | Define o status |
205
+ | `ctx.header(name, value)` | Define um header da resposta |
206
+ | `ctx.get(name)` | Lê um header da requisição |
207
+ | `ctx.json(data)` | JSON explícito |
208
+ | `ctx.text(str)` | `text/plain; charset=utf-8` |
209
+ | `ctx.send(body, contentType?)` | `string`, `Buffer`/`Uint8Array` ou `Readable` (stream) |
210
+ | `ctx.redirect(url, status?)` | 302 por padrão; aceita 301, 303, 307, 308 |
211
+ | `ctx.throw(status, message)` | Lança um `HttpError` |
212
+
213
+ Outros campos: `ctx.params`, `ctx.query`, `ctx.body`, `ctx.state`, `ctx.id`, `ctx.log`, `ctx.ip`, `ctx.method`, `ctx.path`, `ctx.url`, `ctx.req` e `ctx.res`.
214
+
215
+ > ⚠️ `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.
216
+
217
+ ## Middlewares e guards
218
+
219
+ Middlewares seguem o modelo do Koa: `(ctx, next) => Promise<void>`.
220
+
221
+ ```ts
222
+ app.use(async (ctx, next) => {
223
+ const started = Date.now();
224
+ await next();
225
+ ctx.log.debug({ ms: Date.now() - started }, 'done');
226
+ });
227
+ ```
228
+
229
+ Por classe ou por método, use `@Middlewares` e `@Guards`; para um grupo inteiro ou uma rota manual, use as opções `middlewares` e `guards`, com os mesmos nomes em todos os níveis. Um guard retorna `true` para seguir, `false` para responder 403, ou lança um `HttpError` (por exemplo, `unauthorized()` para 401).
230
+
231
+ ```ts
232
+ declare module 'sashiko' {
233
+ interface ContextState { user?: { id: string; role: 'admin' | 'user' } } // ctx.state tipado
234
+ }
235
+
236
+ const authenticate: Middleware = async (ctx, next) => {
237
+ ctx.state.user = await verifyToken(ctx.get('authorization'));
238
+ await next();
239
+ };
240
+ const isAdmin: Guard = (ctx) => ctx.state.user?.role === 'admin';
241
+
242
+ @Middlewares(authenticate)
243
+ @Router('/admin')
244
+ class AdminRoutes {
245
+ @Guards(isAdmin)
246
+ @Delete('/users/:id')
247
+ remove() {}
248
+ }
249
+ ```
250
+
251
+ **Ordem de execução:**
252
+
253
+ ```
254
+ globais → mount.middlewares → @Middlewares (classe → método) → rota manual.middlewares
255
+ → mount.guards → @Guards (classe → método) → rota manual.guards
256
+ → validação → handler
257
+ ```
258
+
259
+ **Ordem dos decorators no código.** `@Router`, `@Get`/`@Post`/…, `@Schema`, `@Middlewares` e `@Guards` só registram metadata, então a posição entre eles não muda o comportamento; a única regra é que vários `@Middlewares` (ou `@Guards`) executam na ordem em que aparecem, de cima para baixo. Já decorators que **envolvem** o método, como `@timer()`, medem tudo o que estiver abaixo deles.
260
+
261
+ Os guards rodam **antes** da validação: quem não tem acesso recebe 401/403, e não um 400 que revelaria o formato esperado.
262
+
263
+ ## Erros
264
+
265
+ ```ts
266
+ import { badRequest, forbidden, HttpError, internal, notFound, unauthorized } from 'sashiko';
267
+
268
+ throw notFound('user not found'); // 404 { "error": "user not found" }
269
+ throw new HttpError(409, 'email taken', 'CONFLICT');
270
+ throw new HttpError(503, 'Maintenance', 'MAINT', { expose: true });
271
+ ```
272
+
273
+ - **Erros 4xx:** a mensagem vai para o cliente.
274
+ - **Erros 5xx e erros comuns** (`throw new Error(...)`): a resposta é sempre `{"error":"Internal Server Error"}`. O erro completo vai para o log, com `reqId`. Para mostrar uma mensagem de 5xx ao cliente, use `expose: true`.
275
+ - **`app.onError(handler)`:** roda antes da resposta padrão, para enviar o erro a um APM, por exemplo. Se o próprio handler falhar, o app não cai: o erro é logado e a resposta padrão é enviada.
276
+
277
+ ## Segurança
278
+
279
+ Ligado por padrão:
280
+
281
+ | Proteção | Detalhe |
282
+ |---|---|
283
+ | 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. |
284
+ | Limite de body | 1 MiB (`bodyLimit`). Recusado já pelo `Content-Length`, com a conexão fechada. |
285
+ | Timeouts | `requestTimeout` 30s, `headersTimeout` 10s, `keepAliveTimeout` 5s (contra Slowloris). |
286
+ | Query | Até 1000 pares (`queryMaxKeys`); `__proto__`, `constructor` e `prototype` são descartados. |
287
+ | Erros 5xx | Sem detalhes para o cliente. |
288
+ | Request id | Ids recebidos só são aceitos se forem seguros (contra log injection). |
289
+
290
+ Opt-in:
291
+
292
+ ```ts
293
+ import { cors, createApp, rateLimit } from 'sashiko';
294
+
295
+ createApp({
296
+ requireSchemas: true,
297
+ securityHeaders: { strictTransportSecurity: true }, // só atrás de HTTPS
298
+ })
299
+ .use(cors({ origin: ['https://app.exemplo.com', /\.exemplo\.dev$/], credentials: true }))
300
+ .use(rateLimit({ max: 100, windowMs: 60_000 })); // 429 + Retry-After
301
+ ```
302
+
303
+ - **`cors()`:** a origem é obrigatória, sem default permissivo. Aceita string, lista, RegExp ou função. Responde ao preflight com 204 e envia `Vary: Origin`. Combinar `origin: '*'` com `credentials: true` gera erro.
304
+ - **`rateLimit()`:** guarda os contadores em memória por padrão. Para várias instâncias, implemente `RateLimitStore` (Redis etc.).
305
+ - **Cookies e CSRF:** ainda não há proteção de CSRF embutida. Se a autenticação usar cookies, valide `Origin` num middleware e use `SameSite=Lax` ou `Strict`.
306
+ - **IP:** `ctx.ip` usa o IP do socket. Atrás de um proxy, `X-Forwarded-For` **não** é considerado.
307
+
308
+ ## Logs e request id
309
+
310
+ ```ts
311
+ import pino from 'pino';
312
+
313
+ createApp({ logger: pino(), logRequests: true });
314
+ ```
315
+
316
+ - **Logger:** compatível com o pino (`info(obj, msg)`, `child()`). O padrão, `consoleLogger({ level })`, usa o `console`. `logger: false` desliga tudo.
317
+ - **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`.
318
+ - **`ctx.log`:** já inclui o `reqId`: `ctx.log.info({ userId }, 'user created')`.
319
+ - **`logRequests: true`:** registra método, path, status e duração de cada requisição.
320
+ - **Dados sensíveis:** headers e body nunca são logados automaticamente.
321
+
322
+ ## Ciclo de vida e testes
323
+
324
+ ```ts
325
+ const app = createApp().mount(UserRoutes);
326
+
327
+ const server = await app.listen({ port: 3000, host: '0.0.0.0' }); // resolve quando está ouvindo
328
+
329
+ process.on('SIGTERM', async () => {
330
+ await app.close(); // para de aceitar conexões e espera as requisições em andamento
331
+ process.exit(0);
332
+ });
333
+
334
+ // Servidor próprio (HTTPS, http2 em modo compat…)
335
+ https.createServer(tlsOptions, app.callback()).listen(443);
336
+ ```
337
+
338
+ **Testes sem rede.** `app.inject()` executa a requisição em memória:
339
+
340
+ ```ts
341
+ const res = await app.inject({ method: 'POST', url: '/users', body: { name: 'Ana' } });
342
+ assert.equal(res.status, 201);
343
+ assert.equal(res.json<{ name: string }>().name, 'Ana');
344
+ ```
345
+
346
+ ## Referência: `createApp(options)`
347
+
348
+ | Opção | Padrão | Descrição |
349
+ |---|---|---|
350
+ | `bodyLimit` | `1048576` | Tamanho máximo do body JSON, em bytes |
351
+ | `queryMaxKeys` | `1000` | Máximo de pares lidos da query string |
352
+ | `requireSchemas` | `false` | Exige schemas de body e de resposta no registro das rotas |
353
+ | `securityHeaders` | ligado | Opções dos headers de segurança; `false` desliga |
354
+ | `timeouts` | 30s / 10s / 5s | `requestTimeout`, `headersTimeout`, `keepAliveTimeout` |
355
+ | `logger` | `consoleLogger()` | Logger compatível com pino; `false` desliga |
356
+ | `logRequests` | `false` | Log de cada requisição concluída |
357
+ | `requestId` | header `x-request-id` | `header`, `trustIncoming`, `generate` |
358
+
359
+ Métodos do app: `use()`, `mount()`, `onError()`, `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.
360
+
361
+ O body só é lido como JSON quando o `Content-Type` é `application/json` ou `application/*+json`.
362
+
363
+ ## Princípios
364
+
365
+ 1. **Micro:** núcleo pequeno, com quatro dependências de runtime (`ajv`, `ajv-formats`, `fast-json-stringify`, `find-my-way`).
366
+ 2. **Só decorators padrão (TC39):** sem `reflect-metadata`.
367
+ 3. **Schema-first:** o schema é a fonte da validação, da serialização e dos tipos.
368
+ 4. **Seguro por padrão:** desligar uma proteção é sempre explícito.
369
+ 5. **Node nativo:** `node:http` direto.
370
+ 6. **Explícito em vez de mágico:** rotas e dependências registradas à mão, no ponto de composição, sem varredura de arquivos.
371
+
372
+ **Extensões:** quando um recurso tem alternativas populares, o core traz uma implementação leve e um ponto de extensão; a biblioteca externa fica a critério do seu projeto. Estão planejados um parser de query plugável (para usar `qs`) e um validador plugável (Zod, Valibot e ArkType via Standard Schema).
373
+
374
+ **Fora do escopo:** container de DI, módulos e providers; decorators de parâmetro (`@Body`, `@Param`), porque a interface é o `ctx`; interceptors, pipes e exception filters como conceitos separados; ORM, views e SSR, CLI geradora, GraphQL e WebSockets no core, compatibilidade com middlewares do Express, Bun, Deno e edge (por enquanto).
375
+
376
+ ## Exemplo completo
377
+
378
+ [`examples/app-users`](examples/app-users) traz CRUD, composição com `app.mount()`, mapeamento manual, guards, CORS, rate limit, logger, herança e injeção de dependências, além de um script que percorre todos os cenários:
379
+
380
+ ```bash
381
+ pnpm dev
382
+ bash examples/app-users/scenarios.sh
383
+ ```
384
+
385
+ ## Desenvolvimento
386
+
387
+ ```bash
388
+ pnpm install
389
+ pnpm test # node:test + tsx
390
+ pnpm test:coverage # com limites mínimos de cobertura
391
+ pnpm typecheck
392
+ pnpm build # dist/
393
+ ```
394
+
395
+ Mudanças notáveis estão no [CHANGELOG](CHANGELOG.md).
396
+
397
+ ## Licença
398
+
399
+ [ISC](LICENSE)
@@ -0,0 +1,104 @@
1
+ import http from 'node:http';
2
+ import { type MountOptions } from './compiler.ts';
3
+ import { MountedGroup } from './mounted-group.ts';
4
+ import { type Middleware } from './compose.ts';
5
+ import { Context } from './context.ts';
6
+ import '../shims/symbol-metadata.ts';
7
+ import { type InjectOptions, type InjectResponse } from './inject.ts';
8
+ import { type Logger } from './logger.ts';
9
+ import { type SecurityHeadersOptions } from '../middlewares/security-headers.ts';
10
+ export interface ListenOptions {
11
+ /** Padrão: 3000. Use 0 para uma porta livre aleatória. */
12
+ port?: number;
13
+ /** Padrão: `127.0.0.1`. Use `0.0.0.0` para aceitar conexões externas (ex.: em containers). */
14
+ host?: string;
15
+ }
16
+ export interface ServerTimeouts {
17
+ /** Tempo máximo para receber a requisição inteira. Padrão: 30s. */
18
+ requestTimeout?: number;
19
+ /** Tempo máximo para receber os headers (mitiga Slowloris). Padrão: 10s. */
20
+ headersTimeout?: number;
21
+ /** Tempo de conexão keep-alive ociosa. Padrão: 5s. */
22
+ keepAliveTimeout?: number;
23
+ }
24
+ export interface AppOptions {
25
+ /** Tamanho máximo do body JSON em bytes. Padrão: 1 MiB. */
26
+ bodyLimit?: number;
27
+ /** Headers de segurança aplicados a toda resposta (S-04). Ligado por padrão; `false` desliga. */
28
+ securityHeaders?: SecurityHeadersOptions | false;
29
+ /** Timeouts do servidor HTTP (S-06). */
30
+ timeouts?: ServerTimeouts;
31
+ /**
32
+ * Modo estrito (S-07): exige schema de `body` em POST/PUT/PATCH e schema de `response`
33
+ * em toda rota, falhando no `app.mount()`. Padrão: `false`.
34
+ */
35
+ requireSchemas?: boolean;
36
+ /** Máximo de pares chave/valor lidos da query string. Padrão: 1000 (S-06). */
37
+ queryMaxKeys?: number;
38
+ /** Logger compatível com pino. Padrão: `consoleLogger()`; `false` desliga (F-06). */
39
+ logger?: Logger | false;
40
+ /** Loga cada requisição concluída (método, path, status, duração) em nível `info`. Padrão: `false`. */
41
+ logRequests?: boolean;
42
+ /** Request id (F-06). */
43
+ requestId?: RequestIdOptions;
44
+ }
45
+ export interface RequestIdOptions {
46
+ /** Header lido da requisição e devolvido na resposta. Padrão: `x-request-id`. `false` não envia. */
47
+ header?: string | false;
48
+ /**
49
+ * Reaproveita o id recebido no header (útil atrás de um gateway). Padrão: `true`.
50
+ * O valor só é aceito se tiver até 128 caracteres de `[A-Za-z0-9._:-]`, para evitar log injection.
51
+ */
52
+ trustIncoming?: boolean;
53
+ /** Gerador de ids. Padrão: `crypto.randomUUID()`. */
54
+ generate?: () => string;
55
+ }
56
+ export declare class SashikoApp {
57
+ private middlewares;
58
+ private router;
59
+ private errorHandlers;
60
+ private applySecurityHeaders;
61
+ private readonly opts;
62
+ private server;
63
+ /** Logger do app (F-06). Use para logs fora de uma requisição. */
64
+ readonly log: Logger;
65
+ constructor(opts?: AppOptions);
66
+ use(mw: Middleware): this;
67
+ onError(h: (err: unknown, ctx: Context) => void | Promise<void>): this;
68
+ /**
69
+ * Registra uma classe com `@Router` (F-28). Aceita a classe (construtor sem argumentos) ou uma
70
+ * instância pronta (B-10), e opções aplicadas no ponto de composição:
71
+ *
72
+ * ```ts
73
+ * app
74
+ * .mount(HealthRoutes)
75
+ * .mount(new UserRoutes(repo), { prefix: '/v2/users', middlewares: [auth], except: (r) => [r.remove] })
76
+ * .get('/legacy/:id', (r) => r.getUser); // mapeamento manual (F-30)
77
+ * ```
78
+ */
79
+ mount<T extends object>(ref: new () => T, options?: MountOptions<T>): MountedGroup<T>;
80
+ mount<T extends object>(ref: T extends abstract new (...args: never) => unknown ? never : T, options?: MountOptions<T>): MountedGroup<T>;
81
+ private addRoute;
82
+ handle(ctx: Context): Promise<void>;
83
+ /**
84
+ * Executa os `onError` do usuário. Se um deles lançar, o erro é logado e seguimos
85
+ * para a resposta padrão com o erro **original** — nunca deixamos a Promise rejeitar (B-08).
86
+ */
87
+ private runErrorHandlers;
88
+ /**
89
+ * Listener `(req, res)` do app, para usar com qualquer servidor do Node:
90
+ * `https.createServer(opts, app.callback())`, `http2` em modo compat, testes etc. (F-09).
91
+ */
92
+ callback(): (req: http.IncomingMessage, res: http.ServerResponse) => void;
93
+ /** Sobe o servidor HTTP e resolve quando ele está aceitando conexões (F-09). */
94
+ listen(options?: number | ListenOptions): Promise<http.Server>;
95
+ /**
96
+ * Para de aceitar conexões, encerra as ociosas e espera as requisições em andamento terminarem.
97
+ * Depois de `graceMs`, força o fechamento das restantes.
98
+ */
99
+ close(graceMs?: number): Promise<void>;
100
+ /** Executa uma requisição em memória, sem rede. Ideal para testes (F-09). */
101
+ inject(opts: InjectOptions | string): Promise<InjectResponse>;
102
+ }
103
+ export declare function createApp(opts?: AppOptions): SashikoApp;
104
+ //# sourceMappingURL=app.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/core/app.ts"],"names":[],"mappings":"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"}