webvas 0.3.0 → 0.3.1

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.
data/lib/webvas/cli.rb ADDED
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+ require "socket"
6
+ require "uri"
7
+
8
+ module Webvas
9
+ class CLI
10
+ ROOT = File.expand_path("../..", __dir__)
11
+ CDN = "https://rbgfx.github.io/webvas"
12
+ LIVE_SCRIPT = '(()=>{const s=new EventSource("/__webvas/events");s.onmessage=()=>location.reload()})();'
13
+ CONTENT_TYPES = {
14
+ ".css" => "text/css; charset=utf-8", ".html" => "text/html; charset=utf-8",
15
+ ".js" => "text/javascript; charset=utf-8", ".json" => "application/json",
16
+ ".png" => "image/png", ".svg" => "image/svg+xml", ".wasm" => "application/wasm",
17
+ ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".gif" => "image/gif",
18
+ ".rb" => "text/plain; charset=utf-8", ".txt" => "text/plain; charset=utf-8"
19
+ }.freeze
20
+
21
+ def self.run(arguments = ARGV, out: $stdout, err: $stderr)
22
+ command = arguments.shift
23
+ case command
24
+ when "new" then new_project(arguments, out:)
25
+ when "serve" then serve(arguments, out:)
26
+ when "build" then build(arguments, out:)
27
+ when "--help", "-h", nil then out.puts help; 0
28
+ else raise Error, "unknown command: #{command}\n#{help}"
29
+ end
30
+ rescue Error, OptionParser::ParseError, SystemCallError, Interrupt => error
31
+ err.puts(error.message) unless error.is_a?(Interrupt)
32
+ error.is_a?(Interrupt) ? 0 : 1
33
+ end
34
+
35
+ def self.help
36
+ <<~HELP
37
+ Usage: webvas <command>
38
+
39
+ Commands:
40
+ new NAME Create a browser sketch project
41
+ serve [--root DIR] Serve the current project and reload on changes
42
+ build [-o DIR] Build a static site using the official runtime
43
+ [--runtime custom] Build a runtime from the project's Gemfile
44
+ HELP
45
+ end
46
+
47
+ def self.new_project(arguments, out:)
48
+ name = arguments.shift
49
+ raise Error, "Usage: webvas new NAME" unless name && arguments.empty?
50
+ raise Error, "Project name must be one path component." unless name.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z/) && !%w[. ..].include?(name)
51
+
52
+ destination = File.expand_path(name)
53
+ raise Error, "Directory already exists and is not empty: #{destination}" if File.directory?(destination) && !Dir.empty?(destination)
54
+ raise Error, "Path already exists: #{destination}" if File.exist?(destination) && !File.directory?(destination)
55
+
56
+ FileUtils.mkdir_p(destination)
57
+ html = project_html.gsub("__WEBVAS_LOADER__", "#{CDN}/loader.js")
58
+ .gsub("__WEBVAS_RUNTIME__", "#{CDN}/assets/webvas.wasm")
59
+ files = {
60
+ "index.html" => html,
61
+ "app.rb" => project_source,
62
+ "Gemfile" => project_gemfile
63
+ }
64
+ existing = files.keys.select { |file| File.exist?(File.join(destination, file)) }
65
+ raise Error, "Refusing to overwrite: #{existing.join(', ')}" unless existing.empty?
66
+ files.each { |file, content| File.write(File.join(destination, file), content) }
67
+ out.puts "Created #{destination}"
68
+ 0
69
+ end
70
+
71
+ def self.serve(arguments, out:)
72
+ options = { host: "127.0.0.1", port: 8000, root: Dir.pwd }
73
+ OptionParser.new do |parser|
74
+ parser.on("--host HOST") { |value| options[:host] = value }
75
+ parser.on("-p", "--port PORT", Integer) { |value| options[:port] = value }
76
+ parser.on("--root DIR") { |value| options[:root] = value }
77
+ end.parse!(arguments)
78
+ raise Error, "Unexpected arguments: #{arguments.join(' ')}" unless arguments.empty?
79
+ root = File.realpath(options[:root])
80
+ raise Error, "Project root must be a directory: #{root}" unless File.directory?(root)
81
+ raise Error, "Port must be between 0 and 65535." unless (0..65_535).cover?(options[:port])
82
+
83
+ server = Server.new(root, host: options[:host], port: options[:port])
84
+ out.puts "Serving #{root} at http://#{options[:host]}:#{server.port}"
85
+ server.start
86
+ 0
87
+ ensure
88
+ server&.close
89
+ end
90
+
91
+ def self.build(arguments, out:)
92
+ options = { output: "dist", runtime: "official", root: Dir.pwd }
93
+ OptionParser.new do |parser|
94
+ parser.on("-o", "--output DIR") { |value| options[:output] = value }
95
+ parser.on("--runtime RUNTIME") { |value| options[:runtime] = value }
96
+ parser.on("--root DIR") { |value| options[:root] = value }
97
+ end.parse!(arguments)
98
+ raise Error, "Unexpected arguments: #{arguments.join(' ')}" unless arguments.empty?
99
+ raise Error, "Runtime must be official or custom." unless %w[official custom].include?(options[:runtime])
100
+
101
+ source = File.realpath(options[:root])
102
+ destination = File.expand_path(options[:output], source)
103
+ raise Error, "Build output cannot overwrite the project." if destination == source
104
+ if destination == File.join(source, "assets") || destination.start_with?(File.join(source, "assets") + File::SEPARATOR)
105
+ raise Error, "Build output cannot be inside the project's assets directory."
106
+ end
107
+ raise Error, "Build output cannot contain the project." if source.start_with?(destination + File::SEPARATOR)
108
+ raise Error, "Project is missing index.html or app.rb." unless %w[index.html app.rb].all? { |file| File.file?(File.join(source, file)) }
109
+
110
+ FileUtils.mkdir_p(destination)
111
+ %w[index.html app.rb].each { |file| FileUtils.cp(File.join(source, file), destination) }
112
+ assets = File.join(source, "assets")
113
+ copy_assets(assets, File.join(destination, "assets")) if File.directory?(assets)
114
+ index = File.read(File.join(destination, "index.html"))
115
+ if options[:runtime] == "custom"
116
+ build_custom_runtime(source, File.join(destination, "assets"))
117
+ webvas_assets = File.join(destination, "assets", "webvas")
118
+ FileUtils.mkdir_p(webvas_assets)
119
+ %w[bridge.js loader.js worker.js].each do |file|
120
+ FileUtils.cp(File.join(ROOT, "js", file), webvas_assets)
121
+ end
122
+ index = index.gsub("__WEBVAS_LOADER__", "./assets/webvas/loader.js")
123
+ .gsub("__WEBVAS_RUNTIME__", "./assets/webvas.wasm")
124
+ else
125
+ index = index.gsub("__WEBVAS_LOADER__", "#{CDN}/loader.js")
126
+ .gsub("__WEBVAS_RUNTIME__", "#{CDN}/assets/webvas.wasm")
127
+ end
128
+ index = index.gsub("<!-- webvas:dev -->", "")
129
+ File.write(File.join(destination, "index.html"), index)
130
+ out.puts "Built #{destination} (#{options[:runtime]} runtime)"
131
+ 0
132
+ end
133
+
134
+ def self.copy_assets(source, destination)
135
+ FileUtils.mkdir_p(destination)
136
+ Dir.children(source).each do |name|
137
+ path = File.join(source, name)
138
+ next if File.symlink?(path)
139
+
140
+ FileUtils.cp_r(path, destination)
141
+ end
142
+ end
143
+ private_class_method :copy_assets
144
+
145
+ def self.build_custom_runtime(source, assets)
146
+ gemfile = File.join(source, "Gemfile")
147
+ raise Error, "Custom runtime requires a project Gemfile." unless File.file?(gemfile)
148
+ FileUtils.mkdir_p(assets)
149
+ command = ["bundle", "exec", "rbwasm", "build", "--ruby-version", "4.0",
150
+ "--target", "wasm32-unknown-wasip1", "--build-profile", "full"]
151
+ patch = File.join(ROOT, "patches", "psych-wasi.patch")
152
+ command.concat(["--patch", patch]) if File.file?(patch)
153
+ command.concat(["-o", File.join(assets, "webvas.wasm")])
154
+ success = Dir.chdir(source) { system({ "BUNDLE_GEMFILE" => gemfile }, *command) }
155
+ raise Error, "Custom runtime build failed. Install the project's bundle and check rbwasm." unless success
156
+ end
157
+ private_class_method :build_custom_runtime
158
+
159
+ def self.project_html
160
+ <<~HTML
161
+ <!doctype html>
162
+ <html lang="en">
163
+ <head>
164
+ <meta charset="utf-8">
165
+ <meta name="viewport" content="width=device-width, initial-scale=1">
166
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://rbgfx.github.io https://cdn.jsdelivr.net 'unsafe-eval' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self' https://rbgfx.github.io https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'">
167
+ <title>My Webvas sketch</title>
168
+ <style>body{margin:2rem auto;max-width:760px;padding:0 1rem;background:#101312;color:#e7e9e2;font:16px/1.5 system-ui}canvas{display:block;width:min(100%,640px);height:auto;aspect-ratio:4/3;background:#101827;image-rendering:pixelated}output{display:block;white-space:pre-wrap;overflow-wrap:anywhere}</style>
169
+ </head>
170
+ <body>
171
+ <h1>My Webvas sketch</h1>
172
+ <canvas id="screen" width="320" height="240" aria-label="Ruby sketch"></canvas>
173
+ <script type="text/ruby" data-webvas data-canvas="#screen" src="./app.rb"></script>
174
+ <!-- webvas:dev -->
175
+ <script data-webvas-loader src="__WEBVAS_LOADER__" data-runtime="__WEBVAS_RUNTIME__"></script>
176
+ </body>
177
+ </html>
178
+ HTML
179
+ end
180
+ private_class_method :project_html
181
+
182
+ def self.project_source
183
+ <<~RUBY
184
+ require "gesso"
185
+
186
+ Gesso.run(width: 320, height: 240, runner: :web, pixelated: true) do
187
+ draw do
188
+ background "#101827"
189
+ no_stroke
190
+ fill "#f07850"
191
+ circle width / 2 + Math.sin(frame_count * 0.05) * 60, height / 2, 28
192
+ end
193
+ end
194
+ RUBY
195
+ end
196
+ private_class_method :project_source
197
+
198
+ def self.project_gemfile
199
+ <<~RUBY
200
+ source "https://rubygems.org"
201
+
202
+ gem "ruby_wasm", "~> 2.10.1"
203
+ gem "js", "~> 2.10.1"
204
+ gem "webvas", "~> #{Webvas::VERSION}"
205
+ gem "rbgl"
206
+ gem "gesso"
207
+ gem "rlsl"
208
+ gem "glyphic"
209
+ RUBY
210
+ end
211
+ private_class_method :project_gemfile
212
+
213
+ class Server
214
+ attr_reader :port
215
+
216
+ def initialize(root, host:, port:)
217
+ @root = File.realpath(root)
218
+ @listener = TCPServer.new(host, port)
219
+ @port = @listener.addr[1]
220
+ @threads = []
221
+ end
222
+
223
+ def start
224
+ loop do
225
+ socket = @listener.accept
226
+ @threads << Thread.new(socket) { |client| handle(client) }
227
+ end
228
+ rescue IOError, Errno::EBADF
229
+ nil
230
+ end
231
+
232
+ def close
233
+ @listener.close unless @listener.closed?
234
+ @threads.each { |thread| thread.kill if thread.alive? }
235
+ end
236
+
237
+ private
238
+
239
+ def handle(socket)
240
+ request = socket.gets("\r\n", 8192)
241
+ return unless request
242
+ method, target = request.split(" ", 3)
243
+ header_bytes = 0
244
+ while (line = socket.gets("\r\n")) && line != "\r\n"
245
+ header_bytes += line.bytesize
246
+ return response(socket, 400, "text/plain; charset=utf-8", "Headers too large") if header_bytes > 16_384
247
+ end
248
+ return response(socket, 405, "text/plain", "GET only") unless method == "GET"
249
+ return response(socket, 400, "text/plain; charset=utf-8", "Bad request") unless target
250
+
251
+ uri = URI.parse(target)
252
+ return events(socket) if uri.path == "/__webvas/events"
253
+ return response(socket, 200, "text/javascript; charset=utf-8", LIVE_SCRIPT) if uri.path == "/__webvas/live.js"
254
+
255
+ file = safe_file(uri.path)
256
+ return response(socket, 404, "text/plain; charset=utf-8", "Not found") unless file
257
+ body = File.binread(file)
258
+ if File.basename(file) == "index.html" && body.include?("<!-- webvas:dev -->")
259
+ body = body.sub("<!-- webvas:dev -->", '<script src="/__webvas/live.js"></script>')
260
+ end
261
+ response(socket, 200, CONTENT_TYPES.fetch(File.extname(file), "application/octet-stream"), body)
262
+ rescue URI::InvalidURIError, ArgumentError
263
+ response(socket, 400, "text/plain; charset=utf-8", "Bad request")
264
+ rescue IOError, SystemCallError
265
+ nil
266
+ ensure
267
+ socket.close unless socket.closed?
268
+ end
269
+
270
+ def safe_file(path)
271
+ decoded = URI::RFC2396_PARSER.unescape(path)
272
+ return if decoded.include?("\0")
273
+ parts = decoded.split("/")
274
+ return if parts.any? { |part| part.start_with?(".") }
275
+ return if %w[.bundle build dist node_modules tmp vendor].include?(parts.first)
276
+ candidate = File.expand_path(decoded.delete_prefix("/"), @root)
277
+ return unless candidate.start_with?("#{@root}#{File::SEPARATOR}") || candidate == @root
278
+
279
+ candidate = File.join(candidate, "index.html") if File.directory?(candidate)
280
+ real = File.realpath(candidate)
281
+ return if real != @root && !real.start_with?("#{@root}#{File::SEPARATOR}")
282
+ return unless File.file?(real)
283
+
284
+ real
285
+ rescue Errno::ENOENT, Errno::EACCES
286
+ nil
287
+ end
288
+
289
+ def signature
290
+ hidden = %w[.bundle build dist node_modules tmp vendor]
291
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: @root).filter_map do |name|
292
+ next if name == "." || name == ".." || name.split("/").any? { |part| hidden.include?(part) || part.start_with?(".") }
293
+ path = File.join(@root, name)
294
+ stat = File.stat(path) rescue next
295
+ [name, stat.mtime.to_f, stat.size] if stat.file?
296
+ end.sort
297
+ end
298
+
299
+ def events(socket)
300
+ previous = signature
301
+ socket.write("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nX-Content-Type-Options: nosniff\r\n\r\n: connected\n\n")
302
+ loop do
303
+ sleep 0.5
304
+ current = signature
305
+ if current != previous
306
+ socket.write("data: reload\n\n")
307
+ previous = current
308
+ end
309
+ end
310
+ end
311
+
312
+ def response(socket, status, type, body)
313
+ label = { 200 => "OK", 400 => "Bad Request", 404 => "Not Found", 405 => "Method Not Allowed" }.fetch(status)
314
+ socket.write("HTTP/1.1 #{status} #{label}\r\nContent-Type: #{type}\r\nContent-Length: #{body.bytesize}\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n")
315
+ socket.write(body)
316
+ end
317
+ end
318
+ end
319
+ end
data/lib/webvas/shader.rb CHANGED
@@ -1,36 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  module Webvas
6
4
  class Shader
7
- DEFAULT_LAYOUT = {
8
- fields: {
9
- resolution: { offset: 0, type: :vec2 },
10
- time: { offset: 8, type: :float },
11
- frame: { offset: 12, type: :int },
12
- mouse: { offset: 16, type: :vec4 }
13
- },
14
- size: 32
15
- }.freeze
16
-
17
- attr_reader :source, :canvas, :layout
5
+ attr_reader :wgsl, :canvas
18
6
 
19
- def initialize(source, canvas: "#screen", layout: nil)
20
- @source = String(source)
7
+ def initialize(wgsl, canvas: "#screen")
8
+ @wgsl = String(wgsl)
21
9
  @canvas = String(canvas)
22
- @layout = layout || DEFAULT_LAYOUT
23
- end
24
-
25
- def run(canvas: @canvas, bridge: Bridge.new, &uniforms)
26
- stop
27
- callback = proc { |time| JSON.generate(uniforms ? uniforms.call(Float(time)) : {}) }
28
- @runner = bridge.run_shader(String(canvas), @source, JSON.generate(@layout), callback)
29
- end
30
-
31
- def stop
32
- @runner.stop if @runner
33
- @runner = nil
10
+ raise ArgumentError, "WGSL source cannot be empty" if @wgsl.empty?
34
11
  end
35
12
  end
36
13
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Webvas
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/webvas.rb CHANGED
@@ -37,9 +37,10 @@ module Webvas
37
37
  Bridge.new.show_error(error.message, error.backtrace || [])
38
38
  end
39
39
 
40
- def self.run_shader(shader, canvas: "#gpu", &uniforms)
41
- raise ArgumentError, "shader must be a Webvas::Shader" unless shader.is_a?(Shader)
40
+ def self.run_shader(shader, uniforms: {})
41
+ raise TypeError, "expected a Webvas::Shader" unless shader.is_a?(Shader)
42
42
 
43
- shader.run(canvas:, &uniforms)
43
+ Bridge.new.run_shader(shader.canvas, shader.wgsl, uniforms)
44
44
  end
45
+
45
46
  end
data/sig/webvas.rbs CHANGED
@@ -4,16 +4,22 @@ module Webvas
4
4
  class Error < StandardError
5
5
  end
6
6
 
7
+ class Shader
8
+ attr_reader wgsl: String
9
+ attr_reader canvas: String
10
+ def initialize: (String wgsl, ?canvas: String) -> void
11
+ end
12
+
7
13
  class Bridge
8
14
  def initialize: (?api: untyped) -> void
9
15
  def attach: (String selector, Integer width, Integer height, pixelated: bool) -> untyped
10
16
  def present: (untyped handle, String bytes, Integer width, Integer height) -> untyped
17
+ def run_shader: (String selector, String wgsl, Hash[String, untyped] uniforms) -> untyped
11
18
  def events: (untyped handle) -> Array[Hash[String, untyped]]
12
19
  def resize: (untyped handle, Integer width, Integer height) -> untyped
13
20
  def close: (untyped handle) -> untyped
14
21
  def request_animation_frame: (Proc callback) -> untyped
15
22
  def show_error: (String message, Array[String] backtrace) -> untyped
16
- def run_shader: (String canvas, String source, String layout, Proc uniforms) -> untyped
17
23
  end
18
24
 
19
25
  class Backend < RBGL::GUI::Backend
@@ -44,16 +50,7 @@ module Webvas
44
50
  def request_animation_frame: (Proc callback) -> untyped
45
51
  end
46
52
 
47
- class Shader
48
- attr_reader source: String
49
- attr_reader canvas: String
50
- attr_reader layout: Hash[Symbol, untyped]
51
- def initialize: (String source, ?canvas: String, ?layout: Hash[Symbol, untyped]) -> void
52
- def run: (?canvas: String, ?bridge: Bridge) ?{ (Float) -> Hash[Symbol, untyped] } -> untyped
53
- def stop: () -> nil
54
- end
55
-
56
53
  def self.run: (RBGL::GUI::Window window, ?scheduler: Scheduler, ?on_error: Proc) { (RBGL::Engine::Context, Float) -> void } -> Proc
57
- def self.run_shader: (Shader shader, ?canvas: String) ?{ (Float) -> Hash[Symbol, untyped] } -> untyped
58
54
  def self.report_error: (StandardError error) -> untyped
55
+ def self.run_shader: (Shader shader, ?uniforms: Hash[Symbol | String, untyped]) -> untyped
59
56
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: webvas
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -29,26 +29,6 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '2'
32
- - !ruby/object:Gem::Dependency
33
- name: glaze
34
- requirement: !ruby/object:Gem::Requirement
35
- requirements:
36
- - - ">="
37
- - !ruby/object:Gem::Version
38
- version: 0.1.0
39
- - - "<"
40
- - !ruby/object:Gem::Version
41
- version: '0.2'
42
- type: :runtime
43
- prerelease: false
44
- version_requirements: !ruby/object:Gem::Requirement
45
- requirements:
46
- - - ">="
47
- - !ruby/object:Gem::Version
48
- version: 0.1.0
49
- - - "<"
50
- - !ruby/object:Gem::Version
51
- version: '0.2'
52
32
  - !ruby/object:Gem::Dependency
53
33
  name: base64
54
34
  requirement: !ruby/object:Gem::Requirement
@@ -133,8 +113,7 @@ dependencies:
133
113
  - - "~>"
134
114
  - !ruby/object:Gem::Version
135
115
  version: '3.6'
136
- description: An RBGL canvas backend, WebGPU shader bridge, and browser playground
137
- powered by ruby.wasm.
116
+ description: An RBGL canvas backend and browser loader powered by ruby.wasm.
138
117
  email:
139
118
  - t.yudai92@gmail.com
140
119
  executables:
@@ -145,31 +124,22 @@ files:
145
124
  - CHANGELOG.md
146
125
  - LICENSE.txt
147
126
  - README.md
148
- - docs/performance.md
149
- - docs/security.md
150
- - docs/spikes.md
151
127
  - exe/webvas
152
128
  - js/bridge.js
153
129
  - js/loader.js
130
+ - js/worker.js
154
131
  - lib/webvas.rb
155
132
  - lib/webvas/backend.rb
156
133
  - lib/webvas/bridge.rb
134
+ - lib/webvas/cli.rb
157
135
  - lib/webvas/input.rb
158
136
  - lib/webvas/key_map.rb
159
137
  - lib/webvas/runner.rb
160
138
  - lib/webvas/shader.rb
161
139
  - lib/webvas/version.rb
162
140
  - patches/psych-wasi.patch
163
- - runtime/Gemfile
164
- - runtime/Gemfile.lock
165
- - script/build_site
166
141
  - sig/rbgl.rbs
167
142
  - sig/webvas.rbs
168
- - site/app.js
169
- - site/index.html
170
- - site/loader-example.html
171
- - site/style.css
172
- - site/worker.js
173
143
  homepage: https://github.com/rbgfx/webvas
174
144
  licenses:
175
145
  - MIT
@@ -191,7 +161,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
191
161
  - !ruby/object:Gem::Version
192
162
  version: '0'
193
163
  requirements: []
194
- rubygems_version: 4.0.16
164
+ rubygems_version: 4.0.20
195
165
  specification_version: 4
196
166
  summary: Run Ruby graphics in the browser
197
167
  test_files: []
data/docs/performance.md DELETED
@@ -1,12 +0,0 @@
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 DELETED
@@ -1,9 +0,0 @@
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 DELETED
@@ -1,18 +0,0 @@
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/runtime/Gemfile DELETED
@@ -1,22 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- gem "ruby_wasm", "~> 2.10.1"
4
- gem "js", "~> 2.10.1"
5
-
6
- [
7
- ["webvas", ".."],
8
- ["glaze", "../../glaze"],
9
- ["gesso", "../../gesso"],
10
- ["glyphic", "../../glyphic"],
11
- ["larb", "../../larb"],
12
- ["rbgl", "../../rbgl"],
13
- ["rlsl", "../../rlsl"],
14
- ["tessel", "../../tessel"]
15
- ].each do |name, relative_path|
16
- path = File.expand_path(relative_path, __dir__)
17
- if File.file?(File.join(path, "#{name}.gemspec"))
18
- gem name, path: path
19
- else
20
- gem name
21
- end
22
- end