diff --git a/Cargo.lock b/Cargo.lock index 6be156f..7b51079 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1663,6 +1663,7 @@ name = "obscura-js" version = "0.1.0" dependencies = [ "anyhow", + "base64", "deno_core", "deno_error", "html5ever", diff --git a/Cargo.toml b/Cargo.toml index f9b0507..068a930 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,3 +39,4 @@ clap = { version = "4", features = ["derive"] } uuid = { version = "1", features = ["v4"] } thiserror = "2" anyhow = "1" +base64 = "0.22" diff --git a/crates/obscura-js/Cargo.toml b/crates/obscura-js/Cargo.toml index 5e6eaa2..c5c96d1 100644 --- a/crates/obscura-js/Cargo.toml +++ b/crates/obscura-js/Cargo.toml @@ -20,3 +20,4 @@ anyhow = { workspace = true } reqwest = { workspace = true } html5ever = { workspace = true } deno_error = "0.6" +base64 = { workspace = true } diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 284d280..9122dc0 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -363,7 +363,13 @@ class Element extends Node { return "http://www.w3.org/1999/xhtml"; } get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; } - set innerHTML(v) { _dom("set_inner_html", this._nid, String(v ?? "")); } + set innerHTML(v) { + if (this.localName === 'template') { + this.content.innerHTML = v; + return; + } + _dom("set_inner_html", this._nid, String(v ?? "")); + } get outerHTML() { return _domParse("outer_html", this._nid) ?? ""; } get innerText() { return this.textContent; } set innerText(v) { this.textContent = v; } @@ -371,6 +377,11 @@ class Element extends Node { const ids = _domParse("element_children", this._nid) || []; return ids.map(_wrapEl).filter(Boolean); } + get content() { + if (this.localName !== 'template') return undefined; + if (!this._templateContent) this._templateContent = document.createDocumentFragment(); + return this._templateContent; + } get childElementCount() { return this.children.length; } get firstElementChild() { return this.children[0] || null; } get lastElementChild() { const ch = this.children; return ch[ch.length-1] || null; } @@ -741,6 +752,9 @@ class Document extends Node { getElementsByClassName(c) { return this.querySelectorAll("." + c); } createElement(t) { const el = _wrapEl(+_dom("create_element", t.toLowerCase())); + if (el && t.toLowerCase() === 'template') { + el._templateContent = this.createDocumentFragment(); + } return el; } createElementNS(ns, t) { @@ -753,9 +767,8 @@ class Document extends Node { const nid = +_dom("create_text_node", ""); const n = new Node(nid); n._isComment = true; - n.nodeType = 8; // Override nodeType - Object.defineProperty(n, "nodeType", { value: 8, writable: false }); - Object.defineProperty(n, "nodeName", { value: "#comment", writable: false }); + Object.defineProperty(n, "nodeType", { value: 8, writable: false, configurable: true }); + Object.defineProperty(n, "nodeName", { value: "#comment", writable: false, configurable: true }); _cache.set(nid, n); return n; } @@ -913,11 +926,27 @@ class Document extends Node { class DocumentFragment extends Node { get nodeType() { return 11; } get nodeName() { return "#document-fragment"; } - querySelector(s) { return null; } - querySelectorAll(s) { return []; } - get children() { return []; } - get firstElementChild() { return null; } + get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; } + set innerHTML(v) { _dom("set_inner_html", this._nid, String(v ?? "")); } + querySelector(s) { return _wrapEl(+_dom("query_selector", s)); } + querySelectorAll(s) { + const ids = _domParse("query_selector_all", s) || []; + const list = ids.map(_wrapEl).filter(Boolean); + list.item = (i) => list[i] || null; + return list; + } + get children() { + const ids = _domParse("element_children", this._nid) || []; + return ids.map(_wrapEl).filter(Boolean); + } + get firstElementChild() { return this.children[0] || null; } + get lastElementChild() { const ch = this.children; return ch[ch.length - 1] || null; } getElementById(id) { return null; } + cloneNode(deep) { + const frag = document.createDocumentFragment(); + if (deep) frag.innerHTML = this.innerHTML; + return frag; + } } class DocumentType extends Node { @@ -993,6 +1022,13 @@ globalThis.frames = globalThis; globalThis.frameElement = null; globalThis.length = 0; +globalThis.Window = globalThis.Window || function Window() {}; +Object.defineProperty(globalThis.Window, Symbol.hasInstance, { + value(obj) { return obj === globalThis || (obj && obj.window === obj); }, + configurable: true, +}); + + const _iframeRegistry = []; function _registerIframe(iframeEl) { const idx = _iframeRegistry.length; @@ -1113,8 +1149,63 @@ globalThis.pageXOffset = 0; globalThis.pageYOffset = 0; globalThis.__fetchInterceptEnabled = false; globalThis.__fetchInterceptCallback = null; // Set by CDP to handle paused requests +function _base64ToUint8Array(b64) { + const clean = String(b64 || '').replace(/[\r\n\s]/g, ''); + if (!clean) return new Uint8Array(); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const padding = clean.endsWith('==') ? 2 : (clean.endsWith('=') ? 1 : 0); + const bytes = new Uint8Array((clean.length * 3 >> 2) - padding); + let out = 0; + for (let i = 0; i < clean.length; i += 4) { + const a = alphabet.indexOf(clean[i]); + const b = alphabet.indexOf(clean[i + 1]); + const c = clean[i + 2] === '=' ? 0 : alphabet.indexOf(clean[i + 2]); + const d = clean[i + 3] === '=' ? 0 : alphabet.indexOf(clean[i + 3]); + const n = (a << 18) | (b << 12) | (c << 6) | d; + if (out < bytes.length) bytes[out++] = (n >> 16) & 0xff; + if (out < bytes.length) bytes[out++] = (n >> 8) & 0xff; + if (out < bytes.length) bytes[out++] = n & 0xff; + } + return bytes; +} + +function _bodyToUint8Array(body) { + if (body == null) return new Uint8Array(); + if (body instanceof Uint8Array) return body; + if (body instanceof ArrayBuffer) return new Uint8Array(body); + if (ArrayBuffer.isView(body)) return new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + return new TextEncoder().encode(String(body)); +} + +function _arrayBufferFromBytes(bytes) { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + +function _installWasmStreamingFallback() { + if (typeof WebAssembly === 'undefined') return; + if (WebAssembly.instantiateStreaming && WebAssembly.instantiateStreaming.__obscuraFallback) return; + const nativeInstantiateStreaming = WebAssembly.instantiateStreaming; + const fallback = async function instantiateStreaming(source, imports) { + const response = await source; + if (response && typeof response.arrayBuffer === 'function') { + return WebAssembly.instantiate(await response.arrayBuffer(), imports); + } + if (typeof nativeInstantiateStreaming === 'function') { + return nativeInstantiateStreaming.call(WebAssembly, response, imports); + } + return WebAssembly.instantiate(response, imports); + }; + fallback.__obscuraFallback = true; + WebAssembly.instantiateStreaming = fallback; +} +_installWasmStreamingFallback(); + globalThis.fetch = async (input, init = {}) => { - let url = typeof input === "string" ? input : (input instanceof Request ? input.url : input?.url || ""); + let url = typeof input === "string" + ? input + : (input instanceof Request + ? input.url + : ((typeof URL === 'function' && input instanceof URL) ? input.href : (input?.url || input?.href || String(input || "")))); if (url && !url.includes('://')) { try { const base = _domParse("document_url") || "about:blank"; @@ -1138,16 +1229,15 @@ globalThis.fetch = async (input, init = {}) => { throw new TypeError('Failed to fetch: ' + (parsed.corsError || 'CORS error')); } const respType = parsed.status === 0 ? "opaque" : (fetchMode === "no-cors" ? "opaque" : "basic"); - return { - ok: parsed.status >= 200 && parsed.status < 300, status: parsed.status, statusText: "", - type: respType, redirected: false, url: parsed.url || url, - headers: new Headers(parsed.headers || {}), - async text() { return parsed.body || ""; }, - async json() { return JSON.parse(parsed.body || "null"); }, - async arrayBuffer() { return new TextEncoder().encode(parsed.body || "").buffer; }, - async blob() { return new Blob([parsed.body || ""]); }, - clone() { return this; }, - }; + const responseBody = parsed.bodyBase64 ? _base64ToUint8Array(parsed.bodyBase64) : (parsed.body || ""); + return new Response(responseBody, { + status: parsed.status, + statusText: "", + headers: parsed.headers || {}, + type: respType, + url: parsed.url || url, + redirected: false, + }); }; if (typeof Headers === "undefined") { @@ -1419,7 +1509,8 @@ if (typeof Request === 'undefined') { constructor(input, init = {}) { if (typeof input === 'string') { this.url = input; } else if (input instanceof Request) { this.url = input.url; init = { ...input, ...init }; } - else { this.url = String(input); } + else if (typeof URL === 'function' && input instanceof URL) { this.url = input.href; } + else { this.url = input?.url || input?.href || String(input); } this.method = (init.method || 'GET').toUpperCase(); this.headers = new Headers(init.headers); this.body = init.body || null; @@ -1440,16 +1531,16 @@ if (typeof Request === 'undefined') { if (typeof Response === 'undefined') { globalThis.Response = class Response { constructor(body, init = {}) { - this._body = body; this.status = init.status || 200; this.statusText = init.statusText || ''; + this._bodyBytes = _bodyToUint8Array(body); this.status = init.status || 200; this.statusText = init.statusText || ''; this.ok = this.status >= 200 && this.status < 300; this.headers = new Headers(init.headers); - this.type = 'basic'; this.url = ''; + this.type = init.type || 'basic'; this.url = init.url || ''; this.redirected = !!init.redirected; } - async text() { return this._body ? String(this._body) : ''; } + async text() { return new TextDecoder().decode(this._bodyBytes); } async json() { return JSON.parse(await this.text()); } - async arrayBuffer() { return new TextEncoder().encode(await this.text()).buffer; } - async blob() { return new Blob([await this.text()]); } - clone() { return new Response(this._body, { status: this.status, headers: this.headers }); } + async arrayBuffer() { return _arrayBufferFromBytes(this._bodyBytes); } + async blob() { return new Blob([this._bodyBytes]); } + clone() { return new Response(this._bodyBytes, { status: this.status, statusText: this.statusText, headers: this.headers, type: this.type, url: this.url, redirected: this.redirected }); } static error() { return new Response(null, { status: 0 }); } static redirect(url, status) { return new Response(null, { status: status || 302, headers: { Location: url } }); } static json(data, init) { return new Response(JSON.stringify(data), { ...init, headers: { 'content-type': 'application/json', ...(init?.headers || {}) } }); } @@ -1544,7 +1635,9 @@ if (typeof TextDecoder === 'undefined') { constructor(label) { this.encoding = label || 'utf-8'; } decode(buf) { if (!buf) return ''; - const bytes = new Uint8Array(buf.buffer || buf); + const bytes = ArrayBuffer.isView(buf) + ? new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) + : new Uint8Array(buf); let str = '', i = 0; while (i < bytes.length) { let c = bytes[i++]; @@ -2908,6 +3001,7 @@ if (typeof Document !== 'undefined' && !Document.prototype.importNode) { globalThis.__obscura_init = function() { _fpSeed = Date.now() ^ (Math.random() * 0xFFFFFFFF >>> 0); _fpCache = null; + _installWasmStreamingFallback(); globalThis.document = new Document(+_dom("document_node_id")); @@ -2938,4 +3032,4 @@ globalThis.__obscura_init = function() { } } delete globalThis.__obscura_init; -}; \ No newline at end of file +}; diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 9d5c5f5..999399a 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -6,6 +6,7 @@ use std::sync::{Arc, OnceLock}; use deno_core::op2; use deno_core::OpState; use deno_core::Extension; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use obscura_dom::{DomTree, NodeData, NodeId}; use obscura_net::{CookieJar, ObscuraHttpClient}; use tokio::sync::Mutex; @@ -517,16 +518,19 @@ async fn op_fetch_url( } } - let resp_body = response - .text() + let resp_bytes = response + .bytes() .await .map_err(|e| deno_error::JsErrorBox::generic(e.to_string()))?; + let resp_body = String::from_utf8_lossy(&resp_bytes).to_string(); + let resp_body_base64 = BASE64.encode(&resp_bytes); tracing::debug!("op_fetch_url completed: {} {} ({} bytes)", method, url, resp_body.len()); Ok(serde_json::json!({ "status": status, "body": resp_body, + "bodyBase64": resp_body_base64, "url": url, "headers": resp_headers, }) diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index e95d1d9..ba51fa4 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1282,6 +1282,92 @@ mod tests { assert_eq!(result.as_str().unwrap(), "http://localhost:8080/dir/api.json"); } + #[tokio::test(flavor = "current_thread")] + async fn test_fetch_url_input_decodes_binary_body_base64() { + let mut rt = setup_runtime(""); + let result = rt.call_function_on_for_cdp( + r#"async () => { + const originalFetchOp = Deno.core.ops.op_fetch_url; + try { + Deno.core.ops.op_fetch_url = (url) => { + globalThis.__capturedFetchUrl = url; + return JSON.stringify({ + status: 200, + headers: { "content-type": "application/wasm" }, + bodyBase64: "AGFzbQEAAAA=", + url, + }); + }; + const response = await fetch(new URL("/pkg/app_bg.wasm", document.URL)); + const bytes = Array.from(new Uint8Array(await response.arrayBuffer())); + return { url: globalThis.__capturedFetchUrl, bytes }; + } finally { + Deno.core.ops.op_fetch_url = originalFetchOp; + } + }"#, + None, + &[], + true, + true, + ).await.unwrap(); + + assert_eq!( + result.value.unwrap(), + serde_json::json!({ + "url": "http://example.com/pkg/app_bg.wasm", + "bytes": [0, 97, 115, 109, 1, 0, 0, 0], + }) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn test_response_array_buffer_preserves_typed_array_view() { + let mut rt = setup_runtime(""); + let result = rt.call_function_on_for_cdp( + r#"async () => { + const bytes = new Uint8Array([9, 0, 97, 115, 109, 1, 8]); + const response = new Response(bytes.subarray(1, 6)); + return Array.from(new Uint8Array(await response.arrayBuffer())); + }"#, + None, + &[], + true, + true, + ).await.unwrap(); + + assert_eq!(result.value.unwrap(), serde_json::json!([0, 97, 115, 109, 1])); + } + + #[tokio::test(flavor = "current_thread")] + async fn test_wasm_instantiate_streaming_uses_response_array_buffer() { + let mut rt = setup_runtime(""); + let result = rt.call_function_on_for_cdp( + r#"async () => { + const bytes = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]); + const result = await WebAssembly.instantiateStreaming( + Promise.resolve(new Response(bytes)), + {}, + ); + return result.instance instanceof WebAssembly.Instance; + }"#, + None, + &[], + true, + true, + ).await.unwrap(); + + assert_eq!(result.value.unwrap(), serde_json::json!(true)); + } + + #[test] + fn test_text_decoder_respects_typed_array_view() { + let mut rt = setup_runtime(""); + let result = rt.evaluate( + "new TextDecoder().decode(new Uint8Array([65, 66, 67]).subarray(1, 2))" + ).unwrap(); + assert_eq!(result.as_str().unwrap(), "B"); + } + #[test] fn test_document_doctype() { let mut rt = setup_runtime("");