@weft-js/start 0.1.1 → 0.1.2

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 (213) hide show
  1. package/README.md +36 -3
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/rust-toolchain.toml +3 -0
  5. package/crate/src/build/emit.rs +103 -0
  6. package/crate/src/build.rs +367 -0
  7. package/crate/src/ctx.rs +186 -0
  8. package/crate/src/envelope.rs +163 -0
  9. package/crate/src/functions.rs +173 -0
  10. package/crate/src/lib.rs +118 -0
  11. package/crate/src/native.rs +212 -0
  12. package/crate/src/otel.rs +62 -0
  13. package/crate/src/server.rs +203 -0
  14. package/package.json +46 -37
  15. package/src/app.mjs +38 -8
  16. package/src/asset-receipt.d.mts +13 -0
  17. package/src/asset-receipt.mjs +61 -0
  18. package/src/auth/better-auth.ts +215 -17
  19. package/src/auth/drizzle.ts +1 -1
  20. package/src/auth.ts +21 -6
  21. package/src/boot.ts +19 -8
  22. package/src/bus/postgres.ts +32 -19
  23. package/src/bus.ts +7 -4
  24. package/src/{capture.mjs → capture.ts} +34 -1
  25. package/src/changes-sql.d.mts +8 -2
  26. package/src/changes-sql.mjs +26 -10
  27. package/src/changes.ts +165 -66
  28. package/src/channel/connections.ts +216 -0
  29. package/src/channel/socket.ts +396 -0
  30. package/src/channel-fake.ts +19 -4
  31. package/src/channel.ts +120 -492
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +144 -157
  35. package/src/data/cache.ts +354 -0
  36. package/src/data/definitions.ts +94 -0
  37. package/src/data/draft.ts +104 -0
  38. package/src/data/foundation.ts +95 -0
  39. package/src/data/live-api.ts +22 -0
  40. package/src/data/mutation.ts +138 -0
  41. package/src/data/primitives.ts +135 -0
  42. package/src/data/query.ts +106 -0
  43. package/src/data/transport.ts +121 -0
  44. package/src/data/types.ts +303 -0
  45. package/src/data.ts +69 -894
  46. package/src/db/bun-sqlite.d.ts +4 -1
  47. package/src/db/changes-mysql.ts +61 -21
  48. package/src/db/changes-postgres.ts +197 -118
  49. package/src/db/changes-sqlite.ts +131 -64
  50. package/src/db/core.ts +38 -13
  51. package/src/db/index.ts +20 -4
  52. package/src/db/mysql.ts +22 -6
  53. package/src/db/postgres.ts +49 -13
  54. package/src/db/sqlite.ts +53 -27
  55. package/src/derive.d.mts +4 -1
  56. package/src/derive.mjs +136 -39
  57. package/src/dev-runtime.mjs +8 -4
  58. package/src/exec/errors.ts +57 -0
  59. package/src/exec/landing.ts +66 -0
  60. package/src/exec/scope.ts +174 -0
  61. package/src/exec/touch.ts +80 -0
  62. package/src/exec/validation.ts +176 -0
  63. package/src/exec.ts +191 -341
  64. package/src/extension.ts +8 -2
  65. package/src/first-paint.ts +26 -9
  66. package/src/form/value.ts +15 -0
  67. package/src/form.tsx +132 -43
  68. package/src/hub/channels.ts +102 -0
  69. package/src/hub/connections.ts +147 -0
  70. package/src/hub/delivery.ts +62 -0
  71. package/src/hub/feed.ts +168 -0
  72. package/src/hub/presence.ts +64 -0
  73. package/src/hub/route.ts +158 -0
  74. package/src/hub/subscriptions.ts +212 -0
  75. package/src/hub/topic.ts +98 -0
  76. package/src/hub/tracked.ts +12 -0
  77. package/src/hub/types.ts +190 -0
  78. package/src/hub/view.ts +147 -0
  79. package/src/hub.ts +132 -758
  80. package/src/index.ts +84 -10
  81. package/src/inline.mjs +356 -0
  82. package/src/inspect/compare.mjs +162 -0
  83. package/src/inspect/routes.mjs +154 -0
  84. package/src/inspect/samples.mjs +59 -0
  85. package/src/inspect/url.mjs +2 -0
  86. package/src/inspect.mjs +146 -187
  87. package/src/link/election.ts +178 -0
  88. package/src/link/lines.ts +27 -0
  89. package/src/link.ts +90 -166
  90. package/src/list-drizzle.ts +104 -18
  91. package/src/list.ts +127 -36
  92. package/src/listen.ts +32 -6
  93. package/src/live/api.ts +18 -0
  94. package/src/live/backoff.ts +6 -0
  95. package/src/live/frames.ts +42 -0
  96. package/src/live/read.ts +77 -0
  97. package/src/live/sub-types.ts +24 -0
  98. package/src/live/transport.ts +86 -0
  99. package/src/live/types.ts +52 -0
  100. package/src/live-fake.ts +20 -5
  101. package/src/live-target.mjs +112 -37
  102. package/src/live.ts +116 -225
  103. package/src/media.ts +8 -1
  104. package/src/migrations.mjs +14 -6
  105. package/src/mux.ts +161 -64
  106. package/src/og/DefaultCard.tsx +40 -9
  107. package/src/og/fonts.mjs +16 -8
  108. package/src/og/images.mjs +43 -22
  109. package/src/og/index.mjs +119 -43
  110. package/src/og/native.mjs +11 -4
  111. package/src/range.mjs +15 -3
  112. package/src/router.ts +56 -6
  113. package/src/routes.mjs +73 -22
  114. package/src/run/admission.ts +80 -0
  115. package/src/run/boot.tsx +115 -0
  116. package/src/run/cache-presentation.ts +67 -0
  117. package/src/run/cache.ts +254 -0
  118. package/src/run/helpers.ts +197 -0
  119. package/src/run/listeners.ts +155 -0
  120. package/src/run/navigation.ts +382 -0
  121. package/src/run/page-skeleton.tsx +35 -0
  122. package/src/run/prefetch.ts +128 -0
  123. package/src/run/retain-view.ts +18 -0
  124. package/src/run/shape-preparation.ts +97 -0
  125. package/src/run/shape-styles.ts +72 -0
  126. package/src/run/source-context/pin.ts +124 -0
  127. package/src/run/source-context.ts +311 -0
  128. package/src/run.tsx +50 -178
  129. package/src/rust/common.ts +163 -0
  130. package/src/rust/native.ts +282 -0
  131. package/src/rust.ts +103 -229
  132. package/src/server/card.tsx +65 -0
  133. package/src/server/compression.ts +115 -0
  134. package/src/server/core.ts +70 -0
  135. package/src/server/dispatch.ts +70 -0
  136. package/src/server/form.ts +85 -0
  137. package/src/server/functions.ts +129 -0
  138. package/src/server/handler.ts +39 -0
  139. package/src/server/head.ts +96 -0
  140. package/src/server/media.ts +60 -0
  141. package/src/server/navigation-cache.ts +80 -0
  142. package/src/server/navigation.tsx +85 -0
  143. package/src/server/page-cold.ts +95 -0
  144. package/src/server/page-stream.tsx +181 -0
  145. package/src/server/page-types.ts +51 -0
  146. package/src/server/page-warm.ts +130 -0
  147. package/src/server/page.ts +69 -0
  148. package/src/server/payload.ts +89 -0
  149. package/src/server/prerender.ts +60 -0
  150. package/src/server/preview.tsx +67 -0
  151. package/src/server/render.ts +167 -0
  152. package/src/server/request.ts +98 -0
  153. package/src/server/resolver.ts +228 -0
  154. package/src/server/routes.ts +150 -0
  155. package/src/server/services.ts +69 -0
  156. package/src/server/text.ts +29 -0
  157. package/src/server/types.ts +160 -0
  158. package/src/server.tsx +82 -971
  159. package/src/shape.mjs +30 -6
  160. package/src/share/display.ts +50 -0
  161. package/src/share/network.ts +119 -0
  162. package/src/share/receive.ts +70 -0
  163. package/src/share/state.ts +30 -0
  164. package/src/share/tab-queue.ts +28 -0
  165. package/src/share-server.ts +128 -43
  166. package/src/share.ts +103 -209
  167. package/src/sheets.mjs +30 -18
  168. package/src/sidecar/build.mjs +170 -0
  169. package/src/sidecar/cargo.mjs +164 -0
  170. package/src/sidecar/common.mjs +17 -0
  171. package/src/sidecar/dev-build.mjs +91 -0
  172. package/src/sidecar/dev-watch.mjs +88 -0
  173. package/src/sidecar/manifest.mjs +19 -0
  174. package/src/sidecar/process.mjs +58 -0
  175. package/src/sidecar/refresh.mjs +19 -0
  176. package/src/sidecar.mjs +75 -416
  177. package/src/socket/check.ts +50 -0
  178. package/src/socket/deliver.ts +16 -0
  179. package/src/socket/publish.ts +56 -0
  180. package/src/socket/rooms.ts +41 -0
  181. package/src/socket/route.ts +79 -0
  182. package/src/socket/shared.ts +26 -0
  183. package/src/socket/types.ts +48 -0
  184. package/src/socket.ts +205 -194
  185. package/src/stream.ts +10 -1
  186. package/src/verify/app.ts +39 -0
  187. package/src/verify/cleanup.ts +27 -0
  188. package/src/verify/database.ts +11 -0
  189. package/src/verify/live.ts +63 -0
  190. package/src/verify/notify.ts +35 -0
  191. package/src/verify/skip.ts +19 -0
  192. package/src/verify/support.ts +149 -0
  193. package/src/verify/timing.ts +15 -0
  194. package/src/verify/trigger.ts +14 -0
  195. package/src/verify.ts +77 -176
  196. package/src/view.tsx +96 -11
  197. package/src/vite/asset-bytes.mjs +95 -0
  198. package/src/vite/assets.mjs +117 -0
  199. package/src/vite/config-hooks.mjs +166 -0
  200. package/src/vite/dev-hooks.mjs +103 -0
  201. package/src/vite/dev.mjs +102 -0
  202. package/src/vite/entries.mjs +145 -0
  203. package/src/vite/module-hooks.mjs +288 -0
  204. package/src/vite/route-modules.mjs +289 -0
  205. package/src/vite/server-functions.mjs +222 -0
  206. package/src/vite/shape-entry-css.mjs +187 -0
  207. package/src/vite/shape-override.mjs +226 -0
  208. package/src/vite/shared.mjs +51 -0
  209. package/src/vite/sheets.mjs +92 -0
  210. package/src/vite/state.mjs +89 -0
  211. package/src/vite/syntax.mjs +139 -0
  212. package/src/vite.mjs +152 -853
  213. package/src/capture.d.mts +0 -1
@@ -0,0 +1,186 @@
1
+ //! What a handler knows about its call. There are three, and the generated trait picks one per function from its
2
+ //! declaration, so what TS guarantees is a compile error in Rust rather than a convention:
3
+ //!
4
+ //! - [`Ctx`]: `ctx.user()` is an `Option`, as `ctx.user` is nullable in TS.
5
+ //! - [`UserCtx`] (`auth: 'user'`): `ctx.user()` is the user; TS refused the call otherwise.
6
+ //! - [`SharedCtx`] (`cache: { life }`): there is no `user()` at all, because the value is shared by every user.
7
+ //!
8
+ //! ```compile_fail
9
+ //! fn leaks(ctx: weft::SharedCtx) { let _ = ctx.user(); }
10
+ //! ```
11
+
12
+ use std::ops::Deref;
13
+ use std::sync::{Arc, Mutex};
14
+ use std::time::Duration;
15
+
16
+ use serde::Deserialize;
17
+
18
+ /// The claims TS sends: never the session, and only what `remote({ claims })` chose to send.
19
+ #[derive(Debug, Clone, Deserialize)]
20
+ pub struct User {
21
+ pub id: String,
22
+ #[serde(default)]
23
+ pub roles: Vec<String>,
24
+ #[serde(default)]
25
+ pub tenant: Option<String>,
26
+ /// Anything else a `claims` function returned.
27
+ #[serde(flatten)]
28
+ pub extra: serde_json::Map<String, serde_json::Value>,
29
+ }
30
+
31
+ impl User {
32
+ pub fn has_role(&self, role: &str) -> bool {
33
+ self.roles.iter().any(|r| r == role)
34
+ }
35
+ }
36
+
37
+ /// What TS tells a sidecar or an addon about the call. Claims only: never a cookie or a session token.
38
+ #[derive(Debug, Clone, Deserialize)]
39
+ #[serde(rename_all = "camelCase")]
40
+ pub struct Envelope {
41
+ pub user: Option<User>,
42
+ pub request_id: String,
43
+ pub deadline_ms: u64,
44
+ pub traceparent: Option<String>,
45
+ /// Milliseconds since the epoch when TS signed it. Nothing signs an in-process call.
46
+ #[serde(default)]
47
+ pub iat: u64,
48
+ }
49
+
50
+ /// A monotonic clock. `Instant::now()` panics on wasm32-unknown-unknown, so there the host tells the time.
51
+ #[cfg(not(target_family = "wasm"))]
52
+ fn clock() -> Duration {
53
+ static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
54
+ START.get_or_init(std::time::Instant::now).elapsed()
55
+ }
56
+
57
+ #[cfg(target_family = "wasm")]
58
+ fn clock() -> Duration {
59
+ Duration::from_secs_f64(crate::native::now_ms() / 1000.0)
60
+ }
61
+
62
+ #[derive(Debug)]
63
+ struct Inner {
64
+ function: String,
65
+ user: Option<User>,
66
+ request_id: String,
67
+ traceparent: Option<String>,
68
+ deadline: Duration,
69
+ tags: Mutex<Vec<String>>,
70
+ touched: Mutex<Vec<String>>,
71
+ }
72
+
73
+ /// What every context has: the deadline, the ids and the two reports back to TS.
74
+ #[derive(Debug, Clone)]
75
+ pub struct Base(Arc<Inner>);
76
+
77
+ impl Base {
78
+ pub(crate) fn new(function: &str, envelope: Envelope) -> Self {
79
+ Base(Arc::new(Inner {
80
+ function: function.to_string(),
81
+ user: envelope.user,
82
+ request_id: envelope.request_id,
83
+ traceparent: envelope.traceparent,
84
+ deadline: clock() + Duration::from_millis(envelope.deadline_ms),
85
+ tags: Mutex::default(),
86
+ touched: Mutex::default(),
87
+ }))
88
+ }
89
+
90
+ /// Report a table this call read (a query) or wrote (a mutation). `weft dev` checks it against the function's tags
91
+ /// (Q011), as it does for a TS `run`; it can't see into Rust any other way.
92
+ pub fn touch(&self, table: impl Into<String>) {
93
+ self.0.touched.lock().expect("touched").push(table.into());
94
+ }
95
+
96
+ /// Add a cache tag to this call's result, or, in a mutation, a tag to invalidate. Declared tags stay in TS: Rust can
97
+ /// add to them and never remove one.
98
+ pub fn tag(&self, tag: impl Into<String>) {
99
+ self.0.tags.lock().expect("tags").push(tag.into());
100
+ }
101
+
102
+ /// The function's id, as the contract spells it.
103
+ pub fn function(&self) -> &str {
104
+ &self.0.function
105
+ }
106
+
107
+ /// The id TS gave this request. Log it: the client sees it on an `Internal` failure.
108
+ pub fn request_id(&self) -> &str {
109
+ &self.0.request_id
110
+ }
111
+
112
+ pub fn traceparent(&self) -> Option<&str> {
113
+ self.0.traceparent.as_deref()
114
+ }
115
+
116
+ /// What's left of the deadline. weft stops waiting at zero; long CPU loops should check [`Base::cancelled`].
117
+ pub fn remaining(&self) -> Duration {
118
+ self.0.deadline.saturating_sub(clock())
119
+ }
120
+
121
+ /// The deadline passed, so no one is waiting for this answer.
122
+ pub fn cancelled(&self) -> bool {
123
+ self.remaining().is_zero()
124
+ }
125
+
126
+ pub(crate) fn tags(&self) -> Vec<String> {
127
+ self.0.tags.lock().expect("tags").clone()
128
+ }
129
+
130
+ pub(crate) fn touched(&self) -> Vec<String> {
131
+ self.0.touched.lock().expect("touched").clone()
132
+ }
133
+
134
+ pub(crate) fn user(&self) -> Option<&User> {
135
+ self.0.user.as_ref()
136
+ }
137
+ }
138
+
139
+ /// How the generated code turns the common [`Base`] into the context a function's declaration calls for. `None` means
140
+ /// the claims don't fit, which only a wrong signer can cause.
141
+ pub trait FromBase: Sized {
142
+ fn from_base(base: Base) -> Option<Self>;
143
+ }
144
+
145
+ /// A public function: `user()` is an `Option`.
146
+ #[derive(Debug, Clone)]
147
+ pub struct Ctx(Base);
148
+
149
+ impl Ctx {
150
+ pub fn user(&self) -> Option<&User> {
151
+ self.0.user()
152
+ }
153
+ }
154
+
155
+ /// `auth: 'user'`: TS refused the call unless someone is signed in.
156
+ #[derive(Debug, Clone)]
157
+ pub struct UserCtx(Base);
158
+
159
+ impl UserCtx {
160
+ pub fn user(&self) -> &User {
161
+ self.0.user().expect("from_base checked it")
162
+ }
163
+ }
164
+
165
+ /// `cache: { life }`: one value for every user, so no user. Take what the value depends on as input.
166
+ #[derive(Debug, Clone)]
167
+ pub struct SharedCtx(Base);
168
+
169
+ macro_rules! context {
170
+ ($($name:ident: $check:expr),*) => {$(
171
+ impl Deref for $name {
172
+ type Target = Base;
173
+ fn deref(&self) -> &Base {
174
+ &self.0
175
+ }
176
+ }
177
+ impl FromBase for $name {
178
+ fn from_base(base: Base) -> Option<Self> {
179
+ let fits: fn(&Base) -> bool = $check;
180
+ fits(&base).then_some(Self(base))
181
+ }
182
+ }
183
+ )*};
184
+ }
185
+
186
+ context!(Ctx: |_| true, UserCtx: |b| b.user().is_some(), SharedCtx: |b| b.user().is_none());
@@ -0,0 +1,163 @@
1
+ //! One crossing per server function (PLAN §4.3). The request is `POST /_weft/fn/:id` with the JSON body `{ "input": … }`:
2
+ //!
3
+ //! ```text
4
+ //! weft-ctx: base64url(JSON of the Envelope)
5
+ //! weft-sig: hex(HMAC-SHA256(secret, "weft1\n" + id + "\n" + weft-ctx + "\n" + body))
6
+ //! ```
7
+ //!
8
+ //! The signature covers the function and the body as well as the claims, so a signed call can't be replayed against
9
+ //! another function or with another input, and `iat` bounds how long it can be replayed at all.
10
+
11
+ use std::fmt::Write as _;
12
+
13
+ use base64::Engine;
14
+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
15
+ use hmac::{Hmac, KeyInit, Mac};
16
+ use sha2::Sha256;
17
+
18
+ pub use crate::ctx::Envelope;
19
+
20
+ /// How far `iat` may be from this machine's clock.
21
+ pub const MAX_SKEW_MS: u64 = 60_000;
22
+
23
+ /// Why a call was refused before any handler ran.
24
+ #[derive(Debug, PartialEq, Eq)]
25
+ pub enum Refused {
26
+ /// No `weft-ctx`/`weft-sig`, or they don't decode.
27
+ Malformed,
28
+ /// The signature doesn't match: a wrong secret, or a changed call.
29
+ BadSignature,
30
+ /// `iat` is too far from now.
31
+ Stale,
32
+ }
33
+
34
+ impl std::fmt::Display for Refused {
35
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36
+ f.write_str(match self {
37
+ Refused::Malformed => "no readable weft-ctx and weft-sig headers",
38
+ Refused::BadSignature => {
39
+ "the signature doesn't match: is WEFT_RUST_SECRET the same on both sides?"
40
+ }
41
+ Refused::Stale => "the call was signed too long ago (or a clock is off)",
42
+ })
43
+ }
44
+ }
45
+
46
+ fn keyed(secret: &[u8], id: &str, ctx: &str, body: &[u8]) -> Hmac<Sha256> {
47
+ let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("HMAC takes a key of any length");
48
+ mac.update(b"weft1\n");
49
+ mac.update(id.as_bytes());
50
+ mac.update(b"\n");
51
+ mac.update(ctx.as_bytes());
52
+ mac.update(b"\n");
53
+ mac.update(body);
54
+ mac
55
+ }
56
+
57
+ fn hex_decode(s: &str) -> Option<Vec<u8>> {
58
+ if !s.len().is_multiple_of(2) {
59
+ return None;
60
+ }
61
+ (0..s.len())
62
+ .step_by(2)
63
+ .map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
64
+ .collect()
65
+ }
66
+
67
+ /// The `weft-sig` for a call. TS is the signer; this is here for tests and hand-written callers.
68
+ pub fn sign(secret: &[u8], id: &str, ctx: &str, body: &[u8]) -> String {
69
+ let mut hex = String::with_capacity(64);
70
+ for b in keyed(secret, id, ctx, body).finalize().into_bytes() {
71
+ let _ = write!(hex, "{b:02x}");
72
+ }
73
+ hex
74
+ }
75
+
76
+ /// Check the signature (in constant time), then decode the envelope and check its age against `now_ms`.
77
+ pub fn verify(
78
+ secret: &[u8],
79
+ id: &str,
80
+ ctx: &str,
81
+ sig: &str,
82
+ body: &[u8],
83
+ now_ms: u64,
84
+ ) -> Result<Envelope, Refused> {
85
+ let sig = hex_decode(sig).ok_or(Refused::Malformed)?;
86
+ keyed(secret, id, ctx, body)
87
+ .verify_slice(&sig)
88
+ .map_err(|_| Refused::BadSignature)?;
89
+ let json = URL_SAFE_NO_PAD
90
+ .decode(ctx)
91
+ .map_err(|_| Refused::Malformed)?;
92
+ let envelope: Envelope = serde_json::from_slice(&json).map_err(|_| Refused::Malformed)?;
93
+ if envelope.iat.abs_diff(now_ms) > MAX_SKEW_MS {
94
+ return Err(Refused::Stale);
95
+ }
96
+ Ok(envelope)
97
+ }
98
+
99
+ #[cfg(test)]
100
+ mod tests {
101
+ use super::*;
102
+
103
+ const CTX: &str = "eyJ1c2VyIjp7ImlkIjoidTEiLCJyb2xlcyI6WyJhZG1pbiJdfSwicmVxdWVzdElkIjoicjEiLCJkZWFkbGluZU1zIjoxMDAwLCJ0cmFjZXBhcmVudCI6bnVsbCwiaWF0IjoxMDAwMDAwfQ";
104
+
105
+ #[test]
106
+ fn the_signature_covers_the_function_the_claims_and_the_body() {
107
+ let sig = sign(b"s3cret", "f.abc", CTX, b"{}");
108
+ let ok = verify(b"s3cret", "f.abc", CTX, &sig, b"{}", 1_000_000).unwrap();
109
+ assert_eq!(ok.user.unwrap().id, "u1");
110
+ assert_eq!(ok.deadline_ms, 1000);
111
+ assert_eq!(
112
+ verify(b"other", "f.abc", CTX, &sig, b"{}", 1_000_000).unwrap_err(),
113
+ Refused::BadSignature
114
+ );
115
+ assert_eq!(
116
+ verify(b"s3cret", "g.abc", CTX, &sig, b"{}", 1_000_000).unwrap_err(),
117
+ Refused::BadSignature
118
+ );
119
+ assert_eq!(
120
+ verify(b"s3cret", "f.abc", CTX, &sig, b"[]", 1_000_000).unwrap_err(),
121
+ Refused::BadSignature
122
+ );
123
+ assert_eq!(
124
+ verify(b"s3cret", "f.abc", "e30", &sig, b"{}", 1_000_000).unwrap_err(),
125
+ Refused::BadSignature
126
+ );
127
+ }
128
+
129
+ #[test]
130
+ fn an_old_call_is_refused_even_with_a_good_signature() {
131
+ let sig = sign(b"k", "f", CTX, b"{}");
132
+ assert!(verify(b"k", "f", CTX, &sig, b"{}", 1_000_000 + MAX_SKEW_MS).is_ok());
133
+ assert_eq!(
134
+ verify(b"k", "f", CTX, &sig, b"{}", 1_000_000 + MAX_SKEW_MS + 1).unwrap_err(),
135
+ Refused::Stale
136
+ );
137
+ }
138
+
139
+ #[test]
140
+ fn junk_headers_are_malformed_not_a_panic() {
141
+ assert_eq!(
142
+ verify(b"k", "f", CTX, "zz", b"{}", 0).unwrap_err(),
143
+ Refused::Malformed
144
+ );
145
+ assert_eq!(
146
+ verify(b"k", "f", CTX, "abc", b"{}", 0).unwrap_err(),
147
+ Refused::Malformed
148
+ );
149
+ assert_eq!(
150
+ verify(b"k", "f", CTX, "é", b"{}", 0).unwrap_err(),
151
+ Refused::Malformed
152
+ );
153
+ }
154
+
155
+ /// The same vector `packages/start/test/rust.test.mjs` signs in JS: the two sides agree byte for byte.
156
+ #[test]
157
+ fn matches_the_typescript_signer() {
158
+ assert_eq!(
159
+ sign(b"s3cret", "f.abc", CTX, b"{\"input\":{\"q\":\"x\"}}"),
160
+ "1a4c89d125bdeda4db5f2dd02045bc1044ff4ecfacff00c7f42f602ceef3ad08"
161
+ );
162
+ }
163
+ }
@@ -0,0 +1,173 @@
1
+ //! A service's functions by contract id, and what a call comes to on any transport: `weft::router` puts them behind
2
+ //! HTTP, `weft::native!` behind Node-API or a WASM export. The body of a reply is the same on all of them:
3
+ //!
4
+ //! ```text
5
+ //! { "ok": true, "value": …, "tags": […], "touched": […] }
6
+ //! { "ok": false, "error": { "code": "NotFound", "data": … } }
7
+ //! ```
8
+
9
+ use std::collections::HashMap;
10
+ use std::future::Future;
11
+ use std::pin::Pin;
12
+ use std::sync::Arc;
13
+
14
+ use serde::Serialize;
15
+ use serde::de::DeserializeOwned;
16
+ use serde_json::{Value, json};
17
+
18
+ use crate::ctx::{Base, FromBase};
19
+
20
+ pub(crate) type Boxed = Pin<Box<dyn Future<Output = Answer> + Send>>;
21
+ pub(crate) type Handler = Arc<dyn Fn(Base, Vec<u8>) -> Boxed + Send + Sync>;
22
+
23
+ /// How a call ended, before the reports are added.
24
+ pub(crate) enum Answer {
25
+ /// The value, already JSON: nothing reads it on this side.
26
+ Value(String),
27
+ Failed(Failed),
28
+ /// serde refused what TS sent: the two sides were built from different contracts.
29
+ Drift(String),
30
+ }
31
+
32
+ /// What a function's error becomes on the wire.
33
+ pub enum Failed {
34
+ /// A declared error: `{ "code": …, "data": … }`, which TS checks against the declaration.
35
+ Typed(Value),
36
+ /// Anything else. The detail stays in this log: the client gets a request id.
37
+ Internal(String),
38
+ }
39
+
40
+ /// An error type a function can return. Generated for each function that declares `errors`.
41
+ pub trait Failure {
42
+ fn failed(self) -> Failed;
43
+ }
44
+
45
+ /// The error of a function that declares none. Any `std::error::Error` converts into it, so `?` works.
46
+ #[derive(Debug)]
47
+ pub struct Error(pub String);
48
+
49
+ impl Error {
50
+ pub fn msg(message: impl Into<String>) -> Self {
51
+ Error(message.into())
52
+ }
53
+ }
54
+
55
+ impl<E: std::error::Error> From<E> for Error {
56
+ fn from(e: E) -> Self {
57
+ Error(e.to_string())
58
+ }
59
+ }
60
+
61
+ impl Failure for Error {
62
+ fn failed(self) -> Failed {
63
+ Failed::Internal(self.0)
64
+ }
65
+ }
66
+
67
+ /// A service's functions, by contract id. `contract::functions(service)` builds it; [`crate::router`] serves it.
68
+ pub struct Functions {
69
+ service: &'static str,
70
+ hash: &'static str,
71
+ table: HashMap<&'static str, Handler>,
72
+ }
73
+
74
+ impl Functions {
75
+ pub fn new(service: &'static str, hash: &'static str) -> Self {
76
+ Functions {
77
+ service,
78
+ hash,
79
+ table: HashMap::new(),
80
+ }
81
+ }
82
+
83
+ /// One function. Called by generated code.
84
+ #[must_use]
85
+ pub fn add<C, I, O, E, F, Fut>(mut self, id: &'static str, f: F) -> Self
86
+ where
87
+ C: FromBase + Send + 'static,
88
+ I: DeserializeOwned + Send + 'static,
89
+ O: Serialize,
90
+ E: Failure,
91
+ F: Fn(C, I) -> Fut + Send + Sync + 'static,
92
+ Fut: Future<Output = Result<O, E>> + Send + 'static,
93
+ {
94
+ #[derive(serde::Deserialize)]
95
+ struct Body<I> {
96
+ input: I,
97
+ }
98
+ let f = Arc::new(f);
99
+ let handler: Handler = Arc::new(move |base, body| {
100
+ let f = f.clone();
101
+ Box::pin(async move {
102
+ let input = match serde_json::from_slice::<Body<I>>(&body) {
103
+ Ok(b) => b.input,
104
+ Err(e) => return Answer::Drift(e.to_string()),
105
+ };
106
+ drop(body);
107
+ let Some(ctx) = C::from_base(base) else {
108
+ return Answer::Drift(
109
+ "the claims don't fit this function's declaration".into(),
110
+ );
111
+ };
112
+ match f(ctx, input).await {
113
+ Ok(v) => match serde_json::to_string(&v) {
114
+ Ok(v) => Answer::Value(v),
115
+ Err(e) => Answer::Failed(Failed::Internal(format!(
116
+ "the result didn't serialize: {e}"
117
+ ))),
118
+ },
119
+ Err(e) => Answer::Failed(e.failed()),
120
+ }
121
+ })
122
+ });
123
+ self.table.insert(id, handler);
124
+ self
125
+ }
126
+
127
+ pub(crate) fn service(&self) -> &'static str {
128
+ self.service
129
+ }
130
+
131
+ pub(crate) fn hash(&self) -> &'static str {
132
+ self.hash
133
+ }
134
+
135
+ pub(crate) fn ids(&self) -> Vec<&'static str> {
136
+ let mut ids: Vec<_> = self.table.keys().copied().collect();
137
+ ids.sort_unstable();
138
+ ids
139
+ }
140
+
141
+ pub(crate) fn handler(&self, id: &str) -> Option<Handler> {
142
+ self.table.get(id).cloned()
143
+ }
144
+ }
145
+
146
+ pub(crate) fn ok_body(value: &str, base: &Base) -> String {
147
+ let list = |l: Vec<String>| serde_json::to_string(&l).expect("strings serialize");
148
+ format!(
149
+ r#"{{"ok":true,"value":{value},"tags":{},"touched":{}}}"#,
150
+ list(base.tags()),
151
+ list(base.touched())
152
+ )
153
+ }
154
+
155
+ /// `detail` crosses only inside this process: TS logs it, and a client never sees it.
156
+ pub(crate) fn internal_body(base: &Base, detail: Option<&str>) -> Value {
157
+ let mut error = json!({ "code": "Internal", "requestId": base.request_id() });
158
+ if let Some(d) = detail {
159
+ error["detail"] = json!(d);
160
+ }
161
+ json!({ "ok": false, "error": error })
162
+ }
163
+
164
+ pub(crate) fn drift_body(detail: &str) -> Value {
165
+ json!({ "ok": false, "error": { "code": "Drift", "message": detail } })
166
+ }
167
+
168
+ pub(crate) fn panic_text(p: &(dyn std::any::Any + Send)) -> String {
169
+ p.downcast_ref::<&str>()
170
+ .map(std::string::ToString::to_string)
171
+ .or_else(|| p.downcast_ref::<String>().cloned())
172
+ .unwrap_or_else(|| "no message".into())
173
+ }
@@ -0,0 +1,118 @@
1
+ //! The Rust side of weft's `remote()` and `native()` (PLAN §4.3, research 36 and 39). A sidecar or an addon implements
2
+ //! server functions declared in TypeScript: Zod stays the source of truth, `.weft/contract.json` carries it, and `weft::build::generate` turns it into
3
+ //! serde types, a typed error enum per function and one `Service` trait, so a change on the TS side stops `cargo` exactly
4
+ //! where the Rust no longer matches.
5
+ //!
6
+ //! ```ignore
7
+ //! // build.rs: fn main() { weft::build::generate("search") }
8
+ //! weft::contract!();
9
+ //! use contract::*;
10
+ //!
11
+ //! struct Search;
12
+ //! impl Service for Search {
13
+ //! async fn search_docs(&self, ctx: weft::SharedCtx, input: SearchDocsInput) -> Result<SearchDocsOutput, weft::Error> {
14
+ //! ctx.touch("docs");
15
+ //! Ok(vec![])
16
+ //! }
17
+ //! }
18
+ //!
19
+ //! #[tokio::main]
20
+ //! async fn main() -> std::io::Result<()> {
21
+ //! weft::serve(weft::router(contract::functions(Search))).await
22
+ //! }
23
+ //! ```
24
+ //!
25
+ //! An addon is the same `impl Service` in a `cdylib`, with one line in place of `main`:
26
+ //!
27
+ //! ```ignore
28
+ //! weft::native!(contract::functions(Media));
29
+ //! ```
30
+ //!
31
+ //! TS keeps the public edge, sessions, CSRF, the cache and validation on both sides of the call. Rust only sees input
32
+ //! that already passed, and claims, never cookies.
33
+
34
+ #[cfg(feature = "build")]
35
+ pub mod build;
36
+
37
+ #[cfg(feature = "core")]
38
+ #[cfg_attr(
39
+ not(any(feature = "server", feature = "native")),
40
+ allow(
41
+ dead_code,
42
+ reason = "`core` alone is what generated code names; the rest is for `server` and `native`"
43
+ )
44
+ )]
45
+ mod ctx;
46
+ #[cfg(feature = "server")]
47
+ mod envelope;
48
+ #[cfg(feature = "core")]
49
+ #[cfg_attr(
50
+ not(any(feature = "server", feature = "native")),
51
+ allow(
52
+ dead_code,
53
+ reason = "`core` alone is what generated code names; the rest is for `server` and `native`"
54
+ )
55
+ )]
56
+ mod functions;
57
+ #[cfg(feature = "native")]
58
+ #[doc(hidden)]
59
+ #[cfg_attr(
60
+ test,
61
+ allow(dead_code, reason = "Node-API registers exports outside test builds")
62
+ )]
63
+ pub mod native;
64
+ #[cfg(feature = "otel")]
65
+ mod otel;
66
+ #[cfg(feature = "server")]
67
+ mod server;
68
+
69
+ #[cfg(feature = "core")]
70
+ pub use ctx::{Base, Ctx, Envelope, FromBase, SharedCtx, User, UserCtx};
71
+ #[cfg(feature = "server")]
72
+ pub use envelope::{Refused, sign, verify};
73
+ #[cfg(feature = "core")]
74
+ pub use functions::{Error, Failed, Failure, Functions};
75
+ #[cfg(feature = "server")]
76
+ pub use server::{router, router_with, serve};
77
+
78
+ /// The generated contract, as `pub mod contract`: the types, the `Service` trait and `functions()`.
79
+ #[macro_export]
80
+ macro_rules! contract {
81
+ () => {
82
+ #[allow(dead_code, unused, clippy::all)]
83
+ pub mod contract {
84
+ include!(concat!(env!("OUT_DIR"), "/weft_contract.rs"));
85
+ }
86
+ };
87
+ }
88
+
89
+ /// This crate's functions as a Node-API addon, or a `.wasm` on that target: a `native()` crate's one line of glue.
90
+ #[cfg(feature = "native")]
91
+ #[macro_export]
92
+ macro_rules! native {
93
+ ($functions:expr) => {
94
+ #[cfg(not(target_family = "wasm"))]
95
+ ::weft::__rt::napi::ctor::declarative::ctor! {
96
+ #[ctor(unsafe)]
97
+ fn __weft_native() {
98
+ ::weft::native::install(|| $functions)
99
+ }
100
+ }
101
+ #[cfg(target_family = "wasm")]
102
+ #[unsafe(no_mangle)]
103
+ pub extern "C" fn weft_init() {
104
+ ::weft::native::install(|| $functions)
105
+ }
106
+ };
107
+ }
108
+
109
+ /// What generated code names, so an app doesn't have to depend on what its schemas happen to use.
110
+ #[cfg(feature = "core")]
111
+ #[doc(hidden)]
112
+ pub mod __rt {
113
+ pub use chrono;
114
+ #[cfg(all(feature = "native", not(target_family = "wasm")))]
115
+ pub use napi;
116
+ pub use regress;
117
+ pub use uuid;
118
+ }