Repository: grafbase/grafbase
Pull request #2975: gateway: WIT and host support for gRPC
Author: login: tomhoule
Created: 2025-04-04T08:38:45Z | Merged: 2025-04-07T14:32:24Z

Subscriptions implementation is left blank for now.

closes GB-8841


Diff:
diff --git a/Cargo.lock b/Cargo.lock
index de3da1011a..15e4569393 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3576,7 +3576,7 @@ dependencies = [
  "temp-env",
  "tempfile",
  "toml",
- "tonic",
+ "tonic 0.12.3",
  "tower-http",
  "url",
 ]
@@ -3993,7 +3993,7 @@ dependencies = [
  "tempfile",
  "thiserror 2.0.12",
  "toml",
- "tonic",
+ "tonic 0.12.3",
  "tracing",
  "tracing-opentelemetry",
  "tracing-subscriber",
@@ -6558,7 +6558,7 @@ dependencies = [
  "reqwest 0.12.15",
  "thiserror 2.0.12",
  "tokio",
- "tonic",
+ "tonic 0.12.3",
  "tracing",
 ]
 
@@ -6571,7 +6571,7 @@ dependencies = [
  "opentelemetry",
  "opentelemetry_sdk",
  "prost",
- "tonic",
+ "tonic 0.12.3",
 ]
 
 [[package]]
@@ -9913,6 +9913,32 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "tonic"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85839f0b32fd242bb3209262371d07feda6d780d16ee9d2bc88581b89da1549b"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "http 1.3.1",
+ "http-body 1.0.1",
+ "http-body-util",
+ "hyper 1.6.0",
+ "hyper-timeout",
+ "hyper-util",
+ "percent-encoding",
+ "pin-project",
+ "rustls-native-certs 0.8.1",
+ "tokio",
+ "tokio-rustls 0.26.2",
+ "tokio-stream",
+ "tower 0.5.2",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
 [[package]]
 name = "topological-sort"
 version = "0.2.2"
@@ -9947,9 +9973,12 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
 dependencies = [
  "futures-core",
  "futures-util",
+ "indexmap 2.8.0",
  "pin-project-lite",
+ "slab",
  "sync_wrapper 1.0.2",
  "tokio",
+ "tokio-util",
  "tower-layer",
  "tower-service",
  "tracing",
@@ -10400,6 +10429,7 @@ dependencies = [
  "anyhow",
  "async-nats",
  "base64 0.22.1",
+ "bytes",
  "crossbeam",
  "dashmap 6.1.0",
  "deadpool 0.12.2",
@@ -10431,6 +10461,7 @@ dependencies = [
  "tokio",
  "tokio-stream",
  "toml",
+ "tonic 0.13.0",
  "tracing",
  "ulid",
  "url",
diff --git a/Cargo.toml b/Cargo.toml
index 014b887ba6..5ddd8a3735 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -217,6 +217,7 @@ tokio-tungstenite = { version = "0.26.2", default-features = false }
 tokio-util = "0.7"
 toml = "0.8"
 tonic = "0.12.0"
+tonic13 = { version = "0.13.0", default-features = false, package = "tonic" }
 tower = "0.5.2"
 tower-http = "0.6.2"
 tower-service = "0.3.3"
diff --git a/crates/grafbase-sdk/wit/since_0_14_0/grpc.wit b/crates/grafbase-sdk/wit/since_0_14_0/grpc.wit
new file mode 100644
index 0000000000..55ac8056a0
--- /dev/null
+++ b/crates/grafbase-sdk/wit/since_0_14_0/grpc.wit
@@ -0,0 +1,127 @@
+interface grpc {
+    // A client connected to a single gRPC service.
+    resource grpc-client {
+        // Construct a new grpc-client. This will fail only if the uri in client-configuration is invalid.
+        new: static func(configuration: grpc-client-configuration) -> result<grpc-client, string>;
+
+        // Send a unary (that is to say, no streaming) request to the endpoint.
+        //
+        // Note: you can still call client streaming methods using this function, but you will only one message will be sent.
+        //
+        // # Arguments
+        //
+        // - `message`: the encoded protocol buffers message to send to the endpoint.
+        // - `service`: the name of the service to invoke on the endpoint.
+        // - `method`: the name of the method to invoke on the service.
+        // - `metadata`: the metadata map of the request. See https://grpc.io/docs/what-is-grpc/core-concepts/#metadata.
+        // - `timeout`: the timeout for the request in milliseconds. If none, the default timeout will apply.
+        //
+        // # Result
+        //
+        // - ok: the response body from the service
+        // - err: the status code and message
+        //
+        // In both cases, the metadata map of the response is included.
+        unary: func(message: list<u8>, service: string, method: string, metadata: metadata-map, timeout: option<u64>) -> result<grpc-unary-response, grpc-status>;
+
+        // Send a request to a method with server side streaming to the endpoint.
+        //
+        // Note: you can call bidirectional streaming methods using this function, but you will only be able to send one message.
+        //
+        // # Arguments
+        //
+        // - `message`: the encoded protocol buffers message to send to the endpoint.
+        // - `method`: the name of the method to invoke on the service.
+        // - `metadata`: the metadata map of the request. See https://grpc.io/docs/what-is-grpc/core-concepts/#metadata.
+        // - `timeout`: the timeout for the request. If none, the default timeout will apply.
+        //
+        // # Result
+        //
+        // - ok: the response body from the service
+        // - err: the status code and message
+        //
+        // In both cases, the metadata map of the response is included.
+        streaming: func(message: list<u8>, method: string, metadata: metadata-map, timeout: option<u64>) -> result<grpc-streaming-response, grpc-status>;
+    }
+
+    // Metadata associated with requests and responses. This is the gRPC analog of HTTP headers.
+    //
+    // https://grpc.io/docs/what-is-grpc/core-concepts/#metadata
+    type metadata-map = list<tuple<string, list<u8>>>;
+
+    // Configuration for create-client.
+    record grpc-client-configuration {
+        // The address of the gRPC endpoint.
+        uri: string
+    }
+
+    // Response status for unsuccessful gRPC requests.
+    record grpc-status {
+        // The status code of the response.
+        code: grpc-status-code,
+
+        // The status message of the response.
+        message: string,
+
+        // The metadata map of the unsuccessful response.
+        metadata: metadata-map,
+    }
+
+    // Response status of gRPC requests.
+    //
+    // Reference: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md#status-codes-and-their-use-in-grpc
+    enum grpc-status-code {
+        // 0. Not an error; returned on success.
+        ok,
+        // 1. The operation was cancelled, typically by the caller.
+        cancelled,
+        // 2. Unknown error. For example, this error may be returned when a Status value received from another address space belongs to an error space that is not known in this address space. Also errors raised by APIs that do not return enough error information may be converted to this error.
+        unknown,
+        // 3. The client specified an invalid argument. Note that this differs from FAILED_PRECONDITION. INVALID_ARGUMENT indicates arguments that are problematic regardless of the state of the system (e.g., a malformed file name).
+        invalid-argument,
+        // 4. The deadline expired before the operation could complete. For operations that change the state of the system, this error may be returned even if the operation has completed successfully. For example, a successful response from a server could have been delayed long
+        deadline-exceeded,
+        // 5. Some requested entity (e.g., file or directory) was not found. Note to server developers: if a request is denied for an entire class of users, such as gradual feature rollout or undocumented allowlist, NOT_FOUND may be used. If a request is denied for some users within a class of users, such as user-based access control, PERMISSION_DENIED must be used.
+        not-found,
+        // 6. The entity that a client attempted to create (e.g., file or directory) already exists.
+        already-exists,
+        // 7. The caller does not have permission to execute the specified operation. PERMISSION_DENIED must not be used for rejections caused by exhausting some resource (use RESOURCE_EXHAUSTED instead for those errors). PERMISSION_DENIED must not be used if the caller can not be identified (use UNAUTHENTICATED instead for those errors). This error code does not imply the request is valid or the requested entity exists or satisfies other pre-conditions.
+        permission-denied,
+        // 8. Some resource has been exhausted, perhaps a per-user quota, or perhaps the entire file system is out of space.
+        resource-exhausted,
+        // 9. The operation was rejected because the system is not in a state required for the operation's execution. For example, the directory to be deleted is non-empty, an rmdir operation is applied to a non-directory, etc. Service implementors can use the following guidelines to decide between FAILED_PRECONDITION, ABORTED, and UNAVAILABLE: (a) Use UNAVAILABLE if the client can retry just the failing call. (b) Use ABORTED if the client should retry at a higher level (e.g., when a client-specified test-and-set fails, indicating the client should restart a read-modify-write sequence). (c) Use FAILED_PRECONDITION if the client should not retry until the system state has been explicitly fixed. E.g., if an "rmdir" fails because the directory is non-empty, FAILED_PRECONDITION should be returned since the client should not retry unless the files are deleted from the directory.
+        failed-precondition,
+        // 10. The operation was aborted, typically due to a concurrency issue such as a sequencer check failure or transaction abort. See the guidelines above for deciding between FAILED_PRECONDITION, ABORTED, and UNAVAILABLE.
+        aborted,
+        // 11. The operation was attempted past the valid range. E.g., seeking or reading past end-of-file. Unlike INVALID_ARGUMENT, this error indicates a problem that may be fixed if the system state changes. For example, a 32-bit file system will generate INVALID_ARGUMENT if asked to read at an offset that is not in the range [0,2^32-1], but it will generate OUT_OF_RANGE if asked to read from an offset past the current file size. There is a fair bit of overlap between FAILED_PRECONDITION and OUT_OF_RANGE. We recommend using OUT_OF_RANGE (the more specific error) when it applies so that callers who are iterating through a space can easily look for an OUT_OF_RANGE error to detect when they are done.
+        out-of-range,
+        // 12. The operation is not implemented or is not supported/enabled in this service.
+        unimplemented,
+        // 13. Internal errors. This means that some invariants expected by the underlying system have been broken. This error code is reserved for serious errors.
+        internal,
+        // 14. The service is currently unavailable. This is most likely a transient condition, which can be corrected by retrying with a backoff. Note that it is not always safe to retry non-idempotent operations.
+        unavailable,
+        // 15. Unrecoverable data loss or corruption.
+        data-loss,
+        // 16. The request does not have valid authentication credentials for the operation.
+        unauthenticated,
+    }
+
+    // A response to a unary (no server-side streaming) request.
+    record grpc-unary-response {
+        // The metadata map of the response.
+        metadata: metadata-map,
+
+        // The response message.
+        message: list<u8>,
+    }
+
+    // A response to a request to a server-side streaming method.
+    resource grpc-streaming-response {
+        // The metadata map of the response.
+        get-metadata: func() -> metadata-map;
+
+        // The get the next streaming response message.
+        get-next-message: func() -> result<list<u8>, string>;
+    }
+}
diff --git a/crates/grafbase-sdk/wit/since_0_14_0/world.wit b/crates/grafbase-sdk/wit/since_0_14_0/world.wit
index 67a7409541..f0f2a0f2b9 100644
--- a/crates/grafbase-sdk/wit/since_0_14_0/world.wit
+++ b/crates/grafbase-sdk/wit/since_0_14_0/world.wit
@@ -4,6 +4,7 @@ world sdk {
     import access-log;
     import cache;
     import error;
+    import grpc;
     import headers;
     import http-client;
     import nats-client;
diff --git a/crates/grafbase-workspace-hack/Cargo.toml b/crates/grafbase-workspace-hack/Cargo.toml
index b50b9be239..f742dfdcfb 100644
--- a/crates/grafbase-workspace-hack/Cargo.toml
+++ b/crates/grafbase-workspace-hack/Cargo.toml
@@ -126,12 +126,13 @@ subtle = { version = "2" }
 sync_wrapper = { version = "1", default-features = false, features = ["futures"] }
 time = { version = "0.3", features = ["serde-well-known"] }
 tokio = { version = "1", features = ["full", "test-util"] }
+tokio-rustls = { version = "0.26", default-features = false, features = ["aws-lc-rs", "logging", "ring", "tls12"] }
 tokio-stream = { version = "0.1", features = ["fs", "net", "sync"] }
 tokio-util = { version = "0.7", features = ["codec", "compat", "io"] }
 toml_datetime = { version = "0.6", default-features = false, features = ["serde"] }
 toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] }
 tower-9fbad63c4bcf4a8f = { package = "tower", version = "0.4", features = ["balance", "buffer", "limit", "util"] }
-tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["log", "make", "retry"] }
+tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["balance", "buffer", "limit", "log", "retry"] }
 tower-http = { version = "0.6", features = ["compression-br", "compression-deflate", "compression-gzip", "compression-zstd", "cors", "fs", "set-header", "timeout", "trace"] }
 tracing = { version = "0.1", features = ["log"] }
 tracing-core = { version = "0.1" }
@@ -256,12 +257,13 @@ syn = { version = "2", features = ["extra-traits", "fold", "full", "visit", "vis
 sync_wrapper = { version = "1", default-features = false, features = ["futures"] }
 time = { version = "0.3", features = ["serde-well-known"] }
 tokio = { version = "1", features = ["full", "test-util"] }
+tokio-rustls = { version = "0.26", default-features = false, features = ["aws-lc-rs", "logging", "ring", "tls12"] }
 tokio-stream = { version = "0.1", features = ["fs", "net", "sync"] }
 tokio-util = { version = "0.7", features = ["codec", "compat", "io"] }
 toml_datetime = { version = "0.6", default-features = false, features = ["serde"] }
 toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] }
 tower-9fbad63c4bcf4a8f = { package = "tower", version = "0.4", features = ["balance", "buffer", "limit", "util"] }
-tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["log", "make", "retry"] }
+tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["balance", "buffer", "limit", "log", "retry"] }
 tower-http = { version = "0.6", features = ["compression-br", "compression-deflate", "compression-gzip", "compression-zstd", "cors", "fs", "set-header", "timeout", "trace"] }
 tracing = { version = "0.1", features = ["log"] }
 tracing-core = { version = "0.1" }
@@ -291,7 +293,6 @@ object = { version = "0.36", default-features = false, features = ["archive", "r
 rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "pipe", "process", "procfs", "stdio", "termios", "thread", "time"] }
 rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "termios", "time"] }
 stable_deref_trait = { version = "1" }
-tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
 tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["timeout"] }
 
 [target.aarch64-apple-darwin.build-dependencies]
@@ -308,7 +309,6 @@ object = { version = "0.36", default-features = false, features = ["archive", "r
 rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "pipe", "process", "procfs", "stdio", "termios", "thread", "time"] }
 rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "termios", "time"] }
 stable_deref_trait = { version = "1" }
-tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
 tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["timeout"] }
 
 [target.x86_64-apple-darwin.dependencies]
@@ -326,7 +326,6 @@ rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["ev
 rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "termios", "time"] }
 spin = { version = "0.9" }
 stable_deref_trait = { version = "1" }
-tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
 tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["timeout"] }
 
 [target.x86_64-apple-darwin.build-dependencies]
@@ -344,7 +343,6 @@ rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["ev
 rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "termios", "time"] }
 spin = { version = "0.9" }
 stable_deref_trait = { version = "1" }
-tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
 tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["timeout"] }
 
 [target.x86_64-pc-windows-msvc.dependencies]
@@ -353,7 +351,6 @@ hyper-rustls = { version = "0.27", default-features = false, features = ["http1"
 idna = { version = "1" }
 rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "fs", "net"] }
 spin = { version = "0.9" }
-tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
 tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["timeout"] }
 winapi = { version = "0.3", default-features = false, features = ["consoleapi", "errhandlingapi", "fileapi", "handleapi", "impl-default", "knownfolders", "minwinbase", "minwindef", "objbase", "processenv", "processthreadsapi", "profileapi", "shlobj", "synchapi", "timezoneapi", "winbase", "winerror", "winnt", "winuser"] }
 windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_Globalization", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Ioctl", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Performance", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell"] }
@@ -366,7 +363,6 @@ hyper-rustls = { version = "0.27", default-features = false, features = ["http1"
 idna = { version = "1" }
 rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "fs", "net"] }
 spin = { version = "0.9" }
-tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
 tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["timeout"] }
 winapi = { version = "0.3", default-features = false, features = ["consoleapi", "errhandlingapi", "fileapi", "handleapi", "impl-default", "knownfolders", "minwinbase", "minwindef", "objbase", "processenv", "processthreadsapi", "profileapi", "shlobj", "synchapi", "timezoneapi", "winbase", "winerror", "winnt", "winuser"] }
 windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_Globalization", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Ioctl", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Performance", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell"] }
@@ -388,7 +384,6 @@ rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["ev
 rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "termios", "time"] }
 spin = { version = "0.9" }
 stable_deref_trait = { version = "1" }
-tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
 tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["timeout"] }
 
 [target.x86_64-unknown-linux-musl.build-dependencies]
@@ -406,7 +401,6 @@ rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["ev
 rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "termios", "time"] }
 spin = { version = "0.9" }
 stable_deref_trait = { version = "1" }
-tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
 tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["timeout"] }
 
 [target.aarch64-unknown-linux-musl.dependencies]
@@ -423,7 +417,6 @@ object = { version = "0.36", default-features = false, features = ["archive", "r
 rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "pipe", "process", "procfs", "stdio", "termios", "thread", "time"] }
 rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "termios", "time"] }
 stable_deref_trait = { version = "1" }
-tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
 tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["timeout"] }
 
 [target.aarch64-unknown-linux-musl.build-dependencies]
@@ -440,7 +433,6 @@ object = { version = "0.36", default-features = false, features = ["archive", "r
 rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "net", "param", "pipe", "process", "procfs", "stdio", "termios", "thread", "time"] }
 rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "termios", "time"] }
 stable_deref_trait = { version = "1" }
-tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
 tower-d8f496e17d97b5cb = { package = "tower", version = "0.5", default-features = false, features = ["timeout"] }
 
 ### END HAKARI SECTION
diff --git a/crates/wasi-component-loader/Cargo.toml b/crates/wasi-component-loader/Cargo.toml
index 0b90728152..407e978440 100644
--- a/crates/wasi-component-loader/Cargo.toml
+++ b/crates/wasi-component-loader/Cargo.toml
@@ -9,6 +9,7 @@ repository.workspace = true
 [dependencies]
 anyhow.workspace = true
 async-nats.workspace = true
+bytes.workspace = true # for gRPC
 crossbeam = { workspace = true, features = ["crossbeam-channel"] }
 dashmap.workspace = true
 deadpool.workspace = true
@@ -43,6 +44,11 @@ time.workspace = true
 tokio = { workspace = true, features = ["time", "rt"] }
 tokio-stream.workspace = true
 toml.workspace = true
+tonic13 = { workspace = true, features = [
+    "channel",
+    "tls-native-roots",
+    "tls-aws-lc",
+] }
 tracing.workspace = true
 ulid.workspace = true
 url.workspace = true
diff --git a/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc.rs b/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc.rs
new file mode 100644
index 0000000000..692abba3f9
--- /dev/null
+++ b/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc.rs
@@ -0,0 +1,8 @@
+mod client;
+mod streaming_response;
+
+pub use super::grafbase::sdk::grpc::*;
+
+use crate::WasiState;
+
+impl Host for WasiState {}
diff --git a/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs b/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
new file mode 100644
index 0000000000..83cda928e9
--- /dev/null
+++ b/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
@@ -0,0 +1,183 @@
+use super::{
+    GrpcClient, GrpcClientConfiguration, GrpcStatus, GrpcStreamingResponse, GrpcUnaryResponse, HostGrpcClient,
+    MetadataMap,
+};
+use crate::{
+    WasiState,
+    tonic::{
+        self,
+        metadata::{MetadataKey, MetadataValue},
+    },
+};
+use bytes::BufMut as _;
+use dashmap::Entry;
+use wasmtime::component::Resource;
+
+impl HostGrpcClient for WasiState {
+    async fn new(
+        &mut self,
+        configuration: GrpcClientConfiguration,
+    ) -> wasmtime::Result<Result<Resource<GrpcClient>, String>> {
+        tracing::debug!("Creating new gRPC client for URI: {}", configuration.uri);
+
+        let client = match self.grpc_clients().entry(configuration.uri.clone()) {
+            Entry::Occupied(entry) => entry.get().clone(),
+            Entry::Vacant(entry) => {
+                let transport = match tonic::transport::Endpoint::new(configuration.uri)?.connect().await {
+                    Ok(transport) => transport,
+                    Err(err) => return Ok(Err(err.to_string())),
+                };
+
+                let client = tonic::client::Grpc::new(transport);
+
+                entry.insert(client.clone());
+
+                client
+            }
+        };
+
+        let resource = self.push_resource(client)?;
+
+        Ok(Ok(resource))
+    }
+
+    async fn unary(
+        &mut self,
+        self_: Resource<GrpcClient>,
+        message: Vec<u8>,
+        service: String,
+        method: String,
+        metadata: MetadataMap,
+        timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<GrpcUnaryResponse, GrpcStatus>> {
+        let client = self.get_mut(&self_)?;
+
+        client
+            .ready()
+            .await
+            .map_err(|e| tonic::Status::unknown(format!("Service was not ready: {e}")))?;
+
+        let path_and_query: http::uri::PathAndQuery = format!("/{service}/{method}").parse()?;
+        let mut request = tonic::Request::new(message);
+
+        for (key, value) in metadata {
+            request.metadata_mut().insert_bin(
+                MetadataKey::from_bytes(key.as_bytes()).unwrap(),
+                MetadataValue::from_bytes(&value),
+            );
+        }
+
+        if let Some(timeout) = timeout {
+            request.set_timeout(std::time::Duration::from_millis(timeout));
+        }
+
+        tracing::debug!("Sending unary request to {path_and_query}");
+
+        match client.unary(request, path_and_query, IdentityCodec).await {
+            Ok(response) => Ok(Ok(GrpcUnaryResponse {
+                metadata: tonic_metadata_to_wasi_metadata(response.metadata()),
+                message: response.into_inner(),
+            })),
+            Err(err) => Ok(Err(GrpcStatus {
+                code: err.code().into(),
+                message: err.message().to_owned(),
+                metadata: tonic_metadata_to_wasi_metadata(err.metadata()),
+            })),
+        }
+    }
+
+    async fn streaming(
+        &mut self,
+        _self_: wasmtime::component::Resource<GrpcClient>,
+        _message: Vec<u8>,
+        _method: String,
+        _metadata: MetadataMap,
+        _timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<wasmtime::component::Resource<GrpcStreamingResponse>, GrpcStatus>> {
+        todo!()
+    }
+
+    async fn drop(&mut self, rep: Resource<GrpcClient>) -> wasmtime::Result<()> {
+        self.table.delete(rep)?;
+        Ok(())
+    }
+}
+
+struct IdentityCodec;
+
+impl tonic::codec::Codec for IdentityCodec {
+    type Encode = Vec<u8>;
+    type Decode = Vec<u8>;
+    type Encoder = Self;
+    type Decoder = Self;
+
+    fn encoder(&mut self) -> Self::Encoder {
+        IdentityCodec
+    }
+
+    fn decoder(&mut self) -> Self::Decoder {
+        IdentityCodec
+    }
+}
+
+impl tonic::codec::Encoder for IdentityCodec {
+    type Item = Vec<u8>;
+    type Error = tonic::Status;
+
+    fn encode(&mut self, item: Self::Item, dst: &mut tonic::codec::EncodeBuf<'_>) -> Result<(), Self::Error> {
+        dst.put_slice(item.as_slice());
+        Ok(())
+    }
+}
+
+impl tonic::codec::Decoder for IdentityCodec {
+    type Item = Vec<u8>;
+    type Error = tonic::Status;
+
+    fn decode(&mut self, src: &mut tonic::codec::DecodeBuf<'_>) -> Result<Option<Self::Item>, Self::Error> {
+        use bytes::Buf;
+        use std::io::Read;
+
+        let mut out = Vec::with_capacity(src.remaining());
+        src.reader().read_to_end(&mut out)?;
+        Ok(Some(out))
+    }
+}
+
+impl From<tonic::Code> for super::GrpcStatusCode {
+    fn from(code: tonic::Code) -> Self {
+        match code {
+            tonic::Code::Ok => super::GrpcStatusCode::Ok,
+            tonic::Code::Cancelled => super::GrpcStatusCode::Cancelled,
+            tonic::Code::Unknown => super::GrpcStatusCode::Unknown,
+            tonic::Code::InvalidArgument => super::GrpcStatusCode::InvalidArgument,
+            tonic::Code::DeadlineExceeded => super::GrpcStatusCode::DeadlineExceeded,
+            tonic::Code::NotFound => super::GrpcStatusCode::NotFound,
+            tonic::Code::AlreadyExists => super::GrpcStatusCode::AlreadyExists,
+            tonic::Code::PermissionDenied => super::GrpcStatusCode::PermissionDenied,
+            tonic::Code::ResourceExhausted => super::GrpcStatusCode::ResourceExhausted,
+            tonic::Code::FailedPrecondition => super::GrpcStatusCode::FailedPrecondition,
+            tonic::Code::Aborted => super::GrpcStatusCode::Aborted,
+            tonic::Code::OutOfRange => super::GrpcStatusCode::OutOfRange,
+            tonic::Code::Unimplemented => super::GrpcStatusCode::Unimplemented,
+            tonic::Code::Internal => super::GrpcStatusCode::Internal,
+            tonic::Code::Unavailable => super::GrpcStatusCode::Unavailable,
+            tonic::Code::DataLoss => super::GrpcStatusCode::DataLoss,
+            tonic::Code::Unauthenticated => super::GrpcStatusCode::Unauthenticated,
+        }
+    }
+}
+
+fn tonic_metadata_to_wasi_metadata(metadata: &tonic::metadata::MetadataMap) -> MetadataMap {
+    metadata
+        .iter()
+        .map(|kv| match kv {
+            tonic::metadata::KeyAndValueRef::Ascii(metadata_key, metadata_value) => {
+                (metadata_key.as_str().to_owned(), metadata_value.as_bytes().to_owned())
+            }
+            tonic::metadata::KeyAndValueRef::Binary(metadata_key, metadata_value) => {
+                (metadata_key.as_str().to_owned(), metadata_value.as_ref().to_owned())
+            }
+        })
+        .collect()
+}
diff --git a/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/streaming_response.rs b/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/streaming_response.rs
new file mode 100644
index 0000000000..b9414aedef
--- /dev/null
+++ b/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/streaming_response.rs
@@ -0,0 +1,22 @@
+use wasmtime::component::Resource;
+
+use crate::WasiState;
+
+use super::{GrpcStreamingResponse, HostGrpcStreamingResponse, MetadataMap};
+
+impl HostGrpcStreamingResponse for WasiState {
+    async fn get_metadata(&mut self, _self_: Resource<GrpcStreamingResponse>) -> wasmtime::Result<MetadataMap> {
+        todo!()
+    }
+
+    async fn get_next_message(
+        &mut self,
+        _self_: Resource<GrpcStreamingResponse>,
+    ) -> wasmtime::Result<Result<Vec<u8>, String>> {
+        todo!()
+    }
+
+    async fn drop(&mut self, _rep: Resource<GrpcStreamingResponse>) -> wasmtime::Result<()> {
+        Ok(())
+    }
+}
diff --git a/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/mod.rs b/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/mod.rs
index a6036b0cf7..f986a88695 100644
--- a/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/mod.rs
+++ b/crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/mod.rs
@@ -1,5 +1,6 @@
 pub mod authorization_types;
 pub mod field_resolver_types;
+pub mod grpc;
 pub mod postgres;
 pub mod resolver_types;
 pub mod schema;
@@ -16,6 +17,7 @@ wasmtime::component::bindgen!({
         "grafbase:sdk/http-client": crate::extension::api::since_0_9_0::wit::http_client,
         "grafbase:sdk/cache": crate::extension::api::since_0_9_0::wit::cache,
         "grafbase:sdk/error": crate::extension::api::since_0_9_0::wit::error,
+        "grafbase:sdk/grpc/grpc-client": crate::resources::GrpcClient,
         "grafbase:sdk/token": crate::extension::api::since_0_10_0::wit::token,
         "grafbase:sdk/headers": crate::extension::api::since_0_10_0::wit::headers,
         "grafbase:sdk/postgres/pg-pool": crate::resources::PgPool,
diff --git a/crates/wasi-component-loader/src/lib.rs b/crates/wasi-component-loader/src/lib.rs
index f6e0f049da..eb4a477e68 100644
--- a/crates/wasi-component-loader/src/lib.rs
+++ b/crates/wasi-component-loader/src/lib.rs
@@ -25,6 +25,7 @@ mod subgraph_request;
 mod tests;
 
 use std::sync::Arc;
+use tonic13 as tonic;
 
 pub use access_log::{AccessLogMessage, AccessLogReceiver, AccessLogSender, create_access_log_channel};
 use cache::Cache;
diff --git a/crates/wasi-component-loader/src/resources/mod.rs b/crates/wasi-component-loader/src/resources/mod.rs
index fb7fa362ee..944be73f7a 100644
--- a/crates/wasi-component-loader/src/resources/mod.rs
+++ b/crates/wasi-component-loader/src/resources/mod.rs
@@ -2,6 +2,7 @@ mod headers;
 
 use std::sync::Arc;
 
+use crate::tonic;
 use futures::StreamExt;
 use runtime::extension::Token;
 use sqlx::Postgres;
@@ -15,6 +16,8 @@ pub struct SharedResources {
     pub access_log: AccessLogSender,
 }
 
+pub type GrpcClient = tonic::client::Grpc<tonic::transport::Channel>;
+
 pub type NatsClient = async_nats::Client;
 pub type NatsKeyValue = async_nats::jetstream::kv::Store;
 
diff --git a/crates/wasi-component-loader/src/state.rs b/crates/wasi-component-loader/src/state.rs
index bd9134cd76..211a39ed49 100644
--- a/crates/wasi-component-loader/src/state.rs
+++ b/crates/wasi-component-loader/src/state.rs
@@ -8,7 +8,10 @@ use wasmtime::component::Resource;
 use wasmtime_wasi::{IoView, ResourceTable, WasiCtx, WasiView};
 use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpView};
 
-use crate::AccessLogSender;
+use crate::{
+    AccessLogSender,
+    resources::{self, GrpcClient},
+};
 
 /// Represents the state of the WASI environment.
 ///
@@ -42,6 +45,8 @@ pub(crate) struct WasiState {
 
     /// A map of PostgreSQL connection pools per named connection.
     postgres_pools: DashMap<String, sqlx::Pool<Postgres>>,
+
+    grpc_clients: DashMap<String, resources::GrpcClient>,
 }
 
 // Allows to define method for a resource that is either owned or an attribute from another one.
@@ -131,6 +136,7 @@ impl WasiState {
             cache,
             network_enabled,
             postgres_pools: DashMap::new(),
+            grpc_clients: DashMap::new(),
         }
     }
 
@@ -196,6 +202,11 @@ impl WasiState {
         &self.request_durations
     }
 
+    /// Returns a reference to the map of gRPC clients.
+    pub(crate) fn grpc_clients(&self) -> &DashMap<String, GrpcClient> {
+        &self.grpc_clients
+    }
+
     /// Returns a reference to the HTTP client used for making requests from the guest.
     pub fn http_client(&self) -> &reqwest::Client {
         &self.http_client


Reviews:
user: login: tomhoule
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T09:27:14Z
user: login: tomhoule
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T12:16:14Z
user: login: Finistere
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T08:22:19Z
user: login: Finistere
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T08:23:33Z
user: login: Finistere
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T08:24:34Z
user: login: Finistere
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T08:24:53Z
user: login: Finistere
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T08:25:57Z
user: login: tomhoule
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T13:33:53Z
user: login: tomhoule
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T13:34:11Z
user: login: tomhoule
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T13:34:16Z
user: login: tomhoule
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T13:46:08Z
user: login: tomhoule
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T13:47:54Z

Review comments:
diff_hunk: @@ -0,0 +1,140 @@
+use super::{
+    ClientConfiguration, GrpcClient, GrpcStatus, GrpcStreamingResponse, GrpcUnaryResponse, HostGrpcClient, MetadataMap,
+};
+use crate::WasiState;
+use bytes::BufMut as _;
+use dashmap::Entry;
+use tonic::metadata::{MetadataKey, MetadataValue};
+use wasmtime::component::Resource;
+
+impl HostGrpcClient for WasiState {
+    async fn new(
+        &mut self,
+        configuration: ClientConfiguration,
+    ) -> wasmtime::Result<Result<Resource<GrpcClient>, String>> {
+        let uri: http::Uri = match configuration.uri.parse() {
+            Ok(uri) => uri,
+            Err(err) => return Ok(Err(err.to_string())),
+        };
+
+        let client = match self.grpc_clients().entry(configuration.uri) {
path: crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
user: login: tomhoule
body: Not a fan of the configuration (apart from the url) getting ignored if the client is already initialized.
created_at: 2025-04-04T09:27:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -217,6 +217,7 @@ tokio-tungstenite = { version = "0.26.2", default-features = false }
 tokio-util = "0.7"
 toml = "0.8"
 tonic = "0.12.0"
path: Cargo.toml
user: login: tomhoule
body: opentelemetry is why we can't have nice things, here.
created_at: 2025-04-05T12:16:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,185 @@
+use super::{
+    GrpcClient, GrpcClientConfiguration, GrpcStatus, GrpcStreamingResponse, GrpcUnaryResponse, HostGrpcClient,
+    MetadataMap,
+};
+use crate::{
+    WasiState,
+    tonic::{
+        self, GrpcMethod,
+        metadata::{MetadataKey, MetadataValue},
+    },
+};
+use bytes::BufMut as _;
+use dashmap::Entry;
+use wasmtime::component::Resource;
+
+impl HostGrpcClient for WasiState {
+    async fn new(
+        &mut self,
+        configuration: GrpcClientConfiguration,
+    ) -> wasmtime::Result<Result<Resource<GrpcClient>, String>> {
+        tracing::debug!("Creating new gRPC client for URI: {}", configuration.uri);
+
+        let client = match self.grpc_clients().entry(configuration.uri.clone()) {
+            Entry::Occupied(entry) => entry.get().clone(),
+            Entry::Vacant(entry) => {
+                let transport = match tonic::transport::Endpoint::new(configuration.uri)?.connect().await {
+                    Ok(transport) => transport,
+                    Err(err) => return Ok(Err(err.to_string())),
+                };
+
+                let client = tonic::client::Grpc::new(transport);
+
+                entry.insert(client.clone());
+
+                client
+            }
+        };
+
+        let resource = self.push_resource(client)?;
+
+        Ok(Ok(resource))
+    }
+
+    async fn unary(
+        &mut self,
+        self_: Resource<GrpcClient>,
+        message: Vec<u8>,
+        service: String,
+        method: String,
+        metadata: MetadataMap,
+        timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<GrpcUnaryResponse, GrpcStatus>> {
+        let client = self.get_mut(&self_)?;
+
+        client
+            .ready()
+            .await
+            .map_err(|e| tonic::Status::unknown(format!("Service was not ready: {e}")))?;
+
+        let path_and_query: http::uri::PathAndQuery = format!("/{service}/{method}").parse()?;
+        let mut request = tonic::Request::new(message);
+
+        for (key, value) in metadata {
+            request.metadata_mut().insert_bin(
+                MetadataKey::from_bytes(key.as_bytes()).unwrap(),
+                MetadataValue::from_bytes(&value),
+            );
+        }
+
+        if let Some(timeout) = timeout {
+            request.set_timeout(std::time::Duration::from_millis(timeout));
+        }
+
+        let service: &'static str = Box::leak(service.into_boxed_str());
path: crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
user: login: Finistere
body: leak???
created_at: 2025-04-07T08:22:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,185 @@
+use super::{
+    GrpcClient, GrpcClientConfiguration, GrpcStatus, GrpcStreamingResponse, GrpcUnaryResponse, HostGrpcClient,
+    MetadataMap,
+};
+use crate::{
+    WasiState,
+    tonic::{
+        self, GrpcMethod,
+        metadata::{MetadataKey, MetadataValue},
+    },
+};
+use bytes::BufMut as _;
+use dashmap::Entry;
+use wasmtime::component::Resource;
+
+impl HostGrpcClient for WasiState {
+    async fn new(
+        &mut self,
+        configuration: GrpcClientConfiguration,
+    ) -> wasmtime::Result<Result<Resource<GrpcClient>, String>> {
+        tracing::debug!("Creating new gRPC client for URI: {}", configuration.uri);
+
+        let client = match self.grpc_clients().entry(configuration.uri.clone()) {
+            Entry::Occupied(entry) => entry.get().clone(),
+            Entry::Vacant(entry) => {
+                let transport = match tonic::transport::Endpoint::new(configuration.uri)?.connect().await {
+                    Ok(transport) => transport,
+                    Err(err) => return Ok(Err(err.to_string())),
+                };
+
+                let client = tonic::client::Grpc::new(transport);
+
+                entry.insert(client.clone());
+
+                client
+            }
+        };
+
+        let resource = self.push_resource(client)?;
+
+        Ok(Ok(resource))
+    }
+
+    async fn unary(
+        &mut self,
+        self_: Resource<GrpcClient>,
+        message: Vec<u8>,
+        service: String,
+        method: String,
+        metadata: MetadataMap,
+        timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<GrpcUnaryResponse, GrpcStatus>> {
+        let client = self.get_mut(&self_)?;
+
+        client
+            .ready()
+            .await
+            .map_err(|e| tonic::Status::unknown(format!("Service was not ready: {e}")))?;
+
+        let path_and_query: http::uri::PathAndQuery = format!("/{service}/{method}").parse()?;
+        let mut request = tonic::Request::new(message);
+
+        for (key, value) in metadata {
+            request.metadata_mut().insert_bin(
+                MetadataKey::from_bytes(key.as_bytes()).unwrap(),
+                MetadataValue::from_bytes(&value),
+            );
+        }
+
+        if let Some(timeout) = timeout {
+            request.set_timeout(std::time::Duration::from_millis(timeout));
+        }
+
+        let service: &'static str = Box::leak(service.into_boxed_str());
+        let method: &'static str = Box::leak(method.into_boxed_str());
+
+        request.extensions_mut().insert(GrpcMethod::new(service, method));
+
+        tracing::debug!("Sending unary request to {path_and_query}");
+
+        match client.unary(request, path_and_query, TrivialCodec).await {
+            Ok(response) => Ok(Ok(GrpcUnaryResponse {
+                metadata: tonic_metadata_to_wasi_metadata(response.metadata()),
+                message: response.into_inner(),
+            })),
+            Err(err) => Ok(Err(GrpcStatus {
+                code: convert_grpc_status_code(err.code()),
+                message: err.message().to_owned(),
+                metadata: tonic_metadata_to_wasi_metadata(err.metadata()),
+            })),
+        }
+    }
+
+    async fn streaming(
+        &mut self,
+        _self_: wasmtime::component::Resource<GrpcClient>,
+        _message: Vec<u8>,
+        _method: String,
+        _metadata: MetadataMap,
+        _timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<wasmtime::component::Resource<GrpcStreamingResponse>, GrpcStatus>> {
+        todo!()
+    }
+
+    async fn drop(&mut self, _rep: Resource<GrpcClient>) -> wasmtime::Result<()> {
+        Ok(())
path: crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
user: login: Finistere
body: you need to drop the `rep` from the table, look at other resource implementations. You're leaking memory inside the guest
created_at: 2025-04-07T08:23:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,185 @@
+use super::{
+    GrpcClient, GrpcClientConfiguration, GrpcStatus, GrpcStreamingResponse, GrpcUnaryResponse, HostGrpcClient,
+    MetadataMap,
+};
+use crate::{
+    WasiState,
+    tonic::{
+        self, GrpcMethod,
+        metadata::{MetadataKey, MetadataValue},
+    },
+};
+use bytes::BufMut as _;
+use dashmap::Entry;
+use wasmtime::component::Resource;
+
+impl HostGrpcClient for WasiState {
+    async fn new(
+        &mut self,
+        configuration: GrpcClientConfiguration,
+    ) -> wasmtime::Result<Result<Resource<GrpcClient>, String>> {
+        tracing::debug!("Creating new gRPC client for URI: {}", configuration.uri);
+
+        let client = match self.grpc_clients().entry(configuration.uri.clone()) {
+            Entry::Occupied(entry) => entry.get().clone(),
+            Entry::Vacant(entry) => {
+                let transport = match tonic::transport::Endpoint::new(configuration.uri)?.connect().await {
+                    Ok(transport) => transport,
+                    Err(err) => return Ok(Err(err.to_string())),
+                };
+
+                let client = tonic::client::Grpc::new(transport);
+
+                entry.insert(client.clone());
+
+                client
+            }
+        };
+
+        let resource = self.push_resource(client)?;
+
+        Ok(Ok(resource))
+    }
+
+    async fn unary(
+        &mut self,
+        self_: Resource<GrpcClient>,
+        message: Vec<u8>,
+        service: String,
+        method: String,
+        metadata: MetadataMap,
+        timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<GrpcUnaryResponse, GrpcStatus>> {
+        let client = self.get_mut(&self_)?;
+
+        client
+            .ready()
+            .await
+            .map_err(|e| tonic::Status::unknown(format!("Service was not ready: {e}")))?;
+
+        let path_and_query: http::uri::PathAndQuery = format!("/{service}/{method}").parse()?;
+        let mut request = tonic::Request::new(message);
+
+        for (key, value) in metadata {
+            request.metadata_mut().insert_bin(
+                MetadataKey::from_bytes(key.as_bytes()).unwrap(),
+                MetadataValue::from_bytes(&value),
+            );
+        }
+
+        if let Some(timeout) = timeout {
+            request.set_timeout(std::time::Duration::from_millis(timeout));
+        }
+
+        let service: &'static str = Box::leak(service.into_boxed_str());
+        let method: &'static str = Box::leak(method.into_boxed_str());
+
+        request.extensions_mut().insert(GrpcMethod::new(service, method));
+
+        tracing::debug!("Sending unary request to {path_and_query}");
+
+        match client.unary(request, path_and_query, TrivialCodec).await {
+            Ok(response) => Ok(Ok(GrpcUnaryResponse {
+                metadata: tonic_metadata_to_wasi_metadata(response.metadata()),
+                message: response.into_inner(),
+            })),
+            Err(err) => Ok(Err(GrpcStatus {
+                code: convert_grpc_status_code(err.code()),
+                message: err.message().to_owned(),
+                metadata: tonic_metadata_to_wasi_metadata(err.metadata()),
+            })),
+        }
+    }
+
+    async fn streaming(
+        &mut self,
+        _self_: wasmtime::component::Resource<GrpcClient>,
+        _message: Vec<u8>,
+        _method: String,
+        _metadata: MetadataMap,
+        _timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<wasmtime::component::Resource<GrpcStreamingResponse>, GrpcStatus>> {
+        todo!()
+    }
+
+    async fn drop(&mut self, _rep: Resource<GrpcClient>) -> wasmtime::Result<()> {
+        Ok(())
+    }
+}
+
+struct TrivialCodec;
path: crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
user: login: Finistere
body: `IdentityCodec`?
created_at: 2025-04-07T08:24:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,185 @@
+use super::{
+    GrpcClient, GrpcClientConfiguration, GrpcStatus, GrpcStreamingResponse, GrpcUnaryResponse, HostGrpcClient,
+    MetadataMap,
+};
+use crate::{
+    WasiState,
+    tonic::{
+        self, GrpcMethod,
+        metadata::{MetadataKey, MetadataValue},
+    },
+};
+use bytes::BufMut as _;
+use dashmap::Entry;
+use wasmtime::component::Resource;
+
+impl HostGrpcClient for WasiState {
+    async fn new(
+        &mut self,
+        configuration: GrpcClientConfiguration,
+    ) -> wasmtime::Result<Result<Resource<GrpcClient>, String>> {
+        tracing::debug!("Creating new gRPC client for URI: {}", configuration.uri);
+
+        let client = match self.grpc_clients().entry(configuration.uri.clone()) {
+            Entry::Occupied(entry) => entry.get().clone(),
+            Entry::Vacant(entry) => {
+                let transport = match tonic::transport::Endpoint::new(configuration.uri)?.connect().await {
+                    Ok(transport) => transport,
+                    Err(err) => return Ok(Err(err.to_string())),
+                };
+
+                let client = tonic::client::Grpc::new(transport);
+
+                entry.insert(client.clone());
+
+                client
+            }
+        };
+
+        let resource = self.push_resource(client)?;
+
+        Ok(Ok(resource))
+    }
+
+    async fn unary(
+        &mut self,
+        self_: Resource<GrpcClient>,
+        message: Vec<u8>,
+        service: String,
+        method: String,
+        metadata: MetadataMap,
+        timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<GrpcUnaryResponse, GrpcStatus>> {
+        let client = self.get_mut(&self_)?;
+
+        client
+            .ready()
+            .await
+            .map_err(|e| tonic::Status::unknown(format!("Service was not ready: {e}")))?;
+
+        let path_and_query: http::uri::PathAndQuery = format!("/{service}/{method}").parse()?;
+        let mut request = tonic::Request::new(message);
+
+        for (key, value) in metadata {
+            request.metadata_mut().insert_bin(
+                MetadataKey::from_bytes(key.as_bytes()).unwrap(),
+                MetadataValue::from_bytes(&value),
+            );
+        }
+
+        if let Some(timeout) = timeout {
+            request.set_timeout(std::time::Duration::from_millis(timeout));
+        }
+
+        let service: &'static str = Box::leak(service.into_boxed_str());
+        let method: &'static str = Box::leak(method.into_boxed_str());
+
+        request.extensions_mut().insert(GrpcMethod::new(service, method));
+
+        tracing::debug!("Sending unary request to {path_and_query}");
+
+        match client.unary(request, path_and_query, TrivialCodec).await {
+            Ok(response) => Ok(Ok(GrpcUnaryResponse {
+                metadata: tonic_metadata_to_wasi_metadata(response.metadata()),
+                message: response.into_inner(),
+            })),
+            Err(err) => Ok(Err(GrpcStatus {
+                code: convert_grpc_status_code(err.code()),
+                message: err.message().to_owned(),
+                metadata: tonic_metadata_to_wasi_metadata(err.metadata()),
+            })),
+        }
+    }
+
+    async fn streaming(
+        &mut self,
+        _self_: wasmtime::component::Resource<GrpcClient>,
+        _message: Vec<u8>,
+        _method: String,
+        _metadata: MetadataMap,
+        _timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<wasmtime::component::Resource<GrpcStreamingResponse>, GrpcStatus>> {
+        todo!()
+    }
+
+    async fn drop(&mut self, _rep: Resource<GrpcClient>) -> wasmtime::Result<()> {
+        Ok(())
+    }
+}
+
+struct TrivialCodec;
+
+impl tonic::codec::Codec for TrivialCodec {
+    type Encode = Vec<u8>;
+    type Decode = Vec<u8>;
+    type Encoder = Self;
+    type Decoder = Self;
+
+    fn encoder(&mut self) -> Self::Encoder {
+        TrivialCodec
+    }
+
+    fn decoder(&mut self) -> Self::Decoder {
+        TrivialCodec
+    }
+}
+
+impl tonic::codec::Encoder for TrivialCodec {
+    type Item = Vec<u8>;
+    type Error = tonic::Status;
+
+    fn encode(&mut self, item: Self::Item, dst: &mut tonic::codec::EncodeBuf<'_>) -> Result<(), Self::Error> {
+        dst.put_slice(item.as_slice());
+        Ok(())
+    }
+}
+
+impl tonic::codec::Decoder for TrivialCodec {
+    type Item = Vec<u8>;
+    type Error = tonic::Status;
+
+    fn decode(&mut self, src: &mut tonic::codec::DecodeBuf<'_>) -> Result<Option<Self::Item>, Self::Error> {
+        use bytes::Buf;
+        use std::io::Read;
+
+        let mut out = Vec::with_capacity(src.remaining());
+        src.reader().read_to_end(&mut out)?;
+        Ok(Some(out))
+    }
+}
+
+fn convert_grpc_status_code(code: tonic::Code) -> super::GrpcStatusCode {
path: crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
user: login: Finistere
body: Could be a `impl From`
created_at: 2025-04-07T08:24:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,185 @@
+use super::{
+    GrpcClient, GrpcClientConfiguration, GrpcStatus, GrpcStreamingResponse, GrpcUnaryResponse, HostGrpcClient,
+    MetadataMap,
+};
+use crate::{
+    WasiState,
+    tonic::{
+        self, GrpcMethod,
+        metadata::{MetadataKey, MetadataValue},
+    },
+};
+use bytes::BufMut as _;
+use dashmap::Entry;
+use wasmtime::component::Resource;
+
+impl HostGrpcClient for WasiState {
+    async fn new(
+        &mut self,
+        configuration: GrpcClientConfiguration,
+    ) -> wasmtime::Result<Result<Resource<GrpcClient>, String>> {
+        tracing::debug!("Creating new gRPC client for URI: {}", configuration.uri);
+
+        let client = match self.grpc_clients().entry(configuration.uri.clone()) {
+            Entry::Occupied(entry) => entry.get().clone(),
+            Entry::Vacant(entry) => {
+                let transport = match tonic::transport::Endpoint::new(configuration.uri)?.connect().await {
+                    Ok(transport) => transport,
+                    Err(err) => return Ok(Err(err.to_string())),
+                };
+
+                let client = tonic::client::Grpc::new(transport);
+
+                entry.insert(client.clone());
+
+                client
+            }
+        };
+
+        let resource = self.push_resource(client)?;
+
+        Ok(Ok(resource))
+    }
+
+    async fn unary(
+        &mut self,
+        self_: Resource<GrpcClient>,
+        message: Vec<u8>,
+        service: String,
+        method: String,
+        metadata: MetadataMap,
+        timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<GrpcUnaryResponse, GrpcStatus>> {
+        let client = self.get_mut(&self_)?;
+
+        client
+            .ready()
+            .await
+            .map_err(|e| tonic::Status::unknown(format!("Service was not ready: {e}")))?;
+
+        let path_and_query: http::uri::PathAndQuery = format!("/{service}/{method}").parse()?;
+        let mut request = tonic::Request::new(message);
+
+        for (key, value) in metadata {
+            request.metadata_mut().insert_bin(
+                MetadataKey::from_bytes(key.as_bytes()).unwrap(),
+                MetadataValue::from_bytes(&value),
+            );
+        }
+
+        if let Some(timeout) = timeout {
+            request.set_timeout(std::time::Duration::from_millis(timeout));
+        }
+
+        let service: &'static str = Box::leak(service.into_boxed_str());
path: crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
user: login: tomhoule
body: Oops, that was for debugging, forgot about that. Will fix before merging.
created_at: 2025-04-07T13:33:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,185 @@
+use super::{
+    GrpcClient, GrpcClientConfiguration, GrpcStatus, GrpcStreamingResponse, GrpcUnaryResponse, HostGrpcClient,
+    MetadataMap,
+};
+use crate::{
+    WasiState,
+    tonic::{
+        self, GrpcMethod,
+        metadata::{MetadataKey, MetadataValue},
+    },
+};
+use bytes::BufMut as _;
+use dashmap::Entry;
+use wasmtime::component::Resource;
+
+impl HostGrpcClient for WasiState {
+    async fn new(
+        &mut self,
+        configuration: GrpcClientConfiguration,
+    ) -> wasmtime::Result<Result<Resource<GrpcClient>, String>> {
+        tracing::debug!("Creating new gRPC client for URI: {}", configuration.uri);
+
+        let client = match self.grpc_clients().entry(configuration.uri.clone()) {
+            Entry::Occupied(entry) => entry.get().clone(),
+            Entry::Vacant(entry) => {
+                let transport = match tonic::transport::Endpoint::new(configuration.uri)?.connect().await {
+                    Ok(transport) => transport,
+                    Err(err) => return Ok(Err(err.to_string())),
+                };
+
+                let client = tonic::client::Grpc::new(transport);
+
+                entry.insert(client.clone());
+
+                client
+            }
+        };
+
+        let resource = self.push_resource(client)?;
+
+        Ok(Ok(resource))
+    }
+
+    async fn unary(
+        &mut self,
+        self_: Resource<GrpcClient>,
+        message: Vec<u8>,
+        service: String,
+        method: String,
+        metadata: MetadataMap,
+        timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<GrpcUnaryResponse, GrpcStatus>> {
+        let client = self.get_mut(&self_)?;
+
+        client
+            .ready()
+            .await
+            .map_err(|e| tonic::Status::unknown(format!("Service was not ready: {e}")))?;
+
+        let path_and_query: http::uri::PathAndQuery = format!("/{service}/{method}").parse()?;
+        let mut request = tonic::Request::new(message);
+
+        for (key, value) in metadata {
+            request.metadata_mut().insert_bin(
+                MetadataKey::from_bytes(key.as_bytes()).unwrap(),
+                MetadataValue::from_bytes(&value),
+            );
+        }
+
+        if let Some(timeout) = timeout {
+            request.set_timeout(std::time::Duration::from_millis(timeout));
+        }
+
+        let service: &'static str = Box::leak(service.into_boxed_str());
+        let method: &'static str = Box::leak(method.into_boxed_str());
+
+        request.extensions_mut().insert(GrpcMethod::new(service, method));
+
+        tracing::debug!("Sending unary request to {path_and_query}");
+
+        match client.unary(request, path_and_query, TrivialCodec).await {
+            Ok(response) => Ok(Ok(GrpcUnaryResponse {
+                metadata: tonic_metadata_to_wasi_metadata(response.metadata()),
+                message: response.into_inner(),
+            })),
+            Err(err) => Ok(Err(GrpcStatus {
+                code: convert_grpc_status_code(err.code()),
+                message: err.message().to_owned(),
+                metadata: tonic_metadata_to_wasi_metadata(err.metadata()),
+            })),
+        }
+    }
+
+    async fn streaming(
+        &mut self,
+        _self_: wasmtime::component::Resource<GrpcClient>,
+        _message: Vec<u8>,
+        _method: String,
+        _metadata: MetadataMap,
+        _timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<wasmtime::component::Resource<GrpcStreamingResponse>, GrpcStatus>> {
+        todo!()
+    }
+
+    async fn drop(&mut self, _rep: Resource<GrpcClient>) -> wasmtime::Result<()> {
+        Ok(())
+    }
+}
+
+struct TrivialCodec;
path: crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
user: login: tomhoule
body: Better. Will rename.
created_at: 2025-04-07T13:34:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,185 @@
+use super::{
+    GrpcClient, GrpcClientConfiguration, GrpcStatus, GrpcStreamingResponse, GrpcUnaryResponse, HostGrpcClient,
+    MetadataMap,
+};
+use crate::{
+    WasiState,
+    tonic::{
+        self, GrpcMethod,
+        metadata::{MetadataKey, MetadataValue},
+    },
+};
+use bytes::BufMut as _;
+use dashmap::Entry;
+use wasmtime::component::Resource;
+
+impl HostGrpcClient for WasiState {
+    async fn new(
+        &mut self,
+        configuration: GrpcClientConfiguration,
+    ) -> wasmtime::Result<Result<Resource<GrpcClient>, String>> {
+        tracing::debug!("Creating new gRPC client for URI: {}", configuration.uri);
+
+        let client = match self.grpc_clients().entry(configuration.uri.clone()) {
+            Entry::Occupied(entry) => entry.get().clone(),
+            Entry::Vacant(entry) => {
+                let transport = match tonic::transport::Endpoint::new(configuration.uri)?.connect().await {
+                    Ok(transport) => transport,
+                    Err(err) => return Ok(Err(err.to_string())),
+                };
+
+                let client = tonic::client::Grpc::new(transport);
+
+                entry.insert(client.clone());
+
+                client
+            }
+        };
+
+        let resource = self.push_resource(client)?;
+
+        Ok(Ok(resource))
+    }
+
+    async fn unary(
+        &mut self,
+        self_: Resource<GrpcClient>,
+        message: Vec<u8>,
+        service: String,
+        method: String,
+        metadata: MetadataMap,
+        timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<GrpcUnaryResponse, GrpcStatus>> {
+        let client = self.get_mut(&self_)?;
+
+        client
+            .ready()
+            .await
+            .map_err(|e| tonic::Status::unknown(format!("Service was not ready: {e}")))?;
+
+        let path_and_query: http::uri::PathAndQuery = format!("/{service}/{method}").parse()?;
+        let mut request = tonic::Request::new(message);
+
+        for (key, value) in metadata {
+            request.metadata_mut().insert_bin(
+                MetadataKey::from_bytes(key.as_bytes()).unwrap(),
+                MetadataValue::from_bytes(&value),
+            );
+        }
+
+        if let Some(timeout) = timeout {
+            request.set_timeout(std::time::Duration::from_millis(timeout));
+        }
+
+        let service: &'static str = Box::leak(service.into_boxed_str());
+        let method: &'static str = Box::leak(method.into_boxed_str());
+
+        request.extensions_mut().insert(GrpcMethod::new(service, method));
+
+        tracing::debug!("Sending unary request to {path_and_query}");
+
+        match client.unary(request, path_and_query, TrivialCodec).await {
+            Ok(response) => Ok(Ok(GrpcUnaryResponse {
+                metadata: tonic_metadata_to_wasi_metadata(response.metadata()),
+                message: response.into_inner(),
+            })),
+            Err(err) => Ok(Err(GrpcStatus {
+                code: convert_grpc_status_code(err.code()),
+                message: err.message().to_owned(),
+                metadata: tonic_metadata_to_wasi_metadata(err.metadata()),
+            })),
+        }
+    }
+
+    async fn streaming(
+        &mut self,
+        _self_: wasmtime::component::Resource<GrpcClient>,
+        _message: Vec<u8>,
+        _method: String,
+        _metadata: MetadataMap,
+        _timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<wasmtime::component::Resource<GrpcStreamingResponse>, GrpcStatus>> {
+        todo!()
+    }
+
+    async fn drop(&mut self, _rep: Resource<GrpcClient>) -> wasmtime::Result<()> {
+        Ok(())
path: crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
user: login: tomhoule
body: Will do before merging.
created_at: 2025-04-07T13:34:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,185 @@
+use super::{
+    GrpcClient, GrpcClientConfiguration, GrpcStatus, GrpcStreamingResponse, GrpcUnaryResponse, HostGrpcClient,
+    MetadataMap,
+};
+use crate::{
+    WasiState,
+    tonic::{
+        self, GrpcMethod,
+        metadata::{MetadataKey, MetadataValue},
+    },
+};
+use bytes::BufMut as _;
+use dashmap::Entry;
+use wasmtime::component::Resource;
+
+impl HostGrpcClient for WasiState {
+    async fn new(
+        &mut self,
+        configuration: GrpcClientConfiguration,
+    ) -> wasmtime::Result<Result<Resource<GrpcClient>, String>> {
+        tracing::debug!("Creating new gRPC client for URI: {}", configuration.uri);
+
+        let client = match self.grpc_clients().entry(configuration.uri.clone()) {
+            Entry::Occupied(entry) => entry.get().clone(),
+            Entry::Vacant(entry) => {
+                let transport = match tonic::transport::Endpoint::new(configuration.uri)?.connect().await {
+                    Ok(transport) => transport,
+                    Err(err) => return Ok(Err(err.to_string())),
+                };
+
+                let client = tonic::client::Grpc::new(transport);
+
+                entry.insert(client.clone());
+
+                client
+            }
+        };
+
+        let resource = self.push_resource(client)?;
+
+        Ok(Ok(resource))
+    }
+
+    async fn unary(
+        &mut self,
+        self_: Resource<GrpcClient>,
+        message: Vec<u8>,
+        service: String,
+        method: String,
+        metadata: MetadataMap,
+        timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<GrpcUnaryResponse, GrpcStatus>> {
+        let client = self.get_mut(&self_)?;
+
+        client
+            .ready()
+            .await
+            .map_err(|e| tonic::Status::unknown(format!("Service was not ready: {e}")))?;
+
+        let path_and_query: http::uri::PathAndQuery = format!("/{service}/{method}").parse()?;
+        let mut request = tonic::Request::new(message);
+
+        for (key, value) in metadata {
+            request.metadata_mut().insert_bin(
+                MetadataKey::from_bytes(key.as_bytes()).unwrap(),
+                MetadataValue::from_bytes(&value),
+            );
+        }
+
+        if let Some(timeout) = timeout {
+            request.set_timeout(std::time::Duration::from_millis(timeout));
+        }
+
+        let service: &'static str = Box::leak(service.into_boxed_str());
+        let method: &'static str = Box::leak(method.into_boxed_str());
+
+        request.extensions_mut().insert(GrpcMethod::new(service, method));
+
+        tracing::debug!("Sending unary request to {path_and_query}");
+
+        match client.unary(request, path_and_query, TrivialCodec).await {
+            Ok(response) => Ok(Ok(GrpcUnaryResponse {
+                metadata: tonic_metadata_to_wasi_metadata(response.metadata()),
+                message: response.into_inner(),
+            })),
+            Err(err) => Ok(Err(GrpcStatus {
+                code: convert_grpc_status_code(err.code()),
+                message: err.message().to_owned(),
+                metadata: tonic_metadata_to_wasi_metadata(err.metadata()),
+            })),
+        }
+    }
+
+    async fn streaming(
+        &mut self,
+        _self_: wasmtime::component::Resource<GrpcClient>,
+        _message: Vec<u8>,
+        _method: String,
+        _metadata: MetadataMap,
+        _timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<wasmtime::component::Resource<GrpcStreamingResponse>, GrpcStatus>> {
+        todo!()
+    }
+
+    async fn drop(&mut self, _rep: Resource<GrpcClient>) -> wasmtime::Result<()> {
+        Ok(())
+    }
+}
+
+struct TrivialCodec;
+
+impl tonic::codec::Codec for TrivialCodec {
+    type Encode = Vec<u8>;
+    type Decode = Vec<u8>;
+    type Encoder = Self;
+    type Decoder = Self;
+
+    fn encoder(&mut self) -> Self::Encoder {
+        TrivialCodec
+    }
+
+    fn decoder(&mut self) -> Self::Decoder {
+        TrivialCodec
+    }
+}
+
+impl tonic::codec::Encoder for TrivialCodec {
+    type Item = Vec<u8>;
+    type Error = tonic::Status;
+
+    fn encode(&mut self, item: Self::Item, dst: &mut tonic::codec::EncodeBuf<'_>) -> Result<(), Self::Error> {
+        dst.put_slice(item.as_slice());
+        Ok(())
+    }
+}
+
+impl tonic::codec::Decoder for TrivialCodec {
+    type Item = Vec<u8>;
+    type Error = tonic::Status;
+
+    fn decode(&mut self, src: &mut tonic::codec::DecodeBuf<'_>) -> Result<Option<Self::Item>, Self::Error> {
+        use bytes::Buf;
+        use std::io::Read;
+
+        let mut out = Vec::with_capacity(src.remaining());
+        src.reader().read_to_end(&mut out)?;
+        Ok(Some(out))
+    }
+}
+
+fn convert_grpc_status_code(code: tonic::Code) -> super::GrpcStatusCode {
path: crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
user: login: tomhoule
body: done
created_at: 2025-04-07T13:46:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,185 @@
+use super::{
+    GrpcClient, GrpcClientConfiguration, GrpcStatus, GrpcStreamingResponse, GrpcUnaryResponse, HostGrpcClient,
+    MetadataMap,
+};
+use crate::{
+    WasiState,
+    tonic::{
+        self, GrpcMethod,
+        metadata::{MetadataKey, MetadataValue},
+    },
+};
+use bytes::BufMut as _;
+use dashmap::Entry;
+use wasmtime::component::Resource;
+
+impl HostGrpcClient for WasiState {
+    async fn new(
+        &mut self,
+        configuration: GrpcClientConfiguration,
+    ) -> wasmtime::Result<Result<Resource<GrpcClient>, String>> {
+        tracing::debug!("Creating new gRPC client for URI: {}", configuration.uri);
+
+        let client = match self.grpc_clients().entry(configuration.uri.clone()) {
+            Entry::Occupied(entry) => entry.get().clone(),
+            Entry::Vacant(entry) => {
+                let transport = match tonic::transport::Endpoint::new(configuration.uri)?.connect().await {
+                    Ok(transport) => transport,
+                    Err(err) => return Ok(Err(err.to_string())),
+                };
+
+                let client = tonic::client::Grpc::new(transport);
+
+                entry.insert(client.clone());
+
+                client
+            }
+        };
+
+        let resource = self.push_resource(client)?;
+
+        Ok(Ok(resource))
+    }
+
+    async fn unary(
+        &mut self,
+        self_: Resource<GrpcClient>,
+        message: Vec<u8>,
+        service: String,
+        method: String,
+        metadata: MetadataMap,
+        timeout: Option<u64>,
+    ) -> wasmtime::Result<Result<GrpcUnaryResponse, GrpcStatus>> {
+        let client = self.get_mut(&self_)?;
+
+        client
+            .ready()
+            .await
+            .map_err(|e| tonic::Status::unknown(format!("Service was not ready: {e}")))?;
+
+        let path_and_query: http::uri::PathAndQuery = format!("/{service}/{method}").parse()?;
+        let mut request = tonic::Request::new(message);
+
+        for (key, value) in metadata {
+            request.metadata_mut().insert_bin(
+                MetadataKey::from_bytes(key.as_bytes()).unwrap(),
+                MetadataValue::from_bytes(&value),
+            );
+        }
+
+        if let Some(timeout) = timeout {
+            request.set_timeout(std::time::Duration::from_millis(timeout));
+        }
+
+        let service: &'static str = Box::leak(service.into_boxed_str());
path: crates/wasi-component-loader/src/extension/api/since_0_14_0/wit/grpc/client.rs
user: login: tomhoule
body: Done
created_at: 2025-04-07T13:47:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-04-04T08:38:48Z
body: <p><a href="https://linear.app/grafbase/issue/GB-8841/host-and-wit-support-for-grpc">GB-8841 Host and WIT support for gRPC</a></p>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-04T08:44:18Z
body: <table><tr><th><th>Tests</th><th>Passed ✅</th><th>Skipped ⏭️</th><th>Failed ❌</th></tr><tr><td>Federation Audit Report</td><td>179 ran</td><td>168 passed</td><td>0 skipped</td><td>11 failed</td></tr></table>

<!-- Summary comment for ["Federation Audit Report"] by mikepenz/action-junit-report -->
author_association: NONE