Server-side rendering for Constela UI framework
npm install @constela/serverServer-side rendering (SSR) for Constela JSON programs.
``bash`
npm install @constela/server
Peer Dependencies:
- @constela/compiler ^0.7.0
JSON program → HTML string
`json`
{
"version": "1.0",
"state": { "name": { "type": "string", "initial": "World" } },
"view": {
"kind": "element",
"tag": "h1",
"children": [
{ "kind": "text", "value": { "expr": "lit", "value": "Hello, " } },
{ "kind": "text", "value": { "expr": "state", "name": "name" } }
]
}
}
↓ SSR
`html`Hello, World
Build dynamic strings during SSR:
`json`
{
"kind": "element",
"tag": "a",
"props": {
"href": {
"expr": "concat",
"items": [
{ "expr": "lit", "value": "/posts/" },
{ "expr": "data", "name": "post", "path": "slug" }
]
}
}
}
↓ SSR
`html`
...
`json`
{
"kind": "markdown",
"content": { "expr": "data", "name": "article", "path": "content" }
}
Rendered with async parsing and Shiki syntax highlighting.
`json`
{
"kind": "code",
"code": { "expr": "lit", "value": "const x = 1;" },
"language": { "expr": "lit", "value": "typescript" }
}
Features:
- Dual theme support (github-light, github-dark)
- CSS custom properties for theme switching
- Preloaded languages: javascript, typescript, json, html, css, python, rust, go, java, bash, markdown
Full support for call and lambda expressions during SSR:
`json`
{
"expr": "call",
"target": { "expr": "data", "name": "posts" },
"method": "filter",
"args": [{
"expr": "lambda",
"param": "post",
"body": { "expr": "get", "base": { "expr": "var", "name": "post" }, "path": "published" }
}]
}
Pass route parameters for dynamic pages:
`json`
{
"route": { "path": "/users/:id" },
"view": {
"kind": "text",
"value": { "expr": "route", "name": "id", "source": "param" }
}
}
Pass external data at render time:
`json`
{
"imports": { "config": "./data/config.json" },
"view": {
"kind": "text",
"value": { "expr": "import", "name": "config", "path": "siteName" }
}
}
Style expressions are evaluated during SSR, producing CSS class strings:
`json`
{
"styles": {
"button": {
"base": "px-4 py-2 rounded",
"variants": {
"variant": {
"primary": "bg-blue-500 text-white",
"secondary": "bg-gray-200 text-gray-800"
}
},
"defaultVariants": { "variant": "primary" }
}
},
"view": {
"kind": "element",
"tag": "button",
"props": {
"className": {
"expr": "style",
"name": "button",
"variants": { "variant": { "expr": "lit", "value": "primary" } }
}
}
}
}
↓ SSR
`html`
Pass style presets via RenderOptions.styles for evaluation.
`html`
typescript
...
`css
/ Light mode /
.shiki { background-color: var(--shiki-light-bg); }
.shiki span { color: var(--shiki-light); }
/ Dark mode /
.dark .shiki { background-color: var(--shiki-dark-bg); }
.dark .shiki span { color: var(--shiki-dark); }
`
Render to a ReadableStream for progressive HTML delivery:
`typescript
import { renderToStream, createHtmlTransformStream } from '@constela/server';
// Render program to stream
const contentStream = renderToStream(compiledProgram, {
flushStrategy: 'batched',
}, {
route: { params: { id: '123' }, query: {}, path: '/posts/123' },
imports: { config: siteConfig },
});
// Wrap with HTML document structure
const htmlStream = contentStream.pipeThrough(
createHtmlTransformStream({
title: 'My Page',
lang: 'en',
stylesheets: ['/styles.css'],
scripts: ['/client.js'],
})
);
// Use with Response (Edge/Workers)
return new Response(htmlStream, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
`
Flush Strategies:
| Strategy | Description |
|----------|-------------|
| immediate | Flush each chunk as soon as it's ready |batched
| | Flush when buffer exceeds 1KB threshold |manual
| | Only flush at the end (for small pages) |
StreamingRenderOptions:
`typescript`
interface StreamingRenderOptions {
flushStrategy: 'immediate' | 'batched' | 'manual';
}
Cancel streaming when the client disconnects:
`typescript
const controller = new AbortController();
const stream = renderToStream(program, { flushStrategy: 'batched' }, {
signal: controller.signal,
});
// Cancel on client disconnect
request.signal.addEventListener('abort', () => {
controller.abort();
});
`
Server-side suspense for async content:
`json`
{
"view": {
"kind": "suspense",
"id": "user-data",
"fallback": {
"kind": "element",
"tag": "div",
"props": { "className": { "expr": "lit", "value": "skeleton" } },
"children": []
},
"content": {
"kind": "component",
"name": "UserProfile",
"props": { "user": { "expr": "data", "name": "user" } }
}
}
}
Renders with markers for client-side hydration:
`html`
- HTML Escaping - All text output is escaped
- DOMPurify - Markdown content is sanitized
- Prototype Pollution Prevention - Same as runtime
> For framework developers only. End users should use the CLI.
`typescript
import { renderToString } from '@constela/server';
const html = await renderToString(compiledProgram, {
route: {
params: { id: '123' },
query: { tab: 'overview' },
path: '/users/123',
},
imports: {
config: { siteName: 'My Site' },
},
styles: {
button: {
base: 'px-4 py-2 rounded',
variants: {
variant: {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-200 text-gray-800',
},
},
defaultVariants: { variant: 'primary' },
},
},
});
`
RenderOptions:
`typescript
interface RenderOptions {
route?: {
params?: Record
query?: Record
path?: string;
};
imports?: Record
styles?: Record
}
interface StylePreset {
base: string;
variants?: Record
defaultVariants?: Record
}
`
Server-rendered HTML can be hydrated on the client:
`json``
{
"version": "1.0",
"lifecycle": { "onMount": "initializeClient" },
"state": { ... },
"actions": [
{
"name": "initializeClient",
"steps": [
{ "do": "storage", "operation": "get", "key": { "expr": "lit", "value": "preferences" }, ... }
]
}
],
"view": { ... }
}
MIT