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));