Skip to content

Commit dcc1101

Browse files
committed
feat(core,sdk): TriggerClient falls back to env vars at construction
`new TriggerClient()` with no constructor config now resolves accessToken, previewBranch, and baseURL from the process env (TRIGGER_SECRET_KEY / TRIGGER_PREVIEW_BRANCH / TRIGGER_API_URL / VERCEL_GIT_COMMIT_REF / TRIGGER_ACCESS_TOKEN). Explicit constructor values still win, so multiple instances pointing at different projects stay isolated. Matches conventions of other env-var-backed SDKs (OpenAI, Anthropic, Stripe) and removes friction of forcing \`accessToken: process.env.TRIGGER_SECRET_KEY!\` everywhere. Mechanics: new \`apiClientManager.resolveApiClientConfig(partial)\` helper resolves env-derived defaults for missing fields. Both the TriggerClient constructor and \`apiClientManager.runWithConfig\` (used by auth.withAuth) feed their config through it before opening a scope, so the resolution happens once at scope creation and the scoped getters in apiClientManager just read scope values directly. Single source of truth replaces the inheritContext-gated env fallback that was previously sprinkled across the scoped getters. Constructor early throw dropped — missing auth now surfaces via ApiClientMissingError at first API call, same as the global API path.
1 parent 2bf9ae4 commit dcc1101

6 files changed

Lines changed: 66 additions & 103 deletions

File tree

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

Lines changed: 24 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,9 @@ export class APIClientManagerAPI {
3131
}
3232

3333
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(...)`.
3834
const scoped = sdkScope.getStore();
3935
if (scoped) {
40-
return (
41-
scoped.apiClientConfig.baseURL ??
42-
getEnvVar("TRIGGER_API_URL") ??
43-
"https://api.trigger.dev"
44-
);
36+
return scoped.apiClientConfig.baseURL ?? "https://api.trigger.dev";
4537
}
4638
const config = this.#getConfig();
4739
return config?.baseURL ?? getEnvVar("TRIGGER_API_URL") ?? "https://api.trigger.dev";
@@ -50,18 +42,7 @@ export class APIClientManagerAPI {
5042
get accessToken(): string | undefined {
5143
const scoped = sdkScope.getStore();
5244
if (scoped) {
53-
const value = scoped.apiClientConfig.accessToken ?? scoped.apiClientConfig.secretKey;
54-
if (value !== undefined) return value;
55-
// `inheritContext: true` scopes (e.g. `auth.withAuth` partial
56-
// overrides) still fall back to the process env so callers who
57-
// rely on TRIGGER_SECRET_KEY don't lose auth when they only
58-
// wanted to override baseURL. Isolated scopes (TriggerClient)
59-
// intentionally do not fall back — the constructor enforces
60-
// accessToken is provided.
61-
if (scoped.inheritContext) {
62-
return getEnvVar("TRIGGER_SECRET_KEY") ?? getEnvVar("TRIGGER_ACCESS_TOKEN");
63-
}
64-
return undefined;
45+
return scoped.apiClientConfig.accessToken ?? scoped.apiClientConfig.secretKey;
6546
}
6647
const config = this.#getConfig();
6748
return (
@@ -76,16 +57,7 @@ export class APIClientManagerAPI {
7657
const scoped = sdkScope.getStore();
7758
if (scoped) {
7859
const value = scoped.apiClientConfig.previewBranch;
79-
if (value) return value;
80-
// Same inheritContext gating as accessToken: withAuth-style
81-
// scopes inherit env-derived branch; TriggerClient instances
82-
// stay isolated from process env for identity.
83-
if (scoped.inheritContext) {
84-
const envValue =
85-
getEnvVar("TRIGGER_PREVIEW_BRANCH") ?? getEnvVar("VERCEL_GIT_COMMIT_REF");
86-
return envValue ? envValue : undefined;
87-
}
88-
return undefined;
60+
return value ? value : undefined;
8961
}
9062
const config = this.#getConfig();
9163
const value =
@@ -96,6 +68,24 @@ export class APIClientManagerAPI {
9668
return value ? value : undefined;
9769
}
9870

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+
9989
get client(): ApiClient | undefined {
10090
if (!this.baseURL || !this.accessToken) {
10191
return undefined;
@@ -130,18 +120,14 @@ export class APIClientManagerAPI {
130120
config: ApiClientConfiguration,
131121
fn: R
132122
): Promise<ReturnType<R>> {
133-
const merged: ApiClientConfiguration = { ...this.#getConfig(), ...config };
123+
const merged = this.resolveApiClientConfig({ ...this.#getConfig(), ...config });
134124

135-
// Use the AsyncLocalStorage scope when installed (Node-side code
136-
// that has loaded TriggerClient or auth) — concurrency-safe.
137125
if (sdkScope.hasStorage()) {
138126
return sdkScope.withScope({ apiClientConfig: merged, inheritContext: true }, fn);
139127
}
140128

141-
// Fallback: in-place global mutation. Matches pre-existing behavior
142-
// and works in any runtime (browser, Edge, Workers, Node without
143-
// the storage installed). Not concurrency-safe — parallel callers
144-
// with different configs will stomp on each other.
129+
// No ALS available (browser, edge, workers). Fall back to in-place
130+
// mutation — same as pre-existing behavior, not concurrency-safe.
145131
const original = this.#getConfig();
146132
registerGlobal(API_NAME, merged, true);
147133
return fn().finally(() => {

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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,6 @@ import type { SdkScope, SdkScopeStorage } from "./types.js";
22

33
export type { SdkScope, SdkScopeStorage } from "./types.js";
44

5-
// Storage slot. Filled at runtime by a Node-only module
6-
// (`@trigger.dev/core/v3/sdk-scope-storage`) that owns the
7-
// AsyncLocalStorage instance. Left undefined in environments that
8-
// never import that module (browsers, edge runtimes), where
9-
// `sdkScope.withScope` falls through to invoking the callback
10-
// directly. `sdkScope/index.ts` deliberately does not statically
11-
// import `node:async_hooks` or `storage-node.ts` so it is safe to
12-
// include in any browser-side bundle that reaches `@trigger.dev/core/v3`.
135
let installedStorage: SdkScopeStorage | undefined;
146

157
export function _installSdkScopeStorage(storage: SdkScopeStorage): void {

packages/core/src/v3/sdkScope/storage-node.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@ import { AsyncLocalStorage } from "node:async_hooks";
22
import { _installSdkScopeStorage } from "./index.js";
33
import type { SdkScope } from "./types.js";
44

5-
// Importing this module installs an AsyncLocalStorage-backed
6-
// `SdkScopeStorage` into the slot exposed by `sdkScope/index.ts`. The
7-
// SDK side-effect-imports this from server-only modules
8-
// (TriggerClient, auth) so that browser-bundled code that never
9-
// touches those modules never pulls `node:async_hooks` either.
105
const als = new AsyncLocalStorage<SdkScope>();
116

127
_installSdkScopeStorage({

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@ import {
44
RealtimeRunSkipColumns,
55
} from "@trigger.dev/core/v3";
66
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
7-
8-
// Install the Node AsyncLocalStorage-backed storage so `auth.withAuth`
9-
// (and the public-token helpers that route through it) actually scope
10-
// API client config. See `triggerClient.ts` for the same import.
117
import "@trigger.dev/core/v3/sdk-scope-storage";
128

139
/**

packages/trigger-sdk/src/v3/triggerClient.test.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,21 @@ describe("TriggerClient", () => {
5252
vi.unstubAllEnvs();
5353
});
5454

55-
it("requires an accessToken at construction", () => {
56-
expect(() => new TriggerClient({})).toThrow(/accessToken/);
55+
it("throws on first API call when no accessToken is configured anywhere", () => {
56+
const client = new TriggerClient();
57+
expect(() => client.runs.list({ limit: 1 })).toThrow(/TRIGGER_SECRET_KEY/);
58+
});
59+
60+
it("falls back to env vars when constructor config is empty", async () => {
61+
vi.stubEnv("TRIGGER_SECRET_KEY", "tr_dev_env_token");
62+
vi.stubEnv("TRIGGER_PREVIEW_BRANCH", "env-branch");
63+
64+
const client = new TriggerClient();
65+
await client.runs.retrieve("run_abc").catch(() => undefined);
66+
67+
expect(fetchSpy.captured).toHaveLength(1);
68+
expect(fetchSpy.captured[0]!.authorization).toBe("Bearer tr_dev_env_token");
69+
expect(fetchSpy.captured[0]!.branch).toBe("env-branch");
5770
});
5871

5972
it("uses the instance accessToken and previewBranch on outgoing requests", async () => {
@@ -70,24 +83,31 @@ describe("TriggerClient", () => {
7083
expect(req.branch).toBe("signup-flow");
7184
});
7285

73-
it("does not fall back to env vars for identity fields, but DOES for baseURL", async () => {
74-
vi.stubEnv("TRIGGER_PREVIEW_BRANCH", "from-env-branch");
75-
vi.stubEnv("TRIGGER_API_URL", "https://from-env.example.com");
86+
it("fills missing fields from env, but explicit constructor values still win", async () => {
87+
vi.stubEnv("TRIGGER_SECRET_KEY", "tr_env_token");
88+
vi.stubEnv("TRIGGER_PREVIEW_BRANCH", "env-branch");
89+
vi.stubEnv("TRIGGER_API_URL", "https://env.example.com");
7690

77-
const client = new TriggerClient({
78-
accessToken: "tr_preview_instance_token",
79-
// no previewBranch, no baseURL
91+
const explicit = new TriggerClient({
92+
accessToken: "tr_explicit",
93+
previewBranch: "explicit-branch",
8094
});
95+
const fromEnv = new TriggerClient();
8196

82-
await client.runs.retrieve("run_abc").catch(() => undefined);
97+
await Promise.all([
98+
explicit.runs.retrieve("run_a").catch(() => undefined),
99+
fromEnv.runs.retrieve("run_b").catch(() => undefined),
100+
]);
83101

84-
expect(fetchSpy.captured).toHaveLength(1);
85-
const req = fetchSpy.captured[0]!;
86-
// Identity (branch) must NOT be filled from env when instance is used.
87-
expect(req.branch).toBeUndefined();
88-
// Plumbing (baseURL) DOES fall back to TRIGGER_API_URL so local-dev /
89-
// CI overrides apply without forcing every consumer to pass baseURL.
90-
expect(req.url, `actual url=${req.url}`).toMatch(/^https:\/\/from-env\.example\.com\//);
102+
const byRun = Object.fromEntries(
103+
fetchSpy.captured.map((r) => [r.url.split("/runs/")[1]?.split(/[/?]/)[0], r])
104+
);
105+
106+
expect(byRun["run_a"]!.authorization).toBe("Bearer tr_explicit");
107+
expect(byRun["run_a"]!.branch).toBe("explicit-branch");
108+
expect(byRun["run_b"]!.authorization).toBe("Bearer tr_env_token");
109+
expect(byRun["run_b"]!.branch).toBe("env-branch");
110+
expect(byRun["run_a"]!.url.startsWith("https://env.example.com/")).toBe(true);
91111
});
92112

93113
it("does not leak instance config to the global apiClientManager", async () => {

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

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
import {
22
type ApiClientConfiguration,
3+
apiClientManager,
34
sdkScope,
45
type SdkScope,
56
} from "@trigger.dev/core/v3";
6-
7-
// Install the Node AsyncLocalStorage-backed storage. Kept as a
8-
// side-effect import so it is never reached from browser bundles
9-
// that don't transitively import TriggerClient (relies on
10-
// `sideEffects: false` in this package + the v3 root not importing
11-
// storage-node statically).
127
import "@trigger.dev/core/v3/sdk-scope-storage";
138

149
import { auth } from "./auth.js";
@@ -26,21 +21,10 @@ import {
2621
} from "./shared.js";
2722

2823
export type TriggerClientConfig = ApiClientConfiguration & {
29-
/**
30-
* When `true`, instance methods inherit the ambient task context
31-
* (`parentRunId`, `lockToVersion`, `isTest`, current task's `taskContext`)
32-
* when invoked from inside a task. Default `false` — instance calls are
33-
* fully isolated from the surrounding task runtime, which is what you
34-
* want when the instance points at a different project, environment, or
35-
* preview branch than the task is running in.
36-
*/
24+
/** Inherit ambient task context (parentRunId, lockToVersion, isTest) when called from inside a task. Default `false`. */
3725
inheritContext?: boolean;
3826
};
3927

40-
// Curated instance surfaces — drop methods that are inside-task-only
41-
// (e.g. `batch.triggerAndWait`, which depends on the runtime manager) or
42-
// task-definition-time (e.g. `schedules.task`, `prompts.define`), and
43-
// drop helpers that don't need a client (`schedules.timezones`).
4428
const tasksApi = { trigger, batchTrigger, triggerAndSubscribe };
4529
const batchInstanceKeys = ["trigger", "triggerByTask", "retrieve"] as const;
4630
const schedulesInstanceKeys = [
@@ -89,21 +73,11 @@ export class TriggerClient {
8973
readonly schedules: SchedulesApi;
9074
readonly auth: AuthApi;
9175

92-
constructor(config: TriggerClientConfig) {
93-
if (!config.accessToken && !config.secretKey) {
94-
throw new Error("TriggerClient: accessToken (or secretKey) is required");
95-
}
96-
76+
constructor(config: TriggerClientConfig = {}) {
77+
const { inheritContext, ...partial } = config;
9778
const scope: SdkScope = {
98-
apiClientConfig: {
99-
baseURL: config.baseURL,
100-
accessToken: config.accessToken,
101-
secretKey: config.secretKey,
102-
previewBranch: config.previewBranch,
103-
requestOptions: config.requestOptions,
104-
future: config.future,
105-
},
106-
inheritContext: config.inheritContext ?? false,
79+
apiClientConfig: apiClientManager.resolveApiClientConfig(partial),
80+
inheritContext: inheritContext ?? false,
10781
};
10882

10983
this.tasks = bindToScope(tasksApi, scope);

0 commit comments

Comments
 (0)