Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
90f05d7
initial commit for v5 harper upgrade
BboyAkers May 7, 2026
879010a
inital v5 upgrade commit with various changes to resource files, read…
BboyAkers May 8, 2026
4d2af8d
Convert Resource instance methods to static REST methods for Harper v5
kriszyp May 25, 2026
ebae724
Fix PageCache.static get: fetch record via super.get(target)
kriszyp May 25, 2026
b9dc35a
Add integration tests, CI workflow, and complete v5 upgrade
BboyAkers Jun 8, 2026
242bb81
fix(tests): pre-install component deps before Harper starts
BboyAkers Jun 8, 2026
9729bfa
fix(tests): use identity content-encoding for primed cache entries
BboyAkers Jun 8, 2026
d4dba22
fix: load fast-xml-parser via CJS for Harper v5 VM module loader
BboyAkers Jun 8, 2026
b6a0da9
fix: guard server.nodes against undefined in replication.js
BboyAkers Jun 8, 2026
9636c3e
fix: single-thread resilience for Mutex and RenderWorkers
BboyAkers Jun 8, 2026
43ca98b
fix: PageCache.static get returns plain object headers (not Headers i…
BboyAkers Jun 8, 2026
021912d
fix: remove headers from PageCache.static get response to avoid merge…
BboyAkers Jun 8, 2026
dbe9a35
fix(tests): replace caching contract tests with simpler PUT/list tests
BboyAkers Jun 8, 2026
e941b23
Fix STATUS_TYPES typo and bare parentPort.postMessage in event handlers
BboyAkers Jun 11, 2026
8d6970c
Address PR review: mutex fallback safety, sitemap list, gzip headers,…
BboyAkers Jul 14, 2026
b1d7ac6
Upgrade harper to 5.2.1
BboyAkers Aug 10, 2026
9b60c3d
Regenerate lockfile with npm 12 for Linux CI
BboyAkers Aug 10, 2026
4ba83f4
Address review feedback
BboyAkers Aug 10, 2026
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
59 changes: 36 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,58 @@ 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,
// Return headers as a plain Record<string, string> (NOT a WHATWG Headers
// instance, which v5's REST mergeHeaders rejects). Without content-encoding
// the gzipped Blob would be served uncompressed and render as garbled binary.
headers: {
'content-type': storedHeaders['content-type'] || 'text/html; charset=utf-8',
'content-encoding': storedHeaders['content-encoding'] || 'gzip',
},
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