@j0hanz/superfetch 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +243 -494
- package/dist/cache.d.ts +2 -3
- package/dist/cache.js +51 -241
- package/dist/config.d.ts +6 -1
- package/dist/config.js +29 -34
- package/dist/crypto.d.ts +0 -1
- package/dist/crypto.js +0 -1
- package/dist/dom-noise-removal.d.ts +5 -0
- package/dist/dom-noise-removal.js +485 -0
- package/dist/errors.d.ts +0 -1
- package/dist/errors.js +8 -6
- package/dist/fetch.d.ts +0 -1
- package/dist/fetch.js +71 -61
- package/dist/host-normalization.d.ts +1 -0
- package/dist/host-normalization.js +47 -0
- package/dist/http-native.d.ts +5 -0
- package/dist/http-native.js +693 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +1 -2
- package/dist/instructions.md +22 -20
- package/dist/json.d.ts +1 -0
- package/dist/json.js +29 -0
- package/dist/language-detection.d.ts +12 -0
- package/dist/language-detection.js +291 -0
- package/dist/markdown-cleanup.d.ts +18 -0
- package/dist/markdown-cleanup.js +283 -0
- package/dist/mcp-validator.d.ts +14 -0
- package/dist/mcp-validator.js +22 -0
- package/dist/mcp.d.ts +0 -1
- package/dist/mcp.js +0 -1
- package/dist/observability.d.ts +1 -1
- package/dist/observability.js +15 -3
- package/dist/server-tuning.d.ts +9 -0
- package/dist/server-tuning.js +30 -0
- package/dist/session.d.ts +36 -0
- package/dist/session.js +159 -0
- package/dist/tools.d.ts +0 -1
- package/dist/tools.js +23 -33
- package/dist/transform-types.d.ts +80 -0
- package/dist/transform-types.js +5 -0
- package/dist/transform.d.ts +7 -53
- package/dist/transform.js +434 -856
- package/dist/type-guards.d.ts +1 -2
- package/dist/type-guards.js +1 -2
- package/dist/workers/transform-worker.d.ts +0 -1
- package/dist/workers/transform-worker.js +52 -43
- package/package.json +11 -12
- package/dist/cache.d.ts.map +0 -1
- package/dist/cache.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/crypto.d.ts.map +0 -1
- package/dist/crypto.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/fetch.d.ts.map +0 -1
- package/dist/fetch.js.map +0 -1
- package/dist/http.d.ts +0 -90
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js +0 -1576
- package/dist/http.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mcp.d.ts.map +0 -1
- package/dist/mcp.js.map +0 -1
- package/dist/observability.d.ts.map +0 -1
- package/dist/observability.js.map +0 -1
- package/dist/tools.d.ts.map +0 -1
- package/dist/tools.js.map +0 -1
- package/dist/transform.d.ts.map +0 -1
- package/dist/transform.js.map +0 -1
- package/dist/type-guards.d.ts.map +0 -1
- package/dist/type-guards.js.map +0 -1
- package/dist/workers/transform-worker.d.ts.map +0 -1
- package/dist/workers/transform-worker.js.map +0 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown cleanup utilities for post-processing converted content.
|
|
3
|
+
*
|
|
4
|
+
* Goals:
|
|
5
|
+
* - Never mutate fenced code blocks (``` / ~~~) content.
|
|
6
|
+
* - Keep rules localized and readable.
|
|
7
|
+
* - Avoid multi-pass regexes that accidentally hit code blocks.
|
|
8
|
+
*/
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// Fence state helpers
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
function isFenceStart(line) {
|
|
13
|
+
const trimmed = line.trimStart();
|
|
14
|
+
return trimmed.startsWith('```') || trimmed.startsWith('~~~');
|
|
15
|
+
}
|
|
16
|
+
function extractFenceMarker(line) {
|
|
17
|
+
const trimmed = line.trimStart();
|
|
18
|
+
const match = /^(`{3,}|~{3,})/.exec(trimmed);
|
|
19
|
+
return match?.[1] ?? '```';
|
|
20
|
+
}
|
|
21
|
+
function isFenceEnd(line, marker) {
|
|
22
|
+
const trimmed = line.trimStart();
|
|
23
|
+
return (trimmed.startsWith(marker) && trimmed.slice(marker.length).trim() === '');
|
|
24
|
+
}
|
|
25
|
+
function initialFenceState() {
|
|
26
|
+
return { inFence: false, marker: '' };
|
|
27
|
+
}
|
|
28
|
+
function advanceFenceState(line, state) {
|
|
29
|
+
if (!state.inFence && isFenceStart(line)) {
|
|
30
|
+
state.inFence = true;
|
|
31
|
+
state.marker = extractFenceMarker(line);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (state.inFence && isFenceEnd(line, state.marker)) {
|
|
35
|
+
state.inFence = false;
|
|
36
|
+
state.marker = '';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// Segment utilities
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
/**
|
|
43
|
+
* Split markdown into segments where each segment is either fully inside
|
|
44
|
+
* a fenced block (including the fence lines), or fully outside.
|
|
45
|
+
*/
|
|
46
|
+
function splitByFences(content) {
|
|
47
|
+
const lines = content.split('\n');
|
|
48
|
+
const segments = [];
|
|
49
|
+
const state = initialFenceState();
|
|
50
|
+
let current = [];
|
|
51
|
+
let currentIsFence = false;
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
// Transition into fence: flush outside segment first.
|
|
54
|
+
if (!state.inFence && isFenceStart(line)) {
|
|
55
|
+
if (current.length > 0) {
|
|
56
|
+
segments.push({ content: current.join('\n'), inFence: currentIsFence });
|
|
57
|
+
current = [];
|
|
58
|
+
}
|
|
59
|
+
currentIsFence = true;
|
|
60
|
+
current.push(line);
|
|
61
|
+
advanceFenceState(line, state);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
current.push(line);
|
|
65
|
+
const wasInFence = state.inFence;
|
|
66
|
+
advanceFenceState(line, state);
|
|
67
|
+
// Transition out of fence: flush fence segment.
|
|
68
|
+
if (wasInFence && !state.inFence) {
|
|
69
|
+
segments.push({ content: current.join('\n'), inFence: true });
|
|
70
|
+
current = [];
|
|
71
|
+
currentIsFence = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (current.length > 0) {
|
|
75
|
+
segments.push({ content: current.join('\n'), inFence: currentIsFence });
|
|
76
|
+
}
|
|
77
|
+
return segments;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Apply a transformation function only to non-fenced content.
|
|
81
|
+
*/
|
|
82
|
+
function mapOutsideFences(content, transform) {
|
|
83
|
+
const segments = splitByFences(content);
|
|
84
|
+
return segments
|
|
85
|
+
.map((seg) => (seg.inFence ? seg.content : transform(seg.content)))
|
|
86
|
+
.join('\n');
|
|
87
|
+
}
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
// Cleanup rules (OUTSIDE fences only)
|
|
90
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
function removeEmptyHeadings(text) {
|
|
92
|
+
return text.replace(/^#{1,6}[ \t\u00A0]*$\r?\n?/gm, '');
|
|
93
|
+
}
|
|
94
|
+
function fixOrphanHeadings(text) {
|
|
95
|
+
// Pattern: hashes on their own line, blank line, then a "heading-like" line.
|
|
96
|
+
return text.replace(/^(.*?)(#{1,6})\s*(?:\r?\n){2}([A-Z][^\r\n]+?)(?:\r?\n)/gm, (_match, prefix, hashes, heading) => {
|
|
97
|
+
if (heading.length > 150)
|
|
98
|
+
return _match;
|
|
99
|
+
const trimmedPrefix = prefix.trim();
|
|
100
|
+
if (trimmedPrefix === '') {
|
|
101
|
+
return `${hashes} ${heading}\n\n`;
|
|
102
|
+
}
|
|
103
|
+
return `${trimmedPrefix}\n\n${hashes} ${heading}\n\n`;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function removeSkipLinksAndEmptyAnchors(text) {
|
|
107
|
+
const zeroWidthAnchorLink = /\[(?:\s|\u200B)*\]\(#[^)]*\)\s*/g;
|
|
108
|
+
return text
|
|
109
|
+
.replace(zeroWidthAnchorLink, '')
|
|
110
|
+
.replace(/^\[Skip to (?:main )?content\]\(#[^)]*\)\s*$/gim, '')
|
|
111
|
+
.replace(/^\[Skip to (?:main )?navigation\]\(#[^)]*\)\s*$/gim, '')
|
|
112
|
+
.replace(/^\[Skip link\]\(#[^)]*\)\s*$/gim, '');
|
|
113
|
+
}
|
|
114
|
+
function ensureBlankLineAfterHeadings(text) {
|
|
115
|
+
// Heading followed immediately by a fence marker
|
|
116
|
+
text = text.replace(/(^#{1,6}\s+\w+)```/gm, '$1\n\n```');
|
|
117
|
+
// Heuristic: Some converters jam words together after a heading
|
|
118
|
+
text = text.replace(/(^#{1,6}\s+\w*[A-Z])([A-Z][a-z])/gm, '$1\n\n$2');
|
|
119
|
+
// Any heading line should be followed by a blank line before body
|
|
120
|
+
return text.replace(/(^#{1,6}\s[^\n]*)\n([^\n])/gm, '$1\n\n$2');
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Remove markdown TOC blocks of the form:
|
|
124
|
+
* - [Title](#anchor)
|
|
125
|
+
* outside fenced code blocks.
|
|
126
|
+
*/
|
|
127
|
+
function removeTocBlocks(text) {
|
|
128
|
+
const tocLine = /^- \[[^\]]+\]\(#[^)]+\)\s*$/;
|
|
129
|
+
const lines = text.split('\n');
|
|
130
|
+
const out = [];
|
|
131
|
+
let skipping = false;
|
|
132
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
133
|
+
const line = lines[i] ?? '';
|
|
134
|
+
const prev = i > 0 ? (lines[i - 1] ?? '') : '';
|
|
135
|
+
const next = i < lines.length - 1 ? (lines[i + 1] ?? '') : '';
|
|
136
|
+
if (tocLine.test(line)) {
|
|
137
|
+
const prevIsToc = tocLine.test(prev) || prev.trim() === '';
|
|
138
|
+
const nextIsToc = tocLine.test(next) || next.trim() === '';
|
|
139
|
+
if (prevIsToc || nextIsToc) {
|
|
140
|
+
skipping = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (skipping) {
|
|
145
|
+
if (line.trim() === '') {
|
|
146
|
+
skipping = false;
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
out.push(line);
|
|
151
|
+
}
|
|
152
|
+
return out.join('\n');
|
|
153
|
+
}
|
|
154
|
+
function tidyLinksAndEscapes(text) {
|
|
155
|
+
return text
|
|
156
|
+
.replace(/\]\(([^)]+)\)\[/g, ']($1)\n\n[')
|
|
157
|
+
.replace(/^Was this page helpful\??\s*$/gim, '')
|
|
158
|
+
.replace(/(`[^`]+`)\s*\\-\s*/g, '$1 - ')
|
|
159
|
+
.replace(/\\([[]])/g, '$1');
|
|
160
|
+
}
|
|
161
|
+
function normalizeListsAndSpacing(text) {
|
|
162
|
+
// Ensure blank line before list starts (bullet/ordered)
|
|
163
|
+
text = text.replace(/([^\n])\n([-*+] )/g, '$1\n\n$2');
|
|
164
|
+
text = text.replace(/(\S)\n(\d+\. )/g, '$1\n\n$2');
|
|
165
|
+
// Collapse excessive blank lines
|
|
166
|
+
return text.replace(/\n{3,}/g, '\n\n');
|
|
167
|
+
}
|
|
168
|
+
const CLEANUP_STEPS = [
|
|
169
|
+
fixOrphanHeadings,
|
|
170
|
+
removeEmptyHeadings,
|
|
171
|
+
removeSkipLinksAndEmptyAnchors,
|
|
172
|
+
ensureBlankLineAfterHeadings,
|
|
173
|
+
removeTocBlocks,
|
|
174
|
+
tidyLinksAndEscapes,
|
|
175
|
+
normalizeListsAndSpacing,
|
|
176
|
+
];
|
|
177
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
// Public API
|
|
179
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
180
|
+
/**
|
|
181
|
+
* Clean up common markdown artifacts and formatting issues.
|
|
182
|
+
* IMPORTANT: All rules are applied ONLY outside fenced code blocks.
|
|
183
|
+
*/
|
|
184
|
+
export function cleanupMarkdownArtifacts(content) {
|
|
185
|
+
if (!content)
|
|
186
|
+
return '';
|
|
187
|
+
const cleaned = mapOutsideFences(content, (outside) => {
|
|
188
|
+
return CLEANUP_STEPS.reduce((text, step) => step(text), outside);
|
|
189
|
+
});
|
|
190
|
+
return cleaned.trim();
|
|
191
|
+
}
|
|
192
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
// Heading Promotion (fence-aware)
|
|
194
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
const HEADING_KEYWORDS = new Set([
|
|
196
|
+
'overview',
|
|
197
|
+
'introduction',
|
|
198
|
+
'summary',
|
|
199
|
+
'conclusion',
|
|
200
|
+
'prerequisites',
|
|
201
|
+
'requirements',
|
|
202
|
+
'installation',
|
|
203
|
+
'configuration',
|
|
204
|
+
'usage',
|
|
205
|
+
'features',
|
|
206
|
+
'limitations',
|
|
207
|
+
'troubleshooting',
|
|
208
|
+
'faq',
|
|
209
|
+
'resources',
|
|
210
|
+
'references',
|
|
211
|
+
'changelog',
|
|
212
|
+
'license',
|
|
213
|
+
'acknowledgments',
|
|
214
|
+
'appendix',
|
|
215
|
+
]);
|
|
216
|
+
function isLikelyHeadingLine(line) {
|
|
217
|
+
const trimmed = line.trim();
|
|
218
|
+
if (!trimmed || trimmed.length > 80)
|
|
219
|
+
return false;
|
|
220
|
+
if (/^#{1,6}\s/.test(trimmed))
|
|
221
|
+
return false;
|
|
222
|
+
if (/^[-*+•]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed))
|
|
223
|
+
return false;
|
|
224
|
+
if (/[.!?]$/.test(trimmed))
|
|
225
|
+
return false;
|
|
226
|
+
if (/^\[.*\]\(.*\)$/.test(trimmed))
|
|
227
|
+
return false;
|
|
228
|
+
if (/^(?:example|note|tip|warning|important|caution):\s+\S/i.test(trimmed)) {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
const words = trimmed.split(/\s+/);
|
|
232
|
+
if (words.length >= 2 && words.length <= 6) {
|
|
233
|
+
const isTitleCase = words.every((w) => /^[A-Z][a-z]*$/.test(w) || /^(?:and|or|the|of|in|for|to|a)$/i.test(w));
|
|
234
|
+
if (isTitleCase)
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if (words.length === 1) {
|
|
238
|
+
const lower = trimmed.toLowerCase();
|
|
239
|
+
if (HEADING_KEYWORDS.has(lower) && /^[A-Z]/.test(trimmed))
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
function shouldPromoteToHeading(line, prevLine) {
|
|
245
|
+
const isPrecededByBlank = prevLine.trim() === '';
|
|
246
|
+
if (!isPrecededByBlank)
|
|
247
|
+
return false;
|
|
248
|
+
return isLikelyHeadingLine(line);
|
|
249
|
+
}
|
|
250
|
+
function formatAsHeading(line) {
|
|
251
|
+
const trimmed = line.trim();
|
|
252
|
+
const isExample = /^example:\s/i.test(trimmed);
|
|
253
|
+
const prefix = isExample ? '### ' : '## ';
|
|
254
|
+
return prefix + trimmed;
|
|
255
|
+
}
|
|
256
|
+
function processNonFencedLine(line, prevLine) {
|
|
257
|
+
if (shouldPromoteToHeading(line, prevLine)) {
|
|
258
|
+
return formatAsHeading(line);
|
|
259
|
+
}
|
|
260
|
+
return line;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Promote standalone lines that look like headings to proper markdown headings.
|
|
264
|
+
* Fence-aware: never modifies content inside fenced code blocks.
|
|
265
|
+
*/
|
|
266
|
+
export function promoteOrphanHeadings(markdown) {
|
|
267
|
+
if (!markdown)
|
|
268
|
+
return '';
|
|
269
|
+
const lines = markdown.split('\n');
|
|
270
|
+
const result = [];
|
|
271
|
+
const state = initialFenceState();
|
|
272
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
273
|
+
const line = lines[i] ?? '';
|
|
274
|
+
const prevLine = i > 0 ? (lines[i - 1] ?? '') : '';
|
|
275
|
+
if (state.inFence || isFenceStart(line)) {
|
|
276
|
+
result.push(line);
|
|
277
|
+
advanceFenceState(line, state);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
result.push(processNonFencedLine(line, prevLine));
|
|
281
|
+
}
|
|
282
|
+
return result.join('\n');
|
|
283
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type JsonRpcId = string | number | null;
|
|
2
|
+
export interface McpRequestParams {
|
|
3
|
+
_meta?: Record<string, unknown>;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface McpRequestBody {
|
|
7
|
+
jsonrpc: '2.0';
|
|
8
|
+
method: string;
|
|
9
|
+
id?: JsonRpcId;
|
|
10
|
+
params?: McpRequestParams;
|
|
11
|
+
}
|
|
12
|
+
export declare function isJsonRpcBatchRequest(body: unknown): boolean;
|
|
13
|
+
export declare function isMcpRequestBody(body: unknown): body is McpRequestBody;
|
|
14
|
+
export declare function acceptsEventStream(header: string | null | undefined): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// --- Validation ---
|
|
3
|
+
const paramsSchema = z.looseObject({});
|
|
4
|
+
const mcpRequestSchema = z.looseObject({
|
|
5
|
+
jsonrpc: z.literal('2.0'),
|
|
6
|
+
method: z.string().min(1),
|
|
7
|
+
id: z.union([z.string(), z.number(), z.null()]).optional(),
|
|
8
|
+
params: paramsSchema.optional(),
|
|
9
|
+
});
|
|
10
|
+
export function isJsonRpcBatchRequest(body) {
|
|
11
|
+
return Array.isArray(body);
|
|
12
|
+
}
|
|
13
|
+
export function isMcpRequestBody(body) {
|
|
14
|
+
return mcpRequestSchema.safeParse(body).success;
|
|
15
|
+
}
|
|
16
|
+
export function acceptsEventStream(header) {
|
|
17
|
+
if (!header)
|
|
18
|
+
return false;
|
|
19
|
+
return header
|
|
20
|
+
.split(',')
|
|
21
|
+
.some((value) => value.trim().toLowerCase().startsWith('text/event-stream'));
|
|
22
|
+
}
|
package/dist/mcp.d.ts
CHANGED
package/dist/mcp.js
CHANGED
package/dist/observability.d.ts
CHANGED
|
@@ -13,5 +13,5 @@ export declare function logDebug(message: string, meta?: LogMetadata): void;
|
|
|
13
13
|
export declare function logWarn(message: string, meta?: LogMetadata): void;
|
|
14
14
|
export declare function logError(message: string, error?: Error | LogMetadata): void;
|
|
15
15
|
export declare function redactUrl(rawUrl: string): string;
|
|
16
|
+
export declare function redactHeaders(headers: Record<string, unknown>): Record<string, unknown>;
|
|
16
17
|
export {};
|
|
17
|
-
//# sourceMappingURL=observability.d.ts.map
|
package/dist/observability.js
CHANGED
|
@@ -13,7 +13,7 @@ export function getSessionId() {
|
|
|
13
13
|
export function getOperationId() {
|
|
14
14
|
return requestContext.getStore()?.operationId;
|
|
15
15
|
}
|
|
16
|
-
function
|
|
16
|
+
function buildContextMetadata() {
|
|
17
17
|
const requestId = getRequestId();
|
|
18
18
|
const sessionId = getSessionId();
|
|
19
19
|
const operationId = getOperationId();
|
|
@@ -24,7 +24,10 @@ function formatMetadata(meta) {
|
|
|
24
24
|
contextMeta.sessionId = sessionId;
|
|
25
25
|
if (operationId)
|
|
26
26
|
contextMeta.operationId = operationId;
|
|
27
|
-
|
|
27
|
+
return contextMeta;
|
|
28
|
+
}
|
|
29
|
+
function formatMetadata(meta) {
|
|
30
|
+
const merged = { ...buildContextMetadata(), ...meta };
|
|
28
31
|
return Object.keys(merged).length > 0 ? ` ${JSON.stringify(merged)}` : '';
|
|
29
32
|
}
|
|
30
33
|
function createTimestamp() {
|
|
@@ -73,4 +76,13 @@ export function redactUrl(rawUrl) {
|
|
|
73
76
|
return rawUrl;
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
|
-
|
|
79
|
+
export function redactHeaders(headers) {
|
|
80
|
+
const redacted = { ...headers };
|
|
81
|
+
const sensitiveKeys = ['authorization', 'cookie', 'set-cookie', 'x-api-key'];
|
|
82
|
+
for (const key of Object.keys(redacted)) {
|
|
83
|
+
if (sensitiveKeys.includes(key.toLowerCase())) {
|
|
84
|
+
redacted[key] = '[REDACTED]';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return redacted;
|
|
88
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface HttpServerTuningTarget {
|
|
2
|
+
headersTimeout?: number;
|
|
3
|
+
requestTimeout?: number;
|
|
4
|
+
keepAliveTimeout?: number;
|
|
5
|
+
closeIdleConnections?: () => void;
|
|
6
|
+
closeAllConnections?: () => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function applyHttpServerTuning(server: HttpServerTuningTarget): void;
|
|
9
|
+
export declare function drainConnectionsOnShutdown(server: HttpServerTuningTarget): void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { config } from './config.js';
|
|
2
|
+
import { logDebug } from './observability.js';
|
|
3
|
+
export function applyHttpServerTuning(server) {
|
|
4
|
+
const { headersTimeoutMs, requestTimeoutMs, keepAliveTimeoutMs } = config.server.http;
|
|
5
|
+
if (headersTimeoutMs !== undefined) {
|
|
6
|
+
server.headersTimeout = headersTimeoutMs;
|
|
7
|
+
}
|
|
8
|
+
if (requestTimeoutMs !== undefined) {
|
|
9
|
+
server.requestTimeout = requestTimeoutMs;
|
|
10
|
+
}
|
|
11
|
+
if (keepAliveTimeoutMs !== undefined) {
|
|
12
|
+
server.keepAliveTimeout = keepAliveTimeoutMs;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function drainConnectionsOnShutdown(server) {
|
|
16
|
+
const { shutdownCloseAllConnections, shutdownCloseIdleConnections } = config.server.http;
|
|
17
|
+
if (shutdownCloseAllConnections) {
|
|
18
|
+
if (typeof server.closeAllConnections === 'function') {
|
|
19
|
+
server.closeAllConnections();
|
|
20
|
+
logDebug('Closed all HTTP connections during shutdown');
|
|
21
|
+
}
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (shutdownCloseIdleConnections) {
|
|
25
|
+
if (typeof server.closeIdleConnections === 'function') {
|
|
26
|
+
server.closeIdleConnections();
|
|
27
|
+
logDebug('Closed idle HTTP connections during shutdown');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
2
|
+
export interface SessionEntry {
|
|
3
|
+
readonly transport: StreamableHTTPServerTransport;
|
|
4
|
+
createdAt: number;
|
|
5
|
+
lastSeen: number;
|
|
6
|
+
protocolInitialized: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface SessionStore {
|
|
9
|
+
get: (sessionId: string) => SessionEntry | undefined;
|
|
10
|
+
touch: (sessionId: string) => void;
|
|
11
|
+
set: (sessionId: string, entry: SessionEntry) => void;
|
|
12
|
+
remove: (sessionId: string) => SessionEntry | undefined;
|
|
13
|
+
size: () => number;
|
|
14
|
+
inFlight: () => number;
|
|
15
|
+
incrementInFlight: () => void;
|
|
16
|
+
decrementInFlight: () => void;
|
|
17
|
+
clear: () => SessionEntry[];
|
|
18
|
+
evictExpired: () => SessionEntry[];
|
|
19
|
+
evictOldest: () => SessionEntry | undefined;
|
|
20
|
+
}
|
|
21
|
+
export interface SlotTracker {
|
|
22
|
+
readonly releaseSlot: () => void;
|
|
23
|
+
readonly markInitialized: () => void;
|
|
24
|
+
readonly isInitialized: () => boolean;
|
|
25
|
+
}
|
|
26
|
+
export type CloseHandler = (() => void) | undefined;
|
|
27
|
+
export declare function composeCloseHandlers(first: CloseHandler, second: CloseHandler): CloseHandler;
|
|
28
|
+
export declare function startSessionCleanupLoop(store: SessionStore, sessionTtlMs: number): AbortController;
|
|
29
|
+
export declare function createSessionStore(sessionTtlMs: number): SessionStore;
|
|
30
|
+
export declare function createSlotTracker(store: SessionStore): SlotTracker;
|
|
31
|
+
export declare function reserveSessionSlot(store: SessionStore, maxSessions: number): boolean;
|
|
32
|
+
export declare function ensureSessionCapacity({ store, maxSessions, evictOldest, }: {
|
|
33
|
+
store: SessionStore;
|
|
34
|
+
maxSessions: number;
|
|
35
|
+
evictOldest: (store: SessionStore) => boolean;
|
|
36
|
+
}): boolean;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { setInterval as setIntervalPromise } from 'node:timers/promises';
|
|
2
|
+
import { logInfo, logWarn } from './observability.js';
|
|
3
|
+
export function composeCloseHandlers(first, second) {
|
|
4
|
+
if (!first)
|
|
5
|
+
return second;
|
|
6
|
+
if (!second)
|
|
7
|
+
return first;
|
|
8
|
+
return () => {
|
|
9
|
+
try {
|
|
10
|
+
first();
|
|
11
|
+
}
|
|
12
|
+
finally {
|
|
13
|
+
second();
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
// --- Session Store ---
|
|
18
|
+
function getCleanupIntervalMs(sessionTtlMs) {
|
|
19
|
+
return Math.min(Math.max(Math.floor(sessionTtlMs / 2), 10000), 60000);
|
|
20
|
+
}
|
|
21
|
+
function isAbortError(error) {
|
|
22
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
23
|
+
}
|
|
24
|
+
function handleSessionCleanupError(error) {
|
|
25
|
+
if (isAbortError(error)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
logWarn('Session cleanup loop failed', {
|
|
29
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function moveSessionToEnd(sessions, sessionId, session) {
|
|
33
|
+
sessions.delete(sessionId);
|
|
34
|
+
sessions.set(sessionId, session);
|
|
35
|
+
}
|
|
36
|
+
function isSessionExpired(session, now, sessionTtlMs) {
|
|
37
|
+
return now - session.lastSeen > sessionTtlMs;
|
|
38
|
+
}
|
|
39
|
+
async function runSessionCleanupLoop(store, sessionTtlMs, signal) {
|
|
40
|
+
const intervalMs = getCleanupIntervalMs(sessionTtlMs);
|
|
41
|
+
for await (const getNow of setIntervalPromise(intervalMs, Date.now, {
|
|
42
|
+
signal,
|
|
43
|
+
ref: false,
|
|
44
|
+
})) {
|
|
45
|
+
const evicted = store.evictExpired();
|
|
46
|
+
for (const session of evicted) {
|
|
47
|
+
void session.transport.close().catch((err) => {
|
|
48
|
+
logWarn('Failed to close expired session', {
|
|
49
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (evicted.length > 0) {
|
|
54
|
+
logInfo('Expired sessions evicted', {
|
|
55
|
+
evicted: evicted.length,
|
|
56
|
+
timestamp: new Date(getNow()).toISOString(),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function startSessionCleanupLoop(store, sessionTtlMs) {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
void runSessionCleanupLoop(store, sessionTtlMs, controller.signal).catch(handleSessionCleanupError);
|
|
64
|
+
return controller;
|
|
65
|
+
}
|
|
66
|
+
export function createSessionStore(sessionTtlMs) {
|
|
67
|
+
const sessions = new Map();
|
|
68
|
+
let inflight = 0;
|
|
69
|
+
return {
|
|
70
|
+
get: (sessionId) => sessions.get(sessionId),
|
|
71
|
+
touch: (sessionId) => {
|
|
72
|
+
const session = sessions.get(sessionId);
|
|
73
|
+
if (session) {
|
|
74
|
+
session.lastSeen = Date.now();
|
|
75
|
+
// Move to end (LRU behavior if needed, but Map insertion order)
|
|
76
|
+
moveSessionToEnd(sessions, sessionId, session);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
set: (sessionId, entry) => {
|
|
80
|
+
sessions.set(sessionId, entry);
|
|
81
|
+
},
|
|
82
|
+
remove: (sessionId) => {
|
|
83
|
+
const session = sessions.get(sessionId);
|
|
84
|
+
sessions.delete(sessionId);
|
|
85
|
+
return session;
|
|
86
|
+
},
|
|
87
|
+
size: () => sessions.size,
|
|
88
|
+
inFlight: () => inflight,
|
|
89
|
+
incrementInFlight: () => {
|
|
90
|
+
inflight += 1;
|
|
91
|
+
},
|
|
92
|
+
decrementInFlight: () => {
|
|
93
|
+
if (inflight > 0)
|
|
94
|
+
inflight -= 1;
|
|
95
|
+
},
|
|
96
|
+
clear: () => {
|
|
97
|
+
const entries = [...sessions.values()];
|
|
98
|
+
sessions.clear();
|
|
99
|
+
return entries;
|
|
100
|
+
},
|
|
101
|
+
evictExpired: () => {
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const evicted = [];
|
|
104
|
+
for (const [id, session] of sessions.entries()) {
|
|
105
|
+
if (isSessionExpired(session, now, sessionTtlMs)) {
|
|
106
|
+
sessions.delete(id);
|
|
107
|
+
evicted.push(session);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return evicted;
|
|
111
|
+
},
|
|
112
|
+
evictOldest: () => {
|
|
113
|
+
const oldestEntry = sessions.keys().next();
|
|
114
|
+
if (oldestEntry.done)
|
|
115
|
+
return undefined;
|
|
116
|
+
const oldestId = oldestEntry.value;
|
|
117
|
+
const session = sessions.get(oldestId);
|
|
118
|
+
sessions.delete(oldestId);
|
|
119
|
+
return session;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// --- Slot Tracker ---
|
|
124
|
+
export function createSlotTracker(store) {
|
|
125
|
+
let slotReleased = false;
|
|
126
|
+
let initialized = false;
|
|
127
|
+
return {
|
|
128
|
+
releaseSlot: () => {
|
|
129
|
+
if (slotReleased)
|
|
130
|
+
return;
|
|
131
|
+
slotReleased = true;
|
|
132
|
+
store.decrementInFlight();
|
|
133
|
+
},
|
|
134
|
+
markInitialized: () => {
|
|
135
|
+
initialized = true;
|
|
136
|
+
},
|
|
137
|
+
isInitialized: () => initialized,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function reserveSessionSlot(store, maxSessions) {
|
|
141
|
+
if (store.size() + store.inFlight() >= maxSessions) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
store.incrementInFlight();
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
export function ensureSessionCapacity({ store, maxSessions, evictOldest, }) {
|
|
148
|
+
const currentSize = store.size();
|
|
149
|
+
const isAtCapacity = currentSize + store.inFlight() >= maxSessions;
|
|
150
|
+
if (!isAtCapacity)
|
|
151
|
+
return true;
|
|
152
|
+
// Try to free a slot
|
|
153
|
+
const canFreeSlot = currentSize >= maxSessions &&
|
|
154
|
+
currentSize - 1 + store.inFlight() < maxSessions;
|
|
155
|
+
if (canFreeSlot && evictOldest(store)) {
|
|
156
|
+
return store.size() + store.inFlight() < maxSessions;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -125,4 +125,3 @@ export declare function fetchUrlToolHandler(input: FetchUrlInput, extra?: ToolHa
|
|
|
125
125
|
export declare function withRequestContextIfMissing<TParams, TResult, TExtra = unknown>(handler: (params: TParams, extra?: TExtra) => Promise<TResult>): (params: TParams, extra?: TExtra) => Promise<TResult>;
|
|
126
126
|
export declare function registerTools(server: McpServer): void;
|
|
127
127
|
export {};
|
|
128
|
-
//# sourceMappingURL=tools.d.ts.map
|