mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-08 09:37:47 +03:00
110 lines
4.7 KiB
TypeScript
110 lines
4.7 KiB
TypeScript
export * as BrowserFiles from "./files.js"
|
|
|
|
import { Browser } from "./rpc.js"
|
|
import { Tool } from "@opencode/schema/tool"
|
|
import { Effect } from "effect"
|
|
|
|
// Files cross machines as bytes. Only this endpoint interprets its local paths.
|
|
export const read = Effect.fn("BrowserFiles.read")((paths: readonly string[], directory: string) =>
|
|
Effect.tryPromise({
|
|
try: async () => {
|
|
const { open } = await import("node:fs/promises")
|
|
const { resolve, basename, extname } = await import("node:path")
|
|
const files = await Promise.all(
|
|
paths.map(async (input) => {
|
|
const file = await open(resolve(directory, input), "r")
|
|
try {
|
|
const stat = await file.stat()
|
|
if (!stat.isFile())
|
|
throw new Error("Upload paths must name files, not directories. Select a server-local file.")
|
|
if (stat.size > Browser.MAX_FILE_BYTES)
|
|
throw new Error(
|
|
`Upload is ${stat.size} bytes; the limit is ${Browser.MAX_FILE_BYTES} bytes (5 MiB). Select a smaller file; do not retry the same upload.`,
|
|
)
|
|
return {
|
|
id: Browser.FileID.make(`file_${crypto.randomUUID()}`),
|
|
name: basename(input),
|
|
mime: types[extname(input).toLowerCase()] ?? "application/octet-stream",
|
|
data: new Uint8Array(await file.readFile()),
|
|
}
|
|
} finally {
|
|
await file.close()
|
|
}
|
|
}),
|
|
)
|
|
if (files.reduce((size, file) => size + file.data.byteLength, 0) > Browser.MAX_FILE_BYTES)
|
|
throw new Error(
|
|
"The selected upload files exceed 5 MiB in total. Send fewer or smaller files; splitting them into one batch does not bypass the total limit.",
|
|
)
|
|
return files
|
|
},
|
|
catch: (error) => failure("read", error),
|
|
}),
|
|
)
|
|
|
|
const types: Record<string, string> = {
|
|
".txt": "text/plain",
|
|
".csv": "text/csv",
|
|
".json": "application/json",
|
|
".html": "text/html",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".webp": "image/webp",
|
|
".gif": "image/gif",
|
|
".svg": "image/svg+xml",
|
|
".pdf": "application/pdf",
|
|
".zip": "application/zip",
|
|
".gz": "application/gzip",
|
|
}
|
|
|
|
export const save = Effect.fn("BrowserFiles.save")((files: readonly Browser.File[]) =>
|
|
Effect.tryPromise({
|
|
try: async () => {
|
|
if (files.length === 0) return []
|
|
if (files.reduce((size, file) => size + file.data.byteLength, 0) > Browser.MAX_FILE_BYTES)
|
|
throw new Error(
|
|
"Capture files exceed the 5 MiB total transfer limit. Use a smaller screenshot, a shorter trace/profile, or a smaller page for heap capture; do not retry the identical capture.",
|
|
)
|
|
const { mkdtemp, mkdir, writeFile } = await import("node:fs/promises")
|
|
const { join } = await import("node:path")
|
|
const { tmpdir } = await import("node:os")
|
|
const directory = await mkdtemp(join(tmpdir(), "opencode-browser-"))
|
|
return Promise.all(
|
|
files.map(async (file, index) => {
|
|
const name = captureName(file.name)
|
|
await mkdir(join(directory, String(index)))
|
|
const path = join(directory, String(index), name)
|
|
await writeFile(path, file.data, { flag: "wx" })
|
|
return { id: file.id, name: file.name, mime: file.mime, bytes: file.data.byteLength, path }
|
|
}),
|
|
)
|
|
},
|
|
catch: (error) => failure("save", error),
|
|
}),
|
|
)
|
|
|
|
// `.`/`..` escape the per-file directory and Windows resolves device names such as CON.txt regardless of directory.
|
|
export function captureName(name: string) {
|
|
const sanitized = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(-160)
|
|
if (!sanitized || /^\.{1,2}$/.test(sanitized) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(sanitized))
|
|
return "capture"
|
|
return sanitized
|
|
}
|
|
|
|
function failure(operation: "read" | "save", error: unknown) {
|
|
const detail = error instanceof Error ? error.message.slice(0, 400) : String(error).slice(0, 400)
|
|
const code =
|
|
error instanceof Error && "code" in error && typeof error.code === "string" && !detail.startsWith(error.code)
|
|
? `${error.code}: `
|
|
: ""
|
|
const recovery =
|
|
operation === "save"
|
|
? "The browser may have completed the capture, but no server-local export is confirmed. Check free space and write access on the server. Use browser.files.list({tabID}) and browser.files.get({tabID,fileID}) to retrieve an existing completed capture instead of repeating its browser action."
|
|
: "Upload paths are on the server, not the desktop. Check that each path exists, is a file, and is readable on the server; correct paths or select smaller files before retrying."
|
|
return new Tool.Error({
|
|
message: `Cannot ${operation} browser files on the server. ${recovery} Details: ${code}${detail}`,
|
|
error,
|
|
})
|
|
}
|