reactor-effect-native 0.2.0 → 0.3.0-rc.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/Dockerfile +4 -2
- package/README.md +90 -15
- package/dist/_internal/bridge.d.ts +70 -14
- package/dist/_internal/bridge.d.ts.map +1 -1
- package/dist/_internal/bridge.js +290 -145
- package/dist/_internal/bridge.js.map +1 -1
- package/dist/_internal/isolated/child.d.ts +2 -0
- package/dist/_internal/isolated/child.d.ts.map +1 -0
- package/dist/_internal/isolated/child.js +176 -0
- package/dist/_internal/isolated/child.js.map +1 -0
- package/dist/_internal/isolated/host.d.ts +147 -0
- package/dist/_internal/isolated/host.d.ts.map +1 -0
- package/dist/_internal/isolated/host.js +645 -0
- package/dist/_internal/isolated/host.js.map +1 -0
- package/dist/_internal/isolated/protocol.d.ts +399 -0
- package/dist/_internal/isolated/protocol.d.ts.map +1 -0
- package/dist/_internal/isolated/protocol.js +250 -0
- package/dist/_internal/isolated/protocol.js.map +1 -0
- package/dist/_internal/peer.d.ts +68 -13
- package/dist/_internal/peer.d.ts.map +1 -1
- package/dist/_internal/peer.js +233 -157
- package/dist/_internal/peer.js.map +1 -1
- package/dist/index.d.ts +19 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -33
- package/dist/index.js.map +1 -1
- package/dist/isolated.d.ts +25 -0
- package/dist/isolated.d.ts.map +1 -0
- package/dist/isolated.js +39 -0
- package/dist/isolated.js.map +1 -0
- package/lib/darwin-arm64/libreactor_effect_native.dylib +0 -0
- package/lib/darwin-arm64/native-identity.json +4 -3
- package/lib/linux-x64/libreactor_effect_native.so +0 -0
- package/lib/linux-x64/native-identity.json +4 -3
- package/package.json +7 -3
- package/rust/Cargo.toml +108 -2
- package/rust/build.rs +253 -114
- package/rust/clippy.toml +7 -0
- package/rust/include/reactor_effect_native.h +101 -42
- package/rust/src/abi.rs +258 -0
- package/rust/src/error.rs +149 -0
- package/rust/src/ffi/memory.rs +256 -0
- package/rust/src/ffi/tests.rs +719 -0
- package/rust/src/ffi.rs +474 -0
- package/rust/src/lib.rs +36 -2341
- package/rust/src/peer/callbacks.rs +145 -0
- package/rust/src/peer/media.rs +221 -0
- package/rust/src/peer/owner/tests.rs +188 -0
- package/rust/src/peer/owner.rs +353 -0
- package/rust/src/peer/shared.rs +323 -0
- package/rust/src/peer/tests.rs +513 -0
- package/rust/src/peer.rs +189 -0
- package/rust/src/protocol/event.rs +238 -0
- package/rust/src/protocol/request.rs +347 -0
- package/rust/src/protocol/stats.rs +356 -0
- package/rust/src/protocol.rs +71 -0
- package/rust/src/sync/gate.rs +159 -0
- package/rust/src/sync/notifier.rs +136 -0
- package/rust/src/sync/queue.rs +384 -0
- package/rust/src/sync.rs +20 -0
- package/rust/src/test_support.rs +99 -0
- package/rust-toolchain.toml +7 -0
- package/scripts/stage.mjs +41 -1
package/dist/_internal/peer.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import * as Duration from "effect/Duration";
|
|
1
2
|
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Predicate from "effect/Predicate";
|
|
4
|
+
import * as Queue from "effect/Queue";
|
|
5
|
+
import * as Result from "effect/Result";
|
|
2
6
|
import * as Schema from "effect/Schema";
|
|
3
7
|
import * as Stream from "effect/Stream";
|
|
4
|
-
import { ReactorError,
|
|
8
|
+
import { IceFailed, Mapping, ReactorError, TransportFailed } from "reactor-effect-client";
|
|
5
9
|
import { Observations, errorOf } from "reactor-effect-client/host";
|
|
6
|
-
import { encodeNativeJson, encodeNativeText, NativeBridge, NativeCall, } from "./bridge.js";
|
|
10
|
+
import { encodeNativeJson, encodeNativeText, NativeBridge, NativeCall, nativeFailure, Ready, } from "./bridge.js";
|
|
7
11
|
const stateValues = new Set([
|
|
8
12
|
"new",
|
|
9
13
|
"connecting",
|
|
@@ -12,7 +16,6 @@ const stateValues = new Set([
|
|
|
12
16
|
"failed",
|
|
13
17
|
"closed",
|
|
14
18
|
]);
|
|
15
|
-
const isErrorCode = Schema.is(ErrorCode);
|
|
16
19
|
const statsBigInts = new Set([
|
|
17
20
|
"bytesSent",
|
|
18
21
|
"bytesReceived",
|
|
@@ -21,61 +24,66 @@ const statsBigInts = new Set([
|
|
|
21
24
|
"retransmittedPacketsSent",
|
|
22
25
|
"priority",
|
|
23
26
|
]);
|
|
24
|
-
|
|
27
|
+
// Bound on reading statistics to classify a failed connection.
|
|
28
|
+
const CLASSIFY_TIMEOUT_MS = 2000;
|
|
29
|
+
/**
|
|
30
|
+
* How long closing a peer waits for its native owner join. A healthy join under
|
|
31
|
+
* real libwebrtc took 13-62 ms in the media load tests on Node and Bun, which
|
|
32
|
+
* require it under 2 s; the default leaves five times that bound.
|
|
33
|
+
*/
|
|
34
|
+
export const defaultShutdownTimeout = Duration.seconds(10);
|
|
35
|
+
export const validateNativeTracks = (tracks) => {
|
|
25
36
|
const incomingVideo = tracks.filter((track) => track.direction === "recvonly" && track.kind === "video").length;
|
|
26
37
|
const incomingAudio = tracks.filter((track) => track.direction === "recvonly" && track.kind === "audio").length;
|
|
27
38
|
if (incomingVideo > 1 || incomingAudio > 1) {
|
|
28
|
-
throw
|
|
39
|
+
throw ReactorError.fromCode("UnsupportedCapability", "native WebRTC currently supports at most one incoming video and one incoming audio track because reactor-webrtc does not expose the remote track MID to its observer", { outcome: "not-submitted" });
|
|
29
40
|
}
|
|
30
41
|
};
|
|
31
42
|
const record = (value, what) => {
|
|
32
|
-
if (
|
|
33
|
-
throw
|
|
43
|
+
if (!Predicate.isObject(value))
|
|
44
|
+
throw ReactorError.fromCode("Protocol", `native ${what} is not an object`);
|
|
34
45
|
return value;
|
|
35
46
|
};
|
|
36
47
|
const string = (value, what) => {
|
|
37
48
|
if (typeof value !== "string")
|
|
38
|
-
throw
|
|
49
|
+
throw ReactorError.fromCode("Protocol", `native ${what} is not a string`);
|
|
39
50
|
return value;
|
|
40
51
|
};
|
|
41
52
|
const integer = (value, what) => {
|
|
42
53
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
|
|
43
|
-
throw
|
|
54
|
+
throw ReactorError.fromCode("Protocol", `native ${what} is not a nonnegative safe integer`);
|
|
44
55
|
return value;
|
|
45
56
|
};
|
|
46
57
|
const bigint = (value, what) => {
|
|
47
58
|
if (typeof value !== "string" || !/^[0-9]+$/.test(value))
|
|
48
|
-
throw
|
|
59
|
+
throw ReactorError.fromCode("Protocol", `native ${what} is not an unsigned integer string`);
|
|
49
60
|
return BigInt(value);
|
|
50
61
|
};
|
|
51
|
-
const nativeError = (cause, operation) => cause
|
|
62
|
+
const nativeError = (cause, operation) => ReactorError.is(cause) ? cause : errorOf(cause, "Native", operation);
|
|
63
|
+
const decodeMappings = Schema.decodeUnknownResult(Schema.Array(Mapping).check(Schema.isMaxLength(64)));
|
|
64
|
+
/** The client's own `Mapping` schema, so the host and the session agree on every rule. */
|
|
52
65
|
const parseMapping = (value) => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
throw new ReactorError("Protocol", "native mapping has an unknown track kind");
|
|
60
|
-
if (direction !== "recvonly" && direction !== "sendonly")
|
|
61
|
-
throw new ReactorError("Protocol", "native mapping has an unknown direction");
|
|
62
|
-
return Object.freeze({ name, kind, direction, mid });
|
|
63
|
-
}));
|
|
66
|
+
const decoded = decodeMappings(value);
|
|
67
|
+
if (Result.isFailure(decoded))
|
|
68
|
+
throw ReactorError.fromCode("Protocol", "native prepare returned an invalid mapping list", {
|
|
69
|
+
detail: decoded.failure,
|
|
70
|
+
});
|
|
71
|
+
return Object.freeze(decoded.success.map((entry) => Object.freeze({ ...entry })));
|
|
64
72
|
};
|
|
65
73
|
const parsePrepared = (value) => {
|
|
66
74
|
const response = record(value, "prepare response"), sdp = string(response.sdp, "prepare.sdp"), mapping = parseMapping(response.mapping);
|
|
67
75
|
if (sdp.length === 0)
|
|
68
|
-
throw
|
|
76
|
+
throw ReactorError.fromCode("Protocol", "native prepare returned an empty SDP offer");
|
|
69
77
|
return Object.freeze({ sdp, mapping });
|
|
70
78
|
};
|
|
71
|
-
const iceServers = (servers) => servers.map((server) => {
|
|
79
|
+
export const iceServers = (servers) => servers.map((server) => {
|
|
72
80
|
const urls = typeof server.urls === "string" ? [server.urls] : [...server.urls];
|
|
73
81
|
if (urls.length === 0 || urls.some((url) => typeof url !== "string" || url.length === 0))
|
|
74
|
-
throw
|
|
82
|
+
throw ReactorError.fromCode("InvalidInput", "native ICE server has no usable URL", {
|
|
75
83
|
outcome: "not-submitted",
|
|
76
84
|
});
|
|
77
85
|
if (server.credential !== undefined && typeof server.credential !== "string")
|
|
78
|
-
throw
|
|
86
|
+
throw ReactorError.fromCode("UnsupportedCapability", "native ICE supports password credentials, not OAuth credential objects", { outcome: "not-submitted" });
|
|
79
87
|
return Object.freeze({
|
|
80
88
|
urls: Object.freeze(urls),
|
|
81
89
|
username: server.username ?? "",
|
|
@@ -89,21 +97,21 @@ const parseEvent = (packet) => {
|
|
|
89
97
|
case "state": {
|
|
90
98
|
const state = string(header.state, "state");
|
|
91
99
|
if (!stateValues.has(state))
|
|
92
|
-
throw
|
|
100
|
+
throw ReactorError.fromCode("Protocol", `native peer reported unknown state ${state}`);
|
|
93
101
|
return { type: "state", state: state };
|
|
94
102
|
}
|
|
95
103
|
case "channel": {
|
|
96
104
|
const channel = string(header.channel, "channel");
|
|
97
105
|
if (channel !== "control" && channel !== "data")
|
|
98
|
-
throw
|
|
106
|
+
throw ReactorError.fromCode("Protocol", "native peer reported an unknown data channel");
|
|
99
107
|
if (typeof header.open !== "boolean")
|
|
100
|
-
throw
|
|
108
|
+
throw ReactorError.fromCode("Protocol", "native channel event omitted its open state");
|
|
101
109
|
return { type: "channel", channel, open: header.open };
|
|
102
110
|
}
|
|
103
111
|
case "message": {
|
|
104
112
|
const channel = string(header.channel, "message.channel");
|
|
105
113
|
if (channel !== "control" && channel !== "data")
|
|
106
|
-
throw
|
|
114
|
+
throw ReactorError.fromCode("Protocol", "native peer message named an unknown channel");
|
|
107
115
|
return { type: "message", channel, bytes: packet.payload };
|
|
108
116
|
}
|
|
109
117
|
case "ice": {
|
|
@@ -127,7 +135,7 @@ const parseEvent = (packet) => {
|
|
|
127
135
|
case "decoded": {
|
|
128
136
|
const kind = string(header.kind, "decoded.kind");
|
|
129
137
|
if (kind !== "video" && kind !== "audio")
|
|
130
|
-
throw
|
|
138
|
+
throw ReactorError.fromCode("Protocol", "native decoded event has unknown media kind");
|
|
131
139
|
return {
|
|
132
140
|
type: "decoded",
|
|
133
141
|
kind,
|
|
@@ -136,63 +144,62 @@ const parseEvent = (packet) => {
|
|
|
136
144
|
};
|
|
137
145
|
}
|
|
138
146
|
case "error": {
|
|
139
|
-
const
|
|
140
|
-
|
|
147
|
+
const status = header.status;
|
|
148
|
+
if (typeof status !== "number" || !Number.isSafeInteger(status))
|
|
149
|
+
throw ReactorError.fromCode("Protocol", "native error event omitted its failure class");
|
|
141
150
|
return {
|
|
142
151
|
type: "error",
|
|
143
|
-
error:
|
|
152
|
+
error: nativeFailure(status, string(header.message, "error.message"), (code) => `native peer failed (${code})`, {}),
|
|
144
153
|
};
|
|
145
154
|
}
|
|
146
155
|
default:
|
|
147
|
-
throw
|
|
156
|
+
throw ReactorError.fromCode("Protocol", `native peer emitted unknown event type ${type}`);
|
|
148
157
|
}
|
|
149
158
|
};
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
/** The native track index is the position of the track in the prepare request. */
|
|
160
|
+
const receiving = (tracks, index, kind) => {
|
|
161
|
+
const track = tracks[index];
|
|
162
|
+
if (track?.direction !== "recvonly" || track.kind !== kind)
|
|
163
|
+
throw ReactorError.fromCode("Protocol", `native ${kind} was delivered without its declared receive mapping`);
|
|
164
|
+
return track.name;
|
|
165
|
+
};
|
|
166
|
+
const videoFrame = (tracks, taken) => {
|
|
167
|
+
const track = receiving(tracks, taken.track, "video");
|
|
168
|
+
if (taken.width === 0 ||
|
|
169
|
+
taken.height === 0 ||
|
|
170
|
+
taken.data.byteLength !== taken.width * taken.height * 4)
|
|
171
|
+
throw ReactorError.fromCode("Protocol", "native BGRA frame dimensions do not match its payload");
|
|
160
172
|
return Object.freeze({
|
|
161
173
|
_tag: "VideoFrame",
|
|
162
|
-
track
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
174
|
+
track,
|
|
175
|
+
format: "BGRA",
|
|
176
|
+
width: taken.width,
|
|
177
|
+
height: taken.height,
|
|
178
|
+
frameId: taken.frameId,
|
|
179
|
+
timestampMicros: taken.timestampMicros,
|
|
180
|
+
sequence: taken.sequence,
|
|
181
|
+
data: taken.data,
|
|
182
|
+
metadata: taken.metadata,
|
|
169
183
|
});
|
|
170
184
|
};
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
174
|
-
throw
|
|
175
|
-
const sampleRate = integer(h.sampleRate, "audio.sampleRate"), channels = integer(h.channels, "audio.channels"), samples = integer(h.samples, "audio.samples");
|
|
176
|
-
if (sampleRate === 0 ||
|
|
177
|
-
channels === 0 ||
|
|
178
|
-
samples % channels !== 0 ||
|
|
179
|
-
packet.payload.length !== samples * 2)
|
|
180
|
-
throw new ReactorError("Protocol", "native PCM format does not match its payload");
|
|
181
|
-
const view = new DataView(packet.payload.buffer, packet.payload.byteOffset, packet.payload.byteLength), pcm = new Int16Array(samples);
|
|
182
|
-
for (let index = 0; index < samples; index++)
|
|
183
|
-
pcm[index] = view.getInt16(index * 2, true);
|
|
185
|
+
const audioFrame = (tracks, taken) => {
|
|
186
|
+
const track = receiving(tracks, taken.track, "audio");
|
|
187
|
+
if (taken.sampleRate === 0 || taken.channels === 0 || taken.samples.length % taken.channels)
|
|
188
|
+
throw ReactorError.fromCode("Protocol", "native PCM format does not match its payload");
|
|
184
189
|
return Object.freeze({
|
|
185
190
|
_tag: "AudioFrame",
|
|
186
|
-
track
|
|
187
|
-
sampleRate,
|
|
188
|
-
channels,
|
|
189
|
-
|
|
191
|
+
track,
|
|
192
|
+
sampleRate: taken.sampleRate,
|
|
193
|
+
channels: taken.channels,
|
|
194
|
+
sequence: taken.sequence,
|
|
195
|
+
samples: taken.samples,
|
|
190
196
|
});
|
|
191
197
|
};
|
|
198
|
+
/** The native snapshot's transport counters; the host adds its own reader overflows. */
|
|
192
199
|
const parseSnapshot = (value) => {
|
|
193
200
|
const s = record(value, "media snapshot");
|
|
194
201
|
if (typeof s.closed !== "boolean")
|
|
195
|
-
throw
|
|
202
|
+
throw ReactorError.fromCode("Protocol", "native media snapshot omitted closed state");
|
|
196
203
|
return Object.freeze({
|
|
197
204
|
closed: s.closed,
|
|
198
205
|
queuedControl: integer(s.queuedControl, "snapshot.queuedControl"),
|
|
@@ -219,23 +226,57 @@ const statsValue = (value) => {
|
|
|
219
226
|
: statsValue(entry);
|
|
220
227
|
return output;
|
|
221
228
|
};
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Classify a failed connection from its candidate pairs. reactor-webrtc does
|
|
231
|
+
* not report ICE connection state, but a pair that succeeded or was nominated
|
|
232
|
+
* shows ICE worked and the DTLS/SCTP transport above it failed.
|
|
233
|
+
*/
|
|
234
|
+
const connectionFailure = (stats) => {
|
|
235
|
+
const entries = stats.filter(Predicate.isObject);
|
|
236
|
+
const pairs = entries.filter((entry) => entry.type === "candidate-pair");
|
|
237
|
+
if (pairs.some((pair) => pair.state === "succeeded" || pair.nominated === true))
|
|
238
|
+
return new ReactorError({
|
|
239
|
+
reason: new TransportFailed({
|
|
240
|
+
message: "native peer failed after ICE connectivity succeeded",
|
|
241
|
+
pairs: pairs.length,
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
const candidateTypes = [
|
|
245
|
+
...new Set(entries
|
|
246
|
+
.filter((entry) => entry.type === "local-candidate")
|
|
247
|
+
.map((entry) => entry.candidateType)
|
|
248
|
+
.filter((type) => typeof type === "string")),
|
|
249
|
+
];
|
|
250
|
+
return new ReactorError({
|
|
251
|
+
reason: new IceFailed({
|
|
252
|
+
message: "native peer found no working ICE candidate pair",
|
|
253
|
+
pairs: pairs.length,
|
|
254
|
+
candidateTypes,
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
/** One synchronous, nonblocking take from a native queue, as a pump step. */
|
|
259
|
+
const drain = (step) => Effect.try({ try: step, catch: (cause) => nativeError(cause, "drain native WebRTC") });
|
|
226
260
|
export class NativePeer {
|
|
261
|
+
shutdownTimeout;
|
|
227
262
|
nativeTracks = false;
|
|
228
263
|
rawMedia;
|
|
229
264
|
bridge;
|
|
230
265
|
video = new Map();
|
|
231
266
|
audio = new Map();
|
|
232
267
|
incoming = new Map();
|
|
268
|
+
tracks = [];
|
|
269
|
+
// Readiness wakes one pump per native queue; a pending wake coalesces.
|
|
270
|
+
wakeEvents = Effect.runSync(Queue.dropping(1));
|
|
271
|
+
wakeVideo = Effect.runSync(Queue.dropping(1));
|
|
272
|
+
wakeAudio = Effect.runSync(Queue.dropping(1));
|
|
233
273
|
emit;
|
|
234
274
|
closed = false;
|
|
235
275
|
failureEmitted = false;
|
|
236
276
|
failure;
|
|
237
|
-
constructor(libraryPath) {
|
|
238
|
-
this.
|
|
277
|
+
constructor(libraryPath, shutdownTimeout = defaultShutdownTimeout) {
|
|
278
|
+
this.shutdownTimeout = shutdownTimeout;
|
|
279
|
+
this.bridge = new NativeBridge(libraryPath, (ready) => this.wake(ready));
|
|
239
280
|
this.rawMedia = Object.freeze({
|
|
240
281
|
video: (name) => Stream.unwrap(Effect.try({
|
|
241
282
|
try: () => {
|
|
@@ -256,7 +297,16 @@ export class NativePeer {
|
|
|
256
297
|
}
|
|
257
298
|
requireIncoming(name, kind) {
|
|
258
299
|
if (this.incoming.get(name) !== kind)
|
|
259
|
-
throw
|
|
300
|
+
throw ReactorError.fromCode("InvalidInput", "native media requires a declared receive track of the requested kind", { outcome: "not-submitted" });
|
|
301
|
+
}
|
|
302
|
+
/** Readers failed for falling behind, across this peer's video and audio tracks. */
|
|
303
|
+
readerOverflows() {
|
|
304
|
+
let overflows = 0n;
|
|
305
|
+
for (const feed of this.video.values())
|
|
306
|
+
overflows += feed.overflowCount;
|
|
307
|
+
for (const feed of this.audio.values())
|
|
308
|
+
overflows += feed.overflowCount;
|
|
309
|
+
return overflows;
|
|
260
310
|
}
|
|
261
311
|
videoFeed(name) {
|
|
262
312
|
let feed = this.video.get(name);
|
|
@@ -299,78 +349,86 @@ export class NativePeer {
|
|
|
299
349
|
this.close();
|
|
300
350
|
}
|
|
301
351
|
}
|
|
302
|
-
|
|
352
|
+
/** The native readiness callback, on the JavaScript thread: it only wakes pumps. */
|
|
353
|
+
wake(ready) {
|
|
354
|
+
if (ready & Ready.Events)
|
|
355
|
+
Queue.offerUnsafe(this.wakeEvents, undefined);
|
|
356
|
+
if (ready & Ready.Video)
|
|
357
|
+
Queue.offerUnsafe(this.wakeVideo, undefined);
|
|
358
|
+
if (ready & Ready.Audio)
|
|
359
|
+
Queue.offerUnsafe(this.wakeAudio, undefined);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Drain one native queue with synchronous takes whenever readiness wakes
|
|
363
|
+
* it. Yielding after every item lets observers run between emits, so a
|
|
364
|
+
* backlog released by a stalled event loop reaches bounded observation
|
|
365
|
+
* queues at their readers' pace rather than all at once. Yielding once per
|
|
366
|
+
* batch of half a subscriber's capacity overflowed an observation queue on
|
|
367
|
+
* Bun under CPU contention, so each item costs one event-loop turn.
|
|
368
|
+
*/
|
|
369
|
+
pump(wake, step) {
|
|
303
370
|
const self = this;
|
|
304
371
|
return Effect.gen(function* () {
|
|
305
372
|
while (!self.closed) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (packet === null)
|
|
310
|
-
return;
|
|
311
|
-
if (packet === undefined)
|
|
312
|
-
continue;
|
|
313
|
-
const event = yield* Effect.try({
|
|
314
|
-
try: () => parseEvent(packet),
|
|
315
|
-
catch: (cause) => nativeError(cause, "decode native event"),
|
|
316
|
-
});
|
|
317
|
-
if (event.type === "error") {
|
|
318
|
-
self.fail(event.error);
|
|
319
|
-
return;
|
|
373
|
+
yield* Queue.take(wake);
|
|
374
|
+
while (!self.closed && (yield* step)) {
|
|
375
|
+
yield* Effect.yieldNow;
|
|
320
376
|
}
|
|
321
|
-
self.emit?.(event);
|
|
322
377
|
}
|
|
323
378
|
}).pipe(Effect.catch((error) => Effect.sync(() => self.fail(error))));
|
|
324
379
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
});
|
|
345
|
-
self
|
|
346
|
-
.videoFeed(frame.track)
|
|
347
|
-
.emit(frame, frame.data.byteLength + frame.metadata.byteLength + frame.track.length * 2);
|
|
348
|
-
}
|
|
349
|
-
}).pipe(Effect.catch((error) => Effect.sync(() => self.fail(error))));
|
|
380
|
+
/**
|
|
381
|
+
* Deliver one event; false once the queue is empty or closed. A failed
|
|
382
|
+
* connection is classified here before the pump takes another event, so its
|
|
383
|
+
* classification decides the error; later events describe the same teardown.
|
|
384
|
+
*/
|
|
385
|
+
get stepEvent() {
|
|
386
|
+
return drain(() => {
|
|
387
|
+
const packet = this.bridge.takeEvent();
|
|
388
|
+
if (packet === undefined || packet === null)
|
|
389
|
+
return false;
|
|
390
|
+
const event = parseEvent(packet);
|
|
391
|
+
if (event.type === "state" && event.state === "failed")
|
|
392
|
+
return "failed";
|
|
393
|
+
if (event.type === "error")
|
|
394
|
+
this.fail(event.error);
|
|
395
|
+
else
|
|
396
|
+
this.emit?.(event);
|
|
397
|
+
return true;
|
|
398
|
+
}).pipe(Effect.filterOrElse((taken) => taken !== "failed", () => this.classify.pipe(Effect.as(true))));
|
|
350
399
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
400
|
+
get stepVideo() {
|
|
401
|
+
return drain(() => {
|
|
402
|
+
const taken = this.bridge.takeVideo();
|
|
403
|
+
if (taken === undefined || taken === null)
|
|
404
|
+
return false;
|
|
405
|
+
const frame = videoFrame(this.tracks, taken);
|
|
406
|
+
this.videoFeed(frame.track).emit(frame, frame.data.byteLength + frame.metadata.byteLength + frame.track.length * 2);
|
|
407
|
+
return true;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
get stepAudio() {
|
|
411
|
+
return drain(() => {
|
|
412
|
+
const taken = this.bridge.takeAudio();
|
|
413
|
+
if (taken === undefined || taken === null)
|
|
414
|
+
return false;
|
|
415
|
+
const frame = audioFrame(this.tracks, taken);
|
|
416
|
+
this.audioFeed(frame.track).emit(frame, frame.samples.byteLength + frame.track.length * 2);
|
|
417
|
+
return true;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Report a failed connection as IceFailed or TransportFailed rather than a
|
|
422
|
+
* bare state. The statistics read runs in the events pump, so the connection
|
|
423
|
+
* scope owns it and its deadline runs on the fiber's Clock.
|
|
424
|
+
*/
|
|
425
|
+
get classify() {
|
|
426
|
+
return bridgeEffect("classify native failure", () => this.bridge.call(NativeCall.Stats)).pipe(Effect.map((stats) => Array.isArray(stats)
|
|
427
|
+
? connectionFailure(stats)
|
|
428
|
+
: ReactorError.fromCode("Disconnected", "peer state failed")), Effect.timeoutOrElse({
|
|
429
|
+
duration: CLASSIFY_TIMEOUT_MS,
|
|
430
|
+
orElse: () => Effect.fail(ReactorError.fromCode("Timeout", "native failure classification timed out")),
|
|
431
|
+
}), Effect.catch((cause) => Effect.succeed(ReactorError.fromCode("Disconnected", "peer state failed", { detail: cause }))), Effect.flatMap((error) => drain(() => this.fail(error))));
|
|
374
432
|
}
|
|
375
433
|
prepare(servers, tracks, emit) {
|
|
376
434
|
const self = this;
|
|
@@ -379,6 +437,7 @@ export class NativePeer {
|
|
|
379
437
|
try: () => validateNativeTracks(tracks),
|
|
380
438
|
catch: (cause) => nativeError(cause, "validate native tracks"),
|
|
381
439
|
});
|
|
440
|
+
self.tracks = Object.freeze([...tracks]);
|
|
382
441
|
for (const track of tracks)
|
|
383
442
|
if (track.direction === "recvonly")
|
|
384
443
|
self.incoming.set(track.name, track.kind);
|
|
@@ -391,9 +450,9 @@ export class NativePeer {
|
|
|
391
450
|
try: () => parsePrepared(value),
|
|
392
451
|
catch: (cause) => nativeError(cause, "decode native prepare"),
|
|
393
452
|
})));
|
|
394
|
-
yield* Effect.forkScoped(self.
|
|
395
|
-
yield* Effect.forkScoped(self.
|
|
396
|
-
yield* Effect.forkScoped(self.
|
|
453
|
+
yield* Effect.forkScoped(self.pump(self.wakeEvents, self.stepEvent));
|
|
454
|
+
yield* Effect.forkScoped(self.pump(self.wakeVideo, self.stepVideo));
|
|
455
|
+
yield* Effect.forkScoped(self.pump(self.wakeAudio, self.stepAudio));
|
|
397
456
|
return prepared;
|
|
398
457
|
});
|
|
399
458
|
}
|
|
@@ -408,16 +467,14 @@ export class NativePeer {
|
|
|
408
467
|
}
|
|
409
468
|
maxBitrate(name, bitsPerSecond) {
|
|
410
469
|
if (!Number.isSafeInteger(bitsPerSecond) || bitsPerSecond < 1 || bitsPerSecond > 0x7fffffff)
|
|
411
|
-
return Effect.fail(
|
|
412
|
-
outcome: "not-submitted",
|
|
413
|
-
}));
|
|
470
|
+
return Effect.fail(ReactorError.fromCode("InvalidInput", "native max bitrate must be an integer in 1..2147483647", { outcome: "not-submitted" }));
|
|
414
471
|
return bridgeEffect("set native sender bitrate", () => this.bridge.call(NativeCall.MaxBitrate, encodeNativeJson({ name, bitsPerSecond }))).pipe(Effect.asVoid);
|
|
415
472
|
}
|
|
416
|
-
stats() {
|
|
473
|
+
get stats() {
|
|
417
474
|
return bridgeEffect("native WebRTC statistics", () => this.bridge.call(NativeCall.Stats)).pipe(Effect.flatMap((value) => Effect.try({
|
|
418
475
|
try: () => {
|
|
419
476
|
if (!Array.isArray(value))
|
|
420
|
-
throw
|
|
477
|
+
throw ReactorError.fromCode("Protocol", "native stats response is not an array");
|
|
421
478
|
return Object.freeze(statsValue(value));
|
|
422
479
|
},
|
|
423
480
|
catch: (cause) => nativeError(cause, "decode native stats"),
|
|
@@ -425,18 +482,18 @@ export class NativePeer {
|
|
|
425
482
|
}
|
|
426
483
|
mediaSnapshot() {
|
|
427
484
|
return bridgeEffect("native media snapshot", () => this.bridge.call(NativeCall.MediaSnapshot)).pipe(Effect.flatMap((value) => Effect.try({
|
|
428
|
-
try: () => parseSnapshot(value),
|
|
485
|
+
try: () => Object.freeze({ ...parseSnapshot(value), readerOverflows: this.readerOverflows() }),
|
|
429
486
|
catch: (cause) => nativeError(cause, "decode native media snapshot"),
|
|
430
487
|
})));
|
|
431
488
|
}
|
|
432
489
|
lease() {
|
|
433
|
-
throw
|
|
490
|
+
throw ReactorError.fromCode("UnsupportedCapability", "native WebRTC exposes owned decoded samples, not browser MediaStreamTrack leases", { outcome: "not-submitted" });
|
|
434
491
|
}
|
|
435
492
|
release() {
|
|
436
493
|
/* lease never succeeds on this host. */
|
|
437
494
|
}
|
|
438
495
|
replace() {
|
|
439
|
-
return Effect.fail(
|
|
496
|
+
return Effect.fail(ReactorError.fromCode("UnsupportedCapability", "native WebRTC does not accept browser MediaStreamTrack publication", { outcome: "not-submitted" }));
|
|
440
497
|
}
|
|
441
498
|
close() {
|
|
442
499
|
if (this.closed)
|
|
@@ -444,6 +501,9 @@ export class NativePeer {
|
|
|
444
501
|
this.closed = true;
|
|
445
502
|
this.emit = undefined;
|
|
446
503
|
this.bridge.close();
|
|
504
|
+
// Let waiting pumps observe the close and exit.
|
|
505
|
+
for (const wake of [this.wakeEvents, this.wakeVideo, this.wakeAudio])
|
|
506
|
+
Queue.offerUnsafe(wake, undefined);
|
|
447
507
|
for (const feed of this.video.values()) {
|
|
448
508
|
if (this.failure === undefined)
|
|
449
509
|
feed.end();
|
|
@@ -457,23 +517,39 @@ export class NativePeer {
|
|
|
457
517
|
feed.fail(this.failure);
|
|
458
518
|
}
|
|
459
519
|
}
|
|
460
|
-
|
|
520
|
+
/**
|
|
521
|
+
* Close, then wait for the native owner join. Only the wait is bounded: the
|
|
522
|
+
* deadline races the interruptible wait inside the uninterruptible region, so
|
|
523
|
+
* expiry stops waiting while the join keeps the handle and callback, and the
|
|
524
|
+
* bridge is retained until the join completes.
|
|
525
|
+
*/
|
|
526
|
+
get shutdown() {
|
|
461
527
|
return bridgeEffect("shutdown native WebRTC", () => {
|
|
462
528
|
this.close();
|
|
463
529
|
return this.bridge.shutdown();
|
|
464
|
-
}).pipe(
|
|
530
|
+
}).pipe(
|
|
531
|
+
// A failed shutdown is a Shutdown failure; the native failure it came
|
|
532
|
+
// from stays in `detail` for inspection.
|
|
533
|
+
Effect.mapError((error) => error.reason._tag === "Shutdown"
|
|
465
534
|
? error
|
|
466
|
-
:
|
|
535
|
+
: ReactorError.fromCode("Shutdown", error.message, { ...error.context, detail: error })), Effect.timeoutOrElse({
|
|
536
|
+
duration: this.shutdownTimeout,
|
|
537
|
+
orElse: () => Effect.suspend(() => {
|
|
538
|
+
this.bridge.retain();
|
|
539
|
+
return Effect.fail(ReactorError.fromCode("Shutdown", "native owner join exceeded its deadline; handle retained", { operation: "shutdown native WebRTC" }));
|
|
540
|
+
}),
|
|
541
|
+
}), Effect.uninterruptible);
|
|
467
542
|
}
|
|
468
543
|
}
|
|
469
544
|
/** Internal test surface. This module is not a package export. */
|
|
470
545
|
export const nativePeerTesting = Object.freeze({
|
|
471
546
|
parsePrepared,
|
|
472
547
|
parseEvent,
|
|
473
|
-
|
|
474
|
-
|
|
548
|
+
videoFrame,
|
|
549
|
+
audioFrame,
|
|
475
550
|
parseSnapshot,
|
|
476
551
|
statsValue,
|
|
477
552
|
validateNativeTracks,
|
|
553
|
+
connectionFailure,
|
|
478
554
|
});
|
|
479
555
|
//# sourceMappingURL=peer.js.map
|