Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 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
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
10 changes: 10 additions & 0 deletions .chachalog/client-callable-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
# Allowed version bumps: patch, minor, major
javascript-modules: minor
---

New: actions — server functions callable from client components as plain async calls. Export a function from a `.action.ts` file, import it in an island, and call it: arguments and results are serialized automatically (devalue), with optional input validation via any Standard Schema compatible library (`action(schema, fn)`).

New: `registerNodeLegacyAction` registers Jahia's node-bound `.do` action endpoints from JavaScript.

The vite plugin's server bundle input now also includes action files (`actions.inputGlob` option, default `**/*.action.{js,ts}`); the same files are replaced by fetch stubs in the client bundle.
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`, `registerNodeLegacyAction` 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.
6 changes: 6 additions & 0 deletions .chachalog/js-server-extension-sdk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
# Allowed version bumps: patch, minor, major
javascript-modules: minor
---

Java modules can now consume JavaScript-declared server extensions through the new `JSServerExtensionInvoker` OSGi service. A module can define its own extension type, let JavaScript modules contribute entries via `server.registry.add`, and invoke their callbacks from Java without depending on GraalVM APIs. This enables, for example, form-field validators written in JavaScript to run during server-side form submission processing.
119 changes: 119 additions & 0 deletions ACTIONS-IMPROVEMENTS-PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Actions framework improvements plan

Improvements to the `feature/js-server-extensions` branch (PR #687) driven by the first external
consumer of the actions framework and the `JSServerExtensionInvoker` SDK: **formidable** (PR
Jahia/formidable#164 + the `fmdb:callServerAction` bridge built on top of it). No code here yet —
this file is the agreed plan. Items 1–3 target the current branch or an immediate follow-up; item
4 is explicitly a separate, dedicated PR.

Context: formidable evaluated replacing its `formidable-form-action` registry type with the
generic actions framework and concluded (correctly) that the two solve different problems — form
actions need host-object context (action node, session, request, files) and HTTP-status failure
semantics that the devalue RPC transport doesn't carry. Formidable keeps its own registry type
consumed through the SDK, and instead **bridges** generic actions as one selectable form action
(`fmdb:callServerAction`). That bridge is what surfaces the gaps below.

## 1. Action metadata (`label`, `description`, `tags`)

**Problem.** Registry entries of type `action` carry only `key` (`<moduleName>/<exportName>`),
`type`, `bundleKey`, and the opaque `execute`. Any consumer that wants to *enumerate* actions —
formidable's "Call Server Action" picker, a future admin UI, docs tooling — has no human label and,
worse, no way to distinguish actions meant for such reuse from internal client RPCs. Formidable
defined the consuming convention already (entries tagged `form-action` are listed/invoked; see
`formidable-elements/src/server/actions/callServerAction.server.ts`), but nothing can produce the
tag yet.

**Design.**

- Metadata is attached to the exported function itself, so it survives the vite plugin's lexical
export discovery without new syntax:
- `action(schema, fn, meta?)` — third optional argument on the safe wrapper.
- `withActionMeta(fn, meta)` — helper for raw (schema-less) exports.
- Both store the object under a well-known symbol/property (e.g. `fn[ACTION_META]`).
- `registerActionsModule` reads the property and spreads it as **flat fields** on the registry
entry: `label: string`, `description?: string`, `tags?: string[]`. Flat because `Registry.find`
filters on top-level fields only, and because consumers read entries as plain maps through the
SDK (`forEach`) where nested structures add noise. `execute` semantics are unchanged.
- Reserved/known tag values are documented, starting with `form-action` (formidable's contract:
the action accepts a single `{formId, locale, parameters}` argument; return value ignored).
Tags are otherwise free-form.
- Guard: metadata keys are whitelisted (`label`, `description`, `tags`) so authors can't shadow
`execute`/`key`/`type`/`bundleKey`.

**Touch points.** `javascript-modules-library/src/framework/actions/action.ts`,
`registerActionsModule.ts`, types, the "Actions" guide, one Cypress assertion enumerating the
test-module action with metadata. No engine-java change.

## 2. SDK `Invoker.call` settles thenables

**Problem.** `JSServerExtensionInvoker.Invoker.call` converts the JS return value to plain Java
immediately, so a JS extension returning a promise is unusable through the SDK. The engine solved
this for its own endpoint with `JSPromise.settle` (microtask drain on host return), but SDK
consumers can't reach it. Formidable had to build a two-phase workaround (`execute` returns
`{pending: true}`; a second `collect` call, made after the first host return drained the microtask
queue, reads the captured outcome — see `JsFormActionDispatcher` + `registerFormAction`).

**Design.**

- In `JSServerExtensionInvokerImpl`, before `convert(...)`: if the result is thenable, settle it
with `JSPromise.settle` (move/share the class — it currently lives in `actions/`); convert the
fulfilled value, or throw a `RuntimeException` carrying the rejection reason. Never-settling
promises (timer/I-O-dependent) fail with the same explicit message as the endpoint.
- Javadoc the contract on `Invoker.call`; add GraalJS-backed unit tests mirroring the existing
`JSPromise` tests, but through the SDK surface.
- Backward compatible: sync results behave exactly as before.

**Follow-up in formidable once released:** delete the `pending`/`collect` protocol on both sides
(TS adapter + dispatcher) — the wrapper just returns the handler's promise.

## 3. Export the registrar SPI

**Problem.** `Registrar` is already a whiteboard (`JavascriptModuleListener` binds
`Registrar` services with dynamic/multiple cardinality), so third-party bundles *could* plug into
JS-module lifecycle — but the `...engine.registrars` package is not exported; only `...engine.sdk`
is. Consumers that want to publish JS registry entries as their own OSGi services (the
`AbstractServiceRegistrar` pattern used by choicelists/render-filters/legacy actions) must instead
re-resolve entries per call, and re-implement matching/fallback logic.

**Design.**

- Promote a consumer-facing SPI into the `sdk` package (keeping the internal registrars where they
are): `JSExtensionRegistrar` (the `register(Bundle)`/`unregister(Bundle)` pair) and an exported
abstract base equivalent to `AbstractServiceRegistrar` (service class + registry type +
`createBridge`), documented with the same invariants (bridges re-resolve entries inside
`doWithContext` per invocation; per-entry failure isolation).
- Internal registrars migrate to the exported base at leisure; no behavior change.

**Payoff for formidable:** `JsFormActionDispatcher`, the optional-reference plumbing in
`FormSubmitServlet`, and the Java-vs-JS precedence special-casing in `FormSubmissionPipeline`
collapse into one registrar that publishes each `formidable-form-action` entry as a regular
`FormAction` OSGi service.

## 4. Java-native `invokeAction` API — separate, dedicated PR (after 1–3)

**Problem.** Generic actions can only be invoked first-class over HTTP. Java code (or SDK
consumers like formidable's bridge, currently doing this from TS instead) must hand-build a
devalue-serialized args string and parse the devalue result — the wire format leaks into every
caller.

**Design sketch (to refine in its own PR).**

- New SDK method, e.g. `ActionInvoker.invoke(String name, Object... args)`:
- resolves the `action` entry, serializes args and deserializes the result **by delegating to a
JS-side adapter** (the library owns devalue, per ADR-0008's "the JS adapter owns all
serialization" — Java must not re-implement devalue),
- settles promises (depends on item 2),
- maps `{message, issues?}` rejections to a typed `ActionInvocationException`.
- Open questions for that PR: which Java types are devalue-encodable (align with island props),
whether host objects should be allowed as args (probably not — keeps the "actions are
context-free" model honest), and whether invocation should honor `tags` (e.g. refuse untagged
internal actions when called by third parties).

## Suggested sequencing

1. Item 1 (metadata) — small, unblocks formidable's picker end-to-end; can land in PR #687 or as
the first follow-up.
2. Item 2 (promise settling) — engine-java only, well-testable; unblocks async simplification for
every SDK consumer.
3. Item 3 (registrar SPI) — API-design-heavy; needs maintainer alignment on the exported surface.
4. Item 4 (invokeAction) — dedicated PR after the above, since it builds on items 1–2.
89 changes: 89 additions & 0 deletions docs/2-guides/4-legacy-node-actions/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.

Not a big fan of releasing something already legacy on day one

Maybe we should start by designing "Jahia as a framework" and offer entrypoints for both Java and JS

Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
page:
$path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/legacy-node-actions
jcr:title: Declaring Legacy Node Actions
j:templateName: documentation
content:
$subpath: document-area/content
---

Legacy node actions are HTTP endpoints bound to content nodes: appending `.<actionName>.do` to a node URL invokes the action against that node. They expose the classic Jahia `org.jahia.bin.Action` mechanism to JavaScript modules, for parity with Java modules — useful for plain HTML form submissions and for interoperating with existing `.do`-based integrations.

> To call server code from client components (islands), prefer [actions](../7-actions/README.md): typed, client-callable functions with automatic serialization.

## Declaring a legacy node action

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

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

registerNodeLegacyAction(
{ 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 (possibly asynchronously — `async` handlers are supported, limited to microtask-based work: the server runtime has no timers or async I/O):

- `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.

Do not combine `redirect` with a `statusCode`: the platform picks the redirect status itself, and a
3xx `statusCode` makes Jahia answer `sendError()` instead of redirecting. For actions answering a
plain browser form POST, always return a `redirect` — a bare `json` result leaves the visitor on a
blank page (the JSON body is only written for requests that ask for JSON).

## CSRF protection for POST actions

POST, PUT and DELETE requests to `.do` URLs — and GET requests made with an authenticated session — 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 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.
66 changes: 66 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,66 @@
---
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 to localize labels for (e.g. `"en"`, `"fr"`). Whether the platform forwards the content language or the editor's UI language varies across Jahia versions. |
| `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).
Loading
Loading