sashiko 0.0.0-stage → 0.1.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/LICENSE +15 -0
- package/README.md +370 -2
- package/dist/core/app.d.ts +101 -0
- package/dist/core/app.d.ts.map +1 -0
- package/dist/core/app.js +199 -0
- package/dist/core/app.js.map +1 -0
- package/dist/core/body-parser.d.ts +4 -0
- package/dist/core/body-parser.d.ts.map +1 -0
- package/dist/core/body-parser.js +38 -0
- package/dist/core/body-parser.js.map +1 -0
- package/dist/core/compiler.d.ts +28 -0
- package/dist/core/compiler.d.ts.map +1 -0
- package/dist/core/compiler.js +50 -0
- package/dist/core/compiler.js.map +1 -0
- package/dist/core/compose.d.ts +4 -0
- package/dist/core/compose.d.ts.map +1 -0
- package/dist/core/compose.js +11 -0
- package/dist/core/compose.js.map +1 -0
- package/dist/core/context.d.ts +84 -0
- package/dist/core/context.d.ts.map +1 -0
- package/dist/core/context.js +121 -0
- package/dist/core/context.js.map +1 -0
- package/dist/core/errors.d.ts +24 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +23 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/guard.d.ts +10 -0
- package/dist/core/guard.d.ts.map +1 -0
- package/dist/core/guard.js +12 -0
- package/dist/core/guard.js.map +1 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +7 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/infer.d.ts +36 -0
- package/dist/core/infer.d.ts.map +1 -0
- package/dist/core/infer.js +2 -0
- package/dist/core/infer.js.map +1 -0
- package/dist/core/inject.d.ts +27 -0
- package/dist/core/inject.d.ts.map +1 -0
- package/dist/core/inject.js +72 -0
- package/dist/core/inject.js.map +1 -0
- package/dist/core/logger.d.ts +28 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +27 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/metadata.d.ts +59 -0
- package/dist/core/metadata.d.ts.map +1 -0
- package/dist/core/metadata.js +83 -0
- package/dist/core/metadata.js.map +1 -0
- package/dist/core/request-validation.d.ts +11 -0
- package/dist/core/request-validation.d.ts.map +1 -0
- package/dist/core/request-validation.js +30 -0
- package/dist/core/request-validation.js.map +1 -0
- package/dist/core/response.d.ts +16 -0
- package/dist/core/response.d.ts.map +1 -0
- package/dist/core/response.js +49 -0
- package/dist/core/response.js.map +1 -0
- package/dist/core/route-table.d.ts +29 -0
- package/dist/core/route-table.d.ts.map +1 -0
- package/dist/core/route-table.js +41 -0
- package/dist/core/route-table.js.map +1 -0
- package/dist/decorators/http.d.ts +16 -0
- package/dist/decorators/http.d.ts.map +1 -0
- package/dist/decorators/http.js +27 -0
- package/dist/decorators/http.js.map +1 -0
- package/dist/decorators/index.d.ts +6 -0
- package/dist/decorators/index.d.ts.map +1 -0
- package/dist/decorators/index.js +6 -0
- package/dist/decorators/index.js.map +1 -0
- package/dist/decorators/log.d.ts +10 -0
- package/dist/decorators/log.d.ts.map +1 -0
- package/dist/decorators/log.js +32 -0
- package/dist/decorators/log.js.map +1 -0
- package/dist/decorators/router.d.ts +12 -0
- package/dist/decorators/router.d.ts.map +1 -0
- package/dist/decorators/router.js +19 -0
- package/dist/decorators/router.js.map +1 -0
- package/dist/decorators/schema.d.ts +8 -0
- package/dist/decorators/schema.d.ts.map +1 -0
- package/dist/decorators/schema.js +13 -0
- package/dist/decorators/schema.js.map +1 -0
- package/dist/decorators/use.d.ts +26 -0
- package/dist/decorators/use.d.ts.map +1 -0
- package/dist/decorators/use.js +38 -0
- package/dist/decorators/use.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/middlewares/cors.d.ts +24 -0
- package/dist/middlewares/cors.d.ts.map +1 -0
- package/dist/middlewares/cors.js +63 -0
- package/dist/middlewares/cors.js.map +1 -0
- package/dist/middlewares/index.d.ts +4 -0
- package/dist/middlewares/index.d.ts.map +1 -0
- package/dist/middlewares/index.js +4 -0
- package/dist/middlewares/index.js.map +1 -0
- package/dist/middlewares/rate-limit.d.ts +35 -0
- package/dist/middlewares/rate-limit.d.ts.map +1 -0
- package/dist/middlewares/rate-limit.js +45 -0
- package/dist/middlewares/rate-limit.js.map +1 -0
- package/dist/middlewares/security-headers.d.ts +26 -0
- package/dist/middlewares/security-headers.d.ts.map +1 -0
- package/dist/middlewares/security-headers.js +34 -0
- package/dist/middlewares/security-headers.js.map +1 -0
- package/dist/schema/serialization.d.ts +2 -0
- package/dist/schema/serialization.d.ts.map +1 -0
- package/dist/schema/serialization.js +11 -0
- package/dist/schema/serialization.js.map +1 -0
- package/dist/schema/validation.d.ts +11 -0
- package/dist/schema/validation.d.ts.map +1 -0
- package/dist/schema/validation.js +48 -0
- package/dist/schema/validation.js.map +1 -0
- package/dist/shims/symbol-metadata.d.ts +2 -0
- package/dist/shims/symbol-metadata.d.ts.map +1 -0
- package/dist/shims/symbol-metadata.js +8 -0
- package/dist/shims/symbol-metadata.js.map +1 -0
- package/dist/utils/content-type.d.ts +5 -0
- package/dist/utils/content-type.d.ts.map +1 -0
- package/dist/utils/content-type.js +10 -0
- package/dist/utils/content-type.js.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +4 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/path.d.ts +3 -0
- package/dist/utils/path.d.ts.map +1 -0
- package/dist/utils/path.js +7 -0
- package/dist/utils/path.js.map +1 -0
- package/dist/utils/query.d.ts +13 -0
- package/dist/utils/query.d.ts.map +1 -0
- package/dist/utils/query.js +30 -0
- package/dist/utils/query.js.map +1 -0
- 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,371 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Sashiko
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
- [Schemas: validação, serialização e tipos](#schemas-validação-serialização-e-tipos)
|
|
37
|
+
- [Respostas e o `ctx`](#respostas-e-o-ctx)
|
|
38
|
+
- [Middlewares e guards](#middlewares-e-guards)
|
|
39
|
+
- [Erros](#erros)
|
|
40
|
+
- [Segurança](#segurança)
|
|
41
|
+
- [Logs e request id](#logs-e-request-id)
|
|
42
|
+
- [Ciclo de vida e testes](#ciclo-de-vida-e-testes)
|
|
43
|
+
- [Referência: `createApp(options)`](#referência-createappoptions)
|
|
44
|
+
- [Princípios](#princípios)
|
|
45
|
+
|
|
46
|
+
## Requisitos e instalação
|
|
47
|
+
|
|
48
|
+
- **Node.js 22+**
|
|
49
|
+
- **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…).
|
|
50
|
+
- ESM (`"type": "module"`).
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pnpm add sashiko @sinclair/typebox
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`tsconfig.json` mínimo:
|
|
57
|
+
|
|
58
|
+
```jsonc
|
|
59
|
+
{
|
|
60
|
+
"compilerOptions": {
|
|
61
|
+
"target": "es2022",
|
|
62
|
+
"module": "nodenext",
|
|
63
|
+
"strict": true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
70
|
+
## Rotas
|
|
71
|
+
|
|
72
|
+
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:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
@Router('/users')
|
|
76
|
+
class UserRoutes {
|
|
77
|
+
@Get('/') list() {}
|
|
78
|
+
@Get('/:id') get(ctx: Context) { return { id: ctx.params.id }; }
|
|
79
|
+
@Post('/') create() {}
|
|
80
|
+
@Put('/:id') replace() {}
|
|
81
|
+
@Patch('/:id') update() {}
|
|
82
|
+
@Delete('/:id') remove(ctx: Context) { ctx.status(204); }
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- **HEAD e OPTIONS automáticos:** toda rota `GET` responde a `HEAD`, sem corpo. `OPTIONS` responde 204 com o header `Allow`.
|
|
87
|
+
- **405 e 404:** um método não suportado num path existente recebe 405 com `Allow`; um path inexistente recebe 404.
|
|
88
|
+
- **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.
|
|
89
|
+
|
|
90
|
+
## Registro e composição: `app.mount()`
|
|
91
|
+
|
|
92
|
+
`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:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
const repo = new UserRepository(db);
|
|
96
|
+
const userRoutes = new UserRoutes(repo);
|
|
97
|
+
|
|
98
|
+
app
|
|
99
|
+
.mount(HealthRoutes)
|
|
100
|
+
.mount(userRoutes) // como declarado
|
|
101
|
+
.mount(userRoutes, { prefix: '/v2/users' }) // a mesma instância em outro prefixo
|
|
102
|
+
.mount(userRoutes, { prefix: '/public/users', only: ['list', 'get'] }) // exposição parcial
|
|
103
|
+
.mount(new AdminRoutes(repo), { middlewares: [authenticate], guards: [isAdmin] }); // regras para o grupo
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Opção | Efeito |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `prefix` | Substitui o prefixo do `@Router` |
|
|
109
|
+
| `only` / `except` | Registra só esses métodos, ou todos menos esses. Os nomes são checados pelo TypeScript; usar os dois juntos gera erro |
|
|
110
|
+
| `middlewares` | Valem para todas as rotas registradas; rodam antes dos `@Use` da classe |
|
|
111
|
+
| `guards` | Valem para todas as rotas registradas; rodam antes dos `@UseGuards` da classe |
|
|
112
|
+
|
|
113
|
+
Erros de registro aparecem na inicialização, com mensagem clara:
|
|
114
|
+
- classe sem `@Router`;
|
|
115
|
+
- nome em `only`/`except` que não é método de rota;
|
|
116
|
+
- a mesma classe registrada duas vezes com o mesmo prefixo.
|
|
117
|
+
|
|
118
|
+
> **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.
|
|
119
|
+
|
|
120
|
+
## Schemas: validação, serialização e tipos
|
|
121
|
+
|
|
122
|
+
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:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
@Get('/:id', byId) // enxuta: no próprio verbo
|
|
126
|
+
getUser(ctx: Context<typeof byId>) {}
|
|
127
|
+
|
|
128
|
+
@Get('/:id') // verbosa: em um decorator separado
|
|
129
|
+
@Schema(byId)
|
|
130
|
+
getUser(ctx: Context<typeof byId>) {}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
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.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const list = {
|
|
137
|
+
query: Type.Object({
|
|
138
|
+
limit: Type.Integer({ minimum: 1, maximum: 100, default: 20 }),
|
|
139
|
+
tag: Type.Optional(Type.Array(Type.String())),
|
|
140
|
+
}),
|
|
141
|
+
response: { 200: Type.Array(User) },
|
|
142
|
+
} satisfies RouteSchemas;
|
|
143
|
+
|
|
144
|
+
@Get('/', list)
|
|
145
|
+
list(ctx: Context<typeof list>) {
|
|
146
|
+
ctx.query.limit; // number (já convertido de "20" e com default)
|
|
147
|
+
ctx.query.tag; // string[] | undefined (?tag=a&tag=b ou ?tag=a)
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Validação**
|
|
152
|
+
- **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.
|
|
153
|
+
- **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`.
|
|
154
|
+
- **Defaults:** valores `default` são aplicados.
|
|
155
|
+
- **Ordem:** headers → params → query → body. O primeiro erro gera um 400 com os detalhes:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{ "error": "Invalid body", "details": [{ "path": "/email", "message": "must match format \"email\"" }] }
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- **Campos extras:** use `additionalProperties: false` nos bodies para rejeitar campos não previstos. É a proteção contra mass assignment.
|
|
162
|
+
|
|
163
|
+
**Resposta**
|
|
164
|
+
- **Filtro:** o schema de `response` serializa a saída com `fast-json-stringify`, e **campos não declarados não saem**, por exemplo `password`.
|
|
165
|
+
- **Status padrão:** se só existe um 2xx declarado (como `201`), ele é o status padrão; não é preciso chamar `ctx.status(201)`.
|
|
166
|
+
- **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.
|
|
167
|
+
- **Sem corpo:** retornar `undefined` (por exemplo, num 204) dispensa schema.
|
|
168
|
+
|
|
169
|
+
**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`.
|
|
170
|
+
|
|
171
|
+
## Respostas e o `ctx`
|
|
172
|
+
|
|
173
|
+
O valor retornado pelo handler vira a resposta JSON. Para outros formatos:
|
|
174
|
+
|
|
175
|
+
| Método | Uso |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `ctx.status(code)` | Define o status |
|
|
178
|
+
| `ctx.header(name, value)` | Define um header da resposta |
|
|
179
|
+
| `ctx.get(name)` | Lê um header da requisição |
|
|
180
|
+
| `ctx.json(data)` | JSON explícito |
|
|
181
|
+
| `ctx.text(str)` | `text/plain; charset=utf-8` |
|
|
182
|
+
| `ctx.send(body, contentType?)` | `string`, `Buffer`/`Uint8Array` ou `Readable` (stream) |
|
|
183
|
+
| `ctx.redirect(url, status?)` | 302 por padrão; aceita 301, 303, 307, 308 |
|
|
184
|
+
| `ctx.throw(status, message)` | Lança um `HttpError` |
|
|
185
|
+
|
|
186
|
+
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`.
|
|
187
|
+
|
|
188
|
+
> ⚠️ `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.
|
|
189
|
+
|
|
190
|
+
## Middlewares e guards
|
|
191
|
+
|
|
192
|
+
Middlewares seguem o modelo do Koa: `(ctx, next) => Promise<void>`.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
app.use(async (ctx, next) => {
|
|
196
|
+
const started = Date.now();
|
|
197
|
+
await next();
|
|
198
|
+
ctx.log.debug({ ms: Date.now() - started }, 'done');
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Por classe ou por método, use `@Use` e `@UseGuards`; para um grupo inteiro, use as opções `middlewares` e `guards` do `app.mount()`. Um guard retorna `true` para seguir, `false` para responder 403, ou lança um `HttpError` (por exemplo, `unauthorized()` para 401).
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
declare module 'sashiko' {
|
|
206
|
+
interface ContextState { user?: { id: string; role: 'admin' | 'user' } } // ctx.state tipado
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const authenticate: Middleware = async (ctx, next) => {
|
|
210
|
+
ctx.state.user = await verifyToken(ctx.get('authorization'));
|
|
211
|
+
await next();
|
|
212
|
+
};
|
|
213
|
+
const isAdmin: Guard = (ctx) => ctx.state.user?.role === 'admin';
|
|
214
|
+
|
|
215
|
+
@Use(authenticate)
|
|
216
|
+
@Router('/admin')
|
|
217
|
+
class AdminRoutes {
|
|
218
|
+
@UseGuards(isAdmin)
|
|
219
|
+
@Delete('/users/:id')
|
|
220
|
+
remove() {}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Ordem de execução:**
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
globais → app.mount(…, { middlewares }) → @Use (classe → método)
|
|
228
|
+
→ app.mount(…, { guards }) → @UseGuards (classe → método) → validação → handler
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Ordem dos decorators no código.** `@Router`, `@Get`/`@Post`/…, `@Schema`, `@Use` e `@UseGuards` só registram metadata, então a posição entre eles não muda o comportamento; a única regra é que vários `@Use` (ou `@UseGuards`) 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.
|
|
232
|
+
|
|
233
|
+
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. Vários `@Use` na mesma posição executam de cima para baixo.
|
|
234
|
+
|
|
235
|
+
## Erros
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import { badRequest, forbidden, HttpError, internal, notFound, unauthorized } from 'sashiko';
|
|
239
|
+
|
|
240
|
+
throw notFound('user not found'); // 404 { "error": "user not found" }
|
|
241
|
+
throw new HttpError(409, 'email taken', 'CONFLICT');
|
|
242
|
+
throw new HttpError(503, 'Maintenance', 'MAINT', { expose: true });
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
- **Erros 4xx:** a mensagem vai para o cliente.
|
|
246
|
+
- **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`.
|
|
247
|
+
- **`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.
|
|
248
|
+
|
|
249
|
+
## Segurança
|
|
250
|
+
|
|
251
|
+
Ligado por padrão:
|
|
252
|
+
|
|
253
|
+
| Proteção | Detalhe |
|
|
254
|
+
|---|---|
|
|
255
|
+
| 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. |
|
|
256
|
+
| Limite de body | 1 MiB (`bodyLimit`). Recusado já pelo `Content-Length`, com a conexão fechada. |
|
|
257
|
+
| Timeouts | `requestTimeout` 30s, `headersTimeout` 10s, `keepAliveTimeout` 5s (contra Slowloris). |
|
|
258
|
+
| Query | Até 1000 pares (`queryMaxKeys`); `__proto__`, `constructor` e `prototype` são descartados. |
|
|
259
|
+
| Erros 5xx | Sem detalhes para o cliente. |
|
|
260
|
+
| Request id | Ids recebidos só são aceitos se forem seguros (contra log injection). |
|
|
261
|
+
|
|
262
|
+
Opt-in:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { cors, createApp, rateLimit } from 'sashiko';
|
|
266
|
+
|
|
267
|
+
createApp({
|
|
268
|
+
requireSchemas: true,
|
|
269
|
+
securityHeaders: { strictTransportSecurity: true }, // só atrás de HTTPS
|
|
270
|
+
})
|
|
271
|
+
.use(cors({ origin: ['https://app.exemplo.com', /\.exemplo\.dev$/], credentials: true }))
|
|
272
|
+
.use(rateLimit({ max: 100, windowMs: 60_000 })); // 429 + Retry-After
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
- **`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.
|
|
276
|
+
- **`rateLimit()`:** guarda os contadores em memória por padrão. Para várias instâncias, implemente `RateLimitStore` (Redis etc.).
|
|
277
|
+
- **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`.
|
|
278
|
+
- **IP:** `ctx.ip` usa o IP do socket. Atrás de um proxy, `X-Forwarded-For` **não** é considerado.
|
|
279
|
+
|
|
280
|
+
## Logs e request id
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
import pino from 'pino';
|
|
284
|
+
|
|
285
|
+
createApp({ logger: pino(), logRequests: true });
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
- **Logger:** compatível com o pino (`info(obj, msg)`, `child()`). O padrão, `consoleLogger({ level })`, usa o `console`. `logger: false` desliga tudo.
|
|
289
|
+
- **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`.
|
|
290
|
+
- **`ctx.log`:** já inclui o `reqId`: `ctx.log.info({ userId }, 'user created')`.
|
|
291
|
+
- **`logRequests: true`:** registra método, path, status e duração de cada requisição.
|
|
292
|
+
- **Dados sensíveis:** headers e body nunca são logados automaticamente.
|
|
293
|
+
|
|
294
|
+
## Ciclo de vida e testes
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
const app = createApp().mount(UserRoutes);
|
|
298
|
+
|
|
299
|
+
const server = await app.listen({ port: 3000, host: '0.0.0.0' }); // resolve quando está ouvindo
|
|
300
|
+
|
|
301
|
+
process.on('SIGTERM', async () => {
|
|
302
|
+
await app.close(); // para de aceitar conexões e espera as requisições em andamento
|
|
303
|
+
process.exit(0);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Servidor próprio (HTTPS, http2 em modo compat…)
|
|
307
|
+
https.createServer(tlsOptions, app.callback()).listen(443);
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
**Testes sem rede.** `app.inject()` executa a requisição em memória:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
const res = await app.inject({ method: 'POST', url: '/users', body: { name: 'Ana' } });
|
|
314
|
+
assert.equal(res.status, 201);
|
|
315
|
+
assert.equal(res.json<{ name: string }>().name, 'Ana');
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Referência: `createApp(options)`
|
|
319
|
+
|
|
320
|
+
| Opção | Padrão | Descrição |
|
|
321
|
+
|---|---|---|
|
|
322
|
+
| `bodyLimit` | `1048576` | Tamanho máximo do body JSON, em bytes |
|
|
323
|
+
| `queryMaxKeys` | `1000` | Máximo de pares lidos da query string |
|
|
324
|
+
| `requireSchemas` | `false` | Exige schemas de body e de resposta no registro das rotas |
|
|
325
|
+
| `securityHeaders` | ligado | Opções dos headers de segurança; `false` desliga |
|
|
326
|
+
| `timeouts` | 30s / 10s / 5s | `requestTimeout`, `headersTimeout`, `keepAliveTimeout` |
|
|
327
|
+
| `logger` | `consoleLogger()` | Logger compatível com pino; `false` desliga |
|
|
328
|
+
| `logRequests` | `false` | Log de cada requisição concluída |
|
|
329
|
+
| `requestId` | header `x-request-id` | `header`, `trustIncoming`, `generate` |
|
|
330
|
+
|
|
331
|
+
Métodos do app: `use()`, `mount()`, `onError()`, `listen()`, `close()`, `callback()`, `inject()`, `log`.
|
|
332
|
+
|
|
333
|
+
O body só é lido como JSON quando o `Content-Type` é `application/json` ou `application/*+json`.
|
|
334
|
+
|
|
335
|
+
## Princípios
|
|
336
|
+
|
|
337
|
+
1. **Micro:** núcleo pequeno, com quatro dependências de runtime (`ajv`, `ajv-formats`, `fast-json-stringify`, `find-my-way`).
|
|
338
|
+
2. **Só decorators padrão (TC39):** sem `reflect-metadata`.
|
|
339
|
+
3. **Schema-first:** o schema é a fonte da validação, da serialização e dos tipos.
|
|
340
|
+
4. **Seguro por padrão:** desligar uma proteção é sempre explícito.
|
|
341
|
+
5. **Node nativo:** `node:http` direto.
|
|
342
|
+
6. **Explícito em vez de mágico:** rotas e dependências registradas à mão, no ponto de composição, sem varredura de arquivos.
|
|
343
|
+
|
|
344
|
+
**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).
|
|
345
|
+
|
|
346
|
+
**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).
|
|
347
|
+
|
|
348
|
+
## Exemplo completo
|
|
349
|
+
|
|
350
|
+
[`examples/app-users`](examples/app-users) traz CRUD, composição com `app.mount()`, guards, CORS, rate limit, logger, herança e injeção de dependências, além de um script que percorre todos os cenários:
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
pnpm dev
|
|
354
|
+
bash examples/app-users/scenarios.sh
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## Desenvolvimento
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
pnpm install
|
|
361
|
+
pnpm test # node:test + tsx
|
|
362
|
+
pnpm test:coverage # com limites mínimos de cobertura
|
|
363
|
+
pnpm typecheck
|
|
364
|
+
pnpm build # dist/
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Mudanças notáveis estão no [CHANGELOG](CHANGELOG.md).
|
|
368
|
+
|
|
369
|
+
## Licença
|
|
370
|
+
|
|
371
|
+
[ISC](LICENSE)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { type MountOptions } from './compiler.ts';
|
|
3
|
+
import { type Middleware } from './compose.ts';
|
|
4
|
+
import { Context } from './context.ts';
|
|
5
|
+
import '../shims/symbol-metadata.ts';
|
|
6
|
+
import { type InjectOptions, type InjectResponse } from './inject.ts';
|
|
7
|
+
import { type Logger } from './logger.ts';
|
|
8
|
+
import { type SecurityHeadersOptions } from '../middlewares/security-headers.ts';
|
|
9
|
+
export interface ListenOptions {
|
|
10
|
+
/** Padrão: 3000. Use 0 para uma porta livre aleatória. */
|
|
11
|
+
port?: number;
|
|
12
|
+
/** Padrão: `127.0.0.1`. Use `0.0.0.0` para aceitar conexões externas (ex.: em containers). */
|
|
13
|
+
host?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ServerTimeouts {
|
|
16
|
+
/** Tempo máximo para receber a requisição inteira. Padrão: 30s. */
|
|
17
|
+
requestTimeout?: number;
|
|
18
|
+
/** Tempo máximo para receber os headers (mitiga Slowloris). Padrão: 10s. */
|
|
19
|
+
headersTimeout?: number;
|
|
20
|
+
/** Tempo de conexão keep-alive ociosa. Padrão: 5s. */
|
|
21
|
+
keepAliveTimeout?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface AppOptions {
|
|
24
|
+
/** Tamanho máximo do body JSON em bytes. Padrão: 1 MiB. */
|
|
25
|
+
bodyLimit?: number;
|
|
26
|
+
/** Headers de segurança aplicados a toda resposta (S-04). Ligado por padrão; `false` desliga. */
|
|
27
|
+
securityHeaders?: SecurityHeadersOptions | false;
|
|
28
|
+
/** Timeouts do servidor HTTP (S-06). */
|
|
29
|
+
timeouts?: ServerTimeouts;
|
|
30
|
+
/**
|
|
31
|
+
* Modo estrito (S-07): exige schema de `body` em POST/PUT/PATCH e schema de `response`
|
|
32
|
+
* em toda rota, falhando no `app.mount()`. Padrão: `false`.
|
|
33
|
+
*/
|
|
34
|
+
requireSchemas?: boolean;
|
|
35
|
+
/** Máximo de pares chave/valor lidos da query string. Padrão: 1000 (S-06). */
|
|
36
|
+
queryMaxKeys?: number;
|
|
37
|
+
/** Logger compatível com pino. Padrão: `consoleLogger()`; `false` desliga (F-06). */
|
|
38
|
+
logger?: Logger | false;
|
|
39
|
+
/** Loga cada requisição concluída (método, path, status, duração) em nível `info`. Padrão: `false`. */
|
|
40
|
+
logRequests?: boolean;
|
|
41
|
+
/** Request id (F-06). */
|
|
42
|
+
requestId?: RequestIdOptions;
|
|
43
|
+
}
|
|
44
|
+
export interface RequestIdOptions {
|
|
45
|
+
/** Header lido da requisição e devolvido na resposta. Padrão: `x-request-id`. `false` não envia. */
|
|
46
|
+
header?: string | false;
|
|
47
|
+
/**
|
|
48
|
+
* Reaproveita o id recebido no header (útil atrás de um gateway). Padrão: `true`.
|
|
49
|
+
* O valor só é aceito se tiver até 128 caracteres de `[A-Za-z0-9._:-]`, para evitar log injection.
|
|
50
|
+
*/
|
|
51
|
+
trustIncoming?: boolean;
|
|
52
|
+
/** Gerador de ids. Padrão: `crypto.randomUUID()`. */
|
|
53
|
+
generate?: () => string;
|
|
54
|
+
}
|
|
55
|
+
export declare class SashikoApp {
|
|
56
|
+
private middlewares;
|
|
57
|
+
private router;
|
|
58
|
+
private errorHandlers;
|
|
59
|
+
private applySecurityHeaders;
|
|
60
|
+
private readonly opts;
|
|
61
|
+
private server;
|
|
62
|
+
/** Logger do app (F-06). Use para logs fora de uma requisição. */
|
|
63
|
+
readonly log: Logger;
|
|
64
|
+
constructor(opts?: AppOptions);
|
|
65
|
+
use(mw: Middleware): this;
|
|
66
|
+
onError(h: (err: unknown, ctx: Context) => void | Promise<void>): this;
|
|
67
|
+
/**
|
|
68
|
+
* Registra uma classe com `@Router` (F-28). Aceita a classe (construtor sem argumentos) ou uma
|
|
69
|
+
* instância pronta (B-10), e opções aplicadas no ponto de composição:
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* app
|
|
73
|
+
* .mount(HealthRoutes)
|
|
74
|
+
* .mount(new UserRoutes(repo), { prefix: '/v2/users', middlewares: [auth], except: ['remove'] });
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
mount<T extends object>(ref: new () => T, options?: MountOptions<T>): this;
|
|
78
|
+
mount<T extends object>(ref: T extends abstract new (...args: never) => unknown ? never : T, options?: MountOptions<T>): this;
|
|
79
|
+
handle(ctx: Context): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Executa os `onError` do usuário. Se um deles lançar, o erro é logado e seguimos
|
|
82
|
+
* para a resposta padrão com o erro **original** — nunca deixamos a Promise rejeitar (B-08).
|
|
83
|
+
*/
|
|
84
|
+
private runErrorHandlers;
|
|
85
|
+
/**
|
|
86
|
+
* Listener `(req, res)` do app, para usar com qualquer servidor do Node:
|
|
87
|
+
* `https.createServer(opts, app.callback())`, `http2` em modo compat, testes etc. (F-09).
|
|
88
|
+
*/
|
|
89
|
+
callback(): (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
|
90
|
+
/** Sobe o servidor HTTP e resolve quando ele está aceitando conexões (F-09). */
|
|
91
|
+
listen(options?: number | ListenOptions): Promise<http.Server>;
|
|
92
|
+
/**
|
|
93
|
+
* Para de aceitar conexões, encerra as ociosas e espera as requisições em andamento terminarem.
|
|
94
|
+
* Depois de `graceMs`, força o fechamento das restantes.
|
|
95
|
+
*/
|
|
96
|
+
close(graceMs?: number): Promise<void>;
|
|
97
|
+
/** Executa uma requisição em memória, sem rede. Ideal para testes (F-09). */
|
|
98
|
+
inject(opts: InjectOptions | string): Promise<InjectResponse>;
|
|
99
|
+
}
|
|
100
|
+
export declare function createApp(opts?: AppOptions): SashikoApp;
|
|
101
|
+
//# 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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"}
|