Serve bundled or proxied UI from CLI
This commit is contained in:
1
packages/cli/.gitignore
vendored
Normal file
1
packages/cli/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
public/
|
||||
@@ -4,19 +4,27 @@
|
||||
"description": "CodeNomad CLI server for HTTP/SSE control plane",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"codenomad-cli": "dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.6.3"
|
||||
|
||||
21
packages/cli/scripts/copy-ui-dist.mjs
Normal file
21
packages/cli/scripts/copy-ui-dist.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
const uiDistDir = path.resolve(cliRoot, "../ui/src/renderer/dist")
|
||||
const targetDir = path.resolve(cliRoot, "public")
|
||||
|
||||
if (!existsSync(uiDistDir)) {
|
||||
console.error(`[copy-ui-dist] Expected UI build artifacts at ${uiDistDir}. Run the UI build before bundling the CLI.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
cpSync(uiDistDir, targetDir, { recursive: true })
|
||||
|
||||
console.log(`[copy-ui-dist] Copied UI bundle from ${uiDistDir} -> ${targetDir}`)
|
||||
29
packages/cli/src/bin.ts
Normal file
29
packages/cli/src/bin.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliEntry = path.join(__dirname, "index.js")
|
||||
const loaderFileUrl = pathToFileURL(path.join(__dirname, "loader.js")).href
|
||||
const registerScript = `import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("${encodeURI(loaderFileUrl)}", pathToFileURL("./"));`
|
||||
const loaderArg = `data:text/javascript,${registerScript}`
|
||||
|
||||
const child = spawn(process.execPath, ["--import", loaderArg, cliEntry, ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 0)
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("Failed to launch CLI runtime", error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -3,7 +3,9 @@
|
||||
* For now this only wires the typed modules together; actual command handling comes later.
|
||||
*/
|
||||
import { Command, InvalidArgumentError, Option } from "commander"
|
||||
import packageJson from "../package.json"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
import { createHttpServer } from "./server/http-server"
|
||||
import { WorkspaceManager } from "./workspaces/manager"
|
||||
import { ConfigStore } from "./config/store"
|
||||
@@ -14,6 +16,12 @@ import { ServerMeta } from "./api-types"
|
||||
import { InstanceStore } from "./storage/instance-store"
|
||||
import { createLogger } from "./logger"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = require("../package.json") as { version: string }
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||
|
||||
interface CliOptions {
|
||||
port: number
|
||||
host: string
|
||||
@@ -22,6 +30,8 @@ interface CliOptions {
|
||||
unrestrictedRoot: boolean
|
||||
logLevel?: string
|
||||
logDestination?: string
|
||||
uiStaticDir: string
|
||||
uiDevServer?: string
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 9898
|
||||
@@ -43,6 +53,10 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.addOption(new Option("--config <path>", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH))
|
||||
.addOption(new Option("--log-level <level>", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL"))
|
||||
.addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION"))
|
||||
.addOption(
|
||||
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
||||
)
|
||||
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
||||
|
||||
program.parse(argv, { from: "user" })
|
||||
const parsed = program.opts<{
|
||||
@@ -54,6 +68,8 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
config: string
|
||||
logLevel?: string
|
||||
logDestination?: string
|
||||
uiDir: string
|
||||
uiDevServer?: string
|
||||
}>()
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
@@ -66,6 +82,8 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||
logLevel: parsed.logLevel,
|
||||
logDestination: parsed.logDestination,
|
||||
uiStaticDir: parsed.uiDir,
|
||||
uiDevServer: parsed.uiDevServer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +134,15 @@ async function main() {
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
uiStaticDir: options.uiStaticDir,
|
||||
uiDevServerUrl: options.uiDevServer,
|
||||
})
|
||||
|
||||
|
||||
await server.start()
|
||||
logger.info({ port: options.port, host: options.host }, "HTTP server listening")
|
||||
const displayHost = options.host === "127.0.0.1" || options.host === "0.0.0.0" ? "localhost" : options.host
|
||||
console.log(`CodeNomad Server is ready at http://${displayHost}:${options.port}`)
|
||||
|
||||
let shuttingDown = false
|
||||
|
||||
|
||||
21
packages/cli/src/loader.ts
Normal file
21
packages/cli/src/loader.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export async function resolve(specifier: string, context: any, defaultResolve: any) {
|
||||
try {
|
||||
return await defaultResolve(specifier, context, defaultResolve)
|
||||
} catch (error: any) {
|
||||
if (shouldRetry(specifier, error)) {
|
||||
const retried = specifier.endsWith(".js") ? specifier : `${specifier}.js`
|
||||
return defaultResolve(retried, context, defaultResolve)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetry(specifier: string, error: any) {
|
||||
if (!error || error.code !== "ERR_MODULE_NOT_FOUND") {
|
||||
return false
|
||||
}
|
||||
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import Fastify from "fastify"
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fetch } from "undici"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
@@ -24,8 +28,11 @@ interface HttpServerDeps {
|
||||
eventBus: EventBus
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
}
|
||||
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
const app = Fastify({ logger: false })
|
||||
|
||||
@@ -53,6 +60,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
|
||||
registerStorageRoutes(app, { instanceStore: deps.instanceStore })
|
||||
|
||||
if (deps.uiDevServerUrl) {
|
||||
setupDevProxy(app, deps.uiDevServerUrl)
|
||||
} else {
|
||||
setupStaticUi(app, deps.uiStaticDir)
|
||||
}
|
||||
|
||||
return {
|
||||
instance: app,
|
||||
start: () => app.listen({ port: deps.port, host: deps.host }),
|
||||
@@ -62,3 +75,93 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
||||
if (!uiDir) {
|
||||
app.log.warn("UI static directory not provided; API endpoints only")
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(uiDir)) {
|
||||
app.log.warn({ uiDir }, "UI static directory missing; API endpoints only")
|
||||
return
|
||||
}
|
||||
|
||||
app.register(fastifyStatic, {
|
||||
root: uiDir,
|
||||
prefix: "/",
|
||||
decorateReply: false,
|
||||
})
|
||||
|
||||
const indexPath = path.join(uiDir, "index.html")
|
||||
|
||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = request.raw.url ?? ""
|
||||
if (isApiRequest(url)) {
|
||||
reply.code(404).send({ message: "Not Found" })
|
||||
return
|
||||
}
|
||||
|
||||
if (fs.existsSync(indexPath)) {
|
||||
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||
} else {
|
||||
reply.code(404).send({ message: "UI bundle missing" })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
||||
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = request.raw.url ?? ""
|
||||
if (isApiRequest(url)) {
|
||||
reply.code(404).send({ message: "Not Found" })
|
||||
return
|
||||
}
|
||||
void proxyToDevServer(request, reply, upstreamBase)
|
||||
})
|
||||
}
|
||||
|
||||
async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) {
|
||||
try {
|
||||
const targetUrl = new URL(request.raw.url ?? "/", upstreamBase)
|
||||
const response = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers: buildProxyHeaders(request.headers),
|
||||
})
|
||||
|
||||
response.headers.forEach((value, key) => {
|
||||
reply.header(key, value)
|
||||
})
|
||||
|
||||
reply.code(response.status)
|
||||
|
||||
if (!response.body || request.method === "HEAD") {
|
||||
reply.send()
|
||||
return
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
reply.send(buffer)
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to proxy UI request to dev server")
|
||||
if (!reply.sent) {
|
||||
reply.code(502).send("UI dev server is unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isApiRequest(rawUrl: string | null | undefined) {
|
||||
if (!rawUrl) return false
|
||||
const pathname = rawUrl.split("?")[0] ?? ""
|
||||
return pathname === "/api" || pathname.startsWith("/api/")
|
||||
}
|
||||
|
||||
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(headers ?? {})) {
|
||||
if (!value || key.toLowerCase() === "host") continue
|
||||
result[key] = Array.isArray(value) ? value.join(",") : value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user