-
Notifications
You must be signed in to change notification settings - Fork 858
Playing around with native adapter #456
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
base: main
Are you sure you want to change the base?
Changes from all commits
5720b39
d1b4d0d
68115bc
d188943
4cb903e
38d8995
090e1dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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); |
| 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
|
||
| process.env['NODE_ENV'] = "production"; | ||
| process.env['__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"); | ||
|
|
||
| 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"); | ||
| } | ||
| if (!fs.existsSync(configPath)) { | ||
| console.error(`❌ Config not found at: ${configPath}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const rawConfig = fs.readFileSync(configPath, '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)) { | ||
|
|
||
| if (rule.headers) { | ||
| for (const [key, value] of Object.entries(rule.headers)) { | ||
| res.setHeader(key, value); | ||
| } | ||
| console.log(`[EDGE] 🔧 Applied headers for: ${pathname}`); | ||
| } | ||
|
|
||
| // Handle Redirects | ||
| if (rule.status && (rule.status >= 300 && rule.status < 400) && rule.headers?.Location) { | ||
| console.log(`[EDGE] ↪️ Redirecting ${pathname} -> ${rule.headers.Location} (${rule.status})`); | ||
|
Check failure on line 56 in packages/@apphosting/adapter-nextjs/src/bin/serve.ts
|
||
| 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 failureCode scanning / CodeQL Uncontrolled data used in path expression High
This path depends on a
user-provided value Error loading related location Loading Check failureCode scanning / CodeQL Uncontrolled data used in path expression High
This path depends on a
user-provided value Error loading related location Loading |
||
| filePath = publicFilePath; | ||
| } | ||
| } | ||
|
|
||
| // If we calculated a path AND it exists, serve it | ||
| if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { | ||
Check failureCode scanning / CodeQL Uncontrolled data used in path expression High
This path depends on a
user-provided value Error loading related location Loading Check failureCode scanning / CodeQL Uncontrolled data used in path expression High
This path depends on a
user-provided value Error loading related location Loading |
||
| 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 failureCode scanning / CodeQL Uncontrolled data used in path expression High
This path depends on a
user-provided value Error loading related location Loading |
||
| 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(); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
fastifydependency was added, but it doesn't appear to be used anywhere in the codebase. The newserve.tsuses Node's built-inhttpmodule. To keep dependencies clean, this unused package should be removed.