@vmz/vmz 0.3.13 → 0.3.15

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.
@@ -10,7 +10,8 @@
10
10
  * 3. Soft reload failure → **auto-respawn** serve-host (fresh ESM graph), not "keep broken host".
11
11
  */
12
12
  import { spawn } from 'node:child_process';
13
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
14
15
  import path from 'node:path';
15
16
  import { buildIntegratedDocuments, projectHasDocuments } from '../document/document-integrate.js';
16
17
  import { createWorkspace, resolveNativePath } from '../workspace/public-api.js';
@@ -22,6 +23,8 @@ import { coalesceRootBurst, collectDevWatchRoots, classifyWatchRoot, isDependenc
22
23
  import { createConfigCycle } from './config-cycle.js';
23
24
  import { buildReloadPayload, filterGenerationIgnore, parseHmrPlan, registerWrittenOutputsIgnore, shouldSoftReload, staticRevisionFromArtifact, } from './dev-incremental.js';
24
25
  import { diffFingerprints, fileFingerprintMap } from '../workspace/watch-diff.js';
26
+ import { packFromDeploymentIr, deploymentUnitOrigins } from '../workspace/pack.js';
27
+ import { loadDeploymentIr } from '../workspace/bundler-adapter.js';
25
28
  /** Track serve-host so a failed/killed launcher cannot leave an orphan port. */
26
29
  const DEV_HOST_PID = '.vmz-dev-host.pid';
27
30
  function cycleFailure(report) {
@@ -29,6 +32,218 @@ function cycleFailure(report) {
29
32
  const error = new Error(detail);
30
33
  return report.error ? Object.assign(error, { diagnostic: report.error }) : error;
31
34
  }
35
+ /**
36
+ * Pack 输入快照:dev 只在「决定产物字节的全部输入」完全不变时才复用上次产物。
37
+ * 判据是文件内容哈希,不使用 mtime、文件数量或后缀推导;读不到或未知的输入一律按变更处理。
38
+ */
39
+ const PACK_INPUT_IGNORED_DIRS = new Set(['_vmz', 'node_modules', '.cache', '.git']);
40
+ const PACK_INPUT_IGNORED_NAMES = /^\.vmz-dev-host\.pid$/;
41
+ /** Rust 侧的模块路径带 `//?/`(或 `\\?\`)verbatim 前缀,落盘读取前需要剥离。 */
42
+ function packInputFsPath(filePath) {
43
+ return filePath.replace(/^(?:\\\\\?\\|\/\/\?\/)/, '').replace(/\\/g, '/');
44
+ }
45
+ function readFileDigest(filePath) {
46
+ try {
47
+ return createHash('sha256').update(readFileSync(filePath)).digest('hex');
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ function findLockfile(project) {
54
+ let dir = path.resolve(project);
55
+ while (true) {
56
+ const candidate = path.join(dir, 'pnpm-lock.yaml');
57
+ if (existsSync(candidate))
58
+ return candidate;
59
+ const parent = path.dirname(dir);
60
+ if (parent === dir)
61
+ return null;
62
+ dir = parent;
63
+ }
64
+ }
65
+ function listPackInputDir(dir) {
66
+ const names = [];
67
+ const walk = (current, prefix) => {
68
+ let entries;
69
+ try {
70
+ entries = readdirSync(current, { withFileTypes: true });
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
76
+ for (const entry of entries) {
77
+ if (PACK_INPUT_IGNORED_DIRS.has(entry.name) || PACK_INPUT_IGNORED_NAMES.test(entry.name))
78
+ continue;
79
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
80
+ if (entry.isDirectory()) {
81
+ if (!walk(path.join(current, entry.name), rel))
82
+ return false;
83
+ }
84
+ else {
85
+ names.push(rel);
86
+ }
87
+ }
88
+ return true;
89
+ };
90
+ return walk(dir, '') ? names : null;
91
+ }
92
+ /** manifest 形状不认识时返回 null(而非空集合),避免把未知输入当成「没变化」。 */
93
+ function packInputFiles(manifest) {
94
+ if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0)
95
+ return null;
96
+ const files = new Set();
97
+ for (const chunk of manifest.chunks) {
98
+ if (!Array.isArray(chunk.modules))
99
+ return null;
100
+ for (const module of chunk.modules) {
101
+ if (typeof module !== 'string' || !module)
102
+ return null;
103
+ files.add(packInputFsPath(module));
104
+ }
105
+ }
106
+ if (!Array.isArray(manifest.styles))
107
+ return null;
108
+ for (const style of manifest.styles) {
109
+ if (!Array.isArray(style.sourcePaths))
110
+ return null;
111
+ for (const source of style.sourcePaths) {
112
+ if (typeof source !== 'string' || !source)
113
+ return null;
114
+ files.add(packInputFsPath(source));
115
+ }
116
+ }
117
+ return [...files].sort();
118
+ }
119
+ /** pack 只经 `deploymentUnitOrigins` 读取 IR;对这份 origins 取规范摘要,避免把构建期 affected/seed 噪声算成输入变更。 */
120
+ function deploymentOriginsDigest(outDir) {
121
+ try {
122
+ const origins = deploymentUnitOrigins(outDir, loadDeploymentIr(outDir));
123
+ const canonical = Object.keys(origins)
124
+ .sort()
125
+ .map((key) => `${key}\u0000${origins[key]}`);
126
+ return createHash('sha256').update(canonical.join('\u0001')).digest('hex');
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ /** 记下决定 pack 产物的全部输入;任何一项取不到就放弃快照(等同于永远重打包)。 */
133
+ export function capturePackInputSnapshot(manifest, outDir, project) {
134
+ const inputs = packInputFiles(manifest);
135
+ if (!inputs)
136
+ return null;
137
+ const files = [];
138
+ for (const file of inputs) {
139
+ const digest = readFileDigest(file);
140
+ if (!digest)
141
+ return null;
142
+ files.push({ path: file, digest });
143
+ }
144
+ const listings = [];
145
+ for (const dir of [...new Set(files.map((file) => path.dirname(file.path)))].sort()) {
146
+ if (dir.split(/[\\/]/).includes('node_modules'))
147
+ continue;
148
+ const names = listPackInputDir(dir);
149
+ if (!names)
150
+ return null;
151
+ listings.push({ dir, names });
152
+ }
153
+ const deploymentOrigins = deploymentOriginsDigest(outDir);
154
+ if (!deploymentOrigins)
155
+ return null;
156
+ const lockfilePath = findLockfile(project);
157
+ if (!lockfilePath)
158
+ return null;
159
+ const lockfileDigest = readFileDigest(lockfilePath);
160
+ if (!lockfileDigest)
161
+ return null;
162
+ const manifestPath = path.join(outDir, '_vmz', 'pack-manifest.json');
163
+ const manifestDigest = readFileDigest(manifestPath);
164
+ if (!manifestDigest)
165
+ return null;
166
+ return {
167
+ files,
168
+ listings,
169
+ deploymentOrigins,
170
+ lockfile: { path: lockfilePath, digest: lockfileDigest },
171
+ manifest: { path: manifestPath, digest: manifestDigest },
172
+ };
173
+ }
174
+ /** 返回 null 表示全部输入字节全等;否则返回第一个变化原因。 */
175
+ export function packInputsChangedReason(snapshot, outDir) {
176
+ const lockfileDigest = readFileDigest(snapshot.lockfile.path);
177
+ if (!lockfileDigest)
178
+ return `lockfile unreadable: ${snapshot.lockfile.path}`;
179
+ if (lockfileDigest !== snapshot.lockfile.digest)
180
+ return 'lockfile changed';
181
+ const manifestDigest = readFileDigest(snapshot.manifest.path);
182
+ if (!manifestDigest)
183
+ return `pack manifest unreadable: ${snapshot.manifest.path}`;
184
+ if (manifestDigest !== snapshot.manifest.digest)
185
+ return 'pack manifest changed';
186
+ const deploymentOrigins = deploymentOriginsDigest(outDir);
187
+ if (!deploymentOrigins)
188
+ return 'deployment IR unreadable';
189
+ if (deploymentOrigins !== snapshot.deploymentOrigins)
190
+ return 'deployment IR changed';
191
+ for (const file of snapshot.files) {
192
+ const digest = readFileDigest(file.path);
193
+ if (!digest)
194
+ return `input unreadable: ${file.path}`;
195
+ if (digest !== file.digest)
196
+ return `input changed: ${file.path}`;
197
+ }
198
+ for (const listing of snapshot.listings) {
199
+ const names = listPackInputDir(listing.dir);
200
+ if (!names)
201
+ return `input directory unreadable: ${listing.dir}`;
202
+ if (names.length !== listing.names.length)
203
+ return `input directory changed: ${listing.dir}`;
204
+ for (let i = 0; i < names.length; i += 1) {
205
+ if (names[i] !== listing.names[i])
206
+ return `input directory changed: ${listing.dir}`;
207
+ }
208
+ }
209
+ return null;
210
+ }
211
+ /** 复用上次 manifest 前确认 entry/chunk/island 查表指向的文件仍在盘上;缺任何一个就退回真实 pack。 */
212
+ export function missingPackOutput(manifest, outDir) {
213
+ const packDir = path.join(outDir, '_vmz', 'pack');
214
+ if (!existsSync(path.join(outDir, '_vmz', 'pack-manifest.json')))
215
+ return '_vmz/pack-manifest.json';
216
+ const names = new Set();
217
+ for (const entry of manifest.entryFiles ?? [])
218
+ if (typeof entry.file === 'string' && entry.file)
219
+ names.add(entry.file);
220
+ for (const dynamic of manifest.dynamicFiles ?? []) {
221
+ if (typeof dynamic.from === 'string' && dynamic.from)
222
+ names.add(dynamic.from);
223
+ if (typeof dynamic.to === 'string' && dynamic.to)
224
+ names.add(dynamic.to);
225
+ }
226
+ for (const chunk of manifest.chunks ?? []) {
227
+ if (typeof chunk.fileName === 'string' && chunk.fileName)
228
+ names.add(chunk.fileName);
229
+ if (typeof chunk.sourceMap === 'string' && chunk.sourceMap)
230
+ names.add(chunk.sourceMap);
231
+ }
232
+ for (const style of manifest.styles ?? [])
233
+ if (typeof style.fileName === 'string' && style.fileName)
234
+ names.add(style.fileName);
235
+ for (const asset of manifest.assets ?? [])
236
+ if (typeof asset.fileName === 'string' && asset.fileName)
237
+ names.add(asset.fileName);
238
+ for (const unit of manifest.unitChunks ?? [])
239
+ if (typeof unit.fileName === 'string' && unit.fileName)
240
+ names.add(unit.fileName);
241
+ for (const name of names) {
242
+ if (!existsSync(path.join(packDir, ...name.split('/'))))
243
+ return name;
244
+ }
245
+ return null;
246
+ }
32
247
  export function createDevSession(options) {
33
248
  const project = options.project;
34
249
  const outDir = options.outDir;
@@ -39,7 +254,8 @@ export function createDevSession(options) {
39
254
  let delivery = options.delivery ?? null;
40
255
  const createWs = options.createWorkspaceFn ?? createWorkspace;
41
256
  const spawnHost = options.spawnHostFn ?? defaultSpawnHost;
42
- const softReload = options.softReloadFn ?? defaultSoftReload;
257
+ const softReload = options.softReloadFn ?? ((body) => defaultReloadRequest(host, port, body));
258
+ const packBrowser = options.packFn ?? packFromDeploymentIr;
43
259
  const makeConfigCycle = options.configCycleFn ?? createConfigCycle;
44
260
  const configCandidates = configCandidatePaths(project);
45
261
  let configCycle = null;
@@ -48,6 +264,9 @@ export function createDevSession(options) {
48
264
  let stopped = false;
49
265
  let lastOutputRevision = null;
50
266
  let lastStaticRevision = null;
267
+ let lastBuildMs = 0;
268
+ let lastPackManifest = null;
269
+ let lastPackInputs = null;
51
270
  const generationIgnore = new Set();
52
271
  function planAndRebuild(changes) {
53
272
  if (changes?.length)
@@ -61,7 +280,9 @@ export function createDevSession(options) {
61
280
  catch {
62
281
  hmrPlan = null;
63
282
  }
283
+ const startedAt = Date.now();
64
284
  const report = ws.build();
285
+ lastBuildMs = Date.now() - startedAt;
65
286
  return { report, hmrPlan };
66
287
  }
67
288
  function rebuild(changes) {
@@ -75,7 +296,6 @@ export function createDevSession(options) {
75
296
  }
76
297
  async function maybeReloadAfterBuild(report, hmrPlan, opts = {}) {
77
298
  const rev = report.outputRevision ?? report.output_revision ?? null;
78
- const skipEntry = Boolean(rev && rev === lastOutputRevision);
79
299
  if (!opts.force && !opts.staticOnly && !shouldSoftReload(report, lastOutputRevision)) {
80
300
  log.info('rebuild ok — artifact unchanged, skip soft reload');
81
301
  if (rev)
@@ -85,14 +305,12 @@ export function createDevSession(options) {
85
305
  }
86
306
  const payload = buildReloadPayload(report, hmrPlan, {
87
307
  staticRevision: lastStaticRevision ?? undefined,
88
- skipEntryRewrite: skipEntry && !opts.force,
89
308
  });
90
309
  if (opts.staticOnly) {
91
310
  payload.full = false;
92
311
  payload.islandHmr = false;
93
- payload.skipEntryRewrite = true;
94
312
  }
95
- const kind = await reloadAfterBuild(payload);
313
+ const kind = await reloadAfterBuild(payload, { staticOnly: Boolean(opts.staticOnly) });
96
314
  if (rev)
97
315
  lastOutputRevision = rev;
98
316
  registerWrittenOutputsIgnore(report.writtenOutputs ?? report.written_outputs, outDir, project, generationIgnore);
@@ -117,7 +335,7 @@ export function createDevSession(options) {
117
335
  return false;
118
336
  const mode = report.full ? 'full' : 'affected';
119
337
  const chunks = (report.affectedChunks || []).join(', ') || '(none)';
120
- log.info(`${label} ok (${mode}; chunks=[${chunks}]; ${(report.emitted ?? []).length} emitted)`);
338
+ log.info(`${label} ok (${mode}; chunks=[${chunks}]; ${(report.emitted ?? []).length} emitted; ${lastBuildMs}ms build)`);
121
339
  return true;
122
340
  }
123
341
  /**
@@ -149,11 +367,11 @@ export function createDevSession(options) {
149
367
  log.info(`wechat pack ok → ${path.join(project, packRoot)} (WeChat DevTools compiles WXSS here)`);
150
368
  return true;
151
369
  }
152
- async function waitHostReady(timeoutMs = 8000) {
370
+ async function waitHostUp(timeoutMs = 8000) {
153
371
  const start = Date.now();
154
372
  while (Date.now() - start < timeoutMs) {
155
373
  try {
156
- const res = await fetch(`http://${host}:${port}/__vmz/ready`);
374
+ const res = await fetch(`http://${host}:${port}/__vmz/health`);
157
375
  if (res.ok)
158
376
  return;
159
377
  }
@@ -162,7 +380,7 @@ export function createDevSession(options) {
162
380
  }
163
381
  await sleep(50);
164
382
  }
165
- throw new Error(`serve-host not ready on ${host}:${port}`);
383
+ throw new Error(`serve-host not up on ${host}:${port}`);
166
384
  }
167
385
  async function waitHostGone(timeoutMs = 3000) {
168
386
  const start = Date.now();
@@ -176,29 +394,93 @@ export function createDevSession(options) {
176
394
  }
177
395
  }
178
396
  }
179
- async function respawnHost(reason) {
180
- log.warn(`respawning serve-host (${reason})…`);
181
- killChild(child);
182
- child = null;
183
- await waitHostGone();
184
- child = spawnHost({ project, outDir, host, port });
185
- await waitHostReady();
186
- log.info('serve-host respawned — browser SSE will reconnect and reload');
187
- }
188
397
  /**
189
- * Soft reload first; on failure auto-respawn so authors never hand-restart.
190
- * @param {object} payload
398
+ * 两阶段重建:host `prepare` 写入口 → CLI 侧 `vmz-pack` → host `commit` 发布。
399
+ * dev 的浏览器交付必须走 pack,否则逐文件模块保留裸包说明符。
400
+ * pack 失败显式 SSE error 且不回退到未打包入口。`staticOnly`(仅 public
401
+ * 资产)不改变客户端图,因此不重打包。
191
402
  */
192
- async function reloadAfterBuild(payload) {
403
+ async function reloadWithPack(payload, opts = {}) {
193
404
  try {
194
- await softReload(host, port, payload);
195
- return 'soft';
405
+ await softReload({ phase: 'prepare', payload });
196
406
  }
197
407
  catch (err) {
408
+ if (opts.initial) {
409
+ log.warn(`initial serve-host load failed (${err}) — host stays up`);
410
+ return 'error';
411
+ }
198
412
  log.warn(`soft reload failed (${err}) — auto-respawning serve-host`);
199
413
  await respawnHost('soft-reload-failed');
200
414
  return 'respawn';
201
415
  }
416
+ if (!opts.staticOnly) {
417
+ const startedAt = Date.now();
418
+ const changedReason = lastPackInputs ? packInputsChangedReason(lastPackInputs, outDir) : 'no pack input snapshot';
419
+ const missing = changedReason === null && lastPackManifest ? missingPackOutput(lastPackManifest, outDir) : null;
420
+ if (changedReason === null && !missing && lastPackManifest) {
421
+ log.info(`dev pack skip (inputs unchanged, ${Date.now() - startedAt}ms)`);
422
+ }
423
+ else {
424
+ if (changedReason)
425
+ log.info(`dev pack (inputs changed: ${changedReason})`);
426
+ if (missing)
427
+ log.info(`dev pack (manifest reuse rejected: missing ${missing})`);
428
+ try {
429
+ const { manifest } = packBrowser(outDir, { projectRoot: project });
430
+ lastPackManifest = manifest;
431
+ lastPackInputs = capturePackInputSnapshot(lastPackManifest, outDir, project);
432
+ log.info(`dev pack ok (${manifest.chunkCount} chunks, ${Date.now() - startedAt}ms)`);
433
+ if (!lastPackInputs)
434
+ log.info('dev pack inputs: snapshot unavailable (unknown input) — next rebuild repacks');
435
+ }
436
+ catch (err) {
437
+ lastPackManifest = null;
438
+ lastPackInputs = null;
439
+ const message = err instanceof Error ? err.message : String(err);
440
+ log.error(`dev pack failed: ${message}`);
441
+ try {
442
+ await softReload({
443
+ phase: 'abort',
444
+ error: { message, stack: err instanceof Error ? err.stack : undefined },
445
+ });
446
+ }
447
+ catch {
448
+ /* host may be gone; keep the reported pack error */
449
+ }
450
+ return 'error';
451
+ }
452
+ }
453
+ }
454
+ try {
455
+ await softReload({ phase: 'commit' });
456
+ return 'soft';
457
+ }
458
+ catch (err) {
459
+ if (opts.initial) {
460
+ log.warn(`initial serve-host commit failed (${err}) — host stays up`);
461
+ return 'error';
462
+ }
463
+ log.warn(`reload commit failed (${err}) — auto-respawning serve-host`);
464
+ await respawnHost('commit-failed');
465
+ return 'respawn';
466
+ }
467
+ }
468
+ async function respawnHost(reason) {
469
+ log.warn(`respawning serve-host (${reason})…`);
470
+ killChild(child);
471
+ child = null;
472
+ await waitHostGone();
473
+ child = spawnHost({ project, outDir, host, port });
474
+ await waitHostUp();
475
+ const kind = await reloadWithPack({}, { initial: true });
476
+ if (kind === 'soft')
477
+ log.info('serve-host respawned — browser SSE will reconnect and reload');
478
+ else
479
+ log.warn('serve-host respawned but initial reload failed — fix the error and save');
480
+ }
481
+ /** @param {object} payload */
482
+ async function reloadAfterBuild(payload, opts = {}) {
483
+ return reloadWithPack(payload, opts);
202
484
  }
203
485
  let docEnginesPromise = null;
204
486
  /** `engines.code` / `engines.markdown` for the document layer (loaded once per session). */
@@ -215,6 +497,33 @@ export function createDevSession(options) {
215
497
  if (!existsSync(src)) {
216
498
  throw new Error(`vmz dev: missing src/ under ${project}`);
217
499
  }
500
+ // Snapshot watch roots and their fingerprints **before** the initial build.
501
+ // A save that lands while `vmz dev` boots must still be detected by the first
502
+ // poll. Taking the baseline after the initial reload absorbs that edit into the
503
+ // baseline, so the host serves the pre-edit bytes until the next save.
504
+ const docsRoot = path.join(project, 'documents');
505
+ const localesRoot = path.join(project, 'locales');
506
+ const designsRoot = path.join(project, 'designs');
507
+ const watched = collectDevWatchRoots({ project, outDir });
508
+ const publicRoot = watched.publicRoot;
509
+ /** Candidate config files plus their local reachable deps; refreshed after each cycle. */
510
+ const refreshConfigPaths = () => {
511
+ const deps = configCandidates
512
+ .filter((p) => existsSync(p))
513
+ .flatMap((p) => collectConfigDependencies(p, project));
514
+ return [...new Set([...configCandidates, ...deps])];
515
+ };
516
+ let configPaths = refreshConfigPaths();
517
+ /** @type {string[]} */
518
+ const watchRoots = [...watched.roots];
519
+ /** @type {string[]} */
520
+ let dependencyRoots = [...watched.dependencyRoots];
521
+ /** @type {Map<string, Map<string, string>>} */
522
+ const fingerprints = new Map();
523
+ for (const root of watchRoots) {
524
+ fingerprints.set(root, fileFingerprintMap(root));
525
+ }
526
+ let configFingerprints = configFingerprintMap(configPaths);
218
527
  // Config evaluation + plugin contributions run in a detached build process.
219
528
  // Functions/plugin objects never cross the boundary; only the data report does.
220
529
  if (configCandidates.some((p) => existsSync(p))) {
@@ -253,27 +562,16 @@ export function createDevSession(options) {
253
562
  throw new Error(`vmz dev: missing ${hostJs}`);
254
563
  }
255
564
  child = spawnHost({ project, outDir, host, port });
256
- await waitHostReady().catch((err) => {
565
+ await waitHostUp().catch((err) => {
257
566
  log.warn(String(err));
258
567
  });
568
+ const initialKind = await reloadAfterBuild({}, { staticOnly: false }).catch((err) => {
569
+ log.warn(`initial reload failed: ${err}`);
570
+ return 'error';
571
+ });
572
+ if (initialKind !== 'soft')
573
+ log.warn('initial serve-host reload failed — fix the error and save');
259
574
  }
260
- const docsRoot = path.join(project, 'documents');
261
- const localesRoot = path.join(project, 'locales');
262
- const designsRoot = path.join(project, 'designs');
263
- const watched = collectDevWatchRoots({ project, outDir });
264
- const publicRoot = watched.publicRoot;
265
- /** Candidate config files plus their local reachable deps; refreshed after each cycle. */
266
- const refreshConfigPaths = () => {
267
- const deps = configCandidates
268
- .filter((p) => existsSync(p))
269
- .flatMap((p) => collectConfigDependencies(p, project));
270
- return [...new Set([...configCandidates, ...deps])];
271
- };
272
- let configPaths = refreshConfigPaths();
273
- /** @type {string[]} */
274
- const watchRoots = [...watched.roots];
275
- /** @type {string[]} */
276
- let dependencyRoots = [...watched.dependencyRoots];
277
575
  if (wechatPreview) {
278
576
  log.info(`dev → WeChat DevTools (watching ${watchRoots.join(', ')}; keep dist/wechat open)`);
279
577
  }
@@ -283,12 +581,6 @@ export function createDevSession(options) {
283
581
  if (dependencyRoots.length) {
284
582
  log.info(`dev dependency watch roots (${dependencyRoots.length}): ${dependencyRoots.join(', ')}`);
285
583
  }
286
- /** @type {Map<string, Map<string, string>>} */
287
- const fingerprints = new Map();
288
- for (const root of watchRoots) {
289
- fingerprints.set(root, fileFingerprintMap(root));
290
- }
291
- let configFingerprints = configFingerprintMap(configPaths);
292
584
  const signal = options.signal;
293
585
  const onAbort = () => {
294
586
  void stop();
@@ -360,8 +652,7 @@ export function createDevSession(options) {
360
652
  break;
361
653
  if (!wechatPreview && child && child.exitCode != null) {
362
654
  log.warn(`serve-host exited (${child.exitCode}) — respawning…`);
363
- child = spawnHost({ project, outDir, host, port });
364
- await waitHostReady().catch(() => { });
655
+ await respawnHost('host-exited');
365
656
  continue;
366
657
  }
367
658
  let batch;
@@ -459,7 +750,7 @@ export function createDevSession(options) {
459
750
  commitFingerprints();
460
751
  if (!wechatPreview) {
461
752
  const kind = await maybeReloadAfterBuild({ reloadRequired: true }, null, { force: true, staticOnly: true });
462
- if (kind !== 'skip')
753
+ if (kind === 'soft' || kind === 'respawn')
463
754
  log.info('soft reload ok (public assets)');
464
755
  }
465
756
  continue;
@@ -555,7 +846,7 @@ export function createDevSession(options) {
555
846
  }
556
847
  const kind = await maybeReloadAfterBuild(report, hmrPlan, { force: true });
557
848
  commitFingerprints();
558
- if (kind !== 'skip') {
849
+ if (kind === 'soft' || kind === 'respawn') {
559
850
  log.info(kind === 'respawn' ? 'reload ok (respawned; deps)' : 'soft reload ok (full page; deps)');
560
851
  }
561
852
  continue;
@@ -592,7 +883,7 @@ export function createDevSession(options) {
592
883
  force: needFullReload,
593
884
  });
594
885
  commitFingerprints();
595
- if (kind !== 'skip') {
886
+ if (kind === 'soft' || kind === 'respawn') {
596
887
  log.info(kind === 'respawn'
597
888
  ? 'reload ok (respawned serve-host)'
598
889
  : needFullReload
@@ -627,7 +918,7 @@ export function createDevSession(options) {
627
918
  }
628
919
  const kind = await maybeReloadAfterBuild(report, hmrPlan, { force: true });
629
920
  commitFingerprints();
630
- if (kind !== 'skip') {
921
+ if (kind === 'soft' || kind === 'respawn') {
631
922
  log.info(kind === 'respawn' ? 'reload ok (respawned; designs)' : 'soft reload ok (full page; designs)');
632
923
  }
633
924
  continue;
@@ -648,7 +939,7 @@ export function createDevSession(options) {
648
939
  else {
649
940
  const kind = await maybeReloadAfterBuild({ reloadRequired: true, full: true }, null, { force: true });
650
941
  commitFingerprints();
651
- if (kind !== 'skip') {
942
+ if (kind === 'soft' || kind === 'respawn') {
652
943
  log.info(kind === 'respawn' ? 'reload ok (respawned; locales)' : 'soft reload ok (full page; locales)');
653
944
  }
654
945
  if (!batch.docsDirty)
@@ -670,7 +961,7 @@ export function createDevSession(options) {
670
961
  else {
671
962
  const kind = await maybeReloadAfterBuild({ reloadRequired: true, full: true }, null, { force: true });
672
963
  commitFingerprints();
673
- if (kind !== 'skip') {
964
+ if (kind === 'soft' || kind === 'respawn') {
674
965
  log.info(kind === 'respawn' ? 'reload ok (respawned; docs)' : 'soft reload ok (full page; docs)');
675
966
  }
676
967
  }
@@ -740,23 +1031,24 @@ function defaultSpawnHost(opts) {
740
1031
  return child;
741
1032
  }
742
1033
  /**
1034
+ * Host reload request(两阶段协议:`prepare` / `commit` / `abort`)。
743
1035
  * @param {string} host
744
1036
  * @param {number} port
745
- * @param {object} [payload]
1037
+ * @param {Record<string, unknown>} body
746
1038
  */
747
- async function defaultSoftReload(host, port, payload = {}) {
1039
+ async function defaultReloadRequest(host, port, body) {
748
1040
  const url = `http://${host}:${port}/__vmz/reload`;
749
- const body = JSON.stringify(payload);
750
1041
  const res = await fetch(url, {
751
1042
  method: 'POST',
752
1043
  headers: { 'content-type': 'application/json' },
753
- body,
1044
+ body: JSON.stringify(body),
754
1045
  });
755
1046
  if (!res.ok)
756
1047
  throw new Error(`HTTP ${res.status}`);
757
1048
  const json = await res.json().catch(() => ({}));
758
1049
  if (!json?.ok)
759
1050
  throw new Error(JSON.stringify(json));
1051
+ return json;
760
1052
  }
761
1053
  function killChild(child) {
762
1054
  if (!child || child.killed)
@@ -8,7 +8,7 @@
8
8
  * colors inside code blocks come from the Shiki theme and stay identical on
9
9
  * every site. Sites do not ship document stylesheets.
10
10
  *
11
- * House rules (规划设计/vmz 05): pure-white base with near-black blocks for
11
+ * House rules: pure-white base with near-black blocks for
12
12
  * rhythm, body text ≥ 16px, small text ≥ 12px at weight ≥ 400, no uppercase
13
13
  * letter-spaced micro labels, no 3D / volumetric surfaces.
14
14
  */
@@ -8,7 +8,7 @@
8
8
  * colors inside code blocks come from the Shiki theme and stay identical on
9
9
  * every site. Sites do not ship document stylesheets.
10
10
  *
11
- * House rules (规划设计/vmz 05): pure-white base with near-black blocks for
11
+ * House rules: pure-white base with near-black blocks for
12
12
  * rhythm, body text ≥ 16px, small text ≥ 12px at weight ≥ 400, no uppercase
13
13
  * letter-spaced micro labels, no 3D / volumetric surfaces.
14
14
  */
@@ -2,7 +2,7 @@
2
2
  * Document — wire the compiled app stylesheet and the framework-owned document
3
3
  * base layer into static document assets.
4
4
  *
5
- * Layering (see 规划设计/vmz 04 文档层):
5
+ * Layering:
6
6
  * - `assets/vmz-designs.css`: the site's compiled design tokens (`vmz-designs.css`
7
7
  * / `vmz.css` from the app build, or `designs/styles/index.css` standalone).
8
8
  * - `assets/vmz-document.css`: the framework document base (`DOCUMENT_BASE_CSS`),
@@ -42,6 +42,12 @@ interface LocalStaticHostOpts {
42
42
  host?: string;
43
43
  port?: number;
44
44
  }
45
+ export interface LocalStaticHost {
46
+ host: string;
47
+ port: number;
48
+ baseUrl: string;
49
+ close: () => Promise<void>;
50
+ }
45
51
  export declare function buildCdnPolicyManifest(staticManifest: Record<string, unknown>, opts?: BuildCdnPolicyOpts): CdnPolicyManifest;
46
52
  /**
47
53
  * Write CDN policy + adapter projections under dist/_vmz.
@@ -165,7 +171,7 @@ export declare function projectCdnAdapter(policy: CdnPolicyManifest, adapterId:
165
171
  * @param {{ host?: string, port?: number }} [opts]
166
172
  * @returns {Promise<{ host: string, port: number, baseUrl: string, close: () => Promise<void> }>}
167
173
  */
168
- export declare function listenLocalStaticHost(distDir: string, policy: Record<string, unknown>, opts?: LocalStaticHostOpts): Promise<unknown>;
174
+ export declare function listenLocalStaticHost(distDir: string, policy: Record<string, unknown>, opts?: LocalStaticHostOpts): Promise<LocalStaticHost>;
169
175
  /**
170
176
  * @param {string} distDir
171
177
  * @param {Record<string, any>} policy