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 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
+ });