fix(service-worker): correctly determine client ID on navigation requests (#42607)

The ServiceWorker assigns an app-version to a each client to ensure that
all subsequent requests for a client are served using the same
app-version. The assignment is done based on the client ID.

Previously, the ServiceWorker would only try to read the client's ID off
of the `FetchEvent`'s `clientId` property. However, for navigation
requests the new client's ID will be set on [resultingClientId][1],
while `clientId` will either be empty or hold the ID of the client where
the request initiated from. See also related discussions in
w3c/ServiceWorker#870 and w3c/ServiceWorker#1266.

In theory, this could lead to the navigation request (i.e. `index.html`)
being served from a different app-version than the subsequent
sub-resource requests (i.e. assets). In practice, the likelihood of this
happening is probably very low though, since it would require the latest
app-version to be updated between the initial navigation request and the
first sub-resource request, which should happen very shortly after the
navigation request.

This commit ensures that the correct client ID is determined even for
navigation requests by also taking the `resultingClientId` property into
account.

[1]: https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/resultingClientId

PR Close #42607
This commit is contained in:
George Kalpakas 2021-06-22 16:09:39 +03:00 committed by Dylan Hunn
parent 81a19e4e65
commit 9498da1038
4 changed files with 15 additions and 12 deletions

View File

@ -609,7 +609,10 @@ export class Driver implements Debuggable, UpdateSource {
private async assignVersion(event: FetchEvent): Promise<AppVersion|null> { private async assignVersion(event: FetchEvent): Promise<AppVersion|null> {
// First, check whether the event has a (non empty) client ID. If it does, the version may // First, check whether the event has a (non empty) client ID. If it does, the version may
// already be associated. // already be associated.
const clientId = event.clientId; //
// NOTE: For navigation requests, we care about the `resultingClientId`. If it is undefined or
// the empty string (which is the case for sub-resource requests), we look at `clientId`.
const clientId = event.resultingClientId || event.clientId;
if (clientId) { if (clientId) {
// Check if there is an assigned client id. // Check if there is an assigned client id.
if (this.clientVersionMap.has(clientId)) { if (this.clientVersionMap.has(clientId)) {

View File

@ -60,8 +60,9 @@ type WindowClientState = 'hidden'|'visible'|'prerender'|'unloaded';
// Fetch API // Fetch API
interface FetchEvent extends ExtendableEvent { interface FetchEvent extends ExtendableEvent {
clientId: string|null;
request: Request; request: Request;
clientId?: string;
resultingClientId?: string;
respondWith(response: Promise<Response>|Response): Promise<Response>; respondWith(response: Promise<Response>|Response): Promise<Response>;
} }

View File

@ -538,7 +538,6 @@ describe('Driver', () => {
it('handles empty client ID', async () => { it('handles empty client ID', async () => {
// Initialize the SW. // Initialize the SW.
expect(await makeNavigationRequest(scope, '/foo/file1', '')).toEqual('this is foo'); expect(await makeNavigationRequest(scope, '/foo/file1', '')).toEqual('this is foo');
expect(await makeNavigationRequest(scope, '/bar/file2', null)).toEqual('this is foo');
await driver.initialized; await driver.initialized;
// Update to a new version. // Update to a new version.
@ -547,7 +546,6 @@ describe('Driver', () => {
// Correctly handle navigation requests, even if `clientId` is null/empty. // Correctly handle navigation requests, even if `clientId` is null/empty.
expect(await makeNavigationRequest(scope, '/foo/file1', '')).toEqual('this is foo v2'); expect(await makeNavigationRequest(scope, '/foo/file1', '')).toEqual('this is foo v2');
expect(await makeNavigationRequest(scope, '/bar/file2', null)).toEqual('this is foo v2');
}); });
it('checks for updates on restart', async () => { it('checks for updates on restart', async () => {
@ -2008,8 +2006,7 @@ async function removeAssetFromCache(
} }
async function makeRequest( async function makeRequest(
scope: SwTestHarness, url: string, clientId: string|null = 'default', scope: SwTestHarness, url: string, clientId = 'default', init?: Object): Promise<string|null> {
init?: Object): Promise<string|null> {
const [resPromise, done] = scope.handleFetch(new MockRequest(url, init), clientId); const [resPromise, done] = scope.handleFetch(new MockRequest(url, init), clientId);
await done; await done;
const res = await resPromise; const res = await resPromise;
@ -2020,8 +2017,7 @@ async function makeRequest(
} }
function makeNavigationRequest( function makeNavigationRequest(
scope: SwTestHarness, url: string, clientId?: string|null, scope: SwTestHarness, url: string, clientId?: string, init: Object = {}): Promise<string|null> {
init: Object = {}): Promise<string|null> {
return makeRequest(scope, url, clientId, { return makeRequest(scope, url, clientId, {
headers: { headers: {
Accept: 'text/plain, text/html, text/css', Accept: 'text/plain, text/html, text/css',

View File

@ -219,12 +219,14 @@ export class SwTestHarness extends Adapter implements ServiceWorkerGlobalScope,
waitUntil(promise: Promise<void>): void {} waitUntil(promise: Promise<void>): void {}
handleFetch(req: Request, clientId: string|null = null): handleFetch(req: Request, clientId = ''): [Promise<Response|undefined>, Promise<void>] {
[Promise<Response|undefined>, Promise<void>] {
if (!this.eventHandlers.has('fetch')) { if (!this.eventHandlers.has('fetch')) {
throw new Error('No fetch handler registered'); throw new Error('No fetch handler registered');
} }
const event = new MockFetchEvent(req, clientId);
const isNavigation = req.mode === 'navigate';
const event = isNavigation ? new MockFetchEvent(req, '', clientId) :
new MockFetchEvent(req, clientId, '');
this.eventHandlers.get('fetch')!.call(this, event); this.eventHandlers.get('fetch')!.call(this, event);
if (clientId) { if (clientId) {
@ -373,7 +375,8 @@ class MockExtendableEvent extends OneTimeContext {}
class MockFetchEvent extends MockExtendableEvent { class MockFetchEvent extends MockExtendableEvent {
response: Promise<Response|undefined> = Promise.resolve(undefined); response: Promise<Response|undefined> = Promise.resolve(undefined);
constructor(readonly request: Request, readonly clientId: string|null) { constructor(
readonly request: Request, readonly clientId: string, readonly resultingClientId: string) {
super(); super();
} }