@cnhongwei/react-native-mqtt 0.0.0-stage → 0.1.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.
- package/LICENSE +20 -0
- package/README.md +169 -2
- package/README.zh-CN.md +100 -0
- package/ReactNativeMqtt.podspec +28 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/android/build.gradle +70 -0
- package/android/consumer-rules.pro +10 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/MqttOptionsParser.kt +176 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/MqttSession.kt +1006 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/PahoLogger.java +44 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/PahoMqttTransport.kt +399 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/ReactNativeMqttModule.kt +619 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/ReactNativeMqttPackage.kt +31 -0
- package/ios/ReactNativeMqtt.h +15 -0
- package/ios/ReactNativeMqtt.mm +1184 -0
- package/ios/ReactNativeMqttCocoaTransport.swift +782 -0
- package/ios/ReactNativeMqttConnAckProbe.swift +87 -0
- package/ios/ReactNativeMqttSession.swift +420 -0
- package/lib/module/NativeReactNativeMqtt.js +5 -0
- package/lib/module/NativeReactNativeMqtt.js.map +1 -0
- package/lib/module/client.js +555 -0
- package/lib/module/client.js.map +1 -0
- package/lib/module/errors.js +20 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/helpers.js +116 -0
- package/lib/module/helpers.js.map +1 -0
- package/lib/module/index.js +8 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/validation.js +306 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeReactNativeMqtt.d.ts +154 -0
- package/lib/typescript/src/NativeReactNativeMqtt.d.ts.map +1 -0
- package/lib/typescript/src/client.d.ts +3 -0
- package/lib/typescript/src/client.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +18 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/helpers.d.ts +5 -0
- package/lib/typescript/src/helpers.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +9 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +161 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/validation.d.ts +13 -0
- package/lib/typescript/src/validation.d.ts.map +1 -0
- package/package.json +188 -4
- package/react-native.config.js +1 -0
- package/src/NativeReactNativeMqtt.ts +173 -0
- package/src/client.ts +859 -0
- package/src/errors.ts +51 -0
- package/src/helpers.ts +151 -0
- package/src/index.ts +30 -0
- package/src/types.ts +184 -0
- package/src/validation.ts +735 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
import NativeReactNativeMqtt, {
|
|
2
|
+
type WireBatch,
|
|
3
|
+
type WireConnAck,
|
|
4
|
+
type WireDiagnostic,
|
|
5
|
+
type WireMessage,
|
|
6
|
+
type WireSnapshot,
|
|
7
|
+
type WireSubscriptionResult,
|
|
8
|
+
} from './NativeReactNativeMqtt';
|
|
9
|
+
import { MqttError, type MqttErrorCode } from './errors';
|
|
10
|
+
import { decodeBase64, encodeBase64, encodeUtf8 } from './helpers';
|
|
11
|
+
import type {
|
|
12
|
+
ConnAck,
|
|
13
|
+
ConnectionState,
|
|
14
|
+
Diagnostic,
|
|
15
|
+
Listener,
|
|
16
|
+
Message,
|
|
17
|
+
MqttClient,
|
|
18
|
+
MqttOptions,
|
|
19
|
+
SessionSnapshot,
|
|
20
|
+
SubscribeOptions,
|
|
21
|
+
Subscription,
|
|
22
|
+
} from './types';
|
|
23
|
+
import {
|
|
24
|
+
normalizeOptions,
|
|
25
|
+
normalizeSubscribeOptions,
|
|
26
|
+
toWirePublishRequest,
|
|
27
|
+
toWireSessionOptions,
|
|
28
|
+
toWireSubscribeOptions,
|
|
29
|
+
validateTopicFilter,
|
|
30
|
+
} from './validation';
|
|
31
|
+
|
|
32
|
+
type SnapshotListener = (snapshot: SessionSnapshot) => void;
|
|
33
|
+
type MessageListener = (message: Message) => void;
|
|
34
|
+
type DiagnosticListener = (diagnostic: Diagnostic) => void;
|
|
35
|
+
type NativeErrorShape = {
|
|
36
|
+
code?: string;
|
|
37
|
+
message?: string;
|
|
38
|
+
userInfo?: {
|
|
39
|
+
operation?: unknown;
|
|
40
|
+
generation?: unknown;
|
|
41
|
+
reasonCode?: unknown;
|
|
42
|
+
reasonString?: unknown;
|
|
43
|
+
outcome?: unknown;
|
|
44
|
+
} | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const errorCodes = new Set<MqttErrorCode>([
|
|
48
|
+
'ERR_INVALID_OPTIONS',
|
|
49
|
+
'ERR_INVALID_PAYLOAD',
|
|
50
|
+
'ERR_NATIVE_UNAVAILABLE',
|
|
51
|
+
'ERR_CONTRACT_MISMATCH',
|
|
52
|
+
'ERR_NOT_CONNECTED',
|
|
53
|
+
'ERR_DISPOSED',
|
|
54
|
+
'ERR_OPERATION_IN_PROGRESS',
|
|
55
|
+
'ERR_OPERATION_SUPERSEDED',
|
|
56
|
+
'ERR_CONNECT_TIMEOUT',
|
|
57
|
+
'ERR_OPERATION_TIMEOUT',
|
|
58
|
+
'ERR_CONNECTION_REFUSED',
|
|
59
|
+
'ERR_CONNECTION_LOST',
|
|
60
|
+
'ERR_SUBSCRIPTION_CONFLICT',
|
|
61
|
+
'ERR_SUBSCRIBE_FAILED',
|
|
62
|
+
'ERR_UNSUBSCRIBE_FAILED',
|
|
63
|
+
'ERR_PUBLISH_FAILED',
|
|
64
|
+
'ERR_TLS',
|
|
65
|
+
'ERR_CLEANUP_FAILED',
|
|
66
|
+
'ERR_UNKNOWN',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
function mapError(value: unknown, operation?: string): MqttError {
|
|
70
|
+
if (value instanceof MqttError) return value;
|
|
71
|
+
const shape = (
|
|
72
|
+
value !== null && typeof value === 'object' ? value : {}
|
|
73
|
+
) as NativeErrorShape;
|
|
74
|
+
const code =
|
|
75
|
+
typeof shape.code === 'string' &&
|
|
76
|
+
errorCodes.has(shape.code as MqttErrorCode)
|
|
77
|
+
? (shape.code as MqttErrorCode)
|
|
78
|
+
: 'ERR_UNKNOWN';
|
|
79
|
+
const message =
|
|
80
|
+
typeof shape.message === 'string'
|
|
81
|
+
? shape.message
|
|
82
|
+
: `${operation ?? 'operation'} failed`;
|
|
83
|
+
const userInfo = shape.userInfo;
|
|
84
|
+
const reasonCode =
|
|
85
|
+
userInfo !== null &&
|
|
86
|
+
userInfo !== undefined &&
|
|
87
|
+
typeof userInfo.reasonCode === 'number'
|
|
88
|
+
? userInfo.reasonCode
|
|
89
|
+
: undefined;
|
|
90
|
+
const reasonString =
|
|
91
|
+
userInfo !== null &&
|
|
92
|
+
userInfo !== undefined &&
|
|
93
|
+
typeof userInfo.reasonString === 'string'
|
|
94
|
+
? userInfo.reasonString
|
|
95
|
+
: undefined;
|
|
96
|
+
const nativeOperation =
|
|
97
|
+
userInfo !== null &&
|
|
98
|
+
userInfo !== undefined &&
|
|
99
|
+
typeof userInfo.operation === 'string'
|
|
100
|
+
? userInfo.operation
|
|
101
|
+
: undefined;
|
|
102
|
+
const generation =
|
|
103
|
+
userInfo !== null &&
|
|
104
|
+
userInfo !== undefined &&
|
|
105
|
+
typeof userInfo.generation === 'number' &&
|
|
106
|
+
Number.isSafeInteger(userInfo.generation)
|
|
107
|
+
? userInfo.generation
|
|
108
|
+
: undefined;
|
|
109
|
+
const outcome =
|
|
110
|
+
userInfo !== null &&
|
|
111
|
+
userInfo !== undefined &&
|
|
112
|
+
(userInfo.outcome === 'not-sent' ||
|
|
113
|
+
userInfo.outcome === 'rejected' ||
|
|
114
|
+
userInfo.outcome === 'unknown')
|
|
115
|
+
? userInfo.outcome
|
|
116
|
+
: 'unknown';
|
|
117
|
+
return new MqttError(code, message, {
|
|
118
|
+
operation: nativeOperation ?? operation,
|
|
119
|
+
generation,
|
|
120
|
+
reasonCode,
|
|
121
|
+
reasonString,
|
|
122
|
+
outcome,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function snapshotFromWire(value: WireSnapshot): SessionSnapshot {
|
|
126
|
+
return {
|
|
127
|
+
...value,
|
|
128
|
+
state: value.state as ConnectionState,
|
|
129
|
+
subscriptionsStatus:
|
|
130
|
+
value.subscriptionsStatus as SessionSnapshot['subscriptionsStatus'],
|
|
131
|
+
deliveryStatus: value.deliveryStatus as SessionSnapshot['deliveryStatus'],
|
|
132
|
+
lastConnAck:
|
|
133
|
+
value.lastConnAck === null ? null : connAckFromWire(value.lastConnAck),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function connAckFromWire(value: WireConnAck): ConnAck {
|
|
137
|
+
return {
|
|
138
|
+
sessionPresent: value.sessionPresent,
|
|
139
|
+
assignedClientId: value.assignedClientId,
|
|
140
|
+
reasonCode: value.reasonCode,
|
|
141
|
+
reasonString: value.reasonString,
|
|
142
|
+
userProperties: value.userProperties?.map((pair) => ({
|
|
143
|
+
key: pair.key,
|
|
144
|
+
value: pair.value,
|
|
145
|
+
})),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function messageFromWire(value: WireMessage): Message {
|
|
149
|
+
return {
|
|
150
|
+
instanceId: value.instanceId,
|
|
151
|
+
generation: value.generation,
|
|
152
|
+
topic: value.topic,
|
|
153
|
+
bytes: decodeBase64(value.payloadBase64),
|
|
154
|
+
qos: value.qos as Message['qos'],
|
|
155
|
+
retained: value.retained,
|
|
156
|
+
userProperties: value.userProperties?.map((pair) => ({
|
|
157
|
+
key: pair.key,
|
|
158
|
+
value: pair.value,
|
|
159
|
+
})),
|
|
160
|
+
contentType: value.contentType,
|
|
161
|
+
responseTopic: value.responseTopic,
|
|
162
|
+
correlationData:
|
|
163
|
+
value.correlationDataBase64 === undefined
|
|
164
|
+
? undefined
|
|
165
|
+
: decodeBase64(value.correlationDataBase64),
|
|
166
|
+
messageExpiryIntervalSeconds: value.messageExpiryIntervalSeconds,
|
|
167
|
+
payloadFormatIndicator:
|
|
168
|
+
value.payloadFormatIndicator as Message['payloadFormatIndicator'],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function diagnosticFromWire(value: WireDiagnostic): Diagnostic {
|
|
172
|
+
return { ...value, code: value.code as Diagnostic['code'] };
|
|
173
|
+
}
|
|
174
|
+
function randomClientId(): string {
|
|
175
|
+
const alphabet =
|
|
176
|
+
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
177
|
+
let result = 'rn';
|
|
178
|
+
while (result.length < 23)
|
|
179
|
+
result += alphabet[Math.floor(Math.random() * alphabet.length)] ?? 'x';
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function createMqttClient(input: MqttOptions): MqttClient {
|
|
184
|
+
const options = normalizeOptions(input);
|
|
185
|
+
const instanceId = `mqtt-${randomClientId()}`;
|
|
186
|
+
const clientId =
|
|
187
|
+
options.clientId === undefined ? randomClientId() : options.clientId;
|
|
188
|
+
const sessionOptions = { ...options, clientId } as MqttOptions;
|
|
189
|
+
let snapshot: SessionSnapshot = {
|
|
190
|
+
instanceId,
|
|
191
|
+
revision: 0,
|
|
192
|
+
generation: 0,
|
|
193
|
+
state: 'idle',
|
|
194
|
+
lastConnAck: null,
|
|
195
|
+
subscriptionsRevision: 0,
|
|
196
|
+
subscriptionsStatus: 'ready',
|
|
197
|
+
deliveryStatus: 'active',
|
|
198
|
+
};
|
|
199
|
+
let openPromise: Promise<void> | undefined;
|
|
200
|
+
let opened = false;
|
|
201
|
+
let connectPromise: Promise<ConnAck> | undefined;
|
|
202
|
+
let lastConnAck: ConnAck | undefined;
|
|
203
|
+
let disconnectPromise: Promise<void> | undefined;
|
|
204
|
+
let disposePromise: Promise<void> | undefined;
|
|
205
|
+
let nativeBound = false;
|
|
206
|
+
let nextIntentRevision = snapshot.subscriptionsRevision;
|
|
207
|
+
const stateListeners = new Set<SnapshotListener>();
|
|
208
|
+
const messageListeners = new Set<MessageListener>();
|
|
209
|
+
const diagnosticListeners = new Set<DiagnosticListener>();
|
|
210
|
+
const nativeListeners: Array<Listener | (() => void)> = [];
|
|
211
|
+
const ackTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
212
|
+
const ackRetryTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
213
|
+
const ackRetryAttempts = new Map<string, number>();
|
|
214
|
+
const subscriptions = new Map<
|
|
215
|
+
string,
|
|
216
|
+
{ options: SubscribeOptions; refs: number; qos: Subscription['qos'] }
|
|
217
|
+
>();
|
|
218
|
+
const pendingSubscriptions = new Map<
|
|
219
|
+
string,
|
|
220
|
+
{ options: SubscribeOptions; promise: Promise<number> }
|
|
221
|
+
>();
|
|
222
|
+
const pendingOfflineRemovals = new Map<string, number>();
|
|
223
|
+
const pendingIntentOperations = new Set<Promise<unknown>>();
|
|
224
|
+
const pendingNativeOperations = new Set<Promise<unknown>>();
|
|
225
|
+
|
|
226
|
+
const emitSnapshot = (next: SessionSnapshot): void => {
|
|
227
|
+
if (next.revision < snapshot.revision) return;
|
|
228
|
+
snapshot = next;
|
|
229
|
+
nextIntentRevision = Math.max(
|
|
230
|
+
nextIntentRevision,
|
|
231
|
+
next.subscriptionsRevision
|
|
232
|
+
);
|
|
233
|
+
for (const listener of [...stateListeners]) {
|
|
234
|
+
try {
|
|
235
|
+
listener(snapshot);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
emitDiagnostic({
|
|
238
|
+
instanceId,
|
|
239
|
+
generation: snapshot.generation,
|
|
240
|
+
code: 'LISTENER_ERROR',
|
|
241
|
+
message: error instanceof Error ? error.message : String(error),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const emitNativeLifecycleSnapshot = (value: WireSnapshot): void => {
|
|
247
|
+
const next = snapshotFromWire(value);
|
|
248
|
+
emitSnapshot(
|
|
249
|
+
next.revision <= snapshot.revision
|
|
250
|
+
? { ...next, revision: snapshot.revision + 1 }
|
|
251
|
+
: next
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
const emitDiagnostic = (diagnostic: Diagnostic): void => {
|
|
255
|
+
for (const listener of [...diagnosticListeners]) {
|
|
256
|
+
try {
|
|
257
|
+
listener(diagnostic);
|
|
258
|
+
} catch {
|
|
259
|
+
/* diagnostic listeners are isolated */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const batchKey = (streamEpoch: number, batchId: number): string =>
|
|
264
|
+
`${streamEpoch}:${batchId}`;
|
|
265
|
+
const allocateIntentRevision = (): number => {
|
|
266
|
+
nextIntentRevision =
|
|
267
|
+
Math.max(nextIntentRevision, snapshot.subscriptionsRevision) + 1;
|
|
268
|
+
return nextIntentRevision;
|
|
269
|
+
};
|
|
270
|
+
const trackIntentOperation = <T>(promise: Promise<T>): Promise<T> => {
|
|
271
|
+
pendingIntentOperations.add(promise);
|
|
272
|
+
promise.then(
|
|
273
|
+
() => pendingIntentOperations.delete(promise),
|
|
274
|
+
() => pendingIntentOperations.delete(promise)
|
|
275
|
+
);
|
|
276
|
+
return promise;
|
|
277
|
+
};
|
|
278
|
+
const trackNativeOperation = <T>(promise: Promise<T>): Promise<T> => {
|
|
279
|
+
pendingNativeOperations.add(promise);
|
|
280
|
+
promise.then(
|
|
281
|
+
() => pendingNativeOperations.delete(promise),
|
|
282
|
+
() => pendingNativeOperations.delete(promise)
|
|
283
|
+
);
|
|
284
|
+
return promise;
|
|
285
|
+
};
|
|
286
|
+
const flushOfflineRemovals = (): void => {
|
|
287
|
+
if (snapshot.state !== 'connected' || pendingOfflineRemovals.size === 0)
|
|
288
|
+
return;
|
|
289
|
+
const native = bindNative();
|
|
290
|
+
for (const [topicFilter, intentRevision] of [
|
|
291
|
+
...pendingOfflineRemovals.entries(),
|
|
292
|
+
]) {
|
|
293
|
+
pendingOfflineRemovals.delete(topicFilter);
|
|
294
|
+
const operation = trackIntentOperation(
|
|
295
|
+
native
|
|
296
|
+
.removeSubscription(instanceId, topicFilter, intentRevision)
|
|
297
|
+
.catch((error: unknown) => {
|
|
298
|
+
pendingOfflineRemovals.set(topicFilter, intentRevision);
|
|
299
|
+
throw error;
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
operation.catch(() => undefined);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
const acknowledgeBatch = (
|
|
306
|
+
native: NonNullable<typeof NativeReactNativeMqtt>,
|
|
307
|
+
streamEpoch: number,
|
|
308
|
+
batchId: number
|
|
309
|
+
): void => {
|
|
310
|
+
const key = batchKey(streamEpoch, batchId);
|
|
311
|
+
const previous = ackTimers.get(key);
|
|
312
|
+
if (previous !== undefined) clearTimeout(previous);
|
|
313
|
+
const timeout = setTimeout(() => {
|
|
314
|
+
if (!ackTimers.has(key) || snapshot.state === 'closed') return;
|
|
315
|
+
emitSnapshot({
|
|
316
|
+
...snapshot,
|
|
317
|
+
deliveryStatus: 'stalled',
|
|
318
|
+
revision: snapshot.revision + 1,
|
|
319
|
+
});
|
|
320
|
+
emitDiagnostic({
|
|
321
|
+
instanceId,
|
|
322
|
+
generation: snapshot.generation,
|
|
323
|
+
code: 'DELIVERY_STALLED',
|
|
324
|
+
message: `Message batch ${key} acknowledgement timed out`,
|
|
325
|
+
});
|
|
326
|
+
}, options.delivery?.batchAckTimeoutMs ?? 15000);
|
|
327
|
+
// Install the watchdog before submitting: a synchronously-resolved native
|
|
328
|
+
// promise must still be able to clear the exact timer it owns.
|
|
329
|
+
ackTimers.set(key, timeout);
|
|
330
|
+
const submit = (attempt: number): void => {
|
|
331
|
+
native
|
|
332
|
+
.ackMessageBatch(instanceId, streamEpoch, batchId)
|
|
333
|
+
.then(() => {
|
|
334
|
+
ackTimers.delete(key);
|
|
335
|
+
ackRetryAttempts.delete(key);
|
|
336
|
+
ackRetryTimers.delete(key);
|
|
337
|
+
if (snapshot.deliveryStatus === 'stalled')
|
|
338
|
+
emitSnapshot({
|
|
339
|
+
...snapshot,
|
|
340
|
+
deliveryStatus: 'active',
|
|
341
|
+
revision: snapshot.revision + 1,
|
|
342
|
+
});
|
|
343
|
+
})
|
|
344
|
+
.catch((error: unknown) => {
|
|
345
|
+
if (attempt === 0 && snapshot.state !== 'closed') {
|
|
346
|
+
ackRetryAttempts.set(key, 1);
|
|
347
|
+
const retry = setTimeout(() => {
|
|
348
|
+
ackRetryTimers.delete(key);
|
|
349
|
+
if (snapshot.state !== 'closed') submit(1);
|
|
350
|
+
}, 0);
|
|
351
|
+
ackRetryTimers.set(key, retry);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
emitDiagnostic({
|
|
355
|
+
instanceId,
|
|
356
|
+
generation: snapshot.generation,
|
|
357
|
+
code: 'DELIVERY_STALLED',
|
|
358
|
+
message: mapError(error, 'ackMessageBatch').message,
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
submit(ackRetryAttempts.get(key) ?? 0);
|
|
363
|
+
};
|
|
364
|
+
const nativeOrThrow = (): NonNullable<typeof NativeReactNativeMqtt> => {
|
|
365
|
+
if (NativeReactNativeMqtt == null)
|
|
366
|
+
throw new MqttError(
|
|
367
|
+
'ERR_NATIVE_UNAVAILABLE',
|
|
368
|
+
'ReactNativeMqtt TurboModule is unavailable; rebuild the app with the native module installed',
|
|
369
|
+
{ outcome: 'not-sent' }
|
|
370
|
+
);
|
|
371
|
+
return NativeReactNativeMqtt;
|
|
372
|
+
};
|
|
373
|
+
const bindNative = (): NonNullable<typeof NativeReactNativeMqtt> => {
|
|
374
|
+
const native = nativeOrThrow();
|
|
375
|
+
if (!nativeBound) {
|
|
376
|
+
nativeBound = true;
|
|
377
|
+
nativeListeners.push(
|
|
378
|
+
native.onSessionSnapshot((value) => {
|
|
379
|
+
if (snapshot.state === 'closed' || value.instanceId !== instanceId)
|
|
380
|
+
return;
|
|
381
|
+
emitSnapshot(snapshotFromWire(value));
|
|
382
|
+
})
|
|
383
|
+
);
|
|
384
|
+
nativeListeners.push(
|
|
385
|
+
native.onMessageBatch((batch: WireBatch) => {
|
|
386
|
+
if (snapshot.state === 'closed' || batch.instanceId !== instanceId)
|
|
387
|
+
return;
|
|
388
|
+
// Keep the native in-flight token reserved while stalled. A late or
|
|
389
|
+
// duplicate event must not deliver another batch or release capacity
|
|
390
|
+
// until the original token is acknowledged.
|
|
391
|
+
if (snapshot.deliveryStatus === 'stalled') return;
|
|
392
|
+
try {
|
|
393
|
+
for (const value of batch.messages) {
|
|
394
|
+
if (value.instanceId !== instanceId) continue;
|
|
395
|
+
let message: Message;
|
|
396
|
+
try {
|
|
397
|
+
message = messageFromWire(value);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
emitDiagnostic({
|
|
400
|
+
instanceId,
|
|
401
|
+
generation: snapshot.generation,
|
|
402
|
+
code: 'LISTENER_ERROR',
|
|
403
|
+
message:
|
|
404
|
+
error instanceof Error ? error.message : String(error),
|
|
405
|
+
});
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
for (const listener of [...messageListeners]) {
|
|
409
|
+
try {
|
|
410
|
+
const result = (listener as (value: Message) => unknown)(
|
|
411
|
+
message
|
|
412
|
+
);
|
|
413
|
+
if (
|
|
414
|
+
result !== null &&
|
|
415
|
+
(typeof result === 'object' || typeof result === 'function')
|
|
416
|
+
) {
|
|
417
|
+
Promise.resolve(result as PromiseLike<unknown>).catch(
|
|
418
|
+
(error: unknown) =>
|
|
419
|
+
emitDiagnostic({
|
|
420
|
+
instanceId,
|
|
421
|
+
generation: snapshot.generation,
|
|
422
|
+
code: 'LISTENER_ERROR',
|
|
423
|
+
message:
|
|
424
|
+
error instanceof Error
|
|
425
|
+
? error.message
|
|
426
|
+
: String(error),
|
|
427
|
+
})
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
} catch (error) {
|
|
431
|
+
emitDiagnostic({
|
|
432
|
+
instanceId,
|
|
433
|
+
generation: snapshot.generation,
|
|
434
|
+
code: 'LISTENER_ERROR',
|
|
435
|
+
message:
|
|
436
|
+
error instanceof Error ? error.message : String(error),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
} finally {
|
|
442
|
+
acknowledgeBatch(native, batch.streamEpoch, batch.batchId);
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
);
|
|
446
|
+
nativeListeners.push(
|
|
447
|
+
native.onDiagnostic((value) => {
|
|
448
|
+
if (snapshot.state === 'closed' || value.instanceId !== instanceId)
|
|
449
|
+
return;
|
|
450
|
+
emitDiagnostic(diagnosticFromWire(value));
|
|
451
|
+
})
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
return native;
|
|
455
|
+
};
|
|
456
|
+
const ensureOpen = (): Promise<void> => {
|
|
457
|
+
if (disposePromise !== undefined)
|
|
458
|
+
return Promise.reject(
|
|
459
|
+
new MqttError('ERR_DISPOSED', 'Client is disposed', {
|
|
460
|
+
outcome: 'not-sent',
|
|
461
|
+
})
|
|
462
|
+
);
|
|
463
|
+
if (openPromise !== undefined) return openPromise;
|
|
464
|
+
openPromise = Promise.resolve()
|
|
465
|
+
.then(() =>
|
|
466
|
+
bindNative().openSession(
|
|
467
|
+
instanceId,
|
|
468
|
+
toWireSessionOptions(sessionOptions)
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
.then((value) => {
|
|
472
|
+
opened = true;
|
|
473
|
+
emitNativeLifecycleSnapshot(value);
|
|
474
|
+
})
|
|
475
|
+
.catch((error: unknown) => {
|
|
476
|
+
opened = false;
|
|
477
|
+
openPromise = undefined;
|
|
478
|
+
throw mapError(error, 'openSession');
|
|
479
|
+
});
|
|
480
|
+
return openPromise;
|
|
481
|
+
};
|
|
482
|
+
const listener = <T>(set: Set<T>, value: T): Listener => {
|
|
483
|
+
if (disposePromise !== undefined || snapshot.state === 'closed')
|
|
484
|
+
return { remove: () => undefined };
|
|
485
|
+
set.add(value);
|
|
486
|
+
return { remove: () => set.delete(value) };
|
|
487
|
+
};
|
|
488
|
+
const ensureUsable = (): void => {
|
|
489
|
+
if (disposePromise !== undefined || snapshot.state === 'closed')
|
|
490
|
+
throw new MqttError('ERR_DISPOSED', 'Client is disposed', {
|
|
491
|
+
outcome: 'not-sent',
|
|
492
|
+
});
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const client: MqttClient = {
|
|
496
|
+
get instanceId() {
|
|
497
|
+
return instanceId;
|
|
498
|
+
},
|
|
499
|
+
get clientId() {
|
|
500
|
+
return clientId;
|
|
501
|
+
},
|
|
502
|
+
get state() {
|
|
503
|
+
return snapshot.state;
|
|
504
|
+
},
|
|
505
|
+
connect: () => {
|
|
506
|
+
ensureUsable();
|
|
507
|
+
if (snapshot.state === 'disconnecting')
|
|
508
|
+
return Promise.reject(
|
|
509
|
+
new MqttError(
|
|
510
|
+
'ERR_OPERATION_IN_PROGRESS',
|
|
511
|
+
'Disconnect is still in progress',
|
|
512
|
+
{ outcome: 'not-sent' }
|
|
513
|
+
)
|
|
514
|
+
);
|
|
515
|
+
if (snapshot.state === 'connected' && lastConnAck !== undefined)
|
|
516
|
+
return Promise.resolve(lastConnAck);
|
|
517
|
+
if (connectPromise !== undefined) return connectPromise;
|
|
518
|
+
connectPromise = ensureOpen()
|
|
519
|
+
.then(() => bindNative().connect(instanceId))
|
|
520
|
+
.then((value) => {
|
|
521
|
+
emitNativeLifecycleSnapshot(value.snapshot);
|
|
522
|
+
flushOfflineRemovals();
|
|
523
|
+
lastConnAck = connAckFromWire(value.ack);
|
|
524
|
+
return lastConnAck;
|
|
525
|
+
})
|
|
526
|
+
.catch((error: unknown) => {
|
|
527
|
+
connectPromise = undefined;
|
|
528
|
+
throw mapError(error, 'connect');
|
|
529
|
+
});
|
|
530
|
+
return connectPromise;
|
|
531
|
+
},
|
|
532
|
+
disconnect: () => {
|
|
533
|
+
if (snapshot.state === 'closed') return Promise.resolve();
|
|
534
|
+
ensureUsable();
|
|
535
|
+
if (disconnectPromise !== undefined) return disconnectPromise;
|
|
536
|
+
if (snapshot.state === 'idle' || snapshot.state === 'disconnected') {
|
|
537
|
+
// A native disconnect callback may have settled the lifecycle before
|
|
538
|
+
// this call reaches JS. Do not retain the resolved connect promise or
|
|
539
|
+
// a later explicit connect would incorrectly return the old ACK.
|
|
540
|
+
connectPromise = undefined;
|
|
541
|
+
return Promise.resolve();
|
|
542
|
+
}
|
|
543
|
+
emitSnapshot({
|
|
544
|
+
...snapshot,
|
|
545
|
+
state: 'disconnecting',
|
|
546
|
+
revision: snapshot.revision + 1,
|
|
547
|
+
});
|
|
548
|
+
disconnectPromise = trackNativeOperation(
|
|
549
|
+
ensureOpen()
|
|
550
|
+
.then(() => bindNative().disconnect(instanceId))
|
|
551
|
+
.then((value) => {
|
|
552
|
+
emitNativeLifecycleSnapshot(value);
|
|
553
|
+
})
|
|
554
|
+
.catch((error: unknown) => {
|
|
555
|
+
throw mapError(error, 'disconnect');
|
|
556
|
+
})
|
|
557
|
+
.finally(() => {
|
|
558
|
+
disconnectPromise = undefined;
|
|
559
|
+
connectPromise = undefined;
|
|
560
|
+
})
|
|
561
|
+
);
|
|
562
|
+
return disconnectPromise;
|
|
563
|
+
},
|
|
564
|
+
dispose: () => {
|
|
565
|
+
if (disposePromise !== undefined) return disposePromise;
|
|
566
|
+
if (snapshot.state === 'closed') return Promise.resolve();
|
|
567
|
+
const pendingOperations = [...pendingIntentOperations];
|
|
568
|
+
const pendingNativeOperationsAtDispose = [...pendingNativeOperations];
|
|
569
|
+
disposePromise = (openPromise ?? Promise.resolve())
|
|
570
|
+
.catch(() => undefined)
|
|
571
|
+
.then(() =>
|
|
572
|
+
Promise.all(
|
|
573
|
+
[...pendingOperations, ...pendingNativeOperationsAtDispose].map(
|
|
574
|
+
(operation) => operation.catch(() => undefined)
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
.then(() =>
|
|
579
|
+
NativeReactNativeMqtt == null
|
|
580
|
+
? undefined
|
|
581
|
+
: NativeReactNativeMqtt.close(instanceId)
|
|
582
|
+
)
|
|
583
|
+
.then((value) => {
|
|
584
|
+
if (value !== undefined) emitNativeLifecycleSnapshot(value);
|
|
585
|
+
else
|
|
586
|
+
emitSnapshot({
|
|
587
|
+
...snapshot,
|
|
588
|
+
state: 'closed',
|
|
589
|
+
deliveryStatus: 'closed',
|
|
590
|
+
revision: snapshot.revision + 1,
|
|
591
|
+
});
|
|
592
|
+
})
|
|
593
|
+
.catch((error: unknown) => {
|
|
594
|
+
throw mapError(error, 'close');
|
|
595
|
+
})
|
|
596
|
+
.finally(() => {
|
|
597
|
+
for (const timer of ackTimers.values()) clearTimeout(timer);
|
|
598
|
+
ackTimers.clear();
|
|
599
|
+
for (const timer of ackRetryTimers.values()) clearTimeout(timer);
|
|
600
|
+
ackRetryTimers.clear();
|
|
601
|
+
ackRetryAttempts.clear();
|
|
602
|
+
for (const subscription of nativeListeners) {
|
|
603
|
+
if (typeof subscription === 'function') subscription();
|
|
604
|
+
else subscription.remove();
|
|
605
|
+
}
|
|
606
|
+
nativeListeners.length = 0;
|
|
607
|
+
stateListeners.clear();
|
|
608
|
+
messageListeners.clear();
|
|
609
|
+
diagnosticListeners.clear();
|
|
610
|
+
});
|
|
611
|
+
return disposePromise;
|
|
612
|
+
},
|
|
613
|
+
ready: () => {
|
|
614
|
+
ensureUsable();
|
|
615
|
+
if (snapshot.state !== 'connected')
|
|
616
|
+
return Promise.reject(
|
|
617
|
+
new MqttError('ERR_NOT_CONNECTED', 'Client is not connected', {
|
|
618
|
+
outcome: 'not-sent',
|
|
619
|
+
})
|
|
620
|
+
);
|
|
621
|
+
const targetGeneration = snapshot.generation;
|
|
622
|
+
const targetRevision = Math.max(
|
|
623
|
+
nextIntentRevision,
|
|
624
|
+
snapshot.subscriptionsRevision
|
|
625
|
+
);
|
|
626
|
+
const operations = [...pendingIntentOperations];
|
|
627
|
+
return ensureOpen()
|
|
628
|
+
.then(() => Promise.all(operations))
|
|
629
|
+
.then(() =>
|
|
630
|
+
bindNative().awaitSubscriptions(
|
|
631
|
+
instanceId,
|
|
632
|
+
targetGeneration,
|
|
633
|
+
targetRevision
|
|
634
|
+
)
|
|
635
|
+
)
|
|
636
|
+
.then((value) => {
|
|
637
|
+
emitSnapshot(snapshotFromWire(value));
|
|
638
|
+
});
|
|
639
|
+
},
|
|
640
|
+
getSnapshot: () => {
|
|
641
|
+
if (snapshot.state === 'closed' || (!opened && openPromise === undefined))
|
|
642
|
+
return Promise.resolve(snapshot);
|
|
643
|
+
return ensureOpen()
|
|
644
|
+
.then(() => bindNative().getSnapshot(instanceId))
|
|
645
|
+
.then((value) => {
|
|
646
|
+
const next = snapshotFromWire(value);
|
|
647
|
+
emitSnapshot(next);
|
|
648
|
+
return next;
|
|
649
|
+
})
|
|
650
|
+
.catch((error: unknown) => {
|
|
651
|
+
throw mapError(error, 'getSnapshot');
|
|
652
|
+
});
|
|
653
|
+
},
|
|
654
|
+
subscribe: (topicFilter, inputOptions) => {
|
|
655
|
+
ensureUsable();
|
|
656
|
+
validateTopicFilter(topicFilter);
|
|
657
|
+
pendingOfflineRemovals.delete(topicFilter);
|
|
658
|
+
const subscribeOptions = normalizeSubscribeOptions(
|
|
659
|
+
inputOptions,
|
|
660
|
+
options.protocolVersion ?? 4
|
|
661
|
+
);
|
|
662
|
+
const existing = subscriptions.get(topicFilter);
|
|
663
|
+
const createHandle = (record: {
|
|
664
|
+
options: SubscribeOptions;
|
|
665
|
+
refs: number;
|
|
666
|
+
qos: Subscription['qos'];
|
|
667
|
+
}): Subscription => {
|
|
668
|
+
record.refs += 1;
|
|
669
|
+
let released = false;
|
|
670
|
+
let releasePromise: Promise<void> | undefined;
|
|
671
|
+
return {
|
|
672
|
+
topicFilter,
|
|
673
|
+
get qos() {
|
|
674
|
+
return record.qos;
|
|
675
|
+
},
|
|
676
|
+
unsubscribe: async () => {
|
|
677
|
+
if (released) return releasePromise;
|
|
678
|
+
released = true;
|
|
679
|
+
if (record.refs > 0) record.refs -= 1;
|
|
680
|
+
if (record.refs !== 0) return;
|
|
681
|
+
subscriptions.delete(topicFilter);
|
|
682
|
+
const intentRevision = allocateIntentRevision();
|
|
683
|
+
if (snapshot.state !== 'connected') {
|
|
684
|
+
const removal = bindNative()
|
|
685
|
+
.removeSubscription(instanceId, topicFilter, intentRevision)
|
|
686
|
+
.then(() => undefined)
|
|
687
|
+
.catch((error: unknown) => {
|
|
688
|
+
pendingOfflineRemovals.set(topicFilter, intentRevision);
|
|
689
|
+
released = false;
|
|
690
|
+
record.refs = 1;
|
|
691
|
+
subscriptions.set(topicFilter, record);
|
|
692
|
+
throw error;
|
|
693
|
+
});
|
|
694
|
+
releasePromise = trackIntentOperation(removal).catch(
|
|
695
|
+
(error: unknown) => {
|
|
696
|
+
throw mapError(error, 'unsubscribe');
|
|
697
|
+
}
|
|
698
|
+
);
|
|
699
|
+
return releasePromise;
|
|
700
|
+
}
|
|
701
|
+
const removal = bindNative()
|
|
702
|
+
.removeSubscription(instanceId, topicFilter, intentRevision)
|
|
703
|
+
.then(() => undefined)
|
|
704
|
+
.catch((error: unknown) => {
|
|
705
|
+
released = false;
|
|
706
|
+
record.refs = 1;
|
|
707
|
+
subscriptions.set(topicFilter, record);
|
|
708
|
+
throw error;
|
|
709
|
+
});
|
|
710
|
+
releasePromise = trackIntentOperation(removal).catch(
|
|
711
|
+
(error: unknown) => {
|
|
712
|
+
throw mapError(error, 'unsubscribe');
|
|
713
|
+
}
|
|
714
|
+
);
|
|
715
|
+
return releasePromise;
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
};
|
|
719
|
+
if (existing !== undefined) {
|
|
720
|
+
if (
|
|
721
|
+
JSON.stringify(existing.options) !== JSON.stringify(subscribeOptions)
|
|
722
|
+
)
|
|
723
|
+
return Promise.reject(
|
|
724
|
+
new MqttError(
|
|
725
|
+
'ERR_SUBSCRIPTION_CONFLICT',
|
|
726
|
+
'Subscription options conflict for this filter',
|
|
727
|
+
{ outcome: 'not-sent' }
|
|
728
|
+
)
|
|
729
|
+
);
|
|
730
|
+
return Promise.resolve(createHandle(existing));
|
|
731
|
+
}
|
|
732
|
+
const pending = pendingSubscriptions.get(topicFilter);
|
|
733
|
+
if (pending !== undefined) {
|
|
734
|
+
if (
|
|
735
|
+
JSON.stringify(pending.options) !== JSON.stringify(subscribeOptions)
|
|
736
|
+
)
|
|
737
|
+
return Promise.reject(
|
|
738
|
+
new MqttError(
|
|
739
|
+
'ERR_SUBSCRIPTION_CONFLICT',
|
|
740
|
+
'Subscription options conflict for this filter',
|
|
741
|
+
{ outcome: 'not-sent' }
|
|
742
|
+
)
|
|
743
|
+
);
|
|
744
|
+
return pending.promise.then(() => {
|
|
745
|
+
const record = subscriptions.get(topicFilter);
|
|
746
|
+
if (record === undefined)
|
|
747
|
+
throw new MqttError('ERR_UNKNOWN', 'Subscription request lost', {
|
|
748
|
+
outcome: 'unknown',
|
|
749
|
+
});
|
|
750
|
+
return createHandle(record);
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
const intentRevision = allocateIntentRevision();
|
|
754
|
+
const promise = trackIntentOperation(
|
|
755
|
+
ensureOpen()
|
|
756
|
+
.then(() =>
|
|
757
|
+
bindNative().setSubscription(
|
|
758
|
+
instanceId,
|
|
759
|
+
topicFilter,
|
|
760
|
+
toWireSubscribeOptions(subscribeOptions),
|
|
761
|
+
intentRevision
|
|
762
|
+
)
|
|
763
|
+
)
|
|
764
|
+
.then((result: WireSubscriptionResult) => {
|
|
765
|
+
if (disposePromise !== undefined)
|
|
766
|
+
throw new MqttError('ERR_DISPOSED', 'Client is disposed', {
|
|
767
|
+
outcome: 'not-sent',
|
|
768
|
+
});
|
|
769
|
+
nextIntentRevision = Math.max(
|
|
770
|
+
nextIntentRevision,
|
|
771
|
+
result.intentRevision
|
|
772
|
+
);
|
|
773
|
+
const record = {
|
|
774
|
+
options: subscribeOptions,
|
|
775
|
+
refs: 0,
|
|
776
|
+
qos: result.grantedQos as Subscription['qos'],
|
|
777
|
+
};
|
|
778
|
+
subscriptions.set(topicFilter, record);
|
|
779
|
+
return result.grantedQos;
|
|
780
|
+
})
|
|
781
|
+
);
|
|
782
|
+
pendingSubscriptions.set(topicFilter, {
|
|
783
|
+
options: subscribeOptions,
|
|
784
|
+
promise,
|
|
785
|
+
});
|
|
786
|
+
return promise
|
|
787
|
+
.then(() => {
|
|
788
|
+
const record = subscriptions.get(topicFilter);
|
|
789
|
+
if (record === undefined)
|
|
790
|
+
throw new MqttError('ERR_UNKNOWN', 'Subscription request lost', {
|
|
791
|
+
outcome: 'unknown',
|
|
792
|
+
});
|
|
793
|
+
return createHandle(record);
|
|
794
|
+
})
|
|
795
|
+
.catch((error: unknown) => {
|
|
796
|
+
throw mapError(error, 'subscribe');
|
|
797
|
+
})
|
|
798
|
+
.finally(() => {
|
|
799
|
+
if (pendingSubscriptions.get(topicFilter)?.promise === promise)
|
|
800
|
+
pendingSubscriptions.delete(topicFilter);
|
|
801
|
+
});
|
|
802
|
+
},
|
|
803
|
+
publishText: (topic, text, publishOptions) => {
|
|
804
|
+
ensureUsable();
|
|
805
|
+
const payload = encodeUtf8(text);
|
|
806
|
+
return client.publishBytes(topic, payload, publishOptions);
|
|
807
|
+
},
|
|
808
|
+
publishBytes: (topic, bytes, publishOptions) => {
|
|
809
|
+
ensureUsable();
|
|
810
|
+
const copy = new Uint8Array(bytes);
|
|
811
|
+
return trackNativeOperation(
|
|
812
|
+
ensureOpen()
|
|
813
|
+
.then(() =>
|
|
814
|
+
bindNative().publish(
|
|
815
|
+
instanceId,
|
|
816
|
+
toWirePublishRequest(
|
|
817
|
+
topic,
|
|
818
|
+
encodeBase64(copy),
|
|
819
|
+
publishOptions,
|
|
820
|
+
options.protocolVersion ?? 4
|
|
821
|
+
)
|
|
822
|
+
)
|
|
823
|
+
)
|
|
824
|
+
.catch((error: unknown) => {
|
|
825
|
+
throw mapError(error, 'publish');
|
|
826
|
+
})
|
|
827
|
+
);
|
|
828
|
+
},
|
|
829
|
+
publishBase64: (topic, base64, publishOptions) => {
|
|
830
|
+
ensureUsable();
|
|
831
|
+
return trackNativeOperation(
|
|
832
|
+
ensureOpen()
|
|
833
|
+
.then(() =>
|
|
834
|
+
bindNative().publish(
|
|
835
|
+
instanceId,
|
|
836
|
+
toWirePublishRequest(
|
|
837
|
+
topic,
|
|
838
|
+
base64,
|
|
839
|
+
publishOptions,
|
|
840
|
+
options.protocolVersion ?? 4
|
|
841
|
+
)
|
|
842
|
+
)
|
|
843
|
+
)
|
|
844
|
+
.catch((error: unknown) => {
|
|
845
|
+
throw mapError(error, 'publish');
|
|
846
|
+
})
|
|
847
|
+
);
|
|
848
|
+
},
|
|
849
|
+
onStateChange: (value) => listener(stateListeners, value),
|
|
850
|
+
onMessage: (value) => listener(messageListeners, value),
|
|
851
|
+
onDiagnostic: (value) => listener(diagnosticListeners, value),
|
|
852
|
+
};
|
|
853
|
+
if (Symbol.asyncDispose !== undefined)
|
|
854
|
+
Object.defineProperty(client, Symbol.asyncDispose, {
|
|
855
|
+
value: () => client.dispose(),
|
|
856
|
+
enumerable: false,
|
|
857
|
+
});
|
|
858
|
+
return client;
|
|
859
|
+
}
|