(service-worker) add service worker api to impress app

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).
This commit is contained in:
Anthony LC
2024-06-17 11:01:56 +02:00
committed by Anthony LC
parent c2aa5be8ff
commit 96bfbfabec
8 changed files with 185 additions and 35 deletions

View File

@@ -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

View File

@@ -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 }) => {

View File

@@ -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",

View File

@@ -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<void> {
const db = await openDB<DocsDB>(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<DocsDB>(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',
},
});
};
}

View File

@@ -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',
);

View File

@@ -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;
/**

View File

@@ -20,7 +20,7 @@ type DocsAPIParams = DocsParams & {
page: number;
};
type DocsResponse = APIList<Doc>;
export type DocsResponse = APIList<Doc>;
export const getDocs = async ({
ordering,

View File

@@ -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"