Skip to content

Commit

Permalink
Add disk cache store (#949)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Alberto Fernández-Capel authored Sep 25, 2023
1 parent 3d30fe1 commit f86a376
Show file tree
Hide file tree
Showing 11 changed files with 286 additions and 61 deletions.
15 changes: 14 additions & 1 deletion src/core/cache.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { setMetaContent } from "../util"
import { SnapshotCache } from "./drive/snapshot_cache"

export class Cache {
constructor(session) {
this.session = session
}

clear() {
this.session.clearCache()
this.store.clear()
}

resetCacheControl() {
Expand All @@ -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)
}
Expand Down
64 changes: 64 additions & 0 deletions src/core/drive/cache_stores/disk_store.js
Original file line number Diff line number Diff line change
@@ -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
}
}
56 changes: 56 additions & 0 deletions src/core/drive/cache_stores/memory_store.js
Original file line number Diff line number Diff line change
@@ -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]
}
}
}
4 changes: 4 additions & 0 deletions src/core/drive/page_snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 5 additions & 1 deletion src/core/drive/page_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -32,6 +32,10 @@ export class PageView extends View {
return this.render(renderer)
}

setCacheStore(cacheName) {
SnapshotCache.setStore(cacheName)
}

clearSnapshotCache() {
this.snapshotCache.clear()
}
Expand Down
4 changes: 1 addition & 3 deletions src/core/drive/preloader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" } })
Expand Down
59 changes: 19 additions & 40 deletions src/core/drive/snapshot_cache.js
Original file line number Diff line number Diff line change
@@ -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()
}
}
8 changes: 4 additions & 4 deletions src/core/drive/visit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 () => {
Expand Down
49 changes: 49 additions & 0 deletions src/tests/fixtures/disk_cache.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html id="html" data-skip-event-details="turbo:submit-start turbo:submit-end">
<head>
<meta charset="utf-8">
<meta name="csp-nonce" content="123">
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<title>Turbo</title>
<script src="/dist/turbo.es2017-umd.js" data-turbo-track="reload"></script>
<script src="/src/tests/fixtures/test.js"></script>
<script>
Turbo.cache.store = "disk"

document.addEventListener("turbo:load", async () => {
await new Promise(resolve => setTimeout(resolve, 100))

const cachesList = document.getElementById("caches")

const cache = await caches.open("turbo-v1")
const keys = await cache.keys()
cachesList.innerHTML = keys.map(key => `<li>${key.url}</li>`).join("")

const clearCacheButton = document.getElementById("clear-cache")
clearCacheButton.addEventListener("click", async (event) => {
await Turbo.cache.clear()
cachesList.innerHTML = ""
})
})
</script>
</head>
<body>
<h1>Cached pages:</h1>
<ul id="caches"></ul>

<h3>Links:</h3>
<ul>
<li>
<a id="first-link" href="./disk_cache.html">First HTTP cached page</a>
</li>
<li>
<a id="second-link" href="./disk_cache.html?page=2">Second HTTP cached page</a>
</li>
<li>
<a id="third-link" href="./disk_cache.html?page=3">Third HTTP cached page</a>
</li>
</ul>

<button id="clear-cache">Clear cache</button>
</body>
</html>
Loading

0 comments on commit f86a376

Please sign in to comment.