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