handoff-app
Version:
Automated documentation toolchain for building client side documentation from figma
655 lines (582 loc) • 21.6 kB
text/typescript
import chokidar from 'chokidar';
import spawn from 'cross-spawn';
import fs from 'fs-extra';
import path from 'path';
import { WebSocket } from 'ws';
import Handoff from '.';
import { getClientConfig } from './config';
import { buildComponents } from './pipeline';
import processComponents, { ComponentSegment } from './transformers/preview/component/builder';
import { ComponentListObject } from './transformers/preview/types';
import { Logger } from './utils/logger';
interface HandoffWebSocket extends WebSocket {
isAlive: boolean;
}
interface WatcherState {
debounce: boolean;
runtimeComponentsWatcher: chokidar.FSWatcher | null;
runtimeConfigurationWatcher: chokidar.FSWatcher | null;
}
/**
* Creates a WebSocket server that broadcasts messages to connected clients.
* Designed for development mode to help with hot-reloading.
*
* @param port - Optional port number for the WebSocket server; defaults to 3001.
* @returns A function that accepts a message string and broadcasts it to all connected clients.
*/
const createWebSocketServer = async (port: number = 3001) => {
const wss = new WebSocket.Server({ port });
// Heartbeat function to mark a connection as alive.
const heartbeat = function (this: HandoffWebSocket) {
this.isAlive = true;
};
// Setup a new connection
wss.on('connection', (ws) => {
const extWs = ws as HandoffWebSocket;
extWs.isAlive = true;
extWs.send(JSON.stringify({ type: 'WELCOME' }));
extWs.on('error', (error) => Logger.error('WebSocket error:', error));
extWs.on('pong', heartbeat);
});
// Periodically ping clients to ensure they are still connected
const pingInterval = setInterval(() => {
wss.clients.forEach((client) => {
const extWs = client as HandoffWebSocket;
if (!extWs.isAlive) {
Logger.warn('Terminating inactive client');
return client.terminate();
}
extWs.isAlive = false;
client.ping();
});
}, 30000);
// Clean up the interval when the server closes
wss.on('close', () => {
clearInterval(pingInterval);
});
Logger.success(`WebSocket server listening on ws://localhost:${port}`);
// Return a function to broadcast a message to all connected clients
return (message: string) => {
Logger.success(`Broadcasting message to ${wss.clients.size} client(s)`);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
};
};
/**
* Gets the working public directory path for a given handoff instance
* Checks for both project-specific and default public directories
*
* @param handoff - The handoff instance containing working path and figma project configuration
* @returns The resolved path to the public directory if it exists, null otherwise
*/
const getWorkingPublicPath = (handoff: Handoff): string | null => {
const paths = [path.resolve(handoff.workingPath, `public-${handoff.getProjectId()}`), path.resolve(handoff.workingPath, `public`)];
for (const path of paths) {
if (fs.existsSync(path)) {
return path;
}
}
return null;
};
/**
* Gets the application path for a given handoff instance
* @param handoff - The handoff instance containing module path and figma project configuration
* @returns The resolved path to the application directory
*/
const getAppPath = (handoff: Handoff): string => {
return path.resolve(handoff.modulePath, '.handoff', `${handoff.getProjectId()}`);
};
/**
* Copy the public dir from the working dir to the module dir
* @param handoff
*/
const syncPublicFiles = async (handoff: Handoff): Promise<void> => {
const appPath = getAppPath(handoff);
const workingPublicPath = getWorkingPublicPath(handoff);
if (workingPublicPath) {
await fs.copy(workingPublicPath, path.resolve(appPath, 'public'), {
overwrite: true,
});
}
};
/**
* Performs cleanup of the application directory by removing the existing app directory if it exists.
* This is typically used before rebuilding the application to ensure a clean state.
*
* @param handoff - The Handoff instance containing configuration and working paths
* @returns Promise that resolves when cleanup is complete
*/
const cleanupAppDirectory = async (handoff: Handoff): Promise<void> => {
const appPath = getAppPath(handoff);
// Clean project app dir
if (fs.existsSync(appPath)) {
await fs.remove(appPath);
}
};
/**
* Publishes the tokens API files to the public directory.
*
* @param handoff - The Handoff instance
*/
const generateTokensApi = async (handoff: Handoff) => {
const apiPath = path.resolve(path.join(handoff.workingPath, 'public/api'));
await fs.ensureDir(apiPath);
const tokens = await handoff.getDocumentationObject();
// Early return if no tokens
if (!tokens) {
// Write empty tokens.json for API consistency
await fs.writeJson(path.join(apiPath, 'tokens.json'), {}, { spaces: 2 });
return;
}
await fs.writeJson(path.join(apiPath, 'tokens.json'), tokens, { spaces: 2 });
const tokensDir = path.join(apiPath, 'tokens');
await fs.ensureDir(tokensDir);
// Only iterate if tokens has properties
if (tokens && typeof tokens === 'object') {
const promises: Promise<void>[] = [];
for (const type in tokens) {
if (type === 'timestamp' || !tokens[type] || typeof tokens[type] !== 'object') continue;
for (const group in tokens[type]) {
if (tokens[type][group]) {
promises.push(fs.writeJson(path.join(tokensDir, `${group}.json`), tokens[type][group], { spaces: 2 }));
}
}
}
await Promise.all(promises);
}
};
/**
* Prepares the project application by copying source files and configuring Next.js.
*
* @param handoff - The Handoff instance
* @returns The path to the prepared application directory
*/
const initializeProjectApp = async (handoff: Handoff): Promise<string> => {
const srcPath = path.resolve(handoff.modulePath, 'src', 'app');
const appPath = getAppPath(handoff);
// Publish tokens API
await generateTokensApi(handoff);
// Prepare project app dir
await fs.ensureDir(appPath);
await fs.copy(srcPath, appPath, { overwrite: true });
await syncPublicFiles(handoff);
// Copy custom theme CSS if it exists in the user's project
const customThemePath = path.resolve(handoff.workingPath, 'theme.css');
if (fs.existsSync(customThemePath)) {
const destPath = path.resolve(appPath, 'css', 'theme.css');
await fs.copy(customThemePath, destPath, { overwrite: true });
Logger.success(`Custom theme.css loaded`);
}
// Prepare project app configuration
// Warning: Regex replacement is fragile and depends on exact formatting in next.config.mjs
const handoffProjectId = handoff.getProjectId();
const handoffAppBasePath = handoff.config.app.base_path ?? '';
const handoffWorkingPath = path.resolve(handoff.workingPath);
const handoffModulePath = path.resolve(handoff.modulePath);
const handoffExportPath = path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.getProjectId());
const nextConfigPath = path.resolve(appPath, 'next.config.mjs');
const handoffUseReferences = handoff.config.useVariables ?? false;
const handoffWebsocketPort = handoff.config.app.ports?.websocket ?? 3001;
const nextConfigContent = (await fs.readFile(nextConfigPath, 'utf-8'))
.replace(/basePath:\s+\'\'/g, `basePath: '${handoffAppBasePath}'`)
.replace(/HANDOFF_PROJECT_ID:\s+\'\'/g, `HANDOFF_PROJECT_ID: '${handoffProjectId}'`)
.replace(/HANDOFF_APP_BASE_PATH:\s+\'\'/g, `HANDOFF_APP_BASE_PATH: '${handoffAppBasePath}'`)
.replace(/HANDOFF_WORKING_PATH:\s+\'\'/g, `HANDOFF_WORKING_PATH: '${handoffWorkingPath}'`)
.replace(/HANDOFF_MODULE_PATH:\s+\'\'/g, `HANDOFF_MODULE_PATH: '${handoffModulePath}'`)
.replace(/HANDOFF_EXPORT_PATH:\s+\'\'/g, `HANDOFF_EXPORT_PATH: '${handoffExportPath}'`)
.replace(/HANDOFF_WEBSOCKET_PORT:\s+\'\'/g, `HANDOFF_WEBSOCKET_PORT: '${handoffWebsocketPort}'`)
.replace(/%HANDOFF_MODULE_PATH%/g, handoffModulePath);
await fs.writeFile(nextConfigPath, nextConfigContent);
return appPath;
};
/**
* Persists the client config to a JSON file.
*
* @param handoff - The Handoff instance
*/
const persistClientConfig = async (handoff: Handoff) => {
const appPath = getAppPath(handoff);
const destination = path.resolve(appPath, 'client.config.json');
// Ensure directory exists
await fs.ensureDir(appPath);
await fs.writeJson(destination, { config: getClientConfig(handoff) }, { spaces: 2 });
};
/**
* Watches the working public directory for changes and updates the app.
*
* @param handoff - The Handoff instance
* @param wss - The WebSocket broadcaster
* @param state - The shared watcher state
* @param chokidarConfig - Configuration for chokidar
*/
const watchPublicDirectory = (handoff: Handoff, wss: (msg: string) => void, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => {
if (fs.existsSync(path.resolve(handoff.workingPath, 'public'))) {
chokidar.watch(path.resolve(handoff.workingPath, 'public'), chokidarConfig).on('all', async (event, path) => {
switch (event) {
case 'add':
case 'change':
case 'unlink':
if (!state.debounce) {
state.debounce = true;
try {
Logger.warn('Public directory changed. Handoff will ingest the new data...');
await syncPublicFiles(handoff);
wss(JSON.stringify({ type: 'reload' }));
} catch (e) {
Logger.error('Error syncing public directory:', e);
} finally {
state.debounce = false;
}
}
break;
}
});
}
};
/**
* Watches the application source code for changes.
*
* @param handoff - The Handoff instance
*/
const watchAppSource = (handoff: Handoff) => {
chokidar
.watch(path.resolve(handoff.modulePath, 'src', 'app'), {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
ignoreInitial: true,
})
.on('all', async (event, path) => {
switch (event) {
case 'add':
case 'change':
case 'unlink':
try {
await initializeProjectApp(handoff);
} catch (e) {
Logger.error('Error initializing project app:', e);
}
break;
}
});
};
/**
* Watches the user's pages directory for changes.
*
* @param handoff - The Handoff instance
* @param chokidarConfig - Configuration for chokidar
*/
const watchPages = (handoff: Handoff, chokidarConfig: chokidar.WatchOptions) => {
if (fs.existsSync(path.resolve(handoff.workingPath, 'pages'))) {
chokidar.watch(path.resolve(handoff.workingPath, 'pages'), chokidarConfig).on('all', async (event, path) => {
switch (event) {
case 'add':
case 'change':
case 'unlink':
try {
Logger.warn(`Doc page ${event}ed. Please reload browser to see changes...`);
Logger.debug(`Path: ${path}`);
} catch (e) {
Logger.error('Error watching pages:', e);
}
break;
}
});
}
};
/**
* Watches the SCSS entry point for changes.
*
* @param handoff - The Handoff instance
* @param state - The shared watcher state
* @param chokidarConfig - Configuration for chokidar
*/
const watchScss = async (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => {
if (handoff.runtimeConfig?.entries?.scss && fs.existsSync(handoff.runtimeConfig?.entries?.scss)) {
const stat = await fs.stat(handoff.runtimeConfig.entries.scss);
chokidar
.watch(stat.isDirectory() ? handoff.runtimeConfig.entries.scss : path.dirname(handoff.runtimeConfig.entries.scss), chokidarConfig)
.on('all', async (event, file) => {
switch (event) {
case 'add':
case 'change':
case 'unlink':
if (!state.debounce) {
state.debounce = true;
try {
await handoff.getSharedStyles();
} catch (e) {
Logger.error('Error processing shared styles:', e);
} finally {
state.debounce = false;
}
}
}
});
}
};
/**
* Maps configuration entry types to component segments.
*/
const mapEntryTypeToSegment = (type: keyof ComponentListObject['entries']): ComponentSegment | undefined => {
return {
js: ComponentSegment.JavaScript,
scss: ComponentSegment.Style,
template: ComponentSegment.Previews,
templates: ComponentSegment.Previews,
}[type];
};
/**
* Gets the paths of runtime components to watch.
*
* @param handoff - The Handoff instance
* @returns A Map of paths to watch and their entry types
*/
const getRuntimeComponentsPathsToWatch = (handoff: Handoff) => {
const result: Map<string, keyof ComponentListObject['entries']> = new Map();
for (const runtimeComponentId of Object.keys(handoff.runtimeConfig?.entries.components ?? {})) {
const runtimeComponent = handoff.runtimeConfig.entries.components[runtimeComponentId];
for (const [runtimeComponentEntryType, runtimeComponentEntryPath] of Object.entries(runtimeComponent.entries ?? {})) {
const normalizedComponentEntryPath = runtimeComponentEntryPath as string;
if (fs.existsSync(normalizedComponentEntryPath)) {
const entryType = runtimeComponentEntryType as keyof ComponentListObject['entries'];
if (fs.statSync(normalizedComponentEntryPath).isFile()) {
result.set(path.resolve(normalizedComponentEntryPath), entryType);
} else {
result.set(normalizedComponentEntryPath, entryType);
}
}
}
}
return result;
};
/**
* Watches runtime components for changes.
*
* @param handoff - The Handoff instance
* @param state - The shared watcher state
* @param runtimeComponentPathsToWatch - Map of paths to watch
*/
const watchRuntimeComponents = (
handoff: Handoff,
state: WatcherState,
runtimeComponentPathsToWatch: Map<string, keyof ComponentListObject['entries']>
) => {
if (state.runtimeComponentsWatcher) {
state.runtimeComponentsWatcher.close();
}
if (runtimeComponentPathsToWatch.size > 0) {
const pathsToWatch = Array.from(runtimeComponentPathsToWatch.keys());
state.runtimeComponentsWatcher = chokidar.watch(pathsToWatch, {
ignoreInitial: true,
});
state.runtimeComponentsWatcher.on('all', async (event, file) => {
if (handoff.getConfigFilePaths().includes(file)) {
return;
}
switch (event) {
case 'add':
case 'change':
case 'unlink':
if (!state.debounce) {
state.debounce = true;
try {
const entryType = runtimeComponentPathsToWatch.get(file);
const segmentToUpdate: ComponentSegment = entryType ? mapEntryTypeToSegment(entryType) : undefined;
const componentDir = path.basename(path.dirname(file));
await processComponents(handoff, componentDir, segmentToUpdate);
} catch (e) {
Logger.error('Error processing component:', e);
} finally {
state.debounce = false;
}
}
break;
}
});
}
};
/**
* Watches the runtime configuration for changes.
*
* @param handoff - The Handoff instance
* @param state - The shared watcher state
*/
const watchRuntimeConfiguration = (handoff: Handoff, state: WatcherState) => {
if (state.runtimeConfigurationWatcher) {
state.runtimeConfigurationWatcher.close();
}
if (handoff.getConfigFilePaths().length > 0) {
state.runtimeConfigurationWatcher = chokidar.watch(handoff.getConfigFilePaths(), { ignoreInitial: true });
state.runtimeConfigurationWatcher.on('all', async (event, file) => {
switch (event) {
case 'add':
case 'change':
case 'unlink':
if (!state.debounce) {
state.debounce = true;
try {
file = path.dirname(file);
// Reload the Handoff instance to pick up configuration changes
handoff.reload();
// After reloading, persist the updated client configuration
await persistClientConfig(handoff);
// Restart the runtime components watcher to track potentially updated/added/removed components
watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff));
// Process components based on the updated configuration and file path
await processComponents(handoff, path.basename(file));
} catch (e) {
Logger.error('Error reloading runtime configuration:', e);
} finally {
state.debounce = false;
}
}
break;
}
});
}
};
/**
* Build the next js application
* @param handoff
* @returns
*/
const buildApp = async (handoff: Handoff, skipComponents?: boolean): Promise<void> => {
skipComponents = skipComponents ?? false;
// Perform cleanup
await cleanupAppDirectory(handoff);
// Build components
if (!skipComponents) {
await buildComponents(handoff);
}
// Prepare app
const appPath = await initializeProjectApp(handoff);
await persistClientConfig(handoff);
// Build app
const buildResult = spawn.sync('npx', ['next', 'build'], {
cwd: appPath,
stdio: 'inherit',
env: {
...process.env,
NODE_ENV: 'production',
},
});
if (buildResult.status !== 0) {
let errorMsg = `Next.js build failed with exit code ${buildResult.status}`;
if (buildResult.error) {
errorMsg += `\nSpawn error: ${buildResult.error.message}`;
}
throw new Error(errorMsg);
}
// Ensure output root directory exists
const outputRoot = path.resolve(handoff.workingPath, handoff.sitesDirectory);
await fs.ensureDir(outputRoot);
// Clean the project output directory (if exists)
const output = path.resolve(outputRoot, handoff.getProjectId());
if (fs.existsSync(output)) {
await fs.remove(output);
}
// Copy the build files into the project output directory
await fs.copy(path.resolve(appPath, 'out'), output);
};
/**
* Watch the next js application.
* Starts a custom dev server with Handoff-specific watchers and hot-reloading.
*
* @param handoff
*/
export const watchApp = async (handoff: Handoff): Promise<void> => {
// Initial processing of the components with caching enabled
// This will skip rebuilding components whose source files haven't changed
await processComponents(handoff, undefined, undefined, { useCache: true });
const appPath = await initializeProjectApp(handoff);
// Persist client configuration
await persistClientConfig(handoff);
// Watch app source
watchAppSource(handoff);
// // does a ts config exist?
// let tsconfigPath = 'tsconfig.json';
// config.typescript = {
// ...config.typescript,
// tsconfigPath,
// };
const dev = true;
const hostname = 'localhost';
const port = handoff.config.app.ports?.app ?? 3000;
// purge out cache
const moduleOutput = path.resolve(appPath, 'out');
if (fs.existsSync(moduleOutput)) {
await fs.remove(moduleOutput);
// create empty directory
await fs.ensureDir(moduleOutput);
}
const nextProcess = spawn('npx', ['next', 'dev', '--port', String(port)], {
cwd: appPath,
stdio: 'inherit',
env: {
...process.env,
NODE_ENV: 'development',
},
});
Logger.success(`Ready on http://${hostname}:${port}`);
nextProcess.on('error', (error) => {
Logger.error(`Next.js dev process error: ${error}`);
process.exit(1);
});
nextProcess.on('close', (code) => {
Logger.success(`Next.js dev process closed with code ${code}`);
process.exit(code);
});
const wss = await createWebSocketServer(handoff.config.app.ports?.websocket ?? 3001);
const chokidarConfig = {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
ignoreInitial: true,
};
const state: WatcherState = {
debounce: false,
runtimeComponentsWatcher: null,
runtimeConfigurationWatcher: null,
};
watchPublicDirectory(handoff, wss, state, chokidarConfig);
watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff));
watchRuntimeConfiguration(handoff, state);
await watchScss(handoff, state, chokidarConfig);
watchPages(handoff, chokidarConfig);
};
/**
* Watch the next js application using the standard Next.js dev server.
* This is useful for debugging the Next.js app itself without the Handoff overlay.
*
* @param handoff
*/
export const devApp = async (handoff: Handoff): Promise<void> => {
// Prepare app
const appPath = await initializeProjectApp(handoff);
// Purge app cache
const moduleOutput = path.resolve(appPath, 'out');
if (fs.existsSync(moduleOutput)) {
await fs.remove(moduleOutput);
}
// Persist client configuration
await persistClientConfig(handoff);
// Run
const devResult = spawn.sync('npx', ['next', 'dev', '--port', String(handoff.config.app.ports?.app ?? 3000)], {
cwd: appPath,
stdio: 'inherit',
env: {
...process.env,
NODE_ENV: 'development',
},
});
if (devResult.status !== 0) {
let errorMsg = `Next.js dev failed with exit code ${devResult.status}`;
if (devResult.error) {
errorMsg += `\nSpawn error: ${devResult.error.message}`;
}
throw new Error(errorMsg);
}
};
export default buildApp;