Skip to content

Commit dfb12e0

Browse files
feat: Add key-value storage support for plugins (#463)
* feat: Add key-value storage support for plugins Signed-off-by: Dylan Kilkenny <[email protected]> * chore: Add comments Signed-off-by: Dylan Kilkenny <[email protected]> * refactor: Apply review changes Signed-off-by: Dylan Kilkenny <[email protected]> * test: Fix Signed-off-by: Dylan Kilkenny <[email protected]> * fix: Rename `scan` method to `listKeys` --------- Signed-off-by: Dylan Kilkenny <[email protected]>
1 parent 0c23016 commit dfb12e0

File tree

13 files changed

+1102
-239
lines changed

13 files changed

+1102
-239
lines changed

docs/modules/ROOT/pages/plugins.adoc

Lines changed: 180 additions & 120 deletions
Large diffs are not rendered by default.

plugins/README.md

Lines changed: 98 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
## Relayer Plugins
32

43
Relayer plugins are TypeScript functions that can be invoked through the relayer HTTP API.
@@ -10,45 +9,63 @@ Under the hood, the relayer will execute the plugin code in a separate process u
109
### 1. Writing your plugin
1110

1211
```typescript
13-
import { Speed } from "@openzeppelin/relayer-sdk";
14-
import { PluginAPI, runPlugin } from "../lib/plugin";
12+
import { Speed, PluginContext } from '@openzeppelin/relayer-sdk';
1513

1614
type Params = {
17-
destinationAddress: string;
15+
destinationAddress: string;
16+
};
17+
18+
type Result = {
19+
success: boolean;
20+
transactionId: string;
1821
};
1922

20-
async function example(api: PluginAPI, params: Params): Promise<string> {
21-
console.info("Plugin started...");
22-
/**
23-
* Instances the relayer with the given id.
24-
*/
25-
const relayer = api.useRelayer("sepolia-example");
26-
27-
/**
28-
* Sends an arbitrary transaction through the relayer.
29-
*/
30-
const result = await relayer.sendTransaction({
31-
to: params.destinationAddress,
32-
value: 1,
33-
data: "0x",
34-
gas_limit: 21000,
35-
speed: Speed.FAST,
36-
});
37-
38-
/*
39-
* Waits for the transaction to be mined on chain.
40-
*/
41-
await result.wait();
42-
43-
return "done!";
23+
export async function handler(context: PluginContext): Promise<Result> {
24+
const { api, params, kv } = context;
25+
console.info('Plugin started...');
26+
27+
const relayer = api.useRelayer('sepolia-example');
28+
const result = await relayer.sendTransaction({
29+
to: params.destinationAddress,
30+
value: 1,
31+
data: '0x',
32+
gas_limit: 21000,
33+
speed: Speed.FAST,
34+
});
35+
36+
// Optional: persist last transaction id
37+
await kv.set('last_tx_id', result.id);
38+
39+
await result.wait();
40+
return { success: true, transactionId: result.id };
4441
}
42+
```
43+
44+
#### Legacy patterns (deprecated)
4545

46-
/**
47-
* This is the entry point for the plugin
48-
*/
49-
runPlugin(example);
46+
The following patterns are supported for backward compatibility but will be removed in a future version. They do not provide access to the KV store.
47+
48+
```typescript
49+
// Legacy: runPlugin pattern (deprecated)
50+
import { PluginAPI, runPlugin } from '../lib/plugin';
51+
52+
async function legacyMain(api: PluginAPI, params: any) {
53+
// logic here (no KV access)
54+
return 'done!';
55+
}
56+
57+
runPlugin(legacyMain);
5058
```
5159

60+
```typescript
61+
// Legacy: two-parameter handler (deprecated, no KV)
62+
import { PluginAPI } from '@openzeppelin/relayer-sdk';
63+
64+
export async function handler(api: PluginAPI, params: any): Promise<any> {
65+
// logic here (no KV access)
66+
return 'done!';
67+
}
68+
```
5269

5370
### 2. Adding extra dependencies
5471

@@ -61,25 +78,17 @@ pnpm add ethers
6178
And then just import them in your plugin.
6279

6380
```typescript
64-
import { ethers } from "ethers";
81+
import { ethers } from 'ethers';
6582
```
6683

6784
### 3. Adding to config file
6885

6986
- id: The id of the plugin. This is used to call a specific plugin through the HTTP API.
7087
- path: The path to the plugin file - relative to the `/plugins` folder.
71-
- timeout (optional): The timeout for the script execution *in seconds*. If not provided, the default timeout of 300 seconds (5 minutes) will be used.
88+
- timeout (optional): The timeout for the script execution _in seconds_. If not provided, the default timeout of 300 seconds (5 minutes) will be used.
7289

7390
```yaml
74-
{
75-
"plugins": [
76-
{
77-
"id": "example",
78-
"path": "examples/example.ts",
79-
"timeout": 30
80-
}
81-
]
82-
}
91+
{ 'plugins': [{ 'id': 'example', 'path': 'examples/example.ts', 'timeout': 30 }] }
8392
```
8493

8594
## Usage
@@ -138,3 +147,49 @@ Example response:
138147
"error": null
139148
}
140149
```
150+
151+
## Key-Value Store (KV)
152+
153+
Plugins have access to a built-in KV store via the `PluginContext.kv` property for persistent state and safe concurrency.
154+
155+
- Uses the same Redis URL as the Relayer (`REDIS_URL`)
156+
- Keys are namespaced per plugin ID
157+
- JSON values are supported
158+
159+
```typescript
160+
import { PluginContext } from '@openzeppelin/relayer-sdk';
161+
162+
export async function handler(context: PluginContext) {
163+
const { kv } = context;
164+
165+
// Set with optional TTL
166+
await kv.set('greeting', { text: 'hello' }, { ttlSec: 3600 });
167+
168+
// Get
169+
const v = await kv.get<{ text: string }>('greeting');
170+
171+
// Atomic update with lock
172+
const count = await kv.withLock(
173+
'counter',
174+
async () => {
175+
const cur = (await kv.get<number>('counter')) ?? 0;
176+
const next = cur + 1;
177+
await kv.set('counter', next);
178+
return next;
179+
},
180+
{ ttlSec: 10 }
181+
);
182+
183+
return { v, count };
184+
}
185+
```
186+
187+
Available methods:
188+
189+
- `get<T>(key: string): Promise<T | null>`
190+
- `set(key: string, value: unknown, opts?: { ttlSec?: number }): Promise<boolean>`
191+
- `del(key: string): Promise<boolean>`
192+
- `exists(key: string): Promise<boolean>`
193+
- `listKeys(pattern?: string, batch?: number): Promise<string[]>`
194+
- `clear(): Promise<number>`
195+
- `withLock<T>(key: string, fn: () => Promise<T>, opts?: { ttlSec?: number; onBusy?: 'throw' | 'skip' }): Promise<T | null>`

plugins/examples/kv-storage.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { PluginContext } from '../lib/plugin';
2+
3+
/**
4+
* Simple KV storage example
5+
*
6+
* Demonstrates:
7+
* - JSON set/get (with optional TTL)
8+
* - exists/del
9+
* - scan pattern listing
10+
* - clear namespace
11+
* - withLock for atomic sections
12+
*
13+
* Usage (params.action):
14+
* - 'demo' (default): run a small end-to-end flow
15+
* - 'set': { key: string, value: any, ttlSec?: number }
16+
* - 'get': { key: string }
17+
* - 'exists': { key: string }
18+
* - 'del': { key: string }
19+
* - 'scan': { pattern?: string, batch?: number }
20+
* - 'clear': {}
21+
* - 'withLock': { key: string, ttlSec?: number, onBusy?: 'throw' | 'skip' }
22+
*/
23+
export async function handler({ kv, params }: PluginContext) {
24+
const action = params?.action ?? 'demo';
25+
26+
switch (action) {
27+
case 'set': {
28+
const { key, value, ttlSec } = params ?? {};
29+
assertString(key, 'key');
30+
const ok = await kv.set(key, value, { ttlSec: toInt(ttlSec) });
31+
return { ok };
32+
}
33+
34+
case 'get': {
35+
const { key } = params ?? {};
36+
assertString(key, 'key');
37+
const value = await kv.get(key);
38+
return { value };
39+
}
40+
41+
case 'exists': {
42+
const { key } = params ?? {};
43+
assertString(key, 'key');
44+
const exists = await kv.exists(key);
45+
return { exists };
46+
}
47+
48+
case 'del': {
49+
const { key } = params ?? {};
50+
assertString(key, 'key');
51+
const deleted = await kv.del(key);
52+
return { deleted };
53+
}
54+
55+
case 'scan': {
56+
const { pattern, batch } = params ?? {};
57+
const keys = await kv.listKeys(pattern ?? '*', toInt(batch, 500));
58+
return { keys };
59+
}
60+
61+
case 'clear': {
62+
const deleted = await kv.clear();
63+
return { deleted };
64+
}
65+
66+
case 'withLock': {
67+
const { key, ttlSec, onBusy } = params ?? {};
68+
assertString(key, 'key');
69+
const result = await kv.withLock(
70+
key,
71+
async () => {
72+
// Simulate a small critical section
73+
const stamp = Date.now();
74+
await kv.set(`example:last-lock:${key}`, { stamp });
75+
return { ok: true, stamp };
76+
},
77+
{ ttlSec: toInt(ttlSec, 30), onBusy: onBusy === 'skip' ? 'skip' : 'throw' }
78+
);
79+
return { result };
80+
}
81+
82+
case 'demo':
83+
default: {
84+
// 1) Write JSON and read it back
85+
await kv.set('example:greeting', { text: 'hello' });
86+
const greeting = await kv.get<{ text: string }>('example:greeting');
87+
88+
// 2) Write a TTL value (won't await expiry here)
89+
await kv.set('example:temp', { expires: true }, { ttlSec: 5 });
90+
91+
// 3) Check existence and delete
92+
const existedBefore = await kv.exists('example:to-delete');
93+
await kv.set('example:to-delete', { remove: true });
94+
const existedAfterSet = await kv.exists('example:to-delete');
95+
const deleted = await kv.del('example:to-delete');
96+
97+
// 4) Scan keys under example:*
98+
const list = await kv.listKeys('example:*');
99+
100+
// 5) Use a lock to protect an update
101+
const lockResult = await kv.withLock(
102+
'example:lock',
103+
async () => {
104+
const count = (await kv.get<number>('example:counter')) ?? 0;
105+
const next = count + 1;
106+
await kv.set('example:counter', next);
107+
return next;
108+
},
109+
{ ttlSec: 10, onBusy: 'throw' }
110+
);
111+
112+
return {
113+
greeting,
114+
ttlKeyWritten: true,
115+
existedBefore,
116+
existedAfterSet,
117+
deleted,
118+
scanned: list,
119+
lockResult,
120+
};
121+
}
122+
}
123+
}
124+
125+
function toInt(v: unknown, def = 0): number {
126+
const n = typeof v === 'string' ? parseInt(v, 10) : typeof v === 'number' ? Math.floor(v) : def;
127+
return Number.isFinite(n) && n > 0 ? n : def;
128+
}
129+
130+
function assertString(v: any, name: string): asserts v is string {
131+
if (typeof v !== 'string' || v.length === 0) throw new Error(`${name} must be a non-empty string`);
132+
}

plugins/lib/executor.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
* 3. Calls the plugin's exported 'handler' function
1616
* 4. Returns results back to the Rust environment
1717
*
18-
* Usage: ts-node executor.ts <socket_path> <params_json> <user_script_path>
18+
* Usage: ts-node executor.ts <socket_path> <plugin_id> <params_json> <user_script_path>
1919
*
2020
* Arguments:
2121
* - socket_path: Unix socket path for communication with relayer
22+
* - plugin_id: Plugin ID for namespacing KV storage
2223
* - params_json: JSON string containing plugin parameters
2324
* - user_script_path: Path to the user's plugin file to execute
2425
*/
@@ -29,27 +30,33 @@ import { LogInterceptor } from './logger';
2930

3031
/**
3132
* Extract and validate CLI arguments passed from Rust script_executor.rs
33+
* Now includes pluginId as a separate argument
3234
*/
3335
function extractCliArguments() {
34-
// Get arguments: [node, executor.ts, socketPath, paramsJson, userScriptPath]
36+
// Get arguments: [node, executor.ts, socketPath, pluginId, paramsJson, userScriptPath]
3537
const socketPath = process.argv[2];
36-
const paramsJson = process.argv[3];
37-
const userScriptPath = process.argv[4];
38+
const pluginId = process.argv[3]; // NEW: Plugin ID as separate arg
39+
const paramsJson = process.argv[4]; // Shifted from argv[3]
40+
const userScriptPath = process.argv[5]; // Shifted from argv[4]
3841

3942
// Validate required arguments
4043
if (!socketPath) {
4144
throw new Error("Socket path is required (argument 1)");
4245
}
4346

47+
if (!pluginId) {
48+
throw new Error("Plugin ID is required (argument 2)");
49+
}
50+
4451
if (!paramsJson) {
45-
throw new Error("Plugin parameters JSON is required (argument 2)");
52+
throw new Error("Plugin parameters JSON is required (argument 3)");
4653
}
4754

4855
if (!userScriptPath) {
49-
throw new Error("User script path is required (argument 3)");
56+
throw new Error("User script path is required (argument 4)");
5057
}
5158

52-
return { socketPath, paramsJson, userScriptPath };
59+
return { socketPath, pluginId, paramsJson, userScriptPath };
5360
}
5461

5562
/**
@@ -74,14 +81,14 @@ async function main(): Promise<void> {
7481
// This provides better backward compatibility with existing scripts
7582
logInterceptor.start();
7683

77-
// Extract and validate CLI arguments
78-
const { socketPath, paramsJson, userScriptPath } = extractCliArguments();
84+
// Extract and validate CLI arguments including plugin ID
85+
const { socketPath, pluginId, paramsJson, userScriptPath } = extractCliArguments();
7986

8087
// Parse plugin parameters
8188
const pluginParams = parsePluginParameters(paramsJson);
8289

83-
// Execute plugin with validated parameters
84-
const result = await runUserPlugin(socketPath, pluginParams, userScriptPath);
90+
// Pass plugin ID as separate argument
91+
const result = await runUserPlugin(socketPath, pluginId, pluginParams, userScriptPath);
8592

8693
// Add the result to LogInterceptor output
8794
logInterceptor.addResult(serializeResult(result));

0 commit comments

Comments
 (0)