Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/src/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion docs/src/guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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 <kbd>[devServerConnectTimeout]</kbd>, 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 <kbd>[devServerConnectionCheck]</kbd>, try increasing the <kbd>[devServerConnectTimeout]</kbd>, 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.
Expand Down
63 changes: 52 additions & 11 deletions test/dev_server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions vite-plugin-ruby/default.vite.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"buildCacheDir": "tmp/cache/vite",
"publicOutputDir": "vite",
"configPath": "config/vite.json",
"devServerConnectionCheck": false,
"devServerConnectTimeout": 0.01,
"packageManager": null,
"publicDir": "public",
Expand Down
3 changes: 3 additions & 0 deletions vite-plugin-ruby/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
74 changes: 74 additions & 0 deletions vite-plugin-ruby/src/dev-server.ts
Original file line number Diff line number Diff line change
@@ -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
})
}
8 changes: 8 additions & 0 deletions vite-plugin-ruby/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions vite-plugin-ruby/tests/dev-server.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({ 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)
})
})
1 change: 1 addition & 0 deletions vite_ruby/default.vite.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"buildCacheDir": "tmp/cache/vite",
"publicOutputDir": "vite",
"configPath": "config/vite.json",
"devServerConnectionCheck": false,
"devServerConnectTimeout": 0.01,
"packageManager": null,
"publicDir": "public",
Expand Down
49 changes: 36 additions & 13 deletions vite_ruby/lib/vite_ruby.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require "json"
require "logger"
require "forwardable"
require "pathname"
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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"
Loading
Loading