Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
// Watching a file you are already allowed to read is still reading.
[WS_METHODS.subscribeProjectFileChanges]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4784,6 +4784,53 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
);

it.effect("routes websocket rpc subscribeProjectFileChanges for edits made outside the app", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-watch-" });
const filePath = path.join(workspaceDir, "package.json");
const before = '{ "description": "An awesome horse platform" }\n';
const after = '{ "description": "An awesome course platform" }\n';
yield* fs.writeFileString(filePath, before);

yield* buildAppUnderTest();

const wsUrl = yield* getWsServerUrl("/ws");
const result = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
Effect.gen(function* () {
const watcher = yield* client[WS_METHODS.subscribeProjectFileChanges]({
cwd: workspaceDir,
relativePath: "package.json",
}).pipe(Stream.runHead, Effect.forkChild());
// `fs.watch` registration is not observable, so rewrite until the
// subscription reports rather than racing a fixed startup delay.
// The pause outlasts the watcher's debounce window.
const writer = yield* fs
.writeFileString(filePath, after)
.pipe(
Effect.orDie,
Effect.delay(Duration.millis(300)),
Effect.forever,
Effect.forkChild(),
);
const event = yield* Fiber.join(watcher).pipe(Effect.timeout(Duration.seconds(10)));
yield* Fiber.interrupt(writer);
const file = yield* client[WS_METHODS.projectsReadFile]({
cwd: workspaceDir,
relativePath: "package.json",
});
return { event, file };
}),
),
);

assert.deepEqual(result.event, Option.some({ relativePath: "package.json" }));
assert.equal(result.file.contents, after);
}).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
);

it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
107 changes: 107 additions & 0 deletions apps/server/src/workspace/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it, describe, expect } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Stream from "effect/Stream";

import * as ServerConfig from "../config.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
Expand Down Expand Up @@ -265,4 +269,107 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
}),
);
});

describe("watchFile", () => {
/**
* `fs.watch` registration is not observable, so the writer keeps rewriting
* until the watcher reports instead of racing a fixed startup delay. It
* pauses longer than the debounce window between rewrites so the stream
* gets the quiet period it waits for.
*/
const awaitFirstChange = Effect.fn("awaitFirstChange")(function* (
cwd: string,
relativePath: string,
rewrite: Effect.Effect<void, never, FileSystem.FileSystem | Path.Path>,
) {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const watcher = yield* workspaceFileSystem
.watchFile({ cwd, relativePath })
.pipe(Stream.runHead, Effect.forkChild());
const writer = yield* rewrite.pipe(
Effect.delay(Duration.millis(300)),
Effect.forever,
Effect.forkChild(),
);
const event = yield* Fiber.join(watcher).pipe(Effect.timeout(Duration.seconds(10)));
yield* Fiber.interrupt(writer);
return event;
});

it.effect("reports a plain on-disk write", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir;
yield* writeTextFile(
cwd,
"package.json",
'{ "description": "An awesome horse platform" }\n',
);

const event = yield* awaitFirstChange(
cwd,
"package.json",
writeTextFile(cwd, "package.json", '{ "description": "An awesome course platform" }\n'),
);

expect(event).toEqual(Option.some({ relativePath: "package.json" }));
}),
);

it.effect("reports an atomic replace that swaps the file's inode", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n");

const target = path.join(cwd, "src/index.ts");
const temp = path.join(cwd, "src/index.ts.tmp");
const replace = Effect.gen(function* () {
yield* fileSystem.writeFileString(temp, "export const answer = 43;\n").pipe(Effect.orDie);
yield* fileSystem.rename(temp, target).pipe(Effect.orDie);
});

const event = yield* awaitFirstChange(cwd, "src/index.ts", replace);

expect(event).toEqual(Option.some({ relativePath: "src/index.ts" }));
}),
);

it.effect("rejects a watch that escapes the workspace through a symlink", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
const outside = yield* makeTempDir;
yield* fileSystem
.writeFileString(path.join(outside, "secret.txt"), "secret\n")
.pipe(Effect.orDie);
// Lexically `link/secret.txt` sits inside the workspace; physically it
// does not, so no watcher may be pointed at it.
yield* fileSystem.symlink(outside, path.join(cwd, "link")).pipe(Effect.orDie);

const error = yield* workspaceFileSystem
.watchFile({ cwd, relativePath: "link/secret.txt" })
.pipe(Stream.runHead, Effect.flip);

expect(error._tag).toBe("WorkspaceFilePathEscapeError");
}),
);

it.effect("rejects watches outside the workspace root", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const cwd = yield* makeTempDir;

const error = yield* workspaceFileSystem
.watchFile({ cwd, relativePath: "../escape.md" })
.pipe(Stream.runHead, Effect.flip);

expect(error.message).toContain(
"Workspace file path must be relative to the project root: ../escape.md",
);
}),
);
});
});
157 changes: 114 additions & 43 deletions apps/server/src/workspace/WorkspaceFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,20 @@
import * as NodeFSP from "node:fs/promises";

import type {
ProjectFileChangedEvent,
ProjectReadFileInput,
ProjectReadFileResult,
ProjectWriteFileInput,
ProjectWriteFileResult,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
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 Stream from "effect/Stream";

import * as WorkspaceEntries from "./WorkspaceEntries.ts";
import * as WorkspacePaths from "./WorkspacePaths.ts";
Expand All @@ -43,6 +46,7 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass<W
"close",
"make-directory",
"write-file",
"watch",
]),
cause: Schema.Defect(),
},
Expand Down Expand Up @@ -123,6 +127,19 @@ export class WorkspaceFileSystem extends Context.Service<
ProjectWriteFileResult,
WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
>;
/**
* Emit a change event every time the file changes on disk.
*
* The stream is a signal only: subscribers re-read through `readFile`, so
* every size, binary and error rule stays in one place. Events are
* debounced because a single save is several `fs.watch` events.
*/
readonly watchFile: (
input: ProjectReadFileInput,
) => Stream.Stream<
ProjectFileChangedEvent,
WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
>;
}
>()("t3/workspace/WorkspaceFileSystem") {}

Expand All @@ -132,51 +149,66 @@ export const make = Effect.gen(function* () {
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,
});

const realWorkspaceRoot = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(input.cwd),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: input.cwd,
operation: "realpath-workspace-root",
cause,
}),
});
const realTargetPath = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(target.absolutePath),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: target.absolutePath,
operation: "realpath-target",
cause,
}),
});
const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
if (
relativeRealPath.startsWith(`..${path.sep}`) ||
relativeRealPath === ".." ||
path.isAbsolute(relativeRealPath)
) {
return yield* new WorkspaceFilePathEscapeError({
/**
* Resolve a workspace-relative path to a real path proven to sit inside the
* workspace root. Lexical containment is not enough: a symlink inside the
* workspace can point anywhere, so both ends are resolved with `realpath`
* and compared physically. Every operation that touches a workspace path
* goes through here so the checks cannot drift apart.
*/
const resolveContainedRealPath = Effect.fn("WorkspaceFileSystem.resolveContainedRealPath")(
function* (input: ProjectReadFileInput) {
const target = yield* workspacePaths.resolveRelativePathWithinRoot({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedWorkspaceRoot: realWorkspaceRoot,
resolvedPath: realTargetPath,
});
}

const realWorkspaceRoot = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(input.cwd),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: input.cwd,
operation: "realpath-workspace-root",
cause,
}),
});
const realTargetPath = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(target.absolutePath),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: target.absolutePath,
operation: "realpath-target",
cause,
}),
});
const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
if (
relativeRealPath.startsWith(`..${path.sep}`) ||
relativeRealPath === ".." ||
path.isAbsolute(relativeRealPath)
) {
return yield* new WorkspaceFilePathEscapeError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedWorkspaceRoot: realWorkspaceRoot,
resolvedPath: realTargetPath,
});
}

return { target, realTargetPath };
},
);

const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(
"WorkspaceFileSystem.readFile",
)(function* (input) {
const { target, realTargetPath } = yield* resolveContainedRealPath(input);

return yield* Effect.acquireUseRelease(
Effect.tryPromise({
Expand Down Expand Up @@ -297,7 +329,46 @@ export const make = Effect.gen(function* () {
return { relativePath: target.relativePath };
});

return WorkspaceFileSystem.of({ readFile, writeFile });
/**
* Watches the containing directory rather than the file itself: saves that
* land as rename-over-temp (git, most editors, atomic writers) replace the
* inode, and a file-level watch would follow the discarded one.
*/
const watchFile: WorkspaceFileSystem["Service"]["watchFile"] = (input) =>
Stream.unwrap(
Effect.gen(function* () {
// Physical containment, not just lexical: a symlink inside the
// workspace must not get a watcher pointed at an external directory.
const { target, realTargetPath } = yield* resolveContainedRealPath(input);
const directory = path.dirname(realTargetPath);
const fileName = path.basename(realTargetPath);

return fileSystem.watch(directory).pipe(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Stream.filter(
(event) =>
event.path === fileName ||
event.path === realTargetPath ||
path.resolve(directory, event.path) === realTargetPath,
),
// Debounce so the file is fully written before subscribers re-read it.
Stream.debounce(Duration.millis(100)),
Stream.map(() => ({ relativePath: target.relativePath })),
Stream.mapError(
(cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: realTargetPath,
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
operationPath: directory,
operation: "watch",
cause,
}),
),
);
}),
);

return WorkspaceFileSystem.of({ readFile, writeFile, watchFile });
});

export const layer = Layer.effect(WorkspaceFileSystem, make);
Loading
Loading