Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/Fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,11 @@ function fetchXHR(fetch, onsuccess, onerror, onprogress, onreadystatechange) {
var userNameStr = userName ? UTF8ToString(userName) : undefined;
var passwordStr = password ? UTF8ToString(password) : undefined;

#if FETCH_BACKEND == 'xhr'
var xhr = new XMLHttpRequest();
#else
var xhr = new FetchXHR();
#endif
xhr.withCredentials = !!{{{ makeGetValue('fetch_attr', C_STRUCTS.emscripten_fetch_attr_t.withCredentials, 'u8') }}};;
#if FETCH_DEBUG
dbg(`fetch: xhr.timeout: ${xhr.timeout}, xhr.withCredentials: ${xhr.withCredentials}`);
Expand All @@ -276,8 +280,8 @@ function fetchXHR(fetch, onsuccess, onerror, onprogress, onreadystatechange) {
xhr.open(requestMethod, url_, !fetchAttrSynchronous, userNameStr, passwordStr);
if (!fetchAttrSynchronous) xhr.timeout = timeoutMsecs; // XHR timeout field is only accessible in async XHRs, and must be set after .open() but before .send().
xhr.url_ = url_; // Save the url for debugging purposes (and for comparing to the responseURL that server side advertised)
#if ASSERTIONS
assert(!fetchAttrStreamData, 'streaming uses moz-chunked-arraybuffer which is no longer supported; TODO: rewrite using fetch()');
#if ASSERTIONS && FETCH_BACKEND != 'fetch'
assert(!fetchAttrStreamData, 'streaming is only supported when using the fetch backend');
#endif
xhr.responseType = 'arraybuffer';

Expand Down
240 changes: 239 additions & 1 deletion src/lib/libfetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,245 @@ var LibraryFetch = {
'$fetchLoadCachedData',
'$fetchDeleteCachedData',
#endif
]
#if FETCH_BACKEND == 'fetch'
'$FetchXHR',
#endif
],
/**
* A class that mimics the XMLHttpRequest API using the modern Fetch API.
* This implementation is specifically tailored to only handle 'arraybuffer'
* responses.
*/
$FetchXHR: class {
constructor() {
// --- Public XHR Properties ---

// Event Handlers
this.onload = null;
this.onerror = null;
this.onprogress = null;
this.onreadystatechange = null;
this.ontimeout = null;

// Request Configuration
this.responseType = 'arraybuffer';
this.withCredentials = false;
this.timeout = 0; // Standard XHR timeout property

// Response / State Properties
this.readyState = 0; // 0: UNSENT
this.response = null;
this.responseURL = '';
this.status = 0;
this.statusText = '';

// --- Internal Properties ---
this._method = '';
this._url = '';
this._headers = {};
this._abortController = null;
this._aborted = false;
this._responseHeaders = null;
}

// --- Private state management ---
_changeReadyState(state) {
this.readyState = state;
this.onreadystatechange?.();
}

// --- Public XHR Methods ---

/**
* Initializes a request.
* @param {string} method The HTTP request method (e.g., 'GET', 'POST').
* @param {string} url The URL to send the request to.
* @param {boolean} [async=true] This parameter is ignored as Fetch is always async.
* @param {string|null} [user=null] The username for basic authentication.
* @param {string|null} [password=null] The password for basic authentication.
*/
open(method, url, async = true, user = null, password = null) {
if (this.readyState !== 0 && this.readyState !== 4) {
console.warn("FetchXHR.open() called while a request is in progress.");
this.abort();
}

// Reset internal state for the new request
this._method = method;
this._url = url;
this._headers = {};
this._responseHeaders = null;

// The async parameter is part of the XHR API but is an error here because
// the Fetch API is inherently asynchronous and does not support synchronous requests.
if (!async) {
throw new Error("FetchXHR does not support synchronous requests.");
}

// Handle Basic Authentication if user/password are provided.
// This creates a base64-encoded string and sets the Authorization header.
if (user) {
const credentials = btoa(`${user}:${password || ''}`);
this._headers['Authorization'] = `Basic ${credentials}`;
}

this._changeReadyState(1); // 1: OPENED
}

/**
* Sets the value of an HTTP request header.
* @param {string} header The name of the header.
* @param {string} value The value of the header.
*/
setRequestHeader(header, value) {
if (this.readyState !== 1) {
throw new Error('setRequestHeader can only be called when state is OPENED.');
}
this._headers[header] = value;
}

/**
* This method is not effectively implemented because Fetch API relies on the
* server's Content-Type header and does not support overriding the MIME type
* on the client side in the same way as XHR.
* @param {string} mimetype The MIME type to use.
*/
overrideMimeType(mimetype) {
throw new Error("overrideMimeType is not supported by the Fetch API and has no effect.");
}

/**
* Returns a string containing all the response headers, separated by CRLF.
* @returns {string} The response headers.
*/
getAllResponseHeaders() {
if (!this._responseHeaders) {
return '';
}

let headersString = '';
// The Headers object is iterable.
for (const [key, value] of this._responseHeaders.entries()) {
headersString += `${key}: ${value}\r\n`;
}
return headersString;
}

/**
* Sends the request.
* @param body The body of the request.
*/
async send(body = null) {
if (this.readyState !== 1) {
throw new Error('send() can only be called when state is OPENED.');
}

this._abortController = new AbortController();
const signal = this._abortController.signal;

// Handle timeout
let timeoutID;
if (this.timeout > 0) {
timeoutID = setTimeout(
() => this._abortController.abort(new DOMException('The user aborted a request.', 'TimeoutError')),
this.timeout
);
}

const fetchOptions = {
method: this._method,
headers: this._headers,
body: body,
signal: signal,
credentials: this.withCredentials ? 'include' : 'same-origin',
};

try {
const response = await fetch(this._url, fetchOptions);

// Populate response properties once headers are received
this.status = response.status;
this.statusText = response.statusText;
this.responseURL = response.url;
this._responseHeaders = response.headers;
this._changeReadyState(2); // 2: HEADERS_RECEIVED

// Start processing the body
this._changeReadyState(3); // 3: LOADING

if (!response.body) {
throw new Error("Response has no body to read.");
}

const reader = response.body.getReader();
const contentLength = +response.headers.get('Content-Length');

let receivedLength = 0;
const chunks = [];

while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}

chunks.push(value);
receivedLength += value.length;

if (this.onprogress) {
// Convert to ArrayBuffer as requested by responseType.
this.response = value.buffer;
const progressEvent = {
lengthComputable: contentLength > 0,
loaded: receivedLength,
total: contentLength
};
this.onprogress(progressEvent);
}
}

// Combine chunks into a single Uint8Array.
const allChunks = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
allChunks.set(chunk, position);
position += chunk.length;
}

// Convert to ArrayBuffer as requested by responseType
this.response = allChunks.buffer;
} catch (error) {
this.statusText = error.message;

if (error.name === 'AbortError') {
// Do nothing.
} else if (error.name === 'TimeoutError') {
this.ontimeout?.();
} else {
// This is a network error
this.onerror?.();
}
} finally {
clearTimeout(timeoutID);
if (!this._aborted) {
this._changeReadyState(4); // 4: DONE
// The XHR 'load' event fires for successful HTTP statuses (2xx) as well as
// unsuccessful ones (4xx, 5xx). The 'error' event is for network failures.
this.onload?.();
}
}
}

/**
* Aborts the request if it has already been sent.
*/
abort() {
this._aborted = true;
this.status = 0;
this._changeReadyState(4); // 4: DONE
this._abortController?.abort();
}
}
};

addToLibrary(LibraryFetch);
11 changes: 11 additions & 0 deletions src/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -1834,6 +1834,17 @@ var FETCH_DEBUG = false;
// [link]
var FETCH = false;

// Change which DOM API is used for the emscripten fetch API. Valid options are:
// - 'xhr' (default) uses XMLHttpRequest
// - 'fetch' uses Fetch
// Both options generally support the same API, but there are some key
// differences:
// - XHR supports synchronous requests
// - XHR supports overriding mime types
// - Fetch supports streaming data using the 'onprogress' callback
// [link]
var FETCH_BACKEND = 'xhr';

// ATTENTION [WIP]: Experimental feature. Please use at your own risk.
// This will eventually replace the current JS file system implementation.
// If set to 1, uses new filesystem implementation.
Expand Down
14 changes: 14 additions & 0 deletions test/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,20 @@ def metafunc(self, with_wasm2js, *args, **kwargs):
return metafunc


def also_with_fetch_backend(f):
assert callable(f)

@wraps(f)
def metafunc(self, with_fetch, *args, **kwargs):
if with_fetch:
self.set_setting('FETCH_BACKEND', 'fetch')
f(self, *args, **kwargs)

parameterize(metafunc, {'': (False,),
'fetch_backend': (True,)})
return metafunc


def can_do_standalone(self, impure=False):
# Pure standalone engines don't support MEMORY64 yet. Even with MEMORY64=2 (lowered)
# the WASI APIs that take pointer values don't have 64-bit variants yet.
Expand Down
2 changes: 2 additions & 0 deletions test/fetch/test_fetch_redirect.c
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,13 @@ void start_next_async_fetch() {
async_method_idx++;
if (async_method_idx >= num_methods) {
// All async tests done, now run sync tests
#ifndef SKIP_SYNC_TESTS
for (int m = 0; m < num_methods; ++m) {
for (int i = 0; i < num_codes; ++i) {
fetchSyncTest(redirect_codes[i], methods[m]);
}
}
#endif
exit(0);
}
}
Expand Down
10 changes: 9 additions & 1 deletion test/fetch/test_fetch_response_headers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ int main() {
emscripten_fetch_attr_t attr;
emscripten_fetch_attr_init(&attr);
strcpy(attr.requestMethod, "GET");
attr.attributes = EMSCRIPTEN_FETCH_REPLACE | EMSCRIPTEN_FETCH_LOAD_TO_MEMORY | EMSCRIPTEN_FETCH_SYNCHRONOUS;
attr.attributes = EMSCRIPTEN_FETCH_REPLACE | EMSCRIPTEN_FETCH_LOAD_TO_MEMORY;
#ifdef SYNC
attr.attributes |= EMSCRIPTEN_FETCH_SYNCHRONOUS;
#endif
attr.requestHeaders = headers;

attr.onsuccess = [] (emscripten_fetch_t *fetch) {
Expand All @@ -44,6 +47,9 @@ int main() {
printf("Data checksum: %02X\n", checksum);
assert(checksum == 0x08);
emscripten_fetch_close(fetch);
#ifndef SYNC
exit(0);
#endif

if (result == 1) result = 0;
};
Expand All @@ -63,9 +69,11 @@ int main() {
};

emscripten_fetch_t *fetch = emscripten_fetch(&attr, "gears.png");
#ifdef SYNC
if (result != 0) {
result = 2;
printf("emscripten_fetch() failed to run synchronously!\n");
}
#endif
return result;
}
Loading