@dimina-kit/compiler 0.0.1-dev.20260702173719

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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +363 -0
  3. package/dist/compile-core.browser.js +281945 -0
  4. package/dist/compile-core.node.js +3702 -0
  5. package/dist/pool.browser.js +99 -0
  6. package/dist/pool.node.js +3743 -0
  7. package/dist/stage-worker.browser.js +291085 -0
  8. package/dist/stage-worker.node.js +3097 -0
  9. package/dist/toolchain.browser.js +30 -0
  10. package/package.json +87 -0
  11. package/scripts/build-compiler.js +207 -0
  12. package/scripts/gen-bench-fixture.js +24 -0
  13. package/scripts/kit-resolve-hook.js +35 -0
  14. package/scripts/register-kit.js +2 -0
  15. package/scripts/test-appid-fallback.js +114 -0
  16. package/scripts/test-decompose.js +90 -0
  17. package/scripts/test-hardening.js +78 -0
  18. package/scripts/test-node.js +69 -0
  19. package/scripts/test-npm-scan.js +164 -0
  20. package/scripts/test-pool-hardening.js +65 -0
  21. package/scripts/test-pool-node.js +117 -0
  22. package/scripts/test-realm-reuse.js +77 -0
  23. package/src/browser-entry.js +25 -0
  24. package/src/compile-core.js +428 -0
  25. package/src/pool-node.js +170 -0
  26. package/src/pool.js +122 -0
  27. package/src/shims/esbuild-wasm.js +19 -0
  28. package/src/shims/fs-promises.js +20 -0
  29. package/src/shims/fs.js +59 -0
  30. package/src/shims/less.js +9 -0
  31. package/src/shims/os.js +11 -0
  32. package/src/shims/oxc-parser.js +21 -0
  33. package/src/shims/oxc-walker.js +29 -0
  34. package/src/shims/postcss-noop-plugin.js +9 -0
  35. package/src/shims/process.js +20 -0
  36. package/src/shims/url.js +4 -0
  37. package/src/shims/worker_threads.js +10 -0
  38. package/src/stage-worker-node.js +41 -0
  39. package/src/stage-worker.js +88 -0
  40. package/src/toolchain.js +49 -0
@@ -0,0 +1,428 @@
1
+ // dmcc adapter: drives @dimina/compiler's compile functions inline (no
2
+ // worker_threads) against a caller-injected fs. web-compiler owns NO fs
3
+ // implementation — the host passes a node:fs replacement (e.g. memfs) already
4
+ // seeded with the project source under workPath.
5
+ import { setFs, resetFs } from './shims/fs.js'
6
+
7
+ // The compiler source lives in dimina-kit's `dimina` submodule. This package
8
+ // sits inside the same dimina-kit checkout, so the path stays relative to the
9
+ // submodule — no machine-specific absolute prefix. esbuild resolves these at
10
+ // bundle time (see scripts/build-compiler.js).
11
+ import {
12
+ storeInfo, resetStoreInfo, getPages, getAppId, getAppName, getWorkPath, getTargetPath,
13
+ } from '../../../dimina/fe/packages/compiler/src/env.js'
14
+ import { createDist } from '../../../dimina/fe/packages/compiler/src/common/publish.js'
15
+ import { compileConfig } from '../../../dimina/fe/packages/compiler/src/core/index.js'
16
+ import { NpmBuilder } from '../../../dimina/fe/packages/compiler/src/common/npm-builder.js'
17
+ // compileJS exported by source; writeCompileRes + __resetLogicState + __setEnableSourcemap
18
+ // appended at bundle time (see scripts/build-compiler.js). __setEnableSourcemap flips the
19
+ // logic compiler's module-level `enableSourcemap` — the ONLY sourcemap entry point, since
20
+ // this package short-circuits dmcc's `parentPort` worker bootstrap (isMainThread=true shim).
21
+ import { compileJS, writeCompileRes, __resetLogicState, __setEnableSourcemap } from '../../../dimina/fe/packages/compiler/src/core/logic-compiler.js'
22
+ // compileML + __resetViewState (appended at bundle time)
23
+ import { compileML, __resetViewState } from '../../../dimina/fe/packages/compiler/src/core/view-compiler.js'
24
+ import { compileSS, __resetStyleState } from '../../../dimina/fe/packages/compiler/src/core/style-compiler.js'
25
+ // __resetAssets appended at bundle time (clears the never-cleared assetsMap cache)
26
+ import { __resetAssets } from '../../../dimina/fe/packages/compiler/src/common/utils.js'
27
+
28
+ function makeProgress() {
29
+ let c = 0
30
+ return {
31
+ get completedTasks() { return c },
32
+ set completedTasks(v) { c = v },
33
+ }
34
+ }
35
+
36
+ // The compiler reads the app id from project.config.json's `appid` and bakes it
37
+ // into output resource paths (`/{appId}/main/static/…`) and the container's
38
+ // `?appId=` query. An embedder that feeds a minimal project may omit
39
+ // project.config.json, which would leave the id `undefined` — corrupting those
40
+ // paths and the container load with no error surfaced. When no appid is present
41
+ // we inject this fixed local-preview id. A constant (not a content hash) keeps
42
+ // the id stable across content edits so a live-edit/HMR host preserves page state.
43
+ const SYNTHETIC_APPID = 'dmlocalpreview'
44
+
45
+ // Ensure the injected fs has a project.config.json carrying a usable `appid`.
46
+ // Honors a caller-provided one; synthesizes a stable fallback when absent.
47
+ // NOTE: when appid is missing this WRITES project.config.json into the caller's
48
+ // fs. It refuses to clobber a file that isn't valid JSON, and rejects a
49
+ // malformed appid — better a clear error than silent data loss / corrupt paths.
50
+ function ensureAppIdFs(fs, configPath) {
51
+ let config = {}
52
+ if (fs.existsSync(configPath)) {
53
+ const raw = fs.readFileSync(configPath, 'utf8')
54
+ try {
55
+ config = JSON.parse(raw)
56
+ } catch {
57
+ throw new Error(`[compiler] ${configPath} is not valid JSON; refusing to overwrite it`)
58
+ }
59
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
60
+ throw new Error(`[compiler] ${configPath} must be a JSON object`)
61
+ }
62
+ }
63
+ const { appid } = config
64
+ if (appid !== undefined && appid !== '' && typeof appid !== 'string') {
65
+ throw new Error(`[compiler] ${configPath} "appid" must be a non-empty string`)
66
+ }
67
+ if (!appid) {
68
+ config.appid = SYNTHETIC_APPID
69
+ const slash = configPath.lastIndexOf('/')
70
+ if (slash > 0) fs.mkdirSync(configPath.slice(0, slash), { recursive: true })
71
+ fs.writeFileSync(configPath, JSON.stringify(config))
72
+ }
73
+ }
74
+
75
+ // Walk the injected fs under targetPath and collect { relPath: content }. Uses
76
+ // only readdirSync({withFileTypes}) + readFileSync — inside the fs contract.
77
+ // Fail-fast: a missing target dir, an unreadable product, or a fs that ignores
78
+ // { withFileTypes: true } throws (with the path) rather than silently dropping
79
+ // products or crashing obscurely later.
80
+ function readOutputs(fs, target) {
81
+ const prefix = target.endsWith('/') ? target : `${target}/`
82
+ const out = {}
83
+ const seen = new Set()
84
+ const walk = (dir) => {
85
+ if (seen.has(dir)) return // guard against symlink cycles in exotic fs backends
86
+ seen.add(dir)
87
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
88
+ if (!Array.isArray(entries)) {
89
+ throw new Error(`[compiler] fs.readdirSync(${dir}, { withFileTypes: true }) must return an array`)
90
+ }
91
+ for (const e of entries) {
92
+ if (!e || typeof e.isDirectory !== 'function') {
93
+ throw new Error(`[compiler] fs.readdirSync must return Dirent entries with isDirectory()/isFile() (got ${typeof e} under ${dir})`)
94
+ }
95
+ const full = `${dir}/${e.name}`
96
+ if (e.isDirectory()) walk(full)
97
+ else out[full.slice(prefix.length)] = fs.readFileSync(full, 'utf8')
98
+ }
99
+ }
100
+ walk(prefix.slice(0, -1))
101
+ return out
102
+ }
103
+
104
+ // --- miniprogram_npm scan scope -------------------------------------------
105
+ //
106
+ // Why this exists — the builder's and the resolver's search spaces must agree.
107
+ //
108
+ // dmcc's NpmBuilder mirrors every directory literally named `miniprogram_npm`
109
+ // it can find into the output, preserving each dir's project-relative path. Its
110
+ // own findMiniprogramNpmDirs walks the WHOLE project tree with zero exclusions:
111
+ // node_modules, hidden directories (.git, test snapshots), and build outputs
112
+ // that happen to live inside the project are all scanned, and any
113
+ // miniprogram_npm buried in them is faithfully copied into the product.
114
+ //
115
+ // The consumer of those copies is NpmResolver, and it defines what is actually
116
+ // reachable: when a compiled source file imports a bare package name, the
117
+ // resolver probes `<ancestor>/miniprogram_npm` walking UP from the importing
118
+ // file's directory to the project root (WeChat's npm addressing). Compiled
119
+ // sources only ever live in the real source tree, so a miniprogram_npm sitting
120
+ // under node_modules, under a dot-directory, or inside a previous build's
121
+ // output can never appear on any lookup chain — copying it is pure junk output,
122
+ // and dropping it can never break resolution.
123
+ //
124
+ // The unexcluded scan is worse than bloat: when the caller publishes the output
125
+ // INSIDE the project (outputDir under workPath), the next build scans the
126
+ // previous build's published npm copies and re-copies them one level deeper —
127
+ // dist/<appId>/dist/<appId>/… grows without bound, one level per build, until
128
+ // path length blows past the OS limit (ENAMETOOLONG) and publishing crashes.
129
+ // Test-artifact snapshots that embed old outputs (e.g. an e2e-captured user-data
130
+ // dir committed into the project) feed the same loop.
131
+ //
132
+ // The fix lives HERE, not in dmcc: the dimina/ submodule is vendored read-only,
133
+ // and setupCompile below is the single place this package instantiates the
134
+ // builder — overriding the scan at that seam fixes every consumer (memfs
135
+ // compileMiniApp, browser pool, Node disk pool) without forking upstream.
136
+ //
137
+ // Scan rules, each mirroring a resolver invariant — and each matching WeChat's
138
+ // own packaging behavior (developers.weixin.qq.com/miniprogram/dev/devtools/npm.html):
139
+ // - skip `node_modules`: it holds RAW npm inputs; miniprogram_npm is their
140
+ // built counterpart, and the resolver never looks inside node_modules.
141
+ // WeChat states it outright: node_modules 目录不会参与编译、上传和打包中.
142
+ // - skip dot-directories: hidden dirs are never mini-program source, so no
143
+ // importing file (and hence no lookup chain) can originate there. This is
144
+ // also what keeps .git and e2e user-data snapshots out. WeChat's packager
145
+ // likewise drops dot-prefixed entries from preview/upload by default.
146
+ // - skip `excludeRoots` subtrees: the staging dir plus any caller-declared
147
+ // publish target (see npmScanExclude) — a build's own output must never
148
+ // become a later build's input, which is exactly the nesting feedback loop.
149
+ // Everything else — project root, subpackage roots, any ancestor level of a
150
+ // source file — is kept, byte-for-byte where dmcc would have put it. Keeping
151
+ // every non-excluded level is deliberate: WeChat generates one miniprogram_npm
152
+ // per package.json (so subpackage roots are legitimate), and packNpmRelationList
153
+ // / miniprogramNpmDistDir let projects place it at ARBITRARY directories — a
154
+ // root-only allowlist would over-filter valid projects.
155
+ // scripts/test-npm-scan.js pins both directions (kept and excluded).
156
+ //
157
+ // Evidence backing the rules above (WeChat official docs + field reports):
158
+ // - npm addressing (upward, per-ancestor miniprogram_npm), per-package.json
159
+ // placement, packNpmRelationList/miniprogramNpmDistDir custom targets, and
160
+ // the node_modules exclusion quote all come from
161
+ // https://developers.weixin.qq.com/miniprogram/dev/devtools/npm.html
162
+ // - dot-prefixed entries are dropped from preview/upload by default; the
163
+ // packOptions prefix/folder ignore rules target exactly this class:
164
+ // https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html
165
+ // - WeChat performs NO whole-tree scan for miniprogram_npm at all — it
166
+ // GENERATES the dir from package.json/node_modules and packs by dependency
167
+ // analysis, so output-recursion is structurally impossible there. The
168
+ // whole-tree walk is dmcc's own approximation of that pipeline; these
169
+ // exclusions restore the boundaries the approximation dropped.
170
+ //
171
+ // Uses the caller-injected fs directly (the same backend the rest of setup runs
172
+ // against), not the module-level fs the parent class closes over.
173
+ function findScopedNpmDirs(fs, workPath, excludeRoots) {
174
+ // Normalized to trailing-slash prefixes so `${dir}/` startsWith covers both
175
+ // the excluded root itself and everything below it.
176
+ const excluded = excludeRoots
177
+ .filter(Boolean)
178
+ .map((p) => (p.endsWith('/') ? p : `${p}/`))
179
+ const npmDirs = []
180
+ const walk = (dir, rel) => {
181
+ if (!fs.existsSync(dir)) return
182
+ for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
183
+ if (!item.isDirectory()) continue
184
+ const { name } = item
185
+ if (name === 'node_modules' || name.startsWith('.')) continue
186
+ const full = `${dir}/${name}`
187
+ if (excluded.some((ex) => `${full}/`.startsWith(ex))) continue
188
+ const childRel = rel ? `${rel}/${name}` : name
189
+ // A found miniprogram_npm is recorded but NOT descended into — package
190
+ // contents are the copy step's job, matching dmcc's own scan shape.
191
+ if (name === 'miniprogram_npm') npmDirs.push(childRel)
192
+ else walk(full, childRel)
193
+ }
194
+ }
195
+ walk(workPath.endsWith('/') ? workPath.slice(0, -1) : workPath, '')
196
+ return npmDirs
197
+ }
198
+
199
+ // NpmBuilder with ONLY the discovery step replaced by the scoped scan above.
200
+ // Copy layout, package-json dependency chasing, and the file-type filter are
201
+ // inherited unchanged, so for every legitimate miniprogram_npm dir the output
202
+ // stays dmcc-identical — the override narrows WHICH dirs ship, never HOW.
203
+ class ScopedNpmBuilder extends NpmBuilder {
204
+ constructor(workPath, targetPath, { fs, excludeRoots }) {
205
+ super(workPath, targetPath)
206
+ this._fs = fs
207
+ this._excludeRoots = excludeRoots
208
+ }
209
+
210
+ findMiniprogramNpmDirs() {
211
+ return findScopedNpmDirs(this._fs, this.workPath, this._excludeRoots)
212
+ }
213
+ }
214
+
215
+ // The sync fs subset the compiler actually calls on the compileMiniApp path.
216
+ // readdirSync must additionally support { withFileTypes: true }.
217
+ const REQUIRED_FS = [
218
+ 'existsSync', 'readFileSync', 'readdirSync', 'statSync',
219
+ 'writeFileSync', 'mkdirSync', 'copyFileSync', 'rmSync',
220
+ ]
221
+
222
+ function assertFs(fs) {
223
+ if (!fs || typeof fs !== 'object') {
224
+ throw new Error('[compiler] compileMiniApp requires { fs }: inject a node:fs replacement (e.g. createFsFromVolume(memfs Volume)) seeded with the project source under workPath')
225
+ }
226
+ const missing = REQUIRED_FS.filter((m) => typeof fs[m] !== 'function')
227
+ if (missing.length) {
228
+ throw new Error(`[compiler] injected fs is missing required method(s): ${missing.join(', ')}. Needs the sync subset ${REQUIRED_FS.join('/')}, and readdirSync must support { withFileTypes: true }.`)
229
+ }
230
+ }
231
+
232
+ // --- compile stages --------------------------------------------------------
233
+ // The three stages read project source and each write their OWN product files;
234
+ // no stage reads back another stage's products (verified against the compiler),
235
+ // so they can run in any order — or concurrently in separate realms/workers over
236
+ // a shared fs. Each function keeps the exact per-stage sequencing of the original
237
+ // single-pass compile so output stays byte-for-byte identical.
238
+
239
+ async function runLogicStage(pages, progress) {
240
+ // Main first (produces mainRes). A subpackage that references a shared component
241
+ // belonging to the main package reverse-injects it into mainRes, so the main
242
+ // package's logic.js must be written LAST — after every subpackage has run.
243
+ const mainRes = await compileJS(pages.mainPages, null, null, progress)
244
+ for (const [root, sub] of Object.entries(pages.subPages)) {
245
+ const subRes = await compileJS(sub.info, root, sub.independent ? [] : mainRes, progress)
246
+ await writeCompileRes(subRes, root)
247
+ }
248
+ await writeCompileRes(mainRes, null)
249
+ }
250
+
251
+ async function runViewStage(pages, progress) {
252
+ await compileML(pages.mainPages, null, progress)
253
+ for (const [root, sub] of Object.entries(pages.subPages)) {
254
+ await compileML(sub.info, root, progress)
255
+ }
256
+ }
257
+
258
+ async function runStyleStage(pages, progress) {
259
+ // app.css is prepended for the main package, matching the original ordering.
260
+ const styleMain = [{ path: 'app', id: '' }, ...pages.mainPages]
261
+ await compileSS(styleMain, null, progress)
262
+ for (const [root, sub] of Object.entries(pages.subPages)) {
263
+ await compileSS(sub.info, root, progress)
264
+ }
265
+ }
266
+
267
+ const STAGES = {
268
+ logic: runLogicStage,
269
+ view: runViewStage,
270
+ style: runStyleStage,
271
+ }
272
+
273
+ /** The compile stages, in the order the single-pass compile runs them. */
274
+ export const STAGE_NAMES = Object.keys(STAGES)
275
+
276
+ /**
277
+ * Run ONE stage's compile functions against whatever fs backend is already active
278
+ * (the fs shim for the memfs path, or native node:fs when this module is bundled
279
+ * without the fs alias for the Node disk pool). Assumes the env singletons are
280
+ * already restored (caller does `resetStoreInfo`). Threads `sourcemap` into the
281
+ * logic stage (view/style have no sourcemap — a dmcc limitation, not ours). Kept
282
+ * separate from `compileStage` so the Node stage worker can drive it directly with
283
+ * native fs, no shim.
284
+ * @param {'logic'|'view'|'style'} stage
285
+ * @param {object} pages
286
+ * @param {{ sourcemap?: boolean }} [opts]
287
+ */
288
+ export async function runStage(stage, pages, { sourcemap = false } = {}) {
289
+ const run = STAGES[stage]
290
+ if (!run) throw new Error(`[compiler] unknown compile stage "${stage}" (expected ${STAGE_NAMES.join('/')})`)
291
+ // Only the logic compiler generates sourcemaps; setting it is a harmless no-op for
292
+ // the other stages (they never read enableSourcemap).
293
+ if (stage === 'logic') __setEnableSourcemap(!!sourcemap)
294
+ await run(pages, makeProgress())
295
+ }
296
+
297
+ /**
298
+ * One-time setup against the injected fs: parse config/paths, scaffold the dist
299
+ * dir, compile app-config.json, build npm packages. Returns a SERIALIZABLE
300
+ * context (the storeInfo bundle + page map + ids + targetPath) that each stage
301
+ * restores via `compileStage`. Setup WRITES scaffolding/app-config/npm into the
302
+ * fs, so with a shared fs the stage workers read them without re-running setup.
303
+ * @param {{ fs: object, workPath?: string, options?: object, npmScanExclude?: string[] }} opts
304
+ * npmScanExclude: absolute directory paths whose subtrees the miniprogram_npm
305
+ * scan must skip — pass the publish outputDir here when it can sit inside the
306
+ * project, so a previous build's published output is never re-ingested as npm
307
+ * input. The staging dir (getTargetPath) is always excluded.
308
+ * @returns {Promise<{ storeInfo: object, pages: object, appId: string, name: string, targetPath: string, workPath: string }>}
309
+ */
310
+ export async function setupCompile({ fs, workPath = '/work', options = {}, npmScanExclude = [] } = {}) {
311
+ assertFs(fs)
312
+ // Guarantee a usable appId regardless of whether the project declared one.
313
+ ensureAppIdFs(fs, `${workPath}/project.config.json`)
314
+ setFs(fs)
315
+ try {
316
+ const store = storeInfo(workPath, options)
317
+ createDist()
318
+ compileConfig()
319
+ // findMiniprogramNpmDirs() no-ops (existsSync guard, returns []) when there is
320
+ // no miniprogram_npm, so this only throws on a GENUINE npm build failure.
321
+ try {
322
+ await new ScopedNpmBuilder(getWorkPath(), getTargetPath(), {
323
+ fs,
324
+ excludeRoots: [getTargetPath(), ...npmScanExclude],
325
+ }).buildNpmPackages()
326
+ } catch (e) {
327
+ throw new Error(`[compiler] miniprogram_npm build failed: ${e.message}`)
328
+ }
329
+ return {
330
+ storeInfo: store,
331
+ pages: getPages(),
332
+ appId: getAppId(),
333
+ name: getAppName(),
334
+ targetPath: getTargetPath(),
335
+ workPath,
336
+ }
337
+ } finally {
338
+ resetFs()
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Run ONE compile stage against the injected fs. `pages` and `storeInfo` come
344
+ * from `setupCompile`. Self-contained: it points the fs shim at `fs` and restores
345
+ * the compiler env from the bundle, so it can run in a fresh worker realm.
346
+ * Products are written into `fs`.
347
+ * @param {{ stage: 'logic'|'view'|'style', pages: object, storeInfo: object, fs: object }} opts
348
+ */
349
+ export async function compileStage({ stage, pages, storeInfo: bundle, fs, sourcemap = false } = {}) {
350
+ assertFs(fs)
351
+ setFs(fs)
352
+ try {
353
+ resetStoreInfo(bundle)
354
+ await runStage(stage, pages, { sourcemap })
355
+ } finally {
356
+ resetFs()
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Collect the compiled products from the injected fs under `targetPath` into a
362
+ * `{ relPath: content }` map. Uses `fs` directly (no shim), so no setup needed.
363
+ * @param {{ fs: object, targetPath: string }} opts
364
+ * @returns {Record<string,string>}
365
+ */
366
+ export function collectOutputs({ fs, targetPath } = {}) {
367
+ return readOutputs(fs, targetPath)
368
+ }
369
+
370
+ /**
371
+ * Clear the compiler's module-level caches so a REUSED realm (e.g. a pooled
372
+ * worker kept warm to amortize wasm-toolchain init) can compile a second project
373
+ * without contamination from the first. The env singletons (pathInfo/configInfo)
374
+ * are overwritten by each setupCompile's storeInfo, so they need no clearing — but
375
+ * these caches are keyed by module/asset path with no appId qualifier and are
376
+ * otherwise never reset on the inline (non-worker) path:
377
+ * - logic processedModules — else a shared page path is skipped as "done"
378
+ * - style compileRes — CSS cache
379
+ * - view compileResCache / wxsModuleRegistry / wxsFilePathMap
380
+ * - assets assetsMap — else a reused path returns a stale uuid and the
381
+ * asset copy into the new fs is skipped
382
+ * Call it BEFORE compiling the next project in the same realm.
383
+ */
384
+ export function resetCompilerState() {
385
+ __resetLogicState()
386
+ __resetStyleState()
387
+ __resetViewState()
388
+ __resetAssets()
389
+ }
390
+
391
+ // The compiler keeps module-level singletons (env.js pathInfo/configInfo) and the
392
+ // fs shim has a single active backend, so two compiles in the same realm must NOT
393
+ // overlap. Serialize calls through a promise chain — each waits for the previous
394
+ // to settle. Cross-realm callers (separate workers/processes) are already isolated.
395
+ let compileChain = Promise.resolve()
396
+
397
+ /**
398
+ * Compile a mini-program against a caller-injected fs. Calls are serialized per
399
+ * realm (see the singleton note above). Convenience wrapper that runs
400
+ * `setupCompile` + all stages + `collectOutputs` in one realm.
401
+ * @param {{ fs: object, workPath?: string }} opts
402
+ * fs: a node:fs replacement (sync subset: existsSync/readFileSync/
403
+ * readdirSync{withFileTypes}/statSync/writeFileSync/mkdirSync{recursive}/
404
+ * copyFileSync/rmSync), already seeded with the project source under
405
+ * `workPath`. The compiler also writes products back into it, and a
406
+ * missing project.config.json appid is written into it.
407
+ * workPath: project root inside the fs, default '/work'.
408
+ * @returns {Promise<{ appId: string, name: string, files: Record<string,string> }>}
409
+ */
410
+ export function compileMiniApp(opts = {}) {
411
+ const result = compileChain.then(() => runCompile(opts))
412
+ // Keep the chain alive regardless of this call's outcome; the caller still gets
413
+ // the real result/rejection via `result`.
414
+ compileChain = result.then(() => {}, () => {})
415
+ return result
416
+ }
417
+
418
+ async function runCompile({ fs, workPath = '/work' } = {}) {
419
+ const ctx = await setupCompile({ fs, workPath })
420
+ const { storeInfo: bundle, pages, appId, name, targetPath } = ctx
421
+ // Same order as the original single pass. Stages are independent (no product
422
+ // read-back), so the order is not load-bearing — Phase 3 runs them concurrently
423
+ // in separate worker realms over a shared fs.
424
+ await compileStage({ stage: 'logic', pages, storeInfo: bundle, fs })
425
+ await compileStage({ stage: 'view', pages, storeInfo: bundle, fs })
426
+ await compileStage({ stage: 'style', pages, storeInfo: bundle, fs })
427
+ return { appId, name, files: collectOutputs({ fs, targetPath }) }
428
+ }
@@ -0,0 +1,170 @@
1
+ // Resident Node worker_threads disk pool — the Node counterpart of pool.js (browser).
2
+ //
3
+ // It reproduces dmcc's own `build()` behavior (3 stage workers writing a shared real-disk
4
+ // staging dir, then publishToDist copies to outputDir/{appId}), including sourcemap, but
5
+ // keeps the workers WARM across builds instead of spawning+terminating them each time.
6
+ // That resident realm reuse is the point: watch/rebuild in an IDE amortizes worker spawn +
7
+ // module init instead of re-paying it on every save.
8
+ //
9
+ // Flow per build():
10
+ // main : setupCompile → prep on real disk (storeInfo/createDist/compileConfig/npm)
11
+ // workers: resetCompilerState + resetStoreInfo + runStage(stage) → write staging disk
12
+ // main : publishToDist(outputDir) → copy staging → outputDir/{appId}
13
+ //
14
+ // fs is NATIVE (bundled without the fs alias); worker_threads is shimmed for dmcc but the
15
+ // REAL Worker/parentPort are reached via createRequire (see stage-worker-node.js).
16
+ import { createRequire } from 'node:module'
17
+ import nodeFs from 'node:fs'
18
+ import nodePath from 'node:path'
19
+ import process from 'node:process'
20
+ import { setupCompile, resetCompilerState, STAGE_NAMES } from './compile-core.js'
21
+ import { createDist, publishToDist } from '../../../dimina/fe/packages/compiler/src/common/publish.js'
22
+ import { getAppConfigInfo, getAppId, getAppName } from '../../../dimina/fe/packages/compiler/src/env.js'
23
+
24
+ const { Worker } = createRequire(import.meta.url)('node:worker_threads')
25
+
26
+ /**
27
+ * Create a resident Node stage-worker pool.
28
+ * @param {{ stages?: string[] }} [opts]
29
+ * @returns {{ build: (outputDir:string, workPath:string, useAppIdDir?:boolean, options?:object)=>Promise<{appId:string,name:string,path:string}>, dispose: ()=>Promise<void>, stages: string[] }}
30
+ */
31
+ // ALL Node disk-pool builds serialize through this single module-level chain, not a
32
+ // per-instance one: setupCompile/publishToDist go through dmcc's process-global env
33
+ // singletons (storeInfo / getTargetPath / getAppId), so builds from two DIFFERENT
34
+ // pool instances would corrupt each other just as surely as two builds in one pool
35
+ // (one pool publishing the other's staging dir under the other's appId).
36
+ let chain = Promise.resolve()
37
+
38
+ export function createNodeCompilerPool({ stages = STAGE_NAMES } = {}) {
39
+ const workerURL = new URL('./stage-worker.node.js', import.meta.url)
40
+ let disposed = false
41
+
42
+ const workers = stages.map((stage) => {
43
+ // Slot with lazy respawn: a crashed/exited worker fails the builds queued on it,
44
+ // vacates the slot, and the NEXT send() forks a fresh worker — a dead stage must
45
+ // not wedge every later rebuild (postMessage to an exited worker neither throws
46
+ // nor ever answers).
47
+ const slot = { stage, w: null, q: [] }
48
+ const spawn = () => {
49
+ const w = new Worker(workerURL)
50
+ const settle = (v) => { const r = slot.q.shift(); if (r) r(v) }
51
+ w.on('message', settle)
52
+ w.on('error', (e) => settle({ type: 'error', stage, error: { message: e && e.message, stack: e && e.stack } }))
53
+ w.on('exit', (code) => {
54
+ if (slot.w === w) slot.w = null
55
+ while (slot.q.length) settle({ type: 'error', stage, error: { message: `stage worker "${stage}" exited (code ${code})` } })
56
+ })
57
+ return w
58
+ }
59
+ // Spawn eagerly so the pool is warm from creation (the resident-realm point);
60
+ // only replacements after a death are lazy.
61
+ slot.w = spawn()
62
+ slot.send = (m) => new Promise((res) => {
63
+ if (!slot.w) slot.w = spawn()
64
+ slot.q.push(res)
65
+ slot.w.postMessage(m)
66
+ })
67
+ return slot
68
+ })
69
+
70
+ async function runBuild(outputDir, workPath, useAppIdDir, options) {
71
+ const { sourcemap = false, fileTypes } = options || {}
72
+
73
+ // 1) Prep once on real disk. resetCompilerState first so a warm main realm does not
74
+ // carry caches (assets/config) from the previous build. setupCompile computes a
75
+ // fresh staging dir (getTargetPath) and scaffolds it via createDist.
76
+ resetCompilerState()
77
+ // outputDir resolved exactly like publishToDist resolves it (against cwd), so
78
+ // when it sits inside the project the npm scan skips the published output —
79
+ // a previous build's copies must never become the next build's input.
80
+ const ctx = await setupCompile({
81
+ fs: nodeFs,
82
+ workPath,
83
+ options: { fileTypes },
84
+ npmScanExclude: [nodePath.resolve(process.cwd(), outputDir)],
85
+ })
86
+ const { storeInfo, pages } = ctx
87
+
88
+ // 2) Fan out to the resident stage workers. They restore the same storeInfo (so their
89
+ // getTargetPath() is the same staging dir) and write disjoint files concurrently.
90
+ const results = await Promise.all(
91
+ workers.map((x) => x.send({ stage: x.stage, pages, storeInfo, sourcemap })),
92
+ )
93
+ for (const r of results) {
94
+ if (!r || r.type === 'error') {
95
+ const info = r && r.error
96
+ const err = new Error(`[compiler] stage "${r && r.stage}" failed: ${(info && info.message) || 'unknown error'}`)
97
+ if (info && info.stack) err.stack = info.stack
98
+ err.stage = r && r.stage
99
+ throw err
100
+ }
101
+ }
102
+
103
+ // 3) Publish the staging dir to the caller's outputDir (dmcc-identical layout).
104
+ publishToDist(outputDir, useAppIdDir)
105
+
106
+ return {
107
+ appId: getAppId(),
108
+ name: getAppName(),
109
+ // dmcc reads mainPages[1] only because its style task unshifts `app` into the
110
+ // SHARED array first, making [1] the original FIRST page. Our style stage builds
111
+ // a fresh array instead of mutating, so the equivalent here is mainPages[0].
112
+ path: getAppConfigInfo().entryPagePath || pages.mainPages[0]?.path || '',
113
+ }
114
+ }
115
+
116
+ function build(outputDir, workPath, useAppIdDir = true, options = {}) {
117
+ if (disposed) return Promise.reject(new Error('[compiler] pool has been disposed'))
118
+ const result = chain.then(() => runBuild(outputDir, workPath, useAppIdDir, options))
119
+ chain = result.then(() => {}, () => {})
120
+ return result
121
+ }
122
+
123
+ async function dispose() {
124
+ disposed = true
125
+ await Promise.all(workers.map((x) => (x.w ? x.w.terminate() : null)))
126
+ }
127
+
128
+ // _slots is a test hook (crash-recovery tests terminate a live worker through
129
+ // it); not part of the supported API surface.
130
+ return { build, dispose, stages, _slots: workers }
131
+ }
132
+
133
+ // dmcc listr2 stage titles — reproduced so the drop-in build() surfaces the same
134
+ // user-facing compile-log lines a host (e.g. devkit/devtools' log panel) already scrapes.
135
+ const STAGE_TITLES = { logic: '编译页面逻辑', view: '编译页面文件', style: '编译样式文件' }
136
+
137
+ // Lazy singleton pool — a DROP-IN replacement for dmcc's `build(targetPath, workPath,
138
+ // useAppIdDir, options)`, matching its behavior on BOTH the happy and error paths so a
139
+ // host that consumed dmcc keeps working unchanged:
140
+ // • the first call spins up the resident workers; every later call (a watch rebuild)
141
+ // reuses them warm;
142
+ // • on success it emits `✔ 输出编译产物` on stdout (dmcc's listr2 completion line — the
143
+ // pool has no listr2, so the equivalent user-facing line is surfaced here);
144
+ // • on failure it reports the failing stage + summary on stderr and RESOLVES undefined
145
+ // (never rethrows), exactly like dmcc's build() catch (index.js) — the host normalizes
146
+ // undefined to a null appInfo and the error detail (incl. dmcc's own
147
+ // `[logic] esbuild 转换失败 …`) still reaches the log channel.
148
+ // Callers that want structured throwing errors + explicit teardown should use
149
+ // createNodeCompilerPool() directly instead.
150
+ let singleton = null
151
+ export default async function build(outputDir, workPath, useAppIdDir = true, options = {}) {
152
+ if (!singleton) singleton = createNodeCompilerPool()
153
+ try {
154
+ const info = await singleton.build(outputDir, workPath, useAppIdDir, options)
155
+ console.log('✔ 输出编译产物')
156
+ return info
157
+ } catch (e) {
158
+ if (e && e.stage) console.error(`✖ ${STAGE_TITLES[e.stage] || e.stage}`)
159
+ console.error(`${workPath} 编译出错: ${e && e.message}`)
160
+ return undefined
161
+ }
162
+ }
163
+
164
+ /** Terminate the lazy singleton pool's workers (no-op if never used). */
165
+ export async function disposeDefaultPool() {
166
+ if (singleton) {
167
+ await singleton.dispose()
168
+ singleton = null
169
+ }
170
+ }