@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/README.md ADDED
@@ -0,0 +1,455 @@
1
+ # @vertz/ui-server
2
+
3
+ Server-side rendering (SSR) for `@vertz/ui`.
4
+
5
+ ## Features
6
+
7
+ - **Streaming HTML** — `renderToStream()` returns a `ReadableStream<Uint8Array>`
8
+ - **Out-of-order streaming** — Suspense boundaries emit placeholders immediately, resolved content later
9
+ - **Atomic hydration markers** — Interactive components get `data-v-id` attributes; static components ship zero JS
10
+ - **Head management** — Collect `<title>`, `<meta>`, and `<link>` tags during render
11
+ - **Asset injection** — Script and stylesheet helpers for the HTML head
12
+ - **Critical CSS inlining** — Inline above-the-fold CSS with injection prevention
13
+ - **CSP nonce support** — All inline scripts support Content Security Policy nonces
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ bun add @vertz/ui-server
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ### Basic SSR
24
+
25
+ ```typescript
26
+ import { renderToStream } from '@vertz/ui-server';
27
+ import type { VNode } from '@vertz/ui-server';
28
+
29
+ const tree: VNode = {
30
+ tag: 'html',
31
+ attrs: { lang: 'en' },
32
+ children: [
33
+ {
34
+ tag: 'head',
35
+ attrs: {},
36
+ children: [{ tag: 'title', attrs: {}, children: ['My App'] }],
37
+ },
38
+ {
39
+ tag: 'body',
40
+ attrs: {},
41
+ children: [
42
+ {
43
+ tag: 'div',
44
+ attrs: { id: 'app' },
45
+ children: ['Hello, SSR!'],
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ };
51
+
52
+ const stream = renderToStream(tree);
53
+
54
+ // Return as HTTP response
55
+ return new Response(stream, {
56
+ headers: { 'content-type': 'text/html; charset=utf-8' },
57
+ });
58
+ ```
59
+
60
+ ### Streaming with Suspense
61
+
62
+ ```typescript
63
+ import { renderToStream } from '@vertz/ui-server';
64
+ import type { VNode } from '@vertz/ui-server';
65
+
66
+ // Create a Suspense boundary
67
+ const suspenseNode = {
68
+ tag: '__suspense',
69
+ attrs: {},
70
+ children: [],
71
+ _fallback: { tag: 'div', attrs: { class: 'skeleton' }, children: ['Loading...'] },
72
+ _resolve: fetchUserData().then((user) => ({
73
+ tag: 'div',
74
+ attrs: { class: 'user-profile' },
75
+ children: [user.name],
76
+ })),
77
+ };
78
+
79
+ const tree: VNode = {
80
+ tag: 'div',
81
+ attrs: { id: 'app' },
82
+ children: [
83
+ { tag: 'h1', attrs: {}, children: ['User Profile'] },
84
+ suspenseNode as VNode,
85
+ ],
86
+ };
87
+
88
+ const stream = renderToStream(tree);
89
+ ```
90
+
91
+ **How out-of-order streaming works:**
92
+
93
+ 1. The initial stream contains the placeholder: `<div id="v-slot-0"><div class="skeleton">Loading...</div></div>`
94
+ 2. When `_resolve` completes, a replacement chunk is streamed:
95
+ ```html
96
+ <template id="v-tmpl-0"><div class="user-profile">Alice</div></template>
97
+ <script>
98
+ (function(){
99
+ var s=document.getElementById("v-slot-0");
100
+ var t=document.getElementById("v-tmpl-0");
101
+ if(s&&t){s.replaceWith(t.content.cloneNode(true));t.remove()}
102
+ })()
103
+ </script>
104
+ ```
105
+
106
+ ### CSP Nonce Support
107
+
108
+ For sites with strict Content Security Policies:
109
+
110
+ ```typescript
111
+ import { renderToStream } from '@vertz/ui-server';
112
+
113
+ const nonce = crypto.randomUUID();
114
+
115
+ const stream = renderToStream(tree, { nonce });
116
+
117
+ return new Response(stream, {
118
+ headers: {
119
+ 'content-type': 'text/html; charset=utf-8',
120
+ 'content-security-policy': `script-src 'nonce-${nonce}'`,
121
+ },
122
+ });
123
+ ```
124
+
125
+ All inline `<script>` tags (Suspense replacement scripts) will include `nonce="..."`.
126
+
127
+ ### Hydration Markers
128
+
129
+ Interactive components get hydration markers so the client knows where to attach event handlers:
130
+
131
+ ```typescript
132
+ import { wrapWithHydrationMarkers } from '@vertz/ui-server';
133
+ import type { VNode } from '@vertz/ui-server';
134
+
135
+ const counterNode: VNode = {
136
+ tag: 'div',
137
+ attrs: {},
138
+ children: [
139
+ { tag: 'span', attrs: {}, children: ['Count: 0'] },
140
+ { tag: 'button', attrs: {}, children: ['+'] },
141
+ ],
142
+ };
143
+
144
+ const hydratedNode = wrapWithHydrationMarkers(counterNode, {
145
+ componentName: 'Counter',
146
+ key: 'counter-0',
147
+ props: { initial: 0 },
148
+ });
149
+
150
+ const stream = renderToStream(hydratedNode);
151
+ ```
152
+
153
+ **Output:**
154
+
155
+ ```html
156
+ <div data-v-id="Counter" data-v-key="counter-0">
157
+ <span>Count: 0</span>
158
+ <button>+</button>
159
+ <script type="application/json">{"initial":0}</script>
160
+ </div>
161
+ ```
162
+
163
+ The hydration runtime on the client reads `data-v-id` and `data-v-key` to restore interactivity.
164
+
165
+ ### Head Management
166
+
167
+ Collect `<title>`, `<meta>`, and `<link>` tags during render:
168
+
169
+ ```typescript
170
+ import { HeadCollector, renderHeadToHtml, rawHtml } from '@vertz/ui-server';
171
+
172
+ const headCollector = new HeadCollector();
173
+ headCollector.addTitle('My SSR App');
174
+ headCollector.addMeta({ charset: 'utf-8' });
175
+ headCollector.addMeta({ name: 'viewport', content: 'width=device-width, initial-scale=1' });
176
+ headCollector.addLink({ rel: 'stylesheet', href: '/styles.css' });
177
+
178
+ const headHtml = renderHeadToHtml(headCollector.getEntries());
179
+
180
+ const tree: VNode = {
181
+ tag: 'html',
182
+ attrs: { lang: 'en' },
183
+ children: [
184
+ {
185
+ tag: 'head',
186
+ attrs: {},
187
+ children: [rawHtml(headHtml)],
188
+ },
189
+ {
190
+ tag: 'body',
191
+ attrs: {},
192
+ children: [{ tag: 'div', attrs: { id: 'app' }, children: ['Content'] }],
193
+ },
194
+ ],
195
+ };
196
+
197
+ const stream = renderToStream(tree);
198
+ ```
199
+
200
+ ### Asset Injection
201
+
202
+ Inject scripts and stylesheets into the HTML head:
203
+
204
+ ```typescript
205
+ import { renderAssetTags } from '@vertz/ui-server';
206
+ import type { AssetDescriptor } from '@vertz/ui-server';
207
+
208
+ const assets: AssetDescriptor[] = [
209
+ { type: 'stylesheet', src: '/styles/main.css' },
210
+ { type: 'script', src: '/js/runtime.js', defer: true },
211
+ { type: 'script', src: '/js/app.js', defer: true },
212
+ ];
213
+
214
+ const assetHtml = renderAssetTags(assets);
215
+ // <link rel="stylesheet" href="/styles/main.css">
216
+ // <script src="/js/runtime.js" defer></script>
217
+ // <script src="/js/app.js" defer></script>
218
+ ```
219
+
220
+ ### Critical CSS Inlining
221
+
222
+ Inline above-the-fold CSS for faster First Contentful Paint:
223
+
224
+ ```typescript
225
+ import { inlineCriticalCss, rawHtml } from '@vertz/ui-server';
226
+
227
+ const criticalCss = `
228
+ body { margin: 0; font-family: system-ui, sans-serif; }
229
+ .hero { padding: 2rem; background: linear-gradient(to right, #667eea, #764ba2); }
230
+ `;
231
+
232
+ const styleTag = inlineCriticalCss(criticalCss);
233
+ // <style>body { margin: 0; font-family: system-ui, sans-serif; } ...</style>
234
+
235
+ const tree: VNode = {
236
+ tag: 'html',
237
+ attrs: {},
238
+ children: [
239
+ {
240
+ tag: 'head',
241
+ attrs: {},
242
+ children: [rawHtml(styleTag)],
243
+ },
244
+ {
245
+ tag: 'body',
246
+ attrs: {},
247
+ children: [{ tag: 'div', attrs: { class: 'hero' }, children: ['Hero Section'] }],
248
+ },
249
+ ],
250
+ };
251
+
252
+ const stream = renderToStream(tree);
253
+ ```
254
+
255
+ The `inlineCriticalCss()` function escapes any `</style>` sequences in the CSS to prevent injection attacks.
256
+
257
+ ### Raw HTML
258
+
259
+ Embed pre-rendered HTML without escaping:
260
+
261
+ ```typescript
262
+ import { rawHtml } from '@vertz/ui-server';
263
+ import type { VNode } from '@vertz/ui-server';
264
+
265
+ const tree: VNode = {
266
+ tag: 'div',
267
+ attrs: {},
268
+ children: [
269
+ rawHtml('<p>This HTML is <strong>not</strong> escaped.</p>'),
270
+ 'This text is escaped.',
271
+ ],
272
+ };
273
+
274
+ const stream = renderToStream(tree);
275
+ // <div><p>This HTML is <strong>not</strong> escaped.</p>This text is escaped.</div>
276
+ ```
277
+
278
+ **Warning:** Only use `rawHtml()` with trusted content. Embedding user-generated content without escaping is a security risk.
279
+
280
+ ## API Reference
281
+
282
+ ### `renderToStream(tree, options?)`
283
+
284
+ Render a VNode tree to a `ReadableStream<Uint8Array>`.
285
+
286
+ - **Parameters:**
287
+ - `tree: VNode | string | RawHtml` — The virtual tree to render
288
+ - `options?: RenderToStreamOptions` — Optional configuration
289
+ - `nonce?: string` — CSP nonce for inline scripts
290
+ - **Returns:** `ReadableStream<Uint8Array>`
291
+
292
+ ### `wrapWithHydrationMarkers(node, options)`
293
+
294
+ Wrap a VNode with hydration markers for interactive components.
295
+
296
+ - **Parameters:**
297
+ - `node: VNode` — The component's root VNode
298
+ - `options: HydrationOptions`
299
+ - `componentName: string` — Component name for `data-v-id`
300
+ - `key: string` — Unique key for `data-v-key`
301
+ - `props?: Record<string, unknown>` — Serialized props
302
+ - **Returns:** `VNode` — A new VNode with hydration attributes
303
+
304
+ ### `HeadCollector`
305
+
306
+ Collects `<head>` metadata during SSR.
307
+
308
+ - **Methods:**
309
+ - `addTitle(text: string)` — Add a `<title>` tag
310
+ - `addMeta(attrs: Record<string, string>)` — Add a `<meta>` tag
311
+ - `addLink(attrs: Record<string, string>)` — Add a `<link>` tag
312
+ - `getEntries(): HeadEntry[]` — Get all collected entries
313
+ - `clear()` — Clear all entries
314
+
315
+ ### `renderHeadToHtml(entries)`
316
+
317
+ Render head entries to an HTML string.
318
+
319
+ - **Parameters:**
320
+ - `entries: HeadEntry[]` — Collected head entries
321
+ - **Returns:** `string` — HTML string
322
+
323
+ ### `renderAssetTags(assets)`
324
+
325
+ Render asset descriptors to HTML tags.
326
+
327
+ - **Parameters:**
328
+ - `assets: AssetDescriptor[]` — Script/stylesheet descriptors
329
+ - **Returns:** `string` — HTML string
330
+
331
+ ### `inlineCriticalCss(css)`
332
+
333
+ Inline critical CSS as a `<style>` tag.
334
+
335
+ - **Parameters:**
336
+ - `css: string` — CSS content
337
+ - **Returns:** `string` — `<style>...</style>` or empty string
338
+
339
+ ### `rawHtml(html)`
340
+
341
+ Create a raw HTML string that bypasses escaping.
342
+
343
+ - **Parameters:**
344
+ - `html: string` — Pre-rendered HTML
345
+ - **Returns:** `RawHtml` — Raw HTML object
346
+
347
+ ### `serializeToHtml(node)`
348
+
349
+ Serialize a VNode tree to an HTML string (synchronous).
350
+
351
+ - **Parameters:**
352
+ - `node: VNode | string | RawHtml` — The virtual tree to serialize
353
+ - **Returns:** `string` — HTML string
354
+
355
+ ### Utilities
356
+
357
+ - `streamToString(stream: ReadableStream<Uint8Array>): Promise<string>` — Convert stream to string (for testing)
358
+ - `collectStreamChunks(stream: ReadableStream<Uint8Array>): Promise<string[]>` — Collect stream chunks as array (for testing)
359
+ - `encodeChunk(html: string): Uint8Array` — Encode string to UTF-8 chunk
360
+
361
+ ## Types
362
+
363
+ ### `VNode`
364
+
365
+ Virtual node representing an HTML element.
366
+
367
+ ```typescript
368
+ interface VNode {
369
+ tag: string;
370
+ attrs: Record<string, string>;
371
+ children: (VNode | string | RawHtml)[];
372
+ }
373
+ ```
374
+
375
+ ### `RawHtml`
376
+
377
+ Raw HTML that bypasses escaping.
378
+
379
+ ```typescript
380
+ interface RawHtml {
381
+ __raw: true;
382
+ html: string;
383
+ }
384
+ ```
385
+
386
+ ### `HydrationOptions`
387
+
388
+ Options for hydration marker generation.
389
+
390
+ ```typescript
391
+ interface HydrationOptions {
392
+ componentName: string;
393
+ key: string;
394
+ props?: Record<string, unknown>;
395
+ }
396
+ ```
397
+
398
+ ### `HeadEntry`
399
+
400
+ Metadata entry for the HTML head.
401
+
402
+ ```typescript
403
+ interface HeadEntry {
404
+ tag: 'title' | 'meta' | 'link';
405
+ attrs?: Record<string, string>;
406
+ textContent?: string;
407
+ }
408
+ ```
409
+
410
+ ### `AssetDescriptor`
411
+
412
+ Asset descriptor for script/stylesheet injection.
413
+
414
+ ```typescript
415
+ interface AssetDescriptor {
416
+ type: 'script' | 'stylesheet';
417
+ src: string;
418
+ async?: boolean; // scripts only
419
+ defer?: boolean; // scripts only
420
+ }
421
+ ```
422
+
423
+ ### `RenderToStreamOptions`
424
+
425
+ Options for `renderToStream()`.
426
+
427
+ ```typescript
428
+ interface RenderToStreamOptions {
429
+ nonce?: string; // CSP nonce for inline scripts
430
+ }
431
+ ```
432
+
433
+ ## Testing
434
+
435
+ Run the test suite:
436
+
437
+ ```bash
438
+ bun run test
439
+ ```
440
+
441
+ Run tests in watch mode:
442
+
443
+ ```bash
444
+ bun run test:watch
445
+ ```
446
+
447
+ Type check:
448
+
449
+ ```bash
450
+ bun run typecheck
451
+ ```
452
+
453
+ ## License
454
+
455
+ MIT
@@ -0,0 +1,84 @@
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
+ /**
13
+ * Base Node class for SSR — matches the browser's Node interface minimally
14
+ */
15
+ declare class SSRNode {
16
+ childNodes: SSRNode[];
17
+ parentNode: SSRNode | null;
18
+ get firstChild(): SSRNode | null;
19
+ get nextSibling(): SSRNode | null;
20
+ removeChild(child: SSRNode): SSRNode;
21
+ insertBefore(newNode: SSRNode, referenceNode: SSRNode | null): SSRNode;
22
+ replaceChild(newNode: SSRNode, oldNode: SSRNode): SSRNode;
23
+ }
24
+ /**
25
+ * SSR text node
26
+ */
27
+ declare class SSRTextNode extends SSRNode {
28
+ text: string;
29
+ constructor(text: string);
30
+ get data(): string;
31
+ set data(value: string);
32
+ }
33
+ /**
34
+ * SSR document fragment
35
+ */
36
+ declare class SSRDocumentFragment extends SSRNode {
37
+ children: (SSRElement | string)[];
38
+ appendChild(child: SSRElement | SSRTextNode | SSRDocumentFragment): void;
39
+ }
40
+ /**
41
+ * A VNode-based element that supports basic DOM-like operations.
42
+ */
43
+ declare class SSRElement extends SSRNode {
44
+ tag: string;
45
+ attrs: Record<string, string>;
46
+ children: (SSRElement | string)[];
47
+ _classList: Set<string>;
48
+ _textContent: string | null;
49
+ _innerHTML: string | null;
50
+ style: Record<string, any>;
51
+ constructor(tag: string);
52
+ setAttribute(name: string, value: string): void;
53
+ getAttribute(name: string): string | null;
54
+ removeAttribute(name: string): void;
55
+ appendChild(child: SSRElement | SSRTextNode | SSRDocumentFragment): void;
56
+ removeChild(child: SSRNode): SSRNode;
57
+ get classList(): {
58
+ add: (cls: string) => void;
59
+ remove: (cls: string) => void;
60
+ };
61
+ set className(value: string);
62
+ get className(): string;
63
+ set textContent(value: string | null);
64
+ get textContent(): string | null;
65
+ set innerHTML(value: string);
66
+ get innerHTML(): string;
67
+ addEventListener(_event: string, _handler: any): void;
68
+ removeEventListener(_event: string, _handler: any): void;
69
+ /** Convert to a VNode tree for @vertz/ui-server */
70
+ toVNode(): VNode;
71
+ }
72
+ /**
73
+ * Create and install the DOM shim
74
+ */
75
+ declare function installDomShim(): void;
76
+ /**
77
+ * Remove the DOM shim
78
+ */
79
+ declare function removeDomShim(): void;
80
+ /**
81
+ * Convert an SSRElement to a VNode
82
+ */
83
+ declare function toVNode2(element: any): VNode;
84
+ export { toVNode2 as toVNode, removeDomShim, installDomShim, SSRTextNode, SSRNode, SSRElement, SSRDocumentFragment };