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
6 changes: 6 additions & 0 deletions .chachalog/Jq4nBx7W.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
# Allowed version bumps: patch, minor, major
javascript-modules: patch
---

Scaffolded modules now lint the server-runtime boundary: using a timer, `fetch`, a browser global or a Node.js built-in inside a `*.server.*` or `*.action.*` file is reported in the editor and by `yarn lint`, with a link to the new [server runtime boundary](https://github.com/Jahia/javascript-modules/blob/main/docs/3-reference/3-server-runtime-boundary/README.md) reference, instead of failing at runtime on the first call. Client files are unaffected. (#703)
2 changes: 2 additions & 0 deletions docs/2-guides/2-island-architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ As with all imports from `@jahia/javascript-modules-library`, the `<Island />` c

Server files, files in `.server.tsx`, are used as entry points for your server code. They contain views and templates to be registered by Jahia.

Server code runs in GraalJS, embedded in the Jahia JVM. It is neither Node.js nor a browser: there are no timers, no `fetch` and no Node.js built-ins. See [Server runtime boundary](https://github.com/Jahia/javascript-modules/blob/main/docs/3-reference/3-server-runtime-boundary/README.md) for the full list of what is and isn't available.

Client files, files in `.client.tsx`, are used as entry points for your client code. All client files, as well as all their imports, will be made available for the browser to download. They should not contain any sensitive information, and cannot import server APIs (`@jahia/javascript-modules-library` and `.server.tsx` files).

In client files, **only the default export** can be used to create an island. For instance, here is a minimal interactive button:
Expand Down
2 changes: 2 additions & 0 deletions docs/3-reference/2-naming-conventions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ The JavaScript Modules engine does not enforce any naming conventions, but we re
- .client.tsx files
- .server.tsx files
- fullPage view

The suffix is not just a convention for humans: it decides which runtime the file ends up in, and therefore which APIs it may use. See [Server runtime boundary](../3-server-runtime-boundary).
132 changes: 132 additions & 0 deletions docs/3-reference/3-server-runtime-boundary/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<!-- too incomplete to publish on the academy -->

# Server runtime boundary

JavaScript modules run in **two different runtimes**, and they are not the same:

| Where | Runtime | What you get |
| --------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------- |
| `*.client.{jsx,tsx}` (hydrated islands) | The visitor's **browser** | The whole web platform: DOM, `fetch`, timers, `WebSocket`, … |
| `*.server.{jsx,tsx}`, `*.action.{js,ts}`, views, and everything they import | **GraalJS**, embedded in the Jahia JVM | Plain ECMAScript, plus the `server` bridge exposed by the engine |

The server runtime is **not Node.js and not a browser**. It is a bare
[GraalJS](https://www.graalvm.org/latest/reference-manual/js/) context created by the engine, with
no Node.js compatibility layer, no timers, and no I/O.

Getting this wrong is only discovered when the page renders. Calling `setTimeout` in an action, for
instance, answers:

```json
{ "error": "setTimeout is not defined" }
```

To turn that into an editor squiggle, the `eslint.config.js` shipped by
[`@jahia/create-module`](../../../javascript-create-module) restricts the unavailable globals and
Node.js built-in imports in server files. See [Lint rule](#lint-rule) below.

## The async model: microtasks only

There **is** a `Promise`, and `async`/`await` works. What is missing is the _event loop_ around it:
GraalJS drains the microtask queue after your code returns, but nothing ever schedules a macrotask.
In practice:

- `await somePromise` resolves fine, as long as something already resolved it synchronously.
- `await new Promise((resolve) => setTimeout(resolve, 100))` cannot work — there is no `setTimeout`,
and even if you polyfilled one with a Java thread, nothing would pump the callback back into the
JS context.
- Rendering is **synchronous** from Jahia's point of view: the engine calls your view and expects
HTML back. There is no point at which the runtime waits for pending work.

This is why the JavaScript modules library exposes _synchronous_ helpers (`useGQLQuery`,
`getNodeProps`, `getChildNodes`, …) instead of promise-based ones: the data is fetched by the JVM,
in the calling thread, and handed back to JS.

**Consequence for npm packages:** any package whose core job is I/O — HTTP clients, database
drivers, most cloud SDKs, anything built on `node:fs`, `node:http` or `fetch` — cannot run in a
server file, no matter how it is bundled. Pure-computation packages (date formatting, validation,
markdown rendering, …) work fine.

## What is available

Verified against GraalJS `23.0.5`, the version embedded by the engine (see `graalvm.version` in the
root `pom.xml`), with the same context options as
`GraalVMEngine.ContextPoolFactory#create`:

- **All of ECMAScript**: `Object`, `Array`, `Map`, `Set`, `WeakMap`, `WeakRef`,
`FinalizationRegistry`, `Proxy`, `Reflect`, `Symbol`, `BigInt`, `JSON`, `Math`, `Date`, `RegExp`,
`Error` and friends, `Promise`, `globalThis`, `Intl`, `Atomics`, `SharedArrayBuffer`, typed
arrays…
- **`console`** — `console.log` and friends are wired to the server logs. This is the way to debug a
server view.
- **`TextEncoder` / `TextDecoder`** — not native to GraalJS, but polyfilled by the engine at startup
(`javascript-modules-engine/src/server/index.ts` imports `fast-text-encoding`).
- **`server`** — the global bridge the engine injects (`server.render`, `server.gql`, `server.jcr`,
`server.registry`, `server.osgi`, `server.config`). Use
[`@jahia/javascript-modules-library`](../../../javascript-modules-library) rather than touching it
directly.
- **ES modules** — `import` / `export` between your own files, bundled by Vite before they ever
reach the server.
- GraalVM's polyglot globals (`Java`, `Polyglot`, `Graal`, `print`, `load`) are technically present,
but they are **not** part of the supported API surface: do not build on them.

## What is not available

Every name below was verified to be `undefined` in the engine's GraalJS context. They are also the
exact list restricted by the lint rule.

| Group | Names | Why |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Timers & scheduling | `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval`, `setImmediate`, `clearImmediate`, `queueMicrotask` | There is no event loop, only the microtask queue used by promises. |
| Network & async I/O | `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `Request`, `Response`, `Headers`, `FormData`, `Blob`, `File`, `FileReader`, `AbortController`, `AbortSignal` | The server runtime cannot perform asynchronous I/O. |
| Node.js | `process`, `Buffer`, `global`, `require`, `module`, `exports`, `__dirname`, `__filename`, and every `node:*` / bare built-in import | The server runtime is not Node.js. |
| Browser | `window`, `document`, `navigator`, `localStorage`, `alert` | The server runtime is not a browser. Move this code to a `*.client.tsx` island. |
| Web platform extras | `URL`, `URLSearchParams`, `structuredClone`, `crypto`, `performance`, `btoa`, `atob` | Not part of ECMAScript, and GraalJS does not provide them. |

Other web APIs that are equally absent but not restricted (because they are rarely reached for by
mistake) include `Worker`, `MessageChannel`, `BroadcastChannel`, `EventTarget`, `CustomEvent`,
`ReadableStream`, `WritableStream` and `TransformStream`.

### `process.env.NODE_ENV`

`process` does not exist at runtime, but `process.env.NODE_ENV` is a **special case**: the Vite
plugin statically replaces that exact expression at build time (see the `define` option in
`vite-plugin/src/index.ts`), so the string never reaches the server. It is the one `process` usage
that works, and it needs an explicit opt-out:

```tsx
// eslint-disable-next-line no-restricted-globals -- process.env.NODE_ENV is inlined at build time
const isDev = process.env.NODE_ENV !== "production";
```

Nothing else on `process` — `process.env.MY_VAR`, `process.cwd()`, `process.argv` — will work.

## What to do instead

| You want to… | Do this |
| -------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Call an HTTP API | Do it from a `*.client.tsx` island, or from Java/OSGi and expose the result through GraalQL. |
| Query content | Use the synchronous helpers of `@jahia/javascript-modules-library` (`useGQLQuery`, `getChildNodes`, …). |
| Read configuration | Use `server.config` through the library, or an OSGi configuration — not `process.env`. |
| Read or write files | Use the JCR, through the library — not `node:fs`. |
| Debounce, poll, animate | Client side only. Anything time-based belongs in an island. |
| Use an npm package that does I/O | It won't run server side. Move the call to the browser, or to a Java service. |

## Lint rule

The `eslint.config.js` generated by `@jahia/create-module` contains a config block scoped to
`**/*.server.{js,jsx,ts,tsx}` and `**/*.action.{js,ts}`:

- `no-restricted-globals` — one entry per name in the table above, each message naming the reason
and linking back to this page.
- `no-restricted-imports` — `node:*` specifiers and bare Node.js built-in module names (`fs`,
`path`, `crypto`, `fs/promises`, …).

Client files are deliberately untouched: `fetch`, `window` and timers are perfectly valid in a
`*.client.tsx` file.

The rule is **syntactic**: it only sees what a server file writes itself. It does not follow imports,
so a helper in `src/utils/http.ts` calling `fetch` is not reported even though it will fail at
runtime. A flow-aware check (or a build-time scan of the server bundle) is a possible follow-up.

The same block is dogfooded by this repository's own configs (`eslint.config.js` at the root and
`samples/hydrogen/eslint.config.js`); keep the three copies in sync.
168 changes: 168 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,168 @@ import globals from "globals";
import eslintReact from "@eslint-react/eslint-plugin";
import pluginCypress from "eslint-plugin-cypress";

/** Reference page describing what the Jahia server runtime does and does not provide. */
const SERVER_RUNTIME_BOUNDARY_DOCS =
"https://github.com/Jahia/javascript-modules/blob/main/docs/3-reference/3-server-runtime-boundary/README.md";

/**
* Globals that do not exist in the GraalJS runtime executing server files, keyed by the reason they
* are missing.
*
* Every name here was verified to be `undefined` in a GraalJS 23.0.5 context configured like the
* engine's. Names the runtime _does_ provide (`console`, `Promise`, `Intl`, `Atomics`, and the
* `TextEncoder`/`TextDecoder` polyfill installed by the engine) are intentionally absent from this
* list.
*/
const serverRuntimeMissingGlobals = {
"there is no event loop, only the microtask queue used by promises": [
"setTimeout",
"setInterval",
"clearTimeout",
"clearInterval",
"setImmediate",
"clearImmediate",
"queueMicrotask",
],
"the server runtime cannot perform asynchronous I/O": [
"fetch",
"XMLHttpRequest",
"WebSocket",
"EventSource",
"Request",
"Response",
"Headers",
"FormData",
"Blob",
"File",
"FileReader",
"AbortController",
"AbortSignal",
],
"the server runtime is not Node.js": [
"process",
"Buffer",
"global",
"require",
"module",
"exports",
"__dirname",
"__filename",
],
"the server runtime is not a browser, move this code to a *.client.tsx island": [
"window",
"document",
"navigator",
"localStorage",
"alert",
],
"it is not part of ECMAScript and GraalJS does not provide it": [
"URL",
"URLSearchParams",
"structuredClone",
"crypto",
"performance",
"btoa",
"atob",
],
};

/**
* Node.js built-in module names, hardcoded as of Node 22. Server files can import none of them,
* with or without the `node:` prefix.
*/
const nodeBuiltinModules = [
"assert",
"async_hooks",
"buffer",
"child_process",
"cluster",
"console",
"constants",
"crypto",
"dgram",
"diagnostics_channel",
"dns",
"domain",
"events",
"fs",
"http",
"http2",
"https",
"inspector",
"module",
"net",
"os",
"path",
"perf_hooks",
"process",
"punycode",
"querystring",
"readline",
"repl",
"stream",
"string_decoder",
"sys",
"timers",
"tls",
"trace_events",
"tty",
"url",
"util",
"v8",
"vm",
"wasi",
"worker_threads",
"zlib",
];

/**
* Keeps APIs that only exist outside of the server runtime out of server files.
*
* Server files run in GraalJS, embedded in the Jahia JVM: plain ECMAScript, no event loop, no I/O,
* no Node.js and no browser. Without this block, a `setTimeout` in a view or an action is only
* discovered when the page renders, as `{"error":"setTimeout is not defined"}`.
*
* Client files (`*.client.tsx`) run in the browser and are deliberately left alone.
*
* Keep in sync with the copies in `eslint.config.js` (repository root),
* `samples/hydrogen/eslint.config.js` and
* `javascript-create-module/templates/module/eslint.config.js`.
*
* @type {import("eslint").Linter.Config}
* @see SERVER_RUNTIME_BOUNDARY_DOCS
*/
const serverRuntimeBoundary = {
files: ["**/*.server.{js,jsx,ts,tsx}", "**/*.action.{js,ts}"],
rules: {
"no-restricted-globals": [
"error",
...Object.entries(serverRuntimeMissingGlobals).flatMap(([reason, names]) =>
names.map((name) => ({
name,
message: `Not available in the Jahia server runtime: ${reason}. See ${SERVER_RUNTIME_BOUNDARY_DOCS}`,
})),
),
],
"no-restricted-imports": [
"error",
{
patterns: [
{
group: [
"node:*",
"node:*/*",
...nodeBuiltinModules,
...nodeBuiltinModules.map((name) => `${name}/*`),
],
message: `Node.js built-in modules are not available in the Jahia server runtime. See ${SERVER_RUNTIME_BOUNDARY_DOCS}`,
},
],
},
],
},
};

export default defineConfig(
{
languageOptions: {
Expand Down Expand Up @@ -38,6 +200,12 @@ export default defineConfig(
},
},

// Server runtime boundary
serverRuntimeBoundary,
// The test module groups its server code in directories instead of using the `.server.tsx`
// suffix, so it needs the same rules under a different glob.
{ ...serverRuntimeBoundary, files: ["jahia-test-module/src/react/server/**/*.{js,jsx,ts,tsx}"] },

// Ignore the same files as .gitignore
includeIgnoreFile(path.resolve(import.meta.dirname, ".gitignore")),
{ ignores: ["**/fixtures/expected/**"] },
Expand Down
Loading
Loading