fix(wasm): preserve binary fetch responses (#48)

* fix wasm response handling

* fix URL inputs in fetch polyfill

* test(wasm): cover binary fetch responses
This commit is contained in:
rifuki authored and GitHub committed 2026-04-27 17:31:54 +02:00
1 parent fdec52c70f
commit 53a04fa905
6 files changed
+216 -29

No files matched your search

Generated
+1
View File
@@ -1663,6 +1663,7 @@ name = "obscura-js"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"deno_core",
"deno_error",
"html5ever",
+1
View File
@@ -39,3 +39,4 @@ clap = { version = "4", features = ["derive"] }
uuid = { version = "1", features = ["v4"] }
thiserror = "2"
anyhow = "1"
base64 = "0.22"
+1
View File
@@ -20,3 +20,4 @@ anyhow = { workspace = true }
reqwest = { workspace = true }
html5ever = { workspace = true }
deno_error = "0.6"
base64 = { workspace = true }
+121 -27
View File
@@ -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"));
+6 -2
View File
@@ -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,
})
+86
View File
@@ -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("<html><body></body></html>");
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("<html><body></body></html>");
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("<html><body></body></html>");
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("<html><body></body></html>");
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("<!DOCTYPE html><html><body></body></html>");