@weft-js/cli 0.0.0-stage → 0.1.1
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/LICENSE +16 -0
- package/README.md +74 -2
- package/bin/weft.mjs +8 -0
- package/docs/async.md +40 -0
- package/docs/browser.md +39 -0
- package/docs/config.md +93 -0
- package/docs/deploy.md +83 -0
- package/docs/design.md +97 -0
- package/docs/fonts.md +70 -0
- package/docs/icons.md +102 -0
- package/docs/lists.md +23 -0
- package/docs/meta.md +82 -0
- package/docs/plugins.md +143 -0
- package/docs/props.md +77 -0
- package/docs/rules.md +32 -0
- package/docs/rust.md +223 -0
- package/docs/start-short.md +8 -0
- package/docs/start.md +422 -0
- package/docs/state.md +96 -0
- package/docs/style.md +59 -0
- package/docs/surface.md +111 -0
- package/docs/testing.md +71 -0
- package/docs/ui-state.md +92 -0
- package/docs/verify.md +48 -0
- package/package.json +39 -4
- package/src/add-rust.mjs +123 -0
- package/src/add.mjs +358 -0
- package/src/agents-md.mjs +134 -0
- package/src/alias.mjs +59 -0
- package/src/audit.mjs +366 -0
- package/src/browser/cli.mjs +34 -0
- package/src/browser/client.mjs +132 -0
- package/src/browser/commands.mjs +143 -0
- package/src/catalog.mjs +496 -0
- package/src/check.mjs +330 -0
- package/src/cli.mjs +364 -0
- package/src/config.d.ts +1 -0
- package/src/config.mjs +5 -0
- package/src/contributions.mjs +69 -0
- package/src/db-layer.mjs +33 -0
- package/src/deploy/cdn.mjs +64 -0
- package/src/deploy/files.mjs +187 -0
- package/src/deploy/fit.mjs +62 -0
- package/src/deploy/hosts.mjs +56 -0
- package/src/deploy/index.mjs +273 -0
- package/src/deploy/needs.mjs +92 -0
- package/src/design-checks.mjs +439 -0
- package/src/diag.mjs +74 -0
- package/src/docs.mjs +132 -0
- package/src/doctor.mjs +30 -0
- package/src/files.mjs +47 -0
- package/src/fix.mjs +40 -0
- package/src/gate.mjs +276 -0
- package/src/index.mjs +9 -0
- package/src/infer/engine.cjs +525 -0
- package/src/infer/facts.cjs +252 -0
- package/src/infer/hosts.cjs +114 -0
- package/src/infer/lsp.mjs +131 -0
- package/src/infer/session.cjs +105 -0
- package/src/infer/shadow.cjs +46 -0
- package/src/infer/worker.cjs +97 -0
- package/src/jsx-scan.mjs +86 -0
- package/src/load-config.mjs +303 -0
- package/src/map.mjs +110 -0
- package/src/mcp.mjs +195 -0
- package/src/meta-table.mjs +28 -0
- package/src/mutate.mjs +140 -0
- package/src/plugin-checks.mjs +89 -0
- package/src/plugins.mjs +172 -0
- package/src/project.mjs +219 -0
- package/src/props-checks.mjs +46 -0
- package/src/props-listener.mjs +38 -0
- package/src/react-checks.mjs +36 -0
- package/src/share-checks.mjs +74 -0
- package/src/site.mjs +19 -0
- package/src/slots.mjs +96 -0
- package/src/start.mjs +296 -0
- package/src/state-checks.mjs +213 -0
- package/src/style.mjs +320 -0
- package/src/surface-checks.mjs +218 -0
- package/src/tailwind.mjs +22 -0
- package/src/tasks.mjs +99 -0
- package/src/testdiff.mjs +157 -0
- package/src/tests.mjs +572 -0
- package/src/text-checks.mjs +172 -0
- package/src/tokens.mjs +24 -0
- package/src/trust.mjs +282 -0
- package/src/weft.mjs +66 -0
- package/src/workspace.mjs +105 -0
- package/ts-plugin/index.js +137 -0
- package/ts-plugin/package.json +1 -0
package/docs/lists.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Lists and conditionals
|
|
2
|
+
|
|
3
|
+
- `xs.map(x => <Row key={x.id} … />)` in JSX is a keyed list. Each key keeps its row; a new object under the same
|
|
4
|
+
key updates the row in place.
|
|
5
|
+
- Over objects, a missing `key` is **W010** (autofix to `key={x.id}` when items have `id`). Over primitives, the
|
|
6
|
+
value is the key.
|
|
7
|
+
- `xs.filter(…).sort(…).map(…)` works; put the chain in a `derived` when other code needs it too.
|
|
8
|
+
- No early `return` inside the `.map` callback (**W013**). Filter first.
|
|
9
|
+
- `cond && <X/>` with a number on the left renders `0` (**W011**). Write `n > 0 &&`.
|
|
10
|
+
- **Stable items (W014).** `xs.value = xs.value.map(p => ({ ...p, price }))` gives every item a new identity, so
|
|
11
|
+
every row re-runs on every change (1 DOM write, hundreds of re-runs). Replace only what changed:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
products.value = products.value.map(p => (p.id === id ? { ...p, price } : p))
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The compiler sees this inside one file; `weft check` follows the box through props into other files.
|
|
18
|
+
- **One entry per row (W017).** A row that reads `cart[p.id]` (a prop) or `cart.value[p.id]` depends on the whole
|
|
19
|
+
`cart`, so adding one item re-runs all 150 rows. Read the entry from the box: `cart.get(p.id)` re-runs only the row
|
|
20
|
+
whose entry changed (`Object.is`). For a prop, pass the box (`cart={cart}`, typed `State<Cart>`); the box form
|
|
21
|
+
has an autofix. The same holds one step further: passing `live={live.value[s.id]}` to a row component, or a row
|
|
22
|
+
component reading `live.value[stock.id]` (in JSX or `derived`), re-runs every row. Pass the box and read
|
|
23
|
+
`live.get(stock.id)` in the row.
|
package/docs/meta.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Page metadata and share cards
|
|
2
|
+
|
|
3
|
+
Every page has a title, a description, a canonical URL, Open Graph and Twitter tags and a share card, in the first
|
|
4
|
+
response, where a crawler can read them. Most of it is automatic. The words are yours.
|
|
5
|
+
|
|
6
|
+
## Set `meta` as you build the page
|
|
7
|
+
|
|
8
|
+
Writing a page includes writing what a search result or a shared link says about it. A title, and one plain sentence
|
|
9
|
+
on what the reader gets, from what is on the page:
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import type { Meta } from '@weft-js/start'
|
|
13
|
+
|
|
14
|
+
export const meta: Meta = { title: 'Pricing', description: 'Three plans, billed monthly, and what each one includes.' }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
On a param route, build it from `data` and `params`, so each page says what it is:
|
|
18
|
+
|
|
19
|
+
<!-- example: skip needs the app's own getUser query -->
|
|
20
|
+
```tsx
|
|
21
|
+
// src/routes/users/[id].tsx
|
|
22
|
+
import { notFound, type LoadArgs, type Meta } from '@weft-js/start'
|
|
23
|
+
import { getUser } from '#/server/users'
|
|
24
|
+
|
|
25
|
+
export const load = async ({ params }: LoadArgs<'/users/[id]'>) => (await getUser.fetch({ id: params.id })) ?? notFound()
|
|
26
|
+
export const meta: Meta<typeof load> = ({ data }) => ({ title: data.name, description: data.bio })
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`meta` is plain data: it runs once per request after the loaders and before the first byte. It can't read a box (H006)
|
|
30
|
+
or a deferred field (H007). Set `title: { absolute: 'Home' }` to skip the template, `og`, `twitter` and `extra` for
|
|
31
|
+
what else a page needs, and `jsonLd` for structured data. Layers merge in this order, later winning: `site` in
|
|
32
|
+
`weft.config.ts`, what the page rendered, each layout's `meta`, the route's `meta`.
|
|
33
|
+
|
|
34
|
+
## What weft does for you
|
|
35
|
+
|
|
36
|
+
With no `meta`, or a `meta` that leaves a field out:
|
|
37
|
+
|
|
38
|
+
- **Title:** the page's `h1`, or with none its first `h2` or `h3`, through `site.title`.
|
|
39
|
+
- **Description:** the first paragraph after the `h1` that reads as prose, inside `<main>`, with lists, code, tables,
|
|
40
|
+
navigation and footers skipped, cleaned, and cut at a sentence by 155 characters. With none, `site.description`.
|
|
41
|
+
- **Share card:** `site.image` defaults to `'auto'`: a 1200×630 PNG of the title, description, app name and address,
|
|
42
|
+
in warp's colours and the text plugin's faces. `site.card` sets them (`scheme: 'light'`, `accent: '#rrggbb'`).
|
|
43
|
+
`routes/x.og.tsx` replaces one route's card, `_default.og.tsx` every card; `og: { image: '/x.png' }` or `image: false`
|
|
44
|
+
skips it. The URL holds a hash of what it draws, so it's cached forever and changes when the page does.
|
|
45
|
+
- **`twitter:card`** is `summary_large_image` when there is an image. **Canonical and `og:url`** come from the origin.
|
|
46
|
+
- **Structured data:** a `WebSite` on `/` when `site.name` is set, and nothing else unless `jsonLd` sets it: weft
|
|
47
|
+
doesn't guess.
|
|
48
|
+
|
|
49
|
+
Pages with a card are drawn by `weft build` when they have no params or are in `prerender`. Every other param page's
|
|
50
|
+
card is drawn on the first request, and only for params the route's own `load` accepts. A static host has no server, so
|
|
51
|
+
there a param route needs `prerender` (H002).
|
|
52
|
+
|
|
53
|
+
## The site's address
|
|
54
|
+
|
|
55
|
+
Absolute URLs need one: `site.url`, or `WEFT_SITE_URL`, or what the host sets (`VERCEL_PROJECT_PRODUCTION_URL`,
|
|
56
|
+
`VERCEL_URL`, Netlify's `URL`, `CF_PAGES_URL`, `RENDER_EXTERNAL_URL`, `RAILWAY_PUBLIC_DOMAIN`, `FLY_APP_NAME`). With none, a
|
|
57
|
+
server uses the request's origin, which is only what its `Host` header said, and warns once in production (H001). A
|
|
58
|
+
prerendered page has no request, so it stays relative: crawlers don't resolve that, so set `site.url` before you ship.
|
|
59
|
+
|
|
60
|
+
## See it, and fix it
|
|
61
|
+
|
|
62
|
+
`weft check` reports metadata for every route, from what the routes really produce. Param routes are sampled from
|
|
63
|
+
`prerender` and from the links on rendered pages. Each diagnostic says what to write; H011's fix is a `meta` to start from.
|
|
64
|
+
|
|
65
|
+
| Code | What it says |
|
|
66
|
+
|---|---|
|
|
67
|
+
| H001 | no `site.url` |
|
|
68
|
+
| H002 | a param route's card, on a static host, with no `prerender` |
|
|
69
|
+
| H003, H004 | no description; a title over 60 or a description over 160 |
|
|
70
|
+
| H005, H008 | a card's style or image failed |
|
|
71
|
+
| H006, H007 | `meta` read a box or a deferred field |
|
|
72
|
+
| H011 | no `meta`, or a field left out: says what was derived |
|
|
73
|
+
| H012, H013 | two routes share a title or a description |
|
|
74
|
+
| H014 | a param route whose pages all look alike |
|
|
75
|
+
| H015 | a description that reads like code, markdown or a fragment |
|
|
76
|
+
| H016 | a route that couldn't be checked here (`check: { routes: false }` turns the pass off) |
|
|
77
|
+
|
|
78
|
+
`weft routes --meta` is a table of each page: title, description, image and where each came from (`route`, `layout`,
|
|
79
|
+
`config`, `auto`, `card`). `--json` has every tag. It's the `routes_meta` tool of `weft mcp`.
|
|
80
|
+
|
|
81
|
+
Never slice raw markdown or code into `description`: `body.slice(0, 155)` is how a page's card ends up reading
|
|
82
|
+
`- xs.map(x = <Row`. Write the sentence, or leave it out and let the page speak.
|
package/docs/plugins.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# How pay is built
|
|
2
|
+
|
|
3
|
+
A plugin is one pure manifest. `@weft-js/pay` is the worked example: payments, with a webhook, a ledger, a fake for tests, its
|
|
4
|
+
own diagnostics and `weft add` steps. It uses only what any plugin can use, and a test holds that to be true: `weft`,
|
|
5
|
+
`@weft-js/start` and the CLI don't name it. Read `packages/pay/plugin.mjs` next to this page. It's 50 lines.
|
|
6
|
+
|
|
7
|
+
## The shape
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
packages/pay/ packages/pay/ (`src/stripe.ts`)
|
|
11
|
+
plugin.mjs the manifest plugin.mjs a `pay.rail` provider
|
|
12
|
+
src/index.ts client-safe src/index.ts stripeRail(options)
|
|
13
|
+
src/server.ts the ledger
|
|
14
|
+
src/test.ts fakePay, payRailSuite
|
|
15
|
+
src/checks.mjs PAY-codes
|
|
16
|
+
src/setup.mjs `weft add pay`
|
|
17
|
+
docs/pay.md `weft docs pay`
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Two packages, because they answer different questions. `@weft-js/pay` is what payments means here, and it names no provider.
|
|
21
|
+
A rail is how a payment happens. `fakePay` is a second rail, which is how you know the seam is real.
|
|
22
|
+
|
|
23
|
+
## 1. Start from `definePlugin`
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
export const pay = definePlugin({
|
|
27
|
+
name: '@weft-js/pay', version: '0.1.0', weft: '*', namespace: 'PAY', permissions: {},
|
|
28
|
+
contribute: () => ({ provides: [...], diagnostics, check, docs, setup, agents, catalog, map }),
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`weft check` loads every plugin, so `plugin.mjs` stays pure and light: no I/O, nothing heavy imported. Heavy parts sit behind
|
|
33
|
+
`import()`. `namespace` is the prefix of your diagnostic codes, by default `<name>/`. `permissions` is what the plugin
|
|
34
|
+
reaches. Pay reaches nothing, so it's `{}`; the Stripe plugin declares `net: ['api.stripe.com']` and the two `env` keys it reads,
|
|
35
|
+
and `weft add` shows them for approval (`weft docs config` has the lock).
|
|
36
|
+
|
|
37
|
+
## 2. Reach the server with a slot
|
|
38
|
+
|
|
39
|
+
A slot is a typed seam: the id, a version, keyed or one, and the sides it runs on. Providers name a module, which weft
|
|
40
|
+
imports lazily, on that side only. That's why an app that doesn't use a plugin carries none of its bytes.
|
|
41
|
+
|
|
42
|
+
Pay provides `server.extension`, the slot for anything that needs a server presence:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
provide(defineSlot({ id: 'server.extension', version: 1, cardinality: 'keyed', sides: ['server'] }),
|
|
46
|
+
{ key: 'pay', sides: { server: { module: '@weft-js/pay/server', export: 'extension' } } })
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The key is the mount: `/_weft/x/pay/...`. A keyed provider also needs `src/<key>.server.ts`, the app's own configuration, and
|
|
50
|
+
weft passes that module's default export to your provider. So `src/pay.server.ts` is `definePay({...})`, and the provider is
|
|
51
|
+
one line: `extension = app => app`. A `ServerExtension` (`@weft-js/start/extension`) may have any of:
|
|
52
|
+
|
|
53
|
+
| Member | What it does | What pay uses it for |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `handle(request, path, api)` | Answers `/_weft/x/<key>/<path>` | Webhooks; `api.invalidate(tags)` pokes live queries |
|
|
56
|
+
| `context(ctx)` | Adds to `ctx`, once per request | `ctx.entitlements` |
|
|
57
|
+
| `guard(fn, ctx)` | Runs before every server function | `requires: ['pro']`, a typed 403 |
|
|
58
|
+
| `options` | The `FnOptions` keys it enforces; with no extension, a call using one is refused | `requires` |
|
|
59
|
+
| `personal` | `ctx` keys a shared cache can't read | `entitlements` (Q006) |
|
|
60
|
+
| `check(dev)` | Refuses a bad boot | The in-memory ledger in production |
|
|
61
|
+
|
|
62
|
+
Types follow: augment `Context` and `FnOptions` in `declare module '@weft-js/start'`, and `ctx.entitlements` and `requires`
|
|
63
|
+
are typed in any app that imports your server module. An error class with an `answer` getter reaches the client as a
|
|
64
|
+
typed error, with that code and status.
|
|
65
|
+
|
|
66
|
+
## 3. Define a slot of your own
|
|
67
|
+
|
|
68
|
+
Pay's `pay.rail` slot is why Stripe is one line in `plugins`. Say what a provider must implement, as a TypeScript interface
|
|
69
|
+
(`PayRail`), and ship a suite that holds any provider to it (`payRailSuite`). Your consumer reads the slot like any other:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const { load, providers } = await import('weft:slot/pay.rail')
|
|
73
|
+
const rail = await load('stripe')
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Another provider means another `provide(...)`, and app code doesn't change. A plugin's own slot is named `<plugin name>.<slot>`;
|
|
77
|
+
`pay.rail` keeps its short name only because it was in weft's slot table from the start. `@weft-js/pay/stripe` is a short manifest,
|
|
78
|
+
and a good template for a provider.
|
|
79
|
+
|
|
80
|
+
## 4. Report your own diagnostics
|
|
81
|
+
|
|
82
|
+
`diagnostics` declares each code: severity, what it catches, the fix. `check` finds them:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
check: { include: ['src/**/*.{ts,tsx}'], run: ctx => import('./src/checks.mjs').then(c => c.run(ctx)) }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`run(ctx)` gets `files`, `read`, `exists`, `jsx`, `slots`, and returns findings: `{ code, file, start, end, why, help?, fix? }`.
|
|
89
|
+
A `fix` is `{ description, safe, edits: [{ start, end, text }] }`; `weft fix` applies the safe ones. Weft does the spans,
|
|
90
|
+
`--json` and `weft docs PAY012`. A code outside your namespace is `P111`. A hook that throws is `P119` and nothing worse.
|
|
91
|
+
|
|
92
|
+
`src/checks.mjs` is the example to copy: a regex decides which files parse, TypeScript is imported only there, and the
|
|
93
|
+
checks that say the most are the ones a type can't: PAY013 (an entitlement read on the client that no server code names),
|
|
94
|
+
PAY011 (a webhook handler with no signature check). Test a hook as a plain function of a map of files
|
|
95
|
+
(`test/checks.test.mjs`).
|
|
96
|
+
|
|
97
|
+
## 5. Give it a fake
|
|
98
|
+
|
|
99
|
+
A plugin that talks to the outside world ships the fake with it. `fakePay` is a real `PayRail` in memory, and reaches `@weft-js/core/test`
|
|
100
|
+
only through what's public: `net.extension(pay)` puts your extension in the test's scope and resets it afterwards, `net.poke`
|
|
101
|
+
delivers tags to live queries, `clock` moves time. Events go through the app's real webhook and ledger, so a test
|
|
102
|
+
sees what a user sees. It's imported from `@weft-js/pay/test`, since core can't depend on a plugin.
|
|
103
|
+
|
|
104
|
+
## 6. Make `weft add` do the wiring
|
|
105
|
+
|
|
106
|
+
`setup` is data. `weft add pay` reads it after installing the package:
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
export const setup = {
|
|
110
|
+
with: ['@weft-js/pay/stripe'], // added too
|
|
111
|
+
needs: ['db', 'auth'], // refused, with nothing changed, until they're there
|
|
112
|
+
run({ engine, layer, read }) {
|
|
113
|
+
return { files: {...}, edits: {...}, env: { STRIPE_SECRET_KEY: 'sk_test_placeholder' }, next: ['...'] }
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`run` describes and never writes, so a dry run and a refusal are free. A file that already exists stops it before anything
|
|
119
|
+
changes. `env` goes to `.env` and `.env.example`: use placeholders.
|
|
120
|
+
|
|
121
|
+
## 7. Say what an agent needs
|
|
122
|
+
|
|
123
|
+
- `agents`: one paragraph for the generated `AGENTS.md`, 1 KB at most.
|
|
124
|
+
- `docs`: `{ topic, summary, file }`, served by `weft docs <topic>`.
|
|
125
|
+
- `catalog`: your exports, `{ name, import, summary }`, listed by `weft plugins --json`.
|
|
126
|
+
- `map`: "want this, call that" lines.
|
|
127
|
+
- `diagnostics`: each is a page: `weft docs PAY012`.
|
|
128
|
+
|
|
129
|
+
## Checklist
|
|
130
|
+
|
|
131
|
+
1. `plugin.mjs` is pure, with heavy code behind `import()`.
|
|
132
|
+
2. No app code names your provider; one line in `plugins` changes it.
|
|
133
|
+
3. Client entries hold no server code. `packed.mjs` checks that an app without you has none of your bytes.
|
|
134
|
+
4. A fake that goes through your real code path.
|
|
135
|
+
5. A suite for providers, passing on your fake and each real provider.
|
|
136
|
+
6. Every failure a type can't catch is a code with a fix.
|
|
137
|
+
7. If the API lacks something you need, add it for everyone. Pay added `server.extension`, `setup`, plugin `docs`, `net.extension`
|
|
138
|
+
and the `FnOptions` interface that way.
|
|
139
|
+
|
|
140
|
+
## Rust, and when not to
|
|
141
|
+
|
|
142
|
+
A plugin can carry its own wasm, with a TS fallback and nothing in client bytes. Measure first. `packages/pay/bench/verify.mjs`
|
|
143
|
+
did, for webhook verification, and pay stayed on WebCrypto, which was fast enough.
|
package/docs/props.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Props
|
|
2
|
+
|
|
3
|
+
## Types
|
|
4
|
+
|
|
5
|
+
Leave a component's props unannotated. weft infers them from its callers and its body, in the editor (the plugin in
|
|
6
|
+
`tsconfig.json`), `weft check`, `weft tsc` and `weft dev`. Nothing is left at run time.
|
|
7
|
+
|
|
8
|
+
```tsx
|
|
9
|
+
function Row(props) {
|
|
10
|
+
return <li><button type="button" onClick={props.onPick}>{props.label.toUpperCase()}</button></li>
|
|
11
|
+
}
|
|
12
|
+
export default function List() {
|
|
13
|
+
return <ul><Row label="One" onPick={() => console.log('picked one')} /></ul>
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Case | What you get |
|
|
18
|
+
|---|---|
|
|
19
|
+
| Several callers | A union if the body handles it; if not, **W084** at the odd caller |
|
|
20
|
+
| Some callers omit a prop | Optional; a default value also types it |
|
|
21
|
+
| `<Button {...props} />` | `Button`'s props |
|
|
22
|
+
| A component in a variable, map or prop | Its sites count, and prop sets merge |
|
|
23
|
+
| Callers that differ by a string prop | A union of shapes the body can narrow |
|
|
24
|
+
| `src/routes` default export | `RouteProps` |
|
|
25
|
+
| An export | `weft dev` or `weft fix` writes the type with an `/*inferred*/` comment, so it shows in a diff. **W082** when callers move on; delete the comment to keep the type |
|
|
26
|
+
| An `any` from a caller | The body's use, or `any`, with **W083** naming where it came from |
|
|
27
|
+
| No caller | **W080**, with what to do |
|
|
28
|
+
| `bind:x={box}` | The box's value, and it satisfies a required `x` under `weft tsc` and in the editor |
|
|
29
|
+
|
|
30
|
+
Write a type yourself when two props must agree (a type parameter), when the file is `.jsx`, or for a public API with no
|
|
31
|
+
caller in this repository. `Props<typeof Button>` names another component's props. `tsc` alone reports TS7006 on an
|
|
32
|
+
inferred parameter; run `weft tsc` in CI. `check: { props: false }` turns this off; `'manual'` leaves writing to `weft fix`.
|
|
33
|
+
|
|
34
|
+
## Reactivity
|
|
35
|
+
|
|
36
|
+
The compiler turns a component's props into live reads, so a parent writing `count={count.value}` is correct.
|
|
37
|
+
|
|
38
|
+
| Form | Behaviour |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `({ a, b }) =>` | Each name is a live read of `props.a` |
|
|
41
|
+
| `(props) =>` | `props.a` is a live getter |
|
|
42
|
+
| `({ size = 'md' }) =>` | Default applied on every read when the prop is `undefined` |
|
|
43
|
+
| `({ a, ...rest }) =>` | `rest` is a live object; `<div {...rest}>` stays live |
|
|
44
|
+
| `const x = props.y` in the body | `x` is another name for the prop: each use reads it live, at no cost |
|
|
45
|
+
| `const { a: b, c = 1, ...rest } = props` in the body | The same as in the parameter: renames, defaults and rest stay live |
|
|
46
|
+
| `const n = props.items.length` in the body | A path off a prop is live too |
|
|
47
|
+
| `({ user: { name } }) =>` | **W006**: nested destructuring can't stay live. Read `user.name` at the use site (autofix) |
|
|
48
|
+
|
|
49
|
+
- The local isn't a copy: every use, closures included, reads the prop as it is at that moment. For a value that must
|
|
50
|
+
stay as it was, write the snapshot out: `const first = untrack(() => props.x)`.
|
|
51
|
+
- Computing in the body runs once: `const n = items.length + 1`, `const a = props.a ?? 1` or `makeChart(items)` is
|
|
52
|
+
**W001**. Use `derived`, `watch`, JSX or a handler.
|
|
53
|
+
- Array patterns and computed keys (`const [a] = props.pair`, `const { [k]: a } = props`) are **W001**: read them at
|
|
54
|
+
the use site.
|
|
55
|
+
- `const` stays read-only: assigning to a `const` alias is **W001**, which says to use `let` or `props.x =`.
|
|
56
|
+
- `state(prop)` never follows the prop (**W005**). Use `state.linked(() => prop)` for an editable local copy.
|
|
57
|
+
- Pass a box (`value={coupon}`) only when the child must write it; otherwise pass `.value`.
|
|
58
|
+
|
|
59
|
+
## Writing a prop
|
|
60
|
+
|
|
61
|
+
A component can write its own props. `let` makes a local writable; `+=`, `++` and `let { a } = props` work too.
|
|
62
|
+
|
|
63
|
+
| In the child | Parent passed `n={x}` | Parent passed `bind:n={box}` |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| Read `n` or `props.n` | Live | Live |
|
|
66
|
+
| `let n = props.n; n = 5` | Holds in the child until the parent's value next changes, then the parent wins | Writes `box` |
|
|
67
|
+
| `props.n = 5` | The same | The same: writes `box` |
|
|
68
|
+
| `const n = props.n; n = 5` | **W001**: use `let` or `props.n =` | **W001** |
|
|
69
|
+
|
|
70
|
+
`bind:` at the call site alone decides whether a write leaves the component. `<Card n={box.value} />` never writes
|
|
71
|
+
`box`, whatever `Card` does. It takes a box, on a component (**W061** otherwise). `weft tsc` and the editor read
|
|
72
|
+
`bind:n={box}` as `n` with the box's value, so it satisfies a required prop and a wrong type is an error. Plain `tsc`
|
|
73
|
+
can't connect `bind:n` to `n` and reports the prop as missing: run `weft tsc`. `n={bind(box)}` is the same binding in
|
|
74
|
+
a form plain `tsc` accepts. An island's props arrive as data, so a write there is always local.
|
|
75
|
+
|
|
76
|
+
Only a whole prop can be written this way. `let n = props.user.name; n = 1` is **W001**; `props.user.name = 1` writes
|
|
77
|
+
that object, as in any JavaScript.
|
package/docs/rules.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
```tsx
|
|
2
|
+
import { derived } from '@weft-js/core'
|
|
3
|
+
|
|
4
|
+
export function Cart({ items, taxRate }: { items: { id: string; name: string; price: number }[]; taxRate: number }) {
|
|
5
|
+
const subtotal = derived(() => items.reduce((s, i) => s + i.price, 0))
|
|
6
|
+
const total = derived(() => subtotal.value * (1 + taxRate))
|
|
7
|
+
return (
|
|
8
|
+
<section>
|
|
9
|
+
<ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
|
|
10
|
+
<p>{total.value.toFixed(2)}</p>
|
|
11
|
+
</section>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
1. **State is a box. Read and write `.value`.** Everywhere, JSX included (W015).
|
|
17
|
+
2. **Anything computed from other values is `derived`.** Not in a `watch` or the component body (W001).
|
|
18
|
+
3. **JSX is always live.** Every `{expr}` and attribute re-evaluates when what it reads changes.
|
|
19
|
+
4. **Props are always current.** Destructure them (one level, W006). Read them in `derived`, `watch`, JSX or handlers.
|
|
20
|
+
`const x = props.y` is a live read-only alias. `let x = props.y` and `props.y =` write locally; only `bind:y={box}`
|
|
21
|
+
writes out.
|
|
22
|
+
5. **The component body runs once.** No early returns on state (W040), no `let` counters (W041). Branch in JSX.
|
|
23
|
+
6. **Pass a box only to give write access** (`value={coupon}`, child prop typed `State<string | null>`), or so list
|
|
24
|
+
rows can read one entry: `cart.get(p.id)` re-runs one row, `cart[p.id]` or `cart.value[p.id]` re-runs them all
|
|
25
|
+
(W017), in the list and in the row component alike.
|
|
26
|
+
7. **Lists and conditionals the way you know them**: `.map` with `key` (W010), `&&`, ternaries. Keep list items
|
|
27
|
+
stable: replace only the item that changed (`p.id === id ? { ...p, price } : p`), never `({ ...p })` every item
|
|
28
|
+
(W014).
|
|
29
|
+
8. **Copy a prop into state with `state.linked(() => prop)`**, not `state(prop)` (W005). `untrack(() => …)` is a
|
|
30
|
+
snapshot that never updates: not for anything you render (W018).
|
|
31
|
+
9. **Side effects go in `watch(() => source, value => cleanup?)`**; DOM work in `onMount`. `flush()` applies pending
|
|
32
|
+
DOM updates now.
|
package/docs/rust.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# Rust: `native()` and `remote()`
|
|
2
|
+
|
|
3
|
+
Rust is optional. weft needs no Rust toolchain to create, run, build or check an app, on any platform: the compiler ships as
|
|
4
|
+
WASM, and a prebuilt native addon installs by itself where one fits, which makes builds faster and changes no output.
|
|
5
|
+
`weft doctor` says which one is running. Rust is for this page: the code you choose to write in it, which is the only thing
|
|
6
|
+
that calls `cargo`.
|
|
7
|
+
|
|
8
|
+
A server function can have a Rust body. The client doesn't change: the same import, call, cache, tags and errors. Zod
|
|
9
|
+
stays the only schema. Reach for this at about 1 ms of CPU per call or on byte payloads (search, parsing, images), not for
|
|
10
|
+
IO-bound CRUD.
|
|
11
|
+
|
|
12
|
+
| | `native('media')` | `remote('rust://search')` |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Runs | in the server's process: an addon, or WASM where one can't load | a sidecar, over loopback or another host |
|
|
15
|
+
| Good for | one hot CPU function | many functions, IO, anything with its own state or runtime |
|
|
16
|
+
| Costs per call | ~10–30 µs | ~50–80 µs |
|
|
17
|
+
| A panic costs | that call | that call |
|
|
18
|
+
| A hang costs | a worker thread until it returns | nothing here: the sidecar is killable |
|
|
19
|
+
|
|
20
|
+
`weft add rust [name]` writes a working crate, its module and one function, with `native()`; `--remote` writes a sidecar.
|
|
21
|
+
The crate's code is the same `impl Service` either way, so moving between them is a line in `src/server/rust.ts` and the
|
|
22
|
+
glue at the bottom of the crate.
|
|
23
|
+
|
|
24
|
+
## Swap a `run` for `impl`
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// src/server/rust.ts: the only place Rust targets are named
|
|
28
|
+
import { remote } from '@weft-js/start/rust'
|
|
29
|
+
export const search = remote('rust://search')
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
<!-- example: skip imports src/server/rust.ts from the block above -->
|
|
33
|
+
```ts
|
|
34
|
+
// src/server/docs.ts
|
|
35
|
+
import { query } from '@weft-js/start'
|
|
36
|
+
import { z } from 'zod'
|
|
37
|
+
import { search } from './rust'
|
|
38
|
+
|
|
39
|
+
export const searchDocs = query({
|
|
40
|
+
input: z.object({ q: z.string().trim().min(1).max(200), limit: z.int().min(1).max(50).default(10) }),
|
|
41
|
+
output: z.array(z.object({ id: z.string(), title: z.string(), score: z.number() })),
|
|
42
|
+
tags: ['docs'],
|
|
43
|
+
impl: search, // was: run: ({ input }) => rank(input.q, input.limit)
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`output` is required on an `impl`. `crates/search` is the crate, unless config says otherwise. `weft add rust` writes all of
|
|
48
|
+
this; the `weft` path is inside `@weft-js/start`, because the crate isn't on crates.io yet:
|
|
49
|
+
|
|
50
|
+
```toml
|
|
51
|
+
# crates/search/Cargo.toml
|
|
52
|
+
[dependencies]
|
|
53
|
+
serde = { version = "1", features = ["derive"] }
|
|
54
|
+
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
|
55
|
+
weft = { path = "../../node_modules/@weft-js/start/crate" }
|
|
56
|
+
|
|
57
|
+
[build-dependencies]
|
|
58
|
+
weft = { path = "../../node_modules/@weft-js/start/crate", default-features = false, features = ["build"] }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```rust
|
|
62
|
+
// crates/search/build.rs
|
|
63
|
+
fn main() { weft::build::generate("search"); }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```rust
|
|
67
|
+
// crates/search/src/main.rs
|
|
68
|
+
weft::contract!(); // types and a `Service` trait, generated from your Zod: never hand-written
|
|
69
|
+
use contract::*;
|
|
70
|
+
|
|
71
|
+
struct Search;
|
|
72
|
+
|
|
73
|
+
impl Service for Search {
|
|
74
|
+
async fn search_docs(&self, ctx: weft::Ctx, input: SearchDocsInput) -> Result<SearchDocsOutput, weft::Error> {
|
|
75
|
+
ctx.touch("docs");
|
|
76
|
+
Ok(vec![])
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#[tokio::main]
|
|
81
|
+
async fn main() -> std::io::Result<()> {
|
|
82
|
+
weft::serve(weft::router(contract::functions(Search))).await
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use Rust 1.99 or newer (`rust-toolchain.toml` pins it). A method is the function's name in snake case; a type is its name plus `Input`, `Output`, or a
|
|
87
|
+
property's name in pascal case.
|
|
88
|
+
|
|
89
|
+
## In-process: `native()`
|
|
90
|
+
|
|
91
|
+
The same `Service`, in a library. `crate-type = ["cdylib"]`, and `features = ["native"]` on `weft` in place of the server:
|
|
92
|
+
|
|
93
|
+
```rust
|
|
94
|
+
// crates/media/src/lib.rs
|
|
95
|
+
weft::contract!();
|
|
96
|
+
use contract::*;
|
|
97
|
+
|
|
98
|
+
struct Media;
|
|
99
|
+
impl Service for Media {
|
|
100
|
+
async fn thumbnail(&self, ctx: weft::Ctx, input: ThumbnailInput) -> Result<ThumbnailOutput, weft::Error> { /* … */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
weft::native!(contract::functions(Media));
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// src/server/rust.ts
|
|
108
|
+
import { native } from '@weft-js/start/rust'
|
|
109
|
+
export const media = native('media') // options: timeout, claims, validate, as for remote()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- An addon runs each call on the worker pool, so a long one doesn't hold the event loop. `native()` sizes the pool to the
|
|
113
|
+
machine unless `UV_THREADPOOL_SIZE` is set.
|
|
114
|
+
- `weft build` also builds `wasm32-unknown-unknown` with SIMD on. With no addon for the platform, the server loads the
|
|
115
|
+
`.wasm` and says so. WASM runs on the event loop, as a TS `run` does.
|
|
116
|
+
- There's no async runtime in it. A method may be `async`, but one that waits on IO belongs in `remote()`; on WASM, one that
|
|
117
|
+
waits is R108.
|
|
118
|
+
- `timeout` stops TS waiting (R107). A call can't be stopped from outside: check `ctx.cancelled()` in a long loop.
|
|
119
|
+
- A panic is R108 for that call. On WASM it ends the instance, and the next call gets a new one, so a static is lost.
|
|
120
|
+
- Bytes cross as JSON, so `z.base64()` for now: the call costs more than the `Buffer` it could be.
|
|
121
|
+
|
|
122
|
+
## What happens where
|
|
123
|
+
|
|
124
|
+
TS keeps the public edge: sessions, CSRF, input validation, the cache, `auth`, `tags`, `invalidates`. Rust gets one crossing
|
|
125
|
+
per call (a signed POST for a sidecar): the parsed input, the user's claims (never cookies), `traceparent` and a deadline.
|
|
126
|
+
TS then validates the output, in production too. Set `remote(target, { validate: false })` to trust it.
|
|
127
|
+
|
|
128
|
+
Options on `remote(target, options)`: `url` (else `WEFT_RUST_<NAME>_URL`), `timeout` (ms, default 10000), `claims`
|
|
129
|
+
(`user => …`, default `id`, `roles`, `tenant`), `validate`.
|
|
130
|
+
|
|
131
|
+
## The context
|
|
132
|
+
|
|
133
|
+
| A function that is | Its `ctx` | `user()` |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `auth: 'user'` | `weft::UserCtx` | `&User`, always there |
|
|
136
|
+
| cached with shared scope | `weft::SharedCtx` | doesn't exist, so it can't leak into a shared cache |
|
|
137
|
+
| anything else | `weft::Ctx` | `Option<&User>` |
|
|
138
|
+
|
|
139
|
+
All three have `ctx.touch(table)`, `ctx.tag(tag)`, `ctx.remaining()`, `ctx.cancelled()`, `ctx.request_id()`,
|
|
140
|
+
`ctx.traceparent()`.
|
|
141
|
+
|
|
142
|
+
- `ctx.touch("docs")` reports a table it read or wrote. In dev it feeds Q011, like a TS `run`'s. A cached query with `tags`
|
|
143
|
+
that never calls it is R105; a query that reads no table should drop `tags`.
|
|
144
|
+
- `ctx.tag("doc:7")` adds a cache tag. It joins the declared ones; Rust can't remove any.
|
|
145
|
+
|
|
146
|
+
## Typed errors
|
|
147
|
+
|
|
148
|
+
<!-- example: skip a fragment of src/server/docs.ts -->
|
|
149
|
+
```ts
|
|
150
|
+
export const getDoc = query({ input, output, errors: { NotFound: z.object({ id: z.string() }) }, impl: search })
|
|
151
|
+
// a TS body raises the same thing: run: () => raise('NotFound', { id })
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```rust
|
|
155
|
+
async fn get_doc(&self, ctx: weft::Ctx, input: GetDocInput) -> Result<GetDocOutput, GetDocError> {
|
|
156
|
+
Err(GetDocError::NotFound(GetDocNotFound { id: input.id.to_string() }))
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The client catches `e.code === 'NotFound'` and `e.data`, either way. A panic or `weft::Error` is R108: the client gets a
|
|
161
|
+
request id, and the detail stays in the sidecar's log.
|
|
162
|
+
|
|
163
|
+
## Types that cross
|
|
164
|
+
|
|
165
|
+
- JSON only. `z.date()` is R101: use `z.iso.datetime()`, or `z.codec(z.iso.datetime(), z.date(), …)` for a `Date` in TS.
|
|
166
|
+
- `.trim()`, `.default()` and codecs are fine. A one-way `.transform()` on an input is R101, as it can't be encoded back.
|
|
167
|
+
- An output may transform: TS parses what Rust sends.
|
|
168
|
+
- `z.void()` is R101: use `z.null()`.
|
|
169
|
+
- Tag a union of objects with `z.discriminatedUnion`. A plain one is R102, or set `rust: { unions: 'allow' }`.
|
|
170
|
+
- The wire is the input side of each schema, so Rust sees what the codec encodes.
|
|
171
|
+
|
|
172
|
+
## `weft dev`, `build`, `start`
|
|
173
|
+
|
|
174
|
+
- `weft dev` builds each sidecar, waits for the first build, and rebuilds on an edit. The new binary starts beside the old
|
|
175
|
+
one, takes over when it's healthy, and the old one drains, so no request fails. A failed build keeps the old one
|
|
176
|
+
running and prints R103 or R109.
|
|
177
|
+
- A `native()` crate rebuilds the same way, and no restart is needed: each build is a new file, the next call loads it, and
|
|
178
|
+
calls on the old one finish. An addon can't be unloaded, so a long session holds its old copies (about 0.5 MB each); they
|
|
179
|
+
go when `weft dev` stops.
|
|
180
|
+
- `weft check` runs `cargo check`, and stops at the Rust a schema edit broke, with the function's id (R103). For a
|
|
181
|
+
`native()` crate it checks the WASM build too (R104), and reads each cached query for `touch` (R105).
|
|
182
|
+
- `weft build` runs `cargo build --release`, runs the sidecars while it prerenders, and ships them in `.output/rust/`.
|
|
183
|
+
A `native()` crate's addon and `.wasm` go in `.output/server/rust/`, so whatever moves the server moves them.
|
|
184
|
+
- `weft start` runs them on loopback, restarts a crash with backoff, and stops them when it stops. A dead sidecar costs
|
|
185
|
+
its functions (R107), not the server.
|
|
186
|
+
|
|
187
|
+
## Deployment and settings
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// weft.config.ts
|
|
191
|
+
rust: {
|
|
192
|
+
unions: 'allow', // let plain object unions through (R102)
|
|
193
|
+
services: {
|
|
194
|
+
search: { crate: 'native/search' }, // default: crates/<name>
|
|
195
|
+
core: { external: true }, // built and run elsewhere: no cargo here
|
|
196
|
+
imaging: { wasm: false }, // links C, and this host has an addon: no fallback (R104)
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
A sidecar on another host needs `external: true`, `WEFT_RUST_<NAME>_URL`, and the same `WEFT_RUST_SECRET` on both sides;
|
|
202
|
+
`weft dev` and `weft start` make those themselves for sidecars they run. Bind it privately: it trusts only the signature,
|
|
203
|
+
and the claims in it. The binary reads `WEFT_LISTEN` (default a free loopback port); a build reads `WEFT_CONTRACT`, or finds
|
|
204
|
+
`.weft/contract.json` above the crate. `GET /_weft/health` gives its service, contract hash, functions and pid. An edge preset can call `remote()`, as it is only
|
|
205
|
+
`fetch`, but something else must run the sidecar. `native()` needs a file system, so on an edge preset it's R104: use
|
|
206
|
+
`remote()`, the same crate behind `weft::router`.
|
|
207
|
+
|
|
208
|
+
Each call is a `tracing` span with `trace_id` and `parent_id`. With the `otel` feature and your own OpenTelemetry layer,
|
|
209
|
+
the span continues TS's trace.
|
|
210
|
+
|
|
211
|
+
In a test, `handlers` replaces a function as it does a `run`, and nothing crosses.
|
|
212
|
+
|
|
213
|
+
## Diagnostics
|
|
214
|
+
|
|
215
|
+
- **R101** no `output`, a type JSON can't carry, or a one-way input transform.
|
|
216
|
+
- **R102** a plain `z.union` of objects.
|
|
217
|
+
- **R103** the Rust and the contract disagree: at `cargo`, in a binary's hash, or in an output or undeclared error at runtime.
|
|
218
|
+
- **R104** `native()` on a preset that can't run it, a `.wasm` that won't build, or a build with nothing for this platform.
|
|
219
|
+
- **R105** a cached query with `tags` that reports no table.
|
|
220
|
+
- **R106** no address, no secret, or a refused signature.
|
|
221
|
+
- **R107** not answering, or past the deadline.
|
|
222
|
+
- **R108** a panic or `weft::Error`.
|
|
223
|
+
- **R109** the crate doesn't build for a reason unrelated to the contract, or it isn't there.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
`@weft-js/start` app: file routes in `src/routes` (`_layout.tsx`, `[id].tsx`, `load`, `meta`), server functions in `src/server/**` or `*.server.ts`. Read `weft docs start` first. Import your own code as `#/server/todos` (`src/server/todos`); generated files are `weft:slot/<id>` and `weft:tokens/<name>`.
|
|
2
|
+
|
|
3
|
+
- **Reads:** `query({ tags: ['todos'], auth: 'user', run: ({ ctx }) => ctx.db.select().from(todos) })`. In a component `todos().value ?? []`; in `load` and handlers `await todos.fetch()`.
|
|
4
|
+
- **Writes:** `mutation({ input, auth: 'user', invalidates: ['todos'], optimistic, run })`.
|
|
5
|
+
- **Database:** Drizzle; `await` every statement. Schema change: `weft db generate` (Q015). Inputs: `schemas.todos.insert.pick({ title: true })`.
|
|
6
|
+
- **Auth:** `ctx.user` is the user or `null`; `auth: 'user'` refuses anyone else (A002). A guard is `redirect('/login')` in a layout's `load`. `run` must still check who owns a row.
|
|
7
|
+
- **Never** copy query results into `state`, import `db` from a component (Q012), `fetch` `/_weft/*`, or poll (`todos.live()`; rooms: `channel`).
|
|
8
|
+
- **Every page sets `meta` as you build it**, from `data` and `params`: `weft docs meta`.
|