redevplugin_worker_sdk/
lib.rs1use base64::Engine as _;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::ptr;
5
6mod hostcalls;
7
8pub use hostcalls::{network, storage};
9
10pub const WORKER_ABI_VERSION: &str = "redevplugin-wasm-worker-v2";
11pub const WORKER_REQUEST_SCHEMA_VERSION: &str = "redevplugin.worker_request.v2";
12const MAX_HOSTCALL_RESPONSE_BYTES: usize = 512 * 1024;
13
14#[derive(Debug, Clone, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct WorkerRequest {
17 pub schema_version: String,
18 pub method: String,
19 pub params: Value,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct WorkerError {
24 pub code: String,
25 pub message: String,
26}
27
28impl WorkerError {
29 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
30 Self {
31 code: code.into(),
32 message: message.into(),
33 }
34 }
35
36 pub fn invalid_request(message: impl Into<String>) -> Self {
37 Self::new("INVALID_REQUEST", message)
38 }
39
40 pub fn hostcall(message: impl Into<String>) -> Self {
41 Self::new("HOSTCALL_FAILED", message)
42 }
43}
44
45pub type WorkerResult = Result<Value, WorkerError>;
46
47#[derive(Serialize)]
48#[serde(untagged)]
49enum WorkerResponse<'a> {
50 Success {
51 ok: bool,
52 data: &'a Value,
53 },
54 Failure {
55 ok: bool,
56 error_code: &'a str,
57 message: &'a str,
58 },
59}
60
61pub fn decode_base64(value: &str) -> Result<Vec<u8>, WorkerError> {
62 base64::engine::general_purpose::STANDARD
63 .decode(value)
64 .map_err(|err| WorkerError::hostcall(format!("decode base64 response: {err}")))
65}
66
67pub fn decode_base64_text(value: &str) -> Result<String, WorkerError> {
68 String::from_utf8(decode_base64(value)?)
69 .map_err(|_| WorkerError::hostcall("decoded response is not UTF-8"))
70}
71
72#[doc(hidden)]
73pub fn allocate(length: u32) -> u32 {
74 if length == 0 {
75 return 0;
76 }
77 let (pointer, _) = leak_buffer(vec![0_u8; length as usize].into_boxed_slice());
78 pointer as u32
79}
80
81#[doc(hidden)]
82pub unsafe fn deallocate(pointer: u32, length: u32) {
83 if pointer == 0 || length == 0 {
84 return;
85 }
86 unsafe { drop_buffer(pointer as *mut u8, length as usize) }
87}
88
89#[doc(hidden)]
90pub unsafe fn invoke(pointer: u32, length: u32, handler: fn(WorkerRequest) -> WorkerResult) -> u64 {
91 let request_bytes = if pointer == 0 || length == 0 {
92 &[][..]
93 } else {
94 unsafe { std::slice::from_raw_parts(pointer as *const u8, length as usize) }
95 };
96 let result = serde_json::from_slice::<WorkerRequest>(request_bytes)
97 .map_err(|err| WorkerError::invalid_request(format!("decode worker request: {err}")))
98 .and_then(|request| {
99 if request.schema_version != WORKER_REQUEST_SCHEMA_VERSION {
100 return Err(WorkerError::invalid_request(
101 "worker request schema version is unsupported",
102 ));
103 }
104 if request.method.trim().is_empty() || !request.params.is_object() {
105 return Err(WorkerError::invalid_request(
106 "worker method and object params are required",
107 ));
108 }
109 handler(request)
110 });
111 let response = match &result {
112 Ok(data) => WorkerResponse::Success { ok: true, data },
113 Err(error) => WorkerResponse::Failure {
114 ok: false,
115 error_code: &error.code,
116 message: &error.message,
117 },
118 };
119 let bytes = serde_json::to_vec(&response).unwrap_or_else(|_| {
120 br#"{"ok":false,"error_code":"WORKER_SERIALIZATION_FAILED","message":"worker response serialization failed"}"#.to_vec()
121 });
122 let (pointer, length) = leak_buffer(bytes.into_boxed_slice());
123 let pointer = pointer as u32;
124 let length = length as u32;
125 ((pointer as u64) << 32) | length as u64
126}
127
128fn leak_buffer(buffer: Box<[u8]>) -> (*mut u8, usize) {
129 let length = buffer.len();
130 let pointer = Box::into_raw(buffer) as *mut u8;
131 (pointer, length)
132}
133
134unsafe fn drop_buffer(pointer: *mut u8, length: usize) {
135 let slice = ptr::slice_from_raw_parts_mut(pointer, length);
136 unsafe { drop(Box::from_raw(slice)) }
137}
138
139#[macro_export]
140macro_rules! export_worker {
141 ($handler:path) => {
142 #[unsafe(no_mangle)]
143 pub extern "C" fn redevplugin_worker_alloc(length: u32) -> u32 {
144 $crate::allocate(length)
145 }
146
147 #[unsafe(no_mangle)]
148 pub unsafe extern "C" fn redevplugin_worker_dealloc(pointer: u32, length: u32) {
149 unsafe { $crate::deallocate(pointer, length) }
150 }
151
152 #[unsafe(no_mangle)]
153 pub unsafe extern "C" fn redevplugin_worker_invoke(pointer: u32, length: u32) -> u64 {
154 unsafe { $crate::invoke(pointer, length, $handler) }
155 }
156 };
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use serde_json::json;
163
164 #[test]
165 fn worker_error_helpers_are_stable() {
166 assert_eq!(
167 WorkerError::invalid_request("missing title"),
168 WorkerError::new("INVALID_REQUEST", "missing title")
169 );
170 }
171
172 #[test]
173 fn base64_helpers_decode_network_payloads() {
174 assert_eq!(decode_base64_text("SGVsbG8=").unwrap(), "Hello");
175 assert!(decode_base64_text("not-base64").is_err());
176 }
177
178 #[test]
179 fn request_shape_is_closed() {
180 let valid: WorkerRequest = serde_json::from_value(json!({
181 "schema_version": WORKER_REQUEST_SCHEMA_VERSION,
182 "method": "notes.list",
183 "params": {}
184 }))
185 .unwrap();
186 assert_eq!(valid.method, "notes.list");
187 assert!(
188 serde_json::from_value::<WorkerRequest>(json!({
189 "schema_version": WORKER_REQUEST_SCHEMA_VERSION,
190 "method": "notes.list",
191 "params": {},
192 "gateway_token": "secret"
193 }))
194 .is_err()
195 );
196 }
197
198 #[test]
199 fn exact_layout_buffers_round_trip_across_response_sizes() {
200 for length in [1_usize, 7, 255, 4096, 65_537] {
201 let expected = (0..length)
202 .map(|index| (index % 251) as u8)
203 .collect::<Vec<_>>();
204 let (pointer, actual_length) = leak_buffer(expected.clone().into_boxed_slice());
205 assert_eq!(actual_length, length);
206 let actual = unsafe { std::slice::from_raw_parts(pointer, actual_length) };
207 assert_eq!(actual, expected);
208 unsafe { drop_buffer(pointer, actual_length) };
209 }
210 }
211}