diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java b/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java
index c8a966a7c9..981f059583 100644
--- a/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java
@@ -204,4 +204,12 @@ private HttpHeaders() {
public static final String WWW_AUTHENTICATE = "WWW-Authenticate";
+ /**
+ * RFC 7639 — ALPN HTTP header field used with CONNECT to advertise the
+ * application protocols intended to run inside the tunnel (e.g. {@code h2}, {@code http/1.1}).
+ *
+ * @since 5.4
+ */
+ public static final String ALPN = "ALPN";
+
}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/message/AlpnHeaderSupport.java b/httpcore5/src/main/java/org/apache/hc/core5/http/message/AlpnHeaderSupport.java
new file mode 100644
index 0000000000..204dc5e85c
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/message/AlpnHeaderSupport.java
@@ -0,0 +1,181 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+package org.apache.hc.core5.http.message;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Codec for the HTTP {@code ALPN} header field (RFC 7639).
+ *
+ * @since 5.4
+ */
+@Contract(threading = ThreadingBehavior.IMMUTABLE)
+@Internal
+public final class AlpnHeaderSupport {
+
+ private static final char[] HEXADECIMAL = "0123456789ABCDEF".toCharArray();
+
+ private AlpnHeaderSupport() {
+ }
+
+ /**
+ * Formats a list of raw ALPN protocol IDs into a single {@code ALPN} header value.
+ */
+ public static String formatValue(final List protocolIds) {
+ Args.notEmpty(protocolIds, "protocolIds");
+ final StringBuilder sb = new StringBuilder();
+ boolean first = true;
+ for (final String id : protocolIds) {
+ if (!first) {
+ sb.append(", ");
+ }
+ sb.append(encodeId(id));
+ first = false;
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Parses an {@code ALPN} header value into decoded protocol IDs.
+ */
+ public static List parseValue(final String value) {
+ if (value == null || value.isEmpty()) {
+ return Collections.emptyList();
+ }
+ final List out = new ArrayList<>();
+ final ParserCursor cursor = new ParserCursor(0, value.length());
+ MessageSupport.parseTokens(value, cursor, token -> {
+ if (!token.isEmpty()) {
+ out.add(decodeId(token));
+ }
+ });
+ return out;
+ }
+
+ /**
+ * Encodes a single raw protocol ID to canonical token form.
+ */
+ public static String encodeId(final String id) {
+ Args.notBlank(id, "id");
+ final byte[] bytes = id.getBytes(StandardCharsets.UTF_8);
+ final StringBuilder sb = new StringBuilder(bytes.length);
+ for (final byte b0 : bytes) {
+ final int b = b0 & 0xFF;
+ if (b == '%' || !isTchar(b)) {
+ appendPctEncoded(b, sb);
+ } else {
+ sb.append((char) b);
+ }
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Decodes percent-encoded token to raw ID using UTF-8.
+ * Accepts lowercase hex; malformed/incomplete sequences are left literal.
+ */
+ public static String decodeId(final String token) {
+ Args.notBlank(token, "token");
+ final byte[] buf = new byte[token.length()];
+ int bi = 0;
+ for (int i = 0; i < token.length(); ) {
+ final char c = token.charAt(i);
+ if (c == '%' && i + 2 < token.length()) {
+ final int hi = hexVal(token.charAt(i + 1));
+ final int lo = hexVal(token.charAt(i + 2));
+ if (hi >= 0 && lo >= 0) {
+ buf[bi++] = (byte) ((hi << 4) | lo);
+ i += 3;
+ continue;
+ }
+ }
+ buf[bi++] = (byte) c;
+ i++;
+ }
+ return new String(buf, 0, bi, StandardCharsets.UTF_8);
+ }
+
+ // RFC7230 tchar minus '%' (RFC7639 requires '%' be percent-encoded)
+ private static boolean isTchar(final int c) {
+ if (c >= '0' && c <= '9') {
+ return true;
+ }
+ if (c >= 'A' && c <= 'Z') {
+ return true;
+ }
+ if (c >= 'a' && c <= 'z') {
+ return true;
+ }
+ switch (c) {
+ case '!':
+ case '#':
+ case '$':
+ case '&':
+ case '\'':
+ case '*':
+ case '+':
+ case '-':
+ case '.':
+ case '^':
+ case '_':
+ case '`':
+ case '|':
+ case '~':
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static void appendPctEncoded(final int b, final StringBuilder sb) {
+ sb.append('%');
+ sb.append(HEXADECIMAL[(b >>> 4) & 0x0F]);
+ sb.append(HEXADECIMAL[b & 0x0F]);
+ }
+
+ private static int hexVal(final char c) {
+ if (c >= '0' && c <= '9') {
+ return c - '0';
+ }
+ if (c >= 'A' && c <= 'F') {
+ return 10 + (c - 'A');
+ }
+ if (c >= 'a' && c <= 'f') {
+ return 10 + (c - 'a');
+ }
+ return -1;
+ }
+}
diff --git a/httpcore5/src/test/java/org/apache/hc/core5/http/message/AlpnHeaderSupportTest.java b/httpcore5/src/test/java/org/apache/hc/core5/http/message/AlpnHeaderSupportTest.java
new file mode 100644
index 0000000000..cf990d4deb
--- /dev/null
+++ b/httpcore5/src/test/java/org/apache/hc/core5/http/message/AlpnHeaderSupportTest.java
@@ -0,0 +1,132 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.message;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+class AlpnHeaderSupportTest {
+
+ @Test
+ void encodes_slash_and_percent_and_space() {
+ assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1"));
+ assertEquals("h2%25", AlpnHeaderSupport.encodeId("h2%"));
+ assertEquals("foo%20bar", AlpnHeaderSupport.encodeId("foo bar"));
+ }
+
+ @Test
+ void encodes_unicode_utf8() {
+ final String raw = "ws/é"; // \u00E9 -> C3 A9
+ final String enc = AlpnHeaderSupport.encodeId(raw);
+ assertEquals("ws%2F%C3%A9", enc);
+ assertEquals(raw, AlpnHeaderSupport.decodeId(enc));
+ }
+
+ @Test
+ void keeps_tchar_plain_and_upper_hex() {
+ assertEquals("h2", AlpnHeaderSupport.encodeId("h2"));
+ assertEquals("A1+B", AlpnHeaderSupport.encodeId("A1+B")); // '+' is a tchar → stays literal
+ assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1")); // slash encoded, hex uppercase
+ }
+
+ @Test
+ void decode_is_liberal_on_hex_case_and_incomplete_sequences() {
+ assertEquals("http/1.1", AlpnHeaderSupport.decodeId("http%2f1.1"));
+ // incomplete % — treat as literal
+ assertEquals("h2%", AlpnHeaderSupport.decodeId("h2%"));
+ assertEquals("h2%G1", AlpnHeaderSupport.decodeId("h2%G1"));
+ }
+
+ @Test
+ void format_and_parse_roundtrip_with_ows() {
+ final String v = "h2, http%2F1.1 ,ws";
+ final List ids = AlpnHeaderSupport.parseValue(v);
+ assertEquals(Arrays.asList("h2", "http/1.1", "ws"), ids);
+ assertEquals("h2, http%2F1.1, ws", AlpnHeaderSupport.formatValue(ids));
+ }
+
+ @Test
+ void parse_empty_and_blank() {
+ assertTrue(AlpnHeaderSupport.parseValue(null).isEmpty());
+ assertTrue(AlpnHeaderSupport.parseValue("").isEmpty());
+ assertTrue(AlpnHeaderSupport.parseValue(" , \t ").isEmpty());
+ }
+
+ @Test
+ void all_tchar_pass_through() {
+ // digits
+ for (char c = '0'; c <= '9'; c++) {
+ assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c)));
+ }
+ // uppercase letters
+ for (char c = 'A'; c <= 'Z'; c++) {
+ assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c)));
+ }
+ // lowercase letters
+ for (char c = 'a'; c <= 'z'; c++) {
+ assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c)));
+ }
+ // the symbol set (minus '%' which must be encoded)
+ final String symbols = "!#$&'*+-.^_`|~";
+ for (int i = 0; i < symbols.length(); i++) {
+ final String s = String.valueOf(symbols.charAt(i));
+ assertEquals(s, AlpnHeaderSupport.encodeId(s));
+ }
+ }
+
+ @Test
+ void percent_is_always_encoded_and_uppercase_hex() {
+ assertEquals("%25", AlpnHeaderSupport.encodeId("%")); // '%' must be encoded
+ assertEquals("h2%25", AlpnHeaderSupport.encodeId("h2%")); // stays uppercase hex
+ }
+
+ @Test
+ void non_tchar_bytes_are_percent_encoded_uppercase() {
+ assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1")); // 'F' uppercase
+ assertEquals("foo%20bar", AlpnHeaderSupport.encodeId("foo bar")); // space → %20
+ }
+
+ @Test
+ void utf8_roundtrip_works() {
+ final String raw = "ws/é";
+ final String enc = AlpnHeaderSupport.encodeId(raw);
+ assertEquals("ws%2F%C3%A9", enc);
+ assertEquals(raw, AlpnHeaderSupport.decodeId(enc));
+ }
+
+ @Test
+ void decoder_is_liberal() {
+ assertEquals("http/1.1", AlpnHeaderSupport.decodeId("http%2f1.1")); // lower hex ok
+ assertEquals("h2%", AlpnHeaderSupport.decodeId("h2%")); // incomplete stays literal
+ assertEquals("h2%G1", AlpnHeaderSupport.decodeId("h2%G1")); // invalid hex stays literal
+ }
+}