Skip to content

Commit

Permalink
Add node.js only parcel watcher watchman back-end (#9789)
Browse files Browse the repository at this point in the history
  • Loading branch information
yamadapc committed Aug 6, 2024
1 parent 6a8d5e9 commit fcce2ab
Show file tree
Hide file tree
Showing 12 changed files with 320 additions and 11 deletions.
38 changes: 38 additions & 0 deletions flow-libs/fb-watchman.js.flow
Original file line number Diff line number Diff line change
@@ -0,0 1,38 @@
// @flow

// Derived from TypeScript typings and source code from
// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/fb-watchman/index.d.ts

declare module 'fb-watchman' {
// Emit the responses to these when they get sent down to us
declare type UnilateralTags = "unilateralTags" | "log";

declare interface ClientOptions {
/**
* Absolute path to the watchman binary.
* If not provided, the Client locates the binary using the PATH specified
* by the node child_process's default env.
*/
watchmanBinaryPath?: string | void;
}

declare interface Capabilities {
optional: any[];
required: any[];
}

declare type doneCallback = (error?: Error | null, resp?: any) => any;

declare class Client extends events$EventEmitter {
constructor(options?: ClientOptions): this;
sendNextCommand(): void;
cancelCommands(why: string): void;
connect(): void;
command(args: any, done: doneCallback): void;
capabilityCheck(
caps: Capabilities,
done: doneCallback,
): void;
end(): void;
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 12,7 @@
],
"scripts": {
"build": "yarn build-bundles && cross-env NODE_ENV=production PARCEL_BUILD_ENV=production gulp",
"build-bundles": "rimraf --glob packages/*/*/lib && lerna run dev:prepare && cross-env NODE_ENV=production PARCEL_BUILD_ENV=production PARCEL_SELF_BUILD=true parcel build --no-cache packages/core/{fs,codeframe,package-manager,utils} packages/reporters/{cli,dev-server} packages/utils/{parcel-lsp,parcel-lsp-protocol}",
"build-bundles": "rimraf --glob packages/*/*/lib && lerna run dev:prepare && cross-env NODE_ENV=production PARCEL_BUILD_ENV=production PARCEL_SELF_BUILD=true parcel build --no-cache packages/core/{fs,codeframe,package-manager,utils} packages/reporters/{cli,dev-server} packages/utils/{parcel-lsp,parcel-lsp-protocol,parcel-watcher-watchman-js}",
"build-ts": "lerna run build-ts && lerna run check-ts",
"build-native": "node scripts/build-native.js",
"build-native-release": "node scripts/build-native.js --release",
Expand Down
3 changes: 0 additions & 3 deletions packages/core/core/test/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 55,6 @@ export const DEFAULT_OPTIONS: ParcelOptions = {
},
featureFlags: {
...DEFAULT_FEATURE_FLAGS,
exampleFeature: false,
parcelV3: false,
importRetry: false,
},
};

Expand Down
1 change: 1 addition & 0 deletions packages/core/feature-flags/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 8,7 @@ export type FeatureFlags = _FeatureFlags;
export const DEFAULT_FEATURE_FLAGS: FeatureFlags = {
exampleFeature: false,
parcelV3: false,
useWatchmanWatcher: false,
importRetry: false,
ownedResolverStructures: false,
};
Expand Down
4 changes: 4 additions & 0 deletions packages/core/feature-flags/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 7,10 @@ export type FeatureFlags = {|
* Rust backed requests
*/
parcelV3: boolean,
/**
* Use node.js implementation of @parcel/watcher watchman backend
*/
useWatchmanWatcher: boolean,
/**
* Configure runtime to enable retriable dynamic imports
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/core/fs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 29,7 @@
"@parcel/types-internal": false,
"@parcel/utils": false,
"@parcel/watcher": false,
"@parcel/watcher-watchman-js": false,
"@parcel/workers": false
}
},
Expand All @@ -39,6 40,7 @@
"@parcel/types-internal": false,
"@parcel/utils": false,
"@parcel/watcher": false,
"@parcel/watcher-watchman-js": false,
"@parcel/workers": false
}
}
Expand All @@ -48,13 50,15 @@
"check-ts": "tsc --noEmit index.d.ts"
},
"dependencies": {
"@parcel/feature-flags": "2.12.0",
"@parcel/rust": "2.12.0",
"@parcel/types-internal": "2.12.0",
"@parcel/utils": "2.12.0",
"@parcel/watcher": "^2.0.7",
"@parcel/workers": "2.12.0"
},
"devDependencies": {
"@parcel/watcher-watchman-js": "2.12.0",
"graceful-fs": "^4.2.4",
"ncp": "^2.0.0",
"nullthrows": "^1.1.1",
Expand Down
22 changes: 19 additions & 3 deletions packages/core/fs/src/NodeFS.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 21,7 @@ import {tmpdir} from 'os';
import {promisify} from 'util';
import {registerSerializableClass} from '@parcel/core';
import {hashFile} from '@parcel/utils';
import {getFeatureFlag} from '@parcel/feature-flags';
import watcher from '@parcel/watcher';
import packageJSON from '../package.json';

Expand All @@ -35,6 36,14 @@ const realpath = promisify(
);
const isPnP = process.versions.pnp != null;

function getWatchmanWatcher(): typeof watcher {
// This is here to trick parcel into ignoring this require...
const packageName = ['@parcel', 'watcher-watchman-js'].join('/');

// $FlowFixMe
return require(packageName);
}

export class NodeFS implements FileSystem {
readFile: any = promisify(fs.readFile);
copyFile: any = promisify(fs.copyFile);
Expand Down Expand Up @@ -64,6 73,12 @@ export class NodeFS implements FileSystem {
? (...args) => searchJS.findFirstFile(this, ...args)
: searchNative.findFirstFile;

watcher(): typeof watcher {
return getFeatureFlag('useWatchmanWatcher')
? getWatchmanWatcher()
: watcher;
}

createWriteStream(filePath: string, options: any): Writable {
// Make createWriteStream atomic
let tmpFilePath = getTempFilePath(filePath);
Expand Down Expand Up @@ -163,23 178,23 @@ export class NodeFS implements FileSystem {
fn: (err: ?Error, events: Array<Event>) => mixed,
opts: WatcherOptions,
): Promise<AsyncSubscription> {
return watcher.subscribe(dir, fn, opts);
return this.watcher().subscribe(dir, fn, opts);
}

getEventsSince(
dir: FilePath,
snapshot: FilePath,
opts: WatcherOptions,
): Promise<Array<Event>> {
return watcher.getEventsSince(dir, snapshot, opts);
return this.watcher().getEventsSince(dir, snapshot, opts);
}

async writeSnapshot(
dir: FilePath,
snapshot: FilePath,
opts: WatcherOptions,
): Promise<void> {
await watcher.writeSnapshot(dir, snapshot, opts);
await this.watcher().writeSnapshot(dir, snapshot, opts);
}

static deserialize(): NodeFS {
Expand Down Expand Up @@ -229,6 244,7 @@ try {
}

let useOsTmpDir;

function shouldUseOsTmpDir(filePath) {
if (useOsTmpDir != null) {
return useOsTmpDir;
Expand Down
7 changes: 7 additions & 0 deletions packages/utils/parcel-watcher-watchman-js/README.md
Original file line number Diff line number Diff line change
@@ -0,0 1,7 @@
# @parcel/watcher-watchman-js

This package acts as a drop-in replacements for `@parcel/watcher` but only for
watchman. It's a temporary workaround for some bugs and feature gaps with the
C watchman client.

It is intended to replace `@parcel/watcher` within node_modules.
13 changes: 13 additions & 0 deletions packages/utils/parcel-watcher-watchman-js/package.json
Original file line number Diff line number Diff line change
@@ -0,0 1,13 @@
{
"name": "@parcel/watcher-watchman-js",
"version": "2.12.0",
"main": "lib/index.js",
"source": "src/index.js",
"dependencies": {
"@parcel/utils": "2.12.0",
"fb-watchman": "^2.0.2"
},
"devDependencies": {
"@parcel/watcher": "^2.4.1"
}
}
8 changes: 8 additions & 0 deletions packages/utils/parcel-watcher-watchman-js/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 1,8 @@
import {ParcelWatcherWatchmanJS} from './wrapper';

const wrapper = new ParcelWatcherWatchmanJS();

export const writeSnapshot = wrapper.writeSnapshot.bind(wrapper);
export const getEventsSince = wrapper.getEventsSince.bind(wrapper);
export const subscribe = wrapper.subscribe.bind(wrapper);
export const unsubscribe = wrapper.unsubscribe.bind(wrapper);
177 changes: 177 additions & 0 deletions packages/utils/parcel-watcher-watchman-js/src/wrapper.js
Original file line number Diff line number Diff line change
@@ -0,0 1,177 @@
// @flow
import * as fs from 'fs';
import * as path from 'path';
import * as watchman from 'fb-watchman';
import {isGlob} from '@parcel/utils';
import type {
Options,
Event,
SubscribeCallback,
AsyncSubscription,
} from '@parcel/watcher';

type WatchmanArgs = any;
type FilePath = string;
type GlobPattern = string;

// Matches the Watcher API from "@parcel/watcher"
export interface Watcher {
getEventsSince(
dir: FilePath,
snapshot: FilePath,
opts?: Options,
): Promise<Array<Event>>;
subscribe(
dir: FilePath,
fn: SubscribeCallback,
opts?: Options,
): Promise<AsyncSubscription>;
unsubscribe(
dir: FilePath,
fn: SubscribeCallback,
opts?: Options,
): Promise<void>;
writeSnapshot(
dir: FilePath,
snapshot: FilePath,
opts?: Options,
): Promise<FilePath>;
}

export class ParcelWatcherWatchmanJS implements Watcher {
subscriptionName: string;
client: watchman.Client;

constructor() {
this.subscriptionName = 'parcel-watcher-subscription-' Date.now();
this.client = new watchman.Client();
}

commandAsync(args: any[]): Promise<any> {
return new Promise((resolve, reject) => {
const client = this.client;
// $FlowFixMe
client.command(args, (err: Error | null | undefined, response: any) => {
if (err) reject(err);
else resolve(response);
});
});
}

// Types should match @parcel/watcher/index.js.flow
async writeSnapshot(dir: string, snapshot: FilePath): Promise<string> {
const response = await this.commandAsync(['clock', dir]);
fs.writeFileSync(snapshot, response.clock, {
encoding: 'utf-8',
});
return response.clock;
}

async getEventsSince(
dir: string,
snapshot: FilePath,
opts?: Options,
): Promise<Event[]> {
const clock = fs.readFileSync(snapshot, {
encoding: 'utf-8',
});

const response = await this.commandAsync([
'query',
dir,
{
expression: this._createExpression(dir, opts?.ignore),
fields: ['name', 'mode', 'exists', 'new'],
since: clock,
},
]);

return (response.files || []).map((file: any) => ({
path: file.name,
type: file.new ? 'create' : file.exists ? 'update' : 'delete',
}));
}

_createExpression(
dir: string,
ignore?: Array<FilePath | GlobPattern>,
): WatchmanArgs {
const ignores = [
// Ignore the watchman cookie
['match', '.watchman-cookie-*'],
// Ignore directory changes as they are just noise
['type', 'd'],
];

if (ignore) {
const customIgnores = ignore?.map(filePathOrGlob => {
const relative = path.relative(dir, filePathOrGlob);

if (isGlob(filePathOrGlob)) {
return ['match', relative, 'wholename'];
}

// If pattern is not a glob, then assume it's a directory.
// Ignoring single files is not currently supported
return ['dirname', relative];
});

ignores.push(...customIgnores);
}

return ['not', ['anyof', ...ignores]];
}

async subscribe(
dir: string,
fn: SubscribeCallback,
opts?: Options,
): Promise<AsyncSubscription> {
const {subscriptionName} = this;
const {clock} = await this.commandAsync(['clock', dir]);

await this.commandAsync([
'subscribe',
dir,
subscriptionName,
{
// `defer` can be used here if you want to pause the
// notification stream until something has finished.
//
// https://facebook.github.io/watchman/docs/cmd/subscribe#defer
// defer: ['my-company-example'],
expression: this._createExpression(dir, opts?.ignore),
fields: ['name', 'mode', 'exists', 'new'],
since: clock,
},
]);

this.client.on('subscription', function (resp) {
if (!resp.files || resp.subscription !== subscriptionName) {
return;
}

fn(
null /* err */,
resp.files.map((file: any) => {
return {
path: path.join(dir, file.name),
type: file.new ? 'create' : file.exists ? 'update' : 'delete',
};
}),
);
});

const unsubscribe = async () => {
await this.commandAsync(['unsubscribe', dir, subscriptionName]);
};

return {
unsubscribe,
};
}

async unsubscribe(dir: string): Promise<void> {
await this.commandAsync(['unsubscribe', dir, this.subscriptionName]);
}
}
Loading

0 comments on commit fcce2ab

Please sign in to comment.