diff --git a/README.md b/README.md index 585d7dc..d129f7a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,19 @@ const gpuTier = await getGPUTier(); Based on the reported `fps` the GPU is then classified into either `tier: 1 (>= 15 fps)`, `tier: 2 (>= 30 fps)` or `tier: 3 (>= 60 fps)`. The higher the tier the more graphically intensive workload you can offer to the user. +## Result types + +`getGPUTier()` returns a `type` field indicating how the result was produced: + +| `type` | Meaning | +| ------------------------ | ----------------------------------------------------------------------------------- | +| `BENCHMARK` | Matched a benchmark entry; `fps` reflects the measured framerate for that GPU. | +| `FALLBACK` | Renderer recognised but no benchmark match found. `tier` is a conservative default. | +| `BENCHMARK_FETCH_FAILED` | Benchmark fetch failed (CDN outage, strict CSP, offline, etc.). Safe to retry. | +| `BLOCKLISTED` | Renderer is on a known-bad list (drivers with severe issues). `tier` is always 0. | +| `WEBGL_UNSUPPORTED` | No WebGL context could be created. `tier` is always 0. | +| `SSR` | Running server-side — no `window`, detection skipped. | + ## API ```ts diff --git a/src/index.ts b/src/index.ts index ebe7d0e..81ce3f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,7 +77,8 @@ export type TierType = | 'WEBGL_UNSUPPORTED' | 'BLOCKLISTED' | 'FALLBACK' - | 'BENCHMARK'; + | 'BENCHMARK' + | 'BENCHMARK_FETCH_FAILED'; export type TierResult = { tier: number; @@ -103,6 +104,11 @@ export const getGPUTier = async ({ benchmarksURL = `https://unpkg.com/@pmndrs/detect-gpu@${version}/dist/benchmarks`, }: GetGPUTier = {}): Promise => { const queryCache: { [k: string]: Promise } = {}; + // Set when any loadBenchmarks() call rejects with a non-OutdatedBenchmarksError + // (e.g. network failure, CORS, CSP blocking unpkg). Consulted only when the + // benchmark result list is empty, so a successful renderer match trumps a + // failed sibling fetch on a different benchmark file. + let benchmarkFetchFailed = false; if (isSSR) { return { tier: 0, @@ -178,6 +184,7 @@ export const getGPUTier = async ({ if (error instanceof OutdatedBenchmarksError) { throw error; } + benchmarkFetchFailed = true; debug?.("queryBenchmarks - couldn't load benchmark:", { error }); return; } @@ -318,6 +325,18 @@ export const getGPUTier = async ({ ); if (blocklistedModel) return toResult(0, 'BLOCKLISTED', blocklistedModel); + // Distinguish "couldn't reach the benchmark CDN" from "GPU is genuinely + // unknown". Silent tier-1 FALLBACK misrepresents fast hardware as slow + // whenever a network/CSP/CORS issue blocks the benchmarks fetch; this + // branch lets consumers detect the condition and retry. + if (benchmarkFetchFailed) { + return toResult( + 1, + 'BENCHMARK_FETCH_FAILED', + `${renderer} (${rawRenderer})` + ); + } + // Apple Silicon on desktop Safari: the renderer string is the generic // "Apple GPU" and Safari reports identical WebGL capabilities across // M1–M5 (verified empirically on M1 Max / M2 / M4), so no web-facing diff --git a/test/index.test.ts b/test/index.test.ts index fdac20d..6256175 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -37,6 +37,7 @@ for (const renderers of [RENDERER_MOBILE, RENDERER_TABLET, RENDERER_DESKTOP]) { 'BLOCKLISTED', 'FALLBACK', 'BENCHMARK', + 'BENCHMARK_FETCH_FAILED', ]).toContain(type); }); } @@ -339,6 +340,22 @@ test('Apple Silicon desktop Safari — conservative tier-3 FALLBACK', async () = expect(result.fps).toBe(60); }); +test('benchmark fetch failure surfaces as BENCHMARK_FETCH_FAILED, not silent FALLBACK', async () => { + // Silent degradation to tier-1 FALLBACK misrepresents fast hardware as + // slow whenever the benchmark CDN is blocked (CSP, CORS, unpkg outage). + // Consumers need a way to detect the condition so they can retry. + const result = await getTier({ + isMobile: false, + renderer: + 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Laptop GPU (0x00002520) Direct3D11 vs_5_0 ps_5_0, D3D11)', + loadBenchmarks: async () => { + throw new Error('simulated network failure'); + }, + }); + expect(result.type).toBe('BENCHMARK_FETCH_FAILED'); + expect(result.tier).toBe(1); +}); + test('Apple GPU on mobile does NOT take the desktop tier-3 path', async () => { // iPhone/iPad route through deobfuscateAppleGPU and resolve to specific // chip benchmarks. The desktop tier-3 fallback must not fire on mobile, @@ -417,11 +434,11 @@ test('Apple GPU on mobile does NOT take the desktop tier-3 path', async () => { }); }); -test(`When queryBenchmarks throws, FALLBACK is returned`, async () => { +test(`When queryBenchmarks throws, BENCHMARK_FETCH_FAILED is returned`, async () => { expectGPUResults( { tier: 1, - type: 'FALLBACK', + type: 'BENCHMARK_FETCH_FAILED', }, await getTier({ loadBenchmarks: async (): Promise => {