diff --git a/docs/src/config/index.md b/docs/src/config/index.md index d8c64a30..6b5bfb36 100644 --- a/docs/src/config/index.md +++ b/docs/src/config/index.md @@ -269,11 +269,26 @@ You can customize this behavior using the following options. "base": "/nested_path" ``` +### devServerConnectionCheck + +- **Default:** `false` +- **Env Var:** `VITE_RUBY_DEV_SERVER_CONNECTION_CHECK` + + By default the dev server is detected by reading `tmp/vite-ruby.json`, a + metadata file the Vite plugin writes with the dev server URL when it starts, + and removes when it stops. + + Enable this to detect the dev server by opening a TCP connection to the + configured `host` and `port` instead. Useful if the metadata file can become + stale, for example when the Vite process is force-killed (`SIGKILL`) and has no + chance to remove it. + ### devServerConnectTimeout - **Default:** `0.01` (seconds) - Timeout when attempting to connect to the dev server (in seconds). + Timeout when attempting to connect to the dev server (in seconds), used only + when [`devServerConnectionCheck`](#devserverconnectioncheck) is enabled. You can increase this timeout if the fallback compilation is being triggered even though the dev server is running. diff --git a/docs/src/guide/troubleshooting.md b/docs/src/guide/troubleshooting.md index f7ad38b5..cdf9a842 100644 --- a/docs/src/guide/troubleshooting.md +++ b/docs/src/guide/troubleshooting.md @@ -5,6 +5,7 @@ [watchAdditionalPaths]: /config/#watchadditionalpaths [entrypointsDir]: /config/#entrypointsDir [devServerConnectTimeout]: /config/#devserverconnecttimeout +[devServerConnectionCheck]: /config/#devserverconnectioncheck [host]: /config/#host [port]: /config/#port [vite]: https://vitejs.dev/ @@ -164,7 +165,10 @@ First, verify that the dev server is reachable by starting a new console session > ViteRuby.instance.dev_server_running? ``` -If it returns `false`, try increasing the [devServerConnectTimeout], restart the console and retry. +By default this reads `tmp/vite-ruby.json`, written by the Vite plugin while the dev server runs. +If it returns `false` while the dev server is running, make sure you are on a recent [vite-plugin-ruby] that writes this file, and that `tmp/` is writable. + +If you have enabled [devServerConnectionCheck], try increasing the [devServerConnectTimeout], restart the console and retry. In systems with constrained resources the [default timeout][devServerConnectTimeout] might not be enough. If that doesn't work, verify that the [host] and [port] configuration is correct. diff --git a/test/dev_server_test.rb b/test/dev_server_test.rb index 1cb51c2a..e04b81e0 100644 --- a/test/dev_server_test.rb +++ b/test/dev_server_test.rb @@ -3,26 +3,67 @@ require "test_helper" class DevServerTest < ViteRuby::Test - def test_not_running + def test_not_running_without_meta_file + refresh_config(mode: "development") + remove_meta_file + refute_predicate ViteRuby.instance, :dev_server_running? + end + def test_running_with_meta_file refresh_config(mode: "development") + write_meta_file(url: "http://localhost:3036", host: "localhost", port: 3036, https: false, pid: 1234) - refute_predicate ViteRuby.instance, :dev_server_running? + assert_predicate ViteRuby.instance, :dev_server_running? + assert_equal "http://localhost:3036", ViteRuby.instance.send(:dev_server_meta)["url"] + ensure + remove_meta_file + end - running_checked_at = ViteRuby.instance.instance_variable_get(:@running_checked_at) + def test_not_running_in_production + refresh_config(mode: "production") + write_meta_file(url: "http://localhost:3036") - assert_in_delta(Time.now.to_f, running_checked_at.to_f, 0.01) + refute_predicate ViteRuby.instance, :dev_server_running? + ensure + remove_meta_file end - def test_running - refresh_config(mode: "development") - ViteRuby.instance.instance_variable_set(:@running, true) - ViteRuby.instance.instance_variable_set(:@running_checked_at, Time.now) + def test_connection_check_ignores_meta_file_when_socket_refused + refresh_config(mode: "development", dev_server_connection_check: true) + write_meta_file(url: "http://localhost:3036") - assert_predicate ViteRuby.instance, :dev_server_running? + Socket.stub(:tcp, ->(*) { raise Errno::ECONNREFUSED }) do + refute_predicate ViteRuby.instance, :dev_server_running? + end ensure - ViteRuby.instance.remove_instance_variable(:@running) - ViteRuby.instance.remove_instance_variable(:@running_checked_at) + remove_meta_file + end + + def test_connection_check_reports_running_when_socket_connects + refresh_config(mode: "development", dev_server_connection_check: true) + remove_meta_file + + Socket.stub(:tcp, ->(*) { FakeSocket.new }) do + assert_predicate ViteRuby.instance, :dev_server_running? + end + end + +private + + def write_meta_file(**meta) + path = ViteRuby.config.dev_server_meta_path + path.dirname.mkpath + path.write(JSON.generate(meta)) + end + + def remove_meta_file + path = ViteRuby.config.dev_server_meta_path + path.delete if path.exist? + end + + class FakeSocket + def close + end end end diff --git a/vite-plugin-ruby/default.vite.json b/vite-plugin-ruby/default.vite.json index 947206f2..52aea8b3 100644 --- a/vite-plugin-ruby/default.vite.json +++ b/vite-plugin-ruby/default.vite.json @@ -6,6 +6,7 @@ "buildCacheDir": "tmp/cache/vite", "publicOutputDir": "vite", "configPath": "config/vite.json", + "devServerConnectionCheck": false, "devServerConnectTimeout": 0.01, "packageManager": null, "publicDir": "public", diff --git a/vite-plugin-ruby/src/constants.ts b/vite-plugin-ruby/src/constants.ts index 49b8b438..5de158cd 100644 --- a/vite-plugin-ruby/src/constants.ts +++ b/vite-plugin-ruby/src/constants.ts @@ -7,6 +7,9 @@ export const ENV_PREFIX = 'VITE_RUBY' // Internal: Key of the vite.json file that is applied to all environments. export const ALL_ENVS_KEY = 'all' +// Internal: Path, relative to the project root, of the dev server metadata file. +export const DEV_SERVER_META_FILE = 'tmp/vite-ruby.json' + // Internal: Extensions of CSS files or known precompilers. export const KNOWN_CSS_EXTENSIONS = [ 'css', diff --git a/vite-plugin-ruby/src/dev-server.ts b/vite-plugin-ruby/src/dev-server.ts new file mode 100644 index 00000000..ac01306c --- /dev/null +++ b/vite-plugin-ruby/src/dev-server.ts @@ -0,0 +1,74 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { dirname } from 'path' +import type { AddressInfo } from 'net' +import type { ResolvedConfig } from 'vite' + +// Internal: Metadata written to disk so Ruby can detect the running dev server. +export interface DevServerMeta { + url: string + host: string + port: number + https: boolean + pid: number +} + +// Internal: Hosts that a browser can not connect to and must be replaced. +const WILDCARD_HOSTS = new Set(['', '0.0.0.0', '::', '::1']) + +let exitHandlersBound = false +let ownedMetaPath: string | null = null + +// Internal: Returns true when the address is a resolved TCP address. +function isAddressInfo (address: string | AddressInfo | null | undefined): address is AddressInfo { + return Boolean(address) && typeof address === 'object' +} + +// Internal: Resolves the address a browser should use to reach the dev server. +export function resolveDevServerMeta (address: string | AddressInfo | null | undefined, config: ResolvedConfig): DevServerMeta { + const https = Boolean(config.server.https) + const protocol = https ? 'https' : 'http' + const bound = isAddressInfo(address) ? address : undefined + + let host = typeof config.server.host === 'string' ? config.server.host : '' + if (WILDCARD_HOSTS.has(host)) host = 'localhost' + + const port = bound?.port ?? config.server.port ?? 0 + const url = `${protocol}://${host.includes(':') ? `[${host}]` : host}:${port}` + + return { url, host, port, https, pid: process.pid } +} + +// Internal: Writes the dev server metadata file, creating the directory if needed. +export function writeDevServerMeta (path: string, meta: DevServerMeta): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify(meta)) +} + +// Internal: Removes the metadata file only when this process owns it. +export function removeOwnedMeta (path: string, pid: number = process.pid): void { + let ownerPid: unknown + try { + ownerPid = JSON.parse(readFileSync(path, 'utf8')).pid + } + catch { + return + } + if (ownerPid === pid) rmSync(path, { force: true }) +} + +// Internal: Ensures the metadata file is removed when the dev server stops. +export function bindDevServerCleanup (path: string): void { + ownedMetaPath = path + if (exitHandlersBound) return + + exitHandlersBound = true + const cleanup = () => { if (ownedMetaPath) removeOwnedMeta(ownedMetaPath) } + process.on('exit', cleanup) + process.on('SIGINT', () => process.exit()) + process.on('SIGTERM', () => process.exit()) + process.on('SIGHUP', () => process.exit()) + process.on('uncaughtException', (error) => { + cleanup() + throw error + }) +} diff --git a/vite-plugin-ruby/src/index.ts b/vite-plugin-ruby/src/index.ts index 4a370d2f..8faa71aa 100644 --- a/vite-plugin-ruby/src/index.ts +++ b/vite-plugin-ruby/src/index.ts @@ -6,6 +6,8 @@ import { createDebug } from 'obug' import { cleanConfig, configOptionFromEnv } from './utils' import { filterEntrypointsForRollup, loadConfiguration, resolveGlobs } from './config' import { assetsManifestPlugin } from './manifest' +import { bindDevServerCleanup, resolveDevServerMeta, writeDevServerMeta } from './dev-server' +import { DEV_SERVER_META_FILE } from './constants' export * from './types' @@ -103,6 +105,12 @@ function config (userConfig: UserConfig, env: ConfigEnv): UserConfig { function configureServer (server: ViteDevServer) { server.watcher.add(watchAdditionalPaths) + const devServerMetaPath = resolve(projectRoot, DEV_SERVER_META_FILE) + server.httpServer?.once('listening', () => { + writeDevServerMeta(devServerMetaPath, resolveDevServerMeta(server.httpServer?.address(), server.config)) + bindDevServerCleanup(devServerMetaPath) + }) + return () => server.middlewares.use((req, res, next) => { if (req.url === '/index.html' && !existsSync(resolve(server.config.root, 'index.html'))) { res.statusCode = 404 diff --git a/vite-plugin-ruby/tests/dev-server.spec.ts b/vite-plugin-ruby/tests/dev-server.spec.ts new file mode 100644 index 00000000..69f9fde8 --- /dev/null +++ b/vite-plugin-ruby/tests/dev-server.spec.ts @@ -0,0 +1,37 @@ +import { existsSync, mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, it, expect } from 'vitest' + +import { removeOwnedMeta, resolveDevServerMeta, writeDevServerMeta } from '../src/dev-server' + +const withServer = (server: Record) => ({ server }) as any + +describe('resolveDevServerMeta', () => { + it('uses the bound port and the configured host', () => { + const meta = resolveDevServerMeta({ address: '127.0.0.1', family: 'IPv4', port: 5273 } as any, withServer({ host: 'localhost', https: false, port: 3036 })) + + expect(meta.url).toBe('http://localhost:5273') + expect(meta).toMatchObject({ host: 'localhost', port: 5273, https: false }) + }) + + it('replaces wildcard hosts with localhost and honors https', () => { + const meta = resolveDevServerMeta({ port: 3036 } as any, withServer({ host: '0.0.0.0', https: {}, port: 3036 })) + + expect(meta.url).toBe('https://localhost:3036') + expect(meta.https).toBe(true) + }) +}) + +describe('removeOwnedMeta', () => { + it('only removes the file when the pid matches', () => { + const path = join(mkdtempSync(join(tmpdir(), 'vpr-')), 'vite-ruby.json') + writeDevServerMeta(path, { url: 'x', host: 'h', port: 1, https: false, pid: 4242 }) + + removeOwnedMeta(path, 9999) + expect(existsSync(path)).toBe(true) + + removeOwnedMeta(path, 4242) + expect(existsSync(path)).toBe(false) + }) +}) diff --git a/vite_ruby/default.vite.json b/vite_ruby/default.vite.json index 947206f2..52aea8b3 100644 --- a/vite_ruby/default.vite.json +++ b/vite_ruby/default.vite.json @@ -6,6 +6,7 @@ "buildCacheDir": "tmp/cache/vite", "publicOutputDir": "vite", "configPath": "config/vite.json", + "devServerConnectionCheck": false, "devServerConnectTimeout": 0.01, "packageManager": null, "publicDir": "public", diff --git a/vite_ruby/lib/vite_ruby.rb b/vite_ruby/lib/vite_ruby.rb index 9c16e02a..71a59ae8 100644 --- a/vite_ruby/lib/vite_ruby.rb +++ b/vite_ruby/lib/vite_ruby.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "json" require "logger" require "forwardable" require "pathname" @@ -82,19 +83,11 @@ def digest end # Public: Returns true if the Vite development server is currently running. - # NOTE: Checks only once every second since every lookup calls this method. def dev_server_running? - return false unless run_proxy? - return @running if defined?(@running) && Time.now - @running_checked_at < 1 + return false unless dev_mode? + return dev_server_connected? if config.dev_server_connection_check - begin - Socket.tcp(config.host, config.port, connect_timeout: config.dev_server_connect_timeout).close - @running = true - rescue - @running = false - ensure - @running_checked_at = Time.now - end + !dev_server_meta.nil? end # Public: Additional environment variables to pass to Vite. @@ -105,13 +98,15 @@ def env @env ||= ENV.select { |key, _| key.start_with?(ENV_PREFIX) } end - # Public: The proxy for assets should only run in development mode. - def run_proxy? + # Public: Whether we are in an environment that might run the Vite dev server. + def dev_mode? config.mode == "development" || (config.mode == "test" && !ENV["CI"]) rescue => error logger.error("Failed to check mode for Vite: #{error.message}") false end + # Public: The proxy for assets should only run in development mode. + alias_method :run_proxy?, :dev_mode? # Internal: Executes the vite binary. def run(argv, **options) @@ -147,6 +142,34 @@ def configure(**options) def manifest @manifest ||= ViteRuby::Manifest.new(self) end + +private + + # Internal: Returns true if a TCP connection to the dev server can be opened. + def dev_server_connected? + return @running if defined?(@running) && Time.now - @running_checked_at < 1 + + begin + Socket.tcp(config.host, config.port, connect_timeout: config.dev_server_connect_timeout).close + @running = true + rescue + @running = false + ensure + @running_checked_at = Time.now + end + end + + # Internal: Metadata written by the running Vite dev server, or nil when stopped. + def dev_server_meta + return @dev_server_meta if defined?(@dev_server_meta) && Time.now - @dev_server_meta_checked_at < 1 + + path = config.dev_server_meta_path + @dev_server_meta = (JSON.parse(path.read) if path.exist?) + rescue JSON::ParserError, SystemCallError + @dev_server_meta = nil + ensure + @dev_server_meta_checked_at = Time.now + end end require "vite_ruby/version" diff --git a/vite_ruby/lib/vite_ruby/config.rb b/vite_ruby/lib/vite_ruby/config.rb index 54cf940a..2f12085f 100644 --- a/vite_ruby/lib/vite_ruby/config.rb +++ b/vite_ruby/lib/vite_ruby/config.rb @@ -5,6 +5,9 @@ # Public: Allows to resolve configuration sourced from `config/vite.json` and # environment variables, combining them with the default options. class ViteRuby::Config + # Internal: Name of the metadata file written by the Vite dev server. + DEV_SERVER_META_FILENAME = "vite-ruby.json" + def origin "#{protocol}://#{host_with_port}" end @@ -38,6 +41,11 @@ def build_output_dir root.join(public_dir, public_output_dir) end + # Internal: Path to the metadata file written by the Vite dev server. + def dev_server_meta_path + root.join("tmp", DEV_SERVER_META_FILENAME) + end + # Public: The directory where the entries are located. def resolved_entrypoints_dir vite_root_dir.join(entrypoints_dir) @@ -96,7 +104,7 @@ def coerce_values(config) config["build_cache_dir"] = root.join(config["build_cache_dir"]) config["ssr_output_dir"] = root.join(config["ssr_output_dir"]) config["dev_server_connect_timeout"] = config["dev_server_connect_timeout"].to_f - coerce_booleans(config, "auto_build", "hide_build_console_output", "https", "skip_compatibility_check", "skip_proxy") + coerce_booleans(config, "auto_build", "dev_server_connection_check", "hide_build_console_output", "https", "skip_compatibility_check", "skip_proxy") config["package_manager"] ||= detect_package_manager(root) end