Created
June 23, 2025 11:48
-
-
Save izakfilmalter/85eeafd46e444a9c33288971cf56842a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { HttpApiClient, type HttpApiError, type HttpClientError } from '@effect/platform' | |
import { RefreshApi } from '@openfaith/pco/refreshApi' | |
import { TokenKey, TokenManager, type TokenState } from '@openfaith/pco/tokenManager' | |
import { Clock, Context, Effect, HashMap, Layer, Option, type ParseResult, Ref } from 'effect' | |
// Internal service tag | |
export class PcoAuth extends Context.Tag('Pco/PcoAuth')< | |
PcoAuth, | |
{ | |
readonly getValidAccessToken: Effect.Effect< | |
string, | |
HttpApiError.HttpApiDecodeError | HttpClientError.HttpClientError | ParseResult.ParseError, | |
TokenKey | |
> | |
} | |
>() {} | |
// Live implementation of the PcoAuth service | |
export const PcoAuthLive = Layer.scoped( | |
PcoAuth, | |
Effect.gen(function* () { | |
const tokenManager = yield* TokenManager | |
const clock = yield* Clock.Clock | |
// This lock is for the REFRESH operation. | |
const tokenRefreshLock = yield* Effect.makeSemaphore(1) | |
// This lock is for the INITIAL LOAD operation for a new key. | |
const tokenLoadLock = yield* Effect.makeSemaphore(1) | |
// const tokenCache = yield* Ref.make(new Map<string, Ref.Ref<TokenState>>()) | |
const tokenCache = yield* Ref.make(HashMap.empty<string, Ref.Ref<TokenState>>()) | |
const refreshClient = yield* HttpApiClient.make(RefreshApi, { | |
baseUrl: 'https://api.planningcenteronline.com', | |
}) | |
const getOrgTokenStateRef = (tokenKey?: string) => | |
Effect.gen(function* () { | |
const key = tokenKey ?? 'default' | |
const cached = yield* Ref.get(tokenCache).pipe(Effect.map(HashMap.get(key))) | |
if (Option.isSome(cached)) { | |
return cached.value | |
} | |
// Cache miss. We need to load from the user's TokenManager. | |
// Use a lock to ensure only one fiber loads the token for a new key. | |
return yield* tokenLoadLock.withPermits(1)( | |
Effect.gen(function* () { | |
// Double-check after acquiring the lock. | |
const cachedAfterLock = yield* Ref.get(tokenCache).pipe(Effect.map(HashMap.get(key))) | |
if (Option.isSome(cachedAfterLock)) { | |
return cachedAfterLock.value | |
} | |
// We are the designated loader for this key. | |
const initialState = yield* tokenManager.loadTokenState(tokenKey) | |
const newRef = yield* Ref.make(initialState) | |
yield* Ref.update(tokenCache, HashMap.set(key, newRef)) | |
return newRef | |
}), | |
) | |
}) | |
const getValidAccessToken = Effect.gen(function* () { | |
const tokenKey = yield* TokenKey | |
const tokenStateRef = yield* getOrgTokenStateRef(tokenKey) | |
const state = yield* Ref.get(tokenStateRef) | |
const now = yield* clock.currentTimeMillis | |
if (state.expiresAt.getTime() - 60_000 >= now) { | |
return state.accessToken | |
} | |
const refreshEffect = Effect.gen(function* () { | |
const currentState = yield* Ref.get(tokenStateRef) | |
const nowAfterLock = yield* clock.currentTimeMillis | |
if (currentState.expiresAt.getTime() - 60_000 >= nowAfterLock) { | |
return currentState.accessToken | |
} | |
console.log(`Token for key "${tokenKey}" expired. Refreshing now...`) | |
const newTokens = yield* refreshClient.auth.refreshToken({ | |
payload: { | |
grant_type: 'refresh_token', | |
refresh_token: currentState.refreshToken, | |
}, | |
}) | |
const newExpiry = new Date((yield* clock.currentTimeMillis) + newTokens.expires_in * 1000) | |
const newState: TokenState = { | |
accessToken: newTokens.access_token, | |
expiresAt: newExpiry, | |
refreshToken: newTokens.refresh_token, | |
tokenKey: currentState.tokenKey, // Preserve the original key | |
} | |
// Save and update the state | |
yield* tokenManager.saveTokenState(newState) | |
yield* Ref.set(tokenStateRef, newState) | |
console.log(`Token for key "${tokenKey}" refresh successful.`) | |
return newState.accessToken | |
}) | |
return yield* tokenRefreshLock.withPermits(1)(refreshEffect) | |
}) | |
return PcoAuth.of({ getValidAccessToken }) | |
}), | |
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Context, type Effect } from 'effect' | |
export class TokenKey extends Context.Tag('Pco/TokenKey')<TokenKey, string>() {} | |
// The shape of the token data your library needs to manage. | |
export interface TokenState { | |
readonly accessToken: string | |
readonly refreshToken: string | |
readonly expiresAt: Date | |
readonly tokenKey: string | |
} | |
// The service interface the user must implement. | |
export class TokenManager extends Context.Tag('Pco/TokenManager')< | |
TokenManager, | |
{ | |
// How to load the initial token state from the user's storage. | |
readonly loadTokenState: (lookupKey?: string) => Effect.Effect<TokenState> | |
// How to save the new token state after a successful refresh. | |
readonly saveTokenState: (state: TokenState) => Effect.Effect<void> | |
} | |
>() {} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment