From 6dad19f2c434f2f346089de2255f20fd01e5e6a5 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Mon, 14 Sep 2026 13:11:38 +0800 Subject: [PATCH 01/13] feat(websocket): add enhanced proxy and plugin hook --- apisix-master-0.rockspec | 1 + apisix/balancer.lua | 17 +++++- apisix/cli/ngx_tpl.lua | 10 ++++ apisix/core.lua | 1 + apisix/core/websocket.lua | 67 +++++++++++++++++++++ apisix/init.lua | 120 ++++++++++++++++++++++++++++++++++++++ apisix/schema_def.lua | 2 +- 7 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 apisix/core/websocket.lua diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec index 9a6a9fa9d98e..256ffab1a6fe 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 35f015da1b45..667e2c700ac0 100644 --- a/apisix/balancer.lua +++ b/apisix/balancer.lua @@ -246,7 +246,15 @@ 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, is a {state, code} pair shaped like the return +-- value of ngx.balancer's get_last_failure(): {state = "failed", code = 504} +-- for a timeout, {state = "failed", code = } for a TCP-level +-- failure, or {state = "ok", code = } for a passive HTTP status +-- report. It lets a caller outside of balancer_by_lua* (where +-- get_last_failure() cannot be called at all) report the outcome of its own +-- connection attempt instead. +local function pick_server(route, ctx, prev_failure) local up_conf = ctx.upstream_conf local nodes_count = #up_conf.nodes @@ -283,7 +291,12 @@ local function pick_server(route, ctx) end if checker then - local state, code = get_last_failure() + local state, code + if prev_failure then + state, code = prev_failure.state, prev_failure.code + else + state, code = get_last_failure() + end 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 diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index 42833de45835..6661eaf1d8e8 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -1179,6 +1179,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 fceb7d6a0d95..2c2bc2d23c90 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 000000000000..9186f90ebb45 --- /dev/null +++ b/apisix/core/websocket.lua @@ -0,0 +1,67 @@ +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 20d0caa1f1d5..5f42298408ab 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") @@ -674,6 +675,13 @@ function _M.handle_upstream(api_ctx, route, enable_websocket) return ngx.exec("@grpc_pass") end + if up_scheme == "wss" or up_scheme == "ws" then + 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") @@ -988,6 +996,118 @@ 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 ok, proxy, err = pcall(ws_proxy.new, { + aggregate_fragments = true, + 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 for fragmented frames; true if 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 or 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 up_conf = api_ctx.upstream_conf + local retries = up_conf.retries + if not retries or retries < 0 then + retries = #up_conf.nodes - 1 + end + + local server = api_ctx.picked_server + local ok, connect_err + for attempt = 0, retries do + local endpoint = string.format("%s://%s:%d", api_ctx.matched_upstream.scheme, + server.host, server.port) + ok, connect_err = proxy:connect(endpoint, { + host = server.upstream_host, + server_name = server.domain, + }) + if ok then + break + end + + ngx.log(ngx.ERR, "failed to connect to websocket upstream ", endpoint, + ": ", connect_err) + + if attempt >= retries then + break + end + + -- ngx.balancer's get_last_failure() only works inside balancer_by_lua*, + -- which this content_by_lua-driven cosocket connection never enters, so + -- report the outcome we already know from proxy:connect() ourselves. + local prev_failure + if connect_err and string.find(connect_err, "timeout", 1, true) then + prev_failure = {state = "failed", code = 504} + else + prev_failure = {state = "failed", code = 599} + 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/schema_def.lua b/apisix/schema_def.lua index 85dd40905299..38975e0f02a1 100644 --- a/apisix/schema_def.lua +++ b/apisix/schema_def.lua @@ -506,7 +506,7 @@ 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 L4 proxy, it can be one of tcp/tls/udp." .. From 96d4c6f653020835a7e99a9956fe52d62a664fff Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Mon, 14 Sep 2026 13:54:04 +0800 Subject: [PATCH 02/13] test --- apisix/init.lua | 15 +++- apisix/plugins/example-plugin.lua | 28 +++++++ t/APISIX.pm | 13 +++ t/lib/server.lua | 41 +++++++++ t/node/websocket-enhanced.spec.mts | 130 +++++++++++++++++++++++++++++ t/node/websocket-enhanced.t | 35 ++++++++ 6 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 t/node/websocket-enhanced.spec.mts create mode 100644 t/node/websocket-enhanced.t diff --git a/apisix/init.lua b/apisix/init.lua index 5f42298408ab..12fa7a3eceb0 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -1049,11 +1049,22 @@ function _M.websocket_content_phase() retries = #up_conf.nodes - 1 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 - local endpoint = string.format("%s://%s:%d", api_ctx.matched_upstream.scheme, - server.host, server.port) + local endpoint = string.format("%s://%s:%d%s", api_ctx.matched_upstream.scheme, + server.host, server.port, request_uri) ok, connect_err = proxy:connect(endpoint, { host = server.upstream_host, server_name = server.domain, diff --git a/apisix/plugins/example-plugin.lua b/apisix/plugins/example-plugin.lua index 767ccfae72a9..e76ce72e9e84 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/t/APISIX.pm b/t/APISIX.pm index 76dc94530a92..a54ecd4c1bb7 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_; @@ -1008,6 +1020,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 c21975ff6691..e8cdd615e40b 100644 --- a/t/lib/server.lua +++ b/t/lib/server.lua @@ -389,6 +389,47 @@ 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 + + -- 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-enhanced.spec.mts b/t/node/websocket-enhanced.spec.mts new file mode 100644 index 000000000000..f86fa3cc8e3a --- /dev/null +++ b/t/node/websocket-enhanced.spec.mts @@ -0,0 +1,130 @@ +/* + * 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 { afterAll, afterEach, beforeAll, describe, expect, it } from '@jest/globals'; +import axios from 'axios'; + +import { request as requestAdminAPI } from '../ts/admin_api'; +import { wait } from '../ts/utils'; + +const PROXY_BASE = 'ws://localhost:1984'; +const ROUTE_URI = '/websocket_echo'; +const DEAD_NODE = '127.0.0.1:1'; +const ECHO_NODE = '127.0.0.1:1980'; + +let nextRouteId = 1; + +// Every route this suite creates is torn down in afterEach, so a failed +// assertion in one test never leaves state for the next one to trip over. +const createdRouteIds: string[] = []; + +const createRoute = async (upstream: object, plugins?: object) => { + const id = `ws-enhanced-${nextRouteId++}`; + const res = await requestAdminAPI(`/apisix/admin/routes/${id}`, 'PUT', { + uri: ROUTE_URI, + upstream, + plugins, + }); + expect(res.status).toBe(res.status < 300 ? res.status : 200); + createdRouteIds.push(id); + // give etcd -> apisix config sync a moment to land before the first request + await wait(300); + return id; +}; + +afterEach(async () => { + while (createdRouteIds.length > 0) { + const id = createdRouteIds.pop(); + await requestAdminAPI(`/apisix/admin/routes/${id}`, 'DELETE'); + } +}); + +// 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 = (payload: string) => + new Promise((resolve, reject) => { + const ws = new WebSocket(`${PROXY_BASE}${ROUTE_URI}`); + 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')), + ); + }); + +describe('websocket-enhanced (ws/wss upstream scheme)', () => { + describe('frame-level plugin hooks', () => { + beforeAll(() => + createRoute( + { + 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 }, + }, + ), + ); + + it('lets a plugin rewrite the client frame before it reaches the upstream, and the upstream frame before it reaches the client', async () => { + // 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('hello'); + expect(reply).toBe('hello-client-upstream'); + }); + }); + + describe('upstream retry', () => { + it('retries the next node when the first one refuses the connection', async () => { + await createRoute({ + type: 'roundrobin', + scheme: 'ws', + retries: 3, + nodes: { [DEAD_NODE]: 100, [ECHO_NODE]: 1 }, + }); + + const reply = await sendAndReceive('hello'); + expect(reply).toBe('hello'); + }); + + it('returns 502 once every node has been tried and failed', async () => { + await createRoute({ + type: 'roundrobin', + scheme: 'ws', + retries: 2, + nodes: { [DEAD_NODE]: 1, '127.0.0.1:2': 1 }, + }); + + await expect( + axios.get(`http://localhost:1984${ROUTE_URI}`, { + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': '13', + }, + }), + ).rejects.toMatchObject({ + response: { status: 502 }, + }); + }); + }); +}); diff --git a/t/node/websocket-enhanced.t b/t/node/websocket-enhanced.t new file mode 100644 index 000000000000..9bf172e6a39e --- /dev/null +++ b/t/node/websocket-enhanced.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: 30 +--- max_size: 2048000 +--- exec +cd t && pnpm test node/websocket-enhanced.spec.mts 2>&1 +--- no_error_log +failed to execute the script with status +--- response_body eval +qr/PASS node\/websocket-enhanced.spec.mts/ From 0f486e4dc4367a130df4defa13f9834a29fffe69 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Mon, 14 Sep 2026 14:03:25 +0800 Subject: [PATCH 03/13] fix lint and license --- apisix/core/websocket.lua | 26 ++++++++++++++++++++++---- apisix/init.lua | 2 +- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apisix/core/websocket.lua b/apisix/core/websocket.lua index 9186f90ebb45..d70a070cc785 100644 --- a/apisix/core/websocket.lua +++ b/apisix/core/websocket.lua @@ -1,3 +1,21 @@ +-- +-- 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_ctx = ngx.ctx + local ROLE_CLIENT = "client" local ROLE_UPSTREAM = "upstream" local CTX_KEY_CLIENT = "websocket_client" @@ -10,28 +28,28 @@ local CTX_KEY_UPSTREAM = "websocket_upstream" local function wrap_stash_frame(key) return function(frame) - ngx.ctx[key] = frame + ngx_ctx[key] = frame end end local function wrap_get_frame(key) return function() - return ngx.ctx[key] + return ngx_ctx[key] end end local function wrap_set_frame_data(key) return function(data) - ngx.ctx[key].payload = data + ngx_ctx[key].payload = data end end local function wrap_set_status(key) return function(status) - ngx.ctx[key].code = status + ngx_ctx[key].code = status end end diff --git a/apisix/init.lua b/apisix/init.lua index 12fa7a3eceb0..a44b885094ec 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -1008,7 +1008,7 @@ function _M.websocket_content_phase() -- role: [string] "client" or "upstream" -- typ: [string] "text", "binary", "ping", "pong", "close" -- payload: [string|nil] payload if any - -- last: [boolean] fin flag for fragmented frames; true if aggregate_fragments is on + -- 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) From bf91c3a5360e8b7d3ec1d77862c5227298fbb537 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Mon, 14 Sep 2026 14:12:57 +0800 Subject: [PATCH 04/13] clean --- apisix/core/websocket.lua | 10 +++++----- ...cket-enhanced.spec.mts => websocket-proxy.spec.mts} | 0 t/node/{websocket-enhanced.t => websocket-proxy.t} | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) rename t/node/{websocket-enhanced.spec.mts => websocket-proxy.spec.mts} (100%) rename t/node/{websocket-enhanced.t => websocket-proxy.t} (91%) diff --git a/apisix/core/websocket.lua b/apisix/core/websocket.lua index d70a070cc785..5dbe0eaf49aa 100644 --- a/apisix/core/websocket.lua +++ b/apisix/core/websocket.lua @@ -14,7 +14,7 @@ -- See the License for the specific language governing permissions and -- limitations under the License. -- -local ngx_ctx = ngx.ctx +local ngx = ngx local ROLE_CLIENT = "client" local ROLE_UPSTREAM = "upstream" @@ -28,28 +28,28 @@ local CTX_KEY_UPSTREAM = "websocket_upstream" local function wrap_stash_frame(key) return function(frame) - ngx_ctx[key] = frame + ngx.ctx[key] = frame end end local function wrap_get_frame(key) return function() - return ngx_ctx[key] + return ngx.ctx[key] end end local function wrap_set_frame_data(key) return function(data) - ngx_ctx[key].payload = data + ngx.ctx[key].payload = data end end local function wrap_set_status(key) return function(status) - ngx_ctx[key].code = status + ngx.ctx[key].code = status end end diff --git a/t/node/websocket-enhanced.spec.mts b/t/node/websocket-proxy.spec.mts similarity index 100% rename from t/node/websocket-enhanced.spec.mts rename to t/node/websocket-proxy.spec.mts diff --git a/t/node/websocket-enhanced.t b/t/node/websocket-proxy.t similarity index 91% rename from t/node/websocket-enhanced.t rename to t/node/websocket-proxy.t index 9bf172e6a39e..b53b5ecb0304 100644 --- a/t/node/websocket-enhanced.t +++ b/t/node/websocket-proxy.t @@ -28,8 +28,8 @@ __DATA__ --- timeout: 30 --- max_size: 2048000 --- exec -cd t && pnpm test node/websocket-enhanced.spec.mts 2>&1 +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-enhanced.spec.mts/ +qr/PASS node\/websocket-proxy.spec.mts/ From 0700e2bdf38ca8a48a68845f0b4ff2ce9fd09aa0 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Mon, 14 Sep 2026 15:13:46 +0800 Subject: [PATCH 05/13] test: more cases --- t/lib/server.lua | 127 +++++++++++ t/node/websocket-proxy.spec.mts | 388 +++++++++++++++++++++++++++++--- t/node/websocket-proxy.t | 2 +- t/package.json | 2 + t/pnpm-lock.yaml | 27 +++ 5 files changed, 510 insertions(+), 36 deletions(-) diff --git a/t/lib/server.lua b/t/lib/server.lua index e8cdd615e40b..59bb29d6ca46 100644 --- a/t/lib/server.lua +++ b/t/lib/server.lua @@ -430,6 +430,133 @@ function _M.websocket_echo() 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 + + +-- 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 index f86fa3cc8e3a..89b72f4c187e 100644 --- a/t/node/websocket-proxy.spec.mts +++ b/t/node/websocket-proxy.spec.mts @@ -14,49 +14,75 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { afterAll, afterEach, beforeAll, describe, expect, it } from '@jest/globals'; +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'; -const ROUTE_URI = '/websocket_echo'; +// 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'; +// TEST-NET-1 (RFC 5737): guaranteed unroutable, so connections to it hang +// until a connect timeout fires instead of being refused immediately - +// unlike DEAD_NODE, this exercises the "timeout" (504) branch, not "tcp +// failure". +const BLACKHOLE_NODE = '192.0.2.1:1'; const ECHO_NODE = '127.0.0.1:1980'; let nextRouteId = 1; -// Every route this suite creates is torn down in afterEach, so a failed -// assertion in one test never leaves state for the next one to trip over. -const createdRouteIds: string[] = []; - -const createRoute = async (upstream: object, plugins?: object) => { - const id = `ws-enhanced-${nextRouteId++}`; +// 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: ROUTE_URI, + uri: path, upstream, plugins, }); expect(res.status).toBe(res.status < 300 ? res.status : 200); - createdRouteIds.push(id); // give etcd -> apisix config sync a moment to land before the first request await wait(300); return id; }; -afterEach(async () => { - while (createdRouteIds.length > 0) { - const id = createdRouteIds.pop(); - await requestAdminAPI(`/apisix/admin/routes/${id}`, 'DELETE'); - } -}); +// 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 = (payload: string) => +const sendAndReceive = (path: string, payload: string) => new Promise((resolve, reject) => { - const ws = new WebSocket(`${PROXY_BASE}${ROUTE_URI}`); + const ws = new WebSocket(`${PROXY_BASE}${path}`); ws.addEventListener('open', () => ws.send(payload)); ws.addEventListener('message', (ev) => { resolve(ev.data as string); @@ -67,54 +93,207 @@ const sendAndReceive = (payload: string) => ); }); -describe('websocket-enhanced (ws/wss upstream scheme)', () => { +// 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((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', () => { - beforeAll(() => - createRoute( - { - type: 'roundrobin', - scheme: 'ws', - nodes: { [ECHO_NODE]: 1 }, - }, + 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 }, }, - ), - ); + ); - it('lets a plugin rewrite the client frame before it reaches the upstream, and the upstream frame before it reaches the client', async () => { // 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('hello'); + 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((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((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((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((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 createRoute({ + await putEchoRoute({ type: 'roundrobin', scheme: 'ws', retries: 3, nodes: { [DEAD_NODE]: 100, [ECHO_NODE]: 1 }, }); - const reply = await sendAndReceive('hello'); + const reply = await sendAndReceive('/websocket_echo', 'hello'); expect(reply).toBe('hello'); }); it('returns 502 once every node has been tried and failed', async () => { - await createRoute({ + await putEchoRoute({ type: 'roundrobin', scheme: 'ws', retries: 2, - nodes: { [DEAD_NODE]: 1, '127.0.0.1:2': 1 }, + nodes: { [DEAD_NODE]: 1, [DEAD_NODE_2]: 1 }, }); await expect( - axios.get(`http://localhost:1984${ROUTE_URI}`, { + axios.get('http://localhost:1984/websocket_echo', { headers: { Connection: 'Upgrade', Upgrade: 'websocket', @@ -126,5 +305,144 @@ describe('websocket-enhanced (ws/wss upstream scheme)', () => { 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 reply = await sendAndReceive('/websocket_echo', 'hello'); + expect(reply).toBe('hello'); + }, 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((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((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('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((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 index b53b5ecb0304..73b8fe9251c6 100644 --- a/t/node/websocket-proxy.t +++ b/t/node/websocket-proxy.t @@ -25,7 +25,7 @@ run_tests(); __DATA__ === TEST 1: test ---- timeout: 30 +--- timeout: 60 --- max_size: 2048000 --- exec cd t && pnpm test node/websocket-proxy.spec.mts 2>&1 diff --git a/t/package.json b/t/package.json index 7661c6eabaca..948dd9cba015 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 46bedf29a19c..6e8ca05578d4 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/node@22.14.1)(typescript@5.8.3) + ws: + specifier: ^8.21.3 + version: 8.21.3 xhr2: specifier: ^0.2.1 version: 0.2.1 @@ -509,6 +515,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': 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} + ws@8.21.3: + 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 + xhr2@0.2.1: resolution: {integrity: sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==} engines: {node: '>= 6'} @@ -2402,6 +2423,10 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.14.1 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -3792,6 +3817,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + ws@8.21.3: {} + xhr2@0.2.1: {} y18n@5.0.8: {} From 33e83c58e35f6cb9c0ae3e4d93eef01689d692a4 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Mon, 14 Sep 2026 15:43:27 +0800 Subject: [PATCH 06/13] fix lint --- apisix/core/websocket.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/apisix/core/websocket.lua b/apisix/core/websocket.lua index 5dbe0eaf49aa..64aebf57549f 100644 --- a/apisix/core/websocket.lua +++ b/apisix/core/websocket.lua @@ -15,6 +15,7 @@ -- limitations under the License. -- local ngx = ngx +local tostring = tostring local ROLE_CLIENT = "client" local ROLE_UPSTREAM = "upstream" From bad95764c3c835fb4140eda0bbec9a9802d828fd Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Mon, 14 Sep 2026 16:15:18 +0800 Subject: [PATCH 07/13] try fix e2e --- apisix/init.lua | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apisix/init.lua b/apisix/init.lua index a44b885094ec..406b7d767f7f 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -1000,9 +1000,14 @@ end function _M.websocket_content_phase() ngx.ctx = fetch_ctx() local api_ctx = ngx.ctx.api_ctx + local up_conf = api_ctx.upstream_conf + local up_timeout = 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 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" @@ -1043,7 +1048,6 @@ function _M.websocket_content_phase() -- 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 up_conf = api_ctx.upstream_conf local retries = up_conf.retries if not retries or retries < 0 then retries = #up_conf.nodes - 1 @@ -1063,6 +1067,10 @@ function _M.websocket_content_phase() local server = api_ctx.picked_server local ok, connect_err for attempt = 0, retries do + if connect_timeout_ms then + proxy.client:set_timeout(connect_timeout_ms) + end + local endpoint = string.format("%s://%s:%d%s", api_ctx.matched_upstream.scheme, server.host, server.port, request_uri) ok, connect_err = proxy:connect(endpoint, { From aac770171e84057165830c0e00ee238715e1b7f1 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Mon, 14 Sep 2026 16:48:17 +0800 Subject: [PATCH 08/13] fix lint --- apisix/init.lua | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apisix/init.lua b/apisix/init.lua index 406b7d767f7f..c94d1fb5e736 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -63,6 +63,8 @@ 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 tonumber = tonumber local type = type local pairs = pairs @@ -1071,8 +1073,8 @@ function _M.websocket_content_phase() proxy.client:set_timeout(connect_timeout_ms) end - local endpoint = string.format("%s://%s:%d%s", api_ctx.matched_upstream.scheme, - server.host, server.port, request_uri) + 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 = server.upstream_host, server_name = server.domain, @@ -1092,7 +1094,7 @@ function _M.websocket_content_phase() -- which this content_by_lua-driven cosocket connection never enters, so -- report the outcome we already know from proxy:connect() ourselves. local prev_failure - if connect_err and string.find(connect_err, "timeout", 1, true) then + if connect_err and str_find(connect_err, "timeout", 1, true) then prev_failure = {state = "failed", code = 504} else prev_failure = {state = "failed", code = 599} From 41dd09b6899318473856b2921931438a735b11b3 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Mon, 14 Sep 2026 17:04:01 +0800 Subject: [PATCH 09/13] docs --- 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 ++++++++--- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md index abd9965d3703..e6e4ba3eb4f4 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. | {"version":"v2","build":"16","env":"production"} | | 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. | | @@ -1041,6 +1041,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 whose Upstream uses this scheme, since the connection never reaches the `proxy_pass` path it configures. + 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 cdf837cc7523..75a699a43a7c 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. This gives access to four more phases that only fire for such a route, in place of the usual `header_filter`/`body_filter`/`log`: + +* `ws_handshake` - runs once, at the same point `access` would run for a plain HTTP route, 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, in place of `log`. + +`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 0aee6fc13bbe..686b5dc53d34 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` triggers a different set of phases instead, one per WebSocket frame: `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. +
Routes Diagram diff --git a/docs/zh/latest/admin-api.md b/docs/zh/latest/admin-api.md index aae200b88f85..2c2ca63e4b9a 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 证书私钥 | 设置跟上游通信时的客户端私钥,详细信息请参考下文。 | | @@ -1048,6 +1048,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 所属的 Upstream 使用了这个 scheme,`enable_websocket` 会被忽略,因为连接根本不会走到它所配置的那条 `proxy_pass` 路径。 + 以下特性需要 APISIX 运行于 [APISIX-Runtime](./FAQ.md#如何构建-APISIX-Runtime-环境?): - `scheme` 可以设置成 `tls`,表示 `TLS over TCP`。 From fea6b7895640786bc4e8a102d32b2e94e84cbd28 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Tue, 15 Sep 2026 06:55:46 +0800 Subject: [PATCH 10/13] fix comments --- apisix/balancer.lua | 49 ++++++++++------ apisix/init.lua | 128 +++++++++++++++++++++++++++++++++++++----- apisix/schema_def.lua | 2 +- 3 files changed, 145 insertions(+), 34 deletions(-) diff --git a/apisix/balancer.lua b/apisix/balancer.lua index 667e2c700ac0..de7d07430c23 100644 --- a/apisix/balancer.lua +++ b/apisix/balancer.lua @@ -243,17 +243,29 @@ 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 -- --- prev_failure, when given, is a {state, code} pair shaped like the return --- value of ngx.balancer's get_last_failure(): {state = "failed", code = 504} --- for a timeout, {state = "failed", code = } for a TCP-level --- failure, or {state = "ok", code = } for a passive HTTP status --- report. It lets a caller outside of balancer_by_lua* (where --- get_last_failure() cannot be called at all) report the outcome of its own --- connection attempt instead. +-- 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 @@ -297,17 +309,7 @@ local function pick_server(route, ctx, prev_failure) else state, code = get_last_failure() end - 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 + report_failure(ctx, checker, up_conf, state, code) end end @@ -384,6 +386,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/init.lua b/apisix/init.lua index c94d1fb5e736..9fa600b52589 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -65,6 +65,7 @@ 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 @@ -307,6 +308,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 @@ -319,12 +337,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 @@ -333,6 +346,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() @@ -1006,6 +1059,41 @@ function _M.websocket_content_phase() local up_timeout = 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, @@ -1076,8 +1164,14 @@ function _M.websocket_content_phase() 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 = server.upstream_host, + 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 @@ -1086,20 +1180,24 @@ function _M.websocket_content_phase() ngx.log(ngx.ERR, "failed to connect to websocket upstream ", endpoint, ": ", connect_err) - if attempt >= retries then - break - end - - -- ngx.balancer's get_last_failure() only works inside balancer_by_lua*, - -- which this content_by_lua-driven cosocket connection never enters, so - -- report the outcome we already know from proxy:connect() ourselves. + -- 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 - if connect_err and str_find(connect_err, "timeout", 1, true) then + 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 diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua index 38975e0f02a1..b35396941f46 100644 --- a/apisix/schema_def.lua +++ b/apisix/schema_def.lua @@ -508,7 +508,7 @@ local upstream_schema = { enum = {"grpc", "grpcs", "http", "https", "tcp", "tls", "udp", "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." }, From d6aa9dd5c9e9f7f96f077d75a99a3d07f7860fa4 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Tue, 15 Sep 2026 07:13:40 +0800 Subject: [PATCH 11/13] fix comments --- apisix/init.lua | 16 +++++++++++++++- apisix/upstream.lua | 2 ++ docs/en/latest/plugin-develop.md | 6 +++--- docs/en/latest/terminology/plugin.md | 2 +- t/APISIX.pm | 14 ++++++++++++++ t/node/websocket-proxy.spec.mts | 14 +++++++++----- 6 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apisix/init.lua b/apisix/init.lua index 9fa600b52589..061a05fbe50b 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -1056,7 +1056,10 @@ function _M.websocket_content_phase() ngx.ctx = fetch_ctx() local api_ctx = ngx.ctx.api_ctx local up_conf = api_ctx.upstream_conf - local up_timeout = up_conf.timeout + -- 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 @@ -1143,6 +1146,11 @@ function _M.websocket_content_phase() 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 @@ -1157,6 +1165,12 @@ function _M.websocket_content_phase() 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 diff --git a/apisix/upstream.lua b/apisix/upstream.lua index 99e1e857d37e..760e43cfe52b 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/plugin-develop.md b/docs/en/latest/plugin-develop.md index 75a699a43a7c..61f953ea4076 100644 --- a/docs/en/latest/plugin-develop.md +++ b/docs/en/latest/plugin-develop.md @@ -217,12 +217,12 @@ 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. This gives access to four more phases that only fire for such a route, in place of the usual `header_filter`/`body_filter`/`log`: +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` are skipped, since there's no separate response to filter. In their place, four WebSocket-specific phases fire for such a route: -* `ws_handshake` - runs once, at the same point `access` would run for a plain HTTP route, before APISIX attempts to connect to the upstream. +* `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, in place of `log`. +* `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: diff --git a/docs/en/latest/terminology/plugin.md b/docs/en/latest/terminology/plugin.md index 686b5dc53d34..7f2fbcae880d 100644 --- a/docs/en/latest/terminology/plugin.md +++ b/docs/en/latest/terminology/plugin.md @@ -87,7 +87,7 @@ 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` triggers a different set of phases instead, one per WebSocket frame: `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. +A route whose `upstream.scheme` is `ws` or `wss` still runs `rewrite`/`access`/`before_proxy`/`log` normally, but replaces `header_filter`/`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.
diff --git a/t/APISIX.pm b/t/APISIX.pm index a54ecd4c1bb7..07ce0d5a8fa6 100644 --- a/t/APISIX.pm +++ b/t/APISIX.pm @@ -787,6 +787,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 { diff --git a/t/node/websocket-proxy.spec.mts b/t/node/websocket-proxy.spec.mts index 89b72f4c187e..f890991c8ef0 100644 --- a/t/node/websocket-proxy.spec.mts +++ b/t/node/websocket-proxy.spec.mts @@ -31,11 +31,10 @@ 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'; -// TEST-NET-1 (RFC 5737): guaranteed unroutable, so connections to it hang -// until a connect timeout fires instead of being refused immediately - -// unlike DEAD_NODE, this exercises the "timeout" (504) branch, not "tcp -// failure". -const BLACKHOLE_NODE = '192.0.2.1:1'; +// 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; @@ -315,8 +314,13 @@ describe('websocket-proxy (ws/wss upstream scheme)', () => { 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 () => { From 25d96281cb9d40f2fed59f301e2b7726b084ca28 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Thu, 17 Sep 2026 20:26:58 +0800 Subject: [PATCH 12/13] fix comment --- apisix/init.lua | 11 +++++++ t/lib/server.lua | 51 +++++++++++++++++++++++++++++++++ t/node/websocket-proxy.spec.mts | 27 +++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/apisix/init.lua b/apisix/init.lua index 061a05fbe50b..1790f9d96add 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -731,6 +731,17 @@ function _M.handle_upstream(api_ctx, route, enable_websocket) 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() diff --git a/t/lib/server.lua b/t/lib/server.lua index 59bb29d6ca46..757c3c584acb 100644 --- a/t/lib/server.lua +++ b/t/lib/server.lua @@ -477,6 +477,57 @@ function _M.websocket_echo_uri() 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 diff --git a/t/node/websocket-proxy.spec.mts b/t/node/websocket-proxy.spec.mts index f890991c8ef0..7b85d68699e2 100644 --- a/t/node/websocket-proxy.spec.mts +++ b/t/node/websocket-proxy.spec.mts @@ -424,6 +424,33 @@ describe('websocket-proxy (ws/wss upstream scheme)', () => { }); }); + 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((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( From cc84fd8fc000d9e2ad7364014bb90aaba5735985 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Fri, 18 Sep 2026 15:32:58 +0800 Subject: [PATCH 13/13] fix comments --- apisix/init.lua | 6 +++++- docs/en/latest/admin-api.md | 2 +- docs/en/latest/plugin-develop.md | 2 +- docs/en/latest/terminology/plugin.md | 2 +- docs/zh/latest/admin-api.md | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apisix/init.lua b/apisix/init.lua index 1790f9d96add..7881b0f74a98 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -1144,7 +1144,11 @@ function _M.websocket_content_phase() return new_frame.payload, new_frame.code end }) - if not ok or not proxy then + 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 diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md index e6e4ba3eb4f4..3cfac2ea7f37 100644 --- a/docs/en/latest/admin-api.md +++ b/docs/en/latest/admin-api.md @@ -1044,7 +1044,7 @@ The following should be considered when setting the `hash_on` value: 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 whose Upstream uses this scheme, since the connection never reaches the `proxy_pass` path it configures. +- 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 connection never reaches the `proxy_pass` path it configures. The features described below requires APISIX to be run on [APISIX-Runtime](./FAQ.md#how-do-i-build-the-apisix-runtime-environment): diff --git a/docs/en/latest/plugin-develop.md b/docs/en/latest/plugin-develop.md index 61f953ea4076..e384ec18bd19 100644 --- a/docs/en/latest/plugin-develop.md +++ b/docs/en/latest/plugin-develop.md @@ -217,7 +217,7 @@ 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` are skipped, since there's no separate response to filter. In their place, four WebSocket-specific phases fire for such a route: +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 such a route: * `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. diff --git a/docs/en/latest/terminology/plugin.md b/docs/en/latest/terminology/plugin.md index 7f2fbcae880d..3fe7cd603827 100644 --- a/docs/en/latest/terminology/plugin.md +++ b/docs/en/latest/terminology/plugin.md @@ -87,7 +87,7 @@ 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` 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. +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.
diff --git a/docs/zh/latest/admin-api.md b/docs/zh/latest/admin-api.md index 2c2ca63e4b9a..510f50c136b4 100644 --- a/docs/zh/latest/admin-api.md +++ b/docs/zh/latest/admin-api.md @@ -1051,7 +1051,7 @@ APISIX 的 Upstream 除了基本的负载均衡算法选择外,还支持对上 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 所属的 Upstream 使用了这个 scheme,`enable_websocket` 会被忽略,因为连接根本不会走到它所配置的那条 `proxy_pass` 路径。 +- 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-环境?):