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
74 changes: 74 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Integration Tests

on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
node-version:
description: 'Node.js version'
required: true
type: choice
default: 'all'
options:
- 'all'
- '22'
- '24'
- '26'

jobs:
generate-node-version-matrix:
name: Generate Node Version Matrix
runs-on: ubuntu-latest
outputs:
node-versions: ${{ steps.set-node-versions.outputs.node-versions }}

steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Set Node versions
id: set-node-versions
run: |
if [ "${{ github.event.inputs.node-version }}" == "all" ] || [ -z "${{ github.event.inputs.node-version }}" ]; then
echo "node-versions=[22, 24, 26]" >> $GITHUB_OUTPUT
else
echo "node-versions=[${{ github.event.inputs.node-version }}]" >> $GITHUB_OUTPUT
fi

integration-tests:
name: Integration Tests (Node ${{ matrix.node-version }})
needs: [generate-node-version-matrix]
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
node-version: ${{ fromJSON(needs.generate-node-version-matrix.outputs.node-versions) }}

steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}

- name: Install dependencies
run: npm ci

- name: Run integration tests
run: npm run test:integration
env:
HARPER_INTEGRATION_TEST_LOG_DIR: /tmp/harper-test-logs
FORCE_COLOR: '1'

- name: Upload Harper logs on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: harper-logs-node-${{ matrix.node-version }}
path: /tmp/harper-test-logs/
retention-days: 7
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,11 @@ See below for **integration requirements**.
Your custom render service must be configured with these environment variables. Reference the `.env.example` in `renderer` for more details:

```bash
HDB_HOST=<harperdb-hostname>
HDB_HOST=<harper-hostname>
HDB_HTTP_PORT=<http-port>
HDB_MQTT_PORT=<mqtt-port>
HDB_USER=<harperdb-username>
HDB_PASS=<harperdb-password>
HDB_USER=<harper-username>
HDB_PASS=<harper-password>
WORKER_ID=<unique-worker-identifier>
NODE_ENV=<production|development>
```
Expand Down
10 changes: 5 additions & 5 deletions component/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,16 @@ The orchestrator communicates with renderer services through:

### Running locally

1. `git clone https://github.com/HarperDB/template-static-prerender.git`
1. `git clone https://github.com/HarperFast/template-static-prerender.git`
2. `cd template-static-prerender`
3. `npm install`
4. `harperdb run .`
4. `harper run .`

This assumes you have the Harper stack already [installed]([Install HarperDB | HarperDB](https://docs.harperdb.io/docs/deployments/install-harperdb)) globally.
This assumes you have the Harper stack already [installed]([Install Harper | Harper](https://docs.harperdb.io/learn/getting-started/install-and-connect-harper)) globally.

### Deployment

Deploy the component using Harper"s **Operations API** via the [Harper CLI](https://docs.harperdb.io/docs/deployments/harper-cli#operations-api-through-the-cli).
Deploy the component using Harper"s **Operations API** via the [Harper CLI](https://docs.harperdb.io/reference/v5/cli/overview#operations-api-through-the-cli).

---

Expand All @@ -67,7 +67,7 @@ The first two (2) endpoints are the primary means of interacting with this compo

The `/render_jobs` endpoint is also used by renderer services to claim jobs and upload completed content through the orchestrator.

The last five (5) endpoints provide low level control and direct access to Harper"s REST API. For a full description of what the REST API can do and how to use if your can refer to its [documentation](https://docs.harperdb.io/docs/developers/rest).
The last five (5) endpoints provide low level control and direct access to Harper"s REST API. For a full description of what the REST API can do and how to use if your can refer to its [documentation](https://docs.harperdb.io/reference/v5/rest/overview).

This REST interface for the various tables can be used to manually manipulate the data. See the [Data Model](#data-model) below for details on the structure of each table.

Expand Down
19 changes: 8 additions & 11 deletions component/localExtensions/orchestrator/orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* to the originating thread.
*/

import { databases } from 'harper';
import { isMainThread, threadId } from 'worker_threads';
import { parentPort } from 'node:worker_threads';
import CacheKey from '../../src/util/CacheKey.js';
Expand Down Expand Up @@ -254,16 +255,12 @@ async function savePageContent(result) {
const pageSchedule = await databases.prerender.PageMeta.get(cacheKey);

if (pageSchedule) {
pageSchedule.lastRefresh = Date.now();

if (pageSchedule.refreshInterval > -1) {
pageSchedule.nextRefresh = calculateNextRefresh(pageSchedule.refreshInterval, pageSchedule.lastRefresh);
pageSchedule.status = 'scheduled';
} else {
pageSchedule.nextRefresh = -1;
pageSchedule.status = 'idle';
}

await pageSchedule.update();
const lastRefresh = Date.now();
const isRecurring = pageSchedule.refreshInterval > -1;
await databases.prerender.PageMeta.patch(cacheKey, {
lastRefresh,
nextRefresh: isRecurring ? calculateNextRefresh(pageSchedule.refreshInterval, lastRefresh) : -1,
status: isRecurring ? 'scheduled' : 'idle',
});
}
}
6 changes: 3 additions & 3 deletions component/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"description": "Harper component for caching prerendered static HTML pages",
"type": "module",
"author": {
"name": "HarperDB, Inc.",
"name": "Harper, Inc.",
"email": "opensource@harperdb.io",
"url": "https://harpersystems.dev/"
},
Expand All @@ -19,8 +19,8 @@
"email": "opensource@harperdb.io"
},
"scripts": {
"dev": "harperdb dev .",
"start": "harperdb run ."
"dev": "harper dev .",
"start": "harper run ."
},
"dependencies": {
"@crawlee/utils": "^3.13.2",
Expand Down
11 changes: 5 additions & 6 deletions component/src/resources/JobQueue.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,16 @@
* - {@link CacheKey} for cache storage keys.
*/

import { databases } from 'harper';
import { parentPort } from 'node:worker_threads';
import { handleContent, registerJobCompletionCallback } from 'orchestrator';
import ManagedPage from './ManagedPage.js';
import CacheKey from '../util/CacheKey.js';
import RenderWorkers from '../RenderWorkers.js';
import RenderWorkers from '../util/RenderWorkers.js';
import Mutex from '../util/Mutex.js';
import { currentMinuteMs } from '../util/time.js';
import { nodes } from '../util/replication.js';
import { extractUpstreamResponseHeaderName } from '../util/headers.js';
import { extractUpstreamResponseHeaderName } from '../util/header.js';
import { RES_HEADERS_WHITELIST } from '../util/constants.js';

const mutex = await Mutex.init();
Expand Down Expand Up @@ -108,7 +109,7 @@ export const assignNodeToWorker = mutex.withLock(async (workerId) => {
result = await databases.prerender.WorkerAssignments.get(0);
}

const assignments = { ...result.assignments.toJSON() };
const assignments = { ...(result.assignments || {}) };

nodes.forEach((node) => {
if (!assignments[node]) {
Expand Down Expand Up @@ -149,9 +150,7 @@ export const assignNodeToWorker = mutex.withLock(async (workerId) => {
workerNode = bestNode;
}

result.assignments = assignments;

await result.update();
await databases.prerender.WorkerAssignments.patch(0, { assignments });

return { host: workerNode };
});
Expand Down
1 change: 1 addition & 0 deletions component/src/resources/ManagedPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* This class extends the database-backed `PageMeta` resource and
* provides constants for standard lifecycle statuses.
*/
import { databases } from 'harper';
export default class ManagedPage extends databases.prerender.PageMeta {
static directURLMapping = true;

Expand Down
52 changes: 29 additions & 23 deletions component/src/resources/PageCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* - {@link CacheKey} for uniquely identifying cached content.
*/

import { databases } from 'harper';
import ManagedPage from './ManagedPage.js';
import { render } from '../util/render.js';
import CacheKey from '../util/CacheKey.js';
Expand Down Expand Up @@ -80,7 +81,8 @@ const pageSource = {
if (content instanceof Blob) {
content.on('error', (err) => {
logger.error('Blob error', err);
page.invalidate();
// Invalidate the cache entry via the table (v5: records are frozen plain objects)
databases.prerender.PageCache.invalidate(cacheKey);
});
}

Expand Down Expand Up @@ -140,47 +142,51 @@ export default class PageCache extends databases.prerender.PageCache {
/**
* Retrieves cached page content with headers and status.
* Sets gzip encoding by default.
* @param {object} target - The request target (RequestTarget extends URLSearchParams).
* @returns {Promise<object>} - Response with status, data, and headers.
*/
async get() {
if (!this.content) {
static async get(target) {
const record = await super.get(target);

if (!record?.content) {
return {
status: this.statusCode || 404,
status: record?.statusCode || 404,
data: {
data: 'Page Not Found',
contentType: 'text/plain',
},
};
}

// Check for blob errors
if (this.content instanceof Blob) {
this.content.on('error', (err) => {
// Check for blob errors. In v5, records are frozen plain objects — use
// databases.prerender.PageCache.invalidate(key) instead of record.invalidate().
if (record.content instanceof Blob) {
record.content.on?.('error', (err) => {
logger.error('Blob error', err);
this.invalidate();
databases.prerender.PageCache.invalidate(record.cacheKey);
});
}

let respHeaders = new Headers();
for (const [key, value] of Object.entries(JSON.parse(this.headers))) {
respHeaders.set(key, value);
}

if (!respHeaders.has('content-encoding')) {
respHeaders.set('content-encoding', 'gzip');
}

if (!respHeaders.has('content-type')) {
respHeaders.set('content-type', 'text/html; charset=utf-8');
// Build response headers as a plain object. Stored headers (if any) take
// precedence; defaults ensure content-type and content-encoding are always set.
// Note: do NOT return a Headers instance here — Harper v5's REST mergeHeaders
// calls new Headers(responseData.headers) which fails with a WHATWG Headers
// iterable. Pass a plain object instead.
let storedHeaders = {};
try {
storedHeaders = typeof record.headers === 'string'
? (JSON.parse(record.headers) || {})
: (record.headers || {});
} catch {
logger.warn('PageCache.get: could not parse stored headers for', record.cacheKey);
}

return {
status: this.statusCode || 200,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Bug (medium): headers field omitted from return — gzip Blob served to REST clients without content-encoding

The comment on line 171 says content-type and content-encoding are 'always set', but the return object has no headers key. The old code returned headers: respHeaders as a WHATWG Headers instance, which Harper v5's mergeHeaders rejected. The fix should be to return headers as a plain Record<string, string>, not to omit them entirely.

Without content-encoding: gzip in the response, REST clients receive the compressed Blob as-is and browsers render garbled binary output.

Suggested change
status: this.statusCode || 200,
return {
status: record.statusCode || 200,
headers: {
'content-type': storedHeaders['content-type'] || 'text/html; charset=utf-8',
'content-encoding': storedHeaders['content-encoding'] || 'gzip',
},
data: {
data: record.content,
contentType: storedHeaders['content-type'] || 'text/html; charset=utf-8',
},
};

status: record.statusCode || 200,
data: {
data: this.content,
contentType: 'text/html; charset=utf-8',
data: record.content,
contentType: storedHeaders['content-type'] || 'text/html; charset=utf-8',
},
headers: respHeaders,
};
}
}
Expand Down
Loading