@tier0/sdk-v2 0.9.10 → 0.10.1

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 (238) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/LICENSE +21 -21
  3. package/README.md +162 -24
  4. package/dist/cjs/client.d.ts +83 -3
  5. package/dist/cjs/client.js +4 -6
  6. package/dist/cjs/client.js.map +1 -1
  7. package/dist/cjs/cloud-server.d.ts +33 -26
  8. package/dist/cjs/cloud-server.js +102 -100
  9. package/dist/cjs/cloud-server.js.map +1 -1
  10. package/dist/cjs/cloud-transport.d.ts +15 -20
  11. package/dist/cjs/cloud-transport.js +87 -68
  12. package/dist/cjs/cloud-transport.js.map +1 -1
  13. package/dist/cjs/data/bootstrap.js +3 -4
  14. package/dist/cjs/data/bootstrap.js.map +1 -1
  15. package/dist/cjs/data/call.d.ts +1 -3
  16. package/dist/cjs/data/call.js +8 -12
  17. package/dist/cjs/data/call.js.map +1 -1
  18. package/dist/cjs/data/client.d.ts +8 -7
  19. package/dist/cjs/data/client.js +24 -11
  20. package/dist/cjs/data/client.js.map +1 -1
  21. package/dist/cjs/data/collection.d.ts +3 -5
  22. package/dist/cjs/data/collection.js +3 -4
  23. package/dist/cjs/data/collection.js.map +1 -1
  24. package/dist/cjs/data/context.d.ts +5 -11
  25. package/dist/cjs/data/context.js +21 -36
  26. package/dist/cjs/data/context.js.map +1 -1
  27. package/dist/cjs/data/error.js +1 -1
  28. package/dist/cjs/data/error.js.map +1 -1
  29. package/dist/cjs/data/index.d.ts +5 -3
  30. package/dist/cjs/data/index.js +5 -5
  31. package/dist/cjs/data/index.js.map +1 -1
  32. package/dist/cjs/data/relay.d.ts +51 -0
  33. package/dist/cjs/data/relay.js +371 -0
  34. package/dist/cjs/data/relay.js.map +1 -0
  35. package/dist/cjs/data/subscribe.d.ts +22 -38
  36. package/dist/cjs/data/subscribe.js +22 -28
  37. package/dist/cjs/data/subscribe.js.map +1 -1
  38. package/dist/cjs/event-stream.d.ts +28 -0
  39. package/dist/cjs/event-stream.js +222 -0
  40. package/dist/cjs/event-stream.js.map +1 -0
  41. package/dist/cjs/mq/client.d.ts +32 -1
  42. package/dist/cjs/mq/client.js +404 -69
  43. package/dist/cjs/mq/client.js.map +1 -1
  44. package/dist/cjs/mq/index.d.ts +1 -0
  45. package/dist/cjs/mq/index.js +3 -1
  46. package/dist/cjs/mq/index.js.map +1 -1
  47. package/dist/cjs/mq/topic.d.ts +29 -0
  48. package/dist/cjs/mq/topic.js +48 -0
  49. package/dist/cjs/mq/topic.js.map +1 -0
  50. package/dist/cjs/mq/types.d.ts +21 -1
  51. package/dist/cjs/mq/types.js.map +1 -1
  52. package/dist/cjs/openapi/api.generated.d.ts +21 -0
  53. package/dist/cjs/openapi/api.generated.js +11 -0
  54. package/dist/cjs/openapi/api.generated.js.map +1 -1
  55. package/dist/cjs/openapi/client.d.ts +46 -0
  56. package/dist/cjs/openapi/client.js +158 -10
  57. package/dist/cjs/openapi/client.js.map +1 -1
  58. package/dist/cjs/openapi/codecs.generated.d.ts +75 -0
  59. package/dist/cjs/openapi/codecs.generated.js +1 -1
  60. package/dist/cjs/openapi/codecs.generated.js.map +1 -1
  61. package/dist/cjs/openapi/types.generated.d.ts +206 -1
  62. package/dist/cjs/openapi/types.generated.js.map +1 -1
  63. package/dist/cjs/page-runtime.d.ts +48 -0
  64. package/dist/cjs/page-runtime.js +74 -0
  65. package/dist/cjs/page-runtime.js.map +1 -0
  66. package/dist/cjs/platform-proxy.d.ts +17 -0
  67. package/dist/cjs/platform-proxy.js +52 -0
  68. package/dist/cjs/platform-proxy.js.map +1 -0
  69. package/dist/cjs/runtime-context.d.ts +2 -1
  70. package/dist/cjs/runtime-context.js +6 -2
  71. package/dist/cjs/runtime-context.js.map +1 -1
  72. package/dist/cjs/runtime-env.js.map +1 -1
  73. package/dist/cjs/source.d.ts +19 -1
  74. package/dist/cjs/source.js +9 -3
  75. package/dist/cjs/source.js.map +1 -1
  76. package/dist/cjs/stream.js +3 -2
  77. package/dist/cjs/stream.js.map +1 -1
  78. package/dist/cjs/streams/contract.generated.d.ts +7 -1
  79. package/dist/cjs/streams/contract.generated.js.map +1 -1
  80. package/dist/cjs/streams/messages.js +1 -10
  81. package/dist/cjs/streams/messages.js.map +1 -1
  82. package/dist/cjs/streams/requests.d.ts +3 -0
  83. package/dist/cjs/streams/requests.js +5 -2
  84. package/dist/cjs/streams/requests.js.map +1 -1
  85. package/dist/cjs/streams/transport.d.ts +1 -13
  86. package/dist/cjs/streams/transport.js +2 -18
  87. package/dist/cjs/streams/transport.js.map +1 -1
  88. package/dist/cjs/uns-v2/client.d.ts +1 -6
  89. package/dist/cjs/uns-v2/client.js +30 -6
  90. package/dist/cjs/uns-v2/client.js.map +1 -1
  91. package/dist/cjs/uns-v2/data-client.d.ts +5 -2
  92. package/dist/cjs/uns-v2/data-client.js +7 -4
  93. package/dist/cjs/uns-v2/data-client.js.map +1 -1
  94. package/dist/cjs/uns-v2/generated.d.ts +6783 -4655
  95. package/dist/cjs/uns-v2/generated.js +221 -65
  96. package/dist/cjs/uns-v2/generated.js.map +1 -1
  97. package/dist/cjs/uns-v2/sse.d.ts +7 -1
  98. package/dist/cjs/uns-v2/sse.js +12 -2
  99. package/dist/cjs/uns-v2/sse.js.map +1 -1
  100. package/dist/esm/client.d.ts +83 -3
  101. package/dist/esm/client.js +5 -7
  102. package/dist/esm/client.js.map +1 -1
  103. package/dist/esm/cloud-server.d.ts +33 -26
  104. package/dist/esm/cloud-server.js +99 -97
  105. package/dist/esm/cloud-server.js.map +1 -1
  106. package/dist/esm/cloud-transport.d.ts +15 -20
  107. package/dist/esm/cloud-transport.js +86 -67
  108. package/dist/esm/cloud-transport.js.map +1 -1
  109. package/dist/esm/data/bootstrap.js +3 -4
  110. package/dist/esm/data/bootstrap.js.map +1 -1
  111. package/dist/esm/data/call.d.ts +1 -3
  112. package/dist/esm/data/call.js +9 -13
  113. package/dist/esm/data/call.js.map +1 -1
  114. package/dist/esm/data/client.d.ts +8 -7
  115. package/dist/esm/data/client.js +22 -9
  116. package/dist/esm/data/client.js.map +1 -1
  117. package/dist/esm/data/collection.d.ts +3 -5
  118. package/dist/esm/data/collection.js +3 -4
  119. package/dist/esm/data/collection.js.map +1 -1
  120. package/dist/esm/data/context.d.ts +5 -11
  121. package/dist/esm/data/context.js +19 -33
  122. package/dist/esm/data/context.js.map +1 -1
  123. package/dist/esm/data/error.js +1 -1
  124. package/dist/esm/data/error.js.map +1 -1
  125. package/dist/esm/data/index.d.ts +5 -3
  126. package/dist/esm/data/index.js +3 -2
  127. package/dist/esm/data/index.js.map +1 -1
  128. package/dist/esm/data/relay.d.ts +51 -0
  129. package/dist/esm/data/relay.js +368 -0
  130. package/dist/esm/data/relay.js.map +1 -0
  131. package/dist/esm/data/subscribe.d.ts +22 -38
  132. package/dist/esm/data/subscribe.js +22 -27
  133. package/dist/esm/data/subscribe.js.map +1 -1
  134. package/dist/esm/event-stream.d.ts +28 -0
  135. package/dist/esm/event-stream.js +217 -0
  136. package/dist/esm/event-stream.js.map +1 -0
  137. package/dist/esm/mq/client.d.ts +32 -1
  138. package/dist/esm/mq/client.js +404 -69
  139. package/dist/esm/mq/client.js.map +1 -1
  140. package/dist/esm/mq/index.d.ts +1 -0
  141. package/dist/esm/mq/index.js +1 -0
  142. package/dist/esm/mq/index.js.map +1 -1
  143. package/dist/esm/mq/topic.d.ts +29 -0
  144. package/dist/esm/mq/topic.js +44 -0
  145. package/dist/esm/mq/topic.js.map +1 -0
  146. package/dist/esm/mq/types.d.ts +21 -1
  147. package/dist/esm/mq/types.js.map +1 -1
  148. package/dist/esm/openapi/api.generated.d.ts +21 -0
  149. package/dist/esm/openapi/api.generated.js +11 -0
  150. package/dist/esm/openapi/api.generated.js.map +1 -1
  151. package/dist/esm/openapi/client.d.ts +46 -0
  152. package/dist/esm/openapi/client.js +157 -10
  153. package/dist/esm/openapi/client.js.map +1 -1
  154. package/dist/esm/openapi/codecs.generated.d.ts +75 -0
  155. package/dist/esm/openapi/codecs.generated.js +1 -1
  156. package/dist/esm/openapi/codecs.generated.js.map +1 -1
  157. package/dist/esm/openapi/types.generated.d.ts +206 -1
  158. package/dist/esm/openapi/types.generated.js.map +1 -1
  159. package/dist/esm/page-runtime.d.ts +48 -0
  160. package/dist/esm/page-runtime.js +65 -0
  161. package/dist/esm/page-runtime.js.map +1 -0
  162. package/dist/esm/platform-proxy.d.ts +17 -0
  163. package/dist/esm/platform-proxy.js +47 -0
  164. package/dist/esm/platform-proxy.js.map +1 -0
  165. package/dist/esm/runtime-context.d.ts +2 -1
  166. package/dist/esm/runtime-context.js +6 -2
  167. package/dist/esm/runtime-context.js.map +1 -1
  168. package/dist/esm/runtime-env.js.map +1 -1
  169. package/dist/esm/source.d.ts +19 -1
  170. package/dist/esm/source.js +10 -4
  171. package/dist/esm/source.js.map +1 -1
  172. package/dist/esm/stream.js +4 -3
  173. package/dist/esm/stream.js.map +1 -1
  174. package/dist/esm/streams/contract.generated.d.ts +7 -1
  175. package/dist/esm/streams/contract.generated.js.map +1 -1
  176. package/dist/esm/streams/messages.js +1 -10
  177. package/dist/esm/streams/messages.js.map +1 -1
  178. package/dist/esm/streams/requests.d.ts +3 -0
  179. package/dist/esm/streams/requests.js +5 -2
  180. package/dist/esm/streams/requests.js.map +1 -1
  181. package/dist/esm/streams/transport.d.ts +1 -13
  182. package/dist/esm/streams/transport.js +1 -17
  183. package/dist/esm/streams/transport.js.map +1 -1
  184. package/dist/esm/uns-v2/client.d.ts +1 -6
  185. package/dist/esm/uns-v2/client.js +30 -6
  186. package/dist/esm/uns-v2/client.js.map +1 -1
  187. package/dist/esm/uns-v2/data-client.d.ts +5 -2
  188. package/dist/esm/uns-v2/data-client.js +8 -5
  189. package/dist/esm/uns-v2/data-client.js.map +1 -1
  190. package/dist/esm/uns-v2/generated.d.ts +6783 -4655
  191. package/dist/esm/uns-v2/generated.js +221 -65
  192. package/dist/esm/uns-v2/generated.js.map +1 -1
  193. package/dist/esm/uns-v2/sse.d.ts +7 -1
  194. package/dist/esm/uns-v2/sse.js +12 -2
  195. package/dist/esm/uns-v2/sse.js.map +1 -1
  196. package/package.json +25 -12
  197. package/skills/tier0-sdk-v2/SKILL.md +11 -5
  198. package/skills/tier0-sdk-v2/references/integration.md +14 -24
  199. package/skills/tier0-sdk-v2/tier0-sdk-v2-data/SKILL.md +11 -5
  200. package/skills/tier0-sdk-v2/tier0-sdk-v2-data/references/consistency.md +6 -10
  201. package/skills/tier0-sdk-v2/tier0-sdk-v2-data/references/errors.md +20 -3
  202. package/skills/tier0-sdk-v2/tier0-sdk-v2-data/references/read-write.md +46 -13
  203. package/skills/tier0-sdk-v2/tier0-sdk-v2-members/SKILL.md +2 -2
  204. package/skills/tier0-sdk-v2/tier0-sdk-v2-members/references/app-members.md +5 -1
  205. package/skills/tier0-sdk-v2/tier0-sdk-v2-mq/SKILL.md +25 -0
  206. package/skills/tier0-sdk-v2/tier0-sdk-v2-mq/references/quickstart.md +1 -1
  207. package/skills/tier0-sdk-v2/tier0-sdk-v2-notifications/SKILL.md +3 -3
  208. package/skills/tier0-sdk-v2/tier0-sdk-v2-notifications/references/send.md +6 -6
  209. package/skills/tier0-sdk-v2/tier0-sdk-v2-source/SKILL.md +4 -4
  210. package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/changes.md +2 -2
  211. package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/create.md +26 -36
  212. package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/delete.md +9 -23
  213. package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/protocols.md +12 -29
  214. package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/read.md +4 -5
  215. package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/update.md +5 -5
  216. package/skills/tier0-sdk-v2/tier0-sdk-v2-stream/SKILL.md +3 -3
  217. package/skills/tier0-sdk-v2/tier0-sdk-v2-stream/references/create.md +23 -9
  218. package/skills/tier0-sdk-v2/tier0-sdk-v2-stream/references/deploy.md +14 -12
  219. package/skills/tier0-sdk-v2/tier0-sdk-v2-stream/references/http-server.md +4 -4
  220. package/skills/tier0-sdk-v2/tier0-sdk-v2-stream/references/read.md +4 -3
  221. package/skills/tier0-sdk-v2/tier0-sdk-v2-streams/SKILL.md +14 -12
  222. package/skills/tier0-sdk-v2/tier0-sdk-v2-uns/SKILL.md +3 -1
  223. package/skills/tier0-sdk-v2/tier0-sdk-v2-uns/references/create.md +2 -2
  224. package/skills/tier0-sdk-v2/tier0-sdk-v2-uns/references/delete.md +22 -1
  225. package/skills/tier0-sdk-v2/tier0-sdk-v2-uns/references/read.md +5 -6
  226. package/skills/tier0-sdk-v2/tier0-sdk-v2-uns/references/update.md +11 -0
  227. package/dist/cjs/app-identity-contract.generated.d.ts +0 -22
  228. package/dist/cjs/app-identity-contract.generated.js +0 -51
  229. package/dist/cjs/app-identity-contract.generated.js.map +0 -1
  230. package/dist/cjs/runtime.d.ts +0 -13
  231. package/dist/cjs/runtime.js +0 -51
  232. package/dist/cjs/runtime.js.map +0 -1
  233. package/dist/esm/app-identity-contract.generated.d.ts +0 -22
  234. package/dist/esm/app-identity-contract.generated.js +0 -48
  235. package/dist/esm/app-identity-contract.generated.js.map +0 -1
  236. package/dist/esm/runtime.d.ts +0 -13
  237. package/dist/esm/runtime.js +0 -46
  238. package/dist/esm/runtime.js.map +0 -1
@@ -5,8 +5,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.Tier0MQClient = void 0;
7
7
  const mqtt_1 = __importDefault(require("mqtt"));
8
+ const platform_proxy_js_1 = require("../platform-proxy.js");
8
9
  const runtime_env_js_1 = require("../runtime-env.js");
9
10
  const broker_js_1 = require("./broker.js");
11
+ const topic_js_1 = require("./topic.js");
12
+ /** Design 21.6: backoff ceiling of a client that never gives up. */
13
+ const RECONNECT_MAX_MS = 5 * 60_000;
14
+ /** The first reconnect after a drop is spread over this window. */
15
+ const FIRST_RECONNECT_MS = 500;
10
16
  function parseWorkspaceIDFromApiKey(apiKey) {
11
17
  // Match the backend's sk-<type>-ws<base36>_<secret> contract, not only
12
18
  // service keys. Other workspace-encoded keys use the same MQTT identity.
@@ -30,6 +36,16 @@ function generateRandomString(length = 8) {
30
36
  }
31
37
  return result;
32
38
  }
39
+ function snapshotConfig(config) {
40
+ if (!config)
41
+ return undefined;
42
+ const snapshot = { ...config };
43
+ // Preserve an explicitly supplied non-enumerable password as well.
44
+ if (Object.hasOwn(config, 'password') && !Object.hasOwn(snapshot, 'password'))
45
+ snapshot.password = config.password;
46
+ return snapshot;
47
+ }
48
+ const CANCELLED_BY_DISCONNECT = 'MQTT connection cancelled by disconnect';
33
49
  class Tier0MQClient {
34
50
  client = null;
35
51
  config;
@@ -37,22 +53,44 @@ class Tier0MQClient {
37
53
  explicitUsername;
38
54
  listeners = {};
39
55
  subscriptions = [];
56
+ // One broker subscription (including its pending SUBACK) per topic.
57
+ topicSubscriptions = new Map();
58
+ pendingTopicPackets = new Map();
59
+ sentTopicPackets = new Map();
40
60
  connectingPromise = null;
61
+ rejectPendingConnect = null;
41
62
  _connected = false;
63
+ /** Bumped by disconnect(); callbacks of an older generation are ignored. */
64
+ generation = 0;
65
+ /** Random clientId segment; a persistent session is found again through it. */
66
+ clientSuffix = generateRandomString(16);
67
+ reconnectTimer = null;
68
+ reconnectAttempts = 0;
69
+ connectWaiters = [];
42
70
  constructor(config) {
71
+ config = snapshotConfig(config);
43
72
  this.explicitClientId = config?.clientId !== undefined;
44
73
  this.explicitUsername = config?.username !== undefined;
45
- const envHost = (0, runtime_env_js_1.getEnvVar)('TIER0_MQTT_HOST');
74
+ const envHost = (0, runtime_env_js_1.getEnvVar)('TIER0_MQTT_URL') || (0, runtime_env_js_1.getEnvVar)('TIER0_MQTT_HOST');
46
75
  const envPort = (0, runtime_env_js_1.getEnvVar)('TIER0_MQTT_PORT');
47
76
  const host = config?.host || envHost || '';
48
77
  const port = config?.port || parseInt(envPort || '8084') || 8084;
49
- const password = config?.password || (0, runtime_env_js_1.getEnvVar)('TIER0_API_KEY') || '';
78
+ // An explicitly given password, even undefined, is never replaced by the environment key.
79
+ const explicitPassword = !!config && Object.hasOwn(config, 'password');
80
+ const password = explicitPassword
81
+ ? config.password
82
+ : (this.tokenSource() ? '' : (0, runtime_env_js_1.getEnvVar)('TIER0_API_KEY')) || '';
50
83
  // 从 apiKey 解析 workspaceID,自动生成 clientId 和 username
51
84
  const workspaceID = config?.clientId && config?.username
52
85
  ? undefined
53
- : parseWorkspaceIDFromApiKey(password);
86
+ : parseWorkspaceIDFromApiKey(password ?? '');
87
+ // In an App's runtime the platform injects the App's key as TIER0_API_KEY: Backend accepts that hosted key
88
+ // only with clientId `<ws>&h.<app>.<random>` (app = TIER0_APP_ID). Any other key keeps `<ws>&<random>`.
89
+ const appId = (0, runtime_env_js_1.getEnvVar)('TIER0_APP_ID')?.trim();
90
+ const hosted = !explicitPassword && !!password && config?.clientId === undefined && config?.username === undefined &&
91
+ !!appId && topic_js_1.APP_SEGMENT.test(appId);
54
92
  const autoClientId = workspaceID
55
- ? `${workspaceID}&${generateRandomString(8)}`
93
+ ? hosted ? `${workspaceID}&h.${appId}.${this.clientSuffix}` : `${workspaceID}&${generateRandomString(8)}`
56
94
  : undefined;
57
95
  const autoUsername = workspaceID
58
96
  ? `${workspaceID}&open`
@@ -66,12 +104,12 @@ class Tier0MQClient {
66
104
  keepAlive: config?.keepAlive ?? 60,
67
105
  reconnectPeriod: config?.reconnectPeriod ?? 5000,
68
106
  connectTimeout: config?.connectTimeout ?? 30000,
69
- clean: config?.clean ?? true,
70
107
  ...config,
108
+ clean: config?.clean ?? false,
71
109
  };
72
110
  }
73
- get mqttUrl() {
74
- const { host, port, secure } = this.config;
111
+ mqttUrlFor(config) {
112
+ const { host, port, secure } = config;
75
113
  if (!host) {
76
114
  throw new Error('MQTT host is required. Provide it via MQTTConfig or TIER0_MQTT_HOST environment variable.');
77
115
  }
@@ -93,59 +131,234 @@ class Tier0MQClient {
93
131
  const useSecure = secure ?? ((0, broker_js_1.isBrowserHttps)() || effectivePort === broker_js_1.DEFAULT_WSS_PORT);
94
132
  return `${useSecure ? 'wss' : 'ws'}://${hostname}:${effectivePort}/mqtt`;
95
133
  }
96
- // 内部确保已连接(懒连接)
97
- async ensureConnected() {
134
+ /**
135
+ * Where the credential comes from; undefined when a fixed password is used.
136
+ * `proxy`: platform proxy mode, the node proxy puts in the App's key and
137
+ * the SDK sends none.
138
+ */
139
+ tokenSource(config = this.config) {
140
+ if (config?.password)
141
+ return undefined;
142
+ return (0, platform_proxy_js_1.platformProxyMode)() ? 'proxy' : undefined;
143
+ }
144
+ /** Local preflight before a transport exists: address and credentials must be usable. */
145
+ validateConnectionConfig(config) {
146
+ void this.connectionParams(config);
147
+ }
148
+ /**
149
+ * What the next connection presents. Through the node proxy the same
150
+ * identity reconnects, and the proxy puts in the App's current key.
151
+ */
152
+ connectionParams(config = this.config) {
153
+ if (this.tokenSource(config) === 'proxy') {
154
+ const identity = (0, platform_proxy_js_1.platformIdentity)();
155
+ if (!identity) {
156
+ throw new Error('Tier0 SDK v2: TIER0_WORKSPACE_ID and TIER0_APP_ID are required for MQTT in the platform.');
157
+ }
158
+ // Only `mqtt://` from the platform goes over TCP to the proxy; an explicit host means WebSocket.
159
+ const envUrl = (0, runtime_env_js_1.getEnvVar)('TIER0_MQTT_URL')?.trim();
160
+ const tcp = envUrl !== undefined && /^mqtt:\/\//i.test(envUrl) && config.host === envUrl;
161
+ // The proxy accepts only `<ws>&open` and `<ws>&h.<app>.<random>`, and puts the App's key in as the password.
162
+ return {
163
+ url: tcp ? envUrl : this.mqttUrlFor(config),
164
+ username: config.username ?? `${identity.ws}&open`,
165
+ clientId: config.clientId ?? `${identity.ws}&h.${identity.app}.${this.clientSuffix}`,
166
+ };
167
+ }
168
+ const { username, clientId, password } = config;
169
+ const url = this.mqttUrlFor(config);
170
+ if (!password || !username || !clientId) {
171
+ throw new Error('Tier0 SDK v2: provide a workspace-encoded API key or explicit MQTT username, clientId and password.');
172
+ }
173
+ return { url, username, clientId, password };
174
+ }
175
+ // 内部确保已连接(懒连接)。已有客户端时只等它重连,绝不另建:
176
+ // 同一 clientId 的新旧客户端会互相顶掉。
177
+ async ensureConnected(onTransportCreationFailure) {
98
178
  if (this._connected)
99
179
  return;
100
180
  if (this.connectingPromise)
101
181
  return this.connectingPromise;
102
- void this.mqttUrl;
103
- if (!this.config.password || !this.config.username || !this.config.clientId) {
104
- throw new Error('Tier0 SDK v2: provide a workspace-encoded API key or explicit MQTT username, clientId and password.');
182
+ if (this.client) {
183
+ return new Promise((resolve, reject) => this.connectWaiters.push({ resolve, reject }));
105
184
  }
106
- this.connectingPromise = this.doConnect();
107
- return this.connectingPromise;
185
+ const pending = this.doConnect(this.connectionParams(), onTransportCreationFailure);
186
+ // mqtt.connect may throw synchronously before creating a client. Do not
187
+ // retain that already-failed attempt until the rejection microtask runs.
188
+ if (!this.client)
189
+ return pending;
190
+ this.connectingPromise = pending;
191
+ void pending.catch(() => {
192
+ if (this.connectingPromise === pending)
193
+ this.connectingPromise = null;
194
+ });
195
+ return pending;
196
+ }
197
+ clearReconnect() {
198
+ if (this.reconnectTimer !== null)
199
+ clearTimeout(this.reconnectTimer);
200
+ this.reconnectTimer = null;
108
201
  }
109
- doConnect() {
202
+ reconnectDelay() {
203
+ const attempt = this.reconnectAttempts;
204
+ this.reconnectAttempts += 1;
205
+ if (attempt === 0)
206
+ return Math.random() * FIRST_RECONNECT_MS;
207
+ const base = this.config.reconnectPeriod ?? 5000;
208
+ const ceiling = Math.min(RECONNECT_MAX_MS, base * 2 ** (attempt - 1));
209
+ return ceiling / 2 + Math.random() * (ceiling / 2);
210
+ }
211
+ scheduleReconnect() {
212
+ if (!this.client || this._connected || this.reconnectTimer !== null)
213
+ return;
214
+ if (this.config.reconnectPeriod === 0)
215
+ return;
216
+ this.reconnectTimer = setTimeout(() => {
217
+ this.reconnectTimer = null;
218
+ this.reconnect();
219
+ }, this.reconnectDelay());
220
+ }
221
+ /** The same client: mqtt.js keeps its session and offline queue. */
222
+ reconnect() {
223
+ const client = this.client;
224
+ if (!client || this._connected)
225
+ return;
226
+ let params;
227
+ try {
228
+ params = this.connectionParams();
229
+ }
230
+ catch (error) {
231
+ this.emit('error', error);
232
+ this.scheduleReconnect();
233
+ return;
234
+ }
235
+ client.options.username = params.username;
236
+ client.options.clientId = params.clientId;
237
+ if (params.password === undefined)
238
+ delete client.options.password;
239
+ else
240
+ client.options.password = params.password;
241
+ client.reconnect();
242
+ }
243
+ doConnect(params, onTransportCreationFailure) {
110
244
  return new Promise((resolve, reject) => {
245
+ const generation = this.generation;
246
+ this.rejectPendingConnect = reject;
111
247
  const options = {
112
- clientId: this.config.clientId,
113
- username: this.config.username,
114
- password: this.config.password,
248
+ clientId: params.clientId,
249
+ username: params.username,
250
+ ...(params.password === undefined ? {} : { password: params.password }),
115
251
  keepalive: this.config.keepAlive,
116
- reconnectPeriod: this.config.reconnectPeriod,
252
+ // Reconnects are ours, with their own backoff.
253
+ reconnectPeriod: 0,
254
+ // Without it a refused CONNACK leaves the client disconnected for good.
255
+ reconnectOnConnackError: true,
117
256
  connectTimeout: this.config.connectTimeout,
118
257
  clean: this.config.clean,
258
+ // The SDK owns resubscription so every SUBACK reaches its callback.
259
+ resubscribe: false,
119
260
  };
120
- this.client = mqtt_1.default.connect(this.mqttUrl, options);
121
- this.client.on('connect', () => {
261
+ let client;
262
+ try {
263
+ client = mqtt_1.default.connect(params.url, options);
264
+ }
265
+ catch (err) {
266
+ this.rejectPendingConnect = null;
267
+ onTransportCreationFailure?.();
268
+ reject(err);
269
+ return;
270
+ }
271
+ this.client = client;
272
+ const current = () => this.client === client && this.generation === generation;
273
+ // mqtt.js 5.3 synthesizes the requested QoS if a malformed SUBACK has
274
+ // no grants, and can throw when it contains too many. Track wire packet
275
+ // ids so malformed acknowledgements never become apparent success.
276
+ client.on('packetsend', (packet) => {
277
+ if (!current() || packet.cmd !== 'subscribe' || packet.subscriptions.length !== 1)
278
+ return;
279
+ const topic = packet.subscriptions[0]?.topic;
280
+ if (!topic || typeof packet.messageId !== 'number')
281
+ return;
282
+ const queue = this.pendingTopicPackets.get(topic);
283
+ const attempt = queue?.shift();
284
+ if (queue?.length === 0)
285
+ this.pendingTopicPackets.delete(topic);
286
+ if (attempt)
287
+ this.sentTopicPackets.set(packet.messageId, attempt);
288
+ });
289
+ client.on('packetreceive', (packet) => {
290
+ if (!current() || packet.cmd !== 'suback' || typeof packet.messageId !== 'number')
291
+ return;
292
+ const attempt = this.sentTopicPackets.get(packet.messageId);
293
+ this.sentTopicPackets.delete(packet.messageId);
294
+ if (!attempt)
295
+ return;
296
+ if (!Array.isArray(packet.granted) || packet.granted.length !== 1 ||
297
+ (packet.granted[0] !== 0 && packet.granted[0] !== 1)) {
298
+ attempt.invalidSuback = true;
299
+ // Keep old mqtt.js from indexing past its single requested topic.
300
+ packet.granted = [128];
301
+ }
302
+ });
303
+ client.on('connect', () => {
304
+ if (!current())
305
+ return;
122
306
  this._connected = true;
307
+ this.clearTopicState();
308
+ for (const sub of this.subscriptions)
309
+ sub.pendingConnect = false;
123
310
  this.connectingPromise = null;
311
+ this.rejectPendingConnect = null;
312
+ this.clearReconnect();
313
+ this.reconnectAttempts = 0;
314
+ resolve();
124
315
  this.emit('connect');
125
316
  // 断连重连后自动恢复所有订阅
126
- this.resubscribeAll();
127
- resolve();
317
+ if (current())
318
+ this.resubscribeAll();
319
+ const waiters = this.connectWaiters;
320
+ this.connectWaiters = [];
321
+ waiters.forEach((waiter) => waiter.resolve());
128
322
  });
129
- this.client.on('error', (err) => {
130
- this.emit('error', err);
323
+ client.on('error', (err) => {
324
+ if (!current())
325
+ return;
326
+ // Remove failed pending subscriptions before an error listener can
327
+ // synchronously cause this same client to reconnect and resubscribe.
328
+ this.subscriptions = this.subscriptions.filter((sub) => !sub.pendingConnect);
131
329
  this.connectingPromise = null;
330
+ this.rejectPendingConnect = null;
132
331
  reject(err);
332
+ this.emit('error', err);
133
333
  });
134
- this.client.on('disconnect', () => {
334
+ client.on('disconnect', () => {
335
+ if (!current())
336
+ return;
135
337
  this._connected = false;
338
+ this.clearTopicState();
136
339
  this.emit('disconnect');
137
340
  });
138
- this.client.on('close', () => {
341
+ client.on('close', () => {
342
+ if (!current())
343
+ return;
139
344
  this._connected = false;
345
+ this.clearTopicState();
346
+ this.scheduleReconnect();
140
347
  });
141
- this.client.on('message', (topic, payload, _packet) => {
348
+ client.on('message', (topic, payload, _packet) => {
349
+ if (!current())
350
+ return;
142
351
  const payloadStr = payload.toString();
143
352
  // 复制一份快照:handler 触发背压自动退订时会重建 this.subscriptions
144
353
  this.subscriptions.slice().forEach((sub) => {
354
+ if (!current() || !this.subscriptions.includes(sub))
355
+ return;
145
356
  if (this.topicMatch(sub.topic, topic)) {
146
357
  try {
147
358
  // 公开类型为 void 返回(兼容 async/简写 handler),运行时仅识别严格的 false
148
359
  const accepted = sub.handler(topic, payloadStr);
360
+ if (!current() || !this.subscriptions.includes(sub))
361
+ return;
149
362
  if (accepted === false) {
150
363
  sub.backpressuredCount = (sub.backpressuredCount ?? 0) + 1;
151
364
  const limit = this.config.maxBackpressuredDeliveries ?? 0;
@@ -162,7 +375,8 @@ class Tier0MQClient {
162
375
  }
163
376
  }
164
377
  catch (e) {
165
- this.emit('error', e);
378
+ if (current())
379
+ this.emit('error', e);
166
380
  }
167
381
  }
168
382
  });
@@ -171,25 +385,85 @@ class Tier0MQClient {
171
385
  }
172
386
  // 显式连接(需要等待连接完成时使用)
173
387
  async connect(config) {
174
- if (config) {
175
- const previousPassword = this.config.password;
176
- this.config = { ...this.config, ...config };
177
- if (config.clientId !== undefined)
178
- this.explicitClientId = true;
179
- if (config.username !== undefined)
180
- this.explicitUsername = true;
388
+ const snapshot = snapshotConfig(config);
389
+ // An explicitly missing password must never reuse a previous or environment key.
390
+ if (snapshot && Object.hasOwn(snapshot, 'password') && snapshot.password === undefined) {
391
+ throw new TypeError('MQTT password was explicitly undefined; provide a credential or omit the password field.');
392
+ }
393
+ // Other undefined fields mean no override, not a request to erase settings.
394
+ const update = snapshot
395
+ ? Object.fromEntries(Object.entries(snapshot).filter(([, value]) => value !== undefined))
396
+ : undefined;
397
+ if (this.client || this.connectingPromise) {
398
+ // mqtt.js snapshots its connection options. Changing our config while a
399
+ // client exists (connecting, connected or reconnecting) would leave the
400
+ // old credentials in use, including on reconnect. Refuse the change
401
+ // before mutating anything.
402
+ const connectionFields = [
403
+ 'host', 'port', 'secure', 'clientId', 'username', 'password',
404
+ 'keepAlive', 'reconnectPeriod', 'connectTimeout', 'clean',
405
+ ];
406
+ if (update && connectionFields.some((field) => update[field] !== undefined && update[field] !== this.config[field])) {
407
+ throw new Error('MQTT connection settings cannot change while a client exists; disconnect and create a new client');
408
+ }
409
+ const generation = this.generation;
410
+ // A reconnecting client is waited for, never replaced: the SDK keeps reconnecting it.
411
+ await this.ensureConnected();
412
+ if (this.generation !== generation || !this.client || !this._connected) {
413
+ throw new Error('MQTT connection changed before connect completed');
414
+ }
415
+ if (update) {
416
+ this.config = { ...this.config, ...update };
417
+ if (update.clientId !== undefined)
418
+ this.explicitClientId = true;
419
+ if (update.username !== undefined)
420
+ this.explicitUsername = true;
421
+ }
422
+ return;
423
+ }
424
+ const previousConfig = this.config;
425
+ const previousExplicitClientId = this.explicitClientId;
426
+ const previousExplicitUsername = this.explicitUsername;
427
+ const candidate = update ? { ...previousConfig, ...update } : previousConfig;
428
+ const explicitClientId = previousExplicitClientId || update?.clientId !== undefined;
429
+ const explicitUsername = previousExplicitUsername || update?.username !== undefined;
430
+ if (update) {
431
+ const previousPassword = previousConfig.password;
181
432
  // A connect-time key may belong to a different workspace. Preserve only
182
433
  // caller-supplied identity fields, not values derived from the previous key.
183
- if (config.password !== undefined && config.password !== previousPassword) {
184
- const workspaceID = parseWorkspaceIDFromApiKey(config.password);
185
- if (!this.explicitClientId) {
186
- this.config.clientId = workspaceID ? workspaceID + '&' + generateRandomString(8) : undefined;
434
+ if (update.password !== undefined && update.password !== previousPassword) {
435
+ const workspaceID = parseWorkspaceIDFromApiKey(update.password);
436
+ if (!explicitClientId) {
437
+ candidate.clientId = workspaceID ? workspaceID + '&' + generateRandomString(8) : undefined;
187
438
  }
188
- if (!this.explicitUsername)
189
- this.config.username = workspaceID ? workspaceID + '&open' : undefined;
439
+ if (!explicitUsername)
440
+ candidate.username = workspaceID ? workspaceID + '&open' : undefined;
441
+ }
442
+ }
443
+ this.validateConnectionConfig(candidate);
444
+ const generation = this.generation;
445
+ this.config = candidate;
446
+ this.explicitClientId = explicitClientId;
447
+ this.explicitUsername = explicitUsername;
448
+ const rollbackBeforeTransport = () => {
449
+ if (!this.client && this.generation === generation && this.config === candidate) {
450
+ this.config = previousConfig;
451
+ this.explicitClientId = previousExplicitClientId;
452
+ this.explicitUsername = previousExplicitUsername;
190
453
  }
454
+ };
455
+ try {
456
+ await this.ensureConnected(rollbackBeforeTransport);
457
+ }
458
+ catch (err) {
459
+ // If no transport was created, the attempted settings have no owner.
460
+ // Do not roll back a newer generation or another caller's settings.
461
+ rollbackBeforeTransport();
462
+ throw err;
463
+ }
464
+ if (this.generation !== generation || !this.client || !this._connected) {
465
+ throw new Error('MQTT connection changed before connect completed');
191
466
  }
192
- return this.ensureConnected();
193
467
  }
194
468
  // 订阅 topic,内部 qos 固定为 1,handler 接收 (topic, payload)
195
469
  // 无需先调用 connect(),自动处理连接
@@ -199,20 +473,25 @@ class Tier0MQClient {
199
473
  if (exists) {
200
474
  return;
201
475
  }
202
- this.subscriptions.push({ topic, handler });
476
+ // Only a subscription made before the first connection completes is
477
+ // undone when that connection fails. One made while the SDK reconnects
478
+ // an existing client is kept and restored after the reconnect.
479
+ const pendingConnect = !this._connected && (!this.client || this.connectingPromise !== null);
480
+ const sub = { topic, handler, pendingConnect };
481
+ this.subscriptions.push(sub);
482
+ const generation = this.generation;
203
483
  if (this._connected && this.client) {
204
- this.client.subscribe(topic, { qos: 1 }, (err) => {
205
- if (err) {
484
+ this.subscribeTopic(topic, this.client, generation);
485
+ }
486
+ else {
487
+ // 未连接时触发懒连接(已有客户端时等待它重连),连接成功后 resubscribeAll 会自动 subscribe
488
+ this.ensureConnected().catch((err) => {
489
+ if (this.generation === generation && this.subscriptions.includes(sub)) {
490
+ this.subscriptions = this.subscriptions.filter((item) => item !== sub);
206
491
  this.emit('error', err);
207
- // 订阅失败时回滚
208
- this.subscriptions = this.subscriptions.filter((s) => !(s.topic === topic && s.handler === handler));
209
492
  }
210
493
  });
211
494
  }
212
- else {
213
- // 未连接时触发懒连接,连接成功后 resubscribeAll 会自动 subscribe
214
- this.ensureConnected().catch((err) => this.emit('error', err));
215
- }
216
495
  }
217
496
  // 取消订阅:如果提供了 handler 则仅移除该 handler,否则移除该 topic 下所有 handler
218
497
  unsubscribe(topic, handler) {
@@ -224,10 +503,15 @@ class Tier0MQClient {
224
503
  }
225
504
  if (!this.client)
226
505
  return;
506
+ const client = this.client;
507
+ const generation = this.generation;
227
508
  // 如果该 topic 下已无任何 handler,则向 broker 发送 unsubscribe
228
509
  const stillHasHandler = this.subscriptions.some((s) => s.topic === topic);
229
510
  if (!stillHasHandler) {
230
- this.client.unsubscribe(topic, (err) => {
511
+ this.topicSubscriptions.delete(topic);
512
+ client.unsubscribe(topic, (err) => {
513
+ if (this.client !== client || this.generation !== generation)
514
+ return;
231
515
  if (err) {
232
516
  this.emit('error', err);
233
517
  }
@@ -236,23 +520,41 @@ class Tier0MQClient {
236
520
  }
237
521
  // 发布消息,无需先调用 connect(),内部自动处理连接
238
522
  async publish(topic, payload, options) {
239
- await this.ensureConnected();
523
+ const generation = this.generation;
524
+ // A disconnected client queues the message and sends it after reconnecting.
525
+ if (!this.client)
526
+ await this.ensureConnected();
240
527
  const message = typeof payload === 'string' ? payload : JSON.stringify(payload);
241
- this.client.publish(topic, message, {
242
- qos: (options?.qos ?? 0),
243
- retain: options?.retain ?? false,
244
- });
528
+ const qos = (options?.qos ?? 0);
529
+ const retain = options?.retain ?? false;
530
+ const client = this.client;
531
+ if (this.generation !== generation || !client) {
532
+ throw new Error('MQTT connection changed before publish; message was not submitted');
533
+ }
534
+ client.publish(topic, message, { qos, retain });
245
535
  }
246
536
  disconnect() {
247
- if (!this.client)
248
- return;
249
- this.client.end(true, () => {
250
- this.emit('disconnect');
251
- });
537
+ this.clearReconnect();
538
+ const generation = ++this.generation;
539
+ const client = this.client;
252
540
  this.client = null;
253
541
  this._connected = false;
254
542
  this.connectingPromise = null;
543
+ const rejectPendingConnect = this.rejectPendingConnect;
544
+ this.rejectPendingConnect = null;
545
+ const waiters = this.connectWaiters;
546
+ this.connectWaiters = [];
255
547
  this.subscriptions = [];
548
+ this.clearTopicState();
549
+ const cancelled = new Error(CANCELLED_BY_DISCONNECT);
550
+ rejectPendingConnect?.(cancelled);
551
+ waiters.forEach((waiter) => waiter.reject(cancelled));
552
+ if (!client)
553
+ return;
554
+ client.end(true, () => {
555
+ if (!this.client && this.generation === generation)
556
+ this.emit('disconnect');
557
+ });
256
558
  }
257
559
  on(event, handler) {
258
560
  if (!this.listeners[event]) {
@@ -279,14 +581,47 @@ class Tier0MQClient {
279
581
  resubscribeAll() {
280
582
  if (!this.client)
281
583
  return;
584
+ const client = this.client;
585
+ const generation = this.generation;
282
586
  // 按 topic 去重,每个 topic 只订阅一次
283
587
  const uniqueTopics = Array.from(new Set(this.subscriptions.map((s) => s.topic)));
284
- uniqueTopics.forEach((topic) => {
285
- this.client.subscribe(topic, { qos: 1 }, (err) => {
286
- if (err) {
287
- this.emit('error', err);
288
- }
289
- });
588
+ uniqueTopics.forEach((topic) => this.subscribeTopic(topic, client, generation));
589
+ }
590
+ clearTopicState() {
591
+ this.topicSubscriptions.clear();
592
+ this.pendingTopicPackets.clear();
593
+ this.sentTopicPackets.clear();
594
+ }
595
+ subscribeTopic(topic, client, generation) {
596
+ if (this.client !== client || this.generation !== generation || !this._connected ||
597
+ this.topicSubscriptions.has(topic) || !this.subscriptions.some((sub) => sub.topic === topic))
598
+ return;
599
+ const attempt = {};
600
+ this.topicSubscriptions.set(topic, attempt);
601
+ const pending = this.pendingTopicPackets.get(topic) ?? [];
602
+ pending.push(attempt);
603
+ this.pendingTopicPackets.set(topic, pending);
604
+ client.subscribe(topic, { qos: 1 }, (err, granted) => {
605
+ const queue = this.pendingTopicPackets.get(topic);
606
+ if (queue) {
607
+ const index = queue.indexOf(attempt);
608
+ if (index >= 0)
609
+ queue.splice(index, 1);
610
+ if (!queue.length)
611
+ this.pendingTopicPackets.delete(topic);
612
+ }
613
+ if (this.client !== client || this.generation !== generation || this.topicSubscriptions.get(topic) !== attempt)
614
+ return;
615
+ // mqtt.js 5.3 reports SUBACK failure codes in granted with err=null.
616
+ // A QoS 1 request may only be granted QoS 0 or 1, for this one topic.
617
+ const accepted = Array.isArray(granted) && granted.length === 1 &&
618
+ granted[0]?.topic === topic && (granted[0]?.qos === 0 || granted[0]?.qos === 1);
619
+ if (err || attempt.invalidSuback || !accepted) {
620
+ this.topicSubscriptions.delete(topic);
621
+ // Every handler shares this broker subscription and its SUBACK.
622
+ this.subscriptions = this.subscriptions.filter((sub) => sub.topic !== topic);
623
+ this.emit('error', new Error(`MQTT subscription failed for topic ${topic}`, { cause: err ?? undefined }));
624
+ }
290
625
  });
291
626
  }
292
627
  // 判断接收到的 topic 是否匹配订阅的 topic(支持通配符 # 和 +)