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/scripts/generate-manifest.mjs b/packages/create/scripts/generate-manifest.mjs index cdb61f56..70983e14 100644 --- a/packages/create/scripts/generate-manifest.mjs +++ b/packages/create/scripts/generate-manifest.mjs @@ -120,6 +120,15 @@ function scanCatalogDirectory(addOnsBase) { const addOnDir = join(addOnsBase, entry.name) const info = readJson(join(addOnDir, 'info.json')) + for (const integration of info.integrations ?? []) { + if ( + typeof integration.import === 'string' && + integration.import.includes('<%') + ) { + registerTemplate(integration.import) + } + } + let packageAdditions = {} let packageTemplate const packageJsonPath = join(addOnDir, 'package.json') @@ -430,6 +439,18 @@ function createTemplateRenderersForAddOn(addOn) { renderers.set(getTemplateKey(addOn.readme), compileTemplate(addOn.readme)) } + for (const integration of addOn.integrations ?? []) { + if ( + typeof integration.import === 'string' && + integration.import.includes('<%') + ) { + renderers.set( + getTemplateKey(integration.import), + compileTemplate(integration.import), + ) + } + } + return renderers } diff --git a/packages/create/src/edge-template-file.ts b/packages/create/src/edge-template-file.ts index 916a2342..ea5091eb 100644 --- a/packages/create/src/edge-template-file.ts +++ b/packages/create/src/edge-template-file.ts @@ -117,10 +117,6 @@ export function createTemplateFile(environment: Environment, options: Options) { const localRelativePath = (path: string, stripExtension: boolean = false) => relativePath(file, path, stripExtension) - const integrationImportContent = (integration: Integration) => - integration.import || - `import ${integration.jsName} from '${localRelativePath(integration.path || '')}'` - const integrationImportCode = (integration: Integration) => integration.code || integration.jsName @@ -150,7 +146,17 @@ export function createTemplateFile(environment: Environment, options: Options) { relativePath: (path: string, stripExtension: boolean = false) => relativePath(file, path, stripExtension), - integrationImportContent, + integrationImportContent: (integration: Integration) => { + const raw = + integration.import || + `import ${integration.jsName} from '${localRelativePath(integration.path || '')}'` + + if (!raw.includes('<%')) { + return raw + } + + return renderForOptions(options, raw, templateValues) + }, integrationImportCode, renderTemplate: (templateContent: string) => { 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/src/env.ts.ejs b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/env.ts.ejs new file mode 100644 index 00000000..33458e19 --- /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' +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..953ea617 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/routes/demo/arkenv.tsx.ejs @@ -0,0 +1,82 @@ +<% if (!includeExamples) { 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..d780c64a --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/info.json @@ -0,0 +1,49 @@ +{ + "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", + "import": "import arkenv from '<%= (addOnOption.arkenv && (addOnOption.arkenv.validator === 'zod' || addOnOption.arkenv.validator === 'valibot')) ? '@arkenv/vite-plugin/standard' : '@arkenv/vite-plugin' %>'", + "code": "arkenv()" + } + ] +} 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..dda88865 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/package.json.ejs @@ -0,0 +1,18 @@ +<% 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": "^1.0.0" +<% } else { -%> + "@arkenv/core": "^1.0.0-rc.2", + "arktype": "^2.2.0" +<% } -%> + }, + "devDependencies": { + "@arkenv/vite-plugin": "^1.0.0-rc.2" + } +} 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/template-file.ts b/packages/create/src/template-file.ts index 8713c127..c564c269 100644 --- a/packages/create/src/template-file.ts +++ b/packages/create/src/template-file.ts @@ -120,10 +120,6 @@ export function createTemplateFile(environment: Environment, options: Options) { const localRelativePath = (path: string, stripExtension: boolean = false) => relativePath(file, path, stripExtension) - const integrationImportContent = (integration: Integration) => - integration.import || - `import ${integration.jsName} from '${localRelativePath(integration.path || '')}'` - const integrationImportCode = (integration: Integration) => integration.code || integration.jsName @@ -153,7 +149,17 @@ export function createTemplateFile(environment: Environment, options: Options) { relativePath: (path: string, stripExtension: boolean = false) => relativePath(file, path, stripExtension), - integrationImportContent, + integrationImportContent: (integration: Integration) => { + const raw = + integration.import || + `import ${integration.jsName} from '${localRelativePath(integration.path || '')}'` + + if (!raw.includes('<%')) { + return raw + } + + return render(raw, templateValues) + }, integrationImportCode, renderTemplate: (content: string) => { diff --git a/packages/create/src/types.ts b/packages/create/src/types.ts index 27e1212f..cfb17f48 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..c09cdc5c --- /dev/null +++ b/packages/create/tests/arkenv-addon.test.ts @@ -0,0 +1,183 @@ +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) { + const definition = createFrameworkDefinition() + const framework = frameworkFromDefinition(definition) + const chosenAddOns = await finalizeAddOns(framework, 'file-router', ['arkenv']) + 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, + } 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( + "import arkenv from '@arkenv/vite-plugin'", + ) + expect(output.files['vite.config.ts']).not.toContain( + '@arkenv/vite-plugin/standard', + ) + 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( + "import arkenv from '@arkenv/vite-plugin/standard'", + ) + expect(output.files['src/env.ts']).toContain("from '@arkenv/standard'") + }, + ) + + 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( + "import arkenv 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( + "import arkenv from '@arkenv/vite-plugin/standard'", + ) + }) +})