diff --git a/docs/react-server.config.mjs b/docs/react-server.config.mjs index 051e5733..2f655b29 100644 --- a/docs/react-server.config.mjs +++ b/docs/react-server.config.mjs @@ -41,6 +41,14 @@ export default { accept: "application/xml", }, }, + { + path: "/schema.json", + filename: "schema.json", + method: "GET", + headers: { + accept: "application/json", + }, + }, ]; }, }; diff --git a/docs/src/pages/(i18n).middleware.mjs b/docs/src/pages/(i18n).middleware.mjs index 6f1c8465..dae9f1f5 100644 --- a/docs/src/pages/(i18n).middleware.mjs +++ b/docs/src/pages/(i18n).middleware.mjs @@ -6,7 +6,7 @@ import { defaultLanguage, languages } from "../const.mjs"; export default function I18n() { const { pathname } = useUrl(); - if (pathname === "/sitemap.xml") { + if (pathname === "/sitemap.xml" || pathname === "/schema.json") { return; } diff --git a/docs/src/pages/GET.{schema.json}.server.mjs b/docs/src/pages/GET.{schema.json}.server.mjs new file mode 100644 index 00000000..4e50ac5b --- /dev/null +++ b/docs/src/pages/GET.{schema.json}.server.mjs @@ -0,0 +1,11 @@ +import { generateJsonSchema } from "@lazarv/react-server/config/schema"; + +export default function Schema() { + const schema = generateJsonSchema(); + + return new Response(JSON.stringify(schema, null, 2), { + headers: { + "Content-Type": "application/json", + }, + }); +} diff --git a/docs/src/pages/en/(pages)/features/configuration.mdx b/docs/src/pages/en/(pages)/features/configuration.mdx index 2581ad15..16e2f216 100644 --- a/docs/src/pages/en/(pages)/features/configuration.mdx +++ b/docs/src/pages/en/(pages)/features/configuration.mdx @@ -14,6 +14,39 @@ You can use the `+*.config.*` file to extend the configuration of `@lazarv/react The `+*.config.*` file is useful when you want to extend the configuration of the runtime. You can use as many `+*.config.*` files as you want. All the configuration files are merged together in the order they are loaded. + +## Typed configuration + + +For **JavaScript and TypeScript** config files, wrap your config with `defineConfig` to get full IntelliSense — autocompletion, type checking, and inline descriptions with examples for every option: + +```mjs filename="react-server.config.mjs" +import { defineConfig } from "@lazarv/react-server/config"; + +export default defineConfig({ + port: 3000, + adapter: "vercel", +}); +``` + +For **JSON** config files, add a `$schema` property to enable validation and autocomplete directly in your editor: + +```json filename="react-server.config.json" +{ + "$schema": "https://react-server.dev/schema.json", + "port": 3000, + "adapter": "vercel" +} +``` + +The JSON Schema is also available from the package itself for offline use: + +```json filename="react-server.config.json" +{ + "$schema": "node_modules/@lazarv/react-server/config/schema.json" +} +``` + If you want to only add configuration to use when running the runtime in production mode, then use the `.production.config.*` extension. These configuration files only loaded in production mode. In a similar way, you can create configuration files with the `.development.config.*` extension to use a different configuration for development mode. diff --git a/docs/src/pages/ja/(pages)/features/configuration.mdx b/docs/src/pages/ja/(pages)/features/configuration.mdx index c3cf3b54..268b45ab 100644 --- a/docs/src/pages/ja/(pages)/features/configuration.mdx +++ b/docs/src/pages/ja/(pages)/features/configuration.mdx @@ -14,6 +14,39 @@ import Link from "../../../../components/Link.jsx"; `+*.config.*` ファイルはランタイムの設定を拡張したい場合に便利です。`+*.config.*`ファイルはいくつでも使うことができます。すべての設定ファイルは読み込まれた順にマージされます。 + +## 型付き設定 + + +**JavaScriptおよびTypeScript**の設定ファイルでは、`defineConfig`で設定をラップすることで、すべてのオプションに対する自動補完、型チェック、インラインの説明と例を含む完全なIntelliSenseを利用できます: + +```mjs filename="react-server.config.mjs" +import { defineConfig } from "@lazarv/react-server/config"; + +export default defineConfig({ + port: 3000, + adapter: "vercel", +}); +``` + +**JSON**設定ファイルでは、`$schema`プロパティを追加することで、エディタ上で直接バリデーションと自動補完を有効にできます: + +```json filename="react-server.config.json" +{ + "$schema": "https://react-server.dev/schema.json", + "port": 3000, + "adapter": "vercel" +} +``` + +JSONスキーマはオフライン利用のためにパッケージ自体からも利用できます: + +```json filename="react-server.config.json" +{ + "$schema": "node_modules/@lazarv/react-server/config/schema.json" +} +``` + プロダクションモードでランタイムを実行するときに使用する設定のみを追加したい場合は、`.production.config.*` 拡張子を使用します。これらの設定ファイルはプロダクションモードでのみ読み込まれます。 同じように、`.development.config.*`という拡張子で設定ファイルを作ると、開発モード用に別の設定を使うことができます。 diff --git a/packages/create-react-server/templates/nextjs/react-server.config.json b/packages/create-react-server/templates/nextjs/react-server.config.json index 90fbab1d..d0997a52 100644 --- a/packages/create-react-server/templates/nextjs/react-server.config.json +++ b/packages/create-react-server/templates/nextjs/react-server.config.json @@ -1,9 +1,9 @@ { "root": "src/app", - "layouts": { + "layout": { "include": ["**/layout.jsx"] }, - "pages": { + "page": { "include": ["**/page.jsx"] } } diff --git a/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.bun.snap b/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.bun.snap index d0350b86..21b568ee 100644 --- a/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.bun.snap +++ b/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.bun.snap @@ -4078,10 +4078,10 @@ export default [ ", "react-server.config.json": "{ "root": "src/app", - "layouts": { + "layout": { "include": ["**/layout.jsx"] }, - "pages": { + "page": { "include": ["**/page.jsx"] } } diff --git a/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.npm.snap b/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.npm.snap index 04557ede..adf2506c 100644 --- a/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.npm.snap +++ b/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.npm.snap @@ -4078,10 +4078,10 @@ export default [ ", "react-server.config.json": "{ "root": "src/app", - "layouts": { + "layout": { "include": ["**/layout.jsx"] }, - "pages": { + "page": { "include": ["**/page.jsx"] } } diff --git a/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.pnpm.snap b/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.pnpm.snap index 74d33b84..18139e24 100644 --- a/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.pnpm.snap +++ b/packages/create-react-server/test/__test__/__snapshots__/bun.spec.mjs.pnpm.snap @@ -4078,10 +4078,10 @@ export default [ ", "react-server.config.json": "{ "root": "src/app", - "layouts": { + "layout": { "include": ["**/layout.jsx"] }, - "pages": { + "page": { "include": ["**/page.jsx"] } } diff --git a/packages/create-react-server/test/__test__/__snapshots__/deno.spec.mjs.npm.snap b/packages/create-react-server/test/__test__/__snapshots__/deno.spec.mjs.npm.snap index 26c48f1c..c1116ce6 100644 --- a/packages/create-react-server/test/__test__/__snapshots__/deno.spec.mjs.npm.snap +++ b/packages/create-react-server/test/__test__/__snapshots__/deno.spec.mjs.npm.snap @@ -3869,10 +3869,10 @@ export default [ ", "react-server.config.json": "{ "root": "src/app", - "layouts": { + "layout": { "include": ["**/layout.jsx"] }, - "pages": { + "page": { "include": ["**/page.jsx"] } } diff --git a/packages/create-react-server/test/__test__/__snapshots__/deno.spec.mjs.pnpm.snap b/packages/create-react-server/test/__test__/__snapshots__/deno.spec.mjs.pnpm.snap index 342c5ead..ec37823a 100644 --- a/packages/create-react-server/test/__test__/__snapshots__/deno.spec.mjs.pnpm.snap +++ b/packages/create-react-server/test/__test__/__snapshots__/deno.spec.mjs.pnpm.snap @@ -3869,10 +3869,10 @@ export default [ ", "react-server.config.json": "{ "root": "src/app", - "layouts": { + "layout": { "include": ["**/layout.jsx"] }, - "pages": { + "page": { "include": ["**/page.jsx"] } } diff --git a/packages/create-react-server/test/__test__/__snapshots__/node.spec.mjs.npm.snap b/packages/create-react-server/test/__test__/__snapshots__/node.spec.mjs.npm.snap index d640b4fe..01c10f7e 100644 --- a/packages/create-react-server/test/__test__/__snapshots__/node.spec.mjs.npm.snap +++ b/packages/create-react-server/test/__test__/__snapshots__/node.spec.mjs.npm.snap @@ -3844,10 +3844,10 @@ export default [ ", "react-server.config.json": "{ "root": "src/app", - "layouts": { + "layout": { "include": ["**/layout.jsx"] }, - "pages": { + "page": { "include": ["**/page.jsx"] } } diff --git a/packages/create-react-server/test/__test__/__snapshots__/node.spec.mjs.pnpm.snap b/packages/create-react-server/test/__test__/__snapshots__/node.spec.mjs.pnpm.snap index 9ce40add..72e8ffa2 100644 --- a/packages/create-react-server/test/__test__/__snapshots__/node.spec.mjs.pnpm.snap +++ b/packages/create-react-server/test/__test__/__snapshots__/node.spec.mjs.pnpm.snap @@ -3844,10 +3844,10 @@ export default [ ", "react-server.config.json": "{ "root": "src/app", - "layouts": { + "layout": { "include": ["**/layout.jsx"] }, - "pages": { + "page": { "include": ["**/page.jsx"] } } diff --git a/packages/react-server/config/index.d.ts b/packages/react-server/config/index.d.ts index cdc0d57c..53e0f9d9 100644 --- a/packages/react-server/config/index.d.ts +++ b/packages/react-server/config/index.d.ts @@ -1,10 +1,12 @@ -export type ReactServerConfig = any; -export function loadConfig>( - initialConfig: T +import type { ReactServerConfig } from "./schema.js"; + +export type { ReactServerConfig } from "./schema.js"; +export { DESCRIPTIONS, generateJsonSchema } from "./schema.js"; + +export function loadConfig( + initialConfig: ReactServerConfig ): Promise; -export function defineConfig>( - config: T -): ReactServerConfig; +export function defineConfig(config: ReactServerConfig): ReactServerConfig; export function forRoot(config?: ReactServerConfig): ReactServerConfig; export function forChild(config?: ReactServerConfig): ReactServerConfig; diff --git a/packages/react-server/config/index.mjs b/packages/react-server/config/index.mjs index 457dbdf5..b1aee939 100644 --- a/packages/react-server/config/index.mjs +++ b/packages/react-server/config/index.mjs @@ -195,3 +195,5 @@ export async function loadConfig(initialConfig, options = {}) { export function defineConfig(config) { return config; } + +export { DESCRIPTIONS, generateJsonSchema } from "./schema.mjs"; diff --git a/packages/react-server/config/schema.d.ts b/packages/react-server/config/schema.d.ts new file mode 100644 index 00000000..fd604150 --- /dev/null +++ b/packages/react-server/config/schema.d.ts @@ -0,0 +1,852 @@ +/** + * TypeScript type definitions for `@lazarv/react-server` config. + * + * Use with `defineConfig()` for full IntelliSense in JS/TS config files: + * ```ts + * import { defineConfig } from "@lazarv/react-server/config"; + * export default defineConfig({ root: "src/pages" }); + * ``` + * + * For JSON config files, use `$schema`: + * ```json + * { "$schema": "node_modules/@lazarv/react-server/config/schema.json" } + * ``` + */ + +// ───── Helper types ───── + +/** A Vite-style plugin: object with `name`, function, `[name, options]` tuple, nested array, `false`, `null`, or `undefined`. */ +export type PluginOption = + | Record + | ((...args: unknown[]) => unknown) + | [string, Record?] + | PluginOption[] + | false + | null + | undefined; + +/** Resolve alias entry with `find` and `replacement`. */ +export interface AliasEntry { + /** String or RegExp to match import specifiers. */ + find: string | RegExp; + /** Replacement path. */ + replacement: string; +} + +/** Known adapter names shipped with react-server. */ +export type KnownAdapter = + | "aws" + | "azure" + | "azure-swa" + | "bun" + | "cloudflare" + | "deno" + | "docker" + | "firebase" + | "netlify" + | "singlefile" + | "vercel"; + +/** Adapter value: a known name, custom string, function, or `[name, options]` tuple. */ +export type AdapterOption = + | KnownAdapter + | (string & {}) + | ((...args: unknown[]) => unknown) + | [string, Record?]; + +/** Export path descriptor for static export. */ +export interface ExportPathDescriptor { + /** The URL path to export. */ + path: string; + /** Optional custom output filename. */ + filename?: string; + /** Optional outlet name. */ + outlet?: string; + [key: string]: unknown; +} + +/** Rollup / Rolldown shared options shape. */ +export interface RollupOptions { + /** + * External dependencies to exclude from the bundle. + * @example `external: ["lodash"]` + */ + external?: string[] | ((id: string) => boolean) | RegExp; + /** + * Output options. + * @example `output: { format: "es" }` + */ + output?: Record; + /** + * Rollup/Rolldown plugins. + * @example `plugins: []` + */ + plugins?: unknown[]; + /** + * Entry point(s). + * @example `input: "./src/main.ts"` + */ + input?: string | Record | string[]; + /** + * Checks configuration. + */ + checks?: Record; + /** + * Tree-shaking configuration. + * @example `treeshake: true` + */ + treeshake?: boolean | Record; +} + +// ───── Server config ───── + +export interface ServerConfig { + /** + * Specify which IP addresses the server should listen on. + * @example `host: "0.0.0.0"` + */ + host?: string | true; + + /** + * Specify server port. + * @example `port: 8080` + */ + port?: number; + + /** + * Enable HTTPS / TLS. + * @example `https: true` or `https: { key: "...", cert: "..." }` + */ + https?: boolean | Record; + + /** + * Configure CORS for the dev server. + * @example `cors: true` + */ + cors?: boolean | Record; + + /** + * Open the app in the browser on server start. + * @example `open: true` or `open: "/specific-page"` + */ + open?: boolean | string; + + /** + * Configure HMR connection. + * Set to `false` to disable HMR. + * @example `hmr: { port: 24678 }` or `hmr: false` + */ + hmr?: boolean | Record; + + /** + * File system serving restrictions. + * @example `fs: { allow: [".."] }` + */ + fs?: { + /** Directories allowed to be served. */ + allow?: string[]; + /** Directories denied from being served. */ + deny?: string[]; + /** Enable strict mode. */ + strict?: boolean; + }; + + /** + * File watcher options (passed to chokidar). + * @example `watch: { usePolling: true }` + */ + watch?: Record; + + /** + * Define the origin of the generated asset URLs during development. + * @example `origin: "https://example.com"` + */ + origin?: string; + + /** + * Custom proxy rules for the dev server. + * @example `proxy: { "/api": "http://localhost:4000" }` + */ + proxy?: Record; + + /** + * Trust the `X-Forwarded-*` headers from reverse proxies. + * @example `trustProxy: true` + */ + trustProxy?: boolean; + + /** + * Custom response headers for the dev server. + * @example `headers: { "X-Custom": "value" }` + */ + headers?: Record; + + /** + * Warm up files to pre-transform on server start. + * @example `warmup: { clientFiles: ["./src/main.ts"] }` + */ + warmup?: Record; +} + +// ───── Resolve config ───── + +export interface ResolveConfig { + /** + * Import alias mapping. + * @example `alias: { "@": "./src" }` or `alias: [{ find: "@", replacement: "./src" }]` + */ + alias?: Record | AliasEntry[]; + + /** + * Dependencies to force-deduplicate. + * @example `dedupe: ["react", "react-dom"]` + */ + dedupe?: string[]; + + /** + * Packages to bundle instead of externalizing during SSR. + * @example `noExternal: ["my-package"]` + */ + noExternal?: string[] | boolean | RegExp; + + /** + * Shared dependencies between server and client bundles. + * @example `shared: ["shared-utils"]` + */ + shared?: string[]; + + /** + * External dependencies for SSR. + * @example `external: /^node:/` or `external: ["fs", "path"]` + */ + external?: RegExp | string | string[] | ((id: string) => boolean); + + /** + * Built-in modules that should not be bundled. + * @example `builtins: ["my-builtin"]` + */ + builtins?: string[]; + + /** + * Custom conditions for package exports resolution. + * @example `conditions: ["worker", "browser"]` + */ + conditions?: string[]; + + /** + * File extensions to try when resolving imports. + * @example `extensions: [".mjs", ".js", ".ts"]` + */ + extensions?: string[]; + + /** + * Fields in `package.json` to try when resolving entry points. + * @example `mainFields: ["module", "main"]` + */ + mainFields?: string[]; +} + +// ───── Build config ───── + +export interface BuildConfig { + /** + * Browser compatibility target. + * @example `target: "esnext"` or `target: ["es2020", "edge88"]` + */ + target?: string | string[]; + + /** + * Output directory (relative to project root). + * @example `outDir: "dist"` + */ + outDir?: string; + + /** + * Directory for assets inside `outDir`. + * @example `assetsDir: "assets"` + */ + assetsDir?: string; + + /** + * Minification strategy. + * @example `minify: true` or `minify: "terser"` or `minify: "esbuild"` + */ + minify?: boolean | "terser" | "esbuild"; + + /** + * CSS minification (uses `minify` value by default). + * @example `cssMinify: true` + */ + cssMinify?: boolean | string; + + /** + * Enable CSS code splitting. + * @example `cssCodeSplit: true` + */ + cssCodeSplit?: boolean; + + /** + * Threshold (in bytes) for inlining assets as base64. + * @example `assetsInlineLimit: 4096` + */ + assetsInlineLimit?: number | ((filePath: string) => number); + + /** + * Show compressed size of build output. + * @example `reportCompressedSize: false` + */ + reportCompressedSize?: boolean; + + /** + * Copy public directory to outDir on build. + * @example `copyPublicDir: false` + */ + copyPublicDir?: boolean; + + /** + * Module preload configuration. + * @example `modulePreload: false` or `modulePreload: { polyfill: false }` + */ + modulePreload?: boolean | Record; + + /** + * Warn when a chunk exceeds this size (in kB). + * @example `chunkSizeWarningLimit: 2048` + */ + chunkSizeWarningLimit?: number; + + /** + * Build in library mode. + * @example `lib: true` + */ + lib?: boolean; + + /** + * Rollup-specific build options. + * @example `rollupOptions: { external: ["lodash"] }` + */ + rollupOptions?: RollupOptions; + + /** + * Rolldown-specific build options. + * @example `rolldownOptions: { output: { minify: true } }` + */ + rolldownOptions?: RollupOptions; + + /** + * Custom Vite config for the server build. + * @example `server: { config: { target: "esnext" } }` + */ + server?: { + config?: + | Record + | ((...args: unknown[]) => Record); + }; + + /** + * Custom Vite config for the client build. + * @example `client: { config: { target: "esnext" } }` + */ + client?: { + config?: + | Record + | ((...args: unknown[]) => Record); + }; +} + +// ───── SSR config ───── + +export interface SsrConfig { + /** + * Force externalize these dependencies during SSR. + * @example `external: ["pg", "mysql2"]` + */ + external?: string[] | boolean; + + /** + * Force bundle these dependencies during SSR. + * @example `noExternal: ["my-ui-lib"]` + */ + noExternal?: string[] | boolean | RegExp; + + /** + * SSR resolve options. + * @example `resolve: { conditions: ["worker"] }` + */ + resolve?: Record; + + /** + * Run SSR in a web worker. + * @example `worker: false` + */ + worker?: boolean; +} + +// ───── CSS config ───── + +export interface CssConfig { + /** + * CSS Modules configuration. + * @example `modules: { localsConvention: "camelCase" }` + */ + modules?: Record; + + /** + * Options for CSS preprocessors. + * @example `preprocessorOptions: { scss: { additionalData: '...' } }` + */ + preprocessorOptions?: Record; + + /** + * PostCSS config (inline or path to config file). + * @example `postcss: "./postcss.config.js"` or `postcss: { plugins: [] }` + */ + postcss?: string | Record; + + /** + * Enable sourcemaps during dev for CSS. + * @example `devSourcemap: true` + */ + devSourcemap?: boolean; +} + +// ───── OptimizeDeps config ───── + +export interface OptimizeDepsConfig { + /** + * Dependencies to force-include in pre-bundling. + * @example `include: ["lodash"]` + */ + include?: string[]; + + /** + * Dependencies to exclude from pre-bundling. + * @example `exclude: ["large-dep"]` + */ + exclude?: string[]; + + /** + * Force re-optimization on every dev server start. + * @example `force: true` + */ + force?: boolean; + + /** + * Rolldown options for dependency optimization. + */ + rolldownOptions?: Record; + + /** + * esbuild options for dependency optimization. + */ + esbuildOptions?: Record; +} + +// ───── Cache config ───── + +export interface CacheConfig { + /** + * Cache profiles (TTL, key strategies, etc.). + * @example `profiles: { default: { ttl: 60 } }` + */ + profiles?: Record | unknown[]; + + /** + * Cache storage providers. + * @example `providers: { memory: { ... } }` + */ + providers?: Record | unknown[]; +} + +// ───── Server functions config ───── + +export interface ServerFunctionsConfig { + /** + * Secret key for signing server function calls. + * @example `secret: "my-secret-key"` + */ + secret?: string; + + /** + * Path to a file containing the secret key. + * @example `secretFile: "./secret.pem"` + */ + secretFile?: string; + + /** + * Previously used secrets for key rotation. + * @example `previousSecrets: ["old-secret"]` + */ + previousSecrets?: string[]; + + /** + * Previously used secret files for key rotation. + * @example `previousSecretFiles: ["./old.pem"]` + */ + previousSecretFiles?: string[]; +} + +// ───── MDX config ───── + +export interface MdxConfig { + /** + * Remark plugins for MDX processing. + * @example `remarkPlugins: [remarkGfm]` + */ + remarkPlugins?: unknown[]; + + /** + * Rehype plugins for MDX processing. + * @example `rehypePlugins: [rehypeHighlight]` + */ + rehypePlugins?: unknown[]; + + /** + * Path to the MDX components file. + * @example `components: "./mdx-components.jsx"` + */ + components?: string; +} + +// ───── File-router config ───── + +/** File-router configuration for layout/page/middleware/api/router. Typically `{ include?: string[], exclude?: string[] }`. */ +export type FileRouterConfigValue = + | Record + | ((...args: unknown[]) => Record); + +// ───── Main config interface ───── + +/** + * Configuration for `@lazarv/react-server`. + * + * This interface covers all react-server specific options as well as + * Vite-level pass-through configuration. Every property is optional. + * + * **Usage in JS/TS config files:** + * ```ts + * import { defineConfig } from "@lazarv/react-server/config"; + * export default defineConfig({ + * root: "src/pages", + * adapter: "vercel", + * }); + * ``` + * + * **Usage in JSON config files:** + * ```json + * { + * "$schema": "node_modules/@lazarv/react-server/config/schema.json", + * "root": "src/pages" + * } + * ``` + */ +export interface ReactServerConfig { + /** + * Root directory for file-router page discovery. + * @example `root: "src/pages"` + */ + root?: string; + + /** + * Base public path for the application. + * @example `base: "/my-app/"` + */ + base?: string; + + /** + * Entry point for the application (used in non-file-router mode). + * @example `entry: "./src/App.jsx"` + */ + entry?: string; + + /** + * Public directory for static assets, or `false` to disable. + * @example `public: "public"` or `public: false` + */ + public?: string | false; + + /** + * Application name. + * @example `name: "my-app"` + */ + name?: string; + + /** + * Deployment adapter. + * @example `adapter: "vercel"` or `adapter: ["cloudflare", { routes: true }]` + */ + adapter?: AdapterOption | null; + + /** + * Vite plugins. + * @example `plugins: [myVitePlugin()]` + */ + plugins?: PluginOption[] | ((...args: unknown[]) => PluginOption[]); + + /** + * Global constant replacements (passed to Vite `define`). + * @example `define: { "process.env.MY_VAR": JSON.stringify("value") }` + */ + define?: Record; + + /** + * Directory to load `.env` files from, or `false` to disable. + * @example `envDir: "./env"` or `envDir: false` + */ + envDir?: string | false; + + /** + * Env variable prefix(es) to expose to client-side code. + * @example `envPrefix: "MY_APP_"` or `envPrefix: ["MY_APP_", "VITE_"]` + */ + envPrefix?: string | string[]; + + /** + * Directory for Vite's dependency cache. + * @example `cacheDir: "node_modules/.cache"` + */ + cacheDir?: string; + + /** + * Packages to externalize from the server bundle. + * @example `external: ["pg", "mysql2"]` + */ + external?: string[] | string; + + /** + * Source map generation strategy. + * @example `sourcemap: true` or `sourcemap: "hidden"` + */ + sourcemap?: boolean | "inline" | "hidden" | "server" | "server-inline"; + + /** + * Enable response compression. + * @example `compression: true` + */ + compression?: boolean; + + /** + * Static export configuration. Provide paths to export, a function returning paths, or boolean. + * @example `export: ["/", "/about"]` or `export: [{ path: "/" }]` or `export: true` + */ + export?: + | boolean + | ((paths: string[]) => string[] | ExportPathDescriptor[]) + | (string | ExportPathDescriptor)[]; + + /** + * Enable prerendering. + * @example `prerender: true` or `prerender: { timeout: 30000 }` + */ + prerender?: boolean | Record; + + /** + * Number of cluster workers, or `true` for auto-detection. + * @example `cluster: 4` + */ + cluster?: number | boolean; + + /** + * Enable CORS. + * @example `cors: true` or `cors: { origin: "*", credentials: true }` + */ + cors?: boolean | Record | null; + + /** + * Raw Vite config override (object or function). + * @example `vite: { build: { target: "esnext" } }` or `vite: (config) => config` + */ + vite?: + | Record + | ((config: Record) => Record) + | null; + + /** + * Custom Vite logger instance. + * @example `customLogger: myCustomLogger` + */ + customLogger?: Record; + + /** + * Logger configuration. + * @example `logger: "pino"` or `logger: { level: "info" }` + */ + logger?: string | Record; + + /** + * Glob pattern for the global error component. + * @example `globalErrorComponent: "ErrorBoundary.{jsx,tsx}"` + */ + globalErrorComponent?: string; + + /** + * HTTP request handlers (middleware). + * @example `handlers: [myMiddleware]` or `handlers: { pre: [...], post: [...] }` + */ + handlers?: + | ((...args: unknown[]) => unknown) + | unknown[] + | { pre?: unknown[]; post?: unknown[] }; + + /** + * Import map for bare specifier resolution in the browser. + * @example `importMap: { imports: { "lodash": "/vendor/lodash.js" } }` + */ + importMap?: { imports?: Record }; + + /** + * Enable vite-plugin-inspect for debugging. + * @example `inspect: true` + */ + inspect?: boolean | Record | null; + + /** + * Runtime configuration passed to the server. + * @example `runtime: async () => ({ key: "value" })` + */ + runtime?: ((...args: unknown[]) => unknown) | Record; + + /** + * Cookie options for the session. + * @example `cookies: { secure: true, sameSite: "lax" }` + */ + cookies?: Record; + + /** + * Host to listen on. + * @example `host: "0.0.0.0"` or `host: true` (all interfaces) + */ + host?: string | true; + + /** + * Port to listen on (0–65535). + * @example `port: 3000` + */ + port?: number; + + /** + * Disable the dev console overlay. + * @example `console: false` + */ + console?: boolean; + + /** + * Disable the dev error overlay. + * @example `overlay: false` + */ + overlay?: boolean; + + /** + * Additional file types to treat as static assets. + * @example `assetsInclude: ["*.gltf"]` or `assetsInclude: /\.gltf$/` + */ + assetsInclude?: string | RegExp | (string | RegExp)[]; + + /** + * Vite log level. + * @example `logLevel: "info"` + */ + logLevel?: "info" | "warn" | "error" | "silent"; + + /** + * Whether to clear the terminal screen on dev server start. + * @example `clearScreen: false` + */ + clearScreen?: boolean; + + /** + * Dev server configuration (Vite-compatible). + * @example `server: { port: 3000, host: "localhost", https: false }` + */ + server?: ServerConfig; + + /** + * Module resolution configuration. + * @example `resolve: { alias: { "@": "./src" }, shared: ["lodash"] }` + */ + resolve?: ResolveConfig; + + /** + * Build configuration (Vite-compatible). + * @example `build: { chunkSizeWarningLimit: 1024 }` + */ + build?: BuildConfig; + + /** + * SSR configuration (Vite-compatible). + * @example `ssr: { external: ["pg"], noExternal: ["my-ui-lib"] }` + */ + ssr?: SsrConfig; + + /** + * CSS configuration (Vite-compatible). + * @example `css: { modules: { localsConvention: "camelCase" } }` + */ + css?: CssConfig; + + /** + * Dependency optimization configuration (Vite-compatible). + * @example `optimizeDeps: { include: ["lodash"], force: true }` + */ + optimizeDeps?: OptimizeDepsConfig; + + /** + * Cache configuration for server-side caching. + * @example `cache: { profiles: { default: { ttl: 60 } } }` + */ + cache?: CacheConfig; + + /** + * Server functions (RPC) configuration. + * @example `serverFunctions: { secret: "my-secret-key" }` + */ + serverFunctions?: ServerFunctionsConfig; + + /** + * File-router layout configuration. + * @example `layout: { include: ["layout.jsx"] }` + */ + layout?: FileRouterConfigValue; + + /** + * File-router page configuration. + * @example `page: { include: ["page.jsx"] }` + */ + page?: FileRouterConfigValue; + + /** + * File-router middleware configuration. + * @example `middleware: { include: ["middleware.mjs"] }` + */ + middleware?: FileRouterConfigValue; + + /** + * File-router API route configuration. + * @example `api: { include: ["api.mjs"] }` + */ + api?: FileRouterConfigValue; + + /** + * File-router router configuration (applies on top of layout/page/middleware/api). + * @example `router: { include: ["*.page.jsx"] }` + */ + router?: FileRouterConfigValue; + + /** + * MDX processing configuration. + * @example `mdx: { remarkPlugins: [remarkGfm], rehypePlugins: [rehypeHighlight] }` + */ + mdx?: MdxConfig; +} + +/** + * Descriptions for all config properties (key → human description). + * Useful for building documentation, tooltips, or custom tooling. + */ +export declare const DESCRIPTIONS: Record; + +/** + * Generate a JSON Schema for react-server config. + * Suitable for `$schema` in JSON config files. + */ +export declare function generateJsonSchema(): Record; diff --git a/packages/react-server/config/schema.json b/packages/react-server/config/schema.json new file mode 100644 index 00000000..7131cdd7 --- /dev/null +++ b/packages/react-server/config/schema.json @@ -0,0 +1,936 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "react-server configuration", + "description": "Configuration schema for @lazarv/react-server. Covers react-server specific options and Vite-level pass-through configuration.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference (ignored at runtime)." + }, + "root": { + "type": "string", + "description": "Root directory for file-router page discovery." + }, + "base": { + "type": "string", + "description": "Base public path for the application." + }, + "entry": { + "type": "string", + "description": "Entry point for the application (used in non-file-router mode)." + }, + "public": { + "oneOf": [ + { + "type": "string" + }, + { + "const": false + } + ], + "description": "Public directory for static assets, or false to disable. Example: \"public\"" + }, + "name": { + "type": "string", + "description": "Application name." + }, + "adapter": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "object" + } + ], + "minItems": 1, + "maxItems": 2 + } + ], + "description": "Deployment adapter. Known adapters: aws, azure, azure-swa, bun, cloudflare, deno, docker, firebase, netlify, singlefile, vercel. Example: \"vercel\" or [\"cloudflare\", { ... }]" + }, + "plugins": { + "type": "array", + "description": "Vite plugins array or factory function." + }, + "define": { + "type": "object", + "description": "Global constant replacements (passed to Vite define)." + }, + "envDir": { + "oneOf": [ + { + "type": "string" + }, + { + "const": false + } + ], + "description": "Directory to load .env files from, or false to disable." + }, + "envPrefix": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "Env variable prefix(es) to expose to client-side code." + }, + "cacheDir": { + "type": "string", + "description": "Directory for Vite's dependency cache." + }, + "external": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "Packages to externalize from the server bundle." + }, + "sourcemap": { + "oneOf": [ + { + "type": "boolean" + }, + { + "enum": ["inline", "hidden", "server", "server-inline"] + } + ], + "description": "Source map generation strategy: true, false, \"inline\", \"hidden\", \"server\", or \"server-inline\"." + }, + "compression": { + "type": "boolean", + "description": "Enable response compression." + }, + "export": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + } + ] + } + } + ], + "description": "Static export configuration. Provide paths, path descriptors, a function, or boolean." + }, + "prerender": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object" + } + ], + "description": "Enable prerendering. true or { timeout: number }." + }, + "cluster": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "description": "Number of cluster workers, or true for auto-detection." + }, + "cors": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object" + } + ], + "description": "Enable CORS. true or { origin, credentials, ... }." + }, + "vite": { + "type": "object", + "description": "Raw Vite config override (object or function)." + }, + "customLogger": { + "type": "object", + "description": "Custom Vite logger instance." + }, + "logger": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + } + ], + "description": "Logger configuration. String (e.g. \"pino\") or object." + }, + "globalErrorComponent": { + "type": "string", + "description": "Glob pattern for the global error component." + }, + "handlers": { + "oneOf": [ + { + "type": "array" + }, + { + "type": "object", + "properties": { + "pre": { + "type": "array" + }, + "post": { + "type": "array" + } + }, + "additionalProperties": false + } + ], + "description": "HTTP request handlers (middleware). Array, function, or { pre, post }." + }, + "importMap": { + "type": "object", + "properties": { + "imports": { + "type": "object" + } + }, + "additionalProperties": false, + "description": "Import map for bare specifier resolution in the browser." + }, + "inspect": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object" + } + ], + "description": "Enable vite-plugin-inspect for debugging." + }, + "runtime": { + "type": "object", + "description": "Runtime configuration passed to the server." + }, + "cookies": { + "type": "object", + "description": "Cookie options for the session." + }, + "host": { + "oneOf": [ + { + "type": "string" + }, + { + "const": true + } + ], + "description": "Host to listen on. Example: \"0.0.0.0\" or true (all interfaces)." + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "Port to listen on (0–65535)." + }, + "console": { + "type": "boolean", + "description": "Disable the dev console overlay." + }, + "overlay": { + "type": "boolean", + "description": "Disable the dev error overlay." + }, + "assetsInclude": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "Additional file types to treat as static assets. String, RegExp, or array." + }, + "logLevel": { + "enum": ["info", "warn", "error", "silent"], + "description": "Vite log level: \"info\" | \"warn\" | \"error\" | \"silent\"." + }, + "clearScreen": { + "type": "boolean", + "description": "Whether to clear the terminal screen on dev server start." + }, + "server": { + "type": "object", + "properties": { + "host": { + "oneOf": [ + { + "type": "string" + }, + { + "const": true + } + ], + "description": "Specify which IP addresses the server should listen on." + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "Specify server port." + }, + "https": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object" + } + ], + "description": "Enable HTTPS / TLS." + }, + "cors": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object" + } + ], + "description": "Configure CORS for the dev server." + }, + "open": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ], + "description": "Open the app in the browser on server start." + }, + "hmr": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object" + } + ], + "description": "Configure HMR connection. Set to false to disable HMR." + }, + "fs": { + "type": "object", + "properties": { + "allow": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Directories allowed to be served." + }, + "deny": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Directories denied from being served." + }, + "strict": { + "type": "boolean", + "description": "Enable strict file serving mode." + } + }, + "additionalProperties": false, + "description": "File system serving restrictions." + }, + "watch": { + "type": "object", + "description": "File watcher options (passed to chokidar)." + }, + "origin": { + "type": "string", + "description": "Define the origin of the generated asset URLs during development." + }, + "proxy": { + "type": "object", + "description": "Custom proxy rules for the dev server." + }, + "trustProxy": { + "type": "boolean", + "description": "Trust the X-Forwarded-* headers from reverse proxies." + }, + "headers": { + "type": "object", + "description": "Custom response headers for the dev server." + }, + "warmup": { + "type": "object", + "description": "Warm up files to pre-transform on server start." + } + }, + "additionalProperties": false, + "description": "Dev server configuration (Vite-compatible)." + }, + "resolve": { + "type": "object", + "properties": { + "alias": { + "oneOf": [ + { + "type": "object" + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "find": { + "type": "string" + }, + "replacement": { + "type": "string" + } + }, + "required": ["find", "replacement"] + } + } + ], + "description": "Import alias mapping." + }, + "dedupe": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Dependencies to force-deduplicate." + }, + "noExternal": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + } + ], + "description": "Packages to bundle instead of externalizing during SSR." + }, + "shared": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Shared dependencies between server and client bundles." + }, + "external": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "External dependencies for SSR." + }, + "builtins": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Built-in modules that should not be bundled." + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Custom conditions for package exports resolution." + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "File extensions to try when resolving imports." + }, + "mainFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fields in package.json to try when resolving entry points." + } + }, + "additionalProperties": false, + "description": "Module resolution configuration." + }, + "build": { + "type": "object", + "properties": { + "target": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "Browser compatibility target." + }, + "outDir": { + "type": "string", + "description": "Output directory (relative to project root)." + }, + "assetsDir": { + "type": "string", + "description": "Directory for assets inside outDir." + }, + "minify": { + "oneOf": [ + { + "type": "boolean" + }, + { + "enum": ["terser", "esbuild"] + } + ], + "description": "Minification strategy." + }, + "cssMinify": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ], + "description": "CSS minification (uses minify value by default)." + }, + "cssCodeSplit": { + "type": "boolean", + "description": "Enable CSS code splitting." + }, + "assetsInlineLimit": { + "type": "number", + "description": "Threshold (in bytes) for inlining assets as base64." + }, + "reportCompressedSize": { + "type": "boolean", + "description": "Show compressed size of build output." + }, + "copyPublicDir": { + "type": "boolean", + "description": "Copy public directory to outDir on build." + }, + "modulePreload": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object" + } + ], + "description": "Module preload configuration." + }, + "chunkSizeWarningLimit": { + "type": "number", + "description": "Warn when a chunk exceeds this size (in kB)." + }, + "lib": { + "type": "boolean", + "description": "Build in library mode." + }, + "rollupOptions": { + "type": "object", + "properties": { + "external": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "output": { + "type": "object" + }, + "plugins": { + "type": "array" + }, + "input": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "checks": { + "type": "object" + }, + "treeshake": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object" + } + ] + } + }, + "additionalProperties": false, + "description": "Rollup-specific build options." + }, + "rolldownOptions": { + "type": "object", + "properties": { + "external": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "output": { + "type": "object" + }, + "plugins": { + "type": "array" + }, + "input": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "checks": { + "type": "object" + }, + "treeshake": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object" + } + ] + } + }, + "additionalProperties": false, + "description": "Rolldown-specific build options." + }, + "server": { + "type": "object", + "properties": { + "config": { + "type": "object" + } + }, + "additionalProperties": false, + "description": "Custom Vite config for the server build." + }, + "client": { + "type": "object", + "properties": { + "config": { + "type": "object" + } + }, + "additionalProperties": false, + "description": "Custom Vite config for the client build." + } + }, + "additionalProperties": false, + "description": "Build configuration (Vite-compatible)." + }, + "ssr": { + "type": "object", + "properties": { + "external": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + } + ], + "description": "Force externalize these dependencies during SSR." + }, + "noExternal": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + } + ], + "description": "Force bundle these dependencies during SSR." + }, + "resolve": { + "type": "object", + "description": "SSR resolve options." + }, + "worker": { + "type": "boolean", + "description": "Run SSR in a web worker." + } + }, + "additionalProperties": false, + "description": "SSR configuration (Vite-compatible)." + }, + "css": { + "type": "object", + "properties": { + "modules": { + "type": "object", + "description": "CSS Modules configuration." + }, + "preprocessorOptions": { + "type": "object", + "description": "Options for CSS preprocessors." + }, + "postcss": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + } + ], + "description": "PostCSS config (inline or path to config file)." + }, + "devSourcemap": { + "type": "boolean", + "description": "Enable sourcemaps during dev for CSS." + } + }, + "additionalProperties": false, + "description": "CSS configuration (Vite-compatible)." + }, + "optimizeDeps": { + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Dependencies to force-include in pre-bundling." + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Dependencies to exclude from pre-bundling." + }, + "force": { + "type": "boolean", + "description": "Force re-optimization on every dev server start." + }, + "rolldownOptions": { + "type": "object", + "description": "Rolldown options for dependency optimization." + }, + "esbuildOptions": { + "type": "object", + "description": "esbuild options for dependency optimization." + } + }, + "additionalProperties": false, + "description": "Dependency optimization configuration (Vite-compatible)." + }, + "cache": { + "type": "object", + "properties": { + "profiles": { + "oneOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ], + "description": "Cache profiles (TTL, key strategies, etc.)." + }, + "providers": { + "oneOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ], + "description": "Cache storage providers." + } + }, + "additionalProperties": false, + "description": "Cache configuration for server-side caching." + }, + "serverFunctions": { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "Secret key for signing server function calls." + }, + "secretFile": { + "type": "string", + "description": "Path to a file containing the secret key." + }, + "previousSecrets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Previously used secrets for key rotation." + }, + "previousSecretFiles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Previously used secret files for key rotation." + } + }, + "additionalProperties": false, + "description": "Server functions (RPC) configuration." + }, + "layout": { + "type": "object", + "description": "File-router layout configuration." + }, + "page": { + "type": "object", + "description": "File-router page configuration." + }, + "middleware": { + "type": "object", + "description": "File-router middleware configuration." + }, + "api": { + "type": "object", + "description": "File-router API route configuration." + }, + "router": { + "type": "object", + "description": "File-router router configuration (applies on top of layout/page/middleware/api)." + }, + "mdx": { + "type": "object", + "properties": { + "remarkPlugins": { + "type": "array", + "description": "Remark plugins for MDX processing." + }, + "rehypePlugins": { + "type": "array", + "description": "Rehype plugins for MDX processing." + }, + "components": { + "type": "string", + "description": "Path to the MDX components file." + } + }, + "additionalProperties": false, + "description": "MDX processing configuration." + } + }, + "additionalProperties": false +} diff --git a/packages/react-server/config/schema.mjs b/packages/react-server/config/schema.mjs new file mode 100644 index 00000000..d3fcb552 --- /dev/null +++ b/packages/react-server/config/schema.mjs @@ -0,0 +1,710 @@ +/** + * Schema metadata and JSON Schema generator for @lazarv/react-server config. + * + * This module provides: + * - `DESCRIPTIONS` — human-readable descriptions for every config property + * - `generateJsonSchema()` — produces a JSON Schema for react-server.config.json + * + * The JSON Schema enables IDE autocomplete and validation in JSON config files + * when referenced via `"$schema": "node_modules/@lazarv/react-server/config/schema.json"`. + */ + +// ───── Descriptions ───── + +/** + * Human-readable descriptions for every config property. + * Keys use dot-notation for nested properties (e.g. "server.port"). + */ +export const DESCRIPTIONS = { + root: "Root directory for file-router page discovery.", + base: "Base public path for the application.", + entry: "Entry point for the application (used in non-file-router mode).", + public: + 'Public directory for static assets, or false to disable. Example: "public"', + name: "Application name.", + adapter: + 'Deployment adapter. Known adapters: aws, azure, azure-swa, bun, cloudflare, deno, docker, firebase, netlify, singlefile, vercel. Example: "vercel" or ["cloudflare", { ... }]', + plugins: "Vite plugins array or factory function.", + define: "Global constant replacements (passed to Vite define).", + envDir: "Directory to load .env files from, or false to disable.", + envPrefix: "Env variable prefix(es) to expose to client-side code.", + cacheDir: "Directory for Vite's dependency cache.", + external: "Packages to externalize from the server bundle.", + sourcemap: + 'Source map generation strategy: true, false, "inline", "hidden", "server", or "server-inline".', + compression: "Enable response compression.", + export: + "Static export configuration. Provide paths, path descriptors, a function, or boolean.", + prerender: "Enable prerendering. true or { timeout: number }.", + cluster: "Number of cluster workers, or true for auto-detection.", + cors: "Enable CORS. true or { origin, credentials, ... }.", + vite: "Raw Vite config override (object or function).", + customLogger: "Custom Vite logger instance.", + logger: 'Logger configuration. String (e.g. "pino") or object.', + globalErrorComponent: "Glob pattern for the global error component.", + handlers: + "HTTP request handlers (middleware). Array, function, or { pre, post }.", + importMap: "Import map for bare specifier resolution in the browser.", + inspect: "Enable vite-plugin-inspect for debugging.", + runtime: "Runtime configuration passed to the server.", + cookies: "Cookie options for the session.", + host: 'Host to listen on. Example: "0.0.0.0" or true (all interfaces).', + port: "Port to listen on (0–65535).", + console: "Disable the dev console overlay.", + overlay: "Disable the dev error overlay.", + assetsInclude: + "Additional file types to treat as static assets. String, RegExp, or array.", + logLevel: 'Vite log level: "info" | "warn" | "error" | "silent".', + clearScreen: "Whether to clear the terminal screen on dev server start.", + + // server.* + server: "Dev server configuration (Vite-compatible).", + "server.host": "Specify which IP addresses the server should listen on.", + "server.port": "Specify server port.", + "server.https": "Enable HTTPS / TLS.", + "server.cors": "Configure CORS for the dev server.", + "server.open": "Open the app in the browser on server start.", + "server.hmr": "Configure HMR connection. Set to false to disable HMR.", + "server.fs": "File system serving restrictions.", + "server.fs.allow": "Directories allowed to be served.", + "server.fs.deny": "Directories denied from being served.", + "server.fs.strict": "Enable strict file serving mode.", + "server.watch": "File watcher options (passed to chokidar).", + "server.origin": + "Define the origin of the generated asset URLs during development.", + "server.proxy": "Custom proxy rules for the dev server.", + "server.trustProxy": "Trust the X-Forwarded-* headers from reverse proxies.", + "server.headers": "Custom response headers for the dev server.", + "server.warmup": "Warm up files to pre-transform on server start.", + + // resolve.* + resolve: "Module resolution configuration.", + "resolve.alias": "Import alias mapping.", + "resolve.dedupe": "Dependencies to force-deduplicate.", + "resolve.noExternal": + "Packages to bundle instead of externalizing during SSR.", + "resolve.shared": "Shared dependencies between server and client bundles.", + "resolve.external": "External dependencies for SSR.", + "resolve.builtins": "Built-in modules that should not be bundled.", + "resolve.conditions": "Custom conditions for package exports resolution.", + "resolve.extensions": "File extensions to try when resolving imports.", + "resolve.mainFields": + "Fields in package.json to try when resolving entry points.", + + // build.* + build: "Build configuration (Vite-compatible).", + "build.target": "Browser compatibility target.", + "build.outDir": "Output directory (relative to project root).", + "build.assetsDir": "Directory for assets inside outDir.", + "build.minify": "Minification strategy.", + "build.cssMinify": "CSS minification (uses minify value by default).", + "build.cssCodeSplit": "Enable CSS code splitting.", + "build.assetsInlineLimit": + "Threshold (in bytes) for inlining assets as base64.", + "build.reportCompressedSize": "Show compressed size of build output.", + "build.copyPublicDir": "Copy public directory to outDir on build.", + "build.modulePreload": "Module preload configuration.", + "build.chunkSizeWarningLimit": "Warn when a chunk exceeds this size (in kB).", + "build.lib": "Build in library mode.", + "build.rollupOptions": "Rollup-specific build options.", + "build.rolldownOptions": "Rolldown-specific build options.", + "build.server": "Custom Vite config for the server build.", + "build.client": "Custom Vite config for the client build.", + + // ssr.* + ssr: "SSR configuration (Vite-compatible).", + "ssr.external": "Force externalize these dependencies during SSR.", + "ssr.noExternal": "Force bundle these dependencies during SSR.", + "ssr.resolve": "SSR resolve options.", + "ssr.worker": "Run SSR in a web worker.", + + // css.* + css: "CSS configuration (Vite-compatible).", + "css.modules": "CSS Modules configuration.", + "css.preprocessorOptions": "Options for CSS preprocessors.", + "css.postcss": "PostCSS config (inline or path to config file).", + "css.devSourcemap": "Enable sourcemaps during dev for CSS.", + + // optimizeDeps.* + optimizeDeps: "Dependency optimization configuration (Vite-compatible).", + "optimizeDeps.include": "Dependencies to force-include in pre-bundling.", + "optimizeDeps.exclude": "Dependencies to exclude from pre-bundling.", + "optimizeDeps.force": "Force re-optimization on every dev server start.", + "optimizeDeps.rolldownOptions": + "Rolldown options for dependency optimization.", + "optimizeDeps.esbuildOptions": "esbuild options for dependency optimization.", + + // cache.* + cache: "Cache configuration for server-side caching.", + "cache.profiles": "Cache profiles (TTL, key strategies, etc.).", + "cache.providers": "Cache storage providers.", + + // serverFunctions.* + serverFunctions: "Server functions (RPC) configuration.", + "serverFunctions.secret": "Secret key for signing server function calls.", + "serverFunctions.secretFile": "Path to a file containing the secret key.", + "serverFunctions.previousSecrets": + "Previously used secrets for key rotation.", + "serverFunctions.previousSecretFiles": + "Previously used secret files for key rotation.", + + // file-router child config + layout: "File-router layout configuration.", + page: "File-router page configuration.", + middleware: "File-router middleware configuration.", + api: "File-router API route configuration.", + router: + "File-router router configuration (applies on top of layout/page/middleware/api).", + + // mdx.* + mdx: "MDX processing configuration.", + "mdx.remarkPlugins": "Remark plugins for MDX processing.", + "mdx.rehypePlugins": "Rehype plugins for MDX processing.", + "mdx.components": "Path to the MDX components file.", +}; + +// ───── JSON Schema generator ───── + +/** Helper: create a JSON Schema property with description. */ +function prop(schema, key) { + const desc = DESCRIPTIONS[key]; + return desc ? { ...schema, description: desc } : schema; +} + +/** Build the rollup/rolldown options sub-schema. */ +function rollupOptionsSchema(prefix) { + return prop( + { + type: "object", + properties: { + external: prop( + { + oneOf: [ + { type: "array", items: { type: "string" } }, + { type: "string" }, + ], + }, + `${prefix}.external` + ), + output: prop({ type: "object" }, `${prefix}.output`), + plugins: prop({ type: "array" }, `${prefix}.plugins`), + input: prop( + { + oneOf: [ + { type: "string" }, + { type: "object" }, + { type: "array", items: { type: "string" } }, + ], + }, + `${prefix}.input` + ), + checks: { type: "object" }, + treeshake: { + oneOf: [{ type: "boolean" }, { type: "object" }], + }, + }, + additionalProperties: false, + }, + prefix + ); +} + +/** + * Generate a JSON Schema (draft-07) for `react-server.config.json`. + * + * Suitable for use as: + * ```json + * { "$schema": "node_modules/@lazarv/react-server/config/schema.json" } + * ``` + * + * @returns {Record} JSON Schema object + */ +export function generateJsonSchema() { + return { + $schema: "http://json-schema.org/draft-07/schema#", + title: "react-server configuration", + description: + "Configuration schema for @lazarv/react-server. Covers react-server specific options and Vite-level pass-through configuration.", + type: "object", + properties: { + $schema: { + type: "string", + description: "JSON Schema reference (ignored at runtime).", + }, + + // ── React-server specific ── + root: prop({ type: "string" }, "root"), + base: prop({ type: "string" }, "base"), + entry: prop({ type: "string" }, "entry"), + public: prop({ oneOf: [{ type: "string" }, { const: false }] }, "public"), + name: prop({ type: "string" }, "name"), + adapter: prop( + { + oneOf: [ + { type: "string" }, + { + type: "array", + items: [{ type: "string" }, { type: "object" }], + minItems: 1, + maxItems: 2, + }, + ], + }, + "adapter" + ), + plugins: prop({ type: "array" }, "plugins"), + define: prop({ type: "object" }, "define"), + envDir: prop({ oneOf: [{ type: "string" }, { const: false }] }, "envDir"), + envPrefix: prop( + { + oneOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], + }, + "envPrefix" + ), + cacheDir: prop({ type: "string" }, "cacheDir"), + external: prop( + { + oneOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], + }, + "external" + ), + sourcemap: prop( + { + oneOf: [ + { type: "boolean" }, + { enum: ["inline", "hidden", "server", "server-inline"] }, + ], + }, + "sourcemap" + ), + compression: prop({ type: "boolean" }, "compression"), + export: prop( + { + oneOf: [ + { type: "boolean" }, + { + type: "array", + items: { oneOf: [{ type: "string" }, { type: "object" }] }, + }, + ], + }, + "export" + ), + prerender: prop( + { oneOf: [{ type: "boolean" }, { type: "object" }] }, + "prerender" + ), + cluster: prop( + { oneOf: [{ type: "number" }, { type: "boolean" }] }, + "cluster" + ), + cors: prop({ oneOf: [{ type: "boolean" }, { type: "object" }] }, "cors"), + vite: prop({ type: "object" }, "vite"), + customLogger: prop({ type: "object" }, "customLogger"), + logger: prop( + { oneOf: [{ type: "string" }, { type: "object" }] }, + "logger" + ), + globalErrorComponent: prop({ type: "string" }, "globalErrorComponent"), + handlers: prop( + { + oneOf: [ + { type: "array" }, + { + type: "object", + properties: { + pre: { type: "array" }, + post: { type: "array" }, + }, + additionalProperties: false, + }, + ], + }, + "handlers" + ), + importMap: prop( + { + type: "object", + properties: { + imports: { type: "object" }, + }, + additionalProperties: false, + }, + "importMap" + ), + inspect: prop( + { oneOf: [{ type: "boolean" }, { type: "object" }] }, + "inspect" + ), + runtime: prop({ type: "object" }, "runtime"), + cookies: prop({ type: "object" }, "cookies"), + host: prop({ oneOf: [{ type: "string" }, { const: true }] }, "host"), + port: prop({ type: "integer", minimum: 0, maximum: 65535 }, "port"), + console: prop({ type: "boolean" }, "console"), + overlay: prop({ type: "boolean" }, "overlay"), + assetsInclude: prop( + { + oneOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], + }, + "assetsInclude" + ), + logLevel: prop({ enum: ["info", "warn", "error", "silent"] }, "logLevel"), + clearScreen: prop({ type: "boolean" }, "clearScreen"), + + // ── server.* ── + server: prop( + { + type: "object", + properties: { + host: prop( + { oneOf: [{ type: "string" }, { const: true }] }, + "server.host" + ), + port: prop( + { type: "integer", minimum: 0, maximum: 65535 }, + "server.port" + ), + https: prop( + { oneOf: [{ type: "boolean" }, { type: "object" }] }, + "server.https" + ), + cors: prop( + { oneOf: [{ type: "boolean" }, { type: "object" }] }, + "server.cors" + ), + open: prop( + { oneOf: [{ type: "boolean" }, { type: "string" }] }, + "server.open" + ), + hmr: prop( + { oneOf: [{ type: "boolean" }, { type: "object" }] }, + "server.hmr" + ), + fs: prop( + { + type: "object", + properties: { + allow: prop( + { type: "array", items: { type: "string" } }, + "server.fs.allow" + ), + deny: prop( + { type: "array", items: { type: "string" } }, + "server.fs.deny" + ), + strict: prop({ type: "boolean" }, "server.fs.strict"), + }, + additionalProperties: false, + }, + "server.fs" + ), + watch: prop({ type: "object" }, "server.watch"), + origin: prop({ type: "string" }, "server.origin"), + proxy: prop({ type: "object" }, "server.proxy"), + trustProxy: prop({ type: "boolean" }, "server.trustProxy"), + headers: prop({ type: "object" }, "server.headers"), + warmup: prop({ type: "object" }, "server.warmup"), + }, + additionalProperties: false, + }, + "server" + ), + + // ── resolve.* ── + resolve: prop( + { + type: "object", + properties: { + alias: prop( + { + oneOf: [ + { type: "object" }, + { + type: "array", + items: { + type: "object", + properties: { + find: { type: "string" }, + replacement: { type: "string" }, + }, + required: ["find", "replacement"], + }, + }, + ], + }, + "resolve.alias" + ), + dedupe: prop( + { type: "array", items: { type: "string" } }, + "resolve.dedupe" + ), + noExternal: prop( + { + oneOf: [ + { type: "array", items: { type: "string" } }, + { type: "boolean" }, + ], + }, + "resolve.noExternal" + ), + shared: prop( + { type: "array", items: { type: "string" } }, + "resolve.shared" + ), + external: prop( + { + oneOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], + }, + "resolve.external" + ), + builtins: prop( + { type: "array", items: { type: "string" } }, + "resolve.builtins" + ), + conditions: prop( + { type: "array", items: { type: "string" } }, + "resolve.conditions" + ), + extensions: prop( + { type: "array", items: { type: "string" } }, + "resolve.extensions" + ), + mainFields: prop( + { type: "array", items: { type: "string" } }, + "resolve.mainFields" + ), + }, + additionalProperties: false, + }, + "resolve" + ), + + // ── build.* ── + build: prop( + { + type: "object", + properties: { + target: prop( + { + oneOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], + }, + "build.target" + ), + outDir: prop({ type: "string" }, "build.outDir"), + assetsDir: prop({ type: "string" }, "build.assetsDir"), + minify: prop( + { + oneOf: [{ type: "boolean" }, { enum: ["terser", "esbuild"] }], + }, + "build.minify" + ), + cssMinify: prop( + { oneOf: [{ type: "boolean" }, { type: "string" }] }, + "build.cssMinify" + ), + cssCodeSplit: prop({ type: "boolean" }, "build.cssCodeSplit"), + assetsInlineLimit: prop( + { type: "number" }, + "build.assetsInlineLimit" + ), + reportCompressedSize: prop( + { type: "boolean" }, + "build.reportCompressedSize" + ), + copyPublicDir: prop({ type: "boolean" }, "build.copyPublicDir"), + modulePreload: prop( + { oneOf: [{ type: "boolean" }, { type: "object" }] }, + "build.modulePreload" + ), + chunkSizeWarningLimit: prop( + { type: "number" }, + "build.chunkSizeWarningLimit" + ), + lib: prop({ type: "boolean" }, "build.lib"), + rollupOptions: rollupOptionsSchema("build.rollupOptions"), + rolldownOptions: rollupOptionsSchema("build.rolldownOptions"), + server: prop( + { + type: "object", + properties: { + config: { type: "object" }, + }, + additionalProperties: false, + }, + "build.server" + ), + client: prop( + { + type: "object", + properties: { + config: { type: "object" }, + }, + additionalProperties: false, + }, + "build.client" + ), + }, + additionalProperties: false, + }, + "build" + ), + + // ── ssr.* ── + ssr: prop( + { + type: "object", + properties: { + external: prop( + { + oneOf: [ + { type: "array", items: { type: "string" } }, + { type: "boolean" }, + ], + }, + "ssr.external" + ), + noExternal: prop( + { + oneOf: [ + { type: "array", items: { type: "string" } }, + { type: "boolean" }, + ], + }, + "ssr.noExternal" + ), + resolve: prop({ type: "object" }, "ssr.resolve"), + worker: prop({ type: "boolean" }, "ssr.worker"), + }, + additionalProperties: false, + }, + "ssr" + ), + + // ── css.* ── + css: prop( + { + type: "object", + properties: { + modules: prop({ type: "object" }, "css.modules"), + preprocessorOptions: prop( + { type: "object" }, + "css.preprocessorOptions" + ), + postcss: prop( + { oneOf: [{ type: "string" }, { type: "object" }] }, + "css.postcss" + ), + devSourcemap: prop({ type: "boolean" }, "css.devSourcemap"), + }, + additionalProperties: false, + }, + "css" + ), + + // ── optimizeDeps.* ── + optimizeDeps: prop( + { + type: "object", + properties: { + include: prop( + { type: "array", items: { type: "string" } }, + "optimizeDeps.include" + ), + exclude: prop( + { type: "array", items: { type: "string" } }, + "optimizeDeps.exclude" + ), + force: prop({ type: "boolean" }, "optimizeDeps.force"), + rolldownOptions: prop( + { type: "object" }, + "optimizeDeps.rolldownOptions" + ), + esbuildOptions: prop( + { type: "object" }, + "optimizeDeps.esbuildOptions" + ), + }, + additionalProperties: false, + }, + "optimizeDeps" + ), + + // ── cache.* ── + cache: prop( + { + type: "object", + properties: { + profiles: prop( + { oneOf: [{ type: "object" }, { type: "array" }] }, + "cache.profiles" + ), + providers: prop( + { oneOf: [{ type: "object" }, { type: "array" }] }, + "cache.providers" + ), + }, + additionalProperties: false, + }, + "cache" + ), + + // ── serverFunctions.* ── + serverFunctions: prop( + { + type: "object", + properties: { + secret: prop({ type: "string" }, "serverFunctions.secret"), + secretFile: prop({ type: "string" }, "serverFunctions.secretFile"), + previousSecrets: prop( + { type: "array", items: { type: "string" } }, + "serverFunctions.previousSecrets" + ), + previousSecretFiles: prop( + { type: "array", items: { type: "string" } }, + "serverFunctions.previousSecretFiles" + ), + }, + additionalProperties: false, + }, + "serverFunctions" + ), + + // ── File-router child config ── + layout: prop({ type: "object" }, "layout"), + page: prop({ type: "object" }, "page"), + middleware: prop({ type: "object" }, "middleware"), + api: prop({ type: "object" }, "api"), + router: prop({ type: "object" }, "router"), + + // ── MDX ── + mdx: prop( + { + type: "object", + properties: { + remarkPlugins: prop({ type: "array" }, "mdx.remarkPlugins"), + rehypePlugins: prop({ type: "array" }, "mdx.rehypePlugins"), + components: prop({ type: "string" }, "mdx.components"), + }, + additionalProperties: false, + }, + "mdx" + ), + }, + additionalProperties: false, + }; +} diff --git a/packages/react-server/config/validate.mjs b/packages/react-server/config/validate.mjs new file mode 100644 index 00000000..1603b3f7 --- /dev/null +++ b/packages/react-server/config/validate.mjs @@ -0,0 +1,862 @@ +import colors from "picocolors"; + +// ESC character for ANSI regex (avoids control-character lint warnings) +const ESC = String.fromCharCode(0x1b); +const ANSI_REGEX = new RegExp(`${ESC}\\[[0-9;]*m`, "g"); +const ANSI_RESET = `${ESC}[0m`; + +/** + * Config validation for @lazarv/react-server. + * + * Validates both react-server specific config and Vite-level config fields. + * Returns a list of human-readable validation errors with examples. + */ + +// ───── Primitive type checkers ───── + +const is = { + string: (v) => typeof v === "string", + number: (v) => typeof v === "number" && !Number.isNaN(v), + boolean: (v) => typeof v === "boolean", + function: (v) => typeof v === "function", + object: (v) => v !== null && typeof v === "object" && !Array.isArray(v), + array: (v) => Array.isArray(v), + regexp: (v) => v instanceof RegExp, +}; + +// ───── Schema helpers ───── + +function oneOf(...validators) { + const fn = (v) => validators.some((check) => check(v)); + fn._oneOf = validators; + return fn; +} + +function custom(validator, description) { + const fn = (v) => validator(v); + fn._description = description; + return fn; +} + +function optional(validator) { + const fn = (v) => v === undefined || v === null || validator(v); + fn._optional = true; + fn._inner = validator; + return fn; +} + +function arrayOf(validator) { + const fn = (v) => is.array(v) && v.every((item) => validator(item)); + fn._arrayOf = validator; + return fn; +} + +function objectShape(shape) { + const fn = (v) => is.object(v); + fn._shape = shape; + return fn; +} + +function enumOf(...values) { + const fn = (v) => values.includes(v); + fn._enum = values; + return fn; +} + +// ───── Describe a validator for error messages ───── + +function describeValidator(validator) { + if (!validator) return "unknown"; + if (validator._description) return validator._description; + if (validator._enum) + return validator._enum.map((v) => JSON.stringify(v)).join(" | "); + if (validator._oneOf) + return ( + validator._oneOf + .map(describeValidator) + .filter((d) => d !== "unknown") + .join(" | ") || "unknown" + ); + if (validator._optional) return describeValidator(validator._inner); + if (validator._arrayOf) return `${describeValidator(validator._arrayOf)}[]`; + if (validator._shape) return "object"; + if (validator === is.string) return "string"; + if (validator === is.number) return "number"; + if (validator === is.boolean) return "boolean"; + if (validator === is.function) return "function"; + if (validator === is.object) return "object"; + if (validator === is.array) return "array"; + if (validator === is.regexp) return "RegExp"; + return "unknown"; +} + +// ───── Known adapter names ───── + +const KNOWN_ADAPTERS = [ + "aws", + "azure", + "azure-swa", + "bun", + "cloudflare", + "deno", + "docker", + "firebase", + "netlify", + "singlefile", + "vercel", +]; + +// ───── Schema definition ───── + +const adapterValidator = oneOf( + is.string, + is.function, + custom( + (v) => is.array(v) && v.length >= 1 && v.length <= 2 && is.string(v[0]), + '["adapter-name", options]' + ) +); + +const pluginValidator = oneOf( + is.object, + is.function, + custom( + (v) => is.array(v) && v.length === 2 && is.string(v[0]), + '["plugin-name", options]' + ), + is.array, // nested plugin arrays (Vite PluginOption[]) + is.boolean, // false to disable + custom((v) => v == null, "null") // conditional plugins: condition && plugin() +); + +const aliasValidator = oneOf( + is.object, + custom( + (v) => + is.array(v) && + v.every( + (item) => + is.object(item) && + (is.string(item.find) || is.regexp(item.find)) && + is.string(item.replacement) + ), + "[{ find: string | RegExp, replacement: string }]" + ) +); + +/** + * Top-level react-server config schema. + * Every key is optional because it might not be provided. + */ +const REACT_SERVER_SCHEMA = { + // ── React-server specific ── + root: optional(is.string), + base: optional(is.string), + entry: optional(is.string), + public: optional(oneOf(is.string, (v) => v === false)), + name: optional(is.string), + adapter: optional(adapterValidator), + plugins: optional(oneOf(arrayOf(pluginValidator), is.function)), + define: optional(is.object), + envDir: optional(oneOf(is.string, (v) => v === false)), + envPrefix: optional(oneOf(is.string, arrayOf(is.string))), + cacheDir: optional(is.string), + external: optional(oneOf(arrayOf(is.string), is.string)), + sourcemap: optional( + oneOf(is.boolean, enumOf("inline", "hidden", "server", "server-inline")) + ), + compression: optional(is.boolean), + export: optional( + oneOf(is.boolean, is.function, arrayOf(oneOf(is.string, is.object))) + ), + prerender: optional(oneOf(is.boolean, is.object)), + cluster: optional(oneOf(is.number, is.boolean)), + cors: optional(oneOf(is.boolean, is.object)), + vite: optional(oneOf(is.object, is.function)), + customLogger: optional(is.object), + logger: optional(oneOf(is.string, is.object)), + globalErrorComponent: optional(is.string), + handlers: optional( + oneOf( + is.function, + is.array, + objectShape({ + pre: optional(is.array), + post: optional(is.array), + }) + ) + ), + importMap: optional( + objectShape({ + imports: optional(is.object), + }) + ), + inspect: optional(oneOf(is.boolean, is.object)), + runtime: optional(oneOf(is.function, is.object)), + cookies: optional(is.object), + host: optional(oneOf(is.string, (v) => v === true)), + port: optional(is.number), + + // ── Dev overlay / console ── + console: optional(is.boolean), + overlay: optional(is.boolean), + + // ── Vite top-level pass-through ── + assetsInclude: optional( + oneOf(is.string, is.regexp, arrayOf(oneOf(is.string, is.regexp))) + ), + logLevel: optional(enumOf("info", "warn", "error", "silent")), + clearScreen: optional(is.boolean), + + // ── server.* ── + server: optional( + objectShape({ + host: optional(oneOf(is.string, (v) => v === true)), + port: optional(is.number), + https: optional(oneOf(is.boolean, is.object)), + cors: optional(oneOf(is.boolean, is.object)), + open: optional(oneOf(is.boolean, is.string)), + hmr: optional(oneOf(is.boolean, is.object)), + fs: optional( + objectShape({ + allow: optional(arrayOf(is.string)), + deny: optional(arrayOf(is.string)), + strict: optional(is.boolean), + }) + ), + watch: optional(is.object), + origin: optional(is.string), + proxy: optional(is.object), + trustProxy: optional(is.boolean), + headers: optional(is.object), + warmup: optional(is.object), + }) + ), + + // ── resolve.* ── + resolve: optional( + objectShape({ + alias: optional(aliasValidator), + dedupe: optional(arrayOf(is.string)), + noExternal: optional(oneOf(arrayOf(is.string), is.boolean, is.regexp)), + shared: optional(arrayOf(is.string)), + external: optional( + oneOf(is.regexp, is.string, arrayOf(is.string), is.function) + ), + builtins: optional(arrayOf(is.string)), + conditions: optional(arrayOf(is.string)), + extensions: optional(arrayOf(is.string)), + mainFields: optional(arrayOf(is.string)), + }) + ), + + // ── build.* ── + build: optional( + objectShape({ + target: optional(oneOf(is.string, arrayOf(is.string))), + outDir: optional(is.string), + assetsDir: optional(is.string), + minify: optional(oneOf(is.boolean, enumOf("terser", "esbuild"))), + cssMinify: optional(oneOf(is.boolean, is.string)), + cssCodeSplit: optional(is.boolean), + assetsInlineLimit: optional(oneOf(is.number, is.function)), + reportCompressedSize: optional(is.boolean), + copyPublicDir: optional(is.boolean), + modulePreload: optional(oneOf(is.boolean, is.object)), + chunkSizeWarningLimit: optional(is.number), + lib: optional(is.boolean), + rollupOptions: optional( + objectShape({ + external: optional(oneOf(arrayOf(is.string), is.function, is.regexp)), + output: optional(is.object), + plugins: optional(is.array), + input: optional(oneOf(is.string, is.object, is.array)), + checks: optional(is.object), + treeshake: optional(oneOf(is.boolean, is.object)), + }) + ), + rolldownOptions: optional( + objectShape({ + external: optional(oneOf(arrayOf(is.string), is.function, is.regexp)), + output: optional(is.object), + plugins: optional(is.array), + input: optional(oneOf(is.string, is.object, is.array)), + checks: optional(is.object), + treeshake: optional(oneOf(is.boolean, is.object)), + }) + ), + server: optional( + objectShape({ + config: optional(oneOf(is.object, is.function)), + }) + ), + client: optional( + objectShape({ + config: optional(oneOf(is.object, is.function)), + }) + ), + }) + ), + + // ── ssr.* ── + ssr: optional( + objectShape({ + external: optional(oneOf(arrayOf(is.string), is.boolean)), + noExternal: optional(oneOf(arrayOf(is.string), is.boolean, is.regexp)), + resolve: optional(is.object), + worker: optional(is.boolean), + }) + ), + + // ── css.* ── (passed through to Vite) + css: optional( + objectShape({ + modules: optional(is.object), + preprocessorOptions: optional(is.object), + postcss: optional(oneOf(is.string, is.object)), + devSourcemap: optional(is.boolean), + }) + ), + + // ── optimizeDeps.* ── + optimizeDeps: optional( + objectShape({ + include: optional(arrayOf(is.string)), + exclude: optional(arrayOf(is.string)), + force: optional(is.boolean), + rolldownOptions: optional(is.object), + esbuildOptions: optional(is.object), + }) + ), + + // ── cache.* ── + cache: optional( + objectShape({ + profiles: optional(oneOf(is.object, is.array)), + providers: optional(oneOf(is.object, is.array)), + }) + ), + + // ── serverFunctions.* ── + serverFunctions: optional( + objectShape({ + secret: optional(is.string), + secretFile: optional(is.string), + previousSecrets: optional(arrayOf(is.string)), + previousSecretFiles: optional(arrayOf(is.string)), + }) + ), + + // ── File-router child config ── + layout: optional(oneOf(is.object, is.function)), + page: optional(oneOf(is.object, is.function)), + middleware: optional(oneOf(is.object, is.function)), + api: optional(oneOf(is.object, is.function)), + router: optional(oneOf(is.object, is.function)), + + // ── MDX ── + mdx: optional( + objectShape({ + remarkPlugins: optional(is.array), + rehypePlugins: optional(is.array), + components: optional(is.string), + }) + ), +}; + +// ───── Examples for common config keys ───── + +const EXAMPLES = { + root: `root: "src/pages"`, + base: `base: "/my-app/"`, + entry: `entry: "./src/App.jsx"`, + public: `public: "public" // or false to disable`, + name: `name: "my-app"`, + adapter: `adapter: "vercel" // or ["cloudflare", { ... }]`, + plugins: `plugins: [myVitePlugin()]`, + define: `define: { "process.env.MY_VAR": JSON.stringify("value") }`, + envDir: `envDir: "./env" // or false to disable`, + envPrefix: `envPrefix: "MY_APP_" // or ["MY_APP_", "VITE_"]`, + cacheDir: `cacheDir: "node_modules/.cache"`, + external: `external: ["some-native-module"]`, + sourcemap: `sourcemap: true // or "inline" | "hidden" | "server" | "server-inline"`, + compression: `compression: true`, + export: `export: ["/", "/about"] // or [{ path: "/" }] | true | function`, + prerender: `prerender: true // or { timeout: 30000 }`, + cluster: `cluster: 4 // number of workers, or true for auto`, + cors: `cors: true // or { origin: "*", credentials: true }`, + vite: `vite: { /* raw Vite config */ } // or (config) => config`, + customLogger: `customLogger: myCustomLogger`, + logger: `logger: "pino" // or { level: "info" }`, + globalErrorComponent: `globalErrorComponent: "**/ErrorBoundary.{jsx,tsx}"`, + handlers: `handlers: [myMiddleware] // or { pre: [...], post: [...] }`, + importMap: `importMap: { imports: { "lodash": "/vendor/lodash.js" } }`, + inspect: `inspect: true // enables vite-plugin-inspect`, + runtime: `runtime: async () => ({ key: "value" })`, + cookies: `cookies: { secure: true, sameSite: "lax" }`, + host: `host: "0.0.0.0" // or true for all interfaces`, + port: `port: 3000`, + console: `console: false // disable dev console overlay`, + overlay: `overlay: false // disable dev error overlay`, + assetsInclude: `assetsInclude: ["**/*.gltf"] // or string | RegExp`, + logLevel: `logLevel: "info" // "info" | "warn" | "error" | "silent"`, + clearScreen: `clearScreen: false`, + server: `server: { port: 3000, host: "localhost", https: false }`, + "server.host": `server: { host: "0.0.0.0" }`, + "server.port": `server: { port: 8080 }`, + "server.https": `server: { https: true } // or { key: "...", cert: "..." }`, + "server.cors": `server: { cors: true }`, + "server.open": `server: { open: true } // or "/specific-page"`, + "server.hmr": `server: { hmr: { port: 24678 } } // or false to disable`, + "server.fs": `server: { fs: { allow: [".."] } }`, + "server.watch": `server: { watch: { usePolling: true } }`, + "server.origin": `server: { origin: "https://example.com" }`, + "server.proxy": `server: { proxy: { "/api": "http://localhost:4000" } }`, + "server.trustProxy": `server: { trustProxy: true }`, + "server.headers": `server: { headers: { "X-Custom": "value" } }`, + "server.warmup": `server: { warmup: { clientFiles: ["./src/main.ts"] } }`, + resolve: `resolve: { alias: { "@": "./src" }, shared: ["lodash"] }`, + "resolve.alias": `resolve: { alias: { "@": "./src" } } // or [{ find: "@", replacement: "./src" }]`, + "resolve.dedupe": `resolve: { dedupe: ["react", "react-dom"] }`, + "resolve.noExternal": `resolve: { noExternal: ["my-package"] }`, + "resolve.shared": `resolve: { shared: ["shared-utils"] }`, + "resolve.external": `resolve: { external: /^node:/ } // or ["fs", "path"]`, + "resolve.builtins": `resolve: { builtins: ["my-builtin"] }`, + "resolve.conditions": `resolve: { conditions: ["worker", "browser"] }`, + "resolve.extensions": `resolve: { extensions: [".mjs", ".js", ".ts"] }`, + "resolve.mainFields": `resolve: { mainFields: ["module", "main"] }`, + build: `build: { chunkSizeWarningLimit: 1024, rollupOptions: { ... } }`, + "build.target": `build: { target: "esnext" } // or ["es2020", "edge88"]`, + "build.outDir": `build: { outDir: "dist" }`, + "build.assetsDir": `build: { assetsDir: "assets" }`, + "build.minify": `build: { minify: true } // or "terser" | "esbuild"`, + "build.cssMinify": `build: { cssMinify: true }`, + "build.cssCodeSplit": `build: { cssCodeSplit: true }`, + "build.chunkSizeWarningLimit": `build: { chunkSizeWarningLimit: 2048 }`, + "build.lib": `build: { lib: true }`, + "build.rollupOptions": `build: { rollupOptions: { external: ["lodash"] } }`, + "build.rolldownOptions": `build: { rolldownOptions: { output: { minify: true } } }`, + "build.server": `build: { server: { config: { /* Vite config for server build */ } } }`, + "build.client": `build: { client: { config: { /* Vite config for client build */ } } }`, + ssr: `ssr: { external: ["pg"], noExternal: ["my-ui-lib"] }`, + "ssr.external": `ssr: { external: ["pg", "mysql2"] }`, + "ssr.noExternal": `ssr: { noExternal: ["my-ui-lib"] }`, + "ssr.resolve": `ssr: { resolve: { conditions: ["worker"] } }`, + "ssr.worker": `ssr: { worker: false }`, + css: `css: { modules: { localsConvention: "camelCase" } }`, + "css.modules": `css: { modules: { localsConvention: "camelCase" } }`, + "css.preprocessorOptions": `css: { preprocessorOptions: { scss: { additionalData: '...' } } }`, + "css.postcss": `css: { postcss: "./postcss.config.js" }`, + "css.devSourcemap": `css: { devSourcemap: true }`, + optimizeDeps: `optimizeDeps: { include: ["lodash"], force: true }`, + "optimizeDeps.include": `optimizeDeps: { include: ["lodash"] }`, + "optimizeDeps.exclude": `optimizeDeps: { exclude: ["large-dep"] }`, + "optimizeDeps.force": `optimizeDeps: { force: true }`, + cache: `cache: { profiles: { ... }, providers: { ... } }`, + "cache.profiles": `cache: { profiles: { default: { ttl: 60 } } }`, + "cache.providers": `cache: { providers: { memory: { ... } } }`, + serverFunctions: `serverFunctions: { secret: "my-secret-key" }`, + "serverFunctions.secret": `serverFunctions: { secret: "my-secret-key" }`, + "serverFunctions.secretFile": `serverFunctions: { secretFile: "./secret.pem" }`, + mdx: `mdx: { remarkPlugins: [...], rehypePlugins: [...] }`, + "mdx.remarkPlugins": `mdx: { remarkPlugins: [remarkGfm] }`, + "mdx.rehypePlugins": `mdx: { rehypePlugins: [rehypeHighlight] }`, + "mdx.components": `mdx: { components: "./mdx-components.jsx" }`, + layout: `layout: { /* file-router layout config */ }`, + page: `page: { /* file-router page config */ }`, + middleware: `middleware: { /* file-router middleware config */ }`, + api: `api: { /* file-router api config */ }`, + router: `router: { /* applies on top of layout/page/middleware/api */ }`, +}; + +// ───── Did-you-mean helper ───── + +function levenshtein(a, b) { + const m = a.length; + const n = b.length; + const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = Math.min( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1) + ); + } + } + return dp[m][n]; +} + +function findSimilar(input, candidates, maxDistance = 3) { + const lower = input.toLowerCase(); + let best = null; + let bestDist = Infinity; + for (const candidate of candidates) { + const dist = levenshtein(lower, candidate.toLowerCase()); + if (dist < bestDist && dist <= maxDistance) { + bestDist = dist; + best = candidate; + } + } + return best; +} + +// ───── Validation engine ───── + +/** + * @typedef {Object} ValidationError + * @property {string} path - Dot-separated config path (e.g. "server.port") + * @property {string} message - Human-readable error message + * @property {*} value - The invalid value provided + * @property {string} expected - Description of expected type + * @property {string|undefined} example - Example of valid config + */ + +/** + * Recursively validate a config object against a schema. + * @param {Record} config + * @param {Record} schema + * @param {string} prefix - Current path prefix for nested keys + * @returns {ValidationError[]} + */ +function validateObject(config, schema, prefix = "") { + const errors = []; + if (!is.object(config)) return errors; + + for (const [key, value] of Object.entries(config)) { + // Skip symbol keys (like CONFIG_ROOT, CONFIG_PARENT) + if (typeof key === "symbol") continue; + + const path = prefix ? `${prefix}.${key}` : key; + const validator = schema[key]; + + if (!validator) { + // Unknown key – find similar keys as suggestions + const suggestion = findSimilar(key, Object.keys(schema)); + errors.push({ + path, + message: `Unknown config option "${path}"`, + value, + expected: suggestion + ? `Did you mean "${suggestion}"?` + : `See docs for valid config options`, + example: suggestion + ? (EXAMPLES[prefix ? `${prefix}.${suggestion}` : suggestion] ?? + EXAMPLES[suggestion]) + : undefined, + type: "unknown", + }); + continue; + } + + if (value === undefined) continue; + + // Get the inner validator (unwrap optional) + let innerValidator = validator; + while (innerValidator._optional) { + innerValidator = innerValidator._inner; + } + + // Type check + if (!validator(value)) { + errors.push({ + path, + message: `Invalid value for "${path}"`, + value, + expected: describeValidator(innerValidator), + example: EXAMPLES[path], + type: "invalid", + }); + continue; + } + + // Recurse into nested shapes + if (innerValidator._shape && is.object(value)) { + errors.push(...validateObject(value, innerValidator._shape, path)); + } + + // Specific extra validations + if ( + key === "adapter" && + is.string(value) && + !KNOWN_ADAPTERS.includes(value) + ) { + // Not a hard error — it could be a third-party adapter module path + // but we can warn + errors.push({ + path, + message: `Unknown adapter "${value}". If this is a third-party adapter, you can ignore this warning.`, + value, + expected: `one of: ${KNOWN_ADAPTERS.join(", ")} (or a package name)`, + example: EXAMPLES.adapter, + type: "warning", + }); + } + + if ( + key === "port" && + is.number(value) && + (value < 0 || value > 65535 || !Number.isInteger(value)) + ) { + errors.push({ + path, + message: `Port must be an integer between 0 and 65535`, + value, + expected: "integer (0–65535)", + example: EXAMPLES[path] ?? EXAMPLES.port, + type: "invalid", + }); + } + } + + return errors; +} + +// ───── Formatting ───── + +const BOX_CHARS = { + topLeft: "╭", + topRight: "╮", + bottomLeft: "╰", + bottomRight: "╯", + horizontal: "─", + vertical: "│", + teeRight: "├", + teeLeft: "┤", +}; + +function wrapInBox(title, lines, width = 72) { + const innerWidth = width - 4; // 2 for "│ " + " │" + const output = []; + + // Strip ANSI from title for length calc + const strippedTitle = stripAnsi(title); + const topBorder = `${BOX_CHARS.topLeft}${BOX_CHARS.horizontal} ${title} ${BOX_CHARS.horizontal.repeat(Math.max(0, width - strippedTitle.length - 5))}${BOX_CHARS.topRight}`; + output.push(topBorder); + + for (const line of lines) { + // Strip ANSI for length calculation + const stripped = stripAnsi(line); + if (stripped.length <= innerWidth) { + const padding = innerWidth - stripped.length; + output.push( + `${BOX_CHARS.vertical} ${line}${" ".repeat(padding)} ${BOX_CHARS.vertical}` + ); + } else { + // Truncate the line to fit the box (keep ANSI-aware) + const truncated = truncateAnsi(line, innerWidth); + const strippedTruncated = stripAnsi(truncated); + const padding = Math.max(0, innerWidth - strippedTruncated.length); + output.push( + `${BOX_CHARS.vertical} ${truncated}${" ".repeat(padding)} ${BOX_CHARS.vertical}` + ); + } + } + + const bottomBorder = `${BOX_CHARS.bottomLeft}${BOX_CHARS.horizontal.repeat(width - 2)}${BOX_CHARS.bottomRight}`; + output.push(bottomBorder); + + return output.join("\n"); +} + +function stripAnsi(str) { + return str.replace(ANSI_REGEX, ""); +} + +function truncateAnsi(str, maxLen) { + // Walk through the string tracking visible character count + let visible = 0; + const ansiRegex = new RegExp(ANSI_REGEX.source, "g"); + let result = ""; + let lastIndex = 0; + + for (const match of str.matchAll(ansiRegex)) { + // Add visible chars before this ANSI sequence + const before = str.slice(lastIndex, match.index); + for (const ch of before) { + if (visible >= maxLen - 1) { + result += "…"; + // Close any open ANSI sequences with reset + result += ANSI_RESET; + return result; + } + result += ch; + visible++; + } + result += match[0]; // Add the ANSI sequence (zero width) + lastIndex = match.index + match[0].length; + } + + // Handle remaining text after last ANSI sequence + const remaining = str.slice(lastIndex); + for (const ch of remaining) { + if (visible >= maxLen - 1) { + result += "…"; + result += ANSI_RESET; + return result; + } + result += ch; + visible++; + } + + return result; +} + +function truncate(str, maxLen) { + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 3) + "..."; +} + +function formatValue(value) { + if (value === undefined) return "undefined"; + if (value === null) return "null"; + if (typeof value === "function") return "[Function]"; + if (value instanceof RegExp) return value.toString(); + try { + const str = JSON.stringify(value); + return truncate(str, 50); + } catch { + return String(value); + } +} + +/** + * Format validation errors into a colorful, human-friendly string. + * + * @param {ValidationError[]} errors + * @param {Object} options + * @param {"dev"|"build"} options.command + * @param {string} [options.configFile] + * @returns {string} + */ +export function formatValidationErrors(errors, { command, configFile } = {}) { + if (!errors.length) return ""; + + const hardErrors = errors.filter((e) => e.type !== "warning"); + const warnings = errors.filter((e) => e.type === "warning"); + + const lines = []; + + // Summary line + const errorCount = hardErrors.length; + const warnCount = warnings.length; + + const parts = []; + if (errorCount > 0) + parts.push( + colors.red(colors.bold(`${errorCount} error${errorCount > 1 ? "s" : ""}`)) + ); + if (warnCount > 0) + parts.push( + colors.yellow( + colors.bold(`${warnCount} warning${warnCount > 1 ? "s" : ""}`) + ) + ); + lines.push( + `Found ${parts.join(" and ")} in config${configFile ? ` (${colors.dim(configFile)})` : ""}:` + ); + lines.push(""); + + // Errors + for (const error of hardErrors) { + const icon = + error.type === "unknown" ? colors.yellow("?") : colors.red("✖"); + const label = + error.type === "unknown" + ? colors.yellow(error.path) + : colors.red(error.path); + + lines.push(` ${icon} ${colors.bold(label)}`); + lines.push(` ${colors.dim("Message:")} ${error.message}`); + lines.push( + ` ${colors.dim("Got:")} ${colors.red(formatValue(error.value))}` + ); + lines.push( + ` ${colors.dim("Expected:")} ${colors.green(error.expected)}` + ); + if (error.example) { + lines.push( + ` ${colors.dim("Example:")} ${colors.cyan(error.example)}` + ); + } + lines.push(""); + } + + // Warnings + for (const warning of warnings) { + lines.push( + ` ${colors.yellow("⚠")} ${colors.bold(colors.yellow(warning.path))}` + ); + lines.push(` ${colors.dim("Message:")} ${warning.message}`); + if (warning.expected) { + lines.push( + ` ${colors.dim("Known:")} ${colors.green(warning.expected)}` + ); + } + if (warning.example) { + lines.push( + ` ${colors.dim("Example:")} ${colors.cyan(warning.example)}` + ); + } + lines.push(""); + } + + // Behavior hint + if (command === "dev" && errorCount > 0) { + lines.push( + colors.dim(" The dev server is running but your config has errors.") + ); + lines.push( + colors.dim( + " Fix the config and save — the server will restart automatically." + ) + ); + } else if (command === "build" && errorCount > 0) { + lines.push( + colors.red( + " Build aborted due to invalid config. Please fix the errors above." + ) + ); + } + + const title = + errorCount > 0 + ? colors.red(colors.bold("Config Validation Failed")) + : colors.yellow(colors.bold("Config Validation Warnings")); + + return "\n" + wrapInBox(title, lines) + "\n"; +} + +// ───── Public API ───── + +/** + * Validate the root react-server config. + * + * @param {Record} config - The root config object (config[CONFIG_ROOT]) + * @returns {{ errors: ValidationError[], warnings: ValidationError[], valid: boolean }} + */ +export function validateConfig(config) { + if (!config || !is.object(config)) { + return { errors: [], warnings: [], valid: true }; + } + + // Filter out internal symbol keys and the "root" directory key that + // loadConfig injects (it's always "." or a directory path). + const filteredConfig = {}; + for (const [key, value] of Object.entries(config)) { + if (typeof key === "symbol") continue; + // "root" is both a config key (file-router root) and the resolved + // internal directory — only validate when it looks like a user value. + filteredConfig[key] = value; + } + + const allErrors = validateObject(filteredConfig, REACT_SERVER_SCHEMA); + + return { + errors: allErrors.filter((e) => e.type !== "warning"), + warnings: allErrors.filter((e) => e.type === "warning"), + valid: allErrors.filter((e) => e.type !== "warning").length === 0, + }; +} diff --git a/packages/react-server/lib/build/action.mjs b/packages/react-server/lib/build/action.mjs index 0e83abc6..d6cdd7a5 100644 --- a/packages/react-server/lib/build/action.mjs +++ b/packages/react-server/lib/build/action.mjs @@ -5,6 +5,10 @@ import { fileURLToPath } from "node:url"; import colors from "picocolors"; import logo from "../../bin/logo.mjs"; import { loadConfig } from "../../config/index.mjs"; +import { + validateConfig, + formatValidationErrors, +} from "../../config/validate.mjs"; import { ContextStorage } from "../../server/context.mjs"; import { BUILD_OPTIONS, @@ -54,6 +58,22 @@ export default async function build(root, options) { const config = await loadConfig({}, { ...options, command: "build" }); + // Validate config — fail and exit on hard errors during build. + { + const validation = validateConfig(config[CONFIG_ROOT]); + if (!validation.valid || validation.warnings.length > 0) { + const output = formatValidationErrors( + [...validation.errors, ...validation.warnings], + { command: "build" } + ); + if (output) console.error(output); + + if (!validation.valid) { + return 1; + } + } + } + // Apply sourcemap from config if not explicitly set via CLI if ( !options.sourcemap && diff --git a/packages/react-server/lib/dev/action.mjs b/packages/react-server/lib/dev/action.mjs index d224e065..653ed970 100644 --- a/packages/react-server/lib/dev/action.mjs +++ b/packages/react-server/lib/dev/action.mjs @@ -5,6 +5,10 @@ import colors from "picocolors"; import logo from "../../bin/logo.mjs"; import { loadConfig } from "../../config/index.mjs"; +import { + validateConfig, + formatValidationErrors, +} from "../../config/validate.mjs"; import { getRuntime, init$ as runtime_init$, @@ -88,6 +92,26 @@ export default async function dev(root, options) { ); let configRoot = config[CONFIG_ROOT]; + // Validate config and show errors if invalid + { + const validation = validateConfig(configRoot); + if (!validation.valid || validation.warnings.length > 0) { + const output = formatValidationErrors( + [...validation.errors, ...validation.warnings], + { command: "dev" } + ); + if (output) console.error(output); + + // On hard errors, skip server start and wait for config change + if (!validation.valid) { + console.error( + colors.yellow(" Waiting for config changes to restart...\n") + ); + return; + } + } + } + runtime$(CONFIG_CONTEXT, config); // Resolve the action encryption secret once at startup diff --git a/packages/react-server/package.json b/packages/react-server/package.json index 8351d572..7240d7fe 100644 --- a/packages/react-server/package.json +++ b/packages/react-server/package.json @@ -30,6 +30,11 @@ "types": "./config/index.d.ts", "default": "./config/index.mjs" }, + "./config/schema": { + "types": "./config/schema.d.ts", + "default": "./config/schema.mjs" + }, + "./config/schema.json": "./config/schema.json", "./error-boundary": { "types": "./server/error-boundary.d.ts", "default": "./server/error-boundary.jsx" diff --git a/test/__test__/config-schema.spec.mjs b/test/__test__/config-schema.spec.mjs new file mode 100644 index 00000000..fe726ac5 --- /dev/null +++ b/test/__test__/config-schema.spec.mjs @@ -0,0 +1,294 @@ +import { describe, expect, it } from "vitest"; + +import { + DESCRIPTIONS, + generateJsonSchema, +} from "@lazarv/react-server/config/schema.mjs"; + +// ─── DESCRIPTIONS ─────────────────────────────────────────────────────────── + +describe("DESCRIPTIONS", () => { + it("is an object", () => { + expect(typeof DESCRIPTIONS).toBe("object"); + expect(DESCRIPTIONS).not.toBeNull(); + }); + + it("has at least 50 entries", () => { + expect(Object.keys(DESCRIPTIONS).length).toBeGreaterThanOrEqual(50); + }); + + it("every value is a non-empty string", () => { + for (const [, val] of Object.entries(DESCRIPTIONS)) { + expect(typeof val).toBe("string"); + expect(val.length).toBeGreaterThan(0); + } + }); + + it("includes top-level keys", () => { + const topLevel = [ + "root", + "base", + "entry", + "adapter", + "port", + "host", + "sourcemap", + "compression", + "cluster", + "cors", + "plugins", + ]; + for (const key of topLevel) { + expect(DESCRIPTIONS).toHaveProperty(key); + } + }); + + it("includes nested keys with dot notation", () => { + const nested = [ + "server.port", + "server.host", + "build.outDir", + "build.minify", + "resolve.alias", + "css.modules", + "optimizeDeps.include", + "cache.profiles", + "serverFunctions.secret", + "mdx.remarkPlugins", + ]; + for (const key of nested) { + expect(DESCRIPTIONS).toHaveProperty(key); + } + }); +}); + +// ─── generateJsonSchema ───────────────────────────────────────────────────── + +describe("generateJsonSchema", () => { + let schema; + + // Generate once, reuse across tests. + function getSchema() { + if (!schema) schema = generateJsonSchema(); + return schema; + } + + it("returns a JSON Schema draft-07 object", () => { + const s = getSchema(); + expect(s.$schema).toBe("http://json-schema.org/draft-07/schema#"); + expect(s.type).toBe("object"); + expect(s.title).toBe("react-server configuration"); + expect(typeof s.description).toBe("string"); + }); + + it("has a $schema property for self-reference", () => { + const s = getSchema(); + expect(s.properties).toHaveProperty("$schema"); + expect(s.properties.$schema.type).toBe("string"); + }); + + it("includes all top-level config properties", () => { + const s = getSchema(); + const requiredProps = [ + "root", + "base", + "entry", + "public", + "name", + "adapter", + "plugins", + "define", + "envDir", + "envPrefix", + "cacheDir", + "external", + "sourcemap", + "compression", + "export", + "prerender", + "cluster", + "cors", + "vite", + "host", + "port", + "logLevel", + "clearScreen", + ]; + for (const prop of requiredProps) { + expect(s.properties).toHaveProperty(prop); + } + }); + + it("every property has a description", () => { + const s = getSchema(); + for (const [key, val] of Object.entries(s.properties)) { + if (key === "$schema") continue; + expect(typeof val.description).toBe("string"); + } + }); + + // ── Type correctness ────────────────────────────────────────────────── + + it("port is an integer with min/max", () => { + const s = getSchema(); + expect(s.properties.port.type).toBe("integer"); + expect(s.properties.port.minimum).toBe(0); + expect(s.properties.port.maximum).toBe(65535); + }); + + it("sourcemap allows boolean or enum strings", () => { + const s = getSchema(); + const sm = s.properties.sourcemap; + expect(sm.oneOf).toBeDefined(); + const types = sm.oneOf.map((o) => o.type || "enum"); + expect(types).toContain("boolean"); + + const enumBranch = sm.oneOf.find((o) => o.enum); + expect(enumBranch.enum).toEqual( + expect.arrayContaining(["inline", "hidden", "server"]) + ); + }); + + it("adapter allows string or tuple array", () => { + const s = getSchema(); + const a = s.properties.adapter; + expect(a.oneOf).toBeDefined(); + const types = a.oneOf.map((o) => o.type); + expect(types).toContain("string"); + expect(types).toContain("array"); + }); + + it("logLevel is an enum", () => { + const s = getSchema(); + expect(s.properties.logLevel.enum).toEqual([ + "info", + "warn", + "error", + "silent", + ]); + }); + + // ── Nested sub-schemas ──────────────────────────────────────────────── + + it("server sub-schema has host, port, https, hmr, fs", () => { + const srv = getSchema().properties.server; + expect(srv.type).toBe("object"); + const keys = Object.keys(srv.properties); + expect(keys).toEqual( + expect.arrayContaining(["host", "port", "https", "hmr", "fs"]) + ); + }); + + it("server.fs sub-schema has allow, deny, strict", () => { + const fs = getSchema().properties.server.properties.fs; + expect(fs.type).toBe("object"); + expect(fs.properties).toHaveProperty("allow"); + expect(fs.properties).toHaveProperty("deny"); + expect(fs.properties).toHaveProperty("strict"); + }); + + it("resolve sub-schema has alias, dedupe, conditions", () => { + const res = getSchema().properties.resolve; + expect(res.type).toBe("object"); + const keys = Object.keys(res.properties); + expect(keys).toEqual( + expect.arrayContaining(["alias", "dedupe", "conditions"]) + ); + }); + + it("build sub-schema has target, outDir, minify, rollupOptions", () => { + const b = getSchema().properties.build; + expect(b.type).toBe("object"); + const keys = Object.keys(b.properties); + expect(keys).toEqual( + expect.arrayContaining(["target", "outDir", "minify", "rollupOptions"]) + ); + }); + + it("ssr sub-schema has external, noExternal, worker", () => { + const s = getSchema().properties.ssr; + expect(s.type).toBe("object"); + expect(s.properties).toHaveProperty("external"); + expect(s.properties).toHaveProperty("noExternal"); + expect(s.properties).toHaveProperty("worker"); + }); + + it("css sub-schema has modules, preprocessorOptions, postcss", () => { + const c = getSchema().properties.css; + expect(c.type).toBe("object"); + expect(c.properties).toHaveProperty("modules"); + expect(c.properties).toHaveProperty("preprocessorOptions"); + expect(c.properties).toHaveProperty("postcss"); + }); + + it("optimizeDeps sub-schema has include, exclude, force", () => { + const o = getSchema().properties.optimizeDeps; + expect(o.type).toBe("object"); + expect(o.properties).toHaveProperty("include"); + expect(o.properties).toHaveProperty("exclude"); + expect(o.properties).toHaveProperty("force"); + }); + + it("cache sub-schema has profiles and providers", () => { + const c = getSchema().properties.cache; + expect(c.type).toBe("object"); + expect(c.properties).toHaveProperty("profiles"); + expect(c.properties).toHaveProperty("providers"); + }); + + it("serverFunctions sub-schema has secret, secretFile, previousSecrets", () => { + const sf = getSchema().properties.serverFunctions; + expect(sf.type).toBe("object"); + expect(sf.properties).toHaveProperty("secret"); + expect(sf.properties).toHaveProperty("secretFile"); + expect(sf.properties).toHaveProperty("previousSecrets"); + }); + + it("mdx sub-schema has remarkPlugins, rehypePlugins, components", () => { + const m = getSchema().properties.mdx; + expect(m.type).toBe("object"); + expect(m.properties).toHaveProperty("remarkPlugins"); + expect(m.properties).toHaveProperty("rehypePlugins"); + expect(m.properties).toHaveProperty("components"); + }); + + // ── Serialisability ────────────────────────────────────────────────── + + it("round-trips through JSON.stringify/parse without loss", () => { + const s = getSchema(); + const json = JSON.stringify(s); + const parsed = JSON.parse(json); + expect(parsed).toEqual(s); + }); + + it("does not contain undefined values", () => { + const s = getSchema(); + const json = JSON.stringify(s); + expect(json).not.toContain("undefined"); + }); + + // ── additionalProperties ───────────────────────────────────────────── + + it("sets additionalProperties: false at root level", () => { + const s = getSchema(); + expect(s.additionalProperties).toBe(false); + }); + + it("sets additionalProperties: false on nested object schemas", () => { + const s = getSchema(); + const nested = [ + "server", + "resolve", + "build", + "ssr", + "css", + "optimizeDeps", + "cache", + "serverFunctions", + "mdx", + ]; + for (const key of nested) { + expect(s.properties[key].additionalProperties).toBe(false); + } + }); +}); diff --git a/test/__test__/config-validate.spec.mjs b/test/__test__/config-validate.spec.mjs new file mode 100644 index 00000000..2b33ee53 --- /dev/null +++ b/test/__test__/config-validate.spec.mjs @@ -0,0 +1,1979 @@ +import { describe, expect, it } from "vitest"; + +import { + validateConfig, + formatValidationErrors, +} from "@lazarv/react-server/config/validate.mjs"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** Shorthand: expects validation to pass with no errors. */ +function expectValid(config) { + const result = validateConfig(config); + if (!result.valid) { + const paths = result.errors.map((e) => `${e.path}: ${e.message}`); + throw new Error( + `Expected valid config, got errors:\n ${paths.join("\n ")}` + ); + } + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + return result; +} + +/** Shorthand: expects validation to fail with specific error path(s). */ +function expectInvalid(config, ...expectedPaths) { + const result = validateConfig(config); + expect(result.valid).toBe(false); + for (const path of expectedPaths) { + expect(result.errors.some((e) => e.path === path)).toBe(true); + } + return result; +} + +/** Shorthand: expects a warning (valid but with warnings). */ +function expectWarning(config, warningPath) { + const result = validateConfig(config); + expect(result.valid).toBe(true); + expect(result.warnings.some((w) => w.path === warningPath)).toBe(true); + return result; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Baseline / empty +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — baseline", () => { + it("accepts empty config", () => { + expectValid({}); + }); + + it("accepts undefined / null config", () => { + expect(validateConfig(undefined).valid).toBe(true); + expect(validateConfig(null).valid).toBe(true); + }); + + it("accepts null values for optional fields", () => { + expectValid({ adapter: null, cors: null, inspect: null, vite: null }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Unknown keys +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — unknown keys", () => { + it("rejects unknown top-level key", () => { + expectInvalid({ foobar: true }, "foobar"); + }); + + it("rejects unknown nested key in server.*", () => { + expectInvalid({ server: { foobar: 42 } }, "server.foobar"); + }); + + it("rejects unknown nested key in build.*", () => { + expectInvalid({ build: { foobar: true } }, "build.foobar"); + }); + + it("rejects unknown nested key in resolve.*", () => { + expectInvalid({ resolve: { foobar: [] } }, "resolve.foobar"); + }); + + it("rejects unknown nested key in ssr.*", () => { + expectInvalid({ ssr: { foobar: "x" } }, "ssr.foobar"); + }); + + it("rejects unknown nested key in css.*", () => { + expectInvalid({ css: { foobar: true } }, "css.foobar"); + }); + + it("rejects unknown nested key in optimizeDeps.*", () => { + expectInvalid({ optimizeDeps: { foobar: true } }, "optimizeDeps.foobar"); + }); + + it("rejects unknown nested key in cache.*", () => { + expectInvalid({ cache: { foobar: true } }, "cache.foobar"); + }); + + it("rejects unknown nested key in serverFunctions.*", () => { + expectInvalid( + { serverFunctions: { foobar: true } }, + "serverFunctions.foobar" + ); + }); + + it("rejects unknown nested key in mdx.*", () => { + expectInvalid({ mdx: { foobar: true } }, "mdx.foobar"); + }); + + it("provides did-you-mean suggestion for typos", () => { + const result = validateConfig({ prot: 3000 }); + expect(result.errors[0].expected).toMatch(/Did you mean/); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// root +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — root", () => { + it("accepts string", () => { + expectValid({ root: "src/pages" }); + }); + + it("rejects number", () => { + expectInvalid({ root: 42 }, "root"); + }); + + it("rejects boolean", () => { + expectInvalid({ root: true }, "root"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// base +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — base", () => { + it("accepts string", () => { + expectValid({ base: "/my-app/" }); + }); + + it("rejects number", () => { + expectInvalid({ base: 123 }, "base"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// entry +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — entry", () => { + it("accepts string", () => { + expectValid({ entry: "./src/App.jsx" }); + }); + + it("rejects array", () => { + expectInvalid({ entry: ["a", "b"] }, "entry"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// public +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — public", () => { + it("accepts string", () => { + expectValid({ public: "public" }); + }); + + it("accepts false", () => { + expectValid({ public: false }); + }); + + it("rejects true", () => { + expectInvalid({ public: true }, "public"); + }); + + it("rejects number", () => { + expectInvalid({ public: 42 }, "public"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// name +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — name", () => { + it("accepts string", () => { + expectValid({ name: "my-app" }); + }); + + it("rejects number", () => { + expectInvalid({ name: 42 }, "name"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// adapter +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — adapter", () => { + it("accepts string", () => { + expectValid({ adapter: "vercel" }); + }); + + it("accepts function", () => { + expectValid({ adapter: () => {} }); + }); + + it("accepts [name, options] tuple", () => { + expectValid({ adapter: ["cloudflare", { routes: true }] }); + }); + + it("rejects number", () => { + expectInvalid({ adapter: 42 }, "adapter"); + }); + + it("rejects array with non-string first element", () => { + expectInvalid({ adapter: [42, {}] }, "adapter"); + }); + + it("warns on unknown adapter names", () => { + expectWarning({ adapter: "unknown-adapter" }, "adapter"); + }); + + it("does not warn on known adapters", () => { + for (const name of [ + "aws", + "azure", + "azure-swa", + "bun", + "cloudflare", + "deno", + "docker", + "firebase", + "netlify", + "singlefile", + "vercel", + ]) { + const result = validateConfig({ adapter: name }); + expect(result.warnings).toHaveLength(0); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// plugins +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — plugins", () => { + it("accepts array of objects", () => { + expectValid({ plugins: [{ name: "test" }] }); + }); + + it("accepts function", () => { + expectValid({ plugins: () => [] }); + }); + + it("accepts array with null items (conditional plugins)", () => { + expectValid({ plugins: [null, false, undefined, { name: "p" }] }); + }); + + it("accepts nested arrays (Vite PluginOption[])", () => { + expectValid({ plugins: [[{ name: "a" }], [{ name: "b" }]] }); + }); + + it("accepts [name, options] tuples", () => { + expectValid({ plugins: [["my-plugin", { opt: true }]] }); + }); + + it("rejects string", () => { + expectInvalid({ plugins: "not-valid" }, "plugins"); + }); + + it("rejects number", () => { + expectInvalid({ plugins: 42 }, "plugins"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// define +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — define", () => { + it("accepts object", () => { + expectValid({ define: { "process.env.FOO": JSON.stringify("bar") } }); + }); + + it("rejects string", () => { + expectInvalid({ define: "bad" }, "define"); + }); + + it("rejects array", () => { + expectInvalid({ define: [] }, "define"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// envDir +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — envDir", () => { + it("accepts string", () => { + expectValid({ envDir: "./env" }); + }); + + it("accepts false", () => { + expectValid({ envDir: false }); + }); + + it("rejects true", () => { + expectInvalid({ envDir: true }, "envDir"); + }); + + it("rejects number", () => { + expectInvalid({ envDir: 42 }, "envDir"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// envPrefix +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — envPrefix", () => { + it("accepts string", () => { + expectValid({ envPrefix: "MY_APP_" }); + }); + + it("accepts string array", () => { + expectValid({ envPrefix: ["MY_APP_", "VITE_"] }); + }); + + it("rejects number", () => { + expectInvalid({ envPrefix: 42 }, "envPrefix"); + }); + + it("rejects boolean", () => { + expectInvalid({ envPrefix: true }, "envPrefix"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// cacheDir +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — cacheDir", () => { + it("accepts string", () => { + expectValid({ cacheDir: "node_modules/.cache" }); + }); + + it("rejects number", () => { + expectInvalid({ cacheDir: 123 }, "cacheDir"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// external +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — external", () => { + it("accepts string array", () => { + expectValid({ external: ["pg", "mysql2"] }); + }); + + it("accepts single string", () => { + expectValid({ external: "pg" }); + }); + + it("rejects number", () => { + expectInvalid({ external: 42 }, "external"); + }); + + it("rejects boolean", () => { + expectInvalid({ external: true }, "external"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// sourcemap +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — sourcemap", () => { + it("accepts boolean", () => { + expectValid({ sourcemap: true }); + expectValid({ sourcemap: false }); + }); + + it('accepts "inline"', () => { + expectValid({ sourcemap: "inline" }); + }); + + it('accepts "hidden"', () => { + expectValid({ sourcemap: "hidden" }); + }); + + it('accepts "server"', () => { + expectValid({ sourcemap: "server" }); + }); + + it('accepts "server-inline"', () => { + expectValid({ sourcemap: "server-inline" }); + }); + + it("rejects unknown string", () => { + expectInvalid({ sourcemap: "bogus" }, "sourcemap"); + }); + + it("rejects number", () => { + expectInvalid({ sourcemap: 42 }, "sourcemap"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// compression +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — compression", () => { + it("accepts boolean", () => { + expectValid({ compression: true }); + expectValid({ compression: false }); + }); + + it("rejects string", () => { + expectInvalid({ compression: "gzip" }, "compression"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// export +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — export", () => { + it("accepts boolean", () => { + expectValid({ export: true }); + expectValid({ export: false }); + }); + + it("accepts function", () => { + expectValid({ export: () => ["/"] }); + }); + + it("accepts string array", () => { + expectValid({ export: ["/", "/about"] }); + }); + + it("accepts object array (path descriptors)", () => { + expectValid({ export: [{ path: "/" }] }); + expectValid({ + export: [{ path: "/", filename: "index.html", outlet: "main" }], + }); + }); + + it("accepts mixed string and object array", () => { + expectValid({ export: ["/", { path: "/about" }] }); + }); + + it("rejects number", () => { + expectInvalid({ export: 42 }, "export"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// prerender +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — prerender", () => { + it("accepts boolean", () => { + expectValid({ prerender: true }); + }); + + it("accepts object", () => { + expectValid({ prerender: { timeout: 30000 } }); + }); + + it("rejects string", () => { + expectInvalid({ prerender: "yes" }, "prerender"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// cluster +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — cluster", () => { + it("accepts number", () => { + expectValid({ cluster: 4 }); + }); + + it("accepts boolean", () => { + expectValid({ cluster: true }); + }); + + it("rejects string", () => { + expectInvalid({ cluster: "auto" }, "cluster"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// cors +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — cors", () => { + it("accepts boolean", () => { + expectValid({ cors: true }); + }); + + it("accepts object", () => { + expectValid({ cors: { origin: "*", credentials: true } }); + }); + + it("rejects string", () => { + expectInvalid({ cors: "yes" }, "cors"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// vite +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — vite", () => { + it("accepts object", () => { + expectValid({ vite: { build: { target: "esnext" } } }); + }); + + it("accepts function", () => { + expectValid({ vite: (config) => config }); + }); + + it("rejects string", () => { + expectInvalid({ vite: "bad" }, "vite"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// customLogger +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — customLogger", () => { + it("accepts object", () => { + expectValid({ customLogger: { info: () => {}, warn: () => {} } }); + }); + + it("rejects string", () => { + expectInvalid({ customLogger: "pino" }, "customLogger"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// logger +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — logger", () => { + it("accepts string", () => { + expectValid({ logger: "pino" }); + }); + + it("accepts object", () => { + expectValid({ logger: { level: "info" } }); + }); + + it("rejects number", () => { + expectInvalid({ logger: 42 }, "logger"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// globalErrorComponent +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — globalErrorComponent", () => { + it("accepts string glob", () => { + expectValid({ globalErrorComponent: "**/ErrorBoundary.{jsx,tsx}" }); + }); + + it("rejects boolean", () => { + expectInvalid({ globalErrorComponent: true }, "globalErrorComponent"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// handlers +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — handlers", () => { + it("accepts function", () => { + expectValid({ handlers: () => {} }); + }); + + it("accepts array", () => { + expectValid({ handlers: [() => {}, () => {}] }); + }); + + it("accepts { pre, post } object", () => { + expectValid({ handlers: { pre: [() => {}], post: [() => {}] } }); + }); + + it("rejects string", () => { + expectInvalid({ handlers: "bad" }, "handlers"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// importMap +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — importMap", () => { + it("accepts { imports: {} }", () => { + expectValid({ importMap: { imports: { lodash: "/vendor/lodash.js" } } }); + }); + + it("rejects string", () => { + expectInvalid({ importMap: "bad" }, "importMap"); + }); + + it("rejects array", () => { + expectInvalid({ importMap: [] }, "importMap"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// inspect +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — inspect", () => { + it("accepts boolean", () => { + expectValid({ inspect: true }); + }); + + it("accepts object", () => { + expectValid({ inspect: { build: true } }); + }); + + it("rejects string", () => { + expectInvalid({ inspect: "yes" }, "inspect"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// runtime +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — runtime", () => { + it("accepts function", () => { + expectValid({ runtime: async () => ({ key: "value" }) }); + }); + + it("accepts object", () => { + expectValid({ runtime: { key: "value" } }); + }); + + it("rejects string", () => { + expectInvalid({ runtime: "bad" }, "runtime"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// cookies +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — cookies", () => { + it("accepts object", () => { + expectValid({ cookies: { secure: true, sameSite: "lax" } }); + }); + + it("rejects string", () => { + expectInvalid({ cookies: "secure" }, "cookies"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// host +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — host", () => { + it("accepts string", () => { + expectValid({ host: "0.0.0.0" }); + }); + + it("accepts true", () => { + expectValid({ host: true }); + }); + + it("rejects false", () => { + expectInvalid({ host: false }, "host"); + }); + + it("rejects number", () => { + expectInvalid({ host: 42 }, "host"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// port +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — port", () => { + it("accepts valid port number", () => { + expectValid({ port: 3000 }); + }); + + it("rejects string", () => { + expectInvalid({ port: "3000" }, "port"); + }); + + it("rejects negative port", () => { + expectInvalid({ port: -1 }, "port"); + }); + + it("rejects port > 65535", () => { + expectInvalid({ port: 70000 }, "port"); + }); + + it("rejects non-integer port", () => { + expectInvalid({ port: 3000.5 }, "port"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// console +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — console", () => { + it("accepts boolean", () => { + expectValid({ console: false }); + expectValid({ console: true }); + }); + + it("rejects string", () => { + expectInvalid({ console: "yes" }, "console"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// overlay +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — overlay", () => { + it("accepts boolean", () => { + expectValid({ overlay: false }); + expectValid({ overlay: true }); + }); + + it("rejects string", () => { + expectInvalid({ overlay: "yes" }, "overlay"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// assetsInclude +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — assetsInclude", () => { + it("accepts string", () => { + expectValid({ assetsInclude: "**/*.gltf" }); + }); + + it("accepts RegExp", () => { + expectValid({ assetsInclude: /\.gltf$/ }); + }); + + it("accepts array of strings and RegExps", () => { + expectValid({ assetsInclude: ["**/*.gltf", /\.hdr$/] }); + }); + + it("rejects number", () => { + expectInvalid({ assetsInclude: 42 }, "assetsInclude"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// logLevel +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — logLevel", () => { + it('accepts "info"', () => { + expectValid({ logLevel: "info" }); + }); + + it('accepts "warn"', () => { + expectValid({ logLevel: "warn" }); + }); + + it('accepts "error"', () => { + expectValid({ logLevel: "error" }); + }); + + it('accepts "silent"', () => { + expectValid({ logLevel: "silent" }); + }); + + it("rejects unknown string", () => { + expectInvalid({ logLevel: "debug" }, "logLevel"); + }); + + it("rejects number", () => { + expectInvalid({ logLevel: 0 }, "logLevel"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// clearScreen +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — clearScreen", () => { + it("accepts boolean", () => { + expectValid({ clearScreen: false }); + }); + + it("rejects string", () => { + expectInvalid({ clearScreen: "yes" }, "clearScreen"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// server.* +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — server", () => { + it("accepts empty server object", () => { + expectValid({ server: {} }); + }); + + it("rejects non-object", () => { + expectInvalid({ server: "bad" }, "server"); + }); + + describe("server.host", () => { + it("accepts string", () => { + expectValid({ server: { host: "0.0.0.0" } }); + }); + + it("accepts true", () => { + expectValid({ server: { host: true } }); + }); + + it("rejects number", () => { + expectInvalid({ server: { host: 42 } }, "server.host"); + }); + }); + + describe("server.port", () => { + it("accepts number", () => { + expectValid({ server: { port: 8080 } }); + }); + + it("rejects string", () => { + expectInvalid({ server: { port: "8080" } }, "server.port"); + }); + }); + + describe("server.https", () => { + it("accepts boolean", () => { + expectValid({ server: { https: true } }); + }); + + it("accepts object", () => { + expectValid({ server: { https: { key: "k", cert: "c" } } }); + }); + + it("rejects string", () => { + expectInvalid({ server: { https: "yes" } }, "server.https"); + }); + }); + + describe("server.cors", () => { + it("accepts boolean", () => { + expectValid({ server: { cors: true } }); + }); + + it("accepts object", () => { + expectValid({ server: { cors: { origin: "*" } } }); + }); + + it("rejects string", () => { + expectInvalid({ server: { cors: "yes" } }, "server.cors"); + }); + }); + + describe("server.open", () => { + it("accepts boolean", () => { + expectValid({ server: { open: true } }); + }); + + it("accepts string", () => { + expectValid({ server: { open: "/page" } }); + }); + + it("rejects number", () => { + expectInvalid({ server: { open: 42 } }, "server.open"); + }); + }); + + describe("server.hmr", () => { + it("accepts boolean", () => { + expectValid({ server: { hmr: false } }); + }); + + it("accepts object", () => { + expectValid({ server: { hmr: { port: 24678 } } }); + }); + + it("rejects string", () => { + expectInvalid({ server: { hmr: "yes" } }, "server.hmr"); + }); + }); + + describe("server.fs", () => { + it("accepts { allow, deny, strict }", () => { + expectValid({ + server: { fs: { allow: [".."], deny: [".env"], strict: true } }, + }); + }); + + it("rejects non-object", () => { + expectInvalid({ server: { fs: "bad" } }, "server.fs"); + }); + + it("rejects non-string-array for allow", () => { + expectInvalid({ server: { fs: { allow: 42 } } }, "server.fs.allow"); + }); + }); + + describe("server.watch", () => { + it("accepts object", () => { + expectValid({ server: { watch: { usePolling: true } } }); + }); + + it("rejects string", () => { + expectInvalid({ server: { watch: "yes" } }, "server.watch"); + }); + }); + + describe("server.origin", () => { + it("accepts string", () => { + expectValid({ server: { origin: "https://example.com" } }); + }); + + it("rejects number", () => { + expectInvalid({ server: { origin: 42 } }, "server.origin"); + }); + }); + + describe("server.proxy", () => { + it("accepts object", () => { + expectValid({ + server: { proxy: { "/api": "http://localhost:4000" } }, + }); + }); + + it("rejects string", () => { + expectInvalid({ server: { proxy: "bad" } }, "server.proxy"); + }); + }); + + describe("server.trustProxy", () => { + it("accepts boolean", () => { + expectValid({ server: { trustProxy: true } }); + }); + + it("rejects string", () => { + expectInvalid({ server: { trustProxy: "yes" } }, "server.trustProxy"); + }); + }); + + describe("server.headers", () => { + it("accepts object", () => { + expectValid({ server: { headers: { "X-Custom": "value" } } }); + }); + + it("rejects string", () => { + expectInvalid({ server: { headers: "bad" } }, "server.headers"); + }); + }); + + describe("server.warmup", () => { + it("accepts object", () => { + expectValid({ + server: { warmup: { clientFiles: ["./src/main.ts"] } }, + }); + }); + + it("rejects string", () => { + expectInvalid({ server: { warmup: "bad" } }, "server.warmup"); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// resolve.* +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — resolve", () => { + it("accepts empty resolve object", () => { + expectValid({ resolve: {} }); + }); + + it("rejects non-object", () => { + expectInvalid({ resolve: "bad" }, "resolve"); + }); + + describe("resolve.alias", () => { + it("accepts plain object", () => { + expectValid({ resolve: { alias: { "@": "./src" } } }); + }); + + it("accepts array with string find", () => { + expectValid({ + resolve: { alias: [{ find: "@", replacement: "./src" }] }, + }); + }); + + it("accepts array with RegExp find", () => { + expectValid({ + resolve: { alias: [{ find: /^@\//, replacement: "./src/" }] }, + }); + }); + + it("rejects string", () => { + expectInvalid({ resolve: { alias: "bad" } }, "resolve.alias"); + }); + }); + + describe("resolve.dedupe", () => { + it("accepts string array", () => { + expectValid({ resolve: { dedupe: ["react", "react-dom"] } }); + }); + + it("rejects string", () => { + expectInvalid({ resolve: { dedupe: "react" } }, "resolve.dedupe"); + }); + }); + + describe("resolve.noExternal", () => { + it("accepts string array", () => { + expectValid({ resolve: { noExternal: ["my-lib"] } }); + }); + + it("accepts boolean", () => { + expectValid({ resolve: { noExternal: true } }); + }); + + it("accepts RegExp", () => { + expectValid({ resolve: { noExternal: /my-lib/ } }); + }); + + it("rejects number", () => { + expectInvalid({ resolve: { noExternal: 42 } }, "resolve.noExternal"); + }); + }); + + describe("resolve.shared", () => { + it("accepts string array", () => { + expectValid({ resolve: { shared: ["shared-utils"] } }); + }); + + it("rejects string", () => { + expectInvalid({ resolve: { shared: "bad" } }, "resolve.shared"); + }); + }); + + describe("resolve.external", () => { + it("accepts RegExp", () => { + expectValid({ resolve: { external: /^node:/ } }); + }); + + it("accepts string", () => { + expectValid({ resolve: { external: "fs" } }); + }); + + it("accepts string array", () => { + expectValid({ resolve: { external: ["fs", "path"] } }); + }); + + it("accepts function", () => { + expectValid({ resolve: { external: () => true } }); + }); + + it("rejects number", () => { + expectInvalid({ resolve: { external: 42 } }, "resolve.external"); + }); + }); + + describe("resolve.builtins", () => { + it("accepts string array", () => { + expectValid({ resolve: { builtins: ["my-builtin"] } }); + }); + + it("rejects string", () => { + expectInvalid({ resolve: { builtins: "bad" } }, "resolve.builtins"); + }); + }); + + describe("resolve.conditions", () => { + it("accepts string array", () => { + expectValid({ resolve: { conditions: ["worker", "browser"] } }); + }); + + it("rejects string", () => { + expectInvalid( + { resolve: { conditions: "worker" } }, + "resolve.conditions" + ); + }); + }); + + describe("resolve.extensions", () => { + it("accepts string array", () => { + expectValid({ resolve: { extensions: [".mjs", ".js", ".ts"] } }); + }); + + it("rejects string", () => { + expectInvalid({ resolve: { extensions: ".ts" } }, "resolve.extensions"); + }); + }); + + describe("resolve.mainFields", () => { + it("accepts string array", () => { + expectValid({ resolve: { mainFields: ["module", "main"] } }); + }); + + it("rejects string", () => { + expectInvalid( + { resolve: { mainFields: "module" } }, + "resolve.mainFields" + ); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// build.* +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — build", () => { + it("accepts empty build object", () => { + expectValid({ build: {} }); + }); + + it("rejects non-object", () => { + expectInvalid({ build: "bad" }, "build"); + }); + + describe("build.target", () => { + it("accepts string", () => { + expectValid({ build: { target: "esnext" } }); + }); + + it("accepts string array", () => { + expectValid({ build: { target: ["es2020", "edge88"] } }); + }); + + it("rejects number", () => { + expectInvalid({ build: { target: 42 } }, "build.target"); + }); + }); + + describe("build.outDir", () => { + it("accepts string", () => { + expectValid({ build: { outDir: "dist" } }); + }); + + it("rejects number", () => { + expectInvalid({ build: { outDir: 42 } }, "build.outDir"); + }); + }); + + describe("build.assetsDir", () => { + it("accepts string", () => { + expectValid({ build: { assetsDir: "assets" } }); + }); + + it("rejects number", () => { + expectInvalid({ build: { assetsDir: 42 } }, "build.assetsDir"); + }); + }); + + describe("build.minify", () => { + it("accepts boolean", () => { + expectValid({ build: { minify: true } }); + }); + + it('accepts "terser"', () => { + expectValid({ build: { minify: "terser" } }); + }); + + it('accepts "esbuild"', () => { + expectValid({ build: { minify: "esbuild" } }); + }); + + it("rejects unknown string", () => { + expectInvalid({ build: { minify: "uglify" } }, "build.minify"); + }); + }); + + describe("build.cssMinify", () => { + it("accepts boolean", () => { + expectValid({ build: { cssMinify: true } }); + }); + + it("accepts string", () => { + expectValid({ build: { cssMinify: "lightningcss" } }); + }); + + it("rejects number", () => { + expectInvalid({ build: { cssMinify: 42 } }, "build.cssMinify"); + }); + }); + + describe("build.cssCodeSplit", () => { + it("accepts boolean", () => { + expectValid({ build: { cssCodeSplit: true } }); + }); + + it("rejects string", () => { + expectInvalid({ build: { cssCodeSplit: "yes" } }, "build.cssCodeSplit"); + }); + }); + + describe("build.assetsInlineLimit", () => { + it("accepts number", () => { + expectValid({ build: { assetsInlineLimit: 4096 } }); + }); + + it("accepts function", () => { + expectValid({ build: { assetsInlineLimit: () => 4096 } }); + }); + + it("rejects string", () => { + expectInvalid( + { build: { assetsInlineLimit: "4096" } }, + "build.assetsInlineLimit" + ); + }); + }); + + describe("build.reportCompressedSize", () => { + it("accepts boolean", () => { + expectValid({ build: { reportCompressedSize: false } }); + }); + + it("rejects string", () => { + expectInvalid( + { build: { reportCompressedSize: "yes" } }, + "build.reportCompressedSize" + ); + }); + }); + + describe("build.copyPublicDir", () => { + it("accepts boolean", () => { + expectValid({ build: { copyPublicDir: false } }); + }); + + it("rejects string", () => { + expectInvalid({ build: { copyPublicDir: "yes" } }, "build.copyPublicDir"); + }); + }); + + describe("build.modulePreload", () => { + it("accepts boolean", () => { + expectValid({ build: { modulePreload: false } }); + }); + + it("accepts object", () => { + expectValid({ build: { modulePreload: { polyfill: false } } }); + }); + + it("rejects string", () => { + expectInvalid({ build: { modulePreload: "yes" } }, "build.modulePreload"); + }); + }); + + describe("build.chunkSizeWarningLimit", () => { + it("accepts number", () => { + expectValid({ build: { chunkSizeWarningLimit: 2048 } }); + }); + + it("rejects string", () => { + expectInvalid( + { build: { chunkSizeWarningLimit: "2048" } }, + "build.chunkSizeWarningLimit" + ); + }); + }); + + describe("build.lib", () => { + it("accepts boolean", () => { + expectValid({ build: { lib: true } }); + }); + + it("rejects string", () => { + expectInvalid({ build: { lib: "bad" } }, "build.lib"); + }); + }); + + describe("build.rollupOptions", () => { + it("accepts full shape", () => { + expectValid({ + build: { + rollupOptions: { + external: ["lodash"], + output: { format: "es" }, + plugins: [], + input: "./src/main.ts", + checks: {}, + treeshake: { moduleSideEffects: true }, + }, + }, + }); + }); + + it("accepts external as function", () => { + expectValid({ + build: { rollupOptions: { external: () => true } }, + }); + }); + + it("accepts external as RegExp", () => { + expectValid({ + build: { rollupOptions: { external: /^node:/ } }, + }); + }); + + it("accepts input as object", () => { + expectValid({ + build: { rollupOptions: { input: { main: "./src/main.ts" } } }, + }); + }); + + it("accepts input as array", () => { + expectValid({ + build: { rollupOptions: { input: ["./src/a.ts", "./src/b.ts"] } }, + }); + }); + + it("accepts treeshake as boolean", () => { + expectValid({ + build: { rollupOptions: { treeshake: false } }, + }); + }); + + it("rejects non-object", () => { + expectInvalid({ build: { rollupOptions: "bad" } }, "build.rollupOptions"); + }); + }); + + describe("build.rolldownOptions", () => { + it("accepts full shape", () => { + expectValid({ + build: { + rolldownOptions: { + external: ["lodash"], + output: {}, + plugins: [], + input: { main: "./src/main.ts" }, + checks: {}, + treeshake: true, + }, + }, + }); + }); + + it("rejects non-object", () => { + expectInvalid( + { build: { rolldownOptions: "bad" } }, + "build.rolldownOptions" + ); + }); + }); + + describe("build.server", () => { + it("accepts { config: object }", () => { + expectValid({ build: { server: { config: {} } } }); + }); + + it("accepts { config: function }", () => { + expectValid({ build: { server: { config: () => ({}) } } }); + }); + + it("rejects non-object", () => { + expectInvalid({ build: { server: "bad" } }, "build.server"); + }); + }); + + describe("build.client", () => { + it("accepts { config: object }", () => { + expectValid({ build: { client: { config: {} } } }); + }); + + it("accepts { config: function }", () => { + expectValid({ build: { client: { config: () => ({}) } } }); + }); + + it("rejects non-object", () => { + expectInvalid({ build: { client: "bad" } }, "build.client"); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// ssr.* +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — ssr", () => { + it("accepts empty ssr object", () => { + expectValid({ ssr: {} }); + }); + + it("rejects non-object", () => { + expectInvalid({ ssr: "bad" }, "ssr"); + }); + + describe("ssr.external", () => { + it("accepts string array", () => { + expectValid({ ssr: { external: ["pg", "mysql2"] } }); + }); + + it("accepts boolean", () => { + expectValid({ ssr: { external: true } }); + }); + + it("rejects number", () => { + expectInvalid({ ssr: { external: 42 } }, "ssr.external"); + }); + }); + + describe("ssr.noExternal", () => { + it("accepts string array", () => { + expectValid({ ssr: { noExternal: ["my-ui-lib"] } }); + }); + + it("accepts boolean", () => { + expectValid({ ssr: { noExternal: true } }); + }); + + it("accepts RegExp", () => { + expectValid({ ssr: { noExternal: /my-lib/ } }); + }); + + it("rejects number", () => { + expectInvalid({ ssr: { noExternal: 42 } }, "ssr.noExternal"); + }); + }); + + describe("ssr.resolve", () => { + it("accepts object", () => { + expectValid({ ssr: { resolve: { conditions: ["worker"] } } }); + }); + + it("rejects string", () => { + expectInvalid({ ssr: { resolve: "bad" } }, "ssr.resolve"); + }); + }); + + describe("ssr.worker", () => { + it("accepts boolean", () => { + expectValid({ ssr: { worker: false } }); + }); + + it("rejects string", () => { + expectInvalid({ ssr: { worker: "yes" } }, "ssr.worker"); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// css.* +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — css", () => { + it("accepts empty css object", () => { + expectValid({ css: {} }); + }); + + it("rejects non-object", () => { + expectInvalid({ css: "bad" }, "css"); + }); + + describe("css.modules", () => { + it("accepts object", () => { + expectValid({ css: { modules: { localsConvention: "camelCase" } } }); + }); + + it("rejects string", () => { + expectInvalid({ css: { modules: "bad" } }, "css.modules"); + }); + }); + + describe("css.preprocessorOptions", () => { + it("accepts object", () => { + expectValid({ + css: { preprocessorOptions: { scss: { additionalData: "$x: 1;" } } }, + }); + }); + + it("rejects string", () => { + expectInvalid( + { css: { preprocessorOptions: "bad" } }, + "css.preprocessorOptions" + ); + }); + }); + + describe("css.postcss", () => { + it("accepts string", () => { + expectValid({ css: { postcss: "./postcss.config.js" } }); + }); + + it("accepts object", () => { + expectValid({ css: { postcss: { plugins: [] } } }); + }); + + it("rejects number", () => { + expectInvalid({ css: { postcss: 42 } }, "css.postcss"); + }); + }); + + describe("css.devSourcemap", () => { + it("accepts boolean", () => { + expectValid({ css: { devSourcemap: true } }); + }); + + it("rejects string", () => { + expectInvalid({ css: { devSourcemap: "yes" } }, "css.devSourcemap"); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// optimizeDeps.* +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — optimizeDeps", () => { + it("accepts empty optimizeDeps object", () => { + expectValid({ optimizeDeps: {} }); + }); + + it("rejects non-object", () => { + expectInvalid({ optimizeDeps: "bad" }, "optimizeDeps"); + }); + + describe("optimizeDeps.include", () => { + it("accepts string array", () => { + expectValid({ optimizeDeps: { include: ["lodash"] } }); + }); + + it("rejects string", () => { + expectInvalid( + { optimizeDeps: { include: "lodash" } }, + "optimizeDeps.include" + ); + }); + }); + + describe("optimizeDeps.exclude", () => { + it("accepts string array", () => { + expectValid({ optimizeDeps: { exclude: ["large-dep"] } }); + }); + + it("rejects string", () => { + expectInvalid( + { optimizeDeps: { exclude: "large-dep" } }, + "optimizeDeps.exclude" + ); + }); + }); + + describe("optimizeDeps.force", () => { + it("accepts boolean", () => { + expectValid({ optimizeDeps: { force: true } }); + }); + + it("rejects string", () => { + expectInvalid({ optimizeDeps: { force: "yes" } }, "optimizeDeps.force"); + }); + }); + + describe("optimizeDeps.rolldownOptions", () => { + it("accepts object", () => { + expectValid({ optimizeDeps: { rolldownOptions: {} } }); + }); + + it("rejects string", () => { + expectInvalid( + { optimizeDeps: { rolldownOptions: "bad" } }, + "optimizeDeps.rolldownOptions" + ); + }); + }); + + describe("optimizeDeps.esbuildOptions", () => { + it("accepts object", () => { + expectValid({ optimizeDeps: { esbuildOptions: {} } }); + }); + + it("rejects string", () => { + expectInvalid( + { optimizeDeps: { esbuildOptions: "bad" } }, + "optimizeDeps.esbuildOptions" + ); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// cache.* +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — cache", () => { + it("accepts empty cache object", () => { + expectValid({ cache: {} }); + }); + + it("rejects non-object", () => { + expectInvalid({ cache: "bad" }, "cache"); + }); + + describe("cache.profiles", () => { + it("accepts object", () => { + expectValid({ cache: { profiles: { default: { ttl: 60 } } } }); + }); + + it("accepts array", () => { + expectValid({ cache: { profiles: [{ name: "default" }] } }); + }); + + it("rejects string", () => { + expectInvalid({ cache: { profiles: "bad" } }, "cache.profiles"); + }); + }); + + describe("cache.providers", () => { + it("accepts object", () => { + expectValid({ cache: { providers: { memory: {} } } }); + }); + + it("accepts array", () => { + expectValid({ cache: { providers: [{ driver: "memory" }] } }); + }); + + it("rejects string", () => { + expectInvalid({ cache: { providers: "bad" } }, "cache.providers"); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// serverFunctions.* +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — serverFunctions", () => { + it("accepts empty object", () => { + expectValid({ serverFunctions: {} }); + }); + + it("rejects non-object", () => { + expectInvalid({ serverFunctions: "bad" }, "serverFunctions"); + }); + + describe("serverFunctions.secret", () => { + it("accepts string", () => { + expectValid({ serverFunctions: { secret: "my-secret" } }); + }); + + it("rejects number", () => { + expectInvalid( + { serverFunctions: { secret: 42 } }, + "serverFunctions.secret" + ); + }); + }); + + describe("serverFunctions.secretFile", () => { + it("accepts string", () => { + expectValid({ serverFunctions: { secretFile: "./secret.pem" } }); + }); + + it("rejects number", () => { + expectInvalid( + { serverFunctions: { secretFile: 42 } }, + "serverFunctions.secretFile" + ); + }); + }); + + describe("serverFunctions.previousSecrets", () => { + it("accepts string array", () => { + expectValid({ + serverFunctions: { previousSecrets: ["old-secret"] }, + }); + }); + + it("rejects string", () => { + expectInvalid( + { serverFunctions: { previousSecrets: "bad" } }, + "serverFunctions.previousSecrets" + ); + }); + }); + + describe("serverFunctions.previousSecretFiles", () => { + it("accepts string array", () => { + expectValid({ + serverFunctions: { previousSecretFiles: ["./old.pem"] }, + }); + }); + + it("rejects string", () => { + expectInvalid( + { serverFunctions: { previousSecretFiles: "bad" } }, + "serverFunctions.previousSecretFiles" + ); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// File-router child config: layout, page, middleware, api, router +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — file-router", () => { + for (const key of ["layout", "page", "middleware", "api", "router"]) { + describe(key, () => { + it("accepts object", () => { + expectValid({ [key]: {} }); + }); + + it("accepts function", () => { + expectValid({ [key]: () => ({}) }); + }); + + it("rejects string", () => { + expectInvalid({ [key]: "bad" }, key); + }); + + it("rejects number", () => { + expectInvalid({ [key]: 42 }, key); + }); + }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// mdx.* +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — mdx", () => { + it("accepts empty mdx object", () => { + expectValid({ mdx: {} }); + }); + + it("rejects non-object", () => { + expectInvalid({ mdx: "bad" }, "mdx"); + }); + + describe("mdx.remarkPlugins", () => { + it("accepts array", () => { + expectValid({ mdx: { remarkPlugins: [() => {}] } }); + }); + + it("rejects string", () => { + expectInvalid({ mdx: { remarkPlugins: "bad" } }, "mdx.remarkPlugins"); + }); + }); + + describe("mdx.rehypePlugins", () => { + it("accepts array", () => { + expectValid({ mdx: { rehypePlugins: [() => {}] } }); + }); + + it("rejects string", () => { + expectInvalid({ mdx: { rehypePlugins: "bad" } }, "mdx.rehypePlugins"); + }); + }); + + describe("mdx.components", () => { + it("accepts string", () => { + expectValid({ mdx: { components: "./mdx-components.jsx" } }); + }); + + it("rejects number", () => { + expectInvalid({ mdx: { components: 42 } }, "mdx.components"); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Multiple errors at once +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — multiple errors", () => { + it("reports all errors in one pass", () => { + const result = validateConfig({ + port: "not-a-number", + base: 42, + server: { port: true }, + unknown: "field", + }); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThanOrEqual(4); + const paths = result.errors.map((e) => e.path); + expect(paths).toContain("port"); + expect(paths).toContain("base"); + expect(paths).toContain("server.port"); + expect(paths).toContain("unknown"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// formatValidationErrors +// ═══════════════════════════════════════════════════════════════════════════ + +describe("formatValidationErrors", () => { + it("returns empty string for no errors", () => { + expect(formatValidationErrors([])).toBe(""); + }); + + it("includes error path in output", () => { + const result = validateConfig({ port: "bad" }); + const output = formatValidationErrors(result.errors, { command: "build" }); + expect(output).toContain("port"); + }); + + it("includes example in output when available", () => { + const result = validateConfig({ port: "bad" }); + const output = formatValidationErrors(result.errors, { command: "build" }); + expect(output).toContain("3000"); + }); + + it("includes build abort message for build command", () => { + const result = validateConfig({ port: "bad" }); + const output = formatValidationErrors(result.errors, { command: "build" }); + expect(output).toContain("Build aborted"); + }); + + it("includes dev restart hint for dev command", () => { + const result = validateConfig({ port: "bad" }); + const output = formatValidationErrors(result.errors, { command: "dev" }); + expect(output).toContain("restart automatically"); + }); + + it("shows warnings with warning title when no errors", () => { + const result = validateConfig({ adapter: "my-custom-adapter" }); + const output = formatValidationErrors(result.warnings, { command: "dev" }); + expect(output).toContain("Warnings"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Real-world example configs +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — real-world configs", () => { + it("accepts tanstack-router config", () => { + expectValid({ root: "src/app", export: [{ path: "/" }] }); + }); + + it("accepts spa config", () => { + expectValid({ export: [{ path: "/" }] }); + }); + + it("accepts test config", () => { + expectValid({ + server: { hmr: false }, + console: false, + overlay: false, + cache: { + providers: { + indexedb: { + driver: "unstorage/drivers/indexedb", + }, + }, + }, + }); + }); + + it("accepts express config", () => { + expectValid({ base: "/app" }); + }); + + it("accepts file-router config", () => { + expectValid({ root: "pages" }); + }); + + it("accepts docs config pattern", () => { + expectValid({ + root: "src/pages", + public: "public", + adapter: "netlify", + mdx: { remarkPlugins: [], rehypePlugins: [] }, + prerender: true, + export: () => [], + }); + }); + + it("accepts complex full config", () => { + expectValid({ + root: "src", + base: "/app/", + entry: "./src/App.jsx", + public: "public", + name: "my-app", + adapter: "vercel", + plugins: [{ name: "test-plugin" }], + define: { __DEV__: "true" }, + envDir: "./env", + envPrefix: ["MY_APP_", "VITE_"], + cacheDir: "node_modules/.cache", + external: ["pg"], + sourcemap: "hidden", + compression: true, + export: ["/", "/about"], + prerender: true, + cluster: 4, + cors: { origin: "*" }, + host: "0.0.0.0", + port: 3000, + console: true, + overlay: true, + logLevel: "info", + clearScreen: false, + server: { + https: false, + open: true, + hmr: { port: 24678 }, + fs: { allow: [".."] }, + watch: { usePolling: true }, + origin: "https://example.com", + proxy: { "/api": "http://localhost:4000" }, + trustProxy: true, + headers: { "X-Custom": "value" }, + warmup: { clientFiles: ["./src/main.ts"] }, + }, + resolve: { + alias: { "@": "./src" }, + dedupe: ["react"], + shared: ["shared-utils"], + conditions: ["worker"], + extensions: [".ts"], + mainFields: ["module"], + }, + build: { + target: "esnext", + minify: "esbuild", + chunkSizeWarningLimit: 2048, + rollupOptions: { external: ["lodash"], treeshake: true }, + rolldownOptions: { input: { main: "./src/main.ts" } }, + server: { config: {} }, + client: { config: {} }, + }, + ssr: { + external: ["pg"], + noExternal: ["my-ui-lib"], + resolve: { conditions: ["worker"] }, + worker: false, + }, + css: { + modules: { localsConvention: "camelCase" }, + preprocessorOptions: { scss: {} }, + postcss: "./postcss.config.js", + devSourcemap: true, + }, + optimizeDeps: { + include: ["lodash"], + exclude: ["large-dep"], + force: true, + }, + cache: { profiles: {}, providers: {} }, + serverFunctions: { secret: "key", secretFile: "./key.pem" }, + cookies: { secure: true }, + handlers: { pre: [], post: [] }, + importMap: { imports: {} }, + inspect: true, + runtime: { key: "value" }, + vite: { build: {} }, + mdx: { remarkPlugins: [], rehypePlugins: [], components: "./mdx.jsx" }, + }); + }); +});