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'),