Skip to content

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

Open
ericallam wants to merge 8 commits into
mainfrom
feat/trigger-client
Open

feat(sdk,core): add TriggerClient for per-instance SDK configuration#3683
ericallam wants to merge 8 commits into
mainfrom
feat/trigger-client

Conversation

@ericallam
Copy link
Copy Markdown
Member

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.

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

  • 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.
  • 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.
  • Full SDK suite (219 tests) and core suite (530 tests) pass.
  • Webapp typecheck clean.
  • 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 TriggerClients 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

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 21, 2026

🦋 Changeset detected

Latest commit: de3b063

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 32 packages
Name Type
@trigger.dev/sdk Patch
@trigger.dev/python Patch
@internal/sdk-compat-tests Patch
references-ai-chat Patch
d3-chat Patch
references-d3-openai-agents Patch
references-nextjs-realtime Patch
references-realtime-hooks-test Patch
references-realtime-streams Patch
references-telemetry Patch
@trigger.dev/build Patch
@trigger.dev/core Patch
@trigger.dev/plugins Patch
@trigger.dev/react-hooks Patch
@trigger.dev/redis-worker Patch
@trigger.dev/rsc Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/rbac Patch
trigger.dev Patch
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/schedule-engine Patch
@internal/testcontainers Patch
@internal/tracing Patch
@internal/tsql Patch
@internal/zod-worker Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 21, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR adds an installable SDK scope (types and Node AsyncLocalStorage storage), updates API client configuration resolution to prefer scope-provided apiClientConfig, masks task context when a scope disables inheritance, replaces direct TRIGGER_VERSION env reads with a scoped lookup, introduces a typed TriggerClient class that binds v3 API modules into per-instance surfaces running inside the scope, adjusts package exports/metadata, and adds integration and type tests plus a changeset documenting the feature and fixes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main change: introducing TriggerClient for per-instance SDK configuration. It is specific, directly related to the changeset, and summarizes the primary feature addition.
Description check ✅ Passed The PR description provides comprehensive context including a summary of changes, design rationale, test coverage details, and instructions for reviewers. It follows the template structure with checklist and multiple detailed sections explaining the implementation and benefits.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/trigger-client

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

coderabbitai[bot]

This comment was marked as resolved.

@ericallam ericallam marked this pull request as ready for review May 21, 2026 11:14
devin-ai-integration[bot]

This comment was marked as resolved.

@ericallam ericallam force-pushed the feat/trigger-client branch from b1e2967 to 2bf9ae4 Compare May 21, 2026 11:27
coderabbitai[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

ericallam added 8 commits May 21, 2026 16:51
`new TriggerClient({...})` exposes the management surface (tasks, runs,
schedules, envvars, batch, queues, deployments, prompts, auth) as an
explicit instance with its own auth, preview branch, and baseURL.
Multiple clients can coexist in one process without mutating shared
global state.

Identity fields (`accessToken`, `secretKey`, `previewBranch`) and
task-runtime reads (`parentRunId`, `lockToVersion`, `taskContext.ctx`)
are scope-only by default, so a call from inside a task does not leak
parent context into a trigger that hits a different project. `baseURL`
still falls back to `TRIGGER_API_URL` so local-dev and CI overrides
apply without forcing every consumer to pass it explicitly.

Two correctness fixes folded in:
- `configure()` actually overrides on second call (was silent no-op).
- `auth.withAuth()` is concurrency-safe (no longer mutates the global
  config, uses an AsyncLocalStorage scope instead).

Ships with a `references/multi-client` reference project containing an
echo task, a fan-out task, and two external scripts that smoke-test the
isolation guarantees.
The previous TriggerClient commit added `import { AsyncLocalStorage }
from "node:async_hooks"` in `sdkScope/index.ts`, which is reachable
from `@trigger.dev/core/v3`. Browser bundles importing from the v3
root (webapp dashboard, ai-chat client components) pulled the node
builtin transitively and failed to compile.

Split storage out so the v3 root stays browser-safe:

- `sdkScope/index.ts` exposes the API plus an `_installSdkScopeStorage`
  hook with a slot pattern. No node imports.
- `sdkScope/storage-node.ts` owns the AsyncLocalStorage and installs
  itself via the slot on import. Only file in the package that touches
  `node:async_hooks`.
- Exported as `@trigger.dev/core/v3/sdk-scope-storage`. Deliberately
  NOT re-exported from the v3 root.
- `@trigger.dev/sdk` modules that need the scope (TriggerClient, auth)
  side-effect-import the sub-path.
- `@trigger.dev/sdk` is marked `"sideEffects": false` so browser
  bundles that don't reach TriggerClient or auth tree-shake them and
  their side-effect imports out entirely.

`apiClientManager.runWithConfig` keeps a fallback to in-place global
mutation when storage isn't installed (browser, Edge, Cloudflare
Workers, or Node consumers that haven't imported TriggerClient/auth).
This preserves the pre-existing concurrency-not-safe-but-functional
semantics in runtimes that can't run AsyncLocalStorage. On Node where
TriggerClient or auth has been imported, the ALS path is used and
parallel scopes don't stomp.
…e taskContext cleanup to afterEach

`attw --pack` (check-exports) was failing on the new
`@trigger.dev/core/v3/sdk-scope-storage` sub-path under node10
resolution because the export had no matching `typesVersions`
mapping. Added one alongside the existing per-sub-path entries.

In `triggerClient.test.ts`, moved `taskContext.disable()` from inside
the individual taskContext-masking tests into the shared `afterEach`
so a failing assertion can't leak a stubbed global task context into
later tests in the file.
…itContext scopes

Two follow-ups from review of the TriggerClient PR:

1. The bare side-effect import `import "@trigger.dev/core/v3/sdk-scope-storage"`
   from SDK code (triggerClient.ts, auth.ts) was at risk of being
   tree-shaken away by bundlers that respect `"sideEffects": false`
   on `@trigger.dev/core`. Whitelist the storage-node module in core's
   `sideEffects` array so bundlers keep the install side effect.
   Without this, the scope silently degrades to no-op in production
   bundles even though Node-runtime tests pass.

2. `auth.withAuth({ baseURL: "..." }, fn)` regressed for callers
   relying on `TRIGGER_SECRET_KEY` from the env: the scoped
   accessToken getter returned undefined instead of falling back to
   the env var, so a partial override (just baseURL) broke auth.
   Restore env fallback inside the scope, but gate it on
   `inheritContext: true` so it only applies to withAuth-style scopes,
   not to TriggerClient instances (whose isolation guarantee requires
   identity fields to come only from the constructor config).

Adds an `auth.withAuth` test that covers the partial-override-with-env
case so the regression can't return.
`new TriggerClient()` with no constructor config now resolves
accessToken, previewBranch, and baseURL from the process env
(TRIGGER_SECRET_KEY / TRIGGER_PREVIEW_BRANCH / TRIGGER_API_URL /
VERCEL_GIT_COMMIT_REF / TRIGGER_ACCESS_TOKEN). Explicit constructor
values still win, so multiple instances pointing at different
projects stay isolated. Matches conventions of other env-var-backed
SDKs (OpenAI, Anthropic, Stripe) and removes friction of forcing
\`accessToken: process.env.TRIGGER_SECRET_KEY!\` everywhere.

Mechanics: new \`apiClientManager.resolveApiClientConfig(partial)\`
helper resolves env-derived defaults for missing fields. Both the
TriggerClient constructor and \`apiClientManager.runWithConfig\` (used
by auth.withAuth) feed their config through it before opening a
scope, so the resolution happens once at scope creation and the
scoped getters in apiClientManager just read scope values directly.
Single source of truth replaces the inheritContext-gated env fallback
that was previously sprinkled across the scoped getters.

Constructor early throw dropped — missing auth now surfaces via
ApiClientMissingError at first API call, same as the global API path.
`triggerAndSubscribe_internal` requires `taskContext.ctx` and uses
`ctx.run.id` as the parent run id, so it is fundamentally an
inside-task primitive. Including it on the curated `tasksApi` was a
mistake — with the default `inheritContext: false`, the scoped
taskContext is masked to undefined and the method always throws
"triggerAndSubscribe can only be used from inside a task.run()".

Type test updated to assert the method is no longer reachable from
the instance surface.
`runWithConfig` was building its merged config from the process-wide
global, not from the enclosing ALS scope. That broke the documented
`auth.withAuth(...)` + `auth.withPublicToken(...)` composition: the
inner `withAuth` (called by withPublicToken internally) silently
dropped the outer scope's baseURL/branch overrides.

Read from the active scope first, fall back to the global, then merge
in the new config. Pre-existing concurrency-safety (parallel scopes)
holds. New test covers the nested-composition case.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants