🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
339 lines
8.0 KiB
JavaScript
339 lines
8.0 KiB
JavaScript
/**
|
|
* 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
|
|
};
|