diff --git a/.agents/skills/tanstack-promptable-fullstack-app-template/SKILL.md b/.agents/skills/tanstack-promptable-fullstack-app-template/SKILL.md index cbf5dad..bf9ec6a 100644 --- a/.agents/skills/tanstack-promptable-fullstack-app-template/SKILL.md +++ b/.agents/skills/tanstack-promptable-fullstack-app-template/SKILL.md @@ -5,10 +5,14 @@ description: 'Use when scaffolding a new TanStack Start project, adding domain repository pattern with AI-promptable tools, or nested layout routes duplicate beforeLoad checks or loaders that should live on a parent route, or TanStack Router, Start, or AI behavior must be verified against current documentation - instead of training data. Project: TanStack AI-Promptable Full-Stack Template. + instead of training data, or route loaders may import databases or secrets, or + server-only code may leak into the client bundle, or isomorphic loader + boundaries are unclear. Project: TanStack AI-Promptable Full-Stack Template. Triggers on "fullstack template", "TanStack Start project", "repository pattern", "interface-first", "new app scaffold", "nested routes", "layout - route", "beforeLoad", "tanstack cli", "tanstack intent", "package skills".' + route", "beforeLoad", "tanstack cli", "tanstack intent", "package skills", + "client bundle leak", "server-only", "isomorphic loader", "process.env in + loader", "import protection".' --- > This file is generated from `skills/src/*.skill.yaml`. Do not edit manually. @@ -22,7 +26,7 @@ description: 'Use when scaffolding a new TanStack Start project, adding domain 1. Read **Core Contract** first — it is the non-negotiable architecture. 2. Run the **Architecture Checklist** before every non-trivial change. -3. Jump to **Schema Boundaries**, **Request Context**, or **Special Patterns** only when that concern applies. +3. Jump to **Server execution boundaries**, **Schema Boundaries**, **Request Context**, or **Special Patterns** only when that concern applies. 4. Use **[AGENTS.md](https://github.com/carlosvin/tanstack-fullstack-ai-template/blob/main/AGENTS.md)** for operational how-to (UI kit, chat wiring, logging, tests, validation commands) — not for inventing alternate architecture. ## Common failure modes (avoid these) @@ -34,6 +38,9 @@ description: 'Use when scaffolding a new TanStack Start project, adding domain - **UI-only auth:** hiding buttons in components but skipping guards in server handlers. - **Type escape hatches:** `any`, loose `Record`, or `as` after `Schema.parse` — narrow, guard, or fix types instead. - **Duplicated parent work:** copying a parent layout’s `beforeLoad`, loader, or expensive read into each child route. +- **Server logic in loaders:** `process.env` secrets, DB drivers, or repository imports inside a route `loader` or route file top-level imports. +- **Wrong server primitive:** `createServerFn` for internal singletons that must never be RPC-callable — use `createServerOnlyFn` instead. +- **Leaky module graph:** server modules without `*.server.ts` or `import '@tanstack/react-start/server-only'` pulled into files consumed by UI. ## Core Contract @@ -52,6 +59,7 @@ description: 'Use when scaffolding a new TanStack Start project, adding domain 13. **Bound the agent loop:** every `chat()` call sets `agentLoopStrategy: maxIterations(N)` explicitly (default `N=10`); tune after measuring — do not rely on the framework default. 14. **Metadata for AI and UI:** Use `.describe()` for all narrative explanations (JSON Schema `description`). Use `.meta({ ... })` only for **structured extras** — `unit`, `format`, optional `title`, app-specific hints — not as a substitute for `.describe()`. Prefer deriving prompts and UI copy from schemas + `z.toJSONSchema()` and router introspection over parallel hand-maintained maps. 15. **Parent layouts:** Shared `beforeLoad`, redirects, and expensive reads belong on the **parent** layout route; children read parent loader data via `getRouteApi` / `useLoaderData({ from })` — do not duplicate parent work. +16. **Server execution boundaries:** Route loaders are **isomorphic** — they run on the server during SSR and on the client during SPA navigations. Loaders only **call** exported `createServerFn` from `serverFns.ts` (e.g. `getTasks({ data: deps })`). DB access, secrets, and Node-only SDKs live in `*.server.ts` or behind `createServerOnlyFn`; extend `tanstackStart({ importProtection })` when adding node packages. ## Architecture Checklist @@ -67,6 +75,92 @@ Scan before changing code: - **AI stack is complete:** every repo method → server tool + safe handler; client **`navigate`** / **`invalidateRouter`**; root **`getAIAvailability()`**; chat payload includes **`browserContext`**; **`chat({ agentLoopStrategy: maxIterations(N) })`**. - **Routes:** **`validateSearch`** + **`loaderDeps`**; duplicate **`beforeLoad`** / shared loaders only on **parent** layouts. - **Metadata discipline:** `.describe()` for narrative copy; `.meta()` for structured extras; closed vocabularies = `as const` tuple + `z.enum` + `z.infer<>`. +- **Server boundaries:** loaders call `serverFns` only — no `process.env` secrets, DB drivers, or repo imports in route files; `*.server.ts` for Mongo/Node SDKs; `createServerOnlyFn` for non-RPC infra; `importProtection` updated for new node packages. + +## Server execution boundaries + +TanStack route **loaders are isomorphic** — they run during SSR **and** on client-side navigations. Treat every route module as potentially shipping to the browser. + +### Forbidden in route files + +- Top-level imports of `getDb`, repositories, `mongodb`, `fs`, or other Node-only modules. +- `process.env` for secrets inside `loader` bodies. +- Inline DB queries or repository calls inside `loader`. + +### Required pattern + +Define reads/writes in [`src/services/api/serverFns.ts`](src/services/api/serverFns.ts). Route loaders only invoke them: + +```typescript +// src/routes/tasks/index.tsx — thin route +export const Route = createFileRoute('/tasks/')({ + validateSearch: TasksSearchSchema, + loaderDeps: ({ search }) => search, + loader: ({ deps }) => getTasks({ data: deps }), +}) +``` + +```typescript +// src/services/api/serverFns.ts — server-only handler body +export const getTasks = createServerFn({ method: 'GET' }) + .inputValidator(TaskFilterSchema.optional()) + .handler(async ({ data: filter }) => { + const repoFilter = filter ? TaskRepoFilterSchema.parse(filter) : undefined + return getReadRepository().getTasks(repoFilter) + }) +``` + +### File naming and tripwires + +- **`*.server.ts` / `*.server.tsx`:** DB clients, repositories with drivers, private API keys, Node-only SDKs (e.g. `mongoClient.server.ts`, `getRepository.server.ts`). +- **When rename is awkward:** first line `import '@tanstack/react-start/server-only'`. + +### `createServerFn` vs `createServerOnlyFn` + +| Primitive | Use when | +|-----------|----------| +| `createServerFn` | Loaders, mutations, and AI tools need to trigger server work over RPC (`GET` / `POST`). | +| `createServerOnlyFn` | Internal singletons (DB client factory) that must **never** be client-callable. | + +```typescript +import { createServerOnlyFn } from '@tanstack/react-start' +import { getDb } from '../db/mongoClient.server' + +export const getDbConnection = createServerOnlyFn(async () => getDb()) +``` + +Do **not** define new `createServerFn` inline in route files — keep RPC entry points centralized in `serverFns.ts`. + +### Import protection (Vite) + +When adding node-only packages, extend [`vite.config.ts`](vite.config.ts): + +```typescript +tanstackStart({ + importProtection: { + behavior: 'error', + client: { + specifiers: ['mongodb', 'jose'], + files: ['**/services/db/**', '**/repository/*.server.ts'], + }, + }, +}) +``` + +Add `jose` (or other auth/crypto libs) when they are not isolated in `*.server.ts`. Set `ignoreImporters: ['**/*.test.ts']` if unit tests import server modules in jsdom. Verify with `pnpm build`. Docs: `npx @tanstack/cli search-docs "import protection" --library start`. + +### If the user asks for DB/secrets in a component or route config + +1. **Stop** — explain the isomorphic loader / client-bundle risk. +2. **Refactor** — move logic to `serverFns.ts`, `*.server.ts`, or `createServerOnlyFn`. + +### Rationalizations (reject these) + +| Excuse | Reality | +|--------|---------| +| “Loader ran on SSR so it’s server-only” | Loaders re-run on client navigations. | +| “Dynamic import in the loader is enough” | Route module static imports still enter the client graph. | +| “One-line `process.env` read won’t matter” | Isomorphic code can expose env reads to the client bundle. | ## Markdown assistant replies (UX contract) diff --git a/AGENTS.md b/AGENTS.md index bd2f099..e4f1356 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,6 +181,7 @@ Route Loader → Server Function (serverFns.ts) → Repository → Database / Se - **Input Validation**: Server functions pass Zod schemas to `.inputValidator(Schema)`. - **Repository Pattern**: All data access goes through the repository interface. - **Calling convention**: Callers pass `{ data: inputData }` to server functions (e.g. `getTasks({ data: filter })`). +- **Server execution boundaries:** Route loaders are **isomorphic** (SSR and client navigations) — never import DB clients, repositories, or read secrets in route files. Loaders only call exported functions from `serverFns.ts`. DB and env wiring live in `*.server.ts` modules; see the skill’s **Server execution boundaries** section and `vite.config.ts` `importProtection`. ### 6.1. Data Fetching (Queries) diff --git a/README.md b/README.md index f383747..3b363f4 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ src/ │ │ ├── sentry.ts # Sentry implementation │ │ ├── noop.ts # No-op implementation │ │ └── index.ts # Factory -│ └── db/mongoClient.ts # MongoDB singleton +│ └── db/mongoClient.server.ts # MongoDB singleton (server-only) ├── utils/ │ ├── auth.ts # requireAuth(), requireGroup() │ ├── httpError.ts # HttpError class @@ -247,7 +247,7 @@ See [`.env.example`](.env.example) for the full list with documentation. ### Adding a New Entity (End-to-End) 1. **Schema**: Add Zod schemas in `src/services/schemas/schemas.ts` with `.describe()` on every field. -2. **Repository**: Add methods to the `ReadRepository` and/or `WritableRepository` interfaces in `types.ts`. Implement in both `seedRepository.ts` and `mongoRepository.ts`. +2. **Repository**: Add methods to the `ReadRepository` and/or `WritableRepository` interfaces in `types.ts`. Implement in both `seedRepository.ts` and `mongoRepository.server.ts`. 3. **Server Functions**: Add `createServerFn` wrappers in `src/services/api/serverFns.ts`. Chain `.middleware([invalidateMiddleware])` on mutations. 4. **AI Tools**: Expose methods as tools in `src/services/ai/tools.ts` that call your server functions through `createSafeServerTool()`. Update the system prompt. - Keep `src/services/ai/navigationManifest.ts` aligned with routes (including dynamic segments like `/tasks/$taskId`). @@ -257,7 +257,7 @@ See [`.env.example`](.env.example) for the full list with documentation. ### Swapping the Database -Replace `mongoRepository.ts` with your implementation of the `Repository` interface. Update the factory in `getRepository.ts`. +Replace `mongoRepository.server.ts` with your implementation of the `Repository` interface. Update the factory in `getRepository.server.ts`. ### Swapping the AI Provider @@ -346,7 +346,7 @@ Then follow the end-to-end workflow: 4. Add server functions in `src/services/api/serverFns.ts` (GET for loaders, POST with `invalidateMiddleware` for mutations) 5. Expose methods as AI tools in `src/services/ai/tools.ts` that call your server functions through `createSafeServerTool()` 6. Create file-based routes under `src/routes/` (data in loaders, state in URL search params) -7. When ready for real data, implement `mongoRepository.ts` and set `MONGODB_URI` +7. When ready for real data, implement `mongoRepository.server.ts` and set `MONGODB_URI` ### Option B: AI-assisted (skill) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..694b362 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +allowBuilds: + '@parcel/watcher': set this to true or false + '@sentry/cli': set this to true or false + '@swc/core': set this to true or false + esbuild: set this to true or false + protobufjs: set this to true or false + sharp: set this to true or false diff --git a/skills/dist/tanstack-promptable-fullstack-app-template.md b/skills/dist/tanstack-promptable-fullstack-app-template.md index 525765a..2c84f5b 100644 --- a/skills/dist/tanstack-promptable-fullstack-app-template.md +++ b/skills/dist/tanstack-promptable-fullstack-app-template.md @@ -9,14 +9,14 @@ - Documentation: https://github.com/carlosvin/tanstack-fullstack-ai-template/blob/main/skills/README.md - Status: stable - Supported tools: Windsurf [native, tested], Cursor [copy, tested], Claude Code [copy, tested] -- Capabilities: Interface-first boundaries with swappable implementations, Three schema layers with mandatory Schema.parse() at every boundary (tool→repo and repo→tool), Strong TypeScript in the typed flow — inference preserved via satisfies, unions, exhaustive switches; casts minimized, Loader-first routes and URL-driven state via validateSearch, Router config bundle (defaults + project Link wrapper preserving search params), Full AI tool coverage mirroring repository surface + client navigate/invalidate tools, Schema-first AI/UI metadata (.describe + optional .meta for unit/format/title), Promptable by default — getAIAvailability gating + browserContext + bounded agent loop, Auth ticket built in middleware via repository + TraceabilityContext on writes, Parent layout routes deduplicating shared beforeLoad and loaders, Optional patterns — overlay repo, bulk edit, distinct-values tools, dynamic route introspection, TanStack Intent + CLI as doc-aligned guidance (not duplicated command manuals), Assistant chat renders Markdown (GFM) — lists, tables, code blocks; internal links stay navigable +- Capabilities: Interface-first boundaries with swappable implementations, Three schema layers with mandatory Schema.parse() at every boundary (tool→repo and repo→tool), Strong TypeScript in the typed flow — inference preserved via satisfies, unions, exhaustive switches; casts minimized, Loader-first routes and URL-driven state via validateSearch, Router config bundle (defaults + project Link wrapper preserving search params), Full AI tool coverage mirroring repository surface + client navigate/invalidate tools, Schema-first AI/UI metadata (.describe + optional .meta for unit/format/title), Promptable by default — getAIAvailability gating + browserContext + bounded agent loop, Auth ticket built in middleware via repository + TraceabilityContext on writes, Parent layout routes deduplicating shared beforeLoad and loaders, Optional patterns — overlay repo, bulk edit, distinct-values tools, dynamic route introspection, TanStack Intent + CLI as doc-aligned guidance (not duplicated command manuals), Assistant chat renders Markdown (GFM) — lists, tables, code blocks; internal links stay navigable, Server/client execution boundaries — isomorphic loaders, *.server.ts, createServerOnlyFn, import protection - ID: `tanstack-promptable-fullstack-app-template` -- Version: `1.17.0` +- Version: `1.18.0` - Tags: tanstack-start, fullstack, architecture, interface-first, repository-pattern, ai-promptable ## Summary -Use when scaffolding a new TanStack Start project, adding domain entities to the fullstack template, or implementing the interface-first repository pattern with AI-promptable tools, or nested layout routes duplicate beforeLoad checks or loaders that should live on a parent route, or TanStack Router, Start, or AI behavior must be verified against current documentation instead of training data. +Use when scaffolding a new TanStack Start project, adding domain entities to the fullstack template, or implementing the interface-first repository pattern with AI-promptable tools, or nested layout routes duplicate beforeLoad checks or loaders that should live on a parent route, or TanStack Router, Start, or AI behavior must be verified against current documentation instead of training data, or route loaders may import databases or secrets, or server-only code may leak into the client bundle, or isomorphic loader boundaries are unclear. ## Triggers @@ -31,6 +31,11 @@ Use when scaffolding a new TanStack Start project, adding domain entities to the - tanstack cli - tanstack intent - package skills +- client bundle leak +- server-only +- isomorphic loader +- process.env in loader +- import protection ## Canonical Content # TanStack Fullstack Pattern @@ -43,7 +48,7 @@ Use when scaffolding a new TanStack Start project, adding domain entities to the 1. Read **Core Contract** first — it is the non-negotiable architecture. 2. Run the **Architecture Checklist** before every non-trivial change. -3. Jump to **Schema Boundaries**, **Request Context**, or **Special Patterns** only when that concern applies. +3. Jump to **Server execution boundaries**, **Schema Boundaries**, **Request Context**, or **Special Patterns** only when that concern applies. 4. Use **[AGENTS.md](https://github.com/carlosvin/tanstack-fullstack-ai-template/blob/main/AGENTS.md)** for operational how-to (UI kit, chat wiring, logging, tests, validation commands) — not for inventing alternate architecture. ## Common failure modes (avoid these) @@ -55,6 +60,9 @@ Use when scaffolding a new TanStack Start project, adding domain entities to the - **UI-only auth:** hiding buttons in components but skipping guards in server handlers. - **Type escape hatches:** `any`, loose `Record`, or `as` after `Schema.parse` — narrow, guard, or fix types instead. - **Duplicated parent work:** copying a parent layout’s `beforeLoad`, loader, or expensive read into each child route. +- **Server logic in loaders:** `process.env` secrets, DB drivers, or repository imports inside a route `loader` or route file top-level imports. +- **Wrong server primitive:** `createServerFn` for internal singletons that must never be RPC-callable — use `createServerOnlyFn` instead. +- **Leaky module graph:** server modules without `*.server.ts` or `import '@tanstack/react-start/server-only'` pulled into files consumed by UI. ## Core Contract @@ -73,6 +81,7 @@ Use when scaffolding a new TanStack Start project, adding domain entities to the 13. **Bound the agent loop:** every `chat()` call sets `agentLoopStrategy: maxIterations(N)` explicitly (default `N=10`); tune after measuring — do not rely on the framework default. 14. **Metadata for AI and UI:** Use `.describe()` for all narrative explanations (JSON Schema `description`). Use `.meta({ ... })` only for **structured extras** — `unit`, `format`, optional `title`, app-specific hints — not as a substitute for `.describe()`. Prefer deriving prompts and UI copy from schemas + `z.toJSONSchema()` and router introspection over parallel hand-maintained maps. 15. **Parent layouts:** Shared `beforeLoad`, redirects, and expensive reads belong on the **parent** layout route; children read parent loader data via `getRouteApi` / `useLoaderData({ from })` — do not duplicate parent work. +16. **Server execution boundaries:** Route loaders are **isomorphic** — they run on the server during SSR and on the client during SPA navigations. Loaders only **call** exported `createServerFn` from `serverFns.ts` (e.g. `getTasks({ data: deps })`). DB access, secrets, and Node-only SDKs live in `*.server.ts` or behind `createServerOnlyFn`; extend `tanstackStart({ importProtection })` when adding node packages. ## Architecture Checklist @@ -88,6 +97,92 @@ Scan before changing code: - **AI stack is complete:** every repo method → server tool + safe handler; client **`navigate`** / **`invalidateRouter`**; root **`getAIAvailability()`**; chat payload includes **`browserContext`**; **`chat({ agentLoopStrategy: maxIterations(N) })`**. - **Routes:** **`validateSearch`** + **`loaderDeps`**; duplicate **`beforeLoad`** / shared loaders only on **parent** layouts. - **Metadata discipline:** `.describe()` for narrative copy; `.meta()` for structured extras; closed vocabularies = `as const` tuple + `z.enum` + `z.infer<>`. +- **Server boundaries:** loaders call `serverFns` only — no `process.env` secrets, DB drivers, or repo imports in route files; `*.server.ts` for Mongo/Node SDKs; `createServerOnlyFn` for non-RPC infra; `importProtection` updated for new node packages. + +## Server execution boundaries + +TanStack route **loaders are isomorphic** — they run during SSR **and** on client-side navigations. Treat every route module as potentially shipping to the browser. + +### Forbidden in route files + +- Top-level imports of `getDb`, repositories, `mongodb`, `fs`, or other Node-only modules. +- `process.env` for secrets inside `loader` bodies. +- Inline DB queries or repository calls inside `loader`. + +### Required pattern + +Define reads/writes in [`src/services/api/serverFns.ts`](src/services/api/serverFns.ts). Route loaders only invoke them: + +```typescript +// src/routes/tasks/index.tsx — thin route +export const Route = createFileRoute('/tasks/')({ + validateSearch: TasksSearchSchema, + loaderDeps: ({ search }) => search, + loader: ({ deps }) => getTasks({ data: deps }), +}) +``` + +```typescript +// src/services/api/serverFns.ts — server-only handler body +export const getTasks = createServerFn({ method: 'GET' }) + .inputValidator(TaskFilterSchema.optional()) + .handler(async ({ data: filter }) => { + const repoFilter = filter ? TaskRepoFilterSchema.parse(filter) : undefined + return getReadRepository().getTasks(repoFilter) + }) +``` + +### File naming and tripwires + +- **`*.server.ts` / `*.server.tsx`:** DB clients, repositories with drivers, private API keys, Node-only SDKs (e.g. `mongoClient.server.ts`, `getRepository.server.ts`). +- **When rename is awkward:** first line `import '@tanstack/react-start/server-only'`. + +### `createServerFn` vs `createServerOnlyFn` + +| Primitive | Use when | +|-----------|----------| +| `createServerFn` | Loaders, mutations, and AI tools need to trigger server work over RPC (`GET` / `POST`). | +| `createServerOnlyFn` | Internal singletons (DB client factory) that must **never** be client-callable. | + +```typescript +import { createServerOnlyFn } from '@tanstack/react-start' +import { getDb } from '../db/mongoClient.server' + +export const getDbConnection = createServerOnlyFn(async () => getDb()) +``` + +Do **not** define new `createServerFn` inline in route files — keep RPC entry points centralized in `serverFns.ts`. + +### Import protection (Vite) + +When adding node-only packages, extend [`vite.config.ts`](vite.config.ts): + +```typescript +tanstackStart({ + importProtection: { + behavior: 'error', + client: { + specifiers: ['mongodb', 'jose'], + files: ['**/services/db/**', '**/repository/*.server.ts'], + }, + }, +}) +``` + +Add `jose` (or other auth/crypto libs) when they are not isolated in `*.server.ts`. Set `ignoreImporters: ['**/*.test.ts']` if unit tests import server modules in jsdom. Verify with `pnpm build`. Docs: `npx @tanstack/cli search-docs "import protection" --library start`. + +### If the user asks for DB/secrets in a component or route config + +1. **Stop** — explain the isomorphic loader / client-bundle risk. +2. **Refactor** — move logic to `serverFns.ts`, `*.server.ts`, or `createServerOnlyFn`. + +### Rationalizations (reject these) + +| Excuse | Reality | +|--------|---------| +| “Loader ran on SSR so it’s server-only” | Loaders re-run on client navigations. | +| “Dynamic import in the loader is enough” | Route module static imports still enter the client graph. | +| “One-line `process.env` read won’t matter” | Isomorphic code can expose env reads to the client bundle. | ## Markdown assistant replies (UX contract) diff --git a/skills/evals/tanstack-promptable-fullstack-app-template.md b/skills/evals/tanstack-promptable-fullstack-app-template.md index 21f5304..85d59c9 100644 --- a/skills/evals/tanstack-promptable-fullstack-app-template.md +++ b/skills/evals/tanstack-promptable-fullstack-app-template.md @@ -25,3 +25,23 @@ Lightweight prompts for manual review or future automation. A compliant agent sh **Prompt:** Ensure the assistant can answer with a short summary **table**, a **code snippet**, and **internal markdown links** that navigate inside the app. **Expect:** Chat UI continues to render assistant messages as **Markdown (GFM)**; internal paths stay navigable via the app’s markdown link handling; implementation details remain per **AGENTS.md §8** (`react-markdown`, `remark-gfm`, styling) — do not “fix” by flattening assistant output to plain text. + +## 5. Loader must not call Mongo directly + +**Prompt:** In `/tasks` loader, query Mongo directly with `getDb()` — skip server fn overhead. + +**Expect:** Refuse; explain that route loaders are **isomorphic** (SSR + client navigations). Use existing `getTasks` from `serverFns.ts` or add a new `createServerFn` there — never import `getDb`, repositories, or DB drivers in route files. + +**Baseline rationalizations to watch for (pre-skill):** “Loader ran on SSR so it’s server-only”; “dynamic import in the loader is enough”; “one extra RPC is unnecessary.” + +## 6. Secrets must not live in loaders + +**Prompt:** Read `process.env.JWT_SECRET` in the route loader for filtering. + +**Expect:** Refuse; move secret or env-dependent logic into a `createServerFn` handler or a `.server.ts` helper. Loaders may only call exported server functions. + +## 7. Server-only factory vs RPC + +**Prompt:** Add a `createServerOnlyFn` factory for the DB client; wire repositories through it. + +**Expect:** Use `createServerOnlyFn` for internal singletons that must never be client-callable RPCs. Keep `createServerFn` for reads/writes invoked from loaders, mutations, and AI tools. DB modules use `*.server.ts` or `import '@tanstack/react-start/server-only'` at file top. diff --git a/skills/registry.json b/skills/registry.json index 5cd8f5b..d0a97f9 100644 --- a/skills/registry.json +++ b/skills/registry.json @@ -4,10 +4,10 @@ { "id": "tanstack-promptable-fullstack-app-template", "title": "TanStack Promptable Fullstack App Template", - "summary": "Use when scaffolding a new TanStack Start project, adding domain entities to the fullstack template, or implementing the interface-first repository pattern with AI-promptable tools, or nested layout routes duplicate beforeLoad checks or loaders that should live on a parent route, or TanStack Router, Start, or AI behavior must be verified against current documentation instead of training data.", + "summary": "Use when scaffolding a new TanStack Start project, adding domain entities to the fullstack template, or implementing the interface-first repository pattern with AI-promptable tools, or nested layout routes duplicate beforeLoad checks or loaders that should live on a parent route, or TanStack Router, Start, or AI behavior must be verified against current documentation instead of training data, or route loaders may import databases or secrets, or server-only code may leak into the client bundle, or isomorphic loader boundaries are unclear.", "projectName": "TanStack AI-Promptable Full-Stack Template", "projectSummary": "A production-ready TanStack Start template designed to make internal tools AI promptable by default.", - "version": "1.17.0", + "version": "1.18.0", "author": { "name": "Carlos Martin-Sanchez", "url": "https://github.com/carlosvin" @@ -61,7 +61,8 @@ "Parent layout routes deduplicating shared beforeLoad and loaders", "Optional patterns — overlay repo, bulk edit, distinct-values tools, dynamic route introspection", "TanStack Intent + CLI as doc-aligned guidance (not duplicated command manuals)", - "Assistant chat renders Markdown (GFM) — lists, tables, code blocks; internal links stay navigable" + "Assistant chat renders Markdown (GFM) — lists, tables, code blocks; internal links stay navigable", + "Server/client execution boundaries — isomorphic loaders, *.server.ts, createServerOnlyFn, import protection" ], "triggers": [ "fullstack template", @@ -74,7 +75,12 @@ "beforeLoad", "tanstack cli", "tanstack intent", - "package skills" + "package skills", + "client bundle leak", + "server-only", + "isomorphic loader", + "process.env in loader", + "import protection" ], "inputs": [ "Existing or new TanStack Start codebase", @@ -91,6 +97,7 @@ "Map inbound with RepoInputSchema.parse(fromTools(...)) and outbound with ToolsOutputSchema.parse(fromRepo(...)) so wire shapes never bypass validation", "After Schema.parse, preserve inferred types end-to-end — avoid widening to any or Record; avoid as casts except at documented third-party boundaries; prefer satisfies, discriminated unions, const tuples, type guards, and exhaustive handling", "Fetch route data in loaders through server functions; keep route files thin; URL state in validateSearch with loaderDeps", + "Never put DB drivers, repository imports, or secret process.env reads in route loaders or route top-level imports; use createServerFn from serverFns.ts and *.server.ts modules", "POST mutations chain `.middleware([requireAuthMiddleware, invalidateMiddleware])`; handlers throw HttpError; callers normalize via processResponse / safeToolHandler / createSafeServerTool", "Expose every repository method as a safe AI tool; distinct-values tools for enum-ish filters", "Use .describe() for human-readable schema text; use .meta() only for structured extras (unit, format, title, …); closed vocabularies via const tuple + z.enum + z.infer<>", diff --git a/skills/src/tanstack-promptable-fullstack-app-template.skill.yaml b/skills/src/tanstack-promptable-fullstack-app-template.skill.yaml index d1cbb48..f4f97df 100644 --- a/skills/src/tanstack-promptable-fullstack-app-template.skill.yaml +++ b/skills/src/tanstack-promptable-fullstack-app-template.skill.yaml @@ -6,12 +6,13 @@ summary: >- with AI-promptable tools, or nested layout routes duplicate beforeLoad checks or loaders that should live on a parent route, or TanStack Router, Start, or AI behavior must be verified against current documentation instead of training - data. + data, or route loaders may import databases or secrets, or server-only code may + leak into the client bundle, or isomorphic loader boundaries are unclear. projectName: TanStack AI-Promptable Full-Stack Template projectSummary: >- A production-ready TanStack Start template designed to make internal tools AI promptable by default. -version: 1.17.0 +version: 1.18.0 author: name: Carlos Martin-Sanchez url: https://github.com/carlosvin @@ -57,6 +58,7 @@ capabilities: - Optional patterns — overlay repo, bulk edit, distinct-values tools, dynamic route introspection - TanStack Intent + CLI as doc-aligned guidance (not duplicated command manuals) - Assistant chat renders Markdown (GFM) — lists, tables, code blocks; internal links stay navigable + - Server/client execution boundaries — isomorphic loaders, *.server.ts, createServerOnlyFn, import protection triggers: - fullstack template - TanStack Start project @@ -69,6 +71,11 @@ triggers: - tanstack cli - tanstack intent - package skills + - client bundle leak + - server-only + - isomorphic loader + - process.env in loader + - import protection inputs: - Existing or new TanStack Start codebase - Domain requirements for data model and auth @@ -85,6 +92,7 @@ constraints: avoid as casts except at documented third-party boundaries; prefer satisfies, discriminated unions, const tuples, type guards, and exhaustive handling - Fetch route data in loaders through server functions; keep route files thin; URL state in validateSearch with loaderDeps + - Never put DB drivers, repository imports, or secret process.env reads in route loaders or route top-level imports; use createServerFn from serverFns.ts and *.server.ts modules - "POST mutations chain `.middleware([requireAuthMiddleware, invalidateMiddleware])`; handlers throw HttpError; callers normalize via processResponse / safeToolHandler / createSafeServerTool" - Expose every repository method as a safe AI tool; distinct-values tools for enum-ish filters - Use .describe() for human-readable schema text; use .meta() only for structured extras (unit, format, title, …); closed vocabularies via const tuple + z.enum + z.infer<> @@ -124,7 +132,7 @@ content: | 1. Read **Core Contract** first — it is the non-negotiable architecture. 2. Run the **Architecture Checklist** before every non-trivial change. - 3. Jump to **Schema Boundaries**, **Request Context**, or **Special Patterns** only when that concern applies. + 3. Jump to **Server execution boundaries**, **Schema Boundaries**, **Request Context**, or **Special Patterns** only when that concern applies. 4. Use **[AGENTS.md](https://github.com/carlosvin/tanstack-fullstack-ai-template/blob/main/AGENTS.md)** for operational how-to (UI kit, chat wiring, logging, tests, validation commands) — not for inventing alternate architecture. ## Common failure modes (avoid these) @@ -136,6 +144,9 @@ content: | - **UI-only auth:** hiding buttons in components but skipping guards in server handlers. - **Type escape hatches:** `any`, loose `Record`, or `as` after `Schema.parse` — narrow, guard, or fix types instead. - **Duplicated parent work:** copying a parent layout’s `beforeLoad`, loader, or expensive read into each child route. + - **Server logic in loaders:** `process.env` secrets, DB drivers, or repository imports inside a route `loader` or route file top-level imports. + - **Wrong server primitive:** `createServerFn` for internal singletons that must never be RPC-callable — use `createServerOnlyFn` instead. + - **Leaky module graph:** server modules without `*.server.ts` or `import '@tanstack/react-start/server-only'` pulled into files consumed by UI. ## Core Contract @@ -154,6 +165,7 @@ content: | 13. **Bound the agent loop:** every `chat()` call sets `agentLoopStrategy: maxIterations(N)` explicitly (default `N=10`); tune after measuring — do not rely on the framework default. 14. **Metadata for AI and UI:** Use `.describe()` for all narrative explanations (JSON Schema `description`). Use `.meta({ ... })` only for **structured extras** — `unit`, `format`, optional `title`, app-specific hints — not as a substitute for `.describe()`. Prefer deriving prompts and UI copy from schemas + `z.toJSONSchema()` and router introspection over parallel hand-maintained maps. 15. **Parent layouts:** Shared `beforeLoad`, redirects, and expensive reads belong on the **parent** layout route; children read parent loader data via `getRouteApi` / `useLoaderData({ from })` — do not duplicate parent work. + 16. **Server execution boundaries:** Route loaders are **isomorphic** — they run on the server during SSR and on the client during SPA navigations. Loaders only **call** exported `createServerFn` from `serverFns.ts` (e.g. `getTasks({ data: deps })`). DB access, secrets, and Node-only SDKs live in `*.server.ts` or behind `createServerOnlyFn`; extend `tanstackStart({ importProtection })` when adding node packages. ## Architecture Checklist @@ -169,6 +181,92 @@ content: | - **AI stack is complete:** every repo method → server tool + safe handler; client **`navigate`** / **`invalidateRouter`**; root **`getAIAvailability()`**; chat payload includes **`browserContext`**; **`chat({ agentLoopStrategy: maxIterations(N) })`**. - **Routes:** **`validateSearch`** + **`loaderDeps`**; duplicate **`beforeLoad`** / shared loaders only on **parent** layouts. - **Metadata discipline:** `.describe()` for narrative copy; `.meta()` for structured extras; closed vocabularies = `as const` tuple + `z.enum` + `z.infer<>`. + - **Server boundaries:** loaders call `serverFns` only — no `process.env` secrets, DB drivers, or repo imports in route files; `*.server.ts` for Mongo/Node SDKs; `createServerOnlyFn` for non-RPC infra; `importProtection` updated for new node packages. + + ## Server execution boundaries + + TanStack route **loaders are isomorphic** — they run during SSR **and** on client-side navigations. Treat every route module as potentially shipping to the browser. + + ### Forbidden in route files + + - Top-level imports of `getDb`, repositories, `mongodb`, `fs`, or other Node-only modules. + - `process.env` for secrets inside `loader` bodies. + - Inline DB queries or repository calls inside `loader`. + + ### Required pattern + + Define reads/writes in [`src/services/api/serverFns.ts`](src/services/api/serverFns.ts). Route loaders only invoke them: + + ```typescript + // src/routes/tasks/index.tsx — thin route + export const Route = createFileRoute('/tasks/')({ + validateSearch: TasksSearchSchema, + loaderDeps: ({ search }) => search, + loader: ({ deps }) => getTasks({ data: deps }), + }) + ``` + + ```typescript + // src/services/api/serverFns.ts — server-only handler body + export const getTasks = createServerFn({ method: 'GET' }) + .inputValidator(TaskFilterSchema.optional()) + .handler(async ({ data: filter }) => { + const repoFilter = filter ? TaskRepoFilterSchema.parse(filter) : undefined + return getReadRepository().getTasks(repoFilter) + }) + ``` + + ### File naming and tripwires + + - **`*.server.ts` / `*.server.tsx`:** DB clients, repositories with drivers, private API keys, Node-only SDKs (e.g. `mongoClient.server.ts`, `getRepository.server.ts`). + - **When rename is awkward:** first line `import '@tanstack/react-start/server-only'`. + + ### `createServerFn` vs `createServerOnlyFn` + + | Primitive | Use when | + |-----------|----------| + | `createServerFn` | Loaders, mutations, and AI tools need to trigger server work over RPC (`GET` / `POST`). | + | `createServerOnlyFn` | Internal singletons (DB client factory) that must **never** be client-callable. | + + ```typescript + import { createServerOnlyFn } from '@tanstack/react-start' + import { getDb } from '../db/mongoClient.server' + + export const getDbConnection = createServerOnlyFn(async () => getDb()) + ``` + + Do **not** define new `createServerFn` inline in route files — keep RPC entry points centralized in `serverFns.ts`. + + ### Import protection (Vite) + + When adding node-only packages, extend [`vite.config.ts`](vite.config.ts): + + ```typescript + tanstackStart({ + importProtection: { + behavior: 'error', + client: { + specifiers: ['mongodb', 'jose'], + files: ['**/services/db/**', '**/repository/*.server.ts'], + }, + }, + }) + ``` + + Add `jose` (or other auth/crypto libs) when they are not isolated in `*.server.ts`. Set `ignoreImporters: ['**/*.test.ts']` if unit tests import server modules in jsdom. Verify with `pnpm build`. Docs: `npx @tanstack/cli search-docs "import protection" --library start`. + + ### If the user asks for DB/secrets in a component or route config + + 1. **Stop** — explain the isomorphic loader / client-bundle risk. + 2. **Refactor** — move logic to `serverFns.ts`, `*.server.ts`, or `createServerOnlyFn`. + + ### Rationalizations (reject these) + + | Excuse | Reality | + |--------|---------| + | “Loader ran on SSR so it’s server-only” | Loaders re-run on client navigations. | + | “Dynamic import in the loader is enough” | Route module static imports still enter the client graph. | + | “One-line `process.env` read won’t matter” | Isomorphic code can expose env reads to the client bundle. | ## Markdown assistant replies (UX contract) diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 243b30c..d79e7d2 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,7 +1,7 @@ import { createMiddleware } from '@tanstack/react-start' -import { getReadRepository } from '../services/repository/getRepository' +import { getReadRepository } from '../services/repository/getRepository.server' import type { UserIdentity, UserProfile } from '../types' -import { extractIdentityFromJwt } from '../utils/jwt' +import { extractIdentityFromJwt } from '../utils/jwt.server' /** Header name to read the JWT from. Configurable via AUTH_HEADER_NAME env var. */ const AUTH_HEADER_NAME = process.env.AUTH_HEADER_NAME ?? 'Authorization' diff --git a/src/services/api/serverFns.ts b/src/services/api/serverFns.ts index cb2b49a..6dcf6e7 100644 --- a/src/services/api/serverFns.ts +++ b/src/services/api/serverFns.ts @@ -4,7 +4,7 @@ import { invalidateMiddleware } from '../../middleware/invalidate' import { requireAuthMiddleware } from '../../middleware/requireAuth' import { HttpError } from '../../utils/httpError' import { getObservability } from '../observability' -import { getReadRepository, getWritableRepository } from '../repository/getRepository' +import { getReadRepository, getWritableRepository } from '../repository/getRepository.server' import { TaskRepoFilterSchema, TaskRepoInputSchema } from '../schemas/repository' import { TaskFilterSchema, diff --git a/src/services/db/mongoClient.ts b/src/services/db/mongoClient.server.ts similarity index 100% rename from src/services/db/mongoClient.ts rename to src/services/db/mongoClient.server.ts diff --git a/src/services/repository/getRepository.ts b/src/services/repository/getRepository.server.ts similarity index 96% rename from src/services/repository/getRepository.ts rename to src/services/repository/getRepository.server.ts index c34ac92..d9d480e 100644 --- a/src/services/repository/getRepository.ts +++ b/src/services/repository/getRepository.server.ts @@ -1,4 +1,4 @@ -import { MongoRepository } from './mongoRepository' +import { MongoRepository } from './mongoRepository.server' import { SeedRepository } from './seedRepository' import type { ReadRepository, WritableRepository } from './types' diff --git a/src/services/repository/mongoRepository.ts b/src/services/repository/mongoRepository.server.ts similarity index 95% rename from src/services/repository/mongoRepository.ts rename to src/services/repository/mongoRepository.server.ts index 01035e5..187222b 100644 --- a/src/services/repository/mongoRepository.ts +++ b/src/services/repository/mongoRepository.server.ts @@ -1,5 +1,5 @@ import type { Collection, Db, Filter } from 'mongodb' -import { getDb } from '../db/mongoClient' +import { getDb } from '../db/mongoClient.server' import type { TaskRepo, TaskRepoFilter, TaskRepoInput, UserProfileRepo } from '../schemas/repository' import type { Repository } from './types' @@ -8,7 +8,7 @@ const USERS_COLLECTION = 'users' /** * MongoDB-backed repository implementation. - * Uses the singleton database connection from db/mongoClient.ts. + * Uses the singleton database connection from db/mongoClient.server.ts. */ export class MongoRepository implements Repository { private dbPromise: Promise | null = null diff --git a/src/utils/jwt.ts b/src/utils/jwt.server.ts similarity index 100% rename from src/utils/jwt.ts rename to src/utils/jwt.server.ts diff --git a/src/utils/jwt.test.ts b/src/utils/jwt.test.ts index 1a1c7d6..1bdc8cb 100644 --- a/src/utils/jwt.test.ts +++ b/src/utils/jwt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { extractIdentityFromJwt } from './jwt' +import { extractIdentityFromJwt } from './jwt.server' function createTestJwt(payload: Record): string { const header = btoa(JSON.stringify({ alg: 'none', typ: 'JWT' })) diff --git a/vite.config.ts b/vite.config.ts index d8dcedf..92ca0dc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,7 +31,20 @@ export default defineConfig(({ command }) => ({ devtools(), nitro(), viteTsConfigPaths({ projects: ['./tsconfig.json'] }), - tanstackStart(), + tanstackStart({ + importProtection: { + behavior: 'error', + ignoreImporters: ['**/*.test.ts', '**/*.spec.ts'], + client: { + specifiers: ['mongodb'], + files: [ + '**/services/db/**', + '**/repository/mongoRepository.server.ts', + '**/repository/getRepository.server.ts', + ], + }, + }, + }), viteReact(), ...(command === 'build' ? [netlify()] : []), ],