convex-rust 0.0.0-stage → 0.1.0

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 (90) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +137 -2
  3. package/dist/cli/bin.d.ts +2 -0
  4. package/dist/cli/bin.js +200 -0
  5. package/dist/cli/build.d.ts +146 -0
  6. package/dist/cli/build.js +902 -0
  7. package/dist/cli/demangle.d.ts +7 -0
  8. package/dist/cli/demangle.js +328 -0
  9. package/dist/cli/diagnostics.d.ts +34 -0
  10. package/dist/cli/diagnostics.js +296 -0
  11. package/dist/cli/generate.d.ts +29 -0
  12. package/dist/cli/generate.js +82 -0
  13. package/dist/cli/manifest.d.ts +24 -0
  14. package/dist/cli/manifest.js +75 -0
  15. package/dist/cli/rustApi.d.ts +78 -0
  16. package/dist/cli/rustApi.js +609 -0
  17. package/dist/cli/scaffold.d.ts +22 -0
  18. package/dist/cli/scaffold.js +114 -0
  19. package/dist/cli/shim.d.ts +30 -0
  20. package/dist/cli/shim.js +140 -0
  21. package/dist/cli/size.d.ts +17 -0
  22. package/dist/cli/size.js +73 -0
  23. package/dist/cli/split.d.ts +20 -0
  24. package/dist/cli/split.js +140 -0
  25. package/dist/cli/symbols.d.ts +30 -0
  26. package/dist/cli/symbols.js +133 -0
  27. package/dist/cli/toolchain.d.ts +53 -0
  28. package/dist/cli/toolchain.js +167 -0
  29. package/dist/cli/validators.d.ts +6 -0
  30. package/dist/cli/validators.js +66 -0
  31. package/dist/cli/wasisdk.d.ts +82 -0
  32. package/dist/cli/wasisdk.js +220 -0
  33. package/dist/cli/wasmHints.d.ts +29 -0
  34. package/dist/cli/wasmHints.js +172 -0
  35. package/dist/runtime/base64.d.ts +4 -0
  36. package/dist/runtime/base64.js +77 -0
  37. package/dist/runtime/crypto.d.ts +7 -0
  38. package/dist/runtime/crypto.js +421 -0
  39. package/dist/runtime/fastjson.d.ts +3 -0
  40. package/dist/runtime/fastjson.js +170 -0
  41. package/dist/runtime/host.d.ts +73 -0
  42. package/dist/runtime/host.js +1230 -0
  43. package/dist/runtime/http.d.ts +40 -0
  44. package/dist/runtime/http.js +157 -0
  45. package/dist/runtime/index.d.ts +2 -0
  46. package/dist/runtime/index.js +1 -0
  47. package/dist/runtime/intl.d.ts +5 -0
  48. package/dist/runtime/intl.js +69 -0
  49. package/dist/runtime/tmpfs.d.ts +129 -0
  50. package/dist/runtime/tmpfs.js +624 -0
  51. package/dist/runtime/wasi.d.ts +33 -0
  52. package/dist/runtime/wasi.js +216 -0
  53. package/package.json +57 -4
  54. package/rust/convex_fn/Cargo.toml +32 -0
  55. package/rust/convex_fn/LICENSE +201 -0
  56. package/rust/convex_fn/build.rs +3 -0
  57. package/rust/convex_fn/src/args.rs +416 -0
  58. package/rust/convex_fn/src/crypto.rs +237 -0
  59. package/rust/convex_fn/src/ctx.rs +1044 -0
  60. package/rust/convex_fn/src/dns.rs +356 -0
  61. package/rust/convex_fn/src/error.rs +179 -0
  62. package/rust/convex_fn/src/executor.rs +577 -0
  63. package/rust/convex_fn/src/fanout.rs +106 -0
  64. package/rust/convex_fn/src/fs.rs +19 -0
  65. package/rust/convex_fn/src/function_ref.rs +219 -0
  66. package/rust/convex_fn/src/http.rs +441 -0
  67. package/rust/convex_fn/src/intl.rs +75 -0
  68. package/rust/convex_fn/src/lib.rs +242 -0
  69. package/rust/convex_fn/src/logging.rs +222 -0
  70. package/rust/convex_fn/src/native.rs +360 -0
  71. package/rust/convex_fn/src/net.rs +122 -0
  72. package/rust/convex_fn/src/sys.rs +178 -0
  73. package/rust/convex_fn/src/values.rs +121 -0
  74. package/rust/convex_fn/src/wire.rs +579 -0
  75. package/rust/convex_fn_macros/Cargo.toml +13 -0
  76. package/rust/convex_fn_macros/LICENSE +201 -0
  77. package/rust/convex_fn_macros/src/derive.rs +632 -0
  78. package/rust/convex_fn_macros/src/lib.rs +338 -0
  79. package/rust/convex_reqwest/Cargo.toml +69 -0
  80. package/rust/convex_reqwest/LICENSE +201 -0
  81. package/rust/convex_reqwest/src/blocking.rs +287 -0
  82. package/rust/convex_reqwest/src/body.rs +152 -0
  83. package/rust/convex_reqwest/src/client.rs +359 -0
  84. package/rust/convex_reqwest/src/config_types.rs +150 -0
  85. package/rust/convex_reqwest/src/error.rs +241 -0
  86. package/rust/convex_reqwest/src/into_url.rs +57 -0
  87. package/rust/convex_reqwest/src/lib.rs +42 -0
  88. package/rust/convex_reqwest/src/multipart.rs +140 -0
  89. package/rust/convex_reqwest/src/request.rs +358 -0
  90. package/rust/convex_reqwest/src/response.rs +310 -0
@@ -0,0 +1,356 @@
1
+ //! DNS lookups over HTTPS (RFC 8484 JSON API, as served by Cloudflare and
2
+ //! Google). A wasm action has no sockets, so `dns.resolve*` goes through the
3
+ //! host `fetch` instead.
4
+ //!
5
+ //! ```ignore
6
+ //! let mx = convex_fn::dns::resolve("example.com", RecordType::Mx).await?;
7
+ //! for a in mx {
8
+ //! if let Record::Mx { preference, exchange } = a.record { /* ... */ }
9
+ //! }
10
+ //! ```
11
+ //!
12
+ //! The default endpoint is `https://cloudflare-dns.com/dns-query`; use
13
+ //! [`Resolver::new`] for another JSON endpoint (for example
14
+ //! `https://dns.google/resolve`).
15
+
16
+ use std::net::{Ipv4Addr, Ipv6Addr};
17
+
18
+ use serde_json::Value;
19
+
20
+ use crate::{Ctx, Error, FetchRequest, Result};
21
+
22
+ /// Cloudflare's DNS-over-HTTPS JSON endpoint.
23
+ pub const CLOUDFLARE: &str = "https://cloudflare-dns.com/dns-query";
24
+ /// Google's DNS-over-HTTPS JSON endpoint.
25
+ pub const GOOGLE: &str = "https://dns.google/resolve";
26
+
27
+ /// Record types [`resolve`] understands.
28
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
29
+ pub enum RecordType {
30
+ A,
31
+ Ns,
32
+ Cname,
33
+ Mx,
34
+ Txt,
35
+ Aaaa,
36
+ Srv,
37
+ }
38
+
39
+ impl RecordType {
40
+ /// The IANA type number.
41
+ pub const fn number(self) -> u16 {
42
+ match self {
43
+ RecordType::A => 1,
44
+ RecordType::Ns => 2,
45
+ RecordType::Cname => 5,
46
+ RecordType::Mx => 15,
47
+ RecordType::Txt => 16,
48
+ RecordType::Aaaa => 28,
49
+ RecordType::Srv => 33,
50
+ }
51
+ }
52
+
53
+ pub const fn as_str(self) -> &'static str {
54
+ match self {
55
+ RecordType::A => "A",
56
+ RecordType::Ns => "NS",
57
+ RecordType::Cname => "CNAME",
58
+ RecordType::Mx => "MX",
59
+ RecordType::Txt => "TXT",
60
+ RecordType::Aaaa => "AAAA",
61
+ RecordType::Srv => "SRV",
62
+ }
63
+ }
64
+
65
+ /// Parses `"MX"`, `"txt"`, ... (ASCII case-insensitive).
66
+ pub fn parse(s: &str) -> Option<Self> {
67
+ [
68
+ RecordType::A,
69
+ RecordType::Ns,
70
+ RecordType::Cname,
71
+ RecordType::Mx,
72
+ RecordType::Txt,
73
+ RecordType::Aaaa,
74
+ RecordType::Srv,
75
+ ]
76
+ .into_iter()
77
+ .find(|t| t.as_str().eq_ignore_ascii_case(s))
78
+ }
79
+ }
80
+
81
+ /// A resolved record's data.
82
+ #[derive(Clone, Debug, PartialEq, Eq)]
83
+ pub enum Record {
84
+ A(Ipv4Addr),
85
+ Aaaa(Ipv6Addr),
86
+ /// Host name, without the trailing dot.
87
+ Ns(String),
88
+ Cname(String),
89
+ Mx {
90
+ preference: u16,
91
+ exchange: String,
92
+ },
93
+ /// The character-strings of the record concatenated, as Node's `resolveTxt(...).join("")`.
94
+ Txt(String),
95
+ Srv {
96
+ priority: u16,
97
+ weight: u16,
98
+ port: u16,
99
+ target: String,
100
+ },
101
+ }
102
+
103
+ /// One answer: the owner name, its TTL in seconds and the data.
104
+ #[derive(Clone, Debug, PartialEq, Eq)]
105
+ pub struct Answer {
106
+ pub name: String,
107
+ pub ttl: u32,
108
+ pub record: Record,
109
+ }
110
+
111
+ /// A DNS-over-HTTPS client.
112
+ #[derive(Clone, Debug)]
113
+ pub struct Resolver {
114
+ endpoint: String,
115
+ }
116
+
117
+ impl Default for Resolver {
118
+ fn default() -> Self {
119
+ Self::new(CLOUDFLARE)
120
+ }
121
+ }
122
+
123
+ impl Resolver {
124
+ /// A resolver using the given JSON DoH endpoint.
125
+ pub fn new(endpoint: impl Into<String>) -> Self {
126
+ Self {
127
+ endpoint: endpoint.into(),
128
+ }
129
+ }
130
+
131
+ /// Looks up `name`. A name that does not exist (NXDOMAIN) or has no
132
+ /// records of `rtype` gives an empty list; a server failure is an error.
133
+ pub async fn resolve(&self, name: &str, rtype: RecordType) -> Result<Vec<Answer>> {
134
+ let url = self.url(name, rtype);
135
+ let resp = Ctx::default()
136
+ .fetch(FetchRequest::get(url).header("accept", "application/dns-json"))
137
+ .await?;
138
+ if resp.status != 200 {
139
+ return Err(Error::msg(format!(
140
+ "dns: {} answered {} for {name} {}",
141
+ self.endpoint,
142
+ resp.status,
143
+ rtype.as_str()
144
+ )));
145
+ }
146
+ parse_response(&resp.body, rtype)
147
+ }
148
+
149
+ fn url(&self, name: &str, rtype: RecordType) -> String {
150
+ let sep = if self.endpoint.contains('?') {
151
+ '&'
152
+ } else {
153
+ '?'
154
+ };
155
+ format!(
156
+ "{}{sep}name={}&type={}",
157
+ self.endpoint,
158
+ percent_encode(name),
159
+ rtype.as_str()
160
+ )
161
+ }
162
+ }
163
+
164
+ /// [`Resolver::resolve`] with the default (Cloudflare) endpoint.
165
+ pub async fn resolve(name: &str, rtype: RecordType) -> Result<Vec<Answer>> {
166
+ Resolver::default().resolve(name, rtype).await
167
+ }
168
+
169
+ fn percent_encode(s: &str) -> String {
170
+ let mut out = String::with_capacity(s.len());
171
+ for b in s.bytes() {
172
+ if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_') {
173
+ out.push(b as char);
174
+ } else {
175
+ out.push_str(&format!("%{b:02X}"));
176
+ }
177
+ }
178
+ out
179
+ }
180
+
181
+ /// Parses a DoH JSON response, keeping the answers of type `rtype` (a CNAME
182
+ /// chain in front of an A answer is dropped).
183
+ pub fn parse_response(body: &[u8], rtype: RecordType) -> Result<Vec<Answer>> {
184
+ let v: Value = serde_json::from_slice(body)?;
185
+ // RCODE: 0 NOERROR, 3 NXDOMAIN (no such name: nothing to return).
186
+ match v.get("Status").and_then(Value::as_u64) {
187
+ Some(0) | Some(3) => {}
188
+ Some(code) => {
189
+ return Err(Error::msg(format!("dns: server failure (RCODE {code})")));
190
+ }
191
+ None => return Err(Error::msg("dns: response has no Status")),
192
+ }
193
+ let mut out = vec![];
194
+ for a in v
195
+ .get("Answer")
196
+ .and_then(Value::as_array)
197
+ .map_or(&[][..], Vec::as_slice)
198
+ {
199
+ if a.get("type").and_then(Value::as_u64) != Some(u64::from(rtype.number())) {
200
+ continue;
201
+ }
202
+ let data = a.get("data").and_then(Value::as_str).unwrap_or_default();
203
+ let Some(record) = parse_data(rtype, data) else {
204
+ return Err(Error::msg(format!(
205
+ "dns: cannot parse {} data {data:?}",
206
+ rtype.as_str()
207
+ )));
208
+ };
209
+ out.push(Answer {
210
+ name: trim_dot(a.get("name").and_then(Value::as_str).unwrap_or_default()),
211
+ ttl: a.get("TTL").and_then(Value::as_u64).unwrap_or(0) as u32,
212
+ record,
213
+ });
214
+ }
215
+ Ok(out)
216
+ }
217
+
218
+ fn trim_dot(s: &str) -> String {
219
+ s.strip_suffix('.').unwrap_or(s).to_string()
220
+ }
221
+
222
+ fn parse_data(rtype: RecordType, data: &str) -> Option<Record> {
223
+ let mut it = data.split_whitespace();
224
+ Some(match rtype {
225
+ RecordType::A => Record::A(data.trim().parse().ok()?),
226
+ RecordType::Aaaa => Record::Aaaa(data.trim().parse().ok()?),
227
+ RecordType::Ns => Record::Ns(trim_dot(data.trim())),
228
+ RecordType::Cname => Record::Cname(trim_dot(data.trim())),
229
+ RecordType::Mx => Record::Mx {
230
+ preference: it.next()?.parse().ok()?,
231
+ exchange: trim_dot(it.next()?),
232
+ },
233
+ RecordType::Srv => Record::Srv {
234
+ priority: it.next()?.parse().ok()?,
235
+ weight: it.next()?.parse().ok()?,
236
+ port: it.next()?.parse().ok()?,
237
+ target: trim_dot(it.next()?),
238
+ },
239
+ RecordType::Txt => Record::Txt(unquote_txt(data)),
240
+ })
241
+ }
242
+
243
+ /// `"a" "b"` -> `ab`; text without quotes is returned as is.
244
+ fn unquote_txt(data: &str) -> String {
245
+ let t = data.trim();
246
+ if !t.starts_with('"') {
247
+ return t.to_string();
248
+ }
249
+ let mut out = String::new();
250
+ let mut chars = t.chars();
251
+ let mut in_str = false;
252
+ while let Some(c) = chars.next() {
253
+ match (in_str, c) {
254
+ (false, '"') => in_str = true,
255
+ (true, '"') => in_str = false,
256
+ (true, '\\') => match chars.next() {
257
+ Some(d) if d.is_ascii_digit() => {
258
+ // \DDD decimal escape
259
+ let mut n = d.to_digit(10).unwrap_or(0);
260
+ for _ in 0..2 {
261
+ if let Some(e) = chars.next() {
262
+ n = n * 10 + e.to_digit(10).unwrap_or(0);
263
+ }
264
+ }
265
+ out.push(char::from_u32(n).unwrap_or('?'));
266
+ }
267
+ Some(d) => out.push(d),
268
+ None => {}
269
+ },
270
+ (true, c) => out.push(c),
271
+ (false, _) => {}
272
+ }
273
+ }
274
+ out
275
+ }
276
+
277
+ #[cfg(test)]
278
+ mod tests {
279
+ use super::*;
280
+
281
+ #[test]
282
+ fn parses_mx_with_cname_chain_and_dots() {
283
+ let body = br#"{"Status":0,"Answer":[
284
+ {"name":"www.x.test.","type":5,"TTL":60,"data":"x.test."},
285
+ {"name":"x.test.","type":15,"TTL":300,"data":"10 mx1.x.test."},
286
+ {"name":"x.test.","type":15,"TTL":300,"data":"20 mx2.x.test."}]}"#;
287
+ let a = parse_response(body, RecordType::Mx).unwrap();
288
+ assert_eq!(a.len(), 2);
289
+ assert_eq!(a[0].name, "x.test");
290
+ assert_eq!(a[0].ttl, 300);
291
+ assert_eq!(
292
+ a[1].record,
293
+ Record::Mx {
294
+ preference: 20,
295
+ exchange: "mx2.x.test".into()
296
+ }
297
+ );
298
+ }
299
+
300
+ #[test]
301
+ fn parses_a_aaaa_srv_txt() {
302
+ let a = parse_response(
303
+ br#"{"Status":0,"Answer":[{"name":"h.","type":1,"TTL":1,"data":"1.2.3.4"}]}"#,
304
+ RecordType::A,
305
+ )
306
+ .unwrap();
307
+ assert_eq!(a[0].record, Record::A(Ipv4Addr::new(1, 2, 3, 4)));
308
+ let a = parse_response(
309
+ br#"{"Status":0,"Answer":[{"name":"h.","type":28,"TTL":1,"data":"2606:4700::1"}]}"#,
310
+ RecordType::Aaaa,
311
+ )
312
+ .unwrap();
313
+ assert_eq!(a[0].record, Record::Aaaa("2606:4700::1".parse().unwrap()));
314
+ let a = parse_response(
315
+ br#"{"Status":0,"Answer":[{"name":"_s._tcp.h.","type":33,"TTL":1,"data":"1 2 443 t.h."}]}"#,
316
+ RecordType::Srv,
317
+ )
318
+ .unwrap();
319
+ assert_eq!(
320
+ a[0].record,
321
+ Record::Srv {
322
+ priority: 1,
323
+ weight: 2,
324
+ port: 443,
325
+ target: "t.h".into()
326
+ }
327
+ );
328
+ let a = parse_response(
329
+ br#"{"Status":0,"Answer":[{"name":"h.","type":16,"TTL":1,"data":"\"v=spf1 \" \"a \\\"q\\\" -all\""}]}"#,
330
+ RecordType::Txt,
331
+ )
332
+ .unwrap();
333
+ assert_eq!(a[0].record, Record::Txt("v=spf1 a \"q\" -all".into()));
334
+ }
335
+
336
+ #[test]
337
+ fn nxdomain_is_empty_servfail_is_error() {
338
+ assert!(parse_response(br#"{"Status":3}"#, RecordType::A)
339
+ .unwrap()
340
+ .is_empty());
341
+ assert!(parse_response(br#"{"Status":2}"#, RecordType::A).is_err());
342
+ assert!(parse_response(br#"{"Answer":[]}"#, RecordType::A).is_err());
343
+ }
344
+
345
+ #[test]
346
+ fn url_encoding_and_types() {
347
+ let r = Resolver::new("https://dns.google/resolve");
348
+ assert_eq!(
349
+ r.url("exa mple.com", RecordType::Txt),
350
+ "https://dns.google/resolve?name=exa%20mple.com&type=TXT"
351
+ );
352
+ assert_eq!(RecordType::parse("mx"), Some(RecordType::Mx));
353
+ assert_eq!(RecordType::parse("zz"), None);
354
+ assert_eq!(RecordType::Srv.number(), 33);
355
+ }
356
+ }
@@ -0,0 +1,179 @@
1
+ use std::fmt;
2
+
3
+ use serde_json::Value;
4
+
5
+ pub type Result<T, E = Error> = std::result::Result<T, E>;
6
+
7
+ /// An action error. Like `anyhow::Error`, any `std::error::Error` converts into
8
+ /// it with `?`. `Error::Convex` reaches clients as a `ConvexError` with `data`.
9
+ #[derive(Debug, Clone)]
10
+ pub enum Error {
11
+ /// Application error with structured data (`throw new ConvexError(data)`).
12
+ Convex { message: String, data: Value },
13
+ /// Any other error; clients see "Server Error" plus the message in logs.
14
+ Message(String),
15
+ }
16
+
17
+ /// Constructor for application errors, mirroring the JS `ConvexError` class.
18
+ pub struct ConvexError;
19
+
20
+ impl ConvexError {
21
+ #[allow(clippy::new_ret_no_self)]
22
+ pub fn new(data: impl Into<Value>) -> Error {
23
+ Error::convex(data)
24
+ }
25
+ }
26
+
27
+ impl Error {
28
+ /// An application error with structured data (`throw new ConvexError(data)`).
29
+ pub fn convex(data: impl Into<Value>) -> Self {
30
+ let data = data.into();
31
+ let message = match &data {
32
+ Value::String(s) => s.clone(),
33
+ other => other.to_string(),
34
+ };
35
+ Error::Convex { message, data }
36
+ }
37
+
38
+ /// The `ConvexError` data, if this is an application error.
39
+ pub fn data(&self) -> Option<&Value> {
40
+ match self {
41
+ Error::Convex { data, .. } => Some(data),
42
+ Error::Message(_) => None,
43
+ }
44
+ }
45
+
46
+ pub fn msg(message: impl Into<String>) -> Self {
47
+ Error::Message(message.into())
48
+ }
49
+
50
+ pub fn message(&self) -> &str {
51
+ match self {
52
+ Error::Convex { message, .. } | Error::Message(message) => message,
53
+ }
54
+ }
55
+
56
+ pub(crate) fn to_json(&self) -> Value {
57
+ match self {
58
+ Error::Convex { message, data } => {
59
+ serde_json::json!({ "message": message, "data": data, "kind": "convex" })
60
+ }
61
+ Error::Message(message) => serde_json::json!({ "message": message, "kind": "user" }),
62
+ }
63
+ }
64
+
65
+ /// Parses `{"message": …, "data"?: …}` as returned in task events.
66
+ pub(crate) fn from_json(v: &Value) -> Self {
67
+ let message = v
68
+ .get("message")
69
+ .and_then(Value::as_str)
70
+ .unwrap_or("unknown error")
71
+ .to_string();
72
+ match v.get("data") {
73
+ // A present `data` (even null) is a ConvexError, like `new ConvexError(null)` in JS.
74
+ Some(data) => Error::Convex {
75
+ message,
76
+ data: data.clone(),
77
+ },
78
+ None => Error::Message(message),
79
+ }
80
+ }
81
+ }
82
+
83
+ impl fmt::Display for Error {
84
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85
+ f.write_str(self.message())
86
+ }
87
+ }
88
+
89
+ impl<E: std::error::Error> From<E> for Error {
90
+ fn from(e: E) -> Self {
91
+ Error::Message(e.to_string())
92
+ }
93
+ }
94
+
95
+ /// Formats the panic report: `Uncaught panic: <message> at <file:line:col>`.
96
+ /// The host (`PANIC_LOG_PREFIX` in the fork's rust/core.rs) takes the last such
97
+ /// level-3 log line as the error message when the abort trap follows.
98
+ #[cfg_attr(
99
+ not(any(target_arch = "wasm32", convex_native, test)),
100
+ allow(dead_code)
101
+ )]
102
+ pub(crate) fn format_panic(message: &str, location: Option<(&str, u32, u32)>) -> String {
103
+ match location {
104
+ Some((file, line, col)) => format!("Uncaught panic: {message} at {file}:{line}:{col}"),
105
+ None => format!("Uncaught panic: {message}"),
106
+ }
107
+ }
108
+
109
+ /// Reports panics (message + location) as an error log line before the abort
110
+ /// trap, so the host can surface them instead of "unreachable".
111
+ #[cfg(any(target_arch = "wasm32", convex_native))]
112
+ pub(crate) fn install_panic_hook() {
113
+ static ONCE: std::sync::Once = std::sync::Once::new();
114
+ ONCE.call_once(|| {
115
+ std::panic::set_hook(Box::new(|info| {
116
+ let payload = info.payload();
117
+ let message = payload
118
+ .downcast_ref::<&str>()
119
+ .map(|s| s.to_string())
120
+ .or_else(|| payload.downcast_ref::<String>().cloned())
121
+ .unwrap_or_else(|| "Box<dyn Any>".to_string());
122
+ let location = info.location().map(|l| (l.file(), l.line(), l.column()));
123
+ #[cfg(convex_native)]
124
+ crate::native::record_panic(match location {
125
+ Some((file, line, col)) => format!("{message} at {file}:{line}:{col}"),
126
+ None => message.clone(),
127
+ });
128
+ crate::sys::log_line(
129
+ crate::wire::LogLevel::Error,
130
+ &format_panic(&message, location),
131
+ );
132
+ // Distinguishes the real panic line from a user log line with the same prefix.
133
+ crate::sys::panic_marker();
134
+ }));
135
+ });
136
+ }
137
+
138
+ #[cfg(test)]
139
+ mod tests {
140
+ use serde_json::json;
141
+
142
+ use super::{format_panic, ConvexError, Error};
143
+
144
+ #[test]
145
+ fn convex_error_json_round_trip() {
146
+ let e = Error::convex(json!({ "code": 7, "tags": ["a"] }));
147
+ assert_eq!(e.data(), Some(&json!({ "code": 7, "tags": ["a"] })));
148
+ let j = e.to_json();
149
+ assert_eq!(j["kind"], "convex");
150
+ let back = Error::from_json(&j);
151
+ assert_eq!(back.data(), e.data());
152
+ assert_eq!(back.message(), e.message());
153
+ // String data uses the string itself as message.
154
+ let s = ConvexError::new("nope");
155
+ assert_eq!(s.message(), "nope");
156
+ assert_eq!(Error::from_json(&s.to_json()).data(), Some(&json!("nope")));
157
+ }
158
+
159
+ #[test]
160
+ fn message_error_json_round_trip() {
161
+ let e = Error::msg("boom");
162
+ assert_eq!(e.data(), None);
163
+ let j = e.to_json();
164
+ assert_eq!(j, json!({ "message": "boom", "kind": "user" }));
165
+ let back = Error::from_json(&j);
166
+ assert_eq!(back.data(), None);
167
+ assert_eq!(back.message(), "boom");
168
+ // Missing message falls back.
169
+ assert_eq!(Error::from_json(&json!({})).message(), "unknown error");
170
+ }
171
+
172
+ #[test]
173
+ fn panic_line_has_message_and_location() {
174
+ assert_eq!(
175
+ format_panic("kaboom", Some(("src/spec.rs", 12, 5))),
176
+ "Uncaught panic: kaboom at src/spec.rs:12:5"
177
+ );
178
+ }
179
+ }