fix(reader): support dynamic token-fetcher for foliate PWA caching

Implement a custom callable fetcher function in the Foliate integration that
stringifies to the current auth token. This ensures backward compatibility
for older service-worker cached versions of the Foliate library expecting a
string token, while allowing newer/uncached versions to perform authenticated
requests correctly. Additionally, exclude Foliate assets from PWA precaching
to prevent stale cache bugs on Firefox.

Closes #575
This commit is contained in:
neon
2026-07-07 16:41:20 +00:00
parent 1405fdc13f
commit 0b11583f7d
5 changed files with 63 additions and 6 deletions
@@ -11,7 +11,16 @@
* @returns {Object} Loader interface compatible with Foliate's EPUB class
*/
export const makeStreamingLoader = (bookId, baseUrl, bookInfo, fetchFile = fetch, bookType = null, fileId = null) => {
const requestFile = typeof fetchFile === 'function' ? fetchFile : fetch
const requestFile =
typeof fetchFile === 'function'
? fetchFile
: typeof fetchFile === 'string' && fetchFile
? (input, init) => {
const headers = new Headers(init?.headers)
headers.set('Authorization', `Bearer ${fetchFile}`)
return fetch(input, { ...init, headers })
}
: fetch
const OPTIONAL_TEXT_FILES = new Set([
'META-INF/encryption.xml',
'META-INF/com.apple.ibooks.display-options.xml',
@@ -53,4 +53,21 @@ describe('makeStreamingLoader auth integration', () => {
{ url: chapterUrl, auth: 'Bearer fresh-token' },
])
})
it('supports the legacy static token argument for already-cached app bundles', async () => {
const calls: { url: string; auth: string | null }[] = []
const chapterUrl = '/api/v1/epub/42/file/text/chapter%201.xhtml?fileId=9'
const fetchMock = vi.fn<typeof fetch>((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
const headers = new Headers(init?.headers)
calls.push({ url, auth: headers.get('Authorization') })
return Promise.resolve(new Response('<html>chapter</html>', { status: 200 }))
})
globalThis.fetch = fetchMock as never
const loader = makeStreamingLoader(42, '/api/v1/epub', { manifest: [{ href: 'text/chapter 1.xhtml', size: 20 }] }, 'legacy-token', null, 9)
await expect(loader.loadText('text/chapter 1.xhtml')).resolves.toBe('<html>chapter</html>')
expect(calls).toEqual([{ url: chapterUrl, auth: 'Bearer legacy-token' }])
})
})
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/api', () => ({
api: vi.fn<() => Promise<Response>>(),
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
getAccessToken: vi.fn<() => string | null>(),
}))
vi.mock('../useFoliateAnnotations', () => ({
@@ -34,7 +35,7 @@ vi.mock('../useFoliateInput', () => ({
useFoliateInput: () => inputMock,
}))
import { api } from '@/lib/api'
import { api, getAccessToken } from '@/lib/api'
import { useFoliate } from '../useFoliate'
describe('useFoliate.open', () => {
@@ -86,6 +87,7 @@ describe('useFoliate.open', () => {
ok: true,
json: vi.fn<() => Promise<unknown>>().mockResolvedValue({}),
} as unknown as Response)
vi.mocked(getAccessToken).mockReturnValue('reader-token')
;(window as { makeStreamingBook?: unknown }).makeStreamingBook = vi
.fn<(...args: unknown[]) => Promise<unknown>>()
.mockResolvedValue({ type: 'book' })
@@ -112,7 +114,22 @@ describe('useFoliate.open', () => {
await foliate.open(1, 2, 'epub', null, undefined)
const makeStreamingBook = (window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook
expect(makeStreamingBook).toHaveBeenCalledWith(1, '/api/v1/epub', {}, api, null, 2)
expect(makeStreamingBook).toHaveBeenCalledWith(1, '/api/v1/epub', {}, expect.any(Function), null, 2)
const fetchFile = makeStreamingBook.mock.calls[0]![3] as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
await fetchFile('/api/v1/epub/1/file/chapter.xhtml')
expect(api).toHaveBeenCalledWith('/api/v1/epub/1/file/chapter.xhtml', undefined)
})
it('passes a token-stringable fetcher for old cached Foliate assets', async () => {
const foliate = useFoliate(() => container)
await foliate.open(1, 2, 'epub', null, undefined)
const makeStreamingBook = (window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook
const fetchFile = makeStreamingBook.mock.calls[0]![3] as { toString: () => string; [Symbol.toPrimitive]: () => string }
expect(String(fetchFile)).toBe('reader-token')
vi.mocked(getAccessToken).mockReturnValue('fresh-reader-token')
expect(`${fetchFile}`).toBe('fresh-reader-token')
})
it('forces fixed-layout EPUB spreads off before opening when requested', async () => {
@@ -1,5 +1,5 @@
import { onUnmounted, ref } from 'vue'
import { api } from '@/lib/api'
import { api, getAccessToken } from '@/lib/api'
import { useFoliateAnnotations } from './useFoliateAnnotations'
import { useFoliateSelection } from './useFoliateSelection'
import { useFoliateInput } from './useFoliateInput'
@@ -39,6 +39,11 @@ export interface EpubOpenOptions {
fixedLayoutSpread?: EpubReaderSettings['fixedLayoutSpread']
}
type FoliateFetchFile = ((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) & {
toString: () => string
[Symbol.toPrimitive]: () => string
}
function isResolvedNavigation(value: unknown): boolean {
return Boolean(value && typeof value === 'object' && typeof (value as { index?: unknown }).index === 'number')
}
@@ -59,6 +64,14 @@ function applyEpubOpenOptions(book: unknown, options: EpubOpenOptions | undefine
}
}
function makeFoliateFetchFile(): FoliateFetchFile {
const fetchFile = ((input: RequestInfo | URL, init?: RequestInit) => api(input, init)) as FoliateFetchFile
const currentToken = () => getAccessToken() ?? ''
fetchFile.toString = currentToken
fetchFile[Symbol.toPrimitive] = currentToken
return fetchFile
}
export function useFoliate(
container: () => HTMLElement | null,
onRelocate?: (detail: RelocateDetail) => void,
@@ -243,7 +256,7 @@ export function useFoliate(
) => Promise<unknown>)
| undefined
if (!makeStreamingBook) throw new Error('makeStreamingBook not available')
const book = await makeStreamingBook(bookId, '/api/v1/epub', bookInfo, api, null, fileId)
const book = await makeStreamingBook(bookId, '/api/v1/epub', bookInfo, makeFoliateFetchFile(), null, fileId)
applyEpubOpenOptions(book, options)
shouldRestoreByFraction = isFixedLayoutBook(book)
isFixedLayout.value = shouldRestoreByFraction
+1
View File
@@ -79,6 +79,7 @@ export default defineConfig({
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
globIgnores: ['**/assets/foliate/**'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [