├── .env ├── .github ├── dependabot.yml └── workflows │ └── deploy-pages.yml ├── .gitignore ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── bin └── build.mjs ├── config.ru ├── dist ├── callback.html ├── index.css └── index.html ├── package.json ├── service └── run.rb └── src ├── index.ts ├── ruby-install.ts ├── ruby.worker.ts ├── split-file.test.ts ├── split-file.ts └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | GITHUB_CLIENT_ID_LOCAL=op://Team Shared/play-ruby-secrets/GITHUB_CLIENT_ID_LOCAL 2 | GITHUB_CLIENT_SECRET_LOCAL=op://Team Shared/play-ruby-secrets/GITHUB_CLIENT_SECRET_LOCAL 3 | GITHUB_CLIENT_ID=op://Team Shared/play-ruby-secrets/GITHUB_CLIENT_ID 4 | GITHUB_CLIENT_SECRET=op://Team Shared/play-ruby-secrets/GITHUB_CLIENT_SECRET 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | groups: 6 | dependencies: 7 | patterns: ["*"] 8 | schedule: 9 | interval: monthly 10 | 11 | - package-ecosystem: npm 12 | directory: / 13 | groups: 14 | dependencies: 15 | patterns: ["*"] 16 | schedule: 17 | interval: monthly 18 | -------------------------------------------------------------------------------- /.github/workflows/deploy-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: '20' 19 | 20 | - run: npm ci 21 | - run: npm run build 22 | - uses: actions/upload-pages-artifact@v3 23 | with: 24 | path: dist 25 | 26 | deploy: 27 | runs-on: ubuntu-latest 28 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 29 | needs: build 30 | permissions: 31 | pages: write 32 | id-token: write 33 | 34 | environment: 35 | name: github-pages 36 | url: ${{ steps.deployment.outputs.page_url }} 37 | steps: 38 | - uses: actions/deploy-pages@v4 39 | id: deployment 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/build 2 | /node_modules/ 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | ruby "3.3.0" 6 | 7 | gem "sinatra" 8 | 9 | gem "rackup", "~> 2.1" 10 | 11 | gem "sinatra-contrib", "~> 4.0" 12 | gem "rack", "~> 3.1" 13 | # Use pre-released version for "assume_ssl" option to make "secure" option work in development: 14 | # https://github.com/rack/rack-session/commit/219d8da15b0d1a02c650f956df29db42408a6adb 15 | gem "rack-session", github: "rack/rack-session", ref: "219d8da15b0d1a02c650f956df29db42408a6adb" 16 | 17 | gem "octokit", "~> 8.1" 18 | 19 | gem "debug", "~> 1.9" 20 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/rack/rack-session.git 3 | revision: 219d8da15b0d1a02c650f956df29db42408a6adb 4 | ref: 219d8da15b0d1a02c650f956df29db42408a6adb 5 | specs: 6 | rack-session (2.0.0) 7 | base64 (>= 0.1.0) 8 | rack (>= 3.0.0) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | addressable (2.8.6) 14 | public_suffix (>= 2.0.2, < 6.0) 15 | base64 (0.2.0) 16 | debug (1.9.2) 17 | irb (~> 1.10) 18 | reline (>= 0.3.8) 19 | faraday (2.9.0) 20 | faraday-net_http (>= 2.0, < 3.2) 21 | faraday-net_http (3.1.0) 22 | net-http 23 | io-console (0.7.2) 24 | irb (1.12.0) 25 | rdoc 26 | reline (>= 0.4.2) 27 | logger (1.6.1) 28 | multi_json (1.15.0) 29 | mustermann (3.0.3) 30 | ruby2_keywords (~> 0.0.1) 31 | net-http (0.4.1) 32 | uri 33 | octokit (8.1.0) 34 | base64 35 | faraday (>= 1, < 3) 36 | sawyer (~> 0.9) 37 | psych (5.1.2) 38 | stringio 39 | public_suffix (5.0.4) 40 | rack (3.1.10) 41 | rack-protection (4.1.0) 42 | base64 (>= 0.1.0) 43 | logger (>= 1.6.0) 44 | rack (>= 3.0.0, < 4) 45 | rackup (2.1.0) 46 | rack (>= 3) 47 | webrick (~> 1.8) 48 | rdoc (6.6.3.1) 49 | psych (>= 4.0.0) 50 | reline (0.5.0) 51 | io-console (~> 0.5) 52 | ruby2_keywords (0.0.5) 53 | sawyer (0.9.2) 54 | addressable (>= 2.3.5) 55 | faraday (>= 0.17.3, < 3) 56 | sinatra (4.1.0) 57 | logger (>= 1.6.0) 58 | mustermann (~> 3.0) 59 | rack (>= 3.0.0, < 4) 60 | rack-protection (= 4.1.0) 61 | rack-session (>= 2.0.0, < 3) 62 | tilt (~> 2.0) 63 | sinatra-contrib (4.1.0) 64 | multi_json (>= 0.0.2) 65 | mustermann (~> 3.0) 66 | rack-protection (= 4.1.0) 67 | sinatra (= 4.1.0) 68 | tilt (~> 2.0) 69 | stringio (3.1.0) 70 | tilt (2.4.0) 71 | uri (0.13.0) 72 | webrick (1.8.2) 73 | 74 | PLATFORMS 75 | ruby 76 | x86_64-linux 77 | 78 | DEPENDENCIES 79 | debug (~> 1.9) 80 | octokit (~> 8.1) 81 | rack (~> 3.1) 82 | rack-session! 83 | rackup (~> 2.1) 84 | sinatra 85 | sinatra-contrib (~> 4.0) 86 | 87 | RUBY VERSION 88 | ruby 3.3.0p0 89 | 90 | BUNDLED WITH 91 | 2.5.3 92 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Yuta Saito 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ruby Playground Website 2 | 3 | > [!IMPORTANT] 4 | > This project is very early in development. Feedback and contributions are welcome :) 5 | 6 | This is the source code for the Ruby Playground website. 7 | 8 | ## Development 9 | 10 | | Command | Description | 11 | | --- | --- | 12 | | `npm run build` | Build the website | 13 | | `npm run serve` | Build and serve the website locally | 14 | 15 | ## Query Parameters 16 | 17 | | URL | Description | 18 | | --- | --- | 19 | | https://ruby.github.io/play-ruby | The latest version of Ruby | 20 | | https://ruby.github.io/play-ruby/?pr=123 | Build of a GitHub Pull Request | 21 | | https://ruby.github.io/play-ruby/?run=123 | Build of a GitHub Actions run | 22 | 23 | ## Deployment 24 | 25 | The website is deployed to GitHub Pages using GitHub Actions. 26 | -------------------------------------------------------------------------------- /bin/build.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import { polyfillNode } from "esbuild-plugin-polyfill-node"; 3 | import { spawn } from "node:child_process" 4 | import fs from "node:fs" 5 | import https from "node:https" 6 | 7 | const SERVER_DEVELOPMENT_PORT = 8090 8 | const FRONTEND_DEVELOPMENT_PORT = 8091 9 | function makeBuildOptions(config) { 10 | return { 11 | entryPoints: [ 12 | `src/index.ts`, "src/ruby.worker.ts", 13 | "./node_modules/monaco-editor/esm/vs/editor/editor.worker.js", 14 | "./node_modules/monaco-editor/esm/vs/language/json/json.worker.js", 15 | ], 16 | bundle: true, 17 | format: "esm", 18 | outdir: "./dist/build", 19 | splitting: true, 20 | sourcemap: true, 21 | logLevel: "info", 22 | loader: { 23 | '.ttf': 'file' 24 | }, 25 | define: Object.fromEntries(Object.entries(config).map(([key, value]) => [key, JSON.stringify(value)])), 26 | plugins: [ 27 | polyfillNode(), 28 | ] 29 | } 30 | } 31 | 32 | async function downloadBuiltinRuby(version, rubyVersion) { 33 | const tarball = `ruby-${rubyVersion}-wasm32-unknown-wasip1-full.tar.gz` 34 | const url = `https://github.com/ruby/ruby.wasm/releases/download/${version}/${tarball}` 35 | const destination = `./dist/build/ruby-${rubyVersion}/install.tar.gz` 36 | const zipDest = `./dist/build/ruby-${rubyVersion}.zip` 37 | fs.mkdirSync(`./dist/build/ruby-${rubyVersion}`, { recursive: true }) 38 | 39 | async function downloadUrl(url, destination) { 40 | const response = await new Promise((resolve, reject) => { 41 | https.get(url, resolve).on("error", reject) 42 | }) 43 | if (response.statusCode === 302) { 44 | return downloadUrl(response.headers.location, destination) 45 | } 46 | if (response.statusCode !== 200) { 47 | throw new Error(`Unexpected status code: ${response.statusCode}`) 48 | } 49 | const file = fs.createWriteStream(destination) 50 | await new Promise((resolve, reject) => { 51 | response.pipe(file) 52 | file.on("finish", resolve) 53 | file.on("error", reject) 54 | }) 55 | } 56 | if (!fs.existsSync(destination)) { 57 | console.log(`Downloading ${url} to ${destination}`) 58 | await downloadUrl(url, destination) 59 | } 60 | 61 | if (!fs.existsSync(zipDest)) { 62 | console.log(`Zipping ${destination} to ${zipDest}`) 63 | await new Promise((resolve, reject) => { 64 | const zip = spawn("zip", ["-j", zipDest, destination]) 65 | zip.on("exit", resolve) 66 | zip.on("error", reject) 67 | }) 68 | } 69 | } 70 | 71 | await downloadBuiltinRuby("2.7.1", "3.2") 72 | await downloadBuiltinRuby("2.7.1", "3.3") 73 | await downloadBuiltinRuby("2.7.1", "3.4") 74 | 75 | async function devFrontend(config) { 76 | const ctx = await esbuild.context(makeBuildOptions(config)) 77 | const watch = ctx.watch() 78 | spawn("ruby", ["-run", "-e", "httpd", "--", `--port=${FRONTEND_DEVELOPMENT_PORT}`, "./dist"], { stdio: "inherit" }) 79 | console.log(`Frontend: http://localhost:${FRONTEND_DEVELOPMENT_PORT}`) 80 | return watch 81 | } 82 | 83 | function devServer(config) { 84 | spawn("bundle", [ 85 | "exec", "ruby", "run.rb", "-p", String(SERVER_DEVELOPMENT_PORT), 86 | ], { 87 | cwd: "./service", stdio: "inherit", 88 | env: { 89 | ...process.env, 90 | ...config 91 | } 92 | }) 93 | console.log(`Server: http://localhost:${SERVER_DEVELOPMENT_PORT}`) 94 | console.log('Please ensure that you have enabled "Allow invalid certificates for resources loaded from localhost"') 95 | console.log('in chrome://flags/#allow-insecure-localhost') 96 | } 97 | 98 | const action = process.argv[2] ?? "build" 99 | switch (action) { 100 | case "serve:all": { 101 | const config = { 102 | "PLAY_RUBY_SERVER_URL": `https://127.0.0.1:${SERVER_DEVELOPMENT_PORT}`, 103 | "PLAY_RUBY_FRONTEND_URL": `http://127.0.0.1:${FRONTEND_DEVELOPMENT_PORT}`, 104 | } 105 | 106 | const watch = devFrontend(config) 107 | devServer(config) 108 | await watch 109 | break 110 | } 111 | case "serve": { 112 | const config = { 113 | "PLAY_RUBY_SERVER_URL": `https://play-ruby-34872ef1018e.herokuapp.com`, 114 | "PLAY_RUBY_FRONTEND_URL": `http://127.0.0.1:${FRONTEND_DEVELOPMENT_PORT}`, 115 | } 116 | const watch = devFrontend(config) 117 | await watch 118 | } 119 | case "build": { 120 | const config = { 121 | "PLAY_RUBY_SERVER_URL": `https://play-ruby-34872ef1018e.herokuapp.com`, 122 | "PLAY_RUBY_FRONTEND_URL": `https://ruby.github.io/play-ruby`, 123 | } 124 | await esbuild.build(makeBuildOptions(config)) 125 | break 126 | } 127 | default: 128 | console.error("Unknown action:", action) 129 | process.exit(1) 130 | } 131 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require_relative "./service/run" 2 | run Sinatra::Application 3 | -------------------------------------------------------------------------------- /dist/callback.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 36 | 37 | -------------------------------------------------------------------------------- /dist/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --plrb-color-primary: rgb(55 65 81); 3 | --plrb-color-primary-hover: #23272b; 4 | --plrb-color-primary-active: #1d2124; 5 | 6 | --plrb-status-tools-height: 4rem; 7 | } 8 | 9 | body { 10 | background-color: rgb(231, 233, 235); 11 | color: rgb(55 65 81); 12 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 13 | font-feature-settings: normal; 14 | font-variation-settings: normal; 15 | font-size: 14px; 16 | line-height: 1.5; 17 | margin: 0; 18 | padding: 0; 19 | } 20 | 21 | main { 22 | height: 100vh; 23 | } 24 | 25 | .plrb-pane-container { 26 | display: flex; 27 | flex-direction: row; 28 | flex-grow: 1; 29 | height: 100%; 30 | } 31 | 32 | .plrb-editor-status-group { 33 | display: flex; 34 | flex-direction: column; 35 | height: 100%; 36 | width: 50%; 37 | } 38 | 39 | .plrb-status-pane { 40 | display: flex; 41 | flex-direction: column; 42 | height: calc(var(--plrb-status-tools-height) - 1rem); 43 | padding-left: 1.5rem; 44 | margin: 0.5rem 0; 45 | } 46 | 47 | .plrb-editor-tabs { 48 | display: flex; 49 | flex-direction: row; 50 | align-items: center; 51 | height: 2rem; 52 | margin-bottom: 0.5rem; 53 | } 54 | 55 | .plrb-editor-tab-button { 56 | border-top-left-radius: 0.25rem; 57 | border-top-right-radius: 0.25rem; 58 | border-width: 1px; 59 | border-color: rgb(118, 118, 118); 60 | background-color: #ffffff; 61 | color: rgb(55 65 81); 62 | font-size: 100%; 63 | font-family: inherit; 64 | font-weight: 600; 65 | padding: 0.5rem 1rem; 66 | white-space: nowrap; 67 | flex: 1 1 0%; 68 | } 69 | 70 | .plrb-editor-tab-button:hover { 71 | background-color: #f2f2f2; 72 | cursor: pointer; 73 | } 74 | 75 | .plrb-editor-tab-button-active { 76 | background-color: var(--plrb-color-primary); 77 | color: #ffffff; 78 | } 79 | 80 | .plrb-editor-tab-button-active:hover { 81 | background-color: var(--plrb-color-primary-hover); 82 | } 83 | 84 | .plrb-editor-pane { 85 | flex: 1 1 0%; 86 | } 87 | 88 | .plrb-editor-file-header { 89 | background: rgba(173, 216, 230, 0.5); 90 | } 91 | 92 | .plrb-tools-output-group { 93 | display: flex; 94 | flex-direction: column; 95 | height: 100%; 96 | width: 50%; 97 | } 98 | 99 | .plrb-tools-pane { 100 | display: flex; 101 | flex-direction: row; 102 | width: 100%; 103 | height: var(--plrb-status-tools-height); 104 | align-items: center; 105 | } 106 | 107 | .plrb-tools-button-group { 108 | margin: 0 1rem; 109 | display: flex; 110 | } 111 | 112 | .plrb-tools-do-button { 113 | border-top-right-radius: 0.25rem; 114 | border-bottom-right-radius: 0.25rem; 115 | border-width: 1px; 116 | border-color: rgb(118, 118, 118); 117 | background-color: var(--plrb-color-primary); 118 | color: #ffffff; 119 | font-size: 100%; 120 | padding: 0.5rem 0.5rem; 121 | white-space: nowrap; 122 | } 123 | 124 | .plrb-tools-do-button:hover { 125 | background-color: var(--plrb-color-primary-hover); 126 | cursor: pointer; 127 | } 128 | 129 | .plrb-tools-do-button:active { 130 | background-color: var(--plrb-color-primary-active); 131 | } 132 | 133 | .plrb-tools-more-tools-button { 134 | border-top-left-radius: 0.25rem; 135 | border-bottom-left-radius: 0.25rem; 136 | border-top-right-radius: 0; 137 | border-bottom-right-radius: 0; 138 | border-width: 1px; 139 | border-color: rgb(118, 118, 118); 140 | background-color: #6c757d; 141 | font-size: 100%; 142 | font-family: inherit; 143 | font-weight: 600; 144 | padding: 0.5rem 1rem; 145 | white-space: nowrap; 146 | color: #ffffff; 147 | } 148 | 149 | .plrb-tools-more-tools-button:hover { 150 | background-color: #5a6268; 151 | } 152 | 153 | .plrb-tools-more-tools-button:active { 154 | background-color: #4e555b; 155 | } 156 | 157 | .plrb-tools-metadata-message { 158 | overflow: hidden; 159 | text-overflow: ellipsis; 160 | white-space: nowrap; 161 | font-size: small; 162 | } 163 | 164 | .plrb-tools-metadata-revision a { 165 | text-decoration-line: underline; 166 | } 167 | 168 | .plrb-output-pane { 169 | flex: 1 1 0%; 170 | overflow: auto; 171 | padding: 1rem; 172 | margin: 0 0.5rem; 173 | border-radius: 0.5rem; 174 | background-color: #ffffff; 175 | white-space-collapse: preserve; 176 | } 177 | 178 | .plrb-output-range { 179 | text-decoration: underline; 180 | } 181 | 182 | .plrb-icon-button { 183 | border-width: 1px; 184 | border-radius: 0.25rem; 185 | background-color: #6c757d; 186 | font-size: 100%; 187 | font-family: inherit; 188 | font-weight: 600; 189 | padding: 0.5rem 1rem; 190 | white-space: nowrap; 191 | color: #ffffff; 192 | } 193 | 194 | .plrb-icon-button:hover { 195 | background-color: #5a6268; 196 | } 197 | 198 | .plrb-icon-button:active { 199 | background-color: #4e555b; 200 | } 201 | 202 | .plrb-tools-config-button { 203 | margin-left: auto; 204 | margin-right: 1rem; 205 | } 206 | 207 | .plrb-tools-help-button { 208 | margin-right: 1rem; 209 | } 210 | 211 | .plrb-modal { 212 | border-radius: 0.5rem; 213 | } 214 | 215 | .plrb-modal-content { 216 | background-color: #ffffff; 217 | padding: 1rem; 218 | width: 50vw; 219 | height: 50vh; 220 | overflow: auto; 221 | } 222 | 223 | .plrb-modal-close-button { 224 | position: absolute; 225 | top: 0; 226 | right: 0; 227 | margin: 0.5rem; 228 | border: transparent; 229 | background-color: transparent; 230 | font-size: 100%; 231 | font-family: inherit; 232 | font-weight: 600; 233 | white-space: nowrap; 234 | text-decoration: underline; 235 | } 236 | 237 | .plrb-modal-config-input { 238 | width: 90%; 239 | } 240 | 241 | .plrb-modal-config-github-sign-in-button { 242 | border-radius: 0.25rem; 243 | border-width: 1px; 244 | border-color: rgb(118, 118, 118); 245 | background-color: #6c757d; 246 | color: #ffffff; 247 | font-size: 100%; 248 | font-family: inherit; 249 | font-weight: 600; 250 | padding: 0.5rem 1rem; 251 | white-space: nowrap; 252 | } 253 | 254 | .plrb-modal-config-github-sign-out-button { 255 | border-radius: 0.25rem; 256 | border-width: 1px; 257 | border-color: rgb(118, 118, 118); 258 | background-color: #dc3545; 259 | color: #ffffff; 260 | font-size: 100%; 261 | font-family: inherit; 262 | font-weight: 600; 263 | padding: 0.5rem 1rem; 264 | white-space: nowrap; 265 | } 266 | 267 | /* Keyboard shortcut table */ 268 | .plrb-shortcut-table { 269 | border-collapse: collapse; 270 | width: 100%; 271 | } 272 | 273 | .plrb-shortcut-table th { 274 | text-align: left; 275 | padding: 0.5rem; 276 | } 277 | 278 | .plrb-shortcut-table td { 279 | padding: 0.5rem; 280 | } 281 | 282 | .plrb-shortcut-table tr:nth-child(even) { 283 | background-color: #f2f2f2; 284 | } 285 | 286 | .plrb-shortcut-table th { 287 | background-color: #4e555b; 288 | color: white; 289 | } 290 | 291 | @media screen and (max-width: 768px) { 292 | .plrb-editor-pane { 293 | height: 50vh; 294 | } 295 | .plrb-pane-container { 296 | display: block; 297 | } 298 | .plrb-editor-status-group { 299 | width: 100%; 300 | height: 50vh; 301 | } 302 | .plrb-tools-output-group { 303 | display: block; 304 | width: 100%; 305 | height: 50%; 306 | } 307 | .plrb-output-pane { 308 | overflow: visible; 309 | min-height: 30vh; 310 | } 311 | .plrb-modal-content { 312 | width: 80vw; 313 | height: 60vh; 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Ruby Playground 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 | 22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | 37 | 42 |
43 | 50 | 51 |
52 |
53 |
54 |
55 |
56 | 57 | 58 |
59 |
60 | 61 |

Help

62 |

Keyboard Shortcuts

63 |

64 | 65 | 66 | 67 | 68 | 69 | 70 | 73 | 74 | 75 |
ShortcutAction
71 | Ctrl + Enter or Cmd + Enter 72 | Run
76 |

77 |

Special Pragma Directives

78 |

79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 |
PragmaAction
#--- [filename].rbSplit the code into multiple files like LLVM split-file tool
89 |

90 |

Source Code

91 |

92 | This site is open source. You can find the source code at GitHub. 93 |

94 |

Privacy Policy

95 |

96 | This site does not collect any personal information, such as your IP address and code you entered. 97 |

98 |
99 |
100 |
101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

Configuration

109 |
110 |

GitHub Personal Access Token

111 |

112 | If you want to try a Ruby built from a GitHub Action workflow, you need to set a GitHub Personal Access Token (PAT) at first. 113 | Please create a new PAT with the public_repo scope and set it when you open this page. 114 | 115 |

116 | 117 |
118 |
119 |

GitHub Integration

120 |

121 | If you want to try a Ruby built from a GitHub Action workflow, you need to sign in to GitHub to be able to download artifacts via GitHub API. 122 |

123 |

124 | 125 | 126 |

127 |
128 |
129 |
130 |
131 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "play-ruby", 3 | "scripts": { 4 | "build": "node ./bin/build.mjs", 5 | "serve": "node ./bin/build.mjs serve", 6 | "serve:all": "node ./bin/build.mjs serve:all", 7 | "test": "vitest" 8 | }, 9 | "dependencies": { 10 | "@bjorn3/browser_wasi_shim": "^0.3.0", 11 | "@zip.js/zip.js": "^2.7.54", 12 | "comlink": "^4.4.2", 13 | "esbuild-plugin-polyfill-node": "^0.3.0", 14 | "monaco-editor": "0.52.2", 15 | "tar-stream": "^3.1.7" 16 | }, 17 | "devDependencies": { 18 | "@types/tar-stream": "^3.1.3", 19 | "esbuild": "^0.24.2", 20 | "vitest": "^3.0.4" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /service/run.rb: -------------------------------------------------------------------------------- 1 | # This file is responsible for running GitHub OAuth service. 2 | # This service does not store any user data or access tokens. 3 | 4 | require 'sinatra' 5 | require 'sinatra/reloader' 6 | require 'net/http' 7 | require 'octokit' 8 | 9 | %w[ 10 | GITHUB_CLIENT_ID_LOCAL GITHUB_CLIENT_SECRET_LOCAL 11 | GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET 12 | ].each do |env| 13 | unless ENV[env] 14 | raise <<~MSG 15 | Missing environment variable: #{env} 16 | If you have access to Ruby core team 1Password, you can inject the environment variables by running: 17 | 18 | $ env $(op --account rubylang.1password.com inject -i .env) npm run serve:all 19 | MSG 20 | end 21 | Object.const_set(env, ENV[env]) 22 | end 23 | [ 24 | ["PLAY_RUBY_FRONTEND_URL", "http://127.0.0.1:8091"], 25 | ["PLAY_RUBY_SERVER_URL", "https://127.0.0.1:8090"], 26 | ].each do |env, default| 27 | Object.const_set(env, ENV[env] || default) 28 | end 29 | 30 | GITHUB_OAUTH_CONFIG = { 31 | "development" => { 32 | "GITHUB_OAUTH_CALLBACK_BASEURL" => "http://127.0.0.1:8091/callback.html", 33 | "GITHUB_CLIENT_ID" => GITHUB_CLIENT_ID_LOCAL, 34 | "GITHUB_CLIENT_SECRET" => GITHUB_CLIENT_SECRET_LOCAL, 35 | }, 36 | "production" => { 37 | "GITHUB_OAUTH_CALLBACK_BASEURL" => "https://ruby.github.io/play-ruby/callback.html", 38 | "GITHUB_CLIENT_ID" => GITHUB_CLIENT_ID, 39 | "GITHUB_CLIENT_SECRET" => GITHUB_CLIENT_SECRET, 40 | } 41 | } 42 | 43 | if development? 44 | set :server_settings, 45 | SSLEnable: true, 46 | SSLCertName: [['CN', WEBrick::Utils.getservername]] 47 | end 48 | 49 | use Rack::Session::Cookie, { 50 | same_site: :none, 51 | coder: Rack::Session::Cookie::Base64::JSON.new, 52 | secure: true, 53 | partitioned: true, 54 | assume_ssl: true, 55 | } 56 | 57 | def request_from_localhost? 58 | raw_origin = request.env['HTTP_ORIGIN'] || request.env['HTTP_REFERER'] 59 | return false unless raw_origin 60 | origin_host = URI.parse(raw_origin).host 61 | origin_host == "localhost" || origin_host == "127.0.0.1" 62 | end 63 | 64 | before do 65 | current_origin = request.env['HTTP_ORIGIN'] 66 | valid_frontend_origins = GITHUB_OAUTH_CONFIG.map do |k, v| 67 | uri = URI.parse(v["GITHUB_OAUTH_CALLBACK_BASEURL"]) 68 | "#{uri.scheme}://#{uri.host}" 69 | end 70 | if request_from_localhost? || valid_frontend_origins.include?(current_origin) 71 | headers 'Access-Control-Allow-Origin' => current_origin 72 | end 73 | headers 'Access-Control-Allow-Credentials' => 'true' 74 | 75 | @github_oauth_config = GITHUB_OAUTH_CONFIG[request_from_localhost? ? "development" : "production"] 76 | puts "USING #{request_from_localhost? ? 'LOCAL' : 'PRODUCTION'} GITHUB CLIENT ID" 77 | end 78 | 79 | options '*' do 80 | response.headers['Allow'] = 'HEAD,GET,PUT,POST,DELETE,OPTIONS' 81 | response.headers['Access-Control-Allow-Headers'] = 'X-Requested-With, X-HTTP-Method-Override, Content-Type, Cache-Control, Accept' 82 | 200 83 | end 84 | 85 | def authenticated? 86 | session[:access_token] 87 | end 88 | 89 | def authenticate! 90 | redirect_uri = URI.parse(@github_oauth_config["GITHUB_OAUTH_CALLBACK_BASEURL"]) 91 | redirect_query = { server_url: PLAY_RUBY_SERVER_URL } 92 | if params[:origin] 93 | redirect_query[:origin] = params[:origin] 94 | end 95 | redirect_uri.query = URI.encode_www_form(redirect_query) 96 | 97 | authorize_uri = URI.parse "https://github.com/login/oauth/authorize" 98 | authorize_uri.query = URI.encode_www_form({ 99 | scope: "public_repo", 100 | client_id: @github_oauth_config["GITHUB_CLIENT_ID"], 101 | redirect_uri: redirect_uri.to_s 102 | }) 103 | redirect authorize_uri.to_s 104 | end 105 | 106 | module GitHubExtras 107 | module_function 108 | def get_branch_latest_run_id(client, repo:, branch:, workflow_path:) 109 | self.commits(client, repo, branch) do |commit| 110 | runs_url = "https://api.github.com/repos/#{repo}/actions/runs?event=push&branch=#{branch}&commit_sha=#{commit['sha']}&status=success&exclude_pull_requests=true" 111 | runs = client.get(runs_url) 112 | runs['workflow_runs'].each do |run| 113 | if run['path'] == workflow_path 114 | return run['id'] 115 | end 116 | end 117 | end 118 | raise "Run not found: #{workflow_path}" 119 | end 120 | 121 | def get_pull_request_latest_run_id(client, repo:, pr_number:, workflow_path:) 122 | pr = client.pull_request(repo, pr_number) 123 | head_sha = pr['head']['sha'] 124 | runs_url = "https://api.github.com/repos/#{repo}/actions/runs?event=pull_request&status=success&pull_requests=#{pr_number}" 125 | runs = client.get(runs_url) 126 | runs['workflow_runs'].each do |run| 127 | if run['head_sha'] == head_sha && run['path'] == workflow_path 128 | return run['id'] 129 | end 130 | end 131 | raise "Run not found: #{workflow_path}" 132 | end 133 | 134 | def commits(client, repo, branch) 135 | commits = client.commits(repo, branch) 136 | last_response = client.last_response 137 | while last_response.rels[:next] 138 | commits.each do |commit| 139 | puts "Checking commit #{commit['sha']}" 140 | yield commit 141 | end 142 | page += 1 143 | commits = last_response.rels[:next].get 144 | last_response = client.last_response 145 | end 146 | end 147 | end 148 | 149 | def download_info_from_run_id(client, repo, workflow_path, run_id) 150 | artifact_name = "ruby-wasm-install" 151 | 152 | run_url = "https://api.github.com/repos/#{repo}/actions/runs/#{run_id}" 153 | run = client.get(run_url) 154 | artifacts = client.get(run['artifacts_url']) 155 | artifact = artifacts['artifacts'].find { |artifact| artifact['name'] == artifact_name } 156 | raise "Artifact not found" unless artifact 157 | 158 | # Resolve the final download URL which does not require authentication 159 | archive_download_url = artifact['archive_download_url'] 160 | result = Net::HTTP.get_response(URI(archive_download_url), { 161 | 'Accept' => 'application/json', 162 | 'Authorization' => "bearer #{client.access_token}" 163 | }) 164 | artifact['archive_download_url'] = result['location'] 165 | 166 | { 167 | run: { 168 | id: run['id'], 169 | html_url: run['html_url'], 170 | head_commit: run['head_commit'].to_h 171 | }, 172 | artifact: artifact.to_h 173 | }.to_json 174 | end 175 | 176 | get '/download_info' do 177 | access_token = session[:access_token] 178 | return 401 unless access_token 179 | 180 | payload = params[:payload] or raise "?payload= parameter is required" 181 | repo = "ruby/ruby" 182 | workflow_path = ".github/workflows/wasm.yml" 183 | 184 | client = Octokit::Client.new(access_token: access_token) 185 | case params[:source] 186 | when "run" 187 | run_id = payload 188 | when "pr" 189 | pr_number = payload 190 | run_id = GitHubExtras.get_pull_request_latest_run_id(client, repo: repo, pr_number: pr_number, workflow_path: workflow_path) 191 | else 192 | raise "?source= parameter is missing or invalid" 193 | end 194 | 195 | if run_id == "latest" 196 | run_id = GitHubExtras.get_branch_latest_run_id(client, repo: repo, branch: "master", workflow_path: workflow_path) 197 | end 198 | 199 | content_type :json 200 | return download_info_from_run_id(client, repo, workflow_path, run_id) 201 | end 202 | 203 | get '/sign_in' do 204 | if !authenticated? 205 | authenticate! 206 | else 207 | access_token = session[:access_token] 208 | scopes = [] 209 | 210 | begin 211 | auth_result = Net::HTTP.get(URI('https://api.github.com/user'), { 212 | 'Accept' => 'application/json', 213 | 'Authorization' => "bearer #{access_token}" 214 | }) 215 | rescue => e 216 | session[:access_token] = nil 217 | return authenticate! 218 | end 219 | end 220 | end 221 | 222 | get '/sign_out' do 223 | session[:access_token] = nil 224 | :ok 225 | end 226 | 227 | get '/callback' do 228 | session_code = request.env['rack.request.query_hash']['code'] 229 | 230 | request = Net::HTTP::Post.new( 231 | URI('https://github.com/login/oauth/access_token'), 232 | { 233 | 'Content-Type' => 'application/x-www-form-urlencoded', 234 | 'Accept' => 'application/json' 235 | } 236 | ) 237 | request.form_data = { 238 | 'client_id' => @github_oauth_config['GITHUB_CLIENT_ID'], 239 | 'client_secret' => @github_oauth_config['GITHUB_CLIENT_SECRET'], 240 | 'code' => session_code 241 | } 242 | result = Net::HTTP.start(request.uri.hostname, request.uri.port, use_ssl: true) do |http| 243 | http.request(request) 244 | end 245 | unless result.code.to_i == 200 246 | return "Error getting access token: #{result.body}" 247 | end 248 | 249 | unless access_token = JSON.parse(result.body)['access_token'] 250 | return "Error getting access token: #{result.body}" 251 | end 252 | session[:access_token] = access_token 253 | 254 | :ok 255 | end 256 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as monaco from "monaco-editor" 2 | import * as Comlink from "comlink" 3 | import type { RubyWorker } from "./ruby.worker" 4 | import { splitFile } from "./split-file" 5 | 6 | type PlayRubyConfig = { 7 | SERVER_URL: string, 8 | ENABLE_GITHUB_INTEGRATION: boolean, 9 | } 10 | 11 | class GitHubAPIError extends Error { 12 | constructor(context: string, public response: Response) { 13 | super(`GitHub API error (${context}): ${response.status} ${response.statusText}`) 14 | } 15 | 16 | isUnauthorized() { 17 | return this.response.status === 401 18 | } 19 | } 20 | interface ArtifactDownloader { 21 | getDownloadInfo(source: string, payload: string): Promise<{ run: any, artifact: any }>; 22 | downloadArtifact(url: string): Promise; 23 | } 24 | 25 | /** 26 | * Provides access to GitHub Actions artifacts using GitHub Access Tokens 27 | */ 28 | class TokenBasedArtifactDownloader implements ArtifactDownloader { 29 | constructor(private repo: string, private headers: HeadersInit) { } 30 | 31 | private async jsonRequest(url: string, context: string) { 32 | const headers = { 33 | "Accept": "application/vnd.github.v3+json", 34 | ...this.headers, 35 | } 36 | const response = await fetch(url, { headers }) 37 | if (!response.ok) { 38 | throw new GitHubAPIError(context, response) 39 | } 40 | return await response.json() 41 | } 42 | 43 | async getPullRequestLatestRunId(prNumber: string, workflowPath: string) { 44 | const prUrl = `https://api.github.com/repos/${this.repo}/pulls/${prNumber}` 45 | const pr = await this.jsonRequest(prUrl, "PR fetch") 46 | const headSha = pr["head"]["sha"] 47 | 48 | const runsUrl = `https://api.github.com/repos/${this.repo}/actions/runs?event=pull_request&head_sha=${headSha}` 49 | const runs = await this.jsonRequest(runsUrl, "Runs fetch") 50 | 51 | for (const run of runs["workflow_runs"]) { 52 | if (run["path"] === workflowPath) { 53 | return run["id"] 54 | } 55 | } 56 | throw new Error(`No run for ${workflowPath} in PR ${prNumber}`) 57 | } 58 | 59 | async getBranchLatestRunId(branch: string, workflowPath: string) { 60 | async function* commits() { 61 | let page = 1 62 | while (true) { 63 | const commitsUrl = `https://api.github.com/repos/${this.repo}/commits?sha=${branch}&page=${page}` 64 | const commits = await this.jsonRequest(commitsUrl, "Commits fetch") 65 | for (const commit of commits) { 66 | yield commit 67 | } 68 | if (commits.length === 0) { 69 | break 70 | } 71 | page++ 72 | } 73 | } 74 | 75 | for await (const commit of commits.call(this)) { 76 | const runsUrl = `https://api.github.com/repos/${this.repo}/actions/runs?event=push&branch=${branch}&commit_sha=${commit["sha"]}&status=success&exclude_pull_requests=true` 77 | let runs: any; 78 | try { 79 | runs = await this.jsonRequest(runsUrl, "Runs fetch") 80 | } catch (error) { 81 | if (error instanceof GitHubAPIError && error.response.status === 404) { 82 | // No runs for this commit 83 | continue 84 | } 85 | throw error 86 | } 87 | 88 | for (const run of runs["workflow_runs"]) { 89 | if (run["path"] === workflowPath) { 90 | return run["id"] 91 | } 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * Fetches the metadata for a GitHub Actions run and returns the metadata for the given artifact 98 | * @param runId The ID of the GitHub Actions run 99 | * @param artifactName The name of the artifact 100 | * @returns The metadata for the artifact in the given run 101 | */ 102 | async getMetadata(runId: string, artifactName: string) { 103 | const runUrl = `https://api.github.com/repos/${this.repo}/actions/runs/${runId}` 104 | const run = await this.jsonRequest(runUrl, "Run fetch") 105 | const artifacts = await this.jsonRequest(run["artifacts_url"], "Artifacts fetch") 106 | 107 | const artifact = artifacts["artifacts"].find((artifact: any) => artifact["name"] === artifactName) 108 | if (artifact == null) { 109 | throw new Error(`No ${artifactName} artifact`) 110 | } 111 | return { run, artifact } 112 | } 113 | 114 | async getDownloadInfo(source: string, payload: string): Promise<{ run: any; artifact: any }> { 115 | const workflowPath = ".github/workflows/wasm.yml" 116 | const artifactName = "ruby-wasm-install" 117 | switch (source) { 118 | case "pr": { 119 | const runId = await this.getPullRequestLatestRunId(payload, workflowPath) 120 | return await this.getMetadata(runId, artifactName) 121 | } 122 | case "run": 123 | if (payload === "latest") { 124 | payload = await this.getBranchLatestRunId("master", workflowPath) 125 | } 126 | return await this.getMetadata(payload, artifactName) 127 | default: 128 | throw new Error(`Unknown source: ${source} with payload: ${payload}`) 129 | } 130 | } 131 | 132 | downloadArtifact(url: string): Promise { 133 | return fetch(url, { headers: this.headers }); 134 | } 135 | } 136 | 137 | class PlayRubyService implements ArtifactDownloader { 138 | constructor(public endpoint: string) { } 139 | 140 | private fetch(url: string, options: RequestInit) { 141 | return fetch(url, { ...options, credentials: "include" }) 142 | } 143 | 144 | /** 145 | * Fetches the metadata for a GitHub Actions run and returns the metadata for the given artifact 146 | * @param source The source of the artifact (e.g. "run", "pr") 147 | * @param payload The payload for the source (e.g. run ID, PR number) 148 | * @returns The metadata for the artifact in the given run 149 | */ 150 | async getDownloadInfo(source: string, payload: string): Promise<{ run: any, artifact: any }> { 151 | const url = new URL(this.endpoint) 152 | url.pathname = "/download_info" 153 | url.searchParams.set("source", source) 154 | url.searchParams.set("payload", payload) 155 | const response = await this.fetch(url.toString(), {}) 156 | if (!response.ok) { 157 | throw new GitHubAPIError("Download info", response) 158 | } 159 | return await response.json() 160 | } 161 | 162 | signInLink(origin: string) { 163 | const url = new URL(this.endpoint) 164 | url.pathname = "/sign_in" 165 | url.searchParams.set("origin", origin) 166 | return url.toString() 167 | } 168 | 169 | async signOut() { 170 | const url = new URL(this.endpoint) 171 | url.pathname = "/sign_out" 172 | await this.fetch(url.toString(), {}) 173 | } 174 | 175 | async downloadArtifact(url: string) { 176 | return await fetch(url) 177 | } 178 | } 179 | 180 | /** 181 | * Provides access to GitHub Actions artifacts 182 | */ 183 | class GitHubArtifactRegistry { 184 | constructor(private cache: Cache, private downloader: ArtifactDownloader) { } 185 | 186 | /** 187 | * Returns the artifact at the given URL, either from the cache or by downloading it 188 | */ 189 | async get(artifactUrl: string, cacheKey: string) { 190 | let response = await this.cache.match(cacheKey) 191 | if (response == null || !response.ok) { 192 | response = await this.downloader.downloadArtifact(artifactUrl) 193 | if (response.ok) { 194 | this.cache.put(cacheKey, response.clone()) 195 | } else { 196 | throw new GitHubAPIError("Artifact download", response) 197 | } 198 | } 199 | return response 200 | } 201 | } 202 | 203 | /** 204 | * Passes through a response, but also calls setProgress with the number of bytes downloaded 205 | */ 206 | function teeDownloadProgress(response: Response, setProgress: (bytes: number, response: Response) => void): Response { 207 | let loaded = 0 208 | return new Response(new ReadableStream({ 209 | async start(controller) { 210 | const reader = response.body.getReader(); 211 | while (true) { 212 | const { done, value } = await reader.read(); 213 | if (done) break; 214 | loaded += value.byteLength; 215 | setProgress(loaded, response); 216 | controller.enqueue(value); 217 | } 218 | controller.close(); 219 | }, 220 | })); 221 | } 222 | 223 | 224 | async function initRubyWorkerClass(rubySource: RubySource, service: ArtifactDownloader, setStatus: (status: string) => void, setMetadata: (run: any) => void) { 225 | setStatus("Installing Ruby...") 226 | const artifactRegistry = new GitHubArtifactRegistry(await caches.open("ruby-wasm-install-v1"), service) 227 | const RubyWorkerClass = Comlink.wrap(new Worker("build/src/ruby.worker.js", { type: "module" })) as unknown as { 228 | create(zipBuffer: ArrayBuffer, stripComponents: number, setStatus: (message: string) => void): Promise 229 | } 230 | const initFromZipTarball = async ( 231 | url: string, cacheKey: string, stripComponents: number, 232 | setProgress: (bytes: number, response: Response) => void 233 | ) => { 234 | setStatus("Downloading Ruby...") 235 | const zipSource = await artifactRegistry.get(url, cacheKey) 236 | if (zipSource.status !== 200) { 237 | throw new Error(`Failed to download ${url}: ${zipSource.status} ${await zipSource.text()}`) 238 | } 239 | const zipResponse = teeDownloadProgress( 240 | zipSource, 241 | setProgress 242 | ) 243 | const zipBuffer = await zipResponse.arrayBuffer(); 244 | 245 | return async () => { 246 | return await RubyWorkerClass.create(zipBuffer, stripComponents, Comlink.proxy(setStatus)) 247 | } 248 | } 249 | const initFromGitHubActionsRun = async (run: any, artifact: any) => { 250 | setMetadata(run) 251 | const size = Number(artifact["size_in_bytes"]); 252 | // archive_download_url might be changed, so use runId as cache key 253 | return await initFromZipTarball(artifact["archive_download_url"], run["id"], 0, (bytes, _) => { 254 | const total = size 255 | const percent = Math.round(bytes / total * 100) 256 | setStatus(`Downloading Ruby... ${percent}%`) 257 | }) 258 | } 259 | const initFromBuiltin = async (version: string) => { 260 | const url = `build/ruby-${version}.zip` 261 | return await initFromZipTarball(url, url, 1, (bytes, response) => { 262 | const total = Number(response.headers.get("Content-Length")) 263 | const percent = Math.round(bytes / total * 100) 264 | setStatus(`Downloading Ruby... ${percent}%`) 265 | }) 266 | } 267 | 268 | const workflowPath = ".github/workflows/wasm.yml" 269 | switch (rubySource.type) { 270 | case "github-actions-run": { 271 | let runId = rubySource.runId 272 | const { run, artifact } = await service.getDownloadInfo("run", runId) 273 | return initFromGitHubActionsRun(run, artifact) 274 | } 275 | case "github-pull-request": { 276 | const { run, artifact } = await service.getDownloadInfo("pr", rubySource.prNumber) 277 | return initFromGitHubActionsRun(run, artifact) 278 | } 279 | case "builtin": 280 | return initFromBuiltin(rubySource.version) 281 | default: 282 | throw new Error(`Unknown Ruby source type: ${rubySource}`) 283 | } 284 | } 285 | 286 | type RubySource = { 287 | type: "github-actions-run", 288 | runId: string, 289 | } | { 290 | type: "github-pull-request", 291 | prNumber: string, 292 | } | { 293 | type: "builtin", 294 | version: string, 295 | } 296 | 297 | function rubySourceFromURL(): RubySource | null { 298 | const query = new URLSearchParams(window.location.search) 299 | for (const [key, value] of query.entries()) { 300 | if (key === "run") { 301 | return { type: "github-actions-run", runId: value } 302 | } else if (key === "pr") { 303 | return { type: "github-pull-request", prNumber: value } 304 | } else if (key === "latest") { 305 | return { type: "github-actions-run", runId: "latest" } 306 | } else if (key === "builtin") { 307 | return { type: "builtin", version: value } 308 | } 309 | } 310 | return { type: "builtin", version: "3.4" } 311 | } 312 | 313 | export type Options = { 314 | arguments: string[], 315 | env: Record, 316 | } 317 | 318 | const DEFAULT_OPTIONS: Options = { 319 | arguments: [], 320 | env: {}, 321 | } 322 | 323 | type UIState = { 324 | code: string, 325 | action: string, 326 | options: Options, 327 | } 328 | 329 | self.MonacoEnvironment = { 330 | getWorkerUrl: function (moduleId, label) { 331 | if (label === 'json') { 332 | return './build/node_modules/monaco-editor/esm/vs/language/json/json.worker.js'; 333 | } 334 | return './build/node_modules/monaco-editor/esm/vs/editor/editor.worker.js'; 335 | }, 336 | getWorker: function (moduleId, label) { 337 | let workerUrl = self.MonacoEnvironment.getWorkerUrl(moduleId, label); 338 | return new Worker(workerUrl, { 339 | name: label, 340 | type: 'module', 341 | }); 342 | } 343 | }; 344 | 345 | 346 | function initEditor(state: UIState) { 347 | const editor = monaco.editor.create(document.getElementById('editor'), { 348 | fontSize: 16, 349 | }); 350 | 351 | const layoutEditor = () => { 352 | // 1. Squash the editor to 0x0 to layout the parent container 353 | editor.layout({ width: 0, height: 0 }) 354 | // 2. Wait for the next animation frame to ensure the parent container has been laid out 355 | window.requestAnimationFrame(() => { 356 | // 3. Resize the editor to fill the parent container 357 | const { width, height } = editor.getContainerDomNode().getBoundingClientRect() 358 | editor.layout({ width, height }) 359 | }) 360 | } 361 | window.addEventListener("resize", layoutEditor) 362 | 363 | const codeModel = monaco.editor.createModel(state.code, "ruby") 364 | const optionsModel = monaco.editor.createModel(JSON.stringify(state.options, null, 2), "json") 365 | 366 | type Tab = { 367 | label: string, 368 | model: monaco.editor.ITextModel, 369 | active: boolean, 370 | queryKey: string, 371 | computeQueryValue: (value: string) => string | null, 372 | applyDecorations?: (value: string) => void, 373 | } 374 | const tabs: Tab[] = [ 375 | { 376 | label: "Code", 377 | model: codeModel, 378 | queryKey: "code", 379 | active: true, 380 | computeQueryValue: (value) => value, 381 | applyDecorations: (() => { 382 | let lastDecorations: monaco.editor.IEditorDecorationsCollection | null = null 383 | return (value) => { 384 | const [files, _] = splitFile(value) 385 | const decorations: monaco.editor.IModelDeltaDecoration[] = [] 386 | for (const [filename, file] of Object.entries(files)) { 387 | const line = file.sourceLine; 388 | const range = new monaco.Range(line + 1, 1, line + 1, 1) 389 | decorations.push({ 390 | range, 391 | options: { 392 | isWholeLine: true, 393 | className: "plrb-editor-file-header", 394 | } 395 | }) 396 | } 397 | if (lastDecorations) lastDecorations.clear() 398 | lastDecorations = editor.createDecorationsCollection(decorations) 399 | } 400 | })() 401 | }, 402 | { 403 | label: "Options", 404 | model: optionsModel, 405 | queryKey: "options", 406 | active: false, 407 | computeQueryValue: (value) => { 408 | try { 409 | const minified = JSON.stringify(JSON.parse(value)) 410 | return minified 411 | } catch (error) { 412 | // Ignore invalid JSON 413 | return null; 414 | } 415 | }, 416 | } 417 | ] 418 | 419 | for (const tab of tabs) { 420 | const updateURL = () => { 421 | const url = new URL(window.location.href) 422 | let content = tab.computeQueryValue(tab.model.getValue()) 423 | url.searchParams.set(tab.queryKey, content); 424 | window.history.replaceState({}, "", url.toString()) 425 | } 426 | tab.model.onDidChangeContent(() => { 427 | updateURL() 428 | if (tab.applyDecorations) { 429 | tab.applyDecorations(tab.model.getValue()) 430 | } 431 | }) 432 | } 433 | 434 | const setTab = (tab: Tab) => { 435 | tab.active = true 436 | editor.setModel(tab.model) 437 | if (tab.applyDecorations) { 438 | tab.applyDecorations(tab.model.getValue()) 439 | } 440 | } 441 | setTab(tabs[0]) // Set the first tab as active 442 | 443 | const editorTabs = document.getElementById("editor-tabs") as HTMLDivElement 444 | for (const tab of tabs) { 445 | const button = document.createElement("button") 446 | button.classList.add("plrb-editor-tab-button"); 447 | if (tab.active) { 448 | button.classList.add("plrb-editor-tab-button-active") 449 | } 450 | button.innerText = tab.label 451 | button.addEventListener("click", () => { 452 | editorTabs.querySelectorAll(".plrb-editor-tab-button").forEach((button) => { 453 | button.classList.remove("plrb-editor-tab-button-active") 454 | }); 455 | for (const tab of tabs) { 456 | tab.active = false 457 | } 458 | button.classList.add("plrb-editor-tab-button-active") 459 | setTab(tab) 460 | }); 461 | editorTabs.appendChild(button) 462 | } 463 | 464 | return { 465 | editor, 466 | getOptions() { 467 | return JSON.parse(optionsModel.getValue()) as Options 468 | }, 469 | getCode() { 470 | return codeModel.getValue() 471 | } 472 | }; 473 | } 474 | 475 | function stateFromURL(): UIState { 476 | const query = new URLSearchParams(window.location.search) 477 | let code = query.get("code") 478 | if (code == null) { 479 | code = `def hello = puts "Hello" 480 | hello 481 | puts "World" 482 | puts RUBY_DESCRIPTION` 483 | } 484 | 485 | let action = query.get("action") 486 | if (action == null) { 487 | action = "eval" 488 | } 489 | 490 | let options = JSON.parse(query.get("options")) as Options | null 491 | if (options == null) { 492 | options = DEFAULT_OPTIONS 493 | } 494 | 495 | return { code, action, options } 496 | } 497 | 498 | function initUI(state: UIState, config: PlayRubyConfig, service: PlayRubyService) { 499 | const showHelpButton = document.getElementById("button-show-help") 500 | const helpModal = document.getElementById("modal-help") as HTMLDialogElement 501 | showHelpButton.addEventListener("click", () => { 502 | helpModal.showModal() 503 | }) 504 | 505 | const showConfigButton = document.getElementById("button-show-config") 506 | const configModal = document.getElementById("modal-config") as HTMLDialogElement 507 | const configGithubToken = document.getElementById("config-github-token") as HTMLInputElement 508 | showConfigButton.addEventListener("click", () => { 509 | configGithubToken.value = localStorage.getItem("GITHUB_TOKEN") ?? "" 510 | configModal.showModal() 511 | }) 512 | const configForm = document.getElementById("config-form") as HTMLFormElement 513 | configForm.addEventListener("submit", (event) => { 514 | event.preventDefault() 515 | localStorage.setItem("GITHUB_TOKEN", configGithubToken.value) 516 | configModal.close() 517 | }) 518 | const configGitHubSignIn = document.getElementById("config-github-sign-in") as HTMLButtonElement 519 | configGitHubSignIn.addEventListener("click", () => { 520 | window.open(service.signInLink(location.href).toString(), "_self") 521 | }) 522 | const configGitHubSignOut = document.getElementById("config-github-sign-out") as HTMLButtonElement 523 | configGitHubSignOut.addEventListener("click", async () => { 524 | await service.signOut() 525 | window.location.reload() 526 | }) 527 | 528 | // Show the GitHub integration section if the feature is enabled 529 | document.getElementById("config-github-integration").hidden = !config.ENABLE_GITHUB_INTEGRATION 530 | document.getElementById("config-github-pat").hidden = config.ENABLE_GITHUB_INTEGRATION 531 | 532 | for (const modal of [helpModal, configModal]) { 533 | modal.addEventListener("click", (event) => { 534 | if (event.target === modal) { 535 | // Clicked on the modal backdrop 536 | modal.close() 537 | } 538 | }) 539 | } 540 | } 541 | 542 | interface OutputWriter { 543 | write(message: string): void; 544 | finalize(): void; 545 | } 546 | 547 | class PlainOutputWriter implements OutputWriter { 548 | constructor(private element: HTMLElement) { } 549 | 550 | write(message: string) { 551 | this.element.innerText += message 552 | } 553 | finalize(): void {} 554 | } 555 | 556 | /// Highlight (A,B)-(C,D) as a range in the editor 557 | class LocationHighlightingOutputWriter implements OutputWriter { 558 | private buffered: string = "" 559 | constructor(private element: HTMLElement, private editor: monaco.editor.IEditor) {} 560 | 561 | write(message: string) { 562 | this.buffered += message 563 | } 564 | finalize(): void { 565 | const rangePattern = /\((\d+),(\d+)\)-\((\d+),(\d+)\)/g 566 | // Create spans for each range 567 | this.element.innerHTML = "" 568 | let lastEnd = 0 569 | for (const match of this.buffered.matchAll(rangePattern)) { 570 | const [fullMatch, startLine, startColumn, endLine, endColumn] = match 571 | const start = this.buffered.slice(lastEnd, match.index) 572 | const range = this.buffered.slice(match.index, match.index + fullMatch.length) 573 | lastEnd = match.index + fullMatch.length 574 | const span = document.createElement("span") 575 | span.innerText = start 576 | this.element.appendChild(span) 577 | const rangeSpan = document.createElement("span") 578 | rangeSpan.innerText = range 579 | rangeSpan.addEventListener("mouseover", () => { 580 | // Highlight the range in the editor 581 | // NOTE: Monaco's columns are 1-indexed but Ruby's are 0-indexed 582 | const range = new monaco.Range(Number(startLine), Number(startColumn) + 1, Number(endLine), Number(endColumn) + 1) 583 | this.editor.revealRangeInCenter(range, monaco.editor.ScrollType.Smooth) 584 | this.editor.setSelection(range) 585 | }) 586 | rangeSpan.classList.add("plrb-output-range") 587 | this.element.appendChild(rangeSpan) 588 | } 589 | const end = this.buffered.slice(lastEnd) 590 | const span = document.createElement("span") 591 | span.innerText = end 592 | this.element.appendChild(span) 593 | } 594 | } 595 | 596 | export async function init(config: PlayRubyConfig) { 597 | const rubySource = rubySourceFromURL() 598 | const uiState = stateFromURL(); 599 | 600 | const service = new PlayRubyService(config.SERVER_URL) 601 | const tokenBasedDownloader = new TokenBasedArtifactDownloader("ruby/ruby", { 602 | "Authorization": `token ${localStorage.getItem("GITHUB_TOKEN")}` 603 | }) 604 | const downloader = config.ENABLE_GITHUB_INTEGRATION ? service : tokenBasedDownloader 605 | initUI(uiState, config, service); 606 | const { editor, getOptions, getCode } = initEditor(uiState) 607 | const buttonRun = document.getElementById("button-run") 608 | const outputPane = document.getElementById("output") 609 | const actionSelect = document.getElementById("action") as HTMLSelectElement 610 | actionSelect.value = uiState.action 611 | actionSelect.addEventListener("change", () => { 612 | const url = new URL(window.location.href) 613 | url.searchParams.set("action", actionSelect.value) 614 | window.history.replaceState({}, "", url.toString()) 615 | }) 616 | 617 | const setStatus = (status: string) => { 618 | const statusElement = document.getElementById("status") 619 | statusElement.innerText = status 620 | } 621 | const setMetadata = (run: any) => { 622 | const metadataElement = document.getElementById("metadata") as HTMLSpanElement; 623 | const linkElement = (link: string, text: string) => { 624 | const a = document.createElement("a") 625 | a.href = link 626 | a.target = "_blank" 627 | a.innerText = text 628 | return a 629 | } 630 | const commitLink = () => { 631 | const description = `Commit: ${run["head_commit"]["message"].split("\n")[0]} (${run["head_commit"]["id"].slice(0, 7)})` 632 | const commitURL = `https://github.com/ruby/ruby/commit/${run["head_commit"]["id"]}` 633 | return linkElement(commitURL, description) 634 | } 635 | switch (rubySource.type) { 636 | case "github-actions-run": { 637 | const runLink = linkElement(run["html_url"], run["id"]) 638 | metadataElement.appendChild(document.createTextNode(`GitHub Actions run (`)) 639 | metadataElement.appendChild(runLink) 640 | metadataElement.appendChild(document.createTextNode(`) `)) 641 | metadataElement.appendChild(commitLink()) 642 | break 643 | } 644 | case "github-pull-request": { 645 | const prLink = linkElement(`https://github.com/ruby/ruby/pull/${rubySource.prNumber}`, `#${rubySource.prNumber}`) 646 | metadataElement.appendChild(document.createTextNode(`GitHub PR (`)) 647 | metadataElement.appendChild(prLink) 648 | metadataElement.appendChild(document.createTextNode(`) `)) 649 | metadataElement.appendChild(commitLink()) 650 | break 651 | } 652 | case "builtin": 653 | const description = "Built-in Ruby" 654 | break 655 | } 656 | } 657 | 658 | try { 659 | const makeRubyWorker = await initRubyWorkerClass(rubySource, downloader, setStatus, setMetadata) 660 | if (makeRubyWorker == null) { 661 | return 662 | } 663 | const worker = await makeRubyWorker() 664 | const runCode = async (code: string) => { 665 | const selectedAction = actionSelect.value 666 | outputPane.innerText = "" 667 | let options: Options = DEFAULT_OPTIONS 668 | const outputWriter = (selectedAction == "compile" || selectedAction == "syntax" || selectedAction == "syntax+prism") 669 | ? new LocationHighlightingOutputWriter(outputPane, editor) 670 | : new PlainOutputWriter(outputPane) 671 | try { 672 | options = getOptions() 673 | } catch (error) { 674 | outputWriter.write(`Error parsing options: ${error.message}\n`) 675 | return; 676 | } 677 | const mainFile = "main.rb" 678 | const [files, remaining] = splitFile(code) 679 | const codeMap = { [mainFile]: remaining } 680 | for (const [filename, file] of Object.entries(files)) { 681 | // Prepend empty lines to the file content to match the original source line 682 | codeMap[filename] = "\n".repeat(file.sourceLine + 1) + file.content 683 | } 684 | await worker.run(codeMap, mainFile, selectedAction, options, Comlink.proxy((text) => outputWriter.write(text))) 685 | outputWriter.finalize() 686 | } 687 | const run = async () => await runCode(getCode()); 688 | 689 | buttonRun.addEventListener("click", () => run()) 690 | // Ctrl+Enter to run 691 | editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => run()) 692 | 693 | // If the action is not "eval", run the code every time it changes or the action changes 694 | const runOnChange = () => { 695 | if (actionSelect.value !== "eval") { 696 | run() 697 | } 698 | } 699 | editor.onDidChangeModelContent(() => runOnChange()) 700 | actionSelect.addEventListener("change", () => runOnChange()) 701 | } catch (error) { 702 | console.error(error) 703 | setStatus(error.message) 704 | if (error instanceof GitHubAPIError && error.isUnauthorized()) { 705 | const configModal = document.getElementById("modal-config") as HTMLDialogElement 706 | configModal.showModal() 707 | return 708 | } 709 | } 710 | console.log("init") 711 | } 712 | 713 | // @ts-ignore 714 | init({ SERVER_URL: PLAY_RUBY_SERVER_URL, ENABLE_GITHUB_INTEGRATION: true }) 715 | -------------------------------------------------------------------------------- /src/ruby-install.ts: -------------------------------------------------------------------------------- 1 | import { ZipReader } from "@zip.js/zip.js" 2 | import * as tar from "tar-stream" 3 | 4 | export type IFs = { 5 | mkdirSync(path: string, options?: any): void 6 | writeFileSync(path: string, data: any, options?: any): void 7 | } 8 | 9 | export class RubyInstall { 10 | private stripComponents: number 11 | private setStatus: ((status: string) => void) 12 | 13 | constructor(options: { stripComponents: number | null, setStatus: ((status: string) => void) | null }) { 14 | this.stripComponents = options.stripComponents ?? 0 15 | this.setStatus = options.setStatus ?? (() => { }) 16 | } 17 | 18 | async installZip(fs: IFs, zipResponse: Response) { 19 | const zipReader = new ZipReader(zipResponse.body); 20 | const entries = await zipReader.getEntries() 21 | const installTarGz = entries.find((entry) => entry.filename === "install.tar.gz") 22 | if (installTarGz == null) { 23 | throw new Error("No install.tar.gz!?") 24 | } 25 | await this.installTarGz(fs, (writable) => installTarGz.getData(writable)) 26 | } 27 | 28 | async installTarGz(fs: IFs, pipe: (writable: WritableStream) => void) { 29 | const gzipDecompress = new DecompressionStream("gzip") 30 | pipe(gzipDecompress.writable) 31 | await this.installTar(fs, gzipDecompress.readable) 32 | } 33 | 34 | async installTar(fs: IFs, tarStream: ReadableStream) { 35 | const tarExtract = tar.extract() 36 | 37 | this.setStatus("Downloading, unzipping, and untarring...") 38 | // TODO: Figure out proper way to bridge Node.js's stream and Web Streams API 39 | const buffer = await new Response(tarStream).arrayBuffer() 40 | tarExtract.write(Buffer.from(buffer)) 41 | tarExtract.end() 42 | 43 | this.setStatus("Installing...") 44 | 45 | const dataWorks = [] 46 | for await (const entry of tarExtract) { 47 | const header = entry.header; 48 | let path = header.name 49 | if (this.stripComponents > 0) { 50 | const parts = path.split("/") 51 | path = parts.slice(this.stripComponents).join("/") 52 | } 53 | 54 | if (header.type === "directory") { 55 | fs.mkdirSync(path, { recursive: true }) 56 | } else if (header.type === "file") { 57 | const dataWork = new Promise((resolve, reject) => { 58 | const chunks: Uint8Array[] = [] 59 | entry.on("data", (chunk) => { 60 | chunks.push(chunk) 61 | }) 62 | entry.on("end", () => { 63 | const data = Buffer.concat(chunks) 64 | fs.writeFileSync(path, data) 65 | resolve() 66 | }) 67 | entry.on("error", (err) => { 68 | reject(err) 69 | }) 70 | }) 71 | dataWorks.push(dataWork) 72 | } else { 73 | throw new Error(`Unknown entry type ${header.type}`) 74 | } 75 | entry.resume() 76 | } 77 | await Promise.all(dataWorks) 78 | this.setStatus("Installed") 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/ruby.worker.ts: -------------------------------------------------------------------------------- 1 | import { Directory, File, Inode, OpenFile, PreopenDirectory, WASI, wasi } from "@bjorn3/browser_wasi_shim" 2 | import * as Comlink from "comlink" 3 | import { IFs, RubyInstall } from "./ruby-install" 4 | import type { Options } from "./index" 5 | 6 | 7 | type IDir = Pick; 8 | 9 | class WASIFs implements IFs { 10 | public rootContents: Map = new Map() 11 | constructor() { } 12 | 13 | private _getRoot(): IDir { 14 | return { 15 | contents: this.rootContents, 16 | get_entry_for_path: (path) => { 17 | if (path.parts.length === 0) { 18 | return { ret: wasi.ERRNO_NOTSUP, entry: null } 19 | } 20 | let entry = this.rootContents.get(path.parts[0]) 21 | if (entry == null) { 22 | return { ret: wasi.ERRNO_NOENT, entry: null } 23 | } 24 | for (let i = 1; i < path.parts.length; i++) { 25 | if (entry instanceof Directory) { 26 | entry = entry.contents.get(path.parts[i]) 27 | if (entry == null) { 28 | return { ret: wasi.ERRNO_NOENT, entry: null } 29 | } 30 | } else { 31 | return { ret: wasi.ERRNO_NOTDIR, entry: null } 32 | } 33 | } 34 | return { ret: wasi.ERRNO_SUCCESS, entry } 35 | }, 36 | create_entry_for_path: (path, is_dir: boolean) => { 37 | if (is_dir) { 38 | const dir = new Directory(new Map()) 39 | this.rootContents.set(path, dir) 40 | return { ret: wasi.ERRNO_SUCCESS, entry: dir } 41 | } else { 42 | const file = new File([]) 43 | this.rootContents.set(path, file) 44 | return { ret: wasi.ERRNO_SUCCESS, entry: file } 45 | } 46 | } 47 | } 48 | } 49 | 50 | private _getDirectoryAtPath(path: string[]): IDir { 51 | let dir = this._getRoot() 52 | for (const part of path) { 53 | const entry = dir.contents.get(part) 54 | if (entry == null) { 55 | const { entry: newEntry } = dir.create_entry_for_path(part, true) 56 | dir = newEntry as Directory 57 | } else if (entry instanceof Directory) { 58 | dir = entry 59 | } else { 60 | throw new Error(`ENOTDIR: not a directory, open '${path.join("/")}'`) 61 | } 62 | } 63 | return dir 64 | } 65 | 66 | private _splitPath(path: string): string[] { 67 | const parts = path.split("/") 68 | // Remove empty parts, meaning that "/usr//local" becomes ["", "usr", "", "local"] 69 | // and then remove "." because: 70 | // - Our cwd is always "/" 71 | // - "." does not change the path 72 | return parts.filter((part) => part !== "" && part !== ".") 73 | } 74 | 75 | /// This is a shallow clone, so the contents of directories under the root are not cloned 76 | shallowClone(): WASIFs { 77 | const fs = new WASIFs() 78 | fs.rootContents = new Map(this.rootContents) 79 | return fs 80 | } 81 | 82 | // "node:fs"-like APIs 83 | 84 | mkdirSync(path: string, options?: any): void { 85 | const parts = this._splitPath(path) 86 | const recursive = options?.recursive ?? false 87 | 88 | let current = this._getRoot() 89 | 90 | for (const part of parts) { 91 | if (part === "") { 92 | continue 93 | } 94 | const entry = current.contents.get(part) 95 | if (entry == null) { 96 | if (recursive) { 97 | const { entry: newEntry } = current.create_entry_for_path(part, true) 98 | current = newEntry as Directory 99 | } else { 100 | throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`) 101 | } 102 | } else if (entry instanceof Directory) { 103 | current = entry 104 | } else { 105 | throw new Error(`EEXIST: file already exists, mkdir '${path}'`) 106 | } 107 | } 108 | } 109 | 110 | writeFileSync(path: string, data: any, options?: any): void { 111 | const parts = this._splitPath(path) 112 | const dir = this._getDirectoryAtPath(parts.slice(0, parts.length - 1)) 113 | const { entry } = dir.create_entry_for_path(parts[parts.length - 1], false) 114 | const createdFile = entry as File 115 | createdFile.data = data 116 | } 117 | 118 | readFileSync(path: string, options?: any): any { 119 | const parts = this._splitPath(path) 120 | const dir = this._getDirectoryAtPath(parts.slice(0, parts.length - 1)) 121 | const file = dir.contents.get(parts[parts.length - 1]) as File 122 | if (file == null) { 123 | throw new Error(`ENOENT: no such file or directory, open '${path}'`) 124 | } 125 | return file.data 126 | } 127 | 128 | readdirSync(path: string, options?: any): string[] { 129 | const parts = this._splitPath(path) 130 | const dir = this._getDirectoryAtPath(parts) 131 | return Array.from(dir.contents.keys()) 132 | } 133 | } 134 | 135 | const consolePrinter = (log: (fd: number, str: string) => void) => { 136 | let memory: WebAssembly.Memory | undefined = undefined; 137 | let view: DataView | undefined = undefined; 138 | 139 | const decoder = new TextDecoder(); 140 | 141 | return { 142 | addToImports(imports: WebAssembly.Imports): void { 143 | const original = imports.wasi_snapshot_preview1.fd_write as ( 144 | fd: number, 145 | iovs: number, 146 | iovsLen: number, 147 | nwritten: number, 148 | ) => number; 149 | imports.wasi_snapshot_preview1.fd_write = ( 150 | fd: number, 151 | iovs: number, 152 | iovsLen: number, 153 | nwritten: number, 154 | ): number => { 155 | if (fd !== 1 && fd !== 2) { 156 | return original(fd, iovs, iovsLen, nwritten); 157 | } 158 | 159 | if (typeof memory === "undefined" || typeof view === "undefined") { 160 | throw new Error("Memory is not set"); 161 | } 162 | if (view.buffer.byteLength === 0) { 163 | view = new DataView(memory.buffer); 164 | } 165 | 166 | const buffers = Array.from({ length: iovsLen }, (_, i) => { 167 | const ptr = iovs + i * 8; 168 | const buf = view.getUint32(ptr, true); 169 | const bufLen = view.getUint32(ptr + 4, true); 170 | return new Uint8Array(memory.buffer, buf, bufLen); 171 | }); 172 | 173 | let written = 0; 174 | let str = ""; 175 | for (const buffer of buffers) { 176 | str += decoder.decode(buffer); 177 | written += buffer.byteLength; 178 | } 179 | view.setUint32(nwritten, written, true); 180 | 181 | log(fd, str); 182 | 183 | return 0; 184 | }; 185 | }, 186 | setMemory(m: WebAssembly.Memory) { 187 | memory = m; 188 | view = new DataView(m.buffer); 189 | }, 190 | }; 191 | }; 192 | 193 | 194 | export class RubyWorker { 195 | module: WebAssembly.Module; 196 | 197 | constructor(module: WebAssembly.Module, private fs: WASIFs) { 198 | this.module = module 199 | } 200 | 201 | static async create(zipBuffer: ArrayBuffer, stripComponents: number, setStatus: (message: string) => void): Promise { 202 | setStatus("Loading...") 203 | const fs = new WASIFs() 204 | const installer = new RubyInstall({ stripComponents, setStatus }) 205 | await installer.installZip(fs, new Response(zipBuffer)) 206 | const rubyModuleEntry = fs.readFileSync("/usr/local/bin/ruby") 207 | const rubyModule = WebAssembly.compile(rubyModuleEntry as Uint8Array) 208 | setStatus("Ready") 209 | 210 | return Comlink.proxy(new RubyWorker(await rubyModule, fs)) 211 | } 212 | 213 | private _rubyVersion(): string { 214 | const libRubyDir = "/usr/local/lib/ruby" 215 | const libRubyDirContents = this.fs.readdirSync(libRubyDir); 216 | for (const maybeVersion of libRubyDirContents) { 217 | // Find the first directory that contains rbconfig.rb 218 | const versionDir = `${libRubyDir}/${maybeVersion}` 219 | const versionDirContents = this.fs.readdirSync(versionDir); 220 | for (const maybeArch of versionDirContents) { 221 | const archDir = `${versionDir}/${maybeArch}` 222 | try { 223 | const archDirContents = this.fs.readdirSync(archDir); 224 | if (archDirContents.includes("rbconfig.rb")) { 225 | return maybeVersion; 226 | } 227 | } catch (e) { 228 | // Ignore ENODIR errors 229 | } 230 | } 231 | } 232 | console.warn("Could not find Ruby version by looking for rbconfig.rb. Defaulting to 3.3.0"); 233 | return "3.3.0" 234 | } 235 | 236 | async run(code: { [path: string]: string }, mainScriptPath: string, action: string, options: Options, log: (message: string) => void) { 237 | const extraArgs: string[] = options.arguments 238 | switch (action) { 239 | case "eval": break 240 | case "compile": extraArgs.push("--dump=insns"); break 241 | case "syntax": { 242 | const rubyVersion = this._rubyVersion(); 243 | if (rubyVersion.startsWith("3.2.")) { 244 | // 3.2.x and earlier do not have explicit --parser=parse.y 245 | extraArgs.push("--dump=parsetree"); 246 | } else { 247 | // 3.3.x and later have --parser=parse.y 248 | extraArgs.push("--parser=parse.y"); 249 | extraArgs.push("--dump=parsetree"); 250 | } 251 | break 252 | } 253 | case "syntax+prism": { 254 | extraArgs.push("--parser=prism"); 255 | extraArgs.push("--dump=parsetree"); 256 | break 257 | } 258 | default: throw new Error(`Unknown action: ${action}`) 259 | } 260 | 261 | // Build a fresh file system by merging given code files and the Ruby installation 262 | const codeFs = this.fs.shallowClone() 263 | const textEncoder = new TextEncoder() 264 | for (const path in code) { 265 | codeFs.writeFileSync(path, textEncoder.encode(code[path])) 266 | } 267 | const rootContents = codeFs.rootContents 268 | 269 | // Run the Ruby module with the given code 270 | const wasi = new WASI( 271 | ["ruby"].concat(extraArgs).concat([mainScriptPath]), 272 | Object.entries(options.env).map(([key, value]) => `${key}=${value}`), 273 | [ 274 | new OpenFile(new File([])), // stdin 275 | new OpenFile(new File([])), // stdout 276 | new OpenFile(new File([])), // stderr 277 | new PreopenDirectory("/", rootContents), 278 | ], 279 | { 280 | debug: false 281 | } 282 | ) 283 | const imports = { 284 | wasi_snapshot_preview1: wasi.wasiImport, 285 | } 286 | const printer = consolePrinter((fd, str) => { log(str) }) 287 | printer.addToImports(imports) 288 | 289 | const instnace: any = await WebAssembly.instantiate(this.module, imports); 290 | printer.setMemory(instnace.exports.memory); 291 | try { 292 | wasi.start(instnace) 293 | } catch (e) { 294 | log(e) 295 | throw e 296 | } 297 | } 298 | } 299 | 300 | Comlink.expose(RubyWorker) 301 | -------------------------------------------------------------------------------- /src/split-file.test.ts: -------------------------------------------------------------------------------- 1 | import { splitFile } from "./split-file" 2 | import { expect, test } from "vitest" 3 | 4 | test("basic", () => { 5 | const content = `#--- foo 6 | foo 7 | #--- bar 8 | bar 9 | #--- baz 10 | baz` 11 | const [files, remaining] = splitFile(content) 12 | expect(remaining).toEqual("") 13 | expect(files).toEqual({ 14 | foo: { content: "foo\n", sourceLine: 0 }, 15 | bar: { content: "bar\n", sourceLine: 2 }, 16 | baz: { content: "baz\n", sourceLine: 4 }, 17 | }) 18 | }) 19 | 20 | 21 | test("remaining with trailing file", () => { 22 | const content = `main 23 | #--- foo 24 | foo` 25 | const [files, remaining] = splitFile(content) 26 | expect(remaining).toEqual("main\n") 27 | expect(files).toEqual({ 28 | foo: { content: "foo\n", sourceLine: 1 }, 29 | }) 30 | }) 31 | 32 | test("remaining with no files", () => { 33 | const content = `main` 34 | const [files, remaining] = splitFile(content) 35 | expect(remaining).toEqual("main\n") 36 | expect(files).toEqual({}) 37 | }) 38 | -------------------------------------------------------------------------------- /src/split-file.ts: -------------------------------------------------------------------------------- 1 | type FileEntry = { 2 | content: string, 3 | /// The line number in the original source where this file starts. 0-indexed. 4 | sourceLine: number 5 | } 6 | 7 | /// A utility inspired by the LLVM `split-file` tool. 8 | /// This tool takes a file content and splits it into multiple files 9 | /// by regex pattern `^#--- filename` where `filename` is the 10 | /// name of the file to be created. 11 | /// Returns a tuple of the files and the remaining content. 12 | /// See https://reviews.llvm.org/D83834 for the original tool. 13 | function splitFile(content: string): [{ [filename: string]: FileEntry }, string] { 14 | const files: { [filename: string]: FileEntry } = {} 15 | 16 | const lines = content.split("\n") 17 | let currentFile = null 18 | let currentSourceLine = 0 19 | let currentContent = "" 20 | let remaining = "" 21 | 22 | for (const [i, line] of lines.entries()) { 23 | const match = line.match(/^#--- (.+)$/) 24 | if (match != null) { 25 | if (currentFile === null) { 26 | remaining = currentContent 27 | } else { 28 | files[currentFile] = { content: currentContent, sourceLine: currentSourceLine } 29 | } 30 | currentFile = match[1] 31 | currentSourceLine = i 32 | currentContent = "" 33 | } else { 34 | currentContent += line + "\n" 35 | } 36 | } 37 | 38 | if (currentFile === null) { 39 | remaining = currentContent 40 | } else { 41 | files[currentFile] = { content: currentContent, sourceLine: currentSourceLine } 42 | } 43 | 44 | return [files, remaining] 45 | } 46 | 47 | export { splitFile } 48 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "es2015", 5 | "moduleResolution": "Bundler", 6 | "lib": ["es2015", "dom"] 7 | } 8 | } 9 | --------------------------------------------------------------------------------