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
44 changes: 39 additions & 5 deletions app/composables/use-book-file-loader.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { ORIGINAL_CONTENT_LENGTH_HEADER } from '~~/shared/utils/book-file'
import {
CUSTOM_MESSAGE_PENDING_HEADER,
ORIGINAL_CONTENT_LENGTH_HEADER,
} from '~~/shared/utils/book-file'

// Abort a book-file load when no progress is made within this window. iOS
// WebKit wedges in-flight cross-origin fetches after the app is backgrounded,
// which would otherwise strand the reader on the loading bar forever.
const BOOK_FILE_STALL_TIMEOUT_MS = 30_000

// Stamped on a cached copy that carries its custom message page; a copy without
// it was served pending, or was cached before this stamp existed.
const CUSTOM_MESSAGE_READY_HEADER = 'x-3ook-custom-message-ready'

function stampCustomMessageReady(res: Response) {
const headers = new Headers(res.headers)
headers.set(CUSTOM_MESSAGE_READY_HEADER, '1')
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
}

// Strip query params before a book-file URL reaches analytics or error data: it
// carries a short-lived access token we must not ship to those destinations.
function getSanitizedBookFileURL(url: string) {
Expand All @@ -25,6 +38,7 @@ export default function () {

const loadingFilesize = ref(0)
const loadedFilesize = ref(0)
const isLoadedFromCache = ref(false)

let activeController: AbortController | undefined

Expand Down Expand Up @@ -57,9 +71,16 @@ export default function () {
return `${loadedFilesizeInMB.value} / ${loadingFilesizeInMB.value} MB (${loadingPercentage.value}%)`
})

async function loadFileAsBuffer(url: string, cacheKey?: string) {
async function loadFileAsBuffer(url: string, {
cacheKey,
isCustomMessageEnabled = false,
}: {
cacheKey?: string
isCustomMessageEnabled?: boolean
} = {}) {
loadingFilesize.value = 0
loadedFilesize.value = 0
isLoadedFromCache.value = false

// Guard the whole load with an AbortController + stall watchdog. The
// watchdog re-arms on every chunk, so a slow-but-progressing download is
Expand Down Expand Up @@ -101,9 +122,17 @@ export default function () {
if (cacheKey && window.caches) {
try {
const cache = await raceAbort(caches.open(cacheKey))
res = await raceAbort(cache.match(req))
if (res) {
const cachedRes = await raceAbort(cache.match(req))
// A copy lacking its custom message page is still read offline, but
// online it is refetched below and overwritten by that cache.put.
const isMissingCustomMessage = (
isCustomMessageEnabled
&& cachedRes?.headers.get(CUSTOM_MESSAGE_READY_HEADER) !== '1'
)
if (cachedRes && !(isMissingCustomMessage && navigator.onLine)) {
res = cachedRes
isFromCache = true
isLoadedFromCache.value = true
touchBookFileCacheEntry({
cacheKeyPrefix: config.public.cacheKeyPrefix,
cacheKey,
Expand Down Expand Up @@ -139,12 +168,16 @@ export default function () {
if (cacheKey && window.caches) {
try {
const cache = await raceAbort(caches.open(cacheKey))
const isCustomMessagePending = res.headers.get(CUSTOM_MESSAGE_PENDING_HEADER) === '1'
const resToCache = isCustomMessageEnabled && !isCustomMessagePending
? stampCustomMessageReady(res.clone())
: res.clone()
// Fire-and-forget: awaiting would drain the clone before our own
// stream loop starts, forcing the tee to buffer the whole response.
// On failure we delete the now-empty cache that caches.open() just
// created; otherwise pruneBookFileCaches would treat it as live and
// keep the index entry, inflating the LRU total.
cachePutPromise = cache.put(req, res.clone())
cachePutPromise = cache.put(req, resToCache)
.then(() => true)
.catch((error) => {
// A foreground retry aborts this mid-put; that rejection is
Expand Down Expand Up @@ -300,6 +333,7 @@ export default function () {
loadingFilesizeInMB,
loadedFilesizeInMB,
loadingLabel,
isLoadedFromCache,

loadFileAsBuffer,
abortLoad,
Expand Down
1 change: 1 addition & 0 deletions app/composables/use-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export default function (params: {
bookInfo,
bookCoverSrc,

isCustomMessageEnabled,
bookFileCacheKey,
bookProgressKeyPrefix,
bookFileURLWithCORS,
Expand Down
12 changes: 9 additions & 3 deletions app/pages/reader/epub.vue
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ const {
bookCoverSrc,
bookFileURLWithCORS,
bookFileCacheKey,
isCustomMessageEnabled,
bookProgressKeyPrefix,
replaceStaleNFTId,
} = useReader()
Expand Down Expand Up @@ -501,7 +502,7 @@ const isAnnotationClickInProgress = ref(false)
const pendingSavePromise = ref<Promise<Annotation | null> | null>(null)
const renderedHighlights = new Set<string>()

const { loadingLabel, loadingPercentage, loadFileAsBuffer, abortLoad } = useBookFileLoader()
const { loadingLabel, loadingPercentage, isLoadedFromCache, loadFileAsBuffer, abortLoad } = useBookFileLoader()

const isOnline = useOnline()

Expand Down Expand Up @@ -1144,7 +1145,10 @@ async function loadEPub() {
// if this page instance is reused across books.
detectedNaturalWritingMode.value = undefined
hasEnforcedDefaultVerticalMode = false
const buffer = await loadFileAsBuffer(bookFileURLWithCORS.value, bookFileCacheKey.value)
const buffer = await loadFileAsBuffer(bookFileURLWithCORS.value, {
cacheKey: bookFileCacheKey.value,
isCustomMessageEnabled: isCustomMessageEnabled.value,
})
if (!buffer) {
return
}
Expand All @@ -1166,7 +1170,9 @@ async function loadEPub() {
try {
let isLocationLoaded = false
const locationCacheKey = getCacheKeyWithSuffix('locations')
if (window.localStorage) {
// Locations from an earlier download may not match a freshly fetched file,
// e.g. one that now carries the custom message page.
if (window.localStorage && isLoadedFromCache.value) {
const locationCache = window.localStorage.getItem(locationCacheKey)
if (locationCache) {
book.locations!.load(locationCache)
Expand Down
6 changes: 5 additions & 1 deletion app/pages/reader/pdf.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const {
bookCoverSrc,
bookFileURLWithCORS,
bookFileCacheKey,
isCustomMessageEnabled,
bookProgressKeyPrefix,
replaceStaleNFTId,
} = useReader()
Expand Down Expand Up @@ -213,7 +214,10 @@ onMounted(() => {
})

async function loadPDF() {
const buffer = await loadFileAsBuffer(bookFileURLWithCORS.value, bookFileCacheKey.value)
const buffer = await loadFileAsBuffer(bookFileURLWithCORS.value, {
cacheKey: bookFileCacheKey.value,
isCustomMessageEnabled: isCustomMessageEnabled.value,
})
if (!buffer) {
return
}
Expand Down
4 changes: 4 additions & 0 deletions shared/utils/book-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
// this symbol: a mismatch degrades silently to chunk accumulation (~2x peak
// memory) with no error. Also emitted by the LikeCoin API's /ebook-cors/.
export const ORIGINAL_CONTENT_LENGTH_HEADER = 'x-original-content-length'

// Set to '1' by /ebook-cors/ when the book has a custom message page that can't
// be generated yet (the purchase memo is not indexed), so the file lacks it.
export const CUSTOM_MESSAGE_PENDING_HEADER = 'x-custom-message-pending'
142 changes: 142 additions & 0 deletions test/unit/use-book-file-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockNuxtImport } from '@nuxt/test-utils/runtime'

import useBookFileLoader from '~/composables/use-book-file-loader'
import { CUSTOM_MESSAGE_PENDING_HEADER } from '~~/shared/utils/book-file'

mockNuxtImport('useI18n', () => () => ({ t: (key: string) => key }))
mockNuxtImport('useUserSession', () => () => ({ user: ref(null) }))

const URL_WITH_MESSAGE = 'https://api.example.com/ebook-cors/?class_id=0xabc&nft_id=7&index=0&custom_message=1'
const CACHE_KEY = '3ook-book-file-0xabc-7-0-1'

interface StoredEntry {
body: ArrayBuffer
init: ResponseInit
}

// CacheStorage keeping each body as bytes, so a hit can be served repeatedly.
function stubCacheStorage() {
const stores = new Map<string, Map<string, StoredEntry>>()
const open = vi.fn(async (name: string) => {
let store = stores.get(name)
if (!store) {
store = new Map()
stores.set(name, store)
}
const entries = store
return {
match: vi.fn(async (req: Request) => {
const entry = entries.get(req.url)
return entry ? new Response(entry.body.slice(0), entry.init) : undefined
}),
put: vi.fn(async (req: Request, res: Response) => {
const headers = Object.fromEntries(res.headers.entries())
entries.set(req.url, { body: await res.arrayBuffer(), init: { status: res.status, headers } })
}),
}
})
vi.stubGlobal('caches', {
open,
keys: vi.fn(async () => [...stores.keys()]),
delete: vi.fn(async (name: string) => stores.delete(name)),
})
return {
getEntry: (name: string, url: string) => stores.get(name)?.get(url),
setEntry: (name: string, url: string, entry: StoredEntry) => {
stores.set(name, new Map([[url, entry]]))
},
}
}

function stubFetch(headers: Record<string, string> = {}) {
const fetchMock = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { headers }))
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}

function stubOnline(isOnline: boolean) {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(isOnline)
}

const legacyEntry: StoredEntry = { body: new Uint8Array([9]).buffer, init: { status: 200 } }

beforeEach(() => {
localStorage.clear()
stubOnline(true)
})

afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

describe('useBookFileLoader custom message caching', () => {
it('refetches a copy cached while its custom message page was pending', async () => {
const cacheStorage = stubCacheStorage()
const fetchMock = stubFetch({ [CUSTOM_MESSAGE_PENDING_HEADER]: '1' })
const { loadFileAsBuffer, isLoadedFromCache } = useBookFileLoader()
const options = { cacheKey: CACHE_KEY, isCustomMessageEnabled: true }

const buffer = await loadFileAsBuffer(URL_WITH_MESSAGE, options)
expect(new Uint8Array(buffer!)).toEqual(new Uint8Array([1, 2, 3]))
await vi.waitFor(() => expect(cacheStorage.getEntry(CACHE_KEY, URL_WITH_MESSAGE)).toBeDefined())

await loadFileAsBuffer(URL_WITH_MESSAGE, options)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(isLoadedFromCache.value).toBe(false)
})

it('reads a copy with its custom message page from the cache', async () => {
const cacheStorage = stubCacheStorage()
const fetchMock = stubFetch()
const { loadFileAsBuffer, isLoadedFromCache } = useBookFileLoader()
const options = { cacheKey: CACHE_KEY, isCustomMessageEnabled: true }

await loadFileAsBuffer(URL_WITH_MESSAGE, options)
await vi.waitFor(() => expect(cacheStorage.getEntry(CACHE_KEY, URL_WITH_MESSAGE)).toBeDefined())

const buffer = await loadFileAsBuffer(URL_WITH_MESSAGE, options)
expect(new Uint8Array(buffer!)).toEqual(new Uint8Array([1, 2, 3]))
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(isLoadedFromCache.value).toBe(true)
})

it('refetches a custom message copy cached before the ready stamp existed', async () => {
const cacheStorage = stubCacheStorage()
cacheStorage.setEntry(CACHE_KEY, URL_WITH_MESSAGE, legacyEntry)
const fetchMock = stubFetch()
const { loadFileAsBuffer } = useBookFileLoader()

const buffer = await loadFileAsBuffer(URL_WITH_MESSAGE, { cacheKey: CACHE_KEY, isCustomMessageEnabled: true })
expect(new Uint8Array(buffer!)).toEqual(new Uint8Array([1, 2, 3]))
expect(fetchMock).toHaveBeenCalledTimes(1)
await vi.waitFor(() => expect(cacheStorage.getEntry(CACHE_KEY, URL_WITH_MESSAGE)?.body.byteLength).toBe(3))
})

it('still reads a copy lacking its custom message page while offline', async () => {
stubOnline(false)
const cacheStorage = stubCacheStorage()
cacheStorage.setEntry(CACHE_KEY, URL_WITH_MESSAGE, legacyEntry)
const fetchMock = stubFetch()
const { loadFileAsBuffer, isLoadedFromCache } = useBookFileLoader()

const buffer = await loadFileAsBuffer(URL_WITH_MESSAGE, { cacheKey: CACHE_KEY, isCustomMessageEnabled: true })
expect(new Uint8Array(buffer!)).toEqual(new Uint8Array([9]))
expect(fetchMock).not.toHaveBeenCalled()
expect(isLoadedFromCache.value).toBe(true)
})

it('keeps reading an old cached copy of a book without a custom message page', async () => {
const url = 'https://api.example.com/ebook-cors/?class_id=0xabc&nft_id=7&index=0&custom_message=0'
const cacheKey = '3ook-book-file-0xabc-7-0-0'
const cacheStorage = stubCacheStorage()
cacheStorage.setEntry(cacheKey, url, legacyEntry)
const fetchMock = stubFetch()
const { loadFileAsBuffer } = useBookFileLoader()

const buffer = await loadFileAsBuffer(url, { cacheKey })
expect(new Uint8Array(buffer!)).toEqual(new Uint8Array([9]))
expect(fetchMock).not.toHaveBeenCalled()
})
})
Loading