Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/java/org/htmlunit/javascript/JavaScriptEngine.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions src/main/java/org/htmlunit/javascript/host/Window.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
210 changes: 210 additions & 0 deletions src/main/java/org/htmlunit/javascript/host/fetch/FetchBodyMixin.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading