diff --git a/package-lock.json b/package-lock.json index 726f9c0b..ce44600f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25708,7 +25708,7 @@ } }, "packages/@apphosting/adapter-nextjs": { - "version": "14.0.7", + "version": "14.0.8", "license": "Apache-2.0", "dependencies": { "@apphosting/common": "*", @@ -25721,11 +25721,15 @@ }, "devDependencies": { "@types/fs-extra": "*", + "@types/mocha": "*", "@types/tmp": "*", + "mocha": "*", "next": "~14.0.0", "semver": "*", "tmp": "*", + "ts-mocha": "*", "ts-node": "*", + "typescript": "*", "verdaccio": "^5.30.3" }, "peerDependencies": { @@ -25737,12 +25741,6 @@ } } }, - "packages/@apphosting/adapter-nextjs/node_modules/@apphosting/common": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@apphosting/common/-/common-0.0.2.tgz", - "integrity": "sha512-ab+vq1ntGo1YoKvyYRptkQt3QE86dg+tSZYFSCt9ovEtFjV/zepOGW8hvhnWLpNGErcyE7f2cExySOiLZW/ZPQ==", - "license": "Apache-2.0" - }, "packages/@apphosting/build": { "version": "0.1.0", "license": "Apache-2.0", diff --git a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts index 4c270e71..cc4a53a1 100644 --- a/packages/@apphosting/adapter-nextjs/e2e/run-local.ts +++ b/packages/@apphosting/adapter-nextjs/e2e/run-local.ts @@ -49,7 +49,7 @@ await promiseSpawn("node", [buildScript], { const bundleYaml = parseYaml(readFileSync(join(cwd, ".apphosting/bundle.yaml")).toString()); -const runCommand = bundleYaml.runCommand; +const runCommand = bundleYaml.serverConfig.runCommand; if (typeof runCommand !== "string") { throw new Error("runCommand must be a string"); diff --git a/packages/@apphosting/adapter-nextjs/package.json b/packages/@apphosting/adapter-nextjs/package.json index 44de87a6..b3e9771f 100644 --- a/packages/@apphosting/adapter-nextjs/package.json +++ b/packages/@apphosting/adapter-nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@apphosting/adapter-nextjs", - "version": "14.0.7", + "version": "14.0.8", "main": "dist/index.js", "description": "Experimental addon to the Firebase CLI to add web framework support", "repository": { diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts index de2bdfd9..e2a68378 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts @@ -13,18 +13,18 @@ describe("build commands", () => { beforeEach(() => { tmpDir = generateTmpDir(); outputBundleOptions = { - bundleYamlPath: path.join(tmpDir, ".apphosting/bundle.yaml"), + bundleYamlPath: path.join(tmpDir, ".apphosting", "bundle.yaml"), outputDirectoryBasePath: path.join(tmpDir, ".apphosting"), - outputDirectoryAppPath: path.join(tmpDir, ".apphosting"), - outputPublicDirectoryPath: path.join(tmpDir, ".apphosting/public"), - outputStaticDirectoryPath: path.join(tmpDir, ".apphosting/.next/static"), - serverFilePath: path.join(tmpDir, ".apphosting/server.js"), + outputDirectoryAppPath: path.join(tmpDir, ".next", "standalone"), + outputPublicDirectoryPath: path.join(tmpDir, ".next", "standalone", "public"), + outputStaticDirectoryPath: path.join(tmpDir, ".next", "standalone", ".next", "static"), + serverFilePath: path.join(tmpDir, ".next", "standalone", "server.js"), }; defaultNextVersion = "14.0.3"; }); it("expects all output bundle files to be generated", async () => { - const { generateOutputDirectory, validateOutputDirectory, createMetadata } = await importUtils; + const { generateBuildOutput, validateOutputDirectory, createMetadata } = await importUtils; const files = { ".next/standalone/server.js": "", ".next/static/staticfile": "", @@ -36,27 +36,21 @@ describe("build commands", () => { }; const packageVersion = createMetadata(defaultNextVersion).adapterVersion; generateTestFiles(tmpDir, files); - await generateOutputDirectory( + await generateBuildOutput( tmpDir, tmpDir, outputBundleOptions, path.join(tmpDir, ".next"), defaultNextVersion, ); - await validateOutputDirectory(outputBundleOptions); + await validateOutputDirectory(outputBundleOptions, path.join(tmpDir, ".next")); const expectedFiles = { - ".apphosting/.next/static/staticfile": "", - ".apphosting/server.js": "", - ".apphosting/bundle.yaml": `headers: [] -redirects: [] -rewrites: [] -runCommand: node .apphosting/server.js -neededDirs: - - .apphosting -staticAssets: - - .apphosting/public -env: [] + ".next/standalone/.next/static/staticfile": "", + ".next/standalone/server.js": "", + ".apphosting/bundle.yaml": `version: v1 +serverConfig: + runCommand: node .next/standalone/server.js metadata: adapterPackageName: "@apphosting/adapter-nextjs" adapterVersion: ${packageVersion} @@ -68,155 +62,123 @@ metadata: }); it("moves files into correct location in a monorepo setup", async () => { - const { generateOutputDirectory } = await importUtils; + const { generateBuildOutput } = await importUtils; const files = { ".next/standalone/apps/next-app/standalonefile": "", ".next/static/staticfile": "", "public/publicfile": "", - ".next/routes-manifest.json": `{ - "headers":[], - "rewrites":[], - "redirects":[] - }`, }; generateTestFiles(tmpDir, files); - await generateOutputDirectory( + await generateBuildOutput( tmpDir, "apps/next-app", { - bundleYamlPath: path.join(tmpDir, ".apphosting/bundle.yaml"), + bundleYamlPath: path.join(tmpDir, ".apphosting", "bundle.yaml"), outputDirectoryBasePath: path.join(tmpDir, ".apphosting"), - outputDirectoryAppPath: path.join(tmpDir, ".apphosting/apps/next-app"), - outputPublicDirectoryPath: path.join(tmpDir, ".apphosting/apps/next-app/public"), - outputStaticDirectoryPath: path.join(tmpDir, ".apphosting/apps/next-app/.next/static"), - serverFilePath: path.join(tmpDir, ".apphosting/apps/next-app/server.js"), + outputDirectoryAppPath: path.join(tmpDir, ".next", "standalone", "apps", "next-app"), + outputPublicDirectoryPath: path.join( + tmpDir, + ".next", + "standalone", + "apps", + "next-app", + "public", + ), + outputStaticDirectoryPath: path.join( + tmpDir, + ".next", + "standalone", + "apps", + "next-app", + ".next", + "static", + ), + serverFilePath: path.join(tmpDir, ".next", "standalone", "apps", "next-app", "server.js"), }, path.join(tmpDir, ".next"), defaultNextVersion, ); const expectedFiles = { - ".apphosting/apps/next-app/.next/static/staticfile": "", - ".apphosting/apps/next-app/standalonefile": "", + ".next/standalone/apps/next-app/.next/static/staticfile": "", + ".next/standalone/apps/next-app/standalonefile": "", }; const expectedPartialYaml = { - headers: [], - rewrites: [], - redirects: [], - runCommand: "node .apphosting/apps/next-app/server.js", - neededDirs: [".apphosting"], - staticAssets: [".apphosting/apps/next-app/public"], + version: "v1", + serverConfig: { runCommand: "node .next/standalone/apps/next-app/server.js" }, }; validateTestFiles(tmpDir, expectedFiles); validatePartialYamlContents(tmpDir, ".apphosting/bundle.yaml", expectedPartialYaml); }); - it("expects directories and other files to be copied over", async () => { - const { generateOutputDirectory, validateOutputDirectory } = await importUtils; + it("test failed validateOutputDirectory", async () => { + const { generateBuildOutput, validateOutputDirectory } = await importUtils; const files = { - ".next/standalone/server.js": "", + ".next/standalone/notserver.js": "", ".next/static/staticfile": "", - "public/publicfile": "", - extrafile: "", ".next/routes-manifest.json": `{ - "headers":[], - "rewrites":[], - "redirects":[] + "headers":[{"source":"source", "headers":["header1"]}], + "rewrites":[{"source":"source", "destination":"destination"}], + "redirects":[{"source":"source", "destination":"destination"}] }`, }; generateTestFiles(tmpDir, files); - await generateOutputDirectory( + await generateBuildOutput( tmpDir, tmpDir, outputBundleOptions, path.join(tmpDir, ".next"), defaultNextVersion, ); - await validateOutputDirectory(outputBundleOptions); - - const expectedFiles = { - ".apphosting/.next/static/staticfile": "", - ".apphosting/server.js": "", - ".apphosting/public/publicfile": "", - ".apphosting/extrafile": "", - }; - const expectedPartialYaml = { - headers: [], - rewrites: [], - redirects: [], - runCommand: "node .apphosting/server.js", - neededDirs: [".apphosting"], - staticAssets: [".apphosting/public"], - }; - validateTestFiles(tmpDir, expectedFiles); - validatePartialYamlContents(tmpDir, ".apphosting/bundle.yaml", expectedPartialYaml); + assert.rejects( + async () => await validateOutputDirectory(outputBundleOptions, path.join(tmpDir, ".next")), + ); }); - - it("expects bundle.yaml headers/rewrites/redirects to be generated", async () => { - const { generateOutputDirectory, validateOutputDirectory } = await importUtils; + it("expects directories and other files to be copied over", async () => { + const { generateBuildOutput, validateOutputDirectory } = await importUtils; const files = { ".next/standalone/server.js": "", ".next/static/staticfile": "", + "public/publicfile": "", + extrafile: "", ".next/routes-manifest.json": `{ - "headers":[{"source":"source", "headers":["header1"]}], - "rewrites":[{"source":"source", "destination":"destination"}], - "redirects":[{"source":"source", "destination":"destination"}] + "headers":[], + "rewrites":[], + "redirects":[] }`, }; generateTestFiles(tmpDir, files); - await generateOutputDirectory( + await generateBuildOutput( tmpDir, tmpDir, outputBundleOptions, path.join(tmpDir, ".next"), defaultNextVersion, ); - await validateOutputDirectory(outputBundleOptions); + await validateOutputDirectory(outputBundleOptions, path.join(tmpDir, ".next")); const expectedFiles = { - ".apphosting/.next/static/staticfile": "", - ".apphosting/server.js": "", - }; - const expectedPartialYaml = { - headers: [{ source: "source", headers: ["header1"] }], - rewrites: [{ source: "source", destination: "destination" }], - redirects: [{ source: "source", destination: "destination" }], + ".next/standalone/.next/static/staticfile": "", + ".next/standalone/server.js": "", + ".next/standalone/public/publicfile": "", + ".next/standalone/extrafile": "", }; validateTestFiles(tmpDir, expectedFiles); - validatePartialYamlContents(tmpDir, ".apphosting/bundle.yaml", expectedPartialYaml); - }); - it("test failed validateOutputDirectory", async () => { - const { generateOutputDirectory, validateOutputDirectory } = await importUtils; - const files = { - ".next/standalone/notserver.js": "", - ".next/static/staticfile": "", - ".next/routes-manifest.json": `{ - "headers":[{"source":"source", "headers":["header1"]}], - "rewrites":[{"source":"source", "destination":"destination"}], - "redirects":[{"source":"source", "destination":"destination"}] - }`, - }; - generateTestFiles(tmpDir, files); - await generateOutputDirectory( - tmpDir, - tmpDir, - outputBundleOptions, - path.join(tmpDir, ".next"), - defaultNextVersion, - ); - assert.rejects(async () => await validateOutputDirectory(outputBundleOptions)); }); it("test populate output bundle options", async () => { const { populateOutputBundleOptions } = await importUtils; const expectedOutputBundleOptions = { bundleYamlPath: "test/.apphosting/bundle.yaml", outputDirectoryBasePath: "test/.apphosting", - outputDirectoryAppPath: "test/.apphosting", - outputPublicDirectoryPath: "test/.apphosting/public", - outputStaticDirectoryPath: "test/.apphosting/.next/static", - serverFilePath: "test/.apphosting/server.js", + outputDirectoryAppPath: "test/.next/standalone", + outputPublicDirectoryPath: "test/.next/standalone/public", + outputStaticDirectoryPath: "test/.next/standalone/.next/static", + serverFilePath: "test/.next/standalone/server.js", }; - assert.deepEqual(populateOutputBundleOptions("test", "test"), expectedOutputBundleOptions); + assert.deepEqual( + populateOutputBundleOptions("test", "test", "test/.next"), + expectedOutputBundleOptions, + ); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index b3c7c6a2..5d4e9f1b 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -2,7 +2,7 @@ import { loadConfig, populateOutputBundleOptions, - generateOutputDirectory, + generateBuildOutput, validateOutputDirectory, } from "../utils.js"; import { join } from "path"; @@ -20,15 +20,20 @@ if (!process.env.FRAMEWORK_VERSION) { } await runBuild(); -const outputBundleOptions = populateOutputBundleOptions(root, opts.projectDirectory); const { distDir } = await loadConfig(root, opts.projectDirectory); const nextBuildDirectory = join(opts.projectDirectory, distDir); -await generateOutputDirectory( +const outputBundleOptions = populateOutputBundleOptions( + root, + opts.projectDirectory, + nextBuildDirectory, +); + +await generateBuildOutput( root, opts.projectDirectory, outputBundleOptions, nextBuildDirectory, process.env.FRAMEWORK_VERSION, ); -await validateOutputDirectory(outputBundleOptions); +await validateOutputDirectory(outputBundleOptions, nextBuildDirectory); diff --git a/packages/@apphosting/adapter-nextjs/src/utils.ts b/packages/@apphosting/adapter-nextjs/src/utils.ts index 6521dfec..8a65d931 100644 --- a/packages/@apphosting/adapter-nextjs/src/utils.ts +++ b/packages/@apphosting/adapter-nextjs/src/utils.ts @@ -5,15 +5,13 @@ import { fileURLToPath } from "url"; import { stringify as yamlStringify } from "yaml"; import { PHASE_PRODUCTION_BUILD } from "./constants.js"; -import { ROUTES_MANIFEST } from "./constants.js"; -import { Metadata, OutputBundleOptions, RoutesManifest } from "./interfaces.js"; +import { OutputBundleOptions } from "./interfaces.js"; import { NextConfigComplete } from "next/dist/server/config-shared.js"; +import { OutputBundleConfig, Metadata } from "@apphosting/common"; // fs-extra is CJS, readJson can't be imported using shorthand -export const { move, exists, writeFile, readJson, readdir, readFileSync, existsSync } = fsExtra; - -// The default fallback command prefix to run a build. -export const DEFAULT_COMMAND = "npm"; +export const { move, exists, writeFile, readJson, readdir, readFileSync, existsSync, mkdir } = + fsExtra; // Loads the user's next.config.js file. export async function loadConfig(root: string, projectRoot: string): Promise { @@ -27,6 +25,7 @@ export async function loadConfig(root: string, projectRoot: string): Promise { - return await readJson(join(distDir, ROUTES_MANIFEST)); -} - -export const isMain = (meta: ImportMeta) => { +export const isMain = (meta: ImportMeta): boolean => { if (!meta) return false; if (!process.argv[1]) return false; return process.argv[1] === fileURLToPath(meta.url); @@ -50,63 +45,65 @@ export const isMain = (meta: ImportMeta) => { * @param appDir The path to the application source code, relative to the root. * @return The output bundle paths. */ -export function populateOutputBundleOptions(rootDir: string, appDir: string): OutputBundleOptions { +export function populateOutputBundleOptions( + rootDir: string, + appDir: string, + nextBuildDirectory: string, +): OutputBundleOptions { const outputBundleDir = join(rootDir, ".apphosting"); + const standaloneDirectory = join(nextBuildDirectory, "standalone"); // In monorepo setups, the standalone directory structure will mirror the structure of the monorepo. // We find the relative path from the root to the app directory to correctly locate server.js. - const outputDirectoryAppPath = join( - outputBundleDir, + const standaloneAppPath = join( + standaloneDirectory, process.env.MONOREPO_COMMAND ? relative(rootDir, appDir) : "", ); - return { bundleYamlPath: join(outputBundleDir, "bundle.yaml"), outputDirectoryBasePath: outputBundleDir, - outputDirectoryAppPath, - serverFilePath: join(outputDirectoryAppPath, "server.js"), - outputPublicDirectoryPath: join(outputDirectoryAppPath, "public"), - outputStaticDirectoryPath: join(outputDirectoryAppPath, ".next", "static"), + outputDirectoryAppPath: standaloneAppPath, + serverFilePath: join(standaloneAppPath, "server.js"), + outputPublicDirectoryPath: join(standaloneAppPath, "public"), + outputStaticDirectoryPath: join(standaloneAppPath, ".next", "static"), }; } /** - * Moves the standalone directory, the static directory and copies over all of the apps resources - * to the apphosting output directory. - * Also generates the bundle.yaml file. + * Moves static assets and other resources into the standlone directory, also generates the bundle.yaml * @param rootDir The root directory of the uploaded source code. - * @param appDir The path to the application source code, relative to the root. * @param outputBundleOptions The target location of built artifacts in the output bundle. * @param nextBuildDirectory The location of the .next directory. */ -export async function generateOutputDirectory( +export async function generateBuildOutput( rootDir: string, appDir: string, - outputBundleOptions: OutputBundleOptions, + opts: OutputBundleOptions, nextBuildDirectory: string, nextVersion: string, ): Promise { - const standaloneDirectory = join(nextBuildDirectory, "standalone"); - await move(standaloneDirectory, outputBundleOptions.outputDirectoryBasePath, { overwrite: true }); - const staticDirectory = join(nextBuildDirectory, "static"); await Promise.all([ - move(staticDirectory, outputBundleOptions.outputStaticDirectoryPath, { overwrite: true }), - moveResources(appDir, outputBundleOptions.outputDirectoryAppPath), - generateBundleYaml(outputBundleOptions, nextBuildDirectory, rootDir, nextVersion), + move(staticDirectory, opts.outputStaticDirectoryPath, { overwrite: true }), + moveResources(appDir, opts.outputDirectoryAppPath, opts.bundleYamlPath), + generateBundleYaml(opts, rootDir, nextVersion), ]); return; } // Move all files and directories to apphosting output directory. // Files are skipped if there is already a file with the same name in the output directory -async function moveResources(appDir: string, outputBundleAppDir: string): Promise { +async function moveResources( + appDir: string, + outputBundleAppDir: string, + bundleYamlPath: string, +): Promise { const appDirExists = await exists(appDir); if (!appDirExists) return; const pathsToMove = await readdir(appDir); for (const path of pathsToMove) { - const isOutputBundleDir = join(appDir, path) === outputBundleAppDir; + const isbundleYamlDir = join(appDir, path) === dirname(bundleYamlPath); const existsInOutputBundle = await exists(join(outputBundleAppDir, path)); - if (!isOutputBundleDir && !existsInOutputBundle) { + if (!isbundleYamlDir && !existsInOutputBundle) { await move(join(appDir, path), join(outputBundleAppDir, path)); } } @@ -133,44 +130,32 @@ export function createMetadata(nextVersion: string): Metadata { // generate bundle.yaml async function generateBundleYaml( - outputBundleOptions: OutputBundleOptions, - nextBuildDirectory: string, + opts: OutputBundleOptions, cwd: string, nextVersion: string, ): Promise { - const manifest = await readRoutesManifest(nextBuildDirectory); - const headers = manifest.headers.map((it) => ({ ...it, regex: undefined })); - const redirects = manifest.redirects - .filter((it) => !it.internal) - .map((it) => ({ ...it, regex: undefined })); - const beforeFileRewrites = Array.isArray(manifest.rewrites) - ? manifest.rewrites - : manifest.rewrites?.beforeFiles || []; - const rewrites = beforeFileRewrites.map((it) => ({ ...it, regex: undefined })); - await writeFile( - outputBundleOptions.bundleYamlPath, - yamlStringify({ - headers, - redirects, - rewrites, - runCommand: `node ${normalize(relative(cwd, outputBundleOptions.serverFilePath))}`, - neededDirs: [normalize(relative(cwd, outputBundleOptions.outputDirectoryBasePath))], - staticAssets: [normalize(relative(cwd, outputBundleOptions.outputPublicDirectoryPath))], - env: [], - metadata: createMetadata(nextVersion), - }), - ); + await mkdir(opts.outputDirectoryBasePath); + const outputBundle: OutputBundleConfig = { + version: "v1", + serverConfig: { + runCommand: `node ${normalize(relative(cwd, opts.serverFilePath))}`, + }, + metadata: createMetadata(nextVersion), + }; + await writeFile(opts.bundleYamlPath, yamlStringify(outputBundle)); return; } // Validate output directory includes all necessary parts export async function validateOutputDirectory( - outputBundleOptions: OutputBundleOptions, + opts: OutputBundleOptions, + nextBuildDirectory: string, ): Promise { + const standaloneDirectory = join(nextBuildDirectory, "standalone"); if ( - !(await fsExtra.exists(outputBundleOptions.outputDirectoryBasePath)) || - !(await fsExtra.exists(outputBundleOptions.serverFilePath)) || - !(await fsExtra.exists(outputBundleOptions.bundleYamlPath)) + !(await fsExtra.exists(nextBuildDirectory)) || + !(await fsExtra.exists(standaloneDirectory)) || + !(await fsExtra.exists(opts.bundleYamlPath)) ) { throw new Error("Output directory is not of expected structure"); } diff --git a/packages/@apphosting/common/src/index.ts b/packages/@apphosting/common/src/index.ts index 0ac67445..00f4430e 100644 --- a/packages/@apphosting/common/src/index.ts +++ b/packages/@apphosting/common/src/index.ts @@ -8,7 +8,7 @@ export interface OutputBundleConfig { } // Fields needed to configure the App Hosting server -interface ServerConfig { +export interface ServerConfig { // Command to start the server (e.g. "node dist/index.js"). Assume this command is run from the root dir of the workspace runCommand: string; // Environment variables set when the app is run @@ -27,7 +27,7 @@ interface ServerConfig { } // Additonal fields needed for identifying the framework and adapter being used -interface Metadata { +export interface Metadata { // Name of the adapter (this should be the official package name) e.g. "@apphosting/adapter-nextjs" adapterPackageName: string; // Version of the adapter, e.g. "18.0.1" @@ -39,7 +39,7 @@ interface Metadata { } // Represents a single environment variable. -interface EnvVarConfig { +export interface EnvVarConfig { // Name of the variable variable: string; // Value associated with the variable @@ -49,7 +49,7 @@ interface EnvVarConfig { } // Represents where environment variables are made available -enum Availability { +export enum Availability { // Runtime environment variables are available on the server when the app is run Runtime, }