New server event refreshFileEvent and reliable watchFilesFromArgs
MODIFY
packages/@romejs/core/integrationTestHelpers.ts
packages/@romejs/core/integrationTestHelpers.ts
@@ -100,10 +100,10 @@ export function createIntegrationTest(
}
// Mock and capture stdout
- let console = "";
+ let terminalOutput = "";
const stdout: Stdout = new stream.Writable({
write(chunk, encoding, callback) {
- console += chunk;
+ terminalOutput += chunk;
callback();
},
});
@@ -168,7 +168,7 @@ export function createIntegrationTest(
await client.end();
// Console
- t.namedSnapshot("console", console);
+ t.namedSnapshot("console", terminalOutput);
// Logs
//t.namedSnapshot("logs", logs);

MODIFY
packages/@romejs/core/server/Server.ts
packages/@romejs/core/server/Server.ts
@@ -57,6 +57,8 @@ import setupGlobalErrorHandlers from "../common/utils/setupGlobalErrorHandlers";
import {UserConfig, loadUserConfig} from "../common/userConfig";
import {
AbsoluteFilePath,
+ AbsoluteFilePathMap,
+ AbsoluteFilePathSet,
createAbsoluteFilePath,
createUnknownFilePath,
} from "@romejs/path";
@@ -67,6 +69,7 @@ import VirtualModules from "./fs/VirtualModules";
import {DiagnosticsProcessorOptions} from "@romejs/diagnostics/DiagnosticsProcessor";
import {toKebabCase} from "@romejs/string-utils";
import Locker from "../common/utils/Locker";
+import {writeFile} from "@romejs/fs";
const STDOUT_MAX_CHUNK_LENGTH = 100_000;
@@ -156,11 +159,6 @@ export default class Server {
this.userConfig =
opts.userConfig === undefined ? loadUserConfig() : opts.userConfig;
- this.fileChangeEvent = new Event({
- name: "Server.fileChange",
- onError: this.onFatalErrorBound,
- });
-
this.clientStartEvent = new Event({
name: "Server.clientStart",
onError: this.onFatalErrorBound,
@@ -171,6 +169,11 @@ export default class Server {
onError: this.onFatalErrorBound,
});
+ this.refreshFileEvent = new Event({
+ name: "Server.refreshFile",
+ onError: this.onFatalErrorBound,
+ });
+
this.endEvent = new Event({
name: "Server.end",
onError: this.onFatalErrorBound,
@@ -238,14 +241,6 @@ export default class Server {
this.resolver = new Resolver(this);
this.cache = new Cache(this);
- this.memoryFs.deletedFileEvent.subscribe((path) => {
- return this.handleFileDelete(path);
- });
-
- this.memoryFs.changedFileEvent.subscribe(({path}) => {
- return this.handleFileChange(path);
- });
-
this.warnedCacheClients = new WeakSet();
this.clientIdCounter = 0;
@@ -258,9 +253,15 @@ export default class Server {
requestStartEvent: Event<ServerRequest, void>;
clientStartEvent: Event<ServerClient, void>;
- fileChangeEvent: Event<AbsoluteFilePath, void>;
endEvent: Event<void, void>;
+ // Event for when a file needs to be "refreshed". This could include:
+ // - Deleted
+ // - Created
+ // - Modified
+ // - Buffer updated
+ refreshFileEvent: Event<AbsoluteFilePath, void>;
+
onFatalErrorBound: (err: Error) => void;
requestRunningCounter: number;
@@ -387,6 +388,47 @@ export default class Server {
});
}
+ // Utility to write a list of files and wait for all refresh events to be emitted
+ // parameter designed to handle `AbsoluteFileMap#entries`
+ async writeFiles(files: AbsoluteFilePathMap<string | Buffer>) {
+ const paths: AbsoluteFilePathSet = new AbsoluteFilePathSet(files.keys());
+
+ if (files.size === 0) {
+ this.logger.info("[Server] Writing no files");
+ return;
+ } else {
+ this.logger.info("[Server] Writing files");
+ this.logger.list(Array.from(paths, (path) => path.toMarkup()));
+ }
+
+ let teardown: undefined | (() => void);
+
+ const waitRefresh = new Promise((resolve) => {
+ const sub = this.refreshFileEvent.subscribe((path) => {
+ paths.delete(path);
+ if (paths.size === 0) {
+ resolve();
+ }
+ });
+
+ teardown = () => {
+ sub.unsubscribe();
+ };
+ });
+
+ try {
+ for (const [path, content] of files) {
+ await writeFile(path, content);
+ }
+
+ await waitRefresh;
+ } finally {
+ if (teardown !== undefined) {
+ teardown();
+ }
+ }
+ }
+
async init() {
this.maybeSetupGlobalErrorHandlers();
this.memoryFs.init();
@@ -635,16 +677,6 @@ export default class Server {
return client;
}
- async handleFileDelete(path: AbsoluteFilePath) {
- this.logger.info(`[Server] File delete:`, path.toMarkup());
- this.fileChangeEvent.send(path);
- }
-
- async handleFileChange(path: AbsoluteFilePath) {
- this.logger.info(`[Server] File change:`, path.toMarkup());
- this.fileChangeEvent.send(path);
- }
-
async handleRequestStart(req: ServerRequest) {
this.logger.info(`[Server] Handling CLI request:`, req.query);

MODIFY
packages/@romejs/core/server/ServerRequest.ts
packages/@romejs/core/server/ServerRequest.ts
@@ -589,37 +589,37 @@ export default class ServerRequest {
opts.tryAlternateArg,
);
- let pendingEvictPaths: AbsoluteFilePathSet = new AbsoluteFilePathSet();
- let pendingEvictProjects: Set<ProjectDefinition> = new Set();
let timeout: undefined | NodeJS.Timeout;
- let changesWhileRunningCallback = false;
let runningCallback = false;
let unsubscribed = false;
+ let pendingResult: ServerRequestGetFilesResult = {
+ paths: new AbsoluteFilePathSet(),
+ projects: new Set(),
+ };
+
async function flush(initial: boolean) {
if (unsubscribed) {
return;
}
- if (!initial && pendingEvictPaths.size === 0) {
+ if (!initial && pendingResult.paths.size === 0) {
return;
}
timeout = undefined;
- const result: ServerRequestGetFilesResult = {
- paths: pendingEvictPaths,
- projects: pendingEvictProjects,
+ const result = pendingResult;
+ pendingResult = {
+ paths: new AbsoluteFilePathSet(),
+ projects: new Set(),
};
- pendingEvictPaths = new AbsoluteFilePathSet();
- pendingEvictProjects = new Set();
runningCallback = true;
await callback(result, initial);
runningCallback = false;
- if (changesWhileRunningCallback) {
- changesWhileRunningCallback = false;
+ if (pendingResult.paths.size > 0) {
await flush(false);
}
}
@@ -638,28 +638,21 @@ export default class ServerRequest {
const project = this.server.projectManager.findProjectExisting(path);
if (project !== undefined) {
- pendingEvictProjects.add(project);
+ pendingResult.projects.add(project);
}
- pendingEvictPaths.add(path);
+ pendingResult.paths.add(path);
// Buffer up evicted paths
- if (runningCallback) {
- changesWhileRunningCallback = true;
- } else if (timeout === undefined) {
+ if (!runningCallback && timeout === undefined) {
timeout = setTimeout(() => flush(false), 100);
}
};
- // Subscribe to evictions and file changes. This can cause double emits but we dedupe them with AbsoluteFilePathSet. An updated buffer dispatches a fileChangeEvent but NOT an evictEvent. An evictEvent is dispatched for all files in a project when the project config is changed but does NOT dispatch evictEvent.
- const evictSubscription = this.server.fileAllocator.evictEvent.subscribe(
- onChange,
- );
- const fileChangeEvent = this.server.fileChangeEvent.subscribe(onChange);
+ const refreshFileEvent = this.server.refreshFileEvent.subscribe(onChange);
const sub = mergeEventSubscriptions([
- evictSubscription,
- fileChangeEvent,
+ refreshFileEvent,
{
unsubscribe() {
unsubscribed = true;
@@ -675,9 +668,7 @@ export default class ServerRequest {
});
// Flush initial
- const initial = await this.getFilesFromArgs(opts);
- pendingEvictPaths = initial.paths;
- pendingEvictProjects = initial.projects;
+ pendingResult = await this.getFilesFromArgs(opts);
await flush(true);
return sub;
@@ -912,7 +903,7 @@ export default class ServerRequest {
path,
(bridge, file) => bridge.updateBuffer.call({file, content}),
);
- this.server.fileChangeEvent.send(path);
+ this.server.refreshFileEvent.send(path);
}
async requestWorkerClearBuffer(path: AbsoluteFilePath): Promise<void> {
@@ -924,6 +915,7 @@ export default class ServerRequest {
(bridge, file) => bridge.clearBuffer.call({file}),
);
await this.server.fileAllocator.evict(path);
+ this.server.refreshFileEvent.send(path);
}
async requestWorkerParse(

MODIFY
packages/@romejs/core/server/fs/MemoryFileSystem.ts
packages/@romejs/core/server/fs/MemoryFileSystem.ts
@@ -400,7 +400,9 @@ export default class MemoryFileSystem {
}
// Wait for any subscribers that might need the file's stats
+ this.server.logger.info(`[MemoryFileSystem] File deleted:`, path.toMarkup());
await this.deletedFileEvent.call(path);
+ this.server.refreshFileEvent.send(path);
// Remove from 'all possible caches
this.files.delete(path);
@@ -436,6 +438,11 @@ export default class MemoryFileSystem {
const changed = await this.addFile(path, stats, opts);
if (changed) {
const newStats: Stats = this.getFileStatsAssert(path);
+ this.server.logger.info(
+ `[MemoryFileSystem] File change:`,
+ path.toMarkup(),
+ );
+ this.server.refreshFileEvent.send(path);
this.changedFileEvent.send({path, oldStats, newStats});
}
return changed;

MODIFY
packages/@romejs/core/server/linter/Linter.ts
packages/@romejs/core/server/linter/Linter.ts
@@ -28,7 +28,6 @@ import {
import {markup} from "@romejs/string-markup";
import WorkerQueue from "../WorkerQueue";
import {Dict} from "@romejs/typescript-helpers";
-import {writeFile} from "@romejs/fs";
import {FileNotFound} from "@romejs/core/common/FileNotFound";
type LintWatchChanges = Array<{
@@ -254,14 +253,7 @@ class LintRunner {
// Run through save queue
if (saveQueue.size > 0) {
- const {logger} = server;
- logger.info("[Linter] Saving files");
- logger.list(Array.from(saveQueue.keys(), (path) => path.toMarkup()));
-
- // TODO maybe this could be parallelized?
- for (const [path, content] of saveQueue) {
- await writeFile(path, content);
- }
+ await this.server.writeFiles(saveQueue);
}
return {savedCount: saveQueue.size};

MODIFY
packages/@romejs/path/collections.ts
packages/@romejs/path/collections.ts
@@ -79,7 +79,7 @@ class FilePathMap<FilePath extends UnknownFilePath, Value> {
}
class FilePathSet<FilePath extends UnknownFilePath> {
- constructor(entries?: Array<FilePath>) {
+ constructor(entries?: Iterable<FilePath>) {
this.map = new FilePathMap();
this.size = 0;

New server event refreshFileEvent and reliable watchFilesFromArgs