Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions prerender-hapi/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
PRERENDER_TOKEN=
PRERENDER_SERVICE_URL=
2 changes: 2 additions & 0 deletions prerender-hapi/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
.env
91 changes: 91 additions & 0 deletions prerender-hapi/README.md
Original file line number Diff line number Diff line change
@@ -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
100 changes: 100 additions & 0 deletions prerender-hapi/index.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
}
};
Loading