Skip to content
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
22 changes: 22 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,28 @@ tsdown --env.NODE_ENV=production

Note that environment variables defined with `--env.VAR_NAME` can only be accessed as `import.meta.env.VAR_NAME` or `process.env.VAR_NAME`.

## `--env-file <file>`

Load environment variables from a file. When used together with `--env`, variables in `--env` take precedence.

:::tip
To prevent accidental exposure of sensitive information, only environment variables prefixed with `TSDOWN_` are injected by default. You can customize this behavior using the [`--env-prefix`](#env-prefix) flag.
:::

```bash
tsdown --env-file .env.production
```

## `--env-prefix <prefix>` {#env-prefix}

When loading environment variables from a file via `--env-file`, only include variables that start with these prefixes.

- **Default:** `TSDOWN_`

```bash
tsdown --env-file .env --env-prefix APP_ --env-prefix TSDOWN_
```

## `--debug-logs [feat]`

Show debug logs.
Expand Down
22 changes: 22 additions & 0 deletions docs/zh-CN/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,28 @@ tsdown --env.NODE_ENV=production

注意,通过 `--env.VAR_NAME` 定义的环境变量只能通过 `import.meta.env.VAR_NAME` 或 `process.env.VAR_NAME` 访问。

## `--env-file <file>`

从文件加载环境变量。当与 `--env` 一起使用时,`--env` 中的变量优先生效。

:::tip
为防止敏感信息意外暴露,默认仅注入以 `TSDOWN_` 前缀开头的环境变量。您可以通过 [`--env-prefix`](#env-prefix) 标志自定义此行为。
:::

```bash
tsdown --env-file .env.production
```

## `--env-prefix <prefix>` {#env-prefix}

通过 `--env-file` 加载环境变量时,仅包含以这些前缀开头的变量。

- **默认值:** `TSDOWN_`

```bash
tsdown --env-file .env --env-prefix APP_ --env-prefix TSDOWN_
```

## `--debug-logs [feat]`

显示调试日志。
Expand Down
4 changes: 2 additions & 2 deletions dts.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,10 @@
"NoExternalFn": "type NoExternalFn = (_: string, _: string | undefined) => boolean | null | undefined | void",
"CIOption": "type CIOption = 'ci-only' | 'local-only'",
"WithEnabled": "type WithEnabled<T> = boolean | undefined | CIOption | (T & { enabled?: boolean | CIOption })",
"UserConfig": "interface UserConfig {\n entry?: TsdownInputOption\n external?: ExternalOption\n noExternal?: Arrayable<string | RegExp> | NoExternalFn\n inlineOnly?: Arrayable<string | RegExp>\n skipNodeModulesBundle?: boolean\n alias?: Record<string, string>\n tsconfig?: string | boolean\n platform?: 'node' | 'neutral' | 'browser'\n target?: string | string[] | false\n env?: Record<string, any>\n define?: Record<string, string>\n shims?: boolean\n treeshake?: boolean | TreeshakingOptions\n loader?: ModuleTypes\n removeNodeProtocol?: boolean\n nodeProtocol?: 'strip' | boolean\n plugins?: InputOptions['plugins']\n inputOptions?: InputOptions | ((_: InputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable<InputOptions | void | null>)\n format?: Format | Format[] | Partial<Record<Format, Partial<ResolvedConfig>>>\n globalName?: string\n outDir?: string\n write?: boolean\n sourcemap?: Sourcemap\n clean?: boolean | string[]\n minify?: boolean | 'dce-only' | MinifyOptions\n footer?: ChunkAddon\n banner?: ChunkAddon\n unbundle?: boolean\n bundle?: boolean\n fixedExtension?: boolean\n outExtensions?: OutExtensionFactory\n hash?: boolean\n cjsDefault?: boolean\n outputOptions?: OutputOptions | ((_: OutputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable<OutputOptions | void | null>)\n cwd?: string\n name?: string\n silent?: boolean\n logLevel?: LogLevel\n failOnWarn?: boolean | CIOption\n customLogger?: Logger\n fromVite?: boolean | 'vitest'\n watch?: boolean | Arrayable<string>\n ignoreWatch?: Arrayable<string | RegExp>\n debug?: WithEnabled<DebugOptions>\n onSuccess?: string | ((_: ResolvedConfig, _: AbortSignal) => void | Promise<void>)\n dts?: WithEnabled<DtsOptions>\n unused?: WithEnabled<UnusedOptions>\n publint?: WithEnabled<PublintOptions>\n attw?: WithEnabled<AttwOptions>\n report?: WithEnabled<ReportOptions>\n globImport?: boolean\n exports?: WithEnabled<ExportsOptions>\n publicDir?: CopyOptions | CopyOptionsFn\n copy?: CopyOptions | CopyOptionsFn\n hooks?: Partial<TsdownHooks> | ((_: Hookable<TsdownHooks>) => Awaitable<void>)\n workspace?: Workspace | Arrayable<string> | true\n}",
"UserConfig": "interface UserConfig {\n entry?: TsdownInputOption\n external?: ExternalOption\n noExternal?: Arrayable<string | RegExp> | NoExternalFn\n inlineOnly?: Arrayable<string | RegExp>\n skipNodeModulesBundle?: boolean\n alias?: Record<string, string>\n tsconfig?: string | boolean\n platform?: 'node' | 'neutral' | 'browser'\n target?: string | string[] | false\n env?: Record<string, any>\n envFile?: string\n envPrefix?: string | string[]\n define?: Record<string, string>\n shims?: boolean\n treeshake?: boolean | TreeshakingOptions\n loader?: ModuleTypes\n removeNodeProtocol?: boolean\n nodeProtocol?: 'strip' | boolean\n plugins?: InputOptions['plugins']\n inputOptions?: InputOptions | ((_: InputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable<InputOptions | void | null>)\n format?: Format | Format[] | Partial<Record<Format, Partial<ResolvedConfig>>>\n globalName?: string\n outDir?: string\n write?: boolean\n sourcemap?: Sourcemap\n clean?: boolean | string[]\n minify?: boolean | 'dce-only' | MinifyOptions\n footer?: ChunkAddon\n banner?: ChunkAddon\n unbundle?: boolean\n bundle?: boolean\n fixedExtension?: boolean\n outExtensions?: OutExtensionFactory\n hash?: boolean\n cjsDefault?: boolean\n outputOptions?: OutputOptions | ((_: OutputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable<OutputOptions | void | null>)\n cwd?: string\n name?: string\n silent?: boolean\n logLevel?: LogLevel\n failOnWarn?: boolean | CIOption\n customLogger?: Logger\n fromVite?: boolean | 'vitest'\n watch?: boolean | Arrayable<string>\n ignoreWatch?: Arrayable<string | RegExp>\n debug?: WithEnabled<DebugOptions>\n onSuccess?: string | ((_: ResolvedConfig, _: AbortSignal) => void | Promise<void>)\n dts?: WithEnabled<DtsOptions>\n unused?: WithEnabled<UnusedOptions>\n publint?: WithEnabled<PublintOptions>\n attw?: WithEnabled<AttwOptions>\n report?: WithEnabled<ReportOptions>\n globImport?: boolean\n exports?: WithEnabled<ExportsOptions>\n publicDir?: CopyOptions | CopyOptionsFn\n copy?: CopyOptions | CopyOptionsFn\n hooks?: Partial<TsdownHooks> | ((_: Hookable<TsdownHooks>) => Awaitable<void>)\n workspace?: Workspace | Arrayable<string> | true\n}",
"InlineConfig": "interface InlineConfig extends UserConfig {\n config?: boolean | string\n configLoader?: 'auto' | 'native' | 'unrun'\n filter?: RegExp | Arrayable<string>\n}",
"UserConfigFn": "type UserConfigFn = (_: InlineConfig, _: { ci: boolean }) => Awaitable<Arrayable<UserConfig>>",
"UserConfigExport": "type UserConfigExport = Awaitable<Arrayable<UserConfig> | UserConfigFn>",
"ResolvedConfig": "type ResolvedConfig = Overwrite<MarkPartial<Omit<UserConfig, 'workspace' | 'fromVite' | 'publicDir' | 'silent' | 'bundle' | 'removeNodeProtocol' | 'logLevel' | 'failOnWarn' | 'customLogger'>, 'globalName' | 'inputOptions' | 'outputOptions' | 'minify' | 'define' | 'alias' | 'external' | 'onSuccess' | 'outExtensions' | 'hooks' | 'copy' | 'loader' | 'name' | 'banner' | 'footer'>, { entry: Record<string, string>; nameLabel: string | undefined; format: NormalizedFormat; target?: string[]; clean: string[]; pkg?: PackageJsonWithPath; nodeProtocol: 'strip' | boolean; logger: Logger; ignoreWatch: Array<string | RegExp>; noExternal?: NoExternalFn; inlineOnly?: Array<string | RegExp>; dts: false | DtsOptions; report: false | ReportOptions; tsconfig: false | string; exports: false | ExportsOptions; debug: false | DebugOptions; publint: false | PublintOptions; attw: false | AttwOptions; unused: false | UnusedOptions }>"
"ResolvedConfig": "type ResolvedConfig = Overwrite<MarkPartial<Omit<UserConfig, 'workspace' | 'fromVite' | 'publicDir' | 'silent' | 'bundle' | 'removeNodeProtocol' | 'logLevel' | 'failOnWarn' | 'customLogger' | 'envFile' | 'envPrefix'>, 'globalName' | 'inputOptions' | 'outputOptions' | 'minify' | 'define' | 'alias' | 'external' | 'onSuccess' | 'outExtensions' | 'hooks' | 'copy' | 'loader' | 'name' | 'banner' | 'footer'>, { entry: Record<string, string>; nameLabel: string | undefined; format: NormalizedFormat; target?: string[]; clean: string[]; pkg?: PackageJsonWithPath; nodeProtocol: 'strip' | boolean; logger: Logger; ignoreWatch: Array<string | RegExp>; noExternal?: NoExternalFn; inlineOnly?: Array<string | RegExp>; dts: false | DtsOptions; report: false | ReportOptions; tsconfig: false | string; exports: false | ExportsOptions; debug: false | DebugOptions; publint: false | PublintOptions; attw: false | AttwOptions; unused: false | UnusedOptions }>"
}
}
9 changes: 9 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ cli
.option('--from-vite [vitest]', 'Reuse config from Vite or Vitest')
.option('--report', 'Size report', { default: true })
.option('--env.* <value>', 'Define compile-time env variables')
.option(
'--env-file <file>',
'Load environment variables from a file, when used together with --env, variables in --env take precedence',
)
.option(
'--env-prefix <prefix>',
'Prefix for env variables to inject into the bundle',
{ default: 'TSDOWN_' },
)
.option('--on-success <command>', 'Command to run on success')
.option('--copy <dir>', 'Copy files to output dir')
.option('--public-dir <dir>', 'Alias for --copy, deprecated')
Expand Down
43 changes: 43 additions & 0 deletions src/config/options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { parseEnv } from 'node:util'
import { blue } from 'ansis'
import { createDefu } from 'defu'
import isInCi from 'is-in-ci'
Expand Down Expand Up @@ -62,6 +64,8 @@ export async function resolveUserConfig(
report = true,
target,
env = {},
envFile,
envPrefix = 'TSDOWN_',
copy,
publicDir,
hash = true,
Expand Down Expand Up @@ -164,6 +168,28 @@ export async function resolveUserConfig(
}
}

envPrefix = toArray(envPrefix)
if (envPrefix.includes('')) {
logger.warn(
'`envPrefix` includes an empty string; filtering is disabled. All environment variables from the env file and process.env will be injected into the build. Ensure this is intended to avoid accidental leakage of sensitive information.',
)
}
const envFromProcess = filterEnv(process.env, envPrefix)
if (envFile) {
const resolvedPath = path.resolve(cwd, envFile)
logger.info(nameLabel, `env file: ${color(resolvedPath)}`)

const parsed = parseEnv(await readFile(resolvedPath, 'utf8'))
const envFromFile = filterEnv(parsed, envPrefix)

// precedence: env file < process.env < tsdown option
env = { ...envFromFile, ...envFromProcess, ...env }
} else {
// precedence: process.env < tsdown option
env = { ...envFromProcess, ...env }
}
debugLog(`Environment variables: %O`, env)

if (fromVite) {
const viteUserConfig = await loadViteConfig(
fromVite === true ? 'vite' : fromVite,
Expand Down Expand Up @@ -278,6 +304,23 @@ export async function resolveUserConfig(
})
}

/** filter env variables by prefixes */
function filterEnv(
envDict: Record<string, string | undefined>,
envPrefixes: string[],
) {
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(envDict)) {
if (
envPrefixes.some((prefix) => key.startsWith(prefix)) &&
value !== undefined
) {
env[key] = value
}
}
return env
}

const defu = createDefu((obj, key, value) => {
if (Array.isArray(obj[key]) && Array.isArray(value)) {
obj[key] = value
Expand Down
15 changes: 14 additions & 1 deletion src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ export interface UserConfig {
target?: string | string[] | false

/**
* Compile-time env variables.
* Compile-time env variables, which can be accessed via `import.meta.env` or `process.env`.
* @example
* ```json
* {
Expand All @@ -218,6 +218,17 @@ export interface UserConfig {
* ```
*/
env?: Record<string, any>
/**
* Path to env file providing compile-time env variables.
* @example
* `.env`, `.env.production`, etc.
*/
envFile?: string
/**
* When loading env variables from `envFile`, only include variables with these prefixes.
* @default 'TSDOWN_'
*/
envPrefix?: string | string[]
define?: Record<string, string>

/** @default false */
Expand Down Expand Up @@ -565,6 +576,8 @@ export type ResolvedConfig = Overwrite<
| 'logLevel' // merge to `logger`
| 'failOnWarn' // merge to `logger`
| 'customLogger' // merge to `logger`
| 'envFile' // merged to `env`
| 'envPrefix' // merged to `env`
>,
| 'globalName'
| 'inputOptions'
Expand Down
12 changes: 12 additions & 0 deletions tests/__snapshots__/env-file-flag.snap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## index.mjs

```mjs
//#region index.ts
const foo = "bar";
const bar = "override";
const custom = "tsdown";
const debug = true;

//#endregion
export { bar, custom, debug, foo };
```
11 changes: 11 additions & 0 deletions tests/__snapshots__/env-prefix-flag.snap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## index.mjs

```mjs
//#region index.ts
const foo = "foo";
const bar = "bar";
const custom = import.meta.env.CUSTOM;

//#endregion
export { bar, custom, foo };
```
61 changes: 61 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,67 @@ test('env flag', async (context) => {
expect(snapshot).contains('const debug = true')
})

test('env-file flag', async (context) => {
const files = {
'index.ts': `export const foo = import.meta.env.TSDOWN_FOO
export const bar = import.meta.env.TSDOWN_BAR
export const custom = import.meta.env.CUSTOM
export const debug = process.env.DEBUG
`,
'.env': `TSDOWN_FOO=bar
TSDOWN_BAR=baz`,
}
const { snapshot } = await testBuild({
context,
files,
options: {
env: {
CUSTOM: 'tsdown',
DEBUG: true,
TSDOWN_BAR: 'override',
},
envFile: '.env',
},
})
expect(snapshot).contains('const foo = "bar"')
expect(snapshot).contains(
'const bar = "override"',
'Env var from --env should override .env file',
)
expect(snapshot).contains('const custom = "tsdown"')
expect(snapshot).contains('const debug = true')
})

test('env-prefix flag', async (context) => {
const files = {
'index.ts': `export const foo = import.meta.env.MYAPP_FOO
export const bar = import.meta.env.TSDOWN_BAR
export const custom = import.meta.env.CUSTOM
`,
'.env': `MYAPP_FOO=foo
TSDOWN_BAR=bar
`,
}
const { snapshot } = await testBuild({
context,
files,
options: {
env: {
MYAPP_FOO: 'foo',
TSDOWN_BAR: 'bar',
},
envFile: '.env',
envPrefix: ['MYAPP_', 'TSDOWN_'],
},
})
expect(snapshot).contains('const foo = "foo"')
expect(snapshot).contains('const bar = "bar"')
expect(snapshot).contains(
'const custom = import.meta.env.CUSTOM',
'Unmatched prefix env var should not be replaced',
)
})

test('minify', async (context) => {
const files = { 'index.ts': `export const foo = true` }
const { snapshot } = await testBuild({
Expand Down
Loading