const-enum.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. // @ts-check
  2. /**
  3. * We use rollup-plugin-esbuild for faster builds, but esbuild in isolation
  4. * mode compiles const enums into runtime enums, bloating bundle size.
  5. *
  6. * Here we pre-process all the const enums in the project and turn them into
  7. * global replacements, and remove the original declarations and re-exports.
  8. *
  9. * This erases the const enums before the esbuild transform so that we can
  10. * leverage esbuild's speed while retaining the DX and bundle size benefits
  11. * of const enums.
  12. *
  13. * This file is expected to be executed with project root as cwd.
  14. */
  15. import execa from 'execa'
  16. import {
  17. existsSync,
  18. mkdirSync,
  19. readFileSync,
  20. rmSync,
  21. writeFileSync
  22. } from 'node:fs'
  23. import { parse } from '@babel/parser'
  24. import path from 'node:path'
  25. import MagicString from 'magic-string'
  26. const ENUM_CACHE_PATH = 'temp/enum.json'
  27. function evaluate(exp) {
  28. return new Function(`return ${exp}`)()
  29. }
  30. // this is called in the build script entry once
  31. // so the data can be shared across concurrent Rollup processes
  32. export function scanEnums() {
  33. /**
  34. * @type {{ ranges: Record<string, [number, number][]>, defines: Record<string, string>, ids: string[] }}
  35. */
  36. const enumData = {
  37. ranges: {},
  38. defines: {},
  39. ids: []
  40. }
  41. // 1. grep for files with exported const enum
  42. const { stdout } = execa.sync('git', ['grep', `export const enum`])
  43. const files = [...new Set(stdout.split('\n').map(line => line.split(':')[0]))]
  44. // 2. parse matched files to collect enum info
  45. for (const relativeFile of files) {
  46. const file = path.resolve(process.cwd(), relativeFile)
  47. const content = readFileSync(file, 'utf-8')
  48. const ast = parse(content, {
  49. plugins: ['typescript'],
  50. sourceType: 'module'
  51. })
  52. for (const node of ast.program.body) {
  53. if (
  54. node.type === 'ExportNamedDeclaration' &&
  55. node.declaration &&
  56. node.declaration.type === 'TSEnumDeclaration'
  57. ) {
  58. if (file in enumData.ranges) {
  59. // @ts-ignore
  60. enumData.ranges[file].push([node.start, node.end])
  61. } else {
  62. // @ts-ignore
  63. enumData.ranges[file] = [[node.start, node.end]]
  64. }
  65. const decl = node.declaration
  66. let lastInitialized
  67. for (let i = 0; i < decl.members.length; i++) {
  68. const e = decl.members[i]
  69. const id = decl.id.name
  70. if (!enumData.ids.includes(id)) {
  71. enumData.ids.push(id)
  72. }
  73. const key = e.id.type === 'Identifier' ? e.id.name : e.id.value
  74. const fullKey = `${id}.${key}`
  75. const saveValue = value => {
  76. if (fullKey in enumData.defines) {
  77. throw new Error(`name conflict for enum ${id} in ${file}`)
  78. }
  79. enumData.defines[fullKey] = JSON.stringify(value)
  80. }
  81. const init = e.initializer
  82. if (init) {
  83. let value
  84. if (
  85. init.type === 'StringLiteral' ||
  86. init.type === 'NumericLiteral'
  87. ) {
  88. value = init.value
  89. }
  90. // e.g. 1 << 2
  91. if (init.type === 'BinaryExpression') {
  92. const resolveValue = node => {
  93. if (
  94. node.type === 'NumericLiteral' ||
  95. node.type === 'StringLiteral'
  96. ) {
  97. return node.value
  98. } else if (node.type === 'MemberExpression') {
  99. const exp = content.slice(node.start, node.end)
  100. if (!(exp in enumData.defines)) {
  101. throw new Error(
  102. `unhandled enum initialization expression ${exp} in ${file}`
  103. )
  104. }
  105. return enumData.defines[exp]
  106. } else {
  107. throw new Error(
  108. `unhandled BinaryExpression operand type ${node.type} in ${file}`
  109. )
  110. }
  111. }
  112. const exp = `${resolveValue(init.left)}${
  113. init.operator
  114. }${resolveValue(init.right)}`
  115. value = evaluate(exp)
  116. }
  117. if (init.type === 'UnaryExpression') {
  118. if (
  119. init.argument.type === 'StringLiteral' ||
  120. init.argument.type === 'NumericLiteral'
  121. ) {
  122. const exp = `${init.operator}${init.argument.value}`
  123. value = evaluate(exp)
  124. } else {
  125. throw new Error(
  126. `unhandled UnaryExpression argument type ${init.argument.type} in ${file}`
  127. )
  128. }
  129. }
  130. if (value === undefined) {
  131. throw new Error(
  132. `unhandled initializer type ${init.type} for ${fullKey} in ${file}`
  133. )
  134. }
  135. saveValue(value)
  136. lastInitialized = value
  137. } else {
  138. if (lastInitialized === undefined) {
  139. // first initialized
  140. saveValue((lastInitialized = 0))
  141. } else if (typeof lastInitialized === 'number') {
  142. saveValue(++lastInitialized)
  143. } else {
  144. // should not happen
  145. throw new Error(`wrong enum initialization sequence in ${file}`)
  146. }
  147. }
  148. }
  149. }
  150. }
  151. }
  152. // 3. save cache
  153. if (!existsSync('temp')) mkdirSync('temp')
  154. writeFileSync(ENUM_CACHE_PATH, JSON.stringify(enumData))
  155. return () => {
  156. rmSync(ENUM_CACHE_PATH, { force: true })
  157. }
  158. }
  159. /**
  160. * @returns {[import('rollup').Plugin, Record<string, string>]}
  161. */
  162. export function constEnum() {
  163. if (!existsSync(ENUM_CACHE_PATH)) {
  164. throw new Error('enum cache needs to be initialized before creating plugin')
  165. }
  166. /**
  167. * @type {{ ranges: Record<string, [number, number][]>, defines: Record<string, string>, ids: string[] }}
  168. */
  169. const enumData = JSON.parse(readFileSync(ENUM_CACHE_PATH, 'utf-8'))
  170. // construct a regex for matching re-exports of known const enums
  171. const reExportsRE = new RegExp(
  172. `export {[^}]*?\\b(${enumData.ids.join('|')})\\b[^]*?}`
  173. )
  174. // 3. during transform:
  175. // 3.1 files w/ const enum declaration: remove declaration
  176. // 3.2 files using const enum: inject into esbuild define
  177. /**
  178. * @type {import('rollup').Plugin}
  179. */
  180. const plugin = {
  181. name: 'remove-const-enum',
  182. transform(code, id) {
  183. let s
  184. if (id in enumData.ranges) {
  185. s = s || new MagicString(code)
  186. for (const [start, end] of enumData.ranges[id]) {
  187. s.remove(start, end)
  188. }
  189. }
  190. // check for const enum re-exports that must be removed
  191. if (reExportsRE.test(code)) {
  192. s = s || new MagicString(code)
  193. const ast = parse(code, {
  194. plugins: ['typescript'],
  195. sourceType: 'module'
  196. })
  197. for (const node of ast.program.body) {
  198. if (
  199. node.type === 'ExportNamedDeclaration' &&
  200. node.exportKind !== 'type' &&
  201. node.source
  202. ) {
  203. for (let i = 0; i < node.specifiers.length; i++) {
  204. const spec = node.specifiers[i]
  205. if (
  206. spec.type === 'ExportSpecifier' &&
  207. spec.exportKind !== 'type' &&
  208. enumData.ids.includes(spec.local.name)
  209. ) {
  210. const next = node.specifiers[i + 1]
  211. if (next) {
  212. // @ts-ignore
  213. s.remove(spec.start, next.start)
  214. } else {
  215. // last one
  216. const prev = node.specifiers[i - 1]
  217. // @ts-ignore
  218. s.remove(prev ? prev.end : spec.start, spec.end)
  219. }
  220. }
  221. }
  222. }
  223. }
  224. }
  225. if (s) {
  226. return {
  227. code: s.toString(),
  228. map: s.generateMap()
  229. }
  230. }
  231. }
  232. }
  233. return [plugin, enumData.defines]
  234. }