Skip to content

Commit

Permalink
Add a Browser cache store
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
broser cache store is now available.

The browser cache store uses the
[Cache API](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage)
to store and retrieve snapshotsfrom the browser's cache. This allows for
the snapshots to be persisted across different tabs, page reloads and
even browser restarts.

The browser cache store is not enabled by default. To enable it, you need
to set the `Turbo.cache.store` property to `"http-cache"`.

```js
Turbo.cache.store = "http-cache"
```

This is also a stepping stone to implement offline support with Service
Workers. With a Service Worker in place, and the Browser cache store enabled,
we can still serve cached snapshots even when the browser is offline.
  • Loading branch information
afcapel committed Jul 21, 2023
1 parent 151aca2 commit 8463af2
Show file tree
Hide file tree
Showing 12 changed files with 291 additions and 63 deletions.
16 changes: 15 additions & 1 deletion src/core/cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Session } from "./session"
import { setMetaContent } from "../util"
import { SnapshotCache } from "./drive/snapshot_cache"
import { CacheStore } from "./drive/cache_store"

export class Cache {
readonly session: Session
Expand All @@ -9,7 +11,7 @@ export class Cache {
}

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

resetCacheControl() {
Expand All @@ -24,6 +26,18 @@ export class Cache {
this.setCacheControl("no-preview")
}

set store(store: string | CacheStore) {
if (typeof store === "string") {
SnapshotCache.setStore(store)
} else {
SnapshotCache.currentStore = store
}
}

get store(): CacheStore {
return SnapshotCache.currentStore
}

private setCacheControl(value: string) {
setMetaContent("turbo-cache-control", value)
}
Expand Down
8 changes: 8 additions & 0 deletions src/core/drive/cache_store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { PageSnapshot } from "./page_snapshot"

export abstract class CacheStore {
abstract has(location: URL): Promise<boolean>
abstract get(location: URL): Promise<PageSnapshot | undefined>
abstract put(location: URL, snapshot: PageSnapshot): Promise<PageSnapshot>
abstract clear(): Promise<void>
}
54 changes: 54 additions & 0 deletions src/core/drive/cache_stores/browser_cache_store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { CacheStore } from "../cache_store"
import { PageSnapshot } from "../page_snapshot"

export class BrowserCacheStore extends CacheStore {
_version = "v1"
storage!: Cache

constructor() {
super()
this.initialize()
}

async initialize() {
this.storage = await caches.open(`turbo-${this.version}`)
}

async has(location: URL) {
return (await this.storage.match(location)) !== undefined
}

async get(location: URL) {
const response = await this.storage.match(location)
if (response && response.ok) {
const html = await response.text()
return PageSnapshot.fromHTMLString(html)
}
}

async put(location: URL, snapshot: PageSnapshot) {
const response = new Response(snapshot.html, {
status: 200,
statusText: "OK",
headers: {
"Content-Type": "text/html",
},
})
await this.storage.put(location, response)
return snapshot
}

async clear() {
const keys = await this.storage.keys()
await Promise.all(keys.map((key) => this.storage.delete(key)))
}

set version(value: string) {
this._version = value
this.initialize()
}

get version() {
return this._version
}
}
60 changes: 60 additions & 0 deletions src/core/drive/cache_stores/memory_store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { toCacheKey } from "../../url"
import { PageSnapshot } from "../page_snapshot"
import { CacheStore } from "../cache_store"

export class MemoryStore extends CacheStore {
readonly keys: string[] = []
readonly size: number
snapshots: { [url: string]: PageSnapshot } = {}

constructor(size: number) {
super()
this.size = size
}

async has(location: URL) {
return toCacheKey(location) in this.snapshots
}

async get(location: URL): Promise<PageSnapshot | undefined> {
if (await this.has(location)) {
const snapshot = this.read(location)
this.touch(location)
return snapshot
}
}

async put(location: URL, snapshot: PageSnapshot): Promise<PageSnapshot> {
this.write(location, snapshot)
this.touch(location)
return snapshot
}

async clear() {
this.snapshots = {}
}

// Private

read(location: URL) {
return this.snapshots[toCacheKey(location)]
}

write(location: URL, snapshot: PageSnapshot) {
this.snapshots[toCacheKey(location)] = snapshot
}

touch(location: URL) {
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.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export class PageSnapshot extends Snapshot<HTMLBodyElement> {
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.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface PageViewDelegate extends ViewDelegate<HTMLBodyElement, PageSnap
type PageViewRenderer = PageRenderer | ErrorRenderer

export class PageView extends View<HTMLBodyElement, PageSnapshot, PageViewRenderer, PageViewDelegate> {
readonly snapshotCache = new SnapshotCache(10)
readonly snapshotCache = new SnapshotCache()
lastRenderedLocation = new URL(location.href)
forceReloaded = false

Expand All @@ -39,6 +39,10 @@ export class PageView extends View<HTMLBodyElement, PageSnapshot, PageViewRender
return this.render(renderer)
}

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

clearSnapshotCache() {
this.snapshotCache.clear()
}
Expand Down
4 changes: 1 addition & 3 deletions src/core/drive/preloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@ export class Preloader {
async preloadURL(link: HTMLAnchorElement) {
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: { "VND.PREFETCH": "true", Accept: "text/html" } })
Expand Down
63 changes: 21 additions & 42 deletions src/core/drive/snapshot_cache.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,37 @@
import { toCacheKey } from "../url"
import { PageSnapshot } from "./page_snapshot"
import { CacheStore } from "./cache_store"
import { BrowserCacheStore } from "./cache_stores/browser_cache_store"
import { MemoryStore } from "./cache_stores/memory_store"

export class SnapshotCache {
readonly keys: string[] = []
readonly size: number
snapshots: { [url: string]: PageSnapshot } = {}

constructor(size: number) {
this.size = size
static currentStore: CacheStore = new MemoryStore(10)

static setStore(storeName: string) {
switch (storeName) {
case "memory":
SnapshotCache.currentStore = new MemoryStore(10)
break
case "browser-cache":
SnapshotCache.currentStore = new BrowserCacheStore()
break
default:
throw new Error(`Invalid store name: ${storeName}`)
}
}

has(location: URL) {
return toCacheKey(location) in this.snapshots
return SnapshotCache.currentStore.has(location)
}

get(location: URL): PageSnapshot | undefined {
if (this.has(location)) {
const snapshot = this.read(location)
this.touch(location)
return snapshot
}
get(location: URL) {
return SnapshotCache.currentStore.get(location)
}

put(location: URL, snapshot: PageSnapshot) {
this.write(location, snapshot)
this.touch(location)
return snapshot
return SnapshotCache.currentStore.put(location, snapshot)
}

clear() {
this.snapshots = {}
}

// Private

read(location: URL) {
return this.snapshots[toCacheKey(location)]
}

write(location: URL, snapshot: PageSnapshot) {
this.snapshots[toCacheKey(location)] = snapshot
}

touch(location: URL) {
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.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,8 @@ export class Visit implements FetchRequestDelegate {
}
}

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 @@ -291,8 +291,8 @@ export class Visit implements FetchRequestDelegate {
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/browser_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 = "browser-cache"

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="./browser_cache.html">First HTTP cached page</a>
</li>
<li>
<a id="second-link" href="./browser_cache.html?page=2">Second HTTP cached page</a>
</li>
<li>
<a id="third-link" href="./browser_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 8463af2

Please sign in to comment.