refactor(server): serve raw filesystem content (#31911)
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
export * as FileSystem from "./filesystem"
|
export * as FileSystem from "./filesystem"
|
||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
@@ -59,7 +58,7 @@ export const Event = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly read: (input: ReadInput) => Effect.Effect<Content>
|
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||||
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
||||||
@@ -91,27 +90,9 @@ const baseLayer = Layer.effect(
|
|||||||
const target = yield* resolve(input.path)
|
const target = yield* resolve(input.path)
|
||||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||||
const bytes = yield* fs.readFile(target.real).pipe(Effect.orDie)
|
|
||||||
const mime = FSUtil.mimeType(target.real)
|
|
||||||
if (!bytes.includes(0)) {
|
|
||||||
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
|
|
||||||
Effect.option,
|
|
||||||
)
|
|
||||||
if (Option.isSome(content))
|
|
||||||
return {
|
|
||||||
uri: pathToFileURL(target.real).href,
|
|
||||||
name: path.basename(target.real),
|
|
||||||
content: content.value,
|
|
||||||
encoding: "utf8" as const,
|
|
||||||
mime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
uri: pathToFileURL(target.real).href,
|
content: yield* fs.readFile(target.real).pipe(Effect.orDie),
|
||||||
name: path.basename(target.real),
|
mime: FSUtil.mimeType(target.real),
|
||||||
content: Buffer.from(bytes).toString("base64"),
|
|
||||||
encoding: "base64" as const,
|
|
||||||
mime,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Exit, Layer } from "effect"
|
import { Effect, Exit, Layer } from "effect"
|
||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
@@ -40,9 +39,9 @@ describe("FileSystem", () => {
|
|||||||
const service = yield* FileSystem.Service
|
const service = yield* FileSystem.Service
|
||||||
const text = yield* service.read({ path: RelativePath.make("text.txt") })
|
const text = yield* service.read({ path: RelativePath.make("text.txt") })
|
||||||
const binary = yield* service.read({ path: RelativePath.make("data.bin") })
|
const binary = yield* service.read({ path: RelativePath.make("data.bin") })
|
||||||
expect(text).toMatchObject({ name: "text.txt", content: "hello", encoding: "utf8", mime: "text/plain" })
|
expect(new TextDecoder().decode(text.content)).toBe("hello")
|
||||||
expect(fileURLToPath(text.uri)).toBe(path.join(directory, "text.txt"))
|
expect(text.mime).toBe("text/plain")
|
||||||
expect(binary).toMatchObject({ name: "data.bin", content: "AAEC", encoding: "base64" })
|
expect(binary.content).toEqual(new Uint8Array([0, 1, 2]))
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ const FileReadCommand = effectCmd({
|
|||||||
description: "File path to read",
|
description: "File path to read",
|
||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
||||||
const content = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
|
const file = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
|
||||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
process.stdout.write(
|
||||||
|
JSON.stringify({ content: Buffer.from(file.content).toString("base64"), encoding: "base64", mime: file.mime }, null, 2) +
|
||||||
|
EOL,
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
|||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer, Option } from "effect"
|
||||||
import ignore from "ignore"
|
import ignore from "ignore"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
@@ -101,11 +101,26 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||||||
return yield* filesystem(
|
return yield* filesystem(
|
||||||
FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })),
|
FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.map((item) => ({
|
Effect.flatMap((item) =>
|
||||||
type: item.encoding === "utf8" ? ("text" as const) : ("binary" as const),
|
Effect.gen(function* () {
|
||||||
content: item.encoding === "utf8" ? item.content.trim() : item.content,
|
const text = item.content.includes(0)
|
||||||
...(item.encoding === "base64" ? { encoding: item.encoding, mimeType: item.mime } : {}),
|
? Option.none<string>()
|
||||||
})),
|
: yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(item.content)).pipe(
|
||||||
|
Effect.option,
|
||||||
|
)
|
||||||
|
return { item, text }
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.map(({ item, text }) =>
|
||||||
|
Option.isSome(text)
|
||||||
|
? { type: "text" as const, content: text.value.trim() }
|
||||||
|
: {
|
||||||
|
type: "binary" as const,
|
||||||
|
content: Buffer.from(item.content).toString("base64"),
|
||||||
|
encoding: "base64" as const,
|
||||||
|
mimeType: item.mime,
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -709,10 +709,18 @@ const scenarios: Scenario[] = [
|
|||||||
"status",
|
"status",
|
||||||
),
|
),
|
||||||
http.protected
|
http.protected
|
||||||
.get("/api/fs/read", "v2.fs.read")
|
.get("/api/fs/read/*", "v2.fs.read")
|
||||||
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
|
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
|
||||||
.at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
|
.at((ctx) => ({ path: "/api/fs/read/hello.txt", headers: ctx.headers() }))
|
||||||
.json(200, locationData(object)),
|
.status(
|
||||||
|
200,
|
||||||
|
(_ctx, result) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
check(result.text === "hello\n", "v2 fs read should return the file body")
|
||||||
|
check(result.contentType.includes("text/plain"), "v2 fs read should return the file content type")
|
||||||
|
}),
|
||||||
|
"status",
|
||||||
|
),
|
||||||
http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
|
http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
|
||||||
http.protected
|
http.protected
|
||||||
.get("/api/fs/find", "v2.fs.find")
|
.get("/api/fs/find", "v2.fs.find")
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
|||||||
test("documents references separately from filesystem routes", () => {
|
test("documents references separately from filesystem routes", () => {
|
||||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||||
|
|
||||||
for (const path of ["/api/fs/read", "/api/fs/list"]) {
|
for (const path of ["/api/fs/read/*", "/api/fs/list"]) {
|
||||||
expect(spec.paths[path]?.get?.parameters, path).not.toContainEqual(expect.objectContaining({ name: "reference" }))
|
expect(spec.paths[path]?.get?.parameters, path).not.toContainEqual(expect.objectContaining({ name: "reference" }))
|
||||||
}
|
}
|
||||||
expect(spec.paths["/api/reference"]?.get).toBeDefined()
|
expect(spec.paths["/api/reference"]?.get).toBeDefined()
|
||||||
|
|||||||
@@ -389,12 +389,9 @@ describe("HttpApi SDK", () => {
|
|||||||
workspaceID,
|
workspaceID,
|
||||||
onRequest: (value) => (request = value),
|
onRequest: (value) => (request = value),
|
||||||
})
|
})
|
||||||
const file = yield* call(() => sdk.v2.fs.read({ path: "hello.txt" }))
|
|
||||||
const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" }))
|
const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" }))
|
||||||
const url = new URL(request!.url)
|
const url = new URL(request!.url)
|
||||||
|
|
||||||
expect(file.response.status).toBe(200)
|
|
||||||
expect(file.data).toMatchObject({ data: { content: "hello" } })
|
|
||||||
expect(found.response.status).toBe(200)
|
expect(found.response.status).toBe(200)
|
||||||
expect(found.data).toMatchObject({ data: [{ path: "hello.txt", type: "file" }] })
|
expect(found.data).toMatchObject({ data: [{ path: "hello.txt", type: "file" }] })
|
||||||
expect(url.searchParams.get("directory")).toBe(directory)
|
expect(url.searchParams.get("directory")).toBe(directory)
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
export * from "./gen/types.gen.js"
|
export * from "./gen/types.gen.js"
|
||||||
export type {
|
export type { FileSystemEntry as LocationFileSystemEntry } from "./gen/types.gen.js"
|
||||||
FileSystemContent as LocationFileSystemContent,
|
|
||||||
FileSystemEntry as LocationFileSystemEntry,
|
|
||||||
} from "./gen/types.gen.js"
|
|
||||||
|
|
||||||
import { createClient } from "./gen/client/client.gen.js"
|
import { createClient } from "./gen/client/client.gen.js"
|
||||||
import { type Config } from "./gen/client/types.gen.js"
|
import { type Config } from "./gen/client/types.gen.js"
|
||||||
|
|||||||
@@ -5964,31 +5964,20 @@ export class Fs extends HeyApiClient {
|
|||||||
/**
|
/**
|
||||||
* Read file
|
* Read file
|
||||||
*
|
*
|
||||||
* Read one file relative to the requested location.
|
* Serve one file relative to the requested location.
|
||||||
*/
|
*/
|
||||||
public read<ThrowOnError extends boolean = false>(
|
public read<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters?: {
|
||||||
location?: {
|
location?: {
|
||||||
directory?: string
|
directory?: string
|
||||||
workspace?: string
|
workspace?: string
|
||||||
}
|
}
|
||||||
path: string
|
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
const params = buildClientParams(
|
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
|
||||||
[parameters],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
args: [
|
|
||||||
{ in: "query", key: "location" },
|
|
||||||
{ in: "query", key: "path" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
return (options?.client ?? this.client).get<V2FsReadResponses, V2FsReadErrors, ThrowOnError>({
|
return (options?.client ?? this.client).get<V2FsReadResponses, V2FsReadErrors, ThrowOnError>({
|
||||||
url: "/api/fs/read",
|
url: "/api/fs/read/*",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4186,14 +4186,6 @@ export type PermissionSavedInfo = {
|
|||||||
resource: string
|
resource: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileSystemContent = {
|
|
||||||
uri: string
|
|
||||||
name?: string
|
|
||||||
content: string
|
|
||||||
encoding: "utf8" | "base64"
|
|
||||||
mime: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FileSystemEntry = {
|
export type FileSystemEntry = {
|
||||||
path: string
|
path: string
|
||||||
type: "file" | "directory"
|
type: "file" | "directory"
|
||||||
@@ -10554,14 +10546,13 @@ export type V2SessionPermissionReplyResponse =
|
|||||||
export type V2FsReadData = {
|
export type V2FsReadData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
query: {
|
query?: {
|
||||||
location?: {
|
location?: {
|
||||||
directory?: string
|
directory?: string
|
||||||
workspace?: string
|
workspace?: string
|
||||||
}
|
}
|
||||||
path: string
|
|
||||||
}
|
}
|
||||||
url: "/api/fs/read"
|
url: "/api/fs/read/*"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type V2FsReadErrors = {
|
export type V2FsReadErrors = {
|
||||||
@@ -10581,10 +10572,7 @@ export type V2FsReadResponses = {
|
|||||||
/**
|
/**
|
||||||
* Success
|
* Success
|
||||||
*/
|
*/
|
||||||
200: {
|
200: Blob | File
|
||||||
location: LocationInfo
|
|
||||||
data: FileSystemContent
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type V2FsReadResponse = V2FsReadResponses[keyof V2FsReadResponses]
|
export type V2FsReadResponse = V2FsReadResponses[keyof V2FsReadResponses]
|
||||||
|
|||||||
@@ -2,14 +2,9 @@ import { FileSystem } from "@opencode-ai/core/filesystem"
|
|||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { PositiveInt, RelativePath } from "@opencode-ai/core/schema"
|
import { PositiveInt, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
|
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
|
||||||
|
|
||||||
const ReadQuery = Schema.Struct({
|
|
||||||
...LocationQuery.fields,
|
|
||||||
path: RelativePath,
|
|
||||||
})
|
|
||||||
|
|
||||||
const ListQuery = Schema.Struct({
|
const ListQuery = Schema.Struct({
|
||||||
...LocationQuery.fields,
|
...LocationQuery.fields,
|
||||||
path: RelativePath.pipe(Schema.optional),
|
path: RelativePath.pipe(Schema.optional),
|
||||||
@@ -24,16 +19,16 @@ const FindQuery = Schema.Struct({
|
|||||||
|
|
||||||
export const FileSystemGroup = HttpApiGroup.make("server.fs")
|
export const FileSystemGroup = HttpApiGroup.make("server.fs")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("fs.read", "/api/fs/read", {
|
HttpApiEndpoint.get("fs.read", "/api/fs/read/*", {
|
||||||
query: ReadQuery,
|
query: LocationQuery,
|
||||||
success: Location.response(FileSystem.Content),
|
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
|
||||||
})
|
})
|
||||||
.annotateMerge(locationQueryOpenApi)
|
.annotateMerge(locationQueryOpenApi)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "v2.fs.read",
|
identifier: "v2.fs.read",
|
||||||
summary: "Read file",
|
summary: "Read file",
|
||||||
description: "Read one file relative to the requested location.",
|
description: "Serve one file relative to the requested location.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { HttpServerResponse } from "effect/unstable/http"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
import { response } from "../groups/location"
|
import { response } from "../groups/location"
|
||||||
@@ -7,13 +9,13 @@ import { response } from "../groups/location"
|
|||||||
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
|
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return handlers
|
return handlers
|
||||||
.handle("fs.read", (ctx) =>
|
.handleRaw("fs.read", (ctx) =>
|
||||||
response(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const file = yield* (yield* FileSystem.Service).read({
|
||||||
const fs = yield* FileSystem.Service
|
path: RelativePath.make(decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))),
|
||||||
return yield* fs.read(ctx.query)
|
})
|
||||||
}),
|
return HttpServerResponse.uint8Array(file.content, { contentType: file.mime })
|
||||||
),
|
}),
|
||||||
)
|
)
|
||||||
.handle("fs.list", (ctx) =>
|
.handle("fs.list", (ctx) =>
|
||||||
response(
|
response(
|
||||||
|
|||||||
Reference in New Issue
Block a user