nostr-deploy-server
Version:
Node.js server for hosting static websites under npub subdomains using Nostr protocol and Blossom servers
251 lines (210 loc) • 7.48 kB
text/typescript
import puppeteer, { Browser, Page } from 'puppeteer';
import { ConfigManager } from '../utils/config';
import { logger } from '../utils/logger';
export interface SimpleSSRResult {
html: string;
contentType: string;
status: number;
}
export class SimpleSSRHelper {
private config = ConfigManager.getInstance().getConfig();
/**
* Render a page using a fresh browser instance (simpler approach)
*/
async renderPage(
url: string,
originalContent: Buffer,
contentType: string
): Promise<SimpleSSRResult> {
const startTime = Date.now();
let browser: Browser | null = null;
let page: Page | null = null;
try {
// Check if SSR is enabled
if (!this.config.ssrEnabled) {
return {
html: originalContent.toString(),
contentType,
status: 200,
};
}
// Only render HTML files
if (!contentType.includes('text/html')) {
return {
html: originalContent.toString(),
contentType,
status: 200,
};
}
logger.debug(`SSR: Starting render for ${url}`);
// Launch fresh browser for each request (simpler but more reliable)
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
timeout: 15000,
});
page = await browser.newPage();
// Set viewport
await page.setViewport({
width: this.config.ssrViewportWidth,
height: this.config.ssrViewportHeight,
});
// Set a special user agent to identify SSR requests and prevent recursion
await page.setUserAgent('NostrSSRBot/1.0 (Internal SSR Request)');
// Bypass CSP to allow JavaScript execution
await page.setBypassCSP(true);
// Add console logging for debugging
page.on('console', (msg) => {
logger.debug(`SSR Browser [${msg.type()}]: ${msg.text()}`);
});
page.on('pageerror', (error) => {
logger.warn(`SSR Page error: ${error.message}`);
});
// Enable request interception to handle asset loading
await page.setRequestInterception(true);
page.on('request', (request) => {
const requestUrl = request.url();
logger.debug(`SSR: Request for ${requestUrl}`);
// Handle relative URLs by redirecting to the actual server
if (requestUrl.startsWith('/') && !requestUrl.startsWith('//')) {
const baseUrl = new URL(url);
const fullUrl = `${baseUrl.protocol}//${baseUrl.host}${requestUrl}`;
logger.debug(`SSR: Redirecting ${requestUrl} to ${fullUrl}`);
request.continue({ url: fullUrl });
return;
}
// Allow all other requests
request.continue();
});
page.on('response', (response) => {
const responseUrl = response.url();
if (responseUrl.includes('/assets/')) {
logger.debug(`SSR: Asset response ${response.status()} for ${responseUrl}`);
}
});
// Set timeout
page.setDefaultTimeout(this.config.ssrTimeoutMs);
// Instead of setContent, use goto to the actual URL (this works!)
logger.debug(`SSR: Navigating to ${url}`);
await page.goto(url, {
waitUntil: 'networkidle0', // Wait for all network requests to finish
timeout: 15000,
});
// Wait for any JavaScript to execute
logger.debug('SSR: Waiting for JavaScript execution...');
await new Promise((resolve) => setTimeout(resolve, 5000)); // Increased wait time
// Try to wait for root element to be populated
try {
await page.waitForFunction(
`() => {
const root = document.getElementById('root');
return root && root.innerHTML.trim().length > 50;
}`,
{ timeout: 8000 } // Increased timeout
);
logger.debug('SSR: Root element populated with content');
} catch (waitError) {
logger.warn('SSR: Root element not populated within timeout, checking current state...');
// Check what's actually in the root element
const rootContent = await page.evaluate(`
const root = document.getElementById('root');
return root ? root.innerHTML : 'ROOT_NOT_FOUND';
`);
logger.debug(`SSR: Current root content: "${rootContent}"`);
// Check for any errors or missing assets
const hasErrors = await page.evaluate(`
return window.onerror ? 'Has errors' : 'No errors detected';
`);
logger.debug(`SSR: Error status: ${hasErrors}`);
}
// Get the rendered HTML
const renderedHtml = await page.content();
// Add SSR meta tags
const enhancedHtml = this.addSSRMetaTags(renderedHtml, url);
const renderTime = Date.now() - startTime;
logger.info(`SSR rendered page in ${renderTime}ms for URL: ${url}`);
return {
html: enhancedHtml,
contentType: 'text/html; charset=utf-8',
status: 200,
};
} catch (error) {
const renderTime = Date.now() - startTime;
logger.warn(`SSR failed after ${renderTime}ms for URL: ${url}: ${error}`);
// Fallback to original content
return {
html: originalContent.toString(),
contentType,
status: 200,
};
} finally {
try {
if (page) await page.close();
if (browser) await browser.close();
} catch (closeError) {
logger.debug('SSR cleanup error (expected):', closeError);
}
}
}
/**
* Add basic SSR meta tags
*/
private addSSRMetaTags(html: string, url: string): string {
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}">
`;
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, userAgent?: string): boolean {
// Check if SSR is enabled
if (!this.config.ssrEnabled) {
return false;
}
// Skip SSR for internal SSR requests to prevent recursion
if (userAgent && userAgent.includes('NostrSSRBot')) {
return false;
}
// Only render HTML files
if (!contentType.includes('text/html')) {
return false;
}
// Don't render API endpoints or admin paths
if (path.startsWith('/api/') || path.startsWith('/admin/')) {
return false;
}
return true;
}
/**
* Close any open browser instances
*/
async close(): Promise<void> {
// This implementation uses fresh browser instances for each request
// so there's nothing persistent to close
logger.debug('SimpleSSRHelper: No persistent browser to close');
}
/**
* Get browser statistics
*/
async getBrowserStats(): Promise<{
isConnected: boolean;
pagesCount: number;
}> {
// Since we use fresh browser instances for each request,
// we don't have persistent browser connections to report on
return {
isConnected: false, // No persistent browser connection
pagesCount: 0, // No persistent pages
};
}
}