diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index a912c6560..5ab334902 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -45,7 +45,7 @@ Classify the task first, then load the smallest useful reference set. Each refer | Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` | | Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` | | The `@webjsdev/ui` component kit (a `components.json` is present): class helpers, tokens, `add` / `view`, the MCP `ui` tool | `references/ui-kit.md` | -| TypeScript at runtime, erasable syntax, full-stack types | `references/typescript.md` | +| TypeScript at runtime, erasable syntax, full-stack types, the derive-the-type rule (never `unknown` / `any`) | `references/typescript.md` | | Unit, browser, e2e tests, the `handle()` harness, Bun parity | `references/testing.md` | | Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` | | Node vs Bun, running the app, deploying, runtime-specific differences | `references/runtime.md` | @@ -68,7 +68,8 @@ Common bundles: 5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser. 6. **Validate input at the boundary.** Declare `export const validate` on an action; the RPC and `route()` boundaries run it. 7. **Default mutations to optimistic UI** where the client can predict the result (`optimistic()` from `@webjsdev/core`). -8. **Test the narrowest meaningful layer**, and render the app in a real browser for any UI change (static checks do not catch a collapsed layout). +8. **Type every boundary from its source, never `unknown` or `any`.** The row type comes from the schema (`typeof todos.$inferSelect`), the action's input from a named `interface` and its result from `ActionResult`, the routing files from `PageProps` / `LayoutProps` / `RouteHandlerContext`. `unknown` belongs on a payload nothing has vouched for yet that the next line narrows, and on a parameter of your own helper that forwards into an `html` template hole. Everywhere else, including a layout's `children`, it is a missing type. See `references/typescript.md`. +9. **Test the narrowest meaningful layer**, and render the app in a real browser for any UI change (static checks do not catch a collapsed layout). ## Project Layout diff --git a/.agents/skills/webjs/references/data-and-actions.md b/.agents/skills/webjs/references/data-and-actions.md index 81cf1d6c7..fa3390396 100644 --- a/.agents/skills/webjs/references/data-and-actions.md +++ b/.agents/skills/webjs/references/data-and-actions.md @@ -105,15 +105,23 @@ Declare `export const validate` beside the action. It runs SERVER-SIDE before th ```ts // modules/posts/actions/create-post.server.ts 'use server'; -export const validate = (input: any) => { +export interface CreatePostInput { title: string; body: string } + +// `unknown` is CORRECT here and nowhere else in this file: the validator IS +// the narrowing site for an untrusted wire payload. Never `any`, which would +// un-type the returned `data` and with it the action's own input. +export const validate = (input: unknown) => { + const raw = (input ?? {}) as Record; const fieldErrors: Record = {}; - const title = String(input?.title || '').trim(); + const title = String(raw.title ?? '').trim(); if (!title) fieldErrors.title = 'Title is required'; - if (String(input?.body || '').length < 10) fieldErrors.body = 'Too short'; + if (String(raw.body ?? '').length < 10) fieldErrors.body = 'Too short'; if (Object.keys(fieldErrors).length) return { success: false, fieldErrors }; - return { success: true, data: { title, body: String(input.body) } }; + // `satisfies` ties the validator's output to the action's input, so the two + // cannot drift apart. + return { success: true, data: { title, body: String(raw.body) } satisfies CreatePostInput }; }; -export async function createPost(input: { title: string; body: string }) { /* runs only when valid */ } +export async function createPost(input: CreatePostInput) { /* runs only when valid */ } ``` A client call resolves with the failure envelope (it does NOT throw), so the component reads `result.fieldErrors`. A zod adapter wraps `safeParse` so its result becomes the envelope; the framework stays zod-free. diff --git a/.agents/skills/webjs/references/optimistic-ui.md b/.agents/skills/webjs/references/optimistic-ui.md index 9177fba70..90c4c107f 100644 --- a/.agents/skills/webjs/references/optimistic-ui.md +++ b/.agents/skills/webjs/references/optimistic-ui.md @@ -28,10 +28,13 @@ class TodoList extends WebComponent({ source: () => this.todos, update: (state, title: string) => [ ...state, - // A client-only placeholder id for the pending row; the real id arrives - // from the server on reconcile, so the `as any` cast on this temp row is - // fine (the row is dropped when the promise settles). - { id: crypto.randomUUID() as any, title, completed: false, pending: true }, + // A client-only placeholder id for the pending row. The real id arrives + // from the server on reconcile, when this row is dropped. No cast is + // needed because `Todo['id']` is a string here (the schema uses a uuid + // primary key). Against an auto-increment integer id there is no honest + // client-side value, so model the temp row instead (an optional id, or a + // `tempId` the reducer keys on) rather than casting one in. + { id: crypto.randomUUID(), title, completed: false, createdAt: new Date(), pending: true }, ], }); @@ -46,9 +49,12 @@ class TodoList extends WebComponent({ const result = await promise; if (result.success && result.data) { - // Reconcile: the optimistic entry has ALREADY auto-released (the promise - // settled), so `this.todos` holds only confirmed rows here. Append the - // server's canonical row, matching the order the `update` reducer used. + // Reconcile: the optimistic row is never written to `this.todos`. The + // overlay holds only the PAYLOAD, and `update` rebuilds the row from it + // on each `.value` read, so this prop holds only confirmed rows. Append + // the server's canonical row, matching the order the `update` reducer + // used. (The overlay entry auto-released when the promise settled, so + // `.value` does not double-count it on the next paint.) this.todos = [...this.todos, result.data]; } } diff --git a/.agents/skills/webjs/references/routing-and-pages.md b/.agents/skills/webjs/references/routing-and-pages.md index 31da64858..6781bc82c 100644 --- a/.agents/skills/webjs/references/routing-and-pages.md +++ b/.agents/skills/webjs/references/routing-and-pages.md @@ -41,7 +41,8 @@ The default export receives `{ children, params, searchParams, url }` and must e ```ts // app/layout.ts (root) import { html } from '@webjsdev/core'; -export default function RootLayout({ children }: { children: unknown }) { +import type { LayoutProps } from '@webjsdev/core'; +export default function RootLayout({ children }: LayoutProps) { return html`${children}`; } ``` diff --git a/.agents/skills/webjs/references/typescript.md b/.agents/skills/webjs/references/typescript.md index a5a110827..e06dec770 100644 --- a/.agents/skills/webjs/references/typescript.md +++ b/.agents/skills/webjs/references/typescript.md @@ -5,8 +5,8 @@ - TypeScript at runtime with **no build step**: `.ts` / `.mts` is stripped in place, not compiled. - **Erasable syntax only** (`erasableSyntaxOnly: true`) and the exact list of banned constructs, with their allowed rewrites. - The **pluggable stripper** (Node 24+ built-in vs `amaro` on Bun) and how the browser gets stripped source. -- **Full-stack type safety**: server-action types flow to the call site, `import type` crosses the `.server` boundary, plus the carrier rule. -- **Typing pages, layouts, and route handlers** with `PageProps` / `LayoutProps` / `RouteHandlerContext` and the generated route union (`webjs types`). +- **Full-stack type safety**: the rule (derive the type at every boundary, never `unknown` / `any`), server-action types flowing to the call site, and the `import type` carrier rule across the `.server` boundary. +- **Typing pages, layouts, and route handlers** with `PageProps` / `LayoutProps` / `RouteHandlerContext` and the generated route union (`npx webjsdev types`). Read this when you are writing `.ts` in a WebJs app, hit a strip-time 500, or want typed params and hrefs. For action signatures and the serializer wire see `data-and-actions.md`. For typing reactive props see `components.md`. @@ -77,6 +77,75 @@ Prefer explicit `.ts` extensions in imports. A `.js` specifier pointing at a `.t ## Full-stack type safety +### The rule: derive the type, never `unknown` or `any` + +Every value crossing an app boundary in WebJs already has a type you can reach, so reach for it. Writing `unknown` or `any` at a boundary throws away the framework's central guarantee, and it does so silently: the app still runs, the checker just stops helping. Nothing catches this for you. Both are valid TypeScript, so `webjs check` will not flag them (it is a correctness tool, and this is a convention), and `tsc --noEmit` passes happily. + +`unknown` is the more dangerous of the two, because it reads as the safe choice. It is safe only in the sense that it forces a narrow at the read site. As a boundary type it is exactly as uninformative as `any`, and it pushes a cast into every consumer. + +The ladder, in the order to climb it: + +| Boundary | Write this | Not this | +|---|---|---| +| A database row | `export type Todo = typeof todos.$inferSelect` in `db/schema.server.ts` (`$inferInsert` for a write) | a hand-written interface that drifts from the schema | +| That row inside a shipping component | `import type { Todo } from '#db/schema.server.ts'` | `any[]`, or re-declaring the shape by hand | +| A server action's input | a named `interface CreatePostInput` | `input: unknown` / `input: any` | +| A server action's result | `Promise>` | `Promise` | +| `export const validate` | `(input: unknown)`, narrowed in the body, returning `data` that `satisfies` the action's input type | `(input: any)`, which un-types the returned `data` too | +| A page | `PageProps<'/blog/[slug]'>` | `{ params: Record }` | +| A layout | `LayoutProps` (whose `children` is a `TemplateResult`) | `{ children: unknown }` | +| A route handler's 2nd argument | `RouteHandlerContext<'/api/users/[id]'>` | `{ params: any }` | +| A client-router href | the generated `Route` union (`npx webjsdev types`; `npm run dev` also emits it) | a bare `string` | +| A reactive property | `prop(Object)`, `prop(Array)` | `prop(Object)` plus a cast at every read | +| An optimistic temp row | the pending shape on the row type (`pending?: boolean`) | `as any` on the temp id | + +One flow, end to end, with no escape hatch anywhere in it: + +```ts +// db/schema.server.ts +export const posts = table('posts', { id: uuidPk(), title: text().notNull(), body: text().notNull() }); +export type Post = typeof posts.$inferSelect; // derived, never hand-written + +// modules/posts/actions/create-post.server.ts +'use server'; +import type { Post } from '#db/schema.server.ts'; // type-only: erased before the browser sees it +export interface CreatePostInput { title: string; body: string } +export const validate = (input: unknown) => { /* narrows, returns data satisfying CreatePostInput */ }; +export async function createPost(input: CreatePostInput): Promise> { /* ... */ } + +// modules/posts/components/new-post.ts +import { createPost } from '#modules/posts/actions/create-post.server.ts'; +const r = await createPost({ title, body }); +if (r.success && r.data) r.data.title; // Post.title: string, checked at the call site +``` + +The payoff is not stylistic. A typo in `r.data.titel`, a renamed column, a changed action signature, and a page reading `params.slugg` are all compile errors in that version and all silent runtime `undefined` in the `unknown` version. + +### Where `unknown` is still the right type + +There are two cases, and only the first is about narrowing. + +**Case one: a value that genuinely has no type yet, at the moment before it is narrowed.** + +```ts +// app/api/webhook/route.ts +export async function POST(req: Request) { + const body: unknown = await req.json(); // correct: nothing has vouched for this yet + const parsed = parseWebhook(body); // narrowed on the very next line + return Response.json({ ok: parsed.kind }); +} +``` + +The test is what the next line does. Correct `unknown` here is narrowed immediately by a parse, a validator, or a type guard, and the narrowed type is what the rest of the function sees. Anything standing at that boundary qualifies: a `route.ts` handler's `await req.json()` (above), a `catch (e)` binding (already `unknown` under `strict`), an action's `export const validate`, and any validator function those delegate to. In a validator, returning `data` that `satisfies` the action's input type is what carries a real type into the action body. See `data-and-actions.md`. + +**Case two: a parameter of YOUR OWN helper that forwards its argument into an `html` template hole, which is NOT narrowed.** A hole renders a string, a number, a `TemplateResult`, an array of those, a directive result, or nothing, so a helper like `lede(content: unknown)` is correctly typed and `TemplateResult` alone would be too narrow. Narrow it only when the helper genuinely accepts one shape (`backLink(href: string, ...)`). + +This case is about a value YOU accept, never one the framework hands you. A layout's `children` also ends up in a hole, but the framework already types it (`LayoutProps.children` is a `TemplateResult`), so `{ children: unknown }` is a discarded type, not this carve-out. + +Everywhere else `unknown` is a missing type, not a safe one: surviving into a return type, a component prop, a layout's `children`, or an action signature is the shape to fix. + +`any` gets no carve-out at all in app code. It does not defer checking, it disables it, so a validator typed `(input: any)` un-types everything downstream of the call. + ### Server actions type-check automatically Calling a server action from a client component resolves at type-check time to the action's real source file. The runtime stub swap is invisible to the checker, and the RPC serializer makes runtime match the types (`Date` stays `Date`, `Map` stays `Map`, `BigInt` stays `BigInt`; see `data-and-actions.md` for the full supported set). diff --git a/AGENTS.md b/AGENTS.md index ba72e77e0..86feb9d61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ to these `references/`: | `references/styling.md` | Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | | `references/client-router-and-streaming.md` | Client router, prefetch, frames, view transitions, Suspense streaming | | `references/optimistic-ui.md` | Optimistic UI for a user-facing mutation | -| `references/typescript.md` | TypeScript at runtime, erasable syntax, full-stack types | +| `references/typescript.md` | TypeScript at runtime, erasable syntax, full-stack types, the derive-the-type rule (never `unknown` / `any`) | | `references/testing.md` | Unit, browser, e2e tests, the `handle()` harness, Bun parity | | `references/built-ins.md` | Auth, caching, env vars, rate limit, file storage, the `webjs` config block | | `references/runtime.md` | Node vs Bun, running the app, deploying, runtime-specific differences | @@ -338,6 +338,16 @@ type ActionResult = --- +## Types: derive them, never `unknown` or `any` + +**Full-stack type safety is the point of the `.server.{js,ts}` boundary, so an app that types its boundaries `unknown` or `any` gives up WebJs's central guarantee for nothing.** There is no build step and no code generation between the two sides: a client component importing a server action resolves to the action's real signature at type-check time, so the type is already there to be used. Nothing enforces this (both are valid TypeScript, so `tsc` and `webjs check` pass either way), which is exactly why it is written down. + +Derive the type at every boundary: a DB row from the schema (`typeof todos.$inferSelect`, carried into a shipping component with `import type`), an action's input from a named `interface` and its result from `ActionResult`, a page from `PageProps<'/blog/[slug]'>`, a layout from `LayoutProps`, a route handler's second argument from `RouteHandlerContext`, an href from the generated `Route` union (`npx webjsdev types`), a reactive property from `prop(Object)`. + +`unknown` is right for a payload nothing has vouched for yet, narrowed on the next line (a `route.ts` `await req.json()`, an action's `export const validate`, a `catch` binding), and for a value headed for an `html` template hole (a hole renders a string, a number, a `TemplateResult`, or an array of those, so `TemplateResult` alone is too narrow). Everywhere else it is a missing type, not a safe one: `unknown` surviving into a return type, a component prop, a layout's `children`, or an action signature is the shape to fix. The template-hole case covers a parameter of YOUR OWN helper, never a value the framework already types. `any` has no carve-out at all. Full ladder, with the end-to-end example, in `references/typescript.md`. + +--- + ## Styling: Tailwind-first **Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`). Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`. @@ -369,7 +379,9 @@ class TodoList extends WebComponent({ source: () => this.todos, update: (state, title: string) => [ ...state, - { id: crypto.randomUUID() as any, title, completed: false, pending: true }, + // No cast needed: `Todo['id']` is a string (a uuid primary key). Against + // an auto-increment integer id, model the temp row instead of casting. + { id: crypto.randomUUID(), title, completed: false, createdAt: new Date(), pending: true }, ], }); @@ -384,7 +396,11 @@ class TodoList extends WebComponent({ const result = await promise; if (result.success && result.data) { - this.todos = [...this.todos.filter(t => t.pending), result.data, ...this.todos.filter(t => !t.pending)]; + // The optimistic row is never written to `this.todos`: the overlay holds + // only the payload and `update` rebuilds the row on each `.value` read, + // so this prop holds only confirmed rows. Append the server's row, + // matching the order the `update` reducer used. + this.todos = [...this.todos, result.data]; } } diff --git a/blog/csrf-protection-without-tokens.md b/blog/csrf-protection-without-tokens.md index 055de4ec8..50af332de 100644 --- a/blog/csrf-protection-without-tokens.md +++ b/blog/csrf-protection-without-tokens.md @@ -75,13 +75,13 @@ WebJs sends no `Set-Cookie` riding the SSR HTML for CSRF, because there is no to ```ts // app/layout.ts (root layout for a visitor-identical app) import { html } from '@webjsdev/core'; -import type { Metadata } from '@webjsdev/core'; +import type { Metadata, LayoutProps } from '@webjsdev/core'; export const metadata: Metadata = { cacheControl: 'public, max-age=300', }; -export default function RootLayout({ children }: { children: unknown }) { +export default function RootLayout({ children }: LayoutProps) { return html`
${children}
`; } ``` diff --git a/examples/blog/CONVENTIONS.md b/examples/blog/CONVENTIONS.md index d6f3855b3..89cf29c81 100644 --- a/examples/blog/CONVENTIONS.md +++ b/examples/blog/CONVENTIONS.md @@ -812,6 +812,7 @@ export async function createPost(input: { } ``` If you turn `erasableSyntaxOnly` off and use non-erasable syntax, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule. WebJs is buildless end-to-end and has no bundler fallback. The `erasable-typescript-only` convention check warns when the flag is off. +- **Derive types at every boundary, never `unknown` or `any`.** Full-stack type safety is what the `.server.ts` boundary buys: a component importing a server action resolves to its real signature at type-check time, with no build step in between. So take the type that is already there: a row from the schema (`typeof posts.$inferSelect`, carried into a shipping component with `import type`), an action's input from a named `interface` and its result from `ActionResult`, the routing files from `PageProps` / `LayoutProps` / `RouteHandlerContext`, an href from the generated `Route` union, a reactive property from `prop(Object)`. `unknown` is right for a payload nothing has vouched for yet that the next line narrows (a `route.ts` `await req.json()`, an action's `export const validate`, a `catch` binding), and for a parameter of your own helper that forwards into an `html` template hole, which is NOT narrowed because a hole renders a string, a number, a `TemplateResult`, or an array of those, so `TemplateResult` alone is too narrow (a value the framework already types, like a layout's `children`, is not this case). `unknown` that survives into a return type, a component prop, or an action signature is a missing type, not a safe one, and `any` has no carve-out. Nothing enforces this, since both are valid TypeScript, which is why it is written down. - No semicolons (or with semicolons, pick one and stay consistent) - `const` by default, `let` when needed, never `var` - Prefer `async/await` over `.then()` chains diff --git a/examples/blog/modules/auth/actions/login.server.ts b/examples/blog/modules/auth/actions/login.server.ts index c70f89958..006a33dc1 100644 --- a/examples/blog/modules/auth/actions/login.server.ts +++ b/examples/blog/modules/auth/actions/login.server.ts @@ -4,11 +4,15 @@ import { db } from '#db/connection.server.ts'; import { verifyPassword } from '#lib/password.server.ts'; import { createSession } from '#lib/session.server.ts'; import { validateLogin } from '#modules/auth/utils/validate.ts'; +import type { LoginInput } from '#modules/auth/utils/validate.ts'; import type { ActionResult, PublicUser } from '#modules/auth/types.ts'; /** Authenticate by email + password; open a new session. */ +// The parameter declares the CONTRACT (so a caller type-checks against it); +// validateLogin is the runtime enforcement, since this action is also +// reachable from route.ts with a raw JSON body. export async function login( - input: unknown, + input: LoginInput, ): Promise> { let parsed; try { parsed = validateLogin(input); } diff --git a/examples/blog/modules/auth/actions/signup.server.ts b/examples/blog/modules/auth/actions/signup.server.ts index 5f18174b0..dbee83f05 100644 --- a/examples/blog/modules/auth/actions/signup.server.ts +++ b/examples/blog/modules/auth/actions/signup.server.ts @@ -5,14 +5,18 @@ import { users } from '#db/schema.server.ts'; import { hashPassword } from '#lib/password.server.ts'; import { createSession } from '#lib/session.server.ts'; import { validateSignup } from '#modules/auth/utils/validate.ts'; +import type { SignupPayload } from '#modules/auth/utils/validate.ts'; import type { ActionResult, PublicUser } from '#modules/auth/types.ts'; /** * Register a new user + open a session. The session token is returned for * the caller (route handler) to set as a cookie on the HTTP response. */ +// The parameter declares the CONTRACT (so a caller type-checks against it); +// validateSignup is the runtime enforcement, since this action is also +// reachable from route.ts with a raw JSON body. export async function signup( - input: unknown, + input: SignupPayload, ): Promise> { let parsed; try { parsed = validateSignup(input); } diff --git a/examples/blog/modules/auth/utils/validate.ts b/examples/blog/modules/auth/utils/validate.ts index e29fb2806..35d69dcc9 100644 --- a/examples/blog/modules/auth/utils/validate.ts +++ b/examples/blog/modules/auth/utils/validate.ts @@ -3,6 +3,13 @@ * the same rules for a better UX. */ +// Each type is a CONTRACT an action's signature declares, so a caller +// type-checks against it; the validator below is the runtime enforcement. +// Signup needs two because the wire shape and the narrowed shape differ: a +// caller may omit `name`, and the validator always resolves it to `string | +// null`. Login's wire shape and narrowed shape are identical, so one type +// serves both. +export type SignupPayload = { email: string; password: string; name?: string | null }; export type SignupInput = { email: string; password: string; name: string | null }; export type LoginInput = { email: string; password: string }; diff --git a/examples/blog/modules/comments/actions/create-comment.server.ts b/examples/blog/modules/comments/actions/create-comment.server.ts index 380f1d8a2..bfd02e943 100644 --- a/examples/blog/modules/comments/actions/create-comment.server.ts +++ b/examples/blog/modules/comments/actions/create-comment.server.ts @@ -6,24 +6,27 @@ import { currentUser } from '#modules/auth/queries/current-user.server.ts'; import { publish } from '#modules/comments/utils/bus.ts'; import { formatComment } from '#modules/comments/utils/format.ts'; import type { ActionResult } from '#modules/auth/types.ts'; -import type { CommentFormatted } from '#modules/comments/types.ts'; +import type { CommentFormatted, CreateCommentInput } from '#modules/comments/types.ts'; // A mutation (#488, POST by default). A new comment invalidates the per-post // `comments:` tag, matching listComments' tags, so a fresh page render gets the // new comment even before the live WebSocket push. -export const invalidates = (input: { postId: number }) => ['comments', `comments:${input.postId}`]; +export const invalidates = (input: CreateCommentInput) => ['comments', `comments:${input.postId}`]; /** * Add a comment to a post. Requires auth. Publishes to the comments bus * so live subscribers (WebSocket clients) pick it up instantly. */ +// The parameter declares the CONTRACT (so type-checks its call); +// the checks below ENFORCE it, since this action is also reachable from +// route.ts with a raw JSON body. export async function createComment( - input: { postId: number; body: string } | { postId: unknown; body: unknown }, + input: CreateCommentInput, ): Promise> { const me = await currentUser(); if (!me) return { success: false, error: 'Not signed in', status: 401 }; - const postId = Number((input as any)?.postId); - const body = typeof (input as any)?.body === 'string' ? (input as any).body.trim() : ''; + const postId = Number(input?.postId); + const body = typeof input?.body === 'string' ? input.body.trim() : ''; if (!Number.isFinite(postId)) return { success: false, error: 'postId required', status: 400 }; if (!body) return { success: false, error: 'body is required', status: 400 }; if (body.length > 2000) return { success: false, error: 'body too long', status: 400 }; diff --git a/examples/blog/modules/comments/types.ts b/examples/blog/modules/comments/types.ts index 607d70dfe..05e66b292 100644 --- a/examples/blog/modules/comments/types.ts +++ b/examples/blog/modules/comments/types.ts @@ -6,3 +6,6 @@ export type CommentFormatted = { body: string; createdAt: string; }; + +/** What a caller sends to createComment. The contract, enforced at runtime. */ +export type CreateCommentInput = { postId: number; body: string }; diff --git a/examples/blog/modules/comments/utils/format.ts b/examples/blog/modules/comments/utils/format.ts index 650bb15a5..e490db61f 100644 --- a/examples/blog/modules/comments/utils/format.ts +++ b/examples/blog/modules/comments/utils/format.ts @@ -1,6 +1,16 @@ import type { CommentFormatted } from '#modules/comments/types.ts'; +import type { Comment, User } from '#db/schema.server.ts'; -export function formatComment(c: any): CommentFormatted { +// The row is a comments row DERIVED from the schema, plus the author the +// caller joined or spliced onto it. `import type` is what lets a schema type +// cross the `.server.ts` boundary: the stripper erases it, so nothing pulls the +// DB driver in. Rename a column and every call site is a compile error instead +// of an `undefined` in the rendered comment. +type CommentRow = Comment & { + author?: Pick | null; +}; + +export function formatComment(c: CommentRow): CommentFormatted { return { id: c.id, postId: c.postId, diff --git a/examples/blog/modules/posts/actions/create-post.server.ts b/examples/blog/modules/posts/actions/create-post.server.ts index 59f3c0041..b3bec7c07 100644 --- a/examples/blog/modules/posts/actions/create-post.server.ts +++ b/examples/blog/modules/posts/actions/create-post.server.ts @@ -6,7 +6,7 @@ import { slugify, formatPost } from '#modules/posts/utils/slugify.ts'; import { currentUser } from '#modules/auth/queries/current-user.server.ts'; import { listPosts } from '#modules/posts/queries/list-posts.server.ts'; import type { ActionResult } from '#modules/auth/types.ts'; -import type { PostFormatted } from '#modules/posts/types.ts'; +import type { PostFormatted, CreatePostInput } from '#modules/posts/types.ts'; // A mutation (#488, POST by default). A new post invalidates the `posts` tag // so the next listing / GET read refetches. (listPosts.invalidate() below is @@ -17,8 +17,12 @@ export const invalidates = () => ['posts']; * Create a post authored by the currently-logged-in user. Reads the user * from the request context (AsyncLocalStorage): no userId parameter. */ +// The parameter declares the CONTRACT (CreatePostInput already lives in +// modules/posts/types.ts), so type-checks its call. The runtime +// checks below stay because this action is also reachable from route.ts +// with a raw JSON body. export async function createPost( - input: unknown, + input: CreatePostInput, ): Promise> { const me = await currentUser(); if (!me) return { success: false, error: 'Not signed in', status: 401 }; diff --git a/examples/blog/modules/posts/utils/slugify.ts b/examples/blog/modules/posts/utils/slugify.ts index 31e9e684f..69133d41c 100644 --- a/examples/blog/modules/posts/utils/slugify.ts +++ b/examples/blog/modules/posts/utils/slugify.ts @@ -1,4 +1,5 @@ import type { PostFormatted } from '#modules/posts/types.ts'; +import type { Post, User } from '#db/schema.server.ts'; /** Produce a URL-safe slug from a title. Truncates at 60 chars. */ export function slugify(s: string): string { @@ -9,14 +10,12 @@ export function slugify(s: string): string { .slice(0, 60); } -type PostRow = { - id: number; - slug: string; - title: string; - body: string; - authorId: number; - createdAt: Date; - author?: { name: string | null; email: string } | null; +// Derived from the schema, not re-declared: a renamed column is a compile +// error at every call site instead of an `undefined` in the rendered post. +// `import type` erases before anything reaches the browser, so a schema type +// crosses the `.server.ts` boundary safely. +type PostRow = Post & { + author?: Pick | null; }; export function formatPost(post: PostRow): PostFormatted { diff --git a/examples/blog/test/posts/posts.test.ts b/examples/blog/test/posts/posts.test.ts index 46197e0d4..52cd5c41b 100644 --- a/examples/blog/test/posts/posts.test.ts +++ b/examples/blog/test/posts/posts.test.ts @@ -18,6 +18,7 @@ import { withRequest } from '@webjsdev/server'; import { setStore, memoryStore } from '@webjsdev/server'; import { signup } from '#modules/auth/actions/signup.server.ts'; +import type { CreatePostInput } from '#modules/posts/types.ts'; import { listPosts } from '#modules/posts/queries/list-posts.server.ts'; import { getPost } from '#modules/posts/queries/get-post.server.ts'; import { createPost } from '#modules/posts/actions/create-post.server.ts'; @@ -134,7 +135,11 @@ describe('posts', () => { test('createPost with non-object input returns error', async () => { const result = await withSession(authToken, () => - createPost(null as unknown), + // A deliberate contract violation: the point of this test is that the + // action's RUNTIME guard still fires for a caller that ignores the type + // (a raw JSON body through route.ts). The cast is what makes that + // intent explicit rather than leaving the parameter untyped. + createPost(null as unknown as CreatePostInput), ); assert.equal(result.success, false); if (result.success) return; diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index 9ea28741d..98d0c6eb1 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -1149,6 +1149,7 @@ ${uiThemeRaw} await copyGallery(appDir); await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce, asset } from '@webjsdev/core'; +import type { LayoutProps } from '@webjsdev/core'; import '#components/theme-toggle.ts'; /** @@ -1167,7 +1168,11 @@ import '#components/theme-toggle.ts'; // lives at public/favicon.svg and serves at /public/favicon.svg. export const metadata = { icons: '/public/favicon.svg' }; -export default function RootLayout({ children }: { children: unknown }) { +// LayoutProps types every layout argument (children, params, searchParams, +// url) from the framework, so children is a TemplateResult rather than an +// untyped value. Derive types like this everywhere instead of widening to +// unknown; see .agents/skills/webjs/references/typescript.md. +export default function RootLayout({ children }: LayoutProps) { // Read the in-flight request's CSP nonce so the theme-detection inline script // passes strict CSP. Returns '' when no CSP nonce is set. const nonce = cspNonce(); diff --git a/packages/cli/templates/AGENTS.md b/packages/cli/templates/AGENTS.md index 8c932523a..7da873cae 100644 --- a/packages/cli/templates/AGENTS.md +++ b/packages/cli/templates/AGENTS.md @@ -31,11 +31,37 @@ This is what separates a working app from a broken one. ## Type everything (all templates) -Define explicit TypeScript interfaces and discriminated unions for your data -payloads and action inputs and outputs (and, in a UI app, component props and -optimistic updates). Narrow an `ActionResult` with -`if (result.success && result.data)`. Never reach for `any` or a loose -`as any` cast. +Full-stack type safety is what the `.server.ts` boundary buys you: a client +component importing a server action resolves to that action's real signature at +type-check time, with no build step and no code generation in between. So +DERIVE the type at every boundary instead of widening it: + +- A database row: `export type Todo = typeof todos.$inferSelect` in + `db/schema.server.ts` (`$inferInsert` for a write), carried into a + browser-shipped component with `import type` (erased before it reaches the + browser, so it does not trip the server-import boundary). +- An action's input: a named `interface`. Its result: `ActionResult`. + Narrow with `if (result.success && result.data)`. +- Routing files: `PageProps<'/blog/[slug]'>`, `LayoutProps`, + `RouteHandlerContext`, all from `@webjsdev/core`. Run `npx webjsdev types` + for the typed `Route` union and per-route `params`. +- A reactive property: `prop(Object)`, `prop(Array)`. + +Never reach for `any` or a loose `as any` cast, and do not reach for `unknown` +either just because it looks safer. `unknown` is right for a payload nothing +has vouched for yet, narrowed on the very next line (a `route.ts` `await +req.json()`, an action's `export const validate` or a validator it delegates +to, a `catch` binding), and for a parameter of YOUR OWN helper that forwards +into an `html` template hole (a hole renders a string, a number, a +`TemplateResult`, or an array of those, so `TemplateResult` alone is too +narrow). That second case is about a value you accept, never one the framework +already types. Everywhere else it is a missing type, not a safe one: `unknown` +that survives into a return type, a component prop, a layout's `children`, or +an action signature is the shape to fix. +Nothing enforces this (both are valid TypeScript, so `webjs check` and `tsc` +pass either way), which is exactly why it is written down. The full ladder, +with an end-to-end example, is in +`.agents/skills/webjs/references/typescript.md`. Keep server-only code (database drivers, secrets, `node:*` builtins) in `.server.ts` modules. There are exactly two kinds: diff --git a/packages/cli/templates/CONVENTIONS.md b/packages/cli/templates/CONVENTIONS.md index fa595b4ed..2c82beba0 100644 --- a/packages/cli/templates/CONVENTIONS.md +++ b/packages/cli/templates/CONVENTIONS.md @@ -24,6 +24,9 @@ is the short version. teaches the same and survives the clear), run `npm run gallery:clear` to shed the showcase, then grow the app in place. `AGENTS.md` has the full template-specific playbook. +- **Derive types at every boundary.** Rows from `$inferSelect`, action inputs + from an `interface`, routing files from `PageProps` / `LayoutProps`. Never + `any`, and never `unknown` where a real type exists. - **Progressive enhancement is the default.** Pages render as HTML, `` navigates, `
` + a page action submits, all with JavaScript off; opt into interactivity per behaviour inside a component. diff --git a/packages/cli/templates/gallery/app/examples/layout.ts b/packages/cli/templates/gallery/app/examples/layout.ts index b14f294a3..39a4bfb78 100644 --- a/packages/cli/templates/gallery/app/examples/layout.ts +++ b/packages/cli/templates/gallery/app/examples/layout.ts @@ -1,10 +1,11 @@ import { html } from '@webjsdev/core'; +import type { LayoutProps } from '@webjsdev/core'; import { backLink } from '#lib/utils/ui.ts'; // Shared layout for every gallery example app under /examples/*. It adds the same // slim "back to the gallery" link the feature demos get, so an example is never a // dead end. A non-root layout, so it never writes the document shell. -export default function ExamplesLayout({ children }: { children: unknown }) { +export default function ExamplesLayout({ children }: LayoutProps) { // An example app has no sidebar, so center it in a focused reading column // (the root centers the whole page; this narrows the example within it). return html` diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts index 47da8bbb9..132c9624d 100644 --- a/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts @@ -1,4 +1,5 @@ import { html } from '@webjsdev/core'; +import type { LayoutProps } from '@webjsdev/core'; import { buttonClass } from '#components/ui/button.ts'; // Nested layout for the protected dashboard subtree. Logout is a plain @@ -7,7 +8,7 @@ import { buttonClass } from '#components/ui/button.ts'; // default). signOut is server-only (modules/auth/auth.server.ts), so we POST to // its route rather than import it into a browser-shipping page. After signout the // dashboard middleware bounces any later visit to login. -export default function DashboardLayout({ children }: { children: unknown }) { +export default function DashboardLayout({ children }: LayoutProps) { return html`