Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/arkenv-addon.md
Original file line number Diff line number Diff line change
@@ -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`.
21 changes: 21 additions & 0 deletions packages/create/scripts/generate-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
}

Expand Down
16 changes: 11 additions & 5 deletions packages/create/src/edge-template-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
integrationImportCode,

renderTemplate: (templateContent: string) => {
Expand Down
18 changes: 18 additions & 0 deletions packages/create/src/frameworks/react/add-ons/arkenv/README.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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'",
})
<% } -%>
Original file line number Diff line number Diff line change
@@ -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 <p>Server key leaked: {env.DATABASE_URL}</p>
}

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

return (
<div className="p-6 max-w-xl mx-auto space-y-4 font-sans">
<h1 className="text-2xl font-bold">ArkEnv Demo</h1>
<p className="text-sm text-gray-600">
Typesafe environment variables with build-time validation and runtime
leak protection.
</p>

<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-md space-y-2">
<h2 className="font-semibold text-lg">Public Client Variables</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">
Inlined safely into client bundles:
</p>
<code className="block p-2 bg-white dark:bg-black rounded border text-xs font-mono">
env.VITE_API_URL: {env.VITE_API_URL}
</code>
</div>

<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-md space-y-2">
<h2 className="font-semibold text-lg">Server-Only Variables</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">
Accessible inside createServerFn handlers. This page shows an example
endpoint, not the deployed database host:
</p>
<code className="block p-2 bg-white dark:bg-black rounded border text-xs font-mono">
Example endpoint: {dbConfig.host} ({dbConfig.protocol})
</code>
</div>

<div className="p-4 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-md space-y-2">
<h2 className="font-semibold text-red-800 dark:text-red-300 text-lg">
Secret Leak Protection
</h2>
<p className="text-sm text-red-700 dark:text-red-400">
Clicking the button below attempts to access the server secret{' '}
<code>env.DATABASE_URL</code> on the client, which ArkEnv blocks:
</p>
{attemptLeak ? (
<LeakedSecret />
) : (
<button
type="button"
className="px-3 py-1.5 bg-red-600 text-white rounded text-sm hover:bg-red-700 cursor-pointer"
onClick={() => setAttemptLeak(true)}
>
Attempt client access to DATABASE_URL
</button>
)}
</div>
</div>
)
}
49 changes: 49 additions & 0 deletions packages/create/src/frameworks/react/add-ons/arkenv/info.json
Original file line number Diff line number Diff line change
@@ -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()"
}
]
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
14 changes: 14 additions & 0 deletions packages/create/src/frameworks/react/add-ons/arkenv/small-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 11 additions & 5 deletions packages/create/src/template-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/create/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading