Skip to content

Commit 8dc35ec

Browse files
feat: add official TanStack CLI add-on for ArkEnv (#1811)
> [!IMPORTANT] > **Blocked by #1812**: To ensure the canonical URL `https://arkenv.js.org/tanstack/info.json` resolves in the documentation quickstart commands, #1812 must be merged into `dev` and synced to `main` (`gh workflow run sync-main.yml --ref dev`) so production proxies `/tanstack/*` to `https://arkenv-v1.vercel.app/tanstack/*`. ### Summary This PR adds the official TanStack CLI add-on for ArkEnv (`@arkenv/tanstack-addon`), enabling developers to scaffold typesafe environment variable validation in TanStack Start applications via `tanstack create my-app --add-ons https://arkenv.js.org/tanstack/info.json` or `tanstack add ...`. ### Key Features - **Package Scaffolding (`packages/tanstack-addon`)**: - Authoring metadata in `.add-on/info.json` prompting for validator choice: - **ArkType** (default, via `@arkenv/core` and `arktype`) - **Zod** (via `@arkenv/standard` and `zod`) - **Valibot** (via `@arkenv/standard` and `valibot`) - Dynamic `package.json.ejs` injecting validator runtime dependencies and `@arkenv/vite-plugin` devDependencies aligned with `RELEASE_CONFIG`. - `src/env.ts.ejs` template with fallback matching for remote add-on option IDs (`addOnOption[url]` vs `addOnOption['arkenv']`). - Interactive demo route at `src/routes/demo/arkenv.tsx` showcasing server-only variable protection in `createServerFn`, inlined public `VITE_` variables on the client, and an interactive leak button triggering ArkEnv's throwing getter boundary error. - `.env.example` pre-populated with declared schema keys. - **Standalone Build & Distribution (`scripts/build.ts`)**: - Compiles asset templates into standalone schema-valid JSON bundles (`dist/info.json` and `dist/add-on.json`) conforming to `@tanstack/create`'s `AddOnCompiledSchema`. - Automatically mirrors compiled outputs and raw assets to `apps/www/public/tanstack/` for CDN distribution at `https://arkenv.js.org/tanstack/info.json`. - **Documentation & Changeset**: - Updated `apps/www/content/docs/frameworks/tanstack-start.mdx` with quickstart instructions using TanStack CLI. - Added changeset `.changeset/tanstack-addon.md` with a minor bump for `@arkenv/tanstack-addon`. ### Verification - **Automated Tests**: - `packages/tanstack-addon/tests/compile.test.ts`: validated bundle against `@tanstack/create`'s `AddOnCompiledSchema`. - `packages/tanstack-addon/tests/templates.test.ts`: 10 test cases verifying EJS rendering for ArkType, Zod, Valibot, and URL-based option keys. - `apps/www/lib/package-install-fences.test.ts`: verified remark-npm package-install compliance. - **Workspace Checks**: - `pnpm biome check packages/tanstack-addon`: clean (0 errors, 0 warnings). - `pnpm run check:mdx`: 132 files checked, 0 errors. - `pnpm manypkg check`: workspaces valid. - `pnpm typecheck`: 32/32 Turbo tasks passed. - `pnpm test -- --run`: 161/161 test files passed (1,359 tests passed). - `pnpm --filter www build`: Next.js production build succeeded (108 static pages generated). --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 212554e commit 8dc35ec

23 files changed

Lines changed: 1844 additions & 16 deletions

File tree

.changeset/tanstack-addon.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@arkenv/tanstack-addon": minor
3+
---
4+
5+
#### Add official TanStack CLI add-on for ArkEnv
6+
7+
The TanStack CLI add-on is now available for generating and configuring ArkEnv in TanStack Start applications.
8+
9+
Features include:
10+
- Multi-validator support (ArkType by default; configurable for Zod or Valibot)
11+
- Automated `vite.config.ts` setup with `@arkenv/vite-plugin`
12+
- Typesafe `src/env.ts` schema definition with server secret isolation
13+
- Scaffolding of an interactive demo route at `/demo/arkenv` demonstrating client-side secret protection
14+
15+
Usage:
16+
17+
```bash
18+
npx @tanstack/cli create my-app --add-ons https://arkenv.js.org/tanstack/info.json
19+
```
20+
21+
Or add to an existing project:
22+
23+
```bash
24+
npx @tanstack/cli add https://arkenv.js.org/tanstack/info.json
25+
```

apps/www/content/docs/frameworks/tanstack-start.mdx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,51 @@ See [`init` reference](/docs/reference/init#import-the-validator-from-core).
2020

2121
## Quickstart
2222

23-
Scaffold TanStack Start integration in an existing project:
23+
You can create a new TanStack Start project with ArkEnv pre-configured using the
24+
official TanStack CLI add-on, or scaffold it into an existing project.
2425

25-
```bash title="Terminal"
26-
pnpm dlx arkenv@alpha init
26+
### Create a new project with TanStack CLI
27+
28+
Generate a new TanStack Start project with the ArkEnv add-on:
29+
30+
```package-install
31+
npx @tanstack/cli create my-app --add-ons https://arkenv.js.org/tanstack/info.json
2732
```
2833

29-
Use the runner that matches your lockfile. Init detects the package manager for installs.
34+
By default, the add-on scaffolds with **ArkType** (`@arkenv/core`) and includes the interactive demo route at `/demo/arkenv`.
35+
36+
Configuration options supported by the add-on:
37+
38+
- **Validator Engine**:
39+
- **ArkType** (default) — installs `@arkenv/core` and `arktype`
40+
- **Zod** — installs `@arkenv/standard` and `zod`
41+
- **Valibot** — installs `@arkenv/standard` and `valibot`
42+
- **Interactive Demo Route**:
43+
- **Include `/demo/arkenv`** (default) — scaffolds an interactive secret leak test showcasing server-only isolation in the browser
44+
- **Skip demo route** — scaffolds only the schema definition and plugin configuration (automatically skipped when creating a project with `--no-examples` or the `blank` preset)
45+
46+
The add-on automatically:
47+
48+
- Installs the required validator runtime and dev dependencies
49+
- Configures `arkenvVitePlugin()` in `vite.config.ts`
50+
- Generates `src/env.ts` with your selected validator engine
51+
- Pre-populates declared keys in `.env.example`
52+
53+
<Callout type="info" title="Adding to an existing TanStack project">
54+
If you already have a TanStack application, add ArkEnv directly with the TanStack CLI:
55+
56+
```package-install
57+
npx @tanstack/cli add https://arkenv.js.org/tanstack/info.json
58+
```
59+
</Callout>
60+
61+
### Existing projects with ArkEnv CLI
62+
63+
If you prefer using the ArkEnv CLI in an existing TanStack Start project:
64+
65+
```package-install
66+
npx arkenv init
67+
```
3068

3169
The CLI detects `@tanstack/react-start` in your dependencies and installs
3270
`@arkenv/vite-plugin` the same way it does for Vite projects. For Rsbuild
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"id": "arkenv",
3+
"name": "ArkEnv",
4+
"version": "1.0.0-alpha.1",
5+
"description": "Typesafe environment variable validation with build-time validation and runtime leak protection.",
6+
"type": "add-on",
7+
"phase": "add-on",
8+
"category": "tooling",
9+
"color": "#06B6D4",
10+
"priority": 28,
11+
"link": "https://arkenv.js.org",
12+
"modes": ["file-router", "code-router"],
13+
"options": {
14+
"validator": {
15+
"type": "select",
16+
"label": "Validator Engine",
17+
"default": "arktype",
18+
"options": [
19+
{
20+
"value": "arktype",
21+
"label": "ArkType (@arkenv/core) - Recommended"
22+
},
23+
{
24+
"value": "zod",
25+
"label": "Zod (@arkenv/standard)"
26+
},
27+
{
28+
"value": "valibot",
29+
"label": "Valibot (@arkenv/standard)"
30+
}
31+
]
32+
},
33+
"demo": {
34+
"type": "select",
35+
"label": "Interactive Demo Route",
36+
"default": "true",
37+
"options": [
38+
{
39+
"value": "true",
40+
"label": "Include /demo/arkenv (recommended)"
41+
},
42+
{
43+
"value": "false",
44+
"label": "Skip demo route"
45+
}
46+
]
47+
}
48+
},
49+
"routes": [
50+
{
51+
"url": "/demo/arkenv",
52+
"name": "ArkEnv Demo",
53+
"path": "src/routes/demo/arkenv.tsx",
54+
"jsName": "ArkEnvDemo"
55+
}
56+
],
57+
"integrations": [
58+
{
59+
"type": "vite-plugin",
60+
"import": "import arkenv from '@arkenv/vite-plugin'",
61+
"code": "arkenv()"
62+
}
63+
],
64+
"packageTemplate": "<%\n const arkenvOption = (typeof addOnOption !== 'undefined' && (\n addOnOption['arkenv'] ||\n Object.entries(addOnOption).find(([k]) => k.includes('arkenv') || k.includes('info.json'))?.[1]\n )) || {};\n const validator = arkenvOption.validator || 'arktype';\n-%>\n{\n \"dependencies\": {\n<% if (validator === 'zod') { -%>\n \"@arkenv/standard\": \"^1.0.0-alpha.1\",\n \"zod\": \"^3.24.2\"\n<% } else if (validator === 'valibot') { -%>\n \"@arkenv/standard\": \"^1.0.0-alpha.1\",\n \"valibot\": \"^1.0.0\"\n<% } else { -%>\n \"@arkenv/core\": \"^1.0.0-alpha.1\",\n \"arktype\": \"^2.2.0\"\n<% } -%>\n },\n \"devDependencies\": {\n \"@arkenv/vite-plugin\": \"^1.0.0-alpha.1\"\n }\n}\n",
65+
"files": {
66+
"_dot_env.example": "# Port for the dev/preview server\nPORT=3000\n\n# Public API URL (inlined into client bundle)\nVITE_API_URL=https://api.example.com\n\n# Server-only database connection URL (protected from client access)\nDATABASE_URL=postgresql://postgres:postgres@localhost:5432/db\n\n# Environment mode\nNODE_ENV=development\n",
67+
"src/env.ts.ejs": "<%\n const arkenvOption = (typeof addOnOption !== 'undefined' && (\n addOnOption['arkenv'] ||\n Object.entries(addOnOption).find(([k]) => k.includes('arkenv') || k.includes('info.json'))?.[1]\n )) || {};\n const validator = arkenvOption.validator || 'arktype';\n-%>\n<% if (validator === 'zod') { -%>\nimport arkenv from \"@arkenv/standard\";\nimport { z } from \"zod\";\n\nexport const env = arkenv({\n PORT: z.coerce.number().int().min(1).max(65535).default(3000),\n VITE_API_URL: z.string().url().default(\"https://api.example.com\"),\n DATABASE_URL: z.string().url().default(\"postgresql://postgres:postgres@localhost:5432/db\"),\n NODE_ENV: z.enum([\"development\", \"production\", \"test\"]).default(\"development\"),\n});\n<% } else if (validator === 'valibot') { -%>\nimport arkenv from \"@arkenv/standard\";\nimport * as v from \"valibot\";\n\nexport const env = arkenv({\n PORT: v.optional(v.pipe(v.unknown(), v.transform(Number), v.integer()), 3000),\n VITE_API_URL: v.optional(v.pipe(v.string(), v.url()), \"https://api.example.com\"),\n DATABASE_URL: v.optional(v.pipe(v.string(), v.url()), \"postgresql://postgres:postgres@localhost:5432/db\"),\n NODE_ENV: v.optional(v.picklist([\"development\", \"production\", \"test\"]), \"development\"),\n});\n<% } else { -%>\nimport arkenv from \"@arkenv/core\";\n\nexport const env = arkenv({\n PORT: \"number.port = 3000\",\n VITE_API_URL: \"string = 'https://api.example.com'\",\n DATABASE_URL: \"string = 'postgresql://postgres:postgres@localhost:5432/db'\",\n NODE_ENV: \"'development' | 'production' | 'test' = 'development'\",\n});\n<% } -%>\n",
68+
"src/routes/demo/arkenv.tsx.ejs": "<%\n const arkenvOption = (typeof addOnOption !== 'undefined' && (\n addOnOption['arkenv'] ||\n Object.entries(addOnOption).find(([k]) => k.includes('arkenv') || k.includes('info.json'))?.[1]\n )) || {};\n const includeDemo = typeof includeExamples !== 'undefined' ? includeExamples : true;\n if (arkenvOption.demo === 'false' || !includeDemo) {\n if (typeof ignoreFile === 'function') {\n ignoreFile();\n return;\n }\n }\n-%>\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { createServerFn } from \"@tanstack/react-start\";\nimport { useState } from \"react\";\nimport { env } from \"../../env\";\n\nconst getDatabaseConfig = createServerFn({ method: \"GET\" }).handler(() => {\n\t// Server-only key: safely read on the server during SSR / RPC\n\ttry {\n\t\tconst url = new URL(env.DATABASE_URL);\n\t\treturn { host: url.host, protocol: url.protocol };\n\t} catch {\n\t\treturn { host: \"localhost:5432\", protocol: \"postgresql:\" };\n\t}\n});\n\nexport const Route = createFileRoute(\"/demo/arkenv\")({\n\tcomponent: ArkEnvDemo,\n\tloader: () => getDatabaseConfig(),\n});\n\nfunction LeakedSecret() {\n\t// Accessing server-only DATABASE_URL directly on the client throws at runtime\n\treturn <p>Server key leaked: {env.DATABASE_URL}</p>;\n}\n\nfunction ArkEnvDemo() {\n\tconst dbConfig = Route.useLoaderData();\n\tconst [attemptLeak, setAttemptLeak] = useState(false);\n\n\treturn (\n\t\t<div className=\"p-6 max-w-xl mx-auto space-y-4 font-sans\">\n\t\t\t<h1 className=\"text-2xl font-bold\">ArkEnv Demo</h1>\n\t\t\t<p className=\"text-sm text-gray-600\">\n\t\t\t\tTypesafe environment variables with build-time validation and runtime\n\t\t\t\tleak protection.\n\t\t\t</p>\n\n\t\t\t<div className=\"p-4 bg-gray-100 dark:bg-gray-800 rounded-md space-y-2\">\n\t\t\t\t<h2 className=\"font-semibold text-lg\">Public Client Variables</h2>\n\t\t\t\t<p className=\"text-sm text-gray-600 dark:text-gray-300\">\n\t\t\t\t\tInlined safely into client bundles:\n\t\t\t\t</p>\n\t\t\t\t<code className=\"block p-2 bg-white dark:bg-black rounded border text-xs font-mono\">\n\t\t\t\t\tenv.VITE_API_URL: {env.VITE_API_URL}\n\t\t\t\t</code>\n\t\t\t</div>\n\n\t\t\t<div className=\"p-4 bg-gray-100 dark:bg-gray-800 rounded-md space-y-2\">\n\t\t\t\t<h2 className=\"font-semibold text-lg\">Server-Only Variables</h2>\n\t\t\t\t<p className=\"text-sm text-gray-600 dark:text-gray-300\">\n\t\t\t\t\tAccessible inside createServerFn handlers:\n\t\t\t\t</p>\n\t\t\t\t<code className=\"block p-2 bg-white dark:bg-black rounded border text-xs font-mono\">\n\t\t\t\t\tDatabase Host: {dbConfig.host} ({dbConfig.protocol})\n\t\t\t\t</code>\n\t\t\t</div>\n\n\t\t\t<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\">\n\t\t\t\t<h2 className=\"font-semibold text-red-800 dark:text-red-300 text-lg\">\n\t\t\t\t\tSecret Leak Protection\n\t\t\t\t</h2>\n\t\t\t\t<p className=\"text-sm text-red-700 dark:text-red-400\">\n\t\t\t\t\tClicking the button below attempts to access the server secret{\" \"}\n\t\t\t\t\t<code>env.DATABASE_URL</code> on the client, which ArkEnv blocks:\n\t\t\t\t</p>\n\t\t\t\t{attemptLeak ? (\n\t\t\t\t\t<LeakedSecret />\n\t\t\t\t) : (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclassName=\"px-3 py-1.5 bg-red-600 text-white rounded text-sm hover:bg-red-700 cursor-pointer\"\n\t\t\t\t\t\tonClick={() => setAttemptLeak(true)}\n\t\t\t\t\t>\n\t\t\t\t\t\tAttempt client access to DATABASE_URL\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
69+
},
70+
"deletedFiles": []
71+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Port for the dev/preview server
2+
PORT=3000
3+
4+
# Public API URL (inlined into client bundle)
5+
VITE_API_URL=https://api.example.com
6+
7+
# Server-only database connection URL (protected from client access)
8+
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/db
9+
10+
# Environment mode
11+
NODE_ENV=development
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<%
2+
const arkenvOption = (typeof addOnOption !== 'undefined' && (
3+
addOnOption['arkenv'] ||
4+
Object.entries(addOnOption).find(([k]) => k.includes('arkenv') || k.includes('info.json'))?.[1]
5+
)) || {};
6+
const validator = arkenvOption.validator || 'arktype';
7+
-%>
8+
<% if (validator === 'zod') { -%>
9+
import arkenv from "@arkenv/standard";
10+
import { z } from "zod";
11+
12+
export const env = arkenv({
13+
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
14+
VITE_API_URL: z.string().url().default("https://api.example.com"),
15+
DATABASE_URL: z.string().url().default("postgresql://postgres:postgres@localhost:5432/db"),
16+
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
17+
});
18+
<% } else if (validator === 'valibot') { -%>
19+
import arkenv from "@arkenv/standard";
20+
import * as v from "valibot";
21+
22+
export const env = arkenv({
23+
PORT: v.optional(v.pipe(v.unknown(), v.transform(Number), v.integer()), 3000),
24+
VITE_API_URL: v.optional(v.pipe(v.string(), v.url()), "https://api.example.com"),
25+
DATABASE_URL: v.optional(v.pipe(v.string(), v.url()), "postgresql://postgres:postgres@localhost:5432/db"),
26+
NODE_ENV: v.optional(v.picklist(["development", "production", "test"]), "development"),
27+
});
28+
<% } else { -%>
29+
import arkenv from "@arkenv/core";
30+
31+
export const env = arkenv({
32+
PORT: "number.port = 3000",
33+
VITE_API_URL: "string = 'https://api.example.com'",
34+
DATABASE_URL: "string = 'postgresql://postgres:postgres@localhost:5432/db'",
35+
NODE_ENV: "'development' | 'production' | 'test' = 'development'",
36+
});
37+
<% } -%>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<%
2+
const arkenvOption = (typeof addOnOption !== 'undefined' && (
3+
addOnOption['arkenv'] ||
4+
Object.entries(addOnOption).find(([k]) => k.includes('arkenv') || k.includes('info.json'))?.[1]
5+
)) || {};
6+
const includeDemo = typeof includeExamples !== 'undefined' ? includeExamples : true;
7+
if (arkenvOption.demo === 'false' || !includeDemo) {
8+
if (typeof ignoreFile === 'function') {
9+
ignoreFile();
10+
return;
11+
}
12+
}
13+
-%>
14+
import { createFileRoute } from "@tanstack/react-router";
15+
import { createServerFn } from "@tanstack/react-start";
16+
import { useState } from "react";
17+
import { env } from "../../env";
18+
19+
const getDatabaseConfig = createServerFn({ method: "GET" }).handler(() => {
20+
// Server-only key: safely read on the server during SSR / RPC
21+
try {
22+
const url = new URL(env.DATABASE_URL);
23+
return { host: url.host, protocol: url.protocol };
24+
} catch {
25+
return { host: "localhost:5432", protocol: "postgresql:" };
26+
}
27+
});
28+
29+
export const Route = createFileRoute("/demo/arkenv")({
30+
component: ArkEnvDemo,
31+
loader: () => getDatabaseConfig(),
32+
});
33+
34+
function LeakedSecret() {
35+
// Accessing server-only DATABASE_URL directly on the client throws at runtime
36+
return <p>Server key leaked: {env.DATABASE_URL}</p>;
37+
}
38+
39+
function ArkEnvDemo() {
40+
const dbConfig = Route.useLoaderData();
41+
const [attemptLeak, setAttemptLeak] = useState(false);
42+
43+
return (
44+
<div className="p-6 max-w-xl mx-auto space-y-4 font-sans">
45+
<h1 className="text-2xl font-bold">ArkEnv Demo</h1>
46+
<p className="text-sm text-gray-600">
47+
Typesafe environment variables with build-time validation and runtime
48+
leak protection.
49+
</p>
50+
51+
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-md space-y-2">
52+
<h2 className="font-semibold text-lg">Public Client Variables</h2>
53+
<p className="text-sm text-gray-600 dark:text-gray-300">
54+
Inlined safely into client bundles:
55+
</p>
56+
<code className="block p-2 bg-white dark:bg-black rounded border text-xs font-mono">
57+
env.VITE_API_URL: {env.VITE_API_URL}
58+
</code>
59+
</div>
60+
61+
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-md space-y-2">
62+
<h2 className="font-semibold text-lg">Server-Only Variables</h2>
63+
<p className="text-sm text-gray-600 dark:text-gray-300">
64+
Accessible inside createServerFn handlers:
65+
</p>
66+
<code className="block p-2 bg-white dark:bg-black rounded border text-xs font-mono">
67+
Database Host: {dbConfig.host} ({dbConfig.protocol})
68+
</code>
69+
</div>
70+
71+
<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">
72+
<h2 className="font-semibold text-red-800 dark:text-red-300 text-lg">
73+
Secret Leak Protection
74+
</h2>
75+
<p className="text-sm text-red-700 dark:text-red-400">
76+
Clicking the button below attempts to access the server secret{" "}
77+
<code>env.DATABASE_URL</code> on the client, which ArkEnv blocks:
78+
</p>
79+
{attemptLeak ? (
80+
<LeakedSecret />
81+
) : (
82+
<button
83+
type="button"
84+
className="px-3 py-1.5 bg-red-600 text-white rounded text-sm hover:bg-red-700 cursor-pointer"
85+
onClick={() => setAttemptLeak(true)}
86+
>
87+
Attempt client access to DATABASE_URL
88+
</button>
89+
)}
90+
</div>
91+
</div>
92+
);
93+
}

0 commit comments

Comments
 (0)