import httpProxy from 'http-proxy' import Debug from 'debug' import { ErrorRequestHandler, Request, Router } from 'express' import send from 'send' import { getPathToDist } from '@packages/resolve-dist' import { cors } from '@packages/network' import type { NetworkProxy } from '@packages/proxy' import type { Cfg } from './project-base' import xhrs from './controllers/xhrs' import { runner } from './controllers/runner' import { iframesController } from './controllers/iframes' import type { FoundSpec } from '@packages/types' import { getCtx } from '@packages/data-context' import { graphQLHTTP } from '@packages/graphql/src/makeGraphQLServer' import type { RemoteStates } from './remote_states' import bodyParser from 'body-parser' import path from 'path' import AppData from './util/app_data' import CacheBuster from './util/cache_buster' import specController from './controllers/spec' import client from './controllers/client' import files from './controllers/files' import * as plugins from './plugins' import { privilegedCommandsManager } from './privileged-commands/privileged-commands-manager' const debug = Debug('cypress:server:routes') export interface InitializeRoutes { config: Cfg getSpec: () => FoundSpec | null nodeProxy: httpProxy networkProxy: NetworkProxy remoteStates: RemoteStates onError: (...args: unknown[]) => any testingType: Cypress.TestingType } export const createCommonRoutes = ({ config, networkProxy, testingType, getSpec, remoteStates, nodeProxy, onError, }: InitializeRoutes) => { const router = Router() const { clientRoute, namespace } = config // When a test visits an http:// site and we load our main app page, // (e.g. test has cy.visit('http://example.com'), we load http://example.com/__/) // Chrome will make a request to the the https:// version (i.e. https://example.com/__/) // to check if it's valid. If it is valid, it will load the https:// version // instead. This leads to an infinite loop of Cypress trying to load // the http:// version because that's what the test wants and Chrome // loading the https:// version. Then since it doesn't match what the test // is visiting, Cypress attempts to the load the http:// version and the loop // continues. // See https://blog.chromium.org/2023/08/towards-https-by-default.html for // more info about Chrome's automatic https upgrades. // // The fix for Cypress is to signal to Chrome that the https:// version is // not valid by replying with a 301 redirect when we detect that it's // an https upgrade, which is when an https:// request comes through // one of your own proxied routes, but the the primary domain (a.k.a remote state) // is the http:// version of that domain // // https://github.com/cypress-io/cypress/issues/25891 // @ts-expect-error - TS doesn't like the Request intersection router.use('/', (req: Request & { proxiedUrl: string }, res, next) => { if ( // only these paths will receive the relevant https upgrade check (req.path !== '/' && req.path !== clientRoute) // not an https upgrade request if not https protocol || req.protocol !== 'https' // primary has not been established by a cy.visit() yet || !remoteStates.hasPrimary() ) { return next() } const primary = remoteStates.getPrimary() // props can be null in certain circumstances even if the primary is established if (!primary.props) { return next() } const primaryHostname = cors.domainPropsToHostname(primary.props) // domain matches (example.com === example.com), but incoming request is // https:// (established above), while the domain the user is trying to // visit (a.k.a primary origin) is http:// if ( primaryHostname === req.hostname && primary.origin.startsWith('http:') ) { res.status(301).redirect(req.proxiedUrl.replace('https://', 'http://')) return } next() }) // If we are in cypress in cypress we need to pass along the studio routes // to the child project. We also add a utility route for testing HTTP status code UI if (process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF_PARENT_PROJECT) { router.get('/__cypress-studio/*', async (req, res) => { await networkProxy.handleHttpRequest(req, res) }) router.get('/status-code-test/:num', (req, res) => { res.sendStatus(Number(req.params.num)) }) } else { // express matches routes in order. since this callback executes after the // router has already been defined, we need to create a new router to use // for the studio routes const studioRouter = Router() router.use('/', studioRouter) getCtx().coreData.studioLifecycleManager?.registerStudioReadyListener((studio) => { studio.initializeRoutes(studioRouter) }) } router.get(`/${config.namespace}/tests`, (req, res, next) => { // slice out the cache buster const test = CacheBuster.strip(req.query.p) specController.handle(test, req, res, config, next, onError) }) router.post(`/${config.namespace}/process-origin-callback`, bodyParser.json(), async (req, res) => { try { const { file, fn, projectRoot } = req.body debug('process origin callback: %s', fn) const contents = await plugins.execute('_process:cross:origin:callback', { file, fn, projectRoot }) res.json({ contents }) } catch (err) { const errorMessage = `Processing the origin callback errored:\n\n${err.stack}` debug(errorMessage) res.json({ error: errorMessage, }) } }) router.get(`/${config.namespace}/socket.io.js`, (req, res) => { client.handle(req, res) }) router.get(`/${config.namespace}/automation/getLocalStorage`, (req, res) => { res.sendFile(path.join(__dirname, './html/get-local-storage.html')) }) router.get(`/${config.namespace}/automation/setLocalStorage`, (req, res) => { const origin = req.originalUrl.slice(req.originalUrl.indexOf('?') + 1) networkProxy.http.getRenderedHTMLOrigins()[origin] = true res.sendFile(path.join(__dirname, './html/set-local-storage.html')) }) router.get(`/${config.namespace}/source-maps/:id.map`, async (req, res) => { await networkProxy.handleSourceMapRequest(req, res) }) // special fallback - serve dist'd (bundled/static) files from the project path folder router.get(`/${config.namespace}/bundled/*`, (req, res) => { const file = AppData.getBundledFilePath(config.projectRoot, path.join('src', req.params[0])) debug(`Serving dist'd bundle at file path: %o`, { path: file, url: req.url }) res.sendFile(file, { etag: false }) }) router.get(`/${config.namespace}/spec-bridge-iframes`, async (req, res) => { debug('handling cross-origin iframe for domain: %s', req.hostname) // Chrome plans to make document.domain immutable in Chrome 109, with the default value // of the Origin-Agent-Cluster header becoming 'true'. We explicitly disable this header // in the spec-bridge-iframe to allow setting document.domain to the bare domain // to guarantee the spec bridge can communicate with the injected code. // @see https://github.com/cypress-io/cypress/issues/25010 res.setHeader('Origin-Agent-Cluster', '?0') await files.handleCrossOriginIframe(req, res, config) }) router.post(`/${config.namespace}/add-verified-command`, bodyParser.json(), (req, res) => { privilegedCommandsManager.addVerifiedCommand(req.body) res.sendStatus(204) }) if (process.env.CYPRESS_INTERNAL_VITE_DEV) { const proxy = httpProxy.createProxyServer({ target: `http://localhost:${process.env.CYPRESS_INTERNAL_VITE_APP_PORT}/`, }) router.get('/__cypress/assets/*', (req, res) => { proxy.web(req, res, {}, (e) => {}) }) } else { router.get('/__cypress/assets/*', (req, res) => { const pathToFile = getPathToDist('app', req.params[0]) return send(req, pathToFile).pipe(res) }) } router.use(`/${namespace}/graphql/*`, graphQLHTTP) router.get(`/${namespace}/runner/*`, (req, res) => { runner.handle(req, res) }) router.all(`/${namespace}/xhrs/*`, (req, res, next) => { xhrs.handle(req, res, config, next) }) router.get(`/${namespace}/iframes/*`, async (req, res) => { if (testingType === 'e2e') { await iframesController.e2e({ config, getSpec, remoteStates }, req, res) } if (testingType === 'component') { iframesController.component({ config, nodeProxy }, req, res) } }) if (!clientRoute) { throw Error(`clientRoute is required. Received ${clientRoute}`) } router.get(clientRoute, (req: Request & { proxiedUrl?: string }, res) => { const nonProxied = req.proxiedUrl?.startsWith('/') ?? false getCtx().actions.app.setBrowserUserAgent(req.headers['user-agent']) // Chrome plans to make document.domain immutable in Chrome 109, with the default value // of the Origin-Agent-Cluster header becoming 'true'. We explicitly disable this header // so that we can continue to support tests that visit multiple subdomains in a single spec. // https://github.com/cypress-io/cypress/issues/20147 res.setHeader('Origin-Agent-Cluster', '?0') getCtx().html.appHtml(nonProxied) .then((html) => res.send(html)) .catch((e) => res.status(500).send({ stack: e.stack })) }) // serve static assets from the dist'd Vite app router.get([ `${clientRoute}assets/*`, `${clientRoute}shiki/*`, ], (req, res) => { debug('proxying static assets %s, params[0] %s', req.url, req.params[0]) const pathToFile = getPathToDist('app', 'assets', req.params[0]) return send(req, pathToFile).pipe(res) }) // user app code + spec code // default mounted to /__cypress/src/* // TODO: Remove this - only needed for Cy in Cy testing for unknown reasons. if (process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) { router.get(`${config.devServerPublicPathRoute}*`, (req, res) => { debug(`proxying to %s, originalUrl %s`, config.devServerPublicPathRoute, req.originalUrl) // user the node proxy here instead of the network proxy // to avoid the user accidentally intercepting and modifying // their own app.js files + spec.js files nodeProxy.web(req, res, {}, (e) => { if (e) { // eslint-disable-next-line debug('Proxy request error. This is likely the socket hangup issue, we can basically ignore this because the stream will automatically continue once the asset will be available', e) } }) }) } router.all('*', async (req, res) => { await networkProxy.handleHttpRequest(req, res) }) // when we experience uncaught errors // during routing just log them out to // the console and send 500 status // and report to raygun (in production) const errorHandlingMiddleware: ErrorRequestHandler = (err, req, res) => { console.log(err.stack) // eslint-disable-line no-console res.set('x-cypress-error', err.message) res.set('x-cypress-stack', JSON.stringify(err.stack)) res.sendStatus(500) } router.use(errorHandlingMiddleware) return router }