From 41ad9cb71f7fc8e044bd7a3acff4defcf68905c9 Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Thu, 22 Oct 2026 20:44:09 +0100 Subject: [PATCH] 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. --- .../java/cc/tweaked/web/builder/Builder.java | 2 +- .../java/cc/tweaked/web/http/HttpRequest.java | 91 +++++++++++-------- .../tweaked/web/js/abort/AbortController.java | 30 ++++++ .../cc/tweaked/web/js/abort/AbortSignal.java | 23 +++++ .../java/cc/tweaked/web/js/fetch/Headers.java | 29 ++++++ .../cc/tweaked/web/js/fetch/RequestInit.java | 50 ++++++++++ .../cc/tweaked/web/js/fetch/Response.java | 38 ++++++++ 7 files changed, 226 insertions(+), 37 deletions(-) create mode 100644 projects/web/src/main/java/cc/tweaked/web/js/abort/AbortController.java create mode 100644 projects/web/src/main/java/cc/tweaked/web/js/abort/AbortSignal.java create mode 100644 projects/web/src/main/java/cc/tweaked/web/js/fetch/Headers.java create mode 100644 projects/web/src/main/java/cc/tweaked/web/js/fetch/RequestInit.java create mode 100644 projects/web/src/main/java/cc/tweaked/web/js/fetch/Response.java diff --git a/projects/web/src/builder/java/cc/tweaked/web/builder/Builder.java b/projects/web/src/builder/java/cc/tweaked/web/builder/Builder.java index c43c1661a..3f741d565 100644 --- a/projects/web/src/builder/java/cc/tweaked/web/builder/Builder.java +++ b/projects/web/src/builder/java/cc/tweaked/web/builder/Builder.java @@ -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"); diff --git a/projects/web/src/main/java/cc/tweaked/web/http/HttpRequest.java b/projects/web/src/main/java/cc/tweaked/web/http/HttpRequest.java index 03d3cb5c3..512d91d93 100644 --- a/projects/web/src/main/java/cc/tweaked/web/http/HttpRequest.java +++ b/projects/web/src/main/java/cc/tweaked/web/http/HttpRequest.java @@ -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 { } } - 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 { 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 { } } - private void onResponseStateChange(XMLHttpRequest request) { - if (request.getReadyState() != XMLHttpRequest.DONE) return; - if (request.getStatus() == 0) { + private @Nullable JSPromise 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 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) { diff --git a/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortController.java b/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortController.java new file mode 100644 index 000000000..911f69b87 --- /dev/null +++ b/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortController.java @@ -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 MDN Reference + */ +@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); +} diff --git a/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortSignal.java b/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortSignal.java new file mode 100644 index 000000000..9b292e9b9 --- /dev/null +++ b/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortSignal.java @@ -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 MDN Reference + */ +public interface AbortSignal extends EventTarget { + @JSProperty + boolean getAborted(); + + @JSProperty + JSObject getReason(); +} diff --git a/projects/web/src/main/java/cc/tweaked/web/js/fetch/Headers.java b/projects/web/src/main/java/cc/tweaked/web/js/fetch/Headers.java new file mode 100644 index 000000000..5efed587c --- /dev/null +++ b/projects/web/src/main/java/cc/tweaked/web/js/fetch/Headers.java @@ -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 MDN Reference + */ +@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); + } +} diff --git a/projects/web/src/main/java/cc/tweaked/web/js/fetch/RequestInit.java b/projects/web/src/main/java/cc/tweaked/web/js/fetch/RequestInit.java new file mode 100644 index 000000000..a017a96c2 --- /dev/null +++ b/projects/web/src/main/java/cc/tweaked/web/js/fetch/RequestInit.java @@ -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 MDN Reference + */ +@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 headers); + + @JSProperty + public native void setSignal(AbortSignal signal); +} diff --git a/projects/web/src/main/java/cc/tweaked/web/js/fetch/Response.java b/projects/web/src/main/java/cc/tweaked/web/js/fetch/Response.java new file mode 100644 index 000000000..788bfd2be --- /dev/null +++ b/projects/web/src/main/java/cc/tweaked/web/js/fetch/Response.java @@ -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 MDN Reference + */ +public abstract class Response implements JSObject { + private Response() { + } + + @JSBody(params = "url", script = "return fetch(url)") + public static native JSPromise fetch(String url); + + @JSBody(params = { "url", "options" }, script = "return fetch(url, options)") + public static native JSPromise fetch(String url, RequestInit options); + + @JSProperty + public native int getStatus(); + + @JSProperty + public native String getStatusText(); + + @JSProperty + public native Headers getHeaders(); + + public native JSPromise bytes(); +}