This is an automated email from the ASF dual-hosted git repository.

bzp2010 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 52eb4e31c6 feat(websocket): add enhanced proxy and plugin hook (#13939)
52eb4e31c6 is described below

commit 52eb4e31c6fdcc45a4426dcc55473faf59a3888c
Author: Zeping Bai <[email protected]>
AuthorDate: Fri Sep 18 16:28:16 2026 +0800

    feat(websocket): add enhanced proxy and plugin hook (#13939)
---
 apisix-master-0.rockspec             |   1 +
 apisix/balancer.lua                  |  48 +++-
 apisix/cli/ngx_tpl.lua               |  10 +
 apisix/core.lua                      |   1 +
 apisix/core/websocket.lua            |  86 +++++++
 apisix/init.lua                      | 280 +++++++++++++++++++-
 apisix/plugins/example-plugin.lua    |  28 ++
 apisix/schema_def.lua                |   4 +-
 apisix/upstream.lua                  |   2 +
 docs/en/latest/admin-api.md          |  11 +-
 docs/en/latest/plugin-develop.md     |  27 ++
 docs/en/latest/terminology/plugin.md |   2 +
 docs/zh/latest/admin-api.md          |  11 +-
 t/APISIX.pm                          |  27 ++
 t/lib/server.lua                     | 219 ++++++++++++++++
 t/node/websocket-proxy.spec.mts      | 479 +++++++++++++++++++++++++++++++++++
 t/node/websocket-proxy.t             |  35 +++
 t/package.json                       |   2 +
 t/pnpm-lock.yaml                     |  27 ++
 19 files changed, 1275 insertions(+), 25 deletions(-)

diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index 40b41a0222..8c1fc783bd 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -32,6 +32,7 @@ description = {
 
 dependencies = {
     "lua-resty-ctxdump = 0.1-0",
+    "api7-lua-resty-websocket = 0.1.0-0",
     "api7-lua-resty-redis-connector = 0.13.0",
     "lyaml = 6.2.8-1",
     "api7-lua-resty-dns-client = 7.1.2-0",
diff --git a/apisix/balancer.lua b/apisix/balancer.lua
index 0258d4bb54..132cf89378 100644
--- a/apisix/balancer.lua
+++ b/apisix/balancer.lua
@@ -249,10 +249,30 @@ local function 
parse_server_for_upstream_host(picked_server, upstream_scheme)
 end
 
 
+-- reports a connection outcome (get_last_failure()-shaped state/code) for the
+-- node ctx.balancer_ip/balancer_port currently point at
+local function report_failure(ctx, checker, up_conf, state, code)
+    local host = up_conf.checks and up_conf.checks.active and 
up_conf.checks.active.host
+    local port = up_conf.checks and up_conf.checks.active and 
up_conf.checks.active.port
+    if state == "failed" then
+        if code == 504 then
+            checker:report_timeout(ctx.balancer_ip, port or ctx.balancer_port, 
host)
+        else
+            checker:report_tcp_failure(ctx.balancer_ip, port or 
ctx.balancer_port, host)
+        end
+    else
+        checker:report_http_status(ctx.balancer_ip, port or ctx.balancer_port, 
host, code)
+    end
+end
+
+
 -- pick_server will be called:
 -- 1. in the access phase so that we can set headers according to the picked 
server
 -- 2. each time we need to retry upstream
-local function pick_server(route, ctx)
+--
+-- prev_failure, when given, overrides get_last_failure() for callers outside
+-- balancer_by_lua* that already know their own connection's outcome.
+local function pick_server(route, ctx, prev_failure)
     local up_conf = ctx.upstream_conf
 
     local nodes_count = #up_conf.nodes
@@ -293,18 +313,13 @@ local function pick_server(route, ctx)
         end
 
         if checker then
-            local state, code = get_last_failure()
-            local host = up_conf.checks and up_conf.checks.active and 
up_conf.checks.active.host
-            local port = up_conf.checks and up_conf.checks.active and 
up_conf.checks.active.port
-            if state == "failed" then
-                if code == 504 then
-                    checker:report_timeout(ctx.balancer_ip, port or 
ctx.balancer_port, host)
-                else
-                    checker:report_tcp_failure(ctx.balancer_ip, port or 
ctx.balancer_port, host)
-                end
+            local state, code
+            if prev_failure then
+                state, code = prev_failure.state, prev_failure.code
             else
-                checker:report_http_status(ctx.balancer_ip, port or 
ctx.balancer_port, host, code)
+                state, code = get_last_failure()
             end
+            report_failure(ctx, checker, up_conf, state, code)
         end
     end
 
@@ -385,6 +400,17 @@ end
 _M.pick_server = pick_server
 
 
+-- reports a final failure with no next node to pick_server() for
+function _M.report_failure(ctx, prev_failure)
+    local checker = ctx.up_checker
+    if not checker then
+        return
+    end
+
+    report_failure(ctx, checker, ctx.upstream_conf, prev_failure.state, 
prev_failure.code)
+end
+
+
 -- Keyed by the `ca_certs` array itself: a config update always rebuilds that
 -- table, so a stale digest can never outlive the certificates it was made 
from.
 local ca_certs_digest_cache = core.lrucache.new({
diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua
index 567f288b15..8688fcfe20 100644
--- a/apisix/cli/ngx_tpl.lua
+++ b/apisix/cli/ngx_tpl.lua
@@ -1184,6 +1184,16 @@ http {
         }
         {% end %}
 
+        location @websocket_pass {
+            content_by_lua_block {
+                apisix.websocket_content_phase()
+            }
+
+            log_by_lua_block {
+                apisix.websocket_log_phase()
+            }
+        }
+
         {% if enabled_plugins["proxy-mirror"] then %}
         location = /proxy_mirror {
             internal;
diff --git a/apisix/core.lua b/apisix/core.lua
index fceb7d6a0d..2c2bc2d23c 100644
--- a/apisix/core.lua
+++ b/apisix/core.lua
@@ -66,4 +66,5 @@ return {
     event       = require("apisix.core.event"),
     env         = require("apisix.core.env"),
     data_encryption = require("apisix.core.data_encryption"),
+    websocket   = require("apisix.core.websocket"),
 }
diff --git a/apisix/core/websocket.lua b/apisix/core/websocket.lua
new file mode 100644
index 0000000000..64aebf5754
--- /dev/null
+++ b/apisix/core/websocket.lua
@@ -0,0 +1,86 @@
+--
+-- 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.
+--
+local ngx = ngx
+local tostring = tostring
+
+local ROLE_CLIENT = "client"
+local ROLE_UPSTREAM = "upstream"
+local CTX_KEY_CLIENT = "websocket_client"
+local CTX_KEY_UPSTREAM = "websocket_upstream"
+
+-- ngx.ctx is per-request and can only be accessed from within a request
+-- context, so it must be fetched inside each wrapped function, not cached
+-- as a module-level upvalue at require() time (which also runs during
+-- init_by_lua, before any request exists).
+
+local function wrap_stash_frame(key)
+  return function(frame)
+    ngx.ctx[key] = frame
+  end
+end
+
+
+local function wrap_get_frame(key)
+  return function()
+    return ngx.ctx[key]
+  end
+end
+
+
+local function wrap_set_frame_data(key)
+  return function(data)
+    ngx.ctx[key].payload = data
+  end
+end
+
+
+local function wrap_set_status(key)
+  return function(status)
+    ngx.ctx[key].code = status
+  end
+end
+
+
+local _M = {
+  ROLE_CLIENT = ROLE_CLIENT,
+  ROLE_UPSTREAM = ROLE_UPSTREAM,
+  [ROLE_CLIENT] = {
+    stash_frame = wrap_stash_frame(CTX_KEY_CLIENT),
+    get_frame = wrap_get_frame(CTX_KEY_CLIENT),
+    set_frame_data = wrap_set_frame_data(CTX_KEY_CLIENT),
+    set_status = wrap_set_status(CTX_KEY_CLIENT),
+    --drop_frame = wrap_drop_frame
+  },
+  [ROLE_UPSTREAM] = {
+    stash_frame = wrap_stash_frame(CTX_KEY_UPSTREAM),
+    get_frame = wrap_get_frame(CTX_KEY_UPSTREAM),
+    set_frame_data = wrap_set_frame_data(CTX_KEY_UPSTREAM),
+    set_status = wrap_set_status(CTX_KEY_UPSTREAM),
+  },
+}
+
+function _M.get_role(role)
+  if role == ROLE_CLIENT or role == "client" then
+    return _M[ROLE_CLIENT]
+  elseif role == ROLE_UPSTREAM or role == "upstream" then
+    return _M[ROLE_UPSTREAM]
+  else
+    return nil, "invalid role: " .. tostring(role)
+  end
+end
+
+return _M
diff --git a/apisix/init.lua b/apisix/init.lua
index b4ef63195e..4e78d2335a 100644
--- a/apisix/init.lua
+++ b/apisix/init.lua
@@ -26,6 +26,7 @@ require("jit.opt").start("minstitch=2", "maxtrace=4000",
                          "maxmcode=4000", "maxirconst=1000")
 
 require("apisix.patch").patch()
+local ws_proxy        = require("resty.websocket.proxy")
 local core            = require("apisix.core")
 local plugin          = require("apisix.plugin")
 local plugin_config   = require("apisix.plugin_config")
@@ -62,6 +63,9 @@ local re_gsub         = ngx.re.gsub
 local str_byte        = string.byte
 local str_sub         = string.sub
 local str_char        = string.char
+local str_format      = string.format
+local str_find        = string.find
+local str_lower       = string.lower
 local tonumber        = tonumber
 local type            = type
 local pairs           = pairs
@@ -305,6 +309,23 @@ local function parse_domain_in_route(route)
 end
 
 
+-- host per upstream.pass_host: pass = client's Host, rewrite = configured
+-- upstream_host, node = picked node's host[:port]. Also used directly by the
+-- websocket phase, which has no nginx variable to fall back on for "pass".
+local function compute_upstream_host(api_ctx, picked_server)
+    local pass_host = api_ctx.pass_host or "pass"
+    if pass_host == "rewrite" then
+        return api_ctx.upstream_host
+    end
+
+    if pass_host == "node" then
+        return picked_server.upstream_host
+    end
+
+    return api_ctx.var.http_host
+end
+
+
 local function set_upstream_host(api_ctx, picked_server)
     local up_conf = api_ctx.upstream_conf
     if up_conf.pass_host then
@@ -317,12 +338,7 @@ local function set_upstream_host(api_ctx, picked_server)
         return
     end
 
-    if pass_host == "rewrite" then
-        api_ctx.var.upstream_host = api_ctx.upstream_host
-        return
-    end
-
-    api_ctx.var.upstream_host = picked_server.upstream_host
+    api_ctx.var.upstream_host = compute_upstream_host(api_ctx, picked_server)
 end
 
 
@@ -331,6 +347,46 @@ local function set_upstream_headers(api_ctx, picked_server)
 end
 
 
+-- hop-by-hop headers, plus handshake headers connect() already sets itself
+-- (host/protocols/origin opts, or generated Sec-WebSocket-Key/-Version).
+local ws_skip_forward_headers = {
+    ["host"] = true,
+    ["connection"] = true,
+    ["upgrade"] = true,
+    ["keep-alive"] = true,
+    ["te"] = true,
+    ["trailers"] = true,
+    ["proxy-authenticate"] = true,
+    ["proxy-authorization"] = true,
+    ["content-length"] = true,
+    ["transfer-encoding"] = true,
+    ["sec-websocket-key"] = true,
+    ["sec-websocket-version"] = true,
+    ["sec-websocket-extensions"] = true,
+    ["sec-websocket-protocol"] = true,
+    ["origin"] = true,
+}
+
+
+-- forwards the client's other headers (Cookie, Authorization, ...) upstream.
+local function build_ws_forward_headers(api_ctx)
+    local headers = {}
+    for name, value in pairs(core.request.headers(api_ctx)) do
+        if not ws_skip_forward_headers[str_lower(name)] then
+            if type(value) == "table" then
+                for _, v in ipairs(value) do
+                    headers[#headers + 1] = name .. ": " .. v
+                end
+            else
+                headers[#headers + 1] = name .. ": " .. value
+            end
+        end
+    end
+
+    return headers
+end
+
+
 -- verify the TLS session resumption by checking if the SNI in the client hello
 -- matches the hostname of the SSL session, this is to prevent the mTLS bypass 
security issue.
 local function verify_tls_session_resumption()
@@ -675,6 +731,24 @@ function _M.handle_upstream(api_ctx, route, 
enable_websocket)
         return ngx.exec("@grpc_pass")
     end
 
+    if up_scheme == "wss" or up_scheme == "ws" then
+        -- @websocket_pass never runs proxy_pass, so it never gets the
+        -- `proxy_set_header X-Real-IP $remote_addr` / `... X-Forwarded-For
+        -- $proxy_add_x_forwarded_for` that ngx_tpl.lua's proxy_pass location
+        -- sends: set the same values here, once, so ws_handshake and later
+        -- phases see what proxy_pass routes would have seen, and
+        -- build_ws_forward_headers() (apisix/init.lua) can just forward them
+        -- like any other header instead of special-casing these two.
+        core.request.set_header(api_ctx, "X-Real-IP", api_ctx.var.remote_addr)
+        core.request.set_header(api_ctx, "X-Forwarded-For",
+                                api_ctx.var.proxy_add_x_forwarded_for)
+
+        common_phase("ws_handshake")
+
+        stash_ngx_ctx()
+        return ngx.exec("@websocket_pass")
+    end
+
     if api_ctx.dubbo_proxy_enabled then
         stash_ngx_ctx()
         return ngx.exec("@dubbo_pass")
@@ -989,6 +1063,200 @@ function _M.grpc_access_phase()
 end
 
 
+-- call ws_x_frame hook
+function _M.websocket_content_phase()
+    ngx.ctx = fetch_ctx()
+    local api_ctx = ngx.ctx.api_ctx
+    local up_conf = api_ctx.upstream_conf
+    -- a Route's own `timeout` overrides upstream.timeout, same as
+    -- set_balancer_opts() does for the plain proxy_pass path
+    local route = api_ctx.matched_route
+    local up_timeout = (route and route.value and route.value.timeout) or 
up_conf.timeout
+    local connect_timeout_ms = up_timeout and up_timeout.connect and 
up_timeout.connect * 1000
+    local recv_timeout_ms = up_timeout and up_timeout.read and up_timeout.read 
* 1000
+    -- upstream.timeout.send is silently ignored for ws/wss
+
+    local ws_headers = build_ws_forward_headers(api_ctx)
+    local ws_protocols = core.request.header(api_ctx, "Sec-WebSocket-Protocol")
+    local ws_origin = core.request.header(api_ctx, "Origin")
+
+    -- resolve upstream.tls once, same as https/grpcs in apisix/upstream.lua
+    local ssl_verify, client_cert, client_priv_key
+    if api_ctx.matched_upstream.scheme == "wss" and up_conf.tls then
+        ssl_verify = up_conf.tls.verify
+
+        if up_conf.tls.client_cert or up_conf.tls.client_cert_id then
+            local cert_pem, key_pem
+            if up_conf.tls.client_cert_id then
+                cert_pem = api_ctx.upstream_ssl and api_ctx.upstream_ssl.cert
+                key_pem = api_ctx.upstream_ssl and api_ctx.upstream_ssl.key
+            else
+                cert_pem = up_conf.tls.client_cert
+                key_pem = up_conf.tls.client_key
+            end
+
+            local cert_err, key_err
+            client_cert, cert_err = 
apisix_ssl.fetch_cert(api_ctx.var.upstream_host, cert_pem)
+            if not client_cert then
+                ngx.log(ngx.ERR, "failed to fetch websocket upstream client 
cert: ", cert_err)
+                return core.response.exit(503)
+            end
+
+            client_priv_key, key_err = 
apisix_ssl.fetch_pkey(api_ctx.var.upstream_host, key_pem)
+            if not client_priv_key then
+                ngx.log(ngx.ERR, "failed to fetch websocket upstream client 
key: ", key_err)
+                return core.response.exit(503)
+            end
+        end
+    end
+
+    local ok, proxy, err = pcall(ws_proxy.new, {
+        aggregate_fragments = true,
+        recv_timeout = recv_timeout_ms,
+        on_frame = function(proxy, role, typ, payload, last, code)
+            --   proxy: [table]       the proxy instance
+            --    role: [string]      "client" or "upstream"
+            --     typ: [string]      "text", "binary", "ping", "pong", "close"
+            -- payload: [string|nil]  payload if any
+            --    last: [boolean]     fin flag; true when aggregate_fragments 
is on
+            --    code: [number|nil]  code for "close" frames
+
+            local role_handler, err = core.websocket.get_role(role)
+            if not role_handler then
+                ngx.log(ngx.ERR, "invalid websocket role: ", err)
+                return
+            end
+
+            role_handler.stash_frame({
+                proxy = proxy,
+                type = typ,
+                payload = payload,
+                last = last,
+                code = code,
+            })
+
+            if role == "client" then
+                common_phase("ws_client_frame")
+            else
+                common_phase("ws_upstream_frame")
+            end
+
+            local new_frame = role_handler.get_frame()
+            return new_frame.payload, new_frame.code
+        end
+    })
+    if not ok then
+        ngx.log(ngx.ERR, "failed to create proxy: ", proxy)
+        return core.response.exit(500)
+    end
+    if not proxy then
+        ngx.log(ngx.ERR, "failed to create proxy: ", err)
+        return core.response.exit(500)
+    end
+
+    -- proxy:connect() only sends the 101 response to the downstream client
+    -- after it has successfully connected upstream, so it's safe to retry
+    -- against another node here without having committed to the client yet.
+    local retries = up_conf.retries
+    if not retries or retries < 0 then
+        retries = #up_conf.nodes - 1
+    end
+
+    local retry_deadline
+    if retries > 0 and up_conf.retry_timeout and up_conf.retry_timeout > 0 then
+        retry_deadline = ngx_now() + up_conf.retry_timeout
+    end
+
+    -- upstream_uri is only ever set by plugins like proxy-rewrite that
+    -- explicitly rewrite the forwarded path; the normal proxy_pass paths get
+    -- the client's original request URI for free from nginx's own passthrough
+    -- behavior, but we build the request line ourselves here, so we have to
+    -- fall back to the client's URI (plus query string) the same way
+    -- proxy-mirror.lua does.
+    local request_uri = api_ctx.var.upstream_uri
+    if not request_uri or request_uri == "" then
+        request_uri = api_ctx.var.uri .. (api_ctx.var.is_args or "") .. 
(api_ctx.var.args or "")
+    end
+
+    local server = api_ctx.picked_server
+    local ok, connect_err
+    for attempt = 0, retries do
+        if attempt > 0 and retry_deadline and retry_deadline < ngx_now() then
+            ngx.log(ngx.ERR, "websocket proxy retry timeout, retry count: ", 
attempt,
+                   ", deadline: ", retry_deadline, " now: ", ngx_now())
+            return core.response.exit(502)
+        end
+
+        if connect_timeout_ms then
+            proxy.client:set_timeout(connect_timeout_ms)
+        end
+
+        local endpoint = str_format("%s://%s:%d%s", 
api_ctx.matched_upstream.scheme,
+                                    server.host, server.port, request_uri)
+        ok, connect_err = proxy:connect(endpoint, {
+            host = compute_upstream_host(api_ctx, server),
+            server_name = server.domain,
+            headers = ws_headers,
+            protocols = ws_protocols,
+            origin = ws_origin,
+            ssl_verify = ssl_verify,
+            client_cert = client_cert,
+            client_priv_key = client_priv_key,
+        })
+        if ok then
+            break
+        end
+
+        ngx.log(ngx.ERR, "failed to connect to websocket upstream ", endpoint,
+               ": ", connect_err)
+
+        -- no balancer_by_lua* here, so report the outcome ourselves; a parsed
+        -- HTTP status (just not 101) is a passive HTTP status report, not 
tcp_failure
+        local prev_failure
+        local resp_status_code = proxy.client.resp_status_code
+        if resp_status_code then
+            prev_failure = {state = "ok", code = tonumber(resp_status_code)}
+        elseif connect_err and str_find(connect_err, "timeout", 1, true) then
+            prev_failure = {state = "failed", code = 504}
+        else
+            prev_failure = {state = "failed", code = 599}
+        end
+
+        if attempt >= retries then
+            -- last attempt: report it, pick_server() won't be called again
+            load_balancer.report_failure(api_ctx, prev_failure)
+            break
+        end
+
+        local next_server, pick_err = 
load_balancer.pick_server(api_ctx.matched_route,
+                                                                 api_ctx, 
prev_failure)
+        if not next_server then
+            ngx.log(ngx.ERR, "failed to pick next websocket upstream server: 
", pick_err)
+            break
+        end
+
+        server = next_server
+        api_ctx.picked_server = server
+    end
+
+    if not ok then
+        return core.response.exit(502)
+    end
+
+    local done, err = proxy:execute()
+    if not done then
+        ngx.log(ngx.ERR, "failed proxying: ", err)
+        return core.response.exit(502)
+    end
+end
+
+
+function _M.websocket_log_phase()
+    common_phase("ws_close")
+    _M.http_log_phase()
+end
+
+
 local function set_resp_upstream_status(up_status)
     local_conf = core.config.local_conf()
 
diff --git a/apisix/plugins/example-plugin.lua 
b/apisix/plugins/example-plugin.lua
index 767ccfae72..e76ce72e9e 100644
--- a/apisix/plugins/example-plugin.lua
+++ b/apisix/plugins/example-plugin.lua
@@ -128,6 +128,34 @@ function _M.log(conf, ctx)
 end
 
 
+function _M.ws_handshake(conf, ctx)
+    core.log.warn("plugin ws_handshake phase, conf: ", core.json.encode(conf))
+end
+
+
+function _M.ws_client_frame(conf, ctx)
+    local frame = core.websocket.client.get_frame()
+    core.log.warn("plugin ws_client_frame phase, type: ", frame.type)
+    if frame.type == "text" and frame.payload then
+        core.websocket.client.set_frame_data(frame.payload .. "-client")
+    end
+end
+
+
+function _M.ws_upstream_frame(conf, ctx)
+    local frame = core.websocket.upstream.get_frame()
+    core.log.warn("plugin ws_upstream_frame phase, type: ", frame.type)
+    if frame.type == "text" and frame.payload then
+        core.websocket.upstream.set_frame_data(frame.payload .. "-upstream")
+    end
+end
+
+
+function _M.ws_close(conf, ctx)
+    core.log.warn("plugin ws_close phase, conf: ", core.json.encode(conf))
+end
+
+
 local function hello()
     local args = ngx.req.get_uri_args()
     if args["json"] then
diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua
index 0ff40e5646..64f661f821 100644
--- a/apisix/schema_def.lua
+++ b/apisix/schema_def.lua
@@ -549,9 +549,9 @@ local upstream_schema = {
         scheme = {
             default = "http",
             enum = {"grpc", "grpcs", "http", "https", "tcp", "tls", "udp",
-                "kafka"},
+                "kafka", "ws", "wss"},
             description = "The scheme of the upstream." ..
-                " For L7 proxy, it can be one of grpc/grpcs/http/https." ..
+                " For L7 proxy, it can be one of 
grpc/grpcs/http/https/ws/wss." ..
                 " For L4 proxy, it can be one of tcp/tls/udp." ..
                 " For specific protocols, it can be kafka."
         },
diff --git a/apisix/upstream.lua b/apisix/upstream.lua
index acb6ab3663..9f061d8543 100644
--- a/apisix/upstream.lua
+++ b/apisix/upstream.lua
@@ -190,6 +190,8 @@ local scheme_to_port = {
     https = 443,
     grpc = 80,
     grpcs = 443,
+    ws = 80,
+    wss = 443,
 }
 
 
diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md
index 77d8d76969..7b0ac85340 100644
--- a/docs/en/latest/admin-api.md
+++ b/docs/en/latest/admin-api.md
@@ -368,7 +368,7 @@ ID's as a text string must be of a length between 1 and 64 
characters and they s
 | plugin_config_id | False, can't be used with `script`       | Plugin      | 
[Plugin config](terminology/plugin-config.md) bound to the Route.               
                                                                                
                                                                                
                                       |                                        
              |
 | labels           | False                                    | Match Rules | 
Attributes of the Route specified as key-value pairs.                           
                                                                                
                                                                                
                                               | 
{"version":"v2","build":"16","env":"production"}     |
 | timeout          | False                                    | Auxiliary   | 
Sets the timeout (in seconds) for connecting to, and sending and receiving 
messages between the Upstream and the Route. This will overwrite the `timeout` 
value configured in your [Upstream](#upstream).                                 
                                                                  | {"connect": 
3, "send": 3, "read": 3}                 |
-| enable_websocket | False                                    | Auxiliary   | 
Enables a websocket. Set to `false` by default.                                 
                                                                                
                                                                                
                                               |                                
                      |
+| enable_websocket | False                                    | Auxiliary   | 
Enables a websocket. Set to `false` by default. This is a plain protocol 
upgrade with no access to individual frames; see the note under Upstream 
[`scheme`](#upstream) if a plugin needs to inspect or rewrite them.             
                                                           |                    
                                  |
 | status           | False                                    | Auxiliary   | 
Enables the current Route. Set to `1` (enabled) by default.                     
                                                                                
                                                                                
                                               | `1` to enable, `0` to disable  
                      |
 
 Example configuration:
@@ -671,7 +671,7 @@ Service resource request address: 
/apisix/admin/services/{id}
 | name             | False    | Auxiliary   | Identifier for the Service.      
                                                                                
  | service-xxxx                                     |
 | desc             | False    | Auxiliary   | Description of usage scenarios.  
                                                                                
  | service xxxx                                     |
 | labels           | False    | Match Rules | Attributes of the Service 
specified as key-value pairs.                                                   
         | {"version":"v2","build":"16","env":"production"} |
-| enable_websocket | False    | Auxiliary   | Enables a websocket. Set to 
`false` by default.                                                             
       |                                                  |
+| enable_websocket | False    | Auxiliary   | Enables a websocket. Set to 
`false` by default. This is a plain protocol upgrade with no access to 
individual frames; see the note under Upstream [`scheme`](#upstream) if a 
plugin needs to inspect or rewrite them.  |                                     
             |
 | hosts            | False    | Match Rules | Matches with any one of the 
multiple `host`s specified in the form of a non-empty list.                     
       | ["foo.com", "*.bar.com"]                         |
 
 Example configuration:
@@ -1014,7 +1014,7 @@ In addition to the equalization algorithm selections, 
Upstream also supports pas
 | desc                        | False                                          
                  | Auxiliary                     | Description of usage 
scenarios.                                                                      
                                                                                
                                                                                
                                                                                
                     [...]
 | pass_host                   | False                                          
                  | Enumeration                   | Configures the `host` when 
the request is forwarded to the upstream. Can be one of `pass`, `node` or 
`rewrite`. Defaults to `pass` if not specified. `pass`- transparently passes 
the client's host to the Upstream. `node`- uses the host configured in the node 
of the Upstream. `rewrite`- Uses the value configured in `upstream_host`.       
                        [...]
 | upstream_host               | False                                          
                  | Auxiliary                     | Specifies the host of the 
Upstream request. This is only valid if the `pass_host` is set to `rewrite`.    
                                                                                
                                                                                
                                                                                
                [...]
-| scheme                      | False                                          
                  | Auxiliary                     | The scheme used when 
communicating with the Upstream. For an L7 proxy, this value can be one of 
`http`, `https`, `grpc`, `grpcs`. For an L4 proxy, this value could be one of 
`tcp`, `udp`, `tls`. Defaults to `http`.                                        
                                                                                
                            [...]
+| scheme                      | False                                          
                  | Auxiliary                     | The scheme used when 
communicating with the Upstream. For an L7 proxy, this value can be one of 
`http`, `https`, `grpc`, `grpcs`, `ws`, `wss`. For an L4 proxy, this value 
could be one of `tcp`, `udp`, `tls`. Defaults to `http`.                        
                                                                                
                               [...]
 | labels                      | False                                          
                  | Match Rules                   | Attributes of the Upstream 
specified as `key-value` pairs.                                                 
                                                                                
                                                                                
                                                                                
               [...]
 | tls.client_cert             | False, can't be used with `tls.client_cert_id` 
                  | HTTPS certificate             | Sets the client certificate 
while connecting to a TLS Upstream.                                             
                                                                                
                                                                                
                                                                                
              [...]
 | tls.client_key              | False, can't be used with `tls.client_cert_id` 
                  | HTTPS certificate private key | Sets the client private key 
while connecting to a TLS Upstream.                                             
                                                                                
                                                                                
                                                                                
              [...]
@@ -1046,6 +1046,11 @@ The following should be considered when setting the 
`hash_on` value:
 - When set to `consumer`, the `key` is optional and the key is set to the 
`consumer_name` captured from the authentication Plugin.
 - When set to `vars_combinations`, the `key` is required. The value of the key 
can be a combination of any of the [Nginx 
variables](http://nginx.org/en/docs/varindex.html) like 
`$request_uri$remote_addr`.
 
+APISIX supports proxying WebSocket connections in two different ways, and they 
don't combine:
+
+- Route or Service level [`enable_websocket`](#route) with an `http`/`https` 
Upstream `scheme`. This is a plain protocol upgrade: nginx's own `proxy_pass` 
forwards the raw TCP stream after the `101 Switching Protocols` handshake, and 
no plugin phase sees the individual WebSocket frames.
+- Upstream `scheme: ws` or `scheme: wss`. APISIX parses and proxies the 
WebSocket frames itself in both directions, which lets a plugin inspect or 
rewrite frames in flight through the `ws_handshake`, `ws_client_frame`, 
`ws_upstream_frame`, and `ws_close` phases. See the ["extra phase" section of 
the plugin development guide](./plugin-develop.md#extra-phase) for how to hook 
into them. `enable_websocket` is ignored on a Route or a Service whose Upstream 
uses this scheme, since the connecti [...]
+
 The features described below requires APISIX to be run on 
[APISIX-Runtime](./FAQ.md#how-do-i-build-the-apisix-runtime-environment):
 
 You can set the `scheme` to `tls`, which means "TLS over TCP".
diff --git a/docs/en/latest/plugin-develop.md b/docs/en/latest/plugin-develop.md
index cdf837cc75..e384ec18bd 100644
--- a/docs/en/latest/plugin-develop.md
+++ b/docs/en/latest/plugin-develop.md
@@ -217,6 +217,33 @@ function _M.delayed_body_filter(conf, ctx)
 end
 ```
 
+When a route's `upstream.scheme` is `ws` or `wss`, APISIX proxies WebSocket 
frames itself instead of letting nginx's `proxy_pass` transparently forward 
them, so it can also run a plugin's logic against each frame. The normal 
`rewrite`/`access`/`before_proxy` phases still run beforehand and `log` still 
runs afterward; only `header_filter`/`body_filter`/`delayed_body_filter` are 
skipped, since there's no separate response to filter. In their place, four 
WebSocket-specific phases fire for s [...]
+
+* `ws_handshake` - runs once, after `before_proxy`, before APISIX attempts to 
connect to the upstream.
+* `ws_client_frame` - runs once per frame received from the downstream client, 
before it is forwarded to the upstream.
+* `ws_upstream_frame` - runs once per frame received from the upstream, before 
it is forwarded to the downstream client.
+* `ws_close` - runs once, when the connection ends, before the normal `log` 
phase.
+
+`ws_client_frame` and `ws_upstream_frame` can read and rewrite the frame in 
flight through `core.websocket.client` and `core.websocket.upstream` 
respectively (`core.websocket.get_role("client")` and 
`core.websocket.get_role("upstream")` return the same two tables). 
`get_frame()` returns the current frame (`type`, `payload`, `last`, `code`); 
`set_frame_data(payload)` replaces the payload that actually gets forwarded:
+
+```lua
+function _M.ws_client_frame(conf, ctx)
+    local frame = core.websocket.client.get_frame()
+    if frame.type == "text" then
+        core.websocket.client.set_frame_data(frame.payload .. "-client")
+    end
+end
+
+function _M.ws_upstream_frame(conf, ctx)
+    local frame = core.websocket.upstream.get_frame()
+    if frame.type == "text" then
+        core.websocket.upstream.set_frame_data(frame.payload .. "-upstream")
+    end
+end
+```
+
+See 
[`example-plugin`](https://github.com/apache/apisix/blob/master/apisix/plugins/example-plugin.lua)
 for a complete reference implementation of all four phases.
+
 ### Implement the logic
 
 Write the logic of the plugin in the corresponding phase. There are two 
parameters `conf` and `ctx` in the phase method, take the `limit-conn` plugin 
configuration as an example.
diff --git a/docs/en/latest/terminology/plugin.md 
b/docs/en/latest/terminology/plugin.md
index 0aee6fc13b..3fe7cd6038 100644
--- a/docs/en/latest/terminology/plugin.md
+++ b/docs/en/latest/terminology/plugin.md
@@ -87,6 +87,8 @@ An installed plugin is first initialized. The configuration 
of the plugin is the
 
 When a request goes through APISIX, the plugin's corresponding methods are 
executed in one or more of the following phases : `rewrite`, `access`, 
`before_proxy`, `header_filter`, `body_filter`, and `log`. These phases are 
largely influenced by the [OpenResty 
directives](https://openresty-reference.readthedocs.io/en/latest/Directives/).
 
+A route whose `upstream.scheme` is `ws` or `wss` still runs 
`rewrite`/`access`/`before_proxy`/`log` normally, but replaces 
`header_filter`/`body_filter`/`delayed_body_filter` with four 
WebSocket-specific phases: `ws_handshake`, `ws_client_frame`, 
`ws_upstream_frame`, and `ws_close`. See the ["extra phase" section of the 
plugin development guide](../plugin-develop.md#extra-phase) for details.
+
 <br />
 <div style={{textAlign: 'center'}}>
 <img 
src="https://static.apiseven.com/uploads/2023/03/09/ZsH5C8Og_plugins-phases.png";
 alt="Routes Diagram" width="50%"/>
diff --git a/docs/zh/latest/admin-api.md b/docs/zh/latest/admin-api.md
index ec6aa9c8a7..b08318c76b 100644
--- a/docs/zh/latest/admin-api.md
+++ b/docs/zh/latest/admin-api.md
@@ -370,7 +370,7 @@ Route 也称之为路由,可以通过定义一些规则来匹配客户端的
 | filter_func      | 否                               | 匹配规则 | 
用户自定义的过滤函数。可以使用它来实现特殊场景的匹配要求实现。该函数默认接受一个名为 `vars` 的输入参数,可以用它来获取 NGINX 变量。       
                                                                                
                                                                                
                                        | function(vars) return 
vars["arg_name"] == "json" end |
 | labels           | 否                               | 匹配规则 | 标识附加属性的键值对。      
                                                                                
                                                                                
                                                                                
                      | {"version":"v2","build":"16","env":"production"}     |
 | timeout          | 否                               | 辅助     | 为 Route 设置 
Upstream 连接、发送消息和接收消息的超时时间(单位为秒)。该配置将会覆盖在 Upstream 中配置的 [timeout](#upstream) 
选项。                                                                             
                                                                                
                                  | {"connect": 3, "send": 3, "read": 3}        
      |
-| enable_websocket | 否                               | 辅助     | 当设置为 `true` 
时,启用 `websocket`(boolean), 默认值为 `false`。                                        
                                                                                
                                                                                
                                        |                                       
               |
+| enable_websocket | 否                               | 辅助     | 当设置为 `true` 
时,启用 `websocket`(boolean), 默认值为 
`false`。这只是纯粹的协议升级,插件无法访问单独的帧;如果插件需要读取或改写帧内容,请参考 Upstream [`scheme`](#upstream) 
下的说明。                                                                           
                                                                                
                       |                                                      |
 | status           | 否                               | 辅助     | 当设置为 `1` 
时,启用该路由,默认值为 `1`。                                                               
                                                                                
                                                                                
                                        | `1` 表示启用,`0` 表示禁用。                    
       |
 
 :::note 注意
@@ -679,7 +679,7 @@ Service 是某类 API 的抽象(也可以理解为一组 Route 的抽象)。
 | name             | 否                     | 辅助     | 服务名称。                    
                                  |                                             
|
 | desc             | 否                     | 辅助     | 服务描述。                    
                                      |                                         
         |
 | labels           | 否                     | 匹配规则 | 标识附加属性的键值对。                
                                 | 
{"version":"v2","build":"16","env":"production"} |
-| enable_websocket | 否                     | 辅助     | `websocket`(boolean) 
配置,默认值为 `false`。                       |                                        
          |
+| enable_websocket | 否                     | 辅助     | `websocket`(boolean) 
配置,默认值为 `false`。这只是纯粹的协议升级,插件无法访问单独的帧;如果插件需要读取或改写帧内容,请参考 Upstream 
[`scheme`](#upstream) 下的说明。 |                                                  |
 | hosts            | 否                     | 匹配规则 | 非空列表形态的 `host`,表示允许有多个不同 
`host`,匹配其中任意一个即可。| ["foo.com", "\*.bar.com"]                        |
 
 Service 对象 JSON 配置示例:
@@ -1022,7 +1022,7 @@ APISIX 的 Upstream 除了基本的负载均衡算法选择外,还支持对上
 | desc           | 否                                             | 辅助          
 | 上游服务描述、使用场景等。                                                                
                                                                                
                                                                                
                                                                                
                  |                                                  |
 | pass_host      | 否                                             | 枚举          
 | 请求发给上游时的 `host` 设置选型。 [`pass`,`node`,`rewrite`] 之一,默认是 `pass`。`pass`: 将客户端的 
host 透传给上游; `node`: 使用 `upstream` node 中配置的 `host`; `rewrite`: 使用配置项 
`upstream_host` 的值。                                                             
                                                                                
                           |                                                  |
 | upstream_host  | 否                                             | 辅助          
 | 指定上游请求的 host,只在 `pass_host` 配置为 `rewrite` 时有效。                               
                                                                                
                                                                                
                                                                                
                                   |                                            
      |
-| scheme         | 否                                             | 辅助          
 | 跟上游通信时使用的 scheme。对于 7 层代理,可选值为 [`http`, `https`, `grpc`, `grpcs`]。对于 4 
层代理,可选值为 [`tcp`, `udp`, `tls`]。默认值为 `http`,详细信息请参考下文。                           
                                                                                
                                                                                
                                                                |
+| scheme         | 否                                             | 辅助          
 | 跟上游通信时使用的 scheme。对于 7 层代理,可选值为 [`http`, `https`, `grpc`, `grpcs`, `ws`, 
`wss`]。对于 4 层代理,可选值为 [`tcp`, `udp`, `tls`]。默认值为 `http`,详细信息请参考下文。               
                                                                                
                                                                                
                                                                      |
 | labels         | 否                                             | 匹配规则       
| 标识附加属性的键值对。                                                                   
                                                                                
                                                                                
                                                                                
                     | {"version":"v2","build":"16","env":"production"} |
 | tls.client_cert    | 否,不能和 `tls.client_cert_id` 一起使用               | https 
证书           | 设置跟上游通信时的客户端证书,详细信息请参考下文。                                        
                                | |
 | tls.client_key        | 否,不能和 `tls.client_cert_id` 一起使用               | 
https 证书私钥           | 设置跟上游通信时的客户端私钥,详细信息请参考下文。                                
                                                                                
                                                                                
                                                                                
                              | |
@@ -1053,6 +1053,11 @@ APISIX 的 Upstream 除了基本的负载均衡算法选择外,还支持对上
 - 设为 `cookie` 时,`key` 为必传参数,其值为自定义的 cookie name,即 "cookie\_`key`"。请注意 cookie 
name 是**区分大小写字母**的。例如:`cookie_x_foo` 与 `cookie_X_Foo` 表示不同的 `cookie`。
 - 设为 `consumer` 时,`key` 不需要设置。此时哈希算法采用的 `key` 为认证通过的 `consumer_name`。
 
+APISIX 支持两种不同的方式来代理 WebSocket 连接,二者不能混用:
+
+- Route 或 Service 级别的 [`enable_websocket`](#route),配合 `http`/`https` 的 
Upstream `scheme`。这是纯粹的协议升级:`101 Switching Protocols` 握手完成后,由 nginx 自身的 
`proxy_pass` 转发原始 TCP 流,没有任何插件 phase 能看到单独的 WebSocket 帧。
+- Upstream `scheme: ws` 或 `scheme: wss`。APISIX 会自己双向解析并代理 WebSocket 帧,插件可以通过 
`ws_handshake`、`ws_client_frame`、`ws_upstream_frame`、`ws_close` 这几个 phase 
在帧的转发过程中读取或改写它们,具体用法参考[插件开发指南的 "extra phase" 
一节](./plugin-develop.md#extra-phase)。如果 Route 或 Service 所属的 Upstream 使用了这个 
scheme,`enable_websocket` 会被忽略,因为连接根本不会走到它所配置的那条 `proxy_pass` 路径。
+
 以下特性需要 APISIX 运行于 [APISIX-Runtime](./FAQ.md#如何构建-APISIX-Runtime-环境?):
 
 - `scheme` 可以设置成 `tls`,表示 `TLS over TCP`。
diff --git a/t/APISIX.pm b/t/APISIX.pm
index db1727424a..1ae608eeb9 100644
--- a/t/APISIX.pm
+++ b/t/APISIX.pm
@@ -259,6 +259,18 @@ my $disable_proxy_buffering_location = <<_EOC_;
         }
 _EOC_
 
+my $websocket_location = <<_EOC_;
+        location \@websocket_pass {
+            content_by_lua_block {
+                apisix.websocket_content_phase()
+            }
+
+            log_by_lua_block {
+                apisix.websocket_log_phase()
+            }
+        }
+_EOC_
+
 my $a6_ngx_directives = "";
 if ($version =~ m/\/apisix-nginx-module/) {
     $a6_ngx_directives = <<_EOC_;
@@ -777,6 +789,20 @@ _EOC_
         }
     }
 
+    # accepts a connection on any path but never writes a response, so a
+    # client waiting on it reliably times out instead of being refused or
+    # having to depend on an unroutable address actually hanging
+    server {
+        listen 1986;
+        server_tokens off;
+
+        location / {
+            content_by_lua_block {
+                ngx.sleep(30)
+            }
+        }
+    }
+
     $a6_ngx_directives
 
     server {
@@ -1010,6 +1036,7 @@ _EOC_
         $grpc_location
         $dubbo_location
         $disable_proxy_buffering_location
+        $websocket_location
 
         location = /proxy_mirror {
             internal;
diff --git a/t/lib/server.lua b/t/lib/server.lua
index c21975ff66..757c3c584a 100644
--- a/t/lib/server.lua
+++ b/t/lib/server.lua
@@ -389,6 +389,225 @@ end
 _M.websocket_handshake_route = _M.websocket_handshake
 
 
+-- Echoes every text/binary frame it receives back to the sender unchanged,
+-- so a fronting proxy's frame-level plugin hooks can be observed by diffing
+-- what the client sent against what it gets back. Used by the
+-- websocket-enhanced (ws/wss upstream scheme) test suite.
+function _M.websocket_echo()
+    local websocket = require "resty.websocket.server"
+    local wb, err = websocket:new()
+    if not wb then
+        ngx.log(ngx.ERR, "failed to new websocket: ", err)
+        return ngx.exit(400)
+    end
+
+    while true do
+        local data, typ, err = wb:recv_frame()
+        if not data then
+            if err and err:find("timeout", 1, true) then
+                goto continue
+            end
+            ngx.log(ngx.ERR, "failed to receive frame: ", err)
+            return
+        end
+
+        if typ == "close" then
+            wb:send_close(1000, "")
+            return
+        elseif typ == "ping" then
+            wb:send_pong(data)
+        elseif typ == "text" or typ == "binary" then
+            local send = typ == "text" and wb.send_text or wb.send_binary
+            local bytes, send_err = send(wb, data)
+            if not bytes then
+                ngx.log(ngx.ERR, "failed to echo frame: ", send_err)
+                return
+            end
+        end
+
+        ::continue::
+    end
+end
+
+
+-- Like websocket_echo, but the first thing it sends back is a text frame
+-- carrying the request URI (with query string) it was actually dispatched
+-- with, so a test can confirm what path/query a fronting proxy forwarded.
+-- Falls into the same echo loop afterwards.
+function _M.websocket_echo_uri()
+    local websocket = require "resty.websocket.server"
+    local wb, err = websocket:new()
+    if not wb then
+        ngx.log(ngx.ERR, "failed to new websocket: ", err)
+        return ngx.exit(400)
+    end
+
+    local bytes, send_err = wb:send_text(ngx.var.request_uri)
+    if not bytes then
+        ngx.log(ngx.ERR, "failed to send request_uri: ", send_err)
+        return
+    end
+
+    while true do
+        local data, typ, recv_err = wb:recv_frame()
+        if not data then
+            if recv_err and recv_err:find("timeout", 1, true) then
+                goto continue
+            end
+            ngx.log(ngx.ERR, "failed to receive frame: ", recv_err)
+            return
+        end
+
+        if typ == "close" then
+            wb:send_close(1000, "")
+            return
+        elseif typ == "ping" then
+            wb:send_pong(data)
+        elseif typ == "text" or typ == "binary" then
+            local send = typ == "text" and wb.send_text or wb.send_binary
+            local ok, echo_err = send(wb, data)
+            if not ok then
+                ngx.log(ngx.ERR, "failed to echo frame: ", echo_err)
+                return
+            end
+        end
+
+        ::continue::
+    end
+end
+
+
+-- Like websocket_echo, but the first thing it sends back is a text frame
+-- carrying the X-Real-IP/X-Forwarded-For it actually received as JSON, so a
+-- test can confirm what a fronting proxy set them to. Falls into the same
+-- echo loop afterwards.
+function _M.websocket_echo_headers()
+    local websocket = require "resty.websocket.server"
+    local wb, err = websocket:new()
+    if not wb then
+        ngx.log(ngx.ERR, "failed to new websocket: ", err)
+        return ngx.exit(400)
+    end
+
+    local headers = ngx.req.get_headers()
+    local bytes, send_err = wb:send_text(json_encode({
+        x_real_ip = headers["X-Real-IP"],
+        x_forwarded_for = headers["X-Forwarded-For"],
+    }))
+    if not bytes then
+        ngx.log(ngx.ERR, "failed to send headers: ", send_err)
+        return
+    end
+
+    while true do
+        local data, typ, recv_err = wb:recv_frame()
+        if not data then
+            if recv_err and recv_err:find("timeout", 1, true) then
+                goto continue
+            end
+            ngx.log(ngx.ERR, "failed to receive frame: ", recv_err)
+            return
+        end
+
+        if typ == "close" then
+            wb:send_close(1000, "")
+            return
+        elseif typ == "ping" then
+            wb:send_pong(data)
+        elseif typ == "text" or typ == "binary" then
+            local send = typ == "text" and wb.send_text or wb.send_binary
+            local ok, echo_err = send(wb, data)
+            if not ok then
+                ngx.log(ngx.ERR, "failed to echo frame: ", echo_err)
+                return
+            end
+        end
+
+        ::continue::
+    end
+end
+
+
+-- Sends one fragmented text message ("hello " + "world" as two continuation
+-- frames) right after the handshake, to verify a fronting proxy's
+-- aggregate_fragments option reassembles it into a single frame instead of
+-- forwarding (or invoking frame hooks on) two separate pieces.
+function _M.websocket_fragment()
+    local websocket = require "resty.websocket.server"
+    local wb, err = websocket:new()
+    if not wb then
+        ngx.log(ngx.ERR, "failed to new websocket: ", err)
+        return ngx.exit(400)
+    end
+
+    local ok, send_err = wb:send_frame(false, 0x1, "hello ")
+    if not ok then
+        ngx.log(ngx.ERR, "failed to send first fragment: ", send_err)
+        return
+    end
+
+    ok, send_err = wb:send_frame(true, 0x0, "world")
+    if not ok then
+        ngx.log(ngx.ERR, "failed to send final fragment: ", send_err)
+        return
+    end
+
+    -- drain until the client closes, so the connection doesn't just vanish
+    -- out from under the proxy mid-test
+    while true do
+        local data, typ, recv_err = wb:recv_frame()
+        if not data then
+            if recv_err and recv_err:find("timeout", 1, true) then
+                goto continue
+            end
+            return
+        end
+        if typ == "close" then
+            wb:send_close(1000, "")
+            return
+        end
+        ::continue::
+    end
+end
+
+
+-- Sends a close frame of its own right after the handshake, without waiting
+-- for the client to initiate one, to exercise an upstream-initiated close.
+function _M.websocket_close_upstream_initiated()
+    local websocket = require "resty.websocket.server"
+    local wb, err = websocket:new()
+    if not wb then
+        ngx.log(ngx.ERR, "failed to new websocket: ", err)
+        return ngx.exit(400)
+    end
+
+    wb:send_close(1000, "bye")
+end
+
+
+-- Completes the handshake, echoes exactly one frame, then vanishes without
+-- sending a close frame, to simulate an upstream that dies mid-session
+-- instead of closing cleanly.
+function _M.websocket_abrupt_close()
+    local websocket = require "resty.websocket.server"
+    local wb, err = websocket:new()
+    if not wb then
+        ngx.log(ngx.ERR, "failed to new websocket: ", err)
+        return ngx.exit(400)
+    end
+
+    local data, typ = wb:recv_frame()
+    if data and (typ == "text" or typ == "binary") then
+        local send = typ == "text" and wb.send_text or wb.send_binary
+        send(wb, data)
+    end
+
+    -- returning here, with the connection already hijacked by
+    -- resty.websocket.server, drops the raw TCP connection without a
+    -- close handshake
+end
+
+
 -- keep the session open until the peer goes away, so that the request stays in
 -- flight in the balancer the way a real WebSocket session does. An idle 
timeout is
 -- the normal state of such a session, not an error: keep waiting, and only 
give up
diff --git a/t/node/websocket-proxy.spec.mts b/t/node/websocket-proxy.spec.mts
new file mode 100644
index 0000000000..7b85d68699
--- /dev/null
+++ b/t/node/websocket-proxy.spec.mts
@@ -0,0 +1,479 @@
+/*
+ * 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.
+ */
+import { describe, expect, it, jest } from '@jest/globals';
+import axios from 'axios';
+import WS from 'ws';
+
+import { request as requestAdminAPI } from '../ts/admin_api';
+import { wait } from '../ts/utils';
+
+// Every test here does at least one real websocket handshake plus etcd sync
+// round trip, which the shared 5s Jest default leaves little room for on a
+// loaded machine; the handful of tests that need more than this still set
+// their own per-test timeout on top of it.
+jest.setTimeout(15000);
+
+const PROXY_BASE = 'ws://localhost:1984';
+// a loopback address nothing listens on, used as an unreachable upstream node
+const DEAD_NODE = '127.0.0.1:1';
+const DEAD_NODE_2 = '127.0.0.1:2';
+// accepts the connection but never responds, so it reliably exercises the
+// "timeout" (504) branch instead of "tcp failure" - unlike an unroutable
+// address, this doesn't depend on how a given network treats one
+const BLACKHOLE_NODE = '127.0.0.1:1986';
+const ECHO_NODE = '127.0.0.1:1980';
+
+let nextRouteId = 1;
+
+// Routes with a URI no other test in this file reuses can just be created
+// once and left behind (the whole test-nginx instance goes away at the end
+// of the run anyway).
+const createRoute = async (
+  path: string,
+  upstream: object,
+  plugins?: object,
+) => {
+  const id = `ws-proxy-${nextRouteId++}`;
+  const res = await requestAdminAPI(`/apisix/admin/routes/${id}`, 'PUT', {
+    uri: path,
+    upstream,
+    plugins,
+  });
+  expect(res.status).toBe(res.status < 300 ? res.status : 200);
+  // give etcd -> apisix config sync a moment to land before the first request
+  await wait(300);
+  return id;
+};
+
+// Most tests below share the /websocket_echo URI across very different
+// upstream/plugin configs. Deleting and recreating a route under the same
+// URI between tests leaves a window where the delete has been sent but
+// hasn't synced yet when the next test's create lands, and the two can race
+// - the client then gets whichever half-applied state the router held at
+// that instant. PUTting the same fixed route id instead is a plain
+// overwrite, so there's no delete in flight to race with.
+const ECHO_ROUTE_ID = 'ws-proxy-echo';
+const putEchoRoute = async (upstream: object, plugins?: object) => {
+  const res = await requestAdminAPI(`/apisix/admin/routes/${ECHO_ROUTE_ID}`, 
'PUT', {
+    uri: '/websocket_echo',
+    upstream,
+    plugins,
+  });
+  expect(res.status).toBe(res.status < 300 ? res.status : 200);
+  await wait(300);
+};
+
+// Opens a websocket connection, sends one text frame, resolves with the
+// first frame received in reply (or rejects on error/close-before-reply).
+const sendAndReceive = (path: string, payload: string) =>
+  new Promise<string>((resolve, reject) => {
+    const ws = new WebSocket(`${PROXY_BASE}${path}`);
+    ws.addEventListener('open', () => ws.send(payload));
+    ws.addEventListener('message', (ev) => {
+      resolve(ev.data as string);
+      ws.close();
+    });
+    ws.addEventListener('error', (ev) =>
+      reject(new Error((ev as unknown as { message?: string }).message ?? 
'websocket error')),
+    );
+  });
+
+// Resolves with the close event's code. onOpen fires right after connecting,
+// so it can send a frame or otherwise trigger whatever leads to the close.
+//
+// The WebSocket spec requires an abnormal closure to fire an error event
+// before its close event, not instead of it, so an expected-to-fail
+// connection (tolerateError: true) must not treat that error as a failure
+// and must instead keep waiting for the close event that follows it.
+const waitForClose = (
+  path: string,
+  onOpen?: (ws: WebSocket) => void,
+  tolerateError = false,
+) =>
+  new Promise<number>((resolve, reject) => {
+    const ws = new WebSocket(`${PROXY_BASE}${path}`);
+    ws.addEventListener('open', () => onOpen?.(ws));
+    ws.addEventListener('close', (ev) => resolve(ev.code));
+    ws.addEventListener('error', (ev) => {
+      if (!tolerateError) {
+        reject(new Error((ev as unknown as { message?: string }).message ?? 
'websocket error'));
+      }
+    });
+  });
+
+describe('websocket-proxy (ws/wss upstream scheme)', () => {
+  describe('frame-level plugin hooks', () => {
+    it('lets a plugin rewrite the client frame before it reaches the upstream, 
and the upstream frame before it reaches the client', async () => {
+      await putEchoRoute(
+        { type: 'roundrobin', scheme: 'ws', nodes: { [ECHO_NODE]: 1 } },
+        {
+          // example-plugin's ws_client_frame/ws_upstream_frame hooks append
+          // "-client"/"-upstream" to every text frame they see, in-flight.
+          'example-plugin': { i: 1 },
+        },
+      );
+
+      // the echo backend bounces whatever it received back unchanged, so the
+      // round trip proves both directions were actually rewritten in flight.
+      const reply = await sendAndReceive('/websocket_echo', 'hello');
+      expect(reply).toBe('hello-client-upstream');
+    });
+
+    it('does not touch binary frames (the hooks only rewrite text frames)', 
async () => {
+      await putEchoRoute(
+        { type: 'roundrobin', scheme: 'ws', nodes: { [ECHO_NODE]: 1 } },
+        { 'example-plugin': { i: 1 } },
+      );
+
+      const reply = await new Promise<ArrayBuffer>((resolve, reject) => {
+        const ws = new WebSocket(`${PROXY_BASE}/websocket_echo`);
+        ws.binaryType = 'arraybuffer';
+        ws.addEventListener('open', () => ws.send(new Uint8Array([1, 2, 3, 
4])));
+        ws.addEventListener('message', (ev) => {
+          resolve(ev.data as ArrayBuffer);
+          ws.close();
+        });
+        ws.addEventListener('error', (ev) =>
+          reject(new Error((ev as unknown as { message?: string }).message ?? 
'websocket error')),
+        );
+      });
+      expect(new Uint8Array(reply)).toEqual(new Uint8Array([1, 2, 3, 4]));
+    });
+  });
+
+  describe('fragmented frames', () => {
+    it('reassembles a fragmented message into one frame before invoking plugin 
hooks', async () => {
+      await createRoute('/websocket_fragment', {
+        type: 'roundrobin',
+        scheme: 'ws',
+        nodes: { [ECHO_NODE]: 1 },
+      }, {
+        'example-plugin': { i: 1 },
+      });
+
+      // websocket_fragment sends "hello " and "world" as two continuation
+      // frames of the same message; if aggregate_fragments works, the client
+      // (and the ws_upstream_frame hook in between) see exactly one frame
+      // with the joined payload, not two separate ones.
+      const messages: string[] = [];
+      await new Promise<void>((resolve, reject) => {
+        const ws = new WebSocket(`${PROXY_BASE}/websocket_fragment`);
+        ws.addEventListener('message', (ev) => {
+          messages.push(ev.data as string);
+          ws.close();
+        });
+        ws.addEventListener('close', () => resolve());
+        ws.addEventListener('error', (ev) =>
+          reject(new Error((ev as unknown as { message?: string }).message ?? 
'websocket error')),
+        );
+      });
+      expect(messages).toEqual(['hello world-upstream']);
+    });
+  });
+
+  describe('ping/pong', () => {
+    it('forwards a client ping to the upstream and the upstream pong back to 
the client', async () => {
+      await putEchoRoute({ type: 'roundrobin', scheme: 'ws', nodes: { 
[ECHO_NODE]: 1 } });
+
+      await new Promise<void>((resolve, reject) => {
+        const ws = new WS(`${PROXY_BASE}/websocket_echo`);
+        ws.on('open', () => ws.ping());
+        ws.on('pong', () => {
+          ws.terminate();
+          resolve();
+        });
+        ws.on('error', reject);
+      });
+    });
+  });
+
+  describe('close handshake', () => {
+    it('lets the client close cleanly and the upstream echoes the close code 
back', async () => {
+      await putEchoRoute({ type: 'roundrobin', scheme: 'ws', nodes: { 
[ECHO_NODE]: 1 } });
+
+      const code = await waitForClose('/websocket_echo', (ws) => 
ws.close(1000, 'bye'));
+      expect(code).toBe(1000);
+    });
+
+    it('forwards an upstream-initiated close to the client', async () => {
+      await createRoute('/websocket_close_upstream_initiated', {
+        type: 'roundrobin',
+        scheme: 'ws',
+        nodes: { [ECHO_NODE]: 1 },
+      });
+
+      const code = await waitForClose('/websocket_close_upstream_initiated');
+      expect(code).toBe(1000);
+    });
+  });
+
+  describe('abrupt disconnects', () => {
+    it('reports an abnormal closure (1006) to the client when the upstream 
vanishes mid-session', async () => {
+      await createRoute('/websocket_abrupt_close', {
+        type: 'roundrobin',
+        scheme: 'ws',
+        nodes: { [ECHO_NODE]: 1 },
+      });
+
+      const code = await waitForClose('/websocket_abrupt_close', (ws) => 
ws.send('hi'), true);
+      expect(code).toBe(1006);
+    });
+
+    it('cleans up the upstream side when the client vanishes without closing', 
async () => {
+      await putEchoRoute({ type: 'roundrobin', scheme: 'ws', nodes: { 
[ECHO_NODE]: 1 } });
+
+      const activeConnections = async () => {
+        const res = await 
axios.get('http://localhost:1984/apisix/nginx_status');
+        const match = /Active connections:\s*(\d+)/.exec(res.data as string);
+        return match ? Number(match[1]) : NaN;
+      };
+
+      const baseline = await activeConnections();
+
+      // open and forcibly kill a handful of connections without a close
+      // handshake (ws's .terminate() drops the TCP connection directly,
+      // which the standard WebSocket API has no equivalent for)
+      for (let i = 0; i < 10; i++) {
+        await new Promise<void>((resolve, reject) => {
+          const ws = new WS(`${PROXY_BASE}/websocket_echo`);
+          ws.on('open', () => {
+            ws.terminate();
+            resolve();
+          });
+          ws.on('error', reject);
+        });
+      }
+
+      // give the proxy's forwarder coroutines a moment to notice the dead
+      // sockets and tear themselves down
+      await wait(1000);
+
+      const after = await activeConnections();
+      // a leak would grow roughly linearly with the number of terminated
+      // connections (10 here); allow some slack for unrelated background
+      // activity in the shared test-nginx instance instead of an exact match
+      expect(after).toBeLessThan(baseline + 5);
+    }, 10000);
+  });
+
+  describe('upstream retry', () => {
+    it('retries the next node when the first one refuses the connection', 
async () => {
+      await putEchoRoute({
+        type: 'roundrobin',
+        scheme: 'ws',
+        retries: 3,
+        nodes: { [DEAD_NODE]: 100, [ECHO_NODE]: 1 },
+      });
+
+      const reply = await sendAndReceive('/websocket_echo', 'hello');
+      expect(reply).toBe('hello');
+    });
+
+    it('returns 502 once every node has been tried and failed', async () => {
+      await putEchoRoute({
+        type: 'roundrobin',
+        scheme: 'ws',
+        retries: 2,
+        nodes: { [DEAD_NODE]: 1, [DEAD_NODE_2]: 1 },
+      });
+
+      await expect(
+        axios.get('http://localhost:1984/websocket_echo', {
+          headers: {
+            Connection: 'Upgrade',
+            Upgrade: 'websocket',
+            'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
+            'Sec-WebSocket-Version': '13',
+          },
+        }),
+      ).rejects.toMatchObject({
+        response: { status: 502 },
+      });
+    });
+
+    it('retries past a connect timeout, not just a refused connection', async 
() => {
+      await putEchoRoute({
+        type: 'roundrobin',
+        scheme: 'ws',
+        retries: 2,
+        timeout: { connect: 1, send: 5, read: 5 },
+        nodes: { [BLACKHOLE_NODE]: 100, [ECHO_NODE]: 1 },
+      });
+
+      const start = Date.now();
+      const reply = await sendAndReceive('/websocket_echo', 'hello');
+      const elapsed = Date.now() - start;
+      expect(reply).toBe('hello');
+      // an instant refusal (tcp_failure) would resolve in a few ms; only a
+      // real connect timeout takes close to the configured 1s
+      expect(elapsed).toBeGreaterThanOrEqual(900);
+    }, 10000);
+
+    it('retries across more than one dead node before reaching a healthy one', 
async () => {
+      await putEchoRoute({
+        type: 'roundrobin',
+        scheme: 'ws',
+        retries: 3,
+        nodes: { [DEAD_NODE]: 100, [DEAD_NODE_2]: 100, [ECHO_NODE]: 1 },
+      });
+
+      const reply = await sendAndReceive('/websocket_echo', 'hello');
+      expect(reply).toBe('hello');
+    });
+
+    it('retries the same way for a least_conn upstream, not just roundrobin', 
async () => {
+      await putEchoRoute({
+        type: 'least_conn',
+        scheme: 'ws',
+        retries: 3,
+        nodes: { [DEAD_NODE]: 100, [ECHO_NODE]: 1 },
+      });
+
+      const reply = await sendAndReceive('/websocket_echo', 'hello');
+      expect(reply).toBe('hello');
+    });
+  });
+
+  describe('passive health check', () => {
+    it('marks a node unhealthy after enough failed connection attempts', async 
() => {
+      await putEchoRoute({
+        type: 'roundrobin',
+        scheme: 'ws',
+        retries: 1,
+        nodes: { [DEAD_NODE]: 1, [ECHO_NODE]: 1 },
+        checks: {
+          active: { type: 'tcp', http_path: '/', timeout: 1, healthy: { 
interval: 1 } },
+          passive: { unhealthy: { tcp_failures: 1 } },
+        },
+      });
+
+      // one connect attempt is enough to report a tcp failure for DEAD_NODE
+      await sendAndReceive('/websocket_echo', 'hello');
+
+      let unhealthyFound = false;
+      for (let i = 0; i < 10 && !unhealthyFound; i++) {
+        await wait(500);
+        const res = await 
requestAdminAPI(`/v1/healthcheck/routes/${ECHO_ROUTE_ID}`);
+        const { nodes } = res.data as { nodes: { ip: string; port: number; 
status: string }[] };
+        unhealthyFound = nodes.some((n) => n.port === 1 && n.status !== 
'healthy');
+      }
+
+      expect(unhealthyFound).toBe(true);
+    }, 15000);
+  });
+
+  describe('upstream URI forwarding', () => {
+    it("forwards the client's request URI, including the query string", async 
() => {
+      await createRoute('/websocket_echo_uri', {
+        type: 'roundrobin',
+        scheme: 'ws',
+        nodes: { [ECHO_NODE]: 1 },
+      });
+
+      const reply = await new Promise<string>((resolve, reject) => {
+        const ws = new WebSocket(`${PROXY_BASE}/websocket_echo_uri?foo=bar`);
+        ws.addEventListener('message', (ev) => {
+          resolve(ev.data as string);
+          ws.close();
+        });
+        ws.addEventListener('error', (ev) =>
+          reject(new Error((ev as unknown as { message?: string }).message ?? 
'websocket error')),
+        );
+      });
+      expect(reply).toBe('/websocket_echo_uri?foo=bar');
+    });
+
+    it("forwards the proxy-rewrite plugin's rewritten URI instead of the 
original one", async () => {
+      await createRoute(
+        '/websocket_proxy_rewrite_uri',
+        {
+          type: 'roundrobin',
+          scheme: 'ws',
+          nodes: { [ECHO_NODE]: 1 },
+        },
+        {
+          'proxy-rewrite': { uri: '/websocket_echo_uri' },
+        },
+      );
+
+      const reply = await new Promise<string>((resolve, reject) => {
+        const ws = new WebSocket(`${PROXY_BASE}/websocket_proxy_rewrite_uri`);
+        ws.addEventListener('message', (ev) => {
+          resolve(ev.data as string);
+          ws.close();
+        });
+        ws.addEventListener('error', (ev) =>
+          reject(new Error((ev as unknown as { message?: string }).message ?? 
'websocket error')),
+        );
+      });
+      expect(reply).toBe('/websocket_echo_uri');
+    });
+  });
+
+  describe('client address headers', () => {
+    it('overrides X-Real-IP and appends this hop to X-Forwarded-For, not what 
the client sent', async () => {
+      await createRoute('/websocket_echo_headers', {
+        type: 'roundrobin',
+        scheme: 'ws',
+        nodes: { [ECHO_NODE]: 1 },
+      });
+
+      // connect via the literal loopback address, not PROXY_BASE's
+      // "localhost", so $remote_addr is deterministically 127.0.0.1
+      const reply = await new Promise<string>((resolve, reject) => {
+        const ws = new WS('ws://127.0.0.1:1984/websocket_echo_headers', {
+          headers: { 'X-Real-IP': '1.2.3.4', 'X-Forwarded-For': '5.6.7.8' },
+        });
+        ws.on('message', (data) => {
+          resolve(data.toString());
+          ws.close();
+        });
+        ws.on('error', reject);
+      });
+
+      const seen = JSON.parse(reply);
+      expect(seen.x_real_ip).toBe('127.0.0.1');
+      expect(seen.x_forwarded_for).toBe('5.6.7.8, 127.0.0.1');
+    });
+  });
+
+  describe('concurrent connections', () => {
+    it("keeps two simultaneous connections' frame data isolated from each 
other", async () => {
+      await putEchoRoute(
+        { type: 'roundrobin', scheme: 'ws', nodes: { [ECHO_NODE]: 1 } },
+        { 'example-plugin': { i: 1 } },
+      );
+
+      const open = (payload: string) =>
+        new Promise<string>((resolve, reject) => {
+          const ws = new WebSocket(`${PROXY_BASE}/websocket_echo`);
+          ws.addEventListener('open', () => ws.send(payload));
+          ws.addEventListener('message', (ev) => {
+            resolve(ev.data as string);
+            ws.close();
+          });
+          ws.addEventListener('error', (ev) =>
+            reject(new Error((ev as unknown as { message?: string }).message 
?? 'websocket error')),
+          );
+        });
+
+      const [replyA, replyB] = await Promise.all([open('alpha'), 
open('beta')]);
+      expect(replyA).toBe('alpha-client-upstream');
+      expect(replyB).toBe('beta-client-upstream');
+    });
+  });
+});
diff --git a/t/node/websocket-proxy.t b/t/node/websocket-proxy.t
new file mode 100644
index 0000000000..73b8fe9251
--- /dev/null
+++ b/t/node/websocket-proxy.t
@@ -0,0 +1,35 @@
+#
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: test
+--- timeout: 60
+--- max_size: 2048000
+--- exec
+cd t && pnpm test node/websocket-proxy.spec.mts 2>&1
+--- no_error_log
+failed to execute the script with status
+--- response_body eval
+qr/PASS node\/websocket-proxy.spec.mts/
diff --git a/t/package.json b/t/package.json
index 7661c6eaba..948dd9cba0 100644
--- a/t/package.json
+++ b/t/package.json
@@ -12,6 +12,7 @@
     "@types/google-protobuf": "^3.15.12",
     "@types/jest": "29.5.14",
     "@types/node": "22.14.1",
+    "@types/ws": "^8.18.1",
     "axios": "^1.16.0",
     "docker-compose": "^1.2.0",
     "google-protobuf": "^3.21.4",
@@ -23,6 +24,7 @@
     "simple-git": "^3.27.0",
     "ts-jest": "29.3.2",
     "ts-node": "10.9.2",
+    "ws": "^8.21.3",
     "xhr2": "^0.2.1",
     "yaml": "^2.7.1"
   },
diff --git a/t/pnpm-lock.yaml b/t/pnpm-lock.yaml
index 46bedf29a1..6e8ca05578 100644
--- a/t/pnpm-lock.yaml
+++ b/t/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
       '@types/node':
         specifier: 22.14.1
         version: 22.14.1
+      '@types/ws':
+        specifier: ^8.18.1
+        version: 8.18.1
       axios:
         specifier: ^1.16.0
         version: 1.16.0
@@ -65,6 +68,9 @@ importers:
       ts-node:
         specifier: 10.9.2
         version: 10.9.2(@types/[email protected])([email protected])
+      ws:
+        specifier: ^8.21.3
+        version: 8.21.3
       xhr2:
         specifier: ^0.2.1
         version: 0.2.1
@@ -509,6 +515,9 @@ packages:
   '@types/[email protected]':
     resolution: {integrity: 
sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
 
+  '@types/[email protected]':
+    resolution: {integrity: 
sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+
   '@types/[email protected]':
     resolution: {integrity: 
sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
 
@@ -1764,6 +1773,18 @@ packages:
     resolution: {integrity: 
sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
     engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
 
+  [email protected]:
+    resolution: {integrity: 
sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: '>=5.0.2'
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
   [email protected]:
     resolution: {integrity: 
sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==}
     engines: {node: '>= 6'}
@@ -2402,6 +2423,10 @@ snapshots:
 
   '@types/[email protected]': {}
 
+  '@types/[email protected]':
+    dependencies:
+      '@types/node': 22.14.1
+
   '@types/[email protected]': {}
 
   '@types/[email protected]':
@@ -3792,6 +3817,8 @@ snapshots:
       imurmurhash: 0.1.4
       signal-exit: 3.0.7
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}

Reply via email to