Skip to content

Commit 832cf72

Browse files
authored
feat(sdk,core): add TriggerClient for per-instance SDK configuration (#3683)
## Summary `new TriggerClient({...})` exposes the management API (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 — useful when a single service triggers across multiple projects, environments, or preview branches. ```ts import { TriggerClient } from "@trigger.dev/sdk"; const prod = new TriggerClient({ accessToken: process.env.TRIGGER_PROD_KEY }); const preview = new TriggerClient({ accessToken: process.env.TRIGGER_PREVIEW_KEY, previewBranch: "signup-flow", }); await prod.tasks.trigger("send-email", payload); await preview.runs.list({ status: ["COMPLETED"] }); ``` The existing global `configure()` API keeps working unchanged. ## Design Instance methods enter an `AsyncLocalStorage`-backed scope (`sdkScope`) before delegating to the existing module-level functions. The four "pollution" points that previously read globals now consult the scope first: - `apiClientManager.{baseURL, accessToken, branchName}` and `clientOrThrow` — identity fields are scope-only when scoped; `baseURL` still falls back to `TRIGGER_API_URL` because plumbing (where the API lives) is not identity. - `taskContext.{ctx, worker, isWarmStart, isInsideTask}` — masked inside an isolated scope so a `client.tasks.trigger(...)` from inside a task doesn't leak the parent's `parentRunId` / `lockToVersion` / `isTest` into a trigger that hits a different project. - Inline `getEnvVar("TRIGGER_VERSION")` reads in `shared.ts` go through a `scopedEnvVar` helper that returns `undefined` inside an isolated scope. The `TriggerClient` class itself is a thin wrapper that captures the scope in its constructor and proxies each namespace method to enter that scope before calling the existing impl. Generic inference (e.g. `client.tasks.trigger<typeof t>(...)`) is preserved via `Pick<typeof ns, keyof curatedSubset>` typings. Two correctness fixes uncovered along the way are folded in: - `apiClientManager.setGlobalAPIClientConfiguration` no longer silently no-ops on the second call. `configure()` now actually overrides as users expect (this is the root cause behind some "I changed the config but nothing happened" reports). - `apiClientManager.runWithConfig` (and therefore `auth.withAuth`) is now backed by `sdkScope.withScope` instead of "mutate the global and restore in finally". Two parallel `withAuth` calls with different configs no longer stomp each other. Surface curation: instance namespaces drop methods that don't make sense per-instance — `batch.*AndWait` (runtime-dependent), `schedules.task` / `schedules.timezones` (definition-time / stateless), `prompts.define` (definition-time), `auth.configure` / `auth.withAuth` (global-only). ## Test plan - [x] 9 runtime unit tests in `triggerClient.test.ts` cover: required accessToken, instance auth + branch headers, no env fallback for identity fields, no leakage between global and instance, four parallel calls across two clients stay isolated, taskContext masking + `inheritContext: true` override, `configure()` second-call override, parallel `auth.withAuth` isolation. - [x] 10 type-level assertions in `triggerClient.types.test.ts` using `expectTypeOf` + `@ts-expect-error` lock in generic inference, return type passthrough, overload preservation, and curated-surface drift. - [x] Full SDK suite (219 tests) and core suite (530 tests) pass. - [x] Webapp typecheck clean. - [x] End-to-end smoke test against local webapp and a freshly-provisioned cloud project — six concurrent multi-client triggers all returned 200 with run IDs, headers per-client as expected. - [ ] Reviewer: run `references/multi-client` per its `README.md` to reproduce the smoke test locally. ## Try it `references/multi-client` is a new reference workspace that exercises this end-to-end: - `src/trigger/echo.ts` — trivial target task - `src/trigger/fanOut.ts` — opens two `TriggerClient`s from inside a task, fires `echo` through each in parallel - `src/external/main.ts` — external Node script with two clients triggering `echo` sequentially and concurrently; logs every outgoing request's `authorization` + `x-trigger-branch` - `src/external/isolation.ts` — interleaves global `configure()` and an instance call, asserts the captured fetch sequence shows no leakage either way
1 parent 18d7144 commit 832cf72

26 files changed

Lines changed: 1088 additions & 22 deletions

.changeset/trigger-client.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Add `TriggerClient` for running multiple SDK clients side-by-side, each with its own auth, preview branch, and baseURL. Useful when a single process needs to trigger tasks or read runs across multiple projects, environments, or preview branches without mutating shared global state.
6+
7+
```ts
8+
import { TriggerClient } from "@trigger.dev/sdk";
9+
10+
const prod = new TriggerClient({ accessToken: process.env.TRIGGER_PROD_KEY });
11+
const preview = new TriggerClient({
12+
accessToken: process.env.TRIGGER_PREVIEW_KEY,
13+
previewBranch: "signup-flow",
14+
});
15+
16+
await prod.tasks.trigger("send-email", payload);
17+
await preview.runs.list({ status: ["COMPLETED"] });
18+
```

packages/core/package.json

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@
5555
"./v3/runEngineWorker": "./src/v3/runEngineWorker/index.ts",
5656
"./v3/machines": "./src/v3/machines/index.ts",
5757
"./v3/serverOnly": "./src/v3/serverOnly/index.ts",
58-
"./v3/isomorphic": "./src/v3/isomorphic/index.ts"
58+
"./v3/isomorphic": "./src/v3/isomorphic/index.ts",
59+
"./v3/sdk-scope-storage": "./src/v3/sdkScope/storage-node.ts"
5960
},
6061
"sourceDialects": [
6162
"@triggerdotdev/source"
@@ -162,12 +163,19 @@
162163
"v3/isomorphic": [
163164
"dist/commonjs/v3/isomorphic/index.d.ts"
164165
],
166+
"v3/sdk-scope-storage": [
167+
"dist/commonjs/v3/sdkScope/storage-node.d.ts"
168+
],
165169
"v3/test": [
166170
"dist/commonjs/v3/test/index.d.ts"
167171
]
168172
}
169173
},
170-
"sideEffects": false,
174+
"sideEffects": [
175+
"./dist/esm/v3/sdkScope/storage-node.js",
176+
"./dist/commonjs/v3/sdkScope/storage-node.js",
177+
"./src/v3/sdkScope/storage-node.ts"
178+
],
171179
"scripts": {
172180
"clean": "rimraf dist .tshy .tshy-build .turbo src/v3/vendor",
173181
"update-version": "tsx ../../scripts/updateVersion.ts",
@@ -622,6 +630,17 @@
622630
"types": "./dist/commonjs/v3/isomorphic/index.d.ts",
623631
"default": "./dist/commonjs/v3/isomorphic/index.js"
624632
}
633+
},
634+
"./v3/sdk-scope-storage": {
635+
"import": {
636+
"@triggerdotdev/source": "./src/v3/sdkScope/storage-node.ts",
637+
"types": "./dist/esm/v3/sdkScope/storage-node.d.ts",
638+
"default": "./dist/esm/v3/sdkScope/storage-node.js"
639+
},
640+
"require": {
641+
"types": "./dist/commonjs/v3/sdkScope/storage-node.d.ts",
642+
"default": "./dist/commonjs/v3/sdkScope/storage-node.js"
643+
}
625644
}
626645
},
627646
"type": "module",

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

Lines changed: 52 additions & 9 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,19 @@ export class APIClientManagerAPI {
3031
}
3132

3233
get baseURL(): string | undefined {
34+
const scoped = sdkScope.getStore();
35+
if (scoped) {
36+
return scoped.apiClientConfig.baseURL ?? "https://api.trigger.dev";
37+
}
3338
const config = this.#getConfig();
3439
return config?.baseURL ?? getEnvVar("TRIGGER_API_URL") ?? "https://api.trigger.dev";
3540
}
3641

3742
get accessToken(): string | undefined {
43+
const scoped = sdkScope.getStore();
44+
if (scoped) {
45+
return scoped.apiClientConfig.accessToken ?? scoped.apiClientConfig.secretKey;
46+
}
3847
const config = this.#getConfig();
3948
return (
4049
config?.secretKey ??
@@ -45,6 +54,11 @@ export class APIClientManagerAPI {
4554
}
4655

4756
get branchName(): string | undefined {
57+
const scoped = sdkScope.getStore();
58+
if (scoped) {
59+
const value = scoped.apiClientConfig.previewBranch;
60+
return value ? value : undefined;
61+
}
4862
const config = this.#getConfig();
4963
const value =
5064
config?.previewBranch ??
@@ -54,13 +68,33 @@ export class APIClientManagerAPI {
5468
return value ? value : undefined;
5569
}
5670

71+
public resolveApiClientConfig(partial: ApiClientConfiguration = {}): ApiClientConfiguration {
72+
return {
73+
baseURL: partial.baseURL ?? getEnvVar("TRIGGER_API_URL"),
74+
accessToken:
75+
partial.accessToken ??
76+
partial.secretKey ??
77+
getEnvVar("TRIGGER_SECRET_KEY") ??
78+
getEnvVar("TRIGGER_ACCESS_TOKEN"),
79+
secretKey: partial.secretKey,
80+
previewBranch:
81+
partial.previewBranch ??
82+
getEnvVar("TRIGGER_PREVIEW_BRANCH") ??
83+
getEnvVar("VERCEL_GIT_COMMIT_REF"),
84+
requestOptions: partial.requestOptions,
85+
future: partial.future,
86+
};
87+
}
88+
5789
get client(): ApiClient | undefined {
5890
if (!this.baseURL || !this.accessToken) {
5991
return undefined;
6092
}
6193

62-
const requestOptions = this.#getConfig()?.requestOptions;
63-
const futureFlags = this.#getConfig()?.future;
94+
const scoped = sdkScope.getStore();
95+
const source = scoped?.apiClientConfig ?? this.#getConfig();
96+
const requestOptions = source?.requestOptions;
97+
const futureFlags = source?.future;
6498

6599
return new ApiClient(this.baseURL, this.accessToken, this.branchName, requestOptions, futureFlags);
66100
}
@@ -74,8 +108,10 @@ export class APIClientManagerAPI {
74108
}
75109

76110
const branchName = config?.previewBranch ?? this.branchName;
77-
const requestOptions = config?.requestOptions ?? this.#getConfig()?.requestOptions;
78-
const futureFlags = config?.future ?? this.#getConfig()?.future;
111+
const scoped = sdkScope.getStore();
112+
const source = scoped?.apiClientConfig ?? this.#getConfig();
113+
const requestOptions = config?.requestOptions ?? source?.requestOptions;
114+
const futureFlags = config?.future ?? source?.future;
79115

80116
return new ApiClient(baseURL, accessToken, branchName, requestOptions, futureFlags);
81117
}
@@ -84,17 +120,24 @@ export class APIClientManagerAPI {
84120
config: ApiClientConfiguration,
85121
fn: R
86122
): Promise<ReturnType<R>> {
87-
const originalConfig = this.#getConfig();
88-
const $config = { ...originalConfig, ...config };
89-
registerGlobal(API_NAME, $config, true);
123+
const current = sdkScope.getStore()?.apiClientConfig ?? this.#getConfig();
124+
const merged = this.resolveApiClientConfig({ ...current, ...config });
125+
126+
if (sdkScope.hasStorage()) {
127+
return sdkScope.withScope({ apiClientConfig: merged, inheritContext: true }, fn);
128+
}
90129

130+
// No ALS available (browser, edge, workers). Fall back to in-place
131+
// mutation — same as pre-existing behavior, not concurrency-safe.
132+
const original = this.#getConfig();
133+
registerGlobal(API_NAME, merged, true);
91134
return fn().finally(() => {
92-
registerGlobal(API_NAME, originalConfig, true);
135+
registerGlobal(API_NAME, original, true);
93136
});
94137
}
95138

96139
public setGlobalAPIClientConfiguration(config: ApiClientConfiguration): boolean {
97-
return registerGlobal(API_NAME, config);
140+
return registerGlobal(API_NAME, config, true);
98141
}
99142

100143
#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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { SdkScope, SdkScopeStorage } from "./types.js";
2+
3+
export type { SdkScope, SdkScopeStorage } from "./types.js";
4+
5+
let installedStorage: SdkScopeStorage | undefined;
6+
7+
export function _installSdkScopeStorage(storage: SdkScopeStorage): void {
8+
installedStorage = storage;
9+
}
10+
11+
export const sdkScope = {
12+
hasStorage(): boolean {
13+
return installedStorage !== undefined;
14+
},
15+
getStore(): SdkScope | undefined {
16+
return installedStorage?.getStore();
17+
},
18+
withScope<R>(scope: SdkScope, fn: () => R): R {
19+
return installedStorage ? installedStorage.run(scope, fn) : fn();
20+
},
21+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { AsyncLocalStorage } from "node:async_hooks";
2+
import { _installSdkScopeStorage } from "./index.js";
3+
import type { SdkScope } from "./types.js";
4+
5+
const als = new AsyncLocalStorage<SdkScope>();
6+
7+
_installSdkScopeStorage({
8+
getStore: () => als.getStore(),
9+
run: (scope, fn) => als.run(scope, fn),
10+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { ApiClientConfiguration } from "../apiClientManager/types.js";
2+
3+
export type SdkScope = {
4+
apiClientConfig: ApiClientConfiguration;
5+
inheritContext: boolean;
6+
};
7+
8+
export type SdkScopeStorage = {
9+
getStore(): SdkScope | undefined;
10+
run<R>(scope: SdkScope, fn: () => R): R;
11+
};

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/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"directory": "packages/trigger-sdk"
1313
},
1414
"type": "module",
15+
"sideEffects": false,
1516
"files": [
1617
"dist"
1718
],

0 commit comments

Comments
 (0)