From 640ce157f2e731933dca14136373e72ba50696ea Mon Sep 17 00:00:00 2001 From: Ryunosuke Sato Date: Sat, 22 Aug 2026 16:16:32 +0000 Subject: [PATCH] Serve Vite-based apps from Vite's development server Vite-based Ember applications (`ember-cli >= 6.8`) were built once, synchronously, on the first request in `development`, so picking up a change meant restarting Rails. The classic (Broccoli-based) build avoids that with `ember build --watch` and `ember-cli-rails-addon`, neither of which is available to the Vite-based build. Serve those applications from Vite's own development server instead -- the one the blueprint's `npm start` script runs. `EmberCli::DevServer` starts it on the first request, waits for it to accept connections, and signals its process group when Rails exits. If something is already listening on the configured address, it is used as-is, so a fixed `port` lets several Rails workers, or a hand-started `npm start`, share one server. `EmberCli::Deploy::DevServer` reads `index.html` over HTTP and rewrites its root-relative `src` and `href` attributes to absolute URLs on the development server. Loading `@vite/client` from there is what makes the Vite client open its HMR WebSocket against the development server directly, so Rails never proxies the socket. Assets the application references relatively are still requested from Rails, and `EmberCli::DevServerProxy` forwards them to the development server so they resolve the same way they do when served out of `dist`. The strategy is chosen by default only for Vite-based applications in `development`, and is configured -- or disabled -- with the `dev_server` option. `test` and `production` continue to be served from the output of `ember build`, and the classic build is untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RvmVfpMX8Dou7euMFAyzf4 --- CHANGELOG.md | 4 + README.md | 78 +++++- UPGRADING.md | 27 +- lib/ember_cli/app.rb | 84 +++++-- lib/ember_cli/command.rb | 14 ++ lib/ember_cli/deploy/dev_server.rb | 47 ++++ lib/ember_cli/dev_server.rb | 171 +++++++++++++ lib/ember_cli/dev_server_proxy.rb | 103 ++++++++ lib/ember_cli/path_set.rb | 18 ++ lib/ember_cli/shell.rb | 50 +++- spec/dummy/config/initializers/ember.rb | 11 + spec/dummy/config/routes.rb | 7 + ...ews_ember_app_served_by_dev_server_spec.rb | 62 +++++ spec/lib/ember_cli/app_spec.rb | 81 ++++++ spec/lib/ember_cli/deploy/dev_server_spec.rb | 120 +++++++++ spec/lib/ember_cli/dev_server_proxy_spec.rb | 131 ++++++++++ spec/lib/ember_cli/dev_server_spec.rb | 238 ++++++++++++++++++ spec/requests/dev_server/asset_proxy_spec.rb | 37 +++ spec/support/dev_server.rb | 18 ++ 19 files changed, 1270 insertions(+), 31 deletions(-) create mode 100644 lib/ember_cli/deploy/dev_server.rb create mode 100644 lib/ember_cli/dev_server.rb create mode 100644 lib/ember_cli/dev_server_proxy.rb create mode 100644 spec/features/user_views_ember_app_served_by_dev_server_spec.rb create mode 100644 spec/lib/ember_cli/deploy/dev_server_spec.rb create mode 100644 spec/lib/ember_cli/dev_server_proxy_spec.rb create mode 100644 spec/lib/ember_cli/dev_server_spec.rb create mode 100644 spec/requests/dev_server/asset_proxy_spec.rb create mode 100644 spec/support/dev_server.rb 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