All Products
Search
Document Center

API Gateway:Develop gateway plug-ins using Go

Last Updated:Aug 26, 2026

Build custom Go plug-ins to extend AI gateway capabilities, covering plug-in structure, HTTP processing hooks, and local debugging with Docker Compose.

Important

Higress has migrated from TinyGo 0.29 + Go 1.20 to Go 1.24, which natively supports Wasm compilation.

To migrate from TinyGo to Go 1.24: update dependencies in go.mod and move plug-in initialization from main to init.

When adapting TinyGo-based plug-ins:

1. Replace type.ActionPause with types.HeaderStopAllIterationAndWatermark during header processing. See the "Call an external service in a plug-in" section for an example.

2. Replace the go-re2 library with the standard Go regexp package.

Prerequisites

Install Go 1.24 or later.

Go

Install Go 1.24 or later from the official guide.

Note

Plug-ins compiled with Go 1.24 require AI gateway version 2.1.5 or later. Develop a WASM plug-in by using Go covers earlier gateway versions.

Windows

  • Download the installation file.

  • Run the installer. Go installs to Program Files or Program Files (x86) by default.

  • Press Win+R, enter cmd, and click OK. Run go version to verify the installation.

macOS

  • Download the installation file.

  • Run the installer. Go installs to /usr/local/go by default.

  • Run go version in a terminal to verify the installation.

Linux

  • Download the installation file.

  • Run these commands to install Go.

    • Install Go.

      rm -rf /usr/local/go && tar -C /usr/local -xzf go1.24.4.linux-amd64.tar.gz
    • Configure the environment variable.

      export PATH=$PATH:/usr/local/go/bin
    • Run go version to verify the installation.

Write the plugin

Initialize the project

  1. Create a project directory and initialize a Go module:

       mkdir wasm-demo-go && cd wasm-demo-go
       go mod init wasm-demo-go
  2. Download the plugin SDK dependencies:

       go get github.com/higress-group/proxy-wasm-go-sdk@go-1.24
       go get github.com/higress-group/wasm-go@main
       go get github.com/tidwall/gjson

    If you are in Chinese mainland, set a proxy first:

       go env -w GOPROXY=https://proxy.golang.com.cn,direct

Write the main.go file

The following example adds a hello: world request header to every incoming request. When mockEnable is true in the plugin configuration, the plugin returns hello world directly instead of forwarding to the backend.

package main

import (
  "github.com/higress-group/wasm-go/pkg/wrapper"
  logs "github.com/higress-group/wasm-go/pkg/log"
  "github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
  "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
  "github.com/tidwall/gjson"
)

func main() {}

func init() {
  wrapper.SetCtx(
    // Plugin name
    "my-plugin",
    // Custom configuration parser
     wrapper.ParseConfigBy(parseConfig),
    // Hook into the request header processing phase
    wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
  )
}

// Custom plugin configuration
type MyConfig struct {
  mockEnable bool
}

// Parse the JSON configuration into the config struct.
// The gateway console converts YAML to JSON automatically.
func parseConfig(json gjson.Result, config *MyConfig, log logs.Log) error {
  config.mockEnable = json.Get("mockEnable").Bool()
  return nil
}

func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
  proxywasm.AddHttpRequestHeader("hello", "world")
  if config.mockEnable {
    proxywasm.SendHttpResponse(200, nil, []byte("hello world"), -1)
  }
  return types.HeaderContinue
}

Key points:

  • The main() function must be empty. Place all initialization logic in init().

  • wrapper.SetCtx registers the plugin name, configuration parser, and processing hooks.

  • Return types.HeaderContinue (equivalent to types.ActionContinue) to pass the request to the next filter.

SDK reference

Utility methods

The plugin SDK provides the following proxywasm methods for request and response manipulation:

Request header processing (effective during the request header phase)

Method

Purpose

GetHttpRequestHeaders

Get all request headers

ReplaceHttpRequestHeaders

Replace all request headers

GetHttpRequestHeader

Get a specific request header

RemoveHttpRequestHeader

Remove a specific request header

ReplaceHttpRequestHeader

Replace a specific request header

AddHttpRequestHeader

Add a request header

Request body processing (effective during the request body phase)

Method

Purpose

GetHttpRequestBody

Get the request body

AppendHttpRequestBody

Append data to the end of the request body

PrependHttpRequestBody

Prepend data to the beginning of the request body

ReplaceHttpRequestBody

Replace the entire request body

Response header processing (effective during the response header phase)

Method

Purpose

GetHttpResponseHeaders

Get all response headers from the backend

ReplaceHttpResponseHeaders

Replace all response headers

GetHttpResponseHeader

Get a specific response header

RemoveHttpResponseHeader

Remove a specific response header

ReplaceHttpResponseHeader

Replace a specific response header

AddHttpResponseHeader

Add a response header

Response body processing (effective during the response body phase)

Method

Purpose

GetHttpResponseBody

Get the response body

AppendHttpResponseBody

Append data to the end of the response body

PrependHttpResponseBody

Prepend data to the beginning of the response body

ReplaceHttpResponseBody

Replace the entire response body

HTTP calls and flow control

Method

Purpose

DispatchHttpCall

Send an HTTP request to an external service

GetHttpCallResponseHeaders

Get response headers from a DispatchHttpCall request

GetHttpCallResponseBody

Get the response body from a DispatchHttpCall request

GetHttpCallResponseTrailers

Get response trailers from a DispatchHttpCall request

SendHttpResponse

Return an HTTP response directly to the client

ResumeHttpRequest

Resume a paused request processing flow

ResumeHttpResponse

Resume a paused response processing flow

Important

Do not call ResumeHttpRequest or ResumeHttpResponse when processing is not paused. After SendHttpResponse is called, any paused request or response resumes automatically. Calling ResumeHttpRequest or ResumeHttpResponse again causes undefined behavior.

Header status codes

Each processing hook returns a header status code that controls the request flow. Choose the appropriate status based on whether your plugin needs to pause, buffer, or stream data:

Status

Behavior

HeaderContinue

The current filter is done. Pass the request to the next filter. Equivalent to types.ActionContinue.

HeaderStopIteration

Hold the header but continue reading body data. Use this to modify request headers during the body processing phase. A body is required -- if no body exists, the request blocks indefinitely. Check with HasRequestBody().

HeaderContinueAndEndStream

Pass the header to the next filter with end_stream = false, allowing the current filter to append more body data.

HeaderStopAllIterationAndBuffer

Stop all iteration and buffer headers, body, and trailers. If the buffer exceeds the limit, the gateway returns 413 during the request phase or 500 during the response phase. Resume with proxywasm.ResumeHttpRequest(), proxywasm.ResumeHttpResponse(), or proxywasm.SendHttpResponseWithDetail().

HeaderStopAllIterationAndWatermark

Same as HeaderStopAllIterationAndBuffer, but triggers connection-level throttling when the buffer exceeds the limit instead of returning an error. Equivalent to types.ActionPause in ABI 0.2.1.

Note

For real-world examples of HeaderStopIteration and HeaderStopAllIterationAndWatermark, see the Higress ai-transformer plugin and ai-quota plugin.

Compile the Wasm file

Compile the plugin into a Wasm binary:

go mod tidy
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm ./

This produces a main.wasm file. Use this file for Local debugging or upload it through the cloud-native gateway marketplace to deploy a custom plugin.

To deploy through Higress using the WasmPlugin CustomResourceDefinition (CRD) or the console UI, package the Wasm file into an OCI or Docker image. For details, see Custom plugins.

Local debugging

Prerequisites

Install Docker.

Set up the test environment

Make sure main.wasm exists in your project directory, then create the following two files.

docker-compose.yaml

version: '3.7'
services:
  envoy:
    image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.1.5
    entrypoint: /usr/local/bin/envoy
    # Enable debug-level logging for Wasm. Use info level in production.
    command: -c /etc/envoy/envoy.yaml --component-log-level wasm:debug
    depends_on:
    - httpbin
    networks:
    - wasmtest
    ports:
    - "10000:10000"
    volumes:
    - ./envoy.yaml:/etc/envoy/envoy.yaml
    - ./main.wasm:/etc/envoy/main.wasm

  httpbin:
    image: kennethreitz/httpbin:latest
    networks:
    - wasmtest
    ports:
    - "12345:80"

networks:
  wasmtest: {}

envoy.yaml

admin:
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 9901
static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address:
        protocol: TCP
        address: 0.0.0.0
        port_value: 10000
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          scheme_header_transformation:
            scheme_to_overwrite: https
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: httpbin
          http_filters:
          - name: wasmdemo
            typed_config:
              "@type": type.googleapis.com/udpa.type.v1.TypedStruct
              type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
              value:
                config:
                  name: wasmdemo
                  vm_config:
                    runtime: envoy.wasm.runtime.v8
                    code:
                      local:
                        filename: /etc/envoy/main.wasm
                  configuration:
                    "@type": "type.googleapis.com/google.protobuf.StringValue"
                    value: |
                      {
                        "mockEnable": false
                      }
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
  - name: httpbin
    connect_timeout: 30s
    type: LOGICAL_DNS
    # Comment out the following line to test on v6 networks
    dns_lookup_family: V4_ONLY
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: httpbin
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: httpbin
                port_value: 80

Start the environment:

docker compose up

Verify

Test the added header. Send a request through the gateway (port 10000) and confirm that the Hello: world header appears:

curl http://127.0.0.1:10000/get

Expected response:

{
  "args": {},
  "headers": {
    "Accept": "*/*",
    "Hello": "world",
    "Host": "127.0.0.1:10000",
    "Original-Host": "127.0.0.1:10000",
    "Req-Start-Time": "1681269273896",
    "User-Agent": "curl/7.79.1",
    "X-Envoy-Expected-Rq-Timeout-Ms": "15000"
  },
  "origin": "172.18.0.3",
  "url": "https://127.0.0.1:10000/get"
}

The Hello: world header confirms the plugin is active.

For comparison, a direct request to httpbin (port 12345) does not include this header:

curl http://127.0.0.1:12345/get

Test a configuration change. Edit envoy.yaml and set mockEnable to true:

configuration:
    "@type": "type.googleapis.com/google.protobuf.StringValue"
    value: |
      {
        "mockEnable": true
      }

Restart the environment and send the same request:

curl http://127.0.0.1:10000/get

Expected response:

hello world

The mock response confirms that configuration changes take effect correctly.

More examples

Plugin without configuration

For a plugin that requires no configuration, define an empty config struct and omit ParseConfigBy:

package main

import (
  "github.com/higress-group/wasm-go/pkg/wrapper"
  logs "github.com/higress-group/wasm-go/pkg/log"
  "github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
  "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
)

func main() {}

func init() {
  wrapper.SetCtx(
    "hello-world",
    wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
  )
}

type MyConfig struct {}

func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
  proxywasm.SendHttpResponse(200, nil, []byte("hello world"), -1)
  return types.HeaderContinue
}

Call an external HTTP service

Plugins support HTTP calls to Nacos services, Kubernetes services, and fixed-address or DNS services configured in the gateway console. The standard net/http library is unavailable in the Wasm runtime. Use the SDK's encapsulated HTTP client instead.

The following example parses the service configuration during startup, then calls the service during request processing. It extracts a token from the response headers and injects it into the original request.

package main

import (
  "errors"
  "net/http"
  "strings"
  "github.com/higress-group/wasm-go/pkg/wrapper"
  logs "github.com/higress-group/wasm-go/pkg/log"
  "github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
  "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
  "github.com/tidwall/gjson"
)

func main() {}

func init() {
  wrapper.SetCtx(
    "http-call",
    wrapper.ParseConfigBy(parseConfig),
    wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
  )
}

type MyConfig struct {
  // HTTP client for external calls
  client      wrapper.HttpClient
  // Path to request on the external service
  requestPath string
  // Response header key to extract and inject into the original request
  tokenHeader string
}

func parseConfig(json gjson.Result, config *MyConfig, log logs.Log) error {
  config.tokenHeader = json.Get("tokenHeader").String()
  if config.tokenHeader == "" {
    return errors.New("missing tokenHeader in config")
  }
  config.requestPath = json.Get("requestPath").String()
  if config.requestPath == "" {
    return errors.New("missing requestPath in config")
  }
  // Full FQDN with service type suffix.
  // Examples: my-svc.dns, my-svc.static,
  //   service-provider.DEFAULT-GROUP.public.nacos,
  //   httpbin.my-ns.svc.cluster.local
  serviceName := json.Get("serviceName").String()
  servicePort := json.Get("servicePort").Int()
  if servicePort == 0 {
    if strings.HasSuffix(serviceName, ".static") {
      // Default port for static IP services
      servicePort = 80
    }
  }
  config.client = wrapper.NewClusterClient(wrapper.FQDNCluster{
    FQDN: serviceName,
    Port: servicePort,
        })
}

func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
  // Send an async HTTP GET request. Default timeout is 500 ms.
  err := config.client.Get(config.requestPath, nil,
           // Callback runs when the response arrives
           func(statusCode int, responseHeaders http.Header, responseBody []byte) {
             if statusCode != http.StatusOK {
               log.Errorf("http call failed, status: %d", statusCode)
               proxywasm.SendHttpResponse(http.StatusInternalServerError, nil,
                 []byte("http call failed"), -1)
               return
             }
             log.Infof("get status: %d, response body: %s", statusCode, responseBody)
             // Extract the token from the response and add it to the original request
             token := responseHeaders.Get(config.tokenHeader)
             if token != "" {
               proxywasm.AddHttpRequestHeader(config.tokenHeader, token)
             }
             // Resume the paused request so it can be forwarded to the backend
             proxywasm.ResumeHttpRequest()
    })

  if err != nil {
    // If the service call fails, let the request proceed and log the error
    log.Errorf("Error occured while calling http, it seems cannot find the service cluster.")
    return types.ActionContinue
  } else {
    // Pause the request until the async callback completes
    return types.HeaderStopAllIterationAndWatermark
  }
}

Call Redis from a plugin

The following example implements a rate-limiting plugin backed by Redis. It tracks requests per minute (QPM) and returns HTTP 429 when the limit is exceeded.

package main

import (
  "strconv"
  "time"

  "github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
  "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
  "github.com/tidwall/gjson"
  "github.com/tidwall/resp"

  "github.com/higress-group/wasm-go/pkg/wrapper"
  logs "github.com/higress-group/wasm-go/pkg/log"
)

func main() {}

func init() {
  wrapper.SetCtx(
    "redis-demo",
    wrapper.ParseConfigBy(parseConfig),
    wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
    wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
  )
}

type RedisCallConfig struct {
  client wrapper.RedisClient
  qpm    int
}

func parseConfig(json gjson.Result, config *RedisCallConfig, log logs.Log) error {
  // Full FQDN with service type suffix.
  // Examples: my-redis.dns, redis.my-ns.svc.cluster.local
  serviceName := json.Get("serviceName").String()
  servicePort := json.Get("servicePort").Int()
  if servicePort == 0 {
    if strings.HasSuffix(serviceName, ".static") {
      servicePort = 80
    } else {
      servicePort = 6379
    }
  }
  username := json.Get("username").String()
  password := json.Get("password").String()
  // Timeout in milliseconds
  timeout := json.Get("timeout").Int()
  if timeout == 0 {
    timeout = 1000
  }
  qpm := json.Get("qpm").Int()
  config.qpm = int(qpm)
  config.client = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{
    FQDN: serviceName,
    Port: servicePort,
  })
  return config.client.Init(username, password, timeout)
}

func onHttpRequestHeaders(ctx wrapper.HttpContext, config RedisCallConfig, log logs.Log) types.Action {
  now := time.Now()
  minuteAligned := now.Truncate(time.Minute)
  timeStamp := strconv.FormatInt(minuteAligned.Unix(), 10)
  // If err != nil, the gateway likely cannot reach the Redis backend.
  // Verify that the Redis service has not been deleted.
  err := config.client.Incr(timeStamp, func(response resp.Value) {
    if response.Error() != nil {
      log.Errorf("call redis error: %v", response.Error())
      proxywasm.ResumeHttpRequest()
    } else {
      ctx.SetContext("timeStamp", timeStamp)
      ctx.SetContext("callTimeLeft", strconv.Itoa(config.qpm-response.Integer()))
      if response.Integer() == 1 {
        err := config.client.Expire(timeStamp, 60, func(response resp.Value) {
          if response.Error() != nil {
            log.Errorf("call redis error: %v", response.Error())
          }
          proxywasm.ResumeHttpRequest()
        })
        if err != nil {
          log.Errorf("Error occured while calling redis, it seems cannot find the redis cluster.")
          proxywasm.ResumeHttpRequest()
        }
      } else {
        if response.Integer() > config.qpm {
          proxywasm.SendHttpResponse(429, [][2]string{{"timeStamp", timeStamp}, {"callTimeLeft", "0"}}, []byte("Too many requests\n"), -1)
        } else {
          proxywasm.ResumeHttpRequest()
        }
      }
    }
  })
  if err != nil {
    // If the Redis call fails, let the request proceed and log the error
    log.Errorf("Error occured while calling redis, it seems cannot find the redis cluster.")
    return types.HeaderContinue
  } else {
    // Pause the request until the Redis callback completes
    return types.HeaderStopAllIterationAndWatermark
  }
}

func onHttpResponseHeaders(ctx wrapper.HttpContext, config RedisCallConfig, log logs.Log) types.Action {
  if ctx.GetContext("timeStamp") != nil {
    proxywasm.AddHttpResponseHeader("timeStamp", ctx.GetContext("timeStamp").(string))
  }
  if ctx.GetContext("callTimeLeft") != nil {
    proxywasm.AddHttpResponseHeader("callTimeLeft", ctx.GetContext("callTimeLeft").(string))
  }
  return types.HeaderContinue
}