An application framework for VS Code extensions. You declare what your extension is made of; the framework validates that declaration before touching VS Code, runs it, and tears it down through exactly one path. Built for extensions big enough that "what does this thing actually register?" has stopped being obvious.
Status:
4.1.0— the current release. It adds introspection and tooling to4.0.1— a plan as JSON, a preflight failure as data, a command-line tool, an API reference, a migration guide — and changes nothing that existed; the VS Code floor stays^1.134.0. 2.x was a utility library with a different shape; it continues onv2-maintenanceand anything pinned to^2.xis unaffected. See Coming from 2.x.
describePlan hands you that plan as JSON, to diff in a review or feed to a tooldeactivate is the only teardown path; context.subscriptions gets one synchronous failsafe and nothing elseAbortSignal, a progress session and a resource scopenpm install @kkdev92/vscode-ext-kit
Subpaths, all ESM:
| Import | What It Is |
|---|---|
@kkdev92/vscode-ext-kit |
the framework and the capability APIs |
@kkdev92/vscode-ext-kit/testing |
Test Host, one fake per capability, and the vscode mock kit |
@kkdev92/vscode-ext-kit/testing/vitest |
a vscode stand-in for Vitest's resolve.alias (needs the optional vitest peer) |
@kkdev92/vscode-ext-kit/testing/vitest-config |
the matching Vitest config to merge |
@kkdev92/vscode-ext-kit/webview-client |
the browser side of the typed webview RPC |
@kkdev92/vscode-ext-kit/timing |
debounce throttle withTimeout withTiming measureTime |
@kkdev92/vscode-ext-kit/retry |
retry with backoff, jitter and a per-attempt AbortSignal |
@kkdev92/vscode-ext-kit/format |
Intl date/number/relative-time/plural formatting |
The last three are also on the root barrel. They have their own subpaths because
the root imports vscode and a webview bundle cannot.
Two files. The first is your extension.ts:
import { defineExtension } from '@kkdev92/vscode-ext-kit';
import { CountProjects, projectsModule } from './commands-and-services.js';
// Preflight runs here, at import time: duplicate ids, a missing service, a cycle
// or a captive dependency fail before VS Code is touched at all.
// Exported so a test can run this exact plan on fakes -- see testing.ts.
export const app = defineExtension({
name: 'Sample',
modules: [projectsModule],
});
// `activate` registers one synchronous failsafe on `context.subscriptions`;
// `deactivate` is the single cleanup path. Nothing else needs disposing by hand.
export const activate = app.activate;
export const deactivate = app.deactivate;
// Typed invocation from anywhere: the contract fixes the arguments and the
// result, and both the value and any rejection reach this caller.
export const countProjects = (): Promise<number> => app.commands.execute(CountProjects);
The second is the module it names:
import {
defineCommandContract,
defineModule,
serviceToken,
type OperationContext,
} from '@kkdev92/vscode-ext-kit';
// A service is an interface plus a token. The token carries the type, so
// injection sites need no casts and a missing registration is a compile error.
interface ProjectIndex {
count(): number;
rebuild(signal: AbortSignal): Promise<number>;
}
export const ProjectIndex = serviceToken<ProjectIndex>('sample.projectIndex');
// A command contract names the id once and fixes the argument and result types
// for every caller.
export const CountProjects = defineCommandContract<readonly [], number>({
id: 'sample.countProjects',
});
export const Rebuild = defineCommandContract<readonly [force: boolean], number>({
id: 'sample.rebuild',
});
export const projectsModule = defineModule('projects', (module): undefined => {
module.services.singleton(ProjectIndex, () => {
let known = 0;
return {
count: () => known,
rebuild: async (signal) => {
// Cooperative cancellation: the operation's signal aborts on stop,
// caller cancellation and timeout alike.
for (let step = 0; step < 10 && !signal.aborted; step += 1) {
known += 1;
}
return known;
},
};
});
module.commands.handle(CountProjects, {
inject: { index: ProjectIndex },
execute: (_context: OperationContext, _args, { index }) => index.count(),
});
module.commands.handle(Rebuild, {
inject: { index: ProjectIndex },
execute: async (context: OperationContext, [force], { index }) => {
context.logger.info('rebuilding', { force });
return index.rebuild(context.signal);
},
});
return undefined;
});
That is a complete extension: a service, two commands with typed arguments and
results, cancellation, and structured logging. Add sample.countProjects and
sample.rebuild to contributes.commands in your package.json and they appear
in the Command Palette.
Two things that are easy to miss. A service factory is synchronous on purpose — asynchronous setup belongs in a hosted service, so resolving a service can never deadlock. And a command's return value and its rejection both reach the caller: the framework does not toast errors for you, because the Command Palette already shows a dialog and a keybinding already warns.
See the Guide for settings, storage, secrets, hosted services, watchers, editor commands, UI, views, testing and the escape hatch.
A VS Code extension starts as one activate() function, and for a while that is
the right shape. Past a certain size it stops being one: registrations pile onto
context.subscriptions, half of them are pushed and half are forgotten, setup
order becomes load-bearing without anyone deciding that it should be, and the
answer to "what does this extension register?" is "read activate() and hope".
The usual next step is a folder of helper functions. That tidies the file
without changing the shape — the helpers still reach for vscode directly, so
none of it runs in a unit test, and the lifecycle is still whatever order the
calls happen to be in.
This package takes a different route. An extension is a declaration: modules describe what exists, and the framework validates that description before VS Code is touched, runs it, and unwinds it through one path.
Every ability is reached through a declaration or an injected token.
A module declares:
| Declaration | Registers |
|---|---|
module.commands.handle / .handleTextEditor |
a command; the second hands the handler an ActiveEditor |
module.services.singleton / .transient |
a service, against a serviceToken |
module.hostedServices.add / .background |
long-lived work with a lifecycle |
module.settings.add |
a defineSettings group |
module.storage.add / module.secrets.add |
a defineStorage / defineSecret |
module.fileWatchers.add |
a debounced, batched watcher |
module.statusBar.add / module.languageStatus.add |
a declared UI item |
module.treeViews.add |
a tree view, from a BaseTreeDataProvider |
module.webviews.addView / .restorePanel |
a webview view, or a panel restorer |
module.raw.register |
any VS Code API with no model here, still owned and rolled back |
A handler injects:
| Token | Gives You |
|---|---|
Notifications |
info warn error confirm |
QuickInput |
one many text wizard |
Editors |
the active editor, and cross-file edits |
Localization |
t plural number date relativeTime, in the host's language |
Commands |
invoking a command, this extension's or the platform's |
Webviews |
openPanel |
Secrets |
secrets the user names: get set delete keys |
StatusBar |
flash — a short-lived message with no item of its own |
FileWatchers |
watch, for a glob known only at runtime |
Operations |
run, for work that did not start in a handler |
Log |
a logger for a service, which has no operation to borrow one from |
a definition's own .token |
the settings accessor, typed storage, secret or UI controller it declared |
notify, ask, l10n, editors, commands and status are on every
handler's context without being declared — they resolve the same tokens an
inject would. context also carries id, logger, signal, progress
(run and weighted steps), resources and services.
Values you pass around: ok err unwrap mapResult and the s.* schema
builders; FrameworkError with userError / validationError / classifyError
/ isCancellation; DisposableCollection and createScope.
Full signatures live in the .d.ts files and the JSDoc on each export.
npm run docs:api renders that JSDoc into an API reference under docs/api/
(TypeDoc); CI runs it with warnings as errors, so a broken link or a type a
public signature names but the package does not export cannot ship.
The package ships one command with two subcommands. Both read the plan an
extension compiles at import time, so what the extension registers can be
reviewed, diffed, drawn and checked against package.json without starting
VS Code.
npx vscode-ext-kit plan ./out/extension.js # the plan as JSON
npx vscode-ext-kit plan ./out/extension.js --format mermaid # modules, services and their edges
npx vscode-ext-kit plan ./out/extension.js --check # exit 1 with every problem preflight found
npx vscode-ext-kit manifest ./out/extension.js # every disagreement with package.json
npx vscode-ext-kit manifest ./out/extension.js --apply # add the commands and settings it is missing
manifest makes the comparison assertManifestMatches makes in a test, from
the command line. --apply adds what the manifest is missing and the source
can supply — commands and settings, with placeholder titles and descriptions
marked TODO — and reports what a person has to decide: a view without a
container, a default the two sides disagree on, an entry only the manifest
has.
The entry module is evaluated with a stand-in for vscode, which only exists
inside an extension host. That works because nothing in this package touches
VS Code before activate — and it means module-scope code in the extension
must not either, which the framework already asks for. Export the
defineExtension result as app (or name the export with --export); the
JSON is what describePlan returns.
stop() runs exactly once, and only after start() completed or failedactivate is single-use: a second call while starting or running joins the same start and resolves to the same value; after deactivate it rejects rather than rebuilding the application behind VS Code's backStated plainly, because a framework that is vague about its boundaries gets trusted for things it cannot do.
deactivate never runs — persist what matters when the operation that produced it completes, not during shutdownapplication.shutdownTimeout diagnostic names what was still holding onimport "vscode"Editors hands you the active editor and cross-file edits, but there is no onDidChangeActive / onDidChangeSelection / onDidChangeDocument; subscribing means reaching for vscode directly and disposing by hand, which is the one place the single-cleanup-owner rule leaksLogOutputChannel and VS Code owns the level — per channel, persisted, in the Output panel. An extension cannot raise its own channel's level, so a logLevel setting of your own can only ever make the log quieternpm run docs:api) but not published defineModule(...) pure data: no VS Code, no side effects
|
v
compileApplication() preflight: ids, service graph, scopes
|
v
ApplicationPlan immutable, deeply frozen
|
v
ApplicationHost state machine; start / stop exactly once
|
v
capability ports ------> real adapters | fakes (Test Host)
The split at the bottom is the point: the plan above it never learns which side it is running on, so the plan a test runs is the plan that ships.
Four ideas carry the whole design.
A module is a value. Its callback runs once, synchronously, with no side effects, and produces a frozen description. Anything that needs to happen happens later, in a hosted service or a handler.
Preflight is not a linter. It runs inside defineExtension, at module import
time, and refuses to produce a plan it cannot run.
Every unit of work is an operation. A command invocation, a watcher batch:
each gets an id, a logger, a combined AbortSignal, a progress session and a
resource scope that is disposed when the work settles.
Ownership is explicit. The host owns registrations and declared UI; the
container owns singletons and disposes them in reverse creation order; an
operation owns what it resolved. deactivate() unwinds all of it, once.
2.x is a utility library: you call its helpers from your own activate().
3.x is a framework: it owns activation and deactivation, and you hand it
modules. Everything 2.x could do, 3.x can do — the shapes changed, because there
is now one way in per ability rather than a standalone function and a
declaration.
Three consequences worth knowing before you port:
context.subscriptions: the host owns what it registered, and deactivate unwinds all of itCancellationToken takes an AbortSignal, and context.signal already combines the operation's own cancellation with the user'sTyped storage keeps 2.x's envelope format, so values written by a 2.x build are read by a 3.x build, and the mock kit is unchanged.
The CHANGELOG carries the full old-to-new mapping, and Migrating from 2.x the order to do the work in: inventory, classification, one module holding everything as raw registrations first, then each entry turned into the declaration it is, and finally the Test Host, the manifest check and an Extension Host lane.
| VS Code | ^1.134.0 — your extension declares the same engines.vscode; CI tests stable |
| Extension hosts | desktop and web, both covered by CI |
| Node (to build) | >=22.12.0 |
| Module format | ESM only — require() of any subpath fails by design; bundle as extensions normally do |
TypeScript lib |
ESNext.Disposable (the public types name Symbol.dispose) and one of DOM / WebWorker / @types/node (they name AbortSignal) |
| TypeScript | 6.0.x is what this repo builds with; 7.x compiles the package in a non-blocking CI lane |
The floor is 1.134.0 because that is the newest @types/vscode there is, so it
is the newest API this package can name at all. The two move together: vsce
refuses to package an extension whose @types/vscode outruns its
engines.vscode, and raising only the types would let code compile against an
API the declared floor does not have. VS Code updates itself and CI tests stable,
so treat the floor as a formality rather than a tested target.
scripts/verify-package.mjs checks the lib requirements against the packed
.d.ts files on every CI run, so that row is verified rather than remembered.
ERR_REQUIRE_ESM or require() of ES Module: the package is ESM only, by design; bundle your extension with esbuild/webpack/rollup, which is what VS Code extensions normally do anywaySymbol.dispose or AbortSignal is not defined in the types: add ESNext.Disposable and one of DOM / WebWorker / @types/node to lib — see Platform Requirementsproblems on the error carries each one as a code a script can act onenablement / commandPalette when in your package.json, not something this package controlsundefined: VS Code runs those handlers fire-and-forget and discards what they return; use module.commands.handle with Editors.active when the caller needs the resultvscode cannot be resolved in tests: point Vitest's resolve.alias at @kkdev92/vscode-ext-kit/testing/vitest, or merge vscodeExtKitVitestConfigscript-src rather than the webview's own sourceFor vulnerability reporting, see SECURITY.md.
Contributions are welcome — thank you for helping make this better 🙌 Please see CONTRIBUTING.md for guidelines.
npm run quality is the gate: formatting, typecheck, lint, tests with per-layer
coverage floors, and dead-code detection. Two more lanes run the framework in a
real desktop Extension Host and a real browser worker.
If you're planning a larger change, opening an issue first is appreciated. One thing to know about direction: adding a second way to do something that already has one is the change most likely to be turned down.
This is a personal project maintained in spare time. It is active, but support is best-effort: I'll do my best to review issues and PRs, and releases may be a bit slow sometimes — thank you for your patience.
4.1.0 is the current release and holds latest on npm, so a fresh
npm install gets the framework. 2.x continues on v2-maintenance and still
takes bug fixes; anything pinned to ^2.x resolves there and is unaffected.
Breaking changes are listed in the CHANGELOG.
Helpful things when reporting bugs:
Security-related reports should follow SECURITY.md. Really appreciate you using it 💛
@vscode/test-electron and
@vscode/test-web