diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-common.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-common.ts index e1c42dab61..a108d66ef1 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-common.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-common.ts @@ -35,6 +35,27 @@ export interface ActorGatewayOptions { bypassConnectable?: boolean; } +export type ResolvedActorGatewayOptions = Required; + +export function resolveActorGatewayOptions( + defaults: ActorGatewayOptions = {}, + overrides?: ActorGatewayOptions, +): ResolvedActorGatewayOptions { + return { + bypassConnectable: + overrides?.bypassConnectable ?? defaults.bypassConnectable ?? false, + }; +} + +export interface ActorActionOptions { + gateway?: ActorGatewayOptions; + signal?: AbortSignal; +} + +export interface ActorConnectOptions { + gateway?: ActorGatewayOptions; +} + export interface ActorFetchInit extends RequestInit { gateway?: ActorGatewayOptions; } diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts index 5d4abcabfe..6988f5ca54 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts @@ -34,7 +34,10 @@ import type { ActorDefinitionActions, ActorDefinitionEventSubscriptions, ActorDefinitionQueueSend, + ActorGatewayOptions, + ResolvedActorGatewayOptions, } from "./actor-common"; +import { resolveActorGatewayOptions } from "./actor-common"; import { type ActorResolutionState, checkForSchedulingError, @@ -53,6 +56,7 @@ import { type QueueSendResult, type QueueSendWaitOptions, } from "./queue"; +import { resolveGatewayTarget } from "./resolve-gateway-target"; import { type WebSocketMessage as ConnMessage, messageLength, @@ -186,6 +190,7 @@ export class ActorConnRaw { #getParams?: () => Promise; #encoding: Encoding; #actorResolutionState: ActorResolutionState; + #gatewayOptions: ResolvedActorGatewayOptions; // TODO: ws message queue @@ -203,6 +208,7 @@ export class ActorConnRaw { getParams: (() => Promise) | undefined, encoding: Encoding, actorResolutionState: ActorResolutionState, + gatewayOptions: ActorGatewayOptions = {}, ) { this.#client = client; this.#driver = driver; @@ -210,6 +216,7 @@ export class ActorConnRaw { this.#getParams = getParams; this.#encoding = encoding; this.#actorResolutionState = actorResolutionState; + this.#gatewayOptions = resolveActorGatewayOptions(gatewayOptions); this.#readyPromise = promiseWithResolvers((reason) => logger().warn({ msg: "unhandled ready promise rejection", @@ -225,6 +232,7 @@ export class ActorConnRaw { return await this.#driver.sendRequest( getGatewayTarget(this.#actorResolutionState), request, + this.#gatewayOptions, ); }, }); @@ -570,12 +578,15 @@ export class ActorConnRaw { async #connectWebSocket() { const params = await this.#resolveConnectionParams(); - const target = getGatewayTarget(this.#actorResolutionState); + const target = this.#gatewayOptions.bypassConnectable + ? await this.#resolveGatewayTargetForBypass() + : getGatewayTarget(this.#actorResolutionState); const ws = await this.#driver.openWebSocket( PATH_CONNECT, target, this.#encoding, params, + this.#gatewayOptions, ); invariant(ws, "websocket should have been created"); logger().debug({ @@ -623,6 +634,25 @@ export class ActorConnRaw { }); } + async #resolveGatewayTargetForBypass() { + if ("getForId" in this.#actorResolutionState) { + return { + directId: this.#actorResolutionState.getForId.actorId, + } as const; + } + + if (this.#actorId) { + return { directId: this.#actorId } as const; + } + + return { + directId: await resolveGatewayTarget( + this.#driver, + this.#actorResolutionState, + ), + } as const; + } + /** Called by the onopen event from drivers. */ #handleOnOpen() { // Connection was disposed before Init message arrived - close the websocket to avoid leak diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts index 33c1c30ea2..16c858182d 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts @@ -25,11 +25,15 @@ import type { EngineControlClient } from "@/engine-client/driver"; import { decodeCborCompat, deserializeWithEncoding, encodeCborCompat } from "@/serde"; import { bufferToArrayBuffer } from "@/utils"; import type { + ActorActionOptions, + ActorConnectOptions, ActorDefinitionActions, ActorFetchInit, ActorDefinitionQueueSend, + ActorGatewayOptions, ActorWebSocketOptions, } from "./actor-common"; +import { resolveActorGatewayOptions } from "./actor-common"; import { type ActorConn, ActorConnRaw } from "./actor-conn"; import { type ActorResolutionState, @@ -65,6 +69,7 @@ export class ActorHandleRaw { #driver: EngineControlClient; #encoding: Encoding; #actorResolutionState: ActorResolutionState; + #gatewayOptions: ActorGatewayOptions; #params: unknown; #getParams?: () => Promise; #resolvedActorId?: string; @@ -85,11 +90,13 @@ export class ActorHandleRaw { getParams: (() => Promise) | undefined, encoding: Encoding, actorResolutionState: ActorResolutionState, + gatewayOptions: ActorGatewayOptions = {}, ) { this.#client = client; this.#driver = driver; this.#encoding = encoding; this.#actorResolutionState = actorResolutionState; + this.#gatewayOptions = gatewayOptions; this.#params = params; this.#getParams = getParams; } @@ -139,7 +146,13 @@ export class ActorHandleRaw { encoding: this.#encoding, params: this.#params, customFetch: async (request: Request) => { - return await this.#driver.sendRequest(target, request); + return await this.#driver.sendRequest( + target, + request, + resolveActorGatewayOptions( + this.#gatewayOptions, + ), + ); }, }).send(name, body, options as any); } catch (err) { @@ -224,8 +237,7 @@ export class ActorHandleRaw { >(opts: { name: string; args: Args; - signal?: AbortSignal; - }): Promise { + } & ActorActionOptions): Promise { if ( typeof opts === "string" || typeof opts !== "object" || @@ -247,10 +259,13 @@ export class ActorHandleRaw { async #sendActionNow(opts: { name: string; args: unknown[]; - signal?: AbortSignal; - }): Promise { + } & ActorActionOptions): Promise { const maxAttempts = this.#getDynamicQueryMaxAttempts(); let useQueryTarget = false; + const gatewayOptions = resolveActorGatewayOptions( + this.#gatewayOptions, + opts.gateway, + ); for (let attempt = 0; attempt < maxAttempts; attempt++) { let actorId: string | undefined; @@ -294,10 +309,12 @@ export class ActorHandleRaw { }, body: opts.args, encoding: this.#encoding, - customFetch: this.#driver.sendRequest.bind( - this.#driver, - target, - ), + customFetch: async (request) => + await this.#driver.sendRequest( + target, + request, + gatewayOptions, + ), signal: opts?.signal, requestVersion: CLIENT_PROTOCOL_CURRENT_VERSION, requestVersionedDataHandler: HTTP_ACTION_REQUEST_VERSIONED, @@ -550,7 +567,10 @@ export class ActorHandleRaw { * @template AD The actor class that this connection is for. * @returns {ActorConn} A connection to the actor. */ - connect(params?: unknown): ActorConn { + connect( + params?: unknown, + options: ActorConnectOptions = {}, + ): ActorConn { logger().debug({ msg: "establishing connection from handle", query: this.#actorResolutionState, @@ -566,6 +586,7 @@ export class ActorHandleRaw { getParams, this.#encoding, this.#actorResolutionState, + resolveActorGatewayOptions(this.#gatewayOptions, options.gateway), ); return this.#client[CREATE_ACTOR_CONN_PROXY]( @@ -588,6 +609,10 @@ export class ActorHandleRaw { const maxAttempts = this.#getDynamicQueryMaxAttempts(); let useQueryTarget = false; const { gateway, ...requestInit } = init ?? {}; + const gatewayOptions = resolveActorGatewayOptions( + this.#gatewayOptions, + gateway, + ); for (let attempt = 0; attempt < maxAttempts; attempt++) { let actorId: string | undefined; @@ -600,7 +625,7 @@ export class ActorHandleRaw { this.#params, input, requestInit, - gateway, + gatewayOptions, ); const retry = await this.#shouldRetryRawFetchResponse( response, @@ -793,7 +818,11 @@ export class ActorHandleRaw { options: ActorWebSocketOptions = {}, ) { const params = await this.#resolveConnectionParams(); - const target = options.gateway?.bypassConnectable + const gatewayOptions = resolveActorGatewayOptions( + this.#gatewayOptions, + options.gateway, + ); + const target = gatewayOptions.bypassConnectable ? await this.#resolveActionTarget(false) : getGatewayTarget(this.#actorResolutionState); return await rawWebSocket( @@ -802,7 +831,7 @@ export class ActorHandleRaw { params, path, protocols, - options.gateway, + gatewayOptions, ); } @@ -828,6 +857,7 @@ export class ActorHandleRaw { async getGatewayUrl(): Promise { return await this.#driver.buildGatewayUrl( getGatewayTarget(this.#actorResolutionState), + this.#gatewayOptions, ); } @@ -870,7 +900,7 @@ export type ActorHandle = Omit< "connect" | "send" > & { // Add typed version of ActorConn (instead of using AnyActorDefinition) - connect(params?: unknown): ActorConn; + connect(params?: unknown, options?: ActorConnectOptions): ActorConn; // Resolve method returns the actor ID resolve(): Promise; } & ActorDefinitionQueueSend & diff --git a/rivetkit-typescript/packages/rivetkit/src/client/client.ts b/rivetkit-typescript/packages/rivetkit/src/client/client.ts index 21d0ad1d41..99e6f7df36 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/client.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/client.ts @@ -1,9 +1,9 @@ import type { AnyActorDefinition } from "@/actor/definition"; +import type { ActorQuery } from "@/client/query"; import type { Encoding } from "@/common/encoding"; import type { EngineControlClient } from "@/engine-client/driver"; -import type { ActorQuery } from "@/client/query"; import type { Registry } from "@/registry"; -import type { ActorActionFunction } from "./actor-common"; +import type { ActorActionFunction, ActorGatewayOptions } from "./actor-common"; import { type ActorConn, type ActorConnRaw, @@ -178,6 +178,7 @@ export class ClientRaw { #driver: EngineControlClient; #encodingKind: Encoding; + #gatewayOptions: ActorGatewayOptions; /** * Creates an instance of Client. @@ -185,10 +186,12 @@ export class ClientRaw { public constructor( driver: EngineControlClient, encoding: Encoding | undefined, + gatewayOptions: ActorGatewayOptions = {}, ) { this.#driver = driver; this.#encodingKind = encoding ?? "bare"; + this.#gatewayOptions = gatewayOptions; } /** @@ -382,6 +385,7 @@ export class ClientRaw { getParams, this.#encodingKind, actorQuery, + this.#gatewayOptions, ); } @@ -438,9 +442,9 @@ export type AnyClient = Client>; export function createClientWithDriver>( driver: EngineControlClient, - config: { encoding?: Encoding } = {}, + config: { encoding?: Encoding; gateway?: ActorGatewayOptions } = {}, ): Client { - const client = new ClientRaw(driver, config.encoding); + const client = new ClientRaw(driver, config.encoding, config.gateway); // Create proxy for accessing actors by name return new Proxy(client, { diff --git a/rivetkit-typescript/packages/rivetkit/src/client/config.ts b/rivetkit-typescript/packages/rivetkit/src/client/config.ts index a05657dab5..5cc956013c 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/config.ts @@ -70,6 +70,13 @@ export const ClientConfigSchemaBase = z.object({ .optional() .default(() => ({})), + gateway: z + .object({ + bypassConnectable: z.boolean().optional().default(false), + }) + .optional() + .default(() => ({ bypassConnectable: false })), + // See RunConfig.getUpgradeWebSocket // // This is required in the client config in order to support @@ -147,6 +154,7 @@ export function convertRegistryConfigToClientConfig( namespace: config.namespace, poolName: config.envoy.poolName, headers: config.headers, + gateway: { bypassConnectable: false }, encoding: "bare", getUpgradeWebSocket: undefined, // We don't need health checks for internal clients diff --git a/rivetkit-typescript/packages/rivetkit/src/client/mod.ts b/rivetkit-typescript/packages/rivetkit/src/client/mod.ts index b182dbe4f6..69b644bbd7 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/mod.ts @@ -1,6 +1,6 @@ import { injectDevtools } from "@/devtools-loader"; -import type { Registry } from "@/registry"; import { RemoteEngineControlClient } from "@/engine-client/mod"; +import type { Registry } from "@/registry"; import { type Client, type ClientConfigInput, @@ -9,18 +9,23 @@ import { import { ClientConfigSchema } from "./config"; export type { ActorDefinition, AnyActorDefinition } from "@/actor/definition"; -export type { Encoding } from "@/common/encoding"; export { ActorClientError, ActorConnDisposed, ActorError, - RivetError, MalformedResponseMessage, ManagerError, + RivetError, UserError, } from "@/client/errors"; export type { CreateRequest } from "@/client/query"; -export type { ActorActionFunction } from "./actor-common"; +export type { Encoding } from "@/common/encoding"; +export type { + ActorActionFunction, + ActorActionOptions, + ActorConnectOptions, + ActorGatewayOptions, +} from "./actor-common"; export type { ActorConn, ActorConnStatus, diff --git a/rivetkit-typescript/packages/rivetkit/src/engine-client/mod.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/mod.ts index a81ba9adcb..5bfb7dfe5a 100644 --- a/rivetkit-typescript/packages/rivetkit/src/engine-client/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-client/mod.ts @@ -2,20 +2,25 @@ import type { Context as HonoContext } from "hono"; import invariant from "invariant"; import { deserializeActorKey, serializeActorKey } from "@/actor/keys"; import type { ClientConfig } from "@/client/client"; -import { noopNext } from "@/common/utils"; import { - type ActorOutput, - type CreateInput, - type GatewayTarget, - type GatewayRequestOptions, - type GetForIdInput, - type GetOrCreateWithKeyInput, - type GetWithKeyInput, - type ListActorsInput, - type RuntimeDisplayInformation, - type EngineControlClient, -} from "@/engine-client/driver"; + PATH_CONNECT, + PATH_WEBSOCKET_BASE, + PATH_WEBSOCKET_PREFIX, +} from "@/common/actor-router-consts"; +import { noopNext } from "@/common/utils"; import type { Actor as ApiActor } from "@/engine-api/actors"; +import type { + ActorOutput, + CreateInput, + EngineControlClient, + GatewayRequestOptions, + GatewayTarget, + GetForIdInput, + GetOrCreateWithKeyInput, + GetWithKeyInput, + ListActorsInput, + RuntimeDisplayInformation, +} from "@/engine-client/driver"; import type { Encoding, UniversalWebSocket } from "@/mod"; import { encodeCborCompat, uint8ArrayToBase64 } from "@/serde"; import { combineUrlPath, type GetUpgradeWebSocket } from "@/utils"; @@ -252,7 +257,11 @@ export class RemoteEngineControlClient implements EngineControlClient { await this.#metadataPromise; const path = requestPath(actorRequest); - const gatewayUrl = this.#buildGatewayUrlForTarget(target, path, options); + const gatewayUrl = this.#buildGatewayUrlForTarget( + target, + path, + options, + ); const httpOptions = { ...options, directActorId: options.bypassConnectable @@ -277,7 +286,11 @@ export class RemoteEngineControlClient implements EngineControlClient { ): Promise { await this.#metadataPromise; - const gatewayUrl = this.#buildGatewayUrlForTarget(target, path, options); + const gatewayUrl = this.#buildGatewayUrlForTarget( + target, + path, + options, + ); return openWebSocketToGateway( this.#config, @@ -410,7 +423,11 @@ export class RemoteEngineControlClient implements EngineControlClient { ): string { const endpoint = getEndpoint(this.#config); - if (options.bypassConnectable && directActorIdFromTarget(target)) { + if ( + options.bypassConnectable && + directActorIdFromTarget(target) && + canUseDirectBypassPath(path) + ) { return combineUrlPath(endpoint, path); } @@ -458,6 +475,25 @@ export class RemoteEngineControlClient implements EngineControlClient { } } +function canUseDirectBypassPath(path: string): boolean { + return ( + isActorHttpRequestPath(path) || + path === PATH_CONNECT || + path === PATH_WEBSOCKET_BASE || + path.startsWith(PATH_WEBSOCKET_PREFIX) + ); +} + +function isActorHttpRequestPath(path: string): boolean { + const stripped = path.slice("/request".length); + return ( + path.startsWith("/request") && + (stripped.length === 0 || + stripped.startsWith("/") || + stripped.startsWith("?")) + ); +} + function directActorIdFromTarget(target: GatewayTarget): string | undefined { if ("directId" in target) { return target.directId; diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver/gateway-bypass-client.test.ts b/rivetkit-typescript/packages/rivetkit/tests/driver/gateway-bypass-client.test.ts new file mode 100644 index 0000000000..ec6cb9ab15 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/driver/gateway-bypass-client.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "vitest"; +import { describeDriverMatrix } from "./shared-matrix"; +import { setupDriverTest } from "./shared-utils"; + +const BYPASS_HEADER = "x-rivet-bypass-connectable"; +const BYPASS_PROTOCOL = "rivet_bypass_connectable"; + +function websocketProtocols(headers: Record): string[] { + return (headers["sec-websocket-protocol"] ?? "") + .split(",") + .map((protocol) => protocol.trim()) + .filter(Boolean); +} + +describeDriverMatrix("Gateway Bypass Client", (driverTestConfig) => { + describe("Gateway Bypass Client", () => { + test("action calls can enable and disable gateway bypass", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + + const enabledView = client.requestAccessActor.getOrCreate([ + "action-bypass-enabled", + ]); + const enabledTracking = client.requestAccessActor.getOrCreate( + ["action-bypass-enabled"], + { params: { trackRequest: true } }, + ); + + await enabledTracking.action({ + name: "ping", + args: [], + gateway: { bypassConnectable: true }, + }); + + const enabledInfo = await enabledView.getRequestInfo(); + expect( + enabledInfo.onBeforeConnect.requestHeaders[BYPASS_HEADER], + ).toBe("1"); + + const disabledView = client.requestAccessActor.getOrCreate([ + "action-bypass-disabled", + ]); + const disabledTracking = client.requestAccessActor.getOrCreate( + ["action-bypass-disabled"], + { params: { trackRequest: true } }, + ); + + await disabledTracking.action({ + name: "ping", + args: [], + gateway: { bypassConnectable: false }, + }); + + const disabledInfo = await disabledView.getRequestInfo(); + expect( + disabledInfo.onBeforeConnect.requestHeaders[BYPASS_HEADER], + ).toBeUndefined(); + }); + + test("client gateway bypass default can be overridden per action", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig, { + client: { gateway: { bypassConnectable: true } }, + }); + + const defaultView = client.requestAccessActor.getOrCreate([ + "client-action-bypass-default", + ]); + const defaultTracking = client.requestAccessActor.getOrCreate( + ["client-action-bypass-default"], + { params: { trackRequest: true } }, + ); + + await defaultTracking.ping(); + + const defaultInfo = await defaultView.getRequestInfo(); + expect( + defaultInfo.onBeforeConnect.requestHeaders[BYPASS_HEADER], + ).toBe("1"); + + const overrideView = client.requestAccessActor.getOrCreate([ + "client-action-bypass-override", + ]); + const overrideTracking = client.requestAccessActor.getOrCreate( + ["client-action-bypass-override"], + { params: { trackRequest: true } }, + ); + + await overrideTracking.action({ + name: "ping", + args: [], + gateway: { bypassConnectable: false }, + }); + + const overrideInfo = await overrideView.getRequestInfo(); + expect( + overrideInfo.onBeforeConnect.requestHeaders[BYPASS_HEADER], + ).toBeUndefined(); + }); + + test("connect can enable gateway bypass for its websocket", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + + const defaultConn = client.requestAccessActor + .getOrCreate(["connect-bypass-default"], { + params: { trackRequest: true }, + }) + .connect(); + + const defaultInfo = await defaultConn.getRequestInfo(); + expect( + websocketProtocols(defaultInfo.onBeforeConnect.requestHeaders), + ).not.toContain(BYPASS_PROTOCOL); + await defaultConn.dispose(); + + const bypassConn = client.requestAccessActor + .getOrCreate(["connect-bypass-enabled"], { + params: { trackRequest: true }, + }) + .connect(undefined, { + gateway: { bypassConnectable: true }, + }); + + const bypassInfo = await bypassConn.getRequestInfo(); + expect( + websocketProtocols(bypassInfo.onBeforeConnect.requestHeaders), + ).toContain(BYPASS_PROTOCOL); + await bypassConn.dispose(); + }); + + test("client gateway bypass default can be overridden per connect", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig, { + client: { gateway: { bypassConnectable: true } }, + }); + + const defaultConn = client.requestAccessActor + .getOrCreate(["client-connect-bypass-default"], { + params: { trackRequest: true }, + }) + .connect(); + + const defaultInfo = await defaultConn.getRequestInfo(); + expect( + websocketProtocols(defaultInfo.onBeforeConnect.requestHeaders), + ).toContain(BYPASS_PROTOCOL); + await defaultConn.dispose(); + + const overrideConn = client.requestAccessActor + .getOrCreate(["client-connect-bypass-override"], { + params: { trackRequest: true }, + }) + .connect(undefined, { + gateway: { bypassConnectable: false }, + }); + + const overrideInfo = await overrideConn.getRequestInfo(); + expect( + websocketProtocols(overrideInfo.onBeforeConnect.requestHeaders), + ).not.toContain(BYPASS_PROTOCOL); + await overrideConn.dispose(); + }); + }); +}); diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver/shared-utils.ts b/rivetkit-typescript/packages/rivetkit/tests/driver/shared-utils.ts index 845282b5c5..6bf5837156 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/driver/shared-utils.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/driver/shared-utils.ts @@ -1,8 +1,12 @@ // @ts-nocheck import { type TestContext, vi } from "vitest"; -import { type Client, createClient } from "../../src/client/mod"; -import { getLogger } from "../../src/common/log"; import type { registry } from "../../fixtures/driver-test-suite/registry-static"; +import { + type Client, + type ClientConfigInput, + createClient, +} from "../../src/client/mod"; +import { getLogger } from "../../src/common/log"; import type { DriverTestConfig } from "./shared-types"; export const FAKE_TIME = new Date("2024-01-01T00:00:00.000Z"); @@ -26,6 +30,9 @@ function timing(label: string, startedAt: number, testName?: string) { export async function setupDriverTest( c: TestContext, driverTestConfig: DriverTestConfig, + options: { + client?: Partial; + } = {}, ): Promise<{ client: Client; endpoint: string; @@ -62,6 +69,7 @@ export async function setupDriverTest( // Disable metadata lookup to prevent redirect to the wrong port. // Each test starts a runtime on a dynamic namespace and pool. disableMetadataLookup: true, + ...options.client, }); timing("setup.client", clientStartedAt, testName); timing("setup.total", setupStartedAt, testName);