nostr-deploy-server
Version:
Node.js server for hosting static websites under npub subdomains using Nostr protocol and Blossom servers
522 lines (454 loc) • 16.6 kB
text/typescript
import * as mimeTypes from 'mime-types';
import puppeteer, { Browser, Page } from 'puppeteer';
import { ConfigManager } from '../utils/config';
import { logger } from '../utils/logger';
export interface SSRResult {
html: string;
contentType: string;
status: number;
}
export interface SSROptions {
timeout?: number;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
viewport?: {
width: number;
height: number;
};
userAgent?: string;
}
export class SSRHelper {
private browser: Browser | null = null;
private config = ConfigManager.getInstance().getConfig();
private activePageCount = 0;
constructor() {
this.initializeBrowser();
}
private async initializeBrowser(): Promise<void> {
try {
logger.info('Initializing Puppeteer browser...');
this.browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-web-security',
'--disable-features=site-per-process',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--memory-pressure-off',
'--max_old_space_size=4096',
],
ignoreDefaultArgs: ['--disable-extensions'],
timeout: 30000,
});
logger.info(
`Puppeteer browser initialized successfully, connected: ${this.browser.isConnected()}`
);
// Handle browser disconnect
this.browser.on('disconnected', () => {
logger.warn('Puppeteer browser disconnected, will reinitialize on next request');
this.browser = null;
// Don't immediately reinitialize - wait for next request to avoid resource waste
});
} catch (error) {
logger.error('Failed to initialize Puppeteer browser:', error);
throw error;
}
}
private async ensureBrowser(): Promise<Browser> {
if (!this.browser || !this.browser.isConnected()) {
await this.initializeBrowser();
}
return this.browser!;
}
/**
* Render a static site using Puppeteer
*/
async renderPage(
url: string,
originalContent: Buffer,
contentType: string,
options: SSROptions = {}
): Promise<SSRResult> {
const startTime = Date.now();
let page: Page | null = null;
try {
// Check if we've hit the concurrent page limit
if (this.activePageCount >= this.config.ssrMaxConcurrentPages) {
logger.warn(
`SSR: Hit concurrent page limit (${this.config.ssrMaxConcurrentPages}), falling back to original content for ${url}`
);
return {
html: originalContent.toString(),
contentType,
status: 200,
};
}
this.activePageCount++;
logger.debug(
`SSR: Active pages: ${this.activePageCount}/${this.config.ssrMaxConcurrentPages}`
);
const browser = await this.ensureBrowser();
logger.debug(`SSR: Browser connected: ${browser.isConnected()}`);
page = await browser.newPage();
logger.debug(`SSR: New page created for ${url}`);
// Set viewport
const viewport = options.viewport || {
width: this.config.ssrViewportWidth,
height: this.config.ssrViewportHeight,
};
await page.setViewport(viewport);
// Set user agent if provided
if (options.userAgent) {
await page.setUserAgent(options.userAgent);
}
// Set timeout
const timeout = options.timeout || this.config.ssrTimeoutMs;
page.setDefaultTimeout(timeout);
// Handle console logs from the page
page.on('console', (msg) => {
logger.debug(`Browser console [${msg.type()}]: ${msg.text()}`);
});
// Handle page errors
page.on('pageerror', (error) => {
logger.warn(`Browser page error: ${error.message}`);
});
// Handle console errors
page.on('console', (msg) => {
if (msg.type() === 'error') {
logger.warn(`Browser console error: ${msg.text()}`);
}
});
// Disable CSP to allow JavaScript execution during SSR
await page.setBypassCSP(true);
// Enable request interception to handle relative asset URLs
await page.setRequestInterception(true);
page.on('request', (request) => {
const requestUrl = request.url();
// If it's a relative URL starting with /assets or similar, redirect to actual domain
if (requestUrl.startsWith('data:text/html') || requestUrl.includes('about:blank')) {
request.continue();
return;
}
// Handle relative asset requests
if (requestUrl.startsWith('/') && !requestUrl.startsWith('//')) {
const baseUrl = new URL(url);
const fullUrl = `${baseUrl.protocol}//${baseUrl.host}${requestUrl}`;
logger.debug(`SSR: Redirecting asset request from ${requestUrl} to ${fullUrl}`);
request.continue({ url: fullUrl });
return;
}
request.continue();
});
// Handle response interception to fix MIME types
page.on('response', async (response) => {
const responseUrl = response.url();
const originalContentType = response.headers()['content-type'] || '';
// Only fix MIME types for asset requests (not the main HTML)
if (
responseUrl !== url &&
(responseUrl.includes('/assets/') ||
responseUrl.match(/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/i))
) {
const path = new URL(responseUrl).pathname;
const correctedContentType = this.fixMimeTypeForSSR(originalContentType, path);
if (correctedContentType !== originalContentType) {
logger.debug(
`SSR: Fixed MIME type for ${path}: ${originalContentType} -> ${correctedContentType}`
);
// Note: We can't modify response headers in Puppeteer, but this helps with logging
// The actual fix happens at the server level when serving the assets
}
}
});
// Check if content is HTML
if (!contentType.includes('text/html')) {
// For non-HTML files, return original content
return {
html: originalContent.toString(),
contentType,
status: 200,
};
}
// Set the HTML content directly
const htmlContent = originalContent.toString();
logger.debug(`SSR: Loading page content for ${url}`);
await page.setContent(htmlContent, {
waitUntil: options.waitUntil || 'networkidle0',
timeout,
});
// Set the current URL to help with relative asset loading
await page.evaluate(`window.history.replaceState({}, '', '${url}');`);
// Wait for the root element to be populated (for SPAs)
try {
// Wait for either root div to have content or for a reasonable timeout
await page.waitForFunction(
`() => {
const root = document.getElementById('root');
return root && (root.children.length > 0 || root.innerHTML.trim().length > 0);
}`,
{ timeout: timeout / 2 } // Use half the total timeout for this check
);
logger.debug('Root element populated, content rendered');
} catch (waitError) {
logger.warn('Root element not populated within timeout, proceeding anyway');
// Additional wait for JavaScript execution
await new Promise((resolve) => setTimeout(resolve, 3000));
// Try alternative selectors for common frameworks
try {
await page.waitForFunction(
`() => {
// Check for common app containers
const selectors = ['#root', '#app', '[data-reactroot]', '.app'];
return selectors.some(sel => {
const el = document.querySelector(sel);
return el && (el.children.length > 0 || el.innerHTML.trim().length > 20);
});
}`,
{ timeout: 5000 }
);
logger.debug('App container found with alternative selectors');
} catch (altError) {
logger.warn('No app container found, using current page state');
}
}
// Get the rendered HTML
const renderedHtml = await page.content();
// Debug: Check if root element has content
const rootContent = await page.evaluate(`
const root = document.getElementById('root');
return root ? root.innerHTML.length : 0;
`);
logger.debug(`SSR: Root element content length: ${rootContent} characters`);
// Inject meta tags for SEO if this is the main page
const enhancedHtml = this.enhanceHtmlForSSR(renderedHtml, url);
const renderTime = Date.now() - startTime;
logger.info(
`SSR rendered page in ${renderTime}ms for URL: ${url} (root content: ${rootContent} chars)`
);
return {
html: enhancedHtml,
contentType: 'text/html; charset=utf-8',
status: 200,
};
} catch (error) {
const renderTime = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Check if it's a browser disconnection error
if (
errorMessage.includes('Protocol error') ||
errorMessage.includes('Target closed') ||
errorMessage.includes('Connection closed')
) {
logger.warn(
`SSR browser disconnected after ${renderTime}ms for URL: ${url}, reinitializing browser`
);
// Clear the browser reference so it gets reinitialized on next request
this.browser = null;
} else {
logger.error(`SSR failed after ${renderTime}ms for URL: ${url}`, error);
}
// Fallback to original content if SSR fails
return {
html: originalContent.toString(),
contentType,
status: 200,
};
} finally {
// Always decrement the active page count
this.activePageCount = Math.max(0, this.activePageCount - 1);
if (page) {
try {
// Check if page is still valid before closing
if (!page.isClosed()) {
await page.close();
}
} catch (error) {
// Ignore errors when closing page - browser might have disconnected
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.debug('Page close error (expected if browser disconnected):', errorMessage);
}
}
}
}
/**
* Enhance HTML with SSR-specific optimizations
*/
private enhanceHtmlForSSR(html: string, url: string): string {
// Add meta tags for better SEO and social sharing
const metaTags = `
<meta name="generator" content="Nostr Static Server SSR">
<meta property="og:url" content="${url}">
<meta name="twitter:url" content="${url}">
<link rel="canonical" href="${url}">
`;
// Inject meta tags into head if possible
if (html.includes('<head>')) {
return html.replace('<head>', `<head>${metaTags}`);
} else if (html.includes('<html>')) {
return html.replace('<html>', `<html><head>${metaTags}</head>`);
}
return html;
}
/**
* Check if a file should be SSR rendered
*/
shouldRenderSSR(contentType: string, path: string): boolean {
// Check if SSR is enabled in configuration
if (!this.config.ssrEnabled) {
return false;
}
// Only render HTML files
if (!contentType.includes('text/html')) {
return false;
}
// Don't render if it's an API endpoint or admin path
if (path.startsWith('/api/') || path.startsWith('/admin/')) {
return false;
}
return true;
}
/**
* Close the browser and cleanup
*/
async close(): Promise<void> {
if (this.browser) {
try {
await this.browser.close();
logger.info('Puppeteer browser closed successfully');
} catch (error) {
logger.error('Error closing Puppeteer browser:', error);
} finally {
this.browser = null;
}
}
}
/**
* Fix incorrect MIME types for assets during SSR
* Copied from BlossomHelper to handle same MIME type issues
*/
private fixMimeTypeForSSR(serverContentType: string, path: string): string {
if (!path) return serverContentType;
const ext = path.toLowerCase().split('.').pop();
if (!ext) return serverContentType;
// Get the expected MIME type based on file extension
const expectedMimeType = this.getContentTypeFromPath(path);
// List of correct MIME types for major file types that we want to enforce
const criticalMimeTypes: Record<string, string[]> = {
'text/html': ['html', 'htm'],
'text/css': ['css'],
'application/javascript': ['js'],
'text/javascript': ['js'], // Alternative for JavaScript
'application/json': ['json'],
'text/xml': ['xml'],
'application/xml': ['xml'],
'image/png': ['png'],
'image/jpeg': ['jpg', 'jpeg'],
'image/gif': ['gif'],
'image/svg+xml': ['svg'],
'image/x-icon': ['ico'],
'font/woff': ['woff'],
'font/woff2': ['woff2'],
'font/ttf': ['ttf'],
'application/vnd.ms-fontobject': ['eot'],
};
// Check if this is a critical file type that we want to fix
const isCriticalFile = Object.values(criticalMimeTypes).some((extensions) =>
extensions.includes(ext)
);
if (!isCriticalFile) {
return serverContentType; // Don't modify MIME types for non-critical files
}
// List of commonly incorrect MIME types that servers might return
const incorrectMimeTypes = [
'application/json',
'text/plain',
'application/octet-stream',
'binary/octet-stream',
'text/html', // Sometimes HTML is returned for non-HTML files
];
// If server returned an incorrect MIME type for a critical file, fix it
if (
incorrectMimeTypes.includes(serverContentType) ||
!this.isMimeTypeCorrectForExtension(serverContentType, ext)
) {
logger.warn(
`SSR: Correcting incorrect MIME type for ${path}: ${serverContentType} -> ${expectedMimeType}`
);
return expectedMimeType;
}
return serverContentType;
}
/**
* Check if the MIME type is correct for the given file extension
*/
private isMimeTypeCorrectForExtension(mimeType: string, extension: string): boolean {
const mimeTypeMap: Record<string, string[]> = {
html: ['text/html'],
htm: ['text/html'],
css: ['text/css'],
js: ['application/javascript', 'text/javascript'],
json: ['application/json'],
xml: ['text/xml', 'application/xml'],
png: ['image/png'],
jpg: ['image/jpeg'],
jpeg: ['image/jpeg'],
gif: ['image/gif'],
svg: ['image/svg+xml'],
ico: ['image/x-icon', 'image/vnd.microsoft.icon'],
woff: ['font/woff', 'application/font-woff'],
woff2: ['font/woff2', 'application/font-woff2'],
ttf: ['font/ttf', 'application/font-ttf'],
eot: ['application/vnd.ms-fontobject'],
};
const validMimeTypes = mimeTypeMap[extension.toLowerCase()];
return validMimeTypes ? validMimeTypes.includes(mimeType.toLowerCase()) : true;
}
/**
* Get content type from file path
*/
private getContentTypeFromPath(path: string): string {
if (!path) return 'application/octet-stream';
const contentType = mimeTypes.lookup(path);
if (contentType) {
return contentType;
}
// Fallback based on extension
const ext = path.toLowerCase().split('.').pop();
switch (ext) {
case 'html':
case 'htm':
return 'text/html';
case 'css':
return 'text/css';
case 'js':
return 'application/javascript';
case 'json':
return 'application/json';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'gif':
return 'image/gif';
case 'svg':
return 'image/svg+xml';
case 'ico':
return 'image/x-icon';
case 'woff':
return 'font/woff';
case 'woff2':
return 'font/woff2';
case 'ttf':
return 'font/ttf';
case 'eot':
return 'application/vnd.ms-fontobject';
default:
return 'application/octet-stream';
}
}
}