Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
149 changes: 149 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,149 @@ 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("follows a symlinked file to edits made through its target", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "real/config.json", '{ "value": "before" }\n');
yield* fileSystem
.symlink(path.join(cwd, "real", "config.json"), path.join(cwd, "alias.json"))
.pipe(Effect.orDie);

// Watching through the alias must see a write to the canonical path,
// which lives in a different directory under a different name.
//
// This discriminates on inotify platforms, where a watch on the alias's
// own directory would never hear about the target. It does not on
// macOS: FSEvents reports the alias entry as touched when its target is
// written, so this passes there even with the parent-only resolve.
const event = yield* awaitFirstChange(
cwd,
"alias.json",
writeTextFile(cwd, "real/config.json", '{ "value": "after" }\n'),
);

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

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",
);
}),
);
});
});
Loading
Loading