@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.
@@ -0,0 +1,332 @@
1
+ // src/dom-shim/ssr-node.ts
2
+ class SSRNode {
3
+ childNodes = [];
4
+ parentNode = null;
5
+ get firstChild() {
6
+ return this.childNodes[0] ?? null;
7
+ }
8
+ get nextSibling() {
9
+ if (!this.parentNode)
10
+ return null;
11
+ const index = this.parentNode.childNodes.indexOf(this);
12
+ return this.parentNode.childNodes[index + 1] ?? null;
13
+ }
14
+ removeChild(child) {
15
+ const index = this.childNodes.indexOf(child);
16
+ if (index !== -1) {
17
+ this.childNodes.splice(index, 1);
18
+ child.parentNode = null;
19
+ }
20
+ return child;
21
+ }
22
+ insertBefore(newNode, referenceNode) {
23
+ if (!referenceNode) {
24
+ this.childNodes.push(newNode);
25
+ newNode.parentNode = this;
26
+ } else {
27
+ const index = this.childNodes.indexOf(referenceNode);
28
+ if (index !== -1) {
29
+ this.childNodes.splice(index, 0, newNode);
30
+ newNode.parentNode = this;
31
+ }
32
+ }
33
+ return newNode;
34
+ }
35
+ replaceChild(newNode, oldNode) {
36
+ const index = this.childNodes.indexOf(oldNode);
37
+ if (index !== -1) {
38
+ this.childNodes[index] = newNode;
39
+ newNode.parentNode = this;
40
+ oldNode.parentNode = null;
41
+ }
42
+ return oldNode;
43
+ }
44
+ }
45
+
46
+ // src/dom-shim/ssr-text-node.ts
47
+ class SSRTextNode extends SSRNode {
48
+ text;
49
+ constructor(text) {
50
+ super();
51
+ this.text = text;
52
+ }
53
+ get data() {
54
+ return this.text;
55
+ }
56
+ set data(value) {
57
+ this.text = value;
58
+ }
59
+ }
60
+
61
+ // src/dom-shim/ssr-fragment.ts
62
+ class SSRDocumentFragment extends SSRNode {
63
+ children = [];
64
+ appendChild(child) {
65
+ if (child instanceof SSRTextNode) {
66
+ this.children.push(child.text);
67
+ this.childNodes.push(child);
68
+ child.parentNode = this;
69
+ } else if (child instanceof SSRDocumentFragment) {
70
+ this.children.push(...child.children);
71
+ this.childNodes.push(...child.childNodes);
72
+ for (const fragmentChild of child.childNodes) {
73
+ fragmentChild.parentNode = this;
74
+ }
75
+ } else {
76
+ this.children.push(child);
77
+ this.childNodes.push(child);
78
+ child.parentNode = this;
79
+ }
80
+ }
81
+ }
82
+
83
+ // src/dom-shim/ssr-element.ts
84
+ function createStyleProxy(element) {
85
+ const styles = {};
86
+ return new Proxy(styles, {
87
+ set(_target, prop, value) {
88
+ if (typeof prop === "string") {
89
+ styles[prop] = value;
90
+ const pairs = Object.entries(styles).map(([k, v]) => {
91
+ const key = k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
92
+ return `${key}: ${v}`;
93
+ });
94
+ element.attrs.style = pairs.join("; ");
95
+ }
96
+ return true;
97
+ },
98
+ get(_target, prop) {
99
+ if (typeof prop === "string") {
100
+ return styles[prop] ?? "";
101
+ }
102
+ return;
103
+ }
104
+ });
105
+ }
106
+
107
+ class SSRElement extends SSRNode {
108
+ tag;
109
+ attrs = {};
110
+ children = [];
111
+ _classList = new Set;
112
+ _textContent = null;
113
+ _innerHTML = null;
114
+ style;
115
+ constructor(tag) {
116
+ super();
117
+ this.tag = tag;
118
+ this.style = createStyleProxy(this);
119
+ }
120
+ setAttribute(name, value) {
121
+ if (name === "class") {
122
+ this._classList = new Set(value.split(/\s+/).filter(Boolean));
123
+ }
124
+ this.attrs[name] = value;
125
+ }
126
+ getAttribute(name) {
127
+ return this.attrs[name] ?? null;
128
+ }
129
+ removeAttribute(name) {
130
+ delete this.attrs[name];
131
+ if (name === "class") {
132
+ this._classList.clear();
133
+ }
134
+ }
135
+ appendChild(child) {
136
+ if (child instanceof SSRTextNode) {
137
+ this.children.push(child.text);
138
+ this.childNodes.push(child);
139
+ child.parentNode = this;
140
+ } else if (child instanceof SSRDocumentFragment) {
141
+ for (const fragmentChild of child.childNodes) {
142
+ if (fragmentChild instanceof SSRTextNode) {
143
+ this.children.push(fragmentChild.text);
144
+ } else if (fragmentChild instanceof SSRElement) {
145
+ this.children.push(fragmentChild);
146
+ }
147
+ this.childNodes.push(fragmentChild);
148
+ fragmentChild.parentNode = this;
149
+ }
150
+ } else {
151
+ this.children.push(child);
152
+ this.childNodes.push(child);
153
+ child.parentNode = this;
154
+ }
155
+ }
156
+ removeChild(child) {
157
+ const result = super.removeChild(child);
158
+ if (child instanceof SSRTextNode) {
159
+ const textIndex = this.children.indexOf(child.text);
160
+ if (textIndex !== -1) {
161
+ this.children.splice(textIndex, 1);
162
+ }
163
+ } else if (child instanceof SSRElement) {
164
+ const index = this.children.indexOf(child);
165
+ if (index !== -1) {
166
+ this.children.splice(index, 1);
167
+ }
168
+ }
169
+ return result;
170
+ }
171
+ get classList() {
172
+ const self = this;
173
+ return {
174
+ add(cls) {
175
+ self._classList.add(cls);
176
+ self.attrs.class = [...self._classList].join(" ");
177
+ },
178
+ remove(cls) {
179
+ self._classList.delete(cls);
180
+ const val = [...self._classList].join(" ");
181
+ if (val) {
182
+ self.attrs.class = val;
183
+ } else {
184
+ delete self.attrs.class;
185
+ }
186
+ }
187
+ };
188
+ }
189
+ set className(value) {
190
+ this._classList = new Set(value.split(/\s+/).filter(Boolean));
191
+ if (value) {
192
+ this.attrs.class = value;
193
+ } else {
194
+ delete this.attrs.class;
195
+ }
196
+ }
197
+ get className() {
198
+ return this.attrs.class ?? "";
199
+ }
200
+ set textContent(value) {
201
+ this._textContent = value;
202
+ this.children = value ? [value] : [];
203
+ this.childNodes = [];
204
+ }
205
+ get textContent() {
206
+ return this._textContent;
207
+ }
208
+ set innerHTML(value) {
209
+ this._innerHTML = value;
210
+ this.children = value ? [value] : [];
211
+ this.childNodes = [];
212
+ }
213
+ get innerHTML() {
214
+ return this._innerHTML ?? "";
215
+ }
216
+ addEventListener(_event, _handler) {}
217
+ removeEventListener(_event, _handler) {}
218
+ toVNode() {
219
+ return {
220
+ tag: this.tag,
221
+ attrs: { ...this.attrs },
222
+ children: this.children.map((child) => {
223
+ if (typeof child === "string")
224
+ return child;
225
+ return child.toVNode();
226
+ })
227
+ };
228
+ }
229
+ }
230
+
231
+ // src/dom-shim/index.ts
232
+ function installDomShim() {
233
+ const isSSRContext = typeof globalThis.__SSR_URL__ !== "undefined";
234
+ if (typeof document !== "undefined" && !isSSRContext) {
235
+ return;
236
+ }
237
+ const fakeDocument = {
238
+ createElement(tag) {
239
+ return new SSRElement(tag);
240
+ },
241
+ createTextNode(text) {
242
+ return new SSRTextNode(text);
243
+ },
244
+ createComment(text) {
245
+ return new SSRTextNode(`<!-- ${text} -->`);
246
+ },
247
+ createDocumentFragment() {
248
+ return new SSRDocumentFragment;
249
+ },
250
+ head: new SSRElement("head"),
251
+ body: new SSRElement("body")
252
+ };
253
+ globalThis.document = fakeDocument;
254
+ if (typeof window === "undefined") {
255
+ globalThis.window = {
256
+ location: { pathname: globalThis.__SSR_URL__ || "/" },
257
+ addEventListener: () => {},
258
+ removeEventListener: () => {},
259
+ history: {
260
+ pushState: () => {},
261
+ replaceState: () => {}
262
+ }
263
+ };
264
+ } else {
265
+ globalThis.window.location = {
266
+ ...globalThis.window.location || {},
267
+ pathname: globalThis.__SSR_URL__ || "/"
268
+ };
269
+ }
270
+ globalThis.Node = SSRNode;
271
+ globalThis.HTMLElement = SSRElement;
272
+ globalThis.HTMLAnchorElement = SSRElement;
273
+ globalThis.HTMLDivElement = SSRElement;
274
+ globalThis.HTMLInputElement = SSRElement;
275
+ globalThis.HTMLButtonElement = SSRElement;
276
+ globalThis.HTMLSelectElement = SSRElement;
277
+ globalThis.HTMLTextAreaElement = SSRElement;
278
+ globalThis.DocumentFragment = SSRDocumentFragment;
279
+ globalThis.MouseEvent = class MockMouseEvent {
280
+ };
281
+ globalThis.Event = class MockEvent {
282
+ };
283
+ }
284
+ function removeDomShim() {
285
+ const globals = [
286
+ "document",
287
+ "window",
288
+ "Node",
289
+ "HTMLElement",
290
+ "HTMLAnchorElement",
291
+ "HTMLDivElement",
292
+ "HTMLInputElement",
293
+ "HTMLButtonElement",
294
+ "HTMLSelectElement",
295
+ "HTMLTextAreaElement",
296
+ "DocumentFragment",
297
+ "MouseEvent",
298
+ "Event"
299
+ ];
300
+ for (const g of globals) {
301
+ delete globalThis[g];
302
+ }
303
+ }
304
+ function toVNode(element) {
305
+ if (element instanceof SSRElement) {
306
+ return element.toVNode();
307
+ }
308
+ if (element instanceof SSRDocumentFragment) {
309
+ return {
310
+ tag: "fragment",
311
+ attrs: {},
312
+ children: element.children.map((child) => {
313
+ if (typeof child === "string")
314
+ return child;
315
+ return child.toVNode();
316
+ })
317
+ };
318
+ }
319
+ if (typeof element === "object" && "tag" in element) {
320
+ return element;
321
+ }
322
+ return { tag: "span", attrs: {}, children: [String(element)] };
323
+ }
324
+ export {
325
+ toVNode,
326
+ removeDomShim,
327
+ installDomShim,
328
+ SSRTextNode,
329
+ SSRNode,
330
+ SSRElement,
331
+ SSRDocumentFragment
332
+ };
@@ -0,0 +1,227 @@
1
+ /** A raw HTML string that bypasses escaping during serialization. */
2
+ interface RawHtml {
3
+ __raw: true;
4
+ html: string;
5
+ }
6
+ /** Create a raw HTML string that will not be escaped during serialization. */
7
+ declare function rawHtml(html: string): RawHtml;
8
+ /** Virtual node representing an HTML element for SSR serialization. */
9
+ interface VNode {
10
+ tag: string;
11
+ attrs: Record<string, string>;
12
+ children: (VNode | string | RawHtml)[];
13
+ }
14
+ /** Options for hydration marker generation. */
15
+ interface HydrationOptions {
16
+ /** Component name for `data-v-id`. */
17
+ componentName: string;
18
+ /** Unique key for `data-v-key`. */
19
+ key: string;
20
+ /** Serialized props to embed as JSON. */
21
+ props?: Record<string, unknown>;
22
+ }
23
+ /** Metadata collected by the Head component during rendering. */
24
+ interface HeadEntry {
25
+ tag: "title" | "meta" | "link";
26
+ attrs?: Record<string, string>;
27
+ textContent?: string;
28
+ }
29
+ /** Options for {@link renderToStream}. */
30
+ interface RenderToStreamOptions {
31
+ /**
32
+ * CSP nonce to inject on all inline `<script>` tags emitted during SSR.
33
+ *
34
+ * When set, every inline script (e.g. Suspense replacement scripts) will
35
+ * include `nonce="<value>"` so that strict Content-Security-Policy headers
36
+ * do not block them.
37
+ */
38
+ nonce?: string;
39
+ }
40
+ /** Asset descriptor for script/stylesheet injection. */
41
+ interface AssetDescriptor {
42
+ type: "script" | "stylesheet";
43
+ src: string;
44
+ /** Whether to add `async` attribute (scripts only). */
45
+ async?: boolean;
46
+ /** Whether to add `defer` attribute (scripts only). */
47
+ defer?: boolean;
48
+ }
49
+ /**
50
+ * Render asset descriptors to HTML tags for script/stylesheet injection.
51
+ *
52
+ * - Scripts: `<script src="..." [async] [defer]><\/script>`
53
+ * - Stylesheets: `<link rel="stylesheet" href="...">`
54
+ */
55
+ declare function renderAssetTags(assets: AssetDescriptor[]): string;
56
+ /**
57
+ * Inline critical CSS as a `<style>` tag.
58
+ *
59
+ * Wraps the provided CSS in `<style>...</style>` for embedding in the HTML head.
60
+ * Escapes any `</style>` sequences in the CSS content to prevent injection.
61
+ *
62
+ * Returns an empty string if the CSS is empty.
63
+ */
64
+ declare function inlineCriticalCss(css: string): string;
65
+ import { IncomingMessage, Server, ServerResponse } from "node:http";
66
+ import { InlineConfig, ViteDevServer } from "vite";
67
+ interface DevServerOptions {
68
+ /**
69
+ * Path to the SSR entry module (relative to project root).
70
+ * This module should a `renderToString` function.
71
+ */
72
+ entry: string;
73
+ /**
74
+ * Port to listen on.
75
+ * @default 5173
76
+ */
77
+ port?: number;
78
+ /**
79
+ * Host to bind to.
80
+ * @default '0.0.0.0'
81
+ */
82
+ host?: string;
83
+ /**
84
+ * Custom Vite configuration.
85
+ * Merged with default middleware mode config.
86
+ */
87
+ viteConfig?: InlineConfig;
88
+ /**
89
+ * Custom middleware to run before SSR handler.
90
+ * Useful for API routes, static file serving, etc.
91
+ */
92
+ middleware?: (req: IncomingMessage, res: ServerResponse, next: () => void) => void;
93
+ /**
94
+ * Skip invalidating modules on each request.
95
+ * Useful for debugging or performance testing.
96
+ * @default false
97
+ */
98
+ skipModuleInvalidation?: boolean;
99
+ /**
100
+ * Log requests to console.
101
+ * @default true
102
+ */
103
+ logRequests?: boolean;
104
+ }
105
+ interface DevServer {
106
+ /**
107
+ * Start the server and listen on the configured port.
108
+ */
109
+ listen(): Promise<void>;
110
+ /**
111
+ * Close the server.
112
+ */
113
+ close(): Promise<void>;
114
+ /**
115
+ * The underlying Vite dev server.
116
+ */
117
+ vite: ViteDevServer;
118
+ /**
119
+ * The underlying HTTP server.
120
+ */
121
+ httpServer: Server;
122
+ }
123
+ /**
124
+ * Create a Vite SSR development server.
125
+ */
126
+ declare function createDevServer(options: DevServerOptions): DevServer;
127
+ /**
128
+ * Collector for `<head>` metadata during SSR.
129
+ *
130
+ * Components call `addTitle`, `addMeta`, or `addLink` during render,
131
+ * and the collected entries are serialized into the HTML `<head>` section.
132
+ */
133
+ declare class HeadCollector {
134
+ private entries;
135
+ /** Add a `<title>` entry. */
136
+ addTitle(text: string): void;
137
+ /** Add a `<meta>` entry. */
138
+ addMeta(attrs: Record<string, string>): void;
139
+ /** Add a `<link>` entry. */
140
+ addLink(attrs: Record<string, string>): void;
141
+ /** Get all collected entries. */
142
+ getEntries(): HeadEntry[];
143
+ /** Clear all collected entries. */
144
+ clear(): void;
145
+ }
146
+ /**
147
+ * Render head entries to an HTML string.
148
+ *
149
+ * - `<title>` renders as `<title>text</title>`
150
+ * - `<meta>` and `<link>` render as void elements
151
+ */
152
+ declare function renderHeadToHtml(entries: HeadEntry[]): string;
153
+ /**
154
+ * Serialize a VNode tree (or plain string) to an HTML string.
155
+ *
156
+ * This is the core serialization function for SSR. It walks the virtual tree
157
+ * recursively and produces an HTML string without requiring a real DOM.
158
+ *
159
+ * - Text content inside `<script>` and `<style>` tags is not escaped.
160
+ * - `RawHtml` values bypass escaping entirely.
161
+ */
162
+ declare function serializeToHtml(node: VNode | string | RawHtml): string;
163
+ /**
164
+ * Wrap a VNode with hydration markers for interactive components.
165
+ *
166
+ * Adds `data-v-id` and `data-v-key` attributes to the root element,
167
+ * and optionally embeds serialized props as a `<script type="application/json">` child.
168
+ *
169
+ * Returns a new VNode; the original is not mutated.
170
+ */
171
+ declare function wrapWithHydrationMarkers(node: VNode, options: HydrationOptions): VNode;
172
+ /**
173
+ * Render a VNode tree to a `ReadableStream` of HTML chunks.
174
+ *
175
+ * This is the main SSR entry point. It walks the virtual tree, serializing
176
+ * synchronous content immediately and deferring Suspense boundaries.
177
+ *
178
+ * Suspense boundaries emit:
179
+ * 1. A `<div id="v-slot-N">fallback</div>` placeholder inline
180
+ * 2. A `<template id="v-tmpl-N">resolved</template><script>...<\/script>` chunk
181
+ * appended after the main content once the async content resolves
182
+ *
183
+ * This enables out-of-order streaming: the browser can paint the fallback
184
+ * immediately and swap in the resolved content when it arrives.
185
+ */
186
+ declare function renderToStream(tree: VNode | string | RawHtml, options?: RenderToStreamOptions): ReadableStream<Uint8Array>;
187
+ /** Reset the slot counter (for testing). */
188
+ declare function resetSlotCounter(): void;
189
+ /**
190
+ * Create a Suspense slot placeholder.
191
+ *
192
+ * Wraps fallback content in a `<div id="v-slot-N">` so it can later
193
+ * be replaced by the resolved async content via a template chunk.
194
+ *
195
+ * Returns the placeholder VNode and the assigned slot ID.
196
+ */
197
+ declare function createSlotPlaceholder(fallback: VNode | string): VNode & {
198
+ _slotId: number;
199
+ };
200
+ /**
201
+ * Encode a string as a UTF-8 Uint8Array chunk.
202
+ */
203
+ declare function encodeChunk(html: string): Uint8Array;
204
+ /**
205
+ * Convert a ReadableStream of Uint8Array chunks to a single string.
206
+ * Useful for testing SSR output.
207
+ */
208
+ declare function streamToString(stream: ReadableStream<Uint8Array>): Promise<string>;
209
+ /**
210
+ * Collect all chunks from a ReadableStream as an array of strings.
211
+ * Useful for testing streaming behavior (chunk ordering, etc.).
212
+ */
213
+ declare function collectStreamChunks(stream: ReadableStream<Uint8Array>): Promise<string[]>;
214
+ /**
215
+ * Create a replacement template chunk for out-of-order streaming.
216
+ *
217
+ * When a Suspense boundary resolves, this function generates the HTML
218
+ * containing:
219
+ * 1. A `<template id="v-tmpl-N">` with the resolved content
220
+ * 2. A `<script>` that replaces the placeholder `<div id="v-slot-N">` with the template content
221
+ *
222
+ * @param slotId - The unique slot ID for this suspense boundary.
223
+ * @param resolvedHtml - The resolved HTML content to insert.
224
+ * @param nonce - Optional CSP nonce to add to the inline script tag.
225
+ */
226
+ declare function createTemplateChunk(slotId: number, resolvedHtml: string, nonce?: string): string;
227
+ export { wrapWithHydrationMarkers, streamToString, serializeToHtml, resetSlotCounter, renderToStream, renderHeadToHtml, renderAssetTags, rawHtml, inlineCriticalCss, encodeChunk, createTemplateChunk, createSlotPlaceholder, createDevServer, collectStreamChunks, VNode, RenderToStreamOptions, RawHtml, HydrationOptions, HeadEntry, HeadCollector, DevServerOptions, DevServer, AssetDescriptor };