Skip to content

Commit 126cdd3

Browse files
authored
fix(core): detect class when Function#toString is unreliable (#4553)
* fix: enhance isClass function to handle Bytenode obfuscation Updated the isClass function to correctly identify class constructors even when their source text is obscured by tools like Bytenode. Added tests to validate this behavior, ensuring that classes are accurately recognized regardless of modifications to Function.prototype.toString. Additionally, updated deployment documentation to reflect the importance of this fix when using Bytenode. * fix: narrow isClass fallback to exclude native constructors The prototype-based fallback incorrectly treated native constructors (Object, Array, Function, etc.) as user-defined classes because they also have non-writable prototype. Add a [native code] check to the fallback condition to skip these. This caused CI failures in validation/validation-zod tests because MetadataManager.formatTarget() would normalize targets incorrectly, leading to lost metadata on PickDto/OmitDto. Also use jest.isolateModules in tests so the toString mock takes effect after module-level caching.
1 parent 257e51d commit 126cdd3

4 files changed

Lines changed: 170 additions & 1 deletion

File tree

packages/core/src/util/types.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,24 @@ export function isClass(fn) {
1313
return false;
1414
}
1515

16-
if (/^class[\s{]/.test(ToString.call(fn))) {
16+
const fnSource = ToString.call(fn);
17+
if (/^class[\s{]/.test(fnSource)) {
1718
return true;
1819
}
20+
21+
// Tools like Bytenode replace function source text, so Function#toString is
22+
// unreliable. ECMAScript class constructors have a non-writable `prototype`.
23+
// Native constructors also satisfy this shape, so skip "[native code]" here.
24+
const descriptor = Object.getOwnPropertyDescriptor(fn, 'prototype');
25+
if (
26+
descriptor &&
27+
descriptor.writable === false &&
28+
!/\[native code\]/.test(fnSource)
29+
) {
30+
return true;
31+
}
32+
33+
return false;
1934
}
2035

2136
export function isAsyncFunction(value) {

packages/core/test/decorator/util/index.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,42 @@ describe('/test/util/index.test.ts', () => {
2323
expect(Types.isString('')).toBeTruthy();
2424
expect(Types.isString(undefined)).toBeFalsy();
2525
expect(Types.isString({})).toBeFalsy();
26+
27+
function plainFn() {}
28+
const asyncFn = async function () {};
29+
expect(Types.isClass(plainFn)).toBeFalsy();
30+
expect(Types.isClass(asyncFn)).toBeFalsy();
31+
expect(Types.isClass(() => {})).toBeFalsy();
32+
expect(Types.isClass(Object)).toBeFalsy();
33+
expect(Types.isClass(Array)).toBeFalsy();
34+
expect(Types.isClass(Function)).toBeFalsy();
35+
});
36+
37+
it('should detect class when toString source is obscured', () => {
38+
class ObscuredCtor {}
39+
const origToString = Function.prototype.toString;
40+
try {
41+
jest.resetModules();
42+
Function.prototype.toString = function () {
43+
if (this === ObscuredCtor) {
44+
return 'function () {}';
45+
}
46+
return origToString.call(this);
47+
};
48+
49+
let isolatedTypes!: typeof Types;
50+
jest.isolateModules(() => {
51+
isolatedTypes = require('../../../src/util/types').Types;
52+
});
53+
54+
expect(isolatedTypes.isClass(ObscuredCtor)).toBeTruthy();
55+
expect(isolatedTypes.isClass(Object)).toBeFalsy();
56+
expect(isolatedTypes.isClass(Array)).toBeFalsy();
57+
expect(isolatedTypes.isClass(Function)).toBeFalsy();
58+
} finally {
59+
Function.prototype.toString = origToString;
60+
jest.resetModules();
61+
}
2662
});
2763

2864
it('should test toAsyncFunction', async () => {

site/docs/deployment.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,65 @@ $ npm run bundle_start
715715
716716
717717
718+
### 使用 Bytenode 编译为字节码(可选)
719+
720+
在已完成 [单文件构建](#构建)(例如得到 `build/index.js`)之后,可以再用 [Bytenode](https://github.com/bytenode/bytenode) 将 JavaScript 编译为 V8 字节码(`.jsc`),进一步减少明文源码暴露。注意:**字节码与编译时使用的 Node.js 主版本强相关**,部署环境应使用相同(或官方保证兼容)的 Node 版本;跨机器部署前请在同版本 Node 下做冒烟验证。
721+
722+
Midway 在运行时会通过 `isClass` 等逻辑识别 ES `class` 构造函数。Bytenode 会替换 `Function.prototype.toString` 所见的源码,旧实现仅依赖 `toString` 可能把类误判为普通函数,从而导致依赖注入异常。**请使用包含该修复版本的 `@midwayjs/core`**(见源码 [`packages/core/src/util/types.ts`](https://github.com/midwayjs/midway/blob/main/packages/core/src/util/types.ts) 中 `isClass`)。
723+
724+
:::warning
725+
726+
Bytenode 官方说明:任何依赖 `Function.prototype.toString` 的逻辑在字节码下都可能异常(参见 [bytenode#34](https://github.com/bytenode/bytenode/issues/34))。除框架侧识别外,业务代码也应避免依赖函数源码字符串。
727+
728+
:::
729+
730+
#### 前置依赖
731+
732+
```bash
733+
$ npm i bytenode --save-dev
734+
```
735+
736+
`package.json` 中可写作:
737+
738+
```json
739+
{
740+
"devDependencies": {
741+
"bytenode": "^1.5.7"
742+
}
743+
}
744+
```
745+
746+
(版本号请安装时以 npm 可查到的最新兼容版本为准。)
747+
748+
#### 编译与启动
749+
750+
在单文件产物生成后,将入口 JS 编译为字节码,例如:
751+
752+
```bash
753+
$ npx bytenode --compile build/index.js
754+
```
755+
756+
默认会在同目录生成 `build/index.jsc`。使用 Bytenode 自带的方式启动(需已安装 `bytenode`,可为项目依赖或全局):
757+
758+
```bash
759+
$ NODE_ENV=production npx bytenode ./build/index.jsc
760+
```
761+
762+
也可将上述步骤写入 `scripts`,例如:
763+
764+
```json
765+
{
766+
"scripts": {
767+
"bundle_jsc": "npm run bundle && npx bytenode --compile build/index.js",
768+
"bundle_jsc_start": "NODE_ENV=production npx bytenode ./build/index.jsc"
769+
}
770+
}
771+
```
772+
773+
[单文件构建部署](#单文件构建部署) 一节的约束仍然适用:例如依赖注入相关代码不要使用默认导出、配置需为对象模式、数据源 `entities` 路径扫描等限制不变。字节码发布后调试难度更高,建议保留未编译的构建流水线用于排障。
774+
775+
776+
718777
## 二进制文件部署
719778

720779
将 Node.js 打包为一个单独的可执行文件,部署时直接拷贝执行即可,这种方式包含了 node 运行时,业务代码,有利于保护知识产权。

site/i18n/en/docusaurus-plugin-content-docs/current/deployment.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,65 @@ If boot access is fine, then you can distribute your build in the build director
710710
711711
712712
713+
### Optional: compile to bytecode with Bytenode
714+
715+
After a [single-file build](#construct) (for example `build/index.js`), you can use [Bytenode](https://github.com/bytenode/bytenode) to compile JavaScript into V8 bytecode (`.jsc`) and further reduce plaintext source exposure. **Bytecode is tied to the Node.js major version used at compile time**; production should run the same (or officially compatible) Node version, and you should smoke-test on that version before rollout.
716+
717+
Midway identifies ES `class` constructors in places like `isClass`. Bytenode replaces what `Function.prototype.toString` returns, so an implementation that only checks `toString` may treat classes as plain functions and break dependency injection. **Use a `@midwayjs/core` release that includes this fix** (see `isClass` in [`packages/core/src/util/types.ts`](https://github.com/midwayjs/midway/blob/main/packages/core/src/util/types.ts)).
718+
719+
:::warning
720+
721+
Bytenode documents that anything relying on `Function.prototype.toString` may break with bytecode (see [bytenode#34](https://github.com/bytenode/bytenode/issues/34)). Besides framework detection, avoid depending on function source strings in application code.
722+
723+
:::
724+
725+
#### Prerequisites
726+
727+
```bash
728+
$ npm i bytenode --save-dev
729+
```
730+
731+
In `package.json`:
732+
733+
```json
734+
{
735+
"devDependencies": {
736+
"bytenode": "^1.5.7"
737+
}
738+
}
739+
```
740+
741+
(Use a current compatible version from npm when you install.)
742+
743+
#### Compile and run
744+
745+
After the single-file bundle exists, compile the entry file:
746+
747+
```bash
748+
$ npx bytenode --compile build/index.js
749+
```
750+
751+
This typically produces `build/index.jsc` next to the source. Start it with Bytenode (local `node_modules` or global install):
752+
753+
```bash
754+
$ NODE_ENV=production npx bytenode ./build/index.jsc
755+
```
756+
757+
You can script it, for example:
758+
759+
```json
760+
{
761+
"scripts": {
762+
"bundle_jsc": "npm run bundle && npx bytenode --compile build/index.js",
763+
"bundle_jsc_start": "NODE_ENV=production npx bytenode ./build/index.jsc"
764+
}
765+
}
766+
```
767+
768+
All constraints from the [single-file deployment](#single-file-deployment) section still apply: no default exports in DI-related code, config as object mode, datasource `entities` path scanning limitations, etc. Bytecode is harder to debug; keep an uncompiled build path for troubleshooting.
769+
770+
771+
713772
## Binary deployment
714773

715774
Package Node.js into a single executable file, which can be directly copied and executed during deployment. This method includes the node runtime and business code, which is conducive to the protection of intellectual property rights.

0 commit comments

Comments
 (0)