Repository navigation
build: generate icons from a Vite plugin and run the server as TypeScript - #13
Conversation
other/build-icons.ts was 153 lines of hand-rolled sprite generation that shelled out to prettier after every write and had to be run by hand (via `predev` and a CI step in all three jobs) before anything else could work. vite-plugin-icons-spritesheet does the same job inside the Vite pipeline, which also means the sprite and its types are regenerated when a file in other/svg-icons is added, changed or deleted, instead of on the next manual script run. - vite-plugin-icons-spritesheet is configured before reactRouter() in vite.config.ts, with iconNameTransformer set to identity so the existing kebab-case <Icon name="..."> call sites keep working, and typesOutputFile pointed at app/components/ui/icons/name.d.ts so the `@/icon-name` alias in tsconfig.json still resolves to a real file. - other/build-icons.ts, the `build:icons` and `predev` scripts, the sly.json postinstall hook, and the three CI "Build icons" steps are gone. `react-router typegen` instantiates the Vite config, so the generated files exist before tsc runs — verified from a clean `rm -rf app/components/ui/icons`. - The spritesheet keeps being emitted as its own immutable-cached asset rather than inlined: the plugin returns false for it, and app/root.tsx preloads it. The now-dead `favicon.svg` case is dropped from assetsInlineLimit (this app has favicon.png). Refs #8
other/build-server.ts, server/dev-server.js and the whole server-build/ directory existed to hand Node a compiled copy of server/*.ts. Node strips the types off them itself, so the build step, its esbuild config and the process that chose between the compiled and the source copy are all gone. - index.js becomes index.ts and unconditionally imports ./server/index.ts. The `dotenv` and source-map-support preamble is unchanged. - The React Router request handler moves to server/app.ts, which is now the entry of the SSR build, so `vite build` bundles it into build/server/index.js and server/index.ts mounts the app from there. That also deletes the hand-rolled getBuild() and the build closure that existed only to swallow import errors. - server/app.ts has to be set as `environments.ssr.build.rollupOptions.input` rather than the top-level `build.rollupOptions.input` the upstream config uses: @react-router/dev replaces the top-level one with its virtual server build in its own config hook, and `config.isSsrBuild` is not set for the SSR environment build at all, so the conditional silently produced `undefined`. Both verified against a build. - It is loaded through Vite in development, which is what lets `virtual:react-router/server-build` resolve. React Router's `development` export condition then gives Vite and Node two different builds of the package, so `@react-router/express` is noExternal for the ssr environment: the handler has to share a module graph with the RouterContextProvider that server/app.ts hands it, or every request 500s on an `instanceof` check. - Relative imports in the server graph now carry their real extension, because Node's type stripping does not remap `.js` to `.ts`. - `dev` runs `tsx watch` over index.ts rather than dropping to `node`, so editing server/ still restarts the process while app/ edits stay on Vite's HMR. `build` collapses to `react-router build` now that it has one step. - Both server-spawning tests start index.ts instead of the esbuild bundle. - esbuild, execa, fs-extra, glob, node-html-parser and @types/fs-extra lose their last consumer and are pruned, along with the server-build entries in .gitignore, .prettierignore, package.json's eslintIgnore, the Dockerfile and Sentry's filesToDeleteAfterUpload. Refs #8
The spritesheet plugin writes `export const iconNames = [...] as const`, and a const initializer in an ambient context is an error (TS1254) that only skipLibCheck hides, so the union of icon names was being read out of a malformed declaration file. It happened to infer the right type, so nothing noticed. Point typesOutputFile at name.ts and follow it in the `@/icon-name` path mapping. Refs #8
- tsconfig sets `erasableSyntaxOnly`. Node now runs the server graph directly, so tsc has to reject what Node's stripper refuses to load — enums, namespaces, parameter properties. `tsx` compiles them happily in development, so nothing caught it. `verbatimModuleSyntax`, the other setting Node's docs suggest, is deliberately not set: it would keep the type-only `@/icon-name` import in app/components/ui/icon.tsx from being elided, and Vite cannot resolve that tsconfig paths alias. - eslint ignores the generated icon directory. It is gitignored, so CI never sees it, and a leftover name.d.ts from the deleted script made `npm run lint` fail on every existing checkout with "not found by the project service" — tsc drops a .d.ts that has a .ts beside it. - The `assetsInlineLimit` comment claimed the icon directory was not gitignored from prettier. It is, which is also why the plugin's `formatter` option bought nothing: prettier never reads those files. The option goes, taking two subprocess spawns per regeneration and the plugin's format-the-sprite-as-HTML quirk with it. `sprite.svg` goes back into the predicate, because the plugin matches the spritesheet on a path built from cwd and its own issues report that match silently failing. - server/app.ts is the single home for the app.ts/build relationship; server/index.ts points at it and now says the React Router build lands in a hashed sibling chunk, so the whole build/server tree has to travel together. - `dev` sets FORCE_COLOR again. server/dev-server.js passed it to the child and the styleText banner lost its colour in a piped log when that went. - `@sentry/node` is declared. server/index.ts and server/utils/monitoring.ts import it directly, and it only resolved because `@sentry/profiling-node` hoists it into the root of node_modules. - The build-artefact guard in tests/allow-indexing.test.ts is SERVER_BUILD rather than REACT_ROUTER_BUILD; it is the server/app.ts entry now. Refs #8
…sconfig note Follow-up to the review fixes: the sprite comment credited the plugin's exclusion to cwd when it is a string match on the asset path, @sentry/node sat out of alphabetical order, and the erasableSyntaxOnly note implied the flag was scoped to the server graph when it is repo-wide. Refs #8
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (20)
💤 Files with no reviewable changes (8)
Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe change replaces the custom icon and server build scripts. Vite generates icon assets and builds the server app. Node runs the TypeScript server entry point, with separate development and production app-loading paths. ChangesBuild and server migration
Priority: ➖ Normal Estimated code review effort: 4 (Complex) | ~45 minutes Change: Refactor Sequence Diagram(s)sequenceDiagram
participant Node as index.ts
participant Server as server/index.ts
participant Vite
participant App as server/app.ts
participant Router as React Router request handler
participant BuiltApp as build/server/index.js
Node->>Server: start server
Server->>Vite: development request loads server/app.ts
Vite-->>Server: return app
Server->>App: forward request
App->>Router: handle request
Server->>BuiltApp: production imports built app
Merge Risk: ⚪ Minimal · up to No actionable merge-blocking issue was established; the change is mergeable after normal checks. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The server migration warrants design review, but the inspected request path keeps its existing security controls ahead of the application handler. No new security bypass was established; production-build behavior remains to be verified. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 3 | ❌ 1 | ❓ 1❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
Full details: Linked Issues checkExplanation The visible changes address the coding requirements in [ Full details: Docstring CoverageExplanation Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 9 files. (3 skipped: 3 unsupported.)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Closes #8. One PR for both tasks, as agreed — they touch the same three files and splitting them would have left
build:iconsandesbuildhalf-wired.Net: -1082 / +284 lines, and four scripts, one CI step × 3 jobs, and a build-output directory gone.
Task 1 — the icon sprite is a Vite plugin now
other/build-icons.ts(153 lines) shelled out to prettier after every write and had to be run by hand before anything else could work.vite-plugin-icons-spritesheetdoes the same job inside the Vite pipeline, which also means the sprite regenerates when a file inother/svg-iconsis added, changed or deleted — the old script did not.Gone:
other/build-icons.ts, thebuild:iconsandpredevscripts, thesly.jsonpostinstall hook, and the🖼 Build iconsstep in all three CI jobs.The
react-router typegenstep thattypecheckruns does instantiate the Vite config, so the generated files exist beforetscdoes — verified from a cleanrm -rf app/components/ui/icons, which is what let the CI steps go.The sprite stays a separate request, with the preload. The issue's premise here was out of date:
assetsInlineLimitreturningfalsemeans don't inline, so the sprite was already one immutable-cached ~5 KB request thatapp/root.tsxpreloads. That is the better of the two options the issue weighs (inlining would put ~7 KB of base64 in every document and make the preload dead weight), so nothing about the delivery changed. The only edit is dropping thefavicon.svgbranch, which has been dead since the favicon became a.png.Task 2 — the server runs as TypeScript
other/build-server.tsandserver/dev-server.jsexisted to hand Node a compiled copy ofserver/*.tsand to choose between it and the source. Node strips the types itself, so both are gone, along withserver-build/,index.js(nowindex.ts), andbuild:server.index.tsunconditionally imports./server/index.ts.server/app.ts, which is now the entry of the SSR build.vite buildbundles it intobuild/server/index.js, andserver/index.tsmounts it from there. One handler definition, two loading strategies: through Vite in development (which is what letsvirtual:react-router/server-buildresolve), from the build in production. That also deleted the hand-rolledgetBuild()and thebuild:closure that existed only to swallow import errors..jsto.ts.⚖️ The decision you will want to weigh:
devstill auto-restartsserver/dev-server.jswrapped the process intsx watch, so editingserver/restarted it. Upstream dropped that andnode index.tsleaves you to Ctrl-C. This PR keeps the watcher, per the issue's own recommendation:app/stays on Vite's HMR,server/restarts,tsxis a devDependency so it costs nothing in the shipped image, and the--inspectflag andFORCE_COLORthat the deleted file passed to its child are both preserved.Two things in the issue that do not work as written
Both were flagged as risks in the issue, and both are documented in the code:
build.rollupOptions.inputat the top level does nothing.@react-router/devreplaces it with its virtual server build in its ownconfighook — the SSR build came out as the plainServerBuildwith noappexport. The override has to go inenvironments.ssr.build.rollupOptions.input, which is the key the plugin actually reads.config.isSsrBuildis not set for the SSR environment build. With the Environment API the user config is resolved once for the whole build, so upstream'sconfig.isSsrBuild ? './server/app.ts' : undefinedevaluates toundefinedand silently does nothing. The input is therefore unconditional.One non-obvious consequence:
noExternal: ['@react-router/express']In development,
server/app.tsis loaded by Vite, and React Router'sdevelopmentexport condition gives Vite and Node two different builds of the package.@react-router/expressis external by default, so it got Node's copy while theRouterContextProvidercame from Vite's — and the handler'sinstanceofcheck failed on every request:@react-router/expressis therefore bundled into the SSR graph instead of externalized, so the handler and the provider share one module instance. This is specific to React Router 8, which requiresgetLoadContextto return aRouterContextProvider; upstream is on 7.16, has no such split, and carries nonoExternal.Smaller things
tsconfigsetserasableSyntaxOnly: Node now runs the server graph directly, andtsxcompiles anenumhappily in development, so nothing would have caught one landing inserver/and taking production down at boot.verbatimModuleSyntax, the other setting Node's docs suggest, is deliberately not set — it would stop the type-only@/icon-nameimport from being elided and Vite cannot resolve that tsconfig paths alias. Both are commented.name.ts, notname.d.ts. The plugin writesexport const iconNames = [...] as const, and aconstinitializer in an ambient context is an error (TS1254) that onlyskipLibCheckwas hiding.app/components/ui/icons/. It is gitignored, so CI never sees it — and a leftovername.d.tsfrom the old script madenpm run lintfail on every existing checkout with "not found by the project service", becausetscdrops a.d.tsthat has a.tsbeside it.formatter: 'prettier'option is not set, against the issue's explicit instruction. Prettier 3 ignores.gitignored paths by default and that directory is gitignored, soformat:checknever reads those files — the option was dead config that cost two subprocess spawns per build. Proven: a deliberately misformatted file inother/is reported, the same file in the icon directory is not.@sentry/nodeis declared independencies. It is imported directly by two server files and only resolved because@sentry/profiling-nodehoists it.buildcollapses toreact-router buildnow that it has one step, and the stale/server-buildentries are out of.gitignore,.prettierignore,eslintIgnore, the Dockerfile and Sentry'sfilesToDeleteAfterUpload.esbuild,execa(a runtime dep that only the dev wrapper used),fs-extra,glob,node-html-parser,@types/fs-extra.How this was verified
npm run validaterm -rf build .react-router app/components/ui/iconsnpm run buildbuild/server/index.jsexportsapp, route chunks intact inbuild/client/assetsnpm run test:runnpm start/,/talks,/uses,/contact,/resources/healthcheckall 200;fly-region/fly-app, the per-request CSP nonce andServer-Timingall still presentnpm run dev/robots.txtand/sitemap.xml; anapp/edit is served without a restart, aserver/edit restarts the process<use href>on/,/talks,/uses,/contactresolves; sprite served200 image/svg+xmlwithimmutableRUN --mount=type=secret, so it needs BuildKit, and this machine has nodocker buildx. Left to the deploy job, which builds the image on Fly. As a substitute I reproduced the final image layout locally: the.dockerignore-respecting context,npm install --omit=dev,build/copied in, no dev deps and noserver-build—npm startserved 200s on/,/talks,/uses,/contactand the healthcheckKnown trade-offs, deliberately
npm starton an unbuilt tree now fails at boot instead of logging an error per request.getBuild()'s try/catch is gone because the issue called for it, and fail-fast is the better behaviour — but it presents on Fly as a crashloop withCannot find module '../build/server/index.js'rather than a 5xx. Both server tests keep an explicitexistsSyncguard with a message namingnpm run build.build/server/index.jsis a 3.4 kB entry that lazily imports the React Router build from a hashed sibling chunk inbuild/server/assets/. Anything that copies, warms or uploads the server build has to take the wholebuild/servertree. That is called out inserver/index.ts; the issue expected one inlined file.npm run buildfirst —tests/talk-card.test.tsximports a component that imports the sprite, and vitest never runs the plugin'sbuildStart. Unchanged frommain(the CI step that used to generate it is nownpm run build), but worth stating.Summary by CodeRabbit