forked from imbytecat/fullstack-starter
refactor(desktop): 使用预分配端口替代 stdout 解析获取服务器端口
This commit is contained in:
@@ -62,8 +62,9 @@ The desktop build is orchestrated by Turbo. It depends on the server's productio
|
|||||||
### Server Lifecycle
|
### Server Lifecycle
|
||||||
In production, the main process manages the embedded server:
|
In production, the main process manages the embedded server:
|
||||||
- **Spawn**: Spawns server from `server-output/server/index.mjs` using `Bun.spawn`.
|
- **Spawn**: Spawns server from `server-output/server/index.mjs` using `Bun.spawn`.
|
||||||
- **Port Allocation**: Server is started with `PORT=0` and `HOST=127.0.0.1`.
|
- **Port Allocation**: A free port is pre-allocated via `node:net` (`createServer` on `127.0.0.1:0`), then passed to the server as the `PORT` environment variable.
|
||||||
- **Port Detection**: The main process parses the server's `stdout` using a regex to find the dynamically assigned port.
|
- **Readiness Check**: The main process polls the server URL with `fetch` until it responds, rather than parsing stdout.
|
||||||
|
- **Retry**: If the server fails to become ready (timeout or early exit), the process is killed and a new attempt is made with a fresh port (up to 3 retries).
|
||||||
- **Lifecycle**: The server process is tied to the app; it is killed on `SIGTERM`, `SIGINT`, or app exit. If the server process crashes, the app exits with an error.
|
- **Lifecycle**: The server process is tied to the app; it is killed on `SIGTERM`, `SIGINT`, or app exit. If the server process crashes, the app exits with an error.
|
||||||
|
|
||||||
## Environment Detection
|
## Environment Detection
|
||||||
@@ -119,7 +120,7 @@ const serverEntryPath = join(PATHS.VIEWS_FOLDER, '..', 'server-output', 'server'
|
|||||||
**DO:**
|
**DO:**
|
||||||
- Use arrow functions for all components and utility functions.
|
- Use arrow functions for all components and utility functions.
|
||||||
- Ensure `apps/server` is built before building `apps/desktop` (handled by Turbo).
|
- Ensure `apps/server` is built before building `apps/desktop` (handled by Turbo).
|
||||||
- Parse the server's stdout for the port instead of hardcoding ports in production.
|
- Pre-allocate a free port and pass it via `PORT` env var instead of parsing stdout.
|
||||||
- Handle server process termination to avoid orphan processes.
|
- Handle server process termination to avoid orphan processes.
|
||||||
- Use `catalog:` for all dependency versions in `package.json`.
|
- Use `catalog:` for all dependency versions in `package.json`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { createServer } from 'node:net'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { BrowserWindow, PATHS } from 'electrobun/bun'
|
import { BrowserWindow, PATHS } from 'electrobun/bun'
|
||||||
|
|
||||||
const DEV_SERVER_URL = 'http://localhost:3000'
|
const DEV_SERVER_URL = 'http://localhost:3000'
|
||||||
const SERVER_READY_TIMEOUT_MS = 30_000
|
const SERVER_READY_TIMEOUT_MS = 5_000
|
||||||
const PORT_PATTERN = /Listening on:?\s*https?:\/\/[^\s:]+:(\d+)/
|
const MAX_SPAWN_RETRIES = 3
|
||||||
|
|
||||||
const isDev = (): boolean => {
|
const isDev = (): boolean => {
|
||||||
const env = process.env.ELECTROBUN_BUILD_ENV
|
const env = process.env.ELECTROBUN_BUILD_ENV
|
||||||
@@ -14,6 +15,23 @@ const getServerEntryPath = (): string => {
|
|||||||
return join(PATHS.VIEWS_FOLDER, '..', 'server-output', 'server', 'index.mjs')
|
return join(PATHS.VIEWS_FOLDER, '..', 'server-output', 'server', 'index.mjs')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFreePort = (): Promise<number> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const srv = createServer()
|
||||||
|
srv.unref()
|
||||||
|
srv.once('error', reject)
|
||||||
|
srv.listen({ port: 0, host: '127.0.0.1', exclusive: true }, () => {
|
||||||
|
const addr = srv.address()
|
||||||
|
if (addr && typeof addr === 'object') {
|
||||||
|
const port = addr.port
|
||||||
|
srv.close((err) => (err ? reject(err) : resolve(port)))
|
||||||
|
} else {
|
||||||
|
srv.close(() => reject(new Error('Unexpected address() result')))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const waitForServer = async (
|
const waitForServer = async (
|
||||||
url: string,
|
url: string,
|
||||||
timeoutMs = SERVER_READY_TIMEOUT_MS,
|
timeoutMs = SERVER_READY_TIMEOUT_MS,
|
||||||
@@ -38,64 +56,39 @@ const spawnServer = async (): Promise<{
|
|||||||
const serverEntryPath = getServerEntryPath()
|
const serverEntryPath = getServerEntryPath()
|
||||||
const serverDir = dirname(serverEntryPath)
|
const serverDir = dirname(serverEntryPath)
|
||||||
|
|
||||||
const serverProc = Bun.spawn([process.execPath, serverEntryPath], {
|
for (let attempt = 1; attempt <= MAX_SPAWN_RETRIES; attempt++) {
|
||||||
cwd: serverDir,
|
const port = await getFreePort()
|
||||||
env: {
|
const url = `http://127.0.0.1:${port}`
|
||||||
...process.env,
|
|
||||||
PORT: '0',
|
|
||||||
HOST: '127.0.0.1',
|
|
||||||
},
|
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe',
|
|
||||||
})
|
|
||||||
|
|
||||||
const port = await new Promise<string>((resolve, reject) => {
|
const serverProc = Bun.spawn([process.execPath, serverEntryPath], {
|
||||||
const timeout = setTimeout(() => {
|
cwd: serverDir,
|
||||||
serverProc.kill()
|
env: {
|
||||||
reject(
|
...process.env,
|
||||||
new Error(`Server did not start within ${SERVER_READY_TIMEOUT_MS}ms`),
|
PORT: String(port),
|
||||||
)
|
HOST: '127.0.0.1',
|
||||||
}, SERVER_READY_TIMEOUT_MS)
|
},
|
||||||
|
stdio: ['ignore', 'inherit', 'inherit'],
|
||||||
const reader = serverProc.stdout.getReader()
|
|
||||||
let buffer = ''
|
|
||||||
|
|
||||||
const read = async () => {
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
reject(new Error('Server process exited before reporting port'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = new TextDecoder().decode(value)
|
|
||||||
buffer += text
|
|
||||||
process.stdout.write(text)
|
|
||||||
|
|
||||||
const match = PORT_PATTERN.exec(buffer)
|
|
||||||
if (match?.[1]) {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
resolve(match[1])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
reject(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
serverProc.exited.then((code) => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
reject(new Error(`Server exited with code ${code} before becoming ready`))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
read()
|
const ready = await Promise.race([
|
||||||
})
|
waitForServer(url),
|
||||||
|
serverProc.exited.then((code) => {
|
||||||
|
throw new Error(`Server exited with code ${code} before becoming ready`)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
return { process: serverProc, url: `http://127.0.0.1:${port}` }
|
if (ready) {
|
||||||
|
return { process: serverProc, url }
|
||||||
|
}
|
||||||
|
|
||||||
|
serverProc.kill()
|
||||||
|
await serverProc.exited
|
||||||
|
console.warn(
|
||||||
|
`Server failed to become ready on port ${port} (attempt ${attempt}/${MAX_SPAWN_RETRIES})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Server failed to start after ${MAX_SPAWN_RETRIES} attempts`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user