@vertz/ui-server 0.2.0 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,58 +1,36 @@
1
- // src/html-serializer.ts
2
- var VOID_ELEMENTS = new Set([
3
- "area",
4
- "base",
5
- "br",
6
- "col",
7
- "embed",
8
- "hr",
9
- "img",
10
- "input",
11
- "link",
12
- "meta",
13
- "param",
14
- "source",
15
- "track",
16
- "wbr"
17
- ]);
18
- var RAW_TEXT_ELEMENTS = new Set(["script", "style"]);
19
- function escapeHtml(text) {
20
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
21
- }
22
- function escapeAttr(value) {
23
- return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
24
- }
25
- function serializeAttrs(attrs) {
26
- const parts = [];
27
- for (const [key, value] of Object.entries(attrs)) {
28
- parts.push(` ${key}="${escapeAttr(value)}"`);
29
- }
30
- return parts.join("");
31
- }
32
- function isRawHtml(value) {
33
- return typeof value === "object" && "__raw" in value && value.__raw === true;
34
- }
35
- function serializeToHtml(node) {
36
- if (typeof node === "string") {
37
- return escapeHtml(node);
38
- }
39
- if (isRawHtml(node)) {
40
- return node.html;
41
- }
42
- const { tag, attrs, children } = node;
43
- const attrStr = serializeAttrs(attrs);
44
- if (VOID_ELEMENTS.has(tag)) {
45
- return `<${tag}${attrStr}>`;
46
- }
47
- const isRawText = RAW_TEXT_ELEMENTS.has(tag);
48
- const childrenHtml = children.map((child) => {
49
- if (typeof child === "string" && isRawText) {
50
- return child;
51
- }
52
- return serializeToHtml(child);
53
- }).join("");
54
- return `<${tag}${attrStr}>${childrenHtml}</${tag}>`;
55
- }
1
+ import {
2
+ clearGlobalSSRTimeout,
3
+ collectStreamChunks,
4
+ createSSRDataChunk,
5
+ createSSRHandler,
6
+ createSlotPlaceholder,
7
+ createTemplateChunk,
8
+ encodeChunk,
9
+ escapeAttr,
10
+ escapeHtml,
11
+ getGlobalSSRTimeout,
12
+ getSSRQueries,
13
+ getSSRUrl,
14
+ getStreamingRuntimeScript,
15
+ isInSSR,
16
+ registerSSRQuery,
17
+ renderToStream,
18
+ resetSlotCounter,
19
+ safeSerialize,
20
+ serializeToHtml,
21
+ setGlobalSSRTimeout,
22
+ ssrDiscoverQueries,
23
+ ssrRenderToString,
24
+ ssrStorage,
25
+ streamToString
26
+ } from "./shared/chunk-32688jav.js";
27
+ import {
28
+ SSRElement,
29
+ createSSRAdapter,
30
+ installDomShim,
31
+ rawHtml,
32
+ removeDomShim
33
+ } from "./shared/chunk-4t0ekdyv.js";
56
34
 
57
35
  // src/asset-pipeline.ts
58
36
  function renderAssetTags(assets) {
@@ -79,136 +57,6 @@ function inlineCriticalCss(css) {
79
57
  const safeCss = css.replace(/<\/style>/gi, "<\\/style>");
80
58
  return `<style>${safeCss}</style>`;
81
59
  }
82
- // src/dev-server.ts
83
- import { createServer as createHttpServer } from "node:http";
84
- import { InternalServerErrorException } from "@vertz/server";
85
- import { createServer as createViteServer } from "vite";
86
- function createDevServer(options) {
87
- const {
88
- entry,
89
- port = 5173,
90
- host = "0.0.0.0",
91
- viteConfig = {},
92
- middleware,
93
- skipModuleInvalidation = false,
94
- logRequests = true
95
- } = options;
96
- let vite;
97
- let httpServer;
98
- const listen = async () => {
99
- if (logRequests) {
100
- console.log("[Server] Starting Vite SSR dev server...");
101
- }
102
- try {
103
- vite = await createViteServer({
104
- ...viteConfig,
105
- server: {
106
- ...viteConfig.server,
107
- middlewareMode: true
108
- },
109
- appType: "custom"
110
- });
111
- if (logRequests) {
112
- console.log("[Server] Vite dev server created");
113
- }
114
- } catch (err) {
115
- console.error("[Server] Failed to create Vite server:", err);
116
- throw err;
117
- }
118
- if (middleware) {
119
- vite.middlewares.use(middleware);
120
- }
121
- vite.middlewares.use(async (req, res, next) => {
122
- const url = req.url || "/";
123
- try {
124
- if (url.startsWith("/@") || url.startsWith("/node_modules")) {
125
- return next();
126
- }
127
- if (url === entry || url.startsWith("/src/")) {
128
- return next();
129
- }
130
- if (logRequests) {
131
- console.log(`[Server] Rendering: ${url}`);
132
- }
133
- if (!skipModuleInvalidation) {
134
- for (const mod of vite.moduleGraph.idToModuleMap.values()) {
135
- if (mod.ssrModule) {
136
- vite.moduleGraph.invalidateModule(mod);
137
- }
138
- }
139
- }
140
- const entryModule = await vite.ssrLoadModule(entry);
141
- if (!entryModule.renderToString) {
142
- throw new InternalServerErrorException(`Entry module "${entry}" does not export a renderToString function`);
143
- }
144
- const html = await entryModule.renderToString(url);
145
- const transformedHtml = await vite.transformIndexHtml(url, html);
146
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
147
- res.end(transformedHtml);
148
- } catch (err) {
149
- console.error("[Server] SSR error:", err);
150
- if (err instanceof Error) {
151
- vite.ssrFixStacktrace(err);
152
- }
153
- res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
154
- res.end(err.stack || String(err));
155
- }
156
- });
157
- httpServer = createHttpServer(vite.middlewares);
158
- httpServer.on("error", (err) => {
159
- if (err.code === "EADDRINUSE") {
160
- console.error(`[Server] Port ${port} is already in use`);
161
- } else {
162
- console.error(`[Server] Server error:`, err);
163
- }
164
- process.exit(1);
165
- });
166
- await new Promise((resolve) => {
167
- httpServer.listen(port, host, () => {
168
- if (logRequests) {
169
- console.log(`[Server] Running at http://${host}:${port}`);
170
- console.log(`[Server] Local: http://localhost:${port}`);
171
- }
172
- resolve();
173
- });
174
- });
175
- const shutdown = () => {
176
- if (logRequests) {
177
- console.log("[Server] Shutting down...");
178
- }
179
- httpServer.close();
180
- vite.close();
181
- process.exit(0);
182
- };
183
- process.on("SIGTERM", shutdown);
184
- process.on("SIGINT", shutdown);
185
- };
186
- const close = async () => {
187
- if (httpServer) {
188
- await new Promise((resolve, reject) => {
189
- httpServer.close((err) => {
190
- if (err)
191
- reject(err);
192
- else
193
- resolve();
194
- });
195
- });
196
- }
197
- if (vite) {
198
- await vite.close();
199
- }
200
- };
201
- return {
202
- listen,
203
- close,
204
- get vite() {
205
- return vite;
206
- },
207
- get httpServer() {
208
- return httpServer;
209
- }
210
- };
211
- }
212
60
  // src/head.ts
213
61
  class HeadCollector {
214
62
  entries = [];
@@ -263,141 +111,286 @@ function wrapWithHydrationMarkers(node, options) {
263
111
  children: newChildren
264
112
  };
265
113
  }
266
- // src/slot-placeholder.ts
267
- var slotCounter = 0;
268
- function resetSlotCounter() {
269
- slotCounter = 0;
270
- }
271
- function createSlotPlaceholder(fallback) {
272
- const id = slotCounter++;
273
- const placeholder = {
274
- tag: "div",
275
- attrs: { id: `v-slot-${id}` },
276
- children: typeof fallback === "string" ? [fallback] : [fallback],
277
- _slotId: id
278
- };
279
- return placeholder;
280
- }
281
-
282
- // src/streaming.ts
283
- var encoder = new TextEncoder;
284
- var decoder = new TextDecoder;
285
- function encodeChunk(html) {
286
- return encoder.encode(html);
287
- }
288
- async function streamToString(stream) {
289
- const reader = stream.getReader();
290
- const parts = [];
291
- for (;; ) {
292
- const { done, value } = await reader.read();
293
- if (done)
294
- break;
295
- parts.push(decoder.decode(value, { stream: true }));
114
+ // src/render-page.ts
115
+ function buildHeadHtml(options, _componentHeadEntries = []) {
116
+ const entries = [];
117
+ entries.push({ tag: "meta", attrs: { charset: "utf-8" } });
118
+ entries.push({
119
+ tag: "meta",
120
+ attrs: { name: "viewport", content: "width=device-width, initial-scale=1" }
121
+ });
122
+ if (options.title) {
123
+ entries.push({ tag: "title", textContent: options.title });
296
124
  }
297
- parts.push(decoder.decode());
298
- return parts.join("");
299
- }
300
- async function collectStreamChunks(stream) {
301
- const reader = stream.getReader();
302
- const chunks = [];
303
- for (;; ) {
304
- const { done, value } = await reader.read();
305
- if (done)
306
- break;
307
- chunks.push(decoder.decode(value, { stream: true }));
125
+ if (options.description) {
126
+ entries.push({ tag: "meta", attrs: { name: "description", content: options.description } });
308
127
  }
309
- return chunks;
310
- }
311
-
312
- // src/template-chunk.ts
313
- function createTemplateChunk(slotId, resolvedHtml, nonce) {
314
- const tmplId = `v-tmpl-${slotId}`;
315
- const slotRef = `v-slot-${slotId}`;
316
- const nonceAttr = nonce != null ? ` nonce="${escapeNonce(nonce)}"` : "";
317
- return `<template id="${tmplId}">${resolvedHtml}</template>` + `<script${nonceAttr}>` + `(function(){` + `var s=document.getElementById("${slotRef}");` + `var t=document.getElementById("${tmplId}");` + `if(s&&t){s.replaceWith(t.content.cloneNode(true));t.remove()}` + `})()` + "</script>";
318
- }
319
- function escapeNonce(value) {
320
- return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
321
- }
322
-
323
- // src/render-to-stream.ts
324
- function isSuspenseNode(node) {
325
- return typeof node === "object" && "tag" in node && node.tag === "__suspense" && "_resolve" in node;
326
- }
327
- function renderToStream(tree, options) {
328
- const pendingBoundaries = [];
329
- function walkAndSerialize(node) {
330
- if (typeof node === "string") {
331
- return escapeHtml(node);
128
+ const hasOgConfig = options.og != null;
129
+ const hasTitleOrDesc = options.title != null || options.description != null;
130
+ if (hasOgConfig || hasTitleOrDesc) {
131
+ const ogTitle = options.og?.title ?? options.title ?? "";
132
+ const ogDesc = options.og?.description ?? options.description ?? "";
133
+ if (ogTitle) {
134
+ entries.push({ tag: "meta", attrs: { property: "og:title", content: ogTitle } });
332
135
  }
333
- if (isRawHtml(node)) {
334
- return node.html;
136
+ if (ogDesc) {
137
+ entries.push({ tag: "meta", attrs: { property: "og:description", content: ogDesc } });
335
138
  }
336
- if (isSuspenseNode(node)) {
337
- const placeholder = createSlotPlaceholder(node._fallback);
338
- pendingBoundaries.push({
339
- slotId: placeholder._slotId,
340
- resolve: node._resolve
341
- });
342
- return serializeToHtml(placeholder);
139
+ if (options.og?.image) {
140
+ entries.push({ tag: "meta", attrs: { property: "og:image", content: options.og.image } });
343
141
  }
344
- const { tag, attrs, children } = node;
345
- const isRawText = RAW_TEXT_ELEMENTS.has(tag);
346
- const attrStr = Object.entries(attrs).map(([k, v]) => ` ${k}="${escapeAttr(v)}"`).join("");
347
- if (VOID_ELEMENTS.has(tag)) {
348
- return `<${tag}${attrStr}>`;
142
+ if (options.og?.url) {
143
+ entries.push({ tag: "meta", attrs: { property: "og:url", content: options.og.url } });
349
144
  }
350
- const childrenHtml = children.map((child) => {
351
- if (typeof child === "string" && isRawText) {
352
- return child;
353
- }
354
- return walkAndSerialize(child);
355
- }).join("");
356
- return `<${tag}${attrStr}>${childrenHtml}</${tag}>`;
145
+ entries.push({
146
+ tag: "meta",
147
+ attrs: { property: "og:type", content: options.og?.type ?? "website" }
148
+ });
357
149
  }
358
- return new ReadableStream({
150
+ if (options.twitter) {
151
+ if (options.twitter.card) {
152
+ entries.push({ tag: "meta", attrs: { name: "twitter:card", content: options.twitter.card } });
153
+ }
154
+ if (options.twitter.site) {
155
+ entries.push({ tag: "meta", attrs: { name: "twitter:site", content: options.twitter.site } });
156
+ }
157
+ }
158
+ if (options.favicon) {
159
+ entries.push({ tag: "link", attrs: { rel: "icon", href: options.favicon } });
160
+ }
161
+ if (options.styles) {
162
+ for (const style of options.styles) {
163
+ entries.push({ tag: "link", attrs: { rel: "stylesheet", href: style } });
164
+ }
165
+ }
166
+ return renderHeadToHtml(entries);
167
+ }
168
+ function renderPage(vnode, options) {
169
+ const status = options?.status ?? 200;
170
+ const lang = options?.lang ?? "en";
171
+ const headHtml = buildHeadHtml(options ?? {});
172
+ const fullHeadHtml = options?.head ? `${headHtml}
173
+ ${options.head}` : headHtml;
174
+ const scriptsHtml = options?.scripts ? `
175
+ ` + options.scripts.map((src) => ` <script type="module" src="${escapeAttr(src)}"></script>`).join(`
176
+ `) : "";
177
+ const stream = new ReadableStream({
359
178
  async start(controller) {
360
- const mainHtml = walkAndSerialize(tree);
361
- controller.enqueue(encodeChunk(mainHtml));
362
- if (pendingBoundaries.length > 0) {
363
- const nonce = options?.nonce;
364
- const resolutions = pendingBoundaries.map(async (boundary) => {
365
- try {
366
- const resolved = await boundary.resolve;
367
- const resolvedHtml = serializeToHtml(resolved);
368
- return createTemplateChunk(boundary.slotId, resolvedHtml, nonce);
369
- } catch (_err) {
370
- const errorHtml = `<div data-v-ssr-error="true" id="v-ssr-error-${boundary.slotId}">` + "<!--SSR error--></div>";
371
- return createTemplateChunk(boundary.slotId, errorHtml, nonce);
372
- }
373
- });
374
- const chunks = await Promise.all(resolutions);
375
- for (const chunk of chunks) {
376
- controller.enqueue(encodeChunk(chunk));
179
+ controller.enqueue(encodeChunk(`<!DOCTYPE html>
180
+ `));
181
+ controller.enqueue(encodeChunk(`<html lang="${escapeAttr(lang)}">
182
+ `));
183
+ controller.enqueue(encodeChunk(`<head>
184
+ `));
185
+ controller.enqueue(encodeChunk(fullHeadHtml));
186
+ controller.enqueue(encodeChunk(`
187
+ </head>
188
+ `));
189
+ controller.enqueue(encodeChunk(`<body>
190
+ `));
191
+ const componentStream = renderToStream(vnode);
192
+ const reader = componentStream.getReader();
193
+ try {
194
+ while (true) {
195
+ const { done, value } = await reader.read();
196
+ if (done)
197
+ break;
198
+ controller.enqueue(value);
377
199
  }
200
+ } finally {
201
+ reader.releaseLock();
378
202
  }
203
+ controller.enqueue(encodeChunk(scriptsHtml));
204
+ controller.enqueue(encodeChunk(`
205
+ </body>
206
+ `));
207
+ controller.enqueue(encodeChunk("</html>"));
379
208
  controller.close();
380
209
  }
381
210
  });
211
+ return new Response(stream, {
212
+ status,
213
+ headers: {
214
+ "content-type": "text/html; charset=utf-8"
215
+ }
216
+ });
217
+ }
218
+ // src/render-to-html.ts
219
+ import { compileTheme } from "@vertz/ui";
220
+ import { setAdapter } from "@vertz/ui/internals";
221
+ function installSSR() {
222
+ setAdapter(createSSRAdapter());
223
+ installDomShim();
224
+ }
225
+ function removeSSR() {
226
+ setAdapter(null);
227
+ removeDomShim();
228
+ }
229
+ async function twoPassRender(options) {
230
+ options.app();
231
+ const queries = getSSRQueries();
232
+ if (queries.length > 0) {
233
+ await Promise.allSettled(queries.map((entry) => Promise.race([
234
+ entry.promise.then((data) => {
235
+ entry.resolve(data);
236
+ entry.resolved = true;
237
+ }),
238
+ new Promise((r) => setTimeout(r, entry.timeout))
239
+ ])));
240
+ const store = ssrStorage.getStore();
241
+ if (store)
242
+ store.queries = [];
243
+ }
244
+ const pendingQueries = queries.filter((q) => !q.resolved);
245
+ const vnode = options.app();
246
+ const fakeDoc = globalThis.document;
247
+ const collectedCSS = [];
248
+ if (fakeDoc?.head?.children) {
249
+ for (const child of fakeDoc.head.children) {
250
+ if (child instanceof SSRElement && child.tag === "style") {
251
+ const cssText = child.children?.join("") ?? "";
252
+ if (cssText)
253
+ collectedCSS.push(cssText);
254
+ }
255
+ }
256
+ }
257
+ const themeCss = options.theme ? compileTheme(options.theme).css : "";
258
+ const allStyles = [themeCss, ...options.styles ?? [], ...collectedCSS].filter(Boolean);
259
+ const styleTags = allStyles.map((css) => `<style>${css}</style>`).join(`
260
+ `);
261
+ const metaHtml = options.head?.meta?.map((m) => `<meta ${m.name ? `name="${m.name}"` : `property="${m.property}"`} content="${m.content}">`).join(`
262
+ `) ?? "";
263
+ const linkHtml = options.head?.links?.map((link) => `<link rel="${link.rel}" href="${link.href}">`).join(`
264
+ `) ?? "";
265
+ const runtimeScript = pendingQueries.length > 0 ? getStreamingRuntimeScript(options.nonce) : "";
266
+ const headContent = [metaHtml, linkHtml, styleTags, runtimeScript].filter(Boolean).join(`
267
+ `);
268
+ const response = renderPage(vnode, {
269
+ title: options.head?.title,
270
+ head: headContent
271
+ });
272
+ const html = await response.text();
273
+ return { html, pendingQueries };
274
+ }
275
+ async function renderToHTMLStream(options) {
276
+ installSSR();
277
+ const streamTimeout = options.streamTimeout ?? 30000;
278
+ return ssrStorage.run({ url: options.url, errors: [], queries: [] }, async () => {
279
+ try {
280
+ if (options.ssrTimeout !== undefined) {
281
+ setGlobalSSRTimeout(options.ssrTimeout);
282
+ }
283
+ const { html, pendingQueries } = await twoPassRender(options);
284
+ if (pendingQueries.length === 0) {
285
+ clearGlobalSSRTimeout();
286
+ removeSSR();
287
+ return new Response(html, {
288
+ status: 200,
289
+ headers: { "content-type": "text/html; charset=utf-8" }
290
+ });
291
+ }
292
+ clearGlobalSSRTimeout();
293
+ removeSSR();
294
+ const TIMEOUT_SENTINEL = Symbol("stream-timeout");
295
+ let closed = false;
296
+ let hardTimeoutId;
297
+ const stream = new ReadableStream({
298
+ async start(controller) {
299
+ controller.enqueue(encodeChunk(html));
300
+ const hardTimeout = new Promise((r) => {
301
+ hardTimeoutId = setTimeout(() => r(TIMEOUT_SENTINEL), streamTimeout);
302
+ });
303
+ const streamPromises = pendingQueries.map(async (entry) => {
304
+ try {
305
+ const result = await Promise.race([entry.promise, hardTimeout]);
306
+ if (result === TIMEOUT_SENTINEL || closed)
307
+ return;
308
+ const chunk = createSSRDataChunk(entry.key, result, options.nonce);
309
+ controller.enqueue(encodeChunk(chunk));
310
+ } catch {}
311
+ });
312
+ await Promise.race([Promise.allSettled(streamPromises), hardTimeout]);
313
+ if (hardTimeoutId !== undefined)
314
+ clearTimeout(hardTimeoutId);
315
+ closed = true;
316
+ controller.close();
317
+ }
318
+ });
319
+ return new Response(stream, {
320
+ status: 200,
321
+ headers: { "content-type": "text/html; charset=utf-8" }
322
+ });
323
+ } catch (err) {
324
+ clearGlobalSSRTimeout();
325
+ removeSSR();
326
+ throw err;
327
+ }
328
+ });
329
+ }
330
+ async function renderToHTML(appOrOptions, maybeOptions) {
331
+ const options = typeof appOrOptions === "function" ? { ...maybeOptions, app: appOrOptions } : appOrOptions;
332
+ installSSR();
333
+ return ssrStorage.run({ url: options.url, errors: [], queries: [] }, async () => {
334
+ try {
335
+ const { html } = await twoPassRender(options);
336
+ return html;
337
+ } finally {
338
+ clearGlobalSSRTimeout();
339
+ removeSSR();
340
+ }
341
+ });
382
342
  }
383
- // src/types.ts
384
- function rawHtml(html) {
385
- return { __raw: true, html };
343
+ // src/ssr-html.ts
344
+ function generateSSRHtml(options) {
345
+ const { appHtml, css, ssrData, clientEntry, title = "Vertz App" } = options;
346
+ const ssrDataScript = ssrData.length > 0 ? `<script>window.__VERTZ_SSR_DATA__ = ${JSON.stringify(ssrData)};</script>` : "";
347
+ return `<!doctype html>
348
+ <html lang="en">
349
+ <head>
350
+ <meta charset="UTF-8" />
351
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
352
+ <title>${escapeHtml(title)}</title>
353
+ ${css}
354
+ </head>
355
+ <body>
356
+ <div id="app">${appHtml}</div>
357
+ ${ssrDataScript}
358
+ <script type="module" src="${escapeAttr(clientEntry)}"></script>
359
+ </body>
360
+ </html>`;
386
361
  }
387
362
  export {
388
363
  wrapWithHydrationMarkers,
389
364
  streamToString,
365
+ ssrStorage,
366
+ ssrRenderToString,
367
+ ssrDiscoverQueries,
368
+ setGlobalSSRTimeout,
390
369
  serializeToHtml,
370
+ safeSerialize,
391
371
  resetSlotCounter,
392
372
  renderToStream,
373
+ renderToHTMLStream,
374
+ renderToHTML,
375
+ renderPage,
393
376
  renderHeadToHtml,
394
377
  renderAssetTags,
378
+ registerSSRQuery,
395
379
  rawHtml,
380
+ isInSSR,
396
381
  inlineCriticalCss,
382
+ getStreamingRuntimeScript,
383
+ getSSRUrl,
384
+ getSSRQueries,
385
+ getGlobalSSRTimeout,
386
+ generateSSRHtml,
397
387
  encodeChunk,
398
388
  createTemplateChunk,
399
389
  createSlotPlaceholder,
400
- createDevServer,
390
+ createSSRHandler,
391
+ createSSRDataChunk,
392
+ createSSRAdapter,
401
393
  collectStreamChunks,
394
+ clearGlobalSSRTimeout,
402
395
  HeadCollector
403
396
  };
@@ -1,4 +1,10 @@
1
1
  // src/jsx-runtime/index.ts
2
+ function unwrapSignal(value) {
3
+ if (value != null && typeof value === "object" && "peek" in value && typeof value.peek === "function") {
4
+ return value.peek();
5
+ }
6
+ return value;
7
+ }
2
8
  function normalizeChildren(children) {
3
9
  if (children == null || children === false || children === true) {
4
10
  return [];
@@ -9,6 +15,12 @@ function normalizeChildren(children) {
9
15
  if (typeof children === "object" && (("tag" in children) || ("__raw" in children))) {
10
16
  return [children];
11
17
  }
18
+ if (typeof children === "object") {
19
+ const unwrapped = unwrapSignal(children);
20
+ if (unwrapped !== children) {
21
+ return normalizeChildren(unwrapped);
22
+ }
23
+ }
12
24
  return [String(children)];
13
25
  }
14
26
  function jsx(tag, props) {
@@ -17,10 +29,11 @@ function jsx(tag, props) {
17
29
  }
18
30
  const { children, ...attrs } = props || {};
19
31
  const serializableAttrs = {};
20
- for (const [key, value] of Object.entries(attrs)) {
21
- if (key.startsWith("on") && typeof value === "function") {
32
+ for (const [key, rawValue] of Object.entries(attrs)) {
33
+ if (key.startsWith("on") && typeof rawValue === "function") {
22
34
  continue;
23
35
  }
36
+ const value = unwrapSignal(rawValue);
24
37
  if (key === "class" && value != null) {
25
38
  serializableAttrs.class = String(value);
26
39
  continue;