feat(sdk,core): add TriggerClient for per-instance SDK configuration (#3683)

## Summary

`new TriggerClient({...})` exposes the management API (tasks, runs,
schedules, envvars, batch, queues, deployments, prompts, auth) as an
explicit instance with its own auth, preview branch, and baseURL.
Multiple clients can coexist in one process without mutating shared
global state — useful when a single service triggers across multiple
projects, environments, or preview branches.

```ts
import { TriggerClient } from "@trigger.dev/sdk";

const prod = new TriggerClient({ accessToken: process.env.TRIGGER_PROD_KEY });
const preview = new TriggerClient({
  accessToken: process.env.TRIGGER_PREVIEW_KEY,
  previewBranch: "signup-flow",
});

await prod.tasks.trigger("send-email", payload);
await preview.runs.list({ status: ["COMPLETED"] });
```

The existing global `configure()` API keeps working unchanged.

## Design

Instance methods enter an `AsyncLocalStorage`-backed scope (`sdkScope`)
before delegating to the existing module-level functions. The four
"pollution" points that previously read globals now consult the scope
first:

- `apiClientManager.{baseURL, accessToken, branchName}` and
`clientOrThrow` — identity fields are scope-only when scoped; `baseURL`
still falls back to `TRIGGER_API_URL` because plumbing (where the API
lives) is not identity.
- `taskContext.{ctx, worker, isWarmStart, isInsideTask}` — masked inside
an isolated scope so a `client.tasks.trigger(...)` from inside a task
doesn't leak the parent's `parentRunId` / `lockToVersion` / `isTest`
into a trigger that hits a different project.
- Inline `getEnvVar("TRIGGER_VERSION")` reads in `shared.ts` go through
a `scopedEnvVar` helper that returns `undefined` inside an isolated
scope.

The `TriggerClient` class itself is a thin wrapper that captures the
scope in its constructor and proxies each namespace method to enter that
scope before calling the existing impl. Generic inference (e.g.
`client.tasks.trigger<typeof t>(...)`) is preserved via `Pick<typeof ns,
keyof curatedSubset>` typings.

Two correctness fixes uncovered along the way are folded in:

- `apiClientManager.setGlobalAPIClientConfiguration` no longer silently
no-ops on the second call. `configure()` now actually overrides as users
expect (this is the root cause behind some "I changed the config but
nothing happened" reports).
- `apiClientManager.runWithConfig` (and therefore `auth.withAuth`) is
now backed by `sdkScope.withScope` instead of "mutate the global and
restore in finally". Two parallel `withAuth` calls with different
configs no longer stomp each other.

Surface curation: instance namespaces drop methods that don't make sense
per-instance — `batch.*AndWait` (runtime-dependent), `schedules.task` /
`schedules.timezones` (definition-time / stateless), `prompts.define`
(definition-time), `auth.configure` / `auth.withAuth` (global-only).

## Test plan

- [x] 9 runtime unit tests in `triggerClient.test.ts` cover: required
accessToken, instance auth + branch headers, no env fallback for
identity fields, no leakage between global and instance, four parallel
calls across two clients stay isolated, taskContext masking +
`inheritContext: true` override, `configure()` second-call override,
parallel `auth.withAuth` isolation.
- [x] 10 type-level assertions in `triggerClient.types.test.ts` using
`expectTypeOf` + `@ts-expect-error` lock in generic inference, return
type passthrough, overload preservation, and curated-surface drift.
- [x] Full SDK suite (219 tests) and core suite (530 tests) pass.
- [x] Webapp typecheck clean.
- [x] End-to-end smoke test against local webapp and a
freshly-provisioned cloud project — six concurrent multi-client triggers
all returned 200 with run IDs, headers per-client as expected.
- [ ] Reviewer: run `references/multi-client` per its `README.md` to
reproduce the smoke test locally.

## Try it

`references/multi-client` is a new reference workspace that exercises
this end-to-end:

- `src/trigger/echo.ts` — trivial target task
- `src/trigger/fanOut.ts` — opens two `TriggerClient`s from inside a
task, fires `echo` through each in parallel
- `src/external/main.ts` — external Node script with two clients
triggering `echo` sequentially and concurrently; logs every outgoing
request's `authorization` + `x-trigger-branch`
- `src/external/isolation.ts` — interleaves global `configure()` and an
instance call, asserts the captured fetch sequence shows no leakage
either way
This commit is contained in:
Eric Allam
2026-05-22 12:29:42 +01:00
committed by GitHub
parent 18d7144e74
commit 832cf7220b
26 changed files with 1088 additions and 22 deletions
+18
View File
@@ -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"] });
```
+21 -2
View File
@@ -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",
+52 -9
View File
@@ -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<ReturnType<R>> {
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 {
+1
View File
@@ -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";
+1
View File
@@ -0,0 +1 @@
export { sdkScope, type SdkScope } from "./sdkScope/index.js";
+21
View File
@@ -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<R>(scope: SdkScope, fn: () => R): R {
return installedStorage ? installedStorage.run(scope, fn) : fn();
},
};
@@ -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<SdkScope>();
_installSdkScopeStorage({
getStore: () => als.getStore(),
run: (scope, fn) => als.run(scope, fn),
});
+11
View File
@@ -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<R>(scope: SdkScope, fn: () => R): R;
};
+10
View File
@@ -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 {
+1
View File
@@ -12,6 +12,7 @@
"directory": "packages/trigger-sdk"
},
"type": "module",
"sideEffects": false,
"files": [
"dist"
],
+1
View File
@@ -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.
+1
View File
@@ -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";
+14 -7
View File
@@ -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<TTask extends AnyTask>(
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<TTasks extends readonly AnyTask[]>(
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<TTask extends AnyTask>(
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<TTasks extends readonly AnyTask[
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,
},
};
@@ -2127,7 +2134,7 @@ async function* transformSingleTaskBatchItemsStream<TPayload>(
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<TRunTypes extends AnyRunTypes>(
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<TRunTypes extends AnyRunTypes>(
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"),
},
};
})
@@ -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<typeof installFetchSpy>;
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<void>((r) => (settle.resolveA = r));
const gateB = new Promise<void>((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");
});
});
@@ -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<typeof batch, (typeof batchInstanceKeys)[number]>;
type DeploymentsApi = typeof deployments;
type EnvvarsApi = typeof envvarsModule;
type PromptsApi = Pick<typeof promptsModule, (typeof promptsInstanceKeys)[number]>;
type QueuesApi = typeof queuesModule;
type SchedulesApi = Pick<typeof schedulesModule, (typeof schedulesInstanceKeys)[number]>;
type AuthApi = Pick<typeof auth, (typeof authInstanceKeys)[number]>;
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<T extends object, K extends keyof T = keyof T>(
api: T,
scope: SdkScope,
keys?: readonly K[]
): Pick<T, K> {
const targetKeys = (keys ?? (Object.keys(api) as K[])) as readonly K[];
const bound: Record<string, unknown> = {};
for (const key of targetKeys) {
const value = (api as Record<string, unknown>)[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<T, K>;
}
@@ -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<typeof t>", () => {
// If the proxy cast in bindToScope ever erodes generics, this fails:
// the return type degrades to `unknown` and `.id`/`.taskIdentifier`
// disappear.
type Returned = ReturnType<typeof client.tasks.trigger<ExampleTask>>;
expectTypeOf<Returned>().resolves.toHaveProperty("id");
expectTypeOf<Returned>().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<ApiPromise<...>>.
// We want the original ApiPromise<...> to flow through unchanged.
const handle = client.runs.retrieve<ExampleTask>("run_x");
expectTypeOf(handle).toEqualTypeOf<ReturnType<typeof runs.retrieve<ExampleTask>>>();
// And it should be assignable to a plain Promise (since ApiPromise extends Promise).
expectTypeOf(handle).toMatchTypeOf<Promise<unknown>>();
});
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<Keys>().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<Keys>().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<Keys>().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<Keys>().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<T, K>` ever drops or widens the underlying signatures,
// these break.
it("client.runs is structurally `typeof runs`", () => {
expectTypeOf(client.runs).toEqualTypeOf<typeof runs>();
});
it("client.envvars is structurally `typeof envvars`", () => {
expectTypeOf(client.envvars).toEqualTypeOf<typeof envvars>();
});
});
+26 -4
View File
@@ -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
+45
View File
@@ -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.
+21
View File
@@ -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"
}
}
+93
View File
@@ -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) + "...";
}
+101
View File
@@ -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}...`;
}
+1
View File
@@ -0,0 +1 @@
export {};
@@ -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,
};
},
});
@@ -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 },
};
},
});
+19
View File
@@ -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",
});
+14
View File
@@ -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"]
}