diff --git a/README.md b/README.md
index 2f1a1bc363..1587241cc3 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,9 @@
- AI Native API Gateway
+ AI Gateway
+ AI Native API Gateway
[](https://github.com/alibaba/higress/actions)
[](https://www.apache.org/licenses/LICENSE-2.0.html)
diff --git a/plugins/wasm-assemblyscript/README.md b/plugins/wasm-assemblyscript/README.md
new file mode 100644
index 0000000000..88471f9352
--- /dev/null
+++ b/plugins/wasm-assemblyscript/README.md
@@ -0,0 +1,53 @@
+## 介绍
+
+此 SDK 用于使用 AssemblyScript 语言开发 Higress 的 Wasm 插件。
+
+### 如何使用SDK
+
+创建一个新的 AssemblyScript 项目。
+
+```
+npm init
+npm install --save-dev assemblyscript
+npx asinit .
+```
+
+在asconfig.json文件中,作为传递给asc编译器的选项之一,包含"use": "abort=abort_proc_exit"。
+
+```
+{
+ "options": {
+ "use": "abort=abort_proc_exit"
+ }
+}
+```
+
+将`"@higress/proxy-wasm-assemblyscript-sdk": "^0.0.1"`和`"@higress/wasm-assemblyscript": "^0.0.3"`添加到你的依赖项中,然后运行`npm install`。
+
+### 本地构建
+
+```
+npm run asbuild
+```
+
+构建结果将在`build`文件夹中。其中,`debug.wasm`和`release.wasm`是已编译的文件,在生产环境中建议使用`release.wasm`。
+
+注:如果需要插件带有 name section 信息需要带上`"debug": true`,编译参数解释详见[using-the-compiler](https://www.assemblyscript.org/compiler.html#using-the-compiler)。
+
+```json
+"release": {
+ "outFile": "build/release.wasm",
+ "textFile": "build/release.wat",
+ "sourceMap": true,
+ "optimizeLevel": 3,
+ "shrinkLevel": 0,
+ "converge": false,
+ "noAssert": false,
+ "debug": true
+}
+```
+
+### AssemblyScript 限制
+
+此 SDK 使用的 AssemblyScript 版本为`0.27.29`,参考[AssemblyScript Status](https://www.assemblyscript.org/status.html)该版本尚未支持闭包、异常、迭代器等特性,并且JSON,正则表达式等功能还尚未在标准库中实现,暂时需要使用社区提供的实现。
+
diff --git a/plugins/wasm-assemblyscript/asconfig.json b/plugins/wasm-assemblyscript/asconfig.json
new file mode 100644
index 0000000000..9ba8d90d14
--- /dev/null
+++ b/plugins/wasm-assemblyscript/asconfig.json
@@ -0,0 +1,23 @@
+{
+ "targets": {
+ "debug": {
+ "outFile": "build/debug.wasm",
+ "textFile": "build/debug.wat",
+ "sourceMap": true,
+ "debug": true
+ },
+ "release": {
+ "outFile": "build/release.wasm",
+ "textFile": "build/release.wat",
+ "sourceMap": true,
+ "optimizeLevel": 3,
+ "shrinkLevel": 0,
+ "converge": false,
+ "noAssert": false
+ }
+ },
+ "options": {
+ "bindings": "esm",
+ "use": "abort=abort_proc_exit"
+ }
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/assembly/cluster_wrapper.ts b/plugins/wasm-assemblyscript/assembly/cluster_wrapper.ts
new file mode 100644
index 0000000000..292b62f477
--- /dev/null
+++ b/plugins/wasm-assemblyscript/assembly/cluster_wrapper.ts
@@ -0,0 +1,214 @@
+import {
+ log,
+ LogLevelValues,
+ get_property,
+ WasmResultValues,
+} from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
+import { getRequestHost } from "./request_wrapper";
+
+export abstract class Cluster {
+ abstract clusterName(): string;
+ abstract hostName(): string;
+}
+
+export class RouteCluster extends Cluster {
+ host: string;
+ constructor(host: string = "") {
+ super();
+ this.host = host;
+ }
+
+ clusterName(): string {
+ let result = get_property("cluster_name");
+ if (result.status != WasmResultValues.Ok) {
+ log(LogLevelValues.error, "get route cluster failed");
+ return "";
+ }
+ return String.UTF8.decode(result.returnValue);
+ }
+
+ hostName(): string {
+ if (this.host != "") {
+ return this.host;
+ }
+ return getRequestHost();
+ }
+}
+
+export class K8sCluster extends Cluster {
+ serviceName: string;
+ namespace: string;
+ port: i64;
+ version: string;
+ host: string;
+
+ constructor(
+ serviceName: string,
+ namespace: string,
+ port: i64,
+ version: string = "",
+ host: string = ""
+ ) {
+ super();
+ this.serviceName = serviceName;
+ this.namespace = namespace;
+ this.port = port;
+ this.version = version;
+ this.host = host;
+ }
+
+ clusterName(): string {
+ let namespace = this.namespace != "" ? this.namespace : "default";
+ return `outbound|${this.port}|${this.version}|${this.serviceName}.${namespace}.svc.cluster.local`;
+ }
+
+ hostName(): string {
+ if (this.host != "") {
+ return this.host;
+ }
+ return `${this.serviceName}.${this.namespace}.svc.cluster.local`;
+ }
+}
+
+export class NacosCluster extends Cluster {
+ serviceName: string;
+ group: string;
+ namespaceID: string;
+ port: i64;
+ isExtRegistry: boolean;
+ version: string;
+ host: string;
+
+ constructor(
+ serviceName: string,
+ namespaceID: string,
+ port: i64,
+ // use DEFAULT-GROUP by default
+ group: string = "DEFAULT-GROUP",
+ // set true if use edas/sae registry
+ isExtRegistry: boolean = false,
+ version: string = "",
+ host: string = ""
+ ) {
+ super();
+ this.serviceName = serviceName;
+ this.group = group.replace("_", "-");
+ this.namespaceID = namespaceID;
+ this.port = port;
+ this.isExtRegistry = isExtRegistry;
+ this.version = version;
+ this.host = host;
+ }
+
+ clusterName(): string {
+ let tail = "nacos" + (this.isExtRegistry ? "-ext" : "");
+ return `outbound|${this.port}|${this.version}|${this.serviceName}.${this.group}.${this.namespaceID}.${tail}`;
+ }
+
+ hostName(): string {
+ if (this.host != "") {
+ return this.host;
+ }
+ return this.serviceName;
+ }
+}
+
+export class StaticIpCluster extends Cluster {
+ serviceName: string;
+ port: i64;
+ host: string;
+
+ constructor(serviceName: string, port: i64, host: string = "") {
+ super()
+ this.serviceName = serviceName;
+ this.port = port;
+ this.host = host;
+ }
+
+ clusterName(): string {
+ return `outbound|${this.port}||${this.serviceName}.static`;
+ }
+
+ hostName(): string {
+ if (this.host != "") {
+ return this.host;
+ }
+ return this.serviceName;
+ }
+}
+
+export class DnsCluster extends Cluster {
+ serviceName: string;
+ domain: string;
+ port: i64;
+
+ constructor(serviceName: string, domain: string, port: i64) {
+ super();
+ this.serviceName = serviceName;
+ this.domain = domain;
+ this.port = port;
+ }
+
+ clusterName(): string {
+ return `outbound|${this.port}||${this.serviceName}.dns`;
+ }
+
+ hostName(): string {
+ return this.domain;
+ }
+}
+
+export class ConsulCluster extends Cluster {
+ serviceName: string;
+ datacenter: string;
+ port: i64;
+ host: string;
+
+ constructor(
+ serviceName: string,
+ datacenter: string,
+ port: i64,
+ host: string = ""
+ ) {
+ super();
+ this.serviceName = serviceName;
+ this.datacenter = datacenter;
+ this.port = port;
+ this.host = host;
+ }
+
+ clusterName(): string {
+ return `outbound|${this.port}||${this.serviceName}.${this.datacenter}.consul`;
+ }
+
+ hostName(): string {
+ if (this.host != "") {
+ return this.host;
+ }
+ return this.serviceName;
+ }
+}
+
+export class FQDNCluster extends Cluster {
+ fqdn: string;
+ host: string;
+ port: i64;
+
+ constructor(fqdn: string, port: i64, host: string = "") {
+ super();
+ this.fqdn = fqdn;
+ this.host = host;
+ this.port = port;
+ }
+
+ clusterName(): string {
+ return `outbound|${this.port}||${this.fqdn}`;
+ }
+
+ hostName(): string {
+ if (this.host != "") {
+ return this.host;
+ }
+ return this.fqdn;
+ }
+}
diff --git a/plugins/wasm-assemblyscript/assembly/http_wrapper.ts b/plugins/wasm-assemblyscript/assembly/http_wrapper.ts
new file mode 100644
index 0000000000..695d91b96a
--- /dev/null
+++ b/plugins/wasm-assemblyscript/assembly/http_wrapper.ts
@@ -0,0 +1,120 @@
+import {
+ Cluster
+} from "./cluster_wrapper"
+
+import {
+ log,
+ LogLevelValues,
+ Headers,
+ HeaderPair,
+ root_context,
+ BufferTypeValues,
+ get_buffer_bytes,
+ BaseContext,
+ stream_context,
+ WasmResultValues,
+ RootContext,
+ ResponseCallBack
+} from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
+
+export interface HttpClient {
+ get(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
+ head(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
+ options(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
+ post(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
+ put(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
+ patch(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
+ delete(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
+ connect(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
+ trace(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
+}
+
+const methodArrayBuffer: ArrayBuffer = String.UTF8.encode(":method");
+const pathArrayBuffer: ArrayBuffer = String.UTF8.encode(":path");
+const authorityArrayBuffer: ArrayBuffer = String.UTF8.encode(":authority");
+
+const StatusBadGateway: i32 = 502;
+
+export class ClusterClient {
+ cluster: Cluster;
+
+ constructor(cluster: Cluster) {
+ this.cluster = cluster;
+ }
+
+ private httpCall(method: string, path: string, headers: Headers, body: ArrayBuffer, callback: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
+ if (root_context == null) {
+ log(LogLevelValues.error, "Root context is null");
+ return false;
+ }
+ for (let i: i32 = headers.length - 1; i >= 0; i--) {
+ const key = String.UTF8.decode(headers[i].key)
+ if ((key == ":method") || (key == ":path") || (key == ":authority")) {
+ headers.splice(i, 1);
+ }
+ }
+
+ headers.push(new HeaderPair(methodArrayBuffer, String.UTF8.encode(method)));
+ headers.push(new HeaderPair(pathArrayBuffer, String.UTF8.encode(path)));
+ headers.push(new HeaderPair(authorityArrayBuffer, String.UTF8.encode(this.cluster.hostName())));
+
+ const result = (root_context as RootContext).httpCall(this.cluster.clusterName(), headers, body, [], timeoutMillisecond, root_context as BaseContext, callback,
+ (_origin_context: BaseContext, _numHeaders: u32, body_size: usize, _trailers: u32, callback: ResponseCallBack): void => {
+ const respBody = get_buffer_bytes(BufferTypeValues.HttpCallResponseBody, 0, body_size as u32);
+ const respHeaders = stream_context.headers.http_callback.get_headers()
+ let code = StatusBadGateway;
+ let headers = new Array();
+ for (let i = 0; i < respHeaders.length; i++) {
+ const h = respHeaders[i];
+ if (String.UTF8.decode(h.key) == ":status") {
+ code = parseInt(String.UTF8.decode(h.value))
+ }
+ headers.push(new HeaderPair(h.key, h.value));
+ }
+ log(LogLevelValues.debug, `http call end, code: ${code}, body: ${String.UTF8.decode(respBody)}`)
+ callback(code, headers, respBody);
+ })
+ log(LogLevelValues.debug, `http call start, cluster: ${this.cluster.clusterName()}, method: ${method}, path: ${path}, body: ${String.UTF8.decode(body)}, timeout: ${timeoutMillisecond}`)
+ if (result != WasmResultValues.Ok) {
+ log(LogLevelValues.error, `http call failed, result: ${result}`)
+ return false
+ }
+ return true
+ }
+
+ get(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
+ return this.httpCall("GET", path, headers, new ArrayBuffer(0), cb, timeoutMillisecond);
+ }
+
+ head(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
+ return this.httpCall("HEAD", path, headers, new ArrayBuffer(0), cb, timeoutMillisecond);
+ }
+
+ options(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
+ return this.httpCall("OPTIONS", path, headers, new ArrayBuffer(0), cb, timeoutMillisecond);
+ }
+
+ post(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
+ return this.httpCall("POST", path, headers, body, cb, timeoutMillisecond);
+ }
+
+ put(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
+ return this.httpCall("PUT", path, headers, body, cb, timeoutMillisecond);
+ }
+
+ patch(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
+ return this.httpCall("PATCH", path, headers, body, cb, timeoutMillisecond);
+ }
+
+ delete(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
+ return this.httpCall("DELETE", path, headers, body, cb, timeoutMillisecond);
+ }
+
+ connect(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
+ return this.httpCall("CONNECT", path, headers, body, cb, timeoutMillisecond);
+ }
+
+ trace(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
+ return this.httpCall("TRACE", path, headers, body, cb, timeoutMillisecond);
+ }
+}
diff --git a/plugins/wasm-assemblyscript/assembly/index.ts b/plugins/wasm-assemblyscript/assembly/index.ts
new file mode 100644
index 0000000000..6ff8a48936
--- /dev/null
+++ b/plugins/wasm-assemblyscript/assembly/index.ts
@@ -0,0 +1,18 @@
+export {RouteCluster,
+ K8sCluster,
+ NacosCluster,
+ ConsulCluster,
+ FQDNCluster,
+ StaticIpCluster} from "./cluster_wrapper"
+export {HttpClient,
+ ClusterClient} from "./http_wrapper"
+export {Log} from "./log_wrapper"
+export {SetCtx,
+ HttpContext,
+ ParseConfigBy,
+ ProcessRequestBodyBy,
+ ProcessRequestHeadersBy,
+ ProcessResponseBodyBy,
+ ProcessResponseHeadersBy,
+ Logger, RegisteTickFunc} from "./plugin_wrapper"
+export {ParseResult} from "./rule_matcher"
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/assembly/log_wrapper.ts b/plugins/wasm-assemblyscript/assembly/log_wrapper.ts
new file mode 100644
index 0000000000..15072d013d
--- /dev/null
+++ b/plugins/wasm-assemblyscript/assembly/log_wrapper.ts
@@ -0,0 +1,66 @@
+import { log, LogLevelValues } from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
+
+enum LogLevel {
+ Trace = 0,
+ Debug,
+ Info,
+ Warn,
+ Error,
+ Critical,
+}
+
+export class Log {
+ private pluginName: string;
+
+ constructor(pluginName: string) {
+ this.pluginName = pluginName;
+ }
+
+ private log(level: LogLevel, msg: string): void {
+ let formattedMsg = `[${this.pluginName}] ${msg}`;
+ switch (level) {
+ case LogLevel.Trace:
+ log(LogLevelValues.trace, formattedMsg);
+ break;
+ case LogLevel.Debug:
+ log(LogLevelValues.debug, formattedMsg);
+ break;
+ case LogLevel.Info:
+ log(LogLevelValues.info, formattedMsg);
+ break;
+ case LogLevel.Warn:
+ log(LogLevelValues.warn, formattedMsg);
+ break;
+ case LogLevel.Error:
+ log(LogLevelValues.error, formattedMsg);
+ break;
+ case LogLevel.Critical:
+ log(LogLevelValues.critical, formattedMsg);
+ break;
+ }
+ }
+
+ public Trace(msg: string): void {
+ this.log(LogLevel.Trace, msg);
+ }
+
+ public Debug(msg: string): void {
+ this.log(LogLevel.Debug, msg);
+ }
+
+ public Info(msg: string): void {
+ this.log(LogLevel.Info, msg);
+ }
+
+ public Warn(msg: string): void {
+ this.log(LogLevel.Warn, msg);
+ }
+
+ public Error(msg: string): void {
+ this.log(LogLevel.Error, msg);
+ }
+
+ public Critical(msg: string): void {
+ this.log(LogLevel.Critical, msg);
+ }
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/assembly/plugin_wrapper.ts b/plugins/wasm-assemblyscript/assembly/plugin_wrapper.ts
new file mode 100644
index 0000000000..52f5b5371c
--- /dev/null
+++ b/plugins/wasm-assemblyscript/assembly/plugin_wrapper.ts
@@ -0,0 +1,445 @@
+import { Log } from "./log_wrapper";
+import {
+ Context,
+ FilterHeadersStatusValues,
+ RootContext,
+ setRootContext,
+ proxy_set_effective_context,
+ log,
+ LogLevelValues,
+ FilterDataStatusValues,
+ get_buffer_bytes,
+ BufferTypeValues,
+ set_tick_period_milliseconds,
+ get_current_time_nanoseconds
+} from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
+import {
+ getRequestHost,
+ getRequestMethod,
+ getRequestPath,
+ getRequestScheme,
+ isBinaryRequestBody,
+} from "./request_wrapper";
+import { RuleMatcher, ParseResult } from "./rule_matcher";
+import { JSON } from "assemblyscript-json/assembly";
+
+export function SetCtx(
+ pluginName: string,
+ setFuncs: usize[] = []
+): void {
+ const rootContextId = 1
+ setRootContext(new CommonRootCtx(rootContextId, pluginName, setFuncs));
+}
+
+export interface HttpContext {
+ Scheme(): string;
+ Host(): string;
+ Path(): string;
+ Method(): string;
+ SetContext(key: string, value: usize): void;
+ GetContext(key: string): usize;
+ DontReadRequestBody(): void;
+ DontReadResponseBody(): void;
+}
+
+type ParseConfigFunc = (
+ json: JSON.Obj,
+) => ParseResult;
+type OnHttpHeadersFunc = (
+ context: HttpContext,
+ config: PluginConfig,
+) => FilterHeadersStatusValues;
+type OnHttpBodyFunc = (
+ context: HttpContext,
+ config: PluginConfig,
+ body: ArrayBuffer,
+) => FilterDataStatusValues;
+
+
+export var Logger: Log = new Log("");
+
+class CommonRootCtx extends RootContext {
+ pluginName: string;
+ hasCustomConfig: boolean;
+ ruleMatcher: RuleMatcher;
+ parseConfig: ParseConfigFunc | null;
+ onHttpRequestHeaders: OnHttpHeadersFunc | null;
+ onHttpRequestBody: OnHttpBodyFunc | null;
+ onHttpResponseHeaders: OnHttpHeadersFunc | null;
+ onHttpResponseBody: OnHttpBodyFunc | null;
+ onTickFuncs: Array;
+
+ constructor(context_id: u32, pluginName: string, setFuncs: usize[]) {
+ super(context_id);
+ this.pluginName = pluginName;
+ Logger = new Log(pluginName);
+ this.hasCustomConfig = true;
+ this.onHttpRequestHeaders = null;
+ this.onHttpRequestBody = null;
+ this.onHttpResponseHeaders = null;
+ this.onHttpResponseBody = null;
+ this.parseConfig = null;
+ this.ruleMatcher = new RuleMatcher();
+ this.onTickFuncs = new Array();
+ for (let i = 0; i < setFuncs.length; i++) {
+ changetype>(setFuncs[i]).lambdaFn(
+ setFuncs[i],
+ this
+ );
+ }
+ if (this.parseConfig == null) {
+ this.hasCustomConfig = false;
+ this.parseConfig = (json: JSON.Obj): ParseResult =>{ return new ParseResult(null, true); };
+ }
+ }
+
+ createContext(context_id: u32): Context {
+ return new CommonCtx(context_id, this);
+ }
+
+ onConfigure(configuration_size: u32): boolean {
+ super.onConfigure(configuration_size);
+ const data = this.getConfiguration();
+ let jsonData: JSON.Obj = new JSON.Obj();
+ if (data == "{}") {
+ if (this.hasCustomConfig) {
+ log(LogLevelValues.warn, "config is empty, but has ParseConfigFunc");
+ }
+ } else {
+ const parseData = JSON.parse(data);
+ if (parseData.isObj) {
+ jsonData = changetype(JSON.parse(data));
+ } else {
+ log(LogLevelValues.error, "parse json data failed")
+ return false;
+ }
+ }
+
+ if (!this.ruleMatcher.parseRuleConfig(jsonData, this.parseConfig as ParseConfigFunc)) {
+ return false;
+ }
+
+ if (globalOnTickFuncs.length > 0) {
+ this.onTickFuncs = globalOnTickFuncs;
+ set_tick_period_milliseconds(100);
+ }
+ return true;
+ }
+
+ onTick(): void {
+ for (let i = 0; i < this.onTickFuncs.length; i++) {
+ const tickFuncEntry = this.onTickFuncs[i];
+ const now = getCurrentTimeMilliseconds();
+ if (tickFuncEntry.lastExecuted + tickFuncEntry.tickPeriod <= now) {
+ tickFuncEntry.tickFunc();
+ tickFuncEntry.lastExecuted = getCurrentTimeMilliseconds();
+ }
+ }
+ }
+}
+
+function getCurrentTimeMilliseconds(): u64 {
+ return get_current_time_nanoseconds() / 1000000;
+}
+
+class TickFuncEntry {
+ lastExecuted: u64;
+ tickPeriod: u64;
+ tickFunc: () => void;
+
+ constructor(lastExecuted: u64, tickPeriod: u64, tickFunc: () => void) {
+ this.lastExecuted = lastExecuted;
+ this.tickPeriod = tickPeriod;
+ this.tickFunc = tickFunc;
+ }
+}
+
+var globalOnTickFuncs = new Array();
+
+export function RegisteTickFunc(tickPeriod: i64, tickFunc: () => void): void {
+ globalOnTickFuncs.push(new TickFuncEntry(0, tickPeriod, tickFunc));
+}
+
+class Closure {
+ lambdaFn: (closure: usize, ctx: CommonRootCtx) => void;
+ parseConfigFunc: ParseConfigFunc | null;
+ onHttpHeadersFunc: OnHttpHeadersFunc | null;
+ OnHttpBodyFunc: OnHttpBodyFunc | null;
+
+ constructor(
+ lambdaFn: (closure: usize, ctx: CommonRootCtx) => void
+ ) {
+ this.lambdaFn = lambdaFn;
+ this.parseConfigFunc = null;
+ this.onHttpHeadersFunc = null;
+ this.OnHttpBodyFunc = null;
+ }
+
+ setParseConfigFunc(f: ParseConfigFunc): void {
+ this.parseConfigFunc = f;
+ }
+
+ setHttpHeadersFunc(f: OnHttpHeadersFunc): void {
+ this.onHttpHeadersFunc = f;
+ }
+
+ setHttpBodyFunc(f: OnHttpBodyFunc): void {
+ this.OnHttpBodyFunc = f;
+ }
+}
+
+export function ParseConfigBy(
+ f: ParseConfigFunc
+): usize {
+ const lambdaFn = function (
+ closure: usize,
+ ctx: CommonRootCtx
+ ): void {
+ const f = changetype>(closure).parseConfigFunc;
+ if (f != null) {
+ ctx.parseConfig = f;
+ }
+ };
+ const closure = new Closure(lambdaFn);
+ closure.setParseConfigFunc(f);
+ return changetype(closure);
+}
+
+export function ProcessRequestHeadersBy(
+ f: OnHttpHeadersFunc
+): usize {
+ const lambdaFn = function (
+ closure: usize,
+ ctx: CommonRootCtx
+ ): void {
+ const f = changetype>(closure).onHttpHeadersFunc;
+ if (f != null) {
+ ctx.onHttpRequestHeaders = f;
+ }
+ };
+ const closure = new Closure(lambdaFn);
+ closure.setHttpHeadersFunc(f);
+ return changetype(closure);
+}
+
+export function ProcessRequestBodyBy(
+ f: OnHttpBodyFunc
+): usize {
+ const lambdaFn = function (
+ closure: usize,
+ ctx: CommonRootCtx
+ ): void {
+ const f = changetype>(closure).OnHttpBodyFunc;
+ if (f != null) {
+ ctx.onHttpRequestBody = f;
+ }
+ };
+ const closure = new Closure(lambdaFn);
+ closure.setHttpBodyFunc(f);
+ return changetype(closure);
+}
+
+export function ProcessResponseHeadersBy(
+ f: OnHttpHeadersFunc
+): usize {
+ const lambdaFn = function (
+ closure: usize,
+ ctx: CommonRootCtx
+ ): void {
+ const f = changetype>(closure).onHttpHeadersFunc;
+ if (f != null) {
+ ctx.onHttpResponseHeaders = f;
+ }
+ };
+ const closure = new Closure(lambdaFn);
+ closure.setHttpHeadersFunc(f);
+ return changetype(closure);
+}
+
+export function ProcessResponseBodyBy(
+ f: OnHttpBodyFunc
+): usize {
+ const lambdaFn = function (
+ closure: usize,
+ ctx: CommonRootCtx
+ ): void {
+ const f = changetype>(closure).OnHttpBodyFunc;
+ if (f != null) {
+ ctx.onHttpResponseBody = f;
+ }
+ };
+ const closure = new Closure(lambdaFn);
+ closure.setHttpBodyFunc(f);
+ return changetype(closure);
+}
+
+class CommonCtx extends Context implements HttpContext {
+ commonRootCtx: CommonRootCtx;
+ config: PluginConfig |null;
+ needRequestBody: boolean;
+ needResponseBody: boolean;
+ requestBodySize: u32;
+ responseBodySize: u32;
+ contextID: u32;
+ userContext: Map;
+
+ constructor(context_id: u32, root_context: CommonRootCtx) {
+ super(context_id, root_context);
+ this.userContext = new Map();
+ this.commonRootCtx = root_context;
+ this.contextID = context_id;
+ this.requestBodySize = 0;
+ this.responseBodySize = 0;
+ this.config = null
+ if (this.commonRootCtx.onHttpRequestHeaders != null) {
+ this.needResponseBody = true;
+ } else {
+ this.needResponseBody = false;
+ }
+ if (this.commonRootCtx.onHttpRequestBody != null) {
+ this.needRequestBody = true;
+ } else {
+ this.needRequestBody = false;
+ }
+ }
+
+ SetContext(key: string, value: usize): void {
+ this.userContext.set(key, value);
+ }
+
+ GetContext(key: string): usize {
+ return this.userContext.get(key);
+ }
+
+ Scheme(): string {
+ proxy_set_effective_context(this.contextID);
+ return getRequestScheme();
+ }
+
+ Host(): string {
+ proxy_set_effective_context(this.contextID);
+ return getRequestHost();
+ }
+
+ Path(): string {
+ proxy_set_effective_context(this.contextID);
+ return getRequestPath();
+ }
+
+ Method(): string {
+ proxy_set_effective_context(this.contextID);
+ return getRequestMethod();
+ }
+
+ DontReadRequestBody(): void {
+ this.needRequestBody = false;
+ }
+
+ DontReadResponseBody(): void {
+ this.needResponseBody = false;
+ }
+
+ onRequestHeaders(_a: u32, _end_of_stream: boolean): FilterHeadersStatusValues {
+ const parseResult = this.commonRootCtx.ruleMatcher.getMatchConfig();
+ if (parseResult.success == false) {
+ log(LogLevelValues.error, "get match config failed");
+ return FilterHeadersStatusValues.Continue;
+ }
+ this.config = parseResult.pluginConfig;
+
+ if (isBinaryRequestBody()) {
+ this.needRequestBody = false;
+ }
+
+ if (this.commonRootCtx.onHttpRequestHeaders == null) {
+ return FilterHeadersStatusValues.Continue;
+ }
+ return this.commonRootCtx.onHttpRequestHeaders(
+ this,
+ this.config as PluginConfig
+ );
+ }
+
+ onRequestBody(
+ body_buffer_length: usize,
+ end_of_stream: boolean
+ ): FilterDataStatusValues {
+ if (this.config == null || !this.needRequestBody) {
+ return FilterDataStatusValues.Continue;
+ }
+
+ if (this.commonRootCtx.onHttpRequestBody == null) {
+ return FilterDataStatusValues.Continue;
+ }
+ this.requestBodySize += body_buffer_length as u32;
+
+ if (!end_of_stream) {
+ return FilterDataStatusValues.StopIterationAndBuffer;
+ }
+
+ const body = get_buffer_bytes(
+ BufferTypeValues.HttpRequestBody,
+ 0,
+ this.requestBodySize
+ );
+
+ return this.commonRootCtx.onHttpRequestBody(
+ this,
+ this.config as PluginConfig,
+ body
+ );
+ }
+
+ onResponseHeaders(_a: u32, _end_of_stream: bool): FilterHeadersStatusValues {
+ if (this.config == null) {
+ return FilterHeadersStatusValues.Continue;
+ }
+
+ if (isBinaryRequestBody()) {
+ this.needResponseBody = false;
+ }
+
+ if (this.commonRootCtx.onHttpResponseHeaders == null) {
+ return FilterHeadersStatusValues.Continue;
+ }
+
+ return this.commonRootCtx.onHttpResponseHeaders(
+ this,
+ this.config as PluginConfig
+ );
+ }
+
+ onResponseBody(
+ body_buffer_length: usize,
+ end_of_stream: bool
+ ): FilterDataStatusValues {
+ if (this.config == null) {
+ return FilterDataStatusValues.Continue;
+ }
+
+ if (this.commonRootCtx.onHttpResponseBody == null) {
+ return FilterDataStatusValues.Continue;
+ }
+
+ if (!this.needResponseBody) {
+ return FilterDataStatusValues.Continue;
+ }
+
+ this.responseBodySize += body_buffer_length as u32;
+
+ if (!end_of_stream) {
+ return FilterDataStatusValues.StopIterationAndBuffer;
+ }
+ const body = get_buffer_bytes(
+ BufferTypeValues.HttpResponseBody,
+ 0,
+ this.responseBodySize
+ );
+
+ return this.commonRootCtx.onHttpResponseBody(
+ this,
+ this.config as PluginConfig,
+ body
+ );
+ }
+}
diff --git a/plugins/wasm-assemblyscript/assembly/request_wrapper.ts b/plugins/wasm-assemblyscript/assembly/request_wrapper.ts
new file mode 100644
index 0000000000..6a017575ee
--- /dev/null
+++ b/plugins/wasm-assemblyscript/assembly/request_wrapper.ts
@@ -0,0 +1,65 @@
+import {
+ stream_context,
+ log,
+ LogLevelValues
+} from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
+
+export function getRequestScheme(): string {
+ let scheme: string = stream_context.headers.request.get(":scheme");
+ if (scheme == "") {
+ log(LogLevelValues.error, "Parse request scheme failed");
+ }
+ return scheme;
+}
+
+export function getRequestHost(): string {
+ let host: string = stream_context.headers.request.get(":authority");
+ if (host == "") {
+ log(LogLevelValues.error, "Parse request host failed");
+ }
+ return host;
+}
+
+export function getRequestPath(): string {
+ let path: string = stream_context.headers.request.get(":path");
+ if (path == "") {
+ log(LogLevelValues.error, "Parse request path failed");
+ }
+ return path;
+}
+
+export function getRequestMethod(): string {
+ let method: string = stream_context.headers.request.get(":method");
+ if (method == "") {
+ log(LogLevelValues.error, "Parse request method failed");
+ }
+ return method;
+}
+
+export function isBinaryRequestBody(): boolean {
+ let contentType: string = stream_context.headers.request.get("content-type");
+ if (contentType != "" && (contentType.includes("octet-stream") || contentType.includes("grpc"))) {
+ return true;
+ }
+
+ let encoding: string = stream_context.headers.request.get("content-encoding");
+ if (encoding != "") {
+ return true;
+ }
+
+ return false;
+}
+
+export function isBinaryResponseBody(): boolean {
+ let contentType: string = stream_context.headers.response.get("content-type");
+ if (contentType != "" && (contentType.includes("octet-stream") || contentType.includes("grpc"))) {
+ return true;
+ }
+
+ let encoding: string = stream_context.headers.response.get("content-encoding");
+ if (encoding != "") {
+ return true;
+ }
+
+ return false;
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/assembly/rule_matcher.ts b/plugins/wasm-assemblyscript/assembly/rule_matcher.ts
new file mode 100644
index 0000000000..ed625e311d
--- /dev/null
+++ b/plugins/wasm-assemblyscript/assembly/rule_matcher.ts
@@ -0,0 +1,346 @@
+import { getRequestHost } from "./request_wrapper";
+import {
+ get_property,
+ LogLevelValues,
+ log,
+ WasmResultValues,
+} from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
+import { JSON } from "assemblyscript-json/assembly";
+
+enum Category {
+ Route,
+ Host,
+ RoutePrefix,
+ Service
+}
+
+enum MatchType {
+ Prefix,
+ Exact,
+ Suffix,
+}
+
+const RULES_KEY: string = "_rules_";
+const MATCH_ROUTE_KEY: string = "_match_route_";
+const MATCH_DOMAIN_KEY: string = "_match_domain_";
+const MATCH_SERVICE_KEY: string = "_match_service_";
+const MATCH_ROUTE_PREFIX_KEY: string = "_match_route_prefix_"
+
+class HostMatcher {
+ matchType: MatchType;
+ host: string;
+
+ constructor(matchType: MatchType, host: string) {
+ this.matchType = matchType;
+ this.host = host;
+ }
+}
+
+class RuleConfig {
+ category: Category;
+ routes!: Map;
+ services!: Map;
+ routePrefixs!: Map;
+ hosts!: Array;
+ config: PluginConfig | null;
+
+ constructor() {
+ this.category = Category.Route;
+ this.config = null;
+ }
+}
+
+export class ParseResult {
+ pluginConfig: PluginConfig | null;
+ success: boolean;
+ constructor(pluginConfig: PluginConfig | null, success: boolean) {
+ this.pluginConfig = pluginConfig;
+ this.success = success;
+ }
+}
+
+export class RuleMatcher {
+ ruleConfig: Array>;
+ globalConfig: PluginConfig | null;
+ hasGlobalConfig: boolean;
+
+ constructor() {
+ this.ruleConfig = new Array>();
+ this.globalConfig = null;
+ this.hasGlobalConfig = false;
+ }
+
+ getMatchConfig(): ParseResult {
+ const host = getRequestHost();
+ if (host == "") {
+ return new ParseResult(null, false);
+ }
+ let result = get_property("route_name");
+ if (result.status != WasmResultValues.Ok && result.status != WasmResultValues.NotFound) {
+ return new ParseResult(null, false);
+ }
+ const routeName = String.UTF8.decode(result.returnValue);
+
+ result = get_property("cluster_name");
+ if (result.status != WasmResultValues.Ok && result.status != WasmResultValues.NotFound) {
+ return new ParseResult(null, false);
+ }
+ const serviceName = String.UTF8.decode(result.returnValue);
+
+ for (let i = 0; i < this.ruleConfig.length; i++) {
+ const rule = this.ruleConfig[i];
+ // category == Host
+ if (rule.category == Category.Host) {
+ if (this.hostMatch(rule, host)) {
+ log(LogLevelValues.debug, "getMatchConfig: match host " + host);
+ return new ParseResult(rule.config, true);
+ }
+ }
+ // category == Route
+ if (rule.category == Category.Route) {
+ if (rule.routes.has(routeName)) {
+ log(LogLevelValues.debug, "getMatchConfig: match route " + routeName);
+ return new ParseResult(rule.config, true);
+ }
+ }
+ // category == RoutePrefix
+ if (rule.category == Category.RoutePrefix) {
+ for (let i = 0; i < rule.routePrefixs.keys().length; i++) {
+ const routePrefix = rule.routePrefixs.keys()[i];
+ if (routeName.startsWith(routePrefix)) {
+ return new ParseResult(rule.config, true);
+ }
+ }
+ }
+ // category == Cluster
+ if (this.serviceMatch(rule, serviceName)) {
+ return new ParseResult(rule.config, true);
+ }
+ }
+
+ if (this.hasGlobalConfig) {
+ return new ParseResult(this.globalConfig, true);
+ }
+ return new ParseResult(null, false);
+ }
+
+ parseRuleConfig(
+ config: JSON.Obj,
+ parsePluginConfig: (json: JSON.Obj) => ParseResult
+ ): boolean {
+ const obj = config;
+ let keyCount = obj.keys.length;
+ if (keyCount == 0) {
+ this.hasGlobalConfig = true;
+ const parseResult = parsePluginConfig(config);
+ if (parseResult.success) {
+ this.globalConfig = parseResult.pluginConfig;
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ let rules: JSON.Arr | null = null;
+ if (obj.has(RULES_KEY)) {
+ rules = obj.getArr(RULES_KEY);
+ keyCount--;
+ }
+
+ if (keyCount > 0) {
+ const parseResult = parsePluginConfig(config);
+ if (parseResult.success) {
+ this.globalConfig = parseResult.pluginConfig;
+ this.hasGlobalConfig = true;
+ }
+ }
+
+ if (!rules) {
+ if (this.hasGlobalConfig) {
+ return true;
+ }
+ log(LogLevelValues.error, "parse config failed, no valid rules; global config parse error");
+ return false;
+ }
+
+ const rulesArray = rules.valueOf();
+ for (let i = 0; i < rulesArray.length; i++) {
+ if (!rulesArray[i].isObj) {
+ log(LogLevelValues.error, "parse rule failed, rules must be an array of objects");
+ continue;
+ }
+ const ruleJson = changetype(rulesArray[i]);
+ const rule = new RuleConfig();
+ const parseResult = parsePluginConfig(ruleJson);
+ if (parseResult.success) {
+ rule.config = parseResult.pluginConfig;
+ } else {
+ return false;
+ }
+
+ rule.routes = this.parseRouteMatchConfig(ruleJson);
+ rule.hosts = this.parseHostMatchConfig(ruleJson);
+ rule.services = this.parseServiceMatchConfig(ruleJson);
+ rule.routePrefixs = this.parseRoutePrefixMatchConfig(ruleJson);
+
+ const noRoute = rule.routes.size == 0;
+ const noHosts = rule.hosts.length == 0;
+ const noServices = rule.services.size == 0;
+ const noRoutePrefixs = rule.routePrefixs.size == 0;
+
+ if ((boolToInt(noRoute) + boolToInt(noHosts) + boolToInt(noServices) + boolToInt(noRoutePrefixs)) != 3) {
+ log(LogLevelValues.error, "there is only one of '_match_route_', '_match_domain_', '_match_service_' and '_match_route_prefix_' can present in configuration.");
+ return false;
+ }
+ if (!noRoute) {
+ rule.category = Category.Route;
+ } else if (!noHosts) {
+ rule.category = Category.Host;
+ } else if (!noServices) {
+ rule.category = Category.Service;
+ } else {
+ rule.category = Category.RoutePrefix;
+ }
+ this.ruleConfig.push(rule);
+ }
+ return true;
+ }
+
+ parseRouteMatchConfig(config: JSON.Obj): Map {
+ const keys = config.getArr(MATCH_ROUTE_KEY);
+ const routes = new Map();
+ if (keys) {
+ const array = keys.valueOf();
+ for (let i = 0; i < array.length; i++) {
+ const key = array[i].toString();
+ if (key != "") {
+ routes.set(key, true);
+ }
+ }
+ }
+ return routes;
+ }
+
+ parseRoutePrefixMatchConfig(config: JSON.Obj): Map {
+ const keys = config.getArr(MATCH_ROUTE_PREFIX_KEY);
+ const routePrefixs = new Map();
+ if (keys) {
+ const array = keys.valueOf();
+ for (let i = 0; i < array.length; i++) {
+ const key = array[i].toString();
+ if (key != "") {
+ routePrefixs.set(key, true);
+ }
+ }
+ }
+ return routePrefixs;
+ }
+
+ parseServiceMatchConfig(config: JSON.Obj): Map {
+ const keys = config.getArr(MATCH_SERVICE_KEY);
+ const clusters = new Map();
+ if (keys) {
+ const array = keys.valueOf();
+ for (let i = 0; i < array.length; i++) {
+ const key = array[i].toString();
+ if (key != "") {
+ clusters.set(key, true);
+ }
+ }
+ }
+ return clusters;
+ }
+
+ parseHostMatchConfig(config: JSON.Obj): Array {
+ const hostMatchers = new Array();
+ const keys = config.getArr(MATCH_DOMAIN_KEY);
+ if (keys !== null) {
+ const array = keys.valueOf();
+ for (let i = 0; i < array.length; i++) {
+ const item = array[i].toString(); // Assuming the array has string elements
+ let hostMatcher: HostMatcher;
+ if (item.startsWith("*")) {
+ hostMatcher = new HostMatcher(MatchType.Suffix, item.substr(1));
+ } else if (item.endsWith("*")) {
+ hostMatcher = new HostMatcher(
+ MatchType.Prefix,
+ item.substr(0, item.length - 1)
+ );
+ } else {
+ hostMatcher = new HostMatcher(MatchType.Exact, item);
+ }
+ hostMatchers.push(hostMatcher);
+ }
+ }
+ return hostMatchers;
+ }
+
+ stripPortFromHost(reqHost: string): string {
+ // Port removing code is inspired by
+ // https://github.com/envoyproxy/envoy/blob/v1.17.0/source/common/http/header_utility.cc#L219
+ let portStart: i32 = reqHost.lastIndexOf(":");
+ if (portStart != -1) {
+ // According to RFC3986 v6 address is always enclosed in "[]".
+ // section 3.2.2.
+ let v6EndIndex: i32 = reqHost.lastIndexOf("]");
+ if (v6EndIndex == -1 || v6EndIndex < portStart) {
+ if (portStart + 1 <= reqHost.length) {
+ return reqHost.substring(0, portStart);
+ }
+ }
+ }
+ return reqHost;
+ }
+
+ hostMatch(rule: RuleConfig, reqHost: string): boolean {
+ reqHost = this.stripPortFromHost(reqHost);
+ for (let i = 0; i < rule.hosts.length; i++) {
+ let hostMatch = rule.hosts[i];
+ switch (hostMatch.matchType) {
+ case MatchType.Suffix:
+ if (reqHost.endsWith(hostMatch.host)) {
+ return true;
+ }
+ break;
+ case MatchType.Prefix:
+ if (reqHost.startsWith(hostMatch.host)) {
+ return true;
+ }
+ break;
+ case MatchType.Exact:
+ if (reqHost == hostMatch.host) {
+ return true;
+ }
+ break;
+ default:
+ return false;
+ }
+ }
+ return false;
+ }
+
+ serviceMatch(rule: RuleConfig, serviceName: string): boolean {
+ const parts = serviceName.split('|');
+ if (parts.length != 4) {
+ return false;
+ }
+ const port = parts[1];
+ const fqdn = parts[3];
+ for (let i = 0; i < rule.services.keys().length; i++) {
+ let configServiceName = rule.services.keys()[i];
+ let colonIndex = configServiceName.lastIndexOf(':');
+ if (colonIndex != -1) {
+ let configFQDN = configServiceName.slice(0, colonIndex);
+ let configPort = configServiceName.slice(colonIndex + 1);
+ if (fqdn == configFQDN && port == configPort) return true;
+ } else if (fqdn == configServiceName) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
+
+function boolToInt(value: boolean): i32 {
+ return value ? 1 : 0;
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/assembly/tsconfig.json b/plugins/wasm-assemblyscript/assembly/tsconfig.json
new file mode 100644
index 0000000000..e28fcf2573
--- /dev/null
+++ b/plugins/wasm-assemblyscript/assembly/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "assemblyscript/std/assembly.json",
+ "include": [
+ "./**/*.ts"
+ ]
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/README.md b/plugins/wasm-assemblyscript/extensions/custom-response/README.md
new file mode 100644
index 0000000000..d3f07f8cd5
--- /dev/null
+++ b/plugins/wasm-assemblyscript/extensions/custom-response/README.md
@@ -0,0 +1,80 @@
+# 功能说明
+`custom-response`插件支持配置自定义的响应,包括自定义 HTTP 应答状态码、HTTP 应答头,以及 HTTP 应答 Body。可以用于 Mock 响应,也可以用于判断特定状态码后给出自定义应答,例如在触发网关限流策略时实现自定义响应。
+
+# 配置字段
+
+| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
+| -------- | -------- | -------- | -------- | -------- |
+| status_code | number | 选填 | 200 | 自定义 HTTP 应答状态码 |
+| headers | array of string | 选填 | - | 自定义 HTTP 应答头,key 和 value 用`=`分隔 |
+| body | string | 选填 | - | 自定义 HTTP 应答 Body |
+| enable_on_status | array of number | 选填 | - | 匹配原始状态码,生成自定义响应,不填写时,不判断原始状态码 |
+
+# 配置示例
+
+## Mock 应答场景
+
+```yaml
+status_code: 200
+headers:
+- Content-Type=application/json
+- Hello=World
+body: "{\"hello\":\"world\"}"
+
+```
+
+根据该配置,请求将返回自定义应答如下:
+
+```text
+HTTP/1.1 200 OK
+Content-Type: application/json
+Hello: World
+Content-Length: 17
+
+{"hello":"world"}
+```
+
+## 触发限流时自定义响应
+
+```yaml
+enable_on_status:
+- 429
+status_code: 302
+headers:
+- Location=https://example.com
+```
+
+触发网关限流时一般会返回 `429` 状态码,这时请求将返回自定义应答如下:
+
+```text
+HTTP/1.1 302 Found
+Location: https://example.com
+```
+
+从而实现基于浏览器 302 重定向机制,将限流后的用户引导到其他页面,比如可以是一个 CDN 上的静态页面。
+
+如果希望触发限流时,正常返回其他应答,参考 Mock 应答场景配置相应的字段即可。
+
+## 对特定路由或域名开启
+```yaml
+# 使用 matchRules 字段进行细粒度规则配置
+matchRules:
+# 规则一:按 Ingress 名称匹配生效
+- ingress:
+ - default/foo
+ - default/bar
+ body: "{\"hello\":\"world\"}"
+# 规则二:按域名匹配生效
+- domain:
+ - "*.example.com"
+ - test.com
+ enable_on_status:
+ - 429
+ status_code: 200
+ headers:
+ - Content-Type=application/json
+ body: "{\"errmsg\": \"rate limited\"}"
+```
+此例 `ingress` 中指定的 `default/foo` 和 `default/bar` 对应 default 命名空间下名为 foo 和 bar 的 Ingress,当匹配到这两个 Ingress 时,将使用此段配置;
+此例 `domain` 中指定的 `*.example.com` 和 `test.com` 用于匹配请求的域名,当发现域名匹配时,将使用此段配置;
+配置的匹配生效顺序,将按照 `matchRules` 下规则的排列顺序,匹配第一个规则后生效对应配置,后续规则将被忽略。
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/asconfig.json b/plugins/wasm-assemblyscript/extensions/custom-response/asconfig.json
new file mode 100644
index 0000000000..f3435b13c7
--- /dev/null
+++ b/plugins/wasm-assemblyscript/extensions/custom-response/asconfig.json
@@ -0,0 +1,24 @@
+{
+ "targets": {
+ "debug": {
+ "outFile": "build/debug.wasm",
+ "textFile": "build/debug.wat",
+ "sourceMap": true,
+ "debug": true
+ },
+ "release": {
+ "outFile": "build/release.wasm",
+ "textFile": "build/release.wat",
+ "sourceMap": true,
+ "optimizeLevel": 3,
+ "shrinkLevel": 0,
+ "converge": false,
+ "noAssert": false,
+ "debug": true
+ }
+ },
+ "options": {
+ "bindings": "esm",
+ "use": "abort=abort_proc_exit"
+ }
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/assembly/index.ts b/plugins/wasm-assemblyscript/extensions/custom-response/assembly/index.ts
new file mode 100644
index 0000000000..8619528250
--- /dev/null
+++ b/plugins/wasm-assemblyscript/extensions/custom-response/assembly/index.ts
@@ -0,0 +1,96 @@
+export * from "@higress/proxy-wasm-assemblyscript-sdk/assembly/proxy";
+import { SetCtx, HttpContext, ProcessRequestHeadersBy, Logger, ParseConfigBy, ParseResult, ProcessResponseHeadersBy } from "@higress/wasm-assemblyscript/assembly";
+import { FilterHeadersStatusValues, Headers, send_http_response, stream_context, HeaderPair } from "@higress/proxy-wasm-assemblyscript-sdk/assembly"
+import { JSON } from "assemblyscript-json/assembly";
+
+class CustomResponseConfig {
+ statusCode: u32;
+ headers: Headers;
+ body: ArrayBuffer;
+ enableOnStatus: Array;
+ contentType: string;
+ constructor() {
+ this.statusCode = 200;
+ this.headers = [];
+ this.body = new ArrayBuffer(0);
+ this.enableOnStatus = [];
+ this.contentType = "text/plain; charset=utf-8";
+ }
+}
+
+SetCtx(
+ "custom-response",
+ [ParseConfigBy(parseConfig),
+ ProcessRequestHeadersBy(onHttpRequestHeaders),
+ ProcessResponseHeadersBy(onHttpResponseHeaders),])
+
+function parseConfig(json: JSON.Obj): ParseResult {
+ let headersArray = json.getArr("headers");
+ let config = new CustomResponseConfig();
+ if (headersArray != null) {
+ for (let i = 0; i < headersArray.valueOf().length; i++) {
+ let header = headersArray._arr[i];
+ let jsonString = (header).toString()
+ let kv = jsonString.split("=")
+ if (kv.length == 2) {
+ let key = kv[0].trim();
+ let value = kv[1].trim();
+ if (key.toLowerCase() == "content-type") {
+ config.contentType = value;
+ } else if (key.toLowerCase() == "content-length") {
+ continue;
+ } else {
+ config.headers.push(new HeaderPair(String.UTF8.encode(key), String.UTF8.encode(value)));
+ }
+ } else {
+ Logger.Error("parse header failed");
+ return new ParseResult(null, false);
+ }
+ }
+ }
+ let body = json.getString("body");
+ if (body != null) {
+ config.body = String.UTF8.encode(body.valueOf());
+ }
+ config.headers.push(new HeaderPair(String.UTF8.encode("content-type"), String.UTF8.encode(config.contentType)));
+
+ let statusCode = json.getInteger("statusCode");
+ if (statusCode != null) {
+ config.statusCode = statusCode.valueOf() as u32;
+ }
+
+ let enableOnStatus = json.getArr("enableOnStatus");
+
+ if (enableOnStatus != null) {
+ for (let i = 0; i < enableOnStatus.valueOf().length; i++) {
+ let status = enableOnStatus._arr[i];
+ if (status.isInteger) {
+ config.enableOnStatus.push((status).valueOf() as u32);
+ }
+ }
+ }
+ return new ParseResult(config, true);
+}
+
+function onHttpRequestHeaders(context: HttpContext, config: CustomResponseConfig): FilterHeadersStatusValues {
+ if (config.enableOnStatus.length != 0) {
+ return FilterHeadersStatusValues.Continue;
+ }
+ send_http_response(config.statusCode, "custom-response", config.body, config.headers);
+ return FilterHeadersStatusValues.StopIteration;
+}
+
+function onHttpResponseHeaders(context: HttpContext, config: CustomResponseConfig): FilterHeadersStatusValues {
+ let statusCodeStr = stream_context.headers.response.get(":status")
+ if (statusCodeStr == "") {
+ Logger.Error("get http response status code failed");
+ return FilterHeadersStatusValues.Continue;
+ }
+ let statusCode = parseInt(statusCodeStr);
+ for (let i = 0; i < config.enableOnStatus.length; i++) {
+ if (statusCode == config.enableOnStatus[i]) {
+ send_http_response(config.statusCode, "custom-response", config.body, config.headers);
+ }
+ }
+ return FilterHeadersStatusValues.Continue;
+}
diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/assembly/tsconfig.json b/plugins/wasm-assemblyscript/extensions/custom-response/assembly/tsconfig.json
new file mode 100644
index 0000000000..e28fcf2573
--- /dev/null
+++ b/plugins/wasm-assemblyscript/extensions/custom-response/assembly/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "assemblyscript/std/assembly.json",
+ "include": [
+ "./**/*.ts"
+ ]
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/package-lock.json b/plugins/wasm-assemblyscript/extensions/custom-response/package-lock.json
new file mode 100644
index 0000000000..bbc970b844
--- /dev/null
+++ b/plugins/wasm-assemblyscript/extensions/custom-response/package-lock.json
@@ -0,0 +1,75 @@
+{
+ "name": "custom-response",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "custom-response",
+ "version": "1.0.0",
+ "license": "ISC",
+ "devDependencies": {
+ "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2",
+ "@higress/wasm-assemblyscript": "^0.0.3",
+ "assemblyscript": "^0.27.29",
+ "assemblyscript-json": "^1.1.0"
+ }
+ },
+ "node_modules/@higress/proxy-wasm-assemblyscript-sdk": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmmirror.com/@higress/proxy-wasm-assemblyscript-sdk/-/proxy-wasm-assemblyscript-sdk-0.0.2.tgz",
+ "integrity": "sha512-0J1tFJMTE6o37JpGJBLq0wc5kBC/fpbISrP+KFb4bAEeshu6daXzD2P3bAfJXmW+oZdY0WGptTGXWx8pf9Fk+g==",
+ "dev": true
+ },
+ "node_modules/@higress/wasm-assemblyscript": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmmirror.com/@higress/wasm-assemblyscript/-/wasm-assemblyscript-0.0.3.tgz",
+ "integrity": "sha512-D9hTvjAt54WoedNBsYAp9q/mDPWOO9yoGY7yG7Gkgp3KB7O5lHEEu2T6V8K14DpfC8ObSP26EhBcJ6G70JjODg==",
+ "dev": true
+ },
+ "node_modules/assemblyscript": {
+ "version": "0.27.29",
+ "resolved": "https://registry.npmmirror.com/assemblyscript/-/assemblyscript-0.27.29.tgz",
+ "integrity": "sha512-pH6udb7aE2F0t6cTh+0uCepmucykhMnAmm7k0kkAU3SY7LvpIngEBZWM6p5VCguu4EpmKGwEuZpZbEXzJ/frHQ==",
+ "dev": true,
+ "dependencies": {
+ "binaryen": "116.0.0-nightly.20240114",
+ "long": "^5.2.1"
+ },
+ "bin": {
+ "asc": "bin/asc.js",
+ "asinit": "bin/asinit.js"
+ },
+ "engines": {
+ "node": ">=16",
+ "npm": ">=7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/assemblyscript"
+ }
+ },
+ "node_modules/assemblyscript-json": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/assemblyscript-json/-/assemblyscript-json-1.1.0.tgz",
+ "integrity": "sha512-UbE8ts8csTWQgd5TnSPN7MRV9NveuHv1bVnKmDLoo/tzjqxkmsZb3lu59Uk8H7SGoqdkDSEE049alx/nHnSdFw==",
+ "dev": true
+ },
+ "node_modules/binaryen": {
+ "version": "116.0.0-nightly.20240114",
+ "resolved": "https://registry.npmmirror.com/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz",
+ "integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==",
+ "dev": true,
+ "bin": {
+ "wasm-opt": "bin/wasm-opt",
+ "wasm2js": "bin/wasm2js"
+ }
+ },
+ "node_modules/long": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmmirror.com/long/-/long-5.2.3.tgz",
+ "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==",
+ "dev": true
+ }
+ }
+}
diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/package.json b/plugins/wasm-assemblyscript/extensions/custom-response/package.json
new file mode 100644
index 0000000000..60f2f359df
--- /dev/null
+++ b/plugins/wasm-assemblyscript/extensions/custom-response/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "custom-response",
+ "version": "1.0.0",
+ "main": "index.js",
+ "scripts": {
+ "test": "node tests",
+ "asbuild:debug": "asc assembly/index.ts --target debug",
+ "asbuild:release": "asc assembly/index.ts --target release",
+ "asbuild": "npm run asbuild:debug && npm run asbuild:release",
+ "start": "npx serve ."
+ },
+ "author": "",
+ "license": "ISC",
+ "description": "",
+ "devDependencies": {
+ "assemblyscript": "^0.27.29",
+ "assemblyscript-json": "^1.1.0",
+ "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2",
+ "@higress/wasm-assemblyscript": "^0.0.3"
+ },
+ "type": "module",
+ "exports": {
+ ".": {
+ "import": "./build/release.js",
+ "types": "./build/release.d.ts"
+ }
+ }
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/extensions/hello-world/asconfig.json b/plugins/wasm-assemblyscript/extensions/hello-world/asconfig.json
new file mode 100644
index 0000000000..f3435b13c7
--- /dev/null
+++ b/plugins/wasm-assemblyscript/extensions/hello-world/asconfig.json
@@ -0,0 +1,24 @@
+{
+ "targets": {
+ "debug": {
+ "outFile": "build/debug.wasm",
+ "textFile": "build/debug.wat",
+ "sourceMap": true,
+ "debug": true
+ },
+ "release": {
+ "outFile": "build/release.wasm",
+ "textFile": "build/release.wat",
+ "sourceMap": true,
+ "optimizeLevel": 3,
+ "shrinkLevel": 0,
+ "converge": false,
+ "noAssert": false,
+ "debug": true
+ }
+ },
+ "options": {
+ "bindings": "esm",
+ "use": "abort=abort_proc_exit"
+ }
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/extensions/hello-world/assembly/index.ts b/plugins/wasm-assemblyscript/extensions/hello-world/assembly/index.ts
new file mode 100644
index 0000000000..e7fb7b5d17
--- /dev/null
+++ b/plugins/wasm-assemblyscript/extensions/hello-world/assembly/index.ts
@@ -0,0 +1,42 @@
+export * from "@higress/proxy-wasm-assemblyscript-sdk/assembly/proxy";
+import { SetCtx, HttpContext, ProcessRequestHeadersBy, Logger, ParseResult, ParseConfigBy, RegisteTickFunc, ProcessResponseHeadersBy } from "@higress/wasm-assemblyscript/assembly";
+import { FilterHeadersStatusValues, send_http_response, stream_context } from "@higress/proxy-wasm-assemblyscript-sdk/assembly"
+import { JSON } from "assemblyscript-json/assembly";
+class HelloWorldConfig {
+}
+
+SetCtx("hello-world",
+ [ParseConfigBy(parseConfig),
+ ProcessRequestHeadersBy(onHttpRequestHeaders),
+ ProcessResponseHeadersBy(onHttpResponseHeaders)
+ ])
+
+function parseConfig(json: JSON.Obj): ParseResult {
+ RegisteTickFunc(2000, () => {
+ Logger.Debug("tick 2s");
+ })
+ RegisteTickFunc(5000, () => {
+ Logger.Debug("tick 5s");
+ })
+ return new ParseResult(new HelloWorldConfig(), true);
+}
+
+class TestContext{
+ value: string
+ constructor(value: string){
+ this.value = value
+ }
+}
+function onHttpRequestHeaders(context: HttpContext, config: HelloWorldConfig): FilterHeadersStatusValues {
+ stream_context.headers.request.add("hello", "world");
+ Logger.Debug("[hello-world] logger test");
+ context.SetContext("test-set-context", changetype(new TestContext("value")))
+ send_http_response(200, "hello-world", String.UTF8.encode("[wasm-assemblyscript]hello world"), []);
+ return FilterHeadersStatusValues.Continue;
+}
+
+function onHttpResponseHeaders(context: HttpContext, config: HelloWorldConfig): FilterHeadersStatusValues {
+ const str = changetype(context.GetContext("test-set-context")).value;
+ Logger.Debug("[hello-world] test-set-context: " + str);
+ return FilterHeadersStatusValues.Continue;
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/extensions/hello-world/assembly/tsconfig.json b/plugins/wasm-assemblyscript/extensions/hello-world/assembly/tsconfig.json
new file mode 100644
index 0000000000..e28fcf2573
--- /dev/null
+++ b/plugins/wasm-assemblyscript/extensions/hello-world/assembly/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "assemblyscript/std/assembly.json",
+ "include": [
+ "./**/*.ts"
+ ]
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/extensions/hello-world/package-lock.json b/plugins/wasm-assemblyscript/extensions/hello-world/package-lock.json
new file mode 100644
index 0000000000..9ccf0130e5
--- /dev/null
+++ b/plugins/wasm-assemblyscript/extensions/hello-world/package-lock.json
@@ -0,0 +1,75 @@
+{
+ "name": "hello-world",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "hello-world",
+ "version": "1.0.0",
+ "license": "ISC",
+ "devDependencies": {
+ "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2",
+ "@higress/wasm-assemblyscript": "^0.0.3",
+ "assemblyscript": "^0.27.29",
+ "assemblyscript-json": "^1.1.0"
+ }
+ },
+ "node_modules/@higress/proxy-wasm-assemblyscript-sdk": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmmirror.com/@higress/proxy-wasm-assemblyscript-sdk/-/proxy-wasm-assemblyscript-sdk-0.0.2.tgz",
+ "integrity": "sha512-0J1tFJMTE6o37JpGJBLq0wc5kBC/fpbISrP+KFb4bAEeshu6daXzD2P3bAfJXmW+oZdY0WGptTGXWx8pf9Fk+g==",
+ "dev": true
+ },
+ "node_modules/@higress/wasm-assemblyscript": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmmirror.com/@higress/wasm-assemblyscript/-/wasm-assemblyscript-0.0.3.tgz",
+ "integrity": "sha512-D9hTvjAt54WoedNBsYAp9q/mDPWOO9yoGY7yG7Gkgp3KB7O5lHEEu2T6V8K14DpfC8ObSP26EhBcJ6G70JjODg==",
+ "dev": true
+ },
+ "node_modules/assemblyscript": {
+ "version": "0.27.29",
+ "resolved": "https://registry.npmmirror.com/assemblyscript/-/assemblyscript-0.27.29.tgz",
+ "integrity": "sha512-pH6udb7aE2F0t6cTh+0uCepmucykhMnAmm7k0kkAU3SY7LvpIngEBZWM6p5VCguu4EpmKGwEuZpZbEXzJ/frHQ==",
+ "dev": true,
+ "dependencies": {
+ "binaryen": "116.0.0-nightly.20240114",
+ "long": "^5.2.1"
+ },
+ "bin": {
+ "asc": "bin/asc.js",
+ "asinit": "bin/asinit.js"
+ },
+ "engines": {
+ "node": ">=16",
+ "npm": ">=7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/assemblyscript"
+ }
+ },
+ "node_modules/assemblyscript-json": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/assemblyscript-json/-/assemblyscript-json-1.1.0.tgz",
+ "integrity": "sha512-UbE8ts8csTWQgd5TnSPN7MRV9NveuHv1bVnKmDLoo/tzjqxkmsZb3lu59Uk8H7SGoqdkDSEE049alx/nHnSdFw==",
+ "dev": true
+ },
+ "node_modules/binaryen": {
+ "version": "116.0.0-nightly.20240114",
+ "resolved": "https://registry.npmmirror.com/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz",
+ "integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==",
+ "dev": true,
+ "bin": {
+ "wasm-opt": "bin/wasm-opt",
+ "wasm2js": "bin/wasm2js"
+ }
+ },
+ "node_modules/long": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmmirror.com/long/-/long-5.2.3.tgz",
+ "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==",
+ "dev": true
+ }
+ }
+}
diff --git a/plugins/wasm-assemblyscript/extensions/hello-world/package.json b/plugins/wasm-assemblyscript/extensions/hello-world/package.json
new file mode 100644
index 0000000000..41d919d031
--- /dev/null
+++ b/plugins/wasm-assemblyscript/extensions/hello-world/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "hello-world",
+ "version": "1.0.0",
+ "main": "index.js",
+ "scripts": {
+ "test": "node tests",
+ "asbuild:debug": "asc assembly/index.ts --target debug",
+ "asbuild:release": "asc assembly/index.ts --target release",
+ "asbuild": "npm run asbuild:debug && npm run asbuild:release",
+ "start": "npx serve ."
+ },
+ "author": "",
+ "license": "ISC",
+ "description": "",
+ "devDependencies": {
+ "assemblyscript": "^0.27.29",
+ "assemblyscript-json": "^1.1.0",
+ "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2",
+ "@higress/wasm-assemblyscript": "^0.0.3"
+ },
+ "type": "module",
+ "exports": {
+ ".": {
+ "import": "./build/release.js",
+ "types": "./build/release.d.ts"
+ }
+ }
+}
\ No newline at end of file
diff --git a/plugins/wasm-assemblyscript/package-lock.json b/plugins/wasm-assemblyscript/package-lock.json
new file mode 100644
index 0000000000..e94af779b9
--- /dev/null
+++ b/plugins/wasm-assemblyscript/package-lock.json
@@ -0,0 +1,75 @@
+{
+ "name": "@higress/wasm-assemblyscript",
+ "version": "0.0.4",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@higress/wasm-assemblyscript",
+ "version": "0.0.3",
+ "license": "Apache-2.0",
+ "devDependencies": {
+ "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2",
+ "as-uuid": "^0.0.4",
+ "assemblyscript": "^0.27.29",
+ "assemblyscript-json": "^1.1.0"
+ }
+ },
+ "node_modules/@higress/proxy-wasm-assemblyscript-sdk": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmmirror.com/@higress/proxy-wasm-assemblyscript-sdk/-/proxy-wasm-assemblyscript-sdk-0.0.2.tgz",
+ "integrity": "sha512-0J1tFJMTE6o37JpGJBLq0wc5kBC/fpbISrP+KFb4bAEeshu6daXzD2P3bAfJXmW+oZdY0WGptTGXWx8pf9Fk+g==",
+ "dev": true
+ },
+ "node_modules/as-uuid": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmmirror.com/as-uuid/-/as-uuid-0.0.4.tgz",
+ "integrity": "sha512-ZHNv0ETSzg5ZD0IWWJVyip/73LWtrWeMmvRi+16xbkpU/nZ0O8EegvgS7bgZ5xRqrUbc2NqZqHOWMOtPqbLrhg==",
+ "dev": true
+ },
+ "node_modules/assemblyscript": {
+ "version": "0.27.29",
+ "resolved": "https://registry.npmmirror.com/assemblyscript/-/assemblyscript-0.27.29.tgz",
+ "integrity": "sha512-pH6udb7aE2F0t6cTh+0uCepmucykhMnAmm7k0kkAU3SY7LvpIngEBZWM6p5VCguu4EpmKGwEuZpZbEXzJ/frHQ==",
+ "dev": true,
+ "dependencies": {
+ "binaryen": "116.0.0-nightly.20240114",
+ "long": "^5.2.1"
+ },
+ "bin": {
+ "asc": "bin/asc.js",
+ "asinit": "bin/asinit.js"
+ },
+ "engines": {
+ "node": ">=16",
+ "npm": ">=7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/assemblyscript"
+ }
+ },
+ "node_modules/assemblyscript-json": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/assemblyscript-json/-/assemblyscript-json-1.1.0.tgz",
+ "integrity": "sha512-UbE8ts8csTWQgd5TnSPN7MRV9NveuHv1bVnKmDLoo/tzjqxkmsZb3lu59Uk8H7SGoqdkDSEE049alx/nHnSdFw==",
+ "dev": true
+ },
+ "node_modules/binaryen": {
+ "version": "116.0.0-nightly.20240114",
+ "resolved": "https://registry.npmmirror.com/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz",
+ "integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==",
+ "dev": true,
+ "bin": {
+ "wasm-opt": "bin/wasm-opt",
+ "wasm2js": "bin/wasm2js"
+ }
+ },
+ "node_modules/long": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmmirror.com/long/-/long-5.2.3.tgz",
+ "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==",
+ "dev": true
+ }
+ }
+}
diff --git a/plugins/wasm-assemblyscript/package.json b/plugins/wasm-assemblyscript/package.json
new file mode 100644
index 0000000000..b73751460e
--- /dev/null
+++ b/plugins/wasm-assemblyscript/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@higress/wasm-assemblyscript",
+ "version": "0.0.4",
+ "main": "assembly/index.ts",
+ "scripts": {
+ "test": "node tests",
+ "asbuild:debug": "asc assembly/index.ts --target debug",
+ "asbuild:release": "asc assembly/index.ts --target release",
+ "asbuild": "npm run asbuild:debug && npm run asbuild:release",
+ "start": "npx serve ."
+ },
+ "author": "jingze.dai",
+ "license": "Apache-2.0",
+ "description": "",
+ "devDependencies": {
+ "assemblyscript": "^0.27.29",
+ "as-uuid": "^0.0.4",
+ "assemblyscript-json": "^1.1.0",
+ "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2"
+ },
+ "type": "module",
+ "exports": {
+ ".": {
+ "import": "./build/release.js",
+ "types": "./build/release.d.ts"
+ }
+ },
+ "files": [
+ "/assembly",
+ "package-lock.json",
+ "index.js"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/Jing-ze/wasm-assemblyscript.git"
+ }
+}
diff --git a/plugins/wasm-go/extensions/ai-quota/README.md b/plugins/wasm-go/extensions/ai-quota/README.md
new file mode 100644
index 0000000000..11ddf80359
--- /dev/null
+++ b/plugins/wasm-go/extensions/ai-quota/README.md
@@ -0,0 +1,58 @@
+# 功能说明
+
+`ai-qutoa` 插件实现给特定 consumer 根据分配固定的 quota 进行 quota 策略限流,同时支持 quota 管理能力,包括查询 quota 、刷新 quota、增减 quota。
+
+`ai-quota` 插件需要配合 认证插件比如 `key-auth`、`jwt-auth` 等插件获取认证身份的 consumer 名称,同时需要配合 `ai-statatistics` 插件获取 AI Token 统计信息。
+
+# 配置说明
+
+| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
+|--------------------|-----------------|--------------------------------------| ---- |--------------------------------------------|
+| `redis_key_prefix` | string | 选填 | chat_quota: | qutoa redis key 前缀 |
+| `admin_consumer` | string | 必填 | | 管理 quota 管理身份的 consumer 名称 |
+| `admin_path` | string | 选填 | /quota | 管理 quota 请求 path 前缀 |
+| `redis` | object | 是 | | redis相关配置 |
+
+`redis`中每一项的配置字段说明
+
+| 配置项 | 类型 | 必填 | 默认值 | 说明 |
+| ------------ | ------ | ---- | ---------------------------------------------------------- | --------------------------- |
+| service_name | string | 必填 | - | redis 服务名称,带服务类型的完整 FQDN 名称,例如 my-redis.dns、redis.my-ns.svc.cluster.local |
+| service_port | int | 否 | 服务类型为固定地址(static service)默认值为80,其他为6379 | 输入redis服务的服务端口 |
+| username | string | 否 | - | redis用户名 |
+| password | string | 否 | - | redis密码 |
+| timeout | int | 否 | 1000 | redis连接超时时间,单位毫秒 |
+
+
+
+# 配置示例
+
+## 识别请求参数 apikey,进行区别限流
+```yaml
+redis_key_prefix: "chat_quota:"
+admin_consumer: consumer3
+admin_path: /quota
+redis:
+ service_name: redis-service.default.svc.cluster.local
+ service_port: 6379
+ timeout: 2000
+```
+
+
+## 刷新 quota
+
+如果当前请求 url 的后缀符合 admin_path,例如插件在 example.com/v1/chat/completions 这个路由上生效,那么更新 quota 可以通过
+curl https://example.com/v1/chat/completions/quota/refresh -H "Authorization: Bearer credential3" -d "consumer=consumer1"a=10000"
+
+Redis 中 key 为 chat_quota:consumer1 的值就会被刷新为 10000
+
+## 查询 quota
+
+查询特定用户的 quota 可以通过 curl https://example.com/v1/chat/completions/quota?consumer=consumer1 -H "Authorization: Bearer credential3"
+将返回: {"quota": 10000, "consumer": "consumer1"}
+
+## 增减 quota
+
+增减特定用户的 quota 可以通过 curl https://example.com/v1/chat/completions/quota/delta -d "consumer=consumer1&value=100" -H "Authorization: Bearer credential3"
+这样 Redis 中 Key 为 chat_quota:consumer1 的值就会增加100,可以支持负数,则减去对应值。
+
diff --git a/plugins/wasm-go/extensions/ai-quota/go.mod b/plugins/wasm-go/extensions/ai-quota/go.mod
new file mode 100644
index 0000000000..ec77e402e4
--- /dev/null
+++ b/plugins/wasm-go/extensions/ai-quota/go.mod
@@ -0,0 +1,20 @@
+module github.com/alibaba/higress/plugins/wasm-go/extensions/ai-quota
+
+go 1.19
+
+//replace github.com/alibaba/higress/plugins/wasm-go => ../..
+
+require (
+ github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240808022948-34f5722d93de
+ github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f
+ github.com/tidwall/gjson v1.17.3
+ github.com/tidwall/resp v0.1.1
+)
+
+require (
+ github.com/google/uuid v1.3.0 // indirect
+ github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect
+ github.com/magefile/mage v1.14.0 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.0 // indirect
+)
diff --git a/plugins/wasm-go/extensions/ai-quota/go.sum b/plugins/wasm-go/extensions/ai-quota/go.sum
new file mode 100644
index 0000000000..996d474d43
--- /dev/null
+++ b/plugins/wasm-go/extensions/ai-quota/go.sum
@@ -0,0 +1,22 @@
+github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240808022948-34f5722d93de h1:lDLqj7Hw41ox8VdsP7oCTPhjPa3+QJUCKApcLh2a45Y=
+github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240808022948-34f5722d93de/go.mod h1:359don/ahMxpfeLMzr29Cjwcu8IywTTDUzWlBPRNLHw=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
+github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew=
+github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg=
+github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
+github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
+github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
+github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
+github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/plugins/wasm-go/extensions/ai-quota/main.go b/plugins/wasm-go/extensions/ai-quota/main.go
new file mode 100644
index 0000000000..8d6e57dc45
--- /dev/null
+++ b/plugins/wasm-go/extensions/ai-quota/main.go
@@ -0,0 +1,399 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/alibaba/higress/plugins/wasm-go/extensions/ai-quota/util"
+ "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
+ "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"
+)
+
+const (
+ pluginName = "ai-quota"
+)
+
+type ChatMode string
+
+const (
+ ChatModeCompletion ChatMode = "completion"
+ ChatModeAdmin ChatMode = "admin"
+ ChatModeNone ChatMode = "none"
+)
+
+type AdminMode string
+
+const (
+ AdminModeRefresh AdminMode = "refresh"
+ AdminModeQuery AdminMode = "query"
+ AdminModeDelta AdminMode = "delta"
+ AdminModeNone AdminMode = "none"
+)
+
+func main() {
+ wrapper.SetCtx(
+ pluginName,
+ wrapper.ParseConfigBy(parseConfig),
+ wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
+ wrapper.ProcessRequestBodyBy(onHttpRequestBody),
+ wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody),
+ )
+}
+
+type QuotaConfig struct {
+ redisInfo RedisInfo `yaml:"redis"`
+ RedisKeyPrefix string `yaml:"redis_key_prefix"`
+ AdminConsumer string `yaml:"admin_consumer"`
+ AdminPath string `yaml:"admin_path"`
+ credential2Name map[string]string `yaml:"-"`
+ redisClient wrapper.RedisClient
+}
+
+type Consumer struct {
+ Name string `yaml:"name"`
+ Credential string `yaml:"credential"`
+}
+
+type RedisInfo struct {
+ ServiceName string `required:"true" yaml:"service_name" json:"service_name"`
+ ServicePort int `required:"false" yaml:"service_port" json:"service_port"`
+ Username string `required:"false" yaml:"username" json:"username"`
+ Password string `required:"false" yaml:"password" json:"password"`
+ Timeout int `required:"false" yaml:"timeout" json:"timeout"`
+}
+
+func parseConfig(json gjson.Result, config *QuotaConfig, log wrapper.Log) error {
+ log.Debugf("parse config()")
+ // admin
+ config.AdminPath = json.Get("admin_path").String()
+ config.AdminConsumer = json.Get("admin_consumer").String()
+ if config.AdminPath == "" {
+ config.AdminPath = "/quota"
+ }
+ if config.AdminConsumer == "" {
+ return errors.New("missing admin_consumer in config")
+ }
+ // Redis
+ config.RedisKeyPrefix = json.Get("redis_key_prefix").String()
+ if config.RedisKeyPrefix == "" {
+ config.RedisKeyPrefix = "chat_quota:"
+ }
+ redisConfig := json.Get("redis")
+ if !redisConfig.Exists() {
+ return errors.New("missing redis in config")
+ }
+ serviceName := redisConfig.Get("service_name").String()
+ if serviceName == "" {
+ return errors.New("redis service name must not be empty")
+ }
+ servicePort := int(redisConfig.Get("service_port").Int())
+ if servicePort == 0 {
+ if strings.HasSuffix(serviceName, ".static") {
+ // use default logic port which is 80 for static service
+ servicePort = 80
+ } else {
+ servicePort = 6379
+ }
+ }
+ username := redisConfig.Get("username").String()
+ password := redisConfig.Get("password").String()
+ timeout := int(redisConfig.Get("timeout").Int())
+ if timeout == 0 {
+ timeout = 1000
+ }
+ config.redisInfo.ServiceName = serviceName
+ config.redisInfo.ServicePort = servicePort
+ config.redisInfo.Username = username
+ config.redisInfo.Password = password
+ config.redisInfo.Timeout = timeout
+ config.redisClient = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{
+ FQDN: serviceName,
+ Port: int64(servicePort),
+ })
+
+ return config.redisClient.Init(username, password, int64(timeout))
+}
+
+func onHttpRequestHeaders(context wrapper.HttpContext, config QuotaConfig, log wrapper.Log) types.Action {
+ log.Debugf("onHttpRequestHeaders()")
+ // get tokens
+ consumer, err := proxywasm.GetHttpRequestHeader("x-mse-consumer")
+ if err != nil {
+ return deniedNoKeyAuthData()
+ }
+ if consumer == "" {
+ return deniedUnauthorizedConsumer()
+ }
+
+ rawPath := context.Path()
+ path, _ := url.Parse(rawPath)
+ chatMode, adminMode := getOperationMode(path.Path, config.AdminPath, log)
+ context.SetContext("chatMode", chatMode)
+ context.SetContext("adminMode", adminMode)
+ context.SetContext("consumer", consumer)
+ log.Debugf("chatMode:%s, adminMode:%s, consumer:%s", chatMode, adminMode, consumer)
+ if chatMode == ChatModeNone {
+ return types.ActionContinue
+ }
+ if chatMode == ChatModeAdmin {
+ // query quota
+ if adminMode == AdminModeQuery {
+ return queryQuota(context, config, consumer, path, log)
+ }
+ if adminMode == AdminModeRefresh || adminMode == AdminModeDelta {
+ context.BufferRequestBody()
+ return types.HeaderStopIteration
+ }
+ return types.ActionContinue
+ }
+
+ // there is no need to read request body when it is on chat completion mode
+ context.DontReadRequestBody()
+ // check quota here
+ config.redisClient.Get(config.RedisKeyPrefix+consumer, func(response resp.Value) {
+ isDenied := false
+ if err := response.Error(); err != nil {
+ isDenied = true
+ }
+ if response.IsNull() {
+ isDenied = true
+ }
+ if response.Integer() <= 0 {
+ isDenied = true
+ }
+ log.Debugf("get consumer:%s quota:%d isDenied:%t", consumer, response.Integer(), isDenied)
+ if isDenied {
+ util.SendResponse(http.StatusForbidden, "ai-quota.noquota", "text/plain", "Request denied by ai quota check, No quota left")
+ return
+ }
+ proxywasm.ResumeHttpRequest()
+ })
+ return types.HeaderStopAllIterationAndWatermark
+}
+
+func onHttpRequestBody(ctx wrapper.HttpContext, config QuotaConfig, body []byte, log wrapper.Log) types.Action {
+ log.Debugf("onHttpRequestBody()")
+ chatMode, ok := ctx.GetContext("chatMode").(ChatMode)
+ if !ok {
+ return types.ActionContinue
+ }
+ if chatMode == ChatModeNone || chatMode == ChatModeCompletion {
+ return types.ActionContinue
+ }
+ adminMode, ok := ctx.GetContext("adminMode").(AdminMode)
+ if !ok {
+ return types.ActionContinue
+ }
+ adminConsumer, ok := ctx.GetContext("consumer").(string)
+ if !ok {
+ return types.ActionContinue
+ }
+
+ if adminMode == AdminModeRefresh {
+ return refreshQuota(ctx, config, adminConsumer, string(body), log)
+ }
+ if adminMode == AdminModeDelta {
+ return deltaQuota(ctx, config, adminConsumer, string(body), log)
+ }
+
+ return types.ActionContinue
+}
+
+func onHttpStreamingResponseBody(ctx wrapper.HttpContext, config QuotaConfig, data []byte, endOfStream bool, log wrapper.Log) []byte {
+ chatMode, ok := ctx.GetContext("chatMode").(ChatMode)
+ if !ok {
+ return data
+ }
+ if chatMode == ChatModeNone || chatMode == ChatModeAdmin {
+ return data
+ }
+ // chat completion mode
+ if !endOfStream {
+ return data
+ }
+ inputTokenStr, err := proxywasm.GetProperty([]string{"filter_state", "wasm.input_token"})
+ if err != nil {
+ return data
+ }
+ outputTokenStr, err := proxywasm.GetProperty([]string{"filter_state", "wasm.output_token"})
+ if err != nil {
+ return data
+ }
+ inputToken, err := strconv.Atoi(string(inputTokenStr))
+ if err != nil {
+ return data
+ }
+ outputToken, err := strconv.Atoi(string(outputTokenStr))
+ if err != nil {
+ return data
+ }
+ consumer, ok := ctx.GetContext("consumer").(string)
+ if ok {
+ totalToken := int(inputToken + outputToken)
+ log.Debugf("update consumer:%s, totalToken:%d", consumer, totalToken)
+ config.redisClient.DecrBy(config.RedisKeyPrefix+consumer, totalToken, nil)
+ }
+ return data
+}
+
+func deniedNoKeyAuthData() types.Action {
+ util.SendResponse(http.StatusUnauthorized, "ai-quota.no_key", "text/plain", "Request denied by ai quota check. No Key Authentication information found.")
+ return types.ActionContinue
+}
+
+func deniedUnauthorizedConsumer() types.Action {
+ util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized consumer.")
+ return types.ActionContinue
+}
+
+func getOperationMode(path string, adminPath string, log wrapper.Log) (ChatMode, AdminMode) {
+ fullAdminPath := "/v1/chat/completions" + adminPath
+ if strings.HasSuffix(path, fullAdminPath+"/refresh") {
+ return ChatModeAdmin, AdminModeRefresh
+ }
+ if strings.HasSuffix(path, fullAdminPath+"/delta") {
+ return ChatModeAdmin, AdminModeDelta
+ }
+ if strings.HasSuffix(path, fullAdminPath) {
+ return ChatModeAdmin, AdminModeQuery
+ }
+ if strings.HasSuffix(path, "/v1/chat/completions") {
+ return ChatModeCompletion, AdminModeNone
+ }
+ return ChatModeNone, AdminModeNone
+}
+
+func refreshQuota(ctx wrapper.HttpContext, config QuotaConfig, adminConsumer string, body string, log wrapper.Log) types.Action {
+ // check consumer
+ if adminConsumer != config.AdminConsumer {
+ util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized admin consumer.")
+ return types.ActionContinue
+ }
+
+ queryValues, _ := url.ParseQuery(body)
+ values := make(map[string]string, len(queryValues))
+ for k, v := range queryValues {
+ values[k] = v[0]
+ }
+ queryConsumer := values["consumer"]
+ quota, err := strconv.Atoi(values["quota"])
+ if queryConsumer == "" || err != nil {
+ util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. consumer can't be empty and quota must be integer.")
+ return types.ActionContinue
+ }
+ err2 := config.redisClient.Set(config.RedisKeyPrefix+queryConsumer, quota, func(response resp.Value) {
+ log.Debugf("Redis set key = %s quota = %d", config.RedisKeyPrefix+queryConsumer, quota)
+ if err := response.Error(); err != nil {
+ util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
+ return
+ }
+ util.SendResponse(http.StatusOK, "ai-quota.refreshquota", "text/plain", "refresh quota successful")
+ })
+
+ if err2 != nil {
+ util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
+ return types.ActionContinue
+ }
+
+ return types.ActionPause
+}
+func queryQuota(ctx wrapper.HttpContext, config QuotaConfig, adminConsumer string, url *url.URL, log wrapper.Log) types.Action {
+ // check consumer
+ if adminConsumer != config.AdminConsumer {
+ util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized admin consumer.")
+ return types.ActionContinue
+ }
+ // check url
+ queryValues := url.Query()
+ values := make(map[string]string, len(queryValues))
+ for k, v := range queryValues {
+ values[k] = v[0]
+ }
+ if values["consumer"] == "" {
+ util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. consumer can't be empty.")
+ return types.ActionContinue
+ }
+ queryConsumer := values["consumer"]
+ err := config.redisClient.Get(config.RedisKeyPrefix+queryConsumer, func(response resp.Value) {
+ quota := 0
+ if err := response.Error(); err != nil {
+ util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
+ return
+ } else if response.IsNull() {
+ quota = 0
+ } else {
+ quota = response.Integer()
+ }
+ result := struct {
+ Consumer string `json:"consumer"`
+ Quota int `json:"quota"`
+ }{
+ Consumer: queryConsumer,
+ Quota: quota,
+ }
+ body, _ := json.Marshal(result)
+ util.SendResponse(http.StatusOK, "ai-quota.queryquota", "application/json", string(body))
+ })
+ if err != nil {
+ util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
+ return types.ActionContinue
+ }
+ return types.ActionPause
+}
+func deltaQuota(ctx wrapper.HttpContext, config QuotaConfig, adminConsumer string, body string, log wrapper.Log) types.Action {
+ // check consumer
+ if adminConsumer != config.AdminConsumer {
+ util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized admin consumer.")
+ return types.ActionContinue
+ }
+
+ queryValues, _ := url.ParseQuery(body)
+ values := make(map[string]string, len(queryValues))
+ for k, v := range queryValues {
+ values[k] = v[0]
+ }
+ queryConsumer := values["consumer"]
+ value, err := strconv.Atoi(values["value"])
+ if queryConsumer == "" || err != nil {
+ util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. consumer can't be empty and value must be integer.")
+ return types.ActionContinue
+ }
+
+ if value >= 0 {
+ err := config.redisClient.IncrBy(config.RedisKeyPrefix+queryConsumer, value, func(response resp.Value) {
+ log.Debugf("Redis Incr key = %s value = %d", config.RedisKeyPrefix+queryConsumer, value)
+ if err := response.Error(); err != nil {
+ util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
+ return
+ }
+ util.SendResponse(http.StatusOK, "ai-quota.deltaquota", "text/plain", "delta quota successful")
+ })
+ if err != nil {
+ util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
+ return types.ActionContinue
+ }
+ } else {
+ err := config.redisClient.DecrBy(config.RedisKeyPrefix+queryConsumer, 0-value, func(response resp.Value) {
+ log.Debugf("Redis Decr key = %s value = %d", config.RedisKeyPrefix+queryConsumer, 0-value)
+ if err := response.Error(); err != nil {
+ util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
+ return
+ }
+ util.SendResponse(http.StatusOK, "ai-quota.deltaquota", "text/plain", "delta quota successful")
+ })
+ if err != nil {
+ util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
+ return types.ActionContinue
+ }
+ }
+
+ return types.ActionPause
+}
diff --git a/plugins/wasm-go/extensions/ai-quota/plugin.yaml b/plugins/wasm-go/extensions/ai-quota/plugin.yaml
new file mode 100644
index 0000000000..ac0cee4c5e
--- /dev/null
+++ b/plugins/wasm-go/extensions/ai-quota/plugin.yaml
@@ -0,0 +1,61 @@
+apiVersion: extensions.higress.io/v1alpha1
+kind: WasmPlugin
+metadata:
+ name: ai-quota
+ namespace: higress-system
+spec:
+ defaultConfig: {}
+ defaultConfigDisable: true
+ matchRules:
+ - config:
+ redis_key_prefix: "chat_quota:"
+ admin_consumer: consumer3
+ admin_path: /quota
+ redis:
+ service_name: redis-service.default.svc.cluster.local
+ service_port: 6379
+ timeout: 2000
+ configDisable: false
+ ingress:
+ - qwen
+ phase: UNSPECIFIED_PHASE
+ priority: 280
+ url: oci://registry.cn-hangzhou.aliyuncs.com/2456868764/ai-quota:1.0.8
+
+---
+apiVersion: extensions.higress.io/v1alpha1
+kind: WasmPlugin
+metadata:
+ name: ai-statistics
+ namespace: higress-system
+spec:
+ defaultConfig:
+ enable: true
+ defaultConfigDisable: false
+ phase: UNSPECIFIED_PHASE
+ priority: 250
+ url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/ai-statistics:1.0.0
+
+---
+apiVersion: extensions.higress.io/v1alpha1
+kind: WasmPlugin
+metadata:
+ name: wasm-keyauth
+ namespace: higress-system
+spec:
+ defaultConfig:
+ consumers:
+ - credential: "Bearer credential1"
+ name: consumer1
+ - credential: "Bearer credential2"
+ name: consumer2
+ - credential: "Bearer credential3"
+ name: consumer3
+ global_auth: true
+ keys:
+ - authorization
+ in_header: true
+ defaultConfigDisable: false
+ priority: 300
+ url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/key-auth:1.0.0
+ imagePullPolicy: Always
\ No newline at end of file
diff --git a/plugins/wasm-go/extensions/ai-quota/util/http.go b/plugins/wasm-go/extensions/ai-quota/util/http.go
new file mode 100644
index 0000000000..ae0e826478
--- /dev/null
+++ b/plugins/wasm-go/extensions/ai-quota/util/http.go
@@ -0,0 +1,22 @@
+package util
+
+import "github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
+
+const (
+ HeaderContentType = "Content-Type"
+
+ MimeTypeTextPlain = "text/plain"
+ MimeTypeApplicationJson = "application/json"
+)
+
+func SendResponse(statusCode uint32, statusCodeDetails string, contentType, body string) error {
+ return proxywasm.SendHttpResponseWithDetail(statusCode, statusCodeDetails, CreateHeaders(HeaderContentType, contentType), []byte(body), -1)
+}
+
+func CreateHeaders(kvs ...string) [][2]string {
+ headers := make([][2]string, 0, len(kvs)/2)
+ for i := 0; i < len(kvs); i += 2 {
+ headers = append(headers, [2]string{kvs[i], kvs[i+1]})
+ }
+ return headers
+}
diff --git a/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum b/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum
index 10e66f8ec0..57f5b02b2f 100644
--- a/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum
+++ b/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum
@@ -5,8 +5,7 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew=
-github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc h1:t2AT8zb6N/59Y78lyRWedVoVWHNRSCBh0oWCC+bluTQ=
-github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
+github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
diff --git a/plugins/wasm-go/extensions/cluster-key-rate-limit/main.go b/plugins/wasm-go/extensions/cluster-key-rate-limit/main.go
index 3ed1b839fa..ebf7fa58d2 100644
--- a/plugins/wasm-go/extensions/cluster-key-rate-limit/main.go
+++ b/plugins/wasm-go/extensions/cluster-key-rate-limit/main.go
@@ -16,15 +16,16 @@ package main
import (
"fmt"
+ "net"
+ "net/url"
+ "strconv"
+ "strings"
+
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"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"
- "net"
- "net/url"
- "strconv"
- "strings"
)
func main() {
@@ -88,12 +89,10 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config ClusterKeyRateLimitCon
args := []interface{}{configItem.count, configItem.timeWindow}
// 执行限流逻辑
err := config.redisClient.Eval(FixedWindowScript, 1, keys, args, func(response resp.Value) {
- defer func() {
- _ = proxywasm.ResumeHttpRequest()
- }()
resultArray := response.Array()
if len(resultArray) != 3 {
log.Errorf("redis response parse error, response: %v", response)
+ proxywasm.ResumeHttpRequest()
return
}
context := LimitContext{
@@ -106,6 +105,7 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config ClusterKeyRateLimitCon
rejected(config, context)
} else {
ctx.SetContext(LimitContextKey, context)
+ proxywasm.ResumeHttpRequest()
}
})
if err != nil {