Compare commits

...

12 Commits

Author SHA1 Message Date
Sean Massa
b30f000d2a Publish Stable
- vercel@28.16.4
 - @vercel/next@3.5.2
2023-02-21 18:51:33 -06:00
JJ Kasper
f78051ada9 [next] Ensure .rsc outputs are not created for route handlers (#9503) 2023-02-21 16:41:54 -08:00
Don Alvarez
10bc74904c [docs] Improve docs for @vercel/next no pages built message (#9494)
After many hours of debugging, I tracked down that having an old Node
version (eg 14.x) listed in your Vercel project settings can result in
the build step failing with a confusing and unhelpful error message
"`@vercel/next` No Serverless Pages Built". Note that this is a case
where it "can" cause it to fail, including with NextJS 13.1.6 and Vercel
CLI 28.16.2, but it is not guaranteed to fail. I have six NextJS
projects. They have identical next.config.js, tsconfig.json,
eslintrc.js, and .gitignore files, and other than a few seemingly
non-critical dependencies they have identical package.json files. Four
of the six consistently built and deployed in the cloud without issue.
Two consistently failed to build in the cloud. All built successfully
locally including using vercel build locally, and all would vercel
deploy --prebuilt successfully. Switching all the vercel cloud project
settings from Node 14.x to Node 18.x enabled all the projects to build
and deploy successfully in the vercel cloud without needing local vercel
build and local vercel deploy --prebuilt steps.

---------

Co-authored-by: Steven <steven@ceriously.com>
2023-02-21 11:32:03 -05:00
Steven
c8f7a9a874 [cli] Fix global detection for fnm (#9496)
Fix `--global` install detection when
[fnm](https://github.com/Schniz/fnm) was used to install node
2023-02-21 10:11:08 -05:00
Sean Massa
2fd3315221 Publish Stable
- vercel@28.16.3
 - @vercel/next@3.5.1
 - @vercel/remix@1.3.5
2023-02-21 08:53:23 -06:00
JJ Kasper
54ef027cbe [next] Fix rsc routes order (#9493)
This ensures we maintain the correct order for our rsc routes with
reference to middleware so that they match correctly. This also adds a
regression test to ensure it's working as expected.

Fixes: https://github.com/vercel/next.js/issues/45331
x-ref: [slack
thread](https://vercel.slack.com/archives/C035J346QQL/p1676926522772859?thread_ts=1676926096.412539&cid=C035J346QQL)
2023-02-20 17:54:17 -08:00
Shu Ding
6620c7f600 [next] Add initial vary header to all prerendered pages when RSC is enabled (#9481)
This PR changes the fallback headers that relate to RSC to the defaults that Next.js currently uses. Also, it sets the initial `vary` header to all prerendered routes when RSC is enabled (`routesManifest?.rsc`), even for the pages directory. 

That's because although the pages directory won't return any RSC payload, it can still be used in a project that contains app routes. When the app route requests a page route for RSC data, it's important for the browser to not accidentally cache that result hence we need the `vary` header to set there as well.

More related discussions can be found [here](https://linear.app/vercel/issue/NEXT-382/add-vary-rsc-etc-header-to-all-responses-to-ensure-browser-caching).
2023-02-20 19:35:06 +00:00
JJ Kasper
38f40f1c15 [next] Handle prerender-manifest v4 (#9489)
Co-authored-by: Tim Neutkens <tim@timneutkens.nl>
2023-02-20 11:41:24 +01:00
Nathan Rajlich
63211b8b89 [remix] Add unit tests (#9469)
Moves parts of the `@vercel/remix` builder into util functions that have isolated unit tests. No functionality changes.
2023-02-17 18:46:33 +00:00
Ikko Eltociear Ashimine
83ee5ea2b8 [cli] Fix typo in get-latest-worker.js (#9470)
persistance -> persistence

Co-authored-by: Chris Barber <chris.barber@vercel.com>
2023-02-17 11:45:44 -05:00
Ethan Arrowood
f063645646 [examples] Update remix template (#9472)
Previous PR was merged automatically but need to fix a dependency
version
2023-02-17 09:42:26 -07:00
Ethan Arrowood
4f8c5e344d [examples] update remix template (#9455)
Updates our remix template so that it works with our new Remix changes
2023-02-17 15:10:17 +00:00
35 changed files with 2067 additions and 2394 deletions

View File

@@ -14,7 +14,9 @@ In order to create the smallest possible lambdas Next.js has to be configured to
npm install next --save
```
2. Add the `now-build` script to your `package.json`
2. Check [Node.js Version](https://vercel.link/node-version) in your Project Settings. Using an old or incompatible version of Node.js can cause the Build Step to fail with this error message.
3. Add the `now-build` script to your `package.json` [deprecated]
```json
{
@@ -24,7 +26,7 @@ npm install next --save
}
```
3. Add `target: 'serverless'` to `next.config.js` [deprecated]
4. Add `target: 'serverless'` to `next.config.js` [deprecated]
```js
module.exports = {
@@ -33,9 +35,9 @@ module.exports = {
};
```
4. Remove `distDir` from `next.config.js` as `@vercel/next` can't parse this file and expects your build output at `/.next`
5. Remove `distDir` from `next.config.js` as `@vercel/next` can't parse this file and expects your build output at `/.next`
5. Optionally make sure the `"src"` in `"builds"` points to your application `package.json`
6. Optionally make sure the `"src"` in `"builds"` points to your application `package.json`
```js
{

View File

@@ -13,10 +13,10 @@ function hydrate() {
});
}
if (window.requestIdleCallback) {
window.requestIdleCallback(hydrate);
if (typeof requestIdleCallback === "function") {
requestIdleCallback(hydrate);
} else {
// Safari doesn't support requestIdleCallback
// https://caniuse.com/requestidlecallback
window.setTimeout(hydrate, 1);
setTimeout(hydrate, 1);
}

View File

@@ -1,21 +1,14 @@
import type { EntryContext } from "@remix-run/node";
import handleRequest from "@vercel/remix-entry-server";
import { RemixServer } from "@remix-run/react";
import { renderToString } from "react-dom/server";
import type { EntryContext } from "@remix-run/server-runtime";
export default function handleRequest(
export default function (
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const markup = renderToString(
<RemixServer context={remixContext} url={request.url} />
);
responseHeaders.set("Content-Type", "text/html");
return new Response("<!DOCTYPE html>" + markup, {
headers: responseHeaders,
status: responseStatusCode,
});
const remixServer = <RemixServer context={remixContext} url={request.url} />;
return handleRequest(request, responseStatusCode, responseHeaders, remixServer)
}

View File

@@ -0,0 +1,11 @@
export const config = {
runtime: 'edge'
};
export default function Edge() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
<h1>Welcome to Remix@Edge</h1>
</div>
);
}

View File

@@ -6,20 +6,20 @@
"dev": "remix dev"
},
"dependencies": {
"@remix-run/node": "^1.7.6",
"@remix-run/react": "^1.7.6",
"@remix-run/vercel": "^1.7.6",
"@vercel/analytics": "^0.1.5",
"@vercel/node": "^2.7.0",
"@remix-run/node": "^1.13.0",
"@remix-run/react": "^1.13.0",
"@remix-run/serve": "^1.13.0",
"@remix-run/server-runtime": "^1.13.0",
"@vercel/analytics": "^0.1.10",
"@vercel/remix-entry-server": "^0.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^1.7.6",
"@remix-run/eslint-config": "^1.7.6",
"@remix-run/serve": "^1.7.6",
"@remix-run/dev": "^1.13.0",
"@remix-run/eslint-config": "^1.13.0",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.9",
"@types/react-dom": "^18.0.11",
"eslint": "^8.28.0",
"typescript": "^4.9.3"
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,10 @@
/** @type {import('@remix-run/dev').AppConfig} */
/**
* @type {import('@remix-run/dev').AppConfig}
*/
module.exports = {
serverBuildTarget: "vercel",
// When running locally in development mode, we use the built in remix
// server. This does not understand the vercel lambda module format,
// so we default back to the standard build output.
server: process.env.NODE_ENV === "development" ? undefined : "./server.js",
ignoredRouteFiles: ["**/.*"],
ignoredRouteFiles: ['**/.*'],
// appDirectory: "app",
// assetsBuildDirectory: "public/build",
// serverBuildPath: "api/index.js",
// serverBuildPath: "build/index.js",
// publicPath: "/build/",
};

View File

@@ -1,4 +0,0 @@
import { createRequestHandler } from "@remix-run/vercel";
import * as build from "@remix-run/dev/server-build";
export default createRequestHandler({ build, mode: process.env.NODE_ENV });

View File

@@ -1,6 +1,6 @@
{
"name": "vercel",
"version": "28.16.2",
"version": "28.16.4",
"preferGlobal": true,
"license": "Apache-2.0",
"description": "The command-line interface for Vercel",
@@ -44,11 +44,11 @@
"@vercel/build-utils": "6.3.0",
"@vercel/go": "2.3.7",
"@vercel/hydrogen": "0.0.53",
"@vercel/next": "3.5.0",
"@vercel/next": "3.5.2",
"@vercel/node": "2.9.6",
"@vercel/python": "3.1.49",
"@vercel/redwood": "1.1.5",
"@vercel/remix": "1.3.4",
"@vercel/remix": "1.3.5",
"@vercel/ruby": "1.3.66",
"@vercel/static-build": "1.3.10"
},

View File

@@ -22,7 +22,7 @@ const { format, inspect } = require('util');
/**
* An simple output helper which accumulates error and debug log messages in
* memory for potential persistance to disk while immediately outputting errors
* memory for potential persistence to disk while immediately outputting errors
* and debug messages, when the `--debug` flag is set, to `stderr`.
*/
class WorkerOutput {

View File

@@ -76,6 +76,10 @@ async function isGlobal() {
return true;
}
if (installPath.includes(['', 'fnm', 'node-versions', ''].join(sep))) {
return true;
}
const prefixPath =
process.env.PREFIX ||
process.env.npm_config_prefix ||

View File

@@ -1,6 +1,6 @@
{
"name": "@vercel/next",
"version": "3.5.0",
"version": "3.5.2",
"license": "MIT",
"main": "./dist/index",
"homepage": "https://vercel.com/docs/runtimes#official-runtimes/next-js",

View File

@@ -2116,6 +2116,8 @@ export const build: BuildV2 = async ({
...Object.entries(prerenderManifest.fallbackRoutes),
...Object.entries(prerenderManifest.blockingFallbackRoutes),
].forEach(([, { dataRouteRegex, dataRoute }]) => {
if (!dataRoute || !dataRouteRegex) return;
dataRoutes.push({
// Next.js provided data route regex
src: dataRouteRegex.replace(

View File

@@ -182,7 +182,10 @@ export async function serverBuild({
}
const pageMatchesApi = (page: string) => {
return page.startsWith('api/') || page === 'api.js';
return (
!appPathRoutesManifest?.[page] &&
(page.startsWith('api/') || page === 'api.js')
);
};
const { i18n } = routesManifest;
@@ -1136,8 +1139,19 @@ export async function serverBuild({
// to match prerenders so we can route the same when the
// __rsc__ header is present
const edgeFunctions = middleware.edgeFunctions;
// allow looking up original route from normalized route
const inverseAppPathManifest: Record<string, string> = {};
for (const ogRoute of Object.keys(appPathRoutesManifest)) {
inverseAppPathManifest[appPathRoutesManifest[ogRoute]] = ogRoute;
}
for (let route of Object.values(appPathRoutesManifest)) {
const ogRoute = inverseAppPathManifest[route];
if (ogRoute.endsWith('/route')) {
continue;
}
route = path.posix.join('./', route === '/' ? '/index' : route);
if (lambdas[route]) {
@@ -1150,6 +1164,10 @@ export async function serverBuild({
}
const rscHeader = routesManifest.rsc?.header?.toLowerCase() || '__rsc__';
const rscVaryHeader =
routesManifest?.rsc?.varyHeader ||
'RSC, Next-Router-State-Tree, Next-Router-Prefetch';
const completeDynamicRoutes: typeof dynamicRoutes = [];
if (appDir) {
@@ -1422,7 +1440,9 @@ export async function serverBuild({
},
],
dest: path.posix.join('/', entryDirectory, '/index.rsc'),
headers: { vary: rscVaryHeader },
continue: true,
override: true,
},
{
src: `^${path.posix.join(
@@ -1437,7 +1457,9 @@ export async function serverBuild({
},
],
dest: path.posix.join('/', entryDirectory, '/$1.rsc'),
headers: { vary: rscVaryHeader },
continue: true,
override: true,
},
]
: []),

View File

@@ -849,16 +849,18 @@ export type NextPrerenderedRoutes = {
staticRoutes: {
[route: string]: {
initialRevalidate: number | false;
dataRoute: string;
dataRoute: string | null;
srcRoute: string | null;
initialStatus?: number;
initialHeaders?: Record<string, string>;
};
};
blockingFallbackRoutes: {
[route: string]: {
routeRegex: string;
dataRoute: string;
dataRouteRegex: string;
dataRoute: string | null;
dataRouteRegex: string | null;
};
};
@@ -866,16 +868,16 @@ export type NextPrerenderedRoutes = {
[route: string]: {
fallback: string;
routeRegex: string;
dataRoute: string;
dataRouteRegex: string;
dataRoute: string | null;
dataRouteRegex: string | null;
};
};
omittedRoutes: {
[route: string]: {
routeRegex: string;
dataRoute: string;
dataRouteRegex: string;
dataRoute: string | null;
dataRouteRegex: string | null;
};
};
@@ -1068,6 +1070,30 @@ export async function getPrerenderManifest(
previewModeId: string;
};
notFoundRoutes?: string[];
}
| {
version: 4;
routes: {
[route: string]: {
initialRevalidateSeconds: number | false;
srcRoute: string | null;
dataRoute: string | null;
initialStatus?: number;
initialHeaders?: Record<string, string>;
};
};
dynamicRoutes: {
[route: string]: {
routeRegex: string;
fallback: string | false;
dataRoute: string | null;
dataRouteRegex: string | null;
};
};
preview: {
previewModeId: string;
};
notFoundRoutes?: string[];
} = JSON.parse(await fs.readFile(pathPrerenderManifest, 'utf8'));
switch (manifest.version) {
@@ -1122,7 +1148,8 @@ export async function getPrerenderManifest(
return ret;
}
case 2:
case 3: {
case 3:
case 4: {
const routes = Object.keys(manifest.routes);
const lazyRoutes = Object.keys(manifest.dynamicRoutes);
@@ -1143,6 +1170,15 @@ export async function getPrerenderManifest(
routes.forEach(route => {
const { initialRevalidateSeconds, dataRoute, srcRoute } =
manifest.routes[route];
let initialStatus: undefined | number;
let initialHeaders: undefined | Record<string, string>;
if (manifest.version === 4) {
initialStatus = manifest.routes[route].initialStatus;
initialHeaders = manifest.routes[route].initialHeaders;
}
ret.staticRoutes[route] = {
initialRevalidate:
initialRevalidateSeconds === false
@@ -1150,6 +1186,8 @@ export async function getPrerenderManifest(
: Math.max(1, initialRevalidateSeconds),
dataRoute,
srcRoute,
initialStatus,
initialHeaders,
};
});
@@ -1652,7 +1690,7 @@ export const onPrerenderRouteInitial = (
const { initialRevalidate, srcRoute, dataRoute } = pr;
const route = srcRoute || routeKey;
const isAppPathRoute = appDir && dataRoute?.endsWith('.rsc');
const isAppPathRoute = appDir && (!dataRoute || dataRoute?.endsWith('.rsc'));
const routeNoLocale = routesManifest?.i18n
? normalizeLocalePath(routeKey, routesManifest.i18n.locales).pathname
@@ -1810,7 +1848,9 @@ export const onPrerenderRoute =
let initialRevalidate: false | number;
let srcRoute: string | null;
let dataRoute: string;
let dataRoute: string | null;
let initialStatus: number | undefined;
let initialHeaders: Record<string, string> | undefined;
if (isFallback || isBlocking) {
const pr = isFallback
@@ -1833,44 +1873,60 @@ export const onPrerenderRoute =
dataRoute = prerenderManifest.omittedRoutes[routeKey].dataRoute;
} else {
const pr = prerenderManifest.staticRoutes[routeKey];
({ initialRevalidate, srcRoute, dataRoute } = pr);
({
initialRevalidate,
srcRoute,
dataRoute,
initialHeaders,
initialStatus,
} = pr);
}
let isAppPathRoute = false;
// TODO: leverage manifest to determine app paths more accurately
if (appDir && srcRoute && dataRoute.endsWith('.rsc')) {
if (appDir && srcRoute && (!dataRoute || dataRoute?.endsWith('.rsc'))) {
isAppPathRoute = true;
}
const isOmittedOrNotFound = isOmitted || isNotFound;
const htmlFsRef =
isBlocking || (isNotFound && !static404Page)
? // Blocking pages do not have an HTML fallback
null
: new FileFsRef({
fsPath: path.join(
isAppPathRoute && !isOmittedOrNotFound && appDir
? appDir
: pagesDir,
isFallback
? // Fallback pages have a special file.
addLocaleOrDefault(
prerenderManifest.fallbackRoutes[routeKey].fallback,
routesManifest,
locale
)
: // Otherwise, the route itself should exist as a static HTML
// file.
`${
isOmittedOrNotFound
? addLocaleOrDefault('/404', routesManifest, locale)
: routeFileNoExt
}.html`
),
});
let htmlFsRef: FileFsRef | null;
if (appDir && !dataRoute && isAppPathRoute && !(isBlocking || isFallback)) {
const contentType = initialHeaders?.['content-type'];
htmlFsRef = new FileFsRef({
fsPath: path.join(appDir, `${routeFileNoExt}.body`),
contentType: contentType || 'text/html;charset=utf-8',
});
} else {
htmlFsRef =
isBlocking || (isNotFound && !static404Page)
? // Blocking pages do not have an HTML fallback
null
: new FileFsRef({
fsPath: path.join(
isAppPathRoute && !isOmittedOrNotFound && appDir
? appDir
: pagesDir,
isFallback
? // Fallback pages have a special file.
addLocaleOrDefault(
prerenderManifest.fallbackRoutes[routeKey].fallback,
routesManifest,
locale
)
: // Otherwise, the route itself should exist as a static HTML
// file.
`${
isOmittedOrNotFound
? addLocaleOrDefault('/404', routesManifest, locale)
: routeFileNoExt
}.html`
),
});
}
const jsonFsRef =
// JSON data does not exist for fallback or blocking pages
isFallback || isBlocking || (isNotFound && !static404Page)
isFallback || isBlocking || (isNotFound && !static404Page) || !dataRoute
? null
: new FileFsRef({
fsPath: path.join(
@@ -1908,16 +1964,20 @@ export const onPrerenderRoute =
);
let lambda: undefined | Lambda;
let outputPathData = path.posix.join(entryDirectory, dataRoute);
let outputPathData: null | string = null;
if (nonDynamicSsg || isFallback || isOmitted) {
outputPathData = outputPathData.replace(
new RegExp(`${escapeStringRegexp(origRouteFileNoExt)}.json$`),
// ensure we escape "$" correctly while replacing as "$" is a special
// character, we need to do double escaping as first is for the initial
// replace on the routeFile and then the second on the outputPath
`${routeFileNoExt.replace(/\$/g, '$$$$')}.json`
);
if (dataRoute) {
outputPathData = path.posix.join(entryDirectory, dataRoute);
if (nonDynamicSsg || isFallback || isOmitted) {
outputPathData = outputPathData.replace(
new RegExp(`${escapeStringRegexp(origRouteFileNoExt)}.json$`),
// ensure we escape "$" correctly while replacing as "$" is a special
// character, we need to do double escaping as first is for the initial
// replace on the routeFile and then the second on the outputPath
`${routeFileNoExt.replace(/\$/g, '$$$$')}.json`
);
}
}
if (isSharedLambdas) {
@@ -1954,7 +2014,7 @@ export const onPrerenderRoute =
if (htmlFsRef == null || jsonFsRef == null) {
throw new NowBuildError({
code: 'NEXT_HTMLFSREF_JSONFSREF',
message: 'invariant: htmlFsRef != null && jsonFsRef != null',
message: `invariant: htmlFsRef != null && jsonFsRef != null ${routeFileNoExt}`,
});
}
@@ -1966,7 +2026,10 @@ export const onPrerenderRoute =
) {
htmlFsRef.contentType = htmlContentType;
prerenders[outputPathPage] = htmlFsRef;
prerenders[outputPathData] = jsonFsRef;
if (outputPathData) {
prerenders[outputPathData] = jsonFsRef;
}
}
}
const isNotFoundPreview =
@@ -2028,11 +2091,10 @@ export const onPrerenderRoute =
allowQuery = [];
}
}
const rscVaryHeader =
routesManifest?.rsc?.varyHeader ||
'__rsc__, __next_router_state_tree__, __next_router_prefetch__';
const rscContentTypeHeader =
routesManifest?.rsc?.contentTypeHeader || 'application/octet-stream';
const rscEnabled = !!routesManifest?.rsc;
const rscVaryHeader = routesManifest?.rsc?.varyHeader || 'RSC, Next-Router-State-Tree, Next-Router-Prefetch';
const rscContentTypeHeader = routesManifest?.rsc?.contentTypeHeader || 'text/x-component';
prerenders[outputPathPage] = new Prerender({
expiration: initialRevalidate,
@@ -2041,27 +2103,8 @@ export const onPrerenderRoute =
fallback: htmlFsRef,
group: prerenderGroup,
bypassToken: prerenderManifest.bypassToken,
...(isNotFound
? {
initialStatus: 404,
}
: {}),
...(isAppPathRoute
? {
initialHeaders: {
vary: rscVaryHeader,
},
}
: {}),
});
prerenders[outputPathData] = new Prerender({
expiration: initialRevalidate,
lambda,
allowQuery,
fallback: jsonFsRef,
group: prerenderGroup,
bypassToken: prerenderManifest.bypassToken,
initialStatus,
initialHeaders,
...(isNotFound
? {
@@ -2069,16 +2112,42 @@ export const onPrerenderRoute =
}
: {}),
...(isAppPathRoute
...(rscEnabled
? {
initialHeaders: {
'content-type': rscContentTypeHeader,
...initialHeaders,
vary: rscVaryHeader,
},
}
: {}),
});
if (outputPathData) {
prerenders[outputPathData] = new Prerender({
expiration: initialRevalidate,
lambda,
allowQuery,
fallback: jsonFsRef,
group: prerenderGroup,
bypassToken: prerenderManifest.bypassToken,
...(isNotFound
? {
initialStatus: 404,
}
: {}),
...(rscEnabled
? {
initialHeaders: {
'content-type': rscContentTypeHeader,
vary: rscVaryHeader,
},
}
: {}),
});
}
++prerenderGroup;
if (routesManifest?.i18n && isBlocking) {
@@ -2092,29 +2161,30 @@ export const onPrerenderRoute =
path.posix.join(entryDirectory, localeRouteFileNoExt),
isServerMode
);
const localeOutputPathData = outputPathData.replace(
new RegExp(`${escapeStringRegexp(origRouteFileNoExt)}.json$`),
`${localeRouteFileNoExt}${
localeRouteFileNoExt !== origRouteFileNoExt &&
origRouteFileNoExt === '/index'
? '/index'
: ''
}.json`
);
const origPrerenderPage = prerenders[outputPathPage];
const origPrerenderData = prerenders[outputPathData];
prerenders[localeOutputPathPage] = {
...origPrerenderPage,
group: prerenderGroup,
} as Prerender;
prerenders[localeOutputPathData] = {
...origPrerenderData,
group: prerenderGroup,
} as Prerender;
if (outputPathData) {
const localeOutputPathData = outputPathData.replace(
new RegExp(`${escapeStringRegexp(origRouteFileNoExt)}.json$`),
`${localeRouteFileNoExt}${
localeRouteFileNoExt !== origRouteFileNoExt &&
origRouteFileNoExt === '/index'
? '/index'
: ''
}.json`
);
const origPrerenderData = prerenders[outputPathData];
prerenders[localeOutputPathData] = {
...origPrerenderData,
group: prerenderGroup,
} as Prerender;
}
++prerenderGroup;
}
}
@@ -2491,9 +2561,13 @@ export async function getMiddlewareBundle({
// app/index/page -> index/index
if (shortPath.startsWith('pages/')) {
shortPath = shortPath.replace(/^pages\//, '');
} else if (shortPath.startsWith('app/') && shortPath.endsWith('/page')) {
} else if (
shortPath.startsWith('app/') &&
(shortPath.endsWith('/page') || shortPath.endsWith('/route'))
) {
shortPath =
shortPath.replace(/^app\//, '').replace(/(^|\/)page$/, '') || 'index';
shortPath.replace(/^app\//, '').replace(/(^|\/)(page|route)$/, '') ||
'index';
}
if (routesManifest?.basePath) {

View File

@@ -0,0 +1,21 @@
import Link from 'next/link';
const paths = ['/', '/shop', '/product', '/who-we-are', '/about', '/contact'];
export default function Page({ params }) {
return (
<>
<p>variant: {params.variant}</p>
<p>slug: {params.rest?.join('/')}</p>
<ul>
{paths.map(path => {
return (
<li key={path}>
<Link href={path}>to {path}</Link>
</li>
);
})}
</ul>
</>
);
}

View File

@@ -0,0 +1,10 @@
export default function Root({ children }) {
return (
<html className="this-is-the-document-html">
<head>
<title>{`hello world`}</title>
</head>
<body className="this-is-the-document-body">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,12 @@
/* eslint-env jest */
const path = require('path');
const { deployAndTest } = require('../../utils');
const ctx = {};
describe(`${__dirname.split(path.sep).pop()}`, () => {
it('should deploy and pass probe checks', async () => {
const info = await deployAndTest(__dirname);
Object.assign(ctx, info);
});
});

View File

@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
export function middleware(request) {
request.nextUrl.pathname = `/no-variant${request.nextUrl.pathname}`;
return NextResponse.rewrite(request.nextUrl);
}
// See "Matching Paths" below to learn more
export const config = {
matcher: ['/', '/shop', '/product', '/who-we-are', '/about', '/contact'],
};

View File

@@ -0,0 +1,14 @@
module.exports = {
experimental: {
appDir: true,
runtime: 'nodejs',
},
rewrites: async () => {
return [
{
source: '/rewritten-to-dashboard',
destination: '/dashboard',
},
];
},
};

View File

@@ -0,0 +1,7 @@
{
"dependencies": {
"next": "canary",
"react": "experimental",
"react-dom": "experimental"
}
}

View File

@@ -0,0 +1,3 @@
export default function handler(req, res) {
return res.json({ hello: 'world' });
}

View File

@@ -0,0 +1,7 @@
export default function Page(props) {
return (
<>
<p>hello from pages/blog/[slug]</p>
</>
);
}

View File

@@ -0,0 +1 @@
hello world

View File

@@ -0,0 +1,82 @@
{
"builds": [
{
"src": "package.json",
"use": "@vercel/next"
}
],
"probes": [
{
"path": "/",
"status": 200,
"fetchOptions": {
"redirect": "manual"
},
"mustContain": "no-variant",
"mustNotContain": "index"
},
{
"path": "/",
"status": 200,
"fetchOptions": {
"redirect": "manual"
},
"responseHeaders": {
"vary": "RSC, Next-Router-State-Tree, Next-Router-Prefetch"
},
"headers": {
"RSC": "1"
},
"mustContain": ":{",
"mustNotContain": "<html"
},
{
"path": "/shop",
"status": 200,
"fetchOptions": {
"redirect": "manual"
},
"mustContain": "no-variant",
"mustNotContain": "shop.rsc"
},
{
"path": "/shop",
"status": 200,
"fetchOptions": {
"redirect": "manual"
},
"responseHeaders": {
"vary": "RSC, Next-Router-State-Tree, Next-Router-Prefetch"
},
"headers": {
"RSC": "1"
},
"mustContain": ":{",
"mustNotContain": "<html"
},
{
"path": "/no-variant/shop",
"status": 200,
"fetchOptions": {
"redirect": "manual"
},
"mustContain": "no-variant",
"mustNotContain": "shop.rsc"
},
{
"path": "/no-variant/shop",
"status": 200,
"fetchOptions": {
"redirect": "manual"
},
"responseHeaders": {
"vary": "RSC, Next-Router-State-Tree, Next-Router-Prefetch"
},
"headers": {
"RSC": "1"
},
"mustContain": ":{",
"mustNotContain": "<html"
}
]
}

View File

@@ -0,0 +1,7 @@
export const runtime = 'experimental-edge';
export const GET = req => {
// use query to trigger dynamic usage
console.log('query', Object.fromEntries(req.nextUrl.searchParams));
return new Response('hello world');
};

View File

@@ -32,6 +32,10 @@ if (parseInt(process.versions.node.split('.')[0], 10) >= 16) {
expect(buildResult.output['dashboard/changelog']).toBeDefined();
expect(buildResult.output['dashboard/deployments/[id]']).toBeDefined();
expect(buildResult.output['edge-route-handler']).toBeDefined();
expect(buildResult.output['edge-route-handler'].type).toBe('EdgeFunction');
expect(buildResult.output['edge-route-handler.rsc']).not.toBeDefined();
// prefixed static generation output with `/app` under dist server files
expect(buildResult.output['dashboard'].type).toBe('Prerender');
expect(buildResult.output['dashboard'].fallback.fsPath).toMatch(

View File

@@ -1,6 +1,6 @@
{
"name": "@vercel/remix",
"version": "1.3.4",
"version": "1.3.5",
"license": "MIT",
"main": "./dist/index.js",
"homepage": "https://vercel.com/docs",
@@ -11,8 +11,9 @@
},
"scripts": {
"build": "node build.js",
"test-e2e": "pnpm test test/integration.test.ts",
"test": "jest --env node --verbose --bail --runInBand"
"test": "jest --env node --verbose --bail --runInBand",
"test-unit": "pnpm test test/unit.*test.*",
"test-e2e": "pnpm test test/integration.test.ts"
},
"files": [
"dist",

View File

@@ -1,7 +1,6 @@
import { Project } from 'ts-morph';
import { promises as fs } from 'fs';
import { basename, dirname, extname, join, relative, sep } from 'path';
import { pathToRegexp, Key } from 'path-to-regexp';
import {
debug,
download,
@@ -30,7 +29,12 @@ import type {
BuildResultV2Typical,
} from '@vercel/build-utils';
import type { ConfigRoute } from '@remix-run/dev/dist/config/routes';
import { findConfig } from './utils';
import {
findConfig,
getPathFromRoute,
getRegExpFromPath,
isLayoutRoute,
} from './utils';
const _require: typeof require = eval('require');
@@ -173,12 +177,13 @@ module.exports = config;`;
}
}
const { serverBuildPath, routes: remixRoutes } = remixConfig;
const { serverBuildPath } = remixConfig;
const remixRoutes = Object.values(remixConfig.routes);
// Figure out which pages should be edge functions
const edgePages = new Set<ConfigRoute>();
const project = new Project();
for (const route of Object.values(remixRoutes)) {
for (const route of remixRoutes) {
const routePath = join(remixConfig.appDirectory, route.file);
const staticConfig = getConfig(project, routePath);
const isEdge =
@@ -228,27 +233,11 @@ module.exports = config;`;
},
];
for (const route of Object.values(remixRoutes)) {
for (const route of remixRoutes) {
// Layout routes don't get a function / route added
const isLayoutRoute = Object.values(remixRoutes).some(
r => r.parentId === route.id
);
if (isLayoutRoute) continue;
// Build up the full request path
let currentRoute: ConfigRoute | undefined = route;
const pathParts: string[] = [];
do {
if (currentRoute.index) pathParts.push('index');
if (currentRoute.path) pathParts.push(currentRoute.path);
if (currentRoute.parentId) {
currentRoute = remixRoutes[currentRoute.parentId];
} else {
currentRoute = undefined;
}
} while (currentRoute);
const path = join(...pathParts.reverse());
if (isLayoutRoute(route.id, remixRoutes)) continue;
const path = getPathFromRoute(route, remixConfig.routes);
const isEdge = edgePages.has(route);
const fn =
isEdge && edgeFunction
@@ -264,13 +253,8 @@ module.exports = config;`;
output[path] = fn;
// If this is a dynamic route then add a Vercel route
const keys: Key[] = [];
// Replace "/*" at the end to handle "splat routes"
const splatPath = '/:params+';
const rePath =
path === '*' ? splatPath : `/${path.replace(/\/\*$/, splatPath)}`;
const re = pathToRegexp(rePath, keys);
if (keys.length > 0) {
const re = getRegExpFromPath(path);
if (re) {
routes.push({
src: re.source,
dest: path,

View File

@@ -1,5 +1,10 @@
import { existsSync } from 'fs';
import { join } from 'path';
import { existsSync } from 'fs';
import { pathToRegexp, Key } from 'path-to-regexp';
import type {
ConfigRoute,
RouteManifest,
} from '@remix-run/dev/dist/config/routes';
const configExts = ['.js', '.cjs', '.mjs'];
@@ -12,3 +17,39 @@ export function findConfig(dir: string, basename: string): string | undefined {
return undefined;
}
export function isLayoutRoute(
routeId: string,
routes: Pick<ConfigRoute, 'id' | 'parentId'>[]
): boolean {
return routes.some(r => r.parentId === routeId);
}
export function getPathFromRoute(
route: ConfigRoute,
routes: RouteManifest
): string {
let currentRoute: ConfigRoute | undefined = route;
const pathParts: string[] = [];
do {
if (currentRoute.index) pathParts.push('index');
if (currentRoute.path) pathParts.push(currentRoute.path);
if (currentRoute.parentId) {
currentRoute = routes[currentRoute.parentId];
} else {
currentRoute = undefined;
}
} while (currentRoute);
const path = pathParts.reverse().join('/');
return path;
}
export function getRegExpFromPath(path: string): RegExp | false {
const keys: Key[] = [];
// Replace "/*" at the end to handle "splat routes"
const splatPath = '/:params+';
const rePath =
path === '*' ? splatPath : `/${path.replace(/\/\*$/, splatPath)}`;
const re = pathToRegexp(rePath, keys);
return keys.length > 0 ? re : false;
}

View File

@@ -0,0 +1,17 @@
import { join } from 'path';
import { findConfig } from '../src/utils';
const fixture = (name: string) => join(__dirname, 'fixtures', name);
describe('findConfig()', () => {
it.each([
{ name: '01-remix-basics', config: 'remix.config.js' },
{ name: '02-remix-basics-mjs', config: 'remix.config.mjs' },
{ name: '03-with-pnpm', config: 'remix.config.js' },
{ name: '04-with-npm9-linked', config: 'remix.config.js' },
])('should find `$config` from "$name"', ({ name, config }) => {
const dir = fixture(name);
const resolved = findConfig(dir, 'remix.config');
expect(resolved).toEqual(join(dir, config));
});
});

View File

@@ -0,0 +1,73 @@
import { getPathFromRoute } from '../src/utils';
import type { RouteManifest } from '@remix-run/dev/dist/config/routes';
describe('getPathFromRoute()', () => {
const routes: RouteManifest = {
root: { path: '', id: 'root', file: 'root.tsx' },
'routes/$foo.$bar.$baz': {
path: ':foo/:bar/:baz',
id: 'routes/$foo.$bar.$baz',
parentId: 'root',
file: 'routes/$foo.$bar.$baz.tsx',
},
'routes/api.hello': {
path: 'api/hello',
id: 'routes/api.hello',
parentId: 'root',
file: 'routes/api.hello.tsx',
},
'routes/projects': {
path: 'projects',
id: 'routes/projects',
parentId: 'root',
file: 'routes/projects.tsx',
},
'routes/projects/index': {
path: undefined,
index: true,
id: 'routes/projects/indexx',
parentId: 'routes/projects',
file: 'routes/projects/indexx.tsx',
},
'routes/projects/$': {
path: '*',
id: 'routes/projects/$',
parentId: 'routes/projects',
file: 'routes/projects/$.tsx',
},
'routes/index': {
path: undefined,
index: true,
id: 'routes/index',
parentId: 'root',
file: 'routes/index.tsx',
},
'routes/node': {
path: 'node',
id: 'routes/node',
parentId: 'root',
file: 'routes/node.tsx',
},
'routes/$': {
path: '*',
id: 'routes/$',
parentId: 'root',
file: 'routes/$.tsx',
},
};
it.each([
{ id: 'root', expected: '' },
{ id: 'routes/index', expected: 'index' },
{ id: 'routes/api.hello', expected: 'api/hello' },
{ id: 'routes/projects', expected: 'projects' },
{ id: 'routes/projects/index', expected: 'projects/index' },
{ id: 'routes/projects/$', expected: 'projects/*' },
{ id: 'routes/$foo.$bar.$baz', expected: ':foo/:bar/:baz' },
{ id: 'routes/node', expected: 'node' },
{ id: 'routes/$', expected: '*' },
])('should return `$expected` for "$id" route', ({ id, expected }) => {
const route = routes[id];
expect(getPathFromRoute(route, routes)).toEqual(expected);
});
});

View File

@@ -0,0 +1,124 @@
import { getRegExpFromPath } from '../src/utils';
describe('getRegExpFromPath()', () => {
describe('paths without parameters', () => {
it.each([{ path: 'index' }, { path: 'api/hello' }, { path: 'projects' }])(
'should return `false` for "$path"',
({ path }) => {
expect(getRegExpFromPath(path)).toEqual(false);
}
);
});
describe.each([
{
path: '*',
urls: [
{
url: '/',
expected: false,
},
{
url: '/foo',
expected: true,
},
{
url: '/projects/foo',
expected: true,
},
{
url: '/projects/another',
expected: true,
},
{
url: '/to/infinity/and/beyond',
expected: true,
},
],
},
{
path: 'projects/*',
urls: [
{
url: '/',
expected: false,
},
{
url: '/foo',
expected: false,
},
{
url: '/projects/foo',
expected: true,
},
{
url: '/projects/another',
expected: true,
},
],
},
{
path: ':foo',
urls: [
{
url: '/',
expected: false,
},
{
url: '/foo',
expected: true,
},
{
url: '/projects/foo',
expected: false,
},
{
url: '/projects/another',
expected: false,
},
],
},
{
path: 'blog/:id/edit',
urls: [
{
url: '/',
expected: false,
},
{
url: '/foo',
expected: false,
},
{
url: '/blog/123/edit',
expected: true,
},
{
url: '/blog/456/edit',
expected: true,
},
{
url: '/blog/123/456/edit',
expected: false,
},
{
url: '/blog/123/another',
expected: false,
},
],
},
])('with path "$path"', ({ path, urls }) => {
const re = getRegExpFromPath(path) as RegExp;
it('should return RegExp', () => {
expect(re).toBeInstanceOf(RegExp);
});
it.each(urls)(
'should match URL "$url" - $expected',
({ url, expected }) => {
expect(re.test(url)).toEqual(expected);
}
);
});
});

View File

@@ -0,0 +1,21 @@
import { isLayoutRoute } from '../src/utils';
describe('isLayoutRoute()', () => {
const routes = [
{ id: 'root' },
{ id: 'routes/auth', parentId: 'root' },
{ id: 'routes/login', parentId: 'routes/auth' },
{ id: 'routes/logout', parentId: 'routes/auth' },
{ id: 'routes/index', parentId: 'root' },
];
it.each([
{ id: 'root', expected: true },
{ id: 'routes/auth', expected: true },
{ id: 'routes/index', expected: false },
{ id: 'routes/login', expected: false },
{ id: 'routes/logout', expected: false },
])('should return `$expected` for "$id" route', ({ id, expected }) => {
expect(isLayoutRoute(id, routes)).toEqual(expected);
});
});

4
pnpm-lock.yaml generated
View File

@@ -213,11 +213,11 @@ importers:
'@vercel/go': 2.3.7
'@vercel/hydrogen': 0.0.53
'@vercel/ncc': 0.24.0
'@vercel/next': 3.5.0
'@vercel/next': 3.5.2
'@vercel/node': 2.9.6
'@vercel/python': 3.1.49
'@vercel/redwood': 1.1.5
'@vercel/remix': 1.3.4
'@vercel/remix': 1.3.5
'@vercel/routing-utils': 2.1.9
'@vercel/ruby': 1.3.66
'@vercel/static-build': 1.3.10