Skip to content

Commit

Permalink
Improve lazy loading of Monaco editor and fix webworker warning
Browse files Browse the repository at this point in the history
- Load web worker through blob URL
- Avoid scripts from being loaded automatically if they are dynamic

Co-authored-by: Olaf Lessenich <olessenich@eclipsesource.com>
  • Loading branch information
xai authored and martin-fleck-at committed Mar 19, 2024
1 parent ee5ce00 commit 890626e
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 36 deletions.
52 changes: 52 additions & 0 deletions extension/src/base/build-manifest.ts
@@ -0,0 +1,52 @@
import fs from 'fs';
import * as vscode from 'vscode';

// from https://github.com/vitejs/vite/blob/main/packages/vite/src/node/plugins/manifest.ts
export type ViteManifest = Record<string, ViteManifestChunk>;

export interface ViteManifestChunk {
src?: string;
file: string;
css?: string[];
assets?: string[];
isEntry?: boolean;
name?: string;
isDynamicEntry?: boolean;
imports?: string[];
dynamicImports?: string[];
}

export interface ViteManifestEntry {
source: string;
chunk: ViteManifestChunk;
}

export const ROOT_ENTRY = 'index.html';
export const EDITOR_WORKER_ENTRY = 'editor.worker.js?worker';

export function parseBuildManifest(path: string): ViteManifest {
return JSON.parse(fs.readFileSync(path, 'utf8'));
}

export function findRootEntry(manifest: ViteManifest): ViteManifestEntry {
const rootEntry = Object.entries(manifest).find(entry => entry[1].isEntry);
return { source: rootEntry![0], chunk: rootEntry![1] };
}

export function findEditorWorkerWrapperChunk(manifest: ViteManifest): ViteManifestChunk | undefined {
return Object.entries(manifest).find(entry => entry[0].endsWith(EDITOR_WORKER_ENTRY))?.[1];
}

export function findEditorWorker(appPath: vscode.Uri, manifest: ViteManifest): vscode.Uri | undefined {
// Finding the location of the editor worker is a bit tricky as Vite automatically generates a wrapper for it
// But for web views we need the actual code so we can turn it into a blob URL later on
const workerWrapper = findEditorWorkerWrapperChunk(manifest);
if (!workerWrapper) {
return;
}
// find the editor worker file that is not the wrapper
const assetsDirectory = vscode.Uri.joinPath(appPath, 'assets');
const assetFiles = fs.readdirSync(assetsDirectory.fsPath);
const editorWorker = assetFiles.find(fileName => fileName.includes('editor.worker') && !workerWrapper.file.includes(fileName));
return editorWorker ? vscode.Uri.joinPath(assetsDirectory, editorWorker) : undefined;
}
37 changes: 17 additions & 20 deletions extension/src/process-editor/ivy-editor-provider.ts
@@ -1,10 +1,10 @@
import { executeCommand } from '../base/commands';
import { GlspVscodeConnector, GlspEditorProvider, DisposableCollection } from '@eclipse-glsp/vscode-integration';
import * as vscode from 'vscode';
import fs from 'fs';
import { NotificationType, RequestType, MessageParticipant } from 'vscode-messenger-common';
import { InscriptionWebSocketMessage, IvyScriptWebSocketMessage, WebSocketForwarder } from '../websocket-forwarder';
import { Messenger } from 'vscode-messenger';
import { findEditorWorker, findRootEntry, parseBuildManifest } from '../base/build-manifest';

const ColorThemeChangedNotification: NotificationType<'dark' | 'light'> = { method: 'colorThemeChanged' };
const WebviewConnectionReadyNotification: NotificationType<void> = { method: 'connectionReady' };
Expand Down Expand Up @@ -53,28 +53,29 @@ export default class IvyEditorProvider extends GlspEditorProvider {
}
const nonce = getNonce();

const manifest = JSON.parse(fs.readFileSync(this.getAppUri('build.manifest.json').fsPath, 'utf8'));
const manifest = parseBuildManifest(this.getAppUri('build.manifest.json').fsPath);
const rootEntry = findRootEntry(manifest)!;

const rootHtmlKey = this.findRootHtmlKey(manifest);
const relevantScriptFiles = rootEntry.chunk.dynamicImports
? new Set<string>([rootEntry.source, ...rootEntry.chunk.dynamicImports])
: [rootEntry.source];

const relativeRootPath = manifest[rootHtmlKey]['file'] as string;
const indexJs = webview.asWebviewUri(this.getAppUri(relativeRootPath));
const scriptSources = Array.from(relevantScriptFiles)
.map(chunk => manifest[chunk])
.filter(chunk => !chunk.isDynamicEntry)
.map(chunk => webview.asWebviewUri(this.getAppUri(chunk.file)));

const dynamicImports = manifest[rootHtmlKey]['dynamicImports'] as Array<string>;
const jsUris = dynamicImports
.map(i => manifest[i]['file'] as string)
.map(relativePath => webview.asWebviewUri(this.getAppUri(relativePath)));

const relativeCssFilePaths = manifest[rootHtmlKey]['css'] as string[];
const cssUris = relativeCssFilePaths.map(relativePath => webview.asWebviewUri(this.getAppUri(relativePath)));
const cssUris = rootEntry.chunk.css?.map(relativePath => webview.asWebviewUri(this.getAppUri(relativePath))) ?? [];
cssUris.push(webview.asWebviewUri(vscode.Uri.joinPath(this.extensionContext.extensionUri, 'css', 'inscription-editor.css')));

const editorWorkerLocation = webview.asWebviewUri(findEditorWorker(this.getAppUri(), manifest)!);

const contentSecurityPolicy =
`default-src 'none';` +
`style-src 'unsafe-inline' ${webview.cspSource};` +
`img-src ${webview.cspSource} https: data:;` +
`script-src 'nonce-${nonce}';` +
`worker-src ${webview.cspSource};` +
`script-src 'nonce-${nonce}' *;` +
`worker-src ${webview.cspSource} blob: data:;` +
`font-src ${webview.cspSource};` +
`connect-src ${webview.cspSource}`;

Expand All @@ -86,12 +87,12 @@ export default class IvyEditorProvider extends GlspEditorProvider {
<meta http-equiv="Content-Security-Policy" content="${contentSecurityPolicy}">
<meta name="viewport" content="width=device-width, height=device-height">
${cssUris.map(cssUri => `<link rel="stylesheet" type="text/css" href="${cssUri}" />`).join('\n')}
<script nonce="${nonce}" id="_editorWorkerLocation">globalThis.editorWorkerLocation = "${editorWorkerLocation}";</script>
</head>
<body>
<div id="${clientId}_container" class="main-widget"></div>
<div id="inscription"></div>
<script nonce="${nonce}" type="module" src="${indexJs}"></script>
${jsUris.map(jsUri => `<script nonce="${nonce}" type="module" src="${jsUri}"></script>`).join('\n')}
${scriptSources.map(jsUri => `<script nonce="${nonce}" type="module" src="${jsUri}"></script>`).join('\n')}
</body>
</html>`;
}
Expand All @@ -115,10 +116,6 @@ export default class IvyEditorProvider extends GlspEditorProvider {
private getAppUri(...pathSegments: string[]) {
return vscode.Uri.joinPath(this.extensionContext.extensionUri, 'webviews', 'process-editor', 'dist', ...pathSegments);
}

private findRootHtmlKey(buildManifest: object) {
return Object.keys(buildManifest).filter(key => key.endsWith('index.html'))[0];
}
}

function getNonce() {
Expand Down
55 changes: 40 additions & 15 deletions extension/webviews/process-editor/src/app.ts
Expand Up @@ -2,15 +2,12 @@ import '@eclipse-glsp/vscode-integration-webview/css/glsp-vscode.css';
import '../css/colors.css';
import '../css/diagram.css';

import { MonacoUtil } from '@axonivy/inscription-core';
import { MonacoEditorUtil } from '@axonivy/inscription-editor';
import { createIvyDiagramContainer, ivyBreakpointModule } from '@axonivy/process-editor';
import { ivyInscriptionModule } from '@axonivy/process-editor-inscription';
import { ContainerConfiguration } from '@eclipse-glsp/client';
import { GLSPStarter } from '@eclipse-glsp/vscode-integration-webview';
import { Container } from 'inversify';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import { NotificationType } from 'vscode-messenger-common';
import { Messenger } from 'vscode-messenger-webview';
import ivyStartActionModule from './start/di.config';
Expand All @@ -23,18 +20,7 @@ const ColorThemeChangedNotification: NotificationType<ColorTheme> = { method: 'c
class IvyGLSPStarter extends GLSPStarter {
constructor() {
super();
this.initMonaco();
}

private async initMonaco() {
const isMonacoReady = MonacoUtil.initStandalone(editorWorker);
this.messenger.onNotification(ColorThemeChangedNotification, theme => this.updateMonacoTheme(theme, isMonacoReady));
await isMonacoReady;
await MonacoEditorUtil.configureInstance(monaco, 'light');
}

private updateMonacoTheme(theme: ColorTheme, isMonacoReady: Promise<void>) {
isMonacoReady.then(() => monaco.editor.defineTheme(MonacoEditorUtil.DEFAULT_THEME_NAME, MonacoEditorUtil.themeData(theme)));
this.messenger.onNotification(ColorThemeChangedNotification, theme => MonacoEditorUtil.setTheme(theme));
}

createContainer(...containerConfiguration: ContainerConfiguration): Container {
Expand All @@ -55,5 +41,44 @@ class IvyGLSPStarter extends GLSPStarter {
}

export function launch() {
initMonaco();
new IvyGLSPStarter();
}

declare global {
// Must be set in the IvyEditorProvider when the webview HTML code is generated
const editorWorkerLocation: string;
}

async function initMonaco(): Promise<void> {
// Packaging with Vite has it's own handling of web workers so it can be properly accessed without any custom configuration.
// We therefore import the worker here and to trigger the generation in the 'dist' directory.
//
await import('monaco-editor/esm/vs/editor/editor.worker?worker');
//
// According to the documentation (https://code.visualstudio.com/api/extension-guides/webview#using-web-workers) web worker
// support within web views is limited. So we cannot access the script directly or through fetching directly without running into:
//
// > Failed to construct 'Worker': Script at 'https://file+.vscode-resource.vscode-cdn.net/[...]' cannot be accessed from origin 'vscode-webview://[...]'.
//
// Not creating any workers or unsuccessfully creating workers will show the following warnings in the console.
//
// > "Could not create web worker(s). Falling back to loading web worker code in main thread, which might cause UI freezes."
// > "You must define a function MonacoEnvironment.getWorkerUrl or MonacoEnvironment.getWorker"
//
// So what we do is we expose the editor worker location as Webview Uri in our IvyEditorProvider and store it in the 'editorWorkerLocation' variable.
// From there, we fetch the script properly and create a blob worker from that.
//
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const script = await fetch((globalThis as any).editorWorkerLocation);
const blob = await script.blob();
class BlobWorker extends Worker {
constructor(workerId?: string, label?: string, url = URL.createObjectURL(blob)) {
super(url, { name: workerId ?? label });
this.addEventListener('error', () => {
URL.revokeObjectURL(url);
});
}
}
MonacoEditorUtil.configureInstance({ theme: 'light', worker: { workerConstructor: BlobWorker } });
}
11 changes: 10 additions & 1 deletion extension/webviews/process-editor/vite.config.ts
Expand Up @@ -7,7 +7,16 @@ export default defineConfig(() => {
build: {
manifest: 'build.manifest.json',
outDir: 'dist',
chunkSizeWarningLimit: 5000
chunkSizeWarningLimit: 5000,
rollupOptions: {
output: {
manualChunks(id: string) {
if (id.includes('monaco-languageclient' || id.includes('vscode'))) {
return 'monaco-chunk';
}
}
}
}
},
server: {
port: 3000,
Expand Down

0 comments on commit 890626e

Please sign in to comment.