Skip to content

Restore blog RSS post rendering with resilient /api/rss fallback parsing#27

Merged
jaypatrick merged 4 commits intomainfrom
copilot/fix-rss-feeds-issues
Apr 22, 2026
Merged

Restore blog RSS post rendering with resilient /api/rss fallback parsing#27
jaypatrick merged 4 commits intomainfrom
copilot/fix-rss-feeds-issues

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 22, 2026

Blog feed cards were failing to populate because /api/rss depended on a single upstream XML feed path that can return challenge HTML/invalid payloads in production. This change makes feed ingestion tolerant of those upstream failure modes so posts still render.

  • Feed retrieval hardening

    • Added a secondary fetch path to /{origin}/wp-json/wp/v2/posts when primary RSS/Atom fetch returns:
      • non-2xx
      • text/html (challenge/redirect pages)
      • non-feed XML
      • empty parsed items
      • network/timeout errors
    • Primary behavior remains RSS/Atom-first; fallback is only used on failure/empty conditions.
  • Parsing quality improvements

    • Extended XML entity decoding to include numeric entities (&#...;, &#x...;) so excerpts/titles from WordPress-style content render correctly.
    • Added runtime-safe timeout signal creation (uses AbortSignal.timeout when present, otherwise AbortController fallback) to avoid environment-specific timeout regressions.
  • Targeted API behavior coverage

    • Added tests for:
      • numeric entity decoding in feed descriptions
      • fallback-to-WordPress path when feed endpoint returns HTML
// /api/rss fallback trigger (simplified)
if (!response.ok || mimeType === 'text/html' || !isValidFeedDocument(xml) || items.length === 0) {
  const fallbackItems = await tryWordPressFallback();
  if (fallbackItems) return json({ items: fallbackItems });
}

Copilot AI changed the title [WIP] Fix RSS feeds display and functionality issues Restore blog RSS post rendering with resilient /api/rss fallback parsing Apr 22, 2026
Copilot AI requested a review from jaypatrick April 22, 2026 04:39
@jaypatrick jaypatrick added the bug Something isn't working label Apr 22, 2026
@jaypatrick jaypatrick added this to the launch milestone Apr 22, 2026
@jaypatrick jaypatrick marked this pull request as ready for review April 22, 2026 14:23
Copilot AI review requested due to automatic review settings April 22, 2026 14:23
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves the resilience of the blog RSS ingestion endpoint (/api/rss) so feed-backed UI can still render posts when the upstream XML feed is blocked/invalid by falling back to the WordPress REST posts API.

Changes:

  • Added numeric XML entity decoding (&#...;, &#x...;) to improve rendering of WordPress-style excerpts/titles.
  • Added runtime-compatible request timeout signal creation (uses AbortSignal.timeout when available, otherwise an AbortController fallback).
  • Added a WordPress /wp-json/wp/v2/posts fallback path when RSS/Atom fetch/parsing fails or yields no items, plus targeted tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
src/pages/api/rss.ts Adds numeric entity decoding, timeout signal helper, and WordPress fallback parsing/response paths for /api/rss.
src/pages/api/rss.test.ts Adds tests for numeric entity decoding and the HTML-response WordPress fallback behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/pages/api/rss.ts Outdated
Comment thread src/pages/api/rss.ts Outdated
Comment thread src/pages/api/rss.ts
Comment thread src/pages/api/rss.ts Outdated
Comment thread src/pages/api/rss.ts Outdated
@jaypatrick jaypatrick requested a review from Copilot April 22, 2026 15:01
@jaypatrick jaypatrick merged commit 3b0c983 into main Apr 22, 2026
6 checks passed
@jaypatrick jaypatrick deleted the copilot/fix-rss-feeds-issues branch April 22, 2026 15:02
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/pages/api/rss.ts
const extractTag = (block: string, tags: string[]): string => {
for (const tag of tags) {
const match = block.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i'));
const match = block.match(new RegExp(`<${tag}(?:\s[^>]*)?>([\s\S]*?)<\/${tag}>`, 'i'));
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractTag builds a RegExp from a string that contains \s/\S escapes, but the string literal currently uses single backslashes (\s is written as \s in the source). In JS/TS string literals \s is not a valid escape and becomes just s, so the regex will not match whitespace/newlines and tag extraction will break. Use double-escaped backslashes (e.g. \\s, \\S) or String.raw so the intended regex escapes reach the RegExp constructor.

Suggested change
const match = block.match(new RegExp(`<${tag}(?:\s[^>]*)?>([\s\S]*?)<\/${tag}>`, 'i'));
const match = block.match(new RegExp(String.raw`<${tag}(?:\s[^>]*)?>([\s\S]*?)<\/${tag}>`, 'i'));

Copilot uses AI. Check for mistakes.
Comment thread src/pages/api/rss.ts
Comment on lines +25 to +30
const safeFromCodePoint = (codePoint: number): string => {
if (codePoint >= 0 && codePoint <= 0x10ffff) {
return String.fromCodePoint(codePoint);
}
return '\uFFFD';
};
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

safeFromCodePoint currently treats any value in 0..0x10FFFF as valid, which includes surrogate code points (0xD800–0xDFFF). Decoding numeric entities to lone surrogates can produce invalid/unpaired UTF-16 in JS strings and unexpected rendering. Consider rejecting surrogate ranges (and non-finite values) and returning U+FFFD for those as well.

Copilot uses AI. Check for mistakes.
Comment thread src/pages/api/rss.ts
Comment on lines +131 to +132
if (typeof AbortSignal.timeout === 'function') {
return fetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) });
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchWithTimeout assumes AbortSignal is always defined and references AbortSignal.timeout directly. In runtimes where AbortSignal is missing or not global, this will throw before falling back to AbortController. Consider guarding via globalThis.AbortSignal (e.g., typeof globalThis.AbortSignal?.timeout === 'function') to keep the timeout wrapper truly runtime-safe.

Suggested change
if (typeof AbortSignal.timeout === 'function') {
return fetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) });
const abortSignalCtor = globalThis.AbortSignal;
if (typeof abortSignalCtor?.timeout === 'function') {
return fetch(input, { ...init, signal: abortSignalCtor.timeout(timeoutMs) });

Copilot uses AI. Check for mistakes.
Comment thread src/pages/api/rss.ts
Comment on lines +212 to +224
const tryWordPressFallback = async (): Promise<FeedItem[] | null> => {
try {
const wpApiUrl = new URL('/wp-json/wp/v2/posts', `${parsedFeedUrl.origin}/`);
wpApiUrl.searchParams.set('per_page', String(max));
wpApiUrl.searchParams.set('_fields', 'link,title.rendered,excerpt.rendered,date,date_gmt');

const response = await fetchWithTimeout(wpApiUrl, {
headers: {
Accept: 'application/json',
'User-Agent': 'Mozilla/5.0 (compatible; JKcom-RSSBot/1.0; +https://jaysonknight.com)',
},
cache: 'no-store',
});
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WordPress fallback reuses parsedFeedUrl.origin derived from the user-controlled url parameter, which means the endpoint will now make an additional request to /<origin>/wp-json/wp/v2/posts for arbitrary hosts. This increases SSRF blast radius (extra internal endpoints hit on the same host) compared to the RSS-only fetch. Consider restricting allowed origins (e.g., a known blog host), enforcing http/https only, and/or blocking private IP ranges before issuing either fetch.

Copilot uses AI. Check for mistakes.
Comment thread src/pages/api/rss.ts
Comment on lines 246 to 252
if (!response.ok) {
const fallbackItems = await tryWordPressFallback();
if (fallbackItems) {
return respondWithItems(fallbackItems);
}
console.error('[api/rss] Failed to fetch feed with non-OK status:', response.status, 'for URL:', sanitizedFeedUrl);
return new Response(JSON.stringify({ error: `Failed to fetch feed (${response.status}).` }), {
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback behavior is now triggered in several new cases (non-OK status, invalid feed XML, empty parsed items, thrown errors), but the new tests only cover the text/html trigger. Add at least one more test that demonstrates a successful fallback for another trigger (e.g., upstream 503 or invalid feed body) and a test that asserts no fallback request is made when the primary RSS/Atom parse yields items.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants