diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml new file mode 100644 index 0000000000..cc9b43da5d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-client-build-environment.md +2026-08-18-client-build-environment.md: 771fdb936d71daad982cb25f58033284941df679 +2026-08-18-client-build-environment.zh.md: 2a5948d64f585f8eade49cff7d925b6a1c73f129 diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md new file mode 100644 index 0000000000..771fdb936d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md @@ -0,0 +1,35 @@ +# Agent Note: Build-time public environment variables for client business code + +Status: implemented + +English | [中文](2026-08-18-client-build-environment.zh.md) + +## Problem + +Browser business packages need deployment builds to select static behavior, but the Web client has two artifact paths that do not contain one another: Vite builds the static shell, while the shared tsdown preset builds dynamically loaded plugins. Replacing an environment expression in only one path would give the same business expression different results depending on its package type. + +Browsers have no Node `process`, and embedding the build process's complete environment object would expose values unrelated to the frontend. Runtime configuration also does not accurately represent a build variant because this choice must remain fixed after an artifact is published. + +## Decision + +`DSH_CLIENT_*` is the build-time namespace for values that may be exposed to browser business code. Business code may use a static property read such as `process.env.DSH_CLIENT_NAME` to select behavior. Values come only from the build process environment, not from Vite `.env*` files. Set values are inlined as strings, and unset values evaluate to `undefined`. + +The Vite config and the shared tsdown preset for dynamic client bundles use one define generator. The generator creates exact substitutions only for `DSH_CLIENT_*` and reduces all remaining `process.env` reads to an empty object. The browser receives no global `process`, dynamic-key lookup, or environment enumeration capability. + +The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, paths, and other Host- or CI-only values must not use it. + +## Alternatives considered + +**Replace values only in Vite.** A dynamic plugin's `lib/client.js` is loaded as an independent script and never enters Vite's module graph, so the expression would remain in a browser that has no `process`. + +**Expose every `DSH_*` value.** Host, test, and CI variables already use that prefix and may contain credentials or local paths. The narrower `DSH_CLIENT_*` prefix makes exposure intent auditable. + +**Provide a complete `process.env` object in the browser.** This would permit build-environment enumeration and turn a Node compatibility shim into a runtime API. Exact static substitutions are sufficient for build choices. + +**Standardize on `import.meta.env`.** Dynamic plugins are emitted as independent CommonJS factories and cannot retain `import.meta`. Business code would still need two interfaces depending on the artifact path. + +## Consequences + +The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. CI workflows that produce DSH client artifacts set the required variables explicitly; workflows that do not produce those artifacts do not need them. + +Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism. diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md new file mode 100644 index 0000000000..2a5948d64f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Client 业务代码使用构建期公开环境变量 + +Status: implemented + +[English](2026-08-18-client-build-environment.md) | 中文 + +## Problem + +浏览器业务包需要按部署构建选择静态行为,但 Web client 有两条互不包含的产物路径:Vite 构建静态壳,共享 tsdown preset 构建运行时加载的动态插件。只在一条路径替换环境变量会使相同业务表达式因所在包类型不同而产生不同结果。 + +浏览器没有 Node `process`,而把构建进程的完整环境对象放入产物会泄露与前端无关的值。运行时配置也不能准确表达构建变体,因为产物发布后不应再改变这类选择。 + +## Decision + +`DSH_CLIENT_*` 是可公开给浏览器业务代码的构建期命名空间。业务代码可用静态点访问 `process.env.DSH_CLIENT_NAME` 选择行为;值只取自构建进程环境,不读取 Vite `.env*` 文件。设置的值在构建时内联为字符串,未设置的值为 `undefined`。 + +Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define 生成器。生成器只为 `DSH_CLIENT_*` 创建精确替换,并把其余 `process.env` 读取收敛到空对象;浏览器不获得全局 `process`、动态键读取或环境枚举能力。 + +`DSH_CLIENT_*` 的名称本身表示公开性。凭据、路径和其他仅供 Host 或 CI 使用的值不得使用该前缀。 + +## Alternatives considered + +**只在 Vite 中替换。** 动态插件的 `lib/client.js` 作为独立脚本由浏览器加载,不进入 Vite 模块图,表达式会残留到无 `process` 的浏览器。 + +**公开全部 `DSH_*`。** 仓库中的 Host、测试和 CI 变量使用该前缀,其中可能包含凭据或本地路径;更窄的 `DSH_CLIENT_*` 让公开意图可审计。 + +**在浏览器提供完整 `process.env` 对象。** 这会允许枚举构建环境并把 Node 兼容垫片变成运行时 API;静态精确替换足以承载构建选择。 + +**统一改用 `import.meta.env`。** 动态插件输出为独立 CJS factory,不能保留 `import.meta`;业务代码仍会因产物路径不同而使用两套接口。 + +## Consequences + +Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。生成 DSH client 产物的 CI workflow 显式提供所需变量;不生成这些产物的 workflow 不需要携带它们。 + +任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 02c04257b0..12f6daeeae 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -49,6 +49,7 @@ permissions: contents: read env: + DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771fe0eb31..264843264a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ permissions: contents: read env: + DSH_CLIENT_BRAND: official PRIMARY_NODE_VERSION: '24' # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). diff --git a/.github/workflows/e2b-e2e.yml b/.github/workflows/e2b-e2e.yml index abbd0482ed..7e8d33072e 100644 --- a/.github/workflows/e2b-e2e.yml +++ b/.github/workflows/e2b-e2e.yml @@ -9,6 +9,7 @@ permissions: contents: read env: + DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a9c67fca42..227033892c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,6 +47,7 @@ permissions: contents: read env: + DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e20c215fd..b4fab5e1f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,7 @@ concurrency: cancel-in-progress: false env: + DSH_CLIENT_BRAND: official PRIMARY_NODE_VERSION: '24' DSH_TELEMETRY_DISABLED: '1' diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 192cbbd524..bbc19cf49e 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -19,6 +19,7 @@ permissions: contents: read env: + DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 33c4a048ae..5e57eee177 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' import type { Plugin } from 'vite' import react from '@vitejs/plugin-react' +import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' @@ -146,6 +147,7 @@ export default defineConfig({ ], }, define: { + ...clientBuildEnvironmentDefines(process.env), // vendored loader internal.ts: fromInternal() probes the Node major — // "0.0.0" takes neither branch, returning undefined (exactly the empty // internal slot the shell boot fills with the client module loader). diff --git a/knip.json b/knip.json index 9edea9ab23..8733835187 100644 --- a/knip.json +++ b/knip.json @@ -25,7 +25,11 @@ ".": { "entry": [ "scripts/**/*.mjs", - "scripts/**/*.cjs" + "scripts/**/*.cjs", + "scripts/types/client-build-environment/index.d.ts" + ], + "ignoreUnresolved": [ + "client-build-environment" ], "project": [ "scripts/**/*.ts", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 86a23bbf12..0a3e21852a 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -66,6 +66,10 @@ Npm sections describe installation and development relationships; each build fac 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. +## Build-time browser environment + +Client business code may statically read `process.env.DSH_CLIENT_*`; every referenced value is public artifact content. The shared build-environment helper gives Vite and dynamic tsdown bundles the same build-process values, resolves unset names to `undefined`, and exposes no dynamic lookup or enumeration. Use runtime configuration for choices that must change after build. + ## Shared modules and the module graph A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI libraries; `PRELOADED_CLIENT_EXTERNALS` names dynamic rows, currently runtime, whose ordinary `lib/client.js` factory arrives before shell boot. diff --git a/packages/client/runtime/src/env.d.ts b/packages/client/runtime/src/env.d.ts deleted file mode 100644 index 54d3d9d8f3..0000000000 --- a/packages/client/runtime/src/env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Bundler-replaced NODE_ENV: vite/tsdown substitute the literal, so browsers - * never evaluate a bare `process`. tsconfig carries no node types on purpose. - */ -declare const process: { env: { NODE_ENV?: string } } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 30c157edf1..44aab8cf1b 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -17,6 +17,7 @@ import type { UserConfig } from 'tsdown' import { transform } from 'lightningcss' import { optionalStringArray } from './modules/src/client/manifest.ts' import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts' +import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts' /** * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline @@ -470,6 +471,7 @@ function clientConfig(id: string, entry: string): UserConfig { // key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and // the truthiness probe would otherwise survive as an empty import.meta. define: { + ...clientBuildEnvironmentDefines(process.env), 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'), 'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'), 'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }), diff --git a/scripts/client-build-environment.client.spec.ts b/scripts/client-build-environment.client.spec.ts new file mode 100644 index 0000000000..2f4f6cb178 --- /dev/null +++ b/scripts/client-build-environment.client.spec.ts @@ -0,0 +1,83 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import yaml from 'js-yaml' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { clientBuildEnvironmentDefines } from './client-build-environment.ts' +import { clientBundle } from '../packages/client/tsdown.client.ts' + +const root = resolve(import.meta.dirname, '..') +const PROBE_NAME = 'DSH_CLIENT_BUILD_TEST' +const PROBE_KEY = `process.env.${PROBE_NAME}` +const originalProbe = process.env[PROBE_NAME] +const dshBuildWorkflows = [ + 'build-exe-for-python-sdk.yml', + 'ci.yml', + 'e2b-e2e.yml', + 'e2e.yml', + 'release.yml', + 'sandbox.yml', +] + +afterEach(() => { + if (originalProbe === undefined) Reflect.deleteProperty(process.env, PROBE_NAME) + else process.env[PROBE_NAME] = originalProbe + vi.resetModules() +}) + +describe('client build environment', () => { + it('defines only public client values over a non-enumerable fallback', () => { + expect(clientBuildEnvironmentDefines({ + PATH: '/bin', + DSH_TEST_API_KEY: 'secret', + DSH_CLIENT_VARIANT: 'quoted "value"', + DSH_CLIENT_EMPTY: '', + DSH_CLIENT_UNSET: undefined, + })).toEqual({ + 'process.env': '{}', + 'process.env.DSH_CLIENT_EMPTY': '""', + 'process.env.DSH_CLIENT_VARIANT': '"quoted \\"value\\""', + }) + }) + + it('feeds the same build-process value to dynamic tsdown bundles and the Vite shell', async () => { + process.env[PROBE_NAME] = 'shared-value' + + const configs = clientBundle('@deepseek-ai/dsh-client-ui-sidebar', [ + 'lib/types/index.js', + 'lib/types/invariant.js', + ])({ env: { DSH_BUILD_FACE: 'client' } }) + if (!Array.isArray(configs)) throw new TypeError('client bundle config must be an array') + const dynamic = configs.find(config => config.name === '@deepseek-ai/dsh-client-ui-sidebar/client') + expect(dynamic?.define).toMatchObject({ + 'process.env': '{}', + [PROBE_KEY]: '"shared-value"', + }) + + const viteConfigPath = '../apps/web/vite.config.ts' + const viteModule: unknown = await import(viteConfigPath) + if (typeof viteModule !== 'object' || viteModule === null) { + throw new TypeError('web Vite config module must be an object') + } + const viteConfig: unknown = Reflect.get(viteModule, 'default') + if (typeof viteConfig === 'function') throw new TypeError('web Vite config must be an object') + if (typeof viteConfig !== 'object' || viteConfig === null) { + throw new TypeError('web Vite config must be an object') + } + expect(Reflect.get(viteConfig, 'define')).toMatchObject({ + 'process.env': '{}', + [PROBE_KEY]: '"shared-value"', + }) + }) + + it('sets the official client build variant in DSH artifact build workflows', () => { + for (const name of dshBuildWorkflows) { + const path = `.github/workflows/${name}` + const document: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8')) + if (typeof document !== 'object' || document === null || Array.isArray(document)) { + throw new TypeError(`${path} must contain a workflow object`) + } + const environment: unknown = Reflect.get(document, 'env') + expect(environment, path).toMatchObject({ DSH_CLIENT_BRAND: 'official' }) + } + }) +}) diff --git a/scripts/client-build-environment.ts b/scripts/client-build-environment.ts new file mode 100644 index 0000000000..037e491790 --- /dev/null +++ b/scripts/client-build-environment.ts @@ -0,0 +1,24 @@ +/** Prefix reserved for build-time values that may be embedded in browser artifacts. */ +const CLIENT_BUILD_ENV_PREFIX = 'DSH_CLIENT_' + +/** + * Create bundler substitutions for public client build environment variables. + * + * The empty `process.env` fallback makes an unset static property read + * evaluate to `undefined` without providing a browser `process` global. + * Exact substitutions remain longer matches than that fallback. Dynamic + * property reads and enumeration deliberately observe the empty object. + * + * @param environment - environment inherited by the build process. + * @returns deterministic Vite/tsdown `define` expressions. + */ +export function clientBuildEnvironmentDefines( + environment: NodeJS.ProcessEnv, +): Record { + const defines: Record = { 'process.env': '{}' } + for (const [name, value] of Object.entries(environment).sort(([left], [right]) => left.localeCompare(right))) { + if (!name.startsWith(CLIENT_BUILD_ENV_PREFIX) || value === undefined) continue + defines[`process.env.${name}`] = JSON.stringify(value) + } + return defines +} diff --git a/scripts/types/client-build-environment/index.d.ts b/scripts/types/client-build-environment/index.d.ts new file mode 100644 index 0000000000..db7b9f24fe --- /dev/null +++ b/scripts/types/client-build-environment/index.d.ts @@ -0,0 +1,7 @@ +/** Build-time values that bundlers replace before client code reaches a browser. */ +declare const process: { + readonly env: { + readonly NODE_ENV?: string + readonly [name: `DSH_CLIENT_${string}`]: string | undefined + } +} diff --git a/tsconfig.base.client.json b/tsconfig.base.client.json index a6efecbe2d..9222c69f26 100644 --- a/tsconfig.base.client.json +++ b/tsconfig.base.client.json @@ -6,6 +6,7 @@ "compilerOptions": { "jsx": "react-jsx", "lib": ["ES2024", "DOM", "DOM.Iterable"], - "types": [] + "typeRoots": ["./scripts/types", "./node_modules/@types"], + "types": ["client-build-environment"] } } diff --git a/tsconfig.client.json b/tsconfig.client.json index 17dbaff070..de5dc5acf8 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -26,6 +26,8 @@ // Client package outside packages/client with a local CSS module face. "packages/extensions/ui-cordis/src/css-modules.d.ts", "packages/client/tsdown.client.ts", + "scripts/client-build-environment.ts", + "scripts/*.client.spec.ts", "scripts/client-bundle-css.spec.ts", "scripts/client-bundle-purity.spec.ts" ], diff --git a/tsconfig.host.json b/tsconfig.host.json index bb885e7ecc..a57f9e6901 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -107,6 +107,7 @@ "packages/*/*/tests/**/*.client.spec.ts", "packages/*/*/tests/**/*.client.spec.tsx", "packages/client/tsdown.client.ts", + "scripts/*.client.spec.ts", "scripts/client-bundle-css.spec.ts", "packages/typert/generator/tests/fixtures/**", "scripts/client-bundle-purity.spec.ts"