@temporalio/client 1.0.0-rc.0 → 1.0.0-rc.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,374 @@
1
+ import * as grpc from '@grpc/grpc-js';
2
+ import { filterNullAndUndefined, normalizeTlsConfig, TLSConfig } from '@temporalio/internal-non-workflow-common';
3
+ import { AsyncLocalStorage } from 'async_hooks';
4
+ import type { RPCImpl } from 'protobufjs';
5
+ import { isServerErrorResponse, ServiceError } from './errors';
6
+ import { defaultGrpcRetryOptions, makeGrpcRetryInterceptor } from './grpc-retry';
7
+ import pkg from './pkg';
8
+ import { CallContext, Metadata, OperatorService, WorkflowService } from './types';
9
+
10
+ /**
11
+ * gRPC and Temporal Server connection options
12
+ */
13
+ export interface ConnectionOptions {
14
+ /**
15
+ * Server hostname and optional port.
16
+ * Port defaults to 7233 if address contains only host.
17
+ *
18
+ * @default localhost:7233
19
+ */
20
+ address?: string;
21
+
22
+ /**
23
+ * TLS configuration.
24
+ * Pass a falsy value to use a non-encrypted connection or `true` or `{}` to
25
+ * connect with TLS without any customization.
26
+ *
27
+ * Either {@link credentials} or this may be specified for configuring TLS
28
+ */
29
+ tls?: TLSConfig | boolean | null;
30
+
31
+ /**
32
+ * Channel credentials, create using the factory methods defined {@link https://grpc.github.io/grpc/node/grpc.credentials.html | here}
33
+ *
34
+ * Either {@link tls} or this may be specified for configuring TLS
35
+ */
36
+ credentials?: grpc.ChannelCredentials;
37
+
38
+ /**
39
+ * GRPC Channel arguments
40
+ *
41
+ * @see option descriptions {@link https://grpc.github.io/grpc/core/group__grpc__arg__keys.html | here}
42
+ */
43
+ channelArgs?: grpc.ChannelOptions;
44
+
45
+ /**
46
+ * Grpc interceptors which will be applied to every RPC call performed by this connection. By
47
+ * default, an interceptor will be included which automatically retries retryable errors. If you
48
+ * do not wish to perform automatic retries, set this to an empty list (or a list with your own
49
+ * interceptors).
50
+ */
51
+ interceptors?: grpc.Interceptor[];
52
+
53
+ /**
54
+ * Optional mapping of gRPC metadata (HTTP headers) to send with each request to the server.
55
+ *
56
+ * In order to dynamically set metadata, use {@link Connection.withMetadata}
57
+ */
58
+ metadata?: Metadata;
59
+
60
+ /**
61
+ * Milliseconds to wait until establishing a connection with the server.
62
+ *
63
+ * Used either when connecting eagerly with {@link Connection.connect} or
64
+ * calling {@link Connection.ensureConnected}.
65
+ *
66
+ * @format {@link https://www.npmjs.com/package/ms | ms} formatted string
67
+ * @default 10 seconds
68
+ */
69
+ connectTimeout?: number | string;
70
+ }
71
+
72
+ export type ConnectionOptionsWithDefaults = Required<Omit<ConnectionOptions, 'tls' | 'connectTimeout'>> & {
73
+ connectTimeoutMs: number;
74
+ };
75
+
76
+ export const LOCAL_TARGET = '127.0.0.1:7233';
77
+
78
+ export function defaultConnectionOpts(): ConnectionOptionsWithDefaults {
79
+ return {
80
+ address: LOCAL_TARGET,
81
+ credentials: grpc.credentials.createInsecure(),
82
+ channelArgs: {},
83
+ interceptors: [makeGrpcRetryInterceptor(defaultGrpcRetryOptions())],
84
+ metadata: {},
85
+ connectTimeoutMs: 10_000,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * - Convert {@link ConnectionOptions.tls} to {@link grpc.ChannelCredentials}
91
+ * - Add the grpc.ssl_target_name_override GRPC {@link ConnectionOptions.channelArgs | channel arg}
92
+ * - Add default port to address if port not specified
93
+ */
94
+ function normalizeGRPCConfig(options?: ConnectionOptions): ConnectionOptions {
95
+ const { tls: tlsFromConfig, credentials, ...rest } = options || {};
96
+ if (rest.address) {
97
+ // eslint-disable-next-line prefer-const
98
+ let [host, port] = rest.address.split(':', 2);
99
+ port = port || '7233';
100
+ rest.address = `${host}:${port}`;
101
+ }
102
+ const tls = normalizeTlsConfig(tlsFromConfig);
103
+ if (tls) {
104
+ if (credentials) {
105
+ throw new TypeError('Both `tls` and `credentials` ConnectionOptions were provided');
106
+ }
107
+ return {
108
+ ...rest,
109
+ credentials: grpc.credentials.createSsl(
110
+ tls.serverRootCACertificate,
111
+ tls.clientCertPair?.key,
112
+ tls.clientCertPair?.crt
113
+ ),
114
+ channelArgs: {
115
+ ...rest.channelArgs,
116
+ ...(tls.serverNameOverride
117
+ ? {
118
+ 'grpc.ssl_target_name_override': tls.serverNameOverride,
119
+ 'grpc.default_authority': tls.serverNameOverride,
120
+ }
121
+ : undefined),
122
+ },
123
+ };
124
+ } else {
125
+ return rest;
126
+ }
127
+ }
128
+
129
+ export interface RPCImplOptions {
130
+ serviceName: string;
131
+ client: grpc.Client;
132
+ callContextStorage: AsyncLocalStorage<CallContext>;
133
+ interceptors?: grpc.Interceptor[];
134
+ }
135
+
136
+ export interface ConnectionCtorOptions {
137
+ readonly options: ConnectionOptionsWithDefaults;
138
+ readonly client: grpc.Client;
139
+ /**
140
+ * Raw gRPC access to the Temporal service.
141
+ *
142
+ * **NOTE**: The namespace provided in {@link options} is **not** automatically set on requests made to the service.
143
+ */
144
+ readonly workflowService: WorkflowService;
145
+ readonly operatorService: OperatorService;
146
+ readonly callContextStorage: AsyncLocalStorage<CallContext>;
147
+ }
148
+
149
+ /**
150
+ * Client connection to the Temporal Server
151
+ *
152
+ * ⚠️ Connections are expensive to construct and should be reused. Make sure to {@link close} any unused connections to
153
+ * avoid leaking resources.
154
+ */
155
+ export class Connection {
156
+ /**
157
+ * @internal
158
+ */
159
+ public static readonly Client = grpc.makeGenericClientConstructor({}, 'WorkflowService', {});
160
+
161
+ public readonly options: ConnectionOptionsWithDefaults;
162
+ protected readonly client: grpc.Client;
163
+
164
+ /**
165
+ * Used to ensure `ensureConnected` is called once.
166
+ */
167
+ protected connectPromise?: Promise<void>;
168
+
169
+ /**
170
+ * Raw gRPC access to Temporal Server's {@link
171
+ * https://github.com/temporalio/api/blob/master/temporal/api/workflowservice/v1/service.proto | Workflow service}
172
+ */
173
+ public readonly workflowService: WorkflowService;
174
+
175
+ /**
176
+ * Raw gRPC access to Temporal Server's
177
+ * {@link https://github.com/temporalio/api/blob/master/temporal/api/operatorservice/v1/service.proto | Operator service}
178
+ */
179
+ public readonly operatorService: OperatorService;
180
+ readonly callContextStorage: AsyncLocalStorage<CallContext>;
181
+
182
+ protected static createCtorOptions(options?: ConnectionOptions): ConnectionCtorOptions {
183
+ const optionsWithDefaults = {
184
+ ...defaultConnectionOpts(),
185
+ ...filterNullAndUndefined(normalizeGRPCConfig(options)),
186
+ };
187
+ // Allow overriding this
188
+ optionsWithDefaults.metadata['client-name'] ??= 'temporal-typescript';
189
+ optionsWithDefaults.metadata['client-version'] ??= pkg.version;
190
+
191
+ const client = new this.Client(
192
+ optionsWithDefaults.address,
193
+ optionsWithDefaults.credentials,
194
+ optionsWithDefaults.channelArgs
195
+ );
196
+ const callContextStorage = new AsyncLocalStorage<CallContext>();
197
+ callContextStorage.enterWith({ metadata: optionsWithDefaults.metadata });
198
+
199
+ const workflowRpcImpl = this.generateRPCImplementation({
200
+ serviceName: 'temporal.api.workflowservice.v1.WorkflowService',
201
+ client,
202
+ callContextStorage,
203
+ interceptors: optionsWithDefaults?.interceptors,
204
+ });
205
+ const workflowService = WorkflowService.create(workflowRpcImpl, false, false);
206
+ const operatorRpcImpl = this.generateRPCImplementation({
207
+ serviceName: 'temporal.api.operatorservice.v1.OperatorService',
208
+ client,
209
+ callContextStorage,
210
+ interceptors: optionsWithDefaults?.interceptors,
211
+ });
212
+ const operatorService = OperatorService.create(operatorRpcImpl, false, false);
213
+
214
+ return {
215
+ client,
216
+ callContextStorage,
217
+ workflowService,
218
+ operatorService,
219
+ options: optionsWithDefaults,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Ensure connection can be established.
225
+ *
226
+ * Does not need to be called if you use {@link connect}.
227
+ *
228
+ * This method's result is memoized to ensure it runs only once.
229
+ *
230
+ * Calls {@link proto.temporal.api.workflowservice.v1.WorkflowService.getSystemInfo} internally.
231
+ */
232
+ async ensureConnected(): Promise<void> {
233
+ if (this.connectPromise == null) {
234
+ const deadline = Date.now() + this.options.connectTimeoutMs;
235
+ this.connectPromise = (async () => {
236
+ await this.untilReady(deadline);
237
+
238
+ try {
239
+ await this.withDeadline(deadline, () => this.workflowService.getSystemInfo({}));
240
+ } catch (err) {
241
+ if (isServerErrorResponse(err)) {
242
+ // Ignore old servers
243
+ if (err.code !== grpc.status.UNIMPLEMENTED) {
244
+ throw new ServiceError('Failed to connect to Temporal server', { cause: err });
245
+ }
246
+ } else {
247
+ throw err;
248
+ }
249
+ }
250
+ })();
251
+ }
252
+ return this.connectPromise;
253
+ }
254
+
255
+ /**
256
+ * Create a lazy `Connection` instance.
257
+ *
258
+ * This method does not verify connectivity with the server. We recommend using {@link connect} instead.
259
+ */
260
+ static lazy(options?: ConnectionOptions): Connection {
261
+ return new this(this.createCtorOptions(options));
262
+ }
263
+
264
+ /**
265
+ * Establish a connection with the server and return a `Connection` instance.
266
+ *
267
+ * This is the preferred method of creating connections as it verifies connectivity by calling
268
+ * {@link ensureConnected}.
269
+ */
270
+ static async connect(options?: ConnectionOptions): Promise<Connection> {
271
+ const conn = this.lazy(options);
272
+ await conn.ensureConnected();
273
+ return conn;
274
+ }
275
+
276
+ protected constructor({
277
+ options,
278
+ client,
279
+ workflowService,
280
+ operatorService,
281
+ callContextStorage,
282
+ }: ConnectionCtorOptions) {
283
+ this.options = options;
284
+ this.client = client;
285
+ this.workflowService = workflowService;
286
+ this.operatorService = operatorService;
287
+ this.callContextStorage = callContextStorage;
288
+ }
289
+
290
+ protected static generateRPCImplementation({
291
+ serviceName,
292
+ client,
293
+ callContextStorage,
294
+ interceptors,
295
+ }: RPCImplOptions): RPCImpl {
296
+ return (method: { name: string }, requestData: any, callback: grpc.requestCallback<any>) => {
297
+ const metadataContainer = new grpc.Metadata();
298
+ const { metadata, deadline } = callContextStorage.getStore() ?? {};
299
+ if (metadata != null) {
300
+ for (const [k, v] of Object.entries(metadata)) {
301
+ metadataContainer.set(k, v);
302
+ }
303
+ }
304
+ return client.makeUnaryRequest(
305
+ `/${serviceName}/${method.name}`,
306
+ (arg: any) => arg,
307
+ (arg: any) => arg,
308
+ requestData,
309
+ metadataContainer,
310
+ { interceptors, deadline },
311
+ callback
312
+ );
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Set the deadline for any service requests executed in `fn`'s scope.
318
+ *
319
+ * @returns value returned from `fn`
320
+ */
321
+ async withDeadline<ReturnType>(deadline: number | Date, fn: () => Promise<ReturnType>): Promise<ReturnType> {
322
+ const cc = this.callContextStorage.getStore();
323
+ return await this.callContextStorage.run({ deadline, metadata: cc?.metadata }, fn);
324
+ }
325
+
326
+ /**
327
+ * Set metadata for any service requests executed in `fn`'s scope.
328
+ *
329
+ * The provided metadata is merged on top of any existing metadata in current scope, including metadata provided in
330
+ * {@link ConnectionOptions.metadata}.
331
+ *
332
+ * @returns value returned from `fn`
333
+ *
334
+ * @example
335
+ *
336
+ *```ts
337
+ *const workflowHandle = await conn.withMetadata({ apiKey: 'secret' }, () =>
338
+ * conn.withMetadata({ otherKey: 'set' }, () => client.start(options)))
339
+ *);
340
+ *```
341
+ */
342
+ async withMetadata<ReturnType>(metadata: Metadata, fn: () => Promise<ReturnType>): Promise<ReturnType> {
343
+ const cc = this.callContextStorage.getStore();
344
+ metadata = { ...cc?.metadata, ...metadata };
345
+ return await this.callContextStorage.run({ metadata, deadline: cc?.deadline }, fn);
346
+ }
347
+
348
+ /**
349
+ * Wait for successful connection to the server.
350
+ *
351
+ * @see https://grpc.github.io/grpc/node/grpc.Client.html#waitForReady__anchor
352
+ */
353
+ protected async untilReady(deadline: number): Promise<void> {
354
+ return new Promise<void>((resolve, reject) => {
355
+ this.client.waitForReady(deadline, (err) => {
356
+ if (err) {
357
+ reject(err);
358
+ } else {
359
+ resolve();
360
+ }
361
+ });
362
+ });
363
+ }
364
+
365
+ // This method is async for uniformity with NativeConnection which could be used in the future to power clients
366
+ /**
367
+ * Close the underlying gRPC client.
368
+ *
369
+ * Make sure to call this method to ensure proper resource cleanup.
370
+ */
371
+ public async close(): Promise<void> {
372
+ this.client.close();
373
+ }
374
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { ServerErrorResponse } from '@grpc/grpc-js';
2
+ import { RetryState, TemporalFailure } from '@temporalio/common';
3
+ export { WorkflowExecutionAlreadyStartedError } from '@temporalio/common';
4
+
5
+ /**
6
+ * Generic Error class for errors coming from the service
7
+ */
8
+ export class ServiceError extends Error {
9
+ public readonly name: string = 'ServiceError';
10
+ public readonly cause?: Error;
11
+
12
+ constructor(message: string, opts?: { cause: Error }) {
13
+ super(message);
14
+ this.cause = opts?.cause;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Thrown by the client while waiting on Workflow execution result if execution
20
+ * completes with failure.
21
+ *
22
+ * The failure type will be set in the `cause` attribute.
23
+ *
24
+ * For example if the workflow is cancelled, `cause` will be set to
25
+ * {@link CancelledFailure}.
26
+ */
27
+ export class WorkflowFailedError extends Error {
28
+ public readonly name: string = 'WorkflowFailedError';
29
+ public constructor(
30
+ message: string,
31
+ public readonly cause: TemporalFailure | undefined,
32
+ public readonly retryState: RetryState
33
+ ) {
34
+ super(message);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Thrown the by client while waiting on Workflow execution result if Workflow
40
+ * continues as new.
41
+ *
42
+ * Only thrown if asked not to follow the chain of execution (see {@link WorkflowOptions.followRuns}).
43
+ */
44
+ export class WorkflowContinuedAsNewError extends Error {
45
+ public readonly name: string = 'WorkflowExecutionContinuedAsNewError';
46
+ public constructor(message: string, public readonly newExecutionRunId: string) {
47
+ super(message);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Type assertion helper, assertion is mostly empty because any additional
53
+ * properties are optional.
54
+ */
55
+ export function isServerErrorResponse(err: unknown): err is ServerErrorResponse {
56
+ return err instanceof Error;
57
+ }
@@ -0,0 +1,119 @@
1
+ import {
2
+ InterceptingCall,
3
+ Interceptor,
4
+ ListenerBuilder,
5
+ Metadata,
6
+ RequesterBuilder,
7
+ StatusObject,
8
+ } from '@grpc/grpc-js';
9
+ import * as grpc from '@grpc/grpc-js';
10
+
11
+ export interface GrpcRetryOptions {
12
+ /** Maximum number of allowed retries. Defaults to 10. */
13
+ maxRetries: number;
14
+
15
+ /**
16
+ * A function which accepts the current retry attempt (starts at 0) and returns the millisecond
17
+ * delay that should be applied before the next retry.
18
+ */
19
+ delayFunction: (attempt: number) => number;
20
+
21
+ /**
22
+ * A function which accepts a failed status object and returns true if the call should be retried
23
+ */
24
+ retryableDecider: (status: StatusObject) => boolean;
25
+ }
26
+
27
+ export function defaultGrpcRetryOptions(): GrpcRetryOptions {
28
+ return {
29
+ maxRetries: 10,
30
+ delayFunction: backOffAmount,
31
+ retryableDecider: isRetryableError,
32
+ };
33
+ }
34
+
35
+ const retryableCodes = new Set([
36
+ grpc.status.UNKNOWN,
37
+ grpc.status.RESOURCE_EXHAUSTED,
38
+ grpc.status.UNAVAILABLE,
39
+ grpc.status.ABORTED,
40
+ grpc.status.DATA_LOSS,
41
+ grpc.status.OUT_OF_RANGE,
42
+ ]);
43
+
44
+ export function isRetryableError(status: StatusObject): boolean {
45
+ return retryableCodes.has(status.code);
46
+ }
47
+
48
+ /** Return backoff amount in ms */
49
+ export function backOffAmount(attempt: number): number {
50
+ return 2 ** attempt * 20;
51
+ }
52
+
53
+ /**
54
+ * Returns a GRPC interceptor that will perform automatic retries for some types of failed calls
55
+ *
56
+ * @param retryOptions Options for the retry interceptor
57
+ */
58
+ export function makeGrpcRetryInterceptor(retryOptions: GrpcRetryOptions): Interceptor {
59
+ return (options, nextCall) => {
60
+ let savedMetadata: Metadata;
61
+ let savedSendMessage: any;
62
+ let savedReceiveMessage: any;
63
+ let savedMessageNext: any;
64
+ const requester = new RequesterBuilder()
65
+ .withStart(function (metadata, _listener, next) {
66
+ savedMetadata = metadata;
67
+ const newListener = new ListenerBuilder()
68
+ .withOnReceiveMessage((message, next) => {
69
+ savedReceiveMessage = message;
70
+ savedMessageNext = next;
71
+ })
72
+ .withOnReceiveStatus((status, next) => {
73
+ let retries = 0;
74
+ const retry = (message: any, metadata: Metadata) => {
75
+ retries++;
76
+ const newCall = nextCall(options);
77
+ newCall.start(metadata, {
78
+ onReceiveMessage: (message) => {
79
+ savedReceiveMessage = message;
80
+ },
81
+ onReceiveStatus: (status) => {
82
+ if (retryOptions.retryableDecider(status)) {
83
+ if (retries <= retryOptions.maxRetries) {
84
+ setTimeout(() => retry(message, metadata), retryOptions.delayFunction(retries));
85
+ } else {
86
+ savedMessageNext(savedReceiveMessage);
87
+ next(status);
88
+ }
89
+ } else {
90
+ savedMessageNext(savedReceiveMessage);
91
+ // TODO: For reasons that are completely unclear to me, if you pass a handcrafted
92
+ // status object here, node will magically just exit at the end of this line.
93
+ // No warning, no nothing. Here be dragons.
94
+ next(status);
95
+ }
96
+ },
97
+ });
98
+ newCall.sendMessage(message);
99
+ newCall.halfClose();
100
+ };
101
+
102
+ if (retryOptions.retryableDecider(status)) {
103
+ setTimeout(() => retry(savedSendMessage, savedMetadata), backOffAmount(retries));
104
+ } else {
105
+ savedMessageNext(savedReceiveMessage);
106
+ next(status);
107
+ }
108
+ })
109
+ .build();
110
+ next(metadata, newListener);
111
+ })
112
+ .withSendMessage((message, next) => {
113
+ savedSendMessage = message;
114
+ next(message);
115
+ })
116
+ .build();
117
+ return new InterceptingCall(nextCall(options), requester);
118
+ };
119
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Client for communicating with Temporal Server.
3
+ *
4
+ * Most functionality is available through {@link WorkflowClient}, but you can also call gRPC methods directly using {@link Connection.workflowService} and {@link Connection.operatorService}.
5
+ *
6
+ * ### Usage
7
+ * <!--SNIPSTART typescript-hello-client-->
8
+ * <!--SNIPEND-->
9
+ * @module
10
+ */
11
+
12
+ export {
13
+ ActivityFailure,
14
+ ApplicationFailure,
15
+ CancelledFailure,
16
+ ChildWorkflowFailure,
17
+ DataConverter,
18
+ defaultPayloadConverter,
19
+ ProtoFailure,
20
+ ServerFailure,
21
+ TemporalFailure,
22
+ TerminatedFailure,
23
+ TimeoutFailure,
24
+ } from '@temporalio/common';
25
+ export { TLSConfig } from '@temporalio/internal-non-workflow-common';
26
+ export { RetryPolicy } from '@temporalio/internal-workflow-common';
27
+ export * from '@temporalio/internal-workflow-common/lib/errors';
28
+ export * from '@temporalio/internal-workflow-common/lib/interfaces';
29
+ export * from '@temporalio/internal-workflow-common/lib/workflow-handle';
30
+ export * from './async-completion-client';
31
+ export { Connection, ConnectionOptions, ConnectionOptionsWithDefaults, LOCAL_TARGET } from './connection';
32
+ export * from './errors';
33
+ export * from './grpc-retry';
34
+ export * from './interceptors';
35
+ export * from './types';
36
+ export * from './workflow-client';
37
+ export * from './workflow-options';