Skip to content

Commit 6c531e5

Browse files
aepfliclaude
andauthored
refactor(java): dynamically match wasm-bindgen host functions by prefix (#96)
## Summary - **WasmRuntime.java** now inspects the WASM module's import section at startup and registers host function handlers by prefix pattern matching, instead of hardcoding 9 function names with wasm-bindgen hash suffixes - **HOST_FUNCTIONS.md** rewritten to document all 9 WASM imports across 3 modules, with dynamic matching examples for Java, Go, and JavaScript ## Motivation wasm-bindgen generates function names with hash suffixes (e.g., `__wbg_getTime_ad1e9878a735af08`) that change whenever Rust dependencies or wasm-bindgen versions update. This has broken CI in multiple PRs (#64). By matching imports by prefix (`__wbg_getTime_*`) instead of exact name, the Java integration survives dependency changes without code updates. ## How it works 1. Load the WASM module via `CompiledEvaluator.load()` 2. Iterate `importSection().stream()` to discover all `FunctionImport`s 3. Match each import by module + name prefix to the appropriate handler 4. Register handlers dynamically in the Chicory `Store` ## Test plan - [x] All 30 Java tests pass (`./mvnw test`) - [x] WASM binary imports verified via `wasm-objdump` - [x] No Rust code changes required Closes #74 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 044f4c3 commit 6c531e5

2 files changed

Lines changed: 243 additions & 401 deletions

File tree

HOST_FUNCTIONS.md

Lines changed: 119 additions & 203 deletions
Original file line numberDiff line numberDiff line change
@@ -1,240 +1,166 @@
11
# Host Functions
22

3-
The flagd-evaluator WASM module requires the host environment to provide certain functions. These are imported by the WASM module and must be implemented by the runtime (e.g., Java/Chicory, JavaScript, Go, etc.).
3+
The flagd-evaluator WASM module requires the host environment to provide certain functions. These are imported by the WASM module and must be implemented by the runtime (e.g., Java/Chicory, Go/wazero, JavaScript).
44

5-
## Required Host Function: `get_current_time_unix_seconds`
5+
## Import Overview
66

7-
**Module:** `host`
8-
**Function name:** `get_current_time_unix_seconds`
9-
**Signature:** `() -> u64`
7+
The WASM module declares imports across 3 modules:
108

11-
### Purpose
9+
| Module | Functions | Stability |
10+
|--------|-----------|-----------|
11+
| `host` | 1 (stable names) | Stable — names never change |
12+
| `__wbindgen_placeholder__` | ~6 (hashed names) | Names change with Rust dependency updates |
13+
| `__wbindgen_externref_xform__` | ~2 (fixed names) | Names are stable but may appear/disappear |
1214

13-
Provides the current Unix timestamp (seconds since epoch: 1970-01-01 00:00:00 UTC) to the WASM module. This is used for context enrichment to populate the `$flagd.timestamp` property, which can be used in targeting rules.
15+
**Important:** The `__wbindgen_placeholder__` function names include a hash suffix (e.g., `__wbg_getTime_ad1e9878a735af08`) that changes whenever Rust dependencies or wasm-bindgen versions change. Host implementations should **match by prefix**, not by exact name. See the [Dynamic Matching](#dynamic-matching-recommended) section.
1416

15-
### Why a Host Function?
17+
## Stable Host Functions
1618

17-
The WASM sandbox cannot access system time directly without WASI support. Since Chicory and other pure WASM runtimes don't provide WASI, the host must supply the current time.
19+
### `host::get_current_time_unix_seconds`
1820

19-
### Return Value
21+
**Signature:** `() -> i64`
2022

21-
- **Type:** `u64`
22-
- **Value:** Unix timestamp in seconds since epoch
23-
- **Example:** `1735689600` (represents 2025-01-01 00:00:00 UTC)
23+
Provides the current Unix timestamp (seconds since epoch) for `$flagd.timestamp` context enrichment. The WASM sandbox cannot access system time without WASI, so the host must supply it.
2424

25-
## Implementation Examples
25+
**Return value:** Unix timestamp in seconds (e.g., `1735689600` for 2025-01-01 00:00:00 UTC).
2626

27-
### Java (Chicory)
27+
**If not provided:** The module defaults `$flagd.timestamp` to `0`. Time-based targeting won't work, but evaluation continues without errors.
2828

29-
```java
30-
import com.dylibso.chicory.runtime.HostFunction;
31-
import com.dylibso.chicory.wasm.types.Value;
32-
import com.dylibso.chicory.wasm.types.ValueType;
33-
34-
// Create the host function
35-
HostFunction getCurrentTime = new HostFunction(
36-
"host", // Module name
37-
"get_current_time_unix_seconds", // Function name
38-
List.of(), // No parameters
39-
List.of(ValueType.I64), // Returns i64
40-
(Instance instance, Value... args) -> {
41-
long currentTimeSeconds = System.currentTimeMillis() / 1000;
42-
return new Value[] { Value.i64(currentTimeSeconds) };
43-
}
44-
);
29+
## wasm-bindgen Functions
4530

46-
// Add to module imports when loading WASM
47-
Module module = Module.builder(wasmBytes)
48-
.withHostFunction(getCurrentTime)
49-
.build();
50-
```
31+
These imports come from Rust dependencies (chrono, getrandom) using wasm-bindgen. Their names contain hashes that change across builds. Match by prefix.
5132

52-
### Complete Java Example
33+
### `__wbg_getRandomValues_*`
5334

54-
```java
55-
import com.dylibso.chicory.runtime.*;
56-
import com.dylibso.chicory.wasm.types.*;
57-
import java.nio.charset.StandardCharsets;
58-
59-
public class FlagdEvaluatorWithHostFunctions {
60-
public static void main(String[] args) {
61-
// Load WASM module
62-
byte[] wasmBytes = Files.readAllBytes(Path.of("flagd_evaluator.wasm"));
63-
64-
// Define host function for current time
65-
HostFunction getCurrentTime = new HostFunction(
66-
"host",
67-
"get_current_time_unix_seconds",
68-
List.of(),
69-
List.of(ValueType.I64),
70-
(Instance instance, Value... unused) -> {
71-
long now = System.currentTimeMillis() / 1000;
72-
return new Value[] { Value.i64(now) };
73-
}
74-
);
75-
76-
// Build module with host function
77-
Module module = Module.builder(wasmBytes)
78-
.withHostFunction(getCurrentTime)
79-
.build();
80-
81-
Instance instance = module.instantiate();
82-
83-
// Get WASM exports
84-
ExportFunction alloc = instance.export("alloc");
85-
ExportFunction dealloc = instance.export("dealloc");
86-
ExportFunction updateState = instance.export("update_state");
87-
ExportFunction evaluate = instance.export("evaluate");
88-
89-
// Load flag configuration
90-
String config = """
91-
{
92-
"flags": {
93-
"time-based-flag": {
94-
"state": "ENABLED",
95-
"variants": {
96-
"on": true,
97-
"off": false
98-
},
99-
"defaultVariant": "off",
100-
"targeting": {
101-
"if": [
102-
{">": [{"var": "$flagd.timestamp"}, 1700000000]},
103-
"on",
104-
"off"
105-
]
106-
}
107-
}
108-
}
109-
}
110-
""";
35+
**Module:** `__wbindgen_placeholder__`
36+
**Signature:** `(i32, i32) -> void`
37+
**Purpose:** Cryptographic entropy for hash table seeding (ahash in boon JSON schema validation).
11138

112-
// Update state
113-
byte[] configBytes = config.getBytes(StandardCharsets.UTF_8);
114-
int configPtr = alloc.apply(Value.i32(configBytes.length))[0].asInt();
115-
instance.memory().write(configPtr, configBytes);
39+
The first argument is an externref index (can be ignored). The second argument is a pointer to a 32-byte buffer in WASM memory. Fill the buffer with random bytes.
11640

117-
long packedResult = updateState.apply(
118-
Value.i32(configPtr),
119-
Value.i32(configBytes.length)
120-
)[0].asLong();
41+
### `__wbg_new_0_*`
12142

122-
dealloc.apply(Value.i32(configPtr), Value.i32(configBytes.length));
43+
**Module:** `__wbindgen_placeholder__`
44+
**Signature:** `() -> i32`
45+
**Purpose:** JavaScript `Date` constructor shim (used by chrono's wasmbind feature).
12346

124-
// Evaluate flag with context
125-
String flagKey = "time-based-flag";
126-
String context = "{\"email\":\"user@example.com\"}";
47+
Return `0` (dummy reference). The actual timestamp is provided by `host::get_current_time_unix_seconds`.
12748

128-
byte[] flagKeyBytes = flagKey.getBytes(StandardCharsets.UTF_8);
129-
byte[] contextBytes = context.getBytes(StandardCharsets.UTF_8);
49+
### `__wbg_getTime_*`
13050

131-
int flagKeyPtr = alloc.apply(Value.i32(flagKeyBytes.length))[0].asInt();
132-
int contextPtr = alloc.apply(Value.i32(contextBytes.length))[0].asInt();
51+
**Module:** `__wbindgen_placeholder__`
52+
**Signature:** `(i32) -> f64`
53+
**Purpose:** JavaScript `Date.getTime()` shim.
13354

134-
instance.memory().write(flagKeyPtr, flagKeyBytes);
135-
instance.memory().write(contextPtr, contextBytes);
55+
Return current time in **milliseconds** as f64. The argument is the Date reference from `new_0` (ignored).
13656

137-
long evalResult = evaluate.apply(
138-
Value.i32(flagKeyPtr),
139-
Value.i32(flagKeyBytes.length),
140-
Value.i32(contextPtr),
141-
Value.i32(contextBytes.length)
142-
)[0].asLong();
57+
### `__wbg___wbindgen_throw_*`
14358

144-
// Unpack result pointer and length
145-
int resultPtr = (int) (evalResult >> 32);
146-
int resultLen = (int) (evalResult & 0xFFFFFFFF);
59+
**Module:** `__wbindgen_placeholder__`
60+
**Signature:** `(i32, i32) -> void`
61+
**Purpose:** Error propagation from WASM to host.
14762

148-
byte[] resultBytes = new byte[resultLen];
149-
instance.memory().read(resultPtr, resultBytes);
150-
String result = new String(resultBytes, StandardCharsets.UTF_8);
63+
Arguments are (pointer, length) of a UTF-8 error message in WASM memory. The host should throw/raise an exception with the message.
15164

152-
System.out.println("Evaluation result: " + result);
65+
### `__wbindgen_object_drop_ref`, `__wbindgen_describe`
15366

154-
// Clean up
155-
dealloc.apply(Value.i32(flagKeyPtr), Value.i32(flagKeyBytes.length));
156-
dealloc.apply(Value.i32(contextPtr), Value.i32(contextBytes.length));
157-
dealloc.apply(Value.i32(resultPtr), Value.i32(resultLen));
158-
}
159-
}
160-
```
67+
**Module:** `__wbindgen_placeholder__`
68+
**Signature:** `(i32) -> void`
69+
**Purpose:** wasm-bindgen internals. No-ops — these track JavaScript object references which don't exist in non-browser runtimes.
16170

162-
### JavaScript (Node.js with WASI)
71+
### `__wbindgen_externref_table_grow`
16372

164-
```javascript
165-
const fs = require('fs');
73+
**Module:** `__wbindgen_externref_xform__`
74+
**Signature:** `(i32) -> i32`
75+
**Purpose:** Grow the external reference table. Return a fixed value (e.g., `128`).
16676

167-
// Load WASM
168-
const wasmBytes = fs.readFileSync('flagd_evaluator.wasm');
77+
### `__wbindgen_externref_table_set_null`
16978

170-
// Define host functions
171-
const importObject = {
172-
host: {
173-
get_current_time_unix_seconds: () => {
174-
return BigInt(Math.floor(Date.now() / 1000));
175-
}
176-
}
177-
};
79+
**Module:** `__wbindgen_externref_xform__`
80+
**Signature:** `(i32) -> void`
81+
**Purpose:** Set externref table entry to null. No-op.
82+
83+
## Dynamic Matching (Recommended)
84+
85+
Instead of hardcoding exact function names, inspect the WASM module's import section at startup and match by prefix. This way, hash changes from Rust dependency updates don't require host code changes.
17886

179-
// Instantiate WASM with host functions
180-
WebAssembly.instantiate(wasmBytes, importObject)
181-
.then(({ instance }) => {
182-
// Use the WASM exports
183-
const { alloc, dealloc, evaluate } = instance.exports;
184-
// ... rest of implementation
87+
### Java (Chicory)
88+
89+
```java
90+
WasmModule module = CompiledEvaluator.load();
91+
Store store = new Store();
92+
93+
module.importSection().stream()
94+
.filter(FunctionImport.class::isInstance)
95+
.map(FunctionImport.class::cast)
96+
.forEach(fi -> {
97+
String mod = fi.module();
98+
String name = fi.name();
99+
100+
if ("host".equals(mod) && "get_current_time_unix_seconds".equals(name)) {
101+
// Register timestamp provider
102+
} else if (name.startsWith("__wbg_getRandomValues_")) {
103+
// Register random bytes provider
104+
} else if (name.startsWith("__wbg_getTime_")) {
105+
// Register Date.getTime shim
106+
} else if (name.startsWith("__wbg_new_0_")) {
107+
// Register Date constructor shim (return 0)
108+
} else if (name.contains("__wbindgen_throw")) {
109+
// Register throw handler
110+
} else {
111+
// Register no-op for all other wasm-bindgen imports
112+
}
185113
});
186114
```
187115

188-
### Go
116+
See `WasmRuntime.java` for the full implementation.
117+
118+
### Go (wazero)
189119

190120
```go
191-
package main
192-
193-
import (
194-
"time"
195-
"github.com/tetratelabs/wazero"
196-
"github.com/tetratelabs/wazero/api"
197-
)
198-
199-
func main() {
200-
ctx := context.Background()
201-
runtime := wazero.NewRuntime(ctx)
202-
defer runtime.Close(ctx)
203-
204-
// Define host function
205-
hostModule := runtime.NewHostModuleBuilder("host")
206-
hostModule.NewFunctionBuilder().
207-
WithFunc(func() int64 {
208-
return time.Now().Unix()
209-
}).
210-
Export("get_current_time_unix_seconds")
211-
212-
_, err := hostModule.Instantiate(ctx)
213-
if err != nil {
214-
panic(err)
121+
// Match by prefix when registering host functions
122+
hostBuilder := r.NewHostModuleBuilder("__wbindgen_placeholder__")
123+
124+
// Use the actual import names from the WASM binary
125+
for _, imp := range wasmModule.ImportedFunctions() {
126+
moduleName, name, _ := imp.Import()
127+
switch {
128+
case strings.HasPrefix(name, "__wbg_getRandomValues_"):
129+
hostBuilder.NewFunctionBuilder().
130+
WithFunc(func(ctx context.Context, mod api.Module, externref, bufPtr uint32) { ... }).
131+
Export(name)
132+
// ... other prefix matches
215133
}
216-
217-
// Load and instantiate WASM module
218-
wasmBytes, _ := os.ReadFile("flagd_evaluator.wasm")
219-
module, _ := runtime.Instantiate(ctx, wasmBytes)
220-
221-
// Use the WASM exports
222-
// ...
223134
}
224135
```
225136

226-
## Behavior Without Host Function
137+
### JavaScript
227138

228-
If the host function is not provided:
229-
- The WASM module will catch the panic and default `$flagd.timestamp` to `0`
230-
- This allows targeting rules to detect unavailable time by checking for `timestamp == 0`
231-
- Evaluation will continue without errors, but time-based targeting will not work correctly
139+
```javascript
140+
// In JavaScript, wasm-bindgen host functions are usually provided
141+
// automatically by the generated JS glue code. For manual usage:
142+
const importObject = {
143+
host: {
144+
get_current_time_unix_seconds: () => BigInt(Math.floor(Date.now() / 1000))
145+
},
146+
__wbindgen_placeholder__: new Proxy({}, {
147+
get: (target, name) => {
148+
// Dynamic proxy handles any wbindgen function name
149+
if (name.startsWith('__wbg_getRandomValues_'))
150+
return (ref, ptr) => { crypto.getRandomValues(new Uint8Array(memory.buffer, ptr, 32)); };
151+
if (name.startsWith('__wbg_getTime_'))
152+
return (ref) => Date.now();
153+
// ... etc
154+
return () => {}; // no-op fallback
155+
}
156+
})
157+
};
158+
```
232159

233-
## Testing the Host Function
160+
## Testing
234161

235-
You can verify the host function is working by:
162+
Verify host functions work by evaluating a flag with time-based targeting:
236163

237-
1. Create a flag with time-based targeting:
238164
```json
239165
{
240166
"flags": {
@@ -243,21 +169,11 @@ You can verify the host function is working by:
243169
"variants": {"on": true, "off": false},
244170
"defaultVariant": "off",
245171
"targeting": {
246-
"if": [
247-
{">": [{"var": "$flagd.timestamp"}, 0]},
248-
"on",
249-
"off"
250-
]
172+
"if": [{">": [{"var": "$flagd.timestamp"}, 0]}, "on", "off"]
251173
}
252174
}
253175
}
254176
}
255177
```
256178

257-
2. Evaluate the flag and check that `$flagd.timestamp` is non-zero in the context
258-
259-
3. The flag should resolve to `"on"` if the timestamp is provided correctly
260-
261-
## Future Host Functions
262-
263-
This document will be updated as additional host functions are added to the WASM module.
179+
The flag should resolve to `"on"` when the timestamp host function is provided correctly.

0 commit comments

Comments
 (0)