wasm_rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +22 -0
- data/README.md +157 -0
- data/lib/generators/wasm_rails/install/install_generator.rb +109 -0
- data/lib/generators/wasm_rails/install/templates/boot.js +31 -0
- data/lib/generators/wasm_rails/install/templates/build_app_bundle.mjs +201 -0
- data/lib/generators/wasm_rails/install/templates/esbuild_wasm.mjs +58 -0
- data/lib/generators/wasm_rails/install/templates/serve_wasm.mjs +86 -0
- data/lib/generators/wasm_rails/install/templates/service_worker.js +413 -0
- data/lib/generators/wasm_rails/install/templates/wasm_shell.html +97 -0
- data/lib/generators/wasm_rails/install/templates/wasm_sqlite3_adapter.rb +160 -0
- data/lib/wasm_rails/railtie.rb +23 -0
- data/lib/wasm_rails/version.rb +3 -0
- data/lib/wasm_rails.rb +8 -0
- data/wasm_stubs/io/console/size.rb +8 -0
- data/wasm_stubs/io/wait.rb +6 -0
- data/wasm_stubs/loofah.rb +39 -0
- data/wasm_stubs/nokogiri.rb +67 -0
- data/wasm_stubs/openssl.rb +122 -0
- data/wasm_stubs/rails-html-sanitizer.rb +89 -0
- data/wasm_stubs/resolv.rb +19 -0
- data/wasm_stubs/socket.rb +47 -0
- data/wasm_stubs/sqlite3.rb +102 -0
- data/wasm_stubs/thread.rb +10 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e88c3bdb062ebc82a44f218f48d8e93614dda54d89a584f873a0715dbd130f82
|
|
4
|
+
data.tar.gz: 103d7130d41648dcee8e9a527ec02fe8aab8f7e281a7b52ef194c6a4c33b86b8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ddc70640c5a8b8dc0ec733b0f89b8b2cd507bdd27ab187336b8cea2169f9828d7aa243ba93494896a1f9660b30e1c68d331d306691dde73809523316a76a6896
|
|
7
|
+
data.tar.gz: 678917372a8eb43a1dfde066ed849d5597f26607f5637275f244c1f162cab4282b19be6e08620f17112d6092402ecf1e5ab80e475b3b29fbb4fe8998a7cd38d0
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2026 Emerson Argueta
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# wasm_rails
|
|
2
|
+
|
|
3
|
+
Run Rails apps entirely in the browser via WebAssembly.
|
|
4
|
+
|
|
5
|
+
The entire Rails runtime — ActiveRecord, ActionController, ActionView — executes inside a Service Worker. SQLite is persisted to OPFS (with IndexedDB fallback). No server required after first load.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "wasm_rails"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Run the installer:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle install
|
|
19
|
+
rails g wasm_rails:install
|
|
20
|
+
npm install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What the generator installs
|
|
24
|
+
|
|
25
|
+
| File | Purpose |
|
|
26
|
+
|------|---------|
|
|
27
|
+
| `app/javascript/wasm/service_worker.js` | Boots Rails in SW, handles SQLite, intercepts fetches, export/import DB |
|
|
28
|
+
| `app/javascript/wasm/boot.js` | Page-side SW registration and progress display |
|
|
29
|
+
| `bin/build_app_bundle.mjs` | Bundles Ruby source + gems → `public/wasm/app_bundle.json` |
|
|
30
|
+
| `bin/esbuild_wasm.mjs` | esbuild config for WASM JS entry points |
|
|
31
|
+
| `bin/serve_wasm.mjs` | Local dev server with COOP/COEP headers |
|
|
32
|
+
| `lib/active_record/connection_adapters/wasm_sqlite3_adapter.rb` | AR adapter bridging Ruby to JS sqlite |
|
|
33
|
+
| `wasm_stubs/` | Stubs for C extensions unavailable in WASM |
|
|
34
|
+
| `public/wasm_shell.html` | Entry point HTML — registers SW, shows boot progress |
|
|
35
|
+
|
|
36
|
+
## `config/application.rb` setup
|
|
37
|
+
|
|
38
|
+
After installing, add these requires at the top of `config/application.rb`, **before** `Bundler.require`:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
require "wasm_rails"
|
|
42
|
+
require "turbo-rails"
|
|
43
|
+
require "stimulus-rails"
|
|
44
|
+
# Add any other gems that need explicit requires for Propshaft asset discovery:
|
|
45
|
+
# require "chartkick"
|
|
46
|
+
# require "groupdate"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Also add the WASM SQLite adapter inside your `Application` class:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
module YourApp
|
|
53
|
+
class Application < Rails::Application
|
|
54
|
+
require_relative "../../lib/active_record/connection_adapters/wasm_sqlite3_adapter" if RUBY_PLATFORM == "wasm32-wasi"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## `config/boot.rb` setup
|
|
60
|
+
|
|
61
|
+
Wrap Bundler setup so it's skipped inside the Service Worker:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
unless RUBY_PLATFORM == "wasm32-wasi"
|
|
65
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
|
66
|
+
require "bundler/setup"
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## `config/initializers/assets.rb` setup
|
|
71
|
+
|
|
72
|
+
Add `app/javascript` to Propshaft's asset paths so `application.js` and controller files are found:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
Rails.application.config.assets.paths << Rails.root.join("app/javascript")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Gems with `app/` directories
|
|
79
|
+
|
|
80
|
+
Some gems (like `turbo-rails`) ship controllers, helpers, and views in their `app/` directory. Zeitwerk normally autoloads these, but WASM has no lazy autoloading from gem `app/` dirs. The `wasm_rails` Railtie handles `turbo-rails` automatically.
|
|
81
|
+
|
|
82
|
+
For other gems that use `app/` dirs, add them to `GEM_EXTRA_PATHS` in `bin/build_app_bundle.mjs`:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
const GEM_EXTRA_PATHS = {
|
|
86
|
+
'turbo-rails': ['app/controllers', 'app/controllers/concerns', 'app/helpers', 'app/models', 'app/models/concerns', 'app/views'],
|
|
87
|
+
'your-gem': ['app/helpers'],
|
|
88
|
+
};
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
### Build
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Precompile Rails assets (Propshaft reads the manifest at runtime)
|
|
97
|
+
SECRET_KEY_BASE=dummy RAILS_ENV=production bin/rails assets:precompile
|
|
98
|
+
|
|
99
|
+
# Bundle Ruby source + gems (~39MB)
|
|
100
|
+
npm run build:app
|
|
101
|
+
|
|
102
|
+
# Bundle service worker JS
|
|
103
|
+
npm run build:wasm
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Serve locally
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
node bin/serve_wasm.mjs # http://localhost:3100
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Requires Chrome or Edge — Firefox/Safari lack full OPFS SAH Pool + module Service Worker support.
|
|
113
|
+
|
|
114
|
+
### Deploy
|
|
115
|
+
|
|
116
|
+
`ruby+stdlib.wasm` (~34MB) and `app_bundle.json` (~39MB) exceed Cloudflare Pages' 25MB file size limit. Upload them to R2 or any CDN. Deploy the rest to Cloudflare Pages or any static host.
|
|
117
|
+
|
|
118
|
+
Set `WASM_BASE_URL` at build time to point to your CDN:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
WASM_BASE_URL=https://your-cdn.example.com npm run build:wasm
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The built JS files in `public/wasm/` (`service_worker.js`, `boot.js`, etc.) must be committed — they're served directly by the static host.
|
|
125
|
+
|
|
126
|
+
## `WasmRails.wasm?`
|
|
127
|
+
|
|
128
|
+
The gem provides a clean predicate you can use anywhere:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
WasmRails.wasm? # => true when running inside ruby.wasm
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## How it works
|
|
135
|
+
|
|
136
|
+
1. `wasm_shell.html` is served statically and registers the Service Worker
|
|
137
|
+
2. The SW downloads `ruby+stdlib.wasm` (~34MB, cached after first load)
|
|
138
|
+
3. The SW downloads `app_bundle.json` (all gem + app `.rb` files, base64-encoded)
|
|
139
|
+
4. Ruby boots, Rails initializes, SQLite opens (OPFS SAH Pool or IndexedDB fallback)
|
|
140
|
+
5. On first boot: runs `db/schema.rb`. On subsequent boots: runs pending migrations
|
|
141
|
+
6. Every page request is intercepted by the SW, dispatched to the Rails Rack app, returned as HTML
|
|
142
|
+
|
|
143
|
+
## C extension stubs
|
|
144
|
+
|
|
145
|
+
Native gems that can't run in WASM are stubbed in `wasm_stubs/`:
|
|
146
|
+
|
|
147
|
+
- `sqlite3` → replaced by the JS sqlite4rails interface
|
|
148
|
+
- `openssl`, `nokogiri`, `loofah`, `rails-html-sanitizer` → empty stubs
|
|
149
|
+
- `resolv`, `socket`, `io/wait`, `io/console/size` → empty stubs
|
|
150
|
+
- `thread` → mapped to `Fiber` (WASM is single-threaded)
|
|
151
|
+
|
|
152
|
+
## Requirements
|
|
153
|
+
|
|
154
|
+
- Ruby 3.3+
|
|
155
|
+
- Rails 7.1+
|
|
156
|
+
- Node.js 20+
|
|
157
|
+
- Chrome or Edge (for OPFS SAH Pool)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module WasmRails
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path("templates", __dir__)
|
|
8
|
+
|
|
9
|
+
desc "Sets up a Rails app to run as a WASM app in the browser."
|
|
10
|
+
|
|
11
|
+
def copy_wasm_adapter
|
|
12
|
+
copy_file "wasm_sqlite3_adapter.rb",
|
|
13
|
+
"lib/active_record/connection_adapters/wasm_sqlite3_adapter.rb"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def copy_wasm_stubs
|
|
17
|
+
stubs_src = File.expand_path("../../../../../wasm_stubs", __dir__)
|
|
18
|
+
Dir.glob("#{stubs_src}/**/*").each do |src|
|
|
19
|
+
next if File.directory?(src)
|
|
20
|
+
rel = Pathname.new(src).relative_path_from(Pathname.new(stubs_src))
|
|
21
|
+
copy_file src, "wasm_stubs/#{rel}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def copy_js_files
|
|
26
|
+
copy_file "service_worker.js", "app/javascript/wasm/service_worker.js"
|
|
27
|
+
copy_file "boot.js", "app/javascript/wasm/boot.js"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def copy_bin_scripts
|
|
31
|
+
copy_file "build_app_bundle.mjs", "bin/build_app_bundle.mjs"
|
|
32
|
+
copy_file "esbuild_wasm.mjs", "bin/esbuild_wasm.mjs"
|
|
33
|
+
copy_file "serve_wasm.mjs", "bin/serve_wasm.mjs"
|
|
34
|
+
chmod "bin/build_app_bundle.mjs", 0o755
|
|
35
|
+
chmod "bin/esbuild_wasm.mjs", 0o755
|
|
36
|
+
chmod "bin/serve_wasm.mjs", 0o755
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def copy_public_files
|
|
40
|
+
copy_file "wasm_shell.html", "public/wasm_shell.html"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def patch_boot_rb
|
|
44
|
+
boot = "config/boot.rb"
|
|
45
|
+
return unless File.exist?(boot)
|
|
46
|
+
return if File.read(boot).include?("wasm32-wasi")
|
|
47
|
+
gsub_file boot,
|
|
48
|
+
/^(ENV\["BUNDLE_GEMFILE"\].+\nrequire "bundler\/setup"\nrequire "bootsnap\/setup")$/m,
|
|
49
|
+
"unless RUBY_PLATFORM == \"wasm32-wasi\"\n \\1\nend"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def patch_application_rb
|
|
53
|
+
application_rb = "config/application.rb"
|
|
54
|
+
return if File.read(application_rb).include?("wasm_sqlite3_adapter")
|
|
55
|
+
inject_into_class application_rb, "Application" do
|
|
56
|
+
<<~RUBY.indent(4)
|
|
57
|
+
if RUBY_PLATFORM == "wasm32-wasi"
|
|
58
|
+
require_relative "../../lib/active_record/connection_adapters/wasm_sqlite3_adapter"
|
|
59
|
+
end
|
|
60
|
+
RUBY
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def patch_assets_initializer
|
|
65
|
+
initializer = "config/initializers/assets.rb"
|
|
66
|
+
create_file initializer unless File.exist?(initializer)
|
|
67
|
+
return if File.read(initializer).include?("app/javascript")
|
|
68
|
+
append_to_file initializer,
|
|
69
|
+
"\nRails.application.config.assets.paths << Rails.root.join(\"app/javascript\")\n"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def update_package_json
|
|
73
|
+
return unless File.exist?("package.json")
|
|
74
|
+
pkg = JSON.parse(File.read("package.json"))
|
|
75
|
+
|
|
76
|
+
(pkg["dependencies"] ||= {}).merge!(
|
|
77
|
+
"@ruby/3.3-wasm-wasi" => "^3.3.0",
|
|
78
|
+
"@ruby/wasm-wasi" => "^3.3.0",
|
|
79
|
+
"@sqlite.org/sqlite-wasm" => "^3.0.0",
|
|
80
|
+
"esbuild" => "^0.25.0"
|
|
81
|
+
) { |_k, old, _new| old }
|
|
82
|
+
|
|
83
|
+
(pkg["scripts"] ||= {}).merge!(
|
|
84
|
+
"build:wasm" => "node bin/esbuild_wasm.mjs",
|
|
85
|
+
"watch:wasm" => "node bin/esbuild_wasm.mjs --watch",
|
|
86
|
+
"build:app" => "node bin/build_app_bundle.mjs"
|
|
87
|
+
) { |_k, old, _new| old }
|
|
88
|
+
|
|
89
|
+
File.write("package.json", JSON.pretty_generate(pkg))
|
|
90
|
+
say_status :update, "package.json"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def show_post_install_message
|
|
94
|
+
say "\n"
|
|
95
|
+
say " ✓ wasm_rails installed!", :green
|
|
96
|
+
say "\n"
|
|
97
|
+
say " Next steps:"
|
|
98
|
+
say " 1. npm install"
|
|
99
|
+
say " 2. In config/application.rb, require these before Bundler.require:"
|
|
100
|
+
say " require 'wasm_rails'"
|
|
101
|
+
say " require 'turbo-rails'"
|
|
102
|
+
say " require 'stimulus-rails'"
|
|
103
|
+
say " 3. npm run build:app && npm run build:wasm"
|
|
104
|
+
say " 4. node bin/serve_wasm.mjs → http://localhost:3100"
|
|
105
|
+
say "\n"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// boot.js — Main thread glue.
|
|
2
|
+
// Registers the Service Worker and relays progress messages to the shell page.
|
|
3
|
+
|
|
4
|
+
export async function bootWasm() {
|
|
5
|
+
if (!('serviceWorker' in navigator)) {
|
|
6
|
+
throw new Error('Service Workers not supported in this browser.');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const reg = await navigator.serviceWorker.register('/wasm/service_worker.js', { scope: '/', type: 'module' });
|
|
10
|
+
console.log('[wasm/boot] Service Worker registered', reg.scope);
|
|
11
|
+
|
|
12
|
+
// If a SW is already active with no pending update, boot completed in a previous session.
|
|
13
|
+
if (reg.active && !reg.installing && !reg.waiting) {
|
|
14
|
+
console.log('[wasm/boot] SW already active');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
navigator.serviceWorker.addEventListener('message', function handler({ data }) {
|
|
20
|
+
if (data.type === 'progress') {
|
|
21
|
+
window.dispatchEvent(new CustomEvent('wasm-progress', { detail: data }));
|
|
22
|
+
} else if (data.type === 'ready') {
|
|
23
|
+
navigator.serviceWorker.removeEventListener('message', handler);
|
|
24
|
+
resolve();
|
|
25
|
+
} else if (data.type === 'error') {
|
|
26
|
+
navigator.serviceWorker.removeEventListener('message', handler);
|
|
27
|
+
reject(new Error(data.message));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bundles Ruby source files + gem lib files into public/wasm/app_bundle.json.
|
|
3
|
+
// Also generates public/wasm/wasm_setup.rb which sets up $LOAD_PATH inside WASM.
|
|
4
|
+
|
|
5
|
+
import { readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
6
|
+
import { resolve, relative, extname, dirname, join } from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { execSync } from 'child_process';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const root = resolve(__dirname, '..');
|
|
12
|
+
const outdir = resolve(root, 'public', 'wasm');
|
|
13
|
+
|
|
14
|
+
mkdirSync(outdir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
// ── App source files ──────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const APP_DIRS = [
|
|
19
|
+
'app/models', 'app/controllers', 'app/helpers', 'app/views',
|
|
20
|
+
'app/mailers', 'app/jobs', 'app/services', 'config', 'db/migrate', 'lib',
|
|
21
|
+
];
|
|
22
|
+
const APP_FILES = [
|
|
23
|
+
'db/schema.rb', 'db/seeds.rb',
|
|
24
|
+
'public/assets/.manifest.json',
|
|
25
|
+
];
|
|
26
|
+
const EXCLUDE = [/node_modules/, /\.git/, /tmp\//, /log\//, /public\//, /storage\//, /\.DS_Store/];
|
|
27
|
+
const SOURCE_EXTS = new Set(['.rb', '.erb', '.yml', '.yaml', '.json', '.ru']);
|
|
28
|
+
|
|
29
|
+
function shouldExclude(p) { return EXCLUDE.some(r => r.test(p)); }
|
|
30
|
+
|
|
31
|
+
function collectDir(dir, mountPath, bundle) {
|
|
32
|
+
const abs = resolve(root, dir);
|
|
33
|
+
try {
|
|
34
|
+
const walk = (cur) => {
|
|
35
|
+
if (shouldExclude(cur)) return;
|
|
36
|
+
const stat = statSync(cur);
|
|
37
|
+
if (stat.isDirectory()) {
|
|
38
|
+
readdirSync(cur).forEach(f => walk(resolve(cur, f)));
|
|
39
|
+
} else if (SOURCE_EXTS.has(extname(cur).toLowerCase())) {
|
|
40
|
+
const rel = mountPath + '/' + relative(abs, cur);
|
|
41
|
+
bundle[rel] = Buffer.from(readFileSync(cur)).toString('base64');
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
walk(abs);
|
|
45
|
+
} catch { /* skip missing dirs */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Gem source files ──────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const NATIVE_GEMS = new Set([
|
|
51
|
+
'sqlite3', 'puma', 'bootsnap', 'nio4r', 'ffi', 'nokogiri',
|
|
52
|
+
'msgpack', 'bcrypt', 'ed25519', 'bcrypt_pbkdf', 'bindex',
|
|
53
|
+
'websocket-driver', 'websocket-extensions', 'image_processing',
|
|
54
|
+
'mini_magick', 'ruby-vips', 'selenium-webdriver', 'capybara',
|
|
55
|
+
'debug', 'web-console', 'kamal', 'thruster',
|
|
56
|
+
// loofah and rails-html-sanitizer depend on nokogiri — stub them instead
|
|
57
|
+
'loofah', 'rails-html-sanitizer', 'crass',
|
|
58
|
+
// dev/build tools
|
|
59
|
+
'rubocop', 'rubocop-rails-omakase', 'brakeman', 'bundler-audit',
|
|
60
|
+
'tailwindcss-rails', 'tailwindcss-ruby',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
// Extra non-lib paths to bundle for specific gems (e.g. Rails engines with app/ dirs)
|
|
64
|
+
const GEM_EXTRA_PATHS = {
|
|
65
|
+
'turbo-rails': [
|
|
66
|
+
'app/controllers',
|
|
67
|
+
'app/controllers/concerns',
|
|
68
|
+
'app/helpers',
|
|
69
|
+
'app/models',
|
|
70
|
+
'app/models/concerns',
|
|
71
|
+
'app/views',
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const STDLIB_GEMS = new Set([
|
|
76
|
+
'json', 'psych', 'stringio', 'date', 'bigdecimal', 'racc',
|
|
77
|
+
'strscan', 'io-console', 'timeout', 'logger', 'ostruct',
|
|
78
|
+
'prism', 'rbs',
|
|
79
|
+
'bundler',
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
function getGemSpecs() {
|
|
83
|
+
try {
|
|
84
|
+
const json = execSync(
|
|
85
|
+
'bundle exec ruby -e \'' +
|
|
86
|
+
'require "json"; ' +
|
|
87
|
+
'puts Gem.loaded_specs.values.map { |s| ' +
|
|
88
|
+
' { name: s.name, version: s.version.to_s, gem_dir: s.gem_dir, ' +
|
|
89
|
+
' require_paths: s.require_paths } ' +
|
|
90
|
+
'}.to_json\'',
|
|
91
|
+
{ cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }
|
|
92
|
+
);
|
|
93
|
+
return JSON.parse(json);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.warn('[build_app_bundle] Could not enumerate gems:', e.message);
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function collectGems(bundle) {
|
|
101
|
+
const specs = getGemSpecs();
|
|
102
|
+
const loadPaths = [];
|
|
103
|
+
let gemFileCount = 0;
|
|
104
|
+
|
|
105
|
+
for (const spec of specs) {
|
|
106
|
+
if (NATIVE_GEMS.has(spec.name) || STDLIB_GEMS.has(spec.name)) continue;
|
|
107
|
+
|
|
108
|
+
const allPaths = [
|
|
109
|
+
...spec.require_paths,
|
|
110
|
+
...(GEM_EXTRA_PATHS[spec.name] || []),
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
for (const rp of allPaths) {
|
|
114
|
+
const libDir = join(spec.gem_dir, rp);
|
|
115
|
+
const mountAt = `/gems/${spec.name}-${spec.version}/${rp}`;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
statSync(libDir);
|
|
119
|
+
} catch { continue; }
|
|
120
|
+
|
|
121
|
+
loadPaths.push(mountAt);
|
|
122
|
+
|
|
123
|
+
const walk = (cur) => {
|
|
124
|
+
try { statSync(cur); } catch { return; }
|
|
125
|
+
if (statSync(cur).isDirectory()) {
|
|
126
|
+
readdirSync(cur).forEach(f => walk(join(cur, f)));
|
|
127
|
+
} else if (['.rb', '.erb', '.yml', '.yaml'].includes(extname(cur))) {
|
|
128
|
+
const rel = mountAt + '/' + relative(libDir, cur);
|
|
129
|
+
bundle[rel] = Buffer.from(readFileSync(cur)).toString('base64');
|
|
130
|
+
gemFileCount++;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
walk(libDir);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(`[build_app_bundle] Bundled ${gemFileCount} gem .rb files from ${loadPaths.length} load paths`);
|
|
138
|
+
return loadPaths;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Build ─────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
// 1. App source — mounted at /app/... in the WASM virtual FS
|
|
144
|
+
const cleanBundle = {};
|
|
145
|
+
for (const dir of APP_DIRS) {
|
|
146
|
+
const abs = resolve(root, dir);
|
|
147
|
+
try {
|
|
148
|
+
const walk = (cur) => {
|
|
149
|
+
if (shouldExclude(cur)) return;
|
|
150
|
+
if (statSync(cur).isDirectory()) {
|
|
151
|
+
readdirSync(cur).forEach(f => walk(resolve(cur, f)));
|
|
152
|
+
} else if (SOURCE_EXTS.has(extname(cur).toLowerCase())) {
|
|
153
|
+
const rel = '/app/' + relative(root, cur);
|
|
154
|
+
cleanBundle[rel] = Buffer.from(readFileSync(cur)).toString('base64');
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
walk(abs);
|
|
158
|
+
} catch { /* skip */ }
|
|
159
|
+
}
|
|
160
|
+
for (const file of APP_FILES) {
|
|
161
|
+
try {
|
|
162
|
+
const abs = resolve(root, file);
|
|
163
|
+
cleanBundle['/app/' + file] = Buffer.from(readFileSync(abs)).toString('base64');
|
|
164
|
+
} catch { /* skip */ }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2. WASM stubs — C extensions not available in ruby+stdlib.wasm
|
|
168
|
+
const stubsDir = resolve(root, 'wasm_stubs');
|
|
169
|
+
try {
|
|
170
|
+
const walkStubs = (cur) => {
|
|
171
|
+
if (statSync(cur).isDirectory()) {
|
|
172
|
+
readdirSync(cur).forEach(f => walkStubs(resolve(cur, f)));
|
|
173
|
+
} else if (extname(cur) === '.rb') {
|
|
174
|
+
cleanBundle['/stubs/' + relative(stubsDir, cur)] = Buffer.from(readFileSync(cur)).toString('base64');
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
walkStubs(stubsDir);
|
|
178
|
+
console.log(`[build_app_bundle] Bundled ${Object.keys(cleanBundle).filter(k => k.startsWith('/stubs/')).length} WASM stubs`);
|
|
179
|
+
} catch { /* wasm_stubs dir missing — skip */ }
|
|
180
|
+
|
|
181
|
+
// 3. Gem source files + collect load paths
|
|
182
|
+
const gemLoadPaths = collectGems(cleanBundle);
|
|
183
|
+
|
|
184
|
+
// 4. Generate wasm_setup.rb
|
|
185
|
+
const setupRb = [
|
|
186
|
+
'# Auto-generated by bin/build_app_bundle.mjs — do not edit',
|
|
187
|
+
'$LOAD_PATH.unshift("/stubs")',
|
|
188
|
+
'$LOAD_PATH.unshift("/app")',
|
|
189
|
+
...gemLoadPaths.map(p => `$LOAD_PATH.unshift("${p}")`),
|
|
190
|
+
].join("\n") + "\n";
|
|
191
|
+
|
|
192
|
+
writeFileSync(resolve(outdir, 'wasm_setup.rb'), setupRb);
|
|
193
|
+
cleanBundle['/wasm_setup.rb'] = Buffer.from(setupRb).toString('base64');
|
|
194
|
+
|
|
195
|
+
// 5. Write bundle
|
|
196
|
+
const outPath = resolve(outdir, 'app_bundle.json');
|
|
197
|
+
writeFileSync(outPath, JSON.stringify(cleanBundle));
|
|
198
|
+
|
|
199
|
+
const count = Object.keys(cleanBundle).length;
|
|
200
|
+
const size = (Buffer.byteLength(JSON.stringify(cleanBundle)) / 1024 / 1024).toFixed(1);
|
|
201
|
+
console.log(`[build_app_bundle] Total: ${count} files → ${size} MB → public/wasm/app_bundle.json`);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bundles the WASM service worker and boot helper into public/wasm/.
|
|
3
|
+
// Add your own app entry points to the entryPoints array below.
|
|
4
|
+
|
|
5
|
+
import * as esbuild from 'esbuild';
|
|
6
|
+
import { cpSync, mkdirSync } from 'fs';
|
|
7
|
+
import { resolve, dirname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const root = resolve(__dirname, '..');
|
|
12
|
+
const outdir = resolve(root, 'public', 'wasm');
|
|
13
|
+
|
|
14
|
+
mkdirSync(outdir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
const watching = process.argv.includes('--watch');
|
|
17
|
+
|
|
18
|
+
const ctx = await esbuild.context({
|
|
19
|
+
entryPoints: [
|
|
20
|
+
resolve(root, 'app/javascript/wasm/service_worker.js'),
|
|
21
|
+
resolve(root, 'app/javascript/wasm/boot.js'),
|
|
22
|
+
// Add your app-specific WASM entry points here:
|
|
23
|
+
// resolve(root, 'app/javascript/wasm/auth.js'),
|
|
24
|
+
// resolve(root, 'app/javascript/wasm/proxy_client.js'),
|
|
25
|
+
],
|
|
26
|
+
bundle: true,
|
|
27
|
+
format: 'esm',
|
|
28
|
+
splitting: false,
|
|
29
|
+
outdir,
|
|
30
|
+
sourcemap: true,
|
|
31
|
+
define: {
|
|
32
|
+
'process.env.NODE_ENV': '"production"',
|
|
33
|
+
'__BUILD_ID__': JSON.stringify(Date.now().toString()),
|
|
34
|
+
'__RUBY_WASM_URL__': JSON.stringify(process.env.WASM_BASE_URL ? `${process.env.WASM_BASE_URL}/ruby+stdlib.wasm` : '/wasm/ruby+stdlib.wasm'),
|
|
35
|
+
'__APP_BUNDLE_URL__': JSON.stringify(process.env.WASM_BASE_URL ? `${process.env.WASM_BASE_URL}/app_bundle.json` : '/wasm/app_bundle.json'),
|
|
36
|
+
},
|
|
37
|
+
loader: { '.wasm': 'file' },
|
|
38
|
+
assetNames: '[name]',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
cpSync(
|
|
42
|
+
resolve(root, 'node_modules/@ruby/3.3-wasm-wasi/dist/ruby+stdlib.wasm'),
|
|
43
|
+
resolve(outdir, 'ruby+stdlib.wasm')
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
cpSync(
|
|
47
|
+
resolve(root, 'node_modules/@sqlite.org/sqlite-wasm/dist/sqlite3.wasm'),
|
|
48
|
+
resolve(outdir, 'sqlite3.wasm')
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (watching) {
|
|
52
|
+
await ctx.watch();
|
|
53
|
+
console.log('[esbuild_wasm] Watching…');
|
|
54
|
+
} else {
|
|
55
|
+
await ctx.rebuild();
|
|
56
|
+
await ctx.dispose();
|
|
57
|
+
console.log('[esbuild_wasm] Built → public/wasm/');
|
|
58
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Static file server for local WASM testing.
|
|
3
|
+
// Sets the COOP/COEP headers required for SharedArrayBuffer (and therefore Atomics).
|
|
4
|
+
|
|
5
|
+
import { createServer } from 'http';
|
|
6
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
7
|
+
import { resolve, extname, dirname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
const PORT = process.env.PORT || 3100;
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const PUBLIC = resolve(__dirname, '../public');
|
|
13
|
+
|
|
14
|
+
const MIME = {
|
|
15
|
+
'.html': 'text/html; charset=utf-8',
|
|
16
|
+
'.js': 'application/javascript',
|
|
17
|
+
'.mjs': 'application/javascript',
|
|
18
|
+
'.wasm': 'application/wasm',
|
|
19
|
+
'.json': 'application/json',
|
|
20
|
+
'.css': 'text/css',
|
|
21
|
+
'.png': 'image/png',
|
|
22
|
+
'.svg': 'image/svg+xml',
|
|
23
|
+
'.ico': 'image/x-icon',
|
|
24
|
+
'.txt': 'text/plain',
|
|
25
|
+
'.map': 'application/json',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const SAB_HEADERS = {
|
|
29
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
30
|
+
'Cross-Origin-Embedder-Policy': 'credentialless',
|
|
31
|
+
'Cross-Origin-Resource-Policy': 'cross-origin',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const server = createServer((req, res) => {
|
|
35
|
+
const urlPath = req.url.split('?')[0];
|
|
36
|
+
let filePath = resolve(PUBLIC, '.' + urlPath);
|
|
37
|
+
|
|
38
|
+
if (urlPath === '/' || urlPath === '') {
|
|
39
|
+
filePath = resolve(PUBLIC, 'wasm_shell.html');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
|
|
43
|
+
if (!extname(urlPath)) {
|
|
44
|
+
filePath = resolve(PUBLIC, 'wasm_shell.html');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!existsSync(filePath)) {
|
|
49
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
50
|
+
res.end(`404 Not Found: ${urlPath}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ext = extname(filePath).toLowerCase();
|
|
55
|
+
const contentType = MIME[ext] || 'application/octet-stream';
|
|
56
|
+
|
|
57
|
+
Object.entries(SAB_HEADERS).forEach(([k, v]) => res.setHeader(k, v));
|
|
58
|
+
res.setHeader('Content-Type', contentType);
|
|
59
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
60
|
+
|
|
61
|
+
if (urlPath === '/wasm/service_worker.js') {
|
|
62
|
+
res.setHeader('Service-Worker-Allowed', '/');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const body = readFileSync(filePath);
|
|
67
|
+
res.writeHead(200);
|
|
68
|
+
res.end(body);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
71
|
+
res.end(e.message);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
76
|
+
console.log('');
|
|
77
|
+
console.log(' Budget Clear WASM test server');
|
|
78
|
+
console.log(` http://localhost:${PORT}`);
|
|
79
|
+
console.log('');
|
|
80
|
+
console.log(' SharedArrayBuffer headers: ✓');
|
|
81
|
+
console.log(' Serving from: client/public/');
|
|
82
|
+
console.log('');
|
|
83
|
+
console.log(' Open http://localhost:3100 — first boot downloads ruby+stdlib.wasm (34MB)');
|
|
84
|
+
console.log(' Subsequent loads are instant (cached by Service Worker).');
|
|
85
|
+
console.log('');
|
|
86
|
+
});
|