node-red-context-s3 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -0
- package/context.js +152 -0
- package/context.test.js +159 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Node-RED S3 Context Storage
|
|
2
|
+
|
|
3
|
+
A Node-RED context storage implementation that uses AWS S3 as the backend storage for persistent context data.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This module provides a context storage implementation for Node-RED that stores context data in AWS S3 buckets. It implements the Node-RED context API and allows you to persist context data across Node-RED restarts using Amazon S3 as the storage backend.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Persistent Storage**: Context data is stored in AWS S3, ensuring data persistence
|
|
12
|
+
- **Scope Support**: Supports different context scopes (global, flow, node)
|
|
13
|
+
- **JSON Storage**: Data is stored as JSON objects in S3
|
|
14
|
+
- **AWS SDK Integration**: Uses the official AWS SDK for JavaScript v3
|
|
15
|
+
- **Error Handling**: Proper error handling for S3 operations
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install node-red-context-s3
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Configuration
|
|
24
|
+
|
|
25
|
+
To use this context storage in your Node-RED application, add the following to your `settings.js` file:
|
|
26
|
+
|
|
27
|
+
```javascript
|
|
28
|
+
module.exports = {
|
|
29
|
+
// ... other settings ...
|
|
30
|
+
|
|
31
|
+
contextStorage: {
|
|
32
|
+
s3: {
|
|
33
|
+
module: "node-red-context-s3",
|
|
34
|
+
config: {
|
|
35
|
+
bucket: "your-s3-bucket-name",
|
|
36
|
+
prefix: "node-red", // optional prefix for S3 keys
|
|
37
|
+
flushInterval: 5, // optional flush interval in seconds
|
|
38
|
+
// AWS credentials configuration
|
|
39
|
+
region: "us-east-1",
|
|
40
|
+
credentials: {
|
|
41
|
+
accessKeyId: "your-access-key",
|
|
42
|
+
secretAccessKey: "your-secret-key"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Set S3 as the default context storage
|
|
49
|
+
defaultContextStorage: "s3"
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### AWS Credentials
|
|
54
|
+
|
|
55
|
+
The module uses the AWS SDK for JavaScript v3. You can provide credentials in several ways:
|
|
56
|
+
|
|
57
|
+
1. **Direct configuration** (as shown above)
|
|
58
|
+
2. **Environment variables**: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`
|
|
59
|
+
3. **AWS credentials file**: `~/.aws/credentials`
|
|
60
|
+
4. **IAM roles** (when running on EC2 or ECS)
|
|
61
|
+
|
|
62
|
+
## API Methods
|
|
63
|
+
|
|
64
|
+
This module implements the standard Node-RED context API:
|
|
65
|
+
|
|
66
|
+
### `get(scope, keys, callback)`
|
|
67
|
+
Retrieves context values for the specified scope and keys.
|
|
68
|
+
|
|
69
|
+
### `set(scope, keys, values, callback)`
|
|
70
|
+
Sets context values for the specified scope and keys.
|
|
71
|
+
|
|
72
|
+
### `keys(scope, callback)`
|
|
73
|
+
Returns all keys available in the specified scope.
|
|
74
|
+
|
|
75
|
+
### `delete(scope)`
|
|
76
|
+
Deletes all context data for the specified scope.
|
|
77
|
+
|
|
78
|
+
### `clean(scopes)`
|
|
79
|
+
Cleans context data for multiple scopes.
|
|
80
|
+
|
|
81
|
+
## S3 Storage Structure
|
|
82
|
+
|
|
83
|
+
Context data is stored in S3 with the following key structure:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
{prefix}/context/{scope}.json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
- `node-red/context/global.json`
|
|
91
|
+
- `node-red/context/flow:abc123.json`
|
|
92
|
+
- `node-red/context/node:xyz789.json`
|
|
93
|
+
|
|
94
|
+
Each scope is stored as a separate JSON object in the S3 bucket.
|
|
95
|
+
|
|
96
|
+
## Testing
|
|
97
|
+
|
|
98
|
+
To run the tests:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm test
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Make sure to set up your AWS credentials and configure a test S3 bucket before running tests.
|
|
105
|
+
|
|
106
|
+
## Dependencies
|
|
107
|
+
|
|
108
|
+
- `@aws-sdk/client-s3`: AWS SDK for S3 operations
|
|
109
|
+
- `jest`: Testing framework
|
|
110
|
+
- `dotenv`: Environment variable management for tests
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT License - see LICENSE file for details
|
|
115
|
+
|
|
116
|
+
## Contributing
|
|
117
|
+
|
|
118
|
+
Contributions are welcome! Please feel free to submit issues and pull requests.
|
package/context.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const S3 = require("@aws-sdk/client-s3").S3;
|
|
2
|
+
|
|
3
|
+
function Context(settings) {
|
|
4
|
+
this.settings = Object.assign({
|
|
5
|
+
bucket: '',
|
|
6
|
+
prefix: '',
|
|
7
|
+
flushInterval: 5,
|
|
8
|
+
}, settings);
|
|
9
|
+
|
|
10
|
+
if (!this.settings.bucket) {
|
|
11
|
+
throw new Error('S3 bucket name is required');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
this.client = new S3(this.settings);
|
|
15
|
+
this.data = {};
|
|
16
|
+
this.cache = new Map(); // Use Map for better performance with string keys
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Context.prototype.open = async function () {
|
|
20
|
+
// Implementation can be added later if needed
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
Context.prototype.close = async function () {
|
|
24
|
+
// Implementation can be added later if needed
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Context.prototype.get = async function (scope, keys, callback) {
|
|
28
|
+
if (typeof callback !== 'function') {
|
|
29
|
+
callback = () => {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
if (!this.cache.has(scope)) {
|
|
34
|
+
try {
|
|
35
|
+
const object = await this.client.getObject({
|
|
36
|
+
Bucket: this.settings.bucket,
|
|
37
|
+
Key: `${this.settings.prefix}/context/${scope}.json`,
|
|
38
|
+
});
|
|
39
|
+
this.data[scope] = JSON.parse(await object.Body.transformToString()) || {};
|
|
40
|
+
this.cache.set(scope, true);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.name && error.name.indexOf('NoSuch') === 0) {
|
|
43
|
+
console.warn(`Scope ${scope} not found in S3:`, error.message);
|
|
44
|
+
this.data[scope] = {};
|
|
45
|
+
this.cache.set(scope, true);
|
|
46
|
+
} else {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const keysArray = Array.isArray(keys) ? keys : [keys];
|
|
53
|
+
const values = keysArray.map(key => this.data[scope][key]);
|
|
54
|
+
|
|
55
|
+
callback(null, ...values);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
callback(error);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
Context.prototype.set = async function (scope, keys, values, callback) {
|
|
62
|
+
if (typeof callback !== 'function') {
|
|
63
|
+
callback = () => {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
if (!this.data[scope] || typeof this.data[scope] !== 'object') {
|
|
68
|
+
this.data[scope] = {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const keysArray = Array.isArray(keys) ? keys : [keys];
|
|
72
|
+
const valuesArray = Array.isArray(values) ? values : [values];
|
|
73
|
+
|
|
74
|
+
if (keysArray.length !== valuesArray.length) {
|
|
75
|
+
throw new Error('Keys and values arrays must have the same length');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < keysArray.length; i++) {
|
|
79
|
+
this.data[scope][keysArray[i]] = valuesArray[i];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await this.client.putObject({
|
|
83
|
+
Bucket: this.settings.bucket,
|
|
84
|
+
Key: `${this.settings.prefix}/context/${scope}.json`,
|
|
85
|
+
Body: JSON.stringify(this.data[scope]),
|
|
86
|
+
ContentType: 'application/json',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Invalidate cache after successful write
|
|
90
|
+
this.cache.delete(scope);
|
|
91
|
+
|
|
92
|
+
callback(null);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
callback(error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
Context.prototype.keys = async function (scope, callback) {
|
|
99
|
+
if (typeof callback !== 'function') {
|
|
100
|
+
callback = () => {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
if (!this.cache.has(scope)) {
|
|
105
|
+
await this.get(scope, []);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const keys = Object.keys(this.data[scope] || {});
|
|
109
|
+
callback(null, keys);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
callback(error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
Context.prototype.delete = async function (scope) {
|
|
116
|
+
try {
|
|
117
|
+
await this.client.deleteObject({
|
|
118
|
+
Bucket: this.settings.bucket,
|
|
119
|
+
Key: `${this.settings.prefix}/context/${scope}.json`,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Clean up local cache
|
|
123
|
+
delete this.data[scope];
|
|
124
|
+
this.cache.delete(scope);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error.name && error.name.indexOf('NoSuch') === 0) {
|
|
127
|
+
console.warn(`Scope ${scope} not found during deletion:`, error.message);
|
|
128
|
+
} else {
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
Context.prototype.clean = async function (scopes) {
|
|
135
|
+
if (!Array.isArray(scopes)) {
|
|
136
|
+
scopes = [scopes];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const scope of scopes) {
|
|
140
|
+
await this.delete(scope);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Add a method to clear all cached data
|
|
145
|
+
Context.prototype.clearCache = function () {
|
|
146
|
+
this.data = {};
|
|
147
|
+
this.cache.clear();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
module.exports = function (settings) {
|
|
151
|
+
return new Context(settings);
|
|
152
|
+
};
|
package/context.test.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
require('dotenv').config();
|
|
2
|
+
const context = require('./context');
|
|
3
|
+
|
|
4
|
+
// Mock AWS S3 client
|
|
5
|
+
jest.mock('@aws-sdk/client-s3', () => {
|
|
6
|
+
return {
|
|
7
|
+
S3: jest.fn().mockImplementation(() => ({
|
|
8
|
+
getObject: jest.fn(),
|
|
9
|
+
putObject: jest.fn(),
|
|
10
|
+
deleteObject: jest.fn(),
|
|
11
|
+
})),
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const { S3 } = require('@aws-sdk/client-s3');
|
|
16
|
+
|
|
17
|
+
describe('S3 Context Storage', () => {
|
|
18
|
+
let s3Context;
|
|
19
|
+
let mockS3Client;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mockS3Client = {
|
|
23
|
+
getObject: jest.fn(),
|
|
24
|
+
putObject: jest.fn(),
|
|
25
|
+
deleteObject: jest.fn(),
|
|
26
|
+
};
|
|
27
|
+
S3.mockImplementation(() => mockS3Client);
|
|
28
|
+
|
|
29
|
+
s3Context = context({
|
|
30
|
+
bucket: 'test-bucket',
|
|
31
|
+
prefix: 'test-prefix',
|
|
32
|
+
flushInterval: 5,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
jest.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('should throw error when bucket name is not provided', () => {
|
|
41
|
+
expect(() => context({})).toThrow('S3 bucket name is required');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('should initialize with default settings', () => {
|
|
45
|
+
const ctx = context({ bucket: 'test-bucket' });
|
|
46
|
+
expect(ctx.settings.bucket).toBe('test-bucket');
|
|
47
|
+
expect(ctx.settings.prefix).toBe('');
|
|
48
|
+
expect(ctx.settings.flushInterval).toBe(5);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('should get values from scope', async () => {
|
|
52
|
+
const mockData = { key1: 'value1', key2: 'value2' };
|
|
53
|
+
mockS3Client.getObject.mockResolvedValue({
|
|
54
|
+
Body: {
|
|
55
|
+
transformToString: jest.fn().mockResolvedValue(JSON.stringify(mockData)),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const callback = jest.fn();
|
|
60
|
+
await s3Context.get('test-scope', ['key1', 'key2'], callback);
|
|
61
|
+
|
|
62
|
+
expect(callback).toHaveBeenCalledWith(null, 'value1', 'value2');
|
|
63
|
+
expect(mockS3Client.getObject).toHaveBeenCalledWith({
|
|
64
|
+
Bucket: 'test-bucket',
|
|
65
|
+
Key: 'test-prefix/context/test-scope.json',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('should handle missing scope gracefully', async () => {
|
|
70
|
+
const error = new Error('NoSuchKey');
|
|
71
|
+
error.name = 'NoSuchKey';
|
|
72
|
+
mockS3Client.getObject.mockRejectedValue(error);
|
|
73
|
+
|
|
74
|
+
const callback = jest.fn();
|
|
75
|
+
await s3Context.get('non-existent-scope', ['key1'], callback);
|
|
76
|
+
|
|
77
|
+
expect(callback).toHaveBeenCalledWith(null, undefined);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('should set values to scope', async () => {
|
|
81
|
+
const callback = jest.fn();
|
|
82
|
+
await s3Context.set('test-scope', 'key1', 'value1', callback);
|
|
83
|
+
|
|
84
|
+
expect(callback).toHaveBeenCalledWith(null);
|
|
85
|
+
expect(mockS3Client.putObject).toHaveBeenCalledWith({
|
|
86
|
+
Bucket: 'test-bucket',
|
|
87
|
+
Key: 'test-prefix/context/test-scope.json',
|
|
88
|
+
Body: JSON.stringify({ key1: 'value1' }),
|
|
89
|
+
ContentType: 'application/json',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('should handle array keys and values', async () => {
|
|
94
|
+
const callback = jest.fn();
|
|
95
|
+
await s3Context.set('test-scope', ['key1', 'key2'], ['value1', 'value2'], callback);
|
|
96
|
+
|
|
97
|
+
expect(callback).toHaveBeenCalledWith(null);
|
|
98
|
+
expect(mockS3Client.putObject).toHaveBeenCalledWith({
|
|
99
|
+
Bucket: 'test-bucket',
|
|
100
|
+
Key: 'test-prefix/context/test-scope.json',
|
|
101
|
+
Body: JSON.stringify({ key1: 'value1', key2: 'value2' }),
|
|
102
|
+
ContentType: 'application/json',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('should validate keys and values array length', async () => {
|
|
107
|
+
const callback = jest.fn();
|
|
108
|
+
await expect(
|
|
109
|
+
s3Context.set('test-scope', ['key1', 'key2'], ['value1'], callback)
|
|
110
|
+
).rejects.toThrow('Keys and values arrays must have the same length');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('should list keys from scope', async () => {
|
|
114
|
+
const mockData = { key1: 'value1', key2: 'value2' };
|
|
115
|
+
mockS3Client.getObject.mockResolvedValue({
|
|
116
|
+
Body: {
|
|
117
|
+
transformToString: jest.fn().mockResolvedValue(JSON.stringify(mockData)),
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const callback = jest.fn();
|
|
122
|
+
await s3Context.keys('test-scope', callback);
|
|
123
|
+
|
|
124
|
+
expect(callback).toHaveBeenCalledWith(null, ['key1', 'key2']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('should delete scope', async () => {
|
|
128
|
+
await s3Context.delete('test-scope');
|
|
129
|
+
|
|
130
|
+
expect(mockS3Client.deleteObject).toHaveBeenCalledWith({
|
|
131
|
+
Bucket: 'test-bucket',
|
|
132
|
+
Key: 'test-prefix/context/test-scope.json',
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('should handle deletion of non-existent scope', async () => {
|
|
137
|
+
const error = new Error('NoSuchKey');
|
|
138
|
+
error.name = 'NoSuchKey';
|
|
139
|
+
mockS3Client.deleteObject.mockRejectedValue(error);
|
|
140
|
+
|
|
141
|
+
await expect(s3Context.delete('non-existent-scope')).resolves.not.toThrow();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('should clean multiple scopes', async () => {
|
|
145
|
+
await s3Context.clean(['scope1', 'scope2']);
|
|
146
|
+
|
|
147
|
+
expect(mockS3Client.deleteObject).toHaveBeenCalledTimes(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('should clear cache', () => {
|
|
151
|
+
s3Context.data = { scope1: { key: 'value' } };
|
|
152
|
+
s3Context.cache.set('scope1', true);
|
|
153
|
+
|
|
154
|
+
s3Context.clearCache();
|
|
155
|
+
|
|
156
|
+
expect(s3Context.data).toEqual({});
|
|
157
|
+
expect(s3Context.cache.size).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-context-s3",
|
|
3
|
+
"license": "MIT",
|
|
4
|
+
"main": "context.js",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "jest ./ --runInBand --forceExit --colors --verbose=false --silent=false"
|
|
7
|
+
},
|
|
8
|
+
"version": "0.0.1",
|
|
9
|
+
"author": {
|
|
10
|
+
"name": "eslizn",
|
|
11
|
+
"email": "eslizn@gmail.com"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@aws-sdk/client-s3": "^3.987.0"
|
|
15
|
+
},
|
|
16
|
+
"deprecated": false,
|
|
17
|
+
"description": "implementation of the node-red context API for S3 Protocol",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"node-red",
|
|
20
|
+
"context",
|
|
21
|
+
"s3"
|
|
22
|
+
],
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"dotenv": "^17.2.4",
|
|
25
|
+
"jest": "^30.2.0"
|
|
26
|
+
}
|
|
27
|
+
}
|