Marketplace Launch Deepdive
Cross-extension code sharing in VS Code when you have a pnpm workspace
TLDR
Five Ginexys extensions ship to Marketplace as separate .vsix files but share code from a core extension. The pnpm workspace symlinks that make this work at dev time do not exist post-install. The canonical VS Code pattern for cross-extension code sharing is extensionDependencies plus vscode.extensions.getExtension('publisher.name').exports. Here's how to wire it without losing TypeScript types.
The shape of the problem
The Ginexys VS Code extension suite has five packages in a pnpm workspace:
extension/
├── pnpm-workspace.yaml
└── packages/
├── core/ ← shared: AuthProvider, GinexysRouter, html-rewriter
├── tafne/ ← satellite: depends on core
├── pdf/ ← satellite: depends on core
├── schema/ ← satellite: depends on core
└── pack/ ← extension pack umbrella, no code
Tafne and schema both call core's rewriteHtmlForWebview(opts) to transform tool HTML before injecting into a webview. In source they did:
// tafne/src/editor/TafneEditorProvider.ts
import { rewriteHtmlForWebview } from 'core/webview/html-rewriter';
This compiles. The IDE has type information. The unit tests pass. In dev, the F5 Extension Development Host loads tafne, which dynamically requires core/webview/html-rewriter. The pnpm symlink at tafne/node_modules/core points at the sibling packages/core. Everything works.
Why it breaks when you publish
VS Code Marketplace ships each extension as a standalone .vsix archive. When a user installs ginexys.ginexys-tafne, VS Code unpacks it into a sandboxed directory:
~/.vscode/extensions/ginexys.ginexys-tafne-0.1.0/
├── package.json
├── out/
│ └── editor/TafneEditorProvider.js ← contains require("core/webview/html-rewriter")
└── ...
There is no node_modules/core here. The pnpm symlink existed only in the development workspace. Even if the user installs ginexys.ginexys-core separately (which extensionDependencies guarantees), that extension lives in a parallel sandboxed directory:
~/.vscode/extensions/ginexys.ginexys-core-0.1.0/
~/.vscode/extensions/ginexys.ginexys-tafne-0.1.0/ ← cannot see ../core/
VS Code does not merge node_modules. Each extension has its own module resolution scope. The require("core/...") call in tafne throws Cannot find module 'core/webview/html-rewriter' at the first webview open.
The canonical fix: activate-exports API
VS Code's documented pattern for extension-to-extension code sharing has two pieces. First, extensionDependencies in the consumer's package.json:
{
"name": "ginexys-tafne",
"publisher": "ginexys",
"extensionDependencies": [
"ginexys.ginexys-core"
]
}
This guarantees that when a user installs tafne, VS Code installs core first. Without it, tafne could activate before core exists.
Second, the provider extension returns a public API from its activate() function. Consumers resolve that API at runtime through vscode.extensions:
// core/src/extension.ts
import * as vscode from 'vscode';
import { AuthProvider } from './auth/AuthProvider';
import { GinexysRouter } from './router/GinexysRouter';
import { rewriteHtmlForWebview } from './webview/html-rewriter';
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(); // ... register commands, etc.
const api: GinexysCoreApi = { router, authProvider, rewriteHtmlForWebview }; return api; }
The return value of activate becomes .exports on the extension instance. Satellites grab it:
// tafne/src/editor/TafneEditorProvider.ts
import * as vscode from 'vscode';
// Inline type matching the function signature core exposes type RewriteFn = (opts: { html: string; webview: vscode.Webview; mediaRoot: vscode.Uri; sharedRoot: vscode.Uri; toolRoot: vscode.Uri; cspNonce: string; }) => string;
async resolveCustomTextEditor(document, panel) { const coreExtension = vscode.extensions.getExtension('ginexys.ginexys-core'); if (!coreExtension) { throw new Error('Ginexys Core extension not found'); } if (!coreExtension.isActive) { await coreExtension.activate(); } const coreApi = coreExtension.exports as { rewriteHtmlForWebview: RewriteFn }; if (!coreApi || typeof coreApi.rewriteHtmlForWebview !== 'function') { throw new Error('Ginexys Core extension did not expose rewriteHtmlForWebview'); }
panel.webview.html = coreApi.rewriteHtmlForWebview({ html: injectedHtml, webview: panel.webview, mediaRoot: vscode.Uri.joinPath(this.ctx.extensionUri, 'media'), sharedRoot: vscode.Uri.joinPath(coreExtension.extensionUri, 'media'), toolRoot: vscode.Uri.file(path.join(this.portfolioRoot, 'tools/table-formatter')), cspNonce: nonce }); }
Three things to notice. First, await coreExtension.activate() if not already active. Otherwise core's activate() hasn't run and exports is undefined. Second, the type guard is paranoid for a reason. If a future version of core changes the API shape, tafne should error loudly at the call site, not silently call undefined and crash later. Third, the inline type RewriteFn declaration replaces the compile-time import. Tafne's source no longer references core's source. The two extensions can ship at different versions.
Why this preserves dev ergonomics
The pnpm workspace symlink continues to exist. Dev workflow is unchanged. You still F5 the host, still hot-reload changes to core, still get TypeScript intellisense across packages because the types come from the workspace dep declaration in tafne's package.json:
"dependencies": {
"core": "workspace:*"
}
In production this dep is irrelevant — the workspace doesn't ship. The extensionDependencies array is what actually matters at install time.
You get the best of both: workspace ergonomics at dev time, proper sandboxing at publish time.
What if you need to share more than one function
The exports object can carry anything. The Ginexys core returns three things: the IPC router, the auth provider, and the rewrite function. Satellites that need cross-tool routing call coreApi.router.register(toolId, panel). Satellites that need to gate features call coreApi.authProvider.isPaidTier().
If the API surface grows past 5-10 functions, define a TypeScript interface in core (we have GinexysCoreApi) and publish it as a .d.ts artifact in core's out/. Satellites can reference it as a type-only import:
import type { GinexysCoreApi } from 'ginexys-core';
Type-only imports get erased at compile time and never appear in the runtime JS. The require() call never happens. You get full types without the runtime failure.
What the gotchas are
extensionDependencies only guarantees install order on a fresh install. If a user already has tafne installed and then disables core, tafne will fail until they re-enable core. Add a UI-level check: if getExtension returns undefined, show a friendly notification with a "Reinstall Core" command.
If you publish a breaking change to core's API, the activation order still works but the satellite will crash. Use semantic versioning on the api shape itself, not just on core's package.json version. The api object can carry its own version: '1.0.0' field and consumers can check it before calling.
VS Code's extension activation is async. Calling coreApi.someFunction() before await coreExtension.activate() returns undefined. Always await activation in the satellite's resolver, not at module load time.
What it looks like in production
After publish, the install order on a clean VS Code looks like:
- User installs
ginexys.ginexys(the pack). - Marketplace resolves the pack's
extensionPackarray and queues five installs: core, tafne, pdf, schema, plus the pack itself. - Core's
extensionDependenciesarray is empty. It installs first. - Tafne/pdf/schema each declare
extensionDependencies: ["ginexys.ginexys-core"]. They wait for core to install, then proceed. - User opens a CSV. VS Code activates tafne. Tafne's editor calls
getExtension('ginexys.ginexys-core').activate(). Core'sactivate()runs, returns the api object. Tafne callscoreApi.rewriteHtmlForWebview(...). Webview renders.