Skip to content

Commit

Permalink
feat: allow explicit sw de-registering + timebomb (#100)
Browse files Browse the repository at this point in the history
* feat: allow explicit sw de-registering

* feat: create a generic indexDb wrapper

* feat: service-workers implode after 24hrs

* fix: close DB after calling loadConfigFromLocalStorage

* chore: remove registration.update() calls on sw

* chore: dont throw error on db.close if already closed

* tmp: do not prefetch content

* fix: sw handling of request checks
  • Loading branch information
SgtPooki authored Mar 14, 2024
1 parent 47b8af4 commit 8ec199c
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 82 deletions.
2 changes: 1 addition & 1 deletion src/components/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default (): JSX.Element | null => {
// we get the iframe origin from a query parameter called 'origin', if this is loaded in an iframe
const targetOrigin = decodeURIComponent(window.location.hash.split('@origin=')[1])
const config = await getConfig()
trace('config-page: postMessage config to origin ', config, origin)
trace('config-page: postMessage config to origin ', config, targetOrigin)
/**
* The reload page in the parent window is listening for this message, and then it passes a RELOAD_CONFIG message to the service worker
*/
Expand Down
19 changes: 13 additions & 6 deletions src/context/service-worker-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
* 1. The page being loaded using some /ip[fn]s/<path> url, but subdomain isolation is supported, so we need to redirect to the isolated origin
*/
import React, { createContext, useEffect, useState } from 'react'
import { getRedirectUrl, isDeregisterRequest } from '../lib/deregister-request.ts'
import { translateIpfsRedirectUrl } from '../lib/ipfs-hosted-redirect-utils.ts'
import { error } from '../lib/logger.ts'
import { error, trace } from '../lib/logger.ts'
import { findOriginIsolationRedirect } from '../lib/path-or-subdomain.ts'
import { registerServiceWorker } from '../service-worker-utils.ts'

Expand Down Expand Up @@ -54,17 +55,23 @@ export const ServiceWorkerProvider = ({ children }): JSX.Element => {
return
}
async function doWork (): Promise<void> {
if (isDeregisterRequest(window.location.href)) {
trace('UI: deregistering service worker')
const registration = await navigator.serviceWorker.getRegistration()
if (registration != null) {
await registration.unregister()
window.location.replace(getRedirectUrl(window.location.href).href)
} else {
error('UI: service worker not registered, cannot deregister')
}
}
const registration = await navigator.serviceWorker.getRegistration()

if (registration != null) {
// service worker already registered
// attempt to update it
await registration.update()
setIsServiceWorkerRegistered(true)
} else {
try {
const registration = await registerServiceWorker()
await registration.update()
await registerServiceWorker()
setIsServiceWorkerRegistered(true)
} catch (err) {
error('error registering service worker', err)
Expand Down
79 changes: 21 additions & 58 deletions src/lib/config-db.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,20 @@
import debugLib from 'debug'
import { GenericIDB, type BaseDbConfig } from './generic-db'
import { LOCAL_STORAGE_KEYS } from './local-storage.ts'
import { log } from './logger'

export interface ConfigDb {
export interface ConfigDb extends BaseDbConfig {
gateways: string[]
routers: string[]
autoReload: boolean
debug: string
}

export type configDbKeys = keyof ConfigDb

export const DB_NAME = 'helia-sw'
export const STORE_NAME = 'config'

export async function openDatabase (): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1)
request.onerror = () => { reject(request.error) }
request.onsuccess = () => { resolve(request.result) }
request.onupgradeneeded = (event) => {
const db = request.result
db.createObjectStore(STORE_NAME)
}
})
}

export async function getFromDatabase <T extends keyof ConfigDb> (db: IDBDatabase, key: T): Promise<ConfigDb[T] | undefined> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly')
const store = transaction.objectStore(STORE_NAME)
const request = store.get(key)
request.onerror = () => { reject(request.error) }
request.onsuccess = () => { resolve(request.result) }
})
}

export async function setInDatabase <T extends keyof ConfigDb> (db: IDBDatabase, key: T, value: ConfigDb[T]): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.put(value, key)
request.onerror = () => { reject(request.error) }
request.onsuccess = () => { resolve() }
})
}

export async function closeDatabase (db: IDBDatabase): Promise<void> {
db.close()
}
const configDb = new GenericIDB<ConfigDb>('helia-sw', 'config')

export async function loadConfigFromLocalStorage (): Promise<void> {
if (typeof globalThis.localStorage !== 'undefined') {
const db = await openDatabase()
await configDb.open()
const localStorage = globalThis.localStorage
const localStorageGatewaysString = localStorage.getItem(LOCAL_STORAGE_KEYS.config.gateways) ?? '["https://trustless-gateway.link"]'
const localStorageRoutersString = localStorage.getItem(LOCAL_STORAGE_KEYS.config.routers) ?? '["https://delegated-ipfs.dev"]'
Expand All @@ -62,33 +24,34 @@ export async function loadConfigFromLocalStorage (): Promise<void> {
const routers = JSON.parse(localStorageRoutersString)
debugLib.enable(debug)

await setInDatabase(db, 'gateways', gateways)
await setInDatabase(db, 'routers', routers)
await setInDatabase(db, 'autoReload', autoReload)
await setInDatabase(db, 'debug', debug)
await closeDatabase(db)
await configDb.put('gateways', gateways)
await configDb.put('routers', routers)
await configDb.put('autoReload', autoReload)
await configDb.put('debug', debug)
configDb.close()
}
}

export async function setConfig (config: ConfigDb): Promise<void> {
debugLib.enable(config.debug ?? '') // set debug level first.
log('config-debug: setting config %O for domain %s', config, window.location.origin)

const db = await openDatabase()
await setInDatabase(db, 'gateways', config.gateways)
await setInDatabase(db, 'routers', config.routers)
await setInDatabase(db, 'autoReload', config.autoReload)
await setInDatabase(db, 'debug', config.debug ?? '')
await closeDatabase(db)
await configDb.open()
await configDb.put('gateways', config.gateways)
await configDb.put('routers', config.routers)
await configDb.put('autoReload', config.autoReload)
await configDb.put('debug', config.debug ?? '')
configDb.close()
}

export async function getConfig (): Promise<ConfigDb> {
const db = await openDatabase()
await configDb.open()

const gateways = await getFromDatabase(db, 'gateways') ?? ['https://trustless-gateway.link']
const routers = await getFromDatabase(db, 'routers') ?? ['https://delegated-ipfs.dev']
const autoReload = await getFromDatabase(db, 'autoReload') ?? false
const debug = await getFromDatabase(db, 'debug') ?? ''
const gateways = await configDb.get('gateways') ?? ['https://trustless-gateway.link']
const routers = await configDb.get('routers') ?? ['https://delegated-ipfs.dev']
const autoReload = await configDb.get('autoReload') ?? false
const debug = await configDb.get('debug') ?? ''
configDb.close()
debugLib.enable(debug)

return {
Expand Down
16 changes: 16 additions & 0 deletions src/lib/deregister-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { trace } from './logger.ts'

// things are wonky with hash routes for deregistering.
export function isDeregisterRequest (url: string): boolean {
const urlObj = new URL(url)
const result = urlObj.search.includes('ipfs-sw-deregister')
trace('isDeregisterRequest: ', url, result)
return result
}

export function getRedirectUrl (url: string): URL {
const redirectUrl = new URL(url)
redirectUrl.search = ''
redirectUrl.hash = '#/ipfs-sw-config'
return redirectUrl
}
68 changes: 68 additions & 0 deletions src/lib/generic-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
export type BaseDbConfig = Record<string, any>

type DbKeys<T extends BaseDbConfig> = (keyof T)
type validDbKey<T extends BaseDbConfig, K> = K extends IDBValidKey ? keyof T : never

// eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style
interface TypedIDBDatabase<T extends BaseDbConfig> extends IDBDatabase {
get<K extends keyof T>(key: validDbKey<T, K>): Promise<T[K]>
put<K extends DbKeys<T>>(value: T[K], key: validDbKey<T, K>): Promise<void>
store(name: string): IDBObjectStore

}

export class GenericIDB<T extends BaseDbConfig> {
private db: TypedIDBDatabase<T> | null = null

constructor (private readonly dbName: string, private readonly storeName: string) {
}

#openDatabase = async (): Promise<TypedIDBDatabase<T>> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1)
request.onerror = () => { reject(request.error) }
request.onsuccess = () => { resolve(request.result as TypedIDBDatabase<T>) }
request.onupgradeneeded = (event) => {
const db = request.result
db.createObjectStore(this.storeName)
}
})
}

async open (): Promise<void> {
this.db = await this.#openDatabase()
}

async put <K extends keyof T>(key: any extends K ? never : K & IDBValidKey, value: any extends (K extends keyof T ? T[K] : never) ? never : T[K]): Promise<void> {
if (this.db == null) {
throw new Error('Database not opened')
}
const transaction = this.db.transaction(this.storeName, 'readwrite')
const store = transaction.objectStore(this.storeName)
const request = store.put(value, key)
return new Promise((resolve, reject) => {
request.onerror = () => { reject(request.error) }
request.onsuccess = () => { resolve() }
})
}

async get<K extends keyof T> (key: any extends T[K] ? never : K & IDBValidKey): Promise<K extends keyof T ? T[K] : never> {
if (this.db == null) {
throw new Error('Database not opened')
}
const transaction = this.db.transaction(this.storeName, 'readonly')
const store = transaction.objectStore(this.storeName)
const request = store.get(key) as IDBRequest<T[K]>
return new Promise((resolve, reject) => {
request.onerror = () => { reject(request.error) }
request.onsuccess = () => { resolve(request.result) }
})
}

close (): void {
if (this.db != null) {
this.db.close()
this.db = null
}
}
}
2 changes: 1 addition & 1 deletion src/redirectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function RedirectPage (): JSX.Element {
await channel.messageAndWaitForResponse('SW', { target: 'SW', action: 'RELOAD_CONFIG' })
trace('redirect-page: RELOAD_CONFIG_SUCCESS on %s', window.location.origin)
// try to preload the content
await fetch(window.location.href, { method: 'GET' })
// await fetch(window.location.href, { method: 'GET' })
} catch (err) {
error('redirect-page: error setting config on subdomain', err)
}
Expand Down
Loading

0 comments on commit 8ec199c

Please sign in to comment.