@evolu/nodejs 3.0.0-next.2 → 3.0.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/src/Task.ts CHANGED
@@ -5,89 +5,198 @@
5
5
  */
6
6
 
7
7
  import {
8
- createRun as createCommonRun,
9
- createUnknownError,
10
- type CreateRun,
8
+ createConsole,
9
+ createRun,
10
+ isDisposable,
11
+ ok,
12
+ waitForAbort,
13
+ type ConsoleDep,
14
+ type ReportDefectDep,
15
+ type Resource,
11
16
  type Run,
12
- type RunDeps,
17
+ type RunCustomDeps,
18
+ type Task,
19
+ type Typed,
13
20
  } from "@evolu/common";
14
21
 
15
22
  /**
16
- * A promise that resolves when a termination signal is received.
23
+ * An abort requested by a Node.js termination signal.
17
24
  *
18
- * Resolves on `SIGINT` (Ctrl-C), `SIGTERM` (OS/k8s/Docker termination),
19
- * `SIGHUP` (console close/terminal disconnect), or `SIGBREAK` (Windows
20
- * Ctrl-Break).
21
- *
22
- * @group Node.js Run
25
+ * @group Node.js Task
23
26
  */
24
- export type Shutdown = Promise<void>;
27
+ export interface NodeSignalAbortReason extends Typed<"NodeSignalAbortReason"> {
28
+ readonly signal: NodeSignal;
29
+ }
30
+
31
+ /** A Node.js termination signal handled by {@link runMain}. */
32
+ export type NodeSignal = "SIGINT" | "SIGTERM" | "SIGBREAK";
33
+
34
+ /** Process lifecycle behavior for {@link runMain}. */
35
+ export type RunMainMode = "service" | "command";
25
36
 
26
- export interface ShutdownDep {
27
- readonly shutdown: Shutdown;
37
+ /** Options for {@link runMain}. */
38
+ export interface RunMainOptions {
39
+ /**
40
+ * How termination signals affect the process exit status.
41
+ *
42
+ * Services treat a gracefully handled signal as a successful shutdown.
43
+ * Commands use the conventional `128 + signal number` exit status unless a
44
+ * reported defect has already set a failure status.
45
+ *
46
+ * @default "service"
47
+ */
48
+ readonly mode?: RunMainMode;
28
49
  }
29
50
 
30
51
  /**
31
- * Creates {@link Run} for Node.js with global error handling and graceful
32
- * shutdown.
52
+ * Runs the main Task as the Node.js program lifecycle.
53
+ *
54
+ * Creates one root {@link Run} and aborts it on:
55
+ *
56
+ * - `SIGINT`: Ctrl-C on all platforms.
57
+ * - `SIGTERM`: OS, service, Docker, or Kubernetes termination on Unix.
58
+ * - `SIGBREAK`: Ctrl-Break on Windows.
59
+ *
60
+ * The first signal logs shutdown progress, aborts the root Run, and waits for
61
+ * the main Task and structured cleanup to finish. A subsequent signal exits
62
+ * immediately with its conventional signal status, abandoning cleanup. A signal
63
+ * received during final cleanup still applies signal shutdown behavior.
33
64
  *
34
- * Registers `uncaughtException` and `unhandledRejection` handlers that log
35
- * errors and initiate graceful shutdown. Adds a `shutdown` promise to deps that
36
- * resolves on termination signals (`SIGINT`, `SIGTERM`, `SIGHUP`). Handlers are
37
- * removed when the Run is disposed.
65
+ * A main Task returning {@link Resource} keeps the program running until a
66
+ * termination signal and is disposed during shutdown. A main Task returning
67
+ * `void` completes the program immediately. A Resource result transfers
68
+ * ownership of a live resource that must remain valid after its creating Task
69
+ * settles.
38
70
  *
39
- * ### Example
71
+ * Service mode treats graceful signal shutdown as successful. Command mode
72
+ * preserves conventional signal exit statuses. Every defect reported through
73
+ * `reportDefect`, including an observer defect that does not abort the Run,
74
+ * sets `process.exitCode` to 1. The default reporter logs to the configured
75
+ * Evolu console.
76
+ *
77
+ * Escaped uncaught exceptions and unhandled rejections remain under Node.js
78
+ * native reporting and termination.
79
+ *
80
+ * ### Service Example
40
81
  *
41
82
  * ```ts
42
- * const deps = { ...createRelayDeps(), console };
83
+ * const deps = { ...createRelayDeps(), console: createConsole() };
43
84
  *
44
- * await using run = createRun(deps);
45
- * await using stack = new AsyncDisposableStack();
85
+ * await runMain(deps)(createRelay({ port: 4000 }));
86
+ * ```
46
87
  *
47
- * stack.use(await run.orThrow(startRelay({ port: 4000 })));
88
+ * A Task returning `void` can keep a service alive explicitly when no Resource
89
+ * owns its lifetime:
48
90
  *
49
- * await run.deps.shutdown;
91
+ * ```ts
92
+ * await runMain(deps)(async (run) => {
93
+ * void run(processMessages);
94
+ * return await run(waitForAbort);
95
+ * });
50
96
  * ```
51
97
  *
52
- * @group Node.js Run
98
+ * ### Command Example
99
+ *
100
+ * ```ts
101
+ * await runMain(command, { mode: "command" });
102
+ * ```
103
+ *
104
+ * @group Node.js Task
53
105
  */
54
- export const createRun: CreateRun<RunDeps & ShutdownDep> = <D>(
55
- deps?: D,
56
- ): Run<RunDeps & ShutdownDep & D> => {
57
- const { promise: shutdown, resolve: resolveShutdown } =
58
- Promise.withResolvers<void>();
59
-
60
- const run = createCommonRun({ ...deps, shutdown } as D & ShutdownDep);
61
-
62
- const console = run.deps.console.child("global");
63
-
64
- const handleError = (source: string) => (error: unknown) => {
65
- console.error(source, createUnknownError(error));
66
- process.exitCode = 1;
67
-
68
- // Resolve shutdown so `await run.deps.shutdown` unblocks
69
- // and allows the stack to be disposed.
70
- resolveShutdown();
71
- };
72
-
73
- const handleUncaughtException = handleError("uncaughtException");
74
- const handleUnhandledRejection = handleError("unhandledRejection");
75
-
76
- process.on("uncaughtException", handleUncaughtException);
77
- process.on("unhandledRejection", handleUnhandledRejection);
78
- process.on("SIGINT", resolveShutdown); // Ctrl-C (all platforms)
79
- process.on("SIGTERM", resolveShutdown); // OS/k8s/Docker termination (Unix)
80
- process.on("SIGHUP", resolveShutdown); // Console close (Windows), terminal disconnect (Unix)
81
- process.on("SIGBREAK", resolveShutdown); // Ctrl-Break (Windows)
82
-
83
- run.onAbort(() => {
84
- process.off("uncaughtException", handleUncaughtException);
85
- process.off("unhandledRejection", handleUnhandledRejection);
86
- process.off("SIGINT", resolveShutdown);
87
- process.off("SIGTERM", resolveShutdown);
88
- process.off("SIGHUP", resolveShutdown);
89
- process.off("SIGBREAK", resolveShutdown);
106
+ export function runMain<T extends void | Resource>(
107
+ main: Task<T>,
108
+ options?: RunMainOptions,
109
+ ): Promise<void>;
110
+ /** With custom dependencies. */
111
+ export function runMain<D extends object>(
112
+ deps: RunCustomDeps<D>,
113
+ options?: RunMainOptions,
114
+ ): <T extends void | Resource>(main: Task<T, never, D>) => Promise<void>;
115
+ export function runMain<T extends void | Resource, D extends object>(
116
+ mainOrDeps: Task<T> | RunCustomDeps<D>,
117
+ { mode = "service" }: RunMainOptions = {},
118
+ ):
119
+ | Promise<void>
120
+ | (<R extends void | Resource>(main: Task<R, never, D>) => Promise<void>) {
121
+ return typeof mainOrDeps === "function"
122
+ ? runMainInternal(mainOrDeps, {}, mode)
123
+ : (main) => runMainInternal(main, mainOrDeps, mode);
124
+ }
125
+
126
+ const commandExitCodeBySignal: Readonly<Record<NodeSignal, number>> = {
127
+ SIGINT: 130,
128
+ SIGTERM: 143,
129
+ SIGBREAK: 149,
130
+ };
131
+
132
+ const runMainInternal = async <T extends void | Resource, D extends object>(
133
+ main: Task<T, never, D>,
134
+ deps: RunCustomDeps<D> & Partial<ConsoleDep & ReportDefectDep>,
135
+ mode: RunMainMode,
136
+ ): Promise<void> => {
137
+ const console = deps.console ?? createConsole();
138
+ const mainConsole = console.child("main");
139
+
140
+ let defectReported = false as boolean;
141
+ let receivedSignal = null as NodeSignal | null;
142
+
143
+ await using disposer = new AsyncDisposableStack();
144
+ const run = disposer.use(
145
+ createRun<D>({
146
+ ...deps,
147
+ console,
148
+ reportDefect: (reported) => {
149
+ defectReported = true;
150
+ process.exitCode = 1;
151
+ if (deps.reportDefect) deps.reportDefect(reported);
152
+ else console.error(reported);
153
+ },
154
+ }),
155
+ );
156
+
157
+ (["SIGINT", "SIGTERM", "SIGBREAK"] as const).forEach((signal) => {
158
+ const handleSignal = (): void => {
159
+ if (receivedSignal !== null) {
160
+ mainConsole.warn("Forcing shutdown...");
161
+ process.exit(commandExitCodeBySignal[signal]);
162
+ return;
163
+ }
164
+
165
+ receivedSignal = signal;
166
+ mainConsole.info("Shutting down...");
167
+ run.abort({ type: "NodeSignalAbortReason", signal });
168
+ };
169
+
170
+ process.on(signal, handleSignal);
171
+ run.defer(() => {
172
+ process.off(signal, handleSignal);
173
+ });
90
174
  });
91
175
 
92
- return run;
176
+ try {
177
+ await run(async (run) => {
178
+ const resource = await run.ok(main);
179
+ if (!isDisposable(resource)) return ok();
180
+
181
+ await using _resource = resource;
182
+ return await run(waitForAbort);
183
+ });
184
+ } catch {
185
+ // Aborts are control flow; defects are already handled by reportDefect.
186
+ }
187
+
188
+ // Move ownership out of the await-using setup safety net so an already
189
+ // reported finalizer defect can be suppressed during explicit disposal.
190
+ try {
191
+ await disposer.move().disposeAsync();
192
+ } catch {
193
+ // Finalizer defects are already handled by reportDefect.
194
+ }
195
+
196
+ if (receivedSignal !== null) {
197
+ if (defectReported) mainConsole.warn("Shutdown finished with errors");
198
+ else mainConsole.info("Shutdown complete");
199
+ if (mode === "command")
200
+ process.exitCode ??= commandExitCodeBySignal[receivedSignal];
201
+ }
93
202
  };