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..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 @@ -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; @@ -36,10 +38,12 @@ /** * 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; + private JahiaTemplateManagerService templateManagerService; private final Queue registrars = new ConcurrentLinkedQueue<>(); @Reference(cardinality = ReferenceCardinality.MANDATORY) @@ -47,6 +51,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 +113,57 @@ 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. + * + *

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 synchronized 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); + } + + 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); + } + } + 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}): + * + *

+ */ +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..949b6043 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/dev/DevServlet.java @@ -0,0 +1,369 @@ +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: + * + *

+ * + *

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); + // 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; + } + 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; + } + + 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); + // 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) + "\"}"); + } + + /** 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; + } + + // 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)); + + 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()); + } + + /** 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())) + .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..f936eb0e 100644 --- a/javascript-modules-library/src/globals.d.ts +++ b/javascript-modules-library/src/globals.d.ts @@ -16,6 +16,20 @@ 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. + * + * 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: { + /** + * 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/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 100755 index 00000000..75499d01 --- /dev/null +++ b/vite-plugin/bin/jahia-dev.js @@ -0,0 +1,259 @@ +#!/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 (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; + } + + 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))); +} + +/** + * 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(code) { + const started = Date.now(); + try { + 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}`); + } +} + +// 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 = 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}`)); + +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. +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", 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