@setu-ts/service-discovery-plugin

Service discovery for Setu-TS — turns a logical service name into a reachable address, balances across the instances behind it, and takes them out of rotation when callers report failures.

Registers an IServiceDiscovery under CAPABILITIES.SERVICE_DISCOVERY. Zero npm dependencies: the HTTP providers run on web-standard fetch and the DNS provider on the optional IRuntimeServices.dns.

Install

npm install @setu-ts/service-discovery-plugin
# Deno: deno add npm:@setu-ts/service-discovery-plugin

Quick start

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { ServiceDiscoveryPlugin } from '@setu-ts/service-discovery-plugin';
import { CAPABILITIES, type IServiceDiscovery } from '@setu-ts/common';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    ServiceDiscoveryPlugin({
      provider: 'static',
      services: {
        billing: [
          { host: '10.0.0.1', port: 8080 },
          { host: '10.0.0.2', port: 8080, weight: 3 },
        ],
      },
    }),
  ],
});

await app.start({ port: 3000 });

const discovery = app.services.get<IServiceDiscovery>(CAPABILITIES.SERVICE_DISCOVERY);
const url = await discovery.resolveUrl('billing', '/invoices'); // http://10.0.0.1:8080/invoices

Providers

ProviderReadswatch()Runtimes
'static'A literal list in your optionsFires once, then neverAll, incl. Workers
'consul'GET /v1/health/service/:serviceBlocking queries (push)All, incl. Workers
'kubernetes'EndpointSlices from the API serverWatch stream (push)All, incl. Workers¹
'dns'SRV or A/AAAA recordsPolled at watchIntervalMsDeno, Node, Bun
'custom'Your own DiscoveryProviderWhatever you implementYours

¹ Workers needs an explicit token, since it has no file system to read the projected service-account token from. So does any process outside a pod (a developer machine, a VM): without token the provider reads /var/run/secrets/kubernetes.io/serviceaccount/token, and since M119c a missing file is reported by one startup warning and a DiscoveryUnavailableError that name token and apiServer (V10-44), rather than a silent down.

The 'dns' arm needs IRuntimeServices.dns, which Cloudflare Workers does not supply — its network access is fetch, which resolves names internally and exposes no lookup surface. Configuring it there throws DiscoveryUnavailableError at register(), naming the alternatives.

Load balancing

strategy is 'round-robin' (default), 'random', or 'weighted-random', set per plugin and overridable per call:

await discovery.pick('billing', { strategy: 'weighted-random' });

weighted-random reads ServiceInstance.weight (absent means 1, non-positive means never selected). The DNS provider maps an SRV record’s own weight onto it, so a zone’s intent is honoured with no extra configuration. Randomness comes from IRuntimeServices.randomBytes, never Math.random().

SRV resolution keeps only the records in the numerically lowest priority tier, per RFC 2782 — a primary tier and its designated fallback are not mixed into one pool.

Outlier ejection

Report how each call went and discovery stops picking instances that keep failing:

const instance = await discovery.pick('billing');
if (instance === null) throw new Error('no billing instance');

try {
  const response = await fetch(`http://${instance.host}:${instance.port}/invoices`);
  discovery.report(instance, response.ok ? 'success' : 'failure');
} catch (error) {
  discovery.report(instance, 'failure');
  throw error;
}

Defaults: 5 failures inside a 30 s rolling window eject an instance for 30 s; one success clears its window and un-ejects it immediately. ejection: false disables the mechanism.

Two safeguards exist because ejection can otherwise amplify a correlated failure into a total outage. maxEjectionPercent (default 50) caps how much of a service may be ejected at once, and if every instance is ejected anyway, pick() falls back to the unfiltered list rather than returning null. Ejection state is per-process; a cluster-wide view is not attempted.

resolve() deliberately does not filter ejected instances — it reports what discovery knows, while pick() reports what is usable.

Composing with the resilience plugin

Ejection is a different mechanism from a circuit breaker, not a duplicate of one. wrap() breaks a call site — after enough failures it stops calling at all, which for a multi-instance service means refusing healthy instances because unhealthy ones failed. Ejection removes a pool member while the call site stays open. They compose by re-picking inside the wrapped call:

const call = resilience.wrap(
  async () => {
    const instance = await discovery.pick('billing');
    if (instance === null) throw new Error('no billing instance');
    try {
      const response = await fetch(`http://${instance.host}:${instance.port}/invoices`);
      discovery.report(instance, response.ok ? 'success' : 'failure');
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();
    } catch (error) {
      discovery.report(instance, 'failure');
      throw error;
    }
  },
  { retry: { limit: 3, delay: 100, backoff: 'exponential' } },
);

wrap re-enters the function on every attempt, so each retry picks again and lands on a different instance once the failing one is ejected.

Watching for changes

const unsubscribe = await discovery.watch('billing', (instances) => {
  console.log(`billing now has ${instances.length} instances`);
});

The listener always receives the full instance list, never a delta, and a change invalidates that service’s cache entry immediately — so the TTL is a safety net for unwatched services rather than the freshness mechanism for watched ones.

Self-registration (Consul only)

import { ServiceDiscoveryPlugin } from '@setu-ts/service-discovery-plugin';

ServiceDiscoveryPlugin({
  provider: 'consul',
  address: 'http://127.0.0.1:8500',
  selfRegistration: {
    serviceName: 'orders',
    address: '10.0.0.7',
    port: 3000,
    drainDelayMs: 5_000,
  },
});

Registration runs at onBootstrap and deregistration at onStopping — the kernel hook that fires before the application starts refusing requests. Deregistering at onShutdown instead would leave callers routed at a closed port for up to one check interval on every rolling deploy. drainDelayMs then keeps serving normally for that long after deregistering, so callers holding a stale instance list are served rather than refused while the change propagates.

The health check is not optional: it defaults to { httpPath: '/health', intervalSeconds: 10, deregisterAfterSeconds: 60 } and cannot be disabled. onBootstrap runs before the socket is bound, so the instance is advertised a moment before it can serve — that window is harmless only because Consul marks a newly registered service critical until its first check passes and every read here sends passing=true.

Configuring selfRegistration with any other provider throws SelfRegistrationNotSupportedError at register() rather than silently doing nothing.

Kubernetes RBAC

The kubernetes provider reads EndpointSlices with the pod’s projected ServiceAccount token, so it needs permission the plugin cannot grant itself:

rules:
  - apiGroups: ['discovery.k8s.io']
    resources: ['endpointslices']
    verbs: ['get', 'list', 'watch']

watch is required as well as list — the provider re-LISTs on every watch event. The framework’s Helm chart renders this Role and its RoleBinding when serviceDiscovery.enabled=true; see the deployment guide.

Kubernetes in-cluster TLS

The API server presents a cluster-internal CA that fetch rejects. No code change fixes this from inside the process — point the runtime at the mounted CA bundle:

  • Deno: DENO_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
  • Node: NODE_EXTRA_CA_CERTS=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt

The http option is the alternative: supply your own IDiscoveryHttp over a TLS-configured client.

Service-account tokens are re-read from disk (memoized for 60 s) rather than read once at startup, because Kubernetes rotates projected tokens roughly hourly — a token captured at registration stops working while the pod is still healthy.

Options

OptionArmsDefaultNotes
providerall—Discriminant; always explicit
cacheTtlMsall30_0000 disables caching
strategyall'round-robin'Overridable per pick() call
ejectionallsee abovefalse disables
selfRegistrationconsul (+ custom)—Throws on the other arms
watchIntervalMsstatic, dns30_000Absent from the push-based arms
servicesstatic—Unknown name resolves to []
addressconsul—Agent base URL
tokenconsul, kubernetes— / projected token fileConsul: X-Consul-Token; Kubernetes: bearer
datacenterconsul—Sent as ?dc=
waitSecondsconsul30Clamped to Consul’s maximum of 600
namespacekubernetes—Required
apiServerkubernetesin-cluster envRequired outside a cluster
portNamekubernetes—Required when a service exposes several ports
modedns—'srv' or 'a'
domainTemplatedns'{service}.service.consul'{service} is substituted
portdns ('a' only)—Mandatory: address records carry no port
secureconsul, k8s, dnsfalseDecides the https scheme
httpconsul, kubernetesfetchInjectable transport
discoverycustom—Your DiscoveryProvider, used as supplied

Health indicator

Registered as service-discovery. Since M70c it composes three facts:

  • everResolved — false until the first successful provider read, so a backend that was never reached is down, not up (the X10-3 fix).
  • degraded — a refresh failed and a stale snapshot is being served.
  • isHealthy() — the provider’s reachability probe (Consul GET /v1/status/leader, Kubernetes a limit=1 EndpointSlice LIST).
StatusMeaning
upThe provider is reachable, or has resolved at least one service before. A name never cached still fails while the backend is down, so alert on data.degraded and data.reachable.
downThe provider has never resolved and is unreachable.

data reports { provider, cachedServices, watchedServices, ejectedInstances, degraded, reachable, everResolved }.

Errors

DiscoveryUnavailableError is thrown when discovery cannot answer. Its cold-outage instance — a backend read failed and nothing is cached — carries a 503 Service Unavailable status hint (M119g), so errorHandler answers a named 503 with the fixed detail “Service discovery is temporarily unavailable.” rather than a masked 500. Configuration failures are not branded: the 'dns' arm on a runtime with no resolver and the 'kubernetes' arm with neither a token nor IRuntimeServices.fs are refused at startup, and a multi-port service with no portName fails its lookup with an unbranded DiscoveryUnavailableError that is answered as a 500 — a misconfiguration is the operator’s to fix, not a temporary outage. SelfRegistrationNotSupportedError is thrown at register() when selfRegistration is configured on a provider with no registration API.

License

MIT

Exports

ExportKind
createDefaultDiscoveryHttpfunction
ServiceDiscoveryPluginfunction
ConsulProviderclass
DiscoveryUnavailableErrorclass
DnsProviderclass
KubernetesProviderclass
SelfRegistrationNotSupportedErrorclass
StaticProviderclass
ADnsDiscoveryOptionsinterface
ConsulDiscoveryOptionsinterface
ConsulProviderOptionsinterface
CustomDiscoveryOptionsinterface
DiscoveryHttpResponseinterface
DiscoveryHttpStreaminterface
DiscoveryProviderinterface
EjectionOptionsinterface
IDiscoveryHttpinterface
KubernetesDiscoveryOptionsinterface
KubernetesProviderOptionsinterface
SelfRegistrationinterface
SelfRegistrationCheckinterface
SrvDnsDiscoveryOptionsinterface
StaticDiscoveryOptionsinterface
StaticServiceDefinitioninterface
DnsDiscoveryOptionstype
DnsProviderOptionstype
ServiceDiscoveryPluginOptionstype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Readiness and dependency outages (M113b)

Provider reachability probes are invoked as methods, retaining their receiver. Healthy Kubernetes and Consul providers therefore report reachable and allow /ready to return 200. Since M113o, a warm-cache outage also reports up, keeping serving replicas in rotation. data.degraded still signals a failed refresh serving stale data; data.reachable exposes backend reachability.

The discovery service caches reachability for 5 s, coalesces concurrent reads, and bounds each probe at 2 s; a hung or rejecting probe reports false. Consul and Kubernetes probe requests carry an AbortSignal aborted after 2 s by runtime timers, cleared on completion. Health reads now issue at most one backend probe request per 5 s window per service instance. Before M113b, the detached provider call threw before I/O and issued none.

API reference

Service Discovery (@setu-ts/service-discovery-plugin)

Provider reachability probes are invoked as methods, retaining their receiver. Healthy Kubernetes and Consul providers therefore report reachable and allow /ready to return 200. Stale-cache degraded semantics remain unchanged.

The discovery service caches reachability for 5 s, coalesces concurrent reads, and bounds each probe at 2 s; a hung or rejecting probe reports false. Consul and Kubernetes probe requests carry an AbortSignal aborted after 2 s by runtime timers, cleared on completion. Health reads now issue at most one backend probe request per 5 s window per service instance. Before M113b, the detached provider call threw before I/O and issued none.

Turns a logical service name into a reachable address, balances across the instances behind it, and takes them out of rotation when callers report failures. Registers an IServiceDiscovery under CAPABILITIES.SERVICE_DISCOVERY ('service-discovery'). Zero npm dependencies — the HTTP providers run on web-standard fetch and the DNS provider on the optional IRuntimeServices.dns.

Registration

import { ServiceDiscoveryPlugin } from '@setu-ts/service-discovery-plugin';

app.register(
  ServiceDiscoveryPlugin({
    provider: 'consul',
    address: 'http://127.0.0.1:8500',
    strategy: 'round-robin',
  }),
);

Usage

import { CAPABILITIES, type IServiceDiscovery } from '@setu-ts/common';

const discovery = ctx.services.get<IServiceDiscovery>(CAPABILITIES.SERVICE_DISCOVERY);

const instances = await discovery.resolve('billing');
const instance = await discovery.pick('billing', { strategy: 'weighted-random' });
const url = await discovery.resolveUrl('billing', '/invoices');

discovery.report(instance!, 'failure');

const unsubscribe = await discovery.watch('billing', (list) => {
  console.log(`billing now has ${list.length} instances`);
});

Contract

interface IServiceDiscovery {
  resolve(serviceName: string): Promise<readonly ServiceInstance[]>;
  pick(serviceName: string, options?: PickOptions): Promise<ServiceInstance | null>;
  resolveUrl(serviceName: string, path?: string, options?: PickOptions): Promise<string | null>;
  report(instance: ServiceInstance, outcome: ServiceOutcome): void;
  watch(
    serviceName: string,
    listener: (instances: readonly ServiceInstance[]) => void,
  ): Promise<Unsubscribe>;
}

interface ServiceInstance {
  readonly id: string;
  readonly serviceName: string;
  readonly host: string;
  readonly port: number;
  readonly secure?: boolean;
  readonly weight?: number;
  readonly tags?: readonly string[];
  readonly metadata?: Readonly<Record<string, string>>;
}

interface PickOptions {
  readonly strategy?: LoadBalanceStrategy;
}

type LoadBalanceStrategy = 'round-robin' | 'random' | 'weighted-random';
type ServiceOutcome = 'success' | 'failure';

Registration and deregistration of this instance are deliberately not on the contract — the plugin drives its own lifecycle hooks, so a registerSelf()/deregisterSelf() pair here would be surface no application code path reads.

Providers

ServiceDiscoveryPluginOptions is a union discriminated on provider, so a missing per-arm credential is a compile error rather than a startup throw.

ArmReadswatch()Runtimes
'static'A literal list in the optionsFires once, then neverAll, incl. Workers
'consul'GET /v1/health/service/:serviceBlocking queries (push)All, incl. Workers
'kubernetes'EndpointSlices from the API serverWatch stream (push)All, incl. Workers¹
'dns'SRV or A/AAAA recordsPolled at watchIntervalMsDeno, Node, Bun
'custom'The application’s own providerWhatever it implementsAny

¹ Workers needs an explicit token: it has no file system to read the projected service-account token from.

Options

OptionArmsDefaultBehavior
providerall—Discriminant; always explicit
cacheTtlMsall30_0000 disables caching so every resolve hits the backend
strategyall'round-robin'Overridable per pick() call via PickOptions.strategy
ejectionallsee belowfalse disables outlier ejection entirely
selfRegistrationconsul, custom—Throws SelfRegistrationNotSupportedError on the other arms
watchIntervalMsstatic, dns30_000Absent from the push-based arms rather than silently unread
servicesstatic—Unknown name resolves to [], never a throw
addressconsul—Agent base URL
tokenconsul—Sent as X-Consul-Token; the header is omitted entirely when unset
datacenterconsul—Sent as ?dc=
waitSecondsconsul30Blocking-query wait, clamped to Consul’s documented maximum of 600
namespacekubernetes—Required
apiServerkubernetesin-cluster envAbsent both, register() throws
tokenkubernetesprojected token fileUsed verbatim; otherwise the file is re-read behind a 60 s memo
portNamekubernetes—Unset with one port uses it; unset with several throws
modedns—'srv' honours RFC 2782 priority tiers; 'a' reads address records
domainTemplatedns'{service}.service.consul'{service} is substituted with the requested name
portdns ('a' only)—Mandatory on that arm: DNS address records carry no port
secureconsul, k8s, dnsfalseSets ServiceInstance.secure, which decides the https scheme
httpconsul, kubernetesfetchOverrides createDefaultDiscoveryHttp()
discoverycustom—The application’s own DiscoveryProvider, used as supplied

Outlier ejection

report(instance, outcome) feeds a per-process ejection tracker. Defaults: { failureThreshold: 5, windowMs: 30_000, durationMs: 30_000, maxEjectionPercent: 50 }. A 'success' clears that instance’s window and un-ejects it immediately.

pick() filters ejected instances; resolve() does not — it reports what discovery knows, while pick() reports what is usable. maxEjectionPercent caps the share of a service ejected at once, and when every instance is ejected anyway pick() falls back to the unfiltered list rather than returning null: a correlated failure ejects the whole pool at once, and serving nothing turns a partial outage into a total one.

This is a different mechanism from IResilienceService.wrap’s circuit breaker, not a duplicate of it. wrap breaks a call site; ejection removes a pool member while the call site stays open. They compose by re-pick()ing inside the wrapped call.

Self-registration

Only the Consul arm (and a custom provider implementing registerSelf) can advertise this instance. Registration runs at onBootstrap; deregistration runs at onStopping, the lifecycle hook that fires before the application starts refusing requests, so the change propagates while traffic is still being served. selfRegistration.drainDelayMs (default 0) then holds that window open.

selfRegistration.check is not optional and cannot be disabled — it defaults to { httpPath: '/health', intervalSeconds: 10, deregisterAfterSeconds: 60 }. onBootstrap runs before the socket binds, so the instance is advertised a moment before it can serve; that window is harmless only because Consul marks a newly registered service critical until its first check passes and every read here sends passing=true.

Exports

ExportKindPurpose
ServiceDiscoveryPluginfunctionThe plugin factory
ServiceDiscoveryPluginOptionstypeThe discriminated option union
StaticDiscoveryOptionsinterfaceThe 'static' arm
ConsulDiscoveryOptionsinterfaceThe 'consul' arm
KubernetesDiscoveryOptionsinterfaceThe 'kubernetes' arm
DnsDiscoveryOptionstypeThe 'dns' arm (SrvDnsDiscoveryOptions | ADnsDiscoveryOptions)
CustomDiscoveryOptionsinterfaceThe 'custom' arm
StaticServiceDefinitioninterfaceOne entry of a static service list
EjectionOptionsinterfaceEjection tuning
SelfRegistrationinterfaceWhat this instance advertises
SelfRegistrationCheckinterfaceThe mandatory health check
DiscoveryProviderinterfaceThe provider port, for the 'custom' arm
StaticProviderclassThe 'static' provider
ConsulProviderclassThe 'consul' provider
KubernetesProviderclassThe 'kubernetes' provider
DnsProviderclassThe 'dns' provider
IDiscoveryHttpinterfaceThe injectable HTTP seam (buffered + streaming)
DiscoveryHttpResponseinterfaceBuffered response shape
DiscoveryHttpStreaminterfaceStreaming response shape
createDefaultDiscoveryHttpfunctionThe default seam over fetch
DiscoveryUnavailableErrorclassCold backend failure (branded 503), missing DNS resolver, k8s multi-port
SelfRegistrationNotSupportedErrorclassselfRegistration on an arm that cannot register

Notes

  • Registers a service-discovery health indicator reporting provider, cachedServices, watchedServices, ejectedInstances, and degraded. It reads the cache’s own observed state and never issues a backend call, so a health scrape does not become load against Consul. 'degraded' means a refresh failed and a stale snapshot is being served; 'down' is unreachable by construction, because with nothing cached the caller already received a DiscoveryUnavailableError.
  • onClose unsubscribes every active watch and clears ejection state.
  • The cache is read-through on the monotonic clock, with per-service in-flight coalescing (a burst of concurrent pick()s for one cold service issues one backend read) and stale-on-failure. A watch event invalidates that name’s entry immediately.
  • pick and resolveUrl funnel through one implementation, so both honour the same configured strategy and the same ejection filter.
  • Ejection state is per-process. A cluster-wide view is a distributed-consensus problem and is not attempted.
  • In-cluster Kubernetes needs DENO_CERT / NODE_EXTRA_CA_CERTS pointed at /var/run/secrets/kubernetes.io/serviceaccount/ca.crt; no code change fixes the cluster CA from inside the process. The http option is the escape hatch for a caller-supplied TLS-configured client.

Health status

Since M70c the indicator composes three facts. everResolved is false until the first successful provider read, so a backend that was never reached is down, not up (the X10-3 fix). degraded means a stale cache is being served. isHealthy() is the cached reachability probe (Consul /v1/status/leader, Kubernetes a limit=1 EndpointSlice LIST).

StatusMeaning
upThe provider is reachable, or has resolved at least one service before. A name never cached still fails while the backend is down, so alert on data.degraded and data.reachable.
downThe provider has never resolved and is unreachable.

data reports { provider, cachedServices, watchedServices, ejectedInstances, degraded, reachable, everResolved }.

Since M113o, serving a stale cache or losing backend reachability after resolution keeps up. data.degraded and data.reachable preserve visibility without draining serving replicas.

Agent card · TypeDoc API reference