client-build-environment.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import { createHash } from 'node:crypto'
  2. import { execFileSync } from 'node:child_process'
  3. import {
  4. existsSync,
  5. globSync,
  6. mkdirSync,
  7. readFileSync,
  8. statSync,
  9. writeFileSync,
  10. } from 'node:fs'
  11. import { dirname, resolve } from 'node:path'
  12. /** Prefix reserved for build-time values that may be embedded in browser artifacts. */
  13. const CLIENT_BUILD_ENV_PREFIX = 'DSH_CLIENT_'
  14. /** Non-public selector used by build orchestration to request a named client profile. */
  15. export const CLIENT_BUILD_PROFILE_SELECTOR = 'DSH_BUILD_CLIENT_PROFILE'
  16. /** Public client environment required by official DSH artifacts. */
  17. const OFFICIAL_CLIENT_BUILD_ENVIRONMENT = {
  18. DSH_CLIENT_BUILD_PROFILE: 'official',
  19. DSH_CLIENT_TITLE: 'DeepSeek Harness',
  20. } as const
  21. /** Public variable carrying the source commit embedded in client artifacts. */
  22. const CLIENT_COMMIT_HASH_VARIABLE = 'DSH_CLIENT_COMMIT_HASH'
  23. /** Repository-relative path of the complete client build record. */
  24. export const CLIENT_BUILD_RECORD_PATH = '.dsh-build/client-build-environment.json'
  25. const CLIENT_BUILD_RECORD_FORMAT = 1
  26. const CLIENT_ARTIFACT_PATTERNS = [
  27. 'apps/web/dist/**/*',
  28. 'packages/*/*/lib/client.js',
  29. 'packages/*/*/lib/client.js.map',
  30. ] as const
  31. /** Public values embedded in one set of client artifacts. */
  32. export type ClientBuildEnvironment = Readonly<Record<string, string>>
  33. /**
  34. * Resolve the short source commit used by browser build metadata.
  35. * @param root - repository root used when no explicit value is supplied.
  36. * @param environment - environment that may already carry a commit value.
  37. * @returns lowercase 7-character Git commit prefix.
  38. */
  39. export function repositoryCommitHash(root: string, environment: NodeJS.ProcessEnv = process.env): string {
  40. const explicit = environment[CLIENT_COMMIT_HASH_VARIABLE]
  41. const value = explicit ?? execFileSync('git', ['rev-parse', 'HEAD'], {
  42. cwd: root,
  43. encoding: 'utf8',
  44. stdio: ['ignore', 'pipe', 'ignore'],
  45. }).trim()
  46. if (!/^[0-9a-f]{7,40}$/iu.test(value)) {
  47. throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} must be a Git commit hash; got ${JSON.stringify(value)}`)
  48. }
  49. return value.slice(0, 7).toLowerCase()
  50. }
  51. /**
  52. * Resolve the exact public values required by an official build at one commit.
  53. * @param root - repository root whose HEAD must match the built source.
  54. * @param environment - optional explicit commit source for non-Git build environments.
  55. * @returns complete official client environment.
  56. */
  57. export function officialClientBuildEnvironment(
  58. root: string,
  59. environment: NodeJS.ProcessEnv = process.env,
  60. ): Readonly<Record<`DSH_CLIENT_${string}`, string>> {
  61. return {
  62. DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment),
  63. ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT,
  64. }
  65. }
  66. /** Digest of every client artifact produced by the complete root build. */
  67. interface ClientArtifactDigest {
  68. /** Number of files covered by the digest. */
  69. readonly fileCount: number
  70. /** Lowercase SHA-256 digest of sorted paths and file contents. */
  71. readonly sha256: string
  72. }
  73. /** Durable description of one complete root client build. */
  74. export interface ClientBuildRecord {
  75. /** Record schema version. */
  76. readonly formatVersion: number
  77. /** Exact public environment embedded by Vite and tsdown. */
  78. readonly environment: ClientBuildEnvironment
  79. /** Digest that binds the environment to the current artifacts. */
  80. readonly artifacts: ClientArtifactDigest
  81. }
  82. /**
  83. * Collect the public client environment in deterministic key order.
  84. * @param environment - environment inherited by the build process.
  85. * @returns defined `DSH_CLIENT_*` values only.
  86. */
  87. function clientBuildEnvironment(environment: NodeJS.ProcessEnv): ClientBuildEnvironment {
  88. return Object.fromEntries(Object.entries(environment)
  89. .filter(([name, value]) => name.startsWith(CLIENT_BUILD_ENV_PREFIX) && value !== undefined)
  90. .sort(([left], [right]) => left.localeCompare(right))) as Record<string, string>
  91. }
  92. /**
  93. * Resolve the exact public environment selected for a complete client build.
  94. * @param environment - parent process environment.
  95. * @param profile - explicit profile, or the non-public selector when omitted.
  96. * @returns the inherited public values when no profile is selected, otherwise the named profile.
  97. */
  98. export function resolveClientBuildEnvironment(
  99. environment: NodeJS.ProcessEnv,
  100. profile: string | undefined = environment[CLIENT_BUILD_PROFILE_SELECTOR],
  101. ): ClientBuildEnvironment {
  102. if (profile === undefined) return clientBuildEnvironment(environment)
  103. if (profile === 'official') {
  104. const commitHash = environment[CLIENT_COMMIT_HASH_VARIABLE]
  105. if (commitHash === undefined) {
  106. throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} is required for the official client build profile`)
  107. }
  108. return { DSH_CLIENT_COMMIT_HASH: commitHash, ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT }
  109. }
  110. throw new Error(`unknown client build profile ${JSON.stringify(profile)}; expected "official"`)
  111. }
  112. /**
  113. * Construct a subprocess environment containing exactly the selected public values.
  114. * @param environment - parent process environment.
  115. * @param clientEnvironment - complete public environment selected for the build.
  116. * @returns the parent environment with selectors and inherited public values replaced.
  117. */
  118. export function clientBuildProcessEnvironment(
  119. environment: NodeJS.ProcessEnv,
  120. clientEnvironment: ClientBuildEnvironment,
  121. ): NodeJS.ProcessEnv {
  122. const child: NodeJS.ProcessEnv = {}
  123. for (const [name, value] of Object.entries(environment)) {
  124. if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith(CLIENT_BUILD_ENV_PREFIX)) continue
  125. child[name] = value
  126. }
  127. return { ...child, ...clientEnvironment }
  128. }
  129. /**
  130. * Require the public client environment to match an artifact profile exactly.
  131. *
  132. * An exact key set matters because every prefixed value is eligible for
  133. * inlining: an unexpected variable can change published bytes just as surely
  134. * as a missing or incorrect required value.
  135. *
  136. * @param environment - public environment from a build process or build record.
  137. * @param expected - complete public client environment for the artifact profile.
  138. */
  139. export function assertClientBuildEnvironment(
  140. environment: Readonly<Record<string, string | undefined>>,
  141. expected: Readonly<Record<`DSH_CLIENT_${string}`, string>>,
  142. ): void {
  143. const actual = Object.fromEntries(Object.entries(environment)
  144. .filter(([name, value]) => name.startsWith(CLIENT_BUILD_ENV_PREFIX) && value !== undefined)
  145. .sort(([left], [right]) => left.localeCompare(right)))
  146. const normalizedExpected = Object.fromEntries(Object.entries(expected)
  147. .sort(([left], [right]) => left.localeCompare(right)))
  148. if (JSON.stringify(actual) === JSON.stringify(normalizedExpected)) return
  149. const names = [...new Set([...Object.keys(actual), ...Object.keys(normalizedExpected)])].sort()
  150. const differences = names.filter(name => actual[name] !== normalizedExpected[name])
  151. throw new Error(`client build environment differs from the required artifact profile: ${differences.join(', ')}`)
  152. }
  153. /**
  154. * Create bundler substitutions for public client build environment variables.
  155. *
  156. * The empty `process.env` fallback makes an unset static property read
  157. * evaluate to `undefined` without providing a browser `process` global.
  158. * Exact substitutions remain longer matches than that fallback. Dynamic
  159. * property reads and enumeration deliberately observe the empty object.
  160. *
  161. * @param environment - environment inherited by the build process.
  162. * @returns deterministic Vite/tsdown `define` expressions.
  163. */
  164. export function clientBuildEnvironmentDefines(
  165. environment: NodeJS.ProcessEnv,
  166. ): Record<string, string> {
  167. const defines: Record<string, string> = { 'process.env': '{}' }
  168. for (const [name, value] of Object.entries(clientBuildEnvironment(environment))) {
  169. defines[`process.env.${name}`] = JSON.stringify(value)
  170. }
  171. return defines
  172. }
  173. /**
  174. * Write the build record after a complete root build succeeds.
  175. * @param root - repository root containing the generated artifacts.
  176. * @param environment - exact public environment supplied to both bundlers.
  177. * @returns the record written to disk.
  178. */
  179. export function writeClientBuildRecord(
  180. root: string,
  181. environment: ClientBuildEnvironment,
  182. ): ClientBuildRecord {
  183. const record: ClientBuildRecord = {
  184. formatVersion: CLIENT_BUILD_RECORD_FORMAT,
  185. environment: clientBuildEnvironment(environment),
  186. artifacts: clientArtifactDigest(root),
  187. }
  188. const path = resolve(root, CLIENT_BUILD_RECORD_PATH)
  189. mkdirSync(dirname(path), { recursive: true })
  190. writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`)
  191. return record
  192. }
  193. /**
  194. * Read a complete build record and prove it still describes the current artifacts.
  195. * @param root - repository root containing the record and generated artifacts.
  196. * @param expected - optional exact public environment required by a consumer.
  197. * @returns the parsed and artifact-verified record.
  198. */
  199. export function readClientBuildRecord(
  200. root: string,
  201. expected?: Readonly<Record<`DSH_CLIENT_${string}`, string>>,
  202. ): ClientBuildRecord {
  203. const path = resolve(root, CLIENT_BUILD_RECORD_PATH)
  204. if (!existsSync(path)) {
  205. throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} is missing; run a complete pnpm run build first`)
  206. }
  207. let parsed: unknown
  208. try {
  209. parsed = JSON.parse(readFileSync(path, 'utf8'))
  210. } catch (error) {
  211. const detail = error instanceof Error ? error.message : String(error)
  212. throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} is invalid JSON: ${detail}`)
  213. }
  214. const record = parseClientBuildRecord(parsed)
  215. if (expected !== undefined) assertClientBuildEnvironment(record.environment, expected)
  216. const current = clientArtifactDigest(root)
  217. if (current.fileCount !== record.artifacts.fileCount || current.sha256 !== record.artifacts.sha256) {
  218. throw new Error(
  219. `client artifacts differ from ${CLIENT_BUILD_RECORD_PATH}; run a complete pnpm run build before consuming them`,
  220. )
  221. }
  222. return record
  223. }
  224. /** Return the deterministic digest of every artifact affected by the public client environment. */
  225. function clientArtifactDigest(root: string): ClientArtifactDigest {
  226. const paths = globSync([...CLIENT_ARTIFACT_PATTERNS], { cwd: root })
  227. .map(path => path.replaceAll('\\', '/'))
  228. .filter(path => statSync(resolve(root, path)).isFile())
  229. .sort()
  230. if (paths.length === 0) throw new Error('complete client build produced no Vite or dynamic client artifacts')
  231. const digest = createHash('sha256')
  232. for (const path of paths) {
  233. const content = readFileSync(resolve(root, path))
  234. digest.update(`${Buffer.byteLength(path)}:`)
  235. digest.update(path)
  236. digest.update(`${content.byteLength}:`)
  237. digest.update(content)
  238. }
  239. return { fileCount: paths.length, sha256: digest.digest('hex') }
  240. }
  241. /** Parse and validate the persisted record before any consumer trusts it. */
  242. function parseClientBuildRecord(value: unknown): ClientBuildRecord {
  243. if (!isObject(value) || !hasExactKeys(value, ['artifacts', 'environment', 'formatVersion'])) {
  244. throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid top-level schema`)
  245. }
  246. if (value.formatVersion !== CLIENT_BUILD_RECORD_FORMAT) {
  247. throw new Error(
  248. `client build record ${CLIENT_BUILD_RECORD_PATH} uses format ${String(value.formatVersion)}; expected ${String(CLIENT_BUILD_RECORD_FORMAT)}`,
  249. )
  250. }
  251. if (!isObject(value.environment)) {
  252. throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid environment`)
  253. }
  254. const environment: Record<string, string> = {}
  255. for (const [name, entry] of Object.entries(value.environment).sort(([left], [right]) => left.localeCompare(right))) {
  256. if (!name.startsWith(CLIENT_BUILD_ENV_PREFIX) || typeof entry !== 'string') {
  257. throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid environment entry ${name}`)
  258. }
  259. environment[name] = entry
  260. }
  261. if (!isObject(value.artifacts) || !hasExactKeys(value.artifacts, ['fileCount', 'sha256'])) {
  262. throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid artifact digest`)
  263. }
  264. if (!Number.isSafeInteger(value.artifacts.fileCount) || Number(value.artifacts.fileCount) < 1) {
  265. throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid artifact count`)
  266. }
  267. if (typeof value.artifacts.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.artifacts.sha256)) {
  268. throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid SHA-256 digest`)
  269. }
  270. return {
  271. formatVersion: CLIENT_BUILD_RECORD_FORMAT,
  272. environment,
  273. artifacts: {
  274. fileCount: Number(value.artifacts.fileCount),
  275. sha256: value.artifacts.sha256,
  276. },
  277. }
  278. }
  279. function isObject(value: unknown): value is Record<string, unknown> {
  280. return typeof value === 'object' && value !== null && !Array.isArray(value)
  281. }
  282. function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
  283. const actual = Object.keys(value).sort()
  284. return actual.length === expected.length && actual.every((key, index) => key === expected[index])
  285. }