Skip to content
This repository has been archived by the owner on Mar 21, 2022. It is now read-only.

Commit

Permalink
Merge pull request #14 from gatsbyjs/feat/site-watcher
Browse files Browse the repository at this point in the history
feat: Auto-discover Gatsby sites
  • Loading branch information
ascorbic authored Aug 8, 2020
2 parents 84fcaa8 + 17d8013 commit 8bd5f53
Show file tree
Hide file tree
Showing 10 changed files with 429 additions and 65 deletions.
15 changes: 15 additions & 0 deletions app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
loadPackageJson,
hasGatsbyDependency,
} from "./utils"
import { watchSites, stopWatching } from "./site-watcher"

const dir = path.resolve(__dirname, `..`)

Expand All @@ -26,6 +27,8 @@ async function start(): Promise<void> {
const mb = menubar({
dir,
icon: path.resolve(dir, `assets`, `IconTemplate.png`),
// In prod we can preload the window. In develop we need to wait for Gatsby to load
preloadWindow: !process.env.GATSBY_DEVELOP_URL,
// If we're running develop we pass in a URL, otherwise use the one
// of the express server we just started
index: process.env.GATSBY_DEVELOP_URL || `http://localhost:${port}`,
Expand All @@ -47,8 +50,20 @@ async function start(): Promise<void> {
childPids.delete(payload)
})

ipcMain.on(`watch-sites`, (event) => {
watchSites((sites) => {
console.log(`Got sites`, sites)
event.sender.send(`sites-updated`, sites)
})
})

ipcMain.on(`unwatch-sites`, () => {
stopWatching()
})

app.on(`before-quit`, () => {
childPids.forEach((pid) => process.kill(pid))
stopWatching()
})

ipcMain.on(`quit-app`, () => {
Expand Down
190 changes: 190 additions & 0 deletions app/site-watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* Watches the Gatsby metadata files to find available sites
*/
import tmp from "tmp"
import path from "path"
import xdgBasedir from "xdg-basedir"
import fs from "fs-extra"
import chokidar from "chokidar"
import { debounce } from "./utils"

// TODO: move these to gatsby-core-utils

export interface ISiteMetadata {
sitePath: string
name?: string
pid?: number
lastRun?: number
}

export interface IServiceInfo {
port?: number
pid?: number
}

const configDir = path.join(
xdgBasedir.config || tmp.fileSync().name,
`gatsby`,
`sites`
)

async function getSiteInfo(file: string): Promise<ISiteMetadata> {
return fs.readJSON(path.join(configDir, file))
}

// Shallow merge of site metadata
async function mergeSiteInfo(
file: string,
info: Partial<ISiteMetadata>
): Promise<ISiteMetadata> {
const current = await getSiteInfo(file)
const newInfo = { ...current, ...info }
await fs.writeJSON(path.join(configDir, file), newInfo)
return newInfo
}

// Sort by last run
export function sortSites(
sites: Map<string, ISiteMetadata>
): Array<ISiteMetadata> {
return Array.from(sites.values()).sort(
(siteA, siteB) => (siteB.lastRun || 0) - (siteA.lastRun || 0)
)
}

export async function deleteSiteMetaData(metadataPath: string): Promise<void> {
console.log(`delete`, metadataPath)
await fs.unlink(path.join(configDir, metadataPath))
}

// Deletes metadata for missing sites
export async function cleanupDeletedSites(
siteList: Map<string, ISiteMetadata>
): Promise<void> {
await Promise.all(
[...siteList.entries()].map(async ([metadataPath, site]) => {
if (
!site.sitePath ||
!(await fs.pathExists(path.join(site.sitePath, `package.json`)))
) {
console.log(`deleting`, metadataPath, site.sitePath)
deleteSiteMetaData(metadataPath)
}
})
)
}

let metadataWatcher: chokidar.FSWatcher
let siteWatcher: chokidar.FSWatcher
let lockWatcher: chokidar.FSWatcher

export async function watchSites(
updateHandler: (siteList: Array<ISiteMetadata>) => void
): Promise<void> {
const sites = new Map<string, ISiteMetadata>()
const reverseLookup = new Map<string, string>()
const notify = debounce(updateHandler, 500)
const cleanup = debounce(cleanupDeletedSites, 10000)

await stopWatching()

metadataWatcher = chokidar.watch(`*/metadata.json`, { cwd: configDir })
lockWatcher = chokidar.watch(`*/developproxy.json.lock`, { cwd: configDir })
// This will watch sites' package.json files
siteWatcher = chokidar.watch([])

// Sends an update to the renderer
function update(sites: Map<string, ISiteMetadata>): void {
const siteArray = sortSites(sites)
notify(siteArray)
cleanup(sites)
}

siteWatcher.on(`unlink`, (pkgJsonPath) => {
console.log(`site deleted`, path)
const metadataPath = reverseLookup.get(pkgJsonPath)
if (metadataPath) {
deleteSiteMetaData(metadataPath)
}
})

siteWatcher.on(`change`, async (pkgJsonPath) => {
console.log(`packagejson changed`, pkgJsonPath)
const metadata = reverseLookup.get(pkgJsonPath)
if (!metadata) {
return
}
const siteInfo = sites.get(metadata)

const newPkgJson = await fs.readJSON(pkgJsonPath)

if (newPkgJson?.name && newPkgJson.name !== siteInfo?.name) {
console.log(`changing site name`)
mergeSiteInfo(metadata, { name: newPkgJson?.name })
}
})

metadataWatcher.on(`add`, async (metadataPath) => {
console.log({ metadataPath })
const json = await getSiteInfo(metadataPath)
if (json.name === `gatsby-desktop` || !json.sitePath) {
return
}
const sitePkgJsonPath = path.resolve(json.sitePath, `package.json`)
try {
const packageJson = await fs.readJSON(sitePkgJsonPath)

if (!packageJson) {
deleteSiteMetaData(metadataPath)
return
}
reverseLookup.set(sitePkgJsonPath, metadataPath)

if (!json.name) {
json.name = packageJson.name
}
siteWatcher.add(sitePkgJsonPath)
} catch (e) {
console.log(`Couldn't load site`, e, sitePkgJsonPath)
deleteSiteMetaData(metadataPath)
return
}
console.log(`added`, json)
sites.set(metadataPath, json)
update(sites)
})

async function metadataChanged(path: string): Promise<void> {
const json = await getSiteInfo(path)
console.log(`changed`, json)
const oldJson = JSON.stringify(sites.get(path) || {})
if (JSON.stringify(oldJson) === JSON.stringify(json)) {
return
}
sites.set(path, json)
update(sites)
}

metadataWatcher.on(`change`, metadataChanged)

metadataWatcher.on(`unlink`, async (path) => {
console.log(`deleted`, path)
sites.delete(path)
update(sites)
})

async function lockChanged(file: string): Promise<void> {
const metadataPath = path.join(path.dirname(file), `metadata.json`)
console.log(`lockfile changed`, file, `checking`, metadataPath)
metadataChanged(metadataPath)
}
// The lockfiles are actually directories
// lockWatcher.on(`addDir`, lockChanged)
lockWatcher.on(`unlinkDir`, lockChanged)
}

export async function stopWatching(): Promise<void> {
await metadataWatcher?.close()
await siteWatcher?.close()
await lockWatcher?.close()
}
12 changes: 12 additions & 0 deletions app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,15 @@ export function hasGatsbyDependency(packageJson: PackageJson): boolean {
const { dependencies, devDependencies } = packageJson
return !!(dependencies?.gatsby || devDependencies?.gatsby)
}

export const debounce = <F extends unknown[]>(
func: (...args: F) => void,
waitFor: number
): ((...args: F) => void) => {
let timeout: NodeJS.Timeout
const debounced = (...args: F): void => {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), waitFor)
}
return debounced
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
"@babel/runtime": "^7.10.5",
"@types/theme-ui": "^0.3.5",
"ansi-to-html": "^0.6.14",
"chokidar": "^3.4.1",
"core-js": "^3.6.5",
"detect-port": "^1.3.0",
"electron-store": "^6.0.0",
"execa": "^4.0.3",
"express": "^4.17.1",
"fix-path": "^3.0.0",
Expand All @@ -28,8 +30,10 @@
"serve-static": "^1.14.1",
"spawn-sync": "^2.0.0",
"theme-ui": "^0.4.0-rc.1",
"tmp": "^0.2.1",
"typeface-inter": "^3.12.0",
"typeface-roboto-mono": "^0.0.75"
"typeface-roboto-mono": "^0.0.75",
"xdg-basedir": "^4.0.0"
},
"devDependencies": {
"@types/detect-port": "^1.3.0",
Expand Down
4 changes: 2 additions & 2 deletions src/components/site-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ISiteInfo } from "../controllers/site"
import { Button, ButtonProps } from "gatsby-interface"

interface IProps extends ButtonProps {
onSelectSite: (siteInfo: ISiteInfo) => void
onSelectSite?: (siteInfo: ISiteInfo) => void
onSiteError: (message?: string) => void
}

Expand Down Expand Up @@ -32,7 +32,7 @@ export function SiteBrowser({
return
}

onSelectSite(result)
onSelectSite?.(result)
}, [onSelectSite])

return <Button size="S" textVariant="BRAND" onClick={browse} {...props} />
Expand Down
5 changes: 1 addition & 4 deletions src/components/site-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,7 @@ export function SitePreview({ site }: PropsWithChildren<IProps>): JSX.Element {
}}
>
<Flex css={{ justifyContent: `space-between`, minHeight: `24px` }}>
<SiteName
siteName={site?.packageJson?.name ?? `Unnamed site`}
status={status}
/>
<SiteName siteName={site?.name ?? `Unnamed site`} status={status} />
{!running ? (
<StartButton label={`Start`} />
) : (
Expand Down
Loading

0 comments on commit 8bd5f53

Please sign in to comment.