diff --git a/nodejs/.gitignore b/nodejs/.gitignore new file mode 100644 index 0000000..a0a7fe7 --- /dev/null +++ b/nodejs/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +*.html +.DS_Store +examples/*.html diff --git a/nodejs/.npmignore b/nodejs/.npmignore new file mode 100644 index 0000000..31b5499 --- /dev/null +++ b/nodejs/.npmignore @@ -0,0 +1,8 @@ +src/ +tsconfig.json +*.ts +!*.d.ts +examples/ +node_modules/ +*.log +.DS_Store diff --git a/nodejs/LICENSE b/nodejs/LICENSE new file mode 100644 index 0000000..8b201ea --- /dev/null +++ b/nodejs/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 Anthony Donlon + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/nodejs/README.md b/nodejs/README.md new file mode 100644 index 0000000..3d37467 --- /dev/null +++ b/nodejs/README.md @@ -0,0 +1,141 @@ +# Cloudflare Error Page Generator (Node.js/TypeScript) + +Carbon copy of the original Python version. + +## Installation + +```bash +npm install cloudflare-error-page +``` + +Or install from GitHub: + +```bash +npm install git+https://github.com/donlon/cloudflare-error-page.git#main:nodejs +``` + +## Quick Start + +```typescript +import { render } from 'cloudflare-error-page'; +import * as fs from 'fs'; + +const errorPage = render({ + browser_status: { status: 'ok' }, + cloudflare_status: { status: 'error', status_text: 'Error' }, + host_status: { status: 'ok', location: 'example.com' }, + error_source: 'cloudflare', + what_happened: '

There is an internal server error on Cloudflare\'s network.

', + what_can_i_do: '

Please try again in a few minutes.

', +}); + +fs.writeFileSync('error.html', errorPage); +``` + +## API Reference + +### `render(params: ErrorPageParams, allowHtml?: boolean): string` + +Generates an HTML error page based on the provided parameters. + +#### Parameters + +- `params`: An object containing error page configuration +- `allowHtml` (optional): Whether to allow HTML in `what_happened` and `what_can_i_do` fields. Default: `true` + +#### ErrorPageParams Interface + +```typescript +interface ErrorPageParams { + // Basic information + error_code?: number; // Default: 500 + title?: string; // Default: 'Internal server error' + html_title?: string; // Default: '{error_code}: {title}' + time?: string; // Auto-generated if not provided + ray_id?: string; // Auto-generated if not provided + client_ip?: string; // Default: '1.1.1.1' + + // Status for each component + browser_status?: StatusItem; + cloudflare_status?: StatusItem; + host_status?: StatusItem; + + // Error source indicator + error_source?: 'browser' | 'cloudflare' | 'host'; + + // Content sections + what_happened?: string; // HTML content + what_can_i_do?: string; // HTML content + + // Optional customization + more_information?: MoreInformation; + perf_sec_by?: PerfSecBy; + creator_info?: CreatorInfo; +} + +interface StatusItem { + status?: 'ok' | 'error'; + status_text?: string; // Default: 'Working' or 'Error' + status_text_color?: string; // CSS color + location?: string; + name?: string; +} +``` + +## Examples + +### Basic Error Page + +```typescript +import { render } from 'cloudflare-error-page'; + +const html = render({ + cloudflare_status: { status: 'error' }, + error_source: 'cloudflare', + what_happened: '

Something went wrong.

', + what_can_i_do: '

Try again later.

', +}); +``` + +### Express.js Integration + +```typescript +import express from 'express'; +import { render } from 'cloudflare-error-page'; + +const app = express(); + +app.use((err, req, res, next) => { + const errorPage = render({ + error_code: err.status || 500, + title: err.message || 'Internal server error', + cloudflare_status: { status: 'ok' }, + host_status: { + status: 'error', + location: req.hostname + }, + error_source: 'host', + what_happened: `

${err.message}

`, + what_can_i_do: '

Please try again or contact support.

', + }); + + res.status(err.status || 500).send(errorPage); +}); +``` + +## TypeScript Support + +This package includes full TypeScript type definitions. Import types as needed: + +```typescript +import { render, ErrorPageParams, StatusItem } from 'cloudflare-error-page'; +``` + +## License + +MIT + +## Related + +- [Python version](https://github.com/donlon/cloudflare-error-page) +- [Online Editor](https://virt.moe/cloudflare-error-page/editor/) diff --git a/nodejs/examples/example.js b/nodejs/examples/example.js new file mode 100644 index 0000000..ba428b1 --- /dev/null +++ b/nodejs/examples/example.js @@ -0,0 +1,32 @@ +import { render } from '../dist/index.js'; +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Generate an error page +const errorPage = render({ + browser_status: { + status: 'ok', + }, + cloudflare_status: { + status: 'error', + status_text: 'Error', + }, + host_status: { + status: 'ok', + location: 'example.com', + }, + error_source: 'cloudflare', + + what_happened: '

There is an internal server error on Cloudflare\'s network.

', + what_can_i_do: '

Please try again in a few minutes.

', +}); + +const outputPath = join(__dirname, 'error.html'); +fs.writeFileSync(outputPath, errorPage); + +console.log(`Error page generated: ${outputPath}`); +console.log('Open the file in your browser to view it.'); diff --git a/nodejs/examples/test-compatibility.js b/nodejs/examples/test-compatibility.js new file mode 100644 index 0000000..640c1d0 --- /dev/null +++ b/nodejs/examples/test-compatibility.js @@ -0,0 +1,32 @@ +import { render } from '../dist/index.js'; +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Test all JSON configs from oringinal examples directory +const testConfigs = [ + { file: '../../examples/default.json', output: 'test-default.html' }, + { file: '../../examples/working.json', output: 'test-working.html' }, + { file: '../../examples/catastrophic.json', output: 'test-catastrophic.html' } +]; + +testConfigs.forEach(({ file, output }) => { + try { + const configPath = join(__dirname, file); + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + + console.log(`Testing: ${file}`); + console.log(`Config keys: ${Object.keys(config).join(', ')}`); + + const html = render(config); + + const outputPath = join(__dirname, output); + fs.writeFileSync(outputPath, html); + } catch (error) { + console.error(`Something went wrong: ${file}`); + console.error(`Error: ${error.message}\n`); + } +}); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json new file mode 100644 index 0000000..6c3a013 --- /dev/null +++ b/nodejs/package-lock.json @@ -0,0 +1,142 @@ +{ + "name": "cloudflare-error-page", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cloudflare-error-page", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "ejs": "^3.1.10" + }, + "devDependencies": { + "@types/ejs": "^3.1.5", + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.26", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz", + "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/nodejs/package.json b/nodejs/package.json new file mode 100644 index 0000000..e3e9336 --- /dev/null +++ b/nodejs/package.json @@ -0,0 +1,45 @@ +{ + "name": "cloudflare-error-page", + "version": "0.0.1", + "description": "Cloudflare Error Page Generator", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "tsc", + "prepublishOnly": "pnpm run build", + "example": "node examples/example.js" + }, + "keywords": [ + "cloudflare", + "error-page", + "error", + "http", + "html", + "template" + ], + "author": "Anthony Donlon", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/donlon/cloudflare-error-page.git", + "directory": "nodejs" + }, + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "ejs": "^3.1.10" + }, + "devDependencies": { + "@types/ejs": "^3.1.5", + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + }, + "files": [ + "dist/**/*", + "templates/**/*", + "README.md", + "LICENSE" + ] +} diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts new file mode 100644 index 0000000..6e5565c --- /dev/null +++ b/nodejs/src/index.ts @@ -0,0 +1,123 @@ +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; +import * as ejs from "ejs"; +import * as crypto from "crypto"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export interface StatusItem { + status?: "ok" | "error"; + status_text?: string; + status_text_color?: string; + location?: string; + name?: string; +} + +export interface MoreInformation { + hidden?: boolean; + link?: string; + text?: string; + for?: string; +} + +export interface PerfSecBy { + link?: string; + text?: string; +} + +export interface CreatorInfo { + hidden?: boolean; + link?: string; + text?: string; +} + +export interface ErrorPageParams { + error_code?: number; + title?: string; + html_title?: string; + time?: string; + ray_id?: string; + client_ip?: string; + + browser_status?: StatusItem; + cloudflare_status?: StatusItem; + host_status?: StatusItem; + + error_source?: "browser" | "cloudflare" | "host"; + + what_happened?: string; + what_can_i_do?: string; + + more_information?: MoreInformation; + perf_sec_by?: PerfSecBy; + creator_info?: CreatorInfo; +} + +/** + * Fill default parameters if not provided + */ +function fillParams(params: ErrorPageParams): ErrorPageParams { + const filledParams = { ...params }; + + if (!filledParams.time) { + const now = new Date(); + filledParams.time = + now.toISOString().replace("T", " ").substring(0, 19) + " UTC"; + } + + if (!filledParams.ray_id) { + filledParams.ray_id = crypto.randomBytes(8).toString("hex"); + } + + return filledParams; +} + +/** + * Escape HTML special characters + */ +function escapeHtml(text: string): string { + const htmlEscapeMap: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return text.replace(/[&<>"']/g, (char) => htmlEscapeMap[char] || char); +} + +/** + * Render a customized Cloudflare error page + * @param params - The parameters for the error page + * @param allowHtml - Whether to allow HTML in what_happened and what_can_i_do fields (default: true) + * @returns The rendered HTML string + */ +export function render( + params: ErrorPageParams, + allowHtml: boolean = true +): string { + let processedParams = fillParams(params); + + if (!allowHtml) { + processedParams = { ...processedParams }; + if (processedParams.what_happened) { + processedParams.what_happened = escapeHtml(processedParams.what_happened); + } + if (processedParams.what_can_i_do) { + processedParams.what_can_i_do = escapeHtml(processedParams.what_can_i_do); + } + } + + // Load EJS template + const templatePath = path.join(__dirname, "..", "templates", "error.ejs"); + + const template = fs.readFileSync(templatePath, "utf-8"); + + const rendered = ejs.render(template, { params: processedParams }); + + return rendered; +} + +export default render; diff --git a/nodejs/templates/error.ejs b/nodejs/templates/error.ejs new file mode 100644 index 0000000..95aee8b --- /dev/null +++ b/nodejs/templates/error.ejs @@ -0,0 +1,110 @@ +{# Note: This is generated with scripts/inline_resources.py. Please do not edit this file manually. #} + + + + + + +<% const error_code = params.error_code || 500; %> +<% const title = params.title || 'Internal server error'; %> +<% const html_title = params.html_title || (error_code + ': ' + title); %> +<%= html_title %> + + + + + + + + +
+
+
+

+ <%= title %> + Error code <%= error_code %> +

+ <% const more_info = params.more_information || {}; %> + <% if (!more_info.hidden) { %> +
+ Visit <%= more_info.text || 'cloudflare.com' %> for <%= more_info.for || 'more information' %>. +
+ <% } %> +
<%= params.time %>
+
+
+
+
+ <% + const items = [ + { id: 'browser', icon: 'browser', default_location: 'You', default_name: 'Browser' }, + { id: 'cloudflare', icon: 'cloud', default_location: 'San Francisco', default_name: 'Cloudflare' }, + { id: 'host', icon: 'server', default_location: 'Website', default_name: 'Host' } + ]; + items.forEach(({ id, icon, default_location, default_name }) => { + const item = params[id + '_status'] || {}; + const status = item.status || 'ok'; + let text_color; + if (item.status_text_color) { + text_color = item.status_text_color; + } else if (status === 'ok') { + text_color = '#9bca3e'; + } else if (status === 'error') { + text_color = '#bd2426'; + } + const status_text = item.status_text || (status === 'ok' ? 'Working' : 'Error'); + const is_error_source = params.error_source === id; + %> +
+
+ + +
+ <%= item.location || default_location %> +

><%= item.name || default_name %>

+ <%= status_text %> +
+ <% }); %> +
+
+
+ +
+
+
+

What happened?

+ <%- params.what_happened || '

There is an internal server error on Cloudflare\'s network.

' %> +
+
+

What can I do?

+ <%- params.what_can_i_do || '

Please try again in a few minutes.

' %> +
+
+
+ + +
+
+ + + diff --git a/nodejs/tsconfig.json b/nodejs/tsconfig.json new file mode 100644 index 0000000..24d9f1d --- /dev/null +++ b/nodejs/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "examples"] +}