service-bridge 1.1.0-dev.27 → 1.1.1-dev.30

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 +192 -0
  2. package/dist/index.js +546 -137
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -953,6 +953,198 @@ try {
953
953
 
954
954
  ---
955
955
 
956
+ ## v2 Session API
957
+
958
+ `session_v2.ts` реализует новый Enterprise Session Protocol — Channel-based bidi stream с 8-состоянийным FSM, адаптивным heartbeat и кредитным управлением потоком. Симметричен с Go и Python SDK.
959
+
960
+ ### Жизненный цикл сессии (8 состояний FSM)
961
+
962
+ ```
963
+ connecting → handshaking → ready ↔ active
964
+ ↘ suspended → (reconnect)
965
+ ↘ draining → closed
966
+ ↘ fenced (permanent)
967
+ ```
968
+
969
+ | Состояние | Описание |
970
+ |-----------|----------|
971
+ | `connecting` | Устанавливается TCP/TLS соединение |
972
+ | `handshaking` | Отправлен Hello, ждём HelloAck |
973
+ | `ready` | HelloAck получен, команды не выполняются |
974
+ | `active` | Есть активные команды |
975
+ | `suspended` | Heartbeat пропущен 2+ раза |
976
+ | `draining` | Инициирован graceful shutdown |
977
+ | `fenced` | Сервер прислал GOAWAY_FENCED — сессия закрыта навсегда |
978
+ | `closed` | Соединение закрыто |
979
+
980
+ ### Быстрый старт
981
+
982
+ ```typescript
983
+ import { V2SessionClient, validateV2Config } from 'service-bridge';
984
+
985
+ const cfg = {
986
+ serverAddress: 'localhost:9090',
987
+ serviceName: 'my-worker',
988
+ instanceId: 'worker-1',
989
+ zone: 'us-east-1a',
990
+ transportMode: 'direct' as const,
991
+ maxInflight: 64,
992
+ };
993
+
994
+ validateV2Config(cfg);
995
+ const session = new V2SessionClient(cfg);
996
+
997
+ // Отправить Hello при подключении
998
+ const hello = session.getHelloFields();
999
+
1000
+ // Обработать HelloAck от сервера
1001
+ session.onHelloAck({
1002
+ sessionId: 'sess-abc',
1003
+ resumeToken: 'token-xyz',
1004
+ epoch: 1n,
1005
+ resumed: false,
1006
+ resumeFromSeq: 0n,
1007
+ replayedCommands: 0,
1008
+ reconciledResults: 0,
1009
+ heartbeatIntervalMs: 10_000,
1010
+ heartbeatTimeoutMs: 30_000,
1011
+ initialPermits: 64,
1012
+ maxPermits: 128,
1013
+ effectiveTransportMode: 'direct',
1014
+ });
1015
+
1016
+ console.log(session.state); // 'ready'
1017
+
1018
+ // Входящая команда
1019
+ const accepted = session.onCommandReceived(1n, 'cmd-001');
1020
+ if (!accepted) {
1021
+ // backpressure — permits = 0
1022
+ }
1023
+
1024
+ // Команда выполнена
1025
+ session.onCommandCompleted(1n, 'cmd-001');
1026
+ ```
1027
+
1028
+ ### Адаптивный heartbeat (EWMA RTT)
1029
+
1030
+ ```typescript
1031
+ import { AdaptiveHeartbeatV2 } from 'service-bridge';
1032
+
1033
+ const hb = new AdaptiveHeartbeatV2(10_000, 30_000);
1034
+
1035
+ // Получен pong
1036
+ hb.onPong(25); // rttMs
1037
+
1038
+ // Следующий интервал (адаптируется по EWMA RTT)
1039
+ const nextMs = hb.nextIntervalMs();
1040
+
1041
+ // Пропуск — ускоряем пинги
1042
+ const missCount = hb.onMiss();
1043
+ if (missCount >= 2) {
1044
+ // reconnect
1045
+ }
1046
+ ```
1047
+
1048
+ Алгоритм: базовый интервал `intervalMs / 3`; при пропусках делится на `2^miss` (min 2s); при стабильном RTT < 50ms удваивается (max 30s).
1049
+
1050
+ ### Кредитное управление потоком
1051
+
1052
+ ```typescript
1053
+ import { FlowControlStateV2 } from 'service-bridge';
1054
+
1055
+ const fc = new FlowControlStateV2(64, 1, 128);
1056
+
1057
+ if (fc.tryConsume()) {
1058
+ // dispatch command
1059
+ }
1060
+
1061
+ // Команда завершена — вернуть permit
1062
+ fc.release(1);
1063
+
1064
+ // Сервер прислал FlowControlUpdate
1065
+ fc.setWindow(32);
1066
+ ```
1067
+
1068
+ ### Reconnect и resume
1069
+
1070
+ `BackoffV2` реализует экспоненциальный backoff с full jitter (base=100ms, max=30s). При переподключении `getHelloFields()` автоматически включает `resumeToken`, `epoch`, `lastReceivedSeq`, `lastSentSeq`, `completedCommandIds` — сервер продолжит сессию с нужной позиции.
1071
+
1072
+ ```typescript
1073
+ import { BackoffV2 } from 'service-bridge';
1074
+
1075
+ const backoff = new BackoffV2();
1076
+
1077
+ while (true) {
1078
+ if (backoff.isCircuitOpen()) break; // 10+ сбоев подряд
1079
+
1080
+ const delayMs = backoff.next();
1081
+ await new Promise(r => setTimeout(r, delayMs));
1082
+
1083
+ try {
1084
+ // reconnect...
1085
+ backoff.reset();
1086
+ } catch {
1087
+ backoff.recordFail();
1088
+ }
1089
+ }
1090
+ ```
1091
+
1092
+ ### ConfigPush — динамическая конфигурация транспорта
1093
+
1094
+ Сервер может в любой момент прислать `ConfigPush` с новыми правилами маршрутизации:
1095
+
1096
+ ```typescript
1097
+ session.onConfigPush({
1098
+ defaultMode: 'direct',
1099
+ serviceOverrides: {
1100
+ 'payment-svc': { mode: 'proxy', fallbackPolicy: 'fallback_to_direct' },
1101
+ },
1102
+ functionOverrides: {
1103
+ 'payment-svc/charge': { mode: 'proxy', timeoutMs: 5000 },
1104
+ },
1105
+ });
1106
+
1107
+ // Разрешить транспорт для функции
1108
+ const mode = session.resolveTransportMode('payment-svc/charge'); // 'proxy'
1109
+ ```
1110
+
1111
+ ### Все события сессии
1112
+
1113
+ | Метод | Описание |
1114
+ |-------|----------|
1115
+ | `getHelloFields()` | Поля для отправки Hello (первый + resume) |
1116
+ | `onHelloAck(ack)` | Обработка HelloAck от сервера |
1117
+ | `onCommandReceived(seq, id)` | Входящая команда; возвращает `false` при backpressure |
1118
+ | `onCommandCompleted(seq, id)` | Команда выполнена; освобождает permit |
1119
+ | `onPermitGrant(n)` | Сервер добавил `n` permits |
1120
+ | `onFlowControlUpdate(size, reason)` | Сервер изменил размер окна |
1121
+ | `onPong(rttMs)` | Получен pong; обновляет EWMA |
1122
+ | `onHeartbeatMiss()` | Таймаут pong; возвращает `true` → `suspended` |
1123
+ | `onDrain(reason, deadlineMs)` | Инициировать graceful drain |
1124
+ | `onGoaway(code, reason)` | GoawaySignal от сервера |
1125
+ | `onConfigPush(config)` | Применить новую конфигурацию транспорта |
1126
+ | `resolveTransportMode(fnName)` | Получить режим транспорта для функции |
1127
+ | `stop()` | Немедленно закрыть сессию |
1128
+
1129
+ ### Экспортируемые классы и типы
1130
+
1131
+ | Символ | Тип | Описание |
1132
+ |--------|-----|----------|
1133
+ | `V2SessionClient` | class | Главный клиент сессии |
1134
+ | `AdaptiveHeartbeatV2` | class | EWMA RTT heartbeat controller |
1135
+ | `FlowControlStateV2` | class | Кредитное управление потоком |
1136
+ | `BackoffV2` | class | Exponential backoff + circuit |
1137
+ | `PositionTrackerV2` | class | Трекер seq/completed IDs |
1138
+ | `ConfigPushStateV2` | class | Менеджер динамической конфигурации |
1139
+ | `validateV2Config` | function | Валидация конфига; бросает `Error` |
1140
+ | `V2Config` | interface | Конфигурация сессии |
1141
+ | `SessionStateV2` | type | Союз 8 состояний FSM |
1142
+ | `TransportMode` | type | `'direct' \| 'proxy'` |
1143
+ | `HelloAckV2` | interface | Данные HelloAck от сервера |
1144
+ | `TransportConfigV2` | interface | ConfigPush payload |
1145
+
1146
+ ---
1147
+
956
1148
  ## FAQ
957
1149
 
958
1150
  **How does ServiceBridge handle service failures?**
package/dist/index.js CHANGED
@@ -12,6 +12,312 @@ import * as protobuf from "protobufjs";
12
12
  // sdk/src/generated/servicebridge-package-definition.ts
13
13
  var DESCRIPTOR_SET_BASE64 = "Cuk9ChNzZXJ2aWNlYnJpZGdlLnByb3RvEg1zZXJ2aWNlYnJpZGdlIk8KDUhhbmRsZVJlcXVlc3QSCgoCZm4YASABKAkSDwoHcGF5bG9hZBgCIAEoDBIQCgh0cmFjZV9pZBgDIAEoCRIPCgdzcGFuX2lkGAQgASgJIkAKDkhhbmRsZVJlc3BvbnNlEg4KBm91dHB1dBgBIAEoDBIPCgdzdWNjZXNzGAIgASgIEg0KBWVycm9yGAMgASgJIt8BChVEZWxpdmVyTWVzc2FnZVJlcXVlc3QSEgoKbWVzc2FnZV9pZBgBIAEoCRISCgpncm91cF9uYW1lGAIgASgJEg0KBXRvcGljGAMgASgJEg8KB3BheWxvYWQYBCABKAwSGAoHaGVhZGVycxgFIAMoCzIHSGVhZGVycxIQCgh0cmFjZV9pZBgGIAEoCRIWCg5wYXJlbnRfc3Bhbl9pZBgHIAEoCRIPCgdhdHRlbXB0GAggASgFGikKB0hlYWRlcnMSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASLSAQoOUHVibGlzaFJlcXVlc3QSDQoFdG9waWMYASABKAkSDwoHcGF5bG9hZBgCIAEoDBIYCgdoZWFkZXJzGAMgAygLMgdIZWFkZXJzEhAKCHRyYWNlX2lkGAQgASgJEhYKDnBhcmVudF9zcGFuX2lkGAUgASgJEhgKEHByb2R1Y2VyX3NlcnZpY2UYBiABKAkSFwoPaWRlbXBvdGVuY3lfa2V5GAcgASgJGikKB0hlYWRlcnMSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASIlCg9QdWJsaXNoUmVzcG9uc2USEgoKbWVzc2FnZV9pZBgBIAEoCSJYCgtEZWxpdmVyeUFjaxILCgNhY2sYASABKAgSFgoOcmV0cnlfYWZ0ZXJfbXMYAiABKAUSFQoNcmVqZWN0X3JlYXNvbhgDIAEoCRINCgVlcnJvchgEIAEoCSIuChRXYXRjaFJlZ2lzdHJ5UmVxdWVzdBIWCg5mdW5jdGlvbl9uYW1lcxgBIAMoCSIoChVMb29rdXBGdW5jdGlvblJlcXVlc3QSDwoHZm5fbmFtZRgBIAEoCSJgChZMb29rdXBGdW5jdGlvblJlc3BvbnNlEg0KBWZvdW5kGAEgASgIEhYKDmNhbm9uaWNhbF9uYW1lGAIgASgJEh8KCWVuZHBvaW50cxgDIAEoCzIMRW5kcG9pbnRMaXN0IoQBCg9SZWdpc3RyeU1lc3NhZ2USIQoEa2luZBgBIAEoDjITUmVnaXN0cnlNZXNzYWdlS2luZBIQCghyZXZpc2lvbhgCIAEoAxIeCgRmdWxsGAMgASgLMhBSZWdpc3RyeVNuYXBzaG90EhwKBWRlbHRhGAQgASgLMg1SZWdpc3RyeURlbHRhInQKDVJlZ2lzdHJ5RGVsdGESGAoHdXBzZXJ0cxgBIAMoCzIHVXBzZXJ0cxIQCghyZW1vdmFscxgCIAMoCRo3CgdVcHNlcnRzEgsKA2tleRgBIAEoCRIbCgV2YWx1ZRgCIAEoCzIMRW5kcG9pbnRMaXN0OgI4ASJaCg5Xb3JrZXJFbmRwb2ludBIQCghlbmRwb2ludBgBIAEoCRIRCgl0cmFuc3BvcnQYAiABKAkSEwoLaW5zdGFuY2VfaWQYAyABKAkSDgoGd2VpZ2h0GAQgASgFIlkKEFJlZ2lzdHJ5U25hcHNob3QSEAoDcnBjGAEgAygLMgNScGMaMwoDUnBjEgsKA2tleRgBIAEoCRIbCgV2YWx1ZRgCIAEoCzIMRW5kcG9pbnRMaXN0OgI4ASKHAQoMRW5kcG9pbnRMaXN0EiEKCWVuZHBvaW50cxgBIAMoCzIOV29ya2VyRW5kcG9pbnQSGQoRaW5wdXRfc2NoZW1hX2pzb24YAiABKAkSGgoSb3V0cHV0X3NjaGVtYV9qc29uGAMgASgJEhcKD2FsbG93ZWRfY2FsbGVycxgEIAMoCUoECAUQByK+AQoWUmVwb3J0Q2FsbFN0YXJ0UmVxdWVzdBIQCgh0cmFjZV9pZBgBIAEoCRIPCgdzcGFuX2lkGAIgASgJEgoKAmZuGAMgASgJEhQKDHNlcnZpY2VfbmFtZRgEIAEoCRISCgpzdGFydGVkX2F0GAUgASgDEg0KBWlucHV0GAYgASgMEg8KB2F0dGVtcHQYByABKAUSFgoOcGFyZW50X3NwYW5faWQYCCABKAkSEwoLaW5zdGFuY2VfaWQYCSABKAki5gEKEVJlcG9ydENhbGxSZXF1ZXN0EhAKCHRyYWNlX2lkGAEgASgJEg8KB3NwYW5faWQYAiABKAkSCgoCZm4YAyABKAkSFAoMc2VydmljZV9uYW1lGAQgASgJEhIKCnN0YXJ0ZWRfYXQYBSABKAMSEwoLZHVyYXRpb25fbXMYBiABKAMSDwoHc3VjY2VzcxgHIAEoCBINCgVlcnJvchgIIAEoCRINCgVpbnB1dBgJIAEoDBIOCgZvdXRwdXQYCiABKAwSDwoHYXR0ZW1wdBgLIAEoBRITCgtpbnN0YW5jZV9pZBgMIAEoCSIUChJSZXBvcnRDYWxsUmVzcG9uc2Ui3AEKCExvZ0VudHJ5EhAKCHRyYWNlX2lkGAEgASgJEg8KB3NwYW5faWQYAiABKAkSFAoMc2VydmljZV9uYW1lGAMgASgJEg0KBWxldmVsGAQgASgJEg8KB21lc3NhZ2UYBSABKAkSFAoMdGltZXN0YW1wX25zGAYgASgDEh4KCmF0dHJpYnV0ZXMYByADKAsyCkF0dHJpYnV0ZXMSEwoLaW5zdGFuY2VfaWQYCCABKAkaLAoKQXR0cmlidXRlcxILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIi0KEFJlcG9ydExvZ1JlcXVlc3QSGQoHZW50cmllcxgBIAMoCzIITG9nRW50cnkiEwoRUmVwb3J0TG9nUmVzcG9uc2UiwQEKF1JlZ2lzdGVyRnVuY3Rpb25SZXF1ZXN0Eg8KB2ZuX25hbWUYASABKAkSFAoMc2VydmljZV9uYW1lGAIgASgJEhMKC2luc3RhbmNlX2lkGAMgASgJEhAKCGVuZHBvaW50GAQgASgJEg4KBndlaWdodBgFIAEoBRIZChFpbnB1dF9zY2hlbWFfanNvbhgGIAEoCRIaChJvdXRwdXRfc2NoZW1hX2pzb24YByABKAkSEQoJdHJhbnNwb3J0GAggASgJIhoKGFJlZ2lzdGVyRnVuY3Rpb25SZXNwb25zZSKRAQocUmVnaXN0ZXJDb25zdW1lckdyb3VwUmVxdWVzdBIMCgRuYW1lGAEgASgJEg8KB3BhdHRlcm4YAiABKAkSDAoEbW9kZRgDIAEoCRIZChFyZXRyeV9wb2xpY3lfanNvbhgEIAEoCRIOCgZhY3RpdmUYBiABKAgSEwoLZmlsdGVyX2V4cHIYByABKAlKBAgFEAUiHwodUmVnaXN0ZXJDb25zdW1lckdyb3VwUmVzcG9uc2UikAEKGlJlZ2lzdGVyR3JvdXBNZW1iZXJSZXF1ZXN0EhIKCmdyb3VwX25hbWUYASABKAkSFAoMc2VydmljZV9uYW1lGAIgASgJEhMKC2luc3RhbmNlX2lkGAMgASgJEhAKCGVuZHBvaW50GAQgASgJEg4KBndlaWdodBgFIAEoBRIRCgl0cmFuc3BvcnQYBiABKAkiHQobUmVnaXN0ZXJHcm91cE1lbWJlclJlc3BvbnNlItUBChBIZWFydGJlYXRSZXF1ZXN0EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRITCgtpbnN0YW5jZV9pZBgCIAEoCRIQCghlbmRwb2ludBgDIAEoCRITCgtncm91cF9uYW1lcxgEIAMoCRIWCg5mdW5jdGlvbl9uYW1lcxgFIAMoCRIRCgl0cmFuc3BvcnQYBiABKAkSFwoLY3B1X3BlcmNlbnQYByABKAJCAEgAEhIKBnJhbV9tYhgIIAEoBUIASAFCDQoLX2NwdVBlcmNlbnRCCAoGX3JhbU1iIhMKEUhlYXJ0YmVhdFJlc3BvbnNlIugBChtSZWdpc3Rlckh0dHBFbmRwb2ludFJlcXVlc3QSFAoMc2VydmljZV9uYW1lGAEgASgJEg4KBm1ldGhvZBgCIAEoCRIVCg1yb3V0ZV9wYXR0ZXJuGAMgASgJEhsKE3JlcXVlc3Rfc2NoZW1hX2pzb24YBCABKAkSHAoUcmVzcG9uc2Vfc2NoZW1hX2pzb24YBSABKAkSEwoLaW5zdGFuY2VfaWQYBiABKAkSEAoIZW5kcG9pbnQYByABKAkSEQoJdHJhbnNwb3J0GAggASgJEhcKD2FsbG93ZWRfY2FsbGVycxgJIAMoCSJCChxSZWdpc3Rlckh0dHBFbmRwb2ludFJlc3BvbnNlEgoKAm9rGAEgASgIEhYKDmNhbm9uaWNhbF9uYW1lGAIgASgJIkkKHEhlYXJ0YmVhdEh0dHBFbmRwb2ludFJlcXVlc3QSFAoMc2VydmljZV9uYW1lGAEgASgJEhMKC2luc3RhbmNlX2lkGAIgASgJIisKHUhlYXJ0YmVhdEh0dHBFbmRwb2ludFJlc3BvbnNlEgoKAm9rGAEgASgIImQKIVByb3Zpc2lvbldvcmtlckNlcnRpZmljYXRlUmVxdWVzdBIUCgxzZXJ2aWNlX25hbWUYASABKAkSFgoOcHVibGljX2tleV9wZW0YAiABKAkSEQoJZXh0cmFfaXBzGAMgAygJIkYKIlByb3Zpc2lvbldvcmtlckNlcnRpZmljYXRlUmVzcG9uc2USEAoIY2VydF9wZW0YASABKAkSDgoGY2FfcGVtGAIgASgJIlUKEldvcmtlclNlc3Npb25IZWxsbxIUCgxzZXJ2aWNlX25hbWUYASABKAkSEwoLaW5zdGFuY2VfaWQYAiABKAkSFAoMbWF4X2luZmxpZ2h0GAMgASgFImIKEFdvcmtlclJQQ0NvbW1hbmQSCgoCZm4YASABKAkSDwoHcGF5bG9hZBgCIAEoDBIQCgh0cmFjZV9pZBgDIAEoCRIPCgdzcGFuX2lkGAQgASgJEg4KBnJ1bl9pZBgFIAEoCSLsAQoSV29ya2VyRXZlbnRDb21tYW5kEhIKCm1lc3NhZ2VfaWQYASABKAkSEgoKZ3JvdXBfbmFtZRgCIAEoCRINCgV0b3BpYxgDIAEoCRIPCgdwYXlsb2FkGAQgASgMEhgKB2hlYWRlcnMYBSADKAsyB0hlYWRlcnMSEAoIdHJhY2VfaWQYBiABKAkSFgoOcGFyZW50X3NwYW5faWQYByABKAkSDwoHYXR0ZW1wdBgIIAEoBRIOCgZydW5faWQYCSABKAkaKQoHSGVhZGVycxILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIqcBChRXb3JrZXJTZXNzaW9uQ29tbWFuZBISCgpjb21tYW5kX2lkGAEgASgJEh8KBGtpbmQYAiABKA4yEVdvcmtlckNvbW1hbmRLaW5kEh0KA3JwYxgDIAEoCzIQV29ya2VyUlBDQ29tbWFuZBIhCgVldmVudBgEIAEoCzISV29ya2VyRXZlbnRDb21tYW5kEhgKEGRlYWRsaW5lX3VuaXhfbXMYBSABKAMinAEKGldvcmtlclNlc3Npb25Db21tYW5kUmVzdWx0EhIKCmNvbW1hbmRfaWQYASABKAkSDwoHc3VjY2VzcxgCIAEoCBIOCgZvdXRwdXQYAyABKAwSDQoFZXJyb3IYBCABKAkSCwoDYWNrGAUgASgIEhYKDnJldHJ5X2FmdGVyX21zGAYgASgFEhUKDXJlamVjdF9yZWFzb24YByABKAkiLgoRV29ya2VyU2Vzc2lvblBpbmcSGQoRdGltZXN0YW1wX3VuaXhfbXMYASABKAMiLgoRV29ya2VyU2Vzc2lvblBvbmcSGQoRdGltZXN0YW1wX3VuaXhfbXMYASABKAMinwEKFFdvcmtlclNlc3Npb25SZXF1ZXN0EiMKBWhlbGxvGAEgASgLMhJXb3JrZXJTZXNzaW9uSGVsbG9IABI0Cg5jb21tYW5kX3Jlc3VsdBgCIAEoCzIaV29ya2VyU2Vzc2lvbkNvbW1hbmRSZXN1bHRIABIhCgRwaW5nGAMgASgLMhFXb3JrZXJTZXNzaW9uUGluZ0gAQgkKB3BheWxvYWQibgoVV29ya2VyU2Vzc2lvblJlc3BvbnNlEicKB2NvbW1hbmQYASABKAsyFFdvcmtlclNlc3Npb25Db21tYW5kSAASIQoEcG9uZxgCIAEoCzIRV29ya2VyU2Vzc2lvblBvbmdIAEIJCgdwYXlsb2FkIl8KF1JlZ2lzdGVyV29ya2Zsb3dSZXF1ZXN0EgwKBG5hbWUYASABKAkSEgoKZGVmaW5pdGlvbhgCIAEoCRIMCgRvcHRzGAMgASgJEhQKDHNlcnZpY2VfbmFtZRgEIAEoCSImChhSZWdpc3RlcldvcmtmbG93UmVzcG9uc2USCgoCaWQYASABKAkiRwoSUnVuV29ya2Zsb3dSZXF1ZXN0EgwKBG5hbWUYASABKAkSDQoFaW5wdXQYAiABKAwSFAoMc2VydmljZV9uYW1lGAMgASgJIjcKE1J1bldvcmtmbG93UmVzcG9uc2USDgoGcnVuX2lkGAEgASgJEhAKCHRyYWNlX2lkGAIgASgJIioKGENhbmNlbFdvcmtmbG93UnVuUmVxdWVzdBIOCgZydW5faWQYASABKAkiPgoZQ2FuY2VsV29ya2Zsb3dSdW5SZXNwb25zZRIRCgljYW5jZWxsZWQYASABKAgSDgoGcnVuX2lkGAIgASgJIr0BChJSZWdpc3RlckpvYlJlcXVlc3QSEQoJY3Jvbl9leHByGAEgASgJEhAKCHRpbWV6b25lGAIgASgJEhYKDm1pc2ZpcmVfcG9saWN5GAMgASgJEhMKC3RhcmdldF90eXBlGAQgASgJEhIKCnRhcmdldF9yZWYYBSABKAkSEAoIZGVsYXlfbXMYBiABKAUSFAoMc2VydmljZV9uYW1lGAcgASgJEhkKEXJldHJ5X3BvbGljeV9qc29uGAggASgJIiEKE1JlZ2lzdGVySm9iUmVzcG9uc2USCgoCaWQYASABKAkiQAoTQXBwZW5kU3RyZWFtUmVxdWVzdBIOCgZydW5faWQYASABKAkSCwoDa2V5GAIgASgJEgwKBGRhdGEYAyABKAwiFgoUQXBwZW5kU3RyZWFtUmVzcG9uc2UiRQoPV2F0Y2hSdW5SZXF1ZXN0Eg4KBnJ1bl9pZBgBIAEoCRILCgNrZXkYAiABKAkSFQoNZnJvbV9zZXF1ZW5jZRgDIAEoAyJlCg5SdW5TdHJlYW1DaHVuaxIMCgR0eXBlGAEgASgJEgsKA2tleRgDIAEoCRIMCgRkYXRhGAQgASgMEhAKCHNlcXVlbmNlGAUgASgDEhIKCnJ1bl9zdGF0dXMYBiABKAlKBAgCEAIqKgoTUmVnaXN0cnlNZXNzYWdlS2luZBIICgRGVUxMEAASCQoFREVMVEEQASp0ChFXb3JrZXJDb21tYW5kS2luZBIjCh9XT1JLRVJfQ09NTUFORF9LSU5EX1VOU1BFQ0lGSUVEEAASGwoXV09SS0VSX0NPTU1BTkRfS0lORF9SUEMQARIdChlXT1JLRVJfQ09NTUFORF9LSU5EX0VWRU5UEAIykw8KDVNlcnZpY2VCcmlkZ2USYwoQUmVnaXN0ZXJGdW5jdGlvbhImLnNlcnZpY2VicmlkZ2UuUmVnaXN0ZXJGdW5jdGlvblJlcXVlc3QaJy5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVyRnVuY3Rpb25SZXNwb25zZRJyChVSZWdpc3RlckNvbnN1bWVyR3JvdXASKy5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVyQ29uc3VtZXJHcm91cFJlcXVlc3QaLC5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVyQ29uc3VtZXJHcm91cFJlc3BvbnNlEmwKE1JlZ2lzdGVyR3JvdXBNZW1iZXISKS5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVyR3JvdXBNZW1iZXJSZXF1ZXN0Giouc2VydmljZWJyaWRnZS5SZWdpc3Rlckdyb3VwTWVtYmVyUmVzcG9uc2USTgoJSGVhcnRiZWF0Eh8uc2VydmljZWJyaWRnZS5IZWFydGJlYXRSZXF1ZXN0GiAuc2VydmljZWJyaWRnZS5IZWFydGJlYXRSZXNwb25zZRJvChRSZWdpc3Rlckh0dHBFbmRwb2ludBIqLnNlcnZpY2VicmlkZ2UuUmVnaXN0ZXJIdHRwRW5kcG9pbnRSZXF1ZXN0Gisuc2VydmljZWJyaWRnZS5SZWdpc3Rlckh0dHBFbmRwb2ludFJlc3BvbnNlEnIKFUhlYXJ0YmVhdEh0dHBFbmRwb2ludBIrLnNlcnZpY2VicmlkZ2UuSGVhcnRiZWF0SHR0cEVuZHBvaW50UmVxdWVzdBosLnNlcnZpY2VicmlkZ2UuSGVhcnRiZWF0SHR0cEVuZHBvaW50UmVzcG9uc2USgQEKGlByb3Zpc2lvbldvcmtlckNlcnRpZmljYXRlEjAuc2VydmljZWJyaWRnZS5Qcm92aXNpb25Xb3JrZXJDZXJ0aWZpY2F0ZVJlcXVlc3QaMS5zZXJ2aWNlYnJpZGdlLlByb3Zpc2lvbldvcmtlckNlcnRpZmljYXRlUmVzcG9uc2USYgoRT3BlbldvcmtlclNlc3Npb24SIy5zZXJ2aWNlYnJpZGdlLldvcmtlclNlc3Npb25SZXF1ZXN0GiQuc2VydmljZWJyaWRnZS5Xb3JrZXJTZXNzaW9uUmVzcG9uc2UoATABEmMKEFJlZ2lzdGVyV29ya2Zsb3cSJi5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVyV29ya2Zsb3dSZXF1ZXN0Gicuc2VydmljZWJyaWRnZS5SZWdpc3RlcldvcmtmbG93UmVzcG9uc2USVAoLUnVuV29ya2Zsb3cSIS5zZXJ2aWNlYnJpZGdlLlJ1bldvcmtmbG93UmVxdWVzdBoiLnNlcnZpY2VicmlkZ2UuUnVuV29ya2Zsb3dSZXNwb25zZRJmChFDYW5jZWxXb3JrZmxvd1J1bhInLnNlcnZpY2VicmlkZ2UuQ2FuY2VsV29ya2Zsb3dSdW5SZXF1ZXN0Giguc2VydmljZWJyaWRnZS5DYW5jZWxXb3JrZmxvd1J1blJlc3BvbnNlElQKC1JlZ2lzdGVySm9iEiEuc2VydmljZWJyaWRnZS5SZWdpc3RlckpvYlJlcXVlc3QaIi5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVySm9iUmVzcG9uc2USVgoNV2F0Y2hSZWdpc3RyeRIjLnNlcnZpY2VicmlkZ2UuV2F0Y2hSZWdpc3RyeVJlcXVlc3QaHi5zZXJ2aWNlYnJpZGdlLlJlZ2lzdHJ5TWVzc2FnZTABEl0KDkxvb2t1cEZ1bmN0aW9uEiQuc2VydmljZWJyaWRnZS5Mb29rdXBGdW5jdGlvblJlcXVlc3QaJS5zZXJ2aWNlYnJpZGdlLkxvb2t1cEZ1bmN0aW9uUmVzcG9uc2USWwoPUmVwb3J0Q2FsbFN0YXJ0EiUuc2VydmljZWJyaWRnZS5SZXBvcnRDYWxsU3RhcnRSZXF1ZXN0GiEuc2VydmljZWJyaWRnZS5SZXBvcnRDYWxsUmVzcG9uc2USUQoKUmVwb3J0Q2FsbBIgLnNlcnZpY2VicmlkZ2UuUmVwb3J0Q2FsbFJlcXVlc3QaIS5zZXJ2aWNlYnJpZGdlLlJlcG9ydENhbGxSZXNwb25zZRJOCglSZXBvcnRMb2cSHy5zZXJ2aWNlYnJpZGdlLlJlcG9ydExvZ1JlcXVlc3QaIC5zZXJ2aWNlYnJpZGdlLlJlcG9ydExvZ1Jlc3BvbnNlEkgKB1B1Ymxpc2gSHS5zZXJ2aWNlYnJpZGdlLlB1Ymxpc2hSZXF1ZXN0Gh4uc2VydmljZWJyaWRnZS5QdWJsaXNoUmVzcG9uc2USVwoMQXBwZW5kU3RyZWFtEiIuc2VydmljZWJyaWRnZS5BcHBlbmRTdHJlYW1SZXF1ZXN0GiMuc2VydmljZWJyaWRnZS5BcHBlbmRTdHJlYW1SZXNwb25zZRJLCghXYXRjaFJ1bhIeLnNlcnZpY2VicmlkZ2UuV2F0Y2hSdW5SZXF1ZXN0Gh0uc2VydmljZWJyaWRnZS5SdW5TdHJlYW1DaHVuazABMlwKE1NlcnZpY2VCcmlkZ2VXb3JrZXISRQoGSGFuZGxlEhwuc2VydmljZWJyaWRnZS5IYW5kbGVSZXF1ZXN0Gh0uc2VydmljZWJyaWRnZS5IYW5kbGVSZXNwb25zZUItWitnaXRodWIuY29tL3NlcnZpY2UtYnJpZGdlL2dvL2ludGVybmFsL3BiO3BiYgZwcm90bzM=";
14
14
 
15
+ // sdk/src/session_v2.ts
16
+ class AdaptiveHeartbeatV2 {
17
+ intervalMs;
18
+ timeoutMs;
19
+ missCount = 0;
20
+ rttEwma = 0;
21
+ alpha = 0.3;
22
+ constructor(intervalMs, timeoutMs) {
23
+ this.intervalMs = intervalMs;
24
+ this.timeoutMs = timeoutMs;
25
+ }
26
+ nextIntervalMs() {
27
+ let base = this.intervalMs / 3;
28
+ if (this.missCount > 0) {
29
+ const shift = Math.min(this.missCount, 4);
30
+ base = base / 2 ** shift;
31
+ } else if (this.rttEwma > 0 && this.rttEwma < 50) {
32
+ base = Math.min(base * 2, 30000);
33
+ }
34
+ return Math.max(2000, Math.min(30000, base));
35
+ }
36
+ onPong(rttMs) {
37
+ this.rttEwma = this.alpha * rttMs + (1 - this.alpha) * this.rttEwma;
38
+ this.missCount = 0;
39
+ }
40
+ onMiss() {
41
+ return ++this.missCount;
42
+ }
43
+ resetMiss() {
44
+ this.missCount = 0;
45
+ }
46
+ getTimeoutMs() {
47
+ return this.timeoutMs;
48
+ }
49
+ }
50
+
51
+ class FlowControlStateV2 {
52
+ permits;
53
+ window;
54
+ minW;
55
+ maxW;
56
+ constructor(initial, min, max) {
57
+ this.permits = initial;
58
+ this.window = initial;
59
+ this.minW = min;
60
+ this.maxW = max;
61
+ }
62
+ tryConsume() {
63
+ if (this.permits <= 0)
64
+ return false;
65
+ this.permits--;
66
+ return true;
67
+ }
68
+ release(n) {
69
+ this.permits += n;
70
+ }
71
+ available() {
72
+ return this.permits;
73
+ }
74
+ setWindow(n) {
75
+ this.window = Math.max(this.minW, Math.min(this.maxW, n));
76
+ this.permits = this.window;
77
+ }
78
+ getWindow() {
79
+ return this.window;
80
+ }
81
+ }
82
+
83
+ class BackoffV2 {
84
+ attempt = 0;
85
+ baseMs = 100;
86
+ maxMs = 30000;
87
+ circuitFails = 0;
88
+ next() {
89
+ this.attempt++;
90
+ const calculated = this.baseMs * 2 ** Math.min(this.attempt, 10);
91
+ const capped = Math.min(calculated, this.maxMs);
92
+ const jittered = Math.random() * capped;
93
+ return Math.max(this.baseMs, jittered);
94
+ }
95
+ reset() {
96
+ this.attempt = 0;
97
+ this.circuitFails = 0;
98
+ }
99
+ recordFail() {
100
+ this.circuitFails++;
101
+ }
102
+ isCircuitOpen() {
103
+ return this.circuitFails >= 10;
104
+ }
105
+ }
106
+
107
+ class PositionTrackerV2 {
108
+ lastReceivedSeq = BigInt(0);
109
+ lastSentSeq = BigInt(0);
110
+ completedIds = new Set;
111
+ maxCompleted = 512;
112
+ recordReceived(seq) {
113
+ if (seq > this.lastReceivedSeq)
114
+ this.lastReceivedSeq = seq;
115
+ }
116
+ recordSent(seq) {
117
+ if (seq > this.lastSentSeq)
118
+ this.lastSentSeq = seq;
119
+ }
120
+ markCompleted(commandId) {
121
+ this.completedIds.add(commandId);
122
+ if (this.completedIds.size > this.maxCompleted) {
123
+ const first = this.completedIds.values().next().value;
124
+ if (first !== undefined)
125
+ this.completedIds.delete(first);
126
+ }
127
+ }
128
+ getResumeState(token, epoch) {
129
+ return {
130
+ resumeToken: token,
131
+ epoch,
132
+ lastReceivedSeq: this.lastReceivedSeq,
133
+ lastSentSeq: this.lastSentSeq,
134
+ completedCommandIds: Array.from(this.completedIds)
135
+ };
136
+ }
137
+ }
138
+
139
+ class ConfigPushStateV2 {
140
+ defaultMode = "direct";
141
+ serviceOverrides = {};
142
+ functionOverrides = {};
143
+ apply(config) {
144
+ if (config.defaultMode)
145
+ this.defaultMode = config.defaultMode;
146
+ if (config.serviceOverrides) {
147
+ Object.assign(this.serviceOverrides, config.serviceOverrides);
148
+ }
149
+ if (config.functionOverrides) {
150
+ Object.assign(this.functionOverrides, config.functionOverrides);
151
+ }
152
+ }
153
+ resolveMode(fnName) {
154
+ const fnOverride = this.functionOverrides[fnName];
155
+ if (fnOverride?.mode)
156
+ return fnOverride.mode;
157
+ const svcName = fnName.includes("/") ? fnName.split("/")[0] : fnName;
158
+ const svcOverride = this.serviceOverrides[svcName];
159
+ if (svcOverride?.mode)
160
+ return svcOverride.mode;
161
+ return this.defaultMode;
162
+ }
163
+ }
164
+
165
+ class V2SessionClient {
166
+ config;
167
+ _state = "connecting";
168
+ resumeToken = "";
169
+ epoch = BigInt(0);
170
+ position;
171
+ flowCtrl;
172
+ heartbeat;
173
+ backoff;
174
+ configPush;
175
+ _draining = false;
176
+ _stopped = false;
177
+ constructor(config) {
178
+ this.config = {
179
+ serverAddress: config.serverAddress,
180
+ serviceName: config.serviceName,
181
+ instanceId: config.instanceId,
182
+ zone: config.zone ?? "",
183
+ transportMode: config.transportMode ?? "direct",
184
+ maxInflight: config.maxInflight ?? 128,
185
+ minInflight: config.minInflight ?? 1,
186
+ sdkVersion: config.sdkVersion ?? "node-v2.0.0",
187
+ capabilities: config.capabilities ?? [],
188
+ tlsEnabled: config.tlsEnabled ?? false,
189
+ certPem: config.certPem ?? "",
190
+ keyPem: config.keyPem ?? "",
191
+ caPem: config.caPem ?? ""
192
+ };
193
+ this.position = new PositionTrackerV2;
194
+ this.flowCtrl = new FlowControlStateV2(this.config.maxInflight, this.config.minInflight, this.config.maxInflight);
195
+ this.heartbeat = new AdaptiveHeartbeatV2(1e4, 30000);
196
+ this.backoff = new BackoffV2;
197
+ this.configPush = new ConfigPushStateV2;
198
+ }
199
+ get state() {
200
+ return this._state;
201
+ }
202
+ get isDraining() {
203
+ return this._draining;
204
+ }
205
+ get isStopped() {
206
+ return this._stopped;
207
+ }
208
+ getHelloFields() {
209
+ const rs = this.position.getResumeState(this.resumeToken, this.epoch);
210
+ return {
211
+ identity: {
212
+ serviceName: this.config.serviceName,
213
+ instanceId: this.config.instanceId,
214
+ transport: this.config.transportMode
215
+ },
216
+ maxInflight: this.config.maxInflight,
217
+ minInflight: this.config.minInflight,
218
+ resumeToken: rs.resumeToken,
219
+ epoch: rs.epoch,
220
+ lastReceivedSeq: rs.lastReceivedSeq,
221
+ lastSentSeq: rs.lastSentSeq,
222
+ completedCommandIds: rs.completedCommandIds,
223
+ sdkVersion: this.config.sdkVersion,
224
+ capabilities: this.config.capabilities,
225
+ zone: this.config.zone,
226
+ transportMode: this.config.transportMode
227
+ };
228
+ }
229
+ onHelloAck(ack) {
230
+ this.resumeToken = ack.resumeToken;
231
+ this.epoch = ack.epoch;
232
+ this._state = "ready";
233
+ this.flowCtrl.setWindow(ack.initialPermits);
234
+ this.heartbeat = new AdaptiveHeartbeatV2(ack.heartbeatIntervalMs, ack.heartbeatTimeoutMs);
235
+ this.backoff.reset();
236
+ console.log(`[session-v2] hello_ack session=${ack.sessionId} resumed=${ack.resumed} epoch=${ack.epoch}`);
237
+ }
238
+ onCommandReceived(seq, commandId) {
239
+ this.position.recordReceived(seq);
240
+ if (!this.flowCtrl.tryConsume()) {
241
+ console.warn(`[session-v2] no permits for command ${commandId}, backpressure`);
242
+ return false;
243
+ }
244
+ if (this._state === "ready")
245
+ this._state = "active";
246
+ return true;
247
+ }
248
+ onCommandCompleted(seq, commandId) {
249
+ this.position.markCompleted(commandId);
250
+ this.position.recordSent(seq);
251
+ this.flowCtrl.release(1);
252
+ }
253
+ onPermitGrant(additional) {
254
+ this.flowCtrl.release(additional);
255
+ }
256
+ onFlowControlUpdate(newWindowSize, reason) {
257
+ this.flowCtrl.setWindow(newWindowSize);
258
+ console.log(`[session-v2] flow_control_update window=${newWindowSize} reason=${reason}`);
259
+ }
260
+ onPong(rttMs) {
261
+ this.heartbeat.onPong(rttMs);
262
+ if (this._state === "suspended") {
263
+ this._state = "ready";
264
+ console.log("[session-v2] heartbeat resumed, state→ready");
265
+ }
266
+ }
267
+ onHeartbeatMiss() {
268
+ const count = this.heartbeat.onMiss();
269
+ if (count >= 2 && (this._state === "ready" || this._state === "active")) {
270
+ this._state = "suspended";
271
+ console.warn(`[session-v2] heartbeat miss ${count}, state→suspended`);
272
+ return true;
273
+ }
274
+ return false;
275
+ }
276
+ onDrain(reason, deadlineMs) {
277
+ if (!this._draining) {
278
+ this._draining = true;
279
+ this._state = "draining";
280
+ console.log(`[session-v2] drain initiated reason=${reason} deadline=${deadlineMs}ms`);
281
+ }
282
+ }
283
+ onGoaway(code, reason) {
284
+ console.log(`[session-v2] goaway code=${code} reason=${reason}`);
285
+ switch (code) {
286
+ case "GOAWAY_FENCED":
287
+ this._state = "fenced";
288
+ this.stop();
289
+ break;
290
+ case "GOAWAY_GRACEFUL_SHUTDOWN":
291
+ this._state = "closed";
292
+ break;
293
+ default:
294
+ this._state = "closed";
295
+ }
296
+ }
297
+ onConfigPush(config) {
298
+ this.configPush.apply(config);
299
+ console.log("[session-v2] config push applied");
300
+ }
301
+ resolveTransportMode(fnName) {
302
+ return this.configPush.resolveMode(fnName);
303
+ }
304
+ stop() {
305
+ this._stopped = true;
306
+ this._state = "closed";
307
+ }
308
+ }
309
+ function validateV2Config(cfg) {
310
+ if (!cfg.serverAddress)
311
+ throw new Error("servicebridge: serverAddress is required");
312
+ if (!cfg.serviceName)
313
+ throw new Error("servicebridge: serviceName is required");
314
+ if (!cfg.instanceId)
315
+ throw new Error("servicebridge: instanceId is required");
316
+ if (cfg.transportMode && cfg.transportMode !== "direct" && cfg.transportMode !== "proxy") {
317
+ throw new Error(`servicebridge: transportMode must be 'direct' or 'proxy', got '${cfg.transportMode}'`);
318
+ }
319
+ }
320
+
15
321
  // sdk/src/grpc-client.ts
16
322
  function outboundIP() {
17
323
  return new Promise((resolve) => {
@@ -591,13 +897,16 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
591
897
  const fnAliasMap = new Map;
592
898
  const functionChannels = new Map;
593
899
  let isOnline = false;
900
+ let isFlushing = false;
594
901
  let stopped = false;
595
- let onlineRestoreTimer = null;
902
+ let isWatchingChannel = false;
596
903
  const offlineQueue = [];
597
904
  let workerServer = null;
598
905
  let workerSessionStream = null;
599
906
  let workerSessionReconnectTimer = null;
600
907
  let workerSessionPingTimer = null;
908
+ let workerSessionPositionTimer = null;
909
+ let v2Session = null;
601
910
  let serveState = null;
602
911
  let heartbeatTimer = null;
603
912
  let registrationSyncPromise = null;
@@ -656,7 +965,7 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
656
965
  span_id: op.spanId,
657
966
  parent_span_id: op.parentSpanId,
658
967
  fn: op.fn,
659
- started_at: String(op.startedAt),
968
+ started_at: op.startedAt,
660
969
  input: op.inputBuf,
661
970
  attempt: op.attempt
662
971
  }
@@ -670,7 +979,7 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
670
979
  parent_span_id: op.parentSpanId,
671
980
  fn: op.fn,
672
981
  service_name: service,
673
- started_at: String(op.startedAt),
982
+ started_at: op.startedAt,
674
983
  input: op.inputBuf,
675
984
  attempt: op.attempt,
676
985
  instance_id: serveState?.instanceId ?? ""
@@ -683,8 +992,8 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
683
992
  trace_id: op.traceId,
684
993
  span_id: op.spanId,
685
994
  fn: op.fn,
686
- started_at: String(op.startedAt),
687
- duration_ms: String(op.durationMs),
995
+ started_at: op.startedAt,
996
+ duration_ms: op.durationMs,
688
997
  success: op.success,
689
998
  error: op.error ?? "",
690
999
  input: op.inputBuf,
@@ -700,8 +1009,8 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
700
1009
  span_id: op.spanId,
701
1010
  fn: op.fn,
702
1011
  service_name: service,
703
- started_at: String(op.startedAt),
704
- duration_ms: String(op.durationMs),
1012
+ started_at: op.startedAt,
1013
+ duration_ms: op.durationMs,
705
1014
  success: op.success,
706
1015
  error: op.error ?? "",
707
1016
  input: op.inputBuf,
@@ -900,36 +1209,43 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
900
1209
  }
901
1210
  }
902
1211
  _controlReady.then(() => {
903
- isOnline = true;
904
- flushQueue().catch((err) => {
905
- if (isConnectionError(err))
906
- scheduleOnlineRestore();
907
- else
908
- reportSDKError("flush-on-ready", err);
909
- });
1212
+ watchChannelConnectivity();
910
1213
  }).catch(() => {});
911
- function scheduleOnlineRestore() {
912
- if (stopped || isOnline || onlineRestoreTimer)
1214
+ function watchChannelConnectivity() {
1215
+ isWatchingChannel = false;
1216
+ if (stopped)
913
1217
  return;
914
- onlineRestoreTimer = setTimeout(() => {
915
- onlineRestoreTimer = null;
916
- if (!stopped) {
1218
+ const channel = stub.getChannel();
1219
+ const state = channel.getConnectivityState(true);
1220
+ if (state === grpc.connectivityState.READY) {
1221
+ if (!isOnline) {
917
1222
  isOnline = true;
918
1223
  const queueLen = offlineQueue.length;
919
1224
  console.info(`[servicebridge] reconnected to runtime${queueLen > 0 ? ` — flushing ${queueLen} queued operation(s)` : ""}`);
920
- flushQueue().catch((err) => {
921
- if (isConnectionError(err))
922
- scheduleOnlineRestore();
923
- else
924
- reportSDKError("flush-on-restore", err);
1225
+ flushQueue().then(() => {
1226
+ if (isOnline)
1227
+ scheduleNextHeartbeat(100);
1228
+ }).catch((err) => {
1229
+ reportSDKError("flush-on-restore", err);
925
1230
  });
926
- scheduleNextHeartbeat(100);
927
1231
  }
928
- }, 2000);
1232
+ } else if (state === grpc.connectivityState.TRANSIENT_FAILURE) {
1233
+ if (isOnline) {
1234
+ isOnline = false;
1235
+ console.warn("[servicebridge] lost connection to runtime — entering offline mode");
1236
+ }
1237
+ }
1238
+ isWatchingChannel = true;
1239
+ channel.watchConnectivityState(state, Infinity, watchChannelConnectivity);
1240
+ }
1241
+ function ensureChannelWatch() {
1242
+ if (!isWatchingChannel && !stopped) {
1243
+ watchChannelConnectivity();
1244
+ }
929
1245
  }
930
1246
  function isConnectionError(e) {
931
1247
  const code = e?.code;
932
- return code === grpc.status.UNAVAILABLE || code === grpc.status.UNKNOWN || code === grpc.status.DEADLINE_EXCEEDED || code === grpc.status.RESOURCE_EXHAUSTED || code === grpc.status.INTERNAL;
1248
+ return code === grpc.status.UNAVAILABLE || code === grpc.status.UNKNOWN;
933
1249
  }
934
1250
  function normalizeUnknownErrorMessage(error) {
935
1251
  return error instanceof Error ? error.message : String(error);
@@ -962,7 +1278,7 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
962
1278
  console.warn("[servicebridge] lost connection to runtime — entering offline mode");
963
1279
  }
964
1280
  isOnline = false;
965
- scheduleOnlineRestore();
1281
+ ensureChannelWatch();
966
1282
  enqueueOffline({
967
1283
  type: "reportCallStart",
968
1284
  traceId,
@@ -1013,7 +1329,7 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1013
1329
  console.warn("[servicebridge] lost connection to runtime — entering offline mode");
1014
1330
  }
1015
1331
  isOnline = false;
1016
- scheduleOnlineRestore();
1332
+ ensureChannelWatch();
1017
1333
  enqueueOffline({
1018
1334
  type: "reportCall",
1019
1335
  traceId,
@@ -1033,66 +1349,71 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1033
1349
  });
1034
1350
  }
1035
1351
  async function flushQueue() {
1036
- if (offlineQueue.length === 0)
1352
+ if (isFlushing || offlineQueue.length === 0)
1037
1353
  return;
1354
+ isFlushing = true;
1038
1355
  const snapshot = offlineQueue.slice();
1039
1356
  let flushed = 0;
1040
- while (offlineQueue.length > 0 && isOnline) {
1041
- const op = offlineQueue[0];
1042
- try {
1043
- if (op.type === "event") {
1044
- await new Promise((res, rej) => {
1045
- stub.Publish({
1046
- topic: op.topic,
1047
- payload: toJsonBuffer(op.payload),
1048
- headers: toWireStringMap(op.opts?.headers),
1049
- trace_id: op.opts?.traceId ?? "",
1050
- parent_span_id: op.opts?.parentSpanId ?? "",
1051
- producer_service: service,
1052
- idempotency_key: op.opts?.idempotencyKey ?? ""
1053
- }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
1054
- });
1055
- } else if (op.type === "job") {
1056
- await new Promise((res, rej) => {
1057
- stub.RegisterJob({
1058
- cron_expr: op.opts.cron ?? "",
1059
- timezone: op.opts.timezone ?? "UTC",
1060
- misfire_policy: op.opts.misfire ?? "fire_now",
1061
- target_type: op.opts.via ?? "rpc",
1062
- target_ref: op.target,
1063
- delay_ms: op.opts.delay ?? 0,
1064
- service_name: service,
1065
- retry_policy_json: op.opts.retryPolicyJson ?? "{}"
1066
- }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
1067
- });
1068
- } else if (op.type === "workflow") {
1069
- await new Promise((res, rej) => {
1070
- stub.RegisterWorkflow({
1071
- name: op.name,
1072
- definition: JSON.stringify(op.steps),
1073
- opts: op.opts ? JSON.stringify(op.opts) : "{}",
1074
- service_name: service
1075
- }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
1076
- });
1077
- } else if (op.type === "reportCallStart") {
1078
- await sendReportCallStart({ ...op });
1079
- } else if (op.type === "reportCall") {
1080
- await sendReportCall(op);
1081
- }
1082
- offlineQueue.shift();
1083
- flushed++;
1084
- } catch (err) {
1085
- if (isConnectionError(err)) {
1086
- if (isOnline) {
1087
- console.warn("[servicebridge] lost connection to runtime — entering offline mode");
1357
+ try {
1358
+ while (offlineQueue.length > 0 && isOnline) {
1359
+ const op = offlineQueue[0];
1360
+ try {
1361
+ if (op.type === "event") {
1362
+ await new Promise((res, rej) => {
1363
+ stub.Publish({
1364
+ topic: op.topic,
1365
+ payload: toJsonBuffer(op.payload),
1366
+ headers: toWireStringMap(op.opts?.headers),
1367
+ trace_id: op.opts?.traceId ?? "",
1368
+ parent_span_id: op.opts?.parentSpanId ?? "",
1369
+ producer_service: service,
1370
+ idempotency_key: op.opts?.idempotencyKey ?? ""
1371
+ }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
1372
+ });
1373
+ } else if (op.type === "job") {
1374
+ await new Promise((res, rej) => {
1375
+ stub.RegisterJob({
1376
+ cron_expr: op.opts.cron ?? "",
1377
+ timezone: op.opts.timezone ?? "UTC",
1378
+ misfire_policy: op.opts.misfire ?? "fire_now",
1379
+ target_type: op.opts.via ?? "rpc",
1380
+ target_ref: op.target,
1381
+ delay_ms: op.opts.delay ?? 0,
1382
+ service_name: service,
1383
+ retry_policy_json: op.opts.retryPolicyJson ?? "{}"
1384
+ }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
1385
+ });
1386
+ } else if (op.type === "workflow") {
1387
+ await new Promise((res, rej) => {
1388
+ stub.RegisterWorkflow({
1389
+ name: op.name,
1390
+ definition: JSON.stringify(op.steps),
1391
+ opts: op.opts ? JSON.stringify(op.opts) : "{}",
1392
+ service_name: service
1393
+ }, meta, unaryDeadlineOptions(), (err) => err ? rej(err) : res());
1394
+ });
1395
+ } else if (op.type === "reportCallStart") {
1396
+ await sendReportCallStart({ ...op });
1397
+ } else if (op.type === "reportCall") {
1398
+ await sendReportCall(op);
1399
+ }
1400
+ offlineQueue.shift();
1401
+ flushed++;
1402
+ } catch (err) {
1403
+ if (isConnectionError(err)) {
1404
+ if (isOnline) {
1405
+ console.warn("[servicebridge] lost connection to runtime — entering offline mode");
1406
+ }
1407
+ isOnline = false;
1408
+ ensureChannelWatch();
1409
+ break;
1088
1410
  }
1089
- isOnline = false;
1090
- scheduleOnlineRestore();
1091
- break;
1411
+ reportSDKError("flush-offline-queue", err);
1412
+ offlineQueue.shift();
1092
1413
  }
1093
- reportSDKError("flush-offline-queue", err);
1094
- break;
1095
1414
  }
1415
+ } finally {
1416
+ isFlushing = false;
1096
1417
  }
1097
1418
  if (flushed > 0) {
1098
1419
  const remaining = offlineQueue.length;
@@ -1174,7 +1495,7 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1174
1495
  });
1175
1496
  });
1176
1497
  }
1177
- async function syncRegistrations(reason) {
1498
+ async function reconcileRegistrations(reason) {
1178
1499
  if (!serveState)
1179
1500
  return;
1180
1501
  if (registrationSyncPromise) {
@@ -1182,50 +1503,44 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1182
1503
  }
1183
1504
  const state = serveState;
1184
1505
  registrationSyncPromise = (async () => {
1185
- const nextGroups = new Set;
1186
- for (const [fn, fnEntry] of fnHandlers.entries()) {
1187
- await new Promise((resolve, reject) => {
1188
- stub.RegisterFunction({
1189
- fn_name: fn,
1190
- service_name: service,
1191
- instance_id: state.instanceId,
1192
- endpoint: state.endpoint,
1193
- transport: state.transport,
1194
- weight: Math.max(1, state.opts.weight ?? 1),
1195
- input_schema_json: fnEntry.opts.schema?.input ? JSON.stringify(fnEntry.opts.schema.input) : "",
1196
- output_schema_json: fnEntry.opts.schema?.output ? JSON.stringify(fnEntry.opts.schema.output) : ""
1197
- }, meta, unaryDeadlineOptions(), (err) => err ? reject(normalizeServiceError(err, `register-function:${reason}:${fn}`)) : resolve());
1198
- });
1199
- }
1200
- for (const entry of eventHandlers.values()) {
1201
- const groupName = entry.groupName;
1202
- await new Promise((resolve, reject) => {
1203
- stub.RegisterConsumerGroup({
1204
- name: groupName,
1205
- pattern: entry.pattern,
1206
- mode: "shared",
1207
- retry_policy_json: entry.opts.retryPolicyJson ?? "{}",
1208
- active: true,
1209
- filter_expr: entry.opts.filterExpr ?? ""
1210
- }, meta, unaryDeadlineOptions(), (err) => err ? reject(normalizeServiceError(err, `register-group:${reason}:${groupName}`)) : resolve());
1211
- });
1212
- await new Promise((resolve, reject) => {
1213
- stub.RegisterGroupMember({
1214
- group_name: groupName,
1506
+ const functions = Array.from(fnHandlers.entries()).map(([fn, fnEntry]) => ({
1507
+ fn_name: fn,
1508
+ weight: Math.max(1, state.opts.weight ?? 1),
1509
+ input_schema_json: fnEntry.opts.schema?.input ? JSON.stringify(fnEntry.opts.schema.input) : "",
1510
+ output_schema_json: fnEntry.opts.schema?.output ? JSON.stringify(fnEntry.opts.schema.output) : "",
1511
+ allowed_callers: fnEntry.opts.allowedCallers ?? []
1512
+ }));
1513
+ const consumerGroups = Array.from(eventHandlers.values()).map((entry) => ({
1514
+ name: entry.groupName,
1515
+ pattern: entry.pattern,
1516
+ mode: "shared",
1517
+ retry_policy_json: entry.opts.retryPolicyJson ?? "{}",
1518
+ active: true,
1519
+ filter_expr: entry.opts.filterExpr ?? ""
1520
+ }));
1521
+ const metrics = getProcessMetrics();
1522
+ await new Promise((resolve, reject) => {
1523
+ stub.Reconcile({
1524
+ identity: {
1215
1525
  service_name: service,
1216
1526
  instance_id: state.instanceId,
1217
1527
  endpoint: state.endpoint,
1218
- transport: state.transport,
1219
- weight: Math.max(1, state.opts.weight ?? 1)
1220
- }, meta, unaryDeadlineOptions(), (err) => err ? reject(normalizeServiceError(err, `register-member:${reason}:${groupName}`)) : resolve());
1221
- });
1222
- nextGroups.add(groupName);
1223
- }
1528
+ transport: state.transport
1529
+ },
1530
+ functions,
1531
+ consumer_groups: consumerGroups,
1532
+ transport_mode: "TRANSPORT_MODE_DIRECT",
1533
+ zone: "",
1534
+ metrics: {
1535
+ cpu_percent: metrics.cpuPercent ?? null,
1536
+ ram_mb: metrics.ramMb ?? null
1537
+ }
1538
+ }, meta, unaryDeadlineOptions(), (err) => err ? reject(normalizeServiceError(err, `reconcile:${reason}`)) : resolve());
1539
+ });
1224
1540
  registeredGroups.clear();
1225
- for (const groupName of nextGroups) {
1226
- registeredGroups.add(groupName);
1541
+ for (const entry of eventHandlers.values()) {
1542
+ registeredGroups.add(entry.groupName);
1227
1543
  }
1228
- await sendHeartbeat();
1229
1544
  })().finally(() => {
1230
1545
  registrationSyncPromise = null;
1231
1546
  });
@@ -1256,9 +1571,9 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1256
1571
  console.warn("[servicebridge] lost connection to runtime — entering offline mode");
1257
1572
  }
1258
1573
  isOnline = false;
1259
- scheduleOnlineRestore();
1574
+ ensureChannelWatch();
1260
1575
  } else if (isRegistryResyncRequiredError(err)) {
1261
- await syncRegistrations("heartbeat-resync");
1576
+ await reconcileRegistrations("heartbeat-resync");
1262
1577
  } else {
1263
1578
  reportSDKError("heartbeat", err);
1264
1579
  }
@@ -1424,6 +1739,10 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1424
1739
  clearInterval(workerSessionPingTimer);
1425
1740
  workerSessionPingTimer = null;
1426
1741
  }
1742
+ if (workerSessionPositionTimer) {
1743
+ clearInterval(workerSessionPositionTimer);
1744
+ workerSessionPositionTimer = null;
1745
+ }
1427
1746
  if (workerSessionReconnectTimer) {
1428
1747
  clearTimeout(workerSessionReconnectTimer);
1429
1748
  workerSessionReconnectTimer = null;
@@ -1492,6 +1811,10 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1492
1811
  result.error = normalizeUnknownErrorMessage(err);
1493
1812
  }
1494
1813
  stream.write({ command_result: result });
1814
+ if (v2Session) {
1815
+ v2Session.onCommandCompleted(BigInt(0), commandId);
1816
+ stream.write({ permit_release: { released: 1 } });
1817
+ }
1495
1818
  }
1496
1819
  function scheduleWorkerSessionReconnect(delayMs = 1000) {
1497
1820
  if (stopped || !serveState || workerSessionReconnectTimer)
@@ -1504,22 +1827,57 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1504
1827
  });
1505
1828
  }, delayMs);
1506
1829
  }
1830
+ function adaptConfigPush(cp) {
1831
+ const tc = cp?.transport_config;
1832
+ if (!tc)
1833
+ return {};
1834
+ const defaultModeRaw = tc.default_mode;
1835
+ return {
1836
+ defaultMode: defaultModeRaw === "TRANSPORT_MODE_PROXY" ? "proxy" : "direct",
1837
+ serviceOverrides: tc.service_overrides,
1838
+ functionOverrides: tc.function_overrides
1839
+ };
1840
+ }
1507
1841
  async function openWorkerSession(initial) {
1508
1842
  if (!serveState) {
1509
1843
  throw new Error("serve() state is not initialized");
1510
1844
  }
1511
1845
  if (workerSessionStream)
1512
1846
  return;
1847
+ if (!v2Session) {
1848
+ v2Session = new V2SessionClient({
1849
+ serverAddress: target,
1850
+ serviceName: service,
1851
+ instanceId: serveState.instanceId,
1852
+ transportMode: "direct",
1853
+ maxInflight: serveState.maxInFlight,
1854
+ minInflight: 1
1855
+ });
1856
+ }
1513
1857
  const stream = stub.OpenWorkerSession(meta);
1514
1858
  workerSessionStream = stream;
1515
- const hello = {
1859
+ const hf = v2Session.getHelloFields();
1860
+ stream.write({
1516
1861
  hello: {
1517
- service_name: service,
1518
- instance_id: serveState.instanceId,
1519
- max_inflight: serveState.maxInFlight
1862
+ identity: {
1863
+ service_name: hf.identity.serviceName,
1864
+ instance_id: hf.identity.instanceId,
1865
+ endpoint: serveState.endpoint,
1866
+ transport: serveState.transport
1867
+ },
1868
+ max_inflight: hf.maxInflight,
1869
+ min_inflight: hf.minInflight,
1870
+ resume_token: hf.resumeToken,
1871
+ epoch: String(hf.epoch),
1872
+ last_received_seq: String(hf.lastReceivedSeq),
1873
+ last_sent_seq: String(hf.lastSentSeq),
1874
+ completed_command_ids: hf.completedCommandIds,
1875
+ sdk_version: hf.sdkVersion,
1876
+ capabilities: hf.capabilities,
1877
+ zone: hf.zone,
1878
+ transport_mode: hf.transportMode === "proxy" ? "TRANSPORT_MODE_PROXY" : "TRANSPORT_MODE_DIRECT"
1520
1879
  }
1521
- };
1522
- stream.write(hello);
1880
+ });
1523
1881
  workerSessionPingTimer = setInterval(() => {
1524
1882
  if (!workerSessionStream)
1525
1883
  return;
@@ -1527,7 +1885,50 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1527
1885
  ping: { timestamp_unix_ms: Date.now() }
1528
1886
  });
1529
1887
  }, 1e4);
1888
+ workerSessionPositionTimer = setInterval(() => {
1889
+ if (!workerSessionStream || !v2Session)
1890
+ return;
1891
+ workerSessionStream.write({
1892
+ position_update: {
1893
+ last_received_seq: String(v2Session.position.lastReceivedSeq)
1894
+ }
1895
+ });
1896
+ }, 5000);
1530
1897
  stream.on("data", (msg) => {
1898
+ if (msg.seq !== undefined && v2Session) {
1899
+ v2Session.position.recordReceived(BigInt(msg.seq));
1900
+ }
1901
+ if (msg.hello_ack && v2Session) {
1902
+ const ack = msg.hello_ack;
1903
+ const helloAck = {
1904
+ sessionId: ack.session_id ?? "",
1905
+ resumeToken: ack.resume_token ?? "",
1906
+ epoch: BigInt(ack.epoch ?? "0"),
1907
+ resumed: ack.resumed ?? false,
1908
+ resumeFromSeq: BigInt(ack.resume_from_seq ?? "0"),
1909
+ replayedCommands: ack.replayed_commands ?? 0,
1910
+ reconciledResults: ack.reconciled_results ?? 0,
1911
+ heartbeatIntervalMs: ack.heartbeat_interval_ms ?? 1e4,
1912
+ heartbeatTimeoutMs: ack.heartbeat_timeout_ms ?? 30000,
1913
+ initialPermits: ack.initial_permits ?? 0,
1914
+ maxPermits: ack.max_permits ?? 0,
1915
+ effectiveTransportMode: ack.effective_transport_mode === "TRANSPORT_MODE_PROXY" ? "proxy" : "direct",
1916
+ resumeFailReason: ack.resume_fail_reason
1917
+ };
1918
+ v2Session.onHelloAck(helloAck);
1919
+ }
1920
+ if (msg.goaway && v2Session) {
1921
+ v2Session.onGoaway(msg.goaway.code ?? "", msg.goaway.reason ?? "");
1922
+ }
1923
+ if (msg.config_push && v2Session) {
1924
+ v2Session.onConfigPush(adaptConfigPush(msg.config_push));
1925
+ }
1926
+ if (msg.permit_grant && v2Session) {
1927
+ v2Session.onPermitGrant(msg.permit_grant.additional_permits ?? 0);
1928
+ }
1929
+ if (msg.flow_update && v2Session) {
1930
+ v2Session.onFlowControlUpdate(msg.flow_update.new_window_size ?? 0, msg.flow_update.reason ?? "");
1931
+ }
1531
1932
  if (msg.command) {
1532
1933
  processWorkerSessionCommand(msg.command).catch((err) => {
1533
1934
  reportSDKError("worker-session-command", err);
@@ -1541,6 +1942,10 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1541
1942
  clearInterval(workerSessionPingTimer);
1542
1943
  workerSessionPingTimer = null;
1543
1944
  }
1945
+ if (workerSessionPositionTimer) {
1946
+ clearInterval(workerSessionPositionTimer);
1947
+ workerSessionPositionTimer = null;
1948
+ }
1544
1949
  workerSessionStream = null;
1545
1950
  if (err != null) {
1546
1951
  reportSDKError(operation, err);
@@ -1751,7 +2156,6 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1751
2156
  }
1752
2157
  server.bindAsync(`${host}:0`, serverCreds, (err, p) => err ? reject(err) : resolve(p));
1753
2158
  });
1754
- workerServer.start();
1755
2159
  const endpoint = `${advertiseHost}:${port}`;
1756
2160
  const instanceId = opts.instanceId || Array.from(crypto.getRandomValues(new Uint8Array(3))).map((b) => b.toString(16).padStart(2, "0")).join("");
1757
2161
  serveState = {
@@ -1768,7 +2172,7 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1768
2172
  serverName: tlsOpts?.serverName
1769
2173
  };
1770
2174
  await openWorkerSession(true);
1771
- await syncRegistrations("initial");
2175
+ await reconcileRegistrations("initial");
1772
2176
  scheduleNextHeartbeat();
1773
2177
  await _controlReady;
1774
2178
  } catch (err) {
@@ -1787,11 +2191,9 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
1787
2191
  stop() {
1788
2192
  stopped = true;
1789
2193
  isOnline = false;
1790
- if (onlineRestoreTimer)
1791
- clearTimeout(onlineRestoreTimer);
2194
+ isWatchingChannel = false;
1792
2195
  if (heartbeatTimer)
1793
2196
  clearTimeout(heartbeatTimer);
1794
- onlineRestoreTimer = null;
1795
2197
  heartbeatTimer = null;
1796
2198
  registeredGroups.clear();
1797
2199
  closeWorkerSession();
@@ -2227,8 +2629,15 @@ function captureConsole(svc) {
2227
2629
  }
2228
2630
  }
2229
2631
  export {
2632
+ validateV2Config,
2230
2633
  servicebridge,
2231
2634
  runWithTraceContext,
2232
2635
  getTraceContext,
2233
- ServiceBridgeError
2636
+ V2SessionClient,
2637
+ ServiceBridgeError,
2638
+ PositionTrackerV2,
2639
+ FlowControlStateV2,
2640
+ ConfigPushStateV2,
2641
+ BackoffV2,
2642
+ AdaptiveHeartbeatV2
2234
2643
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "service-bridge",
3
- "version": "1.1.0-dev.27",
3
+ "version": "1.1.1-dev.30",
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": [