UNPKG

nostr-deploy-server

Version:

Node.js server for hosting static websites under npub subdomains using Nostr protocol and Blossom servers

462 lines 20 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SSRHelper = void 0; const mimeTypes = __importStar(require("mime-types")); const puppeteer_1 = __importDefault(require("puppeteer")); const config_1 = require("../utils/config"); const logger_1 = require("../utils/logger"); class SSRHelper { constructor() { this.browser = null; this.config = config_1.ConfigManager.getInstance().getConfig(); this.activePageCount = 0; this.initializeBrowser(); } async initializeBrowser() { try { logger_1.logger.info('Initializing Puppeteer browser...'); this.browser = await puppeteer_1.default.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_1.logger.info(`Puppeteer browser initialized successfully, connected: ${this.browser.isConnected()}`); // Handle browser disconnect this.browser.on('disconnected', () => { logger_1.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_1.logger.error('Failed to initialize Puppeteer browser:', error); throw error; } } async ensureBrowser() { if (!this.browser || !this.browser.isConnected()) { await this.initializeBrowser(); } return this.browser; } /** * Render a static site using Puppeteer */ async renderPage(url, originalContent, contentType, options = {}) { const startTime = Date.now(); let page = null; try { // Check if we've hit the concurrent page limit if (this.activePageCount >= this.config.ssrMaxConcurrentPages) { logger_1.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_1.logger.debug(`SSR: Active pages: ${this.activePageCount}/${this.config.ssrMaxConcurrentPages}`); const browser = await this.ensureBrowser(); logger_1.logger.debug(`SSR: Browser connected: ${browser.isConnected()}`); page = await browser.newPage(); logger_1.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_1.logger.debug(`Browser console [${msg.type()}]: ${msg.text()}`); }); // Handle page errors page.on('pageerror', (error) => { logger_1.logger.warn(`Browser page error: ${error.message}`); }); // Handle console errors page.on('console', (msg) => { if (msg.type() === 'error') { logger_1.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_1.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_1.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_1.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_1.logger.debug('Root element populated, content rendered'); } catch (waitError) { logger_1.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_1.logger.debug('App container found with alternative selectors'); } catch (altError) { logger_1.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_1.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_1.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_1.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_1.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_1.logger.debug('Page close error (expected if browser disconnected):', errorMessage); } } } } /** * Enhance HTML with SSR-specific optimizations */ enhanceHtmlForSSR(html, url) { // 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, path) { // 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() { if (this.browser) { try { await this.browser.close(); logger_1.logger.info('Puppeteer browser closed successfully'); } catch (error) { logger_1.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 */ fixMimeTypeForSSR(serverContentType, path) { 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 = { '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_1.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 */ isMimeTypeCorrectForExtension(mimeType, extension) { const mimeTypeMap = { 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 */ getContentTypeFromPath(path) { 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'; } } } exports.SSRHelper = SSRHelper; //# sourceMappingURL=ssr.js.map