Skip to content

Commit a17cad9

Browse files
feat: support dynamic accessToken function for token refresh
The accessToken option now accepts either a string or a function returning a string. This enables dynamic token refresh patterns: new TriggerChatTransport({ taskId: 'my-task', accessToken: () => getLatestToken(), }) The function is called on each sendMessages() call, allowing fresh tokens to be used for each task trigger. Co-authored-by: Eric Allam <eric@trigger.dev>
1 parent 97811bb commit a17cad9

File tree

3 files changed

+113
-12
lines changed

3 files changed

+113
-12
lines changed

packages/ai/src/transport.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ describe("TriggerChatTransport", () => {
7777

7878
expect(transport).toBeInstanceOf(TriggerChatTransport);
7979
});
80+
81+
it("should accept a function for accessToken", () => {
82+
let tokenCallCount = 0;
83+
const transport = new TriggerChatTransport({
84+
taskId: "my-chat-task",
85+
accessToken: () => {
86+
tokenCallCount++;
87+
return `dynamic-token-${tokenCallCount}`;
88+
},
89+
});
90+
91+
expect(transport).toBeInstanceOf(TriggerChatTransport);
92+
});
8093
});
8194

8295
describe("sendMessages", () => {
@@ -627,6 +640,78 @@ describe("TriggerChatTransport", () => {
627640
});
628641
});
629642

643+
describe("dynamic accessToken", () => {
644+
it("should call the accessToken function for each sendMessages call", async () => {
645+
let tokenCallCount = 0;
646+
647+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
648+
const urlStr = typeof url === "string" ? url : url.toString();
649+
650+
if (urlStr.includes("/trigger")) {
651+
return new Response(
652+
JSON.stringify({ id: `run_dyn_${tokenCallCount}` }),
653+
{
654+
status: 200,
655+
headers: {
656+
"content-type": "application/json",
657+
"x-trigger-jwt": "stream-token",
658+
},
659+
}
660+
);
661+
}
662+
663+
if (urlStr.includes("/realtime/v1/streams/")) {
664+
const chunks: UIMessageChunk[] = [
665+
{ type: "text-start", id: "p1" },
666+
{ type: "text-end", id: "p1" },
667+
];
668+
return new Response(createSSEStream(sseEncode(chunks)), {
669+
status: 200,
670+
headers: {
671+
"content-type": "text/event-stream",
672+
"X-Stream-Version": "v1",
673+
},
674+
});
675+
}
676+
677+
throw new Error(`Unexpected fetch URL: ${urlStr}`);
678+
});
679+
680+
const transport = new TriggerChatTransport({
681+
taskId: "my-task",
682+
accessToken: () => {
683+
tokenCallCount++;
684+
return `dynamic-token-${tokenCallCount}`;
685+
},
686+
baseURL: "https://api.test.trigger.dev",
687+
});
688+
689+
// First call — the token function should be invoked
690+
await transport.sendMessages({
691+
trigger: "submit-message",
692+
chatId: "chat-dyn-1",
693+
messageId: undefined,
694+
messages: [createUserMessage("first")],
695+
abortSignal: undefined,
696+
});
697+
698+
const firstCount = tokenCallCount;
699+
expect(firstCount).toBeGreaterThanOrEqual(1);
700+
701+
// Second call — the token function should be invoked again
702+
await transport.sendMessages({
703+
trigger: "submit-message",
704+
chatId: "chat-dyn-2",
705+
messageId: undefined,
706+
messages: [createUserMessage("second")],
707+
abortSignal: undefined,
708+
});
709+
710+
// Token function was called at least once more
711+
expect(tokenCallCount).toBeGreaterThan(firstCount);
712+
});
713+
});
714+
630715
describe("body merging", () => {
631716
it("should merge ChatRequestOptions.body into the task payload", async () => {
632717
const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => {

packages/ai/src/transport.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,11 @@ const DEFAULT_STREAM_TIMEOUT_SECONDS = 120;
6666
*/
6767
export class TriggerChatTransport implements ChatTransport<UIMessage> {
6868
private readonly taskId: string;
69-
private readonly accessToken: string;
69+
private readonly resolveAccessToken: () => string;
7070
private readonly baseURL: string;
7171
private readonly streamKey: string;
7272
private readonly extraHeaders: Record<string, string>;
7373
private readonly streamTimeoutSeconds: number;
74-
private readonly apiClient: ApiClient;
7574

7675
/**
7776
* Tracks active chat sessions for reconnection support.
@@ -81,12 +80,18 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
8180

8281
constructor(options: TriggerChatTransportOptions) {
8382
this.taskId = options.taskId;
84-
this.accessToken = options.accessToken;
83+
this.resolveAccessToken =
84+
typeof options.accessToken === "function"
85+
? options.accessToken
86+
: () => options.accessToken as string;
8587
this.baseURL = options.baseURL ?? DEFAULT_BASE_URL;
8688
this.streamKey = options.streamKey ?? DEFAULT_STREAM_KEY;
8789
this.extraHeaders = options.headers ?? {};
8890
this.streamTimeoutSeconds = options.streamTimeoutSeconds ?? DEFAULT_STREAM_TIMEOUT_SECONDS;
89-
this.apiClient = new ApiClient(this.baseURL, this.accessToken);
91+
}
92+
93+
private getApiClient(): ApiClient {
94+
return new ApiClient(this.baseURL, this.resolveAccessToken());
9095
}
9196

9297
/**
@@ -118,8 +123,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
118123
...(body ?? {}),
119124
};
120125

126+
const currentToken = this.resolveAccessToken();
127+
121128
// Trigger the task
122-
const triggerResponse = await this.apiClient.triggerTask(this.taskId, {
129+
const apiClient = this.getApiClient();
130+
const triggerResponse = await apiClient.triggerTask(this.taskId, {
123131
payload: JSON.stringify(payload),
124132
options: {
125133
payloadType: "application/json",
@@ -135,11 +143,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
135143
// Store session state for reconnection
136144
this.sessions.set(chatId, {
137145
runId,
138-
publicAccessToken: publicAccessToken ?? this.accessToken,
146+
publicAccessToken: publicAccessToken ?? currentToken,
139147
});
140148

141149
// Subscribe to the realtime stream for this run
142-
return this.subscribeToStream(runId, publicAccessToken ?? this.accessToken, abortSignal);
150+
return this.subscribeToStream(runId, publicAccessToken ?? currentToken, abortSignal);
143151
};
144152

145153
/**

packages/ai/src/types.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,21 @@ export type TriggerChatTransportOptions = {
1111
taskId: string;
1212

1313
/**
14-
* A public access token or trigger token for authenticating with the Trigger.dev API.
15-
* This is used both to trigger the task and to subscribe to the realtime stream.
14+
* An access token for authenticating with the Trigger.dev API.
1615
*
17-
* You can generate one using `auth.createTriggerPublicToken()` or
18-
* `auth.createPublicToken()` from the `@trigger.dev/sdk`.
16+
* This must be a token with permission to trigger the task. You can use:
17+
* - A **trigger public token** created via `auth.createTriggerPublicToken(taskId)` (recommended for frontend use)
18+
* - A **secret API key** (for server-side use only — never expose in the browser)
19+
*
20+
* The token returned from triggering the task (`publicAccessToken`) is automatically
21+
* used for subscribing to the realtime stream.
22+
*
23+
* Can also be a function that returns a token string, useful for dynamic token refresh:
24+
* ```ts
25+
* accessToken: () => getLatestToken()
26+
* ```
1927
*/
20-
accessToken: string;
28+
accessToken: string | (() => string);
2129

2230
/**
2331
* Base URL for the Trigger.dev API.

0 commit comments

Comments
 (0)