@swell/cli
Version:
Swell's command line interface/utility
582 lines (580 loc) • 26.4 kB
JavaScript
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 };
}
}