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
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions assets/build/generate-ssr-pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([\n${serverRendered.map((n) => ` '${n}',`).join('\n')}\n])`
: 'new Set<string>()';

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/
Expand All @@ -78,6 +87,12 @@ const pages: Record<string, () => Promise<{ default: React.ComponentType<any> }>
${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;
`;

Expand Down
59 changes: 52 additions & 7 deletions assets/js/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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];
Expand Down Expand Up @@ -53,17 +78,14 @@ 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).
router.on('success', (event) => {
applyFlash(event.detail.page.props.flash as Flash | undefined);
});

createRoot(el).render(
const tree = (
<StrictMode>
{/* AppProviders lives INSIDE Inertia's <App> because the providers
it wraps consume page context (usePage). Using App's children
Expand All @@ -80,9 +102,32 @@ createInertiaApp({
</AppProviders>
)}
</App>
<InitialFlash flash={props.initialPage.props.flash as Flash | undefined} />
<Toaster />
</StrictMode>
);

// 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 `<Toaster />`: 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',
Expand Down
21 changes: 16 additions & 5 deletions assets/js/ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }) => (
<App {...props}>
{({ Component, props: pageProps, key }) => (
<AppProviders>{createElement(Component, { key: key ?? undefined, ...pageProps })}</AppProviders>
)}
</App>
<>
<App {...props}>
{({ Component, props: pageProps, key }) => (
<AppProviders>{createElement(Component, { key: key ?? undefined, ...pageProps })}</AppProviders>
)}
</App>
<Toaster />
</>
),
});
};
Expand Down
24 changes: 23 additions & 1 deletion config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions lib/elixir_react_starter_web/plugs/client_ip.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down