fix(mobile): lazy env proxy so missing EXPO_PUBLIC vars throw inside React

This commit is contained in:
byGalax
2026-05-16 16:21:02 +02:00
parent 3b884b0415
commit c430a590fc
2 changed files with 61 additions and 9 deletions
+30
View File
@@ -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');
});
});
+31 -9
View File
@@ -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
// <BootError> 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];
},
});