///|
/// JavaScript fetch() wrapper for JS target
///|
/// Optionally disable TLS certificate verification for Node.js.
/// This is gated behind CRATER_ALLOW_INSECURE_TLS=1 so normal test runs
/// do not trigger Node's insecure TLS warning.
extern "js" fn js_disable_tls_verify() -> Unit =
#| () => {
#| if (
#| typeof process !== 'undefined' &&
#| process.env &&
#| process.env.CRATER_ALLOW_INSECURE_TLS === '1'
#| ) {
#| process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
#| }
#| }
///|
fn init {
js_disable_tls_verify()
}
///|
/// Internal: fetch response as JSON string
/// Binary content types (image/*) are returned with base64-encoded body
extern "js" fn js_fetch_internal(
url : String,
http_method : String,
headers_json : String,
body_json : String,
timeout_ms : Int,
) -> @js_async.Promise[String] =
#| async (url, method, headersJson, bodyJson, timeoutMs) => {
#| const headers = JSON.parse(headersJson);
#| const body = JSON.parse(bodyJson);
#| const controller = new AbortController();
#| const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
#| try {
#| const init = { method, headers, signal: controller.signal };
#| if (method !== 'GET' && method !== 'HEAD' && body.length > 0) {
#| init.body = body;
#| }
#| const res = await fetch(url, init);
#| clearTimeout(timeoutId);
#| const resHeaders = {};
#| res.headers.forEach((v, k) => { resHeaders[k] = v; });
#| const ct = (resHeaders['content-type'] || '').toLowerCase();
#| let responseBody;
#| if (ct.startsWith('image/') && !ct.includes('svg')) {
#| const buf = await res.arrayBuffer();
#| const bytes = new Uint8Array(buf);
#| let binary = '';
#| for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
#| responseBody = 'base64:' + btoa(binary);
#| } else {
#| responseBody = await res.text();
#| }
#| return JSON.stringify({ status: res.status, headers: resHeaders, body: responseBody });
#| } catch (e) {
#| clearTimeout(timeoutId);
#| if (e.name === 'AbortError') {
#| throw new Error('TIMEOUT');
#| }
#| throw e;
#| }
#| }
///|
fn escape_json_string(s : String) -> String {
let buf = StringBuilder::new()
for c in s {
match c {
'"' => buf.write_string("\\\"")
'\\' => buf.write_string("\\\\")
'\n' => buf.write_string("\\n")
'\r' => buf.write_string("\\r")
'\t' => buf.write_string("\\t")
_ => buf.write_char(c)
}
}
buf.to_string()
}
///|
fn body_to_json(body : String) -> String {
"\"" + escape_json_string(body) + "\""
}
///|
/// Parse JSON response into HttpResponse
#warnings("-unused_error_type")
fn parse_response_json(json : String) -> HttpResponse raise HttpError {
// Simple JSON parsing for response
// Format: {"status":200,"headers":{"key":"value"},"body":"..."}
let status = extract_json_int(json, "status")
let body = extract_json_string(json, "body")
let headers = parse_headers_json(json)
{ status, headers, body }
}
///|
/// Parse headers object from JSON response
fn parse_headers_json(json_str : String) -> Map[String, String] {
let headers : Map[String, String] = {}
if json_str.length() == 0 || json_str == "{}" {
return headers
}
let json = @json.parse(json_str) catch { _ => return headers }
match json {
Object(map) =>
match map.get("headers") {
Some(Object(hmap)) =>
hmap.each(fn(k, v) {
match v {
String(s) => headers[k.to_lower()] = s
_ => ()
}
})
_ => ()
}
_ => ()
}
headers
}
///|
/// Extract integer value from JSON by key
fn extract_json_int(json : String, key : String) -> Int {
let pattern = "\"" + key + "\":"
match json.find(pattern) {
Some(idx) => {
let start = idx + pattern.length()
let mut end = start
let chars = json.to_array()
while end < chars.length() && chars[end] >= '0' && chars[end] <= '9' {
end = end + 1
}
try {
let num_str = json.unsafe_substring(start~, end~)
@string.parse_int(num_str)
} catch {
_ => 0
}
}
None => 0
}
}
///|
/// Extract string value from JSON by key
fn extract_json_string(json : String, key : String) -> String {
let pattern = "\"" + key + "\":\""
match json.find(pattern) {
Some(idx) => {
let start = idx + pattern.length()
let mut end = start
let chars = json.to_array()
while end < chars.length() {
if chars[end] == '"' && (end == start || chars[end - 1] != '\\') {
break
}
end = end + 1
}
unescape_json_string(json.unsafe_substring(start~, end~))
}
None => ""
}
}
///|
/// Unescape JSON string
fn unescape_json_string(s : String) -> String {
let buf = StringBuilder::new()
let chars = s.to_array()
let mut i = 0
while i < chars.length() {
if chars[i] == '\\' && i + 1 < chars.length() {
match chars[i + 1] {
'n' => buf.write_char('\n')
'r' => buf.write_char('\r')
't' => buf.write_char('\t')
'"' => buf.write_char('"')
'\\' => buf.write_char('\\')
c => {
buf.write_char('\\')
buf.write_char(c)
}
}
i = i + 2
} else {
buf.write_char(chars[i])
i = i + 1
}
}
buf.to_string()
}
///|
/// Fetch URL using JavaScript fetch() API
pub async fn fetch(
url : String,
options? : FetchOptions = FetchOptions::default(),
) -> HttpResponse raise HttpError {
let prepared = prepare_fetch_options(url, options)
let headers_json = headers_to_json(prepared.headers)
let body_json = body_to_json(prepared.body)
let controller = @js_async.AbortController::new()
let promise = js_fetch_internal(
url,
prepared.http_method,
headers_json,
body_json,
prepared.timeout_ms,
)
let result_json = @js_async.Promise::wait(
promise,
abort_controller=controller,
) catch {
err => {
let msg = err.to_string()
if msg.contains("TIMEOUT") {
raise TimeoutError
}
raise NetworkError(msg)
}
}
let response = parse_response_json(result_json)
enforce_cors_response(url, prepared, response)
}