Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
65ae7a7
Introduce basic offline support via service workers
rosa Jul 29, 2025
ff59b46
Rename KeyValueStoreWithTimestamp to CacheRegistry
rosa Aug 1, 2025
7d884ef
Extract rules and cache-first strategy to their own classes
rosa Aug 1, 2025
6caa07b
Use new rules approach in service worker
rosa Aug 1, 2025
0740865
Implement network-first handler
rosa Aug 1, 2025
f1d277e
Implement stale-while-revalidate handler
rosa Aug 1, 2025
f17f686
Don't cache responses with status == 0 on cache first strategy
rosa Aug 1, 2025
00cfbf8
Use node-resolve plugin for turbo-offline bundle
rosa Aug 4, 2025
bdbba41
Expose different handlers for use with rules
rosa Aug 5, 2025
d5185d6
Move `getCookie` to `utils` module and add `setCookie` there as well
rosa Aug 5, 2025
9509adc
Implement custom `turbo-offline` element to configure offline mode
rosa Aug 5, 2025
69c1233
Upgrade all `<turbo-offline>` elements in the page after registering
rosa Aug 6, 2025
4330f82
Add alternative approach to setting up offline mode on the app side
rosa Aug 7, 2025
a6bcb0c
Remove custom `<turbo-offline>` element
rosa Aug 7, 2025
2ee7339
Tidy up service worker's API to be used from the app's service worker
rosa Aug 7, 2025
09640ae
Don't allow configuring the DB name, version and store for cache regi…
rosa Aug 7, 2025
048d537
Store cacheName in the cache registry and query by it and timestamp
rosa Aug 7, 2025
cd1e0c7
Use type: "module" by default for service worker
rosa Aug 7, 2025
8e14ef9
Fix service worker registration parameters
rosa Aug 7, 2025
02bd1f2
Add `delete` operation to cache registry
rosa Aug 7, 2025
96c7c70
Implement cache trimming by maxAge
rosa Aug 7, 2025
c008b7d
Provide UMD build together with the ESM build
rosa Aug 8, 2025
fb18168
Set `Service-Worker-Allowed: /` for sw requests in test server
rosa Aug 10, 2025
aea4c4c
Implement tests for all caching strategies
rosa Aug 10, 2025
fc13f3a
Test different types of matchers and rules combined
rosa Aug 10, 2025
1c0b231
Test cache trimming with very short-lived cache
rosa Aug 10, 2025
d5848b6
Remove unused `offline/config` class
rosa Aug 11, 2025
a9e565d
Fix Linter issues
rosa Aug 11, 2025
8c88878
Switch to `type: classic` as defalt for service workers
rosa Aug 11, 2025
13a4848
DRY-up tests a little bit and tidy them
rosa Aug 12, 2025
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
24 changes: 24 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@ module.exports = {
parserOptions: {
sourceType: "script"
}
},
{
env: {
serviceworker: true
},
files: ["src/tests/fixtures/service_workers/*.js"],
parserOptions: {
sourceType: "script"
},
globals: {
TurboOffline: true
}
},
{
env: {
serviceworker: true
},
files: ["src/tests/fixtures/service_workers/module.js"],
parserOptions: {
sourceType: "module"
},
globals: {
TurboOffline: true
}
}
],
parserOptions: {
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
"dist/*.js",
"dist/*.js.map"
],
"exports": {
".": {
"import": "./dist/turbo.es2017-esm.js",
"require": "./dist/turbo.es2017-umd.js"
},
"./offline": {
"import": "./dist/turbo-offline.js",
"require": "./dist/turbo-offline-umd.js"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/hotwired/turbo.git"
Expand Down
20 changes: 20 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,25 @@ export default [
watch: {
include: "src/**"
}
},
{
input: "src/offline/index.js",
output: [
{
file: "dist/turbo-offline.js",
format: "es",
banner
},
{
name: "TurboOffline",
file: "dist/turbo-offline-umd.js",
format: "umd",
banner
}
],
plugins: [resolve()],
watch: {
include: "src/offline/**"
}
}
]
15 changes: 2 additions & 13 deletions src/core/drive/form_submission.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FetchRequest, FetchMethod, fetchMethodFromString, fetchEnctypeFromString, isSafe } from "../../http/fetch_request"
import { expandURL } from "../url"
import { clearBusyState, dispatch, getAttribute, getMetaContent, hasAttribute, markAsBusy } from "../../util"
import { clearBusyState, dispatch, getAttribute, getMetaContent, hasAttribute, markAsBusy, getCookie } from "../../util"
import { StreamMessage } from "../streams/stream_message"
import { prefetchCache } from "./prefetch_cache"
import { config } from "../config"
Expand Down Expand Up @@ -108,7 +108,7 @@ export class FormSubmission {

prepareRequest(request) {
if (!request.isSafe) {
const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token")
const token = getCookie(getMetaContent("csrf-param")) || getMetaContent("csrf-token")
if (token) {
request.headers["X-CSRF-Token"] = token
}
Expand Down Expand Up @@ -228,17 +228,6 @@ function buildFormData(formElement, submitter) {
return formData
}

function getCookieValue(cookieName) {
if (cookieName != null) {
const cookies = document.cookie ? document.cookie.split("; ") : []
const cookie = cookies.find((cookie) => cookie.startsWith(cookieName))
if (cookie) {
const value = cookie.split("=").slice(1).join("=")
return value ? decodeURIComponent(value) : undefined
}
}
}

function responseSucceededWithoutRedirect(response) {
return response.statusCode == 200 && !response.redirected
}
Expand Down
5 changes: 4 additions & 1 deletion src/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import { PageSnapshot } from "./drive/page_snapshot"
import { FrameRenderer } from "./frames/frame_renderer"
import { fetch, recentRequests } from "../http/fetch"
import { config } from "./config"

import { MorphingPageRenderer } from "./drive/morphing_page_renderer"
import { MorphingFrameRenderer } from "./frames/morphing_frame_renderer"

export { morphChildren, morphElements } from "./morphing"

import { offline } from "./offline"

const session = new Session(recentRequests)
const { cache, navigator } = session
export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer, fetch, config }
export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer, fetch, config, offline }

/**
* Starts the main session.
Expand Down
60 changes: 60 additions & 0 deletions src/core/offline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { urlsAreEqual } from "./url"
import { setCookie } from "../util"

class Offline {
serviceWorker

async start(url = "/service-worker.js", { scope = "/", type = "classic", native = true } = {}) {
if (!("serviceWorker" in navigator)) {
console.warn("Service Worker not available.")
return
}

if (native) this.#setUserAgentCookie()

await this.#domReady()

// Check if there's already a service worker registered for a different location
this.#checkExistingController(navigator.serviceWorker.controller, url)

try {
const registration = await navigator.serviceWorker.register(url, { scope, type })

// Check the registration result for any mismatches
const registered = registration.active || registration.waiting || registration.installing
this.#checkExistingController(registered, url)

this.serviceWorker = registered
return registration
} catch(error) {
console.error(error)
}
}

#setUserAgentCookie() {
// Cookie for Hotwire Native's overridden UA
const oneYear = 365 * 24 * 60 * 60 * 1000
setCookie("x_user_agent", window.navigator.userAgent, oneYear)
}

#checkExistingController(controller, url) {
if (controller && !urlsAreEqual(controller.scriptURL, url)) {
console.warn(
`Expected service worker script ${url} but found ${controller.scriptURL}. ` +
`This may indicate multiple service workers or a cached version.`
)
}
}

#domReady() {
return new Promise((resolve) => {
if (document.readyState !== "complete") {
document.addEventListener("DOMContentLoaded", () => resolve())
} else {
resolve()
}
})
}
}

export const offline = new Offline()
134 changes: 134 additions & 0 deletions src/offline/cache_registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
const DATABASE_NAME = "turbo-offline-database"
const DATABASE_VERSION = 1
const STORE_NAME = "cache-registry"

class CacheRegistryDatabase {
get(cacheName, key) {
const getOp = (store) => this.#requestToPromise(store.get(key))
return this.#performOperation(STORE_NAME, getOp, "readonly")
}

has(cacheName, key) {
const countOp = (store) => this.#requestToPromise(store.count(key))
return this.#performOperation(STORE_NAME, countOp, "readonly").then((result) => result === 1)
}

put(cacheName, key, value) {
const putOp = (store) => {
const item = { key: key, cacheName: cacheName, timestamp: Date.now(), ...value }
store.put(item)
return this.#requestToPromise(store.transaction)
}

return this.#performOperation(STORE_NAME, putOp, "readwrite")
}

getTimestamp(cacheName, key) {
return this.get(cacheName, key).then((result) => result?.timestamp)
}

getOlderThan(cacheName, timestamp) {
const getOlderThanOp = (store) => {
const index = store.index("cacheNameAndTimestamp")
// Use compound key range: [cacheName, timestamp]
const range = IDBKeyRange.bound(
[cacheName, 0], // start of range
[cacheName, timestamp], // end of range
false, // lowerOpen: include lower bound
true // upperOpen: exclude upper bound
)
const cursorRequest = index.openCursor(range)

return this.#cursorRequestToPromise(cursorRequest)
}
return this.#performOperation(STORE_NAME, getOlderThanOp, "readonly")
}

delete(cacheName, key) {
const deleteOp = (store) => this.#requestToPromise(store.delete(key))
return this.#performOperation(STORE_NAME, deleteOp, "readwrite")
}

#performOperation(storeName, operation, mode) {
return this.#openDatabase().then((database) => {
const transaction = database.transaction(storeName, mode)
const store = transaction.objectStore(storeName)
return operation(store)
})
}

#openDatabase() {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION)
request.onupgradeneeded = () => {
const cacheMetadataStore = request.result.createObjectStore(STORE_NAME, { keyPath: "key" })
cacheMetadataStore.createIndex("cacheNameAndTimestamp", [ "cacheName", "timestamp" ])
}

return this.#requestToPromise(request)
}

#requestToPromise(request) {
return new Promise((resolve, reject) => {
request.oncomplete = request.onsuccess = () => resolve(request.result)
request.onabort = request.onerror = () => reject(request.error)
})
}

#cursorRequestToPromise(request) {
return new Promise((resolve, reject) => {
const results = []

request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor) {
results.push(cursor.value)
cursor.continue()
} else {
resolve(results)
}
}

request.onerror = () => reject(request.error)
})
}
}

let cacheRegistryDatabase = null

function getDatabase() {
if (!cacheRegistryDatabase) {
cacheRegistryDatabase = new CacheRegistryDatabase()
}
return cacheRegistryDatabase
}

export class CacheRegistry {
constructor(cacheName) {
this.cacheName = cacheName
this.database = getDatabase()
}

get(key) {
return this.database.get(this.cacheName, key)
}

has(key) {
return this.database.has(this.cacheName, key)
}

put(key, value = {}) {
return this.database.put(this.cacheName, key, value)
}

getTimestamp(key) {
return this.database.getTimestamp(this.cacheName, key)
}

getOlderThan(timestamp) {
return this.database.getOlderThan(this.cacheName, timestamp)
}

delete(key) {
return this.database.delete(this.cacheName, key)
}
}
65 changes: 65 additions & 0 deletions src/offline/cache_trimmer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export class CacheTrimmer {
#isRunning = false

constructor(cacheName, cacheRegistry, options = {}) {
this.cacheName = cacheName
this.cacheRegistry = cacheRegistry
this.options = options
}

async trim() {
if (this.#isRunning) {
return
}

if (!this.#shouldTrim()) {
return
}

this.#isRunning = true

try {
await this.deleteEntries()
} finally {
this.#isRunning = false
}
}

#shouldTrim() {
// For now, only check maxAge. To be extended for maxEntries, maxStorage, etc.
return this.options.maxAge && this.options.maxAge > 0
}

async deleteEntries() {
if (this.options.maxAge) {
await this.deleteEntriesByAge()
}
// To be extended with other options
}

async deleteEntriesByAge() {
const maxAgeMs = this.options.maxAge * 1000
const cutoffTimestamp = Date.now() - maxAgeMs

const expiredEntries = await this.cacheRegistry.getOlderThan(cutoffTimestamp)

if (expiredEntries.length === 0) {
return
}

console.debug(`Trimming ${expiredEntries.length} expired entries from cache "${this.cacheName}"`)

const cache = await caches.open(this.cacheName)

const deletePromises = expiredEntries.map(async (entry) => {
const cacheDeletePromise = cache.delete(entry.key)
const registryDeletePromise = this.cacheRegistry.delete(entry.key)

return Promise.all([cacheDeletePromise, registryDeletePromise])
})

await Promise.all(deletePromises)

console.debug(`Successfully trimmed ${expiredEntries.length} entries from cache "${this.cacheName}"`)
}
}
Loading