Skip to content
Draft
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
107 changes: 107 additions & 0 deletions .github/workflows/nextjs-pages-router-deploy-suite.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: Next.js Pages Router Deploy Suite

on:
# Temporary PR trigger while validating this first-pass Pages Router adapter
# parity suite. Remove the pull_request trigger before merging.
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
inputs:
next-ref:
description: Next.js ref to test against
required: true
default: v16.2.4
test-concurrency:
description: Per-shard Next.js test concurrency
required: true
default: "2"

permissions:
contents: read

concurrency:
group: nextjs-pages-router-deploy-suite-${{ github.ref }}-${{ inputs.next-ref || 'v16.2.4' }}
cancel-in-progress: false

jobs:
pages-router-deploy-suite:
name: Pages Router deploy suite (${{ matrix.shard }}/16)
runs-on: ubuntu-latest
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]

steps:
- name: Checkout vinext
uses: actions/checkout@v6

- uses: ./.github/actions/setup
with:
node-version: "22"

- name: Enable pnpm shim for Next.js scripts
run: corepack enable pnpm

- name: Checkout Next.js
uses: actions/checkout@v6
with:
repository: vercel/next.js
ref: ${{ inputs.next-ref || 'v16.2.4' }}
path: next.js

- name: Locate pnpm store
id: pnpm-store
run: echo "path=$(corepack pnpm store path --silent)" >> "$GITHUB_OUTPUT"

- name: Cache Next.js pnpm store
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: nextjs-pages-router-pnpm-${{ runner.os }}-${{ hashFiles('next.js/pnpm-lock.yaml') }}
restore-keys: |
nextjs-pages-router-pnpm-${{ runner.os }}-

- name: Cache Playwright browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: nextjs-pages-router-playwright-${{ runner.os }}-${{ hashFiles('next.js/pnpm-lock.yaml') }}
restore-keys: |
nextjs-pages-router-playwright-${{ runner.os }}-

- name: Build vinext
run: vp run vinext#build

- name: Prepare Next.js checkout
run: bash scripts/run-nextjs-deploy-suite.sh "$GITHUB_WORKSPACE/next.js"
env:
CI: true
VINEXT_BUILD: "0"
NEXTJS_PREPARE: "1"
NEXTJS_PREPARE_ONLY: "1"

- name: Generate Pages Router deploy manifest
run: node scripts/nextjs-pages-router-deploy-manifest.mjs "$GITHUB_WORKSPACE/next.js" "$RUNNER_TEMP/nextjs-pages-router-deploy-manifest.json"

- name: Run Pages Router deploy shard
run: bash scripts/run-nextjs-deploy-suite.sh "$GITHUB_WORKSPACE/next.js"
env:
CI: true
VINEXT_BUILD: "0"
NEXT_TEST_JOB: ${{ matrix.shard }}
NEXT_TEST_GROUP: ${{ matrix.shard }}/16
NEXT_TEST_CONCURRENCY: ${{ inputs.test-concurrency || '2' }}
NEXT_EXTERNAL_TESTS_FILTERS: ${{ runner.temp }}/nextjs-pages-router-deploy-manifest.json

- name: Upload deploy debug artifacts
if: failure()
uses: actions/upload-artifact@v7
with:
name: nextjs-pages-router-debug-${{ matrix.shard }}
path: |
reports/nextjs-deploy-debug/
next.js/test/**/*.results.json
if-no-files-found: ignore
retention-days: 7
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
coverage/
reports/

# Worktrees
.worktrees/
Expand Down
3 changes: 2 additions & 1 deletion benchmarks/vinext/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"vinext": ["../../packages/vinext/src/index.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@
"dependencies": {
"@unpic/react": "catalog:",
"@vercel/og": "catalog:",
"jiti": "catalog:",
"magic-string": "catalog:",
"urlpattern-polyfill": "^10.1.0",
"vite-plugin-commonjs": "catalog:",
"vite-tsconfig-paths": "catalog:"
},
Expand Down
37 changes: 31 additions & 6 deletions packages/vinext/src/build/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,32 @@ function buildUrlFromParams(pattern: string, params: Record<string, string | str
return "/" + result.join("/");
}

type PagesStaticPathEntry =
| string
| {
params: Record<string, string | string[]>;
locale?: string;
};

export function normalizePagesStaticPathEntry(
pattern: string,
pathEntry: PagesStaticPathEntry,
): { urlPath: string; params: Record<string, string | string[]> } {
if (typeof pathEntry === "string") {
const pathname = pathEntry.split("?")[0] || "/";
return {
urlPath: pathname.startsWith("/") ? pathname : `/${pathname}`,
params: {},
};
}

const params = pathEntry.params ?? {};
return {
urlPath: buildUrlFromParams(pattern, params),
params,
};
}

/**
* Determine the HTML output file path for a URL.
* Respects trailingSlash config.
Expand Down Expand Up @@ -419,7 +445,7 @@ export async function prerenderPages({
params: Record<string, string>;
module: {
getStaticPaths?: (opts: { locales: string[]; defaultLocale: string }) => Promise<{
paths: Array<{ params: Record<string, string | string[]> }>;
paths: PagesStaticPathEntry[];
fallback: unknown;
}>;
getStaticProps?: unknown;
Expand Down Expand Up @@ -458,7 +484,7 @@ export async function prerenderPages({
}
if (text === "null") return { paths: [], fallback: false };
return JSON.parse(text) as {
paths: Array<{ params: Record<string, string | string[]> }>;
paths: PagesStaticPathEntry[];
fallback: unknown;
};
}
Expand Down Expand Up @@ -539,10 +565,9 @@ export async function prerenderPages({
continue;
}

const paths: Array<{ params: Record<string, string | string[]> }> =
pathsResult?.paths ?? [];
for (const { params } of paths) {
const urlPath = buildUrlFromParams(route.pattern, params);
const paths: PagesStaticPathEntry[] = pathsResult?.paths ?? [];
for (const pathEntry of paths) {
const { urlPath, params } = normalizePagesStaticPathEntry(route.pattern, pathEntry);
pagesToRender.push({ route, urlPath, params, revalidate });
}
} else {
Expand Down
11 changes: 9 additions & 2 deletions packages/vinext/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import fs from "node:fs";
import { pathToFileURL } from "node:url";
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
import { detectPackageManager, ensureViteConfigCompatibility } from "./utils/project.js";
import {
detectPackageManager,
ensureViteConfigCompatibility,
execPackageManagerCommand,
} from "./utils/project.js";
import { deploy as runDeploy, parseDeployArgs } from "./deploy.js";
import { runCheck, formatReport } from "./check.js";
import { init as runInit, getReactUpgradeDeps } from "./init.js";
Expand Down Expand Up @@ -407,7 +411,10 @@ async function buildApp() {
const installCmd = detectPackageManager(process.cwd()).replace(/ -D$/, "");
const [pm, ...pmArgs] = installCmd.split(" ");
console.log(" Upgrading React for RSC compatibility...");
execFileSync(pm, [...pmArgs, ...reactUpgrade], { cwd: process.cwd(), stdio: "inherit" });
execPackageManagerCommand(pm, [...pmArgs, ...reactUpgrade], {
cwd: process.cwd(),
stdio: "inherit",
});
}
}

Expand Down
15 changes: 13 additions & 2 deletions packages/vinext/src/client/validate-module-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@ export function isValidModulePath(p: unknown): p is string {
if (p.startsWith("//")) return false;
// Must not contain protocol (prevents importing from external URLs)
if (p.includes("://")) return false;
// Must not traverse directories
if (p.includes("..")) return false;
// Must not traverse directories. Check path segments instead of any ".."
// substring so valid catch-all route chunks like "__...slug__-hash.js" work.
const pathOnly = p.split(/[?#]/, 1)[0];
const segments = pathOnly.split(/[\\/]/);
for (const segment of segments) {
let decoded = segment;
try {
decoded = decodeURIComponent(segment);
} catch {
// Malformed escapes are not traversal segments by themselves.
}
if (segment === ".." || decoded.split(/[\\/]/).includes("..")) return false;
}
return true;
}
Loading
Loading