Skip to content

Module Federation: Extract shared remote scaffolding - #2071

Merged
rappm merged 7 commits into
mainfrom
refactor/mf-remote-scaffold
Aug 22, 2026
Merged

Module Federation: Extract shared remote scaffolding#2071
rappm merged 7 commits into
mainfrom
refactor/mf-remote-scaffold

Conversation

@rappm

@rappm rappm commented Aug 20, 2026

Copy link
Copy Markdown
Member

Summary

Every phase remote carried its own copy of the Module Federation scaffolding: seven
rspack.config.mjs files of ~110 lines that differed only in a name, a port, and one alias, plus a
bootstrap.tsx and an App.tsx per component that did the same two things. This extracts that
scaffolding into clients/shared/ so a remote declares only what is actually specific to it.

Details

Where the shared code lives: clients/shared/, deliberately not a yarn workspace.

clients/shared/rspack/federatedDependencies.mjs   the singleton share scope
clients/shared/rspack/createRspackConfig.mjs      rspack config factory for a remote
clients/shared/runtime/mountRemote.tsx            React root mount for the standalone page
clients/shared/runtime/StandaloneNotice.tsx       the notice that page renders

A workspace package was the obvious alternative and was rejected: the runtime files are TypeScript
sources, so consuming them through node_modules would put them behind every component's
exclude: /node_modules/ swc-loader rule and they would ship untranspiled. A plain directory
consumed through relative imports compiles inside each component's own rspack and TypeScript setup,
and needs no dependency, lockfile, workspace, or Dockerfile change. It is already present in every
build context: clients/Dockerfile copies all of clients/ into the prompt-clients-base image at
/app, and each component image is FROM that base, so /app/shared exists before yarn build
runs in /app/<component>.

The build-time half sits under rspack/ rather than the more obvious build/ because
clients/.gitignore and clients/.dockerignore both ignore build at any depth. The first version
of this branch used build/, and the effect was instructive: every local checkout worked while the
factory was never committed and never reached the base image, so CI failed on the client builds and
the e2e stack failed with MODULE_NOT_FOUND on /app/core/rspack.config.mjs. Worth knowing before
adding anything else called build under clients/.

The build factory. Each remote config is now:

export default createRspackConfig({
  name: 'example_component',
  port: 3001,
  configUrl: import.meta.url,
})

configUrl is what keeps the factory faithful. The old configs used __dirname for
devServer.static and output.path while leaving context unset, so entry, the HTML template,
and the public/ copy stayed relative to the working directory. Moving the file would have silently
repointed the first group at the shared directory and, worse, changed output.uniqueName, which
rspack derives from the package.json nearest context. The factory therefore resolves the
component directory from the caller's import.meta.url and never sets context.

The assessment alias goes through resolveAlias: (componentDir) => ({ … }). It is a callback
rather than a plain object because the two @hookform/resolvers paths are resolved against the
component directory, which only the factory knows.

Share scope. federatedDependencies.mjs is imported by clients/core and by the factory, so
host and remotes now read the same five singletons (react, react-dom, react-router-dom,
@tanstack/react-query, @tumaet/prompt-shared-state) from one place and cannot drift apart. This
is the only part of core's config that changed. Core keeps its own config otherwise: it is the host,
and its remotes, publicPath, css rules, aliases, optimization, and compression plugin have
nothing in common with a remote.

The runtime scaffolding replaces bootstrap.tsx + App.tsx per component with a two-line
bootstrap.tsx. App.tsx is deleted; it was never exposed to core (a phase is mounted through
./routes, never through a root component) and was only imported by its own bootstrap.

Two latent bugs surfaced and are fixed. mountRemote throws on a missing root element instead
of casting null through unknown, which exposed that the assessment bootstrap looked for
assessment-root while its template.html declared template-root, so its standalone page rendered
nothing at all. Matching, team allocation, and self team allocation had likewise inherited a root id
from whichever component they were copied from (interview-root, template-root); each now uses its
own.

Tooling that referenced the removed constants, all updated: scripts/new-course-phase.sh and
template-repository/init.sh rewrote COMPONENT_DEV_PORT, which no longer exists; the
module-federation-remote skill, the module-federation/remotes rule, new_course_phase.md, and
the two component readmes documented the old per-component constants. quality-clients.yml lints
one module directory per matrix entry, so clients/shared would have gone unlinted; the lint step
now includes it. generator-smoke.yml also triggers on clients/shared/**, since the config it
generates depends on it.

template-repository/ stages an external repository that by definition cannot import from this
monorepo. That migration has not been executed, so the runbook now carries the step to vendor
clients/shared into the template and the two import depths to shorten. MIGRATION.md is the only
place this is recorded, because the staged client/ directory itself is created from
clients/example_component at migration time.

One unrelated change rode along: the repo's trailing-whitespace pre-commit hook normalized the
markdown hard breaks in clients/matching_component/readme when that file was touched.

Deliberately not included

  • src/index.js and src/provide/index.ts are named in the issue as duplicated, but they are not.
    Each is one to three lines of genuinely per-component content: assessment imports ./loadStyles
    where the others import ./styles.css, certificate has a src/provide.tsx file instead of a
    directory, example's provide/index.ts is empty, and the rest re-export a component-specific
    StudentDetail. There is nothing to factor out of them.
  • The two external remotes (intro_course_developer_component, github_challenge_component) live
    in their own repositories and are untouched.

Reason / Link to issue

Closes #1981

How to Test

The risk here is that a config or share-scope change breaks the app at runtime rather than at build
time, so this was verified by comparing artifacts against main rather than by typecheck alone.

  1. Resolved configs are byte-identical. A script imports each rspack.config.mjs with the same
    working directory rspack uses, calls it for NODE_ENV=development and =production, and
    serializes the result (regexes and plugin option objects included). All seven remotes produce
    output identical to main in both modes. Core's differs only in the ?${Date.now()}
    cache-buster; with that normalized it is identical too.
  2. Built artifacts match. All eight components build clean (yarn --cwd <c> build, caches
    cleared on both sides). Six of the seven remoteEntry.js files are byte-identical to main;
    assessment's differs in exactly one chunk contenthash reference. Per component the only changed
    assets are the standalone-page chunk, the entry chunk, and the two HTML files, which is precisely
    the surface this PR edits.
  3. Core is a no-op. Its main chunk is byte-identical. Its runtime chunk differs only in the
    MF module ids, which hash the timestamped remote URLs and therefore already differ between two
    builds of an unmodified tree; normalizing the timestamp and the ids makes base, new, and a second
    new build all identical.
  4. yarn tsc --noEmit per component, make lint, and CI's per-module yarn biome check "<module>" shared all pass with the same warning count as main and no errors.
  5. The generator still works. scripts/new-course-phase.sh ci_smoke 3990 8990 5490 produces a
    component whose factory call, root element id, and notice title are all renamed consistently,
    passes the smoke workflow's ! grep -rin 'example' assertion, and typechecks, lints, and builds
    a remoteEntry.js with the full share scope. The scaffold job covers this in CI.
  6. Every e2e shard is green, including the phase-module shards that load real remotes into the
    host, plus a full local make test-e2e run.

Manual check for a reviewer: cd clients && yarn dev, open http://localhost:3000, and confirm a
course with several phase types still lazy-loads each remote. Then open a remote's own port directly
(e.g. http://localhost:3007) and confirm the standalone notice renders, which is the part that was
broken for assessment before this PR.

PR Checklist

  • Tested locally or on the dev environment
  • Code is clean, readable, and documented
  • Tests added or updated (if needed)
  • Screenshots attached for UI changes (if any)
  • Documentation updated (if relevant)

@rappm
rappm requested a review from a team August 20, 2026 18:21
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@rappm, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bdda6dc6-99e3-4942-a424-79a0fcf83167

📥 Commits

Reviewing files that changed from the base of the PR and between 2538ea3 and 4c645c5.

📒 Files selected for processing (44)
  • .agents/skills/module-federation-remote/SKILL.md
  • .agents/skills/new-course-phase/SKILL.md
  • .claude/rules/module-federation/remotes.md
  • .github/workflows/generator-smoke.yml
  • .github/workflows/quality-clients.yml
  • AGENTS.md
  • clients/assessment_component/public/template.html
  • clients/assessment_component/rspack.config.mjs
  • clients/assessment_component/src/App.tsx
  • clients/assessment_component/src/bootstrap.tsx
  • clients/certificate_component/rspack.config.mjs
  • clients/certificate_component/src/App.tsx
  • clients/certificate_component/src/bootstrap.tsx
  • clients/core/rspack.config.mjs
  • clients/example_component/README.md
  • clients/example_component/rspack.config.mjs
  • clients/example_component/src/App.tsx
  • clients/example_component/src/bootstrap.tsx
  • clients/interview_component/rspack.config.mjs
  • clients/interview_component/src/App.tsx
  • clients/interview_component/src/bootstrap.tsx
  • clients/matching_component/public/template.html
  • clients/matching_component/readme
  • clients/matching_component/rspack.config.mjs
  • clients/matching_component/src/App.tsx
  • clients/matching_component/src/bootstrap.tsx
  • clients/self_team_allocation_component/public/template.html
  • clients/self_team_allocation_component/rspack.config.mjs
  • clients/self_team_allocation_component/src/App.tsx
  • clients/self_team_allocation_component/src/bootstrap.tsx
  • clients/shared/readme.md
  • clients/shared/rspack/createRspackConfig.mjs
  • clients/shared/rspack/federatedDependencies.mjs
  • clients/shared/runtime/StandaloneNotice.tsx
  • clients/shared/runtime/mountRemote.tsx
  • clients/team_allocation_component/public/template.html
  • clients/team_allocation_component/rspack.config.mjs
  • clients/team_allocation_component/src/App.tsx
  • clients/team_allocation_component/src/bootstrap.tsx
  • docs/contributor/new_course_phase.md
  • scripts/new-course-phase.sh
  • template-repository/MIGRATION.md
  • template-repository/init.sh
  • template-repository/repo/client.package.json

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

rappm added 4 commits August 20, 2026 20:27
Adds clients/shared, the scaffolding every phase remote in this repository
copied by hand until now:

- rspack/federatedDependencies.mjs owns the singleton share scope, so the host
  and the remotes read it from one place and cannot drift apart.
- rspack/createRspackConfig.mjs is the rspack config factory for a remote.
- runtime/mountRemote.tsx and runtime/StandaloneNotice.tsx are the standalone
  dev page.

It is deliberately not a yarn workspace: the files are consumed through
relative imports, so they compile inside each component's own rspack and
TypeScript setup and need no dependency, lockfile, or Dockerfile change.

The build-time half sits under rspack/ rather than the more obvious build/,
because clients/.gitignore ignores build/ at any depth and would have kept the
factory out of the repository while every local checkout still worked.
The seven remote rspack.config.mjs files were ~110 identical lines each,
differing only in the component name, the dev port, and assessment's
@hookform/resolvers alias. Each is now a call to createRspackConfig; the alias
goes through a resolveAlias hook that receives the component directory.

clients/core stays on its own config, since it is the host and shares almost
nothing with a remote beyond the share scope, which it now imports from
clients/shared instead of restating.
Every remote had the same bootstrap.tsx plus an App.tsx whose only job was the
'load this from inside the Prompt Core' notice. Both collapse into a two-line
bootstrap.tsx over mountRemote and StandaloneNotice, and App.tsx is gone (it was
never exposed to core, only imported by its own bootstrap).

mountRemote throws when the root element is missing instead of casting null
through unknown, which surfaces the mismatch this also fixes: the assessment
bootstrap looked for 'assessment-root' while its template.html declared
'template-root', so the standalone page rendered nothing. Matching, team
allocation, and self team allocation had likewise inherited a root id from the
component they were copied from; each now uses its own.
…lding

- scripts/new-course-phase.sh rewrote the COMPONENT_DEV_PORT constant, which no
  longer exists; it now rewrites the factory's port option. Same for
  template-repository/init.sh.
- quality-clients lints per module directory, so clients/shared would have gone
  unchecked; the lint step now includes it, and generator-smoke also triggers on
  it since the generated config depends on it.
- The MF skill and rule, the phase docs, and the two component readmes described
  the old per-component constants. The trailing-whitespace hook additionally
  normalized the markdown hard breaks in clients/matching_component/readme.
- template-repository is a staging area for an external repo that cannot import
  from this monorepo. Its runbook now says to vendor clients/shared into the
  template and how to shorten the two import paths.
@rappm
rappm force-pushed the refactor/mf-remote-scaffold branch from da956a3 to b969de7 Compare August 20, 2026 18:28
@rappm rappm added the schau mi o Translation: Ready to review label Aug 21, 2026
rappm added 2 commits August 22, 2026 21:57
createRspackConfig fell back to rspack's default port when `port` was omitted, and federatedDependencies emitted a share entry without a requiredVersion for any singleton package missing from clients/package.json. Both now throw.
The port substitution was the only anchor in either generator that failed silently. A generated phase would have kept port 3001 and collided with example_component under `make clients`.

@mathildeshagl mathildeshagl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. This is the best-verified PR of the four, and the description is doing real work rather than narrating the diff.

I reproduced your central check independently instead of trusting it: worktrees for the merge base and this branch, each rspack.config.mjs imported with the working directory rspack uses, called for both NODE_ENV values, and the result serialized including regex sources and plugin option objects.

  • All seven remotes: resolved configs byte-identical in both development and production.
  • Core: the only difference is the ?${Date.now()} cache-buster on the remote URLs. Nothing else, in either mode.

Other claims I checked rather than assumed:

  • exposes was already the same three entries in all seven configs on main, so the factory hardcoding them loses nothing.
  • The assessment bug is real: main had assessment-root in the bootstrap against template-root in template.html, so that standalone page rendered nothing at all. The other three inherited root ids are now self-consistent.
  • clients/shared is tracked and not ignored. And build/ genuinely would have been swallowed at any depth by both clients/.gitignore and clients/.dockerignore, so the rspack/ naming is load-bearing rather than cosmetic. Thanks for writing that story down in the description; it is the kind of thing that costs the next person an afternoon.
  • rootDir: ".." in the component tsconfigs already covers ../../shared/..., so the relative imports need no tsconfig change.
  • Docker: the base image's COPY . ./ from the clients/ context includes shared/, and each component image is FROM that base and only overlays its own directory, so /app/shared is present at yarn build time.

The configUrl reasoning is the part I'd have most likely got wrong myself. Deriving the component directory from the caller while leaving context unset, so entry, the HTML template, the public/ copy and output.uniqueName all keep resolving exactly as before, is subtle and the byte-identical configs confirm you got it right.

I did go looking for one failure mode and it is not there, so recording it as checked: since shared now reaches a component build only through the base image, a stale base would silently ship old scaffolding. It cannot happen in CI, because clients-base runs unconditionally on every run and every component job consumes IMAGE_TAG from that same run. And dev.yml uses paths-ignore rather than paths, so a clients/shared-only change still triggers the whole matrix.

Small things, none blocking:

  • The resolveAlias callback exists only so its one consumer can resolve against componentDir. On Node 24 that component could use import.meta.dirname and pass a plain object, which removes the callback indirection from the shared factory entirely. The same trick would let configUrl become dirname: import.meta.dirname and drop the fileURLToPath hop.
  • yarn biome check "<module>" shared lints shared once per matrix entry, so eight times. Redundant, but shared cannot be a matrix entry of its own since it has no tsconfig or build script, so this looks like the right trade rather than an oversight.
  • Worth a line in clients/shared/readme.md: building a single component image locally against a stale prompt-clients-base:latest now silently uses old shared code. That was impossible before, when all of a component's build inputs came from its own context.

One cross-PR note rather than a change request: #1939 adds an eighth remote with its own copy of exactly this scaffolding, and it does not know about clients/shared. Whichever of the two lands second needs a follow-up commit. Details on that PR.

@rappm
rappm merged commit 92f068e into main Aug 22, 2026
90 of 91 checks passed
@rappm
rappm deleted the refactor/mf-remote-scaffold branch August 22, 2026 21:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

schau mi o Translation: Ready to review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extract shared Module Federation remote scaffolding

2 participants