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 33dc1cd8..ae41c540 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 e59b6237..a093d3e9 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 e8842507..df526cda 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 00000000..89647f65 --- /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 00000000..8062a2f5 --- /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 b848df37..0dd051ad 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 92568f14..c9d5b18d 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 873db8bf..fc52874d 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"