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.
- package/README.md +74 -1
- 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 +34 -175
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +101 -496
- 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/compiler.d.ts +2 -0
- package/dist/core/compiler.d.ts.map +1 -1
- package/dist/core/compiler.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 +61 -14
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +162 -39
- package/dist/core/context.js.map +1 -1
- package/dist/core/health.js +1 -1
- package/dist/core/index.d.ts +3 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/inject.d.ts.map +1 -1
- package/dist/core/inject.js +6 -0
- package/dist/core/inject.js.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/mounted-group.d.ts +5 -0
- package/dist/core/mounted-group.d.ts.map +1 -1
- package/dist/core/mounted-group.js +10 -0
- package/dist/core/mounted-group.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/response.d.ts.map +1 -1
- package/dist/core/response.js +25 -0
- package/dist/core/response.js.map +1 -1
- 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/openapi/generate.d.ts.map +1 -1
- package/dist/openapi/generate.js +2 -0
- package/dist/openapi/generate.js.map +1 -1
- package/dist/server/static.d.ts +45 -0
- package/dist/server/static.d.ts.map +1 -0
- package/dist/server/static.js +204 -0
- package/dist/server/static.js.map +1 -0
- 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/dist/utils/mime.d.ts +2 -0
- package/dist/utils/mime.d.ts.map +1 -0
- package/dist/utils/mime.js +52 -0
- package/dist/utils/mime.js.map +1 -0
- 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 {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
37
|
+
this.core = {
|
|
38
|
+
opts,
|
|
68
39
|
log: this.log,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
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) {
|