All Products
Search
Document Center

Microservices Engine:Develop gateway plugins using Go

Last Updated:Mar 11, 2026

Gateway plugins extend the core features of an API gateway to handle specific business requirements. This guide covers the full development workflow: writing a Go-based WebAssembly (Wasm) plugin, compiling it, and testing it locally with Docker.

Important

Higress has migrated from TinyGo 0.29 and Go 1.20 to the native Wasm compilation supported by Go 1.24. If you previously compiled plugins with TinyGo, see the Migrate from TinyGo section at the end of this guide.

How it works

A gateway plugin hooks into the HTTP processing lifecycle of the Envoy-based gateway. Four phases are available, each mapped to a mount method in the plugin SDK:

                    Request flow                  Response flow
  Client ──────────────────────────► Backend ──────────────────────────► Client

  ┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐
  │ Request headers  │  │  Request body    │  │ Response headers │  │  Response body   │
  │  ProcessRequest  │  │  ProcessRequest  │  │  ProcessResponse │  │  ProcessResponse │
  │  HeadersBy       │  │  BodyBy          │  │  HeadersBy       │  │  BodyBy          │
  └──────────────────┘  └──────────────────┘  └──────────────────┘  └──────────────────┘
PhaseTriggerMount method
Request headersGateway receives request headers from the clientwrapper.ProcessRequestHeadersBy
Request bodyGateway receives the request body from the clientwrapper.ProcessRequestBodyBy
Response headersGateway receives response headers from the backend servicewrapper.ProcessResponseHeadersBy
Response bodyGateway receives the response body from the backend servicewrapper.ProcessResponseBodyBy

The plugin configuration in the gateway console uses YAML format. It is automatically converted to JSON before being passed to the plugin, so the parseConfig function parses directly from JSON.

Prerequisites

Install Go 1.24 or later. For more information, see the official installation guide.

Note

Plugins compiled with Go 1.24 require MSE Gateway version 2.0.11 or later. For earlier gateway versions, see Develop Wasm plugins using Go (legacy).

Windows

  1. Download the installation file.

  2. Double-click the downloaded file to start the installation. By default, Go is installed in the Program Files or Program Files (x86) folder.

  3. After the installation is complete, press Win+R to open the Run dialog. Enter cmd and click OK to open the command prompt. Run go version to verify the installation.

macOS

  1. Download the installation file.

  2. Double-click the downloaded file to start the installation. By default, Go is installed in the /usr/local/go directory.

  3. Open the terminal and run go version to verify the installation.

Linux

  1. Download the installation file.

  2. Run the following commands to install Go:

       rm -rf /usr/local/go && tar -C /usr/local -xzf go1.24.4.linux-amd64.tar.gz
       export PATH=$PATH:/usr/local/go/bin
  3. 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)

MethodPurpose
GetHttpRequestHeadersGet all request headers
ReplaceHttpRequestHeadersReplace all request headers
GetHttpRequestHeaderGet a specific request header
RemoveHttpRequestHeaderRemove a specific request header
ReplaceHttpRequestHeaderReplace a specific request header
AddHttpRequestHeaderAdd a request header

Request body processing (effective during the request body phase)

MethodPurpose
GetHttpRequestBodyGet the request body
AppendHttpRequestBodyAppend data to the end of the request body
PrependHttpRequestBodyPrepend data to the beginning of the request body
ReplaceHttpRequestBodyReplace the entire request body

Response header processing (effective during the response header phase)

MethodPurpose
GetHttpResponseHeadersGet all response headers from the backend
ReplaceHttpResponseHeadersReplace all response headers
GetHttpResponseHeaderGet a specific response header
RemoveHttpResponseHeaderRemove a specific response header
ReplaceHttpResponseHeaderReplace a specific response header
AddHttpResponseHeaderAdd a response header

Response body processing (effective during the response body phase)

MethodPurpose
GetHttpResponseBodyGet the response body
AppendHttpResponseBodyAppend data to the end of the response body
PrependHttpResponseBodyPrepend data to the beginning of the response body
ReplaceHttpResponseBodyReplace the entire response body

HTTP calls and flow control

MethodPurpose
DispatchHttpCallSend an HTTP request to an external service
GetHttpCallResponseHeadersGet response headers from a DispatchHttpCall request
GetHttpCallResponseBodyGet the response body from a DispatchHttpCall request
GetHttpCallResponseTrailersGet response trailers from a DispatchHttpCall request
SendHttpResponseReturn an HTTP response directly to the client
ResumeHttpRequestResume a paused request processing flow
ResumeHttpResponseResume 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:

StatusBehavior
HeaderContinueThe current filter is done. Pass the request to the next filter. Equivalent to types.ActionContinue.
HeaderStopIterationHold 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().
HeaderContinueAndEndStreamPass the header to the next filter with end_stream = false, allowing the current filter to append more body data.
HeaderStopAllIterationAndBufferStop 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().
HeaderStopAllIterationAndWatermarkSame 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
}

Migrate from TinyGo

If you previously compiled plugins with TinyGo 0.29 and Go 1.20, make the following changes to migrate to Go 1.24:

  1. Update go.mod dependencies. Replace the TinyGo-compatible SDK versions with the Go 1.24 branches listed in Initialize the project.

  2. Move initialization from main() to init(). The main() function must be empty. Place all wrapper.SetCtx calls in the init() function.

  3. Replace types.ActionPause with types.HeaderStopAllIterationAndWatermark. If your plugin calls an external service during the header processing phase and returns types.ActionPause, change it to types.HeaderStopAllIterationAndWatermark. See the the external HTTP service example.

  4. Replace go-re2 with the standard regexp package. TinyGo had incomplete support for Go's standard regexp library, requiring the go-re2 workaround. Go 1.24 supports regexp natively.