UNPKG

@swell/cli

Version:

Swell's command line interface/utility

409 lines (407 loc) 18.3 kB
import { Flags } from '@oclif/core'; import getPort, { portNumbers } from 'get-port'; import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; 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 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', ]; 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', }), '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', }), }; static orientation = { env: 'test', }; static summary = `Run an app in dev mode from your local machine.`; functionErrors = new Map(); // All available functions functionPorts = new Map(); // Directory for compiled function files and wrangler context tmpDir = ''; async run() { const { flags } = await this.parse(AppDev); const { port, 'frontend-port': frontendPort } = flags; const noPush = flags['no-push']; if (!(await this.ensureAppExists(undefined, false))) { return; } if (!noPush) { await this.pushAppConfigs(); } this.saveCurrentStorefront(); const spinner = ora(); spinner.start(`Starting app dev server...\n`); const serverPort = await this.startProxyServer(port); await this.startAppFunctionServer(spinner, serverPort); await this.runAppFrontendDevIfApplicable(frontendPort); } async runAppFrontendDevIfApplicable(frontendPort) { const projectType = 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) })); this.argv.push('--proxy-port', String(proxyPort)); this.config.runCommand('app:frontend:dev', this.argv); 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 { 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.promises.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 { for (const { configFile } of allConfigFilesInDir(this.appPath, 'functions', ConfigType.FUNCTION)) { const config = appConfigFromFile(configFile, ConfigType.FUNCTION, this.appPath); if (!config.isRootFunction()) { continue; // Skip if not a root function config } functions.push(config); } } catch { // functions directory doesn't exist } 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 { 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) { const headers = Object.entries(config.route.headers).map(([key, value]) => `${key}: ${value}`); this.log(` Headers:`); for (const header of headers) this.log(` ${style.dim(header)}`); } 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; } try { const fullPath = path.join(this.appPath, appConfig.filePath); // Re-bundle the function const { code } = await bundleFunction(fullPath); // Update the bundled file (wrangler dev will auto-reload) const bundledPath = path.join(this.tmpDir, `${appConfig.name}.js`); await fs.promises.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(); if (functions.length === 0) { 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 a routing server that proxies requests to function servers 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`); } async startFunctionServers(functions) { const functionStatus = new Map(); const allocatedInspectorPorts = new Set(); /* eslint-disable no-await-in-loop */ for (const func of functions) { const functionName = func.name; if (this.functionPorts.has(functionName)) { // Function server already running continue; } const functionPort = await getPort({ port: portNumbers(9000, 9100) }); 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 { code } = await bundleFunction(fullPath); // Write bundled function to tmp directory const bundledPath = path.join(this.tmpDir, `${functionName}.js`); await fs.promises.writeFile(bundledPath, code); // Generate wrangler config const wranglerConfig = this.generateWranglerConfig(functionName, bundledPath); const configPath = path.join(this.tmpDir, `${functionName}.toml`); await fs.promises.writeFile(configPath, wranglerConfig); functionStatus.set(functionName, 'starting'); // Start wrangler process in background const wranglerProcess = spawn('npx', [ 'wrangler', 'dev', `--config=${configPath}`, `--port=${functionPort}`, `--inspector-port=${inspectorPort}`, ], { cwd: this.tmpDir, // 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 port for routing 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 }; } }