@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.
- package/dist/browser/client-nav.types.d.ts +2 -5
- package/dist/browser/direct-api.types.d.ts +6 -0
- package/dist/browser/dom/attributes.js +142 -2
- package/dist/browser/dom/component.d.ts +10 -0
- package/dist/browser/dom/component.js +16 -4
- package/dist/browser/dom/direct-api.d.ts +43 -3
- package/dist/browser/dom/direct-api.js +421 -84
- package/dist/browser/dom-core.d.ts +1 -0
- package/dist/browser/dom-core.js +1 -0
- package/dist/browser/dom-core.types.d.ts +10 -2
- package/dist/browser/navigation/client-navigation.js +18 -30
- package/dist/browser/reactivity/write-barrier.js +38 -11
- package/dist/browser/resume/adopt.js +142 -10
- package/dist/browser/resume/hydrate.js +36 -5
- package/dist/browser/resume/resume.js +2 -5
- package/dist/host/deployment/deployment-registry.js +47 -1
- package/dist/host/render/render-host.js +2 -0
- package/dist/host/serve/bootstrap.js +8 -4
- package/dist/host/serve/hmr-dev.d.ts +16 -17
- package/dist/host/serve/hmr-dev.js +114 -35
- package/dist/host/serve/http.js +25 -8
- package/dist/host/serve/pack-entry.d.ts +31 -0
- package/dist/host/serve/pack-entry.js +98 -0
- package/dist/host/serve/page-render.js +12 -4
- package/dist/shared/dom-attr-normalize.d.ts +14 -0
- package/dist/shared/dom-attr-normalize.js +158 -19
- package/dist/shared/host.types.d.ts +9 -0
- package/dist/shared/html-digest.d.ts +15 -0
- package/dist/shared/html-digest.js +25 -0
- package/dist/ssr/onmount.d.ts +37 -0
- package/dist/ssr/onmount.js +94 -0
- package/dist/ssr/render.js +7 -8
- package/dist/ssr/serialize.d.ts +1 -1
- package/dist/ssr/serialize.js +309 -13
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
134
136
|
}
|
|
135
|
-
|
|
136
|
-
|
|
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,
|
|
147
|
+
return native.generateServeEntryClient(eager, lazy, '');
|
|
142
148
|
}
|
|
143
|
-
function emitEntryEvent(
|
|
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(
|
|
154
|
+
return native.generateServeEntryEvent(eager, lazy, '');
|
|
150
155
|
}
|
|
151
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
319
|
+
logLine = `vmz serve: soft reload ok (mode=${mode}; ${scope}; catalog=${serveState.pageCatalog.length};${rev}${aff})`;
|
|
267
320
|
}
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
+
}
|
package/dist/host/serve/http.js
CHANGED
|
@@ -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,
|
|
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
|
|
73
|
+
let body = {};
|
|
74
74
|
try {
|
|
75
|
-
|
|
75
|
+
body = raw ? JSON.parse(String(raw)) : {};
|
|
76
76
|
}
|
|
77
77
|
catch {
|
|
78
|
-
|
|
78
|
+
body = {};
|
|
79
79
|
}
|
|
80
|
-
|
|
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,
|
|
101
|
+
res.end(JSON.stringify({ ok: true, phase, ...info }));
|
|
85
102
|
})
|
|
86
103
|
.catch((err) => {
|
|
87
|
-
console.error('vmz serve:
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
169
|
+
return;
|
|
42
170
|
if (typeof value === 'string') {
|
|
43
|
-
for (const chunk of value
|
|
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)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
180
|
+
return;
|
|
53
181
|
}
|
|
54
|
-
if (
|
|
55
|
-
for (const
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
}
|