convex-rust 0.0.0-stage → 0.1.1
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/LICENSE +201 -0
- package/README.md +137 -2
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +200 -0
- package/dist/cli/build.d.ts +146 -0
- package/dist/cli/build.js +902 -0
- package/dist/cli/demangle.d.ts +7 -0
- package/dist/cli/demangle.js +328 -0
- package/dist/cli/diagnostics.d.ts +34 -0
- package/dist/cli/diagnostics.js +296 -0
- package/dist/cli/generate.d.ts +29 -0
- package/dist/cli/generate.js +82 -0
- package/dist/cli/manifest.d.ts +24 -0
- package/dist/cli/manifest.js +75 -0
- package/dist/cli/rustApi.d.ts +78 -0
- package/dist/cli/rustApi.js +609 -0
- package/dist/cli/scaffold.d.ts +22 -0
- package/dist/cli/scaffold.js +114 -0
- package/dist/cli/shim.d.ts +30 -0
- package/dist/cli/shim.js +140 -0
- package/dist/cli/size.d.ts +17 -0
- package/dist/cli/size.js +73 -0
- package/dist/cli/split.d.ts +20 -0
- package/dist/cli/split.js +140 -0
- package/dist/cli/symbols.d.ts +30 -0
- package/dist/cli/symbols.js +133 -0
- package/dist/cli/toolchain.d.ts +53 -0
- package/dist/cli/toolchain.js +167 -0
- package/dist/cli/validators.d.ts +6 -0
- package/dist/cli/validators.js +66 -0
- package/dist/cli/wasisdk.d.ts +82 -0
- package/dist/cli/wasisdk.js +220 -0
- package/dist/cli/wasmHints.d.ts +29 -0
- package/dist/cli/wasmHints.js +172 -0
- package/dist/runtime/base64.d.ts +4 -0
- package/dist/runtime/base64.js +77 -0
- package/dist/runtime/crypto.d.ts +7 -0
- package/dist/runtime/crypto.js +421 -0
- package/dist/runtime/fastjson.d.ts +3 -0
- package/dist/runtime/fastjson.js +170 -0
- package/dist/runtime/host.d.ts +73 -0
- package/dist/runtime/host.js +1230 -0
- package/dist/runtime/http.d.ts +40 -0
- package/dist/runtime/http.js +157 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/intl.d.ts +5 -0
- package/dist/runtime/intl.js +69 -0
- package/dist/runtime/tmpfs.d.ts +129 -0
- package/dist/runtime/tmpfs.js +624 -0
- package/dist/runtime/wasi.d.ts +33 -0
- package/dist/runtime/wasi.js +216 -0
- package/package.json +66 -4
- package/rust/convex_fn/Cargo.toml +32 -0
- package/rust/convex_fn/LICENSE +201 -0
- package/rust/convex_fn/build.rs +3 -0
- package/rust/convex_fn/src/args.rs +416 -0
- package/rust/convex_fn/src/crypto.rs +237 -0
- package/rust/convex_fn/src/ctx.rs +1044 -0
- package/rust/convex_fn/src/dns.rs +356 -0
- package/rust/convex_fn/src/error.rs +179 -0
- package/rust/convex_fn/src/executor.rs +577 -0
- package/rust/convex_fn/src/fanout.rs +106 -0
- package/rust/convex_fn/src/fs.rs +19 -0
- package/rust/convex_fn/src/function_ref.rs +219 -0
- package/rust/convex_fn/src/http.rs +441 -0
- package/rust/convex_fn/src/intl.rs +75 -0
- package/rust/convex_fn/src/lib.rs +242 -0
- package/rust/convex_fn/src/logging.rs +222 -0
- package/rust/convex_fn/src/native.rs +360 -0
- package/rust/convex_fn/src/net.rs +122 -0
- package/rust/convex_fn/src/sys.rs +178 -0
- package/rust/convex_fn/src/values.rs +121 -0
- package/rust/convex_fn/src/wire.rs +579 -0
- package/rust/convex_fn_macros/Cargo.toml +13 -0
- package/rust/convex_fn_macros/LICENSE +201 -0
- package/rust/convex_fn_macros/src/derive.rs +632 -0
- package/rust/convex_fn_macros/src/lib.rs +338 -0
- package/rust/convex_reqwest/Cargo.toml +69 -0
- package/rust/convex_reqwest/LICENSE +201 -0
- package/rust/convex_reqwest/src/blocking.rs +287 -0
- package/rust/convex_reqwest/src/body.rs +152 -0
- package/rust/convex_reqwest/src/client.rs +359 -0
- package/rust/convex_reqwest/src/config_types.rs +150 -0
- package/rust/convex_reqwest/src/error.rs +241 -0
- package/rust/convex_reqwest/src/into_url.rs +57 -0
- package/rust/convex_reqwest/src/lib.rs +42 -0
- package/rust/convex_reqwest/src/multipart.rs +140 -0
- package/rust/convex_reqwest/src/request.rs +358 -0
- 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
|
+
}
|