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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -5
- data/LICENSE.txt +1 -1
- data/README.md +68 -77
- data/exe/webvas +3 -267
- data/js/bridge.js +117 -19
- data/js/loader.js +114 -52
- data/{site → js}/worker.js +2 -30
- data/lib/webvas/backend.rb +8 -1
- data/lib/webvas/bridge.rb +4 -5
- data/lib/webvas/cli.rb +319 -0
- data/lib/webvas/shader.rb +4 -27
- data/lib/webvas/version.rb +1 -1
- data/lib/webvas.rb +4 -3
- data/sig/webvas.rbs +8 -11
- metadata +5 -35
- data/docs/performance.md +0 -12
- data/docs/security.md +0 -9
- data/docs/spikes.md +0 -18
- data/runtime/Gemfile +0 -22
- data/runtime/Gemfile.lock +0 -101
- data/script/build_site +0 -52
- data/site/app.js +0 -466
- data/site/index.html +0 -56
- data/site/loader-example.html +0 -26
- data/site/style.css +0 -89
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
|
-
|
|
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(
|
|
20
|
-
@
|
|
7
|
+
def initialize(wgsl, canvas: "#screen")
|
|
8
|
+
@wgsl = String(wgsl)
|
|
21
9
|
@canvas = String(canvas)
|
|
22
|
-
|
|
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
|
data/lib/webvas/version.rb
CHANGED
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,
|
|
41
|
-
raise
|
|
40
|
+
def self.run_shader(shader, uniforms: {})
|
|
41
|
+
raise TypeError, "expected a Webvas::Shader" unless shader.is_a?(Shader)
|
|
42
42
|
|
|
43
|
-
shader.
|
|
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.
|
|
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
|
|
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.
|
|
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
|