service-bridge 1.0.17 → 1.1.0-dev.27

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 +315 -28
  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();
@@ -444,7 +639,30 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
444
639
  md.add("x-caller-service", service);
445
640
  return md;
446
641
  }
642
+ function sessionSend(msg) {
643
+ if (!workerSessionStream)
644
+ return false;
645
+ try {
646
+ workerSessionStream.write(msg);
647
+ return true;
648
+ } catch {
649
+ return false;
650
+ }
651
+ }
447
652
  async function sendReportCallStart(op) {
653
+ if (sessionSend({
654
+ telemetry_start: {
655
+ trace_id: op.traceId,
656
+ span_id: op.spanId,
657
+ parent_span_id: op.parentSpanId,
658
+ fn: op.fn,
659
+ started_at: String(op.startedAt),
660
+ input: op.inputBuf,
661
+ attempt: op.attempt
662
+ }
663
+ })) {
664
+ return;
665
+ }
448
666
  await new Promise((resolve, reject) => {
449
667
  stub.ReportCallStart({
450
668
  trace_id: op.traceId,
@@ -460,6 +678,22 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
460
678
  });
461
679
  }
462
680
  async function sendReportCall(op) {
681
+ if (sessionSend({
682
+ telemetry_finish: {
683
+ trace_id: op.traceId,
684
+ span_id: op.spanId,
685
+ fn: op.fn,
686
+ started_at: String(op.startedAt),
687
+ duration_ms: String(op.durationMs),
688
+ success: op.success,
689
+ error: op.error ?? "",
690
+ input: op.inputBuf,
691
+ output: op.outputBuf ?? Buffer.alloc(0),
692
+ attempt: op.attempt
693
+ }
694
+ })) {
695
+ return;
696
+ }
463
697
  await new Promise((resolve, reject) => {
464
698
  stub.ReportCall({
465
699
  trace_id: op.traceId,
@@ -467,7 +701,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
467
701
  fn: op.fn,
468
702
  service_name: service,
469
703
  started_at: String(op.startedAt),
470
- duration_ms: String(computeDurationMs(op.startedAt)),
704
+ duration_ms: String(op.durationMs),
471
705
  success: op.success,
472
706
  error: op.error ?? "",
473
707
  input: op.inputBuf,
@@ -482,12 +716,17 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
482
716
  function flushLogs() {
483
717
  if (logBatch.length === 0)
484
718
  return;
485
- const entries = logBatch;
719
+ const entries = logBatch.map((entry) => ({
720
+ ...entry,
721
+ attributes: toWireStringMap(entry.attributes)
722
+ }));
486
723
  logBatch = [];
487
724
  if (logFlushTimer !== null) {
488
725
  clearTimeout(logFlushTimer);
489
726
  logFlushTimer = null;
490
727
  }
728
+ if (sessionSend({ telemetry_log: { entries } }))
729
+ return;
491
730
  stub.ReportLog({ entries }, meta, unaryDeadlineOptions(), () => {});
492
731
  }
493
732
  function pushLog(level, msg, attrs) {
@@ -614,7 +853,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
614
853
  throw new Error("worker TLS is not configured");
615
854
  }
616
855
  const creds = workerClientCredentials(tlsOpts);
617
- ch = new grpc.Channel(`sb://${clientId}/${canonicalName}`, creds, {
856
+ ch = new grpc.Channel(`sb://localhost/${clientId}/${canonicalName}`, creds, {
618
857
  ...workerChannelOptions(tlsOpts ?? undefined),
619
858
  "grpc.lb_policy_name": "round_robin"
620
859
  });
@@ -676,18 +915,21 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
676
915
  onlineRestoreTimer = null;
677
916
  if (!stopped) {
678
917
  isOnline = true;
918
+ const queueLen = offlineQueue.length;
919
+ console.info(`[servicebridge] reconnected to runtime${queueLen > 0 ? ` — flushing ${queueLen} queued operation(s)` : ""}`);
679
920
  flushQueue().catch((err) => {
680
921
  if (isConnectionError(err))
681
922
  scheduleOnlineRestore();
682
923
  else
683
924
  reportSDKError("flush-on-restore", err);
684
925
  });
926
+ scheduleNextHeartbeat(100);
685
927
  }
686
928
  }, 2000);
687
929
  }
688
930
  function isConnectionError(e) {
689
931
  const code = e?.code;
690
- return code === grpc.status.UNAVAILABLE || code === grpc.status.UNKNOWN;
932
+ return code === grpc.status.UNAVAILABLE || code === grpc.status.UNKNOWN || code === grpc.status.DEADLINE_EXCEEDED || code === grpc.status.RESOURCE_EXHAUSTED || code === grpc.status.INTERNAL;
691
933
  }
692
934
  function normalizeUnknownErrorMessage(error) {
693
935
  return error instanceof Error ? error.message : String(error);
@@ -716,6 +958,9 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
716
958
  attempt
717
959
  }).catch((err) => {
718
960
  if (isConnectionError(err)) {
961
+ if (isOnline) {
962
+ console.warn("[servicebridge] lost connection to runtime — entering offline mode");
963
+ }
719
964
  isOnline = false;
720
965
  scheduleOnlineRestore();
721
966
  enqueueOffline({
@@ -734,6 +979,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
734
979
  });
735
980
  }
736
981
  function reportCallAsync(traceId, spanId, fn, startedAt, inputBuf, success, attempt, outputBuf, error = "") {
982
+ const durationMs = computeDurationMs(startedAt);
737
983
  if (!isOnline) {
738
984
  enqueueOffline({
739
985
  type: "reportCall",
@@ -741,6 +987,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
741
987
  spanId,
742
988
  fn,
743
989
  startedAt,
990
+ durationMs,
744
991
  inputBuf,
745
992
  success,
746
993
  attempt,
@@ -754,6 +1001,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
754
1001
  spanId,
755
1002
  fn,
756
1003
  startedAt,
1004
+ durationMs,
757
1005
  inputBuf,
758
1006
  success,
759
1007
  attempt,
@@ -761,6 +1009,9 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
761
1009
  error
762
1010
  }).catch((err) => {
763
1011
  if (isConnectionError(err)) {
1012
+ if (isOnline) {
1013
+ console.warn("[servicebridge] lost connection to runtime — entering offline mode");
1014
+ }
764
1015
  isOnline = false;
765
1016
  scheduleOnlineRestore();
766
1017
  enqueueOffline({
@@ -769,6 +1020,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
769
1020
  spanId,
770
1021
  fn,
771
1022
  startedAt,
1023
+ durationMs,
772
1024
  inputBuf,
773
1025
  success,
774
1026
  attempt,
@@ -781,6 +1033,10 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
781
1033
  });
782
1034
  }
783
1035
  async function flushQueue() {
1036
+ if (offlineQueue.length === 0)
1037
+ return;
1038
+ const snapshot = offlineQueue.slice();
1039
+ let flushed = 0;
784
1040
  while (offlineQueue.length > 0 && isOnline) {
785
1041
  const op = offlineQueue[0];
786
1042
  try {
@@ -789,7 +1045,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
789
1045
  stub.Publish({
790
1046
  topic: op.topic,
791
1047
  payload: toJsonBuffer(op.payload),
792
- headers: op.opts?.headers ?? {},
1048
+ headers: toWireStringMap(op.opts?.headers),
793
1049
  trace_id: op.opts?.traceId ?? "",
794
1050
  parent_span_id: op.opts?.parentSpanId ?? "",
795
1051
  producer_service: service,
@@ -824,8 +1080,12 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
824
1080
  await sendReportCall(op);
825
1081
  }
826
1082
  offlineQueue.shift();
1083
+ flushed++;
827
1084
  } catch (err) {
828
1085
  if (isConnectionError(err)) {
1086
+ if (isOnline) {
1087
+ console.warn("[servicebridge] lost connection to runtime — entering offline mode");
1088
+ }
829
1089
  isOnline = false;
830
1090
  scheduleOnlineRestore();
831
1091
  break;
@@ -834,6 +1094,18 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
834
1094
  break;
835
1095
  }
836
1096
  }
1097
+ if (flushed > 0) {
1098
+ const remaining = offlineQueue.length;
1099
+ const sentOps = snapshot.slice(0, flushed);
1100
+ console.info(`[servicebridge] flushed ${flushed}/${snapshot.length} queued operation(s) to runtime` + ` (${formatQueueSummary(sentOps)})` + (remaining > 0 ? ` — ${remaining} still queued` : ""));
1101
+ }
1102
+ }
1103
+ function formatQueueSummary(ops) {
1104
+ const counts = {};
1105
+ for (const op of ops) {
1106
+ counts[op.type] = (counts[op.type] ?? 0) + 1;
1107
+ }
1108
+ return Object.entries(counts).map(([type, n]) => `${n} ${type}`).join(", ");
837
1109
  }
838
1110
  function requirePeerCN(call, allowedCallers, isDeliver) {
839
1111
  const peerCN = extractPeerCN(call);
@@ -858,6 +1130,17 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
858
1130
  return;
859
1131
  const state = serveState;
860
1132
  const metrics = getProcessMetrics();
1133
+ const hbMsg = {
1134
+ endpoint: state.endpoint,
1135
+ group_names: [...registeredGroups],
1136
+ function_names: [...fnHandlers.keys()]
1137
+ };
1138
+ if (metrics.cpuPercent != null)
1139
+ hbMsg.cpu_percent = metrics.cpuPercent;
1140
+ if (metrics.ramMb != null)
1141
+ hbMsg.ram_mb = metrics.ramMb;
1142
+ if (sessionSend({ heartbeat: hbMsg }))
1143
+ return;
861
1144
  const req = {
862
1145
  service_name: service,
863
1146
  instance_id: state.instanceId,
@@ -875,6 +1158,8 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
875
1158
  });
876
1159
  }
877
1160
  async function sendHttpHeartbeat(instanceId) {
1161
+ if (sessionSend({ http_heartbeat: {} }))
1162
+ return;
878
1163
  await new Promise((resolve, reject) => {
879
1164
  stub.HeartbeatHttpEndpoint({ service_name: service, instance_id: instanceId }, meta, unaryDeadlineOptions(), (err, res) => {
880
1165
  if (err) {
@@ -967,6 +1252,9 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
967
1252
  }
968
1253
  } catch (err) {
969
1254
  if (isConnectionError(err)) {
1255
+ if (isOnline) {
1256
+ console.warn("[servicebridge] lost connection to runtime — entering offline mode");
1257
+ }
970
1258
  isOnline = false;
971
1259
  scheduleOnlineRestore();
972
1260
  } else if (isRegistryResyncRequiredError(err)) {
@@ -996,12 +1284,12 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
996
1284
  const append = (data, key = "default") => {
997
1285
  if (!isOnline || !runId)
998
1286
  return Promise.resolve();
1287
+ const dataBuf = Buffer.from(JSON.stringify(data));
1288
+ if (sessionSend({ append_stream: { run_id: runId, key, data: dataBuf } })) {
1289
+ return Promise.resolve();
1290
+ }
999
1291
  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());
1292
+ stub.AppendStream({ run_id: runId, key, data: dataBuf }, meta, unaryDeadlineOptions(), (err) => err ? reject(normalizeServiceError(err, `append-stream:${runId}`)) : resolve());
1005
1293
  });
1006
1294
  };
1007
1295
  return {
@@ -1083,7 +1371,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1083
1371
  groupName: entry.groupName,
1084
1372
  messageId: String(req.message_id ?? ""),
1085
1373
  attempt: Number(req.attempt ?? 0),
1086
- headers: req.headers ?? {}
1374
+ headers: fromWireStringMap(req.headers)
1087
1375
  },
1088
1376
  retry(delayMs = 1000) {
1089
1377
  shouldRetry = true;
@@ -1187,7 +1475,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1187
1475
  group_name: String(event.group_name ?? ""),
1188
1476
  topic: String(event.topic ?? ""),
1189
1477
  payload: event.payload ? Buffer.from(event.payload) : Buffer.alloc(0),
1190
- headers: event.headers ?? {},
1478
+ headers: fromWireStringMap(event.headers),
1191
1479
  trace_id: String(event.trace_id ?? ""),
1192
1480
  parent_span_id: String(event.parent_span_id ?? ""),
1193
1481
  attempt: Number(event.attempt ?? 0)
@@ -1391,7 +1679,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1391
1679
  stub.Publish({
1392
1680
  topic,
1393
1681
  payload: toJsonBuffer(payload),
1394
- headers: opts?.headers ?? {},
1682
+ headers: toWireStringMap(opts?.headers),
1395
1683
  trace_id: opts?.traceId ?? tc?.traceId ?? "",
1396
1684
  parent_span_id: opts?.parentSpanId ?? tc?.spanId ?? "",
1397
1685
  producer_service: service,
@@ -1405,7 +1693,7 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1405
1693
  },
1406
1694
  handleEvent(pattern, handler, opts) {
1407
1695
  const normalizedOpts = opts ?? {};
1408
- const groupName = normalizedOpts.groupName || `${service}.${pattern}`;
1696
+ const groupName = normalizedOpts.groupName || `${defaultConsumerPrefix}.${pattern}`;
1409
1697
  if (eventHandlers.has(groupName)) {
1410
1698
  throw new Error(`Duplicate event consumer group "${groupName}". ` + "Use a distinct groupName for each handleEvent() registration.");
1411
1699
  }
@@ -1423,9 +1711,6 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1423
1711
  if (fnHandlers.size === 0 && eventHandlers.size === 0) {
1424
1712
  throw new Error("No handlers registered. Call handleRpc() or handleEvent() before serve().");
1425
1713
  }
1426
- if (!service.trim()) {
1427
- throw new Error("serve() requires a non-empty service name");
1428
- }
1429
1714
  if (opts.maxInFlight != null && (!Number.isInteger(opts.maxInFlight) || opts.maxInFlight < 1)) {
1430
1715
  throw new Error("serve() maxInFlight must be a positive integer");
1431
1716
  }
@@ -1494,7 +1779,9 @@ function servicebridge(url, serviceKey, service = "", globalOpts = {}) {
1494
1779
  closeWorkerSession();
1495
1780
  serveState = null;
1496
1781
  shutdownWorkerServerGracefully();
1497
- throw normalizeServiceError(err, "serve");
1782
+ const fatal = normalizeServiceError(err, "serve");
1783
+ console.error(`[servicebridge] startup failed: ${fatal.message}`);
1784
+ throw fatal;
1498
1785
  }
1499
1786
  },
1500
1787
  stop() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "service-bridge",
3
- "version": "1.0.17",
3
+ "version": "1.1.0-dev.27",
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": [