@furious.luke/argus-js 0.3.1 → 0.5.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @furious.luke/argus-js
2
2
 
3
- Browser client for the [Argus](https://github.com/furious-luke/go-projects/tree/main/argus) video streaming platform. It publishes a `MediaStream` (camera, screen share, etc.) from a browser to an Argus media server over WebRTC, handling gateway selection, the signaling handshake, and ICE negotiation for you.
3
+ Browser client for [Argus](https://github.com/furious-luke/go-projects/tree/main/argus) WebRTC sessions. It establishes the reliable agent text channel and optionally publishes camera, screen, or microphone media, handling gateway selection, signaling, and ICE negotiation for you.
4
4
 
5
5
  Zero runtime dependencies — it uses only the browser's built-in `WebSocket` and `RTCPeerConnection`.
6
6
 
@@ -9,8 +9,11 @@ Zero runtime dependencies — it uses only the browser's built-in `WebSocket` an
9
9
  Argus is a distributed video ingestion service. A stream flows through three parties:
10
10
 
11
11
  - **Your server** mints a short-lived **join token** using its secret API key (see the [Go client](https://github.com/furious-luke/go-projects/tree/main/argus/client), published as the `github.com/furious-luke/argus-go` module, or the control-plane HTTP API).
12
- - **The browser** (this library) uses that token to publish video to the nearest Argus media server. It never sees the API key.
13
- - **Your server** later fetches frames from the stream on demand.
12
+ - **The browser** (this library) uses that token to establish a text-only or media-backed session with the nearest Argus media server. It never sees the API key.
13
+ - **The browser** relays the winning gateway URL and its region-scoped read token
14
+ back to your server. The URL routes both frame reads and notifications to the
15
+ selected region; the read token authorizes frame reads only, while notifications
16
+ use the server-held control token.
14
17
 
15
18
  This library is only the browser publishing half. It deliberately does **not** talk to the control plane or mint tokens — that requires your secret API key and must stay server-side.
16
19
 
@@ -20,7 +23,8 @@ This library is only the browser publishing half. It deliberately does **not** t
20
23
  │ │ ◀─────────────────────── │ │ ◀── token + gateway_urls ──
21
24
  │ argus-js │ 2. token + gateway_urls └────────────┘
22
25
  │ │
23
- │ │ 3. publish WebRTC video ────────────────▶ Argus media server
26
+ │ │ 3. WebRTC text + optional media ────────▶ Argus media server
27
+ │ │ 4. frameReadToken + selectedGatewayURL ─▶ your server
24
28
  └──────────┘
25
29
  ```
26
30
 
@@ -42,24 +46,50 @@ const publisher = new Publisher({
42
46
  gatewayURLs: join.gateway_urls,
43
47
  token: join.token,
44
48
  callbacks: {
45
- onConnected: () => console.log("streaming live"),
49
+ onConnected: async () => {
50
+ console.log("streaming live");
51
+ // Relay these as a pair; the read token is valid in the selected region.
52
+ await fetch("/api/stream-credentials", {
53
+ method: "POST",
54
+ headers: { "Content-Type": "application/json" },
55
+ body: JSON.stringify({
56
+ stream_id: join.stream_id,
57
+ read_token: publisher.frameReadToken,
58
+ gateway_url: publisher.selectedGatewayURL,
59
+ }),
60
+ });
61
+ },
46
62
  onConnectionStateChange: (state) => console.log("state:", state),
47
63
  onRecoveryStateChange: (event) => console.log("media recovery:", event),
48
64
  // Show a button or prompt. From its click handler, call captureScreen()
49
- // and then publisher.replaceStream(newStream).
50
- onRecoveryRequired: (event) => console.warn("screen share must be restarted", event),
65
+ // and then publisher.publish(newStream, "screen").
66
+ onRecoveryRequired: (event) => console.warn(`${event.track} must be restarted`, event),
51
67
  onError: (err) => console.error("publish error:", err),
52
68
  },
53
69
  });
54
70
 
55
71
  // captureCamera() applies sensible capture defaults; any MediaStream works too.
72
+ // The second argument labels the track type (defaults to "camera").
56
73
  const stream = await captureCamera();
57
- await publisher.start(stream);
74
+ await publisher.start(stream, "camera");
75
+ ```
76
+
77
+ For a typed agent that needs no initial media or capture permission, start the
78
+ same WebRTC session with only its ordered text data channel:
58
79
 
59
- // After start() resolves, this token lets your server fetch frames for the stream.
60
- console.log("read token:", publisher.frameReadToken);
80
+ ```ts
81
+ await publisher.startTextOnly();
82
+ publisher.sendUserText(crypto.randomUUID(), "Hello");
61
83
  ```
62
84
 
85
+ Camera, screen, microphone, and the optional inbound speech track can still be
86
+ added later through `publish`, `publishMicrophone`, and `enableSpeech`.
87
+
88
+ Each published track is labelled with a **track type** (`"camera"` or
89
+ `"screen"`). The label is declared to Argus during signaling, so frame reads and
90
+ change-notification subscriptions can address a specific track by type. A stream
91
+ may carry one track of each type at a time.
92
+
63
93
  ### Capture helpers
64
94
 
65
95
  `captureCamera()` and `captureScreen()` wrap `getUserMedia` / `getDisplayMedia` with defaults tuned for streaming — a capped resolution and modest frame rate, audio off. They matter most for screen sharing: on HiDPI/Retina displays a raw `getDisplayMedia` captures at native resolution (often 3456px+ / effectively 4k), wasting upload bandwidth and downstream decode for no benefit. `captureScreen()` caps the width instead.
@@ -68,18 +98,34 @@ console.log("read token:", publisher.frameReadToken);
68
98
  import { captureScreen } from "@furious.luke/argus-js";
69
99
 
70
100
  const stream = await captureScreen();
71
- await publisher.start(stream);
101
+ await publisher.start(stream, "screen");
72
102
  ```
73
103
 
74
104
  Both accept overrides (shallow-merged over the defaults), and any `MediaStream` you build yourself still works if you'd rather manage constraints directly.
75
105
 
76
- ### Switching sources without reconnecting
106
+ ### Adding and removing tracks live
77
107
 
78
- `replaceStream` renegotiates in place, e.g. to toggle between camera and screen:
108
+ Start with one track, then add or remove others without reconnecting. `publish`
109
+ adds a track of a given type (renegotiating in place); `unpublish` removes it.
110
+ Each supplied stream must contain exactly one video track. Publishing a second
111
+ track of a type replaces the first.
79
112
 
80
113
  ```ts
114
+ // Live camera already publishing from start(stream, "camera")...
81
115
  const screen = await captureScreen();
82
- await publisher.replaceStream(screen);
116
+ await publisher.publish(screen, "screen"); // now streaming camera + screen
117
+
118
+ // Later, stop sharing the screen but keep the camera live.
119
+ await publisher.unpublish("screen");
120
+ ```
121
+
122
+ `replaceStream(stream, type?)` remains available as a convenience — it delegates
123
+ to `publish` and defaults to the `"camera"` type. It is handy from
124
+ `onRecoveryRequired` to swap in a freshly reacquired screen share:
125
+
126
+ ```ts
127
+ const screen = await captureScreen();
128
+ await publisher.replaceStream(screen, "screen");
83
129
  ```
84
130
 
85
131
  ### Stopping
@@ -90,13 +136,50 @@ publisher.stop(); // stops all tracks and tears down the peer connection
90
136
 
91
137
  ## API
92
138
 
139
+ ### Assistant speech and text
140
+
141
+ Every publisher creates an ordered, reliable `argus.text` data channel. Enable
142
+ the persistent outbound speech track only after the user opts in:
143
+
144
+ ```ts
145
+ const publisher = new Publisher({
146
+ gatewayURLs: join.gateway_urls,
147
+ token: join.token,
148
+ callbacks: {
149
+ onSpeechTrack(track) {
150
+ const audio = new Audio();
151
+ audio.srcObject = new MediaStream([track]);
152
+ void audio.play();
153
+ },
154
+ onAssistantText({ utteranceId, text }) {
155
+ renderCaption(utteranceId, text);
156
+ },
157
+ onUserTextResult(result) {
158
+ console.log(result.messageId, result.accepted);
159
+ },
160
+ },
161
+ });
162
+
163
+ await publisher.startTextOnly();
164
+ await publisher.enableSpeech(); // call from the application's user action
165
+ publisher.sendUserText(crypto.randomUUID(), "Stop and explain that again");
166
+ ```
167
+
168
+ `enableSpeech()` is idempotent. The remote track is labelled `speech` and stays
169
+ attached but silent between utterances. `sendUserText` is admitted only while
170
+ the customer server owns a live control-token notify subscription.
171
+
93
172
  ### `new Publisher(options)`
94
173
 
95
174
  | Option | Type | Description |
96
175
  | --- | --- | --- |
97
- | `gatewayURLs` | `string[]` | **Required.** The `gateway_urls` from the join response. All are raced simultaneously; the fastest to accept wins. |
176
+ | `gatewayURLs` | `string[]` | **Required.** The `gateway_urls` from the join response. All are raced simultaneously; the first to return `ready` wins. |
98
177
  | `token` | `string` | **Required.** The short-lived join token from the join response. |
99
178
  | `iceServers` | `RTCIceServer[]` | Optional extra ICE servers (e.g. your own STUN). TURN is supplied automatically by the winning gateway. |
179
+ | `iceTransportPolicy` | `RTCIceTransportPolicy` | Passed to the underlying `RTCPeerConnection`. Defaults to `"all"`. Set `"relay"` to force media through TURN only (verifies the relay path end to end). |
180
+ | `turnTransportPolicy` | `"all" \| "udp" \| "tls"` | Restricts gateway TURN URLs. Defaults to `"all"`; use `"tls"` with relay-only ICE to verify TURN over TLS. Startup fails if the required transport was not advertised. |
181
+ | `gatewayHandshakeTimeoutMs` | `number` | Overall deadline for the initial gateway race and `accepted` → `ready` handshake. Unaccepted sockets are replaced after 3 seconds so a blackholed TCP flow cannot consume the full deadline. Defaults to 20 seconds. |
182
+ | `peerConnectionTimeoutMs` | `number` | Deadline after the initial offer for WebRTC to reach `connected`. Defaults to 30 seconds. |
100
183
  | `signalingReconnectTimeoutMs` | `number` | How long to retry a dropped signaling socket against the selected regional gateway. Defaults to 20 seconds. |
101
184
  | `callbacks` | `PublisherCallbacks` | Optional lifecycle callbacks (see below). |
102
185
 
@@ -104,10 +187,19 @@ publisher.stop(); // stops all tracks and tears down the peer connection
104
187
 
105
188
  | Member | Description |
106
189
  | --- | --- |
107
- | `start(stream)` | Races the gateways, completes the handshake, and sends the SDP offer. Resolves once the offer is sent — use `onConnected` to know when media is actually flowing. |
108
- | `replaceStream(stream)` | Replaces the published tracks and renegotiates in place. |
190
+ | `start(stream, type?)` | Races the gateways, completes the handshake, and sends the SDP offer. The stream must contain exactly one video track; `type` labels it, defaulting to `"camera"`. Resolves once the offer is sent — use `onConnected` to know when media is actually flowing. |
191
+ | `startTextOnly()` | Starts with only the reliable ordered `argus.text` data channel and requests no capture permission. Media or speech may be added later. |
192
+ | `startAudioOnly(stream)` | Starts with the stream's single microphone track plus the text data channel. |
193
+ | `publish(stream, type)` | Adds the stream's single video track under `type` and renegotiates in place. Replaces any existing track of the same type. |
194
+ | `publishMicrophone(stream)` | Adds or replaces the session's microphone track and renegotiates in place (turns on transcription). |
195
+ | `unpublish(type)` | Removes the live track(s) of `type`, stops their capture, and renegotiates so Argus ends that track. |
196
+ | `unpublishMicrophone()` | Removes the microphone track and renegotiates (ends transcription). |
197
+ | `enableSpeech()` | Opts into the inbound `speech` track for text-to-speech. Idempotent; call from a user action. |
198
+ | `sendUserText(messageId, text)` | Sends typed input over the `argus.text` data channel (≤4 KiB). |
199
+ | `replaceStream(stream, type?)` | Convenience wrapper over `publish`; defaults to `"camera"`. |
109
200
  | `stop()` | Stops all local tracks and closes the peer connection. |
110
201
  | `frameReadToken` | The one-hour read token from the gateway's `ready` message, or `null` before connecting. The publisher uses it internally to resume signaling; hand it to your server to fetch frames. |
202
+ | `selectedGatewayURL` | The signaling URL that won the regional race, or `null` before connecting. Relay it so your server uses the same region for frame reads and notifications; `frameReadToken` authorizes only the frame reads. |
111
203
  | `peerConnection` | The underlying `RTCPeerConnection`, or `null` if not started. |
112
204
  | `isConnected` | `true` when the peer connection state is `"connected"`. |
113
205
 
@@ -119,13 +211,20 @@ publisher.stop(); // stops all tracks and tears down the peer connection
119
211
  | `onConnectionStateChange(state)` | The `RTCPeerConnectionState` changed. |
120
212
  | `onRecoveryStateChange(event)` | Argus detected stalled media and the publisher started, escalated, completed, or failed automatic recovery. |
121
213
  | `onRecoveryRequired(event)` | Automatic recovery could not restore media, or capture ended and the host must ask the user for a new screen share. |
122
- | `onError(error)` | A fatal error occurred (signaling error, initial gateway failure, or signaling resume timed out). |
214
+ | `onSpeechTrack(track, streams)` | The inbound `speech` track arrived after `enableSpeech()` attach it to an `<audio>` element to play text-to-speech. |
215
+ | `onAssistantText({ utteranceId, text })` | Assistant text arrived; it is paced with synthesized speech when speech is enabled and delivered immediately in text-only mode. |
216
+ | `onUserTextResult({ messageId, accepted, reason })` | The server accepted or rejected a `sendUserText` message. |
217
+ | `onError(error)` | A fatal error occurred (signaling error, WebRTC connection failure/timeout, or signaling resume timed out). |
123
218
 
124
219
  ## How `start()` works
125
220
 
126
221
  1. **Gateway race.** Every URL in `gatewayURLs` is opened at once with the token in the query string. The first to complete the two-phase handshake (`accepted` → `proceed` → `ready`) wins; the rest are closed. This picks the lowest-latency region without a separate probe.
127
222
  2. **TURN + read token.** The winning gateway's `ready` message carries per-session TURN credentials (merged into the ICE configuration) and the read token exposed as `frameReadToken`.
128
- 3. **WebRTC.** A peer connection is created, tracks are added, and an offer is sent. Remote ICE candidates that arrive before the SDP answer are buffered and flushed once the answer is applied.
223
+ 3. **WebRTC.** A peer connection and the text data channel are created. Any
224
+ initial media track is added and labelled, then an offer is sent. Remote ICE
225
+ candidates that arrive before the SDP answer are buffered and flushed once
226
+ the answer is applied. Locally gathered candidates remain queued until their
227
+ signaling write succeeds and are replayed after signaling resume.
129
228
 
130
229
  After this initial race, the publisher is pinned to the selected regional
131
230
  gateway. If signaling drops, it reconnects to that same gateway with the
@@ -133,6 +232,22 @@ one-hour read token and waits for `resumed`; it does not race regions, rebuild
133
232
  the peer connection, or repeat the `ready` handshake. If the retry deadline
134
233
  expires, the publisher closes the stream and calls `onError`.
135
234
 
235
+ Leave both transport policies at their defaults for normal use. To prove the
236
+ firewall-friendly TURN TLS path from a VPN or restrictive network, force both
237
+ relay ICE and TLS TURN:
238
+
239
+ ```ts
240
+ const publisher = new Publisher({
241
+ gatewayURLs: join.gateway_urls,
242
+ token: join.token,
243
+ iceTransportPolicy: "relay",
244
+ turnTransportPolicy: "tls",
245
+ });
246
+ ```
247
+
248
+ This diagnostic configuration fails startup when the winning gateway did not
249
+ advertise a TLS TURN URL instead of silently testing another path.
250
+
136
251
  ## Automatic media recovery
137
252
 
138
253
  Argus measures freshness from complete encoded samples received by the media
@@ -143,11 +258,12 @@ first detaches and reattaches the live sender and renegotiates. If samples do no
143
258
  resume within four seconds, it performs an ICE restart and waits another eight
144
259
  seconds. These fixed timings deliberately are not application configuration.
145
260
 
146
- `onRecoveryStateChange` reports the recovery transitions. If both automatic
147
- steps fail, `onRecoveryRequired` is called and the host can acquire a replacement
148
- stream and pass it to `replaceStream`. A browser cannot silently reacquire a
149
- screen share after the user or operating system ends it, so `capture_ended`
150
- always requires host UI and a fresh `captureScreen()` call.
261
+ `onRecoveryStateChange` reports the recovery transitions, and the event's `track`
262
+ field names the affected track type. If both automatic steps fail,
263
+ `onRecoveryRequired` is called and the host can acquire a replacement stream and
264
+ pass it to `publish` (or `replaceStream`) for that track type. A browser cannot
265
+ silently reacquire a screen share after the user or operating system ends it, so
266
+ `capture_ended` always requires host UI and a fresh `captureScreen()` call.
151
267
 
152
268
  ## Browser support
153
269
 
@@ -164,7 +280,7 @@ npm run typecheck # tsc --noEmit
164
280
 
165
281
  ## Publishing
166
282
 
167
- The package is published to npm under the `@furious-luke` scope. `prepublishOnly` runs typecheck, tests, and build first, so a release is:
283
+ The package is published to npm under the `@furious.luke` scope. `prepublishOnly` runs typecheck, tests, and build first, so a release is:
168
284
 
169
285
  ```bash
170
286
  npm version <patch|minor|major>