diff --git a/impl/pom.xml b/impl/pom.xml index 33eed293d4..82152f3447 100644 --- a/impl/pom.xml +++ b/impl/pom.xml @@ -29,7 +29,7 @@ mojarra jar - Mojarra ${project.version} + Mojarra ${project.version} - Impl EE4J Compatible Implementation for Jakarta Faces API https://github.com/eclipse-ee4j/mojarra diff --git a/impl/src/main/java/org/glassfish/mojarra/RIConstants.java b/impl/src/main/java/org/glassfish/mojarra/RIConstants.java index 7db1fcc502..a2830100b8 100644 --- a/impl/src/main/java/org/glassfish/mojarra/RIConstants.java +++ b/impl/src/main/java/org/glassfish/mojarra/RIConstants.java @@ -88,7 +88,7 @@ public class RIConstants { public static final String VIEWID_KEY_NAME = RI_PREFIX + "viewId"; - public static final String PUSH_RESOURCE_URLS_KEY_NAME = RI_PREFIX + "resourceUrls"; + public static final String EARLY_HINTS_RESOURCE_URLS_KEY_NAME = RI_PREFIX + "earlyHintsResourceUrls"; /** * Marker used when saving the list of component adds and removes. diff --git a/impl/src/main/java/org/glassfish/mojarra/context/ExternalContextImpl.java b/impl/src/main/java/org/glassfish/mojarra/context/ExternalContextImpl.java index f9452d9d40..7555477853 100644 --- a/impl/src/main/java/org/glassfish/mojarra/context/ExternalContextImpl.java +++ b/impl/src/main/java/org/glassfish/mojarra/context/ExternalContextImpl.java @@ -17,7 +17,7 @@ package org.glassfish.mojarra.context; -import static org.glassfish.mojarra.RIConstants.PUSH_RESOURCE_URLS_KEY_NAME; +import static org.glassfish.mojarra.RIConstants.EARLY_HINTS_RESOURCE_URLS_KEY_NAME; import static org.glassfish.mojarra.context.MojarraContextParam.SendPoweredByHeader; import static org.glassfish.mojarra.context.UrlBuilder.PROTOCOL_SEPARATOR; import static org.glassfish.mojarra.context.UrlBuilder.WEBSOCKET_PROTOCOL; @@ -61,7 +61,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; -import jakarta.servlet.http.PushBuilder; import org.glassfish.mojarra.RIConstants; import org.glassfish.mojarra.config.WebConfiguration; @@ -574,7 +573,7 @@ public String encodeResourceURL(String url) { Util.notNull("url", url); String encodedURL = ((HttpServletResponse) response).encodeURL(url); - pushIfPossibleAndNecessary(encodedURL); + addEarlyHintIfPossibleAndNecessary(encodedURL); return encodedURL; } @@ -1079,38 +1078,38 @@ public void release() { } @SuppressWarnings("unchecked") - private void pushIfPossibleAndNecessary(String encodedURL) { + private void addEarlyHintIfPossibleAndNecessary(String encodedURL) { FacesContext context = FacesContext.getCurrentInstance(); if (!context.getApplication().getResourceHandler().isResourceURL(encodedURL)) { return; } - Set resourceUrls = (Set) context.getAttributes().computeIfAbsent(PUSH_RESOURCE_URLS_KEY_NAME, k -> new HashSet<>()); - - if (!resourceUrls.add(encodedURL)) { + // Skip when the client is revalidating, as it then most likely already has the resources cached. + if (!isEmpty(getRequestHeaderMap().get("If-Modified-Since"))) { return; } - PushBuilder pushBuilder = getPushBuilder(context); + if (response instanceof HttpServletResponse hres && !hres.isCommitted()) { + Set resourceUrls = (Set) context.getAttributes().computeIfAbsent(EARLY_HINTS_RESOURCE_URLS_KEY_NAME, k -> new HashSet<>()); - if (pushBuilder != null) { - pushBuilder.path(encodedURL).push(); + if (resourceUrls.add(encodedURL)) { + hres.addHeader("Link", "<" + encodedURL + ">;rel=preload"); + } } } - private PushBuilder getPushBuilder(FacesContext context) { - ExternalContext extContext = context.getExternalContext(); - - if (extContext.getRequest() instanceof HttpServletRequest) { - HttpServletRequest hreq = (HttpServletRequest) extContext.getRequest(); + /** + * Sends a single HTTP 103 (Early Hints) interim response with the {@code Link} preload headers collected so far, + * provided the response is not yet committed. Invoked once, right after the head resources have been rendered, so the + * client can start fetching the render-blocking resources while the body is still being rendered. + */ + public static void sendEarlyHints(FacesContext context) { + Object resourceUrls = context.getAttributes().get(EARLY_HINTS_RESOURCE_URLS_KEY_NAME); - if (isEmpty(extContext.getRequestHeaderMap().get("If-Modified-Since"))) { - return hreq.newPushBuilder(); - } + if (resourceUrls instanceof Set set && !set.isEmpty() && context.getExternalContext().getResponse() instanceof HttpServletResponse hres && !hres.isCommitted()) { + hres.sendEarlyHints(); } - - return null; } private void doLastPhaseActions(FacesContext context, boolean outgoingResponseIsRedirect) { diff --git a/impl/src/main/java/org/glassfish/mojarra/renderkit/html_basic/HeadRenderer.java b/impl/src/main/java/org/glassfish/mojarra/renderkit/html_basic/HeadRenderer.java index de86b422e6..bbabb00c6f 100644 --- a/impl/src/main/java/org/glassfish/mojarra/renderkit/html_basic/HeadRenderer.java +++ b/impl/src/main/java/org/glassfish/mojarra/renderkit/html_basic/HeadRenderer.java @@ -23,6 +23,7 @@ import jakarta.faces.context.FacesContext; import jakarta.faces.context.ResponseWriter; +import org.glassfish.mojarra.context.ExternalContextImpl; import org.glassfish.mojarra.renderkit.Attribute; import org.glassfish.mojarra.renderkit.AttributeManager; import org.glassfish.mojarra.renderkit.RenderKitUtils; @@ -62,6 +63,7 @@ public void encodeChildren(FacesContext context, UIComponent component) throws I public void encodeEnd(FacesContext context, UIComponent component) throws IOException { ResponseWriter writer = context.getResponseWriter(); encodeHeadResources(context); + ExternalContextImpl.sendEarlyHints(context); RenderKitUtils.flushPendingBehaviorEventListeners(context, component, null); writer.endElement("head"); } diff --git a/pom.xml b/pom.xml index d9d3f7d619..acc50eea8f 100644 --- a/pom.xml +++ b/pom.xml @@ -47,6 +47,7 @@ impl + test diff --git a/test/issue5746/pom.xml b/test/issue5746/pom.xml new file mode 100644 index 0000000000..7a4733c8ed --- /dev/null +++ b/test/issue5746/pom.xml @@ -0,0 +1,32 @@ + + + + 4.0.0 + + + org.glassfish.mojarra.test + pom + 5.0.0-SNAPSHOT + + + issue5746 + war + + Mojarra ${project.version} - IT - ${project.artifactId} + diff --git a/test/issue5746/src/main/webapp/WEB-INF/beans.xml b/test/issue5746/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 0000000000..b0120a7530 --- /dev/null +++ b/test/issue5746/src/main/webapp/WEB-INF/beans.xml @@ -0,0 +1,24 @@ + + + + diff --git a/test/issue5746/src/main/webapp/WEB-INF/faces-config.xml b/test/issue5746/src/main/webapp/WEB-INF/faces-config.xml new file mode 100644 index 0000000000..28fd976524 --- /dev/null +++ b/test/issue5746/src/main/webapp/WEB-INF/faces-config.xml @@ -0,0 +1,24 @@ + + + + diff --git a/test/issue5746/src/main/webapp/WEB-INF/web.xml b/test/issue5746/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000000..81eba94d9f --- /dev/null +++ b/test/issue5746/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,45 @@ + + + + + jakarta.faces.PROJECT_STAGE + ${webapp.projectStage} + + + jakarta.faces.PARTIAL_STATE_SAVING + ${webapp.partialStateSaving} + + + jakarta.faces.STATE_SAVING_METHOD + ${webapp.stateSavingMethod} + + + jakarta.faces.SERIALIZE_SERVER_STATE + ${webapp.serializeServerState} + + + + jakarta.faces.ENABLE_CSP_NONCE + true + + diff --git a/test/issue5746/src/main/webapp/issue5746.xhtml b/test/issue5746/src/main/webapp/issue5746.xhtml new file mode 100644 index 0000000000..af3c597254 --- /dev/null +++ b/test/issue5746/src/main/webapp/issue5746.xhtml @@ -0,0 +1,33 @@ + + + + + Early Hints 103 + + + + + + + +

Early Hints 103 test page

+
+ diff --git a/test/issue5746/src/main/webapp/resources/css/style1.css b/test/issue5746/src/main/webapp/resources/css/style1.css new file mode 100644 index 0000000000..a160682359 --- /dev/null +++ b/test/issue5746/src/main/webapp/resources/css/style1.css @@ -0,0 +1,4 @@ +/* Early Hints 103 test resource. */ +body { + font-family: sans-serif; +} diff --git a/test/issue5746/src/main/webapp/resources/css/style2.css b/test/issue5746/src/main/webapp/resources/css/style2.css new file mode 100644 index 0000000000..457eac0338 --- /dev/null +++ b/test/issue5746/src/main/webapp/resources/css/style2.css @@ -0,0 +1,4 @@ +/* Early Hints 103 test resource. */ +h1 { + color: #336699; +} diff --git a/test/issue5746/src/main/webapp/resources/js/script1.js b/test/issue5746/src/main/webapp/resources/js/script1.js new file mode 100644 index 0000000000..bfc88432a0 --- /dev/null +++ b/test/issue5746/src/main/webapp/resources/js/script1.js @@ -0,0 +1,2 @@ +// Early Hints 103 test resource. +console.log("script1.js loaded"); diff --git a/test/issue5746/src/main/webapp/resources/js/script2.js b/test/issue5746/src/main/webapp/resources/js/script2.js new file mode 100644 index 0000000000..ed64f8ed16 --- /dev/null +++ b/test/issue5746/src/main/webapp/resources/js/script2.js @@ -0,0 +1,2 @@ +// Early Hints 103 test resource. +console.log("script2.js loaded"); diff --git a/test/issue5746/src/main/webapp/resources/js/script3.js b/test/issue5746/src/main/webapp/resources/js/script3.js new file mode 100644 index 0000000000..ca191d8067 --- /dev/null +++ b/test/issue5746/src/main/webapp/resources/js/script3.js @@ -0,0 +1,2 @@ +// Early Hints 103 test resource. +console.log("script3.js loaded"); diff --git a/test/issue5746/src/test/java/ee/jakarta/tck/faces/faces50/issue5746/Issue5746IT.java b/test/issue5746/src/test/java/ee/jakarta/tck/faces/faces50/issue5746/Issue5746IT.java new file mode 100644 index 0000000000..24c3d69248 --- /dev/null +++ b/test/issue5746/src/test/java/ee/jakarta/tck/faces/faces50/issue5746/Issue5746IT.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GPL-2.0 with Classpath-exception-2.0 which + * is available at https://openjdk.java.net/legal/gplv2+ce.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 or Apache-2.0 + */ +package ee.jakarta.tck.faces.faces50.issue5746; + +import static java.nio.charset.StandardCharsets.ISO_8859_1; +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.util.Locale.ROOT; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.Socket; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BooleanSupplier; + +import org.junit.jupiter.api.Test; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.devtools.DevTools; +import org.openqa.selenium.devtools.v139.network.Network; +import org.openqa.selenium.devtools.v139.network.model.ResponseReceivedEarlyHints; + +import ee.jakarta.tck.faces.util.selenium.BaseITNG; + +/** + * Verifies the HTTP 103 (Early Hints) support: the head resources of a Facelets page must be announced as + * {@code Link: ;rel=preload} headers in a 103 interim response sent before the final 200 response. + *

+ * This is a Mojarra-specific integration test and deliberately does not live in the Jakarta Faces TCK: the + * specification does not mandate Early Hints. Whether to send a 103 at all, which resources to advertise, and how, is + * left entirely to the implementation — implementors are free to choose their own early-hints approach (or none). + * Being implementation-specific rather than spec-mandated behaviour, it is not TCK-testable. The sources nonetheless + * follow the TCK's package and naming conventions ({@code ee.jakarta.tck.faces.faces50.issue5746}) so they could be + * dropped into the TCK unchanged should the spec ever standardise this. + * + * @see Mojarra issue 5746 + */ +class Issue5746IT extends BaseITNG { + + private static final String VIEW = "issue5746.xhtml"; + private static final int HTTPS_PORT = 8181; + private static final List HEAD_RESOURCES = List.of("style1.css", "style2.css", "script1.js", "script2.js", "script3.js"); + + /** + * On a regular (non-conditional) request, the server must emit a 103 interim response that preloads every head + * resource, ahead of the final 200 response. Asserted at the wire level because {@link java.net.http.HttpClient} + * silently discards 1xx interim responses. + */ + @Test + void earlyHintsArePreloadedBeforeFinalResponse() throws IOException { + String response = rawHttpGet(Map.of()); + + int interimStatus = response.indexOf("HTTP/1.1 103"); + int finalStatus = response.indexOf("HTTP/1.1 200"); + assertTrue(interimStatus >= 0, () -> "Expected a '103 Early Hints' interim response, but got:\n" + response); + assertTrue(finalStatus > interimStatus, () -> "The '200' final response must come after the '103' interim response:\n" + response); + + String interim = response.substring(interimStatus, finalStatus).toLowerCase(ROOT); + assertTrue(interim.contains("link:"), () -> "The 103 block must carry Link headers:\n" + response); + assertTrue(interim.contains("rel=preload"), () -> "The Link headers must use rel=preload:\n" + response); + for (String resource : HEAD_RESOURCES) { + assertTrue(interim.contains(resource), () -> "The 103 block must preload " + resource + ":\n" + response); + } + } + + /** + * A conditional (revalidating) request already has the resources cached, so the server must skip early hints. + * + * @see org.glassfish.mojarra.context.ExternalContextImpl#addEarlyHintIfPossibleAndNecessary + */ + @Test + void earlyHintsAreSkippedOnConditionalRequest() throws IOException { + String response = rawHttpGet(Map.of("If-Modified-Since", "Wed, 21 Oct 2099 07:28:00 GMT")); + + assertFalse(response.contains("HTTP/1.1 103"), () -> "No 103 expected on a conditional request, but got:\n" + response); + } + + /** + * Verifies that a real browser actually receives the 103 interim response, captured via the Chrome DevTools + * {@code Network.responseReceivedEarlyHints} event. Chrome only acts on Early Hints over HTTP/2, hence this + * navigates to the TLS (HTTP/2) listener instead of the plaintext HTTP/1.1 one used by the wire checks above. + */ + @Test + void browserReceivesEarlyHints() { + DevTools devTools = ((ChromeDriver) getWebDriver().getDelegate()).getDevTools(); + List received = new CopyOnWriteArrayList<>(); + devTools.addListener(Network.responseReceivedEarlyHints(), hints -> received.add(hints)); + + getWebDriver().get("https://" + webUrl.getHost() + ":" + HTTPS_PORT + viewPath()); + waitUntil(() -> !received.isEmpty()); + + assertFalse(received.isEmpty(), "The browser should have received a 103 Early Hints response"); + boolean preloadsResources = received.stream() + .flatMap(hints -> hints.getHeaders().entrySet().stream()) + .anyMatch(header -> "link".equalsIgnoreCase(header.getKey()) && String.valueOf(header.getValue()).contains("rel=preload")); + assertTrue(preloadsResources, "The early hints received by the browser must contain Link rel=preload headers"); + } + + private String viewPath() { + String base = webUrl.getPath(); + return base + (base.endsWith("/") ? "" : "/") + VIEW; + } + + private String rawHttpGet(Map headers) throws IOException { + String host = webUrl.getHost(); + int port = webUrl.getPort() != -1 ? webUrl.getPort() : webUrl.getDefaultPort(); + + StringBuilder request = new StringBuilder(); + request.append("GET ").append(viewPath()).append(" HTTP/1.1\r\n"); + request.append("Host: ").append(host).append(':').append(port).append("\r\n"); + headers.forEach((name, value) -> request.append(name).append(": ").append(value).append("\r\n")); + request.append("Connection: close\r\n\r\n"); + + try (Socket socket = new Socket(host, port)) { + socket.setSoTimeout(15_000); + socket.getOutputStream().write(request.toString().getBytes(US_ASCII)); + socket.getOutputStream().flush(); + return new String(socket.getInputStream().readAllBytes(), ISO_8859_1); + } + } + + private static void waitUntil(BooleanSupplier condition) { + long deadline = System.currentTimeMillis() + 5_000; + while (!condition.getAsBoolean() && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } +} diff --git a/test/issue5746/src/test/resources/arquillian.xml b/test/issue5746/src/test/resources/arquillian.xml new file mode 100644 index 0000000000..d825d3bb46 --- /dev/null +++ b/test/issue5746/src/test/resources/arquillian.xml @@ -0,0 +1,33 @@ + + + + + + + ${glassfish.home} + true + + + diff --git a/test/perf/pom.xml b/test/perf/pom.xml index ba2353a993..8e0de9d42a 100644 --- a/test/perf/pom.xml +++ b/test/perf/pom.xml @@ -31,11 +31,8 @@ Mojarra ${project.version} - IT - ${project.artifactId} - - server - false + true - @@ -48,16 +45,4 @@ - - - - org.apache.maven.plugins - maven-war-plugin - - - true - - - - diff --git a/test/pom.xml b/test/pom.xml index b42c32cecc..2cd7c37ba8 100644 --- a/test/pom.xml +++ b/test/pom.xml @@ -34,12 +34,14 @@ ../faces/tck/util base + issue5746 perf 5.0.0-SNAPSHOT true + 0 139 chrome 5.0.0-SNAPSHOT @@ -52,11 +54,16 @@ 26.0.0.6 11.0.24 + + Production + server + true + false + cross-server comparison is apples-to-apples. --> 1g -Xmx${perf.heapSize} @@ -109,6 +116,7 @@ 3.4.0 false + true @@ -124,7 +132,7 @@ - 2 + ${failsafe.rerunFailingTestsCount} ${project.build.finalName} ${test.selenium}