From e2e71023c13e0825f0d61309609c4d015c167857 Mon Sep 17 00:00:00 2001 From: oxmc7769 Date: Thu, 26 Feb 2026 07:02:55 -0800 Subject: [PATCH] Fix NodeJS 22+ path handling, improve SnapshotFS handling, add bundleRules --- README.md | 16 ++ package-lock.json | 164 +++++++++++++----- package.json | 16 +- src/fs/patch.ts | 310 +++++++++++++++++++++++++++++------ src/options.ts | 10 ++ src/patches/boot-nexe.ts | 2 +- src/patches/fs-stat-debug.ts | 23 +++ src/patches/index.ts | 3 + src/steps/bundle.ts | 227 +++++++++++++++++++++++-- 9 files changed, 658 insertions(+), 113 deletions(-) create mode 100644 src/patches/fs-stat-debug.ts diff --git a/README.md b/README.md index f8f1950..f14a609 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,22 @@ compile({ export function createBundle (options: NexeOptions): Promise ``` - default: true + - #### `bundleRules: object` + - Configuration for the runtime module resolution inside the snapshot. + This controls how bare module specifiers (e.g., `require('lodash')`) are resolved from the embedded `node_modules`. + - Properties: + - `conditions?: string[]` – The set of conditions to use when evaluating the exports field of a package. + Default: `['require', 'node', 'default']`. + - `basePath?: string` – The virtual filesystem path where `node_modules` is mounted. Default: `'/snapshot/node_modules'`. + - Example: + ``` + javascript + { + conditions: ['require', 'node', 'production'], + basePath: '/snapshot/packages' + } + ``` + - default: `{ conditions: ['require', 'node', 'default'], basePath: '/snapshot/node_modules' }` - #### `name: string` - Module friendly name of the application - default: basename of the input file, or `nexe_${Date.now()}` diff --git a/package-lock.json b/package-lock.json index b821a60..5989885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nexe", - "version": "5.0.0-beta.5", + "version": "5.0.0-beta.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nexe", - "version": "5.0.0-beta.5", + "version": "5.0.0-beta.6", "license": "MIT", "dependencies": { "@calebboyd/semaphore": "^1.3.1", @@ -19,6 +19,7 @@ "chalk": "^2.4.2", "globby": "^11.0.2", "meriyah": "^4.3.5", + "minimatch": "^10.1.2", "minimist": "^1.2.8", "mkdirp": "^1.0.4", "multistream": "^4.1.0", @@ -167,6 +168,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/js": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", @@ -193,6 +207,19 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -215,6 +242,27 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1990,9 +2038,9 @@ } }, "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2061,13 +2109,13 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -2104,9 +2152,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "license": "MIT" }, "node_modules/es-object-atoms": { @@ -2422,6 +2470,19 @@ "dev": true, "license": "MIT" }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2905,6 +2966,18 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -3535,9 +3608,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.defaults": { @@ -3765,15 +3838,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@isaacs/brace-expansion": "^5.0.1" }, "engines": { - "node": "*" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -4912,9 +4988,9 @@ } }, "node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -4944,9 +5020,9 @@ } }, "node_modules/terser": { - "version": "5.44.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", - "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -4962,9 +5038,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.15", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.15.tgz", - "integrity": "sha512-PGkOdpRFK+rb1TzVz+msVhw4YMRT9txLF4kRqvJhGhCM324xuR3REBSHALN+l+sAhKUmz0aotnjp5D+P83mLhQ==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", @@ -5180,9 +5256,9 @@ } }, "node_modules/ts-node/node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5325,9 +5401,9 @@ "license": "MIT" }, "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", @@ -5347,9 +5423,9 @@ } }, "node_modules/webpack": { - "version": "5.103.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", - "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", @@ -5360,10 +5436,10 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", @@ -5374,8 +5450,8 @@ "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.4", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { diff --git a/package.json b/package.json index dc0914a..4c82d94 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,12 @@ "name": "nexe", "description": "Create a single executable out of your Node.js application", "license": "MIT", - "version": "5.0.0-beta.5", + "version": "5.0.1-beta", "contributors": [ "Craig Condon (http://crcn.io)", "Jared Allard ", - "Caleb Boyd " + "Caleb Boyd ", + "Seth Olivarez (http://oxmc.me)" ], "scripts": { "ci:build": "ts-node tasks/build", @@ -54,6 +55,7 @@ "chalk": "^2.4.2", "globby": "^11.0.2", "meriyah": "^4.3.5", + "minimatch": "^10.1.2", "minimist": "^1.2.8", "mkdirp": "^1.0.4", "multistream": "^4.1.0", @@ -76,17 +78,17 @@ "@types/ora": "^3.2.0", "@types/rimraf": "3.0.2", "@types/semver": "^7.3.13", + "@typescript-eslint/eslint-plugin": "^5.59.0", + "@typescript-eslint/parser": "^5.59.0", "chai": "^4.3.7", + "eslint": "^8.40.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-prettier": "^4.2.1", "execa": "^5.1.1", "lodash": "^4.17.21", "mocha": "^10.2.0", "prettier": "^2.8.7", "ts-node": "^10.9.1", - "@typescript-eslint/eslint-plugin": "^5.59.0", - "@typescript-eslint/parser": "^5.59.0", - "eslint": "^8.40.0", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-prettier": "^4.2.1", "typescript": "^5.0.3", "webpack-cli": "^5.0.1" } diff --git a/src/fs/patch.ts b/src/fs/patch.ts index 662856f..07cafaa 100644 --- a/src/fs/patch.ts +++ b/src/fs/patch.ts @@ -3,7 +3,7 @@ import { patchFs, PosixFS, NodeFS } from "@yarnpkg/fslib"; import { SnapshotZipFS } from "./SnapshotZipFS"; import * as assert from "assert"; import * as constants from "constants"; -import { dirname } from "path"; +import { dirname, relative, sep } from "path"; export interface NexeHeader { blobPath: string; @@ -16,7 +16,7 @@ export interface NexeHeader { } let originalFsMethods: any = null; -let lazyRestoreFs = () => { }; +let lazyRestoreFs = () => {}; const patches = (process as any).nexe.patches || {}; const originalPatches = { ...patches }; delete (process as any).nexe; @@ -47,15 +47,19 @@ function shimFs(binary: NexeHeader, fs: typeof import("fs") = require("fs")) { libzip: getLibzipSync(), zipFs, baseFs: nodeFs, - root: dirname(process.argv[0]), + root: dirname(process.argv[0]), // executable directory = original project root }); const posixSnapshotZipFs = new PosixFS(snapshotZipFS); - patchFs(fs, posixSnapshotZipFs); + patchFs(fs, posixSnapshotZipFs); // patches global fs + // Capture the project root for path mapping + const projectRoot = dirname(process.argv[0]); + const drive = projectRoot.slice(0, 2); // e.g., "C:" + + // Enable logging with DEBUG=nexe:require let log = (_: string) => true; if ((process.env.DEBUG || "").toLowerCase().includes("nexe:require")) { process.stderr.write( - // @ts-ignore `[nexe] - FILES ${JSON.stringify( // @ts-ignore - accessing private property for debugging Array.from(zipFs.entries.keys()), @@ -64,7 +68,6 @@ function shimFs(binary: NexeHeader, fs: typeof import("fs") = require("fs")) { )}\n` ); process.stderr.write( - // @ts-ignore `[nexe] - DIRECTORIES ${JSON.stringify( // @ts-ignore - accessing private property for debugging Array.from(zipFs.listings.keys()), @@ -72,60 +75,273 @@ function shimFs(binary: NexeHeader, fs: typeof import("fs") = require("fs")) { 4 )}\n` ); - log = (text: string) => { - return process.stderr.write(`[nexe] - ${text}\n`); - }; + log = (text: string) => process.stderr.write(`[nexe] - ${text}\n`); } + + /** + * Convert a Windows path to the snapshot POSIX path. + * Handles: + * - Paths under the original project root (e.g., C:\Users\oxmc\Desktop\src\entry.js → /snapshot/src/entry.js) + * - Paths that have been converted to C:\snapshot\... (from _findPath returning /snapshot/...) + * - Already POSIX /snapshot/... paths (return as-is) + */ + function toSnapshotPath(filePath: string): string { + // Already a POSIX snapshot path + if (filePath.startsWith("/snapshot/")) { + return filePath; + } + // Handle Windows-style snapshot path: e.g., C:\snapshot\src\entry.js + if (filePath.startsWith(drive + "\\snapshot\\")) { + return "/snapshot/" + filePath.slice(drive.length + 9).replace(/\\/g, "/"); + } + // Path under original project root + if (filePath.startsWith(projectRoot)) { + const rel = relative(projectRoot, filePath); + return "/snapshot/" + rel.replace(/\\/g, "/"); + } + // Not mappable – return unchanged (will likely fail) + return filePath; + } + + // --- Internal Module Patches (unconditionally assigned) --- + function internalModuleReadFile(this: any, original: any, ...args: any[]) { - log(`internalModuleReadFile ${args[0]}`); + // Strip Windows extended-length path prefix if present + let filePath = typeof args[0] === "string" ? args[0] : ""; + if (filePath.startsWith("\\\\?\\")) { + filePath = filePath.slice(4); + } + const mappedPath = toSnapshotPath(filePath); + log(`internalModuleReadFile ${filePath} -> ${mappedPath}`); try { - return posixSnapshotZipFs.readFileSync(args[0], "utf-8"); + // Use the patched fs (which already sees the zip) + return fs.readFileSync(mappedPath, "utf-8"); } catch (e) { return ""; } } - if (patches.internalModuleReadFile) { - patches.internalModuleReadFile = internalModuleReadFile; - } - let returningArray: boolean; - patches.internalModuleReadJSON = function ( - this: any, - original: any, - ...args: any[] - ) { - if (returningArray == null) - returningArray = Array.isArray(original.call(this, "")); - const res = internalModuleReadFile.call(this, original, ...args); - return returningArray && !Array.isArray(res) - ? res === "" - ? [] - : [res, /"(main|name|type|exports|imports)"/.test(res)] - : res; + + patches.internalModuleReadFile = internalModuleReadFile; + + // internalModuleReadJSON should return a string (or undefined) in Node 22 + patches.internalModuleReadJSON = function (this: any, original: any, ...args: any[]) { + const content = internalModuleReadFile.call(this, original, ...args); + return content === "" ? undefined : content; }; - patches.internalModuleStat = function ( - this: any, - original: any, - ...args: any[] - ) { - let statPath = args[0]; - //in node 22, the path arg moved to arg[1] - if (typeof args[0] !== "string") statPath = args[1]; - let result = 0; - try { - const stat = posixSnapshotZipFs.statSync(statPath); - if (stat.isDirectory()) result = 1; - else result = 0; - } catch (e) { - result = -constants.ENOENT; + + // Handle Node 22+ signature: first arg is context (non-string), second is path + function internalModuleStat(this: any, original: any, ...args: any[]) { + const target = args[0]; + let statPath: string; + + // Node 22+: args[0] is context (often a buffer), args[1] is the path string + if (typeof target === "number") { + // file descriptor case – unchanged + try { + originalFsMethods.fstatSync(target); + return 0; + } catch (e) { + return -constants.ENOENT; + } } - log(`internalModuleStat ${result} ${statPath}`); - return result; - }; + + if (typeof target === "string") { + // Node <22 or when a string is passed directly + statPath = target; + } else { + // Node 22+: path is the second argument + statPath = args[1]; + } + + if (typeof statPath !== "string") { + // Unexpected: fallback to original + return original.call(this, ...args); + } + + // Strip Windows extended-length prefix + if (statPath.startsWith("\\\\?\\")) { + statPath = statPath.slice(4); + } + + const mappedPath = toSnapshotPath(statPath); + log(`internalModuleStat ${statPath} -> ${mappedPath}`); + + try { + const stat = fs.statSync(mappedPath); + return stat.isDirectory() ? 1 : 0; + } catch (e) { + return -constants.ENOENT; + } + } + + patches.internalModuleStat = internalModuleStat; + + // --- Patch Module._findPath with a correct exports resolver --- + + // Helper to check if a path exists and is a file + function isFile(p: string): boolean { + try { + const stat = fs.statSync(p); + return stat.isFile(); + } catch { + return false; + } + } + + // Recursive exports resolver following Node's conditional exports + function resolvePackageExports(exports: any, conditions: string[]): string | null { + if (typeof exports === "string") { + return exports; + } + if (!exports || typeof exports !== "object") { + return null; + } + // Handle "." subpath first (for bare module, we always start with ".") + const subExports = exports["."] ?? exports; + return resolveExportsObject(subExports, conditions); + } + + function resolveExportsObject(obj: any, conditions: string[]): string | null { + if (typeof obj === "string") { + return obj; + } + if (!obj || typeof obj !== "object") { + return null; + } + // Iterate over keys in order; if a key matches a condition, take that branch + for (const key of Object.keys(obj)) { + if (conditions.includes(key)) { + const value = obj[key]; + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object") { + const nested = resolveExportsObject(value, conditions); + if (nested) return nested; + } + } + } + // If no matching condition, try "default" if not already tried + if (!conditions.includes("default") && obj.default) { + const def = obj.default; + if (typeof def === "string") return def; + if (def && typeof def === "object") { + return resolveExportsObject(def, conditions); + } + } + return null; + } + + let _origFindPath: ((...args: any[]) => any) | null = null; + try { + const Module = require("module"); + const nodePath = require("path"); + const capturedOrigFindPath = (Module as any)._findPath; + if (typeof capturedOrigFindPath === "function") { + _origFindPath = capturedOrigFindPath; + (Module as any)._findPath = function nexeFindPath(this: any, ...args: any[]) { + // Try original first + let result: any; + try { + result = capturedOrigFindPath.apply(this, args); + } catch (_) { + result = false; + } + if (result) return result; + + const request: string = args[0]; + // Only handle bare module specifiers + if (!request || request[0] === "." || nodePath.isAbsolute(request)) { + return result; + } + + // Use snapshot node_modules as the base for all bare modules + const basePath = "/snapshot/node_modules"; + const pkgJsonPath = nodePath.posix.join(basePath, request, "package.json"); + log(`_findPath trying snapshot package.json: ${pkgJsonPath}`); + let pkg: any; + try { + pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8") as string); + } catch (e) { + return result; // not found in snapshot + } + + // 1. Try exports field with appropriate conditions (CJS, Node) + if (pkg.exports) { + const conditions = ["require", "node", "default"]; + const exportTarget = resolvePackageExports(pkg.exports, conditions); + if (exportTarget) { + const candidate = nodePath.posix.join(basePath, request, exportTarget); + log(`_findPath trying exports target: ${candidate}`); + if (isFile(candidate)) return candidate; + // Try with extensions if the export target doesn't have one + for (const ext of [".js", ".json", ".node"]) { + const withExt = candidate + ext; + if (isFile(withExt)) return withExt; + } + } + } + + // 2. Fallback to main field + let main = "index.js"; + if (typeof pkg.main === "string") { + main = pkg.main; + } + + // Normalize main path + if (main.startsWith("./")) main = main.slice(2); + if (!main || main === ".") main = "index.js"; + if (main.endsWith("/")) main += "index.js"; + + const mainPath = nodePath.posix.join(basePath, request, main); + log(`_findPath trying main: ${mainPath}`); + + // Detailed check for main + try { + const st = fs.statSync(mainPath); + if (st.isFile()) return mainPath; + if (st.isDirectory()) { + const idxPath = nodePath.posix.join(mainPath, "index.js"); + if (isFile(idxPath)) return idxPath; + } + } catch (e) { + // mainPath may not exist; proceed to extension checks + } + + // 3. Try with extensions + for (const ext of [".js", ".json", ".node"]) { + const withExt = mainPath + ext; + if (isFile(withExt)) return withExt; + } + + // 4. Common patterns: if main is missing, look for index.js in the package root + const fallbackIndex = nodePath.posix.join(basePath, request, "index.js"); + if (isFile(fallbackIndex)) return fallbackIndex; + + // 5. If package uses a dist subdirectory (like axios), try dist/index.js or dist/.js + const distIndex = nodePath.posix.join(basePath, request, "dist", "index.js"); + if (isFile(distIndex)) return distIndex; + const distMain = nodePath.posix.join(basePath, request, "dist", request + ".js"); + if (isFile(distMain)) return distMain; + + return result; + }; + } + } catch (_) { + // Patching _findPath failed, ignore + } lazyRestoreFs = () => { Object.assign(fs, originalFsMethods); Object.assign(patches, originalPatches); - lazyRestoreFs = () => { }; + if (_origFindPath !== null) { + try { + const Module = require("module"); + (Module as any)._findPath = _origFindPath; + } catch (_) {} + _origFindPath = null; + } + lazyRestoreFs = () => {}; }; } diff --git a/src/options.ts b/src/options.ts index ef067dd..4f948e5 100644 --- a/src/options.ts +++ b/src/options.ts @@ -16,6 +16,15 @@ export interface NexePatch { (compiler: NexeCompiler, next: () => Promise): Promise; } +export interface ModuleBundleRule { + include?: string[]; + exclude?: string[]; +} + +export interface BundleRules { + [moduleName: string]: ModuleBundleRule; +} + export interface NexeOptions { build: boolean; input: string; @@ -36,6 +45,7 @@ export interface NexeOptions { rc: { [key: string]: string }; enableNodeCli: boolean; bundle: boolean | string; + bundleRules?: BundleRules; patches: (string | NexePatch)[]; plugins: (string | NexePatch)[]; native: any; diff --git a/src/patches/boot-nexe.ts b/src/patches/boot-nexe.ts index bc39803..5197319 100644 --- a/src/patches/boot-nexe.ts +++ b/src/patches/boot-nexe.ts @@ -1,6 +1,6 @@ const fs = require("fs"), fd = fs.openSync(process.execPath, "r"), - stat = fs.statSync(fd), + stat = fs.fstatSync(fd), tailSize = Math.min(stat.size, 16000), tailWindow = Buffer.alloc(tailSize), match = "", diff --git a/src/patches/fs-stat-debug.ts b/src/patches/fs-stat-debug.ts new file mode 100644 index 0000000..26a659b --- /dev/null +++ b/src/patches/fs-stat-debug.ts @@ -0,0 +1,23 @@ +import { NexeCompiler } from '../compiler' + +export default async function fsStat(compiler: NexeCompiler, next: () => Promise) { + await compiler.replaceInFileAsync( + 'lib/fs.js', + 'function statSync(path, options = { bigint: false, throwIfNoEntry: true }) {', + `function statSync(path, options = { bigint: false, throwIfNoEntry: true }) { + // If Node passes a file descriptor, we must fstat the REAL fs + if (typeof path === 'number') { + try { + return fstatSync(path, options); + } catch (e) { + if (options.throwIfNoEntry) { + throw e; + } + return options.bigint ? BigInt(-1) : -1; + } + } +` + ) + + return next() +} diff --git a/src/patches/index.ts b/src/patches/index.ts index 6acb98b..0ce60d9 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -6,7 +6,10 @@ import flags from "./flags"; import ico from "./ico"; import rc from "./node-rc"; import snapshot from "./snapshot"; +import fsStat from "./fs-stat-debug"; +// Patches are applied in order, so if a patch depends on another patch, it should be listed after the patch it depends on. +// For example, if a patch modifies the output of gyp, it should be listed after gyp in this array. const patches = [gyp, bootNexe, buildFixes, cli, flags, ico, rc, snapshot]; export default patches; diff --git a/src/steps/bundle.ts b/src/steps/bundle.ts index 5b1a1b2..cc95e2a 100644 --- a/src/steps/bundle.ts +++ b/src/steps/bundle.ts @@ -1,8 +1,122 @@ import { NexeCompiler, NexeError } from "../compiler"; -import { resolve, relative } from "path"; +import { resolve, relative, sep, join } from "path"; import resolveFiles, { resolveSync } from "resolve-dependencies"; import { dequote, STDIN_FLAG, semverGt } from "../util"; import { Readable } from "stream"; +import { minimatch } from "minimatch"; +import { readdirSync, statSync } from "fs"; + +type ModuleRule = { + include?: string[]; + exclude?: string[]; +}; + +function getModuleName(file: string): string | null { + const parts = file.split(/[\\/]/); + const idx = parts.lastIndexOf("node_modules"); + if (idx === -1) return null; + + const name = parts[idx + 1]; + if (!name) return null; + + return name.startsWith("@") + ? parts.slice(idx + 1, idx + 3).join("/") + : name; +} + +function getModuleBasePath(file: string, moduleName: string): string | null { + const parts = file.split(/[\\/]/); + const idx = parts.lastIndexOf("node_modules"); + if (idx === -1) return null; + + const baseParts = parts.slice(0, idx + 1); + if (moduleName.startsWith("@")) { + baseParts.push(...moduleName.split("/")); + } else { + baseParts.push(moduleName); + } + + return baseParts.join(sep); +} + +function getModuleRelativePath(file: string, moduleName: string): string { + const normalizedFile = file.replace(/\\/g, '/'); + const normalizedModuleName = moduleName.replace(/\\/g, '/'); + + const marker = `node_modules/${normalizedModuleName}/`; + const idx = normalizedFile.indexOf(marker); + + if (idx === -1) { + return ""; + } + + return normalizedFile.slice(idx + marker.length); +} + +function getAllFilesInModule(basePath: string): string[] { + const files: string[] = []; + + function walk(dir: string) { + try { + const entries = readdirSync(dir); + + for (const entry of entries) { + const fullPath = join(dir, entry); + try { + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + walk(fullPath); + } else if (stat.isFile()) { + files.push(fullPath); + } + } catch (err) { + // Skip files we can't read + } + } + } catch (err) { + // Skip directories we can't read + } + } + + walk(basePath); + return files; +} + +function shouldIncludeFile( + file: string, + moduleName: string, + rule: ModuleRule +): boolean { + const rel = getModuleRelativePath(file, moduleName); + + if (!rel) { + return false; + } + + // Always include package.json + if (rel === "package.json") { + return true; + } + + // If there's an include list, file MUST match it + if (rule.include && rule.include.length > 0) { + const matchesInclude = rule.include.some((g) => minimatch(rel, g)); + if (!matchesInclude) { + return false; + } + } + + // Check exclude patterns + if (rule.exclude && rule.exclude.length > 0) { + const matchesExclude = rule.exclude.some((g) => minimatch(rel, g)); + if (matchesExclude) { + return false; + } + } + + return true; +} function getStdIn(stdin: Readable): Promise { let out = ""; @@ -25,7 +139,11 @@ function getStdIn(stdin: Readable): Promise { } export default async function bundle(compiler: NexeCompiler, next: any) { - const { bundle: doBundle, cwd, input: inputPath } = compiler.options; + const { bundle: doBundle, cwd, input: inputPath, bundleRules } = compiler.options; + + const rules: Record = + bundleRules && Object.keys(bundleRules).length ? bundleRules : {}; + let input = inputPath; compiler.entrypoint = "./" + relative(cwd, input); @@ -66,13 +184,11 @@ export default async function bundle(compiler: NexeCompiler, next: any) { const step = compiler.log.step("Resolving dependencies..."); - const { files, warnings } = await resolveFiles( - input, - ...Object.keys(compiler.bundle.list).filter( - (x) => x.endsWith(".js") || x.endsWith(".mjs") - ), - { cwd, expand: "variable", loadContent: false } - ); + const { files, warnings } = await resolveFiles(input, { + cwd, + expand: "variable", + loadContent: false, + }); if ( warnings.filter( @@ -82,14 +198,97 @@ export default async function bundle(compiler: NexeCompiler, next: any) { throw new NexeError("Parsing Error:\n" + warnings.join("\n")); } - //TODO: warnings.forEach((x) => console.log(x)) + // If no bundle rules, use original behavior + if (Object.keys(rules).length === 0) { + const pkgJsonsToAdd = new Set(); + await Promise.all( + Object.entries(files).map(([key]) => { + step.log(`Including dependency: ${key}`); + // Collect the package.json for each bundled module so Node can + // resolve the correct entry point (main/exports) at runtime. + const moduleName = getModuleName(key); + if (moduleName) { + const basePath = getModuleBasePath(key, moduleName); + if (basePath) { + pkgJsonsToAdd.add(join(basePath, "package.json")); + } + } + return compiler.addResource(key); + }) + ); + + await Promise.all( + Array.from(pkgJsonsToAdd).map((pkgJson) => { + try { + statSync(pkgJson); + step.log(`Including package.json: ${pkgJson}`); + return compiler.addResource(pkgJson); + } catch (e) { + // package.json doesn't exist on disk, skip + } + }) + ); + + return next(); + } + + // Track modules with explicit bundle rules + const modulesWithRules = new Map(); + + for (const file of Object.keys(files)) { + const moduleName = getModuleName(file); + if (moduleName && rules[moduleName] && !modulesWithRules.has(moduleName)) { + const basePath = getModuleBasePath(file, moduleName); + if (basePath) { + modulesWithRules.set(moduleName, basePath); + } + } + } + + // Collect files to include + const filesToInclude = new Set(); + + // Add all files that AREN'T from modules with rules (preserve original behavior) + for (const file of Object.keys(files)) { + const moduleName = getModuleName(file); + + // If this file is from a module with rules, skip it here + if (moduleName && modulesWithRules.has(moduleName)) { + continue; + } + + // All other files get included as normal + filesToInclude.add(file); + } + + // For modules WITH rules: scan and filter + for (const [moduleName, basePath] of modulesWithRules) { + const rule = rules[moduleName]; + + step.log(`Scanning ${moduleName} with bundle rules...`); + + const moduleFiles = getAllFilesInModule(basePath); + + for (const file of moduleFiles) { + const rel = getModuleRelativePath(file, moduleName); + + if (shouldIncludeFile(file, moduleName, rule)) { + filesToInclude.add(file); + step.log(`Including ${rel} from ${moduleName}`); + } else { + step.log(`Excluding ${rel} from ${moduleName}`); + } + } + } + + // Add all files await Promise.all( - Object.entries(files).map(([key]) => { - step.log(`Including dependency: ${key}`); - return compiler.addResource(key); + Array.from(filesToInclude).map((file) => { + step.log(`Including dependency: ${file}`); + return compiler.addResource(file); }) ); return next(); -} +} \ No newline at end of file