├── cli.js ├── .gitignore ├── examples ├── amd-bundle.js ├── inspect-paths.js └── browserify-bundle.js ├── LICENSE ├── package.json ├── CHANGELOG.md ├── unpack ├── recursive.js ├── amd.js ├── utils.js ├── browserify.js └── pathResolver.js ├── .github └── workflows │ └── release.yml ├── index.js ├── CONTRIBUTING.md ├── bin └── unpack-bundle.js ├── README.md ├── CONTRIBUTING.en.md ├── README.en.md └── plugins └── rename-exports.js /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Thin wrapper so existing entry points keep working. 4 | // Actual CLI implementation lives in bin/unpack-bundle.js. 5 | require("./bin/unpack-bundle"); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | *.pem 18 | 19 | # debug 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # local env files 25 | .env*.local 26 | .env 27 | 28 | # vercel 29 | .vercel 30 | 31 | # typescript 32 | *.tsbuildinfo 33 | next-env.d.ts 34 | 35 | out_test 36 | -------------------------------------------------------------------------------- /examples/amd-bundle.js: -------------------------------------------------------------------------------- 1 | // Minimal AMD bundle that demonstrates directory-like module ids and 2 | // a relative require that can be preserved when unpacking. 3 | 4 | // Entry module: app/main.js -> requires ./util/math 5 | define("examples/amd/app/main.js", function (require, module, exports) { 6 | const math = require("./util/math"); 7 | console.log("2 + 3 =", math.add(2, 3)); 8 | }); 9 | 10 | // Leaf module: app/util/math.js 11 | define("examples/amd/app/util/math.js", function (require, module, exports) { 12 | exports.add = function add(a, b) { 13 | return a + b; 14 | }; 15 | }); 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) , 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | 17 | -------------------------------------------------------------------------------- /examples/inspect-paths.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | const { unpackBundle } = require(".."); 5 | 6 | function inspectBundle(bundlePath) { 7 | const absPath = path.resolve(bundlePath); 8 | const source = fs.readFileSync(absPath, "utf8"); 9 | 10 | const modules = unpackBundle({ 11 | path: path.basename(absPath), 12 | source, 13 | }); 14 | 15 | console.log(`Bundle: ${bundlePath}`); 16 | console.log(`Modules: ${modules.length}`); 17 | 18 | modules.forEach((mod, index) => { 19 | console.log(` [${index}] ${mod.path}`); 20 | }); 21 | 22 | console.log(""); 23 | } 24 | 25 | function main() { 26 | const args = process.argv.slice(2); 27 | 28 | if (args.length === 0) { 29 | console.log("Usage: node examples/inspect-paths.js [bundle2 ...]"); 30 | console.log("Example:"); 31 | console.log( 32 | " node examples/inspect-paths.js ./examples/browserify-bundle.js ./examples/amd-bundle.js" 33 | ); 34 | return; 35 | } 36 | 37 | for (const bundlePath of args) { 38 | inspectBundle(bundlePath); 39 | } 40 | } 41 | 42 | main(); 43 | 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unpack-bundle", 3 | "version": "1.1.1", 4 | "main": "index.js", 5 | "bin": { 6 | "unpack-bundle": "./bin/unpack-bundle.js" 7 | }, 8 | "scripts": { 9 | "start": "node bin/unpack-bundle.js", 10 | "demo:paths": "node examples/inspect-paths.js ./examples/browserify-bundle.js ./examples/amd-bundle.js", 11 | "release": "standard-version" 12 | }, 13 | "keywords": [ 14 | "browserify", 15 | "amd", 16 | "bundle", 17 | "unpack", 18 | "source", 19 | "reverse", 20 | "paths" 21 | ], 22 | "author": "", 23 | "license": "ISC", 24 | "description": "Unpack Browserify/AMD JavaScript bundles into individual modules and reconstruct their file path relationships.", 25 | "files": [ 26 | "index.js", 27 | "cli.js", 28 | "bin", 29 | "unpack", 30 | "plugins", 31 | "examples", 32 | "README.md", 33 | "README.en.md", 34 | "CONTRIBUTING.md", 35 | "CONTRIBUTING.en.md" 36 | ], 37 | "dependencies": { 38 | "@babel/core": "^7.25.2", 39 | "@babel/generator": "^7.25.0", 40 | "@babel/parser": "^7.25.3", 41 | "@babel/traverse": "^7.25.3", 42 | "@babel/types": "^7.25.2" 43 | }, 44 | "devDependencies": { 45 | "standard-version": "^9.5.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [1.1.1](https://github.com/p0ise/unpack-bundle/compare/v1.1.0...v1.1.1) (2025-11-29) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * avoid extra parent dirs when merging FileTree branches ([6ea0e3c](https://github.com/p0ise/unpack-bundle/commit/6ea0e3cb1f6559ed6e2b7f99f83e40140aae471a)) 11 | 12 | ## 1.1.0 (2025-11-28) 13 | 14 | 15 | ### Features 16 | 17 | * add CLI, Babel plugin, and package metadata ([7308a80](https://github.com/p0ise/unpack-bundle/commit/7308a806ddfaa90572dd72041a33f65ca2292069)) 18 | * unpack browserify and AMD bundles into modules ([0095f2e](https://github.com/p0ise/unpack-bundle/commit/0095f2e20c0d4425054980eec40a0d91c9c9f332)) 19 | 20 | # Changelog 21 | 22 | All notable changes to this project will be documented in this file. 23 | 24 | This project uses [standard-version](https://github.com/conventional-changelog/standard-version) 25 | to generate and maintain the changelog based on Conventional Commits. 26 | 27 | The first release using this automated changelog will be published as `v1.0.0` 28 | or a later version, depending on future changes. 29 | -------------------------------------------------------------------------------- /examples/browserify-bundle.js: -------------------------------------------------------------------------------- 1 | // Minimal Browserify-style bundle that demonstrates how unpack-bundle 2 | // reconstructs partial path relationships from relative require(...) calls. 3 | // 4 | // Path structure (what we want to reverse engineer): 5 | // [entry] -> requires "./lib/add" 6 | // lib/add -> requires "../shared/log" 7 | // shared/log 8 | 9 | (function (modules, cache, entries) { 10 | // Runtime implementation is not needed for static unpacking. 11 | })( 12 | { 13 | // Entry module: requires a file in ./lib 14 | 1: [ 15 | function (require, module, exports) { 16 | const add = require("./lib/add"); 17 | console.log("sum:", add(1, 2)); 18 | }, 19 | { "./lib/add": 2 }, 20 | ], 21 | 22 | // ./lib/add 23 | 2: [ 24 | function (require, module, exports) { 25 | const log = require("../shared/log"); 26 | module.exports = function add(a, b) { 27 | log("adding", a, b); 28 | return a + b; 29 | }; 30 | }, 31 | { "../shared/log": 3 }, 32 | ], 33 | 34 | // ../shared/log 35 | 3: [ 36 | function (require, module, exports) { 37 | module.exports = function log(message, a, b) { 38 | console.log("[log]", message, a, b); 39 | }; 40 | }, 41 | {}, 42 | ], 43 | }, 44 | {}, 45 | [1] 46 | ); 47 | -------------------------------------------------------------------------------- /unpack/recursive.js: -------------------------------------------------------------------------------- 1 | const parser = require("@babel/parser"); 2 | const { isBrowserifyBundle, unpackBrowserify } = require("./browserify"); 3 | const { isAmdBundle, unpackAmd } = require("./amd"); 4 | 5 | function recursiveUnpack(module, seen = new Set(), ctx = {}) { 6 | const allModules = []; 7 | if (!module || seen.has(module.path)) return allModules; 8 | seen.add(module.path); 9 | 10 | try { 11 | const ast = parser.parse(module.source, { }); 12 | const body = ast.program.body; 13 | let submodules = []; 14 | 15 | if (isBrowserifyBundle(body)) { 16 | // 在多输入场景下共享 Browserify 的解析上下文以统一依赖路径解析 17 | ctx.browserifySession = ctx.browserifySession || {}; 18 | submodules = unpackBrowserify(module.source, ctx.browserifySession); 19 | } else if (isAmdBundle(body)) { 20 | submodules = unpackAmd(module.source); 21 | } 22 | 23 | // 如果成功解包出子模块,则不保留原包,只保留子模块 24 | if (submodules.length > 0) { 25 | for (const innerModule of submodules) { 26 | const deeperModules = recursiveUnpack(innerModule, seen, ctx); 27 | allModules.push(...deeperModules); 28 | } 29 | } else { 30 | // 如果没有子模块,则保留原模块(叶子模块) 31 | allModules.push(module); 32 | } 33 | } catch { 34 | // 解析失败时,保留原模块 35 | console.warn(`Failed to parse module at ${module.path}, keeping as is.`); 36 | allModules.push(module); 37 | } 38 | 39 | return allModules; 40 | } 41 | 42 | module.exports = { recursiveUnpack }; 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | # Manual trigger from GitHub UI 5 | workflow_dispatch: 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Configure git user 21 | run: | 22 | git config user.name "github-actions[bot]" 23 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 24 | 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: "20" 29 | cache: "npm" 30 | registry-url: "https://registry.npmjs.org" 31 | 32 | - name: Install dependencies 33 | run: npm ci 34 | 35 | - name: Run tests (if any) 36 | run: | 37 | npm test || echo "No tests defined or tests failed, continuing for release." 38 | 39 | - name: Generate release and changelog 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | run: | 43 | npx standard-version 44 | git push --follow-tags origin HEAD:main 45 | 46 | - name: Read version from package.json 47 | id: get_version 48 | run: echo "version=v$(node -p \"require('./package.json').version\")" >> $GITHUB_OUTPUT 49 | 50 | - name: Create GitHub Release (auto notes) 51 | uses: actions/create-release@v1 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | with: 55 | tag_name: ${{ steps.get_version.outputs.version }} 56 | release_name: ${{ steps.get_version.outputs.version }} 57 | generate_release_notes: true 58 | -------------------------------------------------------------------------------- /unpack/amd.js: -------------------------------------------------------------------------------- 1 | const parser = require("@babel/parser"); 2 | const t = require("@babel/types"); 3 | const { restoreParams } = require("./utils"); 4 | 5 | function isAmdBundle(body) { 6 | const requireFileSuffix = [".js", ".ts"]; 7 | 8 | for (const node of body) { 9 | if ( 10 | t.isExpressionStatement(node) && 11 | t.isCallExpression(node.expression) && 12 | t.isIdentifier(node.expression.callee, { name: "define" }) 13 | ) { 14 | const args = node.expression.arguments; 15 | if ( 16 | args.length === 2 && 17 | t.isStringLiteral(args[0]) && 18 | t.isFunctionExpression(args[1]) 19 | ) { 20 | const id = args[0].value; 21 | if (requireFileSuffix.some((suffix) => id.endsWith(suffix))) { 22 | return true; 23 | } 24 | } 25 | } 26 | } 27 | 28 | return false; 29 | } 30 | 31 | function unpackAmd(code) { 32 | const ast = parser.parse(code, {}); 33 | const body = ast.program.body; 34 | const modules = []; 35 | for (const node of body) { 36 | if ( 37 | t.isExpressionStatement(node) && 38 | t.isCallExpression(node.expression) && 39 | t.isIdentifier(node.expression.callee, { name: "define" }) 40 | ) { 41 | const args = node.expression.arguments; 42 | if ( 43 | args.length === 2 && 44 | t.isStringLiteral(args[0]) && 45 | t.isFunctionExpression(args[1]) 46 | ) { 47 | const id = args[0].value; 48 | if (!id.endsWith(".js") && !id.endsWith(".ts")) continue; 49 | const funcNode = args[1]; 50 | const rawCode = code.slice(funcNode.start, funcNode.end); 51 | const restored = restoreParams(rawCode); 52 | 53 | modules.push({ 54 | path: id, 55 | source: restored 56 | }); 57 | } 58 | } 59 | } 60 | 61 | return modules; 62 | } 63 | 64 | module.exports = { 65 | isAmdBundle, 66 | unpackAmd, 67 | }; 68 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { recursiveUnpack } = require("./unpack/recursive"); 2 | 3 | // 简单的路径评分:尽量避免 parent- 开头的占位目录,优先选择更“干净”的真实路径 4 | function pathScore(p) { 5 | const segments = String(p).split("/"); 6 | const placeholderCount = segments.filter((s) => s.startsWith("parent")).length; 7 | // 占位目录越多,分数越低;路径越短,稍微加一点分 8 | return -placeholderCount * 10 - segments.length; 9 | } 10 | 11 | /** 12 | * 解包任意 JavaScript bundle(支持 Browserify / AMD) 13 | * @param {Object} initialModule { path, source } 14 | * @returns {Array} 标准模块列表 [{ path, source }] 15 | */ 16 | function unpackBundle(initialModule) { 17 | return unpackBundles([initialModule]); 18 | } 19 | 20 | /** 21 | * 解包多个 JavaScript bundle(支持 Browserify / AMD) 22 | * 多输入场景会跨输入去重,同一依赖关系可避免重复输出。 23 | * @param {Array<{path:string, source:string}>} initialModules 24 | * @returns {Array<{path:string, source:string}>} 25 | */ 26 | function unpackBundles(initialModules = []) { 27 | const seenRecursive = new Set(); // 跨初始输入共享,避免重复递归处理 28 | const ctx = {}; // 共享上下文(如 Browserify 解析会话) 29 | const aggregated = []; 30 | 31 | for (const mod of initialModules) { 32 | if (!mod || !mod.path || !mod.source) continue; 33 | const sub = recursiveUnpack(mod, seenRecursive, ctx); 34 | aggregated.push(...sub); 35 | } 36 | 37 | // 先按“逻辑模块 id”去重(Browserify 场景下由 unpack/browserify 暴露的 id 字段) 38 | const byLogicalId = new Map(); // id(string) -> { id, path, source } 39 | const rest = []; // 其它没有 id 的模块(AMD / 非 Browserify) 40 | 41 | for (const m of aggregated) { 42 | if (!m || !m.path || !m.source) continue; 43 | if (m.id != null) { 44 | const key = String(m.id); 45 | const existing = byLogicalId.get(key); 46 | if (!existing || pathScore(m.path) > pathScore(existing.path)) { 47 | byLogicalId.set(key, m); 48 | } 49 | } else { 50 | rest.push(m); 51 | } 52 | } 53 | 54 | // 再按最终 path 去重,避免同一路径被多个模块占用 55 | const seenPaths = new Set(); 56 | const result = []; 57 | 58 | for (const m of [...byLogicalId.values(), ...rest]) { 59 | if (!m.path || !m.source) continue; 60 | if (seenPaths.has(m.path)) continue; 61 | seenPaths.add(m.path); 62 | // 对外仍只暴露 { path, source },内部 id 字段不向调用方泄露 63 | result.push({ path: m.path, source: m.source }); 64 | } 65 | 66 | return result; 67 | } 68 | 69 | module.exports = { unpackBundle, unpackBundles }; 70 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 贡献指南 2 | 3 | [简体中文](CONTRIBUTING.md) · [English](CONTRIBUTING.en.md) 4 | 5 | 感谢你愿意为本项目贡献代码或反馈问题! 6 | 7 | 本项目是一个用于解包 JavaScript Bundle(Browserify / AMD)的 Node.js 小工具, 8 | 目标是:代码简单、行为稳定、易于理解和维护。 9 | 10 | ## 如何报告 Bug 11 | 12 | 1. 先在 issue 中搜索,看是否已经有人报告过类似问题。 13 | 2. 如果没有,请尽量提供: 14 | - Node.js 版本 15 | - 操作系统信息 16 | - 尝试解包的 bundle 文件(或最小可复现的示例) 17 | - 运行的命令及完整输出 18 | 19 | 如果你能把 bundle 精简成“最小仍能复现问题”的版本,会非常有帮助。 20 | 21 | ## 如何提交修改 22 | 23 | 1. Fork 仓库,并基于 `develop` 分支创建新分支: 24 | 25 | - `feature/...`:新功能 26 | - `fix/...`:缺陷修复 27 | - `chore/...`:工具、CI、文档等维护性改动 28 | 29 | 2. 修改代码时尽量让每个提交**聚焦一个主题**。 30 | 3. 在提交前运行至少一条验证命令: 31 | 32 | - 如果定义了 `test` 脚本: 33 | 34 | ```bash 35 | npm test 36 | ``` 37 | 38 | - 最少也要跑一下 README 中的 CLI 示例,例如: 39 | 40 | ```bash 41 | node bin/unpack-bundle.js ./examples/browserify-bundle.js ./out/browserify 42 | node bin/unpack-bundle.js ./examples/amd-bundle.js ./out/amd 43 | ``` 44 | 45 | 4. 向 `develop` 分支发起 Pull Request,并在描述中说明: 46 | - 这次修改做了什么; 47 | - 为什么要做这个修改; 48 | - 是否存在潜在的兼容性变化。 49 | 50 | ## Git 工作流与历史 51 | 52 | - 分支角色: 53 | - `main`:稳定分支,用于打发布 tag(如 `v1.0.0`、`v1.0.1`)。 54 | - `develop`:日常开发的集成分支。 55 | - `feature/*`:新功能(例如 `feature/add-path-examples`)。 56 | - `fix/*`:缺陷修复(例如 `fix/handle-cross-bundle-id-conflicts`)。 57 | - `chore/*`:工具、CI、文档等维护工作。 58 | 59 | - 推荐工作流: 60 | 1. 从 `develop` 起步(`git checkout develop`)。 61 | 2. 创建主题分支(`feature/...`、`fix/...` 或 `chore/...`)。 62 | 3. 保持提交小而专一,避免提交生成的构建产物。 63 | 4. 在发起 PR 前,将分支 rebase 到最新的 `develop`。 64 | 5. 合并时尽量使用 squash merge 或少量结构清晰的提交。 65 | 66 | - 提交信息约定(类似 Conventional Commits): 67 | - 使用前缀: 68 | - `feat: add path reconstruction examples` 69 | - `fix: handle empty browserify module objects` 70 | - `docs: document CLI logging options` 71 | - `refactor: simplify FileTree merge logic` 72 | - `test: add examples-based smoke tests` 73 | - `chore: update dependencies` 74 | - 使用英文、祈使语(用 “add”,不要用 “added”)。 75 | - 修修补补的提交在合并前尽量 squash 成一个。 76 | 77 | - 发布(Release): 78 | - 项目使用 `standard-version` 自动维护版本号和 `CHANGELOG.md`。 79 | - 提交信息遵循上述前缀,便于自动工具正确分类变更。 80 | - 有两种发布方式: 81 | 82 | 1. **本地通过 npm 脚本发布** 83 | 84 | - 确认 `main` 已包含所有需要发布的提交,且工作区干净; 85 | - 然后执行: 86 | 87 | ```bash 88 | npm run release 89 | git push --follow-tags origin main 90 | npm publish 91 | ``` 92 | 93 | 2. **通过 GitHub Actions(推荐仓库托管在 GitHub 时使用)** 94 | 95 | - 打开仓库页面,进入 **Actions → Release**; 96 | - 点击 **Run workflow** 触发发布; 97 | - 工作流会自动: 98 | - 安装依赖; 99 | - 运行测试(如未定义测试则给出提示但不中断发布); 100 | - 运行 `standard-version`,更新版本号和 `CHANGELOG.md`; 101 | - 将发布提交和 tag 推回 `main`; 102 | - 创建带自动 Release Notes 的 GitHub Release。 103 | - 然后你可以在本地或单独的 CI 步骤中使用新的 tag 手动发布到 npm(如果需要)。 104 | 105 | ## 代码风格 106 | 107 | - 参考现有代码风格,保持一致; 108 | - 更倾向于小而专一的函数,而不是超长函数; 109 | - 只在必要时添加注释,解释不明显的逻辑,而不是陈述显而易见的事情。 110 | 111 | ## 测试 112 | 113 | - 修 Bug 或加功能时,请一并补充或更新测试(如果项目未来增加测试基础设施); 114 | - 测试应保持体量小、可重复、运行快速。 115 | 116 | ## 行为准则(Code of conduct) 117 | 118 | - 在任何交流中保持尊重和建设性; 119 | - 友善对待其他使用者和贡献者; 120 | - 不接受任何形式的骚扰、歧视或人身攻击。 121 | -------------------------------------------------------------------------------- /unpack/utils.js: -------------------------------------------------------------------------------- 1 | const parser = require("@babel/parser"); 2 | const traverse = require("@babel/traverse").default; 3 | const t = require("@babel/types"); 4 | 5 | function restoreParams(code) { 6 | code = "(" + code + ")"; 7 | let ast = parser.parse(code, {}); 8 | let funcPath; 9 | 10 | traverse(ast, { 11 | Program(path) { 12 | if (path.node.body.length !== 1) return; 13 | funcPath = path.get("body.0.expression"); 14 | if (funcPath.isFunctionExpression()) { 15 | let params = funcPath.node.params; 16 | 17 | // 为每个参数处理重命名 18 | const renameMap = [ 19 | { index: 0, newName: "require" }, 20 | { index: 1, newName: "module" }, 21 | { index: 2, newName: "exports" } 22 | ]; 23 | 24 | for (let { index, newName } of renameMap) { 25 | if (params.length > index && !t.isIdentifier(params[index], { name: newName })) { 26 | const paramName = params[index].name; 27 | const binding = funcPath.scope.getBinding(paramName); 28 | if (binding) { 29 | renameBeforeReassignment(funcPath, binding, paramName, newName); 30 | } 31 | } 32 | } 33 | } 34 | }, 35 | }); 36 | 37 | return funcPath 38 | .get("body.body") 39 | .map((path) => path.toString()) 40 | .join("\n"); 41 | } 42 | 43 | function renameBeforeReassignment(funcPath, binding, oldName, newName) { 44 | // 找到第一个重新赋值的代码位置 45 | let reassignmentPosition = findReassignmentPosition(funcPath, binding, oldName); 46 | 47 | // 重命名所有在重新赋值之前的引用 48 | const renameVisitor = { 49 | ReferencedIdentifier(path) { 50 | if (path.node.name === oldName && 51 | path.scope.getBindingIdentifier(oldName) === binding.identifier && 52 | isBeforeReassignment(path.node, reassignmentPosition)) { 53 | path.node.name = newName; 54 | } 55 | }, 56 | 57 | Scope(path) { 58 | if (!path.scope.bindingIdentifierEquals(oldName, binding.identifier)) { 59 | path.skip(); 60 | } 61 | }, 62 | 63 | "AssignmentExpression|Declaration|VariableDeclarator"(path) { 64 | if (path.isVariableDeclaration()) return; 65 | 66 | const ids = path.isAssignmentExpression() 67 | ? getAssignmentIdentifiers(path.node) 68 | : path.getOuterBindingIdentifiers(); 69 | 70 | for (const name in ids) { 71 | if (name === oldName && 72 | ids[name] === binding.identifier && 73 | isBeforeReassignment(ids[name], reassignmentPosition)) { 74 | ids[name].name = newName; 75 | } 76 | } 77 | } 78 | }; 79 | 80 | funcPath.get("body").traverse(renameVisitor); 81 | } 82 | 83 | function findReassignmentPosition(funcPath, binding, oldName) { 84 | let reassignmentPosition = -1; 85 | 86 | funcPath.get("body").traverse({ 87 | "AssignmentExpression|Declaration|VariableDeclarator"(path) { 88 | if (path.isVariableDeclaration()) return; 89 | const id = path.isAssignmentExpression() ? path.node.left : path.node.id; 90 | if (t.isIdentifier(id, { name: oldName }) && 91 | path.scope.getBindingIdentifier(oldName) === binding.identifier) { 92 | reassignmentPosition = path.node.start; 93 | path.stop(); 94 | } 95 | } 96 | }); 97 | 98 | return reassignmentPosition; 99 | } 100 | 101 | function getAssignmentIdentifiers(node) { 102 | const ids = {}; 103 | if (t.isIdentifier(node.left)) { 104 | ids[node.left.name] = node.left; 105 | } 106 | return ids; 107 | } 108 | 109 | function isBeforeReassignment(node, reassignmentPosition) { 110 | return reassignmentPosition === -1 || 111 | (node.start !== undefined && node.start < reassignmentPosition); 112 | } 113 | 114 | module.exports = { 115 | restoreParams, 116 | }; -------------------------------------------------------------------------------- /bin/unpack-bundle.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | const crypto = require("crypto"); 6 | const babel = require("@babel/core"); 7 | const { unpackBundles } = require(".."); 8 | 9 | // Load all babel plugins used for transformation 10 | const renameExportsPlugin = require("../plugins/rename-exports"); 11 | const plugins = [renameExportsPlugin]; 12 | 13 | // Simple CLI options parsing 14 | const rawArgs = process.argv.slice(2); 15 | const cliOptions = { 16 | logLevel: "normal", // quiet | normal | verbose 17 | }; 18 | const bundleArgs = []; 19 | 20 | for (const arg of rawArgs) { 21 | if (arg === "-q" || arg === "--quiet") { 22 | cliOptions.logLevel = "quiet"; 23 | } else if (arg === "-v" || arg === "--verbose") { 24 | cliOptions.logLevel = "verbose"; 25 | } else { 26 | bundleArgs.push(arg); 27 | } 28 | } 29 | 30 | if (bundleArgs.length < 2) { 31 | console.error( 32 | "Usage: unpack-bundle [--quiet|-q] [--verbose|-v] [bundle2 ...] " 33 | ); 34 | process.exit(1); 35 | } 36 | 37 | // Last argument is output directory, others are input bundle files 38 | const outputDir = bundleArgs[bundleArgs.length - 1]; 39 | const bundlePaths = bundleArgs.slice(0, -1); 40 | 41 | // Reduce internal noise in normal/quiet mode while keeping warnings/errors 42 | const originalConsoleInfo = console.info.bind(console); 43 | if (cliOptions.logLevel !== "verbose") { 44 | console.info = () => {}; 45 | } 46 | 47 | // Read all bundle sources 48 | const initialModules = bundlePaths.map((p) => ({ 49 | path: path.basename(p), 50 | source: fs.readFileSync(p, "utf8"), 51 | })); 52 | 53 | // Unpack modules (recursive + Browserify + AMD), dedupe across inputs 54 | const modules = unpackBundles(initialModules); 55 | 56 | if (!modules || modules.length === 0) { 57 | console.log("No modules found in the bundle."); 58 | process.exit(0); 59 | } 60 | 61 | console.log( 62 | `Unpacked ${modules.length} module(s) from ${bundlePaths.length} bundle(s).` 63 | ); 64 | console.log(`Writing files into: ${outputDir}`); 65 | 66 | const total = modules.length; 67 | const isSmallBundle = total <= 50; 68 | const maxDetailedLogs = 20; 69 | let detailedCount = 0; 70 | let errors = 0; 71 | 72 | // Write modules to target directory 73 | modules.forEach((mod, index) => { 74 | const filePath = path.posix.join(outputDir, normalizeFileName(mod.path)); 75 | try { 76 | fs.mkdirSync(path.dirname(filePath), { recursive: true }); 77 | 78 | const transformed = babel.transformSync(mod.source, { 79 | plugins, 80 | filename: mod.path, 81 | sourceType: "unambiguous", 82 | }); 83 | 84 | fs.writeFileSync(filePath, transformed.code, "utf8"); 85 | 86 | const shouldLogDetails = 87 | cliOptions.logLevel === "verbose" || 88 | (cliOptions.logLevel === "normal" && 89 | (isSmallBundle || detailedCount < maxDetailedLogs)); 90 | 91 | if (shouldLogDetails) { 92 | console.log(`✓ ${mod.path}`); 93 | console.log(` Size: ${Buffer.byteLength(mod.source, "utf8")} bytes`); 94 | console.log(` Hash: ${generateHash(mod.source)}`); 95 | console.log("----------------------------------------"); 96 | detailedCount++; 97 | } else if ( 98 | cliOptions.logLevel === "normal" && 99 | !isSmallBundle && 100 | (index + 1) % 200 === 0 101 | ) { 102 | console.log(`Progress: ${index + 1}/${total} modules written...`); 103 | } 104 | } catch (err) { 105 | errors++; 106 | console.error(`Failed to write ${mod.path}: ${err.message}`); 107 | } 108 | }); 109 | 110 | if (cliOptions.logLevel === "normal" && !isSmallBundle && detailedCount > 0) { 111 | const skipped = total - detailedCount; 112 | if (skipped > 0) { 113 | console.log( 114 | `Skipped detailed logs for ${skipped} module(s). Use --verbose to see all.` 115 | ); 116 | } 117 | } 118 | 119 | console.log( 120 | `Done. Wrote ${total - errors} module(s)` + 121 | (errors ? ` (${errors} error(s))` : "") + 122 | "." 123 | ); 124 | 125 | // Restore original console.info in case this process is embedded 126 | console.info = originalConsoleInfo; 127 | 128 | // Ensure file names always have .js suffix 129 | function normalizeFileName(filePath) { 130 | return filePath.endsWith(".js") ? filePath : `${filePath}.js`; 131 | } 132 | 133 | // Generate MD5 hash for content 134 | function generateHash(content) { 135 | return crypto.createHash("md5").update(content).digest("hex"); 136 | } 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # unpack-bundle 2 | 3 | [简体中文](README.md) · [English](README.en.md) 4 | 5 | 一个用于**解包 JavaScript Bundle** 的小型 Node.js 工具,支持: 6 | 7 | - Browserify 打包 8 | - 简单 AMD 形式(`define("xxx.js", function (require, module, exports) { ... })`) 9 | 10 | 它既可以作为 **命令行工具(CLI)** 使用,也可以作为 **库** 集成到你的脚本中。 11 | 12 | ## 安装 13 | 14 | 全局安装(用于命令行): 15 | 16 | ```bash 17 | npm install -g unpack-bundle 18 | ``` 19 | 20 | 作为项目依赖安装(用于代码中调用): 21 | 22 | ```bash 23 | npm install unpack-bundle --save-dev 24 | ``` 25 | 26 | ## CLI 使用方式 27 | 28 | ```bash 29 | unpack-bundle [--quiet|-q] [--verbose|-v] [bundle2 ...] 30 | ``` 31 | 32 | - `bundleX`:输入的打包文件(支持 Browserify / AMD)。 33 | - `outputDir`:解包后模块输出目录。 34 | - `--quiet` / `-q`:静默模式,只输出汇总和错误,不打印每个模块的详细信息。 35 | - `--verbose` / `-v`:详细模式,打印全部内部信息和每个模块的详细日志。 36 | 37 | 示例: 38 | 39 | ```bash 40 | unpack-bundle ./examples/browserify-bundle.js ./out/browserify 41 | unpack-bundle ./examples/amd-bundle.js ./out/amd 42 | ``` 43 | 44 | 上述命令会: 45 | 46 | - 解析 bundle; 47 | - 自动识别 Browserify / AMD 模块; 48 | - 递归解包嵌套 bundle; 49 | - 在多输入场景下对模块进行**去重**; 50 | - 利用模块 id 和相对 `require(...)` 调用,**尽可能还原文件路径结构**; 51 | - 将每个模块写为单独的 `.js` 文件到 `./out/...`。 52 | 53 | 默认情况下,CLI 会: 54 | 55 | - 对部分模块输出:路径、体积、MD5 哈希; 56 | - 对大 bundle,按一定间隔输出进度; 57 | - 你可以通过 `--verbose` 查看所有模块的详细信息。 58 | 59 | ## 库 API 60 | 61 | ```js 62 | const { unpackBundle, unpackBundles } = require("unpack-bundle"); 63 | 64 | // 解包单个 bundle 65 | const modules = unpackBundle({ 66 | path: "bundle.js", 67 | source: bundleCodeString, 68 | }); 69 | 70 | // 解包多个 bundle(跨输入去重) 71 | const allModules = unpackBundles([ 72 | { path: "bundle1.js", source: code1 }, 73 | { path: "bundle2.js", source: code2 }, 74 | ]); 75 | 76 | // 返回结果格式: 77 | // [ 78 | // { path: "path/to/moduleA.js", source: "module source code..." }, 79 | // { path: "path/to/moduleB.js", source: "..." }, 80 | // ... 81 | // ] 82 | ``` 83 | 84 | ### 支持的打包格式 85 | 86 | - 标准 Browserify wrapper 形式的 bundle。 87 | - 简单 AMD 形式: 88 | 89 | ```js 90 | define("path/to/module.js", function (require, module, exports) { 91 | // module body 92 | }); 93 | ``` 94 | 95 | 如果输入既不是 Browserify 也不是 AMD,库会直接返回原始模块,不做强制解包。 96 | 97 | ## 路径还原示例(Path reconstruction) 98 | 99 | 本项目的一个核心能力是:根据 bundle 中的模块 id 和相对 `require(...)` 调用, 100 | 尽可能还原模块之间的**路径结构关系**。 101 | 102 | 你可以使用内置示例来观察这一点: 103 | 104 | ```bash 105 | node examples/inspect-paths.js ./examples/browserify-bundle.js ./examples/amd-bundle.js 106 | ``` 107 | 108 | 典型输出: 109 | 110 | ```text 111 | Bundle: ./examples/browserify-bundle.js 112 | Modules: 3 113 | [0] parent-1/1 114 | [1] parent-1/lib/add 115 | [2] parent-1/shared/log 116 | 117 | Bundle: ./examples/amd-bundle.js 118 | Modules: 2 119 | [0] examples/amd/app/main.js 120 | [1] examples/amd/app/util/math.js 121 | ``` 122 | 123 | 可以看到: 124 | 125 | - Browserify 示例中,入口模块 `1` 通过相对路径 `./lib/add`、`../shared/log` 逐步还原出了 126 | `parent-1/lib/add`、`parent-1/shared/log` 这种层级关系; 127 | - AMD 示例中,模块 id `examples/amd/app/main.js` / `examples/amd/app/util/math.js` 128 | 会直接作为路径使用。 129 | 130 | ## 内部工作原理(概览) 131 | 132 | - `unpack/recursive.js` 133 | 解析顶层代码,判断是 Browserify 还是 AMD,并递归下钻到内层 bundle。 134 | 135 | - `unpack/browserify.js` / `unpack/amd.js` 136 | 针对不同打包格式,把 bundle 还原成统一的模块图结构。 137 | 138 | - `unpack/pathResolver.js` 139 | 构建一个虚拟文件树(FileTree),把模块 id 与 `require(...)` 的相对路径组合起来, 140 | 尽可能推导出合理的文件路径,并处理多 bundle / 多别名等冲突情况。 141 | 142 | - `unpack/utils.js` 143 | 提供一些 AST 级别的辅助函数(如参数还原)。 144 | 145 | - `plugins/rename-exports.js` 146 | 一个 Babel 插件,CLI 在写出文件前会用它把 `exports.xxx = LocalName` 等形式的导出 147 | 尽量重命名为更接近源码语义的名字(例如 `class MyClass {}`)。 148 | 149 | ## 项目结构 150 | 151 | - `index.js`:主库入口,导出 `unpackBundle` / `unpackBundles`。 152 | - `bin/unpack-bundle.js`:CLI 实现,对应 `unpack-bundle` 命令。 153 | - `cli.js`:向后兼容的薄封装,内部转发到 `bin/unpack-bundle.js`。 154 | - `unpack/`:核心解包逻辑(Browserify / AMD / 递归解包 / 路径还原)。 155 | - `plugins/`:CLI 使用的 Babel 插件。 156 | - `examples/`:用于演示路径还原的极简 bundle 示例与辅助脚本。 157 | - `test/`:更大、更接近真实项目的 bundle 示例(主要用于开发时手动测试)。 158 | 159 | ## 开发与发布 160 | 161 | 安装依赖: 162 | 163 | ```bash 164 | npm install 165 | ``` 166 | 167 | 本地运行 CLI 示例: 168 | 169 | ```bash 170 | node bin/unpack-bundle.js ./examples/browserify-bundle.js ./out/browserify 171 | ``` 172 | 173 | 发布流程(概览): 174 | 175 | - 提交信息使用类似 `feat: ...` / `fix: ...` 的 Conventional Commits 风格; 176 | - 项目使用 `standard-version` 自动维护版本号和 `CHANGELOG.md`; 177 | - 可以在本地执行: 178 | 179 | ```bash 180 | npm run release 181 | git push --follow-tags origin main 182 | npm publish 183 | ``` 184 | 185 | - 或使用 GitHub Actions 中的 **Release** 工作流,自动: 186 | - 更新版本号和 changelog; 187 | - 创建 GitHub Release(自动生成 Release Notes)。 188 | 189 | 之后你可以根据新 tag 选择在本地或其他 CI 流程中手动执行 `npm publish`。 190 | 191 | 更详细的贡献规范与 Git 流程,请参考中文文档 `CONTRIBUTING.md`, 192 | 或英文文档 `CONTRIBUTING.en.md`。 193 | -------------------------------------------------------------------------------- /CONTRIBUTING.en.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | [简体中文](CONTRIBUTING.md) · [English](CONTRIBUTING.en.md) 4 | 5 | Thanks for your interest in contributing! 6 | 7 | This project is a small Node.js tool for unpacking JavaScript bundles 8 | (Browserify / AMD) into individual modules. The goal is to keep the code 9 | simple, well-tested, and easy to understand. 10 | 11 | ## How to report bugs 12 | 13 | 1. Search existing issues to see if your problem is already reported. 14 | 2. Include: 15 | - Node.js version 16 | - Operating system 17 | - The bundle you tried to unpack (or a minimal reproducible example) 18 | - The command you ran and full output 19 | 20 | If you can reduce the bundle to the smallest possible file that still 21 | reproduces the problem, that helps a lot. 22 | 23 | ## How to propose changes 24 | 25 | 1. Fork the repository and create a branch from `develop`: 26 | 27 | - `feature/...` for new features 28 | - `fix/...` for bug fixes 29 | - `chore/...` for maintenance and tooling 30 | 31 | 2. Make your changes and keep them focused on a single topic. 32 | 3. Run tests or example commands: 33 | 34 | - If a `test` script exists, run: 35 | 36 | ```bash 37 | npm test 38 | ``` 39 | 40 | - At minimum, run the example CLI commands from the README, for example: 41 | 42 | ```bash 43 | node bin/unpack-bundle.js ./examples/browserify-bundle.js ./out/browserify 44 | node bin/unpack-bundle.js ./examples/amd-bundle.js ./out/amd 45 | ``` 46 | 47 | 4. Open a pull request against the `develop` branch with: 48 | - A clear title (what the change does) 49 | - A short description of the motivation 50 | - Notes about any breaking changes or behavior changes 51 | 52 | ## Git workflow and history 53 | 54 | - Branch roles: 55 | - `main`: stable branch for tagged releases (`v1.0.0`, `v1.0.1`, ...). 56 | - `develop`: integration branch for day-to-day development. 57 | - `feature/*`: new features (for example `feature/add-path-examples`). 58 | - `fix/*`: bug fixes (for example `fix/handle-cross-bundle-id-conflicts`). 59 | - `chore/*`: tooling, CI, documentation, and other maintenance. 60 | 61 | - Recommended workflow: 62 | 1. Start from `develop` (`git checkout develop`). 63 | 2. Create a topic branch (`feature/...`, `fix/...`, or `chore/...`). 64 | 3. Keep commits small and focused; avoid committing generated output. 65 | 4. Rebase on top of the latest `develop` before opening a pull request. 66 | 5. Prefer squash-merge or a small number of well-structured commits. 67 | 68 | - Commit message conventions: 69 | - Use a simple Conventional Commits–style prefix: 70 | - `feat: add path reconstruction examples` 71 | - `fix: handle empty browserify module objects` 72 | - `docs: document CLI logging options` 73 | - `refactor: simplify FileTree merge logic` 74 | - `test: add examples-based smoke tests` 75 | - `chore: update dependencies` 76 | - Use English and the imperative mood (“add”, not “added”). 77 | - Fix-up commits should be squashed before merging when possible. 78 | 79 | - Releases: 80 | - This project uses `standard-version` to automate versioning and changelog generation. 81 | - Use the Conventional Commits-style prefixes above so changes are categorized correctly. 82 | - Two ways to cut a release: 83 | 84 | 1. **Locally via npm script** 85 | 86 | - Ensure `main` contains all desired commits and the working tree is clean. 87 | - Run: 88 | 89 | ```bash 90 | npm run release 91 | git push --follow-tags origin main 92 | npm publish 93 | ``` 94 | 95 | 2. **Via GitHub Actions (recommended once the repo is on GitHub)** 96 | 97 | - Open the repository on GitHub and go to **Actions → Release**. 98 | - Trigger the workflow with **Run workflow**. 99 | - The workflow will: 100 | - Install dependencies. 101 | - Run tests if defined (or skip with a warning). 102 | - Run `standard-version` to bump versions and update `CHANGELOG.md`. 103 | - Push the release commit and tag back to `main`. 104 | - Create a GitHub Release with auto-generated release notes. 105 | - Publish the package to npm using the `NPM_TOKEN` repository secret. 106 | - Make sure you have set `NPM_TOKEN` in the repository secrets with an npm access token that has publish permissions. 107 | 108 | ## Code style 109 | 110 | - Use the existing style in the codebase as a reference. 111 | - Prefer small, focused functions over large ones. 112 | - Add comments only where they help understanding non-obvious logic. 113 | 114 | ## Tests 115 | 116 | - Add or update tests when fixing bugs or adding features. 117 | - Keep tests small and deterministic. The test suite should be fast. 118 | 119 | ## Code of conduct 120 | 121 | Please be respectful and constructive in all interactions. Treat other 122 | contributors and users with courtesy. Harassment, discrimination, and 123 | personal attacks are not acceptable. 124 | -------------------------------------------------------------------------------- /README.en.md: -------------------------------------------------------------------------------- 1 | # unpack-bundle 2 | 3 | [简体中文](README.md) · [English](README.en.md) 4 | 5 | Small Node.js tool to unpack JavaScript bundles (Browserify and simple AMD) into individual modules. 6 | 7 | It can be used both as a CLI and as a library. 8 | 9 | ## Installation 10 | 11 | Install globally to use the CLI: 12 | 13 | ```bash 14 | npm install -g unpack-bundle 15 | ``` 16 | 17 | Or install locally for programmatic use: 18 | 19 | ```bash 20 | npm install unpack-bundle --save-dev 21 | ``` 22 | 23 | ## CLI usage 24 | 25 | ```bash 26 | unpack-bundle [--quiet|-q] [--verbose|-v] [bundle2 ...] 27 | ``` 28 | 29 | - `bundleX` are input bundle files (Browserify or AMD bundles). 30 | - `outputDir` is the directory where unpacked modules will be written. 31 | - `--quiet` / `-q` hides per-module logs and only prints summaries and errors. 32 | - `--verbose` / `-v` prints all internal info logs and per-module details. 33 | 34 | Examples: 35 | 36 | ```bash 37 | unpack-bundle ./examples/browserify-bundle.js ./out/browserify 38 | unpack-bundle ./examples/amd-bundle.js ./out/amd 39 | ``` 40 | 41 | This will: 42 | 43 | - Parse the bundle. 44 | - Detect Browserify or AMD modules. 45 | - Recursively unpack nested bundles. 46 | - De-duplicate modules across multiple input bundles. 47 | - Reconstruct best-effort file paths from module ids and relative `require(...)` calls. 48 | - Write each module as a separate `.js` file under `./out`. 49 | 50 | By default the CLI prints basic information for some modules (size and MD5 hash) 51 | and periodic progress. Use `--verbose` to see details for every module. 52 | 53 | ## Library API 54 | 55 | ```js 56 | const { unpackBundle, unpackBundles } = require("unpack-bundle"); 57 | 58 | // Single bundle 59 | const modules = unpackBundle({ 60 | path: "bundle.js", 61 | source: bundleCodeString, 62 | }); 63 | 64 | // Multiple bundles (dedupe across inputs) 65 | const allModules = unpackBundles([ 66 | { path: "bundle1.js", source: code1 }, 67 | { path: "bundle2.js", source: code2 }, 68 | ]); 69 | 70 | // `modules` / `allModules`: 71 | // [ 72 | // { path: "path/to/moduleA.js", source: "module source code..." }, 73 | // { path: "path/to/moduleB.js", source: "..." }, 74 | // ... 75 | // ] 76 | ``` 77 | 78 | ### Supported bundle formats 79 | 80 | - Browserify bundles with the classic wrapper. 81 | - Simple AMD bundles of the form: 82 | 83 | ```js 84 | define("path/to/module.js", function (require, module, exports) { 85 | // module body 86 | }); 87 | ``` 88 | 89 | If the input is not recognized as Browserify or AMD, the library falls back to returning the original module. 90 | 91 | ## How it works (high level) 92 | 93 | - `unpack/recursive.js` parses top level code, detects Browserify or AMD, and recurses into nested bundles. 94 | - `unpack/browserify.js` and `unpack/amd.js` decode each bundle format into a normal module graph. 95 | - `unpack/pathResolver.js` builds a virtual file tree from module ids and `require(...)` paths to reconstruct reasonable file paths. 96 | - `unpack/utils.js` contains helpers used during AST transforms. 97 | - `plugins/rename-exports.js` is a Babel plugin used by the CLI to rename exported identifiers to match their export names (for example, `exports.MyClass = Foo` becomes `class MyClass { ... }` where possible). 98 | 99 | ## Path reconstruction example 100 | 101 | To see how module ids and relative `require(...)` calls are turned into 102 | best-effort file paths, you can inspect the example bundles: 103 | 104 | ```bash 105 | node examples/inspect-paths.js ./examples/browserify-bundle.js ./examples/amd-bundle.js 106 | ``` 107 | 108 | Example output: 109 | 110 | ```text 111 | Bundle: ./examples/browserify-bundle.js 112 | Modules: 3 113 | [0] parent-1/1 114 | [1] parent-1/lib/add 115 | [2] parent-1/shared/log 116 | 117 | Bundle: ./examples/amd-bundle.js 118 | Modules: 2 119 | [0] examples/amd/app/main.js 120 | [1] examples/amd/app/util/math.js 121 | ``` 122 | 123 | ## Project structure 124 | 125 | - `index.js`: main library entry, exports `unpackBundle` and `unpackBundles`. 126 | - `bin/unpack-bundle.js`: CLI implementation used by the `unpack-bundle` command. 127 | - `cli.js`: thin wrapper that forwards to `bin/unpack-bundle.js` (kept for compatibility). 128 | - `unpack/`: core unpacking logic (Browserify, AMD, recursive traversal, path resolution). 129 | - `plugins/`: Babel plugins used by the CLI. 130 | - `examples/`: minimal bundles that demonstrate how path reconstruction works. 131 | - `test/`: larger, real-world bundles used during development and manual testing. 132 | 133 | ## Development 134 | 135 | Install dependencies: 136 | 137 | ```bash 138 | npm install 139 | ``` 140 | 141 | Run the CLI directly with one of the sample bundles: 142 | 143 | ```bash 144 | node bin/unpack-bundle.js ./examples/browserify-bundle.js ./out/browserify 145 | ``` 146 | 147 | For contribution guidelines and the recommended git workflow, 148 | see `CONTRIBUTING.en.md`. 149 | 150 | ## Publishing checklist 151 | 152 | - Make sure `name`, `version`, `description`, `author`, and `license` in `package.json` match your needs. 153 | - Add a `repository` field in `package.json` pointing to your Git repository. 154 | - Optionally use the automated release script locally: 155 | 156 | ```bash 157 | npm run release 158 | git push --follow-tags origin main 159 | npm publish 160 | ``` 161 | 162 | Or use the GitHub Actions **Release** workflow once the repository is on GitHub. 163 | The workflow will update the changelog, create a GitHub Release, and publish 164 | the package to npm (when `NPM_TOKEN` is configured as a repository secret). 165 | 166 | See `CONTRIBUTING.en.md` for details on the release workflow and changelog generation. 167 | -------------------------------------------------------------------------------- /plugins/rename-exports.js: -------------------------------------------------------------------------------- 1 | module.exports = function renameExportsPlugin({ types: t }) { 2 | return { 3 | visitor: { 4 | AssignmentExpression(path, state) { 5 | const { node, scope } = path; 6 | 7 | // 检查是否为 exports.xxx = variable 格式 8 | if (!isExportsAssignment(t, node)) { 9 | return; 10 | } 11 | 12 | const exportName = node.left.property.name; 13 | const localName = node.right.name; 14 | const binding = scope.getBinding(localName); 15 | 16 | if (!binding) { 17 | return; 18 | } 19 | 20 | // 确定目标重命名 21 | const targetName = getTargetName(exportName, localName, state.file.opts.filename); 22 | 23 | if (localName !== targetName) { 24 | renameInScope(t, path, binding, localName, targetName); 25 | } 26 | }, 27 | }, 28 | }; 29 | }; 30 | 31 | /** 32 | * 检查是否为 exports.xxx = variable 格式的赋值 33 | */ 34 | function isExportsAssignment(t, node) { 35 | return ( 36 | t.isMemberExpression(node.left) && 37 | t.isIdentifier(node.left.object, { name: "exports" }) && 38 | t.isIdentifier(node.left.property) && 39 | t.isIdentifier(node.right) 40 | ); 41 | } 42 | 43 | /** 44 | * 获取目标重命名 45 | */ 46 | function getTargetName(exportName, localName, filename) { 47 | if (exportName === localName) { 48 | return localName; 49 | } 50 | 51 | if (exportName === "default") { 52 | return inferNameFromFilename(filename) || "DefaultExport"; 53 | } 54 | 55 | return exportName; 56 | } 57 | 58 | /** 59 | * 在适当的作用域范围内重命名变量 60 | */ 61 | function renameInScope(t, exportsPath, binding, localName, targetName) { 62 | const lastAssignment = findLastAssignmentBefore(t, exportsPath, binding, localName); 63 | const nextAssignment = findNextAssignmentAfter(t, exportsPath, binding, localName); 64 | 65 | const startPos = lastAssignment ? lastAssignment.end : -1; 66 | const endPos = nextAssignment ? nextAssignment.start : -1; 67 | 68 | // 重命名最后一次赋值中的标识符 69 | if (lastAssignment) { 70 | renameAssignmentTarget(lastAssignment, targetName); 71 | } 72 | 73 | // 重命名范围内的所有引用 74 | const rootScope = exportsPath.scope.getBlockParent(); 75 | rootScope.path.traverse(createRenameVisitor(binding, localName, targetName, startPos, endPos)); 76 | } 77 | 78 | /** 79 | * 重命名赋值目标 80 | */ 81 | function renameAssignmentTarget(assignment, newName) { 82 | const targetNode = assignment.isAssignmentExpression 83 | ? assignment.node.left 84 | : assignment.node.id; 85 | 86 | if (targetNode) { 87 | targetNode.name = newName; 88 | } 89 | } 90 | 91 | /** 92 | * 创建重命名访问器 93 | */ 94 | function createRenameVisitor(binding, localName, targetName, startPos, endPos) { 95 | return { 96 | ReferencedIdentifier(path) { 97 | if (shouldRenameReference(path, binding, localName, startPos, endPos)) { 98 | path.node.name = targetName; 99 | } 100 | }, 101 | 102 | Scope(path) { 103 | if (!path.scope.bindingIdentifierEquals(localName, binding.identifier)) { 104 | path.skip(); 105 | } 106 | }, 107 | 108 | "AssignmentExpression|Declaration|VariableDeclarator"(path) { 109 | if (path.isVariableDeclaration()) return; 110 | 111 | const identifiers = getIdentifiersFromPath(path, localName); 112 | 113 | for (const identifier of identifiers) { 114 | if (identifier === binding.identifier && 115 | isInRange(identifier, startPos, endPos)) { 116 | identifier.name = targetName; 117 | } 118 | } 119 | }, 120 | }; 121 | } 122 | 123 | /** 124 | * 检查是否应该重命名引用 125 | */ 126 | function shouldRenameReference(path, binding, localName, startPos, endPos) { 127 | return ( 128 | path.node.name === localName && 129 | path.scope.getBindingIdentifier(localName) === binding.identifier && 130 | isInRange(path.node, startPos, endPos) 131 | ); 132 | } 133 | 134 | /** 135 | * 从路径中获取相关标识符 136 | */ 137 | function getIdentifiersFromPath(path, localName) { 138 | if (path.isAssignmentExpression()) { 139 | return getAssignmentIdentifiers(path.node, localName); 140 | } 141 | 142 | const identifiers = path.getOuterBindingIdentifiers(); 143 | return Object.keys(identifiers) 144 | .filter(name => name === localName) 145 | .map(name => identifiers[name]); 146 | } 147 | 148 | /** 149 | * 获取赋值表达式中的标识符 150 | */ 151 | function getAssignmentIdentifiers(node, targetName) { 152 | if (node.left && node.left.name === targetName) { 153 | return [node.left]; 154 | } 155 | return []; 156 | } 157 | 158 | /** 159 | * 查找 exports 语句之前的最后一次赋值 160 | */ 161 | function findLastAssignmentBefore(t, exportsPath, binding, localName) { 162 | const assignments = findAssignments(t, exportsPath, binding, localName, true); 163 | const topLevelAssignments = filterTopLevelAssignments(assignments); 164 | 165 | return topLevelAssignments.reduce((latest, current) => { 166 | return !latest || current.end > latest.end ? current : latest; 167 | }, null); 168 | } 169 | 170 | /** 171 | * 查找 exports 语句之后的下一次赋值 172 | */ 173 | function findNextAssignmentAfter(t, exportsPath, binding, localName) { 174 | const assignments = findAssignments(t, exportsPath, binding, localName, false); 175 | const topLevelAssignments = filterTopLevelAssignments(assignments); 176 | 177 | return topLevelAssignments.reduce((earliest, current) => { 178 | return !earliest || current.start < earliest.start ? current : earliest; 179 | }, null); 180 | } 181 | 182 | /** 183 | * 查找赋值语句 184 | */ 185 | function findAssignments(t, exportsPath, binding, localName, before) { 186 | const assignments = []; 187 | const comparePos = before ? exportsPath.node.start : exportsPath.node.end; 188 | const rootScope = exportsPath.scope.getBlockParent(); 189 | 190 | rootScope.path.traverse({ 191 | "AssignmentExpression|Declaration|VariableDeclarator"(path) { 192 | if (path.isVariableDeclaration()) return; 193 | 194 | const shouldInclude = before 195 | ? path.node.start < comparePos 196 | : path.node.start > comparePos; 197 | 198 | if (!shouldInclude) return; 199 | 200 | const identifier = path.isAssignmentExpression() ? path.node.left : path.node.id; 201 | 202 | if (t.isIdentifier(identifier, { name: localName }) && 203 | path.scope.getBindingIdentifier(localName) === binding.identifier) { 204 | assignments.push({ 205 | path, 206 | node: path.node, 207 | start: path.node.start, 208 | end: path.node.end, 209 | isAssignmentExpression: path.isAssignmentExpression(), 210 | }); 211 | } 212 | }, 213 | }); 214 | 215 | return assignments; 216 | } 217 | 218 | /** 219 | * 过滤出顶层赋值语句(排除被包含的内层表达式) 220 | */ 221 | function filterTopLevelAssignments(assignments) { 222 | return assignments.filter(assignment => { 223 | return !assignments.some(other => 224 | other !== assignment && 225 | other.start <= assignment.start && 226 | other.end >= assignment.end 227 | ); 228 | }); 229 | } 230 | 231 | /** 232 | * 检查节点是否在指定范围内 233 | */ 234 | function isInRange(node, startPos, endPos) { 235 | if (node.start === undefined) return false; 236 | const afterStart = startPos === -1 || node.start >= startPos; 237 | const beforeEnd = endPos === -1 || node.start < endPos; 238 | return afterStart && beforeEnd; 239 | } 240 | 241 | /** 242 | * 从文件名推测变量名 243 | */ 244 | function inferNameFromFilename(filename) { 245 | if (!filename) return null; 246 | 247 | const base = filename.split(/[\\/]/).pop(); 248 | const name = base.replace(/\.[^/.]+$/, ""); 249 | 250 | return /^[a-zA-Z_$][\w$]*$/.test(name) ? name : null; 251 | } -------------------------------------------------------------------------------- /unpack/browserify.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const crypto = require("crypto"); 3 | const parser = require("@babel/parser"); 4 | const t = require("@babel/types"); 5 | const { restoreParams } = require("./utils"); 6 | const { FileTree } = require("./pathResolver"); 7 | 8 | function isBrowserifyBundle(body) { 9 | const nonEmptyBody = body.filter((node) => !t.isEmptyStatement(node)); 10 | if (nonEmptyBody.length === 0 || nonEmptyBody.length > 2) return false; 11 | 12 | const expression = nonEmptyBody[nonEmptyBody.length - 1]; 13 | if (!t.isExpressionStatement(expression)) return false; 14 | 15 | let funcExpr = expression.expression; 16 | if (t.isUnaryExpression(funcExpr)) { 17 | funcExpr = funcExpr.argument; 18 | } else if (t.isAssignmentExpression(funcExpr)) { 19 | funcExpr = funcExpr.right; 20 | } 21 | 22 | if (!t.isCallExpression(funcExpr)) return false; 23 | 24 | let args = funcExpr.arguments; 25 | if (args.length === 1) { 26 | const extracted = extractStandalone(args); 27 | if (extracted) { 28 | args = extracted; 29 | } 30 | } 31 | 32 | if (args.length !== 3) return false; 33 | 34 | return ( 35 | t.isObjectExpression(args[0]) && 36 | t.isObjectExpression(args[1]) && 37 | t.isArrayExpression(args[2]) 38 | ); 39 | } 40 | 41 | function ensureSession(session) { 42 | const s = session || {}; 43 | s.moduleMap = s.moduleMap || new Map(); // canonicalId -> { id, source, deps, isExternalStub } 44 | s.depFromMap = s.depFromMap || new Map(); // depId -> Set(fromIds) 45 | s.depScoreMap = s.depScoreMap || new Map(); // depId -> bestScore 46 | s.fileTree = s.fileTree || new FileTree(); 47 | // hashToCanonicalId: sourceHash -> canonicalId(跨 bundle 统一同一模块) 48 | s.hashToCanonicalId = s.hashToCanonicalId || new Map(); 49 | // idAlias: raw browserify id -> canonicalId 50 | s.idAlias = s.idAlias || new Map(); 51 | return s; 52 | } 53 | 54 | function getSourceHash(source) { 55 | return crypto.createHash("md5").update(source).digest("hex"); 56 | } 57 | 58 | function unpackBrowserify(code, session) { 59 | const ast = parser.parse(code, {}); 60 | let body = ast.program.body; 61 | body = body.filter(function (node) { 62 | return !t.isEmptyStatement(node); 63 | }); 64 | 65 | let expression; 66 | if (body.length <= 2) { 67 | expression = body[body.length - 1]; 68 | } else { 69 | return; 70 | } 71 | if (!t.isExpressionStatement(expression)) return; 72 | 73 | let func; 74 | if (t.isUnaryExpression(expression.expression)) { 75 | func = expression.expression.argument; 76 | } else if (t.isAssignmentExpression(expression.expression)) { 77 | func = expression.expression.right; 78 | } else { 79 | func = expression.expression; 80 | } 81 | if (!t.isCallExpression(func)) return; 82 | 83 | let args = func.arguments; 84 | if (args.length === 1) args = extractStandalone(args) || args; 85 | if (args.length !== 3) return; 86 | 87 | if (!t.isObjectExpression(args[0])) return; 88 | if (!t.isObjectExpression(args[1])) return; 89 | if (!t.isArrayExpression(args[2])) return; 90 | 91 | const files = args[0].properties; 92 | if (!files || files.length === 0) return []; 93 | // let cache = args[1]; 94 | // let entries = args[2].elements.map(function (e) { 95 | // return e.value; 96 | // }); 97 | 98 | const sess = ensureSession(session); 99 | const moduleMap = sess.moduleMap; // shared across calls(按 canonicalId 存储) 100 | const depFromMap = sess.depFromMap; 101 | const depScoreMap = sess.depScoreMap; 102 | const hashToCanonicalId = sess.hashToCanonicalId; 103 | const idAlias = sess.idAlias; 104 | 105 | // ===== 工具函数部分 ===== 106 | function fileNameScore(p) { 107 | if (p.endsWith(".js")) return 3; 108 | if (p.endsWith("index")) return 2; 109 | return 1; 110 | } 111 | 112 | function normalizePathByScore(path, score) { 113 | const currentScore = fileNameScore(path); 114 | if (score >= 2 && currentScore < 2) path += "/index"; 115 | if (score >= 3 && currentScore < 3) path += ".js"; 116 | return path; 117 | } 118 | 119 | 120 | function resolveDepPathConflict(depId, rawPath, fromId) { 121 | // 第一步:补上 index(如果以 / 结尾) 122 | if (rawPath.endsWith("/")) { 123 | rawPath += "index"; 124 | } 125 | 126 | // 计算当前路径的优先级得分 127 | const score = fileNameScore(rawPath); 128 | const bestScore = depScoreMap.get(depId) || 1; 129 | 130 | if (!depScoreMap.has(depId)) { 131 | depScoreMap.set(depId, score); 132 | return rawPath; 133 | } else if (score > bestScore) { 134 | depScoreMap.set(depId, score); 135 | const finalPath = normalizePathByScore(rawPath, score); 136 | 137 | // 回填所有引用过这个 depId 的模块中的路径 138 | for (const fid of depFromMap.get(depId) || []) { 139 | const mod = moduleMap.get(String(fid)); 140 | if (mod && mod.deps[depId]) { 141 | mod.deps[depId] = finalPath; 142 | } 143 | } 144 | 145 | return finalPath; 146 | } else { 147 | // 当前路径优先级较低 → 使用已有优先级补全路径 148 | return normalizePathByScore(rawPath, bestScore); 149 | } 150 | } 151 | 152 | for (const file of files) { 153 | try { 154 | const id = t.isLiteral(file.key) ? file.key.value : file.key.name; 155 | const elements = file.value.elements; 156 | if (!elements || elements.length < 2) { 157 | console.warn(`Invalid module format for id: ${id}`); 158 | continue; 159 | } 160 | 161 | const funcNode = elements[0]; 162 | const depsNode = elements[1]; 163 | 164 | const rawCode = code.slice(funcNode.start, funcNode.end); 165 | const restored = restoreParams(rawCode); 166 | 167 | // 为模块建立“逻辑 id”:使用源码 hash 将跨 bundle 的相同模块折叠到同一个 canonicalId 168 | const rawIdStr = String(id); 169 | const logicalHash = getSourceHash(restored); 170 | let canonicalId = hashToCanonicalId.get(logicalHash); 171 | if (!canonicalId) { 172 | canonicalId = rawIdStr; 173 | hashToCanonicalId.set(logicalHash, canonicalId); 174 | } 175 | idAlias.set(rawIdStr, canonicalId); 176 | 177 | const deps = {}; 178 | 179 | for (const prop of depsNode.properties) { 180 | let rawPath = t.isLiteral(prop.key) ? prop.key.value : prop.key.name; 181 | const originalPath = String(rawPath); 182 | rawPath = path.posix.normalize(originalPath); 183 | // 保留以 ./ 开头的相对路径前缀,便于后续在路径解析阶段识别相对路径 184 | // path.posix.normalize("./foo") 会变成 "foo",这里把信息加回来 185 | if (originalPath.startsWith("./") && !rawPath.startsWith(".")) { 186 | rawPath = "./" + rawPath; 187 | } 188 | 189 | let depId = prop.value && prop.value.value; 190 | 191 | if (depId === undefined) { 192 | // Browserify 在多 bundle 场景下会把跨 bundle 依赖的 id 写成 void 0, 193 | // 运行时再用 require 的字符串去全局查找实际模块: 194 | // - 相对路径(包含 ./ 或 ../)会在运行时回退到最后一段文件名 195 | // - 包名 / 绝对模块名(如 ts-md5、buffer、@o4e/cc-mobx、@jimu/basis)直接用完整名字查找 196 | // 197 | // 这里一律为 void 0 依赖生成一个“逻辑 id”,以便跨 bundle 合并: 198 | // - 相对路径:使用最后一段文件名 199 | // - 其它(包名、@scope/name 等):使用最后一段(对 ts-md5 / buffer 就是自身) 200 | if (typeof originalPath === "string") { 201 | const segments = originalPath.split("/"); 202 | depId = segments[segments.length - 1] || originalPath; 203 | 204 | let kind; 205 | if (originalPath.startsWith("./") || originalPath.startsWith("../")) { 206 | kind = "Cross-bundle dependency"; 207 | } else if (originalPath.startsWith("@")) { 208 | kind = "Scoped cross-bundle dependency"; 209 | } else { 210 | // 例如 ts-md5、buffer 这类包名 / 绝对模块名 211 | kind = "Bare cross-bundle dependency"; 212 | } 213 | 214 | console.info(`${kind}: ${originalPath} -> ${depId}`); 215 | } else { 216 | // 理论上这里很难命中,保守地当作真正外部依赖 217 | console.info(`External dependency: ${rawPath}`); 218 | continue; 219 | } 220 | } 221 | 222 | depId = String(depId); 223 | 224 | // 标准化 & 优先级冲突处理 225 | const finalPath = resolveDepPathConflict(depId, rawPath, id); 226 | deps[depId] = finalPath; 227 | 228 | // 记录依赖来源(按 canonicalId 记录,方便之后回填) 229 | if (!depFromMap.has(depId)) { 230 | depFromMap.set(depId, new Set()); 231 | } 232 | depFromMap.get(depId).add(canonicalId); 233 | } 234 | 235 | const isExternalStub = 236 | restored.trim() === "" && Object.keys(deps).length === 0; 237 | 238 | const canonicalIdStr = String(canonicalId); 239 | const existing = moduleMap.get(canonicalIdStr); 240 | 241 | if (!existing) { 242 | moduleMap.set(canonicalIdStr, { 243 | id: canonicalIdStr, 244 | source: restored, 245 | deps, 246 | isExternalStub, 247 | }); 248 | } else { 249 | // 同一个逻辑模块出现在多个 bundle 中: 250 | // 合并依赖信息;如果任一实现不是 stub,则视为非 stub。 251 | Object.assign(existing.deps, deps); 252 | existing.isExternalStub = existing.isExternalStub && isExternalStub; 253 | } 254 | } catch (err) { 255 | console.warn("Failed to parse module:", err.message); 256 | continue; 257 | } 258 | } 259 | 260 | // 构建路径树 261 | const fileTree = sess.fileTree; 262 | for (const [cid, mod] of moduleMap.entries()) { 263 | if (!fileTree.hasNode(cid)) { 264 | fileTree.addFile(cid, cid, true); 265 | } 266 | 267 | for (const [depId, depPath] of Object.entries(mod.deps)) { 268 | const depCanonicalId = idAlias.get(String(depId)) || String(depId); 269 | const depModule = moduleMap.get(depCanonicalId); 270 | if (depModule?.isExternalStub) { 271 | console.info(`Skipped external stub: ${depPath} → ${depCanonicalId}`); 272 | continue; 273 | } 274 | 275 | try { 276 | fileTree.parseRequirePath(cid, depCanonicalId, depPath); 277 | } catch (e) { 278 | console.warn( 279 | `Failed to resolve path for ${cid} → ${depPath}: ${e.message}` 280 | ); 281 | } 282 | } 283 | } 284 | 285 | // 返回结果:仅保留 path 和 source 286 | const outputModules = []; 287 | for (const [cid, mod] of moduleMap.entries()) { 288 | if (mod.isExternalStub) continue; 289 | 290 | outputModules.push({ 291 | // 暴露逻辑模块 id,便于在最终聚合阶段按模块维度去重 292 | id: cid, 293 | path: fileTree.getNodeAbsolutePath(cid), 294 | source: mod.source, 295 | }); 296 | } 297 | return outputModules; 298 | } 299 | 300 | function extractStandalone(args) { 301 | if (!t.isFunctionExpression(args[0])) return; 302 | if (args[0].body.length < 2) return; 303 | if (args[0].body.body.length < 2) return; 304 | 305 | args = args[0].body.body[1].argument; 306 | if (!t.isCallExpression(args)) return; 307 | if (!t.isCallExpression(args.callee)) return; 308 | 309 | return args.callee.arguments; 310 | } 311 | 312 | module.exports = { 313 | isBrowserifyBundle, 314 | unpackBrowserify, 315 | }; 316 | -------------------------------------------------------------------------------- /unpack/pathResolver.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | // Node class represents a file or directory in the tree 4 | class Node { 5 | constructor(id, name = "", isFile = false, isPlaceholder = false) { 6 | this.id = String(id); 7 | this.parent = null; 8 | this.children = new Map(); // Stores child nodes by their id 9 | this.name = String(name); 10 | this.isFile = isFile; 11 | this.isPlaceholder = isPlaceholder; // Indicates if the node is a placeholder 12 | } 13 | 14 | // Get the absolute path of the node 15 | getAbsolutePath() { 16 | const name = this.isPlaceholder 17 | ? compressParentPathName(this.name) 18 | : this.name; 19 | if (this.parent) { 20 | return path.posix.join(this.parent.getAbsolutePath(), name); 21 | } else { 22 | return name; 23 | } 24 | } 25 | } 26 | 27 | function compressParentPathName(name) { 28 | const parts = name.split("-"); 29 | const last = parts[parts.length - 1]; 30 | 31 | let i = 0; 32 | while (i < parts.length - 1 && parts[i] === "parent") { 33 | i++; 34 | } 35 | 36 | if (i >= 2 && i === parts.length - 1) { 37 | return `parent${i}-${last}`; 38 | } 39 | 40 | return name; // 如果不满足格式要求,返回原字符串 41 | } 42 | 43 | // FileTree class manages the nodes and their relationships 44 | class FileTree { 45 | constructor() { 46 | this.nodes = new Map(); // Stores nodes by their id 47 | } 48 | 49 | // Check if a node exists 50 | hasNode(id) { 51 | id = String(id); 52 | return this.nodes.has(id); 53 | } 54 | 55 | // Add a node to the tree 56 | addNode(id, name = "", isFile = false, isPlaceholder = false) { 57 | id = String(id); 58 | name = String(name); 59 | if (!id) { 60 | throw new Error(`Invalid id: ${id}`); 61 | } 62 | if (this.nodes.has(id)) { 63 | throw new Error(`Node with id ${id} already exists`); 64 | } 65 | const newNode = new Node(id, name, isFile, isPlaceholder); 66 | this.nodes.set(id, newNode); 67 | return newNode; 68 | } 69 | 70 | // Add a file node 71 | addFile(id, name, isPlaceholder = false) { 72 | return this.addNode(id, name, true, isPlaceholder); 73 | } 74 | 75 | // Add a directory node 76 | addDirectory(id, name, isPlaceholder = false) { 77 | return this.addNode(id, name, false, isPlaceholder); 78 | } 79 | 80 | // Add a parent directory for a given child node 81 | addParentDirectory(childNode, name = "") { 82 | const id = `parent-${childNode.id}`; 83 | const isPlaceholder = name === ""; 84 | if (isPlaceholder) name = id; 85 | const parentNode = this.addDirectory(id, name, isPlaceholder); 86 | childNode.parent = parentNode; 87 | parentNode.children.set(childNode.id, childNode); 88 | return parentNode; 89 | } 90 | 91 | mergePaths(branch1Node, branch2Node) { 92 | // 直接挂到共同父节点 93 | if (!branch1Node.parent || !branch2Node.parent) { 94 | let parentNode, childNode; 95 | if (!branch1Node.parent) { 96 | if (!branch2Node.parent) { 97 | this.addParentDirectory(branch2Node); 98 | } 99 | parentNode = branch2Node.parent; 100 | childNode = branch1Node; 101 | } else if (!branch2Node.parent) { 102 | parentNode = branch1Node.parent; 103 | childNode = branch2Node; 104 | } 105 | childNode.parent = parentNode; 106 | parentNode.children.set(childNode.id, childNode); 107 | } else { 108 | // 循环合并父节点 109 | while ( 110 | branch1Node.parent && 111 | branch2Node.parent && 112 | branch1Node.parent !== branch2Node.parent 113 | ) { 114 | const p1 = branch1Node.parent; 115 | const p2 = branch2Node.parent; 116 | 117 | let mainNode; 118 | let tmpNode; 119 | 120 | // 1) 优先采用非占位目录作为主节点 121 | if (p1.isPlaceholder && !p2.isPlaceholder) { 122 | mainNode = p2; 123 | tmpNode = p1; 124 | } else if (!p1.isPlaceholder && p2.isPlaceholder) { 125 | mainNode = p1; 126 | tmpNode = p2; 127 | } else if (p1.isPlaceholder && p2.isPlaceholder) { 128 | // 2) 双方都是占位目录时,选择“更短”的占位名称作为主节点 129 | const name1 = compressParentPathName(p1.name); 130 | const name2 = compressParentPathName(p2.name); 131 | if (name1.length <= name2.length) { 132 | mainNode = p1; 133 | tmpNode = p2; 134 | } else { 135 | mainNode = p2; 136 | tmpNode = p1; 137 | } 138 | } else { 139 | // 3) 双方都是确定目录名: 140 | // - 如果名字相同,视为同一目录层级,按深度选择一个主节点即可(无需视为冲突) 141 | // - 如果名字不同,再按深度选择主节点并输出冲突日志 142 | const depth1 = this._getDepth(p1); 143 | const depth2 = this._getDepth(p2); 144 | if (depth1 <= depth2) { 145 | mainNode = p1; 146 | tmpNode = p2; 147 | } else { 148 | mainNode = p2; 149 | tmpNode = p1; 150 | } 151 | if (p1.name !== p2.name) { 152 | console.error( 153 | `Conflicting parent paths: ${p1.name} vs ${p2.name}, merged into ${mainNode.name}` 154 | ); 155 | } 156 | } 157 | 158 | // 向上推进一层,继续尝试合并更高层级的父目录 159 | branch1Node = p1; 160 | branch2Node = p2; 161 | 162 | // Merge the child nodes 163 | for (const childNode of tmpNode.children.values()) { 164 | // TODO: There may be a conflict if the different child nodes have the same name 165 | if (!mainNode.children.has(childNode.id)) { 166 | childNode.parent = mainNode; 167 | mainNode.children.set(childNode.id, childNode); 168 | } 169 | } 170 | } 171 | } 172 | } 173 | 174 | // 计算节点到根的层级深度 175 | _getDepth(node) { 176 | let depth = 0; 177 | let cur = node; 178 | while (cur) { 179 | depth += 1; 180 | cur = cur.parent; 181 | } 182 | return depth; 183 | } 184 | 185 | // Get the absolute path of a node by its id 186 | getNodeAbsolutePath(id) { 187 | id = String(id); 188 | const node = this.nodes.get(id); 189 | if (!node) { 190 | throw new Error(`Node with id ${id} does not exist`); 191 | } 192 | return node.getAbsolutePath(); 193 | } 194 | 195 | // TODO: Consider the levels of the nodes 196 | // Parse the require path and update the tree structure 197 | parseRequirePath(currentFileId, requireFileId, requirePath) { 198 | currentFileId = String(currentFileId); 199 | requireFileId = String(requireFileId); 200 | requirePath = String(requirePath); 201 | const isRelativeRequire = requirePath.startsWith("."); 202 | if (!requirePath || requirePath.endsWith("/")) { 203 | throw new Error("Invalid require path: " + requirePath); 204 | } 205 | if (!requireFileId) { 206 | throw new Error("Invalid require file id:" + requireFileId); 207 | } 208 | const normalizedRequirePath = path.posix.normalize(requirePath); // 使用 posix 保证路径一致性 209 | const segments = normalizedRequirePath.split("/"); 210 | 211 | // Process the require file 212 | const requireFileName = segments.pop(); 213 | if (!this.nodes.has(requireFileId)) { 214 | this.addFile(requireFileId, requireFileName); 215 | } 216 | const requireNode = this.nodes.get(requireFileId); 217 | if (requireNode.isPlaceholder) { 218 | requireNode.name = requireFileName; 219 | requireNode.isPlaceholder = false; 220 | } else if (requireNode.name !== requireFileName) { 221 | // 同一个 id 被不同文件名引用,视为别名冲突: 222 | // 保留第一次确定下来的文件名,继续使用已有节点参与路径合并, 223 | // 但不再尝试用新文件名覆盖,避免整条依赖被丢弃。 224 | console.debug(`currentFileId: ${currentFileId}`); 225 | console.debug(`requireFileId: ${requireFileId}`); 226 | console.debug(`requirePath: ${requirePath}`); 227 | console.error( 228 | `Conflicting file names for id ${requireFileId}: ${requireNode.name} and ${requireFileName}` 229 | ); 230 | // 直接继续向下执行,使用已有的 requireNode/name 参与后续路径构建 231 | } 232 | 233 | // Construct the require file's path 234 | let childNode = requireNode; 235 | while (segments.length > 0) { 236 | const segment = segments.pop(); 237 | if (segment === "." || segment === "..") { 238 | segments.push(segment); 239 | break; 240 | } else { 241 | if (childNode.parent) { 242 | if (childNode.parent.isPlaceholder) { 243 | childNode.parent.name = segment; 244 | childNode.parent.isPlaceholder = false; 245 | } else if (childNode.parent.name !== segment) { 246 | // 目录名冲突时,不再抛异常中断整条依赖。 247 | // 已经存在的父节点路径视为“更可信”,当前这条依赖的 248 | // 目录信息丢弃掉,仅保留已存在的路径结构。 249 | console.error( 250 | `Conflicting directory names for node ${childNode.id}: ${childNode.parent.name} and ${segment}` 251 | ); 252 | // 清空剩余的目录 segment,终止本次向上的目录扩展。 253 | segments.length = 0; 254 | break; 255 | } 256 | } else { 257 | this.addParentDirectory(childNode, segment); 258 | } 259 | childNode = childNode.parent; 260 | } 261 | } 262 | 263 | const branch1Node = childNode; 264 | 265 | // 对于非相对路径(不以 . 开头)且没有剩余 ../ 信息,保持原有行为:视为“绝对路径”,不与当前文件路径合并 266 | // 对于 ./foo 这类相对路径,segments 为空但仍应与当前文件处于同一目录层级,因此继续向下处理。 267 | if (segments.length === 0 && !isRelativeRequire) { 268 | return; 269 | } 270 | 271 | // Process the current file 272 | if (!this.nodes.has(currentFileId)) { 273 | this.addFile(currentFileId, currentFileId, true); 274 | } 275 | const currentNode = this.nodes.get(currentFileId); 276 | 277 | // Construct the current file's path 278 | childNode = currentNode; 279 | while (segments.length > 0) { 280 | const segment = segments.shift(); 281 | if (segment === ".") { 282 | continue; 283 | } else if (segment === "..") { 284 | if (!childNode.parent) { 285 | this.addParentDirectory(childNode); 286 | } 287 | childNode = childNode.parent; 288 | } else { 289 | throw new Error("Invalid require path: " + requirePath); 290 | } 291 | } 292 | 293 | let branch2Node = childNode; 294 | 295 | // Merge the two paths 296 | this.mergePaths(branch1Node, branch2Node); 297 | } 298 | } 299 | 300 | module.exports = { Node, FileTree }; 301 | --------------------------------------------------------------------------------