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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5741995c39bcdce271bdf34cf96cc377d48cfb43abc9705d8beb6f2b49345034
4
- data.tar.gz: 77fee6bec1cafd49ff8fb62b65ca00afeff04e618986b73977b375b13d652ad7
3
+ metadata.gz: e74011a1aa90c2b4a7b339e3ccb0660033e375a6f5b37199c1b429e038af5f77
4
+ data.tar.gz: 9d6e7f7dca48db357712fced13ad4e9e07afd1742208136294feed9b26eb93bd
5
5
  SHA512:
6
- metadata.gz: 7b98cc6279a621f860e0bde96266c11c54913e5331d0a80f4a880ae17caf3b277bd3699dbf5c34913a4376efc792b05b1793ec152933438d4d2a6819789a21cd
7
- data.tar.gz: cf95c627be83f6525fd9a5205ffd253367cb06fc6aed829e9d1f056b1bad192c6a41f983e145f71c18b7d494faf6cc38adf10df0b95752eeb53192e2be6485f5
6
+ metadata.gz: f0b27578c68b7bca6eb81a832732739c90b22835cf1e40b4b0619614a8f1e6de5f04b72b4f75e5b5f0e184c760977387ad9ee479a5fec8d537a25994dab5cc1d
7
+ data.tar.gz: e56a688ab162568fe5ada3a19863e46d6f3ad98b4a61071c48e07ab7d6736c792edaa8a551e849c8e91cdf4f8574e76fa3d2631cd8c5aa1d979da9e83756f7b0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
- ## [0.1.0] - 2026-09-25
3
+ ## [0.3.0] - 2026-09-25
4
4
 
5
- Initial release
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
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2026 Yudai Takada
3
+ Copyright (c) 2026 ydah
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -2,95 +2,129 @@
2
2
 
3
3
  # Webvas
4
4
 
5
- **Run RBGL sketches in a browser with Ruby.**
5
+ **A small drawing machine for Ruby.**
6
6
 
7
- Webvas connects RBGL's software renderer to an `OffscreenCanvas` and drives one frame at a time with `requestAnimationFrame`.
7
+ Run RBGL and Gesso sketches in a browser with CRuby compiled to WebAssembly.
8
8
 
9
- [Live example](https://rbgfx.github.io/webvas/) · [Ruby API](#ruby-api) · [Browser requirements](#browser-requirements)
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
- ## Features
13
+ ## What it does
14
14
 
15
- - RBGL browser backend for RGBA frames and pointer, keyboard, and wheel events
16
- - Frame scheduling through `RBGL::GUI::Window#step`
17
- - Standalone loader for `<script type="text/ruby" data-webvas>`
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
- Add Webvas to the Ruby bundle used to prepare your WebAssembly runtime:
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
- The browser runtime must also include `rbgl` and its dependencies. When using the runtime distributed with the Webvas example, no local build is required.
27
+ Gesso is an optional companion. Add <code>gem "gesso"</code> if you want its 2D sketch DSL.
29
28
 
30
- ## Quick start
29
+ ## Ruby API
31
30
 
32
- Add a canvas, a Ruby script, and the loader to an HTML page:
31
+ ~~~ruby
32
+ require "webvas"
33
+ require "rbgl"
33
34
 
34
- ~~~html
35
- <canvas id="screen" width="320" height="240"></canvas>
36
- <script type="text/ruby" data-webvas>
37
- require "rbgl"
38
- require "webvas"
39
-
40
- window = RBGL::GUI::Window.new(
41
- width: 320,
42
- height: 240,
43
- backend: Webvas::Backend.new(width: 320, height: 240)
44
- )
45
-
46
- Webvas.run(window) do |context, _delta_time|
47
- context.clear
48
- # Bind an RBGL pipeline and draw here.
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
- </script>
51
- <script src="https://rbgfx.github.io/webvas/loader.js"></script>
58
+ end
52
59
  ~~~
53
60
 
54
- The loader reads `assets/webvas.wasm` beside itself by default. Set `data-runtime` on the loader script to use another HTTPS or same-origin runtime URL. Set `data-canvas` on the Ruby script and use the same selector in `Webvas::Backend` when the canvas is not `#screen`.
61
+ To run a WGSL shader, provide its generated source and uniform layout:
55
62
 
56
- ## Ruby API
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
- Create an `RBGL::GUI::Window` with a `Webvas::Backend`, then pass it to `Webvas.run`. The callback receives the RBGL context and elapsed seconds. `Webvas.run` reuses one callback for each animation frame and closes the window when it stops or raises an exception.
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
- `Webvas::Backend` accepts `width`, `height`, `canvas`, `title`, and `pixelated`. The canvas size sets the rendering resolution; CSS can scale its display size. With `pixelated: true`, scaled output uses nearest-neighbor display.
71
+ ## Playground
61
72
 
62
- Pointer coordinates are mapped from the canvas display bounds to rendering pixels. Pointer buttons, keyboard keys, modifier keys, and wheel deltas are converted to RBGL events. Focus the canvas to send keyboard events.
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
- ## Browser requirements
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
- - WebAssembly, module workers, and `OffscreenCanvas`
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
- Ruby runs in a worker without access to the page DOM. The `js` gem exposes APIs available to that worker, so this worker should not be treated as a security sandbox for hostile code. The page's Content Security Policy must allow the worker, runtime URL, and WebAssembly compilation.
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
- Blocking frame loops and `sleep` cannot drive animation in the browser. Use `Webvas.run` to advance the window one frame at a time. WebAssembly threads and compiling native extensions in the browser are not supported.
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
- Ruby source is limited to 32 KiB. Frame pixels use base64 between Ruby and JavaScript; a 320×240 RGBA frame is 307,200 bytes before encoding and 409,600 bytes after encoding.
92
+ See the [loader example](https://rbgfx.github.io/webvas/loader-example.html) for pointer input.
75
93
 
76
- ## Development
94
+ ## CLI
77
95
 
78
- The repository expects adjacent checkouts of `larb`, `rbgl`, and `tessel`.
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
- The Pages workflow builds the WebAssembly runtime, checks the browser loader in Chromium, and deploys the static example.
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
 
@@ -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
  }