@j0hanz/superfetch 2.0.0 → 2.1.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.
Files changed (150) hide show
  1. package/README.md +139 -46
  2. package/dist/cache.d.ts +42 -0
  3. package/dist/cache.js +565 -0
  4. package/dist/config/env-parsers.d.ts +1 -0
  5. package/dist/config/env-parsers.js +12 -0
  6. package/dist/config/index.d.ts +7 -0
  7. package/dist/config/index.js +20 -8
  8. package/dist/config/types/content.d.ts +1 -0
  9. package/dist/config.d.ts +77 -0
  10. package/dist/config.js +261 -0
  11. package/dist/crypto.d.ts +2 -0
  12. package/dist/crypto.js +32 -0
  13. package/dist/errors.d.ts +10 -0
  14. package/dist/errors.js +28 -0
  15. package/dist/fetch.d.ts +40 -0
  16. package/dist/fetch.js +910 -0
  17. package/dist/http/auth.js +161 -2
  18. package/dist/http/base-middleware.d.ts +7 -0
  19. package/dist/http/base-middleware.js +143 -0
  20. package/dist/http/cors.d.ts +0 -5
  21. package/dist/http/cors.js +0 -6
  22. package/dist/http/download-routes.js +6 -2
  23. package/dist/http/error-handler.d.ts +2 -0
  24. package/dist/http/error-handler.js +55 -0
  25. package/dist/http/host-allowlist.d.ts +3 -0
  26. package/dist/http/host-allowlist.js +117 -0
  27. package/dist/http/mcp-routes.d.ts +8 -2
  28. package/dist/http/mcp-routes.js +101 -8
  29. package/dist/http/mcp-session-eviction.d.ts +3 -0
  30. package/dist/http/mcp-session-eviction.js +24 -0
  31. package/dist/http/mcp-session-init.d.ts +7 -0
  32. package/dist/http/mcp-session-init.js +94 -0
  33. package/dist/http/mcp-session-slots.d.ts +17 -0
  34. package/dist/http/mcp-session-slots.js +55 -0
  35. package/dist/http/mcp-session-transport-init.d.ts +7 -0
  36. package/dist/http/mcp-session-transport-init.js +41 -0
  37. package/dist/http/mcp-session-types.d.ts +5 -0
  38. package/dist/http/mcp-session-types.js +1 -0
  39. package/dist/http/mcp-session.d.ts +9 -9
  40. package/dist/http/mcp-session.js +5 -114
  41. package/dist/http/mcp-sessions.d.ts +41 -0
  42. package/dist/http/mcp-sessions.js +392 -0
  43. package/dist/http/rate-limit.js +2 -2
  44. package/dist/http/server-middleware.d.ts +6 -1
  45. package/dist/http/server-middleware.js +3 -117
  46. package/dist/http/server-shutdown.js +1 -1
  47. package/dist/http/server-tuning.d.ts +9 -0
  48. package/dist/http/server-tuning.js +45 -0
  49. package/dist/http/server.js +206 -9
  50. package/dist/http/session-cleanup.js +8 -5
  51. package/dist/http.d.ts +78 -0
  52. package/dist/http.js +1437 -0
  53. package/dist/index.js +3 -3
  54. package/dist/mcp.d.ts +3 -0
  55. package/dist/mcp.js +94 -0
  56. package/dist/middleware/error-handler.d.ts +1 -1
  57. package/dist/middleware/error-handler.js +31 -30
  58. package/dist/observability.d.ts +16 -0
  59. package/dist/observability.js +78 -0
  60. package/dist/resources/cached-content-params.d.ts +5 -0
  61. package/dist/resources/cached-content-params.js +36 -0
  62. package/dist/resources/cached-content.js +33 -33
  63. package/dist/server.js +21 -6
  64. package/dist/services/cache-events.d.ts +8 -0
  65. package/dist/services/cache-events.js +19 -0
  66. package/dist/services/cache.d.ts +5 -4
  67. package/dist/services/cache.js +49 -45
  68. package/dist/services/context.d.ts +2 -0
  69. package/dist/services/context.js +3 -0
  70. package/dist/services/extractor.d.ts +1 -0
  71. package/dist/services/extractor.js +77 -40
  72. package/dist/services/fetcher/agents.js +1 -1
  73. package/dist/services/fetcher/dns-selection.js +1 -1
  74. package/dist/services/fetcher/interceptors.js +29 -60
  75. package/dist/services/fetcher/redirects.js +12 -4
  76. package/dist/services/fetcher/response.js +18 -8
  77. package/dist/services/fetcher.d.ts +23 -0
  78. package/dist/services/fetcher.js +553 -13
  79. package/dist/services/logger.js +4 -1
  80. package/dist/services/telemetry.d.ts +19 -0
  81. package/dist/services/telemetry.js +43 -0
  82. package/dist/services/transform-worker-pool.d.ts +10 -3
  83. package/dist/services/transform-worker-pool.js +213 -184
  84. package/dist/tools/handlers/fetch-single.shared.d.ts +11 -3
  85. package/dist/tools/handlers/fetch-single.shared.js +131 -2
  86. package/dist/tools/handlers/fetch-url.tool.d.ts +6 -0
  87. package/dist/tools/handlers/fetch-url.tool.js +56 -12
  88. package/dist/tools/index.d.ts +1 -0
  89. package/dist/tools/index.js +13 -1
  90. package/dist/tools/schemas.d.ts +2 -0
  91. package/dist/tools/schemas.js +8 -0
  92. package/dist/tools/utils/content-shaping.js +19 -4
  93. package/dist/tools/utils/content-transform-core.d.ts +5 -0
  94. package/dist/tools/utils/content-transform-core.js +180 -0
  95. package/dist/tools/utils/content-transform-workers.d.ts +1 -0
  96. package/dist/tools/utils/content-transform-workers.js +1 -0
  97. package/dist/tools/utils/content-transform.d.ts +2 -1
  98. package/dist/tools/utils/content-transform.js +37 -136
  99. package/dist/tools/utils/fetch-pipeline.js +47 -56
  100. package/dist/tools/utils/frontmatter.d.ts +3 -0
  101. package/dist/tools/utils/frontmatter.js +73 -0
  102. package/dist/tools/utils/markdown-heuristics.d.ts +1 -0
  103. package/dist/tools/utils/markdown-heuristics.js +19 -0
  104. package/dist/tools/utils/markdown-signals.d.ts +1 -0
  105. package/dist/tools/utils/markdown-signals.js +19 -0
  106. package/dist/tools/utils/raw-markdown-frontmatter.d.ts +3 -0
  107. package/dist/tools/utils/raw-markdown-frontmatter.js +73 -0
  108. package/dist/tools/utils/raw-markdown.d.ts +6 -0
  109. package/dist/tools/utils/raw-markdown.js +149 -0
  110. package/dist/tools.d.ts +104 -0
  111. package/dist/tools.js +421 -0
  112. package/dist/transform.d.ts +69 -0
  113. package/dist/transform.js +1509 -0
  114. package/dist/transformers/markdown/fenced-code-rule.d.ts +2 -0
  115. package/dist/transformers/markdown/fenced-code-rule.js +38 -0
  116. package/dist/transformers/markdown/frontmatter.d.ts +2 -0
  117. package/dist/transformers/markdown/frontmatter.js +45 -0
  118. package/dist/transformers/markdown/noise-rule.d.ts +2 -0
  119. package/dist/transformers/markdown/noise-rule.js +80 -0
  120. package/dist/transformers/markdown/turndown-instance.d.ts +2 -0
  121. package/dist/transformers/markdown/turndown-instance.js +19 -0
  122. package/dist/transformers/markdown.d.ts +5 -0
  123. package/dist/transformers/markdown.js +314 -0
  124. package/dist/transformers/markdown.transformer.js +2 -189
  125. package/dist/utils/cancellation.d.ts +1 -0
  126. package/dist/utils/cancellation.js +18 -0
  127. package/dist/utils/code-language-bash.d.ts +1 -0
  128. package/dist/utils/code-language-bash.js +48 -0
  129. package/dist/utils/code-language-core.d.ts +2 -0
  130. package/dist/utils/code-language-core.js +13 -0
  131. package/dist/utils/code-language-detectors.d.ts +5 -0
  132. package/dist/utils/code-language-detectors.js +142 -0
  133. package/dist/utils/code-language-helpers.d.ts +5 -0
  134. package/dist/utils/code-language-helpers.js +62 -0
  135. package/dist/utils/code-language-parsing.d.ts +5 -0
  136. package/dist/utils/code-language-parsing.js +62 -0
  137. package/dist/utils/code-language.js +250 -46
  138. package/dist/utils/error-details.d.ts +3 -0
  139. package/dist/utils/error-details.js +12 -0
  140. package/dist/utils/filename-generator.js +14 -3
  141. package/dist/utils/host-normalizer.d.ts +1 -0
  142. package/dist/utils/host-normalizer.js +37 -0
  143. package/dist/utils/ip-address.d.ts +4 -0
  144. package/dist/utils/ip-address.js +6 -0
  145. package/dist/utils/tool-error-handler.js +12 -17
  146. package/dist/utils/url-redactor.d.ts +1 -0
  147. package/dist/utils/url-redactor.js +13 -0
  148. package/dist/utils/url-validator.js +35 -20
  149. package/dist/workers/transform-worker.js +82 -38
  150. package/package.json +13 -10
@@ -1,10 +1,28 @@
1
1
  import { setInterval as setIntervalPromise } from 'node:timers/promises';
2
2
  import { config } from '../config/index.js';
3
- import { getErrorMessage } from '../utils/error-utils.js';
3
+ import { getErrorMessage } from '../utils/error-details.js';
4
4
  import { parseCacheKey } from './cache-keys.js';
5
5
  import { logWarn } from './logger.js';
6
6
  const contentCache = new Map();
7
7
  let cleanupController = null;
8
+ const updateListeners = new Set();
9
+ export function onCacheUpdate(listener) {
10
+ updateListeners.add(listener);
11
+ return () => {
12
+ updateListeners.delete(listener);
13
+ };
14
+ }
15
+ function notifyCacheUpdate(cacheKey) {
16
+ if (updateListeners.size === 0)
17
+ return;
18
+ const parts = parseCacheKey(cacheKey);
19
+ if (!parts)
20
+ return;
21
+ const event = { cacheKey, ...parts };
22
+ for (const listener of updateListeners) {
23
+ listener(event);
24
+ }
25
+ }
8
26
  function startCleanupLoop() {
9
27
  if (cleanupController)
10
28
  return;
@@ -17,15 +35,14 @@ function startCleanupLoop() {
17
35
  }
18
36
  async function runCleanupLoop(signal) {
19
37
  const intervalMs = Math.floor(config.cache.ttl * 1000);
20
- for await (const _ of setIntervalPromise(intervalMs, undefined, {
38
+ for await (const getNow of setIntervalPromise(intervalMs, Date.now, {
21
39
  signal,
22
40
  ref: false,
23
41
  })) {
24
- enforceCacheLimits();
42
+ enforceCacheLimits(getNow());
25
43
  }
26
44
  }
27
- function enforceCacheLimits() {
28
- const now = Date.now();
45
+ function enforceCacheLimits(now) {
29
46
  for (const [key, item] of contentCache.entries()) {
30
47
  if (now > item.expiresAt) {
31
48
  contentCache.delete(key);
@@ -33,21 +50,6 @@ function enforceCacheLimits() {
33
50
  }
34
51
  trimCacheToMaxKeys();
35
52
  }
36
- const updateListeners = new Set();
37
- export function onCacheUpdate(listener) {
38
- updateListeners.add(listener);
39
- return () => {
40
- updateListeners.delete(listener);
41
- };
42
- }
43
- function emitCacheUpdate(cacheKey) {
44
- const parts = parseCacheKey(cacheKey);
45
- if (!parts)
46
- return;
47
- for (const listener of updateListeners) {
48
- listener({ cacheKey, ...parts });
49
- }
50
- }
51
53
  export function get(cacheKey) {
52
54
  if (!isCacheReadable(cacheKey))
53
55
  return undefined;
@@ -69,16 +71,17 @@ function runCacheOperation(cacheKey, message, operation) {
69
71
  }
70
72
  }
71
73
  function readCacheEntry(cacheKey) {
72
- return readCacheItem(cacheKey)?.entry;
74
+ const now = Date.now();
75
+ return readCacheItem(cacheKey, now)?.entry;
73
76
  }
74
- function isExpired(item) {
75
- return Date.now() > item.expiresAt;
77
+ function isExpired(item, now) {
78
+ return now > item.expiresAt;
76
79
  }
77
- function readCacheItem(cacheKey) {
80
+ function readCacheItem(cacheKey, now) {
78
81
  const item = contentCache.get(cacheKey);
79
82
  if (!item)
80
83
  return undefined;
81
- if (isExpired(item)) {
84
+ if (isExpired(item, now)) {
82
85
  contentCache.delete(cacheKey);
83
86
  return undefined;
84
87
  }
@@ -89,8 +92,15 @@ export function set(cacheKey, content, metadata) {
89
92
  return;
90
93
  runCacheOperation(cacheKey, 'Cache set error', () => {
91
94
  startCleanupLoop();
92
- const entry = buildCacheEntry(content, metadata);
93
- persistCacheEntry(cacheKey, entry);
95
+ const now = Date.now();
96
+ const expiresAtMs = now + config.cache.ttl * 1000;
97
+ const entry = buildCacheEntry({
98
+ content,
99
+ metadata,
100
+ fetchedAtMs: now,
101
+ expiresAtMs,
102
+ });
103
+ persistCacheEntry(cacheKey, entry, expiresAtMs);
94
104
  });
95
105
  }
96
106
  export function keys() {
@@ -99,20 +109,19 @@ export function keys() {
99
109
  export function isEnabled() {
100
110
  return config.cache.enabled;
101
111
  }
102
- function buildCacheEntry(content, metadata) {
112
+ function buildCacheEntry({ content, metadata, fetchedAtMs, expiresAtMs, }) {
103
113
  return {
104
114
  url: metadata.url,
105
115
  content,
106
- fetchedAt: new Date().toISOString(),
107
- expiresAt: new Date(resolveExpiryTimestamp()).toISOString(),
116
+ fetchedAt: new Date(fetchedAtMs).toISOString(),
117
+ expiresAt: new Date(expiresAtMs).toISOString(),
108
118
  ...(metadata.title === undefined ? {} : { title: metadata.title }),
109
119
  };
110
120
  }
111
- function persistCacheEntry(cacheKey, entry) {
112
- const expiresAt = resolveExpiryTimestamp();
113
- contentCache.set(cacheKey, { entry, expiresAt });
121
+ function persistCacheEntry(cacheKey, entry, expiresAtMs) {
122
+ contentCache.set(cacheKey, { entry, expiresAt: expiresAtMs });
114
123
  trimCacheToMaxKeys();
115
- emitCacheUpdate(cacheKey);
124
+ notifyCacheUpdate(cacheKey);
116
125
  }
117
126
  function trimCacheToMaxKeys() {
118
127
  if (contentCache.size <= config.cache.maxKeys)
@@ -120,19 +129,14 @@ function trimCacheToMaxKeys() {
120
129
  removeOldestEntries(contentCache.size - config.cache.maxKeys);
121
130
  }
122
131
  function removeOldestEntries(count) {
123
- if (count <= 0)
124
- return;
125
- let removed = 0;
126
- for (const key of contentCache.keys()) {
127
- contentCache.delete(key);
128
- removed += 1;
129
- if (removed >= count)
130
- return;
132
+ const iterator = contentCache.keys();
133
+ for (let removed = 0; removed < count; removed += 1) {
134
+ const next = iterator.next();
135
+ if (next.done)
136
+ break;
137
+ contentCache.delete(next.value);
131
138
  }
132
139
  }
133
- function resolveExpiryTimestamp() {
134
- return Date.now() + config.cache.ttl * 1000;
135
- }
136
140
  function logCacheError(message, cacheKey, error) {
137
141
  logWarn(message, {
138
142
  key: cacheKey.length > 100 ? cacheKey.slice(0, 100) : cacheKey,
@@ -1,8 +1,10 @@
1
1
  interface RequestContext {
2
2
  readonly requestId: string;
3
3
  readonly sessionId?: string;
4
+ readonly operationId?: string;
4
5
  }
5
6
  export declare function runWithRequestContext<T>(context: RequestContext, fn: () => T): T;
6
7
  export declare function getRequestId(): string | undefined;
7
8
  export declare function getSessionId(): string | undefined;
9
+ export declare function getOperationId(): string | undefined;
8
10
  export {};
@@ -9,3 +9,6 @@ export function getRequestId() {
9
9
  export function getSessionId() {
10
10
  return requestContext.getStore()?.sessionId;
11
11
  }
12
+ export function getOperationId() {
13
+ return requestContext.getStore()?.operationId;
14
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ExtractionResult } from '../config/types/content.js';
2
2
  export declare function extractContent(html: string, url: string, options?: {
3
3
  extractArticle?: boolean;
4
+ signal?: AbortSignal;
4
5
  }): ExtractionResult;
@@ -1,28 +1,31 @@
1
1
  import { parseHTML } from 'linkedom';
2
2
  import { Readability } from '@mozilla/readability';
3
- import { getErrorMessage } from '../utils/error-utils.js';
3
+ import { FetchError } from '../errors/app-error.js';
4
+ import { throwIfAborted } from '../utils/cancellation.js';
5
+ import { getErrorMessage } from '../utils/error-details.js';
4
6
  import { isRecord } from '../utils/guards.js';
5
7
  import { truncateHtml } from '../utils/html-truncator.js';
6
8
  import { logError, logInfo, logWarn } from './logger.js';
7
9
  import { extractMetadata } from './metadata-collector.js';
10
+ import { endTransformStage, startTransformStage } from './telemetry.js';
8
11
  function isReadabilityCompatible(doc) {
9
12
  if (!isRecord(doc))
10
13
  return false;
11
- if (!('documentElement' in doc))
12
- return false;
13
- if (typeof doc.querySelectorAll !== 'function')
14
- return false;
15
- if (typeof doc.querySelector !== 'function')
16
- return false;
17
- return true;
14
+ return hasDocumentElement(doc) && hasQuerySelectors(doc);
15
+ }
16
+ function hasDocumentElement(record) {
17
+ return 'documentElement' in record;
18
+ }
19
+ function hasQuerySelectors(record) {
20
+ return (typeof record.querySelectorAll === 'function' &&
21
+ typeof record.querySelector === 'function');
18
22
  }
19
23
  function extractArticle(document) {
20
24
  if (!isReadabilityCompatible(document)) {
21
25
  logWarn('Document not compatible with Readability');
22
26
  return null;
23
27
  }
24
- const parsed = parseReadabilityArticle(document);
25
- return parsed ? mapReadabilityResult(parsed) : null;
28
+ return mapParsedArticle(parseReadabilityArticle(document));
26
29
  }
27
30
  function parseReadabilityArticle(document) {
28
31
  try {
@@ -31,33 +34,42 @@ function parseReadabilityArticle(document) {
31
34
  return reader.parse();
32
35
  }
33
36
  catch (error) {
34
- logError('Failed to extract article with Readability', error instanceof Error ? error : undefined);
37
+ logError('Failed to extract article with Readability', asError(error));
35
38
  return null;
36
39
  }
37
40
  }
41
+ function asError(error) {
42
+ if (error instanceof Error) {
43
+ return error;
44
+ }
45
+ return undefined;
46
+ }
47
+ function mapParsedArticle(parsed) {
48
+ return parsed ? mapReadabilityResult(parsed) : null;
49
+ }
38
50
  function mapReadabilityResult(parsed) {
39
- const article = {
51
+ return {
40
52
  content: parsed.content ?? '',
41
53
  textContent: parsed.textContent ?? '',
54
+ ...buildOptionalArticleFields(parsed),
42
55
  };
43
- const title = toOptional(parsed.title);
44
- if (title !== undefined)
45
- article.title = title;
46
- const byline = toOptional(parsed.byline);
47
- if (byline !== undefined)
48
- article.byline = byline;
49
- const excerpt = toOptional(parsed.excerpt);
50
- if (excerpt !== undefined)
51
- article.excerpt = excerpt;
52
- const siteName = toOptional(parsed.siteName);
53
- if (siteName !== undefined)
54
- article.siteName = siteName;
55
- return article;
56
- }
57
- function toOptional(value) {
58
- return value ?? undefined;
59
- }
60
- export function extractContent(html, url, options = { extractArticle: true }) {
56
+ }
57
+ function buildOptionalArticleFields(parsed) {
58
+ const optional = {};
59
+ addOptionalField(optional, 'title', parsed.title);
60
+ addOptionalField(optional, 'byline', parsed.byline);
61
+ addOptionalField(optional, 'excerpt', parsed.excerpt);
62
+ addOptionalField(optional, 'siteName', parsed.siteName);
63
+ return optional;
64
+ }
65
+ function addOptionalField(target, key, value) {
66
+ if (value == null)
67
+ return;
68
+ target[key] = value;
69
+ }
70
+ export function extractContent(html, url, options = {
71
+ extractArticle: true,
72
+ }) {
61
73
  if (!isValidInput(html, url)) {
62
74
  return { article: null, metadata: {} };
63
75
  }
@@ -65,29 +77,54 @@ export function extractContent(html, url, options = { extractArticle: true }) {
65
77
  }
66
78
  function tryExtractContent(html, url, options) {
67
79
  try {
80
+ throwIfAborted(options.signal, url, 'extract:begin');
81
+ const parseStage = startTransformStage(url, 'extract:parse');
68
82
  const { document } = parseHTML(truncateHtml(html));
83
+ endTransformStage(parseStage);
84
+ throwIfAborted(options.signal, url, 'extract:parsed');
69
85
  applyBaseUri(document, url);
86
+ const metadataStage = startTransformStage(url, 'extract:metadata');
70
87
  const metadata = extractMetadata(document);
88
+ endTransformStage(metadataStage);
89
+ throwIfAborted(options.signal, url, 'extract:metadata');
90
+ let article;
91
+ if (options.extractArticle) {
92
+ const articleStage = startTransformStage(url, 'extract:article');
93
+ article = resolveArticleExtraction(document, options.extractArticle);
94
+ endTransformStage(articleStage);
95
+ }
96
+ else {
97
+ article = null;
98
+ }
99
+ throwIfAborted(options.signal, url, 'extract:article');
71
100
  return {
72
- article: options.extractArticle ? extractArticle(document) : null,
101
+ article,
73
102
  metadata,
74
103
  };
75
104
  }
76
105
  catch (error) {
106
+ if (error instanceof FetchError) {
107
+ throw error;
108
+ }
109
+ throwIfAborted(options.signal, url, 'extract:error');
77
110
  logError('Failed to extract content', error instanceof Error ? error : undefined);
78
111
  return { article: null, metadata: {} };
79
112
  }
80
113
  }
81
114
  function isValidInput(html, url) {
82
- if (!html || typeof html !== 'string') {
83
- logWarn('extractContent called with invalid HTML input');
84
- return false;
85
- }
86
- if (!url || typeof url !== 'string') {
87
- logWarn('extractContent called with invalid URL');
88
- return false;
89
- }
90
- return true;
115
+ return (validateRequiredString(html, 'extractContent called with invalid HTML input') && validateRequiredString(url, 'extractContent called with invalid URL'));
116
+ }
117
+ function validateRequiredString(value, message) {
118
+ if (isNonEmptyString(value))
119
+ return true;
120
+ logWarn(message);
121
+ return false;
122
+ }
123
+ function isNonEmptyString(value) {
124
+ return typeof value === 'string' && value.length > 0;
125
+ }
126
+ function resolveArticleExtraction(document, shouldExtract) {
127
+ return shouldExtract ? extractArticle(document) : null;
91
128
  }
92
129
  function applyBaseUri(document, url) {
93
130
  try {
@@ -1,7 +1,7 @@
1
1
  import dns from 'node:dns';
2
2
  import os from 'node:os';
3
3
  import { Agent } from 'undici';
4
- import { createErrorWithCode } from '../../utils/error-utils.js';
4
+ import { createErrorWithCode } from '../../utils/error-details.js';
5
5
  import { isRecord } from '../../utils/guards.js';
6
6
  import { handleLookupResult } from './dns-selection.js';
7
7
  const DNS_LOOKUP_TIMEOUT_MS = 5000;
@@ -1,4 +1,4 @@
1
- import { createErrorWithCode } from '../../utils/error-utils.js';
1
+ import { createErrorWithCode } from '../../utils/error-details.js';
2
2
  import { isBlockedIp } from '../../utils/url-validator.js';
3
3
  function normalizeLookupResults(addresses, family) {
4
4
  if (Array.isArray(addresses)) {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import diagnosticsChannel from 'node:diagnostics_channel';
3
3
  import { performance } from 'node:perf_hooks';
4
- import { isSystemError } from '../../utils/error-utils.js';
4
+ import { isSystemError } from '../../utils/error-details.js';
5
5
  import { logDebug, logError, logWarn } from '../logger.js';
6
6
  const fetchChannel = diagnosticsChannel.channel('superfetch.fetch');
7
7
  function redactUrl(rawUrl) {
@@ -27,7 +27,14 @@ function publishFetchEvent(event) {
27
27
  // Avoid crashing the publisher if a subscriber throws.
28
28
  }
29
29
  }
30
- function publishAndLogFetchStart(context) {
30
+ export function startFetchTelemetry(url, method) {
31
+ const safeUrl = redactUrl(url);
32
+ const context = {
33
+ requestId: randomUUID(),
34
+ startTime: performance.now(),
35
+ url: safeUrl,
36
+ method: method.toUpperCase(),
37
+ };
31
38
  publishFetchEvent({
32
39
  v: 1,
33
40
  type: 'start',
@@ -40,65 +47,40 @@ function publishAndLogFetchStart(context) {
40
47
  method: context.method,
41
48
  url: context.url,
42
49
  });
43
- }
44
- export function startFetchTelemetry(url, method) {
45
- const safeUrl = redactUrl(url);
46
- const context = {
47
- requestId: randomUUID(),
48
- startTime: performance.now(),
49
- url: safeUrl,
50
- method: method.toUpperCase(),
51
- };
52
- publishAndLogFetchStart(context);
53
50
  return context;
54
51
  }
55
52
  export function recordFetchResponse(context, response, contentSize) {
56
53
  const duration = performance.now() - context.startTime;
57
- publishFetchEnd(context, response.status, duration);
58
- logDebug('HTTP Response', {
59
- requestId: context.requestId,
60
- status: response.status,
61
- url: context.url,
62
- ...buildResponseMeta(response, contentSize, duration),
63
- });
64
- logSlowRequestIfNeeded(context, duration);
65
- }
66
- function publishFetchEnd(context, status, duration) {
54
+ const durationLabel = `${Math.round(duration)}ms`;
67
55
  publishFetchEvent({
68
56
  v: 1,
69
57
  type: 'end',
70
58
  requestId: context.requestId,
71
- status,
59
+ status: response.status,
72
60
  duration,
73
61
  });
74
- }
75
- function buildResponseMeta(response, contentSize, duration) {
76
- const contentLength = response.headers.get('content-length') ?? contentSize?.toString();
77
- const meta = {
78
- duration: `${Math.round(duration)}ms`,
79
- };
80
62
  const contentType = response.headers.get('content-type');
81
- if (contentType !== null) {
82
- meta.contentType = contentType;
83
- }
84
- if (contentLength !== undefined) {
85
- meta.size = contentLength;
86
- }
87
- return meta;
88
- }
89
- function logSlowRequestIfNeeded(context, duration) {
90
- if (duration <= 5000)
91
- return;
92
- logWarn('Slow HTTP request detected', {
63
+ const contentLength = response.headers.get('content-length') ??
64
+ (contentSize === undefined ? undefined : String(contentSize));
65
+ logDebug('HTTP Response', {
93
66
  requestId: context.requestId,
67
+ status: response.status,
94
68
  url: context.url,
95
- duration: `${Math.round(duration)}ms`,
69
+ duration: durationLabel,
70
+ ...(contentType ? { contentType } : {}),
71
+ ...(contentLength ? { size: contentLength } : {}),
96
72
  });
73
+ if (duration > 5000) {
74
+ logWarn('Slow HTTP request detected', {
75
+ requestId: context.requestId,
76
+ url: context.url,
77
+ duration: durationLabel,
78
+ });
79
+ }
97
80
  }
98
- function normalizeError(error) {
99
- return error instanceof Error ? error : new Error(String(error));
100
- }
101
- function buildFetchErrorEvent(context, err, duration, status) {
81
+ export function recordFetchError(context, error, status) {
82
+ const duration = performance.now() - context.startTime;
83
+ const err = error instanceof Error ? error : new Error(String(error));
102
84
  const event = {
103
85
  v: 1,
104
86
  type: 'error',
@@ -107,10 +89,6 @@ function buildFetchErrorEvent(context, err, duration, status) {
107
89
  error: err.message,
108
90
  duration,
109
91
  };
110
- addOptionalErrorFields(event, err, status);
111
- return event;
112
- }
113
- function addOptionalErrorFields(event, err, status) {
114
92
  const code = isSystemError(err) ? err.code : undefined;
115
93
  if (code !== undefined) {
116
94
  event.code = code;
@@ -118,17 +96,8 @@ function addOptionalErrorFields(event, err, status) {
118
96
  if (status !== undefined) {
119
97
  event.status = status;
120
98
  }
121
- }
122
- function selectErrorLogger(status) {
123
- return status === 429 ? logWarn : logError;
124
- }
125
- export function recordFetchError(context, error, status) {
126
- const duration = performance.now() - context.startTime;
127
- const err = normalizeError(error);
128
- const event = buildFetchErrorEvent(context, err, duration, status);
129
99
  publishFetchEvent(event);
130
- const log = selectErrorLogger(status);
131
- const code = isSystemError(err) ? err.code : undefined;
100
+ const log = status === 429 ? logWarn : logError;
132
101
  log('HTTP Request Error', {
133
102
  requestId: context.requestId,
134
103
  url: context.url,
@@ -1,11 +1,19 @@
1
1
  import { FetchError } from '../../errors/app-error.js';
2
- import { createErrorWithCode } from '../../utils/error-utils.js';
2
+ import { createErrorWithCode } from '../../utils/error-details.js';
3
3
  import { isRecord } from '../../utils/guards.js';
4
4
  import { validateAndNormalizeUrl } from '../../utils/url-validator.js';
5
5
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
6
6
  function isRedirectStatus(status) {
7
7
  return REDIRECT_STATUSES.has(status);
8
8
  }
9
+ function cancelResponseBody(response) {
10
+ const cancelPromise = response.body?.cancel();
11
+ if (cancelPromise) {
12
+ cancelPromise.catch(() => {
13
+ // Best-effort cancellation; ignore failures.
14
+ });
15
+ }
16
+ }
9
17
  async function performFetchCycle(currentUrl, init, redirectLimit, redirectCount) {
10
18
  const response = await fetch(currentUrl, { ...init, redirect: 'manual' });
11
19
  if (!isRedirectStatus(response.status)) {
@@ -13,7 +21,7 @@ async function performFetchCycle(currentUrl, init, redirectLimit, redirectCount)
13
21
  }
14
22
  assertRedirectWithinLimit(response, currentUrl, redirectLimit, redirectCount);
15
23
  const location = getRedirectLocation(response, currentUrl);
16
- void response.body?.cancel();
24
+ cancelResponseBody(response);
17
25
  return {
18
26
  response,
19
27
  nextUrl: resolveRedirectTarget(currentUrl, location),
@@ -22,14 +30,14 @@ async function performFetchCycle(currentUrl, init, redirectLimit, redirectCount)
22
30
  function assertRedirectWithinLimit(response, currentUrl, redirectLimit, redirectCount) {
23
31
  if (redirectCount < redirectLimit)
24
32
  return;
25
- void response.body?.cancel();
33
+ cancelResponseBody(response);
26
34
  throw new FetchError('Too many redirects', currentUrl);
27
35
  }
28
36
  function getRedirectLocation(response, currentUrl) {
29
37
  const location = response.headers.get('location');
30
38
  if (location)
31
39
  return location;
32
- void response.body?.cancel();
40
+ cancelResponseBody(response);
33
41
  throw new FetchError('Redirect response missing Location header', currentUrl);
34
42
  }
35
43
  function annotateRedirectError(error, url) {
@@ -7,7 +7,7 @@ function assertContentLengthWithinLimit(response, url, maxBytes) {
7
7
  if (Number.isNaN(contentLength) || contentLength <= maxBytes) {
8
8
  return;
9
9
  }
10
- void response.body?.cancel();
10
+ cancelResponseBody(response);
11
11
  throw new FetchError(`Response exceeds maximum size of ${maxBytes} bytes`, url);
12
12
  }
13
13
  function createReadState() {
@@ -36,6 +36,14 @@ function createAbortError(url) {
36
36
  reason: 'aborted',
37
37
  });
38
38
  }
39
+ function cancelResponseBody(response) {
40
+ const cancelPromise = response.body?.cancel();
41
+ if (cancelPromise) {
42
+ cancelPromise.catch(() => {
43
+ // Best-effort cancellation; ignore failures.
44
+ });
45
+ }
46
+ }
39
47
  async function cancelReaderQuietly(reader) {
40
48
  try {
41
49
  await reader.cancel();
@@ -50,6 +58,14 @@ async function throwIfAborted(signal, url, reader) {
50
58
  await cancelReaderQuietly(reader);
51
59
  throw createAbortError(url);
52
60
  }
61
+ async function handleReadFailure(error, signal, url, reader) {
62
+ const aborted = signal?.aborted ?? false;
63
+ await cancelReaderQuietly(reader);
64
+ if (aborted) {
65
+ throw createAbortError(url);
66
+ }
67
+ throw error;
68
+ }
53
69
  async function readAllChunks(reader, state, url, maxBytes, signal) {
54
70
  await throwIfAborted(signal, url, reader);
55
71
  let result = await reader.read();
@@ -66,13 +82,7 @@ async function readStreamWithLimit(stream, url, maxBytes, signal) {
66
82
  await readAllChunks(reader, state, url, maxBytes, signal);
67
83
  }
68
84
  catch (error) {
69
- if (!signal?.aborted) {
70
- await cancelReaderQuietly(reader);
71
- }
72
- if (signal?.aborted) {
73
- throw createAbortError(url);
74
- }
75
- throw error;
85
+ await handleReadFailure(error, signal, url, reader);
76
86
  }
77
87
  finally {
78
88
  reader.releaseLock();
@@ -1,2 +1,25 @@
1
+ import type { Dispatcher } from 'undici';
1
2
  import type { FetchOptions } from '../config/types/runtime.js';
3
+ export declare const dispatcher: Dispatcher;
4
+ export declare function destroyAgents(): void;
5
+ interface FetchTelemetryContext {
6
+ requestId: string;
7
+ startTime: number;
8
+ url: string;
9
+ method: string;
10
+ contextRequestId?: string;
11
+ operationId?: string;
12
+ }
13
+ export declare function startFetchTelemetry(url: string, method: string): FetchTelemetryContext;
14
+ export declare function recordFetchResponse(context: FetchTelemetryContext, response: Response, contentSize?: number): void;
15
+ export declare function recordFetchError(context: FetchTelemetryContext, error: unknown, status?: number): void;
16
+ export declare function fetchWithRedirects(url: string, init: RequestInit, maxRedirects: number): Promise<{
17
+ response: Response;
18
+ url: string;
19
+ }>;
20
+ export declare function readResponseText(response: Response, url: string, maxBytes: number, signal?: AbortSignal): Promise<{
21
+ text: string;
22
+ size: number;
23
+ }>;
2
24
  export declare function fetchNormalizedUrl(normalizedUrl: string, options?: FetchOptions): Promise<string>;
25
+ export {};