From fd7a229c83463872ce14c8dc1888fac4a8cef766 Mon Sep 17 00:00:00 2001 From: romain-pm Date: Tue, 21 Jul 2026 21:21:15 +0200 Subject: [PATCH] feat: expose JSServerExtensionInvoker SDK for third-party server extensions Adds a public, polyglot-free SDK so other OSGi bundles can consume JS-declared server extensions (server.registry.add(type,...)) and invoke their callbacks: - JSServerExtensionInvoker (interface) + Impl (@Component) in a new exported package org.jahia.modules.javascript.modules.engine.sdk - forEach(type, handler) runs within one pooled GraalVM context; Invoker.call executes a JS callable and converts the result to plain Java (no GraalVM types leak to consumers, so callers need no polyglot dependency) - engine bundle now Export-Package's only the sdk package Enables Formidable to run JS-authored form-field validators server-side (issue Jahia/formidable#158) without embedding GraalVM or engine internals. --- .chachalog/js-server-extension-sdk.md | 6 ++ .../engine/sdk/JSServerExtensionInvoker.java | 73 +++++++++++++++ .../sdk/JSServerExtensionInvokerImpl.java | 92 +++++++++++++++++++ javascript-modules-engine/pom.xml | 4 + 4 files changed, 175 insertions(+) create mode 100644 .chachalog/js-server-extension-sdk.md create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvoker.java create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImpl.java diff --git a/.chachalog/js-server-extension-sdk.md b/.chachalog/js-server-extension-sdk.md new file mode 100644 index 00000000..63d7e8c9 --- /dev/null +++ b/.chachalog/js-server-extension-sdk.md @@ -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. diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvoker.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvoker.java new file mode 100644 index 00000000..c68cc022 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvoker.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.sdk; + +import java.util.List; +import java.util.Map; + +/** + * Public SDK entry point letting other OSGi bundles consume JavaScript-declared server + * extensions registered via {@code server.registry.add(type, key, entry)} in JS modules — without any + * dependency on GraalVM/polyglot types or on the engine internals. + * + *

This is the supported extension surface for modules (such as Formidable) that define their own + * server-side extension type and need to run the JS callbacks contributed against it. It complements the + * built-in registrars ({@code node-validator}, {@code action}, …), which wire JS entries to Jahia's own + * extension points; here the consumer owns the extension point. + * + *

All work happens inside a single pooled GraalVM context for the duration of one {@link #forEach} + * call. GraalVM values (the JS callables stored in entries) are only valid during that call, so callables + * must be invoked through the {@link Invoker} passed to the handler, never captured for later use. + */ +public interface JSServerExtensionInvoker { + + /** + * Iterates all registry entries of {@code registryType} (across every deployed JS module) within one + * JS context, invoking {@code handler} for each. Results that are non-null are collected and returned + * in registry order. + * + *

The handler receives the entry as a plain {@code Map} (scalar fields such as + * {@code nodeType} are plain Java; function fields are opaque handles to pass to the {@link Invoker}). + * A handler exception propagates to the caller — callers that need fail-closed semantics should catch + * their own errors inside the handler and translate them into a result. + * + * @param registryType the JS registry type to look up (e.g. {@code "formidable-field-validator"}) + * @param handler invoked once per matching entry; return {@code null} to skip an entry + * @param the result type accumulated across entries + * @return the non-null handler results, in registry order (never {@code null}) + */ + List forEach(String registryType, ExtensionHandler handler); + + /** Handles a single registry entry, optionally invoking its JS callables through {@code invoker}. */ + @FunctionalInterface + interface ExtensionHandler { + T handle(Map entry, Invoker invoker); + } + + /** Invokes a JS callable stored in a registry entry and converts its result to plain Java. */ + @FunctionalInterface + interface Invoker { + /** + * Executes {@code callable} (a function field read from an entry) with {@code args} and returns + * the result converted to plain Java: {@code null}, {@link Boolean}, {@link Long}/{@link Double}, + * {@link String}, {@link List}, or {@link Map}. Host objects passed as arguments (e.g. a + * {@code JCRNodeWrapper}) are forwarded to JS as-is. + * + * @throws RuntimeException if the callable is not executable or throws + */ + Object call(Object callable, Object... args); + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImpl.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImpl.java new file mode 100644 index 00000000..089dc572 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImpl.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.sdk; + +import org.graalvm.polyglot.Value; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Default {@link JSServerExtensionInvoker}. Runs each {@link #forEach} within one pooled GraalVM context + * (re-resolving the registry inside the context, as GraalVM contexts are recycled on module (un)deploy), + * and converts GraalVM {@link Value} results to plain Java so callers never see polyglot types. + */ +@Component(service = JSServerExtensionInvoker.class, immediate = true) +public class JSServerExtensionInvokerImpl implements JSServerExtensionInvoker { + + private GraalVMEngine graalVMEngine; + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + public void setGraalVMEngine(GraalVMEngine graalVMEngine) { + this.graalVMEngine = graalVMEngine; + } + + @Override + public List forEach(String registryType, ExtensionHandler handler) { + return graalVMEngine.doWithContext(contextProvider -> { + List results = new ArrayList<>(); + Invoker invoker = (callable, args) -> convert(Value.asValue(callable).execute(args)); + Map filter = new HashMap<>(); + filter.put("type", registryType); + for (Map entry : contextProvider.getRegistry().find(filter)) { + T result = handler.handle(entry, invoker); + if (result != null) { + results.add(result); + } + } + return results; + }); + } + + /** Recursively converts a GraalVM value to plain Java ({@code null}/Boolean/Long/Double/String/List/Map). */ + static Object convert(Value value) { + if (value == null || value.isNull()) { + return null; + } + if (value.isBoolean()) { + return value.asBoolean(); + } + if (value.isNumber()) { + return value.fitsInLong() ? (Object) value.asLong() : (Object) value.asDouble(); + } + if (value.isString()) { + return value.asString(); + } + if (value.hasArrayElements()) { + List list = new ArrayList<>((int) value.getArraySize()); + for (long i = 0; i < value.getArraySize(); i++) { + list.add(convert(value.getArrayElement(i))); + } + return list; + } + if (value.hasMembers()) { + Map map = new LinkedHashMap<>(); + for (String key : value.getMemberKeys()) { + map.put(key, convert(value.getMember(key))); + } + return map; + } + return value.toString(); + } +} diff --git a/javascript-modules-engine/pom.xml b/javascript-modules-engine/pom.xml index 8fbf1178..39df7ff3 100644 --- a/javascript-modules-engine/pom.xml +++ b/javascript-modules-engine/pom.xml @@ -83,6 +83,10 @@ <_dsannotations>* bndlib,chromeinspector,commons-pool2,pax-swissbox-bnd,graal-sdk,truffle-api,js,icu4j,regex + + org.jahia.modules.javascript.modules.engine.sdk;version="${project.version}"