webvas 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -2
- data/LICENSE.txt +1 -1
- data/README.md +82 -48
- data/docs/performance.md +12 -0
- data/docs/security.md +9 -0
- data/docs/spikes.md +18 -0
- data/exe/webvas +271 -0
- data/js/bridge.js +18 -0
- data/js/loader.js +22 -8
- data/lib/webvas/bridge.rb +5 -0
- data/lib/webvas/shader.rb +36 -0
- data/lib/webvas/version.rb +1 -1
- data/lib/webvas.rb +6 -0
- data/patches/psych-wasi.patch +9 -0
- data/runtime/Gemfile +22 -0
- data/runtime/Gemfile.lock +101 -0
- data/script/build_site +52 -0
- data/sig/webvas.rbs +11 -0
- data/site/app.js +466 -0
- data/site/index.html +56 -0
- data/site/loader-example.html +26 -0
- data/site/style.css +89 -0
- data/{js → site}/worker.js +30 -2
- metadata +40 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e74011a1aa90c2b4a7b339e3ccb0660033e375a6f5b37199c1b429e038af5f77
|
|
4
|
+
data.tar.gz: 9d6e7f7dca48db357712fced13ad4e9e07afd1742208136294feed9b26eb93bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f0b27578c68b7bca6eb81a832732739c90b22835cf1e40b4b0619614a8f1e6de5f04b72b4f75e5b5f0e184c760977387ad9ee479a5fec8d537a25994dab5cc1d
|
|
7
|
+
data.tar.gz: e56a688ab162568fe5ada3a19863e46d6f3ad98b4a61071c48e07ab7d6736c792edaa8a551e849c8e91cdf4f8574e76fa3d2631cd8c5aa1d979da9e83756f7b0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
3
|
+
## [0.3.0] - 2026-09-25
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Add the RBGL browser backend, pointer and keyboard input, and Gesso runner.
|
|
8
|
+
- Add RLSL WebGPU rendering and the shared Glaze shader runner.
|
|
9
|
+
- Add a nine-example playground, compressed sharing, source highlighting on errors, and HTML export.
|
|
10
|
+
- Add `webvas new`, `serve`, and `build` commands with gzip and live reload.
|
|
11
|
+
- Report Ruby callback frame timing and runtime size measurements.
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -2,95 +2,129 @@
|
|
|
2
2
|
|
|
3
3
|
# Webvas
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**A small drawing machine for Ruby.**
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Run RBGL and Gesso sketches in a browser with CRuby compiled to WebAssembly.
|
|
8
8
|
|
|
9
|
-
[
|
|
9
|
+
[Play with Webvas](https://rbgfx.github.io/webvas/) · [Ruby API](#ruby-api) · [Build the playground](#build-the-playground)
|
|
10
10
|
|
|
11
11
|
</div>
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## What it does
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- Runtime published with the example site; no local WebAssembly build is needed to use it
|
|
15
|
+
Webvas connects RBGL's framebuffer to an <code>OffscreenCanvas</code>, schedules frames on a worker, and maps browser pointer and keyboard input to RBGL events. Gesso can use the same backend with <code>runner: :web</code>; Glaze shaders use the shared WebGPU runner.
|
|
16
|
+
|
|
17
|
+
The public playground runs user Ruby in a dedicated worker. The worker has no document or storage APIs; the page transfers only the canvases and input events. It receives the edited source through <code>postMessage</code>.
|
|
19
18
|
|
|
20
19
|
## Install
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
The `0.3.0` gem is not on RubyGems yet. Add the repository to a Gemfile:
|
|
23
22
|
|
|
24
23
|
~~~ruby
|
|
25
|
-
gem "webvas"
|
|
24
|
+
gem "webvas", git: "https://github.com/rbgfx/webvas"
|
|
26
25
|
~~~
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
Gesso is an optional companion. Add <code>gem "gesso"</code> if you want its 2D sketch DSL.
|
|
29
28
|
|
|
30
|
-
##
|
|
29
|
+
## Ruby API
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
~~~ruby
|
|
32
|
+
require "webvas"
|
|
33
|
+
require "rbgl"
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
35
|
+
backend = Webvas::Backend.new(width: 480, height: 320)
|
|
36
|
+
window = RBGL::GUI::Window.new(width: 480, height: 320, backend: backend)
|
|
37
|
+
|
|
38
|
+
Webvas.run(window) do |context, delta_time|
|
|
39
|
+
context.clear
|
|
40
|
+
# Bind an RBGL pipeline and draw here.
|
|
41
|
+
end
|
|
42
|
+
~~~
|
|
43
|
+
|
|
44
|
+
<code>Webvas.run</code> uses one reusable <code>Proc</code> with <code>requestAnimationFrame</code>. The callback receives RBGL's context and the elapsed seconds. Call <code>window.stop</code> to end the loop.
|
|
45
|
+
|
|
46
|
+
Gesso's browser runner is selected per sketch:
|
|
47
|
+
|
|
48
|
+
~~~ruby
|
|
49
|
+
require "gesso"
|
|
50
|
+
|
|
51
|
+
Gesso.run(width: 480, height: 320, runner: :web, pixelated: false) do
|
|
52
|
+
background "#171a18"
|
|
53
|
+
draw do
|
|
54
|
+
background "#171a18"
|
|
55
|
+
fill "#d38a62"
|
|
56
|
+
circle 240, 160, 48
|
|
49
57
|
end
|
|
50
|
-
|
|
51
|
-
<script src="https://rbgfx.github.io/webvas/loader.js"></script>
|
|
58
|
+
end
|
|
52
59
|
~~~
|
|
53
60
|
|
|
54
|
-
|
|
61
|
+
To run a WGSL shader, provide its generated source and uniform layout:
|
|
55
62
|
|
|
56
|
-
|
|
63
|
+
~~~ruby
|
|
64
|
+
shader = Webvas::Shader.new(wgsl_source, layout: uniform_layout)
|
|
65
|
+
shader.run { |time| { gain: 0.75, time: time } }
|
|
66
|
+
# shader.stop when it is no longer needed
|
|
67
|
+
~~~
|
|
57
68
|
|
|
58
|
-
|
|
69
|
+
The WebGPU helper is shared with Glaze's standalone HTML exporter. WebGPU is optional; browsers without a usable adapter report an error in the playground.
|
|
59
70
|
|
|
60
|
-
|
|
71
|
+
## Playground
|
|
61
72
|
|
|
62
|
-
|
|
73
|
+
The editor supports examples, Ctrl/⌘ + Enter, compressed share links, and Ruby or standalone HTML downloads. The HTML includes the sketch and UI; it loads the runtime and playground assets from the Webvas Pages site. The source limit for a run or share link is 32 KiB.
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
The footer reports the recent frame rate and average synchronous callback time. It measures Ruby-side work through command submission; it does not include GPU execution time. See [the runtime measurements](docs/spikes.md) for the tested environment and limits.
|
|
65
76
|
|
|
66
|
-
|
|
67
|
-
- A secure context when loading the page over the network
|
|
68
|
-
- A Ruby runtime built with Webvas, RBGL, and RBGL's dependencies
|
|
77
|
+
### Run Ruby from an HTML page
|
|
69
78
|
|
|
70
|
-
|
|
79
|
+
Add a canvas, a Ruby script, and the loader. The loader defaults to `#screen`, reads the official WASM runtime beside itself, and reports startup or runtime errors in an `<output>` element. Set `data-canvas` on the Ruby script and pass the same selector to `Webvas::Backend` or Gesso when the canvas has another ID.
|
|
71
80
|
|
|
72
|
-
|
|
81
|
+
~~~html
|
|
82
|
+
<canvas id="screen" width="320" height="240"></canvas>
|
|
83
|
+
<script type="text/ruby" data-webvas>
|
|
84
|
+
require "gesso"
|
|
85
|
+
Gesso.run(width: 320, height: 240, runner: :web) do
|
|
86
|
+
draw { background "#123456" }
|
|
87
|
+
end
|
|
88
|
+
</script>
|
|
89
|
+
<script src="https://rbgfx.github.io/webvas/loader.js"></script>
|
|
90
|
+
~~~
|
|
73
91
|
|
|
74
|
-
|
|
92
|
+
See the [loader example](https://rbgfx.github.io/webvas/loader-example.html) for pointer input.
|
|
75
93
|
|
|
76
|
-
##
|
|
94
|
+
## CLI
|
|
77
95
|
|
|
78
|
-
|
|
96
|
+
`webvas new mysketch` creates an `index.html` and `app.rb` that use the official runtime from Webvas Pages. Run `webvas serve` in that directory for gzip and live reload, or export it with `webvas build -o dist/`. The default build keeps the runtime on the CDN. Add gems to the project bundle and use `webvas build --runtime custom -o dist/` to compile a custom WASM runtime.
|
|
97
|
+
|
|
98
|
+
To compile and serve the playground from this checkout:
|
|
79
99
|
|
|
80
100
|
~~~sh
|
|
81
101
|
bundle install
|
|
82
102
|
BUNDLE_GEMFILE=runtime/Gemfile bundle install
|
|
103
|
+
mkdir -p build
|
|
83
104
|
BUNDLE_GEMFILE=runtime/Gemfile bundle exec rbwasm build \
|
|
84
105
|
--ruby-version 4.0 --target wasm32-unknown-wasip1 \
|
|
85
106
|
--build-profile full --patch "$(pwd)/patches/psych-wasi.patch" -o build/webvas.wasm
|
|
86
107
|
ruby script/build_site
|
|
108
|
+
ruby exe/webvas serve public
|
|
109
|
+
~~~
|
|
110
|
+
|
|
111
|
+
The runtime Gemfile uses the adjacent rbgfx source checkouts because Larb contains a native extension that the WASI build must compile. The build prints raw and deflate sizes so the deployed payload can be checked.
|
|
112
|
+
|
|
113
|
+
## Requirements
|
|
114
|
+
|
|
115
|
+
- CRuby 3.1 or later for the gem and CLI.
|
|
116
|
+
- WebAssembly, module workers, and <code>OffscreenCanvas</code> for the playground.
|
|
117
|
+
- A secure context for WebGPU. GPU support depends on the browser and device.
|
|
118
|
+
|
|
119
|
+
The browser worker does not expose the page DOM or browser storage to Ruby. Source typed in the editor is passed to the worker with a message; a project build can explicitly load a same-site sketch with the <code>?source=sketch.rb</code> query. Share links include compressed source in the URL fragment.
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
~~~sh
|
|
87
124
|
bundle exec rake verify
|
|
88
|
-
npm ci
|
|
89
|
-
npx playwright install chromium
|
|
90
|
-
npm run test:browser
|
|
91
125
|
~~~
|
|
92
126
|
|
|
93
|
-
|
|
127
|
+
This runs RuboCop, the test-unit suite with an 85% coverage floor, RBS validation, and JavaScript syntax checks. The Pages workflow builds the WASI runtime and deploys the static editor.
|
|
94
128
|
|
|
95
129
|
## License
|
|
96
130
|
|
data/docs/performance.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Browser performance
|
|
2
|
+
|
|
3
|
+
Baseline collected on 2026-09-25 with the full Ruby 4.0 WASI runtime, Playwright 1.63.0, and Chromium 1243 on macOS. Run `npm run test:browser` to print the current measurements in the E2E log.
|
|
4
|
+
|
|
5
|
+
| Workload | Mean frame rate | Synchronous callback time |
|
|
6
|
+
|---|---:|---:|
|
|
7
|
+
| Default Gesso orbit | 44.0–45.2 fps | 4.29–4.71 ms |
|
|
8
|
+
| 320×240 RGBA base64 transfer + canvas presentation (three runs) | 52.7–53.3 fps | 1.17–1.31 ms |
|
|
9
|
+
|
|
10
|
+
The callback timer covers Ruby frame work, the base64 bridge call, and synchronous canvas presentation. It excludes GPU execution and network/runtime startup. These are single local Chromium runs, not cross-device performance guarantees. The transfer uses 307,200 raw bytes per frame and 409,600 base64 bytes.
|
|
11
|
+
|
|
12
|
+
Runtime artifact: 62,127,849 bytes raw, 18,842,812 bytes DEFLATE level 9, and 13,368,588 bytes Brotli quality 11. In a one-minute Chromium run, Ruby `GC.stat[:heap_live_slots]` rose from 18,228 to 33,275 across 81 samples and stayed below the test's 2× growth bound. This records Ruby live slots, not browser or JavaScript heap bytes, and is one local run. Startup, Firefox/Safari, and CRuby-versus-WASM timings remain unmeasured.
|
data/docs/security.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Playground security boundary
|
|
2
|
+
|
|
3
|
+
Ruby runs in a module worker created from a Blob. This makes the worker inherit the page's Content Security Policy. The worker receives only two transferred canvases and user input events; it has no DOM, cookie, or storage APIs. The policy allows the worker to connect only to the same site and embedded image data. `'unsafe-eval'` is required by the `js` gem's Ruby-to-JavaScript bridge; WebAssembly compilation separately requires `'wasm-unsafe-eval'`.
|
|
4
|
+
|
|
5
|
+
The public playground is hosted at `rbgfx.github.io/webvas`, which shares the `rbgfx.github.io` origin with other GitHub Pages projects owned by that account. A separate repository does not create a separate browser origin. Use a separately controlled domain if sketches need origin isolation from other hosted projects.
|
|
6
|
+
|
|
7
|
+
The parent validates that status messages come from its active worker and renders messages with textContent. User source is passed as worker data and is never inserted into HTML or JavaScript source. Share links are explicit and keep compressed source in the URL fragment. The loader rejects source over 32 KiB and caps decompressed data before allocation. The worker is an isolation boundary, not a hardened sandbox: Ruby code can use the `js` gem to execute JavaScript with the worker's available APIs. Do not use it to run hostile code when a security sandbox is required.
|
|
8
|
+
|
|
9
|
+
WebGPU is optional. The W3C API exposes it in secure Window and Worker contexts; browser support and hardware availability still vary. The playground reports adapter, device, and shader errors without granting the worker access to the page DOM.
|
data/docs/spikes.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Browser runtime measurements
|
|
2
|
+
|
|
3
|
+
Measured on 2026-09-25 from the local `wasm32-unknown-wasip1` full-profile build. The runtime uses Ruby 4.0, `ruby_wasm` 2.10.1, and `js` 2.10.1.
|
|
4
|
+
|
|
5
|
+
| Check | Result |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `webvas` name availability | RubyGems API returned HTTP 404 on the measurement date. |
|
|
8
|
+
| Larb native extension | Builds into the WASI runtime; browser test verifies `Larb::Vec3` arithmetic. |
|
|
9
|
+
| Prism and RLSL | RLSL compiles in the WASM runtime; all three WGSL examples render through WebGPU in Chromium. |
|
|
10
|
+
| Pixel transfer | The base64 path renders exact RGBA samples (`[18, 52, 86, 255]`) at 320×240 and 480×320. A 320×240 frame is 307,200 bytes and its base64 representation is 409,600 bytes. Chromium reports per-frame synchronous callback time in the E2E log; see `performance.md` for the recorded run. |
|
|
11
|
+
| Frame scheduling | The reusable `requestAnimationFrame` callback runs the Gesso and RBGL examples. A one-minute Chromium run collected 81 Ruby `GC.stat[:heap_live_slots]` samples; the median rose from 18,228 at the start to 33,275 at the end. |
|
|
12
|
+
| Ruby speed | CRuby-versus-WASM rendering and math timings were not measured. |
|
|
13
|
+
| Runtime size | 62,127,849 bytes raw; 18,842,812 bytes with zlib DEFLATE level 9; 13,368,588 bytes with Brotli quality 11. |
|
|
14
|
+
| Startup/network time | Not isolated from browser setup, cache state, and the local server; no loading-time claim is made. |
|
|
15
|
+
|
|
16
|
+
The implementation uses transfer method A (base64). Method B (reading Ruby's linear memory from JavaScript through a native extension) remains unimplemented and unmeasured; a supported memory-view path has not been established. The measured runtime size is large, so Pages and the development server should keep compression enabled. The absolute size still exceeds the plan's original five-second first-load target on slower connections.
|
|
17
|
+
|
|
18
|
+
The one-minute result measures Ruby live slots, not JavaScript or process heap bytes, and is a single local run; it does not establish a bound for every workload. The browser checks used Playwright 1.63.0 and Chromium. Firefox, Safari, and mobile browsers were not manually verified. SwiftShader is enabled for Linux CI; the local macOS run used the host GPU. These results confirm functionality, not the plan's frame-rate targets.
|
data/exe/webvas
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "cgi/escape"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "socket"
|
|
8
|
+
require "stringio"
|
|
9
|
+
require "uri"
|
|
10
|
+
require "time"
|
|
11
|
+
require "zlib"
|
|
12
|
+
require_relative "../lib/webvas/version"
|
|
13
|
+
|
|
14
|
+
module Webvas
|
|
15
|
+
module CLI
|
|
16
|
+
STARTER = <<~'RUBY'
|
|
17
|
+
require "gesso"
|
|
18
|
+
|
|
19
|
+
Gesso.run(width: 480, height: 320, runner: :web) do
|
|
20
|
+
background "#171a18"
|
|
21
|
+
draw do
|
|
22
|
+
background "#171a18"
|
|
23
|
+
fill "#d38a62"
|
|
24
|
+
circle 240, 160, 48
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
RUBY
|
|
28
|
+
PLAYGROUND_URL = "https://rbgfx.github.io/webvas"
|
|
29
|
+
RUNTIME_URL = "#{PLAYGROUND_URL}/assets/webvas.wasm"
|
|
30
|
+
RELOAD_SCRIPT = <<~'JS'.freeze
|
|
31
|
+
(() => {
|
|
32
|
+
const directory = new URL(".", location.href);
|
|
33
|
+
const source = new URLSearchParams(location.search).get("source");
|
|
34
|
+
const sourceUrl = source && new URL(source, location.href);
|
|
35
|
+
const targets = [location.pathname];
|
|
36
|
+
if (sourceUrl && sourceUrl.origin === location.origin && sourceUrl.pathname.startsWith(directory.pathname)) {
|
|
37
|
+
targets.push(sourceUrl.pathname);
|
|
38
|
+
}
|
|
39
|
+
let previous;
|
|
40
|
+
setInterval(async () => {
|
|
41
|
+
const values = await Promise.all(targets.map(async path => {
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(path, { method: "HEAD", cache: "no-store" });
|
|
44
|
+
return response.headers.get("ETag");
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}));
|
|
49
|
+
const current = values.join("|");
|
|
50
|
+
if (previous && current !== previous) location.reload();
|
|
51
|
+
previous = current;
|
|
52
|
+
}, 750);
|
|
53
|
+
})();
|
|
54
|
+
JS
|
|
55
|
+
|
|
56
|
+
module_function
|
|
57
|
+
|
|
58
|
+
def run(arguments)
|
|
59
|
+
case arguments.shift
|
|
60
|
+
when "new" then create(arguments.shift || abort("Usage: webvas new PATH"))
|
|
61
|
+
when "build" then build(arguments)
|
|
62
|
+
when "serve" then serve(arguments)
|
|
63
|
+
when "--version", "-v" then puts Webvas::VERSION
|
|
64
|
+
else
|
|
65
|
+
abort "Usage: webvas {new PATH|build [PROJECT] [-o DIR] [--runtime cdn|custom]|serve [DIRECTORY] [--port PORT]}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def create(path)
|
|
70
|
+
project = File.expand_path(path)
|
|
71
|
+
index = File.read(File.expand_path("../site/index.html", __dir__))
|
|
72
|
+
index.gsub!('href="./style.css"', "href=\"#{PLAYGROUND_URL}/style.css\"")
|
|
73
|
+
index.gsub!('src="./app.js"', "src=\"#{PLAYGROUND_URL}/app.js\"")
|
|
74
|
+
index.gsub!('content="./assets/webvas.wasm"', "content=\"#{RUNTIME_URL}\"")
|
|
75
|
+
index.sub!("<title>Webvas — Ruby, in motion</title>", "<title>#{CGI.escapeHTML(File.basename(project))} · Webvas</title>")
|
|
76
|
+
files = {
|
|
77
|
+
"index.html" => index,
|
|
78
|
+
"app.rb" => STARTER,
|
|
79
|
+
"Gemfile" => <<~GEMFILE,
|
|
80
|
+
source "https://rubygems.org"
|
|
81
|
+
|
|
82
|
+
gem "gesso"
|
|
83
|
+
gem "webvas", git: "https://github.com/rbgfx/webvas"
|
|
84
|
+
gem "ruby_wasm", "~> 2.10.1"
|
|
85
|
+
gem "js", "~> 2.10.1"
|
|
86
|
+
GEMFILE
|
|
87
|
+
"README.md" => <<~README,
|
|
88
|
+
# #{File.basename(project)}
|
|
89
|
+
|
|
90
|
+
Edit app.rb, then serve the project with Webvas:
|
|
91
|
+
|
|
92
|
+
webvas serve
|
|
93
|
+
|
|
94
|
+
Open http://127.0.0.1:8000/?source=app.rb. Build a static site with `webvas build -o dist/`.
|
|
95
|
+
To compile a custom runtime, add gems to the Gemfile, run `bundle install`, then use
|
|
96
|
+
`webvas build --runtime custom -o dist/`.
|
|
97
|
+
README
|
|
98
|
+
".gitignore" => "/.webvas/\n/public/\n"
|
|
99
|
+
}
|
|
100
|
+
existing = files.keys.select { |name| File.exist?(File.join(project, name)) }
|
|
101
|
+
abort "Already exists: #{existing.join(", ")}" unless existing.empty?
|
|
102
|
+
|
|
103
|
+
FileUtils.mkdir_p(project)
|
|
104
|
+
files.each { |name, contents| File.write(File.join(project, name), contents) }
|
|
105
|
+
puts "Created #{project}/index.html and #{project}/app.rb"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build(arguments)
|
|
109
|
+
project = Dir.pwd
|
|
110
|
+
project_argument_seen = false
|
|
111
|
+
destination = nil
|
|
112
|
+
runtime = "cdn"
|
|
113
|
+
until arguments.empty?
|
|
114
|
+
argument = arguments.shift
|
|
115
|
+
case argument
|
|
116
|
+
when "-o", "--output" then destination = arguments.shift || abort("Missing path after #{argument}")
|
|
117
|
+
when "--runtime" then runtime = arguments.shift || abort("Missing runtime after --runtime")
|
|
118
|
+
when "--help", "-h" then abort "Usage: webvas build [PROJECT] [-o DIRECTORY] [--runtime cdn|custom]"
|
|
119
|
+
else
|
|
120
|
+
if !project_argument_seen && !argument.start_with?("-")
|
|
121
|
+
project = File.expand_path(argument)
|
|
122
|
+
project_argument_seen = true
|
|
123
|
+
else
|
|
124
|
+
abort "Unexpected argument: #{argument}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
abort "Runtime must be cdn or custom" unless %w[cdn custom].include?(runtime)
|
|
129
|
+
|
|
130
|
+
root = File.expand_path("..", __dir__)
|
|
131
|
+
destination = File.expand_path(destination || "public", project)
|
|
132
|
+
wasm = nil
|
|
133
|
+
runtime_url = RUNTIME_URL
|
|
134
|
+
if runtime == "custom"
|
|
135
|
+
wasm = File.join(project, ".webvas", "build", "webvas.wasm")
|
|
136
|
+
gemfile = File.join(project, "Gemfile")
|
|
137
|
+
runtime_gemfile = File.join(project, "runtime", "Gemfile")
|
|
138
|
+
project_gemfile = File.file?(gemfile) && File.read(gemfile).match?(/^\s*gem\s+["']ruby_wasm["']/)
|
|
139
|
+
gemfile = if File.file?(runtime_gemfile)
|
|
140
|
+
runtime_gemfile
|
|
141
|
+
elsif project_gemfile
|
|
142
|
+
gemfile
|
|
143
|
+
else
|
|
144
|
+
File.join(root, "runtime", "Gemfile")
|
|
145
|
+
end
|
|
146
|
+
FileUtils.mkdir_p(File.dirname(wasm))
|
|
147
|
+
env = { "BUNDLE_GEMFILE" => gemfile }
|
|
148
|
+
success = system(env, "bundle", "exec", "rbwasm", "build", "--ruby-version", "4.0",
|
|
149
|
+
"--target", "wasm32-unknown-wasip1", "--build-profile", "full",
|
|
150
|
+
"--patch", File.join(root, "patches", "psych-wasi.patch"), "-o", wasm,
|
|
151
|
+
chdir: project)
|
|
152
|
+
abort "ruby.wasm build failed" unless success
|
|
153
|
+
runtime_url = "./assets/webvas.wasm"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
command = [RbConfig.ruby, File.join(root, "script", "build_site"), destination, runtime_url]
|
|
157
|
+
command << wasm if wasm
|
|
158
|
+
success = system(*command)
|
|
159
|
+
abort "Playground build failed" unless success
|
|
160
|
+
app = File.join(project, "app.rb")
|
|
161
|
+
FileUtils.cp(app, File.join(destination, "app.rb")) if File.file?(app)
|
|
162
|
+
puts "Open http://127.0.0.1:8000/?source=app.rb" if File.file?(app)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def serve(arguments)
|
|
166
|
+
port = 8000
|
|
167
|
+
directory = nil
|
|
168
|
+
until arguments.empty?
|
|
169
|
+
argument = arguments.shift
|
|
170
|
+
if argument == "--port"
|
|
171
|
+
port = Integer(arguments.shift || abort("Missing port after --port"))
|
|
172
|
+
elsif directory.nil?
|
|
173
|
+
directory = argument
|
|
174
|
+
else
|
|
175
|
+
abort "Usage: webvas serve [DIRECTORY] [--port PORT]"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
abort "Port must be between 1 and 65535" unless port.between?(1, 65_535)
|
|
179
|
+
|
|
180
|
+
serve_static(File.expand_path(directory || Dir.pwd), port)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def serve_static(directory, port)
|
|
184
|
+
root = File.realpath(directory)
|
|
185
|
+
server = TCPServer.new("127.0.0.1", port)
|
|
186
|
+
puts "Serving #{root} at http://127.0.0.1:#{port}/"
|
|
187
|
+
loop do
|
|
188
|
+
client = server.accept
|
|
189
|
+
Thread.new(client, root) { |socket, base| handle_request(socket, base) }
|
|
190
|
+
end
|
|
191
|
+
ensure
|
|
192
|
+
server&.close
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def handle_request(socket, root)
|
|
196
|
+
request = socket.gets("\n", 8192)&.split
|
|
197
|
+
return unless request
|
|
198
|
+
|
|
199
|
+
method, target = request
|
|
200
|
+
headers = {}
|
|
201
|
+
while (line = socket.gets("\n", 8192)) && line != "\r\n" && line != "\n"
|
|
202
|
+
name, value = line.split(":", 2)
|
|
203
|
+
headers[name.downcase] = value.to_s.strip if value
|
|
204
|
+
end
|
|
205
|
+
unless %w[GET HEAD].include?(method)
|
|
206
|
+
return response(socket, 405, "Method not allowed", "text/plain", "Method not allowed")
|
|
207
|
+
end
|
|
208
|
+
path = URI::RFC2396_Parser.new.unescape(URI.parse(target).path)
|
|
209
|
+
if path == "/__webvas_reload.js"
|
|
210
|
+
return response(socket, 200, "OK", "text/javascript", RELOAD_SCRIPT, "Cache-Control" => "no-store")
|
|
211
|
+
end
|
|
212
|
+
candidate = File.realpath(File.join(root, path.delete_prefix("/")))
|
|
213
|
+
candidate = File.realpath(File.join(candidate, "index.html")) if File.directory?(candidate)
|
|
214
|
+
unless candidate.start_with?("#{root}/") || candidate == root
|
|
215
|
+
return response(socket, 404, "Not found", "text/plain", "Not found")
|
|
216
|
+
end
|
|
217
|
+
return response(socket, 404, "Not found", "text/plain", "Not found") unless File.file?(candidate)
|
|
218
|
+
|
|
219
|
+
type = { ".css" => "text/css", ".html" => "text/html", ".js" => "text/javascript",
|
|
220
|
+
".json" => "application/json", ".wasm" => "application/wasm", ".rb" => "text/plain" }
|
|
221
|
+
content_type = type.fetch(File.extname(candidate), "application/octet-stream")
|
|
222
|
+
content_type = "#{content_type}; charset=utf-8" if content_type.start_with?("text/", "application/json")
|
|
223
|
+
body = File.binread(candidate)
|
|
224
|
+
if content_type.start_with?("text/html")
|
|
225
|
+
hook = '<script src="/__webvas_reload.js" defer></script>'
|
|
226
|
+
body.sub!(%r{</body>}i) { |closing| "#{hook}#{closing}" } || body.concat(hook)
|
|
227
|
+
end
|
|
228
|
+
response_headers = {
|
|
229
|
+
"Content-Type" => content_type,
|
|
230
|
+
"Last-Modified" => File.mtime(candidate).httpdate,
|
|
231
|
+
"ETag" => %Q("#{File.size(candidate).to_s(16)}-#{File.mtime(candidate).to_f}"),
|
|
232
|
+
"Cache-Control" => File.extname(candidate) == ".wasm" ? "public, max-age=86400" : "no-store",
|
|
233
|
+
"X-Content-Type-Options" => "nosniff"
|
|
234
|
+
}
|
|
235
|
+
response_headers["Vary"] = "Accept-Encoding" if headers.fetch("accept-encoding", "").include?("gzip")
|
|
236
|
+
if gzip_accepted?(headers) && body.bytesize > 1024
|
|
237
|
+
buffer = StringIO.new("".b)
|
|
238
|
+
Zlib::GzipWriter.wrap(buffer) { |gzip| gzip.write(body) }
|
|
239
|
+
body = buffer.string
|
|
240
|
+
response_headers["Content-Encoding"] = "gzip"
|
|
241
|
+
end
|
|
242
|
+
response_headers["Content-Length"] = body.bytesize
|
|
243
|
+
socket.write("HTTP/1.1 200 OK\r\n#{response_headers.map { |name, value| "#{name}: #{value}" }.join("\r\n")}\r\nConnection: close\r\n\r\n")
|
|
244
|
+
socket.write(body) unless method == "HEAD"
|
|
245
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN, Errno::ECONNABORTED
|
|
246
|
+
nil
|
|
247
|
+
rescue ArgumentError, Errno::ENOENT, URI::InvalidURIError
|
|
248
|
+
response(socket, 404, "Not found", "text/plain", "Not found")
|
|
249
|
+
ensure
|
|
250
|
+
socket.close
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def response(socket, code, reason, type, body, headers = {})
|
|
254
|
+
response_headers = {
|
|
255
|
+
"Content-Type" => "#{type}; charset=utf-8",
|
|
256
|
+
"Content-Length" => body.bytesize,
|
|
257
|
+
"Connection" => "close"
|
|
258
|
+
}.merge(headers)
|
|
259
|
+
socket.write("HTTP/1.1 #{code} #{reason}\r\n#{response_headers.map { |name, value| "#{name}: #{value}" }.join("\r\n")}\r\n\r\n#{body}")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def gzip_accepted?(headers)
|
|
263
|
+
headers.fetch("accept-encoding", "").split(",").any? do |encoding|
|
|
264
|
+
name, *parameters = encoding.strip.split(";")
|
|
265
|
+
name == "gzip" && parameters.none? { |parameter| parameter.strip.match?(/\Aq=0(?:\.0*)?\z/) }
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
Webvas::CLI.run(ARGV) if $PROGRAM_NAME == __FILE__
|
data/js/bridge.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
const handle = { id: nextId++, canvas, context, events: pendingEvents.splice(0) };
|
|
21
21
|
globalThis.postMessage({ type: "webvas:pixelated", selector, enabled: pixelated });
|
|
22
22
|
canvases.set(handle.id, handle);
|
|
23
|
+
globalThis.postMessage({ type: "webvas:size", width, height });
|
|
23
24
|
return handle.id;
|
|
24
25
|
},
|
|
25
26
|
|
|
@@ -45,6 +46,19 @@
|
|
|
45
46
|
},
|
|
46
47
|
|
|
47
48
|
pushEvent(event) {
|
|
49
|
+
const canvas = canvases.values().next().value?.canvas || globalThis.WebvasCanvases?.["#screen"];
|
|
50
|
+
if (canvas && event.clientX !== undefined) {
|
|
51
|
+
const width = Number(event.rectWidth);
|
|
52
|
+
const height = Number(event.rectHeight);
|
|
53
|
+
const x = width > 0 ? Math.floor((event.clientX - event.left) * canvas.width / width) : 0;
|
|
54
|
+
const y = height > 0 ? Math.floor((event.clientY - event.top) * canvas.height / height) : 0;
|
|
55
|
+
const mouse = globalThis.WebvasMouse || [0, 0, 0, 0];
|
|
56
|
+
mouse[0] = Math.max(0, Math.min(canvas.width - 1, x));
|
|
57
|
+
mouse[1] = canvas.height - 1 - Math.max(0, Math.min(canvas.height - 1, y));
|
|
58
|
+
if (event.type === "pointerdown") [mouse[2], mouse[3]] = [mouse[0], mouse[1]];
|
|
59
|
+
if (event.type === "pointerup") [mouse[2], mouse[3]] = [-Math.abs(mouse[2]), -Math.abs(mouse[3])];
|
|
60
|
+
globalThis.WebvasMouse = mouse;
|
|
61
|
+
}
|
|
48
62
|
if (!canvases.size) {
|
|
49
63
|
if (pendingEvents.length === eventLimit) pendingEvents.shift();
|
|
50
64
|
pendingEvents.push(event);
|
|
@@ -65,6 +79,10 @@
|
|
|
65
79
|
canvases.delete(id);
|
|
66
80
|
},
|
|
67
81
|
|
|
82
|
+
shaderMode(canvas) {
|
|
83
|
+
globalThis.postMessage({ type: "webvas:mode", canvas });
|
|
84
|
+
},
|
|
85
|
+
|
|
68
86
|
showError(message, backtrace) {
|
|
69
87
|
globalThis.postMessage({ type: "webvas:error", message, backtrace });
|
|
70
88
|
}
|