@rhinestone/service-sdk 0.2.16 → 0.2.17
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 +46 -1
- package/dist/lifecycle.d.ts +82 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +96 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Shared TypeScript utilities for Rhinestone backend services — the pieces every service
|
|
4
4
|
needs and none of them should reimplement: OpenTelemetry setup and helpers, chain-aware gas
|
|
5
|
-
fee estimation, and BigInt-safe JSON serialization.
|
|
5
|
+
fee estimation, graceful-shutdown plumbing, and BigInt-safe JSON serialization.
|
|
6
6
|
|
|
7
7
|
Published to npm and consumed by `orchestrator`, `relayer`, `deposit-service-processor`,
|
|
8
8
|
`route-stats`, `back-office` (api), and `1auth` (passkey, recovery-signer).
|
|
@@ -25,6 +25,7 @@ bun add @rhinestone/service-sdk
|
|
|
25
25
|
|---|---|
|
|
26
26
|
| `@rhinestone/service-sdk` | Re-exports `./serialization` only |
|
|
27
27
|
| `@rhinestone/service-sdk/chains` | Chain-aware gas fee estimation |
|
|
28
|
+
| `@rhinestone/service-sdk/lifecycle` | Termination signals, crashes and explicit aborts as one channel |
|
|
28
29
|
| `@rhinestone/service-sdk/opentelemetry` | SDK setup, tracing/metrics helpers, propagation, integrations |
|
|
29
30
|
| `@rhinestone/service-sdk/serialization` | BigInt-safe JSON serialization |
|
|
30
31
|
|
|
@@ -105,6 +106,50 @@ from the value alone.
|
|
|
105
106
|
|
|
106
107
|
---
|
|
107
108
|
|
|
109
|
+
## Lifecycle
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import {
|
|
113
|
+
installTerminationHandlers,
|
|
114
|
+
terminationExitCode,
|
|
115
|
+
describeReason,
|
|
116
|
+
} from '@rhinestone/service-sdk/lifecycle'
|
|
117
|
+
|
|
118
|
+
const termination = installTerminationHandlers()
|
|
119
|
+
|
|
120
|
+
const reason = await Promise.race([serve(termination), termination.waitForTermination()])
|
|
121
|
+
|
|
122
|
+
logger.info(`shutting down: ${describeReason(reason)}`)
|
|
123
|
+
await teardownInOrder()
|
|
124
|
+
process.exit(terminationExitCode(reason))
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Folds every way a process can be asked to stop into one channel: `SIGTERM` / `SIGINT`, an
|
|
128
|
+
uncaught exception, an unhandled rejection, and an explicit `terminate(reason)` from code
|
|
129
|
+
that found a broken invariant. `waitForTermination()` resolves once with a discriminated
|
|
130
|
+
`TerminationReason`, so the caller can log it and pick an exit code instead of every call
|
|
131
|
+
site inventing its own.
|
|
132
|
+
|
|
133
|
+
**The first reason wins; later ones are dropped.** That is the point rather than a detail:
|
|
134
|
+
sockets closing during a shutdown routinely orphan promises, and those rejections must not
|
|
135
|
+
restart the shutdown or flip a clean stop to a failure.
|
|
136
|
+
|
|
137
|
+
`isTerminating()` is the same state as a synchronous boolean, for the callers that can't
|
|
138
|
+
await — readiness probes returning 503 the instant a signal lands, admission gates refusing
|
|
139
|
+
new work while in-flight work drains.
|
|
140
|
+
|
|
141
|
+
Two things to know:
|
|
142
|
+
|
|
143
|
+
- **Registering an `uncaughtException` / `unhandledRejection` listener stops Node exiting on
|
|
144
|
+
either.** The caller *must* exit once teardown is done, or a crashed process lingers.
|
|
145
|
+
- Nothing is registered at import time, and each install is independent. The install call is
|
|
146
|
+
the only thing that touches `process`, and `dispose()` removes exactly what it added, so
|
|
147
|
+
tests get a fresh one and own its disposal.
|
|
148
|
+
|
|
149
|
+
This module logs nothing itself; reporting is the owner's job, once the promise resolves.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
108
153
|
## OpenTelemetry
|
|
109
154
|
|
|
110
155
|
### Setup
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process termination as one observable event.
|
|
3
|
+
*
|
|
4
|
+
* A shutdown needs two different things, and a bare promise only gives the
|
|
5
|
+
* first: an *ordered* teardown (release this, then close that), and an
|
|
6
|
+
* *immediate* answer to "should I still take on new work?" for readiness probes
|
|
7
|
+
* and admission gates. Handing those callers a promise means each of them keeps
|
|
8
|
+
* its own boolean in sync with it, which is the bug this shape avoids.
|
|
9
|
+
*
|
|
10
|
+
* Every way a process can be asked to stop lands in the same channel:
|
|
11
|
+
*
|
|
12
|
+
* - an external signal — SIGTERM from an orchestrator, SIGINT from a terminal
|
|
13
|
+
* - an uncaught exception or an unhandled rejection
|
|
14
|
+
* - an explicit `terminate()` from code that found a broken invariant
|
|
15
|
+
*
|
|
16
|
+
* The first reason wins; anything after it is dropped. That is what keeps a
|
|
17
|
+
* crash *during* shutdown from restarting the shutdown or changing its exit
|
|
18
|
+
* code — orphaned promises rejecting as sockets close is normal, and must not
|
|
19
|
+
* turn a clean stop into a failed one.
|
|
20
|
+
*
|
|
21
|
+
* Nothing is registered at import time. `installTerminationHandlers` is the only
|
|
22
|
+
* thing that touches `process`, and `dispose()` undoes exactly what it added.
|
|
23
|
+
*/
|
|
24
|
+
/** Why the process was asked to stop. */
|
|
25
|
+
export type TerminationReason = {
|
|
26
|
+
kind: 'signal';
|
|
27
|
+
signal: NodeJS.Signals;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'uncaughtException';
|
|
30
|
+
error: Error;
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'unhandledRejection';
|
|
33
|
+
reason: unknown;
|
|
34
|
+
} | {
|
|
35
|
+
kind: 'explicit';
|
|
36
|
+
reason: string | Error | object;
|
|
37
|
+
};
|
|
38
|
+
export interface Termination {
|
|
39
|
+
/**
|
|
40
|
+
* Resolves once, with whatever asked the process to stop. Never rejects, and
|
|
41
|
+
* safe to await from several places; awaiting after the fact returns the
|
|
42
|
+
* reason that already settled. Reporting the reason is the owner's job — this
|
|
43
|
+
* module logs nothing.
|
|
44
|
+
*/
|
|
45
|
+
waitForTermination(): Promise<TerminationReason>;
|
|
46
|
+
/**
|
|
47
|
+
* True from the instant a reason arrives. For readiness gates and admission
|
|
48
|
+
* checks that must answer synchronously, long before teardown finishes.
|
|
49
|
+
*/
|
|
50
|
+
isTerminating(): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Ask the process to stop: an invariant broke and it must not keep running.
|
|
53
|
+
* The reason is carried through to the awaiting caller — an `Error` where
|
|
54
|
+
* there is one, or a string / context object where the caller has facts
|
|
55
|
+
* rather than a throw. First caller wins.
|
|
56
|
+
*/
|
|
57
|
+
terminate(reason: string | Error | object): void;
|
|
58
|
+
/** Remove the process listeners this install added. Idempotent. */
|
|
59
|
+
dispose(): void;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Register the process listeners and return the channel they feed.
|
|
63
|
+
*
|
|
64
|
+
* Each call is independent: it installs its own listeners and owns its own
|
|
65
|
+
* reason, so two installs in one process both fire. That is deliberate — tests
|
|
66
|
+
* want a fresh one — and it means the caller is responsible for `dispose()`.
|
|
67
|
+
*
|
|
68
|
+
* Registering an `uncaughtException` / `unhandledRejection` listener stops Node
|
|
69
|
+
* from exiting on either, so **the caller must exit itself** once teardown is
|
|
70
|
+
* done. Use `terminationExitCode` for the code.
|
|
71
|
+
*/
|
|
72
|
+
export declare function installTerminationHandlers(): Termination;
|
|
73
|
+
/**
|
|
74
|
+
* The exit code to leave with: 0 for a signal, 1 for everything else.
|
|
75
|
+
*
|
|
76
|
+
* A handled SIGTERM means the process did what it was asked, so a clean 0 —
|
|
77
|
+
* exiting non-zero there makes an orderly rollout look like a failed one.
|
|
78
|
+
*/
|
|
79
|
+
export declare function terminationExitCode(reason: TerminationReason): number;
|
|
80
|
+
/** One-line description of a reason, for the owner's shutdown log. */
|
|
81
|
+
export declare function describeReason(reason: TerminationReason): string;
|
|
82
|
+
//# sourceMappingURL=lifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,yCAAyC;AACzC,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,OAAO,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,mBAAmB,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,GAC3C;IAAE,IAAI,EAAE,oBAAoB,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,CAAA;CAAE,CAAA;AAEzD,MAAM,WAAW,WAAW;IAC1B;;;;;OAKG;IACH,kBAAkB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAAA;IAChD;;;OAGG;IACH,aAAa,IAAI,OAAO,CAAA;IACxB;;;;;OAKG;IACH,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,IAAI,CAAA;IAChD,mEAAmE;IACnE,OAAO,IAAI,IAAI,CAAA;CAChB;AAKD;;;;;;;;;;GAUG;AACH,wBAAgB,0BAA0B,IAAI,WAAW,CA2CxD;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAErE;AAED,sEAAsE;AACtE,wBAAgB,cAAc,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAWhE"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.installTerminationHandlers = installTerminationHandlers;
|
|
4
|
+
exports.terminationExitCode = terminationExitCode;
|
|
5
|
+
exports.describeReason = describeReason;
|
|
6
|
+
const serialization_1 = require("./serialization");
|
|
7
|
+
/** SIGTERM from orchestrators, SIGINT from terminals. */
|
|
8
|
+
const SIGNALS = ['SIGTERM', 'SIGINT'];
|
|
9
|
+
/**
|
|
10
|
+
* Register the process listeners and return the channel they feed.
|
|
11
|
+
*
|
|
12
|
+
* Each call is independent: it installs its own listeners and owns its own
|
|
13
|
+
* reason, so two installs in one process both fire. That is deliberate — tests
|
|
14
|
+
* want a fresh one — and it means the caller is responsible for `dispose()`.
|
|
15
|
+
*
|
|
16
|
+
* Registering an `uncaughtException` / `unhandledRejection` listener stops Node
|
|
17
|
+
* from exiting on either, so **the caller must exit itself** once teardown is
|
|
18
|
+
* done. Use `terminationExitCode` for the code.
|
|
19
|
+
*/
|
|
20
|
+
function installTerminationHandlers() {
|
|
21
|
+
let settledReason;
|
|
22
|
+
let resolve;
|
|
23
|
+
const reasonPromise = new Promise((r) => {
|
|
24
|
+
resolve = r;
|
|
25
|
+
});
|
|
26
|
+
const accept = (next) => {
|
|
27
|
+
if (settledReason !== undefined)
|
|
28
|
+
return;
|
|
29
|
+
settledReason = next;
|
|
30
|
+
resolve(next);
|
|
31
|
+
};
|
|
32
|
+
const onSignal = (signal) => {
|
|
33
|
+
accept({ kind: 'signal', signal });
|
|
34
|
+
};
|
|
35
|
+
const onUncaughtException = (error) => {
|
|
36
|
+
accept({ kind: 'uncaughtException', error });
|
|
37
|
+
};
|
|
38
|
+
const onUnhandledRejection = (reason) => {
|
|
39
|
+
accept({ kind: 'unhandledRejection', reason });
|
|
40
|
+
};
|
|
41
|
+
for (const signal of SIGNALS) {
|
|
42
|
+
process.on(signal, onSignal);
|
|
43
|
+
}
|
|
44
|
+
process.on('uncaughtException', onUncaughtException);
|
|
45
|
+
process.on('unhandledRejection', onUnhandledRejection);
|
|
46
|
+
return {
|
|
47
|
+
waitForTermination: () => reasonPromise,
|
|
48
|
+
isTerminating: () => settledReason !== undefined,
|
|
49
|
+
terminate: (reason) => {
|
|
50
|
+
accept({ kind: 'explicit', reason });
|
|
51
|
+
},
|
|
52
|
+
dispose: () => {
|
|
53
|
+
for (const signal of SIGNALS) {
|
|
54
|
+
process.off(signal, onSignal);
|
|
55
|
+
}
|
|
56
|
+
process.off('uncaughtException', onUncaughtException);
|
|
57
|
+
process.off('unhandledRejection', onUnhandledRejection);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The exit code to leave with: 0 for a signal, 1 for everything else.
|
|
63
|
+
*
|
|
64
|
+
* A handled SIGTERM means the process did what it was asked, so a clean 0 —
|
|
65
|
+
* exiting non-zero there makes an orderly rollout look like a failed one.
|
|
66
|
+
*/
|
|
67
|
+
function terminationExitCode(reason) {
|
|
68
|
+
return reason.kind === 'signal' ? 0 : 1;
|
|
69
|
+
}
|
|
70
|
+
/** One-line description of a reason, for the owner's shutdown log. */
|
|
71
|
+
function describeReason(reason) {
|
|
72
|
+
switch (reason.kind) {
|
|
73
|
+
case 'signal':
|
|
74
|
+
return `signal ${reason.signal}`;
|
|
75
|
+
case 'uncaughtException':
|
|
76
|
+
return `uncaught exception: ${formatValue(reason.error)}`;
|
|
77
|
+
case 'unhandledRejection':
|
|
78
|
+
return `unhandled rejection: ${formatValue(reason.reason)}`;
|
|
79
|
+
case 'explicit':
|
|
80
|
+
return `explicit termination: ${formatValue(reason.reason)}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function formatValue(value) {
|
|
84
|
+
if (value instanceof Error)
|
|
85
|
+
return value.stack ?? value.message;
|
|
86
|
+
if (typeof value === 'string')
|
|
87
|
+
return value;
|
|
88
|
+
try {
|
|
89
|
+
// Guards bigints and circular references, both plausible in a context
|
|
90
|
+
// object assembled at the point an invariant broke.
|
|
91
|
+
return (0, serialization_1.stringifyBigIntFields)(value);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return String(value);
|
|
95
|
+
}
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhinestone/service-sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.17",
|
|
4
4
|
"description": "Shared SDK for Rhinestone services",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Rhinestone",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./dist/index.js",
|
|
20
20
|
"./chains": "./dist/chains.js",
|
|
21
|
+
"./lifecycle": "./dist/lifecycle.js",
|
|
21
22
|
"./opentelemetry": "./dist/opentelemetry/index.js",
|
|
22
23
|
"./serialization": "./dist/serialization.js"
|
|
23
24
|
},
|
|
@@ -26,6 +27,9 @@
|
|
|
26
27
|
"chains": [
|
|
27
28
|
"dist/chains.d.ts"
|
|
28
29
|
],
|
|
30
|
+
"lifecycle": [
|
|
31
|
+
"dist/lifecycle.d.ts"
|
|
32
|
+
],
|
|
29
33
|
"opentelemetry": [
|
|
30
34
|
"dist/opentelemetry/index.d.ts"
|
|
31
35
|
],
|