@azuliani/node-service 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.
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ const doValidation = require("../misc/Validation").SinkValidation;
3
+
4
+
5
+ class SinkClient {
6
+ constructor(endpoint, transports){
7
+ this.endpoint = endpoint;
8
+ this.transport = transports.sink;
9
+ if (!this.transport)
10
+ throw "Trying to construct Sink endpoint without pushpull transport";
11
+ }
12
+
13
+ push(message){
14
+ doValidation(this.endpoint, message, false);
15
+
16
+ const OTW = message;
17
+ this.transport.send(JSON.stringify(OTW));
18
+ }
19
+ }
20
+
21
+ module.exports = SinkClient;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+
3
+ const EventEmitter = require("events").EventEmitter;
4
+ const doValidate = require("../misc/Validation").SourceValidation;
5
+
6
+ class SourceClient extends EventEmitter{
7
+ constructor(endpoint, transports){
8
+ super();
9
+ this.endpoint = endpoint;
10
+ this.transport = transports.source;
11
+ if (!this.transport)
12
+ throw "Trying to construct Source endpoint without Source transport";
13
+ }
14
+
15
+ subscribe(){
16
+ this.transport.subscribe(this.endpoint.name);
17
+ };
18
+
19
+ unsubscribe(){
20
+ this.transport.unsubscribe(this.endpoint.name);
21
+ };
22
+ _processMessage(data){
23
+ if (this.endpoint.name === data.endpoint){
24
+ doValidate(this.endpoint, data.message, true);
25
+ this.emit('message', data.message);
26
+ }
27
+ }
28
+ }
29
+
30
+ module.exports = SourceClient;
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Alexander Zuliani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # node-service
2
+
3
+ Node.js library for building services with ZeroMQ-based messaging patterns. Provides both Service (server) and Client abstractions for different communication patterns.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @azuliani/node-service
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```javascript
14
+ const { Service, Client } = require('@azuliani/node-service');
15
+
16
+ // Define a descriptor
17
+ const descriptor = {
18
+ transports: {
19
+ rpc: { client: "tcp://127.0.0.1:5555", server: "tcp://127.0.0.1:5555" }
20
+ },
21
+ endpoints: [
22
+ { name: "Echo", type: "RPC", requestSchema: { type: "string" }, replySchema: { type: "string" } }
23
+ ]
24
+ };
25
+
26
+ // Create a service
27
+ const service = new Service(descriptor, {
28
+ Echo: (request) => request // Echo handler
29
+ });
30
+
31
+ // Create a client
32
+ const client = new Client(descriptor);
33
+ const reply = await client.Echo("Hello");
34
+ console.log(reply); // "Hello"
35
+ ```
36
+
37
+ ## Service Types
38
+
39
+ Services are servers that expose endpoints. Created via `new Service(descriptor, handlers, initials, options)`.
40
+
41
+ | Type | Description |
42
+ |------|-------------|
43
+ | **RPCService** | Request/response pattern over ZMQ dealer/router |
44
+ | **SourceService** | Publishes messages to subscribers (ZMQ pub socket) |
45
+ | **SinkService** | Receives messages from clients (ZMQ pull socket) |
46
+ | **PushService** | Pushes messages to workers (ZMQ push socket) |
47
+ | **SharedObjectService** | Syncs object state to clients via diffs |
48
+
49
+ ### Service Options
50
+
51
+ - `heartbeatMs` - Heartbeat interval in milliseconds (default: 5000)
52
+
53
+ ## Client Types
54
+
55
+ Clients connect to services. Created via `new Client(descriptor, options)`.
56
+
57
+ | Type | Description |
58
+ |------|-------------|
59
+ | **RPCClient** | Calls RPC endpoints with timeout support |
60
+ | **SourceClient** | Subscribes to source messages (ZMQ sub socket) |
61
+ | **SinkClient** | Sends messages to sink (ZMQ push socket) |
62
+ | **PullClient** | Receives pushed messages (ZMQ pull socket) |
63
+ | **SharedObjectClient** | Maintains synchronized copy of server's SharedObject |
64
+
65
+ ### Client Options
66
+
67
+ - `initDelay` - Delay in ms before SharedObjectClient fetches full state after subscribe (default: 100)
68
+
69
+ ## Descriptor Format
70
+
71
+ Both Service and Client use the same descriptor object:
72
+
73
+ ```javascript
74
+ {
75
+ transports: {
76
+ source: { client: "tcp://...", server: "tcp://..." },
77
+ sink: { client: "tcp://...", server: "tcp://..." },
78
+ rpc: { client: "tcp://...", server: "tcp://..." },
79
+ pushpull: { client: "tcp://...", server: "tcp://..." }
80
+ },
81
+ endpoints: [
82
+ { name: "MyRPC", type: "RPC", requestSchema: {...}, replySchema: {...} },
83
+ { name: "MySource", type: "Source", messageSchema: {...} },
84
+ { name: "MySO", type: "SharedObject", objectSchema: {...} }
85
+ ]
86
+ }
87
+ ```
88
+
89
+ ## Schema Validation
90
+
91
+ Schema validation using `schema-inspector`. Schemas support:
92
+ - Standard types: `string`, `number`, `object`, `array`
93
+ - Special type `date` for automatic Date parsing from JSON
94
+ - Wildcard `*` in object properties for dynamic keys
95
+ - Set `skip: true` on schema to bypass validation
96
+
97
+ ## Heartbeat System
98
+
99
+ Services automatically send heartbeat messages on the source transport. Clients detect disconnection when no messages arrive within 3x the heartbeat interval.
100
+
101
+ - Client learns heartbeat frequency from the first heartbeat message
102
+ - Heartbeat timeout triggers automatic disconnect/reconnect cycle
103
+ - On disconnect, SharedObjectClient emits synthetic deletion diffs before flushing data
104
+
105
+ ## Lifecycle
106
+
107
+ Both `Service` and `Client` have a `close()` method for proper cleanup:
108
+
109
+ ```javascript
110
+ service.close(); // Stops heartbeat, closes all sockets
111
+ client.close(); // Stops heartbeat checking, closes all sockets
112
+ ```
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+
3
+ const doValidation = require("../misc/Validation").SinkValidation;
4
+
5
+ class SourceService{
6
+ constructor(endpoint, transports){
7
+ this.endpoint = endpoint;
8
+ this.transport = transports.pushpull;
9
+ if (!this.transport)
10
+ throw "Trying to construct PushPull endpoint without pushpull transport";
11
+ this.stats = {updates: 0};
12
+ }
13
+
14
+ push(message){
15
+ doValidation(this.endpoint, message, false);
16
+
17
+ const OTW = message;
18
+ this.transport.send(JSON.stringify(OTW));
19
+ this.stats.updates++;
20
+ }
21
+
22
+ getStats(){
23
+ const current_stats = JSON.parse(JSON.stringify(this.stats));
24
+ this.stats.updates = 0;
25
+ return current_stats;
26
+ }
27
+ }
28
+
29
+ module.exports = SourceService;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+
3
+ const assert = require("assert");
4
+
5
+ const doValidation = require("../misc/Validation").RPCValidation;
6
+
7
+ class RPCService{
8
+ constructor(endpoint, handler){
9
+ this.endpoint = endpoint;
10
+ this.handler = handler;
11
+ this.stats = {updates: 0};
12
+ }
13
+
14
+ call(data, callback){
15
+ assert(this.endpoint.name === data.endpoint);
16
+
17
+ doValidation(this.endpoint, 'input', data.input, true);
18
+
19
+ this.stats.updates++;
20
+
21
+ this.handler(data.input, (err, res) => {
22
+
23
+ if (!err){
24
+ doValidation(this.endpoint, 'output', res, false);
25
+ }
26
+
27
+ const reply = JSON.stringify({err,res});
28
+ callback(reply);
29
+ });
30
+ }
31
+
32
+ getStats(){
33
+ const current_stats = JSON.parse(JSON.stringify(this.stats));
34
+ this.stats.updates = 0;
35
+ return current_stats;
36
+ }
37
+ }
38
+
39
+ module.exports = RPCService;
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+
3
+ const zmq = require("zeromq/v5-compat");
4
+ const http = require("http");
5
+ const EventEmitter = require("events").EventEmitter;
6
+
7
+ const RPCService = require("./RPCService");
8
+ const SourceService = require("./SourceService");
9
+ const SharedObjectService = require("./SharedObjectService");
10
+ const PushService = require("./PushService");
11
+ const SinkService = require("./SinkService");
12
+
13
+ class Service {
14
+ constructor(descriptor, handlers, initials, options = {}) {
15
+ this.descriptor = descriptor;
16
+ this.transports = {};
17
+ this.handlers = handlers || {};
18
+ this.initials = initials || {};
19
+ this._heartbeatMs = options.heartbeatMs ?? 5000;
20
+
21
+ this._setupTransports();
22
+ this._setupEndpoints();
23
+ }
24
+
25
+ _setupTransports() {
26
+ for (let transport in this.descriptor.transports) {
27
+ switch (transport) {
28
+ case 'source':
29
+ this._setupSource(this.descriptor.transports.source.server);
30
+ break;
31
+ case 'sink':
32
+ this._setupSink(this.descriptor.transports.sink.server);
33
+ break;
34
+ case 'rpc':
35
+ this._setupRpc(this.descriptor.transports.rpc.server);
36
+ break;
37
+ case 'pushpull':
38
+ this._setupPushPull(this.descriptor.transports.pushpull.server);
39
+ break;
40
+ default:
41
+ break;
42
+ }
43
+ }
44
+ }
45
+
46
+ _setupSource(hostname) {
47
+ const sock = new zmq.socket('pub');
48
+
49
+ sock.setsockopt(zmq.ZMQ_SNDHWM, 10000);
50
+ sock.setsockopt(zmq.ZMQ_LINGER, 0);
51
+ sock.setsockopt(39, 1); // ZMQ_IMMEDIATE
52
+
53
+ this.transports.source = sock;
54
+ sock.bind(hostname);
55
+ this._setupHeartbeat();
56
+ }
57
+
58
+ _setupHeartbeat() {
59
+ this._heartbeatInterval = setInterval(this._sendHeartbeat.bind(this), this._heartbeatMs);
60
+ }
61
+
62
+ _sendHeartbeat() {
63
+ const OTW = {
64
+ endpoint: '_heartbeat',
65
+ frequencyMs: this._heartbeatMs
66
+ };
67
+ this.transports.source.send([OTW.endpoint, JSON.stringify(OTW)]);
68
+ }
69
+
70
+ _setupSink(hostname) {
71
+ const sock = new zmq.socket('pull');
72
+ this.transports.sink = sock;
73
+
74
+ sock.bind(hostname);
75
+ sock.on('message', this._sinkCallback.bind(this));
76
+ }
77
+
78
+ _sinkCallback(message) {
79
+ if (!this.SinkEndpoint) {
80
+ throw new Error("Got a pull message, but ot Pull enpoint is connected!");
81
+ }
82
+ this.SinkEndpoint._processMessage(JSON.parse(message));
83
+ }
84
+
85
+ _setupRpc(hostname) {
86
+ this.transports.rpc = new EventEmitter();
87
+
88
+ const hostnameAndPort = hostname.split(":");
89
+ const url = hostnameAndPort[1].substr(2);
90
+ const port = hostnameAndPort[2];
91
+ this._httpServer = http.createServer(this._rpcCallback.bind(this));
92
+ this._httpServer.listen(port, url);
93
+ }
94
+
95
+ _rpcCallback(req, res) {
96
+ if (req.method === 'POST') {
97
+ let body = "";
98
+ req.on('data', function (data) {
99
+ body += data;
100
+ });
101
+ const self = this;
102
+ req.on('end', function () {
103
+ const parsedReq = JSON.parse(body);
104
+ const handler = self.RPCServices[parsedReq.endpoint];
105
+ if (handler) {
106
+ handler.call(parsedReq, (result) => {
107
+ res.writeHead(200, {'Content-Type': 'application/json'});
108
+ res.end(result);
109
+ });
110
+ } else {
111
+ res.writeHead(404, {'Content-Type': 'application/json'});
112
+ res.end(JSON.stringify({err: `Unknown endpoint: ${parsedReq.endpoint}`}));
113
+ }
114
+ });
115
+ }
116
+ else {
117
+ res.writeHead(200, {'Content-Type': 'text/html'});
118
+ res.end("-");
119
+ }
120
+ }
121
+
122
+ _setupPushPull(hostname) {
123
+ const sock = new zmq.socket('push');
124
+ sock.bind(hostname);
125
+ this.transports.pushpull = sock;
126
+ }
127
+
128
+ _setupEndpoints() {
129
+ this.RPCServices = {};
130
+
131
+ for (let endpoint of this.descriptor.endpoints) {
132
+ switch (endpoint.type) {
133
+ case 'RPC': {
134
+ const handler = this.handlers[endpoint.name];
135
+ if (!handler)
136
+ throw "Missing handler: " + endpoint.name;
137
+ this.RPCServices[endpoint.name] = new RPCService(endpoint, handler);
138
+ break;
139
+ }
140
+ case 'Source':
141
+ this[endpoint.name] = new SourceService(endpoint, this.transports);
142
+ break;
143
+ case 'SharedObject':
144
+ this[endpoint.name] = new SharedObjectService(endpoint, this.transports, this.initials[endpoint.name]);
145
+ this.RPCServices["_SO_" + endpoint.name] = this[endpoint.name];
146
+ break;
147
+ case 'PushPull':
148
+ this[endpoint.name] = new PushService(endpoint, this.transports);
149
+ break;
150
+ case 'Sink':
151
+ this[endpoint.name] = new SinkService(endpoint, this.transports);
152
+ this.SinkEndpoint = this[endpoint.name];
153
+ break;
154
+ default:
155
+ throw "Unknown endpoint type";
156
+ }
157
+ }
158
+ }
159
+
160
+ close() {
161
+ if (this._heartbeatInterval) {
162
+ clearInterval(this._heartbeatInterval);
163
+ }
164
+ if (this._httpServer) {
165
+ this._httpServer.close();
166
+ }
167
+ if (this.transports.source) {
168
+ this.transports.source.close();
169
+ }
170
+ if (this.transports.sink) {
171
+ this.transports.sink.close();
172
+ }
173
+ if (this.transports.pushpull) {
174
+ this.transports.pushpull.close();
175
+ }
176
+ }
177
+ }
178
+
179
+ module.exports = Service;
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+
3
+ const assert = require("assert");
4
+ const fastcopy = require("fast-copy").default;
5
+ const doValidate = require("../misc/Validation").SharedObjectValidation;
6
+ const deepDiff = require("@azuliani/deep-diff");
7
+
8
+ class SharedObjectService{
9
+ constructor(endpoint, transports, initial){
10
+ if (!transports.rpc || !transports.source)
11
+ throw new Error("Shared objects need both Source and RPC transports to be configured");
12
+
13
+ doValidate(endpoint, initial);
14
+ this.data = initial;
15
+ this._lastTransmit = fastcopy(initial);
16
+ this._v = 0;
17
+ this.endpoint = endpoint;
18
+ this.diffTransport = transports.source;
19
+ this.stats = {updates: 0};
20
+ }
21
+
22
+ call(data, callback) {
23
+ if (data.input === "init") {
24
+ callback(JSON.stringify({err: null, res: {data: this.data, v: this._v}}));
25
+ } else {
26
+ callback(JSON.stringify({err: `Unknown command: ${data.input}`}));
27
+ }
28
+ }
29
+
30
+ notify(hint, dirtyBypass){
31
+ const now = new Date();
32
+
33
+ if (!hint)
34
+ hint = [];
35
+
36
+ doValidate(this.endpoint, this.data, hint);
37
+
38
+ const diffs = diffAndReverseAndApplyWithHint(this._lastTransmit, this.data, hint, dirtyBypass);
39
+
40
+ if (diffs && diffs.length) {
41
+ this.stats.updates++;
42
+ this._v++;
43
+ const OTW = {
44
+ endpoint: "_SO_" + this.endpoint.name,
45
+ message: {diffs, v: this._v, now}
46
+ };
47
+ this.diffTransport.send([OTW.endpoint,JSON.stringify(OTW)]);
48
+ }
49
+ }
50
+
51
+ getStats(){
52
+ const current_stats = JSON.parse(JSON.stringify(this.stats));
53
+ this.stats.updates = 0;
54
+ return current_stats;
55
+ }
56
+ }
57
+
58
+ function diffAndReverseAndApplyWithHint(lhs, rhs, hint, bypass){
59
+ let lhsWithHint = lhs;
60
+ let rhsWithHint = rhs;
61
+ let lhsParent = null;
62
+ let i = 0;
63
+
64
+ while(i < hint.length){
65
+ // Stop if add or delete.
66
+ if (!(hint[i] in lhsWithHint) || !(hint[i] in rhsWithHint)){
67
+ break
68
+ }
69
+
70
+ lhsParent = lhsWithHint;
71
+
72
+ lhsWithHint = lhsWithHint[hint[i]];
73
+ rhsWithHint = rhsWithHint[hint[i]];
74
+ i++;
75
+ }
76
+
77
+ const hintUsed = hint.slice(0,i);
78
+
79
+ const reportDiffs = []; // Separate because of clone changes
80
+
81
+ if (hintUsed.length < hint.length){ // SHORTCUT
82
+ if (!(hint[i] in lhsWithHint) && (hint[i] in rhsWithHint)){ // SHORTCUT ADD
83
+ const cloned = fastcopy(rhsWithHint[hint[i]]);
84
+ reportDiffs.push({kind: 'N', path: [...hintUsed, hint[i]], rhs: cloned});
85
+ lhsWithHint[hint[i]] = cloned
86
+ }else if (!(hint[i] in rhsWithHint)){ // SHORTCUT DEL
87
+ reportDiffs.push({kind: 'D', path: [...hintUsed, hint[i]], lhs: lhsWithHint[hint[i]]});
88
+ delete lhsWithHint[hint[i]];
89
+ }else{
90
+ throw new Error("Wut?");
91
+ }
92
+ } else {
93
+ if (!bypass) {
94
+ const diffs = deepDiff.diff(lhsWithHint, rhsWithHint);
95
+ if (diffs) {
96
+ for (let i = diffs.length - 1; i >= 0; i--) {
97
+ const diff = fastcopy(diffs[i]);
98
+ if (diff.path) {
99
+ diff.path = [...hintUsed, ...diff.path];
100
+ } else {
101
+ diff.path = hintUsed;
102
+ }
103
+ deepDiff.applyChange(lhs, rhs, diff);
104
+ reportDiffs.push(diff);
105
+ }
106
+ }
107
+ } else {
108
+ // BYPASS
109
+ assert(lhsParent && hint[0]);
110
+
111
+ reportDiffs.push({
112
+ kind: 'N',
113
+ path: hint,
114
+ rhs: rhsWithHint
115
+ });
116
+
117
+ lhsParent[hint[hint.length-1]] = rhsWithHint;
118
+
119
+ }
120
+ }
121
+
122
+ return reportDiffs;
123
+ }
124
+
125
+ module.exports = SharedObjectService;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+
3
+ const EventEmitter = require("events").EventEmitter;
4
+
5
+ class SinkService extends EventEmitter {
6
+ constructor(endpoint, transports, hostname) {
7
+ super();
8
+ this.endpoint = endpoint;
9
+ this.transport = transports.sink;
10
+ this.hostname = hostname;
11
+ if (!this.transport)
12
+ throw new Error("Trying to construct Sink endpoint without Source transport");
13
+ this.stats = {updates: 0};
14
+ }
15
+
16
+ subscribe() {
17
+ this.transport.connect(this.hostname);
18
+ };
19
+
20
+ unsubscribe() {
21
+ this.transport.disconnect(this.hostname);
22
+ };
23
+
24
+ _processMessage(data) {
25
+ this.stats.updates++;
26
+ this.emit("message", data);
27
+ }
28
+
29
+ getStats(){
30
+ const current_stats = JSON.parse(JSON.stringify(this.stats));
31
+ this.stats.updates = 0;
32
+ return current_stats;
33
+ }
34
+ }
35
+
36
+
37
+ module.exports = SinkService;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+
3
+ const doValidation = require("../misc/Validation").SourceValidation;
4
+
5
+ class SourceService{
6
+ constructor(endpoint, transports){
7
+ this.endpoint = endpoint;
8
+ this.transport = transports.source;
9
+ if (!this.transport)
10
+ throw "Trying to construct Source endpoint without Source transport";
11
+ this.stats = {updates: 0};
12
+ }
13
+
14
+ send(message){
15
+ doValidation(this.endpoint, message, false);
16
+ const OTW = {
17
+ endpoint: this.endpoint.name,
18
+ message: message
19
+ };
20
+ this.transport.send([OTW.endpoint, JSON.stringify(OTW)]);
21
+ this.stats.updates++;
22
+ }
23
+
24
+ getStats(){
25
+ const current_stats = JSON.parse(JSON.stringify(this.stats));
26
+ this.stats.updates = 0;
27
+ return current_stats;
28
+ }
29
+ }
30
+
31
+ module.exports = SourceService;
package/index.js ADDED
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+
3
+ const Service = require("./Service/Service");
4
+ const Client = require("./Client/Client");
5
+
6
+ module.exports = {
7
+ Service,
8
+ Client
9
+ };