diff --git a/src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts b/src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts index c56f8fb6..7776218d 100644 --- a/src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts @@ -1,31 +1,10 @@ -import { DBSchema, openDB } from 'idb'; import { WorkboxPlugin } from 'workbox-core'; import { Doc, DocsResponse } from '@/features/docs'; -import { RequestData, RequestSerializer } from './RequestSerializer'; +import { DBRequest, DocsDB } from './DocsDB'; +import { RequestSerializer } from './RequestSerializer'; import { SyncManager } from './SyncManager'; -import { getApiCatchHandler } from './utils'; - -type DBRequest = { - requestData: RequestData; - key: string; -}; - -interface DocsDB extends DBSchema { - 'doc-list': { - key: string; - value: DocsResponse; - }; - 'doc-item': { - key: string; - value: Doc; - }; - 'doc-mutation': { - key: string; - value: DBRequest; - }; -} interface OptionsReadonly { tableName: 'doc-list' | 'doc-item'; @@ -33,35 +12,30 @@ interface OptionsReadonly { } interface OptionsMutate { - tableName: 'doc-mutation'; type: 'update' | 'delete' | 'create'; } type Options = (OptionsReadonly | OptionsMutate) & { syncManager: SyncManager }; export class ApiPlugin implements WorkboxPlugin { - private static readonly DBNAME = 'api-docs-db'; private readonly options: Options; private isFetchDidFailed = false; private initialRequest?: Request; + static getApiCatchHandler = () => { + return new Response(JSON.stringify({ error: 'Network is unavailable.' }), { + status: 502, + statusText: 'Network is unavailable.', + headers: { + 'Content-Type': 'application/json', + }, + }); + }; + constructor(options: Options) { this.options = options; } - /** - * Save the response in the IndexedDB. - */ - private async cacheResponse( - key: string, - body: DocsResponse | Doc | DBRequest, - tableName: Options['tableName'], - ): Promise { - const db = await ApiPlugin.DB(); - - await db.put(tableName, body, key); - } - /** * This method is called after the response is received. * An error server response is not a failed fetch. @@ -75,17 +49,9 @@ export class ApiPlugin implements WorkboxPlugin { return response; } - try { - const tableName = this.options.tableName; - const body = (await response.clone().json()) as DocsResponse | Doc; - await this.cacheResponse(request.url, body, tableName); - } catch (error) { - console.error( - 'SW-DEV: Failed to save response in IndexedDB', - error, - this.options, - ); - } + const tableName = this.options.tableName; + const body = (await response.clone().json()) as DocsResponse | Doc; + await DocsDB.cacheResponse(request.url, body, tableName); } return response; @@ -119,28 +85,22 @@ export class ApiPlugin implements WorkboxPlugin { */ handlerDidError: WorkboxPlugin['handlerDidError'] = async ({ request }) => { if (!this.isFetchDidFailed) { - return Promise.resolve(getApiCatchHandler()); + return Promise.resolve(ApiPlugin.getApiCatchHandler()); } - /** - * Update the cache item to sync it later. - */ - if (this.options.type === 'update') { - return this.handlerDidErrorUpdate(request); + switch (this.options.type) { + case 'update': + return this.handlerDidErrorUpdate(request); + case 'list': + case 'item': + return this.handlerDidErrorRead(this.options.tableName, request.url); } - /** - * Get data from the cache. - */ - if (this.options.type === 'list' || this.options.type === 'item') { - return this.handlerDidErrorRead(this.options.tableName, request.url); - } - - return Promise.resolve(getApiCatchHandler()); + return Promise.resolve(ApiPlugin.getApiCatchHandler()); }; private handlerDidErrorUpdate = async (request: Request) => { - const db = await openDB(ApiPlugin.DBNAME, 1); + const db = await DocsDB.open(); const storedResponse = await db.get('doc-item', request.url); if (!storedResponse || !this.initialRequest) { @@ -159,7 +119,7 @@ export class ApiPlugin implements WorkboxPlugin { key: `${Date.now()}`, }; - await this.cacheResponse( + await DocsDB.cacheResponse( serializeRequest.key, serializeRequest, 'doc-mutation', @@ -177,7 +137,7 @@ export class ApiPlugin implements WorkboxPlugin { ...bodyMutate, }; - await db.put('doc-item', newResponse, request.url); + await DocsDB.cacheResponse(request.url, newResponse, 'doc-item'); /** * Update the cache list with the new data. @@ -205,9 +165,11 @@ export class ApiPlugin implements WorkboxPlugin { return result; }); - await db.put('doc-list', list, key); + await DocsDB.cacheResponse(key, list, 'doc-list'); } + db.close(); + /** * All is good for our client, we return the new response. */ @@ -224,11 +186,11 @@ export class ApiPlugin implements WorkboxPlugin { tableName: OptionsReadonly['tableName'], url: string, ) => { - const db = await openDB(ApiPlugin.DBNAME, 1); + const db = await DocsDB.open(); const storedResponse = await db.get(tableName, url); if (!storedResponse) { - return Promise.resolve(getApiCatchHandler()); + return Promise.resolve(ApiPlugin.getApiCatchHandler()); } return new Response(JSON.stringify(storedResponse), { @@ -239,49 +201,4 @@ export class ApiPlugin implements WorkboxPlugin { }, }); }; - - public static hasSyncToDo = async () => { - const db = await ApiPlugin.DB(); - const requests = await db.getAll('doc-mutation'); - - return requests.length > 0; - }; - - /** - * Sync the queue with the server. - */ - public static sync = async () => { - const db = await ApiPlugin.DB(); - const requests = await db.getAll('doc-mutation'); - - for (const request of requests) { - try { - await fetch(new RequestSerializer(request.requestData).toRequest()); - await db.delete('doc-mutation', request.key); - } catch (error) { - console.error('SW-DEV: Replay failed for request', request, error); - break; - } - } - }; - - /** - * IndexedDB instance. - * @returns Promise> - */ - private static DB = async () => { - return await openDB(ApiPlugin.DBNAME, 1, { - upgrade(db) { - if (!db.objectStoreNames.contains('doc-list')) { - db.createObjectStore('doc-list'); - } - if (!db.objectStoreNames.contains('doc-item')) { - db.createObjectStore('doc-item'); - } - if (!db.objectStoreNames.contains('doc-mutation')) { - db.createObjectStore('doc-mutation'); - } - }, - }); - }; } diff --git a/src/frontend/apps/impress/src/core/service-worker/DocsDB.ts b/src/frontend/apps/impress/src/core/service-worker/DocsDB.ts new file mode 100644 index 00000000..4d0e85f1 --- /dev/null +++ b/src/frontend/apps/impress/src/core/service-worker/DocsDB.ts @@ -0,0 +1,168 @@ +import { DBSchema, IDBPDatabase, deleteDB, openDB } from 'idb'; + +import { Doc, DocsResponse } from '@/features/docs'; + +import { RequestData, RequestSerializer } from './RequestSerializer'; + +// eslint-disable-next-line import/order +import pkg from '@/../package.json'; + +export type DBRequest = { + requestData: RequestData; + key: string; +}; + +interface IDocsDB extends DBSchema { + 'doc-list': { + key: string; + value: DocsResponse; + }; + 'doc-item': { + key: string; + value: Doc; + }; + 'doc-mutation': { + key: string; + value: DBRequest; + }; + 'doc-version': { + key: 'version'; + value: number; + }; +} + +type TableName = 'doc-list' | 'doc-item' | 'doc-mutation'; + +/** + * IndexDB version must be a integer + * @returns + */ +const getCurrentVersion = () => { + const [major, minor, patch] = pkg.version.split('.'); + return parseFloat(`${major}${minor}${patch}`); +}; + +/** + * Static class for managing the Docs with IndexedDB. + */ +export class DocsDB { + private static readonly DBNAME = 'api-docs-db'; + + /** + * IndexedDB instance. + * @returns Promise> + */ + public static open = async () => { + let db: IDBPDatabase; + + try { + db = await openDB(DocsDB.DBNAME, getCurrentVersion(), { + upgrade(db) { + if (!db.objectStoreNames.contains('doc-list')) { + db.createObjectStore('doc-list'); + } + if (!db.objectStoreNames.contains('doc-item')) { + db.createObjectStore('doc-item'); + } + if (!db.objectStoreNames.contains('doc-mutation')) { + db.createObjectStore('doc-mutation'); + } + if (!db.objectStoreNames.contains('doc-version')) { + db.createObjectStore('doc-version'); + } + }, + }); + } catch (error) { + /** + * If for any reason the current version is lower than the previous one, + * we need to delete the database and create a new one. + */ + console.error('SW: Failed to open IndexedDB', error); + await deleteDB(DocsDB.DBNAME); + + db = await DocsDB.open(); + } + + return db; + }; + + public static deleteAll = async (tableName: TableName) => { + const db = await DocsDB.open(); + const keys = await db.getAllKeys(tableName); + + for (const key of keys) { + await db.delete(tableName, key); + } + + db.close(); + }; + + public static cleanupOutdatedVersion = async () => { + const db = await DocsDB.open(); + const version = await db.get('doc-version', 'version'); + const currentVersion = getCurrentVersion(); + + if (version != currentVersion) { + console.debug('SW: Cleaning up outdated caches', version, currentVersion); + + await DocsDB.deleteAll('doc-item'); + await DocsDB.deleteAll('doc-list'); + await DocsDB.deleteAll('doc-mutation'); + await db.put('doc-version', currentVersion, 'version'); + } + + db.close(); + }; + + /** + * Save the response in the IndexedDB. + */ + public static async cacheResponse( + key: string, + body: DocsResponse | Doc | DBRequest, + tableName: TableName, + ): Promise { + const db = await DocsDB.open(); + + try { + await db.put(tableName, body, key); + } catch (error) { + console.error( + 'SW: Failed to save response in IndexedDB', + error, + key, + body, + ); + } + + db.close(); + } + + public static hasSyncToDo = async () => { + const db = await DocsDB.open(); + const requests = await db.getAll('doc-mutation'); + db.close(); + + return requests.length > 0; + }; + + /** + * Sync the queue with the server. + */ + public static sync = async () => { + const db = await DocsDB.open(); + const requests = await db.getAll('doc-mutation'); + + for (const request of requests) { + try { + await fetch(new RequestSerializer(request.requestData).toRequest()); + await db.delete('doc-mutation', request.key); + } catch (error) { + console.error('SW: Replay failed for request', request, error); + break; + } + } + + db.close(); + }; +} diff --git a/src/frontend/apps/impress/src/core/service-worker/service-worker-api.ts b/src/frontend/apps/impress/src/core/service-worker/service-worker-api.ts index b4026bc6..b08fdd26 100644 --- a/src/frontend/apps/impress/src/core/service-worker/service-worker-api.ts +++ b/src/frontend/apps/impress/src/core/service-worker/service-worker-api.ts @@ -2,11 +2,16 @@ import { registerRoute } from 'workbox-routing'; import { NetworkOnly } from 'workbox-strategies'; import { ApiPlugin } from './ApiPlugin'; +import { DocsDB } from './DocsDB'; import { SyncManager } from './SyncManager'; declare const self: ServiceWorkerGlobalScope; -const syncManager = new SyncManager(ApiPlugin.sync, ApiPlugin.hasSyncToDo); +const syncManager = new SyncManager(DocsDB.sync, DocsDB.hasSyncToDo); + +self.addEventListener('activate', function (event) { + event.waitUntil(DocsDB.cleanupOutdatedVersion()); +}); export const isApiUrl = (href: string) => { const devDomain = 'http://localhost:8071'; @@ -53,7 +58,6 @@ registerRoute( new NetworkOnly({ plugins: [ new ApiPlugin({ - tableName: 'doc-mutation', type: 'update', syncManager, }),