✨(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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
135
src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts
Normal file
135
src/frontend/apps/impress/src/core/service-worker/ApiPlugin.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ type DocsAPIParams = DocsParams & {
|
||||
page: number;
|
||||
};
|
||||
|
||||
type DocsResponse = APIList<Doc>;
|
||||
export type DocsResponse = APIList<Doc>;
|
||||
|
||||
export const getDocs = async ({
|
||||
ordering,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user