Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
122 changes: 122 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,122 @@ 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("reports a file that does not exist yet when it appears", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir;
// No file at this path: the watcher must still attach, so a tab whose
// file is momentarily absent keeps its live refresh.
const event = yield* awaitFirstChange(
cwd,
"later.txt",
writeTextFile(cwd, "later.txt", "created\n"),
);

expect(event).toEqual(Option.some({ relativePath: "later.txt" }));
}),
);

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",
);
}),
);
});
});
110 changes: 94 additions & 16 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,39 +149,42 @@ 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,
});

/**
* Resolve one absolute path with `realpath` and prove it sits inside the
* workspace root. Lexical containment is not enough: a symlink inside the
* workspace can point anywhere, so both ends are resolved physically and
* compared. Callers pick what they need contained — `readFile` contains the
* file it opens, `watchFile` the directory it watches.
*/
const realPathWithinRoot = Effect.fn("WorkspaceFileSystem.realPathWithinRoot")(function* (
input: ProjectReadFileInput,
absolutePath: string,
) {
const realWorkspaceRoot = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(input.cwd),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
resolvedPath: absolutePath,
operationPath: input.cwd,
operation: "realpath-workspace-root",
cause,
}),
});
const realTargetPath = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(target.absolutePath),
const realPath = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(absolutePath),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: target.absolutePath,
resolvedPath: absolutePath,
operationPath: absolutePath,
operation: "realpath-target",
cause,
}),
});
const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
const relativeRealPath = path.relative(realWorkspaceRoot, realPath);
if (
relativeRealPath.startsWith(`..${path.sep}`) ||
relativeRealPath === ".." ||
Expand All @@ -174,10 +194,22 @@ export const make = Effect.gen(function* () {
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedWorkspaceRoot: realWorkspaceRoot,
resolvedPath: realTargetPath,
resolvedPath: realPath,
});
}

return realPath;
});

const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(
"WorkspaceFileSystem.readFile",
)(function* (input) {
const target = yield* workspacePaths.resolveRelativePathWithinRoot({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
});
const realTargetPath = yield* realPathWithinRoot(input, target.absolutePath);

return yield* Effect.acquireUseRelease(
Effect.tryPromise({
try: () => NodeFSP.open(realTargetPath, "r"),
Expand Down Expand Up @@ -297,7 +329,53 @@ 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* () {
const target = yield* workspacePaths.resolveRelativePathWithinRoot({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
});
// Contain the directory rather than the file: the directory is what
// gets watched, and requiring the leaf to resolve would refuse to watch
// a path that is momentarily absent — mid atomic-replace, or not yet
// created. A symlinked leaf stays safe because the re-read still goes
// through `readFile`, which contains the file it opens.
const directory = yield* realPathWithinRoot(input, path.dirname(target.absolutePath));
const fileName = path.basename(target.absolutePath);
const watchedPath = path.join(directory, fileName);

return fileSystem.watch(directory).pipe(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Stream.filter(
(event) =>
event.path === fileName ||
event.path === watchedPath ||
path.resolve(directory, event.path) === watchedPath,
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
),
// 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: watchedPath,
operationPath: directory,
operation: "watch",
cause,
}),
),
);
}),
);

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

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