@onivoro/server-vscode
v24.33.15
Published
NestJS-based framework for building VSCode extensions with a three-tier architecture: extension host + stdio server + React webview.
Readme
@onivoro/server-vscode
NestJS-based framework for building VSCode extensions with a three-tier architecture: extension host + stdio server + React webview.
Installation
npm install @onivoro/server-vscodeOverview
Each VSCode extension consists of four Nx projects that work together:
| Project | Location | Runtime | Build Tool | Purpose |
|---|---|---|---|---|
| app-browser-my-extension | apps/browser/my-extension/ | Browser (webview) | Vite | React UI rendered inside VSCode's webview panel |
| app-stdio-my-extension | apps/stdio/my-extension/ | Node.js (child process) | Webpack | NestJS backend server communicating via stdio JSON-RPC |
| app-vscode-my-extension | apps/vscode/my-extension/ | Node.js (extension host) | Webpack | VSCode extension host — orchestrates server + webview |
| lib-isomorphic-my-extension | libs/isomorphic/my-extension/ | Any | Vite | Shared constants and types across all three tiers |
Communication Architecture
┌─────────────────────────────────────────────────────────┐
│ VSCode Extension Host (apps/vscode/my-extension) │
│ │
│ ┌───────────────────┐ ┌──────────────────────────┐ │
│ │ CommandHandlers │ │ WebviewHandlers │ │
│ │ (palette cmds) │ │ (webview→extension msgs) │ │
│ └───────────────────┘ └──────────────────────────┘ │
│ │
│ ┌───────────────────┐ ┌──────────────────────────┐ │
│ │ ServerNotification│ │ WebviewProvider │ │
│ │ Handlers │ │ (serves React app) │ │
│ └───────────────────┘ └──────────────────────────┘ │
│ ▲ ▲ │
└───────────┼─────────────────────────┼───────────────────┘
│ stdio JSON-RPC │ postMessage
▼ ▼
┌───────────────────────┐ ┌──────────────────────────────┐
│ Stdio Server │ │ React Webview │
│ (apps/stdio/ │ │ (apps/browser/ │
│ my-extension) │ │ my-extension) │
│ │ │ │
│ NestJS + @StdioHandler│ │ Redux + MessageBus middleware│
│ Spawned as child proc │ │ Rendered in webview iframe │
└───────────────────────┘ └──────────────────────────────┘Message Routing
- Webview → Server (request/response): Webview dispatches Redux action →
messageBusMiddlewareintercepts → sends viaWebviewMessageBus→ extension receives and checks for a@WebviewHandler→ if none, forwards to stdio server → server@StdioHandlerprocesses and responds → response dispatched back to Redux - Server → Extension (notifications): Server calls
messageBus.sendNotification()→ extension@ServerNotificationHandlerreceives → can trigger VSCode UI (messages, status bar, etc.) - Extension → Server (request/response): Command handler calls
messageBus.sendRequest()→ routes to stdio server →@StdioHandlerprocesses and responds - Extension → Webview (notifications): Notifications are automatically broadcast to both extension and webview (with sender exclusion)
Project 1: VSCode Extension (apps/vscode/my-extension/)
The orchestrator. It spawns the stdio server, serves the webview, and bridges communication.
Directory Structure
apps/vscode/my-extension/
├── project.json
├── package.json # VSCode extension manifest (contributes, activationEvents)
├── webpack.config.js
├── tsconfig.json # extends tsconfig.server.json
├── tsconfig.app.json # types: ["node", "vscode"]
├── tsconfig.spec.json
└── src/
├── main.ts # Extension entry point
├── assets/ # Icons, images
│ └── icon.svg
└── app/
├── my-extension-extension.module.ts # NestJS module with @VscodeExtensionModule
├── classes/
│ └── my-extension-webview-provider.class.ts
└── services/
├── my-extension-command-handler.service.ts
├── my-extension-webview-handler.service.ts
└── my-extension-server-notification-handler.service.tsEntry Point (src/main.ts)
import 'reflect-metadata';
import { createExtensionFromModule } from '@onivoro/server-vscode';
import { MyExtensionExtensionModule } from './app/my-extension-extension.module';
export const { activate, deactivate } = createExtensionFromModule(
MyExtensionExtensionModule,
);Extension Module (src/app/my-extension-extension.module.ts)
The @VscodeExtensionModule decorator configures the extension. createExtensionFromModule reads this configuration to bootstrap everything automatically.
import { Module } from '@nestjs/common';
import { VscodeExtensionModule } from '@onivoro/server-vscode';
import { MyExtensionWebviewProvider } from './classes/my-extension-webview-provider.class';
import { MyExtensionCommandHandlerService } from './services/my-extension-command-handler.service';
import { MyExtensionWebviewHandlerService } from './services/my-extension-webview-handler.service';
import { MyExtensionServerNotificationHandlerService } from './services/my-extension-server-notification-handler.service';
@VscodeExtensionModule({
name: 'MyExtension', // Display name
serverScript: 'dist/server/main.js', // Relative to extensionPath (works for local debug + VSIX)
webviewViewType: MyExtensionWebviewProvider.viewType, // Must match package.json views id
createWebviewProvider: (uri) => new MyExtensionWebviewProvider(uri),
commandHandlerTokens: [MyExtensionCommandHandlerService], // Services with @CommandHandler methods
serverOutputChannel: {
name: 'MyExtension Server', // OutputChannel for server logs
showOnError: true,
},
})
@Module({
providers: [
MyExtensionCommandHandlerService,
MyExtensionWebviewHandlerService,
MyExtensionServerNotificationHandlerService,
],
})
export class MyExtensionExtensionModule {}Webview Provider (src/app/classes/my-extension-webview-provider.class.ts)
Extends BaseWebviewProvider to serve the React app from the bundled dist/webview directory.
import * as vscode from 'vscode';
import {
BaseWebviewProvider,
generateVscodeThemeBridgeInjection,
} from '@onivoro/server-vscode';
export class MyExtensionWebviewProvider extends BaseWebviewProvider {
public static readonly viewType = 'my-extension.webview'; // Must match package.json views id
constructor(extensionUri: vscode.Uri) {
super(extensionUri, {
webviewDistPath: 'dist/webview', // Relative to extensionPath (works for local debug + VSIX)
enableCacheBusting: true,
allowUnsafeInlineStyles: true,
});
}
protected override getInjectedScripts(nonce: string): string {
return generateVscodeThemeBridgeInjection(nonce);
}
}Command Handler Service (src/app/services/my-extension-command-handler.service.ts)
Handles VSCode command palette commands. Injects VSCODE_API, MESSAGE_BUS, and WEBVIEW_PROVIDER.
import { Injectable, Inject } from '@nestjs/common';
import {
CommandHandler,
BaseWebviewProvider,
WEBVIEW_PROVIDER,
VSCODE_API,
VscodeApi,
} from '@onivoro/server-vscode';
import { MESSAGE_BUS, MessageBus } from '@onivoro/isomorphic-jsonrpc';
@Injectable()
export class MyExtensionCommandHandlerService {
constructor(
@Inject(VSCODE_API) private readonly vscode: VscodeApi,
@Inject(MESSAGE_BUS) private readonly messageBus: MessageBus,
@Inject(WEBVIEW_PROVIDER) private readonly webviewProvider: BaseWebviewProvider,
) {}
@CommandHandler('my-extension.exampleCommand')
async exampleCommand(): Promise<void> {
const result = await this.messageBus.sendRequest('example.method', { param: 'value' });
await this.vscode.window.showInformationMessage(`Result: ${JSON.stringify(result)}`);
}
@CommandHandler('my-extension.reloadWebview')
async reloadWebview(): Promise<void> {
this.webviewProvider.reload();
await this.vscode.window.showInformationMessage('Webview reloaded');
}
}Webview Handler Service (src/app/services/my-extension-webview-handler.service.ts)
Handles messages originating from the React webview. Requests from the webview are routed here first; if no handler matches, they pass through to the stdio server.
import { Injectable, Inject } from '@nestjs/common';
import {
WebviewHandler,
VscodeWorkspaceService,
VSCODE_API,
VscodeApi,
} from '@onivoro/server-vscode';
@Injectable()
export class MyExtensionWebviewHandlerService {
constructor(
@Inject(VSCODE_API) private readonly vscode: VscodeApi,
private readonly workspace: VscodeWorkspaceService,
) {}
@WebviewHandler('showMessage')
async showMessage(params: { message: string; type?: 'info' | 'warning' | 'error' }): Promise<void> {
switch (params.type) {
case 'warning': await this.vscode.window.showWarningMessage(params.message); break;
case 'error': await this.vscode.window.showErrorMessage(params.message); break;
default: await this.vscode.window.showInformationMessage(params.message);
}
}
@WebviewHandler('showQuickPick')
async showQuickPick(params: {
items: Array<{ label: string; description?: string }>;
title?: string;
placeholder?: string;
}): Promise<{ label: string } | null> {
const result = await this.vscode.window.showQuickPick(params.items, {
title: params.title,
placeHolder: params.placeholder,
});
return result ? { label: result.label } : null;
}
@WebviewHandler('showInputBox')
async showInputBox(params: {
prompt?: string; placeholder?: string; value?: string; password?: boolean;
}): Promise<{ value: string } | null> {
const result = await this.vscode.window.showInputBox({
prompt: params.prompt, placeHolder: params.placeholder,
value: params.value, password: params.password,
});
return result !== undefined ? { value: result } : null;
}
@WebviewHandler('getWorkspaceFolders')
getWorkspaceFolders(): { folders: string[] } {
const folders = this.workspace.workspaceFolders;
return { folders: folders?.map((f) => f.uri.fsPath) ?? [] };
}
@WebviewHandler('getWorkspaceName')
getWorkspaceName(): { name: string | null } {
return { name: this.workspace.name ?? null };
}
@WebviewHandler('getConfiguration')
getConfiguration(params: { section: string; key: string }): { value: unknown } {
const config = this.workspace.getConfiguration(params.section);
return { value: config.get(params.key) };
}
}Server Notification Handler Service (src/app/services/my-extension-server-notification-handler.service.ts)
Listens for notifications sent from the stdio server process. Notifications are also automatically broadcast to the webview.
import { Injectable, Inject } from '@nestjs/common';
import { ServerNotificationHandler, VSCODE_API, VscodeApi } from '@onivoro/server-vscode';
@Injectable()
export class MyExtensionServerNotificationHandlerService {
constructor(@Inject(VSCODE_API) private readonly vscode: VscodeApi) {}
@ServerNotificationHandler('showMessage')
async handleShowMessage(params: { message: string; type?: 'info' | 'warning' | 'error' }): Promise<void> {
const { message, type = 'info' } = params;
switch (type) {
case 'warning': await this.vscode.window.showWarningMessage(message); break;
case 'error': await this.vscode.window.showErrorMessage(message); break;
default: await this.vscode.window.showInformationMessage(message);
}
}
@ServerNotificationHandler('taskProgress')
handleTaskProgress(params: { taskId: string; progress: number; message?: string }): void {
const { taskId, progress, message } = params;
const statusMessage = message
? `Task ${taskId}: ${progress}% - ${message}`
: `Task ${taskId}: ${progress}%`;
this.vscode.window.setStatusBarMessage(statusMessage, 3000);
}
@ServerNotificationHandler('customEvent')
handleCustomEvent(params: { event: string; payload: unknown }): void {
console.log(`[MyExtension] Custom event: ${params.event}`, params.payload);
}
}VSCode Extension Manifest (package.json)
This is a standard VSCode extension package.json. Key sections:
{
"name": "my-extension",
"displayName": "MyExtension",
"description": "VSCode extension for MyExtension",
"version": "1.0.0",
"publisher": "your-publisher-id",
"engines": { "vscode": "^1.74.0" },
"categories": ["Other"],
"activationEvents": ["onStartupFinished"],
"main": "./dist/main.js",
"repository": {
"type": "git",
"url": "https://github.com/your-org/your-repo"
},
"contributes": {
"commands": [
{ "command": "my-extension.exampleCommand", "title": "MyExtension: Run Example Command" },
{ "command": "my-extension.reloadWebview", "title": "MyExtension: Reload Webview" }
],
"viewsContainers": {
"activitybar": [
{ "id": "my-extension", "title": "MyExtension", "icon": "resources/icon.svg" }
]
},
"views": {
"my-extension": [
{ "type": "webview", "id": "my-extension.webview", "name": "MyExtension", "icon": "resources/icon.svg" }
]
}
}
}Critical alignment points:
contributes.views.my-extension[0].idmust equalMyExtensionWebviewProvider.viewTypeand@VscodeExtensionModule.webviewViewTypecontributes.commands[*].commandmust match the strings in@CommandHandler()decoratorsmainmust point to the webpack output entryrepositoryfield is required orvscewill prompt interactively
Nx Project Configuration (project.json)
{
"name": "app-vscode-my-extension",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/vscode/my-extension/src",
"projectType": "application",
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"dependsOn": ["app-stdio-my-extension:build", "app-browser-my-extension:build"],
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"target": "node",
"compiler": "tsc",
"outputPath": "apps/vscode/my-extension/dist",
"main": "apps/vscode/my-extension/src/main.ts",
"tsConfig": "apps/vscode/my-extension/tsconfig.app.json",
"generatePackageJson": false,
"assets": [
{ "input": "apps/vscode/my-extension", "glob": "package.json", "output": "." },
{ "input": "apps/vscode/my-extension", "glob": ".vscodeignore", "output": "." },
{ "input": "apps/vscode/my-extension/src/assets", "glob": "**/*", "output": "./resources" },
{ "input": "apps/stdio/my-extension/dist", "glob": "main.js", "output": "./server" },
{ "input": "apps/stdio/my-extension/dist", "glob": "main.js.map", "output": "./server" },
{ "input": "dist/apps/browser/my-extension", "glob": "**/*", "output": "./webview" }
],
"isolatedConfig": true,
"sourceMap": true,
"webpackConfig": "apps/vscode/my-extension/webpack.config.js"
},
"configurations": {
"development": {},
"production": {}
}
}
}
}Key build dependency: The vscode project dependsOn both the stdio and browser builds. The assets array bundles their outputs into the extension:
apps/stdio/my-extension/dist/main.js→dist/server/main.js(referenced by@VscodeExtensionModule.serverScript)dist/apps/browser/my-extension/**/*→dist/webview/**/*(referenced byBaseWebviewProvider.webviewDistPath)
TypeScript Configuration
// tsconfig.json — extends the server base
{ "extends": "../../../tsconfig.server.json", "files": [], "include": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.spec.json" }] }
// tsconfig.app.json — adds vscode types
{ "extends": "./tsconfig.json",
"compilerOptions": { "outDir": "../../../dist/out-tsc", "types": ["node", "vscode"] },
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"] }Webpack Configuration
VSCode extensions ship without node_modules, so all dependencies must be bundled except vscode (provided by the runtime). The withNx() plugin externalizes all node_modules by default for Node targets — this must be overridden. NestJS also pulls in optional microservices transport packages (kafka, grpc, redis, etc.) that must be ignored.
const { composePlugins, withNx } = require('@nx/webpack');
const webpack = require('webpack');
module.exports = composePlugins(withNx(), (config) => {
// Only 'vscode' should remain external (provided by the VS Code runtime).
config.externals = { vscode: 'commonjs vscode' };
// Ignore optional NestJS microservices transport dependencies
config.plugins = config.plugins || [];
config.plugins.push(
new webpack.IgnorePlugin({
checkResource(resource) {
const optionalDeps = [
'@grpc/grpc-js',
'@grpc/proto-loader',
'kafkajs',
'mqtt',
'nats',
'ioredis',
'amqplib',
'amqp-connection-manager',
'bufferutil',
'utf-8-validate',
];
return optionalDeps.includes(resource);
},
})
);
config.ignoreWarnings = [/Failed to parse source map/];
return config;
});Project 2: Stdio Server (apps/stdio/my-extension/)
The backend process. Spawned by the extension as a child process; communicates via stdio using JSON-RPC.
Directory Structure
apps/stdio/my-extension/
├── project.json
├── webpack.config.js
├── tsconfig.json # extends tsconfig.server.json
├── tsconfig.app.json # types: ["node", "vscode"]
├── tsconfig.spec.json
└── src/
├── main.ts
└── app/
├── app-stdio-my-extension.module.ts
├── app-stdio-my-extension-config.class.ts
└── services/
└── my-extension-message-handler.service.tsEntry Point (src/main.ts)
import { bootstrapStdioApp } from '@onivoro/server-stdio';
import { AppStdioMyExtensionModule } from './app/app-stdio-my-extension.module';
bootstrapStdioApp(AppStdioMyExtensionModule);NestJS Module (src/app/app-stdio-my-extension.module.ts)
import { Module } from '@nestjs/common';
import {
StdioTransportModule,
StdioTransportService,
StdioMessageBus,
createStdioMessageBus,
} from '@onivoro/server-stdio';
import { MESSAGE_BUS } from '@onivoro/isomorphic-jsonrpc';
import { AppStdioMyExtensionConfig } from './app-stdio-my-extension-config.class';
import { MyExtensionMessageHandlerService } from './services/my-extension-message-handler.service';
const config = new AppStdioMyExtensionConfig();
@Module({
imports: [
StdioTransportModule.forRoot(), // Handlers are auto-discovered, not passed here
],
providers: [
{ provide: AppStdioMyExtensionConfig, useValue: config },
{
provide: StdioMessageBus,
useFactory: (transportService: StdioTransportService) =>
createStdioMessageBus(transportService),
inject: [StdioTransportService],
},
{ provide: MESSAGE_BUS, useExisting: StdioMessageBus },
MyExtensionMessageHandlerService,
],
exports: [StdioMessageBus, MESSAGE_BUS],
})
export class AppStdioMyExtensionModule {}Message Handler Service (src/app/services/my-extension-message-handler.service.ts)
Methods decorated with @StdioHandler are auto-discovered by StdioTransportModule.
import { Injectable, Inject } from '@nestjs/common';
import { StdioHandler } from '@onivoro/server-stdio';
import { MESSAGE_BUS, MessageBus } from '@onivoro/isomorphic-jsonrpc';
@Injectable()
export class MyExtensionMessageHandlerService {
constructor(@Inject(MESSAGE_BUS) private readonly messageBus: MessageBus) {}
@StdioHandler('health')
async health(): Promise<{ status: string; timestamp: string }> {
return { status: 'ok', timestamp: new Date().toISOString() };
}
@StdioHandler('example.method')
async exampleMethod(params: unknown): Promise<{ success: boolean; params: unknown }> {
return { success: true, params };
}
// Example: long-running task with progress notifications back to extension
@StdioHandler('demo.longRunningTask')
async demoLongRunningTask(params: { taskId: string }): Promise<{ success: boolean; taskId: string }> {
const { taskId } = params;
for (let progress = 0; progress <= 100; progress += 20) {
this.messageBus.sendNotification('extension.taskProgress', {
taskId, progress,
message: progress < 100 ? 'Processing...' : 'Complete!',
});
await new Promise((resolve) => setTimeout(resolve, 500));
}
this.messageBus.sendNotification('extension.showMessage', {
message: `Task ${taskId} completed successfully!`,
type: 'info',
});
return { success: true, taskId };
}
}Nx Project Configuration (project.json)
{
"name": "app-stdio-my-extension",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/stdio/my-extension/src",
"projectType": "application",
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"target": "node",
"compiler": "tsc",
"outputPath": "apps/stdio/my-extension/dist",
"main": "apps/stdio/my-extension/src/main.ts",
"tsConfig": "apps/stdio/my-extension/tsconfig.app.json",
"generatePackageJson": false,
"isolatedConfig": true,
"sourceMap": true,
"webpackConfig": "apps/stdio/my-extension/webpack.config.js"
},
"configurations": { "development": {}, "production": {} }
}
}
}Webpack Configuration
The stdio server runs as a child process of the extension — it also ships without node_modules, so all dependencies must be bundled. Unlike the extension, nothing should remain external.
const { composePlugins, withNx } = require('@nx/webpack');
const webpack = require('webpack');
module.exports = composePlugins(withNx(), (config) => {
// Bundle everything — the server runs inside the extension package
// which has no node_modules.
config.externals = {};
// Ignore optional NestJS microservices transport dependencies
config.plugins = config.plugins || [];
config.plugins.push(
new webpack.IgnorePlugin({
checkResource(resource) {
const optionalDeps = [
'@grpc/grpc-js',
'@grpc/proto-loader',
'kafkajs',
'mqtt',
'nats',
'ioredis',
'amqplib',
'amqp-connection-manager',
'bufferutil',
'utf-8-validate',
];
return optionalDeps.includes(resource);
},
})
);
config.ignoreWarnings = [/Failed to parse source map/];
return config;
});Project 3: Browser Webview (apps/browser/my-extension/)
The React UI rendered inside VSCode's webview panel.
Directory Structure
apps/browser/my-extension/
├── project.json
├── index.html
├── vite.config.ts
├── tsconfig.json # extends tsconfig.web.json
├── tsconfig.app.json
└── src/
├── main.tsx
└── app/
├── app.tsx
├── pages/
│ └── SomePage.tsx
├── components/
│ └── SomeComponent.tsx
├── hooks/
│ └── use-rpc-request.hook.ts
└── state/
├── store.ts
├── middleware/
│ └── message-bus.middleware.ts
├── slices/
│ ├── jsonrpc-request-entity.slice.ts
│ └── jsonrpc-response-entity.slice.ts
└── types/
└── root-state.type.tsEntry Point (src/main.tsx)
import { StrictMode } from 'react';
import { BrowserRouter } from 'react-router-dom';
import * as ReactDOM from 'react-dom/client';
import App from './app/app';
import { ThemeProvider, createTheme } from '@mui/material';
import { Provider } from 'react-redux';
import { store } from './app/state/store';
const theme = createTheme();
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<StrictMode>
<Provider store={store}>
<ThemeProvider theme={theme}>
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</Provider>
</StrictMode>,
);Redux Store (src/app/state/store.ts)
import { configureStore } from '@reduxjs/toolkit';
import { buildReducers, SliceConfig } from '@onivoro/browser-redux';
import { jsonRpcRequestEntitySlice } from './slices/jsonrpc-request-entity.slice';
import { jsonRpcResponseEntitySlice } from './slices/jsonrpc-response-entity.slice';
import { messageBusMiddleware } from './middleware/message-bus.middleware';
export const sliceRegistry: SliceConfig[] = [
{ slice: jsonRpcRequestEntitySlice },
{ slice: jsonRpcResponseEntitySlice },
];
export const store = configureStore({
reducer: buildReducers(sliceRegistry),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(messageBusMiddleware),
});MessageBus Middleware (src/app/state/middleware/message-bus.middleware.ts)
This is the bridge between Redux and the JSON-RPC communication layer. When a request action is dispatched, the middleware sends it to the extension (and potentially on to the server) and dispatches the response back to Redux.
import { Middleware, isAction } from '@reduxjs/toolkit';
import { createWebviewMessageBus, WebviewMessageBus } from '@onivoro/browser-jsonrpc';
import { JsonRpcRequest } from '@onivoro/isomorphic-jsonrpc';
import { jsonRpcRequestEntitySlice } from '../slices/jsonrpc-request-entity.slice';
import { jsonRpcResponseEntitySlice } from '../slices/jsonrpc-response-entity.slice';
let messageBusInstance: WebviewMessageBus | null = null;
function getMessageBus(): WebviewMessageBus | null {
if (messageBusInstance) return messageBusInstance;
try {
messageBusInstance = createWebviewMessageBus();
return messageBusInstance;
} catch {
console.warn('[MessageBusMiddleware] VSCode API not available');
return null;
}
}
export const messageBusMiddleware: Middleware = (store) => (next) => (action) => {
const result = next(action);
if (!isAction(action)) return result;
const setOneActionType = jsonRpcRequestEntitySlice.actions.setOne.type;
if (action.type !== setOneActionType) return result;
const request = (action as ReturnType<typeof jsonRpcRequestEntitySlice.actions.setOne>)
.payload as JsonRpcRequest & { id: string };
const messageBus = getMessageBus();
if (!messageBus) return result;
messageBus.sendRequest(request.method, request.params)
.then((responseResult) => {
store.dispatch(jsonRpcResponseEntitySlice.actions.setOne({
id: request.id, jsonrpc: '2.0', result: responseResult,
}));
})
.catch((error: Error) => {
store.dispatch(jsonRpcResponseEntitySlice.actions.setOne({
id: request.id, jsonrpc: '2.0',
error: { code: -32603, message: error.message || 'Internal error' },
}));
});
return result;
};
export function disposeMessageBus(): void {
if (messageBusInstance) { messageBusInstance.dispose(); messageBusInstance = null; }
}Entity Slices (src/app/state/slices/)
// jsonrpc-request-entity.slice.ts
import { createEntitySlice } from '@onivoro/browser-redux';
import { JsonRpcRequest } from '@onivoro/isomorphic-jsonrpc';
export const jsonRpcRequestEntitySlice = createEntitySlice<
Omit<JsonRpcRequest, 'id'> & { id: string }
>('jsonRpcRequestEntitySlice');
// jsonrpc-response-entity.slice.ts
import { createEntitySlice } from '@onivoro/browser-redux';
import { JsonRpcResponse } from '@onivoro/isomorphic-jsonrpc';
export const jsonRpcResponseEntitySlice = createEntitySlice<
Omit<JsonRpcResponse, 'id'> & { id: string }
>('jsonRpcResponseEntitySlice');RPC Hook (src/app/hooks/use-rpc-request.hook.ts)
Convenience hook for components to send JSON-RPC requests and look up responses.
import { useDispatch, useSelector } from 'react-redux';
import { jsonRpcRequestEntitySlice } from '../state/slices/jsonrpc-request-entity.slice';
import { jsonRpcResponseEntitySlice } from '../state/slices/jsonrpc-response-entity.slice';
import { JsonRpcRequest } from '@onivoro/isomorphic-jsonrpc';
import { v4 } from 'uuid';
export function useRpc() {
const dispatch = useDispatch();
const responseEntities = useSelector(jsonRpcResponseEntitySlice.selectors.entities);
function sendRequest<TParams = any>(_: Pick<JsonRpcRequest, 'method'> & { params?: TParams }) {
const id = v4();
const { method, params = {} } = _;
dispatch(jsonRpcRequestEntitySlice.actions.setOne({ id, jsonrpc: '2.0', method, params }));
return id;
}
function lookupResponse(id: string) {
return responseEntities[id];
}
return { sendRequest, lookupResponse };
}Vite Configuration (vite.config.ts)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/apps/browser/my-extension',
server: { port: 4200, host: 'localhost' },
preview: { port: 4300, host: 'localhost' },
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
build: {
outDir: '../../../dist/apps/browser/my-extension',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: { transformMixedEsModules: true },
},
}));Nx Project Configuration (project.json)
{
"name": "app-browser-my-extension",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/browser/my-extension/src",
"projectType": "application",
"targets": {}
}Targets are inferred by Nx from the vite.config.ts.
Project 4: Shared Library (libs/isomorphic/my-extension/)
Constants and types shared across all three tiers.
Directory Structure
libs/isomorphic/my-extension/
├── project.json
├── tsconfig.json # extends tsconfig.isomorphic.json
├── tsconfig.lib.json
├── tsconfig.spec.json
└── src/
├── index.ts # Barrel export
└── lib/
├── constants/
│ └── my-extension-commands.constant.ts
└── types/ # Add shared interfaces/types hereCommands Constant (src/lib/constants/my-extension-commands.constant.ts)
export const myExtensionCommands = {
SELECT_JSONL_FILE: 'SELECT_JSONL_FILE',
// Add more shared command identifiers here
};Barrel Export (src/index.ts)
export * from './lib/constants/my-extension-commands.constant';Nx Project Configuration (project.json)
{
"name": "lib-isomorphic-my-extension",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/isomorphic/my-extension/src",
"projectType": "library",
"targets": {
"build": {
"executor": "@nx/vite:build",
"generatePackageJson": true,
"outputs": ["{options.outputPath}"],
"options": { "outputPath": "dist/libs/isomorphic/my-extension" }
}
}
}TypeScript Configuration
// tsconfig.json
{ "extends": "../../../tsconfig.isomorphic.json", "files": [], "include": [],
"references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }] }
// tsconfig.lib.json
{ "extends": "./tsconfig.json",
"compilerOptions": { "outDir": "../../../dist/out-tsc" },
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"] }Framework Libraries
These @onivoro/* libraries provide the foundation. They are not part of the extension itself but are required dependencies:
| Import | Purpose |
|---|---|
| @onivoro/server-vscode | createExtensionFromModule, @VscodeExtensionModule, @CommandHandler, @WebviewHandler, @ServerNotificationHandler, BaseWebviewProvider, VSCODE_API, VscodeApi, WEBVIEW_PROVIDER, VscodeWorkspaceService, generateVscodeThemeBridgeInjection |
| @onivoro/server-stdio | bootstrapStdioApp, StdioTransportModule, StdioTransportService, StdioMessageBus, createStdioMessageBus, @StdioHandler |
| @onivoro/isomorphic-jsonrpc | MESSAGE_BUS, MessageBus, JsonRpcRequest, JsonRpcResponse |
| @onivoro/browser-jsonrpc | createWebviewMessageBus, WebviewMessageBus |
| @onivoro/browser-redux | createEntitySlice, buildReducers, SliceConfig |
Building and Installing the VSIX
Prerequisites
@vscode/vsce must be installed as a dev dependency in the monorepo root.
Package Target
The vscode project's project.json includes a package target that builds the .vsix:
{
"package": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"command": "cd apps/vscode/my-extension/dist && node -e \"const p=require('./package.json');p.main='./main.js';require('fs').writeFileSync('./package.json',JSON.stringify(p,null,2))\" && mkdir -p dist && mv server dist/server && mv webview dist/webview && vsce package --no-dependencies --skip-license -o ../my-extension.vsix --baseContentUrl https://github.com/your-org/your-repo --baseImagesUrl https://github.com/your-org/your-repo"
}
}
}What this does:
dependsOn: ["build"]ensures the stdio server, browser webview, and extension are all built first- Patches
dist/package.jsonto fix themainentrypoint (the sourcepackage.jsonuses"main": "./dist/main.js"for development, but inside thedist/folder it needs to be"main": "./main.js") - Moves
server/andwebview/into adist/subdirectory so that paths likedist/server/main.jsanddist/webviewresolve correctly fromextensionPathin both contexts (local debug usesapps/vscode/my-extension/as the extension root; the VSIX uses the flatdist/content as the root) - Runs
vsce packageto produce the.vsixfile
Path resolution strategy: Source code uses dist/-prefixed paths (dist/server/main.js, dist/webview) which resolve correctly during local debug (where extensionPath = the project source directory and built files live under dist/). The package command restructures the build output so the same dist/ prefix resolves correctly inside the installed VSIX.
Build the VSIX
nx package app-vscode-my-extensionThis outputs apps/vscode/my-extension/my-extension.vsix.
Install Locally for Testing
code --install-extension apps/vscode/my-extension/my-extension.vsixThen reload VS Code (Cmd+Shift+P → "Developer: Reload Window"). The extension should appear in the activity bar and its commands should be available via the command palette (Cmd+Shift+P → "MyExtension:").
Checklist: Creating a New Extension
Replace my-extension with your extension's lowercase name and MyExtension with the PascalCase version throughout.
Create the shared library (
libs/isomorphic/my-extension/)- [ ]
project.jsonwithlib-isomorphic-my-extensionas the project name - [ ]
tsconfig.jsonextendingtsconfig.isomorphic.json - [ ]
tsconfig.lib.json - [ ]
src/index.tsbarrel export - [ ]
src/lib/constants/my-extension-commands.constant.ts - [ ] Add path mapping to
tsconfig.base.json
- [ ]
Create the stdio server (
apps/stdio/my-extension/)- [ ]
project.jsonwithapp-stdio-my-extensionas the project name - [ ]
webpack.config.js - [ ]
tsconfig.jsonextendingtsconfig.server.json - [ ]
tsconfig.app.jsonwithtypes: ["node", "vscode"] - [ ]
src/main.ts—bootstrapStdioApp() - [ ]
src/app/app-stdio-my-extension.module.ts— NestJS module withStdioTransportModule.forRoot() - [ ]
src/app/app-stdio-my-extension-config.class.ts - [ ]
src/app/services/my-extension-message-handler.service.ts— at minimum a@StdioHandler('health')method
- [ ]
Create the browser webview (
apps/browser/my-extension/)- [ ]
project.jsonwithapp-browser-my-extensionas the project name - [ ]
vite.config.tswithoutDir: '../../../dist/apps/browser/my-extension' - [ ]
index.html - [ ]
src/main.tsx— React bootstrap with Redux Provider and MUI ThemeProvider - [ ]
src/app/app.tsx - [ ]
src/app/state/store.ts— configureStore withmessageBusMiddleware - [ ]
src/app/state/middleware/message-bus.middleware.ts - [ ]
src/app/state/slices/jsonrpc-request-entity.slice.ts - [ ]
src/app/state/slices/jsonrpc-response-entity.slice.ts - [ ]
src/app/hooks/use-rpc-request.hook.ts
- [ ]
Create the VSCode extension (
apps/vscode/my-extension/)- [ ]
project.jsonwithapp-vscode-my-extensionas the project name,dependsOnstdio + browser builds, and asset bundling - [ ]
package.json— VSCode manifest with commands, viewsContainers, views - [ ]
webpack.config.js - [ ]
tsconfig.jsonextendingtsconfig.server.json - [ ]
tsconfig.app.jsonwithtypes: ["node", "vscode"] - [ ]
.vscodeignore - [ ]
src/main.ts—createExtensionFromModule() - [ ]
src/assets/icon.svg - [ ]
src/app/my-extension-extension.module.ts—@VscodeExtensionModule+@Module - [ ]
src/app/classes/my-extension-webview-provider.class.ts— extendsBaseWebviewProvider - [ ]
src/app/services/my-extension-command-handler.service.ts—@CommandHandlermethods - [ ]
src/app/services/my-extension-webview-handler.service.ts—@WebviewHandlermethods - [ ]
src/app/services/my-extension-server-notification-handler.service.ts—@ServerNotificationHandlermethods
- [ ]
Verify alignment
- [ ]
MyExtensionWebviewProvider.viewTypematchespackage.jsoncontributes.viewsid - [ ]
@CommandHandlerstrings matchpackage.jsoncontributes.commandsentries - [ ]
@VscodeExtensionModule.serverScriptpoints to where stdiomain.jsis bundled - [ ] Vite
outDirfor browser matches the extension's assetinputpath for webview
- [ ]
Build and test
- [ ]
nx build app-stdio-my-extension - [ ]
nx build app-browser-my-extension - [ ]
nx build app-vscode-my-extension(should cascade via dependsOn)
- [ ]
License
MIT
