webvas 0.1.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 +17 -0
- data/README.md +72 -47
- data/exe/webvas +7 -0
- data/js/bridge.js +117 -1
- data/js/loader.js +109 -33
- data/lib/webvas/backend.rb +8 -1
- data/lib/webvas/bridge.rb +4 -0
- data/lib/webvas/cli.rb +319 -0
- data/lib/webvas/shader.rb +13 -0
- data/lib/webvas/version.rb +1 -1
- data/lib/webvas.rb +7 -0
- data/patches/psych-wasi.patch +9 -0
- data/sig/webvas.rbs +8 -0
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a2023d01aad96f20ed9938d18ddedf254edd56ba69c2dc0383a84d00c2efd03
|
|
4
|
+
data.tar.gz: 4ad4fbd5919583226a281186a717d59eb038a0d1aac25e79716ccd8cc5775fb9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 034c1b98ebf7a2607d8b985c21e63394ccca5b228bf99fc39dc74bb710ea6ae4e72b2ae13968e7b8108f3f3a71280c0119ab025290274d1b8b877b3bba3aeb78
|
|
7
|
+
data.tar.gz: affd813bbbbf4ab437bfa794c5d56dd54187c27e4c6315bec9ad76b40bde5f9fae4dfa6941641e668f56f8a32506c40efe9c0e7983a5b5da32206a03209f1439
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.1] - 2026-09-25
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Build the current WebAssembly runtime before browser tests and Pages deployment.
|
|
8
|
+
- Include the CLI executable and runtime build inputs in the gem.
|
|
9
|
+
- Require a Gesso release that supports browser sketches.
|
|
10
|
+
|
|
11
|
+
## [0.3.0] - 2026-09-25
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Gesso sketches and RLSL WGSL shaders in the browser runtime.
|
|
16
|
+
- An editable playground with example modes, compressed share URLs, and standalone HTML downloads.
|
|
17
|
+
- `webvas new`, `serve`, and `build` commands, including custom WebAssembly runtime builds.
|
|
18
|
+
- Worker reset between runs, Ruby error reporting, and frame performance measurements.
|
|
19
|
+
|
|
3
20
|
## [0.1.0] - 2026-09-25
|
|
4
21
|
|
|
5
22
|
Initial release
|
data/README.md
CHANGED
|
@@ -2,80 +2,107 @@
|
|
|
2
2
|
|
|
3
3
|
# Webvas
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Write Ruby graphics sketches and run them in a browser.**
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
[Live example](https://rbgfx.github.io/webvas/) · [Ruby API](#ruby-api) · [Browser requirements](#browser-requirements)
|
|
7
|
+
[Playground](https://rbgfx.github.io/webvas/) · [Ruby API](#ruby-api) · [CLI](#cli) · [Security](#security)
|
|
10
8
|
|
|
11
9
|
</div>
|
|
12
10
|
|
|
11
|
+
Webvas connects RBGL's software renderer to an `OffscreenCanvas` and advances one frame at a time with `requestAnimationFrame`. The playground includes RBGL, Gesso, and RLSL examples, shareable source links, and single-file HTML downloads.
|
|
12
|
+
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
|
-
- RBGL
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
15
|
+
- RBGL canvas backend with pointer, keyboard, and wheel input
|
|
16
|
+
- Gesso sketch runner and RLSL WGSL compute shaders
|
|
17
|
+
- Fresh Ruby worker for each playground run, with visible errors and frame measurements
|
|
18
|
+
- Deflate-compressed source in URL fragments; source is not sent to a Webvas service
|
|
19
|
+
- `webvas new`, `serve`, and `build` for standalone sketch projects
|
|
20
|
+
- Browser runtime built with Webvas, RBGL, Tessel, Gesso, Glyphic, and RLSL
|
|
19
21
|
|
|
20
|
-
##
|
|
22
|
+
## Quick start
|
|
21
23
|
|
|
22
|
-
Add
|
|
24
|
+
Add the loader and a Ruby source file to an HTML page:
|
|
25
|
+
|
|
26
|
+
~~~html
|
|
27
|
+
<canvas id="screen" width="320" height="240"></canvas>
|
|
28
|
+
<script type="text/ruby" data-webvas data-canvas="#screen" src="app.rb"></script>
|
|
29
|
+
<script src="https://rbgfx.github.io/webvas/loader.js"
|
|
30
|
+
data-runtime="https://rbgfx.github.io/webvas/assets/webvas.wasm"></script>
|
|
31
|
+
~~~
|
|
32
|
+
|
|
33
|
+
In `app.rb`:
|
|
23
34
|
|
|
24
35
|
~~~ruby
|
|
25
|
-
|
|
36
|
+
require "gesso"
|
|
37
|
+
|
|
38
|
+
Gesso.run(width: 320, height: 240, runner: :web) do
|
|
39
|
+
draw do
|
|
40
|
+
background "#101827"
|
|
41
|
+
no_stroke
|
|
42
|
+
fill "#f07850"
|
|
43
|
+
circle width / 2, height / 2, 36
|
|
44
|
+
end
|
|
45
|
+
end
|
|
26
46
|
~~~
|
|
27
47
|
|
|
28
|
-
The
|
|
48
|
+
The official runtime is built with these gems already included. A runtime URL can be set with `data-runtime`; use the same URL for sketches that bundle their own dependencies.
|
|
29
49
|
|
|
30
|
-
##
|
|
50
|
+
## Ruby API
|
|
31
51
|
|
|
32
|
-
|
|
52
|
+
Create an `RBGL::GUI::Window` with `Webvas::Backend.new`, then call `Webvas.run(window)` with a frame block. The block receives the RBGL context and delta time. `Webvas.run` reuses one animation callback, closes the window on stop or error, and reports exceptions through the bridge.
|
|
33
53
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
context.clear
|
|
48
|
-
# Bind an RBGL pipeline and draw here.
|
|
54
|
+
`Webvas::Backend` accepts `width`, `height`, `canvas`, `title`, and `pixelated`. Canvas drawing dimensions set render resolution; CSS scales its display. Pointer coordinates are mapped from CSS pixels to render pixels. Focus the canvas to send keyboard input.
|
|
55
|
+
|
|
56
|
+
RLSL can generate the WGSL source for WebGPU:
|
|
57
|
+
|
|
58
|
+
~~~ruby
|
|
59
|
+
require "rlsl"
|
|
60
|
+
require "webvas"
|
|
61
|
+
|
|
62
|
+
wgsl = RLSL.to_wgsl(:plasma) do
|
|
63
|
+
uniforms { float :time }
|
|
64
|
+
fragment do |frag_coord, resolution, uniforms|
|
|
65
|
+
uv = frag_coord / resolution.y
|
|
66
|
+
vec3(sin(uv.x + uniforms.time), uv.y, 0.6)
|
|
49
67
|
end
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
end
|
|
69
|
+
Webvas.run_shader(Webvas::Shader.new(wgsl))
|
|
52
70
|
~~~
|
|
53
71
|
|
|
54
|
-
|
|
72
|
+
WebGPU requires a supported browser, secure context, and GPU adapter. The shader runner updates the `resolution` and `time` uniforms automatically.
|
|
55
73
|
|
|
56
|
-
##
|
|
57
|
-
|
|
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.
|
|
74
|
+
## CLI
|
|
59
75
|
|
|
60
|
-
|
|
76
|
+
~~~sh
|
|
77
|
+
gem install webvas
|
|
78
|
+
webvas new my-sketch
|
|
79
|
+
cd my-sketch
|
|
80
|
+
webvas serve
|
|
81
|
+
webvas build -o dist
|
|
82
|
+
~~~
|
|
61
83
|
|
|
62
|
-
|
|
84
|
+
`serve` binds to `127.0.0.1:8000`, serves `.wasm` as `application/wasm`, and reloads when project files change. Use `--host`, `--port`, or `--root` to change its defaults. `build` writes a static site that loads the official runtime. To bundle a custom runtime, add `ruby_wasm` and `js` to the project's `Gemfile`, install it, then run `webvas build --runtime custom`; the project's bundle is passed to `rbwasm build`.
|
|
63
85
|
|
|
64
|
-
##
|
|
86
|
+
## Playground
|
|
65
87
|
|
|
66
|
-
|
|
67
|
-
- A secure context when loading the page over the network
|
|
68
|
-
- A Ruby runtime built with Webvas, RBGL, and RBGL's dependencies
|
|
88
|
+
Open the [Webvas playground](https://rbgfx.github.io/webvas/). Choose a mode and example, edit the Ruby, then press **Run** or **Ctrl/Cmd + Enter**. Each run starts a fresh worker and canvas. **Share** compresses the source with `CompressionStream` into the URL fragment; **Download HTML** embeds the source as escaped JSON and keeps the runtime on its CDN URL.
|
|
69
89
|
|
|
70
|
-
|
|
90
|
+
## Security and limitations
|
|
71
91
|
|
|
72
|
-
|
|
92
|
+
- Ruby runs in a worker, but the worker is not a security sandbox. Sketches can call browser APIs and make network requests allowed by the page's Content Security Policy. Inspect shared code before running it.
|
|
93
|
+
- Webvas does not set cookies, store source, or send code to a Webvas service. The GitHub Pages project URL shares the `rbgfx.github.io` origin; a dedicated hostname requires a separate hosting or domain decision.
|
|
94
|
+
- Runtime downloads omit browser credentials. Pages need to allow the chosen loader, worker, and runtime in their Content Security Policy.
|
|
95
|
+
- The `js` gem evaluates Ruby-to-JavaScript bridge code, so the playground policy requires `unsafe-eval` and `wasm-unsafe-eval`. This weakens script restrictions; use a dedicated origin for untrusted sketches.
|
|
96
|
+
- The 0.3.0 candidate was verified locally in Chromium 153 on macOS arm64. Firefox, Safari, and mobile browsers are not verified; the software renderer needs `OffscreenCanvas` and module workers, and RLSL also needs WebGPU.
|
|
97
|
+
- Blocking loops and `sleep` stop browser frame progress. WebAssembly threads and compiling native extensions in the browser are unsupported.
|
|
98
|
+
- Ruby source is limited to 32 KiB. RGBA frames use base64 transfer; a 320×240 frame is 307,200 bytes before encoding.
|
|
99
|
+
- RLSL mode requires WebGPU; RBGL and Gesso modes use the software renderer.
|
|
73
100
|
|
|
74
|
-
|
|
101
|
+
See [browser security notes](docs/security.md) and [runtime/performance records](docs/performance.md).
|
|
75
102
|
|
|
76
103
|
## Development
|
|
77
104
|
|
|
78
|
-
The repository expects adjacent checkouts of
|
|
105
|
+
The repository expects adjacent checkouts of Larb, RBGL, Tessel, Gesso, Glyphic, and RLSL. Ruby unit tests run under CRuby; the browser integration tests use Chromium and the locally built runtime.
|
|
79
106
|
|
|
80
107
|
~~~sh
|
|
81
108
|
bundle install
|
|
@@ -90,8 +117,6 @@ npx playwright install chromium
|
|
|
90
117
|
npm run test:browser
|
|
91
118
|
~~~
|
|
92
119
|
|
|
93
|
-
The Pages workflow builds the WebAssembly runtime, checks the browser loader in Chromium, and deploys the static example.
|
|
94
|
-
|
|
95
120
|
## License
|
|
96
121
|
|
|
97
122
|
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/exe/webvas
ADDED
data/js/bridge.js
CHANGED
|
@@ -4,6 +4,107 @@
|
|
|
4
4
|
const pendingEvents = [];
|
|
5
5
|
const eventLimit = 2048;
|
|
6
6
|
|
|
7
|
+
function uniformLayout(wgsl) {
|
|
8
|
+
const source = wgsl.match(/struct\s+Uniforms\s*\{([\s\S]*?)\}/)?.[1];
|
|
9
|
+
if (!source) throw new Error("WGSL must define a Uniforms struct");
|
|
10
|
+
const fields = [...source.matchAll(/\b(\w+)\s*:\s*(f32|i32|u32|vec[234]<f32>)\s*,/g)];
|
|
11
|
+
let offset = 0;
|
|
12
|
+
const layout = fields.map(([, name, type]) => {
|
|
13
|
+
const components = Number(type.match(/^vec(\d)/)?.[1] || 1);
|
|
14
|
+
const align = components === 1 ? 4 : components === 2 ? 8 : 16;
|
|
15
|
+
const size = components === 3 ? 12 : components * 4;
|
|
16
|
+
offset = Math.ceil(offset / align) * align;
|
|
17
|
+
const field = { name, type, offset, components };
|
|
18
|
+
offset += size;
|
|
19
|
+
return field;
|
|
20
|
+
});
|
|
21
|
+
if (!layout.length) throw new Error("WGSL Uniforms must contain supported numeric fields");
|
|
22
|
+
return { fields: layout, size: Math.ceil(offset / 16) * 16 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function startShader(selector, wgsl, initialUniforms) {
|
|
26
|
+
const canvas = globalThis.WebvasCanvases?.[selector];
|
|
27
|
+
if (!(canvas instanceof OffscreenCanvas)) throw new Error("Canvas not found: " + selector);
|
|
28
|
+
if (!navigator.gpu) throw new Error("WebGPU is unavailable. Try a browser with WebGPU enabled.");
|
|
29
|
+
if (!canvas.width || !canvas.height) throw new Error("Shader canvas size must be positive");
|
|
30
|
+
|
|
31
|
+
const startTime = performance.now();
|
|
32
|
+
let metricFrames = 0;
|
|
33
|
+
let metricStart = startTime;
|
|
34
|
+
let metricDispatchMs = 0;
|
|
35
|
+
const uniforms = JSON.parse(initialUniforms);
|
|
36
|
+
const layout = uniformLayout(wgsl);
|
|
37
|
+
const frame = async () => {
|
|
38
|
+
try {
|
|
39
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
40
|
+
if (!adapter) throw new Error("WebGPU could not find an adapter.");
|
|
41
|
+
const device = await adapter.requestDevice();
|
|
42
|
+
const context = canvas.getContext("webgpu");
|
|
43
|
+
if (!context) throw new Error("WebGPU canvas is unavailable.");
|
|
44
|
+
context.configure({ device, format: "rgba8unorm", usage: GPUTextureUsage.STORAGE_BINDING, alphaMode: "opaque" });
|
|
45
|
+
const module = device.createShaderModule({ code: wgsl });
|
|
46
|
+
const info = await module.getCompilationInfo();
|
|
47
|
+
const errors = info.messages.filter(message => message.type === "error");
|
|
48
|
+
if (errors.length) throw new Error(errors.map(error => error.message).join("\n"));
|
|
49
|
+
const pipeline = await device.createComputePipelineAsync({
|
|
50
|
+
layout: "auto", compute: { module, entryPoint: "main" }
|
|
51
|
+
});
|
|
52
|
+
const buffer = device.createBuffer({ size: layout.size, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
|
53
|
+
const draw = () => {
|
|
54
|
+
try {
|
|
55
|
+
const started = performance.now();
|
|
56
|
+
const data = new DataView(new ArrayBuffer(layout.size));
|
|
57
|
+
for (const field of layout.fields) {
|
|
58
|
+
let value = uniforms[field.name];
|
|
59
|
+
if (field.name === "resolution") value = [canvas.width, canvas.height];
|
|
60
|
+
if (field.name === "time" && value === undefined) value = (performance.now() - startTime) / 1000;
|
|
61
|
+
const values = field.components === 1 ? [value ?? 0] : Array.isArray(value) ? value : [];
|
|
62
|
+
for (let index = 0; index < field.components; index += 1) {
|
|
63
|
+
const at = field.offset + index * 4;
|
|
64
|
+
if (field.type === "i32") data.setInt32(at, Number(values[index] || 0), true);
|
|
65
|
+
else if (field.type === "u32") data.setUint32(at, Number(values[index] || 0), true);
|
|
66
|
+
else data.setFloat32(at, Number(values[index] || 0), true);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
device.queue.writeBuffer(buffer, 0, data);
|
|
70
|
+
const bindGroup = device.createBindGroup({
|
|
71
|
+
layout: pipeline.getBindGroupLayout(0),
|
|
72
|
+
entries: [
|
|
73
|
+
{ binding: 0, resource: { buffer } },
|
|
74
|
+
{ binding: 1, resource: context.getCurrentTexture().createView() }
|
|
75
|
+
]
|
|
76
|
+
});
|
|
77
|
+
const encoder = device.createCommandEncoder();
|
|
78
|
+
const pass = encoder.beginComputePass();
|
|
79
|
+
pass.setPipeline(pipeline);
|
|
80
|
+
pass.setBindGroup(0, bindGroup);
|
|
81
|
+
pass.dispatchWorkgroups(Math.ceil(canvas.width / 8), Math.ceil(canvas.height / 8));
|
|
82
|
+
pass.end();
|
|
83
|
+
device.queue.submit([encoder.finish()]);
|
|
84
|
+
metricDispatchMs += performance.now() - started;
|
|
85
|
+
metricFrames += 1;
|
|
86
|
+
if (metricFrames === 60) {
|
|
87
|
+
const elapsed = performance.now() - metricStart;
|
|
88
|
+
globalThis.postMessage({ type: "webvas:metrics", frames: metricFrames, elapsedMs: elapsed,
|
|
89
|
+
fps: metricFrames * 1000 / elapsed, averagePresentMs: metricDispatchMs / metricFrames });
|
|
90
|
+
metricFrames = 0;
|
|
91
|
+
metricStart = performance.now();
|
|
92
|
+
metricDispatchMs = 0;
|
|
93
|
+
}
|
|
94
|
+
globalThis.window.requestAnimationFrame(draw);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
globalThis.WebvasBridge.showError(error.message || String(error), "");
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
globalThis.window.requestAnimationFrame(draw);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
globalThis.WebvasBridge.showError(error.message || String(error), "");
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
frame();
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
7
108
|
function queue(handle, event) {
|
|
8
109
|
if (handle.events.length === eventLimit) handle.events.shift();
|
|
9
110
|
handle.events.push(event);
|
|
@@ -17,7 +118,7 @@
|
|
|
17
118
|
canvas.height = height;
|
|
18
119
|
const context = canvas.getContext("2d", { alpha: false });
|
|
19
120
|
if (!context) throw new Error("Canvas 2D is unavailable");
|
|
20
|
-
const handle = { id: nextId++, canvas, context, events: pendingEvents.splice(0) };
|
|
121
|
+
const handle = { id: nextId++, canvas, context, events: pendingEvents.splice(0), metricStart: performance.now(), metricFrames: 0, metricPresentMs: 0 };
|
|
21
122
|
globalThis.postMessage({ type: "webvas:pixelated", selector, enabled: pixelated });
|
|
22
123
|
canvases.set(handle.id, handle);
|
|
23
124
|
return handle.id;
|
|
@@ -26,6 +127,7 @@
|
|
|
26
127
|
present(id, encoded, width, height) {
|
|
27
128
|
const handle = canvases.get(id);
|
|
28
129
|
if (!handle) throw new Error("Canvas backend is closed");
|
|
130
|
+
const started = performance.now();
|
|
29
131
|
const { canvas, context } = handle;
|
|
30
132
|
if (canvas.width !== width || canvas.height !== height) {
|
|
31
133
|
canvas.width = width;
|
|
@@ -36,9 +138,23 @@
|
|
|
36
138
|
const pixels = new Uint8ClampedArray(binary.length);
|
|
37
139
|
for (let index = 0; index < binary.length; index += 1) pixels[index] = binary.charCodeAt(index);
|
|
38
140
|
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
|
141
|
+
handle.metricFrames += 1;
|
|
142
|
+
handle.metricPresentMs += performance.now() - started;
|
|
143
|
+
if (handle.metricFrames === 60) {
|
|
144
|
+
const elapsed = performance.now() - handle.metricStart;
|
|
145
|
+
globalThis.postMessage({ type: "webvas:metrics", frames: handle.metricFrames, elapsedMs: elapsed,
|
|
146
|
+
fps: handle.metricFrames * 1000 / elapsed, averagePresentMs: handle.metricPresentMs / handle.metricFrames });
|
|
147
|
+
handle.metricStart = performance.now();
|
|
148
|
+
handle.metricFrames = 0;
|
|
149
|
+
handle.metricPresentMs = 0;
|
|
150
|
+
}
|
|
39
151
|
return true;
|
|
40
152
|
},
|
|
41
153
|
|
|
154
|
+
runShader(selector, wgsl, uniforms) {
|
|
155
|
+
return startShader(selector, wgsl, uniforms);
|
|
156
|
+
},
|
|
157
|
+
|
|
42
158
|
events(id) {
|
|
43
159
|
const handle = canvases.get(id);
|
|
44
160
|
return JSON.stringify(handle ? handle.events.splice(0) : []);
|
data/js/loader.js
CHANGED
|
@@ -3,44 +3,92 @@
|
|
|
3
3
|
if (!loader) return;
|
|
4
4
|
|
|
5
5
|
const base = new URL(".", loader.src);
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
6
|
+
const defaultRuntime = loader.dataset.runtime || new URL("./assets/webvas.wasm", base).href;
|
|
7
|
+
const sessions = new Map();
|
|
8
|
+
const sourceLimit = 32768;
|
|
9
|
+
|
|
10
|
+
function textSource(element) {
|
|
11
|
+
if (element.matches('script[type="application/json"][data-webvas-source]')) {
|
|
12
|
+
const payload = JSON.parse(element.textContent);
|
|
13
|
+
if (typeof payload.source !== "string") throw new Error("Embedded Ruby source is missing.");
|
|
14
|
+
return payload.source;
|
|
15
|
+
}
|
|
16
|
+
return element.textContent;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readSource(element) {
|
|
20
|
+
if (!element.src) return textSource(element);
|
|
21
|
+
const response = await fetch(element.src, { credentials: "omit" });
|
|
22
|
+
if (!response.ok) throw new Error(`Ruby source download failed: ${response.status}`);
|
|
23
|
+
return response.text();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function dispose(selector, replaceCanvas) {
|
|
27
|
+
const old = sessions.get(selector);
|
|
28
|
+
if (!old) return;
|
|
29
|
+
old.worker.terminate();
|
|
30
|
+
URL.revokeObjectURL(old.workerBlobUrl);
|
|
31
|
+
old.listeners.forEach(([target, type, listener, options]) => target.removeEventListener(type, listener, options));
|
|
32
|
+
if (replaceCanvas && old.canvas.isConnected) {
|
|
33
|
+
const canvas = old.canvas.cloneNode(false);
|
|
34
|
+
canvas.style.imageRendering = "";
|
|
35
|
+
old.canvas.replaceWith(canvas);
|
|
36
|
+
}
|
|
37
|
+
sessions.delete(selector);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function start(source, { canvas: selector = "#screen", runtime, statusElement, onStatus } = {}) {
|
|
41
|
+
if (typeof source !== "string") throw new TypeError("Ruby source must be a string.");
|
|
42
|
+
if (new TextEncoder().encode(source).length > sourceLimit) throw new Error("Ruby source exceeds 32 KB.");
|
|
43
|
+
dispose(selector, true);
|
|
9
44
|
|
|
10
|
-
async function run(sourceElement) {
|
|
11
|
-
const selector = sourceElement.dataset.canvas || "#screen";
|
|
12
45
|
const canvas = document.querySelector(selector);
|
|
13
46
|
if (!(canvas instanceof HTMLCanvasElement)) throw new Error(`Canvas not found: ${selector}`);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const report =
|
|
21
|
-
|
|
47
|
+
const status = statusElement || document.createElement("output");
|
|
48
|
+
if (!statusElement) {
|
|
49
|
+
status.setAttribute("role", "status");
|
|
50
|
+
status.setAttribute("aria-live", "polite");
|
|
51
|
+
canvas.insertAdjacentElement("afterend", status);
|
|
52
|
+
}
|
|
53
|
+
const report = detail => {
|
|
54
|
+
if (onStatus) onStatus(detail);
|
|
55
|
+
else status.textContent = detail.message || "";
|
|
56
|
+
};
|
|
57
|
+
report({ state: "loading", message: "Loading Ruby runtime…" });
|
|
22
58
|
if (canvas.tabIndex < 0) canvas.tabIndex = 0;
|
|
23
59
|
|
|
24
|
-
const response = await fetch(
|
|
60
|
+
const response = await fetch(new URL("./worker.js", base), { credentials: "omit" });
|
|
25
61
|
if (!response.ok) throw new Error(`Worker download failed: ${response.status}`);
|
|
26
62
|
const workerBlobUrl = URL.createObjectURL(new Blob([await response.text()], { type: "text/javascript" }));
|
|
27
63
|
const worker = new Worker(workerBlobUrl, { type: "module", name: "webvas" });
|
|
28
|
-
|
|
64
|
+
const listeners = [];
|
|
65
|
+
const session = { canvas, worker, workerBlobUrl, listeners };
|
|
66
|
+
sessions.set(selector, session);
|
|
67
|
+
|
|
68
|
+
const listen = (target, type, callback, options) => {
|
|
69
|
+
target.addEventListener(type, callback, options);
|
|
70
|
+
listeners.push([target, type, callback, options]);
|
|
71
|
+
};
|
|
72
|
+
listen(worker, "message", event => {
|
|
29
73
|
const message = event.data || {};
|
|
30
|
-
if (message.type === "webvas:loading") report(message.message || "Loading Ruby runtime…");
|
|
74
|
+
if (message.type === "webvas:loading") report({ state: "loading", message: message.message || "Loading Ruby runtime…" });
|
|
31
75
|
else if (message.type === "webvas:ready") {
|
|
32
76
|
URL.revokeObjectURL(workerBlobUrl);
|
|
33
|
-
worker.postMessage({ type: "webvas:run", source
|
|
34
|
-
} else if (message.type === "webvas:started") report("Ruby sketch is running");
|
|
35
|
-
else if (message.type === "webvas:error") report(
|
|
77
|
+
worker.postMessage({ type: "webvas:run", source });
|
|
78
|
+
} else if (message.type === "webvas:started") report({ state: "running", message: "Ruby sketch is running" });
|
|
79
|
+
else if (message.type === "webvas:error") report({ state: "error", message: message.message || "Ruby sketch failed", backtrace: message.backtrace || "" });
|
|
80
|
+
else if (message.type === "webvas:metrics") report({ state: "metrics", ...message });
|
|
36
81
|
else if (message.type === "webvas:pixelated" && message.selector === selector) {
|
|
37
|
-
|
|
82
|
+
const enabled = message.enabled === true || message.enabled === "true";
|
|
83
|
+
canvas.dataset.pixelated = String(enabled);
|
|
84
|
+
canvas.style.imageRendering = enabled ? "pixelated" : "auto";
|
|
38
85
|
}
|
|
39
86
|
});
|
|
40
|
-
worker
|
|
87
|
+
listen(worker, "error", event => {
|
|
41
88
|
URL.revokeObjectURL(workerBlobUrl);
|
|
42
|
-
report(event.message || "Webvas worker failed");
|
|
89
|
+
report({ state: "error", message: event.message || "Webvas worker failed" });
|
|
43
90
|
});
|
|
91
|
+
listen(worker, "messageerror", () => report({ state: "error", message: "Webvas worker sent an unreadable message." }));
|
|
44
92
|
|
|
45
93
|
const forward = event => {
|
|
46
94
|
if (event.type === "wheel") event.preventDefault();
|
|
@@ -54,7 +102,7 @@
|
|
|
54
102
|
type: "webvas:input",
|
|
55
103
|
event: {
|
|
56
104
|
type: event.type === "pointercancel" ? "pointerup" : event.type,
|
|
57
|
-
clientX: event.clientX
|
|
105
|
+
clientX: event.clientX ?? 0, clientY: event.clientY ?? 0,
|
|
58
106
|
left: rect.left, top: rect.top, rectWidth: rect.width, rectHeight: rect.height,
|
|
59
107
|
button: event.button, deltaX: event.deltaX, deltaY: event.deltaY,
|
|
60
108
|
code: event.code, key: event.key, shiftKey: event.shiftKey,
|
|
@@ -63,24 +111,52 @@
|
|
|
63
111
|
});
|
|
64
112
|
};
|
|
65
113
|
for (const type of ["pointerdown", "pointerup", "pointermove", "pointercancel", "keydown", "keyup", "wheel"]) {
|
|
66
|
-
canvas
|
|
114
|
+
listen(canvas, type, forward, type === "wheel" ? { passive: false } : undefined);
|
|
67
115
|
}
|
|
68
116
|
|
|
117
|
+
if (typeof canvas.transferControlToOffscreen !== "function") throw new Error("OffscreenCanvas is unavailable in this browser.");
|
|
69
118
|
const offscreen = canvas.transferControlToOffscreen();
|
|
70
119
|
worker.postMessage({
|
|
71
120
|
type: "webvas:init", canvas: offscreen, selector,
|
|
72
|
-
bridgeUrl
|
|
121
|
+
bridgeUrl: new URL("./bridge.js", base).href,
|
|
122
|
+
wasmUrl: new URL(runtime || defaultRuntime, document.baseURI).href
|
|
73
123
|
}, [offscreen]);
|
|
124
|
+
return session;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function run(source, options = {}) {
|
|
128
|
+
try {
|
|
129
|
+
return await start(source, options);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const detail = { state: "error", message: error.message || String(error) };
|
|
132
|
+
dispose(options.canvas || "#screen", true);
|
|
133
|
+
if (options.onStatus) options.onStatus(detail);
|
|
134
|
+
else if (options.statusElement) options.statusElement.textContent = detail.message;
|
|
135
|
+
else throw error;
|
|
136
|
+
}
|
|
74
137
|
}
|
|
75
138
|
|
|
76
|
-
|
|
77
|
-
run
|
|
139
|
+
globalThis.WebvasPlayground = {
|
|
140
|
+
run,
|
|
141
|
+
stop(canvas = "#screen") { dispose(canvas, true); }
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const startEmbedded = () => {
|
|
145
|
+
for (const element of document.querySelectorAll('script[type="text/ruby"][data-webvas],script[type="application/json"][data-webvas-source]')) {
|
|
78
146
|
const status = document.createElement("output");
|
|
79
|
-
status.setAttribute("role", "
|
|
80
|
-
status.
|
|
147
|
+
status.setAttribute("role", "status");
|
|
148
|
+
status.setAttribute("aria-live", "polite");
|
|
81
149
|
element.insertAdjacentElement("afterend", status);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
150
|
+
readSource(element).then(source => run(source, {
|
|
151
|
+
canvas: element.dataset.canvas || "#screen",
|
|
152
|
+
runtime: element.dataset.runtime,
|
|
153
|
+
statusElement: status
|
|
154
|
+
})).catch(error => {
|
|
155
|
+
status.setAttribute("role", "alert");
|
|
156
|
+
status.textContent = error.message || String(error);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", startEmbedded, { once: true });
|
|
161
|
+
else startEmbedded();
|
|
86
162
|
})();
|
data/lib/webvas/backend.rb
CHANGED
|
@@ -19,7 +19,14 @@ module Webvas
|
|
|
19
19
|
def set_pixels(buffer, width, height)
|
|
20
20
|
width = Integer(width)
|
|
21
21
|
height = Integer(height)
|
|
22
|
-
bytes =
|
|
22
|
+
bytes = String.try_convert(buffer)
|
|
23
|
+
raise ArgumentError, "Pixel buffer must be a String" unless bytes
|
|
24
|
+
|
|
25
|
+
expected_size = width * height * 4
|
|
26
|
+
unless width.positive? && height.positive? && bytes.bytesize == expected_size
|
|
27
|
+
raise ArgumentError, "Pixel buffer size mismatch: expected #{expected_size}, got #{bytes.bytesize}"
|
|
28
|
+
end
|
|
29
|
+
|
|
23
30
|
resize(width, height) if [width, height] != [@width, @height]
|
|
24
31
|
@pending_pixels = [bytes, width, height]
|
|
25
32
|
true
|
data/lib/webvas/bridge.rb
CHANGED
|
@@ -18,6 +18,10 @@ module Webvas
|
|
|
18
18
|
@api.present(handle, Base64.strict_encode64(bytes), width, height)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
def run_shader(selector, wgsl, uniforms)
|
|
22
|
+
@api.runShader(selector, wgsl, JSON.generate(uniforms))
|
|
23
|
+
end
|
|
24
|
+
|
|
21
25
|
def events(handle)
|
|
22
26
|
JSON.parse(@api.events(handle).to_s)
|
|
23
27
|
end
|
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
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webvas
|
|
4
|
+
class Shader
|
|
5
|
+
attr_reader :wgsl, :canvas
|
|
6
|
+
|
|
7
|
+
def initialize(wgsl, canvas: "#screen")
|
|
8
|
+
@wgsl = String(wgsl)
|
|
9
|
+
@canvas = String(canvas)
|
|
10
|
+
raise ArgumentError, "WGSL source cannot be empty" if @wgsl.empty?
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/webvas/version.rb
CHANGED
data/lib/webvas.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "webvas/backend"
|
|
|
7
7
|
require_relative "webvas/input"
|
|
8
8
|
require_relative "webvas/key_map"
|
|
9
9
|
require_relative "webvas/runner"
|
|
10
|
+
require_relative "webvas/shader"
|
|
10
11
|
|
|
11
12
|
module Webvas
|
|
12
13
|
class Error < StandardError; end
|
|
@@ -36,4 +37,10 @@ module Webvas
|
|
|
36
37
|
Bridge.new.show_error(error.message, error.backtrace || [])
|
|
37
38
|
end
|
|
38
39
|
|
|
40
|
+
def self.run_shader(shader, uniforms: {})
|
|
41
|
+
raise TypeError, "expected a Webvas::Shader" unless shader.is_a?(Shader)
|
|
42
|
+
|
|
43
|
+
Bridge.new.run_shader(shader.canvas, shader.wgsl, uniforms)
|
|
44
|
+
end
|
|
45
|
+
|
|
39
46
|
end
|
data/sig/webvas.rbs
CHANGED
|
@@ -4,10 +4,17 @@ 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
|
|
@@ -45,4 +52,5 @@ module Webvas
|
|
|
45
52
|
|
|
46
53
|
def self.run: (RBGL::GUI::Window window, ?scheduler: Scheduler, ?on_error: Proc) { (RBGL::Engine::Context, Float) -> void } -> Proc
|
|
47
54
|
def self.report_error: (StandardError error) -> untyped
|
|
55
|
+
def self.run_shader: (Shader shader, ?uniforms: Hash[Symbol | String, untyped]) -> untyped
|
|
48
56
|
end
|
metadata
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: webvas
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
@@ -116,23 +116,28 @@ dependencies:
|
|
|
116
116
|
description: An RBGL canvas backend and browser loader powered by ruby.wasm.
|
|
117
117
|
email:
|
|
118
118
|
- t.yudai92@gmail.com
|
|
119
|
-
executables:
|
|
119
|
+
executables:
|
|
120
|
+
- webvas
|
|
120
121
|
extensions: []
|
|
121
122
|
extra_rdoc_files: []
|
|
122
123
|
files:
|
|
123
124
|
- CHANGELOG.md
|
|
124
125
|
- LICENSE.txt
|
|
125
126
|
- README.md
|
|
127
|
+
- exe/webvas
|
|
126
128
|
- js/bridge.js
|
|
127
129
|
- js/loader.js
|
|
128
130
|
- js/worker.js
|
|
129
131
|
- lib/webvas.rb
|
|
130
132
|
- lib/webvas/backend.rb
|
|
131
133
|
- lib/webvas/bridge.rb
|
|
134
|
+
- lib/webvas/cli.rb
|
|
132
135
|
- lib/webvas/input.rb
|
|
133
136
|
- lib/webvas/key_map.rb
|
|
134
137
|
- lib/webvas/runner.rb
|
|
138
|
+
- lib/webvas/shader.rb
|
|
135
139
|
- lib/webvas/version.rb
|
|
140
|
+
- patches/psych-wasi.patch
|
|
136
141
|
- sig/rbgl.rbs
|
|
137
142
|
- sig/webvas.rbs
|
|
138
143
|
homepage: https://github.com/rbgfx/webvas
|
|
@@ -156,7 +161,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
156
161
|
- !ruby/object:Gem::Version
|
|
157
162
|
version: '0'
|
|
158
163
|
requirements: []
|
|
159
|
-
rubygems_version: 4.0.
|
|
164
|
+
rubygems_version: 4.0.20
|
|
160
165
|
specification_version: 4
|
|
161
166
|
summary: Run Ruby graphics in the browser
|
|
162
167
|
test_files: []
|