@weft-js/start 0.1.2 → 0.1.4
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.
- package/README.md +1266 -33
- package/crate/README.md +322 -0
- package/package.json +3 -3
- package/src/app.mjs +1 -1
- package/src/contract.mjs +1 -1
- package/src/inspect.mjs +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,111 @@
|
|
|
1
|
-
|
|
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
|
-
>
|
|
7
|
+
<h1 align="center">@weft-js/start</h1>
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
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 24 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
##
|
|
1306
|
+
## License
|
|
71
1307
|
|
|
72
|
-
|
|
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)
|