From fd7bec7c35b0ed890539d7c1482c40cd27771ffe Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 21 May 2026 10:49:19 +0100 Subject: [PATCH 1/8] 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. --- .changeset/trigger-client.md | 23 ++ .../core/src/v3/apiClientManager/index.ts | 45 +++- packages/core/src/v3/index.ts | 1 + packages/core/src/v3/sdkScope-api.ts | 1 + packages/core/src/v3/sdkScope/index.ts | 18 ++ packages/core/src/v3/taskContext/index.ts | 10 + packages/trigger-sdk/src/v3/index.ts | 1 + packages/trigger-sdk/src/v3/shared.ts | 21 +- .../trigger-sdk/src/v3/triggerClient.test.ts | 244 ++++++++++++++++++ packages/trigger-sdk/src/v3/triggerClient.ts | 129 +++++++++ .../src/v3/triggerClient.types.test.ts | 116 +++++++++ pnpm-lock.yaml | 30 ++- references/multi-client/README.md | 45 ++++ references/multi-client/package.json | 21 ++ .../multi-client/src/external/isolation.ts | 93 +++++++ references/multi-client/src/external/main.ts | 101 ++++++++ references/multi-client/src/index.ts | 1 + references/multi-client/src/trigger/echo.ts | 19 ++ references/multi-client/src/trigger/fanOut.ts | 65 +++++ references/multi-client/trigger.config.ts | 19 ++ references/multi-client/tsconfig.json | 14 + 21 files changed, 994 insertions(+), 23 deletions(-) create mode 100644 .changeset/trigger-client.md create mode 100644 packages/core/src/v3/sdkScope-api.ts create mode 100644 packages/core/src/v3/sdkScope/index.ts create mode 100644 packages/trigger-sdk/src/v3/triggerClient.test.ts create mode 100644 packages/trigger-sdk/src/v3/triggerClient.ts create mode 100644 packages/trigger-sdk/src/v3/triggerClient.types.test.ts create mode 100644 references/multi-client/README.md create mode 100644 references/multi-client/package.json create mode 100644 references/multi-client/src/external/isolation.ts create mode 100644 references/multi-client/src/external/main.ts create mode 100644 references/multi-client/src/index.ts create mode 100644 references/multi-client/src/trigger/echo.ts create mode 100644 references/multi-client/src/trigger/fanOut.ts create mode 100644 references/multi-client/trigger.config.ts create mode 100644 references/multi-client/tsconfig.json diff --git a/.changeset/trigger-client.md b/.changeset/trigger-client.md new file mode 100644 index 00000000000..6aeca10c18f --- /dev/null +++ b/.changeset/trigger-client.md @@ -0,0 +1,23 @@ +--- +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +--- + +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. + +```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"] }); +``` + +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. + +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). diff --git a/packages/core/src/v3/apiClientManager/index.ts b/packages/core/src/v3/apiClientManager/index.ts index 96a4bc8e534..c6e5710980d 100644 --- a/packages/core/src/v3/apiClientManager/index.ts +++ b/packages/core/src/v3/apiClientManager/index.ts @@ -1,6 +1,7 @@ import { ApiClient } from "../apiClient/index.js"; import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js"; import { getEnvVar } from "../utils/getEnv.js"; +import { sdkScope } from "../sdkScope/index.js"; import { ApiClientConfiguration } from "./types.js"; const API_NAME = "api-client"; @@ -30,11 +31,27 @@ export class APIClientManagerAPI { } get baseURL(): string | undefined { + // baseURL is plumbing (where the API lives), not identity. Scoped + // instances read their own config first but still fall back to the + // process-level TRIGGER_API_URL so local-dev / CI overrides don't + // require passing baseURL into every `new TriggerClient(...)`. + const scoped = sdkScope.getStore(); + if (scoped) { + return ( + scoped.apiClientConfig.baseURL ?? + getEnvVar("TRIGGER_API_URL") ?? + "https://api.trigger.dev" + ); + } const config = this.#getConfig(); return config?.baseURL ?? getEnvVar("TRIGGER_API_URL") ?? "https://api.trigger.dev"; } get accessToken(): string | undefined { + const scoped = sdkScope.getStore(); + if (scoped) { + return scoped.apiClientConfig.accessToken ?? scoped.apiClientConfig.secretKey; + } const config = this.#getConfig(); return ( config?.secretKey ?? @@ -45,6 +62,11 @@ export class APIClientManagerAPI { } get branchName(): string | undefined { + const scoped = sdkScope.getStore(); + if (scoped) { + const value = scoped.apiClientConfig.previewBranch ?? undefined; + return value ? value : undefined; + } const config = this.#getConfig(); const value = config?.previewBranch ?? @@ -59,8 +81,10 @@ export class APIClientManagerAPI { return undefined; } - const requestOptions = this.#getConfig()?.requestOptions; - const futureFlags = this.#getConfig()?.future; + const scoped = sdkScope.getStore(); + const source = scoped?.apiClientConfig ?? this.#getConfig(); + const requestOptions = source?.requestOptions; + const futureFlags = source?.future; return new ApiClient(this.baseURL, this.accessToken, this.branchName, requestOptions, futureFlags); } @@ -74,8 +98,10 @@ export class APIClientManagerAPI { } const branchName = config?.previewBranch ?? this.branchName; - const requestOptions = config?.requestOptions ?? this.#getConfig()?.requestOptions; - const futureFlags = config?.future ?? this.#getConfig()?.future; + const scoped = sdkScope.getStore(); + const source = scoped?.apiClientConfig ?? this.#getConfig(); + const requestOptions = config?.requestOptions ?? source?.requestOptions; + const futureFlags = config?.future ?? source?.future; return new ApiClient(baseURL, accessToken, branchName, requestOptions, futureFlags); } @@ -84,17 +110,12 @@ export class APIClientManagerAPI { config: ApiClientConfiguration, fn: R ): Promise> { - const originalConfig = this.#getConfig(); - const $config = { ...originalConfig, ...config }; - registerGlobal(API_NAME, $config, true); - - return fn().finally(() => { - registerGlobal(API_NAME, originalConfig, true); - }); + const merged: ApiClientConfiguration = { ...this.#getConfig(), ...config }; + return sdkScope.withScope({ apiClientConfig: merged, inheritContext: true }, fn); } public setGlobalAPIClientConfiguration(config: ApiClientConfiguration): boolean { - return registerGlobal(API_NAME, config); + return registerGlobal(API_NAME, config, true); } #getConfig(): ApiClientConfiguration | undefined { diff --git a/packages/core/src/v3/index.ts b/packages/core/src/v3/index.ts index 72b91c46071..9052884bbd6 100644 --- a/packages/core/src/v3/index.ts +++ b/packages/core/src/v3/index.ts @@ -11,6 +11,7 @@ export * from "./runtime-api.js"; export * from "./task-context-api.js"; export * from "./trace-context-api.js"; export * from "./apiClientManager-api.js"; +export * from "./sdkScope-api.js"; export * from "./usage-api.js"; export * from "./run-metadata-api.js"; export * from "./wait-until-api.js"; diff --git a/packages/core/src/v3/sdkScope-api.ts b/packages/core/src/v3/sdkScope-api.ts new file mode 100644 index 00000000000..ea3906aa97e --- /dev/null +++ b/packages/core/src/v3/sdkScope-api.ts @@ -0,0 +1 @@ +export { sdkScope, type SdkScope } from "./sdkScope/index.js"; diff --git a/packages/core/src/v3/sdkScope/index.ts b/packages/core/src/v3/sdkScope/index.ts new file mode 100644 index 00000000000..2dd11de06f2 --- /dev/null +++ b/packages/core/src/v3/sdkScope/index.ts @@ -0,0 +1,18 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { ApiClientConfiguration } from "../apiClientManager/types.js"; + +export type SdkScope = { + apiClientConfig: ApiClientConfiguration; + inheritContext: boolean; +}; + +const storage = new AsyncLocalStorage(); + +export const sdkScope = { + getStore(): SdkScope | undefined { + return storage.getStore(); + }, + withScope(scope: SdkScope, fn: () => R): R { + return storage.run(scope, fn); + }, +}; diff --git a/packages/core/src/v3/taskContext/index.ts b/packages/core/src/v3/taskContext/index.ts index ecbfa184a6b..c4bbf25c972 100644 --- a/packages/core/src/v3/taskContext/index.ts +++ b/packages/core/src/v3/taskContext/index.ts @@ -1,6 +1,7 @@ import { Attributes } from "@opentelemetry/api"; import { ServerBackgroundWorker, TaskRunContext } from "../schemas/index.js"; import { SemanticInternalAttributes } from "../semanticInternalAttributes.js"; +import { sdkScope } from "../sdkScope/index.js"; import { getGlobal, registerGlobal } from "../utils/globals.js"; import { TaskContext } from "./types.js"; @@ -22,6 +23,7 @@ export class TaskContextAPI { } get isInsideTask(): boolean { + if (this.#isolatedFromContext()) return false; return this.#getTaskContext() !== undefined; } @@ -30,17 +32,25 @@ export class TaskContextAPI { } get ctx(): TaskRunContext | undefined { + if (this.#isolatedFromContext()) return undefined; return this.#getTaskContext()?.ctx; } get worker(): ServerBackgroundWorker | undefined { + if (this.#isolatedFromContext()) return undefined; return this.#getTaskContext()?.worker; } get isWarmStart(): boolean | undefined { + if (this.#isolatedFromContext()) return undefined; return this.#getTaskContext()?.isWarmStart; } + #isolatedFromContext(): boolean { + const scope = sdkScope.getStore(); + return !!scope && !scope.inheritContext; + } + get attributes(): Attributes { if (this.ctx) { return { diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index 5e169cbb8d6..f993105f0bd 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -67,5 +67,6 @@ export * as queues from "./queues.js"; export type { ImportEnvironmentVariablesParams } from "./envvars.js"; export { configure, auth } from "./auth.js"; +export { TriggerClient, type TriggerClientConfig } from "./triggerClient.js"; export * as prompts from "./prompts.js"; export * as skills from "./skills.js"; diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index b8e1874b5be..545594f4826 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -22,6 +22,7 @@ import { RateLimitError, resourceCatalog, runtime, + sdkScope, SemanticInternalAttributes, stringifyIO, SubtaskUnwrapError, @@ -129,6 +130,12 @@ export { SubtaskUnwrapError, TaskRunPromise }; export type Context = TaskRunContext; +function scopedEnvVar(name: string): string | undefined { + const scope = sdkScope.getStore(); + if (scope && !scope.inheritContext) return undefined; + return getEnvVar(name); +} + export function queue(options: QueueOptions): Queue { resourceCatalog.registerQueueMetadata(options); @@ -740,7 +747,7 @@ export async function batchTriggerById( machine: item.options?.machine, priority: item.options?.priority, region: item.options?.region, - lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"), + lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"), debounce: item.options?.debounce, }, }; @@ -1256,7 +1263,7 @@ export async function batchTriggerTasks( machine: item.options?.machine, priority: item.options?.priority, region: item.options?.region, - lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"), + lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"), debounce: item.options?.debounce, }, }; @@ -1920,7 +1927,7 @@ async function* transformBatchItemsStream( machine: item.options?.machine, priority: item.options?.priority, region: item.options?.region, - lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"), + lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"), debounce: item.options?.debounce, }, }; @@ -2023,7 +2030,7 @@ async function* transformBatchByTaskItemsStream( machine: item.options?.machine, priority: item.options?.priority, region: item.options?.region, - lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"), + lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"), debounce: item.options?.debounce, }, }; @@ -2236,7 +2243,7 @@ async function trigger_internal( machine: options?.machine, priority: options?.priority, region: options?.region, - lockToVersion: options?.version ?? getEnvVar("TRIGGER_VERSION"), + lockToVersion: options?.version ?? scopedEnvVar("TRIGGER_VERSION"), debounce: options?.debounce, }, }, @@ -2322,7 +2329,7 @@ async function batchTrigger_internal( machine: item.options?.machine, priority: item.options?.priority, region: item.options?.region, - lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"), + lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"), }, }; }) diff --git a/packages/trigger-sdk/src/v3/triggerClient.test.ts b/packages/trigger-sdk/src/v3/triggerClient.test.ts new file mode 100644 index 00000000000..c0d4a557ebf --- /dev/null +++ b/packages/trigger-sdk/src/v3/triggerClient.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { apiClientManager, sdkScope, taskContext } from "@trigger.dev/core/v3"; +import { auth, configure } from "./auth.js"; +import { runs } from "./runs.js"; +import { TriggerClient } from "./triggerClient.js"; + +type CapturedRequest = { + url: string; + authorization: string | undefined; + branch: string | undefined; +}; + +function installFetchSpy() { + const captured: CapturedRequest[] = []; + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (input: any, init?: RequestInit) => { + const url = typeof input === "string" ? input : input?.url ?? String(input); + const headers = new Headers(init?.headers); + captured.push({ + url, + authorization: headers.get("authorization") ?? undefined, + branch: headers.get("x-trigger-branch") ?? undefined, + }); + // Return a fake successful response shaped like an empty run retrieval. + return new Response(JSON.stringify({}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + return { + captured, + restore: () => { + globalThis.fetch = originalFetch; + }, + }; +} + +describe("TriggerClient", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + apiClientManager.disable(); + fetchSpy = installFetchSpy(); + }); + + afterEach(() => { + fetchSpy.restore(); + apiClientManager.disable(); + vi.unstubAllEnvs(); + }); + + it("requires an accessToken at construction", () => { + expect(() => new TriggerClient({})).toThrow(/accessToken/); + }); + + it("uses the instance accessToken and previewBranch on outgoing requests", async () => { + const client = new TriggerClient({ + accessToken: "tr_preview_instance_token", + previewBranch: "signup-flow", + }); + + await client.runs.retrieve("run_abc").catch(() => undefined); + + expect(fetchSpy.captured).toHaveLength(1); + const req = fetchSpy.captured[0]!; + expect(req.authorization).toBe("Bearer tr_preview_instance_token"); + expect(req.branch).toBe("signup-flow"); + }); + + it("does not fall back to env vars for identity fields, but DOES for baseURL", async () => { + vi.stubEnv("TRIGGER_PREVIEW_BRANCH", "from-env-branch"); + vi.stubEnv("TRIGGER_API_URL", "https://from-env.example.com"); + + const client = new TriggerClient({ + accessToken: "tr_preview_instance_token", + // no previewBranch, no baseURL + }); + + await client.runs.retrieve("run_abc").catch(() => undefined); + + expect(fetchSpy.captured).toHaveLength(1); + const req = fetchSpy.captured[0]!; + // Identity (branch) must NOT be filled from env when instance is used. + expect(req.branch).toBeUndefined(); + // Plumbing (baseURL) DOES fall back to TRIGGER_API_URL so local-dev / + // CI overrides apply without forcing every consumer to pass baseURL. + expect(req.url, `actual url=${req.url}`).toMatch(/^https:\/\/from-env\.example\.com\//); + }); + + it("does not leak instance config to the global apiClientManager", async () => { + configure({ accessToken: "tr_dev_global_token" }); + + const client = new TriggerClient({ + accessToken: "tr_preview_instance_token", + previewBranch: "signup-flow", + }); + + await client.runs.retrieve("run_instance").catch(() => undefined); + await runs.retrieve("run_global").catch(() => undefined); + + expect(fetchSpy.captured).toHaveLength(2); + expect(fetchSpy.captured[0]!.authorization).toBe("Bearer tr_preview_instance_token"); + expect(fetchSpy.captured[0]!.branch).toBe("signup-flow"); + expect(fetchSpy.captured[1]!.authorization).toBe("Bearer tr_dev_global_token"); + expect(fetchSpy.captured[1]!.branch).toBeUndefined(); + }); + + it("keeps two concurrent instances isolated from each other", async () => { + const prod = new TriggerClient({ accessToken: "tr_prod_key" }); + const preview = new TriggerClient({ + accessToken: "tr_preview_key", + previewBranch: "feature-x", + }); + + await Promise.all([ + prod.runs.retrieve("run_a").catch(() => undefined), + preview.runs.retrieve("run_b").catch(() => undefined), + prod.runs.retrieve("run_c").catch(() => undefined), + preview.runs.retrieve("run_d").catch(() => undefined), + ]); + + expect(fetchSpy.captured).toHaveLength(4); + const byPath = Object.fromEntries( + fetchSpy.captured.map((r) => [r.url.split("/runs/")[1]?.split(/[/?]/)[0], r]) + ); + + expect(byPath["run_a"]!.authorization).toBe("Bearer tr_prod_key"); + expect(byPath["run_a"]!.branch).toBeUndefined(); + expect(byPath["run_c"]!.authorization).toBe("Bearer tr_prod_key"); + expect(byPath["run_c"]!.branch).toBeUndefined(); + expect(byPath["run_b"]!.authorization).toBe("Bearer tr_preview_key"); + expect(byPath["run_b"]!.branch).toBe("feature-x"); + expect(byPath["run_d"]!.authorization).toBe("Bearer tr_preview_key"); + expect(byPath["run_d"]!.branch).toBe("feature-x"); + }); + + it("masks taskContext.ctx inside an isolated scope (default)", () => { + const fakeCtx = { + run: { id: "run_parent", isTest: true }, + project: { ref: "proj_xyz" }, + environment: { slug: "preview" }, + } as any; + + taskContext.setGlobalTaskContext({ ctx: fakeCtx } as any); + expect(taskContext.ctx).toBe(fakeCtx); + + const observed = sdkScope.withScope( + { apiClientConfig: { accessToken: "x" }, inheritContext: false }, + () => taskContext.ctx + ); + + expect(observed).toBeUndefined(); + + taskContext.disable(); + }); + + it("exposes taskContext.ctx inside a scope when inheritContext is true", () => { + const fakeCtx = { + run: { id: "run_parent", isTest: true }, + project: { ref: "proj_xyz" }, + environment: { slug: "preview" }, + } as any; + + taskContext.setGlobalTaskContext({ ctx: fakeCtx } as any); + + const observed = sdkScope.withScope( + { apiClientConfig: { accessToken: "x" }, inheritContext: true }, + () => taskContext.ctx + ); + + expect(observed).toBe(fakeCtx); + + taskContext.disable(); + }); +}); + +describe("configure()", () => { + beforeEach(() => { + apiClientManager.disable(); + }); + + afterEach(() => { + apiClientManager.disable(); + }); + + it("overrides previously-set configuration on a second call", async () => { + configure({ accessToken: "tr_first" }); + expect(apiClientManager.accessToken).toBe("tr_first"); + + configure({ accessToken: "tr_second", previewBranch: "branch-b" }); + expect(apiClientManager.accessToken).toBe("tr_second"); + expect(apiClientManager.branchName).toBe("branch-b"); + }); +}); + +describe("auth.withAuth", () => { + beforeEach(() => { + apiClientManager.disable(); + }); + + afterEach(() => { + apiClientManager.disable(); + }); + + it("does not stomp on a parallel withAuth call with a different config", async () => { + configure({ accessToken: "tr_global" }); + + const tokenA = "tr_concurrent_a"; + const tokenB = "tr_concurrent_b"; + + const settle = { + resolveA: () => {}, + resolveB: () => {}, + }; + const gateA = new Promise((r) => (settle.resolveA = r)); + const gateB = new Promise((r) => (settle.resolveB = r)); + + const runA = auth.withAuth({ accessToken: tokenA }, async () => { + // Suspend mid-scope so the parallel B scope opens while A is still pending. + await gateA; + return apiClientManager.accessToken; + }); + + const runB = auth.withAuth({ accessToken: tokenB }, async () => { + // Open B's scope first, then unblock A. If withAuth used the old + // mutate-and-restore pattern, A would observe tokenB or B's + // .finally would restore the wrong "original". + const seenInB = apiClientManager.accessToken; + settle.resolveA(); + await gateB; + return seenInB; + }); + + settle.resolveB(); // let B finish after A reads + const [seenInA, seenInB] = await Promise.all([runA, runB]); + + expect(seenInA).toBe(tokenA); + expect(seenInB).toBe(tokenB); + // Global remains unchanged after both scopes exit. + expect(apiClientManager.accessToken).toBe("tr_global"); + }); +}); diff --git a/packages/trigger-sdk/src/v3/triggerClient.ts b/packages/trigger-sdk/src/v3/triggerClient.ts new file mode 100644 index 00000000000..e26166b8b49 --- /dev/null +++ b/packages/trigger-sdk/src/v3/triggerClient.ts @@ -0,0 +1,129 @@ +import { + type ApiClientConfiguration, + sdkScope, + type SdkScope, +} from "@trigger.dev/core/v3"; +import { auth } from "./auth.js"; +import { batch } from "./batch.js"; +import { deployments } from "./deployments.js"; +import * as envvarsModule from "./envvars.js"; +import * as promptsModule from "./prompts.js"; +import * as queuesModule from "./queues.js"; +import { runs } from "./runs.js"; +import * as schedulesModule from "./schedules/index.js"; +import { + batchTrigger, + trigger, + triggerAndSubscribe, +} from "./shared.js"; + +export type TriggerClientConfig = ApiClientConfiguration & { + /** + * When `true`, instance methods inherit the ambient task context + * (`parentRunId`, `lockToVersion`, `isTest`, current task's `taskContext`) + * when invoked from inside a task. Default `false` — instance calls are + * fully isolated from the surrounding task runtime, which is what you + * want when the instance points at a different project, environment, or + * preview branch than the task is running in. + */ + inheritContext?: boolean; +}; + +// Curated instance surfaces — drop methods that are inside-task-only +// (e.g. `batch.triggerAndWait`, which depends on the runtime manager) or +// task-definition-time (e.g. `schedules.task`, `prompts.define`), and +// drop helpers that don't need a client (`schedules.timezones`). +const tasksApi = { trigger, batchTrigger, triggerAndSubscribe }; +const batchInstanceKeys = ["trigger", "triggerByTask", "retrieve"] as const; +const schedulesInstanceKeys = [ + "activate", + "create", + "deactivate", + "del", + "list", + "retrieve", + "update", +] as const; +const promptsInstanceKeys = [ + "createOverride", + "list", + "promote", + "reactivateOverride", + "removeOverride", + "resolve", + "updateOverride", + "versions", +] as const; +const authInstanceKeys = [ + "createPublicToken", + "createTriggerPublicToken", + "createBatchTriggerPublicToken", +] as const; + +type TasksApi = typeof tasksApi; +type RunsApi = typeof runs; +type BatchApi = Pick; +type DeploymentsApi = typeof deployments; +type EnvvarsApi = typeof envvarsModule; +type PromptsApi = Pick; +type QueuesApi = typeof queuesModule; +type SchedulesApi = Pick; +type AuthApi = Pick; + +export class TriggerClient { + readonly tasks: TasksApi; + readonly runs: RunsApi; + readonly batch: BatchApi; + readonly deployments: DeploymentsApi; + readonly envvars: EnvvarsApi; + readonly prompts: PromptsApi; + readonly queues: QueuesApi; + readonly schedules: SchedulesApi; + readonly auth: AuthApi; + + constructor(config: TriggerClientConfig) { + if (!config.accessToken && !config.secretKey) { + throw new Error("TriggerClient: accessToken (or secretKey) is required"); + } + + const scope: SdkScope = { + apiClientConfig: { + baseURL: config.baseURL, + accessToken: config.accessToken, + secretKey: config.secretKey, + previewBranch: config.previewBranch, + requestOptions: config.requestOptions, + future: config.future, + }, + inheritContext: config.inheritContext ?? false, + }; + + this.tasks = bindToScope(tasksApi, scope); + this.runs = bindToScope(runs, scope); + this.batch = bindToScope(batch, scope, batchInstanceKeys); + this.deployments = bindToScope(deployments, scope); + this.envvars = bindToScope(envvarsModule, scope); + this.prompts = bindToScope(promptsModule, scope, promptsInstanceKeys); + this.queues = bindToScope(queuesModule, scope); + this.schedules = bindToScope(schedulesModule, scope, schedulesInstanceKeys); + this.auth = bindToScope(auth, scope, authInstanceKeys); + } +} + +function bindToScope( + api: T, + scope: SdkScope, + keys?: readonly K[] +): Pick { + const targetKeys = (keys ?? (Object.keys(api) as K[])) as readonly K[]; + const bound: Record = {}; + for (const key of targetKeys) { + const value = (api as Record)[key as string]; + bound[key as string] = + typeof value === "function" + ? (...args: unknown[]) => + sdkScope.withScope(scope, () => (value as (...a: unknown[]) => unknown)(...args)) + : value; + } + return bound as unknown as Pick; +} diff --git a/packages/trigger-sdk/src/v3/triggerClient.types.test.ts b/packages/trigger-sdk/src/v3/triggerClient.types.test.ts new file mode 100644 index 00000000000..75324991ed3 --- /dev/null +++ b/packages/trigger-sdk/src/v3/triggerClient.types.test.ts @@ -0,0 +1,116 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type { ApiPromise } from "@trigger.dev/core/v3"; +import { batch } from "./batch.js"; +import { runs } from "./runs.js"; +import * as envvars from "./envvars.js"; +import * as schedules from "./schedules/index.js"; +import * as prompts from "./prompts.js"; +import { auth } from "./auth.js"; +import type { Task, AnyTask } from "./shared.js"; +import { TriggerClient } from "./triggerClient.js"; + +// Stand-in task type used to verify generic inference flows through the proxy. +// Mirrors the shape returned by `task({...})` calls. +type ExampleTask = Task<"example", { to: string }, { sent: boolean }>; + +const client = new TriggerClient({ accessToken: "tr_x" }); + +describe("TriggerClient surface — type-level guarantees", () => { + it("preserves generic inference on tasks.trigger", () => { + // If the proxy cast in bindToScope ever erodes generics, this fails: + // the return type degrades to `unknown` and `.id`/`.taskIdentifier` + // disappear. + type Returned = ReturnType>; + expectTypeOf().resolves.toHaveProperty("id"); + expectTypeOf().resolves.toHaveProperty("taskIdentifier"); + }); + + it("preserves return type on runs.retrieve (no double-wrap)", () => { + // bindToScope wraps the impl as () => sdkScope.withScope(...). If the + // wrapper were typed loosely it could surface as Promise>. + // We want the original ApiPromise<...> to flow through unchanged. + const handle = client.runs.retrieve("run_x"); + expectTypeOf(handle).toEqualTypeOf>>(); + // And it should be assignable to a plain Promise (since ApiPromise extends Promise). + expectTypeOf(handle).toMatchTypeOf>(); + }); + + it("preserves envvars.list overloads (projectRef+slug form AND zero-arg form)", () => { + // Two-arg form + expectTypeOf(client.envvars.list).toBeCallableWith("proj_1234", "dev"); + // Zero-arg form (uses task context — still typeable at the call site) + expectTypeOf(client.envvars.list).toBeCallableWith(); + }); +}); + +describe("TriggerClient surface — curated subsets", () => { + it("instance.tasks drops inside-task-only and definition-time helpers", () => { + type Keys = keyof typeof client.tasks; + expectTypeOf().toEqualTypeOf<"trigger" | "batchTrigger" | "triggerAndSubscribe">(); + // @ts-expect-error — triggerAndWait is not on the instance surface. + client.tasks.triggerAndWait; + // @ts-expect-error — batchTriggerAndWait is not on the instance surface. + client.tasks.batchTriggerAndWait; + // @ts-expect-error — hooks like onStart are task-definition-time, not on the client. + client.tasks.onStart; + }); + + it("instance.batch drops the *AndWait variants that depend on the runtime", () => { + type Keys = keyof typeof client.batch; + expectTypeOf().toEqualTypeOf<"trigger" | "triggerByTask" | "retrieve">(); + // @ts-expect-error + client.batch.triggerAndWait; + // @ts-expect-error + client.batch.triggerByTaskAndWait; + // The module-level export still has them — sanity check we didn't change that. + expectTypeOf(batch).toHaveProperty("triggerAndWait"); + }); + + it("instance.schedules drops `task` definition helper and `timezones` stateless helper", () => { + type Keys = keyof typeof client.schedules; + expectTypeOf().toEqualTypeOf< + "activate" | "create" | "deactivate" | "del" | "list" | "retrieve" | "update" + >(); + // @ts-expect-error + client.schedules.task; + // @ts-expect-error + client.schedules.timezones; + // Module-level export still has them. + expectTypeOf(schedules).toHaveProperty("task"); + expectTypeOf(schedules).toHaveProperty("timezones"); + }); + + it("instance.prompts drops `define`", () => { + // @ts-expect-error + client.prompts.define; + // Module-level export still has it. + expectTypeOf(prompts).toHaveProperty("define"); + }); + + it("instance.auth is the public-token subset only (no configure/withAuth)", () => { + type Keys = keyof typeof client.auth; + expectTypeOf().toEqualTypeOf< + "createPublicToken" | "createTriggerPublicToken" | "createBatchTriggerPublicToken" + >(); + // @ts-expect-error — configure is global-only, not on the instance. + client.auth.configure; + // @ts-expect-error — withAuth is global-only. + client.auth.withAuth; + // Module-level export still has them. + expectTypeOf(auth).toHaveProperty("configure"); + expectTypeOf(auth).toHaveProperty("withAuth"); + }); +}); + +describe("TriggerClient surface — namespaces match their module sources", () => { + // These are the load-bearing assertions for the bindToScope cast. If the + // `as unknown as Pick` ever drops or widens the underlying signatures, + // these break. + it("client.runs is structurally `typeof runs`", () => { + expectTypeOf(client.runs).toEqualTypeOf(); + }); + + it("client.envvars is structurally `typeof envvars`", () => { + expectTypeOf(client.envvars).toEqualTypeOf(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31e0e2e458d..c742ab1bfc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2636,6 +2636,28 @@ importers: specifier: workspace:* version: link:../../packages/cli-v3 + references/multi-client: + dependencies: + '@trigger.dev/build': + specifier: workspace:* + version: link:../../packages/build + '@trigger.dev/sdk': + specifier: workspace:* + version: link:../../packages/trigger-sdk + devDependencies: + '@types/node': + specifier: 20.14.14 + version: 20.14.14 + trigger.dev: + specifier: workspace:* + version: link:../../packages/cli-v3 + tsx: + specifier: 4.17.0 + version: 4.17.0 + typescript: + specifier: 5.5.4 + version: 5.5.4 + references/nextjs-realtime: dependencies: '@ai-sdk/openai': @@ -23312,7 +23334,7 @@ snapshots: '@epic-web/test-server@0.1.0(bufferutil@4.0.9)': dependencies: - '@hono/node-server': 1.12.2(hono@4.12.15) + '@hono/node-server': 1.12.2(hono@4.5.11) '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9) '@open-draft/deferred-promise': 2.2.0 '@types/ws': 8.5.12 @@ -23991,9 +24013,9 @@ snapshots: dependencies: react: 18.2.0 - '@hono/node-server@1.12.2(hono@4.12.15)': + '@hono/node-server@1.12.2(hono@4.5.11)': dependencies: - hono: 4.12.15 + hono: 4.5.11 '@hono/node-server@1.19.11(hono@4.12.15)': dependencies: @@ -24009,7 +24031,7 @@ snapshots: '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)': dependencies: - '@hono/node-server': 1.12.2(hono@4.12.15) + '@hono/node-server': 1.12.2(hono@4.5.11) ws: 8.18.3(bufferutil@4.0.9) transitivePeerDependencies: - bufferutil diff --git a/references/multi-client/README.md b/references/multi-client/README.md new file mode 100644 index 00000000000..a684e7b7929 --- /dev/null +++ b/references/multi-client/README.md @@ -0,0 +1,45 @@ +# multi-client reference + +Exercises `new TriggerClient(...)` — the explicit, per-instance management +client introduced alongside the global `configure()` API. Useful when a +single process needs to talk to multiple projects, environments, or +preview branches without globally mutating SDK state. + +## What's inside + +- `src/trigger/echo.ts` — a trivial task that returns its payload (the + trigger target for the external scripts and the fan-out task). +- `src/trigger/fanOut.ts` — runs inside a task and triggers `echo` + through two different `TriggerClient` instances in parallel. +- `src/external/main.ts` — external Node script. Two clients with + different secrets (and optionally different preview branches), + triggers `echo` sequentially and concurrently, logs every outgoing + request's `authorization` + `x-trigger-branch` headers. +- `src/external/isolation.ts` — interleaves the global `configure()` + API and an instance call, asserts via the captured fetches that + neither side leaks into the other. + +## Running locally + +Boot the webapp (`pnpm dev --filter webapp`) and `trigger dev` in this +workspace as usual, then run the scripts against `http://localhost:3030`: + +```bash +TRIGGER_API_URL=http://localhost:3030 \ +TRIGGER_PRIMARY_KEY=tr_dev_... \ +TRIGGER_SECONDARY_KEY=tr_dev_... \ +TRIGGER_SECONDARY_BRANCH=signup-flow \ +pnpm trigger:external +``` + +```bash +TRIGGER_API_URL=http://localhost:3030 \ +TRIGGER_GLOBAL_KEY=tr_dev_... \ +TRIGGER_INSTANCE_KEY=tr_dev_... \ +TRIGGER_INSTANCE_BRANCH=preview-x \ +pnpm trigger:isolation +``` + +The fan-out task is exercised by triggering it through the dashboard or +via the Trigger MCP after setting `TRIGGER_FAN_OUT_PRIMARY_KEY` and +`TRIGGER_FAN_OUT_SECONDARY_KEY` in the dev env. diff --git a/references/multi-client/package.json b/references/multi-client/package.json new file mode 100644 index 00000000000..81aaaaa2caf --- /dev/null +++ b/references/multi-client/package.json @@ -0,0 +1,21 @@ +{ + "name": "references-multi-client", + "private": true, + "type": "module", + "devDependencies": { + "trigger.dev": "workspace:*", + "@types/node": "20.14.14", + "tsx": "4.17.0", + "typescript": "^5.5.4" + }, + "dependencies": { + "@trigger.dev/build": "workspace:*", + "@trigger.dev/sdk": "workspace:*" + }, + "scripts": { + "dev": "trigger dev", + "deploy": "trigger deploy", + "trigger:external": "tsx src/external/main.ts", + "trigger:isolation": "tsx src/external/isolation.ts" + } +} diff --git a/references/multi-client/src/external/isolation.ts b/references/multi-client/src/external/isolation.ts new file mode 100644 index 00000000000..3849b26e771 --- /dev/null +++ b/references/multi-client/src/external/isolation.ts @@ -0,0 +1,93 @@ +/** + * Isolation smoke test — proves the global `configure()` API and a + * `new TriggerClient(...)` instance do not leak into each other. + * + * Run with: + * TRIGGER_GLOBAL_KEY=tr_dev_... \ + * TRIGGER_INSTANCE_KEY=tr_dev_... \ + * TRIGGER_INSTANCE_BRANCH=preview-x \ + * TRIGGER_API_URL=http://localhost:3030 \ + * pnpm trigger:isolation + */ + +import { configure, runs, TriggerClient } from "@trigger.dev/sdk"; + +const GLOBAL_KEY = process.env.TRIGGER_GLOBAL_KEY; +const INSTANCE_KEY = process.env.TRIGGER_INSTANCE_KEY; +const INSTANCE_BRANCH = process.env.TRIGGER_INSTANCE_BRANCH; + +if (!GLOBAL_KEY || !INSTANCE_KEY) { + console.error( + "TRIGGER_GLOBAL_KEY and TRIGGER_INSTANCE_KEY env vars are required." + ); + process.exit(1); +} + +const captured: { url: string; auth: string; branch: string | null }[] = []; +const original = globalThis.fetch; +globalThis.fetch = (async (input: any, init?: RequestInit) => { + const url = typeof input === "string" ? input : input?.url ?? String(input); + const headers = new Headers(init?.headers); + captured.push({ + url, + auth: headers.get("authorization")?.slice(0, 20) + "..." ?? "(unset)", + branch: headers.get("x-trigger-branch"), + }); + return original(input, init); +}) as typeof fetch; + +async function main() { + configure({ accessToken: GLOBAL_KEY! }); + const instance = new TriggerClient({ + accessToken: INSTANCE_KEY!, + previewBranch: INSTANCE_BRANCH, + }); + + // Global API call (default behavior, reads from configure) + await runs.list({ limit: 1 }).catch(() => undefined); + + // Instance API call (uses instance config) + await instance.runs.list({ limit: 1 }).catch(() => undefined); + + // Back-to-back global to confirm no global mutation: + await runs.list({ limit: 1 }).catch(() => undefined); + + console.log("\nCaptured requests:"); + for (const r of captured) { + console.log( + ` auth=${r.auth.padEnd(24)} branch=${(r.branch ?? "(unset)").padEnd(15)} ${truncateUrl(r.url)}` + ); + } + + const globalAuthPrefix = `Bearer ${GLOBAL_KEY!}`.slice(0, 20) + "..."; + const instanceAuthPrefix = `Bearer ${INSTANCE_KEY!}`.slice(0, 20) + "..."; + + const okay = [ + captured[0]?.auth === globalAuthPrefix && captured[0]?.branch === null, + captured[1]?.auth === instanceAuthPrefix && + captured[1]?.branch === (INSTANCE_BRANCH ?? null), + captured[2]?.auth === globalAuthPrefix && captured[2]?.branch === null, + ]; + + if (okay.every(Boolean)) { + console.log("\nIsolation verified: global ↔ instance do not leak."); + } else { + console.log("\nIsolation check failed. Expected sequence:"); + console.log(" 1. global auth, no branch"); + console.log( + ` 2. instance auth, branch=${INSTANCE_BRANCH ?? "(none requested)"}` + ); + console.log(" 3. global auth, no branch"); + process.exit(2); + } +} + +main().catch((err) => { + console.error("script failed:", err); + process.exit(1); +}); + +function truncateUrl(url: string): string { + if (url.length <= 80) return url; + return url.slice(0, 77) + "..."; +} diff --git a/references/multi-client/src/external/main.ts b/references/multi-client/src/external/main.ts new file mode 100644 index 00000000000..00f53053403 --- /dev/null +++ b/references/multi-client/src/external/main.ts @@ -0,0 +1,101 @@ +/** + * External multi-client smoke test. + * + * Run with: + * TRIGGER_PRIMARY_KEY=tr_dev_... \ + * TRIGGER_SECONDARY_KEY=tr_dev_... \ + * TRIGGER_SECONDARY_BRANCH=signup-flow \ + * TRIGGER_API_URL=http://localhost:3030 \ + * pnpm trigger:external + * + * Both clients hit the same backend but with different auth + branch + * configuration. The fetch interceptor logs every outgoing request's + * authorization + x-trigger-branch headers so you can visually confirm + * each client uses its own config and they don't leak into each other. + */ + +import { TriggerClient } from "@trigger.dev/sdk"; + +const PRIMARY_KEY = process.env.TRIGGER_PRIMARY_KEY; +const SECONDARY_KEY = process.env.TRIGGER_SECONDARY_KEY; +const SECONDARY_BRANCH = process.env.TRIGGER_SECONDARY_BRANCH; + +if (!PRIMARY_KEY || !SECONDARY_KEY) { + console.error( + "TRIGGER_PRIMARY_KEY and TRIGGER_SECONDARY_KEY env vars are required.\n" + + "Example: TRIGGER_PRIMARY_KEY=tr_dev_xxx TRIGGER_SECONDARY_KEY=tr_dev_yyy pnpm trigger:external" + ); + process.exit(1); +} + +installFetchLogger(); + +async function main() { + const primary = new TriggerClient({ accessToken: PRIMARY_KEY! }); + const secondary = new TriggerClient({ + accessToken: SECONDARY_KEY!, + previewBranch: SECONDARY_BRANCH, + }); + + console.log("\n=== sequential triggers ===\n"); + const sequentialA = await primary.tasks.trigger("echo", { + from: "primary client (sequential)", + }); + const sequentialB = await secondary.tasks.trigger("echo", { + from: "secondary client (sequential)", + }); + + console.log("\nResults:"); + console.log(" primary ->", sequentialA.id); + console.log(" secondary ->", sequentialB.id); + + console.log("\n=== concurrent triggers (verifies ALS isolation) ===\n"); + const [c, d, e, f] = await Promise.all([ + primary.tasks.trigger("echo", { from: "primary client (concurrent #1)" }), + secondary.tasks.trigger("echo", { from: "secondary client (concurrent #1)" }), + primary.tasks.trigger("echo", { from: "primary client (concurrent #2)" }), + secondary.tasks.trigger("echo", { from: "secondary client (concurrent #2)" }), + ]); + + console.log("\nConcurrent results:"); + console.log(" primary ->", c.id, "/", e.id); + console.log(" secondary ->", d.id, "/", f.id); + + console.log( + "\nLook at the fetch log above — every primary request should carry the primary auth header and NO x-trigger-branch,\n" + + "every secondary request should carry the secondary auth header AND x-trigger-branch when set." + ); +} + +main().catch((err) => { + console.error("script failed:", err); + process.exit(1); +}); + +function installFetchLogger() { + const original = globalThis.fetch; + globalThis.fetch = (async (input: any, init?: RequestInit) => { + const url = + typeof input === "string" ? input : input?.url ?? String(input); + const headers = new Headers(init?.headers); + const auth = headers.get("authorization"); + const branch = headers.get("x-trigger-branch"); + console.log( + `→ ${init?.method ?? "GET"} ${truncateUrl(url)}\n` + + ` authorization: ${maskToken(auth)}\n` + + ` x-trigger-branch: ${branch ?? "(unset)"}` + ); + return original(input, init); + }) as typeof fetch; +} + +function truncateUrl(url: string): string { + if (url.length <= 80) return url; + return url.slice(0, 77) + "..."; +} + +function maskToken(value: string | null): string { + if (!value) return "(unset)"; + const prefix = value.slice(0, "Bearer tr_dev_".length + 4); + return `${prefix}...`; +} diff --git a/references/multi-client/src/index.ts b/references/multi-client/src/index.ts new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/references/multi-client/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/references/multi-client/src/trigger/echo.ts b/references/multi-client/src/trigger/echo.ts new file mode 100644 index 00000000000..68813d7c1f2 --- /dev/null +++ b/references/multi-client/src/trigger/echo.ts @@ -0,0 +1,19 @@ +import { logger, task } from "@trigger.dev/sdk"; + +/** + * Echo task — returns its payload unchanged. Used as the trigger target for + * the external multi-client scripts and the fan-out task in this reference + * project. + */ +export const echo = task({ + id: "echo", + run: async (payload: { from: string; note?: string }, { ctx }) => { + logger.info("echo received", { payload, ctx }); + return { + received: payload, + runId: ctx.run.id, + environmentSlug: ctx.environment.slug, + branch: ctx.environment.branchName ?? null, + }; + }, +}); diff --git a/references/multi-client/src/trigger/fanOut.ts b/references/multi-client/src/trigger/fanOut.ts new file mode 100644 index 00000000000..bb70538b7dd --- /dev/null +++ b/references/multi-client/src/trigger/fanOut.ts @@ -0,0 +1,65 @@ +import { logger, task, TriggerClient } from "@trigger.dev/sdk"; + +/** + * Fan-out task — runs inside a task, constructs two `TriggerClient` + * instances with different configs, and fires `echo` through each. + * + * The point: instance calls are isolated from the surrounding task + * runtime. Even though we're inside a task with a parent run id and + * lockToVersion in `taskContext`, the instance calls do NOT propagate + * those automatically — they go out as clean external triggers. + * + * Set TRIGGER_FAN_OUT_PRIMARY_KEY and TRIGGER_FAN_OUT_SECONDARY_KEY env + * vars in the trigger dashboard (or in the dev env if running locally) + * to point each client at a different secret. Optionally set + * TRIGGER_FAN_OUT_SECONDARY_BRANCH to send the second one to a preview + * branch. + */ +export const fanOut = task({ + id: "fan-out", + run: async ( + _: { note?: string }, + { ctx } + ) => { + logger.info("fan-out running inside task", { + runId: ctx.run.id, + env: ctx.environment.slug, + branch: ctx.environment.branchName, + }); + + const primaryKey = process.env.TRIGGER_FAN_OUT_PRIMARY_KEY; + const secondaryKey = process.env.TRIGGER_FAN_OUT_SECONDARY_KEY; + const secondaryBranch = process.env.TRIGGER_FAN_OUT_SECONDARY_BRANCH; + + if (!primaryKey || !secondaryKey) { + logger.warn( + "fan-out skipped — set TRIGGER_FAN_OUT_PRIMARY_KEY and TRIGGER_FAN_OUT_SECONDARY_KEY to exercise the multi-client path" + ); + return { skipped: true }; + } + + const primary = new TriggerClient({ accessToken: primaryKey }); + const secondary = new TriggerClient({ + accessToken: secondaryKey, + previewBranch: secondaryBranch, + }); + + // The instance methods are isolated: taskContext.ctx is masked inside + // the scope, so neither call inherits parentRunId / lockToVersion / etc. + const [a, b] = await Promise.all([ + primary.tasks.trigger("echo", { + from: "fan-out via primary client", + note: `parent run was ${ctx.run.id}`, + }), + secondary.tasks.trigger("echo", { + from: "fan-out via secondary client", + note: `branch: ${secondaryBranch ?? "(none)"}`, + }), + ]); + + return { + primary: { id: a.id, taskIdentifier: a.taskIdentifier }, + secondary: { id: b.id, taskIdentifier: b.taskIdentifier }, + }; + }, +}); diff --git a/references/multi-client/trigger.config.ts b/references/multi-client/trigger.config.ts new file mode 100644 index 00000000000..0c382906fab --- /dev/null +++ b/references/multi-client/trigger.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "@trigger.dev/sdk/v3"; + +export default defineConfig({ + compatibilityFlags: ["run_engine_v2"], + project: "proj_zzoylbutktripkqnwrln", + logLevel: "info", + maxDuration: 600, + retries: { + enabledInDev: false, + default: { + maxAttempts: 1, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + factor: 2, + randomize: false, + }, + }, + machine: "small-1x", +}); diff --git a/references/multi-client/tsconfig.json b/references/multi-client/tsconfig.json new file mode 100644 index 00000000000..3bb455e5d40 --- /dev/null +++ b/references/multi-client/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "Node16", + "moduleResolution": "Node16", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "customConditions": ["@triggerdotdev/source"], + "lib": ["DOM", "DOM.Iterable"], + "noEmit": true + }, + "include": ["./src/**/*.ts", "trigger.config.ts"] +} From 8c6c8c5b4957d0e5f1bce59b65d2eb4735411a0b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 21 May 2026 11:36:17 +0100 Subject: [PATCH 2/8] fix(core,sdk): keep sdkScope out of the browser import graph The previous TriggerClient commit added `import { AsyncLocalStorage } from "node:async_hooks"` in `sdkScope/index.ts`, which is reachable from `@trigger.dev/core/v3`. Browser bundles importing from the v3 root (webapp dashboard, ai-chat client components) pulled the node builtin transitively and failed to compile. Split storage out so the v3 root stays browser-safe: - `sdkScope/index.ts` exposes the API plus an `_installSdkScopeStorage` hook with a slot pattern. No node imports. - `sdkScope/storage-node.ts` owns the AsyncLocalStorage and installs itself via the slot on import. Only file in the package that touches `node:async_hooks`. - Exported as `@trigger.dev/core/v3/sdk-scope-storage`. Deliberately NOT re-exported from the v3 root. - `@trigger.dev/sdk` modules that need the scope (TriggerClient, auth) side-effect-import the sub-path. - `@trigger.dev/sdk` is marked `"sideEffects": false` so browser bundles that don't reach TriggerClient or auth tree-shake them and their side-effect imports out entirely. `apiClientManager.runWithConfig` keeps a fallback to in-place global mutation when storage isn't installed (browser, Edge, Cloudflare Workers, or Node consumers that haven't imported TriggerClient/auth). This preserves the pre-existing concurrency-not-safe-but-functional semantics in runtimes that can't run AsyncLocalStorage. On Node where TriggerClient or auth has been imported, the ALS path is used and parallel scopes don't stomp. --- packages/core/package.json | 14 ++++++++- .../core/src/v3/apiClientManager/index.ts | 17 ++++++++++- packages/core/src/v3/sdkScope/index.ts | 29 +++++++++++++------ packages/core/src/v3/sdkScope/storage-node.ts | 15 ++++++++++ packages/core/src/v3/sdkScope/types.ts | 11 +++++++ packages/trigger-sdk/package.json | 1 + packages/trigger-sdk/src/v3/auth.ts | 5 ++++ packages/trigger-sdk/src/v3/triggerClient.ts | 8 +++++ 8 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/v3/sdkScope/storage-node.ts create mode 100644 packages/core/src/v3/sdkScope/types.ts diff --git a/packages/core/package.json b/packages/core/package.json index a1489754ff0..5ecaccafb05 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -55,7 +55,8 @@ "./v3/runEngineWorker": "./src/v3/runEngineWorker/index.ts", "./v3/machines": "./src/v3/machines/index.ts", "./v3/serverOnly": "./src/v3/serverOnly/index.ts", - "./v3/isomorphic": "./src/v3/isomorphic/index.ts" + "./v3/isomorphic": "./src/v3/isomorphic/index.ts", + "./v3/sdk-scope-storage": "./src/v3/sdkScope/storage-node.ts" }, "sourceDialects": [ "@triggerdotdev/source" @@ -622,6 +623,17 @@ "types": "./dist/commonjs/v3/isomorphic/index.d.ts", "default": "./dist/commonjs/v3/isomorphic/index.js" } + }, + "./v3/sdk-scope-storage": { + "import": { + "@triggerdotdev/source": "./src/v3/sdkScope/storage-node.ts", + "types": "./dist/esm/v3/sdkScope/storage-node.d.ts", + "default": "./dist/esm/v3/sdkScope/storage-node.js" + }, + "require": { + "types": "./dist/commonjs/v3/sdkScope/storage-node.d.ts", + "default": "./dist/commonjs/v3/sdkScope/storage-node.js" + } } }, "type": "module", diff --git a/packages/core/src/v3/apiClientManager/index.ts b/packages/core/src/v3/apiClientManager/index.ts index c6e5710980d..73ea831663f 100644 --- a/packages/core/src/v3/apiClientManager/index.ts +++ b/packages/core/src/v3/apiClientManager/index.ts @@ -111,7 +111,22 @@ export class APIClientManagerAPI { fn: R ): Promise> { const merged: ApiClientConfiguration = { ...this.#getConfig(), ...config }; - return sdkScope.withScope({ apiClientConfig: merged, inheritContext: true }, fn); + + // Use the AsyncLocalStorage scope when installed (Node-side code + // that has loaded TriggerClient or auth) — concurrency-safe. + if (sdkScope.hasStorage()) { + return sdkScope.withScope({ apiClientConfig: merged, inheritContext: true }, fn); + } + + // Fallback: in-place global mutation. Matches pre-existing behavior + // and works in any runtime (browser, Edge, Workers, Node without + // the storage installed). Not concurrency-safe — parallel callers + // with different configs will stomp on each other. + const original = this.#getConfig(); + registerGlobal(API_NAME, merged, true); + return fn().finally(() => { + registerGlobal(API_NAME, original, true); + }); } public setGlobalAPIClientConfiguration(config: ApiClientConfiguration): boolean { diff --git a/packages/core/src/v3/sdkScope/index.ts b/packages/core/src/v3/sdkScope/index.ts index 2dd11de06f2..6b8d49019df 100644 --- a/packages/core/src/v3/sdkScope/index.ts +++ b/packages/core/src/v3/sdkScope/index.ts @@ -1,18 +1,29 @@ -import { AsyncLocalStorage } from "node:async_hooks"; -import type { ApiClientConfiguration } from "../apiClientManager/types.js"; +import type { SdkScope, SdkScopeStorage } from "./types.js"; -export type SdkScope = { - apiClientConfig: ApiClientConfiguration; - inheritContext: boolean; -}; +export type { SdkScope, SdkScopeStorage } from "./types.js"; + +// Storage slot. Filled at runtime by a Node-only module +// (`@trigger.dev/core/v3/sdk-scope-storage`) that owns the +// AsyncLocalStorage instance. Left undefined in environments that +// never import that module (browsers, edge runtimes), where +// `sdkScope.withScope` falls through to invoking the callback +// directly. `sdkScope/index.ts` deliberately does not statically +// import `node:async_hooks` or `storage-node.ts` so it is safe to +// include in any browser-side bundle that reaches `@trigger.dev/core/v3`. +let installedStorage: SdkScopeStorage | undefined; -const storage = new AsyncLocalStorage(); +export function _installSdkScopeStorage(storage: SdkScopeStorage): void { + installedStorage = storage; +} export const sdkScope = { + hasStorage(): boolean { + return installedStorage !== undefined; + }, getStore(): SdkScope | undefined { - return storage.getStore(); + return installedStorage?.getStore(); }, withScope(scope: SdkScope, fn: () => R): R { - return storage.run(scope, fn); + return installedStorage ? installedStorage.run(scope, fn) : fn(); }, }; diff --git a/packages/core/src/v3/sdkScope/storage-node.ts b/packages/core/src/v3/sdkScope/storage-node.ts new file mode 100644 index 00000000000..1aa9329ae47 --- /dev/null +++ b/packages/core/src/v3/sdkScope/storage-node.ts @@ -0,0 +1,15 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { _installSdkScopeStorage } from "./index.js"; +import type { SdkScope } from "./types.js"; + +// Importing this module installs an AsyncLocalStorage-backed +// `SdkScopeStorage` into the slot exposed by `sdkScope/index.ts`. The +// SDK side-effect-imports this from server-only modules +// (TriggerClient, auth) so that browser-bundled code that never +// touches those modules never pulls `node:async_hooks` either. +const als = new AsyncLocalStorage(); + +_installSdkScopeStorage({ + getStore: () => als.getStore(), + run: (scope, fn) => als.run(scope, fn), +}); diff --git a/packages/core/src/v3/sdkScope/types.ts b/packages/core/src/v3/sdkScope/types.ts new file mode 100644 index 00000000000..249035e2924 --- /dev/null +++ b/packages/core/src/v3/sdkScope/types.ts @@ -0,0 +1,11 @@ +import type { ApiClientConfiguration } from "../apiClientManager/types.js"; + +export type SdkScope = { + apiClientConfig: ApiClientConfiguration; + inheritContext: boolean; +}; + +export type SdkScopeStorage = { + getStore(): SdkScope | undefined; + run(scope: SdkScope, fn: () => R): R; +}; diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index f1780901ab0..ab57b2b7a7c 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -12,6 +12,7 @@ "directory": "packages/trigger-sdk" }, "type": "module", + "sideEffects": false, "files": [ "dist" ], diff --git a/packages/trigger-sdk/src/v3/auth.ts b/packages/trigger-sdk/src/v3/auth.ts index 614019941db..899c1a5347a 100644 --- a/packages/trigger-sdk/src/v3/auth.ts +++ b/packages/trigger-sdk/src/v3/auth.ts @@ -5,6 +5,11 @@ import { } from "@trigger.dev/core/v3"; import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3"; +// Install the Node AsyncLocalStorage-backed storage so `auth.withAuth` +// (and the public-token helpers that route through it) actually scope +// API client config. See `triggerClient.ts` for the same import. +import "@trigger.dev/core/v3/sdk-scope-storage"; + /** * Register the global API client configuration. Alternatively, you can set the `TRIGGER_SECRET_KEY` and `TRIGGER_API_URL` environment variables. * @param options The API client configuration. diff --git a/packages/trigger-sdk/src/v3/triggerClient.ts b/packages/trigger-sdk/src/v3/triggerClient.ts index e26166b8b49..c411b0d1d71 100644 --- a/packages/trigger-sdk/src/v3/triggerClient.ts +++ b/packages/trigger-sdk/src/v3/triggerClient.ts @@ -3,6 +3,14 @@ import { sdkScope, type SdkScope, } from "@trigger.dev/core/v3"; + +// Install the Node AsyncLocalStorage-backed storage. Kept as a +// side-effect import so it is never reached from browser bundles +// that don't transitively import TriggerClient (relies on +// `sideEffects: false` in this package + the v3 root not importing +// storage-node statically). +import "@trigger.dev/core/v3/sdk-scope-storage"; + import { auth } from "./auth.js"; import { batch } from "./batch.js"; import { deployments } from "./deployments.js"; From 768ce082870091c467f0ace12b484407a212f780 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 21 May 2026 11:55:02 +0100 Subject: [PATCH 3/8] fix(core,sdk): add typesVersions entry for v3/sdk-scope-storage + move taskContext cleanup to afterEach `attw --pack` (check-exports) was failing on the new `@trigger.dev/core/v3/sdk-scope-storage` sub-path under node10 resolution because the export had no matching `typesVersions` mapping. Added one alongside the existing per-sub-path entries. In `triggerClient.test.ts`, moved `taskContext.disable()` from inside the individual taskContext-masking tests into the shared `afterEach` so a failing assertion can't leak a stubbed global task context into later tests in the file. --- packages/core/package.json | 3 +++ packages/trigger-sdk/src/v3/triggerClient.test.ts | 5 +---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 5ecaccafb05..3ef1bcec0aa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -163,6 +163,9 @@ "v3/isomorphic": [ "dist/commonjs/v3/isomorphic/index.d.ts" ], + "v3/sdk-scope-storage": [ + "dist/commonjs/v3/sdkScope/storage-node.d.ts" + ], "v3/test": [ "dist/commonjs/v3/test/index.d.ts" ] diff --git a/packages/trigger-sdk/src/v3/triggerClient.test.ts b/packages/trigger-sdk/src/v3/triggerClient.test.ts index c0d4a557ebf..f20c0b520bd 100644 --- a/packages/trigger-sdk/src/v3/triggerClient.test.ts +++ b/packages/trigger-sdk/src/v3/triggerClient.test.ts @@ -48,6 +48,7 @@ describe("TriggerClient", () => { afterEach(() => { fetchSpy.restore(); apiClientManager.disable(); + taskContext.disable(); vi.unstubAllEnvs(); }); @@ -152,8 +153,6 @@ describe("TriggerClient", () => { ); expect(observed).toBeUndefined(); - - taskContext.disable(); }); it("exposes taskContext.ctx inside a scope when inheritContext is true", () => { @@ -171,8 +170,6 @@ describe("TriggerClient", () => { ); expect(observed).toBe(fakeCtx); - - taskContext.disable(); }); }); From 90a3e04ada7bb3c11cfed5905548ea3f8a04089b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 21 May 2026 12:27:12 +0100 Subject: [PATCH 4/8] fix(core): preserve storage-node side effect + env fallback for inheritContext scopes Two follow-ups from review of the TriggerClient PR: 1. The bare side-effect import `import "@trigger.dev/core/v3/sdk-scope-storage"` from SDK code (triggerClient.ts, auth.ts) was at risk of being tree-shaken away by bundlers that respect `"sideEffects": false` on `@trigger.dev/core`. Whitelist the storage-node module in core's `sideEffects` array so bundlers keep the install side effect. Without this, the scope silently degrades to no-op in production bundles even though Node-runtime tests pass. 2. `auth.withAuth({ baseURL: "..." }, fn)` regressed for callers relying on `TRIGGER_SECRET_KEY` from the env: the scoped accessToken getter returned undefined instead of falling back to the env var, so a partial override (just baseURL) broke auth. Restore env fallback inside the scope, but gate it on `inheritContext: true` so it only applies to withAuth-style scopes, not to TriggerClient instances (whose isolation guarantee requires identity fields to come only from the constructor config). Adds an `auth.withAuth` test that covers the partial-override-with-env case so the regression can't return. --- packages/core/package.json | 6 ++++- .../core/src/v3/apiClientManager/index.ts | 26 ++++++++++++++++--- .../trigger-sdk/src/v3/triggerClient.test.ts | 20 ++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 3ef1bcec0aa..59e76b5d8c6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -171,7 +171,11 @@ ] } }, - "sideEffects": false, + "sideEffects": [ + "./dist/esm/v3/sdkScope/storage-node.js", + "./dist/commonjs/v3/sdkScope/storage-node.js", + "./src/v3/sdkScope/storage-node.ts" + ], "scripts": { "clean": "rimraf dist .tshy .tshy-build .turbo src/v3/vendor", "update-version": "tsx ../../scripts/updateVersion.ts", diff --git a/packages/core/src/v3/apiClientManager/index.ts b/packages/core/src/v3/apiClientManager/index.ts index 73ea831663f..25382305a12 100644 --- a/packages/core/src/v3/apiClientManager/index.ts +++ b/packages/core/src/v3/apiClientManager/index.ts @@ -50,7 +50,18 @@ export class APIClientManagerAPI { get accessToken(): string | undefined { const scoped = sdkScope.getStore(); if (scoped) { - return scoped.apiClientConfig.accessToken ?? scoped.apiClientConfig.secretKey; + const value = scoped.apiClientConfig.accessToken ?? scoped.apiClientConfig.secretKey; + if (value !== undefined) return value; + // `inheritContext: true` scopes (e.g. `auth.withAuth` partial + // overrides) still fall back to the process env so callers who + // rely on TRIGGER_SECRET_KEY don't lose auth when they only + // wanted to override baseURL. Isolated scopes (TriggerClient) + // intentionally do not fall back — the constructor enforces + // accessToken is provided. + if (scoped.inheritContext) { + return getEnvVar("TRIGGER_SECRET_KEY") ?? getEnvVar("TRIGGER_ACCESS_TOKEN"); + } + return undefined; } const config = this.#getConfig(); return ( @@ -64,8 +75,17 @@ export class APIClientManagerAPI { get branchName(): string | undefined { const scoped = sdkScope.getStore(); if (scoped) { - const value = scoped.apiClientConfig.previewBranch ?? undefined; - return value ? value : undefined; + const value = scoped.apiClientConfig.previewBranch; + if (value) return value; + // Same inheritContext gating as accessToken: withAuth-style + // scopes inherit env-derived branch; TriggerClient instances + // stay isolated from process env for identity. + if (scoped.inheritContext) { + const envValue = + getEnvVar("TRIGGER_PREVIEW_BRANCH") ?? getEnvVar("VERCEL_GIT_COMMIT_REF"); + return envValue ? envValue : undefined; + } + return undefined; } const config = this.#getConfig(); const value = diff --git a/packages/trigger-sdk/src/v3/triggerClient.test.ts b/packages/trigger-sdk/src/v3/triggerClient.test.ts index f20c0b520bd..3b97760114c 100644 --- a/packages/trigger-sdk/src/v3/triggerClient.test.ts +++ b/packages/trigger-sdk/src/v3/triggerClient.test.ts @@ -201,6 +201,26 @@ describe("auth.withAuth", () => { apiClientManager.disable(); }); + it("inherits TRIGGER_SECRET_KEY from env when called with a partial config", async () => { + vi.stubEnv("TRIGGER_SECRET_KEY", "tr_dev_env_token"); + + let observed: string | undefined; + await auth.withAuth({ baseURL: "https://override.example.com" }, async () => { + observed = apiClientManager.accessToken; + }); + + // The scoped `inheritContext: true` path falls back to TRIGGER_SECRET_KEY + // so callers can override only baseURL without re-passing the token. + expect(observed).toBe("tr_dev_env_token"); + // baseURL override still applies. + expect( + await auth.withAuth( + { baseURL: "https://override.example.com" }, + async () => apiClientManager.baseURL + ) + ).toBe("https://override.example.com"); + }); + it("does not stomp on a parallel withAuth call with a different config", async () => { configure({ accessToken: "tr_global" }); From 8a8fb97717e64de900a8636e46fee9794bca1c99 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 21 May 2026 12:41:30 +0100 Subject: [PATCH 5/8] feat(core,sdk): TriggerClient falls back to env vars at construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../core/src/v3/apiClientManager/index.ts | 62 +++++++------------ packages/core/src/v3/sdkScope/index.ts | 8 --- packages/core/src/v3/sdkScope/storage-node.ts | 5 -- packages/trigger-sdk/src/v3/auth.ts | 4 -- .../trigger-sdk/src/v3/triggerClient.test.ts | 52 +++++++++++----- packages/trigger-sdk/src/v3/triggerClient.ts | 38 ++---------- 6 files changed, 66 insertions(+), 103 deletions(-) diff --git a/packages/core/src/v3/apiClientManager/index.ts b/packages/core/src/v3/apiClientManager/index.ts index 25382305a12..4b2363e03a9 100644 --- a/packages/core/src/v3/apiClientManager/index.ts +++ b/packages/core/src/v3/apiClientManager/index.ts @@ -31,17 +31,9 @@ export class APIClientManagerAPI { } get baseURL(): string | undefined { - // baseURL is plumbing (where the API lives), not identity. Scoped - // instances read their own config first but still fall back to the - // process-level TRIGGER_API_URL so local-dev / CI overrides don't - // require passing baseURL into every `new TriggerClient(...)`. const scoped = sdkScope.getStore(); if (scoped) { - return ( - scoped.apiClientConfig.baseURL ?? - getEnvVar("TRIGGER_API_URL") ?? - "https://api.trigger.dev" - ); + return scoped.apiClientConfig.baseURL ?? "https://api.trigger.dev"; } const config = this.#getConfig(); return config?.baseURL ?? getEnvVar("TRIGGER_API_URL") ?? "https://api.trigger.dev"; @@ -50,18 +42,7 @@ export class APIClientManagerAPI { get accessToken(): string | undefined { const scoped = sdkScope.getStore(); if (scoped) { - const value = scoped.apiClientConfig.accessToken ?? scoped.apiClientConfig.secretKey; - if (value !== undefined) return value; - // `inheritContext: true` scopes (e.g. `auth.withAuth` partial - // overrides) still fall back to the process env so callers who - // rely on TRIGGER_SECRET_KEY don't lose auth when they only - // wanted to override baseURL. Isolated scopes (TriggerClient) - // intentionally do not fall back — the constructor enforces - // accessToken is provided. - if (scoped.inheritContext) { - return getEnvVar("TRIGGER_SECRET_KEY") ?? getEnvVar("TRIGGER_ACCESS_TOKEN"); - } - return undefined; + return scoped.apiClientConfig.accessToken ?? scoped.apiClientConfig.secretKey; } const config = this.#getConfig(); return ( @@ -76,16 +57,7 @@ export class APIClientManagerAPI { const scoped = sdkScope.getStore(); if (scoped) { const value = scoped.apiClientConfig.previewBranch; - if (value) return value; - // Same inheritContext gating as accessToken: withAuth-style - // scopes inherit env-derived branch; TriggerClient instances - // stay isolated from process env for identity. - if (scoped.inheritContext) { - const envValue = - getEnvVar("TRIGGER_PREVIEW_BRANCH") ?? getEnvVar("VERCEL_GIT_COMMIT_REF"); - return envValue ? envValue : undefined; - } - return undefined; + return value ? value : undefined; } const config = this.#getConfig(); const value = @@ -96,6 +68,24 @@ export class APIClientManagerAPI { return value ? value : undefined; } + public resolveApiClientConfig(partial: ApiClientConfiguration = {}): ApiClientConfiguration { + return { + baseURL: partial.baseURL ?? getEnvVar("TRIGGER_API_URL"), + accessToken: + partial.accessToken ?? + partial.secretKey ?? + getEnvVar("TRIGGER_SECRET_KEY") ?? + getEnvVar("TRIGGER_ACCESS_TOKEN"), + secretKey: partial.secretKey, + previewBranch: + partial.previewBranch ?? + getEnvVar("TRIGGER_PREVIEW_BRANCH") ?? + getEnvVar("VERCEL_GIT_COMMIT_REF"), + requestOptions: partial.requestOptions, + future: partial.future, + }; + } + get client(): ApiClient | undefined { if (!this.baseURL || !this.accessToken) { return undefined; @@ -130,18 +120,14 @@ export class APIClientManagerAPI { config: ApiClientConfiguration, fn: R ): Promise> { - const merged: ApiClientConfiguration = { ...this.#getConfig(), ...config }; + const merged = this.resolveApiClientConfig({ ...this.#getConfig(), ...config }); - // Use the AsyncLocalStorage scope when installed (Node-side code - // that has loaded TriggerClient or auth) — concurrency-safe. if (sdkScope.hasStorage()) { return sdkScope.withScope({ apiClientConfig: merged, inheritContext: true }, fn); } - // Fallback: in-place global mutation. Matches pre-existing behavior - // and works in any runtime (browser, Edge, Workers, Node without - // the storage installed). Not concurrency-safe — parallel callers - // with different configs will stomp on each other. + // No ALS available (browser, edge, workers). Fall back to in-place + // mutation — same as pre-existing behavior, not concurrency-safe. const original = this.#getConfig(); registerGlobal(API_NAME, merged, true); return fn().finally(() => { diff --git a/packages/core/src/v3/sdkScope/index.ts b/packages/core/src/v3/sdkScope/index.ts index 6b8d49019df..943ed953bd9 100644 --- a/packages/core/src/v3/sdkScope/index.ts +++ b/packages/core/src/v3/sdkScope/index.ts @@ -2,14 +2,6 @@ import type { SdkScope, SdkScopeStorage } from "./types.js"; export type { SdkScope, SdkScopeStorage } from "./types.js"; -// Storage slot. Filled at runtime by a Node-only module -// (`@trigger.dev/core/v3/sdk-scope-storage`) that owns the -// AsyncLocalStorage instance. Left undefined in environments that -// never import that module (browsers, edge runtimes), where -// `sdkScope.withScope` falls through to invoking the callback -// directly. `sdkScope/index.ts` deliberately does not statically -// import `node:async_hooks` or `storage-node.ts` so it is safe to -// include in any browser-side bundle that reaches `@trigger.dev/core/v3`. let installedStorage: SdkScopeStorage | undefined; export function _installSdkScopeStorage(storage: SdkScopeStorage): void { diff --git a/packages/core/src/v3/sdkScope/storage-node.ts b/packages/core/src/v3/sdkScope/storage-node.ts index 1aa9329ae47..01f59f40eb6 100644 --- a/packages/core/src/v3/sdkScope/storage-node.ts +++ b/packages/core/src/v3/sdkScope/storage-node.ts @@ -2,11 +2,6 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { _installSdkScopeStorage } from "./index.js"; import type { SdkScope } from "./types.js"; -// Importing this module installs an AsyncLocalStorage-backed -// `SdkScopeStorage` into the slot exposed by `sdkScope/index.ts`. The -// SDK side-effect-imports this from server-only modules -// (TriggerClient, auth) so that browser-bundled code that never -// touches those modules never pulls `node:async_hooks` either. const als = new AsyncLocalStorage(); _installSdkScopeStorage({ diff --git a/packages/trigger-sdk/src/v3/auth.ts b/packages/trigger-sdk/src/v3/auth.ts index 899c1a5347a..d8d3a397878 100644 --- a/packages/trigger-sdk/src/v3/auth.ts +++ b/packages/trigger-sdk/src/v3/auth.ts @@ -4,10 +4,6 @@ import { RealtimeRunSkipColumns, } from "@trigger.dev/core/v3"; import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3"; - -// Install the Node AsyncLocalStorage-backed storage so `auth.withAuth` -// (and the public-token helpers that route through it) actually scope -// API client config. See `triggerClient.ts` for the same import. import "@trigger.dev/core/v3/sdk-scope-storage"; /** diff --git a/packages/trigger-sdk/src/v3/triggerClient.test.ts b/packages/trigger-sdk/src/v3/triggerClient.test.ts index 3b97760114c..d2b46454a67 100644 --- a/packages/trigger-sdk/src/v3/triggerClient.test.ts +++ b/packages/trigger-sdk/src/v3/triggerClient.test.ts @@ -52,8 +52,21 @@ describe("TriggerClient", () => { vi.unstubAllEnvs(); }); - it("requires an accessToken at construction", () => { - expect(() => new TriggerClient({})).toThrow(/accessToken/); + it("throws on first API call when no accessToken is configured anywhere", () => { + const client = new TriggerClient(); + expect(() => client.runs.list({ limit: 1 })).toThrow(/TRIGGER_SECRET_KEY/); + }); + + it("falls back to env vars when constructor config is empty", async () => { + vi.stubEnv("TRIGGER_SECRET_KEY", "tr_dev_env_token"); + vi.stubEnv("TRIGGER_PREVIEW_BRANCH", "env-branch"); + + const client = new TriggerClient(); + await client.runs.retrieve("run_abc").catch(() => undefined); + + expect(fetchSpy.captured).toHaveLength(1); + expect(fetchSpy.captured[0]!.authorization).toBe("Bearer tr_dev_env_token"); + expect(fetchSpy.captured[0]!.branch).toBe("env-branch"); }); it("uses the instance accessToken and previewBranch on outgoing requests", async () => { @@ -70,24 +83,31 @@ describe("TriggerClient", () => { expect(req.branch).toBe("signup-flow"); }); - it("does not fall back to env vars for identity fields, but DOES for baseURL", async () => { - vi.stubEnv("TRIGGER_PREVIEW_BRANCH", "from-env-branch"); - vi.stubEnv("TRIGGER_API_URL", "https://from-env.example.com"); + it("fills missing fields from env, but explicit constructor values still win", async () => { + vi.stubEnv("TRIGGER_SECRET_KEY", "tr_env_token"); + vi.stubEnv("TRIGGER_PREVIEW_BRANCH", "env-branch"); + vi.stubEnv("TRIGGER_API_URL", "https://env.example.com"); - const client = new TriggerClient({ - accessToken: "tr_preview_instance_token", - // no previewBranch, no baseURL + const explicit = new TriggerClient({ + accessToken: "tr_explicit", + previewBranch: "explicit-branch", }); + const fromEnv = new TriggerClient(); - await client.runs.retrieve("run_abc").catch(() => undefined); + await Promise.all([ + explicit.runs.retrieve("run_a").catch(() => undefined), + fromEnv.runs.retrieve("run_b").catch(() => undefined), + ]); - expect(fetchSpy.captured).toHaveLength(1); - const req = fetchSpy.captured[0]!; - // Identity (branch) must NOT be filled from env when instance is used. - expect(req.branch).toBeUndefined(); - // Plumbing (baseURL) DOES fall back to TRIGGER_API_URL so local-dev / - // CI overrides apply without forcing every consumer to pass baseURL. - expect(req.url, `actual url=${req.url}`).toMatch(/^https:\/\/from-env\.example\.com\//); + const byRun = Object.fromEntries( + fetchSpy.captured.map((r) => [r.url.split("/runs/")[1]?.split(/[/?]/)[0], r]) + ); + + expect(byRun["run_a"]!.authorization).toBe("Bearer tr_explicit"); + expect(byRun["run_a"]!.branch).toBe("explicit-branch"); + expect(byRun["run_b"]!.authorization).toBe("Bearer tr_env_token"); + expect(byRun["run_b"]!.branch).toBe("env-branch"); + expect(byRun["run_a"]!.url.startsWith("https://env.example.com/")).toBe(true); }); it("does not leak instance config to the global apiClientManager", async () => { diff --git a/packages/trigger-sdk/src/v3/triggerClient.ts b/packages/trigger-sdk/src/v3/triggerClient.ts index c411b0d1d71..6ee00285a21 100644 --- a/packages/trigger-sdk/src/v3/triggerClient.ts +++ b/packages/trigger-sdk/src/v3/triggerClient.ts @@ -1,14 +1,9 @@ import { type ApiClientConfiguration, + apiClientManager, sdkScope, type SdkScope, } from "@trigger.dev/core/v3"; - -// Install the Node AsyncLocalStorage-backed storage. Kept as a -// side-effect import so it is never reached from browser bundles -// that don't transitively import TriggerClient (relies on -// `sideEffects: false` in this package + the v3 root not importing -// storage-node statically). import "@trigger.dev/core/v3/sdk-scope-storage"; import { auth } from "./auth.js"; @@ -26,21 +21,10 @@ import { } from "./shared.js"; export type TriggerClientConfig = ApiClientConfiguration & { - /** - * When `true`, instance methods inherit the ambient task context - * (`parentRunId`, `lockToVersion`, `isTest`, current task's `taskContext`) - * when invoked from inside a task. Default `false` — instance calls are - * fully isolated from the surrounding task runtime, which is what you - * want when the instance points at a different project, environment, or - * preview branch than the task is running in. - */ + /** Inherit ambient task context (parentRunId, lockToVersion, isTest) when called from inside a task. Default `false`. */ inheritContext?: boolean; }; -// Curated instance surfaces — drop methods that are inside-task-only -// (e.g. `batch.triggerAndWait`, which depends on the runtime manager) or -// task-definition-time (e.g. `schedules.task`, `prompts.define`), and -// drop helpers that don't need a client (`schedules.timezones`). const tasksApi = { trigger, batchTrigger, triggerAndSubscribe }; const batchInstanceKeys = ["trigger", "triggerByTask", "retrieve"] as const; const schedulesInstanceKeys = [ @@ -89,21 +73,11 @@ export class TriggerClient { readonly schedules: SchedulesApi; readonly auth: AuthApi; - constructor(config: TriggerClientConfig) { - if (!config.accessToken && !config.secretKey) { - throw new Error("TriggerClient: accessToken (or secretKey) is required"); - } - + constructor(config: TriggerClientConfig = {}) { + const { inheritContext, ...partial } = config; const scope: SdkScope = { - apiClientConfig: { - baseURL: config.baseURL, - accessToken: config.accessToken, - secretKey: config.secretKey, - previewBranch: config.previewBranch, - requestOptions: config.requestOptions, - future: config.future, - }, - inheritContext: config.inheritContext ?? false, + apiClientConfig: apiClientManager.resolveApiClientConfig(partial), + inheritContext: inheritContext ?? false, }; this.tasks = bindToScope(tasksApi, scope); From e1ca02e82a9642a4e0a491da385a6f89f9bae118 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 21 May 2026 12:51:31 +0100 Subject: [PATCH 6/8] fix(sdk): drop triggerAndSubscribe from TriggerClient surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `triggerAndSubscribe_internal` requires `taskContext.ctx` and uses `ctx.run.id` as the parent run id, so it is fundamentally an inside-task primitive. Including it on the curated `tasksApi` was a mistake — with the default `inheritContext: false`, the scoped taskContext is masked to undefined and the method always throws "triggerAndSubscribe can only be used from inside a task.run()". Type test updated to assert the method is no longer reachable from the instance surface. --- packages/trigger-sdk/src/v3/triggerClient.ts | 8 ++------ packages/trigger-sdk/src/v3/triggerClient.types.test.ts | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/trigger-sdk/src/v3/triggerClient.ts b/packages/trigger-sdk/src/v3/triggerClient.ts index 6ee00285a21..8d5d32ac95b 100644 --- a/packages/trigger-sdk/src/v3/triggerClient.ts +++ b/packages/trigger-sdk/src/v3/triggerClient.ts @@ -14,18 +14,14 @@ import * as promptsModule from "./prompts.js"; import * as queuesModule from "./queues.js"; import { runs } from "./runs.js"; import * as schedulesModule from "./schedules/index.js"; -import { - batchTrigger, - trigger, - triggerAndSubscribe, -} from "./shared.js"; +import { batchTrigger, trigger } from "./shared.js"; export type TriggerClientConfig = ApiClientConfiguration & { /** Inherit ambient task context (parentRunId, lockToVersion, isTest) when called from inside a task. Default `false`. */ inheritContext?: boolean; }; -const tasksApi = { trigger, batchTrigger, triggerAndSubscribe }; +const tasksApi = { trigger, batchTrigger }; const batchInstanceKeys = ["trigger", "triggerByTask", "retrieve"] as const; const schedulesInstanceKeys = [ "activate", diff --git a/packages/trigger-sdk/src/v3/triggerClient.types.test.ts b/packages/trigger-sdk/src/v3/triggerClient.types.test.ts index 75324991ed3..522f2f9f50c 100644 --- a/packages/trigger-sdk/src/v3/triggerClient.types.test.ts +++ b/packages/trigger-sdk/src/v3/triggerClient.types.test.ts @@ -46,11 +46,13 @@ describe("TriggerClient surface — type-level guarantees", () => { describe("TriggerClient surface — curated subsets", () => { it("instance.tasks drops inside-task-only and definition-time helpers", () => { type Keys = keyof typeof client.tasks; - expectTypeOf().toEqualTypeOf<"trigger" | "batchTrigger" | "triggerAndSubscribe">(); + expectTypeOf().toEqualTypeOf<"trigger" | "batchTrigger">(); // @ts-expect-error — triggerAndWait is not on the instance surface. client.tasks.triggerAndWait; // @ts-expect-error — batchTriggerAndWait is not on the instance surface. client.tasks.batchTriggerAndWait; + // @ts-expect-error — triggerAndSubscribe requires a task context; not on the instance surface. + client.tasks.triggerAndSubscribe; // @ts-expect-error — hooks like onStart are task-definition-time, not on the client. client.tasks.onStart; }); From 53162a6fc19b634634be65c827711e58dd8bbead Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 21 May 2026 14:20:32 +0100 Subject: [PATCH 7/8] chore(changeset): trim TriggerClient changeset to sdk-only patch --- .changeset/trigger-client.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.changeset/trigger-client.md b/.changeset/trigger-client.md index 6aeca10c18f..75699471ba2 100644 --- a/.changeset/trigger-client.md +++ b/.changeset/trigger-client.md @@ -1,9 +1,8 @@ --- "@trigger.dev/sdk": patch -"@trigger.dev/core": patch --- -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. +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. ```ts import { TriggerClient } from "@trigger.dev/sdk"; @@ -17,7 +16,3 @@ const preview = new TriggerClient({ await prod.tasks.trigger("send-email", payload); await preview.runs.list({ status: ["COMPLETED"] }); ``` - -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. - -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). From de3b063e55e2283c54cb48bde0113400d758b467 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 21 May 2026 15:04:17 +0100 Subject: [PATCH 8/8] fix(core): compose nested withAuth by merging from enclosing scope `runWithConfig` was building its merged config from the process-wide global, not from the enclosing ALS scope. That broke the documented `auth.withAuth(...)` + `auth.withPublicToken(...)` composition: the inner `withAuth` (called by withPublicToken internally) silently dropped the outer scope's baseURL/branch overrides. Read from the active scope first, fall back to the global, then merge in the new config. Pre-existing concurrency-safety (parallel scopes) holds. New test covers the nested-composition case. --- packages/core/src/v3/apiClientManager/index.ts | 3 ++- .../trigger-sdk/src/v3/triggerClient.test.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/core/src/v3/apiClientManager/index.ts b/packages/core/src/v3/apiClientManager/index.ts index 4b2363e03a9..6120c3aae0f 100644 --- a/packages/core/src/v3/apiClientManager/index.ts +++ b/packages/core/src/v3/apiClientManager/index.ts @@ -120,7 +120,8 @@ export class APIClientManagerAPI { config: ApiClientConfiguration, fn: R ): Promise> { - const merged = this.resolveApiClientConfig({ ...this.#getConfig(), ...config }); + const current = sdkScope.getStore()?.apiClientConfig ?? this.#getConfig(); + const merged = this.resolveApiClientConfig({ ...current, ...config }); if (sdkScope.hasStorage()) { return sdkScope.withScope({ apiClientConfig: merged, inheritContext: true }, fn); diff --git a/packages/trigger-sdk/src/v3/triggerClient.test.ts b/packages/trigger-sdk/src/v3/triggerClient.test.ts index d2b46454a67..f5374171ed7 100644 --- a/packages/trigger-sdk/src/v3/triggerClient.test.ts +++ b/packages/trigger-sdk/src/v3/triggerClient.test.ts @@ -241,6 +241,22 @@ describe("auth.withAuth", () => { ).toBe("https://override.example.com"); }); + it("composes nested withAuth: outer-scope fields flow into the inner scope", async () => { + vi.stubEnv("TRIGGER_SECRET_KEY", "tr_env_token"); + + let observedBaseURL: string | undefined; + let observedAuth: string | undefined; + await auth.withAuth({ baseURL: "https://outer.example.com" }, async () => { + await auth.withAuth({ accessToken: "tr_inner_token" }, async () => { + observedBaseURL = apiClientManager.baseURL; + observedAuth = apiClientManager.accessToken; + }); + }); + + expect(observedBaseURL).toBe("https://outer.example.com"); + expect(observedAuth).toBe("tr_inner_token"); + }); + it("does not stomp on a parallel withAuth call with a different config", async () => { configure({ accessToken: "tr_global" });