rspack_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: a3eb3563c7877029531bd93b2ed9036ce92b8a89c4008600ea251dfad3b641ff
4
+ data.tar.gz: 5d210e018044764c5e2f3f94cd06e3cb296d16e1da97d010b9e18907c1416644
5
+ SHA512:
6
+ metadata.gz: 5dc66b20aadec8d3695d98448c80eb39e31a0d2c55d693002c6d318ac25c95a96ff81b8ac4829d0e60a6e09af7959ceca287f1f5c440a09af333069b6ffa0458
7
+ data.tar.gz: fc965e7fd0ddd80f60d120b77cf135ce9e4da264b87e66e298767c5e14e367cf9bbfda64130abc6d03d2f0368528196826e5425f8dcae8ddbe36714e9c534da8
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-11-13
4
+
5
+ - `rspack:doctor`: checks Node.js against rspack's `engines`, packages, config, entry, build output, layout, Procfile.dev, Stimulus and the dev server
6
+ - `rspack:install:stimulus`: Stimulus with automatic controller registration and hot-swapping under `rspack serve`
7
+ - `rspack_javascript_tag` / `rspack_stylesheet_tag` helpers that load from `rspack serve` in development and fall back to the asset pipeline
8
+ - Dev server with hot module replacement (`npm run dev`), configured via `RSPACK_DEV_SERVER_*` env vars or `RspackRails.configure`
9
+ - Installer adds `@rspack/dev-server` and upgrades Phase 1 layouts and `Procfile.dev`
10
+ - Railtie with `rspack:install`, `rspack:build`, `rspack:dependencies` and `rspack:clobber` tasks
11
+ - Hook `rspack:build` into `assets:precompile` and test prepare tasks
12
+ - Package manager detection (bun, yarn, pnpm, npm)
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 tungnt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # rspack_rails
2
+
3
+ Bundle your Rails app's JavaScript with [Rspack](https://rspack.rs), the Rust-powered, webpack-compatible bundler.
4
+
5
+ rspack_rails follows the `jsbundling-rails` model: rspack writes bundles to `app/assets/builds`, and Propshaft (or Sprockets) serves and fingerprints them.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bundle add rspack_rails
11
+ bin/rails rspack:install
12
+ ```
13
+
14
+ The installer:
15
+
16
+ - creates `rspack.config.mjs` and `app/javascript/application.js`
17
+ - adds `build`, `build:watch` and `dev` (`rspack serve`) scripts
18
+ - adds `rspack_javascript_tag "application"` to your layout
19
+ - adds a `js:` process running the dev server to `Procfile.dev` and sets up `bin/dev`
20
+ - installs `@rspack/cli`, `@rspack/core` and `@rspack/dev-server` with your package manager (bun, yarn, pnpm or npm, detected from the lockfile)
21
+
22
+ ## Usage
23
+
24
+ Run `bin/dev` to start Rails and the rspack dev server (with hot module replacement) together.
25
+
26
+ ### View helpers
27
+
28
+ ```erb
29
+ <%= rspack_javascript_tag "application", "data-turbo-track": "reload" %>
30
+ <%= rspack_stylesheet_tag "application" %>
31
+ ```
32
+
33
+ In development, when `rspack serve` is reachable, the helpers load bundles from the dev server (`http://localhost:3036/application.js`), so edits are hot-reloaded. Otherwise they fall back to `javascript_include_tag` / `stylesheet_link_tag`, served and fingerprinted by Propshaft from `app/assets/builds`. Production always uses the asset pipeline. `rspack_javascript_tag` adds `defer` by default.
34
+
35
+ ### Dev server settings
36
+
37
+ `rspack.config.mjs` and the gem read the same environment variables, so a `.env` file (loaded by foreman) changes both:
38
+
39
+ | Variable | Default |
40
+ |---|---|
41
+ | `RSPACK_DEV_SERVER_HOST` | `localhost` |
42
+ | `RSPACK_DEV_SERVER_PORT` | `3036` |
43
+ | `RSPACK_DEV_SERVER=false` | turns the dev server check off |
44
+
45
+ Rails-side settings can also go in an initializer (this does not change `rspack.config.mjs`, so prefer env vars for host and port):
46
+
47
+ ```ruby
48
+ RspackRails.configure do |config|
49
+ config.dev_server_port = 4000
50
+ config.dev_server_enabled = false
51
+ end
52
+ ```
53
+
54
+ ### Stimulus
55
+
56
+ ```bash
57
+ bin/rails rspack:install:stimulus
58
+ ```
59
+
60
+ This installs `@hotwired/stimulus` and adds `app/javascript/controllers/autoload.js`, imported from `application.js`. Every `*_controller.js` under `app/javascript/controllers` is registered automatically, with the usual naming (`admin/user_card_controller.js` becomes `admin--user-card`). There is no manifest to keep up to date.
61
+
62
+ Under `rspack serve`, editing a controller or adding a new one hot-swaps it in the open page without a reload, so form input and other page state survive.
63
+
64
+ If `application.js` imported `./controllers` (the stimulus-rails index), the installer switches it to `./controllers/autoload`. stimulus-rails may still rewrite `controllers/index.js` when you run `bin/rails generate stimulus`, but nothing imports that file any more.
65
+
66
+ ### Tasks
67
+
68
+ | Task | What it does |
69
+ |---|---|
70
+ | `bin/rails rspack:install:stimulus` | Sets up Stimulus with controller autoloading |
71
+ | `bin/rails rspack:doctor` | Checks Node.js, packages, config, layout, Procfile and dev server |
72
+ | `bin/rails rspack:build` | Installs JS dependencies, then runs `build` |
73
+ | `bin/rails rspack:clobber` | Removes rspack output from `app/assets/builds` |
74
+
75
+ `rspack:build` runs automatically before `assets:precompile`, `test:prepare`, `spec:prepare` and `db:test:prepare`. Set `SKIP_RSPACK_BUILD=1` to turn that off.
76
+
77
+ When `RAILS_ENV` or `NODE_ENV` is `production`, the generated config builds in production mode (minified). Lazy-loaded chunks are named `*.digested.js`, so Propshaft serves them without fingerprinting them a second time.
78
+
79
+ ### Doctor
80
+
81
+ `bin/rails rspack:doctor` checks the setup and prints a hint for anything missing:
82
+
83
+ ```
84
+ ✓ Node.js: v22.12.0 (rspack needs ^20.19.0 || >=22.12.0)
85
+ ✓ @rspack/core: 2.3.2
86
+ ✓ Layout: uses rspack_javascript_tag
87
+ ! Dev server: not running at http://localhost:3036
88
+ → Start it with bin/dev; until then pages use app/assets/builds
89
+
90
+ 0 error(s), 1 warning(s).
91
+ ```
92
+
93
+ The required Node.js version comes from the `engines` field of the installed `@rspack/core`. The task exits with status 1 when there are errors, so it can run in CI.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ bin/setup
99
+ bundle exec rake # specs + rubocop
100
+ ```
101
+
102
+ ## License
103
+
104
+ Available under the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,2 @@
1
+ // Entry point for rspack. Import your JavaScript here.
2
+ console.log("Hello from rspack_rails")
data/lib/install/dev ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env sh
2
+
3
+ if ! gem list foreman -i --silent; then
4
+ echo "Installing foreman..."
5
+ gem install foreman
6
+ fi
7
+
8
+ export PORT="${PORT:-3000}"
9
+
10
+ exec foreman start -f Procfile.dev "$@"
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails application template run by `bin/rails rspack:install`.
4
+ require "json"
5
+
6
+ pm = RspackRails::PackageManager.detect(Rails.root)
7
+
8
+ say "Creating app/assets/builds"
9
+ empty_directory "app/assets/builds"
10
+ create_file "app/assets/builds/.keep", "" unless File.exist?("app/assets/builds/.keep")
11
+
12
+ if File.exist?(".gitignore") && !File.read(".gitignore").include?("/app/assets/builds/*")
13
+ append_to_file ".gitignore", "\n/app/assets/builds/*\n!/app/assets/builds/.keep\n/node_modules\n"
14
+ end
15
+
16
+ if Rails.root.join("app/assets/config/manifest.js").exist?
17
+ append_to_file "app/assets/config/manifest.js", %(//= link_tree ../builds\n)
18
+ end
19
+
20
+ say "Creating rspack entry point"
21
+ unless File.exist?("app/javascript/application.js")
22
+ copy_file "#{__dir__}/application.js",
23
+ "app/javascript/application.js"
24
+ end
25
+ copy_file "#{__dir__}/rspack.config.mjs", "rspack.config.mjs"
26
+
27
+ say "Configuring package.json"
28
+ copy_file "#{__dir__}/package.json", "package.json" unless File.exist?("package.json")
29
+ package = JSON.parse(File.read("package.json"))
30
+ package["scripts"] ||= {}
31
+ package["scripts"]["build"] = "rspack build"
32
+ package["scripts"]["build:watch"] = "rspack build --watch"
33
+ package["scripts"]["dev"] = "rspack serve"
34
+ File.write("package.json", "#{JSON.pretty_generate(package)}\n")
35
+
36
+ include_tag = %(<%= rspack_javascript_tag "application", "data-turbo-track": "reload" %>)
37
+ layout = Rails.root.join("app/views/layouts/application.html.erb")
38
+ if layout.exist?
39
+ content = File.read(layout)
40
+ if content.include?("javascript_importmap_tags")
41
+ say "Found javascript_importmap_tags in your layout. Remove it if you are replacing importmap with rspack.",
42
+ :yellow
43
+ end
44
+ if content.match?(/javascript_include_tag "application".*$/)
45
+ say "Switching javascript_include_tag to rspack_javascript_tag"
46
+ gsub_file layout, /<%= javascript_include_tag "application".*%>/, include_tag
47
+ elsif !content.include?("rspack_javascript_tag")
48
+ say "Adding rspack_javascript_tag to application layout"
49
+ insert_into_file layout, " #{include_tag}\n", before: %r{^[ \t]*</head>}
50
+ end
51
+ else
52
+ say "Add this to your layout: #{include_tag}", :yellow
53
+ end
54
+
55
+ say "Configuring bin/dev"
56
+ js_process = "js: #{pm.run_command("dev")}"
57
+ if !File.exist?("Procfile.dev")
58
+ create_file "Procfile.dev", "web: env RUBY_DEBUG_OPEN=true bin/rails server\n#{js_process}\n"
59
+ elsif File.read("Procfile.dev").match?(/^js: .*build:watch$/)
60
+ gsub_file "Procfile.dev", /^js: .*build:watch$/, js_process
61
+ elsif !File.read("Procfile.dev").match?(/^js:/)
62
+ append_to_file "Procfile.dev", "#{js_process}\n"
63
+ end
64
+
65
+ unless File.exist?("bin/dev") && File.read("bin/dev").include?("Procfile.dev")
66
+ copy_file "#{__dir__}/dev", "bin/dev", force: true
67
+ chmod "bin/dev", 0o755, verbose: false
68
+ end
69
+
70
+ say "Installing @rspack/cli, @rspack/core and @rspack/dev-server"
71
+ run pm.add_dev_command("@rspack/cli", "@rspack/core", "@rspack/dev-server")
72
+
73
+ say "rspack_rails installed. Run bin/dev to start developing.", :green
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "app",
3
+ "private": true
4
+ }
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { defineConfig } from "@rspack/cli";
4
+
5
+ const root = path.dirname(fileURLToPath(import.meta.url));
6
+ const isDevServer = process.argv.includes("serve");
7
+ const isProduction =
8
+ !isDevServer &&
9
+ (process.env.RAILS_ENV === "production" || process.env.NODE_ENV === "production");
10
+
11
+ // Keep these in sync with RspackRails.config (same env vars, same defaults).
12
+ const host = process.env.RSPACK_DEV_SERVER_HOST || "localhost";
13
+ const port = Number(process.env.RSPACK_DEV_SERVER_PORT || 3036);
14
+
15
+ export default defineConfig({
16
+ mode: isProduction ? "production" : "development",
17
+ devtool: "source-map",
18
+ entry: {
19
+ application: "./app/javascript/application.js",
20
+ },
21
+ output: {
22
+ path: path.resolve(root, "app/assets/builds"),
23
+ filename: "[name].js",
24
+ // ".digested" tells Propshaft the file is already fingerprinted.
25
+ chunkFilename: "[name]-[contenthash].digested.js",
26
+ // Under `rspack serve`, chunks and hot updates load from the dev server.
27
+ publicPath: isDevServer ? `http://${host}:${port}/` : "/assets/",
28
+ },
29
+ devServer: {
30
+ host,
31
+ port,
32
+ hot: true,
33
+ allowedHosts: "all",
34
+ headers: { "Access-Control-Allow-Origin": "*" },
35
+ client: {
36
+ webSocketURL: `ws://${host}:${port}/ws`,
37
+ overlay: true,
38
+ },
39
+ },
40
+ });
@@ -0,0 +1,9 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+
5
+ // Configure Stimulus development experience
6
+ application.debug = false
7
+ window.Stimulus = application
8
+
9
+ export { application }
@@ -0,0 +1,31 @@
1
+ // Registers every app/javascript/controllers/**/*_controller.js with Stimulus.
2
+ // Managed by rspack_rails: new controllers need no manual import, and edited
3
+ // controllers are hot-swapped under `rspack serve` without a page reload.
4
+ import { application } from "./application"
5
+
6
+ const controllers = () =>
7
+ import.meta.webpackContext("./", { recursive: true, regExp: /_controller\.js$/ })
8
+
9
+ // "./users/profile_controller.js" -> "users--profile"
10
+ const identifierFor = (key) =>
11
+ key
12
+ .replace(/^\.\//, "")
13
+ .replace(/_controller\.js$/, "")
14
+ .replace(/\//g, "--")
15
+ .replace(/_/g, "-")
16
+
17
+ const registerAll = (context) => {
18
+ context.keys().forEach((key) => {
19
+ const controller = context(key).default
20
+ if (controller) application.register(identifierFor(key), controller)
21
+ })
22
+ }
23
+
24
+ const context = controllers()
25
+ registerAll(context)
26
+
27
+ if (import.meta.webpackHot) {
28
+ // Stimulus unloads an identifier before registering it again, so
29
+ // re-registering swaps connected controllers in place.
30
+ import.meta.webpackHot.accept(context.id, () => registerAll(controllers()))
31
+ }
@@ -0,0 +1,8 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="hello"
4
+ export default class extends Controller {
5
+ connect() {
6
+ this.element.textContent = "Hello from Stimulus + rspack!"
7
+ }
8
+ }
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails application template run by `bin/rails rspack:install:stimulus`.
4
+ pm = RspackRails::PackageManager.detect(Rails.root)
5
+ entry = "app/javascript/application.js"
6
+
7
+ say "Installing Stimulus with controller autoloading"
8
+ unless File.exist?("app/javascript/controllers/application.js")
9
+ copy_file "#{__dir__}/stimulus/application.js", "app/javascript/controllers/application.js"
10
+ end
11
+ copy_file "#{__dir__}/stimulus/autoload.js", "app/javascript/controllers/autoload.js"
12
+
13
+ if Dir.glob("app/javascript/controllers/**/*_controller.js").empty?
14
+ copy_file "#{__dir__}/stimulus/hello_controller.js", "app/javascript/controllers/hello_controller.js"
15
+ end
16
+
17
+ if !File.exist?(entry)
18
+ create_file entry, %(import "./controllers/autoload"\n)
19
+ elsif File.read(entry).match?(%r{^import ["']\./controllers(/index(\.js)?)?["'];?$})
20
+ say "Replacing the controllers/index.js import with controllers/autoload"
21
+ gsub_file entry, %r{^import ["']\./controllers(/index(\.js)?)?["'];?$}, %(import "./controllers/autoload")
22
+ elsif !File.read(entry).include?("./controllers/autoload")
23
+ append_to_file entry, %(import "./controllers/autoload"\n)
24
+ end
25
+
26
+ if File.exist?("app/javascript/controllers/index.js")
27
+ say "app/javascript/controllers/index.js is no longer imported; controllers/autoload.js replaces it.", :yellow
28
+ end
29
+
30
+ say "Installing @hotwired/stimulus"
31
+ run pm.add_command("@hotwired/stimulus")
32
+
33
+ say "Stimulus installed. Controllers in app/javascript/controllers are registered automatically.", :green
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RspackRails
4
+ # Settings shared by the dev server check and the view helpers.
5
+ # Defaults come from the same environment variables rspack.config.mjs reads.
6
+ class Configuration
7
+ attr_accessor :dev_server_host, :dev_server_port
8
+ attr_writer :dev_server_enabled
9
+
10
+ def initialize(env = ENV)
11
+ @env = env
12
+ @dev_server_host = env.fetch("RSPACK_DEV_SERVER_HOST", "localhost")
13
+ @dev_server_port = Integer(env.fetch("RSPACK_DEV_SERVER_PORT", 3036))
14
+ @dev_server_enabled = nil
15
+ end
16
+
17
+ # Unless set explicitly, the dev server is used only in development and
18
+ # can be switched off with RSPACK_DEV_SERVER=false.
19
+ def dev_server_enabled
20
+ return @dev_server_enabled unless @dev_server_enabled.nil?
21
+
22
+ return false if @env["RSPACK_DEV_SERVER"] == "false"
23
+
24
+ defined?(::Rails.env) ? ::Rails.env.development? : false
25
+ end
26
+
27
+ def dev_server_url
28
+ "http://#{dev_server_host}:#{dev_server_port}"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module RspackRails
6
+ # Answers whether `rspack serve` is reachable. The result is cached briefly
7
+ # so a page with several tags does not open a socket per tag.
8
+ class DevServer
9
+ CONNECT_TIMEOUT = 0.05
10
+
11
+ def initialize(config, check_interval: 1.0)
12
+ @config = config
13
+ @check_interval = check_interval
14
+ @checked_at = nil
15
+ @running = false
16
+ end
17
+
18
+ def running?
19
+ return false unless @config.dev_server_enabled
20
+
21
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
+ return @running if @checked_at && now - @checked_at < @check_interval
23
+
24
+ @checked_at = now
25
+ @running = reachable?
26
+ end
27
+
28
+ def url(path)
29
+ "#{@config.dev_server_url}/#{path.to_s.delete_prefix("/")}"
30
+ end
31
+
32
+ private
33
+
34
+ def reachable?
35
+ Socket.tcp(@config.dev_server_host, @config.dev_server_port, connect_timeout: CONNECT_TIMEOUT) { true }
36
+ rescue SystemCallError, SocketError, IOError
37
+ false
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "pathname"
6
+ require_relative "node_version"
7
+
8
+ module RspackRails
9
+ # Inspects a Rails app's rspack setup and reports what is missing.
10
+ class Doctor
11
+ Check = Struct.new(:status, :name, :detail, :hint)
12
+ SYMBOLS = { ok: "✓", warn: "!", error: "✗" }.freeze
13
+
14
+ def initialize(root, config: RspackRails.config, dev_server: RspackRails.dev_server, node_version: nil)
15
+ @root = Pathname(root)
16
+ @config = config
17
+ @dev_server = dev_server
18
+ @node_version = node_version
19
+ end
20
+
21
+ def checks
22
+ @checks ||= [
23
+ node_check, package_check(:cli), package_check(:core), package_check(:"dev-server"),
24
+ config_check, scripts_check, entry_check, build_check, layout_check, procfile_check,
25
+ stimulus_check, dev_server_check
26
+ ].compact
27
+ end
28
+
29
+ def ok?
30
+ checks.none? { |check| check.status == :error }
31
+ end
32
+
33
+ def report(io = $stdout)
34
+ checks.each do |check|
35
+ io.puts "#{SYMBOLS.fetch(check.status)} #{check.name}: #{check.detail}"
36
+ io.puts " → #{check.hint}" if check.hint && check.status != :ok
37
+ end
38
+ io.puts
39
+ io.puts summary
40
+ end
41
+
42
+ def summary
43
+ errors = checks.count { |c| c.status == :error }
44
+ warnings = checks.count { |c| c.status == :warn }
45
+ return "Everything looks good." if errors.zero? && warnings.zero?
46
+
47
+ "#{errors} error(s), #{warnings} warning(s)."
48
+ end
49
+
50
+ private
51
+
52
+ def node_check
53
+ version = node_version
54
+ return Check.new(:error, "Node.js", "not found", "Install Node.js and make sure `node` is on PATH") unless version
55
+
56
+ required = installed_package("core")&.dig("engines", "node")
57
+ return Check.new(:ok, "Node.js", version) unless required
58
+
59
+ case NodeVersion.check(version, required)
60
+ when true then Check.new(:ok, "Node.js", "#{version} (rspack needs #{required})")
61
+ when false then Check.new(:error, "Node.js", "#{version}, but rspack needs #{required}", "Upgrade Node.js")
62
+ else Check.new(:warn, "Node.js", "#{version}; could not read rspack's requirement #{required}")
63
+ end
64
+ end
65
+
66
+ def package_check(name)
67
+ package = installed_package(name)
68
+ label = "@rspack/#{name}"
69
+ if package
70
+ Check.new(:ok, label, package["version"])
71
+ else
72
+ Check.new(name == :"dev-server" ? :warn : :error, label, "not installed",
73
+ "Run `#{package_manager.add_dev_command(label)}`")
74
+ end
75
+ end
76
+
77
+ def config_check
78
+ return Check.new(:ok, "rspack.config.mjs", "found") if exist?("rspack.config.mjs")
79
+
80
+ Check.new(:error, "rspack.config.mjs", "missing", "Run `bin/rails rspack:install`")
81
+ end
82
+
83
+ def scripts_check
84
+ scripts = package_json&.fetch("scripts", {}) || {}
85
+ missing = %w[build dev].reject { |name| scripts.key?(name) }
86
+ return Check.new(:ok, "package.json scripts", "build, dev") if missing.empty?
87
+
88
+ Check.new(missing.include?("build") ? :error : :warn, "package.json scripts",
89
+ "missing #{missing.join(", ")}", "Run `bin/rails rspack:install`")
90
+ end
91
+
92
+ def entry_check
93
+ return Check.new(:ok, "Entry point", "app/javascript/application.js") if exist?("app/javascript/application.js")
94
+
95
+ Check.new(:error, "Entry point", "app/javascript/application.js is missing",
96
+ "Create it, or point `entry` in rspack.config.mjs at your entry file")
97
+ end
98
+
99
+ def build_check
100
+ built = @root.join("app/assets/builds/application.js")
101
+ return Check.new(:ok, "Build output", "app/assets/builds/application.js (#{built.size} bytes)") if built.exist?
102
+
103
+ Check.new(:warn, "Build output", "app/assets/builds/application.js not built yet",
104
+ "Run `bin/rails rspack:build` (only needed without the dev server)")
105
+ end
106
+
107
+ def layout_check
108
+ layout = read("app/views/layouts/application.html.erb")
109
+ return Check.new(:warn, "Layout", "app/views/layouts/application.html.erb not found") unless layout
110
+
111
+ if layout.include?("javascript_importmap_tags")
112
+ return Check.new(:warn, "Layout", "still includes javascript_importmap_tags",
113
+ "Remove it if rspack replaces importmap, or the app may load JavaScript twice")
114
+ end
115
+ return Check.new(:ok, "Layout", "uses rspack_javascript_tag") if layout.include?("rspack_javascript_tag")
116
+
117
+ Check.new(:warn, "Layout", "does not use rspack_javascript_tag",
118
+ %(Add <%= rspack_javascript_tag "application" %> to get the dev server and HMR))
119
+ end
120
+
121
+ def procfile_check
122
+ procfile = read("Procfile.dev")
123
+ if procfile&.match?(/^js: .*\bdev$/)
124
+ Check.new(:ok, "Procfile.dev", "runs the dev server")
125
+ elsif procfile&.match?(/^js:/)
126
+ Check.new(:warn, "Procfile.dev", "js process does not run `dev`",
127
+ "Use `js: #{package_manager.run_command("dev")}` for HMR")
128
+ else
129
+ Check.new(:warn, "Procfile.dev", "no js process", "Run `bin/rails rspack:install`")
130
+ end
131
+ end
132
+
133
+ def stimulus_check
134
+ return unless exist?("app/javascript/controllers/autoload.js")
135
+
136
+ if node_modules_package("@hotwired/stimulus")
137
+ Check.new(:ok, "Stimulus", "autoload enabled")
138
+ else
139
+ Check.new(:error, "Stimulus", "@hotwired/stimulus is not installed",
140
+ "Run `#{package_manager.add_command("@hotwired/stimulus")}`")
141
+ end
142
+ end
143
+
144
+ def dev_server_check
145
+ return Check.new(:ok, "Dev server", "disabled in this environment") unless @config.dev_server_enabled
146
+ return Check.new(:ok, "Dev server", "running at #{@config.dev_server_url}") if @dev_server.running?
147
+
148
+ Check.new(:warn, "Dev server", "not running at #{@config.dev_server_url}",
149
+ "Start it with bin/dev; until then pages use app/assets/builds")
150
+ end
151
+
152
+ def node_version
153
+ return @node_version if @node_version
154
+
155
+ out, status = Open3.capture2("node", "--version")
156
+ status.success? ? out.strip : nil
157
+ rescue SystemCallError
158
+ nil
159
+ end
160
+
161
+ def installed_package(name)
162
+ node_modules_package("@rspack/#{name}")
163
+ end
164
+
165
+ def node_modules_package(name)
166
+ path = @root.join("node_modules", name, "package.json")
167
+ JSON.parse(path.read) if path.exist?
168
+ rescue JSON::ParserError
169
+ nil
170
+ end
171
+
172
+ def package_json
173
+ JSON.parse(read("package.json")) if exist?("package.json")
174
+ rescue JSON::ParserError
175
+ nil
176
+ end
177
+
178
+ def package_manager
179
+ @package_manager ||= PackageManager.detect(@root)
180
+ end
181
+
182
+ def exist?(path)
183
+ @root.join(path).exist?
184
+ end
185
+
186
+ def read(path)
187
+ file = @root.join(path)
188
+ file.read if file.exist?
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RspackRails
4
+ # View helpers that load bundles from `rspack serve` when it is running
5
+ # and fall back to the asset pipeline (app/assets/builds) otherwise.
6
+ module Helper
7
+ def rspack_javascript_tag(*entries, **options)
8
+ options = { defer: true }.merge(options)
9
+ return javascript_include_tag(*entries, **options) unless RspackRails.dev_server.running?
10
+
11
+ safe_join(entries.map { |entry| javascript_include_tag(RspackRails.dev_server.url("#{entry}.js"), **options) },
12
+ "\n")
13
+ end
14
+
15
+ def rspack_stylesheet_tag(*entries, **options)
16
+ return stylesheet_link_tag(*entries, **options) unless RspackRails.dev_server.running?
17
+
18
+ safe_join(entries.map { |entry| stylesheet_link_tag(RspackRails.dev_server.url("#{entry}.css"), **options) },
19
+ "\n")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RspackRails
4
+ # Minimal semver range matching for package.json "engines" fields.
5
+ # Supports `||`, space-separated comparators, ^, ~, >=, <=, >, <, = and
6
+ # partial versions (20, 20.x).
7
+ module NodeVersion
8
+ COMPARATOR = /\A(\^|~|>=|<=|>|<|=)?v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?\z/
9
+
10
+ OPERATORS = {
11
+ "^" => ->(cmp, current, target) { cmp >= 0 && current[0] == target[0] },
12
+ "~" => ->(cmp, current, target) { cmp >= 0 && current[0..1] == target[0..1] },
13
+ ">=" => ->(cmp, _, _) { cmp >= 0 },
14
+ "<=" => ->(cmp, _, _) { cmp <= 0 },
15
+ ">" => ->(cmp, _, _) { cmp.positive? },
16
+ "<" => ->(cmp, _, _) { cmp.negative? }
17
+ }.freeze
18
+
19
+ Comparator = Struct.new(:operator, :parts) do
20
+ def match?(current)
21
+ target = parts + ([0] * (3 - parts.size))
22
+ operation = OPERATORS[operator]
23
+ return current.take(parts.size) == parts unless operation
24
+
25
+ operation.call(current <=> target, current, target)
26
+ end
27
+ end
28
+
29
+ module_function
30
+
31
+ def parse(version)
32
+ version.to_s.strip.match(/\Av?(\d+)\.(\d+)\.(\d+)/)&.captures&.map(&:to_i)
33
+ end
34
+
35
+ # true or false when the range can be read; nil when the version or any
36
+ # part of the range is in a form this matcher does not support.
37
+ def check(version, range)
38
+ current = parse(version)
39
+ sets = parse_range(range)
40
+ return nil unless current && sets
41
+
42
+ sets.any? { |comparators| comparators.all? { |comparator| comparator.match?(current) } }
43
+ end
44
+
45
+ def parse_range(range)
46
+ sets = range.to_s.split("||").map { |set| set.strip.split(/\s+/).map { |c| parse_comparator(c) } }
47
+ sets.flatten.all? ? sets : nil
48
+ end
49
+
50
+ def parse_comparator(text)
51
+ match = text.match(COMPARATOR)
52
+ return unless match
53
+
54
+ parts = match.captures.drop(1).take_while { |part| part&.match?(/\A\d+\z/) }.map(&:to_i)
55
+ Comparator.new(match[1], parts)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RspackRails
4
+ # Detects the JavaScript package manager of an app from its lockfile and
5
+ # builds the shell commands rspack_rails needs.
6
+ class PackageManager
7
+ LOCKFILES = {
8
+ bun: %w[bun.lock bun.lockb],
9
+ yarn: %w[yarn.lock],
10
+ pnpm: %w[pnpm-lock.yaml],
11
+ npm: %w[package-lock.json]
12
+ }.freeze
13
+
14
+ INSTALL = {
15
+ bun: "bun install",
16
+ yarn: "yarn install",
17
+ pnpm: "pnpm install",
18
+ npm: "npm install"
19
+ }.freeze
20
+
21
+ ADD = {
22
+ bun: "bun add",
23
+ yarn: "yarn add",
24
+ pnpm: "pnpm add",
25
+ npm: "npm install"
26
+ }.freeze
27
+
28
+ ADD_DEV = {
29
+ bun: "bun add --dev",
30
+ yarn: "yarn add --dev",
31
+ pnpm: "pnpm add --save-dev",
32
+ npm: "npm install --save-dev"
33
+ }.freeze
34
+
35
+ attr_reader :root, :name
36
+
37
+ def self.detect(root = Dir.pwd)
38
+ new(root)
39
+ end
40
+
41
+ def initialize(root)
42
+ @root = root.to_s
43
+ @name = LOCKFILES.find { |_, files| files.any? { |f| File.exist?(File.join(@root, f)) } }&.first || :npm
44
+ end
45
+
46
+ def install_command
47
+ INSTALL.fetch(name)
48
+ end
49
+
50
+ def add_command(*packages)
51
+ [ADD.fetch(name), *packages].join(" ")
52
+ end
53
+
54
+ def add_dev_command(*packages)
55
+ [ADD_DEV.fetch(name), *packages].join(" ")
56
+ end
57
+
58
+ def run_command(script)
59
+ "#{name} run #{script}"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module RspackRails
6
+ class Railtie < ::Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path("../tasks/rspack_rails.rake", __dir__)
9
+ end
10
+
11
+ initializer "rspack_rails.helper" do
12
+ require_relative "helper"
13
+ ActiveSupport.on_load(:action_view) { include RspackRails::Helper }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RspackRails
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rspack_rails/version"
4
+ require_relative "rspack_rails/package_manager"
5
+ require_relative "rspack_rails/configuration"
6
+ require_relative "rspack_rails/dev_server"
7
+
8
+ # Rspack integration for Rails: rake tasks, installer and view helpers.
9
+ module RspackRails
10
+ class Error < StandardError; end
11
+
12
+ class << self
13
+ def config
14
+ @config ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield config
19
+ end
20
+
21
+ def dev_server
22
+ @dev_server ||= DevServer.new(config)
23
+ end
24
+ end
25
+ end
26
+
27
+ require_relative "rspack_rails/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :rspack do
4
+ desc "Set up rspack in this Rails app"
5
+ task :install do
6
+ template = File.expand_path("../install/install.rb", __dir__)
7
+ system(RbConfig.ruby, "./bin/rails", "app:template", "LOCATION=#{template}") ||
8
+ raise(RspackRails::Error, "rspack:install failed")
9
+ end
10
+
11
+ namespace :install do
12
+ desc "Set up Stimulus with automatic controller registration"
13
+ task :stimulus do
14
+ template = File.expand_path("../install/stimulus.rb", __dir__)
15
+ system(RbConfig.ruby, "./bin/rails", "app:template", "LOCATION=#{template}") ||
16
+ raise(RspackRails::Error, "rspack:install:stimulus failed")
17
+ end
18
+ end
19
+
20
+ desc "Check the rspack setup of this app"
21
+ task doctor: :environment do
22
+ require "rspack_rails/doctor"
23
+ doctor = RspackRails::Doctor.new(Rails.root)
24
+ doctor.report
25
+ exit 1 unless doctor.ok?
26
+ end
27
+
28
+ desc "Install JavaScript dependencies"
29
+ task :dependencies do
30
+ command = RspackRails::PackageManager.detect(Rails.root).install_command
31
+ system(command, chdir: Rails.root) || raise(RspackRails::Error, "#{command} failed")
32
+ end
33
+
34
+ desc "Build JavaScript bundles with rspack"
35
+ task build: :dependencies do
36
+ command = RspackRails::PackageManager.detect(Rails.root).run_command("build")
37
+ system(command, chdir: Rails.root) || raise(RspackRails::Error, "#{command} failed")
38
+ end
39
+
40
+ desc "Remove rspack build output"
41
+ task :clobber do
42
+ Dir[Rails.root.join("app/assets/builds/**/*.{js,js.map,css,css.map}")].each { |f| File.delete(f) }
43
+ end
44
+ end
45
+
46
+ unless ENV["SKIP_RSPACK_BUILD"]
47
+ %w[assets:precompile test:prepare spec:prepare db:test:prepare].each do |name|
48
+ Rake::Task[name].enhance(["rspack:build"]) if Rake::Task.task_defined?(name)
49
+ end
50
+ end
51
+
52
+ Rake::Task["assets:clobber"].enhance(["rspack:clobber"]) if Rake::Task.task_defined?("assets:clobber")
@@ -0,0 +1,38 @@
1
+ module RspackRails
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ class PackageManager
8
+ attr_reader root: String
9
+ attr_reader name: Symbol
10
+ def self.detect: (?untyped root) -> PackageManager
11
+ def initialize: (untyped root) -> void
12
+ def install_command: () -> String
13
+ def add_command: (*String packages) -> String
14
+ def add_dev_command: (*String packages) -> String
15
+ def run_command: (String script) -> String
16
+ end
17
+ end
18
+
19
+ module RspackRails
20
+ def self.config: () -> Configuration
21
+ def self.configure: () { (Configuration) -> void } -> void
22
+ def self.dev_server: () -> DevServer
23
+
24
+ class Configuration
25
+ attr_accessor dev_server_host: String
26
+ attr_accessor dev_server_port: Integer
27
+ attr_writer dev_server_enabled: bool?
28
+ def initialize: (?Hash[String, String] env) -> void
29
+ def dev_server_enabled: () -> bool
30
+ def dev_server_url: () -> String
31
+ end
32
+
33
+ class DevServer
34
+ def initialize: (Configuration config, ?check_interval: Float | Integer) -> void
35
+ def running?: () -> bool
36
+ def url: (String | Symbol path) -> String
37
+ end
38
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rspack_rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - tungnt
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-11-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: railties
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
33
+ description: 'Bundle Rails JavaScript with Rspack: one-command installer, dev server
34
+ with hot module replacement, Stimulus controller autoloading and a setup doctor.
35
+ Works with Propshaft and Sprockets.'
36
+ email:
37
+ - tungnguyen120301@gmail.com
38
+ executables: []
39
+ extensions: []
40
+ extra_rdoc_files: []
41
+ files:
42
+ - CHANGELOG.md
43
+ - LICENSE.txt
44
+ - README.md
45
+ - lib/install/application.js
46
+ - lib/install/dev
47
+ - lib/install/install.rb
48
+ - lib/install/package.json
49
+ - lib/install/rspack.config.mjs
50
+ - lib/install/stimulus.rb
51
+ - lib/install/stimulus/application.js
52
+ - lib/install/stimulus/autoload.js
53
+ - lib/install/stimulus/hello_controller.js
54
+ - lib/rspack_rails.rb
55
+ - lib/rspack_rails/configuration.rb
56
+ - lib/rspack_rails/dev_server.rb
57
+ - lib/rspack_rails/doctor.rb
58
+ - lib/rspack_rails/helper.rb
59
+ - lib/rspack_rails/node_version.rb
60
+ - lib/rspack_rails/package_manager.rb
61
+ - lib/rspack_rails/railtie.rb
62
+ - lib/rspack_rails/version.rb
63
+ - lib/tasks/rspack_rails.rake
64
+ - sig/rspack_rails.rbs
65
+ homepage: https://github.com/tungnt1203/rspack_rails
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ allowed_push_host: https://rubygems.org
70
+ homepage_uri: https://github.com/tungnt1203/rspack_rails
71
+ changelog_uri: https://github.com/tungnt1203/rspack_rails/blob/main/CHANGELOG.md
72
+ rubygems_mfa_required: 'true'
73
+ post_install_message:
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 3.1.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.5.22
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: Rspack integration for Ruby on Rails
92
+ test_files: []