@vmz/core 0.1.9 → 0.1.11

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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Deployment graph → component registry bootstrap (shared by all SSR/DOM hosts).
3
+ * Replaces ad-hoc registerComponents calls with an explicit preload contract.
4
+ */
5
+ export declare const DEPLOYMENT_SCHEMA = "vmz.deployment.v0";
6
+ /**
7
+ * @param {string} distDir
8
+ * @param {{ strict?: boolean }} [opts]
9
+ * @returns {any | null}
10
+ */
11
+ export declare function readDeploymentDocument(distDir: any, opts?: {}): any;
12
+ /**
13
+ * @param {any} deployment
14
+ * @returns {Array<{ chunkId: string, name: string, entry: string, source: string }>}
15
+ */
16
+ export declare function componentEntriesFromDeployment(deployment: any): any[];
17
+ /**
18
+ * @param {any} deployment
19
+ * @param {string[]} rootChunkIds
20
+ * @returns {Set<string>}
21
+ */
22
+ export declare function collectDependsOnClosure(deployment: any, rootChunkIds: any): Set<unknown>;
23
+ /**
24
+ * Resolve tag conflicts; strict mode throws, dev mode warns and keeps last chunkId.
25
+ * @param {Array<{ chunkId: string, name: string, entry: string, source?: string }>} entries
26
+ * @param {{ strict?: boolean }} [opts]
27
+ * @returns {Array<{ chunkId: string, name: string, entry: string, source?: string }>}
28
+ */
29
+ export declare function dedupeComponentEntriesByTag(entries: any, opts?: {}): any[];
30
+ /**
31
+ * @param {Array<{ chunkId: string, name: string, entry: string, source?: string }>} entries
32
+ * @param {Record<string, string> | undefined} explicit name → chunkId (no .client.js)
33
+ * @returns {Array<{ chunkId: string, name: string, entry: string, source?: string }>}
34
+ */
35
+ export declare function mergeExplicitComponentEntries(entries: any, explicit: any): unknown[];
36
+ /**
37
+ * @param {string} distDir
38
+ * @param {{
39
+ * strict?: boolean,
40
+ * closureRoots?: string[],
41
+ * explicit?: Record<string, string>,
42
+ * }} [opts]
43
+ * @returns {Promise<Array<{ chunkId: string, name: string, entry: string, source?: string }>>}
44
+ */
45
+ export declare function loadComponentEntries(distDir: any, opts?: {}): Promise<any[]>;
46
+ /**
47
+ * @param {string} distDir
48
+ * @param {Array<{ chunkId: string, name: string, entry: string }>} entries
49
+ * @param {(map: Record<string, unknown>) => void} registerComponents
50
+ * @param {{
51
+ * cacheBust?: string | number,
52
+ * loaded?: Set<string>,
53
+ * }} [opts]
54
+ * @returns {Promise<Record<string, unknown>>}
55
+ */
56
+ export declare function importAndRegisterComponentEntries(distDir: any, entries: any, registerComponents: any, opts?: {}): Promise<{}>;
57
+ /**
58
+ * Bootstrap component registry from deployment (all, closure, or directory fallback).
59
+ * @param {string} distDir
60
+ * @param {(map: Record<string, unknown>) => void} registerComponents
61
+ * @param {{
62
+ * strict?: boolean,
63
+ * closureRoots?: string[],
64
+ * explicit?: Record<string, string>,
65
+ * cacheBust?: string | number,
66
+ * loaded?: Set<string>,
67
+ * preload?: 'all' | 'closure' | 'none',
68
+ * }} [opts]
69
+ */
70
+ export declare function bootstrapComponentRegistry(distDir: any, registerComponents: any, opts?: {}): Promise<{}>;
@@ -0,0 +1,256 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Deployment graph → component registry bootstrap (shared by all SSR/DOM hosts).
4
+ * Replaces ad-hoc registerComponents calls with an explicit preload contract.
5
+ */
6
+ import fs from 'node:fs';
7
+ import { readdir } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+ export const DEPLOYMENT_SCHEMA = 'vmz.deployment.v0';
11
+ /**
12
+ * @param {string} distDir
13
+ * @param {{ strict?: boolean }} [opts]
14
+ * @returns {any | null}
15
+ */
16
+ export function readDeploymentDocument(distDir, opts = {}) {
17
+ const strict = opts.strict === true;
18
+ const filePath = path.join(distDir, 'vmz-deployment.json');
19
+ if (!fs.existsSync(filePath)) {
20
+ if (strict) {
21
+ throw new Error(`vmz: missing vmz-deployment.json under ${distDir} (strict deployment mode)`);
22
+ }
23
+ return null;
24
+ }
25
+ let raw;
26
+ try {
27
+ raw = fs.readFileSync(filePath, 'utf8');
28
+ }
29
+ catch (e) {
30
+ if (strict)
31
+ throw new Error(`vmz: cannot read vmz-deployment.json: ${e instanceof Error ? e.message : e}`);
32
+ return null;
33
+ }
34
+ let doc;
35
+ try {
36
+ doc = JSON.parse(raw);
37
+ }
38
+ catch (e) {
39
+ if (strict)
40
+ throw new Error(`vmz: invalid vmz-deployment.json: ${e instanceof Error ? e.message : e}`);
41
+ return null;
42
+ }
43
+ if (doc.schema !== DEPLOYMENT_SCHEMA) {
44
+ if (strict)
45
+ throw new Error(`vmz: unsupported deployment schema ${doc.schema}`);
46
+ return null;
47
+ }
48
+ return doc;
49
+ }
50
+ /**
51
+ * @param {any} deployment
52
+ * @returns {Array<{ chunkId: string, name: string, entry: string, source: string }>}
53
+ */
54
+ export function componentEntriesFromDeployment(deployment) {
55
+ /** @type {Array<{ chunkId: string, name: string, entry: string, source: string }>} */
56
+ const out = [];
57
+ for (const unit of deployment.units || []) {
58
+ if (unit?.kind !== 'component')
59
+ continue;
60
+ const chunkId = String(unit.chunkId || '').replace(/\\/g, '/');
61
+ const name = chunkId.split('/').pop();
62
+ if (!name)
63
+ continue;
64
+ const entry = String(unit.clientEntry || `${chunkId}.client.js`).replace(/\\/g, '/');
65
+ out.push({
66
+ chunkId,
67
+ name,
68
+ entry,
69
+ source: String(unit.source || ''),
70
+ });
71
+ }
72
+ return out.sort((a, b) => a.chunkId.localeCompare(b.chunkId));
73
+ }
74
+ /**
75
+ * @param {any} deployment
76
+ * @param {string[]} rootChunkIds
77
+ * @returns {Set<string>}
78
+ */
79
+ export function collectDependsOnClosure(deployment, rootChunkIds) {
80
+ /** @type {Map<string, any>} */
81
+ const byId = new Map();
82
+ for (const unit of deployment.units || []) {
83
+ byId.set(String(unit.chunkId || '').replace(/\\/g, '/'), unit);
84
+ }
85
+ /** @type {Set<string>} */
86
+ const out = new Set();
87
+ /** @type {string[]} */
88
+ const stack = rootChunkIds.map((id) => String(id).replace(/\\/g, '/')).filter(Boolean);
89
+ while (stack.length) {
90
+ const id = stack.pop();
91
+ if (!id || out.has(id))
92
+ continue;
93
+ out.add(id);
94
+ const unit = byId.get(id);
95
+ if (!unit)
96
+ continue;
97
+ for (const dep of unit.dependsOn || []) {
98
+ const d = String(dep).replace(/\\/g, '/');
99
+ if (!out.has(d))
100
+ stack.push(d);
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+ /**
106
+ * Resolve tag conflicts; strict mode throws, dev mode warns and keeps last chunkId.
107
+ * @param {Array<{ chunkId: string, name: string, entry: string, source?: string }>} entries
108
+ * @param {{ strict?: boolean }} [opts]
109
+ * @returns {Array<{ chunkId: string, name: string, entry: string, source?: string }>}
110
+ */
111
+ export function dedupeComponentEntriesByTag(entries, opts = {}) {
112
+ const strict = opts.strict === true;
113
+ /** @type {Map<string, { chunkId: string, name: string, entry: string, source?: string }>} */
114
+ const byTag = new Map();
115
+ for (const e of entries) {
116
+ const prev = byTag.get(e.name);
117
+ if (prev && prev.chunkId !== e.chunkId) {
118
+ const msg = `vmz: component tag <${e.name}> maps to both ${prev.chunkId} and ${e.chunkId}`;
119
+ if (strict)
120
+ throw new Error(msg);
121
+ console.warn(`${msg}; using ${e.chunkId}`);
122
+ }
123
+ byTag.set(e.name, e);
124
+ }
125
+ return [...byTag.values()].sort((a, b) => a.name.localeCompare(b.name));
126
+ }
127
+ /**
128
+ * @param {Array<{ chunkId: string, name: string, entry: string, source?: string }>} entries
129
+ * @param {Record<string, string> | undefined} explicit name → chunkId (no .client.js)
130
+ * @returns {Array<{ chunkId: string, name: string, entry: string, source?: string }>}
131
+ */
132
+ export function mergeExplicitComponentEntries(entries, explicit) {
133
+ /** @type {Map<string, { chunkId: string, name: string, entry: string, source?: string }>} */
134
+ const byTag = new Map(entries.map((e) => [e.name, e]));
135
+ if (explicit) {
136
+ for (const [name, chunk] of Object.entries(explicit)) {
137
+ const chunkId = String(chunk).replace(/\\/g, '/');
138
+ byTag.set(name, {
139
+ chunkId,
140
+ name,
141
+ entry: `${chunkId}.client.js`,
142
+ source: '',
143
+ });
144
+ }
145
+ }
146
+ return [...byTag.values()].sort((a, b) => a.name.localeCompare(b.name));
147
+ }
148
+ /**
149
+ * @param {string} distDir
150
+ * @param {{
151
+ * strict?: boolean,
152
+ * closureRoots?: string[],
153
+ * explicit?: Record<string, string>,
154
+ * }} [opts]
155
+ * @returns {Promise<Array<{ chunkId: string, name: string, entry: string, source?: string }>>}
156
+ */
157
+ export async function loadComponentEntries(distDir, opts = {}) {
158
+ const strict = opts.strict === true;
159
+ const deployment = readDeploymentDocument(distDir, { strict });
160
+ /** @type {Array<{ chunkId: string, name: string, entry: string, source?: string }>} */
161
+ let entries = [];
162
+ if (deployment) {
163
+ entries = componentEntriesFromDeployment(deployment);
164
+ if (opts.closureRoots?.length) {
165
+ const closure = collectDependsOnClosure(deployment, opts.closureRoots);
166
+ entries = entries.filter((e) => closure.has(e.chunkId));
167
+ }
168
+ }
169
+ else if (!strict) {
170
+ entries = await listComponentEntriesFromDirectory(distDir);
171
+ }
172
+ entries = mergeExplicitComponentEntries(entries, opts.explicit);
173
+ return dedupeComponentEntriesByTag(entries, { strict });
174
+ }
175
+ /**
176
+ * @param {string} distDir
177
+ * @returns {Promise<Array<{ chunkId: string, name: string, entry: string }>>}
178
+ */
179
+ async function listComponentEntriesFromDirectory(distDir) {
180
+ const folder = path.join(distDir, 'components');
181
+ /** @type {string[]} */
182
+ let files = [];
183
+ try {
184
+ files = await readdir(folder);
185
+ }
186
+ catch {
187
+ return [];
188
+ }
189
+ return files
190
+ .filter((name) => name.endsWith('.client.js'))
191
+ .map((f) => {
192
+ const name = f.replace(/\.client\.js$/, '');
193
+ const chunkId = `components/${name}`;
194
+ return { chunkId, name, entry: `components/${name}.client.js` };
195
+ })
196
+ .sort((a, b) => a.name.localeCompare(b.name));
197
+ }
198
+ /**
199
+ * @param {string} distDir
200
+ * @param {Array<{ chunkId: string, name: string, entry: string }>} entries
201
+ * @param {(map: Record<string, unknown>) => void} registerComponents
202
+ * @param {{
203
+ * cacheBust?: string | number,
204
+ * loaded?: Set<string>,
205
+ * }} [opts]
206
+ * @returns {Promise<Record<string, unknown>>}
207
+ */
208
+ export async function importAndRegisterComponentEntries(distDir, entries, registerComponents, opts = {}) {
209
+ /** @type {Record<string, unknown>} */
210
+ const map = {};
211
+ const loaded = opts.loaded ?? null;
212
+ for (const entry of entries) {
213
+ if (loaded && loaded.has(entry.chunkId))
214
+ continue;
215
+ const abs = path.join(distDir, entry.entry);
216
+ let href = pathToFileURL(abs).href;
217
+ if (opts.cacheBust != null && opts.cacheBust !== '') {
218
+ href = `${href}?t=${encodeURIComponent(String(opts.cacheBust))}`;
219
+ }
220
+ const mod = await import(href);
221
+ map[entry.name] = mod.default;
222
+ if (loaded)
223
+ loaded.add(entry.chunkId);
224
+ }
225
+ if (Object.keys(map).length)
226
+ registerComponents(map);
227
+ return map;
228
+ }
229
+ /**
230
+ * Bootstrap component registry from deployment (all, closure, or directory fallback).
231
+ * @param {string} distDir
232
+ * @param {(map: Record<string, unknown>) => void} registerComponents
233
+ * @param {{
234
+ * strict?: boolean,
235
+ * closureRoots?: string[],
236
+ * explicit?: Record<string, string>,
237
+ * cacheBust?: string | number,
238
+ * loaded?: Set<string>,
239
+ * preload?: 'all' | 'closure' | 'none',
240
+ * }} [opts]
241
+ */
242
+ export async function bootstrapComponentRegistry(distDir, registerComponents, opts = {}) {
243
+ const preload = opts.preload ?? (opts.closureRoots?.length ? 'closure' : 'all');
244
+ if (preload === 'none')
245
+ return {};
246
+ const loadOpts = {
247
+ strict: opts.strict,
248
+ explicit: opts.explicit,
249
+ closureRoots: preload === 'closure' ? opts.closureRoots : undefined,
250
+ };
251
+ const entries = await loadComponentEntries(distDir, loadOpts);
252
+ return importAndRegisterComponentEntries(distDir, entries, registerComponents, {
253
+ cacheBust: opts.cacheBust,
254
+ loaded: opts.loaded,
255
+ });
256
+ }
@@ -39,7 +39,7 @@ export declare function __vmzPrecisionSnapshot(): {
39
39
  patchesByBinding: any;
40
40
  };
41
41
  export declare function noteDomCreate(): void;
42
- /** @param {Record<string, any>} map */
42
+ /** @param {Record<string, any>} map — Prefer createRenderHost().ensureComponents(); process-global registry. */
43
43
  export declare function registerComponents(map: any): void;
44
44
  /** Sync registry lookup (SSR serialize / client). */
45
45
  export declare function getRegisteredComponent(name: any): any;
package/dist/dom-core.js CHANGED
@@ -144,7 +144,7 @@ function noteDomMove() {
144
144
  if (precision.enabled)
145
145
  precision.domMoves++;
146
146
  }
147
- /** @param {Record<string, any>} map */
147
+ /** @param {Record<string, any>} map — Prefer createRenderHost().ensureComponents(); process-global registry. */
148
148
  export function registerComponents(map) {
149
149
  Object.assign(components, map);
150
150
  }
@@ -1,25 +1,62 @@
1
1
  /**
2
2
  * Discover compiled client component modules from dist (deployment graph or components/).
3
- * Shared by serve-host SSR and static emit assemble.
3
+ * Shared by serve-host SSR, static emit, and test hosts.
4
4
  */
5
+ export { DEPLOYMENT_SCHEMA, readDeploymentDocument, componentEntriesFromDeployment, collectDependsOnClosure, dedupeComponentEntriesByTag, mergeExplicitComponentEntries, loadComponentEntries, importAndRegisterComponentEntries, bootstrapComponentRegistry, } from './deployment-registry.js';
6
+ export { createRenderHost } from './render-host.js';
5
7
  /**
6
8
  * @param {string} dir
7
- * @returns {Promise<Array<{ name: string, entry: string }>>}
9
+ * @param {{ strict?: boolean }} [opts]
10
+ * @returns {Promise<Array<{ name: string, entry: string, chunkId?: string }>>}
8
11
  */
9
- export declare function listClientComponents(dir: any): Promise<any[]>;
12
+ export declare function listClientComponents(dir: any, opts?: {}): Promise<{
13
+ name: any;
14
+ entry: any;
15
+ chunkId: any;
16
+ }[]>;
10
17
  /**
11
18
  * Sync variant for callers that already use fs sync (legacy static-emit helpers).
12
19
  * @param {string} dir
13
- * @returns {Array<{ name: string, entry: string }>}
20
+ * @param {{ strict?: boolean }} [opts]
21
+ * @returns {Array<{ name: string, entry: string, chunkId?: string }>}
14
22
  */
15
- export declare function listClientComponentsSync(dir: any): any[];
23
+ export declare function listClientComponentsSync(dir: any, opts?: {}): {
24
+ name: any;
25
+ entry: any;
26
+ chunkId: any;
27
+ }[];
16
28
  /**
17
- * Import all (or filtered) client components and register for SSR / static emit.
29
+ * @param {Array<{ name: string, entry: string, chunkId?: string }>} entries
30
+ * @param {Record<string, string> | undefined} explicit
31
+ * @returns {Array<{ name: string, entry: string, chunkId?: string }>}
32
+ */
33
+ export declare function mergeComponentEntries(entries: any, explicit: any): {
34
+ name: any;
35
+ entry: any;
36
+ chunkId: any;
37
+ }[];
38
+ /**
39
+ * @param {string} distDir
40
+ * @param {Record<string, string> | undefined} [explicit]
41
+ * @param {{ strict?: boolean, closureRoots?: string[] }} [opts]
42
+ * @returns {Promise<Array<{ name: string, entry: string, chunkId?: string }>>}
43
+ */
44
+ export declare function resolveComponentEntries(distDir: any, explicit: any, opts?: {}): Promise<{
45
+ name: any;
46
+ entry: any;
47
+ chunkId: any;
48
+ }[]>;
49
+ /**
50
+ * Import all (or filtered) client components and register for SSR / static emit / test hosts.
18
51
  * @param {string} distDir
19
52
  * @param {(map: Record<string, unknown>) => void} registerComponents
20
53
  * @param {{
21
54
  * cacheBust?: string | number,
22
- * include?: (entry: { name: string, entry: string }) => boolean,
55
+ * include?: (entry: { name: string, entry: string, chunkId?: string }) => boolean,
56
+ * explicit?: Record<string, string>,
57
+ * strict?: boolean,
58
+ * closureRoots?: string[],
59
+ * preload?: 'all' | 'closure' | 'none',
23
60
  * }} [opts]
24
61
  */
25
62
  export declare function preloadComponentRegistry(distDir: any, registerComponents: any, opts?: {}): Promise<{}>;
@@ -1,111 +1,151 @@
1
1
  // @ts-nocheck
2
2
  /**
3
3
  * Discover compiled client component modules from dist (deployment graph or components/).
4
- * Shared by serve-host SSR and static emit assemble.
4
+ * Shared by serve-host SSR, static emit, and test hosts.
5
5
  */
6
6
  import fs from 'node:fs';
7
- import { readdir, readFile } from 'node:fs/promises';
7
+ import { readdir } from 'node:fs/promises';
8
8
  import path from 'node:path';
9
9
  import { pathToFileURL } from 'node:url';
10
+ import { componentEntriesFromDeployment, dedupeComponentEntriesByTag, mergeExplicitComponentEntries, readDeploymentDocument, } from './deployment-registry.js';
11
+ export { DEPLOYMENT_SCHEMA, readDeploymentDocument, componentEntriesFromDeployment, collectDependsOnClosure, dedupeComponentEntriesByTag, mergeExplicitComponentEntries, loadComponentEntries, importAndRegisterComponentEntries, bootstrapComponentRegistry, } from './deployment-registry.js';
12
+ export { createRenderHost } from './render-host.js';
10
13
  /**
11
14
  * @param {string} dir
12
- * @returns {Promise<Array<{ name: string, entry: string }>>}
15
+ * @param {{ strict?: boolean }} [opts]
16
+ * @returns {Promise<Array<{ name: string, entry: string, chunkId?: string }>>}
13
17
  */
14
- export async function listClientComponents(dir) {
15
- /** @type {Map<string, { name: string, entry: string }>} */
16
- const byName = new Map();
18
+ export async function listClientComponents(dir, opts = {}) {
19
+ const strict = opts.strict === true;
20
+ const deployment = readDeploymentDocument(dir, { strict });
21
+ if (deployment) {
22
+ return dedupeComponentEntriesByTag(componentEntriesFromDeployment(deployment).map((e) => ({
23
+ chunkId: e.chunkId,
24
+ name: e.name,
25
+ entry: e.entry,
26
+ source: e.source,
27
+ }))).map((e) => ({
28
+ name: e.name,
29
+ entry: e.entry,
30
+ chunkId: e.chunkId,
31
+ }));
32
+ }
33
+ if (strict) {
34
+ throw new Error(`vmz: missing vmz-deployment.json under ${dir} (strict deployment mode)`);
35
+ }
36
+ const folder = path.join(dir, 'components');
37
+ /** @type {string[]} */
38
+ let files = [];
17
39
  try {
18
- const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
19
- const dep = JSON.parse(raw);
20
- for (const unit of dep.units || []) {
21
- if (unit?.kind !== 'component')
22
- continue;
23
- const chunkId = String(unit.chunkId || '');
24
- const name = chunkId.split('/').pop();
25
- if (!name)
26
- continue;
27
- const entry = String(unit.clientEntry || `${chunkId}.client.js`).replace(/\\/g, '/');
28
- byName.set(name, { name, entry });
29
- }
40
+ files = await readdir(folder);
30
41
  }
31
42
  catch {
32
- /* fall through to directory scan */
33
- }
34
- if (byName.size === 0) {
35
- const folder = path.join(dir, 'components');
36
- let files = [];
37
- try {
38
- files = await readdir(folder);
39
- }
40
- catch {
41
- return [];
42
- }
43
- for (const f of files.filter((name) => name.endsWith('.client.js'))) {
44
- const name = f.replace(/\.client\.js$/, '');
45
- byName.set(name, { name, entry: `components/${name}.client.js` });
46
- }
43
+ return [];
47
44
  }
48
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
45
+ return files
46
+ .filter((name) => name.endsWith('.client.js'))
47
+ .map((f) => {
48
+ const name = f.replace(/\.client\.js$/, '');
49
+ return { name, entry: `components/${name}.client.js`, chunkId: `components/${name}` };
50
+ })
51
+ .sort((a, b) => a.name.localeCompare(b.name));
49
52
  }
50
53
  /**
51
54
  * Sync variant for callers that already use fs sync (legacy static-emit helpers).
52
55
  * @param {string} dir
53
- * @returns {Array<{ name: string, entry: string }>}
56
+ * @param {{ strict?: boolean }} [opts]
57
+ * @returns {Array<{ name: string, entry: string, chunkId?: string }>}
54
58
  */
55
- export function listClientComponentsSync(dir) {
56
- /** @type {Map<string, { name: string, entry: string }>} */
57
- const byName = new Map();
58
- const deploymentPath = path.join(dir, 'vmz-deployment.json');
59
- if (fs.existsSync(deploymentPath)) {
60
- try {
61
- const dep = JSON.parse(fs.readFileSync(deploymentPath, 'utf8'));
62
- for (const unit of dep.units || []) {
63
- if (unit?.kind !== 'component')
64
- continue;
65
- const chunkId = String(unit.chunkId || '');
66
- const name = chunkId.split('/').pop();
67
- if (!name)
68
- continue;
69
- const entry = String(unit.clientEntry || `${chunkId}.client.js`).replace(/\\/g, '/');
70
- byName.set(name, { name, entry });
71
- }
72
- }
73
- catch {
74
- /* fall through */
75
- }
59
+ export function listClientComponentsSync(dir, opts = {}) {
60
+ const strict = opts.strict === true;
61
+ const deployment = readDeploymentDocument(dir, { strict });
62
+ if (deployment) {
63
+ return dedupeComponentEntriesByTag(componentEntriesFromDeployment(deployment).map((e) => ({
64
+ chunkId: e.chunkId,
65
+ name: e.name,
66
+ entry: e.entry,
67
+ source: e.source,
68
+ }))).map((e) => ({
69
+ name: e.name,
70
+ entry: e.entry,
71
+ chunkId: e.chunkId,
72
+ }));
76
73
  }
77
- if (byName.size === 0) {
78
- const folder = path.join(dir, 'components');
79
- let files = [];
80
- try {
81
- files = fs.readdirSync(folder);
82
- }
83
- catch {
84
- return [];
85
- }
86
- for (const f of files.filter((name) => name.endsWith('.client.js'))) {
87
- const name = f.replace(/\.client\.js$/, '');
88
- byName.set(name, { name, entry: `components/${name}.client.js` });
89
- }
74
+ if (strict) {
75
+ throw new Error(`vmz: missing vmz-deployment.json under ${dir} (strict deployment mode)`);
90
76
  }
91
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
77
+ const folder = path.join(dir, 'components');
78
+ /** @type {string[]} */
79
+ let files = [];
80
+ try {
81
+ files = fs.readdirSync(folder);
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ return files
87
+ .filter((name) => name.endsWith('.client.js'))
88
+ .map((f) => {
89
+ const name = f.replace(/\.client\.js$/, '');
90
+ return { name, entry: `components/${name}.client.js`, chunkId: `components/${name}` };
91
+ })
92
+ .sort((a, b) => a.name.localeCompare(b.name));
93
+ }
94
+ /**
95
+ * @param {Array<{ name: string, entry: string, chunkId?: string }>} entries
96
+ * @param {Record<string, string> | undefined} explicit
97
+ * @returns {Array<{ name: string, entry: string, chunkId?: string }>}
98
+ */
99
+ export function mergeComponentEntries(entries, explicit) {
100
+ const normalized = entries.map((e) => ({
101
+ chunkId: e.chunkId || `components/${e.name}`,
102
+ name: e.name,
103
+ entry: e.entry,
104
+ }));
105
+ return mergeExplicitComponentEntries(normalized, explicit).map((e) => ({
106
+ name: e.name,
107
+ entry: e.entry,
108
+ chunkId: e.chunkId,
109
+ }));
110
+ }
111
+ /**
112
+ * @param {string} distDir
113
+ * @param {Record<string, string> | undefined} [explicit]
114
+ * @param {{ strict?: boolean, closureRoots?: string[] }} [opts]
115
+ * @returns {Promise<Array<{ name: string, entry: string, chunkId?: string }>>}
116
+ */
117
+ export async function resolveComponentEntries(distDir, explicit, opts = {}) {
118
+ const { loadComponentEntries } = await import('./deployment-registry.js');
119
+ const entries = await loadComponentEntries(distDir, {
120
+ strict: opts.strict,
121
+ closureRoots: opts.closureRoots,
122
+ explicit,
123
+ });
124
+ return entries.map((e) => ({ name: e.name, entry: e.entry, chunkId: e.chunkId }));
92
125
  }
93
126
  /**
94
- * Import all (or filtered) client components and register for SSR / static emit.
127
+ * Import all (or filtered) client components and register for SSR / static emit / test hosts.
95
128
  * @param {string} distDir
96
129
  * @param {(map: Record<string, unknown>) => void} registerComponents
97
130
  * @param {{
98
131
  * cacheBust?: string | number,
99
- * include?: (entry: { name: string, entry: string }) => boolean,
132
+ * include?: (entry: { name: string, entry: string, chunkId?: string }) => boolean,
133
+ * explicit?: Record<string, string>,
134
+ * strict?: boolean,
135
+ * closureRoots?: string[],
136
+ * preload?: 'all' | 'closure' | 'none',
100
137
  * }} [opts]
101
138
  */
102
139
  export async function preloadComponentRegistry(distDir, registerComponents, opts = {}) {
103
- const entries = await listClientComponents(distDir);
140
+ let entries = await resolveComponentEntries(distDir, opts.explicit, {
141
+ strict: opts.strict,
142
+ closureRoots: opts.closureRoots,
143
+ });
144
+ if (opts.include)
145
+ entries = entries.filter((e) => opts.include(e));
104
146
  /** @type {Record<string, unknown>} */
105
147
  const map = {};
106
148
  for (const entry of entries) {
107
- if (opts.include && !opts.include(entry))
108
- continue;
109
149
  const abs = path.join(distDir, entry.entry);
110
150
  let href = pathToFileURL(abs).href;
111
151
  if (opts.cacheBust != null && opts.cacheBust !== '') {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Unified SSR/DOM render host — explicit deployment bootstrap before renderToString/stream/mount.
3
+ * Hosts must not call renderToString until ensureComponents() has run for the active closure.
4
+ */
5
+ /**
6
+ * @param {string} distDir
7
+ * @param {{
8
+ * strictDeployment?: boolean,
9
+ * strict?: boolean,
10
+ * explicit?: Record<string, string>,
11
+ * cacheBust?: string | number,
12
+ * preload?: 'all' | 'closure' | 'none',
13
+ * closureRoots?: string[],
14
+ * }} [opts]
15
+ */
16
+ export declare function createRenderHost(distDir: any, opts?: {}): Promise<{
17
+ distDir: any;
18
+ deployment: any;
19
+ dom: any;
20
+ loadedChunkIds: Set<unknown>;
21
+ ensureComponents: (rootChunkIds: any) => Promise<{}>;
22
+ closureChunkIds: (rootChunkIds: any) => Set<unknown>;
23
+ renderToString: any;
24
+ renderToStream: any;
25
+ mount: any;
26
+ hydrate: any;
27
+ resume: any;
28
+ destroy: any;
29
+ flushPending: any;
30
+ /** @deprecated Prefer ensureComponents via createRenderHost; low-level escape hatch. */
31
+ registerComponents: any;
32
+ }>;
@@ -0,0 +1,81 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Unified SSR/DOM render host — explicit deployment bootstrap before renderToString/stream/mount.
4
+ * Hosts must not call renderToString until ensureComponents() has run for the active closure.
5
+ */
6
+ import path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { bootstrapComponentRegistry, collectDependsOnClosure, loadComponentEntries, readDeploymentDocument, importAndRegisterComponentEntries, } from './deployment-registry.js';
9
+ /**
10
+ * @param {string} distDir
11
+ * @param {{
12
+ * strictDeployment?: boolean,
13
+ * strict?: boolean,
14
+ * explicit?: Record<string, string>,
15
+ * cacheBust?: string | number,
16
+ * preload?: 'all' | 'closure' | 'none',
17
+ * closureRoots?: string[],
18
+ * }} [opts]
19
+ */
20
+ export async function createRenderHost(distDir, opts = {}) {
21
+ const strict = opts.strictDeployment === true || opts.strict === true;
22
+ const domPath = path.join(distDir, 'vmz-dom.js');
23
+ const dom = await import(pathToFileURL(domPath).href);
24
+ const deployment = readDeploymentDocument(distDir, { strict });
25
+ /** @type {Set<string>} */
26
+ const loadedChunkIds = new Set();
27
+ const bootstrapOpts = {
28
+ strict,
29
+ explicit: opts.explicit,
30
+ cacheBust: opts.cacheBust,
31
+ loaded: loadedChunkIds,
32
+ preload: opts.preload ?? 'none',
33
+ closureRoots: opts.closureRoots,
34
+ };
35
+ if (bootstrapOpts.preload !== 'none') {
36
+ await bootstrapComponentRegistry(distDir, dom.registerComponents, bootstrapOpts);
37
+ }
38
+ /**
39
+ * Load component closure for root chunk ids (page + layouts + fixture).
40
+ * @param {string[]} rootChunkIds
41
+ */
42
+ async function ensureComponents(rootChunkIds) {
43
+ if (!rootChunkIds?.length)
44
+ return {};
45
+ const entries = await loadComponentEntries(distDir, {
46
+ strict,
47
+ closureRoots: rootChunkIds,
48
+ explicit: opts.explicit,
49
+ });
50
+ return importAndRegisterComponentEntries(distDir, entries, dom.registerComponents, {
51
+ cacheBust: opts.cacheBust,
52
+ loaded: loadedChunkIds,
53
+ });
54
+ }
55
+ /**
56
+ * Union closure chunk ids (pages + layouts) without importing yet.
57
+ * @param {string[]} rootChunkIds
58
+ */
59
+ function closureChunkIds(rootChunkIds) {
60
+ if (!deployment || !rootChunkIds?.length)
61
+ return new Set(rootChunkIds || []);
62
+ return collectDependsOnClosure(deployment, rootChunkIds);
63
+ }
64
+ return {
65
+ distDir,
66
+ deployment,
67
+ dom,
68
+ loadedChunkIds,
69
+ ensureComponents,
70
+ closureChunkIds,
71
+ renderToString: dom.renderToString.bind(dom),
72
+ renderToStream: dom.renderToStream.bind(dom),
73
+ mount: dom.mount.bind(dom),
74
+ hydrate: dom.hydrate?.bind(dom),
75
+ resume: dom.resume?.bind(dom),
76
+ destroy: dom.destroy?.bind(dom),
77
+ flushPending: dom.flushPending?.bind(dom),
78
+ /** @deprecated Prefer ensureComponents via createRenderHost; low-level escape hatch. */
79
+ registerComponents: dom.registerComponents.bind(dom),
80
+ };
81
+ }
@@ -22,7 +22,7 @@ import http from 'node:http';
22
22
  import { createRequire, registerHooks } from 'node:module';
23
23
  import path from 'node:path';
24
24
  import { fileURLToPath, pathToFileURL } from 'node:url';
25
- import { registerComponents, renderToStream, renderToString } from './vmz-dom.js';
25
+ import { createRenderHost } from './render-host.js';
26
26
  import { listClientComponents } from './list-client-components.js';
27
27
  import { handleNodeRequest, setRoutes, setServerModuleResolver } from './vmz-runtime.js';
28
28
  const require = createRequire(import.meta.url);
@@ -76,6 +76,8 @@ if (isDev) {
76
76
  }
77
77
  /** @type {number} */
78
78
  let reloadToken = Date.now();
79
+ /** @type {Awaited<ReturnType<typeof createRenderHost>> | null} */
80
+ let ssrRenderHost = null;
79
81
  /** @type {string | null} Correlatable build id from vmz dev (Living §12.8). */
80
82
  let lastDevBuildId = null;
81
83
  /** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
@@ -503,9 +505,17 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
503
505
  const pageDocMeta = resolvePageDocumentMeta(Page);
504
506
  const prevLocaleHint = globalThis.__vmzLocaleIdHint;
505
507
  globalThis.__vmzLocaleIdHint = localeId;
508
+ if (!ssrRenderHost) {
509
+ ssrRenderHost = await createRenderHost(distDir, {
510
+ strictDeployment: !isDev,
511
+ preload: 'none',
512
+ cacheBust: reloadToken,
513
+ });
514
+ }
515
+ await ssrRenderHost.ensureComponents([chunkId, ...layoutChain]);
506
516
  let bodyHtml = '';
507
517
  try {
508
- for await (const chunk of renderToStream(Page, props, { signal })) {
518
+ for await (const chunk of ssrRenderHost.renderToStream(Page, props, { signal })) {
509
519
  if (signal?.aborted)
510
520
  return;
511
521
  bodyHtml += chunk;
@@ -517,7 +527,7 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
517
527
  const Layout = await loadPageCtor(layoutChain[i]);
518
528
  if (!Layout)
519
529
  continue;
520
- bodyHtml = await renderToString(Layout, {}, { signal, slotHtml: bodyHtml });
530
+ bodyHtml = await ssrRenderHost.renderToString(Layout, {}, { signal, slotHtml: bodyHtml });
521
531
  if (signal?.aborted)
522
532
  return;
523
533
  }
@@ -709,28 +719,18 @@ async function softReload(opts = {}) {
709
719
  catch {
710
720
  localeArtifact = null;
711
721
  }
712
- const componentEntries = await listClientComponents(distDir);
722
+ const componentEntries = await listClientComponents(distDir, { strict: !isDev });
723
+ ssrRenderHost = await createRenderHost(distDir, {
724
+ strictDeployment: !isDev,
725
+ preload: 'none',
726
+ cacheBust: nextToken,
727
+ });
713
728
  const nextCatalog = await listPageClientFiles(distDir);
714
729
  if (!nextCatalog.length) {
715
730
  throw new Error(`vmz serve: no pages/**/*.client.js in ${distDir}`);
716
731
  }
717
- /** @type {Record<string, any>} */
718
- const components = {};
719
732
  /** @type {Map<string, any>} */
720
733
  const nextCtors = new Map();
721
- const affectedNames = new Set(affected
722
- .map((c) => String(c))
723
- .filter((c) => c.startsWith('components/') || !c.includes('/'))
724
- .map((c) => c.split('/').pop())
725
- .filter(Boolean));
726
- for (const entry of componentEntries) {
727
- if (islandHmr && affectedNames.size > 0 && !affectedNames.has(entry.name)) {
728
- continue;
729
- }
730
- const href = bustUrl(pathToFileURL(path.join(distDir, entry.entry)).href);
731
- const mod = await import(href);
732
- components[entry.name] = mod.default;
733
- }
734
734
  if (!islandHmr) {
735
735
  const pagesToLoad = reloadAllPages ? nextCatalog : nextCatalog.filter((p) => pageNeedsReload(p.chunkId, affected));
736
736
  for (const p of pagesToLoad) {
@@ -758,9 +758,6 @@ async function softReload(opts = {}) {
758
758
  }
759
759
  }
760
760
  }
761
- if (Object.keys(components).length) {
762
- registerComponents(components);
763
- }
764
761
  const indexChunk = pageCatalog.find((p) => p.chunkId === 'pages/index')?.chunkId || pageCatalog[0].chunkId;
765
762
  const resumeEntries = await loadPageResumeEntries(distDir, indexChunk);
766
763
  const styleMeta = await loadDeploymentStyle(distDir);
package/dist/server.js CHANGED
@@ -231,7 +231,9 @@ export async function handleNodeRequest(req, res, opts = {}) {
231
231
  if (verb === 'GET' && opts.distDir) {
232
232
  const nodePath = await import('node:path');
233
233
  const { readFile, stat } = await import('node:fs/promises');
234
- const file = await resolveDistStatic(opts.distDir, url.pathname, nodePath, stat);
234
+ const file = await resolveDistStatic(opts.distDir, url.pathname, nodePath, stat, {
235
+ cookieHeader: String(req.headers.cookie || ''),
236
+ });
235
237
  const hasSsr = typeof opts.renderPageStream === 'function' ||
236
238
  typeof opts.renderPage === 'function' ||
237
239
  typeof opts.renderIndexStream === 'function' ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/core",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "description": "VMZ production runtime core — DOM / SSR / HTTP / WriteBarrier (no compiler)",
6
6
  "exports": {
@@ -31,6 +31,10 @@
31
31
  "./component-registry": {
32
32
  "types": "./dist/list-client-components.d.ts",
33
33
  "default": "./dist/list-client-components.js"
34
+ },
35
+ "./render-host": {
36
+ "types": "./dist/render-host.d.ts",
37
+ "default": "./dist/render-host.js"
34
38
  }
35
39
  },
36
40
  "files": [