Skip to main content

atmos/os_lib/js/builtins/
static_classlist.rs

1// 分割: builtins.rs より機械的に移動(2026-07-16 リファクタ フェーズ2)。
2// ロジック不変。可視性のみ pub(crate) へ昇格し、親が pub(crate) use で再エクスポート。
3use super::*;
4
5// ===== String / Array 静的 =====
6/// `String.fromCharCode(...codes)`(ES1)が `String.fromCodePoint`(ES2015)と全く同じ
7/// 実装を共有しており、2つのバグがあった。(1) 各引数を 16bit コード単位へマスクしておらず、
8/// 仕様上 `fromCharCode(0x1F600)` は単一の UTF-16 コード単位(`0xF600` 相当)になるべき
9/// ところ絵文字そのものになっていた。(2) サロゲートペア(`fromCharCode(0xD83D,0xDE00)` で
10/// 絵文字1文字を表す ES1 以来の定番イディオム)を一切結合しておらず、個々の値は単独では
11/// 無効な Unicode スカラー値のため `char::from_u32` が `None` を返し無音で消えていた。
12/// UTF-16 コード単位列として正しくデコードする(`String::from_utf16_lossy` がサロゲート
13/// ペアの結合を担う)ことで両方解消する。
14pub(crate) fn string_from_char_code(_it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
15    let units: Vec<u16> = a
16        .iter()
17        .map(|v| (v.to_number() as i64 as u32 & 0xFFFF) as u16)
18        .collect();
19    Ok(Value::str(String::from_utf16_lossy(&units)))
20}
21/// `String.fromCodePoint(...codePoints)`(ES2015)。`fromCharCode` と異なり各引数は
22/// フルの Unicode コードポイント(〜0x10FFFF)で、16bit マスクやサロゲートペア結合は
23/// 行わない。不正なコードポイントは仕様どおり `RangeError` 相当の例外にする。
24pub(crate) fn string_from_code_point(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
25    let mut s = String::new();
26    for v in a {
27        let n = v.to_number();
28        if !n.is_finite() || n < 0.0 || n > 0x10FFFF as f64 || libm::floor(n) != n {
29            return Err(it.error("Invalid code point"));
30        }
31        match char::from_u32(n as u32) {
32            Some(c) => s.push(c),
33            None => return Err(it.error("Invalid code point")),
34        }
35    }
36    Ok(Value::str(s))
37}
38pub(crate) fn array_of(_it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
39    Ok(Value::Object(Obj::array(a.to_vec())))
40}
41/// `String.raw({raw: [...]}, ...substitutions)`(ES2015)。タグ付きテンプレートの
42/// `` String.raw`a\nb${x}c` `` で使う定番の組込みタグ関数。`raw` 配列の要素を
43/// エスケープ解決せずそのまま連結し、間に `substitutions` を挟む。
44pub(crate) fn string_raw(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
45    let strings = arg(a, 0);
46    let raw = it.get_property(&strings, "raw").unwrap_or(Value::Undefined);
47    let raw_items: Vec<Value> = match &raw {
48        Value::Object(o) => match &o.borrow().kind {
49            ObjKind::Array(items) => items.clone(),
50            _ => Vec::new(),
51        },
52        _ => Vec::new(),
53    };
54    let mut out = String::new();
55    for (i, r) in raw_items.iter().enumerate() {
56        out.push_str(&r.to_js_string());
57        if let Some(sub) = a.get(i + 1) {
58            out.push_str(&sub.to_js_string());
59        }
60    }
61    Ok(Value::str(out))
62}
63
64// ===== classList 追加(item / value / toString)=====
65pub fn dom_classlist_item(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
66    if let Some(idx) = this_host_idx(&this, "classList:") {
67        let i = arg(a, 0).to_number() as usize;
68        let cls = it
69            .dom
70            .borrow()
71            .nodes
72            .get(idx)
73            .and_then(|n| n.classes.get(i).cloned());
74        return Ok(match cls {
75            Some(c) => Value::str(c),
76            None => Value::Null,
77        });
78    }
79    Ok(Value::Null)
80}
81pub fn dom_classlist_to_string(it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
82    if let Some(idx) = this_host_idx(&this, "classList:") {
83        return Ok(Value::str(
84            it.dom
85                .borrow()
86                .nodes
87                .get(idx)
88                .map(|n| n.classes.join(" "))
89                .unwrap_or_default(),
90        ));
91    }
92    Ok(Value::str(""))
93}
94
95/// グローバルの location オブジェクトの各成分を url から更新する(メソッドは保持)。
96pub fn update_location(global: &Rc<RefCell<Scope>>, url: &str) {
97    let loc = match global.borrow().vars.get("location") {
98        Some(Value::Object(o)) => o.clone(),
99        _ => return,
100    };
101    let mut b = loc.borrow_mut();
102    for (k, v) in location_components(url) {
103        b.props.insert(String::from(k), Value::str(v));
104    }
105}
106
107/// 相対URLを base(現在ページの絶対URL)基準で解決する。
108/// 絶対(http/https)・`data:` はそのまま。`/...` は scheme+host 直下、その他はディレクトリ相対。
109/// `string_slice` lint 回避のため split_once / str::get のみ使用。
110/// パスセグメントの `.`/`..` を解決する(`RFC 3986 §5.2.4` の簡略版)。
111/// 以前は `resolve_url` がディレクトリ相対結合のみで `.`/`..` をそのまま
112/// 文字列結合していたため、`new URL('../style.css', 'https://h.com/a/b/c')`
113/// のような(相対URLで最も頻繁に使われる)親ディレクトリ参照が
114/// `https://h.com/a/b/../style.css` のように未解決のまま残るバグだった。
115pub(crate) fn normalize_dot_segments(path: &str) -> String {
116    let mut segs: alloc::vec::Vec<&str> = alloc::vec::Vec::new();
117    for seg in path.split('/') {
118        match seg {
119            "" | "." => {}
120            ".." => {
121                segs.pop();
122            }
123            s => segs.push(s),
124        }
125    }
126    alloc::format!("/{}", segs.join("/"))
127}
128
129pub fn resolve_url(base: &str, url: &str) -> String {
130    if url.starts_with("http://") || url.starts_with("https://") || url.starts_with("data:") {
131        return String::from(url);
132    }
133    // スキームを持つ URL(RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":")は絶対 URL。
134    // 【2026-10-17】従来は http/https/data 以外(`blob:`・`mailto:`・`about:` 等)を相対パスとして
135    // ベースに連結しており、`video.src = URL.createObjectURL(ms)` の読み出しが
136    // `http://host/blob:mediasource-…` に化けていた。
137    if let Some((scheme, _)) = url.split_once(':') {
138        let mut cs = scheme.chars();
139        if cs.next().is_some_and(|c| c.is_ascii_alphabetic())
140            && cs.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
141            && !scheme.contains('/')
142        {
143            return String::from(url);
144        }
145    }
146    // プロトコル相対URL(`//host/path`。`<script src="//cdn.example.com/x.js">`
147    // 等の定番パターン)が丸ごと未対応で、ベースのホスト配下の相対パスとして
148    // 誤解決されるバグだった(`//cdn.example.com/x.js` が本来
149    // `https://cdn.example.com/x.js`(別ホスト)になるべきところ、
150    // `https://e.com/cdn.example.com/x.js`(元のホスト配下のパス)という
151    // 壊れた結果になっていた)。ベースのスキームを引き継ぎ、残りをそのまま
152    // ホスト+パスとして使う。
153    if let Some(rest) = url.strip_prefix("//") {
154        let scheme = if base.starts_with("https://") { "https://" } else { "http://" };
155        return alloc::format!("{scheme}{rest}");
156    }
157    let (scheme, after) = if let Some(a) = base.strip_prefix("https://") {
158        ("https://", a)
159    } else if let Some(a) = base.strip_prefix("http://") {
160        ("http://", a)
161    } else {
162        return String::from(url); // base が使えない → そのまま(後段でエラー判定)
163    };
164    let (host, path) = match after.split_once('/') {
165        Some((h, p)) => (h, p), // p は先頭スラッシュ無しのパス
166        None => (after, ""),
167    };
168    // クエリのみ(`?x=2`)/フラグメントのみ(`#frag`)の相対参照は、仕様上
169    // ベースのパス(`?` のみの場合はクエリより前の部分)を保持したまま該当
170    // 部分だけ置き換える必要があるが、以前は他の相対パスと同じ「ディレクトリ
171    // 相対」ロジックに落ちてベースのパスの最後のセグメント(ファイル名)ごと
172    // 消えてしまうバグだった(`new URL('?x=2', 'https://e.com/a/b?x=1').href`
173    // が `https://e.com/a/?x=2` という `b` の消えた壊れた結果になっていた)。
174    if let Some(rest) = url.strip_prefix('?') {
175        let base_path = path.split('?').next().unwrap_or("").split('#').next().unwrap_or("");
176        return alloc::format!("{scheme}{host}/{base_path}?{rest}");
177    }
178    if let Some(rest) = url.strip_prefix('#') {
179        let base_path_and_query = path.split('#').next().unwrap_or("");
180        return alloc::format!("{scheme}{host}/{base_path_and_query}#{rest}");
181    }
182    let raw_path = if let Some(rooted) = url.strip_prefix('/') {
183        alloc::format!("/{rooted}")
184    } else {
185        // ディレクトリ相対: path の最後のセグメント(ファイル名)を捨てる。
186        let dir = match path.rfind('/') {
187            Some(i) => path.get(..i).unwrap_or(""),
188            None => "",
189        };
190        if dir.is_empty() {
191            alloc::format!("/{url}")
192        } else {
193            alloc::format!("/{dir}/{url}")
194        }
195    };
196    alloc::format!("{scheme}{host}{}", normalize_dot_segments(&raw_path))
197}
198
199/// 同期 HTTP リクエストの共通処理。(status, body) を返す。`data:` と `http(s):` 対応(fetch/XHR 共有)。
200/// `base` が非空なら相対URLを解決。method は GET/POST/PUT/DELETE/PATCH/HEAD 等を任意に通す
201/// (web_request 内で英大文字トークン検証)。
202pub(crate) fn do_http_request(
203    base: &str,
204    url: &str,
205    method: &str,
206    body: &str,
207    content_type: &str,
208) -> Result<(u16, String), &'static str> {
209    do_http_request_raw(base, url, method, body.as_bytes(), content_type, &[]).map(|(s, b, _)| (s, b))
210}
211
212/// fetch/XHR の本文をバイト列にする。文字列は UTF-8、TypedArray・ArrayBuffer・DataView・Blob は
213/// その中身のバイト列、FormData/URLSearchParams は `k=v&…`、それ以外は文字列化。
214/// 【2026-10-17】従来は常に文字列化しており、YouTube player の gzip 圧縮した要求本文
215/// (Uint8Array)が "31,139,8,…" という文字列で送られ 400 になっていた。
216pub(crate) fn body_to_bytes(v: &Value) -> Vec<u8> {
217    match v {
218        Value::Undefined | Value::Null => Vec::new(),
219        Value::Str(s) => s.as_bytes().to_vec(),
220        Value::Object(o) => {
221            let (is_binary, ta_bytes) = {
222                let b = o.borrow();
223                let ta = b.props.get("BYTES_PER_ELEMENT").map(|v| v.to_number() as usize).filter(|_| b.props.contains_key("_ta_kind"));
224                let kind = b.props.get("_ta_kind").map(|v| v.to_js_string()).unwrap_or_default();
225                let binary = ta.is_some()
226                    || b.props.contains_key("_blob_bytes")
227                    || b.props.contains_key("_bytes")
228                    || b.props.contains_key("_dv_bytes");
229                (binary, ta.map(|n| (n, kind)))
230            };
231            if !is_binary {
232                if o.borrow().props.contains_key("_query") {
233                    return body_to_string(v).into_bytes();
234                }
235                return v.to_js_string().into_bytes();
236            }
237            if let Some((n, kind)) = ta_bytes {
238                if n > 1 {
239                    // 要素をリトルエンディアンのバイト列に(Float32/Float64 は IEEE 754)。
240                    let mut out = Vec::new();
241                    if let ObjKind::Array(items) = &o.borrow().kind {
242                        for x in items {
243                            let f = x.to_number();
244                            match (kind.as_str(), n) {
245                                ("Float32Array", _) => out.extend_from_slice(&(f as f32).to_le_bytes()),
246                                ("Float64Array", _) => out.extend_from_slice(&f.to_le_bytes()),
247                                (_, 2) => out.extend_from_slice(&(f as i64 as u16).to_le_bytes()),
248                                (_, 4) => out.extend_from_slice(&(f as i64 as u32).to_le_bytes()),
249                                _ => out.extend_from_slice(&(f as i64 as u64).to_le_bytes()),
250                            }
251                        }
252                    }
253                    return out;
254                }
255            }
256            let mut f = Vec::new();
257            super::typed_array::blob_part_bytes(v, &mut f);
258            f.into_iter().map(|x| x as i64 as u8).collect()
259        }
260        other => other.to_js_string().into_bytes(),
261    }
262}
263
264/// 送ってよい要求ヘッダか(fetch 仕様の禁止ヘッダ・改行を含むものは除く)。
265fn request_header_allowed(name: &str, value: &str) -> bool {
266    const FORBIDDEN: &[&str] = &[
267        "host", "content-length", "connection", "accept-encoding", "accept-charset", "cookie", "cookie2",
268        "date", "dnt", "expect", "keep-alive", "origin", "referer", "te", "trailer", "transfer-encoding",
269        "upgrade", "via", "content-type", "user-agent",
270    ];
271    let n = name.trim();
272    !n.is_empty()
273        && n.bytes().all(|c| c.is_ascii_graphic() && c != b':')
274        && !value.bytes().any(|c| c == b'\r' || c == b'\n')
275        && !FORBIDDEN.iter().any(|f| n.eq_ignore_ascii_case(f))
276        && !n.to_ascii_lowercase().starts_with("proxy-")
277        && !n.to_ascii_lowercase().starts_with("sec-")
278}
279
280/// `do_http_request` の本文の生バイト列付き版(バイナリ応答の fetch/XHR 用)。
281pub(crate) fn do_http_request_raw(
282    base: &str,
283    url: &str,
284    method: &str,
285    body: &[u8],
286    content_type: &str,
287    headers: &[(String, String)],
288) -> Result<(u16, String, Vec<u8>), &'static str> {
289    do_http_request_full(base, url, method, body, content_type, headers).map(|(s, b, raw, _)| (s, b, raw))
290}
291
292/// `do_http_request_raw` の応答ヘッダ付き版。ページの URL(`base`)から `Origin`(GET/HEAD 以外)と
293/// 参照元ポリシーに沿った `Referer` を付ける(ブラウザと同じ)。
294pub(crate) fn do_http_request_full(
295    base: &str,
296    url: &str,
297    method: &str,
298    body: &[u8],
299    content_type: &str,
300    headers: &[(String, String)],
301) -> Result<(u16, String, Vec<u8>, alloc::collections::BTreeMap<String, String>), &'static str> {
302    let resolved = resolve_url(base, url);
303    if resolved.starts_with("data:") {
304        return match data_url_body(&resolved) {
305            Some(b) => {
306                let bytes = b.as_bytes().to_vec();
307                Ok((200, b, bytes, alloc::collections::BTreeMap::new()))
308            }
309            None => Err("malformed data URL"),
310        };
311    }
312    let (is_https, host, path) = match split_http_url(&resolved) {
313        Some(t) => t,
314        None => return Err("only absolute http(s) and data URLs are supported"),
315    };
316    let stack = crate::kernel::net_stack::TcpIpStack::new();
317    // ページ(`base`)の origin と参照元。
318    let mut hdrs: Vec<(String, String)> = headers
319        .iter()
320        .filter(|(k, v)| request_header_allowed(k, v))
321        .cloned()
322        .collect();
323    if let Some((b_https, b_host, b_path)) = split_http_url(base) {
324        let m = method.to_ascii_uppercase();
325        if m != "GET" && m != "HEAD" {
326            hdrs.push((String::from("Origin"), format!("{}://{}", if b_https { "https" } else { "http" }, b_host)));
327        }
328        if let Some(r) = crate::os_lib::web_engine::referrer::referrer_for(b_https, &b_host, &b_path, is_https, &host) {
329            hdrs.push((String::from("Referer"), r));
330        }
331    }
332    let hdr_refs: Vec<(&str, &str)> = hdrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
333    match stack.web_request(
334        is_https,
335        &host,
336        &path,
337        method,
338        content_type,
339        body,
340        &hdr_refs,
341    ) {
342        Ok(resp) => {
343            if resp.status_code >= 400 {
344                crate::warn!(
345                    "[XHR] HTTP {} {} {}: {}",
346                    resp.status_code,
347                    method,
348                    resolved.chars().take(120).collect::<String>(),
349                    String::from_utf8_lossy(&resp.body_bytes).chars().take(200).collect::<String>().replace('\n', " ")
350                );
351            }
352            // 途中で切れた応答を記録する(Content-Length より本文が短い)。150KB の
353            // papers.json が切れると jQuery の JSON 解析が黙って失敗し、論文リストが出ない。
354            let declared = resp
355                .headers
356                .iter()
357                .find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
358                .and_then(|(_, v)| v.trim().parse::<usize>().ok());
359            if let Some(len) = declared {
360                if resp.body.len() < len {
361                    crate::warn!("[XHR] 途中切れ url={} 受信={} Content-Length={}", resolved, resp.body.len(), len);
362                }
363            }
364            Ok((resp.status_code, resp.body, resp.body_bytes, resp.headers))
365        }
366        Err(_) => Err("network error"),
367    }
368}
369
370/// Value::Object のプロパティを取得(非オブジェクトは None)。
371pub(crate) fn obj_prop(v: &Value, key: &str) -> Option<Value> {
372    if let Value::Object(o) = v {
373        o.borrow().props.get(key).cloned()
374    } else {
375        None
376    }
377}
378
379/// `Headers`(Fetch API。`new Headers(init)`/`response.headers.get(...)` という
380/// 定番パターンで使われる get/set/has/append/delete 付きの専用クラス)が丸ごと
381/// 未対応で、`headers` は単なるプレーンオブジェクトとしてしか扱えなかった。
382/// ヘッダ名は仕様どおり大小無視・小文字正規化して格納する(プロパティキー自体を
383/// 小文字化したヘッダ名として使う。メソッド名 "get"/"set" 等と衝突するヘッダ名
384/// は理論上あり得るが実用上まず出現しないため簡略化として許容する)。
385pub(crate) fn headers_ctor(_it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
386    Ok(make_headers(arg(a, 0)))
387}
388/// `headers_ctor` の実体(`Interp` を必要としないため `response.headers` の
389/// 生成箇所からも直接呼べる)。
390pub(crate) fn make_headers(init: Value) -> Value {
391    let o = Obj::plain();
392    // init: 別の Headers(`_is_headers` マーカーで判別)/ プレーンオブジェクト /
393    // `[[k,v], ...]` 形式の配列、のいずれも受け付ける。
394    if let Value::Object(io) = &init {
395        let is_headers_like = io.borrow().props.contains_key("_is_headers");
396        let is_array = matches!(&io.borrow().kind, ObjKind::Array(_));
397        if is_array {
398            if let ObjKind::Array(items) = &io.borrow().kind {
399                for pair in items {
400                    if let Value::Object(p) = pair {
401                        if let ObjKind::Array(kv) = &p.borrow().kind {
402                            if kv.len() >= 2 {
403                                let k = kv[0].to_js_string().to_lowercase();
404                                o.borrow_mut().props.insert(k, Value::str(kv[1].to_js_string()));
405                            }
406                        }
407                    }
408                }
409            }
410        } else {
411            let entries: Vec<(String, Value)> =
412                io.borrow().props.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
413            for (k, v) in entries {
414                // Headers 自身のメソッドプロパティ(callable)は値としてコピーしない。
415                if matches!(&v, Value::Object(f) if f.borrow().is_callable()) {
416                    continue;
417                }
418                let key = k.to_lowercase();
419                o.borrow_mut().props.insert(key, Value::str(v.to_js_string()));
420            }
421            let _ = is_headers_like;
422        }
423    }
424    {
425        let mut b = o.borrow_mut();
426        b.props.insert("_is_headers".into(), Value::Bool(true));
427        b.props.insert("get".into(), nv("get", headers_get));
428        b.props.insert("set".into(), nv("set", headers_set));
429        b.props.insert("append".into(), nv("append", headers_append));
430        b.props.insert("has".into(), nv("has", headers_has));
431        b.props.insert("delete".into(), nv("delete", headers_delete));
432        b.props
433            .insert("forEach".into(), nv("forEach", headers_for_each));
434        // `entries`/`keys`/`values`/`Symbol.iterator`(`for (const [k,v] of headers)`
435        // という定番イディオムを含む)が丸ごと未対応だった。
436        b.props.insert("entries".into(), nv("entries", headers_entries));
437        b.props.insert("keys".into(), nv("keys", headers_keys));
438        b.props.insert("values".into(), nv("values", headers_values));
439        b.props.insert(
440            "getSetCookie".into(),
441            nv("getSetCookie", headers_get_set_cookie),
442        );
443        b.props.insert(
444            "Symbol(Symbol.iterator)".into(),
445            nv("[Symbol.iterator]", headers_entries),
446        );
447    }
448    Value::Object(o)
449}
450pub(crate) fn headers_key(a: &[Value]) -> String {
451    arg(a, 0).to_js_string().to_lowercase()
452}
453pub(crate) fn headers_get_set_cookie(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
454    if let Value::Object(o) = &this {
455        if let Some(v) = o.borrow().props.get("set-cookie") {
456            if !matches!(v, Value::Object(f) if f.borrow().is_callable()) {
457                let val_str = v.to_js_string();
458                if !val_str.is_empty() {
459                    let cookies: Vec<Value> = val_str
460                        .split(", ")
461                        .map(|s| Value::str(s.to_string()))
462                        .collect();
463                    return Ok(Value::Object(Obj::array(cookies)));
464                }
465            }
466        }
467    }
468    Ok(Value::Object(Obj::array(Vec::new())))
469}
470/// `Headers` の実データ(メソッド/内部マーカーを除いた header 名/値)を、仕様どおり
471/// 名前順にソートして返す(`forEach`/`entries`/`keys`/`values`/`Symbol.iterator`
472/// が共有する)。
473pub(crate) fn headers_entries_sorted(this: &Value) -> Vec<(String, String)> {
474    let mut entries: Vec<(String, String)> = match this {
475        Value::Object(o) => o
476            .borrow()
477            .props
478            .iter()
479            .filter(|(k, v)| {
480                k.as_str() != "_is_headers" && !matches!(v, Value::Object(f) if f.borrow().is_callable())
481            })
482            .map(|(k, v)| (k.clone(), v.to_js_string()))
483            .collect(),
484        _ => Vec::new(),
485    };
486    entries.sort_by(|a, b| a.0.cmp(&b.0));
487    entries
488}
489pub(crate) fn headers_get(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
490    if let Value::Object(o) = &this {
491        if let Some(v) = o.borrow().props.get(&headers_key(a)) {
492            if !matches!(v, Value::Object(f) if f.borrow().is_callable()) {
493                return Ok(v.clone());
494            }
495        }
496    }
497    Ok(Value::Null)
498}
499pub(crate) fn headers_set(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
500    if let Value::Object(o) = &this {
501        o.borrow_mut()
502            .props
503            .insert(headers_key(a), Value::str(arg(a, 1).to_js_string()));
504    }
505    Ok(Value::Undefined)
506}
507/// `append(name, value)`。既存値があれば `", "` 区切りで連結する(仕様どおり
508/// 複数値ヘッダのコンマ結合表現。`set-cookie` 等の例外は非対応)。
509pub(crate) fn headers_append(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
510    if let Value::Object(o) = &this {
511        let key = headers_key(a);
512        let v = arg(a, 1).to_js_string();
513        let existing = o.borrow().props.get(&key).map(|v| v.to_js_string());
514        let merged = match existing {
515            Some(e) if !e.is_empty() => alloc::format!("{}, {}", e, v),
516            _ => v,
517        };
518        o.borrow_mut().props.insert(key, Value::str(merged));
519    }
520    Ok(Value::Undefined)
521}
522pub(crate) fn headers_has(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
523    if let Value::Object(o) = &this {
524        let key = headers_key(a);
525        let has = matches!(o.borrow().props.get(&key), Some(v) if !matches!(v, Value::Object(f) if f.borrow().is_callable()));
526        return Ok(Value::Bool(has));
527    }
528    Ok(Value::Bool(false))
529}
530pub(crate) fn headers_delete(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
531    if let Value::Object(o) = &this {
532        o.borrow_mut().props.shift_remove(&headers_key(a));
533    }
534    Ok(Value::Undefined)
535}
536pub(crate) fn headers_for_each(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
537    let cb = arg(a, 0);
538    for (k, v) in headers_entries_sorted(&this) {
539        it.call_value(&cb, Value::Undefined, &[Value::str(v), Value::str(k), this.clone()])?;
540    }
541    Ok(Value::Undefined)
542}
543/// `headers.entries()`/`for (const [k,v] of headers)`(`Symbol.iterator` は
544/// `entries()` と同義)が丸ごと未対応だった。
545pub(crate) fn headers_entries(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
546    let items: Vec<Value> = headers_entries_sorted(&this)
547        .into_iter()
548        .map(|(k, v)| Value::Object(Obj::array(alloc::vec![Value::str(k), Value::str(v)])))
549        .collect();
550    Ok(Value::Object(make_iterator(items)))
551}
552pub(crate) fn headers_keys(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
553    let items: Vec<Value> = headers_entries_sorted(&this)
554        .into_iter()
555        .map(|(k, _)| Value::str(k))
556        .collect();
557    Ok(Value::Object(make_iterator(items)))
558}
559pub(crate) fn headers_values(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
560    let items: Vec<Value> = headers_entries_sorted(&this)
561        .into_iter()
562        .map(|(_, v)| Value::str(v))
563        .collect();
564    Ok(Value::Object(make_iterator(items)))
565}
566
567/// headers オブジェクトから Content-Type を大小無視で探す。
568/// Headers オブジェクト(または素のオブジェクト)を `(名前, 値)` の列にする(内部の `_` 始まり・関数は除く)。
569pub(crate) fn headers_list(headers: &Value) -> Vec<(String, String)> {
570    let mut out = Vec::new();
571    if let Value::Object(o) = headers {
572        for (k, val) in o.borrow().props.iter() {
573            if k.starts_with('_') || matches!(val, Value::Object(f) if f.borrow().is_callable()) {
574                continue;
575            }
576            out.push((k.clone(), val.to_js_string()));
577        }
578    }
579    out
580}
581
582pub(crate) fn headers_content_type(headers: &Value) -> Option<String> {
583    if let Value::Object(o) = headers {
584        for (k, val) in o.borrow().props.iter() {
585            if k.eq_ignore_ascii_case("content-type") {
586                return Some(val.to_js_string());
587            }
588        }
589    }
590    None
591}
592
593/// fetch/XHR の body 値を文字列化する。FormData / URLSearchParams は内部 `_query`
594/// ("k=v&k=v")をそのまま送信本文に使う。それ以外は通常の文字列化。
595pub(crate) fn body_to_string(v: &Value) -> String {
596    if let Value::Object(o) = v {
597        let b = o.borrow();
598        if let Some(q) = b.props.get("_query") {
599            return q.to_js_string();
600        }
601        // 【2026-10-03】Blob は中身のバイト列を本文にする(従来は "[object Object]" を送っていた。
602        // sendBeacon(url, new Blob([json], {type:"application/json"})) の定番形)。
603        if let Some(Value::Object(arr)) = b.props.get("_blob_bytes") {
604            if let ObjKind::Array(items) = &arr.borrow().kind {
605                let bytes: alloc::vec::Vec<u8> = items
606                    .iter()
607                    .map(|x| match x {
608                        Value::Number(n) => *n as u8,
609                        _ => 0,
610                    })
611                    .collect();
612                return String::from_utf8_lossy(&bytes).into_owned();
613            }
614        }
615    }
616    v.to_js_string()
617}
618
619/// `new Request(input, options?)`(Fetch API。`fetch(new Request(url, opts))` という
620/// 定番パターンで使われる)が丸ごと未対応で `Request is not defined` になっていた。
621/// `input` は URL 文字列、または別の `Request`(`.url`/`.method` 等をコピーする
622/// clone 的な使い方)のどちらも受け付ける。
623pub(crate) fn request_ctor(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
624    let input = arg(a, 0);
625    let opts = arg(a, 1);
626    let is_request_like = matches!(&input, Value::Object(o) if o.borrow().props.contains_key("url"));
627    let url = if is_request_like {
628        let u = obj_prop(&input, "url").unwrap_or(Value::Undefined);
629        it.to_string_value(u)?
630    } else {
631        // ToString(利用者定義の toString。YouTube は URL を包んだオブジェクトを渡す)。
632        it.to_string_value(input.clone())?
633    };
634    let method = obj_prop(&opts, "method")
635        .or_else(|| if is_request_like { obj_prop(&input, "method") } else { None })
636        .map(|v| v.to_js_string())
637        .unwrap_or_else(|| String::from("GET"));
638    // 本文は元の値のまま持つ(TypedArray 等のバイナリを `fetch` がバイト列で送れるように)。
639    // 【2026-10-17】従来は生成時に文字列化しており、YouTube の gzip 本文が "31,139,8,…" になっていた。
640    let body = obj_prop(&opts, "body")
641        .or_else(|| if is_request_like { obj_prop(&input, "_body") } else { None })
642        .map(|v| match v {
643            Value::Object(_) | Value::Str(_) => v,
644            Value::Undefined | Value::Null => Value::str(""),
645            other => Value::str(other.to_js_string()),
646        })
647        .unwrap_or_else(|| Value::str(""));
648    // `request.headers` が仕様上は常に本物の `Headers` インスタンス(未指定でも
649    // 空の `Headers`)であるべきところ、渡された生の値(プレーンオブジェクト/
650    // `undefined`)をそのまま格納するだけで、`request.headers.get(...)` が
651    // 「関数ではない」になるバグだった。
652    let headers_raw = obj_prop(&opts, "headers")
653        .or_else(|| if is_request_like { obj_prop(&input, "headers") } else { None })
654        .unwrap_or(Value::Undefined);
655    let headers = make_headers(headers_raw);
656    let signal = obj_prop(&opts, "signal")
657        .or_else(|| if is_request_like { obj_prop(&input, "signal") } else { None })
658        .unwrap_or(Value::Undefined);
659    let req = Obj::plain();
660    {
661        let mut r = req.borrow_mut();
662        r.props.insert("url".into(), Value::str(url.clone()));
663        r.props.insert("method".into(), Value::str(method.clone()));
664        // 内部スロット(`url`/`method` を JS が getter に差し替えても、fetch は元の値を使う)。
665        // 【2026-10-20】YouTube の改ざん検査(ha3)は data: の Request の url を getter に替えて
666        // fetch し、本物の fetch が data: を読むことを確かめる。
667        r.props.insert("_req_url".into(), Value::str(url));
668        r.props.insert("_req_method".into(), Value::str(method));
669        r.props.insert("_body".into(), body);
670        r.props.insert("headers".into(), headers);
671        r.props.insert("signal".into(), signal);
672    }
673    Ok(Value::Object(req))
674}
675
676/// `navigator.sendBeacon(url, data)`(丸ごと未対応だった。ページ離脱時の
677/// 計測データ送信に使われる定番パターン)。この処理系の HTTP は同期実行の
678/// ため、仕様上の「ページ破棄後も配送を継続する」非同期性の恩恵は無いが、
679/// 既存の `fetch()` と同じ POST 経路(同じ body/content-type 推論)を通して
680/// その場で送信し、常に成功(`true`)を返す簡略実装。戻り値の Promise は
681/// 仕様どおり呼び出し元へ返さないため破棄する。
682pub(crate) fn navigator_send_beacon(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
683    let url = arg(a, 0).to_js_string();
684    let data = arg(a, 1);
685    // 【2026-10-03】送信は背景スレッドで行い、ここではキューに積むだけにする。
686    // 従来は `fetch()`(この処理系では同期通信)をその場で呼んでおり、Cloudflare の
687    // アクセス解析(beacon.min.js)の送信が終わるまで**ページの描画が止まった**。
688    // 通信路の不調で応答が来ないと、sugi-lab.net/papers.htm が「Loading」のまま固まった。
689    // sendBeacon は仕様上も「送りっぱなし」(結果を返さない)なので背景送信で足りる。
690    let resolved = resolve_url(&it.base_url, &url);
691    if resolved.starts_with("data:") {
692        return Ok(Value::Bool(true));
693    }
694    let body = if matches!(data, Value::Undefined) { String::new() } else { body_to_string(&data) };
695    let content_type = match &data {
696        Value::Object(o) => o
697            .borrow()
698            .props
699            .get("type")
700            .map(|v| v.to_js_string())
701            .filter(|t| !t.is_empty())
702            .unwrap_or_else(|| String::from("text/plain;charset=UTF-8")),
703        _ => String::from("text/plain;charset=UTF-8"),
704    };
705    BEACON_QUEUE.lock().push_back((resolved, body, content_type));
706    if !BEACON_WORKER.swap(true, core::sync::atomic::Ordering::AcqRel) {
707        let pid = crate::kernel::scheduler::spawn(
708            beacon_thread,
709            crate::kernel::scheduler::Priority::Low,
710            "beacon",
711        );
712        if pid == 0 {
713            // 起こせなかった(ヒープ不足)。次の sendBeacon で再挑戦する。
714            BEACON_WORKER.store(false, core::sync::atomic::Ordering::Release);
715        }
716    }
717    Ok(Value::Bool(true))
718}
719
720/// sendBeacon の送信待ち(解決済み URL, 本文, Content-Type)。
721static BEACON_QUEUE: spin::Mutex<alloc::collections::VecDeque<(String, String, String)>> =
722    spin::Mutex::new(alloc::collections::VecDeque::new());
723/// 送信スレッドが動いているか。
724static BEACON_WORKER: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
725
726/// sendBeacon の背景送信スレッド。キューが空になったら終わる。
727fn beacon_thread() {
728    loop {
729        let job = BEACON_QUEUE.lock().pop_front();
730        match job {
731            Some((url, body, content_type)) => {
732                match do_http_request("", &url, "POST", &body, &content_type) {
733                    Ok((status, _)) => crate::debug!("[BEACON] sent status={} url={}", status, url),
734                    Err(e) => crate::warn!("[BEACON] 送信失敗 url={} err={}", url, e),
735                }
736                crate::kernel::scheduler::yield_now();
737            }
738            None => {
739                BEACON_WORKER.store(false, core::sync::atomic::Ordering::Release);
740                // 終える直前に積まれたぶんがあれば、自分で続ける。
741                if BEACON_QUEUE.lock().is_empty()
742                    || BEACON_WORKER.swap(true, core::sync::atomic::Ordering::AcqRel)
743                {
744                    return;
745                }
746            }
747        }
748    }
749}
750
751/// `GeolocationPositionError` 相当のオブジェクトを組み立てる(`code`/`message`/
752/// `PERMISSION_DENIED`/`POSITION_UNAVAILABLE`/`TIMEOUT` 定数。仕様どおり
753/// インスタンスにも static 同名定数が生えている)。
754pub(crate) fn make_geolocation_error() -> Value {
755    let o = Obj::plain();
756    let mut b = o.borrow_mut();
757    b.props.insert("code".into(), Value::Number(1.0));
758    b.props.insert(
759        "message".into(),
760        Value::str("Geolocation is not supported in this environment"),
761    );
762    b.props.insert("PERMISSION_DENIED".into(), Value::Number(1.0));
763    b.props.insert("POSITION_UNAVAILABLE".into(), Value::Number(2.0));
764    b.props.insert("TIMEOUT".into(), Value::Number(3.0));
765    drop(b);
766    Value::Object(o)
767}
768/// `navigator.geolocation.getCurrentPosition(success, error, options)`(丸ごと
769/// 未対応だった)。この処理系には位置情報を提供する手段が一切無いため、常に
770/// `error` を `PERMISSION_DENIED` で同期的に呼ぶ簡略実装(`success` は永遠に
771/// 呼ばれない)。
772pub(crate) fn geolocation_get_current_position(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
773    if let Some(err_cb) = callable_or_none(arg(a, 1)) {
774        it.call_listener(
775            &err_cb,
776            Value::Undefined,
777            &[make_geolocation_error()],
778            "geolocation error callback",
779        );
780    }
781    Ok(Value::Undefined)
782}
783/// `navigator.geolocation.watchPosition(success, error, options)`。継続監視の
784/// 概念が無いためワンショットの `getCurrentPosition` と同じ即時エラー通知の
785/// みだが、`clearWatch` と対にするための一意な id は仕様どおり返す。
786pub(crate) fn geolocation_watch_position(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
787    geolocation_get_current_position(it, this, a)?;
788    Ok(Value::Number(TIMER_ID.fetch_add(1, Ordering::Relaxed) as f64))
789}
790/// `navigator.geolocation.clearWatch(id)`。監視自体が実在しないため no-op。
791pub(crate) fn geolocation_clear_watch(_it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
792    Ok(Value::Undefined)
793}
794/// `navigator.permissions.query({name})`(丸ごと未対応だった)。`PermissionStatus`
795/// 相当の `{state, name, onchange}` を解決済み Promise で返す。実プロンプト UI が
796/// 無いため、他 API の実装状況と整合する固定値のみを返す簡略実装。
797pub(crate) fn permissions_query(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
798    let name = obj_prop(&arg(a, 0), "name").map(|v| v.to_js_string()).unwrap_or_default();
799    let state = match name.as_str() {
800        "geolocation" | "camera" | "microphone" => "denied",
801        "notifications" => "granted",
802        _ => "prompt",
803    };
804    let status = Obj::plain();
805    {
806        let mut b = status.borrow_mut();
807        b.props.insert("state".into(), Value::str(state));
808        b.props.insert("name".into(), Value::str(name));
809        b.props.insert("onchange".into(), Value::Null);
810    }
811    Ok(resolved_promise(it, Value::Object(status)))
812}
813
814/// `navigator.wakeLock.request(type)`(Screen Wake Lock API。丸ごと未対応
815/// だった)。実際の電源管理機構は無いため、`WakeLockSentinel` 相当の
816/// `{released, type, release()}` を状態のみ追跡する簡略実装で返す。
817pub(crate) fn wake_lock_request(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
818    let ty = arg(a, 0).to_js_string();
819    let ty = if ty.is_empty() { String::from("screen") } else { ty };
820    let sentinel = Obj::plain();
821    {
822        let mut b = sentinel.borrow_mut();
823        b.props.insert("released".into(), Value::Bool(false));
824        b.props.insert("type".into(), Value::str(ty));
825        b.props
826            .insert("release".into(), nv("WakeLockSentinel.release", wake_lock_sentinel_release));
827    }
828    Ok(resolved_promise(it, Value::Object(sentinel)))
829}
830/// `WakeLockSentinel.prototype.release()`(対をなす解放メソッド)。
831pub(crate) fn wake_lock_sentinel_release(it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
832    if let Value::Object(o) = &this {
833        o.borrow_mut().props.insert("released".into(), Value::Bool(true));
834    }
835    Ok(resolved_promise(it, Value::Undefined))
836}
837
838/// `navigator.canShare(data)`(Web Share API。丸ごと未対応だった)。実際の
839/// 共有先 UI が無くいかなる `data` も共有できないため、仕様上の「対応不明」
840/// を意味する誠実な `false` を常に返す簡略実装。
841pub(crate) fn navigator_can_share(_it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
842    Ok(Value::Bool(false))
843}
844/// `navigator.share(data)`(Web Share API。丸ごと未対応だった)。実際の
845/// 共有先 UI が無いため、ユーザーがネイティブ共有シートを閉じた場合と同じ
846/// 仕様どおりの `AbortError` で常に拒否する(成功を偽装しない)。
847pub(crate) fn navigator_share(it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
848    let err = Obj::plain();
849    {
850        let mut b = err.borrow_mut();
851        b.props.insert("name".into(), Value::str("AbortError"));
852        b.props.insert(
853            "message".into(),
854            Value::str("Share was cancelled: no share target available in this environment"),
855        );
856    }
857    Ok(rejected_promise(it, Value::Object(err)))
858}
859
860/// `new EyeDropper()`(丸ごと未対応だった)。`.open()` メソッドのみを持つ
861/// オブジェクトを返す。
862pub(crate) fn eye_dropper_ctor(_it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
863    let o = Obj::plain();
864    o.borrow_mut().props.insert("open".into(), nv("EyeDropper.open", eye_dropper_open));
865    Ok(Value::Object(o))
866}
867/// `EyeDropper.prototype.open()`。実際のピッカー UI が無いため、ユーザーが
868/// ピッカーをキャンセルした場合と同じ仕様どおりの `AbortError` で常に拒否
869/// する(色を拾えていないのに成功を偽装しない)。
870pub(crate) fn eye_dropper_open(it: &mut Interp, _this: Value, _a: &[Value]) -> Result<Value, Value> {
871    let err = Obj::plain();
872    {
873        let mut b = err.borrow_mut();
874        b.props.insert("name".into(), Value::str("AbortError"));
875        b.props.insert(
876            "message".into(),
877            Value::str("The color picker was cancelled: no picker UI available in this environment"),
878        );
879    }
880    Ok(rejected_promise(it, Value::Object(err)))
881}
882
883/// `navigator.vibrate(pattern)`(Vibration API。丸ごと未対応だった)。仕様上
884/// 戻り値は「リクエストを受理したか」のみを表しハードウェアの有無とは無関係
885/// なため、常に `true` を返す簡略実装(実際に振動は発生しない)。
886pub(crate) fn navigator_vibrate(_it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
887    Ok(Value::Bool(true))
888}
889
890/// `navigator.getBattery()`(Battery Status API。丸ごと未対応だった)。この
891/// OS のターゲット(Raspberry Pi)はバッテリー非搭載で常時 AC 電源動作の
892/// ため、「常時満充電で給電中」を返す簡略実装(`levelchange` 等のイベントは
893/// 対象外)。
894/// `navigator.locks.request(name, options?, callback)`(Web Locks API。丸ごと
895/// 未対応だった)。`options` は省略可能なため、実際に呼び出し可能な最後の
896/// 引数をコールバックとして扱う。コールバックへ渡す `Lock` は仕様の必須
897/// プロパティ(`name`/`mode`)のみを持つ簡略オブジェクト。
898pub(crate) fn navigator_locks_request(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
899    let cb = if matches!(arg(a, 2), Value::Object(o) if o.borrow().is_callable()) {
900        arg(a, 2)
901    } else {
902        arg(a, 1)
903    };
904    let name = arg(a, 0).to_js_string();
905    let lock = Obj::plain();
906    {
907        let mut b = lock.borrow_mut();
908        b.props.insert("name".into(), Value::str(name));
909        b.props.insert("mode".into(), Value::str("exclusive"));
910    }
911    let result = it.call_value(&cb, Value::Undefined, &[Value::Object(lock)])?;
912    Ok(resolved_promise(it, result))
913}
914/// `navigator.locks.query()`。実際の排他状態を追跡していないため、保持中/
915/// 待機中とも常に空のスナップショットを返す。
916pub(crate) fn navigator_locks_query(it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
917    let o = Obj::plain();
918    {
919        let mut b = o.borrow_mut();
920        b.props.insert("held".into(), Value::Object(Obj::array(alloc::vec![])));
921        b.props.insert("pending".into(), Value::Object(Obj::array(alloc::vec![])));
922    }
923    Ok(resolved_promise(it, Value::Object(o)))
924}
925pub(crate) fn navigator_get_battery(it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
926    let battery = Obj::plain();
927    {
928        let mut b = battery.borrow_mut();
929        b.props.insert("charging".into(), Value::Bool(true));
930        b.props.insert("chargingTime".into(), Value::Number(0.0));
931        b.props.insert("dischargingTime".into(), Value::Number(f64::INFINITY));
932        b.props.insert("level".into(), Value::Number(1.0));
933        b.props
934            .insert("addEventListener".into(), nv("addEventListener", dom_noop));
935        b.props
936            .insert("removeEventListener".into(), nv("removeEventListener", dom_noop));
937    }
938    Ok(resolved_promise(it, Value::Object(battery)))
939}
940
941/// fetch(url|Request, options?): options.method/body/headers に対応(GET/POST)。
942/// AtmOS の HTTP は同期なので、その場で取得し解決済み Promise を返す。
943pub(crate) fn fetch(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
944    let first = arg(a, 0);
945    // `fetch(new Request(url, opts))`(URL 文字列を渡す通常形と並ぶもう一方の
946    // 定番の呼び出し形)が丸ごと未対応で、`Request` オブジェクトを渡すと
947    // `to_js_string()` が `[object Object]` になり不正な URL 扱いになっていた。
948    let is_request_like = matches!(&first, Value::Object(o) if o.borrow().props.contains_key("url") || o.borrow().props.contains_key("_req_url"));
949    let url = if let Some(u) = obj_prop(&first, "_req_url") {
950        u.to_js_string()
951    } else if is_request_like {
952        let u = obj_prop(&first, "url").unwrap_or(Value::Undefined);
953        it.to_string_value(u)?
954    } else {
955        it.to_string_value(first.clone())?
956    };
957    let opts = arg(a, 1);
958    let method = obj_prop(&opts, "method")
959        .or_else(|| if is_request_like { obj_prop(&first, "_req_method").or_else(|| obj_prop(&first, "method")) } else { None })
960        .map(|v| v.to_js_string())
961        .unwrap_or_else(|| String::from("GET"));
962    let body = obj_prop(&opts, "body")
963        .or_else(|| if is_request_like { obj_prop(&first, "_body") } else { None })
964        .map(|v| body_to_bytes(&v))
965        .unwrap_or_default();
966    let headers = obj_prop(&opts, "headers")
967        .or_else(|| if is_request_like { obj_prop(&first, "headers") } else { None })
968        .unwrap_or(Value::Undefined);
969    let content_type =
970        headers_content_type(&headers).unwrap_or_else(|| String::from("text/plain;charset=UTF-8"));
971    // `fetch(url, {signal})` が丸ごと未対応で、`AbortSignal` を渡しても一切見ずに
972    // 常にリクエストを実行してしまっていた。この処理系の HTTP は同期実行のため
973    // 「実行中に中断する」ことはできないが、仕様どおり「呼び出し時点で既に
974    // aborted な signal なら即座に AbortError で reject し、実際のリクエストは
975    // 一切送らない」チェックは実装できる(`fetch` 前のバリデーション失敗を
976    // `AbortSignal.abort()` で早期中断する定番パターンとも整合する)。
977    let signal_opt = obj_prop(&opts, "signal")
978        .or_else(|| if is_request_like { obj_prop(&first, "signal") } else { None });
979    if let Some(signal) = signal_opt {
980        let already_aborted = matches!(&signal, Value::Object(o) if matches!(o.borrow().props.get("aborted"), Some(Value::Bool(true))));
981        if already_aborted {
982            let reason = match &signal {
983                Value::Object(o) => o.borrow().props.get("reason").cloned().unwrap_or(Value::Undefined),
984                _ => Value::Undefined,
985            };
986            return Ok(rejected_promise(it, reason));
987        }
988    }
989    let base = it.base_url.clone();
990    let resolved_url = resolve_url(&base, &url);
991    let req_headers = headers_list(&headers);
992    match do_http_request_raw(&base, &url, &method, &body, &content_type, &req_headers) {
993        Ok((status, body, bytes)) => {
994            let resp = make_response_with_url(status, body, resolved_url);
995            attach_raw_body(&resp, &bytes);
996            // `response.body` はバイト列のストリーム(Uint8Array のチャンク)。
997            let stream = super::streams::make_readable_stream_from_bytes(it, &bytes);
998            if let Value::Object(r) = &resp {
999                r.borrow_mut().props.insert("body".into(), stream);
1000            }
1001            Ok(resolved_promise(it, resp))
1002        }
1003        Err(msg) => Ok(rejected_promise(it, it.error(format!("fetch: {}", msg)))),
1004    }
1005}
1006
1007/// XMLHttpRequest: AtmOS の HTTP は同期なので send() で即取得し readyState=4 まで進め、
1008/// onreadystatechange → onload(またはonerror) を同期発火する。
1009pub(crate) fn xhr_ctor(_it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
1010    let o = Obj::plain();
1011    {
1012        let mut b = o.borrow_mut();
1013        b.props.insert("readyState".into(), Value::Number(0.0));
1014        b.props.insert("status".into(), Value::Number(0.0));
1015        b.props.insert("statusText".into(), Value::str(""));
1016        b.props.insert("responseText".into(), Value::str(""));
1017        b.props.insert("response".into(), Value::str(""));
1018        b.props.insert("open".into(), nv("open", xhr_open));
1019        b.props.insert("send".into(), nv("send", xhr_send));
1020        b.props.insert(
1021            "setRequestHeader".into(),
1022            nv("setRequestHeader", xhr_set_request_header),
1023        );
1024        b.props.insert("abort".into(), nv("abort", xhr_noop));
1025        b.props.insert(
1026            "getAllResponseHeaders".into(),
1027            nv("getAllResponseHeaders", xhr_get_all_response_headers),
1028        );
1029        // `getResponseHeader(name)` が丸ごと未対応で「関数ではない」の TypeError
1030        // になっていた。`getAllResponseHeaders` と同じ理由(この処理系の HTTP
1031        // クライアントは応答ヘッダを捕捉していない)で常に `null`(仕様上、該当
1032        // ヘッダが無い場合の戻り値)を返す。
1033        b.props.insert(
1034            "getResponseHeader".into(),
1035            nv("getResponseHeader", xhr_get_response_header),
1036        );
1037        b.props.insert(
1038            "addEventListener".into(),
1039            nv("addEventListener", xhr_add_event_listener),
1040        );
1041        // `removeEventListener` が丸ごと未対応で、`xhr.removeEventListener(...)`
1042        // が「関数ではない」という TypeError になっていた(`AbortSignal` の
1043        // 対応済みリスナ削除と同じパターンで対応する)。
1044        b.props.insert(
1045            "removeEventListener".into(),
1046            nv("removeEventListener", xhr_remove_event_listener),
1047        );
1048    }
1049    Ok(Value::Object(o))
1050}
1051
1052pub(crate) fn xhr_noop(_it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
1053    Ok(Value::Undefined)
1054}
1055
1056/// setRequestHeader(name, value): Content-Type のみ保持(POST のボディ送信に使用)。
1057pub(crate) fn xhr_set_request_header(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1058    if let Value::Object(o) = &this {
1059        let name = arg(a, 0).to_js_string();
1060        let value = arg(a, 1).to_js_string();
1061        if name.eq_ignore_ascii_case("content-type") {
1062            o.borrow_mut().props.insert("_content_type".into(), Value::str(value));
1063        } else {
1064            // 【2026-10-17】Content-Type 以外も送る(YouTube の X-Goog-Visitor-Id・Content-Encoding 等)。
1065            let hdrs = o.borrow().props.get("_req_headers").cloned();
1066            let hdrs = match hdrs {
1067                Some(Value::Object(h)) => h,
1068                _ => {
1069                    let h = Obj::plain();
1070                    o.borrow_mut().props.insert("_req_headers".into(), Value::Object(h.clone()));
1071                    h
1072                }
1073            };
1074            hdrs.borrow_mut().props.insert(name, Value::str(value));
1075        }
1076    }
1077    Ok(Value::Undefined)
1078}
1079
1080pub(crate) fn xhr_empty_str(_it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
1081    Ok(Value::str(""))
1082}
1083pub(crate) fn xhr_get_response_header(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1084    let name = arg(a, 0).to_js_string().to_ascii_lowercase();
1085    if let Value::Object(o) = &this {
1086        if let Some(Value::Object(h)) = o.borrow().props.get("_resp_headers") {
1087            for (k, v) in h.borrow().props.iter() {
1088                if k.to_ascii_lowercase() == name {
1089                    return Ok(v.clone());
1090                }
1091            }
1092        }
1093    }
1094    Ok(Value::Null)
1095}
1096
1097/// `getAllResponseHeaders()`(`name: value\r\n` の並び、名前は小文字)。
1098pub(crate) fn xhr_get_all_response_headers(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
1099    let mut out = String::new();
1100    if let Value::Object(o) = &this {
1101        if let Some(Value::Object(h)) = o.borrow().props.get("_resp_headers") {
1102            for (k, v) in h.borrow().props.iter() {
1103                out.push_str(&k.to_ascii_lowercase());
1104                out.push_str(": ");
1105                out.push_str(&v.to_js_string());
1106                out.push_str("\r\n");
1107            }
1108        }
1109    }
1110    Ok(Value::str(out))
1111}
1112
1113/// addEventListener(type, fn) を on<type> プロパティへ写像(最後の登録が有効)。
1114pub(crate) fn xhr_add_event_listener(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1115    if let Value::Object(o) = &this {
1116        o.borrow_mut()
1117            .props
1118            .insert(format!("on{}", arg(a, 0).to_js_string()), arg(a, 1));
1119    }
1120    Ok(Value::Undefined)
1121}
1122/// removeEventListener(type, fn)。`AbortSignal` と同じ「`on<type>` に単一リスナを
1123/// 格納する」簡略方式に合わせ、渡された関数が現在登録済みのものと同一参照の場合
1124/// のみ削除する。
1125pub(crate) fn xhr_remove_event_listener(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1126    if let Value::Object(o) = &this {
1127        let key = format!("on{}", arg(a, 0).to_js_string());
1128        let target = arg(a, 1);
1129        let matches = match (o.borrow().props.get(&key), &target) {
1130            (Some(Value::Object(stored)), Value::Object(t)) => Rc::ptr_eq(stored, t),
1131            _ => false,
1132        };
1133        if matches {
1134            o.borrow_mut().props.shift_remove(&key);
1135        }
1136    }
1137    Ok(Value::Undefined)
1138}
1139
1140pub(crate) fn xhr_open(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1141    // URL は ToString(利用者定義の toString。TrustedResourceUrl 等)。
1142    let url = it.to_string_value(arg(a, 1))?;
1143    if let Value::Object(o) = &this {
1144        let mut b = o.borrow_mut();
1145        b.props
1146            .insert("_method".into(), Value::str(arg(a, 0).to_js_string()));
1147        b.props
1148            .insert("_url".into(), Value::str(url));
1149        b.props.insert("readyState".into(), Value::Number(1.0));
1150    }
1151    Ok(Value::Undefined)
1152}
1153
1154pub(crate) fn xhr_send(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1155    let o = match &this {
1156        Value::Object(o) => o.clone(),
1157        _ => return Ok(Value::Undefined),
1158    };
1159    let (url, method, content_type) = {
1160        let b = o.borrow();
1161        (
1162            b.props
1163                .get("_url")
1164                .map(|v| v.to_js_string())
1165                .unwrap_or_default(),
1166            b.props
1167                .get("_method")
1168                .map(|v| v.to_js_string())
1169                .unwrap_or_else(|| String::from("GET")),
1170            b.props
1171                .get("_content_type")
1172                .map(|v| v.to_js_string())
1173                .unwrap_or_else(|| String::from("text/plain;charset=UTF-8")),
1174        )
1175    };
1176    // send(body): undefined/null は空ボディ。TypedArray 等はバイト列のまま送る(`body_to_bytes`)。
1177    let req_body = body_to_bytes(&arg(a, 0));
1178    let req_headers = headers_list(&o.borrow().props.get("_req_headers").cloned().unwrap_or(Value::Undefined));
1179    let base = it.base_url.clone();
1180    let (status, body, ok, raw, resp_headers) = match do_http_request_full(&base, &url, &method, &req_body, &content_type, &req_headers) {
1181        Ok((s, b, raw, h)) => (s, b, true, raw, h),
1182        Err(e) => {
1183            // 失敗理由を捨てない。従来は `Err(_)` で握り潰しており、
1184            // 取得が失敗しても `status=0` になるだけで原因が残らなかった。
1185            crate::warn!("[XHR] 取得失敗 url={:?} 理由={}", url, e);
1186            (0u16, String::new(), false, Vec::new(), alloc::collections::BTreeMap::new())
1187        }
1188    };
1189    {
1190        let h = Obj::plain();
1191        for (k, v) in resp_headers {
1192            h.borrow_mut().props.insert(k, Value::str(v));
1193        }
1194        o.borrow_mut().props.insert("_resp_headers".into(), Value::Object(h));
1195    }
1196    // responseType(XHR Level 2)。'arraybuffer'/'blob' は生バイト列から作る(YouTube 等の
1197    // メディアセグメント取得)。従来は常に文字列で、バイナリが UTF-8 置換で壊れていた。
1198    let response_type = o.borrow().props.get("responseType").map(|v| v.to_js_string()).unwrap_or_default();
1199    {
1200        let mut b = o.borrow_mut();
1201        b.props
1202            .insert("status".into(), Value::Number(status as f64));
1203        // `statusText` はコンストラクタで空文字列に初期化されたきり、`send()` 完了後も
1204        // 一切更新されないバグだった(`Response.prototype.statusText` と同種の欠落。
1205        // `xhr.status`/`xhr.statusText` を併記するエラーログの定番パターンで参照される)。
1206        b.props
1207            .insert("statusText".into(), Value::str(http_status_text(status)));
1208        b.props
1209            .insert("responseText".into(), Value::str(body.clone()));
1210        let response = match response_type.as_str() {
1211            "arraybuffer" => super::typed_array::make_arraybuffer_with_bytes(raw.iter().map(|&x| x as f64).collect()),
1212            "blob" => super::typed_array::make_blob(raw.iter().map(|&x| x as f64).collect(), String::new()),
1213            _ => Value::str(body),
1214        };
1215        b.props.insert("response".into(), response);
1216        b.props.insert("readyState".into(), Value::Number(4.0));
1217    }
1218    let onrsc = o.borrow().props.get("onreadystatechange").cloned();
1219    if let Some(f) = onrsc.and_then(callable_or_none) {
1220        it.call_listener(&f, this.clone(), &[], "XHR onreadystatechange");
1221    }
1222    let cb = o
1223        .borrow()
1224        .props
1225        .get(if ok { "onload" } else { "onerror" })
1226        .cloned();
1227    if let Some(f) = cb.and_then(callable_or_none) {
1228        let ctx = if ok { "XHR onload" } else { "XHR onerror" };
1229        it.call_listener(&f, this.clone(), &[], ctx);
1230    }
1231    Ok(Value::Undefined)
1232}
1233
1234/// Promise.all: マイクロタスクを駆動して各入力を解決し、結果配列に集約する。
1235pub(crate) fn promise_all_static(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
1236    let items = it.iter_to_vec(&arg(a, 0));
1237    let result = new_pending();
1238    // 保留中の Promise を同期的に解決まで進める。
1239    it.run_microtasks();
1240    let mut out: Vec<Value> = Vec::new();
1241    let mut rejected: Option<Value> = None;
1242    for item in items {
1243        // 汎用 thenable(`.then` を持つが本物の Promise ではないオブジェクト)が
1244        // 「非 Promise」としてそのまま結果配列に混入するバグだった(`Promise.resolve`/
1245        // `await` 単体では既に同化に対応済みだったのに `Promise.all` だけ非対称
1246        // だった)。`allSettled`/`race`/`any` と共通の `resolve_maybe_thenable` を使う。
1247        match resolve_maybe_thenable(it, &item) {
1248            Some(s) => {
1249                let b = s.borrow();
1250                match b.status {
1251                    PromiseStatus::Fulfilled => out.push(b.value.clone()),
1252                    PromiseStatus::Rejected => {
1253                        rejected = Some(b.value.clone());
1254                        break;
1255                    }
1256                    PromiseStatus::Pending => out.push(Value::Undefined),
1257                }
1258            }
1259            None => out.push(item), // 非 Promise・非 thenable はそのまま。
1260        }
1261    }
1262    match rejected {
1263        Some(e) => it.promise_reject(&result, e),
1264        None => it.promise_resolve(&result, Value::Object(Obj::array(out))),
1265    }
1266    Ok(Value::Object(Obj::promise(result)))
1267}
1268
1269/// `then`/`catch`/`finally` メソッド(interp の get_property から返す)。
1270pub fn promise_method(key: &str) -> Value {
1271    match key {
1272        "then" => nv("Promise.then", promise_then_m),
1273        "catch" => nv("Promise.catch", promise_catch_m),
1274        "finally" => nv("Promise.finally", promise_finally_m),
1275        _ => Value::Undefined,
1276    }
1277}
1278
1279pub(crate) fn promise_then_m(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1280    if let Some(s) = this_promise_state(&this) {
1281        let on_f = callable_or_none(arg(a, 0));
1282        let on_r = callable_or_none(arg(a, 1));
1283        return Ok(it.promise_then(&s, on_f, on_r));
1284    }
1285    Ok(Value::Undefined)
1286}
1287
1288pub(crate) fn promise_catch_m(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1289    if let Some(s) = this_promise_state(&this) {
1290        let on_r = callable_or_none(arg(a, 0));
1291        return Ok(it.promise_then(&s, None, on_r));
1292    }
1293    Ok(Value::Undefined)
1294}
1295
1296/// `Promise.prototype.finally(cb)`。以前は `cb` を fulfill/reject 双方で副作用として
1297/// 実行するだけで戻り値/例外を完全に無視していた。仕様上は `cb` が例外を投げる、
1298/// または reject する Promise を返した場合、その結果が元の settle を上書きすべき
1299/// (`Promise.resolve(1).finally(() => { throw 'x' })` は `1` ではなく `'x'` で
1300/// reject すべき)で、これが `cb` に副作用以上の意味が無いという誤った前提の
1301/// 静かな破壊バグだった。ネイティブ関数はクロージャで `cb`/`this` を束縛できない
1302/// ため(`AbortSignal.any` 等と同じ制約)、グローバルの一時変数経由で渡した上で
1303/// `eval_source` に組み立てた JS(仕様の `finally` 展開そのもの)を実行する。
1304pub(crate) fn promise_finally_m(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1305    let Some(s) = this_promise_state(&this) else {
1306        return Ok(Value::Undefined);
1307    };
1308    let cb = arg(a, 0);
1309    if callable_or_none(cb.clone()).is_none() {
1310        // cb が呼び出し不能なら仕様上ただの `then()` と同じ(副作用なし)。
1311        return Ok(it.promise_then(&s, None, None));
1312    }
1313    it.global
1314        .borrow_mut()
1315        .vars
1316        .insert("__finally_promise".into(), this);
1317    it.global.borrow_mut().vars.insert("__finally_cb".into(), cb);
1318    let src = "(function(){ \
1319        var p = __finally_promise; \
1320        var cb = __finally_cb; \
1321        return p.then( \
1322            function(v){ return Promise.resolve(cb()).then(function(){ return v; }); }, \
1323            function(r){ return Promise.resolve(cb()).then(function(){ throw r; }); } \
1324        ); \
1325    })()";
1326    let result = it.eval_source(src);
1327    it.global.borrow_mut().vars.remove("__finally_promise");
1328    it.global.borrow_mut().vars.remove("__finally_cb");
1329    result
1330}
1331