feat(cli): support ignoring external hosts - #544
Conversation
|
To preview the documentation for this pull request, visit the following URL:
|
|
🚅 Deployed to the docs.page-pr-544 environment in docs.page
|
|
|
81f6c47 to
dec2272
Compare
I disagree with this. If we're adding a new key and we have a schema, then the schema should be updated to have the key otherwise there's skew. And deferring it until a follow-up just adds future mental labor for something that should be done anyway.
I'm okay with this.
Fine
Sounds like a great peel-off to a new issue or PR... if there is a typecheck, and it's failing on main, then there is A) a CI problem and B) a types problem. Address both
I'm also okay with this - CI environments likely won't have this happen, dev environments will see the warning and can reason about it |
External link User-Agent analysis (RNFB integration testing)Context: This analysis was run while integrating CLI under test: Environment: macOS, Node Methodology1. Understand the checker’s decision logicFrom
This means the outcome for a URL is fully determined by: User-Agent string, HEAD→GET sequence, redirect handling, and status classification — not by curl alone unless curl mirrors that sequence. 2. User-agents compared
Also probed with Firefox, Safari, and default 3. Primary experiment — replicate checker logic per URLA Node script reproduced Additionally, for each URL:
4. Full-corpus runRan the built CLI against the full RNFB docs tree: node packages/cli/dist/cli.js check .
# cwd: invertase/react-native-firebase worktree (docs-page-link-check-ci branch)Captured all 5. Supplementary probes
Results — primary URL table (checker logic + curl)
Observations from this table (facts):
Results — full RNFB
|
| File | Message | URL |
|---|---|---|
docs/auth/social-auth.mdx:111 |
400 Bad Request |
https://developers.facebook.com/docs/android/getting-started/ |
docs/auth/social-auth.mdx:213 |
fetch failed |
https://developer.android.com/identity/sign-in/credential-manager-siwg-implementation |
docs/ai/usage/index.mdx:469 |
fetch failed |
https://ai.google.dev/gemini-api/terms#grounding-with-google-maps |
docs/crashlytics/crash-reports.mdx:55 |
fetch failed |
https://developer.android.com/topic/performance/vitals/anr |
docs/enabling-multidex.mdx:14 |
fetch failed |
https://developer.android.com/studio/build/multidex#mdex-gradle |
docs/index.mdx:406 |
fetch failed |
https://developer.android.com/reference/java/util/concurrent/ThreadPoolExecutor |
Warning pattern: 14–17 warnings, overwhelmingly:
403 Forbiddenonnpmjs.comandstackoverflow.com(classifiedunverified— working as designed)- Intermittent
429 Too Many Requestsongithub.comraw/tree URLs (also classifiedunverified)
No TypeDoc 404 errors appeared in either run.
Projected outcome with bot UA + same status rules: 0 errors from the stable Facebook/Google set above; warnings unchanged.
Results — redirect trace (why developer.android.com fails with pr544 UA)
curl -I (no redirect follow) on https://developer.android.com/topic/performance/vitals/anr:
With pr544 UA — first response:
HTTP/2 302
location: https://developer.android.com/oauth2authorize?return_url=...&prompt=none&auto_signin=True&scopes=...
set-cookie: signin=autosignin; ...
Node fetch with redirect: "follow" enters this OAuth auto-sign-in chain and eventually aborts → fetch failed → error.
With bot UA — first response:
HTTP/2 200
content-type: text/html; charset=utf-8
content-length: 320399
Page content served directly; checker passes.
Results — supplementary UA matrix (Node fetch GET | HEAD)
| URL | pr544 | bot | googlebot | firefox | safari |
|---|---|---|---|---|---|
developers.facebook.com/.../getting-started/ |
GET 400 | HEAD 400 | GET 200 | HEAD 200 | GET 200 | HEAD 200 | GET 400 | HEAD 400 | GET 400 | HEAD 400 |
developer.android.com/.../anr |
GET err | HEAD err | GET 200 | HEAD 200 | GET 500 | HEAD 200 | GET 200 | HEAD 200 | GET 200 | HEAD 200 |
ai.google.dev/gemini-api/terms#... |
GET err | HEAD err | GET 200 | HEAD 200 | GET 200 | HEAD 200 | GET 200 | HEAD 200 | GET 200 | HEAD 200 |
npmjs.com/package/react-native-nitro-google-signin |
403 | 403 | 403 | 403 | 403 | 403 | 403 | 403 | 403 | 403 |
stackoverflow.com/questions/5025256/... |
403 | 403 | 403 | 403 | 403 | 403 | 403 | 403 | 403 | 403 |
Observation: Browser-like UAs (Chrome, Firefox, Safari) do not improve npm/SO outcomes vs bot UA. Google/Facebook doc hosts specifically treat Chrome UA differently from bot UA on this corpus.
Results — 80-URL docs sample (outcome diff pr544 vs bot)
Extracted first 80 unique https://… URLs from docs/**/*.mdx. Applied checker logic with pr544 vs bot UA:
- Same outcome: 80 / 80
- Chrome UA strictly better: 0
- Bot UA strictly better: 0
The sample did not include the failing Facebook/Google URLs (they appear later in alphabetical / extraction order). Failures are concentrated on a small set of Google/Facebook doc URLs (6 distinct URLs, 6 error lines in full run), not spread across the corpus.
Evidence summary (no prescription)
| Claim | Evidence |
|---|---|
PR 544’s 401/403/405/429 → warn correctly handles npm/SO |
Both UAs get 403; checker reports unverified/warn; exit 0 |
| Chrome UA does not reduce npm/SO 403 rate vs bot UA | UA matrix: all browser-like UAs → 403 on npm/SO |
| Chrome UA introduces errors on Google/Facebook docs | 6 stable errors in full RNFB run; primary table shows bot UA passes same URLs |
| TypeDoc 404 detection works with bot UA | Synthetic 404 on reference.rnfirebase.io errors with both UAs; valid TypeDoc URLs pass with both |
| Failures are OAuth redirect loops / 400, not missing pages | Redirect trace shows 302 → oauth2authorize with pr544 UA; 200 with bot UA |
Recommendation (inference from above evidence)
Revert EXTERNAL_LINK_USER_AGENT to the prior bot identity (Mozilla/5.0 (compatible; docs.page-cli/2.0.0; +https://docs.page)) and keep the BOT_GATE_STATUSES / unverified → warn behavior from this PR.
Reasoning chain:
- The stated motivation for Chrome UA was npm/Stack Overflow bot gates — but those hosts return 403 for all tested UAs; PR 544’s status-based downgrade already handles them without UA spoofing.
- Chrome UA causes Google
developer.android.comto enter OAuth auto-sign-in redirect loops and Facebook to return 400 — both classified as hard errors, producing false positives on legitimate documentation links. - Bot UA passes those same URLs, preserves 404 detection on
reference.rnfirebase.io, and leaves npm/SO as non-blocking warnings.
Not recommended based on this evidence: --ignore-external-hosts for developer.android.com / developers.facebook.com / ai.google.dev — that would also suppress real 404s on those hosts. Host ignore lists and --external-links warn were workarounds for the pre-544 world; this data suggests UA choice is the simpler fix.
Happy to re-run against a specific commit or add CI-matrix reproduction steps if useful.
A host that answers 401, 403, 405 or 429 is refusing the automated request, not telling us the target is missing. Classify those responses as "unverified" and report them through the existing per-issue severity as warnings, so a bot gate no longer fails CI while 404s, 5xxs, DNS failures and timeouts stay errors. GET is the authoritative attempt because many hosts do not implement HEAD; the HEAD response is only consulted when GET produced no response at all. The existing --external-links modes are unchanged: off still skips the requests entirely and an explicit warn is never upgraded. Adds the first tests for the external link checker, using a mocked fetch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ENoNC4NzabSZT7yBWy6fn
Some hosts refuse automated requests no matter which user agent is sent. Until now the only escape hatch was the project-wide --external-links warn|off, which turns off the whole check. Add --ignore-external-hosts, a comma-separated list of hosts to skip, and the equivalent "check.ignoreExternalHosts" field in docs.json so CI does not have to repeat the flag. The two sources are unioned; neither replaces the other. Entries are matched against the parsed URL hostname only, so an entry covers the host and its subdomains (npmjs.org also covers www.npmjs.org) but never a lookalike such as evil-npmjs.org.attacker.net. Both sides are normalised: lowercased, trimmed, a leading "*." or "." and a trailing dot stripped, and a pasted URL or host:port reduced to its hostname. Empty and unparsable entries are dropped. Ignored links are skipped before any network request and printed as skip lines, with a skipped count in the summary, so they are never silently dropped. Documents the flag and the config field, and adds unit tests for the normalisation and matching helpers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ENoNC4NzabSZT7yBWy6fn
The CLI reads `check.ignoreExternalHosts` from docs.json, but the app's config schema had no `check` key, so the hosted schema would have flagged it as unknown and editors would not autocomplete it. Carry the key in the schema so the two cannot skew. The shape mirrors what the CLI tolerates: a comma-separated string or a list of hosts. Entry contents stay unvalidated because the CLI normalises and discards unusable entries itself, so a single bad host must not invalidate the whole list. `check` and the field are both optional with the file's `.catch(undefined)` convention, so no existing field, default or fallback changes. `/schema.json` is generated from ConfigSchema at request time, so no generated output needed regenerating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ENoNC4NzabSZT7yBWy6fn
f85b3df to
74460c7
Compare
mikehardy
left a comment
There was a problem hiding this comment.
Works great on a test integration
| * or `.` prefix, a trailing dot, and surrounding whitespace. Returns | ||
| * `undefined` for anything that is not a usable host. | ||
| */ | ||
| export function normalizeIgnoredHost(entry: unknown): string | undefined { |
There was a problem hiding this comment.
@claude could a lot of this logic in the file not be put into the zod transformer?
There was a problem hiding this comment.
Good instinct, and you are right that this block does not belong in check.ts — but a zod transform cannot get us there today, because the CLI never parses docs.json through zod at all. packages/cli/src/lib/docs-config.ts does a raw JSON.parse into an untyped record, validateDocsConfig only checks JSON/YAML syntax, and the CLI has no zod dependency (the app's ConfigSchema lives in the private app workspace, so a published npm package cannot import it).
- What genuinely does not belong here:
readConfigIgnoredHosts,normalizeIgnoredHost,splitIgnoreEntriesand the two host helpers, lines 805-919. Right call to pull them out — but as a plain move topackages/cli/src/lib/, not into a schema. About 115 lines out, one import back, all 22 tests keep passing unchanged. - What cannot move:
--ignore-external-hostsis a Commander string that zod never sees, so the normaliser has to stay directly callable;isIgnoredHostnormalises the target URL hostname with the same helper, so both sides of the comparison need it; and the flag plusdocs.jsonunion, where neither source replaces the other, is CLI policy rather than config shape. - App-side transform: I checked it. Adding
.transform()tomodels/check.tsleavesschema.jsonbyte-identical, because bothtoJSONSchemacall sites useio: "input"and transforms are invisible there. It would improve the inferred type tostring[], but nothing in the app readsconfig.checkand the CLI would not see it. Worth knowing thatio: "output"renders the field as an empty object and marks it required, so that safety rests onio: "input"staying put.
Recommendation: do the plain extraction to lib/, skip the zod transform. The version of your idea that really pays off is making the CLI validate docs.json through the same schema as the app, which would also kill parseConfigObject and the duplicated headerDepth default — but that needs a shared publishable config package and zod in the CLI bundle, so I would rather it be its own PR than get bolted on here.
Mike has approved the current head, so either works: I can push the extraction now, or land as-is and follow up. Your call.
Generated by Claude Code
The check command had grown a block of config-shape and host-list logic that had nothing to do with orchestrating checks. Extract it so check.ts keeps only the call sites: - `readConfigIgnoredHosts` and `CONFIG_IGNORE_HOSTS_PATH` move to lib/docs-config.ts, alongside the sibling config accessors. - `normalizeIgnoredHost`, `parseIgnoredHosts` and `isIgnoredHost` move to a new lib/ignored-hosts.ts, together with the `splitIgnoreEntries`, `stripHostPrefix` and `normalizeHostname` helpers they share. - The host tests move to lib/ignored-hosts.test.ts unchanged. Pure code movement: the helper bodies and every test assertion are byte-identical to before, so matching semantics, normalisation, the union of the flag and the docs.json value, the skip output and severity handling are all unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ENoNC4NzabSZT7yBWy6fn
Requested by Alex Duke · Slack thread
Relates to invertase/react-native-firebase#9214
Summary
Before: Stack Overflow, npm and other bot-gated hosts answer an automated client with a 403 instead of the page, so
docs checkreported perfectly good links as broken and failed CI. The only escape hatch was--external-links warnor--external-links off, which turns the external link check off for the whole project. There was no way to say that one particular host is fine and should stop being checked.After: when a host refuses the request — 401, 403, 405 or 429 — the link is reported as a warning saying the host rejected an automated request and the link was not verified, so a bot gate no longer fails CI. Genuinely dead links (404, 5xx, DNS failures, timeouts) stay errors. Hosts that are permanently unfriendly can be listed once, with
--ignore-external-hostsorcheck.ignoreExternalHostsindocs.json, and are skipped before any request is made.In one sentence: this makes
docs checkusable in CI against docs that link to bot-gated hosts, without giving up external link checking entirely.How
checkExternalUrlreturns a classified failure (unverifiedorbroken) instead of a bare message, andresolveExternalIssueSeveritymapsunverifiedtowarnwhile leaving everything else on the configured--external-linksseverity —offstill skips the requests entirely, and an explicitwarnis never upgraded. GET is treated as the authoritative attempt because many hosts do not implement HEAD. The ignore list is read from thedocs.jsonobject the check command already parses and unioned with the flag value; both entries and targets are normalised to a bare lowercase hostname, then matched by exact equality or a dot-boundary suffix, never by substring matching on the URL, sonpmjs.orgcoverswww.npmjs.orgbut notevil-npmjs.org.attacker.net. Skipped links print asskiplines and are counted in the summary rather than being silently dropped.Commits
72411f9fix(cli): treat bot-gate responses as warnings, not errorse41b551feat(cli): add --ignore-external-hosts option74460c7feat(app): accept check.ignoreExternalHosts in the docs.json schemaScope
app/(hosted site, MCP, Ask AI)packages/cli/packages/mdx-bundler/docs/(product documentation)Type of change
Test plan
Adds
packages/cli/src/commands/check.test.ts, the first coverage on the external link checker: host normalisation (case, whitespace,*.prefix, leading and trailing dots, a pasted URL,host:port, empty and garbage entries), matching (exact host, subdomain, theevil-npmjs.org.attacker.netnegative case, unparsable URLs, empty list), the flag plus config union, and the severity mapping for 401/403/405/429 against 404/410/500/503 and an unreachable host, using a mockedfetchso no test touches the network.Also exercised by hand against a scratch project with links to an ignored host, an ignored subdomain, a lookalike host and an unlisted host, checking the
skiplines, the skipped count, the warning wording, and that--external-links offstill skips everything.bun run checkpasses locallybun test,bun run src/cli.ts check ...,--help)docs/(if user-facing)Each commit in the list above was checked out on its own and passes
biome ci .andbun test.Notes for reviewers
docs.page-cliuser agent with a desktop Chrome string. Mike Hardy ran the branch against the react-native-firebase docs and found it net-negative: npm and Stack Overflow return 403 to every user agent, so the bot-gate warn behaviour is what actually fixes those links, while the Chrome string madedeveloper.android.combounce into an OAuth sign-in redirect loop anddevelopers.facebook.comreturn 400 — six false errors on valid links. Elliot Hesp approved dropping it, sochecknow sends exactly the user agent it sent before this PR, unchanged frommain.app/src/server/config/schema.tsacceptscheck.ignoreExternalHosts, added at the review request from Mike Hardy so the CLI key and the schema do not skew apart, and so the key gets editor autocomplete and validation rather than only being tolerated as an unknown key.app/schema addition goes out with the app;@docs.page/clistill ships only on a version bump plus acli-v*tag push, so the CLI side reaches users on the next release, not on merge.docs checkfails on valid Stack Overflow and npm links; this unblocks ci(docs): add docs.page link check to docs workflow react-native-firebase#9214..npmjs.orgbehaves likenpmjs.org. That reads as the obvious intent, but it also means someone who writes.comignores every.comhost. Happy to reject leading-dot entries instead.packages/clitypecheck (bunx tsc --noEmit) is red onmainalready: bun:test has no types in that package tsconfig, and there are three pre-existing errors inpackages/mdx-bundler. The new test file adds one more missing-module line for bun:test, of exactly the same pre-existing kind. Fixing that needs a tsconfig or devDependency change I left out of scope. There are no new errors of any other kind.