@weft-js/start 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1266 -33
  2. package/crate/README.md +322 -0
  3. package/package.json +2 -2
@@ -0,0 +1,322 @@
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>
6
+
7
+ <h1 align="center">weft (Rust crate)</h1>
8
+
9
+ <p align="center">
10
+ <strong>The Rust side of Weft's <code>remote()</code> and <code>native()</code>: server functions declared in TypeScript, implemented in Rust.</strong><br>
11
+ Zod stays the source of truth. The crate generates the types, a <code>Service</code> trait and the transport, so a contract change stops <code>cargo</code> where the Rust no longer fits.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="../../LICENSE"><img src="https://img.shields.io/badge/license-MIT-145c3c" alt="MIT license"></a>
16
+ <img src="https://img.shields.io/badge/rust-1.99.1-145c3c" alt="Rust toolchain 1.99.1">
17
+ <img src="https://img.shields.io/badge/edition-2024-145c3c" alt="Rust edition 2024">
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="#quick-start">Quick start</a> ·
22
+ <a href="../../site/content/learn/rust.md">Rust guide</a> ·
23
+ <a href="../../packages/cli/docs/rust.md">Rust reference</a> ·
24
+ <a href="../README.md">All crates</a>
25
+ </p>
26
+
27
+ > [!NOTE]
28
+ > This crate is not on crates.io. `@weft-js/start` ships a copy of it in its package as `crate/` (written at `prepack`
29
+ > by `packages/start/scripts/crate.mjs`, without `target/` and `tests/`), and an app's `Cargo.toml` points there by
30
+ > path. That copy is always the version the installed TypeScript speaks. `weft add rust` writes the dependency for you.
31
+
32
+ A Weft server function can have a Rust body instead of a TypeScript `run`. You declare it in TypeScript with Zod, as
33
+ any other function, and give it `impl: <target>`. `weft check`, `weft dev` and `weft build` write the schemas to
34
+ `.weft/contract.json`, and this crate's `build` feature turns that file into Rust: a struct per input and output, a
35
+ typed error enum per function, and one `Service` trait. You implement the trait; the crate serves it as an HTTP sidecar
36
+ (`remote()`) or exports it in-process as a Node-API addon or a `.wasm` (`native()`).
37
+
38
+ TypeScript keeps the public edge: sessions, CSRF, the cache and validation on both sides of the call. Rust only sees
39
+ input that already passed, and claims, never cookies.
40
+
41
+ ## Highlights
42
+
43
+ - **No hand-written Rust types.** `weft::build::generate("<service>")` reads `.weft/contract.json` and writes the types
44
+ with typify, the error enums, the `Service` trait and `functions()` into `OUT_DIR`.
45
+ - **Drift stops the build.** A contract that changes reruns the build script, and `cargo` fails exactly where the
46
+ implementation no longer fits. Weft reports it as `R103` with the function's id.
47
+ - **The context matches the declaration.** A function gets `Ctx`, `UserCtx` (`auth: 'user'`) or `SharedCtx` (a shared
48
+ cache), and `SharedCtx` has no `user()` at all, so leaking per-user data into a shared result is a compile error.
49
+ - **Signed calls.** A sidecar call carries `weft-ctx` and `weft-sig` headers: an HMAC-SHA256 over the function id, the
50
+ context and the body, with `iat` checked against a 60-second skew.
51
+ - **One reply shape on every transport.** `{ "ok": true, "value", "tags", "touched" }` or
52
+ `{ "ok": false, "error": { "code", … } }`, from HTTP, Node-API and WASM alike.
53
+ - **A panic is one failed call.** Each sidecar call runs in its own task with the deadline TypeScript sent; a panic or
54
+ an overrun answers that call and leaves the process serving.
55
+ - **Feature-gated.** A `native()` addon links no server and no tokio; a build script links only the generator.
56
+
57
+ ## Contents
58
+
59
+ - [Quick start](#quick-start)
60
+ - [Features](#features)
61
+ - [The generated contract](#the-generated-contract)
62
+ - [Contexts](#contexts)
63
+ - [`remote()`: the sidecar](#remote-the-sidecar)
64
+ - [`native()`: in-process](#native-in-process)
65
+ - [Errors](#errors)
66
+ - [API reference](#api-reference)
67
+ - [Troubleshooting](#troubleshooting)
68
+ - [Build and test](#build-and-test)
69
+
70
+ ## Quick start
71
+
72
+ In an app, let the CLI write the crate:
73
+
74
+ ```bash
75
+ weft add rust media # native(): an in-process addon
76
+ weft add rust search --remote # remote(): a sidecar
77
+ ```
78
+
79
+ For `remote()`, the crate depends on `weft` twice: the default features for the sidecar, and only `build` for the build
80
+ script. With the copy inside `@weft-js/start`, that looks like this:
81
+
82
+ ```toml
83
+ [dependencies]
84
+ serde = { version = "1", features = ["derive"] }
85
+ serde_json = "1"
86
+ tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
87
+ weft = { path = "../../node_modules/@weft-js/start/crate" }
88
+
89
+ [build-dependencies]
90
+ weft = { path = "../../node_modules/@weft-js/start/crate", default-features = false, features = ["build"] }
91
+ ```
92
+
93
+ ```rust
94
+ // build.rs
95
+ fn main() {
96
+ weft::build::generate("search");
97
+ }
98
+ ```
99
+
100
+ ```rust
101
+ // src/main.rs
102
+ weft::contract!();
103
+ use contract::*;
104
+
105
+ struct Search;
106
+
107
+ impl Service for Search {
108
+ async fn search_docs(
109
+ &self,
110
+ ctx: weft::SharedCtx,
111
+ input: SearchDocsInput,
112
+ ) -> Result<SearchDocsOutput, weft::Error> {
113
+ ctx.touch("docs");
114
+ Ok(vec![])
115
+ }
116
+ }
117
+
118
+ #[tokio::main]
119
+ async fn main() -> std::io::Result<()> {
120
+ weft::serve(weft::router(contract::functions(Search))).await
121
+ }
122
+ ```
123
+
124
+ For `native()`, the crate is a `cdylib`, depends on `weft` with `default-features = false, features = ["native"]`, and
125
+ replaces `main` with one line:
126
+
127
+ ```rust
128
+ weft::native!(contract::functions(Media));
129
+ ```
130
+
131
+ The [Rust guide](../../site/content/learn/rust.md) walks through both, including the TypeScript side.
132
+
133
+ ## Features
134
+
135
+ | Feature | Default | Enables | Use it for |
136
+ |---|---|---|---|
137
+ | `core` | via `server` | `chrono`, `regress`, `serde`, `serde_json`, `uuid`; the `Ctx` types, `Functions`, `Error` | What generated code needs on any target |
138
+ | `server` | yes | `core` plus `axum`, `tokio`, `hmac`, `sha2`, `base64`, `tracing`, `tracing-subscriber`; `router`, `router_with`, `serve`, `sign`, `verify` | A `remote()` sidecar |
139
+ | `native` | no | `core` plus `napi`, `napi-derive` (not on wasm); `weft::native!` | A `native()` crate, as an addon or a `.wasm`. No server, no tokio |
140
+ | `otel` | no | `server` plus `opentelemetry`, `tracing-opentelemetry` | Continue the trace TypeScript started, for an app that installs an OpenTelemetry layer |
141
+ | `build` | no | `typify`, `schemars`, `syn`, `prettyplease`, `napi-build`, `serde_json`; `weft::build::generate` | A sidecar's or addon's `build.rs` |
142
+
143
+ ## The generated contract
144
+
145
+ `weft::build::generate(service)` does this, in a build script:
146
+
147
+ 1. Finds the contract: `WEFT_CONTRACT` if set, else the nearest `.weft/contract.json` above the crate.
148
+ 2. Turns each function of `service` into Rust. Names convert from camelCase: `searchDocs` becomes the method
149
+ `search_docs` and the types `SearchDocsInput`, `SearchDocsOutput`. Two files that export the same name are kept
150
+ apart by the function id.
151
+ 3. Writes `OUT_DIR/weft_contract.rs`, and a copy to read at `.weft/rust/<service>.rs` (a build never depends on the
152
+ copy).
153
+ 4. For a `native` service on a non-wasm target, runs `napi_build::setup()` so the linker accepts the host's `napi_*`
154
+ symbols.
155
+
156
+ `weft::contract!()` includes the generated file as `pub mod contract`, which holds:
157
+
158
+ | Item | What it is |
159
+ |---|---|
160
+ | `<Name>Input`, `<Name>Output` | serde types for each function, from its Zod schemas |
161
+ | `<Name>Error` | A typed error enum, for each function that declares `errors` |
162
+ | `trait Service` | One `async fn` per function, each taking the context its declaration calls for |
163
+ | `fn functions(service) -> weft::Functions` | The dispatch table that `weft::router` serves and `weft::native!` exports |
164
+ | `const CONTRACT_HASH` | The contract's hash. The sidecar's health check reports it, and neither side runs a binary built from another one |
165
+
166
+ ## Contexts
167
+
168
+ The generated trait picks one context per function, from its declaration in TypeScript:
169
+
170
+ | Type | When | `user()` |
171
+ |---|---|---|
172
+ | `weft::Ctx` | default | `Option<&User>`, as `ctx.user` is nullable in TypeScript |
173
+ | `weft::UserCtx` | `auth: 'user'` | `&User`: TypeScript refused the call otherwise |
174
+ | `weft::SharedCtx` | a shared cache (`cache: { life }`) | none: the value is shared by every user |
175
+
176
+ All three dereference to `weft::Base`, which has:
177
+
178
+ | Method | What it does |
179
+ |---|---|
180
+ | `touch(table)` | Report a table this call read or wrote. `weft dev` checks it against the function's tags (Q011) |
181
+ | `tag(tag)` | Add a cache tag to the result, or in a mutation a tag to invalidate. Rust can add tags, never remove declared ones |
182
+ | `function()` | The function's id, as the contract spells it |
183
+ | `request_id()` | The id TypeScript gave this request. The client sees it on an `Internal` failure, so log it |
184
+ | `traceparent()` | The W3C `traceparent` TypeScript sent, if any |
185
+ | `remaining()` | What's left of the deadline |
186
+ | `cancelled()` | `true` once the deadline has passed, so long CPU loops can stop |
187
+
188
+ `weft::User` carries `id`, `roles`, an optional `tenant`, and any other claims in `extra`; `has_role(role)` checks
189
+ `roles`. These are only what `remote({ claims })` chose to send.
190
+
191
+ ## `remote()`: the sidecar
192
+
193
+ `weft::router(functions)` returns an axum `Router`, so a sidecar can be one binary or part of a larger axum app:
194
+
195
+ | Route | What it does |
196
+ |---|---|
197
+ | `POST /_weft/fn/{id}` | Verify the signature, deserialize `{ "input": … }`, run the function in its own task under the deadline, answer |
198
+ | `GET /_weft/health` | `{ ok, service, contract, functions, pid }`, for the supervisor |
199
+
200
+ `router` reads its key from `WEFT_RUST_SECRET` and panics with `R106` when it is unset, because a sidecar that took
201
+ unsigned calls would trust any process that could reach it. `router_with(functions, secret)` takes the key directly.
202
+
203
+ `weft::serve(router)` listens on `WEFT_LISTEN` (`127.0.0.1:0` when unset), prints `weft-listening <addr>` for the
204
+ supervisor, logs to stderr by `RUST_LOG` (default `info`) unless the app installed a subscriber, and drains on SIGTERM
205
+ or Ctrl-C.
206
+
207
+ ### The envelope
208
+
209
+ Each call carries two headers:
210
+
211
+ ```text
212
+ weft-ctx: base64url(JSON of the Envelope)
213
+ weft-sig: hex(HMAC-SHA256(secret, "weft1\n" + id + "\n" + weft-ctx + "\n" + body))
214
+ ```
215
+
216
+ The envelope is `{ user, requestId, deadlineMs, traceparent, iat }`. The signature covers the function id and the body
217
+ as well as the claims, so a signed call can't be replayed against another function or with another input, and `iat`
218
+ bounds how long it can be replayed at all (`MAX_SKEW_MS` is `60_000`). `weft::sign` and `weft::verify` are public, for
219
+ tests and other hosts.
220
+
221
+ ### What a call answers
222
+
223
+ | Outcome | Status | Body |
224
+ |---|---|---|
225
+ | Value | 200 | `{ "ok": true, "value": …, "tags": […], "touched": […] }` |
226
+ | Declared error | 200 | `{ "ok": false, "error": { "code": …, "data": … } }` |
227
+ | Panic, `weft::Error`, or a result that doesn't serialize | 500 | `{ "ok": false, "error": { "code": "Internal", "requestId": … } }`; the detail stays in the sidecar's log |
228
+ | Input serde refuses, or claims that don't fit the context | 422 | `{ "ok": false, "error": { "code": "Drift", "message": … } }` |
229
+ | Unknown function id | 404 | `Drift`, naming the id |
230
+ | Missing, malformed or wrong signature, or stale `iat` | 401 | `Refused` |
231
+ | Deadline passed | 504 | `Deadline` |
232
+
233
+ When TypeScript hangs up, axum drops the handler's future and the crate aborts the task.
234
+
235
+ ### Tracing
236
+
237
+ Each call runs in a `weft.fn` span with `function`, `request_id`, `trace_id` and `parent_id` fields, taken from the
238
+ envelope's `traceparent`. With the `otel` feature, the span's parent is set from `traceparent`, so the call continues
239
+ the trace TypeScript started.
240
+
241
+ ## `native()`: in-process
242
+
243
+ `weft::native!(contract::functions(Svc))` exports the service from a `cdylib`. There is no HTTP and no signature,
244
+ because nothing outside the process can reach it. The service is built on the first call, not while the library loads.
245
+
246
+ | Target | Exports | How a call runs |
247
+ |---|---|---|
248
+ | Node-API addon | `call(id, ctx, body)` (async), `contract()` | On the host's worker pool, so a long call doesn't hold the event loop |
249
+ | `wasm32` | `weft_init`, `weft_alloc`, `weft_free`, `weft_call`, `weft_contract`; imports `weft.now` and `weft.panicked` | On whatever thread calls it. Returns `pointer << 32 | length` |
250
+
251
+ `contract()` returns `{ service, hash, functions }`, and TypeScript refuses a library built from another contract
252
+ (R103).
253
+
254
+ > [!WARNING]
255
+ > `native()` brings no async runtime. It is for CPU work. A function that waits on IO belongs in `remote()`: on the
256
+ > WASM build a method that doesn't complete without waiting panics.
257
+
258
+ ## Errors
259
+
260
+ A function that declares no `errors` returns `weft::Error`. Any `std::error::Error` converts into it, so `?` works, and
261
+ `weft::Error::msg("…")` makes one from text. It always becomes an `Internal` failure: the client gets the request id
262
+ and the detail stays on the server.
263
+
264
+ A function that declares `errors` returns its generated `<Name>Error` enum, which implements `weft::Failure` and
265
+ serializes as `{ "code": …, "data": … }`. TypeScript checks it against the declaration, and the client catches
266
+ `e.code` and `e.data`.
267
+
268
+ ## API reference
269
+
270
+ | Item | Feature | Description |
271
+ |---|---|---|
272
+ | `weft::contract!()` | any | Include the generated contract as `pub mod contract` |
273
+ | `weft::native!(functions)` | `native` | Export the functions as a Node-API addon, or as a `.wasm` on that target |
274
+ | `weft::build::generate(service)` | `build` | Generate the contract in a build script. Panics with `R103` and a fix when it can't |
275
+ | `weft::router(functions)` | `server` | An axum `Router` keyed by `WEFT_RUST_SECRET` |
276
+ | `weft::router_with(functions, secret)` | `server` | The same, with the key in hand |
277
+ | `weft::serve(router)` | `server` | Serve on `WEFT_LISTEN` and drain on SIGTERM or Ctrl-C |
278
+ | `weft::sign(secret, id, ctx, body)` | `server` | The `weft-sig` value for a call |
279
+ | `weft::verify(secret, id, ctx, sig, body, now_ms)` | `server` | Check a call and decode its `Envelope`, or say why it is `Refused` |
280
+ | `weft::Refused` | `server` | `Malformed`, `BadSignature` or `Stale` |
281
+ | `weft::Ctx`, `UserCtx`, `SharedCtx` | `core` | The three contexts |
282
+ | `weft::Base`, `FromBase` | `core` | What every context has, and how generated code builds a context from it |
283
+ | `weft::User`, `Envelope` | `core` | The claims, and what TypeScript sends about a call |
284
+ | `weft::Functions` | `core` | A service's functions by contract id |
285
+ | `weft::Error`, `Failure`, `Failed` | `core` | The default error, the trait an error type implements, and what it becomes on the wire |
286
+
287
+ ## Troubleshooting
288
+
289
+ | Message | Cause and fix |
290
+ |---|---|
291
+ | `R103: no .weft/contract.json above this crate` | The build script found no contract. Run `weft dev` or `weft check` in the app, or set `WEFT_CONTRACT` |
292
+ | `R103: …` with a function's name from `cargo` | The contract changed and the Rust no longer matches. Update the `impl Service` |
293
+ | `R106: WEFT_RUST_SECRET is not set` | `weft dev` and `weft start` set it. A sidecar deployed on its own needs the same value as the server |
294
+ | `Refused`: the signature doesn't match | `WEFT_RUST_SECRET` differs between the two sides |
295
+ | `Refused`: signed too long ago | A clock is more than 60 seconds off |
296
+ | `Drift` | The sidecar and TypeScript were built from different contracts. Rebuild the sidecar |
297
+
298
+ The full list of Rust codes, R101 to R109, is in the [Rust reference](../../packages/cli/docs/rust.md).
299
+
300
+ ## Build and test
301
+
302
+ The toolchain is pinned in `rust-toolchain.toml` to Rust `1.99.1` with `clippy` and `rustfmt`.
303
+
304
+ ```bash
305
+ cd crates/weft
306
+ cargo test # default features: unit tests, tests/router.rs and doc tests
307
+ cargo test --features build,otel # also the generator's and the otel layer's unit tests
308
+ cargo clippy --all-targets -- -D warnings
309
+ ```
310
+
311
+ The `build` and `otel` modules exist only with their features, so their unit tests run only when those are on.
312
+
313
+ `tests/router.rs` drives the router as TypeScript meets it: a signed POST and each way a call can end (a value with
314
+ both reports, the declared context enforced at run time, a typed error, a panic, the deadline, drift, an unknown
315
+ function, a bad signature, and the health check). `src/ctx.rs` holds a `compile_fail` doc test proving `SharedCtx` has
316
+ no `user()`. `evals/gates/phase5a-exit.mjs` and `evals/gates/phase5b-exit.mjs` build real crates against this one.
317
+
318
+ See [CONTRIBUTING.md](../../CONTRIBUTING.md) for the repository workflow.
319
+
320
+ ## License
321
+
322
+ [MIT](../../LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weft-js/start",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "weft Start: file routes, server functions, streaming SSR, a database and auth",
5
5
  "keywords": [
6
6
  "weft"
@@ -78,7 +78,7 @@
78
78
  "vite": "8.3.5"
79
79
  },
80
80
  "peerDependencies": {
81
- "@weft-js/core": "^0.1.2",
81
+ "@weft-js/core": "^0.1.3",
82
82
  "better-auth": "^1.7.0",
83
83
  "drizzle-orm": "^0.45.0",
84
84
  "drizzle-zod": "^0.8.0",