@dimina-kit/compiler 0.0.1 → 0.0.2-dev.20260711062001

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.
Files changed (27) hide show
  1. package/README.md +8 -3
  2. package/dist/compile-core.browser.js +1331 -1252
  3. package/dist/{pool.node-chunks/chunk-FVCSERCO.js → compile-core.node-chunks/chunk-LUD2P6RM.js} +5 -1
  4. package/dist/compile-core.node-chunks/{chunk-5ND5V2SC.js → chunk-QJ34C5KK.js} +77 -2
  5. package/dist/compile-core.node-chunks/{logic-compiler-RX63Y2FZ.js → logic-compiler-2SQFOFEO.js} +2 -2
  6. package/dist/compile-core.node-chunks/{style-compiler-JUY5MZSO.js → style-compiler-ZEJ3XLTS.js} +3 -3
  7. package/dist/compile-core.node-chunks/{view-compiler-KA2OZG44.js → view-compiler-WQNV7H5G.js} +25 -23
  8. package/dist/compile-core.node.js +10 -7
  9. package/dist/pool.browser.js +6 -5
  10. package/dist/pool.node-chunks/{chunk-UYQ7D5I5.js → chunk-7FGOYOXU.js} +77 -2
  11. package/dist/{compile-core.node-chunks/chunk-CCZB2BJG.js → pool.node-chunks/chunk-C7GEIDCP.js} +5 -1
  12. package/dist/pool.node-chunks/{chunk-MHOA4NGC.js → chunk-PDHO4Y56.js} +8 -5
  13. package/dist/pool.node-chunks/{logic-compiler-3VVLN3YN.js → logic-compiler-BODAINZQ.js} +2 -2
  14. package/dist/pool.node-chunks/{style-compiler-QOUWS72U.js → style-compiler-TUNDVSR5.js} +3 -3
  15. package/dist/pool.node-chunks/{view-compiler-KAYOMOXO.js → view-compiler-HOFFL63K.js} +25 -23
  16. package/dist/pool.node.js +34 -11
  17. package/dist/stage-worker.browser.js +1337 -1258
  18. package/dist/stage-worker.node.js +2 -2
  19. package/package.json +4 -2
  20. package/scripts/test-pool-filetypes.js +159 -0
  21. package/scripts/test-pool-node.js +93 -3
  22. package/scripts/test-pool-toolchain-death.js +135 -0
  23. package/scripts/toolchain-setup-node-native.js +13 -0
  24. package/src/compile-core.js +8 -5
  25. package/src/pool-node.js +84 -17
  26. package/src/pool.js +23 -10
  27. package/src/stage-worker.js +12 -8
@@ -3,12 +3,12 @@ import {
3
3
  preloadStage,
4
4
  resetCompilerState,
5
5
  runStage
6
- } from "./pool.node-chunks/chunk-MHOA4NGC.js";
6
+ } from "./pool.node-chunks/chunk-PDHO4Y56.js";
7
7
  import {
8
8
  getAppId,
9
9
  getAppName,
10
10
  resetStoreInfo
11
- } from "./pool.node-chunks/chunk-UYQ7D5I5.js";
11
+ } from "./pool.node-chunks/chunk-7FGOYOXU.js";
12
12
 
13
13
  // src/stage-worker-node.js
14
14
  import { createRequire } from "node:module";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimina-kit/compiler",
3
- "version": "0.0.1",
3
+ "version": "0.0.2-dev.20260711062001",
4
4
  "description": "dmcc compiler bundles (browser + node) that drive @dimina/compiler against a caller-injected node:fs replacement (no bundled fs)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -83,6 +83,7 @@
83
83
  "test:pool-node": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-pool-node.js",
84
84
  "test:pool-scopehash": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-pool-scopehash.js",
85
85
  "test:pool-hardening": "node scripts/build-compiler.js node && node scripts/test-pool-hardening.js",
86
+ "test:pool-toolchain-death": "node scripts/build-compiler.js node && node scripts/test-pool-toolchain-death.js",
86
87
  "test:pool-worker-hardening": "node scripts/test-pool-worker-hardening.js",
87
88
  "test:worker-slot": "node scripts/test-worker-slot.js",
88
89
  "test:pool-watchdog": "node scripts/test-pool-watchdog.js",
@@ -92,6 +93,7 @@
92
93
  "test:npm-scan": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-npm-scan.js",
93
94
  "test:stage-toolchain": "node scripts/build-compiler.js browser && node scripts/test-stage-toolchain.js",
94
95
  "test:stage-worker-message-order": "node scripts/build-compiler.js node && node scripts/test-stage-worker-message-order.js",
95
- "test:stage-load-retry": "node scripts/build-compiler.js node && node scripts/test-stage-load-retry.js"
96
+ "test:stage-load-retry": "node scripts/build-compiler.js node && node scripts/test-stage-load-retry.js",
97
+ "test:pool-filetypes": "node scripts/build-compiler.js node && node scripts/build-compiler.js browser && node scripts/test-pool-filetypes.js"
96
98
  }
97
99
  }
@@ -0,0 +1,159 @@
1
+ // Red/green coverage for `options.fileTypes` (custom template/style/view-script
2
+ // dialect, e.g. .qdml/.qdss/.qds) reaching the browser pool's compile protocol.
3
+ //
4
+ // dmcc's env.js already normalizes options.fileTypes into a `compilerOptions`
5
+ // bundle inside storeInfo (dimina/fe/packages/compiler/src/env.js:91-120), and
6
+ // that bundle rides inside bundle.storeInfo through the setup->compile-subset
7
+ // broadcast (compile-core.js's compileStage -> resetStoreInfo, env.js:122-132) —
8
+ // see src/pool.js's runAttempt and src/stage-worker.js's runSetup/compileSubset.
9
+ // This test drives the REAL browser bundles (dist/pool.browser.js +
10
+ // dist/stage-worker.browser.js) in Node, so it fails (red) without the
11
+ // options-threading fix and passes (green) with it — no reimplementation of the
12
+ // protocol handlers under test.
13
+ //
14
+ // Fixture mirrors qdmp's e2e "qd-app" (see
15
+ // ~/code/qdmp/main/packages/qdmp-devtools/e2e/qd-app): a page using .qdml/.qdss/.qds
16
+ // instead of .wxml/.wxss/.wxs, with a <wxs src="./index.qds" module="m" /> view
17
+ // script and a `{{ m.shout(title) }}` mustache expression — so a correct compile
18
+ // must recognize the custom template AND the custom view-script extension.
19
+ //
20
+ // Also covers compile-core.js's compileMiniApp (the one-shot worker entry point)
21
+ // at the bottom, since it shares the exact same options-threading gap.
22
+
23
+ // Warm real esbuild/oxc-parser module eval AND esbuild's long-lived service child
24
+ // process with the REAL process object BEFORE masking (below) — the service, once
25
+ // spawned, is reused for later transforms even under a masked process, so this
26
+ // must run first.
27
+ import { transform } from 'esbuild'
28
+ import 'oxc-parser'
29
+
30
+ await transform('const __warm = 1', {})
31
+
32
+ // dart-sass's bundled browser shim checks process.versions.node at module-eval
33
+ // time (only reached once the lazily-loaded style-compiler chunk imports it) —
34
+ // masking to a browser-shaped stub makes it take the browser branch instead of
35
+ // crashing on `Dynamic require of "url" is not supported` (see
36
+ // scripts/test-stage-toolchain.js for the same technique). Saved so PART B below
37
+ // (a Node-target bundle, which needs the real process — its transitive deps
38
+ // module-eval-check process.versions.node too) can restore it.
39
+ const realProcess = globalThis.process
40
+ globalThis.process = { env: {}, cwd: () => '/' }
41
+
42
+ const WORKER_URL = new URL('../dist/stage-worker.browser.js', import.meta.url).href
43
+ const TOOLCHAIN_URL = new URL('./toolchain-setup-node-native.js', import.meta.url).href
44
+ const WORK_PATH = '/work'
45
+
46
+ const FIXTURE_FILES = {
47
+ 'app.json': JSON.stringify({ pages: ['pages/index/index'], window: { navigationBarTitleText: 'QD Ext Demo' } }),
48
+ 'project.config.json': JSON.stringify({ appid: 'qd_ext_demo_001', projectname: 'qd-ext-demo' }),
49
+ 'app.js': "App({\n onLaunch() {\n console.log('[QDExt] App launched')\n },\n})\n",
50
+ 'pages/index/index.js': "Page({\n data: {\n title: 'QD Extensions',\n count: 3,\n },\n})\n",
51
+ 'pages/index/index.qdss': '.qd-box {\n padding: 20rpx;\n}\n\n.qd-title {\n font-size: 36rpx;\n color: #4A90D9;\n}\n\n.qd-item {\n font-size: 28rpx;\n color: #333;\n}\n',
52
+ 'pages/index/index.qdml': '<wxs src="./index.qds" module="m" />\n<view class="qd-box">\n <view class="qd-title" id="qd-title">{{ m.shout(title) }}</view>\n <view class="qd-item" wx:for="{{count}}" wx:key="*this">item-{{item}}</view>\n</view>\n',
53
+ 'pages/index/index.json': JSON.stringify({ navigationBarTitleText: 'QD Ext Index' }),
54
+ 'pages/index/index.qds': "function shout(text) {\n return text + '!'\n}\n\nmodule.exports = {\n shout: shout,\n}\n",
55
+ }
56
+ const FILE_TYPES_OPTIONS = { fileTypes: { template: ['qdml'], style: ['qdss'], viewScript: ['qds'] } }
57
+
58
+ let failed = false
59
+ const chk = (cond, msg) => { if (!cond) { failed = true; console.error(`❌ ${msg}`) } else console.log(`✅ ${msg}`) }
60
+
61
+ // Real page module: exists, non-empty, and carries the compiled .qds wxs module
62
+ // (`shout`) plus the compiled .qdml view (`qd-title`) — the assertion this whole
63
+ // test hinges on. Without options.fileTypes this is guaranteed to fail (the
64
+ // custom extensions are unrecognized, so the page template/view-script are never
65
+ // found), which is exactly the RED this test protects against regressing to.
66
+ function pageModuleIsCompiled(files) {
67
+ const js = files && files['main/pages_index_index.js']
68
+ return typeof js === 'string' && js.length > 0 && js.includes('shout') && js.includes('qd-title')
69
+ }
70
+
71
+ // --- PART A: real browser pool (dist/pool.browser.js + dist/stage-worker.browser.js) ---
72
+ //
73
+ // Each stage worker is its own dynamically-imported module instance (own closure
74
+ // state, mirroring a real Web Worker realm); ALL of them funnel through one shared
75
+ // `chain` so only one is ever "in flight" at a time. This matters because the
76
+ // worker module resolves the bare `self` identifier against whatever
77
+ // `globalThis.self` currently is (see test-stage-toolchain.js's note on the same
78
+ // mechanism) — serializing every send means no two realms' async continuations
79
+ // can ever race over that shared global.
80
+ let chain = Promise.resolve()
81
+ let instanceCounter = 0
82
+ async function makeStageWorker() {
83
+ const worker = { onmessage: null, onerror: null, terminate() {} }
84
+ const fakeSelf = {
85
+ onmessage: null,
86
+ postMessage(msg) { if (worker.onmessage) worker.onmessage({ data: msg }) },
87
+ }
88
+ globalThis.self = fakeSelf
89
+ instanceCounter += 1
90
+ await import(`${WORKER_URL}?n=${instanceCounter}`)
91
+ // stage-worker.js does `self.onmessage = async (e) => {...}` at module top level
92
+ // against whatever `globalThis.self` was AT IMPORT TIME — capture it now.
93
+ const boundOnMessage = fakeSelf.onmessage
94
+ worker.postMessage = (msg) => {
95
+ chain = chain.then(async () => {
96
+ globalThis.self = fakeSelf
97
+ try {
98
+ await boundOnMessage({ data: msg })
99
+ } catch (err) {
100
+ // The real handler already catches internally and posts { type:'error' };
101
+ // an escaping exception would mean it threw before its own try, so surface
102
+ // it the same way rather than hanging the pool's pending request forever.
103
+ fakeSelf.postMessage({ type: 'error', error: String((err && err.stack) || err) })
104
+ }
105
+ })
106
+ }
107
+ return worker
108
+ }
109
+
110
+ const { createCompilerPool } = await import('../dist/pool.browser.js')
111
+
112
+ // One pool, pre-spawned with exactly the 3 default-stage workers this test needs
113
+ // (no crash/respawn expected) — createWorker must be synchronous (pool.js calls it
114
+ // inline), so every worker instance is built up front and handed out from a queue.
115
+ const stageWorkers = []
116
+ for (const stage of ['logic', 'view', 'style']) stageWorkers.push(await makeStageWorker())
117
+ let nextWorker = 0
118
+ const pool = createCompilerPool({
119
+ createWorker: () => stageWorkers[nextWorker++],
120
+ toolchainSetupURL: TOOLCHAIN_URL,
121
+ })
122
+
123
+ const withoutOptions = await pool.compile({ files: FIXTURE_FILES, workPath: WORK_PATH })
124
+ chk(!pageModuleIsCompiled(withoutOptions.files),
125
+ 'PART A (pool.js + stage-worker.js): WITHOUT options.fileTypes, the .qdml/.qdss/.qds page module is NOT compiled '
126
+ + `(demonstrates the gap this feature closes; got main/pages_index_index.js=${JSON.stringify(withoutOptions.files['main/pages_index_index.js'])})`)
127
+
128
+ const withOptions = await pool.compile({ files: FIXTURE_FILES, workPath: WORK_PATH, options: FILE_TYPES_OPTIONS })
129
+ chk(pageModuleIsCompiled(withOptions.files),
130
+ 'PART A (pool.js + stage-worker.js): WITH options.fileTypes, the .qdml/.qdss/.qds page module IS compiled and non-empty '
131
+ + `(main/pages_index_index.js length=${(withOptions.files['main/pages_index_index.js'] || '').length})`)
132
+ chk(withOptions.appId === 'qd_ext_demo_001', `PART A: compiled appId matches project.config.json (got ${withOptions.appId})`)
133
+ const compiledCss = withOptions.files['main/pages_index_index.css'] || ''
134
+ chk(compiledCss.includes('qd-title'), `PART A: .qdss style file compiled into page CSS (got ${JSON.stringify(compiledCss)})`)
135
+
136
+ await pool.dispose()
137
+
138
+ // --- PART B: compile-core.js's compileMiniApp (one-shot worker entry point) ---
139
+ // Same options-threading gap, covered directly at the compile-core level (no
140
+ // pool/worker-protocol involved) so a future pool refactor still has this pinned
141
+ // independently of the browser-pool wiring above. This is the NODE-target bundle
142
+ // (dist/compile-core.node.js), whose deps expect a real `process` — restore it
143
+ // before importing.
144
+ globalThis.process = realProcess
145
+ const { compileMiniApp } = await import('../dist/compile-core.node.js')
146
+ const { Volume, createFsFromVolume } = await import('memfs')
147
+ const freshFs = () => createFsFromVolume(Volume.fromJSON(FIXTURE_FILES, WORK_PATH))
148
+
149
+ const coreWithout = await compileMiniApp({ fs: freshFs(), workPath: WORK_PATH })
150
+ chk(!pageModuleIsCompiled(coreWithout.files),
151
+ 'PART B (compile-core.js compileMiniApp): WITHOUT options.fileTypes, page module NOT compiled')
152
+
153
+ const coreWith = await compileMiniApp({ fs: freshFs(), workPath: WORK_PATH, options: FILE_TYPES_OPTIONS })
154
+ chk(pageModuleIsCompiled(coreWith.files),
155
+ 'PART B (compile-core.js compileMiniApp): WITH options.fileTypes, page module IS compiled and non-empty '
156
+ + `(length=${(coreWith.files['main/pages_index_index.js'] || '').length})`)
157
+
158
+ console.log(failed ? '\n❌ FAIL' : '\n✅ PASS: options.fileTypes reaches the browser pool protocol (pool.js/stage-worker.js) and compile-core.js\'s compileMiniApp')
159
+ process.exit(failed ? 1 : 0)
@@ -8,7 +8,7 @@
8
8
  // and require our pool to match dmcc only where dmcc matches itself. Assets (uuid-named)
9
9
  // are matched by CONTENT hash multiset instead of name. Sourcemap is asserted against
10
10
  // dmcc's own map (parity), since that is the format devtools already consumes.
11
- import { readdirSync, readFileSync, statSync, rmSync, mkdirSync } from 'node:fs'
11
+ import { readdirSync, readFileSync, statSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'
12
12
  import { fileURLToPath } from 'node:url'
13
13
  import { createHash } from 'node:crypto'
14
14
  import path from 'node:path'
@@ -42,7 +42,12 @@ function readTree(root) {
42
42
  }
43
43
 
44
44
  const dmccBuild = (await import('../../../dimina/fe/packages/compiler/src/index.js')).default
45
- const { createNodeCompilerPool } = await import('../dist/pool.node.js')
45
+ const poolNodeModule = await import('../dist/pool.node.js')
46
+ const { createNodeCompilerPool } = poolNodeModule
47
+ // Destructured lazily below (may be absent pre-fix): the default drop-in build(),
48
+ // the pure oxc-native-binding-hint mapper, and the lazy-singleton disposer.
49
+ const defaultBuild = poolNodeModule.default
50
+ const { oxcNativeBindingHint, disposeDefaultPool } = poolNodeModule
46
51
 
47
52
  // --- reference: dmcc twice ------------------------------------------------
48
53
  console.log(`[ref] dmcc build ×2 (sourcemap) from ${APP}`)
@@ -113,5 +118,90 @@ checkAgainstDmcc(readTree(path.join(dir('ours1'), appInfo1.appId)), 'cold')
113
118
  checkAgainstDmcc(readTree(path.join(dir('ours2'), appInfo2.appId)), 'warm')
114
119
 
115
120
  console.log(`[sourcemap] logic.js.map: ${refMap.sources.length} sources, sourcesContent present, matches dmcc ✅`)
116
- console.log(failed ? '\n❌ FAIL' : '\n✅ PASS: resident Node pool is dmcc-equivalent (incl. sourcemap) on cold+warm builds')
121
+
122
+ // --- failure contract: the default drop-in build() must REJECT a broken compile ------------------
123
+ // (never resolve undefined and let the caller mistake a failed compile for success), and the pool
124
+ // must expose a pure helper that turns a missing-oxc-native-binding message into an actionable
125
+ // packaging hint (checked as a string mapping only — a real environment can't be made to lack the
126
+ // binding on demand).
127
+ console.log("[fail] broken fixture: default build() must reject, not resolve undefined")
128
+ function writeBrokenFixture(root) {
129
+ const write = (rel, content) => {
130
+ const target = path.join(root, rel)
131
+ mkdirSync(path.dirname(target), { recursive: true })
132
+ writeFileSync(target, content)
133
+ }
134
+ write('project.config.json', JSON.stringify({ appid: 'fixture_app_broken', projectname: 'fixture-broken' }))
135
+ write('app.json', JSON.stringify({ pages: ['pages/index/index'] }))
136
+ write('app.js', 'App({})\n')
137
+ write('app.wxss', 'page { font-size: 14px; }\n')
138
+ write('pages/index/index.json', '{}\n')
139
+ // Unbalanced brace + bad tokens — same corruption shape used by devkit's
140
+ // open-project-compile-log fixture, chosen because it fails the logic stage
141
+ // rather than being silently absorbed by esbuild's error-tolerant fallback.
142
+ write('pages/index/index.js', 'Page({ data: { msg: "hi" } })\nconst broken = {{{ ;;; <<<\n')
143
+ write('pages/index/index.wxml', '<view>{{msg}}</view>\n')
144
+ write('pages/index/index.wxss', '.x { color: red; }\n')
145
+ }
146
+ const brokenRoot = dir('broken-fixture')
147
+ mkdirSync(brokenRoot, { recursive: true })
148
+ writeBrokenFixture(brokenRoot)
149
+
150
+ if (typeof defaultBuild !== 'function') {
151
+ fail('pool.node.js must have a default export `build(outputDir, workPath, useAppIdDir?, options?)` — default export is missing/not a function')
152
+ }
153
+ else {
154
+ let brokenResolvedValue
155
+ let brokenRejectedError
156
+ try {
157
+ brokenResolvedValue = await defaultBuild(dir('broken-out'), brokenRoot, true, {})
158
+ }
159
+ catch (e) {
160
+ brokenRejectedError = e
161
+ }
162
+ if (brokenRejectedError === undefined) {
163
+ fail(`default build() must REJECT on a broken compile — instead it resolved to ${JSON.stringify(brokenResolvedValue)} (a swallowed error looks like success to every caller)`)
164
+ }
165
+ else if (!(brokenRejectedError instanceof Error) || !brokenRejectedError.message) {
166
+ fail(`default build() rejected but without a usable Error message: ${brokenRejectedError}`)
167
+ }
168
+ else if (!/stage "logic" failed/.test(brokenRejectedError.message)) {
169
+ fail(`default build() rejection message must name the failing stage, got: ${brokenRejectedError.message.split('\n')[0]}`)
170
+ }
171
+ else if (!/Transform failed/.test(brokenRejectedError.message)) {
172
+ fail(`default build() rejection message must carry the underlying compile reason (esbuild transform failure), got: ${brokenRejectedError.message.split('\n')[0]}`)
173
+ }
174
+ else {
175
+ console.log(`[fail] ✅ default build() rejected with: ${brokenRejectedError.message.split('\n')[0]}`)
176
+ }
177
+ }
178
+
179
+ // --- oxcNativeBindingHint: pure message -> actionable-packaging-hint mapper -----------------------
180
+ if (typeof oxcNativeBindingHint !== 'function') {
181
+ fail('pool.node.js must export a named `oxcNativeBindingHint(message)` helper — export is missing')
182
+ }
183
+ else {
184
+ const hint = oxcNativeBindingHint('Cannot find native binding. It means oxc-parser was not installed correctly for this platform.')
185
+ if (typeof hint !== 'string' || !hint.includes('@oxc-parser/binding-') || !hint.includes('wasm32-wasi')) {
186
+ fail(`oxcNativeBindingHint must mention both the native platform binding package (@oxc-parser/binding-*) and the wasm32-wasi fallback, got: ${JSON.stringify(hint)}`)
187
+ }
188
+ else {
189
+ console.log('[fail] ✅ oxcNativeBindingHint surfaces a packaging fix for a missing native binding')
190
+ }
191
+ const noHint = oxcNativeBindingHint('some unrelated compile error')
192
+ if (noHint !== null) {
193
+ fail(`oxcNativeBindingHint must return null for a message that is not the native-binding failure, got: ${JSON.stringify(noHint)}`)
194
+ }
195
+ }
196
+
197
+ // The default export is a lazy module-level singleton pool (resident worker threads) — the
198
+ // broken-fixture build above spun it up, so the process cannot exit cleanly without disposing it.
199
+ if (typeof disposeDefaultPool !== 'function') {
200
+ fail('pool.node.js must export `disposeDefaultPool()` to tear down the lazy default-export singleton pool')
201
+ }
202
+ else {
203
+ await disposeDefaultPool()
204
+ }
205
+
206
+ console.log(failed ? '\n❌ FAIL' : '\n✅ PASS: resident Node pool is dmcc-equivalent (incl. sourcemap) on cold+warm builds, and rejects broken compiles')
117
207
  process.exit(failed ? 1 : 0)
@@ -0,0 +1,135 @@
1
+ // Regression coverage for the resident Node disk pool's handling of a DEAD esbuild
2
+ // toolchain service — the Windows-packaged-app crash pattern: esbuild's JS lib spawns
3
+ // a long-lived binary child process (the "service"), and in a packaged Electron app
4
+ // that spawn can fail (e.g. the binary sits inside app.asar, which fs.existsSync()
5
+ // sees but child_process.spawn() cannot exec). Once that service is dead inside a
6
+ // warm worker realm, EVERY later esbuild call in that same realm fails with
7
+ // "The service is no longer running: write EPIPE" — a message shape that looks
8
+ // nothing like the real cause. Today the pool classifies this as
9
+ // `code: 'compiler-stage-error'` ("deterministic, never retried") and keeps the
10
+ // broken worker, so the pool never recovers even after the environment is healed.
11
+ //
12
+ // Fault injection needs no source changes: esbuild's node lib honors
13
+ // ESBUILD_BINARY_PATH, and each worker_threads realm captures a COPY of
14
+ // process.env at spawn. Pointing that var (before the pool spawns its workers) at
15
+ // a real, executable file whose shebang interpreter does not exist reproduces the
16
+ // exact "fs.existsSync() true, spawn() ENOENT" split a binary trapped inside
17
+ // app.asar produces — child_process.spawn() reports ENOENT because the kernel's
18
+ // own shebang-interpreter lookup fails, not because the file is missing.
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ const APP = process.env.APP_DIR
24
+ || fileURLToPath(new URL('../../../dimina/fe/example/base', import.meta.url))
25
+ const TMP = fileURLToPath(new URL('../.tmp-pool-toolchain-death/', import.meta.url))
26
+ fs.rmSync(TMP, { recursive: true, force: true })
27
+ fs.mkdirSync(TMP, { recursive: true })
28
+ const dir = (n) => path.join(TMP, n)
29
+
30
+ const FAKE_ESBUILD = path.join(TMP, 'fake-esbuild-service')
31
+ fs.writeFileSync(FAKE_ESBUILD, '#!/nonexistent/esbuild-interpreter\necho unreachable\n')
32
+ fs.chmodSync(FAKE_ESBUILD, 0o755)
33
+
34
+ let failed = false
35
+ const chk = (cond, msg) => { if (!cond) { failed = true; console.error(`❌ ${msg}`) } else console.log(`✅ ${msg}`) }
36
+ const withTimeout = (p, ms, label) => Promise.race([
37
+ p,
38
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`TIMEOUT: ${label} did not settle in ${ms}ms`)), ms).unref()),
39
+ ])
40
+ const settle = (p) => p.then((v) => ({ ok: true, v }), (e) => ({ ok: false, e }))
41
+ const treeOk = (d, i) => fs.existsSync(path.join(d, i.appId, 'main', 'logic.js'))
42
+
43
+ // Property access (not a named import) so a dist build that has not yet added
44
+ // esbuildAsarSpawnHint fails this script's own assertion instead of a module-link
45
+ // SyntaxError that would abort before any other scenario runs.
46
+ const poolModule = await import('../dist/pool.node.js')
47
+ const { createNodeCompilerPool } = poolModule
48
+ const esbuildAsarSpawnHint = poolModule.esbuildAsarSpawnHint
49
+
50
+ // --- classification + recycle ------------------------------------------------------
51
+ // A dead esbuild service must be coded distinctly from a real compile error, and the
52
+ // worker(s) that hit it must be recycled so the NEXT build — once the environment is
53
+ // healed — runs on a fresh realm instead of replaying the same dead service forever.
54
+ process.env.ESBUILD_BINARY_PATH = FAKE_ESBUILD
55
+ const pool = createNodeCompilerPool({ retryOnWorkerDeath: false })
56
+ // Read immediately after creation, before the pool's own eager ensureAlive() settles —
57
+ // the pristine pre-spawn baseline every later generation count is compared against.
58
+ const gensPristine = pool._slots.map((x) => x.slot.generation)
59
+
60
+ const first = await withTimeout(
61
+ settle(pool.build(dir('a1'), APP, true, {})), 60000, 'first build against a dead esbuild service',
62
+ )
63
+ chk(!first.ok, 'first build against a dead esbuild service rejects')
64
+ const firstErr = first.ok ? null : first.e
65
+ chk(!!firstErr && firstErr.code === 'compiler-toolchain-dead',
66
+ `dead-esbuild-service failure is coded 'compiler-toolchain-dead' (got ${firstErr && firstErr.code})`)
67
+ chk(!!firstErr && typeof firstErr.stage === 'string' && firstErr.stage.length > 0,
68
+ `error carries .stage (got ${firstErr && firstErr.stage})`)
69
+ chk(!!firstErr && /^\[compiler\] stage "[^"]+" failed: .*The service (was stopped|is no longer running)/.test(firstErr.message || ''),
70
+ `error message keeps the stage-shaped prefix around the dead-service cause: ${firstErr && firstErr.message}`)
71
+
72
+ // Heal the environment the way a real fix would (repackage / relaunch): only NEWLY
73
+ // spawned worker realms see this — the pool must actually recycle, not get lucky that
74
+ // the already-spawned realm somehow un-breaks itself.
75
+ delete process.env.ESBUILD_BINARY_PATH
76
+
77
+ const second = await withTimeout(
78
+ settle(pool.build(dir('a2'), APP, true, {})), 60000, 'build after healing the toolchain env',
79
+ )
80
+ chk(second.ok && treeOk(dir('a2'), second.v),
81
+ `build after healing the env succeeds via recycled workers, not a permanent write EPIPE: ${second.ok ? 'ok' : second.e && second.e.message}`)
82
+
83
+ const gensAfterHeal = pool._slots.map((x) => x.slot.generation)
84
+ chk(gensAfterHeal.some((g, i) => g - gensPristine[i] > 1),
85
+ `pool recycled at least one worker slot between the dead-service failure and the next build (pristine ${gensPristine.join(',')} -> after-heal ${gensAfterHeal.join(',')})`)
86
+
87
+ await pool.dispose()
88
+
89
+ // --- transparent retry on a persistently broken toolchain --------------------------
90
+ // retryOnWorkerDeath defaults to true and already gives worker crashes one whole-build
91
+ // retry; a dead-toolchain failure must get the same treatment. With the environment
92
+ // STILL broken the retry cannot succeed either, so the build must still reject (no
93
+ // hang) — but by then the affected slot(s) should have been recycled on both the
94
+ // initial attempt and its retry.
95
+ process.env.ESBUILD_BINARY_PATH = FAKE_ESBUILD
96
+ const poolR = createNodeCompilerPool() // retryOnWorkerDeath defaults to true
97
+ const gensPristineR = poolR._slots.map((x) => x.slot.generation)
98
+
99
+ const retried = await withTimeout(
100
+ settle(poolR.build(dir('r1'), APP, true, {})), 120000, 'build against a persistently dead esbuild service with retry enabled',
101
+ )
102
+ chk(!retried.ok, 'build against a persistently dead toolchain still rejects rather than hanging')
103
+ chk(!retried.ok && retried.e && retried.e.code === 'compiler-toolchain-dead',
104
+ `the final rejection after the transparent retry is still coded 'compiler-toolchain-dead' (got ${retried.ok ? undefined : retried.e && retried.e.code})`)
105
+
106
+ const gensAfterRetry = poolR._slots.map((x) => x.slot.generation)
107
+ chk(gensAfterRetry.some((g, i) => g - gensPristineR[i] > 1),
108
+ `a persistently dead toolchain is recycled on both the initial attempt and its retry (pristine ${gensPristineR.join(',')} -> after-retry ${gensAfterRetry.join(',')})`)
109
+
110
+ await poolR.dispose()
111
+ delete process.env.ESBUILD_BINARY_PATH
112
+
113
+ // --- pure helper: actionable asar-unpack hint for a dead esbuild service ------------
114
+ chk(typeof esbuildAsarSpawnHint === 'function', 'esbuildAsarSpawnHint is exported from the pool module')
115
+ if (typeof esbuildAsarSpawnHint === 'function') {
116
+ const asarMsg = 'The service was stopped: spawn C:\\Users\\x\\AppData\\Local\\Programs\\myapp\\resources\\app.asar\\node_modules\\@esbuild\\win32-x64\\esbuild.exe ENOENT'
117
+ const hint = esbuildAsarSpawnHint(asarMsg)
118
+ chk(typeof hint === 'string' && /asarUnpack/.test(hint),
119
+ `an asar-packaged esbuild ENOENT message gets an asarUnpack hint (got ${JSON.stringify(hint)})`)
120
+
121
+ const syntaxMsg = 'Transform failed with 1 error:\nfile.js:1:1: ERROR: Unexpected "}"'
122
+ chk(esbuildAsarSpawnHint(syntaxMsg) === null,
123
+ `an unrelated esbuild syntax error gets no hint (got ${JSON.stringify(esbuildAsarSpawnHint(syntaxMsg))})`)
124
+
125
+ const oxcMsg = 'Cannot find native binding. Napi module could not be resolved.'
126
+ chk(esbuildAsarSpawnHint(oxcMsg) === null,
127
+ `an oxc native-binding message (oxcNativeBindingHint's own territory) gets no hint here (got ${JSON.stringify(esbuildAsarSpawnHint(oxcMsg))})`)
128
+ } else {
129
+ failed = true
130
+ console.error('❌ skipped esbuildAsarSpawnHint behavior checks (not exported)')
131
+ }
132
+
133
+ fs.rmSync(TMP, { recursive: true, force: true })
134
+ console.log(failed ? '\n❌ FAIL' : '\n✅ PASS: dead-esbuild-service classification, recycle-on-failure, retry-still-rejects, asar hint')
135
+ process.exit(failed ? 1 : 0)
@@ -0,0 +1,13 @@
1
+ // Test-only toolchainSetupURL: installs globalThis.__esbuildTransform /
2
+ // __oxcParseSync from the NATIVE esbuild/oxc-parser already in this package's own
3
+ // dependencies (see package.json), instead of the real esbuild-wasm/oxc wasm32-wasi
4
+ // bindings a real browser host would fetch. Both native functions have the exact
5
+ // signature the browser shims (src/shims/esbuild-wasm.js / oxc-parser.js) forward
6
+ // to verbatim (transform(input,options) / parseSync(filename,code,opts)), so this
7
+ // exercises the REAL dist/stage-worker.browser.js code path end-to-end in Node
8
+ // without paying for a wasm toolchain load.
9
+ import { transform } from 'esbuild'
10
+ import { parseSync } from 'oxc-parser'
11
+
12
+ globalThis.__esbuildTransform = (input, options) => transform(input, options)
13
+ globalThis.__oxcParseSync = parseSync
@@ -455,13 +455,16 @@ let compileChain = Promise.resolve()
455
455
  * Compile a mini-program against a caller-injected fs. Calls are serialized per
456
456
  * realm (see the singleton note above). Convenience wrapper that runs
457
457
  * `setupCompile` + all stages + `collectOutputs` in one realm.
458
- * @param {{ fs: object, workPath?: string }} opts
458
+ * @param {{ fs: object, workPath?: string, options?: { fileTypes?: { template?: string[], style?: string[], viewScript?: string[] } } }} opts
459
459
  * fs: a node:fs replacement (sync subset: existsSync/readFileSync/
460
460
  * readdirSync{withFileTypes}/statSync/writeFileSync/mkdirSync{recursive}/
461
461
  * copyFileSync/rmSync), already seeded with the project source under
462
462
  * `workPath`. The compiler also writes products back into it, and a
463
463
  * missing project.config.json appid is written into it.
464
464
  * workPath: project root inside the fs, default '/work'.
465
+ * options: forwarded to `setupCompile` -> dmcc's `storeInfo` (custom file-type
466
+ * dialect, e.g. { fileTypes: { template: ['qdml'], style: ['qdss'],
467
+ * viewScript: ['qds'] } }).
465
468
  * @returns {Promise<{ appId: string, name: string, files: Record<string,string> }>}
466
469
  */
467
470
  export function compileMiniApp(opts = {}) {
@@ -472,12 +475,12 @@ export function compileMiniApp(opts = {}) {
472
475
  return result
473
476
  }
474
477
 
475
- async function runCompile({ fs, workPath = '/work' } = {}) {
476
- const ctx = await setupCompile({ fs, workPath })
478
+ async function runCompile({ fs, workPath = '/work', options = {} } = {}) {
479
+ const ctx = await setupCompile({ fs, workPath, options })
477
480
  const { storeInfo: bundle, pages, appId, name, targetPath } = ctx
478
481
  // Same order as the original single pass. Stages are independent (no product
479
- // read-back), so the order is not load-bearing — Phase 3 runs them concurrently
480
- // in separate worker realms over a shared fs.
482
+ // read-back), so the order is not load-bearing — the worker pool (pool.js) runs
483
+ // them concurrently in separate worker realms over a shared fs.
481
484
  await compileStage({ stage: 'logic', pages, storeInfo: bundle, fs })
482
485
  await compileStage({ stage: 'view', pages, storeInfo: bundle, fs })
483
486
  await compileStage({ stage: 'style', pages, storeInfo: bundle, fs })
package/src/pool-node.js CHANGED
@@ -38,7 +38,18 @@ const { Worker } = createRequire(import.meta.url)('node:worker_threads')
38
38
  // generous than the browser default: disk builds run whole real projects.
39
39
  const DEFAULT_SEND_TIMEOUT_MS = 120000
40
40
 
41
- const WORKER_DEATH_CODES = new Set(['compiler-worker-timeout', 'compiler-worker-crashed', 'compiler-worker-dead'])
41
+ // 'compiler-toolchain-dead' belongs here even though the worker THREAD is alive: the
42
+ // realm's esbuild service child process is gone, so the realm is just as unusable as a
43
+ // crashed worker — the same one-retry-on-fresh-workers policy applies.
44
+ const WORKER_DEATH_CODES = new Set(['compiler-worker-timeout', 'compiler-worker-crashed', 'compiler-worker-dead', 'compiler-toolchain-dead'])
45
+
46
+ // esbuild's node lib drives a spawned long-lived binary child (its "service"). When that
47
+ // child dies (spawn ENOENT in a packaged app, OOM kill, AV kill), esbuild reports every
48
+ // call with one of these two phrases — and the service NEVER restarts inside that realm,
49
+ // so the warm worker is permanently broken and must be recycled, not kept.
50
+ function isDeadToolchainServiceError(message) {
51
+ return /The service (was stopped|is no longer running)/.test(String(message))
52
+ }
42
53
 
43
54
  // Default idle window before the pool shrinks (terminates its resident stage workers to
44
55
  // release their memory — a warm worker set holds hundreds of MB of toolchain + compile
@@ -164,16 +175,33 @@ export function createNodeCompilerPool({
164
175
  if (err && err.code && !err.stage) err.stage = x.stage
165
176
  throw err
166
177
  })))
167
- for (const r of results) {
168
- if (!r || r.type === 'error') {
169
- const info = r && r.error
170
- const err = new Error(`[compiler] stage "${r && r.stage}" failed: ${(info && info.message) || 'unknown error'}`)
171
- if (info && info.stack) err.stack = info.stack
172
- err.stage = r && r.stage
178
+ // Walk EVERY result before throwing: when more than one stage hit a dead toolchain
179
+ // service (logic and view both drive esbuild), each broken realm must be recycled
180
+ // now throwing on the first would leave the sibling's dead service warm, and the
181
+ // next build would fail on it all over again.
182
+ let firstErr = null
183
+ for (let i = 0; i < results.length; i++) {
184
+ const r = results[i]
185
+ if (r && r.type !== 'error') continue
186
+ const info = r && r.error
187
+ const cause = (info && info.message) || 'unknown error'
188
+ const hint = oxcNativeBindingHint(cause) || esbuildAsarSpawnHint(cause)
189
+ const err = new Error(`[compiler] stage "${r && r.stage}" failed: ${cause}${hint ? ` — ${hint}` : ''}`)
190
+ if (info && info.stack) err.stack = info.stack
191
+ err.stage = r && r.stage
192
+ if (isDeadToolchainServiceError(cause)) {
193
+ // The realm's toolchain service child is dead and never comes back — terminate
194
+ // the worker so the next attempt (the transparent retry, or the next build once
195
+ // the environment is healed) respawns a fresh realm with a fresh service.
196
+ // shrink() is safe here: settleAll above guarantees no request is in flight.
197
+ err.code = 'compiler-toolchain-dead'
198
+ workers[i].slot.shrink()
199
+ } else {
173
200
  err.code = 'compiler-stage-error' // worker-reported compile error — never retried
174
- throw err
175
201
  }
202
+ if (!firstErr) firstErr = err
176
203
  }
204
+ if (firstErr) throw firstErr
177
205
 
178
206
  // 3) Publish the staging dir to the caller's outputDir (dmcc-identical layout).
179
207
  publishToDist(outputDir, useAppIdDir)
@@ -232,19 +260,58 @@ export function createNodeCompilerPool({
232
260
  // user-facing compile-log lines a host (e.g. devkit/devtools' log panel) already scrapes.
233
261
  const STAGE_TITLES = { logic: '编译页面逻辑', view: '编译页面文件', style: '编译样式文件' }
234
262
 
263
+ /**
264
+ * Map a failure message to an actionable packaging hint when it is esbuild failing to
265
+ * spawn its native binary from inside an Electron app.asar archive. Electron patches
266
+ * child_process.execFile for asar paths but NOT child_process.spawn (which esbuild
267
+ * uses), so an in-archive binary path always ENOENTs at spawn even though fs sees the
268
+ * file — the raw message points at a path that plainly exists, which is why it needs
269
+ * a hint. Returns null for every other message.
270
+ * @param {string} message
271
+ * @returns {string | null}
272
+ */
273
+ export function esbuildAsarSpawnHint(message) {
274
+ const msg = String(message)
275
+ if (!/app\.asar/.test(msg) || !/esbuild/i.test(msg) || !/ENOENT/.test(msg)) return null
276
+ return 'esbuild 的原生二进制无法从 app.asar 内 spawn(Electron 只为 execFile 打 asar 补丁):'
277
+ + "打包配置需 asarUnpack '**/node_modules/esbuild/**' 与 '**/node_modules/@esbuild/**',"
278
+ + '并确保 ESBUILD_BINARY_PATH 指向 app.asar.unpacked 下的真实二进制(@dimina-kit/devkit 在 asar 内运行时会自动设置)'
279
+ }
280
+
281
+ /**
282
+ * Map a failure message to an actionable packaging hint when it is oxc-parser's
283
+ * "missing runtime binding" error (thrown when NEITHER the platform-native
284
+ * `@oxc-parser/binding-<platform>` package NOR the `@oxc-parser/binding-wasm32-wasi`
285
+ * fallback resolves at runtime). Neither package is a direct dependency of a
286
+ * typical host, so app bundlers (e.g. electron-builder's dependency collection)
287
+ * silently drop them — and the raw oxc message says nothing about packaging.
288
+ * Returns null for every other message.
289
+ * @param {string} message
290
+ * @returns {string | null}
291
+ */
292
+ export function oxcNativeBindingHint(message) {
293
+ if (!/Cannot find native binding/i.test(String(message))) return null
294
+ return 'oxc-parser 的运行时绑定没有被打进宿主应用:@dimina-kit/compiler 的 Node 编译路径需要 '
295
+ + `@oxc-parser/binding-${process.platform}-${process.arch}(平台原生绑定)或 `
296
+ + '@oxc-parser/binding-wasm32-wasi(wasm 兜底)二者之一实际存在于包内。'
297
+ + '打包分发(如 electron-builder)时请把其中一个显式声明为宿主依赖,避免依赖收集时被丢弃'
298
+ }
299
+
235
300
  // Lazy singleton pool — a DROP-IN replacement for dmcc's `build(targetPath, workPath,
236
- // useAppIdDir, options)`, matching its behavior on BOTH the happy and error paths so a
237
- // host that consumed dmcc keeps working unchanged:
301
+ // useAppIdDir, options)` with ONE deliberate divergence on the error path:
238
302
  // • the first call spins up the resident workers; every later call (a watch rebuild)
239
303
  // reuses them warm;
240
304
  // • on success it emits `✔ 输出编译产物` on stdout (dmcc's listr2 completion line — the
241
305
  // pool has no listr2, so the equivalent user-facing line is surfaced here);
242
- // • on failure it reports the failing stage + summary on stderr and RESOLVES undefined
243
- // (never rethrows), exactly like dmcc's build() catch (index.js) the host normalizes
244
- // undefined to a null appInfo and the error detail (incl. dmcc's own
245
- // `[logic] esbuild 转换失败 …`) still reaches the log channel.
246
- // Callers that want structured throwing errors + explicit teardown should use
247
- // createNodeCompilerPool() directly instead.
306
+ // • on failure it reports the failing stage + summary on stderr (same lines a dmcc
307
+ // host already scrapes: `✖ <stage>` + `<workPath> 编译出错: …`) and then REJECTS
308
+ // with the error. dmcc's own build() swallows the error and resolves undefined,
309
+ // which makes a failed compile indistinguishable from benign "no app info" — a
310
+ // host would start a session that can only 404. Rejecting keeps the log surface
311
+ // identical while giving callers a real failure signal; a resolved null/undefined
312
+ // no longer means "compile failed", only "no app info to report".
313
+ // Callers that want structured errors (`.stage`/`.code`) + explicit teardown should
314
+ // use createNodeCompilerPool() directly instead.
248
315
  let singleton = null
249
316
  export default async function build(outputDir, workPath, useAppIdDir = true, options = {}) {
250
317
  if (!singleton) singleton = createNodeCompilerPool()
@@ -255,7 +322,7 @@ export default async function build(outputDir, workPath, useAppIdDir = true, opt
255
322
  } catch (e) {
256
323
  if (e && e.stage) console.error(`✖ ${STAGE_TITLES[e.stage] || e.stage}`)
257
324
  console.error(`${workPath} 编译出错: ${e && e.message}`)
258
- return undefined
325
+ throw e
259
326
  }
260
327
  }
261
328