diff --git a/libs/client/package.json b/libs/client/package.json index af2da50..9b9af2e 100644 --- a/libs/client/package.json +++ b/libs/client/package.json @@ -1,7 +1,7 @@ { "name": "@fal-ai/client", "description": "The fal.ai client for JavaScript and TypeScript", - "version": "1.9.3", + "version": "1.9.4", "license": "MIT", "repository": { "type": "git", diff --git a/libs/client/src/realtime.ts b/libs/client/src/realtime.ts index 7168905..85d7ca6 100644 --- a/libs/client/src/realtime.ts +++ b/libs/client/src/realtime.ts @@ -163,6 +163,7 @@ const connectionStateMachine = createMachine( ), active: state( transition("send", "active", reduce(sendMessage)), + transition("authenticated", "active", reduce(setToken)), transition("unauthorized", "idle", reduce(expireToken)), transition("connectionClosed", "idle", reduce(closeConnection)), transition("close", "idle", reduce(closeConnection)), @@ -539,6 +540,8 @@ export function createRealtimeClient({ let previousState: string | undefined; let latestEnqueuedMessage: any; + let tokenRefreshTimer: ReturnType | undefined; + let tokenRefreshGeneration = 0; // Although the state machine is cached so we don't open multiple connections, // we still need to update the callbacks so we can call the correct references @@ -571,6 +574,8 @@ export function createRealtimeClient({ previousState !== machine.current ) { send({ type: "initiateAuth" }); + tokenRefreshGeneration++; + const generation = tokenRefreshGeneration; // Use custom tokenProvider if provided, otherwise use default const appId = ensureEndpointIdFormat(app); const resolvedPath = @@ -586,28 +591,52 @@ export function createRealtimeClient({ return getTemporaryAuthToken(app, config); }; + const effectiveExpiration = tokenProvider + ? tokenExpirationSeconds + : TOKEN_EXPIRATION_SECONDS; + + const scheduleTokenRefresh = + effectiveExpiration !== undefined + ? () => { + clearTimeout(tokenRefreshTimer); + const refreshMs = Math.round( + effectiveExpiration * 0.9 * 1000, + ); + tokenRefreshTimer = setTimeout(() => { + if (generation !== tokenRefreshGeneration) { + return; + } + fetchToken() + .then((newToken) => { + if (generation !== tokenRefreshGeneration) { + return; + } + queueMicrotask(() => { + send({ type: "authenticated", token: newToken }); + }); + scheduleTokenRefresh(); + }) + .catch(() => { + if (generation !== tokenRefreshGeneration) { + return; + } + const retryMs = Math.round( + effectiveExpiration * 0.05 * 1000, + ); + tokenRefreshTimer = setTimeout(() => { + scheduleTokenRefresh(); + }, retryMs); + }); + }, refreshMs); + } + : noop; + fetchToken() .then((token) => { - // Use queueMicrotask to ensure the state machine processes - // this on a clean call stack, not nested inside onChange. - // robot3's interpret can lose track of state changes when - // send() is called synchronously inside an onChange handler. queueMicrotask(() => { send({ type: "authenticated", token }); }); - // Only schedule token refresh if we know the expiration time. - // For custom tokenProvider without tokenExpirationSeconds, skip auto-refresh. - const effectiveExpiration = tokenProvider - ? tokenExpirationSeconds - : TOKEN_EXPIRATION_SECONDS; - if (effectiveExpiration !== undefined) { - const tokenRefreshInterval = Math.round( - effectiveExpiration * 0.9 * 1000, - ); - setTimeout(() => { - send({ type: "expireToken" }); - }, tokenRefreshInterval); - } + scheduleTokenRefresh(); }) .catch((error) => { queueMicrotask(() => { @@ -669,6 +698,10 @@ export function createRealtimeClient({ }); }; } + if (previousState === "active" && machine.current !== "active") { + clearTimeout(tokenRefreshTimer); + tokenRefreshTimer = undefined; + } previousState = machine.current; }, );