Better feed error handling (#38)

* Handle errors better and clear cache when updating feeds.ts
This commit is contained in:
Berry de Vos 2023-01-23 09:57:22 +01:00 committed by GitHub
parent 8b2f98a98f
commit 7a46bd34a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 232 additions and 113 deletions

2
.prettierignore Normal file
View file

@ -0,0 +1,2 @@
dist
cache.json

View file

@ -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(),
})

View file

@ -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<CustomItem>

View file

@ -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<CustomItem>[]
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 (
<Html>
<Head />
<Preview children={`RSS to Email with ${itemCount} updates`} />
<Preview children={intro} />
<Section style={main}>
<Container style={container}>
<Text style={sectionText}>RSS to Email with {itemCount} updates</Text>
<Text style={section}>{intro}</Text>
{feeds.map((feed, i) => (
<FeedSwitch key={feed.link} feed={feed} hasBottomSeparator={i < feeds.length - 1} />
))}
{feeds.map((feed, i) => {
switch (feed.status) {
case 'fulfilled':
return <FeedSwitch key={feed.value.link} feed={feed.value} hasBottomSeparator={i < feeds.length - 1} />
case 'rejected':
return <Rejected key={feed.feed} feed={feed.feed} reason={feed.reason} />
}
})}
<Text style={sectionText}>
{actionUrl && (
<Link style={link} href={actionUrl}>
{formatDate(new Date().toISOString())}
</Link>
)}
<Text style={section}>
<Link style={link} href={actionUrl}>
{formatDate(new Date().toISOString())}
</Link>
</Text>
</Container>
</Section>
@ -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',
}

View file

@ -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'

View file

@ -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 {

38
src/email/Rejected.tsx Normal file
View file

@ -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 (
<Container style={box}>
<Text style={text}>
<Link style={link} href={feed}>
{feed}
</Link>
</Text>
<Text style={text}>Reason: {JSON.stringify(reason)}</Text>
</Container>
)
}
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',
}

34
src/filterItems.ts Normal file
View file

@ -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)

58
src/parseFeeds.ts Normal file
View file

@ -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<CustomItem>
}
| {
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<CustomItem> }]
case 'rejected':
console.error(`Could not settle feed ${feeds[i]}, reason: ${current.reason}`)
return [
...acc,
{
...current,
feed: feeds[i],
},
]
}
}, [] as SettledFeed[])
}

21
src/parseLastSuccess.ts Normal file
View file

@ -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}`)
}

View file

@ -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<CustomItem>[]
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<CustomItem>]
case 'rejected':
console.error(`Could not settle feed ${feeds[i]}, reason: ${current.reason}`)
return acc
}
}, [] as Output<CustomItem>[])
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<Props>) {
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(<Email feeds={parsedFeeds} itemCount={itemCount} actionUrl={actionUrl} />, {
const itemCount = getItemCount(filteredFeeds)
const html = render(<Email actionUrl={actionUrl} feeds={filteredFeeds} from={from} initialRun={initialRun} itemCount={itemCount} />, {
pretty,
})

View file

@ -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<CustomItem>[], 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<CustomItem>[]) => feeds.reduce((acc, current) => acc + current.items.length, 0)

View file

@ -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()],
})