From 570643e59d34601a53d2afa0a382245eb2ff01ec Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 15 Jul 2025 14:26:36 -0300 Subject: [PATCH 1/3] feat: add missing HTTP headers for client platform and runtime detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add X-Supabase-Client-Platform header with platform detection (macOS, Windows, Linux, iOS, Android) - Add X-Supabase-Client-Platform-Version header with OS version detection - Add X-Supabase-Client-Runtime header with runtime detection (node, deno, bun, web) - Add X-Supabase-Client-Runtime-Version header with runtime version detection - Add comprehensive unit tests for all new header functionality - Maintain backward compatibility with existing X-Client-Info header 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/constants.ts | 99 ++++++++++++++++++++++++++++++++++++- test/unit/constants.test.ts | 71 +++++++++++++++++++++++--- 2 files changed, 163 insertions(+), 7 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 101927de..fc1fe5f1 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -15,7 +15,104 @@ if (typeof Deno !== 'undefined') { JS_ENV = 'node' } -export const DEFAULT_HEADERS = { 'X-Client-Info': `supabase-js-${JS_ENV}/${version}` } +export function getClientPlatform(): string { + // @ts-ignore + if (typeof navigator !== 'undefined' && navigator.platform) { + // @ts-ignore + const platform = navigator.platform.toLowerCase() + if (platform.includes('mac')) return 'macOS' + if (platform.includes('win')) return 'Windows' + if (platform.includes('linux')) return 'Linux' + if (platform.includes('iphone') || platform.includes('ipad')) return 'iOS' + if (platform.includes('android')) return 'Android' + // @ts-ignore + return navigator.platform + } + // @ts-ignore + if (typeof process !== 'undefined' && process.platform) { + // @ts-ignore + const platform = process.platform + if (platform === 'darwin') return 'macOS' + if (platform === 'win32') return 'Windows' + if (platform === 'linux') return 'Linux' + return platform + } + return 'unknown' +} + +export function getClientPlatformVersion(): string { + // @ts-ignore + if (typeof navigator !== 'undefined' && navigator.userAgent) { + // @ts-ignore + const userAgent = navigator.userAgent + const macMatch = userAgent.match(/Mac OS X (\d+[._]\d+[._]\d+)/) + if (macMatch) return macMatch[1].replace(/_/g, '.') + + const windowsMatch = userAgent.match(/Windows NT (\d+\.\d+)/) + if (windowsMatch) return windowsMatch[1] + + const iosMatch = userAgent.match(/OS (\d+[._]\d+[._]\d+)/) + if (iosMatch) return iosMatch[1].replace(/_/g, '.') + + const androidMatch = userAgent.match(/Android (\d+\.\d+)/) + if (androidMatch) return androidMatch[1] + } + // @ts-ignore + if (typeof process !== 'undefined' && process.version) { + // @ts-ignore + return process.version.slice(1) + } + return 'unknown' +} + +export function getClientRuntime(): string { + // @ts-ignore + if (typeof Deno !== 'undefined') { + return 'deno' + } + // @ts-ignore + if (typeof Bun !== 'undefined') { + return 'bun' + } + // @ts-ignore + if (typeof process !== 'undefined' && process.versions && process.versions.node) { + return 'node' + } + if (typeof document !== 'undefined') { + return 'web' + } + return 'unknown' +} + +export function getClientRuntimeVersion(): string { + // @ts-ignore + if (typeof Deno !== 'undefined' && Deno.version) { + // @ts-ignore + return Deno.version.deno + } + // @ts-ignore + if (typeof Bun !== 'undefined' && Bun.version) { + // @ts-ignore + return Bun.version + } + // @ts-ignore + if (typeof process !== 'undefined' && process.versions && process.versions.node) { + // @ts-ignore + return process.versions.node + } + if (typeof document !== 'undefined') { + return 'unknown' + } + return 'unknown' +} + +export const DEFAULT_HEADERS = { + 'X-Client-Info': `supabase-js-${JS_ENV}/${version}`, + 'X-Supabase-Client-Platform': getClientPlatform(), + 'X-Supabase-Client-Platform-Version': getClientPlatformVersion(), + 'X-Supabase-Client-Runtime': getClientRuntime(), + 'X-Supabase-Client-Runtime-Version': getClientRuntimeVersion(), +} export const DEFAULT_GLOBAL_OPTIONS = { headers: DEFAULT_HEADERS, diff --git a/test/unit/constants.test.ts b/test/unit/constants.test.ts index 814c75b8..1c58f854 100644 --- a/test/unit/constants.test.ts +++ b/test/unit/constants.test.ts @@ -1,4 +1,10 @@ -import { DEFAULT_HEADERS } from '../../src/lib/constants' +import { + DEFAULT_HEADERS, + getClientPlatform, + getClientPlatformVersion, + getClientRuntime, + getClientRuntimeVersion, +} from '../../src/lib/constants' import { version } from '../../src/lib/version' test('it has the correct type of returning with the correct value', () => { @@ -13,11 +19,64 @@ test('it has the correct type of returning with the correct value', () => { } else { JS_ENV = 'node' } - const expected = { - 'X-Client-Info': `supabase-js-${JS_ENV}/${version}`, - } - expect(DEFAULT_HEADERS).toEqual(expected) + expect(typeof DEFAULT_HEADERS).toBe('object') expect(typeof DEFAULT_HEADERS['X-Client-Info']).toBe('string') - expect(Object.keys(DEFAULT_HEADERS).length).toBe(1) + expect(DEFAULT_HEADERS['X-Client-Info']).toBe(`supabase-js-${JS_ENV}/${version}`) + + // Check all required headers are present + expect(DEFAULT_HEADERS).toHaveProperty('X-Client-Info') + expect(DEFAULT_HEADERS).toHaveProperty('X-Supabase-Client-Platform') + expect(DEFAULT_HEADERS).toHaveProperty('X-Supabase-Client-Platform-Version') + expect(DEFAULT_HEADERS).toHaveProperty('X-Supabase-Client-Runtime') + expect(DEFAULT_HEADERS).toHaveProperty('X-Supabase-Client-Runtime-Version') + + expect(Object.keys(DEFAULT_HEADERS).length).toBe(5) +}) + +describe('Client Platform Detection', () => { + test('getClientPlatform returns correct platform', () => { + const platform = getClientPlatform() + expect(typeof platform).toBe('string') + expect(platform.length).toBeGreaterThan(0) + }) + + test('getClientPlatformVersion returns version string', () => { + const version = getClientPlatformVersion() + expect(typeof version).toBe('string') + expect(version.length).toBeGreaterThan(0) + }) +}) + +describe('Client Runtime Detection', () => { + test('getClientRuntime returns correct runtime', () => { + const runtime = getClientRuntime() + expect(typeof runtime).toBe('string') + expect(runtime.length).toBeGreaterThan(0) + expect(['node', 'deno', 'bun', 'web'].includes(runtime)).toBe(true) + }) + + test('getClientRuntimeVersion returns version string', () => { + const version = getClientRuntimeVersion() + expect(typeof version).toBe('string') + expect(version.length).toBeGreaterThan(0) + }) +}) + +describe('Header Constants', () => { + test('X-Client-Info header format', () => { + const header = DEFAULT_HEADERS['X-Client-Info'] + expect(header).toMatch(/^supabase-js-.+\/\d+\.\d+\.\d+/) + }) + + test('All required headers are present', () => { + expect(DEFAULT_HEADERS).toHaveProperty('X-Client-Info') + }) + + test('Headers are properly formatted', () => { + Object.values(DEFAULT_HEADERS).forEach((value) => { + expect(typeof value).toBe('string') + expect(value.length).toBeGreaterThan(0) + }) + }) }) From ddb27460c86595093252ddd5ca3797aaa7e01b31 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 15 Jul 2025 14:30:49 -0300 Subject: [PATCH 2/3] refactor: simplify HTTP header detection logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove regex pattern matching for platform/runtime version detection - Only include headers when platform/runtime can be reliably detected - Return null instead of 'unknown' when detection fails - Simplify platform detection to only handle exact matches - Update tests to handle conditional header presence - Maintain backward compatibility with X-Client-Info header 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/constants.ts | 94 ++++++++++++++++++------------------- test/unit/constants.test.ts | 68 ++++++++++++++++++--------- 2 files changed, 93 insertions(+), 69 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index fc1fe5f1..0aa6c7f6 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -15,19 +15,7 @@ if (typeof Deno !== 'undefined') { JS_ENV = 'node' } -export function getClientPlatform(): string { - // @ts-ignore - if (typeof navigator !== 'undefined' && navigator.platform) { - // @ts-ignore - const platform = navigator.platform.toLowerCase() - if (platform.includes('mac')) return 'macOS' - if (platform.includes('win')) return 'Windows' - if (platform.includes('linux')) return 'Linux' - if (platform.includes('iphone') || platform.includes('ipad')) return 'iOS' - if (platform.includes('android')) return 'Android' - // @ts-ignore - return navigator.platform - } +export function getClientPlatform(): string | null { // @ts-ignore if (typeof process !== 'undefined' && process.platform) { // @ts-ignore @@ -35,37 +23,31 @@ export function getClientPlatform(): string { if (platform === 'darwin') return 'macOS' if (platform === 'win32') return 'Windows' if (platform === 'linux') return 'Linux' - return platform + if (platform === 'android') return 'Android' } - return 'unknown' -} - -export function getClientPlatformVersion(): string { // @ts-ignore - if (typeof navigator !== 'undefined' && navigator.userAgent) { + if (typeof navigator !== 'undefined' && navigator.platform) { // @ts-ignore - const userAgent = navigator.userAgent - const macMatch = userAgent.match(/Mac OS X (\d+[._]\d+[._]\d+)/) - if (macMatch) return macMatch[1].replace(/_/g, '.') - - const windowsMatch = userAgent.match(/Windows NT (\d+\.\d+)/) - if (windowsMatch) return windowsMatch[1] - - const iosMatch = userAgent.match(/OS (\d+[._]\d+[._]\d+)/) - if (iosMatch) return iosMatch[1].replace(/_/g, '.') - - const androidMatch = userAgent.match(/Android (\d+\.\d+)/) - if (androidMatch) return androidMatch[1] + const platform = navigator.platform + if (platform === 'MacIntel') return 'macOS' + if (platform === 'Win32') return 'Windows' + if (platform === 'Linux x86_64') return 'Linux' + if (platform === 'iPhone') return 'iOS' + if (platform === 'iPad') return 'iOS' } + return null +} + +export function getClientPlatformVersion(): string | null { // @ts-ignore if (typeof process !== 'undefined' && process.version) { // @ts-ignore return process.version.slice(1) } - return 'unknown' + return null } -export function getClientRuntime(): string { +export function getClientRuntime(): string | null { // @ts-ignore if (typeof Deno !== 'undefined') { return 'deno' @@ -78,13 +60,10 @@ export function getClientRuntime(): string { if (typeof process !== 'undefined' && process.versions && process.versions.node) { return 'node' } - if (typeof document !== 'undefined') { - return 'web' - } - return 'unknown' + return null } -export function getClientRuntimeVersion(): string { +export function getClientRuntimeVersion(): string | null { // @ts-ignore if (typeof Deno !== 'undefined' && Deno.version) { // @ts-ignore @@ -100,20 +79,39 @@ export function getClientRuntimeVersion(): string { // @ts-ignore return process.versions.node } - if (typeof document !== 'undefined') { - return 'unknown' - } - return 'unknown' + return null } -export const DEFAULT_HEADERS = { - 'X-Client-Info': `supabase-js-${JS_ENV}/${version}`, - 'X-Supabase-Client-Platform': getClientPlatform(), - 'X-Supabase-Client-Platform-Version': getClientPlatformVersion(), - 'X-Supabase-Client-Runtime': getClientRuntime(), - 'X-Supabase-Client-Runtime-Version': getClientRuntimeVersion(), +function buildHeaders() { + const headers: Record = { + 'X-Client-Info': `supabase-js-${JS_ENV}/${version}`, + } + + const platform = getClientPlatform() + if (platform) { + headers['X-Supabase-Client-Platform'] = platform + } + + const platformVersion = getClientPlatformVersion() + if (platformVersion) { + headers['X-Supabase-Client-Platform-Version'] = platformVersion + } + + const runtime = getClientRuntime() + if (runtime) { + headers['X-Supabase-Client-Runtime'] = runtime + } + + const runtimeVersion = getClientRuntimeVersion() + if (runtimeVersion) { + headers['X-Supabase-Client-Runtime-Version'] = runtimeVersion + } + + return headers } +export const DEFAULT_HEADERS = buildHeaders() + export const DEFAULT_GLOBAL_OPTIONS = { headers: DEFAULT_HEADERS, } diff --git a/test/unit/constants.test.ts b/test/unit/constants.test.ts index 1c58f854..558a36ab 100644 --- a/test/unit/constants.test.ts +++ b/test/unit/constants.test.ts @@ -24,42 +24,51 @@ test('it has the correct type of returning with the correct value', () => { expect(typeof DEFAULT_HEADERS['X-Client-Info']).toBe('string') expect(DEFAULT_HEADERS['X-Client-Info']).toBe(`supabase-js-${JS_ENV}/${version}`) - // Check all required headers are present + // X-Client-Info should always be present expect(DEFAULT_HEADERS).toHaveProperty('X-Client-Info') - expect(DEFAULT_HEADERS).toHaveProperty('X-Supabase-Client-Platform') - expect(DEFAULT_HEADERS).toHaveProperty('X-Supabase-Client-Platform-Version') - expect(DEFAULT_HEADERS).toHaveProperty('X-Supabase-Client-Runtime') - expect(DEFAULT_HEADERS).toHaveProperty('X-Supabase-Client-Runtime-Version') - expect(Object.keys(DEFAULT_HEADERS).length).toBe(5) + // Other headers should only be present if they can be detected + Object.keys(DEFAULT_HEADERS).forEach((key) => { + expect(typeof DEFAULT_HEADERS[key]).toBe('string') + expect(DEFAULT_HEADERS[key].length).toBeGreaterThan(0) + }) }) describe('Client Platform Detection', () => { - test('getClientPlatform returns correct platform', () => { + test('getClientPlatform returns platform or null', () => { const platform = getClientPlatform() - expect(typeof platform).toBe('string') - expect(platform.length).toBeGreaterThan(0) + expect(platform === null || typeof platform === 'string').toBe(true) + if (platform) { + expect(platform.length).toBeGreaterThan(0) + expect(['macOS', 'Windows', 'Linux', 'iOS', 'Android'].includes(platform)).toBe(true) + } }) - test('getClientPlatformVersion returns version string', () => { + test('getClientPlatformVersion returns version string or null', () => { const version = getClientPlatformVersion() - expect(typeof version).toBe('string') - expect(version.length).toBeGreaterThan(0) + expect(version === null || typeof version === 'string').toBe(true) + if (version) { + expect(version.length).toBeGreaterThan(0) + } }) }) describe('Client Runtime Detection', () => { - test('getClientRuntime returns correct runtime', () => { + test('getClientRuntime returns runtime or null', () => { const runtime = getClientRuntime() - expect(typeof runtime).toBe('string') - expect(runtime.length).toBeGreaterThan(0) - expect(['node', 'deno', 'bun', 'web'].includes(runtime)).toBe(true) + expect(runtime === null || typeof runtime === 'string').toBe(true) + if (runtime) { + expect(runtime.length).toBeGreaterThan(0) + expect(['node', 'deno', 'bun'].includes(runtime)).toBe(true) + } }) - test('getClientRuntimeVersion returns version string', () => { + test('getClientRuntimeVersion returns version string or null', () => { const version = getClientRuntimeVersion() - expect(typeof version).toBe('string') - expect(version.length).toBeGreaterThan(0) + expect(version === null || typeof version === 'string').toBe(true) + if (version) { + expect(version.length).toBeGreaterThan(0) + } }) }) @@ -69,11 +78,28 @@ describe('Header Constants', () => { expect(header).toMatch(/^supabase-js-.+\/\d+\.\d+\.\d+/) }) - test('All required headers are present', () => { + test('X-Client-Info is always present', () => { expect(DEFAULT_HEADERS).toHaveProperty('X-Client-Info') }) - test('Headers are properly formatted', () => { + test('Optional headers are only present when detected', () => { + // Test that optional headers are either not present or have valid values + const optionalHeaders = [ + 'X-Supabase-Client-Platform', + 'X-Supabase-Client-Platform-Version', + 'X-Supabase-Client-Runtime', + 'X-Supabase-Client-Runtime-Version', + ] + + optionalHeaders.forEach((headerName) => { + if (DEFAULT_HEADERS[headerName]) { + expect(typeof DEFAULT_HEADERS[headerName]).toBe('string') + expect(DEFAULT_HEADERS[headerName].length).toBeGreaterThan(0) + } + }) + }) + + test('All present headers are properly formatted', () => { Object.values(DEFAULT_HEADERS).forEach((value) => { expect(typeof value).toBe('string') expect(value.length).toBeGreaterThan(0) From ad01a02d3cf6ae4bab65f06a3ed9b2513b73ebd2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 15 Jul 2025 15:11:01 -0300 Subject: [PATCH 3/3] refactor: replace deprecated navigator.platform with modern User-Agent Client Hints API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace deprecated navigator.platform with navigator.userAgentData - Use navigator.userAgentData.platform for platform detection - Use navigator.userAgentData.platformVersion for version detection - Maintain backward compatibility with process.platform for Node.js - All tests continue to pass with new implementation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/constants.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 0aa6c7f6..4c0f4c85 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -25,16 +25,22 @@ export function getClientPlatform(): string | null { if (platform === 'linux') return 'Linux' if (platform === 'android') return 'Android' } + // @ts-ignore - if (typeof navigator !== 'undefined' && navigator.platform) { + if (typeof navigator !== 'undefined') { + // Modern User-Agent Client Hints API // @ts-ignore - const platform = navigator.platform - if (platform === 'MacIntel') return 'macOS' - if (platform === 'Win32') return 'Windows' - if (platform === 'Linux x86_64') return 'Linux' - if (platform === 'iPhone') return 'iOS' - if (platform === 'iPad') return 'iOS' + if (navigator.userAgentData && navigator.userAgentData.platform) { + // @ts-ignore + const platform = navigator.userAgentData.platform + if (platform === 'macOS') return 'macOS' + if (platform === 'Windows') return 'Windows' + if (platform === 'Linux') return 'Linux' + if (platform === 'Android') return 'Android' + if (platform === 'iOS') return 'iOS' + } } + return null } @@ -44,6 +50,17 @@ export function getClientPlatformVersion(): string | null { // @ts-ignore return process.version.slice(1) } + + // @ts-ignore + if (typeof navigator !== 'undefined') { + // Modern User-Agent Client Hints API + // @ts-ignore + if (navigator.userAgentData && navigator.userAgentData.platformVersion) { + // @ts-ignore + return navigator.userAgentData.platformVersion + } + } + return null }