import { delimiter, resolve, normalize, join } from "path"; import { Buffer } from "buffer"; import { createReadStream, ReadStream } from "fs"; import { spawn } from "child_process"; import { Logger, LogStep } from "./logger"; import { readFileAsync, writeFileAsync, pathExistsAsync, statAsync, dequote, isWindows, bound, semverGt } from "./util"; import { NexeOptions, version } from "./options"; import { NexeTarget } from "./target"; import { Readable, Transform } from "stream"; import MultiStream = require("multistream"); import { Bundle, toStream } from "./fs/bundle"; import { File } from "resolve-dependencies"; const isBsd = Boolean(~process.platform.indexOf("bsd")); const make = isWindows ? "vcbuild.bat" : isBsd ? "gmake" : "make"; const configure = isWindows ? "configure" : "./configure"; type StringReplacer = (match: string) => string; export interface NexeFile { filename: string; absPath: string; contents: string | Buffer; } export { NexeOptions }; export class NexeError extends Error { constructor(m: string) { super(m); Object.setPrototypeOf(this, NexeError.prototype); } } export class NexeCompiler { /** * Epoch of when compilation started */ private start = Date.now(); private compileStep: LogStep | undefined; public log = new Logger(this.options.loglevel); /** * Copy of process.env */ public env = { ...process.env }; /** * Virtual FileSystem */ public bundle: Bundle; /** * Root directory for the source of the current build */ public src: string; /** * In memory files that are being manipulated by the compiler */ public files: NexeFile[] = []; /** * Standalone pieces of code run before the application entrypoint */ public shims: string[] = []; /** * The last shim (defaults to "require('module').runMain()") */ public startup = ""; /** * The main entrypoint filename for your application - eg. node mainFile.js */ public entrypoint: string | undefined; /** * Not used */ public targets: NexeTarget[]; /** * Current target of the compiler */ public target: NexeTarget; /** * Output filename (-o myapp.exe) */ public output = this.options.output; /** * Flag to indicate whether or notstdin was used for input */ public stdinUsed = false; /** * Path to the configure script */ public configureScript: string; /** * The file path of node binary */ public nodeSrcBinPath: string; /** * Remote asset path if available */ public remoteAsset: string; constructor(public options: NexeOptions) { const { python } = (this.options = options); //SOMEDAY iterate over multiple targets with `--outDir` this.targets = options.targets as NexeTarget[]; this.target = this.targets[0]; if (!/https?\:\/\//.test(options.remote)) { throw new NexeError( `Invalid remote URI scheme (must be http or https): ${options.remote}` ); } this.remoteAsset = options.remote + this.target.toString(); this.src = join(this.options.temp, this.target.version); this.configureScript = configure + (semverGt(this.target.version, "10.10.0") ? ".py" : ""); this.nodeSrcBinPath = isWindows ? join(this.src, "Release", "node.exe") : join(this.src, "out", "Release", "node"); this.log.step("nexe " + version, "info"); this.bundle = new Bundle(options); if (isWindows) { const originalPath = process.env.PATH; delete process.env.PATH; this.env = { ...process.env }; this.env.PATH = python ? (this.env.PATH = dequote(normalize(python)) + delimiter + originalPath) : originalPath; process.env.PATH = originalPath; } else { this.env = { ...process.env }; python && (this.env.PYTHON = python); } } @bound addResource(absoluteFileName: string, content?: Buffer | string | File) { return this.bundle.addResource(absoluteFileName, content); } @bound async readFileAsync(file: string) { this.assertBuild(); let cachedFile = this.files.find( (x) => normalize(x.filename) === normalize(file) ); if (!cachedFile) { const absPath = join(this.src, file); cachedFile = { absPath, filename: file, contents: await readFileAsync(absPath, "utf-8").catch((x) => { if (x.code === "ENOENT") return ""; throw x; }), }; this.files.push(cachedFile); } return cachedFile; } @bound writeFileAsync(file: string, contents: string | Buffer) { this.assertBuild(); return writeFileAsync(join(this.src, file), contents); } @bound async replaceInFileAsync( file: string, replace: string | RegExp, value: string | StringReplacer ) { const entry = await this.readFileAsync(file); entry.contents = entry.contents.toString().replace(replace, value as any); } @bound async setFileContentsAsync(file: string, contents: string | Buffer) { const entry = await this.readFileAsync(file); entry.contents = contents; } quit(error?: any) { const time = Date.now() - this.start; this.log.write(`Finished in ${time / 1000}s`, error ? "red" : "green"); return this.log.flush(); } assertBuild() { if (!this.options.build) { throw new NexeError("This feature is only available with `--build`"); } } public getNodeExecutableLocation(target?: NexeTarget) { if (this.options.asset) { return resolve(this.options.cwd, this.options.asset); } if (target) { return join(this.options.temp, target.toString()); } return this.nodeSrcBinPath; } private _runBuildCommandAsync(command: string, args: string[]) { if (this.log.verbose) { this.compileStep!.pause(); } return new Promise((resolve, reject) => { spawn(command, args, { cwd: this.src, env: this.env, shell: true, stdio: this.log.verbose ? "inherit" : "ignore", }) .once("error", (e: Error) => { if (this.log.verbose) { this.compileStep!.resume(); } reject(e); }) .once("close", (code: number) => { if (this.log.verbose) { this.compileStep!.resume(); } if (code != 0) { const error = `${command} ${args.join( " " )} exited with code: ${code}`; reject(new NexeError(error)); } resolve(); }); }); } private _configureAsync() { if (isWindows && semverGt(this.target.version, "10.15.99")) { return Promise.resolve(); } return this._runBuildCommandAsync(this.env.PYTHON || "python", [ this.configureScript, ...this.options.configure, ]); } public async build(): Promise { this.compileStep!.log( `Configuring node build${this.options.configure.length ? ": " + this.options.configure : "..." }` ); await this._configureAsync(); const buildOptions = this.options.make; this.compileStep!.log( `Compiling Node${buildOptions.length ? " with arguments: " + buildOptions : "..." }` ); await this._runBuildCommandAsync(make, buildOptions); return createReadStream(this.getNodeExecutableLocation()); } private async _shouldCompileBinaryAsync( binary: NodeJS.ReadableStream | null, location: string ) { //SOMEDAY combine make/configure/vcBuild/and modified times of included files const { snapshot, build } = this.options; if (!binary) { return true; } if (build && snapshot != null && (await pathExistsAsync(snapshot))) { const snapshotLastModified = (await statAsync(snapshot)).mtimeMs; const binaryLastModified = (await statAsync(location)).mtimeMs; return snapshotLastModified > binaryLastModified; } return false; } async compileAsync(target: NexeTarget) { const step = (this.compileStep = this.log.step("Compiling result")); const build = this.options.build; const location = this.getNodeExecutableLocation(build ? undefined : target); let binary = (await pathExistsAsync(location)) ? createReadStream(location) : null; if (await this._shouldCompileBinaryAsync(binary, location)) { binary = await this.build(); step.log("Node binary compiled"); } return this._assembleDeliverable(binary!); } code() { return [this.shims.join(""), this.startup].join(";"); } private async _assembleDeliverable(binary: Readable) { if (!this.options.mangle) { return binary; } const launchCode = this.code(); const codeSize = Buffer.byteLength(launchCode); const sentinel = Buffer.from(""); let vfsSize = 0; const streams = [ binary, toStream(launchCode), this.bundle.toStream().pipe( new Transform({ transform: (chunk, _, cb) => { vfsSize || this.bundle.finalize(); chunk && (vfsSize += chunk.length); cb(null, chunk); }, }) ), ]; let done = false; return new MultiStream((cb) => { if (done) cb(null, null); else if (streams.length) cb(null, streams.shift() as Readable); else { done = true; const trailers = Buffer.alloc(16); trailers.writeDoubleLE(codeSize, 0); trailers.writeDoubleLE(vfsSize, 8); cb(null, toStream(Buffer.concat([sentinel, trailers]))); } }); } }