dsh-realtime-audio-ws 0.2.2 → 0.2.3

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/lib/strip.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The strip: the panel this plugin puts in the host's own page.
3
+ *
4
+ * S2 story 3. Until it existed the plugin was driven from a `globalThis` key in a devtools console, and the
5
+ * roadmap's judgement on that was blunt — *a plugin driven from a console global will not be installed by
6
+ * anyone*. What changes is not decoration: the on-switch moves into the page, and `sessionId`, which cost
7
+ * two restarts and a false lead to change, becomes a picker.
8
+ *
9
+ * ## Where the behaviour lives, and why not here
10
+ *
11
+ * This module mints **rows** for `webserver/index-inject` — the door the page already receives its settings
12
+ * through. Two of the six row kinds carry UI: `html` for raw markup, `script` for an inline classic script.
13
+ * So the markup is here and the bootstrap is here, and **the panel's logic is not**: everything that
14
+ * decides what to render and what a button does lives in the client bundle
15
+ * (`src/client/index.ts`), where it is covered by tests. A panel implemented inside an injected string is a
16
+ * feature nothing can execute in CI, which is the same defect class as a build artefact no test reads.
17
+ *
18
+ * The bootstrap is therefore three statements, and it is still *executed* by a test — as a function with a
19
+ * fake scope — so the injected code is not exempt from the gate just because it is shipped as text.
20
+ *
21
+ * ## Two ways in, deliberately
22
+ *
23
+ * The bundle mounts the panel when it applies, and the injected script mounts it again in case it lands
24
+ * first. Both are idempotent, because the order between the module table and the body rows is not something
25
+ * this package controls, and a panel that appears only when two independent orderings agree is a panel that
26
+ * appears only on the author's machine.
27
+ *
28
+ * @module dsh-realtime-audio-ws/strip
29
+ */
30
+ /** The element the panel mounts on. Duplicated in the client half, which cannot import it. */
31
+ export const STRIP_ELEMENT_ID = 'dsh-realtime-strip';
32
+ /**
33
+ * The panel's container, as markup.
34
+ *
35
+ * Fixed to the bottom right and deliberately nearly unstyled: it sits inside the host's own page, so it
36
+ * inherits that page's font and colour rather than inventing a second visual language beside it. A
37
+ * translucent wash rather than a border, because a border here would be visual noise around a strip whose
38
+ * job is to be glanced at.
39
+ * @returns one `html` row's payload.
40
+ */
41
+ export function stripMarkup() {
42
+ return [
43
+ `<div id="${STRIP_ELEMENT_ID}"`,
44
+ ' style="position:fixed;right:12px;bottom:12px;z-index:40;',
45
+ 'max-width:26rem;max-height:60vh;overflow:auto;padding:10px 12px;',
46
+ 'font:inherit;color:inherit;line-height:1.4;',
47
+ 'background:color-mix(in srgb, currentColor 8%, transparent);border-radius:10px">',
48
+ '</div>',
49
+ ].join('');
50
+ }
51
+ /**
52
+ * The bootstrap the page runs after the markup.
53
+ *
54
+ * It calls the bundle's own `mount`, and does nothing when the bundle has not been materialised yet — in
55
+ * which case the bundle mounts the panel itself when it applies, or this is called again on
56
+ * `DOMContentLoaded`. Nothing here decides anything about the panel; it is a doorbell.
57
+ * @returns one `script` row's payload.
58
+ */
59
+ export function stripBootstrap() {
60
+ return [
61
+ ';(function () {',
62
+ " function mount() { var G = globalThis.__dshRealtimeAudio; if (G && typeof G.mount === 'function') G.mount() }",
63
+ ' mount()',
64
+ " if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount)",
65
+ '})()',
66
+ ].join('\n');
67
+ }
68
+ /**
69
+ * The two rows this plugin contributes for its panel.
70
+ *
71
+ * `body` placement for both: the strip belongs to the page's chrome rather than to its head, and a `body`
72
+ * row is rendered after the module queue's own `head` rows, which is the ordering the bundle's bootstrap
73
+ * expects.
74
+ *
75
+ * Neither payload may contain a closing script or style tag — the table's own contract, because the row is
76
+ * spliced into the document text — and a test asserts that for both, alongside executing the bootstrap.
77
+ * @returns the rows, in the order they must execute.
78
+ */
79
+ export function stripRows() {
80
+ return [
81
+ { kind: 'html', placement: 'body', html: stripMarkup() },
82
+ { kind: 'script', placement: 'body', text: stripBootstrap() },
83
+ ];
84
+ }
85
+ //# sourceMappingURL=strip.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strip.js","sourceRoot":"","sources":["../src/strip.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAIH,8FAA8F;AAC9F,MAAM,CAAC,MAAM,gBAAgB,GAAG,oBAAoB,CAAA;AAEpD;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW;IACzB,OAAO;QACL,YAAY,gBAAgB,GAAG;QAC/B,2DAA2D;QAC3D,kEAAkE;QAClE,6CAA6C;QAC7C,kFAAkF;QAClF,QAAQ;KACT,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AACZ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc;IAC5B,OAAO;QACL,iBAAiB;QACjB,iHAAiH;QACjH,WAAW;QACX,kIAAkI;QAClI,MAAM;KACP,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AACd,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,SAAS;IACvB,OAAO;QACL,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE;QACxD,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,EAAE,EAAE;KAC9D,CAAA;AACH,CAAC"}
package/lib/types.d.ts CHANGED
@@ -74,7 +74,7 @@ export interface RealtimeAudioWsConfig {
74
74
  * `Uint8Array`.
75
75
  */
76
76
  export interface AudioSocket {
77
- send(data: Uint8Array): void;
77
+ send(data: Uint8Array | string): void;
78
78
  close(code?: number, reason?: string): void;
79
79
  terminate(): void;
80
80
  on(event: 'message', listener: (data: unknown, isBinary: boolean) => void): unknown;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,0GAA0G;AAC1G,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,8BAA8B,CAAA;AAEnE,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,EAAE,OAAO,CAAA;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;IAC5B,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3C,SAAS,IAAI,IAAI,CAAA;IACjB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAA;IACnF,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;CAC9E"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,0GAA0G;AAC1G,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,8BAA8B,CAAA;AAEnE,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,EAAE,OAAO,CAAA;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAAA;IACrC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3C,SAAS,IAAI,IAAI,CAAA;IACjB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAA;IACnF,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;CAC9E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-realtime-audio-ws",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "The host end of the client half's transport: a WebSocket upgrade route that bridges microphone audio in and agent speech out.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -52,7 +52,8 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "ws": "^8.22.0",
55
- "dsh-realtime-agent": "^0.2.6"
55
+ "dsh-realtime": "^0.2.3",
56
+ "dsh-realtime-agent": "^0.2.7"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "@deepseek-ai/cordis": "^4.0.3",
package/src/bridge.ts CHANGED
@@ -14,6 +14,20 @@ export interface AudioSocketBridgeDeps {
14
14
  readonly subscribeAudio: (listener: (pcm16: Uint8Array) => void) => () => void
15
15
  /** Longest inbound frame accepted, in bytes. */
16
16
  readonly maxFrameBytes: number
17
+ /**
18
+ * Handle one **control frame** — a text frame on this socket.
19
+ *
20
+ * Until S2 story 2 a text frame was not part of this contract, and the comment that said so was right
21
+ * about the wire as it was: the socket carried PCM16 in both directions and nothing else. It now
22
+ * carries a channel too, because it is the only duplex connection the client already holds, and one
23
+ * text frame in / one text frame out is a smaller contract than a second route with its own
24
+ * authentication would have been.
25
+ *
26
+ * `reply` writes exactly one text frame back, and silently drops it once the socket is gone: a control
27
+ * handler is asynchronous, and a reply to a closed socket would throw from inside a promise nobody
28
+ * awaits.
29
+ */
30
+ readonly onControl: (frame: string, reply: (text: string) => void) => void
17
31
  /** Called exactly once when the bridge stops, however it stops. */
18
32
  readonly onDetach: () => void
19
33
  }
@@ -35,6 +49,22 @@ export function toBytes(data: unknown): Uint8Array {
35
49
  return new Uint8Array(Buffer.from(data as ArrayBufferLike))
36
50
  }
37
51
 
52
+ /**
53
+ * Normalise a `ws` message payload to text.
54
+ *
55
+ * The same three shapes {@link toBytes} handles, decoded as UTF-8 — a control frame arrives as a Buffer
56
+ * whichever way it was framed, and a fragmented one as an array of them.
57
+ *
58
+ * @param data - the payload as `ws` delivered it.
59
+ * @returns the payload as text.
60
+ */
61
+ export function toText(data: unknown): string {
62
+ if (typeof data === 'string') return data
63
+ if (Array.isArray(data)) return Buffer.concat(data as readonly Uint8Array[]).toString('utf8')
64
+ if (data instanceof Uint8Array) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8')
65
+ return Buffer.from(data as ArrayBufferLike).toString('utf8')
66
+ }
67
+
38
68
  /**
39
69
  * Wire one accepted socket to the agent's audio events.
40
70
  *
@@ -61,10 +91,36 @@ export function attachAudioSocket(client: AudioSocket, deps: AudioSocketBridgeDe
61
91
  deps.onDetach()
62
92
  }
63
93
 
94
+ /**
95
+ * Answer one control frame, if the socket is still there.
96
+ *
97
+ * The guard is here rather than at the call site because this is the only place that knows whether the
98
+ * bridge is still live, and because a control handler settles asynchronously: a reply that arrives after
99
+ * the socket went away must be dropped, not thrown.
100
+ *
101
+ * **Total**, including the write: a socket can die between the check above and the send, and a throw here
102
+ * would escape into a promise nobody awaits — and, in the plugin, would break the reply queue for every
103
+ * frame behind it.
104
+ * @param text - the reply frame.
105
+ */
106
+ const reply = (text: string): void => {
107
+ if (stopped) return
108
+ try {
109
+ client.send(text)
110
+ } catch {
111
+ stop()
112
+ }
113
+ }
114
+
64
115
  client.on('message', (data, isBinary) => {
65
- // A text frame is not part of this contract. Ignoring one is cheaper than closing the connection and
66
- // no less correct: nothing on this wire is JSON.
67
- if (!isBinary) return
116
+ // A **text** frame is a control frame. This is the deliberate widening of the contract that used to
117
+ // say the opposite: the socket is the one duplex connection the client already holds, and a control
118
+ // plane that needs a second route with a second authentication is a control plane nobody builds.
119
+ // Checked before the audio path, because a text frame is never audio whatever it contains.
120
+ if (!isBinary) {
121
+ deps.onControl(toText(data), reply)
122
+ return
123
+ }
68
124
  const pcm16 = toBytes(data)
69
125
  if (pcm16.byteLength > deps.maxFrameBytes) {
70
126
  // 1009 = message too big. `ws` enforces its own `maxPayload` first, so reaching this means the bound