diff --git a/packages/gatsby/src/commands/serve.ts b/packages/gatsby/src/commands/serve.ts index e62dd06d315d0..f8e6dc79cfbff 100644 --- a/packages/gatsby/src/commands/serve.ts +++ b/packages/gatsby/src/commands/serve.ts @@ -1,391 +1,252 @@ -import path from "path" -import openurl from "better-opn" -import fs from "fs-extra" -import compression from "compression" -import express from "express" -import chalk from "chalk" -import { match as reachMatch } from "@gatsbyjs/reach-router" -import report from "gatsby-cli/lib/reporter" - -import { detectPortInUseAndPrompt } from "../utils/detect-port-in-use-and-prompt" -import { getConfigFile } from "../bootstrap/get-config-file" -import { preferDefault } from "../bootstrap/prefer-default" -import { IProgram } from "./types" -import { IPreparedUrls, prepareUrls } from "../utils/prepare-urls" -import { - IGatsbyConfig, - IGatsbyFunction, - IGatsbyPage, - IGatsbyState, -} from "../redux/types" -import { reverseFixedPagePath } from "../utils/page-data" -import { initTracer } from "../utils/tracer" -import { configureTrailingSlash } from "../utils/express-middlewares" -import { getDataStore } from "../datastore" -import { functionMiddlewares } from "../internal-plugins/functions/middleware" -import { - thirdPartyProxyPath, - partytownProxy, -} from "../internal-plugins/partytown/proxy" -import { slash } from "gatsby-core-utils/path" +import path from 'path'; +import openurl from 'better-opn'; +import fs from 'fs-extra'; +import compression from 'compression'; +import express from 'express'; +import chalk from 'chalk'; +import { match as reachMatch } from '@gatsbyjs/reach-router'; +import report from 'gatsby-cli/lib/reporter'; +import http from 'http'; +import https from 'https'; + +import { detectPortInUseAndPrompt } from '../utils/detect-port-in-use-and-prompt'; +import { getConfigFile } from '../bootstrap/get-config-file'; +import { preferDefault } from '../bootstrap/prefer-default'; +import { IProgram } from './types'; +import { IPreparedUrls, prepareUrls } from '../utils/prepare-urls'; +import { IGatsbyConfig, IGatsbyFunction, IGatsbyPage, IGatsbyState } from '../redux/types'; +import { reverseFixedPagePath } from '../utils/page-data'; +import { initTracer } from '../utils/tracer'; +import { configureTrailingSlash } from '../utils/express-middlewares'; +import { getDataStore } from '../datastore'; +import { functionMiddlewares } from '../internal-plugins/functions/middleware'; +import { thirdPartyProxyPath, partytownProxy } from '../internal-plugins/partytown/proxy'; +import { slash } from 'gatsby-core-utils/path'; interface IMatchPath { - path: string - matchPath: string + path: string; + matchPath: string; } interface IServeProgram extends IProgram { - prefixPaths: boolean + prefixPaths: boolean; } -const readMatchPaths = async ( - program: IServeProgram -): Promise> => { - const filePath = path.join(program.directory, `.cache`, `match-paths.json`) - let rawJSON = `[]` +interface IMatchPathMiddlewareOptions { + root: string; + enableLogging?: boolean; +} + +// Reads and parses match paths from the cache file +const readMatchPaths = async (program: IServeProgram): Promise> => { + const filePath = path.join(program.directory, '.cache', 'match-paths.json'); try { - rawJSON = await fs.readFile(filePath, `utf8`) + const rawJSON = await fs.readFile(filePath, 'utf8'); + return JSON.parse(rawJSON) as Array; } catch (error) { - report.warn(error) - report.warn( - `Could not read ${chalk.bold( - `match-paths.json` - )} from the .cache directory` - ) - report.warn( - `Client-side routing will not work correctly. Maybe you need to re-run ${chalk.bold( - `gatsby build` - )}?` - ) + report.warn(`Could not read ${chalk.bold('match-paths.json')} from the .cache directory`); + report.warn(`Client-side routing may not work correctly. Try re-running ${chalk.bold('gatsby build')}.`); + return []; } - return JSON.parse(rawJSON) as Array -} +}; -const matchPathRouter = - ( - matchPaths: Array, - options: { - root: string +// Decodes and normalizes URLs, handling edge cases +const sanitizeUrl = (url: string): string => { + try { + let decodedUrl = url; + let previousUrl; + + do { + previousUrl = decodedUrl; + decodedUrl = decodeURIComponent(decodedUrl); + } while (previousUrl !== decodedUrl); + + decodedUrl = decodedUrl.split(/[?#]/)[0]; + decodedUrl = decodedUrl.replace(/\/{2,}/g, '/'); + decodedUrl = decodedUrl.startsWith('/') ? decodedUrl : `/${decodedUrl}`; + decodedUrl = decodedUrl !== '/' ? decodedUrl.replace(/\/$/, '') : decodedUrl; + decodedUrl = decodedUrl.replace(/(^|\/)\.\.?(\/|$)/g, '/'); + decodedUrl = decodedUrl.replace(/[^a-zA-Z0-9\-._~!$&'()*+,;=:@/]/g, ''); + + return decodedUrl || '/'; + } catch (e) { + report.warn(`Failed to sanitize URL: "${url}". Error: ${e.message}`); + return url; + } +}; + +// Middleware to match paths with enhanced error handling, caching, and logging +const createMatchPathMiddleware = ( + matchPaths: Array, + options: IMatchPathMiddlewareOptions +): express.RequestHandler => { + const pathCache = new Map(); + const { root, enableLogging = false } = options; + + const getCachedPath = (url: string) => pathCache.get(url); + const cachePath = (url: string, resolvedPath: string | null) => { + pathCache.set(url, resolvedPath); + }; + + return (req, res, next) => { + if (!req.accepts('html')) return next(); + + const originalUrl = req.url; + const cachedPath = getCachedPath(originalUrl); + + if (cachedPath !== undefined) { + return cachedPath + ? res.sendFile(path.join(cachedPath, 'index.html'), { root }) + : next(); } - ) => - ( - req: express.Request, - res: express.Response, - next: express.NextFunction - ): void => { - const { url } = req - if (req.accepts(`html`)) { - const matchPath = matchPaths.find( - ({ matchPath }) => reachMatch(matchPath, url) !== null - ) + + const sanitizedUrl = sanitizeUrl(originalUrl); + const startTime = enableLogging ? process.hrtime() : null; + + try { + const matchPath = findMatchPath(matchPaths, sanitizedUrl); + if (matchPath) { + cachePath(originalUrl, matchPath.path); + logMatch(originalUrl, matchPath.path, startTime, enableLogging); + return res.sendFile( - path.join(matchPath.path, `index.html`), - options, - err => { + path.join(matchPath.path, 'index.html'), + { root }, + (err: Error) => { if (err) { - next() + cachePath(originalUrl, null); + res.status(404).end(); } } - ) + ); } + + cachePath(originalUrl, null); + next(); + } catch (error) { + handleError(originalUrl, error, res, next); } - return next() + }; +}; + +// Finds a matching path using reachMatch while handling potential errors +const findMatchPath = (matchPaths: Array, url: string): IMatchPath | undefined => { + return matchPaths.find(({ matchPath }) => { + try { + return reachMatch(matchPath, url) !== null; + } catch (e) { + report.error(`Error matching path for ${matchPath}: ${e.message}`); + return false; + } + }); +}; + +// Logs path matching information if logging is enabled +const logMatch = ( + url: string, + matchedPath: string, + startTime: [number, number] | null, + enableLogging: boolean +) => { + if (enableLogging && startTime) { + const [seconds, nanoseconds] = process.hrtime(startTime); + const duration = seconds * 1000 + nanoseconds / 1e6; + report.info(`Matched ${url} to ${matchedPath} in ${duration.toFixed(2)}ms`); } +}; + +// Error handling function for unexpected errors during request processing +const handleError = ( + url: string, + error: Error, + res: express.Response, + next: express.NextFunction +) => { + report.error(`Error processing URL "${url}": ${error.message}`); + res.status(500); + next(error); +}; module.exports = async (program: IServeProgram): Promise => { - await initTracer( - process.env.GATSBY_OPEN_TRACING_CONFIG_FILE || program.openTracingConfigFile - ) - let { prefixPaths, port, open, host } = program - port = typeof port === `string` ? parseInt(port, 10) : port - - const { configModule } = await getConfigFile( - program.directory, - `gatsby-config` - ) - const config: IGatsbyConfig = preferDefault(configModule) - - const { pathPrefix: configPathPrefix, trailingSlash } = config || {} - - const pathPrefix = prefixPaths && configPathPrefix ? configPathPrefix : `/` - - const root = path.join(program.directory, `public`) - - const app = express() + await initTracer(process.env.GATSBY_OPEN_TRACING_CONFIG_FILE || program.openTracingConfigFile); + let { prefixPaths, port, open, host, ssl } = program; + port = typeof port === 'string' ? parseInt(port, 10) : port; - // Proxy gatsby-script using off-main-thread strategy - const { partytownProxiedURLs = [] } = config || {} + const { configModule } = await getConfigFile(program.directory, 'gatsby-config'); + const config: IGatsbyConfig = preferDefault(configModule); - app.use(thirdPartyProxyPath, partytownProxy(partytownProxiedURLs)) + const { pathPrefix: configPathPrefix, trailingSlash } = config || {}; + const pathPrefix = prefixPaths && configPathPrefix ? configPathPrefix : '/'; + const root = path.join(program.directory, 'public'); - // eslint-disable-next-line new-cap - const router = express.Router() + const app = express(); + const { partytownProxiedURLs = [] } = config || {}; - router.use(compression()) + app.use(thirdPartyProxyPath, partytownProxy(partytownProxiedURLs)); + const router = express.Router(); + router.use(compression()); router.use( configureTrailingSlash( () => ({ pages: { get(pathName: string): IGatsbyPage | undefined { - return getDataStore().getNode(`SitePage ${pathName}`) as - | IGatsbyPage - | undefined + return getDataStore().getNode(`SitePage ${pathName}`) as IGatsbyPage | undefined; }, values(): Iterable { - return getDataStore().iterateNodesByType( - `SitePage` - ) as Iterable + return getDataStore().iterateNodesByType('SitePage') as Iterable; }, }, } as unknown as IGatsbyState), trailingSlash ) - ) + ); - router.use(express.static(`public`, { dotfiles: `allow` })) + router.use(express.static('public', { dotfiles: 'allow' })); - const compiledFunctionsDir = path.join( - program.directory, - `.cache`, - `functions` - ) - - let functions: Array = [] - try { - functions = JSON.parse( - fs.readFileSync(path.join(compiledFunctionsDir, `manifest.json`), `utf-8`) - ) - } catch (e) { - // ignore - } - - if (functions) { - const functionMiddlewaresInstances = functionMiddlewares({ - getFunctions(): Array { - return functions - }, + const matchPaths = await readMatchPaths(program); + router.use( + createMatchPathMiddleware(matchPaths, { + root, + enableLogging: process.env.NODE_ENV !== 'production', }) + ); - router.use(`/api/*`, ...functionMiddlewaresInstances) - // TODO(v6) remove handler from app and only keep it on router (router is setup on pathPrefix, while app is always root) - app.use(`/api/*`, ...functionMiddlewaresInstances) - } - - // Handle SSR & DSG Pages - let graphqlEnginePath: string | undefined - let pageSSRModule: string | undefined - try { - graphqlEnginePath = require.resolve( - path.posix.join(slash(program.directory), `.cache`, `query-engine`) - ) - pageSSRModule = require.resolve( - path.posix.join(slash(program.directory), `.cache`, `page-ssr`) - ) - } catch (error) { - // TODO: Handle case of engine not being generated - } - - if (graphqlEnginePath && pageSSRModule) { - try { - const { GraphQLEngine } = - require(graphqlEnginePath) as typeof import("../schema/graphql-engine/entry") - const { getData, renderPageData, renderHTML, findEnginePageByPath } = - require(pageSSRModule) as typeof import("../utils/page-ssr-module/entry") - const graphqlEngine = new GraphQLEngine({ - dbPath: path.posix.join( - slash(program.directory), - `.cache`, - `data`, - `datastore` - ), - }) - - router.get( - `/page-data/:pagePath(*)/page-data.json`, - async (req, res, next) => { - const requestedPagePath = req.params.pagePath - if (!requestedPagePath) { - return void next() - } - - const potentialPagePath = reverseFixedPagePath(requestedPagePath) - const page = findEnginePageByPath(potentialPagePath) - - if (page && (page.mode === `DSG` || page.mode === `SSR`)) { - const requestActivity = report.phantomActivity( - `request for "${req.path}"` - ) - requestActivity.start() - try { - const spanContext = requestActivity.span.context() - const data = await getData({ - pathName: req.path, - graphqlEngine, - req, - spanContext, - }) - const results = await renderPageData({ data, spanContext }) - if (data.serverDataHeaders) { - for (const [name, value] of Object.entries( - data.serverDataHeaders - )) { - res.setHeader(name, value) - } - } - - if (page.mode === `SSR` && data.serverDataStatus) { - return void res.status(data.serverDataStatus).send(results) - } else { - return void res.send(results) - } - } catch (e) { - report.error( - `Generating page-data for "${requestedPagePath}" / "${potentialPagePath}" failed.`, - e - ) - return res - .status(500) - .contentType(`text/plain`) - .send(`Internal server error.`) - } finally { - requestActivity.end() - } - } - - return void next() - } - ) - - router.use(async (req, res, next) => { - if (req.accepts(`html`)) { - const potentialPagePath = req.path - const page = findEnginePageByPath(potentialPagePath) - if (page && (page.mode === `DSG` || page.mode === `SSR`)) { - const requestActivity = report.phantomActivity( - `request for "${req.path}"` - ) - requestActivity.start() - - try { - const spanContext = requestActivity.span.context() - const data = await getData({ - pathName: potentialPagePath, - graphqlEngine, - req, - spanContext, - }) - const results = await renderHTML({ data, spanContext }) - if (data.serverDataHeaders) { - for (const [name, value] of Object.entries( - data.serverDataHeaders - )) { - res.setHeader(name, value) - } - } - - if (page.mode === `SSR` && data.serverDataStatus) { - return void res.status(data.serverDataStatus).send(results) - } else { - return void res.send(results) - } - } catch (e) { - report.error( - `Rendering html for "${potentialPagePath}" failed.`, - e - ) - return res.status(500).sendFile(`500.html`, { root }, err => { - if (err) { - res.contentType(`text/plain`).send(`Internal server error.`) - } - }) - } finally { - requestActivity.end() - } - } - } - return next() - }) - } catch (error) { - report.panic({ - id: `98051`, - error, - context: {}, - }) - } - } - - const matchPaths = await readMatchPaths(program) - router.use(matchPathRouter(matchPaths, { root })) - - // TODO: Remove/merge with above same block router.use((req, res, next) => { - if (req.accepts(`html`)) { - return res.status(404).sendFile(`404.html`, { root }) - } - return next() - }) - app.use(function ( - _: express.Request, - res: express.Response, - next: express.NextFunction - ) { - res.header(`Access-Control-Allow-Origin`, `*`) - res.header( - `Access-Control-Allow-Headers`, - `Origin, X-Requested-With, Content-Type, Accept` - ) - next() - }) - app.use(pathPrefix, router) - - function printInstructions(appName: string, urls: IPreparedUrls): void { - console.log() - console.log(`You can now view ${chalk.bold(appName)} in the browser.`) - console.log() - - if (urls.lanUrlForTerminal) { - console.log( - ` ${chalk.bold(`Local:`)} ${urls.localUrlForTerminal}` - ) - console.log( - ` ${chalk.bold(`On Your Network:`)} ${urls.lanUrlForTerminal}` - ) - } else { - console.log(` ${urls.localUrlForTerminal}`) + if (req.accepts('html')) { + return res.status(404).sendFile('404.html', { root }); } - } + return next(); + }); + + app.use(pathPrefix, router); const startListening = (): void => { - app.listen(port, host, () => { - const urls = prepareUrls( - program.ssl ? `https` : `http`, - program.host, - port - ) - printInstructions( - program.sitePackageJson.name || `(Unnamed package)`, - urls - ) + const server = ssl + ? https.createServer(ssl, app) + : http.createServer(app); + + server.listen(port, host, () => { + const urls = prepareUrls(ssl ? 'https' : 'http', program.host, port); + printInstructions(program.sitePackageJson.name || '(Unnamed package)', urls); if (open) { - report.info(`Opening browser...`) + report.info('Opening browser...'); Promise.resolve(openurl(urls.localUrlForBrowser)).catch(() => - report.warn(`Browser not opened because no browser was found`) - ) + report.warn('Browser not opened because no browser was found') + ); } - }) - } + }); + }; try { - port = await detectPortInUseAndPrompt(port, program.host) - startListening() + port = await detectPortInUseAndPrompt(port, program.host); + startListening(); } catch (e) { - if (e.message === `USER_REJECTED`) { - return + if (e.message === 'USER_REJECTED') { + return; } - - throw e + throw e; } -} +};