wasmify-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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/LICENSE.txt +23 -0
  4. data/README.md +168 -0
  5. data/lib/active_record/connection_adapters/nulldb_adapter/checkpoint.rb +13 -0
  6. data/lib/active_record/connection_adapters/nulldb_adapter/column.rb +4 -0
  7. data/lib/active_record/connection_adapters/nulldb_adapter/configuration.rb +5 -0
  8. data/lib/active_record/connection_adapters/nulldb_adapter/core.rb +391 -0
  9. data/lib/active_record/connection_adapters/nulldb_adapter/empty_result.rb +41 -0
  10. data/lib/active_record/connection_adapters/nulldb_adapter/index_definition.rb +5 -0
  11. data/lib/active_record/connection_adapters/nulldb_adapter/null_object.rb +13 -0
  12. data/lib/active_record/connection_adapters/nulldb_adapter/quoting.rb +3 -0
  13. data/lib/active_record/connection_adapters/nulldb_adapter/statement.rb +15 -0
  14. data/lib/active_record/connection_adapters/nulldb_adapter/table_definition.rb +23 -0
  15. data/lib/active_record/connection_adapters/nulldb_adapter.rb +67 -0
  16. data/lib/active_record/connection_adapters/sqlite3_wasm_adapter.rb +163 -0
  17. data/lib/active_storage/null_delivery.rb +15 -0
  18. data/lib/generators/wasmify/install/USAGE +5 -0
  19. data/lib/generators/wasmify/install/install_generator.rb +73 -0
  20. data/lib/generators/wasmify/install/templates/config/environments/wasm.rb +30 -0
  21. data/lib/generators/wasmify/install/templates/config/wasmify.yml +17 -0
  22. data/lib/generators/wasmify/pwa/USAGE +5 -0
  23. data/lib/generators/wasmify/pwa/pwa_generator.rb +13 -0
  24. data/lib/generators/wasmify/pwa/templates/pwa/README.md +40 -0
  25. data/lib/generators/wasmify/pwa/templates/pwa/boot.html +102 -0
  26. data/lib/generators/wasmify/pwa/templates/pwa/boot.js +96 -0
  27. data/lib/generators/wasmify/pwa/templates/pwa/database.js +18 -0
  28. data/lib/generators/wasmify/pwa/templates/pwa/index.html +2 -0
  29. data/lib/generators/wasmify/pwa/templates/pwa/package.json.tt +20 -0
  30. data/lib/generators/wasmify/pwa/templates/pwa/rails.sw.js +115 -0
  31. data/lib/generators/wasmify/pwa/templates/pwa/vite.config.js +29 -0
  32. data/lib/image_processing/null.rb +22 -0
  33. data/lib/wasmify/rails/builder.rb +45 -0
  34. data/lib/wasmify/rails/configuration.rb +41 -0
  35. data/lib/wasmify/rails/packer.rb +60 -0
  36. data/lib/wasmify/rails/railtie.rb +11 -0
  37. data/lib/wasmify/rails/shim.rb +65 -0
  38. data/lib/wasmify/rails/shims/io/console/size.rb +0 -0
  39. data/lib/wasmify/rails/shims/io/console.rb +11 -0
  40. data/lib/wasmify/rails/shims/io/wait.rb +0 -0
  41. data/lib/wasmify/rails/shims/ipaddr.rb +7 -0
  42. data/lib/wasmify/rails/shims/nio.rb +0 -0
  43. data/lib/wasmify/rails/shims/nokogiri/nokogiri.rb +0 -0
  44. data/lib/wasmify/rails/shims/nokogiri.rb +0 -0
  45. data/lib/wasmify/rails/shims/rails-html-sanitizer.rb +163 -0
  46. data/lib/wasmify/rails/shims/socket.rb +21 -0
  47. data/lib/wasmify/rails/shims/sqlite3.rb +0 -0
  48. data/lib/wasmify/rails/tasks.rake +120 -0
  49. data/lib/wasmify/rails/version.rb +7 -0
  50. data/lib/wasmify/rails/wasmtimer.rb +31 -0
  51. data/lib/wasmify-rails.rb +25 -0
  52. metadata +200 -0
@@ -0,0 +1,2 @@
1
+ <meta http-equiv="Refresh" content="0; url=/boot.html" />
2
+ <p>Please follow <a href="/boot.html">this link</a>.</p>
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "wasmify-rails-starter",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "packageManager": "yarn@1.22.22",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "vite build",
10
+ "preview": "vite preview"
11
+ },
12
+ "devDependencies": {
13
+ "vite": "^5.4.7",
14
+ "vite-plugin-pwa": "^0.20.5"
15
+ },
16
+ "dependencies": {
17
+ "wasmify-rails": "^<%= wasmify_rails_version %>",
18
+ "@sqlite.org/sqlite-wasm": "3.46.1-build3"
19
+ }
20
+ }
@@ -0,0 +1,115 @@
1
+ import {
2
+ initRailsVM,
3
+ Progress,
4
+ registerSQLiteWasmInterface,
5
+ RackHandler,
6
+ } from "wasmify-rails";
7
+
8
+ import { setupSQLiteDatabase } from "./database.js";
9
+
10
+ let db = null;
11
+
12
+ const initDB = async (progress) => {
13
+ if (db) return db;
14
+
15
+ progress.updateStep("Initializing SQLite database...");
16
+ db = await setupSQLiteDatabase();
17
+ progress.updateStep("SQLite database created.");
18
+ };
19
+
20
+ let vm = null;
21
+
22
+ const initVM = async (progress, opts = {}) => {
23
+ if (vm) return vm;
24
+
25
+ if (!db) {
26
+ await initDB(progress);
27
+ }
28
+
29
+ registerSQLiteWasmInterface(self, db);
30
+
31
+ let redirectConsole = true;
32
+
33
+ vm = await initRailsVM("/app.wasm", {
34
+ database: { adapter: "sqlite3_wasm" },
35
+ progressCallback: (step) => {
36
+ progress?.updateStep(step);
37
+ },
38
+ outputCallback: (output) => {
39
+ if (!redirectConsole) return;
40
+ progress?.notify(output);
41
+ },
42
+ ...opts,
43
+ });
44
+
45
+ // Ensure schema is loaded
46
+ progress?.updateStep("Preparing database...");
47
+ vm.eval("ActiveRecord::Tasks::DatabaseTasks.prepare_all");
48
+
49
+ redirectConsole = false;
50
+ };
51
+
52
+ const resetVM = () => {
53
+ vm = null;
54
+ };
55
+
56
+ const installApp = async () => {
57
+ const progress = new Progress();
58
+ await progress.attach(self);
59
+
60
+ await initDB(progress);
61
+ await initVM(progress);
62
+ };
63
+
64
+ self.addEventListener("activate", (event) => {
65
+ console.log("[rails-web] Activate Service Worker");
66
+ });
67
+
68
+ self.addEventListener("install", (event) => {
69
+ console.log("[rails-web] Install Service Worker");
70
+ event.waitUntil(installApp());
71
+ });
72
+
73
+ const rackHandler = new RackHandler(initVM, { assumeSSL: true });
74
+
75
+ self.addEventListener("fetch", (event) => {
76
+ const bootResources = ["/boot", "/boot.js", "/boot.html", "/rails.sw.js"];
77
+
78
+ if (
79
+ bootResources.find((r) => new URL(event.request.url).pathname.endsWith(r))
80
+ ) {
81
+ console.log(
82
+ "[rails-web] Fetching boot files from network:",
83
+ event.request.url,
84
+ );
85
+ event.respondWith(fetch(event.request.url));
86
+ return;
87
+ }
88
+
89
+ const viteResources = ["node_modules", "@vite"];
90
+
91
+ if (viteResources.find((r) => event.request.url.includes(r))) {
92
+ console.log(
93
+ "[rails-web] Fetching Vite files from network:",
94
+ event.request.url,
95
+ );
96
+ event.respondWith(fetch(event.request.url));
97
+ return;
98
+ }
99
+
100
+ return event.respondWith(rackHandler.handle(event.request));
101
+ });
102
+
103
+ self.addEventListener("message", async (event) => {
104
+ console.log("[rails-web] Received worker message:", event.data);
105
+
106
+ if (event.data.type === "reload-rails") {
107
+ const progress = new Progress();
108
+ await progress.attach(self);
109
+
110
+ progress.updateStep("Reloading Rails application...");
111
+
112
+ resetVM();
113
+ await initVM(progress, { debug: event.data.debug });
114
+ }
115
+ });
@@ -0,0 +1,29 @@
1
+ import { defineConfig } from "vite";
2
+ import { VitePWA } from "vite-plugin-pwa";
3
+
4
+ export default defineConfig({
5
+ server: {
6
+ headers: {
7
+ "Cross-Origin-Opener-Policy": "same-origin",
8
+ "Cross-Origin-Embedder-Policy": "require-corp",
9
+ },
10
+ },
11
+ optimizeDeps: {
12
+ exclude: ["@sqlite.org/sqlite-wasm"],
13
+ },
14
+ plugins: [
15
+ VitePWA({
16
+ srcDir: ".",
17
+ filename: "rails.sw.js",
18
+ strategies: "injectManifest",
19
+ injectRegister: false,
20
+ manifest: false,
21
+ injectManifest: {
22
+ injectionPoint: null,
23
+ },
24
+ devOptions: {
25
+ enabled: true,
26
+ },
27
+ }),
28
+ ],
29
+ });
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageProcessing
4
+ # Null processor for image_processing that keeps source files untouched
5
+ # and copy them to the destination if provided.
6
+ module Null
7
+ extend Chainable
8
+
9
+ def self.valid_image?(file) = true
10
+
11
+ class Processor < ImageProcessing::Processor
12
+ def self.call(source:, loader:, operations:, saver:, destination: nil)
13
+ fail ArgumentError, "A string path is expected, got #{source.class}" unless source.is_a?(String)
14
+ fail ArgumentError, "File not found: #{source}" unless File.file?(source)
15
+
16
+ if destination
17
+ FileUtils.cp(source, destination)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wasmify-rails"
4
+
5
+ require "ruby_wasm"
6
+ require "ruby_wasm/cli"
7
+
8
+ module Wasmify
9
+ module Rails
10
+ # A wrapper for rbwasm build command
11
+ class Builder
12
+ ORIGINAL_EXCLUDED_GEMS = RubyWasm::Packager::EXCLUDED_GEMS.dup.freeze
13
+
14
+ attr_reader :output_dir
15
+
16
+ def initialize(output_dir: Wasmify::Rails.config.tmp_dir)
17
+ @output_dir = output_dir
18
+ end
19
+
20
+ def run(name:, exclude_gems: [])
21
+ # Reset excluded gems
22
+ RubyWasm::Packager::EXCLUDED_GEMS.replace(ORIGINAL_EXCLUDED_GEMS)
23
+
24
+ # Add configured excluded gems
25
+ Wasmify::Rails.config.exclude_gems.each do |gem_name|
26
+ RubyWasm::Packager::EXCLUDED_GEMS << gem_name
27
+ end
28
+
29
+ # Add additional excluded gems
30
+ exclude_gems.each do |gem_name|
31
+ RubyWasm::Packager::EXCLUDED_GEMS << gem_name
32
+ end
33
+
34
+ args = %W(
35
+ build
36
+ --ruby-version #{Wasmify::Rails.config.short_ruby_version}
37
+ -o #{File.join(output_dir, name)}
38
+ )
39
+
40
+ FileUtils.mkdir_p(output_dir)
41
+ RubyWasm::CLI.new(stdout: $stdout, stderr: $stderr).run(args)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wasmify
4
+ module Rails
5
+ class Configuration
6
+ attr_reader :pack_directories, :exclude_gems, :ruby_version,
7
+ :tmp_dir, :output_dir,
8
+ :skip_assets_precompile
9
+
10
+ def initialize
11
+ config_path = ::Rails.root.join("config", "wasmify.yml")
12
+ raise "config/wasmify.yml not found" unless File.exist?(config_path)
13
+
14
+ config = YAML.load_file(config_path)
15
+
16
+ @pack_directories = config["pack_directories"]
17
+ @exclude_gems = config["exclude_gems"]
18
+ @ruby_version = config["ruby_version"] || ruby_version_from_file || "3.3"
19
+ @tmp_dir = config["tmp_dir"] || ::Rails.root.join("tmp", "wasmify")
20
+ @output_dir = config["output_dir"] || ::Rails.root.join("dist")
21
+ @skip_assets_precompile = config["skip_assets_precompile"] || false
22
+ end
23
+
24
+ def short_ruby_version
25
+ if (matches = ruby_version.match(/^(\d+\.\d+)/))
26
+ matches[1]
27
+ else
28
+ ruby_version
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def ruby_version_from_file
35
+ return unless File.file?(::Rails.root.join(".ruby-version"))
36
+
37
+ File.read(::Rails.root.join(".ruby-version")).strip
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wasmify-rails"
4
+ require "tmpdir"
5
+
6
+ module Wasmify
7
+ module Rails
8
+ # A wrapper for wasi-vfs to add application source code to the
9
+ # ruby.wasm module
10
+ class Packer
11
+ ROOT_FILES = %w[Gemfile config.ru .ruby-version Rakefile]
12
+
13
+ attr_reader :output_dir
14
+
15
+ include ActionView::Helpers::NumberHelper
16
+
17
+ def initialize(output_dir: Wasmify::Rails.config.output_dir)
18
+ @output_dir = output_dir
19
+ end
20
+
21
+ def run(ruby_wasm_path:, name:, directories: Wasmify::Rails.config.pack_directories, storage_dir: nil)
22
+ unless system("which wasi-vfs > /dev/null 2>&1")
23
+ raise "wasi-vfs is required to pack the application.\n"
24
+ "Please see installations instructions at: https://github.com/kateinoigakukun/wasi-vfs"
25
+ end
26
+
27
+ args = %W[
28
+ pack #{ruby_wasm_path}
29
+ ]
30
+
31
+ directories.each do |dir|
32
+ args << "--dir #{::Rails.root.join(dir)}::/rails/#{dir}"
33
+ end
34
+
35
+ output_path = File.join(output_dir, name)
36
+
37
+ FileUtils.mkdir_p(output_dir)
38
+
39
+ # Generate a temporary directory with the require root files
40
+ Dir.mktmpdir do |tdir|
41
+ ROOT_FILES.each do |file|
42
+ FileUtils.cp(::Rails.root.join(file), tdir) if File.exist?(::Rails.root.join(file))
43
+ end
44
+
45
+ # Create a storage/ directory for Active Storage attachments
46
+ FileUtils.mkdir_p(File.join(tdir, storage_dir)) if storage_dir
47
+
48
+ args << "--dir #{tdir}::/rails"
49
+
50
+ args << "-o #{output_path}"
51
+
52
+ spawn("wasi-vfs #{args.join(" ")}").then { Process.wait(_1) }
53
+ end
54
+
55
+ $stdout.puts "Packed the application to #{output_path}\n" \
56
+ "Size: #{number_to_human_size(File.size(output_path))}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wasmify
4
+ module Rails
5
+ class Railtie < ::Rails::Railtie
6
+ rake_tasks do
7
+ load "wasmify/rails/tasks.rake"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # First, expose the global #on_wasm? helper
4
+ module Kernel
5
+ if RUBY_PLATFORM.include?("wasm")
6
+ def on_wasm? = true
7
+ else
8
+ def on_wasm? = false
9
+ end
10
+ end
11
+
12
+ # Only load shims when running within a Wasm runtime
13
+ return unless on_wasm?
14
+
15
+ # Setup Bundler
16
+ require "/bundle/setup"
17
+ require "bundler"
18
+
19
+ # Load core classes and deps patches
20
+ $LOAD_PATH.unshift File.expand_path("shims", __dir__)
21
+
22
+ # Misc patches
23
+
24
+ # Make gem no-op
25
+ define_singleton_method(:gem) { |*| nil }
26
+
27
+ # Patch Bundler.require to simply require files without looking at specs
28
+ def Bundler.require(*groups)
29
+ Bundler.definition.dependencies_for([:wasm]).each do |dep|
30
+ required_file = nil
31
+ # Based on https://github.com/rubygems/rubygems/blob/8a079e9061ad4aaf2bc0b9007da8f362b7a2e1f2/bundler/lib/bundler/runtime.rb#L57
32
+ begin
33
+ Array(dep.autorequire || dep.name).each do |file|
34
+ file = dep.name if file == true
35
+ required_file = file
36
+ begin
37
+ Kernel.require file
38
+ rescue RuntimeError => e
39
+ raise e if e.is_a?(LoadError) # we handle this a little later
40
+ raise Bundler::GemRequireError.new e,
41
+ "There was an error while trying to load the gem '#{file}'."
42
+ end
43
+ end
44
+ rescue LoadError => e
45
+ raise if dep.autorequire || e.path != required_file
46
+
47
+ if dep.autorequire.nil? && dep.name.include?("-")
48
+ begin
49
+ namespaced_file = dep.name.tr("-", "/")
50
+ Kernel.require namespaced_file
51
+ rescue LoadError => e
52
+ raise if e.path != namespaced_file
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ class Thread
60
+ def self.new(...)
61
+ f = Fiber.new(...)
62
+ def f.value = resume
63
+ f
64
+ end
65
+ end
File without changes
@@ -0,0 +1,11 @@
1
+ class IO
2
+ def winsize
3
+ [80, 24]
4
+ end
5
+ def wait_readable(timeout = nil)
6
+ false
7
+ end
8
+ def raw(**kwargs)
9
+ yield
10
+ end
11
+ end
File without changes
@@ -0,0 +1,7 @@
1
+ class IPAddr
2
+ def initialize(addr)
3
+ @addr = addr
4
+ end
5
+
6
+ def to_s = @addr
7
+ end
File without changes
File without changes
File without changes
@@ -0,0 +1,163 @@
1
+ module Rails
2
+ module HTML
3
+ class Scrubber
4
+ CONTINUE = Object.new.freeze
5
+
6
+ attr_accessor :tags, :attributes
7
+ attr_reader :prune
8
+
9
+ def initialize(**)
10
+ end
11
+
12
+ def scrub(node) = CONTINUE
13
+ end
14
+
15
+ PermitScrubber = Scrubber
16
+ TargetScrubber = Scrubber
17
+ TextOnlyScrubber = Scrubber
18
+
19
+ class Sanitizer
20
+ class << self
21
+ def html5_support? = true
22
+ def best_supported_vendor = NullSanitizer
23
+ end
24
+ end
25
+
26
+ module Concern
27
+ module SafeList
28
+ # The default safe list for tags
29
+ DEFAULT_ALLOWED_TAGS = Set.new([
30
+ "a",
31
+ "abbr",
32
+ "acronym",
33
+ "address",
34
+ "b",
35
+ "big",
36
+ "blockquote",
37
+ "br",
38
+ "cite",
39
+ "code",
40
+ "dd",
41
+ "del",
42
+ "dfn",
43
+ "div",
44
+ "dl",
45
+ "dt",
46
+ "em",
47
+ "h1",
48
+ "h2",
49
+ "h3",
50
+ "h4",
51
+ "h5",
52
+ "h6",
53
+ "hr",
54
+ "i",
55
+ "img",
56
+ "ins",
57
+ "kbd",
58
+ "li",
59
+ "mark",
60
+ "ol",
61
+ "p",
62
+ "pre",
63
+ "samp",
64
+ "small",
65
+ "span",
66
+ "strong",
67
+ "sub",
68
+ "sup",
69
+ "time",
70
+ "tt",
71
+ "ul",
72
+ "var",
73
+ ]).freeze
74
+
75
+ # The default safe list for attributes
76
+ DEFAULT_ALLOWED_ATTRIBUTES = Set.new([
77
+ "abbr",
78
+ "alt",
79
+ "cite",
80
+ "class",
81
+ "datetime",
82
+ "height",
83
+ "href",
84
+ "lang",
85
+ "name",
86
+ "src",
87
+ "title",
88
+ "width",
89
+ "xml:lang",
90
+ ]).freeze
91
+
92
+ def self.included(klass)
93
+ class << klass
94
+ attr_accessor :allowed_tags
95
+ attr_accessor :allowed_attributes
96
+ end
97
+
98
+ klass.allowed_tags = DEFAULT_ALLOWED_TAGS.dup
99
+ klass.allowed_attributes = DEFAULT_ALLOWED_ATTRIBUTES.dup
100
+ end
101
+
102
+ def initialize(prune: false)
103
+ @permit_scrubber = PermitScrubber.new(prune: prune)
104
+ end
105
+
106
+ def scrub(fragment, options = {})
107
+ if scrubber = options[:scrubber]
108
+ # No duck typing, Loofah ensures subclass of Loofah::Scrubber
109
+ fragment.scrub!(scrubber)
110
+ elsif allowed_tags(options) || allowed_attributes(options)
111
+ @permit_scrubber.tags = allowed_tags(options)
112
+ @permit_scrubber.attributes = allowed_attributes(options)
113
+ fragment.scrub!(@permit_scrubber)
114
+ else
115
+ fragment.scrub!(:strip)
116
+ end
117
+ end
118
+
119
+ def sanitize_css(style_string)
120
+ Loofah::HTML5::Scrub.scrub_css(style_string)
121
+ end
122
+
123
+ private
124
+ def allowed_tags(options)
125
+ options[:tags] || self.class.allowed_tags
126
+ end
127
+
128
+ def allowed_attributes(options)
129
+ options[:attributes] || self.class.allowed_attributes
130
+ end
131
+ end
132
+ end
133
+
134
+ # TODO: That should be a real sanitizer (backed by JS or another external interface)
135
+ class NullSanitizer
136
+ class << self
137
+ def safe_list_sanitizer = self
138
+ end
139
+
140
+ def sanitize(html, ...) = html
141
+ def sanitize_css(style) = style
142
+ end
143
+ end
144
+
145
+ module HTML4
146
+ Sanitizer = HTML::NullSanitizer
147
+ FullSanitizer = Sanitizer
148
+ LinkSanitizer = Sanitizer
149
+
150
+ class SafeListSanitizer < Sanitizer
151
+ include HTML::Concern::SafeList
152
+ end
153
+ end
154
+
155
+ Html = HTML
156
+
157
+ module HTML
158
+ FullSanitizer = HTML4::FullSanitizer
159
+ LinkSanitizer = HTML4::LinkSanitizer
160
+ SafeListSanitizer = HTML4::SafeListSanitizer
161
+ WhiteListSanitizer = SafeListSanitizer
162
+ end
163
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BasicSocket
4
+ def initialize(...)
5
+ raise NotImplementedError, "Socket is not supported in Wasm"
6
+ end
7
+ end
8
+
9
+ class Socket < BasicSocket
10
+ AF_UNSPEC = 0
11
+ AF_INET = 1
12
+ end
13
+
14
+ class IPSocket < Socket
15
+ def self.getaddress(...)
16
+ raise NotImplementedError, "Socket is not supported in Wasm"
17
+ end
18
+ end
19
+
20
+ class TCPSocket < Socket
21
+ end
File without changes