From c71c8b14b7e44846f1f35653bd97b16bdc56555d Mon Sep 17 00:00:00 2001 From: Arpit Dalal Date: Sun, 27 Sep 2026 11:45:51 -0400 Subject: [PATCH 1/5] build(icons): generate the sprite from a Vite plugin instead of a script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- .github/workflows/deploy.yml | 9 --- other/build-icons.ts | 153 ----------------------------------- other/sly/sly.json | 1 - other/svg-icons/README.md | 8 +- package-lock.json | 119 +++++++++++++++++++++++++++ package.json | 3 +- types/icon-name.d.ts | 4 +- vite.config.ts | 33 ++++++-- 8 files changed, 153 insertions(+), 177 deletions(-) delete mode 100644 other/build-icons.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1d1cb6c..fd93ceb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,9 +30,6 @@ jobs: - name: 📥 Download deps uses: bahmutov/npm-install@v1 - - name: 🖼 Build icons - run: npm run build:icons - - name: 🔬 Lint run: npm run lint @@ -54,9 +51,6 @@ jobs: - name: 📥 Download deps uses: bahmutov/npm-install@v1 - - name: 🖼 Build icons - run: npm run build:icons - - name: 🔎 Type check run: npm run typecheck --if-present @@ -75,9 +69,6 @@ jobs: - name: 📥 Download deps uses: bahmutov/npm-install@v1 - - name: 🖼 Build icons - run: npm run build:icons - - name: 🏗 Build run: npm run build diff --git a/other/build-icons.ts b/other/build-icons.ts deleted file mode 100644 index ccaaf1a..0000000 --- a/other/build-icons.ts +++ /dev/null @@ -1,153 +0,0 @@ -import * as path from 'node:path' -import { $ } from 'execa' -import fsExtra from 'fs-extra' -import { glob } from 'glob' -import { parse } from 'node-html-parser' - -const cwd = process.cwd() -const inputDir = path.join(cwd, 'other', 'svg-icons') -const inputDirRelative = path.relative(cwd, inputDir) -const outputDir = path.join(cwd, 'app', 'components', 'ui', 'icons') -await fsExtra.ensureDir(outputDir) - -const files = glob - .sync('**/*.svg', { - cwd: inputDir, - }) - .sort((a, b) => a.localeCompare(b)) - -const shouldVerboseLog = process.argv.includes('--log=verbose') -const logVerbose = shouldVerboseLog ? console.log : () => {} - -if (files.length === 0) { - console.log(`No SVG files found in ${inputDirRelative}`) -} else { - await generateIconFiles() -} - -async function generateIconFiles() { - const spriteFilepath = path.join(outputDir, 'sprite.svg') - const typeOutputFilepath = path.join(outputDir, 'name.d.ts') - const currentSprite = await fsExtra - .readFile(spriteFilepath, 'utf8') - .catch(() => '') - const currentTypes = await fsExtra - .readFile(typeOutputFilepath, 'utf8') - .catch(() => '') - - const iconNames = files.map((file) => iconName(file)) - - const spriteUpToDate = iconNames.every((name) => - currentSprite.includes(`id=${name}`), - ) - const typesUpToDate = iconNames.every((name) => - currentTypes.includes(`"${name}"`), - ) - - if (spriteUpToDate && typesUpToDate) { - logVerbose(`Icons are up to date`) - return - } - - logVerbose(`Generating sprite for ${inputDirRelative}`) - - const spriteChanged = await generateSvgSprite({ - files, - inputDir, - outputPath: spriteFilepath, - }) - - for (const file of files) { - logVerbose('✅', file) - } - logVerbose(`Saved to ${path.relative(cwd, spriteFilepath)}`) - - const stringifiedIconNames = iconNames.map((name) => JSON.stringify(name)) - - const typeOutputContent = `// This file is generated by npm run build:icons - -export type IconName = -\t| ${stringifiedIconNames.join('\n\t| ')}; -` - const typesChanged = await writeIfChanged( - typeOutputFilepath, - typeOutputContent, - ) - - logVerbose(`Manifest saved to ${path.relative(cwd, typeOutputFilepath)}`) - - const readmeChanged = await writeIfChanged( - path.join(outputDir, 'README.md'), - `# Icons - -This directory contains SVG icons that are used by the app. - -Everything in this directory is generated by \`npm run build:icons\`. -`, - ) - - if (spriteChanged || typesChanged || readmeChanged) { - console.log(`Generated ${files.length} icons`) - } -} - -function iconName(file: string) { - return file.replace(/\.svg$/, '') -} - -/** - * Creates a single SVG file that contains all the icons - */ -async function generateSvgSprite({ - files, - inputDir, - outputPath, -}: { - files: string[] - inputDir: string - outputPath: string -}) { - // Each SVG becomes a symbol and we wrap them all in a single SVG - const symbols = await Promise.all( - files.map(async (file) => { - const input = await fsExtra.readFile(path.join(inputDir, file), 'utf8') - const root = parse(input) - - const svg = root.querySelector('svg') - if (!svg) throw new Error('No SVG element found') - - svg.tagName = 'symbol' - svg.setAttribute('id', iconName(file)) - svg.removeAttribute('xmlns') - svg.removeAttribute('xmlns:xlink') - svg.removeAttribute('version') - svg.removeAttribute('width') - svg.removeAttribute('height') - - return svg.toString().trim() - }), - ) - - const output = [ - ``, - ``, - ``, - ``, // for semantics: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs - ...symbols, - ``, - ``, - '', // trailing newline - ].join('\n') - - return writeIfChanged(outputPath, output) -} - -async function writeIfChanged(filepath: string, newContent: string) { - const currentContent = await fsExtra - .readFile(filepath, 'utf8') - .catch(() => '') - if (currentContent === newContent) return false - await fsExtra.writeFile(filepath, newContent, 'utf8') - await $`prettier --write ${filepath} --ignore-unknown` - return true -} diff --git a/other/sly/sly.json b/other/sly/sly.json index af7a28c..9e8f9da 100644 --- a/other/sly/sly.json +++ b/other/sly/sly.json @@ -4,7 +4,6 @@ { "name": "tabler-icons", "directory": "./other/svg-icons", - "postinstall": ["npm", "run", "build:icons"], "transformers": ["transform-icon.ts"] } ] diff --git a/other/svg-icons/README.md b/other/svg-icons/README.md index a9be9dd..b6a2829 100644 --- a/other/svg-icons/README.md +++ b/other/svg-icons/README.md @@ -5,7 +5,9 @@ under MIT: https://github.com/radix-ui/icons/blob/master/LICENSE It's important that you only add icons to this directory that the application actually needs as there's no "tree-shaking" for sprites. If you wish to manually -split up your SVG sprite into multiple files, you'll need to update the -`build-icons.ts` script to do that. +split up your SVG sprite into multiple files, you'll need to pass a second +config to `iconsSpritesheet` in `vite.config.ts` to do that. -Run `npm run build:icons` to update the sprite. +The sprite and the icon name types are regenerated by the Vite plugin on every +build, on `npm run dev`, and whenever a file in this directory is added, changed +or deleted. There is no separate script to run. diff --git a/package-lock.json b/package-lock.json index 409bc22..a117c5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,6 +97,7 @@ "typescript": "^6.0.3", "vite": "^7.3.6", "vite-env-only": "^3.0.3", + "vite-plugin-icons-spritesheet": "^3.1.0", "vitest": "^5.0.2" }, "engines": { @@ -5441,6 +5442,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -8369,6 +8371,32 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jackspeak/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -12883,6 +12911,97 @@ "vite": "*" } }, + "node_modules/vite-plugin-icons-spritesheet": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-icons-spritesheet/-/vite-plugin-icons-spritesheet-3.1.0.tgz", + "integrity": "sha512-fsdLbInegCg2PM4SoTupTe/dTbCDfCsB2HY3AyL63oXx/JYzWYW7nII2JyLWTrREvVN+PmwYIVL6/GwNQLQUBg==", + "dev": true, + "license": "MIT", + "workspaces": [ + ".", + "test-apps/*" + ], + "dependencies": { + "chalk": "^5.4.1", + "glob": "^11.0.1", + "node-html-parser": "^7.0.1", + "tinyexec": "^0.3.2" + }, + "peerDependencies": { + "vite": ">=5.2.0" + } + }, + "node_modules/vite-plugin-icons-spritesheet/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/vite-plugin-icons-spritesheet/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/vite-plugin-icons-spritesheet/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/vite-plugin-icons-spritesheet/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/vite-plugin-icons-spritesheet/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", diff --git a/package.json b/package.json index 0b12708..8b108a1 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,8 @@ }, "scripts": { "build": "run-s build:*", - "build:icons": "tsx ./other/build-icons.ts", "build:react-router": "react-router build", "build:server": "tsx ./other/build-server.ts", - "predev": "npm run build:icons --silent", "dev": "cross-env NODE_ENV=development node ./server/dev-server.js", "format": "prettier --write .", "format:check": "prettier --check .", @@ -135,6 +133,7 @@ "typescript": "^6.0.3", "vite": "^7.3.6", "vite-env-only": "^3.0.3", + "vite-plugin-icons-spritesheet": "^3.1.0", "vitest": "^5.0.2" }, "engines": { diff --git a/types/icon-name.d.ts b/types/icon-name.d.ts index 6f47700..7d7441b 100644 --- a/types/icon-name.d.ts +++ b/types/icon-name.d.ts @@ -1,3 +1,5 @@ -// This file is a fallback until you run npm run build:icons +// This file is a fallback for typecheck runs that have not loaded the Vite +// config, so the generated sprite and its types do not exist yet. Any Vite +// build, `npm run dev` or `npm run typecheck` regenerates them. export type IconName = string diff --git a/vite.config.ts b/vite.config.ts index 1216397..67f3cac 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,13 @@ import { reactRouter } from '@react-router/dev/vite' import { sentryVitePlugin } from '@sentry/vite-plugin' import react from '@vitejs/plugin-react' -import { type UserConfig } from 'vite' +import { defineConfig } from 'vite' import { envOnlyMacros } from 'vite-env-only' +import { iconsSpritesheet } from 'vite-plugin-icons-spritesheet' const MODE = process.env.NODE_ENV -export default { +export default defineConfig({ build: { target: 'es2022', cssMinify: MODE === 'production', @@ -15,12 +16,12 @@ export default { external: [/node:.*/, 'fsevents'], }, + // The plugin already returns false for the spritesheet it generates, so + // the sprite stays a single immutable-cached request that + // app/root.tsx preloads rather than ~5 KB of data URI inlined into + // every document. Only the one remaining case is left here. assetsInlineLimit: (source: string) => { - if ( - source.endsWith('sprite.svg') || - source.endsWith('favicon.svg') || - source.endsWith('apple-touch-icon.png') - ) { + if (source.endsWith('apple-touch-icon.png')) { return false } }, @@ -29,6 +30,22 @@ export default { }, plugins: [ envOnlyMacros(), + iconsSpritesheet({ + inputDir: './other/svg-icons', + outputDir: './app/components/ui/icons', + fileName: 'sprite.svg', + // The generated file lives where the @/icon-name alias already + // points, so the alias in tsconfig.json does not have to move. + typesOutputFile: './app/components/ui/icons/name.d.ts', + withTypes: true, + // Keep the kebab-case file names (arrow-left-outline) rather than + // the plugin's default camelCase transform, so the existing + // call sites keep working. + iconNameTransformer: (name) => name, + // app/components/ui/icons is generated but not gitignored from + // prettier, so `npm run format:check` reads these files. + formatter: 'prettier', + }), reactRouter(), react({ babel: { @@ -56,4 +73,4 @@ export default { }) : null, ], -} satisfies UserConfig +}) From 1f97e7812fbd05d6fe98ea1aaadb58c7ec0b75bc Mon Sep 17 00:00:00 2001 From: Arpit Dalal Date: Sun, 27 Sep 2026 12:01:28 -0400 Subject: [PATCH 2/5] build(server): run the server as TypeScript instead of esbuild output 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 --- .gitignore | 1 - .prettierignore | 1 - app/routes/_seo+/sitemap[.]xml.ts | 8 +- index.js => index.ts | 8 +- other/Dockerfile | 1 - other/build-server.ts | 72 --- package-lock.json | 737 +----------------------------- package.json | 17 +- server/app.ts | 29 ++ server/dev-server.js | 18 - server/index.ts | 67 ++- tests/allow-indexing.test.ts | 12 +- tests/server.smoke.test.ts | 2 +- vite.config.ts | 29 +- 14 files changed, 100 insertions(+), 902 deletions(-) rename index.js => index.ts (69%) delete mode 100644 other/build-server.ts create mode 100644 server/app.ts delete mode 100644 server/dev-server.js diff --git a/.gitignore b/.gitignore index 1641e4d..d618829 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ node_modules .DS_store /build -/server-build .env .cache diff --git a/.prettierignore b/.prettierignore index f022d02..9dc17fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,7 +2,6 @@ node_modules /build /public/build -/server-build .env /test-results/ diff --git a/app/routes/_seo+/sitemap[.]xml.ts b/app/routes/_seo+/sitemap[.]xml.ts index 8fd216b..3fac073 100644 --- a/app/routes/_seo+/sitemap[.]xml.ts +++ b/app/routes/_seo+/sitemap[.]xml.ts @@ -9,10 +9,10 @@ export function loader({ request }: Route.LoaderArgs) { // generateSitemap needs React Router's server route manifest. It used to // arrive as `context.serverBuild`, but React Router 8 requires // getLoadContext to return a RouterContextProvider, and a createContext() - // symbol cannot be shared between server/index.ts and the app because - // esbuild and Vite produce separate module graphs, so each side would hold - // a different context object. Reading the manifest straight out of the - // server build keeps it inside the app's own module graph instead. + // symbol cannot be shared between server/index.ts and the app because Node + // and Vite produce separate module graphs, so each side would hold a + // different context object. Reading the manifest straight out of the server + // build keeps it inside the app's own module graph instead. return generateSitemap(request, serverRoutes, { siteUrl: getDomainUrl(request), headers: { diff --git a/index.js b/index.ts similarity index 69% rename from index.js rename to index.ts index ec390d6..f04afe6 100644 --- a/index.js +++ b/index.ts @@ -16,8 +16,6 @@ sourceMapSupport.install({ }, }) -if (process.env.NODE_ENV === 'production') { - await import('./server-build/index.js') -} else { - await import('./server/index.ts') -} +// Node strips the types off this and the rest of the server graph, so there is +// no compiled copy of it to pick between in development and production. +await import('./server/index.ts') diff --git a/other/Dockerfile b/other/Dockerfile index dc634fd..02e7464 100644 --- a/other/Dockerfile +++ b/other/Dockerfile @@ -54,7 +54,6 @@ WORKDIR /myapp COPY --from=production-deps /myapp/node_modules /myapp/node_modules -COPY --from=build /myapp/server-build /myapp/server-build COPY --from=build /myapp/build /myapp/build COPY --from=build /myapp/package.json /myapp/package.json COPY --from=build /myapp/app/components/ui/icons /myapp/app/components/ui/icons diff --git a/other/build-server.ts b/other/build-server.ts deleted file mode 100644 index 1cbcdd6..0000000 --- a/other/build-server.ts +++ /dev/null @@ -1,72 +0,0 @@ -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import esbuild from 'esbuild' -import fsExtra from 'fs-extra' -import { globSync } from 'glob' - -const pkg = fsExtra.readJsonSync(path.join(process.cwd(), 'package.json')) - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const here = (...s: Array) => path.join(__dirname, ...s) -const globsafe = (s: string) => s.replace(/\\/g, '/') - -const allFiles = globSync(globsafe(here('../server/**/*.*')), { - ignore: [ - 'server/dev-server.js', // for development only - '**/tsconfig.json', - '**/eslint*', - '**/__tests__/**', - ], -}) - -const entries = [] -for (const file of allFiles) { - if (/\.(ts|js|tsx|jsx)$/.test(file)) { - entries.push(file) - } else { - const dest = file.replace(here('../server'), here('../server-build')) - fsExtra.ensureDirSync(path.parse(dest).dir) - fsExtra.copySync(file, dest) - console.log(`copied: ${file.replace(`${here('../server')}/`, '')}`) - } -} - -console.log() -console.log('building...') - -// esbuild's node target must be a single X[.Y[.Z]] version, but engines.node -// is a semver range. Take the lowest version the range admits so the output -// still runs on the oldest supported runtime. -function lowestSupportedNode(range: string) { - const parts = [...range.matchAll(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/g)] - .map(([, major, minor, patch]) => ({ - major: Number(major), - minor: minor === undefined ? 0 : Number(minor), - patch: patch === undefined ? 0 : Number(patch), - })) - .sort((a, b) => a.major - b.major || a.minor - b.minor || a.patch - b.patch) - - const lowest = parts[0] - if (!lowest) { - throw new Error(`Could not read a version out of engines.node: ${range}`) - } - return `${lowest.major}.${lowest.minor}.${lowest.patch}` -} - -const nodeTarget = `node${lowestSupportedNode(pkg.engines.node)}` -console.log(`target: ${nodeTarget}`) - -esbuild - .build({ - entryPoints: entries, - outdir: here('../server-build'), - target: [nodeTarget], - platform: 'node', - sourcemap: true, - format: 'esm', - logLevel: 'info', - }) - .catch((error: unknown) => { - console.error(error) - process.exit(1) - }) diff --git a/package-lock.json b/package-lock.json index a117c5c..0162171 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "cross-env": "^10.1.0", "date-fns": "^4.4.0", "dotenv": "^17.2.3", - "execa": "^10.0.1", "express": "^5.2.1", "express-rate-limit": "^8.7.0", "get-port": "^7.2.0", @@ -71,7 +70,6 @@ "@types/compression": "^1.8.1", "@types/eslint": "^9.6.1", "@types/express": "^5.0.6", - "@types/fs-extra": "^11.0.4", "@types/morgan": "^1.9.10", "@types/node": "^25.9.8", "@types/nodemailer": "^8.0.2", @@ -81,14 +79,10 @@ "@vitejs/plugin-react": "^5.1.2", "@vitest/ui": "^5.0.2", "babel-plugin-react-compiler": "^1.0.0", - "esbuild": "^0.25.11", "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-compiler": "^19.1.0-rc.2", - "fs-extra": "^11.4.1", - "glob": "^13.0.6", "jsdom": "^30.1.1", - "node-html-parser": "^7.0.2", "npm-run-all": "^4.1.5", "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.7.4", @@ -1006,448 +1000,6 @@ "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -2851,11 +2403,6 @@ "win32" ] }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==" - }, "node_modules/@sentry/browser": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-11.0.0.tgz", @@ -3330,17 +2877,6 @@ "node": ">=18" } }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@sly-cli/sly": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@sly-cli/sly/-/sly-2.1.1.tgz", @@ -4101,16 +3637,6 @@ "@types/send": "*" } }, - "node_modules/@types/fs-extra": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", - "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", - "dev": true, - "dependencies": { - "@types/jsonfile": "*", - "@types/node": "*" - } - }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -4124,15 +3650,6 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, - "node_modules/@types/jsonfile": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", - "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -6376,48 +5893,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6949,91 +6424,6 @@ "node": ">= 0.6" } }, - "node_modules/execa": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-10.0.1.tgz", - "integrity": "sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==", - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "figures": "^6.1.0", - "get-stream": "^9.0.1", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.3.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "which-command": "^0.1.0", - "yoctocolors": "^2.1.2" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/exit-hook": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-5.1.0.tgz", @@ -7178,20 +6568,6 @@ "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", "license": "MIT" }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", @@ -7458,32 +6834,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-stream/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -7798,15 +7148,6 @@ "node": ">= 6" } }, - "node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -8279,18 +7620,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -9891,18 +9220,6 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -10266,21 +9583,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pretty-ms": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", - "license": "MIT", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -11227,6 +10529,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "engines": { "node": ">=14" }, @@ -12668,17 +11971,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -13747,21 +13039,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-command": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/which-command/-/which-command-0.1.0.tgz", - "integrity": "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==", - "license": "MIT", - "bin": { - "which-command": "cli.js" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sindresorhus/which-command?sponsor=1" - } - }, "node_modules/which-typed-array": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", @@ -13966,18 +13243,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", - "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/package.json b/package.json index 8b108a1..63106b1 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,12 @@ "date": "2025-02-02T01:04:37Z" }, "scripts": { - "build": "run-s build:*", - "build:react-router": "react-router build", - "build:server": "tsx ./other/build-server.ts", - "dev": "cross-env NODE_ENV=development node ./server/dev-server.js", + "build": "react-router build", + "dev": "cross-env NODE_ENV=development tsx watch --clear-screen=false --inspect --ignore \".cache/**\" --ignore \"app/**\" --ignore \"vite.config.ts.timestamp-*\" --ignore \"build/**\" --ignore \"node_modules/**\" ./index.ts", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint . --max-warnings 0", - "start": "cross-env NODE_ENV=production node .", + "start": "cross-env NODE_ENV=production node ./index.ts", "typecheck": "react-router typegen && tsc", "validate": "run-p lint typecheck format:check", "test": "vitest", @@ -37,8 +35,7 @@ "eslintIgnore": [ "/node_modules", "/build", - "/public/build", - "/server-build" + "/public/build" ], "prettier": "@epic-web/config/prettier", "dependencies": { @@ -68,7 +65,6 @@ "cross-env": "^10.1.0", "date-fns": "^4.4.0", "dotenv": "^17.2.3", - "execa": "^10.0.1", "express": "^5.2.1", "express-rate-limit": "^8.7.0", "get-port": "^7.2.0", @@ -107,7 +103,6 @@ "@types/compression": "^1.8.1", "@types/eslint": "^9.6.1", "@types/express": "^5.0.6", - "@types/fs-extra": "^11.0.4", "@types/morgan": "^1.9.10", "@types/node": "^25.9.8", "@types/nodemailer": "^8.0.2", @@ -117,14 +112,10 @@ "@vitejs/plugin-react": "^5.1.2", "@vitest/ui": "^5.0.2", "babel-plugin-react-compiler": "^1.0.0", - "esbuild": "^0.25.11", "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-compiler": "^19.1.0-rc.2", - "fs-extra": "^11.4.1", - "glob": "^13.0.6", "jsdom": "^30.1.1", - "node-html-parser": "^7.0.2", "npm-run-all": "^4.1.5", "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.7.4", diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 0000000..4e5673f --- /dev/null +++ b/server/app.ts @@ -0,0 +1,29 @@ +import { createRequestHandler } from '@react-router/express' +import express from 'express' +import { RouterContextProvider } from 'react-router' + +/** + * The React Router request handler, and nothing else. + * + * This module is the entry of the SSR build, so in production it is bundled + * into build/server/index.js and the Express app in server/index.ts mounts it + * from there. In development it is loaded through Vite instead, which is what + * lets `virtual:react-router/server-build` resolve. Keeping it in one module + * means there is a single handler definition to keep in step with the build. + */ +export const app = express() + +app.use( + createRequestHandler({ + mode: process.env.NODE_ENV ?? 'development', + build: () => import('virtual:react-router/server-build'), + // React Router 8 made middleware unconditional, so getLoadContext must + // return a RouterContextProvider rather than a plain object. The CSP + // nonce travels on the request header instead of through a React + // context: the Express middleware that sets it is loaded by Node while + // this module is bundled by Vite, so a createContext() symbol created + // there would be a different instance from the one the app's loaders + // import. See the note on getLoadContext in server/index.ts. + getLoadContext: () => new RouterContextProvider(), + }), +) diff --git a/server/dev-server.js b/server/dev-server.js deleted file mode 100644 index 0261319..0000000 --- a/server/dev-server.js +++ /dev/null @@ -1,18 +0,0 @@ -import { execa } from 'execa' - -if (process.env.NODE_ENV === 'production') { - await import('../server-build/index.js') -} else { - const command = - 'tsx watch --clear-screen=false --ignore ".cache/**" --ignore "app/**" --ignore "vite.config.ts.timestamp-*" --ignore "build/**" --ignore "node_modules/**" --inspect ./index.js' - execa(command, { - stdio: ['ignore', 'inherit', 'inherit'], - shell: true, - env: { - FORCE_COLOR: true, - ...process.env, - }, - // https://github.com/sindresorhus/execa/issues/433 - windowsHide: false, - }) -} diff --git a/server/index.ts b/server/index.ts index a0ad85b..0c461e0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,6 +1,5 @@ import crypto from 'node:crypto' import { styleText } from 'node:util' -import { createRequestHandler } from '@react-router/express' import * as Sentry from '@sentry/node' import { ip as ipAddress } from 'address' import closeWithGrace from 'close-with-grace' @@ -10,17 +9,19 @@ import rateLimit, { ipKeyGenerator } from 'express-rate-limit' import getPort, { portNumbers } from 'get-port' import helmet from 'helmet' import morgan from 'morgan' -import { type ServerBuild, RouterContextProvider } from 'react-router' -import { decodeRequestUrl } from './utils/request-url.js' +import { decodeRequestUrl } from './utils/request-url.ts' const MODE = process.env.NODE_ENV ?? 'development' const IS_PROD = MODE === 'production' const IS_DEV = MODE === 'development' const ALLOW_INDEXING = process.env.ALLOW_INDEXING !== 'false' const SENTRY_ENABLED = IS_PROD && process.env.SENTRY_DSN +// Relative to this file. Written by `vite build`, which bundles server/app.ts +// into it — see the comment on the `app` export there. +const BUILD_PATH = '../build/server/index.js' if (SENTRY_ENABLED) { - void import('./utils/monitoring.js').then(({ init }) => init()) + void import('./utils/monitoring.ts').then(({ init }) => init()) } const viteDevServer = IS_PROD @@ -199,45 +200,31 @@ app.use((req, res, next) => { return generalRateLimit(req, res, next) }) -async function getBuild() { - try { - const build = viteDevServer - ? await viteDevServer.ssrLoadModule('virtual:react-router/server-build') - : // @ts-ignore - the file might not exist yet but it will - await import('../build/server/index.js') - return { build: build as unknown as ServerBuild, error: null } - } catch (error) { - // Catch error and return null to make express happy and avoid an unrecoverable crash - console.error('Error creating build:', error) - return { error: error, build: null as unknown as ServerBuild } +// The React Router request handler lives in server/app.ts, which is mounted +// last so that everything above gets first refusal on the request. Express 5 +// moved to path-to-regexp v8, which rejects the bare '*' wildcard, so the +// catch-all in dev is reached through app.use rather than a route pattern. +if (viteDevServer) { + app.use(async (req, res, next) => { + try { + // Loaded through Vite rather than Node so that + // `virtual:react-router/server-build` resolves, and so that edits + // under server/ and app/ are picked up without a restart. + const source = await viteDevServer.ssrLoadModule('./server/app.ts') + return await source.app(req, res, next) + } catch (error) { + if (error instanceof Error) viteDevServer.ssrFixStacktrace(error) + next(error) + } + }) +} else { + // @ts-ignore - the built server only exists after `npm run build`. + const { app: builtApp } = (await import(BUILD_PATH)) as { + app: express.Express } + app.use(builtApp) } -// Express 5 moved to path-to-regexp v8, which rejects the bare '*' wildcard. -// The catch-all has to be named, and '/{*splat}' rather than '/*splat' so it -// also matches the site root. -app.all( - '/{*splat}', - createRequestHandler({ - // React Router 8 made middleware unconditional, so getLoadContext must - // return a RouterContextProvider rather than a plain object. The CSP - // nonce travels on the request header instead of through a React - // context: this file is bundled separately from the app by esbuild, so - // a createContext() symbol created here would be a different instance - // from the one the app's loaders import. - getLoadContext: () => new RouterContextProvider(), - mode: MODE, - build: async () => { - const { error, build } = await getBuild() - // gracefully "catch" the error - if (error) { - throw error - } - return build - }, - }), -) - const desiredPort = Number(process.env.PORT || 3000) const portToUse = await getPort({ port: portNumbers(desiredPort, desiredPort + 100), diff --git a/tests/allow-indexing.test.ts b/tests/allow-indexing.test.ts index 0a089e6..c763e20 100644 --- a/tests/allow-indexing.test.ts +++ b/tests/allow-indexing.test.ts @@ -14,12 +14,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' * The flag is read once at process start, so each state needs its own process. */ -// Two different build outputs. The server runs the esbuild bundle -// (server-build/), which is what imports the React Router server build -// (build/server/) on demand. Only the second one is worth checking for -// existence, because that is what is missing on a tree that never built. +// Node runs index.ts and the server graph straight from source, so the +// entry is the one in the repository root. The React Router server build it +// imports on demand is the only build output worth checking for existence, +// because that is what is missing on a tree that never built. const REACT_ROUTER_BUILD = 'build/server/index.js' -const SERVER_BUNDLE = 'server-build/index.js' +const SERVER_ENTRY = 'index.ts' const BOOT_TIMEOUT_MS = 30_000 /** @@ -67,7 +67,7 @@ async function bootServer(allowIndexing: string | undefined) { env.ALLOW_INDEXING = allowIndexing as NodeJS.ProcessEnv['ALLOW_INDEXING'] } - const child = spawn(process.execPath, [SERVER_BUNDLE], { + const child = spawn(process.execPath, [SERVER_ENTRY], { env, stdio: ['ignore', 'pipe', 'pipe'], }) diff --git a/tests/server.smoke.test.ts b/tests/server.smoke.test.ts index a2dfa8f..06d00ce 100644 --- a/tests/server.smoke.test.ts +++ b/tests/server.smoke.test.ts @@ -112,7 +112,7 @@ beforeAll(async () => { // "sends no X-Robots-Tag by default" test below fail on their machine only. delete env.ALLOW_INDEXING - const child = spawn(process.execPath, ['server-build/index.js'], { + const child = spawn(process.execPath, ['index.ts'], { env, stdio: ['ignore', 'pipe', 'pipe'], }) diff --git a/vite.config.ts b/vite.config.ts index 67f3cac..6a0d4ae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,30 @@ import { iconsSpritesheet } from 'vite-plugin-icons-spritesheet' const MODE = process.env.NODE_ENV export default defineConfig({ + environments: { + // server/app.ts is the entry of the SSR build rather than React + // Router's virtual server build, so the Express request handler is + // bundled into build/server/index.js and server/index.ts mounts it + // from there. It has to be set per environment: @react-router/dev + // replaces the top-level `build.rollupOptions.input` with its virtual + // server build in its own config hook. + ssr: { + build: { + rollupOptions: { + input: './server/app.ts', + }, + }, + // server/app.ts is loaded by Vite in development, and it hands + // createRequestHandler a RouterContextProvider that the handler then + // checks with `instanceof`. React Router's `development` export + // condition gives Vite and Node two different builds of the package, + // so the handler has to be bundled into the same graph as the + // provider rather than externalized into Node's. + resolve: { + noExternal: ['@react-router/express'], + }, + }, + }, build: { target: 'es2022', cssMinify: MODE === 'production', @@ -65,10 +89,7 @@ export default defineConfig({ }, }, sourcemaps: { - filesToDeleteAfterUpload: [ - './build/**/*.map', - '.server-build/**/*.map', - ], + filesToDeleteAfterUpload: ['./build/**/*.map'], }, }) : null, From 1f198f5cd3993e187fdadb1d2100de5ae41424de Mon Sep 17 00:00:00 2001 From: Arpit Dalal Date: Sun, 27 Sep 2026 12:11:35 -0400 Subject: [PATCH 3/5] fix(icons): generate the icon name types as a .ts module, not a .d.ts 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.json | 2 +- vite.config.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 01b2ca2..def7b58 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ "#app/*": ["./app/*"], "#types/*": ["./types/*"], "@/icon-name": [ - "./app/components/ui/icons/name.d.ts", + "./app/components/ui/icons/name.ts", "./types/icon-name.d.ts" ] } diff --git a/vite.config.ts b/vite.config.ts index 6a0d4ae..85ef001 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -58,9 +58,10 @@ export default defineConfig({ inputDir: './other/svg-icons', outputDir: './app/components/ui/icons', fileName: 'sprite.svg', - // The generated file lives where the @/icon-name alias already - // points, so the alias in tsconfig.json does not have to move. - typesOutputFile: './app/components/ui/icons/name.d.ts', + // A .ts file, not a .d.ts: the generated module exports the icon + // name array as a value, and a const initializer in an ambient + // context is an error that only skipLibCheck hides. + typesOutputFile: './app/components/ui/icons/name.ts', withTypes: true, // Keep the kebab-case file names (arrow-left-outline) rather than // the plugin's default camelCase transform, so the existing From 465ed2d20addd5234448c02bd7a1d53989e03b95 Mon Sep 17 00:00:00 2001 From: Arpit Dalal Date: Sun, 27 Sep 2026 13:54:13 -0400 Subject: [PATCH 4/5] fix: address the review of #8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- eslint.config.js | 7 ++++++- package-lock.json | 1 + package.json | 3 ++- server/app.ts | 19 ++++++++++--------- server/index.ts | 11 +++++++---- tests/allow-indexing.test.ts | 14 +++++++------- tsconfig.json | 11 +++++++++++ vite.config.ts | 19 +++++++++++-------- 8 files changed, 55 insertions(+), 30 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index a56df7e..3e866aa 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,11 @@ export default [ }, }, { - ignores: ['.react-router/*'], + // Generated, gitignored, and absent in CI, so linting them here would + // only ever see files no other machine has. tsconfig picks the sprite's + // types up (it is a real module now), so a leftover .d.ts from the + // script this replaced would otherwise fail lint as a file "not found + // by the project service". + ignores: ['.react-router/*', 'app/components/ui/icons/*'], }, ] diff --git a/package-lock.json b/package-lock.json index 0162171..50e63bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@react-router/express": "^8.4.0", "@react-router/node": "^8.4.0", "@reactuses/core": "^6.5.9", + "@sentry/node": "^11.0.0", "@sentry/profiling-node": "^11.0.0", "@sentry/react": "^11.0.0", "@tailwindcss/postcss": "^4.3.3", diff --git a/package.json b/package.json index 63106b1..998f9ed 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "scripts": { "build": "react-router build", - "dev": "cross-env NODE_ENV=development tsx watch --clear-screen=false --inspect --ignore \".cache/**\" --ignore \"app/**\" --ignore \"vite.config.ts.timestamp-*\" --ignore \"build/**\" --ignore \"node_modules/**\" ./index.ts", + "dev": "cross-env NODE_ENV=development FORCE_COLOR=true tsx watch --clear-screen=false --inspect --ignore \".cache/**\" --ignore \"app/**\" --ignore \"vite.config.ts.timestamp-*\" --ignore \"build/**\" --ignore \"node_modules/**\" ./index.ts", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint . --max-warnings 0", @@ -53,6 +53,7 @@ "@react-router/node": "^8.4.0", "@reactuses/core": "^6.5.9", "@sentry/profiling-node": "^11.0.0", + "@sentry/node": "^11.0.0", "@sentry/react": "^11.0.0", "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/typography": "^0.5.20", diff --git a/server/app.ts b/server/app.ts index 4e5673f..983b083 100644 --- a/server/app.ts +++ b/server/app.ts @@ -5,11 +5,12 @@ import { RouterContextProvider } from 'react-router' /** * The React Router request handler, and nothing else. * - * This module is the entry of the SSR build, so in production it is bundled - * into build/server/index.js and the Express app in server/index.ts mounts it - * from there. In development it is loaded through Vite instead, which is what - * lets `virtual:react-router/server-build` resolve. Keeping it in one module - * means there is a single handler definition to keep in step with the build. + * This module is the entry of the SSR build, so in production `vite build` + * bundles it into build/server/index.js and the Express app in server/index.ts + * mounts it from there. In development it is loaded through Vite instead, which + * is what lets `virtual:react-router/server-build` resolve. Keeping it in one + * module means there is a single handler definition to keep in step with the + * build. */ export const app = express() @@ -20,10 +21,10 @@ app.use( // React Router 8 made middleware unconditional, so getLoadContext must // return a RouterContextProvider rather than a plain object. The CSP // nonce travels on the request header instead of through a React - // context: the Express middleware that sets it is loaded by Node while - // this module is bundled by Vite, so a createContext() symbol created - // there would be a different instance from the one the app's loaders - // import. See the note on getLoadContext in server/index.ts. + // context: the Express middleware that mints it in server/index.ts is + // loaded by Node while this module is bundled by Vite, so a + // createContext() symbol created over there would be a different + // instance from the one the app's loaders import. getLoadContext: () => new RouterContextProvider(), }), ) diff --git a/server/index.ts b/server/index.ts index 0c461e0..b541d7c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -16,8 +16,10 @@ const IS_PROD = MODE === 'production' const IS_DEV = MODE === 'development' const ALLOW_INDEXING = process.env.ALLOW_INDEXING !== 'false' const SENTRY_ENABLED = IS_PROD && process.env.SENTRY_DSN -// Relative to this file. Written by `vite build`, which bundles server/app.ts -// into it — see the comment on the `app` export there. +// Relative to this file. `vite build` bundles server/app.ts into this entry +// and the React Router server build into a hashed sibling chunk beside it under +// build/server/assets, so the whole build/server tree has to travel together — +// see the comment on the `app` export in server/app.ts. const BUILD_PATH = '../build/server/index.js' if (SENTRY_ENABLED) { @@ -116,8 +118,9 @@ app.use( app.use((req, _res, next) => { // The CSP nonce is generated here but consumed by app/entry.server.tsx. // It travels as a request header rather than through a React context - // because this server bundle and the app bundle do not share module - // instances. See the note on getLoadContext below. + // because this file is loaded by Node and the app is bundled by Vite, so + // the two do not share module instances. See `getLoadContext` in + // server/app.ts. req.headers['x-csp-nonce'] = crypto.randomBytes(16).toString('hex') next() }) diff --git a/tests/allow-indexing.test.ts b/tests/allow-indexing.test.ts index c763e20..5181b50 100644 --- a/tests/allow-indexing.test.ts +++ b/tests/allow-indexing.test.ts @@ -14,12 +14,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' * The flag is read once at process start, so each state needs its own process. */ -// Node runs index.ts and the server graph straight from source, so the -// entry is the one in the repository root. The React Router server build it -// imports on demand is the only build output worth checking for existence, -// because that is what is missing on a tree that never built. -const REACT_ROUTER_BUILD = 'build/server/index.js' +// Node runs index.ts and the server graph straight from source, so that is +// the entry. The SSR build it imports on demand is the only build output worth +// checking for existence, because that is what is missing on a tree that never +// built. const SERVER_ENTRY = 'index.ts' +const SERVER_BUILD = 'build/server/index.js' const BOOT_TIMEOUT_MS = 30_000 /** @@ -51,9 +51,9 @@ const servers: ChildProcess[] = [] /** Boots a server with `ALLOW_INDEXING` forced to `value` (or unset). */ async function bootServer(allowIndexing: string | undefined) { - if (!existsSync(REACT_ROUTER_BUILD)) { + if (!existsSync(SERVER_BUILD)) { throw new Error( - `${REACT_ROUTER_BUILD} is missing. Run \`npm run build\` before this test; the deploy workflow's test job does this.`, + `${SERVER_BUILD} is missing. Run \`npm run build\` before this test; the deploy workflow's test job does this.`, ) } diff --git a/tsconfig.json b/tsconfig.json index def7b58..43d152f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,17 @@ "compilerOptions": { "types": ["@react-router/node", "vite/client"], "rootDirs": [".", "./.react-router/types"], + // Node runs index.ts and the server graph directly, stripping the types + // itself, so anything tsc accepts has to be syntax it can erase. + // `erasableSyntaxOnly` rejects enums, namespaces and parameter + // properties, which tsc would otherwise accept and Node would refuse to + // load — in production only, since `tsx` compiles them in development. + // https://nodejs.org/api/typescript.html#type-stripping + // + // `verbatimModuleSyntax` is deliberately not set: it would stop the + // type-only `@/icon-name` import in app/components/ui/icon.tsx from + // being elided, and Vite cannot resolve that tsconfig paths alias. + "erasableSyntaxOnly": true, "paths": { "#app/*": ["./app/*"], "#types/*": ["./types/*"], diff --git a/vite.config.ts b/vite.config.ts index 85ef001..24d7205 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -40,12 +40,18 @@ export default defineConfig({ external: [/node:.*/, 'fsevents'], }, - // The plugin already returns false for the spritesheet it generates, so - // the sprite stays a single immutable-cached request that - // app/root.tsx preloads rather than ~5 KB of data URI inlined into - // every document. Only the one remaining case is left here. + // The sprite stays a single immutable-cached request that app/root.tsx + // preloads rather than ~5 KB of data URI inlined into every document. + // The plugin also returns false for the spritesheet it generates, but it + // matches it on a path built from cwd, which its own issues report as + // having silently failed to match (notably on Windows). If that ever + // regresses, the sprite inlines itself and the preload becomes a no-op + // with nothing failing, so keep the case here. assetsInlineLimit: (source: string) => { - if (source.endsWith('apple-touch-icon.png')) { + if ( + source.endsWith('sprite.svg') || + source.endsWith('apple-touch-icon.png') + ) { return false } }, @@ -67,9 +73,6 @@ export default defineConfig({ // the plugin's default camelCase transform, so the existing // call sites keep working. iconNameTransformer: (name) => name, - // app/components/ui/icons is generated but not gitignored from - // prettier, so `npm run format:check` reads these files. - formatter: 'prettier', }), reactRouter(), react({ From 874100e40025acb8d900e002beb8356ed939d7e0 Mon Sep 17 00:00:00 2001 From: Arpit Dalal Date: Sun, 27 Sep 2026 14:01:43 -0400 Subject: [PATCH 5/5] docs: correct the sprite exclusion, sort the sentry deps, scope the tsconfig 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 --- package.json | 2 +- tsconfig.json | 8 +++++--- vite.config.ts | 10 +++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 998f9ed..f7febfc 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "@react-router/express": "^8.4.0", "@react-router/node": "^8.4.0", "@reactuses/core": "^6.5.9", - "@sentry/profiling-node": "^11.0.0", "@sentry/node": "^11.0.0", + "@sentry/profiling-node": "^11.0.0", "@sentry/react": "^11.0.0", "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/typography": "^0.5.20", diff --git a/tsconfig.json b/tsconfig.json index 43d152f..0377681 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,9 +7,11 @@ // Node runs index.ts and the server graph directly, stripping the types // itself, so anything tsc accepts has to be syntax it can erase. // `erasableSyntaxOnly` rejects enums, namespaces and parameter - // properties, which tsc would otherwise accept and Node would refuse to - // load — in production only, since `tsx` compiles them in development. - // https://nodejs.org/api/typescript.html#type-stripping + // properties: `tsx` compiles them happily in development, so nothing + // else would catch one landing in server/ and taking production down at + // boot. It is set repo-wide rather than narrowed to the server graph, so + // that app/ code is held to the same rule Vite's own transform can + // already meet. https://nodejs.org/api/typescript.html#type-stripping // // `verbatimModuleSyntax` is deliberately not set: it would stop the // type-only `@/icon-name` import in app/components/ui/icon.tsx from diff --git a/vite.config.ts b/vite.config.ts index 24d7205..a44555f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -42,11 +42,11 @@ export default defineConfig({ // The sprite stays a single immutable-cached request that app/root.tsx // preloads rather than ~5 KB of data URI inlined into every document. - // The plugin also returns false for the spritesheet it generates, but it - // matches it on a path built from cwd, which its own issues report as - // having silently failed to match (notably on Windows). If that ever - // regresses, the sprite inlines itself and the preload becomes a no-op - // with nothing failing, so keep the case here. + // The plugin also excludes the spritesheet it generates, but it does so + // by string-matching the asset's path against its outputDir, which its + // own issues report as having silently stopped matching on some + // setups. If that regresses, the sprite inlines itself and the preload + // becomes a no-op with nothing failing, so keep the case here too. assetsInlineLimit: (source: string) => { if ( source.endsWith('sprite.svg') ||