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
42,850 changes: 24,095 additions & 18,755 deletions package-lock.json

Large diffs are not rendered by default.

19 changes: 12 additions & 7 deletions packages/@apphosting/adapter-nextjs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@apphosting/adapter-nextjs",
"version": "14.0.21",
"name": "wei-nextjs-adapter-test",
"version": "15.0.18",
"main": "dist/index.js",
"description": "Experimental addon to the Firebase CLI to add web framework support",
"repository": {
Expand All @@ -9,6 +9,7 @@
},
"bin": {
"apphosting-adapter-nextjs-build": "dist/bin/build.js",
"apphosting-adapter-nextjs-serve": "dist/bin/serve.js",
"apphosting-adapter-nextjs-create": "dist/bin/create.js"
},
"author": {
Expand All @@ -21,7 +22,8 @@
"type": "module",
"sideEffects": false,
"scripts": {
"build": "rm -rf dist && tsc && chmod +x ./dist/bin/*",
"build": "rm -rf dist && tsc && npm run bundle",
"bundle": "esbuild src/bin/build.ts --bundle --platform=node --format=esm --banner:js='#!/usr/bin/env node' --outfile=dist/bin/build.js --external:esbuild --external:fs-extra && esbuild src/bin/serve.ts --bundle --platform=node --format=esm --banner:js='#!/usr/bin/env node' --outfile=dist/bin/serve.js --external:next --external:react --external:react-dom && esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/index.js",
"test": "npm run test:unit && npm run test:functional",
"test:unit": "ts-mocha -p tsconfig.json 'src/**/*.spec.ts' 'src/*.spec.ts'",
"test:functional": "node --loader ts-node/esm ./e2e/run-local.ts",
Expand All @@ -44,9 +46,10 @@
"license": "Apache-2.0",
"dependencies": {
"@apphosting/common": "*",
"esbuild": "^0.25.0",
"fastify": "^5.6.1",

Choose a reason for hiding this comment

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

medium

The fastify dependency was added, but it doesn't appear to be used anywhere in the codebase. The new serve.ts uses Node's built-in http module. To keep dependencies clean, this unused package should be removed.

"fs-extra": "^11.1.1",
"yaml": "^2.3.4",
"semver": "^7.7.3"
"yaml": "^2.3.4"
},
"peerDependencies": {
"next": "*"
Expand All @@ -61,12 +64,14 @@
"@types/mocha": "*",
"@types/tmp": "*",
"mocha": "*",
"next": "~14.0.0",
"next": "15.6.0-canary.54",
"protoc": "^32.1.0",
"semver": "*",
"tmp": "*",
"ts-mocha": "*",
"ts-node": "*",
"ts-proto": "^2.7.7",
"typescript": "*",
"verdaccio": "^5.30.3"
}
}
}
79 changes: 15 additions & 64 deletions packages/@apphosting/adapter-nextjs/src/bin/build.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,31 @@
#! /usr/bin/env node
import {
generateBuildOutput,
loadConfig,
populateOutputBundleOptions,
generateBuildOutput,
validateOutputDirectory,
getAdapterMetadata,
exists,
checkNextJSVersion,
} from "../utils.js";
import { join } from "path";
import { getBuildOptions, runBuild } from "@apphosting/common";
import {
addRouteOverrides,
overrideNextConfig,
restoreNextConfig,
validateNextConfigOverride,
} from "../overrides.js";

const root = process.cwd();
const opts = getBuildOptions();

// Set standalone mode
process.env.NEXT_PRIVATE_STANDALONE = "true";
import { join } from "node:path";
// Opt-out sending telemetry to Vercel
process.env.NEXT_TELEMETRY_DISABLED = "1";

checkNextJSVersion(process.env.FRAMEWORK_VERSION);
const nextConfig = await loadConfig(root, opts.projectDirectory);
process.env.NEXT_ADAPTER_PATH = join(import.meta.dirname, "..", "index.cjs");

/**
* Override user's Next Config to optimize the app for Firebase App Hosting
* and validate that the override resulted in a valid config that Next.js can
* load.
*
* We restore the user's Next Config at the end of the build, after the config file has been
* copied over to the output directory, so that the user's original code is not modified.
*
* If the app does not have a next.config.[js|mjs|ts] file in the first place,
* then can skip config override.
*
* Note: loadConfig always returns a fileName (default: next.config.js) even if
* one does not exist in the app's root: https://github.com/vercel/next.js/blob/23681508ca34b66a6ef55965c5eac57de20eb67f/packages/next/src/server/config.ts#L1115
*/
const nextConfigPath = join(root, nextConfig.configFileName);
if (await exists(nextConfigPath)) {
await overrideNextConfig(root, nextConfig.configFileName);
await validateNextConfigOverride(root, opts.projectDirectory, nextConfig.configFileName);
}
await runBuild();

try {
await runBuild();
const opts = getBuildOptions();
const root = process.cwd();

const adapterMetadata = getAdapterMetadata();
const nextBuildDirectory = join(opts.projectDirectory, nextConfig.distDir);
const outputBundleOptions = populateOutputBundleOptions(
root,
opts.projectDirectory,
nextBuildDirectory,
);
const nextConfig = await loadConfig(root, opts.projectDirectory);

await addRouteOverrides(
outputBundleOptions.outputDirectoryAppPath,
nextConfig.distDir,
adapterMetadata,
);
const nextBuildDirectory = join(opts.projectDirectory, nextConfig.distDir);
const outputBundleOptions = populateOutputBundleOptions(
root,
opts.projectDirectory,
nextBuildDirectory,
);
await generateBuildOutput(root, opts.projectDirectory, outputBundleOptions, nextBuildDirectory);

const nextjsVersion = process.env.FRAMEWORK_VERSION || "unspecified";
await generateBuildOutput(
root,
opts.projectDirectory,
outputBundleOptions,
nextBuildDirectory,
nextjsVersion,
adapterMetadata,
);
await validateOutputDirectory(outputBundleOptions, nextBuildDirectory);
} finally {
await restoreNextConfig(root, nextConfig.configFileName);
}
await validateOutputDirectory(outputBundleOptions, nextBuildDirectory);
230 changes: 230 additions & 0 deletions packages/@apphosting/adapter-nextjs/src/bin/serve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { createServer } from "http";
import { parse } from "url";
import path from "path";
import fs from "fs";
import { createRequire } from "module";
const require = createRequire(import.meta.url);

// --- 1. ENV SETUP ---
// @ts-ignore

Check failure on line 9 in packages/@apphosting/adapter-nextjs/src/bin/serve.ts

View workflow job for this annotation

GitHub Actions / Lint

Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free
process.env['NODE_ENV'] = "production";

Check failure on line 10 in packages/@apphosting/adapter-nextjs/src/bin/serve.ts

View workflow job for this annotation

GitHub Actions / Lint

Replace `'NODE_ENV'` with `"NODE_ENV"`
process.env['__NEXT_PRIVATE_PREBUNDLED_REACT'] = 'experimental';

Check failure on line 11 in packages/@apphosting/adapter-nextjs/src/bin/serve.ts

View workflow job for this annotation

GitHub Actions / Lint

Replace `'__NEXT_PRIVATE_PREBUNDLED_REACT']·=·'experimental'` with `"__NEXT_PRIVATE_PREBUNDLED_REACT"]·=·"experimental"`

async function start() {
// --- 2. CONFIGURATION ---
const serverDir = process.argv[2] ? path.resolve(process.argv[2]) : process.cwd();
const PORT = parseInt(process.env.PORT || "8080");

Check failure on line 17 in packages/@apphosting/adapter-nextjs/src/bin/serve.ts

View workflow job for this annotation

GitHub Actions / Lint

Delete `··`
console.log(`> Starting server from: ${serverDir}`);

// Import Next.js internals
const nextMetaPath = require.resolve("next/dist/server/request-meta", { paths: [serverDir] });
const { NEXT_REQUEST_META } = require(nextMetaPath);

// Load build config
let configPath = path.join(serverDir, "output.json");
if (!fs.existsSync(configPath)) {
configPath = path.join(process.cwd(), ".apphosting", "output.json");

Check failure on line 27 in packages/@apphosting/adapter-nextjs/src/bin/serve.ts

View workflow job for this annotation

GitHub Actions / Lint

Delete `·`
}
if (!fs.existsSync(configPath)) {
console.error(`❌ Config not found at: ${configPath}`);
process.exit(1);
}

const rawConfig = fs.readFileSync(configPath, 'utf-8');

Check failure on line 34 in packages/@apphosting/adapter-nextjs/src/bin/serve.ts

View workflow job for this annotation

GitHub Actions / Lint

Replace `'utf-8'` with `"utf-8"`
const buildContext = JSON.parse(rawConfig);

// --- HELPER: EDGE ROUTING ENGINE ---
function applyRoutingRules(req: any, res: any, pathname: string) {
const routing = buildContext.routing;
if (!routing) return false;

// 1. Headers (beforeMiddleware)
if (routing.beforeMiddleware) {
for (const rule of routing.beforeMiddleware) {
if (rule.sourceRegex && new RegExp(rule.sourceRegex).test(pathname)) {

Check failure on line 46 in packages/@apphosting/adapter-nextjs/src/bin/serve.ts

View workflow job for this annotation

GitHub Actions / Lint

Delete `⏎··········`
if (rule.headers) {
for (const [key, value] of Object.entries(rule.headers)) {
res.setHeader(key, value);
}
console.log(`[EDGE] 🔧 Applied headers for: ${pathname}`);
}

Check failure on line 53 in packages/@apphosting/adapter-nextjs/src/bin/serve.ts

View workflow job for this annotation

GitHub Actions / Lint

Delete `··········`
// Handle Redirects
if (rule.status && (rule.status >= 300 && rule.status < 400) && rule.headers?.Location) {

Check failure on line 55 in packages/@apphosting/adapter-nextjs/src/bin/serve.ts

View workflow job for this annotation

GitHub Actions / Lint

Replace `(rule.status·>=·300·&&·rule.status·<·400)` with `rule.status·>=·300·&&·rule.status·<·400`
console.log(`[EDGE] ↪️ Redirecting ${pathname} -> ${rule.headers.Location} (${rule.status})`);

Check failure on line 56 in packages/@apphosting/adapter-nextjs/src/bin/serve.ts

View workflow job for this annotation

GitHub Actions / Lint

Replace `·············console.log(`[EDGE]·↪️··Redirecting·${pathname}·->·${rule.headers.Location}·(${rule.status})`` with `············console.log(⏎··············`[EDGE]·↪️··Redirecting·${pathname}·->·${rule.headers.Location}·(${rule.status})`,⏎············`
res.statusCode = rule.status;
res.setHeader('Location', rule.headers.Location);
res.end();
return true;
}
}
}
}
return false;
}

// --- HELPER: PPR STATE ---
const getPostponedState = (path: string) => {
let prerender = buildContext.outputs?.prerenders?.find((it: any) => it.pathname === path);
if (!prerender && buildContext.routes?.dynamicRoutes) {
const dynamicMatch = buildContext.routes.dynamicRoutes.find((it: any) =>
path.match(new RegExp(it.sourceRegex))
)?.source;
if (dynamicMatch) {
prerender = buildContext.outputs?.prerenders?.find((it: any) => it.pathname === dynamicMatch);
}
}
return prerender?.fallback?.postponedState;
};

// --- 3. SERVER INSTANTIATION ---
const nextPath = require.resolve("next/dist/server/next-server", { paths: [serverDir] });
const NextServer = require(nextPath).default;

// Minimal Server (PPR)
const minimalServer = new NextServer({
dir: serverDir,
hostname: '0.0.0.0',
port: PORT,
conf: buildContext.config,
minimalMode: true,
customServer: false
});

// Full Server (Actions/API/Standard)
const fullServer = new NextServer({
dir: serverDir,
hostname: '0.0.0.0',
port: PORT,
conf: buildContext.config,
minimalMode: false,
customServer: false
});

console.log("Hydrating Next.js servers...");
try {
await minimalServer.prepare();
await fullServer.prepare();
} catch (e) {
console.error("Failed to prepare Next.js servers:", e);
process.exit(1);
}

const minimalRequestHandler = minimalServer.getRequestHandler();
const fullRequestHandler = fullServer.getRequestHandler();

// --- 4. REQUEST HANDLER ---
const server = createServer(async (req: any, res: any) => {
try {
const parsedUrl = parse(req.url, true);
const { pathname } = parsedUrl;

// [A] ROUTING ENGINE
const handled = applyRoutingRules(req, res, pathname || "/");
if (handled) return;

// [B] STATIC FILE HANDLING
// We check if the request matches a file on disk.
// Since we aren't changing directories, we must construct the full path manually.
if (pathname) {
let filePath = null;

// 1. _next/static assets -> map to .next/static
if (pathname.startsWith("/_next/static/")) {
filePath = path.join(serverDir, ".next/static", pathname.replace(/^\/_next\/static\//, ""));
}

// 2. Explicit /public/ request -> map to public folder
else if (pathname.startsWith("/public/")) {
filePath = path.join(serverDir, pathname);
}

// 3. Root assets (favicon.ico, sparky3d.gif) -> Check inside 'public' folder
// This was the missing piece! Root URLs often map to the public folder.
else if (!pathname.startsWith("/_next/")) {
const publicFilePath = path.join(serverDir, "public", pathname);
if (fs.existsSync(publicFilePath) && fs.statSync(publicFilePath).isFile()) {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
filePath = publicFilePath;
}
}

// If we calculated a path AND it exists, serve it
if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
const ext = path.extname(filePath).toLowerCase();
const mimeTypes: Record<string, string> = {
'.js': 'application/javascript', '.css': 'text/css', '.png': 'image/png',
'.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.gif': 'image/gif'
};
res.setHeader("Content-Type", mimeTypes[ext] || 'application/octet-stream');
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
fs.createReadStream(filePath).pipe(res);

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
return;
}
}

// [C] SERVER ACTIONS (POST)
if (req.method === 'POST') {
console.log(`[ACTION] ⚡️ Routing POST to Full Server: ${pathname}`);
await fullRequestHandler(req, res, parsedUrl);
return;
}

// [D] RESUME REQUESTS (PPR Stream)
if (req.headers['next-resume'] === '1' && pathname) {
console.log(`[RESUME] 🌊 Intercepted Resume request for: ${pathname}`);
const postponed = getPostponedState(pathname);
if (postponed) {
req[NEXT_REQUEST_META] = { postponed };
}
return minimalRequestHandler(req, res, parsedUrl);
}

if (!req.headers['x-matched-path']) {
req.headers['x-matched-path'] = pathname;
}

// [E] ROUTING DECISION
const match = await minimalServer.matchers.match(pathname, {});
let isPPR = false;
if (match) {
const manifest = minimalServer.getPrerenderManifest();

const safePathname = pathname || "";

const routeData = manifest.routes[safePathname] ||
(match.definition && manifest.dynamicRoutes[match.definition.pathname]);
if (routeData && routeData.experimentalPPR) {
isPPR = true;
}
}

if (isPPR) {
console.log(`[MINIMAL] 🟢 Routing to Minimal Server (PPR): ${pathname}`);
await minimalRequestHandler(req, res, parsedUrl);
} else {
console.log(`[FULL] 🔵 Routing to Full Server: ${pathname}`);
await fullRequestHandler(req, res, parsedUrl);
}

} catch (err) {
console.error(err);
res.statusCode = 500;
res.end("Internal Server Error");
}
});

server.on('error', (e: any) => {
if (e.code === 'EADDRINUSE') {
console.error(`\n❌ FATAL: Port ${PORT} is already in use.`);
process.exit(1);
}
});

server.listen(PORT, () => {
console.log(`> Ready on http://localhost:${PORT}`);
});
}

start();
Loading
Loading