From c430a590fc421b015e42f90421436bb905c91d26 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 16:21:02 +0200 Subject: [PATCH] fix(mobile): lazy env proxy so missing EXPO_PUBLIC vars throw inside React --- apps/mobile/lib/env.test.ts | 30 ++++++++++++++++++++++++++++ apps/mobile/lib/env.ts | 40 ++++++++++++++++++++++++++++--------- 2 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/lib/env.test.ts diff --git a/apps/mobile/lib/env.test.ts b/apps/mobile/lib/env.test.ts new file mode 100644 index 0000000..eb867f5 --- /dev/null +++ b/apps/mobile/lib/env.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('mobile env (lazy proxy)', () => { + beforeEach(() => { + vi.resetModules(); + delete process.env.EXPO_PUBLIC_SUPABASE_URL; + delete process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY; + delete process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL; + }); + + it('importing the module does NOT throw when required vars are missing', async () => { + await expect(import('./env')).resolves.toBeTruthy(); + }); + + it('reading a property with no env set throws a clear error', async () => { + const mod = await import('./env'); + expect(() => mod.env.supabaseUrl).toThrowError( + /Missing required env var EXPO_PUBLIC_SUPABASE_URL/, + ); + }); + + it('reading a property after setting env returns the value and memoises', async () => { + process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co'; + process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'anon-123'; + const mod = await import('./env'); + expect(mod.env.supabaseUrl).toBe('https://example.supabase.co'); + expect(mod.env.supabaseAnonKey).toBe('anon-123'); + expect(mod.env.authRedirectUrl).toBe('netralax://auth/callback'); + }); +}); diff --git a/apps/mobile/lib/env.ts b/apps/mobile/lib/env.ts index 181d913..f5df69b 100644 --- a/apps/mobile/lib/env.ts +++ b/apps/mobile/lib/env.ts @@ -1,19 +1,41 @@ -// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin. -// We pull them through a single typed module so a missing var is a loud -// startup error rather than a confusing Supabase 401 later. +// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin (or +// shipped via EAS Secrets for EAS builds — see apps/mobile/README.md). +// We pull them through a Proxy so missing vars throw on first READ, not at +// module-eval time. That keeps the throw inside the React tree where the +// boundary can render it as a readable screen instead of a blank +// white window. function required(name: string): string { const v = process.env[name]; if (!v || v.length === 0) { throw new Error( - 'Missing required env var ' + name + '. Set it in apps/mobile/.env.local — see .env.example.', + 'Missing required env var ' + name + + '. Set it via `eas secret:create --scope project --name ' + name + + ' --value ...` or in apps/mobile/.env.local for local dev (see .env.example).', ); } return v; } -export const env = { - supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'), - supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'), - authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback', -} as const; +interface EnvShape { + supabaseUrl: string; + supabaseAnonKey: string; + authRedirectUrl: string; +} + +function readEnv(): EnvShape { + return { + supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'), + supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'), + authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback', + }; +} + +let cached: EnvShape | null = null; + +export const env: EnvShape = new Proxy({} as EnvShape, { + get(_target, key: string | symbol): unknown { + cached ??= readEnv(); + return cached[key as keyof EnvShape]; + }, +});