From a082e8597c2700bf207c1ffaa63b3fc4bc9fca06 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Fri, 8 May 2026 20:52:52 +0100 Subject: [PATCH] fix: app user agent for api-client reqs using tauri http plugin (#6045) fix: app user agent --- apps/app-frontend/src/App.vue | 3 +- packages/api-client/README.md | 12 ++- .../api-client/src/core/abstract-client.ts | 18 +++-- packages/api-client/src/features/auth.ts | 8 +- packages/api-client/src/platform/generic.ts | 2 +- packages/api-client/src/platform/nuxt.ts | 12 ++- packages/api-client/src/platform/tauri.ts | 5 +- .../src/platform/xhr-upload-client.ts | 79 ++++++++++--------- packages/api-client/src/types/client.ts | 7 +- 9 files changed, 80 insertions(+), 66 deletions(-) diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index dd8b47f68..1bfb77756 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -146,8 +146,9 @@ const popupNotificationManager = new AppPopupNotificationManager() providePopupNotificationManager(popupNotificationManager) const { addPopupNotification } = popupNotificationManager +const appVersion = getVersion() const tauriApiClient = new TauriModrinthClient({ - userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`, + userAgent: async () => `modrinth/theseus/${await appVersion} (support@modrinth.com)`, labrinthBaseUrl: config.labrinthBaseUrl, archonBaseUrl: config.archonBaseUrl, features: [ diff --git a/packages/api-client/README.md b/packages/api-client/README.md index 7aacc56c4..8ae1bedbd 100644 --- a/packages/api-client/README.md +++ b/packages/api-client/README.md @@ -28,7 +28,7 @@ import { AuthFeature, GenericModrinthClient, type Labrinth } from '@modrinth/api const client = new GenericModrinthClient({ userAgent: 'my-app/1.0.0', - features: [new AuthFeature({ token: 'mrp_...' })], + features: [new AuthFeature({ token: process.env.MODRINTH_TOKEN })], }) const project: Labrinth.Projects.v2.Project = await client.labrinth.projects_v2.get('sodium') @@ -51,14 +51,13 @@ import { AuthFeature, CircuitBreakerFeature, NuxtCircuitBreakerStorage, NuxtModr export const useModrinthClient = async () => { const config = useRuntimeConfig() - const auth = await useAuth() return new NuxtModrinthClient({ userAgent: 'my-nuxt-app/1.0.0', rateLimitKey: import.meta.server ? config.rateLimitKey : undefined, features: [ new AuthFeature({ - token: async () => auth.value.token, + token: process.env.MODRINTH_TOKEN, }), new CircuitBreakerFeature({ storage: new NuxtCircuitBreakerStorage(), @@ -74,10 +73,9 @@ export const useModrinthClient = async () => { import { getVersion } from '@tauri-apps/api/app' import { AuthFeature, TauriModrinthClient } from '@modrinth/api-client' -const version = await getVersion() const client = new TauriModrinthClient({ - userAgent: `modrinth/theseus/${version} (support@modrinth.com)`, - features: [new AuthFeature({ token: 'mrp_...' })], + userAgent: async () => `modrinth/theseus/${await getVersion()} (support@modrinth.com)`, + features: [new AuthFeature({ token: process.env.MODRINTH_TOKEN })], }) const project = await client.labrinth.projects_v2.get('sodium') @@ -138,7 +136,7 @@ Features wrap requests before they reach the platform implementation: import { AuthFeature, CircuitBreakerFeature, RetryFeature } from '@modrinth/api-client' const client = new GenericModrinthClient({ - features: [new AuthFeature({ token: async () => getToken() }), new RetryFeature({ maxAttempts: 3, backoffStrategy: 'exponential' }), new CircuitBreakerFeature({ maxFailures: 3, resetTimeout: 30_000 })], + features: [new AuthFeature({ token: async () => process.env.MODRINTH_TOKEN }), new RetryFeature({ maxAttempts: 3, backoffStrategy: 'exponential' }), new CircuitBreakerFeature({ maxFailures: 3, resetTimeout: 30_000 })], }) ``` diff --git a/packages/api-client/src/core/abstract-client.ts b/packages/api-client/src/core/abstract-client.ts index d9161d9e6..97ce38dd1 100644 --- a/packages/api-client/src/core/abstract-client.ts +++ b/packages/api-client/src/core/abstract-client.ts @@ -125,13 +125,15 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { const url = this.buildUrl(path, baseUrl, options.version) + const defaultHeaders = await this.buildDefaultHeaders() + // Merge options with defaults const mergedOptions: RequestOptions = { method: 'GET', timeout: this.config.timeout, ...options, headers: { - ...this.buildDefaultHeaders(), + ...defaultHeaders, ...options.headers, }, } @@ -306,19 +308,25 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { * Subclasses can override this to add platform-specific headers * (e.g., Nuxt rate limit key) */ - protected buildDefaultHeaders(): Record { + protected async buildDefaultHeaders(): Promise> { const headers: Record = { 'Content-Type': 'application/json', ...this.config.headers, } - if (this.config.userAgent) { - headers['User-Agent'] = this.config.userAgent + const userAgent = await this.resolveUserAgent() + if (userAgent) { + headers['User-Agent'] = userAgent } return headers } + private async resolveUserAgent(): Promise { + const userAgent = this.config.userAgent + return typeof userAgent === 'function' ? await userAgent() : userAgent + } + protected attachArchonSentryCaptureHeader(options: RequestOptions): void { if (options.api !== 'archon' || !options.headers || !this.shouldCaptureArchonRequests()) { return @@ -404,7 +412,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { * @example * ```typescript * const client = new GenericModrinthClient() - * client.addFeature(new AuthFeature({ token: 'mrp_...' })) + * client.addFeature(new AuthFeature({ token: async () => getOAuthToken() })) * client.addFeature(new RetryFeature({ maxAttempts: 3 })) * ``` */ diff --git a/packages/api-client/src/features/auth.ts b/packages/api-client/src/features/auth.ts index 81745b3c4..0172b06a9 100644 --- a/packages/api-client/src/features/auth.ts +++ b/packages/api-client/src/features/auth.ts @@ -33,14 +33,8 @@ export interface AuthConfig extends FeatureConfig { * * @example * ```typescript - * // Static token * const auth = new AuthFeature({ - * token: 'mrp_...' - * }) - * - * // Dynamic token (e.g., from auth state) - * const auth = new AuthFeature({ - * token: async () => await getAuthToken() + * token: async () => process.env.MODRINTH_TOKEN * }) * ``` */ diff --git a/packages/api-client/src/platform/generic.ts b/packages/api-client/src/platform/generic.ts index c91b1de2f..4a939b6f7 100644 --- a/packages/api-client/src/platform/generic.ts +++ b/packages/api-client/src/platform/generic.ts @@ -16,7 +16,7 @@ import { XHRUploadClient } from './xhr-upload-client' * const client = new GenericModrinthClient({ * userAgent: 'my-app/1.0.0', * features: [ - * new AuthFeature({ token: 'mrp_...' }), + * new AuthFeature({ token: async () => getOAuthToken() }), * new RetryFeature({ maxAttempts: 3 }) * ] * }) diff --git a/packages/api-client/src/platform/nuxt.ts b/packages/api-client/src/platform/nuxt.ts index 566471ab6..ce0c45634 100644 --- a/packages/api-client/src/platform/nuxt.ts +++ b/packages/api-client/src/platform/nuxt.ts @@ -66,13 +66,17 @@ export interface NuxtClientConfig extends ClientConfig { * ```typescript * // In a Nuxt composable * const config = useRuntimeConfig() - * const auth = await useAuth() * * const client = new NuxtModrinthClient({ * userAgent: 'my-nuxt-app/1.0.0', * rateLimitKey: import.meta.server ? config.rateLimitKey : undefined, * features: [ - * new AuthFeature({ token: () => auth.value.token }) + * new AuthFeature({ + * token: async () => getOAuthToken() + * }), + * new CircuitBreakerFeature({ + * storage: new NuxtCircuitBreakerStorage() + * }) * ] * }) * @@ -171,9 +175,9 @@ export class NuxtModrinthClient extends XHRUploadClient { return super.normalizeError(error) } - protected buildDefaultHeaders(): Record { + protected async buildDefaultHeaders(): Promise> { const headers: Record = { - ...super.buildDefaultHeaders(), + ...(await super.buildDefaultHeaders()), } // Use the resolved key (populated by resolveRateLimitKey in request()) diff --git a/packages/api-client/src/platform/tauri.ts b/packages/api-client/src/platform/tauri.ts index 6b998b017..725bcc779 100644 --- a/packages/api-client/src/platform/tauri.ts +++ b/packages/api-client/src/platform/tauri.ts @@ -27,11 +27,10 @@ interface HttpError extends Error { * ```typescript * import { getVersion } from '@tauri-apps/api/app' * - * const version = await getVersion() * const client = new TauriModrinthClient({ - * userAgent: `modrinth/theseus/${version} (support@modrinth.com)`, + * userAgent: async () => `modrinth/theseus/${await getVersion()} (support@modrinth.com)`, * features: [ - * new AuthFeature({ token: 'mrp_...' }) + * new AuthFeature({ token: async () => getOAuthToken() }) * ] * }) * diff --git a/packages/api-client/src/platform/xhr-upload-client.ts b/packages/api-client/src/platform/xhr-upload-client.ts index c47b4c342..d16e76589 100644 --- a/packages/api-client/src/platform/xhr-upload-client.ts +++ b/packages/api-client/src/platform/xhr-upload-client.ts @@ -27,50 +27,57 @@ export abstract class XHRUploadClient extends AbstractModrinthClient { const url = this.buildUrl(path, baseUrl, options.version) - // For FormData uploads, don't set Content-Type (let browser set multipart boundary) - // For file uploads, use application/octet-stream - const isFormData = 'formData' in options && options.formData instanceof FormData - const baseHeaders = this.buildDefaultHeaders() - // Remove Content-Type for FormData so browser can set multipart/form-data with boundary - if (isFormData) { - delete baseHeaders['Content-Type'] - } else { - baseHeaders['Content-Type'] = 'application/octet-stream' - } - - const mergedOptions: UploadRequestOptions = { - retry: false, // default: don't retry uploads - ...options, - headers: { - ...baseHeaders, - ...options.headers, - }, - } - this.attachArchonSentryCaptureHeader(mergedOptions) - - const context = this.buildUploadContext(url, path, mergedOptions) - const progressCallbacks: Array<(p: UploadProgress) => void> = [] - if (mergedOptions.onProgress) { - progressCallbacks.push(mergedOptions.onProgress) + if (options.onProgress) { + progressCallbacks.push(options.onProgress) } const abortController = new AbortController() - if (mergedOptions.signal) { - mergedOptions.signal.addEventListener('abort', () => abortController.abort()) + if (options.signal) { + options.signal.addEventListener('abort', () => abortController.abort()) } + let context: RequestContext | undefined const handle: UploadHandle = { - promise: this.executeUploadFeatureChain(context, progressCallbacks, abortController) - .then(async (result) => { - await this.config.hooks?.onResponse?.(result, context) - return result - }) - .catch(async (error) => { - const apiError = this.normalizeError(error, context) + promise: (async () => { + const isFormData = 'formData' in options && options.formData instanceof FormData + const baseHeaders = await this.buildDefaultHeaders() + if (isFormData) { + delete baseHeaders['Content-Type'] + } else { + baseHeaders['Content-Type'] = 'application/octet-stream' + } + + const mergedOptions: UploadRequestOptions = { + retry: false, + ...options, + headers: { + ...baseHeaders, + ...options.headers, + }, + } + this.attachArchonSentryCaptureHeader(mergedOptions) + + const uploadContext = this.buildUploadContext(url, path, mergedOptions) + context = uploadContext + if (abortController.signal.aborted) { + throw new ModrinthApiError('Upload cancelled') + } + + const result = await this.executeUploadFeatureChain( + uploadContext, + progressCallbacks, + abortController, + ) + await this.config.hooks?.onResponse?.(result, uploadContext) + return result + })().catch(async (error) => { + const apiError = this.normalizeError(error, context) + if (context) { await this.config.hooks?.onError?.(apiError, context) - throw apiError - }), + } + throw apiError + }), onProgress: (callback) => { progressCallbacks.push(callback) return handle diff --git a/packages/api-client/src/types/client.ts b/packages/api-client/src/types/client.ts index 5196f2209..45828d5c2 100644 --- a/packages/api-client/src/types/client.ts +++ b/packages/api-client/src/types/client.ts @@ -1,6 +1,9 @@ import type { AbstractFeature } from '../core/abstract-feature' import type { RequestContext } from './request' +export type MaybePromise = T | Promise +export type UserAgentProvider = string | (() => MaybePromise) + /** * Request lifecycle hooks */ @@ -26,11 +29,11 @@ export type RequestHooks = { */ export interface ClientConfig { /** - * User agent string for requests + * User agent string or provider for requests * Should identify your application (e.g., 'my-app/1.0.0') * If not provided, the platform's default user agent will be used */ - userAgent?: string + userAgent?: UserAgentProvider /** * Base URL for Labrinth API (main Modrinth API)