Skip to content

Commit

Permalink
refactor: removed "node-watch" dependency in favor of custom implemen…
Browse files Browse the repository at this point in the history
…tation

Yes, I understand the trade offs and that this means more maintenance. However:

- node-watch relies on the temp directory, which may not always work well on all hosting platforms, such as W3Schools Spaces
- This helps reduce package size further
- We don't need all the features node-watch has
- This implementation is similar in that we recursively support Linux and symlinks
- The API can be altered to our liking
- Easier to build in new features without complex wrappers
  • Loading branch information
Pkmmte committed May 28, 2023
1 parent dfe6f3e commit 2da1fba
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 13 deletions.
5 changes: 5 additions & 0 deletions .changeset/early-lamps-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@roboplay/robo.js': patch
---

refactor: removed "node-watch" dependency in favor of custom implementation
1 change: 0 additions & 1 deletion packages/robo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
"chalk": "5.2.0",
"commander": "10.0.1",
"dotenv": "16.0.3",
"node-watch": "0.7.3",
"p-limit": "4.0.0",
"tar": "^6.1.13"
},
Expand Down
9 changes: 4 additions & 5 deletions packages/robo/src/cli/commands/build/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { logger } from '../../../core/logger.js'
import { performance } from 'node:perf_hooks'
import { getProjectSize, printBuildSummary } from '../../utils/build-summary.js'
import { buildInSeparateProcess } from '../dev.js'
import nodeWatch from 'node-watch'
import fs from 'node:fs/promises'
import path from 'node:path'
import url from 'node:url'
import { loadConfigPath } from '../../../core/config.js'
import chalk from 'chalk'
import { hasProperties } from '../../utils/utils.js'
import Watcher from '../../utils/watcher.js'

const command = new Command('plugin')
.description('Builds your plugin for distribution.')
Expand Down Expand Up @@ -95,16 +95,15 @@ async function pluginAction() {
watchedPaths.push(configRelative)
}

const watcher = nodeWatch(watchedPaths, {
recursive: true,
filter: (f) => !/(^|[/\\])\.(?!config)[^.]/.test(f) // ignore dotfiles except .config and directories
const watcher = new Watcher(watchedPaths, {
exclude: ['node_modules', '.git']
})

// Watch while preventing multiple restarts from happening at the same time
let isUpdating = false
logger.ready(`Watching for changes...`)

watcher.on('change', async (event: string, path: string) => {
watcher.start(async (event: string, path: string) => {
logger.debug(`Watcher event: ${event}`)
if (isUpdating) {
return logger.debug(`Already building, skipping...`)
Expand Down
9 changes: 4 additions & 5 deletions packages/robo/src/cli/commands/dev.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Command } from 'commander'
import nodeWatch from 'node-watch'
import { run } from '../utils/run.js'
import { spawn, type ChildProcess } from 'child_process'
import { logger } from '../../core/logger.js'
Expand All @@ -10,6 +9,7 @@ import { IS_WINDOWS, cmd, filterExistingPaths, getPkgManager, getWatchedPlugins,
import path from 'node:path'
import url from 'node:url'
import { getStateSave } from '../../core/state.js'
import Watcher from '../utils/watcher.js'
import type { Config, RoboMessage } from '../../types/index.js'

const command = new Command('dev')
Expand Down Expand Up @@ -78,13 +78,12 @@ async function devAction(options: DevCommandOptions) {

// Watch while preventing multiple restarts from happening at the same time
logger.debug(`Watching:`, watchedPaths)
const watcher = nodeWatch(watchedPaths, {
recursive: true,
filter: (f) => !/(^|[/\\])\.(?!config)[^.]/.test(f) // ignore dotfiles except .config and directories
const watcher = new Watcher(watchedPaths, {
exclude: ['node_modules', '.git']
})
let isUpdating = false

watcher.on('change', async (event: string, path: string) => {
watcher.start(async (event: string, path: string) => {
logger.debug(`Watcher event: ${event}`)
if (isUpdating) {
return logger.debug(`Already updating, skipping...`)
Expand Down
145 changes: 145 additions & 0 deletions packages/robo/src/cli/utils/watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { promises as fs, watch, FSWatcher } from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import { logger } from '../../core/logger.js'
import { hasProperties } from './utils.js'

// Defining the possible values for file changes.
type ChangeType = 'added' | 'removed' | 'changed'

// The interface for options parameter allowing to exclude certain directories.
interface Options {
exclude?: string[]
}

// The interface for the callback function.
interface Callback {
(changeType: ChangeType, filename: string, dirPath: string): void
}

// Watcher class will monitor files and directories for changes.
export default class Watcher {
// Map to keep track of FSWatcher instances for each watched file.
private watchers: Map<string, FSWatcher> = new Map()
// Map to keep track of last modification date for each watched file.
private watchedFiles: Map<string, Date> = new Map()
// A flag to avoid triggering callbacks on the initial setup.
private isFirstTime = true

// Initialize with paths to watch and options.
constructor(private paths: string[], private options: Options) {}

// Start the watcher. Files are read, and callbacks are set up.
async start(callback: Callback) {
await Promise.all(
this.paths.map((filePath) => {
return this.watchPath(filePath, this.options, callback)
})
)
// After setting up, mark this as no longer the first time.
this.isFirstTime = false
}

// Stop the watcher. Close all FSWatcher instances and clear the map.
stop() {
this.watchers.forEach((watcher) => {
watcher.close()
})
this.watchers.clear()
}

// Retry function that repeats a promise-returning function up to a number of times.
private async retry<T>(fn: () => Promise<T>, retries = 2): Promise<T> {
try {
return await fn()
} catch (err) {
if (retries === 0) {
throw err
}
await new Promise((resolve) => setTimeout(resolve, 1000))
return await this.retry(fn, retries - 1)
}
}

// Set up watching a path. Recursively applies to directories, unless excluded by options.
private async watchPath(targetPath: string, options: Options, callback: Callback) {
const stats = await fs.lstat(targetPath)

if (stats.isFile()) {
// If a file, start watching the file.
this.watchFile(targetPath, callback)
// Fire the callback if not first time.
if (!this.isFirstTime) {
callback('added', path.basename(targetPath), path.dirname(targetPath))
}
} else if (
stats.isDirectory() &&
(!options.exclude || !options.exclude.includes(path.basename(targetPath)))
) {
// If a directory, read all the contents and watch them.
const files = await this.retry(() => fs.readdir(targetPath, { withFileTypes: true }))

for (const file of files) {
const filePath = path.join(targetPath, file.name)
await this.watchPath(filePath, options, callback)
}

// Special handling for Linux: Also watch the directory for new files.
if (os.platform() === 'linux') {
const watcher = watch(targetPath, async (event, filename) => {
if (filename) {
const newFilePath = path.join(targetPath, filename)
const stats = await fs.lstat(newFilePath)
if (stats.isDirectory() && !this.watchers.has(newFilePath)) {
await this.watchPath(newFilePath, options, callback)
}
}
})

this.watchers.set(targetPath, watcher)
}
} else if (stats.isSymbolicLink()) {
// If a symlink, resolve the real path and watch that.
const realPath = await fs.realpath(targetPath)
await this.watchPath(realPath, options, callback)
}
}

// Watch a single file. Set up the FSWatcher and callback for changes.
private watchFile(filePath: string, callback: Callback) {
const watcher = watch(filePath, async (event, filename) => {
if (event === 'rename') {
// If the file is renamed, try to access it. If it exists, it was added. Otherwise, removed.
try {
await this.retry(() => fs.access(filePath, fs.constants.F_OK))
if (!this.isFirstTime) {
callback('added', filename, path.dirname(filePath))
}
} catch (e) {
// If the file is not found, it was removed.
if (hasProperties<{ code: unknown }>(e, ['code']) && e.code === 'ENOENT') {
const watcher = this.watchers.get(filePath)
if (watcher) {
callback('removed', filename, path.dirname(filePath))
watcher.close()
this.watchers.delete(filePath)
}
} else {
logger.error(`Unable to access file: ${filePath}`)
}
}
} else if (event === 'change') {
// If the file changed, check the modification time and trigger the callback if it's a new change.
const stat = await fs.lstat(filePath)
if (this.watchedFiles.get(filePath)?.getTime() !== stat.mtime.getTime()) {
this.watchedFiles.set(filePath, stat.mtime)
if (!this.isFirstTime) {
callback('changed', filename, path.dirname(filePath))
}
}
}
})

this.watchers.set(filePath, watcher)
}
}
3 changes: 1 addition & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 comment on commit 2da1fba

@vercel
Copy link

@vercel vercel bot commented on 2da1fba May 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.