From d47cc8390b4bb77c41c0dcee39a3e535f1ed5d2c Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Thu, 7 May 2026 16:31:33 -0500 Subject: [PATCH 01/14] feat: implement cache tag invalidation in CacheHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the stubbed `revalidateTag` with a soft-invalidation pattern backed by a new `nextjs_cache_invalidation` table. Each tag → timestamp entry is hydrated into an in-memory map on first construction and kept fresh across workers via a Harper subscription, so any worker observes invalidations from any other. Adapts the pattern from aeo-page-cache, omitting the background hard-purge: Next.js naturally overwrites stale rows on regeneration, so hard-deleting buys little and would compete with rendering for I/O on the same worker. Cold rows can be reclaimed by the table's expiration. `get` checks both `ctx.revalidatedTags` (the per-request snapshot Next passes the constructor) and the persistent map; if any tag predates the record's `lastModified`, returns null so Next regenerates. `set` extracts tags from `ctx.tags` for FETCH entries and from the `x-next-cache-tags` header for APP_PAGE / APP_ROUTE / PAGES, storing them as a CSV column. Schema adds `tags: String` to `nextjs_isr_cache` and a new `nextjs_cache_invalidation` table (7-day expiration). Wires up the previously unused next-16-caching fixture: corrects the `cacheHandler` reference (was looking for `.js`, dist emits `.cjs`), moves the previously-skipped ISR tests into a dedicated test file against this fixture, and adds a `revalidateTag` end-to-end test using `unstable_cache` + a route handler. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/api/revalidate/route.js | 11 ++ fixtures/next-16-caching/app/tagged/page.js | 17 ++ fixtures/next-16-caching/next.config.mjs | 10 +- integrationTests/next-16-caching.pw.ts | 170 ++++++++++++++++++ integrationTests/next-16.pw.ts | 129 ------------- schema.graphql | 9 +- src/CacheHandler.cts | 146 +++++++++++++-- 7 files changed, 342 insertions(+), 150 deletions(-) create mode 100644 fixtures/next-16-caching/app/api/revalidate/route.js create mode 100644 fixtures/next-16-caching/app/tagged/page.js create mode 100644 integrationTests/next-16-caching.pw.ts diff --git a/fixtures/next-16-caching/app/api/revalidate/route.js b/fixtures/next-16-caching/app/api/revalidate/route.js new file mode 100644 index 0000000..71fd14a --- /dev/null +++ b/fixtures/next-16-caching/app/api/revalidate/route.js @@ -0,0 +1,11 @@ +import { revalidateTag } from 'next/cache'; +import { NextResponse } from 'next/server'; + +export async function POST(request) { + const tag = new URL(request.url).searchParams.get('tag'); + if (!tag) { + return NextResponse.json({ error: 'tag required' }, { status: 400 }); + } + revalidateTag(tag); + return NextResponse.json({ revalidated: true, tag }); +} diff --git a/fixtures/next-16-caching/app/tagged/page.js b/fixtures/next-16-caching/app/tagged/page.js new file mode 100644 index 0000000..a52b1b4 --- /dev/null +++ b/fixtures/next-16-caching/app/tagged/page.js @@ -0,0 +1,17 @@ +import { unstable_cache } from 'next/cache'; + +const getNonce = unstable_cache( + async () => Math.random().toString(36).slice(2), + ['tagged-nonce'], + { tags: ['test-tag'], revalidate: 3600 } +); + +export default async function TaggedPage() { + const nonce = await getNonce(); + return ( +
+

Tagged Page

+

{nonce}

+
+ ); +} diff --git a/fixtures/next-16-caching/next.config.mjs b/fixtures/next-16-caching/next.config.mjs index 206a478..c943641 100644 --- a/fixtures/next-16-caching/next.config.mjs +++ b/fixtures/next-16-caching/next.config.mjs @@ -1,9 +1,3 @@ -import { join } from 'path'; +import { withHarper } from '@harperfast/nextjs'; -export default { - // turbopack: { - // root: '../../../../', - // }, - serverExternalPackages: ['harper', '@harperfast/nextjs'], - cacheHandler: join(import.meta.dirname, 'node_modules', '@harperfast', 'nextjs', 'dist', 'CacheHandler.js'), -}; +export default withHarper({}, { experimentalHarperCache: true }); diff --git a/integrationTests/next-16-caching.pw.ts b/integrationTests/next-16-caching.pw.ts new file mode 100644 index 0000000..5b4aa7c --- /dev/null +++ b/integrationTests/next-16-caching.pw.ts @@ -0,0 +1,170 @@ +import { fixture } from './fixture.ts'; + +const { test, expect } = fixture('next-16-caching'); + +test('ISR page serves cached response and revalidates after expiry', async ({ page, harper }) => { + const url = `${harper.httpURL}/isr`; + + // Warm the cache. The very first render after boot may be a MISS or STALE + // depending on whether Next.js prerendered the page at build time. We do an + // initial throw-away request to ensure the page is in the cache before we + // start asserting behaviour. + await page.goto(url); + + // ── First cached request ────────────────────────────────────────────────── + const response1 = await page.goto(url); + const timestamp1 = await page.getByTestId('timestamp').innerText(); + + // Should be a cache HIT (served from the Harper-backed ISR cache). + expect(response1!.headers()['x-nextjs-cache']).toBe('HIT'); + + // ── Second request within revalidation window ───────────────────────────── + const response2 = await page.goto(url); + const timestamp2 = await page.getByTestId('timestamp').innerText(); + + expect(response2!.headers()['x-nextjs-cache']).toBe('HIT'); + // Content must be identical — the cached page has not been regenerated. + expect(timestamp1).toBe(timestamp2); + + // ── Wait for the revalidation window to expire (revalidate = 2s) ────────── + await page.waitForTimeout(2500); + + // ── Stale request ───────────────────────────────────────────────────────── + // Next.js serves the stale cached page while triggering a background regen. + const response3 = await page.goto(url); + const timestamp3 = await page.getByTestId('timestamp').innerText(); + + expect(response3!.headers()['x-nextjs-cache']).toBe('STALE'); + // Still the old content while revalidation is in flight. + expect(timestamp2).toBe(timestamp3); + + // ── Revalidated request ─────────────────────────────────────────────────── + // Background revalidation should have completed; next hit is the fresh page. + const response4 = await page.goto(url); + const timestamp4 = await page.getByTestId('timestamp').innerText(); + + expect(response4!.headers()['x-nextjs-cache']).toBe('HIT'); + // Content must have changed — the page was regenerated with a new timestamp. + expect(timestamp3).not.toBe(timestamp4); +}); + +test('ISR cache record is persisted in Harper', async ({ request, harper }) => { + // Hit the ISR page so Next.js writes to the cache handler. + await request.get(`${harper.httpURL}/isr`); + // Second request ensures the cache is populated (first may be a build-time miss). + await request.get(`${harper.httpURL}/isr`); + + // Query the Harper Operations API to inspect the nextjs_isr_cache table. + // The key Next.js uses for app-router pages is the route path (e.g. "/isr"). + const response = await request.post(harper.operationsAPIURL, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${Buffer.from(`${harper.admin.username}:${harper.admin.password}`).toString('base64')}`, + }, + data: { + operation: 'search_by_value', + database: 'harperfast_nextjs', + table: 'nextjs_isr_cache', + search_attribute: 'id', + search_value: '/isr', + get_attributes: ['id', 'lastModified'], + }, + }); + + expect(response.status()).toBe(200); + + const records = await response.json(); + expect(records).toHaveLength(1); + + const record = records[0]; + expect(record.id).toBe('/isr'); + // lastModified should be a recent Unix timestamp in milliseconds. + expect(typeof record.lastModified).toBe('number'); + expect(record.lastModified).toBeGreaterThan(Date.now() - 60_000); +}); + +test('ISR cache record is updated after revalidation', async ({ request, harper }) => { + const isrURL = `${harper.httpURL}/isr`; + + // Warm the cache. + await request.get(isrURL); + await request.get(isrURL); + + // Capture the initial lastModified timestamp from the DB. + const authHeader = `Basic ${Buffer.from(`${harper.admin.username}:${harper.admin.password}`).toString('base64')}`; + const queryPayload = { + operation: 'search_by_value', + database: 'harperfast_nextjs', + table: 'nextjs_isr_cache', + search_attribute: 'id', + search_value: '/isr', + get_attributes: ['id', 'lastModified'], + }; + + const before = await request.post(harper.operationsAPIURL, { + headers: { 'Content-Type': 'application/json', 'Authorization': authHeader }, + data: queryPayload, + }); + const [beforeRecord] = await before.json(); + const lastModifiedBefore: number = beforeRecord.lastModified; + + // Wait past the revalidation window and trigger a stale response (which + // kicks off background regeneration). + await request.get(isrURL); // ensure we have a fresh HIT first + await new Promise((resolve) => setTimeout(resolve, 2500)); + await request.get(isrURL); // STALE — triggers background regen + // Give Next.js a moment to complete background regeneration and write to the cache. + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Query again. + const after = await request.post(harper.operationsAPIURL, { + headers: { 'Content-Type': 'application/json', 'Authorization': authHeader }, + data: queryPayload, + }); + const [afterRecord] = await after.json(); + const lastModifiedAfter: number = afterRecord.lastModified; + + // The record's lastModified timestamp must have advanced. + expect(lastModifiedAfter).toBeGreaterThan(lastModifiedBefore); +}); + +test('revalidateTag writes invalidation row and forces regeneration', async ({ request, harper, page }) => { + const taggedURL = `${harper.httpURL}/tagged`; + const revalidateURL = `${harper.httpURL}/api/revalidate?tag=test-tag`; + const authHeader = `Basic ${Buffer.from(`${harper.admin.username}:${harper.admin.password}`).toString('base64')}`; + + // Warm the cache. + await page.goto(taggedURL); + const nonceBefore = await page.getByTestId('nonce').innerText(); + + // Sanity: a second hit returns the cached value (same nonce). + await page.goto(taggedURL); + const nonceCached = await page.getByTestId('nonce').innerText(); + expect(nonceCached).toBe(nonceBefore); + + // Trigger revalidateTag('test-tag') via the route handler. + const revalidateResponse = await request.post(revalidateURL); + expect(revalidateResponse.status()).toBe(200); + + // The invalidation row should now exist in Harper. + const invalidationRow = await request.post(harper.operationsAPIURL, { + headers: { 'Content-Type': 'application/json', 'Authorization': authHeader }, + data: { + operation: 'search_by_value', + database: 'harperfast_nextjs', + table: 'nextjs_cache_invalidation', + search_attribute: 'id', + search_value: 'test-tag', + get_attributes: ['id', 'timestamp'], + }, + }); + const rows = await invalidationRow.json(); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('test-tag'); + expect(typeof rows[0].timestamp).toBe('number'); + + // Next page request must regenerate (new nonce). + await page.goto(taggedURL); + const nonceAfter = await page.getByTestId('nonce').innerText(); + expect(nonceAfter).not.toBe(nonceBefore); +}); diff --git a/integrationTests/next-16.pw.ts b/integrationTests/next-16.pw.ts index 80ff3a9..d209ec1 100644 --- a/integrationTests/next-16.pw.ts +++ b/integrationTests/next-16.pw.ts @@ -16,132 +16,3 @@ test('status endpoint returns 200', async ({ request, harper }) => { const response = await request.get(`${harper.operationsAPIURL}/health`); expect(response.status()).toBe(200); }); - -// These are meant for `next-16-caching` when we get that all working -test.describe.skip('ISR caching', () => { - test('ISR page serves cached response and revalidates after expiry', async ({ page, harper }) => { - const url = `${harper.httpURL}/isr`; - - // Warm the cache. The very first render after boot may be a MISS or STALE - // depending on whether Next.js prerendered the page at build time. We do an - // initial throw-away request to ensure the page is in the cache before we - // start asserting behaviour. - await page.goto(url); - - // ── First cached request ────────────────────────────────────────────────── - const response1 = await page.goto(url); - const nonce1 = await page.getByTestId('nonce').innerText(); - - // Should be a cache HIT (served from the Harper-backed ISR cache). - expect(response1!.headers()['x-nextjs-cache']).toBe('HIT'); - - // ── Second request within revalidation window ───────────────────────────── - const response2 = await page.goto(url); - const nonce2 = await page.getByTestId('nonce').innerText(); - - expect(response2!.headers()['x-nextjs-cache']).toBe('HIT'); - // Content must be identical — the cached page has not been regenerated. - expect(nonce1).toBe(nonce2); - - // ── Wait for the revalidation window to expire (revalidate = 2s) ────────── - await page.waitForTimeout(2500); - - // ── Stale request ───────────────────────────────────────────────────────── - // Next.js serves the stale cached page while triggering a background regen. - const response3 = await page.goto(url); - const nonce3 = await page.getByTestId('nonce').innerText(); - - expect(response3!.headers()['x-nextjs-cache']).toBe('STALE'); - // Still the old content while revalidation is in flight. - expect(nonce2).toBe(nonce3); - - // ── Revalidated request ─────────────────────────────────────────────────── - // Background revalidation should have completed; next hit is the fresh page. - const response4 = await page.goto(url); - const nonce4 = await page.getByTestId('nonce').innerText(); - - expect(response4!.headers()['x-nextjs-cache']).toBe('HIT'); - // Content must have changed — the page was regenerated with a new nonce. - expect(nonce3).not.toBe(nonce4); - }); - - test('ISR cache record is persisted in Harper', async ({ request, harper }) => { - // Hit the ISR page so Next.js writes to the cache handler. - await request.get(`${harper.httpURL}/isr`); - // Second request ensures the cache is populated (first may be a build-time miss). - await request.get(`${harper.httpURL}/isr`); - - // Query the Harper Operations API to inspect the nextjs_isr_cache table. - // The key Next.js uses for app-router pages is the route path (e.g. "/isr"). - const response = await request.post(harper.operationsAPIURL, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Basic ${Buffer.from(`${harper.admin.username}:${harper.admin.password}`).toString('base64')}`, - }, - data: { - operation: 'search_by_value', - database: 'harperfast_nextjs', - table: 'nextjs_isr_cache', - search_attribute: 'id', - search_value: '/isr', - get_attributes: ['id', 'lastModified'], - }, - }); - - expect(response.status()).toBe(200); - - const records = await response.json(); - expect(records).toHaveLength(1); - - const record = records[0]; - expect(record.id).toBe('/isr'); - // lastModified should be a recent Unix nonce in milliseconds. - expect(typeof record.lastModified).toBe('number'); - expect(record.lastModified).toBeGreaterThan(Date.now() - 60_000); - }); - - test('ISR cache record is updated after revalidation', async ({ request, harper }) => { - const isrURL = `${harper.httpURL}/isr`; - - // Warm the cache. - await request.get(isrURL); - await request.get(isrURL); - - // Capture the initial lastModified nonce from the DB. - const authHeader = `Basic ${Buffer.from(`${harper.admin.username}:${harper.admin.password}`).toString('base64')}`; - const queryPayload = { - operation: 'search_by_value', - database: 'harperfast_nextjs', - table: 'nextjs_isr_cache', - search_attribute: 'id', - search_value: '/isr', - get_attributes: ['id', 'lastModified'], - }; - - const before = await request.post(harper.operationsAPIURL, { - headers: { 'Content-Type': 'application/json', 'Authorization': authHeader }, - data: queryPayload, - }); - const [beforeRecord] = await before.json(); - const lastModifiedBefore: number = beforeRecord.lastModified; - - // Wait past the revalidation window and trigger a stale response (which - // kicks off background regeneration). - await request.get(isrURL); // ensure we have a fresh HIT first - await new Promise((resolve) => setTimeout(resolve, 2500)); - await request.get(isrURL); // STALE — triggers background regen - // Give Next.js a moment to complete background regeneration and write to the cache. - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Query again. - const after = await request.post(harper.operationsAPIURL, { - headers: { 'Content-Type': 'application/json', 'Authorization': authHeader }, - data: queryPayload, - }); - const [afterRecord] = await after.json(); - const lastModifiedAfter: number = afterRecord.lastModified; - - // The record's lastModified nonce must have advanced. - expect(lastModifiedAfter).toBeGreaterThan(lastModifiedBefore); - }); -}); diff --git a/schema.graphql b/schema.graphql index b50aafb..c9b7dbb 100644 --- a/schema.graphql +++ b/schema.graphql @@ -6,6 +6,13 @@ type NextBuildInfo @table(database: "harperfast_nextjs", table: "nextjs_build_in type NextISRCache @table(database: "harperfast_nextjs", table: "nextjs_isr_cache") { id: String @primaryKey - data: String + data: Any + tags: String lastModified: Long @updatedTime } + +type NextCacheInvalidation + @table(database: "harperfast_nextjs", table: "nextjs_cache_invalidation", expiration: 604800) { + id: String @primaryKey + timestamp: Long +} diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index 39db41a..d18bdde 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -1,4 +1,8 @@ -import type { CacheHandler, CacheHandlerValue } from 'next/dist/server/lib/incremental-cache/index.d.ts'; +import type { + CacheHandler, + CacheHandlerContext, + CacheHandlerValue, +} from 'next/dist/server/lib/incremental-cache/index.d.ts'; import type { IncrementalCacheValue, @@ -10,40 +14,158 @@ import type { import { databases } from 'harper'; +const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; + +// Map of tag → invalidation timestamp (ms). Hydrated from the +// nextjs_cache_invalidation table on first construction and kept fresh via a +// Harper subscription so any worker observes invalidations from any other. +const cacheInvalidations = new Map(); + +let subscriptionInitialized = false; + +async function initializeSubscription(): Promise { + if (subscriptionInitialized) return; + subscriptionInitialized = true; + + // Harper's TypeScript types require RequestTarget/SubscriptionRequest objects, + // but the runtime accepts plain object literals (and search() accepts no args). + const table = databases.harperfast_nextjs.nextjs_cache_invalidation as unknown as { + search: () => AsyncIterable<{ id: string; timestamp: number }>; + subscribe: (req: { omitCurrent?: boolean }) => Promise<{ + on: (event: string, listener: (e: { type: string; id: string; value?: { timestamp: number } }) => void) => void; + }>; + }; + + try { + for await (const row of table.search()) { + cacheInvalidations.set(row.id, row.timestamp); + } + + const subscription = await table.subscribe({ omitCurrent: true }); + + subscription.on('data', (event) => { + if (!event.id) return; + if (event.type === 'delete') { + cacheInvalidations.delete(event.id); + } else if (event.type === 'put' && event.value) { + cacheInvalidations.set(event.id, event.value.timestamp); + } + }); + + subscription.on('error', (error) => { + console.error('[CacheHandler] invalidation subscription error', error); + }); + } catch (error) { + // Reset so a future construction can retry — failure here means we lose + // cross-worker visibility, but the cache still works (just falls back to + // per-request revalidatedTags). + subscriptionInitialized = false; + console.error('[CacheHandler] failed to initialize invalidation subscription', error); + } +} + +function extractTags(data: IncrementalCacheValue | null, ctx: SetIncrementalFetchCacheContext | SetIncrementalResponseCacheContext): string[] { + if (!data) return []; + + // FETCH entries carry tags via ctx.tags (set context) and data.tags. + if ('fetchCache' in ctx && ctx.fetchCache && 'tags' in ctx && ctx.tags) { + return ctx.tags; + } + + // APP_PAGE / APP_ROUTE / PAGES carry tags via the NEXT_CACHE_TAGS_HEADER + // header that Next.js writes into the cached value. + const headers = (data as { headers?: Record }).headers; + const tagsHeader = headers?.[NEXT_CACHE_TAGS_HEADER]; + if (typeof tagsHeader === 'string' && tagsHeader.length > 0) { + return tagsHeader.split(',').map((t) => t.trim()).filter(Boolean); + } + + const dataTags = (data as { tags?: unknown }).tags; + if (Array.isArray(dataTags)) { + return dataTags.filter((t): t is string => typeof t === 'string'); + } + + return []; +} + +function isInvalidated( + recordTags: string[], + lastModified: number, + revalidatedTags: string[], + ctxTags: string[] +): boolean { + const allTags = recordTags.length > 0 ? recordTags : ctxTags; + for (const tag of allTags) { + if (revalidatedTags.includes(tag)) return true; + const invalidatedAt = cacheInvalidations.get(tag); + if (invalidatedAt !== undefined && invalidatedAt > lastModified) return true; + } + return false; +} + export default class HarperCacheHandler implements CacheHandler { - constructor() {} + private revalidatedTags: string[]; + + constructor(ctx?: CacheHandlerContext) { + this.revalidatedTags = ctx?.revalidatedTags ?? []; + void initializeSubscription(); + } async get( key: string, - _ctx: GetIncrementalFetchCacheContext | GetIncrementalResponseCacheContext + ctx: GetIncrementalFetchCacheContext | GetIncrementalResponseCacheContext ): Promise { const table = databases.harperfast_nextjs.nextjs_isr_cache; const record = await table.get(key); if (!record) return null; - try { - return { - value: record.data, - lastModified: record.lastModified, - }; - } catch { + const recordTags = + typeof record.tags === 'string' && record.tags.length > 0 + ? record.tags.split(',').filter(Boolean) + : []; + + const ctxTags = + 'tags' in ctx && Array.isArray(ctx.tags) + ? [...ctx.tags, ...(('softTags' in ctx && Array.isArray(ctx.softTags)) ? ctx.softTags : [])] + : []; + + if (isInvalidated(recordTags, record.lastModified ?? 0, this.revalidatedTags, ctxTags)) { return null; } + + return { + value: record.data as IncrementalCacheValue | null, + lastModified: record.lastModified, + }; } async set( key: string, data: IncrementalCacheValue | null, - _ctx: SetIncrementalFetchCacheContext | SetIncrementalResponseCacheContext + ctx: SetIncrementalFetchCacheContext | SetIncrementalResponseCacheContext ): Promise { const table = databases.harperfast_nextjs.nextjs_isr_cache; + const tags = extractTags(data, ctx); await table.put(key, { data, + tags: tags.join(','), }); } - async revalidateTag(_tag: string | string[]): Promise { - // TODO: implement tag-based invalidation + async revalidateTag(tags: string | string[]): Promise { + const tagList = typeof tags === 'string' ? [tags] : tags; + if (tagList.length === 0) return; + + const table = databases.harperfast_nextjs.nextjs_cache_invalidation; + const timestamp = Date.now(); + + // Update the local map immediately so reads on this worker see the + // invalidation without waiting for the subscription roundtrip. + for (const tag of tagList) { + cacheInvalidations.set(tag, timestamp); + } + + await Promise.all(tagList.map((tag) => table.put(tag, { timestamp }))); } resetRequestCache(): void {} From 3599f9f1f1c2f847c2856ed79e68a87be4638f39 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Thu, 7 May 2026 16:34:41 -0500 Subject: [PATCH 02/14] refactor: store cache tags as a real string array, not CSV The CSV form was a holdover from the original aeo pattern, where the hard-purge used a `contains` substring search on the column. With the hard-purge dropped, tags are only ever read in-process, so a real array is simpler. `NextISRCache.tags` is now `[String]`. `set()` writes the array directly; `get()` reads it directly. No `.join(',')` / `.split(',')` churn, and no risk of comma-in-tag mishandling. Co-Authored-By: Claude Opus 4.7 (1M context) --- schema.graphql | 2 +- src/CacheHandler.cts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/schema.graphql b/schema.graphql index c9b7dbb..e09937a 100644 --- a/schema.graphql +++ b/schema.graphql @@ -7,7 +7,7 @@ type NextBuildInfo @table(database: "harperfast_nextjs", table: "nextjs_build_in type NextISRCache @table(database: "harperfast_nextjs", table: "nextjs_isr_cache") { id: String @primaryKey data: Any - tags: String + tags: [String] lastModified: Long @updatedTime } diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index d18bdde..b5fdc6e 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -119,10 +119,7 @@ export default class HarperCacheHandler implements CacheHandler { const record = await table.get(key); if (!record) return null; - const recordTags = - typeof record.tags === 'string' && record.tags.length > 0 - ? record.tags.split(',').filter(Boolean) - : []; + const recordTags = Array.isArray(record.tags) ? (record.tags as string[]) : []; const ctxTags = 'tags' in ctx && Array.isArray(ctx.tags) @@ -148,7 +145,7 @@ export default class HarperCacheHandler implements CacheHandler { const tags = extractTags(data, ctx); await table.put(key, { data, - tags: tags.join(','), + tags, }); } From 103e7cec08bfe07a08fdb545d28c33276b590238 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Thu, 7 May 2026 16:45:01 -0500 Subject: [PATCH 03/14] docs: document cache tag invalidation in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Caching section was a stub flagged as WIP. Now that `revalidateTag()` is fully implemented end-to-end, document: - how to enable the handler - how tags propagate across the cluster (subscription on the invalidation table) - the soft-invalidation model — no hard-purge, natural overwrite on regeneration - the two tables added to `harperfast_nextjs` - current limitations (no `revalidatePath`, no group-based invalidation) Keeps the `experimentalHarperCache` caveat since the contract may still shift. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5c15c43..d5dffc5 100644 --- a/README.md +++ b/README.md @@ -183,23 +183,85 @@ The `files` option is now optional with plugins. This make configuration simpler Glob pattern specifying which files Harper should watch for changes. Example: `'/app/*'`. --> -## Caching (Work In Progress) +## Caching -> This custom caching handler is currently a WIP and is actively being developed. +> [!NOTE] +> The Harper cache handler is still gated behind `experimentalHarperCache`. The runtime behaviour described below is implemented, but the option name signals that the contract may evolve. + +`@harperfast/nextjs` includes a Harper-backed cache handler for Next.js [Incremental Static Regeneration (ISR)](https://nextjs.org/docs/app/guides/incremental-static-regeneration), the [Data Cache (`fetch()`)](https://nextjs.org/docs/app/deep-dive/caching#data-cache), and [`unstable_cache`](https://nextjs.org/docs/app/api-reference/functions/unstable_cache). Cached entries live in Harper instead of the worker's local filesystem, so a cache write on one node is visible to every node in the cluster. -`@harperfast/nextjs` includes a built-in cache handler for Next.js [Incremental Static Regeneration (ISR)](https://nextjs.org/docs/app/guides/incremental-static-regeneration). Instead of storing cached pages on the file system, cached data is stored in Harper's database, making it available across all nodes in your Harper cluster. +### Enabling -Enable it via the `experimentalHarperCache` option in [`withHarper()`](#withharper): +Set `experimentalHarperCache: true` in [`withHarper()`](#withharper): ```js -export default withHarper( - { - /* Next.js config */ +// next.config.mjs +import { withHarper } from '@harperfast/nextjs'; + +export default withHarper({}, { experimentalHarperCache: true }); +``` + +### Tag invalidation + +[`revalidateTag()`](https://nextjs.org/docs/app/api-reference/functions/revalidateTag) is supported and propagates across the cluster automatically. A typical flow: + +```js +// app/products/[id]/page.js +import { unstable_cache } from 'next/cache'; + +const getProduct = unstable_cache( + async (id) => { + const res = await fetch(`https://api.example.com/products/${id}`); + return res.json(); }, - { experimentalHarperCache: true } + ['product'], + { tags: ['products'], revalidate: 3600 } ); + +export default async function ProductPage({ params }) { + const product = await getProduct(params.id); + return

{product.name}

; +} +``` + +```js +// app/api/revalidate/route.js +import { revalidateTag } from 'next/cache'; +import { NextResponse } from 'next/server'; + +export async function POST(request) { + const tag = new URL(request.url).searchParams.get('tag'); + revalidateTag(tag); + return NextResponse.json({ revalidated: true }); +} ``` +`fetch()` calls with `next: { tags: [...] }` and the `'use cache'` directive (with `cacheTag()`) are also supported — anywhere Next.js attaches tags to a cached value, the handler will pick them up. + +### How invalidation works + +The cache handler uses a **soft-invalidation** model: + +1. `revalidateTag(tag)` writes a `{ tag, timestamp }` row to the `nextjs_cache_invalidation` table and updates an in-memory map in the calling worker. +2. Every other Harper worker subscribes to that table and updates its own map when the row is replicated — typically within milliseconds. +3. On the next `cache.get()`, if any of the cached entry's tags has an invalidation timestamp newer than the entry's `lastModified`, the handler returns `null` and Next.js regenerates the entry. The new write replaces the row with a fresh `lastModified`, naturally restoring "fresh" status. + +There is no background sweep that hard-deletes invalidated rows; stale rows are overwritten by Next.js the next time the entry is regenerated. The `nextjs_cache_invalidation` rows themselves expire after 7 days so abandoned tags don't accumulate. + +### Schema + +Enabling the cache handler adds two tables to the `harperfast_nextjs` database: + +| Table | Purpose | +| --- | --- | +| `nextjs_isr_cache` | One row per cached entry. Stores `data` (the Next.js `IncrementalCacheValue`), `tags` (the tags attached to the entry), and `lastModified`. | +| `nextjs_cache_invalidation` | One row per invalidated tag. `id` is the tag itself; `timestamp` is when `revalidateTag` was called. Auto-expires after 7 days. | + +### Limitations + +- `revalidatePath()` is not yet implemented. +- Group-based invalidation (revalidate everything in a logical bucket) is not exposed; tag the entries with a shared tag and call `revalidateTag()` instead. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). From c9f3f7b882d32492066add863fee3e28b49c808d Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Thu, 7 May 2026 16:54:19 -0500 Subject: [PATCH 04/14] fixup: revert next-16-caching next.config.mjs to its explicit form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier I replaced the fixture's hand-rolled cacheHandler config with `withHarper({}, { experimentalHarperCache: true })`. The motivation was fixing a real bug — the path pointed at `CacheHandler.js`, but tsc emits `CacheHandler.cjs` — but switching to withHarper was unnecessary. The fixture was deliberately written to exercise the cacheHandler config in isolation from withHarper's other behaviour, and listing `@harperfast/nextjs` in serverExternalPackages was intentional. withHarper does not add that entry. The commented turbopack.root note was also a deliberate placeholder. Reverts the file to its original shape, with the one-character correction (`.js` → `.cjs`) that was actually needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- fixtures/next-16-caching/next.config.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/fixtures/next-16-caching/next.config.mjs b/fixtures/next-16-caching/next.config.mjs index c943641..020d8b9 100644 --- a/fixtures/next-16-caching/next.config.mjs +++ b/fixtures/next-16-caching/next.config.mjs @@ -1,3 +1,9 @@ -import { withHarper } from '@harperfast/nextjs'; +import { join } from 'path'; -export default withHarper({}, { experimentalHarperCache: true }); +export default { + // turbopack: { + // root: '../../../../', + // }, + serverExternalPackages: ['harper', '@harperfast/nextjs'], + cacheHandler: join(import.meta.dirname, 'node_modules', '@harperfast', 'nextjs', 'dist', 'CacheHandler.cjs'), +}; From 33b9d72209e1b411867f3897624168cf7677f51b Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Thu, 7 May 2026 16:55:31 -0500 Subject: [PATCH 05/14] readme --- README.md | 4 ++-- schema.graphql | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d5dffc5..ceea645 100644 --- a/README.md +++ b/README.md @@ -183,10 +183,10 @@ The `files` option is now optional with plugins. This make configuration simpler Glob pattern specifying which files Harper should watch for changes. Example: `'/app/*'`. --> -## Caching +## Caching (Work In Progress) > [!NOTE] -> The Harper cache handler is still gated behind `experimentalHarperCache`. The runtime behaviour described below is implemented, but the option name signals that the contract may evolve. +> The Harper cache handler is still gated behind `experimentalHarperCache`. The runtime behavior described below is implemented, but the option name signals that the contract may evolve. `@harperfast/nextjs` includes a Harper-backed cache handler for Next.js [Incremental Static Regeneration (ISR)](https://nextjs.org/docs/app/guides/incremental-static-regeneration), the [Data Cache (`fetch()`)](https://nextjs.org/docs/app/deep-dive/caching#data-cache), and [`unstable_cache`](https://nextjs.org/docs/app/api-reference/functions/unstable_cache). Cached entries live in Harper instead of the worker's local filesystem, so a cache write on one node is visible to every node in the cluster. diff --git a/schema.graphql b/schema.graphql index e09937a..408b2c9 100644 --- a/schema.graphql +++ b/schema.graphql @@ -6,13 +6,12 @@ type NextBuildInfo @table(database: "harperfast_nextjs", table: "nextjs_build_in type NextISRCache @table(database: "harperfast_nextjs", table: "nextjs_isr_cache") { id: String @primaryKey - data: Any + data: String tags: [String] lastModified: Long @updatedTime } -type NextCacheInvalidation - @table(database: "harperfast_nextjs", table: "nextjs_cache_invalidation", expiration: 604800) { +type NextCacheInvalidation @table(database: "harperfast_nextjs", table: "nextjs_cache_invalidation", expiration: 604800) { id: String @primaryKey timestamp: Long } From 3d6a5ddf3a1a59a56e0f17a3b3c1ffcdc325f9db Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Mon, 11 May 2026 11:30:48 -0500 Subject: [PATCH 06/14] fix: load harper lazily in CacheHandler to avoid worker conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Next.js cacheHandler module is loaded by Next.js itself (via `require()` on the `cacheHandler` config path), not by Harper. With turbopack, that load happens inside a build worker thread. Importing `harper` at the top of CacheHandler.cts ran harper's module initialization in that worker thread, which tried to register native worker hooks process-wide — conflicting with the same registration already done by the Harper main process. The result was a stream of "Worker creator already registered" uncaught exceptions; the HTTP worker kept restarting until Harper gave up (`Thread has been restarted undefined times and will not be restarted`), which manifested in tests as the fixture timing out before Harper reached ready. Switch to the same pattern `plugin.ts` uses: import `harper` for types only, and read `databases` from `globalThis` at call time. The module now loads cleanly in any context, and methods short-circuit (return null / no-op) if `databases` isn't available (i.e. we're in a context without Harper globals — which means there's nothing useful we could do anyway). Verified locally: every integration test file passes individually (19/19 tests across all six fixtures including the four new caching tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CacheHandler.cts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index b5fdc6e..5be10c7 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -12,7 +12,7 @@ import type { SetIncrementalResponseCacheContext, } from 'next/dist/server/response-cache/index.d.ts'; -import { databases } from 'harper'; +import type { databases as DatabasesType } from 'harper'; const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; @@ -23,8 +23,19 @@ const cacheInvalidations = new Map(); let subscriptionInitialized = false; +// `databases` is a Harper-provided global. Access it lazily so that loading +// this module from a non-Harper context (e.g. a turbopack build worker that +// resolves the cacheHandler path) does not pull in the harper runtime — which +// would register native worker hooks a second time and crash with +// "Worker creator already registered". +function getDatabases(): typeof DatabasesType | undefined { + return (globalThis as { databases?: typeof DatabasesType }).databases; +} + async function initializeSubscription(): Promise { if (subscriptionInitialized) return; + const databases = getDatabases(); + if (!databases) return; subscriptionInitialized = true; // Harper's TypeScript types require RequestTarget/SubscriptionRequest objects, @@ -115,6 +126,9 @@ export default class HarperCacheHandler implements CacheHandler { key: string, ctx: GetIncrementalFetchCacheContext | GetIncrementalResponseCacheContext ): Promise { + const databases = getDatabases(); + if (!databases) return null; + const table = databases.harperfast_nextjs.nextjs_isr_cache; const record = await table.get(key); if (!record) return null; @@ -141,6 +155,9 @@ export default class HarperCacheHandler implements CacheHandler { data: IncrementalCacheValue | null, ctx: SetIncrementalFetchCacheContext | SetIncrementalResponseCacheContext ): Promise { + const databases = getDatabases(); + if (!databases) return; + const table = databases.harperfast_nextjs.nextjs_isr_cache; const tags = extractTags(data, ctx); await table.put(key, { @@ -153,6 +170,9 @@ export default class HarperCacheHandler implements CacheHandler { const tagList = typeof tags === 'string' ? [tags] : tags; if (tagList.length === 0) return; + const databases = getDatabases(); + if (!databases) return; + const table = databases.harperfast_nextjs.nextjs_cache_invalidation; const timestamp = Date.now(); From e8ac8caf0f1f0d00cb9cd7584fa1353a270755df Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Tue, 12 May 2026 12:07:33 -0500 Subject: [PATCH 07/14] chore: commit dist/ for git-install testing purposes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEMPORARY — do not merge. Lets the customer install @harperfast/nextjs directly from this branch via github:HarperFast/nextjs#feature-cache-tags without needing a prepare script or a published npm release. Revert before merging to main. Co-Authored-By: Claude Opus 4.7 (1M context) --- dist/CacheHandler.cjs | 141 ++++++++++++++++ dist/CacheHandler.cjs.map | 1 + dist/CacheHandler.d.cts | 10 ++ dist/plugin.d.ts | 2 + dist/plugin.js | 334 ++++++++++++++++++++++++++++++++++++++ dist/plugin.js.map | 1 + dist/withHarper.cjs | 35 ++++ dist/withHarper.cjs.map | 1 + dist/withHarper.d.cts | 5 + 9 files changed, 530 insertions(+) create mode 100644 dist/CacheHandler.cjs create mode 100644 dist/CacheHandler.cjs.map create mode 100644 dist/CacheHandler.d.cts create mode 100644 dist/plugin.d.ts create mode 100644 dist/plugin.js create mode 100644 dist/plugin.js.map create mode 100644 dist/withHarper.cjs create mode 100644 dist/withHarper.cjs.map create mode 100644 dist/withHarper.d.cts diff --git a/dist/CacheHandler.cjs b/dist/CacheHandler.cjs new file mode 100644 index 0000000..d42fb7a --- /dev/null +++ b/dist/CacheHandler.cjs @@ -0,0 +1,141 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; +// Map of tag → invalidation timestamp (ms). Hydrated from the +// nextjs_cache_invalidation table on first construction and kept fresh via a +// Harper subscription so any worker observes invalidations from any other. +const cacheInvalidations = new Map(); +let subscriptionInitialized = false; +// `databases` is a Harper-provided global. Access it lazily so that loading +// this module from a non-Harper context (e.g. a turbopack build worker that +// resolves the cacheHandler path) does not pull in the harper runtime — which +// would register native worker hooks a second time and crash with +// "Worker creator already registered". +function getDatabases() { + return globalThis.databases; +} +async function initializeSubscription() { + if (subscriptionInitialized) + return; + const databases = getDatabases(); + if (!databases) + return; + subscriptionInitialized = true; + // Harper's TypeScript types require RequestTarget/SubscriptionRequest objects, + // but the runtime accepts plain object literals (and search() accepts no args). + const table = databases.harperfast_nextjs.nextjs_cache_invalidation; + try { + for await (const row of table.search()) { + cacheInvalidations.set(row.id, row.timestamp); + } + const subscription = await table.subscribe({ omitCurrent: true }); + subscription.on('data', (event) => { + if (!event.id) + return; + if (event.type === 'delete') { + cacheInvalidations.delete(event.id); + } + else if (event.type === 'put' && event.value) { + cacheInvalidations.set(event.id, event.value.timestamp); + } + }); + subscription.on('error', (error) => { + console.error('[CacheHandler] invalidation subscription error', error); + }); + } + catch (error) { + // Reset so a future construction can retry — failure here means we lose + // cross-worker visibility, but the cache still works (just falls back to + // per-request revalidatedTags). + subscriptionInitialized = false; + console.error('[CacheHandler] failed to initialize invalidation subscription', error); + } +} +function extractTags(data, ctx) { + if (!data) + return []; + // FETCH entries carry tags via ctx.tags (set context) and data.tags. + if ('fetchCache' in ctx && ctx.fetchCache && 'tags' in ctx && ctx.tags) { + return ctx.tags; + } + // APP_PAGE / APP_ROUTE / PAGES carry tags via the NEXT_CACHE_TAGS_HEADER + // header that Next.js writes into the cached value. + const headers = data.headers; + const tagsHeader = headers?.[NEXT_CACHE_TAGS_HEADER]; + if (typeof tagsHeader === 'string' && tagsHeader.length > 0) { + return tagsHeader.split(',').map((t) => t.trim()).filter(Boolean); + } + const dataTags = data.tags; + if (Array.isArray(dataTags)) { + return dataTags.filter((t) => typeof t === 'string'); + } + return []; +} +function isInvalidated(recordTags, lastModified, revalidatedTags, ctxTags) { + const allTags = recordTags.length > 0 ? recordTags : ctxTags; + for (const tag of allTags) { + if (revalidatedTags.includes(tag)) + return true; + const invalidatedAt = cacheInvalidations.get(tag); + if (invalidatedAt !== undefined && invalidatedAt > lastModified) + return true; + } + return false; +} +class HarperCacheHandler { + revalidatedTags; + constructor(ctx) { + this.revalidatedTags = ctx?.revalidatedTags ?? []; + void initializeSubscription(); + } + async get(key, ctx) { + const databases = getDatabases(); + if (!databases) + return null; + const table = databases.harperfast_nextjs.nextjs_isr_cache; + const record = await table.get(key); + if (!record) + return null; + const recordTags = Array.isArray(record.tags) ? record.tags : []; + const ctxTags = 'tags' in ctx && Array.isArray(ctx.tags) + ? [...ctx.tags, ...(('softTags' in ctx && Array.isArray(ctx.softTags)) ? ctx.softTags : [])] + : []; + if (isInvalidated(recordTags, record.lastModified ?? 0, this.revalidatedTags, ctxTags)) { + return null; + } + return { + value: record.data, + lastModified: record.lastModified, + }; + } + async set(key, data, ctx) { + const databases = getDatabases(); + if (!databases) + return; + const table = databases.harperfast_nextjs.nextjs_isr_cache; + const tags = extractTags(data, ctx); + await table.put(key, { + data, + tags, + }); + } + async revalidateTag(tags) { + const tagList = typeof tags === 'string' ? [tags] : tags; + if (tagList.length === 0) + return; + const databases = getDatabases(); + if (!databases) + return; + const table = databases.harperfast_nextjs.nextjs_cache_invalidation; + const timestamp = Date.now(); + // Update the local map immediately so reads on this worker see the + // invalidation without waiting for the subscription roundtrip. + for (const tag of tagList) { + cacheInvalidations.set(tag, timestamp); + } + await Promise.all(tagList.map((tag) => table.put(tag, { timestamp }))); + } + resetRequestCache() { } +} +exports.default = HarperCacheHandler; +//# sourceMappingURL=CacheHandler.cjs.map \ No newline at end of file diff --git a/dist/CacheHandler.cjs.map b/dist/CacheHandler.cjs.map new file mode 100644 index 0000000..31fc621 --- /dev/null +++ b/dist/CacheHandler.cjs.map @@ -0,0 +1 @@ +{"version":3,"file":"CacheHandler.cjs","sourceRoot":"","sources":["../src/CacheHandler.cts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/CacheHandler.d.cts b/dist/CacheHandler.d.cts new file mode 100644 index 0000000..9828589 --- /dev/null +++ b/dist/CacheHandler.d.cts @@ -0,0 +1,10 @@ +import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from 'next/dist/server/lib/incremental-cache/index.d.ts'; +import type { IncrementalCacheValue, GetIncrementalFetchCacheContext, GetIncrementalResponseCacheContext, SetIncrementalFetchCacheContext, SetIncrementalResponseCacheContext } from 'next/dist/server/response-cache/index.d.ts'; +export default class HarperCacheHandler implements CacheHandler { + private revalidatedTags; + constructor(ctx?: CacheHandlerContext); + get(key: string, ctx: GetIncrementalFetchCacheContext | GetIncrementalResponseCacheContext): Promise; + set(key: string, data: IncrementalCacheValue | null, ctx: SetIncrementalFetchCacheContext | SetIncrementalResponseCacheContext): Promise; + revalidateTag(tags: string | string[]): Promise; + resetRequestCache(): void; +} diff --git a/dist/plugin.d.ts b/dist/plugin.d.ts new file mode 100644 index 0000000..a6a8da6 --- /dev/null +++ b/dist/plugin.d.ts @@ -0,0 +1,2 @@ +import type { Scope } from 'harper'; +export declare function handleApplication(scope: Scope): Promise; diff --git a/dist/plugin.js b/dist/plugin.js new file mode 100644 index 0000000..c262423 --- /dev/null +++ b/dist/plugin.js @@ -0,0 +1,334 @@ +import { createRequire } from 'node:module'; +import { parse as urlParse } from 'node:url'; +import { join } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +// Bringing this forward from extension since some validation is better than none. +// Eventually can remove when plugins have better option validation from core. +/** + * Assert that a given option is a specific type, if it is defined. + */ +function assertType(name, option, expectedType) { + if (option && typeof option !== expectedType) { + throw new Error(`${name} must be type ${expectedType}. Received: ${typeof option}`); + } +} +/** + * Validates and resolve plugin options with sensible defaults. + */ +function resolveConfig(scope) { + const options = scope.options.getAll(); + if (options === null || Array.isArray(options) || typeof options !== 'object') { + throw new Error('@harperfast/nextjs plugin options should be a regular object'); + } + // Environment Variables take precedence + switch (process.env.HARPER_NEXTJS_MODE) { + case 'dev': + options.dev = true; + break; + case 'build': + options.buildOnly = true; + options.dev = false; + options.prebuilt = false; + break; + case 'prod': + options.dev = false; + break; + default: + break; + } + assertType('buildOnly', options.buildOnly, 'boolean'); + assertType('bundler', options.bundler, 'string'); + assertType('dev', options.dev, 'boolean'); + assertType('port', options.port, 'number'); + assertType('prebuilt', options.prebuilt, 'boolean'); + assertType('runFirst', options.runFirst, 'boolean'); + assertType('securePort', options.securePort, 'number'); + if (options.bundler && options.bundler !== 'webpack' && options.bundler !== 'turbopack') { + throw new Error(`bundler must be "webpack" or "turbopack". Received: "${options.bundler}"`); + } + // TODO: Remove type casts when we have more proper plugin option validation from core + return { + buildOnly: options.buildOnly ?? false, + // bundler default is set later in handleApplication() based on the detected Next.js version + bundler: options.bundler, + dev: options.dev ?? false, + // @ts-expect-error + files: options.files, + port: options.port, + prebuilt: options.prebuilt ?? false, + runFirst: options.runFirst ?? false, + securePort: options.securePort, + }; +} +function assertNextApp({ appName, directory, logger }) { + logger.debug?.(`Verifying ${directory} is a Next.js application`); + // Couple options to check if its a Next.js project + // 1. Check for Next.js config file (next.config.{js|mjs|ts}) + // - This file is not required for a Next.js project + // 2. Check package.json for Next.js dependency + // - It could be listed in `dependencies` or `devDependencies` (and maybe even `peerDependencies` or `optionalDependencies`) + // - Also not required. Users can use `npx next ...` + // 3. Check for `.next` folder + // - This could be a reasonable fallback if we want to support pre-built Next.js apps + // A combination of options 1 and 2 should be sufficient for our purposes. + // Known Edge case: app does not have a config and are using `npx` (or something similar) to execute Next.js + // Check for Next.js Config + const configExists = ['js', 'mjs', 'ts'].some((ext) => existsSync(join(directory, `next.config.${ext}`))); + // Check for dependency + let dependencyExists = false; + const packageJSONPath = join(directory, 'package.json'); + if (existsSync(packageJSONPath)) { + const packageJSON = JSON.parse(readFileSync(packageJSONPath, 'utf8')); + dependencyExists = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].some((dependencyList) => packageJSON[dependencyList]?.['next']); + } + if (!configExists && !dependencyExists) { + logger.fatal?.(`Failed to verify ${appName} application as a Next.js project. It is missing both a Next.js config file and the "next" dependency in package.json`); + return false; + } + return true; +} +/** + * Safely attempts to read the `.next/BUILD_ID`. + * + * Returns `null` if it does not exist (empty BUILD_ID file or file does not exist) + */ +function getBuildId(scope) { + const buildIdPath = join(scope.directory, '.next', 'BUILD_ID'); + try { + const buildId = readFileSync(buildIdPath, 'utf-8').trim(); + return buildId || null; + } + catch (error) { + // Ignore ENOENT errors (.next/BUILD_ID does not exist) + // @ts-expect-error + if (error.code === 'ENOENT') { + return null; + } + // Otherwise rethrow error + throw error; + } +} +export async function handleApplication(scope) { + scope.logger.debug?.(`Handling Next.js Application ${scope.appName} as ${scope.directory}`); + const config = resolveConfig(scope); + // TODO: delegate asserting the app to the Next.js build/server functions instead. + if (!assertNextApp(scope)) { + return; + } + // The original idea here was to use the file change detection mechanism to make rebuilds smarter. + // Specifically, if the plugin detects application file changes, then it should rebuild immediately + // and _then_ restart the threads. This would then let the user skip building after threads restart. + // Unfortunately, with the time based mechanism below I don't think this is as deterministic and must + // be thought through again. Additionally this was not as simple as originally thought. Unless the user + // knows to finely tune the `files` option, what exactly should Harper watch automatically? Surely not + // _everything_ in an application directory. Including things like `node_modules` would be impossible too. + // So just leave this artifact here for a future feature improvement. + // // If files for the next.js app change, we want to mark the build as stale and request a restart + // // this way when the threads restart, and see the existing `.next/BUILD_ID` file, they will still rebuild the app. + // async function entryHandler (entry: FileEntryEvent | DirectoryEntryEvent) { + // scope.logger.debug?.(`Entry Handler called`, entry) + // await databases.harperfast_nextjs.nextjs_build_info.put(scope.appName, { buildId: null, status: 'stale' }); + // scope.requestRestart(); + // } + // if (config.files) { + // // If the user specified files then use the default handler + // scope.handleEntry(entryHandler); + // } else { + // // Otherwise define our own. + // scope.handleEntry({ + // source: '**/*', + // ignore: ['.next/**/*', 'node_modules/**/*'] + // }, entryHandler); + // } + const next = importNext(scope); + // Set the bundler default based on the detected Next.js version if not explicitly configured. + // Next.js v16 defaults to turbopack; v14 and v15 default to webpack. + if (!config.bundler) { + config.bundler = next.version >= 16 ? 'turbopack' : 'webpack'; + } + if (config.bundler === 'turbopack' && next.version === 14) { + scope.logger.error?.('Turbopack is not supported for Next.js v14. Falling back to webpack.'); + config.bundler = 'webpack'; + } + scope.logger.debug?.(`Detected Next.js version: ${next.version}`); + if (config.prebuilt) { + // TODO: implement record check to skip-over following checks + // get record by appName + // - if it exists && within 500ms(?) + // - if success goto serve + // - if failure log and return early + // - if stale ??? (how do we get here?) (maybe with time-based we don't have stale anymore?) + // - else continue with below logic + // - if valid, write success record and goto serve + // - if invalid, write failure record and log and return + const nextDir = join(scope.directory, '.next'); + if (!existsSync(nextDir)) { + scope.logger.error?.('Prebuilt mode is enabled, but the .next folder does not exist'); + return; + } + if (!existsSync(join(nextDir, 'BUILD_ID'))) { + scope.logger.error?.('Prebuilt mode is enabled, but the .next/BUILD_ID file does not exist'); + return; + } + // In prebuilt mode, we still want to ensure the build is valid by checking for a `buildId`. + // We shouldn't try serving (and failing) if we can detect a potentially bad build. + // This is based on the assumption that a BUILD_ID file only exists for valid builds; not sure + // if that is 100% true or if Next.js provides any other guarantees or validation mechanisms. + const buildId = getBuildId(scope); + // Immediately set the build info record appropriately + await databases.harperfast_nextjs.nextjs_build_info.put(scope.appName, { + buildId, + status: buildId !== null ? 'success' : 'failure', + }); + if (buildId === null) { + return; + } + } + else if (!config.dev) { + // If not prebuilt mode and not dev mode, then proceed to building + try { + await build(scope, config, next); + // In build only we can exit and return early here. + if (config.buildOnly) { + scope.logger.info?.('buildOnly mode is enabled, exiting'); + // TODO: should harper expose a like `scope.shutdown()` method or something that "safely" exits? + process.exit(0); + } + } + catch (error) { + // if build fails for any reason + // mark record as failure, log error, and return + await databases.harperfast_nextjs.nextjs_build_info.put(scope.appName, { buildId: null, status: 'failure' }); + scope.logger.error?.(`Error building Next.js application ${scope.appName}: `, error); + return; + } + } + // Finally, serve the application + await serve(scope, config, next); +} +async function build(scope, config, next) { + const buildInfo = await databases.harperfast_nextjs.nextjs_build_info.get(scope.appName); + if (buildInfo && Date.now() - buildInfo.getUpdatedTime() < 5000) { + // If the build info record is marked as "failure" just return immediately + // avoids building (and failing) on every thread + if (buildInfo.status === 'failure') { + scope.logger.debug?.(`Failure build of ${scope.appName} detected`); + return; + } + // If the build info record is marked as "success" + if (buildInfo.status === 'success') { + // then validate the BUILD_ID value + const buildId = getBuildId(scope); + if (buildId === buildInfo.buildId) { + scope.logger.debug?.(`Fresh build of ${scope.appName} (id: ${buildInfo.buildId}) detected`); + // fresh build + return; + } + } + } + // Otherwise we have a stale build (or no build info at all) and now we can proceed with building + scope.logger.debug?.(`Building Next.js application at ${scope.directory}`); + // --expose-internals is set in Harper's worker execArgv but is not allowed in NODE_OPTIONS. + // Next.js reads process.execArgv to forward flags to its own child workers via NODE_OPTIONS, + // which causes Node to reject the build worker. Strip it before building and restore after. + const exposeInternalsIdx = process.execArgv.indexOf('--expose-internals'); + if (exposeInternalsIdx !== -1) + process.execArgv.splice(exposeInternalsIdx, 1); + try { + switch (next.version) { + case 14: + await next.build({ + lint: false, + mangling: true, + experimentalDebugMemoryUsage: false, + experimentalBuildMode: 'default', + }, scope.directory); + break; + case 15: + await next.build({ + lint: false, + mangling: true, + ...(config.bundler === 'turbopack' && { turbopack: true }), + experimentalDebugMemoryUsage: false, + experimentalBuildMode: 'default', + }, scope.directory); + break; + case 16: + await next.build({ + mangling: true, + ...(config.bundler === 'webpack' && { webpack: true }), + experimentalDebugMemoryUsage: false, + experimentalBuildMode: 'default', + }, scope.directory); + break; + } + const buildIdPath = join(scope.directory, '.next', 'BUILD_ID'); + const buildId = readFileSync(buildIdPath, 'utf-8'); + // Update the build info record + await databases.harperfast_nextjs.nextjs_build_info.put(scope.appName, { buildId, status: 'success' }); + scope.logger.debug?.(`Successful build for ${scope.appName} (id ${buildId})`); + return; + } + catch (error) { + await databases.harperfast_nextjs.nextjs_build_info.put(scope.appName, { buildId: null, status: 'failure' }); + scope.logger.debug?.(`Error building ${scope.appName}`); + throw error; + } + finally { + if (exposeInternalsIdx !== -1) + process.execArgv.splice(exposeInternalsIdx, 0, '--expose-internals'); + } +} +async function serve(scope, config, next) { + scope.logger.debug?.(`Serving Next.js application at ${scope.directory}`); + let app; + switch (next.version) { + case 14: + app = next.server({ dir: scope.directory, dev: config.dev }); + break; + case 15: + app = next.server({ dir: scope.directory, dev: config.dev, ...(config.bundler === 'turbopack' && { turbopack: true }) }); + break; + case 16: + app = next.server({ dir: scope.directory, dev: config.dev, ...(config.bundler === 'webpack' && { webpack: true }) }); + break; + } + await app.prepare(); + const requestHandler = app.getRequestHandler(); + scope.server?.http?.((request, next) => { + return request._nodeResponse === undefined + ? next(request) + : // @ts-expect-error - Not sure when the IncomingMessage.url could be undefined ; need to dig into it. + requestHandler(request._nodeRequest, request._nodeResponse, urlParse(request._nodeRequest.url, true)); + }, { runFirst: config.runFirst, port: config.port, securePort: config.securePort }); + // Early Next.js versions don't have an upgrade handler + if (config.dev && app.getUpgradeHandler) { + const upgradeHandler = app.getUpgradeHandler(); + scope.server?.upgrade?.(async (request, socket, head, next) => { + if (request.url === '/_next/webpack-hmr') { + // Next.js v13+ upgradeHandler implementations return promises + await upgradeHandler(request._nodeRequest, socket, head); + request.__harperRequestUpgraded = true; + return await next(request, socket, head); + } + return next(request, socket, head); + }, + // Okay to set `runFirst: true` here since this has a strict match on `/_next/webpack-hmr` + { runFirst: true, port: config.port, securePort: config.securePort }); + } +} +// This function imports the Next.js version specified by the application +function importNext(scope) { + const require = createRequire(join(scope.directory, 'package.json')); + const nextPackage = require('next/package.json'); + const version = parseInt(nextPackage.version.split('.')[0], 10); + if (version !== 14 && version !== 15 && version !== 16) { + throw new Error(`Unsupported Next.js version detected: ${nextPackage.version}. The \`@harperfast/nextjs\` plugin only supports Next.js versions: 14, 15, 16`); + } + // The default export is the `createServer` function + const server = require('next'); + // Use the `nextBuild` method + const build = require('next/dist/cli/next-build.js').nextBuild; + return { server, build, version }; +} +//# sourceMappingURL=plugin.js.map \ No newline at end of file diff --git a/dist/plugin.js.map b/dist/plugin.js.map new file mode 100644 index 0000000..a152821 --- /dev/null +++ b/dist/plugin.js.map @@ -0,0 +1 @@ +{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/withHarper.cjs b/dist/withHarper.cjs new file mode 100644 index 0000000..e3bc2c0 --- /dev/null +++ b/dist/withHarper.cjs @@ -0,0 +1,35 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.withHarper = withHarper; +const node_path_1 = require("node:path"); +function withHarper(config, harperConfig = {}) { + const { experimentalHarperCache = false } = harperConfig; + // TODO: Do things like `serverExternalPackage` work with Next.js v14? If not, how can we + // detect version reliably and apply? What if we added properties specific to v14? Would + // they be okay with v15 and v16 or do this all need to be guarded? + // Potential solution: To avoid version detection (if thats complicated), add a `version` + // option or provide separate exports for each unique Next.js major. Something like: + // `withHarperNext14()` or `withHarper({}, {}, 14)` + // TODO: We should inspect the Next.js config for properties such as `turbo` and then apply + // specific options when present. I think things like `serverExternalPackages` used to be + // `webpack` and thus maybe theres separate configuration based on the selected bundler. + // But also this means resolving turbopack support in the plugin which is currently proving + // difficult. + return { + ...config, + webpack: (config) => { + config.externals.push({ + 'harperdb': 'commonjs harperdb', + 'harper': 'commonjs harper', + 'harper-pro': 'commonjs harper-pro', + }); + return config; + }, + turbopack: { + ...config.turbopack, + }, + serverExternalPackages: [...(config.serverExternalPackages ?? []), 'harperdb', 'harper', 'harper-pro'], + ...(experimentalHarperCache && { cacheHandler: (0, node_path_1.join)(__dirname, 'CacheHandler.cjs') }), + }; +} +//# sourceMappingURL=withHarper.cjs.map \ No newline at end of file diff --git a/dist/withHarper.cjs.map b/dist/withHarper.cjs.map new file mode 100644 index 0000000..185afeb --- /dev/null +++ b/dist/withHarper.cjs.map @@ -0,0 +1 @@ +{"version":3,"file":"withHarper.cjs","sourceRoot":"","sources":["../src/withHarper.cts"],"names":[],"mappings":";;AAOA,gCAiCC;AAxCD,yCAAiC;AAOjC,SAAgB,UAAU,CAAC,MAAkB,EAAE,eAA6B,EAAE;IAC7E,MAAM,EAAE,uBAAuB,GAAG,KAAK,EAAE,GAAG,YAAY,CAAC;IAEzD,yFAAyF;IACzF,wFAAwF;IACxF,mEAAmE;IACnE,yFAAyF;IACzF,oFAAoF;IACpF,mDAAmD;IAEnD,2FAA2F;IAC3F,yFAAyF;IACzF,wFAAwF;IACxF,2FAA2F;IAC3F,aAAa;IAEb,OAAO;QACN,GAAG,MAAM;QACT,OAAO,EAAE,CAAC,MAAM,EAAE,EAAE;YACnB,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC;gBACrB,UAAU,EAAE,mBAAmB;gBAC/B,QAAQ,EAAE,iBAAiB;gBAC3B,YAAY,EAAE,qBAAqB;aACnC,CAAC,CAAC;YAEH,OAAO,MAAM,CAAC;QACf,CAAC;QACD,SAAS,EAAE;YACV,GAAG,MAAM,CAAC,SAAS;SACnB;QACD,sBAAsB,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,sBAAsB,IAAI,EAAE,CAAC,EAAE,UAAU,EAAE,QAAQ,EAAE,YAAY,CAAC;QACtG,GAAG,CAAC,uBAAuB,IAAI,EAAE,YAAY,EAAE,IAAA,gBAAI,EAAC,SAAS,EAAE,kBAAkB,CAAC,EAAE,CAAC;KACrF,CAAC;AACH,CAAC"} \ No newline at end of file diff --git a/dist/withHarper.d.cts b/dist/withHarper.d.cts new file mode 100644 index 0000000..c0f7a59 --- /dev/null +++ b/dist/withHarper.d.cts @@ -0,0 +1,5 @@ +import type { NextConfig } from 'next'; +export interface HarperConfig { + experimentalHarperCache?: boolean; +} +export declare function withHarper(config: NextConfig, harperConfig?: HarperConfig): NextConfig; From 0d61fb57911ab6b7a64123e6288ea3b9ff197102 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Tue, 12 May 2026 15:27:22 -0500 Subject: [PATCH 08/14] 2.1.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7cc7778..56afbab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@harperfast/nextjs", - "version": "2.1.0", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@harperfast/nextjs", - "version": "2.1.0", + "version": "2.1.1", "license": "Apache-2.0", "devDependencies": { "@harperfast/integration-testing": "0.3.0", diff --git a/package.json b/package.json index 40a699e..ba25648 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@harperfast/nextjs", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "description": "A Harper plugin for running Next.js apps.", "keywords": [ From 68bb9f519d199dd27ee74149bee532ce646e09bb Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Tue, 12 May 2026 15:29:02 -0500 Subject: [PATCH 09/14] 2.1.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 56afbab..ad106db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@harperfast/nextjs", - "version": "2.1.1", + "version": "2.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@harperfast/nextjs", - "version": "2.1.1", + "version": "2.1.2", "license": "Apache-2.0", "devDependencies": { "@harperfast/integration-testing": "0.3.0", diff --git a/package.json b/package.json index ba25648..1e80fd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@harperfast/nextjs", - "version": "2.1.1", + "version": "2.1.2", "type": "module", "description": "A Harper plugin for running Next.js apps.", "keywords": [ From 42d4f5cdd467ed14185b37553bd7b9baf8df4e58 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Tue, 12 May 2026 16:03:02 -0500 Subject: [PATCH 10/14] fix version tag --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1e80fd9..ba25648 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@harperfast/nextjs", - "version": "2.1.2", + "version": "2.1.1", "type": "module", "description": "A Harper plugin for running Next.js apps.", "keywords": [ From aa96f4a096d424fa91a28a0a9e1b8261b2514fc9 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Wed, 13 May 2026 10:16:39 -0500 Subject: [PATCH 11/14] revert dist --- dist/CacheHandler.cjs | 141 ---------------- dist/CacheHandler.cjs.map | 1 - dist/CacheHandler.d.cts | 10 -- dist/plugin.d.ts | 2 - dist/plugin.js | 334 -------------------------------------- dist/plugin.js.map | 1 - dist/withHarper.cjs | 35 ---- dist/withHarper.cjs.map | 1 - dist/withHarper.d.cts | 5 - 9 files changed, 530 deletions(-) delete mode 100644 dist/CacheHandler.cjs delete mode 100644 dist/CacheHandler.cjs.map delete mode 100644 dist/CacheHandler.d.cts delete mode 100644 dist/plugin.d.ts delete mode 100644 dist/plugin.js delete mode 100644 dist/plugin.js.map delete mode 100644 dist/withHarper.cjs delete mode 100644 dist/withHarper.cjs.map delete mode 100644 dist/withHarper.d.cts diff --git a/dist/CacheHandler.cjs b/dist/CacheHandler.cjs deleted file mode 100644 index d42fb7a..0000000 --- a/dist/CacheHandler.cjs +++ /dev/null @@ -1,141 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags'; -// Map of tag → invalidation timestamp (ms). Hydrated from the -// nextjs_cache_invalidation table on first construction and kept fresh via a -// Harper subscription so any worker observes invalidations from any other. -const cacheInvalidations = new Map(); -let subscriptionInitialized = false; -// `databases` is a Harper-provided global. Access it lazily so that loading -// this module from a non-Harper context (e.g. a turbopack build worker that -// resolves the cacheHandler path) does not pull in the harper runtime — which -// would register native worker hooks a second time and crash with -// "Worker creator already registered". -function getDatabases() { - return globalThis.databases; -} -async function initializeSubscription() { - if (subscriptionInitialized) - return; - const databases = getDatabases(); - if (!databases) - return; - subscriptionInitialized = true; - // Harper's TypeScript types require RequestTarget/SubscriptionRequest objects, - // but the runtime accepts plain object literals (and search() accepts no args). - const table = databases.harperfast_nextjs.nextjs_cache_invalidation; - try { - for await (const row of table.search()) { - cacheInvalidations.set(row.id, row.timestamp); - } - const subscription = await table.subscribe({ omitCurrent: true }); - subscription.on('data', (event) => { - if (!event.id) - return; - if (event.type === 'delete') { - cacheInvalidations.delete(event.id); - } - else if (event.type === 'put' && event.value) { - cacheInvalidations.set(event.id, event.value.timestamp); - } - }); - subscription.on('error', (error) => { - console.error('[CacheHandler] invalidation subscription error', error); - }); - } - catch (error) { - // Reset so a future construction can retry — failure here means we lose - // cross-worker visibility, but the cache still works (just falls back to - // per-request revalidatedTags). - subscriptionInitialized = false; - console.error('[CacheHandler] failed to initialize invalidation subscription', error); - } -} -function extractTags(data, ctx) { - if (!data) - return []; - // FETCH entries carry tags via ctx.tags (set context) and data.tags. - if ('fetchCache' in ctx && ctx.fetchCache && 'tags' in ctx && ctx.tags) { - return ctx.tags; - } - // APP_PAGE / APP_ROUTE / PAGES carry tags via the NEXT_CACHE_TAGS_HEADER - // header that Next.js writes into the cached value. - const headers = data.headers; - const tagsHeader = headers?.[NEXT_CACHE_TAGS_HEADER]; - if (typeof tagsHeader === 'string' && tagsHeader.length > 0) { - return tagsHeader.split(',').map((t) => t.trim()).filter(Boolean); - } - const dataTags = data.tags; - if (Array.isArray(dataTags)) { - return dataTags.filter((t) => typeof t === 'string'); - } - return []; -} -function isInvalidated(recordTags, lastModified, revalidatedTags, ctxTags) { - const allTags = recordTags.length > 0 ? recordTags : ctxTags; - for (const tag of allTags) { - if (revalidatedTags.includes(tag)) - return true; - const invalidatedAt = cacheInvalidations.get(tag); - if (invalidatedAt !== undefined && invalidatedAt > lastModified) - return true; - } - return false; -} -class HarperCacheHandler { - revalidatedTags; - constructor(ctx) { - this.revalidatedTags = ctx?.revalidatedTags ?? []; - void initializeSubscription(); - } - async get(key, ctx) { - const databases = getDatabases(); - if (!databases) - return null; - const table = databases.harperfast_nextjs.nextjs_isr_cache; - const record = await table.get(key); - if (!record) - return null; - const recordTags = Array.isArray(record.tags) ? record.tags : []; - const ctxTags = 'tags' in ctx && Array.isArray(ctx.tags) - ? [...ctx.tags, ...(('softTags' in ctx && Array.isArray(ctx.softTags)) ? ctx.softTags : [])] - : []; - if (isInvalidated(recordTags, record.lastModified ?? 0, this.revalidatedTags, ctxTags)) { - return null; - } - return { - value: record.data, - lastModified: record.lastModified, - }; - } - async set(key, data, ctx) { - const databases = getDatabases(); - if (!databases) - return; - const table = databases.harperfast_nextjs.nextjs_isr_cache; - const tags = extractTags(data, ctx); - await table.put(key, { - data, - tags, - }); - } - async revalidateTag(tags) { - const tagList = typeof tags === 'string' ? [tags] : tags; - if (tagList.length === 0) - return; - const databases = getDatabases(); - if (!databases) - return; - const table = databases.harperfast_nextjs.nextjs_cache_invalidation; - const timestamp = Date.now(); - // Update the local map immediately so reads on this worker see the - // invalidation without waiting for the subscription roundtrip. - for (const tag of tagList) { - cacheInvalidations.set(tag, timestamp); - } - await Promise.all(tagList.map((tag) => table.put(tag, { timestamp }))); - } - resetRequestCache() { } -} -exports.default = HarperCacheHandler; -//# sourceMappingURL=CacheHandler.cjs.map \ No newline at end of file diff --git a/dist/CacheHandler.cjs.map b/dist/CacheHandler.cjs.map deleted file mode 100644 index 31fc621..0000000 --- a/dist/CacheHandler.cjs.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"CacheHandler.cjs","sourceRoot":"","sources":["../src/CacheHandler.cts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/CacheHandler.d.cts b/dist/CacheHandler.d.cts deleted file mode 100644 index 9828589..0000000 --- a/dist/CacheHandler.d.cts +++ /dev/null @@ -1,10 +0,0 @@ -import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from 'next/dist/server/lib/incremental-cache/index.d.ts'; -import type { IncrementalCacheValue, GetIncrementalFetchCacheContext, GetIncrementalResponseCacheContext, SetIncrementalFetchCacheContext, SetIncrementalResponseCacheContext } from 'next/dist/server/response-cache/index.d.ts'; -export default class HarperCacheHandler implements CacheHandler { - private revalidatedTags; - constructor(ctx?: CacheHandlerContext); - get(key: string, ctx: GetIncrementalFetchCacheContext | GetIncrementalResponseCacheContext): Promise; - set(key: string, data: IncrementalCacheValue | null, ctx: SetIncrementalFetchCacheContext | SetIncrementalResponseCacheContext): Promise; - revalidateTag(tags: string | string[]): Promise; - resetRequestCache(): void; -} diff --git a/dist/plugin.d.ts b/dist/plugin.d.ts deleted file mode 100644 index a6a8da6..0000000 --- a/dist/plugin.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -import type { Scope } from 'harper'; -export declare function handleApplication(scope: Scope): Promise; diff --git a/dist/plugin.js b/dist/plugin.js deleted file mode 100644 index c262423..0000000 --- a/dist/plugin.js +++ /dev/null @@ -1,334 +0,0 @@ -import { createRequire } from 'node:module'; -import { parse as urlParse } from 'node:url'; -import { join } from 'node:path'; -import { existsSync, readFileSync } from 'node:fs'; -// Bringing this forward from extension since some validation is better than none. -// Eventually can remove when plugins have better option validation from core. -/** - * Assert that a given option is a specific type, if it is defined. - */ -function assertType(name, option, expectedType) { - if (option && typeof option !== expectedType) { - throw new Error(`${name} must be type ${expectedType}. Received: ${typeof option}`); - } -} -/** - * Validates and resolve plugin options with sensible defaults. - */ -function resolveConfig(scope) { - const options = scope.options.getAll(); - if (options === null || Array.isArray(options) || typeof options !== 'object') { - throw new Error('@harperfast/nextjs plugin options should be a regular object'); - } - // Environment Variables take precedence - switch (process.env.HARPER_NEXTJS_MODE) { - case 'dev': - options.dev = true; - break; - case 'build': - options.buildOnly = true; - options.dev = false; - options.prebuilt = false; - break; - case 'prod': - options.dev = false; - break; - default: - break; - } - assertType('buildOnly', options.buildOnly, 'boolean'); - assertType('bundler', options.bundler, 'string'); - assertType('dev', options.dev, 'boolean'); - assertType('port', options.port, 'number'); - assertType('prebuilt', options.prebuilt, 'boolean'); - assertType('runFirst', options.runFirst, 'boolean'); - assertType('securePort', options.securePort, 'number'); - if (options.bundler && options.bundler !== 'webpack' && options.bundler !== 'turbopack') { - throw new Error(`bundler must be "webpack" or "turbopack". Received: "${options.bundler}"`); - } - // TODO: Remove type casts when we have more proper plugin option validation from core - return { - buildOnly: options.buildOnly ?? false, - // bundler default is set later in handleApplication() based on the detected Next.js version - bundler: options.bundler, - dev: options.dev ?? false, - // @ts-expect-error - files: options.files, - port: options.port, - prebuilt: options.prebuilt ?? false, - runFirst: options.runFirst ?? false, - securePort: options.securePort, - }; -} -function assertNextApp({ appName, directory, logger }) { - logger.debug?.(`Verifying ${directory} is a Next.js application`); - // Couple options to check if its a Next.js project - // 1. Check for Next.js config file (next.config.{js|mjs|ts}) - // - This file is not required for a Next.js project - // 2. Check package.json for Next.js dependency - // - It could be listed in `dependencies` or `devDependencies` (and maybe even `peerDependencies` or `optionalDependencies`) - // - Also not required. Users can use `npx next ...` - // 3. Check for `.next` folder - // - This could be a reasonable fallback if we want to support pre-built Next.js apps - // A combination of options 1 and 2 should be sufficient for our purposes. - // Known Edge case: app does not have a config and are using `npx` (or something similar) to execute Next.js - // Check for Next.js Config - const configExists = ['js', 'mjs', 'ts'].some((ext) => existsSync(join(directory, `next.config.${ext}`))); - // Check for dependency - let dependencyExists = false; - const packageJSONPath = join(directory, 'package.json'); - if (existsSync(packageJSONPath)) { - const packageJSON = JSON.parse(readFileSync(packageJSONPath, 'utf8')); - dependencyExists = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].some((dependencyList) => packageJSON[dependencyList]?.['next']); - } - if (!configExists && !dependencyExists) { - logger.fatal?.(`Failed to verify ${appName} application as a Next.js project. It is missing both a Next.js config file and the "next" dependency in package.json`); - return false; - } - return true; -} -/** - * Safely attempts to read the `.next/BUILD_ID`. - * - * Returns `null` if it does not exist (empty BUILD_ID file or file does not exist) - */ -function getBuildId(scope) { - const buildIdPath = join(scope.directory, '.next', 'BUILD_ID'); - try { - const buildId = readFileSync(buildIdPath, 'utf-8').trim(); - return buildId || null; - } - catch (error) { - // Ignore ENOENT errors (.next/BUILD_ID does not exist) - // @ts-expect-error - if (error.code === 'ENOENT') { - return null; - } - // Otherwise rethrow error - throw error; - } -} -export async function handleApplication(scope) { - scope.logger.debug?.(`Handling Next.js Application ${scope.appName} as ${scope.directory}`); - const config = resolveConfig(scope); - // TODO: delegate asserting the app to the Next.js build/server functions instead. - if (!assertNextApp(scope)) { - return; - } - // The original idea here was to use the file change detection mechanism to make rebuilds smarter. - // Specifically, if the plugin detects application file changes, then it should rebuild immediately - // and _then_ restart the threads. This would then let the user skip building after threads restart. - // Unfortunately, with the time based mechanism below I don't think this is as deterministic and must - // be thought through again. Additionally this was not as simple as originally thought. Unless the user - // knows to finely tune the `files` option, what exactly should Harper watch automatically? Surely not - // _everything_ in an application directory. Including things like `node_modules` would be impossible too. - // So just leave this artifact here for a future feature improvement. - // // If files for the next.js app change, we want to mark the build as stale and request a restart - // // this way when the threads restart, and see the existing `.next/BUILD_ID` file, they will still rebuild the app. - // async function entryHandler (entry: FileEntryEvent | DirectoryEntryEvent) { - // scope.logger.debug?.(`Entry Handler called`, entry) - // await databases.harperfast_nextjs.nextjs_build_info.put(scope.appName, { buildId: null, status: 'stale' }); - // scope.requestRestart(); - // } - // if (config.files) { - // // If the user specified files then use the default handler - // scope.handleEntry(entryHandler); - // } else { - // // Otherwise define our own. - // scope.handleEntry({ - // source: '**/*', - // ignore: ['.next/**/*', 'node_modules/**/*'] - // }, entryHandler); - // } - const next = importNext(scope); - // Set the bundler default based on the detected Next.js version if not explicitly configured. - // Next.js v16 defaults to turbopack; v14 and v15 default to webpack. - if (!config.bundler) { - config.bundler = next.version >= 16 ? 'turbopack' : 'webpack'; - } - if (config.bundler === 'turbopack' && next.version === 14) { - scope.logger.error?.('Turbopack is not supported for Next.js v14. Falling back to webpack.'); - config.bundler = 'webpack'; - } - scope.logger.debug?.(`Detected Next.js version: ${next.version}`); - if (config.prebuilt) { - // TODO: implement record check to skip-over following checks - // get record by appName - // - if it exists && within 500ms(?) - // - if success goto serve - // - if failure log and return early - // - if stale ??? (how do we get here?) (maybe with time-based we don't have stale anymore?) - // - else continue with below logic - // - if valid, write success record and goto serve - // - if invalid, write failure record and log and return - const nextDir = join(scope.directory, '.next'); - if (!existsSync(nextDir)) { - scope.logger.error?.('Prebuilt mode is enabled, but the .next folder does not exist'); - return; - } - if (!existsSync(join(nextDir, 'BUILD_ID'))) { - scope.logger.error?.('Prebuilt mode is enabled, but the .next/BUILD_ID file does not exist'); - return; - } - // In prebuilt mode, we still want to ensure the build is valid by checking for a `buildId`. - // We shouldn't try serving (and failing) if we can detect a potentially bad build. - // This is based on the assumption that a BUILD_ID file only exists for valid builds; not sure - // if that is 100% true or if Next.js provides any other guarantees or validation mechanisms. - const buildId = getBuildId(scope); - // Immediately set the build info record appropriately - await databases.harperfast_nextjs.nextjs_build_info.put(scope.appName, { - buildId, - status: buildId !== null ? 'success' : 'failure', - }); - if (buildId === null) { - return; - } - } - else if (!config.dev) { - // If not prebuilt mode and not dev mode, then proceed to building - try { - await build(scope, config, next); - // In build only we can exit and return early here. - if (config.buildOnly) { - scope.logger.info?.('buildOnly mode is enabled, exiting'); - // TODO: should harper expose a like `scope.shutdown()` method or something that "safely" exits? - process.exit(0); - } - } - catch (error) { - // if build fails for any reason - // mark record as failure, log error, and return - await databases.harperfast_nextjs.nextjs_build_info.put(scope.appName, { buildId: null, status: 'failure' }); - scope.logger.error?.(`Error building Next.js application ${scope.appName}: `, error); - return; - } - } - // Finally, serve the application - await serve(scope, config, next); -} -async function build(scope, config, next) { - const buildInfo = await databases.harperfast_nextjs.nextjs_build_info.get(scope.appName); - if (buildInfo && Date.now() - buildInfo.getUpdatedTime() < 5000) { - // If the build info record is marked as "failure" just return immediately - // avoids building (and failing) on every thread - if (buildInfo.status === 'failure') { - scope.logger.debug?.(`Failure build of ${scope.appName} detected`); - return; - } - // If the build info record is marked as "success" - if (buildInfo.status === 'success') { - // then validate the BUILD_ID value - const buildId = getBuildId(scope); - if (buildId === buildInfo.buildId) { - scope.logger.debug?.(`Fresh build of ${scope.appName} (id: ${buildInfo.buildId}) detected`); - // fresh build - return; - } - } - } - // Otherwise we have a stale build (or no build info at all) and now we can proceed with building - scope.logger.debug?.(`Building Next.js application at ${scope.directory}`); - // --expose-internals is set in Harper's worker execArgv but is not allowed in NODE_OPTIONS. - // Next.js reads process.execArgv to forward flags to its own child workers via NODE_OPTIONS, - // which causes Node to reject the build worker. Strip it before building and restore after. - const exposeInternalsIdx = process.execArgv.indexOf('--expose-internals'); - if (exposeInternalsIdx !== -1) - process.execArgv.splice(exposeInternalsIdx, 1); - try { - switch (next.version) { - case 14: - await next.build({ - lint: false, - mangling: true, - experimentalDebugMemoryUsage: false, - experimentalBuildMode: 'default', - }, scope.directory); - break; - case 15: - await next.build({ - lint: false, - mangling: true, - ...(config.bundler === 'turbopack' && { turbopack: true }), - experimentalDebugMemoryUsage: false, - experimentalBuildMode: 'default', - }, scope.directory); - break; - case 16: - await next.build({ - mangling: true, - ...(config.bundler === 'webpack' && { webpack: true }), - experimentalDebugMemoryUsage: false, - experimentalBuildMode: 'default', - }, scope.directory); - break; - } - const buildIdPath = join(scope.directory, '.next', 'BUILD_ID'); - const buildId = readFileSync(buildIdPath, 'utf-8'); - // Update the build info record - await databases.harperfast_nextjs.nextjs_build_info.put(scope.appName, { buildId, status: 'success' }); - scope.logger.debug?.(`Successful build for ${scope.appName} (id ${buildId})`); - return; - } - catch (error) { - await databases.harperfast_nextjs.nextjs_build_info.put(scope.appName, { buildId: null, status: 'failure' }); - scope.logger.debug?.(`Error building ${scope.appName}`); - throw error; - } - finally { - if (exposeInternalsIdx !== -1) - process.execArgv.splice(exposeInternalsIdx, 0, '--expose-internals'); - } -} -async function serve(scope, config, next) { - scope.logger.debug?.(`Serving Next.js application at ${scope.directory}`); - let app; - switch (next.version) { - case 14: - app = next.server({ dir: scope.directory, dev: config.dev }); - break; - case 15: - app = next.server({ dir: scope.directory, dev: config.dev, ...(config.bundler === 'turbopack' && { turbopack: true }) }); - break; - case 16: - app = next.server({ dir: scope.directory, dev: config.dev, ...(config.bundler === 'webpack' && { webpack: true }) }); - break; - } - await app.prepare(); - const requestHandler = app.getRequestHandler(); - scope.server?.http?.((request, next) => { - return request._nodeResponse === undefined - ? next(request) - : // @ts-expect-error - Not sure when the IncomingMessage.url could be undefined ; need to dig into it. - requestHandler(request._nodeRequest, request._nodeResponse, urlParse(request._nodeRequest.url, true)); - }, { runFirst: config.runFirst, port: config.port, securePort: config.securePort }); - // Early Next.js versions don't have an upgrade handler - if (config.dev && app.getUpgradeHandler) { - const upgradeHandler = app.getUpgradeHandler(); - scope.server?.upgrade?.(async (request, socket, head, next) => { - if (request.url === '/_next/webpack-hmr') { - // Next.js v13+ upgradeHandler implementations return promises - await upgradeHandler(request._nodeRequest, socket, head); - request.__harperRequestUpgraded = true; - return await next(request, socket, head); - } - return next(request, socket, head); - }, - // Okay to set `runFirst: true` here since this has a strict match on `/_next/webpack-hmr` - { runFirst: true, port: config.port, securePort: config.securePort }); - } -} -// This function imports the Next.js version specified by the application -function importNext(scope) { - const require = createRequire(join(scope.directory, 'package.json')); - const nextPackage = require('next/package.json'); - const version = parseInt(nextPackage.version.split('.')[0], 10); - if (version !== 14 && version !== 15 && version !== 16) { - throw new Error(`Unsupported Next.js version detected: ${nextPackage.version}. The \`@harperfast/nextjs\` plugin only supports Next.js versions: 14, 15, 16`); - } - // The default export is the `createServer` function - const server = require('next'); - // Use the `nextBuild` method - const build = require('next/dist/cli/next-build.js').nextBuild; - return { server, build, version }; -} -//# sourceMappingURL=plugin.js.map \ No newline at end of file diff --git a/dist/plugin.js.map b/dist/plugin.js.map deleted file mode 100644 index a152821..0000000 --- a/dist/plugin.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/withHarper.cjs b/dist/withHarper.cjs deleted file mode 100644 index e3bc2c0..0000000 --- a/dist/withHarper.cjs +++ /dev/null @@ -1,35 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.withHarper = withHarper; -const node_path_1 = require("node:path"); -function withHarper(config, harperConfig = {}) { - const { experimentalHarperCache = false } = harperConfig; - // TODO: Do things like `serverExternalPackage` work with Next.js v14? If not, how can we - // detect version reliably and apply? What if we added properties specific to v14? Would - // they be okay with v15 and v16 or do this all need to be guarded? - // Potential solution: To avoid version detection (if thats complicated), add a `version` - // option or provide separate exports for each unique Next.js major. Something like: - // `withHarperNext14()` or `withHarper({}, {}, 14)` - // TODO: We should inspect the Next.js config for properties such as `turbo` and then apply - // specific options when present. I think things like `serverExternalPackages` used to be - // `webpack` and thus maybe theres separate configuration based on the selected bundler. - // But also this means resolving turbopack support in the plugin which is currently proving - // difficult. - return { - ...config, - webpack: (config) => { - config.externals.push({ - 'harperdb': 'commonjs harperdb', - 'harper': 'commonjs harper', - 'harper-pro': 'commonjs harper-pro', - }); - return config; - }, - turbopack: { - ...config.turbopack, - }, - serverExternalPackages: [...(config.serverExternalPackages ?? []), 'harperdb', 'harper', 'harper-pro'], - ...(experimentalHarperCache && { cacheHandler: (0, node_path_1.join)(__dirname, 'CacheHandler.cjs') }), - }; -} -//# sourceMappingURL=withHarper.cjs.map \ No newline at end of file diff --git a/dist/withHarper.cjs.map b/dist/withHarper.cjs.map deleted file mode 100644 index 185afeb..0000000 --- a/dist/withHarper.cjs.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"withHarper.cjs","sourceRoot":"","sources":["../src/withHarper.cts"],"names":[],"mappings":";;AAOA,gCAiCC;AAxCD,yCAAiC;AAOjC,SAAgB,UAAU,CAAC,MAAkB,EAAE,eAA6B,EAAE;IAC7E,MAAM,EAAE,uBAAuB,GAAG,KAAK,EAAE,GAAG,YAAY,CAAC;IAEzD,yFAAyF;IACzF,wFAAwF;IACxF,mEAAmE;IACnE,yFAAyF;IACzF,oFAAoF;IACpF,mDAAmD;IAEnD,2FAA2F;IAC3F,yFAAyF;IACzF,wFAAwF;IACxF,2FAA2F;IAC3F,aAAa;IAEb,OAAO;QACN,GAAG,MAAM;QACT,OAAO,EAAE,CAAC,MAAM,EAAE,EAAE;YACnB,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC;gBACrB,UAAU,EAAE,mBAAmB;gBAC/B,QAAQ,EAAE,iBAAiB;gBAC3B,YAAY,EAAE,qBAAqB;aACnC,CAAC,CAAC;YAEH,OAAO,MAAM,CAAC;QACf,CAAC;QACD,SAAS,EAAE;YACV,GAAG,MAAM,CAAC,SAAS;SACnB;QACD,sBAAsB,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,sBAAsB,IAAI,EAAE,CAAC,EAAE,UAAU,EAAE,QAAQ,EAAE,YAAY,CAAC;QACtG,GAAG,CAAC,uBAAuB,IAAI,EAAE,YAAY,EAAE,IAAA,gBAAI,EAAC,SAAS,EAAE,kBAAkB,CAAC,EAAE,CAAC;KACrF,CAAC;AACH,CAAC"} \ No newline at end of file diff --git a/dist/withHarper.d.cts b/dist/withHarper.d.cts deleted file mode 100644 index c0f7a59..0000000 --- a/dist/withHarper.d.cts +++ /dev/null @@ -1,5 +0,0 @@ -import type { NextConfig } from 'next'; -export interface HarperConfig { - experimentalHarperCache?: boolean; -} -export declare function withHarper(config: NextConfig, harperConfig?: HarperConfig): NextConfig; From 7e1e1330c6be68385a6d6759f14fbe2a3fa466d5 Mon Sep 17 00:00:00 2001 From: Joshua Johnson Date: Thu, 14 May 2026 11:58:30 -0500 Subject: [PATCH 12/14] fix: allow structured IncrementalCacheValue in nextjs_isr_cache.data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema declared `data: String`, but the CacheHandler stores the Next.js IncrementalCacheValue object ({ kind, html, rscData, headers, ... }). Every put threw "in property data must be a string", leaving the cache empty and every request a MISS — which broke all next-16-caching integration tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- schema.graphql | 2 +- src/CacheHandler.cts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/schema.graphql b/schema.graphql index 408b2c9..19d1a29 100644 --- a/schema.graphql +++ b/schema.graphql @@ -6,7 +6,7 @@ type NextBuildInfo @table(database: "harperfast_nextjs", table: "nextjs_build_in type NextISRCache @table(database: "harperfast_nextjs", table: "nextjs_isr_cache") { id: String @primaryKey - data: String + data: Any tags: [String] lastModified: Long @updatedTime } diff --git a/src/CacheHandler.cts b/src/CacheHandler.cts index 5be10c7..e010f92 100644 --- a/src/CacheHandler.cts +++ b/src/CacheHandler.cts @@ -160,10 +160,7 @@ export default class HarperCacheHandler implements CacheHandler { const table = databases.harperfast_nextjs.nextjs_isr_cache; const tags = extractTags(data, ctx); - await table.put(key, { - data, - tags, - }); + await table.put(key, { data, tags }); } async revalidateTag(tags: string | string[]): Promise { From 49b6db992e1bb762af10a571f15139b480200da1 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Thu, 14 May 2026 17:20:18 -0600 Subject: [PATCH 13/14] fixup withHarper and provide configuration documentation --- README.md | 46 +++++++++++++----------- fixtures/next-16-caching/next.config.mjs | 13 +++---- integrationTests/fixture.ts | 3 +- package-lock.json | 38 ++++++++++++++++---- package.json | 2 +- src/withHarper.cts | 18 ++++++---- 6 files changed, 76 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index ceea645..74aa811 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ export default async function Dog({ params }) { ## `withHarper()` -`withHarper(config: NextConfig, harperConfig?: HarperConfig): NextConfig` +`withHarper(config?: NextConfig): NextConfig` A configuration helper that wraps your Next.js config. It automatically adds `harper` and `harper-pro` to `serverExternalPackages` so Harper's native dependencies are treated correctly by the bundler. @@ -118,19 +118,6 @@ module.exports = withHarper({ }); ``` -### `experimentalHarperCache: boolean` - -Enables the built-in Harper [cache handler](#caching-work-in-progress). Defaults to `false`. - -```js -export default withHarper( - { - /* Next.js config */ - }, - { experimentalHarperCache: true } -); -``` - ## Options All plugin options are configured in `config.yaml` under the `@harperfast/nextjs` key. All options are optional. @@ -185,20 +172,37 @@ Glob pattern specifying which files Harper should watch for changes. Example: `' ## Caching (Work In Progress) -> [!NOTE] -> The Harper cache handler is still gated behind `experimentalHarperCache`. The runtime behavior described below is implemented, but the option name signals that the contract may evolve. - `@harperfast/nextjs` includes a Harper-backed cache handler for Next.js [Incremental Static Regeneration (ISR)](https://nextjs.org/docs/app/guides/incremental-static-regeneration), the [Data Cache (`fetch()`)](https://nextjs.org/docs/app/deep-dive/caching#data-cache), and [`unstable_cache`](https://nextjs.org/docs/app/api-reference/functions/unstable_cache). Cached entries live in Harper instead of the worker's local filesystem, so a cache write on one node is visible to every node in the cluster. ### Enabling -Set `experimentalHarperCache: true` in [`withHarper()`](#withharper): +Set the `cacheHandler` path using the `cacheHandlerPath()` helper. This helper resolves the cache handler relative to your config file, which is required by Turbopack: ```js -// next.config.mjs -import { withHarper } from '@harperfast/nextjs'; +// next.config.js (CommonJS) +const { withHarper, cacheHandlerPath } = require('@harperfast/nextjs'); -export default withHarper({}, { experimentalHarperCache: true }); +module.exports = withHarper({ + cacheHandler: cacheHandlerPath(__dirname), +}); +``` + +```js +// next.config.mjs (ESM) +import { withHarper, cacheHandlerPath } from '@harperfast/nextjs'; + +export default withHarper({ + cacheHandler: cacheHandlerPath(import.meta.dirname), +}); +``` + +```ts +// next.config.ts (TypeScript) +import { withHarper, cacheHandlerPath } from '@harperfast/nextjs'; + +export default withHarper({ + cacheHandler: cacheHandlerPath(import.meta.dirname), +}); ``` ### Tag invalidation diff --git a/fixtures/next-16-caching/next.config.mjs b/fixtures/next-16-caching/next.config.mjs index 020d8b9..4675e52 100644 --- a/fixtures/next-16-caching/next.config.mjs +++ b/fixtures/next-16-caching/next.config.mjs @@ -1,9 +1,6 @@ -import { join } from 'path'; +import { withHarper, cacheHandlerPath } from '@harperfast/nextjs'; -export default { - // turbopack: { - // root: '../../../../', - // }, - serverExternalPackages: ['harper', '@harperfast/nextjs'], - cacheHandler: join(import.meta.dirname, 'node_modules', '@harperfast', 'nextjs', 'dist', 'CacheHandler.cjs'), -}; +export default withHarper({ + cacheHandler: cacheHandlerPath(import.meta.dirname), + // serverExternalPackages: ['@harperfast/nextjs'], +}); diff --git a/integrationTests/fixture.ts b/integrationTests/fixture.ts index e0b48f6..e1aeef6 100644 --- a/integrationTests/fixture.ts +++ b/integrationTests/fixture.ts @@ -34,8 +34,9 @@ export function makeHarperFixture(fixtureName: string) { }, applications: { lockdown: 'none', - moduleLoader: 'native', + moduleLoader: 'none', dependencyLoader: 'native', + allowedDirectory: 'any' }, }, }); diff --git a/package-lock.json b/package-lock.json index ad106db..97bef66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@harperfast/nextjs", - "version": "2.1.2", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@harperfast/nextjs", - "version": "2.1.2", + "version": "2.1.1", "license": "Apache-2.0", "devDependencies": { - "@harperfast/integration-testing": "0.3.0", + "@harperfast/integration-testing": "0.3.1", "@playwright/test": "1.59.1", "@types/node": "^20", "harper": "5.0.9", @@ -1244,6 +1244,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1565,9 +1566,9 @@ "license": "Apache-2.0" }, "node_modules/@harperfast/integration-testing": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@harperfast/integration-testing/-/integration-testing-0.3.0.tgz", - "integrity": "sha512-q8R6k+aYtYQ7iyVuiWFJ9uB2f1OPEh4hXd07VTv12LxsmUY3XFXGuiLh2buDi36SAB4Y5++IZcF7lZQ/CIDbvA==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@harperfast/integration-testing/-/integration-testing-0.3.1.tgz", + "integrity": "sha512-hW7XsSTRWv38pK0nY4GZhGmmWAeQg/2eSSHAdwOO+niL7QORLExGjKCYxySylpKbWRdORQ7JjG5RMkFH+LQc9g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1777,6 +1778,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1799,6 +1801,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1821,6 +1824,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1837,6 +1841,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1853,6 +1858,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1869,6 +1875,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1885,6 +1892,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1901,6 +1909,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1917,6 +1926,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1933,6 +1943,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1949,6 +1960,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1965,6 +1977,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1981,6 +1994,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2003,6 +2017,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2025,6 +2040,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2047,6 +2063,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2069,6 +2086,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2091,6 +2109,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2113,6 +2132,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2135,6 +2155,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2157,6 +2178,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2176,6 +2198,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2195,6 +2218,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2214,6 +2238,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -5469,6 +5494,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index ba25648..1831ce3 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ } }, "devDependencies": { - "@harperfast/integration-testing": "0.3.0", + "@harperfast/integration-testing": "0.3.1", "@playwright/test": "1.59.1", "@types/node": "^20", "harper": "5.0.9", diff --git a/src/withHarper.cts b/src/withHarper.cts index 9aa90fa..7ede026 100644 --- a/src/withHarper.cts +++ b/src/withHarper.cts @@ -1,19 +1,24 @@ import { join } from 'node:path'; import type { NextConfig } from 'next'; -export interface HarperConfig { - experimentalHarperCache?: boolean; +/** + * Returns the path to the Harper cache handler module, resolved relative to the + * caller's directory. Pass `import.meta.dirname` (ESM) or `__dirname` (CJS). + * + * This avoids `require.resolve`, which dereferences symlinks and produces paths + * outside Turbopack's filesystem root when the package is linked. + */ +export function cacheHandlerPath(configDir: string): string { + return join(configDir, 'node_modules', '@harperfast', 'nextjs', 'dist', 'CacheHandler.cjs'); } -export function withHarper(config: NextConfig, harperConfig: HarperConfig = {}): NextConfig { - const { experimentalHarperCache = false } = harperConfig; - +export function withHarper(config: NextConfig = {}): NextConfig { // TODO: Do things like `serverExternalPackage` work with Next.js v14? If not, how can we // detect version reliably and apply? What if we added properties specific to v14? Would // they be okay with v15 and v16 or do this all need to be guarded? // Potential solution: To avoid version detection (if thats complicated), add a `version` // option or provide separate exports for each unique Next.js major. Something like: - // `withHarperNext14()` or `withHarper({}, {}, 14)` + // `withHarperNext14()` or `withHarper({}, 14)` // TODO: We should inspect the Next.js config for properties such as `turbo` and then apply // specific options when present. I think things like `serverExternalPackages` used to be @@ -36,6 +41,5 @@ export function withHarper(config: NextConfig, harperConfig: HarperConfig = {}): ...config.turbopack, }, serverExternalPackages: [...(config.serverExternalPackages ?? []), 'harperdb', 'harper', 'harper-pro'], - ...(experimentalHarperCache && { cacheHandler: join(__dirname, 'CacheHandler.cjs') }), }; } From 0172e39f37d3286ad5614a574e6a064a1dded993 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Thu, 14 May 2026 17:28:51 -0600 Subject: [PATCH 14/14] remove unused config --- fixtures/next-16-caching/next.config.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/fixtures/next-16-caching/next.config.mjs b/fixtures/next-16-caching/next.config.mjs index 4675e52..0f39fc2 100644 --- a/fixtures/next-16-caching/next.config.mjs +++ b/fixtures/next-16-caching/next.config.mjs @@ -2,5 +2,4 @@ import { withHarper, cacheHandlerPath } from '@harperfast/nextjs'; export default withHarper({ cacheHandler: cacheHandlerPath(import.meta.dirname), - // serverExternalPackages: ['@harperfast/nextjs'], });