@travetto/runtime 7.0.5 → 7.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.
package/README.md CHANGED
@@ -35,12 +35,12 @@ While running any code within the framework, there are common patterns/goals for
35
35
  ```typescript
36
36
  class $Runtime {
37
37
  constructor(idx: ManifestIndex, resourceOverrides?: Record<string, string>);
38
- /** Get env name, with support for the default env */
39
- get env(): string | undefined;
38
+ /** The role we are running as */
39
+ get role(): Role;
40
40
  /** Are we in production mode */
41
41
  get production(): boolean;
42
- /** Get environment type mode */
43
- get envType(): 'production' | 'development' | 'test';
42
+ /** Are we in development mode */
43
+ get localDevelopment(): boolean;
44
44
  /** Get debug value */
45
45
  get debug(): false | string;
46
46
  /** Manifest main */
@@ -87,15 +87,11 @@ interface EnvData {
87
87
  */
88
88
  NODE_ENV: 'development' | 'production';
89
89
  /**
90
- * Outputs all console.debug messages, defaults to `local` in dev, and `off` in prod.
90
+ * Outputs all console.debug messages, defaults to off
91
91
  */
92
92
  DEBUG: boolean | string;
93
93
  /**
94
- * Environment to deploy, defaults to `NODE_ENV` if not `TRV_ENV` is not specified.
95
- */
96
- TRV_ENV: string;
97
- /**
98
- * Special role to run as, used to access additional files from the manifest during runtime.
94
+ * The role we are running as, allows access to additional files from the manifest during runtime.
99
95
  */
100
96
  TRV_ROLE: Role;
101
97
  /**
@@ -112,6 +108,11 @@ interface EnvData {
112
108
  * @default 2s
113
109
  */
114
110
  TRV_SHUTDOWN_WAIT: TimeSpan | number;
111
+ /**
112
+ * The time to wait for stdout to drain during shutdown
113
+ * @default 0s
114
+ */
115
+ TRV_SHUTDOWN_STDOUT_WAIT: TimeSpan | number;
115
116
  /**
116
117
  * The desired runtime module
117
118
  */
@@ -214,7 +215,7 @@ export function work() {
214
215
  ```javascript
215
216
  import * as Δfunction from "@travetto/runtime/src/function.js";
216
217
  import * as Δconsole from "@travetto/runtime/src/console.js";
217
- var Δm_1 = ["@travetto/runtime", "doc/transpile.ts"];
218
+ const Δm_1 = ["@travetto/runtime", "doc/transpile.ts"];
218
219
  export function work() {
219
220
  Δconsole.log({ level: "debug", import: Δm_1, line: 2, scope: "work", args: ['Start Work'] });
220
221
  try {
@@ -282,7 +283,7 @@ tpl`{{age:20}} {{name: 'bob'}}</>;
282
283
  ```
283
284
 
284
285
  ## Time Utilities
285
- [TimeUtil](https://github.com/travetto/travetto/tree/main/module/runtime/src/time.ts#L19) contains general helper methods, created to assist with time-based inputs via environment variables, command line interfaces, and other string-heavy based input.
286
+ [TimeUtil](https://github.com/travetto/travetto/tree/main/module/runtime/src/time.ts#L20) contains general helper methods, created to assist with time-based inputs via environment variables, command line interfaces, and other string-heavy based input.
286
287
 
287
288
  **Code: Time Utilities**
288
289
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/runtime",
3
- "version": "7.0.5",
3
+ "version": "7.1.0",
4
4
  "type": "module",
5
5
  "description": "Runtime for travetto applications.",
6
6
  "keywords": [
@@ -26,12 +26,12 @@
26
26
  "directory": "module/runtime"
27
27
  },
28
28
  "dependencies": {
29
- "@travetto/manifest": "^7.0.4",
29
+ "@travetto/manifest": "^7.1.0",
30
30
  "@types/debug": "^4.1.12",
31
31
  "debug": "^4.4.3"
32
32
  },
33
33
  "peerDependencies": {
34
- "@travetto/transformer": "^7.0.5"
34
+ "@travetto/transformer": "^7.1.0"
35
35
  },
36
36
  "peerDependenciesMeta": {
37
37
  "@travetto/transformer": {
package/src/context.ts CHANGED
@@ -7,6 +7,7 @@ import { Env } from './env.ts';
7
7
  import { RuntimeIndex } from './manifest-index.ts';
8
8
  import { describeFunction } from './function.ts';
9
9
  import { JSONUtil } from './json.ts';
10
+ import type { Role } from './trv';
10
11
 
11
12
  /** Constrained version of {@type ManifestContext} */
12
13
  class $Runtime {
@@ -26,9 +27,10 @@ class $Runtime {
26
27
  };
27
28
  }
28
29
 
29
- /** Get env name, with support for the default env */
30
- get env(): string | undefined {
31
- return Env.TRV_ENV.value || (!this.production ? this.#idx.manifest.workspace.defaultEnv : undefined);
30
+ /** The role we are running as */
31
+ get role(): Role {
32
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
33
+ return Env.TRV_ROLE.value as Role ?? 'std';
32
34
  }
33
35
 
34
36
  /** Are we in production mode */
@@ -36,19 +38,14 @@ class $Runtime {
36
38
  return process.env.NODE_ENV === 'production';
37
39
  }
38
40
 
39
- /** Get environment type mode */
40
- get envType(): 'production' | 'development' | 'test' {
41
- switch (process.env.NODE_ENV) {
42
- case 'production': return 'production';
43
- case 'test': return 'test';
44
- default: return 'development';
45
- }
41
+ /** Are we in development mode */
42
+ get localDevelopment(): boolean {
43
+ return !this.production && this.role === 'std';
46
44
  }
47
45
 
48
46
  /** Get debug value */
49
47
  get debug(): false | string {
50
- const value = Env.DEBUG.value ?? '';
51
- return (!value && this.production) || Env.DEBUG.isFalse ? false : value;
48
+ return Env.DEBUG.isFalse ? false : (Env.DEBUG.value || false);
52
49
  }
53
50
 
54
51
  /** Manifest main */
package/src/exec.ts CHANGED
@@ -42,29 +42,19 @@ export class ExecUtil {
42
42
  /**
43
43
  * Defer control to subprocess execution, mainly used for nested execution
44
44
  */
45
- static async deferToSubprocess(child: ChildProcess, relayInterrupt: boolean = true): Promise<ExecutionResult> {
46
- if (!relayInterrupt) {
47
- process.removeAllListeners('SIGINT'); // Remove any existing listeners
48
- process.on('SIGINT', () => { }); // Prevents SIGINT from killing parent process, the child will handle
45
+ static async deferToSubprocess(child: ChildProcess): Promise<ExecutionResult> {
46
+ const messageToChild = (value: unknown): void => { child.send(value!); };
47
+ const messageToParent = (value: unknown): void => { process.send?.(value); };
48
+
49
+ try {
50
+ process.on('message', messageToChild);
51
+ child.on('message', messageToParent);
52
+ const result = await this.getResult(child, { catch: true });
53
+ process.exitCode = child.exitCode;
54
+ return result;
55
+ } finally {
56
+ process.off('message', messageToChild);
49
57
  }
50
-
51
- child.on('message', value => process.send?.(value));
52
-
53
- const interrupt = (): void => { child?.kill('SIGINT'); };
54
- const toMessage = (value: unknown): void => { child?.send(value!); };
55
-
56
- // Proxy kill requests
57
- process.on('message', toMessage);
58
-
59
- if (relayInterrupt) {
60
- process.on('SIGINT', interrupt);
61
- }
62
-
63
- const result = await ExecUtil.getResult(child, { catch: true });
64
- process.exitCode = child.exitCode;
65
- process.off('message', toMessage);
66
- process.off('SIGINT', interrupt);
67
- return result;
68
58
  }
69
59
 
70
60
  /**
package/src/shutdown.ts CHANGED
@@ -2,93 +2,99 @@ import { Env } from './env.ts';
2
2
  import { Util } from './util.ts';
3
3
  import { TimeUtil } from './time.ts';
4
4
 
5
+ type Handler = (event: Event) => unknown;
6
+
5
7
  /**
6
8
  * Shutdown manager, allowing for listening for graceful shutdowns
7
9
  */
8
10
  export class ShutdownManager {
9
11
 
10
- static #registered = false;
11
- static #handlers: { scope?: string, handler: () => (void | Promise<void>) }[] = [];
12
- static #pending: (PromiseWithResolvers<void> & { time: number }) | undefined;
12
+ static #shouldIgnoreInterrupt = false;
13
+ static #registered = new Map<Handler, Handler>();
14
+ static #pending: Function[] = [];
15
+ static #controller = new AbortController();
16
+ static #startedAt: number = 0;
17
+ static #addListener = this.#controller.signal.addEventListener.bind(this.#controller.signal);
18
+ static #removeListener = this.#controller.signal.removeEventListener.bind(this.#controller.signal);
13
19
 
14
- static #ensureExitListeners(): void {
15
- if (this.#registered) {
16
- return;
17
- }
18
- this.#registered = true;
19
- const cleanup = (signal: string): void => {
20
- if (this.#pending && (Date.now() - this.#pending.time) > 500) {
21
- console.warn('Shutdown already in progress, exiting immediately', { signal });
22
- process.exit(0); // Quit immediately
23
- }
24
- this.gracefulShutdown(signal).then(() => process.exit(0));
20
+ static {
21
+ this.#controller.signal.addEventListener = (type: 'abort', listener: Handler): void => this.onGracefulShutdown(listener);
22
+ this.#controller.signal.removeEventListener = (type: 'abort', listener: Handler): void => {
23
+ this.#removeListener(type, this.#registered.get(listener) ?? listener);
25
24
  };
26
- if (process.stdout.isTTY) {
27
- process.on('SIGINT', () => process.stdout.write('\n')); // Ensure we get a newline on ctrl-c
28
- }
29
- process.on('SIGUSR2', cleanup);
30
- process.on('SIGTERM', cleanup);
31
- process.on('SIGINT', cleanup);
32
25
  }
33
26
 
34
- /**
35
- * On Shutdown requested
36
- * @param source The source of the shutdown request, for logging purposes
37
- * @param handler synchronous or asynchronous handler
38
- */
39
- static onGracefulShutdown(handler: () => (void | Promise<void>), scope?: string): () => void {
40
- this.#ensureExitListeners();
41
- this.#handlers.push({ handler, scope });
42
- return () => {
43
- const idx = this.#handlers.findIndex(item => item.handler === handler);
44
- if (idx >= 0) {
45
- this.#handlers.splice(idx, 1);
46
- }
47
- };
27
+ static get signal(): AbortSignal {
28
+ return this.#controller.signal;
29
+ }
30
+
31
+ static disableInterrupt(): void {
32
+ this.#shouldIgnoreInterrupt = true;
33
+ }
34
+
35
+ static onGracefulShutdown(listener: Handler): void {
36
+ if (!this.#registered.size) {
37
+ process
38
+ .on('SIGINT', () => this.shutdown('SIGINT')) // Ensure we get a newline on ctrl-c
39
+ .on('SIGUSR2', () => this.shutdown('SIGUSR2'))
40
+ .on('SIGTERM', () => this.shutdown('SIGTERM'));
41
+ }
42
+
43
+ const wrappedListener: Handler = event => { this.#pending.push(() => listener(event)); };
44
+ this.#registered.set(listener, wrappedListener);
45
+ return this.#addListener('abort', wrappedListener);
48
46
  }
49
47
 
50
48
  /**
51
- * Wait for graceful shutdown to run and complete
49
+ * Shutdown the application gracefully
52
50
  */
53
- static async gracefulShutdown(source: string): Promise<void> {
54
- if (this.#pending) {
55
- return this.#pending.promise;
56
- } else if (!this.#handlers.length) {
51
+ static async shutdown(source?: string, code?: number): Promise<void> {
52
+ if (this.#shouldIgnoreInterrupt && source === 'SIGINT') {
57
53
  return;
58
54
  }
55
+ if (this.#controller.signal.aborted) {
56
+ if (this.#startedAt && (Date.now() - this.#startedAt) > 500) {
57
+ console.warn('Shutdown already in progress, exiting immediately', { source });
58
+ process.exit(0); // Quit immediately
59
+ } else {
60
+ return;
61
+ }
62
+ }
63
+
64
+ if (process.stdout.isTTY && source === 'SIGINT') {
65
+ process.stdout.write('\n');
66
+ }
59
67
 
60
- this.#pending = { ...Promise.withResolvers<void>(), time: Date.now() };
68
+ process.removeAllListeners('message'); // Allow shutdown if anything is still listening
61
69
 
70
+ const context = source ? [{ source, pid: process.pid }] : [{ pid: process.pid }];
71
+ this.#startedAt = Date.now();
72
+ this.#controller.abort('Shutdown started');
62
73
  await Util.queueMacroTask(); // Force the event loop to wait one cycle
63
74
 
75
+ console.debug('Shutdown started', ...context, { pending: this.#pending.length });
76
+
64
77
  const timeout = TimeUtil.fromValue(Env.TRV_SHUTDOWN_WAIT.value) ?? 2000;
65
- const items = this.#handlers.splice(0, this.#handlers.length);
66
- console.debug('Graceful shutdown: started', { source, timeout, count: items.length });
67
- const handlers = Promise.all(items.map(async ({ scope, handler }) => {
68
- if (scope) {
69
- console.debug('Stopping', { scope });
70
- }
71
- try {
72
- await handler();
73
- if (scope) {
74
- console.debug('Stopped', { scope });
75
- }
76
- } catch (error) {
77
- console.error('Error stopping', { error, scope });
78
- }
79
- }));
78
+ const timeoutTasks = Util.nonBlockingTimeout(timeout).then(() => this);
79
+ const allPendingTasks = Promise.all(this.#pending.map(fn => Promise.resolve(fn()).catch(err => {
80
+ console.error('Error during shutdown task', err, ...context);
81
+ })));
80
82
 
81
- const winner = await Promise.race([
82
- Util.nonBlockingTimeout(timeout).then(() => this), // Wait N seconds and then give up if not done
83
- handlers,
84
- ]);
83
+ const winner = await Promise.race([timeoutTasks, allPendingTasks]);
84
+
85
+ process.exitCode = code ?? process.exitCode ?? 0;
85
86
 
86
87
  if (winner !== this) {
87
- console.debug('Graceful shutdown: completed');
88
+ console.debug('Shutdown completed', ...context);
88
89
  } else {
89
- console.debug('Graceful shutdown: timed-out', { timeout });
90
+ console.warn('Shutdown timed out', ...context);
91
+ }
92
+
93
+ if (Env.TRV_SHUTDOWN_STDOUT_WAIT.isSet) {
94
+ const stdoutDrain = TimeUtil.fromValue(Env.TRV_SHUTDOWN_STDOUT_WAIT.value)!;
95
+ await Util.blockingTimeout(stdoutDrain);
90
96
  }
91
97
 
92
- this.#pending.resolve();
98
+ process.exit();
93
99
  }
94
100
  }
package/src/time.ts CHANGED
@@ -15,6 +15,7 @@ export type TimeSpan = `${number}${keyof typeof TIME_UNITS}`;
15
15
  export type TimeUnit = keyof typeof TIME_UNITS;
16
16
 
17
17
  const TIME_PATTERN = new RegExp(`^(?<amount>-?[0-9.]+)(?<unit>${Object.keys(TIME_UNITS).join('|')})$`);
18
+ const TIME_LIKE_STRING = /\d{1,30}[a-z]$/i;
18
19
 
19
20
  export class TimeUtil {
20
21
 
@@ -66,14 +67,19 @@ export class TimeUtil {
66
67
  * Resolve time or span to possible time
67
68
  */
68
69
  static fromValue(value: Date | number | string | undefined): number | undefined {
69
- if (value === undefined) {
70
- return value;
70
+ switch (typeof value) {
71
+ case 'number': return Number.isNaN(value) ? undefined : value;
72
+ case 'object': return value.getTime();
73
+ case 'undefined': return undefined;
74
+ case 'string': {
75
+ if (TIME_LIKE_STRING.test(value)) { // Looks like span
76
+ return this.isTimeSpan(value) ? this.asMillis(value) : undefined;
77
+ } else {
78
+ const parsed = parseInt(value, 10);
79
+ return Number.isNaN(parsed) ? undefined : parsed;
80
+ }
81
+ }
71
82
  }
72
- const result = (typeof value === 'string' && /\d{1,30}[a-z]$/i.test(value)) ?
73
- (this.isTimeSpan(value) ? this.asMillis(value) : undefined) :
74
- (typeof value === 'string' ? parseInt(value, 10) :
75
- (value instanceof Date ? value.getTime() : value));
76
- return Number.isNaN(result) ? undefined : result;
77
83
  }
78
84
 
79
85
  /**
@@ -90,11 +96,16 @@ export class TimeUtil {
90
96
  * @param time Time in milliseconds
91
97
  */
92
98
  static asClock(time: number): string {
93
- const seconds = Math.trunc(time / 1000);
94
- return [
95
- seconds > 3600 ? `${Math.trunc(seconds / 3600).toString().padStart(2, '0')}h` : '',
96
- seconds > 60 ? `${Math.trunc((seconds % 3600) / 60).toString().padStart(2, '0')}m` : '',
97
- `${(seconds % 60).toString().padStart(2, '0')}s`
98
- ].filter(part => !!part).slice(0, 2).join(' ');
99
+ const rawSeconds = Math.trunc(time / 1000);
100
+ const seconds = rawSeconds % 60;
101
+ const minutes = Math.trunc(rawSeconds / 60) % 60;
102
+ const hours = Math.trunc(rawSeconds / 3600);
103
+ if (hours) {
104
+ return `${hours.toString().padStart(2, '0')}h ${minutes.toString().padStart(2, '0')}m`;
105
+ } else if (minutes) {
106
+ return `${minutes.toString().padStart(2, '0')}m ${seconds.toString().padStart(2, '0')}s`;
107
+ } else {
108
+ return `${seconds.toString().padStart(2, '0')}s`;
109
+ }
99
110
  }
100
111
  }
package/src/trv.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type ManifestModuleRole } from '@travetto/manifest';
2
2
  import { type TimeSpan } from './time.ts';
3
- type Role = Exclude<ManifestModuleRole, 'std' | 'compile'>;
3
+ type Role = Exclude<ManifestModuleRole, 'compile'>;
4
4
 
5
5
  declare module "@travetto/runtime" {
6
6
  interface EnvData {
@@ -10,15 +10,11 @@ declare module "@travetto/runtime" {
10
10
  */
11
11
  NODE_ENV: 'development' | 'production';
12
12
  /**
13
- * Outputs all console.debug messages, defaults to `local` in dev, and `off` in prod.
13
+ * Outputs all console.debug messages, defaults to off
14
14
  */
15
15
  DEBUG: boolean | string;
16
16
  /**
17
- * Environment to deploy, defaults to `NODE_ENV` if not `TRV_ENV` is not specified.
18
- */
19
- TRV_ENV: string;
20
- /**
21
- * Special role to run as, used to access additional files from the manifest during runtime.
17
+ * The role we are running as, allows access to additional files from the manifest during runtime.
22
18
  */
23
19
  TRV_ROLE: Role;
24
20
  /**
@@ -35,6 +31,11 @@ declare module "@travetto/runtime" {
35
31
  * @default 2s
36
32
  */
37
33
  TRV_SHUTDOWN_WAIT: TimeSpan | number;
34
+ /**
35
+ * The time to wait for stdout to drain during shutdown
36
+ * @default 0s
37
+ */
38
+ TRV_SHUTDOWN_STDOUT_WAIT: TimeSpan | number;
38
39
  /**
39
40
  * The desired runtime module
40
41
  */
package/src/watch.ts CHANGED
@@ -31,26 +31,28 @@ export class WatchUtil {
31
31
 
32
32
  /** Convert exit code to a result type */
33
33
  static exitCodeToResult(code: number): RetryRunState['result'] {
34
- return code === this.#RESTART_EXIT_CODE ? 'restart' : code > 0 ? 'error' : 'stop';
34
+ return code === this.#RESTART_EXIT_CODE ? 'restart' : (code !== null && code > 0) ? 'error' : 'stop';
35
35
  }
36
36
 
37
37
  /** Exit with a restart exit code */
38
38
  static exitWithRestart(): void {
39
- process.exit(this.#RESTART_EXIT_CODE);
39
+ ShutdownManager.shutdown('SIGTERM', this.#RESTART_EXIT_CODE);
40
40
  }
41
41
 
42
42
  /** Listen for restart signals */
43
- static listenForRestartSignal(): void {
44
- const listener = (event: unknown): void => {
45
- if (event === 'WATCH_RESTART') { this.exitWithRestart(); }
46
- };
47
- process.on('message', listener);
48
- ShutdownManager.onGracefulShutdown(() => { process.removeListener('message', listener); });
43
+ static listenForSignals(): void {
44
+ ShutdownManager.disableInterrupt();
45
+ process.on('message', event => {
46
+ switch (event) {
47
+ case 'WATCH_RESTART': this.exitWithRestart(); break;
48
+ case 'WATCH_SHUTDOWN': ShutdownManager.shutdown('SIGTERM', 0); break;
49
+ }
50
+ });
49
51
  }
50
52
 
51
- /** Trigger a restart signal to a subprocess */
52
- static triggerRestartSignal(subprocess?: ChildProcess): void {
53
- subprocess?.connected && subprocess.send?.('WATCH_RESTART');
53
+ /** Trigger a watch signal signal to a subprocess */
54
+ static triggerSignal(subprocess: ChildProcess, signal: 'WATCH_RESTART' | 'WATCH_SHUTDOWN'): void {
55
+ subprocess.connected && subprocess.send?.(signal);
54
56
  }
55
57
 
56
58
  /** Compute the delay before restarting */
@@ -64,8 +66,6 @@ export class WatchUtil {
64
66
  * Run with restart capability
65
67
  */
66
68
  static async runWithRetry(run: (state: RetryRunState & { signal: AbortSignal }) => Promise<RetryRunState['result']>, options?: Partial<RetryRunConfig>): Promise<void> {
67
- const controller = new AbortController();
68
- const cleanup = ShutdownManager.onGracefulShutdown(() => controller.abort());
69
69
  let retryExhausted = false;
70
70
 
71
71
  const state: RetryRunState = {
@@ -82,14 +82,14 @@ export class WatchUtil {
82
82
  };
83
83
 
84
84
 
85
- while (!controller.signal.aborted && !retryExhausted) {
85
+ outer: while (!ShutdownManager.signal.aborted && !retryExhausted) {
86
86
  if (state.iteration > 0) {
87
87
  await config.onRetry(state, config);
88
88
  }
89
89
 
90
- state.result = await run({ ...state, signal: controller.signal }).catch(() => 'error' as const);
90
+ state.result = await run({ ...state, signal: ShutdownManager.signal }).catch(() => 'error' as const);
91
91
  switch (state.result) {
92
- case 'stop': controller.abort(); break;
92
+ case 'stop': break outer;
93
93
  case 'error': state.errorIterations += 1; break;
94
94
  case 'restart': {
95
95
  state.startTime = Date.now();
@@ -104,8 +104,6 @@ export class WatchUtil {
104
104
  if (retryExhausted) {
105
105
  throw new AppError(`Operation failed after ${state.errorIterations} attempts`);
106
106
  }
107
-
108
- cleanup?.();
109
107
  }
110
108
 
111
109
  /** Watch compiler events */