@salesforce/vite-plugin-lwc-ui-bundle
v7.0.0
Published
Vite plugin for compiling LWC components into static bundles for off-platform and MCP use
Readme
@salesforce/vite-plugin-lwc-ui-bundle
Vite plugin for compiling and running LWC components off-platform. Bundles the full compilation pipeline — scoped module providers, Lightning npm resolution, missing CSS handling, and the Vite/LWC bridge — behind a single configurable entry point.
Getting started? See the Consumer Guide for a step-by-step walkthrough of adding this plugin to an existing LWC project.
Installation
npm install @salesforce/vite-plugin-lwc-ui-bundle @lwc/rollup-plugin --save-dev@lwc/rollup-plugin is a required peer dependency.
Quick Start
// vite.config.js
import { defineConfig } from "vite";
import lwcVitePlugin, { builtins } from "@salesforce/vite-plugin-lwc-ui-bundle";
export default defineConfig({
plugins: [
lwcVitePlugin({
modules: {
dirs: ["sf/lwc"],
npm: ["lwc-components-lightning"],
},
providers: [
builtins.label(),
builtins.i18n(),
builtins.accessCheck(),
builtins.client(),
builtins.gate(),
builtins.primitiveUtils(),
],
}),
],
});Configuration
modules
Controls where LWC component sources are discovered.
| Property | Type | Description |
| -------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| dirs | string[] | Directories to scan for {namespace}/{component}/{component}.js entries. Each subdirectory of a dir is treated as a namespace. |
| npm | (string \| {npm: string})[] | NPM packages that supply LWC modules. Passed through to @lwc/rollup-plugin. |
modules: {
dirs: ['sf/lwc', 'src/components'],
npm: ['lwc-components-lightning'],
}providers
Ordered array of scoped module providers. Each provider intercepts imports matching its prefix and returns generated JavaScript source. Providers are evaluated in order; the first to return a non-null result wins.
providers: [
builtins.label({ "Custom.MyLabel": "Hello World" }),
builtins.i18n(),
builtins.gate({ myFeature: false }),
builtins.accessCheck(),
builtins.client(),
builtins.primitiveUtils(),
];stubs
Map of bare module specifiers to stub file paths. These are injected as Vite resolve.alias entries.
stubs: {
aura: 'src/stubs/aura-off-platform.js',
logger: 'src/stubs/logger-stub.js',
}lwcOptions
Pass-through options for @lwc/rollup-plugin. See the @lwc/rollup-plugin docs for available options.
lwcOptions: {
enableDynamicComponents: true,
enableSyntheticElementInternals: true,
}ignorePatterns
Specifier prefixes that should never be intercepted by providers, even if they match a provider or intercept prefix. Defaults to ['@salesforce/sdk-', '@salesforce/platform-sdk-', '@salesforce/platform-sdk', '@salesforce/core']. The legacy @salesforce/sdk- and per-domain @salesforce/platform-sdk- entries are kept so external consumers using the previously-published SDK package names continue to work alongside the consolidated @salesforce/platform-sdk package.
passthroughRules
Rules for selectively letting specific imports resolve normally. Each rule has a specifierPrefix and importerPattern; when an import matches both, it bypasses the provider system.
passthroughRules: [
{
specifierPrefix: "@salesforce/label/",
importerPattern: "/lwc-components-lightning/",
},
];Built-in Providers
All built-in providers are factory functions exported from vite-plugin-lwc-ui-bundle as builtins.* (or individually from @salesforce/vite-plugin-lwc-ui-bundle/providers).
label(overrides?)
Handles @salesforce/label/*. Returns label strings from a defaults map; unknown labels get a camelCase-to-words fallback. Pass overrides to add or replace defaults.
i18n(options?)
Handles @salesforce/i18n/*. Browser-derived values (lang, locale, currency, etc.) use the Intl API at runtime. Format patterns use en-US defaults. Accepts staticOverrides and objectOverrides.
gate(overrides?)
Handles @salesforce/gate/*. All gates default to open. Pass a map of gate names to false to close specific gates.
accessCheck(overrides?)
Handles @salesforce/accessCheck/*. All checks default to false. Pass overrides to change individual checks.
client()
Handles @salesforce/client/*. Supports formFactor (Small/Medium/Large via CSS media queries at runtime).
primitiveUtils()
Handles lightning/primitiveUtils. Stubs normalizeBoolean and reflectAttribute.
lds(adapters?)
Handles LDS (Lightning Data Service) specifiers such as lightning/uiRecordApi, lightning/uiObjectInfoApi, and lightning/graphql by routing registered exports to MCP tools. Unregistered exports pass through to normal lightning/* resolution.
A single specifier often mixes wire and imperative exports, so all shapes (wire, imperative-mutation, imperative-read, graphql-wire, graphql-mutation) live in the same registry keyed by module + export name:
lds({
"lightning/uiRecordApi": {
getRecord: {
type: "wire",
mcp: { toolName: "getRecordMcpTool" },
configJsonSchema: {
type: "object",
properties: { recordId: { type: "string" } },
required: ["recordId"],
additionalProperties: false,
},
},
createRecord: {
type: "imperative-mutation",
mcp: { toolName: "createRecordMcpTool" },
configJsonSchema: {
type: "object",
properties: {
apiName: { type: "string" },
fields: {
type: "object",
properties: {},
required: [],
additionalProperties: true,
},
},
required: ["apiName", "fields"],
additionalProperties: false,
},
},
},
"lightning/uiObjectInfoApi": {
getObjectInfo_imperative: {
type: "imperative-read",
invokerShape: "legacy",
mcp: { toolName: "getObjectInfoMcpTool" },
configJsonSchema: {
type: "object",
properties: { objectApiName: { type: "string" } },
required: ["objectApiName"],
additionalProperties: false,
},
},
},
"lightning/graphql": {
graphql: { type: "graphql-wire", mcp: { toolName: "graphqlQuery" } },
executeMutation: { type: "graphql-mutation", mcp: { toolName: "graphqlQuery" } },
},
});The lightning/graphql specifier is owned end-to-end by the virtual module: gql is auto-exported alongside the registered adapters, so import { gql, graphql } from 'lightning/graphql' works without any additional wiring. Both the wire and mutation adapters dispatch through MCP (getChatSDK().callTool) and surface errors in the GraphQL { data, errors[] } envelope rather than throwing.
configJsonSchema is OneStore's JSONSchema — ObjectType requires properties, required, and additionalProperties to be declared. Validation is performed at invoke time by the same assertIsValid used on-platform.
Supported invoker shapes
| type | invokerShape | Export signature | Error surface |
| ------------------------- | -------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------- |
| wire | — | WireAdapter class consumed by @wire | Emits { data, error } to the wire callback. |
| imperative-mutation | — | (config) => Promise<Data> | Throws on validation or tool error. |
| imperative-read | query | (config) => Promise<{ data }> | Throws on validation or tool error. |
| imperative-read | subscribable | (config) => Promise<{ data, subscribe }> | Throws on validation or tool error. |
| imperative-read | subscribable-refreshable | (config) => Promise<{ data, subscribe, refresh }> | Throws on validation or tool error. |
| imperative-read | legacy | { invoke(config, context, callback), subscribe(...): Unsubscribe } | Callback fires with { data, error }; neither throws. |
| graphql-wire | — | WireAdapter class consumed by @wire(graphql, ...) | Emits { data, errors, refresh }; errors routed in-band, not thrown. |
| graphql-mutation | — | (config) => Promise<{ data, errors }> | Errors routed in-band via errors[], not thrown. |
| graphql-imperative-read | query | (config) => Promise<{ data, errors, subscribe }> | Errors routed in-band via errors[], not thrown. |
| graphql-imperative-read | query-refreshable | (config) => Promise<{ data, errors, subscribe, refresh }> | Errors routed in-band via errors[], not thrown. |
| graphql-imperative-read | legacy | { invoke(config, context, callback), subscribe(...): Unsubscribe } | Callback fires with { data, errors }; neither throws. |
Each shape is a direct delegation to the matching OneStore service descriptor (Default, Query, Subscribable, or Legacy ImperativeBindingsService). Consumer code is identical whether the import resolves to native implementation on-platform or to this MCP-backed replacement off-platform — no conditional wrapping, no { data, error } indirection for imperative shapes. Write one try/catch, ship both places.
Because each shape inherits OneStore's invoker wholesale, successful payloads are deep-frozen and Err-branch rejections pass through toError. Validation failures throw typed subclasses of JsonSchemaViolationError (MissingRequiredPropertyError, IncorrectTypeError, etc.) — the same classes consumers catch on-platform.
legacy preserves the older { invoke, subscribe } contract. context is accepted but ignored. Callback payloads always use { data, error }; data is deep-frozen on success.
subscribe() / refresh() stance
On-platform, the reactive store drives subscribe callbacks — including after refresh() — by fanning fresh data out to every listener. Off-platform there is no store, so behavior is partitioned by whether the shape exposes refresh:
- Shapes with no
refresh(legacy,subscribable,graphql-imperative-readwithinvokerShape: "query"):subscribeis a deliberate no-op. Nothing can ever trigger a post-initial update, so the callback never fires; the returned unsubscribe is idempotent. - Shapes with
refresh(subscribable-refreshable,graphql-imperative-readwithinvokerShape: "query-refreshable"):subscribeis wired intorefresh(). Each call to the returned function owns its own subscriber set.refresh()re-executes the MCP tool and broadcasts the fresh payload —{ data, error }for LDS,{ data, errors }(in-band) for GraphQL — to every registered subscriber before resolving. This preserves the on-platform contract end-to-end off-platform.
This is deliberate: native subscribe is driven by a reactive store, and off-platform there is no store to observe. Pretending otherwise would be dishonest.
OneStore runtime dependencies
@conduit-client/* is bundled into the plugin's runtime.js artifact at plugin-publish time, not re-imported from the consumer's module graph. Consumers don't need to install or declare any @conduit-client/* dependency — component code stays identical to its on-platform form. The only runtime peer the virtual module imports from is @salesforce/platform-sdk (specifically getChatSDK), which bridges to window.openai / MCP Apps.
Custom Providers
Create custom providers by implementing the provider interface:
function myProvider() {
return {
// prefix: imports starting with this string will be routed to this provider.
// The parent @scope/ prefix is auto-derived and used as a catch-all intercept.
prefix: "@myorg/config/",
// resolve: given a full import specifier, return JavaScript source or null to pass.
resolve(specifier) {
if (!specifier.startsWith("@myorg/config/")) return null;
const key = specifier.slice("@myorg/config/".length);
return `export default ${JSON.stringify(myConfigMap[key])};`;
},
};
}For providers that don't use a prefix-based match (like lightning/primitiveUtils), add a match function:
{
match(id) { return id === 'lightning/primitiveUtils'; },
resolve(specifier) { /* ... */ },
}Internal Plugins
The plugin factory returns an array of coordinated Vite plugins:
| Plugin | Purpose |
| ------------------------------------- | ---------------------------------------------------- |
| vite-plugin-scoped-module-providers | Virtual module orchestrator for providers |
| vite-plugin-resolve-lightning-npm | Resolves lightning/* from npm with local overrides |
| vite-plugin-lwc-missing-css | Empty CSS for HTML-only templates |
| vite-plugin-lwc-bridge | Bridges Vite/LWC HTML and CSS conflicts |
| rollup-plugin-lwc-compiler | @lwc/rollup-plugin with Vite guard rails |
| vite-plugin-lwc-stubs | Alias stubs (only present if stubs is non-empty) |
Local Dev
The compiled bundle targets MCP hosts (ChatGPT, MCP Apps). For local development, you have two options:
- Mock
window.openai.callToolin your entry script — the recommended and simplest path. See thelwc-simpleandlwc-recordsexamples. Guard the install withif (!window.openai?.callTool)so the same compiled bundle skips the mock when loaded by a real host. - Run a local MCP server. A local demo MCP host serves your
dist/index.htmland provides realcallTooldispatch. More accurately reflects production runtime than the mock; ask your platform team for the current demo MCP app.
Either way, the plugin's runtime is production-only: local-dev concerns belong in your entry script (or local MCP harness), not in the compiled artifact.
Optional: Org Passthrough for /services/* Modules
lwcProxy() is an optional companion plugin for consumers whose components include lightning/* modules that call /services/* directly (e.g. legacy code predating the LDS adapter migration). It proxies those requests to a connected org so the dev server can serve live data.
lightning/graphql and lightning/uiRecordApi do not need lwcProxy — they dispatch through MCP (getChatSDK().callTool) via builtins.lds(). For those, route the graphqlQuery / getRecordMcpTool tool names at your MCP host (local demo or production) rather than the dev-server proxy.
Setup
npm install @salesforce/vite-plugin-lwc-ui-bundle @salesforce/ui-bundle// vite.config.js
import lwcVitePlugin, { lwcProxy } from '@salesforce/vite-plugin-lwc-ui-bundle';
export default defineConfig({
plugins: [
lwcProxy(), // reads sf CLI default org; pass { orgAlias: 'myOrg' } to specify
lwcVitePlugin({ ... }),
],
});How it works
lwcProxy()intercepts/services/*and/lwr/*requests in the Vite dev server and forwards them to Salesforce with the org's access token- Credentials are read automatically from the
sfCLI (uses@salesforce/ui-bundle/app, an optional peer dep) - Works with
npm run devonly — not the production build
Options
| Option | Type | Default | Description |
| ---------- | --------- | ------------------ | ------------------------------------ |
| orgAlias | string | sf CLI default org | Salesforce org alias |
| debug | boolean | false | Log each proxied request to terminal |
Claude Code Skill
This package includes a Claude Code skill that interactively sets up the plugin in your LWC project. It detects your project structure, asks which component to use as the root, inspects the component tree for GraphQL/label/base-component usage, and generates all the config files.
Installing the skill
Option A — Global (available in all projects):
cp -r node_modules/@salesforce/vite-plugin-lwc-ui-bundle/skills/setup-lwc-vite-plugin \
~/.claude/skills/setup-lwc-vite-pluginOption B — Project-scoped (available only in this project):
mkdir -p .claude/skills
cp -r node_modules/@salesforce/vite-plugin-lwc-ui-bundle/skills/setup-lwc-vite-plugin \
.claude/skills/setup-lwc-vite-pluginUsing the skill
Once installed, the skill triggers automatically in Claude Code when you ask things like:
- "Set up vite-plugin-lwc-ui-bundle in my project"
- "I want to run my LWC components outside Salesforce"
- "Build a static LWC bundle"
- "Add Vite to my SFDX project"
- "Compile LWC off-platform"
You can also invoke it explicitly with /setup-lwc-vite-plugin.
