From 4bd3335a373b377af5454a9d9b79b0aaf67967fc Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Fri, 24 Jul 2026 21:29:32 +0200 Subject: [PATCH 1/2] feat(create-module): lint server-runtime-unavailable APIs in 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. Until now that was only discovered at render time, e.g. `{"error":"setTimeout is not defined"}` on the first call of an action using a timer. Add a config-only guard, built on the ESLint core rules `no-restricted-globals` and `no-restricted-imports`, scoped to `**/*.server.{js,jsx,ts,tsx}` and `**/*.action.{js,ts}`: - 40 globals verified `undefined` in a GraalJS 23.0.5 context configured like `GraalVMEngine.ContextPoolFactory#create` (timers, network, Node.js, browser and a few web-platform extras); - `node:*` specifiers and bare Node.js built-in module names. Names the runtime does provide are deliberately not restricted: `console`, `Promise`, `Intl`, `Atomics`, and the `TextEncoder`/`TextDecoder` polyfill the engine installs at startup. Client files are untouched. Every message links the new reference page, which documents the microtask-only async model, what is and isn't available, and the `process.env.NODE_ENV` exception (statically replaced by the Vite plugin). The same block is dogfooded by the repository root config, the hydrogen sample and the test module. The only violation it surfaced is the `process.env.NODE_ENV` vite-plugin fixture, opted out explicitly. Closes #703 --- docs/2-guides/2-island-architecture/README.md | 2 + .../2-naming-conventions/README.md | 2 + .../3-server-runtime-boundary/README.md | 132 ++++++++++++++ eslint.config.js | 168 ++++++++++++++++++ .../templates/module/eslint.config.js | 161 +++++++++++++++++ .../tests/eslint-server-restrictions.test.js | 159 +++++++++++++++++ samples/hydrogen/eslint.config.js | 165 +++++++++++++++++ .../fixtures/expected/server/index.js.map | 2 +- .../fixtures/src/process.env.server.tsx | 1 + 9 files changed, 791 insertions(+), 1 deletion(-) create mode 100644 docs/3-reference/3-server-runtime-boundary/README.md create mode 100644 javascript-create-module/tests/eslint-server-restrictions.test.js diff --git a/docs/2-guides/2-island-architecture/README.md b/docs/2-guides/2-island-architecture/README.md index e0a4d380..f0ede3ba 100644 --- a/docs/2-guides/2-island-architecture/README.md +++ b/docs/2-guides/2-island-architecture/README.md @@ -35,6 +35,8 @@ As with all imports from `@jahia/javascript-modules-library`, the `` 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: diff --git a/docs/3-reference/2-naming-conventions/README.md b/docs/3-reference/2-naming-conventions/README.md index 83320de4..fe00aa16 100644 --- a/docs/3-reference/2-naming-conventions/README.md +++ b/docs/3-reference/2-naming-conventions/README.md @@ -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). diff --git a/docs/3-reference/3-server-runtime-boundary/README.md b/docs/3-reference/3-server-runtime-boundary/README.md new file mode 100644 index 00000000..cf5c72c5 --- /dev/null +++ b/docs/3-reference/3-server-runtime-boundary/README.md @@ -0,0 +1,132 @@ + + +# 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. diff --git a/eslint.config.js b/eslint.config.js index 64610a4b..61c22a3f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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: { @@ -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/**"] }, diff --git a/javascript-create-module/templates/module/eslint.config.js b/javascript-create-module/templates/module/eslint.config.js index 9caa93a8..0ddd7d14 100644 --- a/javascript-create-module/templates/module/eslint.config.js +++ b/javascript-create-module/templates/module/eslint.config.js @@ -7,6 +7,164 @@ import path from "node:path"; import globals from "globals"; import eslintReact from "@eslint-react/eslint-plugin"; +/** 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. + * + * @see SERVER_RUNTIME_BOUNDARY_DOCS + * @type {import("eslint").Linter.Config} + */ +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: { @@ -21,6 +179,9 @@ export default defineConfig( // React eslintReact.configs["recommended-typescript"], + // Server runtime boundary + serverRuntimeBoundary, + // Ignore the same files as .gitignore includeIgnoreFile(path.resolve(import.meta.dirname, ".gitignore")), ); diff --git a/javascript-create-module/tests/eslint-server-restrictions.test.js b/javascript-create-module/tests/eslint-server-restrictions.test.js new file mode 100644 index 00000000..991f1a3d --- /dev/null +++ b/javascript-create-module/tests/eslint-server-restrictions.test.js @@ -0,0 +1,159 @@ +/** + * Checks that the `eslint.config.js` shipped in the scaffolded module flags APIs that do not exist + * in the Jahia server runtime (GraalJS) when they are used in server files, and leaves client files + * alone. + * + * The scaffold is reproduced by copying the template files ESLint needs, and resolving its + * dependencies through the monorepo `node_modules`, so the test doesn't have to install anything. + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, before, test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const testsDir = path.dirname(fileURLToPath(import.meta.url)); +const packageDir = path.dirname(testsDir); +const templateDir = path.join(packageDir, "templates", "module"); +const repoRoot = path.dirname(packageDir); + +const DOCS_URL = + "https://github.com/Jahia/javascript-modules/blob/main/docs/3-reference/3-server-runtime-boundary/README.md"; + +/** @type {string} */ +let projectDir; + +before(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "jsm-eslint-")); + + // The two template files ESLint needs: the config itself, and the ignore file it reads + fs.copyFileSync( + path.join(templateDir, "eslint.config.js"), + path.join(projectDir, "eslint.config.js"), + ); + fs.copyFileSync(path.join(templateDir, "dot", "gitignore"), path.join(projectDir, ".gitignore")); + fs.writeFileSync(path.join(projectDir, "package.json"), JSON.stringify({ type: "module" })); + + // Resolve eslint and its plugins from the monorepo instead of installing them again + fs.symlinkSync(path.join(repoRoot, "node_modules"), path.join(projectDir, "node_modules")); + + fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); +}); + +after(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); +}); + +/** + * Writes a file in the fake project and lints it. + * + * @param {string} file Path relative to the project root. + * @param {string} content File content. + * @returns {{ status: number | null; messages: { ruleId: string | null; message: string }[] }} + */ +function lint(file, content) { + const filePath = path.join(projectDir, file); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + + const eslint = spawnSync( + process.execPath, + [path.join(repoRoot, "node_modules", "eslint", "bin", "eslint.js"), "--format", "json", file], + { cwd: projectDir, encoding: "utf8" }, + ); + + assert.notEqual(eslint.stdout, "", `ESLint produced no output:\n${eslint.stderr}`); + + /** @type {{ messages: { ruleId: string | null; message: string }[] }[]} */ + const results = JSON.parse(eslint.stdout); + return { status: eslint.status, messages: results.flatMap(({ messages }) => messages) }; +} + +test("server files report unavailable globals and Node.js imports", () => { + const { status, messages } = lint( + "src/probe.server.tsx", + `import fs from "node:fs"; +import path from "path"; + +export default function Probe() { + setTimeout(() => {}, 1); + fetch("https://example.com"); + return ( +
+ {fs.constants.F_OK} {path.sep} {process.env.SOME_VAR} +
+ ); +} +`, + ); + + assert.notEqual(status, 0, "ESLint should fail on a server file using unavailable APIs"); + + const rules = messages.map(({ ruleId }) => ruleId); + assert.ok(rules.includes("no-restricted-globals"), `Missing no-restricted-globals in ${rules}`); + assert.ok(rules.includes("no-restricted-imports"), `Missing no-restricted-imports in ${rules}`); + + const globalMessages = messages + .filter(({ ruleId }) => ruleId === "no-restricted-globals") + .map(({ message }) => message); + + for (const name of ["setTimeout", "fetch", "process"]) { + assert.ok( + globalMessages.some( + (message) => + message.includes(`'${name}'`) && + message.includes("Not available in the Jahia server runtime:"), + ), + `${name} should be reported, got ${JSON.stringify(globalMessages)}`, + ); + } + + // Both the `node:` prefixed and the bare specifier must be caught + assert.equal( + messages.filter(({ ruleId }) => ruleId === "no-restricted-imports").length, + 2, + "Both `node:fs` and `path` should be reported", + ); + + // Every message points to the reference page + for (const { message } of messages.filter(({ ruleId }) => + ["no-restricted-globals", "no-restricted-imports"].includes(String(ruleId)), + )) { + assert.ok(message.endsWith(`See ${DOCS_URL}`), `Message does not link the docs: ${message}`); + } +}); + +test("server files using available APIs pass", () => { + const { status, messages } = lint( + "src/clean.server.tsx", + `export default function Clean({ name }: { name: string }) { + console.log(new TextEncoder().encode(name)); + return
{name.toUpperCase()}
; +} +`, + ); + + assert.deepEqual(messages, []); + assert.equal(status, 0); +}); + +test("client files are not restricted", () => { + const { status, messages } = lint( + "src/probe.client.tsx", + `export default function Probe() { + const onClick = () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 1000); + fetch(new URL("/api", window.location.href), { signal: controller.signal }); + }; + + return ; +} +`, + ); + + assert.deepEqual(messages, []); + assert.equal(status, 0); +}); diff --git a/samples/hydrogen/eslint.config.js b/samples/hydrogen/eslint.config.js index 55f99439..e98f6f4d 100644 --- a/samples/hydrogen/eslint.config.js +++ b/samples/hydrogen/eslint.config.js @@ -6,6 +6,168 @@ import path from "node:path"; import globals from "globals"; import eslintReact from "@eslint-react/eslint-plugin"; +/** 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`. + * + * @see SERVER_RUNTIME_BOUNDARY_DOCS + * @type {import("eslint").Linter.Config} + */ +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 tseslint.config( { languageOptions: { @@ -20,6 +182,9 @@ export default tseslint.config( // React eslintReact.configs["recommended-typescript"], + // Server runtime boundary + serverRuntimeBoundary, + // Ignore the same files as .gitignore includeIgnoreFile(path.resolve(import.meta.dirname, ".gitignore")), ); diff --git a/vite-plugin/fixtures/expected/server/index.js.map b/vite-plugin/fixtures/expected/server/index.js.map index db49ff3d..c8120e0f 100644 --- a/vite-plugin/fixtures/expected/server/index.js.map +++ b/vite-plugin/fixtures/expected/server/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","names":[],"sources":["../../src/vite.png","../../src/Layout.tsx","../../src/foo.module.css","../../src/foo.client.tsx","../../src/foo.server.tsx","../../src/process.env.server.tsx"],"sourcesContent":["export default \"__VITE_ASSET__VRAku6fjghkApIISiBWPzg__\"","import { AddResources, buildModuleFileUrl } from \"@jahia/javascript-modules-library\";\nimport \"modern-normalize\";\n\nexport const Layout = ({ children }: { children: React.ReactNode }) => (\n \n {children}\n \n \n);\n","@import \"@fontsource-variable/fira-code\";\n\n.pre {\n font-family: \"Fira Code Variable\";\n}\n","import { useEffect } from \"react\";\nimport classes from \"./foo.module.css\";\n\nexport default function Foo() {\n useEffect(() => {\n console.log(\"Foo component mounted\");\n });\n\n return
Hello World!
;\n}\n","import { buildModuleFileUrl, Island, jahiaComponent } from \"@jahia/javascript-modules-library\";\nimport vite from \"./vite.png\";\nimport { Layout } from \"./Layout.tsx\";\nimport Foo from \"./foo.client.tsx\";\n\njahiaComponent(\n {\n componentType: \"view\",\n nodeType: \"fixtures:foo\",\n },\n () => (\n \n \"Vite\n \n \n ),\n);\n","import { jahiaComponent } from \"@jahia/javascript-modules-library\";\n\njahiaComponent(\n {\n componentType: \"view\",\n nodeType: \"process:env\",\n },\n () =>

Mode: {process.env.NODE_ENV}

,\n);\n"],"mappings":";;;;AAAA,IAAA,eAAe;;;ACGf,IAAa,UAAU,EAAE,eACvB,qBAAC,QAAD,EAAA,UAAA,CACG,UACD,oBAAC,cAAD;CAAc,MAAK;CAAM,WAAW,mBAAmB,uBAAuB;AAAI,CAAA,CAC9E,EAAA,CAAA;;;;AEJR,IAAA,sBAAA,SAAA,GAAA;;;;;;GAAe,SAAS,MAAM;CAK5B,OAAO,oBAAC,OAAD;EAAK,WAAW,mBAAQ;YAAK;CAAiB,CAAA;AACvD,CAAA;;;ACJA,eACE;CACE,eAAe;CACf,UAAU;AACZ,SAEE,qBAAC,QAAD,EAAA,UAAA,CACE,oBAAC,OAAD;CAAK,KAAK,mBAAmB,YAAI;CAAG,KAAI;AAAa,CAAA,GACrD,oBAAC,QAAD,EAAQ,WAAW,mBAAM,CAAA,CACnB,EAAA,CAAA,CAEZ;;;ACdA,eACE;CACE,eAAe;CACf,UAAU;AACZ,SACM,qBAAC,MAAD,EAAA,UAAA,CAAI,UAAA,YAAgC,EAAA,CAAA,CAC5C"} \ No newline at end of file +{"version":3,"file":"index.js","names":[],"sources":["../../src/vite.png","../../src/Layout.tsx","../../src/foo.module.css","../../src/foo.client.tsx","../../src/foo.server.tsx","../../src/process.env.server.tsx"],"sourcesContent":["export default \"__VITE_ASSET__VRAku6fjghkApIISiBWPzg__\"","import { AddResources, buildModuleFileUrl } from \"@jahia/javascript-modules-library\";\nimport \"modern-normalize\";\n\nexport const Layout = ({ children }: { children: React.ReactNode }) => (\n \n {children}\n \n \n);\n","@import \"@fontsource-variable/fira-code\";\n\n.pre {\n font-family: \"Fira Code Variable\";\n}\n","import { useEffect } from \"react\";\nimport classes from \"./foo.module.css\";\n\nexport default function Foo() {\n useEffect(() => {\n console.log(\"Foo component mounted\");\n });\n\n return
Hello World!
;\n}\n","import { buildModuleFileUrl, Island, jahiaComponent } from \"@jahia/javascript-modules-library\";\nimport vite from \"./vite.png\";\nimport { Layout } from \"./Layout.tsx\";\nimport Foo from \"./foo.client.tsx\";\n\njahiaComponent(\n {\n componentType: \"view\",\n nodeType: \"fixtures:foo\",\n },\n () => (\n \n \"Vite\n \n \n ),\n);\n","import { jahiaComponent } from \"@jahia/javascript-modules-library\";\n\njahiaComponent(\n {\n componentType: \"view\",\n nodeType: \"process:env\",\n },\n // eslint-disable-next-line no-restricted-globals -- `process.env.NODE_ENV` is the one `process` usage that works server-side: this plugin replaces it at build time\n () =>

Mode: {process.env.NODE_ENV}

,\n);\n"],"mappings":";;;;AAAA,IAAA,eAAe;;;ACGf,IAAa,UAAU,EAAE,eACvB,qBAAC,QAAD,EAAA,UAAA,CACG,UACD,oBAAC,cAAD;CAAc,MAAK;CAAM,WAAW,mBAAmB,uBAAuB;AAAI,CAAA,CAC9E,EAAA,CAAA;;;;AEJR,IAAA,sBAAA,SAAA,GAAA;;;;;;GAAe,SAAS,MAAM;CAK5B,OAAO,oBAAC,OAAD;EAAK,WAAW,mBAAQ;YAAK;CAAiB,CAAA;AACvD,CAAA;;;ACJA,eACE;CACE,eAAe;CACf,UAAU;AACZ,SAEE,qBAAC,QAAD,EAAA,UAAA,CACE,oBAAC,OAAD;CAAK,KAAK,mBAAmB,YAAI;CAAG,KAAI;AAAa,CAAA,GACrD,oBAAC,QAAD,EAAQ,WAAW,mBAAM,CAAA,CACnB,EAAA,CAAA,CAEZ;;;ACdA,eACE;CACE,eAAe;CACf,UAAU;AACZ,SAEM,qBAAC,MAAD,EAAA,UAAA,CAAI,UAAA,YAAgC,EAAA,CAAA,CAC5C"} \ No newline at end of file diff --git a/vite-plugin/fixtures/src/process.env.server.tsx b/vite-plugin/fixtures/src/process.env.server.tsx index 6c5c21ea..b39a4be1 100644 --- a/vite-plugin/fixtures/src/process.env.server.tsx +++ b/vite-plugin/fixtures/src/process.env.server.tsx @@ -5,5 +5,6 @@ jahiaComponent( componentType: "view", nodeType: "process:env", }, + // eslint-disable-next-line no-restricted-globals -- `process.env.NODE_ENV` is the one `process` usage that works server-side: this plugin replaces it at build time () =>

Mode: {process.env.NODE_ENV}

, ); From 7471d8b09c0348cb3d9bd17755110767b2ece7c4 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Fri, 24 Jul 2026 22:15:32 +0200 Subject: [PATCH 2/2] docs(changelog): record the server-runtime lint rule --- .chachalog/Jq4nBx7W.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .chachalog/Jq4nBx7W.md diff --git a/.chachalog/Jq4nBx7W.md b/.chachalog/Jq4nBx7W.md new file mode 100644 index 00000000..fcd6a1fa --- /dev/null +++ b/.chachalog/Jq4nBx7W.md @@ -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)