Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion core/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type FeedValue = {
lastPublishedAt?: number
loader: LoaderName
reading: 'fast' | 'slow'
refreshedAt?: number
slowReader?: UsefulReaderName
title: string
url: string
Expand Down Expand Up @@ -99,7 +100,12 @@ export function getFeedLatestPosts(
feed: FeedValue,
task = createDownloadTask()
): PostsList {
return loaders[feed.loader].getPosts(task, feed.url)
return loaders[feed.loader].getPosts(
task,
feed.url,
undefined,
feed.refreshedAt
)
}

let testFeedId = 0
Expand Down
3 changes: 2 additions & 1 deletion core/lib/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ export function createDownloadTask(
if (controller.signal.aborted) {
throw new DOMException('', 'AbortError')
}
if (!response.ok) {
if (!response.ok && response.status !== 304) {
throw new HTTPStatusError(
response.status,
url,
Expand All @@ -246,6 +246,7 @@ export function createDownloadTask(
if (controller.signal.aborted) {
throw new DOMException('', 'AbortError')
}

return createTextResponse(text, {
headers: response.headers,
redirected: response.redirected,
Expand Down
12 changes: 8 additions & 4 deletions core/loader/atom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type OriginPost, type PostMedia, stringifyMedia } from '../post.ts'
import { createPostsList, type PostsListResult } from '../posts-list.ts'
import {
buildFullURL,
fetchIfModified,
findAnchorHrefs,
findDocumentLinks,
findHeaderLinks,
Expand Down Expand Up @@ -122,9 +123,12 @@ function parseFeed(

async function loadFeed(
task: DownloadTask,
url: string
url: string,
refreshedAt?: number
): Promise<PostsListResult> {
return parseFeed(task, await task.text(url))
return fetchIfModified(task, url, refreshedAt, response =>
parseFeed(task, response)
)
}

export const atom: Loader = {
Expand All @@ -143,11 +147,11 @@ export const atom: Loader = {
}
},

getPosts(task, url, text) {
getPosts(task, url, text, refreshedAt) {
if (text) {
return createPostsList(() => parseFeed(task, text))
} else {
return createPostsList(() => loadFeed(task, url))
return createPostsList(() => loadFeed(task, url, refreshedAt))
}
},

Expand Down
26 changes: 24 additions & 2 deletions core/loader/common.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FeedValue } from '../feed.ts'
import type { DownloadTask, TextResponse } from '../lib/download.ts'
import type { PostMedia } from '../post.ts'
import type { PostsList } from '../posts-list.ts'
import type { PostsList, PostsListResult } from '../posts-list.ts'

export type Loader = {
/**
Expand All @@ -17,7 +17,12 @@ export type Loader = {
* Task is a way to combine multiple HTTP requests (for instance, during
* the feed search/preview) to cancel all of them fast.
*/
getPosts(task: DownloadTask, url: string, text?: TextResponse): PostsList
getPosts(
task: DownloadTask,
url: string,
text?: TextResponse,
refreshedAt?: number
): PostsList

/**
* Get source data of post from loader's API for debug purposes.
Expand Down Expand Up @@ -183,3 +188,20 @@ export function findMediaInText(
} satisfies PostMedia
})
}

/**
* Prevent heavy loading/parsing by `If-Modified-Since` header.
*/
export async function fetchIfModified(
task: DownloadTask,
url: string,
refreshedAt: number | undefined,
parseCb: (response: TextResponse) => PostsListResult
): Promise<PostsListResult> {
let headers = refreshedAt
? { 'If-Modified-Since': new Date(refreshedAt * 1000).toUTCString() }
: undefined
let response = await task.text(url, { headers })
if (response.status === 304) return [[], undefined]
return parseCb(response)
}
12 changes: 8 additions & 4 deletions core/loader/json-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createDownloadTask, type TextResponse } from '../lib/download.ts'
import { type OriginPost, type PostMedia, stringifyMedia } from '../post.ts'
import { createPostsList } from '../posts-list.ts'
import {
fetchIfModified,
findAnchorHrefs,
findDocumentLinks,
findHeaderLinks,
Expand Down Expand Up @@ -142,13 +143,16 @@ export const jsonFeed: Loader = {
return [...linksByType, ...findAnchorHrefs(text, /feed\.json/i)]
},

getPosts(task, url, text) {
getPosts(task, url, text, refreshedAt) {
if (text) {
return createPostsList(() => [parsePosts(text), undefined])
} else {
return createPostsList(async () => {
return [parsePosts(await task.text(url)), undefined]
})
return createPostsList(() =>
fetchIfModified(task, url, refreshedAt, response => [
parsePosts(response),
undefined
])
)
}
},

Expand Down
12 changes: 8 additions & 4 deletions core/loader/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type OriginPost, type PostMedia, stringifyMedia } from '../post.ts'
import { createPostsList } from '../posts-list.ts'
import { findMRSS } from './atom.ts'
import {
fetchIfModified,
findAnchorHrefs,
findDocumentLinks,
findHeaderLinks,
Expand Down Expand Up @@ -60,13 +61,16 @@ export const rss: Loader = {
]
},

getPosts(task, url, text) {
getPosts(task, url, text, refreshedAt) {
Comment thread
ai marked this conversation as resolved.
if (text) {
return createPostsList(() => [parsePosts(text), undefined])
} else {
return createPostsList(async () => {
return [parsePosts(await task.text(url)), undefined]
})
return createPostsList(() =>
fetchIfModified(task, url, refreshedAt, response => [
parsePosts(response),
undefined
])
)
}
},

Expand Down
3 changes: 3 additions & 0 deletions core/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ async function checkForNextPage(
if (!getFeed(feed.id).deleted) {
await addPosts(feed, pages.get().list)
}
await changeFeed(feed.id, {
refreshedAt: Math.round(Date.now() / 1000)
})
increaseKey(refreshStatistics, 'processedFeeds')
}
}
Expand Down
6 changes: 3 additions & 3 deletions core/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,20 @@ export function setRequestMethod(method: RequestMethod): void {
}

export interface RequestWaiter {
(status: number, body?: string, contentType?: string): Promise<void>
(status: number, body?: null | string, contentType?: string): Promise<void>
aborted?: true
}

export interface RequestMock {
andFail(): void
andRespond(status: number, body?: string, contentType?: string): void
andRespond(status: number, body?: null | string, contentType?: string): void
andWait(): RequestWaiter
}

interface RequestExpect {
contentType: string
error: boolean
response: string
response: null | string
status: number
url: string
wait: Promise<void>
Expand Down
54 changes: 54 additions & 0 deletions core/test/loader/atom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
loaders,
mockRequest,
parseMedia,
setRequestMethod,
setupEnvironment,
testFeed,
type TextResponse
Expand Down Expand Up @@ -680,3 +681,56 @@ test('returns post source', async () => {
undefined
)
})

test('handles conditional request when server returns 304', async () => {
let callCount = 0
let capturedOpts: RequestInit | undefined

// Mock initial and refreshing requests
setRequestMethod((url, opts) => {
if (++callCount === 1) {
return Promise.resolve(
new Response(
`<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Feed</title>
<entry>
<title>Test Post</title>
<id>test-post</id>
<link rel="alternate" href="https://example.com/1" />
</entry>
</feed>`,
{
headers: {
'Content-Type': 'application/atom+xml',
'Last-Modified': 'Thu, 01 Jan 2026 00:00:00 GMT'
}
}
)
)
} else {
capturedOpts = opts
return Promise.resolve(new Response(null, { status: 304 }))
}
})

let task = createDownloadTask()

// Initial request
let page1 = loaders.atom.getPosts(task, 'https://example.com/news/')
await page1.loading
equal(page1.get().list.length, 1)

// Refreshing request
let page2 = loaders.atom.getPosts(
task,
'https://example.com/news/',
undefined,
1767225600 // Thu, 01 Jan 2026 00:00:00 GMT
)
await page2.loading
deepEqual(capturedOpts?.headers, {
'If-Modified-Since': 'Thu, 01 Jan 2026 00:00:00 GMT'
})
equal(page2.get().list.length, 0)
})
56 changes: 56 additions & 0 deletions core/test/loader/json-feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
expectRequest,
loaders,
mockRequest,
setRequestMethod,
setupEnvironment,
testFeed,
type TextResponse
Expand Down Expand Up @@ -628,3 +629,58 @@ test('returns post source', async () => {
undefined
)
})

test('handles conditional request when server returns 304', async () => {
let callCount = 0
let capturedOpts: RequestInit | undefined

// Mock initial and refreshing requests
setRequestMethod((url, opts) => {
if (++callCount === 1) {
return Promise.resolve(
new Response(
JSON.stringify({
items: [
{
id: 'test-post',
title: 'Test Post',
url: 'https://example.com/1'
}
],
title: 'Feed',
version: 'https://jsonfeed.org/version/1.1'
}),
{
headers: {
'Content-Type': 'application/json',
'Last-Modified': 'Thu, 01 Jan 2026 00:00:00 GMT'
}
}
)
)
} else {
capturedOpts = opts
return Promise.resolve(new Response(null, { status: 304 }))
}
})

let task = createDownloadTask()

// Initial request
let page1 = loaders.jsonFeed.getPosts(task, 'https://example.com/news/')
await page1.loading
equal(page1.get().list.length, 1)

// Refreshing request
let page2 = loaders.jsonFeed.getPosts(
task,
'https://example.com/news/',
undefined,
1767225600 // Thu, 01 Jan 2026 00:00:00 GMT
)
await page2.loading
deepEqual(capturedOpts?.headers, {
'If-Modified-Since': 'Thu, 01 Jan 2026 00:00:00 GMT'
})
equal(page2.get().list.length, 0)
})
Loading