Skip to content

feat(engine): swap a module's code instead of reinstalling it - #747

Draft
romain-pm wants to merge 3 commits into
mainfrom
feat/dev-server-fast-path
Draft

feat(engine): swap a module's code instead of reinstalling it#747
romain-pm wants to merge 3 commits into
mainfrom
feat/dev-server-fast-path

Conversation

@romain-pm

Copy link
Copy Markdown

Closes #699 (first stage).

yarn watch rebuilds a module in tens of milliseconds and then spends seconds reinstalling it: pack a tarball, turn it into an OSGi bundle, write it to the JCR, restart the module. yarn dev:fast keeps the rebuild and replaces everything after it.

  • Server views — the built dist/server/index.js is pushed to a new engine endpoint, which replaces the Graal Source in place, bumps the context-pool version and re-runs the registrars.
  • Client bundles, stylesheet, assets — served from the developer's dist through the engine, so the browser reads the build that just ran. buildModuleFileUrl points there while a session is open, and back at the installed bundle when it closes.
  • Open pages poll a reload counter and reload themselves.

Measured

On samples/hydrogen, against Jahia 8.2.3 in Docker: edit → visible 590 / 462 / 435 ms, of which 124–218 ms is the engine swap. The same loop through pack + provisioning measures 4.1–4.9 s.

Correctness details worth reviewing

  • Registrars are unregistered before being re-registered: AbstractServiceRegistrar accumulates its OSGi registrations, so a second register would publish every render filter twice.
  • The swap fires TemplatePackageRedeployedEvent. Nothing else does — a swap raises no bundle event — and development mode does not disable the HTML fragment cache, so without it the old view keeps being served.
  • Registrar.runsOnHotReload() lets a registrar opt out of the hot path. Content patches will need it: their records are terminal, so a per-save loop would burn them.
  • ViewsRegistrar.viewsPerBundle becomes a ConcurrentHashMap — read on every render, and this loop turns one write per deploy into one per save.
  • reloadServerBundle is synchronized: two swaps racing leave the context pool unable to validate any context.

Security

The endpoint evaluates JavaScript the caller supplies, inside the Jahia JVM. It answers only when its own OSGi property enables it and Jahia runs in development mode, and pushing code requires the root user. Development mode alone would not do: operatingMode defaults to development in SettingsBean, in the shipped jahia.properties and in the published images. Activation logs a WARN naming the open endpoint. While a session is open, the module's dist is readable by anyone who can reach that Jahia — stated in the guide.

Scope

Node type definitions, imported content, locales, resource bundles, icons, .cfg and package.json all come from the installed bundle and still need a redeploy; the loop names the file when one of them changes. #742 would make that redeploy cheap too.

A Vite dev server with HMR is deliberately not here — it needs #740 first (the engine ships no react/jsx-dev-runtime and a production React build, so islands would not hydrate and Fast Refresh cannot work). Tracked as #741.

Note for review

server.dev is declared by hand in globals.d.ts rather than imported from the generated Java typings: java-ts-bind emits DevHelper without its methods and I have not found why. Worth a look from someone who knows that generator.

Rebuilding a module takes tens of milliseconds and reinstalling it takes
seconds: a tarball is packed, turned into an OSGi bundle, written to the
JCR, and the module is restarted. The rebuild is not what makes the loop
slow, so `yarn dev:fast` keeps it and replaces everything after it.

The engine gains a development endpoint at /modules/jsm-dev. It takes the
server bundle a build just produced and swaps it into the running GraalVM
engine: the Source for that bundle is replaced in place, the context pool
version is bumped so pooled contexts recycle, and the registrars re-run —
unregister before register, since they accumulate their OSGi services. It
then fires the redeploy event, because nothing else does on a swap and the
HTML fragment cache would keep serving the views that were just replaced;
development mode does not disable that cache. A registrar whose
registration also performs a one-off effect can now opt out of the hot
path with runsOnHotReload().

The endpoint also proxies the module's files to the development server, so
client bundles, the stylesheet and emitted assets come from the build that
just ran rather than from the installed bundle — buildModuleFileUrl points
there while a session is open. Pages poll a reload counter and reload
themselves once a swap lands.

It executes JavaScript that the caller supplies, so it answers only when
its own OSGi property enables it AND Jahia runs in development mode, and
pushing code requires the root user. Development mode alone would not do:
Jahia's operating mode defaults to development, including in the published
images.

ViewsRegistrar.viewsPerBundle becomes a ConcurrentHashMap: it is read on
every render, and the loop turns its one-write-per-deploy into a write per
save.

Node type definitions, imported content, locales, resource bundles and
OSGi configurations still come from the installed bundle and still need a
redeploy. The loop says so when one of them changes.
Everything here was found by running the loop against Jahia 8.2.3 in
Docker; each was invisible to unit tests.

The proxy forwarded the wrong path. Jahia dispatches into OSGi through a
proxy servlet mapped at /modules, so the request arrives without that
prefix and the development server answered 404 for every asset. The path
is now rebuilt from the prefix the servlet owns.

The listener published no service under its own type, only BundleListener,
so the servlet's reference to it was never satisfied and the whole
endpoint stayed silent — a 404 that looked exactly like a disabled one.

A session could be opened onto an origin Jahia cannot reach. Jahia in a
container does not share the developer's localhost, so the endpoint now
probes the origin before accepting it, which is what lets the CLI fall
back to the name the container knows the host by.

The CLI pushed twice per rebuild, because Vite prints "built in" once per
environment. Two swaps racing left the context pool unable to validate any
context, and the push failed with a pool error. It now watches the server
bundle itself, coalesces, and never overlaps two pushes; reloadServerBundle
is synchronized so the engine holds that line whatever the caller does.

The build's own watch callback still packed and deployed the module on
every save, putting back the round trip this loop removes: it now stands
down when the loop drives the build.

Measured after the fixes, on the hydrogen sample: edit to visible 590 /
462 / 435 ms, of which 124-218 ms is the engine swap. The same loop was
4.1-4.9 s through pack and provisioning.
@github-actions

Copy link
Copy Markdown

📝 Documentation Guidelines

Thank you for contributing to our documentation! To ensure your contributions meet our standards, please review these resources:

This comment is posted automatically when changes are detected in the docs/ folder.

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

🦜 Chachalog

javascript-modules minor
  • New: yarn dev:fast, a development loop that swaps a module's code into a running Jahia instead of reinstalling it. The server bundle is pushed into the engine, which replaces the source it evaluates and re-registers what the module declares; the client bundles, the stylesheet and the emitted assets are served from the module's dist directory; open pages reload once the swap lands. Node type definitions, imported content, locales, resource bundles and OSGi configurations still come from the installed bundle and still need a redeploy — the loop says so when one of them changes.

    The endpoint that accepts the code is disabled by default and refuses to answer outside development mode: enable it with enabled = true in org.jahia.modules.javascript.modules.engine.dev.DevServlet.cfg. Pushing code requires the root user. (Dev-mode fast path: sub-second edit-to-visible loop #699)

Create a new entry online or run npx chachalog@0.5.4 prompt to create a new entry locally.

@pkg-pr-new

pkg-pr-new Bot commented Aug 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

yarn add https://pkg.pr.new/@jahia/create-module@747.tgz
yarn add https://pkg.pr.new/@jahia/javascript-modules-library@747.tgz
yarn add https://pkg.pr.new/@jahia/vite-plugin@747.tgz

commit: b8f0250

@romain-pm

Copy link
Copy Markdown
Author

Verified in Page Builder (jContent, Jahia 8.2.3), not just live rendering:

  • a server-view edit appears in the Page Builder frame on its own — two consecutive edits, engine swap 335 ms and 1093 ms (the slow one pays a fresh GraalVM context);
  • the editor survives the frame reload: the Area overlay, New content and the content-type picker all still work;
  • the frame loads its stylesheet from the development server — GET /modules/jsm-dev/hydrogen/dist/assets/style.css → 200.

So the reload script does not fight the editor, which was the open risk. One cost worth noting: each open page polls @jahia/reloads every 500 ms (280 requests during this session). Cheap, but it is a request per half-second per open tab, and a candidate for a longer interval or an SSE stream later.

Attaching changes what every page of that module renders — its files are
addressed on the development server, and the reload script goes in — but
nothing told Jahia to forget what it had already rendered. A page cached
before the session opened kept the old URLs and, having no reload script,
never learned to reload itself: the developer edited, the engine swapped,
and the browser sat there showing the previous build.

Attaching and detaching now drop what Jahia derived from the module, the
same way a swap already did. Found in Page Builder, where the frame served
a render made while detached.
@romain-pm

Copy link
Copy Markdown
Author

Live test in Page Builder found one more real bug, now fixed in b8f0250.

Attaching a session changes what every page renders — the module's files move to the development server and the reload script goes in — but nothing invalidated Jahia's output cache at that moment; only a swap did. A page cached while detached therefore kept the installed-bundle URLs and, carrying no reload script, never learned to reload itself. Symptom: you edit, the engine reports the swap, and the browser sits on the previous build until a hard refresh. Attach and detach now drop what Jahia derived from the module, exactly as a swap does.

Verified afterwards, with Page Builder open the whole time: editing <span>toto</span> into <span>toto — updated live</span> updated the frame on its own (swap 395-429 ms), and reverting the file took it back out. The frame carried /modules/jsm-dev/hydrogen/dist/assets/style.css and the reload script throughout, and the Area overlays stayed attached across every reload.

For the record, the Page Builder frame is /cms/editframe/<workspace>/<lang>/<path>.html?redirect=false — worth knowing for anyone testing this by hand.

@romain-pm

Copy link
Copy Markdown
Author

CI history note: the first run of Integration Tests on b8f0250 failed with 5 errors in getNodesByJCRQuery (HTTP 500) and 1 in absoluteAreaTest. A re-run of the same commit, with no change, passed.

It was a fixture flake, not a regression: the suite passed on 1b68796 (the previous commit of this branch), the only commit since is a pure extraction of dev-only code whose two call sites are never reached in the suite, and the failing specs all depend on jnt:event content under /sites/<site>/contents/events that the log shows never appearing.

All checks green now: 13 pass, 0 fail.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dev-mode fast path: sub-second edit-to-visible loop

1 participant