diff --git a/.changeset/arkenv-addon.md b/.changeset/arkenv-addon.md new file mode 100644 index 00000000..10073e85 --- /dev/null +++ b/.changeset/arkenv-addon.md @@ -0,0 +1,10 @@ +--- +'@tanstack/create': minor +--- + +Add an ArkEnv add-on for environment variable validation in React Start apps. + +`tanstack add arkenv` (or `--add-ons arkenv` on `tanstack create`) +writes `src/env.ts`, registers `@arkenv/vite-plugin`, and can add a +`/demo/arkenv` route. ArkType, Zod, and Valibot are selectable. ArkEnv +and T3Env are mutually exclusive because both write `src/env.ts`. diff --git a/packages/create/src/add-to-app.ts b/packages/create/src/add-to-app.ts index 10feaf91..ba9977fe 100644 --- a/packages/create/src/add-to-app.ts +++ b/packages/create/src/add-to-app.ts @@ -2,6 +2,7 @@ import { basename, resolve } from 'node:path' import { CONFIG_FILE } from './constants.js' import { finalizeAddOns } from './add-ons.js' +import { assertNoExclusiveAddOnConflicts } from './build-tools.js' import { getFrameworkById } from './frameworks.js' import { createMemoryEnvironment } from './environment.js' import { createApp } from './create-app.js' @@ -25,7 +26,7 @@ import { runSpecialSteps } from './special-steps/index.js' import { loadStarter } from './custom-add-ons/starter.js' import { setupIntent } from './integrations/intent.js' -import type { AddOn, Environment, Options } from './types.js' +import type { Environment, Options } from './types.js' import type { PersistedOptions } from './config-file.js' const ENV_FILE_NAMES = new Set(['.env', '.env.local', '.env.example']) @@ -128,22 +129,6 @@ async function createOptions( } as Options } -function assertNoExclusiveAddOnConflicts(addOns: Array) { - const selectedByExclusiveGroup = new Map() - - for (const addOn of addOns) { - for (const exclusive of addOn.exclusive ?? []) { - const selected = selectedByExclusiveGroup.get(exclusive) - if (selected && selected.id !== addOn.id) { - throw new Error( - `Cannot combine ${selected.name} and ${addOn.name}. Add-ons in the "${exclusive}" group are mutually exclusive.`, - ) - } - selectedByExclusiveGroup.set(exclusive, addOn) - } - } -} - async function runCreateApp(options: Required, packageManagerVersion?: string) { const { environment, output } = createMemoryEnvironment(options.targetDir) await createApp(environment, options, { existingProject: true, packageManagerVersion }) diff --git a/packages/create/src/build-tools.ts b/packages/create/src/build-tools.ts index 880967e5..b6b29089 100644 --- a/packages/create/src/build-tools.ts +++ b/packages/create/src/build-tools.ts @@ -56,6 +56,26 @@ export function assertAddOnSupportedByBundler( } } +export function assertNoExclusiveAddOnConflicts( + addOns: Array, + onlyGroup?: NonNullable[number], +) { + const selectedByExclusiveGroup = new Map() + + for (const addOn of addOns) { + for (const exclusive of addOn.exclusive ?? []) { + if (onlyGroup && exclusive !== onlyGroup) continue + const selected = selectedByExclusiveGroup.get(exclusive) + if (selected && selected.id !== addOn.id) { + throw new Error( + `Cannot combine ${selected.name} and ${addOn.name}. Add-ons in the "${exclusive}" group are mutually exclusive.`, + ) + } + selectedByExclusiveGroup.set(exclusive, addOn) + } + } +} + export function normalizeAndValidateBundlerOptions(options: Options): Options { const bundler = resolveBundler(options.framework, options.bundler).id @@ -67,6 +87,8 @@ export function normalizeAndValidateBundlerOptions(options: Options): Options { assertAddOnSupportedByBundler(addOn, bundler) } + assertNoExclusiveAddOnConflicts(options.chosenAddOns, 'env') + return { ...options, bundler, diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/README.md b/packages/create/src/frameworks/react/add-ons/arkenv/README.md new file mode 100644 index 00000000..be775a12 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/README.md @@ -0,0 +1,18 @@ +## ArkEnv + +Typesafe environment variables for TanStack Start. The add-on installs +`@arkenv/vite-plugin`, writes `src/env.ts`, and can add a `/demo/arkenv` +route that shows server-only keys staying on the server. + +Pick a validator when you scaffold: ArkType (`@arkenv/core`), Zod, or +Valibot (`@arkenv/standard`). + +### Usage + +```ts +import { env } from "#/env"; + +console.log(env.VITE_API_URL); +``` + +Docs: [https://arkenv.js.org/docs/frameworks/tanstack-start](https://arkenv.js.org/docs/frameworks/tanstack-start) diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/assets/_dot_env.example b/packages/create/src/frameworks/react/add-ons/arkenv/assets/_dot_env.example new file mode 100644 index 00000000..079ebef3 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/assets/_dot_env.example @@ -0,0 +1,11 @@ +# Port for the dev/preview server +PORT=3000 + +# Public API URL (inlined into client bundle) +VITE_API_URL=https://api.example.com + +# Server-only database connection URL (protected from client access) +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/db + +# Environment mode +NODE_ENV=development diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/assets/arkenv-vite-plugin.ts.ejs b/packages/create/src/frameworks/react/add-ons/arkenv/assets/arkenv-vite-plugin.ts.ejs new file mode 100644 index 00000000..78ffa7a6 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/assets/arkenv-vite-plugin.ts.ejs @@ -0,0 +1,6 @@ +<% const validator = addOnOption.arkenv?.validator -%> +<% if (validator === 'zod' || validator === 'valibot') { -%> +export { default } from '@arkenv/vite-plugin/standard' +<% } else { -%> +export { default } from '@arkenv/vite-plugin' +<% } -%> diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/env.ts.ejs b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/env.ts.ejs new file mode 100644 index 00000000..feba5cef --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/env.ts.ejs @@ -0,0 +1,55 @@ +<% const validator = (addOnOption.arkenv && addOnOption.arkenv.validator) || 'arktype' -%> +<% if (validator === 'zod') { -%> +import arkenv from '@arkenv/standard' +import { z } from 'zod' + +export const env = arkenv({ + PORT: z.coerce.number().int().min(1).max(65535).default(3000), + VITE_API_URL: z.string().url().default('https://api.example.com'), + DATABASE_URL: z + .string() + .url() + .default('postgresql://postgres:postgres@localhost:5432/db'), + NODE_ENV: z + .enum(['development', 'production', 'test']) + .default('development'), +}) +<% } else if (validator === 'valibot') { -%> +import arkenv from '@arkenv/standard/valibot' +import * as v from 'valibot' + +export const env = arkenv({ + PORT: v.optional( + v.pipe( + v.unknown(), + v.transform(Number), + v.integer(), + v.minValue(1), + v.maxValue(65535), + ), + 3000, + ), + VITE_API_URL: v.optional( + v.pipe(v.string(), v.url()), + 'https://api.example.com', + ), + DATABASE_URL: v.optional( + v.pipe(v.string(), v.url()), + 'postgresql://postgres:postgres@localhost:5432/db', + ), + NODE_ENV: v.optional( + v.picklist(['development', 'production', 'test']), + 'development', + ), +}) +<% } else { -%> +import arkenv from '@arkenv/core' + +export const env = arkenv({ + PORT: 'number.port = 3000', + VITE_API_URL: "string = 'https://api.example.com'", + DATABASE_URL: + "string = 'postgresql://postgres:postgres@localhost:5432/db'", + NODE_ENV: "'development' | 'production' | 'test' = 'development'", +}) +<% } -%> diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/routes/demo/arkenv.tsx.ejs b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/routes/demo/arkenv.tsx.ejs new file mode 100644 index 00000000..60e2a2b7 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/routes/demo/arkenv.tsx.ejs @@ -0,0 +1,82 @@ +<% if (!includeExamples || routerOnly || !fileRouter) { ignoreFile(); return; } %> +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { env } from '../../env' + +const getDatabaseConfig = createServerFn({ method: 'GET' }).handler(() => { + // Read the server-only key here. Return a fixed example so the deployed + // database endpoint is not sent to the browser. + if (!env.DATABASE_URL) { + throw new Error('DATABASE_URL is not set') + } + + return { host: 'localhost:5432', protocol: 'postgresql:' } +}) + +export const Route = createFileRoute('/demo/arkenv')({ + component: ArkEnvDemo, + loader: () => getDatabaseConfig(), +}) + +function LeakedSecret() { + // Accessing server-only DATABASE_URL directly on the client throws at runtime + return

Server key leaked: {env.DATABASE_URL}

+} + +function ArkEnvDemo() { + const dbConfig = Route.useLoaderData() + const [attemptLeak, setAttemptLeak] = useState(false) + + return ( +
+

ArkEnv Demo

+

+ Typesafe environment variables with build-time validation and runtime + leak protection. +

+ +
+

Public Client Variables

+

+ Inlined safely into client bundles: +

+ + env.VITE_API_URL: {env.VITE_API_URL} + +
+ +
+

Server-Only Variables

+

+ Accessible inside createServerFn handlers. This page shows an example + endpoint, not the deployed database host: +

+ + Example endpoint: {dbConfig.host} ({dbConfig.protocol}) + +
+ +
+

+ Secret Leak Protection +

+

+ Clicking the button below attempts to access the server secret{' '} + env.DATABASE_URL on the client, which ArkEnv blocks: +

+ {attemptLeak ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/info.json b/packages/create/src/frameworks/react/add-ons/arkenv/info.json new file mode 100644 index 00000000..850facd1 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/info.json @@ -0,0 +1,50 @@ +{ + "id": "arkenv", + "name": "ArkEnv", + "description": "Typesafe environment variable validation with build-time validation and runtime leak protection.", + "type": "add-on", + "phase": "add-on", + "category": "tooling", + "exclusive": ["env"], + "color": "#06B6D4", + "priority": 28, + "link": "https://arkenv.js.org", + "modes": ["file-router", "code-router"], + "options": { + "validator": { + "type": "select", + "label": "Validator Engine", + "default": "arktype", + "options": [ + { + "value": "arktype", + "label": "ArkType (@arkenv/core) - Recommended" + }, + { + "value": "zod", + "label": "Zod (@arkenv/standard)" + }, + { + "value": "valibot", + "label": "Valibot (@arkenv/standard)" + } + ] + } + }, + "routes": [ + { + "url": "/demo/arkenv", + "name": "ArkEnv Demo", + "path": "src/routes/demo/arkenv.tsx", + "jsName": "ArkEnvDemo" + } + ], + "integrations": [ + { + "type": "vite-plugin", + "path": "arkenv-vite-plugin.ts", + "jsName": "arkenvPlugin", + "code": "arkenvPlugin()" + } + ] +} diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/package.json.ejs b/packages/create/src/frameworks/react/add-ons/arkenv/package.json.ejs new file mode 100644 index 00000000..6005736b --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/package.json.ejs @@ -0,0 +1,19 @@ +<% const validator = (addOnOption.arkenv && addOnOption.arkenv.validator) || 'arktype' -%> +{ + "dependencies": { +<% if (validator === 'zod') { -%> + "@arkenv/standard": "^1.0.0-rc.2", + "zod": "^4.4.1" +<% } else if (validator === 'valibot') { -%> + "@arkenv/standard": "^1.0.0-rc.2", + "@valibot/to-json-schema": "^1.0.0", + "valibot": "^1.0.0" +<% } else { -%> + "@arkenv/core": "^1.0.0-rc.2", + "arktype": "^2.2.0" +<% } -%> + }, + "devDependencies": { + "@arkenv/vite-plugin": "^1.0.0-rc.5" + } +} diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/small-logo.svg b/packages/create/src/frameworks/react/add-ons/arkenv/small-logo.svg new file mode 100644 index 00000000..5dd0d792 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/small-logo.svg @@ -0,0 +1,14 @@ + + ArkEnv + + + diff --git a/packages/create/src/frameworks/react/add-ons/t3env/info.json b/packages/create/src/frameworks/react/add-ons/t3env/info.json index 55a3b856..4e7dd903 100644 --- a/packages/create/src/frameworks/react/add-ons/t3env/info.json +++ b/packages/create/src/frameworks/react/add-ons/t3env/info.json @@ -4,6 +4,7 @@ "phase": "add-on", "type": "add-on", "category": "tooling", + "exclusive": ["env"], "color": "#6366F1", "priority": 29, "link": "https://github.com/t3-oss/t3-env", diff --git a/packages/create/src/types.ts b/packages/create/src/types.ts index 99ca24f2..b18df2cf 100644 --- a/packages/create/src/types.ts +++ b/packages/create/src/types.ts @@ -60,7 +60,7 @@ export const AddOnBaseSchema = z.object({ ]) .optional(), exclusive: z - .array(z.enum(['orm', 'auth', 'deploy', 'database', 'linter'])) + .array(z.enum(['orm', 'auth', 'deploy', 'database', 'linter', 'env'])) .optional(), color: z.string().optional(), priority: z.number().optional(), diff --git a/packages/create/tests/arkenv-addon.test.ts b/packages/create/tests/arkenv-addon.test.ts new file mode 100644 index 00000000..a19eea6a --- /dev/null +++ b/packages/create/tests/arkenv-addon.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + finalizeAddOns, + populateAddOnOptionsDefaults, +} from '../src/add-ons.js' +import { createApp } from '../src/create-app.js' +import { createMemoryEnvironment } from '../src/environment.js' +import { + createApp as createEdgeApp, + createMemoryEnvironment as createEdgeMemoryEnvironment, + finalizeAddOns as finalizeEdgeAddOns, + getFrameworkById as getEdgeFrameworkById, + populateAddOnOptionsDefaults as populateEdgeAddOnOptionsDefaults, +} from '../src/edge.js' +import { createFrameworkDefinition } from '../src/frameworks/react/index.js' +import { createBundledWorkerManifestLoader } from '../src/generated/worker/bundled-loader.js' +import { + createMemoryEnvironment as createWorkerMemoryEnvironment, + createWorkerCreate, +} from '../src/worker.js' + +import type { Framework, FrameworkDefinition, Options } from '../src/types.js' + +function frameworkFromDefinition(definition: FrameworkDefinition): Framework { + const { addOns, base, ...framework } = definition + + return { + ...framework, + getFiles: () => Promise.resolve(Object.keys(base)), + getFileContents: (path: string) => Promise.resolve(base[path]), + getDeletedFiles: () => Promise.resolve([]), + getAddOns: () => addOns, + } +} + +async function generateArkEnvApp( + validator?: string, + routerOnly = false, + addOnIds = ['arkenv'], +) { + const definition = createFrameworkDefinition() + const framework = frameworkFromDefinition(definition) + const chosenAddOns = await finalizeAddOns(framework, 'file-router', addOnIds) + const targetDir = '/arkenv-app' + const { environment, output } = createMemoryEnvironment(targetDir) + + await createApp(environment, { + projectName: 'arkenv-app', + targetDir, + framework, + mode: 'file-router', + typescript: true, + tailwind: true, + packageManager: 'pnpm', + git: false, + install: false, + intent: false, + chosenAddOns, + addOnOptions: { + ...populateAddOnOptionsDefaults(chosenAddOns), + ...(validator ? { arkenv: { validator } } : {}), + }, + includeExamples: true, + routerOnly, + } satisfies Options) + + return output +} + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ version: '1.0.0' }), { status: 200 }), + ), + ) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('ArkEnv add-on', () => { + it('uses the ArkType Vite plugin by default and keeps the database endpoint off the demo page', async () => { + const output = await generateArkEnvApp() + const demo = output.files['src/routes/demo/arkenv.tsx'] + + expect(output.files['vite.config.ts']).toContain('arkenv-vite-plugin.ts') + expect(output.files['arkenv-vite-plugin.ts']).toContain( + "from '@arkenv/vite-plugin'", + ) + expect(output.files['src/env.ts']).toContain("from '@arkenv/core'") + expect(demo).toContain("host: 'localhost:5432'") + expect(demo).toContain("protocol: 'postgresql:'") + expect(demo).not.toContain('new URL') + expect(demo).not.toContain('url.host') + }) + + it.each(['zod', 'valibot'])( + 'uses the standard Vite plugin for %s', + async (validator) => { + const output = await generateArkEnvApp(validator) + + expect(output.files['vite.config.ts']).toContain('arkenv-vite-plugin.ts') + expect(output.files['arkenv-vite-plugin.ts']).toContain( + "from '@arkenv/vite-plugin/standard'", + ) + expect(output.files['src/env.ts']).toContain( + validator === 'valibot' + ? "from '@arkenv/standard/valibot'" + : "from '@arkenv/standard'", + ) + }, + ) + + it('rejects ArkEnv and T3Env before either can write src/env.ts', async () => { + await expect( + generateArkEnvApp(undefined, false, ['arkenv', 't3env']), + ).rejects.toThrow('Add-ons in the "env" group are mutually exclusive.') + }) + + it('omits the Start demo in a Router-only project', async () => { + const output = await generateArkEnvApp('arktype', true) + expect(output.files['src/routes/demo/arkenv.tsx']).toBeUndefined() + }) + + it('rejects out-of-range ports in the Valibot schema', async () => { + const output = await generateArkEnvApp('valibot') + const env = output.files['src/env.ts'] + + expect(env).toContain('v.minValue(1)') + expect(env).toContain('v.maxValue(65535)') + }) + + it('renders the validator-specific Vite import on the edge and worker paths', async () => { + const edgeFramework = getEdgeFrameworkById('react') + expect(edgeFramework).toBeDefined() + const edgeAddOns = await finalizeEdgeAddOns(edgeFramework!, 'file-router', [ + 'arkenv', + ]) + const { environment: edgeEnvironment, output: edgeOutput } = + createEdgeMemoryEnvironment('/arkenv-edge') + + await createEdgeApp(edgeEnvironment, { + projectName: 'arkenv-edge', + targetDir: '/arkenv-edge', + framework: edgeFramework!, + mode: 'file-router', + typescript: true, + tailwind: true, + packageManager: 'pnpm', + git: false, + install: false, + intent: false, + chosenAddOns: edgeAddOns, + addOnOptions: { + ...populateEdgeAddOnOptionsDefaults(edgeAddOns), + arkenv: { validator: 'zod' }, + }, + includeExamples: false, + } satisfies Options) + + expect(edgeOutput.files['vite.config.ts']).toContain('arkenv-vite-plugin.ts') + expect(edgeOutput.files['arkenv-vite-plugin.ts']).toContain( + "from '@arkenv/vite-plugin/standard'", + ) + + const workerCreate = createWorkerCreate(createBundledWorkerManifestLoader()) + const workerFramework = await workerCreate.getFrameworkById('react') + const workerAddOns = await workerCreate.finalizeAddOns( + workerFramework!, + 'file-router', + ['arkenv'], + ) + const { environment: workerEnvironment, output: workerOutput } = + createWorkerMemoryEnvironment('/arkenv-worker') + + await workerCreate.createApp(workerEnvironment, { + projectName: 'arkenv-worker', + targetDir: '/arkenv-worker', + framework: workerFramework!, + mode: 'file-router', + typescript: true, + tailwind: true, + packageManager: 'pnpm', + git: false, + install: false, + intent: false, + chosenAddOns: workerAddOns, + addOnOptions: { + ...workerCreate.populateAddOnOptionsDefaults(workerAddOns), + arkenv: { validator: 'valibot' }, + }, + includeExamples: false, + } satisfies Options) + + expect(workerOutput.files['vite.config.ts']).toContain('arkenv-vite-plugin.ts') + expect(workerOutput.files['arkenv-vite-plugin.ts']).toContain( + "from '@arkenv/vite-plugin/standard'", + ) + }) +})