Consuming salesforcedx-vscode-services
Extensions depending on salesforcedx-vscode-services. Examples: salesforcedx-vscode-metadata, salesforcedx-vscode-org-browser.
Getting the API
Use ExtensionProviderService from @salesforce/effect-ext-utils:
import { ExtensionProviderService, getServicesApi } from '@salesforce/effect-ext-utils';
const ExtensionProviderServiceLive = Layer.effect(
ExtensionProviderService,
Effect.sync(() => ({
getServicesApi
}))
);
// In an Effect.gen:
const api = yield * (yield * ExtensionProviderService).getServicesApi;
Prebuilt vs Per-Extension Services
api.services.prebuiltServicesLayer — shared service instances plus runtime configuration, including the redacting logger. Provide or merge this layer directly.
api.services.prebuiltServicesDependencies — deprecated context-only compatibility field. It omits FiberRef runtime configuration; new consumers must use prebuiltServicesLayer.
Shares singleton instances (caches, watchers) across extensions; avoids re-building stateful services.
Per-extension layers (must build yourself):
| Layer | Why |
|---|---|
ChannelServiceLayer(displayName) |
Own output channel |
ErrorHandlerService.Default |
Depends on own ChannelService |
ExtensionContextServiceLayer(context) |
Own ExtensionContext |
SdkLayerFor(context) |
Own tracer (extension name/version in resource attributes) |
ExtensionProviderServiceLive |
Local singleton |
ExtensionContext Setup
Preferred: import buildAllServicesLayer from @salesforce/effect-ext-utils. It reads displayName from package.json, falling back to the second arg. services/extensionProvider.ts only needs the mutable AllServicesLayer + setter:
// services/extensionProvider.ts
import { buildAllServicesLayer } from '@salesforce/effect-ext-utils';
export let AllServicesLayer: ReturnType<typeof buildAllServicesLayer>;
export const setAllServicesLayer = (layer: ReturnType<typeof buildAllServicesLayer>) => {
AllServicesLayer = layer;
};
In activate — pass the context and a localized fallback channel name:
import { buildAllServicesLayer } from '@salesforce/effect-ext-utils';
import { nls } from './messages';
import { setAllServicesLayer } from './services/extensionProvider';
export const activate = async (context: vscode.ExtensionContext): Promise<void> => {
setAllServicesLayer(buildAllServicesLayer(context, nls.localize('channel_name')));
await getRuntime().runPromise(activateEffect(context));
};
Two patterns exist depending on whether the extension adds services beyond the shared base:
- Shared base only (
core,apex,apex-testing,lightning,lwc,org,visualforce): importbuildAllServicesLayerdirectly from@salesforce/effect-ext-utilsand pass it tosetAllServicesLayerat activation. No local factory needed. - Extension-specific services added (
apex-debugger,apex-log,apex-oas,apex-replay-debugger,metadata,org-browser,soql): define a localbuildAllServicesLayerinservices/extensionProvider.tsthat callsbuildSharedServicesLayerfrom@salesforce/effect-ext-utilsand merges the extension's own Effect services viaLayer.mergeAll. The extra services vary —apex-oasaddsApexMetadataServiceandLLMService; extensions with the notifications system addNotificationModeService.Default;org-browseraddsOrgBrowserRetrieveService.
Runtime vs provide
- Do: Build
ManagedRuntime.make(AllServicesLayer)and exportgetRuntime(). - Do: Export runtime disposal, clear the memo, and call it during extension deactivation.
- Do: Use
getRuntime().runPromise(effect)/runFork(effect)for ad-hoc execution. - Don't: Use
Effect.provide(AllServicesLayer)at call sites — use the runtime instead.
export const disposeRuntime = async (): Promise<void> => {
if (_runtime) {
await _runtime.dispose();
_runtime = undefined;
}
};
export const deactivate = async (): Promise<void> => {
await getRuntime().runPromise(deactivation()).finally(disposeRuntime);
};
Resource Lifecycle
Prefer Effect scope ownership for resources created inside Effect services/layers:
- Define resource-owning services with
scoped. - Register VS Code
Disposables withEffect.addFinalizer. - Attach long-lived fibers to the owning scope with
Effect.forkIn. - Dispose the owning
ManagedRuntimeon deactivation so layer finalizers run. - Don't expose
runDispose/disposesolely for consumers to add tocontext.subscriptions. - Keep
context.subscriptionsfor resources created outside an Effect scope.
Allocation and cleanup stay together. See ../effect-best-practices/SKILL.md#effect-owned-resources.
Registering Commands
Use registerCommandWithRuntime:
import { myCommandEffect } from './commands/myCommand';
const api = yield * (yield * ExtensionProviderService).getServicesApi;
const registerCommand = api.services.registerCommandWithRuntime(getRuntime());
yield * registerCommand('sf.my.command', myCommandEffect);
Commands auto:
- Register with ExtensionContext subscriptions
- Wrap with error handling
- Trace with observability spans
- Handle Cancellation
Activation ordering
activate() awaits getRuntime().runPromise(activateEffect(context)); it does not detach the main activation Effect. Only work explicitly started with Effect.fork* continues after activation completes.
Register all manifest-contributed UI before awaiting work that can be slow or unresolved:
- Register tree/webview providers and put any returned
Disposableincontext.subscriptionswhen it is not scope-owned. - Restore the extension's persisted UI state and set its context keys.
- Register every contributed command.
- Set an extension-owned readiness context key only after steps 1-3 succeed, and use it to gate title/menu commands that would otherwise be visible.
- Only then await connection resolution, target-org readiness, catalog hydration, or network work. Use
Effect.forkInfor long-lived watchers that do not need to block activation.
when clauses can expose a contributed command before its handler has registered. A context key owned by another extension, including sf:has_target_org, is a visibility hint, not proof that this extension has initialized. Do not make a contributed handler's registration depend on it. Keep target-org and authorization checks in the command implementation or shared service layer.
export const activateEffect = Effect.fn(`activation:${EXTENSION_NAME}`)(function* (context: vscode.ExtensionContext) {
const api = yield* (yield* ExtensionProviderService).getServicesApi;
const provider = new MyTreeProvider();
context.subscriptions.push(vscode.window.registerTreeDataProvider(VIEW_ID, provider));
yield* setInitialContext();
const registerCommand = api.services.registerCommandWithRuntime(getRuntime());
yield* registerCommand('sf.my.command', () => myCommand(provider));
yield* Effect.promise(() => vscode.commands.executeCommand('setContext', 'sf:myExtension.ready', true));
// Command registration must not wait for org-backed initialization.
yield* api.services.ConnectionService.getConnection();
});
Success handling
Effect.fn accepts middleware args after the generator. Put success-side middleware before catchTag/catchAll — otherwise caught errors become successes.
export const deployActiveEditorCommand = Effect.fn('deploySourcePath.deployActiveEditor')(
function* () {
// ...core logic...
},
// runs only on success — placed before catchTag
withConfigurableSuccessNotification(nls.localize('command_succeeded_text', label)),
// catches errors — placed after success middleware
Effect.catchTag('NoActiveEditorError', () =>
Effect.promise(() => vscode.window.showErrorMessage(nls.localize('deploy_select_file_or_directory'))).pipe(
Effect.as(undefined)
)
)
);
withConfigurableSuccessNotification wraps the effect with Effect.tap, so it only fires when the effect succeeds:
export const withConfigurableSuccessNotification =
(message: string) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.tap(effect, () =>
Effect.sync(() => {
const show = vscode.workspace.getConfiguration(SECTION).get<boolean>(KEY, false);
if (show) void vscode.window.showInformationMessage(message);
})
);
Invoking sf.org.login.web
Cross-extension / executeCommand: vscode.commands.executeCommand('sf.org.login.web', instanceUrl?, reauthAliasOrUsername?).
- No args: interactive flow (palette).
- With
instanceUrl: skips org-type quick pick. - Second arg applies only when
instanceUrlwas provided: trimmed non-empty string becomes the auth alias (access-token re-auth); else alias defaults toreauth-vscodeOrg.
Basic Services
Accessor pattern: call methods directly, don't assign to variable first.
- ChannelService - Output channel
- ComponentSetService - Build component sets (source, manifest, URIs)
- MediaService - Icons (ICONS) and NLS descriptions
- WorkspaceService - Workspace info
- ConnectionService - Org connections
- ProjectService - Project resolution, packageDirectories
- SettingsService - Settings read/write
- FsService - File ops (web-compatible), uri/path conversion,
HashableUri(value-based URI equality for HashSet/HashMap keys) - EditorService - Active editor changes and current URI
- Prompts - QuickPick, InputBox, and UserCancellationError handling
- TerminalService - Run shell commands (desktop-only)
- NotificationModeService - Configurable success notifications
Watchers
File Watching
FileWatcherService exposes a PubSub of all workspace file changes (**/*). Subscribe and filter:
import * as PubSub from 'effect/PubSub';
import * as Stream from 'effect/Stream';
const fileWatcher = yield * api.services.FileWatcherService;
yield* Stream.fromPubSub(fileWatcher.pubsub).pipe(
Stream.filter(event => /* match event.uri to your pattern */),
Stream.runForEach(event =>
Effect.sync(() => {
// Handle event: { type: 'create'|'change'|'delete', uri }
})
)
);
Config Watching
Watch VS Code config changes:
import * as PubSub from 'effect/PubSub';
import * as Stream from 'effect/Stream';
import * as Duration from 'effect/Duration';
const pubsub = yield * PubSub.sliding<vscode.ConfigurationChangeEvent>(100);
const disposable = vscode.workspace.onDidChangeConfiguration(event => {
Effect.runSync(PubSub.publish(pubsub, event));
});
yield *
Effect.addFinalizer(() =>
Effect.sync(() => {
disposable?.dispose();
})
);
yield *
Stream.fromPubSub(pubsub).pipe(
Stream.filter(event => event.affectsConfiguration('section.setting')),
Stream.debounce(Duration.millis(100)),
Stream.runForEach(() => {
// Handle config change
})
);
Target Org Changes
Watch org changes via TargetOrgRef (SubscriptionRef):
const ref = yield * api.services.TargetOrgRef();
yield *
ref.changes.pipe(
Stream.map(org => org.orgId),
Stream.changes,
Stream.tap(orgId => {
// Handle org change
}),
Stream.runForEach(() => {
// Refresh UI, invalidate caches, etc.
})
);
TargetOrgRef is a SubscriptionRef: ref.changes already emits the current value first, so never prepend an explicit get. See the SubscriptionRef section of ../effect-best-practices/SKILL.md for the mechanic (incl. Stream.drop(1) to skip the initial snapshot).
Ref behavior (concise):
- Default-org update: username from User SOQL when present; else AuthInfo login username on the connection.
TargetOrgRefsnapshot without username: optionalConfigUtil.getUsername()(project default) before treating as no target org.TargetOrgRefvalue is always an object (neverundefined); only fields likeorgIdwithin it are optional.
Clearing the Default Org
Call ClearDefaultOrgRef() to reset the in-process org ref (e.g., after deleting the default org):
yield* api.services.ClearDefaultOrgRef();
Clears the reactive ref without rewriting config. Use when the CLI already mutated config but the in-process ref must reset to notify observers (e.g., the source tracking status bar icons). See orgDeleteDefaultCommand for an example.
Complete Example Pattern
// services/extensionProvider.ts
import { buildAllServicesLayer } from '@salesforce/effect-ext-utils';
export let AllServicesLayer: ReturnType<typeof buildAllServicesLayer>;
export const setAllServicesLayer = (layer: ReturnType<typeof buildAllServicesLayer>) => {
AllServicesLayer = layer;
};
// services/runtime.ts
import * as ManagedRuntime from 'effect/ManagedRuntime';
import { AllServicesLayer } from './extensionProvider';
const createRuntime = () => ManagedRuntime.make(AllServicesLayer);
let _runtime: ReturnType<typeof createRuntime> | undefined;
export const getRuntime = () => (_runtime ??= createRuntime());
// index.ts
import { buildAllServicesLayer } from '@salesforce/effect-ext-utils';
import { nls } from './messages';
import { myCommandEffect } from './commands/myCommand';
import { setAllServicesLayer } from './services/extensionProvider';
import { getRuntime } from './services/runtime';
export const activate = async (context: vscode.ExtensionContext) => {
setAllServicesLayer(buildAllServicesLayer(context, nls.localize('channel_name')));
await getRuntime().runPromise(activateEffect(context));
};
export const activateEffect = Effect.fn(`activation:${EXTENSION_NAME}`)(function* (_context: vscode.ExtensionContext) {
const providerService = yield* ExtensionProviderService;
const api = yield* providerService.getServicesApi;
yield* api.services.ChannelService.appendToChannel('Extension activating');
const registerCommand = api.services.registerCommandWithRuntime(getRuntime());
yield* registerCommand('sf.my.command', myCommandEffect);
yield* api.services.ChannelService.appendToChannel('Extension activation complete.');
});
Testing
Mock services via Layer.succeed and combine with Layer.mergeAll. For static accessors (e.g., api.services.WorkspaceService.getWorkspaceInfo()), wire both the provider and service:
import { ExtensionProviderService } from '@salesforce/effect-ext-utils';
import { WorkspaceService } from 'salesforcedx-vscode-services/src/vscode/workspaceService';
import * as Effect from 'effect/Effect';
import * as Layer from 'effect/Layer';
// Mock both ExtensionProviderService and WorkspaceService
const mockWorkspaceLayer = Layer.mergeAll(
Layer.succeed(ExtensionProviderService, {
getServicesApi: Effect.succeed({
services: { WorkspaceService } // Accessor sees real class
} as unknown as SalesforceVSCodeServicesApi)
}),
Layer.succeed(
WorkspaceService,
new WorkspaceService({
getWorkspaceInfo: () => Effect.succeed({ path: '/mock', fsPath: '/mock', isEmpty: false, isVirtualFs: false, cwd: '/mock' }),
getWorkspaceInfoOrThrow: () => Effect.succeed(/* ... */)
} as unknown as WorkspaceService)
)
);
// Use in test
const result = await Effect.runPromise(
myEffect().pipe(Effect.provide(mockWorkspaceLayer))
);
For direct service mocking (no accessor), use Layer.succeed(Service, mockImpl) alone.
Common Patterns
- Start with
api.services.prebuiltServicesLayer— don't add individual*.Defaultfor services already there - Only add per-extension layers on top
import { ICONS }outside Effect;MediaServiceinside EffectChannelServiceLayerbeforeErrorHandlerService- Pass
contexttoSdkLayerFor(extracts name/version from ExtensionContext) Effect.forkIn(..., yield* getExtensionScope())for watcher cleanup on deactivation- Scoped services own their VS Code disposables via finalizers; runtime disposal runs them
registerCommandWithRuntimefor all commands (tracing + error handling)- Use
getRuntime().runPromise/runForkinstead ofEffect.provide(AllServicesLayer)for execution
Don't: rebuild services already in prebuiltServicesLayer
// WRONG — creates new singleton instances, duplicating caches/watchers/state
return Layer.mergeAll(
ExtensionProviderServiceLive,
api.services.ExtensionContextServiceLayer(context),
api.services.FsService.Default, // ← already in prebuilt
api.services.AliasService.Default, // ← already in prebuilt
api.services.SdkLayerFor(context),
channelLayer,
errorHandlerWithChannel
);
// CORRECT — share the already-built singletons
return Layer.mergeAll(
api.services.prebuiltServicesLayer,
ExtensionProviderServiceLive,
api.services.ExtensionContextServiceLayer(context),
api.services.SdkLayerFor(context),
channelLayer,
errorHandlerWithChannel
);
Review
Invoke the effect-advocate subagent on plans and diffs — its top-priority finding category is "you re-implemented something that already exists in salesforcedx-vscode-services."
prebuiltServicesLayer contains ~27 services built once during services extension activation. Calling .Default on any of them creates a second instance with its own caches, watchers, and state — silently breaking cross-extension sharing.