🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
17 KiB
JQHTML Server-Side Rendering (SSR)
See SPECIFICATION.md for the complete technical specification.
Overview
The JQHTML SSR system renders components to HTML on the server for SEO purposes. Unlike React/Vue/Angular which require separate data-fetching abstractions, JQHTML SSR runs the exact same component code - including on_load() with real HTTP requests.
Primary use case: SEO - rendering meaningful HTML for search engine crawlers.
Installation
npm install @jqhtml/ssr
Or install globally for CLI access:
npm install -g @jqhtml/ssr
Quick Start
Starting the Server
cd /var/www/html/jqhtml/aux/ssr
npm install
# Start on TCP port
node src/server.js --tcp 9876
# Or with Unix socket (better performance, local only)
node src/server.js --socket /tmp/jqhtml-ssr.sock
Server Options
--tcp <port> Listen on TCP port
--socket <path> Listen on Unix socket
--max-bundles <n> Max cached bundle sets (default: 10)
--timeout <ms> Default render timeout (default: 30000)
--help Show help
Reference CLI Example
The package includes jqhtml-ssr-example, a complete reference implementation that demonstrates the full SSR workflow. Use this as the canonical source of truth for building integrations in any language.
Basic Usage
# Start the SSR server in one terminal
jqhtml-ssr --tcp 9876
# In another terminal, run the example
jqhtml-ssr-example \
--vendor ./bundles/vendor.js \
--app ./bundles/app.js \
--component Dashboard_Index_Action \
--base-url http://localhost:3000
Example Options
REQUIRED:
--vendor, -v <path> Path to vendor bundle (contains @jqhtml/core)
--app, -a <path> Path to app bundle (contains components)
--component, -c <name> Component name to render
OPTIONS:
--args <json> Component arguments as JSON (default: {})
--base-url, -b <url> Base URL for fetch requests (default: http://localhost:3000)
--timeout, -t <ms> Render timeout in milliseconds (default: 30000)
--port, -p <port> SSR server port (default: 9876)
--socket, -s <path> Use Unix socket instead of TCP
--format, -f <format> Output format: pretty, json, html-only (default: pretty)
--help, -h Show help message
Output Formats
Pretty (default) - Human-readable output showing:
- Rendered HTML (formatted)
- localStorage cache entries
- sessionStorage cache entries
- Timing information
jqhtml-ssr-example -v vendor.js -a app.js -c My_Component
JSON - Raw server response for programmatic use:
jqhtml-ssr-example -v vendor.js -a app.js -c My_Component --format json > result.json
HTML-only - Just the rendered HTML:
jqhtml-ssr-example -v vendor.js -a app.js -c My_Component --format html-only > output.html
Example with Component Arguments
jqhtml-ssr-example \
-v ./bundles/vendor.js \
-a ./bundles/app.js \
-c User_Profile \
--args '{"user_id": 123, "show_avatar": true}'
Integration Reference
The jqhtml-ssr-example source code (bin/jqhtml-ssr-example.js) is the canonical reference for building integrations. Key implementation details:
- Connection - TCP socket to
localhost:PORTor Unix socket - Request format - Newline-delimited JSON
- Bundle loading - Read JS files, send as
contentstrings - Request structure - See the commented request object in the source
- Response parsing - JSON with
status,payload, anderrorfields
When building integrations in PHP, Python, Go, etc., refer to this example for the exact protocol and data structures.
Integration
Protocol
The server uses a simple newline-delimited JSON protocol over TCP or Unix socket.
Request format:
{
"id": "unique-request-id",
"type": "render",
"payload": {
"bundles": [
{ "id": "vendor", "content": "..." },
{ "id": "app", "content": "..." }
],
"component": "Dashboard_Index_Action",
"args": { "user_id": 123 },
"options": {
"baseUrl": "http://localhost:3000",
"timeout": 30000
}
}
}
Response format:
{
"id": "unique-request-id",
"status": "success",
"payload": {
"html": "<div class=\"Dashboard_Index_Action Component\">...</div>",
"cache": {
"localStorage": {},
"sessionStorage": {}
},
"timing": {
"total_ms": 231,
"bundle_load_ms": 49,
"render_ms": 99
}
}
}
PHP Integration Example
<?php
class JqhtmlSSR {
private $socket;
public function __construct(string $socketPath = '/tmp/jqhtml-ssr.sock') {
$this->socket = stream_socket_client("unix://$socketPath", $errno, $errstr, 5);
if (!$this->socket) {
throw new Exception("SSR connection failed: $errstr");
}
}
public function render(string $component, array $args = [], array $bundles = []): array {
$request = json_encode([
'id' => uniqid('ssr-'),
'type' => 'render',
'payload' => [
'bundles' => $bundles,
'component' => $component,
'args' => $args,
'options' => [
'baseUrl' => 'http://localhost',
'timeout' => 30000
]
]
]) . "\n";
fwrite($this->socket, $request);
$response = fgets($this->socket);
return json_decode($response, true);
}
public function ping(): bool {
$request = json_encode([
'id' => 'ping',
'type' => 'ping',
'payload' => []
]) . "\n";
fwrite($this->socket, $request);
$response = json_decode(fgets($this->socket), true);
return $response['status'] === 'success';
}
}
// Usage
$ssr = new JqhtmlSSR();
$result = $ssr->render('Dashboard_Index_Action', ['user_id' => 123], $bundles);
if ($result['status'] === 'success') {
echo $result['payload']['html'];
}
Node.js Client Example
const net = require('net');
function sendSSRRequest(port, request) {
return new Promise((resolve, reject) => {
const client = net.createConnection({ port }, () => {
client.write(JSON.stringify(request) + '\n');
});
let data = '';
client.on('data', (chunk) => {
data += chunk.toString();
if (data.includes('\n')) {
client.end();
resolve(JSON.parse(data.trim()));
}
});
client.on('error', reject);
setTimeout(() => {
client.end();
reject(new Error('Request timeout'));
}, 30000);
});
}
// Usage
const response = await sendSSRRequest(9876, {
id: 'render-1',
type: 'render',
payload: {
bundles: [
{ id: 'vendor', content: vendorBundleContent },
{ id: 'app', content: appBundleContent }
],
component: 'Dashboard_Index_Action',
args: { user_id: 123 },
options: { baseUrl: 'http://localhost:3000' }
}
});
console.log(response.payload.html);
Request Types
ping
Health check.
{ "id": "1", "type": "ping", "payload": {} }
Response includes uptime_ms.
render
Render a component to HTML.
Required payload fields:
bundles- Array of{ id, content }objectscomponent- Component name to renderoptions.baseUrl- Base URL for relative fetch/XHR requests
Optional:
args- Component arguments (default:{})options.timeout- Render timeout in ms (default: 30000)
flush_cache
Clear bundle cache.
{ "id": "1", "type": "flush_cache", "payload": {} }
Or flush specific bundle:
{ "id": "1", "type": "flush_cache", "payload": { "bundle_id": "app" } }
Error Codes
| Code | Description |
|---|---|
PARSE_ERROR |
Invalid JSON or malformed request |
BUNDLE_ERROR |
JavaScript syntax error in bundle |
COMPONENT_NOT_FOUND |
Component not registered after loading bundles |
RENDER_ERROR |
Error during component lifecycle |
RENDER_TIMEOUT |
Component did not reach ready state in time |
INTERNAL_ERROR |
Unexpected server error |
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ SSR Server (long-running Node.js process) │
│ │
│ 1. Receive request (component name, args, bundles) │
│ 2. Load bundles (cached) into isolated jsdom environment │
│ 3. Execute component lifecycle (including real fetch()) │
│ 4. Wait for on_ready │
│ 5. Return HTML + localStorage/sessionStorage cache dump │
└─────────────────────────────────────────────────────────────────┘
Key features:
- Real data fetching - Components make actual HTTP requests during SSR
- Cache export - Server exports storage state for instant client hydration
- Bundle caching - Prepared bundle code cached with LRU eviction
- Request isolation - Each render gets fresh jsdom environment
- URL rewriting - Relative URLs resolved against baseUrl
SSR Preload API (Hydration Acceleration)
The SSR Preload API eliminates redundant on_load() calls during client hydration. When the SSR server already fetched data for a component, the client can skip the identical fetch and use the captured data directly.
Without preload: SSR fetches data → client boots → client fetches same data again (wasted round-trip).
With preload: SSR fetches data → captures it → client receives captured data → client boots with preloaded data → no redundant fetch.
Server-Side: Capturing Data
During SSR rendering, enable data capture to record each component's loaded data:
// 1. Enable capture before rendering
jqhtml.start_data_capture();
// 2. Render the component (on_load() runs, data is captured automatically)
// ... component renders normally ...
// 3. Retrieve captured data (one-shot: clears buffer after retrieval)
const captured = jqhtml.get_captured_data();
// Returns: [{ component: 'DashboardIndex', args: { user_id: 123 }, data: { items: [...] } }, ...]
// 4. Stop capture when done
jqhtml.stop_data_capture();
// 5. Include captured data in SSR response
return { html: renderedHtml, preload: captured };
Capture rules:
- Only components with
on_load()are captured (static components excluded) - Deduplicates by component name + args (Load Coordinator aware)
get_captured_data()is one-shot: clears the buffer on retrievalstart_data_capture()is idempotent (safe to call multiple times)
Client-Side: Injecting Preloaded Data
Before components boot on the client, seed the preload cache with SSR-captured data:
// 1. Inject preloaded data BEFORE components initialize
jqhtml.set_preload_data(ssrPreloadData);
// 2. Components boot → _load() finds preload cache hit → skips on_load() entirely
// ... components initialize normally, but with instant data ...
// 3. Clear unconsumed entries after hydration is complete
jqhtml.clear_preload_data();
Preload rules:
set_preload_data(entries)must be called before components boot- Each preload entry is consumed on first use (one-shot per key)
set_preload_data(null)orset_preload_data([])is a no-opclear_preload_data()removes any unconsumed entries
Complete Data Flow
SSR Server:
1. jqhtml.start_data_capture()
2. Render component (on_load() fetches data → CAPTURED)
3. captured = jqhtml.get_captured_data()
4. Return { html, preload: captured }
Client:
1. jqhtml.set_preload_data(ssr_preload)
2. Components boot → _load() hits preload cache → skips on_load()
3. jqhtml.clear_preload_data()
Updated PHP Integration Example (with Preload)
// Usage with preload data
$ssr = new JqhtmlSSR();
$result = $ssr->render('Dashboard_Index_Action', ['user_id' => 123], $bundles);
if ($result['status'] === 'success') {
$html = $result['payload']['html'];
$preload = json_encode($result['payload']['preload'] ?? []);
echo $html;
echo "<script>jqhtml.set_preload_data({$preload});</script>";
echo "<script src='/bundles/vendor.js'></script>";
echo "<script src='/bundles/app.js'></script>";
echo "<script>jqhtml.boot().then(() => jqhtml.clear_preload_data());</script>";
}
Updated Node.js Client Example (with Preload)
const response = await sendSSRRequest(9876, {
id: 'render-1',
type: 'render',
payload: {
bundles: [
{ id: 'vendor', content: vendorBundleContent },
{ id: 'app', content: appBundleContent }
],
component: 'Dashboard_Index_Action',
args: { user_id: 123 },
options: { baseUrl: 'http://localhost:3000' }
}
});
const { html, preload } = response.payload;
// Embed preload data in the page for client-side hydration
const pageHtml = `
${html}
<script>
jqhtml.set_preload_data(${JSON.stringify(preload || [])});
</script>
<script src="/bundles/vendor.js"></script>
<script src="/bundles/app.js"></script>
<script>
jqhtml.boot().then(() => jqhtml.clear_preload_data());
</script>
`;
API Reference
| Method | Description |
|---|---|
jqhtml.start_data_capture() |
Enable data capture mode (idempotent) |
jqhtml.get_captured_data() |
Returns captured entries and clears buffer (one-shot) |
jqhtml.stop_data_capture() |
Stops capture and clears all capture state |
jqhtml.set_preload_data(entries) |
Seeds preload cache with SSR-captured data |
jqhtml.clear_preload_data() |
Clears unconsumed preload entries |
File Structure
aux/ssr/
├── SPECIFICATION.md # Complete technical specification
├── README.md # This file
├── package.json
├── bin/
│ └── jqhtml-ssr-example.js # Reference CLI example (canonical integration source)
├── src/
│ ├── index.js # Package exports
│ ├── server.js # TCP/Unix socket server (jqhtml-ssr CLI)
│ ├── environment.js # jsdom + jQuery environment setup
│ ├── bundle-cache.js # Bundle caching with LRU eviction
│ ├── http-intercept.js # fetch/XHR URL rewriting
│ ├── storage.js # Fake localStorage/sessionStorage
│ └── protocol.js # Message parsing/formatting
├── test/
│ ├── test-protocol.js # Protocol unit tests (16 tests)
│ ├── test-storage.js # Storage unit tests (13 tests)
│ └── test-server.js # Server integration tests (6 tests)
├── test-manual.js # Manual test with real bundles
└── test-debug.js # Debug script for troubleshooting
Running Tests
# Unit tests
node test/test-protocol.js
node test/test-storage.js
node test/test-server.js
# Manual test with real bundles (requires bundles_for_ssr_test/)
node test-manual.js
Critical Technical Discoveries
1. jQuery Module Load Order (CRITICAL)
jQuery MUST be require()'d BEFORE global.window is set.
// CORRECT - jQuery returns a factory function
const jqueryFactory = require('jquery'); // First!
global.window = jsdomWindow; // Second
const $ = jqueryFactory(jsdomWindow); // Returns working jQuery
// WRONG - jQuery auto-initializes and returns an object
global.window = jsdomWindow; // First (BAD)
const jqueryFactory = require('jquery'); // jQuery sees window, auto-binds
const $ = jqueryFactory(jsdomWindow); // Returns object, not function!
Why: jQuery checks for global.window at require-time. If it exists, jQuery auto-initializes against that window instead of returning a factory.
2. Use vm.runInThisContext Not vm.createContext
Using vm.createContext creates VM context boundary issues where function references don't work across contexts. jQuery wrapper functions fail because closures can't access variables across the boundary.
Solution: Use vm.runInThisContext which executes in Node's global context.
3. Bundle Loading Order
Load bundles in dependency order:
- Vendor bundle - Contains
@jqhtml/core, initializeswindow.jqhtml - App bundle - Contains templates and component classes
4. Global Cleanup
When destroying environments, set globals to undefined instead of using delete:
global.window = undefined; // Safe
// delete global.window; // Can cause issues
Dependencies
{
"jsdom": "^24.0.0",
"jquery": "^3.7.1"
}
References
- SPECIFICATION.md - Complete technical specification
- /packages/core/src/component.ts - Component lifecycle
- /docs/official/18_boot.md - Client-side boot/hydration
- jsdom documentation
- jQuery in Node.js