@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.
- package/LICENSE +21 -0
- package/README.md +363 -0
- package/dist/compile-core.browser.js +281945 -0
- package/dist/compile-core.node.js +3702 -0
- package/dist/pool.browser.js +99 -0
- package/dist/pool.node.js +3743 -0
- package/dist/stage-worker.browser.js +291085 -0
- package/dist/stage-worker.node.js +3097 -0
- package/dist/toolchain.browser.js +30 -0
- package/package.json +87 -0
- package/scripts/build-compiler.js +207 -0
- package/scripts/gen-bench-fixture.js +24 -0
- package/scripts/kit-resolve-hook.js +35 -0
- package/scripts/register-kit.js +2 -0
- package/scripts/test-appid-fallback.js +114 -0
- package/scripts/test-decompose.js +90 -0
- package/scripts/test-hardening.js +78 -0
- package/scripts/test-node.js +69 -0
- package/scripts/test-npm-scan.js +164 -0
- package/scripts/test-pool-hardening.js +65 -0
- package/scripts/test-pool-node.js +117 -0
- package/scripts/test-realm-reuse.js +77 -0
- package/src/browser-entry.js +25 -0
- package/src/compile-core.js +428 -0
- package/src/pool-node.js +170 -0
- package/src/pool.js +122 -0
- package/src/shims/esbuild-wasm.js +19 -0
- package/src/shims/fs-promises.js +20 -0
- package/src/shims/fs.js +59 -0
- package/src/shims/less.js +9 -0
- package/src/shims/os.js +11 -0
- package/src/shims/oxc-parser.js +21 -0
- package/src/shims/oxc-walker.js +29 -0
- package/src/shims/postcss-noop-plugin.js +9 -0
- package/src/shims/process.js +20 -0
- package/src/shims/url.js +4 -0
- package/src/shims/worker_threads.js +10 -0
- package/src/stage-worker-node.js +41 -0
- package/src/stage-worker.js +88 -0
- 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
|
+
}
|
package/src/pool-node.js
ADDED
|
@@ -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
|
+
}
|