diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9dafa2..0dce929 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,9 @@ name: CI -on: [push, pull_request] +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] jobs: test-example: @@ -14,6 +18,9 @@ jobs: - name: Install Zig uses: goto-bus-stop/setup-zig@v2 + + - name: Check Zig Version + run: zig version - name: Compile example run: | @@ -21,11 +28,33 @@ jobs: - name: Test example run : | - TEST=$(extism call zig-out/bin/Basic\ example.wasm --input "this is a test" --set-config='{"thing": "1", "a": "b"}' --log-level=debug count_vowels | jq) + TEST=$(extism call zig-out/bin/basic-example.wasm --input "this is a test" --set-config='{"thing": "1", "a": "b"}' --log-level=debug count_vowels | jq) echo $TEST | grep '"count": 4' echo $TEST | grep '"config": "1"' echo $TEST | grep '"a": "this is var a"' - TEST=$(extism call zig-out/bin/Basic\ example.wasm make_http_request --allow-host '*') + TEST=$(extism call zig-out/bin/basic-example.wasm http_get --allow-host '*') echo $TEST echo $TEST | grep '"userId": 1' + + TEST=$(extism call zig-out/bin/basic-example.wasm greet --config user=Benjamin) + echo $TEST + echo $TEST | grep 'Hello, Benjamin!' + + TEST=$(extism call zig-out/bin/basic-example.wasm greet 2>&1 || true) + echo $TEST + echo $TEST | grep "Error: This plug-in requires a 'user' key in the config" + + TEST=$(extism call zig-out/bin/basic-example.wasm add --input='{"a": 20, "b": 21}') + echo $TEST | grep '{"sum":41}' + + TEST=$(extism call zig-out/bin/basic-example.wasm count --loop '3') + echo $TEST | grep '1' + echo $TEST | grep '2' + echo $TEST | grep '3' + + TEST=$(extism call zig-out/bin/basic-example.wasm log_stuff --log-level 'debug' 2>&1) + echo $TEST | grep 'An info log!' + echo $TEST | grep 'A debug log!' + echo $TEST | grep 'A warning log!' + echo $TEST | grep 'An error log!' diff --git a/README.md b/README.md index 9268a2a..3de3fac 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,386 @@ # Extism Zig PDK -For more information about the Zig PDK, please [visit the docs](https://extism.org/docs/write-a-plugin/zig-pdk). -Join the [Discord](https://discord.gg/cx3usBCWnc) and chat with us! +This library can be used to write [Extism Plug-ins](https://extism.org/docs/concepts/plug-in) in Zig. + +## Install + +Generate a `exe` project with Zig: + +```bash +mkdir my-plugin +cd ./my-plugin +zig init-exe +``` + +Add the library as a dependency: + +```bash +mkdir -p libs +cd libs +git clone https://github.com/extism/zig-pdk.git +``` + +Change your `build.zig` so that it references `extism-pdk`: + +```zig +const std = @import("std"); +const builtin = @import("builtin"); + +pub fn build(b: *std.Build) void { + comptime { + const current_zig = builtin.zig_version; + const min_zig = std.SemanticVersion.parse("0.12.0-dev.64+b835fd90c") catch unreachable; // std.json.ArrayHashMap + if (current_zig.order(min_zig) == .lt) { + @compileError(std.fmt.comptimePrint("Your Zig version v{} does not meet the minimum build requirement of v{}", .{ current_zig, min_zig })); + } + } + + const optimize = b.standardOptimizeOption(.{}); + const target = b.standardTargetOptions(.{ + .default_target = .{ .abi = .musl, .os_tag = .freestanding, .cpu_arch = .wasm32 }, + }); + + const pdk_module = b.addModule("extism-pdk", .{ + .source_file = .{ .path = "libs/zig-pdk/src/main.zig" }, + }); + + var basic_example = b.addExecutable(.{ + .name = "my-plugin", + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + basic_example.addModule("extism-pdk", pdk_module); + basic_example.rdynamic = true; + + b.installArtifact(basic_example); + const basic_example_step = b.step("my-plugin", "Build my-plugin"); + basic_example_step.dependOn(b.getInstallStep()); +} +``` + +## Getting Started + +The goal of writing an [Extism plug-in](https://extism.org/docs/concepts/plug-in) is to compile your Zig code to a Wasm module with exported functions that the host application can invoke. The first thing you should understand is creating an export. Let's write a simple program that exports a `greet` function which will take a name as a string and return a greeting string. Zig has excellent support for this through the `export` keyword: + +```zig +const std = @import("std"); +const extism_pdk = @import("extism-pdk"); +const Plugin = extism_pdk.Plugin; + +const allocator = std.heap.wasm_allocator; + +export fn greet() i32 { + const plugin = Plugin.init(allocator); + const name = plugin.getInput() catch unreachable; + defer allocator.free(name); + + const output = std.fmt.allocPrint(allocator, "Hello, {s}!", .{name}) catch unreachable; + plugin.output(output); + return 0; +} +``` + +Then run: +```sh +zig build +``` + +This will put your compiled wasm in `zig-out/bin`. +We can now test it using the [Extism CLI](https://github.com/extism/cli)'s `run` +command: + +```bash +extism call ./zig-out/bin/my-plugin.wasm greet --input "Benjamin" +# => Hello, Benjamin! +``` + +> **Note**: We also have a web-based, plug-in tester called the [Extism Playground](https://playground.extism.org/) + +### More Exports: Error Handling +Suppose want to re-write our greeting module to never greet Benjamins. We can use `Plugin.setError`: + +```zig +export fn greet() i32 { + const plugin = Plugin.init(allocator); + const name = plugin.getInput() catch unreachable; + defer allocator.free(name); + + if (std.mem.eql(u8, name, "Benjamin")) { + plugin.setError("Sorry, we don't greet Benjamins!"); + return 1; + } + + const output = std.fmt.allocPrint(allocator, "Hello, {s}!", .{name}) catch unreachable; + plugin.output(output); + return 0; +} +``` + +Now when we try again: + +```bash +extism call ./zig-out/bin/my-plugin.wasm greet --input="Benjamin" +# => Error: Sorry, we don't greet Benjamins! +echo $? # print last status code +# => 1 +extism call ./zig-out/bin/my-plugin.wasm greet --input="Zach" +# => Hello, Zach! +echo $? +# => 0 +``` + +### Json + +Extism export functions simply take bytes in and bytes out. Those can be whatever you want them to be. A common and simple way to get more complex types to and from the host is with json: + +```zig +export fn add() i32 { + const Add = struct { + a: i32, + b: i32, + }; + + const Sum = struct { + sum: i32, + }; + + const plugin = Plugin.init(allocator); + const input = plugin.getInput() catch unreachable; + defer allocator.free(input); + + const params = std.json.parseFromSlice(Add, allocator, input, std.json.ParseOptions{}) catch unreachable; + const sum = Sum{ .sum = params.value.a + params.value.b }; + + const output = std.json.stringifyAlloc(allocator, sum, std.json.StringifyOptions{}) catch unreachable; + plugin.output(output); + return 0; +} +``` + +```bash +extism call ./zig-out/bin/my-plugin.wasm add --input='{"a": 20, "b": 21}' +# => {"sum":41} +``` + +## Configs + +Configs are key-value pairs that can be passed in by the host when creating a +plug-in. These can be useful to statically configure the plug-in with some data that exists across every function call. Here is a trivial example using `Plugin.getConfig`: + +```zig +export fn greet() i32 { + const plugin = Plugin.init(allocator); + const user = plugin.getConfig("user") catch unreachable orelse { + plugin.setError("This plug-in requires a 'user' key in the config"); + return 1; + }; + + const output = std.fmt.allocPrint(allocator, "Hello, {s}!", .{user}) catch unreachable; + plugin.output(output); + return 0; +} +``` + +To test it, the [Extism CLI](https://github.com/extism/cli) has a `--config` option that lets you pass in `key=value` pairs: + +```bash +extism call ./zig-out/bin/my-plugin.wasm greet --config user=Benjamin +# => Hello, Benjamin! +``` + +## Variables + +Variables are another key-value mechanism but it's a mutable data store that +will persist across function calls. These variables will persist as long as the +host has loaded and not freed the plug-in. + +```zig +export fn count() i32 { + const plugin = Plugin.init(allocator); + const input = plugin.getInput() catch unreachable; + defer allocator.free(input); + + var c = plugin.getVarInt(i32, "count") catch unreachable orelse 0; + + c += 1; + + plugin.setVarInt(i32, "count", c) catch unreachable; + + const output = std.fmt.allocPrint(allocator, "{d}", .{c}) catch unreachable; + plugin.output(output); + return 0; +} +``` + +To test it, the [Extism CLI](https://github.com/extism/cli) has a `--loop` option that lets you pass call the same function multiple times: + +```sh +extism call ./zig-out/bin/my-plugin.wasm count --loop 3 +1 +2 +3 +``` + +> **Note**: Use the untyped variants `Plugin.setVar(self: Plugin, key: []const u8, value: []const u8)` and `Plugin.getVar(self: Plugin, key: []const u8) !?[]u8` to handle your own types. + +## Logging + +Because Wasm modules by default do not have access to the system, printing to stdout won't work (unless you use WASI). +Extism provides a simple logging function that allows you to use the host application to log without having to give the plug-in permission to make syscalls. + +```zig +export fn log_stuff() i32 { + const plugin = Plugin.init(allocator); + plugin.log(.Info, "An info log!"); + plugin.log(.Debug, "A debug log!"); + plugin.log(.Warn, "A warning log!"); + plugin.log(.Error, "An error log!"); + + return 0; +} +``` + +From [Extism CLI](https://github.com/extism/cli): + +```bash +extism call ./zig-out/bin/my-plugin.wasm log_stuff --log-level=debug +2023/11/22 14:00:26 Calling function : log_stuff +2023/11/22 14:00:26 An info log! +2023/11/22 14:00:26 A debug log! +2023/11/22 14:00:26 A warning log! +2023/11/22 14:00:26 An error log! +``` + +> *Note*: From the CLI you need to pass a level with `--log-level`. If you are running the plug-in in your own host using one of our SDKs, you need to make sure that you call `set_log_file` to `"stdout"` or some file location. + +## HTTP + +Sometimes it is useful to let a plug-in [make HTTP calls](https://pkg.go.dev/github.com/extism/go-pdk#HTTPRequest.Send). [See this example](example/http.go) + +```zig +export fn http_get() i32 { + const plugin = Plugin.init(allocator); + // create an HTTP request via Extism built-in function (doesn't require WASI) + var req = http.HttpRequest.init("GET", "https://jsonplaceholder.typicode.com/todos/1"); + defer req.deinit(allocator); + + // set headers on the request object + req.setHeader(allocator, "some-name", "some-value") catch unreachable; + req.setHeader(allocator, "another", "again") catch unreachable; + + // make the request and get the response back + const res = plugin.request(req, null) catch unreachable; + defer res.deinit(); + + // `outputMemory` provides a zero-copy way to write plugin data back to the host + plugin.outputMemory(res.memory); + + return 0; +} +``` + +By default, Extism modules cannot make HTTP requests unless you specify which hosts it can connect to. You can use `--alow-host` in the Extism CLI to set this: + +``` +extism call ./zig-out/bin/my-plugin.wasm http_get --allow-host='*.typicode.com' +# => { "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false } +``` + +## Imports (Host Functions) + +Like any other code module, Wasm not only let's you export functions to the outside world, you can +import them too. Host Functions allow a plug-in to import functions defined in the host. For example, +if you host application is written in Python, it can pass a Python function down to your Zig plug-in +where you can invoke it. + +This topic can get fairly complicated and we have not yet fully abstracted the Wasm knowledge you need +to do this correctly. So we recommend reading our [concept doc on Host Functions](https://extism.org/docs/concepts/host-functions) before you get started. + +### A Simple Example + +Host functions have a similar interface as exports. You just need to declare them as extern. You only declare the interface as it is the host's responsibility to provide the implementation: + +```zig +pub extern "extism:host/user" fn a_python_func(u64) u64; +``` + +We should be able to call this function as a normal Zig function. Note that we need to manually handle the pointer casting: + +```zig +export fn hello_from_python() i32 { + const plugin = Plugin.init(allocator); + + const msg = "An argument to send to Python"; + const mem = plugin.allocateBytes(msg); + defer mem.free(); + + const ptr = a_python_func(mem.offset); + const rmem = plugin.findMemory(ptr); + + const buffer = plugin.allocator.alloc(u8, @intCast(rmem.length)) catch unreachable; + rmem.load(buffer); + plugin.output(buffer); + + // OR, you can directly output the memory + // plugin.outputMemory(rmem); + + return 0; +} +``` + +### Testing it out + +We can't really test this from the Extism CLI as something must provide the implementation. So let's +write out the Python side here. Check out the [docs for Host SDKs](https://extism.org/docs/concepts/host-sdk) to implement a host function in a language of your choice. + +```python +from extism import host_fn, Function, ValType, Plugin + +@host_fn +def a_python_func(plugin, input_, output, _user_data): + # The plug-in is passing us a string + input_str = plugin.input_string(input_[0]) + + # just printing this out to prove we're in Python land + print("Hello from Python!") + + # let's just add "!" to the input string + # but you could imagine here we could add some + # applicaiton code like query or manipulate the database + # or our application APIs + input_str += "!" + + # set the new string as the return value to the plug-in + plugin.return_string(output[0], input_str) +``` + +Now when we load the plug-in we pass the host function: + +```python +functions = [ + Function( + "a_python_func", + [ValType.I64], + [ValType.I64], + a_python_func, + None + ) +] + +manifest = {"wasm": [{"path": "/path/to/plugin.wasm"}]} +plugin = Plugin(manifest, functions=functions, wasi=True) +result = plugin.call('hello_from_python').decode('utf-8') +print(result) +``` + +```bash +python3 app.py +# => Hello from Python! +# => An argument to send to Python! +``` + +### Reach Out! + +Have a question or just want to drop in and say hi? [Hop on the Discord](https://extism.org/discord)! diff --git a/build.zig b/build.zig index 687f29a..44a234e 100644 --- a/build.zig +++ b/build.zig @@ -20,7 +20,7 @@ pub fn build(b: *std.Build) void { }); var basic_example = b.addExecutable(.{ - .name = "Basic example", + .name = "basic-example", .root_source_file = .{ .path = "examples/basic.zig" }, .target = target, .optimize = optimize, diff --git a/examples/basic.zig b/examples/basic.zig index ae6b561..e98ff1e 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -19,16 +19,16 @@ export fn count_vowels() i32 { const input = plugin.getInput() catch unreachable; defer allocator.free(input); plugin.log(.Debug, "plugin input"); - var count: i32 = 0; + var vowelsCount: i32 = 0; for (input) |char| { switch (char) { - 'A', 'I', 'E', 'O', 'U', 'a', 'e', 'i', 'o', 'u' => count += 1, + 'A', 'I', 'E', 'O', 'U', 'a', 'e', 'i', 'o', 'u' => vowelsCount += 1, else => {}, } } // use persistent variables owned by a plugin instance (stored in-memory between function calls) - var var_a_optional = plugin.getVar("a") catch unreachable; + const var_a_optional = plugin.getVar("a") catch unreachable; plugin.log(.Debug, "plugin var get"); if (var_a_optional == null) { @@ -44,7 +44,7 @@ export fn count_vowels() i32 { const thing = plugin.getConfig("thing") catch unreachable orelse ""; plugin.log(.Debug, "plugin config get"); - const data = Output{ .count = count, .config = thing, .a = var_a }; + const data = Output{ .count = vowelsCount, .config = thing, .a = var_a }; const output = std.json.stringifyAlloc(allocator, data, .{}) catch unreachable; defer allocator.free(output); plugin.log(.Debug, "plugin json encoding"); @@ -56,7 +56,7 @@ export fn count_vowels() i32 { return 0; } -export fn make_http_request() i32 { +export fn http_get() i32 { const plugin = Plugin.init(allocator); // create an HTTP request via Extism built-in function (doesn't require WASI) var req = http.HttpRequest.init("GET", "https://jsonplaceholder.typicode.com/todos/1"); @@ -75,3 +75,63 @@ export fn make_http_request() i32 { return 0; } + +export fn greet() i32 { + const plugin = Plugin.init(allocator); + const user = plugin.getConfig("user") catch unreachable orelse { + plugin.setError("This plug-in requires a 'user' key in the config"); + return 1; + }; + + const output = std.fmt.allocPrint(allocator, "Hello, {s}!", .{user}) catch unreachable; + plugin.output(output); + return 0; +} + +export fn add() i32 { + const Add = struct { + a: i32, + b: i32, + }; + + const Sum = struct { + sum: i32, + }; + + const plugin = Plugin.init(allocator); + const input = plugin.getInput() catch unreachable; + defer allocator.free(input); + + const params = std.json.parseFromSlice(Add, allocator, input, std.json.ParseOptions{}) catch unreachable; + const sum = Sum{ .sum = params.value.a + params.value.b }; + + const output = std.json.stringifyAlloc(allocator, sum, std.json.StringifyOptions{}) catch unreachable; + plugin.output(output); + return 0; +} + +export fn count() i32 { + const plugin = Plugin.init(allocator); + const input = plugin.getInput() catch unreachable; + defer allocator.free(input); + + var c = plugin.getVarInt(i32, "count") catch unreachable orelse 0; + + c += 1; + + plugin.setVarInt(i32, "count", c) catch unreachable; + + const output = std.fmt.allocPrint(allocator, "{d}", .{c}) catch unreachable; + plugin.output(output); + return 0; +} + +export fn log_stuff() i32 { + const plugin = Plugin.init(allocator); + plugin.log(.Info, "An info log!"); + plugin.log(.Debug, "A debug log!"); + plugin.log(.Warn, "A warning log!"); + plugin.log(.Error, "An error log!"); + + return 0; +} diff --git a/src/Memory.zig b/src/Memory.zig index de19e72..cb94eb8 100644 --- a/src/Memory.zig +++ b/src/Memory.zig @@ -25,7 +25,7 @@ pub fn load(self: Self, buf: []u8) void { } const x = extism.load_u64(self.offset + @as(u64, i)); - std.mem.writeIntLittle(u64, buf[i..][0..8], x); + std.mem.writeInt(u64, buf[i..][0..8], x, std.builtin.Endian.little); i += 8; } } @@ -39,27 +39,12 @@ pub fn store(self: Self, buf: []const u8) void { i += 1; continue; } - const data = std.mem.readIntLittle(u64, buf[i..][0..8]); + const data = std.mem.readInt(u64, buf[i..][0..8], std.builtin.Endian.little); extism.store_u64(self.offset + @as(u64, i), data); i += 8; } } -pub fn allocate(length: usize) Self { - const c_len = @as(u64, length); - const offset = extism.alloc(c_len); - - return .{ .offset = @as(u64, offset), .length = @as(u64, c_len) }; -} - -pub fn allocateBytes(data: []const u8) Self { - const c_len = @as(u64, data.len); - const offset = extism.alloc(c_len); - const mem = init(offset, c_len); - mem.store(data); - return .{ .offset = offset, .length = c_len }; -} - pub fn free(self: Self) void { extism.free(self.offset); } diff --git a/src/http.zig b/src/http.zig index 4790321..7764ac9 100644 --- a/src/http.zig +++ b/src/http.zig @@ -7,7 +7,7 @@ pub const HttpResponse = struct { /// IMPORTANT: it's the caller's responsibility to free the returned string pub fn body(self: HttpResponse, allocator: std.mem.Allocator) ![]u8 { - var buf = try allocator.alloc(u8, @intCast(self.memory.length)); + const buf = try allocator.alloc(u8, @intCast(self.memory.length)); errdefer allocator.free(buf); self.memory.load(buf); return buf; diff --git a/src/main.zig b/src/main.zig index 3a79ed4..642874b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -28,12 +28,37 @@ pub const Plugin = struct { } const x = extism.input_load_u64(@as(u64, i)); - std.mem.writeIntLittle(u64, buf[i..][0..8], x); + std.mem.writeInt(u64, buf[i..][0..8], x, std.builtin.Endian.little); i += 8; } return buf; } + pub fn allocate(self: Plugin, length: usize) Memory { + _ = self; // to make the interface consistent + + const c_len = @as(u64, length); + const offset = extism.alloc(c_len); + + return Memory.init(offset, c_len); + } + + pub fn allocateBytes(self: Plugin, data: []const u8) Memory { + _ = self; // to make the interface consistent + const c_len = @as(u64, data.len); + const offset = extism.alloc(c_len); + const mem = Memory.init(offset, c_len); + mem.store(data); + return mem; + } + + pub fn findMemory(self: Plugin, offset: u64) Memory { + _ = self; // to make the interface consistent + + const length = extism.length(offset); + return Memory.init(offset, length); + } + pub fn outputMemory(self: Plugin, mem: Memory) void { _ = self; // to make the interface consistent extism.output_set(mem.offset, mem.length); @@ -49,9 +74,24 @@ pub const Plugin = struct { extism.output_set(offset, c_len); } + pub fn setErrorMemory(self: Plugin, mem: Memory) void { + _ = self; // to make the interface consistent + extism.error_set(mem.offset); + } + + pub fn setError(self: Plugin, data: []const u8) void { + _ = self; // to make the interface consistent + const c_len = @as(u64, data.len); + const offset = extism.alloc(c_len); + const memory = Memory.init(offset, c_len); + defer memory.free(); + memory.store(data); + extism.error_set(offset); + } + /// IMPORTANT: it's the caller's responsibility to free the returned string pub fn getConfig(self: Plugin, key: []const u8) !?[]u8 { - const key_mem = Memory.allocateBytes(key); + const key_mem = self.allocateBytes(key); defer key_mem.free(); const offset = extism.config_get(key_mem.offset); const c_len = extism.length(offset); @@ -77,14 +117,14 @@ pub const Plugin = struct { } pub fn log(self: Plugin, level: LogLevel, data: []const u8) void { - const mem = Memory.allocateBytes(data); + const mem = self.allocateBytes(data); defer mem.free(); self.logMemory(level, mem); } /// IMPORTANT: it's the caller's responsibility to free the returned string pub fn getVar(self: Plugin, key: []const u8) !?[]u8 { - const key_mem = Memory.allocateBytes(key); + const key_mem = self.allocateBytes(key); defer key_mem.free(); const offset = extism.var_get(key_mem.offset); const c_len = extism.length(offset); @@ -99,32 +139,48 @@ pub const Plugin = struct { return value; } + pub fn getVarInt(self: Plugin, comptime T: type, key: []const u8) !?T { + const result = try self.getVar(key); + + if (result) |buf| { + return std.mem.readPackedInt(T, buf, 0, .little); + } + + return null; + } + pub fn setVar(self: Plugin, key: []const u8, value: []const u8) void { - _ = self; // to make the interface consistent - const key_mem = Memory.allocateBytes(key); + const key_mem = self.allocateBytes(key); defer key_mem.free(); - const val_mem = Memory.allocateBytes(value); + const val_mem = self.allocateBytes(value); defer val_mem.free(); extism.var_set(key_mem.offset, val_mem.offset); } + pub fn setVarInt(self: Plugin, comptime T: type, key: []const u8, value: T) !void { + const buffer = try self.allocator.alloc(u8, @sizeOf(T)); + defer self.allocator.free(buffer); + std.mem.writePackedInt(T, buffer, 0, value, .little); + + self.setVar(key, buffer); + } + pub fn removeVar(self: Plugin, key: []const u8) void { - _ = self; // to make the interface consistent - const mem = Memory.allocateBytes(key); + const mem = self.allocateBytes(key); defer mem.free(); - extism.extism_var_set(mem.offset, 0); + extism.var_set(mem.offset, 0); } pub fn request(self: Plugin, http_request: http.HttpRequest, body: ?[]const u8) !http.HttpResponse { const json = try std.json.stringifyAlloc(self.allocator, http_request, .{ .emit_null_optional_fields = false }); defer self.allocator.free(json); - const req = Memory.allocateBytes(json); + const req = self.allocateBytes(json); defer req.free(); const req_body = b: { if (body) |bdy| { - break :b Memory.allocateBytes(bdy); + break :b self.allocateBytes(bdy); } else { - break :b Memory.allocate(0); + break :b self.allocate(0); } }; defer req_body.free();