- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
289 lines
9.4 KiB
JavaScript
289 lines
9.4 KiB
JavaScript
/**
|
|
* Lightweight DOM Patcher for LiveComponent Fragments
|
|
*
|
|
* Efficiently patches specific DOM fragments without full re-render.
|
|
* Optimized for LiveComponent use case with minimal overhead.
|
|
*
|
|
* Features:
|
|
* - Smart element matching by tag and data-lc-fragment attribute
|
|
* - Attribute diffing and patching
|
|
* - Text content updates
|
|
* - Child node reconciliation
|
|
* - Preserves focus and scroll position where possible
|
|
*
|
|
* Philosophy:
|
|
* - Keep it simple and focused
|
|
* - No external dependencies
|
|
* - Framework-compliant modern JavaScript
|
|
*/
|
|
export class DomPatcher {
|
|
/**
|
|
* Patch a specific fragment within a container
|
|
*
|
|
* @param {HTMLElement} container - Container element to search in
|
|
* @param {string} fragmentName - Fragment name (data-lc-fragment value)
|
|
* @param {string} newHtml - New HTML for the fragment
|
|
* @returns {boolean} - True if fragment was patched, false if not found
|
|
*/
|
|
patchFragment(container, fragmentName, newHtml) {
|
|
// Find existing fragment element
|
|
const existingElement = container.querySelector(`[data-lc-fragment="${fragmentName}"]`);
|
|
|
|
if (!existingElement) {
|
|
console.warn(`[DomPatcher] Fragment not found: ${fragmentName}`);
|
|
return false;
|
|
}
|
|
|
|
// Parse new HTML into element
|
|
const temp = document.createElement('div');
|
|
temp.innerHTML = newHtml;
|
|
const newElement = temp.firstElementChild;
|
|
|
|
if (!newElement) {
|
|
console.warn(`[DomPatcher] Invalid HTML for fragment: ${fragmentName}`);
|
|
return false;
|
|
}
|
|
|
|
// Verify fragment name matches
|
|
if (newElement.getAttribute('data-lc-fragment') !== fragmentName) {
|
|
console.warn(`[DomPatcher] Fragment name mismatch: expected ${fragmentName}, got ${newElement.getAttribute('data-lc-fragment')}`);
|
|
return false;
|
|
}
|
|
|
|
// Patch the element in place
|
|
this.patchElement(existingElement, newElement);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Patch multiple fragments at once
|
|
*
|
|
* @param {HTMLElement} container - Container element
|
|
* @param {Object.<string, string>} fragments - Map of fragment names to HTML
|
|
* @returns {Object.<string, boolean>} - Map of fragment names to success status
|
|
*/
|
|
patchFragments(container, fragments) {
|
|
const results = {};
|
|
|
|
for (const [fragmentName, html] of Object.entries(fragments)) {
|
|
results[fragmentName] = this.patchFragment(container, fragmentName, html);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Patch an element with new content
|
|
*
|
|
* Core patching logic that efficiently updates only what changed.
|
|
*
|
|
* @param {HTMLElement} oldElement - Existing element to patch
|
|
* @param {HTMLElement} newElement - New element with updated content
|
|
*/
|
|
patchElement(oldElement, newElement) {
|
|
// 1. Patch attributes
|
|
this.patchAttributes(oldElement, newElement);
|
|
|
|
// 2. Patch child nodes
|
|
this.patchChildren(oldElement, newElement);
|
|
}
|
|
|
|
/**
|
|
* Patch element attributes
|
|
*
|
|
* Only updates attributes that actually changed.
|
|
*
|
|
* @param {HTMLElement} oldElement - Existing element
|
|
* @param {HTMLElement} newElement - New element
|
|
*/
|
|
patchAttributes(oldElement, newElement) {
|
|
// Get all attributes from both elements
|
|
const oldAttrs = new Map();
|
|
const newAttrs = new Map();
|
|
|
|
for (const attr of oldElement.attributes) {
|
|
oldAttrs.set(attr.name, attr.value);
|
|
}
|
|
|
|
for (const attr of newElement.attributes) {
|
|
newAttrs.set(attr.name, attr.value);
|
|
}
|
|
|
|
// Remove attributes that no longer exist
|
|
for (const [name, value] of oldAttrs) {
|
|
if (!newAttrs.has(name)) {
|
|
oldElement.removeAttribute(name);
|
|
}
|
|
}
|
|
|
|
// Add or update attributes
|
|
for (const [name, value] of newAttrs) {
|
|
if (oldAttrs.get(name) !== value) {
|
|
oldElement.setAttribute(name, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Patch child nodes
|
|
*
|
|
* Reconciles child nodes between old and new elements.
|
|
* Uses simple key-based matching for efficiency.
|
|
*
|
|
* @param {HTMLElement} oldElement - Existing element
|
|
* @param {HTMLElement} newElement - New element
|
|
*/
|
|
patchChildren(oldElement, newElement) {
|
|
const oldChildren = Array.from(oldElement.childNodes);
|
|
const newChildren = Array.from(newElement.childNodes);
|
|
|
|
const maxLength = Math.max(oldChildren.length, newChildren.length);
|
|
|
|
for (let i = 0; i < maxLength; i++) {
|
|
const oldChild = oldChildren[i];
|
|
const newChild = newChildren[i];
|
|
|
|
if (!oldChild && newChild) {
|
|
// New child added - append it
|
|
oldElement.appendChild(newChild.cloneNode(true));
|
|
} else if (oldChild && !newChild) {
|
|
// Child removed - remove it
|
|
oldElement.removeChild(oldChild);
|
|
} else if (oldChild && newChild) {
|
|
// Both exist - patch or replace
|
|
if (this.shouldPatch(oldChild, newChild)) {
|
|
if (oldChild.nodeType === Node.ELEMENT_NODE) {
|
|
this.patchElement(oldChild, newChild);
|
|
} else if (oldChild.nodeType === Node.TEXT_NODE) {
|
|
if (oldChild.nodeValue !== newChild.nodeValue) {
|
|
oldChild.nodeValue = newChild.nodeValue;
|
|
}
|
|
}
|
|
} else {
|
|
// Different node types or tags - replace
|
|
oldElement.replaceChild(newChild.cloneNode(true), oldChild);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine if two nodes should be patched or replaced
|
|
*
|
|
* Nodes should be patched if they are compatible (same type and tag).
|
|
*
|
|
* @param {Node} oldNode - Existing node
|
|
* @param {Node} newNode - New node
|
|
* @returns {boolean} - True if nodes should be patched
|
|
*/
|
|
shouldPatch(oldNode, newNode) {
|
|
// Different node types - replace
|
|
if (oldNode.nodeType !== newNode.nodeType) {
|
|
return false;
|
|
}
|
|
|
|
// Text nodes can always be patched
|
|
if (oldNode.nodeType === Node.TEXT_NODE) {
|
|
return true;
|
|
}
|
|
|
|
// Element nodes - check if same tag
|
|
if (oldNode.nodeType === Node.ELEMENT_NODE) {
|
|
if (oldNode.tagName !== newNode.tagName) {
|
|
return false;
|
|
}
|
|
|
|
// Check for special keys that indicate identity
|
|
const oldKey = oldNode.getAttribute('data-lc-key') || oldNode.getAttribute('id');
|
|
const newKey = newNode.getAttribute('data-lc-key') || newNode.getAttribute('id');
|
|
|
|
// If both have keys, they must match
|
|
if (oldKey && newKey) {
|
|
return oldKey === newKey;
|
|
}
|
|
|
|
// Otherwise, assume they match (same tag is enough)
|
|
return true;
|
|
}
|
|
|
|
// Other node types - replace
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Preserve focus state before patching
|
|
*
|
|
* Returns a function to restore focus after patching.
|
|
*
|
|
* @param {HTMLElement} container - Container being patched
|
|
* @returns {Function} - Restore function
|
|
*/
|
|
preserveFocus(container) {
|
|
const activeElement = document.activeElement;
|
|
|
|
// Check if focused element is within container
|
|
if (!activeElement || !container.contains(activeElement)) {
|
|
return () => {}; // No-op restore
|
|
}
|
|
|
|
// Get selector for focused element
|
|
const selector = this.getElementSelector(activeElement);
|
|
const selectionStart = activeElement.selectionStart;
|
|
const selectionEnd = activeElement.selectionEnd;
|
|
|
|
// Return restore function
|
|
return () => {
|
|
try {
|
|
if (selector) {
|
|
const element = container.querySelector(selector);
|
|
if (element && element.focus) {
|
|
element.focus();
|
|
|
|
// Restore selection for input/textarea
|
|
if (element.setSelectionRange &&
|
|
typeof selectionStart === 'number' &&
|
|
typeof selectionEnd === 'number') {
|
|
element.setSelectionRange(selectionStart, selectionEnd);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Focus restoration failed - not critical
|
|
console.debug('[DomPatcher] Could not restore focus:', e);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a selector for an element
|
|
*
|
|
* Tries to create a unique selector using ID, name, or data attributes.
|
|
*
|
|
* @param {HTMLElement} element - Element to get selector for
|
|
* @returns {string|null} - CSS selector or null
|
|
*/
|
|
getElementSelector(element) {
|
|
if (element.id) {
|
|
return `#${element.id}`;
|
|
}
|
|
|
|
if (element.name) {
|
|
return `[name="${element.name}"]`;
|
|
}
|
|
|
|
const lcKey = element.getAttribute('data-lc-key');
|
|
if (lcKey) {
|
|
return `[data-lc-key="${lcKey}"]`;
|
|
}
|
|
|
|
// Fallback - not guaranteed to be unique
|
|
return element.tagName.toLowerCase();
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
export const domPatcher = new DomPatcher();
|
|
|
|
export default domPatcher;
|