website-to-markdown 1.1.0 → 2.0.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 (86) hide show
  1. package/README.md +135 -5
  2. package/dist/cli.d.ts +1 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +17 -7
  5. package/dist/cli.js.map +1 -1
  6. package/dist/content-type.d.ts.map +1 -1
  7. package/dist/content-type.js +9 -8
  8. package/dist/content-type.js.map +1 -1
  9. package/dist/crawl-snapshot.d.ts +2 -1
  10. package/dist/crawl-snapshot.d.ts.map +1 -1
  11. package/dist/crawl-snapshot.js +15 -8
  12. package/dist/crawl-snapshot.js.map +1 -1
  13. package/dist/fetch-page.d.ts +38 -22
  14. package/dist/fetch-page.d.ts.map +1 -1
  15. package/dist/fetch-page.js +209 -167
  16. package/dist/fetch-page.js.map +1 -1
  17. package/dist/hash.d.ts +3 -7
  18. package/dist/hash.d.ts.map +1 -1
  19. package/dist/hash.js +3 -7
  20. package/dist/hash.js.map +1 -1
  21. package/dist/html-metadata.d.ts.map +1 -1
  22. package/dist/html-metadata.js +16 -20
  23. package/dist/html-metadata.js.map +1 -1
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/limit.d.ts +1 -1
  29. package/dist/limit.js +1 -1
  30. package/dist/limit.js.map +1 -1
  31. package/dist/markdown-refs.d.ts +8 -0
  32. package/dist/markdown-refs.d.ts.map +1 -1
  33. package/dist/markdown-refs.js +104 -59
  34. package/dist/markdown-refs.js.map +1 -1
  35. package/dist/memory-limit.d.ts +1 -1
  36. package/dist/memory-limit.js +1 -1
  37. package/dist/memory-limit.js.map +1 -1
  38. package/dist/net-guard.d.ts +38 -2
  39. package/dist/net-guard.d.ts.map +1 -1
  40. package/dist/net-guard.js +88 -17
  41. package/dist/net-guard.js.map +1 -1
  42. package/dist/rate-limit-retry.d.ts.map +1 -1
  43. package/dist/rate-limit-retry.js +12 -21
  44. package/dist/rate-limit-retry.js.map +1 -1
  45. package/dist/read-body.d.ts +12 -0
  46. package/dist/read-body.d.ts.map +1 -0
  47. package/dist/read-body.js +40 -0
  48. package/dist/read-body.js.map +1 -0
  49. package/dist/retry-after.d.ts.map +1 -1
  50. package/dist/retry-after.js +8 -12
  51. package/dist/retry-after.js.map +1 -1
  52. package/dist/schema/index.d.ts +9 -0
  53. package/dist/schema/index.d.ts.map +1 -1
  54. package/dist/schema/index.js +28 -0
  55. package/dist/schema/index.js.map +1 -1
  56. package/dist/sitemap/index.d.ts +13 -4
  57. package/dist/sitemap/index.d.ts.map +1 -1
  58. package/dist/sitemap/index.js +58 -86
  59. package/dist/sitemap/index.js.map +1 -1
  60. package/dist/testing/index.d.ts +16 -0
  61. package/dist/testing/index.d.ts.map +1 -0
  62. package/dist/testing/index.js +54 -0
  63. package/dist/testing/index.js.map +1 -0
  64. package/dist/unchanged-page.d.ts +9 -0
  65. package/dist/unchanged-page.d.ts.map +1 -0
  66. package/dist/unchanged-page.js +31 -0
  67. package/dist/unchanged-page.js.map +1 -0
  68. package/package.json +12 -7
  69. package/src/cli.ts +25 -6
  70. package/src/content-type.ts +11 -9
  71. package/src/crawl-snapshot.ts +21 -8
  72. package/src/fetch-page.ts +304 -221
  73. package/src/hash.ts +3 -7
  74. package/src/html-metadata.ts +20 -18
  75. package/src/index.ts +7 -0
  76. package/src/limit.ts +1 -1
  77. package/src/markdown-refs.ts +129 -57
  78. package/src/memory-limit.ts +1 -1
  79. package/src/net-guard.ts +126 -16
  80. package/src/rate-limit-retry.ts +20 -20
  81. package/src/read-body.ts +38 -0
  82. package/src/retry-after.ts +11 -18
  83. package/src/schema/index.ts +34 -0
  84. package/src/sitemap/index.ts +83 -86
  85. package/src/testing/index.ts +60 -0
  86. package/src/unchanged-page.ts +37 -0
package/src/hash.ts CHANGED
@@ -1,13 +1,9 @@
1
1
  import { createHash } from 'node:crypto'
2
2
 
3
3
  /**
4
- * Hex SHA-256 of a UTF-8 string or of raw bytes - the repo's one content-hash
5
- * function.
6
- *
7
- * Used as a change detector (has this text moved since we last derived from
8
- * it?), as an API-secret digest, and to fingerprint fetched binaries. The value
9
- * is a stored identity in several tables, so it must stay byte-for-byte what it
10
- * has always been.
4
+ * Hex SHA-256 of a UTF-8 string or of raw bytes. `htmlHash` and snapshot file
5
+ * names are built on it, and callers store them, so it must stay byte-for-byte
6
+ * what it has always been.
11
7
  */
12
8
  export function sha256Hex(data: string | Uint8Array): string {
13
9
  return typeof data === 'string'
@@ -1,3 +1,9 @@
1
+ import {
2
+ getUniqueValues,
3
+ isOneOf,
4
+ normalizeWhitespace,
5
+ safeJsonParse
6
+ } from 'hanzio'
1
7
  import { decodeHtmlEntities } from './html-entities.js'
2
8
 
3
9
  const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i
@@ -29,17 +35,15 @@ function attributesOf(tag: string) {
29
35
  }
30
36
 
31
37
  function cleanText(value: string) {
32
- return decodeHtmlEntities(value.replace(HTML_TAG_RE, ' '))
33
- .replace(/\s+/g, ' ')
34
- .trim()
38
+ return normalizeWhitespace(
39
+ decodeHtmlEntities(value.replace(HTML_TAG_RE, ' '))
40
+ )
35
41
  }
36
42
 
37
43
  function absolute(url: string, baseUrl: string) {
38
44
  try {
39
45
  const resolved = new URL(url, baseUrl)
40
- if (resolved.protocol !== 'https:' && resolved.protocol !== 'http:') {
41
- return null
42
- }
46
+ if (!isOneOf(['https:', 'http:'], resolved.protocol)) return null
43
47
  return resolved.href
44
48
  } catch {
45
49
  return null
@@ -51,15 +55,13 @@ function parseJsonLd(html: string) {
51
55
  for (const match of html.matchAll(JSON_LD_RE)) {
52
56
  const body = match[1]?.trim()
53
57
  if (!body) continue
54
- try {
55
- const parsed: unknown = JSON.parse(body)
56
- // A single script may hold an array of nodes; flatten so the profile
57
- // layer can look for `@type` without walking two shapes.
58
- if (Array.isArray(parsed)) blocks.push(...parsed)
59
- else blocks.push(parsed)
60
- } catch {
61
- // Hand-written JSON-LD with a trailing comma is common; skip the block.
62
- }
58
+ const parsed = safeJsonParse(body)
59
+ // Hand-written JSON-LD with a trailing comma is common; skip the block.
60
+ if (!parsed.ok) continue
61
+ // A single script may hold an array of nodes; flatten so the profile
62
+ // layer can look for `@type` without walking two shapes.
63
+ if (Array.isArray(parsed.data)) blocks.push(...parsed.data)
64
+ else blocks.push(parsed.data)
63
65
  }
64
66
  return blocks
65
67
  }
@@ -82,7 +84,7 @@ export function extractHtmlMetadata(html: string, baseUrl: string) {
82
84
  // `<link rel="alternate" hreflang>`: the site's own declaration of which
83
85
  // URLs are translations of this page. `x-default` is a language picker,
84
86
  // not a translation.
85
- const alternates = new Set<string>()
87
+ const alternates: string[] = []
86
88
  /** Each alternate's language (`da` of `da-DK`); what lets a crawl skip a mirror. */
87
89
  const alternateLanguages: Record<string, string> = {}
88
90
  for (const match of html.matchAll(LINK_TAG_RE)) {
@@ -97,7 +99,7 @@ export function extractHtmlMetadata(html: string, baseUrl: string) {
97
99
  if (rel.includes('alternate') && hreflang && hreflang !== 'x-default') {
98
100
  const href = absolute(attributes.href, baseUrl)
99
101
  const language = hreflang.split('-')[0] ?? ''
100
- if (href) alternates.add(href)
102
+ if (href) alternates.push(href)
101
103
  if (href && /^[a-z]{2}$/.test(language))
102
104
  alternateLanguages[href] = language
103
105
  }
@@ -118,7 +120,7 @@ export function extractHtmlMetadata(html: string, baseUrl: string) {
118
120
  title,
119
121
  lang,
120
122
  canonical,
121
- alternates: [...alternates],
123
+ alternates: getUniqueValues(alternates),
122
124
  alternateLanguages,
123
125
  metaDescription: metaDescription || ogDescription || null,
124
126
  jsonLd: parseJsonLd(html)
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  withCrawlSnapshot
18
18
  } from './crawl-snapshot.js'
19
19
  export {
20
+ type CaptureIssue,
20
21
  createWebsiteToMarkdown,
21
22
  type FetchedPage,
22
23
  type FetchOutcome,
@@ -38,6 +39,7 @@ export {
38
39
  } from './limit.js'
39
40
  export type { LogAttributes, Logger } from './logger.js'
40
41
  export {
42
+ cleanConvertedMarkdown,
41
43
  htmlImages,
42
44
  htmlLinks,
43
45
  markdownRefs,
@@ -47,6 +49,11 @@ export {
47
49
  } from './markdown-refs.js'
48
50
  export { createMemoryConcurrencyLimit } from './memory-limit.js'
49
51
  export {
52
+ type Fetch,
53
+ type GuardedFetchOptions,
54
+ guardedFetch,
55
+ type HostGuardOptions,
56
+ HostNotAllowedError,
50
57
  isBlockedAddress,
51
58
  isHostAllowed,
52
59
  isLocalhostHostname
package/src/limit.ts CHANGED
@@ -90,7 +90,7 @@ export const URL_TO_MARKDOWN_HOST_MAX_CONCURRENCY = 16
90
90
 
91
91
  /**
92
92
  * A host that answered 429 is read this narrowly from then on. At 16 in
93
- * flight Shopify refused 117 of Good Habit's 244 reads even after each had
93
+ * flight Shopify refused 117 of one store's 244 reads even after each had
94
94
  * waited out its pause: the pause only delayed the next burst. Calibration
95
95
  * knobs - set them from the crawl log's `rate_limited`, not by theory.
96
96
  */
@@ -1,3 +1,4 @@
1
+ import { compact, getUniqueValues, isOneOf } from 'hanzio'
1
2
  import { decodeHtmlEntities } from './html-entities.js'
2
3
 
3
4
  /**
@@ -31,10 +32,9 @@ export function stripFrontMatter(markdown: string) {
31
32
  * Workers AI `toMarkdown` opens every converted PDF and office file with the
32
33
  * file name as an H1, a `## Metadata` list (creation dates, uuids,
33
34
  * `PDFFormatVersion=1.7`) and a `## Contents` heading, then labels every page
34
- * `### Page N`. None of it is what the document says, and the fact guard
35
- * demanded those dates and numbers of every written document - which is how
36
- * Hotel Hvide Klit's terms came back as 13,000 characters of raw text with
37
- * the PDF metadata inside.
35
+ * `### Page N`. None of it is what the document says: a hotel's terms PDF
36
+ * came back as 13,000 characters with its creation dates and uuids inside,
37
+ * and a reader of the markdown takes those for facts of the document.
38
38
  */
39
39
  const DOCUMENT_TITLE_RE =
40
40
  /^#\s+[^\n]*\.(?:pdf|docx?|xlsx?|pptx?|odt|rtf)\s*\r?\n/i
@@ -60,6 +60,94 @@ export function stripDocumentPreamble(markdown: string): string {
60
60
  .trim()
61
61
  }
62
62
 
63
+ /**
64
+ * Cloudflare's e-mail obfuscation rewrites every address it finds into
65
+ * `[\[email protected\]](/cdn-cgi/l/email-protection#<hex>)`, so a converted
66
+ * page behind it shows no readable address at all. The hex is the address
67
+ * XOR-ed with its first byte; decoded only when the result is shaped like an
68
+ * address.
69
+ */
70
+ const CF_EMAIL_LINK_RE =
71
+ /\[(?:[^[\]\\]|\\.|\[[^\]]*\])*\]\((?:https?:\/\/[^/)]+)?\/cdn-cgi\/l\/email-protection#([0-9a-fA-F]{8,})\)/g
72
+
73
+ function decodeCloudflareEmail(hex: string) {
74
+ const key = Number.parseInt(hex.slice(0, 2), 16)
75
+ let address = ''
76
+ for (let index = 2; index + 1 < hex.length; index += 2) {
77
+ address += String.fromCharCode(
78
+ Number.parseInt(hex.slice(index, index + 2), 16) ^ key
79
+ )
80
+ }
81
+ return /^[^\s@]+@[^\s@]+\.[^\s@.]+$/.test(address) ? address : null
82
+ }
83
+
84
+ /**
85
+ * `<script type="application/ld+json">` survives conversion as a fenced code
86
+ * block on Yoast and Squarespace sites. It is never prose, and the page already
87
+ * carries it parsed as `jsonLd`.
88
+ */
89
+ const FENCED_BLOCK_RE = /^```[^\n]*\n([\s\S]*?)^```[ \t]*$/gm
90
+ const JSON_LD_RE = /schema\.org|["']@context["']\s*:/
91
+
92
+ /** A page that inlines an SVG file gets its prolog back from the converter as text. */
93
+ const MARKUP_DECLARATION_RE = /<\?xml[^>]*\?>|<!DOCTYPE[^>]*>/gi
94
+
95
+ /**
96
+ * A label rendered next to its own value with no space between -
97
+ * `[Saleshello@example.com](mailto:hello@example.com)`,
98
+ * `[Support+45 22 81 82 95](tel:+4522818295)` - reads as an invented address.
99
+ * Matched on the value's shape, so the label's language never matters.
100
+ */
101
+ const VALUE_LINK_RE = /\[([^\]]+)\]\((mailto|tel):([^)\s]+)\)/gi
102
+
103
+ function emailStart(label: string, target: string) {
104
+ const address = (target.split('?')[0] ?? '').toLowerCase()
105
+ return address && label.toLowerCase().endsWith(address)
106
+ ? label.length - address.length
107
+ : -1
108
+ }
109
+
110
+ function phoneStart(label: string, target: string) {
111
+ const digits = target.replace(/\D/g, '')
112
+ if (digits.length < 6) return -1
113
+ const first = label.search(/[+\d]/)
114
+ if (first < 0) return -1
115
+ const rest = label.slice(first)
116
+ return /^[+\d\s().-]+$/.test(rest) && rest.replace(/\D/g, '') === digits
117
+ ? first
118
+ : -1
119
+ }
120
+
121
+ /**
122
+ * Removes what Cloudflare's conversion leaves behind and is never page text:
123
+ * obfuscated e-mail links are decoded, JSON-LD code fences and markup
124
+ * declarations dropped, and labels glued to their `mailto:`/`tel:` value
125
+ * split. Applied to every fetched page; idempotent, so markdown stored by an
126
+ * older version can be run through it too.
127
+ */
128
+ export function cleanConvertedMarkdown(markdown: string) {
129
+ return markdown
130
+ .replace(CF_EMAIL_LINK_RE, (link, hex: string) => {
131
+ const address = decodeCloudflareEmail(hex)
132
+ return address ? `[${address}](mailto:${address})` : link
133
+ })
134
+ .replace(FENCED_BLOCK_RE, (block, body: string) =>
135
+ JSON_LD_RE.test(body) ? '' : block
136
+ )
137
+ .replace(MARKUP_DECLARATION_RE, '')
138
+ .replace(
139
+ VALUE_LINK_RE,
140
+ (link, label: string, scheme: string, target: string) => {
141
+ const start =
142
+ scheme.toLowerCase() === 'mailto'
143
+ ? emailStart(label, target)
144
+ : phoneStart(label, target)
145
+ if (start <= 0 || !/\p{L}/u.test(label[start - 1] ?? '')) return link
146
+ return `[${label.slice(0, start)} ${label.slice(start)}](${scheme}:${target})`
147
+ }
148
+ )
149
+ }
150
+
63
151
  export function titleFromMarkdown(markdown: string) {
64
152
  const heading = markdown.match(/^#\s+(.+)$/m)
65
153
  return heading?.[1]?.trim() || null
@@ -92,13 +180,11 @@ function resolveTarget(target: string, baseUrl: string) {
92
180
  const resolved = new URL(raw, baseUrl)
93
181
  // Drops `mailto:`, `tel:`, `javascript:`, `data:` and every other scheme
94
182
  // discovery cannot crawl; they stay in the markdown itself.
95
- if (resolved.protocol !== 'https:' && resolved.protocol !== 'http:') {
96
- return null
97
- }
183
+ if (!isOneOf(['https:', 'http:'], resolved.protocol)) return null
98
184
  // Cloudflare's reserved prefix is plumbing, never a page: the e-mail
99
185
  // obfuscation endpoint under it answers 410, which used to count as a
100
- // page this site failed to deliver. `clean-pages.ts` decodes the address
101
- // out of the link before it is dropped here.
186
+ // page this site failed to deliver. `cleanConvertedMarkdown` has already
187
+ // decoded the address out of the link.
102
188
  if (resolved.pathname.startsWith('/cdn-cgi/')) return null
103
189
  return resolved.href
104
190
  } catch {
@@ -106,37 +192,32 @@ function resolveTarget(target: string, baseUrl: string) {
106
192
  }
107
193
  }
108
194
 
195
+ /** Resolved targets, deduped, in the order they were found. */
196
+ function resolveUnique(targets: (string | undefined)[], baseUrl: string) {
197
+ return getUniqueValues(
198
+ compact(
199
+ targets.map((target) => (target ? resolveTarget(target, baseUrl) : null))
200
+ )
201
+ )
202
+ }
203
+
109
204
  /**
110
205
  * Absolute, deduped, document-order links and images. Discovery crawls these,
111
206
  * so relative targets (`/kontakt`) and CMS-escaped query strings
112
207
  * (`?image=x&amp;width=200`) have to come out resolved and decoded.
113
208
  */
114
209
  export function markdownRefs(markdown: string, baseUrl: string) {
115
- const links: string[] = []
116
- const images: string[] = []
117
- const seenLinks = new Set<string>()
118
- const seenImages = new Set<string>()
119
-
120
- const push = (isImage: boolean, target: string) => {
121
- const resolved = resolveTarget(target, baseUrl)
122
- if (!resolved) return
123
- const seen = isImage ? seenImages : seenLinks
124
- if (seen.has(resolved)) return
125
- seen.add(resolved)
126
- ;(isImage ? images : links).push(resolved)
127
- }
128
-
129
- for (const match of markdown.matchAll(IMAGE_RE)) {
130
- if (match[1]) push(true, match[1])
131
- }
132
- for (const match of markdown.matchAll(LINK_RE)) {
133
- if (match[1] !== '!' && match[2]) push(false, match[2])
134
- }
135
- for (const match of markdown.matchAll(AUTOLINK_RE)) {
136
- if (match[1]) push(false, match[1])
210
+ const images = [...markdown.matchAll(IMAGE_RE)].map((match) => match[1])
211
+ const links = [
212
+ ...[...markdown.matchAll(LINK_RE)].map((match) =>
213
+ match[1] === '!' ? undefined : match[2]
214
+ ),
215
+ ...[...markdown.matchAll(AUTOLINK_RE)].map((match) => match[1])
216
+ ]
217
+ return {
218
+ links: resolveUnique(links, baseUrl),
219
+ images: resolveUnique(images, baseUrl)
137
220
  }
138
-
139
- return { links, images }
140
221
  }
141
222
 
142
223
  const ANCHOR_HREF_RE = /<a\b[^>]*?\bhref\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi
@@ -160,38 +241,29 @@ const NON_MARKUP_RE = /<(script|style|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi
160
241
  * reachable if ANY representation mentions it.
161
242
  */
162
243
  export function htmlLinks(html: string, baseUrl: string): string[] {
163
- const links: string[] = []
164
- const seen = new Set<string>()
165
- for (const match of html
166
- .replace(NON_MARKUP_RE, ' ')
167
- .matchAll(ANCHOR_HREF_RE)) {
244
+ const targets = [
245
+ ...html.replace(NON_MARKUP_RE, ' ').matchAll(ANCHOR_HREF_RE)
246
+ ].map((match) => {
168
247
  const raw = match[1]
169
- if (!raw) continue
248
+ if (!raw) return undefined
170
249
  const first = raw[0]
171
250
  const target =
172
251
  (first === '"' || first === "'") && raw.endsWith(first)
173
252
  ? raw.slice(1, -1)
174
253
  : raw
175
- const resolved = resolveTarget(decodeHtmlEntities(target), baseUrl)
176
- if (!resolved || seen.has(resolved)) continue
177
- seen.add(resolved)
178
- links.push(resolved)
179
- }
180
- return links
254
+ return decodeHtmlEntities(target)
255
+ })
256
+ return resolveUnique(targets, baseUrl)
181
257
  }
182
258
 
183
259
  /** Conversion may omit an image-only menu; retain original and lazy asset URLs. */
184
260
  export function htmlImages(html: string, baseUrl: string): string[] {
185
- const images = new Set<string>()
186
- for (const tag of html.matchAll(/<img\b[^>]*>/gi)) {
187
- for (const attribute of tag[0].matchAll(
188
- /\b(?:src|data-src|data-original)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi
189
- )) {
190
- const raw = attribute[1]
191
- if (!raw) continue
192
- const url = resolveTarget(raw.replace(/^["']|["']$/g, ''), baseUrl)
193
- if (url) images.add(url)
194
- }
195
- }
196
- return [...images]
261
+ const targets = [...html.matchAll(/<img\b[^>]*>/gi)].flatMap((tag) =>
262
+ [
263
+ ...tag[0].matchAll(
264
+ /\b(?:src|data-src|data-original)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi
265
+ )
266
+ ].map((attribute) => attribute[1]?.replace(/^["']|["']$/g, ''))
267
+ )
268
+ return resolveUnique(targets, baseUrl)
197
269
  }
@@ -7,7 +7,7 @@ const START_WINDOW_MS = 1_000
7
7
  * starts in any second, and a `tripCooldown` pause before the next start.
8
8
  *
9
9
  * ponytail: per-process only. Several processes sharing one Cloudflare quota
10
- * need a shared limiter (hellu passes its Redis Lua limiter).
10
+ * need a shared limiter, e.g. `hanzio/redis`'s `createRedisConcurrencyLimit`.
11
11
  */
12
12
  export function createMemoryConcurrencyLimit(
13
13
  options: ConcurrencyLimitOptions
package/src/net-guard.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { lookup } from 'node:dns/promises'
2
+ import { createCache, isOneOf } from 'hanzio'
2
3
  import { errorLogAttributes, type Logger, silentLogger } from './logger.js'
3
4
 
4
5
  /** One crawl hits the same host hundreds of times; DNS is not cached by Node. */
@@ -116,32 +117,50 @@ export function isBlockedAddress(address: string): boolean {
116
117
 
117
118
  export function isLocalhostHostname(hostname: string) {
118
119
  const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
119
- return host === 'localhost' || host === '127.0.0.1' || host === '::1'
120
+ return isOneOf(['localhost', '127.0.0.1', '::1'], host)
120
121
  }
121
122
 
122
- const resolutionCache = new Map<string, { allowed: boolean; atMs: number }>()
123
+ const resolutionCache = createCache({
124
+ defaultTtlMs: RESOLUTION_TTL_MS,
125
+ maxEntries: RESOLUTION_CACHE_MAX
126
+ })
127
+
128
+ export type HostGuardOptions = {
129
+ logger?: Logger | undefined
130
+ /** Allow `localhost`, `127.0.0.1` and `::1`, e.g. for a dev server. Default false. */
131
+ allowLocalhost?: boolean | undefined
132
+ }
123
133
 
124
134
  /**
125
135
  * SSRF guard: a customer can put any hostname in the website field, and an
126
136
  * internal address behind it would turn the importer into a proxy for our own
127
- * network. Localhost stays reachable outside production for the dev server.
137
+ * network. An IP literal (`[fd00::1]` included) is judged as written, without
138
+ * a DNS lookup.
128
139
  */
129
140
  export async function isHostAllowed(
130
141
  hostname: string,
131
- logger: Logger = silentLogger
142
+ options: HostGuardOptions = {}
132
143
  ) {
133
- if (isLocalhostHostname(hostname)) {
134
- return process.env.NODE_ENV !== 'production'
135
- }
144
+ const logger = options.logger ?? silentLogger
145
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
146
+ if (isLocalhostHostname(host)) return options.allowLocalhost === true
136
147
 
137
- const cached = resolutionCache.get(hostname)
138
- if (cached && Date.now() - cached.atMs < RESOLUTION_TTL_MS) {
139
- return cached.allowed
148
+ // `dns.lookup` fails on a bracketed literal, and a failed lookup is let
149
+ // through below, so a literal never reaches it.
150
+ if (parseIpv4(host) || host.includes(':')) {
151
+ const allowed = !isBlockedAddress(host)
152
+ if (!allowed) {
153
+ logger.warn('net-guard.blocked_address', { hostname, addresses: host })
154
+ }
155
+ return allowed
140
156
  }
141
157
 
158
+ const cached = resolutionCache.get<boolean>(host)
159
+ if (cached !== undefined) return cached
160
+
142
161
  let allowed = false
143
162
  try {
144
- const addresses = await lookup(hostname, { all: true })
163
+ const addresses = await lookup(host, { all: true })
145
164
  allowed =
146
165
  addresses.length > 0 &&
147
166
  addresses.every((entry) => !isBlockedAddress(entry.address))
@@ -161,10 +180,101 @@ export async function isHostAllowed(
161
180
  return true
162
181
  }
163
182
 
164
- if (resolutionCache.size >= RESOLUTION_CACHE_MAX) {
165
- const oldest = resolutionCache.keys().next().value
166
- if (oldest) resolutionCache.delete(oldest)
167
- }
168
- resolutionCache.set(hostname, { allowed, atMs: Date.now() })
183
+ resolutionCache.set(host, allowed)
169
184
  return allowed
170
185
  }
186
+
187
+ /** The `fetch` shape the guard wraps and the library accepts as an option. */
188
+ export type Fetch = (
189
+ input: string | URL,
190
+ init?: RequestInit
191
+ ) => Promise<Response>
192
+
193
+ /** A request, or one of its redirects, went to a host the guard refuses. */
194
+ export class HostNotAllowedError extends Error {
195
+ override readonly name = 'HostNotAllowedError'
196
+
197
+ constructor(readonly url: string) {
198
+ super(`Host not allowed: ${url}`)
199
+ }
200
+ }
201
+
202
+ export type GuardedFetchOptions = {
203
+ /** Run on the URL and on every redirect target. Default {@link isHostAllowed}. */
204
+ isHostAllowed?: ((hostname: string) => Promise<boolean>) | undefined
205
+ /** Default `globalThis.fetch`, read at call time. */
206
+ fetch?: Fetch | undefined
207
+ /** Default 10. */
208
+ maxRedirects?: number | undefined
209
+ }
210
+
211
+ const REDIRECT_STATUSES = [301, 302, 303, 307, 308] as const
212
+ const DEFAULT_MAX_REDIRECTS = 10
213
+
214
+ /**
215
+ * `fetch` that runs the guard on every hop: `redirect: 'follow'` would take a
216
+ * public host's 302 to `http://169.254.169.254/` without asking. Returns the
217
+ * last response and the URL it came from.
218
+ */
219
+ export async function followRedirects(
220
+ input: string | URL,
221
+ init: RequestInit = {},
222
+ options: GuardedFetchOptions = {}
223
+ ): Promise<{ response: Response; url: URL }> {
224
+ const allow =
225
+ options.isHostAllowed ?? ((hostname: string) => isHostAllowed(hostname))
226
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
227
+ const follow = (init.redirect ?? 'follow') === 'follow'
228
+ let url = new URL(input)
229
+ let request: RequestInit = follow ? { ...init, redirect: 'manual' } : init
230
+
231
+ for (let hop = 0; ; hop++) {
232
+ if (
233
+ !isOneOf(['http:', 'https:'], url.protocol) ||
234
+ !(await allow(url.hostname))
235
+ ) {
236
+ throw new HostNotAllowedError(url.href)
237
+ }
238
+ const response = await (options.fetch ?? globalThis.fetch)(url, request)
239
+ const location = response.headers.get('location')
240
+ if (!follow || !location || !isOneOf(REDIRECT_STATUSES, response.status)) {
241
+ return { response, url }
242
+ }
243
+ void response.body?.cancel().catch(() => {})
244
+ if (hop >= maxRedirects) {
245
+ throw new TypeError(`Too many redirects from ${String(input)}`)
246
+ }
247
+
248
+ const next = new URL(location, url)
249
+ // What `fetch` itself does on a redirect: credentials stay with their
250
+ // origin, and a 303 (or a POST answered with 301/302) becomes a GET.
251
+ if (next.origin !== url.origin) {
252
+ const headers = new Headers(request.headers)
253
+ headers.delete('authorization')
254
+ headers.delete('cookie')
255
+ request = { ...request, headers }
256
+ }
257
+ const method = (request.method ?? 'GET').toUpperCase()
258
+ if (
259
+ (response.status === 303 && !isOneOf(['GET', 'HEAD'], method)) ||
260
+ (isOneOf([301, 302], response.status) && method === 'POST')
261
+ ) {
262
+ const headers = new Headers(request.headers)
263
+ headers.delete('content-type')
264
+ request = { ...request, method: 'GET', body: null, headers }
265
+ }
266
+ url = next
267
+ }
268
+ }
269
+
270
+ /**
271
+ * {@link followRedirects} as a drop-in `fetch`. Throws
272
+ * {@link HostNotAllowedError} when the URL or a redirect is refused.
273
+ */
274
+ export async function guardedFetch(
275
+ input: string | URL,
276
+ init?: RequestInit,
277
+ options?: GuardedFetchOptions
278
+ ): Promise<Response> {
279
+ return (await followRedirects(input, init, options)).response
280
+ }
@@ -1,3 +1,12 @@
1
+ import {
2
+ exponentialBackoff,
3
+ getAtPath,
4
+ isNumber,
5
+ isRecord,
6
+ isString,
7
+ safeJsonParse
8
+ } from 'hanzio'
9
+
1
10
  /**
2
11
  * Bounded retries for Cloudflare 429s (Workers AI code 971 / Browser Rendering).
3
12
  *
@@ -27,28 +36,15 @@ const THROTTLE_971_BACKOFF_MS = 5_000
27
36
  const MAX_BACKOFF_MS = 20_000
28
37
 
29
38
  export function cloudflareErrorCode(error: unknown): number | null {
30
- if (typeof error !== 'object' || !error) return null
31
- const body =
32
- 'body' in error && typeof error.body === 'string' ? error.body : null
39
+ if (!isRecord(error)) return null
33
40
  const bodyJson =
34
- 'bodyJson' in error && error.bodyJson && typeof error.bodyJson === 'object'
41
+ error.bodyJson && typeof error.bodyJson === 'object'
35
42
  ? error.bodyJson
36
- : body
37
- ? safeJson(body)
43
+ : isString(error.body) && error.body
44
+ ? safeJsonParse(error.body).data
38
45
  : null
39
- if (!bodyJson || typeof bodyJson !== 'object') return null
40
- const errors = (bodyJson as { errors?: unknown }).errors
41
- if (!Array.isArray(errors) || errors.length === 0) return null
42
- const code = (errors[0] as { code?: unknown }).code
43
- return typeof code === 'number' ? code : null
44
- }
45
-
46
- function safeJson(raw: string) {
47
- try {
48
- return JSON.parse(raw) as unknown
49
- } catch {
50
- return null
51
- }
46
+ const code = getAtPath(bodyJson, ['errors', 0, 'code'])
47
+ return isNumber(code) ? code : null
52
48
  }
53
49
 
54
50
  /**
@@ -66,7 +62,11 @@ export function rateLimitBackoffMs(
66
62
  cloudflareErrorCode(error) === 971
67
63
  ? THROTTLE_971_BACKOFF_MS
68
64
  : DEFAULT_BACKOFF_MS
69
- const exponential = floor * 2 ** Math.max(0, attempt - 1)
65
+ const exponential = exponentialBackoff(attempt, {
66
+ baseMs: floor,
67
+ maxMs: MAX_BACKOFF_MS,
68
+ jitter: false
69
+ })
70
70
  const base = Math.min(MAX_BACKOFF_MS, retryAfterMs ?? exponential)
71
71
  const jitter = Math.floor(random() * Math.max(250, base * 0.2))
72
72
  return base + jitter
@@ -0,0 +1,38 @@
1
+ /**
2
+ * At most `maxBytes` of a response body, streamed, so a chunked body is bounded
3
+ * as well as a declared `Content-Length`. `truncated` says the body went on;
4
+ * the rest is cancelled, never downloaded.
5
+ */
6
+ export async function readBody(response: Response, maxBytes: number) {
7
+ const reader = response.body?.getReader()
8
+ if (!reader) return { bytes: new Uint8Array(0), truncated: false }
9
+ const chunks: Uint8Array[] = []
10
+ let size = 0
11
+ let truncated = false
12
+ while (true) {
13
+ const next = await reader.read()
14
+ if (next.done) break
15
+ const room = maxBytes - size
16
+ if (next.value.byteLength > room) {
17
+ chunks.push(next.value.subarray(0, room))
18
+ size = maxBytes
19
+ truncated = true
20
+ await reader.cancel()
21
+ break
22
+ }
23
+ chunks.push(next.value)
24
+ size += next.value.byteLength
25
+ }
26
+ const bytes = new Uint8Array(size)
27
+ let offset = 0
28
+ for (const chunk of chunks) {
29
+ bytes.set(chunk, offset)
30
+ offset += chunk.byteLength
31
+ }
32
+ return { bytes, truncated }
33
+ }
34
+
35
+ /** UTF-8, as `Response.text()` decodes whatever the charset header says. */
36
+ export function decodeText(bytes: Uint8Array) {
37
+ return new TextDecoder().decode(bytes)
38
+ }