Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions fixtures/next-15-turbopack/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
11 changes: 11 additions & 0 deletions fixtures/next-15-turbopack/app/layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const metadata = {
title: 'Harper - Next.js v15 App',
};

export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
);
}
7 changes: 7 additions & 0 deletions fixtures/next-15-turbopack/app/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default async function Page() {
return (
<div>
<h1>Next.js v15</h1>
</div>
);
}
3 changes: 3 additions & 0 deletions fixtures/next-15-turbopack/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
'@harperfast/nextjs':
package: '@harperfast/nextjs'
bundler: turbopack
3 changes: 3 additions & 0 deletions fixtures/next-15-turbopack/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { withHarper } from '@harperfast/nextjs';

export default withHarper({});
16 changes: 16 additions & 0 deletions fixtures/next-15-turbopack/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 1 addition & 1 deletion fixtures/next-16-webpack/config.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
'@harperfast/nextjs':
package: '@harperfast/nextjs'
webpack: true
bundler: webpack
18 changes: 18 additions & 0 deletions integrationTests/next-15-turbopack.pw.ts
Original file line number Diff line number Diff line change
@@ -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);
});
32 changes: 25 additions & 7 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@ 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;
port?: number;
prebuilt: boolean;
runFirst: boolean;
securePort?: number;
webpack: boolean;
}

// Bringing this forward from extension since some validation is better than none.
Expand Down Expand Up @@ -64,24 +66,29 @@ 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,
port: options.port as number,
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;
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
},
Expand All @@ -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',
},
Expand Down Expand Up @@ -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;
}

Expand Down
Loading