mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 15:14:52 +09:00
Migrate the usage of CommonJS specific code and imports to ESM. Roughly summarised this: - Changes all require() calls to import statement or expressions. - Changes usage of module.exports to export statements. - Replaces require.resolve with import.meta.resolve. - Replaces conditional imports with conditional subpath imports. - Replaces module === require.main with import.meta.main. - Adds the full filename with extention to all relative imports. - Changes all usage of __dirname and __filename to their import.meta equivalents. - Sets "type": "module" in package.json files. - Removes usage of "use strict". - Adds .ts an an alias of .js for the webpack build. Due to differences in how require and import resolves files the plugin entrypoints will have to include the filename and extension after this migration in order to work.
449 lines
13 KiB
TypeScript
449 lines
13 KiB
TypeScript
// Library for patching Factorio saves with scenario code.
|
|
|
|
import events from "events";
|
|
import fs from "node:fs/promises";
|
|
import JSZip from "jszip";
|
|
import path from "path";
|
|
import semver from "semver";
|
|
import { Type, Static } from "@sinclair/typebox";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
|
|
import BaseInstancePlugin from "./BaseInstancePlugin.js";
|
|
|
|
|
|
/**
|
|
* Describes a module that can be patched into a save
|
|
*/
|
|
export class SaveModule {
|
|
constructor(
|
|
/** Module */
|
|
public info: lib.ModuleInfo,
|
|
/** Files and their content that will be patched into the save */
|
|
public files = new Map<string, Buffer>(),
|
|
) { }
|
|
|
|
static moduleFilePath(filePath: string, moduleName: string) {
|
|
// Map locale files to the save's locale folder
|
|
if (filePath.startsWith("locale/")) {
|
|
const slashes = filePath.match(/\//g)!.length;
|
|
if (slashes > 1) {
|
|
const secondSlash = filePath.indexOf("/", "locale/".length);
|
|
return `${filePath.slice(0, secondSlash)}/${moduleName}-${filePath.slice(secondSlash + 1)}`;
|
|
}
|
|
const period = `${filePath}.`.indexOf(".");
|
|
return `${filePath.slice(0, period)}/${moduleName}${filePath.slice(period)}`;
|
|
}
|
|
// Map all other files into modules/name in the save.
|
|
return path.posix.join("modules", moduleName, filePath);
|
|
}
|
|
|
|
async loadFiles(moduleDirectory: string) {
|
|
let dirs: [string, string][] = [[moduleDirectory, ""]];
|
|
while (dirs.length) {
|
|
let [dir, relativeDir] = dirs.pop()!;
|
|
for (let entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
let fsPath = path.join(dir, entry.name);
|
|
let relativePath = path.posix.join(relativeDir, entry.name);
|
|
|
|
if (entry.isFile()) {
|
|
let savePath = SaveModule.moduleFilePath(relativePath, this.info.name);
|
|
this.files.set(savePath, await fs.readFile(fsPath));
|
|
if (relativePath === "module_exports.lua") {
|
|
this.files.set(
|
|
`modules/${this.info.name}.lua`,
|
|
Buffer.from(`return require("modules/${this.info.name}/module_exports")`, "utf-8")
|
|
);
|
|
}
|
|
|
|
} else if (entry.isDirectory()) {
|
|
dirs.push([fsPath, relativePath]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static jsonSchema = Type.Object({
|
|
...lib.ModuleInfo.jsonSchema.properties,
|
|
"files": Type.Array(Type.String()),
|
|
});
|
|
|
|
toJSON() {
|
|
return {
|
|
...this.info.toJSON(),
|
|
files: [...this.files.keys()],
|
|
};
|
|
}
|
|
|
|
static async fromSave(json: Static<typeof SaveModule.jsonSchema>, root: JSZip) {
|
|
const module = new this(lib.ModuleInfo.fromJSON(json));
|
|
module.files = new Map(await Promise.all(json.files
|
|
.map(filename => ({filename, file: root.file(filename)}))
|
|
.filter(({filename, file}) => {
|
|
if (file === null) {
|
|
lib.logger.warn(`Missing file ${filename} in save`);
|
|
}
|
|
return file !== null;
|
|
})
|
|
.map(async ({filename, file}) => [filename, await file!.async("nodebuffer")] as const)
|
|
));
|
|
return module;
|
|
}
|
|
|
|
static async fromPlugin(plugin: BaseInstancePlugin) {
|
|
let moduleDirectory = path.join(path.dirname(plugin.info.packagePath), "module");
|
|
try {
|
|
await fs.access(moduleDirectory);
|
|
} catch (err: any) {
|
|
return null;
|
|
}
|
|
|
|
let moduleJsonPath = path.join(moduleDirectory, "module.json");
|
|
let moduleJson;
|
|
try {
|
|
moduleJson = {
|
|
name: plugin.info.name,
|
|
version: plugin.info.version,
|
|
dependencies: { "clusterio": "*" },
|
|
...JSON.parse(await fs.readFile(moduleJsonPath, "utf8")),
|
|
};
|
|
} catch (err: any) {
|
|
throw new Error(`Loading module/module.json in plugin ${plugin.info.name} failed: ${err.message}`);
|
|
}
|
|
if (!lib.ModuleInfo.validate(moduleJson)) {
|
|
throw new Error(
|
|
`module/module.json in plugin ${plugin.info.name} failed validation:\n` +
|
|
`${JSON.stringify(lib.ModuleInfo.validate.errors, null, "\t")}`
|
|
);
|
|
}
|
|
|
|
let module = new SaveModule(lib.ModuleInfo.fromJSON(moduleJson));
|
|
await module.loadFiles(moduleDirectory);
|
|
return module;
|
|
}
|
|
|
|
static async fromDirectory(moduleDirectory: string) {
|
|
let name = path.basename(moduleDirectory);
|
|
let moduleJsonPath = path.join(moduleDirectory, "module.json");
|
|
let moduleJson;
|
|
try {
|
|
moduleJson = JSON.parse(await fs.readFile(moduleJsonPath, "utf8"));
|
|
} catch (err: any) {
|
|
throw new Error(`Loading ${name}/module.json failed: ${err.message}`);
|
|
}
|
|
if (!lib.ModuleInfo.validate(moduleJson)) {
|
|
throw new Error(
|
|
`${name}/module.json failed validation:\n` +
|
|
`${JSON.stringify(lib.ModuleInfo.validate.errors, null, "\t")}`
|
|
);
|
|
}
|
|
|
|
if (moduleJson.name !== name) {
|
|
throw new Error(`Expected name of module ${moduleJson.name} to match the directory name ${name}`);
|
|
}
|
|
|
|
let module = new SaveModule(lib.ModuleInfo.fromJSON(moduleJson));
|
|
await module.loadFiles(moduleDirectory);
|
|
return module;
|
|
}
|
|
}
|
|
|
|
export class PatchInfo {
|
|
static currentVersion = 1;
|
|
|
|
constructor(
|
|
public patchNumber: number,
|
|
public scenario: lib.ModuleInfo,
|
|
public modules: SaveModule[],
|
|
public version = PatchInfo.currentVersion,
|
|
) { }
|
|
|
|
static jsonSchema = Type.Object({
|
|
"version": Type.Number(),
|
|
"patch_number": Type.Number(),
|
|
"scenario": lib.ModuleInfo.jsonSchema,
|
|
"modules": Type.Array(SaveModule.jsonSchema),
|
|
});
|
|
|
|
toJSON() {
|
|
return {
|
|
version: PatchInfo.currentVersion,
|
|
patch_number: this.patchNumber,
|
|
scenario: this.scenario.toJSON(),
|
|
modules: this.modules.map(m => m.toJSON()),
|
|
};
|
|
}
|
|
|
|
static async fromSave(json: Static<typeof PatchInfo.jsonSchema>, root: JSZip) {
|
|
if (json.version === undefined) {
|
|
interface ScenarioInfoV0 {
|
|
name: string;
|
|
modules: string[];
|
|
}
|
|
interface PatchInfoV0 {
|
|
patch_number: number;
|
|
scenario: ScenarioInfoV0;
|
|
modules: { name: string, files: { path: string, load: boolean, require: boolean }[], }[];
|
|
}
|
|
|
|
const info = json as unknown as PatchInfoV0;
|
|
return new this(
|
|
info.patch_number,
|
|
new lib.ModuleInfo(info.scenario.name, "0.0.0", info.scenario.modules),
|
|
[],
|
|
0,
|
|
);
|
|
}
|
|
|
|
return new this(
|
|
json.patch_number,
|
|
lib.ModuleInfo.fromJSON(json.scenario),
|
|
await Promise.all(json.modules.map(m => SaveModule.fromSave(m, root))),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates control.lua code for loading the Clusterio modules
|
|
*
|
|
* @param patchInfo - The patch info files's json content
|
|
* @returns Generated control.lua code.
|
|
* @internal
|
|
*/
|
|
function generateLoader(patchInfo: PatchInfo) {
|
|
let lines = [
|
|
"-- Auto generated scenario module loader created by Clusterio",
|
|
"-- Modifications to this file will be lost when loaded in Clusterio",
|
|
`clusterio_patch_number = ${patchInfo.patchNumber}`,
|
|
"",
|
|
'local event_handler = require("event_handler")',
|
|
"",
|
|
"-- Scenario modules",
|
|
];
|
|
|
|
for (let requirePath of patchInfo.scenario.load) {
|
|
lines.push(`event_handler.add_lib(require("${requirePath}"))`);
|
|
}
|
|
for (let requirePath of patchInfo.scenario.require) {
|
|
lines.push(`require("${requirePath}")`);
|
|
}
|
|
|
|
lines.push(...[
|
|
"",
|
|
"-- Clusterio modules",
|
|
]);
|
|
|
|
for (let module of patchInfo.modules) {
|
|
for (let file of module.info.load) {
|
|
let requirePath = `modules/${module.info.name}/${file.replace(/\.lua$/i, "")}`;
|
|
lines.push(`event_handler.add_lib(require("${requirePath}"))`);
|
|
}
|
|
for (let file of module.info.require) {
|
|
let requirePath = `modules/${module.info.name}/${file.replace(/\.lua$/i, "")}`;
|
|
lines.push(`require("${requirePath}")`);
|
|
}
|
|
}
|
|
|
|
// End last line with a newline
|
|
lines.push("");
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Reorders modules to satisfy their dependencies
|
|
*
|
|
* Looks through and reorders the array of module definitions in order to
|
|
* satisfy the property that dependencies are earlier in the array than
|
|
* their dependents. Throws an error if this is not possible.
|
|
*
|
|
* @param modules - Array of modules to reorder
|
|
* @internal
|
|
*/
|
|
function reorderDependencies(modules: SaveModule[]) {
|
|
let index = 0;
|
|
let present = new Map<string, string>();
|
|
let hold = new Map<string, [SaveModule]>();
|
|
reorder: while (index < modules.length) {
|
|
let module = modules[index];
|
|
if (semver.valid(module.info.version) === null) {
|
|
throw new Error(`Invalid version '${module.info.version}' for module ${module.info.name}`);
|
|
}
|
|
|
|
for (let [dependency, requirement] of module.info.dependencies) {
|
|
if (semver.validRange(requirement) === null) {
|
|
throw new Error(
|
|
`Invalid version range '${requirement}' for dependency ${dependency} on module ${module.info.name}`
|
|
);
|
|
}
|
|
|
|
if (present.has(dependency)) {
|
|
if (!semver.satisfies(present.get(dependency)!, requirement)) {
|
|
throw new Error(`Module ${module.info.name} requires ${dependency} ${requirement}`);
|
|
}
|
|
|
|
// We have an unmet dependency, take it out and continue
|
|
} else {
|
|
if (hold.has(dependency)) {
|
|
hold.get(dependency)!.push(module);
|
|
} else {
|
|
hold.set(dependency, [module]);
|
|
}
|
|
modules.splice(index, 1);
|
|
continue reorder;
|
|
}
|
|
}
|
|
|
|
// No unmet dependencies, record and continue
|
|
present.set(module.info.name, module.info.version);
|
|
index += 1;
|
|
|
|
if (hold.has(module.info.name)) {
|
|
modules.splice(index, 0, ...hold.get(module.info.name)!);
|
|
hold.delete(module.info.name);
|
|
}
|
|
}
|
|
|
|
if (!hold.size) {
|
|
return;
|
|
}
|
|
|
|
// There are three reasons for a module to end up being held: The module depends
|
|
// on a module that is missing, the module is part of a dependency loop, or the
|
|
// the module depends on a module that satisfy any of these conditions.
|
|
|
|
let remaining = new Map<string, SaveModule>();
|
|
for (let heldModules of hold.values()) {
|
|
for (let module of heldModules) {
|
|
remaining.set(module.info.name, module);
|
|
}
|
|
}
|
|
|
|
// Start with a random module from the remaining modules
|
|
for (let module of remaining.values()) {
|
|
let cycle: string[] = [];
|
|
while (true) {
|
|
// Find an unmet dependency
|
|
let dependency = [...module.info.dependencies.keys()].find(name => !present.has(name))!;
|
|
|
|
if (!remaining.has(dependency)) {
|
|
// There's no module being held up by this dependency, the
|
|
// dependency is missing.
|
|
throw new Error(`Missing dependency ${dependency} for module ${module.info.name}`);
|
|
}
|
|
|
|
if (cycle.includes(module.info.name)) {
|
|
cycle.push(module.info.name);
|
|
cycle.splice(0, cycle.indexOf(module.info.name));
|
|
throw new Error(`Module dependency loop detected: ${cycle.join(" -> ")}`);
|
|
}
|
|
|
|
cycle.push(module.info.name);
|
|
module = remaining.get(dependency)!;
|
|
}
|
|
}
|
|
}
|
|
|
|
const knownScenarios: Record<string, lib.ModuleInfo> = {
|
|
// First seen in 0.17.63
|
|
"4e866186ebe297f1038fd325b09df1a1f5e2fdd1": new lib.ModuleInfo("freeplay", "0.17.63", [], ["scenario"]),
|
|
// First seen in 2.0
|
|
"bcbdde18ce4ec16ebfd93bd694cd9b12ef969c9a": new lib.ModuleInfo("freeplay", "2.0.0", [], ["scenario"]),
|
|
// First seen in 2.0.29
|
|
"3af547d2f4db3728b75ac5d1b3a4f47830dc48e7": new lib.ModuleInfo("freeplay", "2.0.29", [], ["scenario"]),
|
|
};
|
|
|
|
/**
|
|
* Patch a save with the given modules
|
|
*
|
|
* Adds the modules given by the modules parameter to the save located
|
|
* at savePath and rewrites the control.lua in the save to load the
|
|
* modules that were added. Will also remove any previous module
|
|
* located in the save.
|
|
*
|
|
* @param savePath - Path to the Factorio save to patch.
|
|
* @param modules - Description of the modules to patch.
|
|
*/
|
|
export async function patch(savePath: string, modules: SaveModule[]) {
|
|
let zip = await JSZip.loadAsync(await fs.readFile(savePath));
|
|
let root = zip.folder(lib.findRoot(zip))!;
|
|
|
|
let patchInfoFile = root.file("clusterio.json");
|
|
let patchInfo: PatchInfo;
|
|
if (patchInfoFile !== null) {
|
|
let content = await patchInfoFile.async("string");
|
|
patchInfo = await PatchInfo.fromSave(JSON.parse(content), root);
|
|
if (patchInfo.version > PatchInfo.currentVersion) {
|
|
throw new Error(
|
|
`Save patch version ${patchInfo.version} is newer than the patch version this ` +
|
|
`version of Clusterio can load (${PatchInfo.currentVersion})`
|
|
);
|
|
}
|
|
|
|
// No info file present, try to detect if it's a known compatible scenario.
|
|
} else {
|
|
let controlFile = root.file("control.lua");
|
|
if (!controlFile) {
|
|
throw new Error("Unable to patch save, missing control.lua file.");
|
|
}
|
|
let controlStream = controlFile.nodeStream("nodebuffer");
|
|
let controlHash = await lib.hashStream(controlStream);
|
|
|
|
if (controlHash in knownScenarios) {
|
|
patchInfo = new PatchInfo(0, knownScenarios[controlHash], []);
|
|
root.file("scenario.lua", controlFile.nodeStream("nodebuffer"));
|
|
} else {
|
|
throw new Error(`Unable to patch save, unknown scenario (${controlHash})`);
|
|
}
|
|
}
|
|
|
|
// Increment patch number
|
|
patchInfo.patchNumber = patchInfo.patchNumber + 1;
|
|
|
|
// Remove any existing modules from the save
|
|
if (patchInfo.version === 0) {
|
|
for (let file of root.file(/^modules\//)) {
|
|
zip.remove(file.name);
|
|
}
|
|
} else {
|
|
for (let module of patchInfo.modules) {
|
|
for (let filepath of module.files.keys()) {
|
|
const file = root.file(filepath);
|
|
if (file !== null) {
|
|
zip.remove(file.name);
|
|
}
|
|
}
|
|
}
|
|
patchInfo.modules = [];
|
|
}
|
|
|
|
reorderDependencies(modules);
|
|
|
|
// Add the modules to the save.
|
|
for (let module of modules) {
|
|
for (let [relativePath, contents] of module.files) {
|
|
root.file(relativePath, contents);
|
|
}
|
|
patchInfo.modules.push(module);
|
|
}
|
|
|
|
// Add loading code and patch info
|
|
root.file("control.lua", generateLoader(patchInfo));
|
|
root.file("clusterio.json", JSON.stringify(patchInfo, null, "\t"));
|
|
|
|
// Write back the save
|
|
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
|
|
const [tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
|
|
try {
|
|
let pipe = stream.pipe(writeStream);
|
|
await events.once(pipe, "finish");
|
|
} finally {
|
|
writeStream.destroy();
|
|
}
|
|
await fs.rename(tempSavePath, savePath);
|
|
}
|
|
|
|
// For testing only
|
|
export const _generateLoader = generateLoader;
|
|
export const _reorderDependencies = reorderDependencies;
|