️(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:
Anthony LC
2026-04-27 15:07:34 +02:00
committed by Manuel Raynaud
parent 6f2cd8a829
commit 4d250a7342
4 changed files with 160 additions and 7 deletions
@@ -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({