@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.
Files changed (84) hide show
  1. package/build/_fixtures/joinTokenVectors.json +103 -0
  2. package/build/claims.d.ts +327 -0
  3. package/build/claims.d.ts.map +1 -0
  4. package/build/claims.js +91 -0
  5. package/build/claims.js.map +1 -0
  6. package/build/codecs.d.ts +102 -0
  7. package/build/codecs.d.ts.map +1 -0
  8. package/build/codecs.js +138 -0
  9. package/build/codecs.js.map +1 -0
  10. package/build/errorSchema.d.ts +8 -0
  11. package/build/errorSchema.d.ts.map +1 -0
  12. package/build/errorSchema.js +12 -0
  13. package/build/errorSchema.js.map +1 -0
  14. package/build/errors.d.ts +18 -0
  15. package/build/errors.d.ts.map +1 -0
  16. package/build/errors.js +47 -0
  17. package/build/errors.js.map +1 -0
  18. package/build/frames.d.ts +1291 -0
  19. package/build/frames.d.ts.map +1 -0
  20. package/build/frames.js +222 -0
  21. package/build/frames.js.map +1 -0
  22. package/build/grants.d.ts +307 -0
  23. package/build/grants.d.ts.map +1 -0
  24. package/build/grants.js +228 -0
  25. package/build/grants.js.map +1 -0
  26. package/build/index.d.ts +24 -0
  27. package/build/index.d.ts.map +1 -0
  28. package/build/index.js +40 -0
  29. package/build/index.js.map +1 -0
  30. package/build/mp-build/_fixtures/joinTokenVectors.json +103 -0
  31. package/build/mp-build/claims.d.ts +299 -0
  32. package/build/mp-build/claims.d.ts.map +1 -0
  33. package/build/mp-build/claims.js +60 -0
  34. package/build/mp-build/claims.js.map +1 -0
  35. package/build/mp-build/errorSchema.d.ts +8 -0
  36. package/build/mp-build/errorSchema.d.ts.map +1 -0
  37. package/build/mp-build/errorSchema.js +12 -0
  38. package/build/mp-build/errorSchema.js.map +1 -0
  39. package/build/mp-build/errors.d.ts +18 -0
  40. package/build/mp-build/errors.d.ts.map +1 -0
  41. package/build/mp-build/errors.js +47 -0
  42. package/build/mp-build/errors.js.map +1 -0
  43. package/build/mp-build/frames.d.ts +786 -0
  44. package/build/mp-build/frames.d.ts.map +1 -0
  45. package/build/mp-build/frames.js +153 -0
  46. package/build/mp-build/frames.js.map +1 -0
  47. package/build/mp-build/grants.d.ts +245 -0
  48. package/build/mp-build/grants.d.ts.map +1 -0
  49. package/build/mp-build/grants.js +135 -0
  50. package/build/mp-build/grants.js.map +1 -0
  51. package/build/mp-build/index.d.ts +23 -0
  52. package/build/mp-build/index.d.ts.map +1 -0
  53. package/build/mp-build/index.js +39 -0
  54. package/build/mp-build/index.js.map +1 -0
  55. package/build/mp-build/token.d.ts +66 -0
  56. package/build/mp-build/token.d.ts.map +1 -0
  57. package/build/mp-build/token.js +208 -0
  58. package/build/mp-build/token.js.map +1 -0
  59. package/build/mp-build/version.d.ts +9 -0
  60. package/build/mp-build/version.d.ts.map +1 -0
  61. package/build/mp-build/version.js +12 -0
  62. package/build/mp-build/version.js.map +1 -0
  63. package/build/token.d.ts +66 -0
  64. package/build/token.d.ts.map +1 -0
  65. package/build/token.js +208 -0
  66. package/build/token.js.map +1 -0
  67. package/build/version.d.ts +9 -0
  68. package/build/version.d.ts.map +1 -0
  69. package/build/version.js +12 -0
  70. package/build/version.js.map +1 -0
  71. package/package.json +53 -0
  72. package/src/_fixtures/joinTokenVectors.json +103 -0
  73. package/src/_tests/codecs.spec.ts +66 -0
  74. package/src/_tests/publishGrants.spec.ts +109 -0
  75. package/src/_tests/token.spec.ts +272 -0
  76. package/src/claims.ts +116 -0
  77. package/src/codecs.ts +165 -0
  78. package/src/errorSchema.ts +9 -0
  79. package/src/errors.ts +44 -0
  80. package/src/frames.ts +234 -0
  81. package/src/grants.ts +248 -0
  82. package/src/index.ts +23 -0
  83. package/src/token.ts +237 -0
  84. 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;