Skip to content

Commit af9ee5b

Browse files
authored
[fetch] Support streaming fetch requests. (emscripten-core#25383)
Add a new `FETCH_STREAMING` setting that enables using the DOM fetch API to stream data when paired with `EMSCRIPTEN_FETCH_STREAM_DATA`. When `EMSCRIPTEN_FETCH_STREAM_DATA` is not used we will default to a regular XMLHttpRequest. To keep the emscripten Fetch.js code the same I've implemented a polyfill of XMLHttpRequest using Fetch. To support streaming I wired up the fetch code to use the old onprogress code that old versions of Firefox supported. Most of the current API is supported with some notable exceptions: - synchronous requests - overriding the mime type I also changed a few of the tests to support both sync and async so I could test them with the fetch backend.
1 parent 4bf9bfa commit af9ee5b

File tree

9 files changed

+346
-11
lines changed

9 files changed

+346
-11
lines changed

ChangeLog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ See docs/process.md for more on how version tagging works.
2424
`--proxy-to-worker` flag) was removed due to lack of usage. If you were
2525
depending on this feature but missed the PSA, please let us know about your
2626
use case. (#25645, #25440)
27+
- The fetch library now supports streaming data requests when
28+
`-sFETCH_STREAMING` is enabled.
2729

2830
4.0.20 - 11/18/25
2931
-----------------

site/source/docs/tools_reference/settings_reference.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2785,6 +2785,26 @@ If nonzero, enables emscripten_fetch API.
27852785

27862786
Default value: false
27872787

2788+
.. _fetch_streaming:
2789+
2790+
FETCH_STREAMING
2791+
===============
2792+
2793+
Enables streaming fetched data when the fetch attribute
2794+
EMSCRIPTEN_FETCH_STREAM_DATA is used. For streaming requests, the DOM Fetch
2795+
API is used otherwise XMLHttpRequest is used.
2796+
Both modes generally support the same API, but there are some key
2797+
differences:
2798+
2799+
- XHR supports synchronous requests
2800+
- XHR supports overriding mime types
2801+
- Fetch supports streaming data using the 'onprogress' callback
2802+
2803+
If set to a value of 2, only the DOM Fetch backend will be used. This should
2804+
only be used in testing.
2805+
2806+
Default value: 0
2807+
27882808
.. _wasmfs:
27892809

27902810
WASMFS

src/Fetch.js

Lines changed: 250 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,243 @@
44
* SPDX-License-Identifier: MIT
55
*/
66

7+
#if FETCH_STREAMING
8+
/**
9+
* A class that mimics the XMLHttpRequest API using the modern Fetch API.
10+
* This implementation is specifically tailored to only handle 'arraybuffer'
11+
* responses.
12+
*/
13+
// TODO Use a regular class name when #5840 is fixed.
14+
var FetchXHR = class {
15+
// --- Public XHR Properties ---
16+
17+
// Event Handlers
18+
onload = null;
19+
onerror = null;
20+
onprogress = null;
21+
onreadystatechange = null;
22+
ontimeout = null;
23+
24+
// Request Configuration
25+
responseType = 'arraybuffer';
26+
withCredentials = false;
27+
timeout = 0; // Standard XHR timeout property
28+
29+
// Response / State Properties
30+
readyState = 0; // 0: UNSENT
31+
response = null;
32+
responseURL = '';
33+
status = 0;
34+
statusText = '';
35+
36+
// --- Internal Properties ---
37+
_method = '';
38+
_url = '';
39+
_headers = {};
40+
_abortController = null;
41+
_aborted = false;
42+
_responseHeaders = null;
43+
44+
// --- Private state management ---
45+
_changeReadyState(state) {
46+
this.readyState = state;
47+
this.onreadystatechange?.();
48+
}
49+
50+
// --- Public XHR Methods ---
51+
52+
/**
53+
* Initializes a request.
54+
* @param {string} method The HTTP request method (e.g., 'GET', 'POST').
55+
* @param {string} url The URL to send the request to.
56+
* @param {boolean} [async=true] This parameter is ignored as Fetch is always async.
57+
* @param {string|null} [user=null] The username for basic authentication.
58+
* @param {string|null} [password=null] The password for basic authentication.
59+
*/
60+
open(method, url, async = true, user = null, password = null) {
61+
if (this.readyState !== 0 && this.readyState !== 4) {
62+
console.warn("FetchXHR.open() called while a request is in progress.");
63+
this.abort();
64+
}
65+
66+
// Reset internal state for the new request
67+
this._method = method;
68+
this._url = url;
69+
this._headers = {};
70+
this._responseHeaders = null;
71+
72+
// The async parameter is part of the XHR API but is an error here because
73+
// the Fetch API is inherently asynchronous and does not support synchronous requests.
74+
if (!async) {
75+
throw new Error("FetchXHR does not support synchronous requests.");
76+
}
77+
78+
// Handle Basic Authentication if user/password are provided.
79+
// This creates a base64-encoded string and sets the Authorization header.
80+
if (user) {
81+
const credentials = btoa(`${user}:${password || ''}`);
82+
this._headers['Authorization'] = `Basic ${credentials}`;
83+
}
84+
85+
this._changeReadyState(1); // 1: OPENED
86+
}
87+
88+
/**
89+
* Sets the value of an HTTP request header.
90+
* @param {string} header The name of the header.
91+
* @param {string} value The value of the header.
92+
*/
93+
setRequestHeader(header, value) {
94+
if (this.readyState !== 1) {
95+
throw new Error('setRequestHeader can only be called when state is OPENED.');
96+
}
97+
this._headers[header] = value;
98+
}
99+
100+
/**
101+
* This method is not effectively implemented because Fetch API relies on the
102+
* server's Content-Type header and does not support overriding the MIME type
103+
* on the client side in the same way as XHR.
104+
* @param {string} mimetype The MIME type to use.
105+
*/
106+
overrideMimeType(mimetype) {
107+
throw new Error("overrideMimeType is not supported by the Fetch API and has no effect.");
108+
}
109+
110+
/**
111+
* Returns a string containing all the response headers, separated by CRLF.
112+
* @returns {string} The response headers.
113+
*/
114+
getAllResponseHeaders() {
115+
if (!this._responseHeaders) {
116+
return '';
117+
}
118+
119+
let headersString = '';
120+
// The Headers object is iterable.
121+
for (const [key, value] of this._responseHeaders.entries()) {
122+
headersString += `${key}: ${value}\r\n`;
123+
}
124+
return headersString;
125+
}
126+
127+
/**
128+
* Sends the request.
129+
* @param body The body of the request.
130+
*/
131+
async send(body = null) {
132+
if (this.readyState !== 1) {
133+
throw new Error('send() can only be called when state is OPENED.');
134+
}
135+
136+
this._abortController = new AbortController();
137+
const signal = this._abortController.signal;
138+
139+
// Handle timeout
140+
let timeoutID;
141+
if (this.timeout > 0) {
142+
timeoutID = setTimeout(
143+
() => this._abortController.abort(new DOMException('The user aborted a request.', 'TimeoutError')),
144+
this.timeout
145+
);
146+
}
147+
148+
const fetchOptions = {
149+
method: this._method,
150+
headers: this._headers,
151+
body: body,
152+
signal: signal,
153+
credentials: this.withCredentials ? 'include' : 'same-origin',
154+
};
155+
156+
try {
157+
const response = await fetch(this._url, fetchOptions);
158+
159+
// Populate response properties once headers are received
160+
this.status = response.status;
161+
this.statusText = response.statusText;
162+
this.responseURL = response.url;
163+
this._responseHeaders = response.headers;
164+
this._changeReadyState(2); // 2: HEADERS_RECEIVED
165+
166+
// Start processing the body
167+
this._changeReadyState(3); // 3: LOADING
168+
169+
if (!response.body) {
170+
throw new Error("Response has no body to read.");
171+
}
172+
173+
const reader = response.body.getReader();
174+
const contentLength = +response.headers.get('Content-Length');
175+
176+
let receivedLength = 0;
177+
const chunks = [];
178+
179+
while (true) {
180+
const { done, value } = await reader.read();
181+
if (done) {
182+
break;
183+
}
184+
185+
chunks.push(value);
186+
receivedLength += value.length;
187+
188+
if (this.onprogress) {
189+
// Convert to ArrayBuffer as requested by responseType.
190+
this.response = value.buffer;
191+
const progressEvent = {
192+
lengthComputable: contentLength > 0,
193+
loaded: receivedLength,
194+
total: contentLength
195+
};
196+
this.onprogress(progressEvent);
197+
}
198+
}
199+
200+
// Combine chunks into a single Uint8Array.
201+
const allChunks = new Uint8Array(receivedLength);
202+
let position = 0;
203+
for (const chunk of chunks) {
204+
allChunks.set(chunk, position);
205+
position += chunk.length;
206+
}
207+
208+
// Convert to ArrayBuffer as requested by responseType
209+
this.response = allChunks.buffer;
210+
} catch (error) {
211+
this.statusText = error.message;
212+
213+
if (error.name === 'AbortError') {
214+
// Do nothing.
215+
} else if (error.name === 'TimeoutError') {
216+
this.ontimeout?.();
217+
} else {
218+
// This is a network error
219+
this.onerror?.();
220+
}
221+
} finally {
222+
clearTimeout(timeoutID);
223+
if (!this._aborted) {
224+
this._changeReadyState(4); // 4: DONE
225+
// The XHR 'load' event fires for successful HTTP statuses (2xx) as well as
226+
// unsuccessful ones (4xx, 5xx). The 'error' event is for network failures.
227+
this.onload?.();
228+
}
229+
}
230+
}
231+
232+
/**
233+
* Aborts the request if it has already been sent.
234+
*/
235+
abort() {
236+
this._aborted = true;
237+
this.status = 0;
238+
this._changeReadyState(4); // 4: DONE
239+
this._abortController?.abort();
240+
}
241+
}
242+
#endif
243+
7244
var Fetch = {
8245
// HandleAllocator for XHR request object
9246
// xhrs: undefined,
@@ -267,7 +504,18 @@ function fetchXHR(fetch, onsuccess, onerror, onprogress, onreadystatechange) {
267504
var userNameStr = userName ? UTF8ToString(userName) : undefined;
268505
var passwordStr = password ? UTF8ToString(password) : undefined;
269506

507+
#if FETCH_STREAMING == 1
508+
if (fetchAttrStreamData) {
509+
var xhr = new FetchXHR();
510+
} else {
511+
var xhr = new XMLHttpRequest();
512+
}
513+
#elif FETCH_STREAMING == 2
514+
// This setting forces using FetchXHR for all requests. Used only in testing.
515+
var xhr = new FetchXHR();
516+
#else
270517
var xhr = new XMLHttpRequest();
518+
#endif
271519
xhr.withCredentials = !!{{{ makeGetValue('fetch_attr', C_STRUCTS.emscripten_fetch_attr_t.withCredentials, 'u8') }}};;
272520
#if FETCH_DEBUG
273521
dbg(`fetch: xhr.timeout: ${xhr.timeout}, xhr.withCredentials: ${xhr.withCredentials}`);
@@ -276,8 +524,8 @@ function fetchXHR(fetch, onsuccess, onerror, onprogress, onreadystatechange) {
276524
xhr.open(requestMethod, url_, !fetchAttrSynchronous, userNameStr, passwordStr);
277525
if (!fetchAttrSynchronous) xhr.timeout = timeoutMsecs; // XHR timeout field is only accessible in async XHRs, and must be set after .open() but before .send().
278526
xhr.url_ = url_; // Save the url for debugging purposes (and for comparing to the responseURL that server side advertised)
279-
#if ASSERTIONS
280-
assert(!fetchAttrStreamData, 'streaming uses moz-chunked-arraybuffer which is no longer supported; TODO: rewrite using fetch()');
527+
#if ASSERTIONS && !FETCH_STREAMING
528+
assert(!fetchAttrStreamData, 'Streaming is only supported when FETCH_STREAMING is enabled.');
281529
#endif
282530
xhr.responseType = 'arraybuffer';
283531

src/lib/libfetch.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ var LibraryFetch = {
2222
$fetchCacheData: fetchCacheData,
2323
#endif
2424
$fetchXHR: fetchXHR,
25+
#if FETCH_STREAMING
26+
$FetchXHR: FetchXHR,
27+
#endif
2528

2629
emscripten_start_fetch: startFetch,
2730
emscripten_start_fetch__deps: [
@@ -38,7 +41,10 @@ var LibraryFetch = {
3841
'$fetchLoadCachedData',
3942
'$fetchDeleteCachedData',
4043
#endif
41-
]
44+
#if FETCH_STREAMING
45+
'$FetchXHR',
46+
#endif
47+
],
4248
};
4349

4450
addToLibrary(LibraryFetch);

src/settings.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1823,6 +1823,21 @@ var FETCH_DEBUG = false;
18231823
// [link]
18241824
var FETCH = false;
18251825

1826+
// Enables streaming fetched data when the fetch attribute
1827+
// EMSCRIPTEN_FETCH_STREAM_DATA is used. For streaming requests, the DOM Fetch
1828+
// API is used otherwise XMLHttpRequest is used.
1829+
// Both modes generally support the same API, but there are some key
1830+
// differences:
1831+
//
1832+
// - XHR supports synchronous requests
1833+
// - XHR supports overriding mime types
1834+
// - Fetch supports streaming data using the 'onprogress' callback
1835+
//
1836+
// If set to a value of 2, only the DOM Fetch backend will be used. This should
1837+
// only be used in testing.
1838+
// [link]
1839+
var FETCH_STREAMING = 0;
1840+
18261841
// ATTENTION [WIP]: Experimental feature. Please use at your own risk.
18271842
// This will eventually replace the current JS file system implementation.
18281843
// If set to 1, uses new filesystem implementation.

test/decorators.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,21 @@ def metafunc(self, with_wasm64, *args, **kwargs):
404404
return metafunc
405405

406406

407+
def also_with_fetch_streaming(f):
408+
assert callable(f)
409+
410+
@wraps(f)
411+
def metafunc(self, with_fetch, *args, **kwargs):
412+
if with_fetch:
413+
self.set_setting('FETCH_STREAMING', '2')
414+
self.cflags += ['-DSKIP_SYNC_FETCH_TESTS']
415+
f(self, *args, **kwargs)
416+
417+
parameterize(metafunc, {'': (False,),
418+
'fetch_backend': (True,)})
419+
return metafunc
420+
421+
407422
def also_with_wasm2js(func):
408423
assert callable(func)
409424

test/fetch/test_fetch_redirect.c

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,13 @@ void start_next_async_fetch() {
6969
async_method_idx++;
7070
if (async_method_idx >= num_methods) {
7171
// All async tests done, now run sync tests
72+
#ifndef SKIP_SYNC_FETCH_TESTS
7273
for (int m = 0; m < num_methods; ++m) {
7374
for (int i = 0; i < num_codes; ++i) {
7475
fetchSyncTest(redirect_codes[i], methods[m]);
7576
}
7677
}
78+
#endif
7779
exit(0);
7880
}
7981
}

0 commit comments

Comments
 (0)