Rails Engine with Vite
Rails engine คือ mountable app ที่อยู่ภายในแอปอีกที่ใหญ่กว่า — เหมาะกับการแพ็คฟีเจอร์ที่อยากใช้ซ้ำในหลายโปรเจกต์ (ระบบ admin, e-commerce module, CMS) เมื่อ engine ต้องมี frontend asset ของตัวเอง การเลือก Vite แทน importmap หรือ jsbundling-rails ได้ HMR เร็ว ES modules ตรงไปตรงมา และเข้ากับ frontend framework อย่าง Vue/React ได้ดี
โพสต์นี้พาตั้ง engine ที่ build asset ของตัวเองด้วย Vite ไม่ชนกับ asset ของ host app แล้วต่อด้วย Tailwind CSS และ Vue พร้อมวิธีส่งข้อมูลจาก Rails ไป Vue component
Prerequisites
- Ruby 3.2+
- Rails 7+
- Node 20+ และ Yarn
- ความคุ้นเคยกับ Rails engine และ Vite เบื้องต้น
สร้าง engine
1
rails plugin new my_engine --mountable
flag --mountable ทำให้ engine มี namespace แยก (MyEngine::) และมี Rails app structure ของตัวเอง (controllers, views, routes, layouts) แทน plugin แบบธรรมดาที่แชร์ namespace กับ host app
ติดตั้ง Vite
ประกาศ dependency ใน gemspec ของ engine:
1
2
spec.add_dependency 'vite_rails'
spec.add_dependency 'vite_ruby'
ทั้งสอง gem ต้องเป็น runtime dependency เพราะ engine ใช้ vite_rails helper ใน view และ vite_ruby build asset ของตัวเอง ไม่ได้ใช้แค่ตอน dev
แก้ Rakefile ของ engine ให้โหลด vite task และตั้ง root ของ Vite ให้ชี้มาที่ engine:
1
2
3
4
5
6
7
8
9
10
11
12
require "bundler/setup"
+require 'vite_ruby'
+ViteRuby.install_tasks
+ViteRuby.config.root # Ensure the engine is set as the root.
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
load "rails/tasks/engine.rake"
load "rails/tasks/statistics.rake"
require "bundler/gem_tasks"
บรรทัด ViteRuby.config.root สำคัญตรงที่บังคับ Vite ให้มอง engine root เป็นที่อยู่ของ source ไม่ใช่ dummy app ใน test/dummy/ — ถ้าไม่มี Vite จะหา vite.config.ts ไม่เจอตอน task รัน
ติดตั้ง Vite ลง engine:
1
bundle exec vite install
Engine middleware setup
ส่วนที่ซับซ้อนที่สุดของการเชื่อม Vite เข้ากับ engine — engine ต้อง serve static asset และ proxy ไปยัง Vite dev server เอง เพราะ host app ไม่รู้เรื่อง Vite ของ engine
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
require "vite_ruby"
module MyEngine
class Engine < ::Rails::Engine
isolate_namespace MyEngine
delegate :vite_ruby, to: :class
def self.vite_ruby
@vite_ruby ||= ::ViteRuby.new(root: root, mode: Rails.env)
end
# Serves the engine's vite-ruby when requested
initializer "my_engine.vite_rails.static" do |app|
if Rails.application.config.public_file_server.enabled
# this is the right setup when the main application is already
# using Vite for the theme assets.
app.middleware.insert_after ActionDispatch::Static,
Rack::Static,
urls: [ "/#{vite_ruby.config.public_output_dir}" ],
root: root.join(vite_ruby.config.public_dir),
header_rules: [
# rubocop:disable Style/StringHashKeys
[ :all, { "Access-Control-Allow-Origin" => "*" } ]
# rubocop:enable Style/StringHashKeys
]
else
# mostly when running the application in production behind NGINX or APACHE
app.middleware.insert_before 0,
Rack::Static,
urls: [ "/#{vite_ruby.config.public_output_dir}" ],
root: root.join(vite_ruby.config.public_dir),
header_rules: [
# rubocop:disable Style/StringHashKeys
[ :all, { "Access-Control-Allow-Origin" => "*" } ]
# rubocop:enable Style/StringHashKeys
]
end
end
initializer "my_engine.vite_rails_engine.proxy" do |app|
if vite_ruby.run_proxy?
app.middleware.insert_before 0,
ViteRuby::DevServerProxy,
ssl_verify_none: true,
vite_ruby: vite_ruby
end
end
initializer "my_engine.vite_rails_engine.logger" do
config.after_initialize do
vite_ruby.logger = Rails.logger
end
end
end
end
แต่ละ initializer ทำอะไร:
self.vite_ruby— สร้าง ViteRuby instance ที่ผูกกับ root ของ engine (ไม่ใช่ host app) ทำให้ asset path, manifest, dev server ทุกอย่างอ้างอิงโฟลเดอร์ของ engine- Initializer
static— เพิ่มRack::Staticmiddleware เพื่อ serve asset ที่ Vite compile ไว้ใต้public/<output_dir>ของ engine มี 2 branch:public_file_server.enabledเป็น true — host app serve static เอง (dev mode ของ Rails หรือ prod ที่ไม่มี reverse proxy) ใส่ middleware ของเรา หลังActionDispatch::Staticเพื่อให้ host app try ก่อน แล้วค่อย fallback มาที่ engine- กรณีหลัง NGINX/Apache — Rails ไม่ serve static เลย ใส่ middleware ก่อน index 0 เพื่อให้ Rack จับ asset request ของ engine ทันก่อน Rails routing
Access-Control-Allow-Origin: *— เผื่อ host app และ engine asset อยู่คนละ origin (เช่นใช้ CDN หรือ subdomain) จะได้โหลด font/image cross-origin ได้- Initializer
proxy— ถ้าbin/vite devกำลังรันอยู่ (run_proxy?คืน true) สอดแทรกViteRuby::DevServerProxyเป็น middleware แรกเลย เพื่อ proxy request asset ไปที่ Vite dev server สำหรับ HMR - Initializer
logger— ทำให้ log ของ Vite ออกใน Rails log เดียวกัน อ่านง่ายตอน debug
vite.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"all": {
"sourceCodeDir": "app/frontend",
"watchAdditionalPaths": [],
"publicOutputDir": "my_engine-assets"
},
"development": {
"autoBuild": true,
"publicOutputDir": "my_engine-assets-dev",
"port": 3036
},
"test": {
"autoBuild": true,
"publicOutputDir": "my_engine-assets-test",
"port": 3037
}
}
sourceCodeDir: app/frontend— แยกออกจากapp/javascriptที่ Rails default ใช้กับ jsbundling/importmap เพื่อให้ชัดว่าโค้ดในนี้คุมโดย VitepublicOutputDir: my_engine-assets— สำคัญสุดของไฟล์นี้ — ตั้งชื่อ output folder ที่ unique ตามชื่อ engine ถ้าใช้ default (vite) จะชนกับ output ของ host app ที่ใช้ Vite อยู่ด้วยportต่างกันต่อ environment — กัน port ชนกันถ้ามีหลาย engine หรือรัน test คู่กับ dev
Controller, route, layout
สร้าง controller สำหรับลอง:
1
rails g controller home index
ตั้ง route ใน engine (อยู่ภายใต้ MyEngine::Engine.routes):
1
2
3
MyEngine::Engine.routes.draw do
root "home#index"
end
แก้ layout ให้โหลด vite client + entrypoint ของ engine:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<title>My engine</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %>
+ <%= vite_client_tag %>
+ <%= vite_javascript_tag 'application' %>
<%= stylesheet_link_tag "my_engine/application", media: "all" %>
</head>
<body>
<%= yield %>
</body>
</html>
vite_client_tag คือ script ของ Vite HMR (จะ render ออกมาเฉพาะตอน dev) ส่วน vite_javascript_tag 'application' ชี้ไปที่ app/frontend/entrypoints/application.js
ApplicationHelper สำหรับ vite tag
engine ไม่ inherit helper จาก host app — ต้อง include ViteRails::TagHelpers เองและชี้ vite_manifest ไปที่ manifest ของ engine ไม่งั้น helper จะหา manifest ของ host app เจอ (และโหลด asset ผิด):
1
2
3
4
5
6
7
8
9
10
11
12
require "vite_rails/version"
require "vite_rails/tag_helpers"
module MyEngine
module ApplicationHelper
include ::ViteRails::TagHelpers
def vite_manifest
::MyEngine::Engine.vite_ruby.manifest
end
end
end
สร้าง host app และ mount engine
สร้างแอป demo สำหรับ mount engine:
1
rails new demo
เพิ่ม engine เป็น path gem:
1
gem "my_engine", path: "path/to/engine"
mount engine ใน route ของ host app:
1
mount MyEngine::Engine => "/my_engine"
รัน bin/vite dev ใน folder ของ engine แล้ว start host app — เปิด http://localhost:3000/my_engine จะเห็นหน้า home ของ engine พร้อม asset จาก Vite
TailwindCSS
ตัวอย่างต่อจากนี้ใช้ Tailwind v3 ตามช่วงเวลาที่เขียน post หากใช้ Tailwind v4 syntax
tailwind.config.jsและ@tailwinddirective ต่างไป — ดูตัวอย่าง v4 ที่โพสต์ Preline หรือ Flowbite
ติดตั้ง Tailwind ใน engine:
1
2
yarn add -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
ตั้ง content path ให้ครอบไฟล์ของ engine ทั้งหมด:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./app/views/**/*.rb",
"./app/views/**/*.html.erb",
"./app/views/layouts/*.html.erb",
"./app/helpers/**/*.rb",
"./app/assets/stylesheets/**/*.css",
"./app/frontend/**/*.js",
],
theme: {
extend: {},
},
plugins: [],
}
สร้าง CSS entrypoint:
1
touch app/frontend/entrypoints/application.css
1
2
3
@tailwind base;
@tailwind components;
@tailwind utilities;
เพิ่ม vite_stylesheet_tag ใน layout:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html>
<head>
<title>My engine</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %>
<%= vite_client_tag %>
<%= vite_javascript_tag 'application' %>
+ <%= vite_stylesheet_tag 'application', data: { "turbo-track": "reload" } %>
<%= stylesheet_link_tag "my_engine/application", media: "all" %>
</head>
<body>
<%= yield %>
</body>
</html>
ทดสอบด้วยการใส่ class ใน view:
1
2
<h1 class="font-bold text-4xl text-indigo-500">Home#index</h1>
<p>Find me in app/views/my_engine/home/index.html.erb</p>
Vue
ติดตั้ง Vue plugin:
1
yarn add -D vue @vitejs/plugin-vue
ใส่ plugin ใน Vite config:
1
2
3
4
5
6
7
8
9
10
import { defineConfig } from 'vite'
import RubyPlugin from 'vite-plugin-ruby'
import vue from '@vitejs/plugin-vue' // <-------- add this
export default defineConfig({
plugins: [
RubyPlugin(),
vue() // <-------- add this
],
})
สร้าง single-file component:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div>
<h1>Vue App!</h1>
</div>
</template>
<script setup>
</script>
<style lang="css" scoped>
h1 {
color: red;
}
</style>
วาง mount point ใน view:
1
<div id="app"></div>
mount Vue app ใน entrypoint:
1
2
3
4
5
import { createApp } from 'vue'
import App from '../components/App.vue'
const app = createApp(App)
app.mount('#app')
ส่งข้อมูลจาก Rails ไป Vue
Vue mount หลัง DOM render แล้ว ไม่ได้รับ instance variable ของ Rails ตรงๆ pattern ที่นิยมคือ serialize ข้อมูลเป็น data-* attribute แล้ว Vue อ่านตอน mount
ใน component ประกาศ prop:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div>
<h1>{{ msg }}</h1>
</div>
</template>
<script setup>
defineProps({
msg: String
})
</script>
<style lang="css" scoped>
h1 {
color: red;
}
</style>
ส่งข้อมูลจาก controller:
1
2
3
4
5
6
7
module MyEngine
class HomeController < ApplicationController
def index
@msg = "Hello Vue on Rails!"
end
end
end
render เป็น data-props:
1
2
3
4
5
6
7
8
9
10
11
12
<%=
content_tag(
:div,
id: 'appProps',
data: {
props: {
msg: @msg
}
}.as_json
) {}
%>
<div id="app"></div>
จะได้ markup ออกมาประมาณ:
1
2
3
4
<div
id="appProps"
data-props="{"msg":"Hello Vue on Rails!"}">
</div>
อ่าน prop ตอน mount Vue:
1
2
3
4
5
6
import { createApp } from 'vue'
import App from '../components/App.vue'
const appProps = document.getElementById('appProps').dataset.props
const app = createApp(App, JSON.parse(appProps))
app.mount('#app')
ข้อจำกัดของ pattern นี้:
- ข้อมูลถูก serialize เป็น JSON string ครั้งเดียวตอน render ไม่มี reactivity กลับไปหา Rails
- ค่าที่ encode ไม่ได้เป็น JSON (Date instance, ActiveRecord object) ต้อง map เป็น primitive ก่อน
- ถ้าข้อมูลใหญ่ HTML จะบวมตามไปด้วย — ใช้ API call แทนถ้ามีข้อมูลเยอะ
Troubleshooting
Vite Ruby: Manifest file not found ยังไม่ได้รัน bin/vite build (สำหรับ production) หรือ bin/vite dev ยังไม่ขึ้น (สำหรับ dev) — เริ่ม dev server ใน folder ของ engine ก่อน start host app
Asset 404 ทั้งที่ build ผ่าน ส่วนใหญ่ publicOutputDir ใน vite.json ชนกับ host app หรือ engine อื่น ตรวจให้ unique ต่อ engine
SSL_connect ... ssl handshake failed ตอน Vite dev proxy ของ engine ใช้ ssl_verify_none: true อยู่แล้ว แต่ถ้ายังเจอ ตรวจว่า dev server รันด้วย HTTP (ไม่ใช่ HTTPS) — Vite default รัน HTTP
HMR ไม่ทำงานในหน้าของ engine ต้องแน่ใจว่าใส่ vite_client_tag ใน layout ของ engine (ไม่ใช่ของ host app) และเปิด browser ไปที่ route ของ engine ผ่าน host app
Conclusion
ตอนนี้ engine มี frontend stack เป็นของตัวเอง — Vite สำหรับ bundling, Tailwind สำหรับ styling, Vue สำหรับ component — และ mount เข้ากับ host app ได้โดยไม่กระทบ asset pipeline ของ host
วิธีนี้คุ้มเมื่อต้อง reuse engine ในหลายแอป (admin panel, white-label module, multi-tenant feature) แต่ถ้าทำแอปเดียว ใช้ Vite ตรงๆ ใน Rails app ง่ายกว่าและไม่ต้องเขียน middleware setup เอง


