From 7a46bd34a02ea6342b7e4662dc6a1cfa53f97e6f Mon Sep 17 00:00:00 2001 From: Berry de Vos Date: Mon, 23 Jan 2023 09:57:22 +0100 Subject: [PATCH] Better feed error handling (#38) * Handle errors better and clear cache when updating feeds.ts --- .prettierignore | 2 + server.js | 2 - src/email/DaringFireballFeed.tsx | 2 +- src/email/Email.tsx | 67 ++++++++++++++++-------- src/email/FeedSwitch.tsx | 2 +- src/email/GenericFeed.tsx | 2 +- src/email/Rejected.tsx | 38 ++++++++++++++ src/filterItems.ts | 34 ++++++++++++ src/parseFeeds.ts | 58 ++++++++++++++++++++ src/parseLastSuccess.ts | 21 ++++++++ src/renderEmail.tsx | 90 ++++++-------------------------- src/utils/filter.ts | 11 ---- vite.config.ts | 16 +++++- 13 files changed, 232 insertions(+), 113 deletions(-) create mode 100644 .prettierignore create mode 100644 src/email/Rejected.tsx create mode 100644 src/filterItems.ts create mode 100644 src/parseFeeds.ts create mode 100644 src/parseLastSuccess.ts delete mode 100644 src/utils/filter.ts diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..48e15dc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +dist +cache.json diff --git a/server.js b/server.js index 010eee2..e19d007 100644 --- a/server.js +++ b/server.js @@ -36,8 +36,6 @@ async function createServer() { const { html, feeds } = await renderEmail({ pretty: true, - limit: 3, // Limit to the last n posts of every feed in feeds.ts - actionUrl: 'http://localhost:5173', cache: getCache(), }) diff --git a/src/email/DaringFireballFeed.tsx b/src/email/DaringFireballFeed.tsx index b8128bd..5c25308 100644 --- a/src/email/DaringFireballFeed.tsx +++ b/src/email/DaringFireballFeed.tsx @@ -3,7 +3,7 @@ import { Img } from '@react-email/img' import { Link } from '@react-email/link' import { Text } from '@react-email/text' import { Output } from 'rss-parser' -import { CustomItem, ItemLink } from '../renderEmail' +import { CustomItem, ItemLink } from '../parseFeeds' interface Props { feed: Output diff --git a/src/email/Email.tsx b/src/email/Email.tsx index 135e5b8..d0bd2ca 100644 --- a/src/email/Email.tsx +++ b/src/email/Email.tsx @@ -5,38 +5,62 @@ import { Link } from '@react-email/link' import { Preview } from '@react-email/preview' import { Section } from '@react-email/section' import { Text } from '@react-email/text' -import { Output } from 'rss-parser' +import dayjs, { Dayjs } from 'dayjs' import FeedSwitch from './FeedSwitch' import { formatDate } from '../utils/formatter' -import { CustomItem } from '../renderEmail' -import { Heading } from '@react-email/heading' -import { Column } from '@react-email/column' +import { SettledFeed } from '../parseFeeds' +import Rejected from './Rejected' interface Props { - feeds: Output[] + feeds: SettledFeed[] itemCount: number - actionUrl?: string + actionUrl: string + from: Dayjs + initialRun: boolean } -export default function Email({ feeds, itemCount, actionUrl }: Props) { +const parseIntro = (initialRun: boolean, itemCount: number, from: Dayjs) => { + if (initialRun) { + return `First edition with ${itemCount} updates` + } + + const hours = dayjs().diff(from, 'hours') + const days = Math.floor(hours / 24) + + if (days === 1) { + return `${itemCount} updates since yesterday` + } else if (days > 0) { + return `${itemCount} updates in the last ${days} days` + } else if (hours === 1) { + return `${itemCount} updates in the last hour` + } + return `${itemCount} updates in the last ${hours} hours` +} + +export default function Email({ feeds, itemCount, actionUrl, from, initialRun }: Props) { + const intro = parseIntro(initialRun, itemCount, from) + return ( - +
- RSS to Email with {itemCount} updates + {intro} - {feeds.map((feed, i) => ( - - ))} + {feeds.map((feed, i) => { + switch (feed.status) { + case 'fulfilled': + return + case 'rejected': + return + } + })} - - {actionUrl && ( - - {formatDate(new Date().toISOString())} - - )} + + + {formatDate(new Date().toISOString())} +
@@ -54,9 +78,7 @@ const container = { padding: '0', } -const section = {} - -const sectionText = { +const section = { color: '#495057', fontFamily: 'Inter, Avenir, Helvetica, Arial, sans-serif', fontSize: '12px', @@ -68,5 +90,6 @@ const sectionText = { const link = { color: '#495057', - textDecoration: 'underline', + textDecoration: 'none', + marginLeft: '8px', } diff --git a/src/email/FeedSwitch.tsx b/src/email/FeedSwitch.tsx index 51fc516..52924a1 100644 --- a/src/email/FeedSwitch.tsx +++ b/src/email/FeedSwitch.tsx @@ -1,5 +1,5 @@ import { Output } from 'rss-parser' -import { CustomItem } from '../renderEmail' +import { CustomItem } from '../parseFeeds' import DaringFireballFeed from './DaringFireballFeed' import GenericFeed from './GenericFeed' diff --git a/src/email/GenericFeed.tsx b/src/email/GenericFeed.tsx index 7aa72d4..98219a8 100644 --- a/src/email/GenericFeed.tsx +++ b/src/email/GenericFeed.tsx @@ -4,7 +4,7 @@ import { Link } from '@react-email/link' import { Section } from '@react-email/section' import { Text } from '@react-email/text' import { Output } from 'rss-parser' -import { CustomItem } from '../renderEmail' +import { CustomItem } from '../parseFeeds' import { formatDate } from '../utils/formatter' interface Props { diff --git a/src/email/Rejected.tsx b/src/email/Rejected.tsx new file mode 100644 index 0000000..f85530c --- /dev/null +++ b/src/email/Rejected.tsx @@ -0,0 +1,38 @@ +import { Container } from '@react-email/container' +import { Link } from '@react-email/link' +import { Text } from '@react-email/text' + +interface Props { + feed: string + reason: any +} + +export default ({ feed, reason }: Props) => { + return ( + + + + {feed} + + + Reason: {JSON.stringify(reason)} + + ) +} + +const box = { + padding: '32px 48px', + backgroundColor: '#c92a2a', +} + +const text = { + color: '#fff', + fontFamily: 'Inter, Avenir, Helvetica, Arial, sans-serif', + fontSize: '16px', + wordBreak: 'break-word' as const, +} + +const link = { + color: '#fff', + textDecoration: 'underline', +} diff --git a/src/filterItems.ts b/src/filterItems.ts new file mode 100644 index 0000000..b2b33d7 --- /dev/null +++ b/src/filterItems.ts @@ -0,0 +1,34 @@ +import dayjs, { Dayjs } from 'dayjs' +import { Item } from 'rss-parser' +import { CustomItem, SettledFeed } from './parseFeeds' + +const filterItems = (items: (Item & CustomItem)[], from: Dayjs, limit?: number) => + items.filter(({ pubDate }) => pubDate && dayjs(pubDate).isAfter(from)).slice(0, limit) + +export const filterItemsFromFeed = (feeds: SettledFeed[], from: Dayjs, limit?: number) => { + const filteredFeeds = feeds + .map((feed) => { + switch (feed.status) { + case 'fulfilled': + return { ...feed, value: { ...feed.value, items: filterItems(feed.value.items, from, limit) } } + case 'rejected': + return feed + } + }) + .filter((feed) => (feed.status === 'fulfilled' ? feed.value.items.length > 0 : true)) + + if (filteredFeeds.length === 0 || filteredFeeds.some(({ status }) => status === 'fulfilled')) { + return filteredFeeds + } + + // At this point we have no updated items and one or more failed feeds + filteredFeeds.forEach((feed) => { + if (feed.status === 'rejected') { + console.error(`Feed ${feed.feed} failed, reason: ${feed.reason}`) + } + }) + + throw new Error('One or more feeds failed while no new items!') +} + +export const getItemCount = (feeds: SettledFeed[]) => feeds.reduce((acc, feed) => acc + (feed.status === 'fulfilled' ? feed.value.items.length : 0), 0) diff --git a/src/parseFeeds.ts b/src/parseFeeds.ts new file mode 100644 index 0000000..31e2234 --- /dev/null +++ b/src/parseFeeds.ts @@ -0,0 +1,58 @@ +import { Dayjs } from 'dayjs' +import Parser, { Output } from 'rss-parser' +import { feeds } from './feeds' + +// rss-parser has this sorta funky parsing when `keepArray: true` is set +export interface ItemLink { + $: { + rel: 'alternate' | 'shorturl' | 'related' + href: string + } +} + +export type CustomItem = { + id: string // Daring Fireball uses id instead of guid, so had to append that as a custom type for parsing + link: string + links: ItemLink[] // Daring Fireball uses rel= in multiple links so have to do some specific parsing +} + +export type SettledFeed = + | { + status: 'fulfilled' + value: Output + } + | { + status: 'rejected' + feed: string + reason: any + } + +const parser: Parser = new Parser<{}, CustomItem>({ + customFields: { + item: [ + ['id', 'id'], + ['link', 'links', { keepArray: true }], + ], + }, +}) + +export const parseFeeds = async () => { + const settledFeeds = await Promise.allSettled(feeds.map((feed) => parser.parseURL(feed))) + + return settledFeeds.reduce((acc, current, i) => { + switch (current.status) { + case 'fulfilled': + return [...acc, { ...current, value: current.value as Output }] + case 'rejected': + console.error(`Could not settle feed ${feeds[i]}, reason: ${current.reason}`) + + return [ + ...acc, + { + ...current, + feed: feeds[i], + }, + ] + } + }, [] as SettledFeed[]) +} diff --git a/src/parseLastSuccess.ts b/src/parseLastSuccess.ts new file mode 100644 index 0000000..7a23960 --- /dev/null +++ b/src/parseLastSuccess.ts @@ -0,0 +1,21 @@ +import dayjs from 'dayjs' + +export const parseLastSuccess = (lastSuccess: string | undefined) => { + if (!lastSuccess || lastSuccess.trim() === '') { + return { + from: dayjs().subtract(7, 'days'), + initialRun: true, + } + } + + const parsed = dayjs(lastSuccess) + + if (parsed.isValid()) { + return { + from: parsed, + initialRun: false, + } + } + + throw new Error(`Unknown lastSuccess value: ${lastSuccess}`) +} diff --git a/src/renderEmail.tsx b/src/renderEmail.tsx index a1c2591..fdc7b4e 100644 --- a/src/renderEmail.tsx +++ b/src/renderEmail.tsx @@ -1,88 +1,30 @@ import { render } from '@react-email/render' -import Parser, { Output } from 'rss-parser' -import { feeds } from './feeds' import Email from './email/Email' -import { filterItemsFromFeed, getItemCount } from './utils/filter' -import dayjs from 'dayjs' +import { filterItemsFromFeed, getItemCount } from './filterItems' +import { parseLastSuccess } from './parseLastSuccess' +import { parseFeeds, SettledFeed } from './parseFeeds' -// rss-parser has this sorta funky parsing when `keepArray: true` is set -export interface ItemLink { - $: { - rel: 'alternate' | 'shorturl' | 'related' - href: string - } -} - -// Daring Fireball uses id instead of guid, so had to append that as a custom type for parsing -export type CustomItem = { - id: string - link: string - links: ItemLink[] +interface Props { + actionUrl: string + cache: SettledFeed[] + lastSuccess: string + limit: number + pretty: boolean } const ITEMS_ON_INITIAL_RUN = 3 +const FALLBACK_ACTION_URL = 'https://github.com/bdevos/rss-to-email' -const parser: Parser = new Parser<{}, CustomItem>({ - customFields: { - item: [ - ['id', 'id'], - ['link', 'links', { keepArray: true }], - ], - }, -}) - -interface Props { - actionUrl?: string - cache?: Output[] - lastSuccess?: string - limit?: number - pretty?: boolean -} - -const parseLastSuccess = (lastSuccess: string | undefined) => { - if (!lastSuccess || lastSuccess.trim() === '') { - return { - from: dayjs().subtract(7, 'days'), - initialRun: true, - } - } - - const parsed = dayjs(lastSuccess) - - if (parsed.isValid()) { - return { - from: parsed, - initialRun: false, - } - } - - throw new Error(`Unknown lastSuccess value: ${lastSuccess}`) -} - -const parseFeeds = async (from: dayjs.Dayjs, limit: number | undefined) => { - const settledFeeds = await Promise.allSettled(feeds.map((feed) => parser.parseURL(feed))) - - const fulfilledFeeds = settledFeeds.reduce((acc, current, i) => { - switch (current.status) { - case 'fulfilled': - return [...acc, current.value as Output] - case 'rejected': - console.error(`Could not settle feed ${feeds[i]}, reason: ${current.reason}`) - return acc - } - }, [] as Output[]) - - return filterItemsFromFeed(fulfilledFeeds, from, limit) -} - -export async function renderEmail({ actionUrl, cache, lastSuccess, limit, pretty = false }: Props) { +export async function renderEmail({ actionUrl = FALLBACK_ACTION_URL, cache, lastSuccess, limit, pretty = false }: Partial) { const { from, initialRun } = parseLastSuccess(lastSuccess) - const parsedFeeds = cache ?? (await parseFeeds(from, limit ?? initialRun ? ITEMS_ON_INITIAL_RUN : undefined)) + const parsedFeeds = cache ?? (await parseFeeds()) - const itemCount = getItemCount(parsedFeeds) + const filteredFeeds = filterItemsFromFeed(parsedFeeds, from, limit ?? initialRun ? ITEMS_ON_INITIAL_RUN : undefined) - const html = render(, { + const itemCount = getItemCount(filteredFeeds) + + const html = render(, { pretty, }) diff --git a/src/utils/filter.ts b/src/utils/filter.ts deleted file mode 100644 index 0e994c8..0000000 --- a/src/utils/filter.ts +++ /dev/null @@ -1,11 +0,0 @@ -import dayjs from 'dayjs' -import { Item, Output } from 'rss-parser' -import { CustomItem } from '../renderEmail' - -const filterItems = (items: (Item & CustomItem)[], from: dayjs.Dayjs, limit?: number) => - items.filter(({ pubDate }) => pubDate && dayjs(pubDate).isAfter(from)).slice(0, limit) - -export const filterItemsFromFeed = (feeds: Output[], from: dayjs.Dayjs, limit?: number) => - feeds.map((feed) => ({ ...feed, items: filterItems(feed.items, from, limit) })).filter(({ items }) => items.length > 0) - -export const getItemCount = (feeds: Output[]) => feeds.reduce((acc, current) => acc + current.items.length, 0) diff --git a/vite.config.ts b/vite.config.ts index 5a33944..8155c59 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,21 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { existsSync, unlinkSync } from 'fs' + +const cachePath = './cache.json' + +const FeedCacheHmr = () => ({ + name: 'feed-cache-hmr', + enforce: 'pre' as const, + handleHotUpdate({ file }) { + // If the feeds.ts file is updated, remove the cached version + if (file.endsWith('src/feeds.ts') && existsSync(cachePath)) { + unlinkSync(cachePath) + } + }, +}) // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), FeedCacheHmr()], })