@vertz/ui-server 0.2.0

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 ADDED
@@ -0,0 +1,403 @@
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
+ }
56
+
57
+ // src/asset-pipeline.ts
58
+ function renderAssetTags(assets) {
59
+ if (assets.length === 0)
60
+ return "";
61
+ return assets.map((asset) => {
62
+ if (asset.type === "stylesheet") {
63
+ return `<link rel="stylesheet" href="${escapeAttr(asset.src)}">`;
64
+ }
65
+ const parts = [`<script src="${escapeAttr(asset.src)}"`];
66
+ if (asset.async)
67
+ parts.push(" async");
68
+ if (asset.defer)
69
+ parts.push(" defer");
70
+ parts.push("></script>");
71
+ return parts.join("");
72
+ }).join(`
73
+ `);
74
+ }
75
+ // src/critical-css.ts
76
+ function inlineCriticalCss(css) {
77
+ if (css === "")
78
+ return "";
79
+ const safeCss = css.replace(/<\/style>/gi, "<\\/style>");
80
+ return `<style>${safeCss}</style>`;
81
+ }
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
+ // src/head.ts
213
+ class HeadCollector {
214
+ entries = [];
215
+ addTitle(text) {
216
+ this.entries.push({ tag: "title", textContent: text });
217
+ }
218
+ addMeta(attrs) {
219
+ this.entries.push({ tag: "meta", attrs });
220
+ }
221
+ addLink(attrs) {
222
+ this.entries.push({ tag: "link", attrs });
223
+ }
224
+ getEntries() {
225
+ return [...this.entries];
226
+ }
227
+ clear() {
228
+ this.entries = [];
229
+ }
230
+ }
231
+ function renderHeadToHtml(entries) {
232
+ if (entries.length === 0)
233
+ return "";
234
+ return entries.map((entry) => {
235
+ if (entry.tag === "title") {
236
+ return `<title>${escapeHtml(entry.textContent ?? "")}</title>`;
237
+ }
238
+ const attrs = entry.attrs ?? {};
239
+ const attrStr = Object.entries(attrs).map(([k, v]) => ` ${k}="${escapeAttr(v)}"`).join("");
240
+ return `<${entry.tag}${attrStr}>`;
241
+ }).join(`
242
+ `);
243
+ }
244
+ // src/hydration-markers.ts
245
+ function wrapWithHydrationMarkers(node, options) {
246
+ const newAttrs = {
247
+ ...node.attrs,
248
+ "data-v-id": options.componentName,
249
+ "data-v-key": options.key
250
+ };
251
+ const newChildren = [...node.children];
252
+ if (options.props !== undefined) {
253
+ const propsScript = {
254
+ tag: "script",
255
+ attrs: { type: "application/json" },
256
+ children: [JSON.stringify(options.props)]
257
+ };
258
+ newChildren.push(propsScript);
259
+ }
260
+ return {
261
+ tag: node.tag,
262
+ attrs: newAttrs,
263
+ children: newChildren
264
+ };
265
+ }
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 }));
296
+ }
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 }));
308
+ }
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);
332
+ }
333
+ if (isRawHtml(node)) {
334
+ return node.html;
335
+ }
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);
343
+ }
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}>`;
349
+ }
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}>`;
357
+ }
358
+ return new ReadableStream({
359
+ 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));
377
+ }
378
+ }
379
+ controller.close();
380
+ }
381
+ });
382
+ }
383
+ // src/types.ts
384
+ function rawHtml(html) {
385
+ return { __raw: true, html };
386
+ }
387
+ export {
388
+ wrapWithHydrationMarkers,
389
+ streamToString,
390
+ serializeToHtml,
391
+ resetSlotCounter,
392
+ renderToStream,
393
+ renderHeadToHtml,
394
+ renderAssetTags,
395
+ rawHtml,
396
+ inlineCriticalCss,
397
+ encodeChunk,
398
+ createTemplateChunk,
399
+ createSlotPlaceholder,
400
+ createDevServer,
401
+ collectStreamChunks,
402
+ HeadCollector
403
+ };
@@ -0,0 +1,39 @@
1
+ /** A raw HTML string that bypasses escaping during serialization. */
2
+ interface RawHtml {
3
+ __raw: true;
4
+ html: string;
5
+ }
6
+ /** Virtual node representing an HTML element for SSR serialization. */
7
+ interface VNode {
8
+ tag: string;
9
+ attrs: Record<string, string>;
10
+ children: (VNode | string | RawHtml)[];
11
+ }
12
+ type JSXComponent = (props: Record<string, unknown>) => VNode | VNode[] | string | null;
13
+ type Tag = string | JSXComponent;
14
+ /**
15
+ * JSX factory function for server-side rendering.
16
+ *
17
+ * When tag is a function (component), calls it with props.
18
+ * When tag is a string (HTML element), creates a VNode.
19
+ */
20
+ declare function jsx(tag: Tag, props: Record<string, unknown>): VNode;
21
+ /**
22
+ * JSX factory for elements with multiple children.
23
+ * In the automatic runtime, this is used when there are multiple children.
24
+ * For our implementation, it's the same as jsx().
25
+ */
26
+ declare const jsxs: typeof jsx;
27
+ /**
28
+ * JSX development mode factory (used with @jsxImportSource in tsconfig).
29
+ * Same as jsx() for our implementation.
30
+ */
31
+ declare const jsxDEV: typeof jsx;
32
+ /**
33
+ * Fragment component — a virtual container for multiple children.
34
+ * The @vertz/ui-server renderer will unwrap fragments during serialization.
35
+ */
36
+ declare function Fragment(props: {
37
+ children?: unknown;
38
+ }): VNode;
39
+ export { jsxs, jsxDEV, jsx, Fragment };
@@ -0,0 +1,61 @@
1
+ // src/jsx-runtime/index.ts
2
+ function normalizeChildren(children) {
3
+ if (children == null || children === false || children === true) {
4
+ return [];
5
+ }
6
+ if (Array.isArray(children)) {
7
+ return children.flatMap(normalizeChildren);
8
+ }
9
+ if (typeof children === "object" && (("tag" in children) || ("__raw" in children))) {
10
+ return [children];
11
+ }
12
+ return [String(children)];
13
+ }
14
+ function jsx(tag, props) {
15
+ if (typeof tag === "function") {
16
+ return tag(props);
17
+ }
18
+ const { children, ...attrs } = props || {};
19
+ const serializableAttrs = {};
20
+ for (const [key, value] of Object.entries(attrs)) {
21
+ if (key.startsWith("on") && typeof value === "function") {
22
+ continue;
23
+ }
24
+ if (key === "class" && value != null) {
25
+ serializableAttrs.class = String(value);
26
+ continue;
27
+ }
28
+ if (key === "style" && value != null) {
29
+ serializableAttrs.style = String(value);
30
+ continue;
31
+ }
32
+ if (value === true) {
33
+ serializableAttrs[key] = "";
34
+ continue;
35
+ }
36
+ if (value === false || value == null) {
37
+ continue;
38
+ }
39
+ serializableAttrs[key] = String(value);
40
+ }
41
+ return {
42
+ tag,
43
+ attrs: serializableAttrs,
44
+ children: normalizeChildren(children)
45
+ };
46
+ }
47
+ var jsxs = jsx;
48
+ var jsxDEV = jsx;
49
+ function Fragment(props) {
50
+ return {
51
+ tag: "fragment",
52
+ attrs: {},
53
+ children: normalizeChildren(props?.children)
54
+ };
55
+ }
56
+ export {
57
+ jsxs,
58
+ jsxDEV,
59
+ jsx,
60
+ Fragment
61
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@vertz/ui-server",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "Vertz UI server-side rendering runtime",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/vertz-dev/vertz.git",
10
+ "directory": "packages/ui-server"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "import": "./dist/index.js",
21
+ "types": "./dist/index.d.ts"
22
+ },
23
+ "./dom-shim": {
24
+ "import": "./dist/dom-shim/index.js",
25
+ "types": "./dist/dom-shim/index.d.ts"
26
+ },
27
+ "./jsx-runtime": {
28
+ "import": "./dist/jsx-runtime/index.js",
29
+ "types": "./dist/jsx-runtime/index.d.ts"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "scripts": {
36
+ "build": "bunup",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "typecheck": "tsc --noEmit"
40
+ },
41
+ "dependencies": {
42
+ "@vertz/core": "workspace:*",
43
+ "@vertz/server": "workspace:*",
44
+ "@vertz/ui": "workspace:*"
45
+ },
46
+ "peerDependencies": {
47
+ "vite": "^6.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@vitest/coverage-v8": "^4.0.18",
51
+ "bunup": "latest",
52
+ "typescript": "^5.7.0",
53
+ "vite": "^6.0.0",
54
+ "vitest": "^4.0.18"
55
+ },
56
+ "engines": {
57
+ "node": ">=22"
58
+ }
59
+ }