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