@swell/cli 2.0.19 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,15 +2,29 @@ import { PushAppCommand } from '../../push-app-command.js';
2
2
  export default class AppDev extends PushAppCommand {
3
3
  static examples: string[];
4
4
  static flags: {
5
- 'storefront-id': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
6
- 'storefront-select': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
7
5
  'no-push': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
8
6
  port: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
7
+ 'frontend-port': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
+ 'storefront-id': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
+ 'storefront-select': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
9
10
  };
10
11
  static summary: string;
11
12
  static delayOrientation: boolean;
12
13
  static orientation: {
13
14
  env: string;
14
15
  };
16
+ tmpDir: string;
17
+ functionPorts: Map<string, number>;
18
+ functionErrors: Map<string, string>;
15
19
  run(): Promise<void>;
20
+ private startAppFunctionServer;
21
+ runAppFrontendDevIfApplicable(frontendPort?: number): Promise<number | void>;
22
+ private getAppFunctions;
23
+ private logAllFunctions;
24
+ private logFunction;
25
+ private createTmpDirectory;
26
+ private startFunctionServers;
27
+ private generateWranglerConfig;
28
+ private createFunctionRouter;
29
+ private onChangeFunctionWatcher;
16
30
  }
@@ -1,14 +1,33 @@
1
1
  import { Flags } from '@oclif/core';
2
+ import ora from 'ora';
3
+ import getPort, { portNumbers } from 'get-port';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import * as os from 'node:os';
7
+ import * as http from 'node:http';
8
+ import { spawn } from 'node:child_process';
9
+ import { ConfigType, allConfigFilesInDir, appConfigFromFile, } from '../../lib/apps/index.js';
10
+ import { bundleFunction } from '../../lib/bundle.js';
11
+ import style from '../../lib/style.js';
2
12
  import { PushAppCommand } from '../../push-app-command.js';
3
- import AppFrontendDev from './frontend/dev.js';
4
13
  export default class AppDev extends PushAppCommand {
5
14
  static examples = [
6
15
  'swell app dev',
7
16
  'swell app dev --storefront-id <id>',
8
17
  'swell app dev --port 3000',
18
+ 'swell app dev --port 3000 --frontend-port 4000',
9
19
  ];
10
20
  static flags = {
11
- ...AppFrontendDev.flags,
21
+ 'no-push': Flags.boolean({
22
+ description: 'skip pushing app files initially',
23
+ }),
24
+ port: Flags.integer({
25
+ char: 'p',
26
+ description: 'override the default port to run the app locally',
27
+ }),
28
+ 'frontend-port': Flags.integer({
29
+ description: 'specify the port for the frontend dev server when running with frontend',
30
+ }),
12
31
  'storefront-id': Flags.string({
13
32
  description: 'for storefront apps, identify a storefront to preview and push theme files to',
14
33
  }),
@@ -22,7 +41,344 @@ export default class AppDev extends PushAppCommand {
22
41
  static orientation = {
23
42
  env: 'test',
24
43
  };
44
+ // Directory for compiled function files and wrangler context
45
+ tmpDir = '';
46
+ // All available functions
47
+ functionPorts = new Map();
48
+ functionErrors = new Map();
25
49
  async run() {
26
- await this.config.runCommand('app:frontend:dev', this.argv);
50
+ const { flags } = await this.parse(AppDev);
51
+ const { port, 'frontend-port': frontendPort } = flags;
52
+ const noPush = flags['no-push'];
53
+ if (!(await this.ensureAppExists(undefined, false))) {
54
+ return;
55
+ }
56
+ if (!noPush) {
57
+ await this.pushAppConfigs();
58
+ }
59
+ this.saveCurrentStorefront();
60
+ const spinner = ora();
61
+ spinner.start(`Starting app dev server...\n`);
62
+ const serverPort = await this.startProxyServer(port);
63
+ await this.startAppFunctionServer(spinner, serverPort);
64
+ await this.runAppFrontendDevIfApplicable(frontendPort);
65
+ }
66
+ async startAppFunctionServer(spinner, serverPort) {
67
+ // Get all functions in this app
68
+ const functions = await this.getAppFunctions();
69
+ if (functions.length === 0) {
70
+ spinner.stop();
71
+ return;
72
+ }
73
+ // Create TMP directory for wrangler configs and bundled functions
74
+ await this.createTmpDirectory();
75
+ // Bundle functions and start wrangler dev servers for each
76
+ await this.startFunctionServers(functions);
77
+ // Start watching for function file changes
78
+ this.watchForChanges({
79
+ onChange: this.onChangeFunctionWatcher.bind(this),
80
+ });
81
+ // Create a routing server that proxies requests to function servers
82
+ await this.createFunctionRouter(serverPort);
83
+ spinner.succeed(`App function server running on port ${serverPort}\n`);
84
+ this.log(`${style.appConfigName(`Functions:`)}`);
85
+ await this.logAllFunctions(functions);
86
+ this.log(`${style.success('→')} Call functions at: http://localhost:${serverPort}/<function-name>\n`);
87
+ }
88
+ async runAppFrontendDevIfApplicable(frontendPort) {
89
+ const projectType = this.getFrontendProjectType(false);
90
+ if (projectType) {
91
+ // add app-dev flag to avoid watching for changes
92
+ this.argv.push('--app-dev');
93
+ // add proxy-port flag (use provided port or auto-detect)
94
+ const proxyPort = frontendPort || (await getPort({ port: portNumbers(4000, 4100) }));
95
+ this.argv.push('--proxy-port', String(proxyPort));
96
+ this.config.runCommand('app:frontend:dev', this.argv);
97
+ return proxyPort;
98
+ }
99
+ }
100
+ async getAppFunctions() {
101
+ const functions = [];
102
+ // Get all function files from the functions directory
103
+ try {
104
+ for (const { configFile } of allConfigFilesInDir(this.appPath, 'functions', ConfigType.FUNCTION)) {
105
+ const config = appConfigFromFile(configFile, ConfigType.FUNCTION, this.appPath);
106
+ if (!config.isRootFunction()) {
107
+ continue; // Skip if not a root function config
108
+ }
109
+ functions.push(config);
110
+ }
111
+ }
112
+ catch (error) {
113
+ // functions directory doesn't exist
114
+ }
115
+ return functions;
116
+ }
117
+ async logAllFunctions(functions) {
118
+ for (const func of functions) {
119
+ await this.logFunction(func);
120
+ }
121
+ this.log();
122
+ }
123
+ async logFunction(func) {
124
+ const functionName = path.parse(func.filePath).name;
125
+ this.log(` ${style.appConfigValue(functionName)}`);
126
+ if (this.functionErrors.has(functionName)) {
127
+ this.log(` ${style.error('Error starting function:')} ${this.functionErrors.get(functionName)}`);
128
+ return;
129
+ }
130
+ try {
131
+ // Bundle the function to get its config
132
+ const fullPath = path.join(this.appPath, func.filePath);
133
+ const { config } = await bundleFunction(fullPath);
134
+ if (config?.model?.events) {
135
+ const events = Array.isArray(config.model.events)
136
+ ? config.model.events
137
+ : [config.model.events];
138
+ this.log(` Trigger: Model`);
139
+ this.log(` Events: ${style.dim(events.join(', '))}`);
140
+ }
141
+ else if (config?.cron?.schedule) {
142
+ this.log(` Trigger: Cron`);
143
+ this.log(` Schedule: ${style.dim(config.cron.schedule)}`);
144
+ }
145
+ else if (config?.route) {
146
+ this.log(` Trigger: Route`);
147
+ this.log(` Methods: ${style.dim(config.route.methods
148
+ .map((m) => String(m).toUpperCase())
149
+ .join(', '))}`);
150
+ if (config.route.headers) {
151
+ const headers = Object.entries(config.route.headers).map(([key, value]) => `${key}: ${value}`);
152
+ this.log(` Headers:`);
153
+ headers.forEach((header) => this.log(` ${style.dim(header)}`));
154
+ }
155
+ if (config.route.cache?.timeout) {
156
+ this.log(` Cache timeout: ${style.dim(config.route.cache.timeout)}`);
157
+ }
158
+ if (config.route.public) {
159
+ this.log(` Public: ${style.dim('true')}`);
160
+ }
161
+ }
162
+ if (config?.description) {
163
+ this.log(` Description: ${style.dim(config.description)}`);
164
+ }
165
+ }
166
+ catch (error) {
167
+ this.log(` ${style.error('Error loading config:', error.message)}`);
168
+ }
169
+ }
170
+ async createTmpDirectory() {
171
+ const tmpBase = path.join(os.tmpdir(), 'swell-cli');
172
+ const appTmpDir = path.join(tmpBase, this.app.id || 'unknown-app', 'functions');
173
+ // Create the directory structure
174
+ await fs.promises.mkdir(appTmpDir, { recursive: true });
175
+ this.tmpDir = appTmpDir;
176
+ }
177
+ async startFunctionServers(functions) {
178
+ const functionStatus = new Map();
179
+ for (const func of functions) {
180
+ const functionName = func.name;
181
+ if (this.functionPorts.has(functionName)) {
182
+ // Function server already running
183
+ continue;
184
+ }
185
+ const functionPort = await getPort({ port: portNumbers(9000, 9100) });
186
+ try {
187
+ // Bundle the function
188
+ const fullPath = path.join(this.appPath, func.filePath);
189
+ const { code } = await bundleFunction(fullPath);
190
+ // Write bundled function to tmp directory
191
+ const bundledPath = path.join(this.tmpDir, `${functionName}.js`);
192
+ await fs.promises.writeFile(bundledPath, code);
193
+ // Generate wrangler config
194
+ const wranglerConfig = this.generateWranglerConfig(functionName, bundledPath);
195
+ const configPath = path.join(this.tmpDir, `${functionName}.toml`);
196
+ await fs.promises.writeFile(configPath, wranglerConfig);
197
+ functionStatus.set(functionName, 'starting');
198
+ // Start wrangler process in background
199
+ const wranglerProcess = spawn('npx', [
200
+ 'wrangler',
201
+ 'dev',
202
+ `--config=${configPath}`,
203
+ `--port=${functionPort}`,
204
+ ], {
205
+ cwd: this.tmpDir,
206
+ //stdio: 'pipe', // Capture output for debugging
207
+ detached: false,
208
+ });
209
+ const handleFunctionOutput = (data) => {
210
+ const output = data.toString();
211
+ // Remove ANSI escape sequences that cause line clearing
212
+ const cleanOutput = output.replace('\u001b[2K\u001b[1A\u001b[2K\u001b[G', '');
213
+ const lines = cleanOutput
214
+ .split('\n')
215
+ .filter((line) => line.trim());
216
+ for (const line of lines) {
217
+ // Capture running status
218
+ if (line.includes('Ready on http://localhost:')) {
219
+ functionStatus.set(functionName, 'running');
220
+ this.functionErrors.delete(functionName);
221
+ continue;
222
+ }
223
+ // Catch startup error
224
+ if (functionStatus.get(functionName) === 'starting' &&
225
+ line.includes('✘ [ERROR]')) {
226
+ functionStatus.set(functionName, 'error');
227
+ this.functionErrors.set(functionName, line);
228
+ continue;
229
+ }
230
+ if (functionStatus.get(functionName) !== 'running') {
231
+ // No output until function is running
232
+ continue;
233
+ }
234
+ // Hide specific wrangler startup/info messages
235
+ if (line.includes('⎔ Starting local server') ||
236
+ line.includes('⎔ Reloading local server') ||
237
+ line.includes('Starting local server') ||
238
+ line.includes('⛅️ wrangler') ||
239
+ line.includes('-----') ||
240
+ line.includes('▲ [WARNING]') ||
241
+ line.includes('The version of Wrangler') ||
242
+ line.includes('Please update to the latest') ||
243
+ line.includes('Run `npm install') ||
244
+ line.includes('After installation') ||
245
+ line.includes('Your worker has access') ||
246
+ line.includes('- Vars:') ||
247
+ line.includes('- Bindings:')) {
248
+ continue;
249
+ }
250
+ // Skip wrangler info lines
251
+ if (line.includes('[wrangler:')) {
252
+ continue;
253
+ }
254
+ if (line) {
255
+ console.log(`${style.appConfigValue(`→ ${functionName}`)} ${this.timestampStyled()} ${line}`);
256
+ }
257
+ }
258
+ };
259
+ wranglerProcess.stdout?.on('data', handleFunctionOutput);
260
+ wranglerProcess.stderr?.on('data', handleFunctionOutput);
261
+ wranglerProcess.on('error', (error) => {
262
+ functionStatus.set(functionName, 'error');
263
+ this.functionErrors.set(functionName, error.message);
264
+ });
265
+ // Store the port for routing
266
+ this.functionPorts.set(functionName, functionPort);
267
+ }
268
+ catch (error) {
269
+ functionStatus.set(functionName, 'error');
270
+ this.functionErrors.set(functionName, error.message);
271
+ }
272
+ }
273
+ await new Promise((resolve) => {
274
+ if (!functions.length) {
275
+ resolve();
276
+ return;
277
+ }
278
+ const checkAllRunning = () => {
279
+ const allRunning = Array.from(functionStatus.values()).every((status) => status !== 'starting');
280
+ if (allRunning) {
281
+ resolve();
282
+ }
283
+ else {
284
+ setTimeout(checkAllRunning, 250);
285
+ }
286
+ };
287
+ checkAllRunning();
288
+ });
289
+ return { functionStatus };
290
+ }
291
+ generateWranglerConfig(functionName, bundledPath) {
292
+ return `
293
+ name = "${functionName}"
294
+ main = "${bundledPath}"
295
+ compatibility_date = "2023-05-18"
296
+
297
+ [vars]
298
+ ENVIRONMENT = "development"
299
+ `.trim();
300
+ }
301
+ async createFunctionRouter(serverPort) {
302
+ const server = http.createServer((req, res) => {
303
+ const url = new URL(req.url, `http://localhost:${serverPort}`);
304
+ const functionName = url.pathname.slice(1); // Remove leading slash
305
+ if (this.functionPorts.has(functionName)) {
306
+ const targetPort = this.functionPorts.get(functionName);
307
+ // Proxy the request to the function server
308
+ const proxyReq = http.request({
309
+ hostname: 'localhost',
310
+ port: targetPort,
311
+ path: req.url,
312
+ method: req.method,
313
+ headers: {
314
+ ...req.headers,
315
+ 'Swell-Local-Dev': 'true',
316
+ },
317
+ }, (proxyRes) => {
318
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
319
+ proxyRes.pipe(res);
320
+ });
321
+ proxyReq.on('error', (error) => {
322
+ res.writeHead(500);
323
+ res.end(`Error proxying to function ${functionName}: ${error.message}`);
324
+ });
325
+ // Log when response is finished, method, status, and time to execute by proxy
326
+ const startTime = Date.now();
327
+ res.on('finish', async () => {
328
+ const duration = Date.now() - startTime;
329
+ // Log after a short delay to ensure output order
330
+ await new Promise((r) => setTimeout(r, 100));
331
+ this.log(`\n${style.appConfigValue(`→ ${functionName}`)} ${this.timestampStyled()} [${res.statusCode}] ${req.method} ${req.url} (${duration}ms)\n`);
332
+ });
333
+ req.pipe(proxyReq);
334
+ }
335
+ else {
336
+ res.writeHead(404);
337
+ const functionError = this.functionErrors.get(functionName);
338
+ const functionsAvailable = Array.from(this.functionPorts.keys());
339
+ res.end(functionError
340
+ ? `Function Error: ${functionError}`
341
+ : `Function '${functionName}' not found. ${functionsAvailable.length > 0
342
+ ? `Available functions: ${Array.from(this.functionPorts.keys()).join(', ')}`
343
+ : ''}`);
344
+ }
345
+ });
346
+ server.listen(serverPort);
347
+ }
348
+ async onChangeFunctionWatcher(appConfig, action, result) {
349
+ // If no result, skip
350
+ if (!result) {
351
+ return;
352
+ }
353
+ // If deleted result, skip and remove port
354
+ if (action === 'remove' || !result) {
355
+ this.functionPorts.delete(appConfig?.name);
356
+ this.log();
357
+ return;
358
+ }
359
+ // Skip everything except functions
360
+ if (appConfig?.type !== ConfigType.FUNCTION) {
361
+ return;
362
+ }
363
+ try {
364
+ const fullPath = path.join(this.appPath, appConfig.filePath);
365
+ // Re-bundle the function
366
+ const { code } = await bundleFunction(fullPath);
367
+ // Update the bundled file (wrangler dev will auto-reload)
368
+ const bundledPath = path.join(this.tmpDir, `${appConfig.name}.js`);
369
+ await fs.promises.writeFile(bundledPath, code);
370
+ if (!this.functionPorts.has(appConfig.name)) {
371
+ this.log(`\nStarting function ${appConfig.name}...`);
372
+ await this.startFunctionServers([appConfig]);
373
+ }
374
+ else {
375
+ this.log(`\nUpdating function ${appConfig.name}...`);
376
+ }
377
+ await this.logFunction(appConfig);
378
+ }
379
+ catch (error) {
380
+ this.log(`${style.error('Error re-bundling function')} ${appConfig.name}: ${error.message}`);
381
+ }
382
+ this.log();
27
383
  }
28
384
  }
@@ -1,19 +1,20 @@
1
- import { FrontendProjectType } from '../../../lib/apps/index.js';
2
1
  import { PushAppCommand } from '../../../push-app-command.js';
3
2
  export default class AppFrontendDev extends PushAppCommand {
4
3
  static examples: string[];
5
4
  static flags: {
6
- 'no-push': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
7
- port: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
5
+ 'proxy-port': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
6
  'storefront-id': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
7
  'storefront-select': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
8
+ 'app-dev': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
9
+ 'no-push': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
10
+ port: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
+ 'frontend-port': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
10
12
  };
11
13
  static orientation: {
12
14
  env: string;
13
15
  };
14
16
  static summary: string;
15
- logAppLocalUrl(storeId: string, sessionId: string): void;
16
17
  run(): Promise<void>;
17
- startDevServer(projectType: FrontendProjectType, port?: number): Promise<void>;
18
- updateLocalProxy(proxyUrl: string, spinner: any, storefrontId?: string): Promise<void>;
18
+ private execFrontendProject;
19
+ logAppLocalUrl(storeId: string, sessionId: string): void;
19
20
  }