Skip to content

feat(core): support refreshing access tokens for stream subscriptions - #4809

Closed
claude[bot] wants to merge 2 commits into
mainfrom
feat/access-token-refresh
Closed

feat(core): support refreshing access tokens for stream subscriptions#4809
claude[bot] wants to merge 2 commits into
mainfrom
feat/access-token-refresh

Conversation

@claude

@claude claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Requested by Matt Aitken · Slack thread

✅ Checklist

  • I have followed every step in the contributing guide
  • The PR title follows the convention.
  • I ran and tested the code works

Testing

Covered by unit tests in packages/core, run with the package's vitest suite:

  • packages/core/src/v3/apiClient/runStream.test.ts — 8 new cases against SSEStreamSubscription: a 401 with no resolveHeaders still fails the stream (unchanged behaviour); a 401 and a 403 each retry once with the refreshed headers; a refreshed token that is rejected again is terminal; a refresher that throws is terminal; a 401 the refresh recovered from is not reported to onError; a connection that is accepted but delivers no records does not get a second mint; and a connection that has delivered a record may refresh again.
  • packages/core/src/v3/apiClient/refreshAccessToken.test.ts — 4 new cases on the dedupe helper: concurrent callers share one in-flight mint, different refreshers do not share, a new mint happens once the previous one settles, and a rejected mint rejects every concurrent caller without poisoning later calls.

The "does not report a 401 that the refresh recovered from" case fails against the pre-fix code, where onError was invoked before the status was classified.


Changelog

Before. An access token was fixed for the life of a streaming subscription. When it expired, the stream terminated with an auth error and there was no way to recover short of tearing down the subscription and creating a new one.

After. An optional refreshAccessToken on the client configuration (ApiClientConfiguration), and as an option on useApiClient / TriggerAuthContext. Stream subscriptions now resolve their auth header per connection attempt, so an expired token triggers one re-mint and one retry. Behaviour is unchanged when no refresher is supplied: auth errors stay terminal exactly as before.

How it works.

  • ApiClient hands stream subscriptions a per-attempt header resolver (resolveHeaders) instead of only a fixed header snapshot. It is undefined unless a refreshAccessToken was configured, which is what keeps the no-refresher path byte-for-byte the old behaviour.
  • The refresh is bounded to one per connection: after a 401/403 the subscription re-resolves its headers once and retries; a second auth failure on the same connection is terminal, as is a refresher that throws. The bound re-arms only after a record has actually been delivered, so a server that accepts the connection and immediately drops it cannot drive an unbounded mint loop.
  • refreshAccessTokenOnce dedupes concurrent refreshes, keyed on the refresher function itself, so multiple subscriptions or React hooks sharing one refresher mint a single token rather than one each. useApiClient keeps the caller's refresher ref-stable so that key survives re-renders.

The changeset is currently a patch for @trigger.dev/core and @trigger.dev/react-hooks. This adds new public API surface, so whether it should be a minor bump instead is still under discussion — happy to switch it.


Screenshots

n/a — no UI changes.

💯

claude added 2 commits August 28, 2026 00:03
Realtime SSE subscriptions read their auth headers per HTTP attempt. When a
refreshAccessToken callback is configured, a 401/403 now re-resolves the
headers and retries the connect once instead of failing the stream.
…ting

Pass the refresh resolver to the runShapeStream factory so subscribeToRun,
subscribeToRunsWithTag and subscribeToBatch recover from an expired token.
Only re-arm the refresh after a connection delivers a record, keep the auth
error terminal when the refresher throws, and don't report a 401 the refresh
recovered from.
@changeset-bot

changeset-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 525b842

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

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/core Patch
@trigger.dev/react-hooks Patch
@trigger.dev/build Patch
trigger.dev Patch
@trigger.dev/python Patch
@trigger.dev/redis-worker Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/sdk Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@trigger.dev/rbac Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@internal/tracing Patch
@internal/webhook-engine Patch
@internal/webhook-sources Patch
@internal/dashboard-agent Patch
@internal/cache Patch
@trigger.dev/rsc Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/sso Patch
@internal/testcontainers 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

@ericallam

Copy link
Copy Markdown
Member

Closing in favor of a combined realtime-streams PR that folds this token-refresh work in alongside the session-read hook (#4808) and the start-from-latest subscribe option.

@ericallam ericallam closed this Aug 28, 2026
ericallam added a commit that referenced this pull request Aug 28, 2026
…#4811)

## Summary

Realtime streams get a live "last value" mode: subscribe from the latest
record instead of replaying the whole history, keep memory bounded, and
resume across reloads. Plus a new `useSessionStream` hook for reading a
Session's channels from React.

## `useRealtimeStream`: start-from-latest, bounded, resumable

```tsx
const { parts, lastEventId } = useRealtimeStream<Frame>(runId, "frames", {
  from: "latest",   // skip history, only new records after connect
  maxParts: 1,      // keep just the most recent (bounded memory)
  lastEventId: saved, // resume from a persisted cursor (survives reload)
  onParts: (batch) => save(batch.at(-1)?.id), // per-batch event ids
  accessToken,
});
```

`from`, `lastEventId` (option and return), and the batching also apply
to `streams.read()` and `fetchStream()`.

## `useSessionStream`: read a Session channel from React (new)

A read-only hook for a Session's `out` (default) or `in` channel, with
the same start / bound / resume options. `useSession` is reserved for
two-way (read and write).

```tsx
const { records, lastEventId } = useSessionStream<Frame>(sessionId, {
  io: "out",
  from: "latest",
  maxRecords: 5,
  onRecords: (batch) => {/* each throttled batch, with event ids */},
  accessToken,
});
```

## Access-token refresh

Long-lived subscriptions can survive token expiry: pass
`refreshAccessToken` and a 401/403 triggers one re-mint and reconnect.
With no refresher, auth errors stay terminal exactly as before.

```tsx
const { parts } = useRealtimeStream<Frame>(runId, "frames", {
  accessToken,
  // called on a 401/403 to mint a fresh public token from your backend
  refreshAccessToken: async () => {
    const res = await fetch("/api/realtime-token");
    return (await res.json()).token;
  },
});
```

It is also available on `useApiClient` / `TriggerAuthContext`, so every
hook under a provider shares one refresher.

## Notes

Server support (S2 `tail_offset` / Redis `$`, and the start-position
header on the run and session SSE routes) ships here; a client passing
`from: "latest"` against an older server degrades safely to a full
replay. Resume, bounded memory, batched callbacks, and token refresh are
client-only.

Supersedes #4808 and #4809, folded in here. Verified end to end on an
isolated stack: `from: "latest"` on the run and session paths against
real S2, `lastEventId` resume across a reload, bounded memory, batched
callbacks, and a real 401 to token-refresh to reconnect.
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