Skip to main content

submilli_engine/stdlib/http/
declaration.rs

1//! The `submilli:http` package declaration — the LLM-facing type surface
2//! (`Response`, `DownloadOptions`, `DownloadResult`, `Headers`, verb helpers).
3
4use std::collections::BTreeMap;
5
6use crate::{
7    DefaultValue, Dispatch, MethodSig, PackageDeclaration, Param, PropertySig, Span, Type,
8    TypeKind, TypeSymbol, ValueKind, ValueSymbol,
9};
10
11use super::MODULE_NAME;
12
13pub fn package_declaration() -> PackageDeclaration {
14    let mut defs = PackageDeclaration::with_package(MODULE_NAME);
15
16    defs.types.insert(
17        "Headers".to_string(),
18        TypeSymbol {
19            name: "Headers".to_string(),
20            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Headers"),
21            declaration_span: Span::at(crate::FileId::HTTP),
22            kind: TypeKind::Alias {
23                generics: Vec::new(),
24                ty: Type::InterfaceRef { mangled: crate::mangle::prelude("Map"),
25                    package: crate::Package::prelude(),
26                    name: "Map".to_string(),
27                    args: vec![Type::String, Type::String],
28                },
29                doc: crate::doc(crate::FileId::HTTP,
30                    "/** HTTP header bag: `Map<string, string>` with names lowercased on the wire. Future slice may add case-insensitive lookup. */",
31                ),
32            },
33        },
34    );
35
36    insert_response_interface(&mut defs);
37    insert_download_options_interface(&mut defs);
38    insert_download_result_interface(&mut defs);
39
40    // body-less: prevents get(url, h) from ambiguating body vs headers
41    for verb in &["get", "delete", "head", "options"] {
42        insert_verb_fn(&mut defs, verb, /* has_body = */ false);
43    }
44    for verb in &["post", "put", "patch"] {
45        insert_verb_fn(&mut defs, verb, /* has_body = */ true);
46    }
47
48    defs.values.insert(
49        "download".to_string(),
50        ValueSymbol {
51            name: "download".to_string(),
52            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "download"),
53            declaration_span: Span::at(crate::FileId::HTTP),
54            kind: ValueKind::Function {
55                generics: Vec::new(),
56                params: vec![
57                    Param::new("url", Type::String),
58                    Param::new("path", Type::String),
59                    Param::with_default(
60                        "options",
61                        optional_download_options_type(),
62                        DefaultValue::Undefined,
63                    ),
64                ],
65                ret: download_result_type(),
66                type_predicate: None,
67                doc: crate::doc(crate::FileId::HTTP,
68                    "/**\n * Download a remote file directly to the VFS — wget-style. Streams the response body to disk so memory stays bounded regardless of file size. Atomic: a crash mid-download leaves a `.tmp` sibling rather than a half-written final file. Traps on transport failure (DNS, connect, TLS, timeout), oversize response, refuse-on-exists, or VFS path escape.\n * @param url Absolute URL. A path with a `.` or `..` segment, in any spelling such as `%2E%2E`, throws `TypeError` and sends nothing.\n * @param path Destination VFS path (e.g. `\"/workspace/dataset.csv\"`). The parent directory must exist — call `fs.mkdir` first if needed.\n * @param options Optional `DownloadOptions` bag: `overwrite` (default `false`), `maxBytes` (default and upper bound: tier-configured), `headers`, `timeout` (default and upper bound: 60_000 ms, or a lower operator limit), `decompress` (default `false`). Omit or pass `undefined` to take every default.\n * @capability http.download { host: $url.host, url_path: $url.path, vfs_path: $path, max_bytes: number, overwrite: boolean, decompress: boolean }\n * @capability fs.write { path, max_bytes: number }\n */",
69                ),
70            },
71        },
72    );
73
74    defs.values.insert(
75        "request".to_string(),
76        ValueSymbol {
77            name: "request".to_string(),
78            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "request"),
79            declaration_span: Span::at(crate::FileId::HTTP),
80            kind: ValueKind::Function {
81                generics: Vec::new(),
82                params: vec![
83                    Param::new("method", Type::String),
84                    Param::new("url", Type::String),
85                    Param::with_default(
86                        "body",
87                        optional_body_type(),
88                        DefaultValue::Undefined,
89                    ),
90                    Param::with_default(
91                        "headers",
92                        optional_headers_type(),
93                        DefaultValue::Undefined,
94                    ),
95                ],
96                ret: response_type(),
97                type_predicate: None,
98                doc: crate::doc(crate::FileId::HTTP,
99                    "/**\n * Issue an HTTP request with a runtime-chosen verb. Returns the full response (status, headers, UTF-8-decoded body). Traps on transport failure (DNS, connect, TLS, timeout) — surfaced as a guest `Error`.\n * @param method HTTP method (case-insensitive). Common verbs: `\"GET\"` / `\"POST\"` / `\"PUT\"` / `\"PATCH\"` / `\"DELETE\"` / `\"HEAD\"` / `\"OPTIONS\"`.\n * @param url Absolute URL. A path with a `.` or `..` segment, in any spelling such as `%2E%2E`, throws `TypeError` and sends nothing.\n * @param body Optional request body. `string` is UTF-8 encoded and auto-tagged `Content-Type: text/plain; charset=utf-8` unless the caller overrides; a structural object or array is JSON-encoded and auto-tagged `Content-Type: application/json` unless the caller overrides; `Uint8Array` sends the bytes verbatim with no defaulted Content-Type; `null` sends the JSON payload `null` with default `Content-Type: application/json`; `undefined` (or omitted) sends an empty body.\n * @param headers Optional request headers. Omit or pass `undefined` to send no extra headers.\n * @capability http.get { host: $url.host, path: $url.path, body_size: number, timeout_ms: number }\n * @capability http.post { host: $url.host, path: $url.path, body_size: number, timeout_ms: number }\n * @capability http.put { host: $url.host, path: $url.path, body_size: number, timeout_ms: number }\n * @capability http.patch { host: $url.host, path: $url.path, body_size: number, timeout_ms: number }\n * @capability http.delete { host: $url.host, path: $url.path, body_size: number, timeout_ms: number }\n * @capability http.head { host: $url.host, path: $url.path, body_size: number, timeout_ms: number }\n * @capability http.options { host: $url.host, path: $url.path, body_size: number, timeout_ms: number }\n */",
100                ),
101            },
102        },
103    );
104
105    defs
106}
107
108fn insert_verb_fn(defs: &mut PackageDeclaration, verb: &str, has_body: bool) {
109    let upper = verb.to_uppercase();
110    let doc = if has_body {
111        format!(
112            "/**\n * Issue a synchronous HTTP {upper}. Returns the full response (status, headers, UTF-8-decoded body). Traps on transport failure (DNS, connect, TLS, timeout) — surfaced as a guest `Error`.\n * @param url Absolute URL. A path with a `.` or `..` segment, in any spelling such as `%2E%2E`, throws `TypeError` and sends nothing.\n * @param body Optional request body. `string` is UTF-8 encoded and auto-tagged `Content-Type: text/plain; charset=utf-8` unless the caller overrides; a structural object or array is JSON-encoded and auto-tagged `Content-Type: application/json` unless the caller overrides; `Uint8Array` sends the bytes verbatim with no defaulted Content-Type; `null` sends the JSON payload `null` with default `Content-Type: application/json`; `undefined` (or omitted) sends an empty body.\n * @param headers Optional request headers. Omit or pass `undefined` to send no extra headers.\n */",
113        )
114    } else {
115        format!(
116            "/**\n * Issue a synchronous HTTP {upper}. Returns the full response (status, headers, UTF-8-decoded body). Traps on transport failure (DNS, connect, TLS, timeout) — surfaced as a guest `Error`.\n * @param url Absolute URL. A path with a `.` or `..` segment, in any spelling such as `%2E%2E`, throws `TypeError` and sends nothing.\n * @param headers Optional request headers. Omit or pass `undefined` to send no extra headers.\n */",
117        )
118    };
119    let capability = format!(
120        " * @capability http.{verb} {{ host: $url.host, path: $url.path, body_size: number, timeout_ms: number }}\n */"
121    );
122    let doc = doc.replace(" */", &capability);
123    let mut params = vec![Param::new("url", Type::String)];
124    if has_body {
125        params.push(Param::with_default(
126            "body",
127            optional_body_type(),
128            DefaultValue::Undefined,
129        ));
130    }
131    params.push(Param::with_default(
132        "headers",
133        optional_headers_type(),
134        DefaultValue::Undefined,
135    ));
136    defs.values.insert(
137        verb.to_string(),
138        ValueSymbol {
139            name: verb.to_string(),
140            mangled_name: crate::mangle::package_symbol(MODULE_NAME, verb),
141            declaration_span: Span::at(crate::FileId::HTTP),
142            kind: ValueKind::Function {
143                generics: Vec::new(),
144                params,
145                ret: response_type(),
146                type_predicate: None,
147                doc: crate::doc(crate::FileId::HTTP, &doc),
148            },
149        },
150    );
151}
152
153fn optional_headers_type() -> Type {
154    Type::Union(vec![headers_type(), Type::Undefined])
155}
156
157fn optional_body_type() -> Type {
158    Type::Union(vec![
159        Type::String,
160        Type::Uint8Array,
161        // An empty object is the top object type — any structural object is
162        // assignable to it by width subtyping, so this accepts arbitrary object
163        // bodies (JSON-encoded at runtime via the `$Object` `toJson` vtable slot).
164        Type::Object {
165            index: None,
166            fields: BTreeMap::new(),
167        },
168        Type::Array(Box::new(Type::Unknown)),
169        Type::Null,
170        Type::Undefined,
171    ])
172}
173
174fn response_type() -> Type {
175    Type::InterfaceRef {
176        mangled: crate::mangle::package_symbol(MODULE_NAME, "Response"),
177        package: crate::Package(MODULE_NAME.to_string()),
178        name: "Response".to_string(),
179        args: Vec::new(),
180    }
181}
182
183fn download_result_type() -> Type {
184    Type::InterfaceRef {
185        mangled: crate::mangle::package_symbol(MODULE_NAME, "DownloadResult"),
186        package: crate::Package(MODULE_NAME.to_string()),
187        name: "DownloadResult".to_string(),
188        args: Vec::new(),
189    }
190}
191
192fn download_options_type() -> Type {
193    Type::InterfaceRef {
194        mangled: crate::mangle::package_symbol(MODULE_NAME, "DownloadOptions"),
195        package: crate::Package(MODULE_NAME.to_string()),
196        name: "DownloadOptions".to_string(),
197        args: Vec::new(),
198    }
199}
200
201fn optional_download_options_type() -> Type {
202    Type::Union(vec![download_options_type(), Type::Undefined])
203}
204
205fn headers_type() -> Type {
206    Type::Alias {
207        mangled: crate::mangle::package_symbol(MODULE_NAME, "Headers"),
208        package: crate::Package(MODULE_NAME.to_string()),
209        name: "Headers".to_string(),
210        args: Vec::new(),
211        ty: Box::new(Type::InterfaceRef {
212            mangled: crate::mangle::prelude("Map"),
213            package: crate::Package::prelude(),
214            name: "Map".to_string(),
215            args: vec![Type::String, Type::String],
216        }),
217    }
218}
219
220fn insert_response_interface(defs: &mut PackageDeclaration) {
221    let mut properties = BTreeMap::new();
222    insert_property(
223        &mut properties,
224        "body",
225        Type::String,
226        "/** Response body, decoded as UTF-8. For binary responses, a future `bytes()` method will return `Uint8Array` without lossy decoding. */",
227    );
228    insert_property(
229        &mut properties,
230        "headers",
231        headers_type(),
232        "/** Response headers. Keys lowercased on the wire so `headers.get(\"content-type\")` works regardless of how the server cased them. */",
233    );
234    insert_property(
235        &mut properties,
236        "ok",
237        Type::Boolean,
238        "/** `true` iff `status` is in the 2xx range. */",
239    );
240    insert_property(
241        &mut properties,
242        "status",
243        Type::Number,
244        "/** HTTP status code (e.g. `200`, `404`). */",
245    );
246    insert_property(
247        &mut properties,
248        "statusText",
249        Type::String,
250        "/** Reason phrase (e.g. `\"OK\"`, `\"Not Found\"`). Empty when the server doesn't supply one. */",
251    );
252    insert_property(
253        &mut properties,
254        "url",
255        Type::String,
256        "/** Final URL after any redirects. Equals the original URL if there were no redirects. */",
257    );
258
259    let mut methods = BTreeMap::new();
260    methods.insert(
261        "throwForStatus".to_string(),
262        MethodSig {
263            optional: false,
264            generics: Vec::new(),
265            params: Vec::new(),
266            ret: Type::Void,
267            predicate: None,
268            doc: crate::doc(crate::FileId::HTTP,
269                "/** Throw if `ok` is `false`. The error message includes the status code, reason phrase, and final URL; catch it with `try`/`catch` like any other `Error`. */",
270            ),
271        },
272    );
273    methods.insert(
274        "toString".to_string(),
275        MethodSig {
276            optional: false,
277            generics: Vec::new(),
278            params: Vec::new(),
279            ret: Type::String,
280            predicate: None,
281            doc: crate::doc(crate::FileId::HTTP,
282                "/** Concise description of the response — `\"Response(200 OK, https://...)\"`. Built host-side for efficiency. */",
283            ),
284        },
285    );
286    // Recognised and lowered by the typechecker to `JSON.parse(body)`; it has no host
287    // export. Declared here only for the LLM-facing type surface — codegen skips
288    // emitting its import.
289    methods.insert(
290        "json".to_string(),
291        MethodSig {
292            optional: false,
293            generics: Vec::new(),
294            params: Vec::new(),
295            ret: Type::Unknown,
296            predicate: None,
297            doc: crate::doc(crate::FileId::HTTP,
298                "/** Parse the response body as JSON into `unknown`. Validate with `as`, for example `r.json() as User`. */",
299            ),
300        },
301    );
302
303    defs.types.insert(
304        "Response".to_string(),
305        TypeSymbol {
306            name: "Response".to_string(),
307            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Response"),
308            declaration_span: Span::at(crate::FileId::HTTP),
309            kind: TypeKind::Interface { index: None,
310                generics: Vec::new(),
311                methods,
312                properties,
313                dispatch: Dispatch::Direct,
314                doc: crate::doc(crate::FileId::HTTP,
315                    "/** Result of an HTTP call — status, headers, body, and the final URL after any redirects. Constructed only by the verb-form helpers; user code reads fields via property access and calls `throwForStatus()` / `toString()`. */",
316                ),
317            },
318        },
319    );
320}
321
322fn insert_download_options_interface(defs: &mut PackageDeclaration) {
323    let mut properties = BTreeMap::new();
324    insert_optional_property(
325        &mut properties,
326        "overwrite",
327        Type::Boolean,
328        "/** When `true`, clobber an existing file at `path`. Default `false` — refuse with a trap, since LLMs retry operations and silent overwrites cause data loss. */",
329    );
330    insert_optional_property(
331        &mut properties,
332        "maxBytes",
333        Type::Number,
334        "/** Maximum bytes to write to disk (post-decompression when `decompress` is true). Default: the tier-configured global limit. The download traps with a `response too large` error if exceeded. */",
335    );
336    insert_optional_property(
337        &mut properties,
338        "headers",
339        optional_headers_type(),
340        "/** Request headers — typically used for auth (`Authorization: Bearer …`). */",
341    );
342    insert_optional_property(
343        &mut properties,
344        "timeout",
345        Type::Number,
346        "/** Per-request timeout in milliseconds. Default 60_000 (vs 30_000 for the verb-form helpers — downloads are usually larger). */",
347    );
348    insert_optional_property(
349        &mut properties,
350        "decompress",
351        Type::Boolean,
352        "/** When `true`, transparently gunzip / unzstd the response if the `Content-Encoding` header (or URL suffix `.gz` / `.zst`) advertises a compressed stream. Default `false`: downloading `data.csv.gz` saves the `.gz` file as-is. */",
353    );
354
355    defs.types.insert(
356        "DownloadOptions".to_string(),
357        TypeSymbol {
358            name: "DownloadOptions".to_string(),
359            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "DownloadOptions"),
360            declaration_span: Span::at(crate::FileId::HTTP),
361            kind: TypeKind::Interface { index: None,
362                generics: Vec::new(),
363                methods: BTreeMap::new(),
364                properties,
365                // VTable: fields read via inline field-name scan, no per-field imports emitted.
366                dispatch: Dispatch::VTable,
367                doc: crate::doc(crate::FileId::HTTP,
368                    "/** Options bag for [`download`]. All fields optional. Pass `undefined` (or omit the param) to take every default. */",
369                ),
370            },
371        },
372    );
373}
374
375fn insert_optional_property(
376    properties: &mut BTreeMap<String, PropertySig>,
377    name: &str,
378    ty: Type,
379    doc: &str,
380) {
381    properties.insert(
382        name.to_string(),
383        PropertySig {
384            ty,
385            readonly: false,
386            intrinsic: false,
387            optional: true,
388            doc: crate::doc(crate::FileId::HTTP, doc),
389        },
390    );
391}
392
393fn insert_download_result_interface(defs: &mut PackageDeclaration) {
394    let mut properties = BTreeMap::new();
395    insert_property(
396        &mut properties,
397        "bytesWritten",
398        Type::Number,
399        "/** Bytes actually written to disk (post-decompression when `decompress` was set). */",
400    );
401    insert_property(
402        &mut properties,
403        "contentType",
404        Type::String,
405        "/** `Content-Type` header from the response, lowercased. Empty string when the server didn't send one. */",
406    );
407    insert_property(
408        &mut properties,
409        "duration_ms",
410        Type::Number,
411        "/** Wall-clock duration of the download in milliseconds, measured from temp-file open through atomic rename. */",
412    );
413    insert_property(
414        &mut properties,
415        "finalUrl",
416        Type::String,
417        "/** Final URL after any redirects. Equals the original URL if there were no redirects. */",
418    );
419    insert_property(
420        &mut properties,
421        "path",
422        Type::String,
423        "/** The VFS path the file was written to — echoes the `path` argument. */",
424    );
425    insert_property(
426        &mut properties,
427        "status",
428        Type::Number,
429        "/** HTTP status code (e.g. `200`). The download does not trap on non-2xx — a 404 still writes the response body to disk, on the theory that an HTML error page is sometimes what an agent wants. Check `status` to discriminate. */",
430    );
431
432    let mut methods = BTreeMap::new();
433    methods.insert(
434        "toString".to_string(),
435        MethodSig {
436            optional: false,
437            generics: Vec::new(),
438            params: Vec::new(),
439            ret: Type::String,
440            predicate: None,
441            doc: crate::doc(crate::FileId::HTTP,
442                "/** Concise description — `\"Download(200, 1024 bytes -> /workspace/file.csv)\"`. */",
443            ),
444        },
445    );
446
447    defs.types.insert(
448        "DownloadResult".to_string(),
449        TypeSymbol {
450            name: "DownloadResult".to_string(),
451            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "DownloadResult"),
452            declaration_span: Span::at(crate::FileId::HTTP),
453            kind: TypeKind::Interface { index: None,
454                generics: Vec::new(),
455                methods,
456                properties,
457                dispatch: Dispatch::Direct,
458                doc: crate::doc(crate::FileId::HTTP,
459                    "/** Result of [`download`] — what was written, where, and the response metadata. */",
460                ),
461            },
462        },
463    );
464}
465
466fn insert_property(
467    properties: &mut BTreeMap<String, PropertySig>,
468    name: &str,
469    ty: Type,
470    doc: &str,
471) {
472    properties.insert(
473        name.to_string(),
474        PropertySig {
475            ty,
476            readonly: true,
477            intrinsic: false,
478            optional: false,
479            doc: crate::doc(crate::FileId::HTTP, doc),
480        },
481    );
482}