From 3d52566ecd4548ef00841437bd91cd66976cf6dd Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 25 Sep 2026 21:06:46 +0500 Subject: [PATCH 1/7] feat(arkenv): introduce ArkEnv add-on for typesafe environment variables in React Start apps - Added a new add-on, ArkEnv, which provides typesafe environment variable validation with build-time validation and runtime leak protection. - Includes a demo route to showcase server-only keys and their protection. - Supports multiple validator options: ArkType, Zod, and Valibot. - Updated documentation and example files for usage guidance. This enhances the tooling available for managing environment variables in TanStack Start applications. --- .changeset/arkenv-addon.md | 5 + .../frameworks/react/add-ons/arkenv/README.md | 18 ++++ .../add-ons/arkenv/assets/_dot_env.example | 11 +++ .../add-ons/arkenv/assets/src/env.ts.ejs | 37 ++++++++ .../assets/src/routes/demo/arkenv.tsx.ejs | 93 +++++++++++++++++++ .../frameworks/react/add-ons/arkenv/info.json | 65 +++++++++++++ .../react/add-ons/arkenv/package.json.ejs | 24 +++++ .../react/add-ons/arkenv/small-logo.svg | 14 +++ .../frameworks/react/add-ons/t3env/info.json | 1 + 9 files changed, 268 insertions(+) create mode 100644 .changeset/arkenv-addon.md create mode 100644 packages/create/src/frameworks/react/add-ons/arkenv/README.md create mode 100644 packages/create/src/frameworks/react/add-ons/arkenv/assets/_dot_env.example create mode 100644 packages/create/src/frameworks/react/add-ons/arkenv/assets/src/env.ts.ejs create mode 100644 packages/create/src/frameworks/react/add-ons/arkenv/assets/src/routes/demo/arkenv.tsx.ejs create mode 100644 packages/create/src/frameworks/react/add-ons/arkenv/info.json create mode 100644 packages/create/src/frameworks/react/add-ons/arkenv/package.json.ejs create mode 100644 packages/create/src/frameworks/react/add-ons/arkenv/small-logo.svg diff --git a/.changeset/arkenv-addon.md b/.changeset/arkenv-addon.md new file mode 100644 index 00000000..17529833 --- /dev/null +++ b/.changeset/arkenv-addon.md @@ -0,0 +1,5 @@ +--- +"@tanstack/create": minor +--- + +Add an ArkEnv add-on for typesafe environment variables in React Start apps. 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..7b71e247 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/env.ts.ejs @@ -0,0 +1,37 @@ +<% + const arkenvOption = (typeof addOnOption !== 'undefined' && ( + addOnOption['arkenv'] || + Object.entries(addOnOption).find(([k]) => k.includes('arkenv') || k.includes('info.json'))?.[1] + )) || {}; + const validator = arkenvOption.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()), 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..5d282182 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/routes/demo/arkenv.tsx.ejs @@ -0,0 +1,93 @@ +<% + const arkenvOption = (typeof addOnOption !== 'undefined' && ( + addOnOption['arkenv'] || + Object.entries(addOnOption).find(([k]) => k.includes('arkenv') || k.includes('info.json'))?.[1] + )) || {}; + const includeDemo = typeof includeExamples !== 'undefined' ? includeExamples : true; + if (arkenvOption.demo === 'false' || !includeDemo) { + if (typeof ignoreFile === 'function') { + ignoreFile(); + return; + } + } +-%> +import { createFileRoute } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; +import { useState } from "react"; +import { env } from "../../env"; + +const getDatabaseConfig = createServerFn({ method: "GET" }).handler(() => { + // Server-only key: safely read on the server during SSR / RPC + try { + const url = new URL(env.DATABASE_URL); + return { host: url.host, protocol: url.protocol }; + } catch { + 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: +

+ + Database Host: {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..cf1f8701 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/info.json @@ -0,0 +1,65 @@ +{ + "id": "arkenv", + "name": "ArkEnv", + "version": "1.0.0-rc.2", + "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)" + } + ] + }, + "demo": { + "type": "select", + "label": "Interactive Demo Route", + "default": "true", + "options": [ + { + "value": "true", + "label": "Include /demo/arkenv (recommended)" + }, + { + "value": "false", + "label": "Skip demo route" + } + ] + } + }, + "routes": [ + { + "url": "/demo/arkenv", + "name": "ArkEnv Demo", + "path": "src/routes/demo/arkenv.tsx", + "jsName": "ArkEnvDemo" + } + ], + "integrations": [ + { + "type": "vite-plugin", + "import": "import arkenv from '@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..bf40b2e3 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/arkenv/package.json.ejs @@ -0,0 +1,24 @@ +<% + const arkenvOption = (typeof addOnOption !== 'undefined' && ( + addOnOption['arkenv'] || + Object.entries(addOnOption).find(([k]) => k.includes('arkenv') || k.includes('info.json'))?.[1] + )) || {}; + const validator = arkenvOption.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", From cb1cf69a90b912a2f18072ca709d4dca02446ecc Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 25 Sep 2026 21:14:27 +0500 Subject: [PATCH 2/7] fix(types): add 'env' option to exclusive add-ons array - Updated the exclusive add-ons array in the AddOnBaseSchema to include 'env', allowing for better management of environment-related add-ons. --- packages/create/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(), From 7fe89406810f7cc7f6fc1bfaf1d80dc3bc96f398 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 25 Sep 2026 21:31:58 +0500 Subject: [PATCH 3/7] Add ArkEnv add-on for environment variable validation in React Start apps - Updated the description to clarify that the add-on provides environment variable validation. - Added functionality for `tanstack add arkenv` to write `src/env.ts`, register `@arkenv/vite-plugin`, and create a `/demo/arkenv` route. - Included selectable validators: ArkType, Zod, and Valibot, with mutual exclusivity between ArkEnv and T3Env due to both writing `src/env.ts`. --- .changeset/arkenv-addon.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/arkenv-addon.md b/.changeset/arkenv-addon.md index 17529833..e6bc9539 100644 --- a/.changeset/arkenv-addon.md +++ b/.changeset/arkenv-addon.md @@ -2,4 +2,5 @@ "@tanstack/create": minor --- -Add an ArkEnv add-on for typesafe environment variables in React Start apps. +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`. From 7fec7fa5521a43fa37e9a07cfd2959b53103f008 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 25 Sep 2026 21:35:32 +0500 Subject: [PATCH 4/7] Refactor ArkEnv add-on documentation for clarity - Improved formatting of the description for better readability. - Clarified the functionality of `tanstack add arkenv`, including its effects on `src/env.ts`, registration of `@arkenv/vite-plugin`, and the addition of a `/demo/arkenv` route. - Maintained details on selectable validators and mutual exclusivity between ArkEnv and T3Env. --- .changeset/arkenv-addon.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/arkenv-addon.md b/.changeset/arkenv-addon.md index e6bc9539..10073e85 100644 --- a/.changeset/arkenv-addon.md +++ b/.changeset/arkenv-addon.md @@ -1,6 +1,10 @@ --- -"@tanstack/create": minor +'@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`. + +`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`. From 6e489c7d30504c053c8a2fa10e11f1cf290d4440 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 25 Sep 2026 21:43:13 +0500 Subject: [PATCH 5/7] Refactor ArkEnv add-on configuration and demo route - Streamlined the structure by removing unnecessary fields while retaining essential information. - Updated and to simplify validator option handling. - Enhanced the demo route in for better readability and maintainability, ensuring proper handling of server-only variables and client access attempts. - Improved overall code formatting for consistency and clarity. --- .../add-ons/arkenv/assets/src/env.ts.ejs | 55 ++++--- .../assets/src/routes/demo/arkenv.tsx.ejs | 150 ++++++++---------- .../frameworks/react/add-ons/arkenv/info.json | 110 ++++++------- .../react/add-ons/arkenv/package.json.ejs | 8 +- 4 files changed, 149 insertions(+), 174 deletions(-) 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 index 7b71e247..be2e42fd 100644 --- 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 @@ -1,37 +1,46 @@ -<% - const arkenvOption = (typeof addOnOption !== 'undefined' && ( - addOnOption['arkenv'] || - Object.entries(addOnOption).find(([k]) => k.includes('arkenv') || k.includes('info.json'))?.[1] - )) || {}; - const validator = arkenvOption.validator || 'arktype'; --%> +<% const validator = (addOnOption.arkenv && addOnOption.arkenv.validator) || 'arktype' -%> <% if (validator === 'zod') { -%> -import arkenv from "@arkenv/standard"; -import { z } from "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"), -}); + 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"; +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()), 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"), -}); + 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"; +import arkenv from '@arkenv/core' export const env = arkenv({ - PORT: "number.port = 3000", + PORT: 'number.port = 3000', VITE_API_URL: "string = 'https://api.example.com'", - DATABASE_URL: "string = 'postgresql://postgres:postgres@localhost:5432/db'", + 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 index 5d282182..71643788 100644 --- 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 @@ -1,93 +1,81 @@ -<% - const arkenvOption = (typeof addOnOption !== 'undefined' && ( - addOnOption['arkenv'] || - Object.entries(addOnOption).find(([k]) => k.includes('arkenv') || k.includes('info.json'))?.[1] - )) || {}; - const includeDemo = typeof includeExamples !== 'undefined' ? includeExamples : true; - if (arkenvOption.demo === 'false' || !includeDemo) { - if (typeof ignoreFile === 'function') { - ignoreFile(); - return; - } - } --%> -import { createFileRoute } from "@tanstack/react-router"; -import { createServerFn } from "@tanstack/react-start"; -import { useState } from "react"; -import { env } from "../../env"; +<% 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(() => { - // Server-only key: safely read on the server during SSR / RPC - try { - const url = new URL(env.DATABASE_URL); - return { host: url.host, protocol: url.protocol }; - } catch { - return { host: "localhost:5432", protocol: "postgresql:" }; - } -}); +const getDatabaseConfig = createServerFn({ method: 'GET' }).handler(() => { + // Server-only key: safely read on the server during SSR / RPC + try { + const url = new URL(env.DATABASE_URL) + return { host: url.host, protocol: url.protocol } + } catch { + return { host: 'localhost:5432', protocol: 'postgresql:' } + } +}) -export const Route = createFileRoute("/demo/arkenv")({ - component: ArkEnvDemo, - loader: () => getDatabaseConfig(), -}); +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}

; + // 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); + const dbConfig = Route.useLoaderData() + const [attemptLeak, setAttemptLeak] = useState(false) - return ( -
-

ArkEnv Demo

-

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

+ 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} - -
+
+

Public Client Variables

+

+ Inlined safely into client bundles: +

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

Server-Only Variables

-

- Accessible inside createServerFn handlers: -

- - Database Host: {dbConfig.host} ({dbConfig.protocol}) - -
+
+

Server-Only Variables

+

+ Accessible inside createServerFn handlers: +

+ + Database Host: {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 ? ( - - ) : ( - - )} -
-
- ); +
+

+ 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 index cf1f8701..6598d3c8 100644 --- a/packages/create/src/frameworks/react/add-ons/arkenv/info.json +++ b/packages/create/src/frameworks/react/add-ons/arkenv/info.json @@ -1,65 +1,49 @@ { - "id": "arkenv", - "name": "ArkEnv", - "version": "1.0.0-rc.2", - "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)" - } - ] - }, - "demo": { - "type": "select", - "label": "Interactive Demo Route", - "default": "true", - "options": [ - { - "value": "true", - "label": "Include /demo/arkenv (recommended)" - }, - { - "value": "false", - "label": "Skip demo route" - } - ] - } - }, - "routes": [ - { - "url": "/demo/arkenv", - "name": "ArkEnv Demo", - "path": "src/routes/demo/arkenv.tsx", - "jsName": "ArkEnvDemo" - } - ], - "integrations": [ - { - "type": "vite-plugin", - "import": "import arkenv from '@arkenv/vite-plugin'", - "code": "arkenv()" - } - ] + "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 '@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 index bf40b2e3..dda88865 100644 --- a/packages/create/src/frameworks/react/add-ons/arkenv/package.json.ejs +++ b/packages/create/src/frameworks/react/add-ons/arkenv/package.json.ejs @@ -1,10 +1,4 @@ -<% - const arkenvOption = (typeof addOnOption !== 'undefined' && ( - addOnOption['arkenv'] || - Object.entries(addOnOption).find(([k]) => k.includes('arkenv') || k.includes('info.json'))?.[1] - )) || {}; - const validator = arkenvOption.validator || 'arktype'; --%> +<% const validator = (addOnOption.arkenv && addOnOption.arkenv.validator) || 'arktype' -%> { "dependencies": { <% if (validator === 'zod') { -%> From ed2ae67e93a0bb9e8f5fc3044b50da9b53007d8f Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Sat, 26 Sep 2026 13:08:27 +0000 Subject: [PATCH 6/7] Fix ArkEnv plugin entry, port bounds, and demo disclosure. Zod and Valibot need the standard Vite plugin, the Valibot port schema should reject values outside 1-65535, and the demo route should not send the deployed database host to the browser. --- packages/create/src/edge-template-file.ts | 16 ++- .../add-ons/arkenv/assets/src/env.ts.ejs | 11 +- .../assets/src/routes/demo/arkenv.tsx.ejs | 17 +-- .../frameworks/react/add-ons/arkenv/info.json | 2 +- packages/create/src/template-file.ts | 16 ++- packages/create/tests/arkenv-addon.test.ts | 105 ++++++++++++++++++ 6 files changed, 147 insertions(+), 20 deletions(-) create mode 100644 packages/create/tests/arkenv-addon.test.ts 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/assets/src/env.ts.ejs b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/env.ts.ejs index be2e42fd..33458e19 100644 --- 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 @@ -19,7 +19,16 @@ 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()), 3000), + 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', 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 index 71643788..953ea617 100644 --- 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 @@ -5,13 +5,13 @@ import { createServerFn } from '@tanstack/react-start' import { env } from '../../env' const getDatabaseConfig = createServerFn({ method: 'GET' }).handler(() => { - // Server-only key: safely read on the server during SSR / RPC - try { - const url = new URL(env.DATABASE_URL) - return { host: url.host, protocol: url.protocol } - } catch { - return { host: 'localhost:5432', protocol: 'postgresql:' } + // 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')({ @@ -49,10 +49,11 @@ function ArkEnvDemo() {

Server-Only Variables

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

- Database Host: {dbConfig.host} ({dbConfig.protocol}) + Example endpoint: {dbConfig.host} ({dbConfig.protocol})
diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/info.json b/packages/create/src/frameworks/react/add-ons/arkenv/info.json index 6598d3c8..d780c64a 100644 --- a/packages/create/src/frameworks/react/add-ons/arkenv/info.json +++ b/packages/create/src/frameworks/react/add-ons/arkenv/info.json @@ -42,7 +42,7 @@ "integrations": [ { "type": "vite-plugin", - "import": "import arkenv from '@arkenv/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/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/tests/arkenv-addon.test.ts b/packages/create/tests/arkenv-addon.test.ts new file mode 100644 index 00000000..7cc27a32 --- /dev/null +++ b/packages/create/tests/arkenv-addon.test.ts @@ -0,0 +1,105 @@ +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 { createFrameworkDefinition } from '../src/frameworks/react/index.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)') + }) +}) From c451a80dcc42922964605fc0cce60edb0f6789e5 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Sat, 26 Sep 2026 13:51:22 +0000 Subject: [PATCH 7/7] Precompile inline integration imports for edge rendering. The ArkEnv Vite import is EJS inside info.json. Edge and worker generation only render templates registered in the manifest, so that import has to be compiled while the manifest is built. --- packages/create/scripts/generate-manifest.mjs | 21 +++++ packages/create/tests/arkenv-addon.test.ts | 78 +++++++++++++++++++ 2 files changed, 99 insertions(+) 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/tests/arkenv-addon.test.ts b/packages/create/tests/arkenv-addon.test.ts index 7cc27a32..c09cdc5c 100644 --- a/packages/create/tests/arkenv-addon.test.ts +++ b/packages/create/tests/arkenv-addon.test.ts @@ -6,7 +6,19 @@ import { } 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' @@ -102,4 +114,70 @@ describe('ArkEnv add-on', () => { 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'", + ) + }) })