@j0hanz/superfetch 2.4.3 → 2.4.5

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/dist/cache.js CHANGED
@@ -6,29 +6,12 @@ import { getErrorMessage } from './errors.js';
6
6
  import { stableStringify as stableJsonStringify } from './json.js';
7
7
  import { logDebug, logWarn } from './observability.js';
8
8
  import { isObject } from './type-guards.js';
9
- export function parseCachedPayload(raw) {
10
- try {
11
- const parsed = JSON.parse(raw);
12
- return isCachedPayload(parsed) ? parsed : null;
13
- }
14
- catch {
15
- return null;
16
- }
17
- }
18
- export function resolveCachedPayloadContent(payload) {
19
- if (typeof payload.markdown === 'string') {
20
- return payload.markdown;
21
- }
22
- if (typeof payload.content === 'string') {
23
- return payload.content;
24
- }
25
- return null;
26
- }
9
+ /* -------------------------------------------------------------------------------------------------
10
+ * Cached payload codec
11
+ * ------------------------------------------------------------------------------------------------- */
27
12
  function hasOptionalStringProperty(value, key) {
28
13
  const prop = value[key];
29
- if (prop === undefined)
30
- return true;
31
- return typeof prop === 'string';
14
+ return prop === undefined ? true : typeof prop === 'string';
32
15
  }
33
16
  function isCachedPayload(value) {
34
17
  if (!isObject(value))
@@ -41,6 +24,34 @@ function isCachedPayload(value) {
41
24
  return false;
42
25
  return true;
43
26
  }
27
+ class CachedPayloadCodec {
28
+ parse(raw) {
29
+ try {
30
+ const parsed = JSON.parse(raw);
31
+ return isCachedPayload(parsed) ? parsed : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ resolveContent(payload) {
38
+ if (typeof payload.markdown === 'string')
39
+ return payload.markdown;
40
+ if (typeof payload.content === 'string')
41
+ return payload.content;
42
+ return null;
43
+ }
44
+ }
45
+ const payloadCodec = new CachedPayloadCodec();
46
+ export function parseCachedPayload(raw) {
47
+ return payloadCodec.parse(raw);
48
+ }
49
+ export function resolveCachedPayloadContent(payload) {
50
+ return payloadCodec.resolveContent(payload);
51
+ }
52
+ /* -------------------------------------------------------------------------------------------------
53
+ * Cache key codec (hashing + parsing + resource URI)
54
+ * ------------------------------------------------------------------------------------------------- */
44
55
  const CACHE_HASH = {
45
56
  URL_HASH_LENGTH: 32,
46
57
  VARY_HASH_LENGTH: 16,
@@ -64,50 +75,59 @@ function buildCacheKey(namespace, urlHash, varyHash) {
64
75
  function getVaryHash(vary) {
65
76
  if (!vary)
66
77
  return undefined;
67
- let varyString;
68
- if (typeof vary === 'string') {
69
- varyString = vary;
70
- }
71
- else {
72
- varyString = stableStringify(vary);
73
- }
78
+ const varyString = typeof vary === 'string' ? vary : stableStringify(vary);
74
79
  if (varyString === null)
75
80
  return null;
76
81
  if (!varyString)
77
82
  return undefined;
78
83
  return createHashFragment(varyString, CACHE_HASH.VARY_HASH_LENGTH);
79
84
  }
85
+ function buildCacheResourceUri(namespace, urlHash) {
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)
110
+ return null;
111
+ return buildCacheResourceUri(parts.namespace, parts.urlHash);
112
+ }
113
+ }
114
+ const cacheKeyCodec = new CacheKeyCodec();
80
115
  export function createCacheKey(namespace, url, vary) {
81
- if (!namespace || !url)
82
- return null;
83
- const urlHash = createHashFragment(url, CACHE_HASH.URL_HASH_LENGTH);
84
- const varyHash = getVaryHash(vary);
85
- if (varyHash === null)
86
- return null;
87
- return buildCacheKey(namespace, urlHash, varyHash);
116
+ return cacheKeyCodec.create(namespace, url, vary);
88
117
  }
89
118
  export function parseCacheKey(cacheKey) {
90
- if (!cacheKey)
91
- return null;
92
- const [namespace, ...rest] = cacheKey.split(':');
93
- const urlHash = rest.join(':');
94
- if (!namespace || !urlHash)
95
- return null;
96
- return { namespace, urlHash };
97
- }
98
- function buildCacheResourceUri(namespace, urlHash) {
99
- return `superfetch://cache/${namespace}/${urlHash}`;
119
+ return cacheKeyCodec.parse(cacheKey);
100
120
  }
101
121
  export function toResourceUri(cacheKey) {
102
- const parts = parseCacheKey(cacheKey);
103
- if (!parts)
104
- return null;
105
- return buildCacheResourceUri(parts.namespace, parts.urlHash);
106
- }
107
- // Cache behavior contract (native implementation):
108
- // - Max entries: config.cache.maxKeys
109
- // - TTL in ms: config.cache.ttl * 1000
110
- // - Access does NOT extend TTL
122
+ return cacheKeyCodec.toResourceUri(cacheKey);
123
+ }
124
+ /* -------------------------------------------------------------------------------------------------
125
+ * In-memory LRU cache (native)
126
+ * Contract:
127
+ * - Max entries: config.cache.maxKeys
128
+ * - TTL in ms: config.cache.ttl * 1000
129
+ * - Access does NOT extend TTL
130
+ * ------------------------------------------------------------------------------------------------- */
111
131
  class NativeLruCache {
112
132
  max;
113
133
  ttlMs;
@@ -135,10 +155,7 @@ class NativeLruCache {
135
155
  return;
136
156
  const now = Date.now();
137
157
  this.entries.delete(key);
138
- this.entries.set(key, {
139
- value,
140
- expiresAtMs: now + this.ttlMs,
141
- });
158
+ this.entries.set(key, { value, expiresAtMs: now + this.ttlMs });
142
159
  this.maybePurge(now);
143
160
  while (this.entries.size > this.max) {
144
161
  const oldestKey = this.entries.keys().next().value;
@@ -159,131 +176,141 @@ class NativeLruCache {
159
176
  }
160
177
  purgeExpired(now) {
161
178
  for (const [key, entry] of this.entries) {
162
- if (this.isExpired(entry, now)) {
179
+ if (this.isExpired(entry, now))
163
180
  this.entries.delete(key);
164
- }
165
181
  }
166
182
  }
167
183
  isExpired(entry, now) {
168
184
  return entry.expiresAtMs <= now;
169
185
  }
170
186
  }
171
- const contentCache = new NativeLruCache({
172
- max: config.cache.maxKeys,
173
- ttlMs: config.cache.ttl * 1000,
174
- });
175
- const updateListeners = new Set();
176
- export function onCacheUpdate(listener) {
177
- updateListeners.add(listener);
178
- return () => {
179
- updateListeners.delete(listener);
180
- };
181
- }
182
- function notifyCacheUpdate(cacheKey) {
183
- if (updateListeners.size === 0)
184
- return;
185
- const parts = parseCacheKey(cacheKey);
186
- if (!parts)
187
- return;
188
- const event = { cacheKey, ...parts };
189
- for (const listener of updateListeners) {
190
- listener(event);
187
+ class InMemoryCacheStore {
188
+ cache = new NativeLruCache({
189
+ max: config.cache.maxKeys,
190
+ ttlMs: config.cache.ttl * 1000,
191
+ });
192
+ listeners = new Set();
193
+ isEnabled() {
194
+ return config.cache.enabled;
191
195
  }
192
- }
193
- export function get(cacheKey) {
194
- if (!isCacheReadable(cacheKey))
195
- return undefined;
196
- return runCacheOperation(cacheKey, 'Cache get error', () => contentCache.get(cacheKey));
197
- }
198
- function isCacheReadable(cacheKey) {
199
- return config.cache.enabled && Boolean(cacheKey);
200
- }
201
- function isCacheWritable(cacheKey, content) {
202
- return config.cache.enabled && Boolean(cacheKey) && Boolean(content);
203
- }
204
- function runCacheOperation(cacheKey, message, operation) {
205
- try {
206
- return operation();
196
+ keys() {
197
+ return [...this.cache.keys()];
207
198
  }
208
- catch (error) {
209
- logCacheError(message, cacheKey, error);
210
- return undefined;
199
+ onUpdate(listener) {
200
+ this.listeners.add(listener);
201
+ return () => this.listeners.delete(listener);
211
202
  }
212
- }
213
- export function set(cacheKey, content, metadata) {
214
- if (!isCacheWritable(cacheKey, content))
215
- return;
216
- runCacheOperation(cacheKey, 'Cache set error', () => {
217
- const now = Date.now();
218
- const expiresAtMs = now + config.cache.ttl * 1000;
219
- const entry = buildCacheEntry({
203
+ get(cacheKey) {
204
+ if (!this.isReadable(cacheKey))
205
+ return undefined;
206
+ return this.run(cacheKey, 'Cache get error', () => this.cache.get(cacheKey));
207
+ }
208
+ set(cacheKey, content, metadata) {
209
+ if (!this.isWritable(cacheKey, content))
210
+ return;
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);
231
+ return undefined;
232
+ }
233
+ }
234
+ buildEntry(content, metadata, fetchedAtMs, expiresAtMs) {
235
+ return {
236
+ url: metadata.url,
220
237
  content,
221
- metadata,
222
- fetchedAtMs: now,
223
- expiresAtMs,
238
+ fetchedAt: new Date(fetchedAtMs).toISOString(),
239
+ expiresAt: new Date(expiresAtMs).toISOString(),
240
+ ...(metadata.title === undefined ? {} : { title: metadata.title }),
241
+ };
242
+ }
243
+ notify(cacheKey) {
244
+ if (this.listeners.size === 0)
245
+ return;
246
+ const parts = cacheKeyCodec.parse(cacheKey);
247
+ if (!parts)
248
+ return;
249
+ const event = { cacheKey, ...parts };
250
+ for (const listener of this.listeners)
251
+ listener(event);
252
+ }
253
+ logError(message, cacheKey, error) {
254
+ logWarn(message, {
255
+ key: cacheKey.length > 100 ? cacheKey.slice(0, 100) : cacheKey,
256
+ error: getErrorMessage(error),
224
257
  });
225
- persistCacheEntry(cacheKey, entry);
226
- });
258
+ }
227
259
  }
228
- export function keys() {
229
- return [...contentCache.keys()];
260
+ const store = new InMemoryCacheStore();
261
+ export function onCacheUpdate(listener) {
262
+ return store.onUpdate(listener);
230
263
  }
231
- export function isEnabled() {
232
- return config.cache.enabled;
264
+ export function get(cacheKey) {
265
+ return store.get(cacheKey);
233
266
  }
234
- function buildCacheEntry({ content, metadata, fetchedAtMs, expiresAtMs, }) {
235
- return {
236
- url: metadata.url,
237
- content,
238
- fetchedAt: new Date(fetchedAtMs).toISOString(),
239
- expiresAt: new Date(expiresAtMs).toISOString(),
240
- ...(metadata.title === undefined ? {} : { title: metadata.title }),
241
- };
267
+ export function set(cacheKey, content, metadata) {
268
+ store.set(cacheKey, content, metadata);
242
269
  }
243
- function persistCacheEntry(cacheKey, entry) {
244
- contentCache.set(cacheKey, entry);
245
- notifyCacheUpdate(cacheKey);
270
+ export function keys() {
271
+ return store.keys();
246
272
  }
247
- function logCacheError(message, cacheKey, error) {
248
- logWarn(message, {
249
- key: cacheKey.length > 100 ? cacheKey.slice(0, 100) : cacheKey,
250
- error: getErrorMessage(error),
251
- });
273
+ export function isEnabled() {
274
+ return store.isEnabled();
252
275
  }
276
+ /* -------------------------------------------------------------------------------------------------
277
+ * MCP cached content resource (superfetch://cache/markdown/{urlHash})
278
+ * ------------------------------------------------------------------------------------------------- */
253
279
  const CACHE_NAMESPACE = 'markdown';
254
280
  const HASH_PATTERN = /^[a-f0-9.]+$/i;
255
281
  const INVALID_CACHE_PARAMS_MESSAGE = 'Invalid cache resource parameters';
256
282
  function throwInvalidCacheParams() {
257
283
  throw new McpError(ErrorCode.InvalidParams, INVALID_CACHE_PARAMS_MESSAGE);
258
284
  }
259
- function resolveCacheParams(params) {
260
- const parsed = requireRecordParams(params);
261
- const namespace = requireParamString(parsed, 'namespace');
262
- const urlHash = requireParamString(parsed, 'urlHash');
263
- if (!isValidNamespace(namespace) || !isValidHash(urlHash)) {
264
- throwInvalidCacheParams();
265
- }
266
- return { namespace, urlHash };
285
+ function resolveStringParam(value) {
286
+ return typeof value === 'string' ? value : null;
287
+ }
288
+ function isValidNamespace(namespace) {
289
+ return namespace === CACHE_NAMESPACE;
290
+ }
291
+ function isValidHash(hash) {
292
+ return HASH_PATTERN.test(hash) && hash.length >= 8 && hash.length <= 64;
267
293
  }
268
294
  function requireRecordParams(value) {
269
- if (!isObject(value)) {
295
+ if (!isObject(value))
270
296
  throwInvalidCacheParams();
271
- }
272
297
  return value;
273
298
  }
274
299
  function requireParamString(params, key) {
275
- const raw = params[key];
276
- const resolved = resolveStringParam(raw);
300
+ const resolved = resolveStringParam(params[key]);
277
301
  if (!resolved) {
278
302
  throw new McpError(ErrorCode.InvalidParams, 'Both namespace and urlHash parameters are required');
279
303
  }
280
304
  return resolved;
281
305
  }
282
- function isValidNamespace(namespace) {
283
- return namespace === CACHE_NAMESPACE;
284
- }
285
- function isValidHash(hash) {
286
- return HASH_PATTERN.test(hash) && hash.length >= 8 && hash.length <= 64;
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 };
287
314
  }
288
315
  function isValidCacheResourceUri(uri) {
289
316
  if (!uri)
@@ -307,9 +334,6 @@ function isValidCacheResourceUri(uri) {
307
334
  return false;
308
335
  return isValidNamespace(namespace) && isValidHash(urlHash);
309
336
  }
310
- function resolveStringParam(value) {
311
- return typeof value === 'string' ? value : null;
312
- }
313
337
  function buildResourceEntry(namespace, urlHash) {
314
338
  return {
315
339
  name: `${namespace}:${urlHash}`,
@@ -321,7 +345,7 @@ function buildResourceEntry(namespace, urlHash) {
321
345
  function listCachedResources() {
322
346
  const resources = keys()
323
347
  .map((key) => {
324
- const parts = parseCacheKey(key);
348
+ const parts = cacheKeyCodec.parse(key);
325
349
  if (parts?.namespace !== CACHE_NAMESPACE)
326
350
  return null;
327
351
  return buildResourceEntry(parts.namespace, parts.urlHash);
@@ -347,18 +371,13 @@ function attachInitializedGate(server) {
347
371
  }
348
372
  function getClientResourceCapabilities(server) {
349
373
  const caps = server.server.getClientCapabilities();
350
- if (!caps || !isObject(caps)) {
374
+ if (!caps || !isObject(caps))
351
375
  return { listChanged: false, subscribe: false };
352
- }
353
376
  const { resources } = caps;
354
- if (!isObject(resources)) {
377
+ if (!isObject(resources))
355
378
  return { listChanged: false, subscribe: false };
356
- }
357
379
  const { listChanged, subscribe } = resources;
358
- return {
359
- listChanged: listChanged === true,
360
- subscribe: subscribe === true,
361
- };
380
+ return { listChanged: listChanged === true, subscribe: subscribe === true };
362
381
  }
363
382
  function registerResourceSubscriptionHandlers(server) {
364
383
  const subscriptions = new Set();
@@ -395,11 +414,24 @@ function notifyResourceUpdate(server, uri, subscriptions) {
395
414
  });
396
415
  });
397
416
  }
398
- export function registerCachedContentResource(server, serverIcons) {
399
- const isInitialized = attachInitializedGate(server);
400
- const subscriptions = registerResourceSubscriptionHandlers(server);
401
- registerCacheContentResource(server, serverIcons);
402
- registerCacheUpdateSubscription(server, subscriptions, isInitialized);
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
+ ],
434
+ };
403
435
  }
404
436
  function buildCachedContentResponse(uri, cacheKey) {
405
437
  const cached = requireCacheEntry(cacheKey);
@@ -425,10 +457,9 @@ function registerCacheUpdateSubscription(server, subscriptions, isInitialized) {
425
457
  return;
426
458
  const { listChanged, subscribe } = getClientResourceCapabilities(server);
427
459
  if (subscribe) {
428
- const resourceUri = toResourceUri(cacheKey);
429
- if (resourceUri) {
460
+ const resourceUri = cacheKeyCodec.toResourceUri(cacheKey);
461
+ if (resourceUri)
430
462
  notifyResourceUpdate(server, resourceUri, subscriptions);
431
- }
432
463
  }
433
464
  if (listChanged) {
434
465
  server.sendResourceListChanged();
@@ -436,76 +467,26 @@ function registerCacheUpdateSubscription(server, subscriptions, isInitialized) {
436
467
  });
437
468
  appendServerOnClose(server, unsubscribe);
438
469
  }
439
- function requireCacheEntry(cacheKey) {
440
- const cached = get(cacheKey);
441
- if (!cached) {
442
- throw new McpError(-32002, `Content not found in cache for key: ${cacheKey}`);
443
- }
444
- return cached;
445
- }
446
- function buildMarkdownContentResponse(uri, content) {
447
- const payload = parseCachedPayload(content);
448
- const resolvedContent = payload ? resolveCachedPayloadContent(payload) : null;
449
- if (!resolvedContent) {
450
- throw new McpError(ErrorCode.InternalError, 'Cached markdown content is missing');
451
- }
452
- return {
453
- contents: [
454
- {
455
- uri: uri.href,
456
- mimeType: 'text/markdown',
457
- text: resolvedContent,
458
- },
459
- ],
460
- };
461
- }
462
- function parseDownloadParams(namespace, hash) {
463
- const resolvedNamespace = resolveStringParam(namespace);
464
- const resolvedHash = resolveStringParam(hash);
465
- if (!resolvedNamespace || !resolvedHash)
466
- return null;
467
- if (!isValidNamespace(resolvedNamespace))
468
- return null;
469
- if (!isValidHash(resolvedHash))
470
- return null;
471
- return { namespace: resolvedNamespace, hash: resolvedHash };
472
- }
473
- function buildCacheKeyFromParams(params) {
474
- return `${params.namespace}:${params.hash}`;
475
- }
476
- function sendJsonError(res, status, error, code) {
477
- res.writeHead(status, { 'Content-Type': 'application/json' });
478
- res.end(JSON.stringify({ error, code }));
479
- }
480
- function respondBadRequest(res, message) {
481
- sendJsonError(res, 400, message, 'BAD_REQUEST');
482
- }
483
- function respondNotFound(res) {
484
- sendJsonError(res, 404, 'Content not found or expired', 'NOT_FOUND');
485
- }
486
- function respondServiceUnavailable(res) {
487
- sendJsonError(res, 503, 'Download service is disabled', 'SERVICE_UNAVAILABLE');
488
- }
489
- export function generateSafeFilename(url, title, hashFallback, extension = '.md') {
490
- const fromUrl = extractFilenameFromUrl(url);
491
- if (fromUrl)
492
- return sanitizeFilename(fromUrl, extension);
493
- if (title) {
494
- const fromTitle = slugifyTitle(title);
495
- if (fromTitle)
496
- return sanitizeFilename(fromTitle, extension);
497
- }
498
- if (hashFallback) {
499
- return `${hashFallback.substring(0, 16)}${extension}`;
500
- }
501
- return `download-${Date.now()}${extension}`;
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);
502
475
  }
503
- function getLastPathSegment(url) {
504
- const segments = url.pathname.split('/').filter(Boolean);
505
- if (segments.length === 0)
506
- return null;
507
- const lastSegment = segments[segments.length - 1];
508
- return lastSegment ?? null;
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);
509
490
  }
510
491
  function stripCommonPageExtension(segment) {
511
492
  return segment.replace(/\.(html?|php|aspx?|jsp)$/i, '');
@@ -518,6 +499,12 @@ function normalizeUrlFilenameSegment(segment) {
518
499
  return null;
519
500
  return cleaned;
520
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
+ }
521
508
  function extractFilenameFromUrl(url) {
522
509
  try {
523
510
  const urlObj = new URL(url);
@@ -530,20 +517,6 @@ function extractFilenameFromUrl(url) {
530
517
  return null;
531
518
  }
532
519
  }
533
- const MAX_FILENAME_LENGTH = 200;
534
- const UNSAFE_CHARS_REGEX = /[<>:"/\\|?*]|\p{C}/gu;
535
- const WHITESPACE_REGEX = /\s+/g;
536
- function trimHyphens(value) {
537
- let start = 0;
538
- let end = value.length;
539
- while (start < end && value[start] === '-') {
540
- start += 1;
541
- }
542
- while (end > start && value[end - 1] === '-') {
543
- end -= 1;
544
- }
545
- return value.slice(start, end);
546
- }
547
520
  function slugifyTitle(title) {
548
521
  const slug = title
549
522
  .toLowerCase()
@@ -559,27 +532,52 @@ function sanitizeFilename(name, extension) {
559
532
  .replace(UNSAFE_CHARS_REGEX, '')
560
533
  .replace(WHITESPACE_REGEX, '-')
561
534
  .trim();
562
- // Truncate if too long
563
535
  const maxBase = MAX_FILENAME_LENGTH - extension.length;
564
536
  if (sanitized.length > maxBase) {
565
537
  sanitized = sanitized.substring(0, maxBase);
566
538
  }
567
539
  return `${sanitized}${extension}`;
568
540
  }
569
- function resolveDownloadPayload(params, cacheEntry) {
570
- const payload = parseCachedPayload(cacheEntry.content);
571
- if (!payload)
541
+ export function generateSafeFilename(url, title, hashFallback, extension = '.md') {
542
+ const fromUrl = extractFilenameFromUrl(url);
543
+ if (fromUrl)
544
+ return sanitizeFilename(fromUrl, extension);
545
+ if (title) {
546
+ const fromTitle = slugifyTitle(title);
547
+ if (fromTitle)
548
+ return sanitizeFilename(fromTitle, extension);
549
+ }
550
+ if (hashFallback) {
551
+ return `${hashFallback.substring(0, 16)}${extension}`;
552
+ }
553
+ return `download-${Date.now()}${extension}`;
554
+ }
555
+ function parseDownloadParams(namespace, hash) {
556
+ const resolvedNamespace = resolveStringParam(namespace);
557
+ const resolvedHash = resolveStringParam(hash);
558
+ if (!resolvedNamespace || !resolvedHash)
572
559
  return null;
573
- const content = resolveCachedPayloadContent(payload);
574
- if (!content)
560
+ if (!isValidNamespace(resolvedNamespace))
575
561
  return null;
576
- const safeTitle = typeof payload.title === 'string' ? payload.title : undefined;
577
- const fileName = generateSafeFilename(cacheEntry.url, cacheEntry.title ?? safeTitle, params.hash, '.md');
578
- return {
579
- content,
580
- contentType: 'text/markdown; charset=utf-8',
581
- fileName,
582
- };
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');
583
581
  }
584
582
  function buildContentDisposition(fileName) {
585
583
  const encodedName = encodeURIComponent(fileName).replace(/'/g, '%27');
@@ -593,6 +591,21 @@ function sendDownloadPayload(res, payload) {
593
591
  res.setHeader('X-Content-Type-Options', 'nosniff');
594
592
  res.end(payload.content);
595
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,
607
+ };
608
+ }
596
609
  export function handleDownload(res, namespace, hash) {
597
610
  if (!config.cache.enabled) {
598
611
  respondServiceUnavailable(res);