Skip to content

feat(browser-only): Extensions support#17122

Closed
kachurun wants to merge 17 commits intoeclipse-theia:masterfrom
kachurun:browser-only-ext
Closed

feat(browser-only): Extensions support#17122
kachurun wants to merge 17 commits intoeclipse-theia:masterfrom
kachurun:browser-only-ext

Conversation

@kachurun
Copy link
Copy Markdown
Contributor

@kachurun kachurun commented Mar 4, 2026

What it does

Add support for VS Code web extensions in browser-only deployments (pre-installed from /plugins/).
Fixes extension listing in the About dialog.

#12852

How to test

Install plugins as usual, build and run the browser-only example, then start the application.

Tested:

  • Themes (VS Code built-ins)
  • Icon themes (Material Icons)
  • Syntax highlighting (JS / TS / HTML / CSS)
  • IntelliSense (TypeScript, HTML)
  • Quick menus (with icons as well)
  • l10n
  • Extension list in the About dialog

Follow-ups

Extensions that rely on backend functionality may not work fully, as in browser-only mode, only the frontend plugin code is executed.
Extensions that don't have a browser target or contributions will not be copied at all.

VS Code extension localization (vscode.l10n) works. Localization bundles (e.g. bundle.l10n.<lang>.json) are loaded at runtime. However plugin manifests are generated with the default locale for this moment, so nls strings from manifests will not be localized.

How it works

Loading plugins in a browser-only environment requires the following:

  • Plugin code must be available as static assets so the frontend can access it (themes, icons, frontend JS code, etc.).
  • The frontend must receive a list of installed plugins.
  • The frontend must receive a manifest for each plugin.

In a normal Theia build these tasks are handled by the backend. To support browser-only deployments:

  • During the build step all plugins are copied into lib/frontend/hostedPlugin. Directory names are normalized to match the paths normally provided by the backend.
  • Each plugin manifest is localized in place to reduce the number of requests and startup time
  • A list.json file is generated with normalized manifests for all installed plugins.
  • The frontend loads this file and registers the plugins.

Apart from these changes, the plugin loading process remains unchanged.

A small refactoring was also performed:

  • Language suggestions are now loaded lazily instead of loading all languages at startup.
  • Themes are not loaded if they are already cached.
  • Fixed plugin context resolution. Extensions accessing the vscode API previously received the API bound to emptyPlugin, which caused issues such as broken localization. Extensions now receive the correct plugin context.
  • Extracted duplicated logic into shared helpers and constants in several places.

Breaking changes

  • This PR introduces breaking changes and requires careful review. If yes, the breaking changes section in the changelog has been updated.

Attribution

Review checklist

Reminder for reviewers

@github-project-automation github-project-automation Bot moved this to Waiting on reviewers in PR Backlog Mar 4, 2026
Comment thread examples/browser-only/package.json Outdated
Comment on lines +17 to +18
import * as fs from '@theia/core/shared/fs-extra';
import { ApplicationPackage } from '@theia/core/shared/@theia/application-package';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required by ESLint since @theia/core is now a dependency of application-manager

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to avoid a dependency from the build package to the runtime packages of Theia

Comment thread packages/filesystem/src/browser/read-resource.ts Outdated
Copy link
Copy Markdown
Contributor Author

@kachurun kachurun Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes here are needed to lazy-load snippets on demand instead of on Theia load. Otherwise, it loads all snippets of all languages from plugins on page load

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that mean that the snippets are now "slower" when shown?

Comment on lines +80 to +84
const existing = this.themeService.getTheme(id!);

if (existing.id === id && existing.editorTheme) {
return;
}
Copy link
Copy Markdown
Contributor Author

@kachurun kachurun Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not load the theme if it already exists in themeService. Otherwise, it loads all theme files each time Theia loads.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But the ThemeService is scoped to Theia? So whenever Theia loads we load here anyway. Do we load themes multiple times by accident? What are the steps to reproduce the issue?

return `${pluginId}/${Localization.transformKey(scope)}/${key}`;
}

// Localization logic for `package.json` entries
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Helpers were just moved to plugin-package-localization.ts

if (contributions.themes && contributions.themes.length) {
const pending = {};
for (const theme of contributions.themes) {
pushContribution(`themes.${theme.uri}`, () => this.monacoThemingService.register(theme, pending));
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pending was moved to a MonacoThemingService internal property instead of being drilled through all methods in it

@sdirix sdirix self-requested a review March 5, 2026 13:31
@kachurun kachurun force-pushed the browser-only-ext branch 2 times, most recently from 02d4bfa to 12470af Compare March 5, 2026 20:52
@kachurun kachurun changed the title feat(browser-only): Extensions support WIP: feat(browser-only): Extensions support Mar 5, 2026
@kachurun kachurun force-pushed the browser-only-ext branch 2 times, most recently from d6c51cd to 1bfa4af Compare March 7, 2026 15:31
@kachurun kachurun changed the title WIP: feat(browser-only): Extensions support feat(browser-only): Extensions support Mar 7, 2026
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am creating an Inversify DI container here during the build to load the classes we normally use with the Node backend at runtime

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conceptually I am fine with creating a DI container for the build

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this file I do:

  • Find the closest package with theiaPluginsDir specified
  • Copy those plugins to dist/frontend/hostedPlugin
  • Localize package.json to skip package.nls.json loading at runtime
  • Create a DeployerPlugin shape from plugin/package.json
  • Normalize paths
  • Generate dist/frontend/hostedPlugin/list.json with all deployed plugins

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally speaking this makes sense to me

Comment on lines +71 to +77
// If package contains localizable strings, load the translations and localize the manifest
if (hasLocalizableStrings(manifest)) {
const translations = await loadTranslations(pluginModel);
if (Object.keys(translations).length > 0) {
return localizeWithResolver(manifest, key => translations[key]);
}
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Browser-only translations in package.json are already translated, so there is no reason to load them

Comment on lines +363 to +367
// do not start backend plugins for browser-only
if (environment.browserOnly.is()) {
return false;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Attempting to run backend plugins in a browser-only environment leads to an endless await from onWillSave (something like this), so files just never save. Maybe it's not the right place, but it solves the issue.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have another fix: backend plugins could be excluded from the very beginning in the browser-only plugin list, i.e. at build time.

Comment on lines +174 to +178
async localizeManifest(pluginPath: string, manifest: PluginPackage): Promise<PluginPackage> {
const locale = this.localizationProvider.getCurrentLanguage();
const translations = await loadPackageTranslations(pluginPath, locale);
return localizePackage(manifest, translations, (_, defaultVal) => defaultVal) as PluginPackage;
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can do this purely in the application manager where I use it, but then there will be a lot of duplicate code, but here everything is in place

try {
const pluginModel = plg.model;
const pluginLifecycle = plg.lifecycle;
const pluginFolder = FileUri.fsPath(pluginModel.packageUri);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

file:///home/abc/... -> /home/abc/...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code moved from packages/plugin-ext/src/hosted/node/hosted-plugin-localization-service.ts so I kept Original Copyright

Copy link
Copy Markdown
Contributor Author

@kachurun kachurun Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code also moved from packages/plugin-ext/src/hosted/node/plugin-reader.ts so copyright is original, added only .mjs extension in list, just in case

Comment on lines +392 to +395
const commandDef = typeof command === 'string' ? { id: command } : command;

if (handler && commandIsDeclaredInPackage(commandDef.id, plugin.rawModel)) {
return commandRegistry.registerHandler(commandDef.id, handler, thisArg);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To simplify the code and fix the issue when commands are registered twice and warn

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a drive-by fix? Could also be a separate PR

Comment on lines +1685 to +1686
const ownURI = new Endpoint().getRestUrl();
const fullURI = ownURI.parent.resolve(PluginPackage.toPluginUrl(plugin.model, ''));
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

browser-only: Allow serving static files from a base path other than /, for example /ide/bundle.js, /ide/hostedPlugin/*, etc.

Comment on lines +48 to +52
const packageRoot = (URI.parse(plugin.model.packageUri).path || plugin.model.packageUri).replace(/\\/g, '/');
const absolutePath = normalize(arg.startsWith('/') ? arg : resolve(packageRoot, arg));
const relativePath = relative(packageRoot, absolutePath);

return PluginPackage.toPluginUrl(plugin.model, relativePath);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To resolve icons relative to the plugin package for any environment and with a non-standard base path for browser-only

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was completely broken in a browser-only environment, so any plugins that implement quick open menus just fail

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugins can't start if terminals are not initialized, so we must have these mocks

Comment on lines +184 to 199
get: (_target: object, name: ('_empty' | keyof typeof theia)) => {
const plugin = pluginsModulesNames.get(ctx.frontendModuleName);

if (!defaultApi) {
defaultApi = apiFactory(emptyPlugin);
const api = plugin
? pluginsApiImpl.get(plugin.model.id)
: (defaultApi ?? (defaultApi = apiFactory(emptyPlugin)));

// require('vscode') resolves to `theia._empty` return full API so plugin gets the whole object
if (name === '_empty') {
return api;
}

return defaultApi;
// Direct access (theia.l10n, theia.commands, etc.) return that property from the API
return api![name];
}
};
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes l10n in plugins and potentially several other issues, because plugins now receive the correct plugin context in browser-only environments.
The previous implementation did not make much sense because name was always '_empty' rather than the actual plugin name. The plugin name can be derived from ctx.frontendModuleName instead.
This also makes loading a specific key, rather than the entire API object, work correctly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Localization should now basically work. I verified the mechanism using an English → English override: bundle.l10n..json is loaded and parsed, the correct plugin context is resolved, and overridden strings are returned. This should work for other languages as well. However, plugin manifests are not localized yet, so the practical impact is currently limited.

@sdirix
Copy link
Copy Markdown
Member

sdirix commented Mar 9, 2026

@kachurun You are still working on this PR. Can you indicate in which state this PR is, e.g. draft, functionally complete, open-issues, polished and ready to merge etc., and correspondingly what level of review you are currently looking for?

@kachurun
Copy link
Copy Markdown
Contributor Author

kachurun commented Mar 9, 2026

@sdirix
No, I'm done with it, removed the WIP label, and commented on each file in the PR to explain why I made each change. It's ready for testing and review 👍

@kachurun
Copy link
Copy Markdown
Contributor Author

@sdirix I have tested it in my project for a week, and it works great, so it's ready for a complete review. I'm not going to change anything in the code. The only problems I have are not related to Theia. It's just that the TS LSP extension works in partial mode in a browser-only environment, but it doesn't work anywhere: https://code.visualstudio.com/docs/nodejs/working-with-javascript#_partial-intellisense-mode

Copy link
Copy Markdown
Member

@sdirix sdirix left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this contribution. I tested it on my machine and it works! It's a great achievement to bring this into Theia Browser-Only and it's even convenient to use. Good job!

Generally speaking it would be good to reduce the PR to the bare minimum of what is required to bring the feature in. Any drive-by fix which is not related to the plugins itself should be moved out into a separate PR. I already commented two of these, but I think there are more like the command popup not working. These could be nice small PRs which are easy to review and would already be in the Theia codebase if opened separately.

This leaves two major points for me:

  • If the changes were mainly restricted to browser-only code, then I would have an easier time to approve. However as many parts of a core feature of Theia are touched, this will take more time to review. It would be good to restrict these changes to the bare minimum. For example it's unclear to me whether we really need to remove the deprecated field for this change.
  • I am unsure about adding Theia runtime packages as dependency to the dev package. At the moment we already have dependencies the other way around, with this they become fully intertwined. To me it seems it would be cleaner to extract the logic into an own package which is consumed by both. However of course this would increase the scope of the PR which is what I argued against before.

@msujew Do you have an opinion on the plugin discovery mechanism and how it should be consumed as a build step for browser-only?

Comment on lines +32 to +38
"@theia/core": "1.69.0",
"@babel/core": "^7.10.0",
"@babel/plugin-transform-classes": "^7.10.0",
"@babel/plugin-transform-runtime": "^7.10.0",
"@babel/preset-env": "^7.10.0",
"@theia/application-package": "1.69.0",
"@theia/plugin-ext": "1.69.0",
"@theia/plugin-ext-vscode": "1.69.0",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major architectural concern. @theia/application-manager is a low-level dev-package that previously only depended on other dev packages. Adding @theia/core, @theia/plugin-ext, and @theia/plugin-ext-vscode as dependencies pulls in the entire runtime dependency tree into the build tool.

Conceptually this is not clean. I understand why this was done, to bring in the plugin detection logic from runtime to build time, but this needs to be done more cleanly. For example by extracting the logic into an own package which is then reused.

/** Frontend (browser / WebWorker) plugin host id */
export const PLUGIN_HOST_FRONTEND = 'frontend';
/** Identifier for where a plugin runs (RPC/messaging) */
export type PluginHost = typeof PLUGIN_HOST_FRONTEND | typeof PLUGIN_HOST_BACKEND;
Copy link
Copy Markdown
Member

@sdirix sdirix Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PluginHost type was previously 'frontend' | string (in hosted/common/hosted-plugin.ts, link). Now it's narrowed to 'frontend' | 'main'.

This is not sufficient as we also support headless plugins. So it should be at minimum frontend, main, headless. In theory we could even support more hosts, so I think having an explicit string would make sense. main is also not an ideal word, it should likely be expressing that it's tied to the frontend.

export class PluginScannerResolverImpl implements PluginScannerResolver {
private readonly scannersByType = new Map<string, PluginScanner>();

constructor(@multiInject(PluginScanner) scanners: PluginScanner[]) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use multiinject in Theia, we use Theia's utility ContributionProvider.

Usually we also avoid constructor injection and use field injection instead.

private loadPromise: Promise<void> | undefined;

constructor(
@inject(BrowserOnlyPluginsProvider) protected readonly pluginsProvider: BrowserOnlyPluginsProvider
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Comment on lines +86 to +91
function findInMap(map: Record<string, string> | undefined, key: string): string | undefined {
if (!map) {return undefined; }
if (key in map) {return map[key]; }
const found = Object.keys(map).find(k => k.toLowerCase() === key.toLowerCase());
return found ? map[found] : undefined;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes localization behavior for all plugins, not just browser-only as previously we were not case insensitive.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that mean that the snippets are now "slower" when shown?

Comment on lines +80 to +84
const existing = this.themeService.getTheme(id!);

if (existing.id === id && existing.editorTheme) {
return;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But the ThemeService is scoped to Theia? So whenever Theia loads we load here anyway. Do we load themes multiple times by accident? What are the steps to reproduce the issue?

const plugin: Plugin = {
pluginPath: pluginModel.entryPoint.frontend!,
pluginFolder: pluginModel.packagePath,
pluginFolder: pluginModel.packageUri,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The final removal of packagePath and the consistent use of packageUri must be mentioned in the breaking changes of the changelog.

Comment on lines +363 to +367
// do not start backend plugins for browser-only
if (environment.browserOnly.is()) {
return false;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have another fix: backend plugins could be excluded from the very beginning in the browser-only plugin list, i.e. at build time.

Comment on lines +392 to +395
const commandDef = typeof command === 'string' ? { id: command } : command;

if (handler && commandIsDeclaredInPackage(commandDef.id, plugin.rawModel)) {
return commandRegistry.registerHandler(commandDef.id, handler, thisArg);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a drive-by fix? Could also be a separate PR

@github-project-automation github-project-automation Bot moved this from Waiting on reviewers to Waiting on author in PR Backlog Mar 20, 2026
@ndoschek ndoschek mentioned this pull request Mar 23, 2026
25 tasks
@kachurun kachurun closed this Mar 29, 2026
@github-project-automation github-project-automation Bot moved this from Waiting on author to Done in PR Backlog Mar 29, 2026
@kachurun
Copy link
Copy Markdown
Contributor Author

@sdirix Thank you for the detailed review! I will prepare the necessary changes in the chain of separated PRs.

These ones are ready and could be reviewed or merged:

Prepare plugins:
#17258

Fix mount host:
#17253

@kachurun kachurun mentioned this pull request Mar 30, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants