deploy-cloudflare-pages.mjs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { spawnSync } from 'node:child_process'
  2. import { brotliCompressSync, constants as zlibConstants } from 'node:zlib'
  3. import {
  4. cpSync,
  5. existsSync,
  6. mkdirSync,
  7. readdirSync,
  8. readFileSync,
  9. rmSync,
  10. statSync,
  11. writeFileSync
  12. } from 'node:fs'
  13. import { basename, extname, join, relative, resolve } from 'node:path'
  14. const projectName = process.env.CLOUDFLARE_PAGES_PROJECT || 'flyfish-file-viewer'
  15. // Wrangler direct upload publishes to the production deployment when no branch
  16. // is supplied. Keep branch deployments explicit so custom domains are updated
  17. // by the default release commands.
  18. const branch = process.env.CLOUDFLARE_PAGES_BRANCH
  19. const outputDir = process.env.CLOUDFLARE_PAGES_OUTPUT_DIR || 'apps/viewer-demo/dist'
  20. const dryRun = process.env.CLOUDFLARE_PAGES_DRY_RUN === '1' || process.argv.includes('--dry-run')
  21. function commandExists(command, args = ['--version']) {
  22. const result = spawnSync(command, args, {
  23. encoding: 'utf8',
  24. shell: false,
  25. stdio: 'pipe'
  26. })
  27. return result.status === 0
  28. }
  29. const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
  30. const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'
  31. const usePnpmDlx = commandExists(pnpmCommand)
  32. const command = usePnpmDlx ? pnpmCommand : npxCommand
  33. const maxFileBytes = Number.parseInt(
  34. process.env.CLOUDFLARE_PAGES_MAX_FILE_BYTES || String(25 * 1024 * 1024),
  35. 10
  36. )
  37. const resolvedOutputDir = resolve(outputDir)
  38. const uploadDir = resolve('.release', 'cloudflare-pages', projectName)
  39. const skippedFiles = []
  40. const compressedFiles = []
  41. const toUrlPath = filePath => `/${filePath.replaceAll('\\', '/')}`
  42. function writeCompressedAssetHeaders(targetDir) {
  43. if (!compressedFiles.length) {
  44. return
  45. }
  46. const headersPath = join(targetDir, '_headers')
  47. const current = existsSync(headersPath) ? readFileSync(headersPath, 'utf8') : ''
  48. const prefix = current
  49. ? `${current}${current.endsWith('\n') ? '' : '\n'}\n`
  50. : ''
  51. const additions = compressedFiles
  52. .map(file => [
  53. toUrlPath(file.path),
  54. ' ! Content-Type',
  55. ' ! Cache-Control',
  56. ' ! X-Content-Type-Options',
  57. ' Content-Type: application/wasm',
  58. ' Content-Encoding: br',
  59. ' Vary: Accept-Encoding',
  60. ' X-Content-Type-Options: nosniff',
  61. ' Cache-Control: public, max-age=31536000, immutable'
  62. ].join('\n'))
  63. .join('\n\n')
  64. writeFileSync(headersPath, `${prefix}${additions}\n`)
  65. }
  66. function writeBrotliCompressedWasm(source, target, relativePath) {
  67. const raw = readFileSync(source)
  68. const compressed = brotliCompressSync(raw, {
  69. params: {
  70. [zlibConstants.BROTLI_PARAM_QUALITY]: 11
  71. }
  72. })
  73. if (compressed.length > maxFileBytes) {
  74. return false
  75. }
  76. mkdirSync(join(target, '..'), { recursive: true })
  77. writeFileSync(target, compressed)
  78. compressedFiles.push({
  79. path: relativePath,
  80. size: raw.length,
  81. compressedSize: compressed.length
  82. })
  83. return true
  84. }
  85. function copyDeployableFiles(sourceDir, targetDir) {
  86. if (!existsSync(sourceDir)) {
  87. throw new Error(`Cloudflare Pages output directory does not exist: ${sourceDir}`)
  88. }
  89. rmSync(targetDir, { force: true, recursive: true })
  90. mkdirSync(targetDir, { recursive: true })
  91. const visit = (source, target) => {
  92. const stat = statSync(source)
  93. if (stat.isDirectory()) {
  94. mkdirSync(target, { recursive: true })
  95. for (const entry of readdirSync(source)) {
  96. visit(join(source, entry), join(target, entry))
  97. }
  98. return
  99. }
  100. if (!stat.isFile()) {
  101. return
  102. }
  103. if (stat.size > maxFileBytes) {
  104. const relativePath = relative(sourceDir, source)
  105. if (extname(source).toLowerCase() === '.wasm' &&
  106. writeBrotliCompressedWasm(source, target, relativePath)) {
  107. return
  108. }
  109. skippedFiles.push({
  110. path: relativePath,
  111. size: stat.size
  112. })
  113. return
  114. }
  115. mkdirSync(join(target, '..'), { recursive: true })
  116. cpSync(source, target)
  117. }
  118. visit(sourceDir, targetDir)
  119. }
  120. copyDeployableFiles(resolvedOutputDir, uploadDir)
  121. writeCompressedAssetHeaders(uploadDir)
  122. if (compressedFiles.length) {
  123. console.warn(
  124. `[cloudflare-pages] Brotli-compressed ${compressedFiles.length} oversized WASM file(s) while preparing ${basename(uploadDir)}:`
  125. )
  126. for (const file of compressedFiles) {
  127. console.warn(` - ${file.path} (${file.size} bytes -> ${file.compressedSize} bytes)`)
  128. }
  129. }
  130. if (skippedFiles.length) {
  131. console.warn(
  132. `[cloudflare-pages] Skipped ${skippedFiles.length} oversized file(s) above ${maxFileBytes} bytes while preparing ${basename(uploadDir)}:`
  133. )
  134. for (const file of skippedFiles) {
  135. console.warn(` - ${file.path} (${file.size} bytes)`)
  136. }
  137. const fatalWasmFiles = skippedFiles.filter(file => file.path.toLowerCase().endsWith('.wasm'))
  138. if (fatalWasmFiles.length) {
  139. throw new Error(
  140. `Cloudflare Pages upload would miss required WASM assets: ${fatalWasmFiles.map(file => file.path).join(', ')}`
  141. )
  142. }
  143. }
  144. if (dryRun) {
  145. console.log(`[cloudflare-pages] Dry run prepared ${uploadDir}`)
  146. process.exit(0)
  147. }
  148. const args = [
  149. ...(usePnpmDlx ? ['dlx'] : ['--yes']),
  150. 'wrangler',
  151. 'pages',
  152. 'deploy',
  153. uploadDir,
  154. '--project-name',
  155. projectName,
  156. '--commit-dirty=true'
  157. ]
  158. if (branch) {
  159. args.push('--branch', branch)
  160. }
  161. const result = spawnSync(command, args, {
  162. stdio: 'inherit',
  163. shell: false
  164. })
  165. if (result.error) {
  166. throw result.error
  167. }
  168. process.exit(result.status ?? 1)