diff --git a/README.md b/README.md index 72cd2a5..9f6eab0 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,8 @@ docker run -p 4000:4000 \ and `SECRET_KEY_BASE` and will refuse to boot without them. `PHX_HOST` defaults to `example.com` but should always be set in production (it's used for absolute URLs in emails). `PORT` (default `4000`), `POOL_SIZE`, `ECTO_IPV6`, -`DNS_CLUSTER_QUERY`, `TRUST_PROXY_HEADERS`, `TRUSTED_PROXIES`, `SSR_POOL_SIZE` +`DNS_CLUSTER_QUERY`, `TRUST_PROXY_HEADERS`, `TRUSTED_PROXIES`, +`CLIENT_IP_HEADERS`, `SSR_POOL_SIZE` (number of Inertia SSR Node workers, default `2`), and `NODE_OPTIONS` (set in the Dockerfile to `--max-old-space-size=160`, caps each SSR worker's V8 heap) are optional. The last two are the main levers on the Node-side memory the SSR pool @@ -142,9 +143,21 @@ fresh rate-limit bucket per request. Proxies that reach the app from a private address need nothing further — private and reserved ranges are always treated as proxies. For a proxy on a *public* address, list its ranges in `TRUSTED_PROXIES` (comma-separated CIDRs), or it will -be mistaken for the client. Cloudflare deployments should also read -`cf-connecting-ip` instead of `x-forwarded-for`; see -`ElixirReactStarterWeb.Plugs.ClientIp` for that setting. +be mistaken for the client. + +**Behind Cloudflare, also set `CLIENT_IP_HEADERS=cf-connecting-ip`.** Cloudflare's +edge terminates on a public address, so the default `x-forwarded-for` walk stops +there and reports the edge as the caller — every visitor arriving through the +same edge shares one rate-limit bucket. That is better than the single global +bucket, and still not per-caller. `cf-connecting-ip` holds the address Cloudflare +already resolved, so there is no chain to walk. Listing Cloudflare's published +ranges in `TRUSTED_PROXIES` works too, but that list has to be kept current. + +Whichever header is trusted, a request that reaches the origin *directly* — +bypassing the proxy — can forge it and pick its own rate-limit bucket. Closing +that off means restricting origin access to the proxy (IP allowlisting, a tunnel, +or authenticated origin pulls). The exposure is limited to rate limiting, not +authorization, but it is worth deciding deliberately rather than by default. Database migrations run via the release: `bin/migrate`. See Phoenix's [deployment guides](https://hexdocs.pm/phoenix/deployment.html) for hosting specifics. diff --git a/assets/build/generate-ssr-pages.js b/assets/build/generate-ssr-pages.js index 4e08d0d..535baae 100644 --- a/assets/build/generate-ssr-pages.js +++ b/assets/build/generate-ssr-pages.js @@ -68,6 +68,15 @@ const clientEntries = [...all] .map((p) => ` ${keyFor(p.name)}: () => import('${p.importPath}'),`) .join('\n'); +const serverRendered = all + .filter((p) => p.strategy === 'ssr') + .map((p) => p.name) + .sort(); + +const serverRenderedSet = serverRendered.length + ? `new Set([\n${serverRendered.map((n) => ` '${n}',`).join('\n')}\n])` + : 'new Set()'; + const clientContent = `// Auto-generated by build/generate-ssr-pages.js — do not edit manually // Lazy client page loaders keyed by Inertia page name (the ssr/ or client/ @@ -78,6 +87,12 @@ const pages: Record Promise<{ default: React.ComponentType }> ${clientEntries} }; +// Pages the server actually rendered (pages/ssr/). app.tsx hydrates these — +// adopting the markup already in the document — and plain-renders everything +// else: a client-only page produced no server HTML, so hydrating one would +// fail the match and push React through its recovery path for nothing. +export const serverRenderedPages = ${serverRenderedSet}; + export default pages; `; diff --git a/assets/js/app.tsx b/assets/js/app.tsx index 98cc508..6495072 100644 --- a/assets/js/app.tsx +++ b/assets/js/app.tsx @@ -4,9 +4,9 @@ import './sentry'; import './i18n'; import { go } from '@api3/promise-utils'; import { createInertiaApp, router } from '@inertiajs/react'; -import { createElement, StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import pages from './_pages'; +import { createElement, StrictMode, useEffect } from 'react'; +import { createRoot, hydrateRoot } from 'react-dom/client'; +import pages, { serverRenderedPages } from './_pages'; import { AppProviders } from './app-providers'; import ErrorBoundary from './components/ErrorBoundary'; import { syncLocale } from './components/LocaleSync'; @@ -25,6 +25,31 @@ function applyFlash(flash?: Flash) { if (flash.error) toast.error(flash.error); } +// Guards against StrictMode's deliberate double-invocation of effects in +// dev, which would otherwise raise the same flash twice. +let initialFlashApplied = false; + +/** + * Raises the flash from the *initial* page load, from an effect. + * + * Toasts live in a store outside React, so queueing one before the first + * render would put a toast in the client's tree that the server never + * rendered — a hydration mismatch, and React would discard the + * server-rendered DOM it was meant to adopt. Effects run after the + * hydration commit, so the toast arrives in a follow-up render instead. + * + * Renders nothing, so it costs no DOM on either side. + */ +function InitialFlash({ flash }: { flash?: Flash }) { + useEffect(() => { + if (initialFlashApplied) return; + initialFlashApplied = true; + applyFlash(flash); + }, [flash]); + + return null; +} + createInertiaApp({ resolve: async (name) => { const loader = pages[name]; @@ -53,9 +78,6 @@ createInertiaApp({ }); } - // Initial page load: the server embeds flash directly in initialPage props. - applyFlash(props.initialPage.props.flash as Flash | undefined); - // Client-initiated visits: `success` fires for every successful visit, // including same-URL POST → redirect flows (where `navigate` is skipped // because Inertia treats same-URL responses as history replace). @@ -63,7 +85,7 @@ createInertiaApp({ applyFlash(event.detail.page.props.flash as Flash | undefined); }); - createRoot(el).render( + const tree = ( {/* AppProviders lives INSIDE Inertia's because the providers it wraps consume page context (usePage). Using App's children @@ -80,9 +102,32 @@ createInertiaApp({ )} + ); + + // Match the mount to how the page was actually produced. + // + // For a `pages/ssr/` page the server sent real markup, which the browser + // has already parsed and painted; `hydrateRoot` adopts it, where + // `createRoot` clears the container and builds the whole tree again. It + // also surfaces divergence between the two renders, which the silent + // rebuild never did. + // + // A `pages/client/` page is deliberately absent from the SSR bundle and + // renders as a server-side no-op, so there is nothing to adopt. Hydrating + // one fails the match on every load and pushes React through its recovery + // path to reach the same result `createRoot` reaches directly. + // + // Both branches render the identical tree — only the mount differs. This + // is also why `ssr.tsx` renders ``: anything at the root on one + // side but not the other is a mismatch. + if (serverRenderedPages.has(props.initialPage.component)) { + hydrateRoot(el, tree); + } else { + createRoot(el).render(tree); + } }, http: { xsrfHeaderName: 'x-csrf-token', diff --git a/assets/js/ssr.tsx b/assets/js/ssr.tsx index ad842fe..ed5891f 100644 --- a/assets/js/ssr.tsx +++ b/assets/js/ssr.tsx @@ -6,6 +6,7 @@ import { createElement } from 'react'; import ReactDOMServer from 'react-dom/server'; import pages, { ssrClientOnly } from './_ssr_pages.ts'; import { AppProviders } from './app-providers'; +import Toaster from './components/Toaster'; import i18n from './i18n'; // Sentry for the SSR Node workers (errors only — no tracing). The DSN is @@ -48,12 +49,22 @@ export async function render(page: any) { // rendered inside AppProviders so context-dependent components // (Tooltip, etc.) work during SSR. Side-effect providers like // RealtimeProvider are safe — their useEffect doesn't run server-side. + // + // `Toaster` is here for hydration, not for output: it renders an empty + // Radix viewport element, and the client hydrates rather than rebuilds, + // so anything the client renders at the root must exist here too or + // React finds unexpected DOM and discards the subtree. Toasts are + // always client-side — the store is empty server-side, so this emits + // the viewport and nothing else. setup: ({ App, props }) => ( - - {({ Component, props: pageProps, key }) => ( - {createElement(Component, { key: key ?? undefined, ...pageProps })} - )} - + <> + + {({ Component, props: pageProps, key }) => ( + {createElement(Component, { key: key ?? undefined, ...pageProps })} + )} + + + ), }); }; diff --git a/config/runtime.exs b/config/runtime.exs index 315bb99..8fdd0ab 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -84,9 +84,31 @@ if config_env() == :prod do |> String.split(",", trim: true) |> Enum.map(&String.trim/1) + # CLIENT_IP_HEADERS overrides which header the caller's address is read + # from. Behind a CDN that terminates on a *public* address — Cloudflare + # being the common case — the default `x-forwarded-for` walk stops on the + # CDN's edge and mistakes it for the caller, because only private and + # reserved ranges are skipped automatically. Setting this to + # `cf-connecting-ip` reads the address Cloudflare resolved instead, with + # no chain to walk. + # Set-but-empty falls back to the default rather than parsing to `[]`: + # an empty header list is how the plug switches itself off, so honouring + # it here would silently disable client-IP resolution instead of + # configuring it. + client_ip_headers = + case "CLIENT_IP_HEADERS" + |> System.get_env("") + |> String.split(",", trim: true) + |> Enum.map(&(&1 |> String.trim() |> String.downcase())) + |> Enum.reject(&(&1 == "")) do + [] -> ["x-forwarded-for"] + headers -> headers + end + config :elixir_react_starter, trust_proxy_headers: System.get_env("TRUST_PROXY_HEADERS", "false") in ~w(true 1), - trusted_proxies: trusted_proxies + trusted_proxies: trusted_proxies, + client_ip_headers: client_ip_headers # The secret key base is used to sign/encrypt cookies and other secrets. # A default value is used in config/dev.exs and config/test.exs but you diff --git a/lib/elixir_react_starter_web/plugs/client_ip.ex b/lib/elixir_react_starter_web/plugs/client_ip.ex index ad650af..4a6d903 100644 --- a/lib/elixir_react_starter_web/plugs/client_ip.ex +++ b/lib/elixir_react_starter_web/plugs/client_ip.ex @@ -36,10 +36,17 @@ defmodule ElixirReactStarterWeb.Plugs.ClientIp do ranges only — e.g. Cloudflare's published egress blocks. * `:client_ip_headers` — headers to read, lowercase (default - `#{inspect(["x-forwarded-for"])}`). A single header is the safe choice: - with several enabled, a caller can add one the proxy doesn't overwrite - and interfere with parsing. Cloudflare deployments typically want - `["cf-connecting-ip"]` instead. + `#{inspect(["x-forwarded-for"])}`). Set from `CLIENT_IP_HEADERS`. A + single header is the safe choice: with several enabled, a caller can + add one the proxy doesn't overwrite and interfere with parsing. + + Behind Cloudflare, set this to `cf-connecting-ip`. Cloudflare's edge + terminates on a *public* address, so the default `x-forwarded-for` + walk stops there and reports the edge as the caller — every visitor + arriving through the same edge then shares one rate-limit bucket. + `cf-connecting-ip` holds the address Cloudflare already resolved, so + there is no chain to walk. Listing Cloudflare's published ranges in + `:trusted_proxies` also works, but that list has to be kept current. """ @behaviour Plug