retoolrpc 0.1.5 → 0.1.6

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.
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var dedent = require('ts-dedent');
4
- var rpc$1 = require('./rpc-49533d0b.js');
4
+ var rpc$1 = require('./rpc-ee7491ce.js');
5
5
  require('uuid');
6
6
  require('node-fetch');
7
7
  require('abort-controller');
package/dist/cjs/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
- var rpc = require('./rpc-49533d0b.js');
5
+ var rpc = require('./rpc-ee7491ce.js');
6
6
  require('uuid');
7
7
  require('ts-dedent');
8
8
  require('node-fetch');
@@ -0,0 +1,542 @@
1
+ 'use strict';
2
+
3
+ var uuid = require('uuid');
4
+ var dedent = require('ts-dedent');
5
+ var fetch = require('node-fetch');
6
+ var AbortControllerFallback = require('abort-controller');
7
+
8
+ function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
9
+
10
+ var fetch__default = /*#__PURE__*/_interopDefaultLegacy(fetch);
11
+ var AbortControllerFallback__default = /*#__PURE__*/_interopDefaultLegacy(AbortControllerFallback);
12
+
13
+ const AGENT_SERVER_ERROR = 'AgentServerError';
14
+ function createAgentServerError(error) {
15
+ if (error instanceof Error) {
16
+ const agentError = {
17
+ name: error.name,
18
+ message: error.message,
19
+ stack: error.stack,
20
+ };
21
+ if ('code' in error) {
22
+ if (typeof error.code === 'number') {
23
+ agentError.code = error.code;
24
+ }
25
+ else if (error.code === 'string') {
26
+ agentError.code = parseInt(error.code, 10);
27
+ }
28
+ }
29
+ if ('details' in error) {
30
+ agentError.details = error.details;
31
+ }
32
+ return agentError;
33
+ }
34
+ if (typeof error === 'string') {
35
+ return {
36
+ name: AGENT_SERVER_ERROR,
37
+ message: error,
38
+ };
39
+ }
40
+ return {
41
+ name: AGENT_SERVER_ERROR,
42
+ message: 'Unknown agent server error',
43
+ };
44
+ }
45
+ class FunctionNotFoundError extends Error {
46
+ constructor(functionName) {
47
+ super(`Function "${functionName}" not found on remote agent server.`);
48
+ this.name = 'FunctionNotFoundError';
49
+ }
50
+ }
51
+ class InvalidArgumentsError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = 'InvalidArgumentsError';
55
+ }
56
+ }
57
+
58
+ function pick(obj, keys) {
59
+ const result = {};
60
+ for (const key of keys) {
61
+ if (key in obj) {
62
+ result[key] = obj[key];
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+ function isRecord(value) {
68
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
69
+ }
70
+ function isFalsyArgumentValue(value) {
71
+ return value === null || value === undefined || value === '';
72
+ }
73
+ function isBooleanString(value) {
74
+ if (typeof value === 'string') {
75
+ const lowercaseValue = value.toLowerCase();
76
+ return lowercaseValue === 'true' || lowercaseValue === 'false';
77
+ }
78
+ return false;
79
+ }
80
+ function isNumberString(value) {
81
+ if (typeof value === 'string') {
82
+ // Use a regular expression to check if the string is a valid number
83
+ return /^-?\d+(\.\d+)?$/.test(value);
84
+ }
85
+ return false;
86
+ }
87
+ function isClientError(status) {
88
+ return status >= 400 && status < 500;
89
+ }
90
+
91
+ class ArgumentParser {
92
+ constructor(schema) {
93
+ this.schema = schema;
94
+ }
95
+ parse(argumentsToParse) {
96
+ const parsedArguments = { ...argumentsToParse };
97
+ const parsedErrors = [];
98
+ for (const argName in this.schema) {
99
+ const argDefinition = this.schema[argName];
100
+ const argValue = argumentsToParse[argName];
101
+ const falsyArgValue = isFalsyArgumentValue(argValue);
102
+ if (falsyArgValue) {
103
+ if (argDefinition.required) {
104
+ parsedErrors.push(`Argument "${argName}" is required but missing.`);
105
+ continue;
106
+ }
107
+ }
108
+ if (!falsyArgValue) {
109
+ if (argDefinition.array) {
110
+ if (!Array.isArray(argValue)) {
111
+ parsedErrors.push(`Argument "${argName}" should be an array.`);
112
+ continue;
113
+ }
114
+ const parseValueTypeItems = argValue.map((item) => this.parseValueType(item, argDefinition.type));
115
+ if (!parseValueTypeItems.every((item) => item.isValidType)) {
116
+ parsedErrors.push(`Argument "${argName}" should be an array of type "${argDefinition.type}".`);
117
+ }
118
+ parsedArguments[argName] = parseValueTypeItems.map((item) => item.parsedValue);
119
+ }
120
+ else {
121
+ const parsedValueTypeItem = this.parseValueType(argValue, argDefinition.type);
122
+ if (!parsedValueTypeItem.isValidType) {
123
+ parsedErrors.push(`Argument "${argName}" should be of type "${argDefinition.type}".`);
124
+ }
125
+ parsedArguments[argName] = parsedValueTypeItem.parsedValue;
126
+ }
127
+ }
128
+ }
129
+ return { parsedErrors, parsedArguments };
130
+ }
131
+ parseValueType(value, expectedType) {
132
+ switch (expectedType) {
133
+ case 'string':
134
+ // For string type, we just need to convert to string.
135
+ return {
136
+ isValidType: true,
137
+ parsedValue: typeof value === 'object' ? JSON.stringify(value) : String(value), // Need to do this because String(object) returns "[object Object]".
138
+ };
139
+ case 'boolean':
140
+ // For boolean type, we need to check if the value is a boolean or a boolean string.
141
+ if (typeof value === 'boolean') {
142
+ return {
143
+ isValidType: true,
144
+ parsedValue: value,
145
+ };
146
+ }
147
+ if (isBooleanString(value)) {
148
+ return {
149
+ isValidType: true,
150
+ parsedValue: value.toLowerCase() === 'true',
151
+ };
152
+ }
153
+ return {
154
+ isValidType: false,
155
+ parsedValue: value,
156
+ };
157
+ case 'number':
158
+ // For number type, we need to check if the value is a number or a number string.
159
+ if (typeof value === 'number') {
160
+ return {
161
+ isValidType: true,
162
+ parsedValue: value,
163
+ };
164
+ }
165
+ if (isNumberString(value)) {
166
+ return {
167
+ isValidType: true,
168
+ parsedValue: parseFloat(value),
169
+ };
170
+ }
171
+ return {
172
+ isValidType: false,
173
+ parsedValue: value,
174
+ };
175
+ case 'dict':
176
+ // For dict type, we need to check if the value is a record.
177
+ if (isRecord(value)) {
178
+ return {
179
+ isValidType: true,
180
+ parsedValue: value,
181
+ };
182
+ }
183
+ return {
184
+ isValidType: false,
185
+ parsedValue: value,
186
+ };
187
+ case 'json':
188
+ // For json type, we need to check if the value is a valid JSON string.
189
+ try {
190
+ const parsedJSONValue = JSON.parse(JSON.stringify(value));
191
+ return {
192
+ isValidType: true,
193
+ parsedValue: parsedJSONValue,
194
+ };
195
+ }
196
+ catch {
197
+ return {
198
+ isValidType: false,
199
+ parsedValue: value,
200
+ };
201
+ }
202
+ default:
203
+ throw new Error(`Unknown argument type "${expectedType}".`);
204
+ }
205
+ }
206
+ }
207
+ function parseFunctionArguments(args, schema) {
208
+ if (!isRecord(args)) {
209
+ throw new Error(`The given arguments are invalid.`);
210
+ }
211
+ const argumentParser = new ArgumentParser(schema);
212
+ const { parsedArguments, parsedErrors } = argumentParser.parse(args);
213
+ if (parsedErrors.length > 0) {
214
+ const invalidArgumentsError = dedent.dedent `
215
+ Invalid parameter(s) found:
216
+ ${parsedErrors.join('\n')}
217
+ `;
218
+ throw new InvalidArgumentsError(invalidArgumentsError);
219
+ }
220
+ return pick(parsedArguments, Object.keys(schema));
221
+ }
222
+
223
+ const CONNECTION_ERROR_INITIAL_TIMEOUT_MS = 50;
224
+ const CONNECTION_ERROR_RETRY_MAX_MS = 1000 * 60 * 10; // 10 minutes
225
+ function sleep(ms) {
226
+ return new Promise((resolve) => {
227
+ const timeout = setTimeout(resolve, ms);
228
+ timeout.unref();
229
+ });
230
+ }
231
+ async function loopWithBackoff(pollingIntervalMs, logger, callback) {
232
+ let delayTimeMs = CONNECTION_ERROR_INITIAL_TIMEOUT_MS;
233
+ let lastLoopTimestamp = Date.now();
234
+ while (true) {
235
+ try {
236
+ const result = await callback();
237
+ const currentTimestamp = Date.now();
238
+ const loopDurationMs = currentTimestamp - lastLoopTimestamp;
239
+ lastLoopTimestamp = currentTimestamp;
240
+ logger.debug(`Loop time: ${loopDurationMs}ms, delay time: ${delayTimeMs}ms, polling interval: ${pollingIntervalMs}ms`);
241
+ if (result !== 'continue') {
242
+ return result;
243
+ }
244
+ await sleep(pollingIntervalMs);
245
+ delayTimeMs = Math.max(delayTimeMs / 2, CONNECTION_ERROR_INITIAL_TIMEOUT_MS);
246
+ }
247
+ catch (err) {
248
+ logger.error('Error running RPC agent', err);
249
+ await sleep(delayTimeMs);
250
+ delayTimeMs = Math.min(delayTimeMs * 2, CONNECTION_ERROR_RETRY_MAX_MS);
251
+ }
252
+ }
253
+ }
254
+
255
+ const LOG_LEVEL_RANKINGS = {
256
+ debug: 0,
257
+ info: 1,
258
+ warn: 2,
259
+ error: 3,
260
+ };
261
+ class Logger {
262
+ constructor(options) {
263
+ this.currentLogLevel = options.logLevel || 'info'; // Default to 'info' if logLevel is not specified
264
+ }
265
+ shouldLog(level) {
266
+ return LOG_LEVEL_RANKINGS[level] >= LOG_LEVEL_RANKINGS[this.currentLogLevel] && process.env.NODE_ENV !== 'test';
267
+ }
268
+ debug(...messages) {
269
+ if (this.shouldLog('debug')) {
270
+ console.log(...messages);
271
+ }
272
+ }
273
+ info(...messages) {
274
+ if (this.shouldLog('info')) {
275
+ console.log(...messages);
276
+ }
277
+ }
278
+ warn(...messages) {
279
+ if (this.shouldLog('warn')) {
280
+ console.log(...messages);
281
+ }
282
+ }
283
+ error(...messages) {
284
+ if (this.shouldLog('error')) {
285
+ console.log(...messages);
286
+ }
287
+ }
288
+ }
289
+
290
+ const RetoolRPCVersion = '0.1.5';
291
+
292
+ // AbortController was added in node v14.17.0 globally, but we need to polyfill it for older versions
293
+ const AbortController = globalThis.AbortController || AbortControllerFallback__default["default"];
294
+ class RetoolAPI {
295
+ constructor({ hostUrl, apiKey, pollingTimeoutMs }) {
296
+ this._hostUrl = hostUrl;
297
+ this._apiKey = apiKey;
298
+ this._pollingTimeoutMs = pollingTimeoutMs;
299
+ }
300
+ async popQuery(options) {
301
+ const abortController = new AbortController();
302
+ setTimeout(() => {
303
+ abortController.abort();
304
+ }, this._pollingTimeoutMs);
305
+ try {
306
+ return await fetch__default["default"](`${this._hostUrl}/api/v1/retoolrpc/popQuery`, {
307
+ method: 'POST',
308
+ headers: {
309
+ Authorization: `Bearer ${this._apiKey}`,
310
+ 'Content-Type': 'application/json',
311
+ 'User-Agent': `RetoolRPC/${RetoolRPCVersion} (Javascript)`,
312
+ },
313
+ body: JSON.stringify(options),
314
+ // Had to cast to RequestInit['signal'] because of a bug in the types
315
+ // https://github.com/jasonkuhrt/graphql-request/issues/481
316
+ signal: abortController.signal,
317
+ });
318
+ }
319
+ catch (error) {
320
+ if (abortController.signal.aborted) {
321
+ throw new Error(`Polling timeout after ${this._pollingTimeoutMs}ms`);
322
+ }
323
+ throw error;
324
+ }
325
+ }
326
+ async registerAgent(options) {
327
+ return fetch__default["default"](`${this._hostUrl}/api/v1/retoolrpc/registerAgent`, {
328
+ method: 'POST',
329
+ headers: {
330
+ Authorization: `Bearer ${this._apiKey}`,
331
+ 'Content-Type': 'application/json',
332
+ 'User-Agent': `RetoolRPC/${RetoolRPCVersion} (Javascript)`,
333
+ },
334
+ body: JSON.stringify(options),
335
+ });
336
+ }
337
+ async postQueryResponse(options) {
338
+ return fetch__default["default"](`${this._hostUrl}/api/v1/retoolrpc/postQueryResponse`, {
339
+ method: 'POST',
340
+ headers: {
341
+ Authorization: `Bearer ${this._apiKey}`,
342
+ 'Content-Type': 'application/json',
343
+ 'User-Agent': `RetoolRPC/${RetoolRPCVersion} (Javascript)`,
344
+ },
345
+ body: JSON.stringify(options),
346
+ });
347
+ }
348
+ }
349
+
350
+ const MINIMUM_POLLING_INTERVAL_MS = 100;
351
+ const DEFAULT_POLLING_INTERVAL_MS = 1000;
352
+ const DEFAULT_POLLING_TIMEOUT_MS = 5000;
353
+ const DEFAULT_ENVIRONMENT_NAME = 'production';
354
+ const DEFAULT_VERSION = '0.0.1';
355
+ /**
356
+ * Represents the Retool RPC for interacting with Retool functions and contexts.
357
+ */
358
+ class RetoolRPC {
359
+ /**
360
+ * Creates an instance of the RetoolRPC class.
361
+ */
362
+ constructor(config) {
363
+ var _a;
364
+ this._functions = {};
365
+ this._apiKey = config.apiToken;
366
+ this._hostUrl = config.host.replace(/\/$/, ''); // Remove trailing / from host
367
+ this._resourceId = config.resourceId;
368
+ this._environmentName = config.environmentName || DEFAULT_ENVIRONMENT_NAME;
369
+ this._pollingIntervalMs = config.pollingIntervalMs
370
+ ? Math.max(config.pollingIntervalMs, MINIMUM_POLLING_INTERVAL_MS)
371
+ : DEFAULT_POLLING_INTERVAL_MS;
372
+ this._pollingTimeoutMs = config.pollingTimeoutMs || DEFAULT_POLLING_TIMEOUT_MS;
373
+ this._version = config.version || DEFAULT_VERSION;
374
+ this._agentUuid = config.agentUuid || uuid.v4();
375
+ this._retoolApi = new RetoolAPI({
376
+ hostUrl: this._hostUrl,
377
+ apiKey: this._apiKey,
378
+ pollingTimeoutMs: this._pollingTimeoutMs || DEFAULT_POLLING_TIMEOUT_MS,
379
+ });
380
+ this._logger = (_a = config.logger) !== null && _a !== void 0 ? _a : new Logger({ logLevel: config.logLevel });
381
+ this._logger.debug('Retool RPC Configuration', {
382
+ apiKey: this._apiKey,
383
+ hostUrl: this._hostUrl,
384
+ resourceId: this._resourceId,
385
+ environmentName: this._environmentName,
386
+ agentUuid: this._agentUuid,
387
+ version: this._version,
388
+ pollingIntervalMs: this._pollingIntervalMs,
389
+ });
390
+ }
391
+ /**
392
+ * Asynchronously starts listening for incoming Retool function invocations.
393
+ */
394
+ async listen() {
395
+ this._logger.info('Starting RPC agent');
396
+ const registerResult = await loopWithBackoff(this._pollingIntervalMs, this._logger, () => this.registerAgent());
397
+ if (registerResult === 'done') {
398
+ this._logger.info('Agent registered');
399
+ this._logger.info('Starting processing query');
400
+ loopWithBackoff(this._pollingIntervalMs, this._logger, () => this.fetchQueryAndExecute());
401
+ }
402
+ }
403
+ /**
404
+ * Registers a Retool function with the specified function definition.
405
+ */
406
+ register(spec) {
407
+ this._functions[spec.name] = {
408
+ arguments: spec.arguments,
409
+ permissions: spec.permissions,
410
+ implementation: spec.implementation,
411
+ };
412
+ return spec.implementation;
413
+ }
414
+ /**
415
+ * Executes a Retool function with the specified arguments and context.
416
+ */
417
+ async executeFunction(functionName, functionArguments, context) {
418
+ this._logger.info(`Executing function: ${functionName}, context: ${context}`);
419
+ if (functionName === '__testConnection__') {
420
+ return { result: this.testConnection(context), arguments: {} };
421
+ }
422
+ const fnSpec = this._functions[functionName];
423
+ if (!fnSpec) {
424
+ throw new FunctionNotFoundError(functionName);
425
+ }
426
+ const parsedArguments = parseFunctionArguments(functionArguments, fnSpec.arguments);
427
+ this._logger.debug('Parsed arguments: ', parsedArguments);
428
+ const result = await fnSpec.implementation(parsedArguments, context);
429
+ // Consider truncating large arguments
430
+ return { result, arguments: parsedArguments };
431
+ }
432
+ /**
433
+ * Tests the current connection to the Retool server.
434
+ */
435
+ testConnection(context) {
436
+ return {
437
+ success: true,
438
+ version: this._version,
439
+ agentUuid: this._agentUuid,
440
+ context,
441
+ };
442
+ }
443
+ /**
444
+ * Registers the agent with the Retool server.
445
+ */
446
+ async registerAgent() {
447
+ const functionsMetadata = {};
448
+ for (const functionName in this._functions) {
449
+ functionsMetadata[functionName] = {
450
+ arguments: this._functions[functionName].arguments,
451
+ permissions: this._functions[functionName].permissions,
452
+ };
453
+ }
454
+ const registerAgentResponse = await this._retoolApi.registerAgent({
455
+ resourceId: this._resourceId,
456
+ environmentName: this._environmentName,
457
+ version: this._version,
458
+ agentUuid: this._agentUuid,
459
+ operations: functionsMetadata,
460
+ });
461
+ if (!registerAgentResponse.ok) {
462
+ if (isClientError(registerAgentResponse.status)) {
463
+ this._logger.error(`Error registering agent: ${registerAgentResponse.status} ${await registerAgentResponse.text()}}`);
464
+ // client error, stop the client
465
+ return 'stop';
466
+ }
467
+ throw new Error(`Error connecting to retool server: ${registerAgentResponse.status} ${await registerAgentResponse.text()}}`);
468
+ }
469
+ const { versionHash } = await registerAgentResponse.json();
470
+ this._versionHash = versionHash;
471
+ this._logger.info(`Agent registered with versionHash: ${versionHash}`);
472
+ return 'done';
473
+ }
474
+ /**
475
+ * Fetches a query from the Retool server and executes it.
476
+ */
477
+ async fetchQueryAndExecute() {
478
+ const pendingQueryFetch = await this._retoolApi.popQuery({
479
+ resourceId: this._resourceId,
480
+ environmentName: this._environmentName,
481
+ agentUuid: this._agentUuid,
482
+ versionHash: this._versionHash,
483
+ });
484
+ if (!pendingQueryFetch.ok) {
485
+ if (isClientError(pendingQueryFetch.status)) {
486
+ this._logger.error(`Error fetching query (${pendingQueryFetch.status}): ${await pendingQueryFetch.text()}`);
487
+ return 'stop';
488
+ }
489
+ throw new Error(`Server error when fetching query: ${pendingQueryFetch.status}. Retrying...`);
490
+ }
491
+ const { query } = await pendingQueryFetch.json();
492
+ if (query) {
493
+ this._logger.debug('Executing query', query); // This might contain sensitive information
494
+ const agentReceivedQueryAt = new Date().toISOString();
495
+ const queryUuid = query.queryUuid;
496
+ const { method, parameters, context } = query.queryInfo;
497
+ let status;
498
+ let executionResponse = undefined;
499
+ let executionArguments = undefined;
500
+ let agentError = undefined;
501
+ this.executeFunction(method, parameters, context)
502
+ .then((executionResult) => {
503
+ executionResponse = executionResult.result;
504
+ executionArguments = executionResult.arguments;
505
+ status = 'success';
506
+ })
507
+ .catch((err) => {
508
+ agentError = createAgentServerError(err);
509
+ status = 'error';
510
+ })
511
+ .finally(() => {
512
+ this._retoolApi
513
+ .postQueryResponse({
514
+ resourceId: this._resourceId,
515
+ environmentName: this._environmentName,
516
+ versionHash: this._versionHash,
517
+ agentUuid: this._agentUuid,
518
+ queryUuid,
519
+ status,
520
+ data: executionResponse,
521
+ metadata: {
522
+ packageLanguage: 'javascript',
523
+ packageVersion: RetoolRPCVersion,
524
+ agentReceivedQueryAt,
525
+ agentFinishedQueryAt: new Date().toISOString(),
526
+ parameters: executionArguments,
527
+ },
528
+ error: agentError,
529
+ })
530
+ .then(async (updateQueryResponse) => {
531
+ this._logger.debug('Update query response status: ', updateQueryResponse.status, await updateQueryResponse.text());
532
+ })
533
+ .catch((err) => {
534
+ this._logger.error(`Error updating query response: `, err);
535
+ });
536
+ });
537
+ }
538
+ return 'continue';
539
+ }
540
+ }
541
+
542
+ exports.RetoolRPC = RetoolRPC;