UNPKG

rasengan

Version:

The modern React Framework

217 lines (216 loc) 8.24 kB
import { EventEmitter } from 'node:events'; import { Readable } from 'node:stream'; import fs from 'node:fs'; import path from 'node:path'; import zlib from 'node:zlib'; import chalk from 'chalk'; import { createReadableStreamFromReadable, writeReadableStreamToWritable, } from './stream.js'; /** * This function is used to create a Rasengan request from an Express request. * Reference: https://github.com/remix-run/react-router/blob/main/packages/react-router-express/server.ts#L86 */ export default function createRasenganRequest(req, res) { // req.hostname doesn't include port information so grab that from // `X-Forwarded-Host` or `Host` let [, hostnamePort] = req.get('X-Forwarded-Host')?.split(':') ?? []; let [, hostPort] = req.get('host')?.split(':') ?? []; let port = hostnamePort || hostPort; // Use req.hostname here as it respects the "trust proxy" setting let resolvedHost = `${req.hostname}${port ? `:${port}` : ''}`; // Use `req.originalUrl` so Remix is aware of the full path let url = new URL(`${req.protocol}://${resolvedHost}${req.originalUrl}`); // Abort action/loaders once we can no longer write a response let controller = new AbortController(); // Abort action/loaders once we can no longer write a response iff we have // not yet sent a response (i.e., `close` without `finish`) // `finish` -> done rendering the response // `close` -> response can no longer be written to res.on('finish', () => (controller = null)); res.on('close', () => controller?.abort()); let init = { method: req.method, headers: createRasenganHeaders(req.headers), signal: controller.signal, }; if (req.method !== 'GET' && req.method !== 'HEAD') { init.body = createReadableStreamFromReadable(req); init.duplex = 'half'; } return new Request(url.href, init); } /** * This function is used to send a Rasengan response to an Express response. * @param res * @param nodeResponse */ export async function sendRasenganResponse(res, nodeResponse) { try { // Set status and status message res.statusMessage = nodeResponse.statusText; res.status(nodeResponse.status); // Set headers for (let [key, value] of nodeResponse.headers.entries()) { res.append(key, value); } // Handle Server-Sent Events (SSE) if (nodeResponse.headers.get('Content-Type')?.match(/text\/event-stream/i)) { res.flushHeaders(); } // Write the response body if available if (nodeResponse.body) { await writeReadableStreamToWritable(nodeResponse.body, res); } else { res.end(); } } catch (error) { // Log the error (optional) console.error('Error while sending response:', error); // Send a 500 Internal Server Error response with error details res.status(500).send({ message: 'An error occurred while processing the response.', error: error instanceof Error ? error.message : String(error), }); } } /** * This function is used to create a Rasengan headers from Express request headers. * @param requestHeaders * @returns */ export function createRasenganHeaders(requestHeaders) { let headers = new Headers(); for (let [key, values] of Object.entries(requestHeaders)) { if (values) { if (Array.isArray(values)) { for (const value of values) { headers.append(key, value); } } else { headers.set(key, values); } } } return headers; } /** * Creates a fake Express-like request and response for static prerendering. * This mimics the shape expected by `createRasenganRequest(req, res)`. */ export function createFakeRasenganRequest(pathname, options) { const { host = 'localhost:5320', protocol = 'http', method = 'GET', headers = {}, body, } = options ?? {}; const req = { originalUrl: pathname.startsWith('/') ? pathname : `/${pathname}`, method, protocol, hostname: host.split(':')[0], headers, get(headerName) { const key = headerName.toLowerCase(); if (key === 'x-forwarded-host') return host; if (key === 'host') return host; return headers[key]; }, }; if (body) { const readable = Readable.from([body]); Object.assign(req, readable); } const res = new EventEmitter(); res.on = res.addListener; res.write = () => true; res.end = () => { res.emit('finish'); // DO NOT emit "close" }; return { req, res }; } /** * Log formatted and grouped HTML build output, similar to Vite/Next.js style. * @param files List of absolute or relative HTML file paths */ export async function logRenderedPagesGrouped(files) { const rows = []; // Collect all files data for (const file of files) { try { const content = fs.readFileSync(file); const size = content.length; const gzipSize = zlib.gzipSync(content).length; const relative = path.relative(process.cwd(), file); rows.push({ file: relative, size, gzip: gzipSize }); } catch (e) { console.error(`❌ Failed to read file: ${file}`, e); } } // Sort by folder then by size rows.sort((a, b) => a.file.localeCompare(b.file)); // Group by first-level directory (after dist/) const groups = new Map(); for (const row of rows) { const parts = row.file.split(path.sep); const baseFolder = parts.length > 2 && parts[1] !== 'assets' ? parts[1] : 'root'; if (!groups.has(baseFolder)) groups.set(baseFolder, []); groups.get(baseFolder).push(row); } console.log(); // Compute max filename length for nice column alignment const longestPath = Math.max(...rows.map((r) => r.file.length)); for (const [folder, group] of groups) { console.log(chalk.bold.blueBright(`📁 ${folder === 'root' ? '/' : '/' + folder}`)); for (const { file, size, gzip } of group) { const paddedFile = chalk.white(file.padEnd(longestPath + 4, ' ')); const sizeStr = chalk.yellow(formatSize(size)); const gzipStr = chalk.gray(formatSize(gzip)); console.log(` ${paddedFile}${sizeStr} │ gzip: ${gzipStr}`); } console.log(); } console.log(chalk.blueBright('🎉 Static generation completed.\n')); } /** * Convert bytes into human-readable units. */ function formatSize(bytes) { if (bytes < 1024) return `${bytes.toFixed(2)} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} kB`; return `${(bytes / 1024 / 1024).toFixed(2)} MB`; } /** * Match a route pattern like "/blog/**" or "/profile" * to a list of available page paths. */ export function filterRoutesForPrerender(routes, availablePages) { // Convert route patterns to RegExp const routeRegexList = routes.map((route) => { // Escape regex special chars except for * const escaped = route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Replace wildcard `**` with `.*` (match anything after) const regexStr = '^' + escaped.replace(/\\\*\\\*/g, '.*') + '$'; return new RegExp(regexStr); }); // Filter pages that match at least one route pattern const matchedPages = availablePages.filter((page) => routeRegexList.some((regex) => regex.test(page))); // Remove duplicates, add /* for 404 page and sort return [...new Set([...matchedPages, '/*'])].sort(); } // Convert seconds to minutes and seconds export function convertSecondsToMinutes(seconds) { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (minutes === 0) { if (remainingSeconds < 1) { return `${remainingSeconds * 1000}ms`; } return `${remainingSeconds.toFixed(2)}s`; } return `${minutes}m ${remainingSeconds.toFixed(2)}s`; }