Просмотр исходного кода

refactor(server): serve raw filesystem content (#31911)

Dax 2 месяцев назад
Родитель
Сommit
31c5454ee6

+ 4 - 23
packages/core/src/filesystem.ts

@@ -1,8 +1,7 @@
 export * as FileSystem from "./filesystem"
 
 import path from "path"
-import { pathToFileURL } from "url"
-import { Context, Effect, Layer, Option, Schema } from "effect"
+import { Context, Effect, Layer, Schema } from "effect"
 import { EventV2 } from "./event"
 import { FSUtil } from "./fs-util"
 import { Location } from "./location"
@@ -59,7 +58,7 @@ export const Event = {
 }
 
 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 find: (input: FindInput) => Effect.Effect<Entry[]>
   readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
@@ -91,27 +90,9 @@ const baseLayer = Layer.effect(
         const target = yield* resolve(input.path)
         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"))
-        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 {
-          uri: pathToFileURL(target.real).href,
-          name: path.basename(target.real),
-          content: Buffer.from(bytes).toString("base64"),
-          encoding: "base64" as const,
-          mime,
+          content: yield* fs.readFile(target.real).pipe(Effect.orDie),
+          mime: FSUtil.mimeType(target.real),
         }
       }),
       list: Effect.fn("FileSystem.list")(function* (input = {}) {

+ 3 - 4
packages/core/test/location-filesystem.test.ts

@@ -1,6 +1,5 @@
 import fs from "fs/promises"
 import path from "path"
-import { fileURLToPath } from "url"
 import { describe, expect } from "bun:test"
 import { Effect, Exit, Layer } from "effect"
 import { FileSystem } from "@opencode-ai/core/filesystem"
@@ -40,9 +39,9 @@ describe("FileSystem", () => {
         const service = yield* FileSystem.Service
         const text = yield* service.read({ path: RelativePath.make("text.txt") })
         const binary = yield* service.read({ path: RelativePath.make("data.bin") })
-        expect(text).toMatchObject({ name: "text.txt", content: "hello", encoding: "utf8", mime: "text/plain" })
-        expect(fileURLToPath(text.uri)).toBe(path.join(directory, "text.txt"))
-        expect(binary).toMatchObject({ name: "data.bin", content: "AAEC", encoding: "base64" })
+        expect(new TextDecoder().decode(text.content)).toBe("hello")
+        expect(text.mime).toBe("text/plain")
+        expect(binary.content).toEqual(new Uint8Array([0, 1, 2]))
       }).pipe(provide(directory)),
     ),
   )

+ 5 - 2
packages/opencode/src/cli/cmd/debug/file.ts

@@ -38,8 +38,11 @@ const FileReadCommand = effectCmd({
       description: "File path to read",
     }),
   handler: Effect.fn("Cli.debug.file.read")(function* (args) {
-    const content = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
-    process.stdout.write(JSON.stringify(content, null, 2) + EOL)
+    const file = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
+    process.stdout.write(
+      JSON.stringify({ content: Buffer.from(file.content).toString("base64"), encoding: "base64", mime: file.mime }, null, 2) +
+        EOL,
+    )
   }),
 })
 

+ 21 - 6
packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts

@@ -5,7 +5,7 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Location } from "@opencode-ai/core/location"
 import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
-import { Effect, Layer } from "effect"
+import { Effect, Layer, Option } from "effect"
 import ignore from "ignore"
 import path from "path"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -101,11 +101,26 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
       return yield* filesystem(
         FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })),
       ).pipe(
-        Effect.map((item) => ({
-          type: item.encoding === "utf8" ? ("text" as const) : ("binary" as const),
-          content: item.encoding === "utf8" ? item.content.trim() : item.content,
-          ...(item.encoding === "base64" ? { encoding: item.encoding, mimeType: item.mime } : {}),
-        })),
+        Effect.flatMap((item) =>
+          Effect.gen(function* () {
+            const text = item.content.includes(0)
+              ? 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,
+              },
+        ),
       )
     })
 

+ 11 - 3
packages/opencode/test/server/httpapi-exercise/index.ts

@@ -709,10 +709,18 @@ const scenarios: Scenario[] = [
       "status",
     ),
   http.protected
-    .get("/api/fs/read", "v2.fs.read")
+    .get("/api/fs/read/*", "v2.fs.read")
     .seeded((ctx) => ctx.file("hello.txt", "hello\n"))
-    .at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
-    .json(200, locationData(object)),
+    .at((ctx) => ({ path: "/api/fs/read/hello.txt", headers: ctx.headers() }))
+    .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/find", "v2.fs.find")

+ 1 - 1
packages/opencode/test/server/httpapi-public-openapi.test.ts

@@ -97,7 +97,7 @@ describe("PublicApi OpenAPI v2 errors", () => {
   test("documents references separately from filesystem routes", () => {
     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["/api/reference"]?.get).toBeDefined()

+ 0 - 3
packages/opencode/test/server/httpapi-sdk.test.ts

@@ -389,12 +389,9 @@ describe("HttpApi SDK", () => {
           workspaceID,
           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 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.data).toMatchObject({ data: [{ path: "hello.txt", type: "file" }] })
         expect(url.searchParams.get("directory")).toBe(directory)

+ 1 - 4
packages/sdk/js/src/v2/client.ts

@@ -1,8 +1,5 @@
 export * from "./gen/types.gen.js"
-export type {
-  FileSystemContent as LocationFileSystemContent,
-  FileSystemEntry as LocationFileSystemEntry,
-} from "./gen/types.gen.js"
+export type { FileSystemEntry as LocationFileSystemEntry } from "./gen/types.gen.js"
 
 import { createClient } from "./gen/client/client.gen.js"
 import { type Config } from "./gen/client/types.gen.js"

+ 4 - 15
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -5964,31 +5964,20 @@ export class Fs extends HeyApiClient {
   /**
    * Read file
    *
-   * Read one file relative to the requested location.
+   * Serve one file relative to the requested location.
    */
   public read<ThrowOnError extends boolean = false>(
-    parameters: {
+    parameters?: {
       location?: {
         directory?: string
         workspace?: string
       }
-      path: string
     },
     options?: Options<never, ThrowOnError>,
   ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "query", key: "location" },
-            { in: "query", key: "path" },
-          ],
-        },
-      ],
-    )
+    const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
     return (options?.client ?? this.client).get<V2FsReadResponses, V2FsReadErrors, ThrowOnError>({
-      url: "/api/fs/read",
+      url: "/api/fs/read/*",
       ...options,
       ...params,
     })

+ 3 - 15
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -4186,14 +4186,6 @@ export type PermissionSavedInfo = {
   resource: string
 }
 
-export type FileSystemContent = {
-  uri: string
-  name?: string
-  content: string
-  encoding: "utf8" | "base64"
-  mime: string
-}
-
 export type FileSystemEntry = {
   path: string
   type: "file" | "directory"
@@ -10554,14 +10546,13 @@ export type V2SessionPermissionReplyResponse =
 export type V2FsReadData = {
   body?: never
   path?: never
-  query: {
+  query?: {
     location?: {
       directory?: string
       workspace?: string
     }
-    path: string
   }
-  url: "/api/fs/read"
+  url: "/api/fs/read/*"
 }
 
 export type V2FsReadErrors = {
@@ -10581,10 +10572,7 @@ export type V2FsReadResponses = {
   /**
    * Success
    */
-  200: {
-    location: LocationInfo
-    data: FileSystemContent
-  }
+  200: Blob | File
 }
 
 export type V2FsReadResponse = V2FsReadResponses[keyof V2FsReadResponses]

+ 5 - 10
packages/server/src/groups/fs.ts

@@ -2,14 +2,9 @@ import { FileSystem } from "@opencode-ai/core/filesystem"
 import { Location } from "@opencode-ai/core/location"
 import { PositiveInt, RelativePath } from "@opencode-ai/core/schema"
 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"
 
-const ReadQuery = Schema.Struct({
-  ...LocationQuery.fields,
-  path: RelativePath,
-})
-
 const ListQuery = Schema.Struct({
   ...LocationQuery.fields,
   path: RelativePath.pipe(Schema.optional),
@@ -24,16 +19,16 @@ const FindQuery = Schema.Struct({
 
 export const FileSystemGroup = HttpApiGroup.make("server.fs")
   .add(
-    HttpApiEndpoint.get("fs.read", "/api/fs/read", {
-      query: ReadQuery,
-      success: Location.response(FileSystem.Content),
+    HttpApiEndpoint.get("fs.read", "/api/fs/read/*", {
+      query: LocationQuery,
+      success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
     })
       .annotateMerge(locationQueryOpenApi)
       .annotateMerge(
         OpenApi.annotations({
           identifier: "v2.fs.read",
           summary: "Read file",
-          description: "Read one file relative to the requested location.",
+          description: "Serve one file relative to the requested location.",
         }),
       ),
   )

+ 9 - 7
packages/server/src/handlers/fs.ts

@@ -1,5 +1,7 @@
 import { FileSystem } from "@opencode-ai/core/filesystem"
+import { RelativePath } from "@opencode-ai/core/schema"
 import { Effect } from "effect"
+import { HttpServerResponse } from "effect/unstable/http"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Api } from "../api"
 import { response } from "../groups/location"
@@ -7,13 +9,13 @@ import { response } from "../groups/location"
 export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
   Effect.gen(function* () {
     return handlers
-      .handle("fs.read", (ctx) =>
-        response(
-          Effect.gen(function* () {
-            const fs = yield* FileSystem.Service
-            return yield* fs.read(ctx.query)
-          }),
-        ),
+      .handleRaw("fs.read", (ctx) =>
+        Effect.gen(function* () {
+          const file = yield* (yield* FileSystem.Service).read({
+            path: RelativePath.make(decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))),
+          })
+          return HttpServerResponse.uint8Array(file.content, { contentType: file.mime })
+        }),
       )
       .handle("fs.list", (ctx) =>
         response(