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:
1 parent
fdec52c70f
commit
53a04fa905
6 files changed
+217
-30
No files matched your search
Generated
+1
@@ -1663,6 +1663,7 @@ name = "obscura-js"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"deno_core",
|
||||
"deno_error",
|
||||
"html5ever",
|
||||
|
||||
@@ -39,3 +39,4 @@ clap = { version = "4", features = ["derive"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
@@ -20,3 +20,4 @@ anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
html5ever = { workspace = true }
|
||||
deno_error = "0.6"
|
||||
base64 = { workspace = true }
|
||||
Vendored
+122
-28
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>");
|
||||
|
||||
Reference in new issue
Block a user