nostr-deploy-server
Version:
Node.js server for hosting static websites under npub subdomains using Nostr protocol and Blossom servers
462 lines • 20 kB
JavaScript
"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