Skip to content
Merged
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
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,10 @@ UMAMI_WEBSITE_ID="your-umami-website-id"
UMAMI_DOMAINS="your-umami-domains" # comma separated list of domains the umami script should track - https://umami.is/docs/tracker-configuration#data-domains
UMAMI_DOMAIN="your-umami-domain" # umami domain - https://umami.is/docs/bypass-ad-blockers#using-environment-variables-self-hosting
UMAMI_SCRIPT_NAME="your-umami-script-name" # umami script name - https://umami.is/docs/bypass-ad-blockers#using-environment-variables-self-hosting
UMAMI_PUBLIC_ANALYTICS_URL="your-umami-public-analytics-url" # https://umami.is/docs/enable-share-url
UMAMI_PUBLIC_ANALYTICS_URL="your-umami-public-analytics-url" # https://umami.is/docs/enable-share-url

# Set to "false" on non-production deploys (staging, previews) so they are not
# indexed and do not compete with production in search results. It emits
# `X-Robots-Tag: noindex, nofollow` plus a <meta name="robots"> tag. Leave it
# unset (or "true") in production.
ALLOW_INDEXING="false"
42 changes: 35 additions & 7 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { PassThrough } from 'node:stream'
import { styleText } from 'node:util'
import { createReadableStreamFromReadable } from '@react-router/node'
import * as Sentry from '@sentry/node'
import chalk from 'chalk'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
import {
Expand All @@ -12,6 +12,11 @@ import {
} from 'react-router'
import { getEnv, init } from './utils/env.server'
import { NonceProvider } from './utils/nonce-provider'
import {
getRouteErrorCause,
isExpectedReactRouterRouteError,
} from './utils/sentry-event-filters'
import { makeTimings } from './utils/timing.server'

export const streamTimeout = 5000

Expand All @@ -37,6 +42,9 @@ export default async function handleRequest(...args: DocRequestArgs) {
const nonce = request.headers.get('x-csp-nonce') ?? ''
return new Promise(async (resolve, reject) => {
let didError = false
// NOTE: this timing will only include things that are rendered in the shell
// and will not include suspended components and deferred loaders
const timings = makeTimings('render', 'renderToPipeableStream')

const { pipe, abort } = renderToPipeableStream(
<NonceProvider value={nonce}>
Expand All @@ -50,6 +58,7 @@ export default async function handleRequest(...args: DocRequestArgs) {
[callbackName]: () => {
const body = new PassThrough()
responseHeaders.set('Content-Type', 'text/html')
responseHeaders.append('Server-Timing', timings.toString())
resolve(
new Response(createReadableStreamFromReadable(body), {
headers: responseHeaders,
Expand Down Expand Up @@ -88,11 +97,30 @@ export function handleError(
if (request.signal.aborted) {
return
}
if (error instanceof Error) {
console.error(chalk.red(error.stack))
void Sentry.captureException(error)
} else {
console.error(error)
void Sentry.captureException(error)

// React Router hands `handleError` an `ErrorResponse` rather than an
// `Error` for anything a route rejected, so the message and stack live on
// `.error`. Report that instead of the wrapper, otherwise Sentry files a
// stackless issue grouped on a synthesised message. This mirrors React
// Router's own default handler.
const reported = getRouteErrorCause(error)
const logReportedError = () => {
if (reported instanceof Error) {
console.error(styleText('red', String(reported.stack)))
} else {
console.error(reported)
}
}

// Bots and scanners request every URL in the sitemap with methods the
// routes do not handle, and React Router throws for each of those. They are
// expected answers to invalid traffic, not bugs, so they get logged (they
// still show up in `fly logs`) but never reported to Sentry.
if (isExpectedReactRouterRouteError(error)) {
logReportedError()
return
}

logReportedError()
void Sentry.captureException(reported)
}
75 changes: 66 additions & 9 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
data,
href,
Link,
Links,
Expand All @@ -7,6 +8,7 @@ import {
Scripts,
ScrollRestoration,
useLoaderData,
useRouteLoaderData,
} from 'react-router'
import { ClientOnly } from 'remix-utils/client-only'
import { HoneypotProvider } from 'remix-utils/honeypot/react'
Expand All @@ -29,6 +31,7 @@ import { honeypot } from '#app/utils/honeypot.server'
import { getDomainUrl, getUrl } from '#app/utils/misc'
import { useNonce } from '#app/utils/nonce-provider'
import { getSocialMetas } from '#app/utils/seo'
import { makeTimings, time } from '#app/utils/timing.server'
import { type Theme } from '#types/index'
import { type Route } from './+types/root'

Expand Down Expand Up @@ -67,20 +70,64 @@ export const meta: Route.MetaFunction = ({ loaderData }) => {
]
}

export async function loader({ request }: Route.LoaderArgs) {
const honeyProps = await honeypot.getInputProps()
/**
* Split out of `loader` so `RootLoaderData` can be derived from it. The loader
* wraps this payload in `data()` to attach a `Server-Timing` header, which
* means `Awaited<ReturnType<typeof loader>>` is a `DataWithResponseInit` rather
* than the payload. `useLoaderData`/`useRouteLoaderData` unwrap it, but the
* `meta` functions in child routes read the root match's `loaderData` straight
* out of `matches`, where they need the payload type explicitly.
*/
async function getRootData(request: Request) {
const timings = makeTimings('root loader')
const honeyProps = await time(() => honeypot.getInputProps(), {
timings,
type: 'honeypot',
desc: 'get honeypot input props in root',
})

return {
requestInfo: {
hints: getHints(request),
origin: getDomainUrl(request),
path: new URL(request.url).pathname,
timings,
payload: {
requestInfo: {
hints: getHints(request),
origin: getDomainUrl(request),
path: new URL(request.url).pathname,
},
ENV: getEnv(),
honeyProps,
},
ENV: getEnv(),
honeyProps,
}
}

export type RootLoaderData = Awaited<ReturnType<typeof getRootData>>['payload']

export async function loader({ request }: Route.LoaderArgs) {
const { payload, timings } = await getRootData(request)

return data(payload, {
headers: { 'Server-Timing': timings.toString() },
})
}

/**
* React Router only puts a loader's response headers on the document response
* if a matched route exports a `headers` function, so without this the root
* loader's `Server-Timing` never leaves the server. `entry.server.tsx` appends
* the render timings to the same header.
*/
export const headers: Route.HeadersFunction = ({
loaderHeaders,
parentHeaders,
}) => {
const headers = new Headers(parentHeaders)
const serverTiming = loaderHeaders.get('Server-Timing')
if (serverTiming) {
headers.set('Server-Timing', serverTiming)
}
return headers
}

function Document({
children,
nonce,
Expand All @@ -105,6 +152,13 @@ function Document({
<Meta />
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
{env.ALLOW_INDEXING === 'false' ? (
// Staging deploys opt out of indexing with ALLOW_INDEXING=false
// so they do not compete with production in search results. The
// server sends the matching X-Robots-Tag header; this covers
// crawlers that read the document instead.
<meta name="robots" content="noindex, nofollow" />
) : null}
<Links />
</head>
<body className="bg-background text-foreground">
Expand Down Expand Up @@ -324,9 +378,12 @@ function Footer() {

export function ErrorBoundary() {
const nonce = useNonce()
// The server sends `X-Robots-Tag` on every response including this one, so
// pass the same env to keep the document's robots meta tag in agreement.
const data = useRouteLoaderData<typeof loader>('root')

return (
<Document nonce={nonce}>
<Document nonce={nonce} env={data?.ENV ?? globalThis.ENV}>
<GeneralErrorBoundary />
</Document>
)
Expand Down
14 changes: 10 additions & 4 deletions app/routes/_marketing+/talks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '#app/components/highlight'
import { LineGlow } from '#app/components/line-glow'
import { TalkCard } from '#app/components/talk-card'
import { type loader as rootLoader } from '#app/root'
import { type RootLoaderData } from '#app/root'
import { formatDateWithHints } from '#app/utils/client-hints'
import { getUrl } from '#app/utils/misc'
import { getSocialMetas } from '#app/utils/seo'
Expand All @@ -20,8 +20,7 @@ const PAGE_DESCRIPTION =

export const meta: Route.MetaFunction = ({ matches }) => {
const rootMatch = matches.find((m) => m?.id === 'root')
const rootData = rootMatch?.loaderData as
Awaited<ReturnType<typeof rootLoader>> | undefined
const rootData = rootMatch?.loaderData as RootLoaderData | undefined
const requestInfo = rootData?.requestInfo
return [
{ title: PAGE_TITLE },
Expand Down Expand Up @@ -53,7 +52,14 @@ export default function Talks() {
React.useEffect(() => {
const raw = location.hash.slice(1)
if (!raw) return
const id = decodeURIComponent(raw)
// A malformed percent-encoding in the hash would throw a URIError here,
// in the browser, and take the effect down with it.
let id: string
try {
id = decodeURIComponent(raw)
} catch {
return
}
const run = () => {
const el = document.getElementById(id)
if (el instanceof HTMLLIElement && el.tabIndex === -1) {
Expand Down
8 changes: 7 additions & 1 deletion app/utils/analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ import { useState, useEffect, useRef } from 'react'
import { useRequestInfo } from './request-info'

export function usePosthogPageView() {
const { origin, path } = useRequestInfo()
const requestInfo = useRequestInfo()
// No root loader means no request info (see `useRequestInfo`), which happens
// for the error responses React Router short-circuits. There is no page view
// to report in that case, and the hooks below still have to run.
const origin = requestInfo?.origin
const path = requestInfo?.path
const [previousLocation, setPreviousLocation] = useState(path)
const isInitialRender = useRef(true)

useEffect(() => {
if (path === undefined) return
try {
if (isInitialRender.current) {
isInitialRender.current = false
Expand Down
5 changes: 4 additions & 1 deletion app/utils/client-hints.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ export function formatDateWithHints(isoDate: string, request: Request) {
*/
export function useHints() {
const requestInfo = useRequestInfo()
return requestInfo.hints
// With no root loader there are no request headers to read the cookies from
// (see `useRequestInfo`), so fall back to each hint's own default rather
// than throwing in the middle of the document render.
return requestInfo?.hints ?? getHints()
}

/**
Expand Down
6 changes: 6 additions & 0 deletions app/utils/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export function getEnv() {
UMAMI_DOMAINS: process.env.UMAMI_DOMAINS,
UMAMI_SCRIPT_NAME: process.env.UMAMI_SCRIPT_NAME,
UMAMI_PUBLIC_ANALYTICS_URL: process.env.UMAMI_PUBLIC_ANALYTICS_URL,
// Setting this to 'false' tells the server to send an
// `X-Robots-Tag: noindex, nofollow` response header and the document to
// render a matching `<meta name="robots">` tag. Intended for non-production
// deploys (staging, previews) so they do not compete with production in
// search results. Leave it unset or 'true' in production.
ALLOW_INDEXING: process.env.ALLOW_INDEXING,
}
}

Expand Down
16 changes: 10 additions & 6 deletions app/utils/request-info.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { invariant } from '@epic-web/invariant'
import { useRouteLoaderData } from 'react-router'
import { type loader as rootLoader } from '#app/root'

/**
* @returns the request info from the root loader
* @returns the request info from the root loader, or `undefined` when the root
* loader never ran.
*
* That second case is not hypothetical. React Router short-circuits a request
* with no matching `loader`/`action`, or with an unsupported method, *before*
* any loader runs — so for a 400/405 there is no root data, yet the document
* (and therefore the error boundary) still has to render. Asserting the data is
* present here turned every such request into a 500 with the real cause
* buried, so callers degrade instead.
*/
export function useRequestInfo() {
const data = useRouteLoaderData<typeof rootLoader>('root')
invariant(data?.requestInfo, 'No requestInfo found in root loader')

return data.requestInfo
return useRouteLoaderData<typeof rootLoader>('root')?.requestInfo
}
12 changes: 12 additions & 0 deletions app/utils/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ export const envSchema = z.object({
UMAMI_DOMAINS: z.string(),
UMAMI_SCRIPT_NAME: z.string(),
UMAMI_PUBLIC_ANALYTICS_URL: z.string().optional(),
// `server/index.ts` reads the same variable with a deliberately lenient
// grammar: only the exact string 'false' opts out of indexing, everything
// else (including unset and '') means indexing is allowed. An enum would
// reject '', '0' and 'FALSE' in `init()`, and because the app bundle is
// imported per request inside `getBuild()` — whose catch swallows the
// failure — a mistyped value would turn into a permanent silent 500 on
// every route instead of a boot error. Normalise instead of rejecting, so
// the two parsers can never disagree.
ALLOW_INDEXING: z
.string()
.optional()
.transform((value) => (value === 'false' ? 'false' : undefined)),
})

// Type exports for TypeScript inference
Expand Down
Loading
Loading