Skip to main content

atmos/os_lib/js/builtins/
promise.rs

1// 分割: builtins.rs より機械的に移動(2026-07-16 リファクタ フェーズ2)。
2// ロジック不変。可視性のみ pub(crate) へ昇格し、親が pub(crate) use で再エクスポート。
3use super::*;
4
5// ============ Promise ============
6
7pub(crate) use alloc::rc::Rc as PromiseRc;
8pub(crate) use core::cell::RefCell as PromiseRefCell;
9
10pub(crate) fn new_pending() -> PromiseRc<PromiseRefCell<PromiseState>> {
11    PromiseRc::new(PromiseRefCell::new(PromiseState::pending()))
12}
13
14pub(crate) fn this_promise_state(this: &Value) -> Option<PromiseRc<PromiseRefCell<PromiseState>>> {
15    if let Value::Object(o) = this {
16        let o = unwrap_proxy_target(o);
17        let b = o.borrow();
18        if let ObjKind::PromiseObj(s) = &b.kind {
19            return Some(s.clone());
20        }
21    }
22    None
23}
24
25pub(crate) fn callable_or_none(v: Value) -> Option<Value> {
26    if let Value::Object(o) = &v {
27        if o.borrow().is_callable() {
28            return Some(v);
29        }
30    }
31    None
32}
33
34/// `new Promise((resolve, reject) => {...})`。executor を同期実行する。
35pub(crate) fn promise_ctor(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
36    let state = new_pending();
37    let executor = arg(a, 0);
38    if let Some(exec) = callable_or_none(executor) {
39        let resolve = Value::Object(Obj::resolver(state.clone(), false));
40        let reject = Value::Object(Obj::resolver(state.clone(), true));
41        if let Err(e) = it.call_value(&exec, Value::Undefined, &[resolve, reject]) {
42            if !it.aborted {
43                it.promise_reject(&state, e);
44            }
45        }
46    }
47    Ok(Value::Object(Obj::promise(state)))
48}
49
50pub(crate) fn promise_resolve_static(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
51    let v = arg(a, 0);
52    // 既に Promise ならそのまま返す。
53    if this_promise_state(&v).is_some() {
54        return Ok(v);
55    }
56    let state = new_pending();
57    it.promise_resolve(&state, v);
58    Ok(Value::Object(Obj::promise(state)))
59}
60
61pub(crate) fn promise_reject_static(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
62    let state = new_pending();
63    it.promise_reject(&state, arg(a, 0));
64    Ok(Value::Object(Obj::promise(state)))
65}
66
67/// `Promise.withResolvers()`(ES2024): `{ promise, resolve, reject }` を返す。
68pub(crate) fn promise_with_resolvers_static(_it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
69    let state = new_pending();
70    let promise = Value::Object(Obj::promise(state.clone()));
71    let resolve = Value::Object(Obj::resolver(state.clone(), false));
72    let reject = Value::Object(Obj::resolver(state, true));
73    let o = Obj::plain();
74    {
75        let mut b = o.borrow_mut();
76        b.props.insert("promise".into(), promise);
77        b.props.insert("resolve".into(), resolve);
78        b.props.insert("reject".into(), reject);
79    }
80    Ok(Value::Object(o))
81}
82
83/// `Promise.try(callbackFn, ...args)`(ES2025): callbackFn を同期実行し、
84/// 同期例外なら reject、戻り値が thenable ならその状態を採用、それ以外は resolve する。
85/// `new Promise(...)` と違い、callbackFn は非 async 関数でも同期/非同期どちらの結果も統一的に扱える。
86pub(crate) fn promise_try_static(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
87    let state = new_pending();
88    let callback = arg(a, 0);
89    let rest: Vec<Value> = if a.len() > 1 { a[1..].to_vec() } else { Vec::new() };
90    match it.call_value(&callback, Value::Undefined, &rest) {
91        Ok(v) => it.promise_resolve(&state, v),
92        Err(e) => {
93            if !it.aborted {
94                it.promise_reject(&state, e);
95            }
96        }
97    }
98    Ok(Value::Object(Obj::promise(state)))
99}
100
101/// 既に解決済みの Promise を作る(同期取得した値を await 互換で返すため)。
102pub(crate) fn resolved_promise(it: &mut Interp, v: Value) -> Value {
103    let state = new_pending();
104    it.promise_resolve(&state, v);
105    Value::Object(Obj::promise(state))
106}
107/// `TimeRanges`(`buffered`/`seekable`用)の常に空の実装。`length:0` に
108/// 加え、範囲外アクセスとして仕様どおり例外を投げる `start`/`end` を持つ。
109pub fn make_empty_time_ranges() -> super::super::value::ObjRef {
110    let o = Obj::plain();
111    {
112        let mut b = o.borrow_mut();
113        b.props.insert("length".into(), Value::Number(0.0));
114        b.props.insert("start".into(), nv("start", time_ranges_out_of_bounds));
115        b.props.insert("end".into(), nv("end", time_ranges_out_of_bounds));
116    }
117    o
118}
119pub(crate) fn time_ranges_out_of_bounds(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
120    Err(it.error(alloc::format!(
121        "Failed to execute on 'TimeRanges': The index provided ({}) is greater than or equal to the maximum bound (0).",
122        arg(a, 0).to_number() as i64
123    )))
124}
125/// `<audio>`/`<video>` の `.play()`(HTMLMediaElement。丸ごと未対応だった)。
126/// 実際の再生までは配線しない無害な簡略実装だが、仕様どおり Promise を返す
127/// (`await el.play()`/`el.play().catch(...)` という定番パターンで
128/// 「Promise ではない」別種のエラーになるのを防ぐ)。
129pub fn dom_media_play(it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
130    if let Some(idx) = this_dom_idx(&this) {
131        let was_paused = it.dom.borrow().get_attr(idx, "_paused").as_deref() != Some("false");
132        it.dom.borrow_mut().set_attr(idx, "_paused", "false");
133        // 仕様どおり `play`/`playing` イベントを発火(既に再生中なら発火しない)。
134        // `.pause()` の `pause` イベントと対(`dialog.close()` の `close` イベント
135        // 発火と同じパターン)。
136        if was_paused {
137            let _ = it.dispatch_event_in_interp(idx, "play", &[]);
138            let _ = it.dispatch_event_in_interp(idx, "playing", &[]);
139        }
140        // 実際の再生はホストの描画ループが要求を拾って始める(MediaSource の blob URL も可)。
141        let src = it.dom.borrow().get_attr(idx, "src").unwrap_or_default();
142        crate::warn!("[VIDEO] play() src={}", src.chars().take(80).collect::<String>());
143        if !src.is_empty() {
144            *crate::os_lib::mse::MEDIA_REQUEST.lock() = Some(crate::os_lib::mse::MediaRequest::Play(src));
145        }
146    }
147    Ok(resolved_promise(it, Value::Undefined))
148}
149/// `<audio>`/`<video>.pause()`。`.play()` と対になり、内部専用属性
150/// `_paused` を切り替えるだけの簡略実装(`.paused` getter と状態を共有)。
151/// 仕様どおり `pause` イベントを発火する(既に一時停止中なら発火しない)。
152pub fn dom_media_pause(it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
153    if let Some(idx) = this_dom_idx(&this) {
154        let was_playing = it.dom.borrow().get_attr(idx, "_paused").as_deref() == Some("false");
155        it.dom.borrow_mut().set_attr(idx, "_paused", "true");
156        if was_playing {
157            let _ = it.dispatch_event_in_interp(idx, "pause", &[]);
158            *crate::os_lib::mse::MEDIA_REQUEST.lock() = Some(crate::os_lib::mse::MediaRequest::Pause);
159        }
160    }
161    Ok(Value::Undefined)
162}
163/// `navigator.clipboard.writeText(text)`。
164/// OS 全体で共有される実クリップボード(`crate::os_lib::clipboard`)および
165/// `navigator.clipboard` の `_clipboard` prop へ書き込む。
166pub(crate) fn clipboard_write_text(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
167    let text = arg(a, 0).to_js_string();
168    crate::os_lib::clipboard::set_text(&text);
169    if let Value::Object(o) = &this {
170        o.borrow_mut().props.insert("_clipboard".into(), Value::str(text));
171    }
172    Ok(resolved_promise(it, Value::Undefined))
173}
174/// `navigator.clipboard.readText()`。
175pub(crate) fn clipboard_read_text(it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
176    let prop_text = if let Value::Object(o) = &this {
177        o.borrow().props.get("_clipboard").map(|v| v.to_js_string())
178    } else {
179        None
180    };
181    let text = match prop_text {
182        Some(t) if !t.is_empty() => t,
183        _ => crate::os_lib::clipboard::get_text(),
184    };
185    Ok(resolved_promise(it, Value::str(text)))
186}
187/// `new ClipboardItem({mimeType: blobOrValue, ...})`(丸ごと未対応だった)。
188/// `.types`(キー一覧)と `.getType(type)`(対応する Blob/値を解決済み
189/// Promise で返す)のみの簡略実装。
190pub(crate) fn clipboard_item_ctor(_it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
191    let data = arg(a, 0);
192    let types: Vec<Value> = if let Value::Object(d) = &data {
193        d.borrow().props.keys().map(Value::str).collect()
194    } else {
195        Vec::new()
196    };
197    let o = Obj::plain();
198    {
199        let mut b = o.borrow_mut();
200        b.props.insert("_clipboard_item_data".into(), data);
201        b.props.insert("types".into(), Value::Object(Obj::array(types)));
202        b.props
203            .insert("getType".into(), nv("ClipboardItem.getType", clipboard_item_get_type));
204    }
205    Ok(Value::Object(o))
206}
207/// `ClipboardItem.prototype.getType(type)`。
208pub(crate) fn clipboard_item_get_type(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
209    let ty = arg(a, 0).to_js_string();
210    let value = if let Value::Object(o) = &this {
211        match o.borrow().props.get("_clipboard_item_data").cloned() {
212            Some(Value::Object(d)) => d.borrow().props.get(&ty).cloned().unwrap_or(Value::Undefined),
213            _ => Value::Undefined,
214        }
215    } else {
216        Value::Undefined
217    };
218    Ok(resolved_promise(it, value))
219}
220/// Blob の隠しプロパティ `_blob_bytes`(`ObjKind::Array` の数値配列)を UTF-8
221/// 文字列へ同期的に変換する(クリップボード書き込みで `.text()` の Promise を
222/// 経由せず値を取り出すためのヘルパー)。
223pub(crate) fn blob_bytes_to_string(blob: &Value) -> Option<String> {
224    let Value::Object(o) = blob else { return None };
225    let bytes_obj = o.borrow().props.get("_blob_bytes").cloned();
226    let Some(Value::Object(bytes)) = bytes_obj else { return None };
227    let byte_vals: alloc::vec::Vec<u8> = match &bytes.borrow().kind {
228        ObjKind::Array(items) => items.iter().map(|v| v.to_number() as u8).collect(),
229        _ => return None,
230    };
231    Some(String::from_utf8_lossy(&byte_vals).into_owned())
232}
233/// `navigator.clipboard.write(items)`(丸ごと未対応だった。`ClipboardItem`
234/// 配列を受け取り、内部の `text/plain` エントリを既存の `_clipboard`
235/// 文字列プロパティへ書き込む簡略実装。画像等の他 MIME タイプは対象外)。
236pub(crate) fn navigator_clipboard_write(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
237    if let Some(Value::Object(item)) = this_items(&arg(a, 0)).into_iter().next() {
238        let data = item.borrow().props.get("_clipboard_item_data").cloned();
239        if let Some(Value::Object(d)) = data {
240            let plain = d.borrow().props.get("text/plain").cloned();
241            if let Some(v) = plain {
242                let text = blob_bytes_to_string(&v).unwrap_or_else(|| v.to_js_string());
243                crate::os_lib::clipboard::set_text(&text);
244                if let Value::Object(o) = &this {
245                    o.borrow_mut().props.insert("_clipboard".into(), Value::str(text));
246                }
247            }
248        }
249    }
250    Ok(resolved_promise(it, Value::Undefined))
251}
252/// `navigator.userAgentData.getHighEntropyValues(hints)`(丸ごと未対応
253/// だった。低エントロピー値(`brands`/`mobile`/`platform`)は常に含み、
254/// 要求されたヒントに応じて残りの固定値を追加で返す誠実な簡略実装。
255/// 実データを収集する仕組みが無いため全て固定値だが、既存の
256/// `navigator.userAgent`/`.platform`と整合する値を返す)。
257pub(crate) fn navigator_get_high_entropy_values(
258    it: &mut Interp,
259    this: Value,
260    a: &[Value],
261) -> Result<Value, Value> {
262    let hints: alloc::vec::Vec<String> = this_items(&arg(a, 0)).iter().map(|v| v.to_js_string()).collect();
263    let result = Obj::plain();
264    {
265        let mut r = result.borrow_mut();
266        if let Value::Object(ua) = &this {
267            for key in ["brands", "mobile", "platform"] {
268                if let Some(v) = ua.borrow().props.get(key).cloned() {
269                    r.props.insert(key.into(), v);
270                }
271            }
272        }
273        for hint in &hints {
274            match hint.as_str() {
275                "architecture" => {
276                    r.props.insert("architecture".into(), Value::str("arm"));
277                }
278                "bitness" => {
279                    r.props.insert("bitness".into(), Value::str("64"));
280                }
281                "model" => {
282                    r.props.insert("model".into(), Value::str(""));
283                }
284                "platformVersion" => {
285                    r.props.insert("platformVersion".into(), Value::str("1.0"));
286                }
287                "uaFullVersion" | "fullVersionList" => {
288                    if let Value::Object(ua) = &this {
289                        if let Some(v) = ua.borrow().props.get("brands").cloned() {
290                            r.props.insert(hint.clone(), v);
291                        }
292                    }
293                }
294                _ => {}
295            }
296        }
297    }
298    Ok(resolved_promise(it, Value::Object(result)))
299}
300/// `navigator.storage.estimate()`(StorageManager API。丸ごと未対応
301/// だった)。実際のディスク使用量計測機構が無いため`usage: 0`(誠実な
302/// 未計測値)、`quota`は固定の代表値(1GB)を返す簡略実装。
303pub(crate) fn navigator_storage_estimate(it: &mut Interp, _this: Value, _a: &[Value]) -> Result<Value, Value> {
304    let result = Obj::plain();
305    {
306        let mut r = result.borrow_mut();
307        r.props.insert("usage".into(), Value::Number(0.0));
308        r.props.insert("quota".into(), Value::Number(1024.0 * 1024.0 * 1024.0));
309    }
310    Ok(resolved_promise(it, Value::Object(result)))
311}
312/// `navigator.storage.persist()`/`.persisted()`(丸ごと未対応だった)。
313/// この処理系はSylFSへの書き込みが常に永続化されるため、両方とも常に
314/// `true`を返す誠実な簡略実装。
315pub(crate) fn navigator_storage_persist(it: &mut Interp, _this: Value, _a: &[Value]) -> Result<Value, Value> {
316    Ok(resolved_promise(it, Value::Bool(true)))
317}
318/// `navigator.clipboard.read()`(丸ごと未対応だった)。現在の `_clipboard`
319/// 文字列を `text/plain` の `Blob` として包んだ `ClipboardItem` 1件を返す。
320pub(crate) fn navigator_clipboard_read(it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
321    let text = if let Value::Object(o) = &this {
322        o.borrow().props.get("_clipboard").map(|v| v.to_js_string()).unwrap_or_default()
323    } else {
324        String::new()
325    };
326    let blob = make_blob(text.bytes().map(|b| b as f64).collect(), String::from("text/plain"));
327    let data_obj = Obj::plain();
328    data_obj.borrow_mut().props.insert("text/plain".into(), blob);
329    let item = clipboard_item_ctor(it, Value::Undefined, &[Value::Object(data_obj)])?;
330    Ok(resolved_promise(it, Value::Object(Obj::array(alloc::vec![item]))))
331}
332
333/// 拒否済みの Promise を作る(fetch 失敗時)。
334pub(crate) fn rejected_promise(it: &mut Interp, reason: Value) -> Value {
335    let state = new_pending();
336    it.promise_reject(&state, reason);
337    Value::Object(Obj::promise(state))
338}
339
340/// URL を (is_https, host, path) に分解。スキーム無しは弾く(絶対のみ)。
341/// `string_slice` lint を避けるため添字スライスは使わず split_once/strip_prefix で処理。
342pub(crate) fn split_http_url(url: &str) -> Option<(bool, String, String)> {
343    let (is_https, rest) = if let Some(r) = url.strip_prefix("https://") {
344        (true, r)
345    } else if let Some(r) = url.strip_prefix("http://") {
346        (false, r)
347    } else {
348        return None;
349    };
350    let (host, path) = match rest.split_once('/') {
351        Some((h, p)) => (h.to_string(), format!("/{}", p)),
352        None => (rest.to_string(), String::from("/")),
353    };
354    if host.is_empty() {
355        return None;
356    }
357    Some((is_https, host, path))
358}
359
360/// `data:[<mime>][;base64],<data>` の本文部分(カンマ以降)を取り出す。base64 は未対応。
361pub(crate) fn data_url_body(url: &str) -> Option<String> {
362    let rest = url.strip_prefix("data:")?;
363    let (meta, body) = rest.split_once(',')?;
364    // `;base64` は復号、それ以外は %xx を戻す(RFC 2397)。
365    // 【2026-10-20】YouTube の改ざん検査(ha3)は base64 の JSON を data: URL で fetch して json() する。
366    let bytes = if meta.to_ascii_lowercase().ends_with(";base64") {
367        super::base64_to_bytes(body)
368    } else {
369        let b = body.as_bytes();
370        let mut out = Vec::with_capacity(b.len());
371        let mut i = 0;
372        while i < b.len() {
373            if b[i] == b'%' && i + 2 < b.len() {
374                let h = core::str::from_utf8(&b[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok());
375                if let Some(v) = h {
376                    out.push(v);
377                    i += 3;
378                    continue;
379                }
380            }
381            out.push(b[i]);
382            i += 1;
383        }
384        out
385    };
386    Some(String::from_utf8_lossy(&bytes).into_owned())
387}
388
389/// Response オブジェクトを生成(status/ok/_body と text()/json() メソッドを持つ)。
390/// `Response.prototype.statusText`(丸ごと未対応で常に `undefined` だった。`if
391/// (!response.ok) console.log(response.status, response.statusText)` のような
392/// エラーログの定番パターンでよく参照される)。標準的な HTTP ステータス文言の
393/// 主要なもののみのテーブルで近似する(`Reason-Phrase` はサーバ実装依存で仕様上も
394/// クライアント側で正確に再現できるものではない)。
395pub(crate) fn http_status_text(status: u16) -> &'static str {
396    match status {
397        200 => "OK",
398        201 => "Created",
399        202 => "Accepted",
400        204 => "No Content",
401        301 => "Moved Permanently",
402        302 => "Found",
403        304 => "Not Modified",
404        400 => "Bad Request",
405        401 => "Unauthorized",
406        403 => "Forbidden",
407        404 => "Not Found",
408        405 => "Method Not Allowed",
409        408 => "Request Timeout",
410        409 => "Conflict",
411        410 => "Gone",
412        429 => "Too Many Requests",
413        500 => "Internal Server Error",
414        501 => "Not Implemented",
415        502 => "Bad Gateway",
416        503 => "Service Unavailable",
417        504 => "Gateway Timeout",
418        _ => "",
419    }
420}
421pub(crate) fn make_response(status: u16, body: String) -> Value {
422    make_response_with_url(status, body, String::new())
423}
424/// `init.status`(`{status, headers}` 形の `ResponseInit`)を読む共通ヘルパ。
425pub(crate) fn response_init_status(init: &Value, default: u16) -> u16 {
426    match init {
427        Value::Object(o) => o
428            .borrow()
429            .props
430            .get("status")
431            .map(|v| v.to_number() as u16)
432            .unwrap_or(default),
433        _ => default,
434    }
435}
436/// `new Response(body, init)`(Fetch API)。
437pub(crate) fn response_ctor(_it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
438    let body = match arg(a, 0) {
439        Value::Undefined => String::new(),
440        v => v.to_js_string(),
441    };
442    let init = arg(a, 1);
443    let status = response_init_status(&init, 200);
444    let headers_init = match &init {
445        Value::Object(o) => o.borrow().props.get("headers").cloned().unwrap_or(Value::Undefined),
446        _ => Value::Undefined,
447    };
448    let resp = make_response(status, body);
449    if let Value::Object(r) = &resp {
450        r.borrow_mut().props.insert("headers".into(), make_headers(headers_init));
451    }
452    Ok(resp)
453}
454/// `Response.json(data, init)`(ES2022 static factory。`JSON.stringify` +
455/// `content-type: application/json` ヘッダ付与を1回で行う)。
456pub(crate) fn response_json_static(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
457    let body = json_stringify(it, Value::Undefined, &[arg(a, 0)])?.to_js_string();
458    let status = response_init_status(&arg(a, 1), 200);
459    let resp = make_response(status, body);
460    if let Value::Object(r) = &resp {
461        let headers = make_headers(Value::Undefined);
462        if let Value::Object(h) = &headers {
463            h.borrow_mut()
464                .props
465                .insert("content-type".into(), Value::str("application/json"));
466        }
467        r.borrow_mut().props.insert("headers".into(), headers);
468    }
469    Ok(resp)
470}
471/// `Response.error()`(ネットワークエラーを表す、`ok:false`/`type:"error"` の
472/// 特殊な不透明 Response)。
473pub(crate) fn response_error_static(_it: &mut Interp, _t: Value, _a: &[Value]) -> Result<Value, Value> {
474    let resp = make_response(0, String::new());
475    if let Value::Object(r) = &resp {
476        let mut b = r.borrow_mut();
477        b.props.insert("type".into(), Value::str("error"));
478        b.props.insert("ok".into(), Value::Bool(false));
479    }
480    Ok(resp)
481}
482/// `Response.redirect(url, status?)`(既定 302。`Location` ヘッダ付きの
483/// リダイレクト応答を表す Response を返す)。
484pub(crate) fn response_redirect_static(_it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
485    let url = arg(a, 0).to_js_string();
486    let status = match arg(a, 1) {
487        Value::Undefined => 302,
488        v => v.to_number() as u16,
489    };
490    let resp = make_response(status, String::new());
491    if let Value::Object(r) = &resp {
492        let headers = make_headers(Value::Undefined);
493        if let Value::Object(h) = &headers {
494            h.borrow_mut().props.insert("location".into(), Value::str(url));
495        }
496        r.borrow_mut().props.insert("headers".into(), headers);
497    }
498    Ok(resp)
499}
500
501/// `Response.prototype.url`(`fetch()` が実際に取得した絶対 URL。相対 URL 指定時
502/// でも解決済みの絶対形が返る、リダイレクト後の最終 URL 確認等で使われる定番
503/// プロパティ)が丸ごと未対応で常に `undefined` だった。`make_response` の呼び出し元
504/// が URL を知っている場合はこちらを使う。
505pub(crate) fn make_response_with_url(status: u16, body: String, url: String) -> Value {
506    let resp = Obj::plain();
507    {
508        let mut r = resp.borrow_mut();
509        r.props
510            .insert("status".into(), Value::Number(status as f64));
511        r.props
512            .insert("ok".into(), Value::Bool((200..300).contains(&status)));
513        r.props
514            .insert("statusText".into(), Value::str(http_status_text(status)));
515        r.props.insert("url".into(), Value::str(url));
516        // `response.type`/`.redirected` が丸ごと未対応で常に `undefined` だった。
517        // この処理系に CORS モデルは無くリダイレクトも透過的に処理される(追跡は
518        // していない)ため、常に妥当な既定値(`"basic"`/`false`)を返す簡略実装。
519        r.props.insert("type".into(), Value::str("basic"));
520        r.props.insert("redirected".into(), Value::Bool(false));
521        // `response.headers`(`Headers` 未対応だった当時は丸ごと欠落していた)。
522        // この処理系の HTTP クライアントは応答ヘッダを捕捉していないため常に空だが、
523        // `get`/`has` 等のメソッドを持つ本物の `Headers` 形(`undefined` ではない)
524        // を返す点に意味がある。
525        r.props.insert("headers".into(), make_headers(Value::Undefined));
526        r.props.insert("body".into(), make_readable_stream_from_string(&body));
527        r.props.insert("_body".into(), Value::str(body));
528        r.props.insert("text".into(), nv("text", response_text));
529        r.props.insert("json".into(), nv("json", response_json));
530        // `Response.prototype.blob()`(Fetch API。`fetch(...).then(r => r.blob())` という
531        // 定番パターンが `blob is not defined` になっていた)が丸ごと未対応だった。
532        r.props.insert("blob".into(), nv("blob", response_blob));
533        // `Response.prototype.arrayBuffer()` も `blob()` と同種の欠落だった
534        // (バイナリレスポンスを扱う `fetch(...).then(r => r.arrayBuffer())` パターン)。
535        r.props
536            .insert("arrayBuffer".into(), nv("arrayBuffer", response_array_buffer));
537        // `Response.prototype.clone()`(`response.clone().json()` のように本文を
538        // 2回読みたい場合に使う定番パターン)が丸ごと未対応で、`clone is not a
539        // function` になっていた。
540        r.props.insert("clone".into(), nv("clone", response_clone));
541    }
542    Value::Object(resp)
543}
544
545/// `Response.prototype.clone()`。同じ `status`/本文を持つ新しい `Response`
546/// オブジェクトを返す(この処理系は本文を消費済みにする `bodyUsed` 追跡が無く
547/// `.text()`/`.json()` 等を複数回呼べる簡略実装のため、`clone()` は主に
548/// 「呼び出し自体が壊れない」ことを保証する意味合いが強い)。
549pub(crate) fn response_clone(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
550    if let Value::Object(o) = &this {
551        let b = o.borrow();
552        let status = b.props.get("status").map(|v| v.to_number() as u16).unwrap_or(200);
553        let body = b.props.get("_body").map(|v| v.to_js_string()).unwrap_or_default();
554        let url = b.props.get("url").map(|v| v.to_js_string()).unwrap_or_default();
555        drop(b);
556        return Ok(make_response_with_url(status, body, url));
557    }
558    Ok(this)
559}
560
561/// this._body を取り出す共通処理。
562pub(crate) fn response_body_string(this: &Value) -> String {
563    if let Value::Object(o) = this {
564        if let Some(v) = o.borrow().props.get("_body") {
565            return v.to_js_string();
566        }
567    }
568    String::new()
569}
570
571/// Response.text(): 本文文字列で解決する Promise。
572pub(crate) fn response_text(it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
573    let body = response_body_string(&this);
574    Ok(resolved_promise(it, Value::str(body)))
575}
576
577/// Response.json(): 本文を JSON.parse して解決する Promise。
578pub(crate) fn response_json(it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
579    let body = response_body_string(&this);
580    let parsed = json_parse(it, Value::Undefined, &[Value::str(body)])?;
581    Ok(resolved_promise(it, parsed))
582}
583
584/// 応答の生バイト列が UTF-8 文字列(`_body`)と違う(バイナリ)ときだけ、`_body_latin1` に
585/// 1 バイト = 1 文字(U+0000〜U+00FF)で保持する。`arrayBuffer()`/`blob()` はこちらを使う。
586/// 【2026-10-17】従来は本文を常に UTF-8 文字列として持ち、動画セグメント等のバイナリが
587/// 置換文字に化けて壊れていた。
588pub(crate) fn attach_raw_body(resp: &Value, bytes: &[u8]) {
589    let Value::Object(o) = resp else { return };
590    let same = o.borrow().props.get("_body").is_some_and(|v| v.to_js_string().as_bytes() == bytes);
591    if !same {
592        let latin1: String = bytes.iter().map(|&b| b as char).collect();
593        o.borrow_mut().props.insert("_body_latin1".into(), Value::str(latin1));
594    }
595}
596
597/// 応答本文のバイト列(バイナリなら生バイト列、テキストなら UTF-8)。
598fn response_body_bytes(this: &Value) -> alloc::vec::Vec<f64> {
599    if let Value::Object(o) = this {
600        if let Some(v) = o.borrow().props.get("_body_latin1") {
601            return v.to_js_string().chars().map(|c| (c as u32 & 0xff) as f64).collect();
602        }
603    }
604    response_body_string(this).as_bytes().iter().map(|b| *b as f64).collect()
605}
606
607/// Response.blob(): 本文のバイト列の `Blob` で解決する Promise。
608pub(crate) fn response_blob(it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
609    let bytes = response_body_bytes(&this);
610    let blob = make_blob(bytes, String::new());
611    Ok(resolved_promise(it, blob))
612}
613
614/// Response.arrayBuffer(): 本文を UTF-8 バイト列化した実バイト列付き `ArrayBuffer`
615/// (`make_arraybuffer_with_bytes()`)で解決する Promise。
616pub(crate) fn response_array_buffer(it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
617    let bytes = response_body_bytes(&this);
618    let buf = make_arraybuffer_with_bytes(bytes);
619    Ok(resolved_promise(it, buf))
620}
621
622/// location.toString(): href を返す(`String(location)` / 文字列連結用)。
623pub(crate) fn location_to_string(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
624    if let Value::Object(o) = &this {
625        if let Some(v) = o.borrow().props.get("href") {
626            return Ok(v.clone());
627        }
628    }
629    Ok(Value::str(""))
630}
631
632/// location オブジェクトにナビゲーション要求を積む共通ヘルパ。
633/// `mode`: "assign"(履歴あり) / "replace"(履歴置換) / "reload"(再読込)。
634/// ホストが take_pending_location() で回収して実ナビゲーションする。
635pub(crate) fn queue_location_nav(this: &Value, url: &str, mode: &str) {
636    crate::warn!("[JSNAV] location {} {:?}", mode, url.chars().take(120).collect::<String>());
637    if let Value::Object(o) = this {
638        let mut b = o.borrow_mut();
639        b.props
640            .insert(String::from("_pending_location"), Value::str(url));
641        b.props
642            .insert(String::from("_pending_location_mode"), Value::str(mode));
643    }
644}
645
646/// location.assign(url): 指定URLへ遷移(履歴に新エントリを積む)。
647pub(crate) fn location_assign(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
648    let url = a.first().map(|v| v.to_js_string()).unwrap_or_default();
649    if !url.is_empty() {
650        queue_location_nav(&this, &url, "assign");
651    }
652    Ok(Value::Undefined)
653}
654
655/// location.replace(url): 指定URLへ遷移(現在の履歴エントリを置換)。
656pub(crate) fn location_replace(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
657    let url = a.first().map(|v| v.to_js_string()).unwrap_or_default();
658    if !url.is_empty() {
659        queue_location_nav(&this, &url, "replace");
660    }
661    Ok(Value::Undefined)
662}
663
664/// location.reload(): 現在のページを再読込する。
665pub(crate) fn location_reload(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
666    // 現在の href を再読込要求として積む(ホスト側で現URLを使うため空でも可)。
667    let cur = if let Value::Object(o) = &this {
668        o.borrow()
669            .props
670            .get("href")
671            .map(|v| v.to_js_string())
672            .unwrap_or_default()
673    } else {
674        String::new()
675    };
676    queue_location_nav(&this, &cur, "reload");
677    Ok(Value::Undefined)
678}
679
680/// 絶対URL `scheme://host[:port]/path[?query][#hash]` を location の各成分へ分解。
681/// `string_slice` lint 回避で split_once / strip_prefix のみ使用。
682pub(crate) fn location_components(url: &str) -> Vec<(&'static str, String)> {
683    let (protocol, after) = if let Some(a) = url.strip_prefix("https://") {
684        ("https:", a)
685    } else if let Some(a) = url.strip_prefix("http://") {
686        ("http:", a)
687    } else {
688        ("", url)
689    };
690    // 権限部(host)は最初の '/'・'?'・'#' まで。【2026-10-17】'/' だけで区切っており、
691    // `https://a.com?q=1` のホストが `a.com?q=1` になっていた。パスが空なら "/"。
692    let (hostport, pathpart) = match after.find(['/', '?', '#']) {
693        Some(i) => {
694            let (h, rest) = after.split_at(i);
695            let p = if rest.starts_with('/') { String::from(rest) } else { format!("/{}", rest) };
696            (String::from(h), p)
697        }
698        None => (String::from(after), String::from("/")),
699    };
700    // `user:pass@host` 形式のユーザー情報(userinfo)が丸ごと未対応で、
701    // `hostport`(= `host`/`hostname`/`port`/`origin` の元)にそのまま
702    // 混入していたバグ。`https://user:pass@example.com/` の `hostname` が
703    // 仕様上の `example.com` ではなく `pass@example.com` という壊れた値に
704    // なっていた(`:` での2分割で `user` が誤って `hostname` に化ける)。
705    // userinfo を `host` 系の解析より先に切り離す。
706    let (userinfo, hostport) = match hostport.split_once('@') {
707        Some((ui, hp)) => (String::from(ui), String::from(hp)),
708        None => (String::new(), hostport),
709    };
710    let (username, password) = match userinfo.split_once(':') {
711        Some((u, p)) => (String::from(u), String::from(p)),
712        None => (userinfo.clone(), String::new()),
713    };
714    let (before_hash, hash) = match pathpart.split_once('#') {
715        Some((b, h)) => (String::from(b), format!("#{}", h)),
716        None => (pathpart.clone(), String::new()),
717    };
718    let (pathname, search) = match before_hash.split_once('?') {
719        Some((p, q)) => (String::from(p), format!("?{}", q)),
720        None => (before_hash.clone(), String::new()),
721    };
722    let (hostname, port) = match hostport.split_once(':') {
723        Some((h, p)) => (String::from(h), String::from(p)),
724        None => (hostport.clone(), String::new()),
725    };
726    let origin = if protocol.is_empty() {
727        String::new()
728    } else {
729        format!("{}//{}", protocol, hostport)
730    };
731    // href は正規化した形(`https://a.com` → `https://a.com/`)。スキームの無いものはそのまま。
732    let href = if protocol.is_empty() {
733        String::from(url)
734    } else {
735        let auth = if url.contains('@') && !userinfo.is_empty() { format!("{}@{}", userinfo, hostport) } else { hostport.clone() };
736        format!("{}//{}{}{}{}", protocol, auth, pathname, search, hash)
737    };
738    alloc::vec![
739        ("href", href),
740        ("protocol", String::from(protocol)),
741        ("username", username),
742        ("password", password),
743        ("host", hostport),
744        ("hostname", hostname),
745        ("port", port),
746        ("origin", origin),
747        ("pathname", pathname),
748        ("search", search),
749        ("hash", hash),
750    ]
751}
752
753/// グローバルの location.href を読む(未設定/空なら None)。
754pub fn location_href(global: &Rc<RefCell<Scope>>) -> Option<String> {
755    let loc = match global.borrow().vars.get("location") {
756        Some(Value::Object(o)) => o.clone(),
757        _ => return None,
758    };
759    let href = loc
760        .borrow()
761        .props
762        .get("href")
763        .map(|v| v.to_js_string())
764        .unwrap_or_default();
765    if href.is_empty() {
766        None
767    } else {
768        Some(href)
769    }
770}
771
772/// グローバルの location.hostname を読む(未設定なら空文字列。`document.domain`
773/// の読み取り専用近似が呼ぶ)。
774pub fn location_hostname(global: &Rc<RefCell<Scope>>) -> Option<String> {
775    let loc = match global.borrow().vars.get("location") {
776        Some(Value::Object(o)) => o.clone(),
777        _ => return None,
778    };
779    let hostname = loc.borrow().props.get("hostname").map(|v| v.to_js_string());
780    hostname
781}
782
783/// `document.lastModified` を仕様どおりの `"MM/DD/YYYY HH:MM:SS"` 形式へ整形する。
784/// 実ファイルの mtime を追跡する仕組みが無いため、`epoch_ms_now()`(`Date.now()`
785/// と同じ現在時刻ソース)をそのまま使う簡略実装。
786pub fn format_last_modified() -> String {
787    let (y, mo, d, hh, mm, ss, ..) = decompose_ms(epoch_ms_now());
788    alloc::format!("{mo:02}/{d:02}/{y:04} {hh:02}:{mm:02}:{ss:02}")
789}
790
791/// history.pushState / replaceState 共通: url 指定があれば location を書換え、state を保存。
792/// pushState のみ length をインクリメント。popstate は仕様通り発火しない。
793pub(crate) fn history_change_state(
794    it: &mut Interp,
795    this: &Value,
796    a: &[Value],
797    push: bool,
798) -> Result<Value, Value> {
799    let new_state = arg(a, 0);
800    let url_arg = arg(a, 2);
801
802    if let Value::Object(o) = this {
803        if push {
804            // pushState: 現在の URL・state を _spa_back_stack へ退避してから更新。
805            let cur_url = location_href(&it.global).unwrap_or_default();
806            let cur_state = o.borrow().props.get("state").cloned().unwrap_or(Value::Null);
807            let stack_val = o.borrow().props.get("_spa_back_stack").cloned();
808            let stack = match stack_val {
809                Some(Value::Object(arr)) => arr,
810                _ => Obj::array(alloc::vec![]),
811            };
812            let entry = Obj::plain();
813            {
814                let mut eb = entry.borrow_mut();
815                eb.props.insert("url".into(), Value::str(cur_url));
816                eb.props.insert("state".into(), cur_state);
817            }
818            if let ObjKind::Array(items) = &mut stack.borrow_mut().kind {
819                items.push(Value::Object(entry));
820            }
821            o.borrow_mut().props.insert("_spa_back_stack".into(), Value::Object(stack));
822            // pushState 時は forward スタックをクリア(仕様通り)。
823            o.borrow_mut().props.shift_remove("_spa_fwd_stack");
824        }
825
826        if !matches!(url_arg, Value::Undefined | Value::Null) {
827            let url = url_arg.to_js_string();
828            if !url.is_empty() {
829                let base = location_href(&it.global).unwrap_or_default();
830                let resolved = resolve_url(&base, &url);
831                update_location(&it.global, &resolved);
832                it.base_url = resolved;
833            }
834        }
835
836        let mut b = o.borrow_mut();
837        b.props.insert("state".into(), new_state);
838        if push {
839            let len = match b.props.get("length") {
840                Some(Value::Number(n)) => *n,
841                _ => 1.0,
842            };
843            b.props.insert("length".into(), Value::Number(len + 1.0));
844        }
845    } else {
846        if !matches!(url_arg, Value::Undefined | Value::Null) {
847            let url = url_arg.to_js_string();
848            if !url.is_empty() {
849                let base = location_href(&it.global).unwrap_or_default();
850                let resolved = resolve_url(&base, &url);
851                update_location(&it.global, &resolved);
852                it.base_url = resolved;
853            }
854        }
855    }
856    Ok(Value::Undefined)
857}
858
859pub(crate) fn history_push_state(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
860    history_change_state(it, &this, a, true)
861}
862
863pub(crate) fn history_replace_state(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
864    history_change_state(it, &this, a, false)
865}
866
867/// history のナビゲーション要求を history オブジェクトの隠し props `_pending_nav` に積む。
868/// 値は相対移動量(負=戻る / 正=進む)。エンジンが JS 実行後に take_pending_nav で回収し、
869/// go_back / go_forward を実行してから popstate を発火する。
870/// (SPA だけでなく実ページ遷移にも対応するため、実ナビはホスト側に委譲する)。
871pub(crate) fn set_pending_nav(global: &Rc<RefCell<Scope>>, delta: i64) {
872    if delta == 0 {
873        return;
874    }
875    if let Some(Value::Object(h)) = global.borrow().vars.get("history") {
876        let cur = match h.borrow().props.get("_pending_nav") {
877            Some(Value::Number(n)) => *n as i64,
878            _ => 0,
879        };
880        h.borrow_mut()
881            .props
882            .insert("_pending_nav".into(), Value::Number((cur + delta) as f64));
883    }
884}
885
886pub(crate) fn history_back(it: &mut Interp, _this: Value, _a: &[Value]) -> Result<Value, Value> {
887    set_pending_nav(&it.global, -1);
888    Ok(Value::Undefined)
889}
890
891pub(crate) fn history_forward(it: &mut Interp, _this: Value, _a: &[Value]) -> Result<Value, Value> {
892    set_pending_nav(&it.global, 1);
893    Ok(Value::Undefined)
894}
895
896pub(crate) fn history_go(it: &mut Interp, _this: Value, a: &[Value]) -> Result<Value, Value> {
897    // go(0) はリロード相当だが当面は no-op。go(-n)/go(n) を相対移動として扱う。
898    let n = arg(a, 0).to_number();
899    if n.is_finite() {
900        set_pending_nav(&it.global, n as i64);
901    }
902    Ok(Value::Undefined)
903}
904