Files
bbassieandClaude Opus 5.5 5c92a44e65 Limit the development server to sources and dependencies
Vite serves any file under fs.allow through /@fs/, and the default
allow list is the workspace root. In a clusterio checkout that is also
the usual data directory, so with --dev any client that could reach the
controller could fetch config-controller.json, config-ctl.json and the
database without logging in. webpack-dev-middleware only served the
compiled bundles.

Allow the controller, web_ui, lib and dev plugin directories and the
node_modules directories Node.js would resolve their imports from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:01:12 +00:00

388 lines
11 KiB
JavaScript

// Vite configuration shared by the controller web interface and plugins.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import util from "node:util";
import { createRequire } from "node:module";
import { execFile } from "node:child_process";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { normalizePath } from "vite";
/**
* Modules the web interface provides to plugins at runtime. Plugins must not
* bundle their own copy of these, they are imported through the import map.
*/
export const sharedModules = [
"@clusterio/lib",
"@clusterio/web_ui",
"@sinclair/typebox",
"antd",
"react",
"react/jsx-runtime",
"react-dom",
"react-dom/client",
"react-router",
"react-router-dom",
];
/** Our own packages, these are ESM and differ between Node.js and the browser. */
const esmSharedModules = new Set(["@clusterio/lib", "@clusterio/web_ui"]);
// Only the console transport of winston works in the browser, and the colour
// library used by it does not.
const stubbedModules = /node_modules\/(winston\/dist\/winston\/transports\/(http|file)\.js|@colors\/colors\/)/;
// Browser versions of Node.js modules, resolved from web_ui so lib and plugins
// don't need them as dependencies.
const polyfills = {
assert: "assert/",
buffer: "buffer/",
events: "events/",
os: "os-browserify/browser",
path: "path-browserify",
process: "process/browser.js",
stream: "stream-browserify",
util: "util/",
zlib: "browserify-zlib",
};
const webUiPackage = fileURLToPath(new URL("./package.json", import.meta.url));
const libPackage = createRequire(webUiPackage).resolve("@clusterio/lib/package.json");
const sharedPrefix = "\0clusterio:shared/";
const pluginPrefix = "\0clusterio:plugin/";
/** URL of a virtual module on the Vite development server relative to its base. */
export function devUrl(id) {
return `@id/${id.replace("\0", "__x00__")}`;
}
export function devSharedUrl(name) {
return devUrl(sharedPrefix + name);
}
export function devPluginUrl(name) {
return devUrl(pluginPrefix + name);
}
async function getCommitHash() {
try {
const { stdout } = await util.promisify(execFile)("git", ["rev-parse", "--short", "HEAD"]);
return stdout.trim();
} catch {
return "unknown";
}
}
async function findFile(dir, candidates) {
for (const file of candidates) {
try {
await fs.access(path.join(dir, file));
return file;
} catch {
// Try the next one
}
}
return undefined;
}
// node_modules directories Node.js would search for packages imported from dir.
function nodeModulesDirs(dir) {
const dirs = [];
for (let current = dir; ; current = path.dirname(current)) {
if (path.basename(current) !== "node_modules") {
dirs.push(path.join(current, "node_modules"));
}
if (current === path.dirname(current)) {
return dirs;
}
}
}
function chunkName(name) {
return name.replace(/^@/, "").replace(/[/]/g, "_");
}
// Lists the named exports of a CommonJS package so they can be re-exported
// statically, export * from a CommonJS module only forwards default.
function exportNames(name, root) {
if (esmSharedModules.has(name)) {
return null;
}
const mod = createRequire(path.join(root, "package.json"))(name);
const names = Object.keys(mod).filter(key => key !== "__esModule" && /^[A-Za-z_$][\w$]*$/.test(key));
const isModule = Object.prototype.toString.call(mod) === "[object Module]";
if (!isModule && !mod.__esModule && !names.includes("default")) {
names.push("default");
}
return names;
}
function sharedModuleCode(name, root) {
const names = exportNames(name, root);
if (!names) {
return `export * from ${JSON.stringify(name)};\n`;
}
return `export { ${names.join(", ")} } from ${JSON.stringify(name)};\n`;
}
async function pluginEntryCode(pluginDir) {
const resolve = file => JSON.stringify(normalizePath(path.join(pluginDir, file)));
const exposes = {
"./": await findFile(pluginDir, ["index.ts", "index.js"]),
"./package.json": "package.json",
"./web": await findFile(pluginDir, ["web/index.tsx", "web/index.ts", "web/index.jsx", "web/index.js"]),
};
if (!exposes["./"]) {
throw new Error(`No index.ts or index.js found in ${pluginDir}`);
}
const entries = Object.entries(exposes)
.filter(([, file]) => file)
.map(([key, file]) => `\t${JSON.stringify(key)}: () => import(${resolve(file)}),`);
return [
"const modules = {",
...entries,
"};",
"export async function get(key) {",
"\tif (!Object.hasOwn(modules, key)) {",
"\t\tthrow new Error(\"Module \" + key + \" is not exposed by this plugin\");",
"\t}",
"\treturn await modules[key]();",
"}",
"",
].join("\n");
}
// Replaces modules that do not work in the browser with an empty module.
// Also passed to the dependency optimizer as it does not run Vite plugins.
const stubs = {
name: "clusterio:stubs",
load(id) {
if (stubbedModules.test(normalizePath(id))) {
return "module.exports = {};\n";
}
return null;
},
};
const polyfillResolver = {
name: "clusterio:polyfills",
resolveId(id, _importer, options) {
const target = polyfills[id.replace(/^node:/, "")];
if (!target) {
return null;
}
return this.resolve(target, webUiPackage, { ...options, skipSelf: true });
},
};
/** Base config needed to build code that imports @clusterio/lib for the browser. */
function browserCompat() {
return {
name: "clusterio:browser-compat",
enforce: "pre",
async config(_config, { command }) {
const buildEnv = process.env.NODE_ENV ?? (command === "build" ? "production" : "dev");
return {
base: command === "build" ? "./" : undefined,
define: {
"global": "globalThis",
"process.env.APP_ENV": JSON.stringify("browser"),
"process.env.COMMIT_HASH": JSON.stringify(await getCommitHash()),
"process.env.BUILD_DATE": JSON.stringify(new Date().toISOString()),
"process.env.BUILD_ENV": JSON.stringify(buildEnv),
"process.env.BUILD_OS": JSON.stringify(os.platform()),
},
optimizeDeps: {
rolldownOptions: { plugins: [polyfillResolver, stubs] },
},
resolve: {
alias: [{ find: /^node:(.*)$/, replacement: "$1" }],
},
build: {
target: "es2022",
sourcemap: true,
assetsDir: "static",
chunkSizeWarningLimit: 2048,
rolldownOptions: {
// Message and permission registration relies on class names.
output: {
minify: {
compress: { keepNames: { class: true, function: false } },
mangle: { keepNames: { class: true, function: false } },
},
},
},
},
};
},
resolveId: polyfillResolver.resolveId,
load: stubs.load,
transform(code, id) {
if (id.endsWith(".md")) {
return { code: `export default ${JSON.stringify(code)};\n`, map: null };
}
return null;
},
};
}
function writeManifest(manifest) {
return {
type: "asset",
fileName: "manifest.json",
source: `${JSON.stringify(manifest, null, "\t")}\n`,
};
}
/**
* Vite plugins for building the web interface served by the controller.
*
* Builds `entry` together with one entry per shared module and writes
* manifest.json listing the main bundle and the import map for plugins.
*/
export function clusterioApp({ entry }) {
let root;
return [
browserCompat(),
react(),
{
name: "clusterio:app",
api: { entry },
config(config) {
root = normalizePath(path.resolve(config.root ?? process.cwd()));
return {
optimizeDeps: {
entries: [entry],
include: sharedModules.filter(name => !esmSharedModules.has(name)),
},
build: {
outDir: "dist/web",
emptyOutDir: true,
rolldownOptions: {
input: {
main: path.posix.join(root, entry),
...Object.fromEntries(sharedModules.map(name => [
`shared-${chunkName(name)}`, sharedPrefix + name,
])),
},
preserveEntrySignatures: "strict",
},
},
};
},
resolveId(id) {
if (id.startsWith(sharedPrefix)) {
return id;
}
return null;
},
load(id) {
if (id.startsWith(sharedPrefix)) {
return sharedModuleCode(id.slice(sharedPrefix.length), root);
}
return null;
},
generateBundle(_options, bundle) {
const entries = new Map();
for (const chunk of Object.values(bundle)) {
if (chunk.type === "chunk" && chunk.isEntry) {
entries.set(chunk.facadeModuleId, chunk.fileName);
}
}
this.emitFile(writeManifest({
main: entries.get(path.posix.join(root, entry)),
imports: Object.fromEntries(sharedModules.map(
name => [name, entries.get(sharedPrefix + name)]
)),
}));
},
},
];
}
/**
* Vite plugin serving plugins from source on the controller's development
* server, given as a map of plugin name to plugin directory.
*/
export function clusterioDevPlugins(pluginDirs) {
return {
name: "clusterio:dev-plugins",
config(config) {
const root = path.resolve(config.root ?? process.cwd());
const sources = [root, path.dirname(webUiPackage), path.dirname(libPackage), ...pluginDirs.values()];
return {
server: {
// Vite's default is the workspace root, which may be the data directory.
fs: { allow: [...sources, ...sources.flatMap(nodeModulesDirs)] },
},
optimizeDeps: {
entries: [...pluginDirs.values()].flatMap(
dir => [`${dir}/index.{ts,js}`, `${dir}/web/**/*.{ts,tsx,js,jsx}`]
),
},
};
},
resolveId(id) {
if (id.startsWith(pluginPrefix)) {
return id;
}
return null;
},
load(id) {
if (id.startsWith(pluginPrefix)) {
const dir = pluginDirs.get(id.slice(pluginPrefix.length));
return dir ? pluginEntryCode(dir) : null;
}
return null;
},
};
}
/**
* Vite plugins for building the web bundle of a Clusterio plugin.
*
* The bundle exposes index, package.json and web/index from the plugin
* directory. Modules in sharedModules are left as bare imports and resolved
* by the web interface's import map at runtime.
*/
export function clusterioPlugin({ root = process.cwd() } = {}) {
const entryId = "\0clusterio:plugin-entry";
return [
browserCompat(),
react(),
{
name: "clusterio:plugin",
async config() {
const pkg = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
const name = pkg.name.replace(/^@[^/]+\/(plugin-)?/, "");
return {
build: {
outDir: "dist/web",
emptyOutDir: true,
rolldownOptions: {
input: { [name]: entryId },
external: sharedModules,
preserveEntrySignatures: "strict",
},
},
};
},
resolveId(id) {
if (id === entryId) {
return id;
}
return null;
},
load(id) {
if (id === entryId) {
return pluginEntryCode(root);
}
return null;
},
generateBundle(_options, bundle) {
const chunk = Object.values(bundle).find(c => c.type === "chunk" && c.facadeModuleId === entryId);
this.emitFile(writeManifest({ main: chunk.fileName }));
},
},
];
}