Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(env): add schema, types and envField #10805

Merged
merged 6 commits into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type {
} from '../transitions/events.js';
import type { DeepPartial, OmitIndexSignature, Simplify } from '../type-utils.js';
import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js';
import type { EnvSchema } from '../env/schema.js'

export { type AstroIntegrationLogger };

Expand Down Expand Up @@ -1917,6 +1918,31 @@ export interface AstroUserConfig {
origin?: boolean;
};
};

/**
* @docs
* @name experimental.env
* @type {object}
* @default `{}`
* @version TODO:
* @description
*
* TODO:
*/
env?: {

/**
* @docs
* @name experimental.env.schema
* @type {EnvSchema}
* @default `{}`
* @version TODO:
* @description
*
* TODO:
*/
schema?: EnvSchema
}
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { UserConfig } from 'vite';
import type { AstroUserConfig } from '../@types/astro.js';
import { Logger } from '../core/logger/core.js';
export { envField } from "../env/config.js";

export function defineConfig(config: AstroUserConfig) {
return config;
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { z } from 'zod';
import { appendForwardSlash, prependForwardSlash, removeTrailingForwardSlash } from '../path.js';
import { EnvSchema } from '../../env/schema.js';

// The below types are required boilerplate to workaround a Zod issue since v3.21.2. Since that version,
// Zod's compiled TypeScript would "simplify" certain values to their base representation, causing references
Expand Down Expand Up @@ -87,6 +88,7 @@ const ASTRO_CONFIG_DEFAULTS = {
globalRoutePriority: false,
i18nDomains: false,
security: {},
env: {},
},
} satisfies AstroUserConfig & { server: { open: boolean } };

Expand Down Expand Up @@ -525,6 +527,12 @@ export const AstroConfigSchema = z.object({
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.security),
i18nDomains: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.i18nDomains),
env: z
.object({
schema: EnvSchema.optional(),
})
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.env),
})
.strict(
`Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.`
Expand Down
26 changes: 26 additions & 0 deletions packages/astro/src/env/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type {
BooleanField,
BooleanFieldInput,
NumberField,
NumberFieldInput,
StringField,
StringFieldInput,
} from './schema.js';

/**
* TODO:
*/
export const envField = {
string: (options: StringFieldInput): StringField => ({
...options,
type: 'string',
}),
number: (options: NumberFieldInput): NumberField => ({
...options,
type: 'number',
}),
boolean: (options: BooleanFieldInput): BooleanField => ({
...options,
type: 'boolean',
}),
};
134 changes: 134 additions & 0 deletions packages/astro/src/env/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { z } from 'zod';

const StringSchema = z.object({
type: z.literal('string'),
optional: z.boolean().optional(),
default: z.string().optional(),
});
const NumberSchema = z.object({
type: z.literal('number'),
optional: z.boolean().optional(),
default: z.number().optional(),
});
const BooleanSchema = z.object({
type: z.literal('boolean'),
optional: z.boolean().optional(),
default: z.boolean().optional(),
});

const EnvFieldType = z.discriminatedUnion('type', [StringSchema, NumberSchema, BooleanSchema]);

const PublicClientEnvFieldMetadata = z.object({
context: z.literal('client'),
access: z.literal('public'),
});
const PublicServerEnvFieldMetadata = z.object({
context: z.literal('server'),
access: z.literal('public'),
});
const SecretServerEnvFieldMetadata = z.object({
context: z.literal('server'),
access: z.literal('secret'),
});

const KEY_REGEX = /^[A-Z_]+$/;
const PUBLIC_PREFIX = 'PUBLIC_';

export const EnvSchema = z
.record(
z
.string()
.regex(KEY_REGEX, {
message: 'A valid variable name can only contain uppercase letters and underscores.',
}),
z.intersection(
z.union([
PublicClientEnvFieldMetadata,
PublicServerEnvFieldMetadata,
SecretServerEnvFieldMetadata,
]),
EnvFieldType
)
)
.superRefine((schema, ctx) => {
for (const [key, value] of Object.entries(schema)) {
if (
key.startsWith(PUBLIC_PREFIX) &&
!(value.context === 'client' && value.access === 'public')
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `An environment variable whose name is prefixed by "${PUBLIC_PREFIX}" must be public and available on client.`,
});
}
if (
value.context === 'client' &&
value.access === 'public' &&
!key.startsWith(PUBLIC_PREFIX)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `An environment variable that is public and available on the client must have a name prefixed by "${PUBLIC_PREFIX}".`,
});
}
}
});

export type EnvSchema = z.infer<typeof EnvSchema>;

const StringField = z.intersection(
z.union([
PublicClientEnvFieldMetadata,
PublicServerEnvFieldMetadata,
SecretServerEnvFieldMetadata,
]),
StringSchema
);
export type StringField = z.infer<typeof StringField>;
export const StringFieldInput = z.intersection(
z.union([
PublicClientEnvFieldMetadata,
PublicServerEnvFieldMetadata,
SecretServerEnvFieldMetadata,
]),
StringSchema.omit({ type: true })
);
export type StringFieldInput = z.infer<typeof StringFieldInput>;

const NumberField = z.intersection(
z.union([
PublicClientEnvFieldMetadata,
PublicServerEnvFieldMetadata,
SecretServerEnvFieldMetadata,
]),
NumberSchema
);
export type NumberField = z.infer<typeof NumberField>;
export const NumberFieldInput = z.intersection(
z.union([
PublicClientEnvFieldMetadata,
PublicServerEnvFieldMetadata,
SecretServerEnvFieldMetadata,
]),
NumberSchema.omit({ type: true })
);
export type NumberFieldInput = z.infer<typeof NumberFieldInput>;

const BooleanField = z.intersection(
z.union([
PublicClientEnvFieldMetadata,
PublicServerEnvFieldMetadata,
SecretServerEnvFieldMetadata,
]),
BooleanSchema
);
export type BooleanField = z.infer<typeof BooleanField>;
export const BooleanFieldInput = z.intersection(
z.union([
PublicClientEnvFieldMetadata,
PublicServerEnvFieldMetadata,
SecretServerEnvFieldMetadata,
]),
BooleanSchema.omit({ type: true })
);
export type BooleanFieldInput = z.infer<typeof BooleanFieldInput>;
59 changes: 59 additions & 0 deletions packages/astro/test/units/config/config-validate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import stripAnsi from 'strip-ansi';
import { z } from 'zod';
import { validateConfig } from '../../../dist/core/config/config.js';
import { formatConfigErrorMessage } from '../../../dist/core/messages.js';
import { envField } from '../../../dist/config/index.js';

describe('Config Validation', () => {
it('empty user config is valid', async () => {
Expand Down Expand Up @@ -357,4 +358,62 @@ describe('Config Validation', () => {
);
});
});

describe('env', () => {
it('Should allow not providing a schema', () => {
assert.doesNotThrow(() =>
validateConfig(
{
experimental: {
env: {
schema: undefined,
},
},
},
process.cwd()
).catch((err) => err)
);
});

it('Should not allow public client variables without a PUBLIC_ prefix', async () => {
const configError = await validateConfig(
{
experimental: {
env: {
schema: {
FOO: envField.string({ context: 'client', access: 'public' }),
},
},
},
},
process.cwd()
).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
'An environment variable that is public and available on the client must have a name prefixed by "PUBLIC_".'
);
});

it('Should not allow non public client variables with a PUBLIC_ prefix', async () => {
const configError = await validateConfig(
{
experimental: {
env: {
schema: {
PUBLIC_FOO: envField.string({ context: 'server', access: 'public' }),
},
},
},
},
process.cwd()
).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
console.log(configError)
assert.equal(
configError.errors[0].message,
'An environment variable whose name is prefixed by "PUBLIC_" must be public and available on client.'
);
});
});
});
Loading