@vmz/core 0.3.12 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/browser/client-nav.types.d.ts +2 -5
  2. package/dist/browser/direct-api.types.d.ts +6 -0
  3. package/dist/browser/dom/attributes.js +142 -2
  4. package/dist/browser/dom/component.d.ts +10 -0
  5. package/dist/browser/dom/component.js +16 -4
  6. package/dist/browser/dom/direct-api.d.ts +43 -3
  7. package/dist/browser/dom/direct-api.js +421 -84
  8. package/dist/browser/dom-core.d.ts +1 -0
  9. package/dist/browser/dom-core.js +1 -0
  10. package/dist/browser/dom-core.types.d.ts +10 -2
  11. package/dist/browser/navigation/client-navigation.js +18 -30
  12. package/dist/browser/reactivity/write-barrier.js +38 -11
  13. package/dist/browser/resume/adopt.js +142 -10
  14. package/dist/browser/resume/hydrate.js +36 -5
  15. package/dist/browser/resume/resume.js +2 -5
  16. package/dist/host/deployment/deployment-registry.js +47 -1
  17. package/dist/host/render/render-host.js +2 -0
  18. package/dist/host/serve/bootstrap.js +8 -4
  19. package/dist/host/serve/hmr-dev.d.ts +16 -17
  20. package/dist/host/serve/hmr-dev.js +114 -35
  21. package/dist/host/serve/http.js +25 -8
  22. package/dist/host/serve/pack-entry.d.ts +31 -0
  23. package/dist/host/serve/pack-entry.js +98 -0
  24. package/dist/host/serve/page-render.js +12 -4
  25. package/dist/shared/dom-attr-normalize.d.ts +14 -0
  26. package/dist/shared/dom-attr-normalize.js +158 -19
  27. package/dist/shared/host.types.d.ts +9 -0
  28. package/dist/shared/html-digest.d.ts +15 -0
  29. package/dist/shared/html-digest.js +25 -0
  30. package/dist/ssr/onmount.d.ts +37 -0
  31. package/dist/ssr/onmount.js +94 -0
  32. package/dist/ssr/render.js +7 -8
  33. package/dist/ssr/serialize.d.ts +1 -1
  34. package/dist/ssr/serialize.js +309 -13
  35. package/package.json +7 -7
@@ -7,10 +7,12 @@ import { listClientComponents } from '../list-client-components.js';
7
7
  import { LOCALE_LINK_PLAN_SCHEMA, linkRouteAliasesFromUnits } from '../localize-body-links.js';
8
8
  import { loadNativeAddon } from '../native-addon.js';
9
9
  import { createRenderHost } from '../render/render-host.js';
10
+ import { resolveRouteLayoutChain } from '../render/route-layout-chain.js';
10
11
  import { setRoutes } from '../../faces/vmz-runtime.js';
11
12
  import { LOCALE_LINK_PLAN_REL } from './constants.js';
12
13
  import { loadDeploymentStyle } from './locale-theme.js';
13
14
  import { bustUrl, isEventOnlyShell, isEventStrategy, listPageClientFiles, loadPageResumeEntries, pageNeedsReload } from './page-catalog.js';
15
+ import { packIslandHrefs } from './pack-entry.js';
14
16
  import { serveState } from './state.js';
15
17
  export function installDevImportTokenHooks() {
16
18
  if (!serveState.isDev)
@@ -132,34 +134,52 @@ export async function* emitDevErrorHtml(err) {
132
134
  function escapeHtml(s) {
133
135
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
134
136
  }
135
- function emitEntryClient(eager, lazy, token) {
136
- const q = `?t=${token}`;
137
+ /**
138
+ * 客户端入口模块由 pack 消费,必须保持内容稳定:每次重建都把 `?t=` 注入
139
+ * 动态 import 会让打包产物每轮都变,也会让浏览器拿到两个不同 URL 的同一模块。
140
+ * 缓存破坏由打包文件名(内容哈希)与 SSE 侧的 reload 负责。
141
+ */
142
+ function emitEntryClient(eager, lazy) {
137
143
  const native = loadNativeAddon();
138
144
  if (typeof native.generateServeEntryClient !== 'function') {
139
145
  throw new Error('vmz native addon missing generateServeEntryClient — rebuild with `pnpm napi:build`');
140
146
  }
141
- return native.generateServeEntryClient(eager, lazy, q);
147
+ return native.generateServeEntryClient(eager, lazy, '');
142
148
  }
143
- function emitEntryEvent(token) {
144
- const q = `?t=${token}`;
149
+ function emitEntryEvent(eager, lazy) {
145
150
  const native = loadNativeAddon();
146
151
  if (typeof native.generateServeEntryEvent !== 'function') {
147
152
  throw new Error('vmz native addon missing generateServeEntryEvent — rebuild with `pnpm napi:build`');
148
153
  }
149
- return native.generateServeEntryEvent(q);
154
+ return native.generateServeEntryEvent(eager, lazy, '');
150
155
  }
151
- export async function softReload(opts = {}) {
156
+ let pendingReload = null;
157
+ let pendingRollback = null;
158
+ /**
159
+ * 第一阶段:加载构建产物、写客户端入口模块,但**不**发布 SSE、不置 ready。
160
+ *
161
+ * dev 的浏览器交付必须经 `vmz-pack`,而 pack 由 CLI 侧执行(runtime 不反向
162
+ * 依赖 `@vmz/vmz`)。因此先把入口写到 outDir,让调用方 pack,再 `commitReload`。
163
+ */
164
+ export async function prepareReload(opts = {}) {
152
165
  const prevToken = serveState.reloadToken;
153
166
  const prevCatalog = serveState.pageCatalog;
154
167
  const prevCtors = new Map(serveState.pageCtors);
155
168
  const nextToken = Date.now();
156
169
  serveState.reloadToken = nextToken;
170
+ pendingReload = null;
171
+ pendingRollback = () => {
172
+ serveState.reloadToken = prevToken;
173
+ serveState.pageCatalog = prevCatalog;
174
+ serveState.pageCtors.clear();
175
+ for (const [k, v] of prevCtors)
176
+ serveState.pageCtors.set(k, v);
177
+ };
157
178
  const affected = opts.payload?.affectedChunks ?? [];
158
179
  const seeds = opts.payload?.seedChunks ?? [];
159
180
  const full = opts.payload?.full;
160
181
  const islandHmr = Boolean(opts.payload?.islandHmr);
161
182
  const rerunLoaders = opts.payload?.rerunLoaders ?? [];
162
- const skipEntryRewrite = Boolean(opts.payload?.skipEntryRewrite);
163
183
  const buildId = opts.payload?.buildId != null ? String(opts.payload.buildId) : null;
164
184
  const sourceRevision = opts.payload?.sourceRevision != null ? String(opts.payload.sourceRevision) : null;
165
185
  const bundleRevision = opts.payload?.bundleRevision != null ? String(opts.payload.bundleRevision) : null;
@@ -237,13 +257,45 @@ export async function softReload(opts = {}) {
237
257
  .map((e) => e.component)
238
258
  .filter(Boolean);
239
259
  const lazySet = new Set(lazyEventNames);
240
- await writeFile(path.join(serveState.distDir, 'entry-client.js'), emitEntryClient(componentEntries.filter((e) => !lazySet.has(e.name)), componentEntries.filter((e) => lazySet.has(e.name)), skipEntryRewrite ? serveState.reloadToken : nextToken), 'utf8');
260
+ const eagerComponents = componentEntries.filter((e) => !lazySet.has(e.name));
261
+ const lazyComponents = componentEntries.filter((e) => lazySet.has(e.name));
262
+ // Bootstrap tables are literal maps keyed by chunkId / island name:
263
+ // page/layout units must be enumerated (name = chunkId, entry =
264
+ // `<chunkId>.client.js`), unknown names throw at runtime by design.
265
+ const pageEntries = serveState.pageCatalog.map((p) => ({ name: p.chunkId, entry: `${p.chunkId}.client.js` }));
266
+ const layoutNames = new Set();
267
+ for (const p of serveState.pageCatalog) {
268
+ try {
269
+ for (const id of resolveRouteLayoutChain(serveState.distDir, p.chunkId))
270
+ layoutNames.add(String(id));
271
+ }
272
+ catch {
273
+ /* page unit without layoutChain in a partial dev build */
274
+ }
275
+ }
276
+ const layoutEntries = [...layoutNames].sort().map((id) => ({ name: id, entry: `${id}.client.js` }));
277
+ await writeFile(path.join(serveState.distDir, 'entry-client.js'), emitEntryClient([...eagerComponents, ...pageEntries, ...layoutEntries], lazyComponents), 'utf8');
241
278
  const strategies = resumeEntries.map((e) => e.strategy);
242
279
  const eventOnlyShell = isEventOnlyShell(strategies);
243
- await writeFile(path.join(serveState.distDir, 'entry-event.js'), emitEntryEvent(skipEntryRewrite ? serveState.reloadToken : nextToken), 'utf8');
244
- serveState.lastDevError = null;
280
+ await writeFile(path.join(serveState.distDir, 'entry-event.js'), emitEntryEvent([], lazyComponents), 'utf8');
245
281
  const mode = islandHmr ? 'island' : eventOnlyShell ? 'event-shell' : 'full';
246
- notifySse(JSON.stringify({
282
+ const info = {
283
+ affectedChunks: affected,
284
+ seedChunks: seeds,
285
+ full: Boolean(full),
286
+ islandHmr,
287
+ mode,
288
+ eventOnlyShell,
289
+ pageCount: serveState.pageCatalog.length,
290
+ reloadedPages: islandHmr ? 0 : nextCtors.size,
291
+ reloadAllPages,
292
+ buildId: buildId || serveState.lastDevBuildId,
293
+ sourceRevision,
294
+ bundleRevision,
295
+ serveRevision: String(serveState.reloadToken),
296
+ token: serveState.reloadToken,
297
+ };
298
+ const sse = {
247
299
  type: 'hmr',
248
300
  mode,
249
301
  affectedChunks: affected,
@@ -255,7 +307,8 @@ export async function softReload(opts = {}) {
255
307
  serveRevision: String(serveState.reloadToken),
256
308
  full: Boolean(full),
257
309
  eventOnlyShell,
258
- }));
310
+ };
311
+ let logLine = null;
259
312
  if (!opts.quiet) {
260
313
  const aff = affected.length > 0 ? ` affected=[${affected.join(', ')}]` : full === false ? ' affected=[]' : '';
261
314
  const scope = islandHmr ? 'island' : reloadAllPages ? 'all-pages' : `pages=${nextCtors.size}`;
@@ -263,32 +316,58 @@ export async function softReload(opts = {}) {
263
316
  const rev = bid || sourceRevision || bundleRevision
264
317
  ? ` buildId=${bid || '-'} source=${sourceRevision || '-'} bundle=${bundleRevision || '-'} serve=${serveState.reloadToken}`
265
318
  : ` t=${serveState.reloadToken}`;
266
- console.log(`vmz serve: soft reload ok (mode=${mode}; ${scope}; catalog=${serveState.pageCatalog.length};${rev}${aff})`);
319
+ logLine = `vmz serve: soft reload ok (mode=${mode}; ${scope}; catalog=${serveState.pageCatalog.length};${rev}${aff})`;
267
320
  }
268
- return {
269
- affectedChunks: affected,
270
- seedChunks: seeds,
271
- full: Boolean(full),
272
- islandHmr,
273
- mode,
274
- eventOnlyShell,
275
- pageCount: serveState.pageCatalog.length,
276
- reloadedPages: islandHmr ? 0 : nextCtors.size,
277
- reloadAllPages,
278
- buildId: buildId || serveState.lastDevBuildId,
279
- sourceRevision,
280
- bundleRevision,
281
- serveRevision: String(serveState.reloadToken),
282
- token: serveState.reloadToken,
283
- };
321
+ pendingReload = { info, sse, logLine };
322
+ return info;
284
323
  }
285
324
  catch (err) {
286
- serveState.reloadToken = prevToken;
287
- serveState.pageCatalog = prevCatalog;
288
- serveState.pageCtors.clear();
289
- for (const [k, v] of prevCtors)
290
- serveState.pageCtors.set(k, v);
325
+ pendingRollback?.();
326
+ pendingRollback = null;
327
+ pendingReload = null;
291
328
  serveState.lastDevError = normalizeDevError(err);
292
329
  throw err;
293
330
  }
294
331
  }
332
+ /**
333
+ * 第二阶段:发布 SSE 并置 ready。island 热更新的地址表从 pack manifest 精确
334
+ * 查得(逻辑 `chunkId` → chunk 文件、含 `dom.browser` 的共享 chunk),不做后缀
335
+ * 猜测;查不到即整页 reload。
336
+ */
337
+ export function commitReload() {
338
+ const prepared = pendingReload;
339
+ if (!prepared)
340
+ throw new Error('commitReload: no prepared reload');
341
+ let island = null;
342
+ if (serveState.isDev) {
343
+ try {
344
+ island = packIslandHrefs(serveState.distDir);
345
+ }
346
+ catch (err) {
347
+ abortReload(err);
348
+ throw err;
349
+ }
350
+ }
351
+ pendingReload = null;
352
+ pendingRollback = null;
353
+ serveState.lastDevError = null;
354
+ serveState.ready = true;
355
+ notifySse(JSON.stringify({ ...prepared.sse, island }));
356
+ if (prepared.logLine)
357
+ console.log(prepared.logLine);
358
+ return { ...prepared.info, island };
359
+ }
360
+ /** pack 失败:回滚到上一轮已发布状态,并以 SSE error 显式告知浏览器。 */
361
+ export function abortReload(err) {
362
+ pendingReload = null;
363
+ pendingRollback?.();
364
+ pendingRollback = null;
365
+ serveState.ready = false;
366
+ serveState.lastDevError = normalizeDevError(err);
367
+ notifySse(JSON.stringify({
368
+ type: 'error',
369
+ message: serveState.lastDevError.message,
370
+ stack: serveState.lastDevError.stack,
371
+ at: serveState.lastDevError.at,
372
+ }));
373
+ }
@@ -1,7 +1,7 @@
1
1
  import http from 'node:http';
2
2
  import { handleNodeRequest } from '../../faces/vmz-runtime.js';
3
3
  import { SHUTDOWN_TIMEOUT_MS } from './constants.js';
4
- import { normalizeDevError, notifySse, softReload } from './hmr-dev.js';
4
+ import { abortReload, commitReload, normalizeDevError, notifySse, prepareReload } from './hmr-dev.js';
5
5
  import { renderPage, renderPageStream } from './page-render.js';
6
6
  import { serveState } from './state.js';
7
7
  function readRequestBody(req) {
@@ -70,21 +70,38 @@ export function startServeHttp() {
70
70
  if (url.pathname === '/__vmz/reload' && req.method === 'POST') {
71
71
  readRequestBody(req)
72
72
  .then((raw) => {
73
- let payload = {};
73
+ let body = {};
74
74
  try {
75
- payload = raw ? JSON.parse(String(raw)) : {};
75
+ body = raw ? JSON.parse(String(raw)) : {};
76
76
  }
77
77
  catch {
78
- payload = {};
78
+ body = {};
79
79
  }
80
- return softReload({ payload });
80
+ // dev 两阶段:prepare 写入口 → CLI 侧 `vmz-pack` → commit 发布。
81
+ // 未带 phase 的请求不属于 v0 合同,直接拒绝而不是猜测阶段。
82
+ const phase = body.phase;
83
+ if (phase === 'prepare') {
84
+ return prepareReload({ payload: body.payload }).then((info) => ({
85
+ phase,
86
+ info,
87
+ }));
88
+ }
89
+ if (phase === 'commit') {
90
+ return { phase, info: commitReload() };
91
+ }
92
+ if (phase === 'abort') {
93
+ const rawError = body.error;
94
+ abortReload(new Error(rawError?.message || 'pack failed'));
95
+ return { phase, info: { ok: true } };
96
+ }
97
+ throw Object.assign(new Error('reload: unknown phase'), { statusCode: 400 });
81
98
  })
82
- .then((info) => {
99
+ .then(({ phase, info }) => {
83
100
  res.writeHead(200, { 'content-type': 'application/json' });
84
- res.end(JSON.stringify({ ok: true, token: serveState.reloadToken, ...info }));
101
+ res.end(JSON.stringify({ ok: true, phase, ...info }));
85
102
  })
86
103
  .catch((err) => {
87
- console.error('vmz serve: soft reload failed', err);
104
+ console.error('vmz serve: reload failed', err);
88
105
  serveState.lastDevError = normalizeDevError(err);
89
106
  notifySse(JSON.stringify({
90
107
  type: 'error',
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Resolve the browser URL of a packed bootstrap entry from
3
+ * `_vmz/pack-manifest.json`.
4
+ *
5
+ * The manifest is the only logical → physical map for browser delivery. A
6
+ * missing manifest or a missing logical name is an explicit error: serving the
7
+ * unpacked root module would hand the browser bare package specifiers that only
8
+ * `vmz-pack` rewrites.
9
+ */
10
+ export declare function packEntryHref(distDir: string, logical: string): string;
11
+ /**
12
+ * 部署单元逻辑 `chunkId` → 浏览器 chunk URL。
13
+ *
14
+ * 只读 `unitChunks[]` 的精确 `logical` 匹配:逻辑 `chunkId` 与 pack chunk id
15
+ * 不保证相同,禁止前缀推导或后缀猜测。单元未进客户端图(`fileName: null`)
16
+ * 视为错误,消费方不得回退到未打包文件。
17
+ */
18
+ export declare function packChunkHref(distDir: string, logical: string): string;
19
+ /** 含指定产物模块的 chunk URL(精确路径匹配,如 `dom.browser.js`)。 */
20
+ export declare function packModuleChunkHref(distDir: string, moduleRel: string): string;
21
+ export interface PackIslandHrefs {
22
+ /** 含 `dom.browser` 的共享 chunk(`hydrate` 由该 chunk 导出)。 */
23
+ dom: string;
24
+ /** 部署单元逻辑 `chunkId` → 其客户端 chunk URL(未进图的不列出)。 */
25
+ pages: Record<string, string>;
26
+ }
27
+ /**
28
+ * island 热更新地址表:一次读 manifest,给出 runtime 共享 chunk 与各页面 chunk
29
+ * 的 URL。调用方只按 `data-vmz-page` 逻辑名查表,缺失即走整页 reload。
30
+ */
31
+ export declare function packIslandHrefs(distDir: string): PackIslandHrefs;
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const PACK_MANIFEST_REL = ['_vmz', 'pack-manifest.json'];
4
+ const PACK_OUTPUT_DIR_REL = ['_vmz', 'pack'];
5
+ const DEFAULT_PUBLIC_BASE = '/';
6
+ function readPackManifest(distDir, caller) {
7
+ const manifestPath = path.join(distDir, ...PACK_MANIFEST_REL);
8
+ if (!fs.existsSync(manifestPath)) {
9
+ throw new Error(`${caller}: missing pack manifest at ${manifestPath} — run vmz build (pack) first`);
10
+ }
11
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
12
+ }
13
+ function chunkHref(manifest, fileName) {
14
+ const base = String(manifest.publicBase || DEFAULT_PUBLIC_BASE).replace(/\/$/, '');
15
+ return `${base}/${PACK_OUTPUT_DIR_REL.join('/')}/${fileName}`;
16
+ }
17
+ /**
18
+ * 模块路径比较键:去掉 Windows verbatim 前缀(Rust `canonicalize` 给
19
+ * `//?/E:/…`,Node 给 `E:/…`)并统一分隔符,避免同一文件因前缀差异查不到。
20
+ */
21
+ function moduleKey(filePath) {
22
+ return filePath.replace(/^(?:\\\\\?\\|\/\/\?\/)/, '').replace(/\\/g, '/');
23
+ }
24
+ /**
25
+ * Resolve the browser URL of a packed bootstrap entry from
26
+ * `_vmz/pack-manifest.json`.
27
+ *
28
+ * The manifest is the only logical → physical map for browser delivery. A
29
+ * missing manifest or a missing logical name is an explicit error: serving the
30
+ * unpacked root module would hand the browser bare package specifiers that only
31
+ * `vmz-pack` rewrites.
32
+ */
33
+ export function packEntryHref(distDir, logical) {
34
+ const manifest = readPackManifest(distDir, 'packEntryHref');
35
+ const hit = (manifest.entryFiles || []).find((entry) => entry && entry.logical === logical);
36
+ if (!hit || typeof hit.file !== 'string' || !hit.file) {
37
+ throw new Error(`packEntryHref: pack manifest has no entry file for "${logical}"`);
38
+ }
39
+ return chunkHref(manifest, hit.file);
40
+ }
41
+ /**
42
+ * 部署单元逻辑 `chunkId` → 浏览器 chunk URL。
43
+ *
44
+ * 只读 `unitChunks[]` 的精确 `logical` 匹配:逻辑 `chunkId` 与 pack chunk id
45
+ * 不保证相同,禁止前缀推导或后缀猜测。单元未进客户端图(`fileName: null`)
46
+ * 视为错误,消费方不得回退到未打包文件。
47
+ */
48
+ export function packChunkHref(distDir, logical) {
49
+ const manifest = readPackManifest(distDir, 'packChunkHref');
50
+ const hit = (manifest.unitChunks || []).find((entry) => entry && entry.logical === logical);
51
+ if (!hit) {
52
+ throw new Error(`packChunkHref: pack manifest has no unit chunk for "${logical}"`);
53
+ }
54
+ if (typeof hit.fileName !== 'string' || !hit.fileName) {
55
+ throw new Error(`packChunkHref: unit "${logical}" is not in the browser client graph`);
56
+ }
57
+ return chunkHref(manifest, hit.fileName);
58
+ }
59
+ /** 含指定产物模块的 chunk URL(精确路径匹配,如 `dom.browser.js`)。 */
60
+ export function packModuleChunkHref(distDir, moduleRel) {
61
+ const manifest = readPackManifest(distDir, 'packModuleChunkHref');
62
+ const target = moduleKey(path.join(distDir, ...moduleRel.split('/')));
63
+ for (const chunk of manifest.chunks || []) {
64
+ if (typeof chunk.fileName !== 'string' || !chunk.fileName)
65
+ continue;
66
+ for (const module of chunk.modules || []) {
67
+ if (moduleKey(String(module)) === target)
68
+ return chunkHref(manifest, chunk.fileName);
69
+ }
70
+ }
71
+ throw new Error(`packModuleChunkHref: no chunk carries "${moduleRel}"`);
72
+ }
73
+ /**
74
+ * island 热更新地址表:一次读 manifest,给出 runtime 共享 chunk 与各页面 chunk
75
+ * 的 URL。调用方只按 `data-vmz-page` 逻辑名查表,缺失即走整页 reload。
76
+ */
77
+ export function packIslandHrefs(distDir) {
78
+ const manifest = readPackManifest(distDir, 'packIslandHrefs');
79
+ const pages = {};
80
+ for (const unit of manifest.unitChunks || []) {
81
+ if (!unit || typeof unit.logical !== 'string' || !unit.logical)
82
+ continue;
83
+ if (typeof unit.fileName !== 'string' || !unit.fileName)
84
+ continue;
85
+ pages[unit.logical] = chunkHref(manifest, unit.fileName);
86
+ }
87
+ const target = moduleKey(path.join(distDir, 'dom.browser.js'));
88
+ for (const chunk of manifest.chunks || []) {
89
+ if (typeof chunk.fileName !== 'string' || !chunk.fileName)
90
+ continue;
91
+ for (const module of chunk.modules || []) {
92
+ if (moduleKey(String(module)) === target) {
93
+ return { dom: chunkHref(manifest, chunk.fileName), pages };
94
+ }
95
+ }
96
+ }
97
+ throw new Error('packIslandHrefs: no chunk carries "dom.browser.js"');
98
+ }
@@ -6,6 +6,7 @@ import { extractRouteParams, findRootCatchAll, isRootCatchAll, matchFileRoute }
6
6
  import { emitDevErrorHtml, normalizeDevError } from './hmr-dev.js';
7
7
  import { cssEntryWithBust, htmlThemeAttrPair, localeBootstrapScript, pageMetaAlternates, resolveLocalePath, resolveThemeId, siteFaviconHeadHtml, themeBootstrapScript, } from './locale-theme.js';
8
8
  import { isEventOnlyShell, loadPageCtor, loadPageResumeEntries, runRouteGate } from './page-catalog.js';
9
+ import { packEntryHref } from './pack-entry.js';
9
10
  import { serveState } from './state.js';
10
11
  export async function renderPage(pathname, opts = {}) {
11
12
  const rendered = await renderPageStream(pathname, opts);
@@ -290,11 +291,15 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
290
291
  hideOverlay();
291
292
  if (msg.mode === "island") {
292
293
  try {
293
- const { hydrate } = await import("/dom.browser.js?t=" + msg.token);
294
+ // 地址表来自 pack manifest(逻辑名精确匹配),缺失即整页 reload:
295
+ // 不再单独加载裸 dom.browser.js,避免与 packed bootstrap 形成两份 runtime。
296
+ const island = msg.island || {};
294
297
  const root = document.getElementById("app");
295
298
  const pageChunk = root && root.getAttribute("data-vmz-page");
296
- if (root && pageChunk) {
297
- const pageMod = await import("/" + pageChunk + ".client.js?t=" + msg.token);
299
+ const pageHref = pageChunk && island.pages ? island.pages[pageChunk] : null;
300
+ if (root && pageChunk && island.dom && pageHref) {
301
+ const { hydrate } = await import(island.dom);
302
+ const pageMod = await import(pageHref);
298
303
  let hmrProps = {};
299
304
  try {
300
305
  const raw = root.getAttribute("data-vmz-props");
@@ -393,7 +398,10 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
393
398
  if (typeof native.generatePageShell !== 'function') {
394
399
  throw new Error('vmz native addon missing generatePageShell — rebuild with `pnpm napi:build`');
395
400
  }
396
- const entrySrc = `/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${serveState.reloadToken}`;
401
+ // 客户端入口一律经 pack manifest 解析:原始 `entry-client.js` 及其拉取的
402
+ // 客户端模块保留裸包说明符,浏览器无法解析。dev 与生产因此走同一张表。
403
+ const entryLogical = eventOnlyShell ? 'entry-event.js' : 'entry-client.js';
404
+ const entrySrc = packEntryHref(serveState.distDir, entryLogical);
397
405
  const cssHref = cssEntryWithBust(serveState.cssEntry);
398
406
  yield native.generatePageShell({
399
407
  bodyHtml,
@@ -1,8 +1,22 @@
1
1
  /**
2
2
  * Shared class/style normalization for Direct SSR + client (0.1.33).
3
3
  * Vue-compatible: string | string[] | object map | falsy parts.
4
+ *
5
+ * Style keys are normalized to CSS property spelling so SSR text and client
6
+ * patches agree: camelCase becomes kebab-case, custom properties (`--x`) pass
7
+ * through untouched, and names that cannot be CSS properties (whitespace, `;`,
8
+ * `:`, leading digit) are dropped — the same fate invalid declarations have in
9
+ * the browser. Numeric values gain `px` except for unitless properties, `0`,
10
+ * and custom properties. Array values emit repeated declarations (vendor
11
+ * fallback chains) and nested arrays flatten.
4
12
  */
5
13
  /** Normalize one or more class fragments into a deduped class string. */
6
14
  export declare function mergeClassParts(...parts: unknown[]): string;
15
+ /**
16
+ * Split a style fragment on top-level `;`. Semicolons inside `url(...)` /
17
+ * nested parens and inside quotes (e.g. `content: 'a;b'`, data URIs with
18
+ * `;base64,`) do not split — a naive split would truncate those values.
19
+ */
20
+ export declare function splitStyleDeclarations(text: string): string[];
7
21
  /** Merge static + dynamic style fragments; later parts override same keys. */
8
22
  export declare function mergeStyleParts(...parts: unknown[]): string;
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Shared class/style normalization for Direct SSR + client (0.1.33).
3
3
  * Vue-compatible: string | string[] | object map | falsy parts.
4
+ *
5
+ * Style keys are normalized to CSS property spelling so SSR text and client
6
+ * patches agree: camelCase becomes kebab-case, custom properties (`--x`) pass
7
+ * through untouched, and names that cannot be CSS properties (whitespace, `;`,
8
+ * `:`, leading digit) are dropped — the same fate invalid declarations have in
9
+ * the browser. Numeric values gain `px` except for unitless properties, `0`,
10
+ * and custom properties. Array values emit repeated declarations (vendor
11
+ * fallback chains) and nested arrays flatten.
4
12
  */
5
13
  function pushClassTokens(out, value) {
6
14
  if (value == null || value === false)
@@ -35,37 +43,168 @@ export function mergeClassParts(...parts) {
35
43
  pushClassTokens(tokens, part);
36
44
  return [...new Set(tokens)].join(' ');
37
45
  }
38
- function parseStyleObject(value) {
39
- const out = {};
46
+ /** CSS properties whose numeric values stay unitless (kebab-case names). */
47
+ const UNITLESS_STYLE_PROPS = new Set([
48
+ 'animation-iteration-count',
49
+ 'aspect-ratio',
50
+ 'border-image-outset',
51
+ 'border-image-slice',
52
+ 'border-image-width',
53
+ 'box-flex',
54
+ 'box-flex-group',
55
+ 'box-ordinal-group',
56
+ 'column-count',
57
+ 'columns',
58
+ 'flex',
59
+ 'flex-grow',
60
+ 'flex-positive',
61
+ 'flex-shrink',
62
+ 'flex-negative',
63
+ 'flex-order',
64
+ 'grid-row',
65
+ 'grid-row-end',
66
+ 'grid-row-span',
67
+ 'grid-row-start',
68
+ 'grid-column',
69
+ 'grid-column-end',
70
+ 'grid-column-span',
71
+ 'grid-column-start',
72
+ 'font-weight',
73
+ 'line-clamp',
74
+ 'line-height',
75
+ 'opacity',
76
+ 'order',
77
+ 'orphans',
78
+ 'scale',
79
+ 'tab-size',
80
+ 'widows',
81
+ 'z-index',
82
+ 'zoom',
83
+ 'fill-opacity',
84
+ 'flood-opacity',
85
+ 'stop-opacity',
86
+ 'stroke-dasharray',
87
+ 'stroke-dashoffset',
88
+ 'stroke-miterlimit',
89
+ 'stroke-opacity',
90
+ 'stroke-width',
91
+ ]);
92
+ /** camelCase → kebab-case, `--custom` preserved; `null` when not a CSS property name. */
93
+ function normalizeStyleKey(raw) {
94
+ const key = raw.trim();
95
+ if (!key)
96
+ return null;
97
+ if (key.startsWith('--')) {
98
+ return /^--[\w-]+$/.test(key) ? key : null;
99
+ }
100
+ const kebab = key.replace(/\B([A-Z])/g, '-$1').toLowerCase();
101
+ return /^[a-z][a-z0-9-]*$/.test(kebab) ? kebab : null;
102
+ }
103
+ /** One style value as declaration text; `null` drops the declaration. */
104
+ function normalizeStyleValue(key, value) {
105
+ if (value == null || value === false)
106
+ return null;
107
+ if (typeof value === 'number' && value !== 0 && !key.startsWith('--') && !isUnitlessStyleProp(key)) {
108
+ return `${value}px`;
109
+ }
110
+ return String(value);
111
+ }
112
+ /** Unitless lookup ignores vendor prefixes (`webkit-line-clamp` ≙ `line-clamp`). */
113
+ function isUnitlessStyleProp(key) {
114
+ const unprefixed = key.replace(/^(?:-?(?:webkit|moz|ms|o))-/, '');
115
+ return UNITLESS_STYLE_PROPS.has(unprefixed);
116
+ }
117
+ function pushStyleValues(out, key, value) {
118
+ if (Array.isArray(value)) {
119
+ for (const item of value)
120
+ pushStyleValues(out, key, item);
121
+ return;
122
+ }
123
+ const text = normalizeStyleValue(key, value);
124
+ if (text != null)
125
+ out.push(text);
126
+ }
127
+ /**
128
+ * Split a style fragment on top-level `;`. Semicolons inside `url(...)` /
129
+ * nested parens and inside quotes (e.g. `content: 'a;b'`, data URIs with
130
+ * `;base64,`) do not split — a naive split would truncate those values.
131
+ */
132
+ export function splitStyleDeclarations(text) {
133
+ const chunks = [];
134
+ let depth = 0;
135
+ let quote = null;
136
+ let start = 0;
137
+ let i = 0;
138
+ while (i < text.length) {
139
+ const ch = text[i];
140
+ if (quote != null) {
141
+ if (ch === '\\') {
142
+ i += 2;
143
+ continue;
144
+ }
145
+ if (ch === quote)
146
+ quote = null;
147
+ }
148
+ else if (ch === '"' || ch === "'") {
149
+ quote = ch;
150
+ }
151
+ else if (ch === '(') {
152
+ depth += 1;
153
+ }
154
+ else if (ch === ')') {
155
+ if (depth > 0)
156
+ depth -= 1;
157
+ }
158
+ else if (ch === ';' && depth === 0) {
159
+ chunks.push(text.slice(start, i));
160
+ start = i + 1;
161
+ }
162
+ i += 1;
163
+ }
164
+ chunks.push(text.slice(start));
165
+ return chunks;
166
+ }
167
+ function collectStyleDeclarations(merged, value) {
40
168
  if (value == null || value === false)
41
- return out;
169
+ return;
42
170
  if (typeof value === 'string') {
43
- for (const chunk of value.split(';')) {
171
+ for (const chunk of splitStyleDeclarations(value)) {
44
172
  const idx = chunk.indexOf(':');
45
173
  if (idx < 0)
46
174
  continue;
47
- const key = chunk.slice(0, idx).trim();
48
- const val = chunk.slice(idx + 1).trim();
49
- if (key)
50
- out[key] = val;
175
+ const key = normalizeStyleKey(chunk.slice(0, idx));
176
+ if (!key)
177
+ continue;
178
+ merged.set(key, [chunk.slice(idx + 1).trim()]);
51
179
  }
52
- return out;
180
+ return;
53
181
  }
54
- if (typeof value === 'object' && !Array.isArray(value)) {
55
- for (const [key, raw] of Object.entries(value)) {
56
- if (raw == null || raw === false)
182
+ if (Array.isArray(value)) {
183
+ for (const item of value)
184
+ collectStyleDeclarations(merged, item);
185
+ return;
186
+ }
187
+ if (typeof value === 'object') {
188
+ for (const [rawKey, raw] of Object.entries(value)) {
189
+ const key = normalizeStyleKey(rawKey);
190
+ if (!key)
57
191
  continue;
58
- out[key] = String(raw);
192
+ const values = [];
193
+ pushStyleValues(values, key, raw);
194
+ if (values.length)
195
+ merged.set(key, values);
59
196
  }
60
197
  }
61
- return out;
62
198
  }
63
199
  /** Merge static + dynamic style fragments; later parts override same keys. */
64
200
  export function mergeStyleParts(...parts) {
65
- const merged = {};
201
+ const merged = new Map();
66
202
  for (const part of parts)
67
- Object.assign(merged, parseStyleObject(part));
68
- return Object.entries(merged)
69
- .map(([key, val]) => `${key}: ${val}`)
70
- .join('; ');
203
+ collectStyleDeclarations(merged, part);
204
+ const out = [];
205
+ for (const [key, values] of merged) {
206
+ for (const value of values)
207
+ out.push(`${key}: ${value}`);
208
+ }
209
+ return out.join('; ');
71
210
  }