@tier0/sdk-v2 0.9.9 → 0.10.0

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