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 .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
PRERENDER_TOKEN=
PRERENDER_SERVICE_URL=
20 changes: 20 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Test

on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
.env
91 changes: 91 additions & 0 deletions 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` | `async function(request, response)` | `async () => {}` | Called after a successful prerender. Use this to cache the response. Can be sync or async |

## 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
123 changes: 123 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
'use strict';

/**
* @typedef {{ status: number, headers: Headers, body: string }} PrerenderResponse
*/

/**
* @typedef {Object} PrerenderOptions
* @property {string} [token]
* @property {string} [serviceUrl]
* @property {string|null} [protocol]
* @property {(request: import('@hapi/hapi').Request) => Promise<PrerenderResponse|null>} [beforeRender]
* @property {(request: import('@hapi/hapi').Request, response: PrerenderResponse) => void|Promise<void>} [afterRender]
*/

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 = {
Comment thread
giovanniRodighiero marked this conversation as resolved.
serviceUrl: process.env.PRERENDER_SERVICE_URL || 'https://service.prerender.io/',
token: process.env.PRERENDER_TOKEN || null,
protocol: null,
beforeRender: async () => null,
afterRender: async () => {}
};

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}`;
}

/**
* @returns {Promise<PrerenderResponse>}
*/
async function fetchPrerendered(apiUrl, request, settings) {
Comment thread
giovanniRodighiero marked this conversation as resolved.
const headers = { 'User-Agent': request.headers['user-agent'] };
if (settings.token) {
headers['X-Prerender-Token'] = settings.token;
} else {
console.warn('Prerender.io API token not provided');
}
Comment on lines +78 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can log a warning here, could be useful to debug for the client.

headers['X-Prerender-Int-Type'] = 'Hapi';
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();
const SKIP_HEADERS = new Set(['content-encoding', 'content-length', 'transfer-encoding', 'connection']);
for (const [key, value] of prerendered.headers.entries()) {
if (!SKIP_HEADERS.has(key.toLowerCase())) response.header(key, value);
}
return response;
}
Comment thread
giovanniRodighiero marked this conversation as resolved.

exports.plugin = {
pkg: require('./package.json'),
/**
* @param {PrerenderOptions} options
*/
async register(server, options) {
Comment thread
giovanniRodighiero marked this conversation as resolved.
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);
await settings.afterRender(request, prerendered);
return buildResponse(h, prerendered);
} catch (err) {
console.error('Prerender error, falling back:', err.message);
return h.continue;
}
});
}
};
Loading
Loading