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