175 lines · 5.8 KB · TypeScript
RawDownload
// @effect-diagnostics nodeBuiltinImport:off/** * WorkspaceFileSystem - Effect service contract for workspace file mutations. * * Owns workspace-root-relative file read/write operations and their associated * safety checks and cache invalidation hooks. * * @module WorkspaceFileSystem */import * as NodeFSP from "node:fs/promises"; import type {  ProjectReadFileInput,  ProjectReadFileResult,  ProjectWriteFileInput,  ProjectWriteFileResult,} from "@t3tools/contracts";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Path from "effect/Path";import * as Schema from "effect/Schema"; import * as WorkspaceEntries from "./WorkspaceEntries.ts";import * as WorkspacePaths from "./WorkspacePaths.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()(  "WorkspaceFileSystemError",  {    cwd: Schema.String,    relativePath: Schema.optional(Schema.String),    operation: Schema.Literals([      "workspaceFileSystem.readFile",      "workspaceFileSystem.makeDirectory",      "workspaceFileSystem.writeFile",    ]),    cause: Schema.Defect(),  },) {  override get message(): string {    const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`;    return `Workspace file operation '${this.operation}' failed for ${target}.`;  }} /** Service tag for workspace file operations. */export class WorkspaceFileSystem extends Context.Service<  WorkspaceFileSystem,  {    /** Read a UTF-8 text file relative to the workspace root. */    readonly readFile: (      input: ProjectReadFileInput,    ) => Effect.Effect<      ProjectReadFileResult,      WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError    >;    /**     * Write a file relative to the workspace root.     *     * Creates parent directories as needed and rejects paths that escape the     * workspace root.     */    readonly writeFile: (      input: ProjectWriteFileInput,    ) => Effect.Effect<      ProjectWriteFileResult,      WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError    >;  }>()("t3/workspace/WorkspaceFileSystem") {} export const make = Effect.gen(function* () {  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const workspacePaths = yield* WorkspacePaths.WorkspacePaths;  const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;   const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(    "WorkspaceFileSystem.readFile",  )(function* (input) {    const target = yield* workspacePaths.resolveRelativePathWithinRoot({      workspaceRoot: input.cwd,      relativePath: input.relativePath,    });     return yield* Effect.tryPromise({      try: async () => {        const [realWorkspaceRoot, realTargetPath] = await Promise.all([          NodeFSP.realpath(input.cwd),          NodeFSP.realpath(target.absolutePath),        ]);        const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);        if (          relativeRealPath.startsWith(`..${path.sep}`) ||          relativeRealPath === ".." ||          path.isAbsolute(relativeRealPath)        ) {          throw new Error("Workspace file path resolves outside the project root.");        }         const handle = await NodeFSP.open(realTargetPath, "r");        try {          const stat = await handle.stat();          if (!stat.isFile()) {            throw new Error("Workspace path is not a file.");          }          const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);          const buffer = Buffer.alloc(bytesToRead);          const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0);          const fileBytes = buffer.subarray(0, bytesRead);          if (fileBytes.includes(0)) {            throw new Error("Binary files cannot be previewed as text.");          }          const contents = new TextDecoder("utf-8").decode(fileBytes);          return {            relativePath: target.relativePath,            contents,            byteLength: stat.size,            truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES,          };        } finally {          await handle.close();        }      },      catch: (cause) =>        new WorkspaceFileSystemError({          cwd: input.cwd,          relativePath: input.relativePath,          operation: "workspaceFileSystem.readFile",          cause,        }),    });  });   const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(    "WorkspaceFileSystem.writeFile",  )(function* (input) {    const target = yield* workspacePaths.resolveRelativePathWithinRoot({      workspaceRoot: input.cwd,      relativePath: input.relativePath,    });     yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(      Effect.mapError(        (cause) =>          new WorkspaceFileSystemError({            cwd: input.cwd,            relativePath: input.relativePath,            operation: "workspaceFileSystem.makeDirectory",            cause,          }),      ),    );    yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(      Effect.mapError(        (cause) =>          new WorkspaceFileSystemError({            cwd: input.cwd,            relativePath: input.relativePath,            operation: "workspaceFileSystem.writeFile",            cause,          }),      ),    );    yield* workspaceEntries.refresh(input.cwd);    return { relativePath: target.relativePath };  });   return WorkspaceFileSystem.of({ readFile, writeFile });}); export const layer = Layer.effect(WorkspaceFileSystem, make);