🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
621 lines
18 KiB
Markdown
Executable File
621 lines
18 KiB
Markdown
Executable File
# JQHTML Server-Side Rendering (SSR) Specification
|
|
|
|
> **Document Status:** Active specification for `@jqhtml/ssr` package 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
|
|
|
|
1. **Same code path** - Components run identically on server and client
|
|
2. **Real data fetching** - `on_load()` makes actual HTTP requests during SSR
|
|
3. **Cache export** - Server exports localStorage/sessionStorage state for client hydration
|
|
4. **Request isolation** - Each render request has completely fresh state
|
|
5. **Bundle caching** - Parsed bundles stay in memory; environment state is cloned per request
|
|
6. **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:
|
|
|
|
1. Check if bundle set exists in cache by identifier
|
|
2. If cached: clone the parsed bundle environment
|
|
3. 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
|
|
|
|
```json
|
|
{
|
|
"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:**
|
|
|
|
```json
|
|
{
|
|
"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:**
|
|
|
|
```json
|
|
{
|
|
"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):**
|
|
```json
|
|
{"id": "ping-1", "type": "ping", "payload": {}}
|
|
```
|
|
Response:
|
|
```json
|
|
{"id": "ping-1", "status": "success", "payload": {"uptime_ms": 123456}}
|
|
```
|
|
|
|
**Flush cache:**
|
|
```json
|
|
{"id": "flush-1", "type": "flush_cache", "payload": {"bundle_id": "vendor-abc123"}}
|
|
```
|
|
Response:
|
|
```json
|
|
{"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
|
|
|
|
1. **`global.fetch`** - Override with custom implementation
|
|
2. **`XMLHttpRequest`** - 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
|
|
|
|
```javascript
|
|
// 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:
|
|
|
|
```javascript
|
|
// 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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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
|
|
|
|
```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
|
|
|
|
```javascript
|
|
// 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:
|
|
|
|
1. SSR provides meaningful HTML for SEO crawlers
|
|
2. Client replaces with live components using cached data
|
|
3. Cache ensures `on_load()` returns instantly (cache hit)
|
|
4. No complex DOM diffing or event attachment needed
|
|
5. 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`:
|
|
|
|
```bash
|
|
#!/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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
1. **No cookies/auth** - SSR requests don't forward authentication
|
|
2. **URL whitelist** - Only allow fetch to configured baseUrl domain
|
|
3. **Code execution** - Only execute provided bundles, no eval of request data
|
|
4. **Resource limits** - Timeout, memory limits per request
|
|
5. **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 `static` or `interactive`
|
|
- 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}}}
|
|
```
|