@quintal/environment 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.coverage/base.css +224 -0
- package/.coverage/block-navigation.js +87 -0
- package/.coverage/clover.xml +161 -0
- package/.coverage/coverage-final.json +2 -0
- package/.coverage/favicon.png +0 -0
- package/.coverage/index.html +116 -0
- package/.coverage/index.ts.html +541 -0
- package/.coverage/prettify.css +1 -0
- package/.coverage/prettify.js +2 -0
- package/.coverage/sort-arrow-sprite.png +0 -0
- package/.coverage/sorter.js +196 -0
- package/.eslintrc.js +1 -0
- package/.prettierrc.js +1 -0
- package/.turbo/turbo-build.log +28 -0
- package/.turbo/turbo-lint:check.log +6 -0
- package/.turbo/turbo-test.log +23 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.mjs +2794 -0
- package/package.json +32 -0
- package/src/index.ts +152 -0
- package/test/index.spec.ts +162 -0
- package/tsconfig.json +8 -0
- package/vite.config.js +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quintal/environment",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "./dist/index.cjs",
|
|
5
|
+
"module": "./dist/index.mjs",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"peerDependencies": {
|
|
8
|
+
"zod": "^3.22.4"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/node": "20.10.2",
|
|
12
|
+
"@vitest/coverage-v8": "0.34.6",
|
|
13
|
+
"happy-dom": "12.10.3",
|
|
14
|
+
"typescript": "5.3.2",
|
|
15
|
+
"vite": "5.0.4",
|
|
16
|
+
"vitest": "0.34.6",
|
|
17
|
+
"zod": "3.22.4",
|
|
18
|
+
"@quintal/config": "0.2.0"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "run-s build:*",
|
|
22
|
+
"build:clean": "shx rm -rf dist",
|
|
23
|
+
"build:code": "vite build",
|
|
24
|
+
"clean": "shx rm -rf .coverage .coverage-ts .turbo dist node_modules",
|
|
25
|
+
"dev": "vitest --watch",
|
|
26
|
+
"lint": "pnpm lint:fix && pnpm lint:types",
|
|
27
|
+
"lint:check": "eslint .",
|
|
28
|
+
"lint:fix": "pnpm lint:check --fix",
|
|
29
|
+
"lint:types": "tsc",
|
|
30
|
+
"test": "vitest"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ZodError, ZodType } from 'zod';
|
|
3
|
+
|
|
4
|
+
type EnvValue = {
|
|
5
|
+
/**
|
|
6
|
+
* The value
|
|
7
|
+
* @example process.env.NODE_ENV
|
|
8
|
+
* @example process.env.DATABASE_URL
|
|
9
|
+
* @example process.env.NEXT_PUBLIC_ENVIRONMENT
|
|
10
|
+
*/
|
|
11
|
+
value: string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Zod schema that validates the value of the environment variable.
|
|
14
|
+
* @defaultValue z.string()
|
|
15
|
+
*/
|
|
16
|
+
schema?: ZodType;
|
|
17
|
+
/**
|
|
18
|
+
* Only make environment variable available to server usages.
|
|
19
|
+
* @defaultValue false
|
|
20
|
+
*/
|
|
21
|
+
isServerOnly?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type EnvValues = { [key: string]: EnvValue | EnvValues };
|
|
25
|
+
|
|
26
|
+
type UnwrapEnvValue<T> = T extends EnvValue
|
|
27
|
+
? T extends Required<Pick<EnvValue, 'schema'>>
|
|
28
|
+
? ReturnType<T['schema']['parse']>
|
|
29
|
+
: string
|
|
30
|
+
: T extends Record<string, unknown>
|
|
31
|
+
? { [TKey in keyof T]: UnwrapEnvValue<T[TKey]> }
|
|
32
|
+
: never;
|
|
33
|
+
|
|
34
|
+
type UnwrapEnvValues<TEnvValues extends EnvValues> = {
|
|
35
|
+
[TKey in keyof TEnvValues]: UnwrapEnvValue<TEnvValues[TKey]>;
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -- This needs to be here for "prettify" effect
|
|
37
|
+
} & unknown;
|
|
38
|
+
|
|
39
|
+
type CreateEnvironmentOptions<TEnvValues extends EnvValues> = {
|
|
40
|
+
/**
|
|
41
|
+
* Whether or not the application is running on the server.
|
|
42
|
+
* @defaultValue typeof window === 'undefined'
|
|
43
|
+
*/
|
|
44
|
+
isServer?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Called when validation fails.
|
|
47
|
+
* Default behaviour: throw Error
|
|
48
|
+
*/
|
|
49
|
+
onValidationError?: (error: ZodError) => never;
|
|
50
|
+
/**
|
|
51
|
+
* Called when a server-side environment variable is accessed on the client.
|
|
52
|
+
* Default behaviour: throw Error
|
|
53
|
+
*/
|
|
54
|
+
onAccessError?: (variableName: string) => never;
|
|
55
|
+
/**
|
|
56
|
+
* The values in the typed environment object.
|
|
57
|
+
*/
|
|
58
|
+
values: TEnvValues;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function makeValues(values: EnvValues): Record<string, unknown> {
|
|
62
|
+
return Object.entries(values).reduce(
|
|
63
|
+
(prev, [name, obj]) => {
|
|
64
|
+
if ('value' in obj) prev[name] = obj.value || undefined;
|
|
65
|
+
else prev[name] = makeValues(obj);
|
|
66
|
+
return prev;
|
|
67
|
+
},
|
|
68
|
+
{} as Record<string, unknown>,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makeSchema(values: EnvValues, isServer: boolean): ZodType {
|
|
73
|
+
const o = Object.entries(values).reduce(
|
|
74
|
+
(prev, [name, obj]) => {
|
|
75
|
+
if ('value' in obj) {
|
|
76
|
+
if (!obj.isServerOnly || isServer)
|
|
77
|
+
prev[name] = (obj.schema as ZodType | undefined) ?? z.string();
|
|
78
|
+
} else prev[name] = makeSchema(obj, isServer);
|
|
79
|
+
return prev;
|
|
80
|
+
},
|
|
81
|
+
{} as Record<string, ZodType>,
|
|
82
|
+
);
|
|
83
|
+
return z.object(o);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createProxy<TEnvValues extends EnvValues>(
|
|
87
|
+
values: UnwrapEnvValues<TEnvValues>,
|
|
88
|
+
originalValues: EnvValues,
|
|
89
|
+
isServer: boolean,
|
|
90
|
+
onAccessError: (variableName: string) => never,
|
|
91
|
+
prefix?: string,
|
|
92
|
+
): UnwrapEnvValues<TEnvValues> {
|
|
93
|
+
return new Proxy(values, {
|
|
94
|
+
get(target, prop: string) {
|
|
95
|
+
const targetValue = originalValues[prop];
|
|
96
|
+
if (!targetValue) return undefined;
|
|
97
|
+
|
|
98
|
+
const variableName = prefix ? `${prefix}.${prop}` : prop;
|
|
99
|
+
|
|
100
|
+
if (!('value' in targetValue))
|
|
101
|
+
return createProxy(
|
|
102
|
+
values[prop] as UnwrapEnvValues<TEnvValues>,
|
|
103
|
+
originalValues[prop] as EnvValues,
|
|
104
|
+
isServer,
|
|
105
|
+
onAccessError,
|
|
106
|
+
variableName,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (targetValue.isServerOnly && !isServer)
|
|
110
|
+
return onAccessError(variableName);
|
|
111
|
+
|
|
112
|
+
return target[prop];
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createEnvironment<TEnvValues extends EnvValues>(
|
|
118
|
+
opts: CreateEnvironmentOptions<TEnvValues>,
|
|
119
|
+
): UnwrapEnvValues<TEnvValues> {
|
|
120
|
+
const isServer = opts.isServer ?? typeof window === 'undefined';
|
|
121
|
+
|
|
122
|
+
const onValidationError =
|
|
123
|
+
opts.onValidationError ??
|
|
124
|
+
((error: ZodError) => {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`❌ Invalid environment variables: ${error.issues
|
|
127
|
+
.map(({ path, message }) => `${path.join('.')}: ${message}`)
|
|
128
|
+
.join(', ')}`,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const onAccessError =
|
|
133
|
+
opts.onAccessError ??
|
|
134
|
+
((variableName: string) => {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`❌ Attempted to access server-side environment variable '${variableName}' on the client`,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const schema = makeSchema(opts.values, isServer);
|
|
141
|
+
const values = makeValues(opts.values);
|
|
142
|
+
|
|
143
|
+
const parsed = schema.safeParse(values);
|
|
144
|
+
if (!parsed.success) return onValidationError(parsed.error);
|
|
145
|
+
|
|
146
|
+
return createProxy(
|
|
147
|
+
parsed.data as UnwrapEnvValues<TEnvValues>,
|
|
148
|
+
opts.values,
|
|
149
|
+
isServer,
|
|
150
|
+
onAccessError,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
3
|
+
import { createEnvironment } from '../src';
|
|
4
|
+
|
|
5
|
+
describe('environment', () => {
|
|
6
|
+
it('returns a strongly-typed environment object', () => {
|
|
7
|
+
if (false as boolean) {
|
|
8
|
+
const environment = createEnvironment({
|
|
9
|
+
values: {
|
|
10
|
+
environment: {
|
|
11
|
+
value: process.env.NEXT_PUBLIC_ENVIRONMENT,
|
|
12
|
+
schema: z
|
|
13
|
+
.enum(['DEVELOPMENT', 'PREVIEW', 'PRODUCTION'])
|
|
14
|
+
.default('DEVELOPMENT'),
|
|
15
|
+
},
|
|
16
|
+
port: {
|
|
17
|
+
value: process.env.PORT,
|
|
18
|
+
schema: z.coerce.number().int().default(4000),
|
|
19
|
+
isServerOnly: true,
|
|
20
|
+
},
|
|
21
|
+
isFeatureEnabled: {
|
|
22
|
+
value: process.env.NEXT_PUBLIC_IS_FEATURE_ENABLED,
|
|
23
|
+
schema: z.enum(['true', 'false']).transform((s) => s === 'true'),
|
|
24
|
+
},
|
|
25
|
+
baseUrl: {
|
|
26
|
+
self: {
|
|
27
|
+
value: process.env.NEXT_PUBLIC_BASE_URL_SELF,
|
|
28
|
+
schema: z.string().url().default('http://localhost:3000'),
|
|
29
|
+
},
|
|
30
|
+
api: {
|
|
31
|
+
value: process.env.NEXT_PUBLIC_BASE_URL_API,
|
|
32
|
+
schema: z.string().url().default('http://localhost:4000'),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
database: {
|
|
36
|
+
url: {
|
|
37
|
+
value: process.env.DATABASE_URL,
|
|
38
|
+
schema: z.string().url(),
|
|
39
|
+
isServerOnly: true,
|
|
40
|
+
},
|
|
41
|
+
token: {
|
|
42
|
+
value: process.env.DATABASE_TOKEN,
|
|
43
|
+
isServerOnly: true,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expectTypeOf(environment).toEqualTypeOf<{
|
|
50
|
+
environment: 'DEVELOPMENT' | 'PREVIEW' | 'PRODUCTION';
|
|
51
|
+
port: number;
|
|
52
|
+
isFeatureEnabled: boolean;
|
|
53
|
+
baseUrl: {
|
|
54
|
+
self: string;
|
|
55
|
+
api: string;
|
|
56
|
+
};
|
|
57
|
+
database: {
|
|
58
|
+
url: string;
|
|
59
|
+
token: string;
|
|
60
|
+
};
|
|
61
|
+
}>();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('throws when an environment variable is incorrect', () => {
|
|
66
|
+
expect(() =>
|
|
67
|
+
createEnvironment({
|
|
68
|
+
values: {
|
|
69
|
+
undefined: { value: undefined },
|
|
70
|
+
nestedUndefined: { undefined: { value: undefined } },
|
|
71
|
+
emptyString: { value: '' },
|
|
72
|
+
emptyStringToNumber: { value: '', schema: z.number() },
|
|
73
|
+
emptyStringWithDefault: {
|
|
74
|
+
value: '',
|
|
75
|
+
schema: z.string().default('test'),
|
|
76
|
+
},
|
|
77
|
+
emptyStringToNumberWithDefault: {
|
|
78
|
+
value: '',
|
|
79
|
+
schema: z.number().default(42),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
).toThrowErrorMatchingInlineSnapshot(
|
|
84
|
+
'"❌ Invalid environment variables: undefined: Required, nestedUndefined.undefined: Required, emptyString: Required, emptyStringToNumber: Required"',
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does not throw when accessing a client environment variable and server variables are missing', () => {
|
|
89
|
+
const environment = createEnvironment({
|
|
90
|
+
isServer: false,
|
|
91
|
+
values: {
|
|
92
|
+
correct: { value: 'hello world' },
|
|
93
|
+
incorrect: { value: undefined, isServerOnly: true },
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(environment.correct).toBe('hello world');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('throws when trying to access server-only variables from the client', () => {
|
|
101
|
+
const values = {
|
|
102
|
+
serverOnly: { value: 'hello', isServerOnly: true },
|
|
103
|
+
notServerOnly: { value: 'world', isServerOnly: false },
|
|
104
|
+
nested: {
|
|
105
|
+
serverOnly: { value: 'helloNested', isServerOnly: true },
|
|
106
|
+
notServerOnly: { value: 'worldNested', isServerOnly: false },
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const clientEnvironment = createEnvironment({ isServer: false, values });
|
|
111
|
+
expect(
|
|
112
|
+
() => clientEnvironment.serverOnly,
|
|
113
|
+
).toThrowErrorMatchingInlineSnapshot(
|
|
114
|
+
'"❌ Attempted to access server-side environment variable \'serverOnly\' on the client"',
|
|
115
|
+
);
|
|
116
|
+
expect(clientEnvironment.notServerOnly).toBe('world');
|
|
117
|
+
expect(
|
|
118
|
+
() => clientEnvironment.nested.serverOnly,
|
|
119
|
+
).toThrowErrorMatchingInlineSnapshot(
|
|
120
|
+
'"❌ Attempted to access server-side environment variable \'nested.serverOnly\' on the client"',
|
|
121
|
+
);
|
|
122
|
+
expect(clientEnvironment.nested.notServerOnly).toBe('worldNested');
|
|
123
|
+
|
|
124
|
+
const serverEnvironment = createEnvironment({ isServer: true, values });
|
|
125
|
+
expect(serverEnvironment.serverOnly).toBe('hello');
|
|
126
|
+
expect(serverEnvironment.notServerOnly).toBe('world');
|
|
127
|
+
expect(serverEnvironment.nested.serverOnly).toBe('helloNested');
|
|
128
|
+
expect(serverEnvironment.nested.notServerOnly).toBe('worldNested');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('parses (nested) environment variables to be in the correct datatype', () => {
|
|
132
|
+
const environment = createEnvironment({
|
|
133
|
+
values: {
|
|
134
|
+
string: { value: 'hello world', schema: z.string() },
|
|
135
|
+
number: { value: '42', schema: z.coerce.number().int() },
|
|
136
|
+
boolean: {
|
|
137
|
+
value: 'true',
|
|
138
|
+
schema: z.enum(['true', 'false']).transform((s) => s === 'true'),
|
|
139
|
+
},
|
|
140
|
+
nested: {
|
|
141
|
+
string: { value: 'nested hello world', schema: z.string() },
|
|
142
|
+
number: { value: '4242', schema: z.coerce.number().int() },
|
|
143
|
+
boolean: {
|
|
144
|
+
value: 'false',
|
|
145
|
+
schema: z.enum(['true', 'false']).transform((s) => s === 'true'),
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(environment.string).toBe('hello world');
|
|
152
|
+
expect(environment.number).toBe(42);
|
|
153
|
+
expect(environment.boolean).toBe(true);
|
|
154
|
+
expect(
|
|
155
|
+
// @ts-expect-error -- testing invalid access
|
|
156
|
+
environment.never,
|
|
157
|
+
).toBe(undefined);
|
|
158
|
+
expect(environment.nested.string).toBe('nested hello world');
|
|
159
|
+
expect(environment.nested.number).toBe(4242);
|
|
160
|
+
expect(environment.nested.boolean).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
package/tsconfig.json
ADDED
package/vite.config.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('@quintal/config/vite');
|