From 20df2fdba6122b4239b1f55161fdfbac9a1513f3 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:43:07 +0800 Subject: [PATCH] feat(cli): embed web ui (#41525) Co-authored-by: Dax Raad --- packages/cli/script/app-assets.ts | 41 +++++++++++ packages/cli/script/build-node.ts | 11 ++- packages/cli/script/build.ts | 17 ++++- packages/cli/src/app-assets.ts | 55 +++++++++++++++ packages/cli/src/commands/commands.ts | 2 +- packages/cli/src/server-process.ts | 17 +++-- packages/cli/src/services/web-ui.ts | 66 ++++++++++++++++++ packages/cli/src/virtual.d.ts | 4 ++ packages/cli/test/web-ui.test.ts | 68 +++++++++++++++++++ packages/cli/vite.node.config.ts | 16 +++++ .../client/src/promise/generated/client.ts | 6 +- .../test/fixtures/opencode-v2-openapi.json | 4 +- packages/protocol/openapi.json | 4 +- packages/protocol/src/groups/project-copy.ts | 2 +- packages/server/src/process.ts | 14 +++- packages/server/test/process.test.ts | 34 +++++++--- packages/tui/src/app.tsx | 2 +- packages/www/openapi.json | 4 +- packages/www/public/openapi.json | 4 +- 19 files changed, 338 insertions(+), 33 deletions(-) create mode 100644 packages/cli/script/app-assets.ts create mode 100644 packages/cli/src/app-assets.ts create mode 100644 packages/cli/src/services/web-ui.ts create mode 100644 packages/cli/src/virtual.d.ts create mode 100644 packages/cli/test/web-ui.test.ts diff --git a/packages/cli/script/app-assets.ts b/packages/cli/script/app-assets.ts new file mode 100644 index 0000000000..d452932926 --- /dev/null +++ b/packages/cli/script/app-assets.ts @@ -0,0 +1,41 @@ +import { $ } from "bun" +import { readdir } from "node:fs/promises" +import path from "node:path" +import { brotliCompressSync, constants } from "node:zlib" + +export async function buildAppArchive(channel: string) { + const root = path.resolve(import.meta.dirname, "../../app") + await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel }) + const assets = Object.fromEntries( + await Promise.all( + (await files(path.join(root, "dist"))) + .filter((key) => !key.endsWith(".map")) + .map(async (key) => { + const source = path.join(root, "dist", key) + const body = Buffer.from(await Bun.file(source).arrayBuffer()) + const encoding = isText(key) ? "utf8" : "base64" + return [key, { encoding, content: body.toString(encoding) }] as const + }), + ), + ) + return brotliCompressSync(JSON.stringify(assets), { + params: { [constants.BROTLI_PARAM_QUALITY]: 11 }, + }).toString("base64") +} + +function isText(key: string) { + return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key) +} + +async function files(root: string, current = root): Promise { + return ( + await Promise.all( + (await readdir(current, { withFileTypes: true })).map((entry) => { + const target = path.join(current, entry.name) + return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")] + }), + ) + ) + .flat() + .toSorted() +} diff --git a/packages/cli/script/build-node.ts b/packages/cli/script/build-node.ts index 8bb9dda9f6..860655d114 100644 --- a/packages/cli/script/build-node.ts +++ b/packages/cli/script/build-node.ts @@ -12,6 +12,7 @@ import { modelsData } from "./generate" import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets" import { mainConfig } from "../vite.node.config" import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target" +import { buildAppArchive } from "./app-assets" const NODE_VERSION = "26.4.0" const dir = path.resolve(import.meta.dirname, "..") @@ -55,13 +56,21 @@ const builder = !bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch) ? await resolveHostNode() : undefined +const appArchive = await buildAppArchive(Script.channel) for (const target of targets) { console.log(`building cli-node-${targetName(target)}`) const assets = await collectNodeAssets(target) await rm("dist-node", { recursive: true, force: true }) const assetHash = await hashNodeAssets(assets) - const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target } + const input = { + version: Script.version, + channel: Script.channel, + models: modelsData, + assetHash, + target, + appArchive, + } await copyNodeAssets(assets) await build(mainConfig(input)) diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index 67aa029e5c..654f16a4d9 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" import type { BunPlugin } from "bun" import pkg from "../package.json" import { modelsData } from "./generate" +import { buildAppArchive } from "./app-assets" const dir = path.resolve(import.meta.dirname, "..") const binary = "opencode2" @@ -54,6 +55,20 @@ const targets = singleFlag : allTargets if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}` +const appArchive = await buildAppArchive(Script.channel) +const appAssetsPlugin: BunPlugin = { + name: "opencode-app-assets", + setup(build) { + build.onResolve({ filter: /^virtual:opencode-app-assets$/ }, () => ({ + path: "opencode-app-assets", + namespace: "opencode", + })) + build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({ + loader: "js", + contents: `export default ${JSON.stringify(appArchive)}`, + })) + }, +} for (const item of targets) { const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}` @@ -80,7 +95,7 @@ for (const item of targets) { const result = await Bun.build({ entrypoints: ["./src/index.ts"], tsconfig: "./tsconfig.json", - plugins: [solidPlugin, parcelWatcherPlugin], + plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin], external: ["node-gyp"], format: "esm", minify: true, diff --git a/packages/cli/src/app-assets.ts b/packages/cli/src/app-assets.ts new file mode 100644 index 0000000000..e80e15137d --- /dev/null +++ b/packages/cli/src/app-assets.ts @@ -0,0 +1,55 @@ +import { Effect, FileSystem, Option } from "effect" +import path from "node:path" +import { brotliDecompressSync } from "node:zlib" +import { OPENCODE_LOCAL } from "./version" + +export type AssetMap = Readonly> +type EncodedAssetMap = Readonly< + Record +> + +export const load = Effect.fn("cli.app-assets.load")(function* () { + const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option) + if (Option.isSome(embedded) && embedded.value.default.length > 0) return decodeArchive(embedded.value.default) + if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build")) + return decode(yield* sourceAssets()) +}) + +function decodeArchive(archive: string) { + const body = brotliDecompressSync(Buffer.from(archive, "base64")).toString() + return decode(JSON.parse(body) as EncodedAssetMap) +} + +const sourceAssets = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem + const root = path.resolve(import.meta.dirname, "../../app/dist") + const files = yield* fs.readDirectory(root, { recursive: true }) + return Object.fromEntries( + ( + yield* Effect.forEach( + files.filter((file) => !file.endsWith(".map")), + Effect.fnUntraced(function* (file) { + const target = path.join(root, file) + if ((yield* fs.stat(target)).type === "Directory") return + const body = Buffer.from(yield* fs.readFile(target)) + const encoding = isText(file) ? "utf8" : "base64" + return [file, { encoding, content: body.toString(encoding) }] as const + }), + { concurrency: "unbounded" }, + ) + ).filter((asset) => asset !== undefined), + ) +}) + +function decode(assets: EncodedAssetMap): AssetMap { + return Object.fromEntries( + Object.entries(assets).map(([key, asset]) => [ + key, + asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"), + ]), + ) +} + +function isText(file: string) { + return file === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(file) +} diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 19e2d8a1f6..a54896c8de 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -267,7 +267,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO }), Spec.make("pair", { description: "Show server pairing information" }), Spec.make("serve", { - description: "Start the v2 API server", + description: "Start the v2 API and web server", params: { hostname: Flag.string("hostname").pipe(Flag.optional), port: Flag.integer("port").pipe(Flag.optional), diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 46db79cffa..5f6031257d 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -13,6 +13,7 @@ import { HttpServer } from "effect/unstable/http" import { Env } from "./env" import { ServiceConfig } from "./services/service-config" import { Updater } from "./services/updater" +import { WebUi } from "./services/web-ui" export type Mode = "default" | "service" | "stdio" @@ -43,16 +44,16 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home)) return yield* Effect.scoped( Effect.gen(function* () { + const foreground = options.mode === "default" const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined const config = options.mode === "service" ? yield* ServiceConfig.read() : {} const hostname = options.hostname ?? config.hostname ?? "127.0.0.1" const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined) - if ( - serviceOptions !== undefined && - port !== undefined && - (yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined - ) - return + const incumbent = + serviceOptions !== undefined && port !== undefined + ? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) }) + : undefined + if (incumbent !== undefined) return const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process")) const environmentPassword = yield* Env.password // Keep the lease credential out of the environment inherited by tools. @@ -68,6 +69,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { : randomBytes(32).toString("base64url") if (!password) return yield* Effect.fail(new Error("Missing server password")) const instanceID = randomUUID() + const transform = yield* WebUi.handler() const server = yield* start( { app: { @@ -122,6 +124,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { return yield* register(address, password, instanceID, serviceOptions.file, shutdown) }), }, + transform, ).pipe( Effect.catch((error) => { if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error) @@ -143,7 +146,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { if (server === undefined) return const url = HttpServer.formatAddress(server.address) console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`) - if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`) + if (foreground && !environmentPassword) console.log(`server password ${password}`) const updater = yield* Updater.Service yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped) return yield* options.mode === "service" diff --git a/packages/cli/src/services/web-ui.ts b/packages/cli/src/services/web-ui.ts new file mode 100644 index 0000000000..57d3706c6c --- /dev/null +++ b/packages/cli/src/services/web-ui.ts @@ -0,0 +1,66 @@ +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Effect, FileSystem } from "effect" +import { HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { createHash } from "node:crypto" +import { load, type AssetMap } from "../app-assets" + +export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { + readonly assets?: AssetMap +}) { + const fileSystem = yield* FileSystem.FileSystem + const assets = options?.assets + ? Effect.succeed(options.assets) + : yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem))) + return (api: Effect.Effect) => + api.pipe( + Effect.catchIf( + isRouteNotFound, + () => + HttpServerRequest.HttpServerRequest.pipe( + Effect.flatMap((request) => { + const url = new URL(request.url, "http://localhost") + if (url.pathname === "/api" || url.pathname.startsWith("/api/")) + return Effect.succeed(HttpServerResponse.empty({ status: 404 })) + return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files))) + }), + ), + ), + ) +}) + +function serveUI( + request: HttpServerRequest.HttpServerRequest, + url: URL, + assets: AssetMap, +) { + const key = url.pathname.replace(/^\//, "") + const name = assets[key] !== undefined ? key : "index.html" + const file = assets[name] + if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 })) + if (request.method !== "GET" && request.method !== "HEAD") return Effect.succeed(HttpServerResponse.empty({ status: 405 })) + const html = name === "index.html" + const headers = { + "content-type": FSUtil.mimeType(name), + "cache-control": html ? "no-cache" : "public, max-age=31536000, immutable", + "content-security-policy": html ? cspForHtml(typeof file === "string" ? file : Buffer.from(file).toString()) : csp(), + "x-content-type-options": "nosniff", + } + return Effect.succeed( + request.method === "HEAD" ? HttpServerResponse.empty({ headers }) : HttpServerResponse.raw(file, { headers }), + ) +} + +function isRouteNotFound(error: unknown) { + return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound" +} + +function csp(hash = "") { + return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:` +} + +function cspForHtml(body: string) { + const match = body.match(/]*\bsrc\s*=)[^>]*\bid=(["'])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) + return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "") +} + +export * as WebUi from "./web-ui" diff --git a/packages/cli/src/virtual.d.ts b/packages/cli/src/virtual.d.ts new file mode 100644 index 0000000000..bb55d97cc9 --- /dev/null +++ b/packages/cli/src/virtual.d.ts @@ -0,0 +1,4 @@ +declare module "virtual:opencode-app-assets" { + const archive: string + export default archive +} diff --git a/packages/cli/test/web-ui.test.ts b/packages/cli/test/web-ui.test.ts new file mode 100644 index 0000000000..5dce8c5b7d --- /dev/null +++ b/packages/cli/test/web-ui.test.ts @@ -0,0 +1,68 @@ +import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node" +import { afterAll, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { createServer } from "node:http" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { WebUi } from "../src/services/web-ui" + +const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-")) +afterAll(() => rm(root, { recursive: true, force: true })) + +describe("web UI", () => { + test("falls back from API routes to assets and the SPA index", async () => { + const index = path.join(root, "index.html") + const asset = path.join(root, "app.js") + await writeFile(index, "embedded") + await writeFile(asset, "console.log('embedded')") + const assets = { + "index.html": await Bun.file(index).text(), + "app.js": await Bun.file(asset).text(), + "font.woff2": new Uint8Array([0, 1, 2, 255]), + } + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const transform = yield* WebUi.handler({ assets }) + const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 }) + yield* http.serve( + transform( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const pathname = new URL(request.url, "http://localhost").pathname + if (pathname === "/api/health") return HttpServerResponse.jsonUnsafe({ healthy: true }) + return yield* Effect.fail( + new HttpServerError.HttpServerError({ + reason: new HttpServerError.RouteNotFound({ request }), + }), + ) + }), + ), + ) + const origin = HttpServer.formatAddress(http.address) + + const health = yield* Effect.promise(() => fetch(`${origin}/api/health`)) + expect(yield* Effect.promise(() => health.json())).toEqual({ healthy: true }) + + const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`)) + expect(missing.status).toBe(404) + expect(yield* Effect.promise(() => missing.text())).toBe("") + + const script = yield* Effect.promise(() => fetch(`${origin}/app.js`)) + expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')") + + const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`)) + expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual(new Uint8Array([0, 1, 2, 255])) + + const fallback = yield* Effect.promise(() => fetch(`${origin}/workspace/example`)) + expect(yield* Effect.promise(() => fallback.text())).toContain("embedded") + expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'") + expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:") + }), + ).pipe(Effect.provide(NodeFileSystem.layer)), + ) + }) +}) diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts index c7449e378b..782af56b4c 100644 --- a/packages/cli/vite.node.config.ts +++ b/packages/cli/vite.node.config.ts @@ -17,6 +17,19 @@ function rawTextPlugin(): Plugin { } } +function appAssetsPlugin(archive: string): Plugin { + return { + name: "opencode:app-assets", + resolveId(id) { + if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets" + }, + load(id) { + if (id !== "\0virtual:opencode-app-assets") return + return `export default ${JSON.stringify(archive)}` + }, + } +} + function runtimeRequirePlugin(): Plugin { return { name: "opencode:runtime-require", @@ -212,12 +225,14 @@ export type NodeBuildInput = { readonly models: string readonly assetHash: string readonly target: NodeTarget + readonly appArchive: string } export function mainConfig(input: NodeBuildInput): UserConfig { return defineConfig({ root: dir, plugins: [ + appAssetsPlugin(input.appArchive), rawTextPlugin(), runtimeRequirePlugin(), fffNodePlugin(), @@ -259,4 +274,5 @@ export default mainConfig({ models: "undefined", assetHash: "local", target: nodeTarget(process.platform, process.arch), + appArchive: "", }) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 8dbcd8c966..fbba031882 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1740,7 +1740,7 @@ export function make(options: ClientOptions) { request( { method: "POST", - path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`, + path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`, query: { location: input["location"] }, body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, successStatus: 200, @@ -1753,7 +1753,7 @@ export function make(options: ClientOptions) { request( { method: "DELETE", - path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`, + path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`, query: { location: input["location"] }, body: { directory: input["directory"], force: input["force"] }, successStatus: 204, @@ -1766,7 +1766,7 @@ export function make(options: ClientOptions) { request( { method: "POST", - path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`, + path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`, query: { location: input["location"] }, successStatus: 204, declaredStatuses: [400, 401], diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json index 5f87b17660..ea10c57cf1 100644 --- a/packages/codemode/test/fixtures/opencode-v2-openapi.json +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -9310,7 +9310,7 @@ "summary": "List references" } }, - "/experimental/project/{projectID}/copy": { + "/api/experimental/project/{projectID}/copy": { "post": { "tags": ["projectCopy"], "operationId": "v2.projectCopy.create", @@ -9536,7 +9536,7 @@ } } }, - "/experimental/project/{projectID}/copy/refresh": { + "/api/experimental/project/{projectID}/copy/refresh": { "post": { "tags": ["projectCopy"], "operationId": "v2.projectCopy.refresh", diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 3116a38987..6f5a6ceeb0 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -10930,7 +10930,7 @@ "summary": "List references" } }, - "/experimental/project/{projectID}/copy": { + "/api/experimental/project/{projectID}/copy": { "post": { "tags": ["projectCopy"], "operationId": "v2.projectCopy.create", @@ -11156,7 +11156,7 @@ } } }, - "/experimental/project/{projectID}/copy/refresh": { + "/api/experimental/project/{projectID}/copy/refresh": { "post": { "tags": ["projectCopy"], "operationId": "v2.projectCopy.refresh", diff --git a/packages/protocol/src/groups/project-copy.ts b/packages/protocol/src/groups/project-copy.ts index 995b8a47d3..3ac63a1969 100644 --- a/packages/protocol/src/groups/project-copy.ts +++ b/packages/protocol/src/groups/project-copy.ts @@ -4,7 +4,7 @@ import { Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { LocationQuery, locationQueryOpenApi } from "./location.js" -const root = "/experimental/project/:projectID/copy" +const root = "/api/experimental/project/:projectID/copy" export class ProjectCopyError extends Schema.ErrorClass("ProjectCopyError")( { diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index ece570ce66..2b5e8783db 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -5,7 +5,13 @@ import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect" -import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { + HttpMiddleware, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" import { randomUUID } from "node:crypto" import { createServer } from "node:http" import { ServerAuth } from "./auth" @@ -31,6 +37,8 @@ type App = Effect.Effect< HttpServerRequest.HttpServerRequest | Scope.Scope > +export type Transform = (app: App) => App + const errorResponseLogger = HttpMiddleware.make((app) => HttpMiddleware.logger( Effect.tap(app, (response) => @@ -42,6 +50,7 @@ const errorResponseLogger = HttpMiddleware.make((app) => export const start = Effect.fn("ServerProcess.start")(function* ( options: ServerOptions, lifecycle?: Lifecycle, + transform?: Transform, ) { const password = options.password if (!password) return yield* Effect.fail(new Error("Missing server password")) @@ -101,7 +110,8 @@ export const start = Effect.fn("ServerProcess.start")(function* ( Effect.provideService(Scope.Scope, applicationScope), ) } - yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect())) + const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect() + yield* Ref.set(application, Option.some(transform ? transform(app) : app)) yield* status.ready return { address: bound.http.address, shutdown: Deferred.await(shutdown) } }).pipe( diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index b462720bd3..71125a1d99 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -1,18 +1,28 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { HttpServer } from "effect/unstable/http" +import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http" import { it } from "../../core/test/lib/effect" import { ServerProcess } from "../src/process" it.live("allows browser preflight requests without credentials", () => Effect.gen(function* () { - const server = yield* ServerProcess.start({ - hostname: "127.0.0.1", - port: 0, - password: "secret", - app: { version: "test-version" }, - database: { path: ":memory:" }, - }) + const server = yield* ServerProcess.start( + { + hostname: "127.0.0.1", + port: 0, + password: "secret", + app: { version: "test-version" }, + database: { path: ":memory:" }, + }, + undefined, + (api) => + api.pipe( + Effect.catchIf( + (error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound", + () => Effect.succeed(HttpServerResponse.text("fallback")), + ), + ), + ) const response = yield* Effect.promise(() => fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), { method: "OPTIONS", @@ -40,5 +50,13 @@ it.live("allows browser preflight requests without credentials", () => expect(health.status).toBe(200) expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" }) + + const missing = yield* Effect.promise(() => + fetch(new URL("/missing", HttpServer.formatAddress(server.address)), { + headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + }), + ) + expect(missing.status).toBe(200) + expect(yield* Effect.promise(() => missing.text())).toBe("fallback") }), ) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 13528e3211..377ebb598b 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -882,7 +882,7 @@ function App(props: { pair?: DialogPairCredentials }) { { name: "server.pair", title: "Pair device", - slash: { name: "pair" }, + slash: { name: "pair", aliases: ["web"] }, run: () => { dialog.replace(() => ) }, diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 3116a38987..6f5a6ceeb0 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -10930,7 +10930,7 @@ "summary": "List references" } }, - "/experimental/project/{projectID}/copy": { + "/api/experimental/project/{projectID}/copy": { "post": { "tags": ["projectCopy"], "operationId": "v2.projectCopy.create", @@ -11156,7 +11156,7 @@ } } }, - "/experimental/project/{projectID}/copy/refresh": { + "/api/experimental/project/{projectID}/copy/refresh": { "post": { "tags": ["projectCopy"], "operationId": "v2.projectCopy.refresh", diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 3116a38987..6f5a6ceeb0 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -10930,7 +10930,7 @@ "summary": "List references" } }, - "/experimental/project/{projectID}/copy": { + "/api/experimental/project/{projectID}/copy": { "post": { "tags": ["projectCopy"], "operationId": "v2.projectCopy.create", @@ -11156,7 +11156,7 @@ } } }, - "/experimental/project/{projectID}/copy/refresh": { + "/api/experimental/project/{projectID}/copy/refresh": { "post": { "tags": ["projectCopy"], "operationId": "v2.projectCopy.refresh",