From f0960600e25243b256b2e630812f17e4cf37918c Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Wed, 6 May 2026 08:37:34 -0600 Subject: [PATCH 1/4] feat: add turbopack support for Next.js v16 Next.js v16 defaults to Turbopack. The plugin previously hardcoded webpack for both build and serve, disabling turbopack entirely. - Add `webpack` boolean plugin option (defaults to false) so users can opt into webpack when needed, mirroring the `next build --webpack` CLI flag - Update `withHarper()` to include a `turbopack` config key, preventing Next.js v16 from erroring when it sees a webpack config without one - Add `next-16-webpack` fixture and integration test to cover both bundler paths Co-Authored-By: Claude Opus 4.6 (1M context) --- fixtures/next-16-webpack/.npmrc | 1 + fixtures/next-16-webpack/app/layout.js | 11 +++++++++++ fixtures/next-16-webpack/app/page.js | 7 +++++++ fixtures/next-16-webpack/config.yaml | 3 +++ fixtures/next-16-webpack/next.config.ts | 3 +++ fixtures/next-16-webpack/package.json | 16 ++++++++++++++++ integrationTests/next-16-webpack.pw.ts | 18 ++++++++++++++++++ src/plugin.ts | 7 +++++-- src/withHarper.cts | 3 +++ 9 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 fixtures/next-16-webpack/.npmrc create mode 100644 fixtures/next-16-webpack/app/layout.js create mode 100644 fixtures/next-16-webpack/app/page.js create mode 100644 fixtures/next-16-webpack/config.yaml create mode 100644 fixtures/next-16-webpack/next.config.ts create mode 100644 fixtures/next-16-webpack/package.json create mode 100644 integrationTests/next-16-webpack.pw.ts diff --git a/fixtures/next-16-webpack/.npmrc b/fixtures/next-16-webpack/.npmrc new file mode 100644 index 0000000..9cf9495 --- /dev/null +++ b/fixtures/next-16-webpack/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/fixtures/next-16-webpack/app/layout.js b/fixtures/next-16-webpack/app/layout.js new file mode 100644 index 0000000..e9017c8 --- /dev/null +++ b/fixtures/next-16-webpack/app/layout.js @@ -0,0 +1,11 @@ +export const metadata = { + title: 'Harper - Next.js v16 App', +}; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} diff --git a/fixtures/next-16-webpack/app/page.js b/fixtures/next-16-webpack/app/page.js new file mode 100644 index 0000000..ec82c6e --- /dev/null +++ b/fixtures/next-16-webpack/app/page.js @@ -0,0 +1,7 @@ +export default async function Page() { + return ( +
+

Next.js v16

+
+ ); +} diff --git a/fixtures/next-16-webpack/config.yaml b/fixtures/next-16-webpack/config.yaml new file mode 100644 index 0000000..7707779 --- /dev/null +++ b/fixtures/next-16-webpack/config.yaml @@ -0,0 +1,3 @@ +'@harperfast/nextjs': + package: '@harperfast/nextjs' + webpack: true diff --git a/fixtures/next-16-webpack/next.config.ts b/fixtures/next-16-webpack/next.config.ts new file mode 100644 index 0000000..ada3f1d --- /dev/null +++ b/fixtures/next-16-webpack/next.config.ts @@ -0,0 +1,3 @@ +import { withHarper } from '@harperfast/nextjs'; + +export default withHarper({}); diff --git a/fixtures/next-16-webpack/package.json b/fixtures/next-16-webpack/package.json new file mode 100644 index 0000000..15ffafe --- /dev/null +++ b/fixtures/next-16-webpack/package.json @@ -0,0 +1,16 @@ +{ + "name": "next-16", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@harperfast/nextjs": "file:../../", + "react": "^19", + "react-dom": "^19", + "next": "^16" + } +} diff --git a/integrationTests/next-16-webpack.pw.ts b/integrationTests/next-16-webpack.pw.ts new file mode 100644 index 0000000..d72c67a --- /dev/null +++ b/integrationTests/next-16-webpack.pw.ts @@ -0,0 +1,18 @@ +import { fixture } from './fixture.ts'; + +const { test, expect } = fixture('next-16-webpack'); + +test('home page renders', async ({ page, harper }) => { + await page.goto(harper.httpURL); + await expect(page.locator('h1')).toHaveText('Next.js v16'); +}); + +test('page title is set', async ({ page, harper }) => { + await page.goto(harper.httpURL); + await expect(page).toHaveTitle('Harper - Next.js v16 App'); +}); + +test('status endpoint returns 200', async ({ request, harper }) => { + const response = await request.get(`${harper.operationsAPIURL}/health`); + expect(response.status()).toBe(200); +}); diff --git a/src/plugin.ts b/src/plugin.ts index 109b39b..30e048e 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -23,6 +23,7 @@ interface NextPluginConfig extends Config { prebuilt: boolean; runFirst: boolean; securePort?: number; + webpack: boolean; } // Bringing this forward from extension since some validation is better than none. @@ -68,6 +69,7 @@ function resolveConfig(scope: Scope): NextPluginConfig { assertType('prebuilt', options.prebuilt, 'boolean'); assertType('runFirst', options.runFirst, 'boolean'); assertType('securePort', options.securePort, 'number'); + assertType('webpack', options.webpack, 'boolean'); // TODO: Remove type casts when we have more proper plugin option validation from core return { @@ -79,6 +81,7 @@ function resolveConfig(scope: Scope): NextPluginConfig { prebuilt: (options.prebuilt as boolean) ?? false, runFirst: (options.runFirst as boolean) ?? false, securePort: options.securePort as number, + webpack: (options.webpack as boolean) ?? false, } satisfies NextPluginConfig; } @@ -305,7 +308,7 @@ async function build(scope: Scope, config: NextPluginConfig, next: NextPackage) await next.build( { mangling: true, - webpack: true, + webpack: config.webpack, experimentalDebugMemoryUsage: false, experimentalBuildMode: 'default', }, @@ -341,7 +344,7 @@ async function serve(scope: Scope, config: NextPluginConfig, next: NextPackage) app = next.server({ dir: scope.directory, dev: config.dev, turbopack: false }); break; case 16: - app = next.server({ dir: scope.directory, dev: config.dev, turbopack: false }); + app = next.server({ dir: scope.directory, dev: config.dev, ...(config.webpack && { webpack: true }) }); break; } diff --git a/src/withHarper.cts b/src/withHarper.cts index 87cb179..9aa90fa 100644 --- a/src/withHarper.cts +++ b/src/withHarper.cts @@ -32,6 +32,9 @@ export function withHarper(config: NextConfig, harperConfig: HarperConfig = {}): return config; }, + turbopack: { + ...config.turbopack, + }, serverExternalPackages: [...(config.serverExternalPackages ?? []), 'harperdb', 'harper', 'harper-pro'], ...(experimentalHarperCache && { cacheHandler: join(__dirname, 'CacheHandler.cjs') }), }; From 56677428f3baf04d3617911dd3107d58288f2ca5 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Wed, 6 May 2026 08:39:01 -0600 Subject: [PATCH 2/4] add allowImportingTsExtensions for integration testing tsconfig --- integrationTests/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integrationTests/tsconfig.json b/integrationTests/tsconfig.json index fbbcb35..ee6b82c 100644 --- a/integrationTests/tsconfig.json +++ b/integrationTests/tsconfig.json @@ -5,6 +5,7 @@ // This is really only here from VSCode TypeScript intellisense // Type checking passes without it, but however the typescript server is set up // in vscode has issues without it. - "types": ["node"] + "types": ["node"], + "allowImportingTsExtensions": true } } From 88d69ec88a32b304e3aaed2b2ccc4d26c899766c Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Wed, 6 May 2026 10:06:30 -0600 Subject: [PATCH 3/4] feat: add turbopack support for Next.js v15 (#44) * feat: add turbopack support for Next.js v15 and refactor bundler option Replace the `webpack: boolean` plugin option with a `bundler` option that accepts "webpack" or "turbopack". The default is version-aware: - v16: turbopack (matches Next.js v16 default) - v15: webpack (matches Next.js v15 default) - v14: webpack (no turbopack support) Users can now explicitly set `bundler: turbopack` for v15 to opt in, or `bundler: webpack` for v16 to opt out. - Add `next-15-turbopack` fixture and integration test - Update `next-16-webpack` fixture to use `bundler: webpack` Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add v14 turbopack guard and validate all versions (#45) Next.js v14 does not support turbopack via the createServer or nextBuild APIs. Log an error and fall back to webpack if a user sets `bundler: turbopack` with a v14 application. Co-authored-by: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- fixtures/next-15-turbopack/.npmrc | 1 + fixtures/next-15-turbopack/app/layout.js | 11 ++++++++ fixtures/next-15-turbopack/app/page.js | 7 +++++ fixtures/next-15-turbopack/config.yaml | 3 ++ fixtures/next-15-turbopack/next.config.mjs | 3 ++ fixtures/next-15-turbopack/package.json | 16 +++++++++++ fixtures/next-16-webpack/config.yaml | 2 +- integrationTests/next-15-turbopack.pw.ts | 18 ++++++++++++ src/plugin.ts | 32 +++++++++++++++++----- 9 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 fixtures/next-15-turbopack/.npmrc create mode 100644 fixtures/next-15-turbopack/app/layout.js create mode 100644 fixtures/next-15-turbopack/app/page.js create mode 100644 fixtures/next-15-turbopack/config.yaml create mode 100644 fixtures/next-15-turbopack/next.config.mjs create mode 100644 fixtures/next-15-turbopack/package.json create mode 100644 integrationTests/next-15-turbopack.pw.ts diff --git a/fixtures/next-15-turbopack/.npmrc b/fixtures/next-15-turbopack/.npmrc new file mode 100644 index 0000000..9cf9495 --- /dev/null +++ b/fixtures/next-15-turbopack/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/fixtures/next-15-turbopack/app/layout.js b/fixtures/next-15-turbopack/app/layout.js new file mode 100644 index 0000000..de69185 --- /dev/null +++ b/fixtures/next-15-turbopack/app/layout.js @@ -0,0 +1,11 @@ +export const metadata = { + title: 'Harper - Next.js v15 App', +}; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} diff --git a/fixtures/next-15-turbopack/app/page.js b/fixtures/next-15-turbopack/app/page.js new file mode 100644 index 0000000..4a48cf9 --- /dev/null +++ b/fixtures/next-15-turbopack/app/page.js @@ -0,0 +1,7 @@ +export default async function Page() { + return ( +
+

Next.js v15

+
+ ); +} diff --git a/fixtures/next-15-turbopack/config.yaml b/fixtures/next-15-turbopack/config.yaml new file mode 100644 index 0000000..ddeb17c --- /dev/null +++ b/fixtures/next-15-turbopack/config.yaml @@ -0,0 +1,3 @@ +'@harperfast/nextjs': + package: '@harperfast/nextjs' + bundler: turbopack diff --git a/fixtures/next-15-turbopack/next.config.mjs b/fixtures/next-15-turbopack/next.config.mjs new file mode 100644 index 0000000..ada3f1d --- /dev/null +++ b/fixtures/next-15-turbopack/next.config.mjs @@ -0,0 +1,3 @@ +import { withHarper } from '@harperfast/nextjs'; + +export default withHarper({}); diff --git a/fixtures/next-15-turbopack/package.json b/fixtures/next-15-turbopack/package.json new file mode 100644 index 0000000..bf94d84 --- /dev/null +++ b/fixtures/next-15-turbopack/package.json @@ -0,0 +1,16 @@ +{ + "name": "next-15", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@harperfast/nextjs": "file:../../", + "react": "^19", + "react-dom": "^19", + "next": "^15" + } +} diff --git a/fixtures/next-16-webpack/config.yaml b/fixtures/next-16-webpack/config.yaml index 7707779..e7c727f 100644 --- a/fixtures/next-16-webpack/config.yaml +++ b/fixtures/next-16-webpack/config.yaml @@ -1,3 +1,3 @@ '@harperfast/nextjs': package: '@harperfast/nextjs' - webpack: true + bundler: webpack diff --git a/integrationTests/next-15-turbopack.pw.ts b/integrationTests/next-15-turbopack.pw.ts new file mode 100644 index 0000000..9590e3b --- /dev/null +++ b/integrationTests/next-15-turbopack.pw.ts @@ -0,0 +1,18 @@ +import { fixture } from './fixture.ts'; + +const { test, expect } = fixture('next-15-turbopack'); + +test('home page renders', async ({ page, harper }) => { + await page.goto(harper.httpURL); + await expect(page.locator('h1')).toHaveText('Next.js v15'); +}); + +test('page title is set', async ({ page, harper }) => { + await page.goto(harper.httpURL); + await expect(page).toHaveTitle('Harper - Next.js v15 App'); +}); + +test('status endpoint returns 200', async ({ request, harper }) => { + const response = await request.get(`${harper.operationsAPIURL}/health`); + expect(response.status()).toBe(200); +}); diff --git a/src/plugin.ts b/src/plugin.ts index 30e048e..fad6802 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -14,8 +14,11 @@ import type NextBuildModule15 from 'next-15/dist/cli/next-build.d.ts'; import type NextModule16 from 'next-16'; import type NextBuildModule16 from 'next-16/dist/cli/next-build.d.ts'; +type Bundler = 'webpack' | 'turbopack'; + interface NextPluginConfig extends Config { buildOnly: boolean; + bundler: Bundler; dev: boolean; // @ts-expect-error files?: FilesOption; @@ -23,7 +26,6 @@ interface NextPluginConfig extends Config { prebuilt: boolean; runFirst: boolean; securePort?: number; - webpack: boolean; } // Bringing this forward from extension since some validation is better than none. @@ -64,16 +66,22 @@ function resolveConfig(scope: Scope): NextPluginConfig { } 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'); - assertType('webpack', options.webpack, 'boolean'); + + 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 as boolean) ?? false, + // bundler default is set later in handleApplication() based on the detected Next.js version + bundler: options.bundler as Bundler, dev: (options.dev as boolean) ?? false, // @ts-expect-error files: options.files, @@ -81,7 +89,6 @@ function resolveConfig(scope: Scope): NextPluginConfig { prebuilt: (options.prebuilt as boolean) ?? false, runFirst: (options.runFirst as boolean) ?? false, securePort: options.securePort as number, - webpack: (options.webpack as boolean) ?? false, } satisfies NextPluginConfig; } @@ -184,6 +191,17 @@ export async function handleApplication(scope: Scope) { 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) { @@ -297,7 +315,7 @@ async function build(scope: Scope, config: NextPluginConfig, next: NextPackage) { lint: false, mangling: true, - turbopack: false, + ...(config.bundler === 'turbopack' && { turbopack: true }), experimentalDebugMemoryUsage: false, experimentalBuildMode: 'default', }, @@ -308,7 +326,7 @@ async function build(scope: Scope, config: NextPluginConfig, next: NextPackage) await next.build( { mangling: true, - webpack: config.webpack, + ...(config.bundler === 'webpack' && { webpack: true }), experimentalDebugMemoryUsage: false, experimentalBuildMode: 'default', }, @@ -341,10 +359,10 @@ async function serve(scope: Scope, config: NextPluginConfig, next: NextPackage) app = next.server({ dir: scope.directory, dev: config.dev }); break; case 15: - app = next.server({ dir: scope.directory, dev: config.dev, turbopack: false }); + 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.webpack && { webpack: true }) }); + app = next.server({ dir: scope.directory, dev: config.dev, ...(config.bundler === 'webpack' && { webpack: true }) }); break; } From 273973c3c700d2a639d7ba343f92c5e58f621a9b Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Wed, 6 May 2026 10:10:42 -0600 Subject: [PATCH 4/4] docs: document bundler option in README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index dbf7063..5c15c43 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,20 @@ export default withHarper( All plugin options are configured in `config.yaml` under the `@harperfast/nextjs` key. All options are optional. +### `bundler: 'webpack' | 'turbopack'` + +Selects the bundler used for building and serving the Next.js application. The default depends on the detected Next.js version: + +- **Next.js v16**: defaults to `turbopack` (matching the Next.js v16 default) +- **Next.js v15**: defaults to `webpack` (matching the Next.js v15 default) +- **Next.js v14**: always uses `webpack` (turbopack is not supported) + +```yaml +'@harperfast/nextjs': + package: '@harperfast/nextjs' + bundler: webpack +``` + ### `dev: boolean` Enables Next.js development mode with hot module replacement (HMR). Defaults to `false`.