Skip to content

feature(build): per-build virtualModules overlay on buildConfig #58

Description

@shpaw415

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 (.tsxtsx, .tsts, .jsxjsx, .jsjs, 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:

  1. this Builder's overlay map (if any)
  2. 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:

  • buildConfig.virtualModules specifier used as an entrypoint builds successfully through the managed provider (frame-master-virtual-module namespace, chained contents seeded).
  • Legacy buildConfig.files still resolves via the shim (no disk file).
  • A second builder.build() with a different overlay set picks up added specifiers and drops removed ones.
  • Pipeline A overlay is invisible to pipeline B; global plugin virtualModules remain visible to both.
  • Duplicate specifier (plugin virtualModules vs overlay, or two overlay owners) errors and names both owners.
  • Overlay injectRuntime: true is opt-in only; default / files shim stay build-only (createPlugin(true) does not include them).
  • Debug initial snapshot is overlay/factory source, not [Function], and the file is labeled virtual.
  • Docs cover the overlay, the files shim, and pipeline isolation.
  • bun run typecheck, focused virtual-module / builder tests, and package bun test pass.

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions