service-bridge 1.0.17 → 1.1.1-dev.29

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 (3) hide show
  1. package/README.md +10 -13
  2. package/dist/index.js +399 -101
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -97,7 +97,6 @@ import { servicebridge } from "service-bridge";
97
97
  const sb = servicebridge(
98
98
  process.env.SERVICEBRIDGE_URL ?? "localhost:14445",
99
99
  process.env.SERVICEBRIDGE_SERVICE_KEY!,
100
- "payments",
101
100
  );
102
101
 
103
102
  sb.handleRpc("charge", async (payload: { orderId: string; amount: number }) => {
@@ -115,7 +114,6 @@ import { servicebridge } from "service-bridge";
115
114
  const sb = servicebridge(
116
115
  process.env.SERVICEBRIDGE_URL ?? "localhost:14445",
117
116
  process.env.SERVICEBRIDGE_SERVICE_KEY!,
118
- "orders",
119
117
  );
120
118
 
121
119
  const result = await sb.rpc<{ ok: boolean; txId: string }>("payments/charge", {
@@ -153,7 +151,7 @@ import { servicebridge } from "service-bridge";
153
151
 
154
152
  // --- Payments service (worker) ---
155
153
 
156
- const payments = servicebridge("localhost:14445", process.env.SERVICEBRIDGE_SERVICE_KEY!, "payments");
154
+ const payments = servicebridge("localhost:14445", process.env.SERVICEBRIDGE_SERVICE_KEY!);
157
155
 
158
156
  payments.handleRpc("charge", async (payload: { orderId: string; amount: number }, ctx) => {
159
157
  await ctx?.stream.write({ status: "charging", orderId: payload.orderId }, "progress");
@@ -170,7 +168,7 @@ await payments.serve({ host: "localhost" });
170
168
  ```ts
171
169
  // --- Orders service (caller + event publisher) ---
172
170
 
173
- const orders = servicebridge("localhost:14445", process.env.SERVICEBRIDGE_SERVICE_KEY!, "orders");
171
+ const orders = servicebridge("localhost:14445", process.env.SERVICEBRIDGE_SERVICE_KEY!);
174
172
 
175
173
  // Call payments, then publish event
176
174
  const charge = await orders.rpc<{ ok: boolean; txId: string }>("payments/charge", {
@@ -190,7 +188,7 @@ await orders.event("orders.completed", {
190
188
  ```ts
191
189
  // --- Notifications service (event consumer) ---
192
190
 
193
- const notifications = servicebridge("localhost:14445", process.env.SERVICEBRIDGE_SERVICE_KEY!, "notifications");
191
+ const notifications = servicebridge("localhost:14445", process.env.SERVICEBRIDGE_SERVICE_KEY!);
194
192
 
195
193
  notifications.handleEvent("orders.*", async (payload, ctx) => {
196
194
  const body = payload as { orderId: string; txId: string };
@@ -273,18 +271,19 @@ across all three SDKs. Parity differences are naming-only (language idioms):
273
271
  - Handler hints: timeout/retryable/concurrency/prefetch are advisory in all SDKs
274
272
  - Shared `serve()` fields across SDKs: host, max in-flight, instance ID, weight, and per-serve TLS override
275
273
 
276
- ### `servicebridge(url, serviceKey, serviceName?, opts?)`
274
+ ### `servicebridge(url, serviceKey, opts?)`
277
275
 
278
276
  ```ts
279
277
  function servicebridge(
280
278
  url: string,
281
279
  serviceKey: string,
282
- service?: string,
283
- globalOpts?: ServiceBridgeOpts,
280
+ serviceOrOpts?: string | ServiceBridgeOpts,
281
+ maybeGlobalOpts?: ServiceBridgeOpts,
284
282
  ): ServiceBridgeService
285
283
  ```
286
284
 
287
285
  Creates an SDK client instance.
286
+ Service identity is resolved by the runtime from `serviceKey`; passing a third `service` argument is legacy-only.
288
287
 
289
288
  `ServiceBridgeOpts`:
290
289
 
@@ -540,7 +539,7 @@ Registers an event consumer handler. Chainable.
540
539
 
541
540
  | Option | Type | Description |
542
541
  |---|---|---|
543
- | `groupName` | `string` | Consumer group name. Default: `<service>.<pattern>`. |
542
+ | `groupName` | `string` | Consumer group name. Default: `<service-key-id>.<pattern>`. |
544
543
  | `concurrency` | `number` | Advisory concurrency hint (currently not hard-enforced). |
545
544
  | `prefetch` | `number` | Advisory prefetch hint (currently not hard-enforced). |
546
545
  | `retryPolicyJson` | `string` | Retry policy JSON string. |
@@ -766,7 +765,7 @@ import express from "express";
766
765
  import { servicebridge } from "service-bridge";
767
766
  import { servicebridgeMiddleware, registerExpressRoutes } from "service-bridge/express";
768
767
 
769
- const sb = servicebridge(process.env.SERVICEBRIDGE_URL!, process.env.SERVICEBRIDGE_SERVICE_KEY!, "api");
768
+ const sb = servicebridge(process.env.SERVICEBRIDGE_URL!, process.env.SERVICEBRIDGE_SERVICE_KEY!);
770
769
  const app = express();
771
770
 
772
771
  app.use(servicebridgeMiddleware({
@@ -822,7 +821,7 @@ import Fastify from "fastify";
822
821
  import { servicebridge } from "service-bridge";
823
822
  import { servicebridgePlugin, wrapHandler } from "service-bridge/fastify";
824
823
 
825
- const sb = servicebridge(process.env.SERVICEBRIDGE_URL!, process.env.SERVICEBRIDGE_SERVICE_KEY!, "api");
824
+ const sb = servicebridge(process.env.SERVICEBRIDGE_URL!, process.env.SERVICEBRIDGE_SERVICE_KEY!);
826
825
  const app = Fastify();
827
826
 
828
827
  await app.register(servicebridgePlugin, {
@@ -895,13 +894,11 @@ The SDK requires values you pass into `servicebridge(...)`. Common setup:
895
894
  |---|---|---|---|
896
895
  | `SERVICEBRIDGE_URL` | yes | `localhost:14445` | gRPC control plane URL |
897
896
  | `SERVICEBRIDGE_SERVICE_KEY` | yes | `sbv2.<id>.<secret>.<ca>` | Service authentication key (sbv2 only) |
898
- | `SERVICEBRIDGE_SERVICE` | yes (worker mode) | `orders` | Service name in registry |
899
897
 
900
898
  ```ts
901
899
  const sb = servicebridge(
902
900
  process.env.SERVICEBRIDGE_URL ?? "localhost:14445",
903
901
  process.env.SERVICEBRIDGE_SERVICE_KEY!,
904
- process.env.SERVICEBRIDGE_SERVICE ?? "orders",
905
902
  );
906
903
  ```
907
904
 
package/dist/index.js CHANGED
@@ -90,7 +90,27 @@ class ServiceBridgeError extends Error {
90
90
  this.operation = opts.operation;
91
91
  this.severity = opts.severity;
92
92
  this.retryable = opts.severity === "retriable";
93
- this.cause = opts.cause;
93
+ Object.defineProperty(this, "cause", {
94
+ value: opts.cause,
95
+ writable: true,
96
+ enumerable: false,
97
+ configurable: true
98
+ });
99
+ }
100
+ toString() {
101
+ return `ServiceBridgeError [${this.severity}]: ${this.message} (${this.operation})`;
102
+ }
103
+ [Symbol.for("nodejs.util.inspect.custom")]() {
104
+ const lines = [
105
+ `ServiceBridgeError [${this.severity}]`,
106
+ ` operation: ${this.operation}`,
107
+ ` message: ${this.message}`
108
+ ];
109
+ if (this.code !== undefined) {
110
+ lines.push(` code: ${this.code}`);
111
+ }
112
+ return lines.join(`
113
+ `);
94
114
  }
95
115
  }
96
116
  function containsValue(values, value) {
@@ -214,7 +234,14 @@ function normalizeServiceError(err, operation, component = "control-plane") {
214
234
  }
215
235
  const grpcErr = err;
216
236
  const code = typeof grpcErr?.code === "number" ? grpcErr.code : undefined;
217
- const message = typeof grpcErr?.message === "string" && grpcErr.message.length > 0 ? grpcErr.message : err instanceof Error ? err.message : String(err);
237
+ const details = typeof grpcErr?.details === "string" && grpcErr.details.trim() ? grpcErr.details.trim() : undefined;
238
+ const rawMsg = (() => {
239
+ const m = typeof grpcErr?.message === "string" && grpcErr.message ? grpcErr.message : err instanceof Error ? err.message : String(err);
240
+ return m.replace(/^\d+\s+[A-Z_]+:\s*/, "").trim() || m;
241
+ })();
242
+ const base = friendlyGrpcMessage(code, details ?? rawMsg);
243
+ const appendDetails = details && (code === grpc.status.INTERNAL || code === grpc.status.UNKNOWN || code === undefined);
244
+ const message = appendDetails ? `${base}: ${details}` : base;
218
245
  return new ServiceBridgeError({
219
246
  message,
220
247
  code,
@@ -224,13 +251,74 @@ function normalizeServiceError(err, operation, component = "control-plane") {
224
251
  cause: err
225
252
  });
226
253
  }
254
+ function friendlyGrpcMessage(code, rawMessage) {
255
+ if (code === undefined)
256
+ return rawMessage || "unknown error";
257
+ switch (code) {
258
+ case grpc.status.UNAVAILABLE:
259
+ return "control plane unavailable";
260
+ case grpc.status.DEADLINE_EXCEEDED:
261
+ return "request timed out";
262
+ case grpc.status.UNAUTHENTICATED:
263
+ return "authentication failed — check service key";
264
+ case grpc.status.PERMISSION_DENIED:
265
+ return "permission denied — check service key";
266
+ case grpc.status.RESOURCE_EXHAUSTED:
267
+ return "rate limit exceeded";
268
+ case grpc.status.NOT_FOUND:
269
+ return "resource not found";
270
+ case grpc.status.INTERNAL:
271
+ return "internal server error";
272
+ case grpc.status.FAILED_PRECONDITION:
273
+ return "precondition failed";
274
+ case grpc.status.CANCELLED:
275
+ return "request cancelled";
276
+ case grpc.status.UNIMPLEMENTED:
277
+ return "operation not supported by control plane";
278
+ case grpc.status.UNKNOWN:
279
+ return "unknown error from control plane";
280
+ default:
281
+ return rawMessage || "unknown error";
282
+ }
283
+ }
284
+ function friendlySDKMessage(operation, err) {
285
+ const base = err.message;
286
+ switch (operation) {
287
+ case "open-worker-session":
288
+ case "worker-session":
289
+ return err.severity === "retriable" ? `${base} — waiting for reconnect` : `worker session error: ${base}`;
290
+ case "worker-session-end":
291
+ return `worker session cleanup: ${base}`;
292
+ case "worker-session-command":
293
+ return `worker command error: ${base}`;
294
+ case "heartbeat":
295
+ return `heartbeat failed: ${base}`;
296
+ case "http-heartbeat":
297
+ return `HTTP heartbeat failed: ${base}`;
298
+ case "flush-offline-queue":
299
+ return `offline queue flush failed: ${base}`;
300
+ case "flush-on-ready":
301
+ case "flush-on-restore":
302
+ return `offline queue flush failed: ${base}`;
303
+ case "report-call-start":
304
+ case "report-call":
305
+ return `call report failed: ${base}`;
306
+ case "worker-force-shutdown":
307
+ case "worker-try-shutdown":
308
+ return `worker shutdown error: ${base}`;
309
+ case "close-control-plane-client":
310
+ return `control plane disconnect: ${base}`;
311
+ default:
312
+ return `${operation}: ${base}`;
313
+ }
314
+ }
227
315
  function reportSDKError(operation, err, component = "sdk") {
228
316
  const normalized = normalizeServiceError(err, operation, component);
229
- const message = `[servicebridge] ${normalized.component}.${normalized.operation}: ${normalized.message}`;
317
+ const friendly = friendlySDKMessage(operation, normalized);
230
318
  if (normalized.severity === "fatal") {
231
- console.error(message, err);
319
+ console.error(`[servicebridge] ${friendly}`);
232
320
  } else {
233
- console.warn(message, err);
321
+ console.warn(`[servicebridge] ${friendly}`);
234
322
  }
235
323
  return normalized;
236
324
  }
@@ -260,6 +348,101 @@ function workerChannelOptions(tlsOpts) {
260
348
  function workerClientCredentials(tlsOpts) {
261
349
  return grpc.credentials.createSsl(toPemBuffer(tlsOpts?.caCert), toPemBuffer(tlsOpts?.key), toPemBuffer(tlsOpts?.cert));
262
350
  }
351
+ var _wireStringMapMode = null;
352
+ function detectWireStringMapMode() {
353
+ const publishMethod = servicebridgeProto.ServiceBridge.service?.Publish;
354
+ const serialize = publishMethod?.requestSerialize;
355
+ if (typeof serialize !== "function") {
356
+ return "entries";
357
+ }
358
+ const baseReq = {
359
+ topic: "__probe__",
360
+ payload: Buffer.alloc(0),
361
+ trace_id: "",
362
+ parent_span_id: "",
363
+ producer_service: "",
364
+ idempotency_key: ""
365
+ };
366
+ try {
367
+ serialize({
368
+ ...baseReq,
369
+ headers: { probe: "1" }
370
+ });
371
+ return "object";
372
+ } catch {
373
+ try {
374
+ serialize({
375
+ ...baseReq,
376
+ headers: [{ key: "probe", value: "1" }]
377
+ });
378
+ return "entries";
379
+ } catch {
380
+ return "entries";
381
+ }
382
+ }
383
+ }
384
+ function wireStringMapMode() {
385
+ if (_wireStringMapMode === null) {
386
+ _wireStringMapMode = detectWireStringMapMode();
387
+ }
388
+ return _wireStringMapMode;
389
+ }
390
+ function normalizeStringMap(raw) {
391
+ const normalized = {};
392
+ if (!raw)
393
+ return normalized;
394
+ for (const [key, value] of Object.entries(raw)) {
395
+ const k = key.trim();
396
+ if (!k)
397
+ continue;
398
+ if (typeof value === "string") {
399
+ normalized[k] = value;
400
+ continue;
401
+ }
402
+ if (value == null) {
403
+ normalized[k] = "";
404
+ continue;
405
+ }
406
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
407
+ normalized[k] = String(value);
408
+ continue;
409
+ }
410
+ try {
411
+ normalized[k] = JSON.stringify(value);
412
+ } catch {
413
+ normalized[k] = String(value);
414
+ }
415
+ }
416
+ return normalized;
417
+ }
418
+ function toWireStringMap(raw) {
419
+ const normalized = normalizeStringMap(raw);
420
+ if (wireStringMapMode() === "entries") {
421
+ return Object.entries(normalized).map(([key, value]) => ({
422
+ key,
423
+ value
424
+ }));
425
+ }
426
+ return normalized;
427
+ }
428
+ function fromWireStringMap(raw) {
429
+ if (Array.isArray(raw)) {
430
+ const out = {};
431
+ for (const entry of raw) {
432
+ if (typeof entry?.key !== "string")
433
+ continue;
434
+ const key = entry.key.trim();
435
+ if (!key)
436
+ continue;
437
+ out[key] = typeof entry.value === "string" ? entry.value : entry.value == null ? "" : String(entry.value);
438
+ }
439
+ return out;
440
+ }
441
+ if (raw && typeof raw === "object") {
442
+ return normalizeStringMap(raw);
443
+ }
444
+ return {};
445
+ }
263
446
  async function provisionWorkerTLS(controlClient, md, serviceName, extraIps) {
264
447
  const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", {
265
448
  namedCurve: "P-256",
@@ -296,8 +479,16 @@ function ensureSbResolverRegistered() {
296
479
  listener;
297
480
  refreshTimer = null;
298
481
  constructor(target, listener) {
299
- this.clientId = target.authority ?? "";
300
- this.canonicalName = (target.path ?? "").replace(/^\/+/, "");
482
+ const authority = (target.authority ?? "").trim();
483
+ const normalizedPath = (target.path ?? "").replace(/^\/+/, "");
484
+ if (authority && authority !== "localhost" && _sbContexts.has(authority)) {
485
+ this.clientId = authority;
486
+ this.canonicalName = normalizedPath;
487
+ } else {
488
+ const [clientIdFromPath = "", ...canonicalParts] = normalizedPath.split("/");
489
+ this.clientId = clientIdFromPath.trim();
490
+ this.canonicalName = canonicalParts.join("/").trim();
491
+ }
301
492
  this.listener = listener;
302
493
  this.init().catch(() => {});
303
494
  }
@@ -309,7 +500,7 @@ function ensureSbResolverRegistered() {
309
500
  error: {
310
501
  code: 14,
311
502
  details: `No SB context for clientId=${this.clientId}`,
312
- metadata: {}
503
+ metadata: new grpc.Metadata
313
504
  }
314
505
  }, {}, null, "sb-resolver");
315
506
  return;
@@ -336,7 +527,7 @@ function ensureSbResolverRegistered() {
336
527
  error: {
337
528
  code: 14,
338
529
  details: `No live endpoints for ${this.canonicalName}`,
339
- metadata: {}
530
+ metadata: new grpc.Metadata
340
531
  }
341
532
  }, {}, null, "sb-resolver");
342
533
  return;
@@ -355,7 +546,11 @@ function ensureSbResolverRegistered() {
355
546
  }
356
547
  registerResolver("sb", SbResolver);
357
548
  }
358
- function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
549
+ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}) {
550
+ const service = typeof serviceOrOpts === "string" ? serviceOrOpts.trim() : "";
551
+ const globalOpts = typeof serviceOrOpts === "string" ? maybeGlobalOpts : serviceOrOpts ?? {};
552
+ const parsedServiceKey = parseServiceKeyV2(serviceKey);
553
+ const defaultConsumerPrefix = service || parsedServiceKey.keyId || "consumer";
359
554
  const meta = new grpc.Metadata;
360
555
  meta.add("x-service-key", serviceKey);
361
556
  const rawUrl = url.trim();
@@ -396,8 +591,9 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
396
591
  const fnAliasMap = new Map;
397
592
  const functionChannels = new Map;
398
593
  let isOnline = false;
594
+ let isFlushing = false;
399
595
  let stopped = false;
400
- let onlineRestoreTimer = null;
596
+ let isWatchingChannel = false;
401
597
  const offlineQueue = [];
402
598
  let workerServer = null;
403
599
  let workerSessionStream = null;
@@ -444,7 +640,30 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
444
640
  md.add("x-caller-service", service);
445
641
  return md;
446
642
  }
643
+ function sessionSend(msg) {
644
+ if (!workerSessionStream)
645
+ return false;
646
+ try {
647
+ workerSessionStream.write(msg);
648
+ return true;
649
+ } catch {
650
+ return false;
651
+ }
652
+ }
447
653
  async function sendReportCallStart(op) {
654
+ if (sessionSend({
655
+ telemetry_start: {
656
+ trace_id: op.traceId,
657
+ span_id: op.spanId,
658
+ parent_span_id: op.parentSpanId,
659
+ fn: op.fn,
660
+ started_at: String(op.startedAt),
661
+ input: op.inputBuf,
662
+ attempt: op.attempt
663
+ }
664
+ })) {
665
+ return;
666
+ }
448
667
  await new Promise((resolve, reject) => {
449
668
  stub.ReportCallStart({
450
669
  trace_id: op.traceId,
@@ -460,6 +679,22 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
460
679
  });
461
680
  }
462
681
  async function sendReportCall(op) {
682
+ if (sessionSend({
683
+ telemetry_finish: {
684
+ trace_id: op.traceId,
685
+ span_id: op.spanId,
686
+ fn: op.fn,
687
+ started_at: String(op.startedAt),
688
+ duration_ms: String(op.durationMs),
689
+ success: op.success,
690
+ error: op.error ?? "",
691
+ input: op.inputBuf,
692
+ output: op.outputBuf ?? Buffer.alloc(0),
693
+ attempt: op.attempt
694
+ }
695
+ })) {
696
+ return;
697
+ }
463
698
  await new Promise((resolve, reject) => {
464
699
  stub.ReportCall({
465
700
  trace_id: op.traceId,
@@ -467,7 +702,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
467
702
  fn: op.fn,
468
703
  service_name: service,
469
704
  started_at: String(op.startedAt),
470
- duration_ms: String(computeDurationMs(op.startedAt)),
705
+ duration_ms: String(op.durationMs),
471
706
  success: op.success,
472
707
  error: op.error ?? "",
473
708
  input: op.inputBuf,
@@ -482,12 +717,17 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
482
717
  function flushLogs() {
483
718
  if (logBatch.length === 0)
484
719
  return;
485
- const entries = logBatch;
720
+ const entries = logBatch.map((entry) => ({
721
+ ...entry,
722
+ attributes: toWireStringMap(entry.attributes)
723
+ }));
486
724
  logBatch = [];
487
725
  if (logFlushTimer !== null) {
488
726
  clearTimeout(logFlushTimer);
489
727
  logFlushTimer = null;
490
728
  }
729
+ if (sessionSend({ telemetry_log: { entries } }))
730
+ return;
491
731
  stub.ReportLog({ entries }, meta, unaryDeadlineOptions(), () => {});
492
732
  }
493
733
  function pushLog(level, msg, attrs) {
@@ -614,7 +854,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
614
854
  throw new Error("worker TLS is not configured");
615
855
  }
616
856
  const creds = workerClientCredentials(tlsOpts);
617
- ch = new grpc.Channel(`sb://${clientId}/${canonicalName}`, creds, {
857
+ ch = new grpc.Channel(`sb://localhost/${clientId}/${canonicalName}`, creds, {
618
858
  ...workerChannelOptions(tlsOpts ?? undefined),
619
859
  "grpc.lb_policy_name": "round_robin"
620
860
  });
@@ -661,29 +901,39 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
661
901
  }
662
902
  }
663
903
  _controlReady.then(() => {
664
- isOnline = true;
665
- flushQueue().catch((err) => {
666
- if (isConnectionError(err))
667
- scheduleOnlineRestore();
668
- else
669
- reportSDKError("flush-on-ready", err);
670
- });
904
+ watchChannelConnectivity();
671
905
  }).catch(() => {});
672
- function scheduleOnlineRestore() {
673
- if (stopped || isOnline || onlineRestoreTimer)
906
+ function watchChannelConnectivity() {
907
+ isWatchingChannel = false;
908
+ if (stopped)
674
909
  return;
675
- onlineRestoreTimer = setTimeout(() => {
676
- onlineRestoreTimer = null;
677
- if (!stopped) {
910
+ const channel = stub.getChannel();
911
+ const state = channel.getConnectivityState(true);
912
+ if (state === grpc.connectivityState.READY) {
913
+ if (!isOnline) {
678
914
  isOnline = true;
679
- flushQueue().catch((err) => {
680
- if (isConnectionError(err))
681
- scheduleOnlineRestore();
682
- else
683
- reportSDKError("flush-on-restore", err);
915
+ const queueLen = offlineQueue.length;
916
+ console.info(`[servicebridge] reconnected to runtime${queueLen > 0 ? ` — flushing ${queueLen} queued operation(s)` : ""}`);
917
+ flushQueue().then(() => {
918
+ if (isOnline)
919
+ scheduleNextHeartbeat(100);
920
+ }).catch((err) => {
921
+ reportSDKError("flush-on-restore", err);
684
922
  });
685
923
  }
686
- }, 2000);
924
+ } else if (state === grpc.connectivityState.TRANSIENT_FAILURE) {
925
+ if (isOnline) {
926
+ isOnline = false;
927
+ console.warn("[servicebridge] lost connection to runtime — entering offline mode");
928
+ }
929
+ }
930
+ isWatchingChannel = true;
931
+ channel.watchConnectivityState(state, Infinity, watchChannelConnectivity);
932
+ }
933
+ function ensureChannelWatch() {
934
+ if (!isWatchingChannel && !stopped) {
935
+ watchChannelConnectivity();
936
+ }
687
937
  }
688
938
  function isConnectionError(e) {
689
939
  const code = e?.code;
@@ -716,8 +966,11 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
716
966
  attempt
717
967
  }).catch((err) => {
718
968
  if (isConnectionError(err)) {
969
+ if (isOnline) {
970
+ console.warn("[servicebridge] lost connection to runtime — entering offline mode");
971
+ }
719
972
  isOnline = false;
720
- scheduleOnlineRestore();
973
+ ensureChannelWatch();
721
974
  enqueueOffline({
722
975
  type: "reportCallStart",
723
976
  traceId,
@@ -734,6 +987,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
734
987
  });
735
988
  }
736
989
  function reportCallAsync(traceId, spanId, fn, startedAt, inputBuf, success, attempt, outputBuf, error = "") {
990
+ const durationMs = computeDurationMs(startedAt);
737
991
  if (!isOnline) {
738
992
  enqueueOffline({
739
993
  type: "reportCall",
@@ -741,6 +995,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
741
995
  spanId,
742
996
  fn,
743
997
  startedAt,
998
+ durationMs,
744
999
  inputBuf,
745
1000
  success,
746
1001
  attempt,
@@ -754,6 +1009,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
754
1009
  spanId,
755
1010
  fn,
756
1011
  startedAt,
1012
+ durationMs,
757
1013
  inputBuf,
758
1014
  success,
759
1015
  attempt,
@@ -761,14 +1017,18 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
761
1017
  error
762
1018
  }).catch((err) => {
763
1019
  if (isConnectionError(err)) {
1020
+ if (isOnline) {
1021
+ console.warn("[servicebridge] lost connection to runtime — entering offline mode");
1022
+ }
764
1023
  isOnline = false;
765
- scheduleOnlineRestore();
1024
+ ensureChannelWatch();
766
1025
  enqueueOffline({
767
1026
  type: "reportCall",
768
1027
  traceId,
769
1028
  spanId,
770
1029
  fn,
771
1030
  startedAt,
1031
+ durationMs,
772
1032
  inputBuf,
773
1033
  success,
774
1034
  attempt,
@@ -781,59 +1041,84 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
781
1041
  });
782
1042
  }
783
1043
  async function flushQueue() {
784
- while (offlineQueue.length > 0 && isOnline) {
785
- const op = offlineQueue[0];
786
- try {
787
- if (op.type === "event") {
788
- await new Promise((res, rej) => {
789
- stub.Publish({
790
- topic: op.topic,
791
- payload: toJsonBuffer(op.payload),
792
- headers: op.opts?.headers ?? {},
793
- trace_id: op.opts?.traceId ?? "",
794
- parent_span_id: op.opts?.parentSpanId ?? "",
795
- producer_service: service,
796
- idempotency_key: op.opts?.idempotencyKey ?? ""
797
- }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
798
- });
799
- } else if (op.type === "job") {
800
- await new Promise((res, rej) => {
801
- stub.RegisterJob({
802
- cron_expr: op.opts.cron ?? "",
803
- timezone: op.opts.timezone ?? "UTC",
804
- misfire_policy: op.opts.misfire ?? "fire_now",
805
- target_type: op.opts.via ?? "rpc",
806
- target_ref: op.target,
807
- delay_ms: op.opts.delay ?? 0,
808
- service_name: service,
809
- retry_policy_json: op.opts.retryPolicyJson ?? "{}"
810
- }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
811
- });
812
- } else if (op.type === "workflow") {
813
- await new Promise((res, rej) => {
814
- stub.RegisterWorkflow({
815
- name: op.name,
816
- definition: JSON.stringify(op.steps),
817
- opts: op.opts ? JSON.stringify(op.opts) : "{}",
818
- service_name: service
819
- }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
820
- });
821
- } else if (op.type === "reportCallStart") {
822
- await sendReportCallStart({ ...op });
823
- } else if (op.type === "reportCall") {
824
- await sendReportCall(op);
825
- }
826
- offlineQueue.shift();
827
- } catch (err) {
828
- if (isConnectionError(err)) {
829
- isOnline = false;
830
- scheduleOnlineRestore();
831
- break;
1044
+ if (isFlushing || offlineQueue.length === 0)
1045
+ return;
1046
+ isFlushing = true;
1047
+ const snapshot = offlineQueue.slice();
1048
+ let flushed = 0;
1049
+ try {
1050
+ while (offlineQueue.length > 0 && isOnline) {
1051
+ const op = offlineQueue[0];
1052
+ try {
1053
+ if (op.type === "event") {
1054
+ await new Promise((res, rej) => {
1055
+ stub.Publish({
1056
+ topic: op.topic,
1057
+ payload: toJsonBuffer(op.payload),
1058
+ headers: toWireStringMap(op.opts?.headers),
1059
+ trace_id: op.opts?.traceId ?? "",
1060
+ parent_span_id: op.opts?.parentSpanId ?? "",
1061
+ producer_service: service,
1062
+ idempotency_key: op.opts?.idempotencyKey ?? ""
1063
+ }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
1064
+ });
1065
+ } else if (op.type === "job") {
1066
+ await new Promise((res, rej) => {
1067
+ stub.RegisterJob({
1068
+ cron_expr: op.opts.cron ?? "",
1069
+ timezone: op.opts.timezone ?? "UTC",
1070
+ misfire_policy: op.opts.misfire ?? "fire_now",
1071
+ target_type: op.opts.via ?? "rpc",
1072
+ target_ref: op.target,
1073
+ delay_ms: op.opts.delay ?? 0,
1074
+ service_name: service,
1075
+ retry_policy_json: op.opts.retryPolicyJson ?? "{}"
1076
+ }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
1077
+ });
1078
+ } else if (op.type === "workflow") {
1079
+ await new Promise((res, rej) => {
1080
+ stub.RegisterWorkflow({
1081
+ name: op.name,
1082
+ definition: JSON.stringify(op.steps),
1083
+ opts: op.opts ? JSON.stringify(op.opts) : "{}",
1084
+ service_name: service
1085
+ }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
1086
+ });
1087
+ } else if (op.type === "reportCallStart") {
1088
+ await sendReportCallStart({ ...op });
1089
+ } else if (op.type === "reportCall") {
1090
+ await sendReportCall(op);
1091
+ }
1092
+ offlineQueue.shift();
1093
+ flushed++;
1094
+ } catch (err) {
1095
+ if (isConnectionError(err)) {
1096
+ if (isOnline) {
1097
+ console.warn("[servicebridge] lost connection to runtime — entering offline mode");
1098
+ }
1099
+ isOnline = false;
1100
+ ensureChannelWatch();
1101
+ break;
1102
+ }
1103
+ reportSDKError("flush-offline-queue", err);
1104
+ offlineQueue.shift();
832
1105
  }
833
- reportSDKError("flush-offline-queue", err);
834
- break;
835
1106
  }
1107
+ } finally {
1108
+ isFlushing = false;
836
1109
  }
1110
+ if (flushed > 0) {
1111
+ const remaining = offlineQueue.length;
1112
+ const sentOps = snapshot.slice(0, flushed);
1113
+ console.info(`[servicebridge] flushed ${flushed}/${snapshot.length} queued operation(s) to runtime` + ` (${formatQueueSummary(sentOps)})` + (remaining > 0 ? ` — ${remaining} still queued` : ""));
1114
+ }
1115
+ }
1116
+ function formatQueueSummary(ops) {
1117
+ const counts = {};
1118
+ for (const op of ops) {
1119
+ counts[op.type] = (counts[op.type] ?? 0) + 1;
1120
+ }
1121
+ return Object.entries(counts).map(([type, n]) => `${n} ${type}`).join(", ");
837
1122
  }
838
1123
  function requirePeerCN(call, allowedCallers, isDeliver) {
839
1124
  const peerCN = extractPeerCN(call);
@@ -858,6 +1143,17 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
858
1143
  return;
859
1144
  const state = serveState;
860
1145
  const metrics = getProcessMetrics();
1146
+ const hbMsg = {
1147
+ endpoint: state.endpoint,
1148
+ group_names: [...registeredGroups],
1149
+ function_names: [...fnHandlers.keys()]
1150
+ };
1151
+ if (metrics.cpuPercent != null)
1152
+ hbMsg.cpu_percent = metrics.cpuPercent;
1153
+ if (metrics.ramMb != null)
1154
+ hbMsg.ram_mb = metrics.ramMb;
1155
+ if (sessionSend({ heartbeat: hbMsg }))
1156
+ return;
861
1157
  const req = {
862
1158
  service_name: service,
863
1159
  instance_id: state.instanceId,
@@ -875,6 +1171,8 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
875
1171
  });
876
1172
  }
877
1173
  async function sendHttpHeartbeat(instanceId) {
1174
+ if (sessionSend({ http_heartbeat: {} }))
1175
+ return;
878
1176
  await new Promise((resolve, reject) => {
879
1177
  stub.HeartbeatHttpEndpoint({ service_name: service, instance_id: instanceId }, meta, unaryDeadlineOptions(), (err, res) => {
880
1178
  if (err) {
@@ -967,8 +1265,11 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
967
1265
  }
968
1266
  } catch (err) {
969
1267
  if (isConnectionError(err)) {
1268
+ if (isOnline) {
1269
+ console.warn("[servicebridge] lost connection to runtime — entering offline mode");
1270
+ }
970
1271
  isOnline = false;
971
- scheduleOnlineRestore();
1272
+ ensureChannelWatch();
972
1273
  } else if (isRegistryResyncRequiredError(err)) {
973
1274
  await syncRegistrations("heartbeat-resync");
974
1275
  } else {
@@ -996,12 +1297,12 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
996
1297
  const append = (data, key = "default") => {
997
1298
  if (!isOnline || !runId)
998
1299
  return Promise.resolve();
1300
+ const dataBuf = Buffer.from(JSON.stringify(data));
1301
+ if (sessionSend({ append_stream: { run_id: runId, key, data: dataBuf } })) {
1302
+ return Promise.resolve();
1303
+ }
999
1304
  return new Promise((resolve, reject) => {
1000
- stub.AppendStream({
1001
- run_id: runId,
1002
- key,
1003
- data: Buffer.from(JSON.stringify(data))
1004
- }, meta, unaryDeadlineOptions(), (err) => err ? reject(normalizeServiceError(err, `append-stream:${runId}`)) : resolve());
1305
+ stub.AppendStream({ run_id: runId, key, data: dataBuf }, meta, unaryDeadlineOptions(), (err) => err ? reject(normalizeServiceError(err, `append-stream:${runId}`)) : resolve());
1005
1306
  });
1006
1307
  };
1007
1308
  return {
@@ -1083,7 +1384,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1083
1384
  groupName: entry.groupName,
1084
1385
  messageId: String(req.message_id ?? ""),
1085
1386
  attempt: Number(req.attempt ?? 0),
1086
- headers: req.headers ?? {}
1387
+ headers: fromWireStringMap(req.headers)
1087
1388
  },
1088
1389
  retry(delayMs = 1000) {
1089
1390
  shouldRetry = true;
@@ -1187,7 +1488,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1187
1488
  group_name: String(event.group_name ?? ""),
1188
1489
  topic: String(event.topic ?? ""),
1189
1490
  payload: event.payload ? Buffer.from(event.payload) : Buffer.alloc(0),
1190
- headers: event.headers ?? {},
1491
+ headers: fromWireStringMap(event.headers),
1191
1492
  trace_id: String(event.trace_id ?? ""),
1192
1493
  parent_span_id: String(event.parent_span_id ?? ""),
1193
1494
  attempt: Number(event.attempt ?? 0)
@@ -1391,7 +1692,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1391
1692
  stub.Publish({
1392
1693
  topic,
1393
1694
  payload: toJsonBuffer(payload),
1394
- headers: opts?.headers ?? {},
1695
+ headers: toWireStringMap(opts?.headers),
1395
1696
  trace_id: opts?.traceId ?? tc?.traceId ?? "",
1396
1697
  parent_span_id: opts?.parentSpanId ?? tc?.spanId ?? "",
1397
1698
  producer_service: service,
@@ -1405,7 +1706,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1405
1706
  },
1406
1707
  handleEvent(pattern, handler, opts) {
1407
1708
  const normalizedOpts = opts ?? {};
1408
- const groupName = normalizedOpts.groupName || `${service}.${pattern}`;
1709
+ const groupName = normalizedOpts.groupName || `${defaultConsumerPrefix}.${pattern}`;
1409
1710
  if (eventHandlers.has(groupName)) {
1410
1711
  throw new Error(`Duplicate event consumer group "${groupName}". ` + "Use a distinct groupName for each handleEvent() registration.");
1411
1712
  }
@@ -1423,9 +1724,6 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1423
1724
  if (fnHandlers.size === 0 && eventHandlers.size === 0) {
1424
1725
  throw new Error("No handlers registered. Call handleRpc() or handleEvent() before serve().");
1425
1726
  }
1426
- if (!service.trim()) {
1427
- throw new Error("serve() requires a non-empty service name");
1428
- }
1429
1727
  if (opts.maxInFlight != null && (!Number.isInteger(opts.maxInFlight) || opts.maxInFlight < 1)) {
1430
1728
  throw new Error("serve() maxInFlight must be a positive integer");
1431
1729
  }
@@ -1494,17 +1792,17 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1494
1792
  closeWorkerSession();
1495
1793
  serveState = null;
1496
1794
  shutdownWorkerServerGracefully();
1497
- throw normalizeServiceError(err, "serve");
1795
+ const fatal = normalizeServiceError(err, "serve");
1796
+ console.error(`[servicebridge] startup failed: ${fatal.message}`);
1797
+ throw fatal;
1498
1798
  }
1499
1799
  },
1500
1800
  stop() {
1501
1801
  stopped = true;
1502
1802
  isOnline = false;
1503
- if (onlineRestoreTimer)
1504
- clearTimeout(onlineRestoreTimer);
1803
+ isWatchingChannel = false;
1505
1804
  if (heartbeatTimer)
1506
1805
  clearTimeout(heartbeatTimer);
1507
- onlineRestoreTimer = null;
1508
1806
  heartbeatTimer = null;
1509
1807
  registeredGroups.clear();
1510
1808
  closeWorkerSession();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "service-bridge",
3
- "version": "1.0.17",
3
+ "version": "1.1.1-dev.29",
4
4
  "type": "module",
5
5
  "description": "ServiceBridge SDK for Node.js — production-ready RPC, durable events, workflows, jobs, and distributed tracing. One Go runtime + PostgreSQL replaces Istio, RabbitMQ, Temporal, and Jaeger.",
6
6
  "keywords": [