diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..68d9435 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +VITE_ENGINE_ADDRESS=http://localhost:8080 +SERVER_PORT=8080 # passed to benchttp-server, must match VITE_ENGINE_ADDRESS diff --git a/scripts/sidecar-exec.sh b/scripts/sidecar-exec.sh index 41fdbe2..7ed98fe 100755 --- a/scripts/sidecar-exec.sh +++ b/scripts/sidecar-exec.sh @@ -6,4 +6,7 @@ target_triple=$(rustc -Vv | grep host | cut -f2 -d' ') # The binary is suffixed with the triple target as required by tauri sidecar api. program="benchttp-server-$target_triple" -./src-tauri/bin/$program +# Run with .env variables loaded. +flag="--auto-port=false" + +./src-tauri/bin/$program $flag diff --git a/src/App.tsx b/src/App.tsx index c1e549a..6052135 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,11 @@ -import { useSpawnEngine } from '@/hooks' - import './App.css' import { SimpleStream } from './examples' function App() { - const { isLoading } = useSpawnEngine() - return (

Benchttp

- {isLoading ?
Loading
: } +
) } diff --git a/src/engine/spawn.ts b/src/engine/spawn.ts index 0128e56..e046542 100644 --- a/src/engine/spawn.ts +++ b/src/engine/spawn.ts @@ -4,24 +4,92 @@ const program = 'benchttp-server' const command = Command.sidecar(`bin/${program}`) -export const spawnEngine = async () => { - command.on('close', (data) => - console.log( - `${program} finished with code ${data.code} and signal ${data.signal}` +/** + * Spawns benchttp-server as a child process and returns a Promise + * that will resolve with the address the server is listening on, + * or reject if the process closes or produces an unexpected error. + */ +const spawnEngine = async (): Promise => { + const child = await command.spawn() + + return new Promise((resolve, reject) => { + command.on('close', (data) => { + const { code, signal } = data + console.log(`${program} finished with code ${code} and signal ${signal}`) + reject({ message: `${program} closed unexpectedly`, code, signal }) + }) + + command.on('error', (error) => { + console.error(`${program} error: "${error}"`) + reject({ message: `${program} error`, error }) + }) + + command.stdout.on('data', (line) => { + // Wait for the ready signal line before resolving the enclosing Promise. + resolveAddr(resolve, line) + + console.log(`${program} stdout: "${line}"`) + }) + + command.stderr.on('data', (line) => + console.error(`${program} stderr: "${line}"`) ) - ) - command.on('error', (error) => console.error(`${program} error: "${error}"`)) + console.log(`${program} pid: ${child.pid}`) + }) +} - command.stdout.on('data', (line) => - console.log(`${program} stdout: "${line}"`) - ) +const readySignal = 'READY' - command.stderr.on('data', (line) => - console.error(`${program} stderr: "${line}"`) - ) +const host = 'localhost' - const child = await command.spawn() +type ReadySignalLine = `${typeof readySignal} http://${typeof host}:${number}` + +const isReadySignal = (line: unknown): line is ReadySignalLine => + typeof line === 'string' && line.startsWith(readySignal) + +// Expect the engine to always respect the ready signal contract. +// Then we know the address is index 1. +const getAddr = (line: ReadySignalLine): string => line.split(' ')[1] + +type PromiseResolve = (value: T | PromiseLike) => void + +const resolveAddr = (resolve: PromiseResolve, line: unknown): void => { + isReadySignal(line) && resolve(getAddr(line)) +} + +/** + * Spawns benchttp-server as a child process. + * + * If called in the browser, `mustSpawnEngine` will read + * the address to use from the current environment. + * + * If the address cannot be resolved, throws an exception. + */ +export async function mustSpawnEngine(): Promise { + let addr = '' + if (isWeb()) { + console.warn( + `Running in the browser: cannot spawn sidecar with @tauri-apps/api/shell. Make sure engine is running: + npm run sidecar:exec` + ) + addr = import.meta.env.VITE_ENGINE_ADDRESS + } + addr = await spawnEngine() + + setAddress(addr) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type WindowTauri = typeof window & { __TAURI__: any } + +const isWeb = () => (window as WindowTauri).__TAURI__ === undefined + +/** + * The address at which benchttp-server is listening. + */ +export let address: string - console.log(`${program} pid: ${child.pid}`) +function setAddress(value: string): void { + address = value } diff --git a/src/engine/stream.ts b/src/engine/stream.ts index 774d1ac..0586931 100644 --- a/src/engine/stream.ts +++ b/src/engine/stream.ts @@ -1,7 +1,5 @@ import { RunProgress, RunReport, RunError, RunConfiguration } from '@/benchttp' -const streamUrl = 'http://localhost:8080/stream' - export type RunStream = ProgressStream | ReportStream | ErrorStream interface ProgressStream { @@ -28,7 +26,7 @@ export class RunStreamer { #canceled = false #aborter = new AbortController() - constructor(private emit: RunStreamerOptions) {} + constructor(private address: string, private emit: RunStreamerOptions) {} /** * Sends a run request to the local benchttp server with the input config, @@ -42,7 +40,11 @@ export class RunStreamer { this.#aborter = new AbortController() try { - const reader = await startRunStream(config, this.#aborter.signal) + const reader = await startRunStream( + config, + this.address, + this.#aborter.signal + ) while (!this.#canceled) { const { done, value } = await reader.read() @@ -76,22 +78,26 @@ const runStreamDecoder = () => const runStreamWriter = (write: (s: RunStream) => void) => new WritableStream({ write }) +const streamUrl = (address: string) => `${address}/stream` + /** * Makes the HTTP request to start a run and returns a ReadableStream * to stream the emitted progress and report data. */ const startRunStream = async ( config: RunConfiguration, + address: string, signal: AbortSignal ) => { - const response = await fetch(streamUrl, { + const url = streamUrl(address) + const response = await fetch(url, { method: 'POST', body: JSON.stringify(config), signal, }) const reader = response?.body?.getReader() - if (!reader) throw new Error(`Cannot fetch ${streamUrl}`) + if (!reader) throw new Error(`Cannot fetch ${url}`) return reader } diff --git a/src/examples/SimpleStream/SimpleStream.tsx b/src/examples/SimpleStream/SimpleStream.tsx index 7546410..02d22d2 100644 --- a/src/examples/SimpleStream/SimpleStream.tsx +++ b/src/examples/SimpleStream/SimpleStream.tsx @@ -3,7 +3,7 @@ import { useRunStream } from '@/hooks' import { inputConfig } from '../inputConfig' import { RunControlPanel, RunDisplay } from './components' -export const SimpleStream = () => { +export const SimpleStream: React.FC = () => { const { start, stop, reset, progress, report, error } = useRunStream() return ( diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 236388a..168ec88 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1 @@ export { useRunStream } from './useRunStream' -export { useSpawnEngine } from './useSpawnEngine' diff --git a/src/hooks/useOnce.ts b/src/hooks/useOnce.ts deleted file mode 100644 index 6ccc623..0000000 --- a/src/hooks/useOnce.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useRef } from 'react' - -export function useOnce() { - const done = useRef(false) - - const once = { - do: (effect: React.EffectCallback) => { - if (done.current) return - done.current = true - return effect() - }, - } - - return once -} diff --git a/src/hooks/useRunStream.ts b/src/hooks/useRunStream.ts index cb3dd26..ce1b5ba 100644 --- a/src/hooks/useRunStream.ts +++ b/src/hooks/useRunStream.ts @@ -1,6 +1,7 @@ import { useRef, useReducer } from 'react' import { RunProgress, RunReport } from '@/benchttp' +import { address } from '@/engine/spawn' import { RunStreamer, RunStream } from '@/engine/stream' interface State { @@ -43,7 +44,7 @@ export function useRunStream() { const [state, dispatch] = useReducer(reducer, initState()) const stream = useRef( - new RunStreamer({ + new RunStreamer(address, { onError: (err) => dispatch(['ERROR', err.message]), onStream: (stream) => dispatch(['STREAM', stream]), }) diff --git a/src/hooks/useSpawnEngine.ts b/src/hooks/useSpawnEngine.ts deleted file mode 100644 index 833bcf5..0000000 --- a/src/hooks/useSpawnEngine.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useEffect, useState } from 'react' - -import { spawnEngine } from '@/engine/spawn' - -import { useOnce } from './useOnce' - -export function useSpawnEngine() { - const [isLoading, setIsLoading] = useState(false) - - const spawnSync = () => { - const spawn = async () => { - setIsLoading(true) - - try { - await spawnEngine() - } catch (error) { - if (!isWeb()) throw error - console.warn( - `Running in the browser: cannot spawn sidecar with @tauri-apps/api/shell. Make sure engine is running: - npm run sidecar:exec` - ) - } - - setIsLoading(false) - } - spawn() - } - - const once = useOnce() - - useEffect(() => { - once.do(spawnSync) - }, [once]) - - return { isLoading } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type WindowTauri = typeof window & { __TAURI__: any } - -const isWeb = () => (window as WindowTauri).__TAURI__ === undefined diff --git a/src/main.tsx b/src/main.tsx index cf277a6..cda6b11 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,8 +4,11 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' +import { mustSpawnEngine } from './engine/spawn' import './index.css' +await mustSpawnEngine() + // eslint-disable-next-line import/no-named-as-default-member ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..8432dc3 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,9 @@ /// + +interface ImportMetaEnv { + readonly VITE_ENGINE_ADDRESS: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/vite.config.ts b/vite.config.ts index 131ef85..1755b19 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ build: { target: ['esnext'], }, + envPrefix: ['VITE_', 'TAURI_'], resolve: { alias: { '@': path.resolve('./src'),