@vmz/core 0.2.0 → 0.2.1

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.
@@ -2,11 +2,6 @@
2
2
  * Direct DOM API types for `@vmz/core` browser runtime.
3
3
  */
4
4
  export type BindingId = number | string | null;
5
- export type DirectInstance = Record<string, unknown> & {
6
- __vmzDestroyed?: boolean;
7
- __vmzDomRoot?: Node | null;
8
- __vmzFlushTrie?: Record<string, unknown> | null;
9
- };
10
5
  export type PatchFn = (this: DirectInstance) => void;
11
6
  export type DirectBranchCreate = (api: DirectApi) => Node;
12
7
  export type DirectIfBranch = {
@@ -20,6 +15,96 @@ export type DirectEachSpec = {
20
15
  createItem: DirectBranchCreate;
21
16
  serializeItem?: DirectBranchCreate;
22
17
  };
18
+ export type BinderEntry = {
19
+ deps: string[];
20
+ fn: PatchFn;
21
+ bindingId?: BindingId;
22
+ };
23
+ export type DirtyTrieNode = {
24
+ [seg: string]: DirtyTrieNode | true | undefined;
25
+ };
26
+ export type ComponentListenerBag = Record<string, Array<(detail?: unknown) => void>>;
27
+ export type VmzPlan = {
28
+ schema?: string;
29
+ root_ids?: Array<string | number>;
30
+ [key: string]: unknown;
31
+ };
32
+ export type TaskStatus = 'pending' | 'success' | 'error' | 'cancelled';
33
+ export type TaskEntry = {
34
+ generation: number;
35
+ controller: AbortController | {
36
+ signal: {
37
+ aborted: boolean;
38
+ };
39
+ abort: () => void;
40
+ };
41
+ status: TaskStatus;
42
+ result?: unknown;
43
+ error?: unknown;
44
+ promise?: Promise<unknown>;
45
+ };
46
+ export type DirtyNotice = {
47
+ type: 'replace';
48
+ root: string;
49
+ } | {
50
+ type: 'path';
51
+ root: string;
52
+ segs: string[];
53
+ };
54
+ /** Runtime instance shape mounted by Direct create / hydrate / resume. */
55
+ export type DirectInstance = {
56
+ [key: string]: unknown;
57
+ __vmzDestroyed?: boolean;
58
+ __vmzDomRoot?: (Node & {
59
+ __vmzInst?: DirectInstance | null;
60
+ }) | null;
61
+ __vmzFlushTrie?: DirtyTrieNode | null;
62
+ __vmzFlushScheduled?: boolean;
63
+ __vmzFlushSync?: boolean;
64
+ __vmzDirtyTrie?: DirtyTrieNode | null;
65
+ __vmzDirty?: Set<string>;
66
+ __vmzDirtyNotices?: DirtyNotice[];
67
+ __vmzLeafDirty?: Array<{
68
+ root: string;
69
+ field: string;
70
+ idxs: number[];
71
+ }>;
72
+ __vmzDrainLeafDirty?: boolean;
73
+ __vmzBinders?: Record<string, BinderEntry | PatchFn>;
74
+ __vmzBindings?: Record<string, BinderEntry | PatchFn>;
75
+ __vmzDepToBindings?: Record<string, Array<string | number>>;
76
+ __vmzPendingChildMounts?: Array<Promise<unknown>>;
77
+ __vmzComponentListeners?: ComponentListenerBag;
78
+ __vmzOnParentProp?: (propName: string, value: unknown) => void;
79
+ __vmzApplyProps?: (props: object) => void;
80
+ __vmzTasks?: Record<string, TaskEntry>;
81
+ __vmzPlan?: VmzPlan | null;
82
+ __vmzCtor?: ComponentCtorLike | null;
83
+ __vmzQuiet?: boolean;
84
+ __vmzEventDepth?: number;
85
+ __vmzEachApplyLeaf?: unknown;
86
+ __vmzEachCompoundStride?: unknown;
87
+ __vmzEachTranspose?: unknown;
88
+ onMount?: () => void | Promise<void>;
89
+ onDestroy?: () => void | Promise<void>;
90
+ };
91
+ export type ComponentCtorLike = (new (props?: object) => DirectInstance) & {
92
+ __vmzDirect?: boolean;
93
+ __vmzCreate?: (this: DirectInstance, api: DirectApi) => Node;
94
+ __vmzSerialize?: (this: DirectInstance, api: DirectApi) => unknown;
95
+ __vmzPlan?: VmzPlan | unknown;
96
+ __vmzHostBox?: string;
97
+ __vmzTag?: string;
98
+ __vmzState?: string[];
99
+ __vmzProps?: string[];
100
+ __vmzWBInstalled?: boolean;
101
+ __vmzCtorAppliesProps?: boolean;
102
+ name?: string;
103
+ };
104
+ export type DirectEachCtx = {
105
+ noteItemBind: (bindingId: BindingId, deps: string[], fn: PatchFn) => void;
106
+ needDelegate: (type: string) => void;
107
+ };
23
108
  /** Browser + generated artifact platform surface (0.2.0 thin runtime). */
24
109
  export type DirectApi = {
25
110
  _inst: DirectInstance | null;
@@ -29,16 +114,15 @@ export type DirectApi = {
29
114
  bindingId?: BindingId;
30
115
  }> | null;
31
116
  _itemPatches: PatchFn[] | null;
32
- _eachCtx: null | {
33
- noteItemBind: (bindingId: BindingId, deps: string[], fn: PatchFn) => void;
34
- needDelegate: (type: string) => void;
35
- };
117
+ _eachCtx: DirectEachCtx | null;
36
118
  _resumeAdopt: null | Record<string, unknown>;
37
119
  el: (tag: string) => Element;
38
120
  text: (value?: unknown) => Text;
39
121
  frag: () => DocumentFragment;
40
122
  comment: (value?: string) => Comment;
41
123
  attr: (el: Element, name: string, value: unknown) => void;
124
+ mergeClass: (...parts: unknown[]) => string;
125
+ mergeStyle: (...parts: unknown[]) => string;
42
126
  insertBefore: (parent: Node, node: Node, ref: Node | null) => void;
43
127
  removeNode: (node: Node) => void;
44
128
  trackPatch: (inst: DirectInstance, deps: string[], patch: PatchFn, bindingId?: BindingId) => void;
@@ -53,6 +137,5 @@ export type DirectApi = {
53
137
  setHtml: (el: Element, value: unknown) => void;
54
138
  bindHtml: (inst: DirectInstance, bindingId: BindingId, deps: string[], get: PatchFn, el: Element) => void;
55
139
  bindComponentProp: (inst: DirectInstance, bindingId: BindingId, deps: string[], get: PatchFn, hostInst: DirectInstance, propName: string) => void;
56
- component: (Ctor: new (props?: object) => DirectInstance, props?: object) => HTMLElement;
57
- [key: string]: unknown;
140
+ component: (Ctor: ComponentCtorLike | string, props?: object) => HTMLElement;
58
141
  };
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { BindingId, DirectInstance, PatchFn } from './direct-api.types.js';
10
10
  import type { ComponentCtor, EachCtx, ResumeAdoptCtx } from './dom-core.types.js';
11
+ export { mergeClassParts, mergeStyleParts } from '../shared/dom-attr-normalize.js';
11
12
  export { applyDirectHostBox, INLINE_HOST_CONTENTS, resolveDirectHostBox } from './direct-host-box.js';
12
13
  export { createUnknownComponentElement, markUnknownComponentHost, serializeUnknownComponentNode, UNKNOWN_COMPONENT_ERROR, } from './unknown-component.js';
13
14
  export declare function __vmzPrecisionEnable(on?: boolean): void;
@@ -105,6 +106,8 @@ export declare const directApi: {
105
106
  untrackPatch(inst: any, deps: any, patch: any, bindingId?: any): void;
106
107
  disposeTree(root: any): void;
107
108
  attr(el: any, name: any, value: any): void;
109
+ mergeClass(...parts: any[]): string;
110
+ mergeStyle(...parts: any[]): string;
108
111
  on(el: any, type: any, handler: any): void;
109
112
  /**
110
113
  * Bind a named instance method (no arrow / Function#toString).
@@ -6,8 +6,10 @@
6
6
  * Mount and client patches run that same schedule (SSR/hydrate/resume in dom-ssr).
7
7
  * Field writes only run registered dep patches ??never re-create structure.
8
8
  */
9
+ import { mergeClassParts, mergeStyleParts } from '../shared/dom-attr-normalize.js';
9
10
  import { applyDirectHostBox } from './direct-host-box.js';
10
11
  import { createUnknownComponentElement } from './unknown-component.js';
12
+ export { mergeClassParts, mergeStyleParts } from '../shared/dom-attr-normalize.js';
11
13
  export { applyDirectHostBox, INLINE_HOST_CONTENTS, resolveDirectHostBox } from './direct-host-box.js';
12
14
  export { createUnknownComponentElement, markUnknownComponentHost, serializeUnknownComponentNode, UNKNOWN_COMPONENT_ERROR, } from './unknown-component.js';
13
15
  const components = Object.create(null);
@@ -148,7 +150,7 @@ export function getRegisteredComponent(name) {
148
150
  export async function resolveComponent(name) {
149
151
  let Ctor = components[name];
150
152
  if (!Ctor && typeof globalThis.__vmzLoadComponent === 'function') {
151
- Ctor = await globalThis.__vmzLoadComponent(name);
153
+ Ctor = (await globalThis.__vmzLoadComponent(name));
152
154
  if (Ctor)
153
155
  registerComponents({ [name]: Ctor });
154
156
  }
@@ -374,6 +376,12 @@ export const directApi = {
374
376
  attr(el, name, value) {
375
377
  applyDomAttr(el, name, value);
376
378
  },
379
+ mergeClass(...parts) {
380
+ return mergeClassParts(...parts);
381
+ },
382
+ mergeStyle(...parts) {
383
+ return mergeStyleParts(...parts);
384
+ },
377
385
  on(el, type, handler) {
378
386
  const inst = directApi._inst;
379
387
  if (directApi._eachCtx && typeof handler === 'function') {
@@ -479,12 +487,19 @@ export const directApi = {
479
487
  directApi.trackPatch(inst, [fieldName], function specFieldAttrPatch() {
480
488
  const raw = this[fieldName];
481
489
  if (name === 'class' || name === 'className') {
482
- const s = String(raw ?? '');
490
+ const s = mergeClassParts(raw);
483
491
  if (s)
484
492
  el.setAttribute('class', s);
485
493
  else if (el.hasAttribute('class'))
486
494
  el.removeAttribute('class');
487
495
  }
496
+ else if (name === 'style') {
497
+ const s = mergeStyleParts(raw);
498
+ if (s)
499
+ el.setAttribute('style', s);
500
+ else if (el.hasAttribute('style'))
501
+ el.removeAttribute('style');
502
+ }
488
503
  else {
489
504
  applyDomAttr(el, name, raw);
490
505
  }
@@ -731,6 +746,22 @@ export const BOOLEAN_HTML_ATTRS = new Set([
731
746
  ]);
732
747
  export function applyDomAttr(el, name, value) {
733
748
  const key = name === 'className' ? 'class' : name;
749
+ if (key === 'class') {
750
+ const s = mergeClassParts(value);
751
+ if (s)
752
+ el.setAttribute('class', s);
753
+ else
754
+ el.removeAttribute('class');
755
+ return;
756
+ }
757
+ if (key === 'style') {
758
+ const s = mergeStyleParts(value);
759
+ if (s)
760
+ el.setAttribute('style', s);
761
+ else
762
+ el.removeAttribute('style');
763
+ return;
764
+ }
734
765
  if (BOOLEAN_HTML_ATTRS.has(String(key).toLowerCase())) {
735
766
  if (value === false || value == null || value === '') {
736
767
  el.removeAttribute(key);
@@ -1,20 +1,9 @@
1
1
  /**
2
2
  * Internal types for `@vmz/core` browser DOM runtime (`dom-core.ts`).
3
3
  */
4
- import type { DirectApi, DirectInstance, PatchFn, BindingId } from './direct-api.types.js';
5
- export type { DirectApi, DirectInstance, PatchFn, BindingId };
6
- export type ComponentCtor = (new (props?: object) => DirectInstance) & {
7
- __vmzDirect?: boolean;
8
- __vmzCreate?: (this: DirectInstance, api: DirectApi) => Node;
9
- __vmzSerialize?: (this: DirectInstance, api: DirectApi) => unknown;
10
- __vmzPlan?: unknown;
11
- __vmzHostBox?: string;
12
- __vmzTag?: string;
13
- __vmzState?: string[];
14
- __vmzProps?: string[];
15
- __vmzWBInstalled?: boolean;
16
- __vmzCtorAppliesProps?: boolean;
17
- name?: string;
4
+ import type { BindingId, ComponentCtorLike, DirectApi, DirectInstance, PatchFn, VmzPlan } from './direct-api.types.js';
5
+ export type { BindingId, DirectApi, DirectInstance, PatchFn, VmzPlan };
6
+ export type ComponentCtor = ComponentCtorLike & {
18
7
  [key: string]: unknown;
19
8
  };
20
9
  export type VmzDomElement = Element & {
@@ -35,8 +35,7 @@ export function noteUnknownComponent(name, via = 'ssr') {
35
35
  if (!Array.isArray(g.__VMZ_COMPONENT_ERRORS__)) {
36
36
  g.__VMZ_COMPONENT_ERRORS__ = [];
37
37
  }
38
- const bag = g.__VMZ_COMPONENT_ERRORS__;
39
- bag.push(detail);
38
+ g.__VMZ_COMPONENT_ERRORS__.push(detail);
40
39
  }
41
40
  catch {
42
41
  /* ignore */
@@ -4,4 +4,4 @@
4
4
  * Full barrel remains `@vmz/core/dom` (`dom.js` / dist `vmz-dom.js`) for Node host.
5
5
  */
6
6
  export * from '../browser/dom-core.js';
7
- export { attachEventEntries, hydrate, hydrateIslands, hydrateRoute, hydrateRoutePage, resume, resumeIslands, } from '../ssr/dom-ssr.js';
7
+ export { attachEventEntries, hydrate, hydrateIslands, hydrateRoute, hydrateRoutePage, resume, resumeIslands } from '../ssr/dom-ssr.js';
@@ -4,4 +4,4 @@
4
4
  * Full barrel remains `@vmz/core/dom` (`dom.js` / dist `vmz-dom.js`) for Node host.
5
5
  */
6
6
  export * from '../browser/dom-core.js';
7
- export { attachEventEntries, hydrate, hydrateIslands, hydrateRoute, hydrateRoutePage, resume, resumeIslands, } from '../ssr/dom-ssr.js';
7
+ export { attachEventEntries, hydrate, hydrateIslands, hydrateRoute, hydrateRoutePage, resume, resumeIslands } from '../ssr/dom-ssr.js';
@@ -1,6 +1,6 @@
1
- /** Compile-time HTTP decorators �?erased from server JS emit; kept for authoring. */
2
- export declare function Get(path: any): (_target: any, _key: any, descriptor: any) => any;
3
- export declare function Post(path: any): (_target: any, _key: any, descriptor: any) => any;
4
- export declare function Put(path: any): (_target: any, _key: any, descriptor: any) => any;
5
- export declare function Delete(path: any): (_target: any, _key: any, descriptor: any) => any;
6
- export declare function Patch(path: any): (_target: any, _key: any, descriptor: any) => any;
1
+ /** Compile-time HTTP decorators — erased from server JS emit; kept for authoring. */
2
+ export declare function Get(path: string): (_target: object, _key: string | symbol, descriptor: PropertyDescriptor) => PropertyDescriptor;
3
+ export declare function Post(path: string): (_target: object, _key: string | symbol, descriptor: PropertyDescriptor) => PropertyDescriptor;
4
+ export declare function Put(path: string): (_target: object, _key: string | symbol, descriptor: PropertyDescriptor) => PropertyDescriptor;
5
+ export declare function Delete(path: string): (_target: object, _key: string | symbol, descriptor: PropertyDescriptor) => PropertyDescriptor;
6
+ export declare function Patch(path: string): (_target: object, _key: string | symbol, descriptor: PropertyDescriptor) => PropertyDescriptor;
@@ -1,4 +1,4 @@
1
- /** Compile-time HTTP decorators �?erased from server JS emit; kept for authoring. */
1
+ /** Compile-time HTTP decorators — erased from server JS emit; kept for authoring. */
2
2
  export function Get(path) {
3
3
  return function GetDecorator(_target, _key, descriptor) {
4
4
  return descriptor;
@@ -4,26 +4,27 @@
4
4
  * Browser-safe: no static `node:*` imports. Node builtins are loaded only inside
5
5
  * Node/SSR request handlers so client bundles can `import { callServer }`.
6
6
  */
7
- import type { Route, ServerModuleResolver } from '../shared/server.types.js';
7
+ import type { IncomingMessage, ServerResponse } from 'node:http';
8
+ import type { NodeRequestOptions, Route, RpcRequest, ServerModuleResolver } from '../shared/server.types.js';
8
9
  /**
9
- * Map `#server/foo` �?filesystem / URL the host can `import`.
10
- * Only set this in Node/SSR hosts �?browser bundles must omit it so RPC goes HTTP.
10
+ * Map `#server/foo` → filesystem / URL the host can `import`.
11
+ * Only set this in Node/SSR hosts → browser bundles must omit it so RPC goes HTTP.
11
12
  */
12
13
  export declare function setServerModuleResolver(fn: ServerModuleResolver): void;
13
14
  export declare function setRoutes(next: Route[]): void;
14
15
  export declare function callServer(moduleId: string, method: string, args?: unknown[]): Promise<unknown>;
15
- export declare function handleRpc(body: any): Promise<any>;
16
+ export declare function handleRpc(body: RpcRequest): Promise<unknown>;
16
17
  /**
17
18
  * Match REST route from `vmz-routes.json`.
18
19
  */
19
- export declare function matchRoute(verb: any, pathname: any): Route;
20
+ export declare function matchRoute(verb: string, pathname: string): Route | null;
20
21
  /**
21
22
  * Web Standards Fetch entry for ServerArtifact hosts (Node adapter, worker/edge parity).
22
23
  * Handles RPC + public ServerRoute only; static/SSR stay on Node host options.
23
24
  */
24
- export declare function handleFetchRequest(request: any): Promise<Response>;
25
+ export declare function handleFetchRequest(request: Request): Promise<Response>;
25
26
  /**
26
27
  * Node `http.createServer` listener: RPC + REST + optional static / SSR index.
27
28
  * RPC/REST go through {@link handleFetchRequest} so Node and Fetch hosts share one core.
28
29
  */
29
- export declare function handleNodeRequest(req: any, res: any, opts?: any): Promise<void>;
30
+ export declare function handleNodeRequest(req: IncomingMessage, res: ServerResponse, opts?: NodeRequestOptions): Promise<void>;
@@ -8,8 +8,8 @@ const DEFAULT_RPC_PATH = '/__vmz/rpc';
8
8
  let resolveServerModule = null;
9
9
  let routes = [];
10
10
  /**
11
- * Map `#server/foo` �?filesystem / URL the host can `import`.
12
- * Only set this in Node/SSR hosts �?browser bundles must omit it so RPC goes HTTP.
11
+ * Map `#server/foo` → filesystem / URL the host can `import`.
12
+ * Only set this in Node/SSR hosts → browser bundles must omit it so RPC goes HTTP.
13
13
  */
14
14
  export function setServerModuleResolver(fn) {
15
15
  resolveServerModule = fn;
@@ -19,7 +19,7 @@ export function setRoutes(next) {
19
19
  }
20
20
  export async function callServer(moduleId, method, args = []) {
21
21
  // Browser (or tests forcing HTTP): never touch server modules in-process.
22
- // Node SSR / smokes set a resolver and leave __VMZ_USE_HTTP_RPC unset �?local.
22
+ // Node SSR / smokes set a resolver and leave __VMZ_USE_HTTP_RPC unset → local.
23
23
  if (globalThis.__VMZ_USE_HTTP_RPC || !resolveServerModule) {
24
24
  return callServerHttp(moduleId, method, args);
25
25
  }
@@ -30,11 +30,12 @@ async function callServerLocal(moduleId, method, args) {
30
30
  throw new Error(`vmz:runtime callServer(${moduleId}): setServerModuleResolver() required in Node`);
31
31
  }
32
32
  const spec = resolveServerModule(moduleId);
33
- const mod = await import(typeof spec === 'string' ? spec : spec.href);
34
- const Ctor = mod.default ?? mod[exportGuess(moduleId)];
35
- if (typeof Ctor !== 'function') {
33
+ const mod = (await import(typeof spec === 'string' ? spec : spec.href));
34
+ const exported = mod.default ?? mod[exportGuess(moduleId)];
35
+ if (typeof exported !== 'function') {
36
36
  throw new Error(`vmz:runtime: no class export for ${moduleId}`);
37
37
  }
38
+ const Ctor = exported;
38
39
  const instance = new Ctor();
39
40
  const fn = instance[method];
40
41
  if (typeof fn !== 'function') {
@@ -84,8 +85,8 @@ export function matchRoute(verb, pathname) {
84
85
  }
85
86
  /**
86
87
  * Build `callServerLocal` args from a Fetch Request for REST routes.
87
- * GET/HEAD �?`[]`. JSON POST/PUT/PATCH �?`[body]`. form-urlencoded �?`[record]`.
88
- * Multipart �?`[record]` where File/Blob parts stay as File/Blob (tool-site binary upload).
88
+ * GET/HEAD → `[]`. JSON POST/PUT/PATCH → `[body]`. form-urlencoded → `[record]`.
89
+ * Multipart → `[record]` where File/Blob parts stay as File/Blob (tool-site binary upload).
89
90
  * Octet-stream PUT also forwards Upload resumable chunk headers (upload-id / chunk-index / chunk-total).
90
91
  * Extra args are ignored by zero-parameter server methods (JS).
91
92
  */
@@ -106,12 +107,12 @@ async function routeArgsFromRequest(request, verb) {
106
107
  }
107
108
  }
108
109
  if (ctype.includes('multipart/form-data')) {
109
- // Parse from raw bytes �?undici Request.formData() can UTF-8-mangle high bytes in file parts
110
- // (0xFF/0xFE �?U+FFFD), which breaks Upload binary / tool-site intakes.
110
+ // Parse from raw bytes → undici Request.formData() can UTF-8-mangle high bytes in file parts
111
+ // (0xFF/0xFE → U+FFFD), which breaks Upload binary / tool-site intakes.
111
112
  const buf = Buffer.from(await request.arrayBuffer());
112
113
  return [parseMultipartBuffer(buf, ctype)];
113
114
  }
114
- // Object-store PUT / resumable chunk PUT �?keep bytes (never request.text()).
115
+ // Object-store PUT / resumable chunk PUT → keep bytes (never request.text()).
115
116
  if (ctype.includes('application/octet-stream') ||
116
117
  ((v === 'PUT' || v === 'PATCH') && !ctype.includes('json') && !ctype.includes('x-www-form-urlencoded'))) {
117
118
  const buf = Buffer.from(await request.arrayBuffer());
@@ -145,7 +146,7 @@ export async function handleFetchRequest(request) {
145
146
  const verb = (request.method || 'GET').toUpperCase();
146
147
  try {
147
148
  if (verb === 'POST' && url.pathname === DEFAULT_RPC_PATH) {
148
- const body = await request.json();
149
+ const body = (await request.json());
149
150
  const result = await handleRpc(body);
150
151
  return Response.json(result);
151
152
  }
@@ -179,10 +180,11 @@ export async function handleNodeRequest(req, res, opts = {}) {
179
180
  const response = await handleFetchRequest(request);
180
181
  return await writeFetchResponse(res, response);
181
182
  }
182
- // Static first for assets + DocumentMount (`/d/�`) so docs aren't swallowed by SSR 404 shells.
183
- // static route HTML (`index.html`, `about/index.html`, �? is a CDN/deploy projection only �? // when Server Host SSR is active, those files must not shadow live render (local/dev �?SSR truth).
183
+ // Static first for assets + DocumentMount (`/d/…`) so docs aren't swallowed by SSR 404 shells.
184
+ // static route HTML (`index.html`, `about/index.html`, …) is a CDN/deploy projection only —
185
+ // when Server Host SSR is active, those files must not shadow live render (local/dev → SSR truth).
184
186
  if (verb === 'GET' && opts.distDir) {
185
- const nodePath = await import('node:path');
187
+ const nodePath = (await import('node:path'));
186
188
  const { readFile, stat } = await import('node:fs/promises');
187
189
  const file = await resolveDistStatic(opts.distDir, url.pathname, nodePath, stat, {
188
190
  cookieHeader: String(req.headers.cookie || ''),
@@ -197,7 +199,8 @@ export async function handleNodeRequest(req, res, opts = {}) {
197
199
  return sendBytes(res, 200, body, contentType(file, nodePath));
198
200
  }
199
201
  catch (err) {
200
- if (err && (err.code === 'ENOENT' || err.code === 'EISDIR')) {
202
+ const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
203
+ if (code === 'ENOENT' || code === 'EISDIR') {
201
204
  /* fall through */
202
205
  }
203
206
  else {
@@ -216,7 +219,7 @@ export async function handleNodeRequest(req, res, opts = {}) {
216
219
  /* ignore */
217
220
  }
218
221
  };
219
- // Do not abort on IncomingMessage `close` �?that fires after a POST body is
222
+ // Do not abort on IncomingMessage `close` — that fires after a POST body is
220
223
  // fully read (normal), which would cancel SSR before the first chunk.
221
224
  req.on('aborted', onClientGone);
222
225
  res.on('close', () => {
@@ -244,7 +247,7 @@ export async function handleNodeRequest(req, res, opts = {}) {
244
247
  body,
245
248
  });
246
249
  if (rendered) {
247
- if (typeof rendered === 'object' && rendered.redirect) {
250
+ if (typeof rendered === 'object' && 'redirect' in rendered && rendered.redirect) {
248
251
  const status = Number(rendered.status) || 302;
249
252
  const headers = {
250
253
  Location: String(rendered.redirect),
@@ -255,7 +258,7 @@ export async function handleNodeRequest(req, res, opts = {}) {
255
258
  return;
256
259
  }
257
260
  const status = rendered && typeof rendered === 'object' && 'status' in rendered ? Number(rendered.status) || 200 : 200;
258
- const stream = rendered && typeof rendered === 'object' && rendered.stream ? rendered.stream : rendered;
261
+ const stream = rendered && typeof rendered === 'object' && 'stream' in rendered && rendered.stream ? rendered.stream : rendered;
259
262
  return await sendHtmlStream(res, status, stream, ac.signal);
260
263
  }
261
264
  }
@@ -355,7 +358,7 @@ function safeDistFile(distDir, pathname, nodePath) {
355
358
  }
356
359
  /**
357
360
  * static profile emits per-route HTML beside client assets. That HTML is for CDN / local-static
358
- * delivery hosts �?not for Server Host when SSR is available. DocumentMount stays static.
361
+ * delivery hosts — not for Server Host when SSR is available. DocumentMount stays static.
359
362
  */
360
363
  function isWebStaticHtmlShadow(file, pathname, nodePath) {
361
364
  const ext = nodePath.extname(file).toLowerCase();
@@ -364,14 +367,14 @@ function isWebStaticHtmlShadow(file, pathname, nodePath) {
364
367
  let rel = decodeURIComponent(String(pathname || '').split('?')[0] || '/');
365
368
  if (!rel.startsWith('/'))
366
369
  rel = `/${rel}`;
367
- // Integrated DocumentMount �?keep static-first (see resolveDistStatic comment).
370
+ // Integrated DocumentMount — keep static-first (see resolveDistStatic comment).
368
371
  if (rel === '/d' || rel.startsWith('/d/'))
369
372
  return false;
370
373
  return true;
371
374
  }
372
375
  /**
373
376
  * Static resolve for app assets + integrated document mounts:
374
- * `/d/` �?`d/index.html`, `/d/zh-hans/guide` �?`d/zh-hans/guide.html`.
377
+ * `/d/` → `d/index.html`, `/d/zh-hans/guide` → `d/zh-hans/guide.html`.
375
378
  */
376
379
  async function resolveDistStatic(distDir, pathname, nodePath, stat, _opts = {}) {
377
380
  const candidates = staticPathCandidates(pathname);
@@ -451,7 +454,9 @@ function contentType(filePath, nodePath) {
451
454
  function readJson(req) {
452
455
  return new Promise((resolve, reject) => {
453
456
  const chunks = [];
454
- req.on('data', (c) => chunks.push(c));
457
+ req.on('data', (c) => {
458
+ chunks.push(typeof c === 'string' ? Buffer.from(c) : c);
459
+ });
455
460
  req.on('end', () => {
456
461
  try {
457
462
  const raw = Buffer.concat(chunks).toString('utf8') || '{}';
@@ -467,8 +472,10 @@ function readJson(req) {
467
472
  function readRawBody(req) {
468
473
  return new Promise((resolve, reject) => {
469
474
  const chunks = [];
470
- req.on('data', (c) => chunks.push(c));
471
- // Buffer �?never utf8-string: multipart File bytes must survive (Upload binary gate).
475
+ req.on('data', (c) => {
476
+ chunks.push(typeof c === 'string' ? Buffer.from(c) : c);
477
+ });
478
+ // Buffer — never utf8-string: multipart File bytes must survive (Upload binary gate).
472
479
  req.on('end', () => resolve(Buffer.concat(chunks)));
473
480
  req.on('error', reject);
474
481
  });
@@ -513,7 +520,7 @@ function parseMultipartBuffer(buf, contentType) {
513
520
  if (fileM) {
514
521
  const filename = fileM[1] || 'upload.bin';
515
522
  const type = typeM ? String(typeM[1]).trim() : 'application/octet-stream';
516
- // Copy body �?File may outlive the request buffer.
523
+ // Copy body — File may outlive the request buffer.
517
524
  const copy = Buffer.from(body);
518
525
  const file = new File([copy], filename, { type });
519
526
  const prev = out[key];
@@ -551,19 +558,20 @@ function indexOfBuffer(hay, needle, from) {
551
558
  return -1;
552
559
  }
553
560
  function parseFormBody(raw, contentType) {
554
- if (!raw)
561
+ const text = typeof raw === 'string' ? raw : raw.toString('utf8');
562
+ if (!text)
555
563
  return {};
556
564
  if (contentType.includes('application/x-www-form-urlencoded')) {
557
565
  const out = {};
558
- for (const [k, v] of new URLSearchParams(raw))
566
+ for (const [k, v] of new URLSearchParams(text))
559
567
  out[k] = v;
560
568
  return out;
561
569
  }
562
570
  try {
563
- return JSON.parse(raw);
571
+ return JSON.parse(text);
564
572
  }
565
573
  catch {
566
- return { raw };
574
+ return { raw: text };
567
575
  }
568
576
  }
569
577
  function wantsBrowserHtml(req) {
@@ -592,7 +600,7 @@ function wantsBrowserHtml(req) {
592
600
  }
593
601
  /**
594
602
  * Browser-facing error document (no N-API). Used when SSR returns null or throws
595
- * on a document navigation �?must not fall back to API-shaped JSON.
603
+ * on a document navigation — must not fall back to API-shaped JSON.
596
604
  */
597
605
  function browserErrorHtml(status, title, message, stack) {
598
606
  const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -619,7 +627,7 @@ function browserErrorHtml(status, title, message, stack) {
619
627
  <h1>${esc(title)}</h1>
620
628
  <pre>${esc(message)}</pre>
621
629
  ${stackBlock}
622
- <p class="hint">Document navigation returns HTML errors �?not API JSON.</p>
630
+ <p class="hint">Document navigation returns HTML errors — not API JSON.</p>
623
631
  </main>
624
632
  </body>
625
633
  </html>
@@ -652,8 +660,8 @@ async function sendHtmlStream(res, status, source, signal) {
652
660
  'transfer-encoding': 'chunked',
653
661
  'cache-control': 'no-cache',
654
662
  };
655
- // Defer writeHead until the first successful chunk so SSR throw �?clean 500
656
- // (not headersSent + destroy �?ERR_EMPTY_RESPONSE).
663
+ // Defer writeHead until the first successful chunk so SSR throw → clean 500
664
+ // (not headersSent + destroy → ERR_EMPTY_RESPONSE).
657
665
  let started = false;
658
666
  try {
659
667
  for await (const chunk of source) {
@@ -691,7 +699,7 @@ async function sendHtmlStream(res, status, source, signal) {
691
699
  catch (err) {
692
700
  if (aborted())
693
701
  return;
694
- // Before headers: let outer handler sendJson(500). After headers: rethrow �?destroy.
702
+ // Before headers: let outer handler sendJson(500). After headers: rethrow → destroy.
695
703
  throw err;
696
704
  }
697
705
  if (started && !res.writableEnded && !res.destroyed) {
@@ -703,7 +711,7 @@ function sendBytes(res, status, body, type) {
703
711
  'content-type': type,
704
712
  'content-length': body.byteLength,
705
713
  };
706
- // Dev: stylesheets are rebuilt in-place �?never cache @import siblings (VMZ-8).
714
+ // Dev: stylesheets are rebuilt in-place — never cache @import siblings (VMZ-8).
707
715
  if (process.env.VMZ_DEV === '1' && typeof type === 'string' && type.startsWith('text/css')) {
708
716
  headers['cache-control'] = 'no-store';
709
717
  }
@@ -7,8 +7,8 @@ export declare function createRenderHost(distDir: string, opts?: Record<string,
7
7
  deployment: import("../shared/host.types.js").DeploymentDocument;
8
8
  dom: any;
9
9
  loadedChunkIds: Set<string>;
10
- ensureComponents: (rootChunkIds: any) => Promise<import("../shared/host.types.js").ComponentRegistryMap>;
11
- closureChunkIds: (rootChunkIds: any) => Set<unknown>;
10
+ ensureComponents: (rootChunkIds?: string[] | null) => Promise<import("../shared/host.types.js").ComponentRegistryMap>;
11
+ closureChunkIds: (rootChunkIds?: string[] | null) => Set<string>;
12
12
  renderToString: any;
13
13
  renderToStream: any;
14
14
  mount: any;
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
- import { bootstrapComponentRegistry, collectDependsOnClosure, loadComponentEntries, readDeploymentDocument, importAndRegisterComponentEntries, } from './deployment-registry.js';
7
+ import { bootstrapComponentRegistry, collectDependsOnClosure, importAndRegisterComponentEntries, loadComponentEntries, readDeploymentDocument, } from './deployment-registry.js';
8
8
  export async function createRenderHost(distDir, opts = {}) {
9
9
  const strict = opts.strictDeployment === true || opts.strict === true;
10
10
  const domPath = path.join(distDir, 'vmz-dom.js');
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shared class/style normalization for Direct SSR + client (0.1.33).
3
+ * Vue-compatible: string | string[] | object map | falsy parts.
4
+ */
5
+ /** Normalize one or more class fragments into a deduped class string. */
6
+ export declare function mergeClassParts(...parts: unknown[]): string;
7
+ /** Merge static + dynamic style fragments; later parts override same keys. */
8
+ export declare function mergeStyleParts(...parts: unknown[]): string;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared class/style normalization for Direct SSR + client (0.1.33).
3
+ * Vue-compatible: string | string[] | object map | falsy parts.
4
+ */
5
+ function pushClassTokens(out, value) {
6
+ if (value == null || value === false)
7
+ return;
8
+ if (typeof value === 'string') {
9
+ for (const token of value.split(/\s+/)) {
10
+ if (token)
11
+ out.push(token);
12
+ }
13
+ return;
14
+ }
15
+ if (Array.isArray(value)) {
16
+ for (const item of value)
17
+ pushClassTokens(out, item);
18
+ return;
19
+ }
20
+ if (typeof value === 'object') {
21
+ for (const [key, enabled] of Object.entries(value)) {
22
+ if (enabled)
23
+ out.push(key);
24
+ }
25
+ return;
26
+ }
27
+ const s = String(value).trim();
28
+ if (s)
29
+ out.push(s);
30
+ }
31
+ /** Normalize one or more class fragments into a deduped class string. */
32
+ export function mergeClassParts(...parts) {
33
+ const tokens = [];
34
+ for (const part of parts)
35
+ pushClassTokens(tokens, part);
36
+ return [...new Set(tokens)].join(' ');
37
+ }
38
+ function parseStyleObject(value) {
39
+ const out = {};
40
+ if (value == null || value === false)
41
+ return out;
42
+ if (typeof value === 'string') {
43
+ for (const chunk of value.split(';')) {
44
+ const idx = chunk.indexOf(':');
45
+ if (idx < 0)
46
+ continue;
47
+ const key = chunk.slice(0, idx).trim();
48
+ const val = chunk.slice(idx + 1).trim();
49
+ if (key)
50
+ out[key] = val;
51
+ }
52
+ return out;
53
+ }
54
+ if (typeof value === 'object' && !Array.isArray(value)) {
55
+ for (const [key, raw] of Object.entries(value)) {
56
+ if (raw == null || raw === false)
57
+ continue;
58
+ out[key] = String(raw);
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+ /** Merge static + dynamic style fragments; later parts override same keys. */
64
+ export function mergeStyleParts(...parts) {
65
+ const merged = {};
66
+ for (const part of parts)
67
+ Object.assign(merged, parseStyleObject(part));
68
+ return Object.entries(merged)
69
+ .map(([key, val]) => `${key}: ${val}`)
70
+ .join('; ');
71
+ }
@@ -32,7 +32,28 @@ export type ClosedAccessResult = {
32
32
  kind: 'not-found';
33
33
  };
34
34
  export type SseClient = Pick<ServerResponse, 'write' | 'end'>;
35
- export type DeploymentDocument = Record<string, unknown>;
35
+ /** Deployment graph document (`vmz.deployment.v0`). */
36
+ export type DeploymentUnit = {
37
+ chunkId?: string;
38
+ name?: string;
39
+ entry?: string;
40
+ source?: string;
41
+ dependsOn?: string[];
42
+ [key: string]: unknown;
43
+ };
44
+ export type DeploymentLayoutChain = {
45
+ routeId?: string;
46
+ layouts?: string[];
47
+ page?: string;
48
+ [key: string]: unknown;
49
+ };
50
+ export type DeploymentDocument = {
51
+ schema?: string;
52
+ units?: DeploymentUnit[];
53
+ layoutChain?: DeploymentLayoutChain[] | Record<string, unknown>;
54
+ dependsOn?: Record<string, string[]>;
55
+ [key: string]: unknown;
56
+ };
36
57
  export type ComponentRegistryMap = Record<string, unknown>;
37
58
  export type LoadComponentEntriesOpts = {
38
59
  strict?: boolean;
@@ -65,3 +86,28 @@ export type PreloadComponentRegistryOpts = ListClientComponentsOpts & {
65
86
  preload?: 'all' | 'closure' | 'none';
66
87
  };
67
88
  export type NativeAddon = Record<string, unknown>;
89
+ export type ServeHostOpts = {
90
+ distDir?: string;
91
+ port?: number;
92
+ host?: string;
93
+ signal?: AbortSignal;
94
+ [key: string]: unknown;
95
+ };
96
+ export type SoftReloadOpts = {
97
+ reason?: string;
98
+ signal?: AbortSignal;
99
+ [key: string]: unknown;
100
+ };
101
+ export type RenderPageStreamOpts = HostRequestOpts & {
102
+ localeId?: string;
103
+ [key: string]: unknown;
104
+ };
105
+ export type RenderHostOpts = {
106
+ strict?: boolean;
107
+ closureRoots?: string[];
108
+ explicit?: Record<string, string>;
109
+ preload?: 'all' | 'closure' | 'none';
110
+ cacheBust?: string | number;
111
+ rootChunkIds?: string[] | null;
112
+ [key: string]: unknown;
113
+ };
@@ -37,3 +37,31 @@ export type NodeRequestOptions = {
37
37
  req?: IncomingMessage;
38
38
  };
39
39
  export type ServerModuleResolver = (id: string) => string | URL;
40
+ /** Subset of `node:path` used by static resolve helpers. */
41
+ export type NodePathModule = {
42
+ resolve(...paths: string[]): string;
43
+ join(...paths: string[]): string;
44
+ extname(path: string): string;
45
+ readonly sep: string;
46
+ };
47
+ /** Minimal `fs.Stats`-like surface for static file probes. */
48
+ export type StatResult = {
49
+ isFile(): boolean;
50
+ isDirectory(): boolean;
51
+ };
52
+ export type StatFn = (path: string) => Promise<StatResult>;
53
+ export type DistStaticResolveOpts = {
54
+ cookieHeader?: string;
55
+ };
56
+ /** Multipart form fields: text, File, or repeated same-name values. */
57
+ export type MultipartFieldValue = string | File | Array<string | File>;
58
+ export type MultipartFields = Record<string, MultipartFieldValue>;
59
+ export type OctetStreamBody = {
60
+ bytes: Buffer;
61
+ size: number;
62
+ key: string;
63
+ uploadId: string;
64
+ chunkIndex?: number;
65
+ chunkTotal?: number;
66
+ contentType: string;
67
+ };
@@ -3,7 +3,7 @@
3
3
  * Imports client DOM primitives from ./dom-core.js for tree-shakeable browser entry.
4
4
  */
5
5
  import { applyDirectHostBox, directHostBoxStyleAttr } from '../browser/direct-host-box.js';
6
- import { applyPreservedState, BOOLEAN_HTML_ATTRS, createInstance, destroy, directApi, eventPropHandlerName, findOwnedDefaultSlot, getRegisteredComponent, hasMeaningfulChild, isEventEntryStrategy, mount, noteDomCreate, resolveComponent, runDirectCreate, scheduleClientOn, settlePendingChildMounts, snapshotInstanceState, stripFns, } from '../browser/dom-core.js';
6
+ import { applyPreservedState, BOOLEAN_HTML_ATTRS, createInstance, destroy, directApi, eventPropHandlerName, findOwnedDefaultSlot, getRegisteredComponent, hasMeaningfulChild, isEventEntryStrategy, mergeClassParts, mergeStyleParts, mount, noteDomCreate, resolveComponent, runDirectCreate, scheduleClientOn, settlePendingChildMounts, snapshotInstanceState, stripFns, } from '../browser/dom-core.js';
7
7
  import { markUnknownComponentHost, serializeUnknownComponentNode } from '../browser/unknown-component.js';
8
8
  let _ssrDocumentLastError = null;
9
9
  /**
@@ -633,6 +633,12 @@ const serializeApi = {
633
633
  return;
634
634
  applySerializeAttr(el, name, value);
635
635
  },
636
+ mergeClass(...parts) {
637
+ return mergeClassParts(...parts);
638
+ },
639
+ mergeStyle(...parts) {
640
+ return mergeStyleParts(...parts);
641
+ },
636
642
  on() {
637
643
  /* events are no-ops during SSR */
638
644
  },
@@ -666,6 +672,14 @@ const serializeApi = {
666
672
  catch {
667
673
  raw = null;
668
674
  }
675
+ if (name === 'class' || name === 'className') {
676
+ applySerializeAttr(el, 'class', mergeClassParts(raw));
677
+ return;
678
+ }
679
+ if (name === 'style') {
680
+ applySerializeAttr(el, 'style', mergeStyleParts(raw));
681
+ return;
682
+ }
669
683
  applySerializeAttr(el, name, raw);
670
684
  },
671
685
  bindComponentProp() {
@@ -833,6 +847,22 @@ function applySerializeAttr(el, name, value) {
833
847
  if (!el || el.__kind !== 'el')
834
848
  return;
835
849
  const key = name === 'className' ? 'class' : name;
850
+ if (key === 'class') {
851
+ const s = mergeClassParts(value);
852
+ if (s)
853
+ el.attrs[key] = s;
854
+ else
855
+ delete el.attrs[key];
856
+ return;
857
+ }
858
+ if (key === 'style') {
859
+ const s = mergeStyleParts(value);
860
+ if (s)
861
+ el.attrs[key] = s;
862
+ else
863
+ delete el.attrs[key];
864
+ return;
865
+ }
836
866
  if (BOOLEAN_HTML_ATTRS.has(String(key).toLowerCase())) {
837
867
  if (value === false || value == null || value === '')
838
868
  delete el.attrs[key];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/core",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "VMZ production runtime core — DOM / SSR / HTTP / WriteBarrier (no compiler)",
6
6
  "exports": {
@@ -70,12 +70,12 @@
70
70
  "linkedom": "^0.18.13"
71
71
  },
72
72
  "optionalDependencies": {
73
- "@vmz/vmz-win32-x64": "0.2.0",
74
- "@vmz/vmz-win32-arm64": "0.2.0",
75
- "@vmz/vmz-darwin-x64": "0.2.0",
76
- "@vmz/vmz-darwin-arm64": "0.2.0",
77
- "@vmz/vmz-linux-x64": "0.2.0",
78
- "@vmz/vmz-linux-arm64": "0.2.0"
73
+ "@vmz/vmz-win32-x64": "0.2.1",
74
+ "@vmz/vmz-win32-arm64": "0.2.1",
75
+ "@vmz/vmz-darwin-x64": "0.2.1",
76
+ "@vmz/vmz-darwin-arm64": "0.2.1",
77
+ "@vmz/vmz-linux-x64": "0.2.1",
78
+ "@vmz/vmz-linux-arm64": "0.2.1"
79
79
  },
80
80
  "publishConfig": {
81
81
  "access": "public"