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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agents/skills/webjs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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<T>`, 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

Expand Down
18 changes: 13 additions & 5 deletions .agents/skills/webjs/references/data-and-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const fieldErrors: Record<string, string> = {};
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.
Expand Down
20 changes: 13 additions & 7 deletions .agents/skills/webjs/references/optimistic-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
vivek7405 marked this conversation as resolved.
// 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 },
],
});

Expand All @@ -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];
}
}
Expand Down
3 changes: 2 additions & 1 deletion .agents/skills/webjs/references/routing-and-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`<html lang="en"><head></head><body>${children}</body></html>`;
}
```
Expand Down
73 changes: 71 additions & 2 deletions .agents/skills/webjs/references/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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<ActionResult<Post>>` | `Promise<any>` |
| `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<string, any> }` |
| 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<Student>(Object)`, `prop<Tag[]>(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<ActionResult<Post>> { /* ... */ }

// 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).
Expand Down
22 changes: 19 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -338,6 +338,16 @@ type ActionResult<T> =

---

## 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<T>`, 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<Student>(Object)`.
Comment thread
vivek7405 marked this conversation as resolved.

`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`.
Expand Down Expand Up @@ -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 },
],
});

Expand All @@ -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];
}
}

Expand Down
4 changes: 2 additions & 2 deletions blog/csrf-protection-without-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`<main class="max-w-[760px]">${children}</main>`;
}
```
Expand Down
Loading
Loading