@vmz/vmz 0.1.23 → 0.1.25

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.
@@ -7,6 +7,7 @@
7
7
  * so barrels resolve at dist root.
8
8
  */
9
9
  export declare const CONTENT_ADDRESSED_ASSETS_SCHEMA = "vmz.content_addressed_assets.v0";
10
+ export declare const ASSET_PLAN_SCHEMA = "vmz.asset.plan.v0";
10
11
  interface ContentAddressedAssetObject {
11
12
  logicalPath: string;
12
13
  assetPath: string;
@@ -13,6 +13,7 @@ import { requireNativeAddon } from './native-addon.js';
13
13
  import { canonicalJson, sha256Hex } from './release-pack.js';
14
14
  import { writePrettyJsonFile } from './pretty-json.js';
15
15
  export const CONTENT_ADDRESSED_ASSETS_SCHEMA = 'vmz.content_addressed_assets.v0';
16
+ export const ASSET_PLAN_SCHEMA = 'vmz.asset.plan.v0';
16
17
  /** Immutable delivery candidates (client-facing bytes). */
17
18
  const DEFAULT_CANDIDATES = [
18
19
  'entry-client.js',
@@ -92,6 +93,14 @@ export function emitContentAddressedAssets(distDir, opts = {}) {
92
93
  const objects = [];
93
94
  const rewrites = {};
94
95
  const ordered = orderCandidates(candidates);
96
+ const vmzMetaDir = path.join(abs, '_vmz');
97
+ fs.mkdirSync(vmzMetaDir, { recursive: true });
98
+ writePrettyJsonFile(path.join(vmzMetaDir, 'asset-plan.json'), {
99
+ schema: ASSET_PLAN_SCHEMA,
100
+ layout: 'assets/<sha256>.<ext>',
101
+ immutable: true,
102
+ candidates: ordered,
103
+ });
95
104
  for (const rel of ordered) {
96
105
  // Aggregators are replaced in later passes; leaf JS must rewrite ./ → ../ so a
97
106
  // hashed barrel under assets/ never 404s on second-hop relative re-exports.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Dev incremental state helpers (0.1.24): HMR plan parsing, reload decisions, static revision.
3
+ */
4
+ export interface HmrPlanWire {
5
+ mode?: string;
6
+ islandOnly?: boolean;
7
+ island_only?: boolean;
8
+ rerunLoaders?: string[];
9
+ rerun_loaders?: string[];
10
+ affectedChunks?: string[];
11
+ affected_chunks?: string[];
12
+ seedChunks?: string[];
13
+ seed_chunks?: string[];
14
+ }
15
+ export interface BuildReportWire {
16
+ full?: boolean;
17
+ islandHmr?: boolean;
18
+ island_hmr?: boolean;
19
+ affectedChunks?: string[];
20
+ affected_chunks?: string[];
21
+ seedChunks?: string[];
22
+ seed_chunks?: string[];
23
+ emitted?: string[];
24
+ outputRevision?: string;
25
+ output_revision?: string;
26
+ writtenOutputs?: string[];
27
+ written_outputs?: string[];
28
+ reloadRequired?: boolean;
29
+ reload_required?: boolean;
30
+ }
31
+ export interface DevReloadPayload {
32
+ affectedChunks?: string[];
33
+ seedChunks?: string[];
34
+ emitted?: string[];
35
+ full?: boolean;
36
+ islandHmr?: boolean;
37
+ rerunLoaders?: string[];
38
+ staticRevision?: string;
39
+ skipEntryRewrite?: boolean;
40
+ outputRevision?: string;
41
+ }
42
+ export declare function parseHmrPlan(raw: string | object | null | undefined): HmrPlanWire | null;
43
+ export declare function hmrRerunLoaders(plan: HmrPlanWire | null | undefined): string[];
44
+ export declare function buildReloadPayload(report: BuildReportWire, hmrPlan: HmrPlanWire | null, opts?: {
45
+ staticRevision?: string;
46
+ skipEntryRewrite?: boolean;
47
+ }): DevReloadPayload;
48
+ export declare function shouldSoftReload(report: BuildReportWire, lastOutputRevision: string | null, opts?: {
49
+ force?: boolean;
50
+ }): boolean;
51
+ export declare function staticRevisionFromArtifact(artifact: {
52
+ files?: Array<{
53
+ path: string;
54
+ bytes?: number;
55
+ }>;
56
+ fileCount?: number;
57
+ } | null): string;
58
+ export declare function filterGenerationIgnore(changed: string[], ignore: Set<string>): string[];
59
+ export declare function devPathNormalize(p: string): string;
60
+ export declare function registerWrittenOutputsIgnore(writtenOutputs: string[] | undefined, outDir: string, projectRoot: string, ignore: Set<string>): void;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Dev incremental state helpers (0.1.24): HMR plan parsing, reload decisions, static revision.
3
+ */
4
+ import path from 'node:path';
5
+ export function parseHmrPlan(raw) {
6
+ if (!raw)
7
+ return null;
8
+ try {
9
+ const doc = typeof raw === 'string' ? JSON.parse(raw) : raw;
10
+ return doc && typeof doc === 'object' ? doc : null;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ export function hmrRerunLoaders(plan) {
17
+ if (!plan)
18
+ return [];
19
+ const list = plan.rerunLoaders ?? plan.rerun_loaders ?? [];
20
+ return Array.isArray(list) ? list.map(String) : [];
21
+ }
22
+ export function buildReloadPayload(report, hmrPlan, opts = {}) {
23
+ const rerunLoaders = hmrRerunLoaders(hmrPlan);
24
+ const affectedChunks = report.affectedChunks ?? report.affected_chunks ?? [];
25
+ const islandHmr = Boolean(report.islandHmr ?? report.island_hmr);
26
+ const mode = String(hmrPlan?.mode ?? '').toLowerCase();
27
+ let full = Boolean(report.full);
28
+ if (mode === 'full')
29
+ full = true;
30
+ if (islandHmr && rerunLoaders.length === 0 && affectedChunks.length > 0) {
31
+ full = true;
32
+ }
33
+ return {
34
+ affectedChunks,
35
+ seedChunks: report.seedChunks ?? report.seed_chunks ?? [],
36
+ emitted: report.emitted ?? [],
37
+ full,
38
+ islandHmr: islandHmr && !full,
39
+ rerunLoaders,
40
+ staticRevision: opts.staticRevision,
41
+ skipEntryRewrite: opts.skipEntryRewrite,
42
+ outputRevision: report.outputRevision ?? report.output_revision,
43
+ };
44
+ }
45
+ export function shouldSoftReload(report, lastOutputRevision, opts = {}) {
46
+ if (opts.force)
47
+ return true;
48
+ const reloadRequired = report.reloadRequired ?? report.reload_required;
49
+ if (reloadRequired === false)
50
+ return false;
51
+ const rev = report.outputRevision ?? report.output_revision ?? '';
52
+ if (lastOutputRevision && rev && rev === lastOutputRevision)
53
+ return false;
54
+ return true;
55
+ }
56
+ export function staticRevisionFromArtifact(artifact) {
57
+ if (!artifact || artifact.fileCount === 0)
58
+ return 'empty';
59
+ const parts = (artifact.files ?? []).map((f) => `${f.path}:${f.bytes ?? 0}`);
60
+ parts.sort();
61
+ let h = 0xcbf29ce484222325n;
62
+ for (const p of parts) {
63
+ for (const b of Buffer.from(p)) {
64
+ h = (h * 0x100000001b3n + BigInt(b)) & 0xffffffffffffffffn;
65
+ }
66
+ }
67
+ return h.toString(16);
68
+ }
69
+ export function filterGenerationIgnore(changed, ignore) {
70
+ if (!ignore.size)
71
+ return changed;
72
+ return changed.filter((p) => !ignore.has(devPathNormalize(p)));
73
+ }
74
+ export function devPathNormalize(p) {
75
+ return String(p).replace(/\\/g, '/');
76
+ }
77
+ export function registerWrittenOutputsIgnore(writtenOutputs, outDir, projectRoot, ignore) {
78
+ for (const rel of writtenOutputs ?? []) {
79
+ const abs = path.isAbsolute(rel) ? rel : path.join(outDir, rel);
80
+ ignore.add(devPathNormalize(abs));
81
+ const underProject = devPathNormalize(abs);
82
+ if (underProject.startsWith(devPathNormalize(projectRoot))) {
83
+ ignore.add(underProject);
84
+ }
85
+ }
86
+ }
@@ -16,7 +16,8 @@ import { buildIntegratedDocuments, projectHasDocuments } from './document-integr
16
16
  import { createWorkspace, resolveNativePath } from './index.js';
17
17
  import { emitLocaleRuntimeModules, localeHasErrors } from './locale-check.js';
18
18
  import { log } from './log.js';
19
- import { coalesceRootBurst, collectDevWatchRoots, classifyWatchRoot, isDependencyPath, mergeDirtySets } from './dev-watch-roots.js';
19
+ import { coalesceRootBurst, collectDevWatchRoots, classifyWatchRoot, isDependencyPath, mergeDirtySets, collectDevConfigPaths, configFingerprintMap, } from './dev-watch-roots.js';
20
+ import { buildReloadPayload, filterGenerationIgnore, parseHmrPlan, registerWrittenOutputsIgnore, shouldSoftReload, staticRevisionFromArtifact, } from './dev-incremental.js';
20
21
  import { diffFingerprints, fileFingerprintMap } from './watch-diff.js';
21
22
  /** Track serve-host so a failed/killed launcher cannot leave an orphan port. */
22
23
  const DEV_HOST_PID = '.vmz-dev-host.pid';
@@ -33,10 +34,57 @@ export function createDevSession(options) {
33
34
  const ws = createWs({ root: project, outDir });
34
35
  let child = null;
35
36
  let stopped = false;
36
- function rebuild(changes) {
37
+ let lastOutputRevision = null;
38
+ let lastStaticRevision = null;
39
+ const generationIgnore = new Set();
40
+ function planAndRebuild(changes) {
37
41
  if (changes?.length)
38
42
  ws.updateFiles(changes);
39
- return ws.build();
43
+ let hmrPlan = null;
44
+ try {
45
+ if (typeof ws.queryHmrPlan === 'function') {
46
+ hmrPlan = parseHmrPlan(ws.queryHmrPlan());
47
+ }
48
+ }
49
+ catch {
50
+ hmrPlan = null;
51
+ }
52
+ const report = ws.build();
53
+ return { report, hmrPlan };
54
+ }
55
+ function rebuild(changes) {
56
+ return planAndRebuild(changes).report;
57
+ }
58
+ async function emitPublicAndTrack() {
59
+ const { emitPublicStaticAssets } = await import('./public-static-assets.js');
60
+ const artifact = emitPublicStaticAssets(outDir, { projectRoot: project });
61
+ lastStaticRevision = staticRevisionFromArtifact(artifact);
62
+ return artifact;
63
+ }
64
+ async function maybeReloadAfterBuild(report, hmrPlan, opts = {}) {
65
+ const rev = report.outputRevision ?? report.output_revision ?? null;
66
+ const skipEntry = Boolean(rev && rev === lastOutputRevision);
67
+ if (!opts.force && !opts.staticOnly && !shouldSoftReload(report, lastOutputRevision)) {
68
+ log.info('rebuild ok — artifact unchanged, skip soft reload');
69
+ if (rev)
70
+ lastOutputRevision = rev;
71
+ registerWrittenOutputsIgnore(report.writtenOutputs ?? report.written_outputs, outDir, project, generationIgnore);
72
+ return 'skip';
73
+ }
74
+ const payload = buildReloadPayload(report, hmrPlan, {
75
+ staticRevision: lastStaticRevision ?? undefined,
76
+ skipEntryRewrite: skipEntry && !opts.force,
77
+ });
78
+ if (opts.staticOnly) {
79
+ payload.full = false;
80
+ payload.islandHmr = false;
81
+ payload.skipEntryRewrite = true;
82
+ }
83
+ const kind = await reloadAfterBuild(payload);
84
+ if (rev)
85
+ lastOutputRevision = rev;
86
+ registerWrittenOutputsIgnore(report.writtenOutputs ?? report.written_outputs, outDir, project, generationIgnore);
87
+ return kind;
40
88
  }
41
89
  function emitLocales() {
42
90
  const localeEmit = emitLocaleRuntimeModules(project, outDir);
@@ -154,16 +202,14 @@ export function createDevSession(options) {
154
202
  }
155
203
  }
156
204
  log.info('initial build (N-API workspace, full)…');
157
- const initial = rebuild();
205
+ const initialPair = planAndRebuild();
206
+ const initial = initialPair.report;
158
207
  if (!printReport(initial, 'build')) {
159
208
  throw new Error('vmz dev: initial build failed');
160
209
  }
210
+ lastOutputRevision = initial.outputRevision ?? initial.output_revision ?? null;
161
211
  // `vmz build` assemble copies `public/**` → outDir; soft `ws.build()` does not.
162
- // Without this, marketing images and other opaque assets 404 under `vmz dev`.
163
- {
164
- const { emitPublicStaticAssets } = await import('./public-static-assets.js');
165
- emitPublicStaticAssets(outDir, { projectRoot: project });
166
- }
212
+ await emitPublicAndTrack();
167
213
  // Reclaim orphan serve-host from a previous crashed launcher (same outDir).
168
214
  reclaimStaleDevHost(outDir);
169
215
  if (projectHasDocuments(project)) {
@@ -191,6 +237,8 @@ export function createDevSession(options) {
191
237
  const localesRoot = path.join(project, 'locales');
192
238
  const designsRoot = path.join(project, 'designs');
193
239
  const watched = collectDevWatchRoots({ project, outDir });
240
+ const publicRoot = watched.publicRoot;
241
+ const configPaths = watched.configPaths.length ? watched.configPaths : collectDevConfigPaths(project);
194
242
  /** @type {string[]} */
195
243
  const watchRoots = [...watched.roots];
196
244
  /** @type {string[]} */
@@ -209,6 +257,7 @@ export function createDevSession(options) {
209
257
  for (const root of watchRoots) {
210
258
  fingerprints.set(root, fileFingerprintMap(root));
211
259
  }
260
+ let configFingerprints = configFingerprintMap(configPaths);
212
261
  const signal = options.signal;
213
262
  const onAbort = () => {
214
263
  void stop();
@@ -218,7 +267,7 @@ export function createDevSession(options) {
218
267
  * Scan all watch roots into a batch. Does not update fingerprints.
219
268
  */
220
269
  function scanBatch() {
221
- /** @type {{ srcChanged: string[], srcDeleted: string[], depChanged: string[], depDeleted: string[], designsChanged: string[], designsDeleted: string[], docsDirty: boolean, localesDirty: boolean, designsDirty: boolean }} */
270
+ /** @type {{ srcChanged: string[], srcDeleted: string[], depChanged: string[], depDeleted: string[], designsChanged: string[], designsDeleted: string[], publicChanged: string[], docsDirty: boolean, localesDirty: boolean, designsDirty: boolean, configDirty: boolean }} */
222
271
  const batch = {
223
272
  srcChanged: [],
224
273
  srcDeleted: [],
@@ -226,11 +275,13 @@ export function createDevSession(options) {
226
275
  depDeleted: [],
227
276
  designsChanged: [],
228
277
  designsDeleted: [],
278
+ publicChanged: [],
229
279
  docsDirty: false,
230
280
  localesDirty: false,
231
281
  designsDirty: false,
282
+ configDirty: false,
232
283
  };
233
- const watchCtx = { src, docsRoot, localesRoot, designsRoot, dependencyRoots };
284
+ const watchCtx = { src, docsRoot, localesRoot, designsRoot, publicRoot, dependencyRoots };
234
285
  for (const root of watchRoots) {
235
286
  const prev = fingerprints.get(root) || new Map();
236
287
  const next = fileFingerprintMap(root);
@@ -242,6 +293,9 @@ export function createDevSession(options) {
242
293
  batch.srcChanged.push(...diff.changed);
243
294
  batch.srcDeleted.push(...diff.deleted);
244
295
  }
296
+ else if (kind === 'public') {
297
+ batch.publicChanged.push(...diff.changed);
298
+ }
245
299
  else if (kind === 'locales') {
246
300
  batch.localesDirty = true;
247
301
  }
@@ -282,16 +336,51 @@ export function createDevSession(options) {
282
336
  let batch;
283
337
  try {
284
338
  batch = scanBatch();
339
+ const nextConfigFp = configFingerprintMap(configPaths);
340
+ const configDiff = diffFingerprints(configFingerprints, nextConfigFp);
341
+ if (configDiff.changed.length || configDiff.deleted.length) {
342
+ batch.configDirty = true;
343
+ }
285
344
  }
286
345
  catch (err) {
287
346
  log.warn('watch error:', err);
288
347
  continue;
289
348
  }
349
+ if (generationIgnore.size) {
350
+ batch.srcChanged = filterGenerationIgnore(batch.srcChanged, generationIgnore);
351
+ batch.srcDeleted = filterGenerationIgnore(batch.srcDeleted, generationIgnore);
352
+ generationIgnore.clear();
353
+ }
290
354
  const srcDirty = batch.srcChanged.length + batch.srcDeleted.length;
291
355
  const depDirty = batch.depChanged.length + batch.depDeleted.length;
292
356
  const designsDirty = batch.designsDirty || batch.designsChanged.length + batch.designsDeleted.length;
293
- if (!srcDirty && !depDirty && !batch.docsDirty && !batch.localesDirty && !designsDirty)
357
+ const publicDirty = batch.publicChanged.length;
358
+ if (!srcDirty && !depDirty && !batch.docsDirty && !batch.localesDirty && !designsDirty && !publicDirty && !batch.configDirty) {
294
359
  continue;
360
+ }
361
+ if (batch.configDirty) {
362
+ log.info('vmz.config change detected — respawn required (devControl)');
363
+ configFingerprints = configFingerprintMap(configPaths);
364
+ commitFingerprints();
365
+ await respawnHost('config-changed');
366
+ const report = rebuild();
367
+ if (printReport(report, 'rebuild')) {
368
+ lastOutputRevision = report.outputRevision ?? report.output_revision ?? null;
369
+ await emitPublicAndTrack();
370
+ }
371
+ continue;
372
+ }
373
+ if (publicDirty && !srcDirty && !depDirty && !batch.docsDirty && !batch.localesDirty && !designsDirty) {
374
+ log.info(`public change detected (${publicDirty} file(s)) — re-emit static assets…`);
375
+ await emitPublicAndTrack();
376
+ commitFingerprints();
377
+ if (!wechatPreview) {
378
+ const kind = await maybeReloadAfterBuild({ reloadRequired: true }, null, { force: true, staticOnly: true });
379
+ if (kind !== 'skip')
380
+ log.info('soft reload ok (public assets)');
381
+ }
382
+ continue;
383
+ }
295
384
  // Coalesce multi-file bursts without dropping the initial dirty set.
296
385
  if (srcDirty > 1) {
297
386
  const coalesced = await coalesceRootBurst(src, fingerprints, {
@@ -344,7 +433,6 @@ export function createDevSession(options) {
344
433
  batch.designsChanged = designsMerged.changed;
345
434
  batch.designsDeleted = designsMerged.deleted;
346
435
  }
347
- commitFingerprints();
348
436
  if (!batch.srcChanged.length &&
349
437
  !batch.srcDeleted.length &&
350
438
  !batch.depChanged.length &&
@@ -357,11 +445,12 @@ export function createDevSession(options) {
357
445
  // Dependency changes: conservative full rebuild + full reload (v0.1.5).
358
446
  if (batch.depChanged.length || batch.depDeleted.length) {
359
447
  log.info(`dependency change detected (${batch.depChanged.length} update, ${batch.depDeleted.length} delete) — full rebuild…`);
360
- const report = rebuild();
448
+ const { report, hmrPlan } = planAndRebuild();
361
449
  if (!printReport(report, 'rebuild')) {
362
450
  log.warn('rebuild failed — keeping previous server');
363
451
  continue;
364
452
  }
453
+ await emitPublicAndTrack();
365
454
  // Refresh watch roots in case the graph gained new packages.
366
455
  const refreshed = collectDevWatchRoots({ project, outDir });
367
456
  for (const r of refreshed.roots) {
@@ -381,14 +470,11 @@ export function createDevSession(options) {
381
470
  log.warn('wechat pack failed — keeping previous dist/wechat');
382
471
  continue;
383
472
  }
384
- const kind = await reloadAfterBuild({
385
- affectedChunks: report.affectedChunks ?? [],
386
- seedChunks: report.seedChunks ?? [],
387
- emitted: report.emitted ?? [],
388
- full: true,
389
- islandHmr: false,
390
- });
391
- log.info(kind === 'respawn' ? 'reload ok (respawned; deps)' : 'soft reload ok (full page; deps)');
473
+ const kind = await maybeReloadAfterBuild(report, hmrPlan, { force: true });
474
+ commitFingerprints();
475
+ if (kind !== 'skip') {
476
+ log.info(kind === 'respawn' ? 'reload ok (respawned; deps)' : 'soft reload ok (full page; deps)');
477
+ }
392
478
  continue;
393
479
  }
394
480
  let needFullReload = batch.docsDirty || batch.designsDirty;
@@ -398,11 +484,12 @@ export function createDevSession(options) {
398
484
  ...batch.srcChanged.map((p) => ({ path: p, kind: /** @type {'update'} */ ('update') })),
399
485
  ...batch.srcDeleted.map((p) => ({ path: p, kind: /** @type {'delete'} */ ('delete') })),
400
486
  ];
401
- const report = rebuild(changes);
487
+ const { report, hmrPlan } = planAndRebuild(changes);
402
488
  if (!printReport(report, 'rebuild')) {
403
489
  log.warn('rebuild failed — keeping previous server');
404
490
  continue;
405
491
  }
492
+ await emitPublicAndTrack();
406
493
  if (batch.docsDirty || projectHasDocuments(project)) {
407
494
  const docs = await buildIntegratedDocuments({ projectRoot: project, outDir });
408
495
  if (!docs.ok) {
@@ -418,20 +505,19 @@ export function createDevSession(options) {
418
505
  }
419
506
  continue;
420
507
  }
421
- const kind = await reloadAfterBuild({
422
- affectedChunks: report.affectedChunks ?? [],
423
- seedChunks: report.seedChunks ?? [],
424
- emitted: report.emitted ?? [],
425
- full: Boolean(report.full) || needFullReload,
426
- islandHmr: Boolean(report.islandHmr) && !needFullReload,
508
+ const kind = await maybeReloadAfterBuild(report, hmrPlan, {
509
+ force: needFullReload,
427
510
  });
428
- log.info(kind === 'respawn'
429
- ? 'reload ok (respawned serve-host)'
430
- : needFullReload
431
- ? 'soft reload ok (full page; docs)'
432
- : report.islandHmr
433
- ? 'soft reload ok (island HMR)'
434
- : 'soft reload ok');
511
+ commitFingerprints();
512
+ if (kind !== 'skip') {
513
+ log.info(kind === 'respawn'
514
+ ? 'reload ok (respawned serve-host)'
515
+ : needFullReload
516
+ ? 'soft reload ok (full page; docs)'
517
+ : report.islandHmr
518
+ ? 'soft reload ok (island HMR)'
519
+ : 'soft reload ok');
520
+ }
435
521
  continue;
436
522
  }
437
523
  if (batch.designsChanged.length || batch.designsDeleted.length || batch.designsDirty) {
@@ -440,11 +526,12 @@ export function createDevSession(options) {
440
526
  ...batch.designsChanged.map((p) => ({ path: p, kind: /** @type {'update'} */ ('update') })),
441
527
  ...batch.designsDeleted.map((p) => ({ path: p, kind: /** @type {'delete'} */ ('delete') })),
442
528
  ];
443
- const report = rebuild(changes.length ? changes : undefined);
529
+ const { report, hmrPlan } = planAndRebuild(changes.length ? changes : undefined);
444
530
  if (!printReport(report, 'rebuild')) {
445
531
  log.warn('designs rebuild failed — keeping previous styles');
446
532
  continue;
447
533
  }
534
+ await emitPublicAndTrack();
448
535
  if (batch.docsDirty || projectHasDocuments(project)) {
449
536
  const docs = await buildIntegratedDocuments({ projectRoot: project, outDir });
450
537
  if (!docs.ok)
@@ -455,14 +542,11 @@ export function createDevSession(options) {
455
542
  log.warn('wechat pack failed — keeping previous dist/wechat');
456
543
  continue;
457
544
  }
458
- const kind = await reloadAfterBuild({
459
- affectedChunks: report.affectedChunks ?? [],
460
- seedChunks: report.seedChunks ?? [],
461
- emitted: report.emitted ?? [],
462
- full: true,
463
- islandHmr: false,
464
- });
465
- log.info(kind === 'respawn' ? 'reload ok (respawned; designs)' : 'soft reload ok (full page; designs)');
545
+ const kind = await maybeReloadAfterBuild(report, hmrPlan, { force: true });
546
+ commitFingerprints();
547
+ if (kind !== 'skip') {
548
+ log.info(kind === 'respawn' ? 'reload ok (respawned; designs)' : 'soft reload ok (full page; designs)');
549
+ }
466
550
  continue;
467
551
  }
468
552
  if (batch.localesDirty) {
@@ -479,8 +563,11 @@ export function createDevSession(options) {
479
563
  continue;
480
564
  }
481
565
  else {
482
- const kind = await reloadAfterBuild({ full: true, islandHmr: false, emitted: [] });
483
- log.info(kind === 'respawn' ? 'reload ok (respawned; locales)' : 'soft reload ok (full page; locales)');
566
+ const kind = await maybeReloadAfterBuild({ reloadRequired: true, full: true }, null, { force: true });
567
+ commitFingerprints();
568
+ if (kind !== 'skip') {
569
+ log.info(kind === 'respawn' ? 'reload ok (respawned; locales)' : 'soft reload ok (full page; locales)');
570
+ }
484
571
  if (!batch.docsDirty)
485
572
  continue;
486
573
  }
@@ -498,8 +585,11 @@ export function createDevSession(options) {
498
585
  }
499
586
  }
500
587
  else {
501
- const kind = await reloadAfterBuild({ full: true, islandHmr: false, emitted: [] });
502
- log.info(kind === 'respawn' ? 'reload ok (respawned; docs)' : 'soft reload ok (full page; docs)');
588
+ const kind = await maybeReloadAfterBuild({ reloadRequired: true, full: true }, null, { force: true });
589
+ commitFingerprints();
590
+ if (kind !== 'skip') {
591
+ log.info(kind === 'respawn' ? 'reload ok (respawned; docs)' : 'soft reload ok (full page; docs)');
592
+ }
503
593
  }
504
594
  }
505
595
  }
@@ -1,7 +1,16 @@
1
1
  /**
2
2
  * Dev watch helpers: coalesce multi-file bursts without dropping dirty set,
3
3
  * and derive extra watch roots from the compile graph (deployment unit sources).
4
+ *
5
+ * DevInput layers (0.1.24): author · dependency · public · devControl.
6
+ * Never watch `dist/`, generated indexes, or generation write-set paths.
4
7
  */
8
+ /** Author-facing vs generated dev input bucket. */
9
+ export type DevInputKind = 'author' | 'dependency' | 'public' | 'devControl' | 'locales' | 'docs' | 'designs' | 'other';
10
+ /** `vmz.config.*` at project root (devControl). */
11
+ export declare function collectDevConfigPaths(project: string): string[];
12
+ /** Fingerprint map for explicit config files (not directory walks). */
13
+ export declare function configFingerprintMap(configPaths: string[]): Map<string, string>;
5
14
  export interface DirtySet {
6
15
  changed: string[];
7
16
  deleted: string[];
@@ -59,7 +68,11 @@ export declare function collectDevWatchRoots(opts: CollectDevWatchRootsOpts): {
59
68
  roots: string[];
60
69
  dependencyRoots: string[];
61
70
  applicationRoots: string[];
71
+ publicRoot: string | null;
72
+ configPaths: string[];
62
73
  };
74
+ /** Map watch bucket to DevInput layer. */
75
+ export declare function devInputKindForWatchBucket(bucket: ReturnType<typeof classifyWatchRoot>): DevInputKind;
63
76
  /**
64
77
  * Classify which watch bucket a root belongs to.
65
78
  * @param {string} root
@@ -77,9 +90,10 @@ export interface ClassifyWatchRootCtx {
77
90
  docsRoot: string;
78
91
  localesRoot: string;
79
92
  designsRoot: string;
93
+ publicRoot?: string | null;
80
94
  dependencyRoots: string[];
81
95
  }
82
- export declare function classifyWatchRoot(root: string, ctx: ClassifyWatchRootCtx): 'src' | 'locales' | 'docs' | 'designs' | 'dep' | 'other';
96
+ export declare function classifyWatchRoot(root: string, ctx: ClassifyWatchRootCtx): 'src' | 'locales' | 'docs' | 'designs' | 'dep' | 'public' | 'other';
83
97
  /**
84
98
  * Classify whether a changed file lives under a dependency watch root (not app src).
85
99
  * @param {string} file
@@ -1,10 +1,33 @@
1
1
  /**
2
2
  * Dev watch helpers: coalesce multi-file bursts without dropping dirty set,
3
3
  * and derive extra watch roots from the compile graph (deployment unit sources).
4
+ *
5
+ * DevInput layers (0.1.24): author · dependency · public · devControl.
6
+ * Never watch `dist/`, generated indexes, or generation write-set paths.
4
7
  */
5
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
8
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
6
9
  import path from 'node:path';
7
10
  import { diffFingerprints, fileFingerprintMap } from './watch-diff.js';
11
+ const CONFIG_BASENAMES = ['vmz.config.ts', 'vmz.config.js', 'vmz.config.mjs', 'vmz.config.cjs'];
12
+ /** `vmz.config.*` at project root (devControl). */
13
+ export function collectDevConfigPaths(project) {
14
+ const root = path.resolve(project);
15
+ return CONFIG_BASENAMES.map((name) => path.join(root, name)).filter((p) => existsSync(p));
16
+ }
17
+ /** Fingerprint map for explicit config files (not directory walks). */
18
+ export function configFingerprintMap(configPaths) {
19
+ const map = new Map();
20
+ for (const p of configPaths) {
21
+ try {
22
+ const st = statSync(p);
23
+ map.set(p, `${st.mtimeMs}:${st.size}`);
24
+ }
25
+ catch {
26
+ /* ignore */
27
+ }
28
+ }
29
+ return map;
30
+ }
8
31
  export function mergeDirtySets(a, b) {
9
32
  const changed = new Set(a?.changed || []);
10
33
  const deleted = new Set(a?.deleted || []);
@@ -135,10 +158,14 @@ export function localLinkDependencyRoots(project) {
135
158
  export function collectDevWatchRoots(opts) {
136
159
  const project = path.resolve(opts.project);
137
160
  const outDir = path.resolve(opts.outDir);
161
+ void outDir;
138
162
  const src = path.join(project, 'src');
139
163
  const docsRoot = path.join(project, 'documents');
140
164
  const localesRoot = path.join(project, 'locales');
141
165
  const designsRoot = path.join(project, 'designs');
166
+ const publicRootPath = path.join(project, 'public');
167
+ const publicRoot = existsSync(publicRootPath) && statSync(publicRootPath).isDirectory() ? publicRootPath : null;
168
+ const configPaths = collectDevConfigPaths(project);
142
169
  const applicationRoots = [];
143
170
  if (existsSync(src))
144
171
  applicationRoots.push(src);
@@ -148,6 +175,8 @@ export function collectDevWatchRoots(opts) {
148
175
  applicationRoots.push(localesRoot);
149
176
  if (existsSync(designsRoot))
150
177
  applicationRoots.push(designsRoot);
178
+ if (publicRoot)
179
+ applicationRoots.push(publicRoot);
151
180
  const depSet = new Set();
152
181
  const depJson = path.join(outDir, 'vmz-deployment.json');
153
182
  if (existsSync(depJson)) {
@@ -188,11 +217,29 @@ export function collectDevWatchRoots(opts) {
188
217
  if (!roots.includes(r))
189
218
  roots.push(r);
190
219
  }
191
- return { roots, dependencyRoots, applicationRoots };
220
+ return { roots, dependencyRoots, applicationRoots, publicRoot, configPaths };
221
+ }
222
+ /** Map watch bucket to DevInput layer. */
223
+ export function devInputKindForWatchBucket(bucket) {
224
+ if (bucket === 'src')
225
+ return 'author';
226
+ if (bucket === 'dep')
227
+ return 'dependency';
228
+ if (bucket === 'public')
229
+ return 'public';
230
+ if (bucket === 'locales')
231
+ return 'locales';
232
+ if (bucket === 'docs')
233
+ return 'docs';
234
+ if (bucket === 'designs')
235
+ return 'designs';
236
+ return 'other';
192
237
  }
193
238
  export function classifyWatchRoot(root, ctx) {
194
239
  if (root === ctx.src)
195
240
  return 'src';
241
+ if (ctx.publicRoot && root === ctx.publicRoot)
242
+ return 'public';
196
243
  if (root === ctx.localesRoot)
197
244
  return 'locales';
198
245
  if (root === ctx.docsRoot)
@@ -132,7 +132,7 @@ export declare function parseLocaleFromPath(pathname: string, supportedLocales:
132
132
  };
133
133
  export type { LocaleHrefArtifact } from '@vmz/core/localize-body-links';
134
134
  /** Rewrite a same-app href — single implementation in `@vmz/core/localize-body-links`. */
135
- export { localizeBodyLinks, localizeSameAppHref } from '@vmz/core/localize-body-links';
135
+ export { localizeBodyLinks, localizeSameAppHref, buildLocaleLinkPlan, applyLocaleLinkPlan } from '@vmz/core/localize-body-links';
136
136
  /** Plan redirect / negotiation for an incoming URL (omit-prefix aware). */
137
137
  export declare function planLocalePathNavigation(input: {
138
138
  pathname: string;
@@ -229,7 +229,7 @@ export function parseLocaleFromPath(pathname, supportedLocales) {
229
229
  return { localeId: null, restPath: normalizePath(pathname) };
230
230
  }
231
231
  /** Rewrite a same-app href — single implementation in `@vmz/core/localize-body-links`. */
232
- export { localizeBodyLinks, localizeSameAppHref } from '@vmz/core/localize-body-links';
232
+ export { localizeBodyLinks, localizeSameAppHref, buildLocaleLinkPlan, applyLocaleLinkPlan } from '@vmz/core/localize-body-links';
233
233
  /** Plan redirect / negotiation for an incoming URL (omit-prefix aware). */
234
234
  export function planLocalePathNavigation(input) {
235
235
  const routing = {
@@ -3,6 +3,7 @@
3
3
  * Reuses the same Direct SSR path as serve-host (no second SSG runtime).
4
4
  */
5
5
  export declare const STATIC_DELIVERY_MANIFEST_SCHEMA = "vmz.static.delivery_manifest.v0";
6
+ export declare const STATIC_EMIT_PLAN_SCHEMA = "vmz.static.emit_plan.v0";
6
7
  export type EmitWebStaticOpts = {
7
8
  origin?: string;
8
9
  applicationId?: string;
@@ -11,13 +11,14 @@ import { resolveRouteLayoutChain } from '@vmz/core/route-layout-chain';
11
11
  import { listClientComponentsSync } from '@vmz/core/component-registry';
12
12
  import { emitCdnPolicy } from './cdn-policy.js';
13
13
  import { emitContentAddressedAssets, hashedAssetHref } from './content-addressed-assets.js';
14
- import { absoluteUrl, buildLocalePageMeta, localizeBodyLinks } from './locale-router.js';
14
+ import { absoluteUrl, buildLocalePageMeta, buildLocaleLinkPlan, applyLocaleLinkPlan } from './locale-router.js';
15
15
  import { requireNativeAddon } from './native-addon.js';
16
16
  import { writePrettyJsonFile } from './pretty-json.js';
17
17
  import { emitPublicStaticAssets } from './public-static-assets.js';
18
18
  import { listPublicPageUnits, parsePathPattern, unitBrowserPathPattern } from './route-path.js';
19
19
  import { emitSiteFavicon, readSiteFaviconHeadHtml } from './site-favicon.js';
20
20
  export const STATIC_DELIVERY_MANIFEST_SCHEMA = 'vmz.static.delivery_manifest.v0';
21
+ export const STATIC_EMIT_PLAN_SCHEMA = 'vmz.static.emit_plan.v0';
21
22
  export async function emitWebStatic(distDir, opts = {}) {
22
23
  const origin = String(opts.origin || process.env.VMZ_SITE_ORIGIN || 'https://example.test').replace(/\/$/, '');
23
24
  const applicationId = opts.applicationId || path.basename(path.dirname(distDir));
@@ -40,6 +41,8 @@ export async function emitWebStatic(distDir, opts = {}) {
40
41
  const cssLogical = readCssEntry(distDir);
41
42
  const cssEntry = hashedAssetHref(assets.rewrites, cssLogical) || hashedAssetHref(assets.rewrites, 'vmz.css');
42
43
  const moduleScriptSrc = hashedAssetHref(assets.rewrites, 'entry-client.js') || '/entry-client.js';
44
+ const localeArt = loadLocaleArtifact(distDir);
45
+ const localeLinkPlan = buildLocaleLinkPlan(localeArt);
43
46
  for (const page of pageCatalog) {
44
47
  const pattern = page.pathPattern || patternFromSegs(page.segs);
45
48
  const routeId = guessRouteId(distDir, page.chunkId);
@@ -104,8 +107,6 @@ export async function emitWebStatic(distDir, opts = {}) {
104
107
  continue;
105
108
  bodyHtml = await renderToString(Layout, {}, { slotHtml: bodyHtml });
106
109
  }
107
- const localeArt = loadLocaleArtifact(distDir);
108
- // Locale artifact routeId is chunkId (`pages/about`), not the page class name.
109
110
  const localeWrites = expandLocaleStaticGenerations({
110
111
  localeArt,
111
112
  routeId: page.chunkId,
@@ -118,7 +119,7 @@ export async function emitWebStatic(distDir, opts = {}) {
118
119
  const absHtml = path.join(distDir, gen.htmlPath);
119
120
  fs.mkdirSync(path.dirname(absHtml), { recursive: true });
120
121
  // Each LocaleId HTML must retain locale on same-app Links (realization authority).
121
- const localizedBody = gen.localeId && localeArt ? localizeBodyLinks(bodyHtml, gen.localeId, localeArt) : bodyHtml;
122
+ const localizedBody = gen.localeId && localeLinkPlan.rows.length ? applyLocaleLinkPlan(bodyHtml, gen.localeId, localeLinkPlan) : bodyHtml;
122
123
  const html = wrapDocument({
123
124
  bodyHtml: localizedBody,
124
125
  chunkId: page.chunkId,
@@ -227,6 +228,22 @@ export async function emitWebStatic(distDir, opts = {}) {
227
228
  const digest = sha256Hex(canonicalJson(manifest));
228
229
  manifest.manifestDigest = digest;
229
230
  writePrettyJsonFile(path.join(vmzDir, 'static-delivery-manifest.json'), manifest);
231
+ writePrettyJsonFile(path.join(vmzDir, 'static-emit-plan.json'), {
232
+ schema: STATIC_EMIT_PLAN_SCHEMA,
233
+ applicationId,
234
+ deliveryProfile: 'static',
235
+ origin,
236
+ localeLinks: localeLinkPlan,
237
+ assetPlanPath: '_vmz/asset-plan.json',
238
+ routes: generations.map((g) => ({
239
+ routeId: g.routeId,
240
+ path: g.path,
241
+ chunkId: g.chunkId,
242
+ htmlPath: g.htmlPath,
243
+ classification: g.classification,
244
+ localeId: g.localeId || null,
245
+ })),
246
+ });
230
247
  const cdn = emitCdnPolicy(distDir, manifest);
231
248
  return {
232
249
  manifest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/vmz",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "description": "VMZ Node toolchain — N-API workspace session + CLI (publish name @vmz/vmz)",
6
6
  "license": "MIT",
@@ -48,16 +48,16 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@vmz/commander": "0.1.23",
52
- "@vmz/core": "0.1.23",
53
- "@vmz/diagnostic": "0.1.23",
54
- "@vmz/plugin": "0.1.23",
55
- "@vmz/protocol": "0.1.23",
51
+ "@vmz/commander": "0.1.25",
52
+ "@vmz/core": "0.1.25",
53
+ "@vmz/diagnostic": "0.1.25",
54
+ "@vmz/plugin": "0.1.25",
55
+ "@vmz/protocol": "0.1.25",
56
56
  "jiti": "^2.6.1"
57
57
  },
58
58
  "peerDependencies": {
59
- "@vmz/plugin-markdown-it": "0.1.23",
60
- "@vmz/test": "0.1.23",
59
+ "@vmz/plugin-markdown-it": "0.1.25",
60
+ "@vmz/test": "0.1.25",
61
61
  "typescript": "^5.8.3"
62
62
  },
63
63
  "peerDependenciesMeta": {
@@ -92,12 +92,12 @@
92
92
  "cli"
93
93
  ],
94
94
  "optionalDependencies": {
95
- "@vmz/vmz-win32-x64": "0.1.23",
96
- "@vmz/vmz-win32-arm64": "0.1.23",
97
- "@vmz/vmz-darwin-x64": "0.1.23",
98
- "@vmz/vmz-darwin-arm64": "0.1.23",
99
- "@vmz/vmz-linux-x64": "0.1.23",
100
- "@vmz/vmz-linux-arm64": "0.1.23"
95
+ "@vmz/vmz-win32-x64": "0.1.25",
96
+ "@vmz/vmz-win32-arm64": "0.1.25",
97
+ "@vmz/vmz-darwin-x64": "0.1.25",
98
+ "@vmz/vmz-darwin-arm64": "0.1.25",
99
+ "@vmz/vmz-linux-x64": "0.1.25",
100
+ "@vmz/vmz-linux-arm64": "0.1.25"
101
101
  },
102
102
  "publishConfig": {
103
103
  "access": "public"