Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions apps/cli/src/__tests__/plugin-scaffold-dependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join, relative } from "node:path";
import {
PLUGIN_SERVER_EXTERNALS,
RUNTIME_SLOT_BY_SPECIFIER,
SHIMMED_TYPE_PACKAGES,
} from "@bb/plugin-build";
import { scaffoldPlugin } from "@bb/templates/plugin-scaffold";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
Expand Down Expand Up @@ -79,7 +80,11 @@ function packageNameOf(specifier: string): string {
async function scaffoldWithDependencies(args: {
workDir: string;
app: boolean;
}): Promise<{ targetDir: string; dependencies: string[] }> {
}): Promise<{
targetDir: string;
dependencies: string[];
devDependencies: string[];
}> {
const packageName = `bb-plugin-${args.app ? "app" : "headless"}`;
const targetDir = join(args.workDir, packageName);
await scaffoldPlugin({
Expand All @@ -88,10 +93,15 @@ async function scaffoldWithDependencies(args: {
bbVersion: "0.9.0",
app: args.app,
});
const manifest: { dependencies?: Record<string, string> } = JSON.parse(
await readFile(join(targetDir, "package.json"), "utf8"),
);
return { targetDir, dependencies: Object.keys(manifest.dependencies ?? {}) };
const manifest: {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
} = JSON.parse(await readFile(join(targetDir, "package.json"), "utf8"));
return {
targetDir,
dependencies: Object.keys(manifest.dependencies ?? {}),
devDependencies: Object.keys(manifest.devDependencies ?? {}),
};
}

describe("scaffold dependency classification", () => {
Expand Down Expand Up @@ -134,6 +144,28 @@ describe("scaffold dependency classification", () => {
},
);

/**
* The flip side of the shim (#2072): esbuild never reads a shimmed package
* from node_modules, but tsc does, so every shimmed npm package has to be
* installed for types — as a devDependency — or the documented
* `import { toast } from "sonner"` fails to typecheck in a fresh scaffold.
* Derived from the build's shim table, so adding a slot without declaring
* its types fails here.
*/
it("declares every runtime-shimmed package as a type-only devDependency of an app scaffold", async () => {
const { dependencies, devDependencies } = await scaffoldWithDependencies({
workDir,
app: true,
});

expect(
SHIMMED_TYPE_PACKAGES.filter((name) => !devDependencies.includes(name)),
).toEqual([]);
expect(
SHIMMED_TYPE_PACKAGES.filter((name) => dependencies.includes(name)),
).toEqual([]);
});

it("keeps host-provided packages out of dependencies", async () => {
const { dependencies } = await scaffoldWithDependencies({
workDir,
Expand Down
32 changes: 25 additions & 7 deletions apps/cli/src/commands/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1333,7 +1333,7 @@ export function registerPluginCommands(
plugin
.command("types [path]")
.description(
"Sync a plugin's @get-bb/plugin-sdk surface to the running bb (default: cwd): repin the npm devDependency for plugins that depend on the package, or rewrite the vendored types/ declarations for plugins that still carry them",
"Sync a plugin's @get-bb/plugin-sdk surface to the running bb (default: cwd): repin the npm devDependency and the type-only devDependencies of the packages bb shims at runtime (sonner, vaul, the portal radix families, ...) for plugins that depend on the package, or rewrite the vendored types/ declarations for plugins that still carry them",
)
.option(
"--check",
Expand All @@ -1359,6 +1359,7 @@ export function registerPluginCommands(
const pending = await setPluginSdkPin({
rootDir,
sdkVersion: PLUGIN_SDK_VERSION,
app: hasApp,
dryRun: true,
});
if (pending === null) {
Expand All @@ -1367,20 +1368,30 @@ export function registerPluginCommands(
);
return;
}
console.error(
pending.pin === null
? 'Move "@get-bb/plugin-sdk" from dependencies to devDependencies — bb provides its runtime (`bb plugin types` does it for you).'
: `Set "@get-bb/plugin-sdk" to ${PLUGIN_SDK_VERSION} in devDependencies and re-run npm install (\`bb plugin types\` does it for you).`,
);
if (pending.pin !== null || pending.movedFromDependencies) {
console.error(
pending.pin === null
? 'Move "@get-bb/plugin-sdk" from dependencies to devDependencies — bb provides its runtime (`bb plugin types` does it for you).'
: `Set "@get-bb/plugin-sdk" to ${PLUGIN_SDK_VERSION} in devDependencies and re-run npm install (\`bb plugin types\` does it for you).`,
);
}
for (const shim of pending.shimmedTypePins) {
console.error(
shim.movedFromDependencies
? `Move "${shim.name}" from dependencies to devDependencies at ${shim.to} — bb shims it at runtime and never bundles it (\`bb plugin types\` does it for you).`
: `Set "${shim.name}" to ${shim.to} in devDependencies — the version this bb shims at runtime (\`bb plugin types\` does it for you).`,
);
}
process.exit(1);
}
const changed = await setPluginSdkPin({
rootDir,
sdkVersion: PLUGIN_SDK_VERSION,
app: hasApp,
});
if (changed === null) {
console.log(
`@get-bb/plugin-sdk is already pinned to ${PLUGIN_SDK_VERSION} — this bb's SDK version.`,
`@get-bb/plugin-sdk is already pinned to ${PLUGIN_SDK_VERSION} — this bb's SDK version${hasApp ? ", and the runtime-shimmed packages are at this bb's versions" : ""}.`,
);
console.log(
"The declarations are in node_modules/@get-bb/plugin-sdk/bundled-types/ — read them for exact signatures.",
Expand All @@ -1399,6 +1410,13 @@ export function registerPluginCommands(
"Moved @get-bb/plugin-sdk from dependencies to devDependencies.",
);
}
for (const shim of changed.shimmedTypePins) {
// Same reasoning as the SDK: bb shims these at runtime, so they
// are declared for types only, at the versions bb itself ships.
console.log(
`${shim.name}: ${shim.from ?? "(not declared)"} → ${shim.to} in devDependencies${shim.movedFromDependencies ? " (moved from dependencies)" : ""}.`,
);
}
// The new pin has to resolve for the declarations to land, so the
// same unpublished-version warning the scaffold prints applies.
await warnIfSdkVersionUnpublished();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,11 @@ them by mixing ink into canvas), the `--primary` accent, the secondary text tier
- `bb plugin types [path]` — sync the plugin's `@get-bb/plugin-sdk` surface
to the running bb (default: cwd). For a plugin that depends on the npm
package it rewrites the exact `devDependencies` pin to this bb's SDK
version (reporting old → new, and reminding you to `npm install`); for a
version and brings the type-only devDependencies of the packages bb shims
at runtime (sonner, vaul, the portal radix families, @pierre/diffs, clsx,
tailwind-merge, class-variance-authority) to this bb's versions — adding
any an app plugin is missing and moving one out of `dependencies`
(reporting old → new, and reminding you to `npm install`); for a
plugin that still vendors declarations it rewrites `types/*.d.ts`, creating
`types/` when absent. Run it in a cloned or older plugin: the SDK surface
grows every release. `--check` writes nothing and exits non-zero on a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ The manifest is `package.json`:
`devDependencies` makes the plugin uninstallable from git, and unbuildable
after any install that omits dev deps — including the packaged CLI's own,
which runs npm under `NODE_ENV=production`. `devDependencies` is for types
and tooling only.
and tooling only — including every package bb shims at runtime (sonner,
vaul, the portal radix families, @pierre/diffs, clsx, tailwind-merge,
class-variance-authority): the build never bundles them, but `tsc` still
resolves their declarations through node_modules, so each one you import
needs a `devDependencies` entry at the host's version (`bb plugin new`
writes all of them; `bb plugin types` repins them). Never put one in
`dependencies` — that bundles a second copy beside the host's.
- `bb.host` (optional, singular) — full-trust Node 22 ESM entry bundled into
`dist/host.js` + source map + `host.meta.json`. Its owning server entry calls
it through typed host RPC. The daemon downloads it lazily, verifies its
Expand Down Expand Up @@ -159,7 +165,10 @@ does not cover:
1. **`bb plugin types`**, run in the plugin directory (or given its path),
syncs that plugin's SDK surface to the running bb — no server needed. For a
plugin that depends on the npm package it repins the exact
`@get-bb/plugin-sdk` devDependency to this bb's SDK version (run
`@get-bb/plugin-sdk` devDependency to this bb's SDK version and brings the
runtime-shimmed packages' type-only devDependencies (sonner, vaul, the
portal radix families, ...) to the versions this bb ships — adding any an
app plugin is missing and moving one out of `dependencies` (run
`npm install` after); for an older plugin that still vendors `types/*.d.ts`
it rewrites those declarations. Either way a cloned or older plugin can be
thousands of lines behind. `--check` reports a mismatch without writing;
Expand Down Expand Up @@ -2170,7 +2179,13 @@ only `definePluginApp` + the hooks):
`-tooltip`, `-navigation-menu`), `sonner`, `vaul`, `@pierre/diffs` (+
`/react`). Your vendored overlays therefore share the host's
dismissable-layer/focus/scroll-lock world — stacking against host
overlays behaves correctly.
overlays behaves correctly. "Import freely" is about the bundle: `tsc`
still needs each one's declarations in `node_modules`, so every shimmed
package is a **type-only `devDependencies` entry at the host's version**
(the scaffold declares all of them; `bb plugin types` repins them; `bb
plugin types --check` reports drift). Never list one in `dependencies` —
the build would not read it, and a git install would bundle a second
copy of a singleton.
- Also never bundled, for size rather than singleton reasons: `clsx`,
`tailwind-merge`, and `class-variance-authority`. Your app bundle uses the
host's installed copies (tailwind-merge ^3, clsx ^2, cva ^0.7), so keep
Expand Down
37 changes: 4 additions & 33 deletions packages/plugin-build/scripts/generate-runtime-export-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { build } from "esbuild";
import { RUNTIME_SHIM_NPM_SPECIFIERS } from "../src/runtime-shims.mjs";

// Node 20 does not expose the browser-compatible Navigator global added in
// later Node releases. Some shared browser runtimes (currently @pierre/diffs)
Expand All @@ -31,39 +32,9 @@ const appRequire = createRequire(
path.join(scriptDir, "..", "..", "..", "apps", "app", "package.json"),
);

const RUNTIME_MODULE_IDS = [
"react",
"react-dom",
"react-dom/client",
"react/jsx-runtime",
"react/jsx-dev-runtime",
// Portaling radix families (plugin design §5.5): shimmed so vendored
// components share the host's dismissable-layer/focus/scroll-lock world.
// Non-portal radix has no singleton semantics and bundles per plugin.
"@radix-ui/react-alert-dialog",
"@radix-ui/react-context-menu",
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-hover-card",
"@radix-ui/react-menubar",
"@radix-ui/react-navigation-menu",
"@radix-ui/react-popover",
"@radix-ui/react-select",
"@radix-ui/react-tooltip",
// toast() must reach the host toaster; vaul mutates document.body styles.
"sonner",
"vaul",
// Diff rendering: FileDiff reads the host's WorkerPoolContextProvider
// (React context identity requires one module copy) and sharing keeps
// shiki's grammars out of plugin bundles.
"@pierre/diffs",
"@pierre/diffs/react",
// Host-resident libraries (RUNTIME_SLOT_BY_SPECIFIER rule 2): no singleton
// semantics, shimmed so plugin bundles stop duplicating them.
"clsx",
"tailwind-merge",
"class-variance-authority",
];
// The shimmed npm modules, from the same list `bb plugin build` shims
// (src/runtime-shims.mjs) so the manifest can never miss a slot.
const RUNTIME_MODULE_IDS = RUNTIME_SHIM_NPM_SPECIFIERS;

/**
* Workspace TypeScript modules exposed as slots. Not requireable, so their
Expand Down
89 changes: 14 additions & 75 deletions packages/plugin-build/src/build-plugin-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import { RUNTIME_EXPORT_MANIFEST } from "./generated/runtime-export-manifest.gen
import { type PluginBuildToolchain } from "./toolchain.js";
import { createPluginArtifactMeta } from "./plugin-artifact-meta.js";
import { isRecord, validatePluginBuildManifest } from "./plugin-manifest.js";
import {
LEGACY_PLUGIN_SDK_APP_SPECIFIER,
PLUGIN_SDK_APP_SPECIFIER,
RUNTIME_SLOT_BY_SPECIFIER,
SHARED_UI_ICON_SPECIFIER,
} from "./runtime-shims.mjs";
import {
pluginScopeRoots,
scopePluginUtilities,
Expand All @@ -46,82 +52,15 @@ import {
*/

/**
* Runtime slot on `globalThis.__bbPluginRuntime` per shimmed specifier.
* Shim policy (plugin design §5.5), two admission rules:
*
* 1. Singleton/global behavior — one React, the portaling radix families
* (shared dismissable-layer/focus/scroll-lock/aria-hidden world), sonner
* (`toast()` must reach the host toaster), vaul (mutates document.body
* styles), @pierre/diffs (its react FileDiff reads the host's
* WorkerPoolContextProvider — context identity requires one module copy —
* and sharing keeps shiki's grammars out of every plugin bundle) — plus
* the SDK surface itself.
* 2. Host-resident libraries every plugin app would otherwise duplicate —
* tailwind-merge + clsx (the `cn()` pair every vendored component pulls
* in), class-variance-authority, and the shared-ui `Icon` (its hugeicons
* map is ~110 KB raw per copy). These have no singleton semantics; they
* are shimmed so a phone does not parse a dozen copies of the same code.
* A plugin gets the host's installed version, so its declared range must
* stay within the host's major (tailwind-merge ^3, clsx ^2, cva ^0.7).
* Rule 2 has a cost on the host side: exposing a namespace on the
* runtime object stops the app's bundler from tree-shaking that library
* out of the boot chunk, so it only admits libraries whose slot leaves
* the boot budget (apps/app/bundle-budget.json) intact. zod does not —
* the app uses a fraction of its exports and slotting the namespace
* added +193 KB raw / +33 KB brotli to the payload every phone downloads
* before first paint — so zod stays bundled per plugin.
*
* Everything else (non-portal radix, lucide-react, zod, form/calendar/chart
* libs, hugeicons imported directly) bundles from the plugin's own
* node_modules. Adding a slot here requires the matching host slot in
* apps/app/src/lib/plugin-frontend.ts (installPluginRuntime) and an
* export-manifest entry (scripts/generate-runtime-export-manifest.mjs).
*/
/** The SDK app subpath plugin sources import. */
const PLUGIN_SDK_APP_SPECIFIER = "@get-bb/plugin-sdk/app";

/**
* Legacy alias for {@link PLUGIN_SDK_APP_SPECIFIER}, kept so pre-rename plugin
* sources still build. It resolves to the same runtime slot and the same
* export list; a later change removes it.
* The shim specifier → runtime-slot map lives in runtime-shims.mjs (plain
* ESM, so the export-manifest and plugin-scaffold generators can read the
* same list under bare `node`); the shim admission policy is documented
* there. Re-exported for the package's public surface.
*/
const LEGACY_PLUGIN_SDK_APP_SPECIFIER = "@bb/plugin-sdk/app";

/**
* The shared-ui icon module. Builtin plugins import it by package specifier;
* shared-ui's own components import it relatively (`./icon`), and
* {@link runtimeShimPlugin} routes both to the same host slot so no plugin
* bundle carries a second hugeicons map.
*/
const SHARED_UI_ICON_SPECIFIER = "@bb/shared-ui/icon";

export const RUNTIME_SLOT_BY_SPECIFIER: Record<string, string> = {
react: "react",
"react-dom": "reactDom",
"react-dom/client": "reactDomClient",
"react/jsx-runtime": "jsxRuntime",
"react/jsx-dev-runtime": "jsxDevRuntime",
[PLUGIN_SDK_APP_SPECIFIER]: "pluginSdkApp",
[LEGACY_PLUGIN_SDK_APP_SPECIFIER]: "pluginSdkApp",
"@pierre/diffs": "pierreDiffs",
"@pierre/diffs/react": "pierreDiffsReact",
"@radix-ui/react-alert-dialog": "radixAlertDialog",
"@radix-ui/react-context-menu": "radixContextMenu",
"@radix-ui/react-dialog": "radixDialog",
"@radix-ui/react-dropdown-menu": "radixDropdownMenu",
"@radix-ui/react-hover-card": "radixHoverCard",
"@radix-ui/react-menubar": "radixMenubar",
"@radix-ui/react-navigation-menu": "radixNavigationMenu",
"@radix-ui/react-popover": "radixPopover",
"@radix-ui/react-select": "radixSelect",
"@radix-ui/react-tooltip": "radixTooltip",
sonner: "sonner",
vaul: "vaul",
clsx: "clsx",
"tailwind-merge": "tailwindMerge",
"class-variance-authority": "classVarianceAuthority",
[SHARED_UI_ICON_SPECIFIER]: "sharedUiIcon",
};
export {
RUNTIME_SLOT_BY_SPECIFIER,
SHIMMED_TYPE_PACKAGES,
} from "./runtime-shims.mjs";

/**
* Real-path suffix of shared-ui's icon module (extension stripped). esbuild
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-build/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
export {
buildPluginApp,
RUNTIME_SLOT_BY_SPECIFIER,
SHIMMED_TYPE_PACKAGES,
} from "./build-plugin-app.js";
export {
buildPluginServer,
Expand Down
9 changes: 9 additions & 0 deletions packages/plugin-build/src/runtime-shims.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Type surface of runtime-shims.mjs (the data lives there so bare-`node`
// generator scripts can read it). Keep the two in step.

export const PLUGIN_SDK_APP_SPECIFIER: "@get-bb/plugin-sdk/app";
export const LEGACY_PLUGIN_SDK_APP_SPECIFIER: "@bb/plugin-sdk/app";
export const SHARED_UI_ICON_SPECIFIER: "@bb/shared-ui/icon";
export const RUNTIME_SLOT_BY_SPECIFIER: Readonly<Record<string, string>>;
export const RUNTIME_SHIM_NPM_SPECIFIERS: readonly string[];
export const SHIMMED_TYPE_PACKAGES: readonly string[];
Loading
Loading