✨(service-worker) add offline docs update
Add offline docs update to the service worker. We use the Network fisrt strategy, if the network is down, we will update the doc in the indexDB and serve it from there. When the connection is back, we will send the doc to the server.
This commit is contained in:
@@ -3,8 +3,15 @@ import { WorkboxPlugin } from 'workbox-core';
|
||||
|
||||
import { Doc, DocsResponse } from '@/features/docs';
|
||||
|
||||
import { RequestData, RequestSerializer } from './RequestSerializer';
|
||||
import { SyncManager } from './SyncManager';
|
||||
import { getApiCatchHandler } from './utils';
|
||||
|
||||
type DBRequest = {
|
||||
requestData: RequestData;
|
||||
key: string;
|
||||
};
|
||||
|
||||
interface DocsDB extends DBSchema {
|
||||
'doc-list': {
|
||||
key: string;
|
||||
@@ -14,23 +21,29 @@ interface DocsDB extends DBSchema {
|
||||
key: string;
|
||||
value: Doc;
|
||||
};
|
||||
'doc-mutation': {
|
||||
key: string;
|
||||
value: DBRequest;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OptionsReadonly {
|
||||
interface OptionsReadonly {
|
||||
tableName: 'doc-list' | 'doc-item';
|
||||
type: 'list' | 'item';
|
||||
}
|
||||
|
||||
export interface OptionsMutate {
|
||||
interface OptionsMutate {
|
||||
tableName: 'doc-mutation';
|
||||
type: 'update' | 'delete' | 'create';
|
||||
}
|
||||
|
||||
export type Options = OptionsReadonly | OptionsMutate;
|
||||
type Options = (OptionsReadonly | OptionsMutate) & { syncManager: SyncManager };
|
||||
|
||||
export class ApiPlugin implements WorkboxPlugin {
|
||||
private static readonly DBNAME = 'api-docs-db';
|
||||
private readonly options: Options;
|
||||
private isFetchDidFailed = false;
|
||||
private initialRequest?: Request;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.options = options;
|
||||
@@ -40,22 +53,13 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
* Save the response in the IndexedDB.
|
||||
*/
|
||||
private async cacheResponse(
|
||||
request: Request,
|
||||
body: DocsResponse | Doc,
|
||||
tableName: OptionsReadonly['tableName'],
|
||||
key: string,
|
||||
body: DocsResponse | Doc | DBRequest,
|
||||
tableName: Options['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');
|
||||
}
|
||||
},
|
||||
});
|
||||
const db = await ApiPlugin.DB();
|
||||
|
||||
await db.put(tableName, body, request.url);
|
||||
await db.put(tableName, body, key);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,10 +78,10 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
try {
|
||||
const tableName = this.options.tableName;
|
||||
const body = (await response.clone().json()) as DocsResponse | Doc;
|
||||
await this.cacheResponse(request, body, tableName);
|
||||
await this.cacheResponse(request.url, body, tableName);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save response in IndexedDB',
|
||||
'SW-DEV: Failed to save response in IndexedDB',
|
||||
error,
|
||||
this.options,
|
||||
);
|
||||
@@ -95,6 +99,21 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is called before the request is made.
|
||||
* We can use it to capture the body of the request before it is sent.
|
||||
* A body sent get "used", and can't be read anymore.
|
||||
*/
|
||||
requestWillFetch: WorkboxPlugin['requestWillFetch'] = async ({ request }) => {
|
||||
if (this.options.type === 'update') {
|
||||
this.initialRequest = request.clone();
|
||||
}
|
||||
|
||||
await this.options.syncManager.sync();
|
||||
|
||||
return Promise.resolve(request);
|
||||
};
|
||||
|
||||
/**
|
||||
* When we get an network error.
|
||||
*/
|
||||
@@ -103,6 +122,13 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
return Promise.resolve(getApiCatchHandler());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cache item to sync it later.
|
||||
*/
|
||||
if (this.options.type === 'update') {
|
||||
return this.handlerDidErrorUpdate(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data from the cache.
|
||||
*/
|
||||
@@ -113,7 +139,88 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
return Promise.resolve(getApiCatchHandler());
|
||||
};
|
||||
|
||||
handlerDidErrorRead = async (
|
||||
private handlerDidErrorUpdate = async (request: Request) => {
|
||||
const db = await openDB<DocsDB>(ApiPlugin.DBNAME, 1);
|
||||
const storedResponse = await db.get('doc-item', request.url);
|
||||
|
||||
if (!storedResponse || !this.initialRequest) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue the request in the cache 'doc-mutation' to sync it later.
|
||||
*/
|
||||
const requestData = (
|
||||
await RequestSerializer.fromRequest(this.initialRequest)
|
||||
).toObject();
|
||||
|
||||
const serializeRequest: DBRequest = {
|
||||
requestData,
|
||||
key: `${Date.now()}`,
|
||||
};
|
||||
|
||||
await this.cacheResponse(
|
||||
serializeRequest.key,
|
||||
serializeRequest,
|
||||
'doc-mutation',
|
||||
);
|
||||
|
||||
/**
|
||||
* Update the cache item with the new data.
|
||||
*/
|
||||
const bodyMutate = (await this.initialRequest
|
||||
.clone()
|
||||
.json()) as Partial<Doc>;
|
||||
|
||||
const newResponse = {
|
||||
...storedResponse,
|
||||
...bodyMutate,
|
||||
};
|
||||
|
||||
await db.put('doc-item', newResponse, request.url);
|
||||
|
||||
/**
|
||||
* Update the cache list with the new data.
|
||||
*/
|
||||
const listKeys = await db.getAllKeys('doc-list');
|
||||
|
||||
// Get id from url
|
||||
const url = new URL(request.url);
|
||||
const docId = url.pathname.slice(0, -1).split('/').pop();
|
||||
|
||||
for (const key of listKeys) {
|
||||
const list = await db.get('doc-list', key);
|
||||
|
||||
if (!list) {
|
||||
continue;
|
||||
}
|
||||
|
||||
list.results = list.results.map((result) => {
|
||||
if (result.id === docId) {
|
||||
result = {
|
||||
...result,
|
||||
...bodyMutate,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
await db.put('doc-list', list, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* All is good for our client, we return the new response.
|
||||
*/
|
||||
return new Response(JSON.stringify(newResponse), {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private handlerDidErrorRead = async (
|
||||
tableName: OptionsReadonly['tableName'],
|
||||
url: string,
|
||||
) => {
|
||||
@@ -132,4 +239,49 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
public static hasSyncToDo = async () => {
|
||||
const db = await ApiPlugin.DB();
|
||||
const requests = await db.getAll('doc-mutation');
|
||||
|
||||
return requests.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync the queue with the server.
|
||||
*/
|
||||
public static sync = async () => {
|
||||
const db = await ApiPlugin.DB();
|
||||
const requests = await db.getAll('doc-mutation');
|
||||
|
||||
for (const request of requests) {
|
||||
try {
|
||||
await fetch(new RequestSerializer(request.requestData).toRequest());
|
||||
await db.delete('doc-mutation', request.key);
|
||||
} catch (error) {
|
||||
console.error('SW-DEV: Replay failed for request', request, error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* IndexedDB instance.
|
||||
* @returns Promise<IDBPDatabase<DocsDB>>
|
||||
*/
|
||||
private static DB = async () => {
|
||||
return 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');
|
||||
}
|
||||
if (!db.objectStoreNames.contains('doc-mutation')) {
|
||||
db.createObjectStore('doc-mutation');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
export type RequestData = {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers: Record<string, string>;
|
||||
body?: ArrayBuffer;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
const serializableProperties: Array<keyof Request> = [
|
||||
'method',
|
||||
'referrer',
|
||||
'referrerPolicy',
|
||||
'mode',
|
||||
'credentials',
|
||||
'cache',
|
||||
'redirect',
|
||||
'integrity',
|
||||
'keepalive',
|
||||
];
|
||||
|
||||
/**
|
||||
* RequestSerializer helps to manipulate Request objects.
|
||||
*/
|
||||
export class RequestSerializer {
|
||||
private _requestData: RequestData;
|
||||
|
||||
static async fromRequest(request: Request): Promise<RequestSerializer> {
|
||||
const requestData: RequestData = {
|
||||
url: request.url,
|
||||
headers: {},
|
||||
};
|
||||
|
||||
for (const prop of serializableProperties) {
|
||||
if (request[prop] !== undefined) {
|
||||
requestData[prop] = request[prop];
|
||||
}
|
||||
}
|
||||
|
||||
request.headers.forEach((value, key) => {
|
||||
requestData.headers[key] = value;
|
||||
});
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
requestData.body = await request.clone().arrayBuffer();
|
||||
}
|
||||
|
||||
return new RequestSerializer(requestData);
|
||||
}
|
||||
|
||||
constructor(requestData: RequestData) {
|
||||
if (requestData.mode === 'navigate') {
|
||||
requestData.mode = 'same-origin';
|
||||
}
|
||||
this._requestData = requestData;
|
||||
}
|
||||
|
||||
toObject(): RequestData {
|
||||
const requestDataCopy: RequestData = { ...this._requestData };
|
||||
requestDataCopy.headers = { ...this._requestData.headers };
|
||||
if (requestDataCopy.body) {
|
||||
requestDataCopy.body = requestDataCopy.body.slice(0);
|
||||
}
|
||||
return requestDataCopy;
|
||||
}
|
||||
|
||||
toRequest(): Request {
|
||||
const { url, ...rest } = this._requestData;
|
||||
return new Request(url, rest);
|
||||
}
|
||||
|
||||
clone(): RequestSerializer {
|
||||
return new RequestSerializer(this.toObject());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { sleep } from '@/utils/system';
|
||||
|
||||
export class SyncManager {
|
||||
private _toSync: () => Promise<void>;
|
||||
private _hasSyncToDo: () => Promise<boolean>;
|
||||
private isSyncing = false;
|
||||
|
||||
constructor(
|
||||
toSync: () => Promise<void>,
|
||||
hasSyncToDo: () => Promise<boolean>,
|
||||
) {
|
||||
this._toSync = toSync;
|
||||
this._hasSyncToDo = hasSyncToDo;
|
||||
void this.sync();
|
||||
}
|
||||
|
||||
public sync = async (syncAttempt = 0) => {
|
||||
const hasSyncToDo = await this._hasSyncToDo();
|
||||
|
||||
if (!hasSyncToDo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the other sync to finish
|
||||
const maxAttempts = 15;
|
||||
if (this.isSyncing && syncAttempt < maxAttempts) {
|
||||
await sleep(300);
|
||||
await this.sync(syncAttempt + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
|
||||
try {
|
||||
await this._toSync();
|
||||
} catch (error) {
|
||||
console.error('SW-DEV: SyncManager.sync failed:', error);
|
||||
}
|
||||
|
||||
this.isSyncing = false;
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { registerRoute } from 'workbox-routing';
|
||||
import { NetworkOnly } from 'workbox-strategies';
|
||||
|
||||
import { ApiPlugin } from './ApiPlugin';
|
||||
import { SyncManager } from './SyncManager';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
const syncManager = new SyncManager(ApiPlugin.sync, ApiPlugin.hasSyncToDo);
|
||||
|
||||
export const isApiUrl = (href: string) => {
|
||||
const devDomain = 'http://localhost:8071';
|
||||
@@ -18,7 +23,13 @@ registerRoute(
|
||||
({ url }) =>
|
||||
isApiUrl(url.href) && url.href.match(/.*\/documents\/\?(page|ordering)=.*/),
|
||||
new NetworkOnly({
|
||||
plugins: [new ApiPlugin({ tableName: 'doc-list', type: 'list' })],
|
||||
plugins: [
|
||||
new ApiPlugin({
|
||||
tableName: 'doc-list',
|
||||
type: 'list',
|
||||
syncManager,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
'GET',
|
||||
);
|
||||
@@ -26,7 +37,27 @@ registerRoute(
|
||||
registerRoute(
|
||||
({ url }) => isApiUrl(url.href) && url.href.match(/.*\/documents\/.*\//),
|
||||
new NetworkOnly({
|
||||
plugins: [new ApiPlugin({ tableName: 'doc-item', type: 'item' })],
|
||||
plugins: [
|
||||
new ApiPlugin({
|
||||
tableName: 'doc-item',
|
||||
type: 'item',
|
||||
syncManager,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
'GET',
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
({ url }) => isApiUrl(url.href) && url.href.match(/.*\/documents\/.*\//),
|
||||
new NetworkOnly({
|
||||
plugins: [
|
||||
new ApiPlugin({
|
||||
tableName: 'doc-mutation',
|
||||
type: 'update',
|
||||
syncManager,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
'PATCH',
|
||||
);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './string';
|
||||
export * from './system';
|
||||
|
||||
2
src/frontend/apps/impress/src/utils/system.ts
Normal file
2
src/frontend/apps/impress/src/utils/system.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
Reference in New Issue
Block a user