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`. 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/.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..e7c727f --- /dev/null +++ b/fixtures/next-16-webpack/config.yaml @@ -0,0 +1,3 @@ +'@harperfast/nextjs': + package: '@harperfast/nextjs' + bundler: webpack 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-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/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/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 } } diff --git a/src/plugin.ts b/src/plugin.ts index 109b39b..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; @@ -63,15 +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'); + 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, @@ -181,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) { @@ -294,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', }, @@ -305,7 +326,7 @@ async function build(scope: Scope, config: NextPluginConfig, next: NextPackage) await next.build( { mangling: true, - webpack: true, + ...(config.bundler === 'webpack' && { webpack: true }), experimentalDebugMemoryUsage: false, experimentalBuildMode: 'default', }, @@ -338,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, turbopack: false }); + app = next.server({ dir: scope.directory, dev: config.dev, ...(config.bundler === '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') }), };