@weft-js/start 0.1.1 → 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.
- package/README.md +1278 -12
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/README.md +322 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +38 -8
- package/src/asset-receipt.d.mts +13 -0
- package/src/asset-receipt.mjs +61 -0
- package/src/auth/better-auth.ts +215 -17
- package/src/auth/drizzle.ts +1 -1
- package/src/auth.ts +21 -6
- package/src/boot.ts +19 -8
- package/src/bus/postgres.ts +32 -19
- package/src/bus.ts +7 -4
- package/src/{capture.mjs → capture.ts} +34 -1
- package/src/changes-sql.d.mts +8 -2
- package/src/changes-sql.mjs +26 -10
- package/src/changes.ts +165 -66
- package/src/channel/connections.ts +216 -0
- package/src/channel/socket.ts +396 -0
- package/src/channel-fake.ts +19 -4
- package/src/channel.ts +120 -492
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +144 -157
- package/src/data/cache.ts +354 -0
- package/src/data/definitions.ts +94 -0
- package/src/data/draft.ts +104 -0
- package/src/data/foundation.ts +95 -0
- package/src/data/live-api.ts +22 -0
- package/src/data/mutation.ts +138 -0
- package/src/data/primitives.ts +135 -0
- package/src/data/query.ts +106 -0
- package/src/data/transport.ts +121 -0
- package/src/data/types.ts +303 -0
- package/src/data.ts +69 -894
- package/src/db/bun-sqlite.d.ts +4 -1
- package/src/db/changes-mysql.ts +61 -21
- package/src/db/changes-postgres.ts +197 -118
- package/src/db/changes-sqlite.ts +131 -64
- package/src/db/core.ts +38 -13
- package/src/db/index.ts +20 -4
- package/src/db/mysql.ts +22 -6
- package/src/db/postgres.ts +49 -13
- package/src/db/sqlite.ts +53 -27
- package/src/derive.d.mts +4 -1
- package/src/derive.mjs +136 -39
- package/src/dev-runtime.mjs +8 -4
- package/src/exec/errors.ts +57 -0
- package/src/exec/landing.ts +66 -0
- package/src/exec/scope.ts +174 -0
- package/src/exec/touch.ts +80 -0
- package/src/exec/validation.ts +176 -0
- package/src/exec.ts +191 -341
- package/src/extension.ts +8 -2
- package/src/first-paint.ts +26 -9
- package/src/form/value.ts +15 -0
- package/src/form.tsx +132 -43
- package/src/hub/channels.ts +102 -0
- package/src/hub/connections.ts +147 -0
- package/src/hub/delivery.ts +62 -0
- package/src/hub/feed.ts +168 -0
- package/src/hub/presence.ts +64 -0
- package/src/hub/route.ts +158 -0
- package/src/hub/subscriptions.ts +212 -0
- package/src/hub/topic.ts +98 -0
- package/src/hub/tracked.ts +12 -0
- package/src/hub/types.ts +190 -0
- package/src/hub/view.ts +147 -0
- package/src/hub.ts +132 -758
- package/src/index.ts +84 -10
- package/src/inline.mjs +356 -0
- package/src/inspect/compare.mjs +162 -0
- package/src/inspect/routes.mjs +154 -0
- package/src/inspect/samples.mjs +59 -0
- package/src/inspect/url.mjs +2 -0
- package/src/inspect.mjs +146 -187
- package/src/link/election.ts +178 -0
- package/src/link/lines.ts +27 -0
- package/src/link.ts +90 -166
- package/src/list-drizzle.ts +104 -18
- package/src/list.ts +127 -36
- package/src/listen.ts +32 -6
- package/src/live/api.ts +18 -0
- package/src/live/backoff.ts +6 -0
- package/src/live/frames.ts +42 -0
- package/src/live/read.ts +77 -0
- package/src/live/sub-types.ts +24 -0
- package/src/live/transport.ts +86 -0
- package/src/live/types.ts +52 -0
- package/src/live-fake.ts +20 -5
- package/src/live-target.mjs +112 -37
- package/src/live.ts +116 -225
- package/src/media.ts +8 -1
- package/src/migrations.mjs +14 -6
- package/src/mux.ts +161 -64
- package/src/og/DefaultCard.tsx +40 -9
- package/src/og/fonts.mjs +16 -8
- package/src/og/images.mjs +43 -22
- package/src/og/index.mjs +119 -43
- package/src/og/native.mjs +11 -4
- package/src/range.mjs +15 -3
- package/src/router.ts +56 -6
- package/src/routes.mjs +73 -22
- package/src/run/admission.ts +80 -0
- package/src/run/boot.tsx +115 -0
- package/src/run/cache-presentation.ts +67 -0
- package/src/run/cache.ts +254 -0
- package/src/run/helpers.ts +197 -0
- package/src/run/listeners.ts +155 -0
- package/src/run/navigation.ts +382 -0
- package/src/run/page-skeleton.tsx +35 -0
- package/src/run/prefetch.ts +128 -0
- package/src/run/retain-view.ts +18 -0
- package/src/run/shape-preparation.ts +97 -0
- package/src/run/shape-styles.ts +72 -0
- package/src/run/source-context/pin.ts +124 -0
- package/src/run/source-context.ts +311 -0
- package/src/run.tsx +50 -178
- package/src/rust/common.ts +163 -0
- package/src/rust/native.ts +282 -0
- package/src/rust.ts +103 -229
- package/src/server/card.tsx +65 -0
- package/src/server/compression.ts +115 -0
- package/src/server/core.ts +70 -0
- package/src/server/dispatch.ts +70 -0
- package/src/server/form.ts +85 -0
- package/src/server/functions.ts +129 -0
- package/src/server/handler.ts +39 -0
- package/src/server/head.ts +96 -0
- package/src/server/media.ts +60 -0
- package/src/server/navigation-cache.ts +80 -0
- package/src/server/navigation.tsx +85 -0
- package/src/server/page-cold.ts +95 -0
- package/src/server/page-stream.tsx +181 -0
- package/src/server/page-types.ts +51 -0
- package/src/server/page-warm.ts +130 -0
- package/src/server/page.ts +69 -0
- package/src/server/payload.ts +89 -0
- package/src/server/prerender.ts +60 -0
- package/src/server/preview.tsx +67 -0
- package/src/server/render.ts +167 -0
- package/src/server/request.ts +98 -0
- package/src/server/resolver.ts +228 -0
- package/src/server/routes.ts +150 -0
- package/src/server/services.ts +69 -0
- package/src/server/text.ts +29 -0
- package/src/server/types.ts +160 -0
- package/src/server.tsx +82 -971
- package/src/shape.mjs +30 -6
- package/src/share/display.ts +50 -0
- package/src/share/network.ts +119 -0
- package/src/share/receive.ts +70 -0
- package/src/share/state.ts +30 -0
- package/src/share/tab-queue.ts +28 -0
- package/src/share-server.ts +128 -43
- package/src/share.ts +103 -209
- package/src/sheets.mjs +30 -18
- package/src/sidecar/build.mjs +170 -0
- package/src/sidecar/cargo.mjs +164 -0
- package/src/sidecar/common.mjs +17 -0
- package/src/sidecar/dev-build.mjs +91 -0
- package/src/sidecar/dev-watch.mjs +88 -0
- package/src/sidecar/manifest.mjs +19 -0
- package/src/sidecar/process.mjs +58 -0
- package/src/sidecar/refresh.mjs +19 -0
- package/src/sidecar.mjs +75 -416
- package/src/socket/check.ts +50 -0
- package/src/socket/deliver.ts +16 -0
- package/src/socket/publish.ts +56 -0
- package/src/socket/rooms.ts +41 -0
- package/src/socket/route.ts +79 -0
- package/src/socket/shared.ts +26 -0
- package/src/socket/types.ts +48 -0
- package/src/socket.ts +205 -194
- package/src/stream.ts +10 -1
- package/src/verify/app.ts +39 -0
- package/src/verify/cleanup.ts +27 -0
- package/src/verify/database.ts +11 -0
- package/src/verify/live.ts +63 -0
- package/src/verify/notify.ts +35 -0
- package/src/verify/skip.ts +19 -0
- package/src/verify/support.ts +149 -0
- package/src/verify/timing.ts +15 -0
- package/src/verify/trigger.ts +14 -0
- package/src/verify.ts +77 -176
- package/src/view.tsx +96 -11
- package/src/vite/asset-bytes.mjs +95 -0
- package/src/vite/assets.mjs +117 -0
- package/src/vite/config-hooks.mjs +166 -0
- package/src/vite/dev-hooks.mjs +103 -0
- package/src/vite/dev.mjs +102 -0
- package/src/vite/entries.mjs +145 -0
- package/src/vite/module-hooks.mjs +288 -0
- package/src/vite/route-modules.mjs +289 -0
- package/src/vite/server-functions.mjs +222 -0
- package/src/vite/shape-entry-css.mjs +187 -0
- package/src/vite/shape-override.mjs +226 -0
- package/src/vite/shared.mjs +51 -0
- package/src/vite/sheets.mjs +92 -0
- package/src/vite/state.mjs +89 -0
- package/src/vite/syntax.mjs +139 -0
- package/src/vite.mjs +152 -853
- package/src/capture.d.mts +0 -1
|
@@ -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
|
+
}
|
package/crate/src/lib.rs
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
//! `native()` (PLAN §4.3, research 39): a service's functions in-process. One crossing per function, as with a sidecar,
|
|
2
|
+
//! with no HTTP and no signature: nothing outside the process can reach it.
|
|
3
|
+
//!
|
|
4
|
+
//! A real addon runs each call on the host's worker pool, so a long one doesn't hold the event loop. A `.wasm` is the
|
|
5
|
+
//! same code behind a small ABI of its own, on whatever thread calls it. Neither brings an async runtime: this is for
|
|
6
|
+
//! CPU, and a function that waits on IO belongs in `remote()`.
|
|
7
|
+
|
|
8
|
+
use std::future::Future;
|
|
9
|
+
use std::panic::{AssertUnwindSafe, catch_unwind};
|
|
10
|
+
use std::pin::pin;
|
|
11
|
+
use std::sync::OnceLock;
|
|
12
|
+
use std::task::{Context, Poll, Waker};
|
|
13
|
+
|
|
14
|
+
use serde_json::json;
|
|
15
|
+
|
|
16
|
+
use crate::ctx::{Base, Envelope};
|
|
17
|
+
use crate::functions::{Answer, Failed, Functions, drift_body, internal_body, ok_body, panic_text};
|
|
18
|
+
|
|
19
|
+
static MAKE: OnceLock<fn() -> Functions> = OnceLock::new();
|
|
20
|
+
static FUNCTIONS: OnceLock<Functions> = OnceLock::new();
|
|
21
|
+
|
|
22
|
+
/// Called once by `weft::native!`. The service is built on the first call, not while the library loads.
|
|
23
|
+
pub fn install(make: fn() -> Functions) {
|
|
24
|
+
let _ = MAKE.set(make);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn functions() -> &'static Functions {
|
|
28
|
+
FUNCTIONS.get_or_init(|| MAKE.get().expect("weft::native! ran")())
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// `{ service, hash, functions }`: TS refuses a library built from another contract (R103).
|
|
32
|
+
fn contract() -> String {
|
|
33
|
+
let f = functions();
|
|
34
|
+
json!({ "service": f.service(), "hash": f.hash(), "functions": f.ids() }).to_string()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[cfg(not(target_family = "wasm"))]
|
|
38
|
+
fn wait<F: Future>(mut future: std::pin::Pin<&mut F>) -> F::Output {
|
|
39
|
+
struct Unpark(std::thread::Thread);
|
|
40
|
+
impl std::task::Wake for Unpark {
|
|
41
|
+
fn wake(self: std::sync::Arc<Self>) {
|
|
42
|
+
self.0.unpark();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
let waker = Waker::from(std::sync::Arc::new(Unpark(std::thread::current())));
|
|
46
|
+
let mut cx = Context::from_waker(&waker);
|
|
47
|
+
loop {
|
|
48
|
+
match future.as_mut().poll(&mut cx) {
|
|
49
|
+
Poll::Ready(v) => return v,
|
|
50
|
+
Poll::Pending => std::thread::park(),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[cfg(target_family = "wasm")]
|
|
56
|
+
fn wait<F: Future>(future: std::pin::Pin<&mut F>) -> F::Output {
|
|
57
|
+
match future.poll(&mut Context::from_waker(Waker::noop())) {
|
|
58
|
+
Poll::Ready(v) => v,
|
|
59
|
+
Poll::Pending => {
|
|
60
|
+
panic!(
|
|
61
|
+
"a `native()` function can't wait on WASM: nothing would wake it. Use `remote()` for IO"
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// One call, start to finish, on this thread.
|
|
68
|
+
fn dispatch(id: &str, ctx: &str, body: Vec<u8>) -> String {
|
|
69
|
+
let envelope: Envelope = match serde_json::from_str(ctx) {
|
|
70
|
+
Ok(e) => e,
|
|
71
|
+
Err(e) => return drift_body(&format!("the call's context didn't parse: {e}")).to_string(),
|
|
72
|
+
};
|
|
73
|
+
let Some(handler) = functions().handler(id) else {
|
|
74
|
+
return drift_body(&format!(
|
|
75
|
+
"this library has no function `{id}`: rebuild it from the current contract"
|
|
76
|
+
))
|
|
77
|
+
.to_string();
|
|
78
|
+
};
|
|
79
|
+
let base = Base::new(id, envelope);
|
|
80
|
+
let call = handler(base.clone(), body);
|
|
81
|
+
let answer = catch_unwind(AssertUnwindSafe(|| wait(pin!(call)))).unwrap_or_else(|p| {
|
|
82
|
+
Answer::Failed(Failed::Internal(format!("panicked: {}", panic_text(&*p))))
|
|
83
|
+
});
|
|
84
|
+
match answer {
|
|
85
|
+
Answer::Value(value) => ok_body(&value, &base),
|
|
86
|
+
Answer::Failed(Failed::Typed(error)) => json!({ "ok": false, "error": error }).to_string(),
|
|
87
|
+
Answer::Failed(Failed::Internal(detail)) => internal_body(&base, Some(&detail)).to_string(),
|
|
88
|
+
Answer::Drift(detail) => drift_body(&detail).to_string(),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#[cfg(not(target_family = "wasm"))]
|
|
93
|
+
mod addon {
|
|
94
|
+
use napi::bindgen_prelude::{AsyncTask, Env, Result, Task};
|
|
95
|
+
use napi_derive::napi;
|
|
96
|
+
|
|
97
|
+
pub struct Call {
|
|
98
|
+
id: String,
|
|
99
|
+
ctx: String,
|
|
100
|
+
body: Option<String>,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
impl Task for Call {
|
|
104
|
+
type Output = String;
|
|
105
|
+
type JsValue = String;
|
|
106
|
+
|
|
107
|
+
fn compute(&mut self) -> Result<String> {
|
|
108
|
+
Ok(super::dispatch(
|
|
109
|
+
&self.id,
|
|
110
|
+
&self.ctx,
|
|
111
|
+
self.body.take().unwrap_or_default().into_bytes(),
|
|
112
|
+
))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn resolve(&mut self, _: Env, reply: String) -> Result<String> {
|
|
116
|
+
Ok(reply)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#[napi]
|
|
121
|
+
pub fn call(id: String, ctx: String, body: String) -> AsyncTask<Call> {
|
|
122
|
+
AsyncTask::new(Call {
|
|
123
|
+
id,
|
|
124
|
+
ctx,
|
|
125
|
+
body: Some(body),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#[napi]
|
|
130
|
+
pub fn contract() -> String {
|
|
131
|
+
super::contract()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// A pointer and a length in one number: the only return value this ABI needs, and it fits a JS `BigInt`.
|
|
136
|
+
#[cfg(target_family = "wasm")]
|
|
137
|
+
mod wasm {
|
|
138
|
+
#[link(wasm_import_module = "weft")]
|
|
139
|
+
unsafe extern "C" {
|
|
140
|
+
fn now() -> f64;
|
|
141
|
+
fn panicked(ptr: *const u8, len: usize);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn give(text: String) -> u64 {
|
|
145
|
+
let bytes = text.into_bytes().into_boxed_slice();
|
|
146
|
+
let len = bytes.len() as u64;
|
|
147
|
+
((Box::into_raw(bytes).cast::<u8>() as u64) << 32) | len
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// # Safety
|
|
151
|
+
/// `ptr` and `len` are valid UTF-8 that outlives `'a`.
|
|
152
|
+
unsafe fn text<'a>(ptr: *const u8, len: usize) -> &'a str {
|
|
153
|
+
// SAFETY: the caller's contract above.
|
|
154
|
+
unsafe { std::str::from_utf8_unchecked(std::slice::from_raw_parts(ptr, len)) }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[unsafe(no_mangle)]
|
|
158
|
+
pub extern "C" fn weft_alloc(len: usize) -> *mut u8 {
|
|
159
|
+
Box::into_raw(vec![0u8; len].into_boxed_slice()).cast::<u8>()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// # Safety
|
|
163
|
+
/// `ptr` and `len` are what `weft_alloc`, `weft_call` or `weft_contract` handed out.
|
|
164
|
+
#[unsafe(no_mangle)]
|
|
165
|
+
pub unsafe extern "C" fn weft_free(ptr: *mut u8, len: usize) {
|
|
166
|
+
// SAFETY: the caller hands back a boxed slice of exactly `len` bytes.
|
|
167
|
+
drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)) });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// `body` is the caller's, and is taken over, as `weft_alloc` gave it.
|
|
171
|
+
///
|
|
172
|
+
/// # Safety
|
|
173
|
+
/// Each pair is valid UTF-8 from `weft_alloc`.
|
|
174
|
+
#[unsafe(no_mangle)]
|
|
175
|
+
pub unsafe extern "C" fn weft_call(
|
|
176
|
+
id: *const u8,
|
|
177
|
+
id_len: usize,
|
|
178
|
+
ctx: *const u8,
|
|
179
|
+
ctx_len: usize,
|
|
180
|
+
body: *mut u8,
|
|
181
|
+
body_len: usize,
|
|
182
|
+
) -> u64 {
|
|
183
|
+
// A panic aborts here: the instance traps, and TS starts a new one. This is how the message gets out first.
|
|
184
|
+
static HOOK: std::sync::Once = std::sync::Once::new();
|
|
185
|
+
HOOK.call_once(|| {
|
|
186
|
+
std::panic::set_hook(Box::new(|info| {
|
|
187
|
+
let message = info.to_string();
|
|
188
|
+
// SAFETY: the host copies the bytes out during the call; `message` is live and UTF-8.
|
|
189
|
+
unsafe { panicked(message.as_ptr(), message.len()) }
|
|
190
|
+
}));
|
|
191
|
+
});
|
|
192
|
+
// SAFETY: `body` is a `weft_alloc` box of `body_len` bytes, and it's ours now.
|
|
193
|
+
let body =
|
|
194
|
+
unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(body, body_len)) }.into_vec();
|
|
195
|
+
// SAFETY: the caller keeps both strings, valid UTF-8, for the whole call.
|
|
196
|
+
let (id, ctx) = unsafe { (text(id, id_len), text(ctx, ctx_len)) };
|
|
197
|
+
give(super::dispatch(id, ctx, body))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#[unsafe(no_mangle)]
|
|
201
|
+
pub extern "C" fn weft_contract() -> u64 {
|
|
202
|
+
give(super::contract())
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
pub(crate) fn time() -> f64 {
|
|
206
|
+
// SAFETY: a plain import: no arguments, no memory.
|
|
207
|
+
unsafe { now() }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#[cfg(target_family = "wasm")]
|
|
212
|
+
pub(crate) use wasm::time as now_ms;
|