@j0hanz/superfetch 2.0.1 → 2.1.1
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 +121 -38
- package/dist/cache.d.ts +42 -0
- package/dist/cache.js +674 -0
- package/dist/config/env-parsers.d.ts +1 -0
- package/dist/config/env-parsers.js +12 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.js +10 -3
- package/dist/config/types/content.d.ts +1 -0
- package/dist/config.d.ts +82 -0
- package/dist/config.js +274 -0
- package/dist/crypto.d.ts +2 -0
- package/dist/crypto.js +32 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +28 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.js +930 -0
- package/dist/http/base-middleware.d.ts +7 -0
- package/dist/http/base-middleware.js +143 -0
- package/dist/http/cors.d.ts +0 -5
- package/dist/http/cors.js +0 -6
- package/dist/http/download-routes.js +6 -2
- package/dist/http/error-handler.d.ts +2 -0
- package/dist/http/error-handler.js +55 -0
- package/dist/http/mcp-routes.js +2 -2
- package/dist/http/mcp-sessions.d.ts +3 -5
- package/dist/http/mcp-sessions.js +8 -8
- package/dist/http/server-tuning.d.ts +9 -0
- package/dist/http/server-tuning.js +45 -0
- package/dist/http/server.d.ts +0 -10
- package/dist/http/server.js +33 -333
- package/dist/http.d.ts +86 -0
- package/dist/http.js +1507 -0
- package/dist/index.js +3 -3
- package/dist/instructions.md +96 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +104 -0
- package/dist/observability.d.ts +16 -0
- package/dist/observability.js +78 -0
- package/dist/server.js +20 -5
- package/dist/services/cache.d.ts +1 -1
- package/dist/services/context.d.ts +2 -0
- package/dist/services/context.js +3 -0
- package/dist/services/extractor.d.ts +1 -0
- package/dist/services/extractor.js +28 -2
- package/dist/services/fetcher.d.ts +2 -0
- package/dist/services/fetcher.js +35 -14
- package/dist/services/logger.js +4 -1
- package/dist/services/telemetry.d.ts +19 -0
- package/dist/services/telemetry.js +43 -0
- package/dist/services/transform-worker-pool.d.ts +10 -3
- package/dist/services/transform-worker-pool.js +213 -184
- package/dist/tools/handlers/fetch-url.tool.js +8 -6
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +13 -1
- package/dist/tools/schemas.d.ts +2 -0
- package/dist/tools/schemas.js +8 -0
- package/dist/tools/utils/content-transform-core.d.ts +5 -0
- package/dist/tools/utils/content-transform-core.js +180 -0
- package/dist/tools/utils/content-transform-workers.d.ts +1 -0
- package/dist/tools/utils/content-transform-workers.js +1 -0
- package/dist/tools/utils/content-transform.d.ts +3 -5
- package/dist/tools/utils/content-transform.js +35 -148
- package/dist/tools/utils/raw-markdown.js +15 -1
- package/dist/tools.d.ts +109 -0
- package/dist/tools.js +434 -0
- package/dist/transform.d.ts +69 -0
- package/dist/transform.js +1814 -0
- package/dist/transformers/markdown.d.ts +4 -1
- package/dist/transformers/markdown.js +182 -53
- package/dist/utils/cancellation.d.ts +1 -0
- package/dist/utils/cancellation.js +18 -0
- package/dist/utils/code-language.d.ts +0 -9
- package/dist/utils/code-language.js +5 -5
- package/dist/utils/host-normalizer.d.ts +1 -0
- package/dist/utils/host-normalizer.js +37 -0
- package/dist/utils/url-redactor.d.ts +1 -0
- package/dist/utils/url-redactor.js +13 -0
- package/dist/utils/url-validator.js +8 -5
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +3 -0
- package/dist/workers/transform-worker.js +80 -38
- package/package.json +10 -9
package/dist/http/server.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
1
|
import { setInterval as setIntervalPromise } from 'node:timers/promises';
|
|
3
2
|
import { config, enableHttpMode } from '../config/index.js';
|
|
4
|
-
import { FetchError } from '../errors/app-error.js';
|
|
5
|
-
import * as cache from '../services/cache.js';
|
|
6
|
-
import { runWithRequestContext } from '../services/context.js';
|
|
7
3
|
import { destroyAgents } from '../services/fetcher.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
4
|
+
import { logError, logInfo, logWarn } from '../services/logger.js';
|
|
5
|
+
import { shutdownTransformWorkerPool } from '../services/transform-worker-pool.js';
|
|
10
6
|
import { getErrorMessage } from '../utils/error-details.js';
|
|
11
|
-
import { generateSafeFilename } from '../utils/filename-generator.js';
|
|
12
7
|
import { createAuthMetadataRouter, createAuthMiddleware } from './auth.js';
|
|
8
|
+
import { attachBaseMiddleware } from './base-middleware.js';
|
|
9
|
+
import { createCorsMiddleware } from './cors.js';
|
|
10
|
+
import { registerDownloadRoutes } from './download-routes.js';
|
|
11
|
+
import { errorHandler } from './error-handler.js';
|
|
13
12
|
import { registerMcpRoutes } from './mcp-routes.js';
|
|
14
|
-
import { createSessionStore,
|
|
13
|
+
import { createSessionStore, startSessionCleanupLoop, } from './mcp-sessions.js';
|
|
14
|
+
import { applyHttpServerTuning, drainConnectionsOnShutdown, } from './server-tuning.js';
|
|
15
15
|
function getRateLimitKey(req) {
|
|
16
16
|
return req.ip ?? req.socket.remoteAddress ?? 'unknown';
|
|
17
17
|
}
|
|
@@ -104,327 +104,6 @@ function handleRateLimitExceeded(res, entry, now, options) {
|
|
|
104
104
|
});
|
|
105
105
|
return true;
|
|
106
106
|
}
|
|
107
|
-
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
108
|
-
function getNonEmptyStringHeader(value) {
|
|
109
|
-
if (typeof value !== 'string')
|
|
110
|
-
return null;
|
|
111
|
-
const trimmed = value.trim();
|
|
112
|
-
return trimmed === '' ? null : trimmed;
|
|
113
|
-
}
|
|
114
|
-
function respondHostNotAllowed(res) {
|
|
115
|
-
res.status(403).json({
|
|
116
|
-
error: 'Host not allowed',
|
|
117
|
-
code: 'HOST_NOT_ALLOWED',
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
function respondOriginNotAllowed(res) {
|
|
121
|
-
res.status(403).json({
|
|
122
|
-
error: 'Origin not allowed',
|
|
123
|
-
code: 'ORIGIN_NOT_ALLOWED',
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
function tryParseOriginHostname(originHeader) {
|
|
127
|
-
try {
|
|
128
|
-
return new URL(originHeader).hostname.toLowerCase();
|
|
129
|
-
}
|
|
130
|
-
catch {
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
function takeFirstHostValue(value) {
|
|
135
|
-
const first = value.split(',')[0];
|
|
136
|
-
if (!first)
|
|
137
|
-
return null;
|
|
138
|
-
const trimmed = first.trim();
|
|
139
|
-
return trimmed ? trimmed : null;
|
|
140
|
-
}
|
|
141
|
-
function stripIpv6Brackets(value) {
|
|
142
|
-
if (!value.startsWith('['))
|
|
143
|
-
return null;
|
|
144
|
-
const end = value.indexOf(']');
|
|
145
|
-
if (end === -1)
|
|
146
|
-
return null;
|
|
147
|
-
return value.slice(1, end);
|
|
148
|
-
}
|
|
149
|
-
function stripPortIfPresent(value) {
|
|
150
|
-
const colonIndex = value.indexOf(':');
|
|
151
|
-
if (colonIndex === -1)
|
|
152
|
-
return value;
|
|
153
|
-
return value.slice(0, colonIndex);
|
|
154
|
-
}
|
|
155
|
-
function normalizeHost(value) {
|
|
156
|
-
const trimmed = value.trim().toLowerCase();
|
|
157
|
-
if (!trimmed)
|
|
158
|
-
return null;
|
|
159
|
-
const first = takeFirstHostValue(trimmed);
|
|
160
|
-
if (!first)
|
|
161
|
-
return null;
|
|
162
|
-
const ipv6 = stripIpv6Brackets(first);
|
|
163
|
-
if (ipv6)
|
|
164
|
-
return ipv6;
|
|
165
|
-
return stripPortIfPresent(first);
|
|
166
|
-
}
|
|
167
|
-
function isWildcardHost(host) {
|
|
168
|
-
return host === '0.0.0.0' || host === '::';
|
|
169
|
-
}
|
|
170
|
-
function addLoopbackHosts(allowedHosts) {
|
|
171
|
-
for (const host of LOOPBACK_HOSTS) {
|
|
172
|
-
allowedHosts.add(host);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
function addConfiguredHost(allowedHosts) {
|
|
176
|
-
const configuredHost = normalizeHost(config.server.host);
|
|
177
|
-
if (!configuredHost)
|
|
178
|
-
return;
|
|
179
|
-
if (isWildcardHost(configuredHost))
|
|
180
|
-
return;
|
|
181
|
-
allowedHosts.add(configuredHost);
|
|
182
|
-
}
|
|
183
|
-
function addExplicitAllowedHosts(allowedHosts) {
|
|
184
|
-
for (const host of config.security.allowedHosts) {
|
|
185
|
-
allowedHosts.add(host);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
function buildAllowedHosts() {
|
|
189
|
-
const allowedHosts = new Set();
|
|
190
|
-
addLoopbackHosts(allowedHosts);
|
|
191
|
-
addConfiguredHost(allowedHosts);
|
|
192
|
-
addExplicitAllowedHosts(allowedHosts);
|
|
193
|
-
return allowedHosts;
|
|
194
|
-
}
|
|
195
|
-
function createHostValidationMiddleware() {
|
|
196
|
-
const allowedHosts = buildAllowedHosts();
|
|
197
|
-
return (req, res, next) => {
|
|
198
|
-
const hostHeader = typeof req.headers.host === 'string' ? req.headers.host : '';
|
|
199
|
-
const normalized = normalizeHost(hostHeader);
|
|
200
|
-
if (!normalized || !allowedHosts.has(normalized)) {
|
|
201
|
-
respondHostNotAllowed(res);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
next();
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
function createOriginValidationMiddleware() {
|
|
208
|
-
const allowedHosts = buildAllowedHosts();
|
|
209
|
-
return (req, res, next) => {
|
|
210
|
-
const originHeader = getNonEmptyStringHeader(req.headers.origin);
|
|
211
|
-
if (!originHeader) {
|
|
212
|
-
next();
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
const originHostname = tryParseOriginHostname(originHeader);
|
|
216
|
-
if (!originHostname || !allowedHosts.has(originHostname)) {
|
|
217
|
-
respondOriginNotAllowed(res);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
next();
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
function createJsonParseErrorHandler() {
|
|
224
|
-
return (err, _req, res, next) => {
|
|
225
|
-
if (err instanceof SyntaxError && 'body' in err) {
|
|
226
|
-
res.status(400).json({
|
|
227
|
-
jsonrpc: '2.0',
|
|
228
|
-
error: {
|
|
229
|
-
code: -32700,
|
|
230
|
-
message: 'Parse error: Invalid JSON',
|
|
231
|
-
},
|
|
232
|
-
id: null,
|
|
233
|
-
});
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
next();
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
function createContextMiddleware() {
|
|
240
|
-
return (req, _res, next) => {
|
|
241
|
-
const requestId = randomUUID();
|
|
242
|
-
const sessionId = getSessionId(req);
|
|
243
|
-
const context = sessionId === undefined ? { requestId } : { requestId, sessionId };
|
|
244
|
-
runWithRequestContext(context, () => {
|
|
245
|
-
next();
|
|
246
|
-
});
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
export function createCorsMiddleware() {
|
|
250
|
-
return (req, res, next) => {
|
|
251
|
-
// Handle OPTIONS preflight
|
|
252
|
-
if (req.method === 'OPTIONS') {
|
|
253
|
-
res.sendStatus(200);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
next();
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
function registerHealthRoute(app) {
|
|
260
|
-
app.get('/health', (_req, res) => {
|
|
261
|
-
res.json({
|
|
262
|
-
status: 'healthy',
|
|
263
|
-
name: config.server.name,
|
|
264
|
-
version: config.server.version,
|
|
265
|
-
uptime: process.uptime(),
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
export function attachBaseMiddleware(options) {
|
|
270
|
-
const { app, jsonParser, rateLimitMiddleware, corsMiddleware } = options;
|
|
271
|
-
app.use(createHostValidationMiddleware());
|
|
272
|
-
app.use(createOriginValidationMiddleware());
|
|
273
|
-
app.use(jsonParser);
|
|
274
|
-
app.use(createContextMiddleware());
|
|
275
|
-
app.use(createJsonParseErrorHandler());
|
|
276
|
-
app.use(corsMiddleware);
|
|
277
|
-
app.use('/mcp', rateLimitMiddleware);
|
|
278
|
-
registerHealthRoute(app);
|
|
279
|
-
}
|
|
280
|
-
const HASH_PATTERN = /^[a-f0-9.]+$/i;
|
|
281
|
-
function validateNamespace(namespace) {
|
|
282
|
-
return namespace === 'markdown';
|
|
283
|
-
}
|
|
284
|
-
function validateHash(hash) {
|
|
285
|
-
return HASH_PATTERN.test(hash) && hash.length >= 8 && hash.length <= 64;
|
|
286
|
-
}
|
|
287
|
-
function parseDownloadParams(req) {
|
|
288
|
-
const { namespace, hash } = req.params;
|
|
289
|
-
if (!namespace || !hash)
|
|
290
|
-
return null;
|
|
291
|
-
if (!validateNamespace(namespace))
|
|
292
|
-
return null;
|
|
293
|
-
if (!validateHash(hash))
|
|
294
|
-
return null;
|
|
295
|
-
return { namespace, hash };
|
|
296
|
-
}
|
|
297
|
-
function buildCacheKeyFromParams(params) {
|
|
298
|
-
return `${params.namespace}:${params.hash}`;
|
|
299
|
-
}
|
|
300
|
-
function respondBadRequest(res, message) {
|
|
301
|
-
res.status(400).json({
|
|
302
|
-
error: message,
|
|
303
|
-
code: 'BAD_REQUEST',
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
function respondNotFound(res) {
|
|
307
|
-
res.status(404).json({
|
|
308
|
-
error: 'Content not found or expired',
|
|
309
|
-
code: 'NOT_FOUND',
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
function respondServiceUnavailable(res) {
|
|
313
|
-
res.status(503).json({
|
|
314
|
-
error: 'Download service is disabled',
|
|
315
|
-
code: 'SERVICE_UNAVAILABLE',
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
function resolveDownloadPayload(params, cacheEntry) {
|
|
319
|
-
const payload = parseCachedPayload(cacheEntry.content);
|
|
320
|
-
if (!payload)
|
|
321
|
-
return null;
|
|
322
|
-
const content = resolveCachedPayloadContent(payload);
|
|
323
|
-
if (!content)
|
|
324
|
-
return null;
|
|
325
|
-
const safeTitle = typeof payload.title === 'string' ? payload.title : undefined;
|
|
326
|
-
const fileName = generateSafeFilename(cacheEntry.url, cacheEntry.title ?? safeTitle, params.hash, '.md');
|
|
327
|
-
return {
|
|
328
|
-
content,
|
|
329
|
-
contentType: 'text/markdown; charset=utf-8',
|
|
330
|
-
fileName,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
function buildContentDisposition(fileName) {
|
|
334
|
-
const encodedName = encodeURIComponent(fileName).replace(/'/g, '%27');
|
|
335
|
-
return `attachment; filename="${fileName}"; filename*=UTF-8''${encodedName}`;
|
|
336
|
-
}
|
|
337
|
-
function sendDownloadPayload(res, payload) {
|
|
338
|
-
const disposition = buildContentDisposition(payload.fileName);
|
|
339
|
-
res.setHeader('Content-Type', payload.contentType);
|
|
340
|
-
res.setHeader('Content-Disposition', disposition);
|
|
341
|
-
res.setHeader('Cache-Control', `private, max-age=${config.cache.ttl}`);
|
|
342
|
-
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
343
|
-
res.send(payload.content);
|
|
344
|
-
}
|
|
345
|
-
function handleDownload(req, res) {
|
|
346
|
-
if (!config.cache.enabled) {
|
|
347
|
-
respondServiceUnavailable(res);
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
const params = parseDownloadParams(req);
|
|
351
|
-
if (!params) {
|
|
352
|
-
respondBadRequest(res, 'Invalid namespace or hash format');
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
const cacheKey = buildCacheKeyFromParams(params);
|
|
356
|
-
const cacheEntry = cache.get(cacheKey);
|
|
357
|
-
if (!cacheEntry) {
|
|
358
|
-
logDebug('Download request for missing cache key', { cacheKey });
|
|
359
|
-
respondNotFound(res);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
const payload = resolveDownloadPayload(params, cacheEntry);
|
|
363
|
-
if (!payload) {
|
|
364
|
-
logDebug('Download payload unavailable', { cacheKey });
|
|
365
|
-
respondNotFound(res);
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
logDebug('Serving download', { cacheKey, fileName: payload.fileName });
|
|
369
|
-
sendDownloadPayload(res, payload);
|
|
370
|
-
}
|
|
371
|
-
export function registerDownloadRoutes(app) {
|
|
372
|
-
app.get('/mcp/downloads/:namespace/:hash', handleDownload);
|
|
373
|
-
}
|
|
374
|
-
function getStatusCode(fetchError) {
|
|
375
|
-
return fetchError ? fetchError.statusCode : 500;
|
|
376
|
-
}
|
|
377
|
-
function getErrorCode(fetchError) {
|
|
378
|
-
return fetchError ? fetchError.code : 'INTERNAL_ERROR';
|
|
379
|
-
}
|
|
380
|
-
function getFetchErrorMessage(fetchError) {
|
|
381
|
-
return fetchError ? fetchError.message : 'Internal Server Error';
|
|
382
|
-
}
|
|
383
|
-
function getErrorDetails(fetchError) {
|
|
384
|
-
if (fetchError && Object.keys(fetchError.details).length > 0) {
|
|
385
|
-
return fetchError.details;
|
|
386
|
-
}
|
|
387
|
-
return undefined;
|
|
388
|
-
}
|
|
389
|
-
function setRetryAfterHeader(res, fetchError) {
|
|
390
|
-
const retryAfter = resolveRetryAfter(fetchError);
|
|
391
|
-
if (retryAfter === undefined)
|
|
392
|
-
return;
|
|
393
|
-
res.set('Retry-After', retryAfter);
|
|
394
|
-
}
|
|
395
|
-
function buildErrorResponse(fetchError) {
|
|
396
|
-
const details = getErrorDetails(fetchError);
|
|
397
|
-
const response = {
|
|
398
|
-
error: {
|
|
399
|
-
message: getFetchErrorMessage(fetchError),
|
|
400
|
-
code: getErrorCode(fetchError),
|
|
401
|
-
statusCode: getStatusCode(fetchError),
|
|
402
|
-
...(details && { details }),
|
|
403
|
-
},
|
|
404
|
-
};
|
|
405
|
-
// Never expose stack traces in production
|
|
406
|
-
return response;
|
|
407
|
-
}
|
|
408
|
-
function resolveRetryAfter(fetchError) {
|
|
409
|
-
if (fetchError?.statusCode !== 429)
|
|
410
|
-
return undefined;
|
|
411
|
-
const { retryAfter } = fetchError.details;
|
|
412
|
-
return isRetryAfterValue(retryAfter) ? String(retryAfter) : undefined;
|
|
413
|
-
}
|
|
414
|
-
function isRetryAfterValue(value) {
|
|
415
|
-
return typeof value === 'number' || typeof value === 'string';
|
|
416
|
-
}
|
|
417
|
-
export function errorHandler(err, req, res, next) {
|
|
418
|
-
if (res.headersSent) {
|
|
419
|
-
next(err);
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
const fetchError = err instanceof FetchError ? err : null;
|
|
423
|
-
const statusCode = getStatusCode(fetchError);
|
|
424
|
-
logError(`HTTP ${statusCode}: ${err.message} - ${req.method} ${req.path}`, err);
|
|
425
|
-
setRetryAfterHeader(res, fetchError);
|
|
426
|
-
res.status(statusCode).json(buildErrorResponse(fetchError));
|
|
427
|
-
}
|
|
428
107
|
function assertHttpConfiguration() {
|
|
429
108
|
ensureBindAllowed();
|
|
430
109
|
ensureStaticTokens();
|
|
@@ -438,7 +117,9 @@ function ensureBindAllowed() {
|
|
|
438
117
|
logError('Refusing to bind to non-loopback host without ALLOW_REMOTE=true', { host: config.server.host });
|
|
439
118
|
process.exit(1);
|
|
440
119
|
}
|
|
441
|
-
if (
|
|
120
|
+
if (!isLoopback &&
|
|
121
|
+
config.security.allowRemote &&
|
|
122
|
+
config.auth.mode !== 'oauth') {
|
|
442
123
|
logError('Remote HTTP mode requires OAuth configuration; refusing to start');
|
|
443
124
|
process.exit(1);
|
|
444
125
|
}
|
|
@@ -464,7 +145,23 @@ function ensureOauthConfiguration() {
|
|
|
464
145
|
}
|
|
465
146
|
}
|
|
466
147
|
function createShutdownHandler(server, sessionStore, sessionCleanupController, stopRateLimitCleanup) {
|
|
467
|
-
|
|
148
|
+
let inFlight = null;
|
|
149
|
+
let initialSignal = null;
|
|
150
|
+
return (signal) => {
|
|
151
|
+
if (inFlight) {
|
|
152
|
+
logWarn('Shutdown already in progress; ignoring signal', {
|
|
153
|
+
signal,
|
|
154
|
+
initialSignal,
|
|
155
|
+
});
|
|
156
|
+
return inFlight;
|
|
157
|
+
}
|
|
158
|
+
initialSignal = signal;
|
|
159
|
+
inFlight = shutdownServer(signal, server, sessionStore, sessionCleanupController, stopRateLimitCleanup).catch((error) => {
|
|
160
|
+
logError('Shutdown handler failed', error instanceof Error ? error : { error: getErrorMessage(error) });
|
|
161
|
+
throw error;
|
|
162
|
+
});
|
|
163
|
+
return inFlight;
|
|
164
|
+
};
|
|
468
165
|
}
|
|
469
166
|
async function shutdownServer(signal, server, sessionStore, sessionCleanupController, stopRateLimitCleanup) {
|
|
470
167
|
logInfo(`${signal} received, shutting down gracefully...`);
|
|
@@ -472,6 +169,8 @@ async function shutdownServer(signal, server, sessionStore, sessionCleanupContro
|
|
|
472
169
|
sessionCleanupController.abort();
|
|
473
170
|
await closeSessions(sessionStore);
|
|
474
171
|
destroyAgents();
|
|
172
|
+
await shutdownTransformWorkerPool();
|
|
173
|
+
drainConnectionsOnShutdown(server);
|
|
475
174
|
closeServer(server);
|
|
476
175
|
scheduleForcedShutdown(10000);
|
|
477
176
|
}
|
|
@@ -496,10 +195,10 @@ function scheduleForcedShutdown(timeoutMs) {
|
|
|
496
195
|
}, timeoutMs).unref();
|
|
497
196
|
}
|
|
498
197
|
function registerSignalHandlers(shutdown) {
|
|
499
|
-
process.
|
|
198
|
+
process.once('SIGINT', () => {
|
|
500
199
|
void shutdown('SIGINT');
|
|
501
200
|
});
|
|
502
|
-
process.
|
|
201
|
+
process.once('SIGTERM', () => {
|
|
503
202
|
void shutdown('SIGTERM');
|
|
504
203
|
});
|
|
505
204
|
}
|
|
@@ -579,6 +278,7 @@ export async function startHttpServer() {
|
|
|
579
278
|
enableHttpMode();
|
|
580
279
|
const { app, sessionStore, sessionCleanupController, stopRateLimitCleanup } = await buildServerContext();
|
|
581
280
|
const server = startListening(app);
|
|
281
|
+
applyHttpServerTuning(server);
|
|
582
282
|
const shutdown = createShutdownHandler(server, sessionStore, sessionCleanupController, stopRateLimitCleanup);
|
|
583
283
|
registerSignalHandlers(shutdown);
|
|
584
284
|
return { shutdown };
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Express, NextFunction, Request, RequestHandler, Response } from 'express';
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
interface SessionEntry {
|
|
4
|
+
readonly transport: StreamableHTTPServerTransport;
|
|
5
|
+
createdAt: number;
|
|
6
|
+
lastSeen: number;
|
|
7
|
+
}
|
|
8
|
+
interface McpRequestParams {
|
|
9
|
+
_meta?: Record<string, unknown>;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
interface McpRequestBody {
|
|
13
|
+
jsonrpc: '2.0';
|
|
14
|
+
method: string;
|
|
15
|
+
id?: string | number;
|
|
16
|
+
params?: McpRequestParams;
|
|
17
|
+
}
|
|
18
|
+
export declare function startHttpServer(options?: {
|
|
19
|
+
registerSignalHandlers?: boolean;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
shutdown: (signal: string) => Promise<void>;
|
|
22
|
+
stop: () => Promise<void>;
|
|
23
|
+
url: string;
|
|
24
|
+
host: string;
|
|
25
|
+
port: number;
|
|
26
|
+
}>;
|
|
27
|
+
export declare function errorHandler(err: Error, req: Request, res: Response, next: NextFunction): void;
|
|
28
|
+
export declare function normalizeHost(value: string): string | null;
|
|
29
|
+
export declare function attachBaseMiddleware(options: {
|
|
30
|
+
app: Express;
|
|
31
|
+
jsonParser: RequestHandler;
|
|
32
|
+
rateLimitMiddleware: RequestHandler;
|
|
33
|
+
corsMiddleware: RequestHandler;
|
|
34
|
+
}): void;
|
|
35
|
+
export declare function createCorsMiddleware(): (req: Request, res: Response, next: NextFunction) => void;
|
|
36
|
+
export interface SessionStore {
|
|
37
|
+
get: (sessionId: string) => SessionEntry | undefined;
|
|
38
|
+
touch: (sessionId: string) => void;
|
|
39
|
+
set: (sessionId: string, entry: SessionEntry) => void;
|
|
40
|
+
remove: (sessionId: string) => SessionEntry | undefined;
|
|
41
|
+
size: () => number;
|
|
42
|
+
clear: () => SessionEntry[];
|
|
43
|
+
evictExpired: () => SessionEntry[];
|
|
44
|
+
evictOldest: () => SessionEntry | undefined;
|
|
45
|
+
}
|
|
46
|
+
interface McpSessionOptions {
|
|
47
|
+
readonly sessionStore: SessionStore;
|
|
48
|
+
readonly maxSessions: number;
|
|
49
|
+
}
|
|
50
|
+
export declare function createSessionStore(sessionTtlMs: number): SessionStore;
|
|
51
|
+
export declare function reserveSessionSlot(store: SessionStore, maxSessions: number): boolean;
|
|
52
|
+
interface SlotTracker {
|
|
53
|
+
readonly releaseSlot: () => void;
|
|
54
|
+
readonly markInitialized: () => void;
|
|
55
|
+
readonly isInitialized: () => boolean;
|
|
56
|
+
}
|
|
57
|
+
export declare function createSlotTracker(): SlotTracker;
|
|
58
|
+
export declare function ensureSessionCapacity({ store, maxSessions, res, evictOldest, }: {
|
|
59
|
+
store: SessionStore;
|
|
60
|
+
maxSessions: number;
|
|
61
|
+
res: Response;
|
|
62
|
+
evictOldest: (store: SessionStore) => boolean;
|
|
63
|
+
}): boolean;
|
|
64
|
+
type CloseHandler = (() => void) | undefined;
|
|
65
|
+
export declare function composeCloseHandlers(first: CloseHandler, second: CloseHandler): CloseHandler;
|
|
66
|
+
export declare function resolveTransportForPost({ res, body, sessionId, options, }: {
|
|
67
|
+
res: Response;
|
|
68
|
+
body: Pick<McpRequestBody, 'method' | 'id'>;
|
|
69
|
+
sessionId: string | undefined;
|
|
70
|
+
options: McpSessionOptions;
|
|
71
|
+
}): Promise<StreamableHTTPServerTransport | null>;
|
|
72
|
+
export declare function isJsonRpcBatchRequest(body: unknown): boolean;
|
|
73
|
+
export declare function isMcpRequestBody(body: unknown): body is McpRequestBody;
|
|
74
|
+
export declare function ensureMcpProtocolVersionHeader(req: Request, res: Response): boolean;
|
|
75
|
+
export declare function ensurePostAcceptHeader(req: Request): void;
|
|
76
|
+
export declare function acceptsEventStream(req: Request): boolean;
|
|
77
|
+
interface HttpServerTuningTarget {
|
|
78
|
+
headersTimeout?: number;
|
|
79
|
+
requestTimeout?: number;
|
|
80
|
+
keepAliveTimeout?: number;
|
|
81
|
+
closeIdleConnections?: () => void;
|
|
82
|
+
closeAllConnections?: () => void;
|
|
83
|
+
}
|
|
84
|
+
export declare function applyHttpServerTuning(server: HttpServerTuningTarget): void;
|
|
85
|
+
export declare function drainConnectionsOnShutdown(server: HttpServerTuningTarget): void;
|
|
86
|
+
export {};
|