fetch-pyodide-packages.mjs 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. /* eslint-disable @overleaf/require-script-runner */
  2. // This script doesn't work with ScriptRunner because it is run during the build process.
  3. import { createReadStream, createWriteStream } from 'node:fs'
  4. import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
  5. import { Readable } from 'node:stream'
  6. import { pipeline } from 'node:stream/promises'
  7. import { execFile } from 'node:child_process'
  8. import { createHash } from 'node:crypto'
  9. import { promisify } from 'node:util'
  10. import path from 'node:path'
  11. import { fileURLToPath } from 'node:url'
  12. const execFileAsync = promisify(execFile)
  13. const SERVICE_WEB_DIR = path.resolve(fileURLToPath(import.meta.url), '../..')
  14. // Pinned pyodide release tarball. Keep PYODIDE_VERSION in sync with the
  15. // "pyodide" entry in services/web/package.json. When bumping, update both
  16. // PYODIDE_VERSION and EXPECTED_SHA256 together; fetch the hash via:
  17. // curl -sL https://api.github.com/repos/pyodide/pyodide/releases/tags/<ver> \
  18. // | jq -r '.assets[] | select(.name=="pyodide-<ver>.tar.bz2") | .digest'
  19. // (strip the "sha256:" prefix). Cross-check by downloading the tarball and
  20. // running `shasum -a 256 pyodide-<ver>.tar.bz2`.
  21. const PYODIDE_VERSION = '0.29.3'
  22. const EXPECTED_SHA256 =
  23. '458e8ddbcbb6e21037d3237cd5c5146c451765bc738dfa2249ff34c5140331e4'
  24. const TARGET_DIR = path.join(
  25. SERVICE_WEB_DIR,
  26. 'public/js/libs/pyodide',
  27. PYODIDE_VERSION
  28. )
  29. const TARBALL_NAME = `pyodide-${PYODIDE_VERSION}.tar.bz2`
  30. const RELEASE_URL = `https://github.com/pyodide/pyodide/releases/download/${PYODIDE_VERSION}/${TARBALL_NAME}`
  31. const COMPLETE_MARKER = path.join(TARGET_DIR, '.fetch-complete')
  32. async function download(url, dest) {
  33. console.log(`Downloading ${url}`)
  34. const res = await fetch(url, { redirect: 'follow' })
  35. if (!res.ok) {
  36. throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`)
  37. }
  38. await pipeline(Readable.fromWeb(res.body), createWriteStream(dest))
  39. }
  40. async function sha256(file) {
  41. const hash = createHash('sha256')
  42. await pipeline(createReadStream(file), hash)
  43. return hash.digest('hex')
  44. }
  45. // The version subdir only needs what pyodide fetches via packageBaseUrl
  46. // (wheels, their .metadata sidecars, and lib*.zip shared libraries). Skip
  47. // everything else:
  48. // - core runtime (pyodide.mjs / asm / stdlib / lock) lives one level up,
  49. // copied from the npm package by webpack CopyPlugin.
  50. // - *-tests.tar / test-*.zip: per-package test fixtures and pyodide's own
  51. // test packages, not used at runtime.
  52. // - console*.html, python / python.exe / python.bat / python_cli_entry.mjs,
  53. // README.md: REPL UI, CLI shims, and docs.
  54. const TAR_EXCLUDES = [
  55. 'pyodide.mjs',
  56. 'pyodide.asm.js',
  57. 'pyodide.asm.wasm',
  58. 'python_stdlib.zip',
  59. 'pyodide-lock.json',
  60. '*-tests.tar',
  61. 'test-*.zip',
  62. 'console*.html',
  63. 'python',
  64. 'python.exe',
  65. 'python.bat',
  66. 'python_cli_entry.mjs',
  67. 'README.md',
  68. ]
  69. async function extract(tarball, targetDir) {
  70. console.log(`Extracting ${path.basename(tarball)}`)
  71. // Tarball contains a top-level pyodide/ folder; strip it so contents land
  72. // directly in targetDir.
  73. await execFileAsync('tar', [
  74. '-xjf',
  75. tarball,
  76. '-C',
  77. targetDir,
  78. '--strip-components=1',
  79. ...TAR_EXCLUDES.map(p => `--exclude=${p}`),
  80. ])
  81. }
  82. async function main() {
  83. try {
  84. await stat(COMPLETE_MARKER)
  85. console.log(`Pyodide ${PYODIDE_VERSION} already present at ${TARGET_DIR}`)
  86. return
  87. } catch (err) {
  88. if (err.code !== 'ENOENT') throw err
  89. }
  90. // A prior run may have left a partial install without the marker; wipe it
  91. // so extraction starts from a clean directory.
  92. await rm(TARGET_DIR, { recursive: true, force: true })
  93. await mkdir(TARGET_DIR, { recursive: true })
  94. const tarballPath = path.join(TARGET_DIR, TARBALL_NAME)
  95. try {
  96. await download(RELEASE_URL, tarballPath)
  97. const actual = await sha256(tarballPath)
  98. if (actual !== EXPECTED_SHA256) {
  99. throw new Error(
  100. `SHA-256 mismatch for ${TARBALL_NAME}: expected ${EXPECTED_SHA256}, got ${actual}`
  101. )
  102. }
  103. await extract(tarballPath, TARGET_DIR)
  104. await rm(tarballPath, { force: true })
  105. const extracted = await readdir(TARGET_DIR)
  106. if (!extracted.some(name => name.endsWith('.whl'))) {
  107. throw new Error(
  108. `Extraction did not produce any wheels under ${TARGET_DIR}`
  109. )
  110. }
  111. await writeFile(COMPLETE_MARKER, '')
  112. } catch (err) {
  113. // Leave no partial install behind, so the next run starts clean.
  114. await rm(TARGET_DIR, { recursive: true, force: true })
  115. throw err
  116. }
  117. console.log(`Pyodide ${PYODIDE_VERSION} ready at ${TARGET_DIR}`)
  118. }
  119. main().catch(err => {
  120. console.error(err)
  121. process.exit(1)
  122. })