From f86a376b0d088c8ff83ef8180b95d492527902e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Mon, 25 Sep 2023 15:13:36 +0100 Subject: [PATCH] Add disk cache store (#949) This commit extends the Turbo cache API to allow for the use of different cache stores. The default store is still the in-memory store, but a new persistent cache store is now available. The disk store uses the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage) to store and retrieve snapshots from the browser's cache. This allows for the snapshots to be persisted across different tabs, page reloads and even browser restarts. The disk store is not enabled by default. To enable it, you need to set the `Turbo.cache.store` property to `"disk"`. ```js Turbo.cache.store = "disk" ``` This is also a stepping stone to implement offline support with Service Workers. With a Service Worker in place, and the disk cache store enabled, we can still serve cached snapshots even when the browser is offline. --- src/core/cache.js | 15 ++++- src/core/drive/cache_stores/disk_store.js | 64 +++++++++++++++++++++ src/core/drive/cache_stores/memory_store.js | 56 ++++++++++++++++++ src/core/drive/page_snapshot.js | 4 ++ src/core/drive/page_view.js | 6 +- src/core/drive/preloader.js | 4 +- src/core/drive/snapshot_cache.js | 59 ++++++------------- src/core/drive/visit.js | 8 +-- src/tests/fixtures/disk_cache.html | 49 ++++++++++++++++ src/tests/functional/disk_cache_tests.js | 58 +++++++++++++++++++ src/tests/functional/preloader_tests.js | 24 ++++---- 11 files changed, 286 insertions(+), 61 deletions(-) create mode 100644 src/core/drive/cache_stores/disk_store.js create mode 100644 src/core/drive/cache_stores/memory_store.js create mode 100644 src/tests/fixtures/disk_cache.html create mode 100644 src/tests/functional/disk_cache_tests.js diff --git a/src/core/cache.js b/src/core/cache.js index 2c163e6f4..c88bdcd29 100644 --- a/src/core/cache.js +++ b/src/core/cache.js @@ -1,4 +1,5 @@ import { setMetaContent } from "../util" +import { SnapshotCache } from "./drive/snapshot_cache" export class Cache { constructor(session) { @@ -6,7 +7,7 @@ export class Cache { } clear() { - this.session.clearCache() + this.store.clear() } resetCacheControl() { @@ -21,6 +22,18 @@ export class Cache { this.#setCacheControl("no-preview") } + set store(store) { + if (typeof store === "string") { + SnapshotCache.setStore(store) + } else { + SnapshotCache.currentStore = store + } + } + + get store() { + return SnapshotCache.currentStore + } + #setCacheControl(value) { setMetaContent("turbo-cache-control", value) } diff --git a/src/core/drive/cache_stores/disk_store.js b/src/core/drive/cache_stores/disk_store.js new file mode 100644 index 000000000..285caa5d9 --- /dev/null +++ b/src/core/drive/cache_stores/disk_store.js @@ -0,0 +1,64 @@ +import { PageSnapshot } from "../page_snapshot" + +export class DiskStore { + _version = "v1" + + constructor() { + if (typeof caches === "undefined") { + throw new Error("windows.caches is undefined. CacheStore requires a secure context.") + } + + this.storage = this.openStorage() + } + + async has(location) { + const storage = await this.openStorage() + return (await storage.match(location)) !== undefined + } + + async get(location) { + const storage = await this.openStorage() + const response = await storage.match(location) + + if (response && response.ok) { + const html = await response.text() + return PageSnapshot.fromHTMLString(html) + } + } + + async put(location, snapshot) { + const storage = await this.openStorage() + + const response = new Response(snapshot.html, { + status: 200, + statusText: "OK", + headers: { + "Content-Type": "text/html" + } + }) + await storage.put(location, response) + return snapshot + } + + async clear() { + const storage = await this.openStorage() + const keys = await storage.keys() + await Promise.all(keys.map((key) => storage.delete(key))) + } + + openStorage() { + this.storage ||= caches.open(`turbo-${this.version}`) + return this.storage + } + + set version(value) { + if (value !== this._version) { + this._version = value + this.storage ||= caches.open(`turbo-${this.version}`) + } + } + + get version() { + return this._version + } +} diff --git a/src/core/drive/cache_stores/memory_store.js b/src/core/drive/cache_stores/memory_store.js new file mode 100644 index 000000000..3ec8ae0b1 --- /dev/null +++ b/src/core/drive/cache_stores/memory_store.js @@ -0,0 +1,56 @@ +import { toCacheKey } from "../../url" + +export class MemoryStore { + keys = [] + snapshots = {} + + constructor(size) { + this.size = size + } + + async has(location) { + return toCacheKey(location) in this.snapshots + } + + async get(location) { + if (await this.has(location)) { + const snapshot = this.read(location) + this.touch(location) + return snapshot + } + } + + async put(location, snapshot) { + this.write(location, snapshot) + this.touch(location) + return snapshot + } + + async clear() { + this.snapshots = {} + } + + // Private + + read(location) { + return this.snapshots[toCacheKey(location)] + } + + write(location, snapshot) { + this.snapshots[toCacheKey(location)] = snapshot + } + + touch(location) { + const key = toCacheKey(location) + const index = this.keys.indexOf(key) + if (index > -1) this.keys.splice(index, 1) + this.keys.unshift(key) + this.trim() + } + + trim() { + for (const key of this.keys.splice(this.size)) { + delete this.snapshots[key] + } + } +} diff --git a/src/core/drive/page_snapshot.js b/src/core/drive/page_snapshot.js index 575cb25eb..47488d785 100644 --- a/src/core/drive/page_snapshot.js +++ b/src/core/drive/page_snapshot.js @@ -40,6 +40,10 @@ export class PageSnapshot extends Snapshot { return new PageSnapshot(clonedElement, this.headSnapshot) } + get html() { + return `${this.headElement.outerHTML}\n\n${this.element.outerHTML}` + } + get headElement() { return this.headSnapshot.element } diff --git a/src/core/drive/page_view.js b/src/core/drive/page_view.js index 1b95bb1d1..09a7299c6 100644 --- a/src/core/drive/page_view.js +++ b/src/core/drive/page_view.js @@ -6,7 +6,7 @@ import { PageSnapshot } from "./page_snapshot" import { SnapshotCache } from "./snapshot_cache" export class PageView extends View { - snapshotCache = new SnapshotCache(10) + snapshotCache = new SnapshotCache() lastRenderedLocation = new URL(location.href) forceReloaded = false @@ -32,6 +32,10 @@ export class PageView extends View { return this.render(renderer) } + setCacheStore(cacheName) { + SnapshotCache.setStore(cacheName) + } + clearSnapshotCache() { this.snapshotCache.clear() } diff --git a/src/core/drive/preloader.js b/src/core/drive/preloader.js index 23871a530..b971cea19 100644 --- a/src/core/drive/preloader.js +++ b/src/core/drive/preloader.js @@ -30,9 +30,7 @@ export class Preloader { async preloadURL(link) { const location = new URL(link.href) - if (this.snapshotCache.has(location)) { - return - } + if (await this.snapshotCache.has(location)) return try { const response = await fetch(location.toString(), { headers: { "Sec-Purpose": "prefetch", Accept: "text/html" } }) diff --git a/src/core/drive/snapshot_cache.js b/src/core/drive/snapshot_cache.js index 6ed37e8fd..8e8c53c02 100644 --- a/src/core/drive/snapshot_cache.js +++ b/src/core/drive/snapshot_cache.js @@ -1,56 +1,35 @@ -import { toCacheKey } from "../url" +import { DiskStore } from "./cache_stores/disk_store" +import { MemoryStore } from "./cache_stores/memory_store" export class SnapshotCache { - keys = [] - snapshots = {} - - constructor(size) { - this.size = size + static currentStore = new MemoryStore(10) + + static setStore(storeName) { + switch (storeName) { + case "memory": + SnapshotCache.currentStore = new MemoryStore(10) + break + case "disk": + SnapshotCache.currentStore = new DiskStore() + break + default: + throw new Error(`Invalid store name: ${storeName}`) + } } has(location) { - return toCacheKey(location) in this.snapshots + return SnapshotCache.currentStore.has(location) } get(location) { - if (this.has(location)) { - const snapshot = this.read(location) - this.touch(location) - return snapshot - } + return SnapshotCache.currentStore.get(location) } put(location, snapshot) { - this.write(location, snapshot) - this.touch(location) - return snapshot + return SnapshotCache.currentStore.put(location, snapshot) } clear() { - this.snapshots = {} - } - - // Private - - read(location) { - return this.snapshots[toCacheKey(location)] - } - - write(location, snapshot) { - this.snapshots[toCacheKey(location)] = snapshot - } - - touch(location) { - const key = toCacheKey(location) - const index = this.keys.indexOf(key) - if (index > -1) this.keys.splice(index, 1) - this.keys.unshift(key) - this.trim() - } - - trim() { - for (const key of this.keys.splice(this.size)) { - delete this.snapshots[key] - } + return SnapshotCache.currentStore.clear() } } diff --git a/src/core/drive/visit.js b/src/core/drive/visit.js index 7fc494c19..d1929b03e 100644 --- a/src/core/drive/visit.js +++ b/src/core/drive/visit.js @@ -215,8 +215,8 @@ export class Visit { } } - getCachedSnapshot() { - const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot() + async getCachedSnapshot() { + const snapshot = (await this.view.getCachedSnapshotForLocation(this.location)) || this.getPreloadedSnapshot() if (snapshot && (!getAnchor(this.location) || snapshot.hasAnchor(getAnchor(this.location)))) { if (this.action == "restore" || snapshot.isPreviewable) { @@ -235,8 +235,8 @@ export class Visit { return this.getCachedSnapshot() != null } - loadCachedSnapshot() { - const snapshot = this.getCachedSnapshot() + async loadCachedSnapshot() { + const snapshot = await this.getCachedSnapshot() if (snapshot) { const isPreview = this.shouldIssueRequest() this.render(async () => { diff --git a/src/tests/fixtures/disk_cache.html b/src/tests/fixtures/disk_cache.html new file mode 100644 index 000000000..f8ba78a64 --- /dev/null +++ b/src/tests/fixtures/disk_cache.html @@ -0,0 +1,49 @@ + + + + + + + Turbo + + + + + +

Cached pages:

+ + +

Links:

+ + + + + diff --git a/src/tests/functional/disk_cache_tests.js b/src/tests/functional/disk_cache_tests.js new file mode 100644 index 000000000..ca9bd6ec4 --- /dev/null +++ b/src/tests/functional/disk_cache_tests.js @@ -0,0 +1,58 @@ +import { test, expect } from "@playwright/test" +import { nextBody } from "../helpers/page" + +const path = "/src/tests/fixtures/disk_cache.html" + +test.beforeEach(async ({ page }) => { + await page.goto(path) +}) + +test("stores pages in the disk cache", async ({ page }) => { + await assertCachedURLs(page, []) + + page.click("#second-link") + await nextBody(page) + + await assertCachedURLs(page, ["http://localhost:9000/src/tests/fixtures/disk_cache.html"]) + + page.click("#third-link") + await nextBody(page) + + await assertCachedURLs(page, [ + "http://localhost:9000/src/tests/fixtures/disk_cache.html", + "http://localhost:9000/src/tests/fixtures/disk_cache.html?page=2" + ]) + + // Cache persists across reloads + await page.reload() + + await assertCachedURLs(page, [ + "http://localhost:9000/src/tests/fixtures/disk_cache.html", + "http://localhost:9000/src/tests/fixtures/disk_cache.html?page=2" + ]) +}) + +test("can clear the disk cache", async ({ page }) => { + page.click("#second-link") + await nextBody(page) + + await assertCachedURLs(page, ["http://localhost:9000/src/tests/fixtures/disk_cache.html"]) + + page.click("#clear-cache") + await assertCachedURLs(page, []) + + await page.reload() + await assertCachedURLs(page, []) +}) + +const assertCachedURLs = async (page, urls) => { + if (urls.length == 0) { + await expect(page.locator("#caches")).toBeEmpty() + } else { + await Promise.all( + urls.map((url) => { + return expect(page.locator("#caches")).toContainText(url) + }) + ) + } +} diff --git a/src/tests/functional/preloader_tests.js b/src/tests/functional/preloader_tests.js index 3faac3dfd..ecd7ca619 100644 --- a/src/tests/functional/preloader_tests.js +++ b/src/tests/functional/preloader_tests.js @@ -8,11 +8,11 @@ test("test preloads snapshot on initial load", async ({ page }) => { await nextBeat() assert.ok( - await page.evaluate(() => { - const preloadedUrl = "http://localhost:9000/src/tests/fixtures/preloaded.html" - const cache = window.Turbo.session.preloader.snapshotCache.snapshots + await page.evaluate(async () => { + const preloadedUrl = new URL("http://localhost:9000/src/tests/fixtures/preloaded.html") + const cache = window.Turbo.session.preloader.snapshotCache - return preloadedUrl in cache + return await cache.has(preloadedUrl) }) ) }) @@ -27,11 +27,11 @@ test("test preloads snapshot on page visit", async ({ page }) => { await nextBeat() assert.ok( - await page.evaluate(() => { - const preloadedUrl = "http://localhost:9000/src/tests/fixtures/preloaded.html" - const cache = window.Turbo.session.preloader.snapshotCache.snapshots + await page.evaluate(async () => { + const preloadedUrl = new URL("http://localhost:9000/src/tests/fixtures/preloaded.html") + const cache = window.Turbo.session.preloader.snapshotCache - return preloadedUrl in cache + return await cache.has(preloadedUrl) }) ) }) @@ -43,11 +43,11 @@ test("test navigates to preloaded snapshot from frame", async ({ page }) => { await nextBeat() assert.ok( - await page.evaluate(() => { - const preloadedUrl = "http://localhost:9000/src/tests/fixtures/preloaded.html" - const cache = window.Turbo.session.preloader.snapshotCache.snapshots + await page.evaluate(async () => { + const preloadedUrl = new URL("http://localhost:9000/src/tests/fixtures/preloaded.html") + const cache = window.Turbo.session.preloader.snapshotCache - return preloadedUrl in cache + return await cache.has(preloadedUrl) }) ) })