@livekit/rtc-node 0.13.26 → 0.13.28
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/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/participant.cjs +3 -0
- package/dist/participant.cjs.map +1 -1
- package/dist/participant.d.cts +2 -1
- package/dist/participant.d.ts +2 -1
- package/dist/participant.d.ts.map +1 -1
- package/dist/participant.js +3 -0
- package/dist/participant.js.map +1 -1
- package/dist/room.cjs +56 -12
- package/dist/room.cjs.map +1 -1
- package/dist/room.d.cts +21 -2
- package/dist/room.d.ts +21 -2
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +56 -12
- package/dist/room.js.map +1 -1
- package/dist/track_publication.cjs +11 -0
- package/dist/track_publication.cjs.map +1 -1
- package/dist/track_publication.d.cts +9 -0
- package/dist/track_publication.d.ts +9 -0
- package/dist/track_publication.d.ts.map +1 -1
- package/dist/track_publication.js +11 -0
- package/dist/track_publication.js.map +1 -1
- package/dist/version.cjs +1 -1
- package/dist/version.cjs.map +1 -1
- package/dist/version.d.cts +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +5 -4
- package/src/index.ts +11 -2
- package/src/participant.ts +5 -0
- package/src/room.ts +80 -17
- package/src/tests/e2e.test.ts +157 -0
- package/src/track_publication.ts +12 -0
- package/src/version.ts +1 -1
package/src/room.ts
CHANGED
|
@@ -5,7 +5,11 @@ import { Mutex } from '@livekit/mutex';
|
|
|
5
5
|
import { EncryptionState, type EncryptionType } from '@livekit/rtc-ffi-bindings';
|
|
6
6
|
import type { FfiEvent } from '@livekit/rtc-ffi-bindings';
|
|
7
7
|
import { DisconnectReason, type OwnedParticipant } from '@livekit/rtc-ffi-bindings';
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
DataStream_Trailer,
|
|
10
|
+
DisconnectCallback,
|
|
11
|
+
TrackPublicationInfo,
|
|
12
|
+
} from '@livekit/rtc-ffi-bindings';
|
|
9
13
|
import {
|
|
10
14
|
type ConnectCallback,
|
|
11
15
|
ConnectRequest,
|
|
@@ -21,6 +25,9 @@ import {
|
|
|
21
25
|
type IceServer,
|
|
22
26
|
IceTransportType,
|
|
23
27
|
type RoomInfo,
|
|
28
|
+
type SimulateScenarioCallback,
|
|
29
|
+
type SimulateScenarioKind,
|
|
30
|
+
type SimulateScenarioResponse,
|
|
24
31
|
} from '@livekit/rtc-ffi-bindings';
|
|
25
32
|
import { TrackKind } from '@livekit/rtc-ffi-bindings';
|
|
26
33
|
import type { TypedEventEmitter as TypedEmitter } from '@livekit/typed-emitter';
|
|
@@ -107,9 +114,9 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
107
114
|
|
|
108
115
|
private _token?: string;
|
|
109
116
|
private _serverUrl?: string;
|
|
117
|
+
private _connectionState: ConnectionState = ConnectionState.CONN_DISCONNECTED;
|
|
110
118
|
|
|
111
119
|
e2eeManager?: E2EEManager;
|
|
112
|
-
connectionState: ConnectionState = ConnectionState.CONN_DISCONNECTED;
|
|
113
120
|
|
|
114
121
|
remoteParticipants: Map<string, RemoteParticipant> = new Map();
|
|
115
122
|
localParticipant?: LocalParticipant;
|
|
@@ -118,6 +125,10 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
118
125
|
super();
|
|
119
126
|
}
|
|
120
127
|
|
|
128
|
+
get connectionState() {
|
|
129
|
+
return this._connectionState;
|
|
130
|
+
}
|
|
131
|
+
|
|
121
132
|
get name(): string | undefined {
|
|
122
133
|
return this.info?.name;
|
|
123
134
|
}
|
|
@@ -262,7 +273,6 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
262
273
|
this._token = token;
|
|
263
274
|
this._serverUrl = url;
|
|
264
275
|
this.info = cb.message.value.room!.info;
|
|
265
|
-
this.connectionState = ConnectionState.CONN_CONNECTED;
|
|
266
276
|
// Reset the abort controller for this connection session so that
|
|
267
277
|
// a previous disconnect doesn't immediately cancel new operations.
|
|
268
278
|
this.disconnectController = new AbortController();
|
|
@@ -281,6 +291,7 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
281
291
|
rp.trackPublications.set(publication.sid!, publication);
|
|
282
292
|
}
|
|
283
293
|
}
|
|
294
|
+
this.updateConnectionState(ConnectionState.CONN_CONNECTED);
|
|
284
295
|
break;
|
|
285
296
|
case 'error':
|
|
286
297
|
default:
|
|
@@ -321,6 +332,44 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
321
332
|
this.removeAllListeners();
|
|
322
333
|
}
|
|
323
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Trigger a reconnection / chaos scenario for testing. Most useful in
|
|
337
|
+
* tests to deterministically force a Resume (signal-only reconnect that
|
|
338
|
+
* preserves the PeerConnection and existing publications) or a full
|
|
339
|
+
* reconnect (the SDK rebuilds the RtcSession and re-publishes existing
|
|
340
|
+
* local tracks; `RoomEvent.Reconnected` fires).
|
|
341
|
+
*/
|
|
342
|
+
async simulateScenario(scenario: SimulateScenarioKind): Promise<void> {
|
|
343
|
+
if (!this.isConnected || !this.ffiHandle) {
|
|
344
|
+
throw new Error('simulateScenario requires a connected room');
|
|
345
|
+
}
|
|
346
|
+
const res = FfiClient.instance.request<SimulateScenarioResponse>({
|
|
347
|
+
message: {
|
|
348
|
+
case: 'simulateScenario',
|
|
349
|
+
value: {
|
|
350
|
+
roomHandle: this.ffiHandle.handle,
|
|
351
|
+
scenario,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
const cb = await FfiClient.instance.waitFor<SimulateScenarioCallback>(
|
|
356
|
+
(ev: FfiEvent) =>
|
|
357
|
+
ev.message.case === 'simulateScenario' && ev.message.value.asyncId === res.asyncId,
|
|
358
|
+
{ signal: this.disconnectController.signal },
|
|
359
|
+
);
|
|
360
|
+
if (cb.error) {
|
|
361
|
+
throw new Error(`simulateScenario failed: ${cb.error}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private updateConnectionState(newState: ConnectionState) {
|
|
366
|
+
if (this._connectionState === newState) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
this._connectionState = newState;
|
|
370
|
+
this.emit(RoomEvent.ConnectionStateChanged, this._connectionState);
|
|
371
|
+
}
|
|
372
|
+
|
|
324
373
|
// Runs at most once per connection session. The FFI layer and explicit
|
|
325
374
|
// disconnect() both race to get here — whichever wins emits the events,
|
|
326
375
|
// the other is a no-op. A reconnect via connect() clears hasCleanedUp.
|
|
@@ -359,12 +408,7 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
359
408
|
// to reject and clean up their event listeners.
|
|
360
409
|
this.disconnectController.abort();
|
|
361
410
|
|
|
362
|
-
|
|
363
|
-
// path didn't already flip us to DISCONNECTED.
|
|
364
|
-
if (this.connectionState !== ConnectionState.CONN_DISCONNECTED) {
|
|
365
|
-
this.connectionState = ConnectionState.CONN_DISCONNECTED;
|
|
366
|
-
this.emit(RoomEvent.ConnectionStateChanged, this.connectionState);
|
|
367
|
-
}
|
|
411
|
+
this.updateConnectionState(ConnectionState.CONN_DISCONNECTED);
|
|
368
412
|
this.emit(RoomEvent.Disconnected, reason);
|
|
369
413
|
}
|
|
370
414
|
|
|
@@ -474,6 +518,19 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
474
518
|
const publication = this.localParticipant.trackPublications.get(ev.value.publicationSid!);
|
|
475
519
|
this.localParticipant.trackPublications.delete(ev.value.publicationSid!);
|
|
476
520
|
this.emit(RoomEvent.LocalTrackUnpublished, publication!, this.localParticipant!);
|
|
521
|
+
} else if ((ev.case as string) == 'localTrackRepublished') {
|
|
522
|
+
const value = (ev as any).value;
|
|
523
|
+
const previousSid: string = value.previousSid!;
|
|
524
|
+
const newInfo: TrackPublicationInfo = value.info!;
|
|
525
|
+
const publication = this.localParticipant.trackPublications.get(previousSid);
|
|
526
|
+
if (publication) {
|
|
527
|
+
publication.updateInfo(newInfo);
|
|
528
|
+
this.localParticipant.trackPublications.delete(previousSid);
|
|
529
|
+
this.localParticipant.trackPublications.set(publication.sid!, publication);
|
|
530
|
+
this.emit(RoomEvent.LocalTrackRepublished, publication, previousSid, this.localParticipant);
|
|
531
|
+
} else {
|
|
532
|
+
log.warn(`RoomEvent.LocalTrackRepublished: previous publication not found: ${previousSid}`);
|
|
533
|
+
}
|
|
477
534
|
} else if (ev.case == 'localTrackSubscribed') {
|
|
478
535
|
const publication = this.localParticipant.trackPublications.get(ev.value.trackSid!);
|
|
479
536
|
if (publication) {
|
|
@@ -678,14 +735,7 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
678
735
|
this.emit(RoomEvent.EncryptionError, new Error('internal server error'));
|
|
679
736
|
}
|
|
680
737
|
} else if (ev.case == 'connectionStateChanged') {
|
|
681
|
-
|
|
682
|
-
// Skip redundant transitions — cleanupOnDisconnect may have already
|
|
683
|
-
// flipped us to DISCONNECTED, and we don't want to emit the event twice.
|
|
684
|
-
if (this.connectionState === newState) {
|
|
685
|
-
return;
|
|
686
|
-
}
|
|
687
|
-
this.connectionState = newState;
|
|
688
|
-
this.emit(RoomEvent.ConnectionStateChanged, this.connectionState);
|
|
738
|
+
this.updateConnectionState(ev.value.state!);
|
|
689
739
|
/*} else if (ev.case == 'connected') {
|
|
690
740
|
this.emit(RoomEvent.Connected);*/
|
|
691
741
|
} else if (ev.case == 'disconnected') {
|
|
@@ -903,6 +953,18 @@ export type RoomCallbacks = {
|
|
|
903
953
|
publication: LocalTrackPublication,
|
|
904
954
|
participant: LocalParticipant,
|
|
905
955
|
) => void;
|
|
956
|
+
/**
|
|
957
|
+
* Fired when the SDK auto-republished a local track during a full
|
|
958
|
+
* reconnect. The publication object's identity is preserved (the same
|
|
959
|
+
* instance is updated in place with the new server-assigned SIDs);
|
|
960
|
+
* `previousSid` is provided for callers that key external state on the
|
|
961
|
+
* old SID and need to reconcile.
|
|
962
|
+
*/
|
|
963
|
+
localTrackRepublished: (
|
|
964
|
+
publication: LocalTrackPublication,
|
|
965
|
+
previousSid: string,
|
|
966
|
+
participant: LocalParticipant,
|
|
967
|
+
) => void;
|
|
906
968
|
localTrackSubscribed: (track: LocalTrack) => void;
|
|
907
969
|
trackPublished: (publication: RemoteTrackPublication, participant: RemoteParticipant) => void;
|
|
908
970
|
trackUnpublished: (publication: RemoteTrackPublication, participant: RemoteParticipant) => void;
|
|
@@ -960,6 +1022,7 @@ export enum RoomEvent {
|
|
|
960
1022
|
ParticipantDisconnected = 'participantDisconnected',
|
|
961
1023
|
LocalTrackPublished = 'localTrackPublished',
|
|
962
1024
|
LocalTrackUnpublished = 'localTrackUnpublished',
|
|
1025
|
+
LocalTrackRepublished = 'localTrackRepublished',
|
|
963
1026
|
LocalTrackSubscribed = 'localTrackSubscribed',
|
|
964
1027
|
TrackPublished = 'trackPublished',
|
|
965
1028
|
TrackUnpublished = 'trackUnpublished',
|
package/src/tests/e2e.test.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
Room,
|
|
16
16
|
RoomEvent,
|
|
17
17
|
RpcError,
|
|
18
|
+
SimulateScenarioKind,
|
|
19
|
+
TrackKind,
|
|
18
20
|
TrackPublishOptions,
|
|
19
21
|
TrackSource,
|
|
20
22
|
dispose,
|
|
@@ -682,4 +684,159 @@ describeE2E('livekit-rtc e2e', () => {
|
|
|
682
684
|
},
|
|
683
685
|
testTimeoutMs,
|
|
684
686
|
);
|
|
687
|
+
|
|
688
|
+
// -- Reconnect scenarios --
|
|
689
|
+
//
|
|
690
|
+
// Both tests verify the user-visible behavior: after the scenario fires,
|
|
691
|
+
// the subscriber continues to receive the publisher's tone. The full
|
|
692
|
+
// reconnect test additionally asserts there is exactly one audio
|
|
693
|
+
// publication on each side (regression: duplicate-publish bug).
|
|
694
|
+
|
|
695
|
+
const runReconnectScenario = async (scenario: SimulateScenarioKind) => {
|
|
696
|
+
const { rooms } = await connectTestRooms(2);
|
|
697
|
+
const [subRoom, pubRoom] = rooms;
|
|
698
|
+
|
|
699
|
+
const pubRateHz = 48_000;
|
|
700
|
+
const source = new AudioSource(pubRateHz, 1);
|
|
701
|
+
const track = LocalAudioTrack.createAudioTrack('reconnect_tone', source);
|
|
702
|
+
const opts = new TrackPublishOptions();
|
|
703
|
+
opts.source = TrackSource.SOURCE_MICROPHONE;
|
|
704
|
+
await pubRoom!.localParticipant!.publishTrack(track, opts);
|
|
705
|
+
|
|
706
|
+
let tonePhase = 0;
|
|
707
|
+
const samplesPer10ms = Math.floor(pubRateHz / 100);
|
|
708
|
+
const amplitude = 0.8 * 32767;
|
|
709
|
+
const sineHz = 60;
|
|
710
|
+
let toneRunning = true;
|
|
711
|
+
const toneTask = (async () => {
|
|
712
|
+
while (toneRunning) {
|
|
713
|
+
const frame = AudioFrame.create(pubRateHz, 1, samplesPer10ms);
|
|
714
|
+
for (let s = 0; s < samplesPer10ms; s++) {
|
|
715
|
+
frame.data[s] = Math.round(
|
|
716
|
+
amplitude * Math.sin((2 * Math.PI * sineHz * tonePhase) / pubRateHz),
|
|
717
|
+
);
|
|
718
|
+
tonePhase++;
|
|
719
|
+
}
|
|
720
|
+
await source.captureFrame(frame);
|
|
721
|
+
}
|
|
722
|
+
})();
|
|
723
|
+
|
|
724
|
+
// Subscriber-side: re-attach an AudioStream every time TrackSubscribed
|
|
725
|
+
// fires (a full reconnect may issue TrackUnsubscribed → TrackSubscribed
|
|
726
|
+
// with a fresh remote track).
|
|
727
|
+
const sub = {
|
|
728
|
+
lastFrameAt: 0,
|
|
729
|
+
collectFromMs: Number.POSITIVE_INFINITY,
|
|
730
|
+
collected: [] as Int16Array[],
|
|
731
|
+
readers: [] as ReturnType<AudioStream['getReader']>[],
|
|
732
|
+
};
|
|
733
|
+
const attach = (remoteTrack: unknown) => {
|
|
734
|
+
const stream = new AudioStream(remoteTrack as any, {
|
|
735
|
+
sampleRate: pubRateHz,
|
|
736
|
+
numChannels: 1,
|
|
737
|
+
});
|
|
738
|
+
const reader = stream.getReader();
|
|
739
|
+
sub.readers.push(reader);
|
|
740
|
+
(async () => {
|
|
741
|
+
try {
|
|
742
|
+
while (true) {
|
|
743
|
+
const { done, value } = await reader.read();
|
|
744
|
+
if (done) break;
|
|
745
|
+
sub.lastFrameAt = Date.now();
|
|
746
|
+
if (sub.lastFrameAt >= sub.collectFromMs) {
|
|
747
|
+
sub.collected.push(channelSamples(value, 0));
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
} catch {
|
|
751
|
+
// reader released
|
|
752
|
+
}
|
|
753
|
+
})();
|
|
754
|
+
};
|
|
755
|
+
subRoom!.on(RoomEvent.TrackSubscribed, (t) => attach(t));
|
|
756
|
+
|
|
757
|
+
try {
|
|
758
|
+
await waitFor(() => sub.lastFrameAt > 0 && Date.now() - sub.lastFrameAt < 500, {
|
|
759
|
+
timeoutMs: 10_000,
|
|
760
|
+
debugName: 'initial audio flow',
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
const simulateAt = Date.now();
|
|
764
|
+
await pubRoom!.simulateScenario(scenario);
|
|
765
|
+
|
|
766
|
+
// Wait for audio to actually flow again post-simulate: a frame
|
|
767
|
+
// received well after the simulate AND a fresh latest-frame timestamp.
|
|
768
|
+
await waitFor(
|
|
769
|
+
() => sub.lastFrameAt >= simulateAt + 500 && Date.now() - sub.lastFrameAt < 300,
|
|
770
|
+
{ timeoutMs: 30_000, debugName: 'audio re-established after simulate' },
|
|
771
|
+
);
|
|
772
|
+
// Drain post-recovery buffer/jitter, then collect a 2s window of
|
|
773
|
+
// steady-state samples for tone detection.
|
|
774
|
+
await delay(1_500);
|
|
775
|
+
sub.collected.length = 0;
|
|
776
|
+
sub.collectFromMs = Date.now();
|
|
777
|
+
await waitFor(() => sub.collected.reduce((a, s) => a + s.length, 0) >= pubRateHz * 2, {
|
|
778
|
+
timeoutMs: 15_000,
|
|
779
|
+
debugName: 'post-simulate audio sampling',
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const totalLen = sub.collected.reduce((a, s) => a + s.length, 0);
|
|
783
|
+
const concat = new Int16Array(totalLen);
|
|
784
|
+
let off = 0;
|
|
785
|
+
for (const s of sub.collected) {
|
|
786
|
+
concat.set(s, off);
|
|
787
|
+
off += s.length;
|
|
788
|
+
}
|
|
789
|
+
const detected = estimateFreqHz(concat, pubRateHz);
|
|
790
|
+
// Wider tolerance than the clean-path sine test: post-reconnect
|
|
791
|
+
// audio has brief discontinuities, and the autocorrelation is
|
|
792
|
+
// integer-lag (next neighbors to 60Hz are exactly 80Hz/40Hz), so
|
|
793
|
+
// ±20Hz lands right on the failure boundary under CI load.
|
|
794
|
+
expect(Math.abs(detected - sineHz)).toBeLessThan(25);
|
|
795
|
+
|
|
796
|
+
return { rooms, subRoom: subRoom!, pubRoom: pubRoom! };
|
|
797
|
+
} finally {
|
|
798
|
+
toneRunning = false;
|
|
799
|
+
await toneTask;
|
|
800
|
+
for (const r of sub.readers) {
|
|
801
|
+
try {
|
|
802
|
+
r.releaseLock();
|
|
803
|
+
} catch {
|
|
804
|
+
// ignore
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
await track.close();
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
itRaw(
|
|
812
|
+
'resume keeps audio flowing on the subscriber side',
|
|
813
|
+
async () => {
|
|
814
|
+
const { rooms } = await runReconnectScenario(SimulateScenarioKind.SIMULATE_SIGNAL_RECONNECT);
|
|
815
|
+
await Promise.all(rooms.map((r) => r.disconnect()));
|
|
816
|
+
},
|
|
817
|
+
testTimeoutMs * 4,
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
itRaw(
|
|
821
|
+
'full reconnect keeps audio flowing and ends with one publication on the subscriber',
|
|
822
|
+
async () => {
|
|
823
|
+
const { rooms, subRoom, pubRoom } = await runReconnectScenario(
|
|
824
|
+
SimulateScenarioKind.SIMULATE_FULL_RECONNECT,
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
try {
|
|
828
|
+
// Regression: subscriber must see exactly ONE audio publication after
|
|
829
|
+
// recovery — not duplicates from the auto-republish path.
|
|
830
|
+
const subscriberAudioPubs = Array.from(
|
|
831
|
+
subRoom.remoteParticipants
|
|
832
|
+
.get(pubRoom.localParticipant!.identity)!
|
|
833
|
+
.trackPublications.values(),
|
|
834
|
+
).filter((p) => p.kind === TrackKind.KIND_AUDIO);
|
|
835
|
+
expect(subscriberAudioPubs.length).toBe(1);
|
|
836
|
+
} finally {
|
|
837
|
+
await Promise.all(rooms.map((r) => r.disconnect()));
|
|
838
|
+
}
|
|
839
|
+
},
|
|
840
|
+
testTimeoutMs * 4,
|
|
841
|
+
);
|
|
685
842
|
});
|
package/src/track_publication.ts
CHANGED
|
@@ -66,6 +66,18 @@ export abstract class TrackPublication {
|
|
|
66
66
|
get encryptionType(): EncryptionType | undefined {
|
|
67
67
|
return this.info?.encryptionType;
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Update the publication's info in place. Used by the SDK when the
|
|
72
|
+
* server re-issues IDs / metadata for an existing publication (e.g.
|
|
73
|
+
* after a full reconnect). Application code holding a cached
|
|
74
|
+
* publication reference continues to read fresh values via the
|
|
75
|
+
* unchanged object identity.
|
|
76
|
+
* @internal
|
|
77
|
+
*/
|
|
78
|
+
updateInfo(info: TrackPublicationInfo): void {
|
|
79
|
+
this.info = info;
|
|
80
|
+
}
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
export class LocalTrackPublication extends TrackPublication {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = "0.13.
|
|
1
|
+
export const SDK_VERSION = "0.13.28";
|