sashiko 0.11.4 → 0.13.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 (96) hide show
  1. package/README.md +74 -1
  2. package/dist/core/app/core.d.ts +23 -0
  3. package/dist/core/app/core.d.ts.map +1 -0
  4. package/dist/core/app/core.js +2 -0
  5. package/dist/core/app/core.js.map +1 -0
  6. package/dist/core/app/options.d.ts +109 -0
  7. package/dist/core/app/options.d.ts.map +1 -0
  8. package/dist/core/app/options.js +2 -0
  9. package/dist/core/app/options.js.map +1 -0
  10. package/dist/core/app.d.ts +34 -175
  11. package/dist/core/app.d.ts.map +1 -1
  12. package/dist/core/app.js +101 -496
  13. package/dist/core/app.js.map +1 -1
  14. package/dist/core/body-parser.d.ts +6 -3
  15. package/dist/core/body-parser.d.ts.map +1 -1
  16. package/dist/core/body-parser.js +8 -6
  17. package/dist/core/body-parser.js.map +1 -1
  18. package/dist/core/compiler.d.ts +2 -0
  19. package/dist/core/compiler.d.ts.map +1 -1
  20. package/dist/core/compiler.js.map +1 -1
  21. package/dist/core/compose.d.ts +7 -0
  22. package/dist/core/compose.d.ts.map +1 -1
  23. package/dist/core/compose.js +25 -0
  24. package/dist/core/compose.js.map +1 -1
  25. package/dist/core/context.d.ts +61 -14
  26. package/dist/core/context.d.ts.map +1 -1
  27. package/dist/core/context.js +162 -39
  28. package/dist/core/context.js.map +1 -1
  29. package/dist/core/health.js +1 -1
  30. package/dist/core/index.d.ts +3 -2
  31. package/dist/core/index.d.ts.map +1 -1
  32. package/dist/core/index.js.map +1 -1
  33. package/dist/core/inject.d.ts.map +1 -1
  34. package/dist/core/inject.js +6 -0
  35. package/dist/core/inject.js.map +1 -1
  36. package/dist/core/logger.d.ts.map +1 -1
  37. package/dist/core/logger.js +41 -14
  38. package/dist/core/logger.js.map +1 -1
  39. package/dist/core/mounted-group.d.ts +5 -0
  40. package/dist/core/mounted-group.d.ts.map +1 -1
  41. package/dist/core/mounted-group.js +10 -0
  42. package/dist/core/mounted-group.js.map +1 -1
  43. package/dist/core/registry/framework-routes.d.ts +28 -0
  44. package/dist/core/registry/framework-routes.d.ts.map +1 -0
  45. package/dist/core/registry/framework-routes.js +83 -0
  46. package/dist/core/registry/framework-routes.js.map +1 -0
  47. package/dist/core/registry/registry.d.ts +53 -0
  48. package/dist/core/registry/registry.d.ts.map +1 -0
  49. package/dist/core/registry/registry.js +156 -0
  50. package/dist/core/registry/registry.js.map +1 -0
  51. package/dist/core/registry/route-program.d.ts +14 -0
  52. package/dist/core/registry/route-program.d.ts.map +1 -0
  53. package/dist/core/registry/route-program.js +29 -0
  54. package/dist/core/registry/route-program.js.map +1 -0
  55. package/dist/core/response/pipeline.d.ts +51 -0
  56. package/dist/core/response/pipeline.d.ts.map +1 -0
  57. package/dist/core/response/pipeline.js +171 -0
  58. package/dist/core/response/pipeline.js.map +1 -0
  59. package/dist/core/response.d.ts.map +1 -1
  60. package/dist/core/response.js +25 -0
  61. package/dist/core/response.js.map +1 -1
  62. package/dist/core/route-table.d.ts +5 -1
  63. package/dist/core/route-table.d.ts.map +1 -1
  64. package/dist/core/route-table.js +1 -1
  65. package/dist/core/route-table.js.map +1 -1
  66. package/dist/core/runtime/request-handler.d.ts +42 -0
  67. package/dist/core/runtime/request-handler.d.ts.map +1 -0
  68. package/dist/core/runtime/request-handler.js +187 -0
  69. package/dist/core/runtime/request-handler.js.map +1 -0
  70. package/dist/core/runtime/server.d.ts +41 -0
  71. package/dist/core/runtime/server.d.ts.map +1 -0
  72. package/dist/core/runtime/server.js +155 -0
  73. package/dist/core/runtime/server.js.map +1 -0
  74. package/dist/core/streaming/sse.d.ts +35 -0
  75. package/dist/core/streaming/sse.d.ts.map +1 -0
  76. package/dist/core/streaming/sse.js +121 -0
  77. package/dist/core/streaming/sse.js.map +1 -0
  78. package/dist/core/symbols.d.ts +14 -0
  79. package/dist/core/symbols.d.ts.map +1 -0
  80. package/dist/core/symbols.js +14 -0
  81. package/dist/core/symbols.js.map +1 -0
  82. package/dist/openapi/generate.d.ts.map +1 -1
  83. package/dist/openapi/generate.js +2 -0
  84. package/dist/openapi/generate.js.map +1 -1
  85. package/dist/server/static.d.ts +45 -0
  86. package/dist/server/static.d.ts.map +1 -0
  87. package/dist/server/static.js +204 -0
  88. package/dist/server/static.js.map +1 -0
  89. package/dist/utils/content-type.d.ts.map +1 -1
  90. package/dist/utils/content-type.js +26 -6
  91. package/dist/utils/content-type.js.map +1 -1
  92. package/dist/utils/mime.d.ts +2 -0
  93. package/dist/utils/mime.d.ts.map +1 -0
  94. package/dist/utils/mime.js +52 -0
  95. package/dist/utils/mime.js.map +1 -0
  96. package/package.json +1 -1
package/dist/core/app.js CHANGED
@@ -1,84 +1,63 @@
1
- import { randomBytes, randomUUID } from 'node:crypto';
2
- import http from 'node:http';
3
- import https from 'node:https';
4
- import { compileTls } from '../server/tls.js';
5
- import { performance } from 'node:perf_hooks';
6
- import { childScope, compileAutoRoutes, compileManualRoute, createGroup, ROOT_SCOPE, } from './compiler.js';
7
- import { RouteGroupScope } from './mounted-group.js';
8
- import { MountedGroup } from './mounted-group.js';
9
- import { compose } from './compose.js';
10
- import { Context, kBindResponse, kRawSend } from './context.js';
11
- import { docsPage, generateOpenAPI } from '../openapi/generate.js';
12
- import { compileHealth } from './health.js';
13
1
  import '../shims/symbol-metadata.js';
14
- import { bodyParser } from './body-parser.js';
15
- import { assertSchemas, validateRequest } from './request-validation.js';
16
- import { problem, sendError, sendProblem, sendResult } from './response.js';
17
- import { RouteTable } from './route-table.js';
18
- import { canonicalPath } from '../utils/path.js';
2
+ import { childScope, joinPrefix, compileAutoRoutes, compileManualRoute, createGroup, ROOT_SCOPE, } from './compiler.js';
3
+ import { MountedGroup, RouteGroupScope } from './mounted-group.js';
4
+ import { staticHandler } from '../server/static.js';
5
+ import { canonicalPath, joinPaths } from '../utils/path.js';
6
+ import { generateOpenAPI } from '../openapi/generate.js';
7
+ import { compileHealth } from './health.js';
19
8
  import { mediaType } from '../utils/content-type.js';
20
- import { compileHostMatcher, hostRank, hostReachability, NO_HOST_RANK } from '../utils/origin-match.js';
9
+ import { compileHostMatcher } from '../utils/origin-match.js';
21
10
  import { inject } from './inject.js';
22
11
  import { guardsMiddleware } from './guard.js';
23
- import { shutdownCoordinator } from './shutdown.js';
24
12
  import { consoleLogger, silentLogger } from './logger.js';
25
13
  import { buildSecurityHeaders } from '../middlewares/security-headers.js';
26
- /** Registrar num grupo cujo callback já terminou é erro: o registro estaria fora da transação do grupo. */
14
+ import { FrameworkRoutes } from './registry/framework-routes.js';
15
+ import { RouteRegistry } from './registry/registry.js';
16
+ import { RequestHandler } from './runtime/request-handler.js';
17
+ import { ServerLifecycle } from './runtime/server.js';
27
18
  function assertOpen(scope) {
28
19
  if (scope.token && !scope.token.open) {
29
20
  throw new Error('[sashiko] app.group(): this group has already finished; register its routes inside the callback, synchronously.');
30
21
  }
31
22
  }
32
23
  const isThenable = (v) => (typeof v === 'object' || typeof v === 'function') && v !== null && typeof v.then === 'function';
33
- const SAFE_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
24
+ /**
25
+ * O app. É uma fachada (Facade) sobre o registro de rotas, as rotas do próprio framework (health, OpenAPI),
26
+ * o servidor e o pipeline da requisição: a API pública fica aqui, e o trabalho fica nesses componentes.
27
+ */
34
28
  export class SashikoApp {
35
- middlewares = [];
36
- router = new RouteTable();
37
- errorHandlers = [];
38
- onSendHooks = [];
39
- onResponseHooks = [];
40
- onCloseHooks = [];
41
- routes = [];
42
- /** Registros pendentes de um `group()` em andamento (duas fases). */
43
- pending;
44
- warnedHosts = new Set();
45
- draining = false;
46
- healthConfig;
47
- openapiConfig;
48
- closeHooksPending = true;
49
- applySecurityHeaders;
50
- opts;
51
- allowedHosts;
52
- serializers;
53
- server;
54
- tlsState;
55
- /** Respostas em andamento, para o encerramento gracioso não travar em conexões keep-alive. */
56
- inflight = new Set();
57
- closing;
58
- /** Logger do app (F-06). Use para logs fora de uma requisição. */
59
29
  log;
30
+ core;
31
+ registry;
32
+ framework;
33
+ runtime;
34
+ requests;
60
35
  constructor(opts = {}) {
61
- this.opts = opts;
62
- // Compilado na criação: uma regra inválida falha aqui, e não na primeira requisição.
63
- this.allowedHosts = opts.allowedHosts ? compileHostMatcher(opts.allowedHosts) : undefined;
64
- this.serializers = new Map(Object.entries(opts.serializers ?? {}).map(([type, fn]) => [mediaType(type), fn]));
65
36
  this.log = opts.logger === false ? silentLogger : (opts.logger ?? consoleLogger());
66
- const sd = opts.shutdown || {};
67
- Object.assign(this.shutdownTarget, {
37
+ this.core = {
38
+ opts,
68
39
  log: this.log,
69
- signals: sd.signals ?? ['SIGINT', 'SIGTERM'],
70
- exitOnShutdown: sd.exit ?? true,
71
- });
72
- if (opts.securityHeaders !== false)
73
- this.applySecurityHeaders = buildSecurityHeaders(opts.securityHeaders);
74
- this.use(bodyParser(opts.bodyLimit ?? 1024 * 1024, opts.protoPoisoning));
40
+ middlewares: [],
41
+ errorHandlers: [],
42
+ onSendHooks: [],
43
+ onResponseHooks: [],
44
+ onCloseHooks: [],
45
+ // Compilados na criação: uma regra inválida falha aqui, e não na primeira requisição.
46
+ allowedHosts: opts.allowedHosts ? compileHostMatcher(opts.allowedHosts) : undefined,
47
+ serializers: new Map(Object.entries(opts.serializers ?? {}).map(([type, fn]) => [mediaType(type), fn])),
48
+ applySecurityHeaders: opts.securityHeaders !== false ? buildSecurityHeaders(opts.securityHeaders) : undefined,
49
+ };
50
+ this.registry = new RouteRegistry(this.core, () => this.framework.reservedPaths(), () => this.framework.openapi?.cache.clear());
51
+ this.framework = new FrameworkRoutes(this.core, this.registry);
52
+ this.runtime = new ServerLifecycle(this.core, () => this.callback());
53
+ this.requests = new RequestHandler(this.core, this.registry, this.framework, this.runtime);
75
54
  }
76
55
  use(mw) {
77
- this.middlewares.push(mw);
56
+ this.core.middlewares.push(mw);
78
57
  return this;
79
58
  }
80
59
  onError(h) {
81
- this.errorHandlers.push(h);
60
+ this.core.errorHandlers.push(h);
82
61
  return this;
83
62
  }
84
63
  /**
@@ -87,7 +66,7 @@ export class SashikoApp {
87
66
  * novos. Se lançar, o erro é logado e a resposta original é enviada (F-04).
88
67
  */
89
68
  onSend(hook) {
90
- this.onSendHooks.push(hook);
69
+ this.core.onSendHooks.push(hook);
91
70
  return this;
92
71
  }
93
72
  /**
@@ -95,7 +74,7 @@ export class SashikoApp {
95
74
  * auditoria. Pode ser assíncrono; erros são só logados (F-04).
96
75
  */
97
76
  onResponse(hook) {
98
- this.onResponseHooks.push(hook);
77
+ this.core.onResponseHooks.push(hook);
99
78
  return this;
100
79
  }
101
80
  /**
@@ -103,7 +82,7 @@ export class SashikoApp {
103
82
  * e antes do `shutdown.onShutdown`. Erros são logados e fazem o `close()` rejeitar ao final (F-04).
104
83
  */
105
84
  onClose(hook) {
106
- this.onCloseHooks.push(hook);
85
+ this.core.onCloseHooks.push(hook);
107
86
  return this;
108
87
  }
109
88
  mount(ref, options = {}) {
@@ -131,10 +110,10 @@ export class SashikoApp {
131
110
  // Registro em duas fases: o que o callback registrar fica pendente e só é efetivado se ele terminar
132
111
  // de forma síncrona e sem erro. Grupos aninhados entram no lote do grupo mais externo; se um aninhado
133
112
  // falhar (mesmo com o erro capturado pelo externo), só a parte dele é descartada.
134
- const outermost = this.pending === undefined;
113
+ const outermost = this.registry.pending === undefined;
135
114
  if (outermost)
136
- this.pending = [];
137
- const mark = this.pending.length;
115
+ this.registry.pending = [];
116
+ const mark = this.registry.pending.length;
138
117
  try {
139
118
  const result = define(new RouteGroupScope(this, scope));
140
119
  if (isThenable(result)) {
@@ -144,26 +123,63 @@ export class SashikoApp {
144
123
  throw new Error('[sashiko] app.group(): the callback must be synchronous; do async work before registering routes.');
145
124
  }
146
125
  if (outermost)
147
- this.commit(this.pending);
126
+ this.registry.commit(this.registry.pending);
148
127
  }
149
128
  catch (err) {
150
- this.pending.length = mark;
129
+ this.registry.pending.length = mark;
151
130
  throw err;
152
131
  }
153
132
  finally {
154
133
  scope.token.open = false;
155
134
  if (outermost)
156
- this.pending = undefined;
135
+ this.registry.pending = undefined;
157
136
  }
158
137
  }
138
+ /**
139
+ * Serve os arquivos de `root` em `GET prefix/*` (F-13). É uma rota como as outras: vale `group()`
140
+ * (host, guards, middlewares), 404/405 e a checagem de conflitos. Não entra no OpenAPI.
141
+ *
142
+ * ```ts
143
+ * app.static('/assets', './public', { maxAge: '7d', immutable: true });
144
+ * app.static('/', './dist', { fallback: 'index.html' }); // SPA
145
+ * ```
146
+ * Caminhos que saem da raiz (`..`, symlinks), arquivos ocultos e diretórios sem índice respondem 404.
147
+ */
148
+ static(prefix, root, opts = {}) {
149
+ this.staticIn(ROOT_SCOPE, prefix, root, opts);
150
+ return this;
151
+ }
152
+ /** @internal */
153
+ staticIn(scope, prefix, root, opts) {
154
+ assertOpen(scope);
155
+ const handler = staticHandler(root, opts);
156
+ const base = joinPrefix(scope.prefix, prefix) || '/';
157
+ const route = (path) => ({
158
+ method: 'GET',
159
+ path,
160
+ handler: (ctx) => handler(ctx),
161
+ schemas: {},
162
+ middlewares: [...scope.middlewares],
163
+ guards: [...scope.guards],
164
+ owner: 'static',
165
+ handlerName: `static('${prefix}')`,
166
+ hosts: scope.hosts,
167
+ internal: true,
168
+ });
169
+ // `prefix/*` cobre os arquivos; o próprio prefixo (`/assets`, `/assets/`) serve o índice.
170
+ const routes = [route(joinPaths(base, '*'))];
171
+ if (base !== '/')
172
+ routes.push(route(base));
173
+ this.registry.stage(`static('${prefix}')`, routes);
174
+ }
159
175
  /** @internal */
160
176
  mountIn(scope, ref, options = {}) {
161
177
  assertOpen(scope);
162
178
  const group = createGroup(ref, options, scope);
163
- this.stage(group.name, compileAutoRoutes(group));
179
+ this.registry.stage(group.name, compileAutoRoutes(group));
164
180
  return new MountedGroup(this, group, (g, method, path, select, opts) => {
165
181
  assertOpen(scope);
166
- this.stage(g.name, [compileManualRoute(g, method, path, select, opts)]);
182
+ this.registry.stage(g.name, [compileManualRoute(g, method, path, select, opts)]);
167
183
  }, scope);
168
184
  }
169
185
  /**
@@ -176,11 +192,11 @@ export class SashikoApp {
176
192
  const docs = opts.docs === true ? '/docs' : opts.docs || false;
177
193
  if (docs && canonicalPath(docs) === canonicalPath(path))
178
194
  throw new Error(`[sashiko] openapi(): "docs" and "path" cannot both be "${path}".`);
179
- this.assertFreePaths('app.openapi()', docs ? [canonicalPath(path), canonicalPath(docs)] : [canonicalPath(path)], 'openapi');
195
+ this.framework.assertFreePaths('app.openapi()', docs ? [canonicalPath(path), canonicalPath(docs)] : [canonicalPath(path)], 'openapi');
180
196
  const pipeline = [...(opts.middlewares ?? [])];
181
197
  if (opts.guards?.length)
182
198
  pipeline.push(guardsMiddleware(opts.guards));
183
- this.openapiConfig = { opts, path, docs, pipeline, cache: new Map() };
199
+ this.framework.openapi = { opts, path, docs, pipeline, cache: new Map() };
184
200
  return this;
185
201
  }
186
202
  /**
@@ -194,456 +210,45 @@ export class SashikoApp {
194
210
  */
195
211
  health(opts = {}) {
196
212
  const config = compileHealth(opts, this.log);
197
- this.assertFreePaths('app.health()', config.paths, 'health');
198
- this.healthConfig = config;
213
+ this.framework.assertFreePaths('app.health()', config.paths, 'health');
214
+ this.framework.health = config;
199
215
  return this;
200
216
  }
201
- /** Paths servidos pelo próprio app (`health()`, `openapi()`), que encobririam uma rota GET do usuário. */
202
- reservedPaths(except) {
203
- const out = new Map();
204
- if (except !== 'health')
205
- for (const p of this.healthConfig?.paths ?? [])
206
- out.set(p, 'app.health()');
207
- const cfg = this.openapiConfig;
208
- if (cfg && except !== 'openapi') {
209
- out.set(canonicalPath(cfg.path), 'app.openapi()');
210
- if (cfg.docs)
211
- out.set(canonicalPath(cfg.docs), 'app.openapi({ docs })');
212
- }
213
- return out;
214
- }
215
- /** Um path do framework não pode coincidir com uma rota GET/HEAD registrada nem com outro do framework. */
216
- assertFreePaths(where, paths, kind) {
217
- // `health()`/`openapi()` configuram o app, não o grupo: dentro de um `group()` em andamento, a
218
- // configuração sobreviveria a um grupo desfeito e não enxergaria as rotas ainda pendentes.
219
- if (this.pending !== undefined) {
220
- throw new Error(`[sashiko] ${where}: cannot be called inside an app.group() callback; call it on the app, outside the group.`);
221
- }
222
- const reserved = this.reservedPaths(kind);
223
- for (const path of paths) {
224
- const other = reserved.get(path);
225
- if (other)
226
- throw new Error(`[sashiko] ${where}: path "${path}" is already used by ${other}.`);
227
- const route = this.routes.find((r) => (r.method === 'GET' || r.method === 'HEAD') && canonicalPath(r.path) === path);
228
- if (route) {
229
- throw new Error(`[sashiko] ${where}: path "${path}" would shadow ${route.owner}.${route.handlerName} (GET ${route.path}); choose another path.`);
230
- }
231
- }
232
- }
233
217
  /**
234
218
  * Documento OpenAPI 3.1 das rotas registradas até agora, para gravar em arquivo ou testar (F-16).
235
219
  * Com virtual hosts, é por host (F-35): `{ host: 'api.exemplo.com' }` traz as rotas desse host e as sem
236
220
  * host; sem `host`, só as sem host. Domínios diferentes nunca se misturam num mesmo documento.
237
221
  */
238
- openapiDocument(opts = this.openapiConfig?.opts ?? {}) {
239
- return generateOpenAPI(this.routesFor(opts.host?.toLowerCase()), opts, !this.opts.formatError);
240
- }
241
- /**
242
- * Rotas que o roteador **executaria** num host (F-35): para cada método e path, só a vencedora pela mesma
243
- * prioridade do roteador (exato → curinga mais específico → RegExp → sem host). Assim o OpenAPI documenta
244
- * exatamente o handler que responde. Sem `hostname`, só as rotas sem host.
245
- */
246
- routesFor(hostname) {
247
- const best = new Map();
248
- for (const [seq, route] of this.routes.entries()) {
249
- let rank = route.hosts.length === 0 ? NO_HOST_RANK : -1;
250
- if (rank === -1 && hostname !== undefined) {
251
- for (const h of route.hosts) {
252
- if (!h.test(hostname))
253
- continue;
254
- // Desempate pela ordem de registro, a mesma que o roteador usa em cada path.
255
- const r = hostRank(h, seq);
256
- if (rank === -1 || r < rank)
257
- rank = r;
258
- }
259
- }
260
- if (rank === -1)
261
- continue;
262
- const key = `${route.method} ${canonicalPath(route.path)}`;
263
- const current = best.get(key);
264
- if (!current || rank < current.rank)
265
- best.set(key, { route, rank });
266
- }
267
- return this.routes.filter((r) => best.get(`${r.method} ${canonicalPath(r.path)}`)?.route === r);
268
- }
269
- /** Se a requisição é para `/openapi.json` ou `/docs` (quando ligados), responde e devolve `true`. */
270
- async serveOpenAPI(ctx) {
271
- const cfg = this.openapiConfig;
272
- if (!cfg || (ctx.method !== 'GET' && ctx.method !== 'HEAD'))
273
- return false;
274
- const path = canonicalPath(ctx.path);
275
- const isSpec = path === canonicalPath(cfg.path);
276
- if (!isSpec && !(cfg.docs && path === canonicalPath(cfg.docs)))
277
- return false;
278
- await compose(ctx, [
279
- ...cfg.pipeline,
280
- () => {
281
- if (isSpec) {
282
- // Um documento por conjunto de rotas visíveis no host da requisição (F-35); a chave não usa o
283
- // hostname em si, então hosts arbitrários não fazem o cache crescer.
284
- const visible = this.routesFor(ctx.hostname.toLowerCase());
285
- const key = visible.map((r) => this.routes.indexOf(r)).join(',');
286
- let doc = cfg.cache.get(key);
287
- if (doc === undefined) {
288
- doc = JSON.stringify(generateOpenAPI(visible, cfg.opts, !this.opts.formatError));
289
- cfg.cache.set(key, doc);
290
- }
291
- ctx[kRawSend](doc, 'application/json; charset=utf-8');
292
- return;
293
- }
294
- const nonce = randomBytes(16).toString('base64');
295
- const { html, csp } = docsPage(cfg.path, cfg.opts.info?.title ?? 'API', nonce);
296
- ctx.res.setHeader('Content-Security-Policy', csp);
297
- ctx.res.setHeader('Cache-Control', 'no-store');
298
- ctx[kRawSend](html, 'text/html; charset=utf-8');
299
- },
300
- ]);
301
- return true;
302
- }
303
- /**
304
- * Primeira fase: valida as rotas (schemas obrigatórios, hosts alcançáveis, conflitos com o roteador e
305
- * dentro do próprio lote). Dentro de um `group()`, acumula; fora, efetiva na hora.
306
- */
307
- stage(owner, routes) {
308
- const batch = this.pending ?? [];
309
- // Checkpoint: dentro de um group(), um mount() que falha no meio não deixa parte dele no lote,
310
- // mesmo que o callback capture o erro e siga em frente.
311
- const mark = batch.length;
312
- try {
313
- for (const route of routes) {
314
- if (this.opts.requireSchemas)
315
- assertSchemas(owner, route);
316
- if (route.method === 'GET' || route.method === 'HEAD') {
317
- const reservedBy = this.reservedPaths().get(canonicalPath(route.path));
318
- if (reservedBy) {
319
- throw new Error(`[sashiko] app.mount(${owner}): GET ${route.path} is served by ${reservedBy} and would never be reached; choose another path.`);
320
- }
321
- }
322
- this.checkReachableHosts(owner, route);
323
- for (const host of route.hosts.length > 0 ? route.hosts.map((h) => h.key) : [undefined]) {
324
- const taken = this.router.has(route.method, route.path, host) ||
325
- batch.some((p) => p.route.method === route.method && canonicalPath(p.route.path) === canonicalPath(route.path) && p.hosts.includes(host));
326
- if (taken) {
327
- const where = host ? ` on host ${host}` : '';
328
- throw new Error(`[sashiko] app.mount(${owner}): ${route.method} ${route.path}${where} is already registered. ` +
329
- `Use a different "prefix" or "host" to mount the same class twice.`);
330
- }
331
- }
332
- batch.push({ owner, route, hosts: route.hosts.length > 0 ? route.hosts.map((h) => h.key) : [undefined] });
333
- }
334
- }
335
- catch (err) {
336
- batch.length = mark;
337
- throw err;
338
- }
339
- if (this.pending === undefined)
340
- this.commit(batch);
341
- }
342
- /**
343
- * Segunda fase: registra o lote no roteador. Se algo ainda falhar (um conflito que o `has()` não prevê),
344
- * desfaz o que já entrou, e nenhuma rota do lote fica registrada.
345
- */
346
- commit(batch) {
347
- const done = [];
348
- try {
349
- for (const { owner, route, hosts } of batch) {
350
- for (const host of hosts) {
351
- try {
352
- this.router.on(route.method, route.path, route, host);
353
- }
354
- catch (err) {
355
- throw new Error(`[sashiko] app.mount(${owner}): ${route.method} ${route.path} conflicts with a registered route.`, {
356
- cause: err,
357
- });
358
- }
359
- done.push({ method: route.method, path: route.path, host });
360
- }
361
- }
362
- }
363
- catch (err) {
364
- for (const r of done.reverse())
365
- this.router.off(r.method, r.path, r.host);
366
- throw err;
367
- }
368
- for (const { route } of batch)
369
- this.routes.push(route);
370
- if (this.openapiConfig && batch.length > 0)
371
- this.openapiConfig.cache.clear();
372
- }
373
- /**
374
- * Um host de rota sem interseção com o `allowedHosts` nunca seria alcançado: o app responde 400 antes do
375
- * roteamento (F-35). Comprovado: erro (ou aviso, com `unreachableHosts: 'warn'`). Sem como provar
376
- * (RegExp em algum dos lados): aviso.
377
- */
378
- checkReachableHosts(owner, route) {
379
- const mode = this.opts.unreachableHosts ?? 'error';
380
- const allowed = this.opts.allowedHosts;
381
- if (mode === 'off' || !allowed)
382
- return;
383
- for (const host of route.hosts) {
384
- const verdict = hostReachability(host, allowed);
385
- if (verdict === 'yes')
386
- continue;
387
- const where = `app.mount(${owner}): ${route.method} ${route.path} uses host ${host.label}`;
388
- if (verdict === 'no' && mode === 'error') {
389
- throw new Error(`[sashiko] ${where}, which no allowedHosts rule accepts; the route is unreachable. Add it to allowedHosts, or set unreachableHosts: 'warn'.`);
390
- }
391
- const key = `${owner} ${host.key}`;
392
- if (this.warnedHosts.has(key))
393
- continue;
394
- this.warnedHosts.add(key);
395
- this.log.warn({ host: host.label }, verdict === 'no'
396
- ? `[sashiko] ${where}, which no allowedHosts rule accepts; the route is unreachable.`
397
- : `[sashiko] ${where}; could not verify it against allowedHosts (RegExp).`);
398
- }
222
+ openapiDocument(opts = this.framework.openapi?.opts ?? {}) {
223
+ return generateOpenAPI(this.registry.routesFor(opts.host?.toLowerCase()), opts, !this.core.opts.formatError);
399
224
  }
400
- async handle(ctx) {
401
- // Antes de tudo, para valer inclusive em erros, 404 e preflight (S-04).
402
- this.applySecurityHeaders?.(ctx.res);
403
- try {
404
- // Healthchecks antes dos middlewares globais: sondas do load balancer não têm credenciais.
405
- if (this.healthConfig && (await this.healthConfig.serve(ctx, this.draining || this.closing !== undefined)))
406
- return;
407
- await compose(ctx, this.middlewares);
408
- if (ctx.sent || (await this.serveOpenAPI(ctx)))
409
- return;
410
- // F-35: hosts não diferenciam maiúsculas; as regras já são compiladas em minúsculas.
411
- const hostname = ctx.hostname.toLowerCase();
412
- const match = this.router.find(ctx.method, ctx.path, hostname);
413
- if (!match) {
414
- const allowed = this.router.allowedMethods(ctx.path, hostname);
415
- if (allowed.length > 0) {
416
- ctx.res.setHeader('Allow', allowed.join(', '));
417
- // F-08: OPTIONS automático para qualquer path com rotas.
418
- if (ctx.method === 'OPTIONS')
419
- return void ctx.status(204).res.end();
420
- return void sendProblem(ctx, problem(405, undefined, { code: 'METHOD_NOT_ALLOWED', details: { allowed } }), this.opts.formatError);
421
- }
422
- return void sendProblem(ctx, problem(404, undefined, { code: 'NOT_FOUND' }), this.opts.formatError);
423
- }
424
- ctx.params = match.params;
425
- // F-01: middlewares → guards → validação → handler. Guards antes da validação, para que
426
- // quem não tem acesso receba 401/403 e não um 400 que revele a estrutura esperada.
427
- const route = match;
428
- const pipeline = [...route.middlewares];
429
- if (route.guards.length > 0)
430
- pipeline.push(guardsMiddleware(route.guards));
431
- pipeline.push(async () => {
432
- validateRequest(ctx, route.schemas);
433
- // F-32: a partir daqui, ctx.json/send/text passam pelo mesmo pipeline do `return`.
434
- ctx[kBindResponse](route.schemas.response, this.serializers);
435
- const result = await route.handler(ctx);
436
- if (!ctx.sent)
437
- sendResult(ctx, route.schemas.response, result, this.serializers);
438
- });
439
- await compose(ctx, pipeline);
440
- }
441
- catch (err) {
442
- await this.runErrorHandlers(err, ctx);
443
- }
444
- }
445
- /**
446
- * Executa os `onError` do usuário. Se um deles lançar, o erro é logado e seguimos
447
- * para a resposta padrão com o erro **original** — nunca deixamos a Promise rejeitar (B-08).
448
- */
449
- async runErrorHandlers(err, ctx) {
450
- for (const h of this.errorHandlers) {
451
- try {
452
- await h(err, ctx);
453
- }
454
- catch (handlerErr) {
455
- ctx.log.error({ err: handlerErr, method: ctx.method, path: ctx.path }, 'onError handler failed');
456
- break;
457
- }
458
- if (ctx.res.writableEnded)
459
- return;
460
- }
461
- sendError(ctx, err, this.opts.formatError);
225
+ /** Executa o pipeline completo para um contexto já criado. */
226
+ handle(ctx) {
227
+ return this.requests.handle(ctx);
462
228
  }
463
229
  /**
464
230
  * Listener `(req, res)` do app, para usar com qualquer servidor do Node:
465
231
  * `https.createServer(opts, app.callback())`, `http2` em modo compat, testes etc. (F-09).
466
232
  */
467
233
  callback() {
468
- const rid = this.opts.requestId ?? {};
469
- const header = rid.header === false ? undefined : (rid.header ?? 'x-request-id').toLowerCase();
470
- const trust = rid.trustIncoming ?? true;
471
- const generate = rid.generate ?? (() => randomUUID());
472
- return (req, res) => {
473
- const started = performance.now();
474
- this.inflight.add(res);
475
- if (this.closing)
476
- res.setHeader('Connection', 'close');
477
- res.once('close', () => {
478
- this.inflight.delete(res);
479
- // Durante o close(), cada resposta concluída libera as conexões que ficaram ociosas.
480
- this.closing?.closeIdleConnections();
481
- });
482
- const incoming = header ? req.headers[header] : undefined;
483
- const id = trust && typeof incoming === 'string' && SAFE_REQUEST_ID.test(incoming) ? incoming : generate();
484
- if (header)
485
- res.setHeader(header, id);
486
- // Última barreira (B-08): nada que aconteça numa requisição pode virar unhandledRejection.
487
- const fail = (err) => {
488
- this.log.error({ err, reqId: id, method: req.method, url: req.url }, 'unhandled error while handling request');
489
- if (!res.headersSent)
490
- res.statusCode = 500;
491
- if (!res.writableEnded)
492
- res.end();
493
- };
494
- try {
495
- const ctx = new Context(req, res, {
496
- queryMaxKeys: this.opts.queryMaxKeys,
497
- id,
498
- logger: this.log,
499
- trustProxy: this.opts.trustProxy,
500
- onSend: this.onSendHooks,
501
- });
502
- // S-09: Host ausente, malformado ou fora da lista permitida → 400, antes de qualquer middleware.
503
- // Exceção: healthchecks aceitam qualquer host válido, porque sondas chegam com o IP do pod.
504
- const healthProbe = this.healthConfig?.bypassesHostCheck(ctx.method, ctx.path) ?? false;
505
- if (!ctx.host || (this.allowedHosts && !healthProbe && !this.allowedHosts(ctx.hostname, ctx.host))) {
506
- this.applySecurityHeaders?.(res);
507
- sendProblem(ctx, problem(400, 'Invalid Host header', { code: 'INVALID_HOST' }), this.opts.formatError);
508
- return;
509
- }
510
- if (this.opts.logRequests || this.onResponseHooks.length > 0) {
511
- res.once('finish', () => {
512
- const ms = Math.round((performance.now() - started) * 100) / 100;
513
- if (this.opts.logRequests) {
514
- ctx.log.info({ method: ctx.method, path: ctx.path, status: res.statusCode, ms }, 'request completed');
515
- }
516
- for (const hook of this.onResponseHooks) {
517
- Promise.resolve()
518
- .then(() => hook(ctx, { status: res.statusCode, ms }))
519
- .catch((err) => ctx.log.error({ err }, 'onResponse hook failed'));
520
- }
521
- });
522
- }
523
- this.handle(ctx).catch(fail);
524
- }
525
- catch (err) {
526
- fail(err);
527
- }
528
- };
234
+ return this.requests.callback();
529
235
  }
530
- async listen(options = {}) {
531
- if (this.server)
532
- throw new Error('[sashiko] app is already listening; call app.close() first.');
533
- const { port = 3000, host = '127.0.0.1', tls } = typeof options === 'number' ? { port: options } : options;
534
- let server;
535
- if (tls) {
536
- const sni = { current: { find: () => undefined } };
537
- const compiled = compileTls(tls, sni);
538
- server = https.createServer(compiled.options, this.callback());
539
- this.tlsState = { sni };
540
- }
541
- else {
542
- server = http.createServer(this.callback());
543
- this.tlsState = undefined;
544
- }
545
- const t = this.opts.timeouts ?? {};
546
- server.requestTimeout = t.requestTimeout ?? 30_000;
547
- server.headersTimeout = Math.min(t.headersTimeout ?? 10_000, server.requestTimeout || Infinity);
548
- server.keepAliveTimeout = t.keepAliveTimeout ?? 5_000;
549
- await new Promise((resolve, reject) => {
550
- server.once('error', reject);
551
- server.listen(port, host, () => {
552
- server.off('error', reject);
553
- resolve();
554
- });
555
- });
556
- this.server = server;
557
- this.closeHooksPending = true;
558
- this.draining = false;
559
- if (this.opts.shutdown !== false)
560
- shutdownCoordinator.register(this.shutdownTarget);
561
- return server;
236
+ listen(options = {}) {
237
+ return this.runtime.listen(options);
562
238
  }
563
239
  /**
564
240
  * Troca os certificados sem reiniciar nem derrubar conexões (renovação, ACME externo). Conexões novas
565
241
  * usam os novos; as abertas seguem com os antigos. Um certificado inválido lança erro e mantém os atuais.
566
242
  */
567
243
  reloadTLS(tls) {
568
- const server = this.server;
569
- if (!server || !this.tlsState || !(server instanceof https.Server)) {
570
- throw new Error('[sashiko] reloadTLS(): the app is not listening with { tls }.');
571
- }
572
- const next = { current: this.tlsState.sni.current };
573
- const compiled = compileTls(tls, next); // valida tudo antes de trocar
574
- if (compiled.main)
575
- server.setSecureContext(compiled.main);
576
- this.tlsState.sni.current = next.current;
244
+ this.runtime.reloadTLS(tls);
577
245
  }
578
- /** Ponte entre este app e o coordenador de sinais (F-07). */
579
- shutdownTarget = {
580
- log: undefined, // definido no construtor
581
- signals: [],
582
- exitOnShutdown: true,
583
- shutdown: async (signal) => {
584
- const opts = this.opts.shutdown || {};
585
- try {
586
- this.draining = true;
587
- if (opts.drainDelayMs) {
588
- this.log.info({ signal, drainDelayMs: opts.drainDelayMs }, 'draining before shutdown');
589
- await new Promise((r) => setTimeout(r, opts.drainDelayMs));
590
- }
591
- await this.close(opts.timeoutMs ?? 10_000);
592
- await opts.onShutdown?.(signal);
593
- return true;
594
- }
595
- catch (err) {
596
- this.log.error({ err, signal }, 'error during graceful shutdown');
597
- return false;
598
- }
599
- },
600
- };
601
246
  /**
602
247
  * Para de aceitar conexões, encerra as ociosas e espera as requisições em andamento terminarem.
603
248
  * Depois de `graceMs`, força o fechamento das restantes.
604
249
  */
605
- async close(graceMs = 10_000) {
606
- const server = this.server;
607
- if (!server)
608
- return this.runCloseHooks();
609
- this.server = undefined;
610
- this.closing = server;
611
- // Respostas que ainda não enviaram headers avisam o cliente para não reusar a conexão.
612
- for (const res of this.inflight)
613
- if (!res.headersSent)
614
- res.setHeader('Connection', 'close');
615
- const force = setTimeout(() => server.closeAllConnections(), graceMs);
616
- force.unref();
617
- try {
618
- await new Promise((resolve, reject) => {
619
- server.close((err) => (err ? reject(err) : resolve()));
620
- server.closeIdleConnections();
621
- });
622
- }
623
- finally {
624
- clearTimeout(force);
625
- this.closing = undefined;
626
- shutdownCoordinator.unregister(this.shutdownTarget);
627
- }
628
- await this.runCloseHooks();
629
- }
630
- /** `onClose` uma vez por ciclo (até o próximo `listen`); todos rodam mesmo se algum falhar (F-04). */
631
- async runCloseHooks() {
632
- if (!this.closeHooksPending)
633
- return;
634
- this.closeHooksPending = false;
635
- const errors = [];
636
- for (const hook of this.onCloseHooks) {
637
- try {
638
- await hook();
639
- }
640
- catch (err) {
641
- errors.push(err);
642
- this.log.error({ err }, 'onClose hook failed');
643
- }
644
- }
645
- if (errors.length > 0)
646
- throw new AggregateError(errors, `[sashiko] ${errors.length} onClose hook(s) failed`);
250
+ close(graceMs = 10_000) {
251
+ return this.runtime.close(graceMs);
647
252
  }
648
253
  /** Executa uma requisição em memória, sem rede. Ideal para testes (F-09). */
649
254
  inject(opts) {