Update web emulator to use fetch for HTTP requests

This will allow us to support streaming in the future.

TeaVM doesn't have bindings for this right now, we should upstream this.
This commit is contained in:
Jonathan Coates committed 2026-10-22 20:44:45 +01:00
1 parent d5d8fc291d
commit 41ad9cb71f
7 files changed
+226 -37

No files matched your search

@@ -72,7 +72,7 @@ public class Builder {
// Then finally start the compiler!
run(output, remapper, TeaVMTargetType.JAVASCRIPT, TeaVMOptimizationLevel.ADVANCED, minify);
run(output, remapper, TeaVMTargetType.WEBASSEMBLY_GC, TeaVMOptimizationLevel.SIMPLE, minify);
run(output, remapper, TeaVMTargetType.WEBASSEMBLY_GC, TeaVMOptimizationLevel.ADVANCED, minify);
try (var runtime = Objects.requireNonNull(Builder.class.getClassLoader()).getResourceAsStream("org/teavm/backend/wasm/wasm-gc-module-runtime.js")) {
if (runtime == null) throw new IllegalStateException("Cannot find WASM runtime");
@@ -5,7 +5,11 @@
package cc.tweaked.web.http;
import cc.tweaked.web.Main;
import com.google.common.base.Splitter;
import cc.tweaked.web.js.Console;
import cc.tweaked.web.js.abort.AbortController;
import cc.tweaked.web.js.fetch.Headers;
import cc.tweaked.web.js.fetch.RequestInit;
import cc.tweaked.web.js.fetch.Response;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.apis.IAPIEnvironment;
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
@@ -20,8 +24,10 @@ import io.netty.handler.codec.http.HttpMethod;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.teavm.jso.JSObject;
import org.teavm.jso.ajax.XMLHttpRequest;
import org.teavm.jso.typedarrays.ArrayBuffer;
import org.teavm.jso.core.JSError;
import org.teavm.jso.core.JSPromise;
import org.teavm.jso.typedarrays.Int8Array;
import java.nio.ByteBuffer;
@@ -74,10 +80,10 @@ final class HttpRequest extends Resource<HttpRequest, HttpRequest.State> {
}
}
private record Fetching(XMLHttpRequest request) implements State {
private record Fetching(AbortController abort) implements State {
@Override
public void close() {
request.abort();
abort.abort();
}
}
@@ -88,18 +94,31 @@ final class HttpRequest extends Resource<HttpRequest, HttpRequest.State> {
private @Nullable State doRequest(HttpUri uri, HttpMethod method) {
try {
var request = new XMLHttpRequest();
request.setOnReadyStateChange(() -> onResponseStateChange(request));
request.setResponseType("arraybuffer");
var address = uri.getUri().toASCIIString();
request.open(method.toString(), Main.CORS_PROXY.isEmpty() ? address : Main.CORS_PROXY.replace("{}", address));
var abort = new AbortController();
var request = RequestInit.create();
request.setMethod(method.name());
if (postBuffer != null) request.setBody(Int8Array.fromJavaBuffer(postBuffer));
request.setSignal(abort.getSignal());
var requestHeaders = new Headers();
for (var iterator = headers.iteratorAsString(); iterator.hasNext(); ) {
var header = iterator.next();
request.setRequestHeader(header.getKey(), header.getValue());
requestHeaders.append(header.getKey(), header.getValue());
}
request.setRequestHeader("X-CC-Redirect", followRedirects ? "true" : "false");
request.send(postBuffer == null ? null : Int8Array.fromJavaBuffer(postBuffer));
return new Fetching(request);
requestHeaders.append("X-CC-Redirect", Boolean.toString(followRedirects));
var address = uri.getUri().toASCIIString();
Response.fetch(Main.CORS_PROXY.isEmpty() ? address : Main.CORS_PROXY.replace("{}", address), request)
.then(this::handleResponse)
.catchError(e -> {
if (e instanceof JSError err) Console.error(err);
LOG.error(Logging.HTTP_ERROR, "Error in HTTP request", e);
close(Fetching.class, () -> queueFailure("Could not connect"));
return null;
});
return new Fetching(abort);
} catch (Exception e) {
LOG.error(Logging.HTTP_ERROR, "Error in HTTP request", e);
queueFailure("Could not connect");
@@ -107,39 +126,39 @@ final class HttpRequest extends Resource<HttpRequest, HttpRequest.State> {
}
}
private void onResponseStateChange(XMLHttpRequest request) {
if (request.getReadyState() != XMLHttpRequest.DONE) return;
if (request.getStatus() == 0) {
private @Nullable JSPromise<? extends @Nullable JSObject> handleResponse(Response response) {
if (response.getStatus() == 0) {
close(Fetching.class, () -> queueFailure("Could not connect"));
return;
return null;
}
var buffer = (ArrayBuffer) request.getResponse();
SeekableByteChannel contents = new ArrayByteChannel(new Int8Array(buffer).copyToJavaArray());
var reader = new ReadHandle(contents, binary);
Map<String, String> responseHeaders = new HashMap<>();
for (var header : Splitter.on("\r\n").split(request.getAllResponseHeaders())) {
var index = header.indexOf(':');
if (index < 0) continue;
response.getHeaders().forEach((v, k, h) -> {
// Normalise the header (so "content-type" becomes "Content-Type")
var upcase = true;
var headerBuilder = new StringBuilder(index);
for (var i = 0; i < index; i++) {
var c = header.charAt(i);
var headerBuilder = new StringBuilder(k.length());
for (var i = 0; i < k.length(); i++) {
var c = k.charAt(i);
headerBuilder.append(upcase ? Character.toUpperCase(c) : c);
upcase = c == '-';
}
responseHeaders.put(headerBuilder.toString(), header.substring(index + 1).trim());
}
var stream = new HttpResponseHandle(reader, request.getStatus(), request.getStatusText(), responseHeaders);
responseHeaders.put(headerBuilder.toString(), v);
});
if (request.getStatus() >= 200 && request.getStatus() < 400) {
close(Fetching.class, () -> environment.queueEvent(SUCCESS_EVENT, address, stream));
} else {
close(Fetching.class, () -> environment.queueEvent(FAILURE_EVENT, address, request.getStatusText(), stream));
}
return response.bytes().then(body -> {
SeekableByteChannel contents = new ArrayByteChannel(new Int8Array(body).copyToJavaArray());
var reader = new ReadHandle(contents, binary);
var stream = new HttpResponseHandle(reader, response.getStatus(), response.getStatusText(), responseHeaders);
if (response.getStatus() >= 200 && response.getStatus() < 400) {
close(Fetching.class, () -> environment.queueEvent(SUCCESS_EVENT, address, stream));
} else {
close(Fetching.class, () -> environment.queueEvent(FAILURE_EVENT, address, response.getStatusText(), stream));
}
return null;
});
}
private void queueFailure(String message) {
@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: 2026 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.js.abort;
import org.teavm.jso.JSClass;
import org.teavm.jso.JSObject;
import org.teavm.jso.JSProperty;
/**
* The {@link AbortController} interface represents a controller object that allows you to abort one or more Web
* requests as and when desired.
*
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortController">MDN Reference</a>
*/
@JSClass
public class AbortController implements JSObject {
public AbortController() {
}
@JSProperty
public native AbortSignal getSignal();
public native void abort();
public native void abort(String reason);
public native void abort(JSObject reason);
}
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.js.abort;
import org.teavm.jso.JSObject;
import org.teavm.jso.JSProperty;
import org.teavm.jso.dom.events.EventTarget;
/**
* The {@link AbortSignal} interface represents a signal object that allows you to communicate with an asynchronous
* operation (such as a fetch request) and abort it if required via an {@link AbortController} object.
*
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal">MDN Reference</a>
*/
public interface AbortSignal extends EventTarget {
@JSProperty
boolean getAborted();
@JSProperty
JSObject getReason();
}
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.js.fetch;
import org.teavm.jso.JSClass;
import org.teavm.jso.JSFunctor;
import org.teavm.jso.JSObject;
/**
* The {@link Headers} interface of the Fetch API allows you to perform various actions on HTTP request and response
* headers. These actions include retrieving, setting, adding to, and removing headers from the list of the request's
* headers.
*
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Headers">MDN Reference</a>
*/
@JSClass
public class Headers implements JSObject {
public native void append(String key, String value);
public native void forEach(ForEachConsumer consumer);
@JSFunctor
@FunctionalInterface
public interface ForEachConsumer extends JSObject {
void accept(String value, String key, Headers object);
}
}
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2026 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.js.fetch;
import cc.tweaked.web.js.abort.AbortSignal;
import org.teavm.jso.JSClass;
import org.teavm.jso.JSObject;
import org.teavm.jso.JSProperty;
import org.teavm.jso.core.JSMapLike;
import org.teavm.jso.core.JSObjects;
import org.teavm.jso.typedarrays.ArrayBuffer;
import org.teavm.jso.typedarrays.TypedArray;
/**
* The properties that can be passed to {@link Response#fetch(String, RequestInit)}.
*
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/RequestInit">MDN Reference</a>
*/
@JSClass(transparent = true)
public abstract class RequestInit implements JSObject {
private RequestInit() {
}
public static RequestInit create() {
return JSObjects.create();
}
@JSProperty
public native void setMethod(String method);
@JSProperty
public native void setBody(String type);
@JSProperty
public native void setBody(ArrayBuffer body);
@JSProperty
public native void setBody(TypedArray body);
@JSProperty
public native void setHeaders(Headers headers);
@JSProperty
public native void setHeaders(JSMapLike<String> headers);
@JSProperty
public native void setSignal(AbortSignal signal);
}
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2026 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.js.fetch;
import org.teavm.jso.JSBody;
import org.teavm.jso.JSObject;
import org.teavm.jso.JSProperty;
import org.teavm.jso.core.JSPromise;
import org.teavm.jso.typedarrays.Uint8Array;
/**
* The {@link Response} interface of the Fetch API represents the response to a request.
*
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Response">MDN Reference</a>
*/
public abstract class Response implements JSObject {
private Response() {
}
@JSBody(params = "url", script = "return fetch(url)")
public static native JSPromise<Response> fetch(String url);
@JSBody(params = { "url", "options" }, script = "return fetch(url, options)")
public static native JSPromise<Response> fetch(String url, RequestInit options);
@JSProperty
public native int getStatus();
@JSProperty
public native String getStatusText();
@JSProperty
public native Headers getHeaders();
public native JSPromise<Uint8Array> bytes();
}