Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
d255110
docs: add ADRs for JavaScript server extension points
romain-pm Jul 21, 2026
7b3ffd9
refactor: introduce AbstractServiceRegistrar shared base for JS exten…
romain-pm Jul 21, 2026
28c9782
feat: JS modules can declare choicelist initializers
romain-pm Jul 21, 2026
8c5eeff
feat: JS modules can declare actions
romain-pm Jul 21, 2026
3c93a15
feat: JS modules can declare server-side node validators
romain-pm Jul 21, 2026
ece86fb
feat: typed registerRenderFilter helper + hydrogen sample for JS exte…
romain-pm Jul 21, 2026
32cec5c
docs: changelog note and CND reference cross-link for JS extension po…
romain-pm Jul 21, 2026
3b6c507
fix: use JahiaUser.getUsername() in the action test fixture
romain-pm Jul 22, 2026
777bc31
fix: findings from live smoke testing on Jahia 8.2
romain-pm Jul 22, 2026
6469897
feat: JS migrations — live-testing fixes, e2e fixtures + spec, docs +…
romain-pm Jul 22, 2026
e1f6ce7
refactor: rename "migrations" feature to "content patches"
romain-pm Jul 22, 2026
b7930af
feat: expose JSServerExtensionInvoker SDK for third-party server exte…
romain-pm Jul 21, 2026
f3508e5
refactor: rename registerAction to registerNodeLegacyAction
romain-pm Jul 22, 2026
075a37e
feat: actions — server functions callable from client components (#588)
romain-pm Jul 22, 2026
5008239
style: apply prettier across the branch's files
romain-pm Jul 22, 2026
517fd5d
refactor: readability pass ahead of code review
romain-pm Jul 22, 2026
9778c83
refactor: narrow the public API surface (architecture review shortlist)
romain-pm Jul 22, 2026
76631bb
docs: record the servlet transport swap as ADR-0008 intended evolutio…
romain-pm Jul 22, 2026
7c5be88
feat: async callbacks for all extension points + engine package hygiene
romain-pm Jul 22, 2026
9685263
refactor: extract content patches to their own PR (#697)
romain-pm Jul 23, 2026
c0cdca7
fix: update tests/yarn.lock for the devalue dependency
romain-pm Jul 24, 2026
3b02d04
fix(engine): keep the debugger working when only the SDK is exported
romain-pm Jul 25, 2026
3fb73a7
test: fix the three specs the branch's new features shipped red
romain-pm Jul 25, 2026
f4ec857
test: give the last two specs the environment they assume
romain-pm Jul 25, 2026
f7115f5
test: pin the choicelist localization, not the platform's locale routing
romain-pm Jul 25, 2026
2eb1a58
fix(engine): settle async results in the JSServerExtensionInvoker SDK
romain-pm Aug 14, 2026
8f5d7e6
docs(engine): document the nested-invocation limit of async callbacks
romain-pm Aug 14, 2026
0da4649
fix(vite-plugin): honor actions.inputGlob in the action transforms
romain-pm Aug 14, 2026
b9ef448
docs: clean stale references left by the content-patches split and re…
romain-pm Aug 14, 2026
0dac3ee
fix(library): stop forwarding unexpected action error messages to cal…
romain-pm Aug 14, 2026
179984a
fix(vite-plugin): harden the generated action stubs
romain-pm Aug 14, 2026
8be4374
fix(engine): reserve 'jsAction', preserve chained choicelist values
romain-pm Aug 14, 2026
39d5200
fix(samples): redirect the contact form back to the page, fix doc nits
romain-pm Aug 14, 2026
f7487fb
Merge branch 'feature/js-server-extensions' into fix/js-e2e-suite
romain-pm Aug 14, 2026
3e7c81c
docs(library): stop promising the content locale to choicelist initia…
romain-pm Aug 14, 2026
af758cb
Merge branch 'feature/js-server-extensions' into fix/js-e2e-suite
romain-pm Aug 14, 2026
7cffccb
docs: carry the action lessons the e2e repairs uncovered into the guide
romain-pm Aug 14, 2026
20c2435
Merge branch 'fix/js-e2e-suite': fold the integration-suite repairs i…
romain-pm Aug 14, 2026
b4020d1
test(e2e): align genericActionTest with the masked error messages
romain-pm Aug 14, 2026
2e5ed13
fix(library): take synchronous callbacks only in validators and filters
romain-pm Aug 21, 2026
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/js-server-extension-points.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Allowed version bumps: patch, minor, major
javascript-modules: minor
---

JavaScript modules can now declare choicelist initializers, server-side node validators and actions — extension points that previously required a Java module. Use the new `registerChoiceListInitializer`, `registerNodeValidator`, `registerAction` and `registerRenderFilter` functions from `@jahia/javascript-modules-library`.

Note for existing modules using `server.registry.add("render-filter", …)`: a declared `priority` is now honored (it was previously ignored and forced to 0), which may reorder such filters in the render chain.
406 changes: 406 additions & 0 deletions MIGRATIONS-DEMO.md

Large diffs are not rendered by default.

302 changes: 302 additions & 0 deletions MIGRATIONS-PLAN.md

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions docs/2-guides/4-actions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
page:
$path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/actions
jcr:title: Declaring Actions
j:templateName: documentation
content:
$subpath: document-area/content
---

Actions are HTTP endpoints bound to content nodes: appending `.<actionName>.do` to a node URL invokes the action against that node. They are the classic Jahia mechanism for form submissions and lightweight server endpoints. With JavaScript modules you can declare actions in JavaScript, without writing a Java module.

## Declaring an action

Call `registerAction` at the top level of a server file (it registers the action as a side effect at module startup, like `jahiaComponent`):

```ts
import { registerAction } from "@jahia/javascript-modules-library";

registerAction(
{ name: "myModuleGreet", requiredMethods: ["GET"], requireAuthenticatedUser: false },
({ parameters, resource }) => ({
json: {
greeting: `Hello ${parameters.who?.[0] ?? "world"}`,
path: resource.getNode().getPath(),
},
}),
);
```

The action is then reachable on any node URL:

```
GET /cms/render/live/en/sites/mysite/home.myModuleGreet.do?who=Jahia
Accept: application/json
→ 200 {"greeting": "Hello Jahia", "path": "/sites/mysite/home"}
```

Note that Jahia's render servlet only writes the JSON body when the request declares it accepts JSON — send an `Accept: application/json` header (browsers submitting forms get the redirect/status behavior instead).

## Declaration options

| Option | Description |
|--------|-------------|
| `name` | The URL-visible action name. Names are platform-wide (shared with Java modules, last registration wins) — prefix them with your module name. |
| `requiredMethods` | Allowed HTTP methods, e.g. `["POST"]`. Defaults to Jahia's default (GET and POST). |
| `requireAuthenticatedUser` | Defaults to **`true`** (Jahia's default): guests get a 401. Set to `false` explicitly for public actions. |
| `requiredPermission` | Permission required on the target node, e.g. `"jcr:write"`. |
| `requiredWorkspace` | Restrict to `"default"` or `"live"`. |

## The handler

The handler receives a context object:

- `parameters` — merged query-string and form parameters, as `Record<string, string[]>`,
- `resource` / `renderContext` / `session` — the target resource, render context and user JCR session,
- `request` — escape hatch: the raw `HttpServletRequest` (headers, cookies, body),
- `urlResolver` — escape hatch: the Jahia URL resolver.

And returns (synchronously — no promises):

- `json` — an object serialized as the JSON response body,
- `statusCode` — HTTP status, default 200,
- `redirect` (+ `absoluteRedirect`) — redirect the client instead of returning a body.

Returning nothing sends an empty 200.

## CSRF protection for POST actions

POST, PUT and DELETE requests to `.do` URLs are blocked by Jahia's CSRF guard unless the URL is whitelisted. **This is your module's responsibility**: ship an OSGi configuration file in your module's `settings/configurations/` folder:

```properties
# settings/configurations/org.jahia.modules.jahiacsrfguard-mymodule.cfg
whitelist = *.myModuleSubmit.do,*.myModuleOther.do
```

Whitelisting disables CSRF protection for those URLs, so only do it for actions designed to be called without a CSRF token (e.g. public form submissions), and keep the patterns as narrow as possible. Without this file, POST calls to your action fail with a 403.

## Good to know

- **Keep handlers fast and non-blocking** — they run synchronously on a request thread.
- **Content modifications**: use the provided `session` to read/write JCR content as the calling user; standard permissions apply, plus `requiredPermission` if you set it.
- **Errors**: an exception thrown by the handler results in an error response; validate input and return explicit `statusCode` values (e.g. 400) for expected failures.
70 changes: 70 additions & 0 deletions docs/2-guides/5-choicelist-initializers/README.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This implementation seems a bit underwhelming, there's not much we can do in there:

  • no jcr queries
  • no http requests

What goals do we have with choicelist initializers?

Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
page:
$path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/choicelist-initializers
jcr:title: Declaring Choicelist Initializers
j:templateName: documentation
content:
$subpath: document-area/content
---

Choicelist initializers populate the dropdown lists offered to editors in Content Editor. Out of the box, Jahia provides initializers such as `resourceBundle` or `nodes`; with JavaScript modules you can declare your own initializers in JavaScript, without writing a Java module.

## Declaring an initializer

Call `registerChoiceListInitializer` at the top level of a server file (it registers the initializer as a side effect at module startup, like `jahiaComponent`):

```ts
import { registerChoiceListInitializer } from "@jahia/javascript-modules-library";

registerChoiceListInitializer({ key: "myModuleColors" }, ({ locale }) => [
{ label: locale.startsWith("fr") ? "Rouge" : "Red", value: "red" },
{ label: locale.startsWith("fr") ? "Vert" : "Green", value: "green" },
]);
```

Then reference the initializer's key from a property definition in your CND file:

```cnd
[mymodule:myComponent] > jnt:content, mix:title
- color (string, choicelist[myModuleColors])
```

The callback returns the list of choices as `{ label, value, properties? }` objects:

- `label` is the text shown to the editor,
- `value` is the string persisted in the JCR,
- `properties` is optional metadata interpreted by the editing UI, e.g. `{ defaultProperty: true }` to preselect a choice, or `{ image: "/path.png" }` to display a thumbnail.

## The initializer context

The callback receives a context object:

| Property | Description |
|----------|-------------|
| `param` | The parameter from the CND declaration: `choicelist[myModuleColors='myParam']` passes `"myParam"`. Empty string when absent. |
| `locale` | BCP-47 language tag of the content language being edited (e.g. `"en"`, `"fr"`) — not the editor's UI language. Use it to localize labels. |
| `values` | Choices accumulated by previous initializers when several are chained in the CND declaration (e.g. `choicelist[resourceBundle,myModuleColors]`). Include them in your result to keep them. |
| `node` | The node being edited, when it exists (it does not on creation forms). |
| `java` | Escape hatch: the raw Java objects received by the underlying `ModuleChoiceListInitializer` — `propertyDefinition` (`ExtendedPropertyDefinition`), `locale` (`java.util.Locale`), `values`, `context`. |

For example, an initializer that lists values differently per property and honors a parameter:

```ts
registerChoiceListInitializer({ key: "myModuleSizes" }, ({ param, values, java }) => {
const sizes = [
...values,
{ label: "Small", value: "s" },
{ label: "Medium", value: "m" },
];
if (param === "extended") {
sizes.push({ label: `Large (${java.propertyDefinition.getName()})`, value: "l" });
}
return sizes;
});
```

## Good to know

- **Keys are platform-wide.** Initializer keys live in a single namespace shared with Java modules; the last registration wins. Prefix your keys with your module name (`myModuleColors`, not `colors`).
- **Keep callbacks fast.** The callback runs synchronously every time an editor form displays the choicelist.
- **Labels are your responsibility.** Unlike `choicelist[resourceBundle]`, labels are not resolved from resource bundles automatically — return localized labels using the `locale` from the context (you can use your module's i18n setup or any custom logic).
114 changes: 114 additions & 0 deletions docs/2-guides/6-migrations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
page:
$path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/migrations
jcr:title: Writing Migrations
j:templateName: documentation
content:
$subpath: document-area/content
---

Migrations are run-once TypeScript scripts shipped with your module, executed when a new version of the module starts. They reconcile existing content with changes in your content type definitions — the JavaScript equivalent of Jahia's Groovy `META-INF/patches` scripts, with a typed API and built-in guard rails.

Typical uses: removing leftover values of a dropped property, backfilling a default on existing content, converting values after a property type change, deleting a retired node type, or renaming a node type and rebinding its content.

## Declaring a migration

Call `registerMigration` at the top level of a server file (it registers the migration as a side effect at module startup, like `registerAction`). Convention: one migration per file under `src/migrations/`:

```ts
// src/migrations/2.0.0-01-remove-legacy-color.server.ts
import { registerMigration } from "@jahia/javascript-modules-library";

registerMigration(
{
name: "2.0.0-01-remove-legacy-color",
description: "color was dropped from mymodule:banner in 2.0.0 — clean leftover values",
},
({ migrate }) => {
migrate.removePropertyValues({ nodeType: "mymodule:banner", property: "color" });
},
);
```

The `name` is the migration's **run-once identity and ordering key**:

- A module's migrations run in lexicographic order of their names. Use the `"<moduleVersion>-<NN>-<slug>"` convention to keep them sorted.
- Execution is recorded under the name in Jahia's module patch status store (`/module-management` → `j:bundlesScripts`, shared with Groovy patches). Whatever the outcome, a recorded migration **never runs again** — never rename or reorder a released migration; ship a new one instead.

Migrations run **synchronously** on the module start thread (like actions, they must not be `async`), on the **processing server only**, and by the time they run the module's new definitions are already registered.

## The `migrate.*` helpers

Every helper iterates both the `default` and `live` workspaces (override with `workspaces`), commits in batches (`batchSize`, default 100), handles internationalized properties on their translation subnodes, logs progress, and no-ops gracefully when the node type was never registered on this instance (fresh installs).

```ts
// U1 — remove a property + clean its values
migrate.removePropertyValues({ nodeType: "mymodule:banner", property: "color" });

// U2 — add a property + backfill existing content
migrate.setPropertyValues({
nodeType: "mymodule:banner",
property: "theme",
onlyIfMissing: true, // default — never clobbers an existing value
value: (node) => (node.getProperty("price").getDouble() > 1000 ? "premium" : "light"),
});

// U3 — change a property's data type + convert values
migrate.convertPropertyValues({
nodeType: "mymodule:banner",
property: "priority",
convert: (value) => Number.parseInt(value.getString(), 10), // undefined = leave untouched
});

// U4 — delete a definition (owned by this module)
migrate.removeNodeType({
nodeType: "mymodule:legacyBanner",
ifContentExists: "delete", // default is "fail" — destroying content is opt-in
});

// U5 — rename a definition + rebind existing items
migrate.changeNodeType({
from: "mymodule:oldBanner",
to: "mymodule:banner", // must exist in the module's current definitions
mapProperties: { legacyTitle: "title" },
});
```

Selection options on the bulk helpers: `scope` (limit to a subtree), `where` (a JCR-SQL2 constraint fragment), `includeSubtypes` (default `true`). Definition operations (`removeNodeType`, `changeNodeType`) only accept node types **owned by your module** — cross-module definition surgery is not supported.

## The imperative escape hatch

For everything else (ACL fixes, node moves, one-off repairs), open a system session or use the batching engine directly:

```ts
registerMigration({ name: "2.0.0-02-fix-root-title" }, ({ jcr, log, skip }) => {
jcr.withSystemSession({ workspace: "default" }, (session) => {
const path = "/sites/mysite/contents/catalog-root";
if (!session.nodeExists(path)) skip("nothing to fix on this instance");
session.getNode(path).getRealNode().setProperty("jcr:title", null);
session.save();
log.info(`Cleaned stray jcr:title on ${path}`);
});

jcr.forEachNode(
{ query: "SELECT * FROM [mymodule:banner]", workspaces: ["default", "live"] },
(node) => node.setProperty("migrated", true),
);
});
```

With `locale: null` (the default), system sessions see translation subnodes as plain nodes — usually what migrations want.

## Outcomes and failure semantics

- Returning normally records `.installed`.
- Calling `context.skip(reason)` records `.skipped`.
- Throwing records `.failed`, and the module's **remaining migrations are held back** — persistently, across restarts and redeploys — until the failed record is cleared and the migration succeeds. The module itself still starts — migration failures never break startup.

All three outcomes are terminal. Batches already committed before a failure stay committed (JCR has no cross-save transactions), so write migrations to be **idempotent** — the built-in helpers are idempotent by construction.

## Development and testing

- `migrations.autoRun` (configuration PID `org.jahia.modules.javascript.modules.engine.migrations`, default `true`): set to `false` on development servers to log pending migrations at module start instead of running them.
- The reliable test is an end-to-end one against a real Jahia: provision the previous module version, create content, deploy the new version, and assert the transformed content — see `tests/cypress/e2e/engine/migrationTest.cy.ts` in the javascript-modules repository for a complete example (statuses, all five operations, skip/failure semantics).
- To re-run a migration on a development instance, remove its entry from the `j:bundlesScripts` property of `/module-management` (e.g. in the JCR browser) and restart the module. Dedicated CLI/GraphQL tooling for status, dry runs, and resets is planned.
2 changes: 2 additions & 0 deletions docs/3-reference/1-cnd-format/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ Speaking of the UI, let's list all possible visual editor hints for string prope

This hint will display a dropdown list in the UI. The list of choices is defined in the constraints, as a list of strings.

You can also populate the dropdown from JavaScript by declaring your own initializer key with `registerChoiceListInitializer` and referencing it as `choicelist[myKey]` — see the [choicelist initializers guide](../../2-guides/5-choicelist-initializers/README.md).

#### `choicelist[componentTypes='<types>']`

The dropdown list will be populated with a list of coma-separated component types or mixins.
Expand Down
61 changes: 61 additions & 0 deletions docs/3-reference/3-node-validators/README.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I absolutely despise the design, I get why it exists but it means content modeling can spread across three file types:

  • CND can contain a validation regex (we can do the example in CND)
  • JSON overrides can also contain validation stuff
  • and now this

This is the wrong solution to the right problem: CND validation sucks

Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
page:
$path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/node-validators
jcr:title: Server-Side Node Validators
j:templateName: documentation
content:
$subpath: document-area/content
---

Node validators run on the server every time a JCR session saves a node of a given type. Returning violations rejects the save and surfaces error messages in Content Editor — attached to a specific field or to the whole node. With JavaScript modules you can declare validators in JavaScript, without writing a Java module.

## Declaring a validator

Call `registerNodeValidator` at the top level of a server file (it registers the validator as a side effect at module startup, like `jahiaComponent`):

```ts
import { registerNodeValidator } from "@jahia/javascript-modules-library";

registerNodeValidator({ nodeType: "mymodule:article" }, (node) => {
const email = node.getPropertyAsString("email");
if (email && !email.includes("@")) {
return { message: "Please provide a valid email address", propertyName: "email" };
}
});
```

The callback receives the `JCRNodeWrapper` being saved and returns:

- **nothing** — the node is valid,
- **one violation** or **an array of violations** — the save is rejected.

A violation is `{ message, propertyName? }`: with `propertyName`, the message is shown on that field in Content Editor; without it, it is shown as a node-level error.

## Declaration options

| Option | Description |
|--------|-------------|
| `nodeType` | Node type (primary or mixin) the validator applies to, matched with `isNodeType()`. |
| `name` | Distinguishes several validators on the same node type in one module. Default `"default"`. |
| `skipOnImport` | Skip this validator during content imports. Default `false`. |
| `advanced` | Run in the advanced phase, which only runs once **all** default-phase validators passed. Default `false`. |

The two phases mirror Jahia's Java validator groups: default-phase violations suppress the advanced phase entirely (advanced checks can assume basic integrity).

## Localizing messages

Messages of the form `{my.bundle.key}` (the whole message being a single `{…}` reference) are resolved by Jahia against the deployed resource bundles, in the editor's UI locale — the same mechanism Java validators use. Ship the keys in your module's `settings/resources/*.properties` bundles:

```ts
return { message: "{mymodule.validation.email.invalid}", propertyName: "email" };
```

Any other message is displayed verbatim. Alternatively, resolve the text yourself in the callback using `context.locale` (the saving session's locale as a BCP-47 tag, possibly null).

## Good to know

- **Never call `session.save()` inside a validator** — it would recurse into validation.
- **Keep validators fast**: they run synchronously on every matching session save (editing, APIs, publication-driven saves).
- **i18n properties**: Jahia silently drops violations attached to internationalized properties when the saving session has no locale; return a node-level violation as a fallback if that matters for your check.
- **Failure policy**: a validator that throws fails the save with a generic node-level message (fail closed) and logs the error with the validator key; a returned violation without a string `message` is logged and ignored.
- **GraphQL/API saves** are validated too — the violation messages appear in the mutation errors.
Loading
Loading