Add experimental service-worker based loading of webview content

## Problem
We use a custom `vscode-resource` protocol to control access to local resources inside of webviews. This will not work on the web, but we still would prefer a way to intercept webview requests from the main client

## Proposed solution
Move webviews into their own origin and register a service worker on this origin. This service worker can talk with the outer iframe of our webview. When a request for a resource comes in to the service worker:

* In the service worker, add the request to a map and post a message back to the client saying we want to load this resource
* The outer iframe gets the message from the sercice worker and forwards it to our main process
* This process handles the message and use the normal file system api to read the resource (also restricting which files can be read)
* We post the result back into the inner iframe which fowards it back to the service worker
* The service worker now resolves the pending request.

The prototype version in this change works but does not correctly handle multiple clients existing at the same time (plus probably a lot of other edge cases too)
This commit is contained in:
Matt Bierner
2019-06-15 12:35:18 -07:00
parent 8fcbea2b10
commit 1319038ec0
8 changed files with 172 additions and 10 deletions
+2 -1
View File
@@ -17,7 +17,8 @@
folderUri: '{{FOLDER}}',
workspaceUri: '{{WORKSPACE}}',
userDataUri: '{{USER_DATA}}',
authority: '{{AUTHORITY}}'
authority: '{{AUTHORITY}}',
webviewEndpoint: '{{WEBVIEW_ENDPOINT}}'
}
</script>
@@ -151,4 +151,6 @@ export interface IEnvironmentService {
driverHandle?: string;
driverVerbose: boolean;
webviewEndpoint?: string;
}
+5 -1
View File
@@ -41,6 +41,7 @@ interface IWindowConfiguration {
userDataUri: URI;
folderUri?: URI;
workspaceUri?: URI;
webviewEndpoint?: string;
}
class CodeRendererMain extends Disposable {
@@ -149,6 +150,7 @@ class CodeRendererMain extends Disposable {
port: null,
break: false
};
environmentService.webviewEndpoint = this.configuration.webviewEndpoint;
return environmentService;
}
@@ -189,6 +191,7 @@ export interface IWindowConfigurationContents {
userDataUri: UriComponents;
folderUri?: UriComponents;
workspaceUri?: UriComponents;
webviewEndpoint?: string;
}
export function main(windowConfigurationContents: IWindowConfigurationContents): Promise<void> {
@@ -196,7 +199,8 @@ export function main(windowConfigurationContents: IWindowConfigurationContents):
userDataUri: URI.revive(windowConfigurationContents.userDataUri),
remoteAuthority: windowConfigurationContents.authority,
folderUri: windowConfigurationContents.folderUri ? URI.revive(windowConfigurationContents.folderUri) : undefined,
workspaceUri: windowConfigurationContents.workspaceUri ? URI.revive(windowConfigurationContents.workspaceUri) : undefined
workspaceUri: windowConfigurationContents.workspaceUri ? URI.revive(windowConfigurationContents.workspaceUri) : undefined,
webviewEndpoint: windowConfigurationContents.webviewEndpoint
};
const renderer = new CodeRendererMain(windowConfiguration);
@@ -228,6 +228,7 @@ export class SimpleWorkbenchEnvironmentService implements IWorkbenchEnvironmentS
disableCrashReporter: boolean;
driverHandle?: string;
driverVerbose: boolean;
webviewEndpoint?: string;
}
@@ -118,6 +118,28 @@
initialScrollProgress: undefined
};
// Service worker for resource loading
const FAKE_LOAD = !!navigator.serviceWorker;
if (navigator.serviceWorker) {
navigator.serviceWorker.register('service-worker.js');
navigator.serviceWorker.ready.then(registration => {
registration.active.postMessage('ping');
host.onMessage('loaded-resource', event => {
registration.active.postMessage({ channel: 'loaded-resource', data: event.data.args });
});
});
navigator.serviceWorker.addEventListener('message', event => {
switch (event.data.channel) {
case 'load-resource':
host.postMessage('load-resource', { path: event.data.path });
return;
}
});
}
/**
* @param {HTMLDocument?} document
* @param {HTMLElement?} body
@@ -337,15 +359,30 @@
newFrame.setAttribute('id', 'pending-frame');
newFrame.setAttribute('frameborder', '0');
newFrame.setAttribute('sandbox', options.allowScripts ? 'allow-scripts allow-forms allow-same-origin' : 'allow-same-origin');
if (FAKE_LOAD) {
// We should just be able to use srcdoc, but I wasn't
// seeing the service worker applying properly.
// Fake load an empty on the correct origin and then write real html
// into it to get around this.
newFrame.src = '/fake.html';
}
newFrame.style.cssText = 'display: block; margin: 0; overflow: hidden; position: absolute; width: 100%; height: 100%; visibility: hidden';
document.body.appendChild(newFrame);
// write new content onto iframe
newFrame.contentDocument.open('text/html', 'replace');
if (!FAKE_LOAD) {
// write new content onto iframe
newFrame.contentDocument.open();
}
newFrame.contentWindow.addEventListener('keydown', handleInnerKeydown);
newFrame.contentWindow.addEventListener('DOMContentLoaded', e => {
if (FAKE_LOAD) {
newFrame.contentDocument.open();
newFrame.contentDocument.write('<!DOCTYPE html>');
newFrame.contentDocument.write(newDocument.documentElement.innerHTML);
newFrame.contentDocument.close();
}
const contentDocument = e.target ? (/** @type {HTMLDocument} */ (e.target)) : undefined;
if (contentDocument) {
applyStyles(contentDocument, contentDocument.body);
@@ -414,9 +451,11 @@
// set DOCTYPE for newDocument explicitly as DOMParser.parseFromString strips it off
// and DOCTYPE is needed in the iframe to ensure that the user agent stylesheet is correctly overridden
newFrame.contentDocument.write('<!DOCTYPE html>');
newFrame.contentDocument.write(newDocument.documentElement.innerHTML);
newFrame.contentDocument.close();
if (!FAKE_LOAD) {
newFrame.contentDocument.write('<!DOCTYPE html>');
newFrame.contentDocument.write(newDocument.documentElement.innerHTML);
newFrame.contentDocument.close();
}
host.postMessage('did-set-content', undefined);
});
@@ -0,0 +1,84 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// Listen for messages from clients.
const resolvedPaths = new Map();
self.addEventListener('message', (event) => {
switch (event.data.channel) {
case 'loaded-resource':
{
const data = event.data.data;
const target = resolvedPaths.get(data.path);
if (!target) {
console.log('Loaded unknown resource', data.path);
return;
}
if (data.status === 200) {
target.resolve(new Response(data.data, {
status: 200,
headers: { 'Content-Type': data.mime },
}).clone());
} else {
target.resolve(new Response('Not Found', {
status: 404,
}).clone());
}
}
return;
}
});
var clients;
const resourceRoot = '/vscode-resource';
self.addEventListener('fetch', (event) => {
const requestUrl = new URL(event.request.url);
if (!requestUrl.pathname.startsWith(resourceRoot + '/')) {
return event.respondWith(fetch(event.request));
}
event.respondWith((async () => {
const resourcePath = requestUrl.pathname.replace(resourceRoot, '');
const existing = resolvedPaths.get(resourcePath);
if (existing) {
return existing.promise.then(r => r.clone());
}
const allClients = await clients.matchAll({
includeUncontrolled: true
});
for (const client of allClients) {
const clientUrl = new URL(client.url);
if (clientUrl.pathname === '/') {
client.postMessage({
channel: 'load-resource',
path: resourcePath
});
if (resolvedPaths.has(resourcePath)) {
// Someone else added it in the mean time
return resolvedPaths.get(resolvedPaths).promise;
}
let resolve;
const promise = new Promise(r => resolve = r);
resolvedPaths.set(resourcePath, { resolve, promise });
return promise.then(r => r.clone());
}
}
})());
});
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting()); // Activate worker immediately
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim()); // Become available to all pages
});
@@ -16,6 +16,7 @@ import { areWebviewInputOptionsEqual } from 'vs/workbench/contrib/webview/browse
import { addDisposableListener, addClass } from 'vs/base/browser/dom';
import { getWebviewThemeData } from 'vs/workbench/contrib/webview/common/themeing';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { loadLocalResource } from 'vs/workbench/contrib/webview/common/resourceLoader';
interface WebviewContent {
readonly html: string;
@@ -34,12 +35,12 @@ export class IFrameWebview extends Disposable implements Webview {
private readonly id: string;
constructor(
_options: WebviewOptions,
private _options: WebviewOptions,
contentOptions: WebviewContentOptions,
@IInstantiationService instantiationService: IInstantiationService,
@IThemeService themeService: IThemeService,
@IEnvironmentService environmentService: IEnvironmentService,
@IFileService fileService: IFileService,
@IFileService private readonly fileService: IFileService,
@ITelemetryService telemetryService: ITelemetryService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
) {
@@ -55,7 +56,7 @@ export class IFrameWebview extends Disposable implements Webview {
this.element = document.createElement('iframe');
this.element.sandbox.add('allow-scripts');
this.element.sandbox.add('allow-same-origin');
this.element.setAttribute('src', `/src/vs/workbench/contrib/webview/browser/pre/index.html?id=${this.id}`);
this.element.setAttribute('src', `${environmentService.webviewEndpoint}?id=${this.id}`); // TODO: get this from env service
this.element.style.border = 'none';
this.element.style.width = '100%';
this.element.style.height = '100%';
@@ -108,6 +109,13 @@ export class IFrameWebview extends Disposable implements Webview {
this.handleFocusChange(false);
return;
case 'load-resource':
{
const path = e.data.data.path;
const uri = URI.file(path);
this.loadResource(uri);
return;
}
}
}));
@@ -262,5 +270,28 @@ export class IFrameWebview extends Disposable implements Webview {
const { styles, activeTheme } = getWebviewThemeData(theme, this._configurationService);
this._send('styles', { styles, activeTheme });
}
private async loadResource(uri: URI) {
try {
const result = await loadLocalResource(uri, this.fileService, this._options.extension ? this._options.extension.location : undefined,
() => (this.content.options.localResourceRoots || []));
if (result.type === 'success') {
return this._send('loaded-resource', {
status: 200,
path: uri.path,
mime: result.mimeType,
data: result.data.buffer
});
}
} catch {
// noop
}
return this._send('loaded-resource', {
status: 404,
path: uri.path
});
}
}