Skip to content

virtual:emdash/plugins silently generates empty in production builds (content-hash dependent) #2589

Description

@kgittyup

Summary

Under specific, reproducible conditions, astro build produces a green build where the generated virtual:emdash/plugins module is never emitted: no const plugins = [ literal and no adaptSandboxEntry(...) call anywhere in dist/ (only the definition chunk), while the chunk count and chunk name set are identical to a healthy build. All configured plugins are silently absent at runtime — the app boots with an empty plugin list and no errors. astro dev is unaffected and fully correct.

Environment

  • emdash 0.34.0, Astro 6.4.6 (also reproduced on 6.4.8), Node 22.22.3, macOS arm64
  • One standard-format plugin registered via plugins: [factory()] with a package entrypoint (format: "standard", private scoped package, compiled ESM in dist/)
  • vite: { ssr: { noExternal: [/^@scope\//] } } on the consuming site

Evidence (~25 controlled builds, each checked for a positive marker string in dist)

  1. Reproduces with a single plugin and no other integrations; independent of Vite cache state (node_modules/.vite and .astro wiped between builds), bare-specifier vs absolute-path entrypoint, TS-source vs compiled-JS package form, and the trigger file's name.
  2. A stub entrypoint (export default {}) is always healthy — the failure follows the entrypoint's import graph.
  3. Bisecting with used imports (side-effect imports get tree-shaken and mask the failure — important trap) traces the trigger to one small dependency file in node_modules. Most modules in the same package are fine; that one file alone triggers the empty output.
  4. The failure is content-hash dependent, not syntactic. Flip-switch table, all in the same environment, changing only content:
    • Remove the JSDoc comment above one function AND a secondary export → healthy
    • Keep the comment, single export → empty; no comment + two exports → empty
    • Two other exports from the same original file, without that function → healthy
    • Exact same bytes moved to a file in the project's src/ → healthy
    • Trivial content at the same node_modules path (same filename) → healthy
    • Renamed copy (different filename, same content, same directory) → empty
  5. Byte-level dodging is not a viable mitigation: two independent byte patterns of the trigger file (one with comments, one comment-stripped — a pattern never previously built) produce the same empty output in the affected environment, while an unaffected environment stays healthy with both. The sensitivity is to aggregate content, not any specific token.

Minimal trigger

A file in node_modules with this content, imported with used symbols:

import { createHash } from "node:crypto";
/** Short stable fingerprint from the identifying parts of an event. */
export function fingerprint(...parts) {
    return createHash("sha256").update(parts.join(" ")).digest("hex").slice(0, 16);
}
export const dummy = 1;

Plugin entrypoint (pointed at by the plugin descriptor):

import * as m from "<abs path>/node_modules/<pkg>/dist/events-copy.js";
export default { probe: m };

Caveat: this minimal trigger reproduces deterministically in one environment but NOT in another with an identical version stack and module graph — consistent with content-hash dependence on the overall graph. The strength of this report is the flip-switch evidence above (comment on/off in the same environment), which proves hash dependence regardless of whether a maintainer reproduces directly.

Interpretation

Looks like a transform-/cache-collision in the production bundling of the virtual module: certain content hashes in the graph cause virtual:emdash/plugins to resolve to empty, silently.

Suggested hardening regardless of root cause

generatePluginsModule() output is critical infrastructure. A build-time assertion that the emitted bundle actually contains the expected plugin registrations — or at minimum a loud runtime warning when plugins.length differs from the configured descriptor count — would turn this silent failure into a visible one. In our case the site shipped green builds with security tooling silently absent; we now grep dist for a positive marker before every deploy, but that shouldn't be necessary.

Happy to share the full build matrix on request.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions