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        // JSON-encoded at runtime via the `$Object` `toJson` vtable slot.
162        Type::any_object(),
163        Type::Array(Box::new(Type::Unknown)),
164        Type::Null,
165        Type::Undefined,
166    ])
167}
168
169fn response_type() -> Type {
170    Type::InterfaceRef {
171        mangled: crate::mangle::package_symbol(MODULE_NAME, "Response"),
172        package: crate::Package(MODULE_NAME.to_string()),
173        name: "Response".to_string(),
174        args: Vec::new(),
175    }
176}
177
178fn download_result_type() -> Type {
179    Type::InterfaceRef {
180        mangled: crate::mangle::package_symbol(MODULE_NAME, "DownloadResult"),
181        package: crate::Package(MODULE_NAME.to_string()),
182        name: "DownloadResult".to_string(),
183        args: Vec::new(),
184    }
185}
186
187fn download_options_type() -> Type {
188    Type::InterfaceRef {
189        mangled: crate::mangle::package_symbol(MODULE_NAME, "DownloadOptions"),
190        package: crate::Package(MODULE_NAME.to_string()),
191        name: "DownloadOptions".to_string(),
192        args: Vec::new(),
193    }
194}
195
196fn optional_download_options_type() -> Type {
197    Type::Union(vec![download_options_type(), Type::Undefined])
198}
199
200fn headers_type() -> Type {
201    Type::Alias {
202        mangled: crate::mangle::package_symbol(MODULE_NAME, "Headers"),
203        package: crate::Package(MODULE_NAME.to_string()),
204        name: "Headers".to_string(),
205        args: Vec::new(),
206        ty: Box::new(Type::InterfaceRef {
207            mangled: crate::mangle::prelude("Map"),
208            package: crate::Package::prelude(),
209            name: "Map".to_string(),
210            args: vec![Type::String, Type::String],
211        }),
212    }
213}
214
215fn insert_response_interface(defs: &mut PackageDeclaration) {
216    let mut properties = BTreeMap::new();
217    insert_property(
218        &mut properties,
219        "body",
220        Type::String,
221        "/** Response body, decoded as UTF-8. For binary responses, a future `bytes()` method will return `Uint8Array` without lossy decoding. */",
222    );
223    insert_property(
224        &mut properties,
225        "headers",
226        headers_type(),
227        "/** Response headers. Keys lowercased on the wire so `headers.get(\"content-type\")` works regardless of how the server cased them. */",
228    );
229    insert_property(
230        &mut properties,
231        "ok",
232        Type::Boolean,
233        "/** `true` iff `status` is in the 2xx range. */",
234    );
235    insert_property(
236        &mut properties,
237        "status",
238        Type::Number,
239        "/** HTTP status code (e.g. `200`, `404`). */",
240    );
241    insert_property(
242        &mut properties,
243        "statusText",
244        Type::String,
245        "/** Reason phrase (e.g. `\"OK\"`, `\"Not Found\"`). Empty when the server doesn't supply one. */",
246    );
247    insert_property(
248        &mut properties,
249        "url",
250        Type::String,
251        "/** Final URL after any redirects. Equals the original URL if there were no redirects. */",
252    );
253
254    let mut methods = BTreeMap::new();
255    methods.insert(
256        "throwForStatus".to_string(),
257        MethodSig {
258            optional: false,
259            generics: Vec::new(),
260            params: Vec::new(),
261            ret: Type::Void,
262            predicate: None,
263            doc: crate::doc(crate::FileId::HTTP,
264                "/** 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`. */",
265            ),
266        },
267    );
268    methods.insert(
269        "toString".to_string(),
270        MethodSig {
271            optional: false,
272            generics: Vec::new(),
273            params: Vec::new(),
274            ret: Type::String,
275            predicate: None,
276            doc: crate::doc(crate::FileId::HTTP,
277                "/** Concise description of the response — `\"Response(200 OK, https://...)\"`. Built host-side for efficiency. */",
278            ),
279        },
280    );
281    // Recognised and lowered by the typechecker to `JSON.parse(body)`; it has no host
282    // export. Declared here only for the LLM-facing type surface — codegen skips
283    // emitting its import.
284    methods.insert(
285        "json".to_string(),
286        MethodSig {
287            optional: false,
288            generics: Vec::new(),
289            params: Vec::new(),
290            ret: Type::Unknown,
291            predicate: None,
292            doc: crate::doc(crate::FileId::HTTP,
293                "/** Parse the response body as JSON into `unknown`. Validate with `as`, for example `r.json() as User`. */",
294            ),
295        },
296    );
297
298    defs.types.insert(
299        "Response".to_string(),
300        TypeSymbol {
301            name: "Response".to_string(),
302            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Response"),
303            declaration_span: Span::at(crate::FileId::HTTP),
304            kind: TypeKind::Interface { index: None,
305                generics: Vec::new(),
306                methods,
307                properties,
308                dispatch: Dispatch::Direct,
309                doc: crate::doc(crate::FileId::HTTP,
310                    "/** 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()`. */",
311                ),
312            },
313        },
314    );
315}
316
317fn insert_download_options_interface(defs: &mut PackageDeclaration) {
318    let mut properties = BTreeMap::new();
319    insert_optional_property(
320        &mut properties,
321        "overwrite",
322        Type::Boolean,
323        "/** When `true`, clobber an existing file at `path`. Default `false` — refuse with a trap, since LLMs retry operations and silent overwrites cause data loss. */",
324    );
325    insert_optional_property(
326        &mut properties,
327        "maxBytes",
328        Type::Number,
329        "/** 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. */",
330    );
331    insert_optional_property(
332        &mut properties,
333        "headers",
334        optional_headers_type(),
335        "/** Request headers — typically used for auth (`Authorization: Bearer …`). */",
336    );
337    insert_optional_property(
338        &mut properties,
339        "timeout",
340        Type::Number,
341        "/** Per-request timeout in milliseconds. Default 60_000 (vs 30_000 for the verb-form helpers — downloads are usually larger). */",
342    );
343    insert_optional_property(
344        &mut properties,
345        "decompress",
346        Type::Boolean,
347        "/** 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. */",
348    );
349
350    defs.types.insert(
351        "DownloadOptions".to_string(),
352        TypeSymbol {
353            name: "DownloadOptions".to_string(),
354            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "DownloadOptions"),
355            declaration_span: Span::at(crate::FileId::HTTP),
356            kind: TypeKind::Interface { index: None,
357                generics: Vec::new(),
358                methods: BTreeMap::new(),
359                properties,
360                // VTable: fields read via inline field-name scan, no per-field imports emitted.
361                dispatch: Dispatch::VTable,
362                doc: crate::doc(crate::FileId::HTTP,
363                    "/** Options bag for [`download`]. All fields optional. Pass `undefined` (or omit the param) to take every default. */",
364                ),
365            },
366        },
367    );
368}
369
370fn insert_optional_property(
371    properties: &mut BTreeMap<String, PropertySig>,
372    name: &str,
373    ty: Type,
374    doc: &str,
375) {
376    properties.insert(
377        name.to_string(),
378        PropertySig {
379            ty,
380            readonly: false,
381            intrinsic: false,
382            optional: true,
383            doc: crate::doc(crate::FileId::HTTP, doc),
384        },
385    );
386}
387
388fn insert_download_result_interface(defs: &mut PackageDeclaration) {
389    let mut properties = BTreeMap::new();
390    insert_property(
391        &mut properties,
392        "bytesWritten",
393        Type::Number,
394        "/** Bytes actually written to disk (post-decompression when `decompress` was set). */",
395    );
396    insert_property(
397        &mut properties,
398        "contentType",
399        Type::String,
400        "/** `Content-Type` header from the response, lowercased. Empty string when the server didn't send one. */",
401    );
402    insert_property(
403        &mut properties,
404        "duration_ms",
405        Type::Number,
406        "/** Wall-clock duration of the download in milliseconds, measured from temp-file open through atomic rename. */",
407    );
408    insert_property(
409        &mut properties,
410        "finalUrl",
411        Type::String,
412        "/** Final URL after any redirects. Equals the original URL if there were no redirects. */",
413    );
414    insert_property(
415        &mut properties,
416        "path",
417        Type::String,
418        "/** The VFS path the file was written to — echoes the `path` argument. */",
419    );
420    insert_property(
421        &mut properties,
422        "status",
423        Type::Number,
424        "/** 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. */",
425    );
426
427    let mut methods = BTreeMap::new();
428    methods.insert(
429        "toString".to_string(),
430        MethodSig {
431            optional: false,
432            generics: Vec::new(),
433            params: Vec::new(),
434            ret: Type::String,
435            predicate: None,
436            doc: crate::doc(crate::FileId::HTTP,
437                "/** Concise description — `\"Download(200, 1024 bytes -> /workspace/file.csv)\"`. */",
438            ),
439        },
440    );
441
442    defs.types.insert(
443        "DownloadResult".to_string(),
444        TypeSymbol {
445            name: "DownloadResult".to_string(),
446            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "DownloadResult"),
447            declaration_span: Span::at(crate::FileId::HTTP),
448            kind: TypeKind::Interface { index: None,
449                generics: Vec::new(),
450                methods,
451                properties,
452                dispatch: Dispatch::Direct,
453                doc: crate::doc(crate::FileId::HTTP,
454                    "/** Result of [`download`] — what was written, where, and the response metadata. */",
455                ),
456            },
457        },
458    );
459}
460
461fn insert_property(
462    properties: &mut BTreeMap<String, PropertySig>,
463    name: &str,
464    ty: Type,
465    doc: &str,
466) {
467    properties.insert(
468        name.to_string(),
469        PropertySig {
470            ty,
471            readonly: true,
472            intrinsic: false,
473            optional: false,
474            doc: crate::doc(crate::FileId::HTTP, doc),
475        },
476    );
477}