Skip to content

Commit dd9cee8

Browse files
committed
feat(worker): Add support for HTTP CONNECT Proxy (#1411)
1 parent adac766 commit dd9cee8

File tree

12 files changed

+361
-40
lines changed

12 files changed

+361
-40
lines changed

.github/workflows/ci.yml

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ jobs:
8989
prefix-key: corebridge-buildcache
9090
shared-key: ${{ matrix.platform }}
9191
env-vars: ''
92-
save-if: ${{ env.IS_MAIN_OR_RELEASE }}
92+
save-if: env.IS_MAIN_OR_RELEASE == 'true'
9393

9494
- name: Compile rust code
9595
if: steps.cached-artifact.outputs.cache-hit != 'true'
@@ -182,7 +182,7 @@ jobs:
182182
- name: Save NPM cache
183183
uses: actions/cache/save@v4
184184
# Only saves NPM cache from the main branch, to reduce pressure on the cache (limited to 10GB).
185-
if: ${{ env.IS_MAIN_OR_RELEASE }}
185+
if: env.IS_MAIN_OR_RELEASE == 'true'
186186
with:
187187
path: ${{ steps.npm-cache-dir.outputs.dir }}
188188
key: npm-main-${{ matrix.platform }}-${{ hashFiles('./package-lock.json') }}
@@ -254,11 +254,11 @@ jobs:
254254
run: npm run build -- --ignore @temporalio/core-bridge
255255

256256
- name: Install Temporal CLI
257-
if: ${{ matrix.server == 'cli' }}
257+
if: matrix.server == 'cli'
258258
uses: temporalio/setup-temporal@v0
259259

260260
- name: Run Temporal CLI
261-
if: ${{ matrix.server == 'cli' }}
261+
if: matrix.server == 'cli'
262262
shell: bash
263263
run: |
264264
temporal server start-dev --headless &
@@ -357,11 +357,11 @@ jobs:
357357
run: node scripts/init-from-verdaccio.js --registry-dir ./tmp/registry --sample https://github.com/temporalio/samples-typescript/tree/next/${{ matrix.sample }} --target-dir ${{ runner.temp }}/example
358358

359359
- name: Install Temporal CLI
360-
if: ${{ matrix.server == 'cli' }}
360+
if: matrix.server == 'cli'
361361
uses: temporalio/setup-temporal@v0
362362

363363
- name: Run Temporal CLI
364-
if: ${{ matrix.server == 'cli' }}
364+
if: matrix.server == 'cli'
365365
shell: bash
366366
run: |
367367
temporal server start-dev --headless &
@@ -370,12 +370,12 @@ jobs:
370370
- name: Create certs dir
371371
shell: bash
372372
run: node scripts/create-certs-dir.js "${{ runner.temp }}/certs"
373-
if: ${{ matrix.server == 'cloud' }}
373+
if: matrix.server == 'cloud'
374374

375375
- name: Test run a workflow (non-cloud)
376376
run: node scripts/test-example.js --work-dir "${{ runner.temp }}/example"
377377
shell: bash
378-
if: ${{ matrix.server == 'cli' }}
378+
if: matrix.server == 'cli'
379379

380380
- name: Test run a workflow (cloud)
381381
run: node scripts/test-example.js --work-dir "${{ runner.temp }}/example"
@@ -387,7 +387,7 @@ jobs:
387387
TEMPORAL_CLIENT_CERT_PATH: ${{ runner.temp }}/certs/client.pem
388388
TEMPORAL_CLIENT_KEY_PATH: ${{ runner.temp }}/certs/client.key
389389
TEMPORAL_TASK_QUEUE: ${{ format('{0}-{1}-{2}', matrix.platform, matrix.node, matrix.sample) }}
390-
if: ${{ matrix.server == 'cloud' }}
390+
if: matrix.server == 'cloud'
391391

392392
- name: Destroy certs dir
393393
if: always()
@@ -404,6 +404,7 @@ jobs:
404404
typescript-repo-path: ${{github.event.pull_request.head.repo.full_name}}
405405
version: ${{github.event.pull_request.head.ref}}
406406
version-is-repo-ref: true
407+
features-repo-ref: http-connect-proxy-typescript
407408

408409
stress-tests-no-reuse-context:
409410
name: Stress Tests (No Reuse V8 Context)
@@ -541,10 +542,10 @@ jobs:
541542
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
542543

543544
- name: Deploy prod docs # TODO: only deploy prod docs when we publish a new version
544-
if: ${{ env.IS_MAIN_OR_RELEASE }}
545+
if: env.IS_MAIN_OR_RELEASE == 'true'
545546
run: npx vercel deploy packages/docs/build -t ${{ secrets.VERCEL_TOKEN }} --name typescript --scope temporal --prod --yes
546547

547548
- name: Deploy draft docs
548549
# Don't run on forks, since secrets won't be available, and command will fail
549-
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.ref != 'refs/heads/main' }}
550+
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.ref != 'refs/heads/main'
550551
run: npx vercel deploy packages/docs/build -t ${{ secrets.VERCEL_TOKEN }} --name typescript --scope temporal --yes

.github/workflows/stress.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ jobs:
9999
prefix-key: corebridge-buildcache
100100
shared-key: linux-intel
101101
env-vars: ''
102-
save-if: ${{ env.IS_MAIN_BRANCH }}
102+
save-if: env.IS_MAIN_BRANCH == 'true'
103103

104104
- name: Download dependencies
105105
# Make up to 3 attempts to install NPM dependencies, to work around transient NPM errors
@@ -126,16 +126,16 @@ jobs:
126126
timeout ${{ inputs.test-timeout-minutes }}m npm run ${{ inputs.test-type }}
127127
128128
- run: for f in $TEMPORAL_TESTING_LOG_DIR/*.log; do tail -20000 $f > $TEMPORAL_TESTING_LOG_DIR/tails/$(basename $f); done
129-
if: ${{ always() }}
129+
if: always()
130130

131131
- uses: actions/upload-artifact@v4
132-
if: ${{ always() }}
132+
if: always()
133133
with:
134134
name: worker-logs-${{ inputs.reuse-v8-context && 'reuse-v8' || 'no-reuse-v8' }}
135135
path: ${{ env.TEMPORAL_TESTING_LOG_DIR }}/tails
136136

137137
- uses: actions/upload-artifact@v4
138-
if: ${{ always() }}
138+
if: always()
139139
with:
140140
name: worker-mem-logs-${{ inputs.reuse-v8-context && 'reuse-v8' || 'no-reuse-v8' }}
141141
path: ${{ env.TEMPORAL_TESTING_MEM_LOG_DIR }}

packages/client/src/connection.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { AsyncLocalStorage } from 'node:async_hooks';
22
import * as grpc from '@grpc/grpc-js';
33
import type { RPCImpl } from 'protobufjs';
4-
import { filterNullAndUndefined, normalizeTlsConfig, TLSConfig } from '@temporalio/common/lib/internal-non-workflow';
4+
import {
5+
filterNullAndUndefined,
6+
normalizeTlsConfig,
7+
TLSConfig,
8+
normalizeTemporalGrpcEndpointAddress,
9+
} from '@temporalio/common/lib/internal-non-workflow';
510
import { Duration, msOptionalToNumber } from '@temporalio/common/lib/time';
611
import { isGrpcServiceError, ServiceError } from './errors';
712
import { defaultGrpcRetryOptions, makeGrpcRetryInterceptor } from './grpc-retry';
@@ -13,8 +18,9 @@ import { CallContext, HealthService, Metadata, OperatorService, WorkflowService
1318
*/
1419
export interface ConnectionOptions {
1520
/**
16-
* Server hostname and optional port.
17-
* Port defaults to 7233 if address contains only host.
21+
* The address of the Temporal server to connect to, in `hostname:port` format.
22+
*
23+
* Port defaults to 7233. Raw IPv6 addresses must be wrapped in square brackets (e.g. `[ipv6]:port`).
1824
*
1925
* @default localhost:7233
2026
*/
@@ -167,10 +173,7 @@ function normalizeGRPCConfig(options?: ConnectionOptions): ConnectionOptions {
167173
}
168174
}
169175
if (rest.address) {
170-
// eslint-disable-next-line prefer-const
171-
let [host, port] = rest.address.split(':', 2);
172-
port = port || '7233';
173-
rest.address = `${host}:${port}`;
176+
rest.address = normalizeTemporalGrpcEndpointAddress(rest.address);
174177
}
175178
const tls = normalizeTlsConfig(tlsFromConfig);
176179
if (tls) {

packages/common/src/internal-non-workflow/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@
66
export * from './codec-helpers';
77
export * from './codec-types';
88
export * from './data-converter-helpers';
9+
export * from './parse-host-uri';
10+
export * from './proxy-config';
911
export * from './tls-config';
1012
export * from './utils';
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* This file contain helper functions to parse specific subsets of URIs.
3+
*
4+
* The ECMAScript-compliant URL class don't properly handle some syntaxes that
5+
* we care about, such as not providing a protocol (e.g. '127.0.0.1:7233'), and
6+
* performs some normalizations that are not desirable for our use cases
7+
* (e.g. parsing 'http://127.0.0.1:7233' adds a '/' path). On the other side,
8+
* simply using `split(':')` breaks on IPv6 addresses. Hence these helpers.
9+
*/
10+
11+
/**
12+
* Scheme. Requires but doesn't capture the ':' or '://' separator that follows.
13+
* e.g. `http:` will be captured as 'http'.
14+
*/
15+
const scheme = '(?:(?<scheme>[a-z][a-z0-9]+):(?:\\/\\/)?)';
16+
17+
/**
18+
* IPv4-style hostname. Not captured.
19+
* e.g.: `192.168.1.100`.
20+
*/
21+
const ipv4Hostname = '(?:\\d{1,3}(?:\\.\\d{1,3}){3})';
22+
23+
/**
24+
* IPv6-style hostname; must be enclosed in square brackets. Not captured.
25+
* e.g.: `[::1]`, `[2001:db8::1]`, `[::FFFF:129.144.52.38]`, etc.
26+
*/
27+
const ipv6Hostname = '(?:\\[(?<ipv6>[0-9a-fA-F.:]+)\\])';
28+
29+
// DNS-style hostname. Not captured.
30+
// e.g.: `test.com` or `localhost`.
31+
const dnsHostname = '(?:[^:/]+)';
32+
33+
const hostname = `(?:${ipv4Hostname}|${ipv6Hostname}|${dnsHostname})`;
34+
35+
// Port number. Requires but don't capture a preceeding ':' separator.
36+
// For example, `:7233` will be captured as `7233`.
37+
const port = '(?::(?<port>\\d+))';
38+
39+
const protoHostPortRegex = new RegExp(`^${scheme}??(?<hostname>${hostname})${port}?$`);
40+
41+
export interface ProtoHostPort {
42+
scheme?: string;
43+
hostname: string;
44+
port?: number;
45+
}
46+
47+
/**
48+
* Split a URI composed only of a scheme, a hostname, and port.
49+
* The scheme and port are optional.
50+
*
51+
* Examples of valid URIs for HTTP CONNECT proxies:
52+
*
53+
* ```
54+
* http://test.com:8080 => { scheme: 'http', host: 'test.com', port: 8080 }
55+
* http://192.168.0.1:8080 => { scheme: 'http', host: '192.168.0.1', port: 8080 }
56+
* [::1]:8080 => { scheme: 'http', host: '::1', port: 8080 }
57+
* [::ffff:192.0.2.128]:8080 => { scheme: 'http', host: '::ffff:192.0.2.128', port: 8080 }
58+
* 192.168.0.1:8080 => { scheme: 'http', host: '192.168.0.1', port: 8080 }
59+
* ```
60+
*/
61+
export function splitProtoHostPort(uri: string): ProtoHostPort | undefined {
62+
const match = protoHostPortRegex.exec(uri);
63+
if (!match?.groups) return undefined;
64+
return {
65+
scheme: match.groups.scheme,
66+
hostname: match.groups.ipv6 ?? match.groups.hostname,
67+
port: match.groups.port !== undefined ? Number(match.groups.port) : undefined,
68+
};
69+
}
70+
71+
export function joinProtoHostPort(components: ProtoHostPort): string {
72+
const { scheme, hostname, port } = components;
73+
const schemeText = scheme ? `${scheme}:` : '';
74+
const hostnameText = hostname.includes(':') ? `[${hostname}]` : hostname;
75+
const portText = port !== undefined ? `:${port}` : '';
76+
return `${schemeText}${hostnameText}${portText}`;
77+
}
78+
79+
/**
80+
* Parse the address for the gRPC endpoint of a Temporal server.
81+
*
82+
* - The URI may only contain a hostname and a port.
83+
* - Port is optional; it defaults to 7233.
84+
*
85+
* Examples of valid URIs:
86+
*
87+
* ```
88+
* 127.0.0.1:7233 => { host: '192.168.0.1', port: 7233 }
89+
* my.temporal.service.com:7233 => { host: 'my.temporal.service.com', port: 7233 }
90+
* [::ffff:192.0.2.128]:8080 => { host: '[::ffff:192.0.2.128]', port: 8080 }
91+
* ```
92+
*/
93+
export function normalizeTemporalGrpcEndpointAddress(uri: string): string {
94+
const splitted = splitProtoHostPort(uri);
95+
if (!splitted || splitted.scheme !== undefined) {
96+
throw new TypeError(
97+
`Invalid address for Temporal gRPC endpoint: expected URI of the form 'hostname' or 'hostname:port'; got '${uri}'`
98+
);
99+
}
100+
splitted.port ??= 7233;
101+
return joinProtoHostPort(splitted);
102+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { ProtoHostPort, splitProtoHostPort } from './parse-host-uri';
2+
3+
/**
4+
* Configuration for HTTP CONNECT proxying.
5+
*/
6+
export interface HttpConnectProxyConfig {
7+
type: 'http-connect';
8+
9+
/**
10+
* Address of the HTTP CONNECT proxy server, in either `hostname:port` or `http://hostname:port` formats.
11+
*
12+
* Port is required, and only the `http` scheme is supported. Raw IPv6 addresses must be wrapped in square brackets
13+
* (e.g. `[ipv6]:port`).
14+
*/
15+
targetHost: string;
16+
17+
/**
18+
* Basic auth for the HTTP CONNECT proxy, if any.
19+
*
20+
* Neither username nor password may contain `:` or `@`.
21+
*
22+
* Note that these credentials will be exposed through environment variables, and will be exchanged in non-encrypted
23+
* form ovrer the network. The connection to the proxy server is not encrypted.
24+
*/
25+
basicAuth?: {
26+
username: string;
27+
password: string;
28+
};
29+
}
30+
31+
export type ProxyConfig = HttpConnectProxyConfig;
32+
33+
/**
34+
* Parse the address of a HTTP CONNECT proxy endpoint.
35+
*
36+
* - The URI may only contain a scheme, a hostname, and a port;
37+
* - If specified, scheme must be 'http';
38+
* - Port is required.
39+
*
40+
* Examples of valid URIs:
41+
*
42+
* ```
43+
* 127.0.0.1:8080 => { scheme: 'http', host: '192.168.0.1', port: 8080 }
44+
* my.temporal.service.com:8888 => { scheme: 'http', host: 'my.temporal.service.com', port: 8888 }
45+
* [::ffff:192.0.2.128]:8080 => { scheme: 'http', host: '::ffff:192.0.2.128', port: 8080 }
46+
* ```
47+
*/
48+
export function parseHttpConnectProxyAddress(target: string): ProtoHostPort {
49+
const match = splitProtoHostPort(target);
50+
if (!match)
51+
throw new TypeError(
52+
`Invalid address for HTTP CONNECT proxy: expected 'hostname:port' or '[ipv6 address]:port'; got '${target}'`
53+
);
54+
const { scheme = 'http', hostname: host, port } = match;
55+
if (scheme !== 'http')
56+
throw new TypeError(`Invalid address for HTTP CONNECT proxy: scheme must be http'; got '${target}'`);
57+
if (port === undefined)
58+
throw new TypeError(`Invalid address for HTTP CONNECT proxy: port is required; got '${target}'`);
59+
return { scheme, hostname: host, port };
60+
}

packages/core-bridge/src/conversions.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use neon::{
66
types::{JsBoolean, JsNumber, JsString},
77
};
88
use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration};
9+
use temporal_client::HttpConnectProxyOptions;
910
use temporal_sdk_core::{
1011
api::telemetry::{Logger, MetricTemporality, TelemetryOptions, TelemetryOptionsBuilder},
1112
api::{
@@ -121,6 +122,26 @@ impl ObjectHandleConversionsExt for Handle<'_, JsObject> {
121122
}
122123
};
123124

125+
let proxy_cfg = match js_optional_getter!(cx, self, "proxy", JsObject) {
126+
None => None,
127+
Some(proxy) => {
128+
let target_addr = js_value_getter!(cx, &proxy, "targetHost", JsString);
129+
130+
let basic_auth = match js_optional_getter!(cx, &proxy, "basicAuth", JsObject) {
131+
None => None,
132+
Some(proxy_obj) => Some((
133+
js_value_getter!(cx, &proxy_obj, "username", JsString),
134+
js_value_getter!(cx, &proxy_obj, "password", JsString),
135+
)),
136+
};
137+
138+
Some(HttpConnectProxyOptions {
139+
target_addr,
140+
basic_auth,
141+
})
142+
}
143+
};
144+
124145
let retry_config = match js_optional_getter!(cx, self, "retry", JsObject) {
125146
None => RetryConfig::default(),
126147
Some(ref retry_config) => RetryConfig {
@@ -158,6 +179,7 @@ impl ObjectHandleConversionsExt for Handle<'_, JsObject> {
158179
if let Some(tls_cfg) = tls_cfg {
159180
client_options.tls_cfg(tls_cfg);
160181
}
182+
client_options.http_connect_proxy(proxy_cfg);
161183
let headers = match js_optional_getter!(cx, self, "metadata", JsObject) {
162184
None => None,
163185
Some(h) => Some(h.as_hash_map_of_string_to_string(cx).map_err(|reason| {

packages/core-bridge/ts/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { LogLevel, Duration } from '@temporalio/common';
2-
import type { TLSConfig } from '@temporalio/common/lib/internal-non-workflow';
2+
import type { TLSConfig, ProxyConfig, HttpConnectProxyConfig } from '@temporalio/common/lib/internal-non-workflow';
33

4-
export { TLSConfig };
4+
export type { TLSConfig, ProxyConfig, HttpConnectProxyConfig };
55

66
/** @deprecated Import from @temporalio/common instead */
77
export { LogLevel };
@@ -55,6 +55,13 @@ export interface ClientOptions {
5555
*/
5656
tls?: TLSConfig;
5757

58+
/**
59+
* Proxying configuration.
60+
*
61+
* @experimental
62+
*/
63+
proxy?: ProxyConfig;
64+
5865
/**
5966
* Optional retry options for server requests.
6067
*/

0 commit comments

Comments
 (0)