@blamejs/core 0.7.1 → 0.7.18
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/CHANGELOG.md +423 -389
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -282
- package/lib/api-key.js +660 -672
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -266
- package/lib/cache.js +1206 -1211
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +1136 -0
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -202
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -573
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -269
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -614
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -451
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -479
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -855
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -177
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -752
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -489
- package/lib/request-helpers.js +340 -336
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -630
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +879 -114
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -270
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -598
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/router.js
CHANGED
|
@@ -1,748 +1,748 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Custom HTTP router — zero-dependency replacement for express/koa/fastify.
|
|
4
|
-
*
|
|
5
|
-
* Why rolled-our-own: blamejs principle #1 forbids npm runtime dependencies.
|
|
6
|
-
* This router covers what a route concretely requires (path params,
|
|
7
|
-
* middleware chain, static file serving, MIME sniffing) and leaves no
|
|
8
|
-
* attack surface we haven't read.
|
|
9
|
-
*
|
|
10
|
-
* Middleware / handler dispatch (see roadmap "Naming conventions" — verb
|
|
11
|
-
* conventions section, "on/off/emit" vs explicit chain control):
|
|
12
|
-
* - handler.length >= 3 → treated as middleware. Chain stops unless the
|
|
13
|
-
* handler calls next(). Using 2-arg handlers as middleware is
|
|
14
|
-
* structurally fragile and will silently fall through.
|
|
15
|
-
* - handler.length <= 2 → terminal handler. Always falls through to the
|
|
16
|
-
* next entry in the chain if it doesn't end the response.
|
|
17
|
-
*
|
|
18
|
-
* Patterns are compiled ONCE at registration time (compilePattern) — no
|
|
19
|
-
* regex construction on the hot path. Route table is scanned linearly;
|
|
20
|
-
* ordering matters (first match wins).
|
|
21
|
-
*/
|
|
22
|
-
var http = require("http");
|
|
23
|
-
var http2 = require("http2");
|
|
24
|
-
var fs = require("fs");
|
|
25
|
-
var path = require("path");
|
|
26
|
-
var C = require("./constants");
|
|
27
|
-
var requestHelpers = require("./request-helpers");
|
|
28
|
-
var safeAsync = require("./safe-async");
|
|
29
|
-
var safeEnv = require("./parsers/safe-env");
|
|
30
|
-
var safeUrl = require("./safe-url");
|
|
31
|
-
var websocket = require("./websocket");
|
|
32
|
-
var { boot } = require("./log");
|
|
33
|
-
|
|
34
|
-
var log = boot("router");
|
|
35
|
-
var HTTP_STATUS = requestHelpers.HTTP_STATUS;
|
|
36
|
-
|
|
37
|
-
// Cap on operator-defined route patterns. A route registration that
|
|
38
|
-
// somehow attracts a multi-megabyte template string would stall regex
|
|
39
|
-
// compilation; bound it before new RegExp() so the gate at registration
|
|
40
|
-
// time is bounded.
|
|
41
|
-
var MAX_ROUTE_PATTERN_LEN = C.BYTES.kib(1);
|
|
42
|
-
|
|
43
|
-
// ---- Schema-spec helpers (route-level body/query/params validation) ----
|
|
44
|
-
|
|
45
|
-
var ALLOWED_SPEC_KEYS = [
|
|
46
|
-
"body", "query", "params", "response",
|
|
47
|
-
"bodyJsonSchema", "queryJsonSchema", "paramsJsonSchema", "responseJsonSchema",
|
|
48
|
-
"description", "summary", "tags", "validateResponse",
|
|
49
|
-
];
|
|
50
|
-
|
|
51
|
-
function _validateRouteSpec(spec, method, pattern) {
|
|
52
|
-
var keys = Object.keys(spec);
|
|
53
|
-
for (var i = 0; i < keys.length; i++) {
|
|
54
|
-
if (ALLOWED_SPEC_KEYS.indexOf(keys[i]) === -1) {
|
|
55
|
-
throw new Error("router." + method.toLowerCase() + "(" + pattern +
|
|
56
|
-
"): unknown spec key '" + keys[i] + "'. Allowed: " +
|
|
57
|
-
ALLOWED_SPEC_KEYS.slice().sort().join(", "));
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
function _checkSchema(name) {
|
|
61
|
-
var s = spec[name];
|
|
62
|
-
if (s === undefined) return;
|
|
63
|
-
if (!s || typeof s !== "object" || typeof s.safeParse !== "function") {
|
|
64
|
-
throw new Error("router." + method.toLowerCase() + "(" + pattern +
|
|
65
|
-
"): spec." + name + " must be a b.safeSchema-shaped schema (with safeParse)");
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
_checkSchema("body");
|
|
69
|
-
_checkSchema("query");
|
|
70
|
-
_checkSchema("params");
|
|
71
|
-
_checkSchema("response");
|
|
72
|
-
if (spec.tags !== undefined) {
|
|
73
|
-
if (!Array.isArray(spec.tags) || !spec.tags.every(function (t) { return typeof t === "string"; })) {
|
|
74
|
-
throw new Error("router." + method.toLowerCase() + "(" + pattern +
|
|
75
|
-
"): spec.tags must be an array of strings");
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function _writeValidationError(res, where, errors) {
|
|
81
|
-
if (res.writableEnded || res.headersSent) return;
|
|
82
|
-
var body = JSON.stringify({
|
|
83
|
-
error: "validation",
|
|
84
|
-
where: where,
|
|
85
|
-
issues: errors,
|
|
86
|
-
});
|
|
87
|
-
res.writeHead(HTTP_STATUS.BAD_REQUEST, {
|
|
88
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
89
|
-
"Content-Length": Buffer.byteLength(body),
|
|
90
|
-
});
|
|
91
|
-
res.end(body);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function _makeSchemaValidator(spec) {
|
|
95
|
-
// 3-arg signature → router treats as middleware, chains via next().
|
|
96
|
-
return function schemaValidator(req, res, next) {
|
|
97
|
-
if (spec.params && req.params !== undefined) {
|
|
98
|
-
var pp = spec.params.safeParse(req.params);
|
|
99
|
-
if (!pp.ok) return _writeValidationError(res, "params", pp.errors);
|
|
100
|
-
req.params = pp.value;
|
|
101
|
-
}
|
|
102
|
-
if (spec.query && req.query !== undefined) {
|
|
103
|
-
var qq = spec.query.safeParse(req.query);
|
|
104
|
-
if (!qq.ok) return _writeValidationError(res, "query", qq.errors);
|
|
105
|
-
req.query = qq.value;
|
|
106
|
-
}
|
|
107
|
-
if (spec.body && req.body !== undefined) {
|
|
108
|
-
var bb = spec.body.safeParse(req.body);
|
|
109
|
-
if (!bb.ok) return _writeValidationError(res, "body", bb.errors);
|
|
110
|
-
req.body = bb.value;
|
|
111
|
-
}
|
|
112
|
-
next();
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function _makeResponseValidator(spec) {
|
|
117
|
-
// Wraps res.json (and res.end when called with a JSON-shaped buffer)
|
|
118
|
-
// to validate the response body against spec.response. Mode:
|
|
119
|
-
// - BLAMEJS_VALIDATE_RESPONSES=throw (or per-route validateResponse: "throw")
|
|
120
|
-
// → throw a SafeSchemaError-shaped error; route handler's caller sees a 500.
|
|
121
|
-
// - BLAMEJS_VALIDATE_RESPONSES=warn (or per-route validateResponse: "warn")
|
|
122
|
-
// → log a warning; ship the response as-is (prod-safe).
|
|
123
|
-
var perRoute = spec.validateResponse;
|
|
124
|
-
var globalMode = safeEnv.readVar("BLAMEJS_VALIDATE_RESPONSES");
|
|
125
|
-
var mode = (perRoute === "throw" || perRoute === "warn") ? perRoute :
|
|
126
|
-
(globalMode === "throw" || globalMode === "warn") ? globalMode : null;
|
|
127
|
-
if (!mode) return function passthrough(_req, _res, next) { next(); };
|
|
128
|
-
|
|
129
|
-
return function responseValidator(req, res, next) {
|
|
130
|
-
var origJson = typeof res.json === "function" ? res.json.bind(res) : null;
|
|
131
|
-
if (origJson) {
|
|
132
|
-
res.json = function (value) {
|
|
133
|
-
var rr = spec.response.safeParse(value);
|
|
134
|
-
if (!rr.ok) {
|
|
135
|
-
if (mode === "throw") {
|
|
136
|
-
throw new Error("router response-validation failed for " +
|
|
137
|
-
(req.method + " " + req.routePattern) + ": " +
|
|
138
|
-
JSON.stringify(rr.errors));
|
|
139
|
-
}
|
|
140
|
-
// warn mode
|
|
141
|
-
log.warn("response-validation drift on " + req.method + " " + req.routePattern +
|
|
142
|
-
": " + JSON.stringify(rr.errors).slice(0, 500));
|
|
143
|
-
}
|
|
144
|
-
return origJson(value);
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
next();
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function compilePattern(pattern) {
|
|
152
|
-
// pattern is operator-supplied at route registration (router.get(...)).
|
|
153
|
-
// Cap length up-front to bound matcher work even against pathological
|
|
154
|
-
// operator config.
|
|
155
|
-
if (typeof pattern !== "string" || pattern.length === 0) {
|
|
156
|
-
throw new Error("router: pattern must be a non-empty string");
|
|
157
|
-
}
|
|
158
|
-
if (pattern.length > MAX_ROUTE_PATTERN_LEN) {
|
|
159
|
-
throw new Error("router: pattern exceeds " + MAX_ROUTE_PATTERN_LEN +
|
|
160
|
-
" chars (got " + pattern.length + ")");
|
|
161
|
-
}
|
|
162
|
-
// Segment-based matcher — splits on "/" once at registration time and
|
|
163
|
-
// walks token-by-token at match time. Avoids compiling a RegExp from
|
|
164
|
-
// the operator-supplied pattern (which would be a dynamic-regex /
|
|
165
|
-
// ReDoS shape even though pattern is operator-controlled).
|
|
166
|
-
//
|
|
167
|
-
// Each segment is either:
|
|
168
|
-
// - a literal (e.g. "users", "v1")
|
|
169
|
-
// - a named parameter ":id" — captures into params[name]
|
|
170
|
-
//
|
|
171
|
-
// Matching is exact on segment count: "/a/b" does not match "/a/b/c".
|
|
172
|
-
var rawSegments = pattern.split("/");
|
|
173
|
-
var segments = [];
|
|
174
|
-
var keys = [];
|
|
175
|
-
for (var si = 0; si < rawSegments.length; si++) {
|
|
176
|
-
var seg = rawSegments[si];
|
|
177
|
-
if (seg.length > 0 && seg.charAt(0) === ":") {
|
|
178
|
-
var key = seg.slice(1);
|
|
179
|
-
if (key.length === 0) {
|
|
180
|
-
throw new Error("router: pattern '" + pattern +
|
|
181
|
-
"' has an empty parameter name (':' segment)");
|
|
182
|
-
}
|
|
183
|
-
keys.push(key);
|
|
184
|
-
segments.push({ literal: false, key: key });
|
|
185
|
-
} else {
|
|
186
|
-
segments.push({ literal: true, value: seg });
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
return { pattern: pattern, segments: segments, keys: keys };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Walk a request path against a compiled pattern. Returns the params
|
|
193
|
-
// object on match, null otherwise. Single non-empty trailing slash
|
|
194
|
-
// difference is treated as a no-match (callers that want trailing-slash
|
|
195
|
-
// tolerance normalize the path before dispatch).
|
|
196
|
-
function _matchCompiled(compiled, pathname) {
|
|
197
|
-
var pathSegments = pathname.split("/");
|
|
198
|
-
var patSegments = compiled.segments;
|
|
199
|
-
if (pathSegments.length !== patSegments.length) return null;
|
|
200
|
-
var params = {};
|
|
201
|
-
for (var i = 0; i < patSegments.length; i++) {
|
|
202
|
-
var seg = patSegments[i];
|
|
203
|
-
if (seg.literal) {
|
|
204
|
-
if (pathSegments[i] !== seg.value) return null;
|
|
205
|
-
} else {
|
|
206
|
-
// Named param: must be non-empty (mirrors the regex `[^/]+`
|
|
207
|
-
// capture from the previous compiled regex).
|
|
208
|
-
if (pathSegments[i].length === 0) return null;
|
|
209
|
-
params[seg.key] = pathSegments[i];
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return params;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
var MIME_TYPES = {
|
|
216
|
-
".html": "text/html",
|
|
217
|
-
".css": "text/css",
|
|
218
|
-
".js": "application/javascript",
|
|
219
|
-
".json": "application/json",
|
|
220
|
-
".png": "image/png",
|
|
221
|
-
".jpg": "image/jpeg",
|
|
222
|
-
".jpeg": "image/jpeg",
|
|
223
|
-
".gif": "image/gif",
|
|
224
|
-
".svg": "image/svg+xml",
|
|
225
|
-
".ico": "image/x-icon",
|
|
226
|
-
".woff2": "font/woff2",
|
|
227
|
-
".woff": "font/woff",
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
class Router {
|
|
231
|
-
constructor() {
|
|
232
|
-
this.routes = [];
|
|
233
|
-
this.middleware = [];
|
|
234
|
-
// WebSocket routes are kept separate from HTTP routes — they're
|
|
235
|
-
// matched on the upgrade / Extended CONNECT path, not on a method
|
|
236
|
-
// verb. Map<path, { handler, opts }>.
|
|
237
|
-
this._wsRoutes = new Map();
|
|
238
|
-
// Active WebSocket connections opened through router.ws(). Tracked
|
|
239
|
-
// so router.closeWebSockets() can do a clean rolling-shutdown.
|
|
240
|
-
// h1-upgrade detaches the socket from http.Server's connection
|
|
241
|
-
// tracking — without our own registry there's no other way to
|
|
242
|
-
// enumerate active WS connections for graceful close.
|
|
243
|
-
this._activeWsConns = new Set();
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Active WebSocket connections opened via router.ws(). Useful for
|
|
247
|
-
// ops dashboards / health endpoints.
|
|
248
|
-
activeWebSockets() {
|
|
249
|
-
return this._activeWsConns.size;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Graceful shutdown of all WebSocket connections opened via
|
|
253
|
-
// router.ws(). Sends close frame (code 1001 'going away') to each;
|
|
254
|
-
// awaits each connection's 'close' event up to opts.timeoutMs
|
|
255
|
-
// (default 5s). Returns the count of connections closed.
|
|
256
|
-
//
|
|
257
|
-
// Operators call this during rolling deploy:
|
|
258
|
-
// await router.closeWebSockets({ timeoutMs: 10_000 });
|
|
259
|
-
// await new Promise(r => server.close(r));
|
|
260
|
-
// process.exit(0);
|
|
261
|
-
//
|
|
262
|
-
// Tests use the same primitive in teardown — no parallel cleanup
|
|
263
|
-
// path, no h1-upgrade detached-socket workaround.
|
|
264
|
-
async closeWebSockets(opts) {
|
|
265
|
-
opts = opts || {};
|
|
266
|
-
var timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : C.TIME.seconds(5);
|
|
267
|
-
var code = opts.code || 1001; // 1001 = going away
|
|
268
|
-
var reason = opts.reason || "server shutting down";
|
|
269
|
-
|
|
270
|
-
var conns = Array.from(this._activeWsConns);
|
|
271
|
-
if (conns.length === 0) return 0;
|
|
272
|
-
|
|
273
|
-
var closes = conns.map(function (conn) {
|
|
274
|
-
return new Promise(function (resolve) {
|
|
275
|
-
if (conn.readyState === "closed") { resolve(); return; }
|
|
276
|
-
conn.once("close", resolve);
|
|
277
|
-
try { conn.close(code, reason); }
|
|
278
|
-
catch (_e) {
|
|
279
|
-
// close() may throw if already closed; resolve immediately
|
|
280
|
-
// since the 'close' event won't fire again.
|
|
281
|
-
resolve();
|
|
282
|
-
}
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
// Wait up to `timeoutMs` for graceful WS closes, then force-destroy
|
|
287
|
-
// any laggards. `safeAsync.sleep({ unref: true })` matches the
|
|
288
|
-
// framework's outbound-timeout convention.
|
|
289
|
-
await Promise.race([
|
|
290
|
-
Promise.all(closes),
|
|
291
|
-
safeAsync.sleep(timeoutMs, { unref: true }),
|
|
292
|
-
]);
|
|
293
|
-
// Force-destroy any laggards — at this point we've waited the full
|
|
294
|
-
// timeout and they didn't ack. The operator chose timeoutMs; honor it.
|
|
295
|
-
this._activeWsConns.forEach(function (conn) {
|
|
296
|
-
try { if (conn.socket && conn.socket.destroy) conn.socket.destroy(); }
|
|
297
|
-
catch (_e) { /* socket already destroyed */ }
|
|
298
|
-
});
|
|
299
|
-
return conns.length;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
use(fn) {
|
|
303
|
-
this.middleware.push(fn);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Internal: split a route registration's args into { spec, handlers }.
|
|
307
|
-
// The first non-pattern arg is the schema spec when it's a plain object
|
|
308
|
-
// (not a function); subsequent args are handler middlewares. Operators
|
|
309
|
-
// who never pass a spec keep the existing two-arg shape working.
|
|
310
|
-
_splitArgs(args) {
|
|
311
|
-
if (args.length > 0 && args[0] && typeof args[0] === "object" &&
|
|
312
|
-
!Array.isArray(args[0]) && typeof args[0] !== "function") {
|
|
313
|
-
return { spec: args[0], handlers: args.slice(1) };
|
|
314
|
-
}
|
|
315
|
-
return { spec: null, handlers: args };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
_registerRoute(method, pattern, args) {
|
|
319
|
-
var split = this._splitArgs(args);
|
|
320
|
-
if (split.spec) _validateRouteSpec(split.spec, method, pattern);
|
|
321
|
-
var handlers = split.handlers;
|
|
322
|
-
if (split.spec) {
|
|
323
|
-
// Pre-handler validates body / query / params. Runs after the
|
|
324
|
-
// global middleware chain (bodyParser populates req.body before
|
|
325
|
-
// route dispatch) but before any route-specific handler.
|
|
326
|
-
handlers = [_makeSchemaValidator(split.spec)].concat(handlers);
|
|
327
|
-
// Response validation (dev/opt-in via env or per-route opt).
|
|
328
|
-
var globalValidateMode = safeEnv.readVar("BLAMEJS_VALIDATE_RESPONSES");
|
|
329
|
-
if (split.spec.response &&
|
|
330
|
-
(globalValidateMode === "throw" ||
|
|
331
|
-
globalValidateMode === "warn" ||
|
|
332
|
-
split.spec.validateResponse)) {
|
|
333
|
-
handlers = [_makeResponseValidator(split.spec)].concat(handlers);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
this.routes.push(Object.assign(
|
|
337
|
-
{ method: method, handlers: handlers, spec: split.spec || null },
|
|
338
|
-
compilePattern(pattern)
|
|
339
|
-
));
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
get(pattern, ...args) {
|
|
343
|
-
this._registerRoute("GET", pattern, args);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
post(pattern, ...args) {
|
|
347
|
-
this._registerRoute("POST", pattern, args);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
put(pattern, ...args) {
|
|
351
|
-
this._registerRoute("PUT", pattern, args);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
patch(pattern, ...args) {
|
|
355
|
-
this._registerRoute("PATCH", pattern, args);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
delete(pattern, ...args) {
|
|
359
|
-
this._registerRoute("DELETE", pattern, args);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Operator-facing introspection — returns a copy of the route table
|
|
363
|
-
// with each entry's method, pattern, description, and (when provided)
|
|
364
|
-
// operator-supplied jsonSchema bodies for OpenAPI publication.
|
|
365
|
-
inspectRoutes() {
|
|
366
|
-
return this.routes
|
|
367
|
-
.filter(function (r) { return typeof r.method === "string"; })
|
|
368
|
-
.map(function (r) {
|
|
369
|
-
return {
|
|
370
|
-
method: r.method,
|
|
371
|
-
pattern: r.pattern,
|
|
372
|
-
description: r.spec ? r.spec.description || null : null,
|
|
373
|
-
spec: r.spec ? {
|
|
374
|
-
hasBodySchema: !!r.spec.body,
|
|
375
|
-
hasQuerySchema: !!r.spec.query,
|
|
376
|
-
hasParamsSchema: !!r.spec.params,
|
|
377
|
-
hasResponseSchema: !!r.spec.response,
|
|
378
|
-
bodyJsonSchema: r.spec.bodyJsonSchema || null,
|
|
379
|
-
queryJsonSchema: r.spec.queryJsonSchema || null,
|
|
380
|
-
paramsJsonSchema: r.spec.paramsJsonSchema || null,
|
|
381
|
-
responseJsonSchema: r.spec.responseJsonSchema || null,
|
|
382
|
-
tags: Array.isArray(r.spec.tags) ? r.spec.tags.slice() : [],
|
|
383
|
-
summary: r.spec.summary || null,
|
|
384
|
-
} : null,
|
|
385
|
-
};
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// openapi(opts?) → minimal Swagger 3.0 document covering every
|
|
390
|
-
// schema-spec'd route. Body / query / params show up as parameter
|
|
391
|
-
// entries when the operator supplies bodyJsonSchema / etc. (a
|
|
392
|
-
// safeSchema → JSON Schema converter is its own primitive — operators
|
|
393
|
-
// who want full schema bodies in the OpenAPI doc supply the JSON
|
|
394
|
-
// Schema alongside the safeSchema today).
|
|
395
|
-
openapi(opts) {
|
|
396
|
-
opts = opts || {};
|
|
397
|
-
var info = opts.info || { title: "blamejs app", version: "0.0.0" };
|
|
398
|
-
var paths = {};
|
|
399
|
-
var routes = this.inspectRoutes();
|
|
400
|
-
for (var i = 0; i < routes.length; i++) {
|
|
401
|
-
var r = routes[i];
|
|
402
|
-
var openapiPath = r.pattern.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
403
|
-
if (!paths[openapiPath]) paths[openapiPath] = {};
|
|
404
|
-
var op = {
|
|
405
|
-
summary: r.spec ? r.spec.summary || r.description || (r.method + " " + r.pattern) :
|
|
406
|
-
(r.method + " " + r.pattern),
|
|
407
|
-
description: r.description || null,
|
|
408
|
-
};
|
|
409
|
-
if (r.spec) {
|
|
410
|
-
op.tags = r.spec.tags;
|
|
411
|
-
var params = [];
|
|
412
|
-
// Path params from pattern
|
|
413
|
-
var pathParams = (r.pattern.match(/:[a-zA-Z0-9_]+/g) || [])
|
|
414
|
-
.map(function (s) { return s.slice(1); });
|
|
415
|
-
for (var pp = 0; pp < pathParams.length; pp++) {
|
|
416
|
-
params.push({ name: pathParams[pp], in: "path", required: true,
|
|
417
|
-
schema: { type: "string" } });
|
|
418
|
-
}
|
|
419
|
-
if (r.spec.queryJsonSchema && r.spec.queryJsonSchema.properties) {
|
|
420
|
-
var qprops = r.spec.queryJsonSchema.properties;
|
|
421
|
-
var qreq = r.spec.queryJsonSchema.required || [];
|
|
422
|
-
var qkeys = Object.keys(qprops);
|
|
423
|
-
for (var qi = 0; qi < qkeys.length; qi++) {
|
|
424
|
-
params.push({ name: qkeys[qi], in: "query",
|
|
425
|
-
required: qreq.indexOf(qkeys[qi]) !== -1,
|
|
426
|
-
schema: qprops[qkeys[qi]] });
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
if (params.length > 0) op.parameters = params;
|
|
430
|
-
if (r.spec.bodyJsonSchema) {
|
|
431
|
-
op.requestBody = {
|
|
432
|
-
required: true,
|
|
433
|
-
content: { "application/json": { schema: r.spec.bodyJsonSchema } },
|
|
434
|
-
};
|
|
435
|
-
} else if (r.spec.hasBodySchema) {
|
|
436
|
-
// Operator validates via safeSchema but didn't supply JSON Schema.
|
|
437
|
-
op["x-blamejs-body-validation"] = "safe-schema (json schema not provided)";
|
|
438
|
-
}
|
|
439
|
-
if (r.spec.responseJsonSchema) {
|
|
440
|
-
op.responses = {
|
|
441
|
-
"200": {
|
|
442
|
-
description: "OK",
|
|
443
|
-
content: { "application/json": { schema: r.spec.responseJsonSchema } },
|
|
444
|
-
},
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
paths[openapiPath][r.method.toLowerCase()] = op;
|
|
449
|
-
}
|
|
450
|
-
return {
|
|
451
|
-
openapi: "3.0.3",
|
|
452
|
-
info: info,
|
|
453
|
-
paths: paths,
|
|
454
|
-
};
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// ---- WebSocket route registration ----
|
|
458
|
-
//
|
|
459
|
-
// ws(path, handler, opts?)
|
|
460
|
-
// path — exact match. Path-param patterns aren't supported on
|
|
461
|
-
// upgrade requests; operators that need dynamic paths
|
|
462
|
-
// register one ws route per stable shape.
|
|
463
|
-
// handler — function(conn, req) — called with the WebSocketConnection
|
|
464
|
-
// and the original HTTP request (req for h1, request
|
|
465
|
-
// headers object for h2 Extended CONNECT). Operator owns
|
|
466
|
-
// the conn lifecycle from there.
|
|
467
|
-
// opts:
|
|
468
|
-
// transport: "auto" (default) | "h1-only" | "h2-only"
|
|
469
|
-
// auto — accept both transports per ALPN negotiation
|
|
470
|
-
// h1-only — refuse h2 Extended CONNECT with :status 405
|
|
471
|
-
// h2-only — refuse h1 upgrade with 426 Upgrade Required +
|
|
472
|
-
// `Upgrade: h2c` advisory header
|
|
473
|
-
// origins: string[] | "*" | undefined — operator allowlist;
|
|
474
|
-
// omitted = accept all (a startup warning fires when
|
|
475
|
-
// the path is registered, since omitting origin
|
|
476
|
-
// policy on a public-facing path is rarely intended)
|
|
477
|
-
// subprotocols: string[] — first match wins
|
|
478
|
-
// maxMessageBytes / pingIntervalMs / pongTimeoutMs — passed
|
|
479
|
-
// through to WebSocketConnection
|
|
480
|
-
ws(pathStr, handler, opts) {
|
|
481
|
-
if (typeof pathStr !== "string" || pathStr.length === 0) {
|
|
482
|
-
throw new Error("router.ws: path must be a non-empty string");
|
|
483
|
-
}
|
|
484
|
-
if (typeof handler !== "function") {
|
|
485
|
-
throw new Error("router.ws: handler must be a function");
|
|
486
|
-
}
|
|
487
|
-
opts = opts || {};
|
|
488
|
-
var transport = opts.transport || "auto";
|
|
489
|
-
if (transport !== "auto" && transport !== "h1-only" && transport !== "h2-only") {
|
|
490
|
-
throw new Error("router.ws: transport must be 'auto' | 'h1-only' | 'h2-only'");
|
|
491
|
-
}
|
|
492
|
-
if (!opts.origins) {
|
|
493
|
-
log.warn("WebSocket route '" + pathStr + "' registered without origins allowlist — accepting all origins. Pass { origins: [...] } or { origins: '*' } to silence.");
|
|
494
|
-
}
|
|
495
|
-
this._wsRoutes.set(pathStr, { handler: handler, opts: opts, transport: transport });
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
_match(route, pathname) {
|
|
499
|
-
return _matchCompiled(route, pathname);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
async handle(req, res) {
|
|
503
|
-
// Compose an absolute URL from the request's path + Host header so
|
|
504
|
-
// safeUrl.parse can validate the protocol + length. The "http://"
|
|
505
|
-
// base is the relative-resolution origin; the request's actual
|
|
506
|
-
// scheme lives in requestHelpers.requestProtocol elsewhere.
|
|
507
|
-
var absolute = "http://" + (req.headers.host || "localhost") + (req.url || "/");
|
|
508
|
-
var parsed = safeUrl.parse(absolute, {
|
|
509
|
-
allowedProtocols: safeUrl.ALLOW_HTTP_ALL,
|
|
510
|
-
});
|
|
511
|
-
req.pathname = parsed.pathname;
|
|
512
|
-
req.query = Object.fromEntries(parsed.searchParams);
|
|
513
|
-
|
|
514
|
-
// Run middleware
|
|
515
|
-
for (var mw of this.middleware) {
|
|
516
|
-
var next = false;
|
|
517
|
-
try {
|
|
518
|
-
await mw(req, res, () => (next = true));
|
|
519
|
-
} catch (mwErr) {
|
|
520
|
-
log.error("middleware error: " + (mw.name || "anonymous") + " " +
|
|
521
|
-
req.method + " " + req.url + " " + mwErr.message + " " +
|
|
522
|
-
(mwErr.stack ? mwErr.stack.split("\n").slice(0, 3).join(" | ") : ""));
|
|
523
|
-
throw mwErr;
|
|
524
|
-
}
|
|
525
|
-
if (!next || res.writableEnded) return;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Match route
|
|
529
|
-
for (var route of this.routes) {
|
|
530
|
-
if (route.method !== req.method) continue;
|
|
531
|
-
var params = this._match(route, req.pathname);
|
|
532
|
-
if (!params) continue;
|
|
533
|
-
req.params = params;
|
|
534
|
-
// Expose the route TEMPLATE so framework middleware (metrics,
|
|
535
|
-
// tracing) can label by template instead of the actual URL —
|
|
536
|
-
// otherwise every distinct path-param value becomes its own
|
|
537
|
-
// cardinality bucket.
|
|
538
|
-
req.routePattern = route.pattern;
|
|
539
|
-
|
|
540
|
-
for (var handler of route.handlers) {
|
|
541
|
-
if (res.writableEnded) return;
|
|
542
|
-
if (handler.length >= 3) {
|
|
543
|
-
var proceeded = false;
|
|
544
|
-
await handler(req, res, () => (proceeded = true));
|
|
545
|
-
if (!proceeded) return;
|
|
546
|
-
} else {
|
|
547
|
-
await handler(req, res);
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Not found
|
|
554
|
-
if (this.notFoundHandler) {
|
|
555
|
-
this.notFoundHandler(req, res);
|
|
556
|
-
} else {
|
|
557
|
-
res.writeHead(HTTP_STATUS.NOT_FOUND, { "Content-Type": "text/html" });
|
|
558
|
-
res.end("<h1>404 Not Found</h1>");
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
getReservedSlugs() {
|
|
563
|
-
var slugs = new Set();
|
|
564
|
-
for (var i = 0; i < this.routes.length; i++) {
|
|
565
|
-
var parts = this.routes[i].pattern.split("/").filter(Boolean);
|
|
566
|
-
if (parts.length > 0 && !parts[0].startsWith(":")) {
|
|
567
|
-
slugs.add(parts[0].toLowerCase());
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
return slugs;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
onNotFound(handler) {
|
|
574
|
-
this.notFoundHandler = handler;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
onError(handler) {
|
|
578
|
-
this.errorHandler = handler;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
listen(port, cb, tlsOptions, host) {
|
|
582
|
-
var self = this;
|
|
583
|
-
var requestHandler = (req, res) => {
|
|
584
|
-
// Response helpers
|
|
585
|
-
res.json = (data) => {
|
|
586
|
-
res.writeHead(res.statusCode || HTTP_STATUS.OK, { "Content-Type": "application/json" });
|
|
587
|
-
res.end(JSON.stringify(data));
|
|
588
|
-
};
|
|
589
|
-
res.redirect = (url) => {
|
|
590
|
-
// Same-origin redirects only by default. Apps that need cross-origin
|
|
591
|
-
// redirects (OAuth, SSO) wrap res.redirect with their own allowlist.
|
|
592
|
-
var safe = "/";
|
|
593
|
-
if (typeof url === "string" && url.startsWith("/") && !url.startsWith("//")) {
|
|
594
|
-
safe = url;
|
|
595
|
-
}
|
|
596
|
-
// 302 Found — RFC 7231 §6.4.3. Not in HTTP_STATUS table.
|
|
597
|
-
res.writeHead(302, { Location: safe });
|
|
598
|
-
res.end();
|
|
599
|
-
};
|
|
600
|
-
res.status = (code) => {
|
|
601
|
-
res.statusCode = code;
|
|
602
|
-
return res;
|
|
603
|
-
};
|
|
604
|
-
|
|
605
|
-
self.handle(req, res).catch((err) => {
|
|
606
|
-
log.error("route error: " + req.method + " " + req.url + " " + err.message + " " +
|
|
607
|
-
(err.stack ? err.stack.split("\n").slice(0, 5).join(" | ") : ""));
|
|
608
|
-
if (self.errorHandler) {
|
|
609
|
-
try { self.errorHandler(err, req, res); } catch (_) {
|
|
610
|
-
if (!res.writableEnded) {
|
|
611
|
-
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, { "Content-Type": "text/plain" });
|
|
612
|
-
res.end("Internal Server Error");
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
} else if (!res.writableEnded) {
|
|
616
|
-
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, { "Content-Type": "text/plain" });
|
|
617
|
-
res.end("Internal Server Error");
|
|
618
|
-
}
|
|
619
|
-
});
|
|
620
|
-
};
|
|
621
|
-
var server;
|
|
622
|
-
if (tlsOptions) {
|
|
623
|
-
// h2-capable server with h1 fallback via ALPN. ["h2", "http/1.1"]
|
|
624
|
-
// means modern clients negotiate h2 (preferred); legacy clients
|
|
625
|
-
// fall back to h1. allowHTTP1: true is what makes the same server
|
|
626
|
-
// accept both. enableConnectProtocol: true is what enables h2
|
|
627
|
-
// WebSocket (RFC 8441) — clients refuse to issue Extended CONNECT
|
|
628
|
-
// until they see this in the server's SETTINGS frame.
|
|
629
|
-
server = http2.createSecureServer(Object.assign({
|
|
630
|
-
allowHTTP1: true,
|
|
631
|
-
ALPNProtocols: ["h2", "http/1.1"],
|
|
632
|
-
settings: { enableConnectProtocol: true },
|
|
633
|
-
}, tlsOptions), requestHandler);
|
|
634
|
-
} else {
|
|
635
|
-
// Cleartext path is h1-only. Operators wanting h2c on cleartext
|
|
636
|
-
// are typically running behind a TLS-terminating LB that does
|
|
637
|
-
// h1↔h2 translation; the framework's TLS path covers that.
|
|
638
|
-
server = http.createServer(requestHandler);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// ---- WebSocket wiring ----
|
|
642
|
-
// Only registers handlers when there are ws routes — keeps the
|
|
643
|
-
// server's emitter list clean for HTTP-only deployments.
|
|
644
|
-
if (self._wsRoutes.size > 0) {
|
|
645
|
-
// h1 upgrade event — fires for "Upgrade: websocket" from h1
|
|
646
|
-
// clients. Routes by path; refuses with 426 in h2-only mode.
|
|
647
|
-
server.on("upgrade", function (req, socket, head) {
|
|
648
|
-
var pathname = String(req.url || "/").split("?")[0];
|
|
649
|
-
var route = self._wsRoutes.get(pathname);
|
|
650
|
-
if (!route) {
|
|
651
|
-
socket.destroy();
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
if (route.transport === "h2-only") {
|
|
655
|
-
// RFC-correct way to say "use h2": 426 Upgrade Required plus
|
|
656
|
-
// an Upgrade advisory pointing to h2c.
|
|
657
|
-
var body = "WebSocket on this path requires HTTP/2";
|
|
658
|
-
var resp =
|
|
659
|
-
"HTTP/1.1 426 Upgrade Required\r\n" +
|
|
660
|
-
"Upgrade: h2c\r\n" +
|
|
661
|
-
"Connection: close\r\n" +
|
|
662
|
-
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
663
|
-
"Content-Length: " + Buffer.byteLength(body, "utf8") + "\r\n" +
|
|
664
|
-
"\r\n" +
|
|
665
|
-
body;
|
|
666
|
-
try { socket.write(resp); } catch (_e) { /* socket already closed */ }
|
|
667
|
-
try { socket.destroy(); } catch (_e) { /* socket already destroyed */ }
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
var conn = websocket.handleUpgrade(req, socket, head, route.opts);
|
|
671
|
-
if (conn) {
|
|
672
|
-
self._activeWsConns.add(conn);
|
|
673
|
-
conn.once("close", function () { self._activeWsConns.delete(conn); });
|
|
674
|
-
try { route.handler(conn, req); }
|
|
675
|
-
catch (err) { log.error("ws handler threw: " + err.message); conn._abort(websocket.CLOSE_INTERNAL_ERROR, "handler error"); }
|
|
676
|
-
}
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
// h2 Extended CONNECT — only fires on h2-capable (TLS) server.
|
|
680
|
-
// The 'stream' event filter checks for :method=CONNECT,
|
|
681
|
-
// :protocol=websocket. Other CONNECT methods (e.g. tunnel) and
|
|
682
|
-
// ordinary requests pass through.
|
|
683
|
-
if (tlsOptions) {
|
|
684
|
-
server.on("stream", function (stream, headers) {
|
|
685
|
-
if (headers[":method"] !== "CONNECT") return;
|
|
686
|
-
if (headers[":protocol"] !== "websocket") return;
|
|
687
|
-
var pathname = String(headers[":path"] || "/").split("?")[0];
|
|
688
|
-
var route = self._wsRoutes.get(pathname);
|
|
689
|
-
if (!route) {
|
|
690
|
-
try { stream.respond({ ":status": 404 }); stream.end(); } catch (_e) { /* stream already closed */ }
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
if (route.transport === "h1-only") {
|
|
694
|
-
try {
|
|
695
|
-
stream.respond({ ":status": 405, "content-type": "text/plain; charset=utf-8" });
|
|
696
|
-
stream.end("WebSocket on this path requires HTTP/1.1 Upgrade");
|
|
697
|
-
} catch (_e) { /* stream already closed */ }
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
var conn = websocket.handleExtendedConnect(stream, headers, route.opts);
|
|
701
|
-
if (conn) {
|
|
702
|
-
self._activeWsConns.add(conn);
|
|
703
|
-
conn.once("close", function () { self._activeWsConns.delete(conn); });
|
|
704
|
-
try { route.handler(conn, headers); }
|
|
705
|
-
catch (err) { log.error("ws handler threw: " + err.message); conn._abort(websocket.CLOSE_INTERNAL_ERROR, "handler error"); }
|
|
706
|
-
}
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
if (host) server.listen(port, host, cb);
|
|
712
|
-
else server.listen(port, cb);
|
|
713
|
-
server.timeout = C.TIME.minutes(5);
|
|
714
|
-
return server;
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// Static file serving middleware
|
|
719
|
-
function serveStatic(dir) {
|
|
720
|
-
var root = path.resolve(dir);
|
|
721
|
-
return (req, res, next) => {
|
|
722
|
-
if (req.method !== "GET") return next();
|
|
723
|
-
var rel = req.pathname;
|
|
724
|
-
if (rel.includes("\0")) return next();
|
|
725
|
-
var filePath = path.resolve(path.join(root, rel));
|
|
726
|
-
if (!filePath.startsWith(root)) return next();
|
|
727
|
-
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) return next();
|
|
728
|
-
|
|
729
|
-
var ext = path.extname(filePath).toLowerCase();
|
|
730
|
-
var mime = MIME_TYPES[ext] || "application/octet-stream";
|
|
731
|
-
var stat = fs.statSync(filePath);
|
|
732
|
-
var hasVersion = req.url && req.url.includes("?v=");
|
|
733
|
-
var cacheControl = hasVersion
|
|
734
|
-
? "public, max-age=31536000, immutable"
|
|
735
|
-
: "public, max-age=3600";
|
|
736
|
-
res.writeHead(HTTP_STATUS.OK, {
|
|
737
|
-
"Content-Type": mime,
|
|
738
|
-
"Content-Length": stat.size,
|
|
739
|
-
"Cache-Control": cacheControl,
|
|
740
|
-
});
|
|
741
|
-
fs.createReadStream(filePath).pipe(res);
|
|
742
|
-
};
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
module.exports = {
|
|
746
|
-
Router: Router,
|
|
747
|
-
serveStatic: serveStatic,
|
|
748
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Custom HTTP router — zero-dependency replacement for express/koa/fastify.
|
|
4
|
+
*
|
|
5
|
+
* Why rolled-our-own: blamejs principle #1 forbids npm runtime dependencies.
|
|
6
|
+
* This router covers what a route concretely requires (path params,
|
|
7
|
+
* middleware chain, static file serving, MIME sniffing) and leaves no
|
|
8
|
+
* attack surface we haven't read.
|
|
9
|
+
*
|
|
10
|
+
* Middleware / handler dispatch (see roadmap "Naming conventions" — verb
|
|
11
|
+
* conventions section, "on/off/emit" vs explicit chain control):
|
|
12
|
+
* - handler.length >= 3 → treated as middleware. Chain stops unless the
|
|
13
|
+
* handler calls next(). Using 2-arg handlers as middleware is
|
|
14
|
+
* structurally fragile and will silently fall through.
|
|
15
|
+
* - handler.length <= 2 → terminal handler. Always falls through to the
|
|
16
|
+
* next entry in the chain if it doesn't end the response.
|
|
17
|
+
*
|
|
18
|
+
* Patterns are compiled ONCE at registration time (compilePattern) — no
|
|
19
|
+
* regex construction on the hot path. Route table is scanned linearly;
|
|
20
|
+
* ordering matters (first match wins).
|
|
21
|
+
*/
|
|
22
|
+
var http = require("http");
|
|
23
|
+
var http2 = require("http2");
|
|
24
|
+
var fs = require("fs");
|
|
25
|
+
var path = require("path");
|
|
26
|
+
var C = require("./constants");
|
|
27
|
+
var requestHelpers = require("./request-helpers");
|
|
28
|
+
var safeAsync = require("./safe-async");
|
|
29
|
+
var safeEnv = require("./parsers/safe-env");
|
|
30
|
+
var safeUrl = require("./safe-url");
|
|
31
|
+
var websocket = require("./websocket");
|
|
32
|
+
var { boot } = require("./log");
|
|
33
|
+
|
|
34
|
+
var log = boot("router");
|
|
35
|
+
var HTTP_STATUS = requestHelpers.HTTP_STATUS;
|
|
36
|
+
|
|
37
|
+
// Cap on operator-defined route patterns. A route registration that
|
|
38
|
+
// somehow attracts a multi-megabyte template string would stall regex
|
|
39
|
+
// compilation; bound it before new RegExp() so the gate at registration
|
|
40
|
+
// time is bounded.
|
|
41
|
+
var MAX_ROUTE_PATTERN_LEN = C.BYTES.kib(1);
|
|
42
|
+
|
|
43
|
+
// ---- Schema-spec helpers (route-level body/query/params validation) ----
|
|
44
|
+
|
|
45
|
+
var ALLOWED_SPEC_KEYS = [
|
|
46
|
+
"body", "query", "params", "response",
|
|
47
|
+
"bodyJsonSchema", "queryJsonSchema", "paramsJsonSchema", "responseJsonSchema",
|
|
48
|
+
"description", "summary", "tags", "validateResponse",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function _validateRouteSpec(spec, method, pattern) {
|
|
52
|
+
var keys = Object.keys(spec);
|
|
53
|
+
for (var i = 0; i < keys.length; i++) {
|
|
54
|
+
if (ALLOWED_SPEC_KEYS.indexOf(keys[i]) === -1) {
|
|
55
|
+
throw new Error("router." + method.toLowerCase() + "(" + pattern +
|
|
56
|
+
"): unknown spec key '" + keys[i] + "'. Allowed: " +
|
|
57
|
+
ALLOWED_SPEC_KEYS.slice().sort().join(", "));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function _checkSchema(name) {
|
|
61
|
+
var s = spec[name];
|
|
62
|
+
if (s === undefined) return;
|
|
63
|
+
if (!s || typeof s !== "object" || typeof s.safeParse !== "function") {
|
|
64
|
+
throw new Error("router." + method.toLowerCase() + "(" + pattern +
|
|
65
|
+
"): spec." + name + " must be a b.safeSchema-shaped schema (with safeParse)");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
_checkSchema("body");
|
|
69
|
+
_checkSchema("query");
|
|
70
|
+
_checkSchema("params");
|
|
71
|
+
_checkSchema("response");
|
|
72
|
+
if (spec.tags !== undefined) {
|
|
73
|
+
if (!Array.isArray(spec.tags) || !spec.tags.every(function (t) { return typeof t === "string"; })) {
|
|
74
|
+
throw new Error("router." + method.toLowerCase() + "(" + pattern +
|
|
75
|
+
"): spec.tags must be an array of strings");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _writeValidationError(res, where, errors) {
|
|
81
|
+
if (res.writableEnded || res.headersSent) return;
|
|
82
|
+
var body = JSON.stringify({
|
|
83
|
+
error: "validation",
|
|
84
|
+
where: where,
|
|
85
|
+
issues: errors,
|
|
86
|
+
});
|
|
87
|
+
res.writeHead(HTTP_STATUS.BAD_REQUEST, {
|
|
88
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
89
|
+
"Content-Length": Buffer.byteLength(body),
|
|
90
|
+
});
|
|
91
|
+
res.end(body);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function _makeSchemaValidator(spec) {
|
|
95
|
+
// 3-arg signature → router treats as middleware, chains via next().
|
|
96
|
+
return function schemaValidator(req, res, next) {
|
|
97
|
+
if (spec.params && req.params !== undefined) {
|
|
98
|
+
var pp = spec.params.safeParse(req.params);
|
|
99
|
+
if (!pp.ok) return _writeValidationError(res, "params", pp.errors);
|
|
100
|
+
req.params = pp.value;
|
|
101
|
+
}
|
|
102
|
+
if (spec.query && req.query !== undefined) {
|
|
103
|
+
var qq = spec.query.safeParse(req.query);
|
|
104
|
+
if (!qq.ok) return _writeValidationError(res, "query", qq.errors);
|
|
105
|
+
req.query = qq.value;
|
|
106
|
+
}
|
|
107
|
+
if (spec.body && req.body !== undefined) {
|
|
108
|
+
var bb = spec.body.safeParse(req.body);
|
|
109
|
+
if (!bb.ok) return _writeValidationError(res, "body", bb.errors);
|
|
110
|
+
req.body = bb.value;
|
|
111
|
+
}
|
|
112
|
+
next();
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function _makeResponseValidator(spec) {
|
|
117
|
+
// Wraps res.json (and res.end when called with a JSON-shaped buffer)
|
|
118
|
+
// to validate the response body against spec.response. Mode:
|
|
119
|
+
// - BLAMEJS_VALIDATE_RESPONSES=throw (or per-route validateResponse: "throw")
|
|
120
|
+
// → throw a SafeSchemaError-shaped error; route handler's caller sees a 500.
|
|
121
|
+
// - BLAMEJS_VALIDATE_RESPONSES=warn (or per-route validateResponse: "warn")
|
|
122
|
+
// → log a warning; ship the response as-is (prod-safe).
|
|
123
|
+
var perRoute = spec.validateResponse;
|
|
124
|
+
var globalMode = safeEnv.readVar("BLAMEJS_VALIDATE_RESPONSES");
|
|
125
|
+
var mode = (perRoute === "throw" || perRoute === "warn") ? perRoute :
|
|
126
|
+
(globalMode === "throw" || globalMode === "warn") ? globalMode : null;
|
|
127
|
+
if (!mode) return function passthrough(_req, _res, next) { next(); };
|
|
128
|
+
|
|
129
|
+
return function responseValidator(req, res, next) {
|
|
130
|
+
var origJson = typeof res.json === "function" ? res.json.bind(res) : null;
|
|
131
|
+
if (origJson) {
|
|
132
|
+
res.json = function (value) {
|
|
133
|
+
var rr = spec.response.safeParse(value);
|
|
134
|
+
if (!rr.ok) {
|
|
135
|
+
if (mode === "throw") {
|
|
136
|
+
throw new Error("router response-validation failed for " +
|
|
137
|
+
(req.method + " " + req.routePattern) + ": " +
|
|
138
|
+
JSON.stringify(rr.errors));
|
|
139
|
+
}
|
|
140
|
+
// warn mode
|
|
141
|
+
log.warn("response-validation drift on " + req.method + " " + req.routePattern +
|
|
142
|
+
": " + JSON.stringify(rr.errors).slice(0, 500));
|
|
143
|
+
}
|
|
144
|
+
return origJson(value);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
next();
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function compilePattern(pattern) {
|
|
152
|
+
// pattern is operator-supplied at route registration (router.get(...)).
|
|
153
|
+
// Cap length up-front to bound matcher work even against pathological
|
|
154
|
+
// operator config.
|
|
155
|
+
if (typeof pattern !== "string" || pattern.length === 0) {
|
|
156
|
+
throw new Error("router: pattern must be a non-empty string");
|
|
157
|
+
}
|
|
158
|
+
if (pattern.length > MAX_ROUTE_PATTERN_LEN) {
|
|
159
|
+
throw new Error("router: pattern exceeds " + MAX_ROUTE_PATTERN_LEN +
|
|
160
|
+
" chars (got " + pattern.length + ")");
|
|
161
|
+
}
|
|
162
|
+
// Segment-based matcher — splits on "/" once at registration time and
|
|
163
|
+
// walks token-by-token at match time. Avoids compiling a RegExp from
|
|
164
|
+
// the operator-supplied pattern (which would be a dynamic-regex /
|
|
165
|
+
// ReDoS shape even though pattern is operator-controlled).
|
|
166
|
+
//
|
|
167
|
+
// Each segment is either:
|
|
168
|
+
// - a literal (e.g. "users", "v1")
|
|
169
|
+
// - a named parameter ":id" — captures into params[name]
|
|
170
|
+
//
|
|
171
|
+
// Matching is exact on segment count: "/a/b" does not match "/a/b/c".
|
|
172
|
+
var rawSegments = pattern.split("/");
|
|
173
|
+
var segments = [];
|
|
174
|
+
var keys = [];
|
|
175
|
+
for (var si = 0; si < rawSegments.length; si++) {
|
|
176
|
+
var seg = rawSegments[si];
|
|
177
|
+
if (seg.length > 0 && seg.charAt(0) === ":") {
|
|
178
|
+
var key = seg.slice(1);
|
|
179
|
+
if (key.length === 0) {
|
|
180
|
+
throw new Error("router: pattern '" + pattern +
|
|
181
|
+
"' has an empty parameter name (':' segment)");
|
|
182
|
+
}
|
|
183
|
+
keys.push(key);
|
|
184
|
+
segments.push({ literal: false, key: key });
|
|
185
|
+
} else {
|
|
186
|
+
segments.push({ literal: true, value: seg });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { pattern: pattern, segments: segments, keys: keys };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Walk a request path against a compiled pattern. Returns the params
|
|
193
|
+
// object on match, null otherwise. Single non-empty trailing slash
|
|
194
|
+
// difference is treated as a no-match (callers that want trailing-slash
|
|
195
|
+
// tolerance normalize the path before dispatch).
|
|
196
|
+
function _matchCompiled(compiled, pathname) {
|
|
197
|
+
var pathSegments = pathname.split("/");
|
|
198
|
+
var patSegments = compiled.segments;
|
|
199
|
+
if (pathSegments.length !== patSegments.length) return null;
|
|
200
|
+
var params = {};
|
|
201
|
+
for (var i = 0; i < patSegments.length; i++) {
|
|
202
|
+
var seg = patSegments[i];
|
|
203
|
+
if (seg.literal) {
|
|
204
|
+
if (pathSegments[i] !== seg.value) return null;
|
|
205
|
+
} else {
|
|
206
|
+
// Named param: must be non-empty (mirrors the regex `[^/]+`
|
|
207
|
+
// capture from the previous compiled regex).
|
|
208
|
+
if (pathSegments[i].length === 0) return null;
|
|
209
|
+
params[seg.key] = pathSegments[i];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return params;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
var MIME_TYPES = {
|
|
216
|
+
".html": "text/html",
|
|
217
|
+
".css": "text/css",
|
|
218
|
+
".js": "application/javascript",
|
|
219
|
+
".json": "application/json",
|
|
220
|
+
".png": "image/png",
|
|
221
|
+
".jpg": "image/jpeg",
|
|
222
|
+
".jpeg": "image/jpeg",
|
|
223
|
+
".gif": "image/gif",
|
|
224
|
+
".svg": "image/svg+xml",
|
|
225
|
+
".ico": "image/x-icon",
|
|
226
|
+
".woff2": "font/woff2",
|
|
227
|
+
".woff": "font/woff",
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
class Router {
|
|
231
|
+
constructor() {
|
|
232
|
+
this.routes = [];
|
|
233
|
+
this.middleware = [];
|
|
234
|
+
// WebSocket routes are kept separate from HTTP routes — they're
|
|
235
|
+
// matched on the upgrade / Extended CONNECT path, not on a method
|
|
236
|
+
// verb. Map<path, { handler, opts }>.
|
|
237
|
+
this._wsRoutes = new Map();
|
|
238
|
+
// Active WebSocket connections opened through router.ws(). Tracked
|
|
239
|
+
// so router.closeWebSockets() can do a clean rolling-shutdown.
|
|
240
|
+
// h1-upgrade detaches the socket from http.Server's connection
|
|
241
|
+
// tracking — without our own registry there's no other way to
|
|
242
|
+
// enumerate active WS connections for graceful close.
|
|
243
|
+
this._activeWsConns = new Set();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Active WebSocket connections opened via router.ws(). Useful for
|
|
247
|
+
// ops dashboards / health endpoints.
|
|
248
|
+
activeWebSockets() {
|
|
249
|
+
return this._activeWsConns.size;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Graceful shutdown of all WebSocket connections opened via
|
|
253
|
+
// router.ws(). Sends close frame (code 1001 'going away') to each;
|
|
254
|
+
// awaits each connection's 'close' event up to opts.timeoutMs
|
|
255
|
+
// (default 5s). Returns the count of connections closed.
|
|
256
|
+
//
|
|
257
|
+
// Operators call this during rolling deploy:
|
|
258
|
+
// await router.closeWebSockets({ timeoutMs: 10_000 });
|
|
259
|
+
// await new Promise(r => server.close(r));
|
|
260
|
+
// process.exit(0);
|
|
261
|
+
//
|
|
262
|
+
// Tests use the same primitive in teardown — no parallel cleanup
|
|
263
|
+
// path, no h1-upgrade detached-socket workaround.
|
|
264
|
+
async closeWebSockets(opts) {
|
|
265
|
+
opts = opts || {};
|
|
266
|
+
var timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : C.TIME.seconds(5);
|
|
267
|
+
var code = opts.code || 1001; // 1001 = going away
|
|
268
|
+
var reason = opts.reason || "server shutting down";
|
|
269
|
+
|
|
270
|
+
var conns = Array.from(this._activeWsConns);
|
|
271
|
+
if (conns.length === 0) return 0;
|
|
272
|
+
|
|
273
|
+
var closes = conns.map(function (conn) {
|
|
274
|
+
return new Promise(function (resolve) {
|
|
275
|
+
if (conn.readyState === "closed") { resolve(); return; }
|
|
276
|
+
conn.once("close", resolve);
|
|
277
|
+
try { conn.close(code, reason); }
|
|
278
|
+
catch (_e) {
|
|
279
|
+
// close() may throw if already closed; resolve immediately
|
|
280
|
+
// since the 'close' event won't fire again.
|
|
281
|
+
resolve();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Wait up to `timeoutMs` for graceful WS closes, then force-destroy
|
|
287
|
+
// any laggards. `safeAsync.sleep({ unref: true })` matches the
|
|
288
|
+
// framework's outbound-timeout convention.
|
|
289
|
+
await Promise.race([
|
|
290
|
+
Promise.all(closes),
|
|
291
|
+
safeAsync.sleep(timeoutMs, { unref: true }),
|
|
292
|
+
]);
|
|
293
|
+
// Force-destroy any laggards — at this point we've waited the full
|
|
294
|
+
// timeout and they didn't ack. The operator chose timeoutMs; honor it.
|
|
295
|
+
this._activeWsConns.forEach(function (conn) {
|
|
296
|
+
try { if (conn.socket && conn.socket.destroy) conn.socket.destroy(); }
|
|
297
|
+
catch (_e) { /* socket already destroyed */ }
|
|
298
|
+
});
|
|
299
|
+
return conns.length;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
use(fn) {
|
|
303
|
+
this.middleware.push(fn);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Internal: split a route registration's args into { spec, handlers }.
|
|
307
|
+
// The first non-pattern arg is the schema spec when it's a plain object
|
|
308
|
+
// (not a function); subsequent args are handler middlewares. Operators
|
|
309
|
+
// who never pass a spec keep the existing two-arg shape working.
|
|
310
|
+
_splitArgs(args) {
|
|
311
|
+
if (args.length > 0 && args[0] && typeof args[0] === "object" &&
|
|
312
|
+
!Array.isArray(args[0]) && typeof args[0] !== "function") {
|
|
313
|
+
return { spec: args[0], handlers: args.slice(1) };
|
|
314
|
+
}
|
|
315
|
+
return { spec: null, handlers: args };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
_registerRoute(method, pattern, args) {
|
|
319
|
+
var split = this._splitArgs(args);
|
|
320
|
+
if (split.spec) _validateRouteSpec(split.spec, method, pattern);
|
|
321
|
+
var handlers = split.handlers;
|
|
322
|
+
if (split.spec) {
|
|
323
|
+
// Pre-handler validates body / query / params. Runs after the
|
|
324
|
+
// global middleware chain (bodyParser populates req.body before
|
|
325
|
+
// route dispatch) but before any route-specific handler.
|
|
326
|
+
handlers = [_makeSchemaValidator(split.spec)].concat(handlers);
|
|
327
|
+
// Response validation (dev/opt-in via env or per-route opt).
|
|
328
|
+
var globalValidateMode = safeEnv.readVar("BLAMEJS_VALIDATE_RESPONSES");
|
|
329
|
+
if (split.spec.response &&
|
|
330
|
+
(globalValidateMode === "throw" ||
|
|
331
|
+
globalValidateMode === "warn" ||
|
|
332
|
+
split.spec.validateResponse)) {
|
|
333
|
+
handlers = [_makeResponseValidator(split.spec)].concat(handlers);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
this.routes.push(Object.assign(
|
|
337
|
+
{ method: method, handlers: handlers, spec: split.spec || null },
|
|
338
|
+
compilePattern(pattern)
|
|
339
|
+
));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
get(pattern, ...args) {
|
|
343
|
+
this._registerRoute("GET", pattern, args);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
post(pattern, ...args) {
|
|
347
|
+
this._registerRoute("POST", pattern, args);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
put(pattern, ...args) {
|
|
351
|
+
this._registerRoute("PUT", pattern, args);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
patch(pattern, ...args) {
|
|
355
|
+
this._registerRoute("PATCH", pattern, args);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
delete(pattern, ...args) {
|
|
359
|
+
this._registerRoute("DELETE", pattern, args);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Operator-facing introspection — returns a copy of the route table
|
|
363
|
+
// with each entry's method, pattern, description, and (when provided)
|
|
364
|
+
// operator-supplied jsonSchema bodies for OpenAPI publication.
|
|
365
|
+
inspectRoutes() {
|
|
366
|
+
return this.routes
|
|
367
|
+
.filter(function (r) { return typeof r.method === "string"; })
|
|
368
|
+
.map(function (r) {
|
|
369
|
+
return {
|
|
370
|
+
method: r.method,
|
|
371
|
+
pattern: r.pattern,
|
|
372
|
+
description: r.spec ? r.spec.description || null : null,
|
|
373
|
+
spec: r.spec ? {
|
|
374
|
+
hasBodySchema: !!r.spec.body,
|
|
375
|
+
hasQuerySchema: !!r.spec.query,
|
|
376
|
+
hasParamsSchema: !!r.spec.params,
|
|
377
|
+
hasResponseSchema: !!r.spec.response,
|
|
378
|
+
bodyJsonSchema: r.spec.bodyJsonSchema || null,
|
|
379
|
+
queryJsonSchema: r.spec.queryJsonSchema || null,
|
|
380
|
+
paramsJsonSchema: r.spec.paramsJsonSchema || null,
|
|
381
|
+
responseJsonSchema: r.spec.responseJsonSchema || null,
|
|
382
|
+
tags: Array.isArray(r.spec.tags) ? r.spec.tags.slice() : [],
|
|
383
|
+
summary: r.spec.summary || null,
|
|
384
|
+
} : null,
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// openapi(opts?) → minimal Swagger 3.0 document covering every
|
|
390
|
+
// schema-spec'd route. Body / query / params show up as parameter
|
|
391
|
+
// entries when the operator supplies bodyJsonSchema / etc. (a
|
|
392
|
+
// safeSchema → JSON Schema converter is its own primitive — operators
|
|
393
|
+
// who want full schema bodies in the OpenAPI doc supply the JSON
|
|
394
|
+
// Schema alongside the safeSchema today).
|
|
395
|
+
openapi(opts) {
|
|
396
|
+
opts = opts || {};
|
|
397
|
+
var info = opts.info || { title: "blamejs app", version: "0.0.0" };
|
|
398
|
+
var paths = {};
|
|
399
|
+
var routes = this.inspectRoutes();
|
|
400
|
+
for (var i = 0; i < routes.length; i++) {
|
|
401
|
+
var r = routes[i];
|
|
402
|
+
var openapiPath = r.pattern.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
|
|
403
|
+
if (!paths[openapiPath]) paths[openapiPath] = {};
|
|
404
|
+
var op = {
|
|
405
|
+
summary: r.spec ? r.spec.summary || r.description || (r.method + " " + r.pattern) :
|
|
406
|
+
(r.method + " " + r.pattern),
|
|
407
|
+
description: r.description || null,
|
|
408
|
+
};
|
|
409
|
+
if (r.spec) {
|
|
410
|
+
op.tags = r.spec.tags;
|
|
411
|
+
var params = [];
|
|
412
|
+
// Path params from pattern
|
|
413
|
+
var pathParams = (r.pattern.match(/:[a-zA-Z0-9_]+/g) || [])
|
|
414
|
+
.map(function (s) { return s.slice(1); });
|
|
415
|
+
for (var pp = 0; pp < pathParams.length; pp++) {
|
|
416
|
+
params.push({ name: pathParams[pp], in: "path", required: true,
|
|
417
|
+
schema: { type: "string" } });
|
|
418
|
+
}
|
|
419
|
+
if (r.spec.queryJsonSchema && r.spec.queryJsonSchema.properties) {
|
|
420
|
+
var qprops = r.spec.queryJsonSchema.properties;
|
|
421
|
+
var qreq = r.spec.queryJsonSchema.required || [];
|
|
422
|
+
var qkeys = Object.keys(qprops);
|
|
423
|
+
for (var qi = 0; qi < qkeys.length; qi++) {
|
|
424
|
+
params.push({ name: qkeys[qi], in: "query",
|
|
425
|
+
required: qreq.indexOf(qkeys[qi]) !== -1,
|
|
426
|
+
schema: qprops[qkeys[qi]] });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (params.length > 0) op.parameters = params;
|
|
430
|
+
if (r.spec.bodyJsonSchema) {
|
|
431
|
+
op.requestBody = {
|
|
432
|
+
required: true,
|
|
433
|
+
content: { "application/json": { schema: r.spec.bodyJsonSchema } },
|
|
434
|
+
};
|
|
435
|
+
} else if (r.spec.hasBodySchema) {
|
|
436
|
+
// Operator validates via safeSchema but didn't supply JSON Schema.
|
|
437
|
+
op["x-blamejs-body-validation"] = "safe-schema (json schema not provided)";
|
|
438
|
+
}
|
|
439
|
+
if (r.spec.responseJsonSchema) {
|
|
440
|
+
op.responses = {
|
|
441
|
+
"200": {
|
|
442
|
+
description: "OK",
|
|
443
|
+
content: { "application/json": { schema: r.spec.responseJsonSchema } },
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
paths[openapiPath][r.method.toLowerCase()] = op;
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
openapi: "3.0.3",
|
|
452
|
+
info: info,
|
|
453
|
+
paths: paths,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---- WebSocket route registration ----
|
|
458
|
+
//
|
|
459
|
+
// ws(path, handler, opts?)
|
|
460
|
+
// path — exact match. Path-param patterns aren't supported on
|
|
461
|
+
// upgrade requests; operators that need dynamic paths
|
|
462
|
+
// register one ws route per stable shape.
|
|
463
|
+
// handler — function(conn, req) — called with the WebSocketConnection
|
|
464
|
+
// and the original HTTP request (req for h1, request
|
|
465
|
+
// headers object for h2 Extended CONNECT). Operator owns
|
|
466
|
+
// the conn lifecycle from there.
|
|
467
|
+
// opts:
|
|
468
|
+
// transport: "auto" (default) | "h1-only" | "h2-only"
|
|
469
|
+
// auto — accept both transports per ALPN negotiation
|
|
470
|
+
// h1-only — refuse h2 Extended CONNECT with :status 405
|
|
471
|
+
// h2-only — refuse h1 upgrade with 426 Upgrade Required +
|
|
472
|
+
// `Upgrade: h2c` advisory header
|
|
473
|
+
// origins: string[] | "*" | undefined — operator allowlist;
|
|
474
|
+
// omitted = accept all (a startup warning fires when
|
|
475
|
+
// the path is registered, since omitting origin
|
|
476
|
+
// policy on a public-facing path is rarely intended)
|
|
477
|
+
// subprotocols: string[] — first match wins
|
|
478
|
+
// maxMessageBytes / pingIntervalMs / pongTimeoutMs — passed
|
|
479
|
+
// through to WebSocketConnection
|
|
480
|
+
ws(pathStr, handler, opts) {
|
|
481
|
+
if (typeof pathStr !== "string" || pathStr.length === 0) {
|
|
482
|
+
throw new Error("router.ws: path must be a non-empty string");
|
|
483
|
+
}
|
|
484
|
+
if (typeof handler !== "function") {
|
|
485
|
+
throw new Error("router.ws: handler must be a function");
|
|
486
|
+
}
|
|
487
|
+
opts = opts || {};
|
|
488
|
+
var transport = opts.transport || "auto";
|
|
489
|
+
if (transport !== "auto" && transport !== "h1-only" && transport !== "h2-only") {
|
|
490
|
+
throw new Error("router.ws: transport must be 'auto' | 'h1-only' | 'h2-only'");
|
|
491
|
+
}
|
|
492
|
+
if (!opts.origins) {
|
|
493
|
+
log.warn("WebSocket route '" + pathStr + "' registered without origins allowlist — accepting all origins. Pass { origins: [...] } or { origins: '*' } to silence.");
|
|
494
|
+
}
|
|
495
|
+
this._wsRoutes.set(pathStr, { handler: handler, opts: opts, transport: transport });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
_match(route, pathname) {
|
|
499
|
+
return _matchCompiled(route, pathname);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async handle(req, res) {
|
|
503
|
+
// Compose an absolute URL from the request's path + Host header so
|
|
504
|
+
// safeUrl.parse can validate the protocol + length. The "http://"
|
|
505
|
+
// base is the relative-resolution origin; the request's actual
|
|
506
|
+
// scheme lives in requestHelpers.requestProtocol elsewhere.
|
|
507
|
+
var absolute = "http://" + (req.headers.host || "localhost") + (req.url || "/");
|
|
508
|
+
var parsed = safeUrl.parse(absolute, {
|
|
509
|
+
allowedProtocols: safeUrl.ALLOW_HTTP_ALL,
|
|
510
|
+
});
|
|
511
|
+
req.pathname = parsed.pathname;
|
|
512
|
+
req.query = Object.fromEntries(parsed.searchParams);
|
|
513
|
+
|
|
514
|
+
// Run middleware
|
|
515
|
+
for (var mw of this.middleware) {
|
|
516
|
+
var next = false;
|
|
517
|
+
try {
|
|
518
|
+
await mw(req, res, () => (next = true));
|
|
519
|
+
} catch (mwErr) {
|
|
520
|
+
log.error("middleware error: " + (mw.name || "anonymous") + " " +
|
|
521
|
+
req.method + " " + req.url + " " + mwErr.message + " " +
|
|
522
|
+
(mwErr.stack ? mwErr.stack.split("\n").slice(0, 3).join(" | ") : ""));
|
|
523
|
+
throw mwErr;
|
|
524
|
+
}
|
|
525
|
+
if (!next || res.writableEnded) return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Match route
|
|
529
|
+
for (var route of this.routes) {
|
|
530
|
+
if (route.method !== req.method) continue;
|
|
531
|
+
var params = this._match(route, req.pathname);
|
|
532
|
+
if (!params) continue;
|
|
533
|
+
req.params = params;
|
|
534
|
+
// Expose the route TEMPLATE so framework middleware (metrics,
|
|
535
|
+
// tracing) can label by template instead of the actual URL —
|
|
536
|
+
// otherwise every distinct path-param value becomes its own
|
|
537
|
+
// cardinality bucket.
|
|
538
|
+
req.routePattern = route.pattern;
|
|
539
|
+
|
|
540
|
+
for (var handler of route.handlers) {
|
|
541
|
+
if (res.writableEnded) return;
|
|
542
|
+
if (handler.length >= 3) {
|
|
543
|
+
var proceeded = false;
|
|
544
|
+
await handler(req, res, () => (proceeded = true));
|
|
545
|
+
if (!proceeded) return;
|
|
546
|
+
} else {
|
|
547
|
+
await handler(req, res);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Not found
|
|
554
|
+
if (this.notFoundHandler) {
|
|
555
|
+
this.notFoundHandler(req, res);
|
|
556
|
+
} else {
|
|
557
|
+
res.writeHead(HTTP_STATUS.NOT_FOUND, { "Content-Type": "text/html" });
|
|
558
|
+
res.end("<h1>404 Not Found</h1>");
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
getReservedSlugs() {
|
|
563
|
+
var slugs = new Set();
|
|
564
|
+
for (var i = 0; i < this.routes.length; i++) {
|
|
565
|
+
var parts = this.routes[i].pattern.split("/").filter(Boolean);
|
|
566
|
+
if (parts.length > 0 && !parts[0].startsWith(":")) {
|
|
567
|
+
slugs.add(parts[0].toLowerCase());
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return slugs;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
onNotFound(handler) {
|
|
574
|
+
this.notFoundHandler = handler;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
onError(handler) {
|
|
578
|
+
this.errorHandler = handler;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
listen(port, cb, tlsOptions, host) {
|
|
582
|
+
var self = this;
|
|
583
|
+
var requestHandler = (req, res) => {
|
|
584
|
+
// Response helpers
|
|
585
|
+
res.json = (data) => {
|
|
586
|
+
res.writeHead(res.statusCode || HTTP_STATUS.OK, { "Content-Type": "application/json" });
|
|
587
|
+
res.end(JSON.stringify(data));
|
|
588
|
+
};
|
|
589
|
+
res.redirect = (url) => {
|
|
590
|
+
// Same-origin redirects only by default. Apps that need cross-origin
|
|
591
|
+
// redirects (OAuth, SSO) wrap res.redirect with their own allowlist.
|
|
592
|
+
var safe = "/";
|
|
593
|
+
if (typeof url === "string" && url.startsWith("/") && !url.startsWith("//")) {
|
|
594
|
+
safe = url;
|
|
595
|
+
}
|
|
596
|
+
// 302 Found — RFC 7231 §6.4.3. Not in HTTP_STATUS table.
|
|
597
|
+
res.writeHead(302, { Location: safe });
|
|
598
|
+
res.end();
|
|
599
|
+
};
|
|
600
|
+
res.status = (code) => {
|
|
601
|
+
res.statusCode = code;
|
|
602
|
+
return res;
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
self.handle(req, res).catch((err) => {
|
|
606
|
+
log.error("route error: " + req.method + " " + req.url + " " + err.message + " " +
|
|
607
|
+
(err.stack ? err.stack.split("\n").slice(0, 5).join(" | ") : ""));
|
|
608
|
+
if (self.errorHandler) {
|
|
609
|
+
try { self.errorHandler(err, req, res); } catch (_) {
|
|
610
|
+
if (!res.writableEnded) {
|
|
611
|
+
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, { "Content-Type": "text/plain" });
|
|
612
|
+
res.end("Internal Server Error");
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
} else if (!res.writableEnded) {
|
|
616
|
+
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, { "Content-Type": "text/plain" });
|
|
617
|
+
res.end("Internal Server Error");
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
};
|
|
621
|
+
var server;
|
|
622
|
+
if (tlsOptions) {
|
|
623
|
+
// h2-capable server with h1 fallback via ALPN. ["h2", "http/1.1"]
|
|
624
|
+
// means modern clients negotiate h2 (preferred); legacy clients
|
|
625
|
+
// fall back to h1. allowHTTP1: true is what makes the same server
|
|
626
|
+
// accept both. enableConnectProtocol: true is what enables h2
|
|
627
|
+
// WebSocket (RFC 8441) — clients refuse to issue Extended CONNECT
|
|
628
|
+
// until they see this in the server's SETTINGS frame.
|
|
629
|
+
server = http2.createSecureServer(Object.assign({
|
|
630
|
+
allowHTTP1: true,
|
|
631
|
+
ALPNProtocols: ["h2", "http/1.1"],
|
|
632
|
+
settings: { enableConnectProtocol: true },
|
|
633
|
+
}, tlsOptions), requestHandler);
|
|
634
|
+
} else {
|
|
635
|
+
// Cleartext path is h1-only. Operators wanting h2c on cleartext
|
|
636
|
+
// are typically running behind a TLS-terminating LB that does
|
|
637
|
+
// h1↔h2 translation; the framework's TLS path covers that.
|
|
638
|
+
server = http.createServer(requestHandler);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ---- WebSocket wiring ----
|
|
642
|
+
// Only registers handlers when there are ws routes — keeps the
|
|
643
|
+
// server's emitter list clean for HTTP-only deployments.
|
|
644
|
+
if (self._wsRoutes.size > 0) {
|
|
645
|
+
// h1 upgrade event — fires for "Upgrade: websocket" from h1
|
|
646
|
+
// clients. Routes by path; refuses with 426 in h2-only mode.
|
|
647
|
+
server.on("upgrade", function (req, socket, head) {
|
|
648
|
+
var pathname = String(req.url || "/").split("?")[0];
|
|
649
|
+
var route = self._wsRoutes.get(pathname);
|
|
650
|
+
if (!route) {
|
|
651
|
+
socket.destroy();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (route.transport === "h2-only") {
|
|
655
|
+
// RFC-correct way to say "use h2": 426 Upgrade Required plus
|
|
656
|
+
// an Upgrade advisory pointing to h2c.
|
|
657
|
+
var body = "WebSocket on this path requires HTTP/2";
|
|
658
|
+
var resp =
|
|
659
|
+
"HTTP/1.1 426 Upgrade Required\r\n" +
|
|
660
|
+
"Upgrade: h2c\r\n" +
|
|
661
|
+
"Connection: close\r\n" +
|
|
662
|
+
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
663
|
+
"Content-Length: " + Buffer.byteLength(body, "utf8") + "\r\n" +
|
|
664
|
+
"\r\n" +
|
|
665
|
+
body;
|
|
666
|
+
try { socket.write(resp); } catch (_e) { /* socket already closed */ }
|
|
667
|
+
try { socket.destroy(); } catch (_e) { /* socket already destroyed */ }
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
var conn = websocket.handleUpgrade(req, socket, head, route.opts);
|
|
671
|
+
if (conn) {
|
|
672
|
+
self._activeWsConns.add(conn);
|
|
673
|
+
conn.once("close", function () { self._activeWsConns.delete(conn); });
|
|
674
|
+
try { route.handler(conn, req); }
|
|
675
|
+
catch (err) { log.error("ws handler threw: " + err.message); conn._abort(websocket.CLOSE_INTERNAL_ERROR, "handler error"); }
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// h2 Extended CONNECT — only fires on h2-capable (TLS) server.
|
|
680
|
+
// The 'stream' event filter checks for :method=CONNECT,
|
|
681
|
+
// :protocol=websocket. Other CONNECT methods (e.g. tunnel) and
|
|
682
|
+
// ordinary requests pass through.
|
|
683
|
+
if (tlsOptions) {
|
|
684
|
+
server.on("stream", function (stream, headers) {
|
|
685
|
+
if (headers[":method"] !== "CONNECT") return;
|
|
686
|
+
if (headers[":protocol"] !== "websocket") return;
|
|
687
|
+
var pathname = String(headers[":path"] || "/").split("?")[0];
|
|
688
|
+
var route = self._wsRoutes.get(pathname);
|
|
689
|
+
if (!route) {
|
|
690
|
+
try { stream.respond({ ":status": 404 }); stream.end(); } catch (_e) { /* stream already closed */ }
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (route.transport === "h1-only") {
|
|
694
|
+
try {
|
|
695
|
+
stream.respond({ ":status": 405, "content-type": "text/plain; charset=utf-8" });
|
|
696
|
+
stream.end("WebSocket on this path requires HTTP/1.1 Upgrade");
|
|
697
|
+
} catch (_e) { /* stream already closed */ }
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
var conn = websocket.handleExtendedConnect(stream, headers, route.opts);
|
|
701
|
+
if (conn) {
|
|
702
|
+
self._activeWsConns.add(conn);
|
|
703
|
+
conn.once("close", function () { self._activeWsConns.delete(conn); });
|
|
704
|
+
try { route.handler(conn, headers); }
|
|
705
|
+
catch (err) { log.error("ws handler threw: " + err.message); conn._abort(websocket.CLOSE_INTERNAL_ERROR, "handler error"); }
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (host) server.listen(port, host, cb);
|
|
712
|
+
else server.listen(port, cb);
|
|
713
|
+
server.timeout = C.TIME.minutes(5);
|
|
714
|
+
return server;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Static file serving middleware
|
|
719
|
+
function serveStatic(dir) {
|
|
720
|
+
var root = path.resolve(dir);
|
|
721
|
+
return (req, res, next) => {
|
|
722
|
+
if (req.method !== "GET") return next();
|
|
723
|
+
var rel = req.pathname;
|
|
724
|
+
if (rel.includes("\0")) return next();
|
|
725
|
+
var filePath = path.resolve(path.join(root, rel));
|
|
726
|
+
if (!filePath.startsWith(root)) return next();
|
|
727
|
+
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) return next();
|
|
728
|
+
|
|
729
|
+
var ext = path.extname(filePath).toLowerCase();
|
|
730
|
+
var mime = MIME_TYPES[ext] || "application/octet-stream";
|
|
731
|
+
var stat = fs.statSync(filePath);
|
|
732
|
+
var hasVersion = req.url && req.url.includes("?v=");
|
|
733
|
+
var cacheControl = hasVersion
|
|
734
|
+
? "public, max-age=31536000, immutable"
|
|
735
|
+
: "public, max-age=3600";
|
|
736
|
+
res.writeHead(HTTP_STATUS.OK, {
|
|
737
|
+
"Content-Type": mime,
|
|
738
|
+
"Content-Length": stat.size,
|
|
739
|
+
"Cache-Control": cacheControl,
|
|
740
|
+
});
|
|
741
|
+
fs.createReadStream(filePath).pipe(res);
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
module.exports = {
|
|
746
|
+
Router: Router,
|
|
747
|
+
serveStatic: serveStatic,
|
|
748
|
+
};
|