From 96bfbfabec1605545aa974dec9b187112f74be9e Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Mon, 17 Jun 2024 11:01:56 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(service-worker)=20add=20service=20wor?= =?UTF-8?q?ker=20api=20to=20impress=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds the service worker api to the impress app. The service worker api will cache the api calls in the indexdb. We are using the network first strategy to fetch the data. If the network is not available, we will fetch the data from the indexdb. To do that, we create a custom plugin (ApiPlugin). --- CHANGELOG.md | 1 + src/frontend/apps/impress/next.config.js | 2 +- src/frontend/apps/impress/package.json | 1 + .../src/core/service-worker/ApiPlugin.ts | 135 ++++++++++++++++++ .../core/service-worker/service-worker-api.ts | 32 +++++ .../{ => service-worker}/service-worker.ts | 42 ++---- .../docs/doc-management/api/useDocs.tsx | 2 +- src/frontend/yarn.lock | 5 + 8 files changed, 185 insertions(+), 35 deletions(-) create mode 100644 src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts create mode 100644 src/frontend/apps/impress/src/core/service-worker/service-worker-api.ts rename src/frontend/apps/impress/src/core/{ => service-worker}/service-worker.ts (85%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33dc1cd89..ae41c5401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to - (frontend) manage members (update role / list / remove) (#81) - ✨(frontend) offline mode (#88) - (frontend) translate cgu (#83) +- ✨(service-worker) offline doc management (#94) ## Changed diff --git a/src/frontend/apps/impress/next.config.js b/src/frontend/apps/impress/next.config.js index e59b62378..a093d3e98 100644 --- a/src/frontend/apps/impress/next.config.js +++ b/src/frontend/apps/impress/next.config.js @@ -36,7 +36,7 @@ const nextConfig = { if (!isServer && process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') { config.plugins.push( new InjectManifest({ - swSrc: './src/core/service-worker.ts', + swSrc: './src/core/service-worker/service-worker.ts', swDest: '../public/service-worker.js', include: [ ({ asset }) => { diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index e88425074..df526cdaa 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -22,6 +22,7 @@ "@openfun/cunningham-react": "2.9.3", "@tanstack/react-query": "5.48.0", "i18next": "23.11.5", + "idb": "8.0.0", "lodash": "4.17.21", "luxon": "3.4.4", "next": "14.2.4", diff --git a/src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts b/src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts new file mode 100644 index 000000000..89647f658 --- /dev/null +++ b/src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts @@ -0,0 +1,135 @@ +import { DBSchema, openDB } from 'idb'; +import { WorkboxPlugin } from 'workbox-core'; + +import { Doc, DocsResponse } from '@/features/docs'; + +import { getApiCatchHandler } from './utils'; + +interface DocsDB extends DBSchema { + 'doc-list': { + key: string; + value: DocsResponse; + }; + 'doc-item': { + key: string; + value: Doc; + }; +} + +export interface OptionsReadonly { + tableName: 'doc-list' | 'doc-item'; + type: 'list' | 'item'; +} + +export interface OptionsMutate { + type: 'update' | 'delete' | 'create'; +} + +export type Options = OptionsReadonly | OptionsMutate; + +export class ApiPlugin implements WorkboxPlugin { + private static readonly DBNAME = 'api-docs-db'; + private readonly options: Options; + private isFetchDidFailed = false; + + constructor(options: Options) { + this.options = options; + } + + /** + * Save the response in the IndexedDB. + */ + private async cacheResponse( + request: Request, + body: DocsResponse | Doc, + tableName: OptionsReadonly['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'); + } + }, + }); + + await db.put(tableName, body, request.url); + } + + /** + * This method is called after the response is received. + * An error server response is not a failed fetch. + */ + fetchDidSucceed: WorkboxPlugin['fetchDidSucceed'] = async ({ + request, + response, + }) => { + if (this.options.type === 'list' || this.options.type === 'item') { + if (response.status !== 200) { + return response; + } + + try { + const tableName = this.options.tableName; + const body = (await response.clone().json()) as DocsResponse | Doc; + await this.cacheResponse(request, body, tableName); + } catch (error) { + console.error( + 'Failed to save response in IndexedDB', + error, + this.options, + ); + } + } + + return response; + }; + + /** + * Means that the fetch failed (500 is not failed), so often it is a network error. + */ + fetchDidFail: WorkboxPlugin['fetchDidFail'] = async () => { + this.isFetchDidFailed = true; + return Promise.resolve(); + }; + + /** + * When we get an network error. + */ + handlerDidError: WorkboxPlugin['handlerDidError'] = async ({ request }) => { + if (!this.isFetchDidFailed) { + return Promise.resolve(getApiCatchHandler()); + } + + /** + * 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()); + }; + + handlerDidErrorRead = async ( + tableName: OptionsReadonly['tableName'], + url: string, + ) => { + const db = await openDB(ApiPlugin.DBNAME, 1); + const storedResponse = await db.get(tableName, url); + + if (!storedResponse) { + return Promise.resolve(getApiCatchHandler()); + } + + return new Response(JSON.stringify(storedResponse), { + status: 200, + statusText: 'OK', + headers: { + 'Content-Type': 'application/json', + }, + }); + }; +} 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 new file mode 100644 index 000000000..8062a2f50 --- /dev/null +++ b/src/frontend/apps/impress/src/core/service-worker/service-worker-api.ts @@ -0,0 +1,32 @@ +import { registerRoute } from 'workbox-routing'; +import { NetworkOnly } from 'workbox-strategies'; + +import { ApiPlugin } from './ApiPlugin'; + +export const isApiUrl = (href: string) => { + const devDomain = 'http://localhost:8071'; + return ( + href.includes(`${self.location.origin}/api/`) || + href.includes(`${devDomain}/api/`) + ); +}; + +/** + * API routes + */ +registerRoute( + ({ url }) => + isApiUrl(url.href) && url.href.match(/.*\/documents\/\?(page|ordering)=.*/), + new NetworkOnly({ + plugins: [new ApiPlugin({ tableName: 'doc-list', type: 'list' })], + }), + 'GET', +); + +registerRoute( + ({ url }) => isApiUrl(url.href) && url.href.match(/.*\/documents\/.*\//), + new NetworkOnly({ + plugins: [new ApiPlugin({ tableName: 'doc-item', type: 'item' })], + }), + 'GET', +); diff --git a/src/frontend/apps/impress/src/core/service-worker.ts b/src/frontend/apps/impress/src/core/service-worker/service-worker.ts similarity index 85% rename from src/frontend/apps/impress/src/core/service-worker.ts rename to src/frontend/apps/impress/src/core/service-worker/service-worker.ts index b848df37c..0dd051ad6 100644 --- a/src/frontend/apps/impress/src/core/service-worker.ts +++ b/src/frontend/apps/impress/src/core/service-worker/service-worker.ts @@ -17,14 +17,19 @@ import { StrategyOptions, } from 'workbox-strategies'; +// eslint-disable-next-line import/order +import { ApiPlugin } from './ApiPlugin'; +import { isApiUrl } from './service-worker-api'; + +// eslint-disable-next-line import/order +import pkg from '@/../package.json'; + declare const self: ServiceWorkerGlobalScope & { __WB_DISABLE_DEV_LOGS: boolean; }; self.__WB_DISABLE_DEV_LOGS = true; -import pkg from '@/../package.json'; - setCacheNameDetails({ prefix: pkg.name, suffix: `v${pkg.version}`, @@ -114,21 +119,9 @@ warmStrategyCache({ urls: precacheResources, strategy: precacheStrategy }); * Handle requests that fail */ setCatchHandler(async ({ request, url, event }) => { - const devDomain = 'http://localhost:8071'; - switch (true) { - case url.href.includes(`${self.location.origin}/api/`) || - url.href.includes(`${devDomain}/api/`): - return new Response( - JSON.stringify({ error: 'Network is unavailable.' }), - { - status: 502, - statusText: 'Network is unavailable.', - headers: { - 'Content-Type': 'application/json', - }, - }, - ); + case isApiUrl(url.href): + return ApiPlugin.getApiCatchHandler(); case request.destination === 'document': if (url.pathname.match(/^\/docs\/.*\//)) { @@ -145,23 +138,6 @@ setCatchHandler(async ({ request, url, event }) => { } }); -/** - * Cache API requests - */ -registerRoute( - ({ url }) => { - const devDomain = 'http://localhost:8071'; - return ( - url.href.includes(`${self.location.origin}/api/`) || - url.href.includes(`${devDomain}/api/`) - ); - }, - new NetworkFirst({ - cacheName: getCacheNameVersion('api'), - plugins: [new CacheableResponsePlugin({ statuses: [200] })], - }), -); - const DAYS_EXP = 5; /** diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx index 92568f14f..c9d5b18d2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx @@ -20,7 +20,7 @@ type DocsAPIParams = DocsParams & { page: number; }; -type DocsResponse = APIList; +export type DocsResponse = APIList; export const getDocs = async ({ ordering, diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 873db8bf8..fc52874dd 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -6785,6 +6785,11 @@ iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +idb@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/idb/-/idb-8.0.0.tgz#33d7ed894ed36e23bcb542fb701ad579bfaad41f" + integrity sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw== + idb@^7.0.1: version "7.1.1" resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"