Skip to content

Upgrade Harper to v5 - #4

Merged
BboyAkers merged 16 commits into
mainfrom
v5-upgrade
Aug 21, 2026
Merged

Upgrade Harper to v5#4
BboyAkers merged 16 commits into
mainfrom
v5-upgrade

Conversation

@BboyAkers

@BboyAkers BboyAkers commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Completes the Harper v4 → v5 ("Lincoln") upgrade for this React SSR + multi-tier caching example, adds integration tests covering the SSR render path and Harper caching behavior, and wires up CI. This PR resumes the existing v5-upgrade branch (PR #4) — no new/duplicate PR.

Harper dependency

  • harper bumped ^5.0.10^5.0.28 (latest).
  • package-lock.json regenerated cleanly so npm ci resolves the full platform-optional dependency tree (@emnapi/*, bufferutil, utf-8-validate) on Linux CI — an incremental macOS install had pruned them, which broke npm ci.

Migration items applied

  • Package/import rename (harperdbharper): resources.js imports from harper.
  • Caching-source resource contract (real v5 fix): the prior commits on this branch had mechanically converted the resource handlers from instance async get() to static async get(target). For UncachedBlog (a plain exported endpoint) that is fine, but for the caching source PageBuilder (BlogCache.sourcedFrom(PageBuilder)) it broke caching: in v5 a cache source is resolved per-id through an instance get() (the cache instantiates the source resource for the requested id and calls get()). With a static method, PageBuilder.get was never invoked and BlogCache stored raw Post records, so cached.content was always undefined and /CachedBlog/0 returned JSON ({"contentType":"text/html"}) with no HTML body and no ETag/304. Reverted all three handlers to instance get(query) using await super.get(query), matching Harper's own v5 caching reference (unitTests/testApp SimpleCacheSource). This restored the cached HTML body and the full ETag/Last-Modified/304 behavior. (Found via the integration tests; not visible from static review.)
  • rest.lastModified config restored: an earlier change to rest: true dropped Last-Modified header emission. v5's REST layer still honors rest.lastModified (verified in harper/dist/server/REST.js), and this example's caching demo depends on Last-Modified / If-Modified-Since. Restored rest:\n lastModified: true.

N/A migration items (and why)

  • Table.get() plain/frozen records — handlers read fields off the returned record and never mutate it in place; no { ...record } copy needed.
  • wasLoadedFromSource() removal — not used.
  • Transaction/context auto-propagation & explicit commit — no manual transaction polling.
  • Child-process spawning allowlist (allowedSpawnCommands + name) — the app spawns no processes.
  • blob.save() removal — no blob storage.
  • Install-scripts-disabled / VM module loader / moduleLoader / allowedBuiltinModules — no install scripts or restricted built-ins; defaults work (CI green).

Tests

Added integrationTests/app.test.ts (@harperfast/integration-testing 0.4.0, node:test, ESM + TS) running against a real ephemeral Harper instance. Coverage:

  • REST on the Post table: seeded record GET /Post/0, list GET /Post/, PATCH /Post/0 persistence.
  • SSR render path: GET /UncachedBlog/0 and GET /CachedBlog/0 return a full SSR'd HTML document (<!doctype html>, placeholders replaced), the hydration data (window.__INITIAL_POST_DATA__), the correct __CACHED__ flag, and the post title.
  • Harper multi-tier caching: CachedBlog emits ETag + Last-Modified; a conditional re-request returns 304; updating the source Post invalidates the cache (stale conditional headers → 200 fresh render) and, once re-cached, a conditional request with refreshed headers returns 304 again. Mirrors caching-test.js as an automated test. Because BlogCache is populated/revalidated asynchronously, the tests poll until the cache settles on a full text/html document with a stable ETag before asserting conditional-request behavior (deterministic; mirrors real cache use).

A pretest:integration step runs npm run build (Vite) so dist/client/index.html and dist/server/entry-server.js exist before resources.js loads them. The mandatory harperBinPath harness fix is applied (resolve the CLI from harper's exported main entry, since its exports map only exposes .).

Local run: blocked by the macOS loopback limitation only — the harness binds Harper to 127.0.0.2+ and this machine has no loopback aliases (LoopbackAddressValidationError / EADDRNOTAVAIL). Environmental, not a code/test issue.
CI (ubuntu-latest): ✅ all green on Node 22 / 24 / 26https://github.com/HarperFast/react-ssr-example/actions/runs/27036806463

CI

Added .github/workflows/integration-tests.yml at the repo root — Node matrix [22, 24, 26], actions pinned to commit hashes (checkout v6.0.3, setup-node v6.4.0, upload-artifact v7.0.1). npm run test:integration triggers the Vite build via pretest:integration, so no extra build step is needed. Uploads Harper logs on failure.

Branding

HarperDBHarper in README.md prose (live docs.harperdb.io URLs left intact).

Flagged for a human

  • npm scope (manual): @harperdb/code-guidelines (devDep + prettier config) is on the legacy @harperdb scope. Per policy, npm-scope moves are manual — flagged, not changed here.

🤖 Generated with Claude Code

@BboyAkers

Copy link
Copy Markdown
Member Author

Errors:
When navigating to localhost:9926. I'm seeing

{"type":"error:ResourceLoadError","code":"ResourceLoadError","title":"Could not load component 'jsResource' for application 'react-ssr-example' due to: Failed to load resource module /Users/<user>/harperdb_repos/react-ssr-example/resources.js: The \"path\" argument must be of type string. Received undefined","status":500,"instance":"/"}

Console also prints

[http/1] [error]: ResourceLoadError: Failed to load resource module /Users/<user>/harperdb_repos/react-ssr-example/resources.js: The "path" argument must be of type string. Received undefined
    at handleResourceEntry (/Users/<user>/.nvm/versions/node/v24.10.0/lib/node_modules/harper/resources/jsResource.ts:68:10)
    at async Promise.all (index 0)
    at async <anonymous> (/Users/<user>/.nvm/versions/node/v24.10.0/lib/node_modules/harper/components/Scope.ts:244:6) {
  filePath: '/Users/<user>/harperdb_repos/react-ssr-example/resources.js',
  cause: TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received undefined
      at Object.join (node:path:1339:7)
      at file:///Users/<user>/harperdb_repos/react-ssr-example/resources.js:16:39 {
    code: 'ERR_INVALID_ARG_TYPE'
  }
}

Would love some input on moving forward, fixing this issue 🙂

BboyAkers and others added 3 commits May 7, 2026 13:13
- Bump harper ^5.0.10 -> ^5.0.28
- Restore rest.lastModified: true (still honored by v5 REST; required for
  the Last-Modified/If-Modified-Since caching the example demonstrates)
- Remove unused logger import from resources.js
- Add @harperfast/integration-testing + typescript dev deps, tsconfig.json
- Add integrationTests/app.test.ts covering REST, SSR render path, and
  multi-tier caching (ETag/Last-Modified/304 + cache invalidation)
- Add pretest:integration build step and test:integration script
- Add pinned-hash integration-tests CI workflow (Node 22/24/26)
- HarperDB -> Harper branding in README prose

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A clean install regenerates the full optional-dependency tree
(@emnapi/core, @emnapi/runtime, bufferutil, utf-8-validate) that an
incremental macOS install had pruned, fixing `npm ci` on Linux CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@BboyAkers BboyAkers changed the title Initial harper v5 upgrade changes Upgrade Harper to v5 Jun 5, 2026
BboyAkers and others added 7 commits June 5, 2026 15:37
BlogCache (sourced from PageBuilder) is populated asynchronously, so the
ETag/Last-Modified can change across the first reads after a source change.
Poll until the cache ETag is stable before asserting conditional-request
(304/200) behavior, mirroring real cache usage. This fixes two CI-only
flaky failures (full-document body and stale 304 assertions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CachedBlog.get returned { contentType, data }, a v4-era convention that
v5's REST layer does not honor without a `headers` property — it was
JSON-serialized instead of served as raw HTML (CI showed the body was
{"contentType":"text/html"}). Return { status, headers, body } like
UncachedBlog so the HTML is served with Content-Type: text/html.

Also require a full HTML document (not just a stable ETag) before the
cache is considered settled, and drop the diagnostic logging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverting to { contentType, data } keeps CachedBlog in Harper's caching
response branch, which is what emits the ETag/Last-Modified the example
relies on. v5's serialize() honors { contentType, data } only when `data`
is non-null; the BlogCache entry is populated asynchronously, so until
`cached.content` exists the endpoint returns JSON with no ETag. The test
now polls until CachedBlog serves a stable text/html document before
asserting caching behavior, and reports body/status on timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior commits mechanically converted PageBuilder/CachedBlog/UncachedBlog
to static get(target). For a caching source that broke sourcedFrom: v5
resolves a cache source per-id through an instance get() (the cache
instantiates the source resource for the requested id), so the static
PageBuilder.get was never invoked and BlogCache stored raw Post records
(cached.content was always undefined -> /CachedBlog served JSON, no HTML,
no 304). Reverting to instance get() using super.get() matches Harper's
own v5 caching reference (unitTests/testApp SimpleCacheSource) and restores
the cached HTML body + ETag/Last-Modified/304 behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the cache settles, it can briefly re-revalidate, so a conditional
request with a just-captured ETag may momentarily return 200. Poll the
conditional request until it returns 304 (eventual consistency), and check
the stale-headers cache miss before re-settling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@BboyAkers
BboyAkers marked this pull request as ready for review June 5, 2026 19:55
Apply shell-injection fix to integration-tests.yml: move the
github.event.inputs.node-version expression into an env var (NODE_VER)
so it is never interpolated directly into the shell script body.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Comment thread resources.js Outdated
Comment thread resources.js Outdated
Comment thread integrationTests/app.test.ts Outdated
…ertion

- resources.js line 26: escape < in JSON.stringify output to prevent XSS via
  malicious post title/body/comment in inline script tag
- resources.js line 60: guard cached?.content with optional chaining to avoid
  TypeError when cache entry is not yet populated
- integrationTests/app.test.ts lines 57/153: use startsWith('text/html') so
  the settlement loop and assertion are robust to '; charset=utf-8' suffixes

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@BboyAkers

Copy link
Copy Markdown
Member Author

Review follow-up (autonomous agent): Fixed all three blocking findings: escaped < in inline JSON to prevent XSS (resources.js:26), guarded cached?.content with optional chaining to prevent null dereference during cache population (resources.js:60), and changed content-type check to startsWith('text/html') so tests are robust to charset suffixes (app.test.ts:57/153).

BboyAkers and others added 3 commits August 10, 2026 12:34
Bump the harper dependency to ^5.2.1 and regenerate the lockfile.
Regenerated in full so the optional native deps (bufferutil,
utf-8-validate, segfault-handler) stay in the tree for Linux CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous lockfile was generated with npm 11, which does not
auto-install the peer dependencies of an optional dependency. harper
5.2.1 pulls alasql, which optionally depends on react-native-fs, whose
peers (react-native, react) npm 12 installs and npm 11 does not. CI runs
npm 12 on Node 24/26, so npm ci failed there with those packages
"missing from lock file" while Node 22 (npm 11) passed.

Regenerated with npm 12 so the lockfile carries the full tree.
lockfileVersion stays 3; npm ci verified under both npm 11 and npm 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses the three remaining review threads on #4.

Two were already fixed on-branch in 0a44062 and are only tidied here:
the null dereference (`data: cached?.content`) and the `</script>`
injection in the data island. The escape was an inline IIFE; it is now a
named `safeJson` helper with a comment explaining why `<` is escaped,
which is the shape the review suggested.

The Content-Type thread was only partly fixed. The settlement guard and
the CachedBlog assertion had been switched to `startsWith`, but the
UncachedBlog test at line 139 still did
`strictEqual(res.headers.get('Content-Type'), 'text/html')` — the same
fragility, missed because the review named only lines 57 and 153. All
three now go through a `mediaType()` helper that strips parameters, so a
`; charset=utf-8` suffix cannot break them. Comparing the parsed media
type is also tighter than `startsWith`, which would accept `text/html-x`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BboyAkers
BboyAkers merged commit 8dcbdb6 into main Aug 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants