@youcan/app 2.4.0 → 2.4.1

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.
@@ -13,13 +13,16 @@ class Dev extends AppCommand {
13
13
  this.setupExitHandlers();
14
14
  }
15
15
  setupExitHandlers() {
16
- const cleanupAndExit = () => {
16
+ const cleanupAndExit = async () => {
17
17
  try {
18
+ console.log('Shutting down...');
19
+ if (this.workers.length > 0) {
20
+ await Promise.allSettled(this.workers.map(worker => worker.cleanup()));
21
+ }
18
22
  if (this.controller) {
19
23
  this.controller.abort();
20
24
  }
21
25
  this.workers = [];
22
- console.log('Shutting down...');
23
26
  setTimeout(() => {
24
27
  process.exit(0);
25
28
  }, 100);
@@ -31,7 +34,10 @@ class Dev extends AppCommand {
31
34
  process.once('SIGINT', cleanupAndExit);
32
35
  process.once('SIGTERM', cleanupAndExit);
33
36
  process.once('SIGQUIT', cleanupAndExit);
34
- process.once('exit', () => {
37
+ process.once('exit', async () => {
38
+ if (this.workers.length > 0) {
39
+ await Promise.allSettled(this.workers.map(worker => worker.cleanup()));
40
+ }
35
41
  if (this.controller) {
36
42
  this.controller.abort();
37
43
  }
@@ -46,13 +52,16 @@ class Dev extends AppCommand {
46
52
  {
47
53
  keyboardKey: 'q',
48
54
  description: 'quit',
49
- handler: () => {
55
+ handler: async () => {
50
56
  try {
57
+ console.log('Shutting down...');
58
+ if (this.workers.length > 0) {
59
+ await Promise.allSettled(this.workers.map(worker => worker.cleanup()));
60
+ }
51
61
  if (this.controller) {
52
62
  this.controller.abort();
53
63
  }
54
64
  this.workers = [];
55
- console.log('Shutting down...');
56
65
  setTimeout(() => {
57
66
  process.exit(0);
58
67
  }, 100);
@@ -106,6 +115,10 @@ class Dev extends AppCommand {
106
115
  }
107
116
  async reloadWorkers() {
108
117
  this.controller = new AbortController();
118
+ if (this.workers.length > 0) {
119
+ await Promise.allSettled(this.workers.map(worker => worker.cleanup()));
120
+ this.workers = [];
121
+ }
109
122
  const networkConfig = this.app.network_config;
110
123
  this.app = await load();
111
124
  this.app.network_config = networkConfig;
@@ -113,6 +126,7 @@ class Dev extends AppCommand {
113
126
  await this.runWorkers(await this.prepareDevProcesses());
114
127
  }
115
128
  async runWorkers(workers) {
129
+ this.workers = workers;
116
130
  await Promise.all(workers.map(worker => worker.run())).catch((_) => { });
117
131
  }
118
132
  async prepareDevProcesses() {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,164 @@
1
+ import { vi, describe, beforeEach, it, expect } from 'vitest';
2
+ import Dev from './dev.js';
3
+
4
+ vi.mock('@/util/app-loader', () => ({
5
+ load: vi.fn().mockResolvedValue({
6
+ root: '/test/app',
7
+ config: { id: 'test-app' },
8
+ webs: [],
9
+ extensions: [],
10
+ }),
11
+ }));
12
+ vi.mock('@youcan/cli-kit', () => ({
13
+ Session: {
14
+ authenticate: vi.fn().mockResolvedValue({}),
15
+ },
16
+ Tasks: {
17
+ run: vi.fn().mockResolvedValue({ workers: [] }),
18
+ },
19
+ UI: {
20
+ renderDevOutput: vi.fn(),
21
+ },
22
+ System: {
23
+ getPortOrNextOrRandom: vi.fn().mockResolvedValue(3000),
24
+ },
25
+ Services: {
26
+ Cloudflared: vi.fn(),
27
+ },
28
+ Filesystem: {
29
+ writeJsonFile: vi.fn(),
30
+ },
31
+ Path: {
32
+ join: vi.fn(),
33
+ },
34
+ Cli: {
35
+ Command: class MockCommand {
36
+ controller = { abort: vi.fn(), signal: new AbortController().signal };
37
+ output = { wait: vi.fn() };
38
+ },
39
+ },
40
+ Env: {},
41
+ Http: {},
42
+ Worker: {
43
+ Interface: class MockInterface {
44
+ async run() { }
45
+ async boot() { }
46
+ async cleanup() { }
47
+ },
48
+ Abstract: class MockAbstract {
49
+ async run() { }
50
+ async boot() { }
51
+ async cleanup() { }
52
+ },
53
+ },
54
+ }));
55
+ vi.spyOn(process, 'exit').mockImplementation(() => undefined);
56
+ const mockOnce = vi.spyOn(process, 'once').mockImplementation(() => process);
57
+ describe('dev Command', () => {
58
+ let devCommand;
59
+ let mockWorkers;
60
+ beforeEach(() => {
61
+ vi.clearAllMocks();
62
+ mockWorkers = [
63
+ {
64
+ run: vi.fn(),
65
+ boot: vi.fn(),
66
+ cleanup: vi.fn().mockResolvedValue(undefined),
67
+ },
68
+ {
69
+ run: vi.fn(),
70
+ boot: vi.fn(),
71
+ cleanup: vi.fn().mockResolvedValue(undefined),
72
+ },
73
+ ];
74
+ devCommand = new Dev([], {});
75
+ devCommand.workers = mockWorkers;
76
+ });
77
+ describe('setupExitHandlers', () => {
78
+ it('should register signal handlers for graceful shutdown', () => {
79
+ devCommand.setupExitHandlers();
80
+ expect(mockOnce).toHaveBeenCalledWith('SIGINT', expect.any(Function));
81
+ expect(mockOnce).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
82
+ expect(mockOnce).toHaveBeenCalledWith('SIGQUIT', expect.any(Function));
83
+ expect(mockOnce).toHaveBeenCalledWith('exit', expect.any(Function));
84
+ });
85
+ });
86
+ describe('cleanup functionality', () => {
87
+ it('should cleanup all workers when cleanupAndExit is called', async () => {
88
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
89
+ devCommand.setupExitHandlers();
90
+ const sigintCall = mockOnce.mock.calls.find(call => call[0] === 'SIGINT');
91
+ const cleanupAndExit = sigintCall?.[1];
92
+ if (cleanupAndExit) {
93
+ await cleanupAndExit();
94
+ }
95
+ expect(consoleSpy).toHaveBeenCalledWith('Shutting down...');
96
+ expect(mockWorkers[0].cleanup).toHaveBeenCalled();
97
+ expect(mockWorkers[1].cleanup).toHaveBeenCalled();
98
+ consoleSpy.mockRestore();
99
+ });
100
+ it('should handle worker cleanup errors gracefully', async () => {
101
+ const failingWorker = {
102
+ run: vi.fn(),
103
+ boot: vi.fn(),
104
+ cleanup: vi.fn().mockRejectedValue(new Error('Cleanup failed')),
105
+ };
106
+ devCommand.workers = [failingWorker];
107
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
108
+ devCommand.setupExitHandlers();
109
+ const sigintCall = mockOnce.mock.calls.find(call => call[0] === 'SIGINT');
110
+ const cleanupAndExit = sigintCall?.[1];
111
+ if (cleanupAndExit) {
112
+ await expect(cleanupAndExit()).resolves.toBeUndefined();
113
+ }
114
+ expect(failingWorker.cleanup).toHaveBeenCalled();
115
+ consoleSpy.mockRestore();
116
+ });
117
+ });
118
+ describe('reloadWorkers', () => {
119
+ it('should cleanup existing workers before reload', async () => {
120
+ const mockController = {
121
+ abort: vi.fn(),
122
+ signal: new AbortController().signal,
123
+ };
124
+ devCommand.controller = mockController;
125
+ devCommand.app = {
126
+ network_config: { app_port: 3000 },
127
+ };
128
+ devCommand.syncAppConfig = vi.fn().mockResolvedValue(undefined);
129
+ devCommand.prepareDevProcesses = vi.fn().mockResolvedValue([]);
130
+ devCommand.runWorkers = vi.fn().mockResolvedValue(undefined);
131
+ await devCommand.reloadWorkers();
132
+ expect(mockWorkers[0].cleanup).toHaveBeenCalled();
133
+ expect(mockWorkers[1].cleanup).toHaveBeenCalled();
134
+ });
135
+ });
136
+ describe('runWorkers', () => {
137
+ it('should store workers in instance variable', async () => {
138
+ const newWorkers = [
139
+ {
140
+ run: vi.fn().mockResolvedValue(undefined),
141
+ boot: vi.fn(),
142
+ cleanup: vi.fn(),
143
+ },
144
+ ];
145
+ await devCommand.runWorkers(newWorkers);
146
+ expect(devCommand.workers).toBe(newWorkers);
147
+ expect(newWorkers[0].run).toHaveBeenCalled();
148
+ });
149
+ });
150
+ describe('hotkey handlers', () => {
151
+ it('should cleanup workers when q key is pressed', async () => {
152
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
153
+ const hotKeys = devCommand.hotKeys;
154
+ const quitHandler = hotKeys.find((key) => key.keyboardKey === 'q')?.handler;
155
+ if (quitHandler) {
156
+ await quitHandler();
157
+ }
158
+ expect(consoleSpy).toHaveBeenCalledWith('Shutting down...');
159
+ expect(mockWorkers[0].cleanup).toHaveBeenCalled();
160
+ expect(mockWorkers[1].cleanup).toHaveBeenCalled();
161
+ consoleSpy.mockRestore();
162
+ });
163
+ });
164
+ });
@@ -5,7 +5,9 @@ export default class AppWorker extends Worker.Abstract {
5
5
  private command;
6
6
  private app;
7
7
  private logger;
8
+ private watcher?;
8
9
  constructor(command: DevCommand, app: App);
9
10
  boot(): Promise<void>;
10
11
  run(): Promise<void>;
12
+ cleanup(): Promise<void>;
11
13
  }
@@ -5,6 +5,7 @@ class AppWorker extends Worker.Abstract {
5
5
  command;
6
6
  app;
7
7
  logger;
8
+ watcher;
8
9
  constructor(command, app) {
9
10
  super();
10
11
  this.command = command;
@@ -15,20 +16,27 @@ class AppWorker extends Worker.Abstract {
15
16
  async run() {
16
17
  await this.command.output.wait(500);
17
18
  this.logger.write('watching for config updates...');
18
- const watcher = Filesystem.watch(Path.resolve(this.app.root, APP_CONFIG_FILENAME), {
19
+ this.watcher = Filesystem.watch(Path.resolve(this.app.root, APP_CONFIG_FILENAME), {
19
20
  persistent: true,
20
21
  ignoreInitial: true,
21
22
  awaitWriteFinish: {
22
23
  stabilityThreshold: 50,
23
24
  },
24
25
  });
25
- watcher.once('change', async () => {
26
- await watcher.close();
26
+ this.watcher.once('change', async () => {
27
+ await this.watcher?.close();
27
28
  this.logger.write('config update detected, reloading workers...');
28
29
  this.command.controller.abort();
29
30
  this.command.reloadWorkers();
30
31
  });
31
32
  }
33
+ async cleanup() {
34
+ this.logger.write('stopping config watcher...');
35
+ if (this.watcher) {
36
+ await this.watcher.close();
37
+ this.watcher = undefined;
38
+ }
39
+ }
32
40
  }
33
41
 
34
42
  export { AppWorker as default };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,103 @@
1
+ import { Filesystem, Path } from '@youcan/cli-kit';
2
+ import { vi, describe, beforeEach, it, expect } from 'vitest';
3
+ import AppWorker from './app-worker.js';
4
+
5
+ vi.mock('@youcan/cli-kit', () => ({
6
+ Filesystem: {
7
+ watch: vi.fn(),
8
+ },
9
+ Path: {
10
+ resolve: vi.fn(),
11
+ },
12
+ Worker: {
13
+ Logger: class MockLogger {
14
+ name;
15
+ color;
16
+ constructor(name, color) {
17
+ this.name = name;
18
+ this.color = color;
19
+ }
20
+ write = vi.fn();
21
+ },
22
+ Abstract: class MockAbstract {
23
+ async cleanup() { }
24
+ },
25
+ },
26
+ }));
27
+ describe('appWorker', () => {
28
+ let appWorker;
29
+ let mockCommand;
30
+ let mockApp;
31
+ let mockWatcher;
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+ mockWatcher = {
35
+ close: vi.fn(),
36
+ once: vi.fn(),
37
+ };
38
+ vi.mocked(Filesystem.watch).mockReturnValue(mockWatcher);
39
+ vi.mocked(Path.resolve).mockReturnValue('/path/to/app.config.json');
40
+ mockCommand = {
41
+ output: {
42
+ wait: vi.fn().mockResolvedValue(undefined),
43
+ },
44
+ controller: {
45
+ abort: vi.fn(),
46
+ },
47
+ reloadWorkers: vi.fn(),
48
+ };
49
+ mockApp = {
50
+ root: '/path/to/app',
51
+ };
52
+ appWorker = new AppWorker(mockCommand, mockApp);
53
+ });
54
+ describe('cleanup', () => {
55
+ it('should close the file watcher if it exists', async () => {
56
+ await appWorker.run();
57
+ expect(Filesystem.watch).toHaveBeenCalled();
58
+ await appWorker.cleanup();
59
+ expect(mockWatcher.close).toHaveBeenCalled();
60
+ });
61
+ it('should handle cleanup when no watcher exists', async () => {
62
+ await expect(appWorker.cleanup()).resolves.toBeUndefined();
63
+ });
64
+ it('should log cleanup message', async () => {
65
+ await appWorker.run();
66
+ await appWorker.cleanup();
67
+ const logger = appWorker.logger;
68
+ expect(logger.write).toHaveBeenCalledWith('stopping config watcher...');
69
+ });
70
+ it('should set watcher to undefined after cleanup', async () => {
71
+ await appWorker.run();
72
+ expect(appWorker.watcher).toBe(mockWatcher);
73
+ await appWorker.cleanup();
74
+ expect(appWorker.watcher).toBeUndefined();
75
+ });
76
+ });
77
+ describe('run', () => {
78
+ it('should create a file watcher for the app config', async () => {
79
+ await appWorker.run();
80
+ expect(Path.resolve).toHaveBeenCalledWith('/path/to/app', 'youcan.app.json');
81
+ expect(Filesystem.watch).toHaveBeenCalledWith('/path/to/app.config.json', {
82
+ persistent: true,
83
+ ignoreInitial: true,
84
+ awaitWriteFinish: {
85
+ stabilityThreshold: 50,
86
+ },
87
+ });
88
+ });
89
+ it('should set up change handler that reloads workers', async () => {
90
+ await appWorker.run();
91
+ expect(mockWatcher.once).toHaveBeenCalledWith('change', expect.any(Function));
92
+ const changeHandler = mockWatcher.once.mock.calls[0][1];
93
+ await changeHandler();
94
+ expect(mockWatcher.close).toHaveBeenCalled();
95
+ expect(mockCommand.controller.abort).toHaveBeenCalled();
96
+ expect(mockCommand.reloadWorkers).toHaveBeenCalled();
97
+ });
98
+ it('should wait 500ms before starting to watch', async () => {
99
+ await appWorker.run();
100
+ expect(mockCommand.output.wait).toHaveBeenCalledWith(500);
101
+ });
102
+ });
103
+ });
@@ -11,6 +11,7 @@ export default class TunnelWorker extends Worker.Abstract {
11
11
  constructor(command: AppCommand, app: App, tunnelService: Services.Cloudflared);
12
12
  boot(): Promise<void>;
13
13
  run(): Promise<void>;
14
+ cleanup(): Promise<void>;
14
15
  private checkForError;
15
16
  getUrl(): string;
16
17
  }
@@ -18,7 +18,7 @@ class TunnelWorker extends Worker.Abstract {
18
18
  throw new Error('app network config is not set');
19
19
  }
20
20
  this.logger.write('start tunneling the app');
21
- await this.tunnelService.tunnel(this.app.network_config.app_port);
21
+ await this.tunnelService.tunnel(this.app.network_config.app_port, 'localhost', this.command.controller.signal);
22
22
  let attempts = 0;
23
23
  while (!this.url && attempts <= 28) {
24
24
  const url = this.tunnelService.getUrl();
@@ -37,6 +37,10 @@ class TunnelWorker extends Worker.Abstract {
37
37
  async run() {
38
38
  setInterval(() => this.checkForError, 500);
39
39
  }
40
+ async cleanup() {
41
+ this.logger.write('stopping tunnel...');
42
+ // The abort signal passed to cloudflared will handle process termination
43
+ }
40
44
  checkForError() {
41
45
  const error = this.tunnelService.getError();
42
46
  if (error) {
@@ -6,7 +6,9 @@ export default class WebWorker extends Worker.Abstract {
6
6
  private readonly web;
7
7
  private readonly env;
8
8
  private logger;
9
+ private processPort?;
9
10
  constructor(command: Cli.Command, app: App, web: Web, env: Record<string, string>);
10
11
  boot(): Promise<void>;
11
12
  run(): Promise<void>;
13
+ cleanup(): Promise<void>;
12
14
  }
@@ -6,6 +6,7 @@ class WebWorker extends Worker.Abstract {
6
6
  web;
7
7
  env;
8
8
  logger;
9
+ processPort;
9
10
  constructor(command, app, web, env) {
10
11
  super();
11
12
  this.command = command;
@@ -13,6 +14,7 @@ class WebWorker extends Worker.Abstract {
13
14
  this.web = web;
14
15
  this.env = env;
15
16
  this.logger = new Worker.Logger(this.web.config.name || 'web', 'blue');
17
+ this.processPort = this.env.PORT ? Number.parseInt(this.env.PORT, 10) : undefined;
16
18
  }
17
19
  async boot() {
18
20
  }
@@ -25,6 +27,20 @@ class WebWorker extends Worker.Abstract {
25
27
  env: this.env,
26
28
  });
27
29
  }
30
+ async cleanup() {
31
+ this.logger.write('stopping web server...');
32
+ if (this.processPort) {
33
+ try {
34
+ if (!await System.isPortAvailable(this.processPort)) {
35
+ await System.killPortProcess(this.processPort);
36
+ this.logger.write(`killed process on port ${this.processPort}`);
37
+ }
38
+ }
39
+ catch (error) {
40
+ // Ignore errors when killing port process
41
+ }
42
+ }
43
+ }
28
44
  }
29
45
 
30
46
  export { WebWorker as default };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,101 @@
1
+ import { System } from '@youcan/cli-kit';
2
+ import { vi, describe, beforeEach, it, expect } from 'vitest';
3
+ import WebWorker from './web-worker.js';
4
+
5
+ vi.mock('@youcan/cli-kit', () => ({
6
+ System: {
7
+ exec: vi.fn(),
8
+ isPortAvailable: vi.fn(),
9
+ killPortProcess: vi.fn(),
10
+ },
11
+ Worker: {
12
+ Logger: class MockLogger {
13
+ name;
14
+ color;
15
+ constructor(name, color) {
16
+ this.name = name;
17
+ this.color = color;
18
+ }
19
+ write = vi.fn();
20
+ },
21
+ Abstract: class MockAbstract {
22
+ async cleanup() { }
23
+ },
24
+ },
25
+ }));
26
+ describe('webWorker', () => {
27
+ let webWorker;
28
+ let mockCommand;
29
+ let mockApp;
30
+ let mockWeb;
31
+ let mockEnv;
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+ mockCommand = {
35
+ controller: {
36
+ signal: new AbortController().signal,
37
+ },
38
+ };
39
+ mockApp = {};
40
+ mockWeb = {
41
+ config: {
42
+ name: 'test-web',
43
+ commands: {
44
+ dev: 'npm start',
45
+ },
46
+ },
47
+ };
48
+ mockEnv = {
49
+ PORT: '3001',
50
+ NODE_ENV: 'development',
51
+ };
52
+ webWorker = new WebWorker(mockCommand, mockApp, mockWeb, mockEnv);
53
+ });
54
+ describe('cleanup', () => {
55
+ it('should kill process on the assigned port when port is not available', async () => {
56
+ vi.mocked(System.isPortAvailable).mockResolvedValue(false);
57
+ vi.mocked(System.killPortProcess).mockResolvedValue();
58
+ await webWorker.cleanup();
59
+ expect(System.isPortAvailable).toHaveBeenCalledWith(3001);
60
+ expect(System.killPortProcess).toHaveBeenCalledWith(3001);
61
+ });
62
+ it('should not kill process when port is already available', async () => {
63
+ vi.mocked(System.isPortAvailable).mockResolvedValue(true);
64
+ await webWorker.cleanup();
65
+ expect(System.isPortAvailable).toHaveBeenCalledWith(3001);
66
+ expect(System.killPortProcess).not.toHaveBeenCalled();
67
+ });
68
+ it('should handle missing PORT environment variable gracefully', async () => {
69
+ const webWorkerWithoutPort = new WebWorker(mockCommand, mockApp, mockWeb, { NODE_ENV: 'development' });
70
+ await expect(webWorkerWithoutPort.cleanup()).resolves.toBeUndefined();
71
+ expect(System.isPortAvailable).not.toHaveBeenCalled();
72
+ expect(System.killPortProcess).not.toHaveBeenCalled();
73
+ });
74
+ it('should handle killPortProcess errors gracefully', async () => {
75
+ vi.mocked(System.isPortAvailable).mockResolvedValue(false);
76
+ vi.mocked(System.killPortProcess).mockRejectedValue(new Error('Process not found'));
77
+ await expect(webWorker.cleanup()).resolves.toBeUndefined();
78
+ expect(System.killPortProcess).toHaveBeenCalledWith(3001);
79
+ });
80
+ it('should log cleanup messages', async () => {
81
+ vi.mocked(System.isPortAvailable).mockResolvedValue(false);
82
+ vi.mocked(System.killPortProcess).mockResolvedValue();
83
+ await webWorker.cleanup();
84
+ const logger = webWorker.logger;
85
+ expect(logger.write).toHaveBeenCalledWith('stopping web server...');
86
+ expect(logger.write).toHaveBeenCalledWith('killed process on port 3001');
87
+ });
88
+ });
89
+ describe('run', () => {
90
+ it('should execute the web command with correct parameters', async () => {
91
+ vi.mocked(System.exec).mockResolvedValue();
92
+ await webWorker.run();
93
+ expect(System.exec).toHaveBeenCalledWith('npm', ['start'], {
94
+ stdout: expect.any(Object),
95
+ signal: mockCommand.controller.signal,
96
+ stderr: expect.any(Object),
97
+ env: mockEnv,
98
+ });
99
+ });
100
+ });
101
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@youcan/app",
3
3
  "type": "module",
4
- "version": "2.4.0",
4
+ "version": "2.4.1",
5
5
  "description": "OCLIF plugin for building apps",
6
6
  "author": "YouCan <contact@youcan.shop> (https://youcan.shop)",
7
7
  "license": "MIT",
@@ -17,7 +17,7 @@
17
17
  "dependencies": {
18
18
  "@oclif/core": "^2.15.0",
19
19
  "dayjs": "^1.11.10",
20
- "@youcan/cli-kit": "2.4.0"
20
+ "@youcan/cli-kit": "2.4.1"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@oclif/plugin-legacy": "^1.3.0",
@@ -35,6 +35,8 @@
35
35
  "scripts": {
36
36
  "build": "shx rm -rf dist && tsc --noEmit && rollup --config rollup.config.js",
37
37
  "dev": "shx rm -rf dist && rollup --config rollup.config.js --watch",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
38
40
  "release": "pnpm publish --access public",
39
41
  "type-check": "tsc --noEmit"
40
42
  }