@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.
Files changed (31) hide show
  1. package/README.md +8 -0
  2. package/dist/src/_dedupCheck.js +1 -1
  3. package/dist/src/bin/discovery-local.d.ts +3 -0
  4. package/dist/src/bin/discovery-local.d.ts.map +1 -0
  5. package/dist/src/bin/discovery-local.js +58 -0
  6. package/dist/src/bin/discovery-local.js.map +1 -0
  7. package/dist/src/local/GGDiscoveryIPC.d.ts +8 -0
  8. package/dist/src/local/GGDiscoveryIPC.d.ts.map +1 -1
  9. package/dist/src/local/GGDiscoveryIPC.js +7 -0
  10. package/dist/src/local/GGDiscoveryIPC.js.map +1 -1
  11. package/dist/src/local/GGLocalDiscoveryClient.d.ts +3 -0
  12. package/dist/src/local/GGLocalDiscoveryClient.d.ts.map +1 -1
  13. package/dist/src/local/GGLocalDiscoveryClient.js +38 -2
  14. package/dist/src/local/GGLocalDiscoveryClient.js.map +1 -1
  15. package/dist/src/local/GGLocalDiscoveryResilientClient.d.ts +3 -0
  16. package/dist/src/local/GGLocalDiscoveryResilientClient.d.ts.map +1 -1
  17. package/dist/src/local/GGLocalDiscoveryResilientClient.js +32 -8
  18. package/dist/src/local/GGLocalDiscoveryResilientClient.js.map +1 -1
  19. package/dist/src/local/GGLocalDiscoveryServer.d.ts +4 -1
  20. package/dist/src/local/GGLocalDiscoveryServer.d.ts.map +1 -1
  21. package/dist/src/local/GGLocalDiscoveryServer.js +24 -4
  22. package/dist/src/local/GGLocalDiscoveryServer.js.map +1 -1
  23. package/dist/tsconfig.publish.tsbuildinfo +1 -1
  24. package/package.json +11 -7
  25. package/src/_dedupCheck.ts +1 -1
  26. package/src/bin/discovery-local.ts +58 -0
  27. package/src/local/GGDiscoveryIPC.ts +7 -0
  28. package/src/local/GGLocalDiscoveryClient.ts +36 -2
  29. package/src/local/GGLocalDiscoveryResilientClient.ts +33 -8
  30. package/src/local/GGLocalDiscoveryServer.ts +25 -5
  31. 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 (!this.client.isConnected()) {
81
- await this.client.connect();
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 = 9000) {
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 server = new IPCServer(this.port);
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, `Failed to connect to leader: ${err.message}`);
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
- constructor(server: IPCServer) {
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
- existing.push(route);
110
- this.routes.set(route.api, existing);
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
+ });