@evolu/nodejs 3.0.0-next.3 → 3.0.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.
@@ -5,10 +5,10 @@ export interface NodeJsRelayConfig extends RelayConfig {
5
5
  readonly port?: number;
6
6
  }
7
7
  export type RelayDeps = CreateSqliteDriverDep & RandomDep & TimingSafeEqualDep;
8
- /** Dependencies for {@link startRelay} using better-sqlite3. */
8
+ /** Dependencies for {@link createRelay} using better-sqlite3. */
9
9
  export declare const createRelayDeps: () => RelayDeps;
10
10
  /**
11
- * Starts an Evolu relay server using Node.js.
11
+ * Creates an Evolu Relay server resource using Node.js.
12
12
  *
13
13
  * Use {@link createRelayDeps} to create dependencies for better-sqlite3, or
14
14
  * provide a custom SQLite driver implementation.
@@ -29,27 +29,20 @@ export declare const createRelayDeps: () => RelayDeps;
29
29
  *
30
30
  * const deps = { ...createRelayDeps(), console };
31
31
  *
32
- * await using run = createRun(deps);
33
- * await using disposer = new AsyncDisposableStack();
32
+ * await runMain(deps)(
33
+ * createRelay({
34
+ * port: 4000,
34
35
  *
35
- * disposer.use(
36
- * await run.orThrow(
37
- * startRelay({
38
- * port: 4000,
36
+ * // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
37
+ * // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
39
38
  *
40
- * // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
41
- * // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
42
- *
43
- * isOwnerWithinQuota: (_ownerId, requiredBytes) => {
44
- * const maxBytes = 1024 * 1024; // 1MB
45
- * return requiredBytes <= maxBytes;
46
- * },
47
- * }),
48
- * ),
39
+ * isOwnerWithinQuota: (_ownerId, requiredBytes) => {
40
+ * const maxBytes = 1024 * 1024; // 1MB
41
+ * return requiredBytes <= maxBytes;
42
+ * },
43
+ * }),
49
44
  * );
50
- *
51
- * await run.deps.shutdown;
52
45
  * ```
53
46
  */
54
- export declare const startRelay: ({ port, name, isOwnerAllowed, isOwnerWithinQuota, }: NodeJsRelayConfig) => Task<Relay, never, RelayDeps>;
47
+ export declare const createRelay: ({ port, name, isOwnerAllowed, isOwnerWithinQuota, }: NodeJsRelayConfig) => Task<Relay, never, RelayDeps>;
55
48
  //# sourceMappingURL=Relay.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Relay.d.ts","sourceRoot":"","sources":["../../../src/local-first/Relay.ts"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,qBAAqB,EAI1B,KAAK,SAAS,EACd,KAAK,IAAI,EACT,KAAK,kBAAkB,EAExB,MAAM,eAAe,CAAC;AACvB,OAAO,EAQL,KAAK,KAAK,EACV,KAAK,WAAW,EACjB,MAAM,2BAA2B,CAAC;AAQnC,MAAM,WAAW,iBAAkB,SAAQ,WAAW;IACpD,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,SAAS,GAAG,qBAAqB,GAAG,SAAS,GAAG,kBAAkB,CAAC;AAE/E,gEAAgE;AAChE,eAAO,MAAM,eAAe,QAAO,SAIjC,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,eAAO,MAAM,UAAU,GACpB,qDAKE,iBAAiB,KAAG,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,SAAS,CA8MlD,CAAC"}
1
+ {"version":3,"file":"Relay.d.ts","sourceRoot":"","sources":["../../../src/local-first/Relay.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,qBAAqB,EAK1B,KAAK,SAAS,EACd,KAAK,IAAI,EACT,KAAK,kBAAkB,EAGxB,MAAM,eAAe,CAAC;AACvB,OAAO,EAQL,KAAK,KAAK,EACV,KAAK,WAAW,EACjB,MAAM,2BAA2B,CAAC;AAQnC,MAAM,WAAW,iBAAkB,SAAQ,WAAW;IACpD,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,SAAS,GAAG,qBAAqB,GAAG,SAAS,GAAG,kBAAkB,CAAC;AAE/E,iEAAiE;AACjE,eAAO,MAAM,eAAe,QAAO,SAIjC,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,eAAO,MAAM,WAAW,wDAMnB,iBAAiB,KAAG,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,SAAS,CAgNlD,CAAC"}
@@ -50,7 +50,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
50
50
  var e = new Error(message);
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
- import { AbortError, assert, callback, createRandom, createRelation, createSqlite, Name, ok, OwnerId, Uint8Array, } from "@evolu/common";
53
+ import { assert, createRandom, createRelation, createSqlite, daemon, Name, ok, OwnerId, tryAsync, Uint8Array, } from "@evolu/common";
54
54
  import { applyProtocolMessageAsRelay, createBaseSqliteStorageTables, createRelaySqliteStorage, createRelayStorageTables, defaultProtocolMessageMaxSize, parseOwnerIdFromOwnerWebSocketTransportUrl, } from "@evolu/common/local-first";
55
55
  import { once } from "events";
56
56
  import { existsSync } from "fs";
@@ -58,14 +58,14 @@ import { createServer } from "http";
58
58
  import { WebSocket, WebSocketServer } from "ws";
59
59
  import { createTimingSafeEqual } from "../Crypto.js";
60
60
  import { createBetterSqliteDriver } from "../Sqlite.js";
61
- /** Dependencies for {@link startRelay} using better-sqlite3. */
61
+ /** Dependencies for {@link createRelay} using better-sqlite3. */
62
62
  export const createRelayDeps = () => ({
63
63
  createSqliteDriver: createBetterSqliteDriver,
64
64
  random: createRandom(),
65
65
  timingSafeEqual: createTimingSafeEqual(),
66
66
  });
67
67
  /**
68
- * Starts an Evolu relay server using Node.js.
68
+ * Creates an Evolu Relay server resource using Node.js.
69
69
  *
70
70
  * Use {@link createRelayDeps} to create dependencies for better-sqlite3, or
71
71
  * provide a custom SQLite driver implementation.
@@ -86,38 +86,28 @@ export const createRelayDeps = () => ({
86
86
  *
87
87
  * const deps = { ...createRelayDeps(), console };
88
88
  *
89
- * await using run = createRun(deps);
90
- * await using disposer = new AsyncDisposableStack();
89
+ * await runMain(deps)(
90
+ * createRelay({
91
+ * port: 4000,
91
92
  *
92
- * disposer.use(
93
- * await run.orThrow(
94
- * startRelay({
95
- * port: 4000,
93
+ * // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
94
+ * // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
96
95
  *
97
- * // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
98
- * // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
99
- *
100
- * isOwnerWithinQuota: (_ownerId, requiredBytes) => {
101
- * const maxBytes = 1024 * 1024; // 1MB
102
- * return requiredBytes <= maxBytes;
103
- * },
104
- * }),
105
- * ),
96
+ * isOwnerWithinQuota: (_ownerId, requiredBytes) => {
97
+ * const maxBytes = 1024 * 1024; // 1MB
98
+ * return requiredBytes <= maxBytes;
99
+ * },
100
+ * }),
106
101
  * );
107
- *
108
- * await run.deps.shutdown;
109
102
  * ```
110
103
  */
111
- export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isOwnerAllowed, isOwnerWithinQuota, }) => async (run) => {
104
+ export const createRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isOwnerAllowed, isOwnerWithinQuota, }) => async (run) => {
112
105
  const env_1 = { stack: [], error: void 0, hasError: false };
113
106
  try {
114
107
  const disposer = __addDisposableResource(env_1, new AsyncDisposableStack(), true);
115
108
  const console = run.deps.console.child("relay");
116
- disposer.defer(() => {
117
- console.info("Shutdown complete");
118
- });
119
109
  const dbFileExists = existsSync(`${name}.db`);
120
- const sqlite = disposer.use(await run.orThrow(createSqlite(name)));
110
+ const sqlite = disposer.use(await run.ok(createSqlite(name)));
121
111
  const deps = { ...run.deps, sqlite };
122
112
  if (!dbFileExists) {
123
113
  createBaseSqliteStorageTables(deps);
@@ -137,12 +127,8 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
137
127
  });
138
128
  }));
139
129
  const ownerSocketRelation = createRelation();
140
- const relayRun = disposer.use(run.create({
141
- ...run.deps,
142
- storage: createRelaySqliteStorage(deps)({
143
- isOwnerWithinQuota,
144
- }),
145
- }));
130
+ const storage = createRelaySqliteStorage(deps)({ isOwnerWithinQuota });
131
+ const relayRun = disposer.use(run.create({ storage }));
146
132
  server.on("upgrade", (request, socket, head) => {
147
133
  socket.on("error", console.debug);
148
134
  const completeUpgrade = () => {
@@ -170,24 +156,30 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
170
156
  respondAndDestroy(400);
171
157
  return;
172
158
  }
173
- const authorizationFiber = relayRun(callback(({ ok, err, signal }) => {
174
- void Promise.try(() => isOwnerAllowed(ownerId, { signal })).then(ok, err);
175
- }));
159
+ const authorizationFiber = relayRun.abortable(
160
+ // Use daemon because authorization can call an external service that
161
+ // ignores abort. The daemon runs in the root Run, so aborting the
162
+ // current Run does not make its Fiber wait for the service Promise to
163
+ // settle.
164
+ daemon(async (run) => await tryAsync(() => isOwnerAllowed(ownerId, { signal: run.signal }), (error) => ({ type: "OwnerAuthorizationError", error }))));
176
165
  const abortAuthorization = () => {
177
- authorizationFiber.abort("WebSocket upgrade request socket closed");
166
+ authorizationFiber.abort({
167
+ type: "WebSocketUpgradeSocketClosed",
168
+ });
178
169
  };
179
170
  socket.once("close", abortAuthorization);
180
171
  socket.once("error", abortAuthorization);
181
- void authorizationFiber.then((result) => {
172
+ void (async () => {
173
+ const result = await authorizationFiber;
182
174
  socket.removeListener("close", abortAuthorization);
183
175
  socket.removeListener("error", abortAuthorization);
184
176
  if (!result.ok) {
185
- if (!AbortError.is(result.error)) {
186
- console.error("isOwnerAllowed failed", ownerId, result.error);
187
- respondAndDestroy(503);
177
+ if (result.error.type === "AbortError") {
178
+ socket.destroy();
188
179
  return;
189
180
  }
190
- socket.destroy();
181
+ console.error(result.error.error);
182
+ respondAndDestroy(503);
191
183
  return;
192
184
  }
193
185
  if (!result.value) {
@@ -196,7 +188,7 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
196
188
  return;
197
189
  }
198
190
  completeUpgrade();
199
- });
191
+ })();
200
192
  });
201
193
  wss.on("connection", (ws) => {
202
194
  console.debug("on connection", wss.clients.size);
@@ -222,8 +214,10 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
222
214
  if (!Uint8Array.is(message))
223
215
  return;
224
216
  void (async () => {
225
- const response = await relayRun(applyProtocolMessageAsRelay(message, options));
217
+ const response = await relayRun.abortable(applyProtocolMessageAsRelay(message, options));
226
218
  if (!response.ok) {
219
+ if (response.error.type === "AbortError")
220
+ return;
227
221
  console.error(response);
228
222
  return;
229
223
  }
@@ -236,7 +230,6 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
236
230
  });
237
231
  });
238
232
  disposer.defer(() => {
239
- console.info("Shutting down...");
240
233
  for (const client of wss.clients) {
241
234
  if (client.readyState === WebSocket.OPEN) {
242
235
  client.close(1000, "Evolu Relay shutting down");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evolu/nodejs",
3
- "version": "3.0.0-next.3",
3
+ "version": "3.0.1",
4
4
  "description": "Evolu for Node.js",
5
5
  "author": "Daniel Steigerwald <daniel@steigerwald.cz>",
6
6
  "license": "MIT",
@@ -19,6 +19,18 @@
19
19
  "types": "./dist/src/index.d.ts",
20
20
  "import": "./dist/src/index.js",
21
21
  "default": "./dist/src/index.js"
22
+ },
23
+ "./TestBundle": {
24
+ "types": "./dist/src/TestBundle.d.ts",
25
+ "import": "./dist/src/TestBundle.js",
26
+ "default": "./dist/src/TestBundle.js"
27
+ }
28
+ },
29
+ "typesVersions": {
30
+ "*": {
31
+ "TestBundle": [
32
+ "./dist/src/TestBundle.d.ts"
33
+ ]
22
34
  }
23
35
  },
24
36
  "files": [
@@ -27,19 +39,31 @@
27
39
  "README.md"
28
40
  ],
29
41
  "dependencies": {
30
- "better-sqlite3": "^12.6.2",
42
+ "better-sqlite3": "^12.11.1",
31
43
  "ws": "^8.19.0"
32
44
  },
33
45
  "devDependencies": {
34
- "@types/better-sqlite3": "^7.6.13",
46
+ "@types/better-sqlite3": "^9.6.0",
35
47
  "@types/node": "^24.10.9",
36
48
  "@types/ws": "^8.18.1",
37
- "typescript": "^6",
38
- "@evolu/common": "8.0.0-next.5",
49
+ "@typescript/native": "npm:typescript@^7.0.2",
50
+ "vite": "^8.2.0",
51
+ "webpack": "^5.109.2",
52
+ "@evolu/common": "8.1.0",
39
53
  "@evolu/typescript-config": "0.0.2"
40
54
  },
41
55
  "peerDependencies": {
42
- "@evolu/common": "^8.0.0-next.0"
56
+ "@evolu/common": "^8.0.0",
57
+ "vite": ">=8.0.0",
58
+ "webpack": ">=5.108.4"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "vite": {
62
+ "optional": true
63
+ },
64
+ "webpack": {
65
+ "optional": true
66
+ }
43
67
  },
44
68
  "engines": {
45
69
  "node": ">=24.0.0"
package/src/Cli.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Abort-aware CLI process utilities.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { callback, err, ok, type Task, type Typed } from "@evolu/common";
8
+ import { spawn as nodeSpawn } from "node:child_process";
9
+
10
+ /** Runs a command with inherited stdio. */
11
+ export type Spawn = (
12
+ file: string,
13
+ args: ReadonlyArray<string>,
14
+ options?: {
15
+ readonly cwd?: string | URL;
16
+ },
17
+ ) => Task<void, SpawnError>;
18
+
19
+ /** Failure to start a command or an unsuccessful command exit. */
20
+ export interface SpawnError extends Typed<"SpawnError"> {
21
+ readonly command: string;
22
+ readonly exitCode: number | null;
23
+ readonly signal: NodeJS.Signals | null;
24
+ readonly message: string;
25
+ }
26
+
27
+ /** Dependency wrapper for {@link spawn}. */
28
+ export interface SpawnDep {
29
+ readonly spawn: Spawn;
30
+ }
31
+
32
+ /**
33
+ * Runs a command with inherited stdio and aborts it with the current Run.
34
+ *
35
+ * A zero exit code succeeds. A start failure, non-zero exit code, or signal
36
+ * exit returns {@link SpawnError}.
37
+ */
38
+ export const spawn: Spawn = (file, args, { cwd } = {}) => {
39
+ const command = [file, ...args].join(" ");
40
+
41
+ return callback(({ run, resolve }) => {
42
+ const child = nodeSpawn(file, args, {
43
+ cwd,
44
+ signal: run.signal,
45
+ stdio: "inherit",
46
+ });
47
+
48
+ child.once("error", (error) => {
49
+ if (run.signal.aborted) return;
50
+ resolve(
51
+ err({
52
+ type: "SpawnError",
53
+ command,
54
+ exitCode: null,
55
+ signal: null,
56
+ message: `Failed to start ${command}: ${error.message}`,
57
+ }),
58
+ );
59
+ });
60
+ child.once("close", (exitCode, signal) => {
61
+ if (run.signal.aborted) return;
62
+ resolve(
63
+ exitCode === 0
64
+ ? ok()
65
+ : err({
66
+ type: "SpawnError",
67
+ command,
68
+ exitCode,
69
+ signal,
70
+ message:
71
+ signal == null
72
+ ? `${command} exited with code ${exitCode}.`
73
+ : `${command} exited from ${signal}.`,
74
+ }),
75
+ );
76
+ });
77
+ });
78
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Node.js platform utilities.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { PositiveInt } from "@evolu/common";
8
+ import { availableParallelism as nodeAvailableParallelism } from "node:os";
9
+
10
+ /** Returns the recommended amount of parallelism available to this process. */
11
+ export type AvailableParallelism = () => PositiveInt;
12
+
13
+ /** Dependency wrapper for {@link availableParallelism}. */
14
+ export interface AvailableParallelismDep {
15
+ readonly availableParallelism: AvailableParallelism;
16
+ }
17
+
18
+ /** Returns the recommended amount of parallelism available to this process. */
19
+ export const availableParallelism: AvailableParallelism = () =>
20
+ PositiveInt.orThrow(nodeAvailableParallelism());
package/src/Sqlite.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
+ constVoid,
2
3
  createPreparedStatementsCache,
3
- lazyVoid,
4
4
  ok,
5
5
  type CreateSqliteDriver,
6
6
  type SqliteRow,
@@ -30,7 +30,7 @@ export const createBetterSqliteDriver: CreateSqliteDriver =
30
30
  (sql) => db.prepare(sql),
31
31
  // Not needed.
32
32
  // https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#class-statement
33
- lazyVoid,
33
+ constVoid,
34
34
  ),
35
35
  );
36
36
 
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 RunDefaultDeps,
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 disposer = new AsyncDisposableStack();
85
+ * await runMain(deps)(createRelay({ port: 4000 }));
86
+ * ```
46
87
  *
47
- * disposer.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<RunDefaultDeps & ShutdownDep> = <D>(
55
- deps?: D,
56
- ): Run<RunDefaultDeps & 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
  };