From b4e8db9050f1411a1d394901f0901ccbe52b4284 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Mon, 17 Jun 2024 11:03:36 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(service-worker)=20add=20offline=20doc?= =?UTF-8?q?s=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add offline docs update to the service worker. We use the Network fisrt strategy, if the network is down, we will update the doc in the indexDB and serve it from there. When the connection is back, we will send the doc to the server. --- .../src/core/service-worker/ApiPlugin.ts | 192 ++++++++++++++++-- .../core/service-worker/RequestSerializer.ts | 75 +++++++ .../src/core/service-worker/SyncManager.ts | 46 +++++ .../core/service-worker/service-worker-api.ts | 35 +++- src/frontend/apps/impress/src/utils/index.ts | 1 + src/frontend/apps/impress/src/utils/system.ts | 2 + 6 files changed, 329 insertions(+), 22 deletions(-) create mode 100644 src/frontend/apps/impress/src/core/service-worker/RequestSerializer.ts create mode 100644 src/frontend/apps/impress/src/core/service-worker/SyncManager.ts create mode 100644 src/frontend/apps/impress/src/utils/system.ts 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 89647f65..c56f8fb6 100644 --- a/src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts @@ -3,8 +3,15 @@ import { WorkboxPlugin } from 'workbox-core'; import { Doc, DocsResponse } from '@/features/docs'; +import { RequestData, 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; @@ -14,23 +21,29 @@ interface DocsDB extends DBSchema { key: string; value: Doc; }; + 'doc-mutation': { + key: string; + value: DBRequest; + }; } -export interface OptionsReadonly { +interface OptionsReadonly { tableName: 'doc-list' | 'doc-item'; type: 'list' | 'item'; } -export interface OptionsMutate { +interface OptionsMutate { + tableName: 'doc-mutation'; type: 'update' | 'delete' | 'create'; } -export type Options = OptionsReadonly | OptionsMutate; +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; constructor(options: Options) { this.options = options; @@ -40,22 +53,13 @@ export class ApiPlugin implements WorkboxPlugin { * Save the response in the IndexedDB. */ private async cacheResponse( - request: Request, - body: DocsResponse | Doc, - tableName: OptionsReadonly['tableName'], + key: string, + body: DocsResponse | Doc | DBRequest, + tableName: Options['tableName'], ): Promise { - const db = 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'); - } - }, - }); + const db = await ApiPlugin.DB(); - await db.put(tableName, body, request.url); + await db.put(tableName, body, key); } /** @@ -74,10 +78,10 @@ export class ApiPlugin implements WorkboxPlugin { try { const tableName = this.options.tableName; const body = (await response.clone().json()) as DocsResponse | Doc; - await this.cacheResponse(request, body, tableName); + await this.cacheResponse(request.url, body, tableName); } catch (error) { console.error( - 'Failed to save response in IndexedDB', + 'SW-DEV: Failed to save response in IndexedDB', error, this.options, ); @@ -95,6 +99,21 @@ export class ApiPlugin implements WorkboxPlugin { return Promise.resolve(); }; + /** + * This method is called before the request is made. + * We can use it to capture the body of the request before it is sent. + * A body sent get "used", and can't be read anymore. + */ + requestWillFetch: WorkboxPlugin['requestWillFetch'] = async ({ request }) => { + if (this.options.type === 'update') { + this.initialRequest = request.clone(); + } + + await this.options.syncManager.sync(); + + return Promise.resolve(request); + }; + /** * When we get an network error. */ @@ -103,6 +122,13 @@ export class ApiPlugin implements WorkboxPlugin { return Promise.resolve(getApiCatchHandler()); } + /** + * Update the cache item to sync it later. + */ + if (this.options.type === 'update') { + return this.handlerDidErrorUpdate(request); + } + /** * Get data from the cache. */ @@ -113,7 +139,88 @@ export class ApiPlugin implements WorkboxPlugin { return Promise.resolve(getApiCatchHandler()); }; - handlerDidErrorRead = async ( + private handlerDidErrorUpdate = async (request: Request) => { + const db = await openDB(ApiPlugin.DBNAME, 1); + const storedResponse = await db.get('doc-item', request.url); + + if (!storedResponse || !this.initialRequest) { + return new Response('Not found', { status: 404 }); + } + + /** + * Queue the request in the cache 'doc-mutation' to sync it later. + */ + const requestData = ( + await RequestSerializer.fromRequest(this.initialRequest) + ).toObject(); + + const serializeRequest: DBRequest = { + requestData, + key: `${Date.now()}`, + }; + + await this.cacheResponse( + serializeRequest.key, + serializeRequest, + 'doc-mutation', + ); + + /** + * Update the cache item with the new data. + */ + const bodyMutate = (await this.initialRequest + .clone() + .json()) as Partial; + + const newResponse = { + ...storedResponse, + ...bodyMutate, + }; + + await db.put('doc-item', newResponse, request.url); + + /** + * Update the cache list with the new data. + */ + const listKeys = await db.getAllKeys('doc-list'); + + // Get id from url + const url = new URL(request.url); + const docId = url.pathname.slice(0, -1).split('/').pop(); + + for (const key of listKeys) { + const list = await db.get('doc-list', key); + + if (!list) { + continue; + } + + list.results = list.results.map((result) => { + if (result.id === docId) { + result = { + ...result, + ...bodyMutate, + }; + } + return result; + }); + + await db.put('doc-list', list, key); + } + + /** + * All is good for our client, we return the new response. + */ + return new Response(JSON.stringify(newResponse), { + status: 200, + statusText: 'OK', + headers: { + 'Content-Type': 'application/json', + }, + }); + }; + + private handlerDidErrorRead = async ( tableName: OptionsReadonly['tableName'], url: string, ) => { @@ -132,4 +239,49 @@ 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/RequestSerializer.ts b/src/frontend/apps/impress/src/core/service-worker/RequestSerializer.ts new file mode 100644 index 00000000..128af228 --- /dev/null +++ b/src/frontend/apps/impress/src/core/service-worker/RequestSerializer.ts @@ -0,0 +1,75 @@ +export type RequestData = { + url: string; + method?: string; + headers: Record; + body?: ArrayBuffer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}; + +const serializableProperties: Array = [ + 'method', + 'referrer', + 'referrerPolicy', + 'mode', + 'credentials', + 'cache', + 'redirect', + 'integrity', + 'keepalive', +]; + +/** + * RequestSerializer helps to manipulate Request objects. + */ +export class RequestSerializer { + private _requestData: RequestData; + + static async fromRequest(request: Request): Promise { + const requestData: RequestData = { + url: request.url, + headers: {}, + }; + + for (const prop of serializableProperties) { + if (request[prop] !== undefined) { + requestData[prop] = request[prop]; + } + } + + request.headers.forEach((value, key) => { + requestData.headers[key] = value; + }); + + if (request.method !== 'GET') { + requestData.body = await request.clone().arrayBuffer(); + } + + return new RequestSerializer(requestData); + } + + constructor(requestData: RequestData) { + if (requestData.mode === 'navigate') { + requestData.mode = 'same-origin'; + } + this._requestData = requestData; + } + + toObject(): RequestData { + const requestDataCopy: RequestData = { ...this._requestData }; + requestDataCopy.headers = { ...this._requestData.headers }; + if (requestDataCopy.body) { + requestDataCopy.body = requestDataCopy.body.slice(0); + } + return requestDataCopy; + } + + toRequest(): Request { + const { url, ...rest } = this._requestData; + return new Request(url, rest); + } + + clone(): RequestSerializer { + return new RequestSerializer(this.toObject()); + } +} diff --git a/src/frontend/apps/impress/src/core/service-worker/SyncManager.ts b/src/frontend/apps/impress/src/core/service-worker/SyncManager.ts new file mode 100644 index 00000000..2b98e7d3 --- /dev/null +++ b/src/frontend/apps/impress/src/core/service-worker/SyncManager.ts @@ -0,0 +1,46 @@ +import { sleep } from '@/utils/system'; + +export class SyncManager { + private _toSync: () => Promise; + private _hasSyncToDo: () => Promise; + private isSyncing = false; + + constructor( + toSync: () => Promise, + hasSyncToDo: () => Promise, + ) { + this._toSync = toSync; + this._hasSyncToDo = hasSyncToDo; + void this.sync(); + } + + public sync = async (syncAttempt = 0) => { + const hasSyncToDo = await this._hasSyncToDo(); + + if (!hasSyncToDo) { + return; + } + + // Wait for the other sync to finish + const maxAttempts = 15; + if (this.isSyncing && syncAttempt < maxAttempts) { + await sleep(300); + await this.sync(syncAttempt + 1); + return; + } + + if (this.isSyncing) { + return; + } + + this.isSyncing = true; + + try { + await this._toSync(); + } catch (error) { + console.error('SW-DEV: SyncManager.sync failed:', error); + } + + this.isSyncing = false; + }; +} 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 8062a2f5..b4026bc6 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,6 +2,11 @@ import { registerRoute } from 'workbox-routing'; import { NetworkOnly } from 'workbox-strategies'; import { ApiPlugin } from './ApiPlugin'; +import { SyncManager } from './SyncManager'; + +declare const self: ServiceWorkerGlobalScope; + +const syncManager = new SyncManager(ApiPlugin.sync, ApiPlugin.hasSyncToDo); export const isApiUrl = (href: string) => { const devDomain = 'http://localhost:8071'; @@ -18,7 +23,13 @@ registerRoute( ({ url }) => isApiUrl(url.href) && url.href.match(/.*\/documents\/\?(page|ordering)=.*/), new NetworkOnly({ - plugins: [new ApiPlugin({ tableName: 'doc-list', type: 'list' })], + plugins: [ + new ApiPlugin({ + tableName: 'doc-list', + type: 'list', + syncManager, + }), + ], }), 'GET', ); @@ -26,7 +37,27 @@ registerRoute( registerRoute( ({ url }) => isApiUrl(url.href) && url.href.match(/.*\/documents\/.*\//), new NetworkOnly({ - plugins: [new ApiPlugin({ tableName: 'doc-item', type: 'item' })], + plugins: [ + new ApiPlugin({ + tableName: 'doc-item', + type: 'item', + syncManager, + }), + ], }), 'GET', ); + +registerRoute( + ({ url }) => isApiUrl(url.href) && url.href.match(/.*\/documents\/.*\//), + new NetworkOnly({ + plugins: [ + new ApiPlugin({ + tableName: 'doc-mutation', + type: 'update', + syncManager, + }), + ], + }), + 'PATCH', +); diff --git a/src/frontend/apps/impress/src/utils/index.ts b/src/frontend/apps/impress/src/utils/index.ts index 57f9f48d..559df58c 100644 --- a/src/frontend/apps/impress/src/utils/index.ts +++ b/src/frontend/apps/impress/src/utils/index.ts @@ -1 +1,2 @@ export * from './string'; +export * from './system'; diff --git a/src/frontend/apps/impress/src/utils/system.ts b/src/frontend/apps/impress/src/utils/system.ts new file mode 100644 index 00000000..a8c24f36 --- /dev/null +++ b/src/frontend/apps/impress/src/utils/system.ts @@ -0,0 +1,2 @@ +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms));