Progressive breadcrumb resolution with caching, fix double headers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
18 KiB
JQHTML Server-Side Rendering (SSR) Specification
Document Status: Active specification for
@jqhtml/ssrpackage development.
Overview
The JQHTML SSR system provides server-side rendering of JQHTML components via a persistent socket server. Unlike React/Vue/Angular SSR which require separate data-fetching abstractions, JQHTML SSR runs the exact same component code on the server - including on_load() with real HTTP requests.
Primary use case: SEO - rendering meaningful HTML for search engine crawlers.
Architecture: Long-running Node.js process accepting requests via TCP or Unix socket.
Design Principles
- Same code path - Components run identically on server and client
- Real data fetching -
on_load()makes actual HTTP requests during SSR - Cache export - Server exports localStorage/sessionStorage state for client hydration
- Request isolation - Each render request has completely fresh state
- Bundle caching - Parsed bundles stay in memory; environment state is cloned per request
- No authentication - SSR is for SEO; crawlers aren't authenticated
Server Architecture
Process Model
┌─────────────────────────────────────────────────────────────────┐
│ SSR Server Process (long-running) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Bundle Cache │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Bundle Set A│ │ Bundle Set B│ │ Bundle Set C│ │ │
│ │ │ (hash: abc) │ │ (hash: def) │ │ (hash: ghi) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Request Handler │ │
│ │ │ │
│ │ Request → Create Isolated Environment │ │
│ │ → Load Bundle Set (from cache or parse) │ │
│ │ → Execute Component Lifecycle │ │
│ │ → Capture HTML + Cache State │ │
│ │ → Return Response │ │
│ │ → Destroy Environment │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Bundle Caching Strategy
Bundles are identified by a hash of their contents (or explicit version string). When a request comes in:
- Check if bundle set exists in cache by identifier
- If cached: clone the parsed bundle environment
- If not cached: parse bundles, store in cache, then clone
Cache eviction: LRU with configurable max entries (default: 10 bundle sets).
Request Isolation
Each render request gets:
- Fresh jsdom Document
- Fresh fake localStorage/sessionStorage
- Fresh global state (window.jqhtml, etc.)
- Cloned bundle execution context
This ensures one request cannot affect another, even if component code modifies global state.
Protocol Specification
Transport
Support two transport modes:
- TCP: For network access (e.g., PHP/Laravel calling from different process)
- Unix Socket: For local IPC (lower latency, better security)
Message Format
JSON-based protocol with newline-delimited messages.
Request Format
{
"id": "request-uuid-123",
"type": "render",
"payload": {
"bundles": [
{
"id": "vendor-abc123",
"content": "... javascript content ..."
},
{
"id": "app-def456",
"content": "... javascript content ..."
}
],
"component": "Dashboard_Index",
"args": {
"page": 1,
"filter": "active"
},
"options": {
"baseUrl": "https://example.com",
"timeout": 30000
}
}
}
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Unique request identifier for correlation |
type |
string | Yes | Request type: "render", "ping", "flush_cache" |
payload.bundles |
array | Yes | JavaScript bundles to load |
payload.bundles[].id |
string | Yes | Bundle identifier (for caching) |
payload.bundles[].content |
string | Yes | JavaScript source code |
payload.component |
string | Yes | Component name to render |
payload.args |
object | No | Arguments to pass to component |
payload.options.baseUrl |
string | Yes | Base URL for relative fetch/ajax requests |
payload.options.timeout |
number | No | Max render time in ms (default: 30000) |
Response Format
Success Response:
{
"id": "request-uuid-123",
"status": "success",
"payload": {
"html": "<div class=\"Dashboard_Index Component\">...</div>",
"cache": {
"localStorage": {
"jqhtml_cache:Dashboard_Index:{\"page\":1}": "{\"items\":[...],\"total\":42}"
},
"sessionStorage": {}
},
"timing": {
"total_ms": 245,
"bundle_load_ms": 12,
"render_ms": 233
}
}
}
Error Response:
{
"id": "request-uuid-123",
"status": "error",
"error": {
"code": "RENDER_TIMEOUT",
"message": "Component render exceeded 30000ms timeout",
"stack": "..."
}
}
Error Codes:
| Code | HTTP Equivalent | Description |
|---|---|---|
PARSE_ERROR |
400 | Invalid request JSON |
BUNDLE_ERROR |
400 | JavaScript bundle failed to parse/execute |
COMPONENT_NOT_FOUND |
404 | Requested component not registered |
RENDER_ERROR |
500 | Component threw during lifecycle |
RENDER_TIMEOUT |
504 | Render exceeded timeout |
INTERNAL_ERROR |
500 | Unexpected server error |
Other Request Types
Ping (health check):
{"id": "ping-1", "type": "ping", "payload": {}}
Response:
{"id": "ping-1", "status": "success", "payload": {"uptime_ms": 123456}}
Flush cache:
{"id": "flush-1", "type": "flush_cache", "payload": {"bundle_id": "vendor-abc123"}}
Response:
{"id": "flush-1", "status": "success", "payload": {"flushed": true}}
URL Rewriting / HTTP Interception
Components make HTTP requests via fetch() or jQuery's $.ajax(). These need URL rewriting for server-side execution.
Interception Points
global.fetch- Override with custom implementationXMLHttpRequest- Override for jQuery compatibility
URL Rewriting Rules
Given baseUrl: "https://example.com":
| Original | Rewritten |
|---|---|
/api/users |
https://example.com/api/users |
//cdn.example.com/data.json |
https://cdn.example.com/data.json |
https://other.com/api |
https://other.com/api (unchanged) |
api/users (relative) |
https://example.com/api/users |
Implementation
// Pseudocode for fetch override
const originalFetch = globalThis.fetch;
globalThis.fetch = function(url, options) {
const resolvedUrl = resolveUrl(url, baseUrl);
return originalFetch(resolvedUrl, {
...options,
// Strip any auth headers for SEO safety
headers: filterHeaders(options?.headers)
});
};
jQuery uses XMLHttpRequest internally, so overriding XHR covers $.ajax(), $.get(), $.post().
JQHTML Core Modifications
SSR Mode Flag
Add ssr_mode configuration to jqhtml initialization:
// In SSR environment, before loading bundles:
window.__JQHTML_SSR_MODE__ = true;
// Or via jqhtml config:
jqhtml.configure({ ssr_mode: true });
Fake Storage Implementation
When ssr_mode is true, replace localStorage and sessionStorage with capturing implementations:
class SSR_Storage {
constructor() {
this._data = new Map();
}
getItem(key) {
return this._data.get(key) ?? null;
}
setItem(key, value) {
this._data.set(key, String(value));
}
removeItem(key) {
this._data.delete(key);
}
clear() {
this._data.clear();
}
get length() {
return this._data.size;
}
key(index) {
return Array.from(this._data.keys())[index] ?? null;
}
// SSR-specific: export all data
_export() {
return Object.fromEntries(this._data);
}
}
Cache Export
At end of SSR render, export storage state:
function exportCacheState() {
return {
localStorage: window.localStorage._export(),
sessionStorage: window.sessionStorage._export()
};
}
Behaviors Disabled in SSR Mode
When ssr_mode is true:
| Feature | Behavior |
|---|---|
Event handlers (@click, etc.) |
Rendered as attributes but not bound |
setInterval/setTimeout |
Allowed but auto-cleared after render |
requestAnimationFrame |
Stubbed (no-op) |
| CSS animations | Ignored |
window.location mutations |
Blocked with warning |
Client-Side Hydration
The SSR server returns HTML and cache state. Client-side integration is the responsibility of the external tooling, but the expected pattern is:
1. Server Renders Page with SSR HTML
<div id="app">
<!-- SSR-rendered component HTML -->
<div class="Dashboard_Index Component" data-cid="abc123">
<h1>Dashboard</h1>
<div class="User_List Component" data-cid="def456">
<ul>
<li>User 1</li>
<li>User 2</li>
</ul>
</div>
</div>
</div>
<script type="application/json" id="jqhtml-ssr-cache">
{
"localStorage": {
"jqhtml_cache:Dashboard_Index:{...}": "...",
"jqhtml_cache:User_List:{...}": "..."
},
"sessionStorage": {}
}
</script>
2. Client JavaScript Hydrates
// 1. Load cache data into storage BEFORE any components initialize
const cacheData = JSON.parse(document.getElementById('jqhtml-ssr-cache').textContent);
for (const [key, value] of Object.entries(cacheData.localStorage)) {
localStorage.setItem(key, value);
}
for (const [key, value] of Object.entries(cacheData.sessionStorage)) {
sessionStorage.setItem(key, value);
}
// 2. Replace SSR HTML with live components
// Option A: Clear and re-render (simple, guaranteed correct)
$('#app').empty();
$('#app').component('Dashboard_Index', { page: 1, filter: 'active' });
// Option B: Use boot() if SSR output used placeholder format
// jqhtml.boot($('#app'));
Why Replace Instead of Attach?
JQHTML's model is simpler than React/Vue hydration:
- SSR provides meaningful HTML for SEO crawlers
- Client replaces with live components using cached data
- Cache ensures
on_load()returns instantly (cache hit) - No complex DOM diffing or event attachment needed
- Single code path - same render logic everywhere
The "flicker" is avoided because:
- Cache is pre-populated before components initialize
- Components render synchronously from cache
- Browser paints once with final result
Testing Strategy
Unit Tests (Node.js)
Test SSR server components in isolation:
tests/ssr/
├── test-bundle-cache.js # Bundle caching logic
├── test-url-rewriting.js # Fetch/XHR URL rewriting
├── test-storage-capture.js # Fake localStorage/sessionStorage
├── test-isolation.js # Request isolation
├── test-protocol.js # Message parsing/formatting
└── test-timeout.js # Render timeout handling
Integration Tests
Test full SSR render cycle:
tests/ssr_integration/
├── basic_render/
│ ├── test_component.jqhtml
│ ├── test_component.js
│ └── run-test.sh # Starts SSR server, sends request, verifies output
├── fetch_data/
│ ├── fetching_component.jqhtml
│ ├── fetching_component.js
│ ├── mock_server.js # Mock API server for fetch testing
│ └── run-test.sh
├── nested_components/
│ └── ...
├── cache_export/
│ └── ...
└── error_handling/
└── ...
Test Runner Integration
Tests should work with existing run-all-tests.sh:
#!/bin/bash
# tests/ssr_integration/basic_render/run-test.sh
# Start SSR server in background
node /var/www/html/jqhtml/aux/ssr/server.js --port 9999 &
SSR_PID=$!
sleep 1
# Run test
node test-runner.js
# Capture result
RESULT=$?
# Cleanup
kill $SSR_PID
exit $RESULT
File Structure
aux/ssr/
├── SPECIFICATION.md # This document
├── README.md # Quick start and API reference
├── package.json
├── src/
│ ├── server.js # TCP/Unix socket server
│ ├── renderer.js # SSR render orchestration
│ ├── environment.js # jsdom environment setup
│ ├── bundle-cache.js # Bundle caching with LRU
│ ├── http-intercept.js # fetch/XHR URL rewriting
│ ├── storage.js # Fake localStorage/sessionStorage
│ └── protocol.js # Message parsing/formatting
├── bin/
│ └── jqhtml-ssr # CLI entry point
└── test/
└── ...
CLI Interface
# Start SSR server on TCP port
jqhtml-ssr --tcp 9876
# Start SSR server on Unix socket
jqhtml-ssr --socket /tmp/jqhtml-ssr.sock
# With options
jqhtml-ssr --tcp 9876 --max-bundles 20 --timeout 60000
# One-shot render (for testing)
jqhtml-ssr --render \
--bundle vendor.js \
--bundle app.js \
--component Dashboard_Index \
--args '{"page":1}' \
--base-url https://example.com
Performance Considerations
Bundle Caching
- Bundles are parsed once and cached by ID
- Environment state is cloned per request (not re-parsed)
- LRU eviction prevents unbounded memory growth
Request Handling
- Requests processed concurrently (Node.js async I/O)
- Each request gets isolated environment
- Heavy renders don't block other requests
Memory Management
- jsdom environments are destroyed after each request
- Weak references where possible for cache entries
- Configurable max concurrent renders
Timeout Handling
- Each render has configurable timeout (default 30s)
- Timeout triggers environment destruction
- Partial renders are not returned
Security Considerations
- No cookies/auth - SSR requests don't forward authentication
- URL whitelist - Only allow fetch to configured baseUrl domain
- Code execution - Only execute provided bundles, no eval of request data
- Resource limits - Timeout, memory limits per request
- Socket permissions - Unix socket should have restricted permissions
Future Considerations
Streaming SSR
For large pages, stream HTML as it becomes available:
- Send opening tags immediately
- Stream component HTML as each completes
- Requires chunked transfer encoding
Not in initial scope - adds significant complexity.
Partial Hydration
Only hydrate interactive components, leave static HTML as-is:
- Mark components as
staticorinteractive - Static components not replaced on client
Not in initial scope - current model is simpler.
Worker Threads
For CPU-bound renders, use Node.js worker threads:
- Main thread handles I/O
- Workers execute renders
- Better CPU utilization
Consider for v2 if single-threaded performance is insufficient.
Implementation Phases
Phase 1: Core SSR Server
- Protocol implementation (request/response parsing)
- Bundle loading and caching
- jsdom environment with SSR mode
- Basic render (create component, wait for ready, return HTML)
- Error handling and timeouts
Phase 2: HTTP Interception
- fetch() override with URL rewriting
- XMLHttpRequest override for jQuery
- baseUrl configuration
Phase 3: Cache Export
- Fake localStorage/sessionStorage implementation
- Cache state export in response
- Integration with jqhtml's cache system
Phase 4: Testing
- Unit tests for each module
- Integration tests for full render cycle
- Performance benchmarks
- Test runner integration
Phase 5: Documentation & Polish
- CLI help and examples
- Integration guide for Laravel/PHP
- Troubleshooting guide
Appendix: Example Session
# Terminal 1: Start SSR server
$ jqhtml-ssr --tcp 9876
[SSR] Server listening on tcp://0.0.0.0:9876
# Terminal 2: Send render request
$ echo '{"id":"1","type":"render","payload":{"bundles":[{"id":"test","content":"..."}],"component":"Hello_World","args":{},"options":{"baseUrl":"http://localhost"}}}' | nc localhost 9876
{"id":"1","status":"success","payload":{"html":"<div class=\"Hello_World Component\">Hello, World!</div>","cache":{"localStorage":{},"sessionStorage":{}},"timing":{"total_ms":45}}}