diff --git a/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java b/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java
index e23266cd17..a161357202 100644
--- a/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java
+++ b/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java
@@ -82,6 +82,7 @@
import org.htmlunit.javascript.host.Window;
import org.htmlunit.javascript.host.WindowOrWorkerGlobalScope;
import org.htmlunit.javascript.host.dom.DOMException;
+import org.htmlunit.javascript.host.fetch.Headers;
import org.htmlunit.javascript.host.html.HTMLElement;
import org.htmlunit.javascript.host.html.HTMLImageElement;
import org.htmlunit.javascript.host.html.HTMLOptionElement;
@@ -248,6 +249,7 @@ private void init(final WebWindow webWindow, final Page page, final Context cx)
// TODO remove the cast
URLSearchParams.NativeParamsIterator.init(scope, "URLSearchParams Iterator");
FormData.FormDataIterator.init(scope, "FormData Iterator");
+ Headers.NativeHeadersIterator.init(scope, "Headers Iterator");
// strange but this is the reality for browsers
// because there will be still some sites using this for browser detection the property is
diff --git a/src/main/java/org/htmlunit/javascript/host/Window.java b/src/main/java/org/htmlunit/javascript/host/Window.java
index 25e28dc1c9..91f42c4e2c 100644
--- a/src/main/java/org/htmlunit/javascript/host/Window.java
+++ b/src/main/java/org/htmlunit/javascript/host/Window.java
@@ -52,6 +52,8 @@
import org.htmlunit.TopLevelWindow;
import org.htmlunit.WebAssert;
import org.htmlunit.WebClient;
+import org.htmlunit.WebRequest;
+import org.htmlunit.WebResponse;
import org.htmlunit.WebConsole;
import org.htmlunit.WebWindow;
import org.htmlunit.WebWindowNotFoundException;
@@ -61,6 +63,7 @@
import org.htmlunit.corejs.javascript.Function;
import org.htmlunit.corejs.javascript.JavaScriptException;
import org.htmlunit.corejs.javascript.NativeConsole.Level;
+import org.htmlunit.corejs.javascript.NativePromise;
import org.htmlunit.corejs.javascript.NativeObject;
import org.htmlunit.corejs.javascript.Scriptable;
import org.htmlunit.corejs.javascript.ScriptableObject;
@@ -110,6 +113,8 @@
import org.htmlunit.javascript.host.event.MessageEvent;
import org.htmlunit.javascript.host.event.MouseEvent;
import org.htmlunit.javascript.host.event.UIEvent;
+import org.htmlunit.javascript.host.fetch.Request;
+import org.htmlunit.javascript.host.fetch.Response;
import org.htmlunit.javascript.host.html.DocumentProxy;
import org.htmlunit.javascript.host.html.HTMLCollection;
import org.htmlunit.javascript.host.html.HTMLDocument;
@@ -304,6 +309,40 @@ public String atob(final String encodedData) {
return WindowOrWorkerGlobalScopeMixin.atob(encodedData, this);
}
+ /**
+ * The JavaScript function {@code fetch()}.
+ * @param input a string or a Request
+ * @param init optional init dictionary
+ * @return a promise resolving to the response
+ */
+ @JsxFunction
+ public NativePromise fetch(final Object input, final Object init) {
+ try {
+ final VarScope scope = JavaScriptEngine.getTopCallScope();
+ final Request request =
+ (Request) JavaScriptEngine.newObject(scope, "Request", new Object[] {input, init});
+ final WebRequest webRequest = request.toWebRequest(this);
+ final WebResponse webResponse = getWebWindow().getWebClient().loadWebResponse(webRequest);
+
+ final Response response = (Response) JavaScriptEngine.newObject(scope,
+ "Response", new Object[] {JavaScriptEngine.UNDEFINED, JavaScriptEngine.UNDEFINED});
+ response.setFromWebResponse(webResponse, webRequest.getUrl().toExternalForm());
+ return setupPromise(() -> response);
+ }
+ catch (final Exception e) {
+ return setupRejectedPromise(() -> {
+ final VarScope scope = JavaScriptEngine.getTopCallScope();
+ final Object typeError = ScriptableObject.getProperty(scope, "TypeError");
+ if (typeError instanceof Function function) {
+ final String message = org.htmlunit.util.StringUtils.isEmptyOrNull(e.getMessage())
+ ? "Failed to fetch" : e.getMessage();
+ return function.construct(Context.getCurrentContext(), scope, new Object[] {message});
+ }
+ return e.getMessage();
+ });
+ }
+ }
+
/**
* The JavaScript function {@code confirm}.
* @param message the message
diff --git a/src/main/java/org/htmlunit/javascript/host/fetch/FetchBodyMixin.java b/src/main/java/org/htmlunit/javascript/host/fetch/FetchBodyMixin.java
new file mode 100644
index 0000000000..0764dcb1ed
--- /dev/null
+++ b/src/main/java/org/htmlunit/javascript/host/fetch/FetchBodyMixin.java
@@ -0,0 +1,210 @@
+/*
+ * Copyright (c) 2002-2026 Gargoyle Software Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.htmlunit.javascript.host.fetch;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.IOException;
+
+import org.htmlunit.corejs.javascript.Context;
+import org.htmlunit.corejs.javascript.Function;
+import org.htmlunit.corejs.javascript.NativeArrayBuffer;
+import org.htmlunit.corejs.javascript.NativePromise;
+import org.htmlunit.corejs.javascript.ScriptableObject;
+import org.htmlunit.corejs.javascript.VarScope;
+import org.htmlunit.corejs.javascript.json.JsonParser;
+import org.htmlunit.corejs.javascript.json.JsonParser.ParseException;
+import org.htmlunit.javascript.HtmlUnitScriptable;
+import org.htmlunit.javascript.JavaScriptEngine;
+import org.htmlunit.javascript.configuration.JsxFunction;
+import org.htmlunit.javascript.configuration.JsxGetter;
+import org.htmlunit.javascript.host.file.Blob;
+import org.htmlunit.javascript.host.xml.FormData;
+
+/**
+ * Shared body handling for fetch Request/Response objects.
+ *
+ * @author Ronald Brill
+ */
+public class FetchBodyMixin extends HtmlUnitScriptable {
+
+ private byte[] bodyBytes_ = new byte[0];
+ private String contentType_;
+ private boolean bodyUsed_;
+
+ @JsxGetter
+ public Object getBody() {
+ return null;
+ }
+
+ @JsxGetter
+ public boolean isBodyUsed() {
+ return bodyUsed_;
+ }
+
+ @JsxFunction
+ public NativePromise text() {
+ if (bodyUsed_) {
+ return rejectedTypeErrorPromise("Body has already been consumed.");
+ }
+ bodyUsed_ = true;
+ return setupPromise(() -> new String(bodyBytes_, UTF_8));
+ }
+
+ @JsxFunction
+ public NativePromise json() {
+ if (bodyUsed_) {
+ return rejectedTypeErrorPromise("Body has already been consumed.");
+ }
+ bodyUsed_ = true;
+ try {
+ final String json = new String(bodyBytes_, UTF_8);
+ final Object parsed = new JsonParser(Context.getCurrentContext(), getParentScope()).parseValue(json);
+ return setupPromise(() -> parsed);
+ }
+ catch (final ParseException e) {
+ return rejectedTypeErrorPromise(e.getMessage());
+ }
+ }
+
+ @JsxFunction
+ public NativePromise arrayBuffer() {
+ if (bodyUsed_) {
+ return rejectedTypeErrorPromise("Body has already been consumed.");
+ }
+ bodyUsed_ = true;
+ return setupPromise(() -> {
+ final NativeArrayBuffer buffer = new NativeArrayBuffer(bodyBytes_.length);
+ System.arraycopy(bodyBytes_, 0, buffer.getBuffer(), 0, bodyBytes_.length);
+ buffer.setParentScope(getParentScope());
+ buffer.setPrototype(ScriptableObject.getClassPrototype(getParentScope(), buffer.getClassName()));
+ return buffer;
+ });
+ }
+
+ @JsxFunction
+ public NativePromise blob() {
+ if (bodyUsed_) {
+ return rejectedTypeErrorPromise("Body has already been consumed.");
+ }
+ bodyUsed_ = true;
+ return setupPromise(() -> {
+ final Blob blob = new Blob(bodyBytes_, contentType_);
+ blob.setParentScope(getParentScope());
+ blob.setPrototype(getPrototype(Blob.class));
+ return blob;
+ });
+ }
+
+ @JsxFunction
+ public NativePromise formData() {
+ if (bodyUsed_) {
+ return rejectedTypeErrorPromise("Body has already been consumed.");
+ }
+ bodyUsed_ = true;
+ return setupPromise(this::buildFormData);
+ }
+
+ protected byte[] getBodyBytes() {
+ return bodyBytes_;
+ }
+
+ protected String getBodyContentType() {
+ return contentType_;
+ }
+
+ protected void setBody(final byte[] bodyBytes, final String contentType) {
+ bodyBytes_ = bodyBytes != null ? bodyBytes.clone() : new byte[0];
+ contentType_ = contentType;
+ bodyUsed_ = false;
+ }
+
+ protected void copyBodyTo(final FetchBodyMixin other) {
+ other.bodyBytes_ = bodyBytes_.clone();
+ other.contentType_ = contentType_;
+ other.bodyUsed_ = false;
+ }
+
+ protected Object createTypeErrorObject(final String message) {
+ final VarScope scope = ScriptableObject.getTopLevelScope(getParentScope());
+ final Object typeError = ScriptableObject.getProperty(scope, "TypeError");
+ if (typeError instanceof Function function) {
+ return function.construct(Context.getCurrentContext(), scope, new Object[] {message});
+ }
+ return message;
+ }
+
+ protected NativePromise rejectedTypeErrorPromise(final String message) {
+ return setupRejectedPromise(() -> createTypeErrorObject(message));
+ }
+
+ private FormData buildFormData() throws IOException {
+ final FormData formData = new FormData();
+ formData.setParentScope(getParentScope());
+ formData.setPrototype(getPrototype(FormData.class));
+ formData.jsConstructor(JavaScriptEngine.UNDEFINED);
+
+ final String contentType = contentType_ != null ? contentType_.toLowerCase() : "";
+ if (contentType.startsWith("multipart/form-data;")) {
+ final String marker = "boundary=";
+ final int boundaryStart = contentType.indexOf(marker);
+ if (boundaryStart > -1) {
+ final String boundary = contentType.substring(boundaryStart + marker.length()).trim();
+ final String body = new String(bodyBytes_, UTF_8);
+ final String[] parts = body.split("--" + boundary);
+ for (final String part : parts) {
+ final int headerEnd = part.indexOf("\r\n\r\n");
+ if (headerEnd < 0) {
+ continue;
+ }
+ final String headers = part.substring(0, headerEnd);
+ final String data = part.substring(headerEnd + 4).replaceFirst("\r\n$", "");
+ final String name = extractName(headers);
+ if (name != null) {
+ formData.append(name, data, JavaScriptEngine.UNDEFINED);
+ }
+ }
+ return formData;
+ }
+ }
+
+ final String body = new String(bodyBytes_, UTF_8);
+ if (!body.isEmpty()) {
+ final String[] pairs = body.split("&");
+ for (final String pair : pairs) {
+ final int equals = pair.indexOf('=');
+ final String name = equals < 0 ? pair : pair.substring(0, equals);
+ final String value = equals < 0 ? "" : pair.substring(equals + 1);
+ formData.append(org.htmlunit.util.UrlUtils.decode(name),
+ org.htmlunit.util.UrlUtils.decode(value), JavaScriptEngine.UNDEFINED);
+ }
+ }
+
+ return formData;
+ }
+
+ private static String extractName(final String headers) {
+ final String marker = "name=\"";
+ final int start = headers.indexOf(marker);
+ if (start < 0) {
+ return null;
+ }
+ final int end = headers.indexOf('"', start + marker.length());
+ if (end < 0) {
+ return null;
+ }
+ return headers.substring(start + marker.length(), end);
+ }
+}
diff --git a/src/main/java/org/htmlunit/javascript/host/fetch/Headers.java b/src/main/java/org/htmlunit/javascript/host/fetch/Headers.java
index f9600e183e..d930847b63 100644
--- a/src/main/java/org/htmlunit/javascript/host/fetch/Headers.java
+++ b/src/main/java/org/htmlunit/javascript/host/fetch/Headers.java
@@ -14,9 +14,31 @@
*/
package org.htmlunit.javascript.host.fetch;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.htmlunit.corejs.javascript.Context;
+import org.htmlunit.corejs.javascript.ES6Iterator;
+import org.htmlunit.corejs.javascript.Function;
+import org.htmlunit.corejs.javascript.IteratorLikeIterable;
+import org.htmlunit.corejs.javascript.NativeObject;
+import org.htmlunit.corejs.javascript.ScriptRuntime;
+import org.htmlunit.corejs.javascript.Scriptable;
+import org.htmlunit.corejs.javascript.SymbolKey;
+import org.htmlunit.corejs.javascript.TopLevel;
+import org.htmlunit.corejs.javascript.VarScope;
import org.htmlunit.javascript.HtmlUnitScriptable;
+import org.htmlunit.javascript.JavaScriptEngine;
import org.htmlunit.javascript.configuration.JsxClass;
import org.htmlunit.javascript.configuration.JsxConstructor;
+import org.htmlunit.javascript.configuration.JsxFunction;
+import org.htmlunit.javascript.configuration.JsxSymbol;
+import org.htmlunit.util.NameValuePair;
/**
* A JavaScript object for {@code Headers}.
@@ -27,11 +49,298 @@
@JsxClass
public class Headers extends HtmlUnitScriptable {
+ /** Constant used to register the prototype in the context. */
+ public static final String HEADERS_TAG = "Headers";
+
+ private final List headers_ = new ArrayList<>();
+
+ /**
+ * {@link ES6Iterator} implementation for js support.
+ */
+ public static final class NativeHeadersIterator extends ES6Iterator {
+ enum Type { KEYS, VALUES, BOTH }
+
+ private final Type type_;
+ private final String className_;
+ private final transient Iterator iterator_;
+
+ /**
+ * Init.
+ * @param scope the scope
+ * @param className the class name
+ */
+ public static void init(final TopLevel scope, final String className) {
+ ES6Iterator.init(scope, false, new NativeHeadersIterator(className), HEADERS_TAG);
+ }
+
+ /**
+ * Ctor.
+ * @param className the class name
+ */
+ public NativeHeadersIterator(final String className) {
+ super();
+ iterator_ = Collections.emptyIterator();
+ type_ = Type.BOTH;
+ className_ = className;
+ }
+
+ /**
+ * Ctor.
+ * @param scope the scope
+ * @param className the class name
+ * @param type the type
+ * @param iterator the backing iterator
+ */
+ public NativeHeadersIterator(final VarScope scope, final String className, final Type type,
+ final Iterator iterator) {
+ super(scope, HEADERS_TAG);
+ iterator_ = iterator;
+ type_ = type;
+ className_ = className;
+ }
+
+ @Override
+ public String getClassName() {
+ return className_;
+ }
+
+ @Override
+ protected boolean isDone(final Context cx, final VarScope scope) {
+ return !iterator_.hasNext();
+ }
+
+ @Override
+ protected Object nextValue(final Context cx, final VarScope scope) {
+ final NameValuePair e = iterator_.next();
+ return switch (type_) {
+ case KEYS -> e.getName();
+ case VALUES -> e.getValue();
+ case BOTH -> cx.newArray(scope, new Object[] {e.getName(), e.getValue()});
+ };
+ }
+ }
+
/**
* JavaScript constructor.
*/
@JsxConstructor
- public void jsConstructor() {
- // nothing to do
+ public void jsConstructor(final Object init) {
+ if (init == null || JavaScriptEngine.isUndefined(init)) {
+ return;
+ }
+
+ if (init instanceof Headers headers) {
+ headers_.addAll(headers.headers_);
+ return;
+ }
+
+ if (init instanceof NativeObject object) {
+ for (final Map.Entry