feat(sdk,core): add TriggerClient for per-instance SDK configuration#3683
Conversation
🦋 Changeset detectedLatest commit: de3b063 The changes in this PR will be included in the next version bump. This PR includes changesets to release 32 packages
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 |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis 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)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
b1e2967 to
2bf9ae4
Compare
`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.
3229447 to
de3b063
Compare
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.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}andclientOrThrow— identity fields are scope-only when scoped;baseURLstill falls back toTRIGGER_API_URLbecause plumbing (where the API lives) is not identity.taskContext.{ctx, worker, isWarmStart, isInsideTask}— masked inside an isolated scope so aclient.tasks.trigger(...)from inside a task doesn't leak the parent'sparentRunId/lockToVersion/isTestinto a trigger that hits a different project.getEnvVar("TRIGGER_VERSION")reads inshared.tsgo through ascopedEnvVarhelper that returnsundefinedinside an isolated scope.The
TriggerClientclass 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 viaPick<typeof ns, keyof curatedSubset>typings.Two correctness fixes uncovered along the way are folded in:
apiClientManager.setGlobalAPIClientConfigurationno 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 thereforeauth.withAuth) is now backed bysdkScope.withScopeinstead of "mutate the global and restore in finally". Two parallelwithAuthcalls 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
triggerClient.test.tscover: 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: trueoverride,configure()second-call override, parallelauth.withAuthisolation.triggerClient.types.test.tsusingexpectTypeOf+@ts-expect-errorlock in generic inference, return type passthrough, overload preservation, and curated-surface drift.references/multi-clientper itsREADME.mdto reproduce the smoke test locally.Try it
references/multi-clientis a new reference workspace that exercises this end-to-end:src/trigger/echo.ts— trivial target tasksrc/trigger/fanOut.ts— opens twoTriggerClients from inside a task, firesechothrough each in parallelsrc/external/main.ts— external Node script with two clients triggeringechosequentially and concurrently; logs every outgoing request'sauthorization+x-trigger-branchsrc/external/isolation.ts— interleaves globalconfigure()and an instance call, asserts the captured fetch sequence shows no leakage either way