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-04 23:20:19 +00:00
parent a89daf3d43
commit 3ed8517b2a
891 changed files with 11126 additions and 9600 deletions

View File

@@ -5,8 +5,8 @@
Create `.npmrc` in your project root:
```
@jqhtml:registry=https://privatenpm.hanson.xyz/
//privatenpm.hanson.xyz/:_auth=anFodG1sOkFHbTMyNStFdklOQXdUMnN0S0g2cXc9PQ==
@jqhtml:registry=https://npm.internal.hanson.xyz/
//npm.internal.hanson.xyz/:_auth=anFodG1sOkFHbTMyNStFdklOQXdUMnN0S0g2cXc9PQ==
```
## 2. Install

View File

@@ -1,6 +1,6 @@
{
"name": "@jqhtml/ssr",
"version": "2.3.36",
"version": "2.3.37",
"description": "Server-Side Rendering for JQHTML components - renders components to HTML for SEO",
"main": "src/index.js",
"bin": {
@@ -10,7 +10,7 @@
"scripts": {
"start": "node src/server.js --tcp 9876",
"start:socket": "node src/server.js --socket /tmp/jqhtml-ssr.sock",
"test": "node test/test-protocol.js && node test/test-storage.js && node test/test-server.js",
"test": "node test/test-protocol.js && node test/test-storage.js && node test/test-server.js && node test/test-ajax-transport.js && node test/test-render-spa.js",
"example": "node bin/jqhtml-ssr-example.js --help"
},
"keywords": [

View File

@@ -5,6 +5,8 @@
* Uses LRU (Least Recently Used) eviction strategy.
*/
const fs = require('fs');
/**
* LRU Cache for bundle sets
*/
@@ -123,8 +125,19 @@ function prepareBundleCode(bundles) {
const codeChunks = [];
for (const bundle of bundles) {
// Load code from filesystem path or use inline content
let raw;
if (bundle.path) {
if (!fs.existsSync(bundle.path)) {
throw new Error(`Bundle file not found: ${bundle.path}`);
}
raw = fs.readFileSync(bundle.path, 'utf-8');
} else {
raw = bundle.content;
}
// Remove sourcemap comments
let code = bundle.content.replace(/\/\/# sourceMappingURL=.*/g, '');
let code = raw.replace(/\/\/# sourceMappingURL=.*/g, '');
// Add bundle marker comment for debugging
code = `\n/* === Bundle: ${bundle.id} === */\n${code}`;

View File

@@ -8,7 +8,7 @@
const { JSDOM } = require('jsdom');
const vm = require('vm');
const { createStoragePair } = require('./storage.js');
const { installHttpIntercept } = require('./http-intercept.js');
const { installHttpIntercept, installAjaxTransport } = require('./http-intercept.js');
// CRITICAL: Require jQuery BEFORE any global.window is set
// This ensures jQuery returns a factory function, not an auto-bound object
@@ -21,7 +21,11 @@ class SSREnvironment {
constructor(options = {}) {
this.options = {
baseUrl: options.baseUrl || 'http://localhost',
timeout: options.timeout || 30000
timeout: options.timeout || 30000,
ssrToken: options.ssrToken || null,
rsxapp: options.rsxapp || null,
extractMeta: options.extractMeta || false,
readySelector: options.readySelector || '#spa-root > *:first-child'
};
this.dom = null;
@@ -94,8 +98,8 @@ class SSREnvironment {
this.window.__SSR__ = true;
this.window.__JQHTML_SSR_MODE__ = true;
// Install HTTP interception (fetch + XHR URL rewriting)
installHttpIntercept(this.window, this.options.baseUrl);
// Install HTTP interception (fetch + XHR URL rewriting + optional SSR token)
installHttpIntercept(this.window, this.options.baseUrl, this.options.ssrToken);
// Create jQuery bound to jsdom window
this.$ = jqueryFactory(this.window);
@@ -106,12 +110,20 @@ class SSREnvironment {
this.window.$ = this.$;
this.window.jQuery = this.$;
// Install $.ajaxTransport for real HTTP requests via jQuery $.ajax()
// This ensures $.ajax() → Node fetch() instead of jsdom's limited XHR
installAjaxTransport(this.window, this.options.baseUrl, this.options.ssrToken);
// Stub console_debug if bundles use it
this.window.console_debug = function() {};
global.console_debug = function() {};
// rsxapp config object (some bundles expect this)
this.window.rsxapp = this.window.rsxapp || {};
// rsxapp config object - inject from request or create empty default
if (this.options.rsxapp) {
this.window.rsxapp = this.options.rsxapp;
} else {
this.window.rsxapp = this.window.rsxapp || {};
}
global.rsxapp = this.window.rsxapp;
this.initialized = true;
@@ -208,6 +220,145 @@ class SSREnvironment {
return [];
}
/**
* Render a SPA page by URL
*
* Assumes bundles have already been executed via execute(), which triggers
* SPA framework boot (rsxapp.is_spa = true → route dispatch to URL).
* Waits for the root component to reach ready state, then extracts HTML.
*
* @param {string} url - The URL path being rendered (for logging/errors)
* @returns {Promise<{ html: string, meta: object, cache: object }>}
*/
async renderSpa(url) {
if (!this.initialized) {
throw new Error('SSR environment not initialized. Call init() first.');
}
const $ = global.$;
const readySelector = this.options.readySelector;
// Poll for root component to appear (SPA dispatch may be async)
const component = await this._waitForComponent(readySelector);
// Wait for component ready (full lifecycle including children)
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`SPA render exceeded ${this.options.timeout}ms timeout`));
}, this.options.timeout);
});
await Promise.race([
component.ready(),
timeoutPromise
]);
// Extract HTML from #spa-root or the component itself
const $root = $('#spa-root');
const html = $root.length ? $root.html() : component.$.prop('outerHTML');
// Extract metadata if requested
let meta = {};
if (this.options.extractMeta) {
meta = this._extractMeta(component);
}
// Export cache state
const cache = this.storage.exportAll();
return { html, meta, cache };
}
/**
* Poll for a jqhtml component on the given selector
* @param {string} selector - CSS selector to find the component element
* @returns {Promise<object>} Component instance
* @private
*/
_waitForComponent(selector) {
const $ = global.$;
const timeout = this.options.timeout;
const startTime = Date.now();
return new Promise((resolve, reject) => {
const check = () => {
const elapsed = Date.now() - startTime;
if (elapsed > timeout) {
reject(new Error(`SPA component not found on "${selector}" within ${timeout}ms timeout`));
return;
}
const $el = $(selector);
if ($el.length > 0) {
const component = $el.component ? $el.component() : null;
if (component) {
resolve(component);
return;
}
}
setTimeout(check, 10);
};
check();
});
}
/**
* Extract page metadata from the rendered DOM and component
* @param {object} component - The root component instance
* @returns {{ title: string|null, description: string|null, og_image: string|null }}
* @private
*/
_extractMeta(component) {
const doc = global.document;
const meta = {
title: null,
description: null,
og_image: null
};
// Check document.title
if (doc && doc.title) {
meta.title = doc.title;
}
// Check if component has a page_title method
if (component && typeof component.page_title === 'function') {
try {
const title = component.page_title();
if (title && typeof title === 'string') {
meta.title = title;
}
} catch (e) {
// page_title() may throw - ignore
}
}
// Check meta tags in DOM
if (doc) {
const descEl = doc.querySelector('meta[name="description"]');
if (descEl) {
meta.description = descEl.getAttribute('content');
}
const ogImageEl = doc.querySelector('meta[property="og:image"]');
if (ogImageEl) {
meta.og_image = ogImageEl.getAttribute('content');
}
// Also check og:description as fallback
if (!meta.description) {
const ogDescEl = doc.querySelector('meta[property="og:description"]');
if (ogDescEl) {
meta.description = ogDescEl.getAttribute('content');
}
}
}
return meta;
}
/**
* Destroy the environment and clean up globals
*

View File

@@ -138,12 +138,132 @@ function createXHRWrapper(baseUrl, OriginalXHR) {
};
}
/**
* Wrap an existing fetch function to inject the SSR token header on all requests
* @param {function} fetchFn - The fetch function to wrap
* @param {string} ssrToken - SSR token value
* @returns {function} Wrapped fetch function
*/
function wrapFetchWithSsrToken(fetchFn, ssrToken) {
return function ssrTokenFetch(url, options = {}) {
const headers = { ...(options.headers || {}) };
headers['X-SSR-Token'] = ssrToken;
return fetchFn(url, { ...options, headers });
};
}
/**
* Install SSR token injection on fetch and XHR
* @param {Window} window - jsdom window object
* @param {string} ssrToken - SSR token value
*/
function installSsrTokenIntercept(window, ssrToken) {
if (!ssrToken) return;
// Wrap fetch with SSR token
const currentFetch = window.fetch;
window.fetch = wrapFetchWithSsrToken(currentFetch, ssrToken);
if (typeof global !== 'undefined') {
global.fetch = window.fetch;
}
}
/**
* Register a custom jQuery $.ajaxTransport that uses Node's fetch() for real HTTP.
*
* This solves the problem of jsdom's XMLHttpRequest not making real network requests.
* jQuery's $.ajax() will use this transport instead of XHR, so the chain becomes:
* $.ajax() → ajaxTransport → Node fetch() → real HTTP request
*
* @param {Window} window - jsdom window object (must have jQuery on window.$)
* @param {string} baseUrl - Base URL for resolving relative URLs
* @param {string} [ssrToken] - Optional SSR token to include in requests
*/
function installAjaxTransport(window, baseUrl, ssrToken) {
const $ = window.$;
if (!$ || !$.ajaxTransport) return;
$.ajaxTransport('+*', function(options, originalOptions, jqXHR) {
return {
send: function(headers, completeCallback) {
// Build absolute URL
const url = resolveUrl(options.url || '', baseUrl);
// Build fetch options
const fetchHeaders = {};
// Copy headers from jQuery, stripping auth headers
for (const [name, value] of Object.entries(headers)) {
const lowerName = name.toLowerCase();
if (lowerName === 'authorization' || lowerName === 'cookie') continue;
fetchHeaders[name] = value;
}
// Inject SSR token if provided
if (ssrToken) {
fetchHeaders['X-SSR-Token'] = ssrToken;
}
// Set content type if not already set
if (options.contentType && !fetchHeaders['Content-Type']) {
fetchHeaders['Content-Type'] = options.contentType;
}
const fetchOptions = {
method: options.type || 'GET',
headers: fetchHeaders
};
// Add body for non-GET requests
if (options.data && options.type && options.type.toUpperCase() !== 'GET') {
fetchOptions.body = typeof options.data === 'string'
? options.data
: JSON.stringify(options.data);
}
// Use the globally available fetch (already has URL rewriting from createFetch)
const fetchFn = globalThis.fetch || window.fetch;
fetchFn(url, fetchOptions)
.then(async (response) => {
const responseText = await response.text();
const responseHeaders = {};
if (response.headers && typeof response.headers.forEach === 'function') {
response.headers.forEach((value, name) => {
responseHeaders[name] = value;
});
}
const headerString = Object.entries(responseHeaders)
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n');
completeCallback(
response.status,
response.statusText,
{ text: responseText },
headerString
);
})
.catch((err) => {
completeCallback(0, 'Network Error: ' + err.message, {}, '');
});
},
abort: function() {
// Cannot abort native fetch, but jQuery expects this method
}
};
});
}
/**
* Install HTTP interception on a jsdom window
* @param {Window} window - jsdom window object
* @param {string} baseUrl - Base URL for URL resolution
* @param {string} [ssrToken] - Optional SSR token for outgoing requests
*/
function installHttpIntercept(window, baseUrl) {
function installHttpIntercept(window, baseUrl, ssrToken) {
// Override fetch
window.fetch = createFetch(baseUrl);
@@ -161,11 +281,18 @@ function installHttpIntercept(window, baseUrl) {
global.XMLHttpRequest = WrappedXHR;
}
}
// Install SSR token on fetch/XHR if provided
if (ssrToken) {
installSsrTokenIntercept(window, ssrToken);
}
}
module.exports = {
resolveUrl,
createFetch,
createXHRWrapper,
installHttpIntercept
installHttpIntercept,
installSsrTokenIntercept,
installAjaxTransport
};

View File

@@ -8,13 +8,14 @@ const { SSRServer } = require('./server.js');
const { SSREnvironment } = require('./environment.js');
const { BundleCache, prepareBundleCode } = require('./bundle-cache.js');
const { createStoragePair, SSR_Storage } = require('./storage.js');
const { resolveUrl, createFetch, installHttpIntercept } = require('./http-intercept.js');
const { resolveUrl, createFetch, installHttpIntercept, installAjaxTransport, installSsrTokenIntercept } = require('./http-intercept.js');
const {
ErrorCodes,
parseRequest,
successResponse,
errorResponse,
renderResponse,
renderSpaResponse,
pingResponse,
flushCacheResponse,
MessageBuffer
@@ -39,6 +40,8 @@ module.exports = {
resolveUrl,
createFetch,
installHttpIntercept,
installAjaxTransport,
installSsrTokenIntercept,
// Protocol
ErrorCodes,
@@ -46,6 +49,7 @@ module.exports = {
successResponse,
errorResponse,
renderResponse,
renderSpaResponse,
pingResponse,
flushCacheResponse,
MessageBuffer

View File

@@ -25,7 +25,11 @@ const ErrorCodes = {
COMPONENT_NOT_FOUND: 'COMPONENT_NOT_FOUND', // 404 - Component not registered
RENDER_ERROR: 'RENDER_ERROR', // 500 - Component threw during lifecycle
RENDER_TIMEOUT: 'RENDER_TIMEOUT', // 504 - Render exceeded timeout
INTERNAL_ERROR: 'INTERNAL_ERROR' // 500 - Unexpected server error
INTERNAL_ERROR: 'INTERNAL_ERROR', // 500 - Unexpected server error
ROUTE_NOT_FOUND: 'ROUTE_NOT_FOUND', // 404 - No SPA action matched the URL
BUNDLE_LOAD_ERROR: 'BUNDLE_LOAD_ERROR', // 400 - Failed to read bundle from filesystem
SPA_BOOT_ERROR: 'SPA_BOOT_ERROR', // 500 - SPA framework failed to initialize
DATA_FETCH_ERROR: 'DATA_FETCH_ERROR' // 502 - on_load() HTTP request(s) failed
};
/**
@@ -70,7 +74,7 @@ function parseRequest(message) {
};
}
const validTypes = ['render', 'ping', 'flush_cache'];
const validTypes = ['render', 'render_spa', 'ping', 'flush_cache'];
if (!validTypes.includes(parsed.type)) {
return {
ok: false,
@@ -89,6 +93,13 @@ function parseRequest(message) {
}
}
if (parsed.type === 'render_spa') {
const validation = validateRenderSpaPayload(parsed.payload);
if (!validation.ok) {
return validation;
}
}
return { ok: true, request: parsed };
}
@@ -207,6 +218,142 @@ function validateRenderPayload(payload) {
return { ok: true };
}
/**
* Validate render_spa request payload
* @param {object} payload
* @returns {{ ok: true } | { ok: false, error: object }}
*/
function validateRenderSpaPayload(payload) {
if (!payload || typeof payload !== 'object') {
return {
ok: false,
error: {
code: ErrorCodes.PARSE_ERROR,
message: 'Missing or invalid "payload" field for render_spa request'
}
};
}
// Validate bundles
if (!Array.isArray(payload.bundles) || payload.bundles.length === 0) {
return {
ok: false,
error: {
code: ErrorCodes.PARSE_ERROR,
message: 'payload.bundles must be a non-empty array'
}
};
}
for (let i = 0; i < payload.bundles.length; i++) {
const bundle = payload.bundles[i];
if (!bundle.id || typeof bundle.id !== 'string') {
return {
ok: false,
error: {
code: ErrorCodes.PARSE_ERROR,
message: `payload.bundles[${i}].id must be a string`
}
};
}
// Must have either path or content
const hasPath = bundle.path && typeof bundle.path === 'string';
const hasContent = bundle.content && typeof bundle.content === 'string';
if (!hasPath && !hasContent) {
return {
ok: false,
error: {
code: ErrorCodes.PARSE_ERROR,
message: `payload.bundles[${i}] must have either "path" (string) or "content" (string)`
}
};
}
}
// Validate URL
if (!payload.url || typeof payload.url !== 'string') {
return {
ok: false,
error: {
code: ErrorCodes.PARSE_ERROR,
message: 'payload.url must be a string'
}
};
}
// Validate rsxapp
if (!payload.rsxapp || typeof payload.rsxapp !== 'object') {
return {
ok: false,
error: {
code: ErrorCodes.PARSE_ERROR,
message: 'payload.rsxapp must be an object'
}
};
}
// Validate options
if (!payload.options || typeof payload.options !== 'object') {
return {
ok: false,
error: {
code: ErrorCodes.PARSE_ERROR,
message: 'payload.options is required (must include baseUrl)'
}
};
}
if (!payload.options.baseUrl || typeof payload.options.baseUrl !== 'string') {
return {
ok: false,
error: {
code: ErrorCodes.PARSE_ERROR,
message: 'payload.options.baseUrl is required and must be a string'
}
};
}
if (payload.options.timeout !== undefined && typeof payload.options.timeout !== 'number') {
return {
ok: false,
error: {
code: ErrorCodes.PARSE_ERROR,
message: 'payload.options.timeout must be a number'
}
};
}
if (payload.options.ssr_token !== undefined && typeof payload.options.ssr_token !== 'string') {
return {
ok: false,
error: {
code: ErrorCodes.PARSE_ERROR,
message: 'payload.options.ssr_token must be a string'
}
};
}
return { ok: true };
}
/**
* Create a render_spa success response
* @param {string} id - Request ID
* @param {string} html - Rendered HTML
* @param {object} meta - Page metadata { title, description, og_image }
* @param {object} cache - Cache state { localStorage, sessionStorage }
* @param {object} timing - Timing info { total_ms, bundle_load_ms, render_ms }
* @returns {string} JSON string with newline
*/
function renderSpaResponse(id, html, meta, cache, timing) {
return successResponse(id, {
html,
meta,
cache,
timing
});
}
/**
* Create a success response
* @param {string} id - Request ID
@@ -329,9 +476,11 @@ module.exports = {
ErrorCodes,
parseRequest,
validateRenderPayload,
validateRenderSpaPayload,
successResponse,
errorResponse,
renderResponse,
renderSpaResponse,
pingResponse,
flushCacheResponse,
MessageBuffer

View File

@@ -14,6 +14,7 @@ const {
ErrorCodes,
parseRequest,
renderResponse,
renderSpaResponse,
pingResponse,
flushCacheResponse,
errorResponse,
@@ -153,6 +154,10 @@ class SSRServer {
response = await this._handleRender(request);
break;
case 'render_spa':
response = await this._handleRenderSpa(request);
break;
default:
response = errorResponse(
request.id,
@@ -299,6 +304,93 @@ class SSRServer {
}
}
/**
* Handle render_spa request
* @param {object} request
* @returns {Promise<string>} Response
* @private
*/
async _handleRenderSpa(request) {
const startTime = Date.now();
const payload = request.payload;
// Get or prepare bundle code
const cacheKey = BundleCache.generateKey(payload.bundles);
let bundleCode = this.bundleCache.get(cacheKey);
let bundleLoadMs = 0;
if (!bundleCode) {
const bundleStartTime = Date.now();
try {
bundleCode = prepareBundleCode(payload.bundles);
} catch (err) {
return errorResponse(
request.id,
ErrorCodes.BUNDLE_LOAD_ERROR,
err.message,
err.stack
);
}
this.bundleCache.set(cacheKey, bundleCode);
bundleLoadMs = Date.now() - bundleStartTime;
}
// Build the jsdom URL from baseUrl + requested URL path
const baseUrlObj = new URL(payload.options.baseUrl);
const spaUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}${payload.url}`;
// Create fresh environment with SPA options
const env = new SSREnvironment({
baseUrl: spaUrl,
timeout: payload.options.timeout || this.options.defaultTimeout,
ssrToken: payload.options.ssr_token || null,
rsxapp: payload.rsxapp || {},
extractMeta: payload.options.extract_meta || false,
readySelector: payload.options.ready_selector || '#spa-root > *:first-child'
});
try {
// Initialize environment
env.init();
// Execute bundle code (SPA framework boots if rsxapp.is_spa === true)
const execStartTime = Date.now();
env.execute(bundleCode, `spa-bundles:${cacheKey}`);
bundleLoadMs += Date.now() - execStartTime;
// Render SPA (waits for route dispatch + component ready)
const renderStartTime = Date.now();
const result = await env.renderSpa(payload.url);
const renderMs = Date.now() - renderStartTime;
const totalMs = Date.now() - startTime;
return renderSpaResponse(request.id, result.html, result.meta, result.cache, {
total_ms: totalMs,
bundle_load_ms: bundleLoadMs,
render_ms: renderMs
});
} catch (err) {
// Determine error type
let errorCode = ErrorCodes.RENDER_ERROR;
if (err.message.includes('timeout')) {
errorCode = ErrorCodes.RENDER_TIMEOUT;
} else if (err.message.includes('Bundle file not found')) {
errorCode = ErrorCodes.BUNDLE_LOAD_ERROR;
} else if (err.message.includes('SPA')) {
errorCode = ErrorCodes.SPA_BOOT_ERROR;
}
return errorResponse(request.id, errorCode, err.message, err.stack);
} finally {
// Always destroy environment to free resources
env.destroy();
}
}
/**
* Stop the server
* @returns {Promise<void>}