From b673bae4d3817fb3fd7d881b0b5fdbd29de40b03 Mon Sep 17 00:00:00 2001 From: Laszlo Takacs Date: Fri, 17 Apr 2026 09:47:59 +0200 Subject: [PATCH 1/2] Add prerender-hapi plugin for @hapi/hapi v21+ --- prerender-hapi/.env.example | 2 + prerender-hapi/.gitignore | 2 + prerender-hapi/README.md | 91 ++++++++ prerender-hapi/index.js | 100 ++++++++ prerender-hapi/package-lock.json | 377 ++++++++++++++++++++++++++++++ prerender-hapi/package.json | 32 +++ prerender-hapi/test/smoke.test.js | 71 ++++++ 7 files changed, 675 insertions(+) create mode 100644 prerender-hapi/.env.example create mode 100644 prerender-hapi/.gitignore create mode 100644 prerender-hapi/README.md create mode 100644 prerender-hapi/index.js create mode 100644 prerender-hapi/package-lock.json create mode 100644 prerender-hapi/package.json create mode 100644 prerender-hapi/test/smoke.test.js diff --git a/prerender-hapi/.env.example b/prerender-hapi/.env.example new file mode 100644 index 0000000..07bced2 --- /dev/null +++ b/prerender-hapi/.env.example @@ -0,0 +1,2 @@ +PRERENDER_TOKEN= +PRERENDER_SERVICE_URL= diff --git a/prerender-hapi/.gitignore b/prerender-hapi/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/prerender-hapi/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/prerender-hapi/README.md b/prerender-hapi/README.md new file mode 100644 index 0000000..5cdf125 --- /dev/null +++ b/prerender-hapi/README.md @@ -0,0 +1,91 @@ +# prerender-hapi + +Hapi plugin for [Prerender.io](https://prerender.io). Intercepts requests from bots and crawlers and serves prerendered HTML, so your JavaScript-rendered app is fully indexable by search engines and social media scrapers. + +Compatible with **@hapi/hapi v21+** and **Node.js 18+**. + +## Installation + +```bash +npm install prerender-hapi +``` + +## Usage + +```javascript +const Hapi = require('@hapi/hapi'); + +const server = Hapi.server({ host: 'localhost', port: 3000 }); + +await server.register({ + plugin: require('prerender-hapi'), + options: { + token: 'YOUR_PRERENDER_TOKEN' + } +}); +``` + +The plugin registers an `onRequest` extension that transparently proxies bot requests to Prerender.io and returns the prerendered HTML. Regular browser requests are unaffected. + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `token` | `string` | `process.env.PRERENDER_TOKEN` | Your Prerender.io token | +| `serviceUrl` | `string` | `process.env.PRERENDER_SERVICE_URL` or `https://service.prerender.io/` | Prerender service URL (use this for self-hosted Prerender) | +| `protocol` | `string` | `null` | Force a protocol (`http` or `https`). Defaults to the server's protocol | +| `beforeRender` | `async function(request)` | `async () => null` | Called before each prerender request. Return a cached response object `{ status, headers, body }` to skip the Prerender.io call | +| `afterRender` | `function(request, response)` | `() => {}` | Called after a successful prerender. Use this to cache the response | + +## Environment variables + +```bash +PRERENDER_TOKEN=your_token_here +PRERENDER_SERVICE_URL=https://service.prerender.io/ # optional +``` + +## Self-hosted Prerender + +```javascript +await server.register({ + plugin: require('prerender-hapi'), + options: { + serviceUrl: 'http://your-prerender-server:3000' + } +}); +``` + +## Caching example + +```javascript +const cache = new Map(); + +await server.register({ + plugin: require('prerender-hapi'), + options: { + token: 'YOUR_PRERENDER_TOKEN', + beforeRender: async (request) => { + return cache.get(request.url.href) || null; + }, + afterRender: (request, response) => { + cache.set(request.url.href, response); + } + } +}); +``` + +## How it works + +Requests are prerendered when **all** of the following are true: + +- The HTTP method is `GET` +- The `User-Agent` matches a known bot/crawler (Googlebot, Bingbot, Twitterbot, GPTBot, ClaudeBot, etc.) + — OR the URL contains `_escaped_fragment_` + — OR the `X-Bufferbot` header is present +- The URL does not end with a static asset extension (`.js`, `.css`, `.png`, etc.) + +Everything else passes through to your normal route handlers. + +## License + +MIT diff --git a/prerender-hapi/index.js b/prerender-hapi/index.js new file mode 100644 index 0000000..acc93b8 --- /dev/null +++ b/prerender-hapi/index.js @@ -0,0 +1,100 @@ +'use strict'; + +const internals = {}; + +internals.crawlerUserAgents = [ + 'googlebot', 'yahoo', 'bingbot', 'baiduspider', 'facebot', + 'facebookexternalhit', 'twitterbot', 'rogerbot', 'linkedinbot', + 'embedly', 'quora link preview', 'showyoubot', 'outbrain', + 'pinterest', 'slackbot', 'developers.google.com/+/web/snippet', + 'w3c_validator', 'perplexity', 'oai-searchbot', 'chatgpt-user', + 'gptbot', 'claudebot', 'amazonbot' +]; + +internals.extensionsToIgnore = [ + '.js', '.css', '.xml', '.less', '.png', '.jpg', '.jpeg', '.gif', + '.pdf', '.doc', '.txt', '.ico', '.rss', '.zip', '.mp3', '.rar', + '.exe', '.wmv', '.avi', '.ppt', '.mpg', '.mpeg', '.tif', '.wav', + '.mov', '.psd', '.ai', '.xls', '.mp4', '.m4a', '.swf', '.dat', + '.dmg', '.iso', '.flv', '.m4v', '.torrent', '.ttf', '.woff', '.svg' +]; + +internals.defaults = { + serviceUrl: process.env.PRERENDER_SERVICE_URL || 'https://service.prerender.io/', + token: process.env.PRERENDER_TOKEN || null, + protocol: null, + beforeRender: async () => null, + afterRender: () => {} +}; + +function isBot(userAgent) { + const ua = userAgent.toLowerCase(); + return internals.crawlerUserAgents.some((bot) => ua.includes(bot)); +} + +function isStaticAsset(pathname) { + return internals.extensionsToIgnore.some((ext) => pathname.endsWith(ext)); +} + +function shouldPrerender(request) { + const userAgent = request.headers['user-agent']; + if (!userAgent || request.method !== 'get') return false; + + const { pathname, searchParams } = request.url; + if (isStaticAsset(pathname)) return false; + + return searchParams.has('_escaped_fragment_') + || isBot(userAgent) + || !!request.headers['x-bufferbot']; +} + +function buildApiUrl(request, settings) { + const protocol = settings.protocol || request.server.info.protocol; + const base = settings.serviceUrl.endsWith('/') + ? settings.serviceUrl + : settings.serviceUrl + '/'; + const { pathname, search } = request.url; + return `${base}${protocol}://${request.headers.host}${pathname}${search}`; +} + +async function fetchPrerendered(apiUrl, request, settings) { + const headers = { 'User-Agent': request.headers['user-agent'] }; + if (settings.token) { + headers['X-Prerender-Token'] = settings.token; + } + const response = await fetch(apiUrl, { headers, redirect: 'manual' }); + const body = await response.text(); + return { status: response.status, headers: response.headers, body }; +} + +function buildResponse(h, prerendered) { + const response = h.response(prerendered.body).code(prerendered.status).takeover(); + for (const [key, value] of prerendered.headers.entries()) { + response.header(key, value); + } + return response; +} + +exports.plugin = { + pkg: require('./package.json'), + async register(server, options) { + const settings = { ...internals.defaults, ...options }; + + server.ext('onRequest', async (request, h) => { + if (!shouldPrerender(request)) return h.continue; + + const cached = await settings.beforeRender(request); + if (cached) return buildResponse(h, cached); + + try { + const apiUrl = buildApiUrl(request, settings); + const prerendered = await fetchPrerendered(apiUrl, request, settings); + settings.afterRender(request, prerendered); + return buildResponse(h, prerendered); + } catch (err) { + console.error('Prerender error, falling back:', err.message); + return h.continue; + } + }); + } +}; diff --git a/prerender-hapi/package-lock.json b/prerender-hapi/package-lock.json new file mode 100644 index 0000000..f8b2a93 --- /dev/null +++ b/prerender-hapi/package-lock.json @@ -0,0 +1,377 @@ +{ + "name": "prerender-hapi", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "prerender-hapi", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@hapi/hapi": "^21.4.8" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@hapi/hapi": ">=21.0.0" + } + }, + "node_modules/@hapi/accept": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@hapi/accept/-/accept-6.0.3.tgz", + "integrity": "sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/ammo": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/ammo/-/ammo-6.0.1.tgz", + "integrity": "sha512-pmL+nPod4g58kXrMcsGLp05O2jF4P2Q3GiL8qYV7nKYEh3cGf+rV4P5Jyi2Uq0agGhVU63GtaSAfBEZOlrJn9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/b64": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz", + "integrity": "sha512-ZvjX4JQReUmBheeCq+S9YavcnMMHWqx3S0jHNXWIM1kQDxB9cyfSycpVvjfrKcIS8Mh5N3hmu/YKo4Iag9g2Kw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bounce": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/bounce/-/bounce-3.0.2.tgz", + "integrity": "sha512-d0XmlTi3H9HFDHhQLjg4F4auL1EY3Wqj7j7/hGDhFFe6xAbnm3qiGrXeT93zZnPH8gH+SKAFYiRzu26xkXcH3g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/call": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@hapi/call/-/call-9.0.1.tgz", + "integrity": "sha512-uPojQRqEL1GRZR4xXPqcLMujQGaEpyVPRyBlD8Pp5rqgIwLhtveF9PkixiKru2THXvuN8mUrLeet5fqxKAAMGg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/catbox": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@hapi/catbox/-/catbox-12.1.1.tgz", + "integrity": "sha512-hDqYB1J+R0HtZg4iPH3LEnldoaBsar6bYp0EonBmNQ9t5CO+1CqgCul2ZtFveW1ReA5SQuze9GPSU7/aecERhw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/podium": "^5.0.0", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/catbox-memory": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/catbox-memory/-/catbox-memory-6.0.2.tgz", + "integrity": "sha512-H1l4ugoFW/ZRkqeFrIo8p1rWN0PA4MDTfu4JmcoNDvnY975o29mqoZblqFTotxNHlEkMPpIiIBJTV+Mbi+aF0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/content": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/content/-/content-6.0.1.tgz", + "integrity": "sha512-lQ2vOoFMNYxwKVnKf+3Pi3PfoviM4EJYlT9JbrBPfEc0xKMiVDqqXF8UTE1S1oKhHQliWSP5t6zTKNlmaXBGcQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.0" + } + }, + "node_modules/@hapi/cryptiles": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-6.0.3.tgz", + "integrity": "sha512-r6VKalpbMHz4ci3gFjFysBmhwCg70RpYZy6OkjEpdXzAYnYFX5XsW7n4YMJvuIYpnMwLxGUjK/cBhA7X3JDvXw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/file": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/file/-/file-3.0.0.tgz", + "integrity": "sha512-w+lKW+yRrLhJu620jT3y+5g2mHqnKfepreykvdOcl9/6up8GrQQn+l3FRTsjHTKbkbfQFkuksHpdv2EcpKcJ4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hapi": { + "version": "21.4.8", + "resolved": "https://registry.npmjs.org/@hapi/hapi/-/hapi-21.4.8.tgz", + "integrity": "sha512-l93IrEG4iQyM+yKdngWmkPtajkJGM81yfinmSFmiaNHG+r1fgsWaewwcE1hhsFnqPrVZpU8Y3PiVJMb6uT+01Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/accept": "^6.0.3", + "@hapi/ammo": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/bounce": "^3.0.2", + "@hapi/call": "^9.0.1", + "@hapi/catbox": "^12.1.1", + "@hapi/catbox-memory": "^6.0.2", + "@hapi/heavy": "^8.0.1", + "@hapi/hoek": "^11.0.7", + "@hapi/mimos": "^7.0.1", + "@hapi/podium": "^5.0.2", + "@hapi/shot": "^6.0.2", + "@hapi/somever": "^4.1.1", + "@hapi/statehood": "^8.2.1", + "@hapi/subtext": "^8.1.2", + "@hapi/teamwork": "^6.0.1", + "@hapi/topo": "^6.0.2", + "@hapi/validate": "^2.0.1" + }, + "engines": { + "node": ">=14.15.0" + } + }, + "node_modules/@hapi/heavy": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@hapi/heavy/-/heavy-8.0.1.tgz", + "integrity": "sha512-gBD/NANosNCOp6RsYTsjo2vhr5eYA3BEuogk6cxY0QdhllkkTaJFYtTXv46xd6qhBVMbMMqcSdtqey+UQU3//w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/iron": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@hapi/iron/-/iron-7.0.1.tgz", + "integrity": "sha512-tEZnrOujKpS6jLKliyWBl3A9PaE+ppuL/+gkbyPPDb/l2KSKQyH4lhMkVb+sBhwN+qaxxlig01JRqB8dk/mPxQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/b64": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/cryptiles": "^6.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/mimos": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@hapi/mimos/-/mimos-7.0.1.tgz", + "integrity": "sha512-b79V+BrG0gJ9zcRx1VGcCI6r6GEzzZUgiGEJVoq5gwzuB2Ig9Cax8dUuBauQCFKvl2YWSWyOc8mZ8HDaJOtkew==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "mime-db": "^1.52.0" + } + }, + "node_modules/@hapi/nigel": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hapi/nigel/-/nigel-5.0.1.tgz", + "integrity": "sha512-uv3dtYuB4IsNaha+tigWmN8mQw/O9Qzl5U26Gm4ZcJVtDdB1AVJOwX3X5wOX+A07qzpEZnOMBAm8jjSqGsU6Nw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/vise": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/pez": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@hapi/pez/-/pez-6.1.1.tgz", + "integrity": "sha512-yg2OS1tC0S1sHXvhUtWsfRn6lrKl9jKtRhZ+EI0woOW/gqX5vM2PZ1459ypCvCYDRLJ9nIyueeEH5MJV1ZDqIg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/b64": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/content": "^6.0.1", + "@hapi/hoek": "^11.0.7", + "@hapi/nigel": "^5.0.1" + } + }, + "node_modules/@hapi/podium": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@hapi/podium/-/podium-5.0.2.tgz", + "integrity": "sha512-T7gf2JYHQQfEfewTQFbsaXoZxSvuXO/QBIGljucUQ/lmPnTTNAepoIKOakWNVWvo2fMEDjycu77r8k6dhreqHA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/teamwork": "^6.0.0", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/shot": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/shot/-/shot-6.0.2.tgz", + "integrity": "sha512-WKK1ShfJTrL1oXC0skoIZQYzvLsyMDEF8lfcWuQBjpjCN29qivr9U36ld1z0nt6edvzv28etNMOqUF4klnHryw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/somever": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@hapi/somever/-/somever-4.1.1.tgz", + "integrity": "sha512-lt3QQiDDOVRatS0ionFDNrDIv4eXz58IibQaZQDOg4DqqdNme8oa0iPWcE0+hkq/KTeBCPtEOjDOBKBKwDumVg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/bounce": "^3.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/statehood": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@hapi/statehood/-/statehood-8.2.1.tgz", + "integrity": "sha512-xf72TG/QINW26jUu+uL5H+crE1o8GplIgfPWwPZhnAGJzetIVAQEQYvzq+C0aEVHg5/lMMtQ+L9UryuSa5Yjkg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bounce": "^3.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/cryptiles": "^6.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/iron": "^7.0.1", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/subtext": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@hapi/subtext/-/subtext-8.1.2.tgz", + "integrity": "sha512-2x71YJHmFpCjhIhfiNZdKp63nh3xRPp7RrwH7JoO9R4Sd0DRzzRU/VfX2fMmUR7jcoS5qNET1WyGIaqKpMu/ng==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/content": "^6.0.1", + "@hapi/file": "^3.0.0", + "@hapi/hoek": "^11.0.7", + "@hapi/pez": "^6.1.1", + "@hapi/wreck": "^18.1.0" + } + }, + "node_modules/@hapi/teamwork": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/teamwork/-/teamwork-6.0.1.tgz", + "integrity": "sha512-52OXRslUfYwXAOG8k58f2h2ngXYQGP0x5RPOo+eWA/FtyLgHjGMrE3+e9LSXP/0q2YfHAK5wj9aA9DTy1K+kyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/validate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/validate/-/validate-2.0.1.tgz", + "integrity": "sha512-NZmXRnrSLK8MQ9y/CMqE9WSspgB9xA41/LlYR0k967aSZebWr4yNrpxIbov12ICwKy4APSlWXZga9jN5p6puPA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/topo": "^6.0.1" + } + }, + "node_modules/@hapi/vise": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hapi/vise/-/vise-5.0.1.tgz", + "integrity": "sha512-XZYWzzRtINQLedPYlIkSkUr7m5Ddwlu99V9elh8CSygXstfv3UnWIXT0QD+wmR0VAG34d2Vx3olqcEhRRoTu9A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/wreck": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.0.tgz", + "integrity": "sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + } + } +} diff --git a/prerender-hapi/package.json b/prerender-hapi/package.json new file mode 100644 index 0000000..67b2827 --- /dev/null +++ b/prerender-hapi/package.json @@ -0,0 +1,32 @@ +{ + "name": "prerender-hapi", + "version": "1.0.0", + "description": "Hapi plugin for prerendering JavaScript-rendered pages for SEO via Prerender.io", + "author": "Prerender.io", + "license": "MIT", + "main": "index.js", + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "hapi", + "prerender", + "prerender.io", + "seo", + "ssr" + ], + "repository": { + "type": "git", + "url": "git://github.com/prerender/community-integrations", + "directory": "prerender-hapi" + }, + "peerDependencies": { + "@hapi/hapi": ">=21.0.0" + }, + "devDependencies": { + "@hapi/hapi": "^21.4.8" + }, + "scripts": { + "test": "node --test" + } +} diff --git a/prerender-hapi/test/smoke.test.js b/prerender-hapi/test/smoke.test.js new file mode 100644 index 0000000..6e0f40e --- /dev/null +++ b/prerender-hapi/test/smoke.test.js @@ -0,0 +1,71 @@ +'use strict'; + +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const Hapi = require('@hapi/hapi'); + +const plugin = require('../index'); + +const BOT_UA = 'Mozilla/5.0 (compatible; Googlebot/2.1)'; +const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'; +const PRERENDERED_HTML = 'prerendered'; + +function mockFetch(status = 200, body = PRERENDERED_HTML) { + global.fetch = async () => ({ + status, + headers: new Headers({ 'content-type': 'text/html' }), + text: async () => body + }); +} + +async function createServer(options = {}) { + const server = Hapi.server({ host: 'localhost', port: 3000 }); + await server.register({ plugin, options }); + server.route({ method: 'GET', path: '/', handler: () => 'original' }); + server.route({ method: 'GET', path: '/style.css', handler: () => 'body{}' }); + await server.initialize(); + return server; +} + +test('plugin registers without error', async () => { + const server = Hapi.server(); + await assert.doesNotReject(() => server.register(plugin)); +}); + +test('normal browser request passes through to route handler', async () => { + const server = await createServer({ token: 'test-token' }); + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': BROWSER_UA } }); + assert.equal(res.statusCode, 200); + assert.equal(res.payload, 'original'); +}); + +test('bot request receives prerendered response', async () => { + mockFetch(200, PRERENDERED_HTML); + const server = await createServer({ token: 'test-token' }); + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': BOT_UA } }); + assert.equal(res.statusCode, 200); + assert.equal(res.payload, PRERENDERED_HTML); +}); + +test('static asset with bot UA is not prerendered', async () => { + const server = await createServer(); + const res = await server.inject({ method: 'GET', url: '/style.css', headers: { 'user-agent': BOT_UA } }); + assert.equal(res.statusCode, 200); + assert.equal(res.payload, 'body{}'); +}); + +test('_escaped_fragment_ query triggers prerender for any user agent', async () => { + mockFetch(200, PRERENDERED_HTML); + const server = await createServer({ token: 'test-token' }); + const res = await server.inject({ method: 'GET', url: '/?_escaped_fragment_=', headers: { 'user-agent': BROWSER_UA } }); + assert.equal(res.statusCode, 200); + assert.equal(res.payload, PRERENDERED_HTML); +}); + +test('prerender fetch error falls back to normal response', async () => { + global.fetch = async () => { throw new Error('network error'); }; + const server = await createServer({ token: 'test-token' }); + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': BOT_UA } }); + assert.equal(res.statusCode, 200); + assert.equal(res.payload, 'original'); +}); From 729121f9fa135b51c60b564377827a1d23a28351 Mon Sep 17 00:00:00 2001 From: Laszlo Takacs Date: Fri, 17 Apr 2026 10:11:29 +0200 Subject: [PATCH 2/2] Fix repository URL to point to integrations repo --- prerender-hapi/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prerender-hapi/package.json b/prerender-hapi/package.json index 67b2827..1ca3047 100644 --- a/prerender-hapi/package.json +++ b/prerender-hapi/package.json @@ -17,7 +17,7 @@ ], "repository": { "type": "git", - "url": "git://github.com/prerender/community-integrations", + "url": "git://github.com/prerender/integrations", "directory": "prerender-hapi" }, "peerDependencies": {