/**
* 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
};