@swell/cli
Version:
Swell's command line interface/utility
409 lines (407 loc) • 18.3 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';
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 };
}
}