diff --git a/.changeset/trigger-client.md b/.changeset/trigger-client.md new file mode 100644 index 00000000000..75699471ba2 --- /dev/null +++ b/.changeset/trigger-client.md @@ -0,0 +1,18 @@ +--- +"@trigger.dev/sdk": patch +--- + +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"; + +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"] }); +``` diff --git a/packages/core/package.json b/packages/core/package.json index a1489754ff0..59e76b5d8c6 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" @@ -162,12 +163,19 @@ "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" ] } }, - "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", @@ -622,6 +630,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 96a4bc8e534..6120c3aae0f 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,19 @@ export class APIClientManagerAPI { } get baseURL(): string | undefined { + const scoped = sdkScope.getStore(); + if (scoped) { + return scoped.apiClientConfig.baseURL ?? "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 +54,11 @@ export class APIClientManagerAPI { } get branchName(): string | undefined { + const scoped = sdkScope.getStore(); + if (scoped) { + const value = scoped.apiClientConfig.previewBranch; + return value ? value : undefined; + } const config = this.#getConfig(); const value = config?.previewBranch ?? @@ -54,13 +68,33 @@ 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; } - 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 +108,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 +120,24 @@ export class APIClientManagerAPI { config: ApiClientConfiguration, fn: R ): Promise> { - const originalConfig = this.#getConfig(); - const $config = { ...originalConfig, ...config }; - registerGlobal(API_NAME, $config, true); + const current = sdkScope.getStore()?.apiClientConfig ?? this.#getConfig(); + const merged = this.resolveApiClientConfig({ ...current, ...config }); + + if (sdkScope.hasStorage()) { + return sdkScope.withScope({ apiClientConfig: merged, inheritContext: true }, fn); + } + // 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(() => { - registerGlobal(API_NAME, originalConfig, true); + registerGlobal(API_NAME, original, true); }); } 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..943ed953bd9 --- /dev/null +++ b/packages/core/src/v3/sdkScope/index.ts @@ -0,0 +1,21 @@ +import type { SdkScope, SdkScopeStorage } from "./types.js"; + +export type { SdkScope, SdkScopeStorage } from "./types.js"; + +let installedStorage: SdkScopeStorage | undefined; + +export function _installSdkScopeStorage(storage: SdkScopeStorage): void { + installedStorage = storage; +} + +export const sdkScope = { + hasStorage(): boolean { + return installedStorage !== undefined; + }, + getStore(): SdkScope | undefined { + return installedStorage?.getStore(); + }, + withScope(scope: SdkScope, fn: () => R): R { + 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..01f59f40eb6 --- /dev/null +++ b/packages/core/src/v3/sdkScope/storage-node.ts @@ -0,0 +1,10 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { _installSdkScopeStorage } from "./index.js"; +import type { SdkScope } from "./types.js"; + +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/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/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..d8d3a397878 100644 --- a/packages/trigger-sdk/src/v3/auth.ts +++ b/packages/trigger-sdk/src/v3/auth.ts @@ -4,6 +4,7 @@ import { RealtimeRunSkipColumns, } from "@trigger.dev/core/v3"; import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3"; +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. 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..f5374171ed7 --- /dev/null +++ b/packages/trigger-sdk/src/v3/triggerClient.test.ts @@ -0,0 +1,297 @@ +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(); + taskContext.disable(); + vi.unstubAllEnvs(); + }); + + 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 () => { + 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("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 explicit = new TriggerClient({ + accessToken: "tr_explicit", + previewBranch: "explicit-branch", + }); + const fromEnv = new TriggerClient(); + + await Promise.all([ + explicit.runs.retrieve("run_a").catch(() => undefined), + fromEnv.runs.retrieve("run_b").catch(() => undefined), + ]); + + 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 () => { + 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(); + }); + + 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); + }); +}); + +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("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("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" }); + + 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..8d5d32ac95b --- /dev/null +++ b/packages/trigger-sdk/src/v3/triggerClient.ts @@ -0,0 +1,107 @@ +import { + type ApiClientConfiguration, + apiClientManager, + sdkScope, + type SdkScope, +} from "@trigger.dev/core/v3"; +import "@trigger.dev/core/v3/sdk-scope-storage"; + +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 } 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 }; +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 = {}) { + const { inheritContext, ...partial } = config; + const scope: SdkScope = { + apiClientConfig: apiClientManager.resolveApiClientConfig(partial), + inheritContext: 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..522f2f9f50c --- /dev/null +++ b/packages/trigger-sdk/src/v3/triggerClient.types.test.ts @@ -0,0 +1,118 @@ +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">(); + // @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; + }); + + 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"] +}