diff --git a/__tests__/env-fields.test.ts b/__tests__/env-fields.test.ts new file mode 100644 index 000000000..262d88e6b --- /dev/null +++ b/__tests__/env-fields.test.ts @@ -0,0 +1,252 @@ +import { z } from 'zod'; + +import { + envField, + isEnvField, + parseWithoutValidation, + readEnv, +} from '../env-fields'; + +const getErrorMessages = (schema: z.ZodTypeAny, value: unknown) => + schema.safeParse(value).error?.issues.map(issue => issue.message); + +describe('envField', () => { + describe('string', () => { + it('returns the value as is', () => { + expect(envField.string().parse('https://example.com')).toBe( + 'https://example.com', + ); + }); + + it('rejects missing and empty values by default', () => { + expect(envField.string().safeParse(undefined).success).toBe(false); + expect(envField.string().safeParse('').success).toBe(false); + }); + + it('returns undefined for missing or empty values when optional', () => { + const schema = envField.string({ optional: true }); + + expect(schema.parse(undefined)).toBeUndefined(); + expect(schema.parse('')).toBeUndefined(); + expect(schema.parse('value')).toBe('value'); + }); + + it('returns the default value for missing or empty values', () => { + const schema = envField.string({ default: 'fallback' }); + + expect(schema.parse(undefined)).toBe('fallback'); + expect(schema.parse('')).toBe('fallback'); + expect(schema.parse('value')).toBe('value'); + }); + }); + + describe('number', () => { + it('parses numeric strings', () => { + expect(envField.number().parse('42')).toBe(42); + expect(envField.number().parse('0')).toBe(0); + expect(envField.number().parse('-3.5')).toBe(-3.5); + }); + + it('rejects values that are not numbers', () => { + expect(envField.number().safeParse('abc').success).toBe(false); + expect(envField.number().safeParse(' ').success).toBe(false); + expect(envField.number().safeParse('Infinity').success).toBe(false); + expect(envField.number().safeParse('-Infinity').success).toBe(false); + expect(envField.number().safeParse('0x10').success).toBe(false); + expect(envField.number().safeParse('1e3').success).toBe(false); + expect(envField.number().safeParse(' 42 ').success).toBe(false); + }); + + it('describes the accepted format and the received value when invalid', () => { + expect(getErrorMessages(envField.number(), 'abc')).toEqual([ + 'must be a plain decimal number (e.g. 42 or -3.5), received "abc"', + ]); + expect(getErrorMessages(envField.number({ optional: true }), '1e3')).toEqual([ + 'must be a plain decimal number (e.g. 42 or -3.5), received "1e3"', + ]); + }); + + it('rejects missing and empty values by default', () => { + expect(envField.number().safeParse(undefined).success).toBe(false); + expect(envField.number().safeParse('').success).toBe(false); + }); + + it('returns undefined for missing or empty values when optional', () => { + const schema = envField.number({ optional: true }); + + expect(schema.parse(undefined)).toBeUndefined(); + expect(schema.parse('')).toBeUndefined(); + expect(schema.safeParse('abc').success).toBe(false); + }); + + it('returns the default value for missing or empty values', () => { + const schema = envField.number({ default: 4321 }); + + expect(schema.parse(undefined)).toBe(4321); + expect(schema.parse('')).toBe(4321); + expect(schema.parse('8080')).toBe(8080); + }); + }); + + describe('boolean', () => { + it('parses "true" and "false"', () => { + expect(envField.boolean().parse('true')).toBe(true); + expect(envField.boolean().parse('false')).toBe(false); + }); + + it('parses "true" and "false" case-insensitively', () => { + expect(envField.boolean().parse('TRUE')).toBe(true); + expect(envField.boolean().parse('True')).toBe(true); + expect(envField.boolean().parse('FALSE')).toBe(false); + expect(envField.boolean().parse('False')).toBe(false); + }); + + it('rejects any other value', () => { + expect(envField.boolean().safeParse('yes').success).toBe(false); + expect(envField.boolean().safeParse('1').success).toBe(false); + expect(envField.boolean().safeParse('0').success).toBe(false); + expect(envField.boolean().safeParse(' true ').success).toBe(false); + }); + + it('describes the accepted format and the received value when invalid', () => { + expect(getErrorMessages(envField.boolean(), 'yes')).toEqual([ + 'must be \'true\' or \'false\', received "yes"', + ]); + expect(getErrorMessages(envField.boolean({ default: false }), '1')).toEqual([ + 'must be \'true\' or \'false\', received "1"', + ]); + }); + + it('rejects missing and empty values by default', () => { + expect(envField.boolean().safeParse(undefined).success).toBe(false); + expect(envField.boolean().safeParse('').success).toBe(false); + }); + + it('returns undefined for missing or empty values when optional', () => { + const schema = envField.boolean({ optional: true }); + + expect(schema.parse(undefined)).toBeUndefined(); + expect(schema.parse('')).toBeUndefined(); + }); + + it('returns the default value for missing or empty values', () => { + const schema = envField.boolean({ default: false }); + + expect(schema.parse(undefined)).toBe(false); + expect(schema.parse('')).toBe(false); + expect(schema.parse('true')).toBe(true); + }); + }); + + it('uses the default value when both optional and default are set', () => { + const schema = envField.string({ optional: true, default: 'fallback' }); + + expect(schema.parse(undefined)).toBe('fallback'); + expect(schema.parse('value')).toBe('value'); + }); + + it('reports missing variables as required when used in an object schema', () => { + const result = z + .object({ API_URL: envField.string(), VAR_NUMBER: envField.number() }) + .safeParse({ API_URL: '', VAR_NUMBER: undefined }); + + expect(result.success).toBe(false); + expect(result.error?.flatten().fieldErrors).toEqual({ + API_URL: ['Required'], + VAR_NUMBER: ['Required'], + }); + }); +}); + +describe('isEnvField', () => { + it('detects the schemas created with the envField builders', () => { + expect(isEnvField(envField.string())).toBe(true); + expect(isEnvField(envField.number({ optional: true }))).toBe(true); + expect(isEnvField(envField.boolean({ default: false }))).toBe(true); + expect(isEnvField(z.string())).toBe(false); + expect(isEnvField(z.preprocess(value => value, z.number()))).toBe(false); + }); +}); + +describe('readEnv', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('reads the declared variables from process.env', () => { + process.env.API_URL = 'https://api.example.com'; + process.env.UNDECLARED_VAR = 'ignored'; + const schema = z.object({ + API_URL: envField.string(), + VAR_NUMBER: envField.number(), + }); + + expect(readEnv(schema, {})).toEqual({ + API_URL: 'https://api.example.com', + VAR_NUMBER: undefined, + }); + }); + + it('overrides process.env with the static values', () => { + process.env.NAME = 'FromEnvFile'; + const schema = z.object({ NAME: z.string() }); + + expect(readEnv(schema, { NAME: 'StaticName' })).toEqual({ + NAME: 'StaticName', + }); + }); + + it('throws when a variable without a static value is not declared with envField', () => { + process.env.VAR_NUMBER = '42'; + const schema = z.object({ + API_URL: envField.string(), + VAR_NUMBER: z.number(), + }); + + expect(() => readEnv(schema, {})).toThrow( + 'The env variable "VAR_NUMBER" is read from process.env but it isn\'t declared with an envField builder', + ); + }); +}); + +describe('parseWithoutValidation', () => { + const schema = z.object({ + API_URL: envField.string(), + VAR_NUMBER: envField.number(), + VAR_BOOL: envField.boolean({ default: false }), + }); + + it('parses the valid values', () => { + expect( + parseWithoutValidation(schema, { + API_URL: 'https://api.example.com', + VAR_NUMBER: '42', + VAR_BOOL: 'true', + }), + ).toEqual({ + API_URL: 'https://api.example.com', + VAR_NUMBER: 42, + VAR_BOOL: true, + }); + }); + + it('returns undefined for invalid or missing values without throwing', () => { + expect( + parseWithoutValidation(schema, { VAR_NUMBER: 'abc', VAR_BOOL: 'yes' }), + ).toEqual({ + API_URL: undefined, + VAR_NUMBER: undefined, + VAR_BOOL: undefined, + }); + }); + + it('applies default values for missing values', () => { + expect(parseWithoutValidation(schema, {}).VAR_BOOL).toBe(false); + }); +}); diff --git a/__tests__/env.test.ts b/__tests__/env.test.ts new file mode 100644 index 000000000..936be0bfc --- /dev/null +++ b/__tests__/env.test.ts @@ -0,0 +1,91 @@ +const VALID_ENV = { + API_URL: 'https://api.example.com', + VAR_NUMBER: '10', + VAR_BOOL: 'true', + TERMS_OF_SERVICE_URL: 'https://example.com/terms', + WEBSITE_URL: 'https://example.com', + SECRET_KEY: 'fake-secret-key', +}; + +const RUN_SCRIPT = 'expo start'; +const OTHER_SCRIPT = 'jest'; + +type EnvModule = { + Env: Record; + ClientEnv: Record; +}; + +const loadEnv = (values: Record, script: string) => { + process.env = { + ...process.env, + ...values, + APP_ENV: 'development', + EAS_BUILD: 'false', + npm_lifecycle_script: script, + }; + let envModule: EnvModule | undefined; + jest.isolateModules(() => { + envModule = require('../env'); + }); + return envModule as EnvModule; +}; + +describe('env', () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + it('reads and converts the declared variables when validating', () => { + const { Env, ClientEnv } = loadEnv(VALID_ENV, RUN_SCRIPT); + + expect(Env).toMatchObject({ + API_URL: 'https://api.example.com', + VAR_NUMBER: 10, + VAR_BOOL: true, + SECRET_KEY: 'fake-secret-key', + NAME: 'RootstrapApp', + }); + expect(ClientEnv.VAR_NUMBER).toBe(10); + expect(ClientEnv).not.toHaveProperty('SECRET_KEY'); + }); + + it('keeps the static values over the ones in process.env', () => { + const { Env } = loadEnv({ ...VALID_ENV, NAME: 'OtherName' }, RUN_SCRIPT); + + expect(Env.NAME).toBe('RootstrapApp'); + }); + + it('throws and logs how to fix the invalid variables when the env has to be validated', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => + loadEnv( + { ...VALID_ENV, VAR_NUMBER: 'abc', VAR_BOOL: 'yes' }, + RUN_SCRIPT, + ), + ).toThrow('Invalid environment variables'); + expect(consoleError.mock.calls[0][1]).toMatchObject({ + VAR_NUMBER: [ + 'must be a plain decimal number (e.g. 42 or -3.5), received "abc"', + ], + VAR_BOOL: ['must be \'true\' or \'false\', received "yes"'], + }); + }); + + it('returns undefined for invalid variables when the env is not validated', () => { + const { Env, ClientEnv } = loadEnv( + { ...VALID_ENV, VAR_NUMBER: 'abc', VAR_BOOL: 'yes' }, + OTHER_SCRIPT, + ); + + expect(Env.VAR_NUMBER).toBeUndefined(); + expect(Env.VAR_BOOL).toBeUndefined(); + expect(Env.API_URL).toBe('https://api.example.com'); + expect(ClientEnv.VAR_NUMBER).toBeUndefined(); + }); +}); diff --git a/docs/src/content/docs/getting-started/environment-vars-config.mdx b/docs/src/content/docs/getting-started/environment-vars-config.mdx index 5903d8dd4..84cfe0159 100644 --- a/docs/src/content/docs/getting-started/environment-vars-config.mdx +++ b/docs/src/content/docs/getting-started/environment-vars-config.mdx @@ -10,7 +10,7 @@ import Code from '../../../components/code.astro'; Managing environment variables in your project is an essential task, but it can also be challenging. That's why we have included a complete setup for environment variables in this project. This setup comes with validation and type-checking using the `zod` library. -All the code related to environment variables is located in the `env.js` and `src/lib/env.js` files. The `env.js` read the `APP_ENV` variable and loads the correct `.env` file, then defines the `zod` schema for the environment variables for client and build-time, parses the `_env` object, and returns the parsed object, or throws errors in case of invalid or missing variables. +All the code related to environment variables is located in the `env.js`, `env-fields.js` and `src/lib/env.js` files. The `env.js` read the `APP_ENV` variable and loads the correct `.env` file, then defines the `zod` schema for the environment variables for client and build-time using the `envField` helpers from `env-fields.js`, reads the declared variables from `process.env`, and returns the parsed object, or throws errors in case of invalid or missing variables. To increase security, we are splitting environment variables into two parts: @@ -28,38 +28,47 @@ To add a new environment variable to the project, follow these steps: 1. Add the new environment variable to the correct `zod` schema inside the `env.js` file based on this simple rule : If the variable is used in the `src` folder, add it to the `client` schema, otherwise add it to the `buildTime` schema. + Declare it with one of the `envField` builders (`envField.string()`, `envField.number()` or `envField.boolean()`). The value is read from `process.env` automatically and converted to the declared type, so this is the only place where you need to declare the variable. + + Every variable without a static value (see `clientStaticValues` and `buildTimeStaticValues`) is read from `process.env`, so it must be declared with an `envField` builder. Declaring it with a plain `zod` schema (e.g. `z.number()`) throws an error naming the variable, because its raw value would always be a string. + This will ensure that the new variable is validated correctly. and make sure we are only sending the correct vars to the client side Here's an example: ```js title="env.js" -const client = z.object({ +const clientEnvSchema = z.object({ // ... // add the new environment variable here/ accessible on the client side and build time(app.config.ts) - NEW_ENV_VAR: z.string(), + NEW_ENV_VAR: envField.string(), + NEW_NUMBER_VAR: envField.number({ default: 4321 }), + NEW_FLAG_VAR: envField.boolean({ optional: true }), }); -const buildTime = z.object({ +const buildTimeEnvSchema = z.object({ // ... // add the new environment variable here / accessible only on build time(app.config.ts) - NEW_SECRET_ENV: z.string(), + NEW_SECRET_ENV: envField.string(), }); ``` -2. Add the new environment variable to the correct env object inside the `env.js`, `_clientEnv` for client variables and `_buildTimeEnv` for build time variables. Here's an example: +2. Pick the right options for the variable. Every `envField` builder accepts the following options: -```js title="env.js" -const _clientEnv = { - // ... - // add the new environment variable here - NEW_ENV_VAR: process.env.NEW_ENV_VAR, -}; +| Option | Description | +| ---------- | ------------------------------------------------------------------------------------ | +| `optional` | When `true`, the variable can be missing or empty and its value will be `undefined`. | +| `default` | Value used when the variable is missing or empty. | -const _buildTimeEnv = { - // ... - // add the new environment variable here - NEW_SECRET_ENV: process.env.NEW_SECRET_ENV, -}; -``` +Without options, the variable is required. Empty values (e.g. `API_URL=`) are treated as missing. Values are converted as follows: + +- `envField.string()`: the value is used as is. +- `envField.number()`: only plain decimal numbers (e.g. `42` or `-3.5`) are accepted; any other value (e.g. `abc`, `Infinity`, `0x10` or `1e3`) is rejected. +- `envField.boolean()`: only `true` and `false` are accepted, case-insensitively (e.g. `TRUE` or `False`); any other value (e.g. `1` or `yes`) is rejected. + +Invalid values are reported with the accepted format and the received value, for example `VAR_BOOL=yes` is reported as `must be 'true' or 'false', received "yes"`. + +:::caution[Upgrading from a previous version of the template] +Number and boolean variables used to be converted manually in `env.js` with a looser conversion: any boolean value other than `true` (such as `1` or `yes`) silently became `false`, and numbers such as `1e3`, `0x10` or values with surrounding spaces were accepted. The `envField` builders are stricter, so those values now fail validation instead. If you pull template updates (for example, through the `sync-with-upstream.yml` workflow), review your `.env` files and the env files used by your CI and EAS builds, and make sure number variables are plain decimal numbers and boolean variables are `true` or `false`. +::: 3. Add the new environment variable to your `.env` files. Make sure to include it in all relevant files (`development`, `staging`, and `production`). Here's an example: @@ -103,12 +112,16 @@ As mentioned earlier, `zod` is used to validate environment variables at runtime ```bash āŒ Invalid environment variables: { TEST: [ 'Required' ] } -āŒ Missing variables in .env.development file, Make sure all required variables are defined in the .env.development file. +āŒ Missing or invalid variables in .env.development file. Make sure all required variables are defined with valid values in the .env.development file. šŸ’” Tip: If you recently updated the .env.development file and the error still persists, try restarting the server with the -c flag to clear the cache. ``` +:::note[When validation runs] +The variables are only validated when running the app (`expo start` or `expo run`) or when building it, on EAS or locally, with one of the commands listed in `ENVIRONMENT_DEPENDANT_SCRIPTS` in `env.js` (such as `eas build`, `expo prebuild` or `expo export`). Only these flows fail fast on missing or invalid variables. For any other command (for example, `eas update`), validation is skipped: missing or invalid values end up as `undefined` in `Env` and `ClientEnv`, and therefore in the `extra` field of the app config, without any error. +::: + :::note[Important] -As `dotenv` handles variables as strings, you need to convert them to the correct type while reading them from the `process.env` object. check the example below for more details on how to use numbers and boolean values. +As `dotenv` handles variables as strings, the `envField` builders convert them to the declared type (`string`, `number` or `boolean`) before validating them, so you don't need to parse them yourself. ::: ## How it works @@ -123,7 +136,7 @@ If you take a look at the `env.js` file, you will notice that the file is split we define some static variables for the app such as the app name, bundle Id and package. While these variables can be added to the `.env` files, we recommend keeping them in the `env.js` file as they are not meant to change. To handle different app variants, you can add suffixes to these variables using the `withEnvSuffix` function. -**In the second part**, we define the `zod` schema for the environment variables. +**In the second part**, we define the `zod` schema for the environment variables. Variables loaded from the `.env` file are declared with the `envField` builders, while static values defined in `env.js` (such as `NAME` or `BUNDLE_ID`) are declared with plain `zod` schemas and their values are set in the `clientStaticValues` and `buildTimeStaticValues` objects. We split the environment variables into two parts: @@ -133,9 +146,13 @@ We split the environment variables into two parts: These schemas are used to validate the environment variables. All the environment variables should be added to the correct schema. -We use the `z.infer` utility to infer the environment variables' types from the schema and use it to define the `_clientEnv` and `_buildTimeEnv` objects' type. This means that if you add a new environment variable to the schema, you will get a type error if you don't add it to the correct `_clientEnv` and `_buildTimeEnv` object as well, and vice versa. +The values of the variables declared in each schema are read from `process.env` automatically, so each variable is declared only once. We use the `z.infer` utility to infer the environment variables' types from the schema, so `Env` and `ClientEnv` are typed with the converted values (for example, `number` for `envField.number()`). + +The `envField` builders live in the `env-fields.js` file: + + -**Finally**, in the third part, we merge variables to `_env`, pare it using the zod schema, and return the parsed object as well as the client environment variable, or throw errors in case of invalid or missing variables. +**Finally**, in the third part, we merge variables to `_wholeEnv`, parse it using the zod schema, and return the parsed object as well as the client environment variable, or throw errors in case of invalid or missing variables. #### āœ… Use and send environment variables to the client diff --git a/docs/src/content/docs/recipes/sentry-setup.mdx b/docs/src/content/docs/recipes/sentry-setup.mdx index 884b2f217..be3f7c74a 100644 --- a/docs/src/content/docs/recipes/sentry-setup.mdx +++ b/docs/src/content/docs/recipes/sentry-setup.mdx @@ -48,29 +48,19 @@ The starter kit did not come with Sentry pre-configured, but it's very easy to s ```js title='env.js' // ... existing imports and configurations + const { envField } = require('./env-fields'); - const client = z.object({ + const clientEnvSchema = z.object({ // ... other client env vars - SENTRY_DSN: z.string().min(1, 'SENTRY_DSN is required'), + SENTRY_DSN: envField.string(), }); - const buildTime = z.object({ + const buildTimeEnvSchema = z.object({ // ... other build-time env vars - SENTRY_ORG: z.string().min(1, 'SENTRY_ORG is required'), - SENTRY_PROJECT: z.string().min(1, 'SENTRY_PROJECT is required'), + SENTRY_ORG: envField.string(), + SENTRY_PROJECT: envField.string(), }); - const _clientEnv = { - // ... other client env vars - SENTRY_DSN: process.env.SENTRY_DSN, - }; - - const _buildTimeEnv = { - // ... other build-time env vars - SENTRY_ORG: process.env.SENTRY_ORG, - SENTRY_PROJECT: process.env.SENTRY_PROJECT, - }; - // ... rest of the file ``` diff --git a/env-fields.js b/env-fields.js new file mode 100644 index 000000000..ce0255f62 --- /dev/null +++ b/env-fields.js @@ -0,0 +1,205 @@ +/* + * Helpers to declare environment variables in `env.js`. + * Each `envField` builder returns a zod schema that receives the raw value read from `process.env` + * (always a string or undefined), converts it to the right type and validates it. + * This way every variable is declared only once, with its type, in the schemas defined in `env.js`. + * + * Options: + * - optional: the variable may be missing or empty; its value will be `undefined`. + * - default: value used when the variable is missing or empty. + * + * An empty value (e.g. `API_URL=`) is treated the same as a missing one. + */ +const z = require('zod'); + +/** + * @template {z.ZodTypeAny} T + * @typedef {{ optional?: boolean, default?: z.output }} EnvFieldOptions + */ + +/** + * @template {z.ZodTypeAny} T + * @template {EnvFieldOptions} O + * @typedef {O extends { default: {} } ? z.ZodDefault : O extends { optional: true } ? z.ZodOptional : T} EnvFieldInner + */ + +/** + * @template {z.ZodTypeAny} T + * @template {EnvFieldOptions} O + * @typedef {z.ZodEffects, EnvFieldInner['_output'], unknown>} EnvField + */ + +/** + * Schemas created with the `envField` builders, used to make sure that every variable + * read from `process.env` is converted from a string to its declared type. + * @type {WeakSet} + */ +const envFieldSchemas = new WeakSet(); + +/** + * @param {z.ZodTypeAny} schema + * @returns {boolean} whether the schema was created with one of the `envField` builders + */ +function isEnvField(schema) { + return envFieldSchemas.has(schema); +} + +/** + * Builds an env field schema: raw values are converted with `parse` (empty strings become `undefined`) + * and then validated with `schema`, applying the `optional` and `default` options. + * @template {z.ZodTypeAny} T + * @template {EnvFieldOptions} O + * @param {T} schema + * @param {(value: string) => unknown} parse + * @param {O} options + * @returns {EnvField} the env field schema + */ +function createEnvField(schema, parse, options) { + /** @type {z.ZodTypeAny} */ + let inner = schema; + if (options.default !== undefined) { + inner = schema.default(options.default); + } + else if (options.optional) { + inner = schema.optional(); + } + + const envFieldSchema = z.preprocess((value) => { + if (value === undefined || value === '') { + return undefined; + } + return typeof value === 'string' ? parse(value) : value; + }, inner); + envFieldSchemas.add(envFieldSchema); + + return /** @type {EnvField} */ (envFieldSchema); +} + +/** + * Builds a zod error map that replaces the type errors of invalid values with a message + * describing the accepted format and the received value, so it's clear how to fix it. + * Missing values keep the default `Required` message. + * @param {string} expected description of the accepted format + * @returns {z.ZodErrorMap} the error map + */ +function invalidValueErrorMap(expected) { + return (issue, ctx) => { + if (issue.code === 'invalid_type' && ctx.data !== undefined) { + return { message: `must be ${expected}, received ${JSON.stringify(ctx.data)}` }; + } + return { message: ctx.defaultError }; + }; +} + +const DECIMAL_NUMBER_REGEX = /^-?\d+(?:\.\d+)?$/; + +/** + * Only plain decimal numbers (e.g. `42` or `-3.5`) are accepted; any other value + * (e.g. `abc`, `Infinity`, `0x10`, `1e3` or values with spaces) is returned as is so it's rejected. + * @param {string} value + */ +function parseNumber(value) { + return DECIMAL_NUMBER_REGEX.test(value) ? Number(value) : value; +} + +/** + * Only `true` and `false` are accepted, case-insensitively (e.g. `TRUE` or `False`); + * any other value (e.g. `1`, `yes` or values with spaces) is returned as is so it's rejected. + * @param {string} value + */ +function parseBoolean(value) { + const normalizedValue = value.toLowerCase(); + if (normalizedValue === 'true') { + return true; + } + if (normalizedValue === 'false') { + return false; + } + return value; +} + +const envField = { + /** + * @template {EnvFieldOptions} [O={}] + * @param {O} [options] + */ + string: options => + createEnvField( + z.string(), + value => value, + /** @type {O} */ (options ?? {}), + ), + /** + * @template {EnvFieldOptions} [O={}] + * @param {O} [options] + */ + number: options => + createEnvField( + z + .number({ + errorMap: invalidValueErrorMap('a plain decimal number (e.g. 42 or -3.5)'), + }) + .finite(), + parseNumber, + /** @type {O} */ (options ?? {}), + ), + /** + * @template {EnvFieldOptions} [O={}] + * @param {O} [options] + */ + boolean: options => + createEnvField( + z.boolean({ errorMap: invalidValueErrorMap('\'true\' or \'false\'') }), + parseBoolean, + /** @type {O} */ (options ?? {}), + ), +}; + +/** + * Reads the raw value of every variable declared in the schema from `process.env`, + * then overrides them with the static values defined in `env.js`. + * Variables without a static value must be declared with an `envField` builder, as their raw + * value is always a string; otherwise an error is thrown. + * @param {z.AnyZodObject} schema + * @param {Record} staticValues + * @returns {Record} the raw values, ready to be parsed with the schema + */ +function readEnv(schema, staticValues) { + /** @type {[string, z.ZodTypeAny][]} */ + const fields = Object.entries(schema.shape); + const envValues = fields + .filter(([key]) => !Object.hasOwn(staticValues, key)) + .map(([key, fieldSchema]) => { + if (!isEnvField(fieldSchema)) { + throw new Error( + `The env variable "${key}" is read from process.env but it isn't declared with an envField builder. ` + + 'Declare it with envField.string(), envField.number() or envField.boolean() in env.js, ' + + 'or set its value in the static values.', + ); + } + return [key, process.env[key]]; + }); + + return { + ...Object.fromEntries(envValues), + ...staticValues, + }; +} + +/** + * Parses every variable on its own, keeping `undefined` for the invalid ones instead of failing. + * Used when the env variables don't need to be validated. + * @param {z.AnyZodObject} schema + * @param {Record} values + * @returns {Record} the parsed values + */ +function parseWithoutValidation(schema, values) { + return Object.fromEntries( + Object.entries(schema.shape).map(([key, fieldSchema]) => { + const parsed = fieldSchema.safeParse(values[key]); + return [key, parsed.success ? parsed.data : undefined]; + }), + ); +} + +module.exports = { envField, isEnvField, parseWithoutValidation, readEnv }; diff --git a/env.js b/env.js index d396e9671..fa3c90f2a 100644 --- a/env.js +++ b/env.js @@ -15,6 +15,12 @@ */ const z = require('zod'); +const { + envField, + parseWithoutValidation, + readEnv, +} = require('./env-fields'); + const packageJSON = require('./package.json'); const path = require('node:path'); @@ -100,7 +106,7 @@ const withEnvSuffix = (name) => /** * 2nd part: Define your env variables schema - * we use zod to define our env variables schema + * we use zod and the `envField` helpers (see `env-fields.js`) to define our env variables schema * * we split the env variables into two parts: * 1. client: These variables are used in the client-side code (`src` folder). @@ -108,24 +114,17 @@ const withEnvSuffix = (name) => * * Main rules: * 1. If you need your variable on the client-side, you should add it to the client schema; otherwise, you should add it to the buildTime schema. - * 2. Whenever you want to add a new variable, you should add it to the correct schema based on the previous rule, then you should add it to the corresponding object (_clientEnv or _buildTimeEnv). + * 2. Declare variables read from the `.env` file with `envField.string()`, `envField.number()` or `envField.boolean()`. + * Their values are read from `process.env` and converted to the declared type automatically, so you only declare them once. + * Every variable without a static value is read from `process.env`, so it must use an `envField` builder: declaring it with a plain zod schema (e.g. `z.number()`) throws an error. + * 3. Static values defined in this file (such as NAME or BUNDLE_ID) are declared with plain zod schemas and their values are set in `clientStaticValues` or `buildTimeStaticValues`. * - * Note: `z.string()` means that the variable exists and can be an empty string, but not `undefined`. - * If you want to make the variable required, you should use `z.string().min(1)` instead. + * Note: env fields are required by default; an empty value (e.g. `API_URL=`) is treated as missing. + * Pass `{ optional: true }` to allow a missing value, or `{ default: value }` to fall back to a default value. * Read more about zod here: https://zod.dev/?id=strings * */ -// eslint-disable-next-line no-unused-vars, unused-imports/no-unused-vars -const parseString = (/** @type {string | undefined} */ value) => - value === '' ? undefined : value; -// eslint-disable-next-line no-unused-vars, unused-imports/no-unused-vars -const parseNumber = (/** @type {string | undefined} */ value) => - value ? Number(value) : undefined; -// eslint-disable-next-line no-unused-vars, unused-imports/no-unused-vars -const parseBoolean = (/** @type {string | undefined} */ value) => - value ? value === 'true' : undefined; - const clientEnvSchema = z.object({ APP_ENV: z.enum(['development', 'production', 'qa', 'staging']), NAME: z.string(), @@ -135,49 +134,43 @@ const clientEnvSchema = z.object({ VERSION: z.string(), // ADD YOUR CLIENT ENV VARS HERE - API_URL: z.string(), - VAR_NUMBER: z.number(), - VAR_BOOL: z.boolean(), - TERMS_OF_SERVICE_URL: z.string(), - WEBSITE_URL: z.string(), + API_URL: envField.string(), + VAR_NUMBER: envField.number(), + VAR_BOOL: envField.boolean(), + TERMS_OF_SERVICE_URL: envField.string(), + WEBSITE_URL: envField.string(), }); const buildTimeEnvSchema = z.object({ EXPO_ACCOUNT_OWNER: z.string(), EAS_PROJECT_ID: z.string(), // ADD YOUR BUILD TIME ENV VARS HERE - SECRET_KEY: z.string(), + SECRET_KEY: envField.string(), }); /** * @type {Partial>} */ -const _clientEnv = { +const clientStaticValues = { APP_ENV, NAME, SCHEME, BUNDLE_ID: withEnvSuffix(BUNDLE_ID), PACKAGE: withEnvSuffix(PACKAGE), VERSION: packageJSON.version, - - // ADD YOUR ENV VARS HERE TOO - API_URL: parseString(process.env.API_URL), - VAR_NUMBER: parseNumber(process.env.VAR_NUMBER), - VAR_BOOL: parseBoolean(process.env.VAR_BOOL), - WEBSITE_URL: parseString(process.env.WEBSITE_URL), - TERMS_OF_SERVICE_URL: parseString(process.env.TERMS_OF_SERVICE_URL), }; /** - * @type {Record , unknown>} + * @type {Partial>} */ -const _buildTimeEnv = { +const buildTimeStaticValues = { EXPO_ACCOUNT_OWNER, EAS_PROJECT_ID, - // ADD YOUR ENV VARS HERE TOO - SECRET_KEY: parseString(process.env.SECRET_KEY), }; +const _clientEnv = readEnv(clientEnvSchema, clientStaticValues); +const _buildTimeEnv = readEnv(buildTimeEnvSchema, buildTimeStaticValues); + /** * 3rd part: Merge and Validate your env variables * We use zod to validate our env variables based on the schema we defined above @@ -211,7 +204,7 @@ if (shouldValidateEnv) { 'āŒ Invalid environment variables:', parsedWholeEnv.error.flatten().fieldErrors, - `\nāŒ Missing variables in \x1b[1m\x1b[4m\x1b[31m${envFile}\x1b[0m file. Make sure all required variables are defined in the \x1b[1m\x1b[4m\x1b[31m${envFile}\x1b[0m file.`, + `\nāŒ Missing or invalid variables in \x1b[1m\x1b[4m\x1b[31m${envFile}\x1b[0m file. Make sure all required variables are defined with valid values in the \x1b[1m\x1b[4m\x1b[31m${envFile}\x1b[0m file.`, ]; if (isLocalBuild) { @@ -235,11 +228,15 @@ if (shouldValidateEnv) { Env = parsedWholeEnv.data; ClientEnv = clientEnvSchema.parse(_clientEnv); } else { + // The env variables are only validated when running the app or when building it with one of the ENVIRONMENT_DEPENDANT_SCRIPTS + // (for example, `eas update` isn't one of them). + // For any other script, missing or invalid values don't fail: they end up as `undefined` in `Env` and `ClientEnv`, + // and therefore in the `extra` field of the app config. // Don't worry about TypeScript here; if we don't need to validate the env variables is because we aren't using them //@ts-ignore - Env = _wholeEnv; + Env = parseWithoutValidation(wholeEnvSchema, _wholeEnv); //@ts-ignore - ClientEnv = _clientEnv; + ClientEnv = parseWithoutValidation(clientEnvSchema, _clientEnv); } module.exports = {