Switch webview service-worker to use message channel (#138811)

* Switch webview service-worker to use message channel

This change hooks the service worker used for loading webview resources directly up to the main VS Code process over a message channel. Previously this communication had to go through an extra hop through the webview

This simplifies the logic somewhat (although this change required adding extra logic to exchange the message port). It also improves performance a little

* Update webview content commit version
This commit is contained in:
Matt Bierner 2021-12-10 16:35:13 -08:00 committed by GitHub
parent 86ee106054
commit 66b6adf035
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 163 additions and 206 deletions

View File

@ -25,7 +25,7 @@
"licenseFileName": "LICENSE.txt",
"reportIssueUrl": "https://github.com/microsoft/vscode/issues/new",
"urlProtocol": "code-oss",
"webviewContentExternalBaseUrlTemplate": "https://{{uuid}}.vscode-webview.net/insider/9acd320edad7cea2c062d339fa04822c5eeb9e1d/out/vs/workbench/contrib/webview/browser/pre/",
"webviewContentExternalBaseUrlTemplate": "https://{{uuid}}.vscode-webview.net/insider/69df0500a8963fc469161c038a14a39384d5a303/out/vs/workbench/contrib/webview/browser/pre/",
"extensionAllowedProposedApi": [
"ms-vscode.vscode-js-profile-flame",
"ms-vscode.vscode-js-profile-table",

View File

@ -204,40 +204,28 @@ const workerReady = new Promise((resolve, reject) => {
return reject(new Error('Service Workers are not enabled. Webviews will not work. Try disabling private/incognito mode.'));
}
const swPath = `service-worker.js?vscode-resource-base-authority=${searchParams.get('vscode-resource-base-authority')}`;
const swPath = `service-worker.js?v=${expectedWorkerVersion}&vscode-resource-base-authority=${searchParams.get('vscode-resource-base-authority')}`;
navigator.serviceWorker.register(swPath).then(
async registration => {
await navigator.serviceWorker.ready;
/**
* @param {MessageEvent} event
*/
const versionHandler = async (event) => {
if (event.data.channel !== 'version') {
if (event.data.channel !== 'init') {
return;
}
navigator.serviceWorker.removeEventListener('message', versionHandler);
if (event.data.version === expectedWorkerVersion) {
return resolve();
} else {
console.log(`Found unexpected service worker version. Found: ${event.data.version}. Expected: ${expectedWorkerVersion}`);
console.log(`Attempting to reload service worker`);
// If we have the wrong version, try once (and only once) to unregister and re-register
// Note that `.update` doesn't seem to work desktop electron at the moment so we use
// `unregister` and `register` here.
return registration.unregister()
.then(() => navigator.serviceWorker.register(swPath))
.then(() => navigator.serviceWorker.ready)
.finally(() => { resolve(); });
}
// Forward the port back to VS Code
hostMessaging.onMessage('did-init-service-worker', () => resolve());
hostMessaging.postMessage('init-service-worker', {}, event.ports);
};
navigator.serviceWorker.addEventListener('message', versionHandler);
const postVersionMessage = () => {
assertIsDefined(navigator.serviceWorker.controller).postMessage({ channel: 'version' });
assertIsDefined(navigator.serviceWorker.controller).postMessage({ channel: 'init' });
};
// At this point, either the service worker is ready and
@ -388,26 +376,7 @@ const initData = {
themeName: undefined,
};
hostMessaging.onMessage('did-load-resource', (_event, data) => {
navigator.serviceWorker.ready.then(registration => {
assertIsDefined(registration.active).postMessage({ channel: 'did-load-resource', data }, data.data?.buffer ? [data.data.buffer] : []);
});
});
hostMessaging.onMessage('did-load-localhost', (_event, data) => {
navigator.serviceWorker.ready.then(registration => {
assertIsDefined(registration.active).postMessage({ channel: 'did-load-localhost', data });
});
});
navigator.serviceWorker.addEventListener('message', event => {
switch (event.data.channel) {
case 'load-resource':
case 'load-localhost':
hostMessaging.postMessage(event.data.channel, event.data);
return;
}
});
/**
* @param {HTMLDocument?} document
* @param {HTMLElement?} body

View File

@ -9,13 +9,12 @@
const sw = /** @type {ServiceWorkerGlobalScope} */ (/** @type {any} */ (self));
const VERSION = 2;
const VERSION = 3;
const resourceCacheName = `vscode-resource-cache-${VERSION}`;
const rootPath = sw.location.pathname.replace(/\/service-worker.js$/, '');
const searchParams = new URL(location.toString()).searchParams;
/**
@ -98,11 +97,16 @@ class RequestStore {
}
}
/**
* @typedef {{ readonly status: 200; id: number; path: string; mime: string; data: Uint8Array; etag: string | undefined; mtime: number | undefined; }
* | { readonly status: 304; id: number; path: string; mime: string; mtime: number | undefined }
* | { readonly status: 401; id: number; path: string }
* | { readonly status: 404; id: number; path: string }} ResourceResponse
*/
/**
* Map of requested paths to responses.
* @typedef {{ type: 'response', body: Uint8Array, mime: string, etag: string | undefined, mtime: number | undefined } |
* { type: 'not-modified', mime: string, mtime: number | undefined } |
* undefined} ResourceResponse
*
* @type {RequestStore<ResourceResponse>}
*/
const resourceRequestStore = new RequestStore();
@ -120,48 +124,41 @@ const notFound = () =>
const methodNotAllowed = () =>
new Response('Method Not Allowed', { status: 405, });
sw.addEventListener('message', async (event) => {
const vscodeMessageChannel = new MessageChannel();
sw.addEventListener('message', event => {
switch (event.data.channel) {
case 'version':
case 'init':
{
const source = /** @type {Client} */ (event.source);
sw.clients.get(source.id).then(client => {
if (client) {
client.postMessage({
channel: 'version',
version: VERSION
});
}
client?.postMessage({
channel: 'init',
version: VERSION
}, [vscodeMessageChannel.port2]);
});
return;
}
}
console.log('Unknown message');
});
vscodeMessageChannel.port1.onmessage = (event) => {
switch (event.data.channel) {
case 'did-load-resource':
{
/** @type {ResourceResponse} */
let response = undefined;
const data = event.data.data;
switch (data.status) {
case 200:
{
response = { type: 'response', body: data.data, mime: data.mime, etag: data.etag, mtime: data.mtime };
break;
}
case 304:
{
response = { type: 'not-modified', mime: data.mime, mtime: data.mtime };
break;
}
}
if (!resourceRequestStore.resolve(data.id, response)) {
console.log('Could not resolve unknown resource', data.path);
const response = event.data;
if (!resourceRequestStore.resolve(response.id, response)) {
console.log('Could not resolve unknown resource', response.path);
}
return;
}
case 'did-load-localhost':
{
const data = event.data.data;
const data = event.data;
if (!localhostRequestStore.resolve(data.id, data.location)) {
console.log('Could not resolve unknown localhost', data.origin);
}
@ -170,7 +167,7 @@ sw.addEventListener('message', async (event) => {
}
console.log('Unknown message');
});
};
sw.addEventListener('fetch', (event) => {
const requestUrl = new URL(event.request.url);
@ -192,7 +189,7 @@ sw.addEventListener('fetch', (event) => {
});
sw.addEventListener('install', (event) => {
event.waitUntil(sw.skipWaiting()); // Activate worker immediately
event.waitUntil(sw.skipWaiting());
});
sw.addEventListener('activate', (event) => {
@ -210,12 +207,6 @@ async function processResourceRequest(event, requestUrl) {
return notFound();
}
const webviewId = getWebviewIdForClient(client);
if (!webviewId) {
console.error('Could not resolve webview id');
return notFound();
}
const shouldTryCaching = (event.request.method === 'GET');
/**
@ -223,11 +214,7 @@ async function processResourceRequest(event, requestUrl) {
* @param {Response | undefined} cachedResponse
*/
async function resolveResourceEntry(entry, cachedResponse) {
if (!entry) {
return notFound();
}
if (entry.type === 'not-modified') {
if (entry.status === 304) { // Not modified
if (cachedResponse) {
return cachedResponse.clone();
} else {
@ -235,10 +222,14 @@ async function processResourceRequest(event, requestUrl) {
}
}
if (entry.status !== 200) {
return notFound();
}
/** @type {Record<string, string>} */
const headers = {
'Content-Type': entry.mime,
'Content-Length': entry.body.byteLength.toString(),
'Content-Length': entry.data.byteLength.toString(),
'Access-Control-Allow-Origin': '*',
};
if (entry.etag) {
@ -248,7 +239,7 @@ async function processResourceRequest(event, requestUrl) {
if (entry.mtime) {
headers['Last-Modified'] = new Date(entry.mtime).toUTCString();
}
const response = new Response(entry.body, {
const response = new Response(entry.data, {
status: 200,
headers
});
@ -261,12 +252,6 @@ async function processResourceRequest(event, requestUrl) {
return response.clone();
}
const parentClients = await getOuterIframeClient(webviewId);
if (!parentClients.length) {
console.log('Could not find parent client for request');
return notFound();
}
/** @type {Response | undefined} */
let cached;
if (shouldTryCaching) {
@ -280,17 +265,15 @@ async function processResourceRequest(event, requestUrl) {
const scheme = firstHostSegment.split('+', 1)[0];
const authority = firstHostSegment.slice(scheme.length + 1); // may be empty
for (const parentClient of parentClients) {
parentClient.postMessage({
channel: 'load-resource',
id: requestId,
path: requestUrl.pathname,
scheme,
authority,
query: requestUrl.search.replace(/^\?/, ''),
ifNoneMatch: cached?.headers.get('ETag'),
});
}
vscodeMessageChannel.port1.postMessage({
channel: 'load-resource',
id: requestId,
path: requestUrl.pathname,
scheme,
authority,
query: requestUrl.search.replace(/^\?/, ''),
ifNoneMatch: cached?.headers.get('ETag'),
});
return promise.then(entry => resolveResourceEntry(entry, cached));
}
@ -307,11 +290,6 @@ async function processLocalhostRequest(event, requestUrl) {
// that are not spawned by vs code
return fetch(event.request);
}
const webviewId = getWebviewIdForClient(client);
if (!webviewId) {
console.error('Could not resolve webview id');
return fetch(event.request);
}
const origin = requestUrl.origin;
@ -332,42 +310,13 @@ async function processLocalhostRequest(event, requestUrl) {
});
};
const parentClients = await getOuterIframeClient(webviewId);
if (!parentClients.length) {
console.log('Could not find parent client for request');
return notFound();
}
const { requestId, promise } = localhostRequestStore.create();
for (const parentClient of parentClients) {
parentClient.postMessage({
channel: 'load-localhost',
origin: origin,
id: requestId,
});
}
vscodeMessageChannel.port1.postMessage({
channel: 'load-localhost',
origin: origin,
id: requestId,
});
return promise.then(resolveRedirect);
}
/**
* @param {Client} client
* @returns {string | null}
*/
function getWebviewIdForClient(client) {
const requesterClientUrl = new URL(client.url);
return requesterClientUrl.searchParams.get('id');
}
/**
* @param {string} webviewId
* @returns {Promise<Client[]>}
*/
async function getOuterIframeClient(webviewId) {
const allClients = await sw.clients.matchAll({ includeUncontrolled: true });
return allClients.filter(client => {
const clientUrl = new URL(client.url);
const hasExpectedPathName = (clientUrl.pathname === `${rootPath}/` || clientUrl.pathname === `${rootPath}/index.html`);
return hasExpectedPathName && clientUrl.searchParams.get('id') === webviewId;
});
}

View File

@ -48,8 +48,6 @@ export const enum WebviewMessageChannels {
doUpdateState = 'do-update-state',
doReload = 'do-reload',
setConfirmBeforeClose = 'set-confirm-before-close',
loadResource = 'load-resource',
loadLocalhost = 'load-localhost',
webviewReady = 'webview-ready',
wheel = 'did-scroll-wheel',
fatalError = 'fatal-error',
@ -57,8 +55,29 @@ export const enum WebviewMessageChannels {
didKeydown = 'did-keydown',
didKeyup = 'did-keyup',
didContextMenu = 'did-context-menu',
// Service worker
initServiceWorker = 'init-service-worker',
didInitServiceWorker = 'did-init-service-worker',
}
const enum ServiceWorkerMessages {
// From
loadResource = 'load-resource',
loadLocalhost = 'load-localhost',
// To
didLoadLocalHost = 'did-load-localhost',
didLoadResource = 'did-load-resource',
}
type ResponseResponse =
| { readonly status: 200; id: number; path: string; mime: string; data: Uint8Array; etag: string | undefined; mtime: number | undefined; } // success
| { readonly status: 304; id: number; path: string; mime: string; mtime: number | undefined } // not modified
| { readonly status: 401; id: number; path: string } // unauthorized
| { readonly status: 404; id: number; path: string } // not found
;
interface IKeydownEvent {
key: string;
keyCode: number;
@ -99,7 +118,7 @@ export class WebviewElement extends Disposable implements IWebview, WebviewFindD
protected get platform(): string { return 'browser'; }
private readonly _expectedServiceWorkerVersion = 2; // Keep this in sync with the version in service-worker.js
private readonly _expectedServiceWorkerVersion = 3; // Keep this in sync with the version in service-worker.js
private _element: HTMLIFrameElement | undefined;
protected get element(): HTMLIFrameElement | undefined { return this._element; }
@ -294,28 +313,57 @@ export class WebviewElement extends Disposable implements IWebview, WebviewFindD
});
}));
this._register(this.on(WebviewMessageChannels.loadResource, (entry: { id: number, path: string, query: string, scheme: string, authority: string, ifNoneMatch?: string }) => {
try {
// Restore the authority we previously encoded
const authority = decodeAuthority(entry.authority);
const uri = URI.from({
scheme: entry.scheme,
authority: authority,
path: decodeURIComponent(entry.path), // This gets re-encoded
query: entry.query ? decodeURIComponent(entry.query) : entry.query,
});
this.loadResource(entry.id, uri, entry.ifNoneMatch);
} catch (e) {
this._send('did-load-resource', {
id: entry.id,
status: 404,
path: entry.path,
});
}
}));
this._register(this.on(WebviewMessageChannels.initServiceWorker, (_, e) => {
const swPort = e.ports[0];
swPort.onmessage = async (e) => {
switch (e.data.channel) {
case ServiceWorkerMessages.loadResource:
const entry = e.data;
try {
// Restore the authority we previously encoded
const authority = decodeAuthority(entry.authority);
const uri = URI.from({
scheme: entry.scheme,
authority: authority,
path: decodeURIComponent(entry.path), // This gets re-encoded
query: entry.query ? decodeURIComponent(entry.query) : entry.query,
});
this._register(this.on(WebviewMessageChannels.loadLocalhost, (entry: any) => {
this.localLocalhost(entry.id, entry.origin);
const body = await this.loadResource(entry.id, uri, entry.ifNoneMatch);
return swPort.postMessage({
channel: ServiceWorkerMessages.didLoadResource,
...body
});
} catch (e) {
return swPort.postMessage({
channel: ServiceWorkerMessages.didLoadResource,
id: entry.id,
status: 404,
path: entry.path,
});
}
case ServiceWorkerMessages.loadLocalhost:
try {
const redirect = await this.getLocalhostRedirect(entry.id, entry.origin);
return swPort.postMessage({
chanel: ServiceWorkerMessages.didLoadLocalHost,
id: entry.id,
origin: entry.origin,
location: redirect
});
} catch (e) {
return swPort.postMessage({
chanel: ServiceWorkerMessages.didLoadLocalHost,
id: entry.id,
origin: entry.origin,
location: undefined
});
}
}
};
this._send(WebviewMessageChannels.didInitServiceWorker);
}));
this.style();
@ -698,7 +746,7 @@ export class WebviewElement extends Disposable implements IWebview, WebviewFindD
}
}
private async loadResource(id: number, uri: URI, ifNoneMatch: string | undefined) {
private async loadResource(id: number, uri: URI, ifNoneMatch: string | undefined): Promise<ResponseResponse> {
try {
const result = await loadLocalResource(uri, {
ifNoneMatch,
@ -707,57 +755,48 @@ export class WebviewElement extends Disposable implements IWebview, WebviewFindD
switch (result.type) {
case WebviewResourceResponse.Type.Success:
{
const { buffer } = await streamToBuffer(result.stream);
return this._send('did-load-resource', {
id,
status: 200,
path: uri.path,
mime: result.mimeType,
data: buffer,
etag: result.etag,
mtime: result.mtime
});
}
const { buffer } = await streamToBuffer(result.stream);
return {
id,
status: 200,
path: uri.path,
mime: result.mimeType,
data: buffer,
etag: result.etag,
mtime: result.mtime
};
case WebviewResourceResponse.Type.NotModified:
{
return this._send('did-load-resource', {
id,
status: 304, // not modified
path: uri.path,
mime: result.mimeType,
mtime: result.mtime
});
}
return {
id,
status: 304, // not modified
path: uri.path,
mime: result.mimeType,
mtime: result.mtime
};
case WebviewResourceResponse.Type.AccessDenied:
{
return this._send('did-load-resource', {
id,
status: 401, // unauthorized
path: uri.path,
});
}
return {
id,
status: 401, // unauthorized
path: uri.path,
};
}
} catch {
// noop
}
return this._send('did-load-resource', {
return {
id,
status: 404,
path: uri.path,
});
};
}
private async localLocalhost(id: string, origin: string) {
private async getLocalhostRedirect(id: string, origin: string) {
const authority = this._environmentService.remoteAuthority;
const resolveAuthority = authority ? await this._remoteAuthorityResolverService.resolveAuthority(authority) : undefined;
const redirect = resolveAuthority ? await this._portMappingManager.getRedirect(resolveAuthority.authority, origin) : undefined;
return this._send('did-load-localhost', {
id,
origin,
location: redirect
});
return resolveAuthority ? this._portMappingManager.getRedirect(resolveAuthority.authority, origin) : undefined;
}
public focus(): void {

View File

@ -235,7 +235,7 @@ export class BrowserWorkbenchEnvironmentService implements IWorkbenchEnvironment
const webviewExternalEndpointCommit = this.payload?.get('webviewExternalEndpointCommit');
return endpoint
.replace('{{commit}}', webviewExternalEndpointCommit ?? this.productService.commit ?? '9acd320edad7cea2c062d339fa04822c5eeb9e1d')
.replace('{{commit}}', webviewExternalEndpointCommit ?? this.productService.commit ?? '69df0500a8963fc469161c038a14a39384d5a303')
.replace('{{quality}}', (webviewExternalEndpointCommit ? 'insider' : this.productService.quality) ?? 'insider');
}

View File

@ -65,7 +65,7 @@ async function runTestsInBrowser(browserType: BrowserType, endpoint: url.UrlWith
const testExtensionUri = url.format({ pathname: URI.file(path.resolve(optimist.argv.extensionDevelopmentPath)).path, protocol, host, slashes: true });
const testFilesUri = url.format({ pathname: URI.file(path.resolve(optimist.argv.extensionTestsPath)).path, protocol, host, slashes: true });
const payloadParam = `[["extensionDevelopmentPath","${testExtensionUri}"],["extensionTestsPath","${testFilesUri}"],["enableProposedApi",""],["webviewExternalEndpointCommit","9acd320edad7cea2c062d339fa04822c5eeb9e1d"],["skipWelcome","true"]]`;
const payloadParam = `[["extensionDevelopmentPath","${testExtensionUri}"],["extensionTestsPath","${testFilesUri}"],["enableProposedApi",""],["webviewExternalEndpointCommit","69df0500a8963fc469161c038a14a39384d5a303"],["skipWelcome","true"]]`;
if (path.extname(testWorkspaceUri) === '.code-workspace') {
await page.goto(`${endpoint.href}&workspace=${testWorkspaceUri}&payload=${payloadParam}`);