mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-17 21:25:43 +02:00
⚡️(SW) cache content and metadata for API requests
We cache the content of API responses in the service worker, so that we can serve them when the user is offline. We also cache the ETag and Last-Modified headers, so that we can make conditional requests to the server and avoid downloading the content again if it hasn't changed.
This commit is contained in:
@@ -11,6 +11,12 @@ export type DBRequest = {
|
||||
key: string;
|
||||
};
|
||||
|
||||
export interface DocContentCacheEntry {
|
||||
etag: string;
|
||||
lastModified: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface IDocsDB extends DBSchema {
|
||||
'doc-list': {
|
||||
key: string;
|
||||
@@ -28,9 +34,13 @@ interface IDocsDB extends DBSchema {
|
||||
key: 'version';
|
||||
value: number;
|
||||
};
|
||||
'doc-content': {
|
||||
key: string;
|
||||
value: DocContentCacheEntry;
|
||||
};
|
||||
}
|
||||
|
||||
type TableName = 'doc-list' | 'doc-item' | 'doc-mutation';
|
||||
type TableName = 'doc-list' | 'doc-item' | 'doc-mutation' | 'doc-content';
|
||||
|
||||
/**
|
||||
* IndexDB prefers incremental versioning when upgrading the database,
|
||||
@@ -78,6 +88,9 @@ export class DocsDB {
|
||||
if (!db.objectStoreNames.contains('doc-version')) {
|
||||
db.createObjectStore('doc-version');
|
||||
}
|
||||
if (!db.objectStoreNames.contains('doc-content')) {
|
||||
db.createObjectStore('doc-content');
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -127,7 +140,7 @@ export class DocsDB {
|
||||
*/
|
||||
public static async cacheResponse(
|
||||
key: string,
|
||||
body: DocsResponse | Doc | DBRequest,
|
||||
body: DocsResponse | Doc | DBRequest | DocContentCacheEntry,
|
||||
tableName: TableName,
|
||||
): Promise<void> {
|
||||
const db = await DocsDB.open();
|
||||
|
||||
@@ -108,6 +108,7 @@ describe('ApiPlugin', () => {
|
||||
{ type: 'create', withClone: true },
|
||||
{ type: 'list', withClone: false },
|
||||
{ type: 'item', withClone: false },
|
||||
{ type: 'content', withClone: false },
|
||||
].forEach(({ type, withClone }) => {
|
||||
it(`calls requestWillFetch with type ${type}`, async () => {
|
||||
const mockedSync = vi.fn().mockResolvedValue({});
|
||||
@@ -137,6 +138,60 @@ describe('ApiPlugin', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it(`calls requestWillFetch with type content and sets If-None-Match when etag is cached`, async () => {
|
||||
const mockedSync = vi.fn().mockResolvedValue({});
|
||||
const apiPlugin = new ApiPlugin({
|
||||
type: 'content',
|
||||
tableName: 'doc-content',
|
||||
syncManager: { sync: () => mockedSync() } as any,
|
||||
});
|
||||
|
||||
mockedGet.mockResolvedValue({
|
||||
etag: '"abc123"',
|
||||
lastModified: '',
|
||||
content: 'hello',
|
||||
});
|
||||
|
||||
const requestInit = {
|
||||
request: new Request('http://test.jest/documents/123456/content/'),
|
||||
} as any;
|
||||
|
||||
const request = await apiPlugin.requestWillFetch?.(requestInit);
|
||||
expect(mockedGet).toHaveBeenCalledWith(
|
||||
'doc-content',
|
||||
'http://test.jest/documents/123456/content/',
|
||||
);
|
||||
expect(request?.headers.get('If-None-Match')).toBe('"abc123"');
|
||||
});
|
||||
|
||||
it(`calls requestWillFetch with type content and sets If-Modified-Since when only lastModified is cached`, async () => {
|
||||
const mockedSync = vi.fn().mockResolvedValue({});
|
||||
const apiPlugin = new ApiPlugin({
|
||||
type: 'content',
|
||||
tableName: 'doc-content',
|
||||
syncManager: { sync: () => mockedSync() } as any,
|
||||
});
|
||||
|
||||
mockedGet.mockResolvedValue({
|
||||
etag: '',
|
||||
lastModified: 'Mon, 14 Apr 2026 00:00:00 GMT',
|
||||
content: 'hello',
|
||||
});
|
||||
|
||||
const requestInit = {
|
||||
request: new Request('http://test.jest/documents/123456/content/'),
|
||||
} as any;
|
||||
|
||||
const request = await apiPlugin.requestWillFetch?.(requestInit);
|
||||
expect(mockedGet).toHaveBeenCalledWith(
|
||||
'doc-content',
|
||||
'http://test.jest/documents/123456/content/',
|
||||
);
|
||||
expect(request?.headers.get('If-Modified-Since')).toBe(
|
||||
'Mon, 14 Apr 2026 00:00:00 GMT',
|
||||
);
|
||||
});
|
||||
|
||||
it(`checks getApiCatchHandler`, async () => {
|
||||
const response = ApiPlugin.getApiCatchHandler();
|
||||
expect(await response.json()).toEqual({ error: 'Network is unavailable.' });
|
||||
|
||||
@@ -8,8 +8,8 @@ import { RequestSerializer } from '../RequestSerializer';
|
||||
import { SyncManager } from '../SyncManager';
|
||||
|
||||
interface OptionsReadonly {
|
||||
tableName: 'doc-list' | 'doc-item';
|
||||
type: 'list' | 'item';
|
||||
tableName: 'doc-list' | 'doc-item' | 'doc-content';
|
||||
type: 'list' | 'item' | 'content';
|
||||
}
|
||||
|
||||
interface OptionsMutate {
|
||||
@@ -51,6 +51,27 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
request,
|
||||
response,
|
||||
}) => {
|
||||
// For content requests, a 304 means the document hasn't changed:
|
||||
// transparently serve the cached version from IDB.
|
||||
if (this.options.type === 'content' && response.status === 304) {
|
||||
const db = await DocsDB.open();
|
||||
const entry = await db.get('doc-content', request.url);
|
||||
db.close();
|
||||
if (entry) {
|
||||
return new Response(entry.content, {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
...(entry.etag && { ETag: entry.etag }),
|
||||
...(entry.lastModified && {
|
||||
'Last-Modified': entry.lastModified,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
return response;
|
||||
}
|
||||
@@ -59,9 +80,18 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
const tableName = this.options.tableName;
|
||||
const body = (await response.clone().json()) as DocsResponse | Doc;
|
||||
await DocsDB.cacheResponse(request.url, body, tableName);
|
||||
}
|
||||
|
||||
if (this.options.type === 'update') {
|
||||
} else if (this.options.type === 'content') {
|
||||
// Cache the content response with its ETag / Last-Modified to be
|
||||
// able to use it for conditional requests and offline access.
|
||||
const content = await response.clone().text();
|
||||
const etag = response.headers.get('ETag') ?? '';
|
||||
const lastModified = response.headers.get('Last-Modified') ?? '';
|
||||
await DocsDB.cacheResponse(
|
||||
request.url,
|
||||
{ etag, lastModified, content },
|
||||
'doc-content',
|
||||
);
|
||||
} else if (this.options.type === 'update') {
|
||||
const db = await DocsDB.open();
|
||||
const storedResponse = await db.get('doc-item', request.url);
|
||||
|
||||
@@ -108,6 +138,23 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
|
||||
await this.options.syncManager.sync();
|
||||
|
||||
// For content requests, add If-None-Match / If-Modified-Since from IDB
|
||||
// so the backend can return a 304 when the document hasn't changed.
|
||||
if (this.options.type === 'content') {
|
||||
const db = await DocsDB.open();
|
||||
const entry = await db.get('doc-content', request.url);
|
||||
db.close();
|
||||
if (entry?.etag || entry?.lastModified) {
|
||||
const headers = new Headers(request.headers);
|
||||
if (entry.etag) {
|
||||
headers.set('If-None-Match', entry.etag);
|
||||
} else {
|
||||
headers.set('If-Modified-Since', entry.lastModified);
|
||||
}
|
||||
return new Request(request, { headers });
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(request);
|
||||
};
|
||||
|
||||
@@ -129,6 +176,8 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
case 'list':
|
||||
case 'item':
|
||||
return this.handlerDidErrorRead(this.options.tableName, request.url);
|
||||
case 'content':
|
||||
return this.handlerDidErrorContent(request);
|
||||
}
|
||||
|
||||
return Promise.resolve(ApiPlugin.getApiCatchHandler());
|
||||
@@ -420,4 +469,24 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private handlerDidErrorContent = async (request: Request) => {
|
||||
const db = await DocsDB.open();
|
||||
const entry = await db.get('doc-content', request.url);
|
||||
db.close();
|
||||
|
||||
if (!entry) {
|
||||
return Promise.resolve(ApiPlugin.getApiCatchHandler());
|
||||
}
|
||||
|
||||
return new Response(entry.content, {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
...(entry.etag && { ETag: entry.etag }),
|
||||
...(entry.lastModified && { 'Last-Modified': entry.lastModified }),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,22 @@ registerRoute(
|
||||
'GET',
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
({ url }) =>
|
||||
isApiUrl(url.href) && /\/documents\/[a-z0-9-]+\/content\/$/.test(url.href),
|
||||
new NetworkOnly({
|
||||
plugins: [
|
||||
new ApiPlugin({
|
||||
tableName: 'doc-content',
|
||||
type: 'content',
|
||||
syncManager,
|
||||
}),
|
||||
new OfflinePlugin(),
|
||||
],
|
||||
}),
|
||||
'GET',
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
({ url }) => isDocumentApiUrl(url),
|
||||
new NetworkOnly({
|
||||
|
||||
Reference in New Issue
Block a user