From 8ddce80a0bab4ebbd0f7db1c85ee27e4f0249db9 Mon Sep 17 00:00:00 2001 From: Henry Zhang Date: Thu, 10 Oct 2024 11:41:01 +1100 Subject: [PATCH] feat(service-worker): allow specifying maxAge for entire application (#49601) This commit adds an `applicationMaxAge` to the service worker configuration. When set, it will only assign a cached version to clients within the `maxAge`. Afterwards, it will ignored any expired application versions and fetch exclusively from the network. The default is `undefined`, for which the behaviour is the same as it currently is. PR Close #49601 --- .../ecosystem/service-workers/config.md | 6 +- .../service-worker/config/index.api.md | 2 + packages/service-worker/config/schema.json | 4 ++ .../service-worker/config/src/generator.ts | 3 + packages/service-worker/config/src/in.ts | 1 + .../config/test/generator_spec.ts | 6 ++ packages/service-worker/worker/src/driver.ts | 6 +- .../service-worker/worker/src/manifest.ts | 1 + .../service-worker/worker/test/happy_spec.ts | 64 +++++++++++++++++++ 9 files changed, 91 insertions(+), 2 deletions(-) diff --git a/adev/src/content/ecosystem/service-workers/config.md b/adev/src/content/ecosystem/service-workers/config.md index ea752174c25..a4d16eea298 100644 --- a/adev/src/content/ecosystem/service-workers/config.md +++ b/adev/src/content/ecosystem/service-workers/config.md @@ -347,7 +347,7 @@ A request is considered to be a navigation request if: * The URL must not contain a file extension (that is, a `.`) in the last path segment * The URL must not contain `__` -HELPFUL: To configure whether navigation requests are sent through to the network or not, see the [navigationRequestStrategy](#navigationrequeststrategy) section. +HELPFUL: To configure whether navigation requests are sent through to the network or not, see the [navigationRequestStrategy](#navigationrequeststrategy) section and [applicationMaxAge](#application-max-age) sections. #### Matching navigation request URLs @@ -391,3 +391,7 @@ This optional property enables you to configure how the service worker handles n | `'freshness'` | Passes the requests through to the network and falls back to the `performance` behavior when offline. This value is useful when the server redirects the navigation requests elsewhere using a `3xx` HTTP redirect status code. Reasons for using this value include: | IMPORTANT: The `freshness` strategy usually results in more requests sent to the server, which can increase response latency. It is recommended that you use the default performance strategy whenever possible. + +### `applicationMaxAge` + +This optional property enables you to configure how long the service worker will cache any requests. Within the `maxAge`, files will be served from cache. Beyond it, all requests will only be served from the network, including asset and data requests. diff --git a/goldens/public-api/service-worker/config/index.api.md b/goldens/public-api/service-worker/config/index.api.md index 79429bdfbd9..43ae3d67632 100644 --- a/goldens/public-api/service-worker/config/index.api.md +++ b/goldens/public-api/service-worker/config/index.api.md @@ -26,6 +26,8 @@ export interface Config { // (undocumented) appData?: {}; // (undocumented) + applicationMaxAge?: Duration; + // (undocumented) assetGroups?: AssetGroup[]; // (undocumented) dataGroups?: DataGroup[]; diff --git a/packages/service-worker/config/schema.json b/packages/service-worker/config/schema.json index fb1ab0cf368..d9c62082559 100644 --- a/packages/service-worker/config/schema.json +++ b/packages/service-worker/config/schema.json @@ -177,6 +177,10 @@ ], "default": "performance", "description": "The Angular service worker can use two request strategies for navigation requests. 'performance', the default, skips navigation requests. The other strategy, 'freshness', forces all navigation requests through the network." + }, + "applicationMaxAge": { + "type": "string", + "description": "Indicates how long the entire application is allowed to remain in the cache before being considered invalid and bypassed. 'maxAge' is a duration string, using the following unit suffixes: d= days, h= hours, m= minutes, s= seconds, u= milliseconds. For example, the string '3d12h' will cache content for up to three and a half days." } }, "required": [ diff --git a/packages/service-worker/config/src/generator.ts b/packages/service-worker/config/src/generator.ts index 8382eaa9632..9d2659a3d73 100644 --- a/packages/service-worker/config/src/generator.ts +++ b/packages/service-worker/config/src/generator.ts @@ -43,6 +43,9 @@ export class Generator { hashTable: withOrderedKeys(unorderedHashTable), navigationUrls: processNavigationUrls(this.baseHref, config.navigationUrls), navigationRequestStrategy: config.navigationRequestStrategy ?? 'performance', + applicationMaxAge: config.applicationMaxAge + ? parseDurationToMs(config.applicationMaxAge) + : undefined, }; } diff --git a/packages/service-worker/config/src/in.ts b/packages/service-worker/config/src/in.ts index 76d877e2912..22f9763f88f 100644 --- a/packages/service-worker/config/src/in.ts +++ b/packages/service-worker/config/src/in.ts @@ -28,6 +28,7 @@ export interface Config { dataGroups?: DataGroup[]; navigationUrls?: string[]; navigationRequestStrategy?: 'freshness' | 'performance'; + applicationMaxAge?: Duration; } /** diff --git a/packages/service-worker/config/test/generator_spec.ts b/packages/service-worker/config/test/generator_spec.ts index ef146471550..cb679a28ae9 100644 --- a/packages/service-worker/config/test/generator_spec.ts +++ b/packages/service-worker/config/test/generator_spec.ts @@ -58,6 +58,7 @@ describe('Generator', () => { 'http://example.com/included', '!http://example.com/excluded', ], + applicationMaxAge: '1d', }); expect(config).toEqual({ @@ -115,6 +116,7 @@ describe('Generator', () => { {positive: false, regex: '^http:\\/\\/example\\.com\\/excluded$'}, ], navigationRequestStrategy: 'performance', + applicationMaxAge: 86400000, hashTable: { '/test/foo/test.html': '18f6f8eb7b1c23d2bb61bff028b83d867a9e4643', '/test/index.html': 'a54d88e06612d820bc3be72877c74f257b561b19', @@ -208,6 +210,7 @@ describe('Generator', () => { {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'}, ], navigationRequestStrategy: 'performance', + applicationMaxAge: undefined, }); }); @@ -234,6 +237,7 @@ describe('Generator', () => { {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'}, ], navigationRequestStrategy: 'performance', + applicationMaxAge: undefined, hashTable: {}, }); }); @@ -425,6 +429,7 @@ describe('Generator', () => { {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'}, ], navigationRequestStrategy: 'performance', + applicationMaxAge: undefined, hashTable: {}, }); }); @@ -498,6 +503,7 @@ describe('Generator', () => { {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'}, ], navigationRequestStrategy: 'performance', + applicationMaxAge: undefined, hashTable: { '/index.html': 'a54d88e06612d820bc3be72877c74f257b561b19', '/main.js': '41347a66676cdc0516934c76d9d13010df420f2c', diff --git a/packages/service-worker/worker/src/driver.ts b/packages/service-worker/worker/src/driver.ts index 84a15775943..d7d2b635926 100644 --- a/packages/service-worker/worker/src/driver.ts +++ b/packages/service-worker/worker/src/driver.ts @@ -497,10 +497,14 @@ export class Driver implements Debuggable, UpdateSource { // Decide which version of the app to use to serve this request. This is asynchronous as in // some cases, a record will need to be written to disk about the assignment that is made. const appVersion = await this.assignVersion(event); + // If there's a configured max age, check whether this version is within that age. + const isVersionWithinMaxAge = + appVersion?.manifest.applicationMaxAge === undefined || + this.adapter.time - appVersion.manifest.timestamp < appVersion.manifest.applicationMaxAge; let res: Response | null = null; try { - if (appVersion !== null) { + if (appVersion !== null && isVersionWithinMaxAge) { try { // Handle the request. First try the AppVersion. If that doesn't work, fall back on the // network. diff --git a/packages/service-worker/worker/src/manifest.ts b/packages/service-worker/worker/src/manifest.ts index 0d35d477ecd..9ed2a6e25ba 100644 --- a/packages/service-worker/worker/src/manifest.ts +++ b/packages/service-worker/worker/src/manifest.ts @@ -19,6 +19,7 @@ export interface Manifest { dataGroups?: DataGroupConfig[]; navigationUrls: {positive: boolean; regex: string}[]; navigationRequestStrategy: 'freshness' | 'performance'; + applicationMaxAge?: number; hashTable: {[url: string]: string}; } diff --git a/packages/service-worker/worker/test/happy_spec.ts b/packages/service-worker/worker/test/happy_spec.ts index 5fc6f2d05b5..8f9c64f6220 100644 --- a/packages/service-worker/worker/test/happy_spec.ts +++ b/packages/service-worker/worker/test/happy_spec.ts @@ -2562,6 +2562,70 @@ import {envIsSupported} from '../testing/utils'; return {server, scope, driver}; } }); + + describe('applicationMaxAge', () => { + // When within the `applicationMaxAge`, the app should act like `performance` mode + // When outside of it, it should act like `freshness` mode, except it also uncaches asset + // requests + it("doesn't create navigate requests within the maxAge", async () => { + const {server, scope, driver} = createSwForMaxAge(); + + await makeRequest(scope, '/foo.txt'); + await driver.initialized; + await server.clearRequests(); + + // Create multiple requests to prove no navigation OR asset requests were made. + // By default the navigation request is not sent, it's replaced + // with the index request - thus, the `this is foo` value. + expect(await makeNavigationRequest(scope, '/', '')).toBe('this is foo'); + expect(await makeNavigationRequest(scope, '/foo', '')).toBe('this is foo'); + + expect(await makeRequest(scope, '/foo.txt')).toBe('this is foo'); + expect(await makeRequest(scope, '/bar.txt')).toBe('this is bar'); + + server.assertNoOtherRequests(); + }); + + it('creates navigate requests outside the maxAge', async () => { + const {server, scope, driver} = createSwForMaxAge(); + + await makeRequest(scope, '/foo.txt'); + await driver.initialized; + await server.clearRequests(); + + await scope.advance(3000); + + // Create multiple requests to prove the navigation and asset requests are all made + // When enabled, the navigation request is made each time and not replaced + // with the index request - thus, the `null` value. + expect(await makeNavigationRequest(scope, '/', '')).toBe(null); + expect(await makeNavigationRequest(scope, '/foo', '')).toBe(null); + + expect(await makeRequest(scope, '/foo.txt')).toBe('this is foo'); + expect(await makeRequest(scope, '/bar.txt')).toBe('this is bar'); + + server.assertSawRequestFor('/'); + server.assertSawRequestFor('/foo'); + server.assertSawRequestFor('/foo.txt'); + server.assertSawRequestFor('/bar.txt'); + server.assertNoOtherRequests(); + }); + + function createSwForMaxAge() { + const scope = new SwTestHarnessBuilder().build(); + // set the timestamp of the manifest using the server time so it's always "new" on test start + const maxAgeManifest: Manifest = { + ...manifest, + timestamp: scope.time, + applicationMaxAge: 2000, + }; + const server = serverBuilderBase.withManifest(maxAgeManifest).build(); + const driver = new Driver(scope, scope, new CacheDatabase(scope)); + scope.updateServerState(server); + + return {server, scope, driver}; + } + }); }); })();