Files
rspade_system/node_modules/@jqhtml/ssr/src/server.js
root 14dd2fd223 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>
2025-12-16 04:43:47 +00:00

411 lines
9.9 KiB
JavaScript
Executable File

#!/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);
});
}