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