@schukai/monster 3.1.2 → 3.3.0
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/package.json +1 -1
- package/source/data/datasource/websocket.mjs +163 -187
- package/source/net/webconnect/message.mjs +55 -0
- package/source/net/webconnect.mjs +346 -0
- package/source/types/observablequeue.mjs +110 -0
- package/source/types/proxyobserver.mjs +2 -2
- package/source/types/uniquequeue.mjs +9 -6
- package/source/types/version.mjs +1 -1
- package/test/cases/data/datasource/websocket.mjs +118 -33
- package/test/cases/monster.mjs +1 -1
- package/test/cases/net/webconnect/message.mjs +50 -0
- package/test/cases/net/webconnect.mjs +116 -0
- package/test/cases/types/observablequeue.mjs +17 -0
- package/test/cases/types/queue.mjs +4 -1
package/package.json
CHANGED
|
@@ -6,54 +6,50 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {internalSymbol, instanceSymbol} from "../../constants.mjs";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import {isString, isObject} from "../../types/is.mjs";
|
|
10
|
+
import {WebConnect} from "../../net/webconnect.mjs";
|
|
11
|
+
import {Message} from "../../net/webconnect/message.mjs";
|
|
11
12
|
import {Datasource} from "../datasource.mjs";
|
|
12
13
|
import {Pathfinder} from "../pathfinder.mjs";
|
|
13
14
|
import {Pipe} from "../pipe.mjs";
|
|
14
15
|
|
|
15
16
|
export {WebSocketDatasource}
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
* @private
|
|
19
|
-
* @type {Symbol}
|
|
20
|
-
*/
|
|
21
|
-
const receiveQueueSymbol = Symbol("queue");
|
|
22
|
-
|
|
23
18
|
|
|
24
19
|
/**
|
|
25
20
|
* @private
|
|
26
21
|
* @type {Symbol}
|
|
27
|
-
*
|
|
22
|
+
*
|
|
28
23
|
* hint: this name is used in the tests. if you want to change it, please change it in the tests as well.
|
|
29
24
|
*/
|
|
30
|
-
const
|
|
25
|
+
const webConnectSymbol = Symbol("connection");
|
|
31
26
|
|
|
32
27
|
/**
|
|
33
|
-
*
|
|
34
|
-
* @
|
|
28
|
+
*
|
|
29
|
+
* @param self
|
|
30
|
+
* @param obj
|
|
31
|
+
* @returns {*}
|
|
35
32
|
*/
|
|
36
|
-
|
|
33
|
+
function doTransform(type, obj) {
|
|
34
|
+
const self = this;
|
|
35
|
+
let transformation = self.getOption(type + '.mapping.transformer');
|
|
36
|
+
if (transformation !== undefined) {
|
|
37
|
+
const pipe = new Pipe(transformation);
|
|
38
|
+
const callbacks = self.getOption(type + '.mapping.callbacks')
|
|
39
|
+
|
|
40
|
+
if (isObject(callbacks)) {
|
|
41
|
+
for (const key in callbacks) {
|
|
42
|
+
if (callbacks.hasOwnProperty(key) && typeof callbacks[key] === 'function') {
|
|
43
|
+
pipe.setCallback(key, callbacks[key]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
37
47
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
1000: "Normal closure",
|
|
44
|
-
1001: "Going away",
|
|
45
|
-
1002: "Protocol error",
|
|
46
|
-
1003: "Unsupported data",
|
|
47
|
-
1004: "Reserved",
|
|
48
|
-
1005: "No status code",
|
|
49
|
-
1006: "Connection closed abnormally",
|
|
50
|
-
1007: "Invalid frame payload data",
|
|
51
|
-
1008: "Policy violation",
|
|
52
|
-
1009: "Message too big",
|
|
53
|
-
1010: "Mandatory extension",
|
|
54
|
-
1011: "Internal server error",
|
|
55
|
-
1015: "TLS handshake"
|
|
56
|
-
};
|
|
48
|
+
obj = pipe.run(obj);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return obj;
|
|
52
|
+
}
|
|
57
53
|
|
|
58
54
|
/**
|
|
59
55
|
* The RestAPI is a class that enables a REST API server.
|
|
@@ -73,6 +69,8 @@ class WebSocketDatasource extends Datasource {
|
|
|
73
69
|
*/
|
|
74
70
|
constructor(options) {
|
|
75
71
|
super();
|
|
72
|
+
|
|
73
|
+
const self = this;
|
|
76
74
|
|
|
77
75
|
if (isString(options)) {
|
|
78
76
|
options = {url: options};
|
|
@@ -80,83 +78,32 @@ class WebSocketDatasource extends Datasource {
|
|
|
80
78
|
|
|
81
79
|
if (!isObject(options)) options = {};
|
|
82
80
|
this.setOptions(options);
|
|
83
|
-
this[
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
this[webConnectSymbol] = new WebConnect({
|
|
82
|
+
url: self.getOption('url'),
|
|
83
|
+
connection: {
|
|
84
|
+
timeout: self.getOption('connection.timeout'),
|
|
85
|
+
reconnect: {
|
|
86
|
+
timeout: self.getOption('connection.reconnect.timeout'),
|
|
87
|
+
attempts: self.getOption('connection.reconnect.attempts'),
|
|
88
|
+
enabled: self.getOption('connection.reconnect.enabled')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
/**
|
|
92
95
|
*
|
|
93
|
-
* @returns {
|
|
94
|
-
* @throws {Error} No url defined for websocket datasource.
|
|
96
|
+
* @returns {Promise}
|
|
95
97
|
*/
|
|
96
98
|
connect() {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
let connected = false;
|
|
100
|
-
let reconnectTimeout = self.getOption('reconnect.timeout');
|
|
101
|
-
if (!isInteger(reconnectTimeout) || reconnectTimeout < 1000) reconnectTimeout = 1000;
|
|
102
|
-
let reconnectAttempts = self.getOption('reconnect.attempts');
|
|
103
|
-
if (!isInteger(reconnectAttempts) || reconnectAttempts < 1) reconnectAttempts = 1;
|
|
104
|
-
let reconnectEnabled = self.getOption('reconnect.enabled');
|
|
105
|
-
if (reconnectEnabled !== true) reconnectEnabled = false;
|
|
106
|
-
|
|
107
|
-
self[manualCloseSymbol] = false;
|
|
108
|
-
self[connectionSymbol].reconnectCounter++;
|
|
109
|
-
|
|
110
|
-
if (self[connectionSymbol].socket && self[connectionSymbol].socket.readyState < 2) {
|
|
111
|
-
self[connectionSymbol].socket.close();
|
|
112
|
-
}
|
|
113
|
-
self[connectionSymbol].socket = null;
|
|
114
|
-
|
|
115
|
-
const url = self.getOption('url');
|
|
116
|
-
if (!url) throw new Error('No url defined for websocket datasource.');
|
|
117
|
-
|
|
118
|
-
self[connectionSymbol].socket = new WebSocket(url);
|
|
119
|
-
|
|
120
|
-
self[connectionSymbol].socket.onmessage = function (event) {
|
|
121
|
-
self[receiveQueueSymbol].add(event);
|
|
122
|
-
setTimeout(function () {
|
|
123
|
-
self.read();
|
|
124
|
-
}, 0);
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
self[connectionSymbol].socket.onopen = function () {
|
|
128
|
-
connected = true;
|
|
129
|
-
self[connectionSymbol].reconnectCounter = 0;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
self[connectionSymbol].socket.close = function (event) {
|
|
133
|
-
|
|
134
|
-
if (self[manualCloseSymbol]) {
|
|
135
|
-
self[manualCloseSymbol] = false;
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (reconnectEnabled && this[connectionSymbol].reconnectCounter < reconnectAttempts) {
|
|
140
|
-
setTimeout(() => {
|
|
141
|
-
self.connect();
|
|
142
|
-
}, reconnectTimeout * this[connectionSymbol].reconnectCounter);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
self[connectionSymbol].socket.onerror = (error) => {
|
|
148
|
-
|
|
149
|
-
if (reconnectEnabled && self[connectionSymbol].reconnectCounter < reconnectAttempts) {
|
|
150
|
-
setTimeout(() => {
|
|
151
|
-
self.connect();
|
|
152
|
-
}, reconnectTimeout * this[connectionSymbol].reconnectCounter);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
};
|
|
99
|
+
return this[webConnectSymbol].connect();
|
|
156
100
|
}
|
|
157
101
|
|
|
102
|
+
/**
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
158
105
|
isConnected() {
|
|
159
|
-
return this[
|
|
106
|
+
return this[webConnectSymbol].isConnected();
|
|
160
107
|
}
|
|
161
108
|
|
|
162
109
|
/**
|
|
@@ -169,19 +116,20 @@ class WebSocketDatasource extends Datasource {
|
|
|
169
116
|
|
|
170
117
|
/**
|
|
171
118
|
* @property {string} url=undefined Defines the resource that you wish to fetch.
|
|
172
|
-
* @property {
|
|
173
|
-
* @property {
|
|
174
|
-
* @property {
|
|
119
|
+
* @property {Object} connection
|
|
120
|
+
* @property {Object} connection.timeout=5000 Defines the timeout for the connection.
|
|
121
|
+
* @property {Number} connection.reconnect.timeout The timeout in milliseconds for the reconnect.
|
|
122
|
+
* @property {Number} connection.reconnect.attempts The maximum number of reconnects.
|
|
123
|
+
* @property {Bool} connection.reconnect.enabled If the reconnect is enabled.
|
|
175
124
|
* @property {Object} write={} Options
|
|
176
125
|
* @property {Object} write.mapping the mapping is applied before writing.
|
|
177
126
|
* @property {String} write.mapping.transformer Transformer to select the appropriate entries
|
|
178
127
|
* @property {Monster.Data.Datasource~exampleCallback[]} write.mapping.callback with the help of the callback, the structures can be adjusted before writing.
|
|
179
|
-
* @property {Object} write.report
|
|
180
|
-
* @property {String} write.report.path Path to validations
|
|
181
128
|
* @property {Object} write.sheathing
|
|
182
129
|
* @property {Object} write.sheathing.object Object to be wrapped
|
|
183
130
|
* @property {string} write.sheathing.path Path to the data
|
|
184
131
|
* @property {Object} read={} Options
|
|
132
|
+
* @property {String} read.path Path to data
|
|
185
133
|
* @property {Object} read.mapping the mapping is applied after reading.
|
|
186
134
|
* @property {String} read.mapping.transformer Transformer to select the appropriate entries
|
|
187
135
|
* @property {Monster.Data.Datasource~exampleCallback[]} read.mapping.callback with the help of the callback, the structures can be adjusted after reading.
|
|
@@ -192,10 +140,7 @@ class WebSocketDatasource extends Datasource {
|
|
|
192
140
|
write: {
|
|
193
141
|
mapping: {
|
|
194
142
|
transformer: undefined,
|
|
195
|
-
callbacks:
|
|
196
|
-
},
|
|
197
|
-
report: {
|
|
198
|
-
path: undefined
|
|
143
|
+
callbacks: {}
|
|
199
144
|
},
|
|
200
145
|
sheathing: {
|
|
201
146
|
object: undefined,
|
|
@@ -205,28 +150,28 @@ class WebSocketDatasource extends Datasource {
|
|
|
205
150
|
read: {
|
|
206
151
|
mapping: {
|
|
207
152
|
transformer: undefined,
|
|
208
|
-
callbacks:
|
|
153
|
+
callbacks: {}
|
|
209
154
|
},
|
|
155
|
+
path: undefined,
|
|
210
156
|
},
|
|
211
|
-
|
|
212
|
-
timeout:
|
|
213
|
-
|
|
214
|
-
|
|
157
|
+
connection: {
|
|
158
|
+
timeout: 5000,
|
|
159
|
+
reconnect: {
|
|
160
|
+
timeout: 1000,
|
|
161
|
+
attempts: 1,
|
|
162
|
+
enabled: false,
|
|
163
|
+
}
|
|
215
164
|
}
|
|
216
165
|
});
|
|
217
166
|
}
|
|
218
167
|
|
|
219
168
|
/**
|
|
220
169
|
* This method closes the connection.
|
|
221
|
-
*
|
|
170
|
+
*
|
|
222
171
|
* @returns {Promise}
|
|
223
172
|
*/
|
|
224
173
|
close() {
|
|
225
|
-
this[
|
|
226
|
-
if (this[connectionSymbol].socket) {
|
|
227
|
-
this[connectionSymbol].socket.close();
|
|
228
|
-
}
|
|
229
|
-
return this;
|
|
174
|
+
return this[webConnectSymbol].close();
|
|
230
175
|
}
|
|
231
176
|
|
|
232
177
|
/**
|
|
@@ -234,99 +179,130 @@ class WebSocketDatasource extends Datasource {
|
|
|
234
179
|
*/
|
|
235
180
|
read() {
|
|
236
181
|
const self = this;
|
|
237
|
-
let response;
|
|
238
|
-
|
|
239
|
-
if (self[connectionSymbol]?.socket?.readyState!==1) {
|
|
240
|
-
return Promise.reject('The connection is not established.');
|
|
241
|
-
}
|
|
242
182
|
|
|
243
183
|
return new Promise((resolve, reject) => {
|
|
244
|
-
if (self[receiveQueueSymbol].isEmpty()) {
|
|
245
|
-
resolve();
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
while (!self[receiveQueueSymbol].isEmpty()) {
|
|
249
|
-
|
|
250
|
-
const event = self[receiveQueueSymbol].poll();
|
|
251
|
-
const body = event?.data;
|
|
252
|
-
if (!body) continue;
|
|
253
|
-
|
|
254
|
-
let obj;
|
|
255
|
-
try {
|
|
256
|
-
obj = JSON.parse(body);
|
|
257
|
-
} catch (e) {
|
|
258
|
-
|
|
259
|
-
let msg = 'the response does not contain a valid json (actual: ';
|
|
260
|
-
|
|
261
|
-
if (body.length > 100) {
|
|
262
|
-
msg += body.substring(0, 97) + '...';
|
|
263
|
-
} else {
|
|
264
|
-
msg += body;
|
|
265
|
-
}
|
|
266
184
|
|
|
267
|
-
|
|
185
|
+
while (this[webConnectSymbol].dataReceived() === true) {
|
|
186
|
+
let obj = this[webConnectSymbol].poll();
|
|
187
|
+
if (!isObject(obj)) {
|
|
188
|
+
reject(new Error('The received data is not an object.'));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
268
191
|
|
|
269
|
-
|
|
192
|
+
if (!(obj instanceof Message)) {
|
|
193
|
+
reject(new Error('The received data is not a Message.'));
|
|
194
|
+
return;
|
|
270
195
|
}
|
|
271
196
|
|
|
272
|
-
|
|
273
|
-
if (transformation !== undefined) {
|
|
274
|
-
const pipe = new Pipe(transformation);
|
|
197
|
+
obj = obj.getData();
|
|
275
198
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
199
|
+
obj = self.transformServerPayload.call(self, obj);
|
|
200
|
+
self.set( obj);
|
|
201
|
+
}
|
|
279
202
|
|
|
280
|
-
|
|
281
|
-
}
|
|
203
|
+
resolve(self.get());
|
|
282
204
|
|
|
283
|
-
self.set(obj);
|
|
284
|
-
return response;
|
|
285
|
-
}
|
|
286
205
|
})
|
|
287
|
-
|
|
206
|
+
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// const self = this;
|
|
210
|
+
// let response;
|
|
211
|
+
//
|
|
212
|
+
// if (self[webConnectSymbol]?.socket?.readyState !== 1) {
|
|
213
|
+
// return Promise.reject('The connection is not established.');
|
|
214
|
+
// }
|
|
215
|
+
//
|
|
216
|
+
// return new Promise((resolve, reject) => {
|
|
217
|
+
// if (self[receiveQueueSymbol].isEmpty()) {
|
|
218
|
+
// resolve();
|
|
219
|
+
// }
|
|
220
|
+
//
|
|
221
|
+
// while (!self[receiveQueueSymbol].isEmpty()) {
|
|
222
|
+
//
|
|
223
|
+
// const event = self[receiveQueueSymbol].poll();
|
|
224
|
+
// const body = event?.data;
|
|
225
|
+
// if (!body) continue;
|
|
226
|
+
//
|
|
227
|
+
// let obj;
|
|
228
|
+
// try {
|
|
229
|
+
// obj = JSON.parse(body);
|
|
230
|
+
// } catch (e) {
|
|
231
|
+
//
|
|
232
|
+
// let msg = 'the response does not contain a valid json (actual: ';
|
|
233
|
+
//
|
|
234
|
+
// if (body.length > 100) {
|
|
235
|
+
// msg += body.substring(0, 97) + '...';
|
|
236
|
+
// } else {
|
|
237
|
+
// msg += body;
|
|
238
|
+
// }
|
|
239
|
+
//
|
|
240
|
+
// msg += "; " + e.message + ')';
|
|
241
|
+
//
|
|
242
|
+
// reject(msg);
|
|
243
|
+
// return;
|
|
244
|
+
// }
|
|
245
|
+
//
|
|
246
|
+
// obj = self.transformServerPayload.call(self, obj);
|
|
247
|
+
//
|
|
248
|
+
//
|
|
249
|
+
// self.set(obj);
|
|
250
|
+
// return response;
|
|
251
|
+
// }
|
|
252
|
+
// })
|
|
253
|
+
//}
|
|
288
254
|
|
|
289
255
|
/**
|
|
290
|
-
*
|
|
256
|
+
* This prepares the data that comes from the server.
|
|
257
|
+
* Should not be called directly.
|
|
258
|
+
*
|
|
259
|
+
* @private
|
|
260
|
+
* @param {Object} payload
|
|
261
|
+
* @returns {Object}
|
|
291
262
|
*/
|
|
292
|
-
|
|
263
|
+
transformServerPayload(payload) {
|
|
293
264
|
const self = this;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
265
|
+
payload = doTransform.call(self, 'read', payload);
|
|
266
|
+
|
|
267
|
+
const dataPath = self.getOption('read.path');
|
|
268
|
+
if (dataPath) {
|
|
269
|
+
payload = (new Pathfinder(payload)).getVia(dataPath);
|
|
297
270
|
}
|
|
298
271
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (transformation !== undefined) {
|
|
302
|
-
const pipe = new Pipe(transformation);
|
|
272
|
+
return payload;
|
|
273
|
+
}
|
|
303
274
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
275
|
+
/**
|
|
276
|
+
* This prepares the data for writing and should not be called directly.
|
|
277
|
+
*
|
|
278
|
+
* @private
|
|
279
|
+
* @param {Object} payload
|
|
280
|
+
* @returns {Object}
|
|
281
|
+
*/
|
|
282
|
+
prepareServerPayload(payload) {
|
|
283
|
+
const self = this;
|
|
307
284
|
|
|
308
|
-
|
|
309
|
-
}
|
|
285
|
+
payload = doTransform.call(self, 'write', payload);
|
|
310
286
|
|
|
311
287
|
let sheathingObject = self.getOption('write.sheathing.object');
|
|
312
288
|
let sheathingPath = self.getOption('write.sheathing.path');
|
|
313
|
-
let reportPath = self.getOption('write.report.path');
|
|
314
289
|
|
|
315
290
|
if (sheathingObject && sheathingPath) {
|
|
316
|
-
const sub =
|
|
317
|
-
|
|
318
|
-
(new Pathfinder(
|
|
291
|
+
const sub = payload;
|
|
292
|
+
payload = sheathingObject;
|
|
293
|
+
(new Pathfinder(payload)).setVia(sheathingPath, sub);
|
|
319
294
|
}
|
|
320
295
|
|
|
321
|
-
return
|
|
322
|
-
|
|
323
|
-
if (self[connectionSymbol].socket.readyState !== 1) {
|
|
324
|
-
reject('the socket is not ready');
|
|
325
|
-
}
|
|
296
|
+
return payload;
|
|
297
|
+
}
|
|
326
298
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
299
|
+
/**
|
|
300
|
+
* @return {Promise}
|
|
301
|
+
*/
|
|
302
|
+
write() {
|
|
303
|
+
const self = this;
|
|
304
|
+
let obj = self.prepareServerPayload(self.get());
|
|
305
|
+
return self[webConnectSymbol].send(obj)
|
|
330
306
|
}
|
|
331
307
|
|
|
332
308
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright schukai GmbH and contributors 2022. All Rights Reserved.
|
|
3
|
+
* Node module: @schukai/monster
|
|
4
|
+
* This file is licensed under the AGPLv3 License.
|
|
5
|
+
* License text available at https://www.gnu.org/licenses/agpl-3.0.en.html
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {Base} from "../../types/base.mjs";
|
|
9
|
+
import {validateObject, validateString} from "../../types/validate.mjs";
|
|
10
|
+
|
|
11
|
+
export {Message}
|
|
12
|
+
|
|
13
|
+
const dataSymbol = Symbol("@@data");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* This class represents a WebSocket message.
|
|
17
|
+
*/
|
|
18
|
+
class Message extends Base {
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} data
|
|
22
|
+
* @throws {TypeError} value is not a object
|
|
23
|
+
*/
|
|
24
|
+
constructor(data) {
|
|
25
|
+
super();
|
|
26
|
+
this[dataSymbol] = validateObject(data);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the raw message.
|
|
31
|
+
*
|
|
32
|
+
* @returns {object}
|
|
33
|
+
*/
|
|
34
|
+
getData() {
|
|
35
|
+
return this[dataSymbol];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @returns {*}
|
|
40
|
+
*/
|
|
41
|
+
toJSON() {
|
|
42
|
+
return this[dataSymbol];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} json
|
|
47
|
+
* @returns {Message}
|
|
48
|
+
* @throws {TypeError} value is not a string
|
|
49
|
+
*/
|
|
50
|
+
static fromJSON(json) {
|
|
51
|
+
validateString(json);
|
|
52
|
+
return new Message(JSON.parse(json));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
}
|