diff --git a/.gitignore b/.gitignore
index e4afa55..991bf5c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@ obj/
*.pdb
src/Extism.Pdk/build/Extism.Pdk.MSBuild.xml
src/Extism.Pdk/build/publish/Extism.Pdk.MSBuild.xml
+
+.fake
\ No newline at end of file
diff --git a/README.md b/README.md
index 709db0d..7e4682e 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,11 @@
# .NET PDK
-This repo houses the .NET PDK for building Extism plugins in C# and F#.
+This library can be used to write Extism [Plug-ins](https://extism.org/docs/concepts/plug-in) in C# and F#.
> NOTE: This is an experimental PDK. We'd love to hear your feedback.
-Join the [Discord](https://discord.gg/5g3mtQRt) and chat with us!
-
## Prerequisites
+
1. .NET SDK 8: https://dotnet.microsoft.com/en-us/download/dotnet/8.0
2. WASI Workload:
```
@@ -14,7 +13,8 @@ dotnet workload install wasi-experimental
```
3. WASI SDK: https://github.com/WebAssembly/wasi-sdk/releases
-## Installation
+## Install
+
Create a new project and add this nuget package to your project:
```
@@ -23,7 +23,7 @@ cd MyPlugin
dotnet add package Extism.Pdk --prerelease
```
-Update your MyPlugin.csproj as follows:
+Update your `MyPlugin.csproj`/`MyPlugin.fsproj` as follows:
```xml
@@ -39,158 +39,313 @@ Update your MyPlugin.csproj as follows:
```
-Update your Program.cs:
+## Getting Started
+The goal of writing an Extism plug-in is to compile your C#/F# 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. Paste this into your Program.cs/Program.fs:
+
+C#:
```csharp
+using System;
+using System.Runtime.InteropServices;
+using System.Text.Json;
using Extism;
-Pdk.SetOutput("Hello from .NET!");
-```
+namespace MyPlugin;
+public class Functions
+{
+ public static void Main()
+ {
+ // Note: a `Main` method is required for the app to compile
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "greet")]
+ public static int Greet()
+ {
+ var name = Pdk.GetInputString();
+ var greeting = $"Hello, {name}!";
+ Pdk.SetOutput(greeting);
-Then compile your plugin to wasm:
+ return 0;
+ }
+}
```
-dotnet build
+
+F#:
+```fsharp
+module MyPlugin
+
+open System
+open System.Runtime.InteropServices
+open System.Text.Json
+open Extism
+
+[]
+let Greet () : int32 =
+ let name = Pdk.GetInputString()
+ let greeting = $"Hello, {name}!"
+ Pdk.SetOutput(greeting)
+ 0
+
+[]
+let Main args =
+ // Note: an `EntryPoint` function is required for the app to compile
+ 0
```
-This will create a `MyPlugin.wasm` file in `bin/Debug/net8.0/wasi-wasm/AppBundle`. Now, you can try out your plugin by using any of [Extism SDKs](https://extism.org/docs/category/integrate-into-your-codebase) or by using [Extism CLI](https://extism.org/docs/install):
+Some things to note about this code:
+1. The `[UnmanagedCallersOnly(EntryPoint = "greet")]` is required, this marks the `Greet` function as an export with the name `greet` that can be called by the host. `EntryPoint` is optional.
+1. We need a `Main` but it's unused. If you do want to use it, it's exported as a function called `_start`.
+1. Exports in the .NET PDK care coded to the raw ABI. You get parameters from the host by calling `Pdk.GetInput*` functions and you send returns back with the `Pdk.SetOutput` functions.
+1. An Extism export expects an `Int32` return code. `0` is success and `1` is a failure.
+Compile with this command:
```
-extism call .\bin\Debug\net8.0\wasi-wasm\AppBundle\MyPlugin.wasm _start --wasi
-Hello from .NET!
+dotnet build
```
-## Example Usage
-### Using Config, I/O, & Persisted Variables
-
-```csharp
-using System.Text;
-using Extism;
+This will create a `MyPlugin.wasm` file in `bin/Debug/net8.0/wasi-wasm/AppBundle`. Now, you can try out your plugin by using any of the [Extism SDKs](https://extism.org/docs/category/integrate-into-your-codebase) or by using [Extism CLI](https://extism.org/docs/install)'s `run` command:
+```
+extism call .\bin\Debug\net8.0\wasi-wasm\AppBundle\MyPlugin.wasm greet --input "Benjamin" --wasi
+# => Hello, Benjamin!
+```
-// Read input from the host
-var input = Pdk.GetInputString();
+> **Note:** Currently wasi must be provided for all .NET plug-ins even if they don't need system access.
-var count = 0;
+## More Exports: Error Handling
+Suppose we want to re-write our greeting function to never greet Benjamis. We can use `Pdk.SetError`:
-foreach (var c in input)
+C#:
+```csharp
+[UnmanagedCallersOnly(EntryPoint = "greet")]
+public static int Greet()
{
- if ("aeiouAEIOU".Contains(c))
+ var name = Pdk.GetInputString();
+ if (name == "Benjamin")
{
- count++;
+ Pdk.SetError("Sorry, we don't greet Benjamins!");
+ return 1;
}
-}
-// Read configuration values from the host
-if (!Pdk.TryGetConfig("thing", out var thing))
-{
- thing = "";
-}
+ var greeting = $"Hello, {name}!";
+ Pdk.SetOutput(greeting);
-// Read variables persisted by the host
-if (!Pdk.TryGetVar("total", out var totalBlock))
-{
- Pdk.Log(LogLevel.Info, "First time running, total is not set.");
+ return 0;
}
+```
-int.TryParse(Encoding.UTF8.GetString(totalBlock.ReadBytes()), out var total);
+F#:
+```fsharp
+[]
+let Greet () =
+ let name = Pdk.GetInputString()
+ if name = "Benjamin" then
+ Pdk.SetError("Sorry, we don't greet Benjamins!")
+ 1
+ else
+ let greeting = $"Hello, {name}!"
+ Pdk.SetOutput(greeting)
+ 0
+```
+
+Now when we try again:
+```
+extism call plugin.wasm greet --input="Benjamin" --wasi
+# => Error: Sorry, we don't greet Benjamins!
+echo $? # print last status code
+# => 1
+extism call plugin.wasm greet --input="Zach" --wasi
+# => Hello, Zach!
+echo $?
+# => 0
+```
-// Save total for next invocations
-total += count;
-totalBlock = Pdk.Allocate(total.ToString());
-Pdk.SetVar("total", totalBlock);
+We can also throw a normal .NET Exception:
+```
+var name = Pdk.GetInputString();
+if (name == "Benjamin")
+{
+ throw new ArgumentException("Sorry, we don't greet Benjamins!");
+}
+```
-// Set plugin output for host to read
-var output = $$"""{"count": {{count}}, "config": "{{thing}}", "total": "{{total}}" }""";
-Pdk.SetOutput(output);
+Now when we try again:
+```
+extism call plugin.wasm greet --input="Benjamin" --wasi
+# => Error: System.ArgumentException: Sorry, we don't greet Benjamins!
+ at MyPlugin.Functions.Greet()
```
-If you build this app and use Extism's .NET SDK to run it:
+## 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:
+
+C#:
```csharp
-var output = plugin.CallFunction("_start", Encoding.UTF8.GetBytes("Hello World!"));
-Console.WriteLine(Encoding.UTF8.GetString(output));
+public record Add(int A, int B);
+public record Sum(int Result);
-output = plugin.CallFunction("_start", Encoding.UTF8.GetBytes("Hello World!"));
-Console.WriteLine(Encoding.UTF8.GetString(output));
+[UnmanagedCallersOnly]
+public static int add()
+{
+ var inputJson = Pdk.GetInputString();
+ var options = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ var parameters = JsonSerializer.Deserialize(inputJson, options);
+ var sum = new Sum(parameters.A + parameters.B);
+ var outputJson = JsonSerializer.Serialize(sum, options);
+ Pdk.SetOutput(outputJson);
+ return 0;
+}
+```
+
+F#:
+```fsharp
+type Add = { A: int; B: int }
+type Sum = { Result: int }
+
+[]
+let add () =
+ let inputJson = Pdk.GetInputString()
+ let options = JsonSerializerOptions(PropertyNameCaseInsensitive = true)
+ let parameters = JsonSerializer.Deserialize(inputJson, options)
+
+ let sum = { Result = parameters.A + parameters.B }
+ let outputJson = JsonSerializer.Serialize(sum, options)
+
+ Pdk.SetOutput(outputJson)
+ 0
```
-You'll get this output:
```
-{"count": 3, "config": "", "total": "3" }
-{"count": 3, "config": "", "total": "6" }
+extism call .\bin\Debug\net8.0\wasi-wasm\AppBundle\readmeapp.wasm --wasi add --input='{"a": 20, "b": 21}'
+# => {"Result":41}
```
-Notice how total is 6 in the second output.
+## Configs
-The same example works in F# too!:
-```fsharp
-open System.Text
-open Extism
+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 Pdk.TryGetConfig:
-let countVowels (input: string) =
- input
- |> Seq.filter (fun c -> "aeiouAEIOU".Contains(c))
- |> Seq.length
-
-// Read configuration from the host
-let readConfig () =
- match Pdk.TryGetConfig("thing") with
- | true, thing -> thing
- | false, _ -> ""
-
-// Read a variable persisted by the host
-let readTotal () =
- match Pdk.TryGetVar("total") with
- | true, totalBlock ->
- Encoding.UTF8.GetString(totalBlock.ReadBytes()) |> int
- | false, _ ->
- Pdk.Log(LogLevel.Info, "First time running, total is not set.")
- 0
+C#:
+```csharp
+[UnmanagedCallersOnly(EntryPoint = "greet")]
+public static int Greet()
+{
+ if (!Pdk.TryGetConfig("user", out var user)) {
+ throw new InvalidOperationException("This plug-in requires a 'user' key in the config");
+ }
-// Write a variable persisted by the host
-let saveTotal total =
- let totalBlock = Pdk.Allocate(total.ToString())
- Pdk.SetVar("total", totalBlock)
+ var greeting = $"Hello, {user}!";
+ Pdk.SetOutput(greeting);
-[]
-let main args =
- let input = Pdk.GetInputString()
- let count = countVowels input
- let thing = readConfig()
- let total = readTotal() + count
- saveTotal total
-
- let output = sprintf """{"count": %d, "config": "%s", "total": "%d" }""" count thing total
- Pdk.SetOutput(output)
- 0
+ return 0;
+}
```
-### Making HTTP calls
-WASI doesn't allow guests to create socket connections yet, and thus they can't make HTTP calls. However, Extism provides convenient functions to make HTTP calls easy. If the host is configured to allow them, Extism plugins can make http calls by using `Pdk.SendRequest`:
+F#:
+```fsharp
+[]
+let Greet () =
+ match Pdk.TryGetConfig "user" with
+ | true, user ->
+ let greeting = $"Hello, {user}!"
+ Pdk.SetOutput(greeting)
+ 0
+ | false, _ ->
+ failwith "This plug-in requires a 'user' key in the config"
+```
+To test it, the [Extism CLI](https://github.com/extism/cli) has a --config option that lets you pass in key=value pairs:
+```
+extism call .\bin\Debug\net8.0\wasi-wasm\AppBundle\MyPlugin.wasm --wasi 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.
+
+C#:
```csharp
-var request = new HttpRequest("https://jsonplaceholder.typicode.com/todos/1")
+[UnmanagedCallersOnly]
+public static int count()
{
- Method = HttpMethod.GET
-};
-
-request.Headers.Add("some-header", "value");
+ int count = 0;
+ if (Pdk.TryGetVar("count", out var memoryBlock))
+ {
+ count = BitConverter.ToInt32(memoryBlock.ReadBytes());
+ }
+ count += 1;
+ Pdk.SetVar("count", BitConverter.GetBytes(count));
+ Pdk.SetOutput(count.ToString());
+ return 0;
+}
+```
-var response = Pdk.SendRequest(request);
+F#:
+```fsharp
+[]
+let count () =
+
+ let count =
+ match Pdk.TryGetVar "count" with
+ | true, buffer ->
+ BitConverter.ToInt32(buffer.ReadBytes())
+ | false, _ ->
+ 0
+
+ let count = count + 1
+
+ Pdk.SetVar("count", BitConverter.GetBytes(count))
+ Pdk.SetOutput(count.ToString())
+
+ 0
-Pdk.SetOutput(response.Body);
+From [Extism CLI](https://github.com/extism/cli):
+```
+extism call .\bin\Debug\net8.0\wasi-wasm\AppBundle\MyPlugin.wasm --wasi count --loop 3
+1
+2
+3
```
-```fsharp
-open Extism
+## HTTP
+Sometimes it is useful to let a plug-in make HTTP calls:
-let request = Extism.HttpRequest("https://jsonplaceholder.typicode.com/todos/1")
-request.Method = HttpMethod.GET
-request.Headers.Add("some-header", "value")
+C#:
+```csharp
+[UnmanagedCallersOnly]
+public static int http_get()
+{
+ // create an HTTP Request (withuot relying on WASI), set headers as needed
+ var request = new HttpRequest("https://jsonplaceholder.typicode.com/todos/1")
+ {
+ Method = HttpMethod.GET,
+ };
+ request.Headers.Add("some-name", "some-value");
+ request.Headers.Add("another", "again");
+ var response = Pdk.SendRequest(request);
+ Pdk.SetOutput(response.Body);
+ return 0;
+}
+```
-let response = Pdk.SendRequest(request)
-Pdk.SetOutput(response.Body)
+F#:
+```fsharp
+[]
+let http_get () =
+ let request = HttpRequest("https://jsonplaceholder.typicode.com/todos/1")
+ request.Headers.Add("some-name", "some-value")
+ request.Headers.Add("another", "again")
+
+ let response = Pdk.SendRequest(request)
+ Pdk.SetOutput(response.Body)
+
+ 0
```
-Output:
-```json
+From [Extism CLI](https://github.com/extism/cli):
+```
+extism call .\bin\Debug\net8.0\wasi-wasm\AppBundle\MyPlugin.wasm --wasi http_get --allow-host='*.typicode.com'
{
"userId": 1,
"id": 1,
@@ -198,55 +353,117 @@ Output:
"completed": false
}
```
-### Export functions
+> **NOTE**: `HttpClient` doesn't work in Wasm yet.
-If you want to export multiple functions from one plugin, you can use `UnmanagedCallersOnly` attribute:
+## Imports (Host Functions)
-```csharp
-[UnmanagedCallersOnly(EntryPoint = "count_vowels")]
-public static unsafe int CountVowels()
-{
- var text = Pdk.GetInputString();
+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 Go 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.
- Pdk.SetOutput(result);
- return 0;
+### A Simple Example
+
+Host functions have a similar interface as exports. You just need to declare them as extern on the top of your `Program.cs`/`Program.fs`. You only declare the interface as it is the host's responsibility to provide the implementation:
+
+C#:
+```csharp
+[DllImport("env", EntryPoint = "a_go_func")]
+public static extern ulong GoFunc(ulong offset);
+[UnmanagedCallersOnly]
+public static int hello_from_go()
+{
+ var message = "An argument to send to Go";
+ using var block = Pdk.Allocate(message);
+ var ptr = GoFunc(block.Offset);
+ var response = MemoryBlock.Find(ptr).ReadString();
+ Pdk.SetOutput(response);
+ return 0;
}
```
+F#:
```fsharp
-[]
-let CountVowels () =
- let buffer = Pdk.GetInput ()
-
- // ...
+[]
+extern uint64 GoFunc(uint64 offset)
+
+[]
+let hello_from_go () =
+ let message = "An argument to send to Go"
+ use block = Pdk.Allocate(message)
+
+ let ptr = GoFunc(block.Offset)
+ let response = MemoryBlock.Find ptr
+ Pdk.SetOutput(response)
+
+ 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.
+
+```go
+ctx := context.Background()
+config := extism.PluginConfig{
+ EnableWasi: true,
+}
- Pdk.SetOutput ($"""{{ "count": {count} }}""")
- 0
+go_func := extism.NewHostFunctionWithStack(
+ "a_go_func",
+ "env",
+ func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
+ input, err := p.ReadString(stack[0])
+ if err != nil {
+ panic(err)
+ }
+
+ fmt.Println("Hello from Go!")
+
+ offs, err := p.WriteString(input + "!")
+ if err != nil {
+ panic(err)
+ }
+
+ stack[0] = offs
+ },
+ []api.ValueType{api.ValueTypeI64},
+ []api.ValueType{api.ValueTypeI64},
+)
```
-Notes:
-1. If `UnmanagedCallersOnly.EntryPoint` is not specified, the method name will be used.
-2. Exported functions can only have this signature: () => int.
+Now when we load the plug-in we pass the host function:
+
+```go
+manifest := extism.Manifest{
+ Wasm: []extism.Wasm{
+ extism.WasmFile{
+ Path: "/path/to/plugin.wasm",
+ },
+ },
+}
-### Import functions
+plugin, err := extism.NewPlugin(ctx, manifest, config, []extism.HostFunction{go_func})
-The host might give guests additional capabilities. You can import functions from the host using `DllImport`:
+if err != nil {
+ fmt.Printf("Failed to initialize plugin: %v\n", err)
+ os.Exit(1)
+}
-```csharp
-[DllImport("host", EntryPoint = "is_vowel")]
-public static extern int IsVowel(int c);
+_, out, err := plugin.Call("hello_from_go", []byte("Hello, World!"))
+fmt.Println(string(out))
```
-```fsharp
-[]
-extern int IsVowel(int c)
+```bash
+go run .
+# => Hello from Go!
+# => An argument to send to Go!
+```
+### Optimize Size
+Normally, the .NET runtime is very conservative when trimming. This makes sure code doesn't break (when using reflection for example) but it also means large binary sizes. A hello world sample is about 20mb. To instruct the .NET compiler to be aggresive about trimming, you can try out these options:
+```xml
```
-Notes:
-1. Parameters and return types can only be one of these types: `SByte`, `Int16`, `Int32`, `Int64`, `Byte`, `UInt16`, `UInt32`, `UInt64`, `Float`, `Double`, and `Void`.
-2. If `DllImport.EntryPoint` is not specified, the name of the method will be used.
+### Reach Out!
-## Samples
-For more examples, check out the [samples](./samples) folder.
+Have a question or just want to drop in and say hi? [Hop on the Discord](https://extism.org/discord)!
\ No newline at end of file
diff --git a/src/Extism.Pdk/Interop.cs b/src/Extism.Pdk/Interop.cs
index c5ada5b..7da6e79 100644
--- a/src/Extism.Pdk/Interop.cs
+++ b/src/Extism.Pdk/Interop.cs
@@ -152,15 +152,13 @@ public static bool TryGetConfig(string key, [NotNullWhen(true)] out string value
var keyBlock = Allocate(key);
var offset = Native.extism_config_get(keyBlock.Offset);
- var valueBlock = MemoryBlock.Find(offset);
+ using var valueBlock = MemoryBlock.Find(offset);
if (offset == 0 || valueBlock.Length == 0)
{
return false;
}
- valueBlock.Free();
-
var bytes = valueBlock.ReadBytes();
value = Encoding.UTF8.GetString(bytes);
@@ -443,18 +441,29 @@ public HttpResponse(MemoryBlock memory, ushort status)
public ushort Status { get; set; }
///
- /// Frees up the body of the HTTP response.
+ /// Frees the current memory block.
///
public void Dispose()
{
- Body.Free();
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ private void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ // free managed resources
+ }
+
+ Body.Dispose();
}
}
///
/// A block of allocated memory.
///
-public struct MemoryBlock
+public class MemoryBlock : IDisposable
{
///
///
@@ -555,17 +564,6 @@ public string ReadString()
return Encoding.UTF8.GetString(bytes);
}
- ///
- /// Frees the current memory block.
- ///
- public void Free()
- {
- if (!IsEmpty)
- {
- Native.extism_free(Offset);
- }
- }
-
///
/// Finds a memory block based on its start address.
///
@@ -576,4 +574,26 @@ public static MemoryBlock Find(ulong offset)
var length = Native.extism_length(offset);
return new MemoryBlock(offset, length);
}
+
+ ///
+ /// Frees the current memory block.
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ private void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ // free managed resources
+ }
+
+ if (!IsEmpty)
+ {
+ Native.extism_free(Offset);
+ }
+ }
}