@grest-ts/discovery-local 0.0.37 → 0.0.39
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 +8 -0
- package/dist/src/_dedupCheck.js +1 -1
- package/dist/src/bin/discovery-local.d.ts +3 -0
- package/dist/src/bin/discovery-local.d.ts.map +1 -0
- package/dist/src/bin/discovery-local.js +58 -0
- package/dist/src/bin/discovery-local.js.map +1 -0
- package/dist/src/local/GGDiscoveryIPC.d.ts +8 -0
- package/dist/src/local/GGDiscoveryIPC.d.ts.map +1 -1
- package/dist/src/local/GGDiscoveryIPC.js +7 -0
- package/dist/src/local/GGDiscoveryIPC.js.map +1 -1
- package/dist/src/local/GGLocalDiscoveryClient.d.ts +3 -0
- package/dist/src/local/GGLocalDiscoveryClient.d.ts.map +1 -1
- package/dist/src/local/GGLocalDiscoveryClient.js +38 -2
- package/dist/src/local/GGLocalDiscoveryClient.js.map +1 -1
- package/dist/src/local/GGLocalDiscoveryResilientClient.d.ts +3 -0
- package/dist/src/local/GGLocalDiscoveryResilientClient.d.ts.map +1 -1
- package/dist/src/local/GGLocalDiscoveryResilientClient.js +32 -8
- package/dist/src/local/GGLocalDiscoveryResilientClient.js.map +1 -1
- package/dist/src/local/GGLocalDiscoveryServer.d.ts +4 -1
- package/dist/src/local/GGLocalDiscoveryServer.d.ts.map +1 -1
- package/dist/src/local/GGLocalDiscoveryServer.js +24 -4
- package/dist/src/local/GGLocalDiscoveryServer.js.map +1 -1
- package/dist/tsconfig.publish.tsbuildinfo +1 -1
- package/package.json +11 -7
- package/src/_dedupCheck.ts +1 -1
- package/src/bin/discovery-local.ts +58 -0
- package/src/local/GGDiscoveryIPC.ts +7 -0
- package/src/local/GGLocalDiscoveryClient.ts +36 -2
- package/src/local/GGLocalDiscoveryResilientClient.ts +33 -8
- package/src/local/GGLocalDiscoveryServer.ts +25 -5
- package/src/local/yield.test.ts +136 -0
|
@@ -8,10 +8,17 @@ export interface DiscoverApiResult {
|
|
|
8
8
|
error?: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export enum DiscoveryServerKind {
|
|
12
|
+
Bin = "bin",
|
|
13
|
+
Embedded = "embedded",
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
export const GGDiscoveryIPC = {
|
|
12
17
|
discoveryServer: {
|
|
13
18
|
register: IPCServer.defineRequest<GGServiceDiscoveryEntry[], void>("discovery/register"),
|
|
14
19
|
unregister: IPCServer.defineRequest<GGServiceDiscoveryEntry[], void>("discovery/unregister"),
|
|
15
20
|
discoverApi: IPCServer.defineRequest<string, DiscoverApiResult>("discovery/discoverApi"),
|
|
21
|
+
getServerInfo: IPCServer.defineRequest<undefined, {kind: DiscoveryServerKind}>("discovery/getServerInfo"),
|
|
22
|
+
requestYield: IPCServer.defineRequest<undefined, void>("discovery/requestYield"),
|
|
16
23
|
}
|
|
17
24
|
}
|
|
@@ -11,6 +11,18 @@ import {SERVER_ERROR} from "@grest-ts/schema";
|
|
|
11
11
|
*/
|
|
12
12
|
export const GG_LOCAL_ROUTER_PORT = "GG_LOCAL_ROUTER_PORT"
|
|
13
13
|
|
|
14
|
+
/** Default port for the local discovery router. Centralised so a future
|
|
15
|
+
* change (env-var override, per-environment isolation) has one hook. */
|
|
16
|
+
export function getLocalDiscoveryPort(): number {
|
|
17
|
+
const v = process.env[GG_LOCAL_ROUTER_PORT];
|
|
18
|
+
if (v === undefined) return 9000;
|
|
19
|
+
const n = Number(v);
|
|
20
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
21
|
+
throw new Error(`Invalid ${GG_LOCAL_ROUTER_PORT}=${v}, expected positive integer`);
|
|
22
|
+
}
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
export class GGLocalDiscoveryClient extends GGDiscoveryClient {
|
|
15
27
|
|
|
16
28
|
public override readonly isLocal = true;
|
|
@@ -77,12 +89,34 @@ export class GGLocalDiscoveryClient extends GGDiscoveryClient {
|
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
protected async ensureConnected(): Promise<void> {
|
|
80
|
-
if (
|
|
81
|
-
|
|
92
|
+
if (this.client.isConnected()) return;
|
|
93
|
+
// Retry with backoff: when the router runs as its own process
|
|
94
|
+
// (e.g. launched via the `discovery-local` bin), a runtime can
|
|
95
|
+
// come up before the router has bound its port. Tolerate that
|
|
96
|
+
// instead of failing the runtime's startup outright.
|
|
97
|
+
const maxRetries = 20;
|
|
98
|
+
for (let attempt = 0; ; attempt++) {
|
|
99
|
+
try {
|
|
100
|
+
await this.client.connect();
|
|
101
|
+
return;
|
|
102
|
+
} catch (err: any) {
|
|
103
|
+
if (attempt >= maxRetries || !isConnectionError(err)) throw err;
|
|
104
|
+
GGLog.debug(this, "Waiting for discovery router...");
|
|
105
|
+
await new Promise(r => setTimeout(r, Math.min(500 * Math.pow(1.5, attempt), 5000)));
|
|
106
|
+
}
|
|
82
107
|
}
|
|
83
108
|
}
|
|
84
109
|
}
|
|
85
110
|
|
|
111
|
+
/** True for the transient socket errors seen while a router process is
|
|
112
|
+
* still coming up — worth retrying, unlike a real protocol failure. */
|
|
113
|
+
function isConnectionError(err: any): boolean {
|
|
114
|
+
const codes = ["ECONNREFUSED", "ETIMEDOUT", "ECONNRESET", "ENOENT"];
|
|
115
|
+
const has = (code?: string) => code !== undefined && codes.includes(code);
|
|
116
|
+
return has(err?.code) || has(err?.cause?.code)
|
|
117
|
+
|| has(err?.originalError?.code) || has(err?.originalError?.cause?.code);
|
|
118
|
+
}
|
|
119
|
+
|
|
86
120
|
/**
|
|
87
121
|
* What consumers need to discover and connect to a service
|
|
88
122
|
*/
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import {GGLog} from "@grest-ts/logger";
|
|
2
2
|
import {IPCServer} from "@grest-ts/ipc";
|
|
3
3
|
import {GGLocalDiscoveryServer} from "./GGLocalDiscoveryServer";
|
|
4
|
-
import {GGLocalDiscoveryClient} from "./GGLocalDiscoveryClient";
|
|
4
|
+
import {GGLocalDiscoveryClient, getLocalDiscoveryPort} from "./GGLocalDiscoveryClient";
|
|
5
|
+
import {GGDiscoveryIPC, DiscoveryServerKind} from "./GGDiscoveryIPC";
|
|
5
6
|
|
|
6
7
|
export class GGLocalDiscoveryResilientClient extends GGLocalDiscoveryClient {
|
|
7
8
|
|
|
8
9
|
private discoveryServer?: GGLocalDiscoveryServer;
|
|
9
10
|
private isLeader = false;
|
|
10
11
|
private isShuttingDown = false;
|
|
12
|
+
/** Once any bin has held the port in this runtime's lifetime, never
|
|
13
|
+
* bid for it again. In-memory only. */
|
|
14
|
+
private seenBinBasedMaster = false;
|
|
11
15
|
|
|
12
|
-
constructor(port =
|
|
16
|
+
constructor(port = getLocalDiscoveryPort()) {
|
|
13
17
|
super(port);
|
|
14
18
|
}
|
|
15
19
|
|
|
@@ -32,12 +36,19 @@ export class GGLocalDiscoveryResilientClient extends GGLocalDiscoveryClient {
|
|
|
32
36
|
|
|
33
37
|
private async becomeLeaderOrFollower(): Promise<void> {
|
|
34
38
|
if (this.isShuttingDown) return;
|
|
39
|
+
if (this.seenBinBasedMaster) return this.connectToLeader();
|
|
35
40
|
|
|
36
|
-
const
|
|
37
|
-
const router = new GGLocalDiscoveryServer(server);
|
|
41
|
+
const router = new GGLocalDiscoveryServer(new IPCServer(this.port));
|
|
38
42
|
if (await router.start()) {
|
|
39
43
|
this.discoveryServer = router;
|
|
40
44
|
this.isLeader = true;
|
|
45
|
+
router.onYield = async () => {
|
|
46
|
+
GGLog.warn(this, "Yielding leadership to authoritative discovery (bin); will not bid for the port again in this runtime");
|
|
47
|
+
this.seenBinBasedMaster = true;
|
|
48
|
+
this.isLeader = false;
|
|
49
|
+
this.discoveryServer = undefined;
|
|
50
|
+
await this.connectToLeader();
|
|
51
|
+
};
|
|
41
52
|
GGLog.info(this, "This instance is LEADER");
|
|
42
53
|
} else {
|
|
43
54
|
this.isLeader = false;
|
|
@@ -52,18 +63,32 @@ export class GGLocalDiscoveryResilientClient extends GGLocalDiscoveryClient {
|
|
|
52
63
|
try {
|
|
53
64
|
await this.client.connect();
|
|
54
65
|
|
|
66
|
+
if (!this.seenBinBasedMaster) {
|
|
67
|
+
const info = await this.client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.getServerInfo, undefined);
|
|
68
|
+
if (info.kind === DiscoveryServerKind.Bin) {
|
|
69
|
+
GGLog.warn(this, "Connected to authoritative discovery (bin); will not bid for the port in this runtime");
|
|
70
|
+
this.seenBinBasedMaster = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Re-publish entries on every successful connect — covers
|
|
75
|
+
// initial register, follower-on-leader-death, and post-yield
|
|
76
|
+
// reconnect uniformly. Server-side dedup on (clientId, api)
|
|
77
|
+
// makes this idempotent, so the wrapped register()'s own
|
|
78
|
+
// super.register() call is a harmless no-op.
|
|
79
|
+
if (this.entries.length > 0) {
|
|
80
|
+
await this.client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.register, this.entries);
|
|
81
|
+
}
|
|
82
|
+
|
|
55
83
|
this.client.onClose(async () => {
|
|
56
84
|
if (this.isShuttingDown) return;
|
|
57
85
|
GGLog.warn(this, "Leader died");
|
|
58
86
|
await this.becomeLeaderOrFollower();
|
|
59
|
-
if (this.isLeader && this.entries.length > 0) {
|
|
60
|
-
await super.register();
|
|
61
|
-
}
|
|
62
87
|
});
|
|
63
88
|
|
|
64
89
|
GGLog.debug(this, "Connected to leader");
|
|
65
90
|
} catch (err: any) {
|
|
66
|
-
GGLog.error(this, `
|
|
91
|
+
GGLog.error(this, `Discovery router not reachable on port ${this.port} — is @grest-ts/discovery-local running? Will retry. (${err.message || err.code || err})`);
|
|
67
92
|
await this.delay(1000);
|
|
68
93
|
await this.connectToLeader();
|
|
69
94
|
}
|
|
@@ -5,7 +5,7 @@ import {FirstStrategy} from "./routing/strategies/FirstStrategy";
|
|
|
5
5
|
import {LastStrategy} from "./routing/strategies/LastStrategy";
|
|
6
6
|
import {RandomStrategy} from "./routing/strategies/RandomStrategy";
|
|
7
7
|
import {RoundRobinStrategy} from "./routing/strategies/RoundRobinStrategy";
|
|
8
|
-
import {GGDiscoveryIPC} from "./GGDiscoveryIPC";
|
|
8
|
+
import {GGDiscoveryIPC, DiscoveryServerKind} from "./GGDiscoveryIPC";
|
|
9
9
|
import {GGServiceDiscoveryEntry} from "./GGLocalDiscoveryClient";
|
|
10
10
|
|
|
11
11
|
export const ROUTING_STRATEGIES = {
|
|
@@ -32,8 +32,9 @@ export class GGLocalDiscoveryServer {
|
|
|
32
32
|
private readonly server: IPCServer;
|
|
33
33
|
private readonly routes: Map<string, RegisteredEntry[]> = new Map();
|
|
34
34
|
private readonly routingStrategies: Map<string, RoutingStrategy> = new Map();
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
public onYield?: () => Promise<void>;
|
|
36
|
+
|
|
37
|
+
constructor(server: IPCServer, public readonly kind: DiscoveryServerKind = DiscoveryServerKind.Embedded) {
|
|
37
38
|
this.server = server;
|
|
38
39
|
|
|
39
40
|
// Socket handlers for framework communication
|
|
@@ -57,6 +58,17 @@ export class GGLocalDiscoveryServer {
|
|
|
57
58
|
return {success: false, error: "Service '" + apiName + "' is not registered! Did you forget to start it?"};
|
|
58
59
|
});
|
|
59
60
|
|
|
61
|
+
this.server.onFrameworkMessage(GGDiscoveryIPC.discoveryServer.getServerInfo, async () => ({kind: this.kind}));
|
|
62
|
+
|
|
63
|
+
// Defer release so the ack flushes before we close the socket.
|
|
64
|
+
// onYield owners (e.g. resilient client) own teardown themselves.
|
|
65
|
+
this.server.onFrameworkMessage(GGDiscoveryIPC.discoveryServer.requestYield, async () => {
|
|
66
|
+
setTimeout(async () => {
|
|
67
|
+
await this.teardown();
|
|
68
|
+
await this.onYield?.();
|
|
69
|
+
}, 10);
|
|
70
|
+
});
|
|
71
|
+
|
|
60
72
|
this.server.setRouteProxyResolver((path) => {
|
|
61
73
|
return this.matchRoute(path)?.baseUrl || undefined;
|
|
62
74
|
})
|
|
@@ -106,8 +118,16 @@ export class GGLocalDiscoveryServer {
|
|
|
106
118
|
|
|
107
119
|
public addRoute(route: GGServiceDiscoveryEntry): void {
|
|
108
120
|
const existing = this.routes.get(route.api) ?? [];
|
|
109
|
-
|
|
110
|
-
|
|
121
|
+
const stored = route as RegisteredEntry;
|
|
122
|
+
// Dedup on (clientId, api) so a client re-registering its own
|
|
123
|
+
// route — e.g. on reconnect after a leader restart — replaces
|
|
124
|
+
// its previous entry instead of appending a duplicate. Routes
|
|
125
|
+
// from different clients (legitimate replicas) coexist.
|
|
126
|
+
const filtered = stored.clientId !== undefined
|
|
127
|
+
? existing.filter(r => r.clientId !== stored.clientId)
|
|
128
|
+
: existing;
|
|
129
|
+
filtered.push(stored);
|
|
130
|
+
this.routes.set(route.api, filtered);
|
|
111
131
|
GGLog.info(this, `Added route: ${route.pathPrefix} (${route.api}) -> ${route.baseUrl}`);
|
|
112
132
|
}
|
|
113
133
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {IPCServer, IPCClient} from "@grest-ts/ipc";
|
|
2
|
+
import {GGLocatorScope} from "@grest-ts/locator";
|
|
3
|
+
import {GGLocalDiscoveryServer} from "./GGLocalDiscoveryServer";
|
|
4
|
+
import {GGLocalDiscoveryResilientClient} from "./GGLocalDiscoveryResilientClient";
|
|
5
|
+
import {GGDiscoveryIPC, DiscoveryServerKind} from "./GGDiscoveryIPC";
|
|
6
|
+
|
|
7
|
+
async function startServer(kind: DiscoveryServerKind): Promise<{server: GGLocalDiscoveryServer, port: number}> {
|
|
8
|
+
const ipc = new IPCServer(0);
|
|
9
|
+
const server = new GGLocalDiscoveryServer(ipc, kind);
|
|
10
|
+
if (!(await server.start())) throw new Error("bind failed");
|
|
11
|
+
return {server, port: ipc.getPort()};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function isPortFree(port: number): Promise<boolean> {
|
|
15
|
+
const probe = new IPCServer(port);
|
|
16
|
+
const ok = await probe.start();
|
|
17
|
+
if (ok) await probe.teardown();
|
|
18
|
+
return ok;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("yield protocol", () => {
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
new GGLocatorScope("YieldProtocolTest").enter();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("getServerInfo returns kind; requestYield without onYield tears down", async () => {
|
|
28
|
+
const {server, port} = await startServer(DiscoveryServerKind.Bin);
|
|
29
|
+
const client = new IPCClient(port);
|
|
30
|
+
await client.connect();
|
|
31
|
+
const info = await client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.getServerInfo, undefined);
|
|
32
|
+
expect(info.kind).toBe(DiscoveryServerKind.Bin);
|
|
33
|
+
await client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.requestYield, undefined);
|
|
34
|
+
client.disconnect();
|
|
35
|
+
await new Promise(r => setTimeout(r, 100));
|
|
36
|
+
expect(await isPortFree(port)).toBe(true);
|
|
37
|
+
await server.teardown().catch((): undefined => undefined);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("requestYield invokes onYield after teardown", async () => {
|
|
41
|
+
const {server, port} = await startServer(DiscoveryServerKind.Embedded);
|
|
42
|
+
let firedAfterTeardown = false;
|
|
43
|
+
server.onYield = async () => { firedAfterTeardown = await isPortFree(port); };
|
|
44
|
+
const client = new IPCClient(port);
|
|
45
|
+
await client.connect();
|
|
46
|
+
await client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.requestYield, undefined);
|
|
47
|
+
client.disconnect();
|
|
48
|
+
await new Promise(r => setTimeout(r, 100));
|
|
49
|
+
expect(firedAfterTeardown).toBe(true);
|
|
50
|
+
expect(await isPortFree(port)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("resilient client that connected to a bin never re-bids after the bin dies", async () => {
|
|
54
|
+
const {server: bin, port} = await startServer(DiscoveryServerKind.Bin);
|
|
55
|
+
|
|
56
|
+
const subscope = new GGLocatorScope("ResilientClientTest");
|
|
57
|
+
subscope.setLifecycleOwner(() => undefined);
|
|
58
|
+
subscope.enter();
|
|
59
|
+
const resilient = new GGLocalDiscoveryResilientClient(port);
|
|
60
|
+
await resilient.register();
|
|
61
|
+
|
|
62
|
+
await bin.teardown();
|
|
63
|
+
await new Promise(r => setTimeout(r, 200));
|
|
64
|
+
|
|
65
|
+
expect(await isPortFree(port)).toBe(true);
|
|
66
|
+
await resilient.unregister();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("addRoute dedups (same clientId, same api) on re-register", async () => {
|
|
70
|
+
const {server, port} = await startServer(DiscoveryServerKind.Embedded);
|
|
71
|
+
try {
|
|
72
|
+
const client = new IPCClient(port);
|
|
73
|
+
await client.connect();
|
|
74
|
+
const entry = {api: "myapi", baseUrl: "http://localhost:1111", pathPrefix: "/api/my/"};
|
|
75
|
+
await client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.register, [entry]);
|
|
76
|
+
await client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.register, [entry]);
|
|
77
|
+
await client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.register, [entry]);
|
|
78
|
+
// Internal: same clientId + api should appear exactly once.
|
|
79
|
+
expect(server.getRoute("myapi")?.baseUrl).toBe("http://localhost:1111");
|
|
80
|
+
const internalRoutes = (server as unknown as {routes: Map<string, unknown[]>}).routes.get("myapi");
|
|
81
|
+
expect(internalRoutes?.length).toBe(1);
|
|
82
|
+
client.disconnect();
|
|
83
|
+
} finally {
|
|
84
|
+
await server.teardown();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("addRoute keeps entries from different clients (legitimate replicas)", async () => {
|
|
89
|
+
const {server, port} = await startServer(DiscoveryServerKind.Embedded);
|
|
90
|
+
try {
|
|
91
|
+
const a = new IPCClient(port);
|
|
92
|
+
const b = new IPCClient(port);
|
|
93
|
+
await a.connect("replica-a");
|
|
94
|
+
await b.connect("replica-b");
|
|
95
|
+
await a.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.register, [{api: "rep", baseUrl: "http://localhost:2001", pathPrefix: "/api/rep/"}]);
|
|
96
|
+
await b.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.register, [{api: "rep", baseUrl: "http://localhost:2002", pathPrefix: "/api/rep/"}]);
|
|
97
|
+
const internalRoutes = (server as unknown as {routes: Map<string, Array<{baseUrl: string}>>}).routes.get("rep");
|
|
98
|
+
expect(internalRoutes?.length).toBe(2);
|
|
99
|
+
expect(new Set(internalRoutes!.map(r => r.baseUrl))).toEqual(new Set(["http://localhost:2001", "http://localhost:2002"]));
|
|
100
|
+
a.disconnect();
|
|
101
|
+
b.disconnect();
|
|
102
|
+
} finally {
|
|
103
|
+
await server.teardown();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("resilient follower re-publishes its entries on a new leader after the old one dies", async () => {
|
|
108
|
+
const {server: leader1, port} = await startServer(DiscoveryServerKind.Bin);
|
|
109
|
+
|
|
110
|
+
const subscope = new GGLocatorScope("ResilientReregisterTest");
|
|
111
|
+
subscope.setLifecycleOwner(() => undefined);
|
|
112
|
+
subscope.enter();
|
|
113
|
+
const resilient = new GGLocalDiscoveryResilientClient(port);
|
|
114
|
+
resilient.registerRoutes([{runtime: "test", api: "myapi", protocol: "http", port: 1234, pathPrefix: "/api/my/"}]);
|
|
115
|
+
await resilient.register();
|
|
116
|
+
// Sanity: route is on leader1.
|
|
117
|
+
expect(leader1.getRoute("myapi")?.baseUrl).toBe("http://localhost:1234");
|
|
118
|
+
|
|
119
|
+
await leader1.teardown();
|
|
120
|
+
// New leader on the SAME port (also a "bin" so the resilient stays loyal).
|
|
121
|
+
const ipc2 = new IPCServer(port);
|
|
122
|
+
const leader2 = new GGLocalDiscoveryServer(ipc2, DiscoveryServerKind.Bin);
|
|
123
|
+
if (!(await leader2.start())) throw new Error("leader2 bind failed");
|
|
124
|
+
try {
|
|
125
|
+
// Wait long enough for resilient's onClose (1s retry) to trigger
|
|
126
|
+
// a fresh connectToLeader → re-register.
|
|
127
|
+
for (let i = 0; i < 20 && !leader2.getRoute("myapi"); i++) {
|
|
128
|
+
await new Promise(r => setTimeout(r, 200));
|
|
129
|
+
}
|
|
130
|
+
expect(leader2.getRoute("myapi")?.baseUrl).toBe("http://localhost:1234");
|
|
131
|
+
} finally {
|
|
132
|
+
await resilient.unregister();
|
|
133
|
+
await leader2.teardown();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|