Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next 13 fixes #5175

Merged
merged 14 commits into from Oct 31, 2022
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -2,6 +2,8 @@
- Releases RTDB Emulator v4.11.0: Wire protocol update for `startAfter`, `endBefore`.
- Changes `superstatic` dependency to `v8`, addressing Hosting emulator issues on Windows.
- Fixes internal library that was not being correctly published.
- Add support for Next.js 13 in firebase deploy.
- Next.js routes with revalidate are now handled by the a backing Cloud Function.
- Adds `--disable-triggers` flag to RTDB write commands.
- Default enables experiment to skip deploying unmodified functions (#5192)
- Default enables experiment to allow parameterized functions codebases (#5192)
2 changes: 2 additions & 0 deletions scripts/webframeworks-deploy-tests/hosting/.gitignore
Expand Up @@ -34,3 +34,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

.vscode
5 changes: 5 additions & 0 deletions scripts/webframeworks-deploy-tests/hosting/app/bar/page.tsx
@@ -0,0 +1,5 @@
export const revalidate = 60;

export default function Bar() {
return <>Bar</>;
}
3 changes: 3 additions & 0 deletions scripts/webframeworks-deploy-tests/hosting/app/foo/page.tsx
@@ -0,0 +1,3 @@
export default function Foo() {
return <>Foo</>;
}
8 changes: 8 additions & 0 deletions scripts/webframeworks-deploy-tests/hosting/app/layout.tsx
@@ -0,0 +1,8 @@
export default function RootLayout({ children }: any) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}
3 changes: 3 additions & 0 deletions scripts/webframeworks-deploy-tests/hosting/next.config.js
Expand Up @@ -2,6 +2,9 @@
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
experimental: {
appDir: true
},
}

module.exports = nextConfig
277 changes: 146 additions & 131 deletions scripts/webframeworks-deploy-tests/hosting/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion scripts/webframeworks-deploy-tests/hosting/package.json
Expand Up @@ -9,7 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"next": "12.3.1",
"next": "13.0.0",
"react": "18.2.0",
"react-dom": "18.2.0"
},
Expand Down
24 changes: 20 additions & 4 deletions scripts/webframeworks-deploy-tests/hosting/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
Expand All @@ -13,8 +17,20 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
2 changes: 1 addition & 1 deletion scripts/webframeworks-deploy-tests/tests.ts
Expand Up @@ -45,7 +45,7 @@ describe("webframeworks deploy", function (this) {
const result = await setOptsAndDeploy();

expect(result.stdout, "deploy result").to.match(/file upload complete/);
expect(result.stdout, "deploy result").to.match(/found 16 files/);
expect(result.stdout, "deploy result").to.match(/found 20 files/);
expect(result.stdout, "deploy result").to.match(/Deploy complete!/);
});
});
97 changes: 70 additions & 27 deletions src/frameworks/next/index.ts
@@ -1,11 +1,12 @@
import { execSync } from "child_process";
import { readFile, mkdir, copyFile, stat } from "fs/promises";
import { dirname, extname, join } from "path";
import { readFile, mkdir, copyFile } from "fs/promises";
import { dirname, join } from "path";
import type { Header, Rewrite, Redirect } from "next/dist/lib/load-custom-routes";
import type { NextConfig } from "next";
import { copy, mkdirp, pathExists } from "fs-extra";
import { pathToFileURL, parse } from "url";
import { existsSync } from "fs";

import {
BuildResult,
createServerResponseProxy,
Expand All @@ -20,6 +21,7 @@ import { gte } from "semver";
import { IncomingMessage, ServerResponse } from "http";
import { logger } from "../../logger";
import { FirebaseError } from "../../error";
import { fileExistsSync } from "../../fsutils";

// Next.js's exposed interface is incomplete here
// TODO see if there's a better way to grab this
Expand Down Expand Up @@ -47,10 +49,14 @@ export const name = "Next.js";
export const support = SupportLevel.Experimental;
export const type = FrameworkType.MetaFramework;

function getNextVersion(cwd: string) {
function getNextVersion(cwd: string): string | undefined {
return findDependency("next", { cwd, depth: 0, omitDev: false })?.version;
}

function getReactVersion(cwd: string): string | undefined {
return findDependency("react-dom", { cwd, omitDev: false })?.version;
}

/**
* Returns whether this codebase is a Next.js backend.
*/
Expand All @@ -67,6 +73,12 @@ export async function discover(dir: string) {
export async function build(dir: string): Promise<BuildResult> {
const { default: nextBuild } = relativeRequire(dir, "next/dist/build");

const reactVersion = getReactVersion(dir);
if (reactVersion && gte(reactVersion, "18.0.0")) {
// This needs to be set for Next build to succeed with React 18
process.env.__NEXT_REACT_ROOT = "true";
}

await nextBuild(dir, null, false, false, true).catch((e) => {
// Err on the side of displaying this error, since this is likely a bug in
// the developer's code that we want to display immediately
Expand All @@ -89,6 +101,10 @@ export async function build(dir: string): Promise<BuildResult> {
const exportDetailBuffer = exportDetailExists ? await readFile(exportDetailPath) : undefined;
const exportDetailJson = exportDetailBuffer && JSON.parse(exportDetailBuffer.toString());
if (exportDetailJson?.success) {
const appPathRoutesManifestPath = join(dir, distDir, "app-path-routes-manifest.json");
const appPathRoutesManifestJSON = fileExistsSync(appPathRoutesManifestPath)
? await readFile(appPathRoutesManifestPath).then((it) => JSON.parse(it.toString()))
: {};
const prerenderManifestJSON = await readFile(
join(dir, distDir, "prerender-manifest.json")
).then((it) => JSON.parse(it.toString()));
Expand All @@ -100,10 +116,15 @@ export async function build(dir: string): Promise<BuildResult> {
).then((it) => JSON.parse(it.toString()));
const prerenderedRoutes = Object.keys(prerenderManifestJSON.routes);
const dynamicRoutes = Object.keys(prerenderManifestJSON.dynamicRoutes);
const unrenderedPages = Object.keys(pagesManifestJSON).filter(
const unrenderedPages = [
...Object.keys(pagesManifestJSON),
// TODO flush out fully rendered detection with a app directory (Next 13)
// we shouldn't go too crazy here yet, as this is currently an expiriment
...Object.values<string>(appPathRoutesManifestJSON),
].filter(
(it) =>
!(
["/_app", "/_error", "/_document", "/404"].includes(it) ||
["/_app", "/", "/_error", "/_document", "/404"].includes(it) ||
prerenderedRoutes.includes(it) ||
dynamicRoutes.includes(it)
)
Expand Down Expand Up @@ -150,7 +171,7 @@ export async function init(setup: any) {
choices: ["JavaScript", "TypeScript"],
});
execSync(
`npx --yes create-next-app@latest -e hello-world ${setup.hosting.source} ${
`npx --yes create-next-app@latest -e hello-world ${setup.hosting.source} --use-npm ${
language === "TypeScript" ? "--ts" : ""
}`,
{ stdio: "inherit" }
Expand All @@ -176,42 +197,64 @@ export async function ɵcodegenPublicDirectory(sourceDir: string, destDir: strin
}
await copy(join(sourceDir, distDir, "static"), join(destDir, "_next", "static"));

const serverPagesDir = join(sourceDir, distDir, "server", "pages");
await copy(serverPagesDir, destDir, {
filter: async (filename) => {
const status = await stat(filename);
if (status.isDirectory()) return true;
return extname(filename) === ".html";
},
});
// Copy over the default html files
for (const file of ["index.html", "404.html", "500.html"]) {
const pagesPath = join(sourceDir, distDir, "server", "pages", file);
if (await pathExists(pagesPath)) {
await copyFile(pagesPath, join(destDir, file));
continue;
}
const appPath = join(sourceDir, distDir, "server", "app", file);
if (await pathExists(appPath)) {
await copyFile(appPath, join(destDir, file));
}
}

const prerenderManifestBuffer = await readFile(
join(sourceDir, distDir, "prerender-manifest.json")
);
const prerenderManifest = JSON.parse(prerenderManifestBuffer.toString());
// TODO drop from hosting if revalidate
for (const route in prerenderManifest.routes) {
if (prerenderManifest.routes[route]) {
for (const path in prerenderManifest.routes) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this not be of?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not an iterable, it's an object: { "/foo": { ... }, "/bar": { ... }}

so in is correct, though with the linting rule (to ensure we don't iterate over private) it's a little verbose... perhaps const path of Object.keys(prerenderManifest) or const [path, route] of Object.entries(prerenderManifest.routes) would be stylistically preferable?

if (prerenderManifest.routes[path]) {
// Skip ISR in the deploy to hosting
const { initialRevalidateSeconds } = prerenderManifest.routes[path];
if (initialRevalidateSeconds) {
continue;
}

// TODO(jamesdaniels) explore oppertunity to simplify this now that we
// are defaulting cleanURLs to true for frameworks

// / => index.json => index.html => index.html
// /foo => foo.json => foo.html
const parts = route
const parts = path
.split("/")
.slice(1)
.filter((it) => !!it);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slice and filter bits seem redundant. It seems like you're using splice(1) because leading slashes will lead to an empty element from split (are we guaranteed to lead with a slash?) and filter will filter out empty components as well. So if you're only and always going to have a leading slash, then splice does the trick. But filter seems like it works for that case and a final slash.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was likely overly defensive here, worrying aboth leading/trailing slash, I'll check my assumptions. that said, we're pushing cleanURLs to be true by default now when paired with hosting.source, I can probably simplify this code. I'll do that in a follow on so I unblock Next 13 from working

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took on as a TODO to explore simplifying

const partsOrIndex = parts.length > 0 ? parts : ["index"];
const dataPath = `${join(...partsOrIndex)}.json`;
const htmlPath = `${join(...partsOrIndex)}.html`;
await mkdir(join(destDir, dirname(htmlPath)), { recursive: true });
await copyFile(
join(sourceDir, distDir, "server", "pages", htmlPath),
join(destDir, htmlPath)
);
const dataRoute = prerenderManifest.routes[route].dataRoute;
const pagesHtmlPath = join(sourceDir, distDir, "server", "pages", htmlPath);
if (await pathExists(pagesHtmlPath)) {
await copyFile(pagesHtmlPath, join(destDir, htmlPath));
} else {
const appHtmlPath = join(sourceDir, distDir, "server", "app", htmlPath);
if (await pathExists(appHtmlPath)) {
await copyFile(appHtmlPath, join(destDir, htmlPath));
}
}
const dataRoute = prerenderManifest.routes[path].dataRoute;
await mkdir(join(destDir, dirname(dataRoute)), { recursive: true });
await copyFile(
join(sourceDir, distDir, "server", "pages", dataPath),
join(destDir, dataRoute)
);
const pagesDataPath = join(sourceDir, distDir, "server", "pages", dataPath);
if (await pathExists(pagesDataPath)) {
await copyFile(pagesDataPath, join(destDir, dataRoute));
} else {
const appDataPath = join(sourceDir, distDir, "server", "app", dataPath);
if (await pathExists(appDataPath)) {
await copyFile(appDataPath, join(destDir, dataRoute));
}
}
}
}
}
Expand Down