diff --git a/packages/client/src/envconfig.ts b/packages/client/src/envconfig.ts new file mode 100644 index 000000000..6dbdc56f0 --- /dev/null +++ b/packages/client/src/envconfig.ts @@ -0,0 +1,525 @@ +import * as fs from 'fs'; +import { native } from '@temporalio/core-bridge'; +import type { TLSConfig } from '@temporalio/common/lib/internal-non-workflow'; +import type { ConnectionOptions } from './connection'; + +/** + * A data source for configuration, which can be a path to a file, + * the string contents of a file, or raw bytes. + * + * It is defined as a tagged union, to be consumed by other parts of the SDK. + */ +export type DataSource = { path: string } | { data: string | Buffer }; + +interface ClientConfigTLSObject { + disabled?: boolean; + serverName?: string; + serverCaCert?: Record; + clientCert?: Record; + clientKey?: Record; +} + +interface ClientConfigProfileObject { + address?: string; + namespace?: string; + apiKey?: string; + tls?: ClientConfigTLSObject; + grpcMeta?: Record; +} + +function recordToSource(d?: Record): DataSource | undefined { + if (d === undefined) { + return undefined; + } + if (d.data != null) { + return { data: d.data }; + } + if (d.path != null && typeof d.path === 'string') { + return { path: d.path }; + } + return undefined; +} + +function sourceToRecord(source?: DataSource): Record | undefined { + if (source === undefined) { + return undefined; + } + + if ('path' in source) { + return { path: source.path }; + } else { + // source.data + if (Buffer.isBuffer(source.data)) { + return { data: source.data.toString('utf8') }; + } else { + return { data: source.data }; + } + } +} + +function sourceToPathAndData(source?: DataSource): { path?: string; data?: Buffer } { + if (!source) { + return {}; + } + if ('path' in source) { + return { path: source.path }; + } + if (Buffer.isBuffer(source.data)) { + return { data: source.data }; + } + return { data: Buffer.from(source.data, 'utf8') }; +} + +/** + * Synchronously takes a DataSource and resolves it into its raw Buffer representation. + * - If it's a path, it reads the file from disk, blocking the event loop. + * - If it's content, it ensures it's a Buffer. + */ +function readSourceSync(source?: DataSource): Buffer | undefined { + if (source === undefined) { + return undefined; + } + + if ('path' in source) { + try { + return fs.readFileSync(source.path); + } catch (error) { + throw new Error( + `Failed to read file at path "${source.path}": ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + if (Buffer.isBuffer(source.data)) { + return source.data; + } + + // If it's not a buffer, it must be a string. + return Buffer.from(source.data, 'utf8'); +} + +// Utility to convert null to undefined for optional fields +function nullToUndefined(value: T | null | undefined): T | undefined { + return value ?? undefined; +} + +function bridgeToDataSource(bridgeSource?: native.DataSource | null): DataSource | undefined { + if (bridgeSource == null) { + return undefined; + } + if (bridgeSource.path != null) { + return { path: bridgeSource.path }; + } + if (bridgeSource.data != null) { + return { data: bridgeSource.data }; + } + throw new TypeError('Invalid DataSource object received from bridge'); +} + +function clientConfigTlsFromBridge(tls: native.ClientConfigProfile['tls']): ClientConfigTLS { + return new ClientConfigTLS({ + disabled: nullToUndefined(tls?.disabled), + serverName: nullToUndefined(tls?.serverName), + serverRootCaCert: bridgeToDataSource(tls?.serverCaCert), + clientCert: bridgeToDataSource(tls?.clientCert), + clientPrivateKey: bridgeToDataSource(tls?.clientKey), + }); +} + +function clientConfigProfileFromBridge(profile: native.ClientConfigProfile): ClientConfigProfile { + return new ClientConfigProfile({ + address: nullToUndefined(profile.address), + namespace: nullToUndefined(profile.namespace), + apiKey: nullToUndefined(profile.apiKey), + tls: profile.tls ? clientConfigTlsFromBridge(profile.tls) : undefined, + grpcMeta: profile.grpcMeta ?? {}, + }); +} + +function clientConfigFromBridge(bridgeConfig: native.ClientConfig): ClientConfig { + const profiles: Record = {}; + for (const [name, profile] of Object.entries(bridgeConfig.profiles)) { + profiles[name] = clientConfigProfileFromBridge(profile); + } + return new ClientConfig(profiles); +} + +/** + * Options for `ClientConfigTLS` constructor. + */ +export interface ClientConfigTLSOptions { + disabled?: boolean; + serverName?: string; + serverRootCaCert?: DataSource; + clientCert?: DataSource; + clientPrivateKey?: DataSource; +} + +/** + * TLS configuration as specified as part of client configuration. + * + * This class is the TypeScript equivalent of the Python SDK's `ClientConfigTLS`. + * @experimental + */ +export class ClientConfigTLS { + public readonly disabled?: boolean; + public readonly serverName?: string; + public readonly serverRootCaCert?: DataSource; + public readonly clientCert?: DataSource; + public readonly clientPrivateKey?: DataSource; + + constructor(options: ClientConfigTLSOptions) { + // Validate that if client cert is provided, private key is also provided + if ((options.clientCert && !options.clientPrivateKey) || (!options.clientCert && options.clientPrivateKey)) { + throw new Error('Client certificate and private key must both be provided or both be omitted'); + } + + this.disabled = options.disabled; + this.serverName = options.serverName; + this.serverRootCaCert = options.serverRootCaCert; + this.clientCert = options.clientCert; + this.clientPrivateKey = options.clientPrivateKey; + } + + public toObject(): ClientConfigTLSObject { + const obj: ClientConfigTLSObject = {}; + if (this.disabled !== undefined) { + obj.disabled = this.disabled; + } + if (this.serverName) { + obj.serverName = this.serverName; + } + if (this.serverRootCaCert) { + obj.serverCaCert = sourceToRecord(this.serverRootCaCert); + } + if (this.clientCert) { + obj.clientCert = sourceToRecord(this.clientCert); + } + if (this.clientPrivateKey) { + obj.clientKey = sourceToRecord(this.clientPrivateKey); + } + return obj; + } + + public static fromObject(obj: ClientConfigTLSObject): ClientConfigTLS { + return new ClientConfigTLS({ + disabled: obj.disabled, + serverName: obj.serverName, + serverRootCaCert: recordToSource(obj.serverCaCert), + clientCert: recordToSource(obj.clientCert), + clientPrivateKey: recordToSource(obj.clientKey), + }); + } + + /** + * Converts this TLS configuration to a {@link TLSConfig} object for use with connections. + * + * @returns A TLSConfig object with the certificate data loaded from the configured sources, + * or undefined if TLS is disabled. + */ + public toTLSConfig(): TLSConfig | undefined { + if (this.disabled === true) { + return undefined; + } + + const crtBuffer = readSourceSync(this.clientCert); + const keyBuffer = readSourceSync(this.clientPrivateKey); + + const tlsConfig: TLSConfig = { + serverNameOverride: this.serverName, + serverRootCACertificate: readSourceSync(this.serverRootCaCert), + clientCertPair: + crtBuffer && keyBuffer + ? { + crt: crtBuffer, + key: keyBuffer, + } + : undefined, + }; + return tlsConfig; + } +} + +interface ClientConnectConfig { + connectionOptions: ConnectionOptions; + namespace?: string; +} + +export interface ClientConfigProfileOptions { + address?: string; + namespace?: string; + apiKey?: string; + tls?: ClientConfigTLS; + grpcMeta?: Record; +} + +/** + * Options for loading a client configuration profile. + * + * This is the TypeScript equivalent of the options passed to the Python SDK's + * `ClientConfigProfile.load` method. + * + * @experimental + */ +export interface LoadClientProfileOptions { + /** The profile to load from the config. Defaults to "default". */ + profile?: string; + /** + * If present, this is used as the configuration source instead of default + * file locations. This can be a path to the file or the string/byte + * contents of the file. + */ + configSource?: DataSource; + /** + * If true, file loading is disabled. This is only used when `configSource` + * is not present. + */ + disableFile?: boolean; + /** If true, environment variable loading and overriding is disabled. */ + disableEnv?: boolean; + /** If true, will error on unrecognized keys in the TOML file. */ + configFileStrict?: boolean; + /** + * A dictionary of environment variables to use for loading and overrides. + * If not provided, the current process's environment is used. + */ + overrideEnvVars?: Record; +} + +/** + * A client configuration profile loaded from environment variables and/or TOML configuration. + * + * This class represents a single named profile within a client configuration, containing + * all the necessary settings to establish a connection to a Temporal server, including + * address, namespace, authentication, TLS settings, and gRPC metadata. + * + * Configuration sources are loaded and merged in the following precedence (highest to lowest): + * 1. Environment variable overrides + * 2. TOML configuration file settings + * 3. Default values + * + * @example + * ```ts + * // Load the default profile from environment and default config locations + * const profile = ClientConfigProfile.load(); + * const { connectionOptions, namespace } = profile.toClientConnectConfig(); + * + * // Load a specific profile with custom config source + * const customProfile = ClientConfigProfile.load({ + * profile: 'production', + * configSource: { path: '/path/to/config.toml' } + * }); + * ``` + * + * @experimental + */ +export class ClientConfigProfile { + public readonly address?: string; + public readonly namespace?: string; + public readonly apiKey?: string; + public readonly tls?: ClientConfigTLS; + public readonly grpcMeta: Record; + + public constructor(options: ClientConfigProfileOptions) { + this.address = options.address; + this.namespace = options.namespace; + this.apiKey = options.apiKey; + this.tls = options.tls; + this.grpcMeta = options.grpcMeta ?? {}; + } + + /** + * Loads a single client profile from environment variables and/or a TOML file. + */ + public static load(options: LoadClientProfileOptions = {}): ClientConfigProfile { + const { path, data } = sourceToPathAndData(options.configSource); + const bridgeProfile = native.loadClientConnectConfig( + options.profile ?? null, + path ?? null, + data ?? null, + options.disableFile ?? false, + options.disableEnv ?? false, + options.configFileStrict ?? false, + options.overrideEnvVars ?? null + ); + return clientConfigProfileFromBridge(bridgeProfile); + } + + public toObject(): ClientConfigProfileObject { + return { + address: this.address, + namespace: this.namespace, + apiKey: this.apiKey, + tls: this.tls?.toObject(), + grpcMeta: this.grpcMeta, + }; + } + + public static fromObject(obj: ClientConfigProfileObject): ClientConfigProfile { + return new ClientConfigProfile({ + address: obj.address, + namespace: obj.namespace, + apiKey: obj.apiKey, + tls: obj.tls ? ClientConfigTLS.fromObject(obj.tls) : undefined, + grpcMeta: obj.grpcMeta, + }); + } + + /** + * Converts this configuration profile to connection options for creating a Temporal client. + * + * This method validates that required settings are present and transforms the profile + * into the format expected by {@link Connection.connect} and related client constructors. + * + * @returns An object containing connection options and namespace settings + * @throws {Error} If the profile does not contain a required address field + * + * @example + * ```ts + * const profile = ClientConfigProfile.load({ profile: 'production' }); + * const { connectionOptions, namespace } = profile.toClientConnectConfig(); + * + * const connection = await Connection.connect(connectionOptions); + * const client = new Client({ connection, namespace }); + * ``` + */ + public toClientConnectConfig(): ClientConnectConfig { + // Basic validation + if (!this.address) { + throw new Error('Address is required for client connection'); + } + + const tlsConfig = this.tls?.toTLSConfig(); + + return { + namespace: this.namespace, + connectionOptions: { + address: this.address, + apiKey: this.apiKey, + tls: tlsConfig, + metadata: this.grpcMeta, + }, + }; + } +} + +/** + * Options for loading client configuration. + * @experimental + */ +export interface LoadClientConfigOptions { + /** + * If present, this is used as the configuration source instead of default + * file locations. This can be a path to the file or the string/byte + * contents of the file. + */ + configSource?: DataSource; + /** If true, will error on unrecognized keys in the TOML file. */ + configFileStrict?: boolean; + /** + * The environment variables to use for locating the + * default config file. If not provided, the current process's + * environment is used to check for ``TEMPORAL_CONFIG_FILE``. + */ + overrideEnvVars?: Record; +} + +interface ClientConfigObject { + profiles: Record; +} + +/** + * Client configuration container that manages multiple named profiles. + * + * This class loads and manages client configuration profiles from TOML files and + * environment variables, providing a centralized way to handle multiple Temporal + * server connection configurations. Each profile contains settings for server + * address, authentication, TLS, and other connection parameters. + * + * The configuration supports: + * - Loading from TOML configuration files + * - Environment variable-based profile discovery via `TEMPORAL_CONFIG_FILE` + * - Multiple named profiles within a single configuration + * - JSON serialization for configuration persistence + * - Strict mode validation for configuration files + * + * This is the TypeScript equivalent of the Python SDK's `ClientConfig`. + * + * @example + * ```ts + * // Load all profiles from default config locations + * const config = ClientConfig.load(); + * + * // Access a specific profile + * const prodProfile = config.profiles['production']; + * if (prodProfile) { + * const { connectionOptions, namespace } = prodProfile.toClientConnectConfig(); + * } + * + * // Load from a specific TOML file with strict validation + * const strictConfig = ClientConfig.load({ + * configSource: { path: './temporal-config.toml' }, + * configFileStrict: true + * }); + * + * // Convenience method to load a single profile directly + * const connectConfig = ClientConfig.loadClientConnectConfig({ + * profile: 'development' + * }); + * ``` + * + * @experimental + */ +export class ClientConfig { + /** Map of profile name to its corresponding ClientConfigProfile. */ + public readonly profiles: Record; + + /** + * Load all client profiles from given sources. + * + * This does not apply environment variable overrides to the profiles, it + * only uses an environment variable to find the default config file path + * (`TEMPORAL_CONFIG_FILE`). To get a single profile with environment variables + * applied, use {@link ClientConfigProfile.load}. + */ + public static load(options: LoadClientConfigOptions = {}): ClientConfig { + const { path, data } = sourceToPathAndData(options.configSource); + const bridgeConfig = native.loadClientConfig( + path ?? null, + data ?? null, + options.configFileStrict ?? false, + options.overrideEnvVars ?? null + ); + return clientConfigFromBridge(bridgeConfig); + } + + /** + * A convenience function that loads a single client profile and converts it to connect options. + * + * @param options Options for loading the client profile. + * @returns A {@link ConnectionOptions} object that can be used to connect a client. + */ + public static loadClientConnectConfig(options: LoadClientProfileOptions = {}): ClientConnectConfig { + return ClientConfigProfile.load(options).toClientConnectConfig(); + } + + public constructor(profiles: Record) { + this.profiles = profiles; + } + + public toObject(): ClientConfigObject { + const profiles: Record = {}; + for (const [name, profile] of Object.entries(this.profiles)) { + profiles[name] = profile.toObject(); + } + return { profiles }; + } + + public static fromObject(obj: ClientConfigObject): ClientConfig { + const profiles: Record = {}; + for (const [name, profile] of Object.entries(obj.profiles)) { + profiles[name] = ClientConfigProfile.fromObject(profile); + } + return new ClientConfig(profiles); + } +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 328970e2d..6fe186ac7 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -4,6 +4,6 @@ "outDir": "./lib", "rootDir": "./src" }, - "references": [{ "path": "../common" }], + "references": [{ "path": "../common" }, { "path": "../core-bridge" }], "include": ["./src/**/*.ts"] } diff --git a/packages/core-bridge/Cargo.lock b/packages/core-bridge/Cargo.lock index d887f2eff..6a3a7d355 100644 --- a/packages/core-bridge/Cargo.lock +++ b/packages/core-bridge/Cargo.lock @@ -17,17 +17,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -170,15 +159,6 @@ version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bridge-macros" version = "0.1.0" @@ -195,12 +175,6 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.10.1" @@ -209,21 +183,11 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +checksum = "bea8dcd42434048e4f7a304411d9273a411f647446c1234a65ce0554923f4cff" dependencies = [ - "cc", - "pkg-config", + "libbz2-rs-sys", ] [[package]] @@ -259,22 +223,6 @@ dependencies = [ "serde", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "convert_case" version = "0.6.0" @@ -300,30 +248,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - [[package]] name = "crc32fast" version = "1.5.0" @@ -357,16 +281,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "darling" version = "0.20.11" @@ -416,21 +330,6 @@ dependencies = [ "parking_lot_core", ] -[[package]] -name = "deflate64" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" - -[[package]] -name = "deranged" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", -] - [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -495,14 +394,24 @@ dependencies = [ ] [[package]] -name = "digest" -version = "0.10.7" +name = "dirs" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "block-buffer", - "crypto-common", - "subtle", + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.60.2", ] [[package]] @@ -617,6 +526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -753,16 +663,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "gethostname" version = "1.0.2" @@ -808,9 +708,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "governor" -version = "0.8.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be93b4ec2e4710b04d9264c0c7350cdd62a8c20e5e4ac732552ebb8f0debe8eb" +checksum = "444405bbb1a762387aa22dd569429533b54a1d8759d35d3b64cb39b0293eaa19" dependencies = [ "cfg-if", "dashmap", @@ -818,7 +718,7 @@ dependencies = [ "futures-timer", "futures-util", "getrandom 0.3.3", - "no-std-compat", + "hashbrown 0.15.5", "nonzero_ext", "parking_lot", "portable-atomic", @@ -871,15 +771,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "1.3.1" @@ -1126,15 +1017,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "instant" version = "0.1.13" @@ -1221,6 +1103,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.175" @@ -1248,6 +1136,15 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linkme" version = "0.3.33" @@ -1298,9 +1195,9 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "lru" -version = "0.13.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" +checksum = "bfe949189f46fabb938b3a9a0be30fdd93fd8a09260da863399a8cf3db756ec8" dependencies = [ "hashbrown 0.15.5", ] @@ -1311,27 +1208,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lzma-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "matchers" version = "0.1.0" @@ -1439,12 +1315,6 @@ dependencies = [ "syn", ] -[[package]] -name = "no-std-compat" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" - [[package]] name = "nonzero_ext" version = "0.3.0" @@ -1470,12 +1340,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - [[package]] name = "num-traits" version = "0.2.19" @@ -1485,6 +1349,25 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -1596,6 +1479,12 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "os_pipe" version = "1.2.2" @@ -1635,16 +1524,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] - [[package]] name = "percent-encoding" version = "2.3.1" @@ -1732,12 +1611,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2087,6 +1960,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.15", +] + [[package]] name = "regex" version = "1.11.1" @@ -2358,18 +2242,28 @@ checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.224" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "6aaeb1e94f53b16384af593c71e20b095e958dab1d26939c1b70645c5cfbcc0b" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.224" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f39390fa6346e24defbcdd3d9544ba8a19985d0af74df8501fbfe9a64341ab" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.224" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "87ff78ab5e8561c9a675bfc1785cb07ae721f0ee53329a595cefd8c04c2ac4e0" dependencies = [ "proc-macro2", "quote", @@ -2388,6 +2282,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2789234a13a53fc4be1b51ea1bab45a3c338bdb884862a257d10e5a74ae009e6" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2400,17 +2303,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -2548,14 +2440,15 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.33.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +checksum = "07cec4dc2d2e357ca1e610cfb07de2fa7a10fc3e9fe89f72545f3d244ea87753" dependencies = [ - "core-foundation-sys", "libc", "memchr", "ntapi", + "objc2-core-foundation", + "objc2-io-kit", "windows", ] @@ -2679,11 +2572,14 @@ dependencies = [ "async-trait", "derive_builder", "derive_more", + "dirs", "opentelemetry 0.30.0", "prost", + "serde", "serde_json", "temporal-sdk-core-protos", "thiserror 2.0.15", + "toml", "tonic", "tracing", "tracing-core", @@ -2729,6 +2625,7 @@ dependencies = [ "serde_json", "temporal-client", "temporal-sdk-core", + "temporal-sdk-core-api", "thiserror 2.0.15", "tokio", "tokio-stream", @@ -2792,25 +2689,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" - [[package]] name = "tinystr" version = "0.8.1" @@ -2901,6 +2779,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a4cf385da23d1d53bc15cdfa5c2109e93d8d362393c801e87da2f72f0e201" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a197c0ec7d131bfc6f7e82c8442ba1595aeab35da7adbf05b6b73cd06a16b6be" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" + [[package]] name = "tonic" version = "0.13.1" @@ -3056,12 +2973,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" -[[package]] -name = "typenum" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" - [[package]] name = "typetag" version = "0.2.20" @@ -3302,31 +3213,55 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.57.0" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ "windows-core", - "windows-targets 0.52.6", ] [[package]] name = "windows-core" -version = "0.57.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", + "windows-link", "windows-result", - "windows-targets 0.52.6", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", ] [[package]] name = "windows-implement" -version = "0.57.0" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", @@ -3335,9 +3270,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.57.0" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", @@ -3350,13 +3285,32 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" -version = "0.1.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-targets 0.52.6", + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", ] [[package]] @@ -3419,6 +3373,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3515,6 +3478,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -3540,15 +3509,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - [[package]] name = "yoke" version = "0.8.0" @@ -3619,20 +3579,6 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] [[package]] name = "zerotrie" @@ -3669,34 +3615,26 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ - "aes", "arbitrary", "bzip2", - "constant_time_eq", "crc32fast", - "crossbeam-utils", - "deflate64", - "displaydoc", "flate2", - "getrandom 0.3.3", - "hmac", "indexmap", - "lzma-rs", "memchr", - "pbkdf2", - "sha1", - "thiserror 2.0.15", - "time", - "xz2", - "zeroize", "zopfli", "zstd", ] +[[package]] +name = "zlib-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" + [[package]] name = "zopfli" version = "0.8.2" diff --git a/packages/core-bridge/Cargo.toml b/packages/core-bridge/Cargo.toml index 1831c7b59..723b6f53f 100644 --- a/packages/core-bridge/Cargo.toml +++ b/packages/core-bridge/Cargo.toml @@ -38,6 +38,7 @@ serde_json = "1.0" temporal-sdk-core = { version = "*", path = "./sdk-core/core", features = [ "ephemeral-server", ] } +temporal-sdk-core-api = { path = "./sdk-core/core-api", features = ["envconfig"] } temporal-client = { version = "*", path = "./sdk-core/client" } thiserror = "2" tokio = "1.13" diff --git a/packages/core-bridge/sdk-core b/packages/core-bridge/sdk-core index 871b320c8..7ac63c541 160000 --- a/packages/core-bridge/sdk-core +++ b/packages/core-bridge/sdk-core @@ -1 +1 @@ -Subproject commit 871b320c8f51d52cb69fcc31f9c4dcd47b9f3961 +Subproject commit 7ac63c5417445fe3ae53193ea6a011e8dfe11d15 diff --git a/packages/core-bridge/src/client.rs b/packages/core-bridge/src/client.rs index 401dc4ec2..baf33b26f 100644 --- a/packages/core-bridge/src/client.rs +++ b/packages/core-bridge/src/client.rs @@ -71,6 +71,10 @@ pub fn client_new( message: e.to_string(), field: None, })?, + Err(ClientInitError::InvalidHeaders(e)) => Err(BridgeError::TypeError { + message: e.to_string(), + field: None, + })?, }; Ok(OpaqueOutboundHandle::new(Client { diff --git a/packages/core-bridge/src/envconfig.rs b/packages/core-bridge/src/envconfig.rs new file mode 100644 index 000000000..2aca83eb7 --- /dev/null +++ b/packages/core-bridge/src/envconfig.rs @@ -0,0 +1,196 @@ +use std::collections::HashMap; + +use bridge_macros::{TryIntoJs, js_function}; +use neon::prelude::*; +use serde::Serialize; +use temporal_sdk_core_api::envconfig::{ + self, ClientConfig as CoreClientConfig, ClientConfigCodec as CoreClientConfigCodec, + ClientConfigProfile as CoreClientConfigProfile, ClientConfigTLS as CoreClientConfigTLS, + DataSource as CoreDataSource, +}; + +use crate::helpers::{BridgeError, BridgeResult}; + +pub fn init(cx: &mut ModuleContext) -> NeonResult<()> { + cx.export_function("loadClientConfig", load_client_config)?; + cx.export_function("loadClientConnectConfig", load_client_connect_config)?; + Ok(()) +} + +impl From for BridgeError { + fn from(e: envconfig::ConfigError) -> Self { + Self::TypeError { + field: None, + message: e.to_string(), + } + } +} + +#[derive(TryIntoJs, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientConfig { + profiles: HashMap, +} + +impl From for ClientConfig { + fn from(c: CoreClientConfig) -> Self { + Self { + profiles: c.profiles.into_iter().map(|(k, v)| (k, v.into())).collect(), + } + } +} + +#[derive(TryIntoJs, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientConfigProfile { + address: Option, + namespace: Option, + api_key: Option, + tls: Option, + codec: Option, + grpc_meta: HashMap, +} + +impl From for ClientConfigProfile { + fn from(c: CoreClientConfigProfile) -> Self { + Self { + address: c.address, + namespace: c.namespace, + api_key: c.api_key, + tls: c.tls.map(Into::into), + codec: c.codec.map(Into::into), + grpc_meta: c.grpc_meta, + } + } +} + +#[derive(TryIntoJs, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientConfigTls { + disabled: Option, + client_cert: Option, + client_key: Option, + server_ca_cert: Option, + server_name: Option, + disable_host_verification: bool, +} + +impl From for ClientConfigTls { + fn from(c: CoreClientConfigTLS) -> Self { + Self { + disabled: c.disabled, + client_cert: c.client_cert.map(Into::into), + client_key: c.client_key.map(Into::into), + server_ca_cert: c.server_ca_cert.map(Into::into), + server_name: c.server_name, + disable_host_verification: c.disable_host_verification, + } + } +} + +#[derive(TryIntoJs, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientConfigCodec { + endpoint: Option, + auth: Option, +} + +impl From for ClientConfigCodec { + fn from(c: CoreClientConfigCodec) -> Self { + Self { + endpoint: c.endpoint, + auth: c.auth, + } + } +} + +#[derive(TryIntoJs, Serialize, Default)] +#[serde(rename_all = "camelCase")] +struct DataSource { + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option>, +} + +impl From for DataSource { + fn from(c: CoreDataSource) -> Self { + match c { + CoreDataSource::Path(p) => Self { + path: Some(p), + data: None, + }, + CoreDataSource::Data(d) => Self { + path: None, + data: Some(d), + }, + } + } +} + +// Bridge functions //////////////////////////////////////////////////////////////////////////////// + +/// Load all client profiles from given sources +#[js_function] +fn load_client_config( + path: Option, + data: Option>, + config_file_strict: bool, + env_vars: Option>, +) -> BridgeResult { + let config_source = match (path, data) { + (Some(p), None) => Some(CoreDataSource::Path(p)), + (None, Some(d)) => Some(CoreDataSource::Data(d)), + (None, None) => None, + (Some(_), Some(_)) => { + return Err(BridgeError::TypeError { + field: None, + message: "Cannot specify both path and data for config source".to_string(), + }); + } + }; + + let options = envconfig::LoadClientConfigOptions { + config_source, + config_file_strict, + }; + let core_config = envconfig::load_client_config(options, env_vars.as_ref())?; + + Ok(core_config.into()) +} + +/// Load a single client profile +#[js_function] +fn load_client_connect_config( + profile: Option, + path: Option, + data: Option>, + disable_file: bool, + disable_env: bool, + config_file_strict: bool, + env_vars: Option>, +) -> BridgeResult { + let config_source = match (path, data) { + (Some(p), None) => Some(CoreDataSource::Path(p)), + (None, Some(d)) => Some(CoreDataSource::Data(d)), + (None, None) => None, + (Some(_), Some(_)) => { + return Err(BridgeError::TypeError { + field: None, + message: "Cannot specify both path and data for config source".to_string(), + }); + } + }; + + let options = envconfig::LoadClientConfigProfileOptions { + config_source, + config_file_profile: profile, + config_file_strict, + disable_file, + disable_env, + }; + + let profile = envconfig::load_client_config_profile(options, env_vars.as_ref())?; + + Ok(profile.into()) +} diff --git a/packages/core-bridge/src/helpers/try_into_js.rs b/packages/core-bridge/src/helpers/try_into_js.rs index fb0e3ca04..9dbd1e8f0 100644 --- a/packages/core-bridge/src/helpers/try_into_js.rs +++ b/packages/core-bridge/src/helpers/try_into_js.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -8,7 +9,8 @@ use neon::{ prelude::Context, result::JsResult, types::{ - JsArray, JsBigInt, JsBoolean, JsBuffer, JsNumber, JsString, JsUndefined, JsValue, Value, + JsArray, JsBigInt, JsBoolean, JsBuffer, JsNumber, JsObject, JsString, JsUndefined, JsValue, + Value, }, }; @@ -64,6 +66,19 @@ impl TryIntoJs for Vec { } } +impl TryIntoJs for HashMap { + type Output = JsObject; + + fn try_into_js<'a>(self, cx: &mut impl Context<'a>) -> JsResult<'a, Self::Output> { + let obj = cx.empty_object(); + for (k, v) in self { + let val = v.try_into_js(cx)?; + obj.set(cx, k.as_str(), val)?; + } + Ok(obj) + } +} + impl TryIntoJs for Vec { type Output = JsBuffer; fn try_into_js<'a>(self, cx: &mut impl Context<'a>) -> JsResult<'a, JsBuffer> { diff --git a/packages/core-bridge/src/lib.rs b/packages/core-bridge/src/lib.rs index 5c7b46404..9e5d47a08 100644 --- a/packages/core-bridge/src/lib.rs +++ b/packages/core-bridge/src/lib.rs @@ -16,6 +16,7 @@ pub mod helpers; mod client; +mod envconfig; mod logs; mod metrics; mod runtime; @@ -25,6 +26,7 @@ mod worker; #[neon::main] fn main(mut cx: neon::prelude::ModuleContext) -> neon::prelude::NeonResult<()> { client::init(&mut cx)?; + envconfig::init(&mut cx)?; logs::init(&mut cx)?; metrics::init(&mut cx)?; runtime::init(&mut cx)?; diff --git a/packages/core-bridge/ts/native.ts b/packages/core-bridge/ts/native.ts index 6bd4fa002..58aaee773 100644 --- a/packages/core-bridge/ts/native.ts +++ b/packages/core-bridge/ts/native.ts @@ -500,3 +500,56 @@ export declare function setMetricGaugeF64Value( value: number, attrs: JsonString ): void; + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Environment Configuration +//////////////////////////////////////////////////////////////////////////////////////////////////// + +export declare function loadClientConfig( + path: Option, + data: Option, + configFileStrict: boolean, + envVars: Option> +): ClientConfig; + +export declare function loadClientConnectConfig( + profile: Option, + path: Option, + data: Option, + disableFile: boolean, + disableEnv: boolean, + configFileStrict: boolean, + envVars: Option> +): ClientConfigProfile; + +export interface DataSource { + path: Option; + data: Option; +} + +export interface ClientConfig { + profiles: Record; +} + +export interface ClientConfigProfile { + address: Option; + namespace: Option; + apiKey: Option; + tls: Option; + codec: Option; + grpcMeta: Record; +} + +export interface ClientConfigTLS { + disabled: Option; + clientCert: Option; + clientKey: Option; + serverCaCert: Option; + serverName: Option; + disableHostVerification: boolean; +} + +export interface ClientConfigCodec { + endpoint: Option; + auth: Option; +} diff --git a/packages/test/src/test-envconfig.ts b/packages/test/src/test-envconfig.ts new file mode 100644 index 000000000..d0b9213af --- /dev/null +++ b/packages/test/src/test-envconfig.ts @@ -0,0 +1,932 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import test from 'ava'; +import dedent from 'dedent'; +import { ClientConfig, ClientConfigProfile, ClientConfigTLS, type DataSource } from '@temporalio/client/lib/envconfig'; +import { Connection, Client } from '@temporalio/client'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; + +// Focused TOML fixtures +const TOML_CONFIG_BASE = dedent` + [profile.default] + address = "default-address" + namespace = "default-namespace" + + [profile.custom] + address = "custom-address" + namespace = "custom-namespace" + api_key = "custom-api-key" + [profile.custom.tls] + server_name = "custom-server-name" + [profile.custom.grpc_meta] + custom-header = "custom-value" +`; + +const TOML_CONFIG_STRICT_FAIL = dedent` + [profile.default] + address = "default-address" + unrecognized_field = "should-fail" +`; + +const TOML_CONFIG_TLS_DETAILED = dedent` + [profile.tls_disabled] + address = "localhost:1234" + [profile.tls_disabled.tls] + disabled = true + server_name = "should-be-ignored" + + [profile.tls_with_certs] + address = "localhost:5678" + [profile.tls_with_certs.tls] + server_name = "custom-server" + server_ca_cert_data = "ca-pem-data" + client_cert_data = "client-crt-data" + client_key_data = "client-key-data" +`; + +function withTempFile(content: string, fn: (filepath: string) => void): void { + const tempDir = os.tmpdir(); + const filepath = path.join(tempDir, `temporal-test-config-${Date.now()}-${Math.random()}.toml`); + fs.writeFileSync(filepath, content); + try { + fn(filepath); + } finally { + fs.unlinkSync(filepath); + } +} + +function pathSource(p: string): DataSource { + return { path: p }; +} +function dataSource(d: Buffer | string): DataSource { + return { data: typeof d === 'string' ? Buffer.from(d) : d }; +} + +// ============================================================================= +// 🔧 PROFILE LOADING +// ============================================================================= + +test('Load default profile from file', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const profile = ClientConfigProfile.load({ configSource: pathSource(filepath) }); + t.is(profile.address, 'default-address'); + t.is(profile.namespace, 'default-namespace'); + t.is(profile.apiKey, undefined); + t.is(profile.tls, undefined); + t.deepEqual(profile.grpcMeta, {}); + + const { connectionOptions, namespace } = profile.toClientConnectConfig(); + t.is(connectionOptions.address, 'default-address'); + t.is(namespace, 'default-namespace'); + t.is(connectionOptions.apiKey, undefined); + t.is(connectionOptions.tls, undefined); + t.deepEqual(connectionOptions.metadata, {}); + }); +}); + +test('Load custom profile from file', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const profile = ClientConfigProfile.load({ profile: 'custom', configSource: pathSource(filepath) }); + t.is(profile.address, 'custom-address'); + t.is(profile.namespace, 'custom-namespace'); + t.is(profile.apiKey, 'custom-api-key'); + t.truthy(profile.tls); + t.is(profile.tls?.serverName, 'custom-server-name'); + t.is(profile.grpcMeta['custom-header'], 'custom-value'); + + const { connectionOptions, namespace } = profile.toClientConnectConfig(); + t.is(connectionOptions.address, 'custom-address'); + t.is(namespace, 'custom-namespace'); + t.is(connectionOptions.apiKey, 'custom-api-key'); + const tls1 = connectionOptions.tls; + if (tls1 && typeof tls1 === 'object') { + t.is(tls1.serverNameOverride, 'custom-server-name'); + } else { + t.fail('expected TLS config object'); + } + t.is(connectionOptions.metadata?.['custom-header'], 'custom-value'); + }); +}); + +test('Load default profile from data', (t) => { + const profile = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)) }); + t.is(profile.address, 'default-address'); + t.is(profile.namespace, 'default-namespace'); + t.is(profile.tls, undefined); +}); + +test('Load custom profile from data', (t) => { + const profile = ClientConfigProfile.load({ + profile: 'custom', + configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)), + }); + t.is(profile.address, 'custom-address'); + t.is(profile.namespace, 'custom-namespace'); + t.is(profile.apiKey, 'custom-api-key'); + t.is(profile.tls?.serverName, 'custom-server-name'); +}); + +test('Load profile from data with env overrides', (t) => { + const env = { + TEMPORAL_ADDRESS: 'env-address', + TEMPORAL_NAMESPACE: 'env-namespace', + }; + const profile = ClientConfigProfile.load({ + configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)), + overrideEnvVars: env, + }); + t.is(profile.address, 'env-address'); + t.is(profile.namespace, 'env-namespace'); +}); + +test('Load custom profile with env overrides', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const env = { + TEMPORAL_ADDRESS: 'env-address', + TEMPORAL_NAMESPACE: 'env-namespace', + TEMPORAL_API_KEY: 'env-api-key', + TEMPORAL_TLS: 'true', + TEMPORAL_TLS_SERVER_NAME: 'env-server-name', + TEMPORAL_GRPC_META_CUSTOM_HEADER: 'env-value', + TEMPORAL_GRPC_META_ANOTHER_HEADER: 'another-value', + }; + const profile = ClientConfigProfile.load({ + profile: 'custom', + configSource: pathSource(filepath), + overrideEnvVars: env, + }); + t.is(profile.address, 'env-address'); + t.is(profile.namespace, 'env-namespace'); + t.is(profile.apiKey, 'env-api-key'); + t.truthy(profile.tls); + t.is(profile.tls?.serverName, 'env-server-name'); + t.is(profile.grpcMeta['custom-header'], 'env-value'); + t.is(profile.grpcMeta['another-header'], 'another-value'); + }); +}); + +test('Load profiles with string content', (t) => { + const stringContent = TOML_CONFIG_BASE; + const profile = ClientConfigProfile.load({ configSource: dataSource(stringContent) }); + t.is(profile.address, 'default-address'); + t.is(profile.namespace, 'default-namespace'); + + // Test with custom profile from string + const profileCustom = ClientConfigProfile.load({ + profile: 'custom', + configSource: dataSource(stringContent), + }); + t.is(profileCustom.address, 'custom-address'); + t.is(profileCustom.apiKey, 'custom-api-key'); +}); + +// ============================================================================= +// 🌍 ENVIRONMENT VARIABLES +// ============================================================================= + +test('Load profile with grpc metadata env overrides', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.grpc_meta] + original-header = "original-value" + `; + const env = { + TEMPORAL_GRPC_META_NEW_HEADER: 'new-value', + TEMPORAL_GRPC_META_OVERRIDE_HEADER: 'overridden-value', + }; + const profile = ClientConfigProfile.load({ + configSource: dataSource(Buffer.from(toml)), + overrideEnvVars: env, + }); + t.is(profile.grpcMeta['original-header'], 'original-value'); + t.is(profile.grpcMeta['new-header'], 'new-value'); + t.is(profile.grpcMeta['override-header'], 'overridden-value'); +}); + +test('gRPC metadata normalization from TOML', (t) => { + const toml = dedent` + [profile.foo] + address = "addr" + [profile.foo.grpc_meta] + sOme-hEader_key = "some-value" + `; + const conf = ClientConfig.load({ configSource: dataSource(Buffer.from(toml)) }); + const prof = conf.profiles['foo']; + t.truthy(prof); + t.is(prof.grpcMeta['some-header-key'], 'some-value'); +}); + +test('gRPC metadata deletion via empty env value', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.grpc_meta] + some-header = "keep" + remove-me = "to-be-removed" + `; + const env = { + TEMPORAL_GRPC_META_REMOVE_ME: '', + TEMPORAL_GRPC_META_NEW_HEADER: 'added', + }; + const prof = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)), overrideEnvVars: env }); + t.is(prof.grpcMeta['some-header'], 'keep'); + t.is(prof.grpcMeta['new-header'], 'added'); + t.false(Object.prototype.hasOwnProperty.call(prof.grpcMeta, 'remove-me')); +}); + +test('Load profile with disable env flag', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const env = { TEMPORAL_ADDRESS: 'env-address' }; + const profile = ClientConfigProfile.load({ + configSource: pathSource(filepath), + overrideEnvVars: env, + disableEnv: true, + }); + t.is(profile.address, 'default-address'); + }); +}); + +// ============================================================================= +// 🎛️ CONTROL FLAGS +// ============================================================================= + +test('Load profile with disabled file flag', (t) => { + const env = { TEMPORAL_ADDRESS: 'env-address', TEMPORAL_NAMESPACE: 'env-namespace' }; + const profile = ClientConfigProfile.load({ + configSource: pathSource('/non_existent_file.toml'), + disableFile: true, + overrideEnvVars: env, + }); + t.is(profile.address, 'env-address'); + t.is(profile.namespace, 'env-namespace'); +}); + +test('Load profiles without profile-level env overrides', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const env = { TEMPORAL_ADDRESS: 'should-be-ignored' }; + // ClientConfig.load doesn't apply env overrides, so we test it loads correctly + const conf = ClientConfig.load({ + configSource: pathSource(filepath), + overrideEnvVars: env, + }); + t.is(conf.profiles['default'].address, 'default-address'); + + // Test that profile-level loading with disableEnv ignores environment + const profile = ClientConfigProfile.load({ + configSource: pathSource(filepath), + overrideEnvVars: env, + disableEnv: true, + }); + t.is(profile.address, 'default-address'); + }); +}); + +test('Cannot disable both file and env override flags', (t) => { + const err = t.throws(() => + ClientConfigProfile.load({ + configSource: pathSource('/non_existent_file.toml'), + disableFile: true, + disableEnv: true, + }) + ); + t.truthy(err); + t.true(String(err?.message).includes('Cannot disable both')); +}); + +// ============================================================================= +// 📁 CONFIG DISCOVERY +// ============================================================================= + +test('Load all profiles from file', (t) => { + const conf = ClientConfig.load({ configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)) }); + t.truthy(conf.profiles['default']); + t.truthy(conf.profiles['custom']); + t.is(conf.profiles['default'].address, 'default-address'); + t.is(conf.profiles['custom'].apiKey, 'custom-api-key'); +}); + +test('Load all profiles from data', (t) => { + const configData = dedent` + [profile.alpha] + address = "alpha-address" + namespace = "alpha-namespace" + + [profile.beta] + address = "beta-address" + api_key = "beta-key" + `; + const conf = ClientConfig.load({ configSource: dataSource(Buffer.from(configData)) }); + t.truthy(conf.profiles['alpha']); + t.truthy(conf.profiles['beta']); + t.is(conf.profiles['alpha'].address, 'alpha-address'); + t.is(conf.profiles['beta'].apiKey, 'beta-key'); +}); + +test('Load profiles from non-existent file', (t) => { + const conf = ClientConfig.load({ + configSource: pathSource('/non_existent_file.toml'), + }); + t.deepEqual(conf.profiles, {}); +}); + +test('Load all profiles with overridden file path', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const conf = ClientConfig.load({ overrideEnvVars: { TEMPORAL_CONFIG_FILE: filepath } }); + t.truthy(conf.profiles['default']); + t.is(conf.profiles['default'].address, 'default-address'); + }); +}); + +test('Default profile not found returns empty profile', (t) => { + const toml = dedent` + [profile.existing] + address = "my-address" + `; + const prof = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)) }); + t.is(prof.address, undefined); + t.is(prof.namespace, undefined); + t.is(prof.apiKey, undefined); + t.deepEqual(prof.grpcMeta, {}); + t.is(prof.tls, undefined); +}); + +// ============================================================================= +// 🔐 TLS CONFIGURATION +// ============================================================================= + +test('Load profile with api key (enables TLS)', (t) => { + const toml = dedent` + [profile.default] + address = "my-address" + api_key = "my-api-key" + `; + const profile = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)) }); + t.truthy(profile.tls); + t.false(!!profile.tls?.disabled); + const { connectionOptions } = profile.toClientConnectConfig(); + t.truthy(connectionOptions.tls); +}); + +test('Load profile with TLS options', (t) => { + const configSource = dataSource(Buffer.from(TOML_CONFIG_TLS_DETAILED)); + + const profileDisabled = ClientConfigProfile.load({ configSource, profile: 'tls_disabled' }); + t.truthy(profileDisabled.tls); + t.true(!!profileDisabled.tls?.disabled); + const { connectionOptions: connOptsDisabled } = profileDisabled.toClientConnectConfig(); + t.is(connOptsDisabled.tls, undefined); + + const profileCerts = ClientConfigProfile.load({ configSource, profile: 'tls_with_certs' }); + t.truthy(profileCerts.tls); + t.is(profileCerts.tls?.serverName, 'custom-server'); + t.deepEqual(profileCerts.tls?.serverRootCaCert, dataSource('ca-pem-data')); + t.deepEqual(profileCerts.tls?.clientCert, dataSource('client-crt-data')); + t.deepEqual(profileCerts.tls?.clientPrivateKey, dataSource('client-key-data')); + + const { connectionOptions: connOptsCerts } = profileCerts.toClientConnectConfig(); + const tls2 = connOptsCerts.tls; + if (tls2 && typeof tls2 === 'object') { + t.is(tls2.serverNameOverride, 'custom-server'); + t.deepEqual(tls2.serverRootCACertificate, Buffer.from('ca-pem-data')); + t.deepEqual(tls2.clientCertPair?.crt, Buffer.from('client-crt-data')); + t.deepEqual(tls2.clientCertPair?.key, Buffer.from('client-key-data')); + } else { + t.fail('expected TLS config object'); + } +}); + +test('Load profile with TLS options as file paths', (t) => { + withTempFile('ca-pem-data', (caPath) => { + withTempFile('client-crt-data', (certPath) => { + withTempFile('client-key-data', (keyPath) => { + const tomlConfig = dedent` + [profile.default] + address = "localhost:5678" + [profile.default.tls] + server_name = "custom-server" + server_ca_cert_path = "${caPath}" + client_cert_path = "${certPath}" + client_key_path = "${keyPath}" + `; + const profile = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(tomlConfig)) }); + t.truthy(profile.tls); + t.is(profile.tls?.serverName, 'custom-server'); + t.deepEqual(profile.tls?.serverRootCaCert, { path: caPath }); + t.deepEqual(profile.tls?.clientCert, { path: certPath }); + t.deepEqual(profile.tls?.clientPrivateKey, { path: keyPath }); + + const { connectionOptions: connOpts } = profile.toClientConnectConfig(); + const tls3 = connOpts.tls; + if (tls3 && typeof tls3 === 'object') { + t.is(tls3.serverNameOverride, 'custom-server'); + t.deepEqual(tls3.serverRootCACertificate, Buffer.from('ca-pem-data')); + t.deepEqual(tls3.clientCertPair?.crt, Buffer.from('client-crt-data')); + t.deepEqual(tls3.clientCertPair?.key, Buffer.from('client-key-data')); + } else { + t.fail('expected TLS config object'); + } + }); + }); + }); +}); + +test('Load profile with conflicting cert source fails', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.tls] + client_cert_path = "some-path" + client_cert_data = "some-data" + `; + const err = t.throws(() => ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)) })); + t.truthy(err); + t.true(String(err?.message).includes('Cannot specify both')); +}); + +test('TLS conflict across sources: path in TOML, data in env should error', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.tls] + client_cert_path = "some-path" + `; + const env = { TEMPORAL_TLS_CLIENT_CERT_DATA: 'some-data' }; + const err = t.throws(() => + ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)), overrideEnvVars: env }) + ); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('path') + ); +}); + +test('TLS conflict across sources: data in TOML, path in env should error', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.tls] + client_cert_data = "some-data" + `; + const env = { TEMPORAL_TLS_CLIENT_CERT_PATH: 'some-path' }; + const err = t.throws(() => + ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)), overrideEnvVars: env }) + ); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('data') + ); +}); + +// ============================================================================= +// 🚫 ERROR HANDLING +// ============================================================================= + +test('Load non-existent profile', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const err = t.throws(() => + ClientConfigProfile.load({ configSource: pathSource(filepath), profile: 'nonexistent' }) + ); + t.truthy(err); + t.true(String(err?.message).includes("Profile 'nonexistent' not found")); + }); +}); + +test('Load invalid config with strict mode enabled', (t) => { + const toml = dedent` + [unrecognized_table] + foo = "bar" + `; + const err = t.throws(() => + ClientConfig.load({ configSource: dataSource(Buffer.from(toml)), configFileStrict: true }) + ); + t.truthy(err); + t.true(String(err?.message).includes('unrecognized_table')); +}); + +test('Load invalid profile with strict mode enabled', (t) => { + withTempFile(TOML_CONFIG_STRICT_FAIL, (filepath) => { + const err = t.throws(() => + ClientConfigProfile.load({ configSource: pathSource(filepath), configFileStrict: true }) + ); + t.truthy(err); + t.true(String(err?.message).includes('unrecognized_field')); + }); +}); + +test('Load profiles with malformed TOML', (t) => { + const err = t.throws(() => ClientConfig.load({ configSource: dataSource(Buffer.from('this is not valid toml')) })); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('toml') + ); +}); + +// ============================================================================= +// 🔄 SERIALIZATION +// ============================================================================= + +test('Client config profile to/from JSON round-trip', (t) => { + const profile = new ClientConfigProfile({ + address: 'some-address', + namespace: 'some-namespace', + apiKey: 'some-api-key', + tls: new ClientConfigTLS({ + serverName: 'some-server', + serverRootCaCert: { data: Buffer.from('ca') }, + clientCert: { path: '/path/to/client.crt' }, + clientPrivateKey: { data: Buffer.from('key') }, + }), + grpcMeta: { 'some-header': 'some-value' }, + }); + const json = profile.toObject(); + const back = ClientConfigProfile.fromObject(json); + t.is(back.address, 'some-address'); + t.is(back.namespace, 'some-namespace'); + t.is(back.apiKey, 'some-api-key'); + t.truthy(back.tls); + t.is(back.tls?.serverName, 'some-server'); + t.deepEqual(back.tls?.serverRootCaCert, { data: 'ca' }); + t.deepEqual(back.tls?.clientCert, { path: '/path/to/client.crt' }); + t.deepEqual(back.tls?.clientPrivateKey, { data: 'key' }); + t.is(back.grpcMeta['some-header'], 'some-value'); +}); + +test('Client config to/from JSON round-trip', (t) => { + const conf = new ClientConfig({ + default: new ClientConfigProfile({ address: 'addr', namespace: 'ns' }), + custom: new ClientConfigProfile({ address: 'addr2', apiKey: 'key2', grpcMeta: { h: 'v' } }), + } as any); + const json = conf.toObject(); + const back = ClientConfig.fromObject(json); + t.is(back.profiles['default'].address, 'addr'); + t.is(back.profiles['default'].namespace, 'ns'); + t.is(back.profiles['custom'].address, 'addr2'); + t.is(back.profiles['custom'].apiKey, 'key2'); + t.is(back.profiles['custom'].grpcMeta['h'], 'v'); +}); + +// ============================================================================= +// 🎯 INTEGRATION/E2E +// ============================================================================= + +test('ClientConfig.loadClientConnectConfig works with file path and env overrides', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + // From file + let cc = ClientConfig.loadClientConnectConfig({ configSource: pathSource(filepath) }); + t.is(cc.connectionOptions.address, 'default-address'); + t.is(cc.namespace, 'default-namespace'); + + // With env overrides + cc = ClientConfig.loadClientConnectConfig({ + configSource: pathSource(filepath), + overrideEnvVars: { TEMPORAL_NAMESPACE: 'env-namespace-override' }, + }); + t.is(cc.namespace, 'env-namespace-override'); + }); +}); + +test('Create client from default profile', async (t) => { + // Start a local test server + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create TOML config with test server address + const toml = dedent` + [profile.default] + address = "${address}" + namespace = "default" + `; + + // Load config via envconfig + const { connectionOptions, namespace } = ClientConfig.loadClientConnectConfig({ + configSource: dataSource(Buffer.from(toml)), + }); + + // Verify loaded values + t.is(connectionOptions.address, address); + t.is(namespace, 'default'); + + // Create connection and client with loaded config + const connection = await Connection.connect(connectionOptions); + const client = new Client({ + connection, + namespace: namespace || 'default', + }); + + // If we got here without throwing, the connection is working + t.truthy(client); + t.truthy(client.connection); + + // Clean up + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client from custom profile', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create basic development profile configuration + const toml = dedent` + [profile.development] + address = "${address}" + namespace = "development-namespace" + `; + + // Load profile and create connection + const profile = ClientConfigProfile.load({ + profile: 'development', + configSource: dataSource(Buffer.from(toml)), + }); + + t.is(profile.address, address); + t.is(profile.namespace, 'development-namespace'); + t.is(profile.apiKey, undefined); + t.is(profile.tls, undefined); + + const { connectionOptions, namespace } = profile.toClientConnectConfig(); + const connection = await Connection.connect(connectionOptions); + const client = new Client({ connection, namespace: namespace || 'default' }); + + // Verify the client can perform basic operations + t.truthy(client); + t.truthy(client.connection); + t.is(client.options.namespace, 'development-namespace'); + + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client from custom profile with TLS options', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create production profile with API key (auto-enables TLS but disabled for local test) + const toml = dedent` + [profile.production] + address = "${address}" + namespace = "production-namespace" + api_key = "prod-api-key-12345" + [profile.production.tls] + disabled = true + `; + + // Load profile and verify TLS/API key handling + const profile = ClientConfigProfile.load({ + profile: 'production', + configSource: dataSource(Buffer.from(toml)), + }); + + t.is(profile.address, address); + t.is(profile.namespace, 'production-namespace'); + t.is(profile.apiKey, 'prod-api-key-12345'); + t.truthy(profile.tls); + t.true(!!profile.tls?.disabled); + + const { connectionOptions, namespace } = profile.toClientConnectConfig(); + + // Verify API key is present but TLS is disabled for local testing + t.is(connectionOptions.apiKey, 'prod-api-key-12345'); + t.is(connectionOptions.tls, undefined); // disabled = true results in undefined + + const connection = await Connection.connect(connectionOptions); + const client = new Client({ connection, namespace: namespace || 'default' }); + + t.truthy(client); + t.is(client.options.namespace, 'production-namespace'); + + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client from default profile with env overrides', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Base config that will be overridden by environment + const toml = dedent` + [profile.default] + address = "original-address" + namespace = "original-namespace" + `; + + // Environment overrides + const envOverrides = { + TEMPORAL_ADDRESS: address, // Override with test server address + TEMPORAL_NAMESPACE: 'env-override-namespace', + TEMPORAL_GRPC_META_CUSTOM_HEADER: 'env-header-value', + }; + + // Load profile with environment overrides + const profile = ClientConfigProfile.load({ + configSource: dataSource(Buffer.from(toml)), + overrideEnvVars: envOverrides, + }); + + // Verify environment variables took precedence + t.is(profile.address, address); + t.is(profile.namespace, 'env-override-namespace'); + t.is(profile.grpcMeta['custom-header'], 'env-header-value'); + + const { connectionOptions, namespace } = profile.toClientConnectConfig(); + const connection = await Connection.connect(connectionOptions); + const client = new Client({ connection, namespace: namespace || 'default' }); + + // Verify client uses overridden values + t.truthy(client); + t.is(client.options.namespace, 'env-override-namespace'); + t.is(connectionOptions.metadata?.['custom-header'], 'env-header-value'); + + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create clients from multi-profile config', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Multi-profile configuration + const toml = dedent` + [profile.service-a] + address = "${address}" + namespace = "service-a-namespace" + [profile.service-a.grpc_meta] + service-name = "service-a" + + [profile.service-b] + address = "${address}" + namespace = "service-b-namespace" + [profile.service-b.grpc_meta] + service-name = "service-b" + priority = "high" + `; + + // Load different profiles and create separate clients + const profileA = ClientConfigProfile.load({ + profile: 'service-a', + configSource: dataSource(Buffer.from(toml)), + }); + + const profileB = ClientConfigProfile.load({ + profile: 'service-b', + configSource: dataSource(Buffer.from(toml)), + }); + + // Verify profiles are distinct + t.is(profileA.namespace, 'service-a-namespace'); + t.is(profileA.grpcMeta['service-name'], 'service-a'); + t.false('priority' in profileA.grpcMeta); + + t.is(profileB.namespace, 'service-b-namespace'); + t.is(profileB.grpcMeta['service-name'], 'service-b'); + t.is(profileB.grpcMeta['priority'], 'high'); + + // Create separate client connections + const configA = profileA.toClientConnectConfig(); + const configB = profileB.toClientConnectConfig(); + + const connectionA = await Connection.connect(configA.connectionOptions); + const connectionB = await Connection.connect(configB.connectionOptions); + + const clientA = new Client({ connection: connectionA, namespace: configA.namespace || 'default' }); + const clientB = new Client({ connection: connectionB, namespace: configB.namespace || 'default' }); + + // Verify both clients work with their respective configurations + t.truthy(clientA); + t.truthy(clientB); + t.is(clientA.options.namespace, 'service-a-namespace'); + t.is(clientB.options.namespace, 'service-b-namespace'); + + // Verify metadata is correctly set for each connection + t.is(configA.connectionOptions.metadata?.['service-name'], 'service-a'); + t.is(configB.connectionOptions.metadata?.['service-name'], 'service-b'); + t.is(configB.connectionOptions.metadata?.['priority'], 'high'); + + await connectionA.close(); + await connectionB.close(); + } finally { + await env.teardown(); + } +}); + +test('TLS disabled tri-state behavior', (t) => { + // Test 1: disabled=null (unset) with API key -> TLS enabled + const tomlNull = dedent` + [profile.default] + address = "my-address" + api_key = "my-api-key" + [profile.default.tls] + server_name = "my-server" + `; + const profileNull = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(tomlNull)) }); + t.truthy(profileNull.tls); + t.is(profileNull.tls?.disabled, undefined); // disabled is null (unset) + const configNull = profileNull.toClientConnectConfig(); + t.truthy(configNull.connectionOptions.tls); // TLS enabled + + // Test 2: disabled=false (explicitly enabled) -> TLS enabled + const tomlFalse = dedent` + [profile.default] + address = "my-address" + [profile.default.tls] + disabled = false + server_name = "my-server" + `; + const profileFalse = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(tomlFalse)) }); + t.truthy(profileFalse.tls); + t.is(profileFalse.tls?.disabled, false); // explicitly disabled=false + const configFalse = profileFalse.toClientConnectConfig(); + t.truthy(configFalse.connectionOptions.tls); // TLS enabled + + // Test 3: disabled=true (explicitly disabled) -> TLS disabled even with API key + const tomlTrue = dedent` + [profile.default] + address = "my-address" + api_key = "my-api-key" + [profile.default.tls] + disabled = true + server_name = "should-be-ignored" + `; + const profileTrue = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(tomlTrue)) }); + t.truthy(profileTrue.tls); + t.is(profileTrue.tls?.disabled, true); // explicitly disabled=true + const configTrue = profileTrue.toClientConnectConfig(); + t.is(configTrue.connectionOptions.tls, undefined); // TLS disabled even with API key +}); + +test('Comprehensive E2E validation test', (t) => { + // Test comprehensive end-to-end configuration loading with all features + const tomlContent = dedent` + [profile.production] + address = "prod.temporal.com:443" + namespace = "production-ns" + api_key = "prod-api-key" + + [profile.production.tls] + server_name = "prod.temporal.com" + server_ca_cert_data = "prod-ca-cert" + + [profile.production.grpc_meta] + authorization = "Bearer prod-token" + "x-custom-header" = "prod-value" + `; + + const envOverrides = { + TEMPORAL_GRPC_META_X_ENVIRONMENT: 'production', + TEMPORAL_TLS_SERVER_NAME: 'override.temporal.com', + }; + + const { connectionOptions, namespace } = ClientConfig.loadClientConnectConfig({ + profile: 'production', + configSource: dataSource(Buffer.from(tomlContent)), + overrideEnvVars: envOverrides, + }); + + // Validate all configuration aspects + t.is(connectionOptions.address, 'prod.temporal.com:443'); + t.is(namespace, 'production-ns'); + t.is(connectionOptions.apiKey, 'prod-api-key'); + + // TLS configuration (API key should auto-enable TLS) + t.truthy(connectionOptions.tls); + const tls = connectionOptions.tls; + if (tls && typeof tls === 'object') { + t.is(tls.serverNameOverride, 'override.temporal.com'); // Env override + t.deepEqual(tls.serverRootCACertificate, Buffer.from('prod-ca-cert')); + } else { + t.fail('expected TLS config object'); + } + + // gRPC metadata with normalization and env overrides + t.truthy(connectionOptions.metadata); + const metadata = connectionOptions.metadata!; + t.is(metadata['authorization'], 'Bearer prod-token'); + t.is(metadata['x-custom-header'], 'prod-value'); + t.is(metadata['x-environment'], 'production'); // From env +});