@tribe-nest/media-protocol 1.0.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/build/_fixtures/joinTokenVectors.json +103 -0
- package/build/claims.d.ts +327 -0
- package/build/claims.d.ts.map +1 -0
- package/build/claims.js +91 -0
- package/build/claims.js.map +1 -0
- package/build/codecs.d.ts +102 -0
- package/build/codecs.d.ts.map +1 -0
- package/build/codecs.js +138 -0
- package/build/codecs.js.map +1 -0
- package/build/errorSchema.d.ts +8 -0
- package/build/errorSchema.d.ts.map +1 -0
- package/build/errorSchema.js +12 -0
- package/build/errorSchema.js.map +1 -0
- package/build/errors.d.ts +18 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +47 -0
- package/build/errors.js.map +1 -0
- package/build/frames.d.ts +1291 -0
- package/build/frames.d.ts.map +1 -0
- package/build/frames.js +222 -0
- package/build/frames.js.map +1 -0
- package/build/grants.d.ts +307 -0
- package/build/grants.d.ts.map +1 -0
- package/build/grants.js +228 -0
- package/build/grants.js.map +1 -0
- package/build/index.d.ts +24 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +40 -0
- package/build/index.js.map +1 -0
- package/build/mp-build/_fixtures/joinTokenVectors.json +103 -0
- package/build/mp-build/claims.d.ts +299 -0
- package/build/mp-build/claims.d.ts.map +1 -0
- package/build/mp-build/claims.js +60 -0
- package/build/mp-build/claims.js.map +1 -0
- package/build/mp-build/errorSchema.d.ts +8 -0
- package/build/mp-build/errorSchema.d.ts.map +1 -0
- package/build/mp-build/errorSchema.js +12 -0
- package/build/mp-build/errorSchema.js.map +1 -0
- package/build/mp-build/errors.d.ts +18 -0
- package/build/mp-build/errors.d.ts.map +1 -0
- package/build/mp-build/errors.js +47 -0
- package/build/mp-build/errors.js.map +1 -0
- package/build/mp-build/frames.d.ts +786 -0
- package/build/mp-build/frames.d.ts.map +1 -0
- package/build/mp-build/frames.js +153 -0
- package/build/mp-build/frames.js.map +1 -0
- package/build/mp-build/grants.d.ts +245 -0
- package/build/mp-build/grants.d.ts.map +1 -0
- package/build/mp-build/grants.js +135 -0
- package/build/mp-build/grants.js.map +1 -0
- package/build/mp-build/index.d.ts +23 -0
- package/build/mp-build/index.d.ts.map +1 -0
- package/build/mp-build/index.js +39 -0
- package/build/mp-build/index.js.map +1 -0
- package/build/mp-build/token.d.ts +66 -0
- package/build/mp-build/token.d.ts.map +1 -0
- package/build/mp-build/token.js +208 -0
- package/build/mp-build/token.js.map +1 -0
- package/build/mp-build/version.d.ts +9 -0
- package/build/mp-build/version.d.ts.map +1 -0
- package/build/mp-build/version.js +12 -0
- package/build/mp-build/version.js.map +1 -0
- package/build/token.d.ts +66 -0
- package/build/token.d.ts.map +1 -0
- package/build/token.js +208 -0
- package/build/token.js.map +1 -0
- package/build/version.d.ts +9 -0
- package/build/version.d.ts.map +1 -0
- package/build/version.js +12 -0
- package/build/version.js.map +1 -0
- package/package.json +53 -0
- package/src/_fixtures/joinTokenVectors.json +103 -0
- package/src/_tests/codecs.spec.ts +66 -0
- package/src/_tests/publishGrants.spec.ts +109 -0
- package/src/_tests/token.spec.ts +272 -0
- package/src/claims.ts +116 -0
- package/src/codecs.ts +165 -0
- package/src/errorSchema.ts +9 -0
- package/src/errors.ts +44 -0
- package/src/frames.ts +234 -0
- package/src/grants.ts +248 -0
- package/src/index.ts +23 -0
- package/src/token.ts +237 -0
- package/src/version.ts +8 -0
package/src/claims.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { mediaGrantsSchema, participantKindSchema, transportKindSchema } from "./grants";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The join token's claims: the whole authorization decision, made once by the
|
|
6
|
+
* backend and carried to a media plane that never asks it anything.
|
|
7
|
+
*
|
|
8
|
+
* Names here were contested by the parallel phase plans; each disagreement is
|
|
9
|
+
* resolved once, in this file, and everything else imports it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The longest display name a join token may carry.
|
|
14
|
+
*
|
|
15
|
+
* Exported because it is a MINT-TIME refusal, not a preference: `signJoinToken`
|
|
16
|
+
* hard-parses these claims, so a caller that builds a name out of stored text
|
|
17
|
+
* and does not clamp it throws instead of minting. Every such caller has to
|
|
18
|
+
* know the bound, and a caller that hard-codes 200 is a caller that will not
|
|
19
|
+
* follow this number if it ever moves.
|
|
20
|
+
*/
|
|
21
|
+
export const MEDIA_PARTICIPANT_NAME_MAX_LENGTH = 200;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A display name cut to what a token may carry.
|
|
25
|
+
*
|
|
26
|
+
* Lives HERE, beside the bound it enforces, rather than in each surface that
|
|
27
|
+
* mints. Two reasons, and the second is the one that matters.
|
|
28
|
+
*
|
|
29
|
+
* A minting surface builds the name out of stored text (a buyer's name off a
|
|
30
|
+
* booking, a seat's display name off a roster) and cannot promise a length, so
|
|
31
|
+
* every one of them needs this. And a surface that instead compared against the
|
|
32
|
+
* exported NUMBER would go silently unclamped the moment it read a stale build
|
|
33
|
+
* of this package: `value.length > undefined` is `false`, so the clamp
|
|
34
|
+
* evaporates and the failure only shows up as a 500 from `signJoinToken` in
|
|
35
|
+
* production. A missing FUNCTION throws on the first call instead.
|
|
36
|
+
*
|
|
37
|
+
* Truncation rather than refusal is deliberate. A name is a label on a tile,
|
|
38
|
+
* and no call should fail to open over one.
|
|
39
|
+
*/
|
|
40
|
+
export function clampParticipantName(value: string): string {
|
|
41
|
+
return value.length > MEDIA_PARTICIPANT_NAME_MAX_LENGTH ? value.slice(0, MEDIA_PARTICIPANT_NAME_MAX_LENGTH) : value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `iss` IS the instance id (`legal-de`, `main`), not a service name.
|
|
46
|
+
*
|
|
47
|
+
* One plan proposed `iss: "tribenest-backend"` with the instance in a separate
|
|
48
|
+
* `inst` claim. That displaces the one value a verifier actually checks, and a
|
|
49
|
+
* legal-de token would then verify happily on the main instance - which is the
|
|
50
|
+
* whole reason instances are separate.
|
|
51
|
+
*/
|
|
52
|
+
export const mediaJoinClaimsSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
v: z.literal(1),
|
|
55
|
+
iss: z.string().min(1),
|
|
56
|
+
aud: z.literal("media"),
|
|
57
|
+
room: z.string().min(1).max(200),
|
|
58
|
+
/** `identity`, not `sub`: it is a first-class concept the barrier rules name. */
|
|
59
|
+
identity: z.string().min(1).max(200),
|
|
60
|
+
name: z.string().max(MEDIA_PARTICIPANT_NAME_MAX_LENGTH).optional(),
|
|
61
|
+
/** Top level, NOT inside grants - it decides what may be minted at all. */
|
|
62
|
+
kind: participantKindSchema,
|
|
63
|
+
/** Top level. Transport authority is not media authority. */
|
|
64
|
+
transport: transportKindSchema,
|
|
65
|
+
grants: mediaGrantsSchema,
|
|
66
|
+
jti: z.string().min(8),
|
|
67
|
+
iat: z.number().int().positive(),
|
|
68
|
+
nbf: z.number().int().positive().optional(),
|
|
69
|
+
exp: z.number().int().positive(),
|
|
70
|
+
})
|
|
71
|
+
.strict();
|
|
72
|
+
|
|
73
|
+
export type MediaJoinClaims = z.infer<typeof mediaJoinClaimsSchema>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Server-to-server control calls (create room, end room, attach an agent).
|
|
77
|
+
*
|
|
78
|
+
* A separate audience so a join token can never be replayed as a control call.
|
|
79
|
+
* It carries no room, identity, kind, transport or grants: a control caller is
|
|
80
|
+
* not a participant, and giving it participant-shaped claims invites code that
|
|
81
|
+
* treats it as one.
|
|
82
|
+
*/
|
|
83
|
+
export const mediaControlClaimsSchema = z
|
|
84
|
+
.object({
|
|
85
|
+
v: z.literal(1),
|
|
86
|
+
iss: z.string().min(1),
|
|
87
|
+
aud: z.literal("control"),
|
|
88
|
+
jti: z.string().min(8),
|
|
89
|
+
iat: z.number().int().positive(),
|
|
90
|
+
nbf: z.number().int().positive().optional(),
|
|
91
|
+
exp: z.number().int().positive(),
|
|
92
|
+
})
|
|
93
|
+
.strict();
|
|
94
|
+
|
|
95
|
+
export type MediaControlClaims = z.infer<typeof mediaControlClaimsSchema>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Why a token was refused.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately granular for the SERVER's logs and deliberately NOT returned to
|
|
101
|
+
* the client, which gets `unauthorized` and nothing else. Telling a caller
|
|
102
|
+
* whether the signature or the room was wrong is an oracle.
|
|
103
|
+
*/
|
|
104
|
+
export type JoinTokenRejection =
|
|
105
|
+
| "malformed"
|
|
106
|
+
| "unknown_kid"
|
|
107
|
+
| "bad_signature"
|
|
108
|
+
| "expired"
|
|
109
|
+
| "not_yet_valid"
|
|
110
|
+
| "ttl_too_long"
|
|
111
|
+
| "wrong_audience"
|
|
112
|
+
| "wrong_issuer"
|
|
113
|
+
| "wrong_room"
|
|
114
|
+
| "bad_claims";
|
|
115
|
+
|
|
116
|
+
export type VerifyResult<T> = { ok: true; claims: T; kid: string } | { ok: false; reason: JoinTokenRejection };
|
package/src/codecs.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE codec list. Every Router in the fleet is created from this array and no
|
|
3
|
+
* other, and every server-side participant derives what it can receive FROM it.
|
|
4
|
+
*
|
|
5
|
+
* ## Why it is pinned rather than left to mediasoup's defaults
|
|
6
|
+
*
|
|
7
|
+
* Two Routers that disagree about codecs cannot be piped. `pipeToRouter` and
|
|
8
|
+
* the hand-rolled equivalent in the node's `pipe.ts` both require the receiving
|
|
9
|
+
* Router to be able to consume the producer, and "able" is decided by this
|
|
10
|
+
* list. A node booted from a different build with one extra codec still pipes
|
|
11
|
+
* fine until the day a client happens to negotiate that codec, and then exactly
|
|
12
|
+
* one leg of one room goes silent, which reads as a network fault.
|
|
13
|
+
*
|
|
14
|
+
* So the list lives in one place and every Router in the fleet is created from
|
|
15
|
+
* it. A change here is a fleet-wide change and a deploy that mixes versions is
|
|
16
|
+
* a deploy that mixes codec sets, which is why `DEPLOYMENT.md` says drain
|
|
17
|
+
* before upgrade rather than rolling.
|
|
18
|
+
*
|
|
19
|
+
* ## Why it lives in the PROTOCOL package
|
|
20
|
+
*
|
|
21
|
+
* The node is not the only party that has to agree. A room agent, the SIP
|
|
22
|
+
* gateway and the RTMP ingress are participants, not nodes: each one has to
|
|
23
|
+
* declare RTP capabilities on `consume`, each one runs in its own app, and none
|
|
24
|
+
* of them may import `apps/media-server`. Every phase that needed this list
|
|
25
|
+
* from outside the node had exactly one alternative, which is to write a second
|
|
26
|
+
* copy - and a second copy is the failure the pinning exists to prevent. The
|
|
27
|
+
* earlier agent plan proved it: its contract test compared two hand-written
|
|
28
|
+
* lists to EACH OTHER and never to this one, so both could drift together and
|
|
29
|
+
* stay green.
|
|
30
|
+
*
|
|
31
|
+
* Codec agreement is a wire contract between parties that cannot import each
|
|
32
|
+
* other, which is the same reason the frames and the claims are here.
|
|
33
|
+
*
|
|
34
|
+
* ## Zero mediasoup, deliberately
|
|
35
|
+
*
|
|
36
|
+
* The types below are declared structurally rather than imported from
|
|
37
|
+
* mediasoup, because this package is isomorphic and a browser must be able to
|
|
38
|
+
* import a frame type without pulling in a C++ addon. `apps/media-server`
|
|
39
|
+
* re-exports these values through explicitly mediasoup-typed bindings, so if
|
|
40
|
+
* the two shapes ever diverge the node fails to COMPILE rather than failing to
|
|
41
|
+
* pipe at three in the morning.
|
|
42
|
+
*
|
|
43
|
+
* ## What is in it, and what is deliberately not
|
|
44
|
+
*
|
|
45
|
+
* Opus for audio, because it is the only audio codec every browser publishes
|
|
46
|
+
* and the only one worth transcoding to for SIP and for agents.
|
|
47
|
+
*
|
|
48
|
+
* VP8 and H.264 for video. VP8 is universal and simple; H.264 is what Safari
|
|
49
|
+
* and every hardware encoder produce, and its absence is not a quality loss but
|
|
50
|
+
* a "video does not work on iPhone" bug. Both are listed with the RTCP feedback
|
|
51
|
+
* mediasoup needs for keyframes and bandwidth estimation: `nack`, `pli` and
|
|
52
|
+
* `transport-cc`. Drop `pli` and a keyframe request has no wire representation,
|
|
53
|
+
* so every active-speaker switch shows macroblocks until the encoder happens to
|
|
54
|
+
* send an IDR on its own schedule.
|
|
55
|
+
*
|
|
56
|
+
* VP9 and AV1 are deliberately absent. Both are real wins and both are a
|
|
57
|
+
* separate decision with a measurement behind it: SVC changes what
|
|
58
|
+
* `setPreferredLayers` means, and adding a codec that only some clients
|
|
59
|
+
* negotiate widens the "one leg is silent" surface above.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
export type MediaKind = "audio" | "video";
|
|
63
|
+
|
|
64
|
+
export type MediaRtcpFeedback = { type: string; parameter?: string };
|
|
65
|
+
|
|
66
|
+
/** Structurally identical to mediasoup's `RouterRtpCodecCapability`. */
|
|
67
|
+
export type MediaCodecCapability = {
|
|
68
|
+
kind: MediaKind;
|
|
69
|
+
mimeType: string;
|
|
70
|
+
clockRate: number;
|
|
71
|
+
channels?: number;
|
|
72
|
+
parameters?: Record<string, unknown>;
|
|
73
|
+
rtcpFeedback?: MediaRtcpFeedback[];
|
|
74
|
+
preferredPayloadType?: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Structurally identical to mediasoup's `RtpCapabilities`. */
|
|
78
|
+
export type MediaRtpCapabilities = {
|
|
79
|
+
codecs: Array<MediaCodecCapability & { preferredPayloadType: number }>;
|
|
80
|
+
/**
|
|
81
|
+
* Always empty, and typed as the empty tuple rather than an array of some
|
|
82
|
+
* element type.
|
|
83
|
+
*
|
|
84
|
+
* A server-side participant declares no header extensions: it reads the RTP
|
|
85
|
+
* header itself and wants nothing negotiated on top of it. Typing it this way
|
|
86
|
+
* also means this package never has to describe mediasoup's header-extension
|
|
87
|
+
* union in order for the node's mediasoup-typed re-export to stay assignable.
|
|
88
|
+
*/
|
|
89
|
+
headerExtensions: [];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const MEDIA_CODECS: MediaCodecCapability[] = [
|
|
93
|
+
{
|
|
94
|
+
kind: "audio",
|
|
95
|
+
mimeType: "audio/opus",
|
|
96
|
+
clockRate: 48000,
|
|
97
|
+
channels: 2,
|
|
98
|
+
parameters: {
|
|
99
|
+
// In-band FEC costs a little bandwidth and buys intelligibility across
|
|
100
|
+
// exactly the packet loss a consultation is likely to hit.
|
|
101
|
+
useinbandfec: 1,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
kind: "video",
|
|
106
|
+
mimeType: "video/VP8",
|
|
107
|
+
clockRate: 90000,
|
|
108
|
+
parameters: {},
|
|
109
|
+
rtcpFeedback: [
|
|
110
|
+
{ type: "nack" },
|
|
111
|
+
{ type: "nack", parameter: "pli" },
|
|
112
|
+
{ type: "ccm", parameter: "fir" },
|
|
113
|
+
{ type: "goog-remb" },
|
|
114
|
+
{ type: "transport-cc" },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
kind: "video",
|
|
119
|
+
mimeType: "video/H264",
|
|
120
|
+
clockRate: 90000,
|
|
121
|
+
parameters: {
|
|
122
|
+
// Constrained baseline, level 3.1. `packetization-mode: 1` is what every
|
|
123
|
+
// browser actually offers; mode 0 exists and nothing sends it.
|
|
124
|
+
"packetization-mode": 1,
|
|
125
|
+
"profile-level-id": "42e01f",
|
|
126
|
+
// Without this Chrome offers H.264 and then never sends a parameter set
|
|
127
|
+
// out of band, so a late joiner sees nothing until the next IDR.
|
|
128
|
+
"level-asymmetry-allowed": 1,
|
|
129
|
+
},
|
|
130
|
+
rtcpFeedback: [
|
|
131
|
+
{ type: "nack" },
|
|
132
|
+
{ type: "nack", parameter: "pli" },
|
|
133
|
+
{ type: "ccm", parameter: "fir" },
|
|
134
|
+
{ type: "goog-remb" },
|
|
135
|
+
{ type: "transport-cc" },
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The audio half, for a participant that has no video at all.
|
|
142
|
+
*
|
|
143
|
+
* DERIVED, never written beside the list above. A hand-written copy for agents
|
|
144
|
+
* and SIP was compared against another hand-written copy in an earlier plan and
|
|
145
|
+
* never against this list, which is precisely the drift the pinning exists to
|
|
146
|
+
* prevent: both copies agree with each other and neither agrees with the fleet.
|
|
147
|
+
*/
|
|
148
|
+
export function audioOnlyCapabilities(codecs: MediaCodecCapability[] = MEDIA_CODECS): MediaRtpCapabilities {
|
|
149
|
+
return {
|
|
150
|
+
codecs: codecs
|
|
151
|
+
.filter((c) => c.kind === "audio")
|
|
152
|
+
.map((c, index) => ({
|
|
153
|
+
kind: "audio" as const,
|
|
154
|
+
mimeType: c.mimeType,
|
|
155
|
+
clockRate: c.clockRate,
|
|
156
|
+
...(c.channels ? { channels: c.channels } : {}),
|
|
157
|
+
...(c.parameters ? { parameters: c.parameters } : {}),
|
|
158
|
+
...(c.rtcpFeedback ? { rtcpFeedback: c.rtcpFeedback } : {}),
|
|
159
|
+
// Dynamic range. mediasoup assigns the real value per Router; this is
|
|
160
|
+
// only what a server-side participant claims it can receive.
|
|
161
|
+
preferredPayloadType: c.preferredPayloadType ?? 100 + index,
|
|
162
|
+
})),
|
|
163
|
+
headerExtensions: [],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { MEDIA_ERROR_CODES } from "./errors";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Separate from `errors.ts` so that file stays a plain union with no zod
|
|
6
|
+
* import, and so `frames.ts` can validate a code without a circular import
|
|
7
|
+
* through the error class.
|
|
8
|
+
*/
|
|
9
|
+
export const mediaErrorCodeSchema = z.enum(MEDIA_ERROR_CODES);
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every way the media plane refuses, named once.
|
|
3
|
+
*
|
|
4
|
+
* A closed union rather than free-text messages, because three surfaces (the
|
|
5
|
+
* SDK, the harness, the egress client) branch on these, and a string compare
|
|
6
|
+
* against prose drifts the moment someone improves the wording.
|
|
7
|
+
*/
|
|
8
|
+
export const MEDIA_ERROR_CODES = [
|
|
9
|
+
// token / session
|
|
10
|
+
"unauthorized", // token missing, malformed, or rejected
|
|
11
|
+
"replayed", // jti already consumed (a session concern, not a verifier one)
|
|
12
|
+
"protocol_version", // client and node disagree on MEDIA_PROTOCOL_VERSION
|
|
13
|
+
"forbidden_transport", // token's `transport` claim does not permit this transport kind
|
|
14
|
+
// authorization
|
|
15
|
+
"forbidden", // grants do not permit this operation
|
|
16
|
+
"not_subscribable", // maySubscribe said no - an information barrier, not an error
|
|
17
|
+
"subscription_limit", // maxSubscriptions or the active-set ceiling
|
|
18
|
+
// state
|
|
19
|
+
"room_closed",
|
|
20
|
+
"no_such_room",
|
|
21
|
+
"no_such_producer",
|
|
22
|
+
"no_such_transport",
|
|
23
|
+
"duplicate_identity", // that identity is already connected to this room
|
|
24
|
+
// capacity / lifecycle
|
|
25
|
+
"node_draining", // reconnect via the URL; the LB or a redirect will place you
|
|
26
|
+
"capacity",
|
|
27
|
+
// catch-alls
|
|
28
|
+
"bad_request",
|
|
29
|
+
"internal",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
export type MediaErrorCode = (typeof MEDIA_ERROR_CODES)[number];
|
|
33
|
+
|
|
34
|
+
export class MediaError extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
public readonly code: MediaErrorCode,
|
|
37
|
+
message?: string,
|
|
38
|
+
/** Safe to show a user? Most of these are not. */
|
|
39
|
+
public readonly retryable = false,
|
|
40
|
+
) {
|
|
41
|
+
super(message ?? code);
|
|
42
|
+
this.name = "MediaError";
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/frames.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { mediaErrorCodeSchema } from "./errorSchema";
|
|
3
|
+
import { mediaGrantsSchema, participantKindSchema, subscribeRuleSchema } from "./grants";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The client <-> node signalling wire.
|
|
7
|
+
*
|
|
8
|
+
* One envelope, stated once. Six phase plans independently invented four
|
|
9
|
+
* different ones (`m` / `type` / `method` / `action`, numeric versus string
|
|
10
|
+
* ids, `result` versus `data`), and every mismatch is a hard parse failure
|
|
11
|
+
* discovered at integration by a different pair of people.
|
|
12
|
+
*
|
|
13
|
+
* ## Shape
|
|
14
|
+
*
|
|
15
|
+
* Requests carry an `id` and get exactly one reply with the same `id`. Events
|
|
16
|
+
* carry no `id` and are never replied to. That is the whole protocol; anything
|
|
17
|
+
* needing more structure is a request that returns a handle.
|
|
18
|
+
*
|
|
19
|
+
* ## What is deliberately NOT here
|
|
20
|
+
*
|
|
21
|
+
* No pipe verb, no node id, no mesh address. A client cannot express "pipe this
|
|
22
|
+
* producer to that node", because piping is a side effect of `consume` and the
|
|
23
|
+
* only way to keep it lazy is for eager piping to be unsayable.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const requestFrameSchema = z.discriminatedUnion("method", [
|
|
27
|
+
z.object({
|
|
28
|
+
method: z.literal("join"),
|
|
29
|
+
id: z.number().int().nonnegative(),
|
|
30
|
+
protocolVersion: z.number().int(),
|
|
31
|
+
/** The token travels in the first frame, NEVER in the URL: a query string
|
|
32
|
+
* lands in load-balancer access logs, and a join ticket in a log is a join
|
|
33
|
+
* ticket for anyone who can read logs. */
|
|
34
|
+
token: z.string().min(1),
|
|
35
|
+
}),
|
|
36
|
+
z.object({
|
|
37
|
+
method: z.literal("createTransport"),
|
|
38
|
+
id: z.number().int().nonnegative(),
|
|
39
|
+
direction: z.enum(["send", "recv"]),
|
|
40
|
+
/**
|
|
41
|
+
* Deliberately absent: which KIND of transport this is.
|
|
42
|
+
*
|
|
43
|
+
* The kind comes from the token's `transport` claim and from nowhere else.
|
|
44
|
+
* Letting a client ask would mean a browser holding a valid join token
|
|
45
|
+
* could request a PlainTransport pointed at an address of its choosing -
|
|
46
|
+
* an RTP reflector, plus an unauthenticated publish path into whatever room
|
|
47
|
+
* the token names. Three phases independently needed to refuse that and
|
|
48
|
+
* none could, because nothing carried the fact.
|
|
49
|
+
*
|
|
50
|
+
* So there is no field here to refuse. The claim decides.
|
|
51
|
+
*/
|
|
52
|
+
sctp: z.boolean().optional(),
|
|
53
|
+
}),
|
|
54
|
+
z.object({
|
|
55
|
+
method: z.literal("connectTransport"),
|
|
56
|
+
id: z.number().int().nonnegative(),
|
|
57
|
+
transportId: z.string(),
|
|
58
|
+
/** WebRTC. */
|
|
59
|
+
dtlsParameters: z.unknown().optional(),
|
|
60
|
+
/**
|
|
61
|
+
* Plain and pipe transports have no DTLS handshake to carry the address, so
|
|
62
|
+
* a server-side participant names where its RTP comes from. This is the
|
|
63
|
+
* half the token claim cannot cover: the claim says WHO may create a plain
|
|
64
|
+
* transport, and `MEDIA_SERVER_PEER_CIDRS` says WHERE its media may be
|
|
65
|
+
* pointed. Both are required; either alone leaves a reflector.
|
|
66
|
+
*/
|
|
67
|
+
ip: z.string().optional(),
|
|
68
|
+
port: z.number().int().positive().max(65535).optional(),
|
|
69
|
+
rtcpPort: z.number().int().positive().max(65535).optional(),
|
|
70
|
+
srtpParameters: z.unknown().optional(),
|
|
71
|
+
}),
|
|
72
|
+
z.object({
|
|
73
|
+
method: z.literal("produce"),
|
|
74
|
+
id: z.number().int().nonnegative(),
|
|
75
|
+
transportId: z.string(),
|
|
76
|
+
kind: z.enum(["audio", "video"]),
|
|
77
|
+
/** Opaque to this package: only mediasoup interprets it. */
|
|
78
|
+
rtpParameters: z.unknown(),
|
|
79
|
+
appData: z.record(z.unknown()).optional(),
|
|
80
|
+
}),
|
|
81
|
+
z.object({
|
|
82
|
+
method: z.literal("consume"),
|
|
83
|
+
id: z.number().int().nonnegative(),
|
|
84
|
+
transportId: z.string(),
|
|
85
|
+
producerId: z.string(),
|
|
86
|
+
rtpCapabilities: z.unknown(),
|
|
87
|
+
}),
|
|
88
|
+
z.object({
|
|
89
|
+
method: z.literal("pauseProducer"),
|
|
90
|
+
id: z.number().int().nonnegative(),
|
|
91
|
+
producerId: z.string(),
|
|
92
|
+
}),
|
|
93
|
+
z.object({
|
|
94
|
+
method: z.literal("resumeProducer"),
|
|
95
|
+
id: z.number().int().nonnegative(),
|
|
96
|
+
producerId: z.string(),
|
|
97
|
+
}),
|
|
98
|
+
z.object({
|
|
99
|
+
method: z.literal("resumeConsumer"),
|
|
100
|
+
id: z.number().int().nonnegative(),
|
|
101
|
+
consumerId: z.string(),
|
|
102
|
+
}),
|
|
103
|
+
/**
|
|
104
|
+
* Drop a subscription.
|
|
105
|
+
*
|
|
106
|
+
* Needed because the active set CHURNS: as the conversation moves, a client
|
|
107
|
+
* following it stops wanting producers it wanted a moment ago. Closing only
|
|
108
|
+
* the client's own consumer leaves the node holding a Consumer object per
|
|
109
|
+
* dropped subscription, and in a large room that accumulates for the life of
|
|
110
|
+
* the call - on the node, which is the side with the ceiling.
|
|
111
|
+
*/
|
|
112
|
+
z.object({
|
|
113
|
+
method: z.literal("closeConsumer"),
|
|
114
|
+
id: z.number().int().nonnegative(),
|
|
115
|
+
consumerId: z.string(),
|
|
116
|
+
}),
|
|
117
|
+
z.object({
|
|
118
|
+
method: z.literal("closeProducer"),
|
|
119
|
+
id: z.number().int().nonnegative(),
|
|
120
|
+
producerId: z.string(),
|
|
121
|
+
}),
|
|
122
|
+
/** A keyframe request. Without it every active-speaker switch shows garbage
|
|
123
|
+
* until the encoder happens to send one. */
|
|
124
|
+
z.object({
|
|
125
|
+
method: z.literal("requestKeyFrame"),
|
|
126
|
+
id: z.number().int().nonnegative(),
|
|
127
|
+
consumerId: z.string(),
|
|
128
|
+
}),
|
|
129
|
+
z.object({ method: z.literal("leave"), id: z.number().int().nonnegative() }),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
export type RequestFrame = z.infer<typeof requestFrameSchema>;
|
|
133
|
+
export type RequestMethod = RequestFrame["method"];
|
|
134
|
+
|
|
135
|
+
export const responseFrameSchema = z.union([
|
|
136
|
+
z.object({ id: z.number().int().nonnegative(), ok: z.literal(true), data: z.unknown() }),
|
|
137
|
+
z.object({
|
|
138
|
+
id: z.number().int().nonnegative(),
|
|
139
|
+
ok: z.literal(false),
|
|
140
|
+
/** The client sees a CODE, never why a token was refused. Telling a caller
|
|
141
|
+
* whether the signature or the room was wrong turns this into an oracle. */
|
|
142
|
+
code: mediaErrorCodeSchema,
|
|
143
|
+
message: z.string().optional(),
|
|
144
|
+
}),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
export type ResponseFrame = z.infer<typeof responseFrameSchema>;
|
|
148
|
+
|
|
149
|
+
/** A participant as everyone else sees them. Grants are NOT included: what
|
|
150
|
+
* someone else is permitted to do is not the room's business. */
|
|
151
|
+
export const peerSchema = z.object({
|
|
152
|
+
identity: z.string(),
|
|
153
|
+
name: z.string().optional(),
|
|
154
|
+
kind: participantKindSchema,
|
|
155
|
+
});
|
|
156
|
+
export type Peer = z.infer<typeof peerSchema>;
|
|
157
|
+
|
|
158
|
+
export const eventFrameSchema = z.discriminatedUnion("event", [
|
|
159
|
+
z.object({
|
|
160
|
+
event: z.literal("joined"),
|
|
161
|
+
identity: z.string(),
|
|
162
|
+
room: z.string(),
|
|
163
|
+
/** Everything the client needs to build its device before any transport. */
|
|
164
|
+
routerRtpCapabilities: z.unknown(),
|
|
165
|
+
peers: z.array(peerSchema),
|
|
166
|
+
/** Producers this participant is ALLOWED to see. The barrier is applied to
|
|
167
|
+
* the snapshot, not only at consume: an identity it may not receive must
|
|
168
|
+
* not be discoverable either. */
|
|
169
|
+
producers: z.array(z.object({ producerId: z.string(), identity: z.string(), kind: z.enum(["audio", "video"]) })),
|
|
170
|
+
iceServers: z.array(z.object({ urls: z.union([z.string(), z.array(z.string())]), username: z.string().optional(), credential: z.string().optional() })),
|
|
171
|
+
/** True while any `kind: "egress"` participant is present. A consent
|
|
172
|
+
* requirement, not a nicety. */
|
|
173
|
+
recording: z.boolean(),
|
|
174
|
+
/**
|
|
175
|
+
* What this token was minted with, echoed back.
|
|
176
|
+
*
|
|
177
|
+
* The client already holds the token but must not parse it: a signed blob
|
|
178
|
+
* a client reads is a signed blob a client eventually trusts its own
|
|
179
|
+
* reading of. This is the node's understanding of the grants, which is the
|
|
180
|
+
* one that will actually be enforced, so a UI built from it cannot offer a
|
|
181
|
+
* control the node is about to refuse.
|
|
182
|
+
*/
|
|
183
|
+
grants: mediaGrantsSchema.optional(),
|
|
184
|
+
}),
|
|
185
|
+
z.object({ event: z.literal("peerJoined"), peer: peerSchema }),
|
|
186
|
+
z.object({ event: z.literal("peerLeft"), identity: z.string() }),
|
|
187
|
+
z.object({
|
|
188
|
+
event: z.literal("producerAppeared"),
|
|
189
|
+
producerId: z.string(),
|
|
190
|
+
identity: z.string(),
|
|
191
|
+
kind: z.enum(["audio", "video"]),
|
|
192
|
+
}),
|
|
193
|
+
z.object({ event: z.literal("producerClosed"), producerId: z.string() }),
|
|
194
|
+
z.object({ event: z.literal("producerPaused"), producerId: z.string(), paused: z.boolean() }),
|
|
195
|
+
/**
|
|
196
|
+
* The active set the node wants this client to consume. How a 1,000-person
|
|
197
|
+
* room stays survivable: the server decides, the client follows.
|
|
198
|
+
*
|
|
199
|
+
* `producerIds` is the whole set and is what a simple client renders.
|
|
200
|
+
* `audio` and `video` are the same set split, because the two limits differ
|
|
201
|
+
* (you can follow more voices than you can fit faces). `enforced` says
|
|
202
|
+
* whether a consume outside the set will actually be REFUSED - below the
|
|
203
|
+
* threshold the set is advisory and a client that ignores it is not wrong.
|
|
204
|
+
*
|
|
205
|
+
* `speakers` carries the ordering and the levels, which is what drives a
|
|
206
|
+
* layout switch and a level meter. Ordered loudest first.
|
|
207
|
+
*/
|
|
208
|
+
z.object({
|
|
209
|
+
event: z.literal("activeSpeakers"),
|
|
210
|
+
producerIds: z.array(z.string()),
|
|
211
|
+
audio: z.array(z.string()).optional(),
|
|
212
|
+
video: z.array(z.string()).optional(),
|
|
213
|
+
enforced: z.boolean().optional(),
|
|
214
|
+
speakers: z
|
|
215
|
+
.array(z.object({ identity: z.string(), producerId: z.string(), volumeDb: z.number() }))
|
|
216
|
+
.optional(),
|
|
217
|
+
}),
|
|
218
|
+
z.object({ event: z.literal("recordingChanged"), recording: z.boolean() }),
|
|
219
|
+
/** This node is going away. Reconnect through MEDIA_URL; do not retry here. */
|
|
220
|
+
z.object({ event: z.literal("draining"), reconnectAfterMs: z.number().int().nonnegative() }),
|
|
221
|
+
z.object({ event: z.literal("roomClosed"), reason: z.string() }),
|
|
222
|
+
/** Barrier changes are pushed, because a rule that only applies at join is a
|
|
223
|
+
* rule that does not apply to anyone already in the room. */
|
|
224
|
+
z.object({ event: z.literal("subscribeRuleChanged"), subscribe: subscribeRuleSchema }),
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
export type EventFrame = z.infer<typeof eventFrameSchema>;
|
|
228
|
+
export type EventName = EventFrame["event"];
|
|
229
|
+
|
|
230
|
+
/** Anything arriving from a node. */
|
|
231
|
+
export const serverFrameSchema = z.union([responseFrameSchema, eventFrameSchema]);
|
|
232
|
+
export type ServerFrame = z.infer<typeof serverFrameSchema>;
|
|
233
|
+
|
|
234
|
+
export const isEvent = (frame: ServerFrame): frame is EventFrame => "event" in frame;
|