Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
6b12449
Introduce basic offline support via service workers
rosa Jul 29, 2025
238e518
Rename KeyValueStoreWithTimestamp to CacheRegistry
rosa Aug 1, 2025
14961b0
Extract rules and cache-first strategy to their own classes
rosa Aug 1, 2025
86f86f6
Use new rules approach in service worker
rosa Aug 1, 2025
001d0a1
Implement network-first handler
rosa Aug 1, 2025
c3cc016
Implement stale-while-revalidate handler
rosa Aug 1, 2025
0687b10
Don't cache responses with status == 0 on cache first strategy
rosa Aug 1, 2025
91bfbed
Use node-resolve plugin for turbo-offline bundle
rosa Aug 4, 2025
9237a8f
Expose different handlers for use with rules
rosa Aug 5, 2025
c002a8c
Move `getCookie` to `utils` module and add `setCookie` there as well
rosa Aug 5, 2025
61fe34f
Implement custom `turbo-offline` element to configure offline mode
rosa Aug 5, 2025
df73352
Upgrade all `<turbo-offline>` elements in the page after registering
rosa Aug 6, 2025
f9d9e17
Add alternative approach to setting up offline mode on the app side
rosa Aug 7, 2025
b1943c2
Remove custom `<turbo-offline>` element
rosa Aug 7, 2025
b1517b4
Tidy up service worker's API to be used from the app's service worker
rosa Aug 7, 2025
4aa8eb3
Don't allow configuring the DB name, version and store for cache regi…
rosa Aug 7, 2025
4fcbd09
Store cacheName in the cache registry and query by it and timestamp
rosa Aug 7, 2025
c2f5140
Use type: "module" by default for service worker
rosa Aug 7, 2025
19cfbff
Fix service worker registration parameters
rosa Aug 7, 2025
89e367c
Add `delete` operation to cache registry
rosa Aug 7, 2025
272807e
Implement cache trimming by maxAge
rosa Aug 7, 2025
60c8e36
Provide UMD build together with the ESM build
rosa Aug 8, 2025
dcb65b3
Set `Service-Worker-Allowed: /` for sw requests in test server
rosa Aug 10, 2025
f01958f
Implement tests for all caching strategies
rosa Aug 10, 2025
6ac36b0
Test different types of matchers and rules combined
rosa Aug 10, 2025
7b4ccbf
Test cache trimming with very short-lived cache
rosa Aug 10, 2025
9b4b412
Remove unused `offline/config` class
rosa Aug 11, 2025
7507fab
Fix Linter issues
rosa Aug 11, 2025
6153e7d
Switch to `type: classic` as defalt for service workers
rosa Aug 11, 2025
e9db7dc
DRY-up tests a little bit and tidy them
rosa Aug 12, 2025
7490327
Replace chai assertions with Playwright's built-in expect
rosa Jan 16, 2026
1c4b67a
Add except parameter to Rule for excluding requests
rosa Jan 16, 2026
c83c52d
Remove unused cacheName parameter from CacheRegistryDatabase methods
rosa Jan 16, 2026
321c6c9
Handle QuotaExceededError by clearing all caches and IndexedDB
rosa Jan 16, 2026
ee30d1b
Add clearCache() method to ServiceWorker for clearing offline storage
rosa Jan 27, 2026
9754d45
Fix getIndexedDBEntryCount hanging in Chrome after database deletion
rosa Jan 28, 2026
9f93fa9
Add fetchOptions support to offline handlers
rosa Jan 28, 2026
fd50338
Add Range request support for cached audio/video streaming
rosa Jan 29, 2026
4acdf50
Skip Range handling for opaque responses
rosa Jan 29, 2026
47b314a
Fix networkFirst handler re-caching responses from cache
rosa Feb 1, 2026
48f58fc
Add cache size and entry limiting options
rosa Feb 4, 2026
ae0f4c9
Remove maxSize option in favor of maxEntries + maxEntrySize
rosa Feb 4, 2026
a61f8ca
Refactor cache limiting a bit
rosa Feb 5, 2026
e5ed303
Add preload option to cache resources loaded before service worker ac…
rosa Feb 6, 2026
989a15b
Expose clearCache() on Turbo.offline
rosa Feb 20, 2026
fceb767
Improve opaque response warning to suggest CORS fetch mode
rosa Feb 20, 2026
d6c46b9
Use request URL instead of request object in fetchFromNetwork
rosa Feb 20, 2026
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"
}
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding an exports map restricts what subpaths consumers can import. With this change, deep imports like @hotwired/turbo/dist/... (previously allowed by Node resolution) will fail unless explicitly exported. If deep imports are considered supported, add appropriate subpath patterns (or document the breaking change).

Suggested change
}
},
"./dist/*": "./dist/*"

Copilot uses AI. Check for mistakes.
},
"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,11 +4,14 @@ 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"

import { offline } from "./offline"

export { morphChildren, morphElements } from "./morphing"
export { PageRenderer, PageSnapshot, FrameRenderer, fetch, config }
export { PageRenderer, PageSnapshot, FrameRenderer, fetch, config, offline }

Comment on lines +11 to 15
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

offline is now exported from the core entrypoint, but there isn't a corresponding unit assertion in the existing export/interface tests. Consider extending the interface/export tests to cover the new Turbo.offline API surface so accidental export regressions are caught.

Copilot uses AI. Check for mistakes.
const session = new Session(recentRequests)

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

class Offline {
serviceWorker

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

if (native) this.#setUserAgentCookie()

await this.#domReady()

// Preload resources loaded into the page before the service worker controls it
// as they might not go through the service worker later because they might be
// cached by the browser
const needsPreloading = preload && !navigator.serviceWorker.controller

// 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

if (needsPreloading) {
this.#preloadWhenReady(preload)
}

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.`
)
}
}

async clearCache() {
const registration = await navigator.serviceWorker?.ready
registration?.active?.postMessage({ action: "clearCache" })
}

#preloadWhenReady(pattern) {
navigator.serviceWorker.addEventListener("controllerchange", () => {
this.#preloadResources(pattern)
}, { once: true })
}

#preloadResources(pattern) {
const urls = performance.getEntriesByType("resource")
.map(entry => entry.name)
.filter(url => pattern.test(url))

navigator.serviceWorker.controller.postMessage({
action: "preloadResources",
params: { urls }
})
}

#domReady() {
return new Promise((resolve) => {
if (document.readyState !== "complete") {
document.addEventListener("DOMContentLoaded", () => resolve())
} else {
resolve()
}
})
Comment on lines +81 to +88
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#domReady() can hang forever when called after DOMContentLoaded has already fired but document.readyState is still "interactive" (it adds a DOMContentLoaded listener that will never run). Use document.readyState === "loading" as the condition, or resolve immediately for both "interactive" and "complete".

Copilot uses AI. Check for mistakes.
}
}

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

export function deleteCacheRegistries() {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(DATABASE_NAME)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
}).then(() => {
cacheRegistryDatabase = null
})
}

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

has(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(key) {
return this.get(key).then((result) => result?.timestamp)
}

getOlderThan(cacheName, timestamp) {
const getOlderThanOp = (store) => {
const index = store.index("cacheNameAndTimestamp")
const cursorRequest = index.openCursor(this.#getTimestampRange(cacheName, timestamp))

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

getEntryCount(cacheName) {
const countOp = (store) => {
const index = store.index("cacheNameAndTimestamp")
const range = this.#getTimestampRange(cacheName)

return this.#requestToPromise(index.count(range))
}
return this.#performOperation(STORE_NAME, countOp, "readonly")
}

getOldestEntries(cacheName, limit) {
const getOldestOp = (store) => {
const index = store.index("cacheNameAndTimestamp")
const cursorRequest = index.openCursor(this.#getTimestampRange(cacheName))

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

delete(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, limit = Infinity) {
return new Promise((resolve, reject) => {
const results = []

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

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

#getTimestampRange(cacheName, upperBound = Infinity) {
// Use compound key range: [cacheName, timestamp]
return IDBKeyRange.bound(
[cacheName, 0], // start of range
[cacheName, upperBound], // end of range
false, // lowerOpen: include lower bound
true // upperOpen: exclude upper bound
)
}
}

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(key)
}

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

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

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

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

getEntryCount() {
return this.database.getEntryCount(this.cacheName)
}

getOldestEntries(limit) {
return this.database.getOldestEntries(this.cacheName, limit)
}

delete(key) {
return this.database.delete(key)
}
}
Loading