Launching Minecraft from NodeJS

This is the story of how I learned about Minecraft launchers, as well as how you could (in theory) make your own.

Getting started

I've been using clients such as Lunar and Feather for a while now, and only recently decided to poke around the files (just out of curiosity). And to my surprise, JACKPOT! They were both ElectronJS apps. Now, in case you don't know, if you're looking for someone who knows stuff about Electron or NodeJS in general, I'm one of those nerds.

Taking a sneak peek

Now obviously, both of these clients are closed-source. However, I was surprised they weren't compiled with bytenode, merely obfuscated. So I dug in, and here's what I found.

The process

When launching Minecraft, it usually goes like this:

In Detail

Now that you have an outline of what needs to be done, let's dig into deeper details! So, step 1. All it really takes is choosing the version and locating the file:
            
let cwd = 'C:\\Users\\User\\AppData\\Roaming\\.minecraft\\';

let version = '1.19.4'; // Replace to launch another version
let versionInfo = require(path.join(cwd, 'versions', version, version + '.json'));

let osName = process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'osx' : 'linux';
let nativesSuffix = osName + '-' + (process.arch === 'x64' ? '64' : '32');
            
        
All this code does is find an already downloaded release of Minecraft, and get the version.json for that release (osName and nativesSuffix will be used in the future). So far so good? Now, step 2. We need to build a classpath string. There is a "libraries" property in the version.json file, and that contains all the JARs, as well as native dependencies the version of Minecraft needs to run. Here's what I did to parse only the JARs, natives are discussed next:
            
let classPath = versionInfo.libraries.map(lib => {
    if(!lib.downloads || !lib.downloads.artifact) return null;
    return path.join(cwd, 'libraries', lib.downloads.artifact.path);
}).filter(x => x).join(process.platform == 'win32' ? ';' : ':');
            
        
We iterate the libraries and find ones with "downloads.artifact" properties, and add the paths to the classpath. Again, this considers the version and its dependencies already downloaded, however version.json does contain URLs to download these. Next, we have the natives.
            
let natives = versionInfo.libraries.map(lib => {
    if(lib.natives && lib.natives[osName] && (!lib.rules || lib.rules.some(rule => rule.action === 'allow' && (!rule.os || rule.os.name === osName)))) {
        let includeArch = lib.natives[osName].includes('${arch}');
        return path.join(cwd, 'libraries', lib.downloads.classifiers['natives-' + (includeArch ? nativesSuffix : osName)].path);
    }
}).filter(x => x);

if(fs.existsSync(path.join(cwd, 'versions', version, 'natives'))) fs.rmSync(path.join(cwd, 'versions', version, 'natives'), { recursive: true });

// Extract natives
let nativesPath = path.join(cwd, 'versions', version, 'natives');
for(let native of natives) {
    // Extract natives to nativesPath
    let zip = new AdmZip(native);
    zip.extractAllTo(nativesPath, true);
}
            
        
First, we compose the list of native dependencies Minecraft needs. Then, we remove the "natives" folder, just in case of a previous crash. We proceed to extract all the JAR files with the native dependencies into the natives folder. The folder's path will be referenced later. Now we are almost done! All we need is a way of authorizing the user, as well as specifying some other options. Once again, version.json saves the day:
            
let mainClass = versionInfo.mainClass;
// let javaBin = 'C:\\Users\\User\\AppData\\Roaming\\.minecraft\\runtime\\jre-legacy\\windows\\jre-legacy\\bin\\java';
let javaBin = 'C:\\Users\\User\\AppData\\Roaming\\.minecraft\\jre\\OpenJDK17U-jre_x64_windows_hotspot_17.0.3_7\\bin\\java';

let opts = {
    user_properties: '{}',
    user_type: 'legacy',
    auth_player_name: 'Z3DB0Y',
    auth_uuid: '362d041d-d407-45d7-a354-605791d82f96',
    version_name: version,
    game_directory: path.join(cwd, 'versions', version),
    assets_root: path.join(cwd, 'assets'),
    assets_index_name: versionInfo.assets,
    auth_access_token: '0',
    clientid: '0',
    auth_xuid: '0',
    version_type: 'release'
};

let mcArgs = versionInfo.minecraftArguments || '';
let jvmArgs = '';
if(!mcArgs) {
    for(let arg of versionInfo.arguments.game) {
        if(typeof arg === 'string') mcArgs += arg + ' ';
        else if(arg.rules && arg.rules.some(rule => rule.action === 'allow' && (!rule.os || rule.os.name === osName))) mcArgs += arg.value + ' ';
    }
}
mcArgs = mcArgs.trim();

if(versionInfo.arguments && versionInfo.arguments.jvm) {
    for(let arg of versionInfo.arguments.jvm) {
        if(typeof arg === 'string') jvmArgs += arg + ' ';
        else if(arg.rules && arg.rules.some(rule => rule.action === 'allow' && (!rule.os || rule.os.name === osName))) jvmArgs += arg.value + ' ';
    }
}
jvmArgs = jvmArgs.trim();

let mcArgsReplaced = mcArgs.replaceAll(/\$\{([^\}]+)\}/g, (match, p1) => {
    return opts[p1] || match;
});
mcArgsReplaced = mcArgsReplaced.replaceAll(/--demo/g, '');
            
        
All this code does is locates the main class, as well as replacing argument values needed for the game to start. Please note the commented javaBin. This is because older versions of Minecraft use an older JRE to run. Now, the last step. We actually launch the game:
            
let processArgs = [
    `-Djava.library.path=natives`,
    '-cp', classPath + ';' + natives + ';' + path.join(cwd, 'versions', version, `${version}.jar`),
    mainClass
].concat(mcArgsReplaced.split(' '));

let child = child_process.spawn(javaBin, processArgs, {
    cwd: path.join(cwd, 'versions', version),
    stdio: 'inherit',
    env: Object.assign({}, process.env)
});

child.on('close', code => {
    console.log('Exited with code ' + code);
    // Unlink natives
    if(fs.existsSync(path.join(cwd, 'versions', version, 'natives'))) fs.rmSync(path.join(cwd, 'versions', version, 'natives'), { recursive: true });
});
            
        
At this stage, all steps are combined to spawn a Java process, launching the game. Let's go through this code: we build an array of processArgs. First, we have "-Djava.library.path=natives". This means that native files (DLLs) are located under "${cwd}/natives". Next, we specify the classpath using "-cp", I also added native JARs just in case. Next, we have the main class (usually 3rd party Minecraft clients inject a JAR into classpath and hook here). And finally, the Minecraft arguments (auth, directories, etc). The rest of this code is just utility (log the process's console output, specify cwd - oh wait, we DO need that for the natives path ;) - and lastly, unlinking natives after the client closes) This is a lot of info to wrap your head around, and i had some struggles setting this up, but I hope you understood how it's done. Here's the full code:
            
const child_process = require('child_process');
const path = require('path');
const AdmZip = require('adm-zip');
const fs = require('fs');

let cwd = 'C:\\Users\\User\\AppData\\Roaming\\.minecraft\\';

let version = '1.19.4'; // Replace to launch another version
let versionInfo = require(path.join(cwd, 'versions', version, version + '.json'));

let osName = process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'osx' : 'linux';
let nativesSuffix = osName + '-' + (process.arch === 'x64' ? '64' : '32');

let classPath = versionInfo.libraries.map(lib => {
    if(!lib.downloads || !lib.downloads.artifact) return null;
    return path.join(cwd, 'libraries', lib.downloads.artifact.path);
}).filter(x => x).join(process.platform == 'win32' ? ';' : ':');

let natives = versionInfo.libraries.map(lib => {
    if(lib.natives && lib.natives[osName] && (!lib.rules || lib.rules.some(rule => rule.action === 'allow' && (!rule.os || rule.os.name === osName)))) {
        let includeArch = lib.natives[osName].includes('${arch}');
        return path.join(cwd, 'libraries', lib.downloads.classifiers['natives-' + (includeArch ? nativesSuffix : osName)].path);
    }
}).filter(x => x);

if(fs.existsSync(path.join(cwd, 'versions', version, 'natives'))) fs.rmSync(path.join(cwd, 'versions', version, 'natives'), { recursive: true });

// Extract natives
let nativesPath = path.join(cwd, 'versions', version, 'natives');
for(let native of natives) {
    // Extract natives to nativesPath
    let zip = new AdmZip(native);
    zip.extractAllTo(nativesPath, true);
}

let mainClass = versionInfo.mainClass;
// let javaBin = 'C:\\Users\\User\\AppData\\Roaming\\.minecraft\\runtime\\jre-legacy\\windows\\jre-legacy\\bin\\java';
let javaBin = 'C:\\Users\\User\\AppData\\Roaming\\.minecraft\\jre\\OpenJDK17U-jre_x64_windows_hotspot_17.0.3_7\\bin\\java';

let opts = {
    user_properties: '{}',
    user_type: 'legacy',
    auth_player_name: 'Z3DB0Y',
    auth_uuid: '362d041d-d407-45d7-a354-605791d82f96',
    version_name: version,
    game_directory: path.join(cwd, 'versions', version),
    assets_root: path.join(cwd, 'assets'),
    assets_index_name: versionInfo.assets,
    auth_access_token: '0',
    clientid: '0',
    auth_xuid: '0',
    version_type: 'release'
};

let mcArgs = versionInfo.minecraftArguments || '';
let jvmArgs = '';
if(!mcArgs) {
    for(let arg of versionInfo.arguments.game) {
        if(typeof arg === 'string') mcArgs += arg + ' ';
        else if(arg.rules && arg.rules.some(rule => rule.action === 'allow' && (!rule.os || rule.os.name === osName))) mcArgs += arg.value + ' ';
    }
}
mcArgs = mcArgs.trim();

if(versionInfo.arguments && versionInfo.arguments.jvm) {
    for(let arg of versionInfo.arguments.jvm) {
        if(typeof arg === 'string') jvmArgs += arg + ' ';
        else if(arg.rules && arg.rules.some(rule => rule.action === 'allow' && (!rule.os || rule.os.name === osName))) jvmArgs += arg.value + ' ';
    }
}
jvmArgs = jvmArgs.trim();

let mcArgsReplaced = mcArgs.replaceAll(/\$\{([^\}]+)\}/g, (match, p1) => {
    return opts[p1] || match;
});
mcArgsReplaced = mcArgsReplaced.replaceAll(/--demo/g, '');

console.log(mcArgsReplaced.split(' '));

let processArgs = [
    `-Djava.library.path=natives`,
    '-cp', classPath + ';' + natives + ';' + path.join(cwd, 'versions', version, `${version}.jar`),
    mainClass
].concat(mcArgsReplaced.split(' '));

let child = child_process.spawn(javaBin, processArgs, {
    cwd: path.join(cwd, 'versions', version),
    stdio: 'inherit',
    env: Object.assign({}, process.env)
});

child.on('close', code => {
    console.log('Exited with code ' + code);
    // Unlink natives
    if(fs.existsSync(path.join(cwd, 'versions', version, 'natives'))) fs.rmSync(path.join(cwd, 'versions', version, 'natives'), { recursive: true });
});