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(); +}