UNPKG

@swell/cli

Version:

Swell's command line interface/utility

582 lines (580 loc) 26.4 kB
import { Flags } from '@oclif/core'; import getPort, { portNumbers } from 'get-port'; import { spawn } from 'node:child_process'; import * as fs from 'node:fs/promises'; import * as http from 'node:http'; import * as os from 'node:os'; import * as path from 'node:path'; import ora from 'ora'; import { ConfigType, allConfigFilesInDir, appConfigFromFile, } from '../../lib/apps/index.js'; import { bundleFunction } from '../../lib/bundle.js'; import { analyzeFunctionSource, hasKindDiagnostic, } from '../../lib/function-source-analysis.js'; import { detectPackageManager, getExecSpawnArgs, } from '../../lib/package-manager.js'; import style from '../../lib/style.js'; import { PushAppCommand } from '../../push-app-command.js'; export default class AppDev extends PushAppCommand { static delayOrientation = true; static examples = [ 'swell app dev', 'swell app dev --storefront-id <id>', 'swell app dev --port 3000', 'swell app dev --port 3000 --frontend-port 4000', 'swell app dev --function my-function', ]; static flags = { 'no-push': Flags.boolean({ description: 'skip pushing app files initially', }), port: Flags.integer({ char: 'p', description: 'override the default port to run the app locally', }), 'frontend-port': Flags.integer({ description: 'specify the port for the frontend dev server when running with frontend', }), function: Flags.string({ description: 'run only the named function', }), 'storefront-id': Flags.string({ description: 'for storefront apps, identify a storefront to preview and push theme files to', }), 'storefront-select': Flags.boolean({ default: false, description: 'for storefront apps, prompt to select a storefront to preview', }), // Declared here so oclif accepts -y; passed through to app:frontend:dev via this.argv yes: Flags.boolean({ char: 'y', description: 'skip prompts (non-interactive mode)', }), }; static orientation = { env: 'test', }; static summary = `Run an app in dev mode from your local machine.`; functionErrors = new Map(); // When set, only this function name is bundled, started, and watched functionFilter = null; // All available functions functionPorts = new Map(); // Wrangler child processes to kill on cleanup functionProcesses = new Map(); // Directory for compiled function files and wrangler context tmpDir = ''; // Port for frontend dev server (used for routing non-function requests) frontendPort = null; // Guard against multiple cleanup calls isCleaningUp = false; async run() { const { flags } = await this.parse(AppDev); const { port, 'frontend-port': frontendPort } = flags; const noPush = flags['no-push']; this.functionFilter = flags.function ?? null; if (!(await this.ensureAppExists(undefined, false))) { return; } if (!noPush) { await this.pushAppConfigs(); } // Ensure storefront is resolved for local preview URL (may not be set with --no-push) if (this.app.type === 'storefront' && !this.storefront) { this.storefront = await this.getAppStorefront({}); } this.saveCurrentStorefront(); const spinner = ora(); spinner.start(`Starting app dev server...\n`); // Register cleanup handlers to clear local proxy on exit process.on('SIGINT', this.cleanupOnExit.bind(this)); process.on('SIGTERM', this.cleanupOnExit.bind(this)); const serverPort = await this.startProxyServer(port); await this.startAppFunctionServer(spinner, serverPort); await this.runAppFrontendDevIfApplicable(frontendPort); } async cleanupOnExit() { if (this.isCleaningUp) return; this.isCleaningUp = true; for (const [, proc] of this.functionProcesses) { proc.kill(); } try { await this.updateLocalProxy(null, this.storefront?.id); } catch { // Ignore errors during cleanup } this.log(); // eslint-disable-next-line no-process-exit, unicorn/no-process-exit process.exit(); } async runAppFrontendDevIfApplicable(frontendPort) { const projectType = await this.getFrontendProjectType(false); if (projectType) { // add app-dev flag to avoid watching for changes this.argv.push('--app-dev'); // add proxy-port flag (use provided port or auto-detect) const proxyPort = frontendPort || (await getPort({ port: portNumbers(4000, 4100) })); // Store frontend port for function router to use for fallback routing this.frontendPort = proxyPort; this.argv.push('--proxy-port', String(proxyPort)); this.config .runCommand('app:frontend:dev', this.argv) .catch((error) => { // SIGTERM/SIGINT during dev server shutdown is expected switch (error?.signal) { case 'SIGTERM': case 'SIGINT': { return; } default: { this.error(error); } } }); return proxyPort; } } async createFunctionRouter(serverPort) { const server = http.createServer((req, res) => { const url = new URL(req.url, `http://localhost:${serverPort}`); const functionName = url.pathname.slice(1); // Remove leading slash if (this.functionPorts.has(functionName)) { const targetPort = this.functionPorts.get(functionName); // Proxy the request to the function server const proxyReq = http.request({ hostname: 'localhost', port: targetPort, path: req.url, method: req.method, headers: { ...req.headers, 'Swell-Local-Dev': 'true', }, }, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }); proxyReq.on('error', (error) => { res.writeHead(500); res.end(`Error proxying to function ${functionName}: ${error.message}`); }); // Log when response is finished, method, status, and time to execute by proxy const startTime = Date.now(); res.on('finish', async () => { const duration = Date.now() - startTime; // Log after a short delay to ensure output order await new Promise((r) => { setTimeout(r, 100); }); this.log(`\n${style.appConfigValue(`→ ${functionName}`)} ${this.timestampStyled()} [${res.statusCode}] ${req.method} ${req.url} (${duration}ms)\n`); }); req.pipe(proxyReq); } else if (this.frontendPort) { // Proxy non-function requests to frontend dev server const proxyReq = http.request({ hostname: 'localhost', port: this.frontendPort, path: req.url, method: req.method, headers: req.headers, }, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }); proxyReq.on('error', (error) => { res.writeHead(502); res.end(`Frontend dev server error: ${error.message}`); }); req.pipe(proxyReq); } else { res.writeHead(404); const functionError = this.functionErrors.get(functionName); const functionsAvailable = [...this.functionPorts.keys()]; res.end(functionError ? `Function Error: ${functionError}` : `Function '${functionName}' not found. ${functionsAvailable.length > 0 ? `Available functions: ${[...this.functionPorts.keys()].join(', ')}` : ''}`); } }); server.listen(serverPort); } async createTmpDirectory() { const tmpBase = path.join(os.tmpdir(), 'swell-cli'); const appTmpDir = path.join(tmpBase, this.app.id || 'unknown-app', 'functions'); // Create the directory structure await fs.mkdir(appTmpDir, { recursive: true }); this.tmpDir = appTmpDir; } generateWranglerConfig(functionName, bundledPath) { return ` name = "${functionName}" main = "${bundledPath}" compatibility_date = "2023-05-18" [vars] ENVIRONMENT = "development" `.trim(); } async getAppFunctions() { const functions = []; // Get all function files from the functions directory try { const promises = []; for (const { configFile } of allConfigFilesInDir(this.appPath, 'functions', ConfigType.FUNCTION)) { promises.push(appConfigFromFile(configFile, ConfigType.FUNCTION, this.appPath).then(async (config) => { if (!config.isRootFunction()) { return null; // Skip if not a root function config } if (this.functionFilter && config.name !== this.functionFilter) { return null; } const fullPath = path.join(this.appPath, config.filePath); const analysis = await analyzeFunctionSource(fullPath); if (analysis.kind === 'workflow') { if (this.functionFilter) { this.log(`Workflow ${style.appConfigValue(config.name)} deploys through ${style.command('swell app push')} and cannot run in local dev.`); } return null; } if (hasKindDiagnostic(analysis)) { this.functionErrors.set(config.name, analysis.diagnostics.join('\n')); } return config; })); } const configs = await Promise.all(promises); for (const config of configs) { if (config !== null) { functions.push(config); } } } catch (error) { // Skip error if functions directory doesn't exist if (error.code !== 'ENOENT') { throw error; } } if (this.functionFilter && functions.length === 0) { this.error(`Function '${this.functionFilter}' not found in ${style.path('functions/')}.`); } return functions; } async logAllFunctions(functions) { for (const func of functions) { // eslint-disable-next-line no-await-in-loop await this.logFunction(func); } this.log(); } async logFunction(func) { const functionName = path.parse(func.filePath).name; this.log(` ${style.appConfigValue(functionName)}`); if (this.functionErrors.has(functionName)) { this.log(` ${style.error('Error starting function:')} ${this.functionErrors.get(functionName)}`); return; } try { // Bundle the function to get its config const fullPath = path.join(this.appPath, func.filePath); const analysis = await analyzeFunctionSource(fullPath); if (hasKindDiagnostic(analysis)) { this.log(` ${style.error('Error loading config:', analysis.diagnostics.join('\n'))}`); return; } if (analysis.kind === 'workflow') { this.log(` Workflow: deploys through ${style.command('swell app push')}`); return; } const { config } = await bundleFunction(fullPath); if (config?.model?.events) { const events = Array.isArray(config.model.events) ? config.model.events : [config.model.events]; this.log(` Trigger: Model`); this.log(` Events: ${style.dim(events.join(', '))}`); } else if (config?.cron?.schedule) { this.log(` Trigger: Cron`); this.log(` Schedule: ${style.dim(config.cron.schedule)}`); } else if (config?.route) { this.log(` Trigger: Route`); if (config.route.methods?.length) { this.log(` Methods: ${style.dim(config.route.methods .map((m) => String(m).toUpperCase()) .join(', '))}`); } if (config.route.headers?.length) { this.log(` Headers: ${style.dim(config.route.headers.join(', '))}`); } if (config.route.cache?.timeout) { this.log(` Cache timeout: ${style.dim(config.route.cache.timeout)}`); } if (config.route.public) { this.log(` Public: ${style.dim('true')}`); } } if (config?.extension) { this.log(` Extension: ${style.dim(config.extension)}`); } if (config?.description) { this.log(` Description: ${style.dim(config.description)}`); } } catch (error) { this.log(` ${style.error('Error loading config:', error.message)}`); } } async onChangeFunctionWatcher(appConfig, action, result) { // If no result, skip if (!result) { return; } // If deleted result, skip and remove port if (action === 'remove' || !result) { this.functionPorts.delete(appConfig?.name); this.log(); return; } // Skip everything except functions if (appConfig?.type !== ConfigType.FUNCTION) { return; } if (this.functionFilter && appConfig.name !== this.functionFilter) { return; } try { const fullPath = path.join(this.appPath, appConfig.filePath); const analysis = await analyzeFunctionSource(fullPath); if (hasKindDiagnostic(analysis)) { this.functionErrors.set(appConfig.name, analysis.diagnostics.join('\n')); this.log(`${style.error('Error loading config')} ${appConfig.name}: ${analysis.diagnostics.join('\n')}`); return; } if (analysis.kind === 'workflow') { this.functionPorts.delete(appConfig.name); this.log(`\nWorkflow ${style.appConfigValue(appConfig.name)} deploys through ${style.command('swell app push')} and cannot run in local dev.`); return; } // Re-bundle the function const { code } = await bundleFunction(fullPath); // Update the bundled file (wrangler dev will auto-reload) const functionDir = path.join(this.tmpDir, `${appConfig.name}`); await fs.mkdir(functionDir, { recursive: true }); const bundledPath = path.join(functionDir, `${appConfig.name}.js`); await fs.writeFile(bundledPath, code); if (this.functionPorts.has(appConfig.name)) { this.log(`\nUpdating function ${appConfig.name}...`); } else { this.log(`\nStarting function ${appConfig.name}...`); await this.startFunctionServers([appConfig]); } await this.logFunction(appConfig); } catch (error) { this.log(`${style.error('Error re-bundling function')} ${appConfig.name}: ${error.message}`); } this.log(); } async startAppFunctionServer(spinner, serverPort) { // Get all functions in this app const functions = await this.getAppFunctions(); const projectType = await this.getFrontendProjectType(false); const shouldLogPreviewUrl = this.app.type === 'storefront' && !projectType; if (functions.length === 0) { if (!projectType && this.app.type !== 'storefront') { spinner.stop(); this.log(`No functions or frontend detected. Exiting.\n`); await this.cleanupOnExit(); return; } // Create routing server to proxy requests to frontend even without functions await this.createFunctionRouter(serverPort); if (shouldLogPreviewUrl) { spinner.succeed(`App dev server running on port ${serverPort}\n`); this.logLocalPreviewUrl(); } else { spinner.stop(); } return; } // Create TMP directory for wrangler configs and bundled functions await this.createTmpDirectory(); // Bundle functions and start wrangler dev servers for each await this.startFunctionServers(functions); // Start watching for function file changes this.watchForChanges({ onChange: this.onChangeFunctionWatcher.bind(this), }); // Create routing server after function servers are ready await this.createFunctionRouter(serverPort); spinner.succeed(`App function server running on port ${serverPort}\n`); this.log(`${style.appConfigName(`Functions:`)}`); await this.logAllFunctions(functions); this.log(`${style.success('→')} Call functions at: http://localhost:${serverPort}/<function-name>\n`); if (shouldLogPreviewUrl) { this.logLocalPreviewUrl(); } } async startFunctionServers(functions) { const functionStatus = new Map(); const allocatedFunctionPorts = new Set(); const allocatedInspectorPorts = new Set(); /* eslint-disable no-await-in-loop */ for (const func of functions) { const functionName = func.name; if (this.functionErrors.has(functionName)) { functionStatus.set(functionName, 'error'); continue; } if (this.functionPorts.has(functionName)) { // Function server already running continue; } const functionPort = await getPort({ port: portNumbers(9000, 9100), exclude: [...allocatedFunctionPorts], }); allocatedFunctionPorts.add(functionPort); const inspectorPort = await getPort({ port: portNumbers(9229, 9329), exclude: [...allocatedInspectorPorts], }); allocatedInspectorPorts.add(inspectorPort); try { // Bundle the function const fullPath = path.join(this.appPath, func.filePath); const analysis = await analyzeFunctionSource(fullPath); if (hasKindDiagnostic(analysis)) { functionStatus.set(functionName, 'error'); this.functionErrors.set(functionName, analysis.diagnostics.join('\n')); continue; } if (analysis.kind === 'workflow') { functionStatus.set(functionName, 'error'); this.functionErrors.set(functionName, 'Workflows deploy through swell app push and cannot run in local dev.'); continue; } const { code } = await bundleFunction(fullPath); // Each function gets its own directory to avoid wrangler state conflicts const functionDir = path.join(this.tmpDir, functionName); await fs.mkdir(functionDir, { recursive: true }); // Write bundled function to its directory const bundledPath = path.join(functionDir, `${functionName}.js`); await fs.writeFile(bundledPath, code); // Generate wrangler config const wranglerConfig = this.generateWranglerConfig(functionName, bundledPath); const configPath = path.join(functionDir, `${functionName}.toml`); await fs.writeFile(configPath, wranglerConfig); functionStatus.set(functionName, 'starting'); // Start wrangler process in background using detected package manager const pm = await detectPackageManager(this.appPath); const { program, prefixArgs } = getExecSpawnArgs(pm); const wranglerProcess = spawn(program, [ ...prefixArgs, 'wrangler', 'dev', `--config=${configPath}`, `--port=${functionPort}`, `--inspector-port=${inspectorPort}`, ], { cwd: functionDir, // stdio: 'pipe', // Capture output for debugging detached: false, }); const handleFunctionOutput = (data) => { const output = data.toString(); // Remove ANSI escape sequences that cause line clearing const cleanOutput = output.replace('\u001B[2K\u001B[1A\u001B[2K\u001B[G', ''); const lines = cleanOutput .split('\n') .filter((line) => line.trim()); for (const line of lines) { // Capture running status if (line.includes('Ready on http://localhost:')) { functionStatus.set(functionName, 'running'); this.functionErrors.delete(functionName); continue; } // Catch startup error if (functionStatus.get(functionName) === 'starting' && line.includes('✘ [ERROR]')) { functionStatus.set(functionName, 'error'); this.functionErrors.set(functionName, line); continue; } if (functionStatus.get(functionName) !== 'running') { // No output until function is running continue; } // Hide specific wrangler startup/info messages if (line.includes('⎔ Starting local server') || line.includes('⎔ Reloading local server') || line.includes('Starting local server') || line.includes('⛅️ wrangler') || line.includes('-----') || line.includes('▲ [WARNING]') || line.includes('The version of Wrangler') || line.includes('Please update to the latest') || line.includes('Run `npm install') || line.includes('After installation') || line.includes('Your worker has access') || line.includes('- Vars:') || line.includes('- Bindings:')) { continue; } // Skip wrangler info lines if (line.includes('[wrangler:')) { continue; } if (line) { console.log(`${style.appConfigValue(`→ ${functionName}`)} ${this.timestampStyled()} ${line}`); } } }; wranglerProcess.stdout?.on('data', handleFunctionOutput); wranglerProcess.stderr?.on('data', handleFunctionOutput); wranglerProcess.on('error', (error) => { functionStatus.set(functionName, 'error'); this.functionErrors.set(functionName, error.message); }); wranglerProcess.on('exit', (code, _signal) => { if (functionStatus.get(functionName) === 'starting') { functionStatus.set(functionName, 'error'); this.functionErrors.set(functionName, `Process exited with code ${code}`); } }); // Store the process and port for routing this.functionProcesses.set(functionName, wranglerProcess); this.functionPorts.set(functionName, functionPort); } catch (error) { functionStatus.set(functionName, 'error'); this.functionErrors.set(functionName, error.message); } } /* eslint-enable no-await-in-loop */ await new Promise((resolve) => { if (functions.length === 0) { resolve(); return; } const checkAllRunning = () => { const allRunning = [...functionStatus.values()].every((status) => status !== 'starting'); if (allRunning) { resolve(); } else { setTimeout(checkAllRunning, 250); } }; checkAllRunning(); }); return { functionStatus }; } }