Framework updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-03-12 19:09:07 +00:00
parent 3294fc7337
commit daa9bb2fb1
47 changed files with 2495 additions and 525 deletions

136
node_modules/@jqhtml/ssr/README.md generated vendored
View File

@@ -361,6 +361,142 @@ Or flush specific bundle:
---
## 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:
```javascript
// 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 retrieval
- `start_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:
```javascript
// 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)` or `set_preload_data([])` is a no-op
- `clear_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)
```php
// 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)
```javascript
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
```

View File

@@ -544,6 +544,69 @@ jqhtml-ssr --render \
---
## Preload Data Capture and Injection Protocol
The preload system captures component data during SSR and replays it on the client to skip redundant `on_load()` calls during hydration.
### Entry Format
Each captured entry has the following structure:
```json
{
"component": "DashboardIndex",
"args": { "user_id": 123 },
"data": { "items": [...], "total": 42 }
}
```
| Field | Type | Description |
|-------|------|-------------|
| `component` | string | Component name |
| `args` | object | Component arguments (as passed at creation) |
| `data` | object | The component's `this.data` after `on_load()` completed |
### Key Generation
Preload entries are matched by a composite key of `component` + `args`. The key generation algorithm:
1. Component name is used as-is (string)
2. Args are serialized deterministically (stable JSON stringification)
3. Combined key: `component + ":" + serialized_args`
This means two components with the same name and identical args share a preload entry, which is consistent with Load Coordinator deduplication behavior.
### One-Shot Semantics
Both capture and preload use one-shot consumption:
- **`get_captured_data()`** — Returns all captured entries and clears the capture buffer. Calling again returns an empty array until new components are captured.
- **Preload entries** — Each entry is consumed on first use. When `_load()` finds a preload cache hit, it removes that entry. A second component with the same key will not find a preload hit and will call `on_load()` normally.
### Integration Points in `_load()`
The preload system integrates into the component `_load()` method with the following priority:
1. **Preload cache check** (highest priority) — If `set_preload_data()` was called and an entry matches this component's name + args, apply the data via `_apply_load_result()` and skip `on_load()` entirely.
2. **Load Coordinator check** — If another instance of the same component + args is already loading, wait for its result.
3. **Normal `on_load()`** — Call the component's `on_load()` method.
### Capture Integration in `_apply_load_result()`
When data capture is enabled (`start_data_capture()` was called), `_apply_load_result()` records the component's data if the component has an `on_load()` method. Static components (those without `on_load()`) are excluded from capture.
### API Summary
| Method | Side | Description |
|--------|------|-------------|
| `start_data_capture()` | Server | Enable capture mode (idempotent) |
| `get_captured_data()` | Server | Return and clear captured entries |
| `stop_data_capture()` | Server | Stop capture, clear all state |
| `set_preload_data(entries)` | Client | Seed preload cache; null/empty is no-op |
| `clear_preload_data()` | Client | Clear unconsumed preload entries |
---
## Future Considerations
### Streaming SSR

View File

@@ -1,6 +1,6 @@
{
"name": "@jqhtml/ssr",
"version": "2.3.41",
"version": "2.3.42",
"description": "Server-Side Rendering for JQHTML components - renders components to HTML for SEO",
"main": "src/index.js",
"bin": {

View File

@@ -149,6 +149,58 @@ class SSREnvironment {
});
}
/**
* Execute JavaScript code wrapped in an IIFE to prevent class declarations
* from leaking into the global scope across requests.
*
* Without IIFE wrapping, `class Foo {}` in vm.runInThisContext() persists
* in the global context, causing "Identifier already declared" errors on
* subsequent requests that load the same bundles.
*
* @param {string} code - JavaScript code to execute
* @param {string} filename - Filename for error reporting
*/
executeScoped(code, filename = 'scoped-bundle.js') {
if (!this.initialized) {
throw new Error('SSR environment not initialized. Call init() first.');
}
const cleanCode = code.replace(/\/\/# sourceMappingURL=.*/g, '');
// Register Rsx on window inside the IIFE so boot() can access it.
// Class declarations are block-scoped inside the IIFE, but closures
// preserve the scope — so Rsx._rsx_core_boot() can still reference
// Manifest, Ajax, etc. by name when called from outside.
const registration = 'if (typeof Rsx !== "undefined") { window.Rsx = Rsx; }';
const wrappedCode = `(function() {\n${cleanCode}\n${registration}\n})();`;
vm.runInThisContext(wrappedCode, {
filename,
displayErrors: true
});
}
/**
* Boot the RSpade framework lifecycle.
*
* Calls Rsx._rsx_core_boot() which runs the full 10-phase initialization
* sequence: Ajax init, component registration, jqhtml cache setup, etc.
* Framework classes with browser-only APIs detect window.__SSR__ and skip
* their initialization via Rsx.is_ssr() guards.
*
* Must be called after executeScoped() loads the bundles.
*/
async boot() {
if (!this.initialized) {
throw new Error('SSR environment not initialized. Call init() first.');
}
const Rsx = global.Rsx || (global.window && global.window.Rsx);
if (Rsx && typeof Rsx._rsx_core_boot === 'function') {
await Rsx._rsx_core_boot();
}
}
/**
* Render a component to HTML
* @param {string} componentName - Component name to render
@@ -161,6 +213,12 @@ class SSREnvironment {
}
const $ = global.$;
const jqhtml = global.window.jqhtml;
// Start data capture for preload
if (jqhtml && typeof jqhtml.start_data_capture === 'function') {
jqhtml.start_data_capture();
}
// Create container
const $container = $('<div>');
@@ -188,10 +246,16 @@ class SSREnvironment {
// Get rendered HTML
const html = component.$.prop('outerHTML');
// Get captured preload data
let preload = [];
if (jqhtml && typeof jqhtml.get_captured_data === 'function') {
preload = jqhtml.get_captured_data();
}
// Export cache state
const cache = this.storage.exportAll();
return { html, cache };
return { html, cache, preload };
}
/**

View File

@@ -74,7 +74,7 @@ function parseRequest(message) {
};
}
const validTypes = ['render', 'render_spa', 'ping', 'flush_cache'];
const validTypes = ['render', 'render_spa', 'ping', 'flush_cache', 'shutdown'];
if (!validTypes.includes(parsed.type)) {
return {
ok: false,
@@ -396,12 +396,12 @@ function errorResponse(id, code, message, stack) {
* @param {object} timing - Timing info { total_ms, bundle_load_ms, render_ms }
* @returns {string} JSON string with newline
*/
function renderResponse(id, html, cache, timing) {
return successResponse(id, {
html,
cache,
timing
});
function renderResponse(id, html, cache, timing, preload) {
const payload = { html, cache, timing };
if (preload && preload.length > 0) {
payload.preload = preload;
}
return successResponse(id, payload);
}
/**
@@ -424,6 +424,15 @@ function flushCacheResponse(id, flushed) {
return successResponse(id, { flushed });
}
/**
* Create a shutdown response
* @param {string} id - Request ID
* @returns {string} JSON string with newline
*/
function shutdownResponse(id) {
return successResponse(id, { result: 'shutting down' });
}
/**
* MessageBuffer - Handles newline-delimited message framing
*
@@ -483,5 +492,6 @@ module.exports = {
renderSpaResponse,
pingResponse,
flushCacheResponse,
shutdownResponse,
MessageBuffer
};

View File

@@ -17,6 +17,7 @@ const {
renderSpaResponse,
pingResponse,
flushCacheResponse,
shutdownResponse,
errorResponse,
MessageBuffer
} = require('./protocol.js');
@@ -158,6 +159,12 @@ class SSRServer {
response = await this._handleRenderSpa(request);
break;
case 'shutdown':
socket.write(shutdownResponse(request.id));
await this.stop();
process.exit(0);
return;
default:
response = errorResponse(
request.id,
@@ -253,11 +260,15 @@ class SSRServer {
// Initialize environment
env.init();
// Execute bundle code
// Execute bundle code (scoped IIFE prevents class declaration leaks across requests)
const execStartTime = Date.now();
env.execute(bundleCode, `bundles:${cacheKey}`);
env.executeScoped(bundleCode, `bundles:${cacheKey}`);
bundleLoadMs += Date.now() - execStartTime;
// Boot the RSpade framework (runs full 10-phase lifecycle)
// Framework classes detect window.__SSR__ and skip browser-only init
await env.boot();
// Check if jqhtml loaded
if (!env.isJqhtmlReady()) {
throw new Error('jqhtml runtime not available after loading bundles');
@@ -284,7 +295,7 @@ class SSRServer {
total_ms: totalMs,
bundle_load_ms: bundleLoadMs,
render_ms: renderMs
});
}, result.preload);
} catch (err) {
// Determine error type
@@ -499,4 +510,14 @@ Options:
await server.stop();
process.exit(0);
});
process.on('SIGHUP', () => {});
process.on('unhandledRejection', (err) => {
console.error('[SSR] Unhandled rejection:', err);
});
process.on('uncaughtException', (err) => {
console.error('[SSR] Uncaught exception:', err);
});
}