1use 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
13const RESPONSE_JSON: &str = "json";
16
17pub(crate) fn response_json() -> crate::MangledName {
20 crate::mangle::extend(
21 &crate::mangle::package_symbol(MODULE_NAME, "Response"),
22 RESPONSE_JSON,
23 )
24}
25
26pub(crate) fn is_checked_json(mangled: &crate::MangledName) -> bool {
28 *mangled == response_json()
29}
30
31pub fn package_declaration() -> PackageDeclaration {
32 let mut defs = PackageDeclaration::with_package(MODULE_NAME);
33
34 defs.types.insert(
35 "Headers".to_string(),
36 TypeSymbol {
37 name: "Headers".to_string(),
38 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Headers"),
39 declaration_span: Span::at(crate::FileId::HTTP),
40 kind: TypeKind::Alias {
41 generics: Vec::new(),
42 ty: Type::InterfaceRef { mangled: crate::mangle::prelude("Map"),
43 package: crate::Package::prelude(),
44 name: "Map".to_string(),
45 args: vec![Type::String, Type::String],
46 },
47 doc: crate::doc(crate::FileId::HTTP,
48 "/** HTTP header bag: `Map<string, string>` with names lowercased on the wire. Future slice may add case-insensitive lookup. */",
49 ),
50 },
51 },
52 );
53
54 insert_response_interface(&mut defs);
55 defs.runtime_generics.insert(response_json());
56 insert_download_options_interface(&mut defs);
57 insert_download_result_interface(&mut defs);
58
59 for verb in &["get", "delete", "head", "options"] {
61 insert_verb_fn(&mut defs, verb, false);
62 }
63 for verb in &["post", "put", "patch"] {
64 insert_verb_fn(&mut defs, verb, true);
65 }
66
67 defs.values.insert(
68 "download".to_string(),
69 ValueSymbol {
70 name: "download".to_string(),
71 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "download"),
72 declaration_span: Span::at(crate::FileId::HTTP),
73 kind: ValueKind::Function {
74 generics: Vec::new(),
75 params: vec![
76 Param::new("url", Type::String),
77 Param::new("path", Type::String),
78 Param::with_default(
79 "options",
80 optional_download_options_type(),
81 DefaultValue::Undefined,
82 ),
83 ],
84 ret: download_result_type(),
85 type_predicate: None,
86 doc: crate::doc(crate::FileId::HTTP,
87 "/**\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 */",
88 ),
89 },
90 },
91 );
92
93 defs.values.insert(
94 "request".to_string(),
95 ValueSymbol {
96 name: "request".to_string(),
97 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "request"),
98 declaration_span: Span::at(crate::FileId::HTTP),
99 kind: ValueKind::Function {
100 generics: Vec::new(),
101 params: vec![
102 Param::new("method", Type::String),
103 Param::new("url", Type::String),
104 Param::with_default(
105 "body",
106 optional_body_type(),
107 DefaultValue::Undefined,
108 ),
109 Param::with_default(
110 "headers",
111 optional_headers_type(),
112 DefaultValue::Undefined,
113 ),
114 ],
115 ret: response_type(),
116 type_predicate: None,
117 doc: crate::doc(crate::FileId::HTTP,
118 "/**\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.request { method: $method, host: $url.host, path: $url.path, body_size: number, timeout_ms: number }\n */",
119 ),
120 },
121 },
122 );
123
124 defs
125}
126
127fn insert_verb_fn(defs: &mut PackageDeclaration, verb: &str, has_body: bool) {
128 let upper = verb.to_uppercase();
129 let doc = if has_body {
130 format!(
131 "/**\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 */",
132 )
133 } else {
134 format!(
135 "/**\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 */",
136 )
137 };
138 let capability = format!(
139 " * @capability http.{verb} {{ host: $url.host, path: $url.path, body_size: number, timeout_ms: number }}\n */"
140 );
141 let doc = doc.replace(" */", &capability);
142 let mut params = vec![Param::new("url", Type::String)];
143 if has_body {
144 params.push(Param::with_default(
145 "body",
146 optional_body_type(),
147 DefaultValue::Undefined,
148 ));
149 }
150 params.push(Param::with_default(
151 "headers",
152 optional_headers_type(),
153 DefaultValue::Undefined,
154 ));
155 defs.values.insert(
156 verb.to_string(),
157 ValueSymbol {
158 name: verb.to_string(),
159 mangled_name: crate::mangle::package_symbol(MODULE_NAME, verb),
160 declaration_span: Span::at(crate::FileId::HTTP),
161 kind: ValueKind::Function {
162 generics: Vec::new(),
163 params,
164 ret: response_type(),
165 type_predicate: None,
166 doc: crate::doc(crate::FileId::HTTP, &doc),
167 },
168 },
169 );
170}
171
172fn optional_headers_type() -> Type {
173 Type::Union(vec![headers_type(), Type::Undefined])
174}
175
176fn optional_body_type() -> Type {
177 Type::Union(vec![
178 Type::String,
179 Type::Uint8Array,
180 Type::any_object(),
182 Type::Array(Box::new(Type::Unknown)),
183 Type::Null,
184 Type::Undefined,
185 ])
186}
187
188fn response_type() -> Type {
189 Type::InterfaceRef {
190 mangled: crate::mangle::package_symbol(MODULE_NAME, "Response"),
191 package: crate::Package(MODULE_NAME.to_string()),
192 name: "Response".to_string(),
193 args: Vec::new(),
194 }
195}
196
197fn download_result_type() -> Type {
198 Type::InterfaceRef {
199 mangled: crate::mangle::package_symbol(MODULE_NAME, "DownloadResult"),
200 package: crate::Package(MODULE_NAME.to_string()),
201 name: "DownloadResult".to_string(),
202 args: Vec::new(),
203 }
204}
205
206fn download_options_type() -> Type {
207 Type::InterfaceRef {
208 mangled: crate::mangle::package_symbol(MODULE_NAME, "DownloadOptions"),
209 package: crate::Package(MODULE_NAME.to_string()),
210 name: "DownloadOptions".to_string(),
211 args: Vec::new(),
212 }
213}
214
215fn optional_download_options_type() -> Type {
216 Type::Union(vec![download_options_type(), Type::Undefined])
217}
218
219fn headers_type() -> Type {
220 Type::Alias {
221 mangled: crate::mangle::package_symbol(MODULE_NAME, "Headers"),
222 package: crate::Package(MODULE_NAME.to_string()),
223 name: "Headers".to_string(),
224 args: Vec::new(),
225 ty: Box::new(Type::InterfaceRef {
226 mangled: crate::mangle::prelude("Map"),
227 package: crate::Package::prelude(),
228 name: "Map".to_string(),
229 args: vec![Type::String, Type::String],
230 }),
231 }
232}
233
234fn insert_response_interface(defs: &mut PackageDeclaration) {
235 let mut properties = BTreeMap::new();
236 insert_property(
237 &mut properties,
238 "body",
239 Type::String,
240 "/** Response body, decoded as UTF-8. For binary responses, a future `bytes()` method will return `Uint8Array` without lossy decoding. */",
241 );
242 insert_property(
243 &mut properties,
244 "headers",
245 headers_type(),
246 "/** Response headers. Keys lowercased on the wire so `headers.get(\"content-type\")` works regardless of how the server cased them. */",
247 );
248 insert_property(
249 &mut properties,
250 "ok",
251 Type::Boolean,
252 "/** `true` iff `status` is in the 2xx range. */",
253 );
254 insert_property(
255 &mut properties,
256 "status",
257 Type::Number,
258 "/** HTTP status code (e.g. `200`, `404`). */",
259 );
260 insert_property(
261 &mut properties,
262 "statusText",
263 Type::String,
264 "/** Reason phrase (e.g. `\"OK\"`, `\"Not Found\"`). Empty when the server doesn't supply one. */",
265 );
266 insert_property(
267 &mut properties,
268 "url",
269 Type::String,
270 "/** Final URL after any redirects. Equals the original URL if there were no redirects. */",
271 );
272
273 let mut methods = BTreeMap::new();
274 methods.insert(
275 "throwForStatus".to_string(),
276 MethodSig {
277 optional: false,
278 generics: Vec::new(),
279 params: Vec::new(),
280 ret: Type::Void,
281 predicate: None,
282 doc: crate::doc(crate::FileId::HTTP,
283 "/** 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`. */",
284 ),
285 },
286 );
287 methods.insert(
288 "toString".to_string(),
289 MethodSig {
290 optional: false,
291 generics: Vec::new(),
292 params: Vec::new(),
293 ret: Type::String,
294 predicate: None,
295 doc: crate::doc(crate::FileId::HTTP,
296 "/** Concise description of the response — `\"Response(200 OK, https://...)\"`. Built host-side for efficiency. */",
297 ),
298 },
299 );
300 methods.insert(
303 RESPONSE_JSON.to_string(),
304 MethodSig {
305 optional: false,
306 generics: vec!["T".to_string()],
307 params: Vec::new(),
308 ret: Type::TypeVar("T".to_string()),
309 predicate: None,
310 doc: crate::doc(crate::FileId::HTTP,
311 "/** Parse the response body as JSON and check it against `T`, for example `r.json<User>()`. The check covers every field, element, and union arm; a body that does not match throws a catchable `TypeError` naming the type, a body that is not JSON throws a `SyntaxError`, and a number past the `number` range throws a `RangeError`. The expected type supplies `T` when none is written (`const user: User = r.json()`); with neither, the result is an unchecked `unknown`. */",
312 ),
313 },
314 );
315
316 defs.types.insert(
317 "Response".to_string(),
318 TypeSymbol {
319 name: "Response".to_string(),
320 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Response"),
321 declaration_span: Span::at(crate::FileId::HTTP),
322 kind: TypeKind::Interface { index: None,
323 generics: Vec::new(),
324 methods,
325 properties,
326 dispatch: Dispatch::Direct,
327 doc: crate::doc(crate::FileId::HTTP,
328 "/** 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 `json<T>()`, `throwForStatus()` and `toString()`. */",
329 ),
330 },
331 },
332 );
333}
334
335fn insert_download_options_interface(defs: &mut PackageDeclaration) {
336 let mut properties = BTreeMap::new();
337 insert_optional_property(
338 &mut properties,
339 "overwrite",
340 Type::Boolean,
341 "/** When `true`, clobber an existing file at `path`. Default `false` — refuse with a trap, since LLMs retry operations and silent overwrites cause data loss. */",
342 );
343 insert_optional_property(
344 &mut properties,
345 "maxBytes",
346 Type::Number,
347 "/** 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. */",
348 );
349 insert_optional_property(
350 &mut properties,
351 "headers",
352 optional_headers_type(),
353 "/** Request headers — typically used for auth (`Authorization: Bearer …`). */",
354 );
355 insert_optional_property(
356 &mut properties,
357 "timeout",
358 Type::Number,
359 "/** Per-request timeout in milliseconds. Default 60_000 (vs 30_000 for the verb-form helpers — downloads are usually larger). */",
360 );
361 insert_optional_property(
362 &mut properties,
363 "decompress",
364 Type::Boolean,
365 "/** 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. */",
366 );
367
368 defs.types.insert(
369 "DownloadOptions".to_string(),
370 TypeSymbol {
371 name: "DownloadOptions".to_string(),
372 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "DownloadOptions"),
373 declaration_span: Span::at(crate::FileId::HTTP),
374 kind: TypeKind::Interface { index: None,
375 generics: Vec::new(),
376 methods: BTreeMap::new(),
377 properties,
378 dispatch: Dispatch::VTable,
380 doc: crate::doc(crate::FileId::HTTP,
381 "/** Options bag for [`download`]. All fields optional. Pass `undefined` (or omit the param) to take every default. */",
382 ),
383 },
384 },
385 );
386}
387
388fn insert_optional_property(
389 properties: &mut BTreeMap<String, PropertySig>,
390 name: &str,
391 ty: Type,
392 doc: &str,
393) {
394 properties.insert(
395 name.to_string(),
396 PropertySig {
397 ty,
398 readonly: false,
399 intrinsic: false,
400 optional: true,
401 doc: crate::doc(crate::FileId::HTTP, doc),
402 },
403 );
404}
405
406fn insert_download_result_interface(defs: &mut PackageDeclaration) {
407 let mut properties = BTreeMap::new();
408 insert_property(
409 &mut properties,
410 "bytesWritten",
411 Type::Number,
412 "/** Bytes actually written to disk (post-decompression when `decompress` was set). */",
413 );
414 insert_property(
415 &mut properties,
416 "contentType",
417 Type::String,
418 "/** `Content-Type` header from the response, lowercased. Empty string when the server didn't send one. */",
419 );
420 insert_property(
421 &mut properties,
422 "duration_ms",
423 Type::Number,
424 "/** Wall-clock duration of the download in milliseconds, measured from temp-file open through atomic rename. */",
425 );
426 insert_property(
427 &mut properties,
428 "finalUrl",
429 Type::String,
430 "/** Final URL after any redirects. Equals the original URL if there were no redirects. */",
431 );
432 insert_property(
433 &mut properties,
434 "path",
435 Type::String,
436 "/** The VFS path the file was written to — echoes the `path` argument. */",
437 );
438 insert_property(
439 &mut properties,
440 "status",
441 Type::Number,
442 "/** 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. */",
443 );
444
445 let mut methods = BTreeMap::new();
446 methods.insert(
447 "toString".to_string(),
448 MethodSig {
449 optional: false,
450 generics: Vec::new(),
451 params: Vec::new(),
452 ret: Type::String,
453 predicate: None,
454 doc: crate::doc(crate::FileId::HTTP,
455 "/** Concise description — `\"Download(200, 1024 bytes -> /workspace/file.csv)\"`. */",
456 ),
457 },
458 );
459
460 defs.types.insert(
461 "DownloadResult".to_string(),
462 TypeSymbol {
463 name: "DownloadResult".to_string(),
464 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "DownloadResult"),
465 declaration_span: Span::at(crate::FileId::HTTP),
466 kind: TypeKind::Interface { index: None,
467 generics: Vec::new(),
468 methods,
469 properties,
470 dispatch: Dispatch::Direct,
471 doc: crate::doc(crate::FileId::HTTP,
472 "/** Result of [`download`] — what was written, where, and the response metadata. */",
473 ),
474 },
475 },
476 );
477}
478
479fn insert_property(
480 properties: &mut BTreeMap<String, PropertySig>,
481 name: &str,
482 ty: Type,
483 doc: &str,
484) {
485 properties.insert(
486 name.to_string(),
487 PropertySig {
488 ty,
489 readonly: true,
490 intrinsic: false,
491 optional: false,
492 doc: crate::doc(crate::FileId::HTTP, doc),
493 },
494 );
495}