Skip to content

Commit 11ea5b9

Browse files
committed
feat(sdk,core): add TriggerClient for per-instance SDK configuration
`new TriggerClient({...})` exposes the management surface (tasks, runs, schedules, envvars, batch, queues, deployments, prompts, auth) as an explicit instance with its own auth, preview branch, and baseURL. Multiple clients can coexist in one process without mutating shared global state. Identity fields (`accessToken`, `secretKey`, `previewBranch`) and task-runtime reads (`parentRunId`, `lockToVersion`, `taskContext.ctx`) are scope-only by default, so a call from inside a task does not leak parent context into a trigger that hits a different project. `baseURL` still falls back to `TRIGGER_API_URL` so local-dev and CI overrides apply without forcing every consumer to pass it explicitly. Two correctness fixes folded in: - `configure()` actually overrides on second call (was silent no-op). - `auth.withAuth()` is concurrency-safe (no longer mutates the global config, uses an AsyncLocalStorage scope instead). Ships with a `references/multi-client` reference project containing an echo task, a fan-out task, and two external scripts that smoke-test the isolation guarantees.
1 parent d343727 commit 11ea5b9

21 files changed

Lines changed: 999 additions & 28 deletions

File tree

.changeset/trigger-client.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Run multiple SDK clients side-by-side. `new TriggerClient({...})` exposes the management API as an explicit instance with its own auth, preview branch, and baseURL, so a single process can trigger tasks across different projects, environments, or preview branches without mutating shared global state.
7+
8+
```ts
9+
import { TriggerClient } from "@trigger.dev/sdk";
10+
11+
const prod = new TriggerClient({ accessToken: process.env.TRIGGER_PROD_KEY });
12+
const preview = new TriggerClient({
13+
accessToken: process.env.TRIGGER_PREVIEW_KEY,
14+
previewBranch: "signup-flow",
15+
});
16+
17+
await prod.tasks.trigger("send-email", payload);
18+
await preview.runs.list({ status: ["COMPLETED"] });
19+
```
20+
21+
Instance calls are isolated by default: identity fields (auth, branch) and task-runtime reads (`parentRunId`, `lockToVersion`, `taskContext.ctx`) are scope-only, so a call from inside a task does not leak parent context into a trigger that hits a different project. `baseURL` still falls back to `TRIGGER_API_URL` so local-dev and CI overrides apply without forcing every consumer to pass it explicitly.
22+
23+
Also fixes `configure()` silently no-op-ing on the second call, and makes `auth.withAuth()` concurrency-safe (parallel calls with different configs no longer stomp each other).

packages/core/src/v3/apiClientManager/index.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ApiClient } from "../apiClient/index.js";
22
import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js";
33
import { getEnvVar } from "../utils/getEnv.js";
4+
import { sdkScope } from "../sdkScope/index.js";
45
import { ApiClientConfiguration } from "./types.js";
56

67
const API_NAME = "api-client";
@@ -30,11 +31,27 @@ export class APIClientManagerAPI {
3031
}
3132

3233
get baseURL(): string | undefined {
34+
// baseURL is plumbing (where the API lives), not identity. Scoped
35+
// instances read their own config first but still fall back to the
36+
// process-level TRIGGER_API_URL so local-dev / CI overrides don't
37+
// require passing baseURL into every `new TriggerClient(...)`.
38+
const scoped = sdkScope.getStore();
39+
if (scoped) {
40+
return (
41+
scoped.apiClientConfig.baseURL ??
42+
getEnvVar("TRIGGER_API_URL") ??
43+
"https://api.trigger.dev"
44+
);
45+
}
3346
const config = this.#getConfig();
3447
return config?.baseURL ?? getEnvVar("TRIGGER_API_URL") ?? "https://api.trigger.dev";
3548
}
3649

3750
get accessToken(): string | undefined {
51+
const scoped = sdkScope.getStore();
52+
if (scoped) {
53+
return scoped.apiClientConfig.accessToken ?? scoped.apiClientConfig.secretKey;
54+
}
3855
const config = this.#getConfig();
3956
return (
4057
config?.secretKey ??
@@ -45,6 +62,11 @@ export class APIClientManagerAPI {
4562
}
4663

4764
get branchName(): string | undefined {
65+
const scoped = sdkScope.getStore();
66+
if (scoped) {
67+
const value = scoped.apiClientConfig.previewBranch ?? undefined;
68+
return value ? value : undefined;
69+
}
4870
const config = this.#getConfig();
4971
const value =
5072
config?.previewBranch ??
@@ -59,8 +81,10 @@ export class APIClientManagerAPI {
5981
return undefined;
6082
}
6183

62-
const requestOptions = this.#getConfig()?.requestOptions;
63-
const futureFlags = this.#getConfig()?.future;
84+
const scoped = sdkScope.getStore();
85+
const source = scoped?.apiClientConfig ?? this.#getConfig();
86+
const requestOptions = source?.requestOptions;
87+
const futureFlags = source?.future;
6488

6589
return new ApiClient(this.baseURL, this.accessToken, this.branchName, requestOptions, futureFlags);
6690
}
@@ -74,8 +98,10 @@ export class APIClientManagerAPI {
7498
}
7599

76100
const branchName = config?.previewBranch ?? this.branchName;
77-
const requestOptions = config?.requestOptions ?? this.#getConfig()?.requestOptions;
78-
const futureFlags = config?.future ?? this.#getConfig()?.future;
101+
const scoped = sdkScope.getStore();
102+
const source = scoped?.apiClientConfig ?? this.#getConfig();
103+
const requestOptions = config?.requestOptions ?? source?.requestOptions;
104+
const futureFlags = config?.future ?? source?.future;
79105

80106
return new ApiClient(baseURL, accessToken, branchName, requestOptions, futureFlags);
81107
}
@@ -84,17 +110,12 @@ export class APIClientManagerAPI {
84110
config: ApiClientConfiguration,
85111
fn: R
86112
): Promise<ReturnType<R>> {
87-
const originalConfig = this.#getConfig();
88-
const $config = { ...originalConfig, ...config };
89-
registerGlobal(API_NAME, $config, true);
90-
91-
return fn().finally(() => {
92-
registerGlobal(API_NAME, originalConfig, true);
93-
});
113+
const merged: ApiClientConfiguration = { ...this.#getConfig(), ...config };
114+
return sdkScope.withScope({ apiClientConfig: merged, inheritContext: true }, fn);
94115
}
95116

96117
public setGlobalAPIClientConfiguration(config: ApiClientConfiguration): boolean {
97-
return registerGlobal(API_NAME, config);
118+
return registerGlobal(API_NAME, config, true);
98119
}
99120

100121
#getConfig(): ApiClientConfiguration | undefined {

packages/core/src/v3/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from "./runtime-api.js";
1111
export * from "./task-context-api.js";
1212
export * from "./trace-context-api.js";
1313
export * from "./apiClientManager-api.js";
14+
export * from "./sdkScope-api.js";
1415
export * from "./usage-api.js";
1516
export * from "./run-metadata-api.js";
1617
export * from "./wait-until-api.js";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { sdkScope, type SdkScope } from "./sdkScope/index.js";
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { AsyncLocalStorage } from "node:async_hooks";
2+
import type { ApiClientConfiguration } from "../apiClientManager/types.js";
3+
4+
export type SdkScope = {
5+
apiClientConfig: ApiClientConfiguration;
6+
inheritContext: boolean;
7+
};
8+
9+
const storage = new AsyncLocalStorage<SdkScope>();
10+
11+
export const sdkScope = {
12+
getStore(): SdkScope | undefined {
13+
return storage.getStore();
14+
},
15+
withScope<R>(scope: SdkScope, fn: () => R): R {
16+
return storage.run(scope, fn);
17+
},
18+
};

packages/core/src/v3/taskContext/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Attributes } from "@opentelemetry/api";
22
import { ServerBackgroundWorker, TaskRunContext } from "../schemas/index.js";
33
import { SemanticInternalAttributes } from "../semanticInternalAttributes.js";
4+
import { sdkScope } from "../sdkScope/index.js";
45
import { getGlobal, registerGlobal } from "../utils/globals.js";
56
import { TaskContext } from "./types.js";
67

@@ -22,6 +23,7 @@ export class TaskContextAPI {
2223
}
2324

2425
get isInsideTask(): boolean {
26+
if (this.#isolatedFromContext()) return false;
2527
return this.#getTaskContext() !== undefined;
2628
}
2729

@@ -30,17 +32,25 @@ export class TaskContextAPI {
3032
}
3133

3234
get ctx(): TaskRunContext | undefined {
35+
if (this.#isolatedFromContext()) return undefined;
3336
return this.#getTaskContext()?.ctx;
3437
}
3538

3639
get worker(): ServerBackgroundWorker | undefined {
40+
if (this.#isolatedFromContext()) return undefined;
3741
return this.#getTaskContext()?.worker;
3842
}
3943

4044
get isWarmStart(): boolean | undefined {
45+
if (this.#isolatedFromContext()) return undefined;
4146
return this.#getTaskContext()?.isWarmStart;
4247
}
4348

49+
#isolatedFromContext(): boolean {
50+
const scope = sdkScope.getStore();
51+
return !!scope && !scope.inheritContext;
52+
}
53+
4454
get attributes(): Attributes {
4555
if (this.ctx) {
4656
return {

packages/trigger-sdk/src/v3/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,6 @@ export * as queues from "./queues.js";
6767
export type { ImportEnvironmentVariablesParams } from "./envvars.js";
6868

6969
export { configure, auth } from "./auth.js";
70+
export { TriggerClient, type TriggerClientConfig } from "./triggerClient.js";
7071
export * as prompts from "./prompts.js";
7172
export * as skills from "./skills.js";

packages/trigger-sdk/src/v3/shared.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
RateLimitError,
2323
resourceCatalog,
2424
runtime,
25+
sdkScope,
2526
SemanticInternalAttributes,
2627
stringifyIO,
2728
SubtaskUnwrapError,
@@ -129,6 +130,12 @@ export { SubtaskUnwrapError, TaskRunPromise };
129130

130131
export type Context = TaskRunContext;
131132

133+
function scopedEnvVar(name: string): string | undefined {
134+
const scope = sdkScope.getStore();
135+
if (scope && !scope.inheritContext) return undefined;
136+
return getEnvVar(name);
137+
}
138+
132139
export function queue(options: QueueOptions): Queue {
133140
resourceCatalog.registerQueueMetadata(options);
134141

@@ -740,7 +747,7 @@ export async function batchTriggerById<TTask extends AnyTask>(
740747
machine: item.options?.machine,
741748
priority: item.options?.priority,
742749
region: item.options?.region,
743-
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
750+
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
744751
debounce: item.options?.debounce,
745752
},
746753
};
@@ -1256,7 +1263,7 @@ export async function batchTriggerTasks<TTasks extends readonly AnyTask[]>(
12561263
machine: item.options?.machine,
12571264
priority: item.options?.priority,
12581265
region: item.options?.region,
1259-
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
1266+
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
12601267
debounce: item.options?.debounce,
12611268
},
12621269
};
@@ -1920,7 +1927,7 @@ async function* transformBatchItemsStream<TTask extends AnyTask>(
19201927
machine: item.options?.machine,
19211928
priority: item.options?.priority,
19221929
region: item.options?.region,
1923-
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
1930+
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
19241931
debounce: item.options?.debounce,
19251932
},
19261933
};
@@ -2023,7 +2030,7 @@ async function* transformBatchByTaskItemsStream<TTasks extends readonly AnyTask[
20232030
machine: item.options?.machine,
20242031
priority: item.options?.priority,
20252032
region: item.options?.region,
2026-
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
2033+
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
20272034
debounce: item.options?.debounce,
20282035
},
20292036
};
@@ -2127,7 +2134,7 @@ async function* transformSingleTaskBatchItemsStream<TPayload>(
21272134
machine: item.options?.machine,
21282135
priority: item.options?.priority,
21292136
region: item.options?.region,
2130-
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
2137+
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
21312138
debounce: item.options?.debounce,
21322139
},
21332140
};
@@ -2236,7 +2243,7 @@ async function trigger_internal<TRunTypes extends AnyRunTypes>(
22362243
machine: options?.machine,
22372244
priority: options?.priority,
22382245
region: options?.region,
2239-
lockToVersion: options?.version ?? getEnvVar("TRIGGER_VERSION"),
2246+
lockToVersion: options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
22402247
debounce: options?.debounce,
22412248
},
22422249
},
@@ -2322,7 +2329,7 @@ async function batchTrigger_internal<TRunTypes extends AnyRunTypes>(
23222329
machine: item.options?.machine,
23232330
priority: item.options?.priority,
23242331
region: item.options?.region,
2325-
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
2332+
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
23262333
},
23272334
};
23282335
})

0 commit comments

Comments
 (0)