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"