import { IsolatedHydrateRenderer } from './resource-manager.js';
import type { HydrateRenderer, HydrateRendererOptions, RenderResponsePayload } from './types.js';
export const isPlainObject = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const normalizeDiagnostics = (diagnostics: unknown): unknown[] => {
if (!diagnostics) {
return [];
}
if (Array.isArray(diagnostics)) {
return diagnostics;
}
return [diagnostics];
};
const coerceComponents = (components: unknown): string[] => {
if (!Array.isArray(components)) {
return [];
}
return components.filter((component): component is string => typeof component === 'string');
};
const coerceHydratedCount = (hydratedCount: unknown): number => {
return typeof hydratedCount === 'number' && Number.isFinite(hydratedCount) ? hydratedCount : 0;
};
const DEFAULT_RENDERER_OPTIONS: Readonly<HydrateRendererOptions> = Object.freeze({
clientHydrateAnnotations: false,
destroyDocument: true,
destroyWindow: true,
prettyHtml: false,
removeAttributeQuotes: false,
removeBooleanAttributeQuotes: true,
removeEmptyAttributes: false,
removeHtmlComments: true,
removeUnusedStyles: true,
serializeToHtml: true,
timeout: 5000, // Keep original timeout for backward compatibility
});
const DEFAULT_TIMEOUT = 5000;
const mergeHydrateOptions = (base: Record<string, unknown> | undefined, overrides: Record<string, unknown> | undefined): HydrateRendererOptions => ({
...DEFAULT_RENDERER_OPTIONS,
...(base ?? {}),
...(overrides ?? {}),
});
class AsyncMutex {
private queue: Array<() => void> = [];
private locked = false;
public async acquire(): Promise<() => void> {
return new Promise<() => void>((resolve) => {
const release = this.createReleaseCallback();
if (!this.locked) {
this.locked = true;
resolve(release);
return;
}
this.queue.push(() => resolve(release));
});
}
public async runExclusive<T>(callback: () => Promise<T>): Promise<T>;
public async runExclusive<T>(callback: () => T): Promise<T>;
public async runExclusive<T>(callback: () => Promise<T> | T): Promise<T> {
const release = await this.acquire();
try {
return await callback();
} finally {
release();
}
}
private createReleaseCallback(): () => void {
let released = false;
return () => {
if (released) {
return;
}
released = true;
const next = this.queue.shift();
if (next) {
next();
return;
}
this.locked = false;
};
}
}
const rendererMutex = new AsyncMutex();
// Global isolated renderer instance for resource management
let globalIsolatedRenderer: IsolatedHydrateRenderer | null = null;
/**
* Initialize or get the global isolated renderer
*/
export const getIsolatedRenderer = (baseRenderer: HydrateRenderer): IsolatedHydrateRenderer => {
if (!globalIsolatedRenderer) {
globalIsolatedRenderer = new IsolatedHydrateRenderer(baseRenderer);
}
return globalIsolatedRenderer;
};
/**
* Cleanup the global isolated renderer
*/
export const cleanupGlobalRenderer = (): void => {
const cleanupPromise = rendererMutex.runExclusive(() => {
if (globalIsolatedRenderer) {
globalIsolatedRenderer.destroy();
globalIsolatedRenderer = null;
}
});
void cleanupPromise.catch((error) => {
if (typeof queueMicrotask === 'function') {
queueMicrotask(() => {
throw error;
});
return;
}
setTimeout(() => {
throw error;
}, 0);
});
};
/**
* Server-optimized hydrate function that reuses isolated renderer instance
*/
export const hydrateFragmentForServer = async (
renderer: HydrateRenderer,
html: string,
options?: unknown,
baseOptions?: Record<string, unknown>,
): Promise<RenderResponsePayload> => {
if (typeof html !== 'string' || html.trim().length === 0) {
throw new TypeError('HTML input must be a non-empty string');
}
const normalizedOptions = isPlainObject(options) ? (options as Record<string, unknown>) : undefined;
const normalizedBaseOptions = isPlainObject(baseOptions) ? baseOptions : undefined;
const effectiveOptions = mergeHydrateOptions(normalizedBaseOptions, normalizedOptions);
return rendererMutex.runExclusive(async () => {
// Use shared isolated renderer for server efficiency
const isolatedRenderer = getIsolatedRenderer(renderer);
try {
// Race between rendering and timeout to prevent hanging
const renderPromise = isolatedRenderer.render(html, effectiveOptions);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Hydration timeout after ${DEFAULT_TIMEOUT}ms`));
}, DEFAULT_TIMEOUT);
});
const result = await Promise.race([renderPromise, timeoutPromise]);
return {
html: typeof result.html === 'string' ? result.html : html,
components: coerceComponents(result.components),
hydratedCount: coerceHydratedCount(result.hydratedCount),
diagnostics: normalizeDiagnostics(result.diagnostics),
};
} catch (error) {
// Log renderer stats for debugging
const stats = isolatedRenderer.getStats();
console.warn(`Hydration failed with ${stats.activeTimers} active timers:`, error);
// Force cleanup on timeout/error
isolatedRenderer.destroy();
globalIsolatedRenderer = null;
throw error;
}
});
};
export const hydrateFragment = async (
renderer: HydrateRenderer,
html: string,
options?: unknown,
baseOptions?: Record<string, unknown>,
): Promise<RenderResponsePayload> => {
if (typeof html !== 'string' || html.trim().length === 0) {
throw new TypeError('HTML input must be a non-empty string');
}
const normalizedOptions = isPlainObject(options) ? (options as Record<string, unknown>) : undefined;
const normalizedBaseOptions = isPlainObject(baseOptions) ? baseOptions : undefined;
const effectiveOptions = mergeHydrateOptions(normalizedBaseOptions, normalizedOptions);
// Create a new isolated renderer for each call to ensure test isolation
const isolatedRenderer = new IsolatedHydrateRenderer(renderer);
try {
// Race between rendering and timeout to prevent hanging
const renderPromise = isolatedRenderer.render(html, effectiveOptions);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Hydration timeout after ${DEFAULT_TIMEOUT}ms`));
}, DEFAULT_TIMEOUT);
});
const result = await Promise.race([renderPromise, timeoutPromise]);
// Clean up after successful rendering
isolatedRenderer.destroy();
return {
html: typeof result.html === 'string' ? result.html : html,
components: coerceComponents(result.components),
hydratedCount: coerceHydratedCount(result.hydratedCount),
diagnostics: normalizeDiagnostics(result.diagnostics),
};
} catch (error) {
// Log renderer stats for debugging
const stats = isolatedRenderer.getStats();
console.warn(`Hydration failed with ${stats.activeTimers} active timers:`, error);
// Force cleanup on timeout/error
isolatedRenderer.destroy();
throw error;
}
};