-
Notifications
You must be signed in to change notification settings - Fork 477
Add support for (basic, cached on-visit) offline access using service workers #1427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6b12449
238e518
14961b0
86f86f6
001d0a1
c3cc016
0687b10
91bfbed
9237a8f
c002a8c
61fe34f
df73352
f9d9e17
b1943c2
b1517b4
4aa8eb3
4fcbd09
c2f5140
19cfbff
89e367c
272807e
60c8e36
dcb65b3
f01958f
6ac36b0
7b4ccbf
9b4b412
7507fab
6153e7d
e9db7dc
7490327
1c4b67a
c83c52d
321c6c9
ee30d1b
9754d45
9f93fa9
fd50338
4acdf50
47b314a
48f58fc
ae0f4c9
a61f8ca
e5ed303
989a15b
fceb767
d6c46b9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| const session = new Session(recentRequests) | ||
|
|
||
|
|
||
| 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
|
||
| } | ||
| } | ||
|
|
||
| export const offline = new Offline() | ||
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding an
exportsmap 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).