@gadmin2n/schematics 0.0.158 → 0.0.159

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,7 @@
18
18
  "start:debug": "dotenv -e .env.local -e .env -- nest start -c gadmin-cli.json --debug --watch",
19
19
  "start:prod": "/bin/sh start-prod.sh",
20
20
  "typecheck": "tsc --noEmit",
21
+ "check:swc-leak": "node scripts/check-swc-watch-leak.js",
21
22
  "lint": "oxlint src test",
22
23
  "lint:fix": "oxlint src test --fix",
23
24
  "test": "jest",
@@ -30,7 +31,7 @@
30
31
  "migrate:deploy": "dotenv -e .env.local -e .env -- prisma migrate deploy",
31
32
  "migrate:status": "dotenv -e .env.local -e .env -- prisma migrate status",
32
33
  "generate": "dotenv -e .env.local -e .env -- npx prisma generate",
33
- "generate:dev": "pnpm migrate:dev",
34
+ "generate:dev": "npm run migrate:dev",
34
35
  "postgenerate:dev": "dotenv -e .env.local -e .env -- ts-node seed/data-mngt.seed.ts",
35
36
  "postgadminGenerate": "node scripts/notify-dev-servers.js",
36
37
  "push:db": "dotenv -e .env.local -e .env -- prisma db push --skip-generate",
@@ -151,7 +152,7 @@
151
152
  "node": ">=22.13"
152
153
  },
153
154
  "prisma": {
154
- "seed": "pnpm seed"
155
+ "seed": "npm run seed"
155
156
  },
156
157
  "lint-staged": {
157
158
  "**/*.{ts,tsx}": "oxfmt"
@@ -0,0 +1,20 @@
1
+ diff --git a/lib/compiler/swc/swc-compiler.js b/lib/compiler/swc/swc-compiler.js
2
+ index 5e8fdad81ea71216755876461c3835a23df1c653..ff2441021b5f294fa859b1553924cc558f69de02 100644
3
+ --- a/lib/compiler/swc/swc-compiler.js
4
+ +++ b/lib/compiler/swc/swc-compiler.js
5
+ @@ -126,6 +126,15 @@ class SwcCompiler extends base_compiler_1.BaseCompiler {
6
+ cliOptions: {
7
+ ...swcCliOpts.cliOptions,
8
+ filenames: [file],
9
+ + // PATCH: this call only transpiles the single newly-added file.
10
+ + // Leaving watch=true made swcDir() run watchCompilation() again,
11
+ + // leaking a chokidar watcher per event; and sync=false made it
12
+ + // spawn a Piscina pool (never destroyed upstream) just for one
13
+ + // file. sync=true takes the direct dirWorker path: no pool, no
14
+ + // watcher, and ~40x faster for a single file.
15
+ + // Upstream: https://github.com/swc-project/pkgs/issues/126
16
+ + watch: false,
17
+ + sync: true,
18
+ },
19
+ });
20
+ });
@@ -24,3 +24,6 @@ allowBuilds:
24
24
 
25
25
  minimumReleaseAgeExclude:
26
26
  - '@gadmin2n/prisma-nest-generator@0.0.50'
27
+
28
+ patchedDependencies:
29
+ '@nestjs/cli@11.0.21': patches/@nestjs__cli@11.0.21.patch
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @nestjs/cli swc watch 模式的线程/句柄泄漏回归检测。
4
+ *
5
+ * node scripts/check-swc-watch-leak.js
6
+ *
7
+ * 背景:
8
+ * nest CLI 的 swc builder 在 watch 模式下,每个「新增文件」事件都会重新调用
9
+ * @swc/cli 的 swcDir(),且传入的 cliOptions 仍带 watch:true / sync:false:
10
+ *
11
+ * - sync:false → swcDir 走 Piscina 分支,为编译 1 个文件创建一个 worker
12
+ * pool;而上游 @swc/cli 从不 destroy 它(issue 见下),
13
+ * 于是每次事件永久钉住 cores/2 个 idle worker。
14
+ * - watch:true → swcDir 末尾又跑一次 watchCompilation(),每次事件多泄漏
15
+ * 一个 chokidar watcher(各带 fd/kqueue)。
16
+ *
17
+ * 实测(14 核 macOS):每次新增文件 +7 常驻线程,8 次后 RSS 从 90MB 涨到
18
+ * 681MB;长时间 dev 会话可累积上千线程、十余 GB 足迹。
19
+ *
20
+ * 上游 issue:https://github.com/swc-project/pkgs/issues/126
21
+ * (@swc/cli 0.8.1 与 @nestjs/cli 11.0.24 均未修复)
22
+ *
23
+ * 本项目的处置:
24
+ * patches/@nestjs__cli@<version>.patch 给 file-add 回调补上
25
+ * watch:false + sync:true —— 单文件改走 dirWorker 直连,既不建 pool 也不建
26
+ * watcher,且比原路径快约 40 倍。initial 全量编译不受影响(仍是 sync:false
27
+ * 并行),因为 nest 那里是 spread 出的独立对象。
28
+ *
29
+ * 何时需要跑本脚本:
30
+ * 升级 @nestjs/cli 或 @swc/cli 之后。patch 锚点是编译产物,上游重构可能让它
31
+ * 失效;pnpm 在 patch 无法应用时会直接报错,但「能应用却语义变了」只能靠这里
32
+ * 兜住。
33
+ *
34
+ * 退出码:0 = 正常,1 = 检测到回归。
35
+ */
36
+
37
+ const fs = require('fs');
38
+ const os = require('os');
39
+ const path = require('path');
40
+ const { execSync } = require('child_process');
41
+
42
+ const SERVER_DIR = path.join(__dirname, '..');
43
+ const SWC_COMPILER = path.join(
44
+ SERVER_DIR,
45
+ 'node_modules/@nestjs/cli/lib/compiler/swc/swc-compiler.js',
46
+ );
47
+ const SWC_DIR_MOD = path.join(SERVER_DIR, 'node_modules/@swc/cli/lib/swc/dir.js');
48
+
49
+ const RED = '\x1b[31m';
50
+ const GREEN = '\x1b[32m';
51
+ const YELLOW = '\x1b[33m';
52
+ const DIM = '\x1b[90m';
53
+ const RESET = '\x1b[0m';
54
+
55
+ const problems = [];
56
+
57
+ function ok(msg) {
58
+ console.log(`${GREEN}✓${RESET} ${msg}`);
59
+ }
60
+ function bad(msg) {
61
+ console.log(`${RED}✗${RESET} ${msg}`);
62
+ problems.push(msg);
63
+ }
64
+ function info(msg) {
65
+ console.log(`${DIM} ${msg}${RESET}`);
66
+ }
67
+
68
+ /** 本进程线程数(不含主线程记账差异,仅用于相对比较)。 */
69
+ function threadCount() {
70
+ if (process.platform === 'linux') {
71
+ return fs.readdirSync('/proc/self/task').length;
72
+ }
73
+ // darwin:ps -M 每行一个线程,首行是表头
74
+ const out = execSync(`ps -M ${process.pid}`, { encoding: 'utf8' });
75
+ return out.trim().split('\n').length - 1;
76
+ }
77
+
78
+ function handleCount() {
79
+ return typeof process._getActiveHandles === 'function'
80
+ ? process._getActiveHandles().length
81
+ : -1;
82
+ }
83
+
84
+ // ---------- 静态检查:patch 是否还在,且语义是我们要的 ----------
85
+
86
+ function checkPatchApplied() {
87
+ if (!fs.existsSync(SWC_COMPILER)) {
88
+ bad(`找不到 ${path.relative(SERVER_DIR, SWC_COMPILER)},@nestjs/cli 未安装?`);
89
+ return false;
90
+ }
91
+ const src = fs.readFileSync(SWC_COMPILER, 'utf8');
92
+
93
+ // 锚点:nest 自己的注释。上游若重构掉它,说明 patch 需要重新审查。
94
+ const anchor = 'Transpile newly added file';
95
+ const at = src.indexOf(anchor);
96
+ if (at === -1) {
97
+ bad(
98
+ `swc-compiler.js 里找不到锚点注释 "${anchor}"——上游结构已变,` +
99
+ `patch 可能已失效,需重新审查 patches/ 下的补丁`,
100
+ );
101
+ return false;
102
+ }
103
+
104
+ // 精确截出锚点之后那一次 swcCli.default(...) 的实参范围:
105
+ // 用括号配平,避免固定窗口切断(patch 注释较长)或越界到相邻函数。
106
+ const callAt = src.indexOf('swcCli.default(', at);
107
+ if (callAt === -1) {
108
+ bad('锚点之后找不到 swcCli.default(...) 调用——上游结构已变,需重新审查 patch');
109
+ return false;
110
+ }
111
+ let depth = 0;
112
+ let end = -1;
113
+ for (let i = src.indexOf('(', callAt); i < src.length; i++) {
114
+ if (src[i] === '(') depth += 1;
115
+ else if (src[i] === ')') {
116
+ depth -= 1;
117
+ if (depth === 0) {
118
+ end = i;
119
+ break;
120
+ }
121
+ }
122
+ }
123
+ if (end === -1) {
124
+ bad('swcCli.default(...) 括号无法配平,跳过静态检查');
125
+ return false;
126
+ }
127
+ const region = src.slice(callAt, end + 1);
128
+ // 合理性校验:这段必须是那个 cliOptions 对象,否则说明定位错了
129
+ if (!region.includes('filenames')) {
130
+ bad('定位到的 swcCli.default(...) 实参不含 filenames,静态检查定位失败');
131
+ return false;
132
+ }
133
+ const hasWatchFalse = /watch:\s*false/.test(region);
134
+ const hasSyncTrue = /sync:\s*true/.test(region);
135
+
136
+ if (hasWatchFalse && hasSyncTrue) {
137
+ ok('patch 已生效:file-add 回调带 watch:false + sync:true');
138
+ return true;
139
+ }
140
+ bad(
141
+ 'patch 未生效:file-add 回调缺少 watch:false / sync:true' +
142
+ `(watch:false=${hasWatchFalse}, sync:true=${hasSyncTrue})。` +
143
+ '执行 pnpm install 重新应用,或按新版本重打 patch',
144
+ );
145
+ return false;
146
+ }
147
+
148
+ /** 顺带提示 @swc/cli 上游是否已自行修复(届时可考虑撤掉本项目的 patch)。 */
149
+ function checkUpstreamSwcCli() {
150
+ if (!fs.existsSync(SWC_DIR_MOD)) return;
151
+ const src = fs.readFileSync(SWC_DIR_MOD, 'utf8');
152
+ const hasPool = /new piscina_1\.default\(/.test(src);
153
+ const hasDestroy = /workers\.destroy\(\)/.test(src);
154
+ if (hasPool && hasDestroy) {
155
+ console.log(
156
+ `${YELLOW}!${RESET} @swc/cli 现在会 destroy worker pool(上游似已修复)——` +
157
+ '可复核是否还需要本项目的 nest patch',
158
+ );
159
+ } else if (hasPool) {
160
+ info('@swc/cli 仍未 destroy worker pool(上游未修复,patch 仍有必要)');
161
+ }
162
+ }
163
+
164
+ // ---------- 动态检查:按 nest 的调用形状实跑,量泄漏 ----------
165
+
166
+ const swcOptions = {
167
+ sourceMaps: true,
168
+ module: { type: 'commonjs' },
169
+ jsc: {
170
+ target: 'es2020',
171
+ parser: { syntax: 'typescript', decorators: true, dynamicImport: true },
172
+ transform: { legacyDecorator: true, decoratorMetadata: true },
173
+ keepClassNames: true,
174
+ },
175
+ minify: false,
176
+ swcrc: false,
177
+ };
178
+
179
+ async function measure(swcDir, label, shape, rounds, tmpRoot) {
180
+ const src = path.join(tmpRoot, `${label}-src`);
181
+ const out = path.join(tmpRoot, `${label}-dist`);
182
+ fs.mkdirSync(src, { recursive: true });
183
+
184
+ const before = { t: threadCount(), h: handleCount() };
185
+ const times = [];
186
+
187
+ for (let i = 0; i < rounds; i++) {
188
+ const file = path.join(src, `probe${i}.ts`);
189
+ fs.writeFileSync(
190
+ file,
191
+ "import { Injectable } from '@nestjs/common';\n" +
192
+ `@Injectable()\nexport class Probe${i} {\n` +
193
+ ` private readonly n = ${i};\n` +
194
+ ' run(x: number): string { return `${this.n}:${x}`; }\n}\n',
195
+ );
196
+ const started = Date.now();
197
+ await swcDir({
198
+ cliOptions: {
199
+ outDir: out,
200
+ filenames: [path.relative(process.cwd(), file)],
201
+ extensions: ['.js', '.ts'],
202
+ copyFiles: false,
203
+ includeDotfiles: false,
204
+ quiet: true,
205
+ stripLeadingPaths: true,
206
+ ...shape,
207
+ },
208
+ swcOptions,
209
+ });
210
+ times.push(Date.now() - started);
211
+ }
212
+
213
+ // 给 piscina 的 eager spawn 留出时间,避免低估
214
+ await new Promise((r) => setTimeout(r, 500));
215
+
216
+ const after = { t: threadCount(), h: handleCount() };
217
+ const compiled = fs.existsSync(out) ? fs.readdirSync(out).length : 0;
218
+ return {
219
+ dThreads: after.t - before.t,
220
+ dHandles: after.h - before.h,
221
+ avgMs: times.reduce((a, b) => a + b, 0) / times.length,
222
+ compiled,
223
+ };
224
+ }
225
+
226
+ async function main() {
227
+ console.log('swc watch 泄漏回归检测\n');
228
+
229
+ const patchOk = checkPatchApplied();
230
+ checkUpstreamSwcCli();
231
+ console.log('');
232
+
233
+ if (!fs.existsSync(SWC_DIR_MOD)) {
234
+ bad('找不到 @swc/cli,跳过动态检测');
235
+ return finish();
236
+ }
237
+
238
+ const swcDir = require(SWC_DIR_MOD).default;
239
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swc-leak-'));
240
+ const ROUNDS = 6;
241
+
242
+ try {
243
+ // 预热:@swc/core 的原生 binding 首次编译时会建自己的线程池,那是固有开销、
244
+ // 不是泄漏。用 sync:true 预热(该路径不创建 Piscina pool),免得记到第一组头上。
245
+ await measure(swcDir, 'warmup', { watch: false, sync: true }, 1, tmpRoot);
246
+
247
+ // 与 patch 后 nest 实际传参一致
248
+ const fixed = await measure(swcDir, 'fixed', { watch: false, sync: true }, ROUNDS, tmpRoot);
249
+ // 未打 patch 时 nest 的传参,作为对照,确认探针本身有区分力
250
+ const leaky = await measure(swcDir, 'leaky', { watch: true, sync: false }, ROUNDS, tmpRoot);
251
+
252
+ console.log(`${ROUNDS} 次 file-add(cpus=${os.cpus().length}):\n`);
253
+ console.log(' 调用形状 Δ线程 Δ句柄 平均耗时 产物');
254
+ console.log(' ' + '-'.repeat(62));
255
+ const row = (name, r) =>
256
+ ` ${name.padEnd(28)} ${String(r.dThreads).padStart(5)} ${String(r.dHandles).padStart(6)}` +
257
+ ` ${r.avgMs.toFixed(1).padStart(7)}ms ${r.compiled}`;
258
+ console.log(row('watch:false + sync:true', fixed));
259
+ console.log(row('watch:true + sync:false', leaky));
260
+ console.log('');
261
+
262
+ if (fixed.compiled === 0) {
263
+ bad('patch 形状下没有产物,编译链路异常');
264
+ }
265
+
266
+ if (fixed.dThreads > 0 || fixed.dHandles > 0) {
267
+ bad(
268
+ `patch 形状仍在泄漏(Δ线程=${fixed.dThreads}, Δ句柄=${fixed.dHandles}),` +
269
+ '预期均为 0——@swc/cli 行为可能已改变',
270
+ );
271
+ } else {
272
+ ok('patch 形状零泄漏(Δ线程=0, Δ句柄=0)');
273
+ }
274
+
275
+ if (leaky.dThreads <= 0 && leaky.dHandles <= 0) {
276
+ console.log(
277
+ `${YELLOW}!${RESET} 对照组也没泄漏——上游可能已修复,` +
278
+ '本项目的 patch 或许可以撤掉(请复核后再决定)',
279
+ );
280
+ } else {
281
+ info(
282
+ `对照组确认仍会泄漏(Δ线程=${leaky.dThreads}, Δ句柄=${leaky.dHandles}),` +
283
+ '探针有区分力',
284
+ );
285
+ }
286
+ } finally {
287
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
288
+ }
289
+
290
+ if (!patchOk) {
291
+ info('静态检查未通过,即使动态指标正常也应复核 patch');
292
+ }
293
+ finish();
294
+ }
295
+
296
+ function finish() {
297
+ console.log('');
298
+ if (problems.length) {
299
+ console.log(`${RED}检测到 ${problems.length} 个问题${RESET}`);
300
+ process.exit(1);
301
+ }
302
+ console.log(`${GREEN}全部通过${RESET}`);
303
+ process.exit(0);
304
+ }
305
+
306
+ main().catch((e) => {
307
+ console.error(`${RED}探针异常${RESET}: ${e && e.stack ? e.stack : e}`);
308
+ process.exit(1);
309
+ });
@@ -27,7 +27,7 @@
27
27
  * POSTGEN_POLL_MS 健康 poll 间隔,默认 250ms
28
28
  * POSTGEN_INITIAL_STABLE_MS 初始稳定窗口,默认 3000ms
29
29
  * POSTGEN_MAX_STABLE_MS 稳定窗口上限,默认 15000ms
30
- * POSTGEN_OBSERVE_DOWN_TIMEOUT_MS 等待第一次 down 的超时,默认 15000ms
30
+ * POSTGEN_OBSERVE_DOWN_TIMEOUT_MS 等待第一次 down 的超时,默认 5000ms
31
31
  * POSTGEN_TOTAL_TIMEOUT_MS Phase 2 硬超时,默认 60000ms
32
32
  *
33
33
  * 与 CLI 通信约定:
@@ -25,7 +25,9 @@ const DEFAULTS = {
25
25
  initialStableMs: 3000,
26
26
  maxStableMs: 15000,
27
27
  observeDownTimeoutMs: 5000,
28
- totalTimeoutMs: 15000,
28
+ // 必须显著大于 maxStableMs:否则窗口一旦扩到上限,这里的硬超时总会先触发,
29
+ // 自适应窗口永远等不到"稳定"就被 timeout 放行(等于退化成固定等待)。
30
+ totalTimeoutMs: 60000,
29
31
  };
30
32
 
31
33
  /**
@@ -58,7 +58,8 @@ test('nest 一开始就没跑:直接跳过', async () => {
58
58
  });
59
59
 
60
60
  test('Phase 1 超时(一直 up):跳过', async () => {
61
- // 100 次 up,覆盖 15s / 250ms = 60 次以上的 poll。
61
+ // 下面显式把 observeDownTimeoutMs 压到 1000ms,只需 1000/250 = 4 次 poll
62
+ // 就会命中 Phase 1 超时;100 次 up 是富余量。
62
63
  const env = makeEnv(new Array(100).fill('up'));
63
64
  const res = await waitForNestReady({
64
65
  ping: env.ping,
@@ -96,7 +96,7 @@ export default defineConfig(({ mode }) => {
96
96
  '**/.vite/**',
97
97
  // 生成物不参与 HMR:分发期间会有几百个 add/change 事件,
98
98
  // 若不 ignore 会触发大量级联失效。改由 gadminRestartPlugin
99
- // 在分发全部完成后一次性 server.restart() 加载最新内容。
99
+ // 在分发全部完成后一次性 invalidate + full-reload(见文件顶部说明)。
100
100
  '**/src/generated/**',
101
101
  ],
102
102
  awaitWriteFinish: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gadmin2n/schematics",
3
- "version": "0.0.158",
3
+ "version": "0.0.159",
4
4
  "description": "Gadmin - modern, fast, powerful node.js web framework (@schematics)",
5
5
  "main": "dist/index.js",
6
6
  "files": [