Skip to content

Commit a988b75

Browse files
proAlexandrtardyp
authored andcommitted
feat: serve web locally
1 parent 3e67104 commit a988b75

File tree

5 files changed

+90
-5
lines changed

5 files changed

+90
-5
lines changed

bun.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"description": "AI-powered development tool",
55
"private": true,
66
"type": "module",
7-
"packageManager": "bun@1.3.5",
7+
"packageManager": "bun@1.3.6",
88
"scripts": {
99
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
1010
"typecheck": "bun turbo typecheck",
@@ -21,7 +21,7 @@
2121
"packages/slack"
2222
],
2323
"catalog": {
24-
"@types/bun": "1.3.5",
24+
"@types/bun": "1.3.6",
2525
"@octokit/rest": "22.0.0",
2626
"@hono/zod-validator": "0.4.2",
2727
"ulid": "3.0.1",

packages/opencode/script/build.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from "path"
55
import fs from "fs"
66
import { $ } from "bun"
77
import { fileURLToPath } from "url"
8+
import { readdir } from "node:fs/promises"
89

910
const __filename = fileURLToPath(import.meta.url)
1011
const __dirname = path.dirname(__filename)
@@ -137,6 +138,52 @@ for (const item of targets) {
137138
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
138139
const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/")
139140

141+
await Bun.spawn(["bun", "run", "build"], {
142+
cwd: "../app",
143+
stdout: "inherit",
144+
stderr: "inherit",
145+
}).exited
146+
147+
const files = await readdir("../app/dist", { recursive: true, withFileTypes: true })
148+
let importFilesScriptPart = ""
149+
let routesScriptPart = ""
150+
for (let i = 0; i < files.length; ++i) {
151+
const file = files[i]
152+
if (file.isFile()) {
153+
const serveBlob = file.name.endsWith(".woff") || file.name.endsWith(".woff2")
154+
const path = file.parentPath + "/" + file.name
155+
const route = path.substring(11) // cut ../app/dist
156+
let header = ""
157+
if (file.name.endsWith(".js")) {
158+
header = ", jsHeader"
159+
}
160+
if (file.name.endsWith(".css")) {
161+
header = ", cssHeader"
162+
}
163+
if (serveBlob) {
164+
importFilesScriptPart += `import file${i} from '${path}'\n`
165+
routesScriptPart += `'${route}': new Response(Bun.file(file${i})),\n`
166+
} else {
167+
importFilesScriptPart += `import file${i} from '${path}' with { type: 'text' }\n`
168+
routesScriptPart += `'${route}': new Response(file${i}${header}),\n`
169+
}
170+
}
171+
}
172+
173+
const serveWebScript = `
174+
import indexFile from '../app/dist/index.html' with { type: 'text' }
175+
${importFilesScriptPart}
176+
177+
const httpHeader = { headers: { 'Content-Type': 'text/html;charset=utf-8'}}
178+
const jsHeader = { headers: { 'Content-Type': 'text/javascript;charset=utf-8'}}
179+
const cssHeader = { headers: { 'Content-Type': 'text/css;charset=utf-8'}}
180+
181+
export const embeddedFiles = {
182+
'/': new Response(indexFile, httpHeader),
183+
${routesScriptPart}
184+
} as Record<string, Response>
185+
`
186+
140187
await Bun.build({
141188
conditions: ["browser"],
142189
tsconfig: "./tsconfig.json",
@@ -153,6 +200,9 @@ for (const item of targets) {
153200
execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"],
154201
windows: {},
155202
},
203+
files: {
204+
"./src/serveWeb.ts": serveWebScript,
205+
},
156206
entrypoints: ["./src/index.ts", parserWorker, workerPath],
157207
define: {
158208
OPENCODE_VERSION: `'${Script.version}'`,

packages/opencode/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import { WebCommand } from "./cli/cmd/web"
2727
import { PrCommand } from "./cli/cmd/pr"
2828
import { SessionCommand } from "./cli/cmd/session"
2929

30+
// Embedded web files are now served directly from the main server
31+
// No need to start a separate server on port 3000
32+
3033
process.on("unhandledRejection", (e) => {
3134
Log.Default.error("rejection", {
3235
e: e instanceof Error ? e.message : e,

packages/opencode/src/server/server.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,38 @@ export namespace Server {
530530
.all("/*", async (c) => {
531531
const path = c.req.path
532532

533+
// Try to serve from embedded files if available (compiled binary)
534+
const isInsideBunBinary = typeof OPENCODE_VERSION === "string"
535+
if (isInsideBunBinary) {
536+
try {
537+
// @ts-ignore - embedded file is generated at build time
538+
const { embeddedFiles } = await import("./src/serveWeb.ts")
539+
540+
// Check for exact match
541+
if (embeddedFiles[path]) {
542+
const response = embeddedFiles[path].clone()
543+
response.headers.set(
544+
"Content-Security-Policy",
545+
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
546+
)
547+
return response
548+
}
549+
550+
// SPA fallback: serve index.html for routes without extensions
551+
if (!path.includes(".") && path !== "/" && embeddedFiles["/"]) {
552+
const response = embeddedFiles["/"].clone()
553+
response.headers.set(
554+
"Content-Security-Policy",
555+
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
556+
)
557+
return response
558+
}
559+
} catch (e) {
560+
// Embedded files not available, fall through to proxy
561+
}
562+
}
563+
564+
// Fall back to proxying app.opencode.ai (for development)
533565
const response = await proxy(`https://app.opencode.ai${path}`, {
534566
...c.req,
535567
headers: {

0 commit comments

Comments
 (0)