@weft-js/start 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1266 -33
  2. package/crate/README.md +322 -0
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,14 +1,111 @@
1
- # @weft-js/start
1
+ <p align="center">
2
+ <a href="https://github.com/Ravonus/weft">
3
+ <img src="https://raw.githubusercontent.com/Ravonus/weft/main/site/public/logo.svg" width="88" height="88" alt="Weft">
4
+ </a>
5
+ </p>
2
6
 
3
- > **Early release (0.1.x).** APIs can change between minor versions.
7
+ <h1 align="center">@weft-js/start</h1>
4
8
 
5
- The full-stack part of Weft: routes from files, server functions, server rendering with streaming, a database, auth and
6
- realtime. It runs on Node, Bun or Deno, and doesn't need Rust. An app made with `npm create @weft-js` already has it.
9
+ <p align="center">
10
+ <strong>Weft's full-stack layer: file routes, typed server functions, streaming SSR, a database, auth and live data.</strong><br>
11
+ Pages ship only the islands that need the client, and one build runs on Node, Bun or Deno.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/@weft-js/start"><img src="https://img.shields.io/npm/v/@weft-js/start?color=145c3c&label=npm" alt="npm version"></a>
16
+ <a href="https://github.com/Ravonus/weft/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-145c3c" alt="MIT license"></a>
17
+ <a href="https://github.com/Ravonus/weft/actions"><img src="https://img.shields.io/github/actions/workflow/status/Ravonus/weft/quality.yml?branch=main&label=checks" alt="checks"></a>
18
+ <img src="https://img.shields.io/node/v/@weft-js/start?color=145c3c" alt="node version">
19
+ </p>
20
+
21
+ <p align="center">
22
+ <a href="#quick-start">Quick start</a> ·
23
+ <a href="https://github.com/Ravonus/weft/tree/main/docs">Docs</a> ·
24
+ <a href="https://github.com/Ravonus/weft/tree/main/examples">Examples</a> ·
25
+ <a href="https://github.com/Ravonus/weft/blob/main/CHANGELOG.md">Changelog</a> ·
26
+ <a href="https://github.com/Ravonus/weft">GitHub</a>
27
+ </p>
28
+
29
+ > [!IMPORTANT]
30
+ > **Early release (0.1.x).** APIs can change between minor versions. Pin the version and read the
31
+ > [changelog](https://github.com/Ravonus/weft/blob/main/CHANGELOG.md) before you upgrade.
32
+
33
+ `@weft-js/start` turns a folder of Weft components into a server-rendered app. Files in `src/routes` become pages,
34
+ exports in `src/server` become typed functions the client calls over HTTP, and the server streams each page as its
35
+ data arrives. A database, sign-in and realtime updates plug into the same `ctx` every server function gets. It runs on
36
+ Node, Bun or Deno and needs no Rust. An app made with `npm create @weft-js` already has it, wired up.
37
+
38
+ ## Highlights
39
+
40
+ - **Routes from files.** `src/routes/shop/[id].tsx` is `/shop/:id`, with `_layout.tsx` for layouts, `404.tsx` and
41
+ `_error.tsx` for failures, and typed `params` generated from the file names.
42
+ - **Server functions with a contract.** `query` and `mutation` validate input with any Standard Schema (Zod, Valibot…),
43
+ run with `ctx`, return typed results, and name the cache tags they read and invalidate.
44
+ - **Streaming SSR.** The head and shell go first, then each `<Boundary>` as its data resolves. A `load` field left as a
45
+ promise streams into the page instead of holding up the first byte.
46
+ - **Islands by default.** The compiler works out which components need the browser. The rest stays server HTML with no
47
+ JavaScript, and an island loads when it is seen or used, with events before then replayed.
48
+ - **Forms that work without JavaScript.** `<Form of={mutation}>` posts to the mutation, shows schema errors by field,
49
+ and restores what was typed when a no-JS submit comes back with errors.
50
+ - **A database and auth on `ctx`.** Drizzle over SQLite (`node:sqlite` or `bun:sqlite`, no native binary), Postgres or
51
+ MySQL; migrations apply on the next request. Better Auth by default, or any provider with two functions.
52
+ - **Live data without polling code.** `query.live()` refetches whenever a mutation on any tab invalidates one of its
53
+ tags, and `dbChanges` brings in writes from cron jobs, `psql` or other services.
54
+ - **Deploy targets checked before you deploy.** `weft deploy hosts` says which of Netlify, Vercel, Cloudflare, Fly and
55
+ Render can run what the app uses, then `weft deploy init` writes their files and a GitHub workflow.
56
+
57
+ ## Contents
58
+
59
+ - [Install](#install)
60
+ - [Quick start](#quick-start)
61
+ - [Project layout](#project-layout)
62
+ - [File routes](#file-routes)
63
+ - [Data loading](#data-loading)
64
+ - [Server functions](#server-functions)
65
+ - [Forms](#forms)
66
+ - [Streaming SSR and islands](#streaming-ssr-and-islands)
67
+ - [Navigation, view transitions and speculation rules](#navigation-view-transitions-and-speculation-rules)
68
+ - [Head, SEO and share cards](#head-seo-and-share-cards)
69
+ - [Database](#database)
70
+ - [Auth](#auth)
71
+ - [Realtime](#realtime)
72
+ - [Configuration](#configuration)
73
+ - [Runtimes, deploy targets and adapters](#runtimes-deploy-targets-and-adapters)
74
+ - [API reference](#api-reference)
75
+ - [Troubleshooting](#troubleshooting)
76
+ - [Related packages](#related-packages)
77
+ - [Contributing](#contributing)
78
+ - [License](#license)
79
+
80
+ ## Install
81
+
82
+ Start a new app. The wizard asks for a UI kit, a database, a data layer and auth, and installs everything:
83
+
84
+ ```bash
85
+ npm create @weft-js my-app
86
+ # or
87
+ pnpm create @weft-js my-app
88
+ # or
89
+ bun create @weft-js my-app
90
+ # or (Deno)
91
+ deno run -A npm:@weft-js/create my-app
92
+ ```
93
+
94
+ Each one scaffolds for its own runtime and package manager. To add Start to an existing Weft project:
7
95
 
8
96
  ```bash
9
- npm install @weft-js/start
97
+ npm install @weft-js/start @weft-js/core
98
+ # or
99
+ pnpm add @weft-js/start @weft-js/core
100
+ # or
101
+ bun add @weft-js/start @weft-js/core
10
102
  ```
11
103
 
104
+ Start needs Node 26 or later (the `engines` field), Bun or Deno. Drizzle, Better Auth, Zod and the database drivers are
105
+ optional peer dependencies: install the ones your app uses, or let `weft add db` and `weft add auth` do it.
106
+
107
+ ## Quick start
108
+
12
109
  A server function in `src/server/` runs on the server. The client gets a stub that calls it:
13
110
 
14
111
  ```ts
@@ -28,48 +125,1184 @@ arrives:
28
125
 
29
126
  ```tsx
30
127
  // src/routes/hello.tsx
128
+ import type { Meta } from '@weft-js/start'
31
129
  import { greeting } from '#/server/greeting'
32
130
 
131
+ export const meta: Meta = {
132
+ title: 'Hello',
133
+ description:
134
+ 'A greeting from the server, with the time it was made.',
135
+ }
136
+
33
137
  export default function Hello() {
34
138
  const text = greeting()
35
139
  return <p>{text.value ?? 'Loading…'}</p>
36
140
  }
37
141
  ```
38
142
 
39
- `npx weft dev` serves it at `/hello`.
143
+ ```bash
144
+ npx weft dev # serves it at http://localhost:5173/hello
145
+ npx weft build # writes .output/ and .weft/contract.json
146
+ npx weft start # runs .output/ on the configured runtime
147
+ ```
148
+
149
+ `#/server/greeting` is `src/server/greeting`. That alias is `paths` in `tsconfig.json`: change it or add your own there,
150
+ and Vite and `weft check` follow.
151
+
152
+ ## Project layout
153
+
154
+ ```text
155
+ src/routes/** file routes: _layout.tsx, index.tsx,
156
+ [id].tsx, [...rest].tsx, 404.tsx,
157
+ _error.tsx, x.og.tsx
158
+ src/server/** queries, mutations, streams and channels
159
+ (the client gets stubs); also any *.server.ts
160
+ src/db/schema.ts Drizzle tables; safe to import anywhere
161
+ src/db/index.server.ts the database client
162
+ src/auth.server.ts the auth provider, mounted at /api/auth/*
163
+ src/context.server.ts what `ctx` holds
164
+ src/realtime.server.ts bus, origins and change feed for realtime
165
+ weft.config.ts site metadata, runtime, hydration, deploy
166
+ drizzle/ generated migrations (never edit by hand)
167
+ ```
168
+
169
+ `load` and `meta` never ship to the client, and neither does anything under `src/server` beyond a stub per function.
170
+ Importing `@weft-js/start/db*` or `@weft-js/start/auth` from code the client reaches is an error (Q012).
171
+
172
+ ## File routes
173
+
174
+ The router reads `src/routes` into a manifest that the server, the client, the Vite plugin and `weft routes` share.
175
+
176
+ | File | URL | Notes |
177
+ | ---------------------------- | -------------------- | ------------------------------------------------------------------ |
178
+ | `index.tsx` | `/` | |
179
+ | `about.tsx` | `/about` | |
180
+ | `users/[id].tsx` | `/users/:id` | `params.id` |
181
+ | `users/index.tsx` | `/users` | |
182
+ | `docs/[...rest].tsx` | `/docs/*` | `params.rest` is the rest of the path; must be the last segment |
183
+ | `_layout.tsx` | wraps its folder | the root one wraps the whole app |
184
+ | `404.tsx` | any unmatched path | root only; rendered inside the root layout with status 404 |
185
+ | `_error.tsx` | any thrown error | root only; rendered inside the root layout with status 500 |
186
+ | `users/[id].og.tsx` | that route's card | `_default.og.tsx` replaces the built-in card for every route |
187
+ | `_anything`, `*.test.tsx` | ignored | so are folders that start with `_` |
188
+
189
+ Static segments outrank params, and params outrank a splat, so `users/new.tsx` wins over `users/[id].tsx`. A segment
190
+ that mixes text and a param (`user-[id].tsx`) is refused: use a whole segment.
191
+
192
+ A route module may export:
193
+
194
+ | Export | What it is |
195
+ | ----------- | -------------------------------------------------------------------------------------------------- |
196
+ | `default` | The component. Gets `params`, `data` and, in a layout, `children`. |
197
+ | `load` | Async data for the page, run on the server before render. See [Data loading](#data-loading). |
198
+ | `meta` | Title, description and tags, as an object or a function of `data` and `params`. |
199
+ | `prerender` | The params `weft build` writes as static pages with their cards; `true` on a route without params. |
200
+ | `skeleton` | A function of plain markup shown while the route's data loads, or `false` to keep the old page up. |
201
+
202
+ ### A param route
203
+
204
+ ```tsx
205
+ // src/routes/users/[id].tsx
206
+ import {
207
+ notFound,
208
+ type LoadArgs,
209
+ type Meta,
210
+ type RouteProps,
211
+ } from '@weft-js/start'
212
+ import { getUser } from '#/server/users'
213
+
214
+ export const load = async ({ params }: LoadArgs<'/users/[id]'>) => {
215
+ const user = await getUser.fetch({ id: params.id })
216
+ return user ?? notFound()
217
+ }
218
+
219
+ export const meta: Meta<typeof load> = ({ data }) => ({
220
+ title: data.name,
221
+ description: data.bio,
222
+ })
223
+
224
+ export default function User(
225
+ props: RouteProps<typeof load, '/users/[id]'>,
226
+ ) {
227
+ const { data } = props
228
+ return <h1>{data.name}</h1>
229
+ }
230
+ ```
231
+
232
+ `LoadArgs<'/users/[id]'>` types `params` as `{ id: string }`, and `RouteProps<typeof load, …>` types `data` from what
233
+ `load` returns, without `notFound()`. Route paths come from `.weft/routes.d.ts`, which Start's Vite plugin writes during `weft dev` and `weft build`.
234
+
235
+ `props.data` and `const { data } = props` stay live across navigations: compute from them in JSX or `derived`, not
236
+ once in the component body (W001).
237
+
238
+ ### Layouts
239
+
240
+ A `_layout.tsx` wraps every route in its folder and below. It renders `props.children` where the page goes:
241
+
242
+ ```tsx
243
+ // src/routes/_layout.tsx
244
+ import { Link, type RouteProps } from '@weft-js/start'
245
+
246
+ export const meta = {
247
+ extra: [{ name: 'theme-color', content: '#0b0b0f' }],
248
+ }
249
+
250
+ export default function Root(props: RouteProps) {
251
+ return (
252
+ <div class="root">
253
+ <nav>
254
+ <Link href="/">Home</Link>
255
+ <Link
256
+ href="/users"
257
+ match="section"
258
+ >
259
+ Users
260
+ </Link>
261
+ </nav>
262
+ {props.children}
263
+ </div>
264
+ )
265
+ }
266
+ ```
267
+
268
+ - A layout's `load` runs before its children's, so it is where a guard goes:
269
+ `export const load = ({ ctx }: LoadArgs) => ctx.user ?? redirect('/login')`.
270
+ - A layout's `params` are its page's, a `[...rest]` included, so a layout can mark the page it shows.
271
+ - Chrome shared by several sections (a sidebar, section tabs) goes in the one layout above them, following the URL with
272
+ `currentPath()`. A `_layout.tsx` per section is replaced, chrome and all, whenever you move between sections, so the
273
+ sidebar flashes and is rebuilt. Keep `{props.children}` in the same parent on every page.
274
+
275
+ ### 404 and error pages
276
+
277
+ Until you add them, an app gets built-in pages. Your own get the path, or the status and message, as `data`:
278
+
279
+ ```tsx
280
+ // src/routes/404.tsx
281
+ import type { RouteProps } from '@weft-js/start'
282
+
283
+ export const meta = {
284
+ title: 'Page not found',
285
+ description: 'There is nothing here.',
286
+ }
287
+
288
+ export default function NotFound(props: RouteProps) {
289
+ return (
290
+ <h1>Nothing lives at {(props.data as { path: string }).path}</h1>
291
+ )
292
+ }
293
+ ```
294
+
295
+ ```tsx
296
+ // src/routes/_error.tsx
297
+ export const meta = { title: 'Something broke' }
298
+
299
+ export default function Failed(props: {
300
+ data: { status: number; message: string }
301
+ }) {
302
+ return (
303
+ <h1>
304
+ Error {props.data.status}: {props.data.message}
305
+ </h1>
306
+ )
307
+ }
308
+ ```
309
+
310
+ `notFound()` returned or thrown from any `load` renders `404.tsx` with status 404, and `meta` isn't called. Both files
311
+ are read only at the routes root; one in a subfolder is reported and ignored.
312
+
313
+ ### Skeletons
314
+
315
+ Navigation is instant: the URL and `aria-current` change on click, kept layouts stay, and the level that changes shows a
316
+ skeleton made from its own markup until its data arrives. Don't build a loading state for a route change yourself. When
317
+ the generated one doesn't fit, export a function of plain markup:
318
+
319
+ ```tsx
320
+ function HomeSkeleton() {
321
+ return (
322
+ <section data-site="hero">
323
+ <h1>Your headline</h1>
324
+ <p data-skeleton></p>
325
+ </section>
326
+ )
327
+ }
328
+ export const skeleton = HomeSkeleton
329
+ ```
330
+
331
+ It runs before the page's code loads, so it may use only `div span section article main header footer p h1`–`h6
332
+ ul ol li`, string `class` and `data-*` attributes, and text. Anything else warns and leaves no skeleton.
333
+
334
+ ## Data loading
335
+
336
+ `load` runs on the server for the first request and for each client navigation (the client fetches the route's data as
337
+ JSON). It gets:
338
+
339
+ | Argument | What it is |
340
+ | --------- | -------------------------------------------------------------------------- |
341
+ | `params` | The route's params, typed from the path |
342
+ | `request` | The incoming `Request` (headers, cookies, URL) |
343
+ | `signal` | Aborts with the request |
344
+ | `ctx` | What server functions get: `ctx.db`, `ctx.user`, whatever your context adds |
345
+
346
+ Call queries with `await q.fetch(input)` here. It shares the request cache with the page's `q()`, so a query that `load`
347
+ and a component both read runs once.
348
+
349
+ ### Deferred fields stream
350
+
351
+ Return a promise as a top-level field and it isn't awaited before the first byte. It streams into the `<Boundary>` that
352
+ reads it:
353
+
354
+ ```tsx
355
+ // src/routes/product/[id].tsx
356
+ import { Boundary, resource } from '@weft-js/core'
357
+ import type { LoadArgs, RouteProps } from '@weft-js/start'
358
+ import { getProduct, getReviews } from '#/server/products'
359
+
360
+ export const load = async ({
361
+ params,
362
+ }: LoadArgs<'/product/[id]'>) => ({
363
+ // Awaited: in the first byte
364
+ product: await getProduct.fetch({ id: params.id }),
365
+ // A promise: streams later
366
+ reviews: getReviews.fetch({ id: params.id }),
367
+ })
368
+
369
+ export const meta = ({
370
+ data,
371
+ }: {
372
+ data: { product: { name: string } }
373
+ }) => ({
374
+ title: data.product.name,
375
+ })
376
+
377
+ function Reviews(props: { list: Promise<string[]> }) {
378
+ const r = resource(() => props.list)
379
+ return (
380
+ <ul>
381
+ {(r.value ?? []).map((t) => (
382
+ <li>{t}</li>
383
+ ))}
384
+ </ul>
385
+ )
386
+ }
387
+
388
+ export default function Product(
389
+ props: RouteProps<typeof load, '/product/[id]'>,
390
+ ) {
391
+ return (
392
+ <main>
393
+ <h1>{props.data.product.name}</h1>
394
+ <Boundary pending={<p>Loading reviews</p>}>
395
+ <Reviews list={props.data.reviews} />
396
+ </Boundary>
397
+ </main>
398
+ )
399
+ }
400
+ ```
401
+
402
+ `meta` and share cards only see settled fields. Reading a deferred one there is H007, an error, because it is still
403
+ streaming. On a client navigation the deferred fields arrive resolved in the route's JSON.
404
+
405
+ ### Redirects and errors in `load`
406
+
407
+ - `redirect('/x')` answers 307 (or pass `301 | 302 | 303 | 308`).
408
+ - `notFound()` renders the 404 route.
409
+ - Anything else thrown renders `_error.tsx` with status 500.
410
+
411
+ ## Server functions
412
+
413
+ Any module under `src/server/` (or named `*.server.ts`) can export queries and mutations. The `weft:start` Vite plugin
414
+ gives each an id and replaces it in the client build with a stub that calls `/_weft/fn/<id>`. Only `optimistic` ships to
415
+ the client.
416
+
417
+ ```ts
418
+ // src/server/todos.ts
419
+ import { mutation, query } from '@weft-js/start'
420
+ import { schemasOf } from '@weft-js/start/db'
421
+ import { and, eq } from 'drizzle-orm'
422
+ import { z } from 'zod'
423
+ import * as schema from '#/db/schema'
424
+
425
+ const { todos } = schema
426
+ const schemas = schemasOf(schema, {
427
+ todos: { title: (s: z.ZodString) => s.trim().min(1) },
428
+ })
429
+
430
+ export const listTodos = query({
431
+ tags: ['todos'], // what invalidates it
432
+ auth: 'user', // anyone else gets A002; ctx.user is non-null
433
+ run: ({ ctx }) =>
434
+ ctx.db.select().from(todos).where(eq(todos.userId, ctx.user.id)),
435
+ })
436
+
437
+ export const addTodo = mutation({
438
+ input: schemas.todos.insert.pick({ title: true }),
439
+ auth: 'user',
440
+ invalidates: ['todos'], // tagged queries come back in the response
441
+ optimistic: ({ input }, cache) =>
442
+ cache.update(
443
+ listTodos,
444
+ undefined,
445
+ (list) =>
446
+ void list.push({
447
+ id: `tmp:${input.title}`,
448
+ title: input.title,
449
+ done: false,
450
+ userId: '',
451
+ createdAt: 0,
452
+ }),
453
+ ),
454
+ run: async ({ input, ctx }) => {
455
+ const [row] = await ctx.db
456
+ .insert(todos)
457
+ .values({
458
+ id: crypto.randomUUID(),
459
+ userId: ctx.user.id,
460
+ createdAt: Date.now(),
461
+ ...input,
462
+ })
463
+ .returning()
464
+ return row
465
+ },
466
+ })
467
+
468
+ export const removeTodo = mutation({
469
+ input: z.object({ id: z.string() }),
470
+ auth: 'user',
471
+ invalidates: ({ id }) => ['todos', `todo:${id}`],
472
+ run: async ({ input, ctx }) => {
473
+ // auth: 'user' says who is calling, not what they may touch:
474
+ // check ownership in the where.
475
+ await ctx.db
476
+ .delete(todos)
477
+ .where(
478
+ and(eq(todos.id, input.id), eq(todos.userId, ctx.user.id)),
479
+ )
480
+ },
481
+ })
482
+ ```
483
+
484
+ ### Calling them
485
+
486
+ | Where | How | You get |
487
+ | --------------------------------------------- | ------------------------------------------ | ------------------------------------------ |
488
+ | A component or `defineState` setup | `listTodos()` | a `Resource`: `.value`, `.pending`, `.error` |
489
+ | With tracked input | `q(() => (id.value ? { id: id.value } : skip))` | refetches when `id` changes; `skip` waits |
490
+ | `load`, handlers, other server functions, tests | `await listTodos.fetch(input)` | a promise (Q001 if you call `q()` there) |
491
+ | An event handler | `await addTodo({ title })` | the result, or a rejected `FnError` |
492
+ | Mutation state | `addTodo.pending.value`, `addTodo.error.value` | every caller's state |
493
+
494
+ A query created inside a `<Boundary pending={…}>` streams from the server; created outside one, the first byte waits for
495
+ it. Read results in JSX as `(q().value ?? []).map(…)`; never copy query results into `state`.
496
+
497
+ ### Options
498
+
499
+ | Option | On | What it does |
500
+ | ------------- | ----------------- | ------------------------------------------------------------------------------------------------------ |
501
+ | `input` | both | A Standard Schema. Validated on the server before `run`; issues come back on `FnError.issues`. |
502
+ | `output` | both | Checked in dev and tests (C002), and written to `.weft/contract.json`. |
503
+ | `auth` | both | `'user'` refuses a call with no session (A002) and types `ctx.user` as non-null. |
504
+ | `tags` | query | Strings, or `(input, ctx) => string[]`. Every cache (client, request, shared) goes by these. |
505
+ | `invalidates` | mutation | Tags whose queries the response refreshes in one round trip, or `(input, result) => string[]`. |
506
+ | `optimistic` | mutation | `({ input }, cache) => void`. Runs on the client; `cache.update(query, input, draft => …)` edits a cached value until the server answers. |
507
+ | `cache` | query | `{ life: 'seconds' \| 'minutes' \| 'hours' \| 'days' \| 'max' \| number }` shares one value across requests. It can't read `ctx.user` (Q006) unless you add `scope: 'user'`. |
508
+ | `live` | query | `{ required: true }` fails the build on a target with no real change feed; `verify` proves it. See [Realtime](#realtime). |
509
+ | `errors` | both | Named failures, raised with `raise(code, data)`; the client sees `FnError.code` and `.data`. |
510
+ | `shape` | query | An example result that sizes a pending view. |
511
+
512
+ A table a function touches must be named in its `tags` or `invalidates` (Q011, checked at run time in `weft dev`): a
513
+ tag named after the table, or `table:…`.
514
+
515
+ ### Failing on purpose
516
+
517
+ ```ts
518
+ import { FnError, invalid, raise, redirect } from '@weft-js/start'
519
+
520
+ // A user-facing message and a status
521
+ throw new FnError({ message: 'Not yours' }, 403)
522
+ // A field error, shown next to the field by <Form>
523
+ invalid({ email: 'That address is taken' })
524
+ // A declared error: FnError.code and .data on the client
525
+ raise('out_of_stock', { sku })
526
+ redirect('/todos') // in run, the client follows it
527
+ ```
528
+
529
+ On the client, a failed call rejects with an `FnError` carrying `.message`, `.code`, `.status` and `.issues`.
530
+
531
+ ### Invalidating by hand
532
+
533
+ `invalidate('todos')` drops those tags: on the server for this response and the shared cache, on the client by
534
+ refetching what reads them. Tags can name one row: `tags: ({ id }) => ['todos', `todo:${id}`]`.
535
+
536
+ ## Forms
537
+
538
+ `<Form of={mutation}>` renders a real `<form>` that posts to the mutation. Before JavaScript loads, it is a plain POST
539
+ and the page comes back with errors and typed values restored. After, it submits with `fetch`, keeps focus on the first
540
+ invalid field, and never reloads.
541
+
542
+ ```tsx
543
+ import { Form, type FormApi } from '@weft-js/start'
544
+ import { joinWaitlist } from '#/server/waitlist'
545
+
546
+ export function WaitlistForm() {
547
+ return (
548
+ <Form
549
+ of={joinWaitlist}
550
+ class="waitlist"
551
+ >
552
+ {(form: FormApi) => (
553
+ <>
554
+ {form.message.value && (
555
+ <p role="alert">That didn't go through.</p>
556
+ )}
557
+ <label>
558
+ Email
559
+ <input
560
+ type="email"
561
+ required
562
+ {...form.field('email', 'email')}
563
+ />
564
+ </label>
565
+ <form.Error name="email" />
566
+ <button
567
+ type="submit"
568
+ disabled={form.pending.value}
569
+ >
570
+ Join
571
+ </button>
572
+ </>
573
+ )}
574
+ </Form>
575
+ )
576
+ }
577
+ ```
578
+
579
+ | Prop | Default | What it does |
580
+ | ----------- | ------- | -------------------------------------------------------------------- |
581
+ | `of` | | The mutation (F001 if it isn't one) |
582
+ | `id` | | Tells two forms for one mutation apart on the no-JS path |
583
+ | `reset` | `true` | Empty the fields after a success |
584
+ | `onSuccess` | | `(result) => void` |
585
+ | `onError` | | `(error) => void` |
586
+
587
+ The `FormApi` gives `pending`, `errors`, `message`, `result`, `error(name)`, `value(name)`, `field(name, kind?)` and an
588
+ `Error` component that renders a live region. `field(name)` restores the raw submitted value; pass `'text'` or
589
+ `'email'` for a string, or `'multiple'` for an array. Field names with dots nest (`address.city`), and `x[]` or repeated
590
+ names become arrays.
591
+
592
+ ## Streaming SSR and islands
593
+
594
+ ### What a response looks like
595
+
596
+ The server matches the path, runs every `load` in the chain, merges the metadata, then streams:
597
+
598
+ 1. The `<head>` with every tag and the critical CSS, and the page shell.
599
+ 2. Each `<Boundary>`'s content as its resources resolve, one chunk each.
600
+ 3. The page data the client needs to take over.
601
+
602
+ ```tsx
603
+ import { Boundary } from '@weft-js/core'
604
+
605
+ ;<Boundary
606
+ pending={<p>Loading…</p>}
607
+ error={(e, retry) => <button onClick={retry}>Retry</button>}
608
+ >
609
+ <Orders />
610
+ </Boundary>
611
+ ```
612
+
613
+ A `<Boundary>` covers every resource created while its children are built. Its fallback shows after 150 ms and stays
614
+ at least 400 ms, and a refetch keeps the current content. `weft docs async` has the details.
615
+
616
+ ### Islands
617
+
618
+ Most of a page has nothing to do in the browser. Weft's compiler follows each route's component graph and marks a
619
+ component as client only when it has a reason: an event handler, a `ref` or spread on an element, a read of reactive
620
+ state or a query, an `import()` outside a handler, or a top-level side effect. Each call site of such a component from
621
+ static code is an island. Everything else stays server HTML and ships no JavaScript.
622
+
623
+ Each island then loads when it is needed:
624
+
625
+ | Timing | When it loads | What earns it |
626
+ | ------------- | -------------------------------- | --------------------------------------------------------------------------------- |
627
+ | `interaction` | on the first event inside it | only handlers: every change starts with an event, and events replay |
628
+ | `visible` | when it scrolls into view | work that starts by itself but stays inside the island |
629
+ | `load` | with the page | anything else: `autofocus`, a module that runs as it loads, a box at module level |
630
+
631
+ A small inline script captures clicks, input and submits on an island whose code hasn't arrived, holds the default of a
632
+ click or submit, and replays them in order once the code is there. If the code never comes, the defaults run late. When
633
+ the visitor asks for less data (`prefers-reduced-data` or Save-Data), pointer hover doesn't start a load early.
634
+
635
+ When the compiler can't prove what you know, say it with a comment above the component:
636
+
637
+ ```tsx
638
+ /* @weft visible */
639
+ export function Chart(props: { points: number[] }) {
640
+ // measures and draws once it's on screen
641
+ }
642
+ ```
643
+
644
+ | Comment | Means |
645
+ | -------------------------- | -------------------------------------------------------------- |
646
+ | `/* @weft client */` | always ship this component and load it with the page |
647
+ | `/* @weft static */` | this component needs no client code |
648
+ | `/* @weft visible */` | its work only matters once it's on screen |
649
+ | `/* @weft interaction */` | its work only matters to someone using it |
650
+
651
+ `.weft/hydration.json` says why each island loads when it does, and H010 points out islands that load with the page for
652
+ a reason their code could avoid. An island's props cross from server to client as JSON: a function, a `Date` or a class
653
+ instance there is H009. A prop that is a function over local names can't cross either, so the component around it
654
+ hydrates whole (H017).
655
+
656
+ Set `hydration` in `weft.config.ts` to change the policy: `'lazy'` (the default), `'partial'` (every island loads with
657
+ the page) or `'full'` (every route hydrates whole).
658
+
659
+ ### CSS on a page
660
+
661
+ A built page carries the CSS its first paint needs in a `<style>`, found by matching the page's HTML against a table from
662
+ the build, and links the rest at the end of its shell. The links are at most two hashed files: one with what every route
663
+ shares, one with the rest of that route. Hover and focus rules aren't in the first paint, so they work once the late
664
+ sheet lands. Mark a region that must not show before then with `data-weft="styled"`: it keeps its room, stays hidden,
665
+ and shows anyway after 3 s.
666
+
667
+ `site.criticalCss: false` goes back to one sheet per chunk, inlined up to 48 KB. `site.inlineCss: false` keeps plain
668
+ links, as a strict CSP needs. `weft build` keeps old combined sheets for 30 days in `.weft/sheets`, so a page cached
669
+ before a deploy still finds its sheets: keep that folder between builds.
670
+
671
+ ## Navigation, view transitions and speculation rules
672
+
673
+ Links are `<Link href>`, or any same-origin `<a href>`: once the client has started, clicks on either navigate without a
674
+ page load. Code navigates with `navigate(href, { replace?, scroll? })`. `<Link match="section">` sets
675
+ `aria-current="page"` on any page under its `href`, for a site's top links.
676
+
677
+ **View transitions.** A client navigation swaps the page inside `document.startViewTransition` when the browser has it,
678
+ the tab is visible and has painted, and the visitor hasn't asked for reduced motion. On a device with four cores or
679
+ fewer it skips the fade, and it stops fading after one transition takes over 100 ms to start. Style it with the usual
680
+ `::view-transition-*` pseudo-elements.
681
+
682
+ **Prefetch on intent.** Hovering a link (after 80 ms, on a 4G connection) or focusing it fetches that route's data, not
683
+ its code, within a byte budget per connection type (64 KB per response on 4G, 16 KB otherwise, 8 KB on 2G). Nothing is
684
+ prefetched offline or with Save-Data. Put `data-preload="off"` on a link or a container to opt out.
685
+
686
+ **Speculation rules.** A page that runs no client code until it's needed follows its links with full page loads. So the
687
+ server adds a `<script type="speculationrules">` that asks the browser to prefetch the link the pointer rests on
688
+ (`eagerness: 'moderate'`). It is a prefetch, not a prerender, which would run the next page's code unseen. It leaves out
689
+ island links, `/_weft/*`, downloads and anything under `data-preload="off"`, and the router removes the rules when it
690
+ takes over, so nothing is fetched twice.
691
+
692
+ ## Head, SEO and share cards
693
+
694
+ Every page gets a title, a description, a canonical URL, Open Graph and Twitter tags and a share card in the first
695
+ response, where a crawler can read them. Most of it is automatic; the words are yours.
696
+
697
+ ```tsx
698
+ import type { Meta } from '@weft-js/start'
699
+
700
+ export const meta: Meta = {
701
+ title: 'Pricing',
702
+ description:
703
+ 'Three plans, billed monthly, and what each one includes.',
704
+ }
705
+ ```
706
+
707
+ `meta` is plain data, computed once per request after the loaders and before the first byte. It can't read a box (H006)
708
+ or a deferred field (H007). It also takes `title: { absolute: 'Home' }` to skip the site template, `og`, `twitter`,
709
+ `extra` for other tags, and `jsonLd` for structured data. Layers merge in this order, later winning: `site` in
710
+ `weft.config.ts`, what the page rendered, each layout's `meta`, the route's `meta`.
711
+
712
+ With no `meta`, or a field left out:
713
+
714
+ - **Title**: the page's `h1` (or its first `h2`/`h3`), through `site.title`'s `%s` template.
715
+ - **Description**: the first prose paragraph after the `h1` inside `<main>`, cut at a sentence by 155 characters.
716
+ - **Share card**: a 1200×630 PNG of the title, description, app name and address. `routes/x.og.tsx` replaces one route's
717
+ card and `_default.og.tsx` every card; `og: { image: '/x.png' }` or `image: false` skips it. The card URL holds a
718
+ hash of what it draws, so it is cached forever and changes when the page does.
719
+ - **Canonical and `og:url`** from `site.url`, `WEFT_SITE_URL`, or the host's own variable (Vercel, Netlify, Cloudflare
720
+ Pages, Render, Railway, Fly). With none, the server uses the request's origin and warns once in production (H001).
721
+
722
+ `weft check` reports metadata for every route from what the routes really produce (H001–H016), and
723
+ `weft routes --meta` prints a table of each page's tags and where each came from. Full details:
724
+ [`weft docs meta`](https://github.com/Ravonus/weft/blob/main/packages/cli/docs/meta.md).
725
+
726
+ ## Database
727
+
728
+ `ctx.db` is a Drizzle client, async on every driver. `@weft-js/start` ships three, each a `createDb` that connects on
729
+ the first statement, so `weft build` and a page with no queries need no database:
730
+
731
+ | Import | Driver | Options |
732
+ | ------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------- |
733
+ | `@weft-js/start/db/sqlite` | `node:sqlite` on Node and Deno, `bun:sqlite` on Bun; no native binary | `schema`, `url` (default `file:.data/app.db`, or `:memory:`), `migrate` |
734
+ | `@weft-js/start/db/postgres` | postgres.js | `schema`, `url`, `migrate`, `max` (pool, default 10), `prepare` (default `true`; off behind a pooler in transaction mode) |
735
+ | `@weft-js/start/db/mysql` | mysql2 | `schema`, `url`, `migrate`, `max` (pool, default 10) |
736
+
737
+ `migrate` defaults to `true`: pending migrations apply before the first statement a request runs. Postgres and MySQL hold
738
+ a lock while they do, so instances starting together apply each file once.
739
+
740
+ ```ts
741
+ // src/db/index.server.ts
742
+ import { createDb } from '@weft-js/start/db/postgres'
743
+ import { env } from '../env.server'
744
+ import * as schema from './schema'
745
+
746
+ export const db = createDb({ schema, url: env.DATABASE_URL })
747
+ ```
748
+
749
+ ```ts
750
+ // src/context.server.ts
751
+ import { db } from '#/db/index.server'
752
+
753
+ export default () => ({ db })
754
+
755
+ declare module '@weft-js/start' {
756
+ interface Context {
757
+ db: typeof db
758
+ }
759
+ }
760
+ ```
761
+
762
+ ### Migrations
40
763
 
41
- ## What you get
764
+ Change `src/db/schema.ts`, run `weft db generate` (Q015 warns until you do), reload. The new migration in `drizzle/`
765
+ applies before the next statement. There is no `weft db migrate`. Never edit `drizzle/` by hand.
42
766
 
43
- - **Routes from files.** `src/routes/shop/[id].tsx` is `/shop/:id`, with `_layout.tsx` for layouts, `load` for a page's
44
- data, `notFound()` and `redirect()`, and `meta` for titles, descriptions and share cards.
45
- - **Server functions.** `query` and `mutation` validate their input with a schema, run with `ctx` (the request, the
46
- user, the database) and come back typed. Mutations name the tags they invalidate, and `optimistic` updates the page
47
- before the server answers. Forms post to them without JavaScript.
48
- - **Streaming SSR and hydration**, with `<Boundary>` sending each part when its data is ready.
49
- - **A database.** `ctx.db` over Drizzle, Prisma or plain SQL (`@weft-js/db`) on SQLite, Postgres or MySQL. `weft db generate`
50
- writes the migration, which applies on the next request.
51
- - **Auth** through Better Auth or your own, with `ctx.user` in every server function.
52
- - **Live data.** A page that reads a table updates when another user changes it, from the database's change feed.
53
- - **Any runtime.** `weft build` targets Node, Bun or Deno, or a host preset (Netlify, Vercel, Cloudflare, Fly, Render).
767
+ ```bash
768
+ # Drizzle-kit generate, then records the schema hash
769
+ npx weft db generate add_due_dates
770
+ # A migration with change triggers for realtime
771
+ npx weft db live
772
+ # The change feed as its own process
773
+ npx weft db listen
774
+ ```
54
775
 
55
- ## Docs
776
+ A request that hits a missing table gets Q014, which names it. If the database is down, a request that uses it answers
777
+ 503 with Q016, pages with no queries still serve, and the next statement tries again once it is back, with no restart.
56
778
 
57
- - `weft docs start`: routes, `load`, `query`, `mutation`, the database, auth, runtimes and realtime.
58
- - `weft docs meta`: page metadata and share cards.
59
- - `weft docs rust`: server functions with a Rust body, if you want them.
779
+ ### Validating input from tables
60
780
 
61
- The source of each page is in [packages/cli/docs](../cli/docs).
781
+ `schemasOf(schema, refine?)` from `@weft-js/start/db` gives a Zod `select`, `insert` and `update` schema per table
782
+ (through drizzle-zod), so a table is its own validator:
783
+
784
+ ```ts
785
+ const schemas = schemasOf(schema, {
786
+ todos: { title: (s: z.ZodString) => s.trim().min(1) },
787
+ })
788
+ export const addTodo = mutation({
789
+ input: schemas.todos.insert.pick({ title: true }) /* … */,
790
+ })
791
+ ```
792
+
793
+ ### Other data layers
794
+
795
+ Prisma, or plain SQL with no ORM, come from [`@weft-js/db`](https://www.npmjs.com/package/@weft-js/db) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/db)</sup>:
796
+
797
+ ```bash
798
+ npx weft add db postgres --layer native # ctx.db.sql`select …`
799
+ # Ctx.db is your Prisma client
800
+ npx weft add db sqlite --layer prisma
801
+ ```
802
+
803
+ Paged, filtered and searchable lists over any of them (`listInput` and a list source) are covered in the `@weft-js/db`
804
+ README. A Drizzle table's source is `drizzleSource({ db, table }, spec)` from `@weft-js/start/list/drizzle`.
805
+
806
+ ## Auth
807
+
808
+ Auth is a provider in `src/auth.server.ts` with two jobs: `handler(request)` serves everything under `/api/auth`, and
809
+ `session(request)` says who a request is. `ctx.user`, `ctx.session` and `auth: 'user'` come from those.
810
+
811
+ ### Better Auth (the default)
812
+
813
+ ```ts
814
+ // src/auth.server.ts
815
+ import { createAuth } from '@weft-js/start/auth/better-auth'
816
+ import { drizzleAuth } from '@weft-js/start/auth/drizzle'
817
+ import { db } from '#/db/index.server'
818
+ import * as schema from './db/schema'
819
+ import { env } from './env.server'
820
+
821
+ export default createAuth({
822
+ db,
823
+ secret: env.BETTER_AUTH_SECRET,
824
+ options: { database: drizzleAuth(db, schema) },
825
+ })
826
+ ```
827
+
828
+ ```ts
829
+ // src/context.server.ts
830
+ import { session } from '@weft-js/start/auth'
831
+ import auth from '#/auth.server'
832
+ import { db } from '#/db/index.server'
833
+
834
+ export default async ({ request }: { request: Request }) => ({
835
+ db,
836
+ ...(await session(auth, request)),
837
+ })
838
+
839
+ type Session = Awaited<ReturnType<typeof auth.session>>
840
+
841
+ declare module '@weft-js/start' {
842
+ interface Context {
843
+ db: typeof db
844
+ user: Session['user']
845
+ session: Session['session']
846
+ }
847
+ }
848
+ ```
849
+
850
+ `createAuth` takes `db`, `secret` (32+ characters; A001 in production), `options` (any Better Auth option, which wins
851
+ over Weft's defaults) and `api`. `options.database` is required (A005): `drizzleAuth(db, schema)`, `nativeAuth(db)` from
852
+ `@weft-js/db/plugin-native/auth`, or Better Auth's Prisma adapter. `weft add auth` sets all of this up.
853
+
854
+ Sign-in is a mutation. `forward` puts the provider's cookies on the response and turns its failure into an `FnError`:
855
+
856
+ ```ts
857
+ // src/server/session.ts
858
+ import { mutation, redirect } from '@weft-js/start'
859
+ import { forward } from '@weft-js/start/auth'
860
+ import { z } from 'zod'
861
+ import auth from '#/auth.server'
862
+
863
+ export const signIn = mutation({
864
+ input: z.object({ email: z.email(), password: z.string().min(8) }),
865
+ run: async ({ input, ctx }) => {
866
+ await forward(
867
+ ctx,
868
+ auth.api.signInEmail({
869
+ body: input,
870
+ headers: ctx.request.headers,
871
+ asResponse: true,
872
+ }),
873
+ )
874
+ // A new session needs a full load (A004 without one)
875
+ redirect('/todos')
876
+ },
877
+ })
878
+ ```
879
+
880
+ > [!WARNING]
881
+ > **`auth: 'user'` says who is calling, not what they may touch.** A `run` that takes a row's id must check that row
882
+ > belongs to `ctx.user`, usually with `and(eq(todos.id, input.id), eq(todos.userId, ctx.user.id))` in the `where`.
883
+
884
+ **Sessions are opaque tokens in the database, not JWTs.** The HTTP-only cookie holds a random token, so deleting the
885
+ row revokes the session. `createAuth` turns on Better Auth's cookie cache, which spares pages and queries their session
886
+ reads for **5 minutes**. A revoked session can still see pages for up to that long, but it can't write: mutations,
887
+ realtime connections and Better Auth's own routes always read the database. Shorten it with
888
+ `options: { session: { cookieCache: { maxAge: 60 } } }`, or turn it off with `{ cookieCache: { enabled: false } }`.
889
+
890
+ **API access.** `GET /api/auth/token` gives a signed-in user a JWT good for 15 minutes, and `GET /api/auth/jwks` is
891
+ public, so another service verifies it with no database. A function takes a bearer token only if you list it:
892
+
893
+ ```ts
894
+ createAuth({
895
+ db,
896
+ secret,
897
+ options,
898
+ api: {
899
+ functions: () => [orders, shipOrder],
900
+ jwt: {
901
+ audience: 'https://api.example.com',
902
+ expirationTime: '15m',
903
+ },
904
+ },
905
+ })
906
+ ```
907
+
908
+ ```text
909
+ GET /_weft/fn/<id>?i=<json>
910
+ authorization: Bearer <token>
911
+
912
+ POST /_weft/fn/<id>
913
+ authorization: Bearer <token>
914
+ content-type: application/json
915
+ x-weft: 1
916
+ body: {"i": input}
917
+ ```
918
+
919
+ With no `BETTER_AUTH_URL` or fixed `baseURL`, no token is accepted, since nothing could check `iss` and `aud`.
920
+ `api: false` removes all of it.
921
+
922
+ ### Any other provider
923
+
924
+ ```ts
925
+ // src/auth.server.ts
926
+ import { defineAuth } from '@weft-js/start/auth'
927
+
928
+ export default defineAuth<{ id: string }, { id: string }>({
929
+ async session(request) {
930
+ return { user: null, session: null } // who this request is
931
+ },
932
+ async handler(request) {
933
+ // Everything under /api/auth
934
+ return new Response('Not found', { status: 404 })
935
+ },
936
+ })
937
+ ```
938
+
939
+ A provider that caches sessions may answer a GET from the cache, and must ask its store for any other method. Set
940
+ `basePath` to mount `handler` somewhere other than `/api/auth`.
941
+
942
+ ## Realtime
943
+
944
+ Don't poll, and don't open a socket yourself (R007, T013). A live query, a `stream` and a `channel` reconnect, resume
945
+ and clean up for you.
946
+
947
+ ### Live queries
948
+
949
+ Live queries need no server code. Tags are the contract: any mutation that invalidates one of a query's tags, on any
950
+ tab, refetches it for every reader. Unchanged rows keep their identity, so the page writes only what changed.
951
+
952
+ ```tsx
953
+ const todos = listTodos.live()
954
+ todos.value // the rows, as with listTodos()
955
+ // 'connecting' | 'live' | 'degraded' | 'reconnecting' | 'offline'
956
+ todos.live.value
957
+ ```
62
958
 
63
- ## End-to-end tests
959
+ A live query needs `tags` (W053), and its mutations need one of them in `invalidates` (R003 in dev).
960
+
961
+ ### Writes from outside the app
962
+
963
+ Give the database's writes to `realtime` and a cron job, `psql` or another service reaches the same queries:
964
+
965
+ ```ts
966
+ // src/realtime.server.ts
967
+ import { realtime } from '@weft-js/start'
968
+ import { dbChanges } from '@weft-js/start/changes'
969
+ import { db } from '#/db/index.server'
970
+ import { notes } from '#/db/schema'
971
+
972
+ export default realtime({ changes: dbChanges(db, [notes]) })
973
+ ```
974
+
975
+ Then `weft db live` writes the trigger migration and it applies on the next request. A notification carries a table and
976
+ primary keys, never a column: each client refetches through its own `query`, so `auth` and every filter still apply.
977
+
978
+ | Database | How it hears changes | Limits |
979
+ | -------- | ------------------------------------------------------------ | ----------------------------------------------------------------------- |
980
+ | Postgres | triggers and NOTIFY on one held connection | a transaction-mode pooler breaks LISTEN: pass `{ listenUrl }` for a direct connection |
981
+ | SQLite | `PRAGMA data_version` every 100 ms, then the triggers' log | same host only |
982
+ | MySQL | the triggers' log, read every 500 ms | a row in the log per row written |
983
+ | MongoDB | a change stream (`mongoChanges` from `@weft-js/db/mongo`) | needs a replica set |
984
+
985
+ `notes.liveMode.value` says what the server settled on (`'feed'`, `'external'`, `'poll'`, `'app'` or `'off'`), and
986
+ `weft doctor --preset vercel` says what a target can do before you deploy. `live: { required: true }` on a query fails
987
+ `weft check` and `weft build` (R013) on a target that can't deliver it, and `liveHealth()` reports it for a health route.
988
+
989
+ ### Streams and topics
990
+
991
+ ```ts
992
+ // src/server/prices.ts
993
+ import { stream, topic } from '@weft-js/start'
994
+ import { z } from 'zod'
995
+
996
+ const Price = z.object({ symbol: z.string(), price: z.number() })
997
+
998
+ export const priceTopic = topic('prices', {
999
+ key: z.string().max(12),
1000
+ message: Price,
1001
+ })
1002
+
1003
+ export const prices = stream({
1004
+ // Bound the input (R005)
1005
+ input: z.object({ symbols: z.array(z.string().max(12)).max(50) }),
1006
+ message: Price,
1007
+ auth: 'user', // re-checked every 5 minutes
1008
+ async *run({ input, signal, lastEventId }) {
1009
+ yield* priceTopic.subscribe(input.symbols, {
1010
+ signal,
1011
+ after: lastEventId,
1012
+ })
1013
+ },
1014
+ })
1015
+ // anywhere on the server: await priceTopic.publish('AAPL', { symbol:
1016
+ // 'AAPL', price: 1 })
1017
+ ```
1018
+
1019
+ ```tsx
1020
+ // The latest message: tick.value
1021
+ const tick = prices({ symbols: ['AAPL'] })
1022
+ const board = prices(() => ({ symbols: syms.value }), {
1023
+ fold: (acc, m) => ({ ...acc, [m.symbol]: m }),
1024
+ initial: {},
1025
+ keep: '30s',
1026
+ })
1027
+ ```
1028
+
1029
+ ### Channels and shared state
1030
+
1031
+ `channel` is a WebSocket with named, validated messages each way, for what the client sends often (chat, typing,
1032
+ cursors). `authorize` is required (R001) and runs on every join and every 5 minutes.
1033
+
1034
+ ```ts
1035
+ // src/server/chat.ts
1036
+ import { channel } from '@weft-js/start'
1037
+ import { z } from 'zod'
1038
+ import { insertMessage, isMember, Message } from '#/server/messages'
1039
+
1040
+ export const room = channel({
1041
+ params: z.object({ roomId: z.string().max(64) }),
1042
+ authorize: ({ ctx, params }) => isMember(ctx.user, params.roomId),
1043
+ client: { say: z.object({ text: z.string().min(1).max(2000) }) },
1044
+ server: { said: Message },
1045
+ on: {
1046
+ say: async ({ msg, ctx, params, room }) => {
1047
+ const row = await insertMessage(ctx, params.roomId, msg.text)
1048
+ await room.publish('said', row)
1049
+ },
1050
+ },
1051
+ limits: { perSecond: 10 }, // default 20 a second per socket
1052
+ })
1053
+ ```
1054
+
1055
+ ```tsx
1056
+ const chat = room.connect(() => ({ roomId: props.roomId }))
1057
+ const said = chat.collect('said', { max: 200, key: (m) => m.id })
1058
+ await chat.send('say', { text }) // resolves on the server's ack
1059
+ // 'connecting' | 'open' | 'reconnecting' | 'closed' | 'unauthorized'
1060
+ chat.status.value
1061
+ ```
1062
+
1063
+ A channel is a wire, not a log: a reconnect replays the last 60 s or 256 messages per instance, so what must last goes
1064
+ in the database. `sharedState(name, { key, value, initial, authorize: { read, write } })` is one last-writer-wins value
1065
+ per key, held in server memory.
1066
+
1067
+ ### More than one instance
1068
+
1069
+ One instance needs nothing. Several need a bus, and `weft build` refuses the in-memory bus on a multi-instance preset
1070
+ (R002):
1071
+
1072
+ ```ts
1073
+ // src/realtime.server.ts
1074
+ import { realtime } from '@weft-js/start'
1075
+ import { postgresBus } from '@weft-js/start/bus/postgres'
1076
+
1077
+ export default realtime({
1078
+ bus: postgresBus({ url: process.env.DATABASE_URL! }),
1079
+ // Exact; the app's own origin is always allowed
1080
+ origins: ['https://other.example'],
1081
+ // The platform's response limit: streams end 10 s earlier and
1082
+ // resume
1083
+ maxDuration: 300,
1084
+ })
1085
+ ```
1086
+
1087
+ On function hosts (Vercel, Netlify, Lambda, Azure Functions, Firebase, Cloudflare Workers, Deno Deploy) a process ends
1088
+ with its request, so `.live()` shows `degraded` for outside writes (R014). Deploy to a server preset, or run
1089
+ `weft db listen` on any always-on host with a bus both can reach and `dbChanges(db, tables, { listener: 'external' })`
1090
+ in the app.
1091
+
1092
+ The full realtime reference, including `verify` round trips against staging and the test helpers in
1093
+ `@weft-js/core/test`, is [`weft docs start`](https://github.com/Ravonus/weft/blob/main/packages/cli/docs/start.md).
1094
+
1095
+ ## Configuration
1096
+
1097
+ Start reads these keys from `weft.config.ts` (or `.json` / `.yaml`). `weft check` validates them and writes
1098
+ `.weft/config.schema.json` for editor completion.
1099
+
1100
+ ```ts
1101
+ // weft.config.ts
1102
+ import { defineConfig } from '@weft-js/core/config'
1103
+
1104
+ export default defineConfig({
1105
+ site: {
1106
+ name: 'Acme',
1107
+ url: 'https://acme.dev',
1108
+ title: '%s · Acme',
1109
+ description: 'Tools for builders.',
1110
+ headers: { '/admin/**': { 'x-frame-options': 'DENY' } },
1111
+ },
1112
+ runtime: 'node',
1113
+ hydration: 'lazy',
1114
+ deploy: {
1115
+ target: 'fly',
1116
+ fly: {
1117
+ app: 'acme',
1118
+ region: 'iad',
1119
+ volume: { name: 'data', sizeGb: 1 },
1120
+ },
1121
+ },
1122
+ live: {
1123
+ targets: {
1124
+ staging: {
1125
+ url: 'https://staging.acme.dev',
1126
+ db: 'STAGING_DATABASE_URL',
1127
+ production: false,
1128
+ },
1129
+ },
1130
+ },
1131
+ })
1132
+ ```
1133
+
1134
+ | Key | Values | Default | What it does |
1135
+ | -------------------- | ---------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------- |
1136
+ | `site` | object | | Metadata defaults: `name`, `url`, `title` (with `%s`), `description`, `locale`, `image`, `twitter`, `card` |
1137
+ | `site.headers` | `{ [pattern]: headers }` | | Response headers by route pattern, static files included |
1138
+ | `site.inlineCss` | `boolean` | `true` | First-paint CSS in a `<style>`; `false` for plain links (strict CSP) |
1139
+ | `site.criticalCss` | `boolean` | `true` | One hashed sheet plus first-paint CSS; `false` splits by chunk and inlines up to 48 KB |
1140
+ | `site.criticalKeep` | `string[]` | | Classes a head script sets, kept in the first paint |
1141
+ | `site.head` | `string` | | Raw HTML before the stylesheets on every page |
1142
+ | `runtime` | `'node'` \| `'bun'` \| `'deno'` | the one running the CLI | What `weft build` targets |
1143
+ | `hydration` | `'lazy'` \| `'partial'` \| `'full'` | `'lazy'` | When islands load, or hydrate every route whole |
1144
+ | `deploy` | `{ target, <host>, ci, branch, cdn }` | | The host `weft build` and `weft deploy` target |
1145
+ | `live.targets` | `{ [name]: { url, db, production } }` | | Where a live query's `verify` runs its round trip |
1146
+ | `rust` | `{ unions, services }` | | Server functions with a Rust body (`weft docs rust`) |
1147
+
1148
+ The full key list is [`weft docs config`](https://github.com/Ravonus/weft/blob/main/packages/cli/docs/config.md).
1149
+
1150
+ ## Runtimes, deploy targets and adapters
1151
+
1152
+ `weft build` runs Vite with Start's plugin and Nitro, and writes `.output/` and `.weft/contract.json`. The Nitro preset
1153
+ comes from, in order: `--preset`, `--runtime`, `deploy.target`, `runtime` in the config, then the runtime running the
1154
+ CLI.
1155
+
1156
+ | Runtime | Nitro preset | Run it with |
1157
+ | ------- | -------------- | ------------------------------ |
1158
+ | `node` | `node` | `weft start` |
1159
+ | `bun` | `bun` | `weft start --runtime bun` |
1160
+ | `deno` | `deno_server` | `weft start --runtime deno` |
1161
+
1162
+ The CLI itself runs on whichever runtime starts it (`bun --bun`, or `deno run -A npm:@weft-js/cli`). Server code that
1163
+ uses `node:` modules and web APIs works on all three; `Bun.*`, `Deno.*` or `better-sqlite3` break the others. Built
1164
+ assets ship with `.br` and `.gz` copies, and the server picks by `Accept-Encoding`.
1165
+
1166
+ ### Hosts
1167
+
1168
+ `weft deploy` knows five hosts. It reads what the app uses and says which can run it, before a build:
1169
+
1170
+ | What the app uses | Netlify / Vercel | Cloudflare | Fly / Render |
1171
+ | ---------------------------------------------------- | ---------------- | --------------------- | ----------------------------- |
1172
+ | pages, queries, mutations, forms, `stream()` | yes | yes | yes |
1173
+ | `channel`, `sharedState` | no (E001) | yes, Durable Objects | yes |
1174
+ | `.live({ required })`, Rust `remote()` sidecars | no (E002) | no (E002) | yes |
1175
+ | a SQLite file, media uploads | no (E003) | no (E003) | with a volume or disk |
1176
+ | `changes` without an external listener | degraded (R014) | degraded | yes |
1177
+
1178
+ | Target | Preset | Files `weft deploy init` writes | Secrets it reads |
1179
+ | ------------ | -------------------- | ------------------------------------------- | -------------------------------------------------- |
1180
+ | `netlify` | `netlify` | `netlify.toml` | `NETLIFY_AUTH_TOKEN`, `NETLIFY_SITE_ID` |
1181
+ | `vercel` | `vercel` | `vercel.json` | `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID` |
1182
+ | `cloudflare` | `cloudflare_durable` | `wrangler.toml` | `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` |
1183
+ | `fly` | the runtime's server | `fly.toml`, `Dockerfile`, `.dockerignore` | `FLY_API_TOKEN` |
1184
+ | `render` | the runtime's server | `render.yaml`, `Dockerfile`, `.dockerignore` | `RENDER_DEPLOY_HOOK_URL` |
1185
+
1186
+ ```bash
1187
+ npx weft deploy hosts # fit, cost and a suggestion
1188
+ # Host files, a GitHub workflow and `deploy` in the config
1189
+ npx weft deploy init
1190
+ # Dry run: prints the build and commands --yes would run
1191
+ npx weft deploy
1192
+ npx weft deploy --yes # deploy a preview; add --prod for production
1193
+ ```
1194
+
1195
+ Every target also gets `.github/workflows/weft-deploy.yml`, which runs `weft check` then deploys on push. Tokens come
1196
+ from the environment only: one written into the config or a host file is E008. Any other Nitro preset works with
1197
+ `weft build --preset <name>`; `weft doctor --preset <name>` says what live data does there. Full details:
1198
+ [`weft docs deploy`](https://github.com/Ravonus/weft/blob/main/packages/cli/docs/deploy.md).
1199
+
1200
+ ## API reference
1201
+
1202
+ ### `@weft-js/start`
1203
+
1204
+ | Export | What it is |
1205
+ | -------------------------------------------------------------- | ------------------------------------------------------------------------------ |
1206
+ | `query(def)`, `mutation(def)` | Define server functions |
1207
+ | `stream(def)`, `topic(name, def)`, `tracked(id, value)` | Server-to-client message streams |
1208
+ | `channel(def)`, `sharedState(name, def)` | WebSocket rooms and shared per-key values |
1209
+ | `realtime(options)` | The default export of `src/realtime.server.ts`: bus, origins, limits, changes |
1210
+ | `dbChanges(db, tables, options?)` | A change feed from Postgres, SQLite or MySQL triggers |
1211
+ | `memoryBus()` | The one-process bus (the default) |
1212
+ | `liveHealth()`, `realtimeStats()` | Feed status for a health route; hub counters |
1213
+ | `redirect(href, status?)`, `notFound()` | Leave a `load` or `run` early |
1214
+ | `raise(code, data?)`, `invalid(fields)` | A declared error; a field error for `<Form>` |
1215
+ | `FnError`, `StartError`, `isRedirect(v)` | Error types, and a redirect check |
1216
+ | `invalidate(...tags)`, `cache`, `skip`, `stale` | Drop tags; the client cache; skip a tracked input; "a newer build is running" |
1217
+ | `listInput(spec)` | A query `input` for paged, sorted, filtered lists |
1218
+ | `Link`, `currentPath()`, `navigate(href, options?)` | Links with `aria-current`, the current path, and code navigation |
1219
+ | `Form` | A form that posts to a mutation, with or without JavaScript |
1220
+ | Types: `LoadArgs`, `RouteProps`, `Meta`, `OgProps`, `Prerender`, `ParamsOf`, `Context`, `Query`, `Mutation`, `LiveQuery`, `ListSource`, … | Typing routes, cards and functions |
1221
+
1222
+ <details>
1223
+ <summary>Every subpath</summary>
1224
+
1225
+ | Import | What it has |
1226
+ | ------------------------------------- | ------------------------------------------------------------------------------------- |
1227
+ | `@weft-js/start/db` | `schemasOf(schema, refine?)`, `getTableName`, and the `DbHandle` type |
1228
+ | `@weft-js/start/db/sqlite` | `createDb` on `node:sqlite` or `bun:sqlite` |
1229
+ | `@weft-js/start/db/postgres` | `createDb` on postgres.js |
1230
+ | `@weft-js/start/db/mysql` | `createDb` on mysql2 |
1231
+ | `@weft-js/start/db/core` | The migration gate and handle that data layers build on (used by `@weft-js/db`) |
1232
+ | `@weft-js/start/auth` | `defineAuth`, `session`, `forward`, and the `AuthProvider` type |
1233
+ | `@weft-js/start/auth/better-auth` | `createAuth`: Better Auth with Weft's defaults |
1234
+ | `@weft-js/start/auth/drizzle` | `drizzleAuth(db, schema?)`: Better Auth's store on a Drizzle client |
1235
+ | `@weft-js/start/list` | `listInput` and the list contract that sources implement |
1236
+ | `@weft-js/start/list/drizzle` | `drizzleSource({ db, table }, spec)` |
1237
+ | `@weft-js/start/changes` | `dbChanges` and the `Source` interface for your own change feed |
1238
+ | `@weft-js/start/bus/postgres` | `postgresBus({ url })`: LISTEN/NOTIFY as the realtime bus |
1239
+ | `@weft-js/start/bus` | The `Bus` interface and `memoryBus` |
1240
+ | `@weft-js/start/media` | `z`, Zod with `z.media()` for upload records `<Media>` takes |
1241
+ | `@weft-js/start/rust` | `remote()` and `native()`: server functions with a Rust body (`weft docs rust`) |
1242
+ | `@weft-js/start/extension` | `server.extension@1`: how a plugin adds a server route |
1243
+ | `@weft-js/start/og` | The share-card renderer |
1244
+ | `@weft-js/start/client` | `start()`: the client entry |
1245
+ | `@weft-js/start/server` | `createHandler()`: the request handler |
1246
+ | `@weft-js/start/vite` | `weftStart()`, the Vite plugin, and `scanRoutes` |
1247
+ | `@weft-js/start/app` | `dev()` and `build()`, as `weft dev` and `weft build` call them |
1248
+ | `@weft-js/start/data`, `/hub`, `/live`, `/channel`, `/share`, `/listen`, `/verify`, `/live-target`, `/changes-sql`, `/routes`, `/inspect`, `/contract`, `/derive`, `/sidecar` | Internals the CLI, the build and sibling packages use. Not a stable API |
1249
+
1250
+ </details>
1251
+
1252
+ ## Troubleshooting
1253
+
1254
+ `weft docs <code>` explains any diagnostic below, with its fix.
1255
+
1256
+ | Code / symptom | Cause | Fix |
1257
+ | ------------------------------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------- |
1258
+ | Q001 | `q()` called outside a component or `defineState` setup | `await q.fetch(input)` in `load`, handlers and server code |
1259
+ | Q006 | A query with `cache` read `ctx.user` | Add `cache: { scope: 'user' }`, or drop the shared cache |
1260
+ | Q011 | A function touched a table its `tags`/`invalidates` don't name | Add the table's name (or `table:…`) |
1261
+ | Q012 | `@weft-js/start/db*` or `/auth` reached the client | Import it only from server modules or `load` |
1262
+ | Q013 | A database URL the driver can't open | Use the matching `createDb` (`postgres://` needs `/db/postgres`) |
1263
+ | Q014 | A table or column is missing | `weft db generate`, then reload |
1264
+ | Q015 | `src/db/schema.ts` changed since the last generate | `weft db generate` |
1265
+ | Q016 (503) | The database is down, or a migration failed | Start it, check `DATABASE_URL`, or fix the migration; no restart needed |
1266
+ | A001 | The auth secret is missing or under 32 characters in production | Set `BETTER_AUTH_SECRET` |
1267
+ | A002 (401) | `auth: 'user'` called with no session | Sign in first, or guard the route in a layout's `load` |
1268
+ | A004 | A mutation changed the session cookie without a redirect | `redirect()` after sign-in or sign-out |
1269
+ | A005 | `createAuth` has no store | Pass `options.database` |
1270
+ | H007 (500) | `meta` or a card read a deferred `load` field | Await it in `load`, or leave it out of `meta` |
1271
+ | H009 | An island prop JSON can't carry | Pass plain data, or `/* @weft client */` on the parent |
1272
+ | R002 | The memory bus on a multi-instance preset | Set `bus` in `realtime()` |
1273
+ | R014, `.live` shows `degraded` | Outside writes on a function host | A server preset, or `weft db listen` with a shared bus |
1274
+ | W053 | `.live()` on a query with no `tags` | Add `tags` |
1275
+ | Sign-in answers `INVALID_ORIGIN` | `BETTER_AUTH_URL` doesn't match where the app runs (`weft dev --port`) | Update `BETTER_AUTH_URL` in `.env` |
1276
+ | `postgresBus` or `.live` goes quiet | pgBouncer in transaction mode breaks LISTEN | Give it a direct connection |
1277
+ | A native addon fails after deploy (E004) | Built on a Mac, deployed to Linux | Build where the host runs; the generated workflow builds on Linux |
1278
+
1279
+ ## Related packages
1280
+
1281
+ | Package | What it is |
1282
+ | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
1283
+ | [`@weft-js/core`](https://www.npmjs.com/package/@weft-js/core) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/core)</sup> | The reactive runtime, compiler binding, Vite plugin, SSR and test utilities Start is built on |
1284
+ | [`@weft-js/cli`](https://www.npmjs.com/package/@weft-js/cli) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/cli)</sup> | The `weft` command: `dev`, `build`, `check`, `db`, `deploy`, `docs` and the MCP server |
1285
+ | [`@weft-js/create`](https://www.npmjs.com/package/@weft-js/create) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/create)</sup> | `npm create @weft-js`: scaffolds an app with Start, a database and auth |
1286
+ | [`@weft-js/db`](https://www.npmjs.com/package/@weft-js/db) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/db)</sup> | Prisma and no-ORM data layers, and list sources for SQL, Kysely, Prisma, MongoDB, DynamoDB and Firestore |
1287
+ | [`@weft-js/warp`](https://www.npmjs.com/package/@weft-js/warp) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/warp)</sup> | The UI toolkit; its `Table` reads list sources |
1288
+ | [`@weft-js/realtime-redis`](https://www.npmjs.com/package/@weft-js/realtime-redis) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/realtime-redis)</sup>, [`-nats`](https://www.npmjs.com/package/@weft-js/realtime-nats), [`-durable-object`](https://www.npmjs.com/package/@weft-js/realtime-durable-object) | Realtime buses for Redis, NATS and Cloudflare Durable Objects |
1289
+
1290
+ ## Contributing
1291
+
1292
+ See [CONTRIBUTING.md](https://github.com/Ravonus/weft/blob/main/CONTRIBUTING.md). From the repository root:
1293
+
1294
+ ```bash
1295
+ pnpm install
1296
+ # Unit and integration tests (Vitest)
1297
+ pnpm --filter @weft-js/start test
1298
+ # The runtime matrix against a built app
1299
+ pnpm --filter @weft-js/start test:e2e <built-app>
1300
+ ```
64
1301
 
65
- From the repository, `pnpm --filter @weft-js/start test:e2e <built-app>` runs the runtime matrix.
66
- Pass `--e2e <path>` to select another scenario in `test/e2e`. The live database scripts in that
67
- folder run directly with Node and require the database environment shown in each script. They keep
68
- `.mjs` because Node and the runtime matrix load them without a test transform.
1302
+ `test:e2e` takes `--e2e <path>` to pick another scenario in `test/e2e`. The live database scripts in that folder run
1303
+ directly with Node and need the database environment named in each script. They stay `.mjs` because Node and the
1304
+ runtime matrix load them without a test transform.
69
1305
 
70
- ## Links
1306
+ ## License
71
1307
 
72
- - [Source](https://github.com/Ravonus/weft/tree/main/packages/start)
73
- - [Weft on GitHub](https://github.com/Ravonus/weft): the framework, the docs and every package
74
- - [Changelog](https://github.com/Ravonus/weft/blob/main/CHANGELOG.md)
75
- - [Issues](https://github.com/Ravonus/weft/issues)
1308
+ [MIT](https://github.com/Ravonus/weft/blob/main/LICENSE)