fix: app user agent for api-client reqs using tauri http plugin (#6045)

fix: app user agent
This commit is contained in:
Calum H.
2026-05-08 20:52:52 +01:00
committed by GitHub
parent 7048a35e9f
commit a082e8597c
9 changed files with 80 additions and 66 deletions

View File

@@ -146,8 +146,9 @@ const popupNotificationManager = new AppPopupNotificationManager()
providePopupNotificationManager(popupNotificationManager) providePopupNotificationManager(popupNotificationManager)
const { addPopupNotification } = popupNotificationManager const { addPopupNotification } = popupNotificationManager
const appVersion = getVersion()
const tauriApiClient = new TauriModrinthClient({ const tauriApiClient = new TauriModrinthClient({
userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`, userAgent: async () => `modrinth/theseus/${await appVersion} (support@modrinth.com)`,
labrinthBaseUrl: config.labrinthBaseUrl, labrinthBaseUrl: config.labrinthBaseUrl,
archonBaseUrl: config.archonBaseUrl, archonBaseUrl: config.archonBaseUrl,
features: [ features: [

View File

@@ -28,7 +28,7 @@ import { AuthFeature, GenericModrinthClient, type Labrinth } from '@modrinth/api
const client = new GenericModrinthClient({ const client = new GenericModrinthClient({
userAgent: 'my-app/1.0.0', 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') 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 () => { export const useModrinthClient = async () => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const auth = await useAuth()
return new NuxtModrinthClient({ return new NuxtModrinthClient({
userAgent: 'my-nuxt-app/1.0.0', userAgent: 'my-nuxt-app/1.0.0',
rateLimitKey: import.meta.server ? config.rateLimitKey : undefined, rateLimitKey: import.meta.server ? config.rateLimitKey : undefined,
features: [ features: [
new AuthFeature({ new AuthFeature({
token: async () => auth.value.token, token: process.env.MODRINTH_TOKEN,
}), }),
new CircuitBreakerFeature({ new CircuitBreakerFeature({
storage: new NuxtCircuitBreakerStorage(), storage: new NuxtCircuitBreakerStorage(),
@@ -74,10 +73,9 @@ export const useModrinthClient = async () => {
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { AuthFeature, TauriModrinthClient } from '@modrinth/api-client' import { AuthFeature, TauriModrinthClient } from '@modrinth/api-client'
const version = await getVersion()
const client = new TauriModrinthClient({ 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_...' })], features: [new AuthFeature({ token: process.env.MODRINTH_TOKEN })],
}) })
const project = await client.labrinth.projects_v2.get('sodium') 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' import { AuthFeature, CircuitBreakerFeature, RetryFeature } from '@modrinth/api-client'
const client = new GenericModrinthClient({ 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 })],
}) })
``` ```

View File

@@ -125,13 +125,15 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
const url = this.buildUrl(path, baseUrl, options.version) const url = this.buildUrl(path, baseUrl, options.version)
const defaultHeaders = await this.buildDefaultHeaders()
// Merge options with defaults // Merge options with defaults
const mergedOptions: RequestOptions = { const mergedOptions: RequestOptions = {
method: 'GET', method: 'GET',
timeout: this.config.timeout, timeout: this.config.timeout,
...options, ...options,
headers: { headers: {
...this.buildDefaultHeaders(), ...defaultHeaders,
...options.headers, ...options.headers,
}, },
} }
@@ -306,19 +308,25 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
* Subclasses can override this to add platform-specific headers * Subclasses can override this to add platform-specific headers
* (e.g., Nuxt rate limit key) * (e.g., Nuxt rate limit key)
*/ */
protected buildDefaultHeaders(): Record<string, string> { protected async buildDefaultHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...this.config.headers, ...this.config.headers,
} }
if (this.config.userAgent) { const userAgent = await this.resolveUserAgent()
headers['User-Agent'] = this.config.userAgent if (userAgent) {
headers['User-Agent'] = userAgent
} }
return headers return headers
} }
private async resolveUserAgent(): Promise<string | undefined> {
const userAgent = this.config.userAgent
return typeof userAgent === 'function' ? await userAgent() : userAgent
}
protected attachArchonSentryCaptureHeader(options: RequestOptions): void { protected attachArchonSentryCaptureHeader(options: RequestOptions): void {
if (options.api !== 'archon' || !options.headers || !this.shouldCaptureArchonRequests()) { if (options.api !== 'archon' || !options.headers || !this.shouldCaptureArchonRequests()) {
return return
@@ -404,7 +412,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
* @example * @example
* ```typescript * ```typescript
* const client = new GenericModrinthClient() * const client = new GenericModrinthClient()
* client.addFeature(new AuthFeature({ token: 'mrp_...' })) * client.addFeature(new AuthFeature({ token: async () => getOAuthToken() }))
* client.addFeature(new RetryFeature({ maxAttempts: 3 })) * client.addFeature(new RetryFeature({ maxAttempts: 3 }))
* ``` * ```
*/ */

View File

@@ -33,14 +33,8 @@ export interface AuthConfig extends FeatureConfig {
* *
* @example * @example
* ```typescript * ```typescript
* // Static token
* const auth = new AuthFeature({ * const auth = new AuthFeature({
* token: 'mrp_...' * token: async () => process.env.MODRINTH_TOKEN
* })
*
* // Dynamic token (e.g., from auth state)
* const auth = new AuthFeature({
* token: async () => await getAuthToken()
* }) * })
* ``` * ```
*/ */

View File

@@ -16,7 +16,7 @@ import { XHRUploadClient } from './xhr-upload-client'
* const client = new GenericModrinthClient({ * const client = new GenericModrinthClient({
* userAgent: 'my-app/1.0.0', * userAgent: 'my-app/1.0.0',
* features: [ * features: [
* new AuthFeature({ token: 'mrp_...' }), * new AuthFeature({ token: async () => getOAuthToken() }),
* new RetryFeature({ maxAttempts: 3 }) * new RetryFeature({ maxAttempts: 3 })
* ] * ]
* }) * })

View File

@@ -66,13 +66,17 @@ export interface NuxtClientConfig extends ClientConfig {
* ```typescript * ```typescript
* // In a Nuxt composable * // In a Nuxt composable
* const config = useRuntimeConfig() * const config = useRuntimeConfig()
* const auth = await useAuth()
* *
* const client = new NuxtModrinthClient({ * const client = new NuxtModrinthClient({
* userAgent: 'my-nuxt-app/1.0.0', * userAgent: 'my-nuxt-app/1.0.0',
* rateLimitKey: import.meta.server ? config.rateLimitKey : undefined, * rateLimitKey: import.meta.server ? config.rateLimitKey : undefined,
* features: [ * 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) return super.normalizeError(error)
} }
protected buildDefaultHeaders(): Record<string, string> { protected async buildDefaultHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
...super.buildDefaultHeaders(), ...(await super.buildDefaultHeaders()),
} }
// Use the resolved key (populated by resolveRateLimitKey in request()) // Use the resolved key (populated by resolveRateLimitKey in request())

View File

@@ -27,11 +27,10 @@ interface HttpError extends Error {
* ```typescript * ```typescript
* import { getVersion } from '@tauri-apps/api/app' * import { getVersion } from '@tauri-apps/api/app'
* *
* const version = await getVersion()
* const client = new TauriModrinthClient({ * const client = new TauriModrinthClient({
* userAgent: `modrinth/theseus/${version} (support@modrinth.com)`, * userAgent: async () => `modrinth/theseus/${await getVersion()} (support@modrinth.com)`,
* features: [ * features: [
* new AuthFeature({ token: 'mrp_...' }) * new AuthFeature({ token: async () => getOAuthToken() })
* ] * ]
* }) * })
* *

View File

@@ -27,50 +27,57 @@ export abstract class XHRUploadClient extends AbstractModrinthClient {
const url = this.buildUrl(path, baseUrl, options.version) 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> = [] const progressCallbacks: Array<(p: UploadProgress) => void> = []
if (mergedOptions.onProgress) { if (options.onProgress) {
progressCallbacks.push(mergedOptions.onProgress) progressCallbacks.push(options.onProgress)
} }
const abortController = new AbortController() const abortController = new AbortController()
if (mergedOptions.signal) { if (options.signal) {
mergedOptions.signal.addEventListener('abort', () => abortController.abort()) options.signal.addEventListener('abort', () => abortController.abort())
} }
let context: RequestContext | undefined
const handle: UploadHandle<T> = { const handle: UploadHandle<T> = {
promise: this.executeUploadFeatureChain<T>(context, progressCallbacks, abortController) promise: (async () => {
.then(async (result) => { const isFormData = 'formData' in options && options.formData instanceof FormData
await this.config.hooks?.onResponse?.(result, context) const baseHeaders = await this.buildDefaultHeaders()
return result if (isFormData) {
}) delete baseHeaders['Content-Type']
.catch(async (error) => { } else {
const apiError = this.normalizeError(error, context) 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<T>(
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) await this.config.hooks?.onError?.(apiError, context)
throw apiError }
}), throw apiError
}),
onProgress: (callback) => { onProgress: (callback) => {
progressCallbacks.push(callback) progressCallbacks.push(callback)
return handle return handle

View File

@@ -1,6 +1,9 @@
import type { AbstractFeature } from '../core/abstract-feature' import type { AbstractFeature } from '../core/abstract-feature'
import type { RequestContext } from './request' import type { RequestContext } from './request'
export type MaybePromise<T> = T | Promise<T>
export type UserAgentProvider = string | (() => MaybePromise<string | undefined>)
/** /**
* Request lifecycle hooks * Request lifecycle hooks
*/ */
@@ -26,11 +29,11 @@ export type RequestHooks = {
*/ */
export interface ClientConfig { 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') * Should identify your application (e.g., 'my-app/1.0.0')
* If not provided, the platform's default user agent will be used * If not provided, the platform's default user agent will be used
*/ */
userAgent?: string userAgent?: UserAgentProvider
/** /**
* Base URL for Labrinth API (main Modrinth API) * Base URL for Labrinth API (main Modrinth API)