Progressive breadcrumb resolution with caching, fix double headers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
465 lines
13 KiB
Markdown
465 lines
13 KiB
Markdown
# JQHTML Server-Side Rendering (SSR)
|
|
|
|
> **See [SPECIFICATION.md](./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
|
|
|
|
```bash
|
|
npm install @jqhtml/ssr
|
|
```
|
|
|
|
Or install globally for CLI access:
|
|
|
|
```bash
|
|
npm install -g @jqhtml/ssr
|
|
```
|
|
|
|
---
|
|
|
|
## Quick Start
|
|
|
|
### Starting the Server
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
jqhtml-ssr-example -v vendor.js -a app.js -c My_Component
|
|
```
|
|
|
|
**JSON** - Raw server response for programmatic use:
|
|
|
|
```bash
|
|
jqhtml-ssr-example -v vendor.js -a app.js -c My_Component --format json > result.json
|
|
```
|
|
|
|
**HTML-only** - Just the rendered HTML:
|
|
|
|
```bash
|
|
jqhtml-ssr-example -v vendor.js -a app.js -c My_Component --format html-only > output.html
|
|
```
|
|
|
|
### Example with Component Arguments
|
|
|
|
```bash
|
|
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:
|
|
|
|
1. **Connection** - TCP socket to `localhost:PORT` or Unix socket
|
|
2. **Request format** - Newline-delimited JSON
|
|
3. **Bundle loading** - Read JS files, send as `content` strings
|
|
4. **Request structure** - See the commented request object in the source
|
|
5. **Response parsing** - JSON with `status`, `payload`, and `error` fields
|
|
|
|
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:**
|
|
```json
|
|
{
|
|
"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:**
|
|
```json
|
|
{
|
|
"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
|
|
<?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
|
|
|
|
```javascript
|
|
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.
|
|
|
|
```json
|
|
{ "id": "1", "type": "ping", "payload": {} }
|
|
```
|
|
|
|
Response includes `uptime_ms`.
|
|
|
|
### render
|
|
|
|
Render a component to HTML.
|
|
|
|
Required payload fields:
|
|
- `bundles` - Array of `{ id, content }` objects
|
|
- `component` - Component name to render
|
|
- `options.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.
|
|
|
|
```json
|
|
{ "id": "1", "type": "flush_cache", "payload": {} }
|
|
```
|
|
|
|
Or flush specific bundle:
|
|
```json
|
|
{ "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
|
|
|
|
---
|
|
|
|
## 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
|
|
|
|
```bash
|
|
# 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.**
|
|
|
|
```javascript
|
|
// 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:
|
|
1. **Vendor bundle** - Contains `@jqhtml/core`, initializes `window.jqhtml`
|
|
2. **App bundle** - Contains templates and component classes
|
|
|
|
### 4. Global Cleanup
|
|
|
|
When destroying environments, set globals to `undefined` instead of using `delete`:
|
|
```javascript
|
|
global.window = undefined; // Safe
|
|
// delete global.window; // Can cause issues
|
|
```
|
|
|
|
---
|
|
|
|
## Dependencies
|
|
|
|
```json
|
|
{
|
|
"jsdom": "^24.0.0",
|
|
"jquery": "^3.7.1"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## References
|
|
|
|
- [SPECIFICATION.md](./SPECIFICATION.md) - Complete technical specification
|
|
- [/packages/core/src/component.ts](/packages/core/src/component.ts) - Component lifecycle
|
|
- [/docs/official/18_boot.md](/docs/official/18_boot.md) - Client-side boot/hydration
|
|
- [jsdom documentation](https://github.com/jsdom/jsdom)
|
|
- [jQuery in Node.js](https://www.npmjs.com/package/jquery)
|