diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts index 2536c545e..7471da17d 100644 --- a/apps/frontend/src/composables/featureFlags.ts +++ b/apps/frontend/src/composables/featureFlags.ts @@ -43,6 +43,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({ hidePreviewBanner: false, i18nDebug: false, showDiscoverProjectButtons: false, + labrinthApiCanary: false, } as const) export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS diff --git a/apps/frontend/src/composables/fetch.js b/apps/frontend/src/composables/fetch.js index 1d188faba..b7a634a0d 100644 --- a/apps/frontend/src/composables/fetch.js +++ b/apps/frontend/src/composables/fetch.js @@ -5,6 +5,7 @@ let cachedRateLimitKey = undefined let rateLimitKeyPromise = undefined +const LABRINTH_CANARY_COOKIE = 'labrinth-canary=always' async function getRateLimitKey(config) { if (config.rateLimitKey) return config.rateLimitKey @@ -38,6 +39,15 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => { options.headers['x-ratelimit-key'] = await getRateLimitKey(config) } + if (useFeatureFlags().value.labrinthApiCanary) { + const existingCookie = options.headers.cookie + if (!existingCookie?.split('; ').includes(LABRINTH_CANARY_COOKIE)) { + options.headers.cookie = existingCookie + ? `${existingCookie}; ${LABRINTH_CANARY_COOKIE}` + : LABRINTH_CANARY_COOKIE + } + } + if (!skipAuth) { const auth = await useAuth() diff --git a/apps/frontend/src/helpers/api.ts b/apps/frontend/src/helpers/api.ts index 5b49bcf3c..7b659edcb 100644 --- a/apps/frontend/src/helpers/api.ts +++ b/apps/frontend/src/helpers/api.ts @@ -2,7 +2,9 @@ import { type AbstractFeature, type AuthConfig, AuthFeature, + CanaryCookieFeature, CircuitBreakerFeature, + LABRINTH_CANARY_COOKIE, NodeAuthFeature, nodeAuthState, NuxtCircuitBreakerStorage, @@ -28,7 +30,11 @@ export function createModrinthClient( auth: Ref<{ token: string | undefined }>, config: { apiBaseUrl: string; archonBaseUrl: string; rateLimitKey?: string }, ): NuxtModrinthClient { + const flags = useFeatureFlags() const optionalFeatures = [ + new CanaryCookieFeature({ + getCookie: () => (flags.value.labrinthApiCanary ? LABRINTH_CANARY_COOKIE : undefined), + }) as AbstractFeature, import.meta.dev ? (new VerboseLoggingFeature() as AbstractFeature) : undefined, ].filter(Boolean) as AbstractFeature[] diff --git a/apps/frontend/src/middleware/project.global.ts b/apps/frontend/src/middleware/project.global.ts index 0bba2ea3e..d18a72b21 100644 --- a/apps/frontend/src/middleware/project.global.ts +++ b/apps/frontend/src/middleware/project.global.ts @@ -25,7 +25,11 @@ export default defineNuxtRouteMiddleware(async (to) => { const queryClient = useAppQueryClient() const authToken = useCookie('auth-token') - const client = useServerModrinthClient({ authToken: authToken.value || undefined }) + const flags = useFeatureFlags() + const client = useServerModrinthClient({ + authToken: authToken.value || undefined, + canaryCookie: flags.value.labrinthApiCanary, + }) const tags = useGeneratedState() const projectId = to.params.id as string diff --git a/apps/frontend/src/server/utils/api-client.ts b/apps/frontend/src/server/utils/api-client.ts index 0352ddb8e..93c4e0eb7 100644 --- a/apps/frontend/src/server/utils/api-client.ts +++ b/apps/frontend/src/server/utils/api-client.ts @@ -1,7 +1,9 @@ import { type AuthConfig, AuthFeature, + CanaryCookieFeature, type FeatureConfig, + LABRINTH_CANARY_COOKIE, type NuxtClientConfig, NuxtModrinthClient, } from '@modrinth/api-client' @@ -20,6 +22,7 @@ async function getRateLimitKeyFromSecretsStore(): Promise { export interface ServerModrinthClientOptions { event?: H3Event authToken?: string + canaryCookie?: boolean } export function useServerModrinthClient(options?: ServerModrinthClientOptions): NuxtModrinthClient { @@ -37,6 +40,10 @@ export function useServerModrinthClient(options?: ServerModrinthClientOptions): ) } + if (options?.canaryCookie) { + features.push(new CanaryCookieFeature({ getCookie: () => LABRINTH_CANARY_COOKIE })) + } + const clientConfig: NuxtClientConfig = { labrinthBaseUrl: apiBaseUrl, rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore, diff --git a/packages/api-client/src/features/canary-cookie.ts b/packages/api-client/src/features/canary-cookie.ts new file mode 100644 index 000000000..e98719579 --- /dev/null +++ b/packages/api-client/src/features/canary-cookie.ts @@ -0,0 +1,37 @@ +import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature' + +export const LABRINTH_CANARY_COOKIE = 'labrinth-canary=always' + +export interface CanaryCookieConfig extends FeatureConfig { + getCookie?: () => string | undefined | Promise +} + +export class CanaryCookieFeature extends AbstractFeature { + declare protected config: CanaryCookieConfig + + constructor(config?: CanaryCookieConfig) { + super(config) + } + + shouldApply(context: Parameters[0]): boolean { + return super.shouldApply(context) && context.options.api === 'labrinth' + } + + async execute(next: () => Promise, context: Parameters[1]) { + const cookie = this.config.getCookie ? await this.config.getCookie() : LABRINTH_CANARY_COOKIE + if (!cookie) { + return next() + } + + const headers = { ...(context.options.headers ?? {}) } + const existingCookie = headers.cookie ?? headers.Cookie + + if (!existingCookie?.split('; ').includes(cookie)) { + headers.cookie = existingCookie ? `${existingCookie}; ${cookie}` : cookie + delete headers.Cookie + context.options.headers = headers + } + + return next() + } +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index fdb802506..1c48c332d 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -9,6 +9,11 @@ export { } from './core/abstract-websocket' export { ModrinthApiError, ModrinthServerError } from './core/errors' export { type AuthConfig, AuthFeature } from './features/auth' +export { + type CanaryCookieConfig, + CanaryCookieFeature, + LABRINTH_CANARY_COOKIE, +} from './features/canary-cookie' export { type CircuitBreakerConfig, CircuitBreakerFeature,