vercel / analytics

Privacy-friendly, real-time traffic insights
https://vercel.com/analytics
Mozilla Public License 2.0
429 stars 26 forks source link

Access-Control-Allow-Origin header won't attached when reverse-proxying #146

Open allisonIsCoding opened 2 months ago

allisonIsCoding commented 2 months ago

I am running into this issue, where the Access-Control-Allow-Origin header is missing from the /_vercel/insights/view endpoint when reverse proxying (so it works when I am on the request URL directly but not when there's a referrer url. I have added the headers to the vercel.json, the next.config.js, the middleware that handles it, and even tried on the proxy's side via Cloudflare. Hoping to get some help in solving this, and share as much context as I can

The vercel.json file:

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "rewrites": [
    {
      "source": "(.*sitemap.xml[/]?.*)",
      "destination": "/api/sitemaps"
    }
  ],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Access-Control-Allow-Credentials", "value": "true" },
        { "key": "Access-Control-Allow-Origin", "value": "*" },
        {
          "key": "Access-Control-Allow-Methods",
          "value": "GET,OPTIONS,PATCH,DELETE,POST,PUT"
        },
        {
          "key": "Access-Control-Allow-Headers",
          "value": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version"
        }
      ]
    }
  ],
  "github": {
    "silent": true
  }
}

The next.config.js

const path = require('path')
const { withSentryConfig } = require('@sentry/nextjs')

const { version } = require('./package.json')

/** @type {import('next').NextConfig} */
const nextConfig = {
  assetPrefix: process.env.ASSET_PREFIX ?? '',
  compiler: {
    styledComponents: true,
  },
  reactStrictMode: false,
  images: {
    domains: ['asset-url', 'another-asset-url'],
    loader: 'custom',
    loaderFile: './src/imageLoader.js',
    formats: ['image/avif', 'image/webp'],
  },
  webpack: (config, options) => {
    const { dev, isServer } = options

    config.resolve.alias['styled-components'] = path.resolve(
      './node_modules/styled-components'
    )

    return config
  },
  logging: {
    fetches: {
      fullUrl: true,
    },
    level: 'verbose',
  },
  modularizeImports: {
    lodash: {
      transform: 'lodash/{{member}}',
    },
  },
  experimental: {
    forceSwcTransforms: true,
    instrumentationHook: true,
  },
  env: {
    VERSION: version,
  },
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          { key: 'Access-Control-Allow-Credentials', value: 'true' },
          { key: 'Access-Control-Allow-Origin', value: '*' },
          {
            key: 'Access-Control-Allow-Methods',
            value: 'GET,OPTIONS,PATCH,DELETE,POST,PUT',
          },
          {
            key: 'Access-Control-Allow-Headers',
            value:
              'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version',
          },
        ],
      },
    ]
  },
}

module.exports = withSentryConfig(
  // withBundleAnalyzer(nextConfig),
  nextConfig,
  {
    // For all available options, see:
    // https://github.com/getsentry/sentry-webpack-plugin#options

    // Suppresses source map uploading logs during build
    silent: true,
    org: 'my-org',
    project: 'my-project',
  },
  {
    // For all available options, see:
    // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/

    // Upload a larger set of source maps for prettier stack traces (increases build time)
    widenClientFileUpload: true,

    // Transpiles SDK to be compatible with IE11 (increases bundle size)
    transpileClientSDK: false,

    // // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. (increases server load)
    // // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
    // // side errors will fail.
    // tunnelRoute: '/monitoring',

    // Hides source maps from generated client bundles
    hideSourceMaps: true,

    // Automatically tree-shake Sentry logger statements to reduce bundle size
    disableLogger: true,

    environment: process.env.VERCEL_ENV,

    // Enables automatic instrumentation of Vercel Cron Monitors.
    // See the following for more information:
    // https://docs.sentry.io/product/crons/
    // https://vercel.com/docs/cron-jobs
    automaticVercelMonitors: true,
  }
)

The middleware

import { NextResponse, NextRequest } from 'next/server'

import { addQueryStringParams, join, getFullIDUrl } from './api/ssr/utils'
import { OrderAttribution } from 'shared'
import { notFound } from 'next/navigation'
import { logMiddlewareError } from './utils/sentry'

const PROXY_TYPE_ID = 'myorg-type-id'
const PROXY_TYPE_ID_OLD = 'myorg-id'
const PROXY_TYPE_PATH = 'myorg-exampleB-path'
const PROTOCOL = process.env.NEXT_PUBLIC_ENV === 'local' ? 'http' : 'https'

export async function middleware(request: NextRequest) {
  const response = NextResponse.next()

  if (request.method === 'HEAD') {
    console.log('HEAD request, returning 200 request', request)
    console.log('HEAD request, returning 200 response', NextResponse.json({}, { status: 200 }))
    return NextResponse.json({}, { status: 200 })
  }

// NOTE: here I have tried adding these headers as both the request headers and the response headers to no avail.
  request.headers.set('Access-Control-Allow-Origin', '*')
  request.headers.set('Access-Control-Allow-Credentials', 'true')
  request.headers.set('Access-Control-Allow-Methods', 'GET,OPTIONS,PATCH,DELETE,POST,PUT')
  request.headers.set('Access-Control-Allow-Headers', 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version')

  if (request.method !== 'GET') {
    return response
  }

  const requestUrl = new URL(request.nextUrl)
  const nextUrl = request.nextUrl.clone()

  try {
    const requestHeaders = new Headers(request.headers)

    const id =
      requestHeaders.get(PROXY_TYPE_ID) ??
      requestHeaders.get(PROXY_TYPE_ID_OLD)
    let storePath = requestHeaders.get(PROXY_TYPE_PATH)
    typePath = typePath?.startsWith('/') ? typePath : `/${typePath}`

    const nextJsUrl = request.nextUrl.toString()

    requestHeaders.set('x-pathname', request.nextUrl.pathname)
    requestHeaders.set('x-url', nextJsUrl)

    const nextUrlSearchParams = nextUrl.searchParams

    addRequestHeaders({
      nextUrl,
      searchParams: nextUrlSearchParams,
      requestHeaders,
    })

    // ---- proxying ----
    //if we have an id and an external path, we are being proxied to.
    //so we need to rewrite the url to the correct path.
    //the incoming url will be something like '/place/type/things'.
    //we need to rewrite it to '/:id/stuff/things':
    // -- the 'myorg-type-id' is set by the customer's proxy
    //    to tell us which store we are on, used to replace ${id} below
    // -- the 'myorg-type-path' is set by the customer's proxy
    //    to tell us which part of the url our stuff should come after
    //ex. '/place/type'
    //we replace that with the id and '/endpoint' which matches the folder structure for NextJS (src/app/[id]/endopint/thing.tsx).
    if (id && typePath) {
      console.log(
        `----- PROXYING id:${id} typePath:${typePath} -----`
      )

      if (nextUrl.pathname === '/robots.txt') {
        return NextResponse.rewrite(new URL('/robots.txt', nextUrl))
      }

      const newUrl = new URL(
        requestUrl.pathname.replace(path, ''),
        `${PROTOCOL}://${requestUrl.hostname}:${requestUrl.port}`
      )

      if (nextUrl.pathname.endsWith('/robots.txt')) {
        return NextResponse.rewrite(new URL('/robots.txt', newUrl))
      }

      const dir = newUrl.pathname.startsWith('/exampleA') ? '' : 'exampleB'

      const rewritePathname = addQueryStringParams(
        join(`/${id}/${dir}`, newUrl.pathname),
        Object.fromEntries(nextUrlSearchParams.entries()) ?? {}
      )

      const rewriteUrl = new URL(rewritePathname, newUrl.toString())

      return NextResponse.rewrite(rewriteUrl, {
        headers: requestHeaders,
      })
    }

    // console.log('----- NO PROXYING -----')

    if (nextUrl.pathname === '/our-endpoint' && nextUrlSearchParams.get('id')) {
      const newUrl = new URL(request.url)
      const rewritePathname = addQueryStringParams(
        join(`/${nextUrlSearchParams.get('id')}`, nextUrl.pathname),
        Object.fromEntries(newUrl.searchParams.entries()) ?? {}
      )

      return NextResponse.rewrite(new URL(rewritePathname, newUrl.toString()), {
        headers: requestHeaders,
      })
    }

    //ORG Iframe Connect Mode
    //ORG consumer apps should point to /{id}/org-connect?orgUid=12345&anAuthToken=4567
    if (nextUrl.pathname.match(/\/[0-9A-Z&\-%]*\/org-connect/i)) {
      if (
        !nextUrlSearchParams.get('orgUid') ||
        !nextUrlSearchParams.get('anAuthToken')
      ) {
        return NextResponse.rewrite(new URL('/not-found', nextUrl.toString()), {
          headers: requestHeaders,
        })
      }

      requestHeaders.set('an-attribute, 'true')
    }

    return NextResponse.next({
      request: {
        headers: requestHeaders,
      },
    })
  } catch (error) {
    logMiddlewareError(error, {
      url: requestUrl.pathname,
    })

    return notFound()
  }
}

function addRequestHeaders({
  nextUrl,
  searchParams,
  requestHeaders,
}: {
  nextUrl: URL
  searchParams: URLSearchParams
  requestHeaders: Headers
}) {
...
...
...
  requestHeaders.set(
    'example-myorg',
    searchParams.get('example') ?? ''
  )
...
}

export const config = {
  matcher: [
    '/((?!api|_vercel|_next/static|/monitoring|_next/image|favicon.ico|robots.txt|ads.txt|sitemap.xml|manifest.json|android-chrome-*.png|apple-touch-icon.png|browserconfig.xml|mstile-150x150.png|safari-pinned-tab.svg|site.webmanifest|favicon-.*.png).*)',
  ],
}

How we set analytics:

import 'server-only'

import { headers as getHeaders } from 'next/headers'
import Script from 'next/script'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Analytics } from '@vercel/analytics/react'
......

export default async function Layout({
  children,
  params,
}: {
  children: React.ReactNode
  params: { id: string }
}) {
  const headers = getHeaders()

  const pathname = getNextJsPathname(headers)
...

  const id = params.id

  if (!pathname) throw new Error('missing pathname')

  const routeName =
    variant === 'exampleB' && id
      ? getexampleBRouteName({
          id,
          headers,
        })
      : getCurrentExampleRouteName(pathname)

  const vercelAnalyticsSrc = process.env.ASSET_PREFIX
    ? join(process.env.ASSET_PREFIX, '_vercel/insights/script.js')
    : '/_vercel/insights/script.js'
  const vercelAnalyticsEndpoint = process.env.ASSET_PREFIX
    ? join(process.env.ASSET_PREFIX, '_vercel/insights')
    : '/_vercel/insights'
  const vercelSpeedInsightsSrc = process.env.ASSET_PREFIX
    ? join(process.env.ASSET_PREFIX, '_vercel/speed-insights/script.js')
    : '/_vercel/speed-insights/script.js'
  const vercelSpeedInsightsEndpoint = process.env.ASSET_PREFIX
    ? join(process.env.ASSET_PREFIX, '_vercel/speed-insights/vitals')
    : '_vercel/speed-insights/vitals'

  try {
    const appConfig = await getAppConfigById(id)

    if (!appConfig) {
      return notFound()
    }

    let googleFontsEnabled = false
    let googleFontString = ''
    let customFontString = ''
    let cssVars = ''

...
    const queryClient = getSSRQueryClient()
...
...
    return (
      <html
        lang="en"
        title={title}
      >
        <head>
          <style
            id="myorg-exampleB-styles"
            dangerouslySetInnerHTML={{
              __html: `
              :root {
                ${cssVars}
              }

...{style stuff}
          />
          {googleFontString && (
            <link
              href={`https://fonts.googleapis.com/css?family=${googleFontString}&display=swap`}
              rel="stylesheet"
            />
          )}
          <ErrorBoundary error="custom-head-code-error">
            {appConfig.place.exampleHeadCode
              ? htmlReactParser(appConfig.place.exampleBHeadCode)
              : null}
          </ErrorBoundary>
...
...
        </head>
        <body className={bodyClassName}>
...
...
...
          {process.env.ENV !== 'local' && (
            <>
              <SpeedInsights
                scriptSrc={vercelSpeedInsightsSrc}
                endpoint={vercelSpeedInsightsEndpoint}
              />
              <Analytics
                scriptSrc={vercelAnalyticsSrc}
                endpoint={vercelAnalyticsEndpoint}
              />
            </>
          )}
...
...
...
        </body>
      </html>
    )
  } catch (error) {
....
}

export const runtime = 'edge'
...

Then in Cloudflare, I tried from the org doing to proxy to set the headers following their docs, with no such luck.

In the browser, we see a CORs error with a missing header, and I do see it's missing but can't seem to add it with any of the above

Screenshot 2024-08-15 at 9 48 10 AM Screenshot 2024-08-15 at 9 47 59 AM

I suspect there's some issue either with how we have Vercel Analytics implemented, or NextJS is stripping the header at some point, but have hit a bit of a wall and could use new eyes. Thanks in advance for any help!