Fix code quality violations for publish
Progressive breadcrumb resolution with caching, fix double headers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
141
node_modules/@jqhtml/ssr/src/bundle-cache.js
generated
vendored
Normal file
141
node_modules/@jqhtml/ssr/src/bundle-cache.js
generated
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* JQHTML SSR Bundle Cache
|
||||
*
|
||||
* Caches parsed bundle code to avoid re-parsing on every request.
|
||||
* Uses LRU (Least Recently Used) eviction strategy.
|
||||
*/
|
||||
|
||||
/**
|
||||
* LRU Cache for bundle sets
|
||||
*/
|
||||
class BundleCache {
|
||||
constructor(maxSize = 10) {
|
||||
this.maxSize = maxSize;
|
||||
this.cache = new Map(); // bundleSetId -> { code, lastUsed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from bundle array
|
||||
* @param {Array<{id: string, content: string}>} bundles
|
||||
* @returns {string} Cache key
|
||||
*/
|
||||
static generateKey(bundles) {
|
||||
// Key is concatenation of all bundle IDs in order
|
||||
return bundles.map(b => b.id).join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached bundle code if available
|
||||
* @param {string} key - Cache key from generateKey()
|
||||
* @returns {string|null} Concatenated bundle code or null if not cached
|
||||
*/
|
||||
get(key) {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) {
|
||||
entry.lastUsed = Date.now();
|
||||
return entry.code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store bundle code in cache
|
||||
* @param {string} key - Cache key
|
||||
* @param {string} code - Concatenated bundle code
|
||||
*/
|
||||
set(key, code) {
|
||||
// Evict if at capacity
|
||||
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
||||
this._evictLRU();
|
||||
}
|
||||
|
||||
this.cache.set(key, {
|
||||
code,
|
||||
lastUsed: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bundle set is cached
|
||||
* @param {string} key - Cache key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(key) {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific bundle set from cache
|
||||
* @param {string} key - Cache key
|
||||
* @returns {boolean} True if entry was removed
|
||||
*/
|
||||
delete(key) {
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached bundles
|
||||
*/
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* @returns {{ size: number, maxSize: number, keys: string[] }}
|
||||
*/
|
||||
stats() {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
maxSize: this.maxSize,
|
||||
keys: Array.from(this.cache.keys())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict the least recently used entry
|
||||
* @private
|
||||
*/
|
||||
_evictLRU() {
|
||||
let oldestKey = null;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (entry.lastUsed < oldestTime) {
|
||||
oldestTime = entry.lastUsed;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestKey) {
|
||||
this.cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare bundle code for execution
|
||||
* Concatenates bundles and removes problematic patterns
|
||||
* @param {Array<{id: string, content: string}>} bundles
|
||||
* @returns {string} Prepared code
|
||||
*/
|
||||
function prepareBundleCode(bundles) {
|
||||
const codeChunks = [];
|
||||
|
||||
for (const bundle of bundles) {
|
||||
// Remove sourcemap comments
|
||||
let code = bundle.content.replace(/\/\/# sourceMappingURL=.*/g, '');
|
||||
|
||||
// Add bundle marker comment for debugging
|
||||
code = `\n/* === Bundle: ${bundle.id} === */\n${code}`;
|
||||
|
||||
codeChunks.push(code);
|
||||
}
|
||||
|
||||
return codeChunks.join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BundleCache,
|
||||
prepareBundleCode
|
||||
};
|
||||
268
node_modules/@jqhtml/ssr/src/environment.js
generated
vendored
Normal file
268
node_modules/@jqhtml/ssr/src/environment.js
generated
vendored
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* JQHTML SSR Environment
|
||||
*
|
||||
* Creates isolated jsdom environments for SSR rendering.
|
||||
* Each render request gets a fresh environment to ensure isolation.
|
||||
*/
|
||||
|
||||
const { JSDOM } = require('jsdom');
|
||||
const vm = require('vm');
|
||||
const { createStoragePair } = require('./storage.js');
|
||||
const { installHttpIntercept } = 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
|
||||
const jqueryFactory = require('jquery');
|
||||
|
||||
/**
|
||||
* SSR Environment - Isolated rendering context
|
||||
*/
|
||||
class SSREnvironment {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
baseUrl: options.baseUrl || 'http://localhost',
|
||||
timeout: options.timeout || 30000
|
||||
};
|
||||
|
||||
this.dom = null;
|
||||
this.window = null;
|
||||
this.$ = null;
|
||||
this.storage = null;
|
||||
this.initialized = false;
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the jsdom environment with jQuery and all required globals
|
||||
*/
|
||||
init() {
|
||||
// Create jsdom instance
|
||||
this.dom = new JSDOM(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>SSR</title></head>
|
||||
<body></body>
|
||||
</html>
|
||||
`, {
|
||||
url: this.options.baseUrl,
|
||||
pretendToBeVisual: true,
|
||||
runScripts: 'outside-only'
|
||||
});
|
||||
|
||||
this.window = this.dom.window;
|
||||
|
||||
// Create fake storage
|
||||
this.storage = createStoragePair();
|
||||
|
||||
// Set up global references that bundles expect
|
||||
global.window = this.window;
|
||||
global.document = this.window.document;
|
||||
global.HTMLElement = this.window.HTMLElement;
|
||||
global.Element = this.window.Element;
|
||||
global.Node = this.window.Node;
|
||||
global.NodeList = this.window.NodeList;
|
||||
global.DOMParser = this.window.DOMParser;
|
||||
global.Text = this.window.Text;
|
||||
global.DocumentFragment = this.window.DocumentFragment;
|
||||
global.Event = this.window.Event;
|
||||
global.CustomEvent = this.window.CustomEvent;
|
||||
global.MutationObserver = this.window.MutationObserver;
|
||||
|
||||
// Window-like globals
|
||||
global.self = this.window;
|
||||
global.top = this.window;
|
||||
global.parent = this.window;
|
||||
global.frames = this.window;
|
||||
global.location = this.window.location;
|
||||
global.history = this.window.history;
|
||||
global.screen = this.window.screen;
|
||||
// NOTE: Don't set global.performance - causes issues between jsdom instances
|
||||
// The window.performance is available for code that accesses it via window
|
||||
global.requestAnimationFrame = (cb) => setTimeout(cb, 16);
|
||||
global.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
global.getComputedStyle = this.window.getComputedStyle.bind(this.window);
|
||||
|
||||
// Install fake storage
|
||||
global.localStorage = this.storage.localStorage;
|
||||
global.sessionStorage = this.storage.sessionStorage;
|
||||
this.window.localStorage = this.storage.localStorage;
|
||||
this.window.sessionStorage = this.storage.sessionStorage;
|
||||
|
||||
// Mark as SSR environment
|
||||
global.__SSR__ = true;
|
||||
global.__JQHTML_SSR_MODE__ = true;
|
||||
this.window.__SSR__ = true;
|
||||
this.window.__JQHTML_SSR_MODE__ = true;
|
||||
|
||||
// Install HTTP interception (fetch + XHR URL rewriting)
|
||||
installHttpIntercept(this.window, this.options.baseUrl);
|
||||
|
||||
// Create jQuery bound to jsdom window
|
||||
this.$ = jqueryFactory(this.window);
|
||||
|
||||
// Make jQuery available globally
|
||||
global.$ = this.$;
|
||||
global.jQuery = this.$;
|
||||
this.window.$ = this.$;
|
||||
this.window.jQuery = this.$;
|
||||
|
||||
// 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 || {};
|
||||
global.rsxapp = this.window.rsxapp;
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute JavaScript code in the environment
|
||||
* @param {string} code - JavaScript code to execute
|
||||
* @param {string} filename - Filename for error reporting
|
||||
*/
|
||||
execute(code, filename = 'bundle.js') {
|
||||
if (!this.initialized) {
|
||||
throw new Error('SSR environment not initialized. Call init() first.');
|
||||
}
|
||||
|
||||
// Remove sourcemap comments to avoid errors
|
||||
const cleanCode = code.replace(/\/\/# sourceMappingURL=.*/g, '');
|
||||
|
||||
// Execute in Node's global context
|
||||
vm.runInThisContext(cleanCode, {
|
||||
filename,
|
||||
displayErrors: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a component to HTML
|
||||
* @param {string} componentName - Component name to render
|
||||
* @param {object} args - Arguments to pass to component
|
||||
* @returns {Promise<{ html: string, cache: object }>}
|
||||
*/
|
||||
async render(componentName, args = {}) {
|
||||
if (!this.initialized) {
|
||||
throw new Error('SSR environment not initialized. Call init() first.');
|
||||
}
|
||||
|
||||
const $ = global.$;
|
||||
|
||||
// Create container
|
||||
const $container = $('<div>');
|
||||
|
||||
// Create component
|
||||
$container.component(componentName, args);
|
||||
|
||||
const component = $container.component();
|
||||
if (!component) {
|
||||
throw new Error(`Failed to create component: ${componentName}`);
|
||||
}
|
||||
|
||||
// Wait for ready with timeout
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`Component render exceeded ${this.options.timeout}ms timeout`));
|
||||
}, this.options.timeout);
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
component.ready(),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
// Get rendered HTML
|
||||
const html = component.$.prop('outerHTML');
|
||||
|
||||
// Export cache state
|
||||
const cache = this.storage.exportAll();
|
||||
|
||||
return { html, cache };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if jqhtml runtime is available
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isJqhtmlReady() {
|
||||
return !!(global.window && global.window.jqhtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of registered templates
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getRegisteredTemplates() {
|
||||
if (global.window.jqhtml) {
|
||||
// Real jqhtml API
|
||||
if (typeof global.window.jqhtml.get_registered_templates === 'function') {
|
||||
return global.window.jqhtml.get_registered_templates();
|
||||
}
|
||||
// Mock bundle API (for testing)
|
||||
if (global.window.jqhtml._templates) {
|
||||
return Array.from(global.window.jqhtml._templates.keys());
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the environment and clean up globals
|
||||
*
|
||||
* NOTE: We set globals to undefined instead of deleting them,
|
||||
* because jsdom expects certain globals to exist (even if undefined)
|
||||
* when creating new windows.
|
||||
*/
|
||||
destroy() {
|
||||
// Close jsdom window first
|
||||
if (this.window) {
|
||||
this.window.close();
|
||||
}
|
||||
|
||||
// Clean up global references by setting to undefined
|
||||
// This is safer than delete because some libraries check for existence
|
||||
global.window = undefined;
|
||||
global.document = undefined;
|
||||
global.HTMLElement = undefined;
|
||||
global.Element = undefined;
|
||||
global.Node = undefined;
|
||||
global.NodeList = undefined;
|
||||
global.DOMParser = undefined;
|
||||
global.Text = undefined;
|
||||
global.DocumentFragment = undefined;
|
||||
global.Event = undefined;
|
||||
global.CustomEvent = undefined;
|
||||
global.MutationObserver = undefined;
|
||||
global.self = undefined;
|
||||
global.top = undefined;
|
||||
global.parent = undefined;
|
||||
global.frames = undefined;
|
||||
global.location = undefined;
|
||||
global.history = undefined;
|
||||
global.screen = undefined;
|
||||
// Don't touch performance - jsdom needs it
|
||||
global.requestAnimationFrame = undefined;
|
||||
global.cancelAnimationFrame = undefined;
|
||||
global.getComputedStyle = undefined;
|
||||
global.localStorage = undefined;
|
||||
global.sessionStorage = undefined;
|
||||
global.$ = undefined;
|
||||
global.jQuery = undefined;
|
||||
global.__SSR__ = undefined;
|
||||
global.__JQHTML_SSR_MODE__ = undefined;
|
||||
global.console_debug = undefined;
|
||||
global.rsxapp = undefined;
|
||||
global.fetch = undefined;
|
||||
global.XMLHttpRequest = undefined;
|
||||
|
||||
this.dom = null;
|
||||
this.window = null;
|
||||
this.$ = null;
|
||||
this.storage = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SSREnvironment };
|
||||
171
node_modules/@jqhtml/ssr/src/http-intercept.js
generated
vendored
Normal file
171
node_modules/@jqhtml/ssr/src/http-intercept.js
generated
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* JQHTML SSR HTTP Interception
|
||||
*
|
||||
* Intercepts fetch() and XMLHttpRequest to rewrite relative URLs
|
||||
* using the configured baseUrl.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
/**
|
||||
* Resolve a URL relative to a base URL
|
||||
* @param {string} url - URL to resolve (may be relative)
|
||||
* @param {string} baseUrl - Base URL (e.g., "https://example.com")
|
||||
* @returns {string} Absolute URL
|
||||
*/
|
||||
function resolveUrl(url, baseUrl) {
|
||||
// Already absolute with protocol
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Protocol-relative (//cdn.example.com/...)
|
||||
if (url.startsWith('//')) {
|
||||
const baseProtocol = new URL(baseUrl).protocol;
|
||||
return baseProtocol + url;
|
||||
}
|
||||
|
||||
// Absolute path (/api/users)
|
||||
if (url.startsWith('/')) {
|
||||
const base = new URL(baseUrl);
|
||||
return `${base.protocol}//${base.host}${url}`;
|
||||
}
|
||||
|
||||
// Relative path (api/users)
|
||||
const base = new URL(baseUrl);
|
||||
const basePath = base.pathname.endsWith('/') ? base.pathname : base.pathname + '/';
|
||||
return `${base.protocol}//${base.host}${basePath}${url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fetch function that rewrites URLs
|
||||
* @param {string} baseUrl - Base URL for relative URL resolution
|
||||
* @returns {function} Configured fetch function
|
||||
*/
|
||||
function createFetch(baseUrl) {
|
||||
// Use native fetch if available (Node 18+), otherwise use a simple implementation
|
||||
const nativeFetch = globalThis.fetch;
|
||||
|
||||
if (nativeFetch) {
|
||||
return async function ssrFetch(url, options = {}) {
|
||||
const resolvedUrl = resolveUrl(String(url), baseUrl);
|
||||
|
||||
// Remove any auth-related headers for SSR (SEO mode)
|
||||
const safeHeaders = { ...(options.headers || {}) };
|
||||
delete safeHeaders['Authorization'];
|
||||
delete safeHeaders['authorization'];
|
||||
delete safeHeaders['Cookie'];
|
||||
delete safeHeaders['cookie'];
|
||||
|
||||
return nativeFetch(resolvedUrl, {
|
||||
...options,
|
||||
headers: safeHeaders
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for older Node versions - basic fetch implementation
|
||||
return function ssrFetch(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const resolvedUrl = resolveUrl(String(url), baseUrl);
|
||||
const parsedUrl = new URL(resolvedUrl);
|
||||
const isHttps = parsedUrl.protocol === 'https:';
|
||||
const lib = isHttps ? https : http;
|
||||
|
||||
const requestOptions = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (isHttps ? 443 : 80),
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
method: options.method || 'GET',
|
||||
headers: options.headers || {}
|
||||
};
|
||||
|
||||
// Remove auth headers
|
||||
delete requestOptions.headers['Authorization'];
|
||||
delete requestOptions.headers['authorization'];
|
||||
delete requestOptions.headers['Cookie'];
|
||||
delete requestOptions.headers['cookie'];
|
||||
|
||||
const req = lib.request(requestOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
statusText: res.statusMessage,
|
||||
headers: new Map(Object.entries(res.headers)),
|
||||
text: () => Promise.resolve(data),
|
||||
json: () => Promise.resolve(JSON.parse(data))
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (options.body) {
|
||||
req.write(options.body);
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an XMLHttpRequest class that rewrites URLs
|
||||
* This is needed for jQuery $.ajax compatibility
|
||||
* @param {string} baseUrl - Base URL for relative URL resolution
|
||||
* @param {function} OriginalXHR - Original XMLHttpRequest constructor from jsdom
|
||||
* @returns {function} Wrapped XMLHttpRequest constructor
|
||||
*/
|
||||
function createXHRWrapper(baseUrl, OriginalXHR) {
|
||||
return class SSR_XMLHttpRequest extends OriginalXHR {
|
||||
open(method, url, async = true, user, password) {
|
||||
const resolvedUrl = resolveUrl(String(url), baseUrl);
|
||||
return super.open(method, resolvedUrl, async, user, password);
|
||||
}
|
||||
|
||||
setRequestHeader(name, value) {
|
||||
// Block auth headers for SSR
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName === 'authorization' || lowerName === 'cookie') {
|
||||
return;
|
||||
}
|
||||
return super.setRequestHeader(name, value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install HTTP interception on a jsdom window
|
||||
* @param {Window} window - jsdom window object
|
||||
* @param {string} baseUrl - Base URL for URL resolution
|
||||
*/
|
||||
function installHttpIntercept(window, baseUrl) {
|
||||
// Override fetch
|
||||
window.fetch = createFetch(baseUrl);
|
||||
|
||||
// Also set on global for code that accesses global.fetch
|
||||
if (typeof global !== 'undefined') {
|
||||
global.fetch = window.fetch;
|
||||
}
|
||||
|
||||
// Override XMLHttpRequest if it exists
|
||||
if (window.XMLHttpRequest) {
|
||||
const WrappedXHR = createXHRWrapper(baseUrl, window.XMLHttpRequest);
|
||||
window.XMLHttpRequest = WrappedXHR;
|
||||
|
||||
if (typeof global !== 'undefined') {
|
||||
global.XMLHttpRequest = WrappedXHR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveUrl,
|
||||
createFetch,
|
||||
createXHRWrapper,
|
||||
installHttpIntercept
|
||||
};
|
||||
52
node_modules/@jqhtml/ssr/src/index.js
generated
vendored
Normal file
52
node_modules/@jqhtml/ssr/src/index.js
generated
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* JQHTML SSR - Server-Side Rendering
|
||||
*
|
||||
* @module @jqhtml/ssr
|
||||
*/
|
||||
|
||||
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 {
|
||||
ErrorCodes,
|
||||
parseRequest,
|
||||
successResponse,
|
||||
errorResponse,
|
||||
renderResponse,
|
||||
pingResponse,
|
||||
flushCacheResponse,
|
||||
MessageBuffer
|
||||
} = require('./protocol.js');
|
||||
|
||||
module.exports = {
|
||||
// Main server
|
||||
SSRServer,
|
||||
|
||||
// Environment for direct use
|
||||
SSREnvironment,
|
||||
|
||||
// Bundle caching
|
||||
BundleCache,
|
||||
prepareBundleCode,
|
||||
|
||||
// Storage
|
||||
SSR_Storage,
|
||||
createStoragePair,
|
||||
|
||||
// HTTP interception
|
||||
resolveUrl,
|
||||
createFetch,
|
||||
installHttpIntercept,
|
||||
|
||||
// Protocol
|
||||
ErrorCodes,
|
||||
parseRequest,
|
||||
successResponse,
|
||||
errorResponse,
|
||||
renderResponse,
|
||||
pingResponse,
|
||||
flushCacheResponse,
|
||||
MessageBuffer
|
||||
};
|
||||
338
node_modules/@jqhtml/ssr/src/protocol.js
generated
vendored
Normal file
338
node_modules/@jqhtml/ssr/src/protocol.js
generated
vendored
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* JQHTML SSR Protocol
|
||||
*
|
||||
* JSON-based protocol with newline-delimited messages for TCP/Unix socket communication.
|
||||
*
|
||||
* Request format:
|
||||
* {
|
||||
* "id": "request-uuid",
|
||||
* "type": "render" | "ping" | "flush_cache",
|
||||
* "payload": { ... }
|
||||
* }
|
||||
*
|
||||
* Response format:
|
||||
* {
|
||||
* "id": "request-uuid",
|
||||
* "status": "success" | "error",
|
||||
* "payload": { ... } | "error": { code, message, stack }
|
||||
* }
|
||||
*/
|
||||
|
||||
// Error codes matching HTTP semantics
|
||||
const ErrorCodes = {
|
||||
PARSE_ERROR: 'PARSE_ERROR', // 400 - Invalid request JSON
|
||||
BUNDLE_ERROR: 'BUNDLE_ERROR', // 400 - Bundle failed to parse/execute
|
||||
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
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a request message from JSON string
|
||||
* @param {string} message - Raw JSON string
|
||||
* @returns {{ ok: true, request: object } | { ok: false, error: object }}
|
||||
*/
|
||||
function parseRequest(message) {
|
||||
let parsed;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(message);
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.PARSE_ERROR,
|
||||
message: `Invalid JSON: ${e.message}`,
|
||||
stack: e.stack
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!parsed.id || typeof parsed.id !== 'string') {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.PARSE_ERROR,
|
||||
message: 'Missing or invalid "id" field (must be string)'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed.type || typeof parsed.type !== 'string') {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.PARSE_ERROR,
|
||||
message: 'Missing or invalid "type" field (must be string)'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const validTypes = ['render', 'ping', 'flush_cache'];
|
||||
if (!validTypes.includes(parsed.type)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.PARSE_ERROR,
|
||||
message: `Invalid request type "${parsed.type}". Must be one of: ${validTypes.join(', ')}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
if (parsed.type === 'render') {
|
||||
const validation = validateRenderPayload(parsed.payload);
|
||||
if (!validation.ok) {
|
||||
return validation;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, request: parsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate render request payload
|
||||
* @param {object} payload
|
||||
* @returns {{ ok: true } | { ok: false, error: object }}
|
||||
*/
|
||||
function validateRenderPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.PARSE_ERROR,
|
||||
message: 'Missing or invalid "payload" field for render 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`
|
||||
}
|
||||
};
|
||||
}
|
||||
if (!bundle.content || typeof bundle.content !== 'string') {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.PARSE_ERROR,
|
||||
message: `payload.bundles[${i}].content must be a string`
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Validate component name
|
||||
if (!payload.component || typeof payload.component !== 'string') {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.PARSE_ERROR,
|
||||
message: 'payload.component must be a string'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Validate options
|
||||
if (payload.options) {
|
||||
if (typeof payload.options !== 'object') {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.PARSE_ERROR,
|
||||
message: 'payload.options must be an object'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.PARSE_ERROR,
|
||||
message: 'payload.options is required (must include baseUrl)'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// args is optional, but if present must be object
|
||||
if (payload.args !== undefined && (typeof payload.args !== 'object' || payload.args === null)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.PARSE_ERROR,
|
||||
message: 'payload.args must be an object if provided'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success response
|
||||
* @param {string} id - Request ID
|
||||
* @param {object} payload - Response payload
|
||||
* @returns {string} JSON string with newline
|
||||
*/
|
||||
function successResponse(id, payload) {
|
||||
return JSON.stringify({
|
||||
id,
|
||||
status: 'success',
|
||||
payload
|
||||
}) + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error response
|
||||
* @param {string} id - Request ID (or null if unknown)
|
||||
* @param {string} code - Error code from ErrorCodes
|
||||
* @param {string} message - Human-readable error message
|
||||
* @param {string} [stack] - Optional stack trace
|
||||
* @returns {string} JSON string with newline
|
||||
*/
|
||||
function errorResponse(id, code, message, stack) {
|
||||
return JSON.stringify({
|
||||
id: id || 'unknown',
|
||||
status: 'error',
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
...(stack && { stack })
|
||||
}
|
||||
}) + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a render success response
|
||||
* @param {string} id - Request ID
|
||||
* @param {string} html - Rendered HTML
|
||||
* @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 renderResponse(id, html, cache, timing) {
|
||||
return successResponse(id, {
|
||||
html,
|
||||
cache,
|
||||
timing
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ping response
|
||||
* @param {string} id - Request ID
|
||||
* @param {number} uptime_ms - Server uptime in milliseconds
|
||||
* @returns {string} JSON string with newline
|
||||
*/
|
||||
function pingResponse(id, uptime_ms) {
|
||||
return successResponse(id, { uptime_ms });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a flush_cache response
|
||||
* @param {string} id - Request ID
|
||||
* @param {boolean} flushed - Whether flush was successful
|
||||
* @returns {string} JSON string with newline
|
||||
*/
|
||||
function flushCacheResponse(id, flushed) {
|
||||
return successResponse(id, { flushed });
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageBuffer - Handles newline-delimited message framing
|
||||
*
|
||||
* TCP streams don't guarantee message boundaries. This class buffers
|
||||
* incoming data and emits complete messages (delimited by newlines).
|
||||
*/
|
||||
class MessageBuffer {
|
||||
constructor() {
|
||||
this.buffer = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add data to buffer and return any complete messages
|
||||
* @param {string} data - Incoming data chunk
|
||||
* @returns {string[]} Array of complete messages
|
||||
*/
|
||||
push(data) {
|
||||
this.buffer += data;
|
||||
const messages = [];
|
||||
|
||||
let newlineIndex;
|
||||
while ((newlineIndex = this.buffer.indexOf('\n')) !== -1) {
|
||||
const message = this.buffer.slice(0, newlineIndex);
|
||||
this.buffer = this.buffer.slice(newlineIndex + 1);
|
||||
if (message.trim()) {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's incomplete data in the buffer
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasIncomplete() {
|
||||
return this.buffer.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the buffer
|
||||
*/
|
||||
clear() {
|
||||
this.buffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ErrorCodes,
|
||||
parseRequest,
|
||||
validateRenderPayload,
|
||||
successResponse,
|
||||
errorResponse,
|
||||
renderResponse,
|
||||
pingResponse,
|
||||
flushCacheResponse,
|
||||
MessageBuffer
|
||||
};
|
||||
410
node_modules/@jqhtml/ssr/src/server.js
generated
vendored
Executable file
410
node_modules/@jqhtml/ssr/src/server.js
generated
vendored
Executable file
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* JQHTML SSR Server
|
||||
*
|
||||
* Long-running Node.js process that accepts render requests via TCP or Unix socket.
|
||||
* See SPECIFICATION.md for protocol details.
|
||||
*/
|
||||
|
||||
const net = require('net');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
ErrorCodes,
|
||||
parseRequest,
|
||||
renderResponse,
|
||||
pingResponse,
|
||||
flushCacheResponse,
|
||||
errorResponse,
|
||||
MessageBuffer
|
||||
} = require('./protocol.js');
|
||||
|
||||
const { SSREnvironment } = require('./environment.js');
|
||||
const { BundleCache, prepareBundleCode } = require('./bundle-cache.js');
|
||||
|
||||
/**
|
||||
* SSR Server
|
||||
*/
|
||||
class SSRServer {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
maxBundles: options.maxBundles || 10,
|
||||
defaultTimeout: options.defaultTimeout || 30000,
|
||||
...options
|
||||
};
|
||||
|
||||
this.server = null;
|
||||
this.bundleCache = new BundleCache(this.options.maxBundles);
|
||||
this.startTime = Date.now();
|
||||
this.requestCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start server on TCP port
|
||||
* @param {number} port
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
listenTCP(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = net.createServer((socket) => this._handleConnection(socket));
|
||||
|
||||
this.server.on('error', reject);
|
||||
|
||||
this.server.listen(port, () => {
|
||||
console.log(`[SSR] Server listening on tcp://0.0.0.0:${port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start server on Unix socket
|
||||
* @param {string} socketPath
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
listenUnix(socketPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Remove existing socket file if present
|
||||
if (fs.existsSync(socketPath)) {
|
||||
fs.unlinkSync(socketPath);
|
||||
}
|
||||
|
||||
this.server = net.createServer((socket) => this._handleConnection(socket));
|
||||
|
||||
this.server.on('error', reject);
|
||||
|
||||
this.server.listen(socketPath, () => {
|
||||
// Set socket permissions (owner read/write only)
|
||||
fs.chmodSync(socketPath, 0o600);
|
||||
console.log(`[SSR] Server listening on unix://${socketPath}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming connection
|
||||
* @param {net.Socket} socket
|
||||
* @private
|
||||
*/
|
||||
_handleConnection(socket) {
|
||||
const messageBuffer = new MessageBuffer();
|
||||
const remoteId = `${socket.remoteAddress || 'unix'}:${socket.remotePort || 'local'}`;
|
||||
|
||||
socket.on('data', async (data) => {
|
||||
const messages = messageBuffer.push(data.toString());
|
||||
|
||||
for (const message of messages) {
|
||||
await this._handleMessage(socket, message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
console.error(`[SSR] Socket error (${remoteId}):`, err.message);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
if (messageBuffer.hasIncomplete()) {
|
||||
console.warn(`[SSR] Connection closed with incomplete message (${remoteId})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single message
|
||||
* @param {net.Socket} socket
|
||||
* @param {string} message
|
||||
* @private
|
||||
*/
|
||||
async _handleMessage(socket, message) {
|
||||
this.requestCount++;
|
||||
const requestId = this.requestCount;
|
||||
|
||||
// Parse request
|
||||
const parseResult = parseRequest(message);
|
||||
|
||||
if (!parseResult.ok) {
|
||||
const response = errorResponse(
|
||||
null,
|
||||
parseResult.error.code,
|
||||
parseResult.error.message,
|
||||
parseResult.error.stack
|
||||
);
|
||||
socket.write(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = parseResult.request;
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
switch (request.type) {
|
||||
case 'ping':
|
||||
response = this._handlePing(request);
|
||||
break;
|
||||
|
||||
case 'flush_cache':
|
||||
response = this._handleFlushCache(request);
|
||||
break;
|
||||
|
||||
case 'render':
|
||||
response = await this._handleRender(request);
|
||||
break;
|
||||
|
||||
default:
|
||||
response = errorResponse(
|
||||
request.id,
|
||||
ErrorCodes.PARSE_ERROR,
|
||||
`Unknown request type: ${request.type}`
|
||||
);
|
||||
}
|
||||
|
||||
socket.write(response);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[SSR] Error handling request ${request.id}:`, err);
|
||||
|
||||
const response = errorResponse(
|
||||
request.id,
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
err.message,
|
||||
err.stack
|
||||
);
|
||||
socket.write(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle ping request
|
||||
* @param {object} request
|
||||
* @returns {string} Response
|
||||
* @private
|
||||
*/
|
||||
_handlePing(request) {
|
||||
const uptime_ms = Date.now() - this.startTime;
|
||||
return pingResponse(request.id, uptime_ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle flush_cache request
|
||||
* @param {object} request
|
||||
* @returns {string} Response
|
||||
* @private
|
||||
*/
|
||||
_handleFlushCache(request) {
|
||||
const bundleId = request.payload?.bundle_id;
|
||||
|
||||
if (bundleId) {
|
||||
// Flush specific bundle - need to find all cache keys containing this bundle
|
||||
const stats = this.bundleCache.stats();
|
||||
let flushed = false;
|
||||
|
||||
for (const key of stats.keys) {
|
||||
if (key.includes(bundleId)) {
|
||||
this.bundleCache.delete(key);
|
||||
flushed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return flushCacheResponse(request.id, flushed);
|
||||
} else {
|
||||
// Flush all
|
||||
this.bundleCache.clear();
|
||||
return flushCacheResponse(request.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle render request
|
||||
* @param {object} request
|
||||
* @returns {Promise<string>} Response
|
||||
* @private
|
||||
*/
|
||||
async _handleRender(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();
|
||||
bundleCode = prepareBundleCode(payload.bundles);
|
||||
this.bundleCache.set(cacheKey, bundleCode);
|
||||
bundleLoadMs = Date.now() - bundleStartTime;
|
||||
}
|
||||
|
||||
// Create fresh environment for this request
|
||||
const env = new SSREnvironment({
|
||||
baseUrl: payload.options.baseUrl,
|
||||
timeout: payload.options.timeout || this.options.defaultTimeout
|
||||
});
|
||||
|
||||
try {
|
||||
// Initialize environment
|
||||
env.init();
|
||||
|
||||
// Execute bundle code
|
||||
const execStartTime = Date.now();
|
||||
env.execute(bundleCode, `bundles:${cacheKey}`);
|
||||
bundleLoadMs += Date.now() - execStartTime;
|
||||
|
||||
// Check if jqhtml loaded
|
||||
if (!env.isJqhtmlReady()) {
|
||||
throw new Error('jqhtml runtime not available after loading bundles');
|
||||
}
|
||||
|
||||
// Check if component is registered
|
||||
const templates = env.getRegisteredTemplates();
|
||||
if (!templates.includes(payload.component)) {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
ErrorCodes.COMPONENT_NOT_FOUND,
|
||||
`Component "${payload.component}" not found. Available: ${templates.slice(0, 10).join(', ')}${templates.length > 10 ? '...' : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
// Render component
|
||||
const renderStartTime = Date.now();
|
||||
const result = await env.render(payload.component, payload.args || {});
|
||||
const renderMs = Date.now() - renderStartTime;
|
||||
|
||||
const totalMs = Date.now() - startTime;
|
||||
|
||||
return renderResponse(request.id, result.html, 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('SyntaxError') || err.message.includes('parse')) {
|
||||
errorCode = ErrorCodes.BUNDLE_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>}
|
||||
*/
|
||||
stop() {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
this.server.close(() => {
|
||||
console.log('[SSR] Server stopped');
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server statistics
|
||||
* @returns {object}
|
||||
*/
|
||||
stats() {
|
||||
return {
|
||||
uptime_ms: Date.now() - this.startTime,
|
||||
request_count: this.requestCount,
|
||||
bundle_cache: this.bundleCache.stats()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SSRServer };
|
||||
|
||||
// CLI entry point
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
let tcpPort = null;
|
||||
let socketPath = null;
|
||||
let maxBundles = 10;
|
||||
let timeout = 30000;
|
||||
|
||||
// Parse arguments
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--tcp':
|
||||
tcpPort = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--socket':
|
||||
socketPath = args[++i];
|
||||
break;
|
||||
case '--max-bundles':
|
||||
maxBundles = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--timeout':
|
||||
timeout = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--help':
|
||||
console.log(`
|
||||
JQHTML SSR Server
|
||||
|
||||
Usage:
|
||||
node server.js --tcp <port>
|
||||
node server.js --socket <path>
|
||||
|
||||
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 this help
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tcpPort && !socketPath) {
|
||||
console.error('Error: Must specify --tcp <port> or --socket <path>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = new SSRServer({ maxBundles, defaultTimeout: timeout });
|
||||
|
||||
// Start server
|
||||
(async () => {
|
||||
try {
|
||||
if (tcpPort) {
|
||||
await server.listenTCP(tcpPort);
|
||||
} else {
|
||||
await server.listenUnix(socketPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SSR] Failed to start server:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n[SSR] Shutting down...');
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('\n[SSR] Shutting down...');
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
90
node_modules/@jqhtml/ssr/src/storage.js
generated
vendored
Normal file
90
node_modules/@jqhtml/ssr/src/storage.js
generated
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* JQHTML SSR Fake Storage
|
||||
*
|
||||
* Implements localStorage/sessionStorage API that captures all writes
|
||||
* for export at the end of SSR render.
|
||||
*/
|
||||
|
||||
class SSR_Storage {
|
||||
constructor(name = 'storage') {
|
||||
this._name = name;
|
||||
this._data = new Map();
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
const value = this._data.get(key);
|
||||
return value !== undefined ? value : 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) {
|
||||
const keys = Array.from(this._data.keys());
|
||||
return index < keys.length ? keys[index] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSR-specific: Export all data as plain object
|
||||
* @returns {Object} Key-value pairs
|
||||
*/
|
||||
_export() {
|
||||
return Object.fromEntries(this._data);
|
||||
}
|
||||
|
||||
/**
|
||||
* SSR-specific: Import data (for testing or pre-population)
|
||||
* @param {Object} data - Key-value pairs to import
|
||||
*/
|
||||
_import(data) {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
this._data.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSR-specific: Check if any data was written
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_hasData() {
|
||||
return this._data.size > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pair of storage instances for an SSR request
|
||||
* @returns {{ localStorage: SSR_Storage, sessionStorage: SSR_Storage, exportAll: function }}
|
||||
*/
|
||||
function createStoragePair() {
|
||||
const localStorage = new SSR_Storage('localStorage');
|
||||
const sessionStorage = new SSR_Storage('sessionStorage');
|
||||
|
||||
return {
|
||||
localStorage,
|
||||
sessionStorage,
|
||||
exportAll() {
|
||||
return {
|
||||
localStorage: localStorage._export(),
|
||||
sessionStorage: sessionStorage._export()
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SSR_Storage,
|
||||
createStoragePair
|
||||
};
|
||||
Reference in New Issue
Block a user