services-extension-consumption

star 1.0k

Guidelines for consuming salesforcedx-vscode-services extension API. Use when working with extensions that have extensionDependency on salesforcedx-vscode-services, registering commands, using Workspace/Connection/Project/Settings/FS/Channel/Media services, quickpick/quickInput, implementing file/config watchers, editing extensionProvider.ts, buildAllServicesLayer, AllServicesLayer, setAllServicesLayer, prebuiltServicesDependencies, or Layer composition for VS Code extensions.

forcedotcom By forcedotcom schedule Updated 6/5/2026

name: services-extension-consumption description: Guidelines for consuming salesforcedx-vscode-services extension API. Use when working with extensions that have extensionDependency on salesforcedx-vscode-services, registering commands, using Workspace/Connection/Project/Settings/FS/Channel/Media services, quickpick/quickInput, implementing file/config watchers, editing extensionProvider.ts, buildAllServicesLayer, AllServicesLayer, setAllServicesLayer, prebuiltServicesDependencies, or Layer composition for VS Code extensions.

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.prebuiltServicesDependencies — pre-built Context.Context from services extension activation. Wrap with Layer.succeedContext(...).

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));
};

Legacy inline pattern (still present in metadata, org, org-browser, lightning, visualforce, soql, apex-log, apex-testing): a local buildAllServicesLayer factory wraps Layer.unwrapEffect(...) in services/extensionProvider.ts. Migrate to the shared helper when touching these — drop the factory, import buildAllServicesLayer from @salesforce/effect-ext-utils, pass the fallback name at the call site.

Runtime vs provide

  • Do: Build ManagedRuntime.make(AllServicesLayer) and export getRuntime().
  • Do: Use getRuntime().runPromise(effect) / runFork(effect) for ad-hoc execution.
  • Don't: Use Effect.provide(AllServicesLayer) at call sites — use the runtime instead.
  • Exception: registerCommandWithLayer(AllServicesLayer) — keep passing the Layer; it internally uses provide.

Registering Commands

Use registerCommandWithLayer (for layers) or registerCommandWithRuntime (for runtimes):

import { myCommandEffect } from './commands/myCommand';

const api = yield * (yield * ExtensionProviderService).getServicesApi;

// Using Layer
const registerCommand = api.services.registerCommandWithLayer(AllServicesLayer);
yield * registerCommand('sf.my.command', myCommandEffect);

// Using Runtime
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

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 instanceUrl was provided: trimmed non-empty string becomes the auth alias (access-token re-auth); else alias defaults to reauth-vscodeOrg.

Basic Services

Accessor pattern: call methods directly, don't assign to variable first.

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.
    })
  );

ref.changes always emits the current value as element 0, then future changes. Never prepend an explicit get:

// WRONG — the fromEffect/get is redundant; .changes already emits current value first
Stream.concat(Stream.fromEffect(SubscriptionRef.get(ref)), ref.changes)
Stream.concat(Stream.make(yield* SubscriptionRef.get(ref)), ref.changes)

// CORRECT
ref.changes

To suppress the initial snapshot (e.g. avoid triggering a refresh before a tree provider is ready), use Stream.drop(1).

Ref behavior (concise):

  • Default-org update: username from User SOQL when present; else AuthInfo login username on the connection.
  • TargetOrgRef snapshot without username: optional ConfigUtil.getUsername() (project default) before treating as no target org — see salesforcedx-vscode-org orgDisplay.
  • TargetOrgRef value is always an object (never undefined); only fields like orgId within it are optional.

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 { AllServicesLayer, 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 api = yield* (yield* ExtensionProviderService).getServicesApi;
  yield* api.services.ChannelService.appendToChannel('Extension activating');

  const registerCommand = api.services.registerCommandWithLayer(AllServicesLayer);
  yield* registerCommand('sf.my.command', myCommandEffect);

  yield* api.services.ChannelService.appendToChannel('Extension activation complete.');
});

Common Patterns

  • Start with Layer.succeedContext(api.services.prebuiltServicesDependencies) — don't add individual *.Default for services already there
  • Only add per-extension layers on top
  • import { ICONS } outside Effect; MediaService inside Effect
  • ChannelServiceLayer before ErrorHandlerService
  • Pass context to SdkLayerFor (extracts name/version from ExtensionContext)
  • Effect.forkIn(..., yield* getExtensionScope()) for watcher cleanup on deactivation
  • registerCommandWithLayer for all commands (tracing + error handling)
  • Use getRuntime().runPromise / runFork instead of Effect.provide(AllServicesLayer) for execution

Don't: rebuild services already in prebuiltServicesDependencies

// 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(
  Layer.succeedContext(api.services.prebuiltServicesDependencies),
  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."

prebuiltServicesDependencies 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.

Install via CLI
npx skills add https://github.com/forcedotcom/salesforcedx-vscode --skill services-extension-consumption
Repository Details
star Stars 1,020
call_split Forks 455
navigation Branch main
article Path SKILL.md
More from Creator