Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .chachalog/dev-fast-loop.md
Original file line number Diff line number Diff line change
@@ -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)
57 changes: 57 additions & 0 deletions docs/2-guides/4-fast-development-loop/README.md
Original file line number Diff line number Diff line change
@@ -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
# <jahia.data>/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.
1 change: 1 addition & 0 deletions javascript-create-module/templates/module/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions javascript-modules-engine-java/.java-ts-bind/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,17 +38,24 @@
/**
* 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<Registrar> registrars = new ConcurrentLinkedQueue<>();

@Reference(cardinality = ReferenceCardinality.MANDATORY)
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()) {
Expand Down Expand Up @@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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<Registrar> 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.
*
* <p>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<Bundle> getJavascriptModules() {
return Arrays.stream(engine.getBundleContext().getBundles())
.filter(bundle -> bundle.getState() == Bundle.ACTIVE && isJavascriptModule(bundle))
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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("</head>");
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package org.jahia.modules.javascript.modules.engine.dev;

/**
* A parsed {@link DevServlet} request path.
*
* <p>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}):
*
* <ul>
* <li>{@code /<module>/@jahia/<command>} — the CLI driving the session or pushing a build,
* <li>{@code /<module>/<anything else>} — the browser fetching an asset, proxied to Vite.
* </ul>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String, URI> 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<String, AtomicLong> 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.
*
* <p>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 "<script>(function(){var s=null;setInterval(function(){"
+ "fetch(" + quote(url) + ",{cache:'no-store'}).then(function(r){return r.text()})"
+ ".then(function(t){if(s===null){s=t}else if(s!==t){location.reload()}})"
+ ".catch(function(){})},500)})()</script>";
}

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);
}
}
Loading
Loading