├── .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 |
55 |
56 |
57 |
101 |
102 |
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 |
--------------------------------------------------------------------------------