Engineering Journal
Ginexys
Ginexys

Cross-Extension Code Sharing in VS Code Monorepos: The Activate-Exports Pattern

2026-06-03

TLDR

Developing a multi-extension suite in a monorepo (like pnpm workspaces) relies on local package symlinks. However, once published to the VS Code Marketplace, each .vsix extension is unpacked into an isolated directory without a shared node_modules. To share code across extensions in production, you must use extensionDependencies in package.json combined with VS Code's runtime vscode.extensions.getExtension('publisher.id').exports API.
Execution ContextModule Resolution MethodDependency BoundaryType Safety Strategy
Dev Host (F5)pnpm workspace symlinks (packages/core)Shared node_modulesStandard import statements
Marketplace Productionvscode.extensions.getExtension().exportsSandboxed extension dirType-only imports (import type)

Published VS Code extensions lack shared workspace symlinks

Our extension suite consists of five packages in a pnpm workspace:

extension/ ├── pnpm-workspace.yaml └── packages/     ├── core/        ← Shared: AuthProvider, GinexysRouter, html-rewriter     ├── tafne/       ← Satellite tool extension     ├── pdf/         ← Satellite tool extension     ├── schema/      ← Satellite tool extension     └── pack/        ← Extension pack umbrella manifest

In development, satellite extensions imported core code directly:

import { rewriteHtmlForWebview } from 'core/webview/html-rewriter';
This compiled cleanly in dev because pnpm symlinked packages/core into tafne/node_modules/core.

However, when published to the Marketplace, VS Code unpacks ginexys-tafne into its own isolated directory (~/.vscode/extensions/ginexys.ginexys-tafne-0.1.0/). Because VS Code extensions do not share node_modules, require("core/webview/html-rewriter") throws Cannot find module 'core/...' the moment a user opens a webview.


Extension sandboxes isolate node_modules dependencies

VS Code places every installed extension into an isolated classloader scope. Even if a user installs ginexys-core and ginexys-tafne side by side, tafne cannot access core files on disk via standard relative or Node module imports.


Activate-exports exposes public interfaces at runtime

  VS Code Runtime Host           ginexys-core Extension           ginexys-tafne Satellite
           |                                |                                 |
           |--- 1. Activate core ---------->|                                 |
           |    (extensionDependencies)     |                                 |
           |<-- 2. Return GinexysCoreApi ---|                                 |
           |                                |                                 |
           |--- 3. Activate satellite --------------------------------------->|
           |                                |                                 |
           |<-- 4. getExtension('core') --------------------------------------|
           |--- 5. Return active core --------------------------------------->|
           |                                |                                 |
           |--- 6. Access exports (router, authProvider, html rewriter) ----->|

Declaring extensionDependencies guarantees activation order

Declare the dependency in the satellite extension's manifest to guarantee VS Code installs and activates ginexys-core first:
{
  "name": "ginexys-tafne",
  "publisher": "ginexys",
  "extensionDependencies": [
    "ginexys.ginexys-core"
  ]
}

Exporting API objects exposes interfaces to caller context

Return an API object from activate() in core/src/extension.ts:
export interface GinexysCoreApi {
  router: GinexysRouter;
  authProvider: AuthProvider;
  rewriteHtmlForWebview: typeof rewriteHtmlForWebview;
}

export function activate(context: vscode.ExtensionContext): GinexysCoreApi { const authProvider = new AuthProvider(context); const router = new GinexysRouter();

return { router, authProvider, rewriteHtmlForWebview }; }

Consuming exports dynamically resolves runtime APIs

In satellite extensions, resolve core exports object at runtime using type-only imports for compilation:
import * as vscode from 'vscode';
import type { GinexysCoreApi } from 'ginexys-core'; // Erased at compile time!

async function getCoreApi(): Promise<GinexysCoreApi> { const coreExt = vscode.extensions.getExtension<GinexysCoreApi>('ginexys.ginexys-core'); if (!coreExt) { throw new Error('Ginexys Core extension is missing.'); } if (!coreExt.isActive) { await coreExt.activate(); } return coreExt.exports; }

Because import type is stripped during TypeScript compilation, no require('core/...') statements exist in the compiled JavaScript output.

Rule of thumb: Never use direct Node require() statements to import code across published VS Code extensions. Export a public API from your core extension activate() function and consume it via vscode.extensions.getExtension().exports using import type.
Read this post in the full Engineering Journal →