@dimina-kit/compiler 0.0.1-dev.20260702182435 → 0.0.1-dev.20260703101348
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/README.md +15 -9
- package/dist/compile-core.browser.js +4 -3
- package/dist/pool.browser.js +8 -6
- package/dist/stage-worker.browser.js +47 -10
- package/package.json +2 -1
- package/scripts/test-pool-scopehash.js +239 -0
- package/src/pool.js +20 -6
- package/src/stage-worker.js +60 -8
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ pool.dispose() // 用完终止 worker
|
|
|
73
73
|
|
|
74
74
|
> **上面是最小接入代码,但浏览器环境有前置要求**(不满足会在别处报错,不像示例不完整):页面与 worker 开 COOP/COEP;`toolchainSetupURL` 能被 worker 运行时 import;esbuild browser ESM 与 `esbuild.wasm` 可作静态资源访问;npm 用户需显式装 `@oxc-parser/binding-wasm32-wasi`(`cpu:wasm32` 会被跳过);本包经 `file:`/link 引用时要配 Vite `server.fs.allow`。逐条见本节末尾的「故障排查」。
|
|
75
75
|
|
|
76
|
-
`compile()`
|
|
76
|
+
`compile()` 内部分两阶段:**先由一个 worker 跑一次 `setupCompile`**(分配 scope-hash id、构建 miniprogram_npm / app-config scaffold),把可序列化的 `{ pages, storeInfo, … }` bundle **广播给三个常驻 stage worker**(logic / view / style),各自 seed 私有 memfs、只跑 `compileStage`;产物 = scaffold ∪ 各 stage partial 回传。setup 只跑一次是**正确性要求**,不是优化:scope-hash(`data-v-XXXXX`)在 setup 阶段随机分配,若每个 stage realm 各自 setup,CSS 选择器与 render 产物的 id 会各摇一套、WXSS 整体失效(回归由 `test:pool-scopehash` 守护)。跨 `compile()` 复用同一批 worker(每次编译前自动 `resetCompilerState`)。多个 `compile()` 会自动串行(共享常驻 realm 不能并发)。
|
|
77
77
|
|
|
78
78
|
> **连续触发(watch / 保存即编)要自己收敛:** pool 只保证串行,**不合并**——编译进行中再调 N 次 `compile()` 就排队 N 个全量编译,一个不少地跑完(正确性没问题,但白烧算力、垫高最后一次的延迟)。源码快照在**轮到该次编译派发时**才结构化克隆,不是调用时。触发侧应收敛成"至多一个在跑 + 一个待跑"(dirty-flag / debounce)。另外**整个应用共用一个 pool 单例**:多实例之间 realm 全隔离、不会互相污染,但结果没有跨实例顺序保证(旧编译可能晚于新编译返回,按到达顺序应用产物会拿旧盖新),还多付一份工具链常驻内存。
|
|
79
79
|
|
|
@@ -206,22 +206,27 @@ initToolchain(): Promise<void> // no-op,
|
|
|
206
206
|
| `collectOutputs({ fs, targetPath })` | 遍历 fs,把 `targetPath` 前缀下的**所有**产物读成 `{ 相对路径: 内容 }`(无 stage 过滤;含 setup 写的公共产物如 `app-config.json`) |
|
|
207
207
|
| `resetCompilerState()` | 清编译器模块级缓存;**常驻 realm/worker 复用前必调**,否则第二次编译被污染。它清缓存但**不**重新 seed fs |
|
|
208
208
|
|
|
209
|
-
> **stage 并行的正确姿势**(pool
|
|
209
|
+
> **stage 并行的正确姿势**(pool 内部就是这么做):`setupCompile` **整个编译只跑一次**(一个 realm),把返回的 `{ pages, storeInfo }` 发给每个 stage worker 跑 `compileStage`——**绝不能每个 stage realm 各自 setup**:scope-hash(页面/组件的 `data-v-XXXXX` id)在 setup 阶段随机分配,各 realm 各摇一套会让 style 写进 CSS 的选择器与 view 写进 render 产物的 id 完全不相交,WXSS 全部落空(`test:pool-scopehash` 守护此契约)。setup 往它自己的 fs 写 dist scaffold、`main/app-config.json`、`miniprogram_npm` 构建产物——在 setup realm 用 `collectOutputs` 收走这份 scaffold 并入最终并集;各 stage worker 用**私有 fs**、不需要 scaffold(三 stage 只从 `workPath` 读源码,产物写方向才用 `targetPath`),其 partial 只含本 stage 产物。**同一 realm 内不要并发调用接缝**(它们改全局 fs shim 与 compiler env)——并行必须落在不同 worker/realm;`compileMiniApp` 已内部串行化,接缝由调用方自己串行。view stage 不要再按页/分包细拆,必须让单个 view stage 看完整 `pages`(app 级模块去重才成立)。
|
|
210
210
|
|
|
211
|
-
**pool 的线程边界与编译流程**:主线程**只**派发 + 合并(不加载 wasm、不跑编译)
|
|
211
|
+
**pool 的线程边界与编译流程**:主线程**只**派发 + 合并(不加载 wasm、不跑编译);编译分两阶段全在 worker 内跑——Phase 1 一个 worker `setupCompile`(分配 scope-hash id + 写 scaffold,收走 scaffold 与可序列化 bundle);Phase 2 三个 stage worker 各拿**同一份** bundle 跑 `compileStage → collectOutputs`。
|
|
212
212
|
|
|
213
213
|
```mermaid
|
|
214
214
|
flowchart TD
|
|
215
215
|
DISP["主线程 · pool.compile(files)<br/>只派发 + 合并,不加载 wasm、不跑编译"]
|
|
216
|
-
DISP -->|"
|
|
217
|
-
subgraph
|
|
216
|
+
DISP -->|"Phase 1 · files 发给 1 个 worker"| SETUP
|
|
217
|
+
subgraph SETUP["setup worker(Web Worker 线程) · 整个编译只跑一次"]
|
|
218
218
|
direction TB
|
|
219
|
-
|
|
220
|
-
SU -->
|
|
219
|
+
SSRC["seed 私有 memfs"] --> SU["setupCompile<br/>解析配置 + miniprogram_npm → 分配 scope-hash id,写 scaffold/app-config"]
|
|
220
|
+
SU --> SC["collectOutputs 收 scaffold<br/>+ 可序列化 bundle { pages, storeInfo, targetPath, appId, name }"]
|
|
221
|
+
end
|
|
222
|
+
SETUP -->|"Phase 2 · files + 同一份 bundle 广播给 3 个 worker"| WK
|
|
223
|
+
subgraph WK["每个常驻 stage worker(Web Worker 线程) · 各跑一个 stage"]
|
|
224
|
+
direction TB
|
|
225
|
+
SRC["seed 私有 memfs"] --> ST["compileStage(本 worker 的 stage,用 bundle 的 pages/storeInfo)<br/>logic→main/logic.js · view→main/*.js · style→真实 cssnano+autoprefixer→*.css"]
|
|
221
226
|
ST --> CO["collectOutputs<br/>遍历 fs 收 targetPath 下产物 = 本 stage partial"]
|
|
222
|
-
RS["resetCompilerState 清模块级缓存"] -.->|"每次编译前"|
|
|
227
|
+
RS["resetCompilerState 清模块级缓存"] -.->|"每次编译前"| ST
|
|
223
228
|
end
|
|
224
|
-
WK -->|"3 份 partial"| MERGE["主线程 ·
|
|
229
|
+
WK -->|"3 份 partial"| MERGE["主线程 · scaffold ∪ 3 份 partial<br/>→ { appId, name, files }"]
|
|
225
230
|
```
|
|
226
231
|
|
|
227
232
|
> 用 **core 的 `compileMiniApp`**(单线程)时,是同一条 `setupCompile → 三个 stage → collectOutputs`,只是在**一个 realm、你调用它的那个线程**里跑完——你在主线程调就阻塞主线程,放进自己的 worker 就在那个 worker 跑。
|
|
@@ -348,6 +353,7 @@ pnpm --filter @dimina-kit/compiler test:appid # appId fallback 守卫
|
|
|
348
353
|
pnpm --filter @dimina-kit/compiler test:decompose # stage 接缝各自独立、产物互不相交
|
|
349
354
|
pnpm --filter @dimina-kit/compiler test:realm-reuse # resetCompilerState 后复用 realm 与全新 realm 一致
|
|
350
355
|
pnpm --filter @dimina-kit/compiler test:pool-node # Node pool 与 dmcc 冷+热逐字节等价(含 sourcemap 与返回值)
|
|
356
|
+
pnpm --filter @dimina-kit/compiler test:pool-scopehash # 浏览器 pool 跨 stage scope-hash 内容级一致(CSS data-v-* ⊆ render Module id;文件集校验看不见这层)
|
|
351
357
|
```
|
|
352
358
|
|
|
353
359
|
`pool` 的浏览器端到端验证在 `dimina-web-client`(`npm run test:pool`,Playwright 驱动,产物与单线程逐结构一致)。`pool-node` 的宿主端验证在 `@dimina-kit/devkit` 测试套件(真实 fork + `openProject`)。
|
|
@@ -32179,9 +32179,9 @@ var require_agents4 = __commonJS({
|
|
|
32179
32179
|
}
|
|
32180
32180
|
});
|
|
32181
32181
|
|
|
32182
|
-
// ../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.
|
|
32182
|
+
// ../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.385/node_modules/electron-to-chromium/versions.js
|
|
32183
32183
|
var require_versions2 = __commonJS({
|
|
32184
|
-
"../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.
|
|
32184
|
+
"../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.385/node_modules/electron-to-chromium/versions.js"(exports, module) {
|
|
32185
32185
|
module.exports = {
|
|
32186
32186
|
"0.20": "39",
|
|
32187
32187
|
"0.21": "41",
|
|
@@ -32438,7 +32438,8 @@ var require_versions2 = __commonJS({
|
|
|
32438
32438
|
"42.3": "148",
|
|
32439
32439
|
"42.4": "148",
|
|
32440
32440
|
"42.5": "148",
|
|
32441
|
-
"43.0": "150"
|
|
32441
|
+
"43.0": "150",
|
|
32442
|
+
"44.0": "151"
|
|
32442
32443
|
};
|
|
32443
32444
|
}
|
|
32444
32445
|
});
|
package/dist/pool.browser.js
CHANGED
|
@@ -67,17 +67,19 @@ function createCompilerPool(options = {}) {
|
|
|
67
67
|
throw new Error("[compiler] pool.compile expects { files: { relPath: content }, workPath? } (or a non-empty files map)");
|
|
68
68
|
}
|
|
69
69
|
const workPath = input.workPath || defaultWorkPath;
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
const s = await workers[0].send({ type: "setup", files, workPath });
|
|
71
|
+
if (!s || s.type === "error") {
|
|
72
|
+
throw new Error(s && s.error ? s.error : `[compiler] setup phase failed in stage '${workers[0].stage}' worker`);
|
|
73
|
+
}
|
|
74
|
+
const { bundle, scaffold } = s;
|
|
75
|
+
const parts = await Promise.all(workers.map((x) => x.send({ type: "compile-subset", files, workPath, stages: [x.stage], bundle })));
|
|
76
|
+
const merged = { ...scaffold || {} };
|
|
73
77
|
for (let i = 0; i < parts.length; i++) {
|
|
74
78
|
const pr = parts[i];
|
|
75
79
|
if (!pr || pr.type === "error") throw new Error(pr && pr.error ? pr.error : `[compiler] stage '${workers[i].stage}' worker error`);
|
|
76
|
-
appId = pr.result.appId;
|
|
77
|
-
name = pr.result.name;
|
|
78
80
|
Object.assign(merged, pr.result.files);
|
|
79
81
|
}
|
|
80
|
-
return { appId, name, files: merged };
|
|
82
|
+
return { appId: bundle.appId, name: bundle.name, files: merged };
|
|
81
83
|
});
|
|
82
84
|
chain = run.then(() => {
|
|
83
85
|
}, () => {
|
|
@@ -46145,9 +46145,9 @@ var require_agents4 = __commonJS({
|
|
|
46145
46145
|
}
|
|
46146
46146
|
});
|
|
46147
46147
|
|
|
46148
|
-
// ../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.
|
|
46148
|
+
// ../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.385/node_modules/electron-to-chromium/versions.js
|
|
46149
46149
|
var require_versions2 = __commonJS({
|
|
46150
|
-
"../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.
|
|
46150
|
+
"../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.385/node_modules/electron-to-chromium/versions.js"(exports, module) {
|
|
46151
46151
|
module.exports = {
|
|
46152
46152
|
"0.20": "39",
|
|
46153
46153
|
"0.21": "41",
|
|
@@ -46404,7 +46404,8 @@ var require_versions2 = __commonJS({
|
|
|
46404
46404
|
"42.3": "148",
|
|
46405
46405
|
"42.4": "148",
|
|
46406
46406
|
"42.5": "148",
|
|
46407
|
-
"43.0": "150"
|
|
46407
|
+
"43.0": "150",
|
|
46408
|
+
"44.0": "151"
|
|
46408
46409
|
};
|
|
46409
46410
|
}
|
|
46410
46411
|
});
|
|
@@ -290972,17 +290973,44 @@ function needsToolchain(stages) {
|
|
|
290972
290973
|
function freshFs(files, workPath) {
|
|
290973
290974
|
return (0, import_memfs.createFsFromVolume)(import_memfs.Volume.fromJSON(files, workPath));
|
|
290974
290975
|
}
|
|
290975
|
-
async function
|
|
290976
|
+
async function runSetup(files, workPath) {
|
|
290976
290977
|
const fs2 = freshFs(files, workPath);
|
|
290977
290978
|
resetCompilerState();
|
|
290978
290979
|
const ctx = await setupCompile({ fs: fs2, workPath });
|
|
290979
|
-
for (const stage of stages) {
|
|
290980
|
-
await compileStage({ stage, pages: ctx.pages, storeInfo: ctx.storeInfo, fs: fs2 });
|
|
290981
|
-
}
|
|
290982
290980
|
const map4 = collectOutputs({ fs: fs2, targetPath: ctx.targetPath });
|
|
290981
|
+
const scaffold = {};
|
|
290982
|
+
for (const k of Object.keys(map4)) if (map4[k] != null) scaffold[k] = map4[k];
|
|
290983
|
+
const bundle = {
|
|
290984
|
+
pages: ctx.pages,
|
|
290985
|
+
storeInfo: ctx.storeInfo,
|
|
290986
|
+
targetPath: ctx.targetPath,
|
|
290987
|
+
appId: ctx.appId,
|
|
290988
|
+
name: ctx.name
|
|
290989
|
+
};
|
|
290990
|
+
return { bundle, scaffold };
|
|
290991
|
+
}
|
|
290992
|
+
async function compileSubset(files, workPath, stages, bundle) {
|
|
290993
|
+
const fs2 = freshFs(files, workPath);
|
|
290994
|
+
resetCompilerState();
|
|
290995
|
+
let appId, name, targetPath;
|
|
290996
|
+
if (bundle) {
|
|
290997
|
+
for (const stage of stages) {
|
|
290998
|
+
await compileStage({ stage, pages: bundle.pages, storeInfo: bundle.storeInfo, fs: fs2 });
|
|
290999
|
+
}
|
|
291000
|
+
;
|
|
291001
|
+
({ appId, name, targetPath } = bundle);
|
|
291002
|
+
} else {
|
|
291003
|
+
const ctx = await setupCompile({ fs: fs2, workPath });
|
|
291004
|
+
for (const stage of stages) {
|
|
291005
|
+
await compileStage({ stage, pages: ctx.pages, storeInfo: ctx.storeInfo, fs: fs2 });
|
|
291006
|
+
}
|
|
291007
|
+
;
|
|
291008
|
+
({ appId, name, targetPath } = ctx);
|
|
291009
|
+
}
|
|
291010
|
+
const map4 = collectOutputs({ fs: fs2, targetPath });
|
|
290983
291011
|
const out = {};
|
|
290984
291012
|
for (const k of Object.keys(map4)) if (map4[k] != null) out[k] = map4[k];
|
|
290985
|
-
return { appId
|
|
291013
|
+
return { appId, name, files: out };
|
|
290986
291014
|
}
|
|
290987
291015
|
self.onmessage = async (e) => {
|
|
290988
291016
|
const { type } = e.data || {};
|
|
@@ -290994,13 +291022,22 @@ self.onmessage = async (e) => {
|
|
|
290994
291022
|
self.postMessage({ type: "ready", ms: Math.round(performance.now() - t0) });
|
|
290995
291023
|
return;
|
|
290996
291024
|
}
|
|
291025
|
+
if (type === "setup") {
|
|
291026
|
+
const { files, workPath = "/work", toolchainSetupURL } = e.data;
|
|
291027
|
+
if (toolchainSetupURL) toolchainURL = toolchainSetupURL;
|
|
291028
|
+
await ensureToolchain();
|
|
291029
|
+
const t = performance.now();
|
|
291030
|
+
const { bundle, scaffold } = await runSetup(files, workPath);
|
|
291031
|
+
self.postMessage({ type: "setup-done", bundle, scaffold, ms: Math.round(performance.now() - t) });
|
|
291032
|
+
return;
|
|
291033
|
+
}
|
|
290997
291034
|
if (type === "compile-subset") {
|
|
290998
|
-
const { files, workPath = "/work", stages = ["logic", "view", "style"], toolchainSetupURL } = e.data;
|
|
291035
|
+
const { files, workPath = "/work", stages = ["logic", "view", "style"], bundle, toolchainSetupURL } = e.data;
|
|
290999
291036
|
if (toolchainSetupURL) toolchainURL = toolchainSetupURL;
|
|
291000
291037
|
if (needsToolchain(stages)) await ensureToolchain();
|
|
291001
291038
|
const warm = !!toolchainReady;
|
|
291002
291039
|
const t = performance.now();
|
|
291003
|
-
const result2 = await compileSubset(files, workPath, stages);
|
|
291040
|
+
const result2 = await compileSubset(files, workPath, stages, bundle);
|
|
291004
291041
|
self.postMessage({ type: "done", result: result2, ms: Math.round(performance.now() - t), warm });
|
|
291005
291042
|
return;
|
|
291006
291043
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dimina-kit/compiler",
|
|
3
|
-
"version": "0.0.1-dev.
|
|
3
|
+
"version": "0.0.1-dev.20260703101348",
|
|
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",
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"test:decompose": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-decompose.js",
|
|
82
82
|
"test:realm-reuse": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-realm-reuse.js",
|
|
83
83
|
"test:pool-node": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-pool-node.js",
|
|
84
|
+
"test:pool-scopehash": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-pool-scopehash.js",
|
|
84
85
|
"test:pool-hardening": "node scripts/build-compiler.js node && node scripts/test-pool-hardening.js",
|
|
85
86
|
"test:npm-scan": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-npm-scan.js",
|
|
86
87
|
"test:stage-toolchain": "node scripts/build-compiler.js browser && node scripts/test-stage-toolchain.js"
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Content-level scope-hash consistency check for the stage-parallel pool.
|
|
2
|
+
//
|
|
3
|
+
// The pool's structural-equivalence tests (test:pool-node) only compare file
|
|
4
|
+
// NAME/length multisets, so they cannot see whether the `data-v-XXXXX` scope
|
|
5
|
+
// hashes baked into the CSS (`[data-v-X]` selectors) actually match the ones
|
|
6
|
+
// baked into the compiled render templates (`.js`, `scopeId: data-v-X`). If the
|
|
7
|
+
// view stage and the style stage allocate ids in DIFFERENT realms, their hashes
|
|
8
|
+
// diverge and every WXSS rule targets a selector that never appears in the DOM —
|
|
9
|
+
// styles silently stop working while the file lists still match.
|
|
10
|
+
//
|
|
11
|
+
// This script models the browser pool (src/pool.js + src/stage-worker.js) in
|
|
12
|
+
// Node: each stage compiles in its OWN fresh memfs, exactly as a separate Web
|
|
13
|
+
// Worker realm would. It runs the OLD per-worker-setup path and the FIXED
|
|
14
|
+
// shared-setup path and asserts, at the content level, that every scope hash
|
|
15
|
+
// used in CSS also appears in the render output.
|
|
16
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import { Volume, createFsFromVolume } from 'memfs'
|
|
20
|
+
|
|
21
|
+
const APP = process.env.APP_DIR
|
|
22
|
+
|| fileURLToPath(new URL('../../../dimina/fe/example/vant', import.meta.url))
|
|
23
|
+
|
|
24
|
+
const TEXT_EXT = new Set([
|
|
25
|
+
'.json', '.js', '.ts', '.wxml', '.ddml', '.wxss', '.ddss', '.less',
|
|
26
|
+
'.scss', '.sass', '.wxs', '.dds', '.css',
|
|
27
|
+
])
|
|
28
|
+
function readDir(dir, baseDir, out) {
|
|
29
|
+
for (const name of readdirSync(dir)) {
|
|
30
|
+
if (name === 'node_modules' || name === '.git') continue
|
|
31
|
+
const full = path.join(dir, name)
|
|
32
|
+
if (statSync(full).isDirectory()) readDir(full, baseDir, out)
|
|
33
|
+
else if (TEXT_EXT.has(path.extname(name).toLowerCase())) {
|
|
34
|
+
out[path.relative(baseDir, full).split(path.sep).join('/')] = readFileSync(full, 'utf8')
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const seed = {}
|
|
40
|
+
readDir(APP, APP, seed)
|
|
41
|
+
console.log(`[seed] ${Object.keys(seed).length} text files from ${APP}`)
|
|
42
|
+
|
|
43
|
+
const workPath = '/work'
|
|
44
|
+
const core = await import('../dist/compile-core.node.js')
|
|
45
|
+
const { setupCompile, compileStage, collectOutputs, compileMiniApp, resetCompilerState } = core
|
|
46
|
+
// The pool orchestration under test is the real source module (pure ESM, no
|
|
47
|
+
// browser-only imports), driven below through a mock worker backed by node core.
|
|
48
|
+
const { createCompilerPool } = await import('../src/pool.js')
|
|
49
|
+
const fileSet = (m) => Object.keys(m).sort().join('\n')
|
|
50
|
+
|
|
51
|
+
// Every realm in the real pool (setup worker AND each stage worker) calls
|
|
52
|
+
// resetCompilerState() before it works (stage-worker.js). In this single-process
|
|
53
|
+
// model, separate Web Worker realms are simulated by calling it before each
|
|
54
|
+
// realm-equivalent operation so module-level caches never leak between them.
|
|
55
|
+
const realm = () => resetCompilerState()
|
|
56
|
+
|
|
57
|
+
const STAGES = ['logic', 'view', 'style']
|
|
58
|
+
const freshFs = () => createFsFromVolume(Volume.fromJSON(seed, workPath))
|
|
59
|
+
const clean = (m) => { for (const k of Object.keys(m)) if (m[k] == null) delete m[k]; return m }
|
|
60
|
+
|
|
61
|
+
// CSS carries the scope hash as `[data-v-XXXXX]`; the compiled render carries
|
|
62
|
+
// the SAME 5-char id bare in each `Module({ …, id:"XXXXX", … })` (the runtime
|
|
63
|
+
// prepends `data-v-`). Extract each with its own pattern so we compare the
|
|
64
|
+
// cross-file linkage the runtime actually relies on.
|
|
65
|
+
function hashesIn(files, pred, re) {
|
|
66
|
+
const set = new Set()
|
|
67
|
+
for (const k of Object.keys(files)) {
|
|
68
|
+
if (!pred(k)) continue
|
|
69
|
+
const v = files[k]
|
|
70
|
+
if (typeof v !== 'string') continue
|
|
71
|
+
let m
|
|
72
|
+
re.lastIndex = 0
|
|
73
|
+
while ((m = re.exec(v))) set.add(m[1])
|
|
74
|
+
}
|
|
75
|
+
return set
|
|
76
|
+
}
|
|
77
|
+
const CSS_RE = /data-v-([a-z0-9]{5})/g
|
|
78
|
+
const RENDER_RE = /\bid:\s*["']([a-z0-9]{5})["']/g
|
|
79
|
+
const isCss = (k) => k.endsWith('.css')
|
|
80
|
+
const isRender = (k) => k.endsWith('.js') && k !== 'main/logic.js' && !k.endsWith('/logic.js')
|
|
81
|
+
|
|
82
|
+
// Report the CSS-vs-render scope-hash relationship for a merged output map.
|
|
83
|
+
function report(label, files) {
|
|
84
|
+
const css = hashesIn(files, isCss, CSS_RE)
|
|
85
|
+
const js = hashesIn(files, isRender, RENDER_RE)
|
|
86
|
+
const orphanCss = [...css].filter((h) => !js.has(h)) // CSS selectors that target nothing rendered
|
|
87
|
+
const matched = [...css].filter((h) => js.has(h))
|
|
88
|
+
console.log(`\n[${label}] css-scope-hashes=${css.size} render-scope-hashes=${js.size} `
|
|
89
|
+
+ `matched=${matched.length} orphanCSS=${orphanCss.length}`)
|
|
90
|
+
if (orphanCss.length) {
|
|
91
|
+
console.log(` orphan CSS hashes (in CSS, absent from render): ${orphanCss.slice(0, 10).join(', ')}${orphanCss.length > 10 ? ' …' : ''}`)
|
|
92
|
+
}
|
|
93
|
+
return { css, js, orphanCss, matched }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- MODEL A: OLD browser pool — each stage runs setupCompile in its own realm ---
|
|
97
|
+
async function buildPerStageSetup() {
|
|
98
|
+
const merged = {}
|
|
99
|
+
for (const stage of STAGES) {
|
|
100
|
+
realm()
|
|
101
|
+
const fs = freshFs()
|
|
102
|
+
const ctx = await setupCompile({ fs, workPath }) // independent id allocation per stage
|
|
103
|
+
await compileStage({ stage, pages: ctx.pages, storeInfo: ctx.storeInfo, fs })
|
|
104
|
+
Object.assign(merged, clean(collectOutputs({ fs, targetPath: ctx.targetPath })))
|
|
105
|
+
}
|
|
106
|
+
return merged
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- MODEL B: FIXED pool — setup ONCE, broadcast the bundle across stage realms ---
|
|
110
|
+
// This models the fixed browser pool exactly (mirrors the working Node disk pool):
|
|
111
|
+
// one realm runs setupCompile (id allocation + npm/app-config scaffold); the shared
|
|
112
|
+
// `{ pages, storeInfo }` bundle is broadcast to every stage worker, which only runs
|
|
113
|
+
// compileStage against it. Setup scaffold (app-config.json + npm) is merged in.
|
|
114
|
+
async function buildSharedSetup() {
|
|
115
|
+
realm()
|
|
116
|
+
const setupFs = freshFs()
|
|
117
|
+
const ctx = await setupCompile({ fs: setupFs, workPath })
|
|
118
|
+
const bundle = { pages: ctx.pages, storeInfo: ctx.storeInfo, targetPath: ctx.targetPath, appId: ctx.appId, name: ctx.name }
|
|
119
|
+
const merged = clean(collectOutputs({ fs: setupFs, targetPath: ctx.targetPath })) // setup scaffold
|
|
120
|
+
for (const stage of STAGES) {
|
|
121
|
+
realm()
|
|
122
|
+
const fs = freshFs()
|
|
123
|
+
// structuredClone mimics the structured-clone every postMessage bundle undergoes.
|
|
124
|
+
const b = structuredClone(bundle)
|
|
125
|
+
await compileStage({ stage, pages: b.pages, storeInfo: b.storeInfo, fs })
|
|
126
|
+
Object.assign(merged, clean(collectOutputs({ fs, targetPath: b.targetPath })))
|
|
127
|
+
}
|
|
128
|
+
return merged
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- MODEL C: the REAL src/pool.js orchestration, driven by a mock worker ---
|
|
132
|
+
// Exercises the actual pool code (setup phase → broadcast bundle → merge scaffold)
|
|
133
|
+
// that the browser ships, but backs each "worker" with node core so no wasm/Web
|
|
134
|
+
// Worker is needed. Separate Web Worker realms are modeled by serializing every
|
|
135
|
+
// handler through one chain (isolated module state) + resetCompilerState per op —
|
|
136
|
+
// so this proves the pool's WIRING keeps scope hashes consistent, not just the
|
|
137
|
+
// compile-core sequence MODEL B covers.
|
|
138
|
+
// One shared chain across ALL mock workers: a real Web Worker is its own realm with
|
|
139
|
+
// its own compile-core module (fs backend, env singletons), but here every mock
|
|
140
|
+
// worker shares this process's single node core module. Serializing all handlers
|
|
141
|
+
// through one chain models that realm isolation (only one core op runs at a time),
|
|
142
|
+
// so pool.js's parallel compile-subset dispatch can't cross-corrupt the shared fs.
|
|
143
|
+
let mockChain = Promise.resolve()
|
|
144
|
+
function makeMockWorker() {
|
|
145
|
+
const w = { onmessage: null, onerror: null, terminate() {} }
|
|
146
|
+
w.postMessage = (msg) => {
|
|
147
|
+
mockChain = mockChain.then(async () => {
|
|
148
|
+
let reply
|
|
149
|
+
try {
|
|
150
|
+
if (msg.type === 'warmup') {
|
|
151
|
+
reply = { type: 'ready', ms: 0 }
|
|
152
|
+
} else if (msg.type === 'setup') {
|
|
153
|
+
realm()
|
|
154
|
+
const fs = createFsFromVolume(Volume.fromJSON(msg.files, msg.workPath))
|
|
155
|
+
const ctx = await setupCompile({ fs, workPath: msg.workPath })
|
|
156
|
+
reply = {
|
|
157
|
+
type: 'setup-done',
|
|
158
|
+
bundle: { pages: ctx.pages, storeInfo: ctx.storeInfo, targetPath: ctx.targetPath, appId: ctx.appId, name: ctx.name },
|
|
159
|
+
scaffold: clean(collectOutputs({ fs, targetPath: ctx.targetPath })),
|
|
160
|
+
}
|
|
161
|
+
} else if (msg.type === 'compile-subset') {
|
|
162
|
+
realm()
|
|
163
|
+
const fs = createFsFromVolume(Volume.fromJSON(msg.files, msg.workPath))
|
|
164
|
+
const b = structuredClone(msg.bundle) // mimic postMessage structured clone
|
|
165
|
+
for (const stage of msg.stages) await compileStage({ stage, pages: b.pages, storeInfo: b.storeInfo, fs })
|
|
166
|
+
reply = { type: 'done', result: { appId: b.appId, name: b.name, files: clean(collectOutputs({ fs, targetPath: b.targetPath })) } }
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {
|
|
169
|
+
reply = { type: 'error', error: String((e && e.stack) || e) }
|
|
170
|
+
}
|
|
171
|
+
queueMicrotask(() => { if (w.onmessage) w.onmessage({ data: reply }) })
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
return w
|
|
175
|
+
}
|
|
176
|
+
async function buildViaPool() {
|
|
177
|
+
const pool = createCompilerPool({ createWorker: makeMockWorker, toolchainSetupURL: 'noop://toolchain' })
|
|
178
|
+
const res = await pool.compile({ files: seed, workPath })
|
|
179
|
+
pool.dispose()
|
|
180
|
+
return res
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- ground truth: single-realm compileMiniApp ---
|
|
184
|
+
realm()
|
|
185
|
+
const inline = clean((await compileMiniApp({ fs: freshFs(), workPath })).files)
|
|
186
|
+
const gt = report('inline (single realm, ground truth)', inline)
|
|
187
|
+
|
|
188
|
+
const modelA = await buildPerStageSetup()
|
|
189
|
+
const rA = report('MODEL A — per-stage independent setup (current browser pool)', modelA)
|
|
190
|
+
|
|
191
|
+
const modelB = await buildSharedSetup()
|
|
192
|
+
const rB = report('MODEL B — shared setup bundle (fix)', modelB)
|
|
193
|
+
|
|
194
|
+
const poolRes = await buildViaPool()
|
|
195
|
+
const modelC = clean(poolRes.files)
|
|
196
|
+
const rC = report('MODEL C — real src/pool.js orchestration (mock worker)', modelC)
|
|
197
|
+
|
|
198
|
+
let failed = false
|
|
199
|
+
const fail = (m) => { failed = true; console.error(`❌ ${m}`) }
|
|
200
|
+
const pass = (m) => console.log(`✅ ${m}`)
|
|
201
|
+
|
|
202
|
+
// Ground truth must be self-consistent (every CSS hash targets a rendered element).
|
|
203
|
+
if (gt.orphanCss.length) fail(`ground truth has ${gt.orphanCss.length} orphan CSS hashes — test harness bug`)
|
|
204
|
+
else pass('ground truth: every CSS scope hash appears in the render output')
|
|
205
|
+
|
|
206
|
+
// Model A is EXPECTED to be broken — assert it reproduces the bug.
|
|
207
|
+
if (rA.orphanCss.length > 0 && rA.matched.length === 0) {
|
|
208
|
+
pass(`REPRODUCED: per-stage setup yields ${rA.orphanCss.length} orphan CSS hashes, 0 matched — WXSS fully broken`)
|
|
209
|
+
} else if (rA.orphanCss.length > 0) {
|
|
210
|
+
pass(`REPRODUCED (partial): per-stage setup yields ${rA.orphanCss.length} orphan CSS hashes`)
|
|
211
|
+
} else {
|
|
212
|
+
fail('per-stage setup did NOT reproduce the mismatch (bug model invalid)')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Model B (fix) must be fully consistent AND structurally complete.
|
|
216
|
+
if (rB.orphanCss.length === 0 && rB.css.size === gt.css.size) {
|
|
217
|
+
pass(`FIX VERIFIED: shared setup yields 0 orphan CSS hashes, ${rB.matched.length} matched (== ground truth ${gt.css.size})`)
|
|
218
|
+
} else {
|
|
219
|
+
fail(`shared setup still has ${rB.orphanCss.length} orphan CSS hashes (css=${rB.css.size} gt=${gt.css.size})`)
|
|
220
|
+
}
|
|
221
|
+
// Both models must emit the SAME file set as the single-realm ground truth — this is
|
|
222
|
+
// what the structural test already checks, and why it stays green while WXSS is broken.
|
|
223
|
+
if (fileSet(modelA) !== fileSet(inline)) fail('MODEL A file set differs from ground truth (unexpected)')
|
|
224
|
+
else pass('MODEL A file set == ground truth (this is exactly why the name/length structural test passes while WXSS is broken)')
|
|
225
|
+
if (fileSet(modelB) !== fileSet(inline)) fail('MODEL B (fix) file set differs from ground truth')
|
|
226
|
+
else pass('MODEL B (fix) file set == ground truth')
|
|
227
|
+
|
|
228
|
+
// Model C (real pool.js code) must be consistent + complete, and report the right appId.
|
|
229
|
+
if (rC.orphanCss.length === 0 && rC.css.size === gt.css.size) {
|
|
230
|
+
pass(`POOL WIRING VERIFIED: src/pool.js yields 0 orphan CSS hashes, ${rC.matched.length} matched (== ground truth ${gt.css.size})`)
|
|
231
|
+
} else {
|
|
232
|
+
fail(`src/pool.js still has ${rC.orphanCss.length} orphan CSS hashes (css=${rC.css.size} gt=${gt.css.size})`)
|
|
233
|
+
}
|
|
234
|
+
if (fileSet(modelC) !== fileSet(inline)) fail('MODEL C (pool.js) file set differs from ground truth')
|
|
235
|
+
else pass('MODEL C (pool.js) file set == ground truth')
|
|
236
|
+
if (!poolRes.appId) fail('MODEL C (pool.js) returned no appId')
|
|
237
|
+
|
|
238
|
+
console.log(failed ? '\n❌ FAIL' : '\n✅ PASS')
|
|
239
|
+
process.exit(failed ? 1 : 0)
|
package/src/pool.js
CHANGED
|
@@ -97,19 +97,33 @@ export function createCompilerPool(options = {}) {
|
|
|
97
97
|
throw new Error('[compiler] pool.compile expects { files: { relPath: content }, workPath? } (or a non-empty files map)')
|
|
98
98
|
}
|
|
99
99
|
const workPath = input.workPath || defaultWorkPath
|
|
100
|
+
|
|
101
|
+
// Phase 1 — one worker runs setup ONCE: it allocates the scope-hash ids
|
|
102
|
+
// (page + component data-v-XXXXX) and builds miniprogram_npm/app-config.json.
|
|
103
|
+
// Broadcasting this single bundle to every stage is REQUIRED for correctness:
|
|
104
|
+
// each stage runs in its own realm, and if each ran its own setup it would roll
|
|
105
|
+
// independent random uuids, so the CSS `[data-v-X]` selectors would never match
|
|
106
|
+
// the render `Module id` and every WXSS rule would target nothing (regression
|
|
107
|
+
// guarded by scripts/test-pool-scopehash.js). This mirrors the Node disk pool,
|
|
108
|
+
// which likewise sets up once and fans the same { pages, storeInfo } out.
|
|
109
|
+
const s = await workers[0].send({ type: 'setup', files, workPath })
|
|
110
|
+
if (!s || s.type === 'error') {
|
|
111
|
+
throw new Error(s && s.error ? s.error : `[compiler] setup phase failed in stage '${workers[0].stage}' worker`)
|
|
112
|
+
}
|
|
113
|
+
const { bundle, scaffold } = s
|
|
114
|
+
|
|
115
|
+
// Phase 2 — every stage compiles in parallel against the SHARED bundle. The
|
|
116
|
+
// non-stage scaffold (app-config.json + npm, produced once) seeds the union.
|
|
100
117
|
const parts = await Promise.all(workers.map((x) =>
|
|
101
|
-
x.send({ type: 'compile-subset', files, workPath, stages: [x.stage] })))
|
|
102
|
-
const merged = {}
|
|
103
|
-
let appId, name
|
|
118
|
+
x.send({ type: 'compile-subset', files, workPath, stages: [x.stage], bundle })))
|
|
119
|
+
const merged = { ...(scaffold || {}) }
|
|
104
120
|
for (let i = 0; i < parts.length; i++) {
|
|
105
121
|
const pr = parts[i]
|
|
106
122
|
// pr.error carries the worker's real error string (message + stack) — surface it.
|
|
107
123
|
if (!pr || pr.type === 'error') throw new Error(pr && pr.error ? pr.error : `[compiler] stage '${workers[i].stage}' worker error`)
|
|
108
|
-
appId = pr.result.appId
|
|
109
|
-
name = pr.result.name
|
|
110
124
|
Object.assign(merged, pr.result.files) // stages write disjoint files -> clean union
|
|
111
125
|
}
|
|
112
|
-
return { appId, name, files: merged }
|
|
126
|
+
return { appId: bundle.appId, name: bundle.name, files: merged }
|
|
113
127
|
})
|
|
114
128
|
// keep the chain alive regardless of this compile's outcome
|
|
115
129
|
chain = run.then(() => {}, () => {})
|
package/src/stage-worker.js
CHANGED
|
@@ -58,21 +58,61 @@ function freshFs(files, workPath) {
|
|
|
58
58
|
return createFsFromVolume(Volume.fromJSON(files, workPath))
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// Run setupCompile ONCE for a compile: parse config, allocate the scope-hash ids
|
|
62
|
+
// (page + component `data-v-XXXXX`), scaffold app-config.json + miniprogram_npm.
|
|
63
|
+
// Returns the SERIALIZABLE id bundle every stage worker must share, plus the
|
|
64
|
+
// non-stage scaffold files. Sharing this one bundle across the per-stage realms is
|
|
65
|
+
// what keeps the CSS `[data-v-X]` selectors and the render `Module id` in agreement:
|
|
66
|
+
// if each stage ran its own setupCompile it would roll its own random uuids and every
|
|
67
|
+
// WXSS rule would target a selector that never renders (see scripts/test-pool-scopehash.js).
|
|
68
|
+
async function runSetup(files, workPath) {
|
|
69
|
+
const fs = freshFs(files, workPath)
|
|
70
|
+
resetCompilerState()
|
|
71
|
+
const ctx = await setupCompile({ fs, workPath })
|
|
72
|
+
const map = collectOutputs({ fs, targetPath: ctx.targetPath })
|
|
73
|
+
const scaffold = {}
|
|
74
|
+
for (const k of Object.keys(map)) if (map[k] != null) scaffold[k] = map[k]
|
|
75
|
+
const bundle = {
|
|
76
|
+
pages: ctx.pages,
|
|
77
|
+
storeInfo: ctx.storeInfo,
|
|
78
|
+
targetPath: ctx.targetPath,
|
|
79
|
+
appId: ctx.appId,
|
|
80
|
+
name: ctx.name,
|
|
81
|
+
}
|
|
82
|
+
return { bundle, scaffold }
|
|
83
|
+
}
|
|
84
|
+
|
|
61
85
|
// Compile only the requested stages against a fresh memfs seeded with the source.
|
|
62
86
|
// resetCompilerState() clears the compiler's module-level caches so this warm realm
|
|
63
87
|
// stays correct across compiles. Stages write disjoint products; we return this
|
|
64
88
|
// worker's subset and the pool unions them.
|
|
65
|
-
|
|
89
|
+
//
|
|
90
|
+
// With a `bundle` (from runSetup), the stage reuses the coordinator's shared ids
|
|
91
|
+
// (mirroring the Node disk pool, which broadcasts the same { pages, storeInfo } to
|
|
92
|
+
// every stage worker) instead of running its own setupCompile — the fix for the
|
|
93
|
+
// cross-stage scope-hash mismatch. Stages read source from `workPath` and write
|
|
94
|
+
// disjoint products; they never read the setup scaffold, so it is not seeded here.
|
|
95
|
+
// Without a bundle the worker stays self-contained (single-worker / legacy callers).
|
|
96
|
+
async function compileSubset(files, workPath, stages, bundle) {
|
|
66
97
|
const fs = freshFs(files, workPath)
|
|
67
98
|
resetCompilerState()
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
99
|
+
let appId, name, targetPath
|
|
100
|
+
if (bundle) {
|
|
101
|
+
for (const stage of stages) {
|
|
102
|
+
await compileStage({ stage, pages: bundle.pages, storeInfo: bundle.storeInfo, fs })
|
|
103
|
+
}
|
|
104
|
+
;({ appId, name, targetPath } = bundle)
|
|
105
|
+
} else {
|
|
106
|
+
const ctx = await setupCompile({ fs, workPath })
|
|
107
|
+
for (const stage of stages) {
|
|
108
|
+
await compileStage({ stage, pages: ctx.pages, storeInfo: ctx.storeInfo, fs })
|
|
109
|
+
}
|
|
110
|
+
;({ appId, name, targetPath } = ctx)
|
|
71
111
|
}
|
|
72
|
-
const map = collectOutputs({ fs, targetPath
|
|
112
|
+
const map = collectOutputs({ fs, targetPath })
|
|
73
113
|
const out = {}
|
|
74
114
|
for (const k of Object.keys(map)) if (map[k] != null) out[k] = map[k]
|
|
75
|
-
return { appId
|
|
115
|
+
return { appId, name, files: out }
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
self.onmessage = async (e) => {
|
|
@@ -88,13 +128,25 @@ self.onmessage = async (e) => {
|
|
|
88
128
|
self.postMessage({ type: 'ready', ms: Math.round(performance.now() - t0) })
|
|
89
129
|
return
|
|
90
130
|
}
|
|
131
|
+
if (type === 'setup') {
|
|
132
|
+
// Coordinator phase: one worker parses config, allocates the shared scope-hash
|
|
133
|
+
// ids and builds miniprogram_npm once. setupCompile's npm build can invoke the
|
|
134
|
+
// wasm toolchain, so ensure it's loaded regardless of this worker's own stage.
|
|
135
|
+
const { files, workPath = '/work', toolchainSetupURL } = e.data
|
|
136
|
+
if (toolchainSetupURL) toolchainURL = toolchainSetupURL
|
|
137
|
+
await ensureToolchain()
|
|
138
|
+
const t = performance.now()
|
|
139
|
+
const { bundle, scaffold } = await runSetup(files, workPath)
|
|
140
|
+
self.postMessage({ type: 'setup-done', bundle, scaffold, ms: Math.round(performance.now() - t) })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
91
143
|
if (type === 'compile-subset') {
|
|
92
|
-
const { files, workPath = '/work', stages = ['logic', 'view', 'style'], toolchainSetupURL } = e.data
|
|
144
|
+
const { files, workPath = '/work', stages = ['logic', 'view', 'style'], bundle, toolchainSetupURL } = e.data
|
|
93
145
|
if (toolchainSetupURL) toolchainURL = toolchainSetupURL
|
|
94
146
|
if (needsToolchain(stages)) await ensureToolchain()
|
|
95
147
|
const warm = !!toolchainReady
|
|
96
148
|
const t = performance.now()
|
|
97
|
-
const result = await compileSubset(files, workPath, stages)
|
|
149
|
+
const result = await compileSubset(files, workPath, stages, bundle)
|
|
98
150
|
self.postMessage({ type: 'done', result, ms: Math.round(performance.now() - t), warm })
|
|
99
151
|
return
|
|
100
152
|
}
|