@fictjs/ssr
v0.16.0
Published
Fict server-side rendering
Maintainers
Readme
@fictjs/ssr
Fict's Server-Side Rendering (SSR) package, providing high-performance server-side rendering and client-side resumability capabilities.
Table of Contents
- Overview
- Installation
- Quick Start
- Core Concepts
- API Reference
- Architecture Design
- Partial Prerendering
- Edge Runtime
- Integration with Vite
- Advanced Usage
- Performance Optimization
- Production Guides
- Troubleshooting
Overview
Fict SSR adopts a Resumability architecture, which is fundamentally different from traditional Hydration:
| Feature | Traditional Hydration | Fict Resumability | | ------------------------- | --------------------------------- | --------------------------------- | | Client JS Execution | Re-executes entire component tree | Only executes on interaction | | Time to Interactive (TTI) | High (waits for hydration) | Low (Zero JS execution) | | Handler Loading | All preloaded | Lazy loaded on demand | | State Restoration | Re-calculated | Restored from serialized snapshot |
How it Works
┌─────────────────────────────────────────────────────────────────┐
│ Server-Side Rendering │
├─────────────────────────────────────────────────────────────────┤
│ 1. Execute component code │
│ 2. Generate HTML + Serialize state snapshot │
│ 3. Inject QRL (Qualified Resource Locator) references │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Client-Side Resumability │
├─────────────────────────────────────────────────────────────────┤
│ 1. Parse snapshot, store in memory │
│ 2. Install event delegation listeners │
│ 3. On user interaction: │
│ a. Lazy load handler chunk │
│ b. Restore component state (from snapshot) │
│ c. Establish reactive bindings │
│ d. Execute handler │
└─────────────────────────────────────────────────────────────────┘Installation
pnpm add @fictjs/ssrQuick Start
Basic SSR
import { renderToString } from '@fictjs/ssr'
import { App } from './App'
// Server-side
const html = renderToString(() => <App />)SSR with Resumability
import { renderToString } from '@fictjs/ssr'
import { App } from './App'
const html = renderToString(() => <App />, {
includeSnapshot: true, // Include state snapshot (default true)
containerId: 'app',
manifest: './dist/client/fict.manifest.json',
})Client-Side Resumability
// entry-client.tsx
import { installResumableLoader } from '@fictjs/runtime/loader'
// Load manifest (production)
async function loadManifest() {
const res = await fetch('/fict.manifest.json')
if (res.ok) {
globalThis.__FICT_MANIFEST__ = await res.json()
}
}
async function init() {
await loadManifest()
installResumableLoader({
events: ['click', 'input', 'change', 'submit'],
prefetch: {
visibility: true,
visibilityMargin: '200px',
hover: true,
hoverDelay: 50,
},
})
}
init()Core Concepts
1. QRL (Qualified Resource Locator)
QRL is the URL format Fict uses for lazy loading handlers:
virtual:fict-handler:/path/to/file.tsx$$__fict_e0#default
│ │ │ │
│ │ │ └─ Export Name
│ │ └─ Handler ID
│ └─ Source File Path
└─ Virtual Module PrefixRepresentation in HTML:
<button on:click="virtual:fict-handler:/src/App.tsx$$__fict_e0#default">Click me</button>2. State Snapshot
During server-side rendering, component state is serialized into JSON and injected into HTML:
<script id="__FICT_SNAPSHOT__" type="application/json">
{
"v": 1,
"scopes": {
"s1": {
"id": "s1",
"slots": [
[0, "sig", 10], // Index 0: signal, value 10
[1, "store", {...}], // Index 1: store
[2, "raw", null] // Index 2: raw value
],
"vars": { "count": 0 }
}
}
}
</script>Supported Serialization Types:
| Type | Tag | Description |
| --------- | ------------------------- | -------------------------- |
| Date | __t: 'd' | Stored as timestamp |
| Map | __t: 'm' | Stored as entries array |
| Set | __t: 's' | Stored as array |
| RegExp | __t: 'r' | Stored as source + flags |
| undefined | __t: 'u' | Special tag |
| NaN | __t: 'n' | Special tag |
| Infinity | __t: '+i' / __t: '-i' | Positive/Negative Infinity |
| BigInt | __t: 'b' | Stored as string |
3. Scope Registration
Each resumable component instance has a unique scope ID:
<fict-host
data-fict-s="s1" <!-- scope ID -->
data-fict-h="/assets/index.js#__fict_r0" <!-- resume handler -->
data-fict-t="Counter@file:///src/App.tsx" <!-- Component Type -->
>
...
</fict-host>4. Automatic Handler Extraction
The Fict compiler supports two ways to extract handlers:
Explicit Extraction (using $ suffix):
<button onClick$={() => count++}> // Always extractedAutomatic Extraction (enable autoExtractHandlers):
<button onClick={() => count++}> // Simple, might not extract
<button onClick={() => fetchData()}> // Complex, auto-extracted
<button onClick={handleSubmit}> // External reference, auto-extractedHeuristic Rules for Auto-Extraction:
| Condition | Extracted? | | --------------------------- | ---------- | | External function reference | ✅ | | Contains external calls | ✅ | | Contains async/await | ✅ | | AST node count ≥ threshold | ✅ | | Simple expression | ❌ |
API Reference
renderToString
function renderToString(view: () => FictNode, options?: RenderToStringOptions): stringOptions:
interface RenderToStringOptions {
// DOM Configuration
dom?: SSRDom
document?: Document
window?: Window
html?: string
// Container Configuration
container?: HTMLElement
containerTag?: string // Default: 'div'
containerId?: string
containerAttributes?: Record<string, string | number | boolean>
// Output Configuration
includeContainer?: boolean // Include container tag
fullDocument?: boolean // Output full HTML document
doctype?: string | null
// Resumability Configuration
includeSnapshot?: boolean // Default: true
snapshotScriptId?: string // Default: '__FICT_SNAPSHOT__'
snapshotTarget?: 'container' | 'body' | 'head'
// Runtime Configuration
exposeGlobals?: boolean // Default: true
manifest?: Record<string, string> | string
}renderToDocument
function renderToDocument(
view: () => FictNode,
options?: RenderToStringOptions,
): RenderToDocumentResult
interface RenderToDocumentResult extends SSRDom {
html: string
container: HTMLElement
dispose: () => void
}Returns a DOM object for further manipulation or streaming rendering.
renderToStream
Stream HTML to a Web ReadableStream. In shell mode, the fallback shell is
sent immediately and Suspense boundaries patch in as they resolve.
Note: In
shellmode, resumable snapshots are emitted incrementally asdata-fict-snapshotscripts (shell + each resolved boundary). WhensnapshotTarget: 'head', each chunk injects into<head>via a small script.
import { renderToStream } from '@fictjs/ssr'
const stream = renderToStream(() => <App />, { mode: 'shell' })renderToPipeableStream
Node.js-style stream variant (compatible with pipe()).
import { renderToPipeableStream } from '@fictjs/ssr'
const { pipe, shellReady, allReady } = renderToPipeableStream(() => <App />, { mode: 'shell' })
pipe(res)
await shellReady
await allReadyUse
renderToStream()in Edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy).
renderToPartial
Generate a complete shell HTML plus a deferred patch stream for Partial Prerendering workflows. This is an advanced API and currently considered Preview in v1.0.
import { renderToPartial } from '@fictjs/ssr'
const { shell, stream, shellReady, allReady } = renderToPartial(() => <App />, {
mode: 'shell',
fullDocument: true,
})shell: complete HTML document (fallbacks + boundary markers + initial snapshots)stream: patch chunks (data-fict-suspense+ incremental snapshots) for deferred deliveryshellReady/allReady: readiness signals for orchestration
createSSRDocument
function createSSRDocument(html?: string): SSRDom
interface SSRDom {
window: Window
document: Document
}Creates a virtual DOM environment for SSR.
installResumableLoader
function installResumableLoader(options?: ResumableLoaderOptions): void
interface ResumableLoaderOptions {
document?: Document
snapshotScriptId?: string
events?: string[] // Default: DelegatedEvents
onSnapshotIssue?: (issue: SnapshotIssue) => void
prefetch?: PrefetchStrategy | false
}
interface SnapshotIssue {
code:
| 'snapshot_parse_error'
| 'snapshot_invalid_shape'
| 'snapshot_unsupported_version'
| 'scope_snapshot_missing'
message: string
source: string
expectedVersion: number
actualVersion?: number
scopeId?: string
}
interface PrefetchStrategy {
visibility?: boolean // Default: true
visibilityMargin?: string // Default: '200px'
hover?: boolean // Default: true
hoverDelay?: number // Default: 50
}Architecture Design
Build Time
┌──────────────────────────────────────────────────────────────┐
│ Fict Compiler │
├──────────────────────────────────────────────────────────────┤
│ │
│ Source Code Build Output │
│ ─────────── ──────────── │
│ onClick$={() => count++} 1. Main bundle (incl resume fn) │
│ 2. Handler chunk (lazy loaded) │
│ 3. QRL Ref (HTML attribute) │
│ │
└──────────────────────────────────────────────────────────────┘Generated Code Structure:
// Main bundle
const __fict_r0 = (scopeId, host) => {
// Resume Function: Restore state + Bind reactivity
const scope = __fictGetSSRScope(scopeId)
let count = __fictRestoreSignal(scope, 0)
$effect(() => {
/* Bind DOM update */
})
}
__fictRegisterResume('__fict_r0', __fict_r0)
// Handler chunk (separate file)
export default (scopeId, event, el) => {
const [count] = __fictUseLexicalScope(scopeId, ['count'])
count++ // Trigger signal update
}Runtime
┌─────────────────────────────────────────────────────────────────┐
│ Resumable Loader │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Parse snapshot ──────────► Store in snapshotState │
│ │
│ 2. Register event delegation ──────► doc.addEventListener │
│ │
│ 3. On Event Trigger: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ a. Look up on:* attribute to get QRL │ │
│ │ b. Check if hydrated │ │
│ │ c. If not hydrated: │ │
│ │ - Load resume module │ │
│ │ - Get resume fn from registry │ │
│ │ - Execute resume (restore state + bind) │ │
│ │ d. Load handler chunk │ │
│ │ e. Execute handler │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘Manifest File
Generated detailed fict.manifest.json during production build, mapping virtual modules to actual chunks:
{
"virtual:fict-handler:/src/App.tsx$$__fict_e0": "/assets/handler-e0-abc123.js",
"virtual:fict-handler:/src/App.tsx$$__fict_e1": "/assets/handler-e1-def456.js",
"file:///src/App.tsx": "/assets/index-xyz789.js"
}Integration with Vite
Partial Prerendering
renderToPartial() enables a PPR-style split:
- Shell phase: serve/cache
shellas static-first HTML. - Deferred phase: deliver
streampatches for resolved Suspense boundaries.
This keeps shell TTFB low while still allowing server-resolved dynamic islands.
Edge Runtime
@fictjs/ssr can run in Edge environments via renderToStream() and renderToString().
Notes:
manifestas an object works in all runtimes.manifestas a file path string requires Node.js or Deno sync file access.- Prefer
renderToStream()overrenderToPipeableStream()for Edge.
vite.config.ts
import { defineConfig } from 'vite'
import fict from '@fictjs/vite-plugin'
export default defineConfig({
plugins: [
fict({
resumable: true,
autoExtractHandlers: true,
autoExtractThreshold: 3,
}),
],
})Configuration Options
interface FictPluginOptions {
// Resumability
resumable?: boolean // Enable resumable mode
autoExtractHandlers?: boolean // Auto extract handlers
autoExtractThreshold?: number // Auto extract threshold (default: 3)
// Build Options
fineGrainedDom?: boolean // Fine-grained DOM updates
optimize?: boolean // HIR Optimization
// ...
}Build Output
dist/
├── client/
│ ├── index.html
│ ├── fict.manifest.json
│ └── assets/
│ ├── index-abc123.js # Main bundle
│ ├── chunk-xyz789.js # Shared chunk (runtime)
│ ├── handler-__fict_e0-*.js # Handler chunk
│ ├── handler-__fict_e1-*.js
│ └── handler-__fict_e2-*.js
└── server/
└── entry-server.js # SSR bundleAdvanced Usage
Custom SSR Server
// server.js
import express from 'express'
import { renderToString } from '@fictjs/ssr'
import { App } from './dist/server/entry-server.js'
const app = express()
// Static assets
app.use('/assets', express.static('dist/client/assets'))
// SSR Route
app.get('*', async (req, res) => {
const manifest = JSON.parse(
fs.readFileSync('dist/client/fict.manifest.json', 'utf-8')
)
const appHtml = renderToString(() => <App url={req.url} />, {
manifest,
containerId: 'app',
})
const html = template.replace('<!--app-html-->', appHtml)
res.send(html)
})Streaming Rendering
import { renderToPipeableStream } from '@fictjs/ssr'
app.get('*', async (req, res) => {
const { pipe, shellReady, allReady } = renderToPipeableStream(() => <App />, {
mode: 'shell',
})
res.setHeader('Content-Type', 'text/html; charset=utf-8')
pipe(res)
await shellReady
await allReady
})Prefetch Strategy
installResumableLoader({
prefetch: {
// Prefetch when element enters viewport
visibility: true,
visibilityMargin: '500px', // Start prefetch 500px early
// Prefetch on hover
hover: true,
hoverDelay: 100, // 100ms debounce
},
})Disable Extraction for Specific Handlers
// Use normal onClick and set autoExtractHandlers: false
// Or ensure handler is simple enough not to trigger auto-extraction
<button onClick={() => count++}> // Simple, not extractedPerformance Optimization
1. Handler Chunk Size Optimization
// ❌ Not Recommended: Large dependency in handler
<button onClick$={async () => {
const { parse } = await import('large-library')
parse(data)
}}>
// ✅ Recommended: Import at component level
import { parse } from 'large-library'
<button onClick$={() => parse(data)}>2. Prefetch Tuning
// For critical interactions, use more aggressive preloading
installResumableLoader({
prefetch: {
visibility: true,
visibilityMargin: '1000px', // Prefetch even earlier
},
})3. Reduce Serialization Overhead
// ❌ Avoid serializing large objects
let largeData = $state(hugeArray)
// ✅ Recommended: Serialize only necessary data
let dataId = $state(id) // Store ID only, fetch on clientProduction Guides
Troubleshooting
Common Issues
1. "Failed to fetch dynamically imported module"
Cause: Manifest not loaded correctly or QRL path mismatch.
Solution:
// Ensure manifest is loaded before installResumableLoader
await loadManifest()
installResumableLoader(...)2. Handler called but DOM not updating
Cause: Resume function not executed or not correctly registered.
Check:
// Ensure resume function is registered
import { __fictGetResume } from '@fictjs/runtime/internal'
console.log(__fictGetResume('__fict_r0')) // Should return function3. "ReferenceError: xxx is not defined"
Cause: Handler chunk references uncaptured variable.
Solution: Ensure all required variables are in closure scope.
4. Snapshot too large
Cause: Serializing large amount of data.
Solution:
// Use lazy initialization
let data = $state(null) // Initial null
onMount(async () => {
data = await fetchData() // Fetch on client
})Debugging Tips
// Enable loader logs (during dev)
// Console.log statements in loader.ts can help debug
// Check manifest content
console.log(globalThis.__FICT_MANIFEST__)
// Check snapshot content
const fullSnapshot = document.getElementById('__FICT_SNAPSHOT__')
if (fullSnapshot?.textContent) {
console.log(JSON.parse(fullSnapshot.textContent))
}
// In streaming shell mode, snapshots are chunked:
const snapshots = document.querySelectorAll('script[data-fict-snapshot]')
for (const script of snapshots) {
console.log(JSON.parse(script.textContent || '{}'))
}Related Packages
@fictjs/runtime- Core runtime, containing signal/effect system@fictjs/compiler- Babel plugin, handling JSX transform and handler extraction@fictjs/vite-plugin- Vite integration, handling build and code splitting
License
MIT
