0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/plugin-v2-implementation.md
David Taylor 7ae55c3762
waffle
2026-07-14 12:41:35 +01:00

19 KiB
Vendored

staticModules — implementation sketch

Companion to plugin-v2-plan.md. Records what the existing machinery actually does, the contract change, a file-by-file plan, and three problems the plan doesn't cover.

1. How it works today

Plugins and themes are not part of core's rolldown build. They are compiled at Rails runtime by Ruby, driving @rollup/browser inside mini_racer:

core plugin theme
bundler rolldown (frontend/discourse/rolldown.config.mjs) @rollup/browser in mini_racer same
driver node, build time lib/plugin/js_manager.rb lib/theme_javascript_compiler.rb
entrypoint real files generated virtual:entrypoint same generator
output dist/ + manifest app/assets/generated/<plugin>/js/plugins/ + manifest one DB row (javascript_caches)
route splitting yes — wizard no no

The generator is frontend/asset-processor/rollup-virtual-imports.js. For every file in the plugin/theme it emits a namespace import and stuffs it into one object:

import * as Mod1 from "./discourse/components/chat-channel";
const compatModules = { "discourse/components/chat-channel": Mod1, /* ...everything... */ };
export default compatModules;

That default export has two consumers, which is the crux of the design:

  1. Core's AMD registry. app.js:81-105 does (await import(link.href)).default and define("discourse/plugins/<name>/<key>", ...) for each entry.
  2. Cross-bundle imports. babel-resolve-plugin-imports.js:61-129 rewrites import X from "discourse/plugins/chat/discourse/components/foo" into a real ESM default import of the bare specifier "discourse/plugins/chat" (satisfied by the importmap in _plugin_js.html.erb), then indexes into the map: _plugin_chat["discourse/components/foo"].

So the default export is the plugin's cross-bundle public API, and sharedModules is precisely the declaration of what belongs in it. Everything eager today; nothing tree-shakes, because import * as Mod roots every module.

Route splitting already exists for core and the runtime contract is fixed by @embroider/router:

window._embroiderRouteBundles_ = [
  { names: ["wizard", "wizard.index", "wizard.step"],
    load: () => import("./route-wizard-m7beykpk.digested.js") },
];

On navigation the router finds a bundle whose names contains the route name (exact indexOf), awaits load(), and passes .default to resolver.addModules(modules). Discourse's resolver (app/resolver.js:370) implements that as plain define() calls — so lazy route modules land in the same AMD registry as everything else. mapping-router.js already extends EmbroiderRouter and already overrides lazyRoute.

2. The contract change

// default export: cross-bundle public API. Legacy = everything; static = `sharedModules`.
export default { "discourse/components/chat-channel": Mod1, ... };

// eager AMD registrations, define()d by core at boot
export const compatModules = { "discourse/initializers/chat": Mod2, ... };

// lazy route bundles, pushed onto window._embroiderRouteBundles_ by core.
// `names` is always concrete — derived at build time, never a wildcard.
export const routes = [
  { names: ["chat.visualizer"], load: () => import("virtual:route:chat.visualizer") },
  { names: ["chat", "chat.channel", "chat.browse"], load: () => import("virtual:route:chat") },
];

Emitted per entrypoint, so the admin bundle exports its own routes in exactly the same shape.

default must stay the cross-bundle table — babel-resolve-plugin-imports and generate_import_map both depend on it. compatModules becomes a separate named export so the AMD set can shrink independently of the public API.

When staticModules is absent, emit today's output plus export const compatModules = default. No stale-bundle risk: plugin and theme bundles are always recompiled from source by the running core, so the contract can change in one step.

3. File-by-file

JS build — frontend/asset-processor/

rollup-virtual-imports.js — the bulk of the work. Extract a pure partitionModules(moduleFilenames, frontend) used by both generators below, returning { shared, eager, routeBundles }.

  • virtual:entrypoint — when frontend.staticModules, emit the three exports above instead of one eager map. Route bundles become () => import("virtual:route:<glob>"); rollup code-splits them automatically (chunkFileNames is already configured).
  • virtual:route:<routeName> — new generator, same body shape as core's embroider route entrypoint:
    const routeCompatModules = {};
    routeCompatModules["discourse/routes/chat/channel"] = M1;
    export default routeCompatModules;
    
    Keys stay plugin-relative; core prefixes them on load (§4), matching how compatModules is already handled.

rollup-plugins/discourse-virtual-loader.js — teach resolveId/load about virtual:route: ids alongside the existing virtual:entrypoint: / virtual:theme handling. The isTheme gate at :9-14 is the seam.

Route names are derived at build time, exactly like Embroider

Port AppFiles#handleClassicRouteFile + splitRoute from @embroider/core (dist/src/app-files.js:146-198, dist/src/virtual-entrypoint.js:200-260). Embroider builds its route tree purely from file paths — no router AST parsing: it strips the routes/ / controllers/ / templates/ prefix, nests the remaining segments, and splitRoute joins them with . to produce the route name. Ember's own resolver convention guarantees the file path is the route name, so this is sound rather than guesswork.

So names comes out concrete, and @embroider/router's exact names.indexOf(routeName) works untouched — no wildcard matching, and no change to mapping-router.js.

The two sides of splitAtRoutes are for two different consumers

"splitAtRoutes": {
  "chat/visualizer": "chat.visualizer",
  "chat/*":          "chat.*"
}
  • Values are route-name patterns, consumed at build time. They feed shouldSplitRoute exactly as Embroider's splitAtRoutes does. Both entries go into the split set and ordering is irrelevant here: splitRoute recurses, so a parent (chat.*) claims all descendants into one bundle except those a more specific child (chat.visualizer) splits into its own. Two patterns, two bundles, no precedence rules needed.
  • Keys are user-facing URL globs, consumed at request time by Rails, to decide which route chunks to preload. This is why the schema has to stay a map — the URL is not derivable from the route name (route names come from the file tree; URLs come from route-map.js path: options, which the build never sees). Here ordering does matter: first match wins, so chat/visualizer must precede chat/*.

Glob rules: a single trailing *, both sides

No mid-path wildcards, no :id segments, no regexes. Both sides are either an exact string or a prefix followed by one trailing star. This keeps the matchers trivial (a == or a start_with?) and means the JS and Ruby sides can't drift in their interpretation.

On the value side this collapses further than it looks. chat.* means "chat and everything beneath it" — but Embroider's splitRoute always claims descendants when it splits a route, so that is simply "split at route chat". Strip a trailing .* and exact-match the remainder against derived route names; the recursion does the rest, and the ported shouldSplitRoute never needs the regex branch Embroider carries. Worth being explicit in the docs that a bare chat and chat.* are therefore identical — the star is documentation, not behaviour — or authors will reasonably assume the starless form splits only the parent's own files, which is not expressible.

On the key side, match against the request path with the site's relative_url_root stripped, so subfolder installs work.

Preloading — the point of the URL keys

Without this, a user landing directly on /chat/visualizer pays a waterfall: HTML → core → plugin entrypoint → then the router discovers it needs a route chunk. The URL keys let Rails short-circuit that and emit the <link> up front, alongside the existing plugin modulepreloads.

Two pieces of work:

  1. Record route chunks in the plugin manifest. js_manager.rb:169 only stores chunks where isEntry is true; route chunks are dynamic entries, so they're written to disk but never recorded. performRollup must surface them (rollup gives isDynamicEntry + facadeModuleId per chunk — core's rolldown config already does precisely this in its bundle-manifest plugin, rolldown.config.mjs:196), keyed back to the about.json URL glob:
    "routeBundles": [
      { "url": "chat/visualizer", "fileName": "chat_route-chat-visualizer-a1b2c3.digested.js" },
      { "url": "chat/*",          "fileName": "chat_route-chat-d4e5f6.digested.js" }
    ]
    
  2. Match at request time. In application_helper / _plugin_js.html.erb, glob request.path against each plugin's routeBundles (first match wins) and emit a <link rel="modulepreload"> — not rel=preload, since these are ES modules and modulepreload is what the existing plugin/chunk preloads already use (_plugin_js.html.erb:21-27).

Note this only helps the initial page load. Client-side transitions go through the router's lazy load() as normal, which is the behaviour we want.

Route splitting applies to the admin entrypoint too

No special-casing needed, and nothing about the derivation changes: plugin admin assets use the same layout (admin/assets/javascripts/discourse/routes/admin-plugins/show/automation/edit.js → route name admin-plugins.show.automation.edit), already dasherized to match mapping-router's lazyRoute. Core needs no change either — the admin bundle is just another <link rel=modulepreload data-plugin-name>, so loadPluginFromModulePreload picks up its routes export exactly like main's.

Three things follow:

  • Partitioning is per-entrypoint. virtual:entrypoint:<name> already receives only that entrypoint's module list; run the route-tree derivation over each independently. One splitAtRoutes map in about.json covers both, since Ember route names are globally unique — a pattern matching only admin route names simply produces no bundles in main.
  • Virtual route ids stay unscopedvirtual:route:<routeName>. main and admin are inputs to a single rollup call and share an id namespace, but they already share the discourse/plugins/<name>/… compat-module namespace too, so non-clashing module names is an existing rule, not a new one. Route ids inherit it: a route name resolves to exactly one entrypoint, so the loader can find its module list unambiguously.
  • routeBundles in the manifest must be recorded per entrypoint, so Rails only preloads admin route chunks on requests where it is already emitting the admin asset (include_admin_asset). Preloading an admin chunk for an anonymous user would be a leak of sorts and a waste besides.

Shared code between main and admin route chunks is hoisted into common chunks by rollup for free, since they're inputs to the same build.

One Discourse-specific guard. Embroider nests every templates/** path, so templates/connectors/foo/bar would be misread as a route named connectors.foo.bar. Core apps don't have that directory; Discourse plugins do. Mostly moot once .hbs is out of the picture (modern connectors are .gjs under discourse/connectors/), but excluding templates/connectors/** and templates/components/** from the route tree costs a line and avoids a baffling failure.

Ruby plumbing

lib/plugin/js_manager.rbcompile_js_bundle already holds the plugin object, so pass frontend: plugin.about_json_metadata&.dig("frontend") into Plugin::JsCompiler. Mix about.json into the SHA1 cache digest at :120-131 — it isn't there today, so editing the frontend key would not rebuild the bundle.

lib/plugin/js_compiler.rb — forward frontend: into the opts hash handed to AssetProcessor#rollup. That hash is the natural carrier; no other transport needed.

lib/theme_javascript_compiler.rbdeliberately not plumbed. Themes never pass a frontend config into opts, so the generator always takes the legacy path for them and no theme can produce a second chunk. See §4.

This is the whole of the theme deferral: the asset-processor side is written generically and works for themes the moment the config is passed, but the Ruby side simply doesn't pass it. The compiler is constructed with only (theme_id, theme_name, minify:) today and the theme's raw about.json lives in a sibling ThemeField (target_id: Theme.targets[:about]) it never sees — so "don't plumb it" is the default, not extra work.

Consequence to accept knowingly: a theme that sets frontend.staticModules in about.json is silently ignored rather than rejected. Since nothing validates about.json keys today anyway, that's consistent — but it's the one place a theme author could be misled, so a warning when a theme declares a frontend block is cheap and worth adding.

Validation — there is no schema anywhere; unknown about.json keys are silently ignored. Good for compatibility, but nothing will catch staticModule or a splitAtRoutes glob that matches no files. Worth a small validator that warns at compile time.

Core runtime — frontend/discourse/app/

app.jsloadPluginFromModulePreload / loadThemeFromModulePreload become:

const mod = await import(link.href);
const compatModules = mod.compatModules ?? mod.default;  // legacy bundles: default is everything
for (const [key, m] of Object.entries(compatModules)) {
  define(`discourse/plugins/${pluginName}/${key}`, () => m);
}
for (const { names, load } of mod.routes ?? []) {
  window._embroiderRouteBundles_.push({
    names,
    load: async () => {
      const routeModules = (await load()).default;
      return {
        default: Object.fromEntries(
          Object.entries(routeModules).map(([k, v]) => [`discourse/plugins/${pluginName}/${k}`, v])
        ),
      };
    },
  });
}

window._embroiderRouteBundles_ is set by core's own embroider entrypoint, which is imported by app.js — so it exists by the time this runs, but initialise it defensively.

mapping-router.js — no change needed. Because names is derived concretely at build time, the router's exact indexOf match already works, and the existing dasherize override lines up with the dasherized file paths the names come from.

4. The eager set, and two landmines

The eager set

compatModules is everything Discourse still resolves by name at runtime:

eager (define()d) why
pre-/api-/instance-initializers loadInitializers enumerates requirejs.entries (app.js:221)
route-map mapRoutes() scans requirejs.entries for /route-map$/
plugin-outlet connectors resolved by name by the outlet system
services, models, adapters @service chatSuffixTrie over requirejs.entries (resolver.js:114)
routes / controllers / templates resolved by name — minus whatever splitAtRoutes claims

Helpers, components and modifiers are deliberately not eager. Under staticModules they are imported statically from .gjs, and resolver-based lookup for them is dropped.

.hbs is not supported in this mode at all. Assume it isn't used — no detection, no compile-time errors, no graceful degradation. A static plugin that ships .hbs is simply broken, and that's the author's problem. This is what lets the eager set stay as small as it is.

This also settles what sharedModules is for: the default export is reached by ESM indexing from other bundles (babel-resolve-plugin-imports compiles cross-plugin imports into _plugin_chat["discourse/components/foo"]), not through AMD. So sharedModules does not need define()ing — default and compatModules stay genuinely separate sets, and only the latter is registered.

Everything outside both sets — lib/, unshared models/, dead code — now tree-shakes, which it cannot today because import * as Mod roots every module.

The landmines

(a) Two boot-time caches are never invalidated. lookupModuleBySuffix memoises its trie on first use, and populate-template-map snapshots Object.keys(requirejs.entries) once, in an instance-initializer. Modules define()d later by a lazy route bundle are invisible to both. Core's wizard split gets away with it because core templates sit at canonical paths that ember-resolver finds directly; plugin/theme templates live at discourse/plugins/<n>/discourse/templates/... and only DiscourseTemplateMap knows how to map them. Fix centrally in the resolver, which is the single funnel for lazily added modules:

addModules(modules) {
  for (const [key, value] of Object.entries(modules)) {
    define(key, () => value);
  }
  expireModuleTrieCache();
  DiscourseTemplateMap.setModuleNames(Object.keys(requirejs.entries));
}

expireModuleTrieCache() currently has exactly one caller: a test helper (tests/helpers/temporary-module-helper.js), which does precisely this pair of calls.

(b) Themes cannot code-split at all — so themes are deferred. theme_javascript_compiler.rb:52 keeps only the chunk named main and drops the rest; the result is a single DB row served by digest at /theme-javascripts/:digest.js. A second chunk would be silently discarded and the theme would break at runtime. Plugins have none of this problem: they already emit multiple chunks to disk next to the entrypoint, so a relative import("./chunk-x.js") just resolves.

We are not fixing this now. The asset-processor is written generically — nothing in the generator or the virtual:route:* loader is plugin-specific — but ThemeJavascriptCompiler never passes a frontend config, so themes always take the legacy path and can never reach the code-splitting branch. The deferral is enforced by omission, which is what makes it safe.

When it is picked up, the cleanest fit looks like: make each chunk's filename be its content digest, store one JavascriptCache row per chunk, and let the existing theme-javascripts/:digest route serve them — a relative import from /theme-javascripts/<digest>.js then resolves to a sibling digest with no URL rewriting anywhere.

5. Suggested order

  1. Split the entrypoint contract (compatModules named export) with no behaviour change; core reads mod.compatModules ?? mod.default. Ships alone, no flag.
  2. Fix the resolver cache invalidation (landmine (a)). Independently correct, unblocks the rest.
  3. frontend.staticModules for plugins only: partition in the generator, plumb about.json through JsManager (+ cache digest). No route splitting yet — this alone buys tree-shaking, and shakes out the .gjs-only contract on a real plugin before any lazy loading is involved.
  4. splitAtRoutes for plugins: port the route-tree derivation, add virtual:route:*, the routes export, and _embroiderRouteBundles_ registration. Lazy loading works, with a waterfall on direct navigation. Dogfood on chat.
  5. Preloading: surface route chunks in the plugin manifest, match the URL globs in Rails, emit the modulepreload. Removes the waterfall from step 4.
  6. (deferred) Themes: multi-chunk persistence and serving, then pass frontend through ThemeJavascriptCompiler and everything above applies unchanged.