Summary
Plugin-owned virtualModules is a static map built once in PluginLoader. VirtualModuleRegistry.createPlugin() snapshots that map. That is correct for known specifiers (dynamic-ssr:entrypoints), but it cannot express N unknown specifiers produced at scan time and used as entrypoints for one isolated pipeline.
Motivating consumer: frame-master-plugin-cloudflare-pages-dynamic-ssr generates one .cfdynamicssr Functions wrapper per "use dynamic" page. Those wrappers are per-build, Functions-pipeline-only, and currently passed through Bun buildConfig.files. That bypasses the managed virtual-module provider, so they skip registry resolve/load, chain seeding, and debug traces. Putting them on the plugin-level registry would leak them into the client pipeline.
Locked API
Extend Frame-Master buildConfig (not Bun.BuildConfig) with a per-build overlay that uses the same declaration shape as plugin virtualModules:
type FrameMasterBuildConfig = Partial<Bun.BuildConfig> & {
virtualModules?: Record<string, VirtualModuleDeclaration>;
};
build: {
buildConfig: async () => ({
entrypoints: Object.keys(generated),
virtualModules: {
"src/actions/index.cfdynamicssr": {
contents: generated["src/actions/index.cfdynamicssr"],
loader: "tsx",
injectRuntime: false,
},
},
}),
}
Semantics:
- Overlay is collected when
Builder.build() merges configs, then discarded after that build.
- Isolated pipelines only see their own overlay + global plugin
virtualModules.
injectRuntime defaults to false for overlay modules and for the files shim.
contents is the existing string | Uint8Array | (() => string | Uint8Array | Promise<string | Uint8Array>).
- Overlay specifier colliding with a plugin
virtualModules entry, or with another plugin's overlay in the same Builder, errors and names both owners.
- Strip
virtualModules before Bun.build(). After promoting files, strip files too so there is one resolve path.
Legacy files shim
Auto-promote buildConfig.files into the overlay so apply-react / functions-action / dynamic-ssr keep working:
loader from buildConfig.loader[ext] when set, otherwise from the specifier extension (.tsx → tsx, .ts → ts, .jsx → jsx, .js → js, else js).
injectRuntime: false.
- Owner name is the plugin that contributed that
files entry (or "buildConfig.files" if that is not recoverable).
Do not keep passing files through to Bun.build() after promotion.
Prerequisite: live lookup
VirtualModuleRegistry.createPlugin() currently snapshots this.modules into a closed-over Map. Overlay and later registry mutations are invisible.
onResolve / onLoad must live-look up:
- this Builder's overlay map (if any)
- then the global plugin registry
Do not mutate the global PluginLoader registry with overlay entries (that would leak across pipelines). The Builder owns the overlay for the duration of build().
Recreating the managed provider each build() from (global registry + this-build overlay) is acceptable if live lookup of both maps is preserved.
Implementation notes
- Types:
packages/frame-master/src/plugins/types.ts (BuildOptionsPlugin.buildConfig return type → FrameMasterBuildConfig).
- Registry:
packages/frame-master/src/plugins/virtual-modules.ts (createPlugin live lookup + optional overlay argument).
- Builder merge /
Bun.build call: packages/frame-master/src/build/index.ts (createConfigs, mergeConfigSafely, build()).
- Isolated pipelines already pass
virtualModuleRegistry.createPlugin() in packages/frame-master/src/build/pipelines.ts — overlay must be per-Builder, not shared.
- Debug: overlay modules stay in
frame-master-virtual-module so attachTraceSource / debug UI treat them like plugin-declared modules.
- Docs:
packages/frame-master/docs/plugin-chaining.md and apps/docs/src/pages/docs/plugins/chaining/index.mdx (and build hooks if needed). Cover overlay vs plugin virtualModules, files shim, collisions, pipeline isolation.
mergeConfigSafely today deep-merges unknown objects, so files already merges. After this change, collect overlay after merge, then delete files and virtualModules from the object passed to Bun.build().
Acceptance
Use createPluginTestEnv / withTempDir / writeFixture:
Out of scope
invalidateVirtualModule / HMR protocol (4.0 roadmap).
- Mergeable virtual modules (
merge: "deep" | "append" | "error").
- Changing plugin-level
virtualModules into a whole-map factory.
- Rewriting apply-react / functions-action / dynamic-ssr in this PR (they should keep working via the
files shim). Dynamic SSR will switch to explicit buildConfig.virtualModules in a follow-up after this lands.
Why not the alternatives
builder.addVirtualModule(): imperative, easy to leak stale specifiers across rebuilds, second API besides declarative virtualModules.
- Plugin-level
virtualModules: () => Record<...>: rebuilt on invalidate, but leaks generated Functions files into the client pipeline and is not per-build.
Related
Summary
Plugin-owned
virtualModulesis a static map built once inPluginLoader.VirtualModuleRegistry.createPlugin()snapshots that map. That is correct for known specifiers (dynamic-ssr:entrypoints), but it cannot express N unknown specifiers produced at scan time and used as entrypoints for one isolated pipeline.Motivating consumer:
frame-master-plugin-cloudflare-pages-dynamic-ssrgenerates one.cfdynamicssrFunctions wrapper per"use dynamic"page. Those wrappers are per-build, Functions-pipeline-only, and currently passed through BunbuildConfig.files. That bypasses the managed virtual-module provider, so they skip registry resolve/load, chain seeding, and debug traces. Putting them on the plugin-level registry would leak them into the client pipeline.Locked API
Extend Frame-Master
buildConfig(notBun.BuildConfig) with a per-build overlay that uses the same declaration shape as pluginvirtualModules:Semantics:
Builder.build()merges configs, then discarded after that build.virtualModules.injectRuntimedefaults tofalsefor overlay modules and for thefilesshim.contentsis the existingstring | Uint8Array | (() => string | Uint8Array | Promise<string | Uint8Array>).virtualModulesentry, or with another plugin's overlay in the same Builder, errors and names both owners.virtualModulesbeforeBun.build(). After promotingfiles, stripfilestoo so there is one resolve path.Legacy
filesshimAuto-promote
buildConfig.filesinto the overlay so apply-react / functions-action / dynamic-ssr keep working:loaderfrombuildConfig.loader[ext]when set, otherwise from the specifier extension (.tsx→tsx,.ts→ts,.jsx→jsx,.js→js, elsejs).injectRuntime: false.filesentry (or"buildConfig.files"if that is not recoverable).Do not keep passing
filesthrough toBun.build()after promotion.Prerequisite: live lookup
VirtualModuleRegistry.createPlugin()currently snapshotsthis.modulesinto a closed-overMap. Overlay and later registry mutations are invisible.onResolve/onLoadmust live-look up:Do not mutate the global
PluginLoaderregistry with overlay entries (that would leak across pipelines). The Builder owns the overlay for the duration ofbuild().Recreating the managed provider each
build()from(global registry + this-build overlay)is acceptable if live lookup of both maps is preserved.Implementation notes
packages/frame-master/src/plugins/types.ts(BuildOptionsPlugin.buildConfigreturn type →FrameMasterBuildConfig).packages/frame-master/src/plugins/virtual-modules.ts(createPluginlive lookup + optional overlay argument).Bun.buildcall:packages/frame-master/src/build/index.ts(createConfigs,mergeConfigSafely,build()).virtualModuleRegistry.createPlugin()inpackages/frame-master/src/build/pipelines.ts— overlay must be per-Builder, not shared.frame-master-virtual-modulesoattachTraceSource/ debug UI treat them like plugin-declared modules.packages/frame-master/docs/plugin-chaining.mdandapps/docs/src/pages/docs/plugins/chaining/index.mdx(and build hooks if needed). Cover overlay vs pluginvirtualModules,filesshim, collisions, pipeline isolation.mergeConfigSafelytoday deep-merges unknown objects, sofilesalready merges. After this change, collect overlay after merge, then deletefilesandvirtualModulesfrom the object passed toBun.build().Acceptance
Use
createPluginTestEnv/withTempDir/writeFixture:buildConfig.virtualModulesspecifier used as an entrypoint builds successfully through the managed provider (frame-master-virtual-modulenamespace, chained contents seeded).buildConfig.filesstill resolves via the shim (no disk file).builder.build()with a different overlay set picks up added specifiers and drops removed ones.virtualModulesremain visible to both.virtualModulesvs overlay, or two overlay owners) errors and names both owners.injectRuntime: trueis opt-in only; default /filesshim stay build-only (createPlugin(true)does not include them).[Function], and the file is labeled virtual.filesshim, and pipeline isolation.bun run typecheck, focused virtual-module / builder tests, and packagebun testpass.Out of scope
invalidateVirtualModule/ HMR protocol (4.0 roadmap).merge: "deep" | "append" | "error").virtualModulesinto a whole-map factory.filesshim). Dynamic SSR will switch to explicitbuildConfig.virtualModulesin a follow-up after this lands.Why not the alternatives
builder.addVirtualModule(): imperative, easy to leak stale specifiers across rebuilds, second API besides declarativevirtualModules.virtualModules: () => Record<...>: rebuilt on invalidate, but leaks generated Functions files into the client pipeline and is not per-build.Related
virtualModulesregistrycontentsfactory