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,1044 @@
1
+ //! `Ctx`: typed wrappers over the existing V8 action task names (D-008).
2
+
3
+ use std::future::Future;
4
+
5
+ use serde::{de::DeserializeOwned, Serialize};
6
+ use serde_json::{json, Value};
7
+
8
+ use crate::{
9
+ error::Error,
10
+ executor::{abandon_stream, submit, submit_cancellable, NextChunk, StreamFuture},
11
+ function_ref::{address_json, FunctionAddress, FunctionTarget},
12
+ net, sys,
13
+ wire::{self, BodyRef},
14
+ Result,
15
+ };
16
+
17
+ /// The action context, equivalent to `ctx` in a JS action.
18
+ #[derive(Clone, Debug, Default)]
19
+ pub struct Ctx {
20
+ _private: (),
21
+ }
22
+
23
+ #[derive(Clone, Debug)]
24
+ pub struct FetchRequest {
25
+ pub url: String,
26
+ pub method: String,
27
+ pub headers: Vec<(String, String)>,
28
+ pub body: Option<Vec<u8>>,
29
+ /// Redirect handling; follows redirects like Node's `fetch` by default.
30
+ pub redirect: Redirect,
31
+ }
32
+
33
+ /// `RequestInit.redirect`.
34
+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35
+ pub enum Redirect {
36
+ /// Follow up to 20 redirects (the default).
37
+ #[default]
38
+ Follow,
39
+ /// Return the 3xx response as is.
40
+ Manual,
41
+ }
42
+
43
+ /// Maximum redirect hops, as in the fetch standard.
44
+ const MAX_REDIRECTS: usize = 20;
45
+
46
+ /// Headers dropped when a redirect rewrites the request to a body-less GET.
47
+ const BODY_HEADERS: [&str; 5] = [
48
+ "content-type",
49
+ "content-length",
50
+ "content-encoding",
51
+ "content-language",
52
+ "content-location",
53
+ ];
54
+
55
+ /// Headers dropped when a redirect crosses origins.
56
+ const CREDENTIAL_HEADERS: [&str; 3] = ["authorization", "cookie", "proxy-authorization"];
57
+
58
+ impl FetchRequest {
59
+ pub fn get(url: impl Into<String>) -> Self {
60
+ Self {
61
+ url: url.into(),
62
+ method: "GET".into(),
63
+ headers: vec![],
64
+ body: None,
65
+ redirect: Redirect::Follow,
66
+ }
67
+ }
68
+
69
+ pub fn post(url: impl Into<String>, body: impl Into<Vec<u8>>) -> Self {
70
+ Self {
71
+ url: url.into(),
72
+ method: "POST".into(),
73
+ headers: vec![],
74
+ body: Some(body.into()),
75
+ redirect: Redirect::Follow,
76
+ }
77
+ }
78
+
79
+ pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
80
+ self.headers.push((name.into(), value.into()));
81
+ self
82
+ }
83
+
84
+ /// Sets the HTTP method (for example `PUT` before [`Ctx::fetch_upload`]).
85
+ pub fn method(mut self, method: impl Into<String>) -> Self {
86
+ self.method = method.into();
87
+ self
88
+ }
89
+
90
+ /// `redirect: "manual"` returns 3xx responses instead of following them.
91
+ pub fn redirect(mut self, redirect: Redirect) -> Self {
92
+ self.redirect = redirect;
93
+ self
94
+ }
95
+ }
96
+
97
+ /// A source of request-body chunks for [`Ctx::fetch_upload`] /
98
+ /// [`Ctx::fetch_stream_upload`]: `Ok(None)` ends the body, `Err` fails the
99
+ /// request. A [`FetchStream`] is one (proxying a download into an upload);
100
+ /// [`Chunks`] wraps an iterator.
101
+ pub trait ChunkSource {
102
+ fn next_chunk(&mut self) -> impl Future<Output = Result<Option<Vec<u8>>>>;
103
+ }
104
+
105
+ impl ChunkSource for FetchStream {
106
+ fn next_chunk(&mut self) -> impl Future<Output = Result<Option<Vec<u8>>>> {
107
+ self.chunk()
108
+ }
109
+ }
110
+
111
+ /// A [`ChunkSource`] over an iterator of byte chunks.
112
+ pub struct Chunks<I>(pub I);
113
+
114
+ impl<I: Iterator<Item = Vec<u8>>> ChunkSource for Chunks<I> {
115
+ async fn next_chunk(&mut self) -> Result<Option<Vec<u8>>> {
116
+ Ok(self.0.next())
117
+ }
118
+ }
119
+
120
+ /// Slices one big body into [`net::STREAM_SLICE`] chunks.
121
+ struct SliceSource<'a>(&'a [u8]);
122
+
123
+ impl ChunkSource for SliceSource<'_> {
124
+ async fn next_chunk(&mut self) -> Result<Option<Vec<u8>>> {
125
+ if self.0.is_empty() {
126
+ return Ok(None);
127
+ }
128
+ let n = self.0.len().min(net::STREAM_SLICE);
129
+ let (head, rest) = self.0.split_at(n);
130
+ self.0 = rest;
131
+ Ok(Some(head.to_vec()))
132
+ }
133
+ }
134
+
135
+ /// Source for the plain (non-uploading) fetch path.
136
+ struct NoUpload;
137
+
138
+ impl ChunkSource for NoUpload {
139
+ async fn next_chunk(&mut self) -> Result<Option<Vec<u8>>> {
140
+ Ok(None)
141
+ }
142
+ }
143
+
144
+ #[derive(Clone, Debug)]
145
+ pub struct FetchResponse {
146
+ pub status: u16,
147
+ pub status_text: String,
148
+ pub url: String,
149
+ pub headers: Vec<(String, String)>,
150
+ pub body: Vec<u8>,
151
+ }
152
+
153
+ impl FetchResponse {
154
+ pub fn text(&self) -> Result<String> {
155
+ Ok(String::from_utf8(self.body.clone())?)
156
+ }
157
+
158
+ pub fn json<T: DeserializeOwned>(&self) -> Result<T> {
159
+ Ok(serde_json::from_slice(&self.body)?)
160
+ }
161
+ }
162
+
163
+ /// A fetch response whose body is read incrementally, chunk by chunk, as the
164
+ /// host delivers `chunk` events (contracts/ABI.md "Events"). Created by
165
+ /// [`Ctx::fetch_stream`]. Dropping it early discards the rest of the body.
166
+ #[derive(Debug)]
167
+ pub struct FetchStream {
168
+ /// HTTP status code.
169
+ pub status: u16,
170
+ /// HTTP status text.
171
+ pub status_text: String,
172
+ /// Final URL of the response.
173
+ pub url: String,
174
+ /// Response headers.
175
+ pub headers: Vec<(String, String)>,
176
+ stream_id: Option<String>,
177
+ }
178
+
179
+ impl FetchStream {
180
+ /// The next body chunk, `Ok(None)` once the body ended, or `Err` if the
181
+ /// stream failed (chunks received before the failure are returned first).
182
+ /// After `None` or `Err`, further calls return `Ok(None)`.
183
+ pub async fn chunk(&mut self) -> Result<Option<Vec<u8>>> {
184
+ let Some(id) = self.stream_id.as_deref() else {
185
+ return Ok(None);
186
+ };
187
+ let r = NextChunk { stream_id: id }.await;
188
+ if !matches!(r, Ok(Some(_))) {
189
+ self.stream_id = None;
190
+ }
191
+ r
192
+ }
193
+
194
+ /// Reads the remaining body into one buffer.
195
+ pub async fn bytes(&mut self) -> Result<Vec<u8>> {
196
+ let mut out = vec![];
197
+ while let Some(c) = self.chunk().await? {
198
+ out.extend_from_slice(&c);
199
+ }
200
+ Ok(out)
201
+ }
202
+ }
203
+
204
+ impl Drop for FetchStream {
205
+ fn drop(&mut self) {
206
+ if let Some(id) = self.stream_id.take() {
207
+ abandon_stream(&id);
208
+ }
209
+ }
210
+ }
211
+
212
+ /// Arguments of `ctx.vectorSearch`.
213
+ #[derive(Clone, Debug, Default)]
214
+ pub struct VectorSearchQuery {
215
+ pub vector: Vec<f64>,
216
+ /// 1..=256; the backend default (10) if `None`.
217
+ pub limit: Option<u32>,
218
+ pub filter: Option<VectorFilter>,
219
+ }
220
+
221
+ /// A vector search filter: `q.eq(field, value)` combined with `q.or(...)`.
222
+ #[derive(Clone, Debug, PartialEq)]
223
+ pub enum VectorFilter {
224
+ Eq(String, Value),
225
+ Or(Vec<VectorFilter>),
226
+ }
227
+
228
+ impl VectorFilter {
229
+ pub fn eq(field: impl Into<String>, value: impl Serialize) -> Self {
230
+ VectorFilter::Eq(
231
+ field.into(),
232
+ serde_json::to_value(value).unwrap_or(Value::Null),
233
+ )
234
+ }
235
+
236
+ pub fn or(filters: impl IntoIterator<Item = VectorFilter>) -> Self {
237
+ VectorFilter::Or(filters.into_iter().collect())
238
+ }
239
+
240
+ /// The expression JSON the runtime sends: `{"$eq": [{"$field": f}, v]}` / `{"$or": [...]}`.
241
+ pub fn to_json(&self) -> Value {
242
+ match self {
243
+ VectorFilter::Eq(f, v) => json!({ "$eq": [{ "$field": f }, v] }),
244
+ VectorFilter::Or(fs) => {
245
+ json!({ "$or": fs.iter().map(Self::to_json).collect::<Vec<_>>() })
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ /// One `ctx.vectorSearch` result.
252
+ #[derive(Clone, Debug, PartialEq, serde::Deserialize)]
253
+ pub struct VectorHit {
254
+ #[serde(rename = "_id")]
255
+ pub id: String,
256
+ #[serde(rename = "_score")]
257
+ pub score: f64,
258
+ }
259
+
260
+ #[derive(Clone, Debug)]
261
+ pub struct StorageFile {
262
+ pub content_type: Option<String>,
263
+ pub data: Vec<u8>,
264
+ }
265
+
266
+ /// Metadata of a stored file (`ctx.storage.getMetadata`).
267
+ #[derive(Clone, Debug, PartialEq, Eq)]
268
+ pub struct StorageMetadata {
269
+ pub storage_id: String,
270
+ pub sha256: String,
271
+ pub size: u64,
272
+ pub content_type: Option<String>,
273
+ }
274
+
275
+ /// `ctx.storage`: see [`Ctx::storage`].
276
+ #[derive(Clone, Copy, Debug)]
277
+ pub struct Storage<'a> {
278
+ ctx: &'a Ctx,
279
+ }
280
+
281
+ impl Storage<'_> {
282
+ /// `ctx.storage.store(blob)`.
283
+ pub async fn store(&self, data: &[u8], content_type: Option<&str>) -> Result<String> {
284
+ self.ctx.storage_store(data, content_type).await
285
+ }
286
+
287
+ /// `ctx.storage.get(id)`; `None` if the file does not exist.
288
+ pub async fn get(&self, id: &str) -> Result<Option<StorageFile>> {
289
+ self.ctx.storage_get(id).await
290
+ }
291
+
292
+ /// `ctx.storage.getUrl(id)`; `None` if the file does not exist.
293
+ pub async fn get_url(&self, id: &str) -> Result<Option<String>> {
294
+ let v = self
295
+ .ctx
296
+ .syscall("1.0/storageGetUrl", json!({ "storageId": id }))
297
+ .await?;
298
+ Ok(v.as_str().map(str::to_string))
299
+ }
300
+
301
+ /// `ctx.storage.getMetadata(id)`; `None` if the file does not exist.
302
+ pub async fn get_metadata(&self, id: &str) -> Result<Option<StorageMetadata>> {
303
+ let v = self
304
+ .ctx
305
+ .syscall("1.0/storageGetMetadata", json!({ "storageId": id }))
306
+ .await?;
307
+ if v.is_null() {
308
+ return Ok(None);
309
+ }
310
+ Ok(Some(StorageMetadata {
311
+ storage_id: str_field(&v, "storageId"),
312
+ sha256: str_field(&v, "sha256"),
313
+ size: v.get("size").and_then(Value::as_u64).unwrap_or(0),
314
+ content_type: v
315
+ .get("contentType")
316
+ .and_then(Value::as_str)
317
+ .map(str::to_string),
318
+ }))
319
+ }
320
+
321
+ /// `ctx.storage.generateUploadUrl()`: a short-lived URL a client can POST a file to.
322
+ pub async fn generate_upload_url(&self) -> Result<String> {
323
+ let v = self
324
+ .ctx
325
+ .syscall("1.0/storageGenerateUploadUrl", json!({}))
326
+ .await?;
327
+ v.as_str()
328
+ .map(str::to_string)
329
+ .ok_or_else(|| Error::msg("storage.generateUploadUrl returned no url"))
330
+ }
331
+
332
+ /// `ctx.storage.delete(id)`.
333
+ pub async fn delete(&self, id: &str) -> Result<()> {
334
+ self.ctx
335
+ .syscall("1.0/storageDelete", json!({ "storageId": id }))
336
+ .await?;
337
+ Ok(())
338
+ }
339
+ }
340
+
341
+ /// `ctx.scheduler`: see [`Ctx::scheduler`].
342
+ #[derive(Clone, Copy, Debug)]
343
+ pub struct Scheduler<'a> {
344
+ ctx: &'a Ctx,
345
+ }
346
+
347
+ impl Scheduler<'_> {
348
+ /// `ctx.scheduler.runAfter(ms, fn, args)`; `name` is a path, a typed
349
+ /// reference, a [`FunctionHandle`](crate::FunctionHandle) or a component reference.
350
+ pub async fn run_after<A: Serialize>(
351
+ &self,
352
+ delay_ms: f64,
353
+ name: impl FunctionAddress,
354
+ args: A,
355
+ ) -> Result<String> {
356
+ self.ctx.schedule_after(delay_ms, name, args).await
357
+ }
358
+
359
+ /// `ctx.scheduler.runAt(timestamp_ms, fn, args)`.
360
+ pub async fn run_at<A: Serialize>(
361
+ &self,
362
+ ts_ms: f64,
363
+ name: impl FunctionAddress,
364
+ args: A,
365
+ ) -> Result<String> {
366
+ self.ctx.schedule_at(ts_ms, name, args).await
367
+ }
368
+
369
+ /// `ctx.scheduler.cancel(id)`.
370
+ pub async fn cancel(&self, id: &str) -> Result<()> {
371
+ self.ctx
372
+ .syscall("1.0/actions/cancel_job", json!({ "id": id }))
373
+ .await?;
374
+ Ok(())
375
+ }
376
+ }
377
+
378
+ impl Ctx {
379
+ /// `ctx.storage`.
380
+ pub fn storage(&self) -> Storage<'_> {
381
+ Storage { ctx: self }
382
+ }
383
+
384
+ /// `ctx.scheduler`.
385
+ pub fn scheduler(&self) -> Scheduler<'_> {
386
+ Scheduler { ctx: self }
387
+ }
388
+
389
+ pub(crate) fn new() -> Self {
390
+ Self::default()
391
+ }
392
+
393
+ /// Runs any async syscall by name with the exact JSON the V8 runtime sends.
394
+ pub async fn syscall(&self, name: &str, args: Value) -> Result<Value> {
395
+ submit(&wire::syscall_request(name, args)).await
396
+ }
397
+
398
+ /// `ctx.runQuery(api.<path>, args)`. `name` is a function path such as
399
+ /// `"messages:list"` or `"rust/cpu:cpu_001"`; `args` is a Convex JSON object.
400
+ pub async fn run_query<A: Serialize, R: DeserializeOwned>(
401
+ &self,
402
+ name: impl FunctionTarget<A, R>,
403
+ args: A,
404
+ ) -> Result<R> {
405
+ self.call("1.0/actions/query", &name, args).await
406
+ }
407
+
408
+ /// `ctx.runMutation(api.<path>, args)`.
409
+ pub async fn run_mutation<A: Serialize, R: DeserializeOwned>(
410
+ &self,
411
+ name: impl FunctionTarget<A, R>,
412
+ args: A,
413
+ ) -> Result<R> {
414
+ self.call("1.0/actions/mutation", &name, args).await
415
+ }
416
+
417
+ /// `ctx.runAction(api.<path>, args)`; the target may be in any runtime.
418
+ pub async fn run_action<A: Serialize, R: DeserializeOwned>(
419
+ &self,
420
+ name: impl FunctionTarget<A, R>,
421
+ args: A,
422
+ ) -> Result<R> {
423
+ self.call("1.0/actions/action", &name, args).await
424
+ }
425
+
426
+ async fn call<A: Serialize, R: DeserializeOwned>(
427
+ &self,
428
+ syscall: &str,
429
+ target: &impl FunctionAddress,
430
+ args: A,
431
+ ) -> Result<R> {
432
+ let mut req = address_json(target);
433
+ req["args"] = serde_json::to_value(args)?;
434
+ let v = self.syscall(syscall, req).await?;
435
+ Ok(serde_json::from_value(v)?)
436
+ }
437
+
438
+ /// Use [`Ctx::scheduler`] (`run_after`) instead.
439
+ #[doc(hidden)]
440
+ pub async fn schedule_after<A: Serialize>(
441
+ &self,
442
+ delay_ms: f64,
443
+ name: impl FunctionAddress,
444
+ args: A,
445
+ ) -> Result<String> {
446
+ let now = self.now_ms();
447
+ self.schedule_at(now + delay_ms, name, args).await
448
+ }
449
+
450
+ /// Use [`Ctx::scheduler`] (`run_at`) instead.
451
+ #[doc(hidden)]
452
+ pub async fn schedule_at<A: Serialize>(
453
+ &self,
454
+ ts_ms: f64,
455
+ name: impl FunctionAddress,
456
+ args: A,
457
+ ) -> Result<String> {
458
+ let mut req = address_json(&name);
459
+ req["ts"] = json!(ts_ms / 1000.0);
460
+ req["args"] = serde_json::to_value(args)?;
461
+ let v = self.syscall("1.0/actions/schedule", req).await?;
462
+ Ok(serde_json::from_value(v)?)
463
+ }
464
+
465
+ /// `ctx.vectorSearch(table, index, query)`: nearest documents with their scores.
466
+ pub async fn vector_search(
467
+ &self,
468
+ table: &str,
469
+ index: &str,
470
+ query: VectorSearchQuery,
471
+ ) -> Result<Vec<VectorHit>> {
472
+ let mut q = json!({
473
+ "indexName": format!("{table}.{index}"),
474
+ "vector": query.vector,
475
+ "expressions": query.filter.map_or(Value::Null, |f| f.to_json()),
476
+ });
477
+ if let Some(limit) = query.limit {
478
+ q["limit"] = json!(limit);
479
+ }
480
+ let v = self
481
+ .syscall("1.0/actions/vectorSearch", json!({ "query": q }))
482
+ .await?;
483
+ let results = v.get("results").cloned().unwrap_or(json!([]));
484
+ Ok(serde_json::from_value(results)?)
485
+ }
486
+
487
+ /// The value of a component argument (`componentArgument` sync op); `None`
488
+ /// if unset. Only meaningful inside a component.
489
+ pub fn component_argument(&self, name: &str) -> Result<Option<Value>> {
490
+ let v = self.sync_syscall("componentArgument", json!({ "name": name }))?;
491
+ Ok((!v.is_null()).then_some(v))
492
+ }
493
+
494
+ /// `ctx.auth.getUserIdentity()`.
495
+ pub async fn user_identity(&self) -> Result<Option<Value>> {
496
+ let v = self.syscall("1.0/getUserIdentity", json!({})).await?;
497
+ Ok((!v.is_null()).then_some(v))
498
+ }
499
+
500
+ /// `fetch(url, init)`; reads the whole body.
501
+ ///
502
+ /// Dropping the future (a timeout, [`race`](crate::race)) aborts the
503
+ /// request on hosts that support `net/cancel`.
504
+ pub async fn fetch(&self, req: FetchRequest) -> Result<FetchResponse> {
505
+ let head = self.fetch_head(req, None::<NoUpload>).await?;
506
+ Self::read_response(head).await
507
+ }
508
+
509
+ /// `fetch(url, init)` with a streaming body: resolves as soon as the
510
+ /// response head arrives; read the body with [`FetchStream::chunk`].
511
+ pub async fn fetch_stream(&self, req: FetchRequest) -> Result<FetchStream> {
512
+ let head = self.fetch_head(req, None::<NoUpload>).await?;
513
+ Self::stream_response(head)
514
+ }
515
+
516
+ /// `fetch(url, { method, headers, body: <stream> })`: the request body is
517
+ /// pulled from `source` chunk by chunk, so it never has to fit in memory.
518
+ /// `req.body` must be `None`. On hosts without streamed request bodies the
519
+ /// chunks are buffered and sent as one body. A streamed body cannot be
520
+ /// replayed, so a 307/308 redirect (or any redirect that keeps the body)
521
+ /// is an error.
522
+ pub async fn fetch_upload<S: ChunkSource>(
523
+ &self,
524
+ req: FetchRequest,
525
+ source: S,
526
+ ) -> Result<FetchResponse> {
527
+ let head = self.fetch_head(req, Some(source)).await?;
528
+ Self::read_response(head).await
529
+ }
530
+
531
+ /// [`Ctx::fetch_upload`] with a streamed response body.
532
+ pub async fn fetch_stream_upload<S: ChunkSource>(
533
+ &self,
534
+ req: FetchRequest,
535
+ source: S,
536
+ ) -> Result<FetchStream> {
537
+ let head = self.fetch_head(req, Some(source)).await?;
538
+ Self::stream_response(head)
539
+ }
540
+
541
+ async fn read_response(head: Value) -> Result<FetchResponse> {
542
+ let head = FetchHead::parse(&head)?;
543
+ let body = read_body(head.stream_id.as_deref()).await?;
544
+ Ok(FetchResponse {
545
+ status: head.status,
546
+ status_text: head.status_text,
547
+ url: head.url,
548
+ headers: head.headers,
549
+ body,
550
+ })
551
+ }
552
+
553
+ fn stream_response(head: Value) -> Result<FetchStream> {
554
+ let head = FetchHead::parse(&head)?;
555
+ Ok(FetchStream {
556
+ status: head.status,
557
+ status_text: head.status_text,
558
+ url: head.url,
559
+ headers: head.headers,
560
+ stream_id: head.stream_id,
561
+ })
562
+ }
563
+
564
+ /// Submits the fetch and follows redirects (unless `Manual`); returns the
565
+ /// raw response head. `upload` supplies a streamed request body.
566
+ async fn fetch_head<S: ChunkSource>(
567
+ &self,
568
+ req: FetchRequest,
569
+ mut upload: Option<S>,
570
+ ) -> Result<Value> {
571
+ if upload.is_some() && req.body.is_some() {
572
+ return Err(Error::msg(
573
+ "fetch_upload: `body` must be unset, the chunk source is the body",
574
+ ));
575
+ }
576
+ let FetchRequest {
577
+ url,
578
+ method,
579
+ headers,
580
+ body,
581
+ redirect,
582
+ } = req;
583
+ let mut cur = RedirectState {
584
+ url,
585
+ method,
586
+ headers,
587
+ body,
588
+ };
589
+ let mut hops = 0;
590
+ // Probe `net/*` once (cancel on drop, body references, streamed bodies).
591
+ let caps = net::caps();
592
+ loop {
593
+ let mut streamed = false;
594
+ let mut head = match upload.take() {
595
+ Some(mut src) if caps.request_stream => {
596
+ streamed = true;
597
+ self.send_streamed(&cur, &mut src).await?
598
+ }
599
+ Some(mut src) => {
600
+ // Old host: buffer the chunks, send one body.
601
+ let mut all = vec![];
602
+ while let Some(c) = src.next_chunk().await? {
603
+ all.extend_from_slice(&c);
604
+ }
605
+ cur.body = Some(all);
606
+ self.send_buffered(&cur, caps).await?
607
+ }
608
+ None => match cur.body.as_deref() {
609
+ Some(b) if caps.request_stream && b.len() > caps.max_body_ref => {
610
+ self.send_streamed(&cur, &mut SliceSource(b)).await?
611
+ }
612
+ _ => self.send_buffered(&cur, caps).await?,
613
+ },
614
+ };
615
+ if redirect == Redirect::Manual {
616
+ return Ok(head);
617
+ }
618
+ let status = head.get("status").and_then(Value::as_u64).unwrap_or(0);
619
+ let location = match status {
620
+ 301 | 302 | 303 | 307 | 308 => response_header(&head, "location"),
621
+ _ => None,
622
+ };
623
+ let Some(location) = location else {
624
+ head["url"] = Value::String(cur.url);
625
+ return Ok(head);
626
+ };
627
+ if let Some(id) = wire::body_stream_id(&head) {
628
+ abandon_stream(id);
629
+ }
630
+ hops += 1;
631
+ let was_upload = streamed;
632
+ cur = next_redirect(cur, status, &location, hops)?;
633
+ if was_upload && cur.method != "GET" && cur.method != "HEAD" {
634
+ return Err(Error::msg(format!(
635
+ "fetch failed: cannot follow a {status} redirect after a streamed request body"
636
+ )));
637
+ }
638
+ }
639
+ }
640
+
641
+ /// One fetch with the body inline, as a `{ptr,len}` reference, or none.
642
+ async fn send_buffered(&self, cur: &RedirectState, caps: net::Caps) -> Result<Value> {
643
+ let body = match cur.body.as_deref() {
644
+ None => BodyRef::None,
645
+ Some(b)
646
+ if caps.body_ref
647
+ && b.len() >= net::REF_THRESHOLD
648
+ && b.len() <= caps.max_body_ref =>
649
+ {
650
+ BodyRef::Ref(b)
651
+ }
652
+ Some(b) => BodyRef::Inline(b),
653
+ };
654
+ submit_cancellable(&wire::fetch_request_with(
655
+ &cur.url,
656
+ &cur.method,
657
+ &cur.headers,
658
+ body,
659
+ ))
660
+ .await
661
+ }
662
+
663
+ /// One fetch whose body is pulled from `src` and written chunk by chunk.
664
+ async fn send_streamed<S: ChunkSource>(
665
+ &self,
666
+ cur: &RedirectState,
667
+ src: &mut S,
668
+ ) -> Result<Value> {
669
+ let task = submit_cancellable(&wire::fetch_request_with(
670
+ &cur.url,
671
+ &cur.method,
672
+ &cur.headers,
673
+ BodyRef::Stream,
674
+ ));
675
+ let id = task.id();
676
+ if id < 0 {
677
+ return task.await;
678
+ }
679
+ loop {
680
+ match src.next_chunk().await {
681
+ Ok(Some(c)) if c.is_empty() => {}
682
+ Ok(Some(c)) => {
683
+ if net::body_write(id, &c).is_err() {
684
+ // The request already finished (the server answered early) or failed:
685
+ // its own result says which.
686
+ net::body_abort(id, "upload stopped");
687
+ break;
688
+ }
689
+ }
690
+ Ok(None) => {
691
+ if net::body_end(id).is_err() {
692
+ net::body_abort(id, "upload stopped");
693
+ }
694
+ break;
695
+ }
696
+ Err(e) => {
697
+ net::body_abort(id, &e.to_string());
698
+ let _ = task.await;
699
+ return Err(e);
700
+ }
701
+ }
702
+ }
703
+ task.await
704
+ }
705
+
706
+ /// `await new Promise(r => setTimeout(r, ms))`.
707
+ pub async fn sleep(&self, ms: u64) -> Result<()> {
708
+ // Cancellable: dropping the sleep (a lost `select`/timeout race) frees the host timer.
709
+ submit_cancellable(&wire::sleep_request(ms)).await?;
710
+ Ok(())
711
+ }
712
+
713
+ /// Use [`Ctx::storage`] (`store`) instead.
714
+ #[doc(hidden)]
715
+ pub async fn storage_store(&self, data: &[u8], content_type: Option<&str>) -> Result<String> {
716
+ let by_ref = data.len() >= net::REF_THRESHOLD && {
717
+ let caps = net::caps();
718
+ caps.body_ref && data.len() <= caps.max_body_ref
719
+ };
720
+ let body = if by_ref {
721
+ BodyRef::Ref(data)
722
+ } else {
723
+ BodyRef::Inline(data)
724
+ };
725
+ let v = submit(&wire::storage_store_request_with(body, content_type)).await?;
726
+ v.as_str()
727
+ .map(str::to_string)
728
+ .ok_or_else(|| Error::msg("storage.store returned no id"))
729
+ }
730
+
731
+ /// Use [`Ctx::storage`] (`get`) instead.
732
+ #[doc(hidden)]
733
+ pub async fn storage_get(&self, storage_id: &str) -> Result<Option<StorageFile>> {
734
+ let v = submit(&wire::storage_get_request(storage_id)).await?;
735
+ if v.is_null() {
736
+ return Ok(None);
737
+ }
738
+ let data = read_body(wire::body_stream_id(&v)).await?;
739
+ Ok(Some(StorageFile {
740
+ content_type: opt_str(&v, "contentType"),
741
+ data,
742
+ }))
743
+ }
744
+
745
+ /// Milliseconds since the Unix epoch, from the host clock.
746
+ /// Host time (sync op `now`), falling back to the WASI clock.
747
+ pub fn now_ms(&self) -> f64 {
748
+ self.sync_syscall("now", json!({}))
749
+ .ok()
750
+ .and_then(|v| v.as_f64())
751
+ .unwrap_or_else(|| {
752
+ std::time::SystemTime::now()
753
+ .duration_since(std::time::UNIX_EPOCH)
754
+ .map(|d| d.as_secs_f64() * 1000.0)
755
+ .unwrap_or(0.0)
756
+ })
757
+ }
758
+
759
+ /// Synchronous syscall (contracts/ABI.md "Sync syscalls").
760
+ pub fn sync_syscall(&self, op: &str, args: Value) -> Result<Value> {
761
+ let out = sys::sync(op, args.to_string().as_bytes());
762
+ let v: Value = serde_json::from_slice(&out)?;
763
+ match v.get("error") {
764
+ Some(err) => Err(Error::from_json(err)),
765
+ None => Ok(v.get("ok").cloned().unwrap_or(Value::Null)),
766
+ }
767
+ }
768
+ }
769
+
770
+ fn opt_str(v: &Value, k: &str) -> Option<String> {
771
+ v.get(k).and_then(Value::as_str).map(str::to_string)
772
+ }
773
+
774
+ fn str_field(v: &Value, k: &str) -> String {
775
+ opt_str(v, k).unwrap_or_default()
776
+ }
777
+
778
+ /// Reads a host body stream to the end; no stream id means an empty body.
779
+ async fn read_body(stream_id: Option<&str>) -> Result<Vec<u8>> {
780
+ match stream_id {
781
+ Some(id) => {
782
+ StreamFuture::new(id.to_string()).await
783
+ }
784
+ None => Ok(vec![]),
785
+ }
786
+ }
787
+
788
+ /// The parsed response head of a `fetch` task.
789
+ struct FetchHead {
790
+ status: u16,
791
+ status_text: String,
792
+ url: String,
793
+ headers: Vec<(String, String)>,
794
+ stream_id: Option<String>,
795
+ }
796
+
797
+ impl FetchHead {
798
+ fn parse(head: &Value) -> Result<Self> {
799
+ Ok(Self {
800
+ status: head.get("status").and_then(Value::as_u64).unwrap_or(0) as u16,
801
+ status_text: str_field(head, "statusText"),
802
+ url: str_field(head, "url"),
803
+ headers: serde_json::from_value(head.get("headers").cloned().unwrap_or(json!([])))?,
804
+ stream_id: wire::body_stream_id(head).map(str::to_string),
805
+ })
806
+ }
807
+ }
808
+
809
+ /// The request to send for the next hop of a redirect chain.
810
+ #[derive(Clone, Debug, PartialEq)]
811
+ struct RedirectState {
812
+ url: String,
813
+ method: String,
814
+ headers: Vec<(String, String)>,
815
+ body: Option<Vec<u8>>,
816
+ }
817
+
818
+ /// Pure redirect step: given the request that produced a 3xx `status` with
819
+ /// `location`, returns the request for the next hop. `hop` is the 1-based
820
+ /// number of this redirect; exceeding [`MAX_REDIRECTS`] is an error.
821
+ fn next_redirect(
822
+ cur: RedirectState,
823
+ status: u64,
824
+ location: &str,
825
+ hop: usize,
826
+ ) -> Result<RedirectState> {
827
+ if hop > MAX_REDIRECTS {
828
+ return Err(Error::msg(format!(
829
+ "fetch failed: maximum redirect ({MAX_REDIRECTS}) reached for {}",
830
+ cur.url
831
+ )));
832
+ }
833
+ let next = resolve_url(&cur.url, location).ok_or_else(|| {
834
+ Error::msg(format!(
835
+ "fetch failed: invalid redirect location {location:?}"
836
+ ))
837
+ })?;
838
+ let RedirectState {
839
+ url,
840
+ mut method,
841
+ mut headers,
842
+ mut body,
843
+ } = cur;
844
+ let to_get = status == 303 && method != "GET" && method != "HEAD"
845
+ || (status == 301 || status == 302) && method == "POST";
846
+ if to_get {
847
+ method = "GET".into();
848
+ body = None;
849
+ headers.retain(|(k, _)| !BODY_HEADERS.iter().any(|h| k.eq_ignore_ascii_case(h)));
850
+ }
851
+ if origin(&next) != origin(&url) {
852
+ headers.retain(|(k, _)| !CREDENTIAL_HEADERS.iter().any(|h| k.eq_ignore_ascii_case(h)));
853
+ }
854
+ Ok(RedirectState {
855
+ url: next,
856
+ method,
857
+ headers,
858
+ body,
859
+ })
860
+ }
861
+
862
+ fn response_header(head: &Value, name: &str) -> Option<String> {
863
+ head.get("headers")?
864
+ .as_array()?
865
+ .iter()
866
+ .filter_map(|p| Some((p.get(0)?.as_str()?, p.get(1)?.as_str()?)))
867
+ .find(|(k, _)| k.eq_ignore_ascii_case(name))
868
+ .map(|(_, v)| v.to_string())
869
+ }
870
+
871
+ /// `scheme://authority` of an absolute URL.
872
+ fn origin(url: &str) -> &str {
873
+ let Some(i) = url.find("://") else { return url };
874
+ let rest = &url[i + 3..];
875
+ let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
876
+ &url[..i + 3 + end]
877
+ }
878
+
879
+ /// Resolves a `Location` header against the request URL (RFC 3986 subset:
880
+ /// absolute, scheme-relative, origin-relative, path-relative, query-only),
881
+ /// dropping the fragment and removing dot segments.
882
+ pub(crate) fn resolve_url(base: &str, location: &str) -> Option<String> {
883
+ let location = location.trim().split('#').next().unwrap_or("");
884
+ let org = origin(base);
885
+ let scheme_end = org.find("://")? + 1;
886
+ if location.contains("://") {
887
+ let o = origin(location);
888
+ return Some(format!("{o}{}", normalize(&location[o.len()..])));
889
+ }
890
+ if let Some(rest) = location.strip_prefix("//") {
891
+ let end = rest.find(['/', '?']).unwrap_or(rest.len());
892
+ return Some(format!(
893
+ "{}//{}{}",
894
+ &org[..scheme_end],
895
+ &rest[..end],
896
+ normalize(&rest[end..])
897
+ ));
898
+ }
899
+ let base_rest = base[org.len()..].split('#').next().unwrap_or("");
900
+ let base_path = base_rest.split('?').next().unwrap_or("");
901
+ let path = if location.starts_with('/') {
902
+ location.to_string()
903
+ } else if location.is_empty() {
904
+ base_rest.to_string()
905
+ } else if location.starts_with('?') {
906
+ format!(
907
+ "{}{location}",
908
+ if base_path.is_empty() { "/" } else { base_path }
909
+ )
910
+ } else {
911
+ let dir = &base_path[..base_path.rfind('/').map_or(0, |i| i + 1)];
912
+ format!("{}{location}", if dir.is_empty() { "/" } else { dir })
913
+ };
914
+ Some(format!("{org}{}", normalize(&path)))
915
+ }
916
+
917
+ /// Removes `.` / `..` segments from the path part, keeping the query.
918
+ fn normalize(path_and_query: &str) -> String {
919
+ let (path, query) = match path_and_query.find('?') {
920
+ Some(i) => (&path_and_query[..i], &path_and_query[i..]),
921
+ None => (path_and_query, ""),
922
+ };
923
+ let mut out: Vec<&str> = vec![];
924
+ let segs: Vec<&str> = path.split('/').collect();
925
+ for (i, seg) in segs.iter().enumerate() {
926
+ let last = i + 1 == segs.len();
927
+ match *seg {
928
+ "." => {
929
+ if last {
930
+ out.push("");
931
+ }
932
+ }
933
+ ".." => {
934
+ if out.len() > 1 {
935
+ out.pop();
936
+ }
937
+ if last {
938
+ out.push("");
939
+ }
940
+ }
941
+ s => out.push(s),
942
+ }
943
+ }
944
+ let p = out.join("/");
945
+ let p = if p.is_empty() { "/".to_string() } else { p };
946
+ format!("{p}{query}")
947
+ }
948
+
949
+ #[cfg(test)]
950
+ mod tests {
951
+ use super::{next_redirect, resolve_url, RedirectState, VectorFilter, MAX_REDIRECTS};
952
+ use serde_json::json;
953
+
954
+ #[test]
955
+ fn vector_filter_json() {
956
+ let f = VectorFilter::or([VectorFilter::eq("a", 1), VectorFilter::eq("b", "x")]);
957
+ assert_eq!(
958
+ f.to_json(),
959
+ json!({"$or": [{"$eq": [{"$field": "a"}, 1]}, {"$eq": [{"$field": "b"}, "x"]}]})
960
+ );
961
+ }
962
+
963
+ #[test]
964
+ fn resolves_locations() {
965
+ let b = "http://h:1/a/b/c?x=1";
966
+ assert_eq!(resolve_url(b, "http://o/z?q#f").unwrap(), "http://o/z?q");
967
+ assert_eq!(resolve_url(b, "/r?t=3").unwrap(), "http://h:1/r?t=3");
968
+ assert_eq!(resolve_url(b, "d").unwrap(), "http://h:1/a/b/d");
969
+ assert_eq!(resolve_url(b, "../d").unwrap(), "http://h:1/a/d");
970
+ assert_eq!(resolve_url(b, "?y=2").unwrap(), "http://h:1/a/b/c?y=2");
971
+ assert_eq!(resolve_url(b, "//o2/p").unwrap(), "http://o2/p");
972
+ assert_eq!(resolve_url("http://h", "x").unwrap(), "http://h/x");
973
+ }
974
+
975
+ fn st(method: &str) -> RedirectState {
976
+ RedirectState {
977
+ url: "http://a.test/x".into(),
978
+ method: method.into(),
979
+ headers: vec![
980
+ ("Content-Type".into(), "text/plain".into()),
981
+ ("Content-Length".into(), "2".into()),
982
+ ("Authorization".into(), "Bearer t".into()),
983
+ ("X-Keep".into(), "1".into()),
984
+ ],
985
+ body: Some(b"hi".to_vec()),
986
+ }
987
+ }
988
+
989
+ fn names(s: &RedirectState) -> Vec<&str> {
990
+ s.headers.iter().map(|(k, _)| k.as_str()).collect()
991
+ }
992
+
993
+ #[test]
994
+ fn post_becomes_get_on_301_302_303() {
995
+ for status in [301, 302, 303] {
996
+ let n = next_redirect(st("POST"), status, "/y", 1).unwrap();
997
+ assert_eq!(n.method, "GET", "{status}");
998
+ assert_eq!(n.body, None);
999
+ assert_eq!(n.url, "http://a.test/y");
1000
+ assert_eq!(names(&n), ["Authorization", "X-Keep"]);
1001
+ }
1002
+ }
1003
+
1004
+ #[test]
1005
+ fn method_rewrite_edge_cases() {
1006
+ // 301/302 keep non-POST methods and their body.
1007
+ let n = next_redirect(st("PUT"), 302, "/y", 1).unwrap();
1008
+ assert_eq!((n.method.as_str(), n.body.is_some()), ("PUT", true));
1009
+ // 303 rewrites everything except GET/HEAD.
1010
+ let n = next_redirect(st("PUT"), 303, "/y", 1).unwrap();
1011
+ assert_eq!((n.method.as_str(), n.body.is_some()), ("GET", false));
1012
+ let n = next_redirect(st("HEAD"), 303, "/y", 1).unwrap();
1013
+ assert_eq!(n.method, "HEAD");
1014
+ }
1015
+
1016
+ #[test]
1017
+ fn redirects_307_308_preserve_method_and_body() {
1018
+ for status in [307, 308] {
1019
+ let n = next_redirect(st("POST"), status, "/y", 1).unwrap();
1020
+ assert_eq!(n.method, "POST");
1021
+ assert_eq!(n.body.as_deref(), Some(&b"hi"[..]));
1022
+ assert_eq!(n.headers.len(), 4);
1023
+ }
1024
+ }
1025
+
1026
+ #[test]
1027
+ fn cross_origin_strips_credentials() {
1028
+ let mut s = st("GET");
1029
+ s.headers.push(("cookie".into(), "a=b".into()));
1030
+ s.headers.push(("Proxy-Authorization".into(), "x".into()));
1031
+ let n = next_redirect(s.clone(), 307, "http://b.test/z", 1).unwrap();
1032
+ assert_eq!(names(&n), ["Content-Type", "Content-Length", "X-Keep"]);
1033
+ // Same origin keeps them.
1034
+ let n = next_redirect(s, 307, "http://a.test/z", 1).unwrap();
1035
+ assert_eq!(n.headers.len(), 6);
1036
+ }
1037
+
1038
+ #[test]
1039
+ fn max_redirects() {
1040
+ assert!(next_redirect(st("GET"), 302, "/y", MAX_REDIRECTS).is_ok());
1041
+ let e = next_redirect(st("GET"), 302, "/y", MAX_REDIRECTS + 1).unwrap_err();
1042
+ assert!(e.message().contains("maximum redirect (20)"), "{e}");
1043
+ }
1044
+ }