mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
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>
388 lines
11 KiB
JavaScript
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 }));
|
|
},
|
|
},
|
|
];
|
|
}
|