@vertz/ui-server 0.2.47 → 0.2.49

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.
@@ -1,3 +1,32 @@
1
+ interface QuerySnapshot {
2
+ data: SerializedValue;
3
+ loading: boolean;
4
+ revalidating: boolean;
5
+ error: SerializedValue;
6
+ idle: boolean;
7
+ key?: string;
8
+ }
9
+ interface InstanceSnapshot {
10
+ index: number;
11
+ key?: string;
12
+ signals: Record<string, SerializedValue>;
13
+ queries: Record<string, QuerySnapshot>;
14
+ }
15
+ interface ComponentSnapshot {
16
+ name: string;
17
+ moduleId: string;
18
+ instanceCount: number;
19
+ instances: InstanceSnapshot[];
20
+ }
21
+ interface StateSnapshot {
22
+ components: ComponentSnapshot[];
23
+ totalInstances: number;
24
+ connectedClients: number;
25
+ timestamp: string;
26
+ message?: string;
27
+ truncated?: boolean;
28
+ }
29
+ type SerializedValue = string | number | boolean | null | object;
1
30
  import { AccessSet } from "@vertz/ui/auth";
2
31
  interface SessionData {
3
32
  user: {
@@ -198,6 +227,12 @@ interface BunDevServer {
198
227
  clearErrorForFileChange(): void;
199
228
  /** Set the last changed file path (for testing). */
200
229
  setLastChangedFile(file: string): void;
230
+ /**
231
+ * Request a state inspection from connected browser clients.
232
+ * Broadcasts an `inspect-state` command and waits for the first
233
+ * `state-snapshot` response. Returns the snapshot JSON or an error.
234
+ */
235
+ inspectState(filter?: string): Promise<StateSnapshot>;
201
236
  }
202
237
  interface HMRAssets {
203
238
  /** Discovered `/_bun/client/<hash>.js` URL, or null if not found */
@@ -3632,6 +3632,7 @@ function createBunDevServer(options) {
3632
3632
  let ssrFallback = false;
3633
3633
  const wsClients = new Set;
3634
3634
  let currentError = null;
3635
+ const pendingInspections = new Map;
3635
3636
  const sourceMapResolver = createSourceMapResolver(projectRoot);
3636
3637
  let clearGraceUntil = 0;
3637
3638
  let runtimeDebounceTimer = null;
@@ -4098,6 +4099,10 @@ function createBunDevServer(options) {
4098
4099
  const frInitPath = resolve(devDir, "fast-refresh-init.ts");
4099
4100
  writeFileSync3(frInitPath, `import '@vertz/ui-server/fast-refresh-runtime';
4100
4101
  if (import.meta.hot) import.meta.hot.accept();
4102
+ `);
4103
+ const siInitPath = resolve(devDir, "state-inspector-init.ts");
4104
+ writeFileSync3(siInitPath, `import '@vertz/ui-server/state-inspector';
4105
+ if (import.meta.hot) import.meta.hot.accept();
4101
4106
  `);
4102
4107
  const hmrShellHtml = `<!doctype html>
4103
4108
  <html lang="en"><head>
@@ -4105,6 +4110,7 @@ if (import.meta.hot) import.meta.hot.accept();
4105
4110
  <title>HMR Shell</title>
4106
4111
  </head><body>
4107
4112
  <script type="module" src="./fast-refresh-init.ts"></script>
4113
+ <script type="module" src="./state-inspector-init.ts"></script>
4108
4114
  <script type="module" src="${clientSrc}"></script>
4109
4115
  </body></html>`;
4110
4116
  const hmrShellPath = resolve(devDir, "hmr-shell.html");
@@ -4431,6 +4437,13 @@ data: {}
4431
4437
  devServer.restart();
4432
4438
  } else if (data.type === "ping") {
4433
4439
  ws.sendText(JSON.stringify({ type: "pong" }));
4440
+ } else if (data.type === "state-snapshot" && data.requestId) {
4441
+ const pending = pendingInspections.get(data.requestId);
4442
+ if (pending) {
4443
+ clearTimeout(pending.timer);
4444
+ pendingInspections.delete(data.requestId);
4445
+ pending.resolve(data.snapshot);
4446
+ }
4434
4447
  } else if (data.type === "resolve-stack" && data.stack) {
4435
4448
  const selfFetch = async (url) => {
4436
4449
  const absUrl = url.startsWith("http") ? url : `http://${host}:${server?.port}${url}`;
@@ -4803,8 +4816,53 @@ data: {}
4803
4816
  setLastChangedFile(file) {
4804
4817
  lastChangedFile = file;
4805
4818
  },
4819
+ async inspectState(filter) {
4820
+ if (wsClients.size === 0) {
4821
+ return {
4822
+ components: [],
4823
+ totalInstances: 0,
4824
+ connectedClients: 0,
4825
+ timestamp: new Date().toISOString(),
4826
+ message: "No browser clients connected. Open the app in a browser first."
4827
+ };
4828
+ }
4829
+ const requestId = crypto.randomUUID();
4830
+ const TIMEOUT_MS = 5000;
4831
+ return new Promise((resolve2) => {
4832
+ const timer = setTimeout(() => {
4833
+ pendingInspections.delete(requestId);
4834
+ resolve2({
4835
+ components: [],
4836
+ totalInstances: 0,
4837
+ connectedClients: wsClients.size,
4838
+ timestamp: new Date().toISOString(),
4839
+ message: "State inspection timed out after 5 seconds."
4840
+ });
4841
+ }, TIMEOUT_MS);
4842
+ pendingInspections.set(requestId, { resolve: resolve2, timer });
4843
+ const cmd = JSON.stringify({
4844
+ type: "inspect-state",
4845
+ requestId,
4846
+ ...filter ? { filter } : {}
4847
+ });
4848
+ for (const client of wsClients) {
4849
+ client.sendText(cmd);
4850
+ }
4851
+ });
4852
+ },
4806
4853
  async stop() {
4807
4854
  stopped = true;
4855
+ for (const [, { resolve: res, timer }] of pendingInspections) {
4856
+ clearTimeout(timer);
4857
+ res({
4858
+ components: [],
4859
+ totalInstances: 0,
4860
+ connectedClients: 0,
4861
+ timestamp: new Date().toISOString(),
4862
+ message: "Server stopped."
4863
+ });
4864
+ }
4865
+ pendingInspections.clear();
4808
4866
  if (refreshTimeout) {
4809
4867
  clearTimeout(refreshTimeout);
4810
4868
  refreshTimeout = null;
@@ -0,0 +1,59 @@
1
+ interface QuerySnapshot {
2
+ data: SerializedValue;
3
+ loading: boolean;
4
+ revalidating: boolean;
5
+ error: SerializedValue;
6
+ idle: boolean;
7
+ key?: string;
8
+ }
9
+ interface InstanceSnapshot {
10
+ index: number;
11
+ key?: string;
12
+ signals: Record<string, SerializedValue>;
13
+ queries: Record<string, QuerySnapshot>;
14
+ }
15
+ interface ComponentSnapshot {
16
+ name: string;
17
+ moduleId: string;
18
+ instanceCount: number;
19
+ instances: InstanceSnapshot[];
20
+ }
21
+ interface StateSnapshot {
22
+ components: ComponentSnapshot[];
23
+ totalInstances: number;
24
+ connectedClients: number;
25
+ timestamp: string;
26
+ message?: string;
27
+ truncated?: boolean;
28
+ }
29
+ type SerializedValue = string | number | boolean | null | object;
30
+ /**
31
+ * Serialize any JavaScript value to a JSON-safe representation.
32
+ * Handles functions, DOM nodes, circular references, Date, Map, Set,
33
+ * Error, Promise, Symbol, WeakRef, ArrayBuffer, and depth limiting.
34
+ */
35
+ declare function safeSerialize(value: unknown, maxDepth?: number, seen?: WeakSet<object>): SerializedValue;
36
+ /**
37
+ * Walk the Fast Refresh registry and collect a state snapshot.
38
+ * Optionally filter by component function name (case-sensitive).
39
+ */
40
+ declare function collectStateSnapshot(filter?: string): StateSnapshot;
41
+ /**
42
+ * Handle incoming `inspect-state` WebSocket messages.
43
+ * Collects a state snapshot and sends it back with the matching requestId.
44
+ */
45
+ declare function handleInspectMessage(event: MessageEvent, ws: WebSocket): void;
46
+ /**
47
+ * Set up the state inspector's WebSocket listener.
48
+ *
49
+ * Uses `addEventListener` instead of replacing `onmessage` to coexist with
50
+ * the error overlay handler. Polls for `__vertz_overlay._ws` reference changes
51
+ * so that reconnections (which create a new WebSocket instance) are re-hooked.
52
+ *
53
+ * NOTE: When multiple browser tabs are connected, the server broadcasts
54
+ * inspect-state to all. The first response wins — other tabs' responses are
55
+ * dropped. This is acceptable for v0.1.x; a future improvement could merge
56
+ * responses from multiple tabs.
57
+ */
58
+ declare function setupStateInspector(): void;
59
+ export { setupStateInspector, safeSerialize, handleInspectMessage, collectStateSnapshot, StateSnapshot, QuerySnapshot, InstanceSnapshot, ComponentSnapshot };
@@ -0,0 +1,270 @@
1
+ // @bun
2
+ import"../shared/chunk-pshsm7ck.js";
3
+
4
+ // src/bun-plugin/state-inspector.ts
5
+ var REGISTRY_KEY = Symbol.for("vertz:fast-refresh:registry");
6
+ var MAX_RESPONSE_SIZE = 2 * 1024 * 1024;
7
+ var DEFAULT_MAX_DEPTH = 4;
8
+ var QUERY_SIGNAL_NAMES = ["data", "loading", "revalidating", "error", "idle"];
9
+ function safeSerialize(value, maxDepth = DEFAULT_MAX_DEPTH, seen = new WeakSet) {
10
+ if (value === null)
11
+ return null;
12
+ if (value === undefined)
13
+ return null;
14
+ if (typeof value === "boolean")
15
+ return value;
16
+ if (typeof value === "number") {
17
+ if (Number.isNaN(value))
18
+ return "[NaN]";
19
+ if (!Number.isFinite(value))
20
+ return value > 0 ? "[Infinity]" : "[-Infinity]";
21
+ return value;
22
+ }
23
+ if (typeof value === "string")
24
+ return value;
25
+ if (typeof value === "bigint")
26
+ return value.toString();
27
+ if (typeof value === "symbol") {
28
+ const desc = value.description;
29
+ return desc ? `[Symbol: ${desc}]` : "[Symbol]";
30
+ }
31
+ if (typeof value === "function") {
32
+ const name = value.name;
33
+ return name && name !== "anonymous" ? `[Function: ${name}]` : "[Function]";
34
+ }
35
+ const obj = value;
36
+ if (seen.has(obj))
37
+ return "[Circular]";
38
+ if (obj instanceof Date)
39
+ return obj.toISOString();
40
+ if (obj instanceof Error) {
41
+ return { name: obj.name, message: obj.message };
42
+ }
43
+ if (obj instanceof Promise)
44
+ return "[Promise]";
45
+ if (obj instanceof Map)
46
+ return `[Map: ${obj.size} entries]`;
47
+ if (obj instanceof Set)
48
+ return `[Set: ${obj.size} items]`;
49
+ if (obj instanceof WeakMap)
50
+ return "[WeakMap]";
51
+ if (obj instanceof WeakSet)
52
+ return "[WeakSet]";
53
+ if (typeof WeakRef !== "undefined" && obj instanceof WeakRef)
54
+ return "[WeakRef]";
55
+ if (obj instanceof ArrayBuffer)
56
+ return `[ArrayBuffer: ${obj.byteLength} bytes]`;
57
+ if (ArrayBuffer.isView(obj) && "byteLength" in obj) {
58
+ return `[ArrayBuffer: ${obj.byteLength} bytes]`;
59
+ }
60
+ if (typeof HTMLElement !== "undefined" && obj instanceof HTMLElement) {
61
+ return `[HTMLElement: ${obj.tagName}]`;
62
+ }
63
+ if (typeof Node !== "undefined" && obj instanceof Node) {
64
+ return `[Node: ${obj.nodeName}]`;
65
+ }
66
+ if (maxDepth <= 0) {
67
+ if (Array.isArray(obj))
68
+ return `[Array: ${obj.length} items]`;
69
+ return `[Object: ${Object.keys(obj).length} keys]`;
70
+ }
71
+ seen.add(obj);
72
+ if (Array.isArray(obj)) {
73
+ const result2 = obj.map((item) => safeSerialize(item, maxDepth - 1, seen));
74
+ seen.delete(obj);
75
+ return result2;
76
+ }
77
+ const result = {};
78
+ for (const key of Object.keys(obj)) {
79
+ result[key] = safeSerialize(obj[key], maxDepth - 1, seen);
80
+ }
81
+ seen.delete(obj);
82
+ return result;
83
+ }
84
+ function peekSafe(sig) {
85
+ try {
86
+ return sig.peek();
87
+ } catch (e) {
88
+ const msg = e instanceof Error ? e.message : String(e);
89
+ return `[Error: ${msg}]`;
90
+ }
91
+ }
92
+ function collectStateSnapshot(filter) {
93
+ const registry = globalThis[REGISTRY_KEY];
94
+ if (!registry || registry.size === 0) {
95
+ return emptySnapshot(filter ? `${filter} is not in the component registry. Check the name spelling or ensure the file has been loaded.` : undefined);
96
+ }
97
+ const components = [];
98
+ let totalInstances = 0;
99
+ let foundInRegistry = false;
100
+ for (const [moduleId, moduleMap] of registry) {
101
+ for (const [name, record] of moduleMap) {
102
+ if (filter && name !== filter)
103
+ continue;
104
+ if (filter)
105
+ foundInRegistry = true;
106
+ const instances = [];
107
+ for (let i = 0;i < record.instances.length; i++) {
108
+ const inst = record.instances[i];
109
+ if (!inst.element?.isConnected)
110
+ continue;
111
+ const signals = {};
112
+ const queries = {};
113
+ const queryGroups = new Map;
114
+ const standaloneSignals = [];
115
+ for (const sig of inst.signals) {
116
+ const group = sig._queryGroup;
117
+ if (group) {
118
+ if (!queryGroups.has(group))
119
+ queryGroups.set(group, []);
120
+ queryGroups.get(group).push(sig);
121
+ } else {
122
+ standaloneSignals.push(sig);
123
+ }
124
+ }
125
+ let positionalIdx = 0;
126
+ for (const sig of standaloneSignals) {
127
+ const key = sig._hmrKey ?? `signal_${positionalIdx++}`;
128
+ signals[key] = safeSerialize(peekSafe(sig));
129
+ }
130
+ for (const [groupKey, groupSignals] of queryGroups) {
131
+ queries[groupKey] = buildQuerySnapshot(groupSignals, groupKey);
132
+ }
133
+ instances.push({ index: i, signals, queries });
134
+ totalInstances++;
135
+ }
136
+ if (instances.length > 0) {
137
+ components.push({ name, moduleId, instanceCount: instances.length, instances });
138
+ } else if (filter && foundInRegistry) {}
139
+ }
140
+ }
141
+ let message;
142
+ if (filter && components.length === 0) {
143
+ if (foundInRegistry) {
144
+ let moduleId = "";
145
+ for (const [mid, moduleMap] of registry) {
146
+ if (moduleMap.has(filter)) {
147
+ moduleId = mid;
148
+ break;
149
+ }
150
+ }
151
+ message = `${filter} is registered (in ${moduleId}) but has 0 mounted instances on the current page. Navigate to a page that renders it.`;
152
+ } else {
153
+ message = `${filter} is not in the component registry. Check the name spelling or ensure the file has been loaded.`;
154
+ }
155
+ }
156
+ const snapshot = {
157
+ components,
158
+ totalInstances,
159
+ connectedClients: 0,
160
+ timestamp: new Date().toISOString(),
161
+ ...message ? { message } : {}
162
+ };
163
+ const jsonStr = JSON.stringify(snapshot);
164
+ if (jsonStr.length > MAX_RESPONSE_SIZE) {
165
+ return truncateSnapshot(snapshot);
166
+ }
167
+ return snapshot;
168
+ }
169
+ function emptySnapshot(message) {
170
+ return {
171
+ components: [],
172
+ totalInstances: 0,
173
+ connectedClients: 0,
174
+ timestamp: new Date().toISOString(),
175
+ ...message ? { message } : {}
176
+ };
177
+ }
178
+ function buildQuerySnapshot(signals, groupKey) {
179
+ const named = new Map;
180
+ const unnamed = [];
181
+ for (const sig of signals) {
182
+ const val = peekSafe(sig);
183
+ if (sig._hmrKey && QUERY_SIGNAL_NAMES.includes(sig._hmrKey)) {
184
+ named.set(sig._hmrKey, val);
185
+ } else {
186
+ unnamed.push(val);
187
+ }
188
+ }
189
+ return {
190
+ data: safeSerialize(named.get("data") ?? unnamed[0] ?? null),
191
+ loading: Boolean(named.get("loading") ?? unnamed[1] ?? false),
192
+ revalidating: Boolean(named.get("revalidating") ?? unnamed[2] ?? false),
193
+ error: safeSerialize(named.get("error") ?? unnamed[3] ?? null),
194
+ idle: Boolean(named.get("idle") ?? unnamed[4] ?? false),
195
+ key: groupKey
196
+ };
197
+ }
198
+ function handleInspectMessage(event, ws) {
199
+ if (typeof event.data !== "string")
200
+ return;
201
+ try {
202
+ const msg = JSON.parse(event.data);
203
+ if (msg.type === "inspect-state") {
204
+ const snapshot = collectStateSnapshot(msg.filter ?? undefined);
205
+ ws.send(JSON.stringify({
206
+ type: "state-snapshot",
207
+ requestId: msg.requestId,
208
+ snapshot
209
+ }));
210
+ }
211
+ } catch {}
212
+ }
213
+ function setupStateInspector() {
214
+ if (typeof window === "undefined")
215
+ return;
216
+ let currentWs = null;
217
+ const MAX_INIT_RETRIES = 10;
218
+ let initRetries = 0;
219
+ function hookWs(ws) {
220
+ if (ws === currentWs)
221
+ return;
222
+ currentWs = ws;
223
+ ws.addEventListener("message", (event) => {
224
+ handleInspectMessage(event, ws);
225
+ });
226
+ }
227
+ function poll() {
228
+ const overlay = window.__vertz_overlay;
229
+ if (!overlay) {
230
+ if (initRetries++ < MAX_INIT_RETRIES) {
231
+ setTimeout(poll, 500);
232
+ }
233
+ return;
234
+ }
235
+ const checkWs = () => {
236
+ if (overlay._ws && overlay._ws !== currentWs) {
237
+ hookWs(overlay._ws);
238
+ }
239
+ };
240
+ checkWs();
241
+ setInterval(checkWs, 2000);
242
+ }
243
+ poll();
244
+ }
245
+ if (typeof document !== "undefined") {
246
+ if (document.readyState === "loading") {
247
+ document.addEventListener("DOMContentLoaded", setupStateInspector);
248
+ } else {
249
+ setupStateInspector();
250
+ }
251
+ }
252
+ function truncateSnapshot(snapshot) {
253
+ const truncated = {
254
+ ...snapshot,
255
+ truncated: true,
256
+ components: snapshot.components.map((comp) => ({
257
+ ...comp,
258
+ instances: comp.instances.slice(0, 3),
259
+ instanceCount: comp.instanceCount
260
+ }))
261
+ };
262
+ truncated.totalInstances = truncated.components.reduce((sum, c) => sum + c.instances.length, 0);
263
+ return truncated;
264
+ }
265
+ export {
266
+ setupStateInspector,
267
+ safeSerialize,
268
+ handleInspectMessage,
269
+ collectStateSnapshot
270
+ };
package/dist/index.d.ts CHANGED
@@ -536,6 +536,28 @@ interface SSRHandlerOptions {
536
536
  * (JSON files, third-party APIs, custom DB clients).
537
537
  */
538
538
  aotDataResolver?: AotDataResolver;
539
+ /**
540
+ * Derive attributes to set on the `<html>` tag from the incoming request.
541
+ *
542
+ * Useful for setting `data-theme`, `dir`, `lang`, or other attributes that
543
+ * must be on `<html>` to avoid FOUC. The callback runs before SSR rendering
544
+ * so the attributes are available in the first byte of the response.
545
+ *
546
+ * If the template already has an attribute with the same name, the callback's
547
+ * value overrides it. Values are HTML-escaped automatically. Keys must be
548
+ * valid HTML attribute names (`/^[a-zA-Z][a-zA-Z0-9-]*$/`).
549
+ *
550
+ * Return `undefined`, `null`, or `{}` to skip injection.
551
+ *
552
+ * @example
553
+ * ```ts
554
+ * htmlAttributes: (request) => ({
555
+ * 'data-theme': getThemeCookie(request) ?? 'dark',
556
+ * dir: getDirection(request),
557
+ * })
558
+ * ```
559
+ */
560
+ htmlAttributes?: (request: Request) => Record<string, string> | null | undefined;
539
561
  }
540
562
  declare function createSSRHandler(options: SSRHandlerOptions): (request: Request) => Promise<Response>;
541
563
  type NodeHandlerOptions = SSRHandlerOptions;
@@ -741,6 +763,8 @@ interface AotBarrelResult {
741
763
  * Write each entry as `<tempDir>/<filename>.ts` alongside the barrel.
742
764
  */
743
765
  files: Record<string, string>;
766
+ /** Function names skipped due to residual JSX in compiled output. */
767
+ skippedFns: string[];
744
768
  }
745
769
  /**
746
770
  * Generate a barrel module that re-exports __ssr_* functions from compiled files.
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  createSSRHandler,
3
3
  loadAotManifest
4
- } from "./shared/chunk-ck3n699k.js";
4
+ } from "./shared/chunk-91eg6dps.js";
5
5
  import {
6
6
  createNodeHandler
7
- } from "./shared/chunk-kzycr5v0.js";
7
+ } from "./shared/chunk-1hjzk64k.js";
8
8
  import {
9
9
  clearRouteCssCache,
10
10
  collectStreamChunks,
@@ -34,7 +34,7 @@ import {
34
34
  ssrStreamNavQueries,
35
35
  streamToString,
36
36
  toPrefetchSession
37
- } from "./shared/chunk-tm5aeq94.js";
37
+ } from "./shared/chunk-j9z9r179.js";
38
38
  import {
39
39
  clearGlobalSSRTimeout,
40
40
  createSSRAdapter,
@@ -171578,26 +171578,39 @@ function generateAotBarrel(compiledFiles, routeMap, appEntry) {
171578
171578
  "import { __esc, __esc_attr, __ssr_spread, __ssr_style_object } from '@vertz/ui-server';"
171579
171579
  ];
171580
171580
  const files = {};
171581
+ const skippedFns = [];
171581
171582
  let fileIndex = 0;
171582
171583
  for (const [filePath, fns] of fileToFns) {
171583
171584
  const moduleName = basename(filePath, ".tsx").replace(/[^a-zA-Z0-9_-]/g, "_");
171584
171585
  const tempFileName = `__aot_${fileIndex}_${moduleName}`;
171585
171586
  const moduleRef = `./${tempFileName}.ts`;
171586
- lines.push(`export { ${fns.sort().join(", ")} } from '${moduleRef}';`);
171587
171587
  const compiled = compiledFiles[filePath];
171588
171588
  if (compiled) {
171589
171589
  const helperImport = `import { __esc, __esc_attr, __ssr_spread, __ssr_style_object } from '@vertz/ui-server';
171590
171590
  ` + `import type { SSRAotContext } from '@vertz/ui-server';
171591
171591
  `;
171592
- const extracted = extractSsrFunctions(compiled.code, fns);
171593
- files[`${tempFileName}.ts`] = helperImport + extracted;
171592
+ const cleanFns = [];
171593
+ for (const fn of fns) {
171594
+ const fnCode = extractSsrFunctions(compiled.code, [fn]);
171595
+ if (hasResidualJsx(fnCode)) {
171596
+ skippedFns.push(fn);
171597
+ } else {
171598
+ cleanFns.push(fn);
171599
+ }
171600
+ }
171601
+ if (cleanFns.length > 0) {
171602
+ const extracted = extractSsrFunctions(compiled.code, cleanFns);
171603
+ lines.push(`export { ${cleanFns.sort().join(", ")} } from '${moduleRef}';`);
171604
+ files[`${tempFileName}.ts`] = helperImport + extracted;
171605
+ }
171594
171606
  }
171595
171607
  fileIndex++;
171596
171608
  }
171597
171609
  return {
171598
171610
  barrelSource: lines.join(`
171599
171611
  `),
171600
- files
171612
+ files,
171613
+ skippedFns
171601
171614
  };
171602
171615
  }
171603
171616
  function extractSsrFunctions(code, fnNames) {
@@ -171624,6 +171637,9 @@ function extractSsrFunctions(code, fnNames) {
171624
171637
  return extracted.join(`
171625
171638
  `);
171626
171639
  }
171640
+ function hasResidualJsx(code) {
171641
+ return /<[A-Za-z]\w*\s+[\w-]+=\{/.test(code);
171642
+ }
171627
171643
  function findAppComponent(components) {
171628
171644
  for (const [name, comp] of Object.entries(components)) {
171629
171645
  if (comp.tier === "runtime-fallback")
@@ -261,6 +261,28 @@ interface SSRHandlerOptions {
261
261
  * (JSON files, third-party APIs, custom DB clients).
262
262
  */
263
263
  aotDataResolver?: AotDataResolver;
264
+ /**
265
+ * Derive attributes to set on the `<html>` tag from the incoming request.
266
+ *
267
+ * Useful for setting `data-theme`, `dir`, `lang`, or other attributes that
268
+ * must be on `<html>` to avoid FOUC. The callback runs before SSR rendering
269
+ * so the attributes are available in the first byte of the response.
270
+ *
271
+ * If the template already has an attribute with the same name, the callback's
272
+ * value overrides it. Values are HTML-escaped automatically. Keys must be
273
+ * valid HTML attribute names (`/^[a-zA-Z][a-zA-Z0-9-]*$/`).
274
+ *
275
+ * Return `undefined`, `null`, or `{}` to skip injection.
276
+ *
277
+ * @example
278
+ * ```ts
279
+ * htmlAttributes: (request) => ({
280
+ * 'data-theme': getThemeCookie(request) ?? 'dark',
281
+ * dir: getDirection(request),
282
+ * })
283
+ * ```
284
+ */
285
+ htmlAttributes?: (request: Request) => Record<string, string> | null | undefined;
264
286
  }
265
287
  type NodeHandlerOptions = SSRHandlerOptions;
266
288
  declare function createNodeHandler(options: NodeHandlerOptions): (req: IncomingMessage, res: ServerResponse) => void;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createNodeHandler
3
- } from "./shared/chunk-kzycr5v0.js";
4
- import"./shared/chunk-tm5aeq94.js";
3
+ } from "./shared/chunk-1hjzk64k.js";
4
+ import"./shared/chunk-j9z9r179.js";
5
5
  import"./shared/chunk-szvdd1qq.js";
6
6
  import"./shared/chunk-bt1px3c4.js";
7
7
  export {
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  escapeAttr,
3
+ injectHtmlAttributes,
3
4
  injectIntoTemplate,
4
5
  precomputeHandlerState,
5
6
  resolveRouteModulepreload,
@@ -10,7 +11,7 @@ import {
10
11
  ssrRenderSinglePass,
11
12
  ssrStreamNavQueries,
12
13
  toPrefetchSession
13
- } from "./chunk-tm5aeq94.js";
14
+ } from "./chunk-j9z9r179.js";
14
15
 
15
16
  // src/node-handler.ts
16
17
  function createNodeHandler(options) {
@@ -25,7 +26,8 @@ function createNodeHandler(options) {
25
26
  manifest,
26
27
  progressiveHTML,
27
28
  aotManifest,
28
- aotDataResolver
29
+ aotDataResolver,
30
+ htmlAttributes
29
31
  } = options;
30
32
  const { template, linkHeader, modulepreloadTags, splitResult } = precomputeHandlerState(options);
31
33
  const useProgressive = progressiveHTML && splitResult && !(manifest?.routeEntries && Object.keys(manifest.routeEntries).length > 0);
@@ -38,20 +40,37 @@ function createNodeHandler(options) {
38
40
  await handleNavRequest(req, res, module, pathname, ssrTimeout);
39
41
  return;
40
42
  }
41
- let sessionScript = "";
42
- let ssrAuth;
43
- if (sessionResolver) {
43
+ let webRequest;
44
+ if (sessionResolver || htmlAttributes) {
44
45
  const fullUrl = `http://${req.headers.host ?? "localhost"}${url}`;
45
- const webRequest = new Request(fullUrl, {
46
+ webRequest = new Request(fullUrl, {
46
47
  method: req.method ?? "GET",
47
48
  headers: req.headers
48
49
  });
50
+ }
51
+ let sessionScript = "";
52
+ let ssrAuth;
53
+ if (sessionResolver && webRequest) {
49
54
  const result2 = await resolveSession(webRequest, sessionResolver, nonce);
50
55
  sessionScript = result2.sessionScript;
51
56
  ssrAuth = result2.ssrAuth;
52
57
  }
58
+ let requestTemplate = template;
59
+ let requestHeadTemplate = splitResult?.headTemplate;
60
+ if (htmlAttributes && webRequest) {
61
+ const attrs = htmlAttributes(webRequest);
62
+ if (attrs && Object.keys(attrs).length > 0) {
63
+ requestTemplate = injectHtmlAttributes(template, attrs);
64
+ if (requestHeadTemplate) {
65
+ requestHeadTemplate = injectHtmlAttributes(requestHeadTemplate, attrs);
66
+ }
67
+ }
68
+ }
53
69
  if (useProgressive) {
54
- await handleProgressiveRequest(req, res, module, splitResult, url, ssrTimeout, nonce, fallbackMetrics, linkHeader, modulepreloadTags, routeChunkManifest, cacheControl, sessionScript, ssrAuth, manifest);
70
+ await handleProgressiveRequest(req, res, module, {
71
+ headTemplate: requestHeadTemplate ?? splitResult.headTemplate,
72
+ tailTemplate: splitResult.tailTemplate
73
+ }, url, ssrTimeout, nonce, fallbackMetrics, linkHeader, modulepreloadTags, routeChunkManifest, cacheControl, sessionScript, ssrAuth, manifest);
55
74
  return;
56
75
  }
57
76
  const prefetchSession = ssrAuth ? toPrefetchSession(ssrAuth) : undefined;
@@ -79,7 +98,7 @@ function createNodeHandler(options) {
79
98
  const allHeadTags = [result.headTags, resolvedModulepreloadTags].filter(Boolean).join(`
80
99
  `);
81
100
  const html = injectIntoTemplate({
82
- template,
101
+ template: requestTemplate,
83
102
  appHtml: result.html,
84
103
  appCss: result.css,
85
104
  ssrData: result.ssrData,
@@ -94,7 +113,8 @@ function createNodeHandler(options) {
94
113
  headers.Link = linkHeader;
95
114
  if (cacheControl)
96
115
  headers["Cache-Control"] = cacheControl;
97
- res.writeHead(200, headers);
116
+ const status = result.matchedRoutePatterns?.length ? 200 : 404;
117
+ res.writeHead(status, headers);
98
118
  res.end(html);
99
119
  } catch (err) {
100
120
  console.error("[SSR] Render failed:", err instanceof Error ? err.message : err);
@@ -153,7 +173,8 @@ async function handleProgressiveRequest(req, res, module, split, url, ssrTimeout
153
173
  headers.Link = linkHeader;
154
174
  if (cacheControl)
155
175
  headers["Cache-Control"] = cacheControl;
156
- res.writeHead(200, headers);
176
+ const status = result.matchedRoutePatterns?.length ? 200 : 404;
177
+ res.writeHead(status, headers);
157
178
  res.write(headChunk);
158
179
  let clientDisconnected = false;
159
180
  req.on("close", () => {
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  encodeChunk,
3
3
  escapeAttr,
4
+ injectHtmlAttributes,
4
5
  injectIntoTemplate,
5
6
  precomputeHandlerState,
6
7
  resolveRouteModulepreload,
@@ -11,7 +12,7 @@ import {
11
12
  ssrRenderSinglePass,
12
13
  ssrStreamNavQueries,
13
14
  toPrefetchSession
14
- } from "./chunk-tm5aeq94.js";
15
+ } from "./chunk-j9z9r179.js";
15
16
 
16
17
  // src/aot-manifest-loader.ts
17
18
  import { existsSync, readFileSync } from "node:fs";
@@ -108,7 +109,7 @@ function buildProgressiveResponse(options) {
108
109
  "Content-Type": "text/html; charset=utf-8",
109
110
  ...headers
110
111
  };
111
- return new Response(stream, { status: 200, headers: responseHeaders });
112
+ return new Response(stream, { status: options.status ?? 200, headers: responseHeaders });
112
113
  }
113
114
 
114
115
  // src/ssr-handler.ts
@@ -124,7 +125,8 @@ function createSSRHandler(options) {
124
125
  manifest,
125
126
  progressiveHTML,
126
127
  aotManifest,
127
- aotDataResolver
128
+ aotDataResolver,
129
+ htmlAttributes
128
130
  } = options;
129
131
  const { template, linkHeader, modulepreloadTags, splitResult } = precomputeHandlerState(options);
130
132
  return async (request) => {
@@ -141,11 +143,33 @@ function createSSRHandler(options) {
141
143
  ssrAuth = result.ssrAuth;
142
144
  }
143
145
  const cookies = request.headers.get("Cookie") ?? undefined;
146
+ let requestTemplate = template;
147
+ let requestHeadTemplate = splitResult?.headTemplate;
148
+ if (htmlAttributes) {
149
+ try {
150
+ const attrs = htmlAttributes(request);
151
+ if (attrs && Object.keys(attrs).length > 0) {
152
+ requestTemplate = injectHtmlAttributes(template, attrs);
153
+ if (requestHeadTemplate) {
154
+ requestHeadTemplate = injectHtmlAttributes(requestHeadTemplate, attrs);
155
+ }
156
+ }
157
+ } catch (err) {
158
+ console.error("[SSR] htmlAttributes failed:", err instanceof Error ? err.message : err);
159
+ return new Response("Internal Server Error", {
160
+ status: 500,
161
+ headers: { "Content-Type": "text/plain" }
162
+ });
163
+ }
164
+ }
144
165
  const useProgressive = progressiveHTML && splitResult && !(manifest?.routeEntries && Object.keys(manifest.routeEntries).length > 0);
145
166
  if (useProgressive) {
146
- return handleProgressiveHTMLRequest(module, splitResult, pathname + url.search, ssrTimeout, nonce, fallbackMetrics, linkHeader, modulepreloadTags, routeChunkManifest, cacheControl, sessionScript, ssrAuth, manifest, cookies);
167
+ return handleProgressiveHTMLRequest(module, {
168
+ headTemplate: requestHeadTemplate ?? splitResult.headTemplate,
169
+ tailTemplate: splitResult.tailTemplate
170
+ }, pathname + url.search, ssrTimeout, nonce, fallbackMetrics, linkHeader, modulepreloadTags, routeChunkManifest, cacheControl, sessionScript, ssrAuth, manifest, cookies);
147
171
  }
148
- return handleHTMLRequest(module, template, pathname + url.search, ssrTimeout, nonce, fallbackMetrics, linkHeader, modulepreloadTags, routeChunkManifest, cacheControl, sessionScript, ssrAuth, manifest, aotManifest, aotDataResolver, cookies);
172
+ return handleHTMLRequest(module, requestTemplate, pathname + url.search, ssrTimeout, nonce, fallbackMetrics, linkHeader, modulepreloadTags, routeChunkManifest, cacheControl, sessionScript, ssrAuth, manifest, aotManifest, aotDataResolver, cookies);
149
173
  };
150
174
  }
151
175
  async function handleNavRequest(module, url, ssrTimeout) {
@@ -219,13 +243,15 @@ async function handleProgressiveHTMLRequest(module, split, url, ssrTimeout, nonc
219
243
  headers.Link = linkHeader;
220
244
  if (cacheControl)
221
245
  headers["Cache-Control"] = cacheControl;
246
+ const status = result.matchedRoutePatterns?.length ? 200 : 404;
222
247
  return buildProgressiveResponse({
223
248
  headChunk,
224
249
  renderStream: result.renderStream,
225
250
  tailChunk: split.tailTemplate,
226
251
  ssrData: result.ssrData,
227
252
  nonce,
228
- headers
253
+ headers,
254
+ status
229
255
  });
230
256
  } catch (err) {
231
257
  console.error("[SSR] Render failed:", err instanceof Error ? err.message : err);
@@ -278,7 +304,8 @@ async function handleHTMLRequest(module, template, url, ssrTimeout, nonce, fallb
278
304
  headers.Link = linkHeader;
279
305
  if (cacheControl)
280
306
  headers["Cache-Control"] = cacheControl;
281
- return new Response(html, { status: 200, headers });
307
+ const status = result.matchedRoutePatterns?.length ? 200 : 404;
308
+ return new Response(html, { status, headers });
282
309
  } catch (err) {
283
310
  console.error("[SSR] Render failed:", err instanceof Error ? err.message : err);
284
311
  return new Response("Internal Server Error", {
@@ -1050,9 +1050,10 @@ async function ssrRenderAot(module, url, options) {
1050
1050
  try {
1051
1051
  setGlobalSSRTimeout(ssrTimeout);
1052
1052
  const holes = createHoles(aotEntry.holes, module, normalizedUrl, queryCache, options.ssrAuth);
1053
+ const resolveKey = (key) => key.includes("${") ? resolveParamQueryKeys([key], match.params, searchParams)[0] : key;
1053
1054
  const ctx = {
1054
1055
  holes,
1055
- getData: (key) => queryCache.get(key),
1056
+ getData: (key) => queryCache.get(resolveKey(key)),
1056
1057
  session: options.prefetchSession,
1057
1058
  params: match.params,
1058
1059
  searchParams
@@ -1075,7 +1076,7 @@ async function ssrRenderAot(module, url, options) {
1075
1076
  appHoles.RouterView = () => html;
1076
1077
  const appCtx = {
1077
1078
  holes: appHoles,
1078
- getData: (key) => queryCache.get(key),
1079
+ getData: (key) => queryCache.get(resolveKey(key)),
1079
1080
  session: options.prefetchSession,
1080
1081
  params: match.params,
1081
1082
  searchParams
@@ -1158,7 +1159,7 @@ function resolveParamQueryKeys(queryKeys, params, searchParams) {
1158
1159
  return value || defaultVal;
1159
1160
  }
1160
1161
  return value ?? "";
1161
- }).replace(/\$\{(\w+)\}/g, (_, paramName) => params[paramName] ?? ""));
1162
+ }).replace(/\$\{(\w+)\}/g, (_, paramName) => params[paramName] ?? searchParams?.get(paramName) ?? ""));
1162
1163
  }
1163
1164
  function extractSearchParams(url) {
1164
1165
  const qIdx = url.indexOf("?");
@@ -1461,6 +1462,39 @@ async function resolveSession(request, sessionResolver, nonce) {
1461
1462
  }
1462
1463
 
1463
1464
  // src/template-inject.ts
1465
+ var VALID_ATTR_KEY = /^[a-zA-Z][a-zA-Z0-9-]*$/;
1466
+ function injectHtmlAttributes(template, attrs) {
1467
+ const entries = Object.entries(attrs);
1468
+ if (entries.length === 0)
1469
+ return template;
1470
+ for (const [key] of entries) {
1471
+ if (!VALID_ATTR_KEY.test(key)) {
1472
+ throw new Error(`Invalid HTML attribute key: "${key}"`);
1473
+ }
1474
+ }
1475
+ const htmlTagMatch = template.match(/<html(\s[^>]*)?>|<html>/i);
1476
+ if (!htmlTagMatch || htmlTagMatch.index == null)
1477
+ return template;
1478
+ const existingAttrsStr = htmlTagMatch[1] ?? "";
1479
+ const existingAttrs = parseHtmlTagAttrs(existingAttrsStr);
1480
+ const merged = { ...existingAttrs };
1481
+ for (const [key, value] of entries) {
1482
+ merged[key] = escapeAttr(value);
1483
+ }
1484
+ const attrStr = Object.entries(merged).map(([k, v]) => v === "" && !(k in attrs) ? ` ${k}` : ` ${k}="${v}"`).join("");
1485
+ const originalTag = htmlTagMatch[0].match(/^<([a-zA-Z]+)/i)[1];
1486
+ const tagEnd = htmlTagMatch.index + htmlTagMatch[0].length;
1487
+ return template.slice(0, htmlTagMatch.index) + `<${originalTag}${attrStr}>` + template.slice(tagEnd);
1488
+ }
1489
+ function parseHtmlTagAttrs(attrStr) {
1490
+ const attrs = {};
1491
+ const re = /([a-zA-Z][a-zA-Z0-9-]*)(?:\s*=\s*"([^"]*)"|(?:\s*=\s*'([^']*)'))?/g;
1492
+ let m;
1493
+ while ((m = re.exec(attrStr)) !== null) {
1494
+ attrs[m[1]] = m[2] ?? m[3] ?? "";
1495
+ }
1496
+ return attrs;
1497
+ }
1464
1498
  function injectIntoTemplate(options) {
1465
1499
  const { template, appHtml, appCss, ssrData, nonce, headTags, sessionScript } = options;
1466
1500
  let html;
@@ -1523,4 +1557,4 @@ function replaceAppDivContent(template, appHtml) {
1523
1557
  return template.slice(0, contentStart) + appHtml + template.slice(i);
1524
1558
  }
1525
1559
 
1526
- export { escapeHtml, escapeAttr, serializeToHtml, toPrefetchSession, evaluateAccessRule, reconstructDescriptors, compileThemeCached, createRequestContext, matchUrlToPatterns, resetSlotCounter, createSlotPlaceholder, encodeChunk, streamToString, collectStreamChunks, createTemplateChunk, renderToStream, safeSerialize, getStreamingRuntimeScript, createSSRDataChunk, ssrRenderSinglePass, ssrRenderProgressive, ssrStreamNavQueries, createHoles, ssrRenderAot, isAotDebugEnabled, clearRouteCssCache, getAccessSetForSSR, createAccessSetScript, createSessionScript, resolveRouteModulepreload, precomputeHandlerState, resolveSession, injectIntoTemplate };
1560
+ export { escapeHtml, escapeAttr, serializeToHtml, toPrefetchSession, evaluateAccessRule, reconstructDescriptors, compileThemeCached, createRequestContext, matchUrlToPatterns, resetSlotCounter, createSlotPlaceholder, encodeChunk, streamToString, collectStreamChunks, createTemplateChunk, renderToStream, safeSerialize, getStreamingRuntimeScript, createSSRDataChunk, ssrRenderSinglePass, ssrRenderProgressive, ssrStreamNavQueries, createHoles, ssrRenderAot, isAotDebugEnabled, clearRouteCssCache, getAccessSetForSSR, createAccessSetScript, createSessionScript, resolveRouteModulepreload, precomputeHandlerState, resolveSession, injectHtmlAttributes, injectIntoTemplate };
@@ -395,6 +395,28 @@ interface SSRHandlerOptions {
395
395
  * (JSON files, third-party APIs, custom DB clients).
396
396
  */
397
397
  aotDataResolver?: AotDataResolver;
398
+ /**
399
+ * Derive attributes to set on the `<html>` tag from the incoming request.
400
+ *
401
+ * Useful for setting `data-theme`, `dir`, `lang`, or other attributes that
402
+ * must be on `<html>` to avoid FOUC. The callback runs before SSR rendering
403
+ * so the attributes are available in the first byte of the response.
404
+ *
405
+ * If the template already has an attribute with the same name, the callback's
406
+ * value overrides it. Values are HTML-escaped automatically. Keys must be
407
+ * valid HTML attribute names (`/^[a-zA-Z][a-zA-Z0-9-]*$/`).
408
+ *
409
+ * Return `undefined`, `null`, or `{}` to skip injection.
410
+ *
411
+ * @example
412
+ * ```ts
413
+ * htmlAttributes: (request) => ({
414
+ * 'data-theme': getThemeCookie(request) ?? 'dark',
415
+ * dir: getDirection(request),
416
+ * })
417
+ * ```
418
+ */
419
+ htmlAttributes?: (request: Request) => Record<string, string> | null | undefined;
398
420
  }
399
421
  declare function createSSRHandler(options: SSRHandlerOptions): (request: Request) => Promise<Response>;
400
422
  interface InjectIntoTemplateOptions {
package/dist/ssr/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  createSSRHandler,
3
3
  loadAotManifest
4
- } from "../shared/chunk-ck3n699k.js";
4
+ } from "../shared/chunk-91eg6dps.js";
5
5
  import {
6
6
  injectIntoTemplate,
7
7
  ssrRenderSinglePass,
8
8
  ssrStreamNavQueries
9
- } from "../shared/chunk-tm5aeq94.js";
9
+ } from "../shared/chunk-j9z9r179.js";
10
10
  import"../shared/chunk-szvdd1qq.js";
11
11
  import"../shared/chunk-bt1px3c4.js";
12
12
  // src/prerender.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertz/ui-server",
3
- "version": "0.2.47",
3
+ "version": "0.2.49",
4
4
  "description": "Vertz UI server-side rendering runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,6 +39,10 @@
39
39
  "import": "./dist/bun-plugin/fast-refresh-runtime.js",
40
40
  "types": "./dist/bun-plugin/fast-refresh-runtime.d.ts"
41
41
  },
42
+ "./state-inspector": {
43
+ "import": "./dist/bun-plugin/state-inspector.js",
44
+ "types": "./dist/bun-plugin/state-inspector.d.ts"
45
+ },
42
46
  "./bun-dev-server": {
43
47
  "import": "./dist/bun-dev-server.js",
44
48
  "types": "./dist/bun-dev-server.d.ts"
@@ -57,7 +61,7 @@
57
61
  "provenance": true
58
62
  },
59
63
  "scripts": {
60
- "build": "bunup",
64
+ "build": "vtzx bunup",
61
65
  "test": "bun test --timeout 60000 src/",
62
66
  "test:integration": "bun test src/__tests__/bun-dev-server.integration.local.ts",
63
67
  "test:e2e": "bunx playwright test",
@@ -67,15 +71,15 @@
67
71
  "@ampproject/remapping": "^2.3.0",
68
72
  "@capsizecss/unpack": "^4.0.0",
69
73
  "@jridgewell/trace-mapping": "^0.3.31",
70
- "@vertz/core": "^0.2.46",
71
- "@vertz/ui": "^0.2.46",
74
+ "@vertz/core": "^0.2.48",
75
+ "@vertz/ui": "^0.2.48",
72
76
  "magic-string": "^0.30.0",
73
77
  "sharp": "^0.34.5"
74
78
  },
75
79
  "devDependencies": {
76
80
  "@happy-dom/global-registrator": "^20.8.3",
77
81
  "@playwright/test": "^1.58.2",
78
- "@vertz/codegen": "^0.2.46",
82
+ "@vertz/codegen": "^0.2.48",
79
83
  "bun-types": "^1.3.10",
80
84
  "bunup": "^0.16.31",
81
85
  "happy-dom": "^20.8.3",