@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.
- package/dist/src/Cli.d.ts +29 -0
- package/dist/src/Cli.d.ts.map +1 -0
- package/dist/src/Cli.js +49 -0
- package/dist/src/Platform.d.ts +15 -0
- package/dist/src/Platform.d.ts.map +1 -0
- package/dist/src/Platform.js +9 -0
- package/dist/src/Sqlite.js +2 -2
- package/dist/src/Task.d.ts +69 -24
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +148 -52
- package/dist/src/TestBundle.d.ts +113 -0
- package/dist/src/TestBundle.d.ts.map +1 -0
- package/dist/src/TestBundle.js +503 -0
- package/dist/src/Time.d.ts +28 -0
- package/dist/src/Time.d.ts.map +1 -0
- package/dist/src/Time.js +25 -0
- package/dist/src/WebSocket.d.ts.map +1 -1
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +7 -8
- package/dist/src/index.d.ts +13 -6
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -1
- package/dist/src/local-first/Relay.d.ts +13 -20
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +36 -43
- package/package.json +30 -6
- package/src/Cli.ts +78 -0
- package/src/Platform.ts +20 -0
- package/src/Sqlite.ts +2 -2
- package/src/Task.ts +172 -63
- package/src/TestBundle.ts +674 -0
- package/src/Time.ts +55 -0
- package/src/Worker.ts +22 -20
- package/src/index.ts +14 -6
- package/src/local-first/Relay.ts +48 -53
|
@@ -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
|
|
8
|
+
/** Dependencies for {@link createRelay} using better-sqlite3. */
|
|
9
9
|
export declare const createRelayDeps: () => RelayDeps;
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
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
|
|
33
|
-
*
|
|
32
|
+
* await runMain(deps)(
|
|
33
|
+
* createRelay({
|
|
34
|
+
* port: 4000,
|
|
34
35
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
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
|
|
90
|
-
*
|
|
89
|
+
* await runMain(deps)(
|
|
90
|
+
* createRelay({
|
|
91
|
+
* port: 4000,
|
|
91
92
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
141
|
-
|
|
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(
|
|
174
|
-
|
|
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(
|
|
166
|
+
authorizationFiber.abort({
|
|
167
|
+
type: "WebSocketUpgradeSocketClosed",
|
|
168
|
+
});
|
|
178
169
|
};
|
|
179
170
|
socket.once("close", abortAuthorization);
|
|
180
171
|
socket.once("error", abortAuthorization);
|
|
181
|
-
void
|
|
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 (
|
|
186
|
-
|
|
187
|
-
respondAndDestroy(503);
|
|
177
|
+
if (result.error.type === "AbortError") {
|
|
178
|
+
socket.destroy();
|
|
188
179
|
return;
|
|
189
180
|
}
|
|
190
|
-
|
|
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.
|
|
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.
|
|
42
|
+
"better-sqlite3": "^12.11.1",
|
|
31
43
|
"ws": "^8.19.0"
|
|
32
44
|
},
|
|
33
45
|
"devDependencies": {
|
|
34
|
-
"@types/better-sqlite3": "^
|
|
46
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
35
47
|
"@types/node": "^24.10.9",
|
|
36
48
|
"@types/ws": "^8.18.1",
|
|
37
|
-
"typescript": "
|
|
38
|
-
"
|
|
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
|
|
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
|
+
};
|
package/src/Platform.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
17
|
+
type RunCustomDeps,
|
|
18
|
+
type Task,
|
|
19
|
+
type Typed,
|
|
13
20
|
} from "@evolu/common";
|
|
14
21
|
|
|
15
22
|
/**
|
|
16
|
-
*
|
|
23
|
+
* An abort requested by a Node.js termination signal.
|
|
17
24
|
*
|
|
18
|
-
*
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
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
|
|
45
|
-
*
|
|
85
|
+
* await runMain(deps)(createRelay({ port: 4000 }));
|
|
86
|
+
* ```
|
|
46
87
|
*
|
|
47
|
-
*
|
|
88
|
+
* A Task returning `void` can keep a service alive explicitly when no Resource
|
|
89
|
+
* owns its lifetime:
|
|
48
90
|
*
|
|
49
|
-
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* await runMain(deps)(async (run) => {
|
|
93
|
+
* void run(processMessages);
|
|
94
|
+
* return await run(waitForAbort);
|
|
95
|
+
* });
|
|
50
96
|
* ```
|
|
51
97
|
*
|
|
52
|
-
*
|
|
98
|
+
* ### Command Example
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* await runMain(command, { mode: "command" });
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* @group Node.js Task
|
|
53
105
|
*/
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
};
|