UNPKG

@temporalio/client

Version:
463 lines 19.3 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Connection = exports.LOCAL_TARGET = exports.InternalConnectionOptionsSymbol = void 0; const node_async_hooks_1 = require("node:async_hooks"); const grpc = __importStar(require("@grpc/grpc-js")); const internal_non_workflow_1 = require("@temporalio/common/lib/internal-non-workflow"); const internal_workflow_1 = require("@temporalio/common/lib/internal-workflow"); const time_1 = require("@temporalio/common/lib/time"); const errors_1 = require("./errors"); const grpc_retry_1 = require("./grpc-retry"); const pkg_1 = __importDefault(require("./pkg")); const types_1 = require("./types"); /** * The default Temporal Server's TCP port for public gRPC connections. */ const DEFAULT_TEMPORAL_GRPC_PORT = 7233; /** * A symbol used to attach extra, SDK-internal connection options. * * @internal * @hidden */ exports.InternalConnectionOptionsSymbol = Symbol('__temporal_internal_connection_options'); exports.LOCAL_TARGET = 'localhost:7233'; function addDefaults(options) { const { channelArgs, interceptors, connectTimeout, ...rest } = options; return { address: exports.LOCAL_TARGET, credentials: grpc.credentials.createInsecure(), channelArgs: { 'grpc.keepalive_permit_without_calls': 1, 'grpc.keepalive_time_ms': 30_000, 'grpc.keepalive_timeout_ms': 15_000, max_receive_message_length: 128 * 1024 * 1024, // 128 MB ...channelArgs, }, interceptors: interceptors ?? [(0, grpc_retry_1.makeGrpcRetryInterceptor)((0, grpc_retry_1.defaultGrpcRetryOptions)())], metadata: {}, connectTimeoutMs: (0, time_1.msOptionalToNumber)(connectTimeout) ?? 10_000, ...(0, internal_workflow_1.filterNullAndUndefined)(rest), }; } /** * - Convert {@link ConnectionOptions.tls} to {@link grpc.ChannelCredentials} * - Add the grpc.ssl_target_name_override GRPC {@link ConnectionOptions.channelArgs | channel arg} * - Add default port to address if port not specified * - Set `Authorization` header based on {@link ConnectionOptions.apiKey} */ function normalizeGRPCConfig(options) { const { tls: tlsFromConfig, credentials, callCredentials, ...rest } = options || {}; if (rest.apiKey) { if (rest.metadata?.['Authorization']) { throw new TypeError('Both `apiKey` option and `Authorization` header were provided, but only one makes sense to use at a time.'); } if (credentials !== undefined) { throw new TypeError('Both `apiKey` and `credentials` ConnectionOptions were provided, but only one makes sense to use at a time'); } } if (rest.address) { rest.address = (0, internal_non_workflow_1.normalizeGrpcEndpointAddress)(rest.address, DEFAULT_TEMPORAL_GRPC_PORT); } const tls = (0, internal_non_workflow_1.normalizeTlsConfig)(tlsFromConfig); if (tls) { if (credentials) { throw new TypeError('Both `tls` and `credentials` ConnectionOptions were provided'); } return { ...rest, credentials: grpc.credentials.combineChannelCredentials(grpc.credentials.createSsl(tls.serverRootCACertificate, tls.clientCertPair?.key, tls.clientCertPair?.crt), ...(callCredentials ?? [])), channelArgs: { ...rest.channelArgs, ...(tls.serverNameOverride ? { 'grpc.ssl_target_name_override': tls.serverNameOverride, 'grpc.default_authority': tls.serverNameOverride, } : undefined), }, }; } else { return { ...rest, credentials: grpc.credentials.combineChannelCredentials(credentials ?? grpc.credentials.createInsecure(), ...(callCredentials ?? [])), }; } } /** * Client connection to the Temporal Server * * ⚠️ Connections are expensive to construct and should be reused. * Make sure to {@link close} any unused connections to avoid leaking resources. */ class Connection { /** * @internal */ static Client = grpc.makeGenericClientConstructor({}, 'WorkflowService', {}); options; client; /** * Used to ensure `ensureConnected` is called once. */ connectPromise; /** * Raw gRPC access to Temporal Server's {@link * https://github.com/temporalio/api/blob/master/temporal/api/workflowservice/v1/service.proto | Workflow service} */ workflowService; /** * Raw gRPC access to Temporal Server's * {@link https://github.com/temporalio/api/blob/master/temporal/api/operatorservice/v1/service.proto | Operator service} * * The Operator Service API defines how Temporal SDKs and other clients interact with the Temporal * server to perform administrative functions like registering a search attribute or a namespace. * * This Service API is NOT compatible with Temporal Cloud. Attempt to use it against a Temporal * Cloud namespace will result in gRPC `unauthorized` error. */ operatorService; /** * Raw gRPC access to the Temporal test service. * * Will be `undefined` if connected to a server that does not support the test service. */ testService; /** * Raw gRPC access to the standard gRPC {@link https://github.com/grpc/grpc/blob/92f58c18a8da2728f571138c37760a721c8915a2/doc/health-checking.md | health service}. */ healthService; callContextStorage; apiKeyFnRef; static createCtorOptions(options) { const normalizedOptions = normalizeGRPCConfig(options); const apiKeyFnRef = {}; if (normalizedOptions.apiKey) { if (typeof normalizedOptions.apiKey === 'string') { const apiKey = normalizedOptions.apiKey; apiKeyFnRef.fn = () => apiKey; } else { apiKeyFnRef.fn = normalizedOptions.apiKey; } } const optionsWithDefaults = addDefaults(normalizedOptions); // Allow overriding this optionsWithDefaults.metadata['client-name'] ??= 'temporal-typescript'; optionsWithDefaults.metadata['client-version'] ??= pkg_1.default.version; const client = new this.Client(optionsWithDefaults.address, optionsWithDefaults.credentials, optionsWithDefaults.channelArgs); const callContextStorage = new node_async_hooks_1.AsyncLocalStorage(); const workflowRpcImpl = this.generateRPCImplementation({ serviceName: 'temporal.api.workflowservice.v1.WorkflowService', client, callContextStorage, interceptors: optionsWithDefaults?.interceptors, staticMetadata: optionsWithDefaults.metadata, apiKeyFnRef, }); const workflowService = types_1.WorkflowService.create(workflowRpcImpl, false, false); const operatorRpcImpl = this.generateRPCImplementation({ serviceName: 'temporal.api.operatorservice.v1.OperatorService', client, callContextStorage, interceptors: optionsWithDefaults?.interceptors, staticMetadata: optionsWithDefaults.metadata, apiKeyFnRef, }); const operatorService = types_1.OperatorService.create(operatorRpcImpl, false, false); let testService = undefined; if (options?.[exports.InternalConnectionOptionsSymbol]?.supportsTestService) { const testRpcImpl = this.generateRPCImplementation({ serviceName: 'temporal.api.testservice.v1.TestService', client, callContextStorage, interceptors: optionsWithDefaults?.interceptors, staticMetadata: optionsWithDefaults.metadata, apiKeyFnRef, }); testService = types_1.TestService.create(testRpcImpl, false, false); } const healthRpcImpl = this.generateRPCImplementation({ serviceName: 'grpc.health.v1.Health', client, callContextStorage, interceptors: optionsWithDefaults?.interceptors, staticMetadata: optionsWithDefaults.metadata, apiKeyFnRef, }); const healthService = types_1.HealthService.create(healthRpcImpl, false, false); return { client, callContextStorage, workflowService, operatorService, testService, healthService, options: optionsWithDefaults, apiKeyFnRef, }; } /** * Ensure connection can be established. * * Does not need to be called if you use {@link connect}. * * This method's result is memoized to ensure it runs only once. * * Calls {@link proto.temporal.api.workflowservice.v1.WorkflowService.getSystemInfo} internally. */ async ensureConnected() { if (this.connectPromise == null) { const deadline = Date.now() + this.options.connectTimeoutMs; this.connectPromise = (async () => { await this.untilReady(deadline); try { await this.withDeadline(deadline, () => this.workflowService.getSystemInfo({})); } catch (err) { if ((0, errors_1.isGrpcServiceError)(err)) { // Ignore old servers if (err.code !== grpc.status.UNIMPLEMENTED) { throw new errors_1.ServiceError('Failed to connect to Temporal server', { cause: err }); } } else { throw err; } } })(); } return this.connectPromise; } /** * Create a lazy `Connection` instance. * * This method does not verify connectivity with the server. We recommend using {@link connect} instead. */ static lazy(options) { return new this(this.createCtorOptions(options)); } /** * Establish a connection with the server and return a `Connection` instance. * * This is the preferred method of creating connections as it verifies connectivity by calling * {@link ensureConnected}. */ static async connect(options) { const conn = this.lazy(options); await conn.ensureConnected(); return conn; } constructor({ options, client, workflowService, operatorService, testService, healthService, callContextStorage, apiKeyFnRef, }) { this.options = options; this.client = client; this.workflowService = this.withNamespaceHeaderInjector(workflowService); this.operatorService = operatorService; this.testService = testService; this.healthService = healthService; this.callContextStorage = callContextStorage; this.apiKeyFnRef = apiKeyFnRef; } static generateRPCImplementation({ serviceName, client, callContextStorage, interceptors, staticMetadata, apiKeyFnRef, }) { return (method, requestData, callback) => { const metadataContainer = new grpc.Metadata(); const { metadata, deadline, abortSignal } = callContextStorage.getStore() ?? {}; if (apiKeyFnRef.fn) { const apiKey = apiKeyFnRef.fn(); if (apiKey) metadataContainer.set('Authorization', `Bearer ${apiKey}`); } for (const [k, v] of Object.entries(staticMetadata)) { metadataContainer.set(k, v); } if (metadata != null) { for (const [k, v] of Object.entries(metadata)) { metadataContainer.set(k, v); } } const call = client.makeUnaryRequest(`/${serviceName}/${method.name}`, (arg) => arg, (arg) => arg, requestData, metadataContainer, { interceptors, deadline }, callback); if (abortSignal != null) { abortSignal.addEventListener('abort', () => call.cancel()); } return call; }; } /** * Set a deadline for any service requests executed in `fn`'s scope. * * The deadline is a point in time after which any pending gRPC request will be considered as failed; * this will locally result in the request call throwing a {@link grpc.ServiceError|ServiceError} * with code {@link grpc.status.DEADLINE_EXCEEDED|DEADLINE_EXCEEDED}; see {@link isGrpcDeadlineError}. * * It is stronly recommended to explicitly set deadlines. If no deadline is set, then it is * possible for the client to end up waiting forever for a response. * * @param deadline a point in time after which the request will be considered as failed; either a * Date object, or a number of milliseconds since the Unix epoch (UTC). * @returns the value returned from `fn` * * @see https://grpc.io/docs/guides/deadlines/ */ async withDeadline(deadline, fn) { const cc = this.callContextStorage.getStore(); return await this.callContextStorage.run({ ...cc, deadline }, fn); } /** * Set an {@link AbortSignal} that, when aborted, cancels any ongoing service requests executed in * `fn`'s scope. This will locally result in the request call throwing a {@link grpc.ServiceError|ServiceError} * with code {@link grpc.status.CANCELLED|CANCELLED}; see {@link isGrpcCancelledError}. * * This method is only a convenience wrapper around {@link Connection.withAbortSignal}. * * @example * * ```ts * const ctrl = new AbortController(); * setTimeout(() => ctrl.abort(), 10_000); * // 👇 throws if incomplete by the timeout. * await conn.withAbortSignal(ctrl.signal, () => client.workflow.execute(myWorkflow, options)); * ``` * * @returns value returned from `fn` * * @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal */ // FIXME: `abortSignal` should be cumulative, i.e. if a signal is already set, it should be added // to the list of signals, and both the new and existing signal should abort the request. async withAbortSignal(abortSignal, fn) { const cc = this.callContextStorage.getStore(); return await this.callContextStorage.run({ ...cc, abortSignal }, fn); } /** * Set metadata for any service requests executed in `fn`'s scope. * * The provided metadata is merged on top of any existing metadata in current scope, including metadata provided in * {@link ConnectionOptions.metadata}. * * @returns value returned from `fn` * * @example * * ```ts * const workflowHandle = await conn.withMetadata({ apiKey: 'secret' }, () => * conn.withMetadata({ otherKey: 'set' }, () => client.start(options))) * ); * ``` */ async withMetadata(metadata, fn) { const cc = this.callContextStorage.getStore(); return await this.callContextStorage.run({ ...cc, metadata: { ...cc?.metadata, ...metadata }, }, fn); } /** * Set the apiKey for any service requests executed in `fn`'s scope (thus changing the `Authorization` header). * * @returns value returned from `fn` * * @example * * ```ts * const workflowHandle = await conn.withApiKey('secret', () => * conn.withMetadata({ otherKey: 'set' }, () => client.start(options))) * ); * ``` */ async withApiKey(apiKey, fn) { const cc = this.callContextStorage.getStore(); return await this.callContextStorage.run({ ...cc, metadata: { ...cc?.metadata, Authorization: `Bearer ${apiKey}` }, }, fn); } /** * Set the {@link ConnectionOptions.apiKey} for all subsequent requests. A static string or a * callback function may be provided. */ setApiKey(apiKey) { if (typeof apiKey === 'string') { if (apiKey === '') { throw new TypeError('`apiKey` must not be an empty string'); } this.apiKeyFnRef.fn = () => apiKey; } else { this.apiKeyFnRef.fn = apiKey; } } /** * Wait for successful connection to the server. * * @see https://grpc.github.io/grpc/node/grpc.Client.html#waitForReady__anchor */ async untilReady(deadline) { return new Promise((resolve, reject) => { this.client.waitForReady(deadline, (err) => { if (err) { reject(err); } else { resolve(); } }); }); } // This method is async for uniformity with NativeConnection which could be used in the future to power clients /** * Close the underlying gRPC client. * * Make sure to call this method to ensure proper resource cleanup. */ async close() { this.client.close(); this.callContextStorage.disable(); } withNamespaceHeaderInjector(workflowService) { const wrapper = {}; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type for (const [methodName, methodImpl] of Object.entries(workflowService)) { if (typeof methodImpl !== 'function') continue; wrapper[methodName] = (...args) => { const namespace = args[0]?.namespace; if (namespace) { return this.withMetadata({ 'temporal-namespace': namespace }, () => methodImpl.apply(workflowService, args)); } else { return methodImpl.apply(workflowService, args); } }; } return wrapper; } } exports.Connection = Connection; //# sourceMappingURL=connection.js.map