diff --git a/CHANGELOG.md b/CHANGELOG.md
index d46f30da..cd96a31b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,10 @@ main
(`ember-cli >= 6.8`), without `ember-cli-rails-addon`
* Require `ember-cli-rails-assets >= 0.8.0`, which adds Vite support to
`include_ember_script_tags`
+* Serve Vite-based applications from Vite's development server in
+ `development`, so that changes are hot-reloaded instead of requiring a
+ restart. Configure it with the `dev_server` option, or opt out with
+ `dev_server: false`
0.12.3
------
diff --git a/README.md b/README.md
index 17a8e5ff..eec8aa23 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,10 @@ c.app :frontend, path: "~/projects/my-ember-app"
- `yarn` - enables the [yarn](https://github.com/yarnpkg/yarn) package manager when installing dependencies
+- `dev_server` - configures [Vite's development server](#vite-based-applications)
+ for Vite-based applications in `development`. Pass `false` to opt out of it,
+ or a Hash of `host`, `port`, and `timeout` settings.
+
```ruby
EmberCli.configure do |c|
c.app :adminpanel # path defaults to `Rails.root.join("adminpanel")`
@@ -176,16 +180,63 @@ suites, configure the `default` task to depend on both `spec` and `ember:test`.
task default: [:spec, "ember:test"]
```
-**Vite-based applications**
+### Vite-based applications
When Rails is running in development mode, classic (Broccoli-based) Ember
applications are built with `ember build --watch`, so changes to the Ember
application are picked up automatically.
-Vite-based Ember applications (generated with `ember-cli >= 6.8`) are instead
-built once, synchronously, when they are first requested. To pick up changes
-to the Ember application, restart the Rails server, or iterate on the Ember
-application directly with its own development server (`npm start`).
+Vite-based Ember applications (generated with `ember-cli >= 6.8`) are served by
+Vite's own development server instead — the same one the application's
+`npm start` script runs. `ember-cli-rails` starts it on the first request,
+waits for it to accept connections, and shuts it down when Rails exits.
+
+Rails still renders the application's `index.html`, but reads it from the
+development server rather than from disk. The root-relative URLs in that
+document are rewritten to point at the development server, so the browser
+loads the application's modules — and Vite's HMR client — from it directly.
+Changes to the Ember application are hot-reloaded without reloading the page,
+and without restarting Rails.
+
+Assets that the Ember application references relatively (an `` in a
+template, for instance) are still requested from Rails, which proxies them to
+the development server.
+
+**Configuring the development server**
+
+By default the development server listens on an available port on `127.0.0.1`,
+and `ember-cli-rails` waits up to 30 seconds for it to start:
+
+```rb
+EmberCli.configure do |c|
+ c.app :frontend, dev_server: { host: "127.0.0.1", port: 4200, timeout: 60 }
+end
+```
+
+If something is already listening on the configured `host` and `port`,
+`ember-cli-rails` uses it instead of starting a second server. That makes it
+possible to run `npm start` yourself, on a port the initializer names, and have
+Rails serve the application from it.
+
+The development server's output is written to
+`log/ember-..log`.
+
+**Opting out**
+
+To build the application once, synchronously, on the first request instead of
+running a development server, disable it:
+
+```rb
+EmberCli.configure do |c|
+ c.app :frontend, dev_server: false
+end
+```
+
+Changes to the Ember application are then only picked up by restarting the
+Rails server.
+
+The development server is only used in `development`. `test` and `production`
+are served from the output of `ember build`, as they always have been.
## Deploy
@@ -472,6 +523,12 @@ and `modulepreload` links, and the ES module script tags — extracted from
the generated `index.html`. `include_ember_stylesheet_tags` only supports
classic (Broccoli-based) applications.
+The asset helpers always read the output of `ember build`, so they do not use
+[Vite's development server](#vite-based-applications). To serve a Vite-based
+application from it, render the application with `render_ember_app`. Otherwise,
+disable the development server with `dev_server: false` so that the assets the
+helpers refer to are built.
+
Following the example above, configure the mounted EmberCLI application to be
served by a custom controller (`ApplicationController`, in this case).
@@ -627,6 +684,11 @@ make sure it's configured to run a single worker process.
Without restricting the server to a single process, [it is possible for multiple
EmberCLI runners to clobber each others' work][#94].
+This does not apply to Vite-based applications served by [Vite's development
+server](#vite-based-applications): nothing is written to a shared build
+directory. Give the application a fixed `port` so that the workers share a
+single development server rather than starting one each.
+
[Puma]: https://github.com/puma/puma
[Unicorn]: https://rubygems.org/gems/unicorn
[#94]: https://github.com/tricknotes/ember-cli-rails/issues/94#issuecomment-77627453
@@ -689,8 +751,10 @@ Note the following limitations for Vite-based applications:
* `include_ember_script_tags` emits the full set of tags the application
needs to boot, stylesheets included; `include_ember_stylesheet_tags` is
classic-only and must not be called for Vite-based applications
-* in development, the application is built synchronously on first request
- instead of being rebuilt on file changes
+* in development, the application is served by [Vite's development
+ server](#vite-based-applications) rather than out of a build directory. The
+ asset helpers read that build directory, so they require the development
+ server to be disabled with `dev_server: false`
## Ruby and Rails support
diff --git a/UPGRADING.md b/UPGRADING.md
index 2a4ac69a..efad64d0 100644
--- a/UPGRADING.md
+++ b/UPGRADING.md
@@ -17,10 +17,29 @@ following differences in how `ember-cli-rails` treats it:
configuration meta tag, the stylesheet links, and the module script tags
all together, while `include_ember_stylesheet_tags` supports only classic
applications. This requires `ember-cli-rails-assets >= 0.8.0`.
-* In development, the application is built synchronously on first request
- instead of being rebuilt on file changes. Restart the Rails server to pick
- up changes, or iterate with the Ember application's own development server
- (`npm start`).
+* In development, the application is served by Vite's development server —
+ the same one the application's `npm start` script runs. `ember-cli-rails`
+ starts it on the first request and shuts it down when Rails exits, and
+ rewrites the URLs in the `index.html` it serves so that the browser loads
+ the application's modules, and Vite's HMR client, from it directly. Changes
+ are hot-reloaded without restarting Rails.
+
+ Configure it, or opt out of it, with the `dev_server` option:
+
+ ```rb
+ EmberCli.configure do |c|
+ # listen on a fixed port instead of an available one
+ c.app :frontend, dev_server: { port: 4200 }
+
+ # build once, synchronously, on the first request instead
+ c.app :admin, dev_server: false
+ end
+ ```
+
+* `include_ember_script_tags` reads the output of `ember build`, which the
+ development server does not produce. Render Vite-based applications with
+ `render_ember_app`, or disable the development server with
+ `dev_server: false` to keep using the asset helpers.
[Vite]: https://vitejs.dev
diff --git a/lib/ember_cli/app.rb b/lib/ember_cli/app.rb
index 1ac42137..0a33807a 100644
--- a/lib/ember_cli/app.rb
+++ b/lib/ember_cli/app.rb
@@ -2,7 +2,9 @@
require "ember_cli/path_set"
require "ember_cli/shell"
require "ember_cli/build_monitor"
+require "ember_cli/deploy/dev_server"
require "ember_cli/deploy/file"
+require "ember_cli/dev_server"
module EmberCli
class App
@@ -49,20 +51,15 @@ def compile
def build
unless EmberCli.skip?
- if development?
- if paths.vite?
- # The Vite-based blueprint (`ember-cli >= 6.8`) has no
- # `ember-cli-rails-addon` to manage the build lock, so build
- # synchronously instead of watching for changes.
- compile
- else
- build_and_watch
- end
- elsif test?
- compile
+ if dev_server?
+ # Vite's own development server rebuilds and hot-reloads the
+ # application, so there is nothing to build ahead of time.
+ dev_server.start
+ else
+ build_for_environment
+
+ @build.wait!
end
-
- @build.wait!
end
end
@@ -106,6 +103,24 @@ def to_rack
deploy.to_rack
end
+ def dev_server
+ @dev_server ||= DevServer.new(
+ name: name,
+ paths: paths,
+ shell: @shell,
+ options: dev_server_options,
+ )
+ end
+
+ # Whether this application is served by Vite's development server rather
+ # than out of the directory `ember build` writes to.
+ def dev_server?
+ strategy = deploy_strategy
+
+ strategy.is_a?(Class) &&
+ strategy.ancestors.include?(EmberCli::Deploy::DevServer)
+ end
+
private
def development?
@@ -124,12 +139,38 @@ def deploy_strategy
strategy = options.fetch(:deploy, {})
if strategy.respond_to?(:fetch)
- strategy.fetch(rails_env, EmberCli::Deploy::File)
+ strategy.fetch(rails_env) { default_deploy_strategy }
else
strategy
end
end
+ def default_deploy_strategy
+ if development? && paths.vite? && dev_server_enabled?
+ EmberCli::Deploy::DevServer
+ else
+ EmberCli::Deploy::File
+ end
+ end
+
+ def dev_server_option
+ options.fetch(:dev_server, true)
+ end
+
+ def dev_server_enabled?
+ dev_server_option != false
+ end
+
+ def dev_server_options
+ option = dev_server_option
+
+ if option.respond_to?(:fetch)
+ option
+ else
+ {}
+ end
+ end
+
def rails_env
Rails.env.to_s.to_sym
end
@@ -138,6 +179,21 @@ def env
EmberCli.env
end
+ def build_for_environment
+ if development?
+ if paths.vite?
+ # The Vite-based blueprint (`ember-cli >= 6.8`) has no
+ # `ember-cli-rails-addon` to manage the build lock, so build
+ # synchronously instead of watching for changes.
+ compile
+ else
+ build_and_watch
+ end
+ elsif test?
+ compile
+ end
+ end
+
def build_and_watch
prepare
@shell.build_and_watch
diff --git a/lib/ember_cli/command.rb b/lib/ember_cli/command.rb
index affbdfa0..bea5925f 100644
--- a/lib/ember_cli/command.rb
+++ b/lib/ember_cli/command.rb
@@ -17,6 +17,20 @@ def build(watch: false)
ember_build(watch: watch)
end
+ # Boots Vite's development server for an application generated with the
+ # Vite-based blueprint (`ember-cli >= 6.8`). This is what the blueprint's
+ # own `npm start` script runs.
+ def dev_server(host:, port:)
+ line = Terrapin::CommandLine.new(paths.vite, [
+ "--host :host",
+ "--port :port",
+ "--strictPort",
+ "--clearScreen false",
+ ].join(" "))
+
+ line.command(host: host.to_s, port: port.to_s)
+ end
+
private
attr_reader :options, :paths
diff --git a/lib/ember_cli/deploy/dev_server.rb b/lib/ember_cli/deploy/dev_server.rb
new file mode 100644
index 00000000..166c12c9
--- /dev/null
+++ b/lib/ember_cli/deploy/dev_server.rb
@@ -0,0 +1,47 @@
+require "ember_cli/dev_server_proxy"
+require "ember_cli/errors"
+
+module EmberCli
+ module Deploy
+ # Serves an Ember application from its Vite development server instead of
+ # from the `dist` directory written by `ember build`.
+ #
+ # The `index.html` served by the development server refers to its assets
+ # (`/@vite/client` included) with root-relative URLs. Rails serves the
+ # document from its own origin, so those URLs are rewritten to absolute
+ # URLs pointing at the development server. Loading them from there means
+ # the Vite client opens its HMR WebSocket against the development server
+ # directly, without Rails proxying it.
+ class DevServer
+ ROOT_RELATIVE_URL = %r{(\s)(src|href)=(["'])/(?!/)}i
+
+ def initialize(app)
+ @app = app
+ end
+
+ def mountable?
+ true
+ end
+
+ def to_rack
+ DevServerProxy.new(app)
+ end
+
+ def index_html
+ rewrite_root_relative_urls(dev_server.index_html)
+ end
+
+ private
+
+ attr_reader :app
+
+ delegate :dev_server, to: :app
+
+ def rewrite_root_relative_urls(html)
+ html.gsub(ROOT_RELATIVE_URL) do
+ "#{$1}#{$2}=#{$3}#{dev_server.origin}/"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ember_cli/dev_server.rb b/lib/ember_cli/dev_server.rb
new file mode 100644
index 00000000..4c4fe5ec
--- /dev/null
+++ b/lib/ember_cli/dev_server.rb
@@ -0,0 +1,171 @@
+require "monitor"
+require "net/http"
+require "socket"
+require "uri"
+
+require "ember_cli/errors"
+
+module EmberCli
+ # Manages the Vite development server backing a single Ember application.
+ #
+ # The server is booted lazily, and the Ember application's `index.html` and
+ # assets are read from it over HTTP instead of from the `dist` directory
+ # written by `ember build`.
+ class DevServer
+ DEFAULT_HOST = "127.0.0.1".freeze
+ DEFAULT_TIMEOUT = 30
+ POLL_INTERVAL = 0.1
+ CONNECT_TIMEOUT = 1
+
+ def initialize(name:, paths:, shell:, options: {})
+ @name = name
+ @paths = paths
+ @shell = shell
+ @options = options.respond_to?(:fetch) ? options : {}
+ @monitor = Monitor.new
+ end
+
+ def host
+ @host ||= option(:host) { DEFAULT_HOST }.to_s
+ end
+
+ def port
+ @port ||= option(:port) { available_port }.to_i
+ end
+
+ def timeout
+ @timeout ||= option(:timeout) { DEFAULT_TIMEOUT }.to_f
+ end
+
+ def origin
+ "http://#{host}:#{port}"
+ end
+
+ # Boots the development server unless something is already listening on
+ # its address, and blocks until it accepts connections.
+ def start
+ return true if listening?
+
+ # Requests are served concurrently, so make sure only one of them boots
+ # the development server.
+ monitor.synchronize do
+ return true if listening?
+
+ shell.start_dev_server(host: host, port: port)
+
+ wait_until_listening!
+ end
+ end
+
+ def index_html
+ response = get("/")
+
+ unless response.is_a?(Net::HTTPSuccess)
+ fail BuildError.new(<<~MSG)
+ #{name.inspect} failed to serve an `index.html` file.
+
+ #{origin}/ responded with #{response.code} #{response.message}:
+
+ #{response.body}
+ MSG
+ end
+
+ response.body.to_s
+ end
+
+ def get(path, headers = {})
+ request(Net::HTTP::Get, path, headers)
+ end
+
+ def head(path, headers = {})
+ request(Net::HTTP::Head, path, headers)
+ end
+
+ def options_request(path, headers = {})
+ request(Net::HTTP::Options, path, headers)
+ end
+
+ private
+
+ attr_reader :monitor, :name, :options, :paths, :shell
+
+ def option(key)
+ options.fetch(key) { options.fetch(key.to_s) { yield } }
+ end
+
+ def request(request_class, path, headers)
+ start
+
+ uri = URI.join(origin, path)
+ # Pass `nil` as the proxy address so that a `http_proxy` environment
+ # variable never routes requests for the local server through a proxy.
+ Net::HTTP.start(uri.hostname, uri.port, nil, read_timeout: timeout) do |http|
+ http.request(request_class.new(uri, headers))
+ end
+ rescue SystemCallError, IOError, Timeout::Error => error
+ fail BuildError.new(<<~MSG)
+ #{name.inspect} could not reach its development server at #{origin}.
+
+ #{error.class}: #{error.message}
+
+ Its output is written to #{paths.log}.
+ MSG
+ end
+
+ def listening?
+ Socket.tcp(host, port, connect_timeout: CONNECT_TIMEOUT, &:close)
+
+ true
+ rescue SystemCallError, IOError
+ false
+ end
+
+ def wait_until_listening!
+ deadline = now + timeout
+
+ loop do
+ return true if listening?
+
+ unless shell.dev_server_running?
+ fail BuildError.new(<<~MSG)
+ #{name.inspect} failed to start its development server on #{origin}.
+
+ Its output is written to #{paths.log}.
+ MSG
+ end
+
+ if now >= deadline
+ fail BuildError.new(<<~MSG)
+ #{name.inspect} timed out after #{timeout.round} seconds waiting for
+ its development server to listen on #{origin}.
+
+ Its output is written to #{paths.log}.
+
+ Configure a longer timeout with:
+
+ EmberCli.configure do |config|
+ config.app #{name.to_sym.inspect}, dev_server: { timeout: 60 }
+ end
+
+ MSG
+ end
+
+ sleep POLL_INTERVAL
+ end
+ end
+
+ def now
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ end
+
+ def available_port
+ server = TCPServer.new(host, 0)
+
+ begin
+ server.addr[1]
+ ensure
+ server.close
+ end
+ end
+ end
+end
diff --git a/lib/ember_cli/dev_server_proxy.rb b/lib/ember_cli/dev_server_proxy.rb
new file mode 100644
index 00000000..1e30ed40
--- /dev/null
+++ b/lib/ember_cli/dev_server_proxy.rb
@@ -0,0 +1,103 @@
+require "rack"
+
+module EmberCli
+ # Forwards requests made to an Ember application's mount point to its Vite
+ # development server.
+ #
+ # Assets referenced by the Ember application itself (rather than by its
+ # `index.html`) are resolved against the document's URL, so they are
+ # requested from Rails. Proxying them keeps those references working the
+ # same way they do when the application is served out of `dist`.
+ class DevServerProxy
+ ALLOWED_VERBS = %w[GET HEAD OPTIONS].freeze
+ # Hop-by-hop headers, plus the `Content-Encoding` header describing a body
+ # that `Net::HTTP` may have decoded already.
+ SKIPPED_HEADERS = %w[
+ connection
+ content-encoding
+ keep-alive
+ proxy-authenticate
+ proxy-authorization
+ te
+ trailer
+ transfer-encoding
+ upgrade
+ ].freeze
+
+ def initialize(app)
+ @app = app
+ end
+
+ def call(env)
+ request = Rack::Request.new(env)
+
+ unless ALLOWED_VERBS.include?(request.request_method)
+ return method_not_allowed
+ end
+
+ rack_response(proxy(request))
+ end
+
+ private
+
+ attr_reader :app
+
+ delegate :dev_server, to: :app
+
+ def proxy(request)
+ path = path_for(request)
+ headers = { "accept-encoding" => "identity" }
+
+ case request.request_method
+ when "HEAD" then dev_server.head(path, headers)
+ when "OPTIONS" then dev_server.options_request(path, headers)
+ else dev_server.get(path, headers)
+ end
+ end
+
+ def path_for(request)
+ path = request.path_info.presence || "/"
+ query = request.query_string
+
+ if query.present?
+ "#{path}?#{query}"
+ else
+ path
+ end
+ end
+
+ def rack_response(response)
+ body = response.body.to_s
+
+ [response.code.to_i, response_headers(response, body), [body]]
+ end
+
+ def response_headers(response, body)
+ headers = {}
+
+ response.each_header do |name, value|
+ unless SKIPPED_HEADERS.include?(name.downcase)
+ headers[name.downcase] = value
+ end
+ end
+
+ headers["content-length"] ||= body.bytesize.to_s
+
+ headers
+ end
+
+ def method_not_allowed
+ body = "Method Not Allowed"
+
+ [
+ 405,
+ {
+ "allow" => ALLOWED_VERBS.join(", "),
+ "content-type" => "text/plain",
+ "content-length" => body.bytesize.to_s,
+ },
+ [body],
+ ]
+ end
+ end
+end
diff --git a/lib/ember_cli/path_set.rb b/lib/ember_cli/path_set.rb
index 41d0c22e..3a553d97 100644
--- a/lib/ember_cli/path_set.rb
+++ b/lib/ember_cli/path_set.rb
@@ -64,6 +64,24 @@ def ember
end
end
+ def vite
+ @vite ||= begin
+ root.join("node_modules", ".bin", "vite").tap do |path|
+ unless path.executable?
+ fail DependencyError.new <<-MSG.strip_heredoc
+ No `vite` executable found for `#{app_name}`.
+
+ Install it:
+
+ $ cd #{root}
+ $ #{package_manager} install
+
+ MSG
+ end
+ end
+ end
+ end
+
def lockfile
@lockfile ||= tmp.join("build.lock")
end
diff --git a/lib/ember_cli/shell.rb b/lib/ember_cli/shell.rb
index d5febc35..8bc81672 100644
--- a/lib/ember_cli/shell.rb
+++ b/lib/ember_cli/shell.rb
@@ -20,16 +20,44 @@ def compile
def build_and_watch
unless running?
lock_buildfile
- self.pid = spawn ember.build(watch: true)
+ self.pid = spawn(
+ ember.build(watch: true),
+ err: paths.build_error_file.to_s,
+ )
detach
end
end
+ def start_dev_server(host:, port:)
+ unless dev_server_running?
+ # Run the development server in its own process group so that the
+ # whole tree can be signaled when Rails exits.
+ self.dev_server_pid = spawn(
+ ember.dev_server(host: host, port: port),
+ out: [paths.log.to_s, "a"],
+ err: [:child, :out],
+ pgroup: true,
+ )
+ Process.detach(dev_server_pid)
+ end
+
+ dev_server_pid
+ end
+
+ def dev_server_running?
+ process_running?(dev_server_pid)
+ end
+
def stop
if pid.present?
- Process.kill(:INT, pid)
+ signal(pid)
self.pid = nil
end
+
+ if dev_server_pid.present?
+ signal(-dev_server_pid)
+ self.dev_server_pid = nil
+ end
end
def install
@@ -58,7 +86,7 @@ def test
private
- attr_accessor :pid
+ attr_accessor :dev_server_pid, :pid
attr_reader :ember, :env, :options, :paths
delegate :run, :run!, to: :runner
@@ -80,15 +108,21 @@ def ember_dependency_directories
].select(&:exist?)
end
- def spawn(command)
+ def spawn(command, **redirects)
Kernel.spawn(
env,
command,
chdir: paths.root.to_s,
- err: paths.build_error_file.to_s,
+ **redirects,
) || exit(1)
end
+ def signal(process_id)
+ Process.kill(:INT, process_id)
+ rescue Errno::ESRCH, Errno::EPERM
+ nil
+ end
+
def runner
Runner.new(
options: { chdir: paths.root.to_s },
@@ -99,7 +133,11 @@ def runner
end
def running?
- pid.present? && Process.getpgid(pid)
+ process_running?(pid)
+ end
+
+ def process_running?(process_id)
+ process_id.present? && !!Process.getpgid(process_id)
rescue Errno::ESRCH
false
end
diff --git a/spec/dummy/config/initializers/ember.rb b/spec/dummy/config/initializers/ember.rb
index 472f3e06..50a95cfb 100644
--- a/spec/dummy/config/initializers/ember.rb
+++ b/spec/dummy/config/initializers/ember.rb
@@ -1,3 +1,14 @@
EmberCli.configure do |c|
c.app "my-app"
+
+ # The same Ember application, served by Vite's development server.
+ #
+ # The development server is only selected by default for Vite-based
+ # applications in `development`, and the suite runs in `test`, so ask for
+ # the strategy explicitly. Booting Vite is slower than opening a socket, so
+ # allow more time than the default for it to start listening.
+ c.app "my-app-dev-server",
+ path: "my-app",
+ deploy: { test: EmberCli::Deploy::DevServer },
+ dev_server: { timeout: 120 }
end
diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb
index 8c1d17b1..e409b754 100644
--- a/spec/dummy/config/routes.rb
+++ b/spec/dummy/config/routes.rb
@@ -29,6 +29,13 @@
)
end
+ mount_ember_app(
+ "my-app-dev-server",
+ to: "/dev-server",
+ # `get "/dev-server"` already claims the `dev_server` route name.
+ as: "dev_server_app",
+ )
+
mount_ember_app(
"my-app",
to: "/",
diff --git a/spec/features/user_views_ember_app_served_by_dev_server_spec.rb b/spec/features/user_views_ember_app_served_by_dev_server_spec.rb
new file mode 100644
index 00000000..19a3e77b
--- /dev/null
+++ b/spec/features/user_views_ember_app_served_by_dev_server_spec.rb
@@ -0,0 +1,62 @@
+feature "User views ember app served by its development server", :js do
+ before { skip_without_vite_blueprint }
+
+ scenario "the application boots from the development server" do
+ visit dev_server_app_path
+
+ expect(page).to have_javascript_rendered_text
+ expect(page).to have_csrf_tags
+ end
+
+ scenario "the application's assets are loaded from the development server" do
+ visit dev_server_app_path
+
+ expect(page).to have_javascript_rendered_text
+ expect(page).to have_vite_client
+ expect(page).to have_loaded_client_side_asset
+ end
+
+ def have_javascript_rendered_text
+ have_text("Welcome to Ember")
+ end
+
+ def have_csrf_tags
+ have_css("meta[name=csrf-param]", visible: false).
+ and have_css("meta[name=csrf-token]", visible: false)
+ end
+
+ # Rewriting the root-relative URLs in `index.html` is what lets the Vite
+ # client open its HMR connection against the development server directly.
+ def have_vite_client
+ have_css(
+ %{script[src="#{dev_server_origin}/@vite/client"]},
+ visible: false,
+ )
+ end
+
+ # The image is referenced relatively by the application's own template, so
+ # the browser requests it from Rails, which proxies it to the development
+ # server.
+ def have_loaded_client_side_asset
+ have_css(%{img[src*="logo.png"]}).and satisfy { logo_loaded? }
+ end
+
+ def logo_loaded?
+ Timeout.timeout(Capybara.default_max_wait_time) do
+ sleep 0.1 until evaluate_logo_loaded
+ true
+ end
+ rescue Timeout::Error
+ false
+ end
+
+ def evaluate_logo_loaded
+ page.evaluate_script(<<~JS)
+ (function() {
+ var image = document.querySelector('img[src*="logo.png"]');
+
+ return !!image && image.complete && image.naturalWidth > 0;
+ })()
+ JS
+ end
+end
diff --git a/spec/lib/ember_cli/app_spec.rb b/spec/lib/ember_cli/app_spec.rb
index fdacde50..b4eb6802 100644
--- a/spec/lib/ember_cli/app_spec.rb
+++ b/spec/lib/ember_cli/app_spec.rb
@@ -11,6 +11,14 @@
expect(to_rack).to be :delegated
end
+
+ context "when served by the development server" do
+ it "proxies to the development server" do
+ app = build_app("frontend", vite: true, environment: "development")
+
+ expect(app.to_rack).to be_a(EmberCli::DevServerProxy)
+ end
+ end
end
describe "#mountable?" do
@@ -113,6 +121,79 @@
end
end
+ describe "#dev_server?" do
+ context "with a Vite-based application in development" do
+ it "returns true" do
+ app = build_app("frontend", vite: true, environment: "development")
+
+ expect(app.dev_server?).to be true
+ end
+
+ it "returns false when the development server is disabled" do
+ app = build_app(
+ "frontend",
+ vite: true,
+ environment: "development",
+ dev_server: false,
+ )
+
+ expect(app.dev_server?).to be false
+ end
+
+ it "returns false when another strategy is configured for the environment" do
+ app = build_app(
+ "frontend",
+ vite: true,
+ environment: "development",
+ deploy: { development: EmberCli::Deploy::File },
+ )
+
+ expect(app.dev_server?).to be false
+ end
+ end
+
+ context "with a classic application in development" do
+ it "returns false" do
+ app = build_app("frontend", vite: false, environment: "development")
+
+ expect(app.dev_server?).to be false
+ end
+ end
+
+ context "outside of development" do
+ it "returns false" do
+ app = build_app("frontend", vite: true, environment: "test")
+
+ expect(app.dev_server?).to be false
+ end
+ end
+ end
+
+ describe "#build" do
+ context "when served by the development server" do
+ it "starts the development server instead of building" do
+ app = build_app("frontend", vite: true, environment: "development")
+ dev_server = double(start: true)
+ allow(app).to receive(:dev_server).and_return(dev_server)
+ allow(app).to receive(:compile)
+
+ app.build
+
+ expect(dev_server).to have_received(:start)
+ expect(app).not_to have_received(:compile)
+ end
+ end
+ end
+
+ def build_app(name, vite:, environment:, **options)
+ allow(Rails).to receive(:env).
+ and_return(ActiveSupport::StringInquirer.new(environment))
+ allow(EmberCli).to receive(:env).and_return(environment)
+ allow_any_instance_of(EmberCli::PathSet).to receive(:vite?).and_return(vite)
+
+ EmberCli::App.new(name, **options)
+ end
+
def stub_paths(method_to_value)
allow_any_instance_of(EmberCli::PathSet).
to receive(method_to_value.keys.first).
diff --git a/spec/lib/ember_cli/deploy/dev_server_spec.rb b/spec/lib/ember_cli/deploy/dev_server_spec.rb
new file mode 100644
index 00000000..429f3c0b
--- /dev/null
+++ b/spec/lib/ember_cli/deploy/dev_server_spec.rb
@@ -0,0 +1,120 @@
+require "ember_cli/deploy/dev_server"
+
+describe EmberCli::Deploy::DevServer do
+ describe "#index_html" do
+ it "rewrites root-relative URLs to the development server's origin" do
+ deploy = build_deploy(index_html: <<~HTML)
+
+
+
+
+
+
+
+
+
+
+
+ HTML
+
+ index_html = deploy.index_html
+
+ expect(index_html).to include(
+ %{src="http://127.0.0.1:4200/@vite/client"},
+ %{href="http://127.0.0.1:4200/@embroider/virtual/app.css"},
+ %{src="http://127.0.0.1:4200/@embroider/virtual/vendor.js"},
+ %{src="http://127.0.0.1:4200/index.html?html-proxy&index=0.js"},
+ )
+ end
+
+ it "rewrites single-quoted attributes" do
+ deploy = build_deploy(index_html: %{})
+
+ index_html = deploy.index_html
+
+ expect(index_html).to eq(
+ %{},
+ )
+ end
+
+ it "leaves document-relative URLs alone" do
+ deploy = build_deploy(index_html: %{})
+
+ index_html = deploy.index_html
+
+ expect(index_html).to eq(%{})
+ end
+
+ it "leaves absolute and protocol-relative URLs alone" do
+ deploy = build_deploy(index_html: <<~HTML)
+
+
+ HTML
+
+ index_html = deploy.index_html
+
+ expect(index_html).to include(
+ %{href="https://cdn.example.com/app.css"},
+ %{src="//cdn.example.com/app.js"},
+ )
+ end
+
+ it "leaves attributes that merely end in `src` or `href` alone" do
+ deploy = build_deploy(index_html: %{})
+
+ index_html = deploy.index_html
+
+ expect(index_html).to eq(%{})
+ end
+
+ it "leaves the encoded configuration meta tag alone" do
+ content = "%7B%22rootURL%22%3A%22%2F%22%7D"
+ deploy = build_deploy(
+ index_html: %{},
+ )
+
+ index_html = deploy.index_html
+
+ expect(index_html).to include(content)
+ end
+
+ it "returns a string that can be mutated by `HtmlPage::Renderer`" do
+ deploy = build_deploy(index_html: "")
+
+ index_html = deploy.index_html
+
+ expect { index_html.insert(0, "!") }.not_to raise_error
+ end
+ end
+
+ describe "#mountable?" do
+ it "returns true" do
+ deploy = build_deploy(index_html: "")
+
+ mountable = deploy.mountable?
+
+ expect(mountable).to be true
+ end
+ end
+
+ describe "#to_rack" do
+ it "creates a proxy to the development server" do
+ deploy = build_deploy(index_html: "")
+
+ rack_app = deploy.to_rack
+
+ expect(rack_app).to be_a(EmberCli::DevServerProxy)
+ expect(rack_app).to respond_to(:call)
+ end
+ end
+
+ def build_deploy(index_html:, origin: "http://127.0.0.1:4200")
+ dev_server = double(
+ "EmberCli::DevServer",
+ index_html: index_html,
+ origin: origin,
+ )
+
+ EmberCli::Deploy::DevServer.new(double("EmberCli::App", dev_server: dev_server))
+ end
+end
diff --git a/spec/lib/ember_cli/dev_server_proxy_spec.rb b/spec/lib/ember_cli/dev_server_proxy_spec.rb
new file mode 100644
index 00000000..a0877c6b
--- /dev/null
+++ b/spec/lib/ember_cli/dev_server_proxy_spec.rb
@@ -0,0 +1,131 @@
+require "ember_cli/dev_server_proxy"
+
+describe EmberCli::DevServerProxy do
+ describe "#call" do
+ it "forwards the request path and query string to the development server" do
+ dev_server = build_dev_server
+ proxy = EmberCli::DevServerProxy.new(double(dev_server: dev_server))
+
+ proxy.call(rack_env("/assets/logo.png", query: "v=1"))
+
+ expect(dev_server).to have_received(:get).
+ with("/assets/logo.png?v=1", "accept-encoding" => "identity")
+ end
+
+ it "requests the root when mounted without a nested path" do
+ dev_server = build_dev_server
+ proxy = EmberCli::DevServerProxy.new(double(dev_server: dev_server))
+
+ proxy.call(rack_env(""))
+
+ expect(dev_server).to have_received(:get).
+ with("/", "accept-encoding" => "identity")
+ end
+
+ it "returns the development server's status, headers, and body" do
+ dev_server = build_dev_server(
+ body: "png-bytes",
+ headers: { "content-type" => "image/png" },
+ )
+ proxy = EmberCli::DevServerProxy.new(double(dev_server: dev_server))
+
+ status, headers, body = proxy.call(rack_env("/assets/logo.png"))
+
+ expect(status).to eq(200)
+ expect(headers).to include(
+ "content-type" => "image/png",
+ "content-length" => "9",
+ )
+ expect(body).to eq(["png-bytes"])
+ end
+
+ it "returns the development server's error responses" do
+ dev_server = build_dev_server(code: "404", body: "Not Found")
+ proxy = EmberCli::DevServerProxy.new(double(dev_server: dev_server))
+
+ status, _, body = proxy.call(rack_env("/missing.png"))
+
+ expect(status).to eq(404)
+ expect(body).to eq(["Not Found"])
+ end
+
+ it "drops hop-by-hop headers" do
+ dev_server = build_dev_server(headers: {
+ "content-type" => "text/css",
+ "connection" => "keep-alive",
+ "transfer-encoding" => "chunked",
+ "content-encoding" => "gzip",
+ })
+ proxy = EmberCli::DevServerProxy.new(double(dev_server: dev_server))
+
+ _, headers, _ = proxy.call(rack_env("/app.css"))
+
+ expect(headers).to include("content-type" => "text/css")
+ expect(headers.keys).
+ not_to include("connection", "transfer-encoding", "content-encoding")
+ end
+
+ it "keeps the development server's `content-length` when it sends one" do
+ dev_server = build_dev_server(
+ body: "",
+ headers: { "content-length" => "23905" },
+ )
+ proxy = EmberCli::DevServerProxy.new(double(dev_server: dev_server))
+
+ _, headers, _ = proxy.call(rack_env("/assets/logo.png", method: "HEAD"))
+
+ expect(headers).to include("content-length" => "23905")
+ end
+
+ it "sends `HEAD` and `OPTIONS` requests with their own verbs" do
+ dev_server = build_dev_server
+ proxy = EmberCli::DevServerProxy.new(double(dev_server: dev_server))
+
+ proxy.call(rack_env("/app.css", method: "HEAD"))
+ proxy.call(rack_env("/app.css", method: "OPTIONS"))
+
+ expect(dev_server).to have_received(:head).once
+ expect(dev_server).to have_received(:options_request).once
+ expect(dev_server).not_to have_received(:get)
+ end
+
+ it "responds with `405 Method Not Allowed` to other verbs" do
+ dev_server = build_dev_server
+ proxy = EmberCli::DevServerProxy.new(double(dev_server: dev_server))
+
+ status, headers, _ = proxy.call(rack_env("/app.css", method: "POST"))
+
+ expect(status).to eq(405)
+ expect(headers).to include("allow" => "GET, HEAD, OPTIONS")
+ expect(dev_server).not_to have_received(:get)
+ end
+ end
+
+ def build_dev_server(code: "200", body: "", headers: {})
+ response = build_response(code: code, body: body, headers: headers)
+
+ double(
+ "EmberCli::DevServer",
+ get: response,
+ head: response,
+ options_request: response,
+ )
+ end
+
+ def build_response(code:, body:, headers:)
+ double("Net::HTTPResponse", code: code, body: body).tap do |response|
+ allow(response).to receive(:each_header) do |&block|
+ headers.each { |name, value| block.call(name, value) }
+ end
+ end
+ end
+
+ def rack_env(path, method: "GET", query: "")
+ Rack::MockRequest.env_for("http://example.com/mounted#{path}").merge(
+ "REQUEST_METHOD" => method,
+ "SCRIPT_NAME" => "/mounted",
+ "PATH_INFO" => path,
+ "QUERY_STRING" => query,
+ )
+ end
+end
diff --git a/spec/lib/ember_cli/dev_server_spec.rb b/spec/lib/ember_cli/dev_server_spec.rb
new file mode 100644
index 00000000..cc578cf7
--- /dev/null
+++ b/spec/lib/ember_cli/dev_server_spec.rb
@@ -0,0 +1,238 @@
+require "webrick"
+
+require "ember_cli/dev_server"
+
+describe EmberCli::DevServer do
+ describe "#origin" do
+ it "defaults to the loopback interface" do
+ dev_server = build_dev_server(options: { port: 4200 })
+
+ origin = dev_server.origin
+
+ expect(origin).to eq("http://127.0.0.1:4200")
+ end
+
+ it "honors a configured host and port" do
+ dev_server = build_dev_server(options: { host: "0.0.0.0", port: 1234 })
+
+ origin = dev_server.origin
+
+ expect(origin).to eq("http://0.0.0.0:1234")
+ end
+
+ it "accepts string keys" do
+ dev_server = build_dev_server(options: { "host" => "0.0.0.0", "port" => 1234 })
+
+ origin = dev_server.origin
+
+ expect(origin).to eq("http://0.0.0.0:1234")
+ end
+ end
+
+ describe "#port" do
+ it "allocates an available port when none is configured" do
+ dev_server = build_dev_server
+
+ port = dev_server.port
+
+ expect(port).to be > 0
+ expect(dev_server.port).to eq(port)
+ end
+ end
+
+ describe "#start" do
+ it "boots the development server and waits for it to listen" do
+ server = null_server
+ shell = FakeShell.new { |host, port| server.listen(host, port) }
+ dev_server = build_dev_server(shell: shell)
+
+ started = dev_server.start
+
+ expect(started).to be true
+ expect(shell.started_with).to eq(host: "127.0.0.1", port: dev_server.port)
+ end
+
+ it "reuses a development server that is already listening" do
+ server = null_server
+ server.listen("127.0.0.1", 0)
+ shell = FakeShell.new
+ dev_server = build_dev_server(shell: shell, options: { port: server.port })
+
+ started = dev_server.start
+
+ expect(started).to be true
+ expect(shell.started_with).to be_nil
+ end
+
+ it "raises when the development server exits before it listens" do
+ shell = FakeShell.new(running: false)
+ dev_server = build_dev_server(shell: shell)
+
+ expect { dev_server.start }.to raise_error(
+ EmberCli::BuildError,
+ /failed to start its development server/,
+ )
+ end
+
+ it "raises when the development server does not listen in time" do
+ dev_server = build_dev_server(options: { timeout: 0 })
+
+ expect { dev_server.start }.to raise_error(
+ EmberCli::BuildError,
+ /timed out after 0 seconds/,
+ )
+ end
+ end
+
+ describe "#index_html" do
+ it "returns the document served by the development server" do
+ server = null_server
+ server.listen("127.0.0.1", 0, body: "")
+ dev_server = build_dev_server(options: { port: server.port })
+
+ index_html = dev_server.index_html
+
+ expect(index_html).to eq("")
+ expect(server.requested_paths).to eq(["/"])
+ end
+
+ it "raises when the development server fails to render the document" do
+ server = null_server
+ server.listen("127.0.0.1", 0, status: 500, body: "Internal Server Error")
+ dev_server = build_dev_server(options: { port: server.port })
+
+ expect { dev_server.index_html }.to raise_error(
+ EmberCli::BuildError,
+ /responded with 500/,
+ )
+ end
+ end
+
+ describe "#get" do
+ it "requests a path from the development server" do
+ server = null_server
+ server.listen("127.0.0.1", 0, body: "png-bytes")
+ dev_server = build_dev_server(options: { port: server.port })
+
+ response = dev_server.get("/assets/logo.png?v=1")
+
+ expect(response.body).to eq("png-bytes")
+ expect(server.requested_paths).to eq(["/assets/logo.png?v=1"])
+ end
+
+ it "ignores a configured HTTP proxy" do
+ server = null_server
+ server.listen("127.0.0.1", 0, body: "ok")
+ dev_server = build_dev_server(options: { port: server.port })
+
+ response = with_env("http_proxy" => "http://proxy.invalid:3128") do
+ dev_server.get("/")
+ end
+
+ expect(response.body).to eq("ok")
+ end
+
+ it "raises when the development server is unreachable" do
+ dev_server = build_dev_server(options: { port: unused_port })
+ allow(dev_server).to receive(:start).and_return(true)
+
+ expect { dev_server.get("/") }.to raise_error(
+ EmberCli::BuildError,
+ /could not reach its development server/,
+ )
+ end
+ end
+
+ # A stand-in for `EmberCli::Shell` that records how the development server
+ # was booted, and optionally boots a stub HTTP server in its place.
+ class FakeShell
+ attr_reader :started_with
+
+ def initialize(running: true, &on_start)
+ @running = running
+ @on_start = on_start
+ end
+
+ def start_dev_server(host:, port:)
+ @started_with = { host: host, port: port }
+ @on_start&.call(host, port)
+ end
+
+ def dev_server_running?
+ @running
+ end
+ end
+
+ # A stub for Vite's development server.
+ class NullServer
+ attr_reader :port, :requested_paths
+
+ def initialize
+ @requested_paths = []
+ end
+
+ def listen(host, port, status: 200, body: "")
+ @server = WEBrick::HTTPServer.new(
+ BindAddress: host,
+ Port: port,
+ Logger: WEBrick::Log.new(File::NULL),
+ AccessLog: [],
+ )
+ @port = @server.config[:Port]
+
+ @server.mount_proc "/" do |request, response|
+ @requested_paths << [request.path, request.query_string].compact.
+ reject(&:empty?).join("?")
+ response.status = status
+ response.body = body
+ end
+
+ @thread = Thread.new { @server.start }
+
+ @port
+ end
+
+ def shutdown
+ @server&.shutdown
+ @thread&.join
+ end
+ end
+
+ def null_server
+ NullServer.new.tap { |server| servers << server }
+ end
+
+ def servers
+ @servers ||= []
+ end
+
+ after { servers.each(&:shutdown) }
+
+ def unused_port
+ server = TCPServer.new("127.0.0.1", 0)
+
+ begin
+ server.addr[1]
+ ensure
+ server.close
+ end
+ end
+
+ def with_env(variables)
+ original = variables.keys.index_with { |key| ENV[key] }
+ ENV.update(variables)
+
+ yield
+ ensure
+ original.each { |key, value| ENV[key] = value }
+ end
+
+ def build_dev_server(shell: FakeShell.new, options: {})
+ EmberCli::DevServer.new(
+ name: "my-app",
+ paths: double("EmberCli::PathSet", log: "log/ember-my-app.development.log"),
+ shell: shell,
+ options: options,
+ )
+ end
+end
diff --git a/spec/requests/dev_server/asset_proxy_spec.rb b/spec/requests/dev_server/asset_proxy_spec.rb
new file mode 100644
index 00000000..b01bdd74
--- /dev/null
+++ b/spec/requests/dev_server/asset_proxy_spec.rb
@@ -0,0 +1,37 @@
+describe "Request an asset from an app served by its development server" do
+ before { skip_without_vite_blueprint }
+
+ it "proxies the request to the development server" do
+ get "/dev-server/assets/logo.png"
+
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq("image/png")
+ expect(response.body.bytesize).to eq(logo_size)
+ end
+
+ it "forwards the query string" do
+ get "/dev-server/assets/logo.png", params: { v: "1" }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq("image/png")
+ end
+
+ it "responds to `HEAD` requests without a body" do
+ head "/dev-server/assets/logo.png"
+
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq("image/png")
+ expect(response.body).to be_empty
+ end
+
+ it "rejects verbs that the development server does not serve assets for" do
+ post "/dev-server/assets/logo.png"
+
+ expect(response).to have_http_status(:method_not_allowed)
+ expect(response.headers["allow"]).to eq("GET, HEAD, OPTIONS")
+ end
+
+ def logo_size
+ Rails.root.join("my-app", "public", "assets", "logo.png").size
+ end
+end
diff --git a/spec/support/dev_server.rb b/spec/support/dev_server.rb
new file mode 100644
index 00000000..654f7b84
--- /dev/null
+++ b/spec/support/dev_server.rb
@@ -0,0 +1,18 @@
+module DevServerHelpers
+ # The Vite development server is only available for applications generated
+ # with the Vite-based blueprint (`ember-cli >= 6.8`). The dummy application
+ # is generated from `EMBER_VERSION`, which also covers older releases.
+ def skip_without_vite_blueprint
+ unless EmberCli["my-app-dev-server"].paths.vite?
+ skip "The development server requires the Vite-based blueprint"
+ end
+ end
+
+ def dev_server_origin
+ EmberCli["my-app-dev-server"].dev_server.origin
+ end
+end
+
+RSpec.configure do |config|
+ config.include DevServerHelpers
+end