mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(cli): load web assets on demand
from OpenCode
This commit is contained in:
@@ -4,34 +4,31 @@ import { brotliCompressSync, constants } from "node:zlib"
|
||||
import { collectFiles } from "./files"
|
||||
|
||||
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
|
||||
if (options?.skipBuild) return compress({})
|
||||
if (options?.skipBuild) return "{}"
|
||||
const root = path.resolve(import.meta.dirname, "../../app")
|
||||
await $`bun run build`
|
||||
.cwd(root)
|
||||
.env({ ...process.env, OPENCODE_CHANNEL: channel, VITE_OPENCODE_SERVER_MODE: "origin" })
|
||||
const assets = Object.fromEntries(
|
||||
await Promise.all(
|
||||
(await collectFiles(path.join(root, "dist")))
|
||||
.map((key) => key.replaceAll(path.sep, "/"))
|
||||
.filter((key) => !key.endsWith(".map"))
|
||||
.toSorted()
|
||||
.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 JSON.stringify(
|
||||
Object.fromEntries(
|
||||
await Promise.all(
|
||||
(await collectFiles(path.join(root, "dist")))
|
||||
.map((key) => key.replaceAll(path.sep, "/"))
|
||||
.filter((key) => !key.endsWith(".map"))
|
||||
.toSorted()
|
||||
.map(async (key) => {
|
||||
const source = path.join(root, "dist", key)
|
||||
const body = Buffer.from(await Bun.file(source).arrayBuffer())
|
||||
// Independent entries let the server materialize only assets the browser requests.
|
||||
return [key, compress(body)] as const
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return compress(assets)
|
||||
}
|
||||
|
||||
function compress(assets: object) {
|
||||
return brotliCompressSync(JSON.stringify(assets), {
|
||||
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
|
||||
function compress(body: Buffer) {
|
||||
return brotliCompressSync(body, {
|
||||
params: { [constants.BROTLI_PARAM_QUALITY]: 6 },
|
||||
}).toString("base64")
|
||||
}
|
||||
|
||||
function isText(key: string) {
|
||||
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}))
|
||||
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
|
||||
loader: "js",
|
||||
contents: `export default ${JSON.stringify(appArchive)}`,
|
||||
contents: `export default ${appArchive}`,
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,48 +1,51 @@
|
||||
import { Effect, FileSystem, Option } from "effect"
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { brotliDecompressSync } from "node:zlib"
|
||||
import { OPENCODE_LOCAL } from "./version"
|
||||
|
||||
export type AssetMap = Readonly<Record<string, string | Uint8Array>>
|
||||
type EncodedAssetMap = Readonly<Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>>
|
||||
type EncodedAssetMap = Readonly<Record<string, string>>
|
||||
|
||||
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 (Option.isSome(embedded) && (Object.keys(embedded.value.default).length > 0 || !OPENCODE_LOCAL))
|
||||
return lazy(embedded.value.default, (key) =>
|
||||
brotliDecompressSync(Buffer.from(embedded.value.default[key]!, "base64")),
|
||||
)
|
||||
if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build"))
|
||||
return decode(yield* sourceAssets())
|
||||
return 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(
|
||||
const assets = 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
|
||||
return [file, target] as const
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)).filter((asset) => asset !== undefined),
|
||||
)
|
||||
return lazy(assets, (key) => readFileSync(assets[key]!))
|
||||
})
|
||||
|
||||
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 lazy(assets: EncodedAssetMap, load: (key: string) => Uint8Array): AssetMap {
|
||||
// Immutable browser caching makes retaining decompressed copies in the server unnecessary.
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, key) => {
|
||||
if (typeof key !== "string" || assets[key] === undefined) return
|
||||
const body = load(key)
|
||||
return isText(key) ? Buffer.from(body).toString() : body
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,12 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
|
||||
|
||||
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
|
||||
const key = url.pathname.replace(/^\//, "")
|
||||
if ((key.startsWith("_assets/") || key.startsWith("icons/")) && assets[key] === undefined)
|
||||
const requested = assets[key]
|
||||
if ((key.startsWith("_assets/") || key.startsWith("icons/")) && requested === undefined)
|
||||
return Effect.succeed(HttpServerResponse.empty({ status: 404, headers: { "cache-control": "no-store" } }))
|
||||
const name = assets[key] !== undefined ? key : "index.html"
|
||||
const file = assets[name]
|
||||
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
|
||||
const name = requested !== undefined ? key : "index.html"
|
||||
const file = requested ?? assets["index.html"]
|
||||
if (file === undefined) 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"
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
declare module "virtual:opencode-app-assets" {
|
||||
const archive: string
|
||||
const archive: Readonly<Record<string, string>>
|
||||
export default archive
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ function appAssetsPlugin(archive: string): Plugin {
|
||||
},
|
||||
load(id) {
|
||||
if (id !== "\0virtual:opencode-app-assets") return
|
||||
return `export default ${JSON.stringify(archive)}`
|
||||
return `export default ${archive}`
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -292,5 +292,5 @@ export default mainConfig({
|
||||
channel: process.env.OPENCODE_CHANNEL ?? "local",
|
||||
assetHash: "local",
|
||||
target: nodeTarget(process.platform, process.arch),
|
||||
appArchive: "",
|
||||
appArchive: "{}",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user