Skip to content

Commit f8cbdd1

Browse files
committed
Cut SSR memory: ssr/client page convention, smaller pool, heap cap
Each Inertia SSR Node worker loads the entire server bundle into its own V8 heap, including authenticated, interactive pages that don't benefit from server rendering — wasteful on small instances. Three levers: - Split pages into pages/ssr/ (server-rendered: public/SEO) and pages/client/ (client-only: authed/interactive). The strategy dir is stripped from the Inertia page name, so controllers are unchanged. The generator now emits _pages.ts (lazy client manifest, code-splitting preserved) + _ssr_pages.ts (ssr-only, plus a client-only set that ssr.tsx renders as a server-side no-op). - SSR_POOL_SIZE env (default 2) sizes the Node worker pool. - NODE_OPTIONS=--max-old-space-size=160 in the Dockerfile caps each worker's V8 heap. Generate the registries before esbuild in assets.build/deploy (the client bundle imports _pages.ts). Exclude the generated registries from the biome formatter. Document the split in docs/frontend-pages.md, CLAUDE.md, and the README.
1 parent 552d3db commit f8cbdd1

19 files changed

Lines changed: 244 additions & 79 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ npm-debug.log
5656
# TypeScript incremental build info (e.g. from `tsc -p tsconfig.e2e.json`)
5757
*.tsbuildinfo
5858

59-
# Auto-generated SSR pages registry
59+
# Auto-generated page registries
6060
/assets/js/_ssr_pages.ts
61+
/assets/js/_pages.ts
6162

6263
# LSP
6364
.elixir_ls

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ These are the rules that must not be forgotten or looked up — they're the ones
2727

2828
**Inertia (this project's frontend)**
2929
- Use `render_inertia/2|3`, never `render/3`, for Inertia pages. Don't use LiveView for Inertia pages
30-
- Page components are **always** `.tsx` (TypeScript), in `assets/js/pages/`
30+
- Page components are **always** `.tsx` (TypeScript), under `assets/js/pages/` in one of two strategy dirs: **`pages/ssr/`** (server-rendered: public / SEO / first-paint pages) or **`pages/client/`** (client-only: authenticated, interactive, heavy pages — kept out of the Node SSR bundle). The strategy dir is stripped from the Inertia page name, so `render_inertia("Dashboard")` maps to `pages/client/Dashboard.tsx`. The `_pages.ts` / `_ssr_pages.ts` registries are auto-generated from these dirs by `build/generate-ssr-pages.js` — never edit them by hand. New public page → `ssr/`; new authed page → `client/`
3131
- Layout components live in `assets/js/layouts/` (e.g. `AppLayout`, `AuthLayout`)
3232
- Reusable UI primitives live flat in `assets/js/components/` (`Button`, `Spinner`, `Select`, `DropdownMenu`, `AlertDialog`, …) — no sub-folders
3333
- Forms use Inertia's `useForm` hook; errors come from `assign_errors(conn, changeset)` on the server
3434
- **No raw `try/catch` for async work — wrap every Promise in `go()` from `@api3/promise-utils`** (sync work uses `goSync`). It returns `{ success, data, error }`, which forces every call site to acknowledge the failure path explicitly and prevents the "swallow the error and move on" pattern that hides real bugs. The same applies to dynamic `import()`, `fetch()`, JSON parsing, and any vendor SDK call. The only acceptable exception is at top-level error boundaries that genuinely *do* need to catch everything synchronously
35-
- Never edit `assets/js/_ssr_pages.ts`it's auto-generated
35+
- Never edit `assets/js/_pages.ts` or `assets/js/_ssr_pages.ts`they're auto-generated
3636
- **Validation errors must redirect, not re-render.** On `{:error, changeset}` always use `conn |> assign_errors(changeset) |> redirect(to: ~p"/current-page")`. Do **not** use `put_status(:unprocessable_entity) |> render_inertia(...)` — Inertia updates the browser URL to the POST/PUT target when you re-render, which is both wrong UX and a regression risk. The Inertia plug flashes errors through the session across the redirect, so the form still shows them
3737
- Use the `~p"/..."` sigil (verified routes) for every internal URL — in controllers, tests, and anywhere else in Elixir. Never write raw route strings; compile-time verification catches typos and broken links
3838
- **JSON serialisation goes through `*_json.ex` view modules**, never ad-hoc serializer modules (no `*Props`, `*Serializer`, or per-controller helpers). One module per resource at `lib/elixir_react_starter_web/controllers/<resource>_json.ex` (e.g. `ElixirReactStarterWeb.PostJSON`) exposes `index/1` and `show/1` (the Phoenix 1.8 equivalents of `render_many`/`render_one`) that both delegate to a single `data/2`. Inertia callers use `MyJSON.data(record, viewer)` directly inside `assign_prop`; JSON API endpoints use `index`/`show` via `render`. This keeps the wire shape for a resource in one place so multiple callers can't drift

Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ ENV MIX_ENV="prod"
109109
# `bin/server` (from rel/overlays) sets PHX_SERVER=true and boots the
110110
# endpoint. PORT is read in runtime.exs; default matches EXPOSE.
111111
ENV PORT=4000
112+
# Cap each Inertia SSR Node worker's V8 heap. The BEAM spawns these node
113+
# processes (and the HEALTHCHECK below) and they inherit NODE_OPTIONS, so
114+
# this bounds the Node side of memory alongside SSR_POOL_SIZE. Override in
115+
# the deploy env if a heavier SSR page needs more headroom.
116+
ENV NODE_OPTIONS="--max-old-space-size=160"
112117
EXPOSE 4000
113118

114119
COPY --from=builder --chown=app:app /app/_build/prod/rel/elixir_react_starter ./

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ mix docs
9595

9696
In development they're also served at
9797
[`/dev/docs`](http://localhost:4000/dev/docs/index.html). Topic guides live in
98-
`docs/` — e.g. the [End-to-End Testing guide](docs/e2e-testing.md).
98+
`docs/` — e.g. the [End-to-End Testing guide](docs/e2e-testing.md) and
99+
[Frontend Pages: SSR vs. Client](docs/frontend-pages.md).
99100

100101
## Deployment
101102

@@ -118,8 +119,12 @@ docker run -p 4000:4000 \
118119
`config/runtime.exs` requires `DATABASE_URL`, `MAILJET_API_KEY`, `MAILJET_SECRET`,
119120
and `SECRET_KEY_BASE` and will refuse to boot without them. `PHX_HOST` defaults to
120121
`example.com` but should always be set in production (it's used for absolute URLs
121-
in emails). `PORT` (default `4000`), `POOL_SIZE`, `ECTO_IPV6`, and
122-
`DNS_CLUSTER_QUERY` are optional.
122+
in emails). `PORT` (default `4000`), `POOL_SIZE`, `ECTO_IPV6`,
123+
`DNS_CLUSTER_QUERY`, `SSR_POOL_SIZE` (number of Inertia SSR Node workers,
124+
default `2`), and `NODE_OPTIONS` (set in the Dockerfile to
125+
`--max-old-space-size=160`, caps each SSR worker's V8 heap) are optional. The
126+
last two are the main levers on the Node-side memory the SSR pool uses — see
127+
[Frontend Pages: SSR vs. Client](docs/frontend-pages.md).
123128

124129
Database migrations run via the release: `bin/migrate`. See Phoenix's
125130
[deployment guides](https://hexdocs.pm/phoenix/deployment.html) for hosting specifics.

assets/biome.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
"!**/pnpm-lock.yaml",
1313
"!**/.docusaurus/**",
1414
"!**/build/",
15-
"!**/server/static/**"
15+
"!**/server/static/**",
16+
"!js/_pages.ts",
17+
"!js/_ssr_pages.ts"
1618
],
1719
"indentStyle": "space",
1820
"indentWidth": 2,

assets/build/generate-ssr-pages.js

Lines changed: 86 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
1-
// Scans assets/js/pages/ and generates _ssr_pages.ts with static imports.
2-
// This is required because the SSR esbuild bundle uses --format=cjs which
3-
// doesn't support --splitting or dynamic imports.
1+
// Scans assets/js/pages/{ssr,client}/ and generates two page registries:
2+
//
3+
// * _pages.ts — client manifest: EVERY page as a lazy `() => import()`
4+
// loader, keyed by Inertia page name. Lazy so esbuild
5+
// code-splits one chunk per page. Used by app.tsx.
6+
// * _ssr_pages.ts — SSR manifest: static imports for `ssr/` pages only,
7+
// plus the set of client-only page names. Used by
8+
// ssr.tsx, whose CJS bundle can't do dynamic import.
9+
//
10+
// The strategy directory (`ssr/` or `client/`) decides whether a page is
11+
// server-rendered; it is stripped from the page name, so controllers keep
12+
// calling `render_inertia("Dashboard")` regardless of where the file lives.
413

514
const fs = require('node:fs');
615
const path = require('node:path');
716

817
const PAGES_DIR = path.join(__dirname, '..', 'js', 'pages');
9-
const OUTPUT = path.join(__dirname, '..', 'js', '_ssr_pages.ts');
18+
const CLIENT_OUTPUT = path.join(__dirname, '..', 'js', '_pages.ts');
19+
const SSR_OUTPUT = path.join(__dirname, '..', 'js', '_ssr_pages.ts');
20+
21+
const STRATEGIES = ['ssr', 'client'];
1022

1123
function findPages(dir, base = '') {
1224
if (!fs.existsSync(dir)) return [];
@@ -24,50 +36,91 @@ function findPages(dir, base = '') {
2436
}
2537
}
2638

27-
return pages.sort();
39+
return pages;
40+
}
41+
42+
// Collect every page across both strategy directories.
43+
const all = [];
44+
for (const strategy of STRATEGIES) {
45+
for (const rel of findPages(path.join(PAGES_DIR, strategy))) {
46+
all.push({
47+
strategy,
48+
name: rel.replace(/\.tsx$/, ''),
49+
importPath: `./pages/${strategy}/${rel}`,
50+
});
51+
}
52+
}
53+
54+
// Only quote keys that aren't valid JS identifiers (e.g. contain '/').
55+
const keyFor = (name) => (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `'${name}'`);
56+
57+
function writeIfChanged(file, content, label) {
58+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
59+
if (existing !== content) {
60+
fs.writeFileSync(file, content);
61+
console.log(label);
62+
}
2863
}
2964

30-
const pages = findPages(PAGES_DIR);
65+
// --- client manifest (_pages.ts) ----------------------------------------
66+
const clientEntries = [...all]
67+
.sort((a, b) => a.name.localeCompare(b.name))
68+
.map((p) => ` ${keyFor(p.name)}: () => import('${p.importPath}'),`)
69+
.join('\n');
70+
71+
const clientContent = `// Auto-generated by build/generate-ssr-pages.js — do not edit manually
3172
32-
// Sort by full import path matching Biome's organizeImports order.
33-
// Biome sorts './pages/Settings/X' before './pages/Settings.tsx',
34-
// so we replace '.' in extensions with a high-sorting char for comparison.
35-
const sortedByImportPath = pages
36-
.map((p) => ({ page: p, importPath: `./pages/${p}` }))
73+
// Lazy client page loaders keyed by Inertia page name (the ssr/ or client/
74+
// strategy directory is stripped). Lazy import() so esbuild code-splits one
75+
// chunk per page.
76+
// biome-ignore lint/suspicious/noExplicitAny: Inertia supplies props at render time, not via a React parent.
77+
const pages: Record<string, () => Promise<{ default: React.ComponentType<any> }>> = {
78+
${clientEntries}
79+
};
80+
81+
export default pages;
82+
`;
83+
84+
// --- SSR manifest (_ssr_pages.ts) ---------------------------------------
85+
// Sort static imports by full import path to match Biome's import order
86+
// (Biome sorts './pages/Settings/X' before './pages/Settings.tsx', so swap
87+
// the extension for a high-sorting char when comparing).
88+
const ssrSorted = all
89+
.filter((p) => p.strategy === 'ssr')
3790
.sort((a, b) => {
3891
const keyA = a.importPath.replace(/\.tsx$/, '\uffff');
3992
const keyB = b.importPath.replace(/\.tsx$/, '\uffff');
4093
return keyA.localeCompare(keyB);
4194
});
4295

43-
const imports = sortedByImportPath.map((item, i) => `import Page${i} from '${item.importPath}';`).join('\n');
96+
const ssrImports = ssrSorted.map((p, i) => `import Page${i} from '${p.importPath}';`).join('\n');
97+
const ssrEntries = ssrSorted.map((p, i) => ` ${keyFor(p.name)}: Page${i},`).join('\n');
4498

45-
const entries = sortedByImportPath
46-
.map((item, i) => {
47-
const key = item.page.replace(/\.tsx$/, '');
48-
// Only quote keys that aren't valid JS identifiers (e.g. contain '/')
49-
const formattedKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `'${key}'`;
50-
return ` ${formattedKey}: Page${i},`;
51-
})
52-
.join('\n');
99+
const clientOnly = all
100+
.filter((p) => p.strategy === 'client')
101+
.map((p) => p.name)
102+
.sort();
103+
104+
const clientOnlySet = clientOnly.length
105+
? `new Set<string>([\n${clientOnly.map((n) => ` '${n}',`).join('\n')}\n])`
106+
: 'new Set<string>()';
53107

54-
const content = `// Auto-generated by build/generate-ssr-pages.js — do not edit manually
55-
${imports}
108+
const ssrContent = `// Auto-generated by build/generate-ssr-pages.js — do not edit manually
109+
${ssrImports}
56110
57-
// Inertia injects props from the page protocol at render time, so components
58-
// in this map don't have props supplied by a React parent. ComponentType<any>
59-
// reflects that contract — pages with required props still type-check.
60-
// biome-ignore lint/suspicious/noExplicitAny: see comment above
111+
// Server-rendered pages (those under pages/ssr/), keyed by Inertia page name.
112+
// biome-ignore lint/suspicious/noExplicitAny: Inertia supplies props at render time, not via a React parent.
61113
const pages: Record<string, React.ComponentType<any>> = {
62-
${entries}
114+
${ssrEntries}
63115
};
64116
117+
// Pages that exist but are intentionally client-only (pages/client/). ssr.tsx
118+
// renders these as a server-side no-op so the client takes over, instead of
119+
// failing — while a genuinely unknown name still throws.
120+
export const ssrClientOnly = ${clientOnlySet};
121+
65122
export default pages;
66123
`;
67124

68-
const existing = fs.existsSync(OUTPUT) ? fs.readFileSync(OUTPUT, 'utf8') : '';
69-
70-
if (existing !== content) {
71-
fs.writeFileSync(OUTPUT, content);
72-
console.log(`SSR pages registry updated (${pages.length} pages)`);
73-
}
125+
writeIfChanged(CLIENT_OUTPUT, clientContent, `Client pages registry updated (${all.length} pages)`);
126+
writeIfChanged(SSR_OUTPUT, ssrContent, `SSR pages registry updated (${ssrSorted.length} pages)`);

assets/js/app.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { go } from '@api3/promise-utils';
33
import { createInertiaApp, router } from '@inertiajs/react';
44
import { createElement, StrictMode } from 'react';
55
import { createRoot } from 'react-dom/client';
6+
import pages from './_pages';
67
import { AppProviders } from './app-providers';
78
import ErrorBoundary from './components/ErrorBoundary';
89
import { syncLocale } from './components/LocaleSync';
@@ -23,12 +24,19 @@ function applyFlash(flash?: Flash) {
2324

2425
createInertiaApp({
2526
resolve: async (name) => {
26-
const result = await go(() => import(`./pages/${name}.tsx`));
27+
const loader = pages[name];
28+
if (!loader) {
29+
const error = new Error(`Page not found: ${name}`);
30+
console.error(error);
31+
throw error;
32+
}
33+
34+
const result = await go(loader);
2735
if (!result.success) {
2836
console.error(`Failed to load page "${name}":`, result.error);
2937
throw result.error;
3038
}
31-
return result.data;
39+
return result.data.default;
3240
},
3341
setup({ App, el, props }) {
3442
syncLocale(props.initialPage.props);
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { usePage } from '@inertiajs/react';
22
import { useTranslation } from 'react-i18next';
3-
import AppLayout from '../layouts/AppLayout';
4-
import type { CurrentUser } from '../types';
3+
import AppLayout from '../../layouts/AppLayout';
4+
import type { CurrentUser } from '../../types';
55

66
export default function Dashboard() {
77
const { current_user } = usePage<{ current_user: CurrentUser }>().props;
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import {
99
AlertDialogFooter,
1010
AlertDialogTitle,
1111
AlertDialogTrigger,
12-
} from '../components/AlertDialog';
13-
import Button from '../components/Button';
14-
import { inputClass } from '../components/ui';
15-
import AppLayout from '../layouts/AppLayout';
12+
} from '../../components/AlertDialog';
13+
import Button from '../../components/Button';
14+
import { inputClass } from '../../components/ui';
15+
import AppLayout from '../../layouts/AppLayout';
1616

1717
export default function Settings() {
1818
const { t } = useTranslation();

assets/js/pages/Auth/ForgotPassword.tsx renamed to assets/js/pages/ssr/Auth/ForgotPassword.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { useForm } from '@inertiajs/react';
22
import { useTranslation } from 'react-i18next';
3-
import Button from '../../components/Button';
4-
import Link from '../../components/Link';
5-
import { inputClass } from '../../components/ui';
6-
import AuthLayout from '../../layouts/AuthLayout';
3+
import Button from '../../../components/Button';
4+
import Link from '../../../components/Link';
5+
import { inputClass } from '../../../components/ui';
6+
import AuthLayout from '../../../layouts/AuthLayout';
77

88
export default function ForgotPassword() {
99
const { t } = useTranslation();

0 commit comments

Comments
 (0)