diff --git a/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts b/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts index 099fc9dd1..8142fbf72 100644 --- a/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts +++ b/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts @@ -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 { const db = await DocsDB.open(); diff --git a/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx b/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx index 4274f6c8f..1962134bc 100644 --- a/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx +++ b/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx @@ -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.' }); diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index 18b9d3ce0..a44311502 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -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 }), + }, + }); + }; } diff --git a/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts b/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts index 98d568948..677c35f7a 100644 --- a/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts +++ b/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts @@ -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({