diff --git a/.gitignore b/.gitignore index 7d37dab9cd..4a7fb07b86 100644 --- a/.gitignore +++ b/.gitignore @@ -112,5 +112,8 @@ tegg/plugin/tegg/test/fixtures/apps/**/*.js *.tsbuildinfo *.tgz +# bundler e2e test output +tegg/core/bundler/test/.e2e-output + ecosystem-ci/cnpmcore ecosystem-ci/examples \ No newline at end of file diff --git a/package.json b/package.json index 8989bb0ee1..2f92b56647 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,9 @@ "version:beta": "node scripts/version.js prerelease --prerelease-tag=beta", "version:rc": "node scripts/version.js prerelease --prerelease-tag=rc" }, + "dependencies": { + "@utoo/pack": "link:../../../../../tnpm/utoo/packages/pack" + }, "devDependencies": { "@eggjs/bin": "workspace:*", "@eggjs/tsconfig": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c778df9238..d79df51fd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,7 +191,7 @@ catalogs: version: 2.1.4 body-parser: specifier: ^2.0.0 - version: 2.2.2 + version: 2.2.0 bytes: specifier: ^3.1.2 version: 3.1.2 @@ -332,7 +332,7 @@ catalogs: version: 2.2.0 http-errors: specifier: ^2.0.0 - version: 2.0.1 + version: 2.0.0 humanize-ms: specifier: ^2.0.0 version: 2.0.0 @@ -595,10 +595,15 @@ catalogs: overrides: vite: npm:rolldown-vite@^7.1.13 + '@utoo/pack': link:../../../../../tnpm/utoo/packages/pack importers: .: + dependencies: + '@utoo/pack': + specifier: link:../../../../../tnpm/utoo/packages/pack + version: link:../../../../../tnpm/utoo/packages/pack devDependencies: '@eggjs/bin': specifier: workspace:* @@ -1158,7 +1163,7 @@ importers: version: 1.0.2 http-errors: specifier: 'catalog:' - version: 2.0.1 + version: 2.0.0 is-type-of: specifier: 'catalog:' version: 2.2.0 @@ -1293,7 +1298,7 @@ importers: dependencies: http-errors: specifier: 'catalog:' - version: 2.0.1 + version: 2.0.0 inflection: specifier: 'catalog:' version: 3.0.2 @@ -1361,7 +1366,7 @@ importers: version: 5.0.3 body-parser: specifier: 'catalog:' - version: 2.2.2 + version: 2.2.0 cookie-parser: specifier: 'catalog:' version: 1.4.7 @@ -1983,7 +1988,7 @@ importers: dependencies: vitepress: specifier: 'catalog:' - version: 2.0.0-alpha.15(@types/node@24.10.2)(axios@1.13.5)(esbuild@0.27.0)(jiti@2.6.1)(less@4.4.2)(nprogress@0.2.0)(oxc-minify@0.105.0)(postcss@8.5.6)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) + version: 2.0.0-alpha.15(@types/node@24.10.2)(esbuild@0.27.0)(jiti@2.6.1)(less@4.4.2)(nprogress@0.2.0)(oxc-minify@0.105.0)(postcss@8.5.6)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) vitepress-plugin-llms: specifier: 'catalog:' version: 1.10.0 @@ -2092,6 +2097,40 @@ importers: specifier: 'catalog:' version: 5.9.3 + tegg/core/bundler: + dependencies: + '@eggjs/controller-decorator': + specifier: workspace:* + version: link:../controller-decorator + '@eggjs/core-decorator': + specifier: workspace:* + version: link:../core-decorator + '@eggjs/metadata': + specifier: workspace:* + version: link:../metadata + '@eggjs/tegg-loader': + specifier: workspace:* + version: link:../loader + '@eggjs/tegg-types': + specifier: workspace:* + version: link:../types + typescript: + specifier: 'catalog:' + version: 5.9.3 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.10.2 + '@utoo/pack': + specifier: link:../../../../../../../../tnpm/utoo/packages/pack + version: link:../../../../../../../../tnpm/utoo/packages/pack + esbuild: + specifier: 'catalog:' + version: 0.27.0 + vitest: + specifier: 'catalog:' + version: 4.0.15(@types/node@24.10.2)(@vitest/ui@4.0.15)(esbuild@0.27.0)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2) + tegg/core/common-util: dependencies: '@eggjs/tegg-types': @@ -2402,16 +2441,16 @@ importers: version: link:../types '@langchain/core': specifier: ^1.1.1 - version: 1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + version: 1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) '@langchain/langgraph': specifier: ^1.0.2 - version: 1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76) + version: 1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76) '@langchain/openai': specifier: ^1.1.0 - version: 1.2.8(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(ws@8.19.0) + version: 1.2.9(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(ws@8.19.0) langchain: specifier: ^1.1.2 - version: 1.2.25(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(openai@6.22.0(ws@8.19.0)(zod@3.25.76))(zod-to-json-schema@3.25.1(zod@3.25.76)) + version: 1.2.25(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(openai@6.22.0(ws@8.19.0)(zod@3.25.76))(zod-to-json-schema@3.25.1(zod@3.25.76)) devDependencies: '@eggjs/controller-decorator': specifier: workspace:* @@ -2480,7 +2519,7 @@ importers: version: link:../types '@langchain/mcp-adapters': specifier: ^1.0.0 - version: 1.1.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@langchain/langgraph@1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76)) + version: 1.1.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@langchain/langgraph@1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76)) '@modelcontextprotocol/sdk': specifier: ^1.23.0 version: 1.26.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) @@ -3129,19 +3168,19 @@ importers: version: link:../../core/types '@langchain/core': specifier: ^1.1.1 - version: 1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) + version: 1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) '@langchain/langgraph': specifier: ^1.0.2 - version: 1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) + version: 1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) '@langchain/mcp-adapters': specifier: ^1.0.0 - version: 1.1.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(@langchain/langgraph@1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6)) + version: 1.1.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(@langchain/langgraph@1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6)) '@langchain/openai': specifier: ^1.0.0 - version: 1.2.8(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(ws@8.19.0) + version: 1.2.9(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(ws@8.19.0) langchain: specifier: ^1.1.2 - version: 1.2.25(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(openai@6.22.0(ws@8.19.0)(zod@4.3.6))(zod-to-json-schema@3.25.1(zod@4.3.6)) + version: 1.2.25(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(openai@6.22.0(ws@8.19.0)(zod@4.3.6))(zod-to-json-schema@3.25.1(zod@4.3.6)) urllib: specifier: 'catalog:' version: 4.8.2 @@ -4162,8 +4201,8 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@langchain/core@1.1.26': - resolution: {integrity: sha512-Xnwi4xEKEtZcGwjW5xpZVP/Dc+WckFxULMShETuCpD6TxNFS6yRM+FhNUO1DDCkRkGn9b1fuzVZrNYb9W7F32A==} + '@langchain/core@1.1.27': + resolution: {integrity: sha512-YVtEz3nqCh8WxtdVXUICmt2BR2An+mn4YRJUBwcHX47Yrh2VwxpO0l97B2N/sNi658m65HnGyz2/hAjF3fzc1w==} engines: {node: '>=20'} '@langchain/langgraph-checkpoint@1.0.0': @@ -4205,11 +4244,11 @@ packages: '@langchain/core': ^1.0.0 '@langchain/langgraph': ^1.0.0 - '@langchain/openai@1.2.8': - resolution: {integrity: sha512-qliwC7sb7/Kw0tsl/EiMchMThKt62rZbyofKXtxPwYBte3BMzMXo2HKaEFvAN2QHVOuDi4voqQ7ZlRXc/o2e8w==} + '@langchain/openai@1.2.9': + resolution: {integrity: sha512-hExRiUoKOg1vfkwBAI5J2C4tqNx5LLZ0CUelG8Ej6K8bS2LfFN9bL4ZNQYqNIwAJNSqpDaV9tknxP2fssZjp+Q==} engines: {node: '>=20'} peerDependencies: - '@langchain/core': ^1.0.0 + '@langchain/core': ^1.1.27 '@modelcontextprotocol/sdk@1.26.0': resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} @@ -4962,6 +5001,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -5601,6 +5643,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -5700,9 +5746,6 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} - axios@1.13.5: - resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} - bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -5746,6 +5789,10 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -5870,6 +5917,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chan@0.6.1: resolution: {integrity: sha512-/TdBP2UhbBmw7qnqkzo9Mk4rzvwRv4dlNPXFerqWy90T8oBspKagJNZxrDbExKHhx9uXXHjo3f9mHgs9iKO3nQ==} @@ -6635,15 +6686,6 @@ packages: focus-trap@7.6.6: resolution: {integrity: sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -6655,8 +6697,8 @@ packages: form-data-encoder@1.9.0: resolution: {integrity: sha512-rahaRMkN8P8d/tgK/BLPX+WBVM27NbvdXBxqQujBtkDAIFspaRqN7Od7lfdGQA6KAD+f82fYCLBq1ipvcu8qLw==} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} format@0.2.2: @@ -6776,13 +6818,11 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -6791,12 +6831,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} @@ -6875,8 +6915,8 @@ packages: heredoc@1.3.1: resolution: {integrity: sha512-VL/rh/EXkhgpIWSNNde3OPc067oQiorfY+Nhkwgo2jAAIgrLLb5N92wmOblTyMWwCcIKo+8aQzk5s5YxLbVJPQ==} - hono@4.11.10: - resolution: {integrity: sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==} + hono@4.12.1: + resolution: {integrity: sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw==} engines: {node: '>=16.9.0'} hookable@5.5.3: @@ -7354,6 +7394,7 @@ packages: keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} @@ -7396,8 +7437,8 @@ packages: peerDependencies: '@langchain/core': ^1.1.26 - langsmith@0.5.4: - resolution: {integrity: sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==} + langsmith@0.5.5: + resolution: {integrity: sha512-I5dmuLUh8GFkPQwa0J5YeGHB2efrvI/lOpM8PSfOUss+ZdfVyZJfRvlXXvlCXJ1T4EgKN+oXMHwdrpA+wrafJg==} peerDependencies: '@opentelemetry/api': '*' '@opentelemetry/exporter-trace-otlp-proto': '*' @@ -8476,9 +8517,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} @@ -8498,6 +8536,10 @@ packages: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -9124,7 +9166,6 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tcp-base@3.2.0: resolution: {integrity: sha512-fFAqH8QTbheuEbXLdbxTSe31Gkw6Lg3nq4loyrxIXM6+ILGdbYXEblgyuu7UltOkOHbP/q2iqaC+gIXXu0C5bg==} @@ -9953,9 +9994,9 @@ snapshots: '@hapi/bourne@3.0.0': {} - '@hono/node-server@1.19.9(hono@4.11.10)': + '@hono/node-server@1.19.9(hono@4.12.1)': dependencies: - hono: 4.11.10 + hono: 4.12.1 '@iconify-json/simple-icons@1.2.60': dependencies: @@ -10034,14 +10075,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76))': + '@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76))': dependencies: '@cfworker/json-schema': 4.1.1 - ansi-styles: 6.2.3 + ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.21 - langsmith: 0.5.4(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + langsmith: 0.5.5(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) mustache: 4.2.0 p-queue: 6.6.2 uuid: 10.0.0 @@ -10052,14 +10093,14 @@ snapshots: - '@opentelemetry/sdk-trace-base' - openai - '@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6))': + '@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6))': dependencies: '@cfworker/json-schema': 4.1.1 - ansi-styles: 6.2.3 + ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.21 - langsmith: 0.5.4(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) + langsmith: 0.5.5(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) mustache: 4.2.0 p-queue: 6.6.2 uuid: 10.0.0 @@ -10070,39 +10111,39 @@ snapshots: - '@opentelemetry/sdk-trace-base' - openai - '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))': + '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))': dependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) uuid: 10.0.0 - '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))': + '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))': dependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) uuid: 10.0.0 - '@langchain/langgraph-sdk@2.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))': + '@langchain/langgraph-sdk@2.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))': dependencies: '@types/json-schema': 7.0.15 p-queue: 9.1.0 p-retry: 7.1.1 uuid: 13.0.0 optionalDependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) - '@langchain/langgraph-sdk@2.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))': + '@langchain/langgraph-sdk@2.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))': dependencies: '@types/json-schema': 7.0.15 p-queue: 9.1.0 p-retry: 7.1.1 uuid: 13.0.0 optionalDependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) - '@langchain/langgraph@1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76)': + '@langchain/langgraph@1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76)': dependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) - '@langchain/langgraph-sdk': 2.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) + '@langchain/langgraph-sdk': 2.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 3.25.76 @@ -10112,11 +10153,11 @@ snapshots: - react - react-dom - '@langchain/langgraph@1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@4.3.6)': + '@langchain/langgraph@1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@4.3.6)': dependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) - '@langchain/langgraph-sdk': 2.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) + '@langchain/langgraph-sdk': 2.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 4.3.6 @@ -10126,11 +10167,11 @@ snapshots: - react - react-dom - '@langchain/langgraph@1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6)': + '@langchain/langgraph@1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6)': dependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6))) - '@langchain/langgraph-sdk': 2.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6))) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6))) + '@langchain/langgraph-sdk': 2.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6))) '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 4.3.6 @@ -10140,10 +10181,10 @@ snapshots: - react - react-dom - '@langchain/mcp-adapters@1.1.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@langchain/langgraph@1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76))': + '@langchain/mcp-adapters@1.1.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@langchain/langgraph@1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76))': dependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) - '@langchain/langgraph': 1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + '@langchain/langgraph': 1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76) '@modelcontextprotocol/sdk': 1.26.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) debug: 4.4.3(supports-color@8.1.1) zod: 4.3.6 @@ -10153,10 +10194,10 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@langchain/mcp-adapters@1.1.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(@langchain/langgraph@1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6))': + '@langchain/mcp-adapters@1.1.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(@langchain/langgraph@1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6))': dependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) - '@langchain/langgraph': 1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) + '@langchain/langgraph': 1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) '@modelcontextprotocol/sdk': 1.26.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) debug: 4.4.3(supports-color@8.1.1) zod: 4.3.6 @@ -10166,18 +10207,18 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@langchain/openai@1.2.8(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(ws@8.19.0)': + '@langchain/openai@1.2.9(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(ws@8.19.0)': dependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) js-tiktoken: 1.0.21 openai: 6.22.0(ws@8.19.0)(zod@4.3.6) zod: 4.3.6 transitivePeerDependencies: - ws - '@langchain/openai@1.2.8(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(ws@8.19.0)': + '@langchain/openai@1.2.9(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(ws@8.19.0)': dependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) js-tiktoken: 1.0.21 openai: 6.22.0(ws@8.19.0)(zod@4.3.6) zod: 4.3.6 @@ -10186,7 +10227,7 @@ snapshots: '@modelcontextprotocol/sdk@1.26.0(@cfworker/json-schema@4.1.1)(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.9(hono@4.11.10) + '@hono/node-server': 1.19.9(hono@4.12.1) ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 @@ -10196,7 +10237,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.11.10 + hono: 4.12.1 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -10210,7 +10251,7 @@ snapshots: '@modelcontextprotocol/sdk@1.26.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': dependencies: - '@hono/node-server': 1.19.9(hono@4.11.10) + '@hono/node-server': 1.19.9(hono@4.12.1) ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 @@ -10220,7 +10261,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.11.10 + hono: 4.12.1 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -10808,6 +10849,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@swc-node/core@1.14.1(@swc/core@1.15.3)(@swc/types@0.1.25)': @@ -11121,7 +11164,7 @@ snapshots: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 '@types/node': 24.10.2 - form-data: 4.0.5 + form-data: 4.0.4 '@types/type-is@1.6.7': dependencies: @@ -11205,7 +11248,7 @@ snapshots: '@vitest/expect@4.0.15': dependencies: - '@standard-schema/spec': 1.1.0 + '@standard-schema/spec': 1.0.0 '@types/chai': 5.2.3 '@vitest/spy': 4.0.15 '@vitest/utils': 4.0.15 @@ -11332,13 +11375,12 @@ snapshots: '@vueuse/shared': 14.0.0(vue@3.5.25(typescript@5.9.3)) vue: 3.5.25(typescript@5.9.3) - '@vueuse/integrations@14.0.0(axios@1.13.5)(focus-trap@7.6.6)(nprogress@0.2.0)(vue@3.5.25(typescript@5.9.3))': + '@vueuse/integrations@14.0.0(focus-trap@7.6.6)(nprogress@0.2.0)(vue@3.5.25(typescript@5.9.3))': dependencies: '@vueuse/core': 14.0.0(vue@3.5.25(typescript@5.9.3)) '@vueuse/shared': 14.0.0(vue@3.5.25(typescript@5.9.3)) vue: 3.5.25(typescript@5.9.3) optionalDependencies: - axios: 1.13.5 focus-trap: 7.6.6 nprogress: 0.2.0 @@ -11435,6 +11477,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} ansis@3.17.0: {} @@ -11515,15 +11559,6 @@ snapshots: aws-ssl-profiles@1.1.2: {} - axios@1.13.5: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - optional: true - bail@2.0.2: {} balanced-match@1.0.2: {} @@ -11581,12 +11616,26 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.2.0: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3(supports-color@8.1.1) + http-errors: 2.0.0 + iconv-lite: 0.6.3 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.1 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + body-parser@2.2.2: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 4.4.3(supports-color@8.1.1) - http-errors: 2.0.1 + http-errors: 2.0.0 iconv-lite: 0.7.0 on-finished: 2.4.1 qs: 6.15.0 @@ -11750,6 +11799,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + chan@0.6.1: {} change-case@3.1.0: @@ -11893,7 +11944,7 @@ snapshots: dependencies: '@hapi/bourne': 3.0.0 inflation: 2.1.0 - qs: 6.15.0 + qs: 6.14.0 raw-body: 2.5.2 type-is: 1.6.18 @@ -12556,14 +12607,14 @@ snapshots: etag: 1.8.1 finalhandler: 2.1.1 fresh: 2.0.0 - http-errors: 2.0.1 + http-errors: 2.0.0 merge-descriptors: 2.0.0 mime-types: 3.0.2 on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.0 + qs: 6.14.0 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -12668,9 +12719,6 @@ snapshots: dependencies: tabbable: 6.3.0 - follow-redirects@1.15.11: - optional: true - for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -12682,7 +12730,7 @@ snapshots: form-data-encoder@1.9.0: {} - form-data@4.0.5: + form-data@4.0.4: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -12942,7 +12990,7 @@ snapshots: heredoc@1.3.1: {} - hono@4.11.10: {} + hono@4.12.1: {} hookable@5.5.3: {} @@ -13423,12 +13471,12 @@ snapshots: transitivePeerDependencies: - supports-color - langchain@1.2.25(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(openai@6.22.0(ws@8.19.0)(zod@3.25.76))(zod-to-json-schema@3.25.1(zod@3.25.76)): + langchain@1.2.25(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(openai@6.22.0(ws@8.19.0)(zod@3.25.76))(zod-to-json-schema@3.25.1(zod@3.25.76)): dependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) - '@langchain/langgraph': 1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@4.3.6) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) - langsmith: 0.5.4(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + '@langchain/langgraph': 1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@4.3.6) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) + langsmith: 0.5.5(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) uuid: 10.0.0 zod: 4.3.6 transitivePeerDependencies: @@ -13440,12 +13488,12 @@ snapshots: - react-dom - zod-to-json-schema - langchain@1.2.25(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(openai@6.22.0(ws@8.19.0)(zod@4.3.6))(zod-to-json-schema@3.25.1(zod@4.3.6)): + langchain@1.2.25(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(openai@6.22.0(ws@8.19.0)(zod@4.3.6))(zod-to-json-schema@3.25.1(zod@4.3.6)): dependencies: - '@langchain/core': 1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) - '@langchain/langgraph': 1.1.5(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.26(openai@6.22.0(ws@8.19.0)(zod@4.3.6))) - langsmith: 0.5.4(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) + '@langchain/core': 1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) + '@langchain/langgraph': 1.1.5(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6)))(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.27(openai@6.22.0(ws@8.19.0)(zod@4.3.6))) + langsmith: 0.5.5(openai@6.22.0(ws@8.19.0)(zod@4.3.6)) uuid: 10.0.0 zod: 4.3.6 transitivePeerDependencies: @@ -13457,10 +13505,10 @@ snapshots: - react-dom - zod-to-json-schema - langsmith@0.5.4(openai@6.22.0(ws@8.19.0)(zod@3.25.76)): + langsmith@0.5.5(openai@6.22.0(ws@8.19.0)(zod@3.25.76)): dependencies: '@types/uuid': 10.0.0 - chalk: 4.1.2 + chalk: 5.6.2 console-table-printer: 2.15.0 p-queue: 6.6.2 semver: 7.7.3 @@ -13468,10 +13516,10 @@ snapshots: optionalDependencies: openai: 6.22.0(ws@8.19.0)(zod@3.25.76) - langsmith@0.5.4(openai@6.22.0(ws@8.19.0)(zod@4.3.6)): + langsmith@0.5.5(openai@6.22.0(ws@8.19.0)(zod@4.3.6)): dependencies: '@types/uuid': 10.0.0 - chalk: 4.1.2 + chalk: 5.6.2 console-table-printer: 2.15.0 p-queue: 6.6.2 semver: 7.7.3 @@ -14730,9 +14778,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-from-env@1.1.0: - optional: true - prr@1.0.1: optional: true @@ -14754,6 +14799,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -15427,11 +15476,11 @@ snapshots: cookiejar: 2.1.4 debug: 4.4.3(supports-color@8.1.1) fast-safe-stringify: 2.1.1 - form-data: 4.0.5 + form-data: 4.0.4 formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.15.0 + qs: 6.14.0 transitivePeerDependencies: - supports-color @@ -15767,17 +15816,17 @@ snapshots: formstream: 1.5.2 mime-types: 2.1.35 pump: 3.0.3 - qs: 6.15.0 + qs: 6.14.0 type-fest: 4.41.0 undici: 5.29.0 ylru: 1.4.0 urllib@4.8.2: dependencies: - form-data: 4.0.5 + form-data: 4.0.4 formstream: 1.5.2 mime-types: 2.1.35 - qs: 6.15.0 + qs: 6.14.0 type-fest: 4.41.0 undici: 7.16.0 ylru: 2.0.0 @@ -15856,7 +15905,7 @@ snapshots: transitivePeerDependencies: - supports-color - vitepress@2.0.0-alpha.15(@types/node@24.10.2)(axios@1.13.5)(esbuild@0.27.0)(jiti@2.6.1)(less@4.4.2)(nprogress@0.2.0)(oxc-minify@0.105.0)(postcss@8.5.6)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2): + vitepress@2.0.0-alpha.15(@types/node@24.10.2)(esbuild@0.27.0)(jiti@2.6.1)(less@4.4.2)(nprogress@0.2.0)(oxc-minify@0.105.0)(postcss@8.5.6)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2): dependencies: '@docsearch/css': 4.3.2 '@docsearch/js': 4.3.2 @@ -15869,7 +15918,7 @@ snapshots: '@vue/devtools-api': 8.0.5 '@vue/shared': 3.5.25 '@vueuse/core': 14.0.0(vue@3.5.25(typescript@5.9.3)) - '@vueuse/integrations': 14.0.0(axios@1.13.5)(focus-trap@7.6.6)(nprogress@0.2.0)(vue@3.5.25(typescript@5.9.3)) + '@vueuse/integrations': 14.0.0(focus-trap@7.6.6)(nprogress@0.2.0)(vue@3.5.25(typescript@5.9.3)) focus-trap: 7.6.6 mark.js: 8.11.1 minisearch: 7.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index deb0585817..f5f067e8fa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,7 +19,6 @@ catalog: '@fengmk2/ps-tree': ^2.0.1 '@oclif/core': ^4.2.0 '@oxc-node/core': ^0.0.35 - typebox: ^1.0.65 '@swc-node/register': ^1.11.1 '@swc/core': ^1.15.1 '@types/accepts': ^1.3.7 @@ -65,6 +64,7 @@ catalog: '@types/urijs': ^1.19.25 '@types/vary': ^1.1.3 '@typescript/native-preview': 7.0.0-dev.20260117.1 + '@utoo/pack': ^1.2.7 '@vitest/coverage-v8': ^4.0.15 '@vitest/ui': ^4.0.15 accepts: ^1.3.8 @@ -204,6 +204,7 @@ catalog: tsx: 4.20.6 type-fest: ^5.0.1 type-is: ^2.0.0 + typebox: ^1.0.65 typescript: ^5.9.3 unplugin-unused: ^0.5.4 urijs: ^1.19.11 @@ -249,4 +250,5 @@ minimumReleaseAgeExclude: - import-without-cache overrides: + '@utoo/pack': link:../../../../../tnpm/utoo/packages/pack vite: npm:rolldown-vite@^7.1.13 diff --git a/tegg/core/bundler/package.json b/tegg/core/bundler/package.json new file mode 100644 index 0000000000..e9b5a7fe4b --- /dev/null +++ b/tegg/core/bundler/package.json @@ -0,0 +1,64 @@ +{ + "name": "@eggjs/tegg-bundler", + "version": "4.0.0-beta.36", + "description": "tegg bundler for serverless - bundles individual controller methods into standalone files", + "keywords": [ + "bundler", + "egg", + "serverless", + "tegg", + "typescript" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/tegg/core/bundler", + "bugs": { + "url": "https://github.com/eggjs/egg/issues" + }, + "license": "MIT", + "author": "killagu ", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/egg.git", + "directory": "tegg/core/bundler" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@eggjs/controller-decorator": "workspace:*", + "@eggjs/core-decorator": "workspace:*", + "@eggjs/metadata": "workspace:*", + "@eggjs/tegg-loader": "workspace:*", + "@eggjs/tegg-types": "workspace:*", + "typescript": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "@utoo/pack": "catalog:", + "esbuild": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/tegg/core/bundler/src/Bundler.ts b/tegg/core/bundler/src/Bundler.ts new file mode 100644 index 0000000000..b2cfe9e91a --- /dev/null +++ b/tegg/core/bundler/src/Bundler.ts @@ -0,0 +1,224 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { ControllerMetadataUtil, HTTPControllerMeta } from '@eggjs/controller-decorator'; +import { PrototypeUtil } from '@eggjs/core-decorator'; +import { ClassProtoDescriptor, GlobalGraph } from '@eggjs/metadata'; +import { LoaderFactory } from '@eggjs/tegg-loader'; +import { ControllerType } from '@eggjs/tegg-types'; +import type { ModuleReference } from '@eggjs/tegg-types'; + +import { DependencyResolver } from './DependencyResolver.ts'; +import { EntryGenerator } from './EntryGenerator.ts'; +import { MetaGenerator } from './MetaGenerator.ts'; +import type { MethodMeta } from './MetaGenerator.ts'; +import { MethodAnalyzer } from './MethodAnalyzer.ts'; + +export type { MethodMeta }; + +export interface BundlerOptions { + /** Output directory for bundles and meta files */ + outputPath: string; + /** Tegg module references to scan */ + moduleReferences: ModuleReference[]; + /** Externals config passed to the bundler (package → global var mapping) */ + externals?: Record; + /** Build mode */ + mode?: 'production' | 'development'; +} + +export interface MethodBundleResult { + /** Unique key for this method bundle, e.g. "UserController.getUser" */ + key: string; + /** Absolute path to the bundled JS file */ + bundlePath: string; + /** Absolute path to the meta JSON file */ + metaPath: string; + /** Parsed meta object */ + meta: MethodMeta; +} + +/** + * Options for the build step. + * Users provide a `BuildFunc` that accepts these options and produces a JS bundle. + * Example implementation: use `@utoo/pack` or `esbuild`. + */ +export interface BuildOptions { + entry: Array<{ name: string; import: string }>; + target: string; + platform: 'browser' | 'node'; + output: { path: string; type: string }; + externals: Record; + optimization: { treeShaking: boolean; removeUnusedExports: boolean }; + mode: 'production' | 'development'; + /** Project root path — used by Turbopack to resolve entry imports */ + projectPath?: string; + /** Root path — filesystem boundary for module resolution */ + rootPath?: string; +} + +/** + * A function that takes build options and produces a JS bundle. + * Users can inject their own implementation (e.g. @utoo/pack, esbuild, rollup). + */ +export type BuildFunc = (options: BuildOptions) => Promise; + +/** + * Main orchestrator for the tegg serverless bundler. + * + * For each controller method: + * 1. Analyzes the method body to find accessed services + * 2. Resolves the full transitive dependency closure + * 3. Generates a minimal entry file importing only needed deps + * 4. Calls the user-provided build function to produce a standalone bundle + * 5. Generates a meta JSON file with HTTP routing + DI dependency info + */ +export class Bundler { + private readonly buildFunc: BuildFunc; + + constructor(buildFunc?: BuildFunc) { + // Default build func: dynamically import @utoo/pack + this.buildFunc = buildFunc ?? defaultBuildFunc; + } + + async bundle(options: BundlerOptions): Promise { + const { + outputPath, + moduleReferences, + mode = 'production', + externals = { + // @swc/helpers is injected by SWC when compiling decorators + // (experimentalDecorators). It must be external so the bundled + // output can resolve it from node_modules at runtime. + '@swc/helpers': '@swc/helpers', + }, + } = options; + + // Load all modules and build the global dependency graph + const moduleDescriptors = await LoaderFactory.loadApp(moduleReferences); + const globalGraph = await GlobalGraph.create(moduleDescriptors); + globalGraph.build(); + globalGraph.sort(); + + const analyzer = new MethodAnalyzer(); + const resolver = new DependencyResolver(globalGraph); + const entryGenerator = new EntryGenerator(); + const metaGenerator = new MetaGenerator(); + + // Create temp dir for generated entry files inside outputPath + // so Turbopack can resolve them relative to the project root + const tmpDir = path.join(outputPath, '.tegg-entries'); + + // Write a tsconfig.json in outputPath so Turbopack/SWC enables + // experimentalDecorators when compiling TypeScript source files. + // Without this, decorator syntax (@Controller, @Inject, etc.) fails + // at the chunk code generation stage. + await fs.mkdir(outputPath, { recursive: true }); + await fs.writeFile( + path.join(outputPath, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + experimentalDecorators: true, + }, + }) + '\n', + ); + + const results: MethodBundleResult[] = []; + + try { + await fs.mkdir(outputPath, { recursive: true }); + + for (const [, protos] of globalGraph.moduleProtoDescriptorMap) { + for (const proto of protos) { + if (!ClassProtoDescriptor.isClassProtoDescriptor(proto)) continue; + + const controllerMeta = ControllerMetadataUtil.getControllerMetadata(proto.clazz); + if (!controllerMeta || controllerMeta.type !== ControllerType.HTTP) continue; + + if (!(controllerMeta instanceof HTTPControllerMeta)) continue; + + const filePath = PrototypeUtil.getFilePath(proto.clazz); + if (!filePath) continue; + + for (const methodMeta of controllerMeta.methods) { + const key = `${controllerMeta.controllerName}.${methodMeta.name}`; + + // Step 1: Analyze which injected services the method actually uses + const accessedProps = analyzer.analyze(filePath, proto.clazz.name, methodMeta.name); + + // Step 2: Resolve the full dependency closure for those services + const deps = resolver.resolve(proto, accessedProps); + + // Step 3: Generate entry file with only needed imports + const entryPath = await entryGenerator.generate(tmpDir, proto.clazz, methodMeta.name, deps); + + // Step 4: Bundle via user-provided build function + // Each method gets its own output subdirectory to avoid filename collisions + // (@utoo/pack may merge entries sharing the same controller into one file) + const methodOutputPath = path.join(outputPath, key); + await fs.mkdir(methodOutputPath, { recursive: true }); + // Turbopack platform:'node' outputs CJS bundles (require/module.exports). + // Write a package.json to ensure Node.js treats .js files as CommonJS, + // even if a parent package.json has "type": "module". + await fs.writeFile(path.join(methodOutputPath, 'package.json'), '{"type":"commonjs"}\n'); + await this.buildFunc({ + entry: [{ name: key, import: entryPath }], + target: 'node 22', + platform: 'node', + output: { path: methodOutputPath, type: 'standalone' }, + externals, + optimization: { treeShaking: true, removeUnusedExports: true }, + mode, + // Use parent outputPath as projectPath so Turbopack can resolve + // entry files in .tegg-entries/; rootPath is auto-detected so + // it can resolve source files outside the output directory + projectPath: outputPath, + }); + // Find the actual output JS file (entry name may be transformed by the bundler) + const outputFiles = await fs.readdir(methodOutputPath); + const jsFile = outputFiles.find((f) => f.endsWith('.js') && !f.startsWith('_turbopack')); + if (!jsFile) { + throw new Error(`No JS output file found in ${methodOutputPath} for ${key}`); + } + const bundlePath = path.join(methodOutputPath, jsFile); + + // Step 5: Generate meta file + const meta = metaGenerator.generate(controllerMeta, methodMeta, deps, proto, accessedProps); + const metaPath = path.join(outputPath, `${key}.meta.json`); + await fs.writeFile(metaPath, JSON.stringify(meta, null, 2)); + + results.push({ key, bundlePath, metaPath, meta }); + } + } + } + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + + return results; + } +} + +async function defaultBuildFunc(options: BuildOptions): Promise { + // Dynamically import @utoo/pack build command to avoid hard dependency. + // Uses the sub-path import to avoid loading HMR/WebSocket code from the root entry. + // Users must install @utoo/pack themselves if using the default build function. + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { build } = (await import('@utoo/pack/esm/commands/build.js' as any)) as { + build: (options: { config: BuildOptions }, projectPath?: string, rootPath?: string) => Promise; + }; + const projectPath = options.projectPath ?? options.output.path; + const rootPath = options.rootPath; + await build({ config: options }, projectPath, rootPath); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND') { + throw new Error( + 'Default build function requires @utoo/pack to be installed. ' + + 'Either install @utoo/pack or provide a custom buildFunc to the Bundler constructor.', + { cause: err }, + ); + } + throw err; + } +} diff --git a/tegg/core/bundler/src/DependencyResolver.ts b/tegg/core/bundler/src/DependencyResolver.ts new file mode 100644 index 0000000000..9a582be212 --- /dev/null +++ b/tegg/core/bundler/src/DependencyResolver.ts @@ -0,0 +1,60 @@ +import { ProtoNode } from '@eggjs/metadata'; +import type { GlobalGraph } from '@eggjs/metadata'; +import type { ProtoDescriptor } from '@eggjs/tegg-types'; + +/** + * Resolves the full transitive dependency closure for a controller method. + * + * Given a controller proto and the set of property names accessed in a method, + * returns all proto descriptors that must be imported for that method. + */ +export class DependencyResolver { + constructor(private readonly globalGraph: GlobalGraph) {} + + /** + * Resolve all dependencies (direct + transitive) for a controller method. + * + * @param controllerProto - ProtoDescriptor for the controller class + * @param accessedProps - Set of `this.xxx` property names accessed in the method + * @returns Ordered list of all required proto descriptors (not including the controller itself) + */ + resolve(controllerProto: ProtoDescriptor, accessedProps: Set): ProtoDescriptor[] { + const deps: ProtoDescriptor[] = []; + const visited = new Set(); + const queue: ProtoDescriptor[] = []; + + // Seed with directly accessed inject objects from the controller + for (const injectObj of controllerProto.injectObjects) { + if (!accessedProps.has(injectObj.refName as string)) continue; + + const dep = this.globalGraph.findInjectProto(controllerProto, injectObj); + if (!dep) continue; + + const depId = ProtoNode.createProtoId(dep); + if (visited.has(depId)) continue; + + visited.add(depId); + deps.push(dep); + queue.push(dep); + } + + // BFS to collect transitive dependencies + while (queue.length > 0) { + const current = queue.shift(); + if (!current) break; + for (const injectObj of current.injectObjects) { + const dep = this.globalGraph.findInjectProto(current, injectObj); + if (!dep) continue; + + const depId = ProtoNode.createProtoId(dep); + if (visited.has(depId)) continue; + + visited.add(depId); + deps.push(dep); + queue.push(dep); + } + } + + return deps; + } +} diff --git a/tegg/core/bundler/src/EntryGenerator.ts b/tegg/core/bundler/src/EntryGenerator.ts new file mode 100644 index 0000000000..9a19ea7310 --- /dev/null +++ b/tegg/core/bundler/src/EntryGenerator.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { PrototypeUtil } from '@eggjs/core-decorator'; +import { ClassProtoDescriptor } from '@eggjs/metadata'; +import type { EggProtoImplClass, ProtoDescriptor } from '@eggjs/tegg-types'; + +/** + * Generates a temporary TypeScript entry file for bundling a single controller method. + * + * The entry file imports all required classes as side-effects (which triggers their + * decorator registration in the DI container), then re-exports the controller class. + * + * When bundled with tree shaking, only the code reachable from this entry is included. + */ +export class EntryGenerator { + /** + * Generate an entry file for one controller method. + * + * @param outputDir - Directory where the entry file will be written + * @param controllerClazz - The controller class + * @param methodName - Name of the controller method being bundled + * @param deps - All dependency proto descriptors (direct + transitive) + * @returns Absolute path to the generated entry file + */ + async generate( + outputDir: string, + controllerClazz: EggProtoImplClass, + methodName: string, + deps: ProtoDescriptor[], + ): Promise { + const controllerFilePath = PrototypeUtil.getFilePath(controllerClazz); + if (!controllerFilePath) { + throw new Error(`Cannot find file path for controller class ${controllerClazz.name}`); + } + + const lines: string[] = [ + `// AUTO-GENERATED: ${controllerClazz.name}.${methodName} entry`, + `// DO NOT EDIT - this file is generated by @eggjs/tegg-bundler`, + ``, + ]; + + // Import controller file as side effect (registers controller proto via decorators) + lines.push(`import ${JSON.stringify(this.#toRelativeImport(outputDir, controllerFilePath))};`); + + // Import each dependency file as side effect (registers dep proto via decorators) + const importedPaths = new Set([controllerFilePath]); + for (const dep of deps) { + if (!ClassProtoDescriptor.isClassProtoDescriptor(dep)) continue; + + const depFilePath = PrototypeUtil.getFilePath(dep.clazz); + if (!depFilePath || importedPaths.has(depFilePath)) continue; + + importedPaths.add(depFilePath); + lines.push(`import ${JSON.stringify(this.#toRelativeImport(outputDir, depFilePath))};`); + } + + lines.push(``); + lines.push( + `export { ${controllerClazz.name} } from ${JSON.stringify(this.#toRelativeImport(outputDir, controllerFilePath))};`, + ); + lines.push(``); + + await fs.mkdir(outputDir, { recursive: true }); + const entryFileName = `${controllerClazz.name}.${methodName}.entry.ts`; + const entryPath = path.join(outputDir, entryFileName); + await fs.writeFile(entryPath, lines.join('\n')); + + return entryPath; + } + + /** + * Convert an absolute file path to a relative import specifier from the given directory. + * Always produces a path starting with `./` or `../` for bundler compatibility. + */ + #toRelativeImport(fromDir: string, toPath: string): string { + const rel = path.relative(fromDir, toPath).replace(/\\/g, '/'); + return rel.startsWith('.') ? rel : `./${rel}`; + } +} diff --git a/tegg/core/bundler/src/MetaGenerator.ts b/tegg/core/bundler/src/MetaGenerator.ts new file mode 100644 index 0000000000..62b33b74ab --- /dev/null +++ b/tegg/core/bundler/src/MetaGenerator.ts @@ -0,0 +1,132 @@ +import type { HTTPControllerMeta } from '@eggjs/controller-decorator'; +import type { HTTPMethodMeta } from '@eggjs/controller-decorator'; +import { ClassProtoDescriptor } from '@eggjs/metadata'; +import type { ProtoDescriptor } from '@eggjs/tegg-types'; + +export interface ParamMetaInfo { + type: string; + name?: string; + paramIndex: number; +} + +export interface DependencyMetaInfo { + refName: string; + protoName: string; + className: string; + moduleName: string; +} + +export interface MethodHttpMeta { + method: string; + path: string; + fullPath: string; + params: ParamMetaInfo[]; +} + +export interface MethodMeta { + controllerName: string; + className: string; + protoName: string; + methodName: string; + http: MethodHttpMeta; + dependencies: DependencyMetaInfo[]; +} + +/** + * Generates a JSON meta descriptor for a controller method bundle. + * + * The meta file records: + * - HTTP routing info (method, path, params) + * - DI dependency info (which protos the method depends on) + */ +export class MetaGenerator { + /** + * Generate meta for one controller method. + * + * @param controllerMeta - The HTTP controller metadata + * @param methodMeta - The HTTP method metadata + * @param deps - Resolved dependency proto descriptors + * @param controllerProto - The controller's proto descriptor + * @param accessedProps - Set of property names accessed in the method body + * @returns Serializable method meta object + */ + generate( + controllerMeta: HTTPControllerMeta, + methodMeta: HTTPMethodMeta, + deps: ProtoDescriptor[], + controllerProto: ProtoDescriptor, + accessedProps: Set, + ): MethodMeta { + // Build params list from the method's paramMap + const params: ParamMetaInfo[] = []; + for (const [paramIndex, paramInfo] of methodMeta.paramMap.entries()) { + const param: ParamMetaInfo = { + type: paramInfo.type, + paramIndex, + }; + // QueryParamMeta, QueriesParamMeta, PathParamMeta have a `name` property + if ('name' in paramInfo && typeof paramInfo.name === 'string') { + param.name = paramInfo.name; + } + params.push(param); + } + // Sort by paramIndex for deterministic output + params.sort((a, b) => a.paramIndex - b.paramIndex); + + // Build dependencies list - direct deps first (with refName from inject objects), + // then transitive deps that aren't already listed + const dependencies: DependencyMetaInfo[] = []; + + for (const injectObj of controllerProto.injectObjects) { + if (!accessedProps.has(injectObj.refName as string)) continue; + + // Find the corresponding dep proto by matching the objName + const matchedDep = deps.find((d) => String(d.name) === String(injectObj.objName)); + if (!matchedDep) continue; + + const className = ClassProtoDescriptor.isClassProtoDescriptor(matchedDep) + ? matchedDep.clazz.name + : (matchedDep.className ?? String(matchedDep.name)); + + dependencies.push({ + refName: String(injectObj.refName), + protoName: String(matchedDep.name), + className, + moduleName: matchedDep.instanceModuleName, + }); + } + + // Also include transitive deps that are not direct controller inject objects + for (const dep of deps) { + const alreadyListed = dependencies.some( + (d) => d.protoName === String(dep.name) && d.moduleName === dep.instanceModuleName, + ); + if (alreadyListed) continue; + + const className = ClassProtoDescriptor.isClassProtoDescriptor(dep) + ? dep.clazz.name + : (dep.className ?? String(dep.name)); + + dependencies.push({ + refName: String(dep.name), + protoName: String(dep.name), + className, + moduleName: dep.instanceModuleName, + }); + } + + return { + controllerName: controllerMeta.controllerName, + className: controllerMeta.className, + protoName: String(controllerProto.name), + methodName: methodMeta.name, + http: { + method: String(methodMeta.method), + path: methodMeta.path, + fullPath: controllerMeta.getMethodRealPath(methodMeta), + params, + }, + dependencies, + }; + } +} diff --git a/tegg/core/bundler/src/MethodAnalyzer.ts b/tegg/core/bundler/src/MethodAnalyzer.ts new file mode 100644 index 0000000000..8c684d1770 --- /dev/null +++ b/tegg/core/bundler/src/MethodAnalyzer.ts @@ -0,0 +1,127 @@ +import ts from 'typescript'; + +/** + * Analyzes TypeScript class method bodies to determine which + * injected properties (this.xxx) are actually accessed. + * + * This enables method-level tree shaking: only import the services + * that a specific controller method actually uses. + */ +export class MethodAnalyzer { + readonly #programCache = new Map(); + + #getProgram(filePath: string): ts.Program { + if (!this.#programCache.has(filePath)) { + this.#programCache.set( + filePath, + ts.createProgram([filePath], { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + experimentalDecorators: true, + emitDecoratorMetadata: true, + }), + ); + } + return this.#programCache.get(filePath)!; + } + + /** + * Analyze a class method to find which `this.xxx` properties are accessed. + * + * Also follows calls to private/internal methods within the same class + * to collect transitive `this.xxx` accesses. + * + * @param filePath - Absolute path to the TypeScript source file + * @param className - Name of the class containing the method + * @param methodName - Name of the method to analyze + * @returns Set of property names accessed via `this.xxx` + */ + analyze(filePath: string, className: string, methodName: string): Set { + const program = this.#getProgram(filePath); + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) return new Set(); + + const classDecl = this.#findClass(sourceFile, className); + if (!classDecl) return new Set(); + + const methodDecl = this.#findMethod(classDecl, methodName); + if (!methodDecl?.body) return new Set(); + + // Collect names of all methods in the class so we can follow private method calls. + // This includes both regular methods and ES private (#name) methods. + const classMethodNames = new Set(); + for (const member of classDecl.members) { + if (!ts.isMethodDeclaration(member)) continue; + if (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)) { + classMethodNames.add(member.name.text); + } + } + + const accessed = new Set(); + const visitedMethods = new Set([methodName]); + + this.#walkNode(classDecl, methodDecl.body, classMethodNames, visitedMethods, accessed); + + // Remove class method names from the result — #walkNode adds them as property accesses + // (e.g. `this.#loadUser(id)` causes `#loadUser` to appear), but they are call targets, + // not injected properties. Only injected property names are meaningful to callers. + for (const name of classMethodNames) { + accessed.delete(name); + } + + return accessed; + } + + #findClass(sourceFile: ts.SourceFile, className: string): ts.ClassDeclaration | undefined { + for (const stmt of sourceFile.statements) { + if (ts.isClassDeclaration(stmt) && stmt.name?.text === className) { + return stmt; + } + } + return undefined; + } + + #findMethod(classDecl: ts.ClassDeclaration, methodName: string): ts.MethodDeclaration | undefined { + for (const member of classDecl.members) { + if (!ts.isMethodDeclaration(member)) continue; + // Support both regular identifiers and ES private identifiers (#name) + if ((ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)) && member.name.text === methodName) { + return member; + } + } + return undefined; + } + + #walkNode( + classDecl: ts.ClassDeclaration, + node: ts.Node, + classMethodNames: Set, + visitedMethods: Set, + accessed: Set, + ): void { + // Track all this.xxx accesses + if (ts.isPropertyAccessExpression(node) && node.expression.kind === ts.SyntaxKind.ThisKeyword) { + accessed.add(node.name.text); + } + + // When we see this.methodName(), follow the private method body + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.expression.kind === ts.SyntaxKind.ThisKeyword + ) { + const calledMethodName = node.expression.name.text; + if (classMethodNames.has(calledMethodName) && !visitedMethods.has(calledMethodName)) { + visitedMethods.add(calledMethodName); + const calledMethod = this.#findMethod(classDecl, calledMethodName); + if (calledMethod?.body) { + this.#walkNode(classDecl, calledMethod.body, classMethodNames, visitedMethods, accessed); + } + } + } + + ts.forEachChild(node, (child) => { + this.#walkNode(classDecl, child, classMethodNames, visitedMethods, accessed); + }); + } +} diff --git a/tegg/core/bundler/src/index.ts b/tegg/core/bundler/src/index.ts new file mode 100644 index 0000000000..6c0ae592f8 --- /dev/null +++ b/tegg/core/bundler/src/index.ts @@ -0,0 +1,7 @@ +export { Bundler } from './Bundler.ts'; +export type { BundlerOptions, MethodBundleResult, BuildOptions, BuildFunc } from './Bundler.ts'; +export { MethodAnalyzer } from './MethodAnalyzer.ts'; +export { DependencyResolver } from './DependencyResolver.ts'; +export { EntryGenerator } from './EntryGenerator.ts'; +export { MetaGenerator } from './MetaGenerator.ts'; +export type { MethodMeta, MethodHttpMeta, DependencyMetaInfo, ParamMetaInfo } from './MetaGenerator.ts'; diff --git a/tegg/core/bundler/test/Bundler.test.ts b/tegg/core/bundler/test/Bundler.test.ts new file mode 100644 index 0000000000..6a72119d65 --- /dev/null +++ b/tegg/core/bundler/test/Bundler.test.ts @@ -0,0 +1,501 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it } from 'vitest'; + +import type { BuildOptions } from '../src/Bundler.ts'; +import { Bundler } from '../src/Bundler.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MODULE_PATH = path.join(__dirname, './fixtures/apps/simple-app/app/module/user'); +const FOO_MODULE_PATH = path.join(__dirname, './fixtures/apps/multi-module-app/app/module/foo'); +const BAR_MODULE_PATH = path.join(__dirname, './fixtures/apps/multi-module-app/app/module/bar'); +const API_MODULE_PATH = path.join(__dirname, './fixtures/apps/private-method-app/app/module/api'); +const NOTIFY_MODULE_PATH = path.join(__dirname, './fixtures/apps/eventbus-app/app/module/notify'); +const ORDER_MODULE_PATH = path.join(__dirname, './fixtures/apps/aop-app/app/module/order'); + +/** + * A mock build function that just writes an empty file at the expected output path. + * This allows us to test the Bundler's orchestration without an actual bundler. + */ +async function mockBuildFunc(options: BuildOptions): Promise { + const entry = options.entry[0]; + const outputPath = path.join(options.output.path, `${entry.name}.js`); + await fs.mkdir(options.output.path, { recursive: true }); + await fs.writeFile(outputPath, `// mock bundle for ${entry.name}\n`); +} + +describe('Bundler', () => { + it('should produce one bundle per controller method', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + const results = await bundler.bundle({ + outputPath, + moduleReferences: [ + { + name: 'user', + path: MODULE_PATH, + }, + ], + }); + + // UserController has 2 methods: getUser and adminAction + assert.equal(results.length, 2, 'should produce 2 bundle results'); + + const keys = results.map((r) => r.key); + assert( + keys.some((k) => k.includes('getUser')), + 'should have getUser bundle', + ); + assert( + keys.some((k) => k.includes('adminAction')), + 'should have adminAction bundle', + ); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('getUser bundle meta should NOT include adminService', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + const results = await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'user', path: MODULE_PATH }], + }); + + const getUserResult = results.find((r) => r.key.includes('getUser')); + assert(getUserResult, 'getUser bundle result should exist'); + + const depNames = getUserResult.meta.dependencies.map((d) => d.protoName); + assert(depNames.includes('userService'), 'getUser meta should include userService'); + assert(!depNames.includes('adminService'), 'getUser meta should NOT include adminService'); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('adminAction bundle meta should include both services', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + const results = await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'user', path: MODULE_PATH }], + }); + + const adminResult = results.find((r) => r.key.includes('adminAction')); + assert(adminResult, 'adminAction bundle result should exist'); + + const depNames = adminResult.meta.dependencies.map((d) => d.protoName); + assert(depNames.includes('userService'), 'adminAction meta should include userService'); + assert(depNames.includes('adminService'), 'adminAction meta should include adminService'); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('should generate correct meta JSON files on disk', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + const results = await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'user', path: MODULE_PATH }], + }); + + for (const result of results) { + const metaContent = await fs.readFile(result.metaPath, 'utf-8'); + const parsedMeta = JSON.parse(metaContent); + + assert.equal(parsedMeta.methodName, result.meta.methodName); + assert.equal(parsedMeta.http.fullPath, result.meta.http.fullPath); + assert(Array.isArray(parsedMeta.dependencies)); + } + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('should pass externals to the build function', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + const capturedOptions: BuildOptions[] = []; + + try { + const capturingBuildFunc = async (opts: BuildOptions): Promise => { + capturedOptions.push(opts); + await mockBuildFunc(opts); + }; + + const bundler = new Bundler(capturingBuildFunc); + await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'user', path: MODULE_PATH }], + externals: { lodash: '_' }, + }); + + assert(capturedOptions.length > 0, 'build func should be called'); + for (const opts of capturedOptions) { + assert.deepEqual(opts.externals, { lodash: '_' }, 'externals should be passed through'); + } + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + describe('cross-module dependencies (multi-module-app)', () => { + it('should bundle BarController methods with cross-module transitive deps', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + const results = await bundler.bundle({ + outputPath, + moduleReferences: [ + { name: 'foo', path: FOO_MODULE_PATH }, + { name: 'bar', path: BAR_MODULE_PATH }, + ], + }); + + // BarController has 3 methods: fetchUser, createUser, healthCheck + assert.equal(results.length, 3, 'should produce 3 bundle results for BarController'); + + const keys = results.map((r) => r.key); + assert( + keys.some((k) => k.includes('fetchUser')), + 'should have fetchUser bundle', + ); + assert( + keys.some((k) => k.includes('createUser')), + 'should have createUser bundle', + ); + assert( + keys.some((k) => k.includes('healthCheck')), + 'should have healthCheck bundle', + ); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('fetchUser bundle should include fooService and transitive fooRepository', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + const results = await bundler.bundle({ + outputPath, + moduleReferences: [ + { name: 'foo', path: FOO_MODULE_PATH }, + { name: 'bar', path: BAR_MODULE_PATH }, + ], + }); + + const fetchUserResult = results.find((r) => r.key.includes('fetchUser')); + assert(fetchUserResult, 'fetchUser result should exist'); + + const depNames = fetchUserResult.meta.dependencies.map((d) => d.protoName); + assert(depNames.includes('fooService'), 'fetchUser should include fooService (cross-module)'); + assert(depNames.includes('fooRepository'), 'fetchUser should include fooRepository (transitive)'); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('healthCheck bundle should have empty deps (pure method)', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + const results = await bundler.bundle({ + outputPath, + moduleReferences: [ + { name: 'foo', path: FOO_MODULE_PATH }, + { name: 'bar', path: BAR_MODULE_PATH }, + ], + }); + + const healthCheckResult = results.find((r) => r.key.includes('healthCheck')); + assert(healthCheckResult, 'healthCheck result should exist'); + assert.equal(healthCheckResult.meta.dependencies.length, 0, 'healthCheck should have no deps'); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + }); + + describe('private method chain (private-method-app)', () => { + it('should correctly isolate deps via private method chain analysis', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + const results = await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'api', path: API_MODULE_PATH }], + }); + + // ApiController has 3 methods: getProfile, getOrder, getSummary + assert.equal(results.length, 3, 'should produce 3 bundle results'); + + const getProfileResult = results.find((r) => r.key.includes('getProfile')); + assert(getProfileResult, 'getProfile result should exist'); + const profileDepNames = getProfileResult.meta.dependencies.map((d) => d.protoName); + assert(profileDepNames.includes('userService'), 'getProfile should include userService (via #loadUser)'); + assert(!profileDepNames.includes('orderService'), 'getProfile should NOT include orderService'); + + const getOrderResult = results.find((r) => r.key.includes('getOrder')); + assert(getOrderResult, 'getOrder result should exist'); + const orderDepNames = getOrderResult.meta.dependencies.map((d) => d.protoName); + assert(orderDepNames.includes('orderService'), 'getOrder should include orderService (via #loadOrder)'); + assert(!orderDepNames.includes('userService'), 'getOrder should NOT include userService'); + + const getSummaryResult = results.find((r) => r.key.includes('getSummary')); + assert(getSummaryResult, 'getSummary result should exist'); + const summaryDepNames = getSummaryResult.meta.dependencies.map((d) => d.protoName); + assert(summaryDepNames.includes('userService'), 'getSummary should include userService'); + assert(summaryDepNames.includes('orderService'), 'getSummary should include orderService'); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + }); + + describe('framework built-in graceful skip (eventbus-app)', () => { + it('should skip EventBus (not in GlobalGraph) without crashing', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + const results = await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'notify', path: NOTIFY_MODULE_PATH }], + }); + + // NotifyController has 1 method: notifyUser + assert.equal(results.length, 1, 'should produce 1 bundle result'); + + const notifyResult = results.find((r) => r.key.includes('notifyUser')); + assert(notifyResult, 'notifyUser result should exist'); + + const depNames = notifyResult.meta.dependencies.map((d) => d.protoName); + assert(depNames.includes('notifierService'), 'should include notifierService'); + assert(depNames.includes('userService'), 'should include userService (transitive)'); + // EventBus is NOT in GlobalGraph — should be skipped, not crash + assert(!depNames.includes('eventBus'), 'should NOT include eventBus (framework built-in)'); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + }); + + describe('AOP cross-cutting concerns (aop-app)', () => { + it('getOrder bundle should include full transitive dep chain (AOP-like services)', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + const results = await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'order', path: ORDER_MODULE_PATH }], + }); + + // OrderController has 2 methods: getOrder, ping + assert.equal(results.length, 2, 'should produce 2 bundle results'); + + const getOrderResult = results.find((r) => r.key.includes('getOrder')); + assert(getOrderResult, 'getOrder result should exist'); + + // Dep chain: OrderController -> OrderService -> MetricsService + const depNames = getOrderResult.meta.dependencies.map((d) => d.protoName); + assert(depNames.includes('orderService'), 'getOrder should include orderService'); + assert(depNames.includes('metricsService'), 'getOrder should include metricsService (AOP transitive dep)'); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('ping bundle should have empty deps (pure method, no AOP overhead)', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + const results = await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'order', path: ORDER_MODULE_PATH }], + }); + + const pingResult = results.find((r) => r.key.includes('ping')); + assert(pingResult, 'ping result should exist'); + assert.equal(pingResult.meta.dependencies.length, 0, 'ping should have no deps (no service access)'); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + }); + + describe('error paths', () => { + it('should throw when build function produces no JS output file', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + // Build function that creates a directory but no .js file + const noJsBuildFunc = async (opts: BuildOptions): Promise => { + await fs.mkdir(opts.output.path, { recursive: true }); + await fs.writeFile(path.join(opts.output.path, 'not-a-js-file.txt'), 'nope'); + }; + + const bundler = new Bundler(noJsBuildFunc); + await assert.rejects( + () => + bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'user', path: MODULE_PATH }], + }), + (err: Error) => { + assert( + err.message.includes('No JS output file found'), + `expected "No JS output file found" in: ${err.message}`, + ); + return true; + }, + ); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('should propagate errors from build function', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const failingBuildFunc = async (_opts: BuildOptions): Promise => { + throw new Error('build exploded'); + }; + + const bundler = new Bundler(failingBuildFunc); + await assert.rejects( + () => + bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'user', path: MODULE_PATH }], + }), + (err: Error) => { + assert.equal(err.message, 'build exploded'); + return true; + }, + ); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('should return empty results when modules have no HTTP controllers', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + // foo module only has FooService + FooRepository, no controller + const results = await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'foo', path: FOO_MODULE_PATH }], + }); + + assert.equal(results.length, 0, 'should produce 0 bundle results for module without controllers'); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + }); + + describe('configuration pass-through', () => { + it('should use default @swc/helpers external when no externals provided', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + const capturedOptions: BuildOptions[] = []; + + try { + const capturingBuildFunc = async (opts: BuildOptions): Promise => { + capturedOptions.push(opts); + await mockBuildFunc(opts); + }; + + const bundler = new Bundler(capturingBuildFunc); + await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'user', path: MODULE_PATH }], + // No externals specified — should use default + }); + + assert(capturedOptions.length > 0, 'build func should be called'); + for (const opts of capturedOptions) { + assert.deepEqual( + opts.externals, + { '@swc/helpers': '@swc/helpers' }, + 'should use default @swc/helpers external', + ); + } + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('should pass mode through to build function', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + const capturedOptions: BuildOptions[] = []; + + try { + const capturingBuildFunc = async (opts: BuildOptions): Promise => { + capturedOptions.push(opts); + await mockBuildFunc(opts); + }; + + const bundler = new Bundler(capturingBuildFunc); + await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'user', path: MODULE_PATH }], + mode: 'development', + }); + + assert(capturedOptions.length > 0, 'build func should be called'); + for (const opts of capturedOptions) { + assert.equal(opts.mode, 'development', 'mode should be passed through'); + } + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('should write tsconfig.json with experimentalDecorators in outputPath', async () => { + const outputPath = await fs.mkdtemp(path.join(os.tmpdir(), 'bundler-test-')); + + try { + const bundler = new Bundler(mockBuildFunc); + await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'user', path: MODULE_PATH }], + }); + + const tsconfigPath = path.join(outputPath, 'tsconfig.json'); + const tsconfigContent = await fs.readFile(tsconfigPath, 'utf-8'); + const tsconfig = JSON.parse(tsconfigContent); + + assert.equal( + tsconfig.compilerOptions.experimentalDecorators, + true, + 'tsconfig should have experimentalDecorators: true', + ); + } finally { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/tegg/core/bundler/test/DependencyResolver.test.ts b/tegg/core/bundler/test/DependencyResolver.test.ts new file mode 100644 index 0000000000..adc6721fa7 --- /dev/null +++ b/tegg/core/bundler/test/DependencyResolver.test.ts @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ControllerMetadataUtil } from '@eggjs/controller-decorator'; +import { ClassProtoDescriptor, GlobalGraph, GlobalModuleNodeBuilder } from '@eggjs/metadata'; +import { ControllerType } from '@eggjs/tegg-types'; +import { describe, it } from 'vitest'; + +import { DependencyResolver } from '../src/DependencyResolver.ts'; +// EventBus fixtures +import { NotifierService } from './fixtures/apps/eventbus-app/app/module/notify/NotifierService.ts'; +import { NotifyController } from './fixtures/apps/eventbus-app/app/module/notify/NotifyController.ts'; +import { UserService as NotifyUserService } from './fixtures/apps/eventbus-app/app/module/notify/UserService.ts'; +// Multi-module fixtures +import { BarController } from './fixtures/apps/multi-module-app/app/module/bar/BarController.ts'; +import { FooRepository } from './fixtures/apps/multi-module-app/app/module/foo/FooRepository.ts'; +import { FooService } from './fixtures/apps/multi-module-app/app/module/foo/FooService.ts'; +import { AdminService } from './fixtures/apps/simple-app/app/module/user/AdminService.ts'; +// Import fixtures so decorators run and register metadata +import { UserController } from './fixtures/apps/simple-app/app/module/user/UserController.ts'; +import { UserService } from './fixtures/apps/simple-app/app/module/user/UserService.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MODULE_PATH = path.join(__dirname, './fixtures/apps/simple-app/app/module/user'); +const FOO_MODULE_PATH = path.join(__dirname, './fixtures/apps/multi-module-app/app/module/foo'); +const BAR_MODULE_PATH = path.join(__dirname, './fixtures/apps/multi-module-app/app/module/bar'); +const NOTIFY_MODULE_PATH = path.join(__dirname, './fixtures/apps/eventbus-app/app/module/notify'); + +async function buildGraph(): Promise { + const builder = GlobalModuleNodeBuilder.create(MODULE_PATH); + builder.addClazz(UserController); + builder.addClazz(UserService); + builder.addClazz(AdminService); + + const graph = new GlobalGraph(); + graph.addModuleNode(builder.build()); + graph.build(); + graph.sort(); + return graph; +} + +async function buildMultiModuleGraph(): Promise { + const fooBuilder = GlobalModuleNodeBuilder.create(FOO_MODULE_PATH); + fooBuilder.addClazz(FooService); + fooBuilder.addClazz(FooRepository); + + const barBuilder = GlobalModuleNodeBuilder.create(BAR_MODULE_PATH); + barBuilder.addClazz(BarController); + + const graph = new GlobalGraph(); + graph.addModuleNode(fooBuilder.build()); + graph.addModuleNode(barBuilder.build()); + graph.build(); + graph.sort(); + return graph; +} + +async function buildNotifyGraph(): Promise { + // EventBus is intentionally NOT added to the graph — it's a framework built-in + const builder = GlobalModuleNodeBuilder.create(NOTIFY_MODULE_PATH); + builder.addClazz(NotifyController); + builder.addClazz(NotifierService); + builder.addClazz(NotifyUserService); + + const graph = new GlobalGraph(); + graph.addModuleNode(builder.build()); + graph.build(); + graph.sort(); + return graph; +} + +describe('DependencyResolver', () => { + it('should resolve only userService for getUser method', async () => { + const graph = await buildGraph(); + const resolver = new DependencyResolver(graph); + + // Find the controller proto + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto, 'UserController proto should exist'); + + // Only userService is accessed in getUser + const accessedProps = new Set(['userService']); + const deps = resolver.resolve(controllerProto, accessedProps); + + const depNames = deps.map((d) => String(d.name)); + assert(depNames.includes('userService'), 'should include userService'); + assert(!depNames.includes('adminService'), 'should NOT include adminService'); + }); + + it('should resolve both services for adminAction method', async () => { + const graph = await buildGraph(); + const resolver = new DependencyResolver(graph); + + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto, 'UserController proto should exist'); + + // Both services are accessed in adminAction + const accessedProps = new Set(['userService', 'adminService']); + const deps = resolver.resolve(controllerProto, accessedProps); + + const depNames = deps.map((d) => String(d.name)); + assert(depNames.includes('userService'), 'should include userService'); + assert(depNames.includes('adminService'), 'should include adminService'); + }); + + it('should return empty deps when no props are accessed', async () => { + const graph = await buildGraph(); + const resolver = new DependencyResolver(graph); + + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto, 'UserController proto should exist'); + + const deps = resolver.resolve(controllerProto, new Set()); + assert.equal(deps.length, 0, 'should have no deps when no props accessed'); + }); + + it('should return controller meta with HTTP methods', async () => { + const controllerMeta = ControllerMetadataUtil.getControllerMetadata(UserController); + assert(controllerMeta, 'UserController should have controller metadata'); + assert.equal(controllerMeta.type, ControllerType.HTTP); + assert.equal(controllerMeta.methods.length, 2); + }); + + describe('cross-module dependencies (multi-module-app)', () => { + it('should resolve cross-module dep FooService and its transitive FooRepository', async () => { + const graph = await buildMultiModuleGraph(); + const resolver = new DependencyResolver(graph); + + const barProtos = graph.moduleProtoDescriptorMap.get('bar') ?? []; + const barControllerProto = barProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === BarController, + ); + assert(barControllerProto, 'BarController proto should exist in bar module'); + + // fetchUser accesses fooService (cross-module) + const accessedProps = new Set(['fooService']); + const deps = resolver.resolve(barControllerProto, accessedProps); + + const depNames = deps.map((d) => String(d.name)); + // FooService is directly accessed + assert(depNames.includes('fooService'), 'should include fooService (cross-module dep)'); + // FooRepository is a transitive dep of FooService (private to foo module) + assert(depNames.includes('fooRepository'), 'should include fooRepository (transitive dep)'); + }); + + it('should return empty deps for healthCheck (no service access)', async () => { + const graph = await buildMultiModuleGraph(); + const resolver = new DependencyResolver(graph); + + const barProtos = graph.moduleProtoDescriptorMap.get('bar') ?? []; + const barControllerProto = barProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === BarController, + ); + assert(barControllerProto, 'BarController proto should exist'); + + const deps = resolver.resolve(barControllerProto, new Set()); + assert.equal(deps.length, 0, 'healthCheck should have no deps'); + }); + }); + + describe('framework built-in graceful skip (eventbus-app)', () => { + it('should include UserService but skip EventBus (not in GlobalGraph)', async () => { + const graph = await buildNotifyGraph(); + const resolver = new DependencyResolver(graph); + + const notifyProtos = graph.moduleProtoDescriptorMap.get('notify') ?? []; + const notifyControllerProto = notifyProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === NotifyController, + ); + assert(notifyControllerProto, 'NotifyController proto should exist'); + + // notifyUser accesses notifierService + const accessedProps = new Set(['notifierService']); + const deps = resolver.resolve(notifyControllerProto, accessedProps); + + const depNames = deps.map((d) => String(d.name)); + // NotifierService is directly accessed + assert(depNames.includes('notifierService'), 'should include notifierService'); + // UserService is a transitive dep of NotifierService + assert(depNames.includes('userService'), 'should include userService (transitive dep)'); + // EventBus is NOT in GlobalGraph — must be silently skipped without crashing + assert(!depNames.includes('eventBus'), 'should NOT include eventBus (framework built-in)'); + }); + }); + + describe('edge cases', () => { + it('should return empty deps when accessedProps do not match any inject object', async () => { + const graph = await buildGraph(); + const resolver = new DependencyResolver(graph); + + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto, 'UserController proto should exist'); + + // Pass prop names that do not match any inject refName + const accessedProps = new Set(['nonExistentService', 'anotherMissing']); + const deps = resolver.resolve(controllerProto, accessedProps); + + assert.equal(deps.length, 0, 'should return empty deps when no inject matches'); + }); + }); +}); diff --git a/tegg/core/bundler/test/EntryGenerator.test.ts b/tegg/core/bundler/test/EntryGenerator.test.ts new file mode 100644 index 0000000000..4416aab4a3 --- /dev/null +++ b/tegg/core/bundler/test/EntryGenerator.test.ts @@ -0,0 +1,469 @@ +/** + * EntryGenerator integration tests. + * + * These tests verify: + * 1. The generated entry file content is syntactically correct and has the right imports. + * 2. When bundled with esbuild, the output contains exactly the expected classes + * (method-level tree shaking: getUser bundle must NOT contain AdminService code). + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { PrototypeUtil } from '@eggjs/core-decorator'; +import { ClassProtoDescriptor, GlobalGraph, GlobalModuleNodeBuilder } from '@eggjs/metadata'; +import type { ProtoDescriptor } from '@eggjs/tegg-types'; +import { build } from 'esbuild'; +import { afterEach, describe, it } from 'vitest'; + +import { DependencyResolver } from '../src/DependencyResolver.ts'; +import { EntryGenerator } from '../src/EntryGenerator.ts'; +import { MethodAnalyzer } from '../src/MethodAnalyzer.ts'; +// Multi-module fixtures +import { BarController } from './fixtures/apps/multi-module-app/app/module/bar/BarController.ts'; +import { FooRepository } from './fixtures/apps/multi-module-app/app/module/foo/FooRepository.ts'; +import { FooService } from './fixtures/apps/multi-module-app/app/module/foo/FooService.ts'; +import { AdminService } from './fixtures/apps/simple-app/app/module/user/AdminService.ts'; +import { UserController } from './fixtures/apps/simple-app/app/module/user/UserController.ts'; +import { UserService } from './fixtures/apps/simple-app/app/module/user/UserService.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MODULE_PATH = path.join(__dirname, './fixtures/apps/simple-app/app/module/user'); +const FOO_MODULE_PATH = path.join(__dirname, './fixtures/apps/multi-module-app/app/module/foo'); +const BAR_MODULE_PATH = path.join(__dirname, './fixtures/apps/multi-module-app/app/module/bar'); + +async function buildSimpleGraph(): Promise { + const builder = GlobalModuleNodeBuilder.create(MODULE_PATH); + builder.addClazz(UserController); + builder.addClazz(UserService); + builder.addClazz(AdminService); + const graph = new GlobalGraph(); + graph.addModuleNode(builder.build()); + graph.build(); + graph.sort(); + return graph; +} + +async function buildMultiModuleGraph(): Promise { + const fooBuilder = GlobalModuleNodeBuilder.create(FOO_MODULE_PATH); + fooBuilder.addClazz(FooService); + fooBuilder.addClazz(FooRepository); + const barBuilder = GlobalModuleNodeBuilder.create(BAR_MODULE_PATH); + barBuilder.addClazz(BarController); + const graph = new GlobalGraph(); + graph.addModuleNode(fooBuilder.build()); + graph.addModuleNode(barBuilder.build()); + graph.build(); + graph.sort(); + return graph; +} + +/** + * Bundle an entry file with esbuild and return the output JS as a string. + * Uses --bundle to inline all imports, --platform=node, TypeScript loader. + */ +async function esbuildBundle(entryPath: string, outputDir: string, name: string): Promise { + const outfile = path.join(outputDir, `${name}.js`); + await build({ + entryPoints: [entryPath], + bundle: true, + platform: 'node', + target: 'node20', + outfile, + format: 'esm', + loader: { '.ts': 'ts' }, + // Suppress decorator metadata warnings — we only care about import inclusion + logLevel: 'silent', + }); + return fs.readFile(outfile, 'utf-8'); +} + +describe('EntryGenerator — entry file content', () => { + const analyzer = new MethodAnalyzer(); + const entryGen = new EntryGenerator(); + + it('getUser entry should import UserController and UserService, NOT AdminService', async () => { + const graph = await buildSimpleGraph(); + const resolver = new DependencyResolver(graph); + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto); + + const filePath = PrototypeUtil.getFilePath(UserController)!; + const accessed = analyzer.analyze(filePath, 'UserController', 'getUser'); + const deps = resolver.resolve(controllerProto, accessed); + + // fs.realpath resolves macOS /var → /private/var symlink so path.relative() is correct + const tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'entry-test-'))); + try { + const entryPath = await entryGen.generate(tmpDir, UserController, 'getUser', deps); + const content = await fs.readFile(entryPath, 'utf-8'); + + // Entry file must contain relative imports — no import starting with '/' (absolute) + assert(!/import ['"]\//.test(content), 'entry should NOT contain absolute import paths'); + + // Must import UserController (controller itself) + assert(content.includes('UserController'), 'entry must reference UserController'); + + // Must import UserService (direct dep) + assert(content.includes('UserService'), 'entry must import UserService'); + + // Must NOT import AdminService (not accessed by getUser) + assert(!content.includes('AdminService'), 'entry must NOT import AdminService for getUser'); + + // Must have an export of UserController + assert(content.includes('export'), 'entry must export the controller'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('adminAction entry should import both UserService and AdminService', async () => { + const graph = await buildSimpleGraph(); + const resolver = new DependencyResolver(graph); + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto); + + const filePath = PrototypeUtil.getFilePath(UserController)!; + const accessed = analyzer.analyze(filePath, 'UserController', 'adminAction'); + const deps = resolver.resolve(controllerProto, accessed); + + // fs.realpath resolves macOS /var → /private/var symlink so path.relative() is correct + const tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'entry-test-'))); + try { + const entryPath = await entryGen.generate(tmpDir, UserController, 'adminAction', deps); + const content = await fs.readFile(entryPath, 'utf-8'); + + assert(!/import ['"]\//.test(content), 'entry should NOT contain absolute import paths'); + assert(content.includes('UserService'), 'adminAction entry must import UserService'); + assert(content.includes('AdminService'), 'adminAction entry must import AdminService'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('fetchUser entry should import FooService and FooRepository (cross-module transitive)', async () => { + const graph = await buildMultiModuleGraph(); + const resolver = new DependencyResolver(graph); + const barProtos = graph.moduleProtoDescriptorMap.get('bar') ?? []; + const controllerProto = barProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === BarController, + ); + assert(controllerProto); + + const filePath = PrototypeUtil.getFilePath(BarController)!; + const accessed = analyzer.analyze(filePath, 'BarController', 'fetchUser'); + const deps = resolver.resolve(controllerProto, accessed); + + // fs.realpath resolves macOS /var → /private/var symlink so path.relative() is correct + const tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'entry-test-'))); + try { + const entryPath = await entryGen.generate(tmpDir, BarController, 'fetchUser', deps); + const content = await fs.readFile(entryPath, 'utf-8'); + + assert(!/import ['"]\//.test(content), 'entry should NOT contain absolute import paths'); + assert(content.includes('FooService'), 'fetchUser entry must import FooService (cross-module)'); + assert(content.includes('FooRepository'), 'fetchUser entry must import FooRepository (transitive)'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('healthCheck entry should have no service imports (pure method)', async () => { + const graph = await buildMultiModuleGraph(); + const resolver = new DependencyResolver(graph); + const barProtos = graph.moduleProtoDescriptorMap.get('bar') ?? []; + const controllerProto = barProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === BarController, + ); + assert(controllerProto); + + const filePath = PrototypeUtil.getFilePath(BarController)!; + const accessed = analyzer.analyze(filePath, 'BarController', 'healthCheck'); + const deps = resolver.resolve(controllerProto, accessed); + assert.equal(deps.length, 0); + + // fs.realpath resolves macOS /var → /private/var symlink so path.relative() is correct + const tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'entry-test-'))); + try { + const entryPath = await entryGen.generate(tmpDir, BarController, 'healthCheck', deps); + const content = await fs.readFile(entryPath, 'utf-8'); + + assert(!content.includes('FooService'), 'healthCheck entry must NOT import FooService'); + assert(!content.includes('FooRepository'), 'healthCheck entry must NOT import FooRepository'); + // Only BarController itself + assert(content.includes('BarController'), 'healthCheck entry must still export controller'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('EntryGenerator — esbuild bundle output (tree shaking verification)', () => { + const analyzer = new MethodAnalyzer(); + const entryGen = new EntryGenerator(); + + it('getUser bundle must NOT contain AdminService class', async () => { + const graph = await buildSimpleGraph(); + const resolver = new DependencyResolver(graph); + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto); + + const filePath = PrototypeUtil.getFilePath(UserController)!; + const accessed = analyzer.analyze(filePath, 'UserController', 'getUser'); + const deps = resolver.resolve(controllerProto, accessed); + + // fs.realpath resolves macOS /var → /private/var symlink so path.relative() is correct + const tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'bundle-verify-'))); + try { + const entryPath = await entryGen.generate(tmpDir, UserController, 'getUser', deps); + const bundleJs = await esbuildBundle(entryPath, tmpDir, 'getUser'); + + // UserService must be in the bundle (it's a dep) + assert(bundleJs.includes('UserService'), 'getUser bundle must contain UserService'); + // AdminService must NOT be in the bundle (not accessed by getUser) + assert(!bundleJs.includes('AdminService'), 'getUser bundle must NOT contain AdminService'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('adminAction bundle must contain both UserService and AdminService', async () => { + const graph = await buildSimpleGraph(); + const resolver = new DependencyResolver(graph); + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto); + + const filePath = PrototypeUtil.getFilePath(UserController)!; + const accessed = analyzer.analyze(filePath, 'UserController', 'adminAction'); + const deps = resolver.resolve(controllerProto, accessed); + + // fs.realpath resolves macOS /var → /private/var symlink so path.relative() is correct + const tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'bundle-verify-'))); + try { + const entryPath = await entryGen.generate(tmpDir, UserController, 'adminAction', deps); + const bundleJs = await esbuildBundle(entryPath, tmpDir, 'adminAction'); + + assert(bundleJs.includes('UserService'), 'adminAction bundle must contain UserService'); + assert(bundleJs.includes('AdminService'), 'adminAction bundle must contain AdminService'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('fetchUser bundle must contain FooService and FooRepository (cross-module)', async () => { + const graph = await buildMultiModuleGraph(); + const resolver = new DependencyResolver(graph); + const barProtos = graph.moduleProtoDescriptorMap.get('bar') ?? []; + const controllerProto = barProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === BarController, + ); + assert(controllerProto); + + const filePath = PrototypeUtil.getFilePath(BarController)!; + const accessed = analyzer.analyze(filePath, 'BarController', 'fetchUser'); + const deps = resolver.resolve(controllerProto, accessed); + + // fs.realpath resolves macOS /var → /private/var symlink so path.relative() is correct + const tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'bundle-verify-'))); + try { + const entryPath = await entryGen.generate(tmpDir, BarController, 'fetchUser', deps); + const bundleJs = await esbuildBundle(entryPath, tmpDir, 'fetchUser'); + + assert(bundleJs.includes('FooService'), 'fetchUser bundle must contain FooService'); + assert(bundleJs.includes('FooRepository'), 'fetchUser bundle must contain FooRepository'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('healthCheck bundle must NOT contain any service code', async () => { + const graph = await buildMultiModuleGraph(); + const resolver = new DependencyResolver(graph); + const barProtos = graph.moduleProtoDescriptorMap.get('bar') ?? []; + const controllerProto = barProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === BarController, + ); + assert(controllerProto); + + const filePath = PrototypeUtil.getFilePath(BarController)!; + const accessed = analyzer.analyze(filePath, 'BarController', 'healthCheck'); + const deps = resolver.resolve(controllerProto, accessed); + + // fs.realpath resolves macOS /var → /private/var symlink so path.relative() is correct + const tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'bundle-verify-'))); + try { + const entryPath = await entryGen.generate(tmpDir, BarController, 'healthCheck', deps); + const bundleJs = await esbuildBundle(entryPath, tmpDir, 'healthCheck'); + + assert(!bundleJs.includes('FooService'), 'healthCheck bundle must NOT contain FooService'); + assert(!bundleJs.includes('FooRepository'), 'healthCheck bundle must NOT contain FooRepository'); + // Controller itself is included (it's the export) + assert(bundleJs.includes('BarController'), 'healthCheck bundle must contain BarController'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); +}); + +// Undecorated class — has no PrototypeUtil file path set +class PlainClass {} + +describe('EntryGenerator — edge cases and error paths', () => { + const entryGen = new EntryGenerator(); + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should deduplicate dependency imports when same dep appears twice', async () => { + const graph = await buildSimpleGraph(); + const resolver = new DependencyResolver(graph); + + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto); + + const deps = resolver.resolve(controllerProto, new Set(['userService'])); + // Duplicate the dep list + const depsWithDuplicate = [...deps, ...deps]; + + tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'entry-dedup-'))); + const entryPath = await entryGen.generate(tmpDir, UserController, 'getUser', depsWithDuplicate); + const content = await fs.readFile(entryPath, 'utf-8'); + + const importLines = content.split('\n').filter((line) => /^import\s+["']/.test(line)); + // Controller + userService = 2 side-effect imports, no duplicates + assert.equal(importLines.length, 2, 'should have exactly 2 imports (controller + userService, no dups)'); + }); + + it('should skip non-ClassProtoDescriptor deps', async () => { + // Create a mock ProtoDescriptor that is NOT a ClassProtoDescriptor + const mockProto = { + type: 'NOT_CLASS', + name: 'mockService', + injectObjects: [], + } as unknown as ProtoDescriptor; + + tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'entry-nonclass-'))); + const entryPath = await entryGen.generate(tmpDir, UserController, 'getUser', [mockProto]); + const content = await fs.readFile(entryPath, 'utf-8'); + + const importLines = content.split('\n').filter((line) => /^import\s+["']/.test(line)); + // Should only have the controller import, non-class dep skipped + assert.equal(importLines.length, 1, 'should have only 1 import (controller only, mock dep skipped)'); + }); + + it('should skip deps without a file path', async () => { + const graph = await buildSimpleGraph(); + const resolver = new DependencyResolver(graph); + + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto); + + const deps = resolver.resolve(controllerProto, new Set(['userService'])); + assert(deps.length > 0); + + // Temporarily clear the file path of the dep's class + const dep = deps[0]; + assert(ClassProtoDescriptor.isClassProtoDescriptor(dep)); + const originalPath = PrototypeUtil.getFilePath(dep.clazz); + PrototypeUtil.setFilePath(dep.clazz, undefined as unknown as string); + + try { + tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'entry-nopath-'))); + const entryPath = await entryGen.generate(tmpDir, UserController, 'getUser', deps); + const content = await fs.readFile(entryPath, 'utf-8'); + + const importLines = content.split('\n').filter((line) => /^import\s+["']/.test(line)); + // Should only have controller import, dep without path skipped + assert.equal(importLines.length, 1, 'should skip dep without file path'); + } finally { + // Restore the original file path + if (originalPath) { + PrototypeUtil.setFilePath(dep.clazz, originalPath); + } + } + }); + + it('should throw when controller class has no file path', async () => { + tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'entry-throw-'))); + + await assert.rejects( + () => entryGen.generate(tmpDir, PlainClass, 'someMethod', []), + (err: Error) => { + assert(err.message.includes('Cannot find file path for controller class')); + assert(err.message.includes('PlainClass')); + return true; + }, + ); + }); + + it('should generate relative import paths (no absolute paths)', async () => { + const graph = await buildSimpleGraph(); + const resolver = new DependencyResolver(graph); + + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto); + + const deps = resolver.resolve(controllerProto, new Set(['userService', 'adminService'])); + + tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'entry-relpath-'))); + const entryPath = await entryGen.generate(tmpDir, UserController, 'adminAction', deps); + const content = await fs.readFile(entryPath, 'utf-8'); + + // Extract all import specifiers + const importSpecifiers = [...content.matchAll(/import\s+(?:.*from\s+)?["']([^"']+)["']/g)].map((m) => m[1]); + + assert(importSpecifiers.length > 0, 'should have import specifiers'); + for (const specifier of importSpecifiers) { + assert( + specifier.startsWith('./') || specifier.startsWith('../'), + `import specifier should be relative, got: ${specifier}`, + ); + } + }); + + it('should not duplicate controller import when controller appears in deps', async () => { + const graph = await buildSimpleGraph(); + + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto); + + tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'entry-ctrldup-'))); + // Pass controller proto itself as a dep + const entryPath = await entryGen.generate(tmpDir, UserController, 'getUser', [controllerProto]); + const content = await fs.readFile(entryPath, 'utf-8'); + + const importLines = content.split('\n').filter((line) => /^import\s+["']/.test(line)); + // Should have only 1 side-effect import for the controller (not duplicated) + assert.equal(importLines.length, 1, 'controller should not be imported twice'); + }); +}); diff --git a/tegg/core/bundler/test/MetaGenerator.test.ts b/tegg/core/bundler/test/MetaGenerator.test.ts new file mode 100644 index 0000000000..9eb5a77634 --- /dev/null +++ b/tegg/core/bundler/test/MetaGenerator.test.ts @@ -0,0 +1,290 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ControllerMetadataUtil, HTTPControllerMeta } from '@eggjs/controller-decorator'; +import { PrototypeUtil } from '@eggjs/core-decorator'; +import { ClassProtoDescriptor, GlobalGraph, GlobalModuleNodeBuilder } from '@eggjs/metadata'; +import { HTTPMethodEnum } from '@eggjs/tegg-types'; +import { describe, it } from 'vitest'; + +import { DependencyResolver } from '../src/DependencyResolver.ts'; +import { MetaGenerator } from '../src/MetaGenerator.ts'; +import { MethodAnalyzer } from '../src/MethodAnalyzer.ts'; +// Multi-module fixtures (for @HTTPBody, @HTTPQuery and pure-method tests) +import { BarController } from './fixtures/apps/multi-module-app/app/module/bar/BarController.ts'; +import { FooRepository } from './fixtures/apps/multi-module-app/app/module/foo/FooRepository.ts'; +import { FooService } from './fixtures/apps/multi-module-app/app/module/foo/FooService.ts'; +import { AdminService } from './fixtures/apps/simple-app/app/module/user/AdminService.ts'; +import { UserController } from './fixtures/apps/simple-app/app/module/user/UserController.ts'; +import { UserService } from './fixtures/apps/simple-app/app/module/user/UserService.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MODULE_PATH = path.join(__dirname, './fixtures/apps/simple-app/app/module/user'); +const FOO_MODULE_PATH = path.join(__dirname, './fixtures/apps/multi-module-app/app/module/foo'); +const BAR_MODULE_PATH = path.join(__dirname, './fixtures/apps/multi-module-app/app/module/bar'); + +async function buildGraph(): Promise { + const builder = GlobalModuleNodeBuilder.create(MODULE_PATH); + builder.addClazz(UserController); + builder.addClazz(UserService); + builder.addClazz(AdminService); + + const graph = new GlobalGraph(); + graph.addModuleNode(builder.build()); + graph.build(); + graph.sort(); + return graph; +} + +async function buildMultiModuleGraph(): Promise { + const fooBuilder = GlobalModuleNodeBuilder.create(FOO_MODULE_PATH); + fooBuilder.addClazz(FooService); + fooBuilder.addClazz(FooRepository); + + const barBuilder = GlobalModuleNodeBuilder.create(BAR_MODULE_PATH); + barBuilder.addClazz(BarController); + + const graph = new GlobalGraph(); + graph.addModuleNode(fooBuilder.build()); + graph.addModuleNode(barBuilder.build()); + graph.build(); + graph.sort(); + return graph; +} + +describe('MetaGenerator', () => { + const metaGenerator = new MetaGenerator(); + const analyzer = new MethodAnalyzer(); + + it('should generate correct meta for getUser method', async () => { + const graph = await buildGraph(); + const resolver = new DependencyResolver(graph); + + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto, 'UserController proto should exist'); + + const controllerMeta = ControllerMetadataUtil.getControllerMetadata(UserController); + assert(controllerMeta instanceof HTTPControllerMeta); + + const getUserMethod = controllerMeta.methods.find((m) => m.name === 'getUser'); + assert(getUserMethod, 'getUser method should exist'); + + const filePath = PrototypeUtil.getFilePath(UserController)!; + const accessedProps = analyzer.analyze(filePath, 'UserController', 'getUser'); + const deps = resolver.resolve(controllerProto, accessedProps); + + const meta = metaGenerator.generate(controllerMeta, getUserMethod, deps, controllerProto, accessedProps); + + assert.equal(meta.methodName, 'getUser'); + assert.equal(meta.http.method, HTTPMethodEnum.GET); + assert.equal(meta.http.path, '/:id'); + assert.equal(meta.http.fullPath, '/api/users/:id'); + + // Should have id and fields params + assert(meta.http.params.length >= 1, 'should have at least 1 param'); + const idParam = meta.http.params.find((p) => p.name === 'id'); + assert(idParam, 'should have id param'); + + // Should only include userService in dependencies + const depNames = meta.dependencies.map((d) => d.protoName); + assert(depNames.includes('userService'), 'meta should list userService dep'); + assert(!depNames.includes('adminService'), 'meta should NOT list adminService dep'); + }); + + it('should generate correct meta for adminAction method', async () => { + const graph = await buildGraph(); + const resolver = new DependencyResolver(graph); + + const protos = graph.moduleProtoDescriptorMap.get('user') ?? []; + const controllerProto = protos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === UserController, + ); + assert(controllerProto, 'UserController proto should exist'); + + const controllerMeta = ControllerMetadataUtil.getControllerMetadata(UserController); + assert(controllerMeta instanceof HTTPControllerMeta); + + const adminActionMethod = controllerMeta.methods.find((m) => m.name === 'adminAction'); + assert(adminActionMethod, 'adminAction method should exist'); + + const filePath = PrototypeUtil.getFilePath(UserController)!; + const accessedProps = analyzer.analyze(filePath, 'UserController', 'adminAction'); + const deps = resolver.resolve(controllerProto, accessedProps); + + const meta = metaGenerator.generate(controllerMeta, adminActionMethod, deps, controllerProto, accessedProps); + + assert.equal(meta.methodName, 'adminAction'); + assert.equal(meta.http.method, HTTPMethodEnum.POST); + assert.equal(meta.http.path, '/admin'); + assert.equal(meta.http.fullPath, '/api/users/admin'); + + // Should include both services + const depNames = meta.dependencies.map((d) => d.protoName); + assert(depNames.includes('userService'), 'meta should list userService dep'); + assert(depNames.includes('adminService'), 'meta should list adminService dep'); + }); + + describe('HTTP param decorators (multi-module-app)', () => { + it('should capture @HTTPParam and @HTTPQuery params for fetchUser', async () => { + const graph = await buildMultiModuleGraph(); + const resolver = new DependencyResolver(graph); + + const barProtos = graph.moduleProtoDescriptorMap.get('bar') ?? []; + const controllerProto = barProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === BarController, + ); + assert(controllerProto, 'BarController proto should exist'); + + const controllerMeta = ControllerMetadataUtil.getControllerMetadata(BarController); + assert(controllerMeta instanceof HTTPControllerMeta); + + const fetchUserMethod = controllerMeta.methods.find((m) => m.name === 'fetchUser'); + assert(fetchUserMethod, 'fetchUser method should exist'); + + const filePath = PrototypeUtil.getFilePath(BarController)!; + const accessedProps = analyzer.analyze(filePath, 'BarController', 'fetchUser'); + const deps = resolver.resolve(controllerProto, accessedProps); + const meta = metaGenerator.generate(controllerMeta, fetchUserMethod, deps, controllerProto, accessedProps); + + assert.equal(meta.methodName, 'fetchUser'); + assert.equal(meta.http.method, HTTPMethodEnum.GET); + + // fetchUser has @HTTPParam() id and @HTTPQuery() fields + const paramNames = meta.http.params.map((p) => p.name); + assert(paramNames.includes('id'), 'should have id (@HTTPParam)'); + assert(paramNames.includes('fields'), 'should have fields (@HTTPQuery)'); + + // Should include fooService and fooRepository (transitive cross-module dep) + const depNames = meta.dependencies.map((d) => d.protoName); + assert(depNames.includes('fooService'), 'meta should list fooService'); + assert(depNames.includes('fooRepository'), 'meta should list fooRepository (transitive)'); + }); + + it('should capture @HTTPBody param for createUser', async () => { + const graph = await buildMultiModuleGraph(); + const resolver = new DependencyResolver(graph); + + const barProtos = graph.moduleProtoDescriptorMap.get('bar') ?? []; + const controllerProto = barProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === BarController, + ); + assert(controllerProto, 'BarController proto should exist'); + + const controllerMeta = ControllerMetadataUtil.getControllerMetadata(BarController); + assert(controllerMeta instanceof HTTPControllerMeta); + + const createUserMethod = controllerMeta.methods.find((m) => m.name === 'createUser'); + assert(createUserMethod, 'createUser method should exist'); + + const filePath = PrototypeUtil.getFilePath(BarController)!; + const accessedProps = analyzer.analyze(filePath, 'BarController', 'createUser'); + const deps = resolver.resolve(controllerProto, accessedProps); + const meta = metaGenerator.generate(controllerMeta, createUserMethod, deps, controllerProto, accessedProps); + + assert.equal(meta.methodName, 'createUser'); + assert.equal(meta.http.method, HTTPMethodEnum.POST); + + // createUser has @HTTPBody() body parameter + // BodyParamMeta has no name — match by type 'BODY' + assert(meta.http.params.length >= 1, 'should have at least 1 param'); + const bodyParam = meta.http.params.find((p) => p.type === 'BODY'); + assert(bodyParam, 'should have a BODY param (@HTTPBody)'); + }); + + it('should generate empty deps meta for healthCheck (pure method)', async () => { + const graph = await buildMultiModuleGraph(); + const resolver = new DependencyResolver(graph); + + const barProtos = graph.moduleProtoDescriptorMap.get('bar') ?? []; + const controllerProto = barProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === BarController, + ); + assert(controllerProto, 'BarController proto should exist'); + + const controllerMeta = ControllerMetadataUtil.getControllerMetadata(BarController); + assert(controllerMeta instanceof HTTPControllerMeta); + + const healthCheckMethod = controllerMeta.methods.find((m) => m.name === 'healthCheck'); + assert(healthCheckMethod, 'healthCheck method should exist'); + + const filePath = PrototypeUtil.getFilePath(BarController)!; + const accessedProps = analyzer.analyze(filePath, 'BarController', 'healthCheck'); + const deps = resolver.resolve(controllerProto, accessedProps); + const meta = metaGenerator.generate(controllerMeta, healthCheckMethod, deps, controllerProto, accessedProps); + + assert.equal(meta.methodName, 'healthCheck'); + assert.equal(meta.http.method, HTTPMethodEnum.GET); + assert.equal(meta.dependencies.length, 0, 'healthCheck should have no dependencies'); + }); + }); + + describe('dependency classification (direct vs transitive)', () => { + it('should distinguish direct deps (with refName) from transitive deps', async () => { + const graph = await buildMultiModuleGraph(); + const resolver = new DependencyResolver(graph); + + const barProtos = graph.moduleProtoDescriptorMap.get('bar') ?? []; + const controllerProto = barProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === BarController, + ); + assert(controllerProto, 'BarController proto should exist'); + + const controllerMeta = ControllerMetadataUtil.getControllerMetadata(BarController); + assert(controllerMeta instanceof HTTPControllerMeta); + + const fetchUserMethod = controllerMeta.methods.find((m) => m.name === 'fetchUser'); + assert(fetchUserMethod, 'fetchUser method should exist'); + + const filePath = PrototypeUtil.getFilePath(BarController)!; + const accessedProps = analyzer.analyze(filePath, 'BarController', 'fetchUser'); + const deps = resolver.resolve(controllerProto, accessedProps); + const meta = metaGenerator.generate(controllerMeta, fetchUserMethod, deps, controllerProto, accessedProps); + + // fooService is a direct inject on BarController — refName comes from @Inject property name + const fooServiceDep = meta.dependencies.find((d) => d.protoName === 'fooService'); + assert(fooServiceDep, 'fooService should be in dependencies'); + assert.equal(fooServiceDep.refName, 'fooService', 'direct dep refName should match inject property name'); + + // fooRepository is transitive (injected by FooService, not by BarController) + // Its refName should equal its protoName since it is not a direct controller inject + const fooRepoDep = meta.dependencies.find((d) => d.protoName === 'fooRepository'); + assert(fooRepoDep, 'fooRepository should be in dependencies'); + assert.equal(fooRepoDep.refName, 'fooRepository', 'transitive dep refName should equal protoName'); + }); + + it('should include correct moduleName for cross-module dependencies', async () => { + const graph = await buildMultiModuleGraph(); + const resolver = new DependencyResolver(graph); + + const barProtos = graph.moduleProtoDescriptorMap.get('bar') ?? []; + const controllerProto = barProtos.find( + (p) => ClassProtoDescriptor.isClassProtoDescriptor(p) && p.clazz === BarController, + ); + assert(controllerProto, 'BarController proto should exist'); + + const controllerMeta = ControllerMetadataUtil.getControllerMetadata(BarController); + assert(controllerMeta instanceof HTTPControllerMeta); + + const fetchUserMethod = controllerMeta.methods.find((m) => m.name === 'fetchUser'); + assert(fetchUserMethod); + + const filePath = PrototypeUtil.getFilePath(BarController)!; + const accessedProps = analyzer.analyze(filePath, 'BarController', 'fetchUser'); + const deps = resolver.resolve(controllerProto, accessedProps); + const meta = metaGenerator.generate(controllerMeta, fetchUserMethod, deps, controllerProto, accessedProps); + + // FooService and FooRepository are from the foo module + const fooServiceDep = meta.dependencies.find((d) => d.protoName === 'fooService'); + assert(fooServiceDep); + assert.equal(fooServiceDep.moduleName, 'foo', 'fooService moduleName should be "foo"'); + + const fooRepoDep = meta.dependencies.find((d) => d.protoName === 'fooRepository'); + assert(fooRepoDep); + assert.equal(fooRepoDep.moduleName, 'foo', 'fooRepository moduleName should be "foo"'); + }); + }); +}); diff --git a/tegg/core/bundler/test/MethodAnalyzer.test.ts b/tegg/core/bundler/test/MethodAnalyzer.test.ts new file mode 100644 index 0000000000..c7128a9af1 --- /dev/null +++ b/tegg/core/bundler/test/MethodAnalyzer.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; + +import { PrototypeUtil } from '@eggjs/core-decorator'; +import { describe, it } from 'vitest'; + +// Import fixtures so decorators run and register metadata +import './fixtures/apps/simple-app/app/module/user/UserController.ts'; +import { MethodAnalyzer } from '../src/MethodAnalyzer.ts'; +// Multi-module fixtures (for pure-method test) +import { BarController } from './fixtures/apps/multi-module-app/app/module/bar/BarController.ts'; +// Private method chain fixtures +import { ApiController } from './fixtures/apps/private-method-app/app/module/api/ApiController.ts'; +import { UserController } from './fixtures/apps/simple-app/app/module/user/UserController.ts'; + +describe('MethodAnalyzer', () => { + const analyzer = new MethodAnalyzer(); + + it('should find only userService access in getUser method', () => { + const filePath = PrototypeUtil.getFilePath(UserController); + assert(filePath, 'UserController file path should be set by decorator'); + + const accessed = analyzer.analyze(filePath, 'UserController', 'getUser'); + + assert(accessed.has('userService'), 'getUser should access userService'); + assert(!accessed.has('adminService'), 'getUser should NOT access adminService'); + }); + + it('should find both userService and adminService in adminAction method', () => { + const filePath = PrototypeUtil.getFilePath(UserController); + assert(filePath, 'UserController file path should be set by decorator'); + + const accessed = analyzer.analyze(filePath, 'UserController', 'adminAction'); + + assert(accessed.has('userService'), 'adminAction should access userService'); + assert(accessed.has('adminService'), 'adminAction should access adminService'); + }); + + it('should return empty set for non-existent method', () => { + const filePath = PrototypeUtil.getFilePath(UserController); + assert(filePath, 'UserController file path should be set by decorator'); + + const accessed = analyzer.analyze(filePath, 'UserController', 'nonExistentMethod'); + + assert.equal(accessed.size, 0); + }); + + it('should return empty set for non-existent class', () => { + const filePath = PrototypeUtil.getFilePath(UserController); + assert(filePath); + + const accessed = analyzer.analyze(filePath, 'NonExistentClass', 'getUser'); + + assert.equal(accessed.size, 0); + }); + + describe('private method chain (#privateMethod)', () => { + it('should follow #loadUser() and detect userService for getProfile', () => { + const filePath = PrototypeUtil.getFilePath(ApiController); + assert(filePath, 'ApiController file path should be set by decorator'); + + const accessed = analyzer.analyze(filePath, 'ApiController', 'getProfile'); + + assert(accessed.has('userService'), 'getProfile should detect userService via #loadUser()'); + assert(!accessed.has('orderService'), 'getProfile should NOT detect orderService'); + }); + + it('should follow #loadOrder() and detect orderService for getOrder', () => { + const filePath = PrototypeUtil.getFilePath(ApiController); + assert(filePath, 'ApiController file path should be set by decorator'); + + const accessed = analyzer.analyze(filePath, 'ApiController', 'getOrder'); + + assert(accessed.has('orderService'), 'getOrder should detect orderService via #loadOrder()'); + assert(!accessed.has('userService'), 'getOrder should NOT detect userService'); + }); + + it('should detect both services in getSummary (direct access, no private methods)', () => { + const filePath = PrototypeUtil.getFilePath(ApiController); + assert(filePath, 'ApiController file path should be set by decorator'); + + const accessed = analyzer.analyze(filePath, 'ApiController', 'getSummary'); + + assert(accessed.has('userService'), 'getSummary should access userService directly'); + assert(accessed.has('orderService'), 'getSummary should access orderService directly'); + }); + }); + + describe('pure method (no service access)', () => { + it('should return empty set for healthCheck which accesses no services', () => { + const filePath = PrototypeUtil.getFilePath(BarController); + assert(filePath, 'BarController file path should be set by decorator'); + + const accessed = analyzer.analyze(filePath, 'BarController', 'healthCheck'); + + assert.equal(accessed.size, 0, 'healthCheck should access no services'); + }); + }); + + describe('edge cases', () => { + it('should return empty set for non-existent file path', () => { + const accessed = analyzer.analyze('/non/existent/file.ts', 'SomeClass', 'someMethod'); + + assert.equal(accessed.size, 0, 'should return empty set for non-existent file'); + }); + + it('should cache programs and return consistent results on repeated calls', () => { + const filePath = PrototypeUtil.getFilePath(UserController); + assert(filePath); + + const result1 = analyzer.analyze(filePath, 'UserController', 'getUser'); + const result2 = analyzer.analyze(filePath, 'UserController', 'getUser'); + + assert.deepEqual([...result1].sort(), [...result2].sort(), 'repeated calls should return same results'); + assert(result1.has('userService'), 'first call should find userService'); + assert(result2.has('userService'), 'second call should find userService'); + }); + }); +}); diff --git a/tegg/core/bundler/test/e2e.test.ts b/tegg/core/bundler/test/e2e.test.ts new file mode 100644 index 0000000000..ab316ec7eb --- /dev/null +++ b/tegg/core/bundler/test/e2e.test.ts @@ -0,0 +1,160 @@ +/** + * E2E tests: verify the full Bundler pipeline with @utoo/pack produces valid output. + * + * Uses the default Bundler (with @utoo/pack, platform: 'node') to produce + * Node.js-compatible bundles, then verifies: build succeeds, output files + * exist and are non-empty, bundles can be dynamically imported in Node.js, + * and meta JSON files are valid. + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterAll, beforeAll, describe, it } from 'vitest'; + +// Turbopack platform:'node' outputs CJS bundles (require/module.exports). +// Since this package has "type":"module", we need createRequire to load them. +const require = createRequire(import.meta.url); + +import type { MethodBundleResult } from '../src/Bundler.ts'; +import { Bundler } from '../src/Bundler.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MODULE_PATH = path.join(__dirname, './fixtures/apps/simple-app/app/module/user'); +const FOO_MODULE_PATH = path.join(__dirname, './fixtures/apps/multi-module-app/app/module/foo'); +const BAR_MODULE_PATH = path.join(__dirname, './fixtures/apps/multi-module-app/app/module/bar'); + +// Turbopack requires output paths within the project root — cannot use os.tmpdir() +const E2E_OUTPUT_DIR = path.join(__dirname, '.e2e-output'); + +describe('e2e: simple-app — @utoo/pack build pipeline', () => { + let results: MethodBundleResult[]; + let outputPath: string; + + beforeAll(async () => { + outputPath = path.join(E2E_OUTPUT_DIR, 'simple'); + await fs.mkdir(outputPath, { recursive: true }); + const bundler = new Bundler(); + results = await bundler.bundle({ + outputPath, + moduleReferences: [{ name: 'user', path: MODULE_PATH }], + }); + }, 60_000); + + afterAll(async () => { + if (outputPath) { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('produces expected number of method bundles', () => { + assert.equal(results.length, 2, 'should produce 2 method bundles (getUser, adminAction)'); + assert( + results.find((r) => r.key.includes('getUser')), + 'getUser bundle should exist', + ); + assert( + results.find((r) => r.key.includes('adminAction')), + 'adminAction bundle should exist', + ); + }); + + it('getUser bundle can be loaded in Node.js', async () => { + const result = results.find((r) => r.key.includes('getUser'))!; + const stat = await fs.stat(result.bundlePath); + assert(stat.size > 0, 'bundle file should not be empty'); + + // Turbopack node bundles are CJS — use require() to load + const mod = require(result.bundlePath); + assert(mod, 'bundle should be loadable'); + }); + + it('adminAction bundle can be loaded in Node.js', async () => { + const result = results.find((r) => r.key.includes('adminAction'))!; + const stat = await fs.stat(result.bundlePath); + assert(stat.size > 0, 'bundle file should not be empty'); + + const mod = require(result.bundlePath); + assert(mod, 'bundle should be loadable'); + }); + + it('meta JSON files are valid and consistent with results', async () => { + for (const result of results) { + const metaContent = await fs.readFile(result.metaPath, 'utf-8'); + const meta = JSON.parse(metaContent); + + assert.equal(meta.methodName, result.meta.methodName); + assert.equal(meta.className, result.meta.className); + assert.equal(meta.http.fullPath, result.meta.http.fullPath); + assert(Array.isArray(meta.dependencies)); + } + }); +}); + +describe('e2e: multi-module-app — cross-module @utoo/pack build', () => { + let results: MethodBundleResult[]; + let outputPath: string; + + beforeAll(async () => { + outputPath = path.join(E2E_OUTPUT_DIR, 'multi'); + await fs.mkdir(outputPath, { recursive: true }); + const bundler = new Bundler(); + results = await bundler.bundle({ + outputPath, + moduleReferences: [ + { name: 'foo', path: FOO_MODULE_PATH }, + { name: 'bar', path: BAR_MODULE_PATH }, + ], + }); + }, 60_000); + + afterAll(async () => { + if (outputPath) { + await fs.rm(outputPath, { recursive: true, force: true }); + } + }); + + it('produces expected number of method bundles', () => { + assert(results.length >= 2, `should produce at least 2 bundles, got ${results.length}`); + assert( + results.find((r) => r.key.includes('fetchUser')), + 'fetchUser bundle should exist', + ); + assert( + results.find((r) => r.key.includes('healthCheck')), + 'healthCheck bundle should exist', + ); + }); + + it('fetchUser bundle can be loaded in Node.js', async () => { + const result = results.find((r) => r.key.includes('fetchUser'))!; + const stat = await fs.stat(result.bundlePath); + assert(stat.size > 0, 'bundle file should not be empty'); + + const mod = require(result.bundlePath); + assert(mod, 'bundle should be loadable'); + }); + + it('healthCheck bundle can be loaded in Node.js', async () => { + const result = results.find((r) => r.key.includes('healthCheck'))!; + const stat = await fs.stat(result.bundlePath); + assert(stat.size > 0, 'bundle file should not be empty'); + + const mod = require(result.bundlePath); + assert(mod, 'bundle should be loadable'); + }); + + it('meta JSON files are valid for all bundles', async () => { + for (const result of results) { + const metaContent = await fs.readFile(result.metaPath, 'utf-8'); + const meta = JSON.parse(metaContent); + + assert.equal(meta.methodName, result.meta.methodName); + assert.equal(meta.className, result.meta.className); + assert.equal(meta.http.fullPath, result.meta.http.fullPath); + assert(Array.isArray(meta.dependencies)); + } + }); +}); diff --git a/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/MetricsService.ts b/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/MetricsService.ts new file mode 100644 index 0000000000..222706efba --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/MetricsService.ts @@ -0,0 +1,15 @@ +import { SingletonProto, AccessLevel } from '@eggjs/core-decorator'; + +/** + * A simple metrics service injected by the Advice class. + * This tests that Advice's own dependencies are resolvable in the graph. + */ +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class MetricsService { + async record(method: string, duration: number): Promise { + void method; + void duration; + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/OrderController.ts b/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/OrderController.ts new file mode 100644 index 0000000000..d558cb0d96 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/OrderController.ts @@ -0,0 +1,37 @@ +import { HTTPController, HTTPMethod, HTTPParam } from '@eggjs/controller-decorator'; +import { Inject } from '@eggjs/core-decorator'; +import { HTTPMethodEnum } from '@eggjs/tegg-types'; + +import type { OrderService } from './OrderService.ts'; + +@HTTPController({ + path: '/orders', +}) +export class OrderController { + @Inject() + orderService: OrderService; + + /** + * Dep chain: OrderController -> OrderService -> MetricsService + * MetricsService is the Advice-like service that does cross-cutting concerns. + * The bundler must include the full transitive chain. + */ + @HTTPMethod({ + path: '/:id', + method: HTTPMethodEnum.GET, + }) + async getOrder(@HTTPParam() id: string): Promise { + return this.orderService.getOrder(id); + } + + /** + * No service access — pure method, empty dep bundle. + */ + @HTTPMethod({ + path: '/ping', + method: HTTPMethodEnum.GET, + }) + async ping(): Promise<{ ok: boolean }> { + return { ok: true }; + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/OrderService.ts b/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/OrderService.ts new file mode 100644 index 0000000000..9a59b6f414 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/OrderService.ts @@ -0,0 +1,18 @@ +import { SingletonProto, AccessLevel, Inject } from '@eggjs/core-decorator'; + +import type { MetricsService } from './MetricsService.ts'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class OrderService { + @Inject() + private metricsService: MetricsService; + + async getOrder(id: string): Promise<{ id: string; amount: number }> { + const start = Date.now(); + const result = { id, amount: 100 }; + await this.metricsService.record('getOrder', Date.now() - start); + return result; + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/package.json b/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/package.json new file mode 100644 index 0000000000..dc934e639d --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/aop-app/app/module/order/package.json @@ -0,0 +1,6 @@ +{ + "name": "order", + "eggModule": { + "name": "order" + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/NotifierService.ts b/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/NotifierService.ts new file mode 100644 index 0000000000..964e34a2ac --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/NotifierService.ts @@ -0,0 +1,33 @@ +import { SingletonProto, AccessLevel, Inject } from '@eggjs/core-decorator'; + +import type { UserService } from './UserService.ts'; + +/** + * Simulates the framework's EventBus built-in. + * NOT decorated with @SingletonProto, so it is NOT registered in GlobalGraph. + * The bundler/DependencyResolver must silently skip it without crashing. + */ +class EventBus { + emit(_event: string, _data: unknown): void {} +} + +/** + * Service that injects both a user-defined service AND the framework built-in EventBus. + * The bundler must gracefully skip EventBus (not in GlobalGraph) without crashing. + */ +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class NotifierService { + @Inject() + private userService: UserService; + + // Framework built-in - NOT a user-defined proto, not resolvable in GlobalGraph + @Inject() + private eventBus: EventBus; + + async notifyUser(userId: string): Promise { + const user = await this.userService.find(userId); + this.eventBus.emit('user.notified', { userId: user.id }); + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/NotifyController.ts b/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/NotifyController.ts new file mode 100644 index 0000000000..23e6af09f9 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/NotifyController.ts @@ -0,0 +1,26 @@ +import { HTTPController, HTTPMethod, HTTPParam } from '@eggjs/controller-decorator'; +import { Inject } from '@eggjs/core-decorator'; +import { HTTPMethodEnum } from '@eggjs/tegg-types'; + +import type { NotifierService } from './NotifierService.ts'; + +@HTTPController({ + path: '/notify', +}) +export class NotifyController { + @Inject() + notifierService: NotifierService; + + /** + * This method's dep chain: NotifyController -> NotifierService -> UserService + * NotifierService also injects EventBus (framework built-in), which must be + * silently skipped during dep resolution (findInjectProto returns null for it). + */ + @HTTPMethod({ + path: '/user/:id', + method: HTTPMethodEnum.POST, + }) + async notifyUser(@HTTPParam() id: string): Promise { + await this.notifierService.notifyUser(id); + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/UserService.ts b/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/UserService.ts new file mode 100644 index 0000000000..25adf7fcea --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/UserService.ts @@ -0,0 +1,10 @@ +import { SingletonProto, AccessLevel } from '@eggjs/core-decorator'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class UserService { + async find(id: string): Promise<{ id: string; name: string }> { + return { id, name: 'user' }; + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/package.json b/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/package.json new file mode 100644 index 0000000000..564dc9230e --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/eventbus-app/app/module/notify/package.json @@ -0,0 +1,6 @@ +{ + "name": "notify", + "eggModule": { + "name": "notify" + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/bar/BarController.ts b/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/bar/BarController.ts new file mode 100644 index 0000000000..5ae5f8fcf0 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/bar/BarController.ts @@ -0,0 +1,51 @@ +import { HTTPBody, HTTPController, HTTPMethod, HTTPParam, HTTPQuery } from '@eggjs/controller-decorator'; +import { Inject } from '@eggjs/core-decorator'; +import { HTTPMethodEnum } from '@eggjs/tegg-types'; + +import type { FooService } from '../foo/FooService.ts'; + +@HTTPController({ + path: '/bar', +}) +export class BarController { + // Cross-module injection: FooService is from the foo module + @Inject() + fooService: FooService; + + /** + * Fetches a user via FooService (cross-module dep). + * Bundle should include FooService + FooRepository (transitive). + */ + @HTTPMethod({ + path: '/users/:id', + method: HTTPMethodEnum.GET, + }) + async fetchUser(@HTTPParam() id: string, @HTTPQuery() fields: string): Promise { + const user = await this.fooService.getUser(id); + return { user, fields }; + } + + /** + * Creates a user via FooService. + * Bundle should also include FooService + FooRepository. + */ + @HTTPMethod({ + path: '/users', + method: HTTPMethodEnum.POST, + }) + async createUser(@HTTPBody() body: { id: string; name: string }): Promise { + await this.fooService.createUser(body); + } + + /** + * Health check - no service access at all. + * Bundle should have empty deps. + */ + @HTTPMethod({ + path: '/health', + method: HTTPMethodEnum.GET, + }) + async healthCheck(): Promise<{ status: string }> { + return { status: 'ok' }; + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/bar/package.json b/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/bar/package.json new file mode 100644 index 0000000000..acfa2b4807 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/bar/package.json @@ -0,0 +1,6 @@ +{ + "name": "bar", + "eggModule": { + "name": "bar" + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/foo/FooRepository.ts b/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/foo/FooRepository.ts new file mode 100644 index 0000000000..567ec55fbb --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/foo/FooRepository.ts @@ -0,0 +1,13 @@ +import { SingletonProto } from '@eggjs/core-decorator'; + +// PRIVATE (default) - internal to foo module, not accessible from bar +@SingletonProto() +export class FooRepository { + async findById(id: string): Promise<{ id: string; name: string } | null> { + return { id, name: 'repo user' }; + } + + async save(data: { id: string; name: string }): Promise { + void data; + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/foo/FooService.ts b/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/foo/FooService.ts new file mode 100644 index 0000000000..f783ec6437 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/foo/FooService.ts @@ -0,0 +1,20 @@ +import { SingletonProto, AccessLevel, Inject } from '@eggjs/core-decorator'; + +import type { FooRepository } from './FooRepository.ts'; + +// PUBLIC - exposed to other modules (e.g. bar module's BarController) +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class FooService { + @Inject() + private fooRepository: FooRepository; + + async getUser(id: string): Promise<{ id: string; name: string } | null> { + return this.fooRepository.findById(id); + } + + async createUser(data: { id: string; name: string }): Promise { + return this.fooRepository.save(data); + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/foo/package.json b/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/foo/package.json new file mode 100644 index 0000000000..7f30a3209c --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/multi-module-app/app/module/foo/package.json @@ -0,0 +1,6 @@ +{ + "name": "foo", + "eggModule": { + "name": "foo" + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/ApiController.ts b/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/ApiController.ts new file mode 100644 index 0000000000..4ab45fff1e --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/ApiController.ts @@ -0,0 +1,63 @@ +import { HTTPController, HTTPMethod, HTTPParam } from '@eggjs/controller-decorator'; +import { Inject } from '@eggjs/core-decorator'; +import { HTTPMethodEnum } from '@eggjs/tegg-types'; + +import type { OrderService } from './OrderService.ts'; +import type { UserService } from './UserService.ts'; + +@HTTPController({ + path: '/api', +}) +export class ApiController { + @Inject() + userService: UserService; + + @Inject() + orderService: OrderService; + + /** + * Delegates to a private method that accesses userService. + * MethodAnalyzer must follow #loadUser() to detect userService access. + */ + @HTTPMethod({ + path: '/profile/:id', + method: HTTPMethodEnum.GET, + }) + async getProfile(@HTTPParam() id: string): Promise { + return this.#loadUser(id); + } + + /** + * Delegates to a private method that accesses orderService. + * MethodAnalyzer must follow #loadOrder() to detect orderService access. + */ + @HTTPMethod({ + path: '/order/:id', + method: HTTPMethodEnum.GET, + }) + async getOrder(@HTTPParam() id: string): Promise { + return this.#loadOrder(id); + } + + /** + * Uses both services directly (no private methods). + */ + @HTTPMethod({ + path: '/summary/:id', + method: HTTPMethodEnum.GET, + }) + async getSummary(@HTTPParam() id: string): Promise { + const user = await this.userService.find(id); + const order = await this.orderService.find(id); + return { user, order }; + } + + // Private helpers - accessed indirectly via public methods + #loadUser(id: string): Promise { + return this.userService.find(id); + } + + #loadOrder(id: string): Promise { + return this.orderService.find(id); + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/OrderService.ts b/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/OrderService.ts new file mode 100644 index 0000000000..8daabe32d9 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/OrderService.ts @@ -0,0 +1,10 @@ +import { SingletonProto, AccessLevel } from '@eggjs/core-decorator'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class OrderService { + async find(id: string): Promise<{ id: string; amount: number }> { + return { id, amount: 100 }; + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/UserService.ts b/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/UserService.ts new file mode 100644 index 0000000000..25adf7fcea --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/UserService.ts @@ -0,0 +1,10 @@ +import { SingletonProto, AccessLevel } from '@eggjs/core-decorator'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class UserService { + async find(id: string): Promise<{ id: string; name: string }> { + return { id, name: 'user' }; + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/package.json b/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/package.json new file mode 100644 index 0000000000..3007045484 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/private-method-app/app/module/api/package.json @@ -0,0 +1,6 @@ +{ + "name": "api", + "eggModule": { + "name": "api" + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/AdminService.ts b/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/AdminService.ts new file mode 100644 index 0000000000..776eed7252 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/AdminService.ts @@ -0,0 +1,14 @@ +import { SingletonProto, AccessLevel } from '@eggjs/core-decorator'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class AdminService { + async doAdminAction(): Promise { + return 'admin action done'; + } + + async listAdmins(): Promise { + return ['admin1', 'admin2']; + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/UserController.ts b/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/UserController.ts new file mode 100644 index 0000000000..55bb4a63a4 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/UserController.ts @@ -0,0 +1,44 @@ +import { HTTPController, HTTPMethod, HTTPParam, HTTPQuery } from '@eggjs/controller-decorator'; +import { Inject } from '@eggjs/core-decorator'; +import { HTTPMethodEnum } from '@eggjs/tegg-types'; + +import type { AdminService } from './AdminService.ts'; +import type { UserService } from './UserService.ts'; + +@HTTPController({ + path: '/api/users', +}) +export class UserController { + @Inject() + userService: UserService; + + @Inject() + adminService: AdminService; + + /** + * Get a single user - only uses userService, NOT adminService. + * This is the key test: getUser bundle should exclude AdminService. + */ + @HTTPMethod({ + path: '/:id', + method: HTTPMethodEnum.GET, + }) + async getUser(@HTTPParam() id: string, @HTTPQuery() fields: string): Promise { + const user = await this.userService.getUser(id); + return { user, fields }; + } + + /** + * Admin action - uses BOTH userService and adminService. + * This bundle should include both services. + */ + @HTTPMethod({ + path: '/admin', + method: HTTPMethodEnum.POST, + }) + async adminAction(): Promise { + const user = await this.userService.getUser('admin'); + const result = await this.adminService.doAdminAction(); + return { user, result }; + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/UserService.ts b/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/UserService.ts new file mode 100644 index 0000000000..65d3a054f0 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/UserService.ts @@ -0,0 +1,14 @@ +import { SingletonProto, AccessLevel } from '@eggjs/core-decorator'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class UserService { + async getUser(id: string): Promise<{ id: string; name: string }> { + return { id, name: 'test user' }; + } + + async listUsers(): Promise> { + return [{ id: '1', name: 'user1' }]; + } +} diff --git a/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/package.json b/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/package.json new file mode 100644 index 0000000000..d5e39ed803 --- /dev/null +++ b/tegg/core/bundler/test/fixtures/apps/simple-app/app/module/user/package.json @@ -0,0 +1,6 @@ +{ + "name": "user", + "eggModule": { + "name": "user" + } +} diff --git a/tegg/core/bundler/tsconfig.json b/tegg/core/bundler/tsconfig.json new file mode 100644 index 0000000000..618c6c3e97 --- /dev/null +++ b/tegg/core/bundler/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.json" +} diff --git a/tegg/core/bundler/vitest.config.ts b/tegg/core/bundler/vitest.config.ts new file mode 100644 index 0000000000..95f7928ba7 --- /dev/null +++ b/tegg/core/bundler/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, type UserWorkspaceConfig } from 'vitest/config'; + +const config: UserWorkspaceConfig = defineConfig({ + test: { + include: ['test/**/*.test.ts'], + exclude: ['test/fixtures/**', '**/node_modules/**', '**/dist/**'], + coverage: { + provider: 'v8', + exclude: ['test/**'], + }, + hookTimeout: 20000, + testTimeout: 20000, + env: { + // disable tegg plugins by default on unittest + DISABLE_TEGG_PLUGINS: 'true', + // aop plugin required this flag, otherwise there will be a SyntaxError + NODE_OPTIONS: '--import=tsx/esm', + }, + experimental: { + fsModuleCache: true, + }, + }, +}); + +export default config; diff --git a/tegg/core/loader/src/LoaderUtil.ts b/tegg/core/loader/src/LoaderUtil.ts index b93500391f..83583833a6 100644 --- a/tegg/core/loader/src/LoaderUtil.ts +++ b/tegg/core/loader/src/LoaderUtil.ts @@ -1,4 +1,5 @@ import BuiltinModule from 'node:module'; +import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { PrototypeUtil } from '@eggjs/core-decorator'; @@ -58,6 +59,8 @@ export class LoaderUtil { } static async loadFile(filePath: string): Promise { + // Save the original absolute path before Windows conversion + const absoluteFilePath = filePath; if (process.platform === 'win32') { // convert to file:// url // avoid windows path issue: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'd:' @@ -82,6 +85,12 @@ export class LoaderUtil { if (!isEggProto) { continue; } + // Ensure file path is correct — async module evaluators (e.g. vitest) + // can cause StackUtil.getCalleeFromStack to capture wrong stack frames + const currentPath = PrototypeUtil.getFilePath(clazz); + if (!currentPath || !path.isAbsolute(currentPath)) { + PrototypeUtil.setFilePath(clazz, absoluteFilePath); + } clazzList.push(clazz); } return clazzList; diff --git a/tsconfig.json b/tsconfig.json index 14e5ac8d41..ab0330c1f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -109,6 +109,9 @@ }, { "path": "./tegg/plugin/orm" + }, + { + "path": "./tegg/core/bundler" } ] }