From 8ec2b54727ef9475c89a2679db839a9da89c5600 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sat, 22 Aug 2026 09:24:14 +0200 Subject: [PATCH 1/3] feat(engine): swap a module's code instead of reinstalling it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .chachalog/dev-fast-loop.md | 8 + .../4-fast-development-loop/README.md | 57 +++ .../templates/module/package.json | 1 + .../.java-ts-bind/package.json | 1 + .../engine/JavascriptModuleListener.java | 47 +++ .../modules/engine/dev/DevClientFilter.java | 55 +++ .../modules/engine/dev/DevRequest.java | 59 +++ .../modules/engine/dev/DevServerRegistry.java | 101 ++++++ .../modules/engine/dev/DevServlet.java | 339 ++++++++++++++++++ .../modules/engine/js/server/DevHelper.java | 38 ++ .../engine/jsengine/GraalVMEngine.java | 36 ++ .../modules/engine/registrars/Registrar.java | 13 + .../engine/registrars/ViewsRegistrar.java | 2 +- .../modules/engine/dev/DevRequestTest.java | 59 +++ javascript-modules-library/src/globals.d.ts | 6 + .../src/utils/urlBuilder/urlBuilder.ts | 17 +- vite-plugin/bin/jahia-dev.js | 213 +++++++++++ vite-plugin/package.json | 3 +- 18 files changed, 1052 insertions(+), 3 deletions(-) create mode 100644 .chachalog/dev-fast-loop.md create mode 100644 docs/2-guides/4-fast-development-loop/README.md create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevClientFilter.java create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevRequest.java create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServerRegistry.java create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/DevHelper.java create mode 100644 javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/dev/DevRequestTest.java create mode 100644 vite-plugin/bin/jahia-dev.js diff --git a/.chachalog/dev-fast-loop.md b/.chachalog/dev-fast-loop.md new file mode 100644 index 00000000..cfae096d --- /dev/null +++ b/.chachalog/dev-fast-loop.md @@ -0,0 +1,8 @@ +--- +# Allowed version bumps: patch, minor, major +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. (#699) diff --git a/docs/2-guides/4-fast-development-loop/README.md b/docs/2-guides/4-fast-development-loop/README.md new file mode 100644 index 00000000..392e58f7 --- /dev/null +++ b/docs/2-guides/4-fast-development-loop/README.md @@ -0,0 +1,57 @@ +# The fast development loop + +`yarn watch` rebuilds your module and reinstalls it on every save. The rebuild takes tens of +milliseconds; the reinstall takes seconds, because Jahia packs a tarball, turns it into an OSGi +bundle, writes it to the JCR, and restarts the module. + +`yarn dev:fast` keeps the rebuild and replaces the reinstall. Jahia still renders every page, and +the module stays installed exactly as it is; only its code is swapped: + +- the **server bundle** is pushed into the running engine, which replaces the JavaScript source it + evaluates and re-registers the views, filters and initializers the module declares; +- the **client bundles, the stylesheet and the emitted assets** are served straight from your `dist` + directory, so the browser reads the build that just ran instead of the installed one; +- open pages **reload themselves** once the swap lands. + +## Turning it on + +The endpoint that accepts the code is off by default, and it is refused outright unless Jahia runs +in development mode. Enable it on your local instance: + +```properties +# /karaf/etc/org.jahia.modules.javascript.modules.engine.dev.DevServlet.cfg +enabled = true +``` + +Then, from your module, with the module already deployed and started: + +```bash +yarn dev:fast +``` + +It reads the same `.env` as `yarn deploy` (`JAHIA_HOST`, `JAHIA_USER`), and pushing code requires the +root user. Set `JAHIA_DEV_ORIGIN` when Jahia cannot reach your machine at `localhost` or at +`host.docker.internal` — a Jahia on another host, for instance. + +## What it covers, and what it does not + +The swap replaces the server bundle. Everything Jahia reads from the installed bundle keeps needing +`yarn package && yarn deploy`, and the loop tells you when you touch one of those files. + +| You change | What happens | +| ------------------------------------------------------------- | -------------------------------- | +| `*.server.tsx`, `*.action.ts`, anything they import | Pushed, page reloads | +| `*.client.tsx`, CSS, images and fonts emitted into `dist` | Served from `dist`, page reloads | +| `.cnd`, `import.xml`, `locales/`, `settings/`, `package.json` | Redeploy — the loop says so | + +A reload is a reload: component state is not preserved, and there is no hot module replacement in +this version. + +## Why it is safe to leave the endpoint off, and unsafe to leave it on + +The endpoint replaces the code of a running module and serves a directory of your machine over +Jahia. Development mode alone does not enable it, deliberately: Jahia's operating mode defaults to +development, including in the published Docker images, so a switch that development mode implied +would be on nearly everywhere. Pushing code requires the root user, but while a session is open the +files under your module's `dist` are readable by anyone who can reach that Jahia. Run it on your own +instance, not on a shared one. diff --git a/javascript-create-module/templates/module/package.json b/javascript-create-module/templates/module/package.json index 42eb7dd0..c6428798 100644 --- a/javascript-create-module/templates/module/package.json +++ b/javascript-create-module/templates/module/package.json @@ -13,6 +13,7 @@ "clean": "rm -rf dist/", "deploy": "jahia-deploy", "dev": "vite build --watch", + "dev:fast": "jahia-dev", "format": "prettier --write --list-different .", "lint": "eslint .", "package": "yarn pack --out dist/package.tgz", diff --git a/javascript-modules-engine-java/.java-ts-bind/package.json b/javascript-modules-engine-java/.java-ts-bind/package.json index 1fd7be06..081817e2 100644 --- a/javascript-modules-engine-java/.java-ts-bind/package.json +++ b/javascript-modules-engine-java/.java-ts-bind/package.json @@ -22,6 +22,7 @@ "out": "target/java-ts-bind/types", "rootTypes": [ "org.jahia.modules.javascript.modules.engine.js.server.ConfigHelper", + "org.jahia.modules.javascript.modules.engine.js.server.DevHelper", "org.jahia.modules.javascript.modules.engine.js.server.GQLHelper", "org.jahia.modules.javascript.modules.engine.js.server.JcrHelper", "org.jahia.modules.javascript.modules.engine.js.server.OSGiHelper", diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java index 9c79172e..15fb931f 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java @@ -17,6 +17,8 @@ import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; import org.jahia.modules.javascript.modules.engine.registrars.Registrar; +import org.jahia.data.templates.JahiaTemplatesPackage; +import org.jahia.services.templates.JahiaTemplateManagerService; import org.osgi.framework.Bundle; import org.osgi.framework.BundleContext; import org.osgi.framework.BundleEvent; @@ -40,6 +42,7 @@ public class JavascriptModuleListener implements BundleListener { private static final Logger logger = LoggerFactory.getLogger(JavascriptModuleListener.class); private GraalVMEngine engine; + private JahiaTemplateManagerService templateManagerService; private final Queue registrars = new ConcurrentLinkedQueue<>(); @Reference(cardinality = ReferenceCardinality.MANDATORY) @@ -47,6 +50,11 @@ public void setEngine(GraalVMEngine engine) { this.engine = engine; } + @Reference(cardinality = ReferenceCardinality.MANDATORY) + public void setTemplateManagerService(JahiaTemplateManagerService templateManagerService) { + this.templateManagerService = templateManagerService; + } + @Reference(service = Registrar.class, policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.MULTIPLE, policyOption = ReferencePolicyOption.GREEDY) public void addRegistrar(Registrar registrar) { for (Bundle bundle : getJavascriptModules()) { @@ -104,6 +112,45 @@ public void bundleChanged(BundleEvent event) { } } + /** + * Swaps a started module's server bundle for freshly built code and re-registers everything it + * declares, without going through an OSGi restart. + * + *

The registrars are unregistered first: they accumulate their OSGi service registrations, so + * registering twice in a row would publish a module's render filters and actions twice. The + * engine update between the two bumps the context version, so the registrars re-read the new + * registry when they borrow a context, and the reload is complete when this method returns. + * + *

Only what the server bundle carries is reloaded. Node type definitions, imported content, + * resource bundles and static resources come from the deployed bundle and still need a redeploy. + * + * @param bundle a started JavaScript module + * @param code the server bundle to run from now on + */ + public void reloadServerBundle(Bundle bundle, String code) { + List hotReloadable = registrars.stream() + .filter(Registrar::runsOnHotReload) + .collect(Collectors.toList()); + + for (Registrar registrar : hotReloadable) { + registrar.unregister(bundle); + } + engine.updateJavascriptModuleSource(bundle, code); + for (Registrar registrar : hotReloadable) { + registrar.register(bundle); + } + + // A redeploy tells the rest of Jahia to drop what it derived from the module: the HTML + // fragment cache above all, which development mode does not disable, and which would keep + // serving the views this reload just replaced. A swap raises no bundle event, so the engine + // has to say it itself. + JahiaTemplatesPackage templatePackage = + templateManagerService.getTemplatePackageById(bundle.getSymbolicName()); + if (templatePackage != null) { + templateManagerService.fireTemplatePackageRedeployedEvent(templatePackage); + } + } + public List getJavascriptModules() { return Arrays.stream(engine.getBundleContext().getBundles()) .filter(bundle -> bundle.getState() == Bundle.ACTIVE && isJavascriptModule(bundle)) diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevClientFilter.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevClientFilter.java new file mode 100644 index 00000000..bc9caf56 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevClientFilter.java @@ -0,0 +1,55 @@ +package org.jahia.modules.javascript.modules.engine.dev; + +import org.jahia.services.render.RenderContext; +import org.jahia.services.render.Resource; +import org.jahia.services.render.filter.AbstractFilter; +import org.jahia.services.render.filter.RenderChain; +import org.jahia.services.render.filter.RenderFilter; +import org.jahia.settings.SettingsBean; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * Puts the reload script into pages while a module is being developed. + * + *

A rebuilt module is swapped into the engine without the browser knowing, so the page keeps + * showing the output of code that no longer exists. The script watches the module's reload count + * and reloads the page when it moves. It is addressed through the engine's own development path, so + * the browser keeps talking to Jahia and no second origin is involved. + */ +@Component(service = RenderFilter.class, immediate = true) +public class DevClientFilter extends AbstractFilter { + private DevServerRegistry registry; + + @Reference + public void setRegistry(DevServerRegistry registry) { + this.registry = registry; + } + + @Activate + public void activate() { + // after the aggregation and asset filters have had their say, on whole pages only + setPriority(19.5f); + setApplyOnConfigurations("page"); + setApplyOnTemplateTypes("html"); + } + + @Override + public String execute(String previousOut, RenderContext renderContext, Resource resource, RenderChain chain) { + SettingsBean settings = SettingsBean.getInstance(); + if (settings == null || !settings.isDevelopmentMode()) { + return previousOut; + } + String scripts = registry.clientScript(renderContext.getRequest().getContextPath()); + if (scripts.isEmpty()) { + return previousOut; + } + int head = previousOut.indexOf(""); + if (head < 0) { + // no head to inject into: a page fragment, or a template that writes its own document + return previousOut; + } + return previousOut.substring(0, head) + scripts + previousOut.substring(head); + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevRequest.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevRequest.java new file mode 100644 index 00000000..76c83565 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevRequest.java @@ -0,0 +1,59 @@ +package org.jahia.modules.javascript.modules.engine.dev; + +/** + * A parsed {@link DevServlet} request path. + * + *

Two shapes share the servlet's path space, told apart by the {@code @jahia/} segment, which + * cannot collide with a Vite URL (Vite's own reserved segments are {@code @vite/}, {@code @id/}, + * {@code @fs/} and {@code @react-refresh}): + * + *

    + *
  • {@code //@jahia/} — the CLI driving the session or pushing a build, + *
  • {@code //} — the browser fetching an asset, proxied to Vite. + *
+ */ +public final class DevRequest { + private static final String COMMAND_SEGMENT = "@jahia/"; + + private final String module; + private final String command; + + private DevRequest(String module, String command) { + this.module = module; + this.command = command; + } + + /** + * @param pathInfo the servlet path info, i.e. everything after {@code /modules/jsm-dev} + * @return the parsed request, or null when the path names no module + */ + public static DevRequest parse(String pathInfo) { + if (pathInfo == null || pathInfo.length() < 2 || pathInfo.charAt(0) != '/') { + return null; + } + String path = pathInfo.substring(1); + int slash = path.indexOf('/'); + String module = slash < 0 ? path : path.substring(0, slash); + if (module.isEmpty()) { + return null; + } + String rest = slash < 0 ? "" : path.substring(slash + 1); + return new DevRequest(module, rest.startsWith(COMMAND_SEGMENT) + ? rest.substring(COMMAND_SEGMENT.length()) + : null); + } + + /** The module's OSGi symbolic name. */ + public String getModule() { + return module; + } + + /** The CLI command, or null when this request is an asset fetch. */ + public String getCommand() { + return command; + } + + public boolean isCommand() { + return command != null; + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServerRegistry.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServerRegistry.java new file mode 100644 index 00000000..542d6409 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServerRegistry.java @@ -0,0 +1,101 @@ +package org.jahia.modules.javascript.modules.engine.dev; + +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * The development servers currently attached to this Jahia, one per JavaScript module. + * + *

A module is "attached" while its developer runs the {@code jahia dev} server: the CLI opens a + * session holding the origin its Vite server listens on, and the engine then serves that module's + * client assets from it ({@link DevServlet}) instead of from the deployed bundle. Sessions are held + * in memory only: a Jahia restart, or a developer walking away, leaves nothing behind. + */ +@Component(service = DevServerRegistry.class, immediate = true) +public class DevServerRegistry { + private static final Logger logger = LoggerFactory.getLogger(DevServerRegistry.class); + + /** Public path prefix owned by {@link DevServlet}, servlet context path excluded. */ + public static final String DEV_PATH = "/modules" + DevServlet.ALIAS; + + private final Map origins = new ConcurrentHashMap<>(); + + /** + * How many times each module's server bundle was swapped, which is what open pages watch to + * know they are showing code that no longer exists. + */ + private final Map reloads = new ConcurrentHashMap<>(); + + /** + * The path prefix a module's development assets are served under. It is also the {@code base} + * the module's Vite server must be configured with, so that every URL Vite generates already + * carries the prefix and keeps flowing back through the engine. + */ + public static String baseOf(String module) { + return DEV_PATH + "/" + module + "/"; + } + + public void open(String module, URI origin) { + origins.put(module, origin); + reloads.computeIfAbsent(module, key -> new AtomicLong()); + logger.info("Development server attached for module {}: {}", module, origin); + } + + public void close(String module) { + reloads.remove(module); + if (origins.remove(module) != null) { + logger.info("Development server detached for module {}", module); + } + } + + /** Counts one more swap of this module's server bundle. */ + public void reloaded(String module) { + reloads.computeIfAbsent(module, key -> new AtomicLong()).incrementAndGet(); + } + + /** How many times every attached module was reloaded, as one opaque string for open pages. */ + public String reloadStamp() { + StringBuilder stamp = new StringBuilder(); + reloads.forEach((module, count) -> stamp.append(module).append('=').append(count.get()).append(';')); + return stamp.toString(); + } + + /** + * The script that reloads a page when the module it shows has been rebuilt. + * + *

It polls rather than holding a connection open: a websocket would have to survive Jahia's + * filter chain and the OSGi HTTP bridge, which is a lot of machinery to buy back half a second. + * + * @param contextPath the servlet context path, which the browser's URLs have to carry + * @return the script, or an empty string when no module is being developed + */ + public String clientScript(String contextPath) { + if (origins.isEmpty()) { + return ""; + } + String url = contextPath + DEV_PATH + "/" + origins.keySet().iterator().next() + "/@jahia/reloads"; + return ""; + } + + private static String quote(String value) { + return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"; + } + + /** The Vite origin serving this module, or null when no development server is attached. */ + public URI originOf(String module) { + return origins.get(module); + } + + public boolean isAttached(String module) { + return origins.containsKey(module); + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java new file mode 100644 index 00000000..f7fa4277 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java @@ -0,0 +1,339 @@ +package org.jahia.modules.javascript.modules.engine.dev; + +import org.apache.commons.io.IOUtils; +import org.jahia.modules.javascript.modules.engine.JavascriptModuleListener; +import org.jahia.services.content.JCRSessionFactory; +import org.jahia.services.usermanager.JahiaUser; +import org.jahia.settings.SettingsBean; +import org.osgi.framework.Bundle; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.component.annotations.Modified; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.Servlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * The development endpoint of the JavaScript modules engine, serving {@code /modules/jsm-dev/*}. + * + *

It exists to collapse the edit-to-visible loop of a module under development, and it does two + * things for the one module whose {@code jahia dev} server holds a session: + * + *

    + *
  • client assets — every request under {@code /modules/jsm-dev//} is + * proxied verbatim to that module's Vite server, which serves islands and stylesheets + * transformed on demand, with hot module replacement. The path is forwarded unchanged because + * Vite is configured with this very prefix as its {@code base}, so the URLs it generates come + * back through here on their own. + *
  • server views — {@code POST @jahia/server-bundle} replaces the module's + * server bundle inside the running GraalVM engine, skipping the pack, the upload, the JCR + * write and the OSGi restart a redeploy pays for. + *
+ * + *

This endpoint executes JavaScript that the caller supplies, inside the Jahia JVM. + * It is therefore refused unless Jahia runs in development mode, and every request that changes + * something additionally requires the root user. A production instance left in development mode is + * still protected by the second gate. + */ +@Component( + service = {HttpServlet.class, Servlet.class}, + property = {"alias=" + DevServlet.ALIAS, "enabled:Boolean=false"}, + configurationPolicy = ConfigurationPolicy.OPTIONAL, + immediate = true) +public class DevServlet extends HttpServlet { + /** Jahia serves a module servlet alias under {@code /modules}, so this is {@code /modules/jsm-dev}. */ + public static final String ALIAS = "/jsm-dev"; + + private static final Logger logger = LoggerFactory.getLogger(DevServlet.class); + + private static final String COMMAND_SESSION = "session"; + private static final String COMMAND_SERVER_BUNDLE = "server-bundle"; + private static final String COMMAND_RELOADS = "reloads"; + + /** A pushed server bundle is held in memory as a string; refuse anything a build would never emit. */ + private static final int MAX_BUNDLE_BYTES = 32 * 1024 * 1024; + + /** + * Headers a proxy owns rather than forwards. + * + * @see RFC 9110, connection-specific header fields + */ + private static final Set HOP_BY_HOP = Set.of("connection", "keep-alive", "proxy-authenticate", + "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"); + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + + private DevServerRegistry registry; + private JavascriptModuleListener moduleListener; + + /** + * Whether this endpoint answers at all. Development mode is not enough to imply it: Jahia's + * operating mode defaults to development, including in the published images, so a switch that + * development mode turned on would be on almost everywhere. + */ + private boolean enabled; + + @Activate + @Modified + public void activate(Map properties) { + enabled = Boolean.parseBoolean(String.valueOf(properties.getOrDefault("enabled", Boolean.FALSE))); + if (enabled && isDevelopmentMode()) { + logger.warn("The JavaScript modules development endpoint is open at /modules{}. It replaces the code of " + + "a running module and serves a developer's files: never enable it on a shared instance.", ALIAS); + } + } + + @Reference + public void setRegistry(DevServerRegistry registry) { + this.registry = registry; + } + + @Reference + public void setModuleListener(JavascriptModuleListener moduleListener) { + this.moduleListener = moduleListener; + } + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException { + if (!enabled || !isDevelopmentMode()) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + DevRequest devRequest = DevRequest.parse(request.getPathInfo()); + if (devRequest == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + if (!devRequest.isCommand()) { + proxyToDevServer(devRequest.getModule(), request, response); + return; + } + + if (COMMAND_RELOADS.equals(devRequest.getCommand())) { + // read by every open page, so it is the one command that cannot require the root user + response.setContentType("text/plain;charset=UTF-8"); + response.setHeader("Cache-Control", "no-store"); + response.getWriter().write(registry.reloadStamp()); + return; + } + + if (!isRoot()) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "The development endpoint requires the root user"); + return; + } + + switch (devRequest.getCommand()) { + case COMMAND_SESSION: + handleSession(devRequest.getModule(), request, response); + break; + case COMMAND_SERVER_BUNDLE: + handleServerBundle(devRequest.getModule(), request, response); + break; + default: + response.sendError(HttpServletResponse.SC_NOT_FOUND); + } + } + + /** Attaches ({@code POST}) or detaches ({@code DELETE}) a module's development server. */ + private void handleSession(String module, HttpServletRequest request, HttpServletResponse response) + throws IOException { + if ("DELETE".equals(request.getMethod())) { + registry.close(module); + writeJson(response, HttpServletResponse.SC_OK, "{\"attached\":false}"); + return; + } + if (!"POST".equals(request.getMethod())) { + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + return; + } + + Optional bundle = findModule(module); + if (bundle.isEmpty()) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "No JavaScript module named " + module + " is started"); + return; + } + + URI origin; + try { + origin = parseOrigin(request.getParameter("origin")); + } catch (IllegalArgumentException e) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage()); + return; + } + + registry.open(module, origin); + writeJson(response, HttpServletResponse.SC_OK, + "{\"attached\":true,\"base\":\"" + DevServerRegistry.baseOf(module) + "\"}"); + } + + /** Replaces the module's server bundle with the pushed one and re-registers what it declares. */ + private void handleServerBundle(String module, HttpServletRequest request, HttpServletResponse response) + throws IOException { + if (!"POST".equals(request.getMethod())) { + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + return; + } + + Optional bundle = findModule(module); + if (bundle.isEmpty()) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "No JavaScript module named " + module + " is started"); + return; + } + + byte[] code = IOUtils.toByteArray(request.getInputStream()); + if (code.length == 0) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Empty server bundle"); + return; + } + if (code.length > MAX_BUNDLE_BYTES) { + response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); + return; + } + + long start = System.nanoTime(); + try { + moduleListener.reloadServerBundle(bundle.get(), new String(code, StandardCharsets.UTF_8)); + } catch (RuntimeException e) { + logger.error("Cannot reload the server bundle of {}", module, e); + writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "{\"reloaded\":false,\"error\":" + jsonString(String.valueOf(e.getMessage())) + "}"); + return; + } + registry.reloaded(module); + long millis = (System.nanoTime() - start) / 1_000_000; + logger.info("Reloaded the server bundle of {} in {} ms", module, millis); + writeJson(response, HttpServletResponse.SC_OK, "{\"reloaded\":true,\"ms\":" + millis + "}"); + } + + /** + * Forwards an asset request to the module's Vite server, path untouched. + * + *

Only safe methods are forwarded: this proxy exists to serve source files, and a module's + * development server is not a place to POST to through Jahia. + */ + private void proxyToDevServer(String module, HttpServletRequest request, HttpServletResponse response) + throws IOException { + URI origin = registry.originOf(module); + if (origin == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "No development server is attached to " + module); + return; + } + String method = request.getMethod(); + if (!"GET".equals(method) && !"HEAD".equals(method)) { + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + return; + } + + String path = request.getRequestURI().substring(request.getContextPath().length()); + String query = request.getQueryString(); + URI upstream = URI.create(origin + path + (query == null ? "" : "?" + query)); + + HttpRequest.Builder forward = HttpRequest.newBuilder(upstream) + .method(method, HttpRequest.BodyPublishers.noBody()) + .timeout(Duration.ofSeconds(30)); + copyRequestHeaders(request, forward); + + HttpResponse upstreamResponse; + try { + upstreamResponse = httpClient.send(forward.build(), HttpResponse.BodyHandlers.ofInputStream()); + } catch (IOException e) { + logger.warn("Development server of {} is unreachable at {}: {}", module, origin, e.toString()); + response.sendError(HttpServletResponse.SC_BAD_GATEWAY, + "The development server of " + module + " is unreachable"); + return; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + response.sendError(HttpServletResponse.SC_BAD_GATEWAY); + return; + } + + response.setStatus(upstreamResponse.statusCode()); + upstreamResponse.headers().map().forEach((name, values) -> { + if (!HOP_BY_HOP.contains(name.toLowerCase(Locale.ROOT))) { + values.forEach(value -> response.addHeader(name, value)); + } + }); + // Source served from a developer's editor: never let anything hold on to it + response.setHeader("Cache-Control", "no-store"); + + try (java.io.InputStream body = upstreamResponse.body(); OutputStream out = response.getOutputStream()) { + IOUtils.copy(body, out); + } + } + + private void copyRequestHeaders(HttpServletRequest request, HttpRequest.Builder forward) { + for (String name : List.of("accept", "accept-encoding", "accept-language", "if-none-match", + "if-modified-since", "user-agent", "referer", "origin", "sec-fetch-dest", "sec-fetch-mode")) { + String value = request.getHeader(name); + if (value != null) { + forward.header(name, value); + } + } + } + + /** @return the origin, guaranteed to be an absolute http(s) URI with no path */ + private static URI parseOrigin(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing origin parameter"); + } + URI origin = URI.create(value.trim()); + if (!origin.isAbsolute() || origin.getHost() == null + || !List.of("http", "https").contains(origin.getScheme())) { + throw new IllegalArgumentException("Not an http(s) origin: " + value); + } + if (origin.getPath() != null && !origin.getPath().isEmpty() && !"/".equals(origin.getPath())) { + throw new IllegalArgumentException("The origin carries a path: " + value); + } + return URI.create(origin.getScheme() + "://" + origin.getAuthority()); + } + + private Optional findModule(String module) { + return moduleListener.getJavascriptModules().stream() + .filter(bundle -> module.equals(bundle.getSymbolicName())) + .findFirst(); + } + + private static boolean isDevelopmentMode() { + SettingsBean settings = SettingsBean.getInstance(); + return settings != null && settings.isDevelopmentMode(); + } + + private static boolean isRoot() { + JahiaUser user = JCRSessionFactory.getInstance().getCurrentUser(); + return user != null && user.isRoot(); + } + + private static void writeJson(HttpServletResponse response, int status, String body) throws IOException { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(body); + } + + private static String jsonString(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ").replace("\r", " ") + "\""; + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/DevHelper.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/DevHelper.java new file mode 100644 index 00000000..3a33efce --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/DevHelper.java @@ -0,0 +1,38 @@ +package org.jahia.modules.javascript.modules.engine.js.server; + +import org.jahia.modules.javascript.modules.engine.dev.DevServerRegistry; +import org.jahia.modules.javascript.modules.engine.js.injector.OSGiService; + +import javax.inject.Inject; + +/** + * Java helper telling JavaScript code whether a module is being served by a development server. + * + *

A module under {@code jahia dev} has its files served from the developer's machine rather than + * from the deployed bundle, under a path the engine proxies. Views therefore have to address the + * module's own files through that path to see the current state of the sources, which is what + * {@code buildModuleFileUrl} uses this for. + */ +public class DevHelper { + private DevServerRegistry registry; + + @Inject + @OSGiService + public void setRegistry(DevServerRegistry registry) { + this.registry = registry; + } + + /** + * The path a module's files are served under while its development server is attached. + * + * @param module the module's OSGi symbolic name + * @return the path prefix, servlet context path excluded, or null when the module is deployed + * normally — which is every module outside a development session + */ + public String getBase(String module) { + if (registry == null || module == null || !registry.isAttached(module)) { + return null; + } + return DevServerRegistry.baseOf(module); + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/jsengine/GraalVMEngine.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/jsengine/GraalVMEngine.java index 2baabd4f..88a9be8b 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/jsengine/GraalVMEngine.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/jsengine/GraalVMEngine.java @@ -28,6 +28,7 @@ import org.graalvm.polyglot.proxy.ProxyObject; import org.jahia.modules.javascript.modules.engine.js.injector.OSGiServiceInjector; import org.jahia.modules.javascript.modules.engine.js.server.ConfigHelper; +import org.jahia.modules.javascript.modules.engine.js.server.DevHelper; import org.jahia.modules.javascript.modules.engine.js.server.JcrHelper; import org.jahia.modules.javascript.modules.engine.js.server.OSGiHelper; import org.jahia.modules.javascript.modules.engine.js.server.RegistryHelper; @@ -92,6 +93,40 @@ public void enableJavascriptModule(Bundle bundle) { } } + /** + * Replaces a module's server bundle with code pushed by its development server, without + * reinstalling the OSGi bundle. + * + *

The new source takes the place of the one read from the bundle when the module started, at + * the same position in the evaluation order, and the pool version is bumped: pooled contexts are + * recycled on their next borrow, exactly as they are after a redeploy. Callers must re-run the + * registrars, which is what {@code JavascriptModuleListener#reloadServerBundle} is for. + * + *

Development mode only — the endpoint that reaches this owns the gate, because the code is + * evaluated with the privileges of the module it replaces. + * + * @param bundle a started JavaScript module + * @param code the server bundle the module's build just produced + * @throws IllegalStateException when the module is not registered in the engine + */ + public void updateJavascriptModuleSource(Bundle bundle, String code) { + if (!initScripts.containsKey(bundle)) { + throw new IllegalStateException( + "Module " + bundle.getSymbolicName() + " is not registered in the GraalVM engine"); + } + String initScript = bundle.getHeaders().get(BUNDLE_HEADER_JAVASCRIPT_INIT_SCRIPT); + try { + initScripts.put(bundle, Source.newBuilder(JS, code, bundle.getSymbolicName() + "/" + initScript) + .mimeType(JS_MODULE_MIMETYPE) + .build()); + } catch (IOException e) { + // Source.Builder declares it for readers; a string source never reaches the filesystem + throw new IllegalStateException("Cannot build a source for " + bundle.getSymbolicName(), e); + } + version.incrementAndGet(); + logger.info("Updated the server bundle of {} in GraalVM engine", bundle.getSymbolicName()); + } + public void disableJavascriptModule(Bundle bundle) { if (initScripts.remove(bundle) != null) { version.incrementAndGet(); @@ -279,6 +314,7 @@ public ProxyObject getServer(ContextProvider contextProvider) { server.put("gql", new GQLHelper()); server.put("osgi", new OSGiHelper()); server.put("jcr", new JcrHelper()); + server.put("dev", new DevHelper()); for (Map.Entry entry : server.entrySet()) { try { diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/Registrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/Registrar.java index cc651d45..8ec36881 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/Registrar.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/Registrar.java @@ -22,4 +22,17 @@ public interface Registrar { void register(Bundle bundle); void unregister(Bundle bundle); + + /** + * Whether this registrar takes part in a development server's hot reload, which re-runs + * {@link #unregister} then {@link #register} on every save. + * + *

Registrars that publish what the module declares are fine with that rhythm. A registrar + * that also performs a one-off effect on registration is not, and must opt out: at save + * frequency the effect would run over and over, and its record of having run is what a redeploy + * is expected to produce, not an editor keystroke. + */ + default boolean runsOnHotReload() { + return true; + } } diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ViewsRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ViewsRegistrar.java index 281423a6..b5db3933 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ViewsRegistrar.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ViewsRegistrar.java @@ -52,7 +52,7 @@ public class ViewsRegistrar implements ScriptResolver, TemplateResolver, Registr private RenderService renderService; private GraalVMEngine graalVMEngine; - private final Map> viewsPerBundle = new HashMap<>(); + private final Map> viewsPerBundle = new ConcurrentHashMap<>(); private static final Map> siteJSModulesCache = new ConcurrentHashMap<>(15); private static final Map> viewSetCache = new ConcurrentHashMap<>(512); diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/dev/DevRequestTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/dev/DevRequestTest.java new file mode 100644 index 00000000..7cf0f73a --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/dev/DevRequestTest.java @@ -0,0 +1,59 @@ +package org.jahia.modules.javascript.modules.engine.dev; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class DevRequestTest { + + @Test + public void readsTheModuleOfAnAssetRequest() { + DevRequest request = DevRequest.parse("/my-module/src/components/Hello/Hello.client.tsx"); + assertEquals("my-module", request.getModule()); + assertFalse(request.isCommand()); + assertNull(request.getCommand()); + } + + @Test + public void readsACommand() { + DevRequest request = DevRequest.parse("/my-module/@jahia/server-bundle"); + assertEquals("my-module", request.getModule()); + assertTrue(request.isCommand()); + assertEquals("server-bundle", request.getCommand()); + } + + @Test + public void treatsVitesOwnReservedSegmentsAsAssets() { + // the segment that tells the two apart must not collide with anything Vite generates + for (String path : new String[] { "/my-module/@vite/client", "/my-module/@id/react", + "/my-module/@fs/Users/dev/vite/dist/client/env.mjs", "/my-module/@react-refresh" }) { + DevRequest request = DevRequest.parse(path); + assertFalse(path, request.isCommand()); + assertEquals(path, "my-module", request.getModule()); + } + } + + @Test + public void acceptsAModuleWithNoPath() { + DevRequest request = DevRequest.parse("/my-module"); + assertEquals("my-module", request.getModule()); + assertFalse(request.isCommand()); + } + + @Test + public void rejectsPathsNamingNoModule() { + assertNull(DevRequest.parse(null)); + assertNull(DevRequest.parse("")); + assertNull(DevRequest.parse("/")); + assertNull(DevRequest.parse("//src/index.js")); + assertNull(DevRequest.parse("no-leading-slash")); + } + + @Test + public void buildsTheBasePathTheDevServerMustUse() { + assertEquals("/modules/jsm-dev/my-module/", DevServerRegistry.baseOf("my-module")); + } +} diff --git a/javascript-modules-library/src/globals.d.ts b/javascript-modules-library/src/globals.d.ts index 60ceade7..a89d7a6b 100644 --- a/javascript-modules-library/src/globals.d.ts +++ b/javascript-modules-library/src/globals.d.ts @@ -1,5 +1,6 @@ import type { ConfigHelper, + DevHelper, GQLHelper, JcrHelper, OSGiHelper, @@ -16,6 +17,11 @@ declare global { declare const server: { /** This helper provides access to OSGi configuration */ config: ConfigHelper; + /** + * This helper tells whether a module is currently served by a development server, so that views + * address its files there instead of in the deployed bundle + */ + dev: DevHelper; /** This helper allows to perform JCR operations */ jcr: JcrHelper; /** This helper provides access Jahia's GraphQL API, to execute queries and mutations */ diff --git a/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts b/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts index c1777198..d57f7b00 100644 --- a/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts +++ b/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts @@ -113,6 +113,21 @@ export function buildNodeUrl( return url; } +/** + * The path a module's files are served under while a development server is attached to it, or + * undefined for every module that is deployed normally. + * + * A module under `jahia dev` has its files rebuilt on the developer's machine, so addressing the + * copy inside the deployed bundle would show a state that is one redeploy old. + */ +function developmentPathOf(modulePath: string | undefined): string | undefined { + const module = modulePath?.split("/").pop(); + if (!module) return undefined; + const base = server.dev.getBase(module); + // the engine hands back a path with a trailing slash; the caller adds its own separator + return base ? base.replace(/\/$/, "") : undefined; +} + /** * Build a URL for a file in a module. Note that to be accessible, the folder that contains the file * must be part of the jahia.static-resources in package.json. @@ -148,7 +163,7 @@ export function buildModuleFileUrl( ? `/modules/${config.moduleName}` : context.renderContext?.getURLGenerator().getCurrentModule(); return buildEndpointUrl( - `${moduleName}/${filePath}`, + `${developmentPathOf(moduleName) ?? moduleName}/${filePath}`, { parameters: config.parameters }, { renderContext: context.renderContext }, ); diff --git a/vite-plugin/bin/jahia-dev.js b/vite-plugin/bin/jahia-dev.js new file mode 100644 index 00000000..5f8f0c28 --- /dev/null +++ b/vite-plugin/bin/jahia-dev.js @@ -0,0 +1,213 @@ +#!/usr/bin/env node +/** + * `jahia dev` — the fast edit-to-visible loop for a JavaScript module. + * + * Jahia stays the front door and the renderer; this process owns the two things a redeploy is slow + * at. It rebuilds the module on every change (`vite build --watch`, which is what `yarn watch` + * already does), and then, instead of packing a tarball and asking the provisioning API to + * reinstall the module: + * + * - It pushes `dist/server/index.js` into the running engine, which swaps it inside GraalVM; + * - It serves `dist/` over HTTP, so the browser reads the client bundles, the stylesheet and the + * emitted assets from the build that just ran rather than from the installed bundle. + * + * Jahia reaches this process through the engine's development endpoint, so the browser only ever + * talks to Jahia: no second origin, no CORS, and edit mode behaves as it always does. + * + * What the swap cannot reach still needs `yarn package && yarn deploy`: node type definitions, + * imported content, locales, resource bundles, icons, OSGi configurations and package.json all come + * from the installed bundle. This process says so when it sees one of them change. + */ +import { spawn } from "node:child_process"; +import { createReadStream, existsSync, readFileSync, statSync, watch } from "node:fs"; +import { createServer } from "node:http"; +import path from "node:path"; +import { styleText } from "node:util"; +import dotenv from "dotenv"; + +dotenv.config(); + +const host = process.env.JAHIA_HOST || "http://localhost:8080"; +const jahiaUrl = new URL(host.endsWith("/") ? host : `${host}/`); +const authorization = `Basic ${Buffer.from(process.env.JAHIA_USER || "root:root1234").toString("base64")}`; + +const packageJson = JSON.parse(readFileSync("package.json", "utf8")); +const moduleName = packageJson.name; +const distDir = path.resolve("dist"); + +if (!/^[a-z0-9][a-z0-9-]*$/.test(moduleName ?? "")) { + console.error( + `${styleText("red", "[jahia dev]")} "${moduleName}" cannot be a Jahia module name: Jahia derives the bundle's ` + + `symbolic name from it, and the development endpoint addresses the module by that name in a URL. ` + + `Use lowercase letters, digits and hyphens.`, + ); + process.exit(1); +} +const serverBundle = path.join( + distDir, + packageJson.jahia?.server?.replace(/^dist\//, "") ?? "server/index.js", +); + +/** Files the engine reads from the installed bundle, which a push therefore cannot refresh. */ +const NEEDS_REDEPLOY = /\.cnd$|import\.xml$|^settings[/\\]|^locales[/\\]|^package\.json$/; + +const MIME = { + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".map": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".webp": "image/webp", + ".woff2": "font/woff2", +}; + +const log = (message, ...args) => + console.log(`${styleText("cyan", "[jahia dev]")} ${message}`, ...args); +const fail = (message, ...args) => { + console.error(`${styleText("red", "[jahia dev]")} ${message}`, ...args); + process.exit(1); +}; + +/** Calls one of the engine's development commands for this module. */ +async function command(name, { method = "POST", body, search } = {}) { + const url = new URL(`modules/jsm-dev/${moduleName}/@jahia/${name}`, jahiaUrl); + for (const [key, value] of Object.entries(search ?? {})) url.searchParams.set(key, value); + const response = await fetch(url, { + method, + headers: { + Authorization: authorization, + ...(body ? { "Content-Type": "application/javascript" } : {}), + }, + body, + }); + if (!response.ok) { + throw new Error( + `${response.status} ${response.statusText} — ${(await response.text()).slice(0, 400)}`, + ); + } + return response.headers.get("content-type")?.includes("json") ? response.json() : response.text(); +} + +// Serves the module's build output. Only what the engine asks for, and only under dist/: this +// answers requests Jahia forwards, so it must not become a way to read the developer's machine. +const fileServer = createServer((request, response) => { + const requested = decodeURIComponent(new URL(request.url, "http://localhost").pathname); + const prefix = `/modules/jsm-dev/${moduleName}/`; + const relative = requested.startsWith(prefix) ? requested.slice(prefix.length) : ""; + const file = path.resolve(distDir, relative.replace(/^dist\//, "")); + + if (!file.startsWith(distDir + path.sep) || !existsSync(file) || !statSync(file).isFile()) { + response.writeHead(404, { "Content-Type": "text/plain" }).end("Not found"); + return; + } + + response.writeHead(200, { + "Content-Type": MIME[path.extname(file)] ?? "application/octet-stream", + "Content-Length": statSync(file).size, + "Cache-Control": "no-store", + }); + createReadStream(file).pipe(response); +}); + +await new Promise((resolve) => fileServer.listen(Number(process.env.JAHIA_DEV_PORT) || 0, resolve)); +const port = fileServer.address().port; + +/** + * Where Jahia can reach this process. Jahia in a container cannot resolve `localhost` to the host + * it runs on, so the origin is tried and the container's usual name for the host is the fallback. + */ +async function attach() { + const candidates = process.env.JAHIA_DEV_ORIGIN + ? [process.env.JAHIA_DEV_ORIGIN] + : [`http://localhost:${port}`, `http://host.docker.internal:${port}`]; + + let lastError; + for (const origin of candidates) { + try { + const session = await command("session", { search: { origin } }); + return { origin, base: session.base }; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +let session; +try { + session = await attach(); +} catch (error) { + fail( + `Jahia refused the development session: ${error.message}\n` + + ` • is ${jahiaUrl.href} running in development mode, with the engine's dev endpoint enabled?\n` + + ` settings/configurations/org.jahia.modules.javascript.modules.engine.dev.DevServlet.cfg → enabled=true\n` + + ` • is ${moduleName} deployed and started? the dev loop replaces a deployed module, it never installs one\n` + + ` • if Jahia runs somewhere that cannot reach this machine, set JAHIA_DEV_ORIGIN`, + ); +} +log( + "serving %s to Jahia as %s", + styleText("underline", distDir), + styleText("underline", session.origin), +); +log("attached to %s (%s)", styleText("underline", jahiaUrl.href), session.base); + +const detach = async () => { + try { + await command("session", { method: "DELETE" }); + } catch { + // Jahia may already be gone; a shutdown is not worth failing over + } +}; +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => void detach().then(() => process.exit(0))); +} + +/** Pushes the freshly built server bundle into the running engine. Open pages then reload. */ +async function pushServerBundle() { + if (!existsSync(serverBundle)) return; + const started = Date.now(); + try { + const { ms } = await command("server-bundle", { body: readFileSync(serverBundle) }); + log("reloaded in %s ms (engine: %s ms)", Date.now() - started, ms); + } catch (error) { + console.error(`${styleText("red", "[jahia dev]")} server bundle rejected: ${error.message}`); + } +} + +// The build is the module's own: same config, same plugins, same output the installed bundle has. +// Its watcher is the only file watcher here, and every rebuild ends in a push. +const viteBin = path.resolve("node_modules", ".bin", "vite"); +const build = spawn(viteBin, ["build", "--watch"], { stdio: ["ignore", "pipe", "inherit"] }); +build.on("error", (error) => fail(`Cannot start the build: ${error.message}`)); +build.on("exit", (code) => fail(`The build exited with code ${code}`)); + +let building = false; +build.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + const output = chunk.toString(); + if (/build started/i.test(output)) building = true; + if (building && /built in/i.test(output)) { + building = false; + void pushServerBundle(); + } +}); + +// The build only watches what it builds. Everything else in a module reaches Jahia through the +// installed bundle, so a developer editing it would otherwise see nothing happen at all. +watch(".", { recursive: true }, (_event, filename) => { + if (!filename || filename.startsWith("dist") || filename.startsWith("node_modules")) return; + if (!NEEDS_REDEPLOY.test(filename)) return; + log( + styleText( + "yellow", + "%s is read from the installed bundle: run `yarn package && yarn deploy` to apply it", + ), + filename, + ); +}); + +log("watching for changes — %s", styleText("dim", "Ctrl-C to detach")); diff --git a/vite-plugin/package.json b/vite-plugin/package.json index 2e02e8fc..9e8fdf5e 100644 --- a/vite-plugin/package.json +++ b/vite-plugin/package.json @@ -13,7 +13,8 @@ "import": "./dist/index.mjs" }, "bin": { - "jahia-deploy": "bin/jahia-deploy.js" + "jahia-deploy": "bin/jahia-deploy.js", + "jahia-dev": "bin/jahia-dev.js" }, "files": [ "bin", From 1b687964f67435cd587fbe315e80b63eb2248ac5 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sat, 22 Aug 2026 12:45:00 +0200 Subject: [PATCH 2/3] fix(engine): make the development loop work against a real Jahia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../engine/JavascriptModuleListener.java | 8 +- .../modules/engine/dev/DevServlet.java | 27 ++++++- javascript-modules-library/src/globals.d.ts | 14 +++- vite-plugin/bin/jahia-dev.js | 74 +++++++++++++++---- vite-plugin/src/build-successful.ts | 6 +- yarn.lock | 1 + 6 files changed, 109 insertions(+), 21 deletions(-) mode change 100644 => 100755 vite-plugin/bin/jahia-dev.js diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java index 15fb931f..a8923eab 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java @@ -38,7 +38,8 @@ /** * Listener to execute scripts at activate/deactivate time */ -@Component(immediate = true) +// published under its own type as well: the development endpoint reloads a module through it +@Component(immediate = true, service = {JavascriptModuleListener.class, BundleListener.class}) public class JavascriptModuleListener implements BundleListener { private static final Logger logger = LoggerFactory.getLogger(JavascriptModuleListener.class); private GraalVMEngine engine; @@ -124,10 +125,13 @@ public void bundleChanged(BundleEvent event) { *

Only what the server bundle carries is reloaded. Node type definitions, imported content, * resource bundles and static resources come from the deployed bundle and still need a redeploy. * + *

Synchronized: each reload bumps the engine's context version, and two of them racing leaves + * the pool destroying contexts a concurrent borrow is still trying to validate. + * * @param bundle a started JavaScript module * @param code the server bundle to run from now on */ - public void reloadServerBundle(Bundle bundle, String code) { + public synchronized void reloadServerBundle(Bundle bundle, String code) { List hotReloadable = registrars.stream() .filter(Registrar::runsOnHotReload) .collect(Collectors.toList()); diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java index f7fa4277..6be7acfd 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java @@ -185,6 +185,13 @@ private void handleSession(String module, HttpServletRequest request, HttpServle return; } + if (!isReachable(origin)) { + // Jahia often runs in a container, where the developer's `localhost` is not this host's: + // saying so here is what lets the CLI try the name the container knows it by + response.sendError(HttpServletResponse.SC_BAD_GATEWAY, "Jahia cannot reach " + origin); + return; + } + registry.open(module, origin); writeJson(response, HttpServletResponse.SC_OK, "{\"attached\":true,\"base\":\"" + DevServerRegistry.baseOf(module) + "\"}"); @@ -248,7 +255,9 @@ private void proxyToDevServer(String module, HttpServletRequest request, HttpSer return; } - String path = request.getRequestURI().substring(request.getContextPath().length()); + // Rebuilt from the prefix this servlet owns rather than read off the request: Jahia dispatches + // into OSGi through a proxy servlet mapped at /modules, so the request URI arrives without it + String path = DevServerRegistry.DEV_PATH + request.getPathInfo(); String query = request.getQueryString(); URI upstream = URI.create(origin + path + (query == null ? "" : "?" + query)); @@ -311,6 +320,22 @@ private static URI parseOrigin(String value) { return URI.create(origin.getScheme() + "://" + origin.getAuthority()); } + /** Whether the development server answers, so that a session is never opened onto a dead origin. */ + private boolean isReachable(URI origin) { + try { + httpClient.send( + HttpRequest.newBuilder(origin).method("HEAD", HttpRequest.BodyPublishers.noBody()) + .timeout(Duration.ofSeconds(2)).build(), + HttpResponse.BodyHandlers.discarding()); + return true; + } catch (IOException e) { + return false; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + private Optional findModule(String module) { return moduleListener.getJavascriptModules().stream() .filter(bundle -> module.equals(bundle.getSymbolicName())) diff --git a/javascript-modules-library/src/globals.d.ts b/javascript-modules-library/src/globals.d.ts index a89d7a6b..f936eb0e 100644 --- a/javascript-modules-library/src/globals.d.ts +++ b/javascript-modules-library/src/globals.d.ts @@ -1,6 +1,5 @@ import type { ConfigHelper, - DevHelper, GQLHelper, JcrHelper, OSGiHelper, @@ -19,9 +18,18 @@ declare global { config: ConfigHelper; /** * This helper tells whether a module is currently served by a development server, so that views - * address its files there instead of in the deployed bundle + * address its files there instead of in the deployed bundle. + * + * Declared here rather than imported from the generated Java typings: java-ts-bind emits + * `DevHelper` without its methods, for a reason that has not been found yet. */ - dev: DevHelper; + dev: { + /** + * The path the module's files are served under during development, or null — which is every + * module outside a development session. + */ + getBase(module: string): string | null; + }; /** This helper allows to perform JCR operations */ jcr: JcrHelper; /** This helper provides access Jahia's GraphQL API, to execute queries and mutations */ diff --git a/vite-plugin/bin/jahia-dev.js b/vite-plugin/bin/jahia-dev.js old mode 100644 new mode 100755 index 5f8f0c28..75499d01 --- a/vite-plugin/bin/jahia-dev.js +++ b/vite-plugin/bin/jahia-dev.js @@ -99,6 +99,7 @@ const fileServer = createServer((request, response) => { const relative = requested.startsWith(prefix) ? requested.slice(prefix.length) : ""; const file = path.resolve(distDir, relative.replace(/^dist\//, "")); + if (process.env.JAHIA_DEV_TRACE) console.log(`[trace] ${requested} -> ${file}`); if (!file.startsWith(distDir + path.sep) || !existsSync(file) || !statSync(file).isFile()) { response.writeHead(404, { "Content-Type": "text/plain" }).end("Not found"); return; @@ -166,12 +167,23 @@ for (const signal of ["SIGINT", "SIGTERM"]) { process.on(signal, () => void detach().then(() => process.exit(0))); } +/** + * Locates the `vite` binary the way Node resolves a package: this module's own `node_modules` + * first, then every parent's, which is where a workspace keeps it. + */ +function findVite() { + for (let dir = process.cwd(); ; dir = path.dirname(dir)) { + const candidate = path.join(dir, "node_modules", ".bin", "vite"); + if (existsSync(candidate)) return candidate; + if (path.dirname(dir) === dir) return "vite"; + } +} + /** Pushes the freshly built server bundle into the running engine. Open pages then reload. */ -async function pushServerBundle() { - if (!existsSync(serverBundle)) return; +async function pushServerBundle(code) { const started = Date.now(); try { - const { ms } = await command("server-bundle", { body: readFileSync(serverBundle) }); + const { ms } = await command("server-bundle", { body: code }); log("reloaded in %s ms (engine: %s ms)", Date.now() - started, ms); } catch (error) { console.error(`${styleText("red", "[jahia dev]")} server bundle rejected: ${error.message}`); @@ -180,21 +192,55 @@ async function pushServerBundle() { // The build is the module's own: same config, same plugins, same output the installed bundle has. // Its watcher is the only file watcher here, and every rebuild ends in a push. -const viteBin = path.resolve("node_modules", ".bin", "vite"); -const build = spawn(viteBin, ["build", "--watch"], { stdio: ["ignore", "pipe", "inherit"] }); +const viteBin = findVite(); +const build = spawn(viteBin, ["build", "--watch"], { + stdio: ["ignore", "pipe", "inherit"], + // tells the plugin's watch callback to stand down: this process owns what happens after a build + env: { ...process.env, JAHIA_DEV: "1" }, +}); build.on("error", (error) => fail(`Cannot start the build: ${error.message}`)); build.on("exit", (code) => fail(`The build exited with code ${code}`)); -let building = false; -build.stdout.on("data", (chunk) => { - process.stdout.write(chunk); - const output = chunk.toString(); - if (/build started/i.test(output)) building = true; - if (building && /built in/i.test(output)) { - building = false; - void pushServerBundle(); +build.stdout.on("data", (chunk) => process.stdout.write(chunk)); + +// The build writes the server bundle once per rebuild, but prints "built in" once per environment, +// so the file is the signal and its log is not. Pushes never overlap: a swap bumps the engine's +// context version, and two of them racing leaves the pool unable to validate any context. +let pushing = false; +let pending = false; +let lastPushed = ""; + +async function onServerBundleChanged() { + if (pushing) { + pending = true; + return; } -}); + pushing = true; + try { + do { + pending = false; + const built = existsSync(serverBundle) ? readFileSync(serverBundle) : null; + const fingerprint = built ? `${built.length}:${statSync(serverBundle).mtimeMs}` : ""; + if (built && fingerprint !== lastPushed) { + lastPushed = fingerprint; + await pushServerBundle(built); + } + } while (pending); + } finally { + pushing = false; + } +} + +const debounce = (fn, ms) => { + let timer; + return () => { + clearTimeout(timer); + timer = setTimeout(fn, ms); + }; +}; +const serverBundleChanged = debounce(() => void onServerBundleChanged(), 50); +watch(path.dirname(serverBundle), () => serverBundleChanged()); +serverBundleChanged(); // The build only watches what it builds. Everything else in a module reaches Jahia through the // installed bundle, so a developer editing it would otherwise see nothing happen at all. diff --git a/vite-plugin/src/build-successful.ts b/vite-plugin/src/build-successful.ts index 44bba82f..7a4404e4 100644 --- a/vite-plugin/src/build-successful.ts +++ b/vite-plugin/src/build-successful.ts @@ -13,6 +13,10 @@ const building = new Set(); * Plugin to execute a callback when a build succeeds. * * It tracks the completion of builds in watch mode. + * + * The callback is skipped under `jahia dev`, which runs this very build and then pushes its output + * into a running Jahia itself. Leaving the module's callback to reinstall the module on every save + * would put back the round trip that loop exists to remove. */ export function buildSuccessful(callback: () => void | Promise): Plugin { return { @@ -23,7 +27,7 @@ export function buildSuccessful(callback: () => void | Promise): Plugin { async closeBundle(error) { if (error) return; building.delete(this.environment.name); - if (building.size === 0) await callback(); + if (building.size === 0 && !process.env.JAHIA_DEV) await callback(); }, }; } diff --git a/yarn.lock b/yarn.lock index 95e39e32..25a80665 100644 --- a/yarn.lock +++ b/yarn.lock @@ -531,6 +531,7 @@ __metadata: vite: ">=6.0.0" bin: jahia-deploy: bin/jahia-deploy.js + jahia-dev: bin/jahia-dev.js languageName: unknown linkType: soft From b8f0250a442f2d8e2087667f66df433a5ace6c1c Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sat, 22 Aug 2026 13:42:25 +0200 Subject: [PATCH 3/3] fix(engine): drop cached pages when a development server attaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../engine/JavascriptModuleListener.java | 21 +++++++++++++------ .../modules/engine/dev/DevServlet.java | 5 +++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java index a8923eab..ab69b9cb 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/JavascriptModuleListener.java @@ -144,12 +144,21 @@ public synchronized void reloadServerBundle(Bundle bundle, String code) { registrar.register(bundle); } - // A redeploy tells the rest of Jahia to drop what it derived from the module: the HTML - // fragment cache above all, which development mode does not disable, and which would keep - // serving the views this reload just replaced. A swap raises no bundle event, so the engine - // has to say it itself. - JahiaTemplatesPackage templatePackage = - templateManagerService.getTemplatePackageById(bundle.getSymbolicName()); + dropWhatJahiaDerivedFrom(bundle.getSymbolicName()); + } + + /** + * Tells the rest of Jahia to drop what it derived from a module: the HTML fragment cache above + * all, which development mode does not disable. + * + *

A redeploy does this through a bundle event. Nothing a development server does raises one — + * neither swapping the module's code nor attaching to it, though both change what pages render — + * so the engine has to say it itself. + * + * @param module the module's OSGi symbolic name + */ + public void dropWhatJahiaDerivedFrom(String module) { + JahiaTemplatesPackage templatePackage = templateManagerService.getTemplatePackageById(module); if (templatePackage != null) { templateManagerService.fireTemplatePackageRedeployedEvent(templatePackage); } diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java index 6be7acfd..949b6043 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java @@ -163,6 +163,8 @@ private void handleSession(String module, HttpServletRequest request, HttpServle throws IOException { if ("DELETE".equals(request.getMethod())) { registry.close(module); + // the module's pages address the installed bundle again, and lose the reload script + moduleListener.dropWhatJahiaDerivedFrom(module); writeJson(response, HttpServletResponse.SC_OK, "{\"attached\":false}"); return; } @@ -193,6 +195,9 @@ private void handleSession(String module, HttpServletRequest request, HttpServle } registry.open(module, origin); + // from now on the module's pages address the development server and carry the reload script, + // so anything cached from before would stay stale and would never learn to reload itself + moduleListener.dropWhatJahiaDerivedFrom(module); writeJson(response, HttpServletResponse.SC_OK, "{\"attached\":true,\"base\":\"" + DevServerRegistry.baseOf(module) + "\"}"); }