@j0hanz/superfetch 2.5.2 → 2.6.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 +356 -223
- package/dist/assets/logo.svg +24837 -24835
- package/dist/cache.d.ts +28 -20
- package/dist/cache.js +292 -514
- package/dist/config.d.ts +41 -7
- package/dist/config.js +298 -148
- package/dist/crypto.js +25 -12
- package/dist/dom-noise-removal.js +379 -421
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +25 -8
- package/dist/fetch.d.ts +18 -16
- package/dist/fetch.js +1132 -526
- package/dist/host-normalization.js +40 -10
- package/dist/http-native.js +628 -287
- package/dist/index.js +67 -7
- package/dist/instructions.md +44 -30
- package/dist/ip-blocklist.d.ts +8 -0
- package/dist/ip-blocklist.js +65 -0
- package/dist/json.js +14 -9
- package/dist/language-detection.d.ts +2 -11
- package/dist/language-detection.js +289 -280
- package/dist/markdown-cleanup.d.ts +0 -1
- package/dist/markdown-cleanup.js +391 -429
- package/dist/mcp-validator.js +4 -2
- package/dist/mcp.js +184 -135
- package/dist/observability.js +89 -21
- package/dist/resources.js +16 -6
- package/dist/server-tuning.d.ts +2 -0
- package/dist/server-tuning.js +25 -23
- package/dist/session.d.ts +1 -0
- package/dist/session.js +41 -33
- package/dist/tasks.d.ts +2 -0
- package/dist/tasks.js +91 -9
- package/dist/timer-utils.d.ts +5 -0
- package/dist/timer-utils.js +20 -0
- package/dist/tools.d.ts +28 -5
- package/dist/tools.js +317 -183
- package/dist/transform-types.d.ts +5 -1
- package/dist/transform.d.ts +3 -2
- package/dist/transform.js +1138 -421
- package/dist/type-guards.d.ts +1 -0
- package/dist/type-guards.js +7 -0
- package/dist/workers/transform-child.d.ts +1 -0
- package/dist/workers/transform-child.js +118 -0
- package/dist/workers/transform-worker.js +87 -78
- package/package.json +21 -13
package/dist/cache.js
CHANGED
|
@@ -1,61 +1,46 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { posix as pathPosix } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
1
4
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
5
|
import { ErrorCode, McpError, SubscribeRequestSchema, UnsubscribeRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
3
6
|
import { config } from './config.js';
|
|
4
7
|
import { sha256Hex } from './crypto.js';
|
|
5
8
|
import { getErrorMessage } from './errors.js';
|
|
6
9
|
import { stableStringify as stableJsonStringify } from './json.js';
|
|
7
|
-
import {
|
|
8
|
-
import { isObject } from './type-guards.js';
|
|
10
|
+
import { logWarn } from './observability.js';
|
|
9
11
|
/* -------------------------------------------------------------------------------------------------
|
|
10
|
-
*
|
|
12
|
+
* Schemas & Types
|
|
11
13
|
* ------------------------------------------------------------------------------------------------- */
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
14
|
+
const CacheNamespace = z.literal('markdown');
|
|
15
|
+
const HashString = z
|
|
16
|
+
.string()
|
|
17
|
+
.regex(/^[a-f0-9.]+$/i)
|
|
18
|
+
.min(8)
|
|
19
|
+
.max(64);
|
|
20
|
+
const CachedPayloadSchema = z.strictObject({
|
|
21
|
+
content: z.string().optional(),
|
|
22
|
+
markdown: z.string().optional(),
|
|
23
|
+
title: z.string().optional(),
|
|
24
|
+
});
|
|
25
|
+
/* -------------------------------------------------------------------------------------------------
|
|
26
|
+
* Core: Cache Key Logic
|
|
27
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
28
|
+
const CACHE_CONSTANTS = {
|
|
29
|
+
URL_HASH_LENGTH: 32,
|
|
30
|
+
VARY_HASH_LENGTH: 16,
|
|
31
|
+
};
|
|
32
|
+
export function parseCachedPayload(raw) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
return CachedPayloadSchema.parse(parsed);
|
|
36
36
|
}
|
|
37
|
-
|
|
38
|
-
if (typeof payload.markdown === 'string')
|
|
39
|
-
return payload.markdown;
|
|
40
|
-
if (typeof payload.content === 'string')
|
|
41
|
-
return payload.content;
|
|
37
|
+
catch {
|
|
42
38
|
return null;
|
|
43
39
|
}
|
|
44
40
|
}
|
|
45
|
-
const payloadCodec = new CachedPayloadCodec();
|
|
46
|
-
export function parseCachedPayload(raw) {
|
|
47
|
-
return payloadCodec.parse(raw);
|
|
48
|
-
}
|
|
49
41
|
export function resolveCachedPayloadContent(payload) {
|
|
50
|
-
return
|
|
42
|
+
return payload.markdown ?? payload.content ?? null;
|
|
51
43
|
}
|
|
52
|
-
/* -------------------------------------------------------------------------------------------------
|
|
53
|
-
* Cache key codec (hashing + parsing + resource URI)
|
|
54
|
-
* ------------------------------------------------------------------------------------------------- */
|
|
55
|
-
const CACHE_HASH = {
|
|
56
|
-
URL_HASH_LENGTH: 32,
|
|
57
|
-
VARY_HASH_LENGTH: 16,
|
|
58
|
-
};
|
|
59
44
|
function stableStringify(value) {
|
|
60
45
|
try {
|
|
61
46
|
return stableJsonStringify(value);
|
|
@@ -72,183 +57,122 @@ function buildCacheKey(namespace, urlHash, varyHash) {
|
|
|
72
57
|
? `${namespace}:${urlHash}.${varyHash}`
|
|
73
58
|
: `${namespace}:${urlHash}`;
|
|
74
59
|
}
|
|
75
|
-
function
|
|
76
|
-
if (!
|
|
77
|
-
return undefined;
|
|
78
|
-
const varyString = typeof vary === 'string' ? vary : stableStringify(vary);
|
|
79
|
-
if (varyString === null)
|
|
60
|
+
export function createCacheKey(namespace, url, vary) {
|
|
61
|
+
if (!namespace || !url)
|
|
80
62
|
return null;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return `superfetch://cache/${namespace}/${urlHash}`;
|
|
87
|
-
}
|
|
88
|
-
class CacheKeyCodec {
|
|
89
|
-
create(namespace, url, vary) {
|
|
90
|
-
if (!namespace || !url)
|
|
91
|
-
return null;
|
|
92
|
-
const urlHash = createHashFragment(url, CACHE_HASH.URL_HASH_LENGTH);
|
|
93
|
-
const varyHash = getVaryHash(vary);
|
|
94
|
-
if (varyHash === null)
|
|
95
|
-
return null;
|
|
96
|
-
return buildCacheKey(namespace, urlHash, varyHash);
|
|
97
|
-
}
|
|
98
|
-
parse(cacheKey) {
|
|
99
|
-
if (!cacheKey)
|
|
100
|
-
return null;
|
|
101
|
-
const [namespace, ...rest] = cacheKey.split(':');
|
|
102
|
-
const urlHash = rest.join(':');
|
|
103
|
-
if (!namespace || !urlHash)
|
|
104
|
-
return null;
|
|
105
|
-
return { namespace, urlHash };
|
|
106
|
-
}
|
|
107
|
-
toResourceUri(cacheKey) {
|
|
108
|
-
const parts = this.parse(cacheKey);
|
|
109
|
-
if (!parts)
|
|
63
|
+
const urlHash = createHashFragment(url, CACHE_CONSTANTS.URL_HASH_LENGTH);
|
|
64
|
+
let varyHash;
|
|
65
|
+
if (vary) {
|
|
66
|
+
const varyString = typeof vary === 'string' ? vary : stableStringify(vary);
|
|
67
|
+
if (varyString === null)
|
|
110
68
|
return null;
|
|
111
|
-
|
|
69
|
+
if (varyString) {
|
|
70
|
+
varyHash = createHashFragment(varyString, CACHE_CONSTANTS.VARY_HASH_LENGTH);
|
|
71
|
+
}
|
|
112
72
|
}
|
|
113
|
-
|
|
114
|
-
const cacheKeyCodec = new CacheKeyCodec();
|
|
115
|
-
export function createCacheKey(namespace, url, vary) {
|
|
116
|
-
return cacheKeyCodec.create(namespace, url, vary);
|
|
73
|
+
return buildCacheKey(namespace, urlHash, varyHash);
|
|
117
74
|
}
|
|
118
75
|
export function parseCacheKey(cacheKey) {
|
|
119
|
-
|
|
76
|
+
if (!cacheKey)
|
|
77
|
+
return null;
|
|
78
|
+
const [namespace, ...rest] = cacheKey.split(':');
|
|
79
|
+
const urlHash = rest.join(':');
|
|
80
|
+
if (!namespace || !urlHash)
|
|
81
|
+
return null;
|
|
82
|
+
return { namespace, urlHash };
|
|
120
83
|
}
|
|
121
84
|
export function toResourceUri(cacheKey) {
|
|
122
|
-
|
|
85
|
+
const parts = parseCacheKey(cacheKey);
|
|
86
|
+
if (!parts)
|
|
87
|
+
return null;
|
|
88
|
+
return `superfetch://cache/${parts.namespace}/${parts.urlHash}`;
|
|
123
89
|
}
|
|
124
90
|
/* -------------------------------------------------------------------------------------------------
|
|
125
|
-
* In-
|
|
126
|
-
* Contract:
|
|
127
|
-
* - Max entries: config.cache.maxKeys
|
|
128
|
-
* - TTL in ms: config.cache.ttl * 1000
|
|
129
|
-
* - Access does NOT extend TTL
|
|
91
|
+
* Core: In-Memory Store
|
|
130
92
|
* ------------------------------------------------------------------------------------------------- */
|
|
131
|
-
class NativeLruCache {
|
|
132
|
-
max;
|
|
133
|
-
ttlMs;
|
|
134
|
-
entries = new Map();
|
|
135
|
-
nextPurgeAtMs = 0;
|
|
136
|
-
constructor({ max, ttlMs }) {
|
|
137
|
-
this.max = max;
|
|
138
|
-
this.ttlMs = ttlMs;
|
|
139
|
-
}
|
|
140
|
-
get(key) {
|
|
141
|
-
const entry = this.entries.get(key);
|
|
142
|
-
if (!entry)
|
|
143
|
-
return undefined;
|
|
144
|
-
if (this.isExpired(entry, Date.now())) {
|
|
145
|
-
this.entries.delete(key);
|
|
146
|
-
return undefined;
|
|
147
|
-
}
|
|
148
|
-
// Refresh LRU order without extending TTL.
|
|
149
|
-
this.entries.delete(key);
|
|
150
|
-
this.entries.set(key, entry);
|
|
151
|
-
return entry.value;
|
|
152
|
-
}
|
|
153
|
-
set(key, value) {
|
|
154
|
-
if (this.max <= 0 || this.ttlMs <= 0)
|
|
155
|
-
return;
|
|
156
|
-
const now = Date.now();
|
|
157
|
-
this.entries.delete(key);
|
|
158
|
-
this.entries.set(key, { value, expiresAtMs: now + this.ttlMs });
|
|
159
|
-
this.maybePurge(now);
|
|
160
|
-
while (this.entries.size > this.max) {
|
|
161
|
-
const oldestKey = this.entries.keys().next().value;
|
|
162
|
-
if (oldestKey === undefined)
|
|
163
|
-
break;
|
|
164
|
-
this.entries.delete(oldestKey);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
keys() {
|
|
168
|
-
this.maybePurge(Date.now());
|
|
169
|
-
return [...this.entries.keys()];
|
|
170
|
-
}
|
|
171
|
-
maybePurge(now) {
|
|
172
|
-
if (this.entries.size > this.max || now >= this.nextPurgeAtMs) {
|
|
173
|
-
this.purgeExpired(now);
|
|
174
|
-
this.nextPurgeAtMs = now + this.ttlMs;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
purgeExpired(now) {
|
|
178
|
-
for (const [key, entry] of this.entries) {
|
|
179
|
-
if (this.isExpired(entry, now))
|
|
180
|
-
this.entries.delete(key);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
isExpired(entry, now) {
|
|
184
|
-
return entry.expiresAtMs <= now;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
93
|
class InMemoryCacheStore {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
listeners = new Set();
|
|
94
|
+
max = config.cache.maxKeys;
|
|
95
|
+
ttlMs = config.cache.ttl * 1000;
|
|
96
|
+
entries = new Map();
|
|
97
|
+
updateEmitter = new EventEmitter();
|
|
193
98
|
isEnabled() {
|
|
194
99
|
return config.cache.enabled;
|
|
195
100
|
}
|
|
196
101
|
keys() {
|
|
197
|
-
|
|
102
|
+
if (!this.isEnabled())
|
|
103
|
+
return [];
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
return Array.from(this.entries.entries())
|
|
106
|
+
.filter(([, entry]) => entry.expiresAtMs > now)
|
|
107
|
+
.map(([key]) => key);
|
|
198
108
|
}
|
|
199
109
|
onUpdate(listener) {
|
|
200
|
-
|
|
201
|
-
|
|
110
|
+
const wrapped = (event) => {
|
|
111
|
+
try {
|
|
112
|
+
const result = listener(event);
|
|
113
|
+
if (result instanceof Promise) {
|
|
114
|
+
void result.catch((error) => {
|
|
115
|
+
this.logError('Cache update listener failed (async)', event.cacheKey, error);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
this.logError('Cache update listener failed', event.cacheKey, error);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
this.updateEmitter.on('update', wrapped);
|
|
124
|
+
return () => {
|
|
125
|
+
this.updateEmitter.off('update', wrapped);
|
|
126
|
+
};
|
|
202
127
|
}
|
|
203
|
-
get(cacheKey) {
|
|
204
|
-
if (!this.
|
|
128
|
+
get(cacheKey, options) {
|
|
129
|
+
if (!cacheKey || (!this.isEnabled() && !options?.force))
|
|
205
130
|
return undefined;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
this.run(cacheKey, 'Cache set error', () => {
|
|
212
|
-
const now = Date.now();
|
|
213
|
-
const expiresAtMs = now + config.cache.ttl * 1000; // preserve existing behavior
|
|
214
|
-
const entry = this.buildEntry(content, metadata, now, expiresAtMs);
|
|
215
|
-
this.cache.set(cacheKey, entry);
|
|
216
|
-
this.notify(cacheKey);
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
isReadable(cacheKey) {
|
|
220
|
-
return config.cache.enabled && Boolean(cacheKey);
|
|
221
|
-
}
|
|
222
|
-
isWritable(cacheKey, content) {
|
|
223
|
-
return config.cache.enabled && Boolean(cacheKey) && Boolean(content);
|
|
224
|
-
}
|
|
225
|
-
run(cacheKey, message, operation) {
|
|
226
|
-
try {
|
|
227
|
-
return operation();
|
|
228
|
-
}
|
|
229
|
-
catch (error) {
|
|
230
|
-
this.logError(message, cacheKey, error);
|
|
131
|
+
const entry = this.entries.get(cacheKey);
|
|
132
|
+
if (!entry)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (entry.expiresAtMs <= Date.now()) {
|
|
135
|
+
this.entries.delete(cacheKey);
|
|
231
136
|
return undefined;
|
|
232
137
|
}
|
|
138
|
+
// Refresh LRU position
|
|
139
|
+
this.entries.delete(cacheKey);
|
|
140
|
+
this.entries.set(cacheKey, entry);
|
|
141
|
+
return entry;
|
|
233
142
|
}
|
|
234
|
-
|
|
235
|
-
|
|
143
|
+
set(cacheKey, content, metadata, options) {
|
|
144
|
+
if (!cacheKey || !content)
|
|
145
|
+
return;
|
|
146
|
+
if (!this.isEnabled() && !options?.force)
|
|
147
|
+
return;
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
const expiresAtMs = now + this.ttlMs;
|
|
150
|
+
const entry = {
|
|
236
151
|
url: metadata.url,
|
|
237
152
|
content,
|
|
238
|
-
fetchedAt: new Date(
|
|
153
|
+
fetchedAt: new Date(now).toISOString(),
|
|
239
154
|
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
240
|
-
|
|
155
|
+
expiresAtMs,
|
|
156
|
+
...(metadata.title ? { title: metadata.title } : {}),
|
|
241
157
|
};
|
|
158
|
+
this.entries.delete(cacheKey);
|
|
159
|
+
this.entries.set(cacheKey, entry);
|
|
160
|
+
// Eviction
|
|
161
|
+
if (this.entries.size > this.max) {
|
|
162
|
+
const firstKey = this.entries.keys().next();
|
|
163
|
+
if (!firstKey.done) {
|
|
164
|
+
this.entries.delete(firstKey.value);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
this.notify(cacheKey);
|
|
242
168
|
}
|
|
243
169
|
notify(cacheKey) {
|
|
244
|
-
if (this.
|
|
245
|
-
return;
|
|
246
|
-
const parts = cacheKeyCodec.parse(cacheKey);
|
|
247
|
-
if (!parts)
|
|
170
|
+
if (this.updateEmitter.listenerCount('update') === 0)
|
|
248
171
|
return;
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
172
|
+
const parts = parseCacheKey(cacheKey);
|
|
173
|
+
if (parts) {
|
|
174
|
+
this.updateEmitter.emit('update', { cacheKey, ...parts });
|
|
175
|
+
}
|
|
252
176
|
}
|
|
253
177
|
logError(message, cacheKey, error) {
|
|
254
178
|
logWarn(message, {
|
|
@@ -257,15 +181,17 @@ class InMemoryCacheStore {
|
|
|
257
181
|
});
|
|
258
182
|
}
|
|
259
183
|
}
|
|
184
|
+
// Singleton Instance
|
|
260
185
|
const store = new InMemoryCacheStore();
|
|
186
|
+
// Public Proxy API
|
|
261
187
|
export function onCacheUpdate(listener) {
|
|
262
188
|
return store.onUpdate(listener);
|
|
263
189
|
}
|
|
264
|
-
export function get(cacheKey) {
|
|
265
|
-
return store.get(cacheKey);
|
|
190
|
+
export function get(cacheKey, options) {
|
|
191
|
+
return store.get(cacheKey, options);
|
|
266
192
|
}
|
|
267
|
-
export function set(cacheKey, content, metadata) {
|
|
268
|
-
store.set(cacheKey, content, metadata);
|
|
193
|
+
export function set(cacheKey, content, metadata, options) {
|
|
194
|
+
store.set(cacheKey, content, metadata, options);
|
|
269
195
|
}
|
|
270
196
|
export function keys() {
|
|
271
197
|
return store.keys();
|
|
@@ -274,361 +200,213 @@ export function isEnabled() {
|
|
|
274
200
|
return store.isEnabled();
|
|
275
201
|
}
|
|
276
202
|
/* -------------------------------------------------------------------------------------------------
|
|
277
|
-
* MCP
|
|
203
|
+
* Adapter: MCP Cached Content Resource
|
|
278
204
|
* ------------------------------------------------------------------------------------------------- */
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return namespace === CACHE_NAMESPACE;
|
|
290
|
-
}
|
|
291
|
-
function isValidHash(hash) {
|
|
292
|
-
return HASH_PATTERN.test(hash) && hash.length >= 8 && hash.length <= 64;
|
|
293
|
-
}
|
|
294
|
-
function requireRecordParams(value) {
|
|
295
|
-
if (!isObject(value))
|
|
296
|
-
throwInvalidCacheParams();
|
|
297
|
-
return value;
|
|
298
|
-
}
|
|
299
|
-
function requireParamString(params, key) {
|
|
300
|
-
const resolved = resolveStringParam(params[key]);
|
|
301
|
-
if (!resolved) {
|
|
302
|
-
throw new McpError(ErrorCode.InvalidParams, 'Both namespace and urlHash parameters are required');
|
|
303
|
-
}
|
|
304
|
-
return resolved;
|
|
305
|
-
}
|
|
306
|
-
function resolveCacheParams(params) {
|
|
307
|
-
const parsed = requireRecordParams(params);
|
|
308
|
-
const namespace = requireParamString(parsed, 'namespace');
|
|
309
|
-
const urlHash = requireParamString(parsed, 'urlHash');
|
|
310
|
-
if (!isValidNamespace(namespace) || !isValidHash(urlHash)) {
|
|
311
|
-
throwInvalidCacheParams();
|
|
312
|
-
}
|
|
313
|
-
return { namespace, urlHash };
|
|
314
|
-
}
|
|
315
|
-
function isValidCacheResourceUri(uri) {
|
|
316
|
-
if (!uri)
|
|
317
|
-
return false;
|
|
318
|
-
let parsed;
|
|
319
|
-
try {
|
|
320
|
-
parsed = new URL(uri);
|
|
321
|
-
}
|
|
322
|
-
catch {
|
|
323
|
-
return false;
|
|
324
|
-
}
|
|
325
|
-
if (parsed.protocol !== 'superfetch:')
|
|
326
|
-
return false;
|
|
327
|
-
if (parsed.hostname !== 'cache')
|
|
328
|
-
return false;
|
|
329
|
-
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
330
|
-
if (parts.length !== 2)
|
|
331
|
-
return false;
|
|
332
|
-
const [namespace, urlHash] = parts;
|
|
333
|
-
if (!namespace || !urlHash)
|
|
334
|
-
return false;
|
|
335
|
-
return isValidNamespace(namespace) && isValidHash(urlHash);
|
|
336
|
-
}
|
|
337
|
-
function buildResourceEntry(namespace, urlHash) {
|
|
338
|
-
return {
|
|
205
|
+
const CacheResourceParamsSchema = z.object({
|
|
206
|
+
namespace: CacheNamespace,
|
|
207
|
+
urlHash: HashString,
|
|
208
|
+
});
|
|
209
|
+
function listCachedResources() {
|
|
210
|
+
const resources = store
|
|
211
|
+
.keys()
|
|
212
|
+
.map((key) => parseCacheKey(key))
|
|
213
|
+
.filter((parts) => parts !== null && parts.namespace === 'markdown')
|
|
214
|
+
.map(({ namespace, urlHash }) => ({
|
|
339
215
|
name: `${namespace}:${urlHash}`,
|
|
340
|
-
uri:
|
|
216
|
+
uri: `superfetch://cache/${namespace}/${urlHash}`,
|
|
341
217
|
description: `Cached content entry for ${namespace}`,
|
|
342
218
|
mimeType: 'text/markdown',
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
function listCachedResources() {
|
|
346
|
-
const resources = keys()
|
|
347
|
-
.map((key) => {
|
|
348
|
-
const parts = cacheKeyCodec.parse(key);
|
|
349
|
-
if (parts?.namespace !== CACHE_NAMESPACE)
|
|
350
|
-
return null;
|
|
351
|
-
return buildResourceEntry(parts.namespace, parts.urlHash);
|
|
352
|
-
})
|
|
353
|
-
.filter((entry) => entry !== null);
|
|
219
|
+
}));
|
|
354
220
|
return { resources };
|
|
355
221
|
}
|
|
356
|
-
function
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
server.server.oninitialized = () => {
|
|
367
|
-
initialized = true;
|
|
368
|
-
previousInitialized?.();
|
|
369
|
-
};
|
|
370
|
-
return () => initialized;
|
|
371
|
-
}
|
|
372
|
-
function getClientResourceCapabilities(server) {
|
|
373
|
-
const caps = server.server.getClientCapabilities();
|
|
374
|
-
if (!caps || !isObject(caps))
|
|
375
|
-
return { listChanged: false, subscribe: false };
|
|
376
|
-
const { resources } = caps;
|
|
377
|
-
if (!isObject(resources))
|
|
378
|
-
return { listChanged: false, subscribe: false };
|
|
379
|
-
const { listChanged, subscribe } = resources;
|
|
380
|
-
return { listChanged: listChanged === true, subscribe: subscribe === true };
|
|
222
|
+
function resolveCachedMarkdownText(raw) {
|
|
223
|
+
if (!raw)
|
|
224
|
+
return null;
|
|
225
|
+
const payload = parseCachedPayload(raw);
|
|
226
|
+
if (payload)
|
|
227
|
+
return resolveCachedPayloadContent(payload);
|
|
228
|
+
const trimmed = raw.trimStart();
|
|
229
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('['))
|
|
230
|
+
return null;
|
|
231
|
+
return raw;
|
|
381
232
|
}
|
|
382
|
-
function
|
|
233
|
+
export function registerCachedContentResource(server, serverIcons) {
|
|
234
|
+
// Resource Registration
|
|
235
|
+
server.registerResource('cached-content', new ResourceTemplate('superfetch://cache/{namespace}/{urlHash}', {
|
|
236
|
+
list: listCachedResources,
|
|
237
|
+
}), {
|
|
238
|
+
title: 'Cached Content',
|
|
239
|
+
description: 'Access previously fetched web content from cache.',
|
|
240
|
+
mimeType: 'text/markdown',
|
|
241
|
+
...(serverIcons ? { icons: serverIcons } : {}),
|
|
242
|
+
}, (uri, params) => {
|
|
243
|
+
const parsed = CacheResourceParamsSchema.safeParse(params);
|
|
244
|
+
if (!parsed.success) {
|
|
245
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid resource parameters');
|
|
246
|
+
}
|
|
247
|
+
const { namespace, urlHash } = parsed.data;
|
|
248
|
+
const cacheKey = `${namespace}:${urlHash}`;
|
|
249
|
+
const cached = store.get(cacheKey, { force: true });
|
|
250
|
+
if (!cached) {
|
|
251
|
+
throw new McpError(-32002, `Content not found: ${cacheKey}`);
|
|
252
|
+
}
|
|
253
|
+
const text = resolveCachedMarkdownText(cached.content);
|
|
254
|
+
if (!text) {
|
|
255
|
+
throw new McpError(ErrorCode.InternalError, 'Cached content invalid');
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
contents: [{ uri: uri.href, mimeType: 'text/markdown', text }],
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
// Subscriptions
|
|
383
262
|
const subscriptions = new Set();
|
|
384
|
-
server.server.setRequestHandler(SubscribeRequestSchema, (
|
|
385
|
-
|
|
386
|
-
|
|
263
|
+
server.server.setRequestHandler(SubscribeRequestSchema, (req) => {
|
|
264
|
+
if (isValidCacheUri(req.params.uri)) {
|
|
265
|
+
subscriptions.add(req.params.uri);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
387
268
|
throw new McpError(ErrorCode.InvalidParams, 'Invalid resource URI');
|
|
388
269
|
}
|
|
389
|
-
subscriptions.add(uri);
|
|
390
270
|
return {};
|
|
391
271
|
});
|
|
392
|
-
server.server.setRequestHandler(UnsubscribeRequestSchema, (
|
|
393
|
-
|
|
394
|
-
|
|
272
|
+
server.server.setRequestHandler(UnsubscribeRequestSchema, (req) => {
|
|
273
|
+
if (isValidCacheUri(req.params.uri)) {
|
|
274
|
+
subscriptions.delete(req.params.uri);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
395
277
|
throw new McpError(ErrorCode.InvalidParams, 'Invalid resource URI');
|
|
396
278
|
}
|
|
397
|
-
subscriptions.delete(uri);
|
|
398
279
|
return {};
|
|
399
280
|
});
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
if (!server.isConnected())
|
|
407
|
-
return;
|
|
408
|
-
if (!subscriptions.has(uri))
|
|
409
|
-
return;
|
|
410
|
-
void server.server.sendResourceUpdated({ uri }).catch((error) => {
|
|
411
|
-
logWarn('Failed to send resource update notification', {
|
|
412
|
-
uri,
|
|
413
|
-
error: getErrorMessage(error),
|
|
414
|
-
});
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
function requireCacheEntry(cacheKey) {
|
|
418
|
-
const cached = get(cacheKey);
|
|
419
|
-
if (!cached) {
|
|
420
|
-
throw new McpError(-32002, `Content not found in cache for key: ${cacheKey}`);
|
|
421
|
-
}
|
|
422
|
-
return cached;
|
|
423
|
-
}
|
|
424
|
-
function buildMarkdownContentResponse(uri, content) {
|
|
425
|
-
const payload = payloadCodec.parse(content);
|
|
426
|
-
const resolvedContent = payload ? payloadCodec.resolveContent(payload) : null;
|
|
427
|
-
if (!resolvedContent) {
|
|
428
|
-
throw new McpError(ErrorCode.InternalError, 'Cached markdown content is missing');
|
|
429
|
-
}
|
|
430
|
-
return {
|
|
431
|
-
contents: [
|
|
432
|
-
{ uri: uri.href, mimeType: 'text/markdown', text: resolvedContent },
|
|
433
|
-
],
|
|
281
|
+
// Notifications
|
|
282
|
+
let initialized = false;
|
|
283
|
+
const originalOnInitialized = server.server.oninitialized;
|
|
284
|
+
server.server.oninitialized = () => {
|
|
285
|
+
initialized = true;
|
|
286
|
+
originalOnInitialized?.();
|
|
434
287
|
};
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const cached = requireCacheEntry(cacheKey);
|
|
438
|
-
return buildMarkdownContentResponse(uri, cached.content);
|
|
439
|
-
}
|
|
440
|
-
function registerCacheContentResource(server, serverIcons) {
|
|
441
|
-
server.registerResource('cached-content', new ResourceTemplate('superfetch://cache/{namespace}/{urlHash}', {
|
|
442
|
-
list: listCachedResources,
|
|
443
|
-
}), {
|
|
444
|
-
title: 'Cached Content',
|
|
445
|
-
description: 'Access previously fetched web content from cache. Namespace: markdown. UrlHash: SHA-256 hash of the URL.',
|
|
446
|
-
mimeType: 'text/markdown',
|
|
447
|
-
...(serverIcons ? { icons: serverIcons } : {}),
|
|
448
|
-
}, (uri, params) => {
|
|
449
|
-
const { namespace, urlHash } = resolveCacheParams(params);
|
|
450
|
-
const cacheKey = `${namespace}:${urlHash}`;
|
|
451
|
-
return buildCachedContentResponse(uri, cacheKey);
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
function registerCacheUpdateSubscription(server, subscriptions, isInitialized) {
|
|
455
|
-
const unsubscribe = onCacheUpdate(({ cacheKey }) => {
|
|
456
|
-
if (!server.isConnected() || !isInitialized())
|
|
288
|
+
store.onUpdate(({ cacheKey }) => {
|
|
289
|
+
if (!server.isConnected() || !initialized)
|
|
457
290
|
return;
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
291
|
+
// Check capabilities via unsafe cast or helper (SDK limitation)
|
|
292
|
+
const capabilities = server.server.getClientCapabilities();
|
|
293
|
+
const uri = toResourceUri(cacheKey);
|
|
294
|
+
if (capabilities?.resources?.subscribe && uri && subscriptions.has(uri)) {
|
|
295
|
+
void server.server
|
|
296
|
+
.sendResourceUpdated({ uri })
|
|
297
|
+
.catch((error) => {
|
|
298
|
+
logWarn('Failed to send update', {
|
|
299
|
+
uri,
|
|
300
|
+
error: getErrorMessage(error),
|
|
301
|
+
});
|
|
302
|
+
});
|
|
463
303
|
}
|
|
464
|
-
if (listChanged) {
|
|
465
|
-
server.sendResourceListChanged();
|
|
304
|
+
if (capabilities?.resources?.listChanged) {
|
|
305
|
+
void server.server.sendResourceListChanged().catch(() => { });
|
|
466
306
|
}
|
|
467
307
|
});
|
|
468
|
-
appendServerOnClose(server, unsubscribe);
|
|
469
|
-
}
|
|
470
|
-
export function registerCachedContentResource(server, serverIcons) {
|
|
471
|
-
const isInitialized = attachInitializedGate(server);
|
|
472
|
-
const subscriptions = registerResourceSubscriptionHandlers(server);
|
|
473
|
-
registerCacheContentResource(server, serverIcons);
|
|
474
|
-
registerCacheUpdateSubscription(server, subscriptions, isInitialized);
|
|
475
|
-
}
|
|
476
|
-
/* -------------------------------------------------------------------------------------------------
|
|
477
|
-
* Filename generation
|
|
478
|
-
* ------------------------------------------------------------------------------------------------- */
|
|
479
|
-
const MAX_FILENAME_LENGTH = 200;
|
|
480
|
-
const UNSAFE_CHARS_REGEX = /[<>:"/\\|?*]|\p{C}/gu;
|
|
481
|
-
const WHITESPACE_REGEX = /\s+/g;
|
|
482
|
-
function trimHyphens(value) {
|
|
483
|
-
let start = 0;
|
|
484
|
-
let end = value.length;
|
|
485
|
-
while (start < end && value[start] === '-')
|
|
486
|
-
start += 1;
|
|
487
|
-
while (end > start && value[end - 1] === '-')
|
|
488
|
-
end -= 1;
|
|
489
|
-
return value.slice(start, end);
|
|
490
308
|
}
|
|
491
|
-
function
|
|
492
|
-
return segment.replace(/\.(html?|php|aspx?|jsp)$/i, '');
|
|
493
|
-
}
|
|
494
|
-
function normalizeUrlFilenameSegment(segment) {
|
|
495
|
-
const cleaned = stripCommonPageExtension(segment);
|
|
496
|
-
if (!cleaned)
|
|
497
|
-
return null;
|
|
498
|
-
if (cleaned === 'index')
|
|
499
|
-
return null;
|
|
500
|
-
return cleaned;
|
|
501
|
-
}
|
|
502
|
-
function getLastPathSegment(url) {
|
|
503
|
-
const segments = url.pathname.split('/').filter(Boolean);
|
|
504
|
-
if (segments.length === 0)
|
|
505
|
-
return null;
|
|
506
|
-
return segments[segments.length - 1] ?? null;
|
|
507
|
-
}
|
|
508
|
-
function extractFilenameFromUrl(url) {
|
|
309
|
+
function isValidCacheUri(uri) {
|
|
509
310
|
try {
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
311
|
+
const url = new URL(uri);
|
|
312
|
+
if (url.protocol !== 'superfetch:' || url.hostname !== 'cache')
|
|
313
|
+
return false;
|
|
314
|
+
if (url.search || url.hash)
|
|
315
|
+
return false;
|
|
316
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
317
|
+
return parts.length === 2 && parts[0] === 'markdown';
|
|
515
318
|
}
|
|
516
319
|
catch {
|
|
517
|
-
return
|
|
320
|
+
return false;
|
|
518
321
|
}
|
|
519
322
|
}
|
|
520
|
-
|
|
521
|
-
|
|
323
|
+
/* -------------------------------------------------------------------------------------------------
|
|
324
|
+
* Utils: Filename Logic
|
|
325
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
326
|
+
const FILENAME_RULES = {
|
|
327
|
+
MAX_LEN: 200,
|
|
328
|
+
UNSAFE_CHARS: /[<>:"/\\|?*\p{C}]/gu,
|
|
329
|
+
WHITESPACE: /\s+/g,
|
|
330
|
+
EXTENSIONS: /\.(html?|php|aspx?|jsp)$/i,
|
|
331
|
+
};
|
|
332
|
+
function sanitizeString(input) {
|
|
333
|
+
return input
|
|
522
334
|
.toLowerCase()
|
|
523
|
-
.
|
|
524
|
-
.replace(
|
|
525
|
-
.replace(
|
|
526
|
-
.replace(
|
|
527
|
-
const trimmed = trimHyphens(slug);
|
|
528
|
-
return trimmed || null;
|
|
529
|
-
}
|
|
530
|
-
function sanitizeFilename(name, extension) {
|
|
531
|
-
let sanitized = name
|
|
532
|
-
.replace(UNSAFE_CHARS_REGEX, '')
|
|
533
|
-
.replace(WHITESPACE_REGEX, '-')
|
|
534
|
-
.trim();
|
|
535
|
-
const maxBase = MAX_FILENAME_LENGTH - extension.length;
|
|
536
|
-
if (sanitized.length > maxBase) {
|
|
537
|
-
sanitized = sanitized.substring(0, maxBase);
|
|
538
|
-
}
|
|
539
|
-
return `${sanitized}${extension}`;
|
|
335
|
+
.replace(FILENAME_RULES.UNSAFE_CHARS, '')
|
|
336
|
+
.replace(FILENAME_RULES.WHITESPACE, '-')
|
|
337
|
+
.replace(/-+/g, '-')
|
|
338
|
+
.replace(/(?:^-|-$)/g, '');
|
|
540
339
|
}
|
|
541
340
|
export function generateSafeFilename(url, title, hashFallback, extension = '.md') {
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
if (!isValidHash(resolvedHash))
|
|
563
|
-
return null;
|
|
564
|
-
return { namespace: resolvedNamespace, hash: resolvedHash };
|
|
565
|
-
}
|
|
566
|
-
function buildCacheKeyFromParams(params) {
|
|
567
|
-
return `${params.namespace}:${params.hash}`;
|
|
568
|
-
}
|
|
569
|
-
function sendJsonError(res, status, error, code) {
|
|
570
|
-
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
571
|
-
res.end(JSON.stringify({ error, code }));
|
|
572
|
-
}
|
|
573
|
-
function respondBadRequest(res, message) {
|
|
574
|
-
sendJsonError(res, 400, message, 'BAD_REQUEST');
|
|
575
|
-
}
|
|
576
|
-
function respondNotFound(res) {
|
|
577
|
-
sendJsonError(res, 404, 'Content not found or expired', 'NOT_FOUND');
|
|
578
|
-
}
|
|
579
|
-
function respondServiceUnavailable(res) {
|
|
580
|
-
sendJsonError(res, 503, 'Download service is disabled', 'SERVICE_UNAVAILABLE');
|
|
581
|
-
}
|
|
582
|
-
function buildContentDisposition(fileName) {
|
|
583
|
-
const encodedName = encodeURIComponent(fileName).replace(/'/g, '%27');
|
|
584
|
-
return `attachment; filename="${fileName}"; filename*=UTF-8''${encodedName}`;
|
|
585
|
-
}
|
|
586
|
-
function sendDownloadPayload(res, payload) {
|
|
587
|
-
const disposition = buildContentDisposition(payload.fileName);
|
|
588
|
-
res.setHeader('Content-Type', payload.contentType);
|
|
589
|
-
res.setHeader('Content-Disposition', disposition);
|
|
590
|
-
res.setHeader('Cache-Control', `private, max-age=${config.cache.ttl}`);
|
|
591
|
-
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
592
|
-
res.end(payload.content);
|
|
593
|
-
}
|
|
594
|
-
function resolveDownloadPayload(params, cacheEntry) {
|
|
595
|
-
const payload = payloadCodec.parse(cacheEntry.content);
|
|
596
|
-
if (!payload)
|
|
597
|
-
return null;
|
|
598
|
-
const content = payloadCodec.resolveContent(payload);
|
|
599
|
-
if (!content)
|
|
600
|
-
return null;
|
|
601
|
-
const safeTitle = typeof payload.title === 'string' ? payload.title : undefined;
|
|
602
|
-
const fileName = generateSafeFilename(cacheEntry.url, cacheEntry.title ?? safeTitle, params.hash, '.md');
|
|
603
|
-
return {
|
|
604
|
-
content,
|
|
605
|
-
contentType: 'text/markdown; charset=utf-8',
|
|
606
|
-
fileName,
|
|
341
|
+
const tryUrl = () => {
|
|
342
|
+
try {
|
|
343
|
+
if (!URL.canParse(url))
|
|
344
|
+
return null;
|
|
345
|
+
const parsed = new URL(url);
|
|
346
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
347
|
+
return null;
|
|
348
|
+
const { pathname } = parsed;
|
|
349
|
+
const basename = pathPosix.basename(pathname);
|
|
350
|
+
if (!basename || basename === 'index')
|
|
351
|
+
return null;
|
|
352
|
+
const cleaned = basename.replace(FILENAME_RULES.EXTENSIONS, '');
|
|
353
|
+
const sanitized = sanitizeString(cleaned);
|
|
354
|
+
if (sanitized === 'index')
|
|
355
|
+
return null;
|
|
356
|
+
return sanitized || null;
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
607
361
|
};
|
|
362
|
+
const tryTitle = () => {
|
|
363
|
+
if (!title)
|
|
364
|
+
return null;
|
|
365
|
+
return sanitizeString(title) || null;
|
|
366
|
+
};
|
|
367
|
+
const name = tryUrl() ??
|
|
368
|
+
tryTitle() ??
|
|
369
|
+
hashFallback?.substring(0, 16) ??
|
|
370
|
+
`download-${Date.now()}`;
|
|
371
|
+
const maxBase = FILENAME_RULES.MAX_LEN - extension.length;
|
|
372
|
+
const truncated = name.length > maxBase ? name.substring(0, maxBase) : name;
|
|
373
|
+
return `${truncated}${extension}`;
|
|
608
374
|
}
|
|
375
|
+
/* -------------------------------------------------------------------------------------------------
|
|
376
|
+
* Adapter: Download Handler
|
|
377
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
378
|
+
const DownloadParamsSchema = z.object({
|
|
379
|
+
namespace: CacheNamespace,
|
|
380
|
+
hash: HashString,
|
|
381
|
+
});
|
|
609
382
|
export function handleDownload(res, namespace, hash) {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
}
|
|
614
|
-
const
|
|
615
|
-
if (!
|
|
616
|
-
|
|
383
|
+
const respond = (status, msg, code) => {
|
|
384
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
385
|
+
res.end(JSON.stringify({ error: msg, code }));
|
|
386
|
+
};
|
|
387
|
+
const parsed = DownloadParamsSchema.safeParse({ namespace, hash });
|
|
388
|
+
if (!parsed.success) {
|
|
389
|
+
respond(400, 'Invalid namespace or hash', 'BAD_REQUEST');
|
|
617
390
|
return;
|
|
618
391
|
}
|
|
619
|
-
const cacheKey =
|
|
620
|
-
const
|
|
621
|
-
if (!
|
|
622
|
-
|
|
623
|
-
respondNotFound(res);
|
|
392
|
+
const cacheKey = `${parsed.data.namespace}:${parsed.data.hash}`;
|
|
393
|
+
const entry = store.get(cacheKey, { force: true });
|
|
394
|
+
if (!entry) {
|
|
395
|
+
respond(404, 'Not found or expired', 'NOT_FOUND');
|
|
624
396
|
return;
|
|
625
397
|
}
|
|
626
|
-
const payload =
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
398
|
+
const payload = parseCachedPayload(entry.content);
|
|
399
|
+
const content = payload ? resolveCachedPayloadContent(payload) : null;
|
|
400
|
+
if (!content) {
|
|
401
|
+
respond(404, 'Content missing', 'NOT_FOUND');
|
|
630
402
|
return;
|
|
631
403
|
}
|
|
632
|
-
|
|
633
|
-
|
|
404
|
+
const fileName = generateSafeFilename(entry.url, payload?.title, parsed.data.hash);
|
|
405
|
+
// Safe header generation
|
|
406
|
+
const encoded = encodeURIComponent(fileName).replace(/'/g, '%27');
|
|
407
|
+
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
|
|
408
|
+
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"; filename*=UTF-8''${encoded}`);
|
|
409
|
+
res.setHeader('Cache-Control', `private, max-age=${config.cache.ttl}`);
|
|
410
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
411
|
+
res.end(content);
|
|
634
412
|
}
|