dsh-realtime-audio-ws 0.1.2 → 0.2.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/lib/client/bundle.js +313 -0
- package/lib/client/index.js +43 -8
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +18 -1
- package/lib/index.js.map +1 -1
- package/lib/injection.d.ts +91 -0
- package/lib/injection.d.ts.map +1 -0
- package/lib/injection.js +115 -0
- package/lib/injection.js.map +1 -0
- package/package.json +3 -3
- package/src/client/index.ts +56 -9
- package/src/index.ts +43 -1
- package/src/injection.ts +132 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
|
|
2
|
+
factory: (require) => {
|
|
3
|
+
const exports = {}
|
|
4
|
+
const module = { exports }
|
|
5
|
+
"use strict";
|
|
6
|
+
/**
|
|
7
|
+
* The browser face: microphone in, speaker out, over the host route.
|
|
8
|
+
*
|
|
9
|
+
* This is the client half of the transport. `dsh-realtime-audio-ws` claims the socket on the host and
|
|
10
|
+
* bridges it to the agent's audio events; this file opens that socket, captures the microphone into it, and
|
|
11
|
+
* plays what comes back.
|
|
12
|
+
*
|
|
13
|
+
* **One file, on purpose.** A client face is served as a single resource (`<package>/client.js`), so a
|
|
14
|
+
* relative value import would emit a second file the loader never serves. Everything therefore lives here,
|
|
15
|
+
* including the API shapes it needs.
|
|
16
|
+
*
|
|
17
|
+
* **No DOM lib, also on purpose.** The browser APIs are described structurally and read off `globalThis`
|
|
18
|
+
* through one cast, so this compiles in the package's ordinary program — no second tsconfig, no DOM lib,
|
|
19
|
+
* no bundler — and every path is testable by injecting fakes. `standard-browser-types` would have bought
|
|
20
|
+
* accurate types for six objects at the cost of a whole second build face; this is the cheaper trade.
|
|
21
|
+
*
|
|
22
|
+
* **`ScriptProcessorNode`, and why.** The modern capture path is an `AudioWorklet`, whose module must be
|
|
23
|
+
* fetched from a URL — a `blob:` URL, which a page's Content-Security-Policy can refuse. The desktop app
|
|
24
|
+
* demonstrably *has* a CSP (a `connect-src 'none'` string is in its bundle, governing the shell pages), and
|
|
25
|
+
* I have not read the app page's policy. `ScriptProcessorNode` needs no URL, no module fetch and no policy
|
|
26
|
+
* grant, and it runs in the page, so the whole capture path is testable. It is deprecated and its latency is
|
|
27
|
+
* worse. That is a deliberate, disclosed trade: swap it for a worklet once the app page's `script-src` has
|
|
28
|
+
* actually been read, rather than shipping a capture path that a policy might silently kill.
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.INJECTED_KEY = exports.GLOBAL_KEY = exports.MAX_BACKLOG_SECONDS = exports.CAPTURE_BUFFER = exports.SAMPLE_RATE = exports.DEFAULT_PATH = exports.inject = exports.name = void 0;
|
|
32
|
+
exports.socketUrl = socketUrl;
|
|
33
|
+
exports.pageAuthority = pageAuthority;
|
|
34
|
+
exports.withToken = withToken;
|
|
35
|
+
exports.pcm16FromFloat32 = pcm16FromFloat32;
|
|
36
|
+
exports.float32FromPcm16 = float32FromPcm16;
|
|
37
|
+
exports.defaultDeps = defaultDeps;
|
|
38
|
+
exports.createAudioClient = createAudioClient;
|
|
39
|
+
exports.apply = apply;
|
|
40
|
+
/** Browser-side plugin name. */
|
|
41
|
+
exports.name = 'realtime-audio-client';
|
|
42
|
+
/**
|
|
43
|
+
* No client services. The socket, the device and the audio graph are all this plugin's own, so it injects
|
|
44
|
+
* nothing — and a client face that needs nothing cannot be broken by another plugin's absence.
|
|
45
|
+
*/
|
|
46
|
+
exports.inject = [];
|
|
47
|
+
/** Where the host route lives. Duplicated from the host package because a client face cannot import it. */
|
|
48
|
+
exports.DEFAULT_PATH = '/dsh-realtime/audio';
|
|
49
|
+
/** The session's declared rate. The graph is opened at this rate or the client refuses to run. */
|
|
50
|
+
exports.SAMPLE_RATE = 24_000;
|
|
51
|
+
/** Capture chunk in samples. 1024 at 24 kHz is ~43 ms: the 2^10 floor the API allows, so latency is lowest. */
|
|
52
|
+
exports.CAPTURE_BUFFER = 1024;
|
|
53
|
+
/** Backlog past which incoming speech is dropped rather than queued — the same rule the host carries. */
|
|
54
|
+
exports.MAX_BACKLOG_SECONDS = 0.5;
|
|
55
|
+
/** Global the client publishes itself on. There is no UI surface yet; this is how it is started. */
|
|
56
|
+
exports.GLOBAL_KEY = '__dshRealtimeAudio';
|
|
57
|
+
/** Optional host-injected settings, published as a `global` index-injection row before this file runs. */
|
|
58
|
+
exports.INJECTED_KEY = '__DSH_REALTIME_AUDIO__';
|
|
59
|
+
// ---- pure helpers --------------------------------------------------------------------------------------
|
|
60
|
+
/**
|
|
61
|
+
* The socket URL for the host route.
|
|
62
|
+
*
|
|
63
|
+
* @param location - the page's location, or undefined when there is none.
|
|
64
|
+
* @param path - the route pathname.
|
|
65
|
+
* @returns the URL, or undefined when this page cannot host a socket at all.
|
|
66
|
+
*/
|
|
67
|
+
function socketUrl(location, path, authority) {
|
|
68
|
+
const host = authority !== undefined && authority !== '' ? authority : pageAuthority(location);
|
|
69
|
+
if (host === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
// An https page may not open a ws:// socket — the browser blocks it as mixed content — so the scheme has
|
|
72
|
+
// to follow the page rather than being decided here.
|
|
73
|
+
const scheme = location?.protocol === 'https:' ? 'wss' : 'ws';
|
|
74
|
+
return `${scheme}://${host}${path}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The authority a page can derive for itself, or undefined when it cannot derive a usable one.
|
|
78
|
+
*
|
|
79
|
+
* Only an http(s) page addresses a server. The desktop app's page is served from `dsh-app://app`, whose
|
|
80
|
+
* host is the literal string `app`: deriving a socket URL from it yields `ws://app/...`, which resolves
|
|
81
|
+
* nowhere and fails in a way that reads exactly like the host refusing the connection — a wrong answer
|
|
82
|
+
* that costs an afternoon. Returning undefined lets the caller name the real cause instead.
|
|
83
|
+
*
|
|
84
|
+
* @param location - the page's location, or undefined when there is none.
|
|
85
|
+
* @returns the authority, or undefined when this page has no usable one of its own.
|
|
86
|
+
*/
|
|
87
|
+
function pageAuthority(location) {
|
|
88
|
+
if (location === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (location.protocol !== 'http:' && location.protocol !== 'https:')
|
|
91
|
+
return undefined;
|
|
92
|
+
return location.host === '' ? undefined : location.host;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Attach the capability token, when the host injected one.
|
|
96
|
+
*
|
|
97
|
+
* @param url - the socket URL.
|
|
98
|
+
* @param token - the injected token, or undefined on a host that injects none.
|
|
99
|
+
* @returns the URL to open.
|
|
100
|
+
*/
|
|
101
|
+
function withToken(url, token) {
|
|
102
|
+
if (token === undefined || token === '')
|
|
103
|
+
return url;
|
|
104
|
+
return `${url}?t=${encodeURIComponent(token)}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Convert one captured float block to the wire's PCM16.
|
|
108
|
+
*
|
|
109
|
+
* Clamped, not wrapped: a sample above 1.0 is a loud peak, and letting it wrap turns a loud voice into
|
|
110
|
+
* noise. Asymmetric on purpose — 32767 is the largest Int16 and -32768 the smallest, so scaling both by the
|
|
111
|
+
* same factor would clip every negative peak one step early.
|
|
112
|
+
*
|
|
113
|
+
* @param input - samples in the range [-1, 1].
|
|
114
|
+
* @returns the same samples as little-endian PCM16 bytes.
|
|
115
|
+
*/
|
|
116
|
+
function pcm16FromFloat32(input) {
|
|
117
|
+
const out = new Int16Array(input.length);
|
|
118
|
+
// `entries()` rather than an index: under `noUncheckedIndexedAccess` an indexed read is
|
|
119
|
+
// `number | undefined`, and guarding that would add a branch that can never be taken at runtime.
|
|
120
|
+
for (const [index, raw] of input.entries()) {
|
|
121
|
+
const sample = Math.max(-1, Math.min(1, raw));
|
|
122
|
+
out[index] = Math.round(sample < 0 ? sample * 32768 : sample * 32767);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Read one PCM16 frame as playback samples.
|
|
128
|
+
*
|
|
129
|
+
* @param bytes - the frame.
|
|
130
|
+
* @returns samples in [-1, 1).
|
|
131
|
+
*/
|
|
132
|
+
function float32FromPcm16(bytes) {
|
|
133
|
+
const samples = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
|
|
134
|
+
const out = new Float32Array(samples.length);
|
|
135
|
+
for (const [index, sample] of samples.entries())
|
|
136
|
+
out[index] = sample / 32768;
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Read the real browser APIs, and any settings the host injected.
|
|
141
|
+
*
|
|
142
|
+
* @param scope - the global scope, injectable so both the present and absent cases are testable without
|
|
143
|
+
* mutating the process's own globals.
|
|
144
|
+
* @returns the production dependencies. Every one may be absent, which is why `start` reports rather than
|
|
145
|
+
* throws: a page without a microphone API is a fact about the page, not a defect in the caller.
|
|
146
|
+
*/
|
|
147
|
+
function defaultDeps(scope = globalThis) {
|
|
148
|
+
const media = scope.navigator?.mediaDevices;
|
|
149
|
+
const Audio = scope.AudioContext;
|
|
150
|
+
const Socket = scope.WebSocket;
|
|
151
|
+
return {
|
|
152
|
+
location: scope.location,
|
|
153
|
+
getUserMedia: media === undefined ? undefined : (constraints) => media.getUserMedia(constraints),
|
|
154
|
+
createAudioContext: Audio === undefined ? undefined : (rate) => new Audio({ sampleRate: rate }),
|
|
155
|
+
createSocket: Socket === undefined ? undefined : (url) => new Socket(url),
|
|
156
|
+
injected: scope.__DSH_REALTIME_AUDIO__,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The client: one microphone in, one socket out, one speaker fed.
|
|
161
|
+
*
|
|
162
|
+
* @param deps - the outside world, injected so every path is testable.
|
|
163
|
+
* @returns the start/stop/state handle.
|
|
164
|
+
*/
|
|
165
|
+
function createAudioClient(deps) {
|
|
166
|
+
let current = { kind: 'idle' };
|
|
167
|
+
let socket;
|
|
168
|
+
let context;
|
|
169
|
+
let stream;
|
|
170
|
+
let processor;
|
|
171
|
+
let silence;
|
|
172
|
+
let playsAt = 0;
|
|
173
|
+
const fail = (reason) => {
|
|
174
|
+
current = { kind: 'failed', reason };
|
|
175
|
+
return current;
|
|
176
|
+
};
|
|
177
|
+
/** Release everything held. Idempotent, because stop, an error and a close all reach it. */
|
|
178
|
+
const release = () => {
|
|
179
|
+
socket?.close();
|
|
180
|
+
socket = undefined;
|
|
181
|
+
processor?.disconnect();
|
|
182
|
+
processor = undefined;
|
|
183
|
+
silence?.disconnect();
|
|
184
|
+
silence = undefined;
|
|
185
|
+
for (const track of stream?.getTracks() ?? [])
|
|
186
|
+
track.stop();
|
|
187
|
+
stream = undefined;
|
|
188
|
+
const closing = context;
|
|
189
|
+
context = undefined;
|
|
190
|
+
if (closing !== undefined)
|
|
191
|
+
void closing.close().catch(() => undefined);
|
|
192
|
+
playsAt = 0;
|
|
193
|
+
};
|
|
194
|
+
/** Play one frame, dropping it rather than queueing when we are already behind. */
|
|
195
|
+
const play = (bytes) => {
|
|
196
|
+
if (context === undefined)
|
|
197
|
+
return;
|
|
198
|
+
const samples = float32FromPcm16(bytes);
|
|
199
|
+
if (samples.length === 0)
|
|
200
|
+
return;
|
|
201
|
+
const buffer = context.createBuffer(1, samples.length, exports.SAMPLE_RATE);
|
|
202
|
+
buffer.getChannelData(0).set(samples);
|
|
203
|
+
const now = context.currentTime;
|
|
204
|
+
const at = Math.max(now, playsAt);
|
|
205
|
+
// Unbuffered, like the host: audio that is already late is dropped rather than played behind, because a
|
|
206
|
+
// queue that grows converts a stutter into a permanent delay that never recovers.
|
|
207
|
+
if (at - now > exports.MAX_BACKLOG_SECONDS)
|
|
208
|
+
return;
|
|
209
|
+
const source = context.createBufferSource();
|
|
210
|
+
source.buffer = buffer;
|
|
211
|
+
source.connect(context.destination);
|
|
212
|
+
source.start(at);
|
|
213
|
+
playsAt = at + buffer.duration;
|
|
214
|
+
};
|
|
215
|
+
/** Build the capture graph and start shipping frames. */
|
|
216
|
+
const attachCapture = () => {
|
|
217
|
+
if (context === undefined || stream === undefined || socket === undefined) {
|
|
218
|
+
return fail('the audio graph went away before the socket opened');
|
|
219
|
+
}
|
|
220
|
+
const source = context.createMediaStreamSource(stream);
|
|
221
|
+
const node = context.createScriptProcessor(exports.CAPTURE_BUFFER, 1, 1);
|
|
222
|
+
node.onaudioprocess = (event) => {
|
|
223
|
+
const pcm16 = pcm16FromFloat32(event.inputBuffer.getChannelData(0));
|
|
224
|
+
socket?.send(new Uint8Array(pcm16.buffer));
|
|
225
|
+
};
|
|
226
|
+
// A ScriptProcessorNode only fires while connected to the destination — and connecting the microphone
|
|
227
|
+
// there is a feedback loop. A zero-gain node keeps it running with nothing audible, which is the whole
|
|
228
|
+
// reason this chain looks the way it does.
|
|
229
|
+
const mute = context.createGain();
|
|
230
|
+
mute.gain.value = 0;
|
|
231
|
+
source.connect(node);
|
|
232
|
+
node.connect(mute);
|
|
233
|
+
mute.connect(context.destination);
|
|
234
|
+
processor = node;
|
|
235
|
+
silence = mute;
|
|
236
|
+
current = { kind: 'live' };
|
|
237
|
+
return current;
|
|
238
|
+
};
|
|
239
|
+
const stop = () => {
|
|
240
|
+
release();
|
|
241
|
+
current = { kind: 'idle' };
|
|
242
|
+
};
|
|
243
|
+
const start = async () => {
|
|
244
|
+
if (current.kind === 'live')
|
|
245
|
+
return current;
|
|
246
|
+
const route = socketUrl(deps.location, deps.injected?.path ?? exports.DEFAULT_PATH, deps.injected?.authority);
|
|
247
|
+
if (route === undefined) {
|
|
248
|
+
return fail('no authority for the audio socket: this page has none of its own and the host injected none');
|
|
249
|
+
}
|
|
250
|
+
const url = withToken(route, deps.injected?.token);
|
|
251
|
+
if (deps.getUserMedia === undefined)
|
|
252
|
+
return fail('this page has no microphone API');
|
|
253
|
+
if (deps.createAudioContext === undefined)
|
|
254
|
+
return fail('this page has no audio API');
|
|
255
|
+
if (deps.createSocket === undefined)
|
|
256
|
+
return fail('this page has no WebSocket API');
|
|
257
|
+
try {
|
|
258
|
+
stream = await deps.getUserMedia({
|
|
259
|
+
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
// A refused permission is the ordinary case here, not an error to hide: the reason is what tells the
|
|
264
|
+
// user whether to grant it or to look somewhere else.
|
|
265
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
266
|
+
}
|
|
267
|
+
context = deps.createAudioContext(exports.SAMPLE_RATE);
|
|
268
|
+
if (context.sampleRate !== exports.SAMPLE_RATE) {
|
|
269
|
+
// Refuse rather than mislabel. Streaming 48 kHz samples declared as 24 kHz arrives at half speed and
|
|
270
|
+
// sounds like a provider fault, which is the most expensive possible way to learn about a resampler.
|
|
271
|
+
const opened = context.sampleRate;
|
|
272
|
+
release();
|
|
273
|
+
return fail(`the audio graph opened at ${String(opened)} Hz, not ${String(exports.SAMPLE_RATE)}`);
|
|
274
|
+
}
|
|
275
|
+
const opened = deps.createSocket(url);
|
|
276
|
+
socket = opened;
|
|
277
|
+
opened.binaryType = 'arraybuffer';
|
|
278
|
+
opened.onmessage = (event) => {
|
|
279
|
+
if (event.data instanceof ArrayBuffer)
|
|
280
|
+
play(new Uint8Array(event.data));
|
|
281
|
+
};
|
|
282
|
+
opened.onclose = () => {
|
|
283
|
+
release();
|
|
284
|
+
current = { kind: 'idle' };
|
|
285
|
+
};
|
|
286
|
+
return await new Promise((resolve) => {
|
|
287
|
+
opened.onopen = () => { resolve(attachCapture()); };
|
|
288
|
+
opened.onerror = () => { resolve(fail('the host refused the audio socket')); };
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
return { start, stop, state: () => current };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The client plugin. Publishes the client on a global and releases it with the plugin.
|
|
295
|
+
*
|
|
296
|
+
* There is no UI surface yet, so a global is how it is driven — a settings card is its own increment and a
|
|
297
|
+
* bigger one than this. `inject` is empty for the same reason: nothing here needs another plugin.
|
|
298
|
+
*
|
|
299
|
+
* @param ctx - the client context, used only to own the lifetime.
|
|
300
|
+
*/
|
|
301
|
+
function apply(ctx) {
|
|
302
|
+
const client = createAudioClient(defaultDeps());
|
|
303
|
+
globalThis[exports.GLOBAL_KEY] = {
|
|
304
|
+
start: () => client.start(),
|
|
305
|
+
stop: () => { client.stop(); },
|
|
306
|
+
state: () => client.state(),
|
|
307
|
+
};
|
|
308
|
+
// A held microphone and a live audio graph must not outlive the plugin that opened them.
|
|
309
|
+
ctx.effect(() => () => { client.stop(); }, 'realtime-audio-client');
|
|
310
|
+
}
|
|
311
|
+
return exports
|
|
312
|
+
},
|
|
313
|
+
})
|
package/lib/client/index.js
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
27
|
exports.INJECTED_KEY = exports.GLOBAL_KEY = exports.MAX_BACKLOG_SECONDS = exports.CAPTURE_BUFFER = exports.SAMPLE_RATE = exports.DEFAULT_PATH = exports.inject = exports.name = void 0;
|
|
28
28
|
exports.socketUrl = socketUrl;
|
|
29
|
+
exports.pageAuthority = pageAuthority;
|
|
30
|
+
exports.withToken = withToken;
|
|
29
31
|
exports.pcm16FromFloat32 = pcm16FromFloat32;
|
|
30
32
|
exports.float32FromPcm16 = float32FromPcm16;
|
|
31
33
|
exports.defaultDeps = defaultDeps;
|
|
@@ -48,7 +50,7 @@ exports.CAPTURE_BUFFER = 1024;
|
|
|
48
50
|
exports.MAX_BACKLOG_SECONDS = 0.5;
|
|
49
51
|
/** Global the client publishes itself on. There is no UI surface yet; this is how it is started. */
|
|
50
52
|
exports.GLOBAL_KEY = '__dshRealtimeAudio';
|
|
51
|
-
/** Optional host-injected settings
|
|
53
|
+
/** Optional host-injected settings, published as a `global` index-injection row before this file runs. */
|
|
52
54
|
exports.INJECTED_KEY = '__DSH_REALTIME_AUDIO__';
|
|
53
55
|
// ---- pure helpers --------------------------------------------------------------------------------------
|
|
54
56
|
/**
|
|
@@ -58,13 +60,44 @@ exports.INJECTED_KEY = '__DSH_REALTIME_AUDIO__';
|
|
|
58
60
|
* @param path - the route pathname.
|
|
59
61
|
* @returns the URL, or undefined when this page cannot host a socket at all.
|
|
60
62
|
*/
|
|
61
|
-
function socketUrl(location, path) {
|
|
62
|
-
|
|
63
|
+
function socketUrl(location, path, authority) {
|
|
64
|
+
const host = authority !== undefined && authority !== '' ? authority : pageAuthority(location);
|
|
65
|
+
if (host === undefined)
|
|
63
66
|
return undefined;
|
|
64
67
|
// An https page may not open a ws:// socket — the browser blocks it as mixed content — so the scheme has
|
|
65
68
|
// to follow the page rather than being decided here.
|
|
66
|
-
const scheme = location
|
|
67
|
-
return `${scheme}://${
|
|
69
|
+
const scheme = location?.protocol === 'https:' ? 'wss' : 'ws';
|
|
70
|
+
return `${scheme}://${host}${path}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The authority a page can derive for itself, or undefined when it cannot derive a usable one.
|
|
74
|
+
*
|
|
75
|
+
* Only an http(s) page addresses a server. The desktop app's page is served from `dsh-app://app`, whose
|
|
76
|
+
* host is the literal string `app`: deriving a socket URL from it yields `ws://app/...`, which resolves
|
|
77
|
+
* nowhere and fails in a way that reads exactly like the host refusing the connection — a wrong answer
|
|
78
|
+
* that costs an afternoon. Returning undefined lets the caller name the real cause instead.
|
|
79
|
+
*
|
|
80
|
+
* @param location - the page's location, or undefined when there is none.
|
|
81
|
+
* @returns the authority, or undefined when this page has no usable one of its own.
|
|
82
|
+
*/
|
|
83
|
+
function pageAuthority(location) {
|
|
84
|
+
if (location === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (location.protocol !== 'http:' && location.protocol !== 'https:')
|
|
87
|
+
return undefined;
|
|
88
|
+
return location.host === '' ? undefined : location.host;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Attach the capability token, when the host injected one.
|
|
92
|
+
*
|
|
93
|
+
* @param url - the socket URL.
|
|
94
|
+
* @param token - the injected token, or undefined on a host that injects none.
|
|
95
|
+
* @returns the URL to open.
|
|
96
|
+
*/
|
|
97
|
+
function withToken(url, token) {
|
|
98
|
+
if (token === undefined || token === '')
|
|
99
|
+
return url;
|
|
100
|
+
return `${url}?t=${encodeURIComponent(token)}`;
|
|
68
101
|
}
|
|
69
102
|
/**
|
|
70
103
|
* Convert one captured float block to the wire's PCM16.
|
|
@@ -206,9 +239,11 @@ function createAudioClient(deps) {
|
|
|
206
239
|
const start = async () => {
|
|
207
240
|
if (current.kind === 'live')
|
|
208
241
|
return current;
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
211
|
-
return fail('this page has
|
|
242
|
+
const route = socketUrl(deps.location, deps.injected?.path ?? exports.DEFAULT_PATH, deps.injected?.authority);
|
|
243
|
+
if (route === undefined) {
|
|
244
|
+
return fail('no authority for the audio socket: this page has none of its own and the host injected none');
|
|
245
|
+
}
|
|
246
|
+
const url = withToken(route, deps.injected?.token);
|
|
212
247
|
if (deps.getUserMedia === undefined)
|
|
213
248
|
return fail('this page has no microphone API');
|
|
214
249
|
if (deps.createAudioContext === undefined)
|
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAelD,OAAO,EAML,KAAK,qBAAqB,EAC3B,MAAM,YAAY,CAAA;AAEnB,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,aAAa,CAAA;AACpF,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAEzF,mBAAmB;AACnB,eAAO,MAAM,IAAI,sBAAsB,CAAA;AAEvC,eAAO,MAAM,MAAM;;;;;;;;;;aAQjB,CAAA;AA4BF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,qBAAqB,GAAG,IAAI,CA+EvE"}
|
package/lib/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import Schema from '@deepseek-ai/schemastery';
|
|
14
14
|
import { attachAudioSocket } from './bridge.js';
|
|
15
|
+
import { createRouteToken, routeAuthority, routeInjectionRow, tokenFromUrl, verdictFor, } from './injection.js';
|
|
15
16
|
import { createUpgradeAcceptor, rejectUpgrade } from './upgrade.js';
|
|
16
17
|
import { DEFAULT_MAX_CONNECTIONS, DEFAULT_MAX_FRAME_BYTES, DEFAULT_OPEN_SESSION_ON_CONNECT, DEFAULT_PATH, } from './types.js';
|
|
17
18
|
export * from './types.js';
|
|
@@ -33,7 +34,18 @@ export function apply(ctx, config) {
|
|
|
33
34
|
const services = injected;
|
|
34
35
|
const acceptor = createUpgradeAcceptor(config.maxFrameBytes);
|
|
35
36
|
const clients = new Set();
|
|
37
|
+
const token = createRouteToken();
|
|
38
|
+
// The injection event is declared by `@deepseek-ai/dsh-host-webserver`, which this package deliberately
|
|
39
|
+
// does not import: importing it would augment `Context` with a second declaration of `webServer` and
|
|
40
|
+
// turn the structural dependency above into a compile error. The name is cast at this one boundary.
|
|
41
|
+
const onInjection = ctx.on;
|
|
36
42
|
ctx.effect(() => {
|
|
43
|
+
// The page is told where the route is and what to present. The web server gathers this table on every
|
|
44
|
+
// index render and every worker boot-payload request, so the row is built at emit time — the only
|
|
45
|
+
// moment the listening port is known for certain, since an index render follows the listen.
|
|
46
|
+
onInjection('webserver/index-inject', (table) => {
|
|
47
|
+
table.push(routeInjectionRow(config.path, routeAuthority(services.webServer.config?.host, services.webServer.listenedPort), token));
|
|
48
|
+
});
|
|
37
49
|
const unregister = services.webServer.registerUpgrade({
|
|
38
50
|
path: config.path,
|
|
39
51
|
handler: (req, socket, head) => {
|
|
@@ -42,7 +54,12 @@ export function apply(ctx, config) {
|
|
|
42
54
|
// microphone audio in and the agent's answers out. DSH's own transport asks the connection
|
|
43
55
|
// service in exactly this position, so this route asks the same question rather than inventing a
|
|
44
56
|
// second scheme that would drift from it.
|
|
45
|
-
|
|
57
|
+
//
|
|
58
|
+
// The token is the one addition, and it is not a second scheme. The desktop app's page is served
|
|
59
|
+
// from `dsh-app://app`, so its requests to loopback are cross-site and the harness's
|
|
60
|
+
// `SameSite=Strict` cookie cannot travel; without the token that page is refused forever, however
|
|
61
|
+
// correct the rest of the client half is.
|
|
62
|
+
const rejection = verdictFor(services.connection.requestRejection(req), tokenFromUrl(req.url), token);
|
|
46
63
|
if (rejection !== undefined) {
|
|
47
64
|
rejectUpgrade(socket, rejection);
|
|
48
65
|
return;
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAM7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAC/C,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AACnE,OAAO,EACL,uBAAuB,EACvB,uBAAuB,EACvB,+BAA+B,EAC/B,YAAY,GAGb,MAAM,YAAY,CAAA;AAEnB,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAA8B,MAAM,aAAa,CAAA;AACpF,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAwB,MAAM,cAAc,CAAA;AAEzF,mBAAmB;AACnB,MAAM,CAAC,MAAM,IAAI,GAAG,mBAAmB,CAAA;AAEvC,MAAM,CAAC,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,YAAY,CAAC;IAC3C,aAAa,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;IAChE,cAAc,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;IACjE,uGAAuG;IACvG,0GAA0G;IAC1G,uGAAuG;IACvG,oBAAoB,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,+BAA+B,CAAC;CAChF,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route's page-side settings: the row the host injects, and the credential check that lets the
|
|
3
|
+
* desktop app's renderer use the route at all.
|
|
4
|
+
*
|
|
5
|
+
* Two facts decide this file's shape.
|
|
6
|
+
*
|
|
7
|
+
* The first is that the harness web server gathers a structured *injection table* on every index render
|
|
8
|
+
* and every worker boot-payload request, by emitting `webserver/index-inject`; listeners append their
|
|
9
|
+
* rows and the rows are read fresh at emit time. That is a door built for plugins, which is why this
|
|
10
|
+
* package uses it rather than a raw string transform on the HTML.
|
|
11
|
+
*
|
|
12
|
+
* The second is a consequence of being a browser face inside the desktop app. That page's origin is
|
|
13
|
+
* `dsh-app://app`, and the harness's own auth cookie is `SameSite=Strict` and bound to the loopback
|
|
14
|
+
* authority — so a request from the app page to `127.0.0.1` is cross-site and carries no cookie. The
|
|
15
|
+
* connection service would therefore refuse the app's renderer, correctly and forever, no matter how
|
|
16
|
+
* right the rest of the client half is.
|
|
17
|
+
*
|
|
18
|
+
* A capability token — generated once per process, injected only into the page the host itself serves —
|
|
19
|
+
* is the credential that can travel. It is script-readable where the cookie is not, and that is a real
|
|
20
|
+
* weakening: it is the price of the app working at all. It authorises one route, on loopback, for the
|
|
21
|
+
* life of the process, and is worthless afterwards.
|
|
22
|
+
*/
|
|
23
|
+
/** Global the page reads its route settings from. Duplicated in the client half, which cannot import it. */
|
|
24
|
+
export declare const INJECTED_KEY = "__DSH_REALTIME_AUDIO__";
|
|
25
|
+
/** Query parameter carrying the capability token on an upgrade request. */
|
|
26
|
+
export declare const TOKEN_PARAM = "t";
|
|
27
|
+
/** A structured index-injection row of the kind this package contributes. */
|
|
28
|
+
export interface InjectedGlobalRow {
|
|
29
|
+
readonly kind: 'global';
|
|
30
|
+
readonly name: string;
|
|
31
|
+
readonly value: unknown;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Where the client should open its socket, or undefined when the host is not listening yet.
|
|
35
|
+
*
|
|
36
|
+
* A wildcard bind is normalised to loopback because the page is on this machine: handing a client
|
|
37
|
+
* `0.0.0.0:port` asks it to connect to a name meaning "every interface", which resolves nowhere useful.
|
|
38
|
+
* An IPv6 literal is bracketed so the authority parses as a URL rather than as a host with a port.
|
|
39
|
+
*
|
|
40
|
+
* @param host - the configured bind host, as the web server reports it.
|
|
41
|
+
* @param port - the port actually listening — the OS-assigned value when the config asked for zero.
|
|
42
|
+
* @returns the authority (`host:port`), or undefined before the server is listening.
|
|
43
|
+
*/
|
|
44
|
+
export declare function routeAuthority(host: string | undefined, port: number | undefined): string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* One process-scoped capability token.
|
|
47
|
+
*
|
|
48
|
+
* 32 bytes, base64url, because the token travels in a URL query and base64url needs no escaping there.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createRouteToken(): string;
|
|
51
|
+
/**
|
|
52
|
+
* Read the token off an upgrade request's target.
|
|
53
|
+
*
|
|
54
|
+
* @param url - the request target as `node:http` reports it: pathname plus query.
|
|
55
|
+
* @returns the token, or undefined when absent or empty.
|
|
56
|
+
*/
|
|
57
|
+
export declare function tokenFromUrl(url: string | undefined): string | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Compare a presented token with this process's own, in constant time.
|
|
60
|
+
*
|
|
61
|
+
* Length is compared first because `timingSafeEqual` throws on buffers of different lengths, which would
|
|
62
|
+
* turn a probe into a crash. The length is not the secret; the value is.
|
|
63
|
+
*
|
|
64
|
+
* @param provided - the token the caller presented, if any.
|
|
65
|
+
* @param expected - this process's token.
|
|
66
|
+
* @returns whether the caller may be treated as this process's own page.
|
|
67
|
+
*/
|
|
68
|
+
export declare function tokenMatches(provided: string | undefined, expected: string): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The connection service's verdict, overridden by a valid token.
|
|
71
|
+
*
|
|
72
|
+
* DSH's own transport asks the connection service in this position, and this route asks the same question
|
|
73
|
+
* rather than inventing a second scheme that would drift from it. The token is the only addition, and it
|
|
74
|
+
* is not a second scheme: a caller that cannot carry the cookie at all — the app's renderer — may present
|
|
75
|
+
* the token the host injected into that very page instead.
|
|
76
|
+
*
|
|
77
|
+
* @param rejection - what the connection service said about this request.
|
|
78
|
+
* @param provided - the token on the request, if any.
|
|
79
|
+
* @param expected - this process's token.
|
|
80
|
+
* @returns the rejection to write, or undefined to accept the upgrade.
|
|
81
|
+
*/
|
|
82
|
+
export declare function verdictFor(rejection: 401 | 403 | undefined, provided: string | undefined, expected: string): 401 | 403 | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* The row the page reads, built at emit time.
|
|
85
|
+
*
|
|
86
|
+
* `value` is JSON-serialised by the web server, which escapes `<` so a value cannot close the script
|
|
87
|
+
* element early; an undefined `authority` is dropped by `JSON.stringify`, which is exactly the "this host
|
|
88
|
+
* injected no authority" case the client half names rather than guesses at.
|
|
89
|
+
*/
|
|
90
|
+
export declare function routeInjectionRow(path: string, authority: string | undefined, token: string): InjectedGlobalRow;
|
|
91
|
+
//# sourceMappingURL=injection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"injection.d.ts","sourceRoot":"","sources":["../src/injection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAIH,4GAA4G;AAC5G,eAAO,MAAM,YAAY,2BAA2B,CAAA;AAEpD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,MAAM,CAAA;AAE9B,6EAA6E;AAC7E,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CACxB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAIrG;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAMxE;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAMpF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CACxB,SAAS,EAAE,GAAG,GAAG,GAAG,GAAG,SAAS,EAChC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,QAAQ,EAAE,MAAM,GACf,GAAG,GAAG,GAAG,GAAG,SAAS,CAGvB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,KAAK,EAAE,MAAM,GACZ,iBAAiB,CAEnB"}
|
package/lib/injection.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route's page-side settings: the row the host injects, and the credential check that lets the
|
|
3
|
+
* desktop app's renderer use the route at all.
|
|
4
|
+
*
|
|
5
|
+
* Two facts decide this file's shape.
|
|
6
|
+
*
|
|
7
|
+
* The first is that the harness web server gathers a structured *injection table* on every index render
|
|
8
|
+
* and every worker boot-payload request, by emitting `webserver/index-inject`; listeners append their
|
|
9
|
+
* rows and the rows are read fresh at emit time. That is a door built for plugins, which is why this
|
|
10
|
+
* package uses it rather than a raw string transform on the HTML.
|
|
11
|
+
*
|
|
12
|
+
* The second is a consequence of being a browser face inside the desktop app. That page's origin is
|
|
13
|
+
* `dsh-app://app`, and the harness's own auth cookie is `SameSite=Strict` and bound to the loopback
|
|
14
|
+
* authority — so a request from the app page to `127.0.0.1` is cross-site and carries no cookie. The
|
|
15
|
+
* connection service would therefore refuse the app's renderer, correctly and forever, no matter how
|
|
16
|
+
* right the rest of the client half is.
|
|
17
|
+
*
|
|
18
|
+
* A capability token — generated once per process, injected only into the page the host itself serves —
|
|
19
|
+
* is the credential that can travel. It is script-readable where the cookie is not, and that is a real
|
|
20
|
+
* weakening: it is the price of the app working at all. It authorises one route, on loopback, for the
|
|
21
|
+
* life of the process, and is worthless afterwards.
|
|
22
|
+
*/
|
|
23
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
24
|
+
/** Global the page reads its route settings from. Duplicated in the client half, which cannot import it. */
|
|
25
|
+
export const INJECTED_KEY = '__DSH_REALTIME_AUDIO__';
|
|
26
|
+
/** Query parameter carrying the capability token on an upgrade request. */
|
|
27
|
+
export const TOKEN_PARAM = 't';
|
|
28
|
+
/**
|
|
29
|
+
* Where the client should open its socket, or undefined when the host is not listening yet.
|
|
30
|
+
*
|
|
31
|
+
* A wildcard bind is normalised to loopback because the page is on this machine: handing a client
|
|
32
|
+
* `0.0.0.0:port` asks it to connect to a name meaning "every interface", which resolves nowhere useful.
|
|
33
|
+
* An IPv6 literal is bracketed so the authority parses as a URL rather than as a host with a port.
|
|
34
|
+
*
|
|
35
|
+
* @param host - the configured bind host, as the web server reports it.
|
|
36
|
+
* @param port - the port actually listening — the OS-assigned value when the config asked for zero.
|
|
37
|
+
* @returns the authority (`host:port`), or undefined before the server is listening.
|
|
38
|
+
*/
|
|
39
|
+
export function routeAuthority(host, port) {
|
|
40
|
+
if (port === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
const name = host === undefined || host === '' || host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
43
|
+
return name.includes(':') ? `[${name}]:${String(port)}` : `${name}:${String(port)}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* One process-scoped capability token.
|
|
47
|
+
*
|
|
48
|
+
* 32 bytes, base64url, because the token travels in a URL query and base64url needs no escaping there.
|
|
49
|
+
*/
|
|
50
|
+
export function createRouteToken() {
|
|
51
|
+
return randomBytes(32).toString('base64url');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Read the token off an upgrade request's target.
|
|
55
|
+
*
|
|
56
|
+
* @param url - the request target as `node:http` reports it: pathname plus query.
|
|
57
|
+
* @returns the token, or undefined when absent or empty.
|
|
58
|
+
*/
|
|
59
|
+
export function tokenFromUrl(url) {
|
|
60
|
+
if (url === undefined)
|
|
61
|
+
return undefined;
|
|
62
|
+
const query = url.indexOf('?');
|
|
63
|
+
if (query < 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
const value = new URLSearchParams(url.slice(query + 1)).get(TOKEN_PARAM);
|
|
66
|
+
return value === null || value === '' ? undefined : value;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Compare a presented token with this process's own, in constant time.
|
|
70
|
+
*
|
|
71
|
+
* Length is compared first because `timingSafeEqual` throws on buffers of different lengths, which would
|
|
72
|
+
* turn a probe into a crash. The length is not the secret; the value is.
|
|
73
|
+
*
|
|
74
|
+
* @param provided - the token the caller presented, if any.
|
|
75
|
+
* @param expected - this process's token.
|
|
76
|
+
* @returns whether the caller may be treated as this process's own page.
|
|
77
|
+
*/
|
|
78
|
+
export function tokenMatches(provided, expected) {
|
|
79
|
+
if (provided === undefined)
|
|
80
|
+
return false;
|
|
81
|
+
const offered = Buffer.from(provided);
|
|
82
|
+
const own = Buffer.from(expected);
|
|
83
|
+
if (offered.length !== own.length)
|
|
84
|
+
return false;
|
|
85
|
+
return timingSafeEqual(offered, own);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The connection service's verdict, overridden by a valid token.
|
|
89
|
+
*
|
|
90
|
+
* DSH's own transport asks the connection service in this position, and this route asks the same question
|
|
91
|
+
* rather than inventing a second scheme that would drift from it. The token is the only addition, and it
|
|
92
|
+
* is not a second scheme: a caller that cannot carry the cookie at all — the app's renderer — may present
|
|
93
|
+
* the token the host injected into that very page instead.
|
|
94
|
+
*
|
|
95
|
+
* @param rejection - what the connection service said about this request.
|
|
96
|
+
* @param provided - the token on the request, if any.
|
|
97
|
+
* @param expected - this process's token.
|
|
98
|
+
* @returns the rejection to write, or undefined to accept the upgrade.
|
|
99
|
+
*/
|
|
100
|
+
export function verdictFor(rejection, provided, expected) {
|
|
101
|
+
if (rejection === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
return tokenMatches(provided, expected) ? undefined : rejection;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The row the page reads, built at emit time.
|
|
107
|
+
*
|
|
108
|
+
* `value` is JSON-serialised by the web server, which escapes `<` so a value cannot close the script
|
|
109
|
+
* element early; an undefined `authority` is dropped by `JSON.stringify`, which is exactly the "this host
|
|
110
|
+
* injected no authority" case the client half names rather than guesses at.
|
|
111
|
+
*/
|
|
112
|
+
export function routeInjectionRow(path, authority, token) {
|
|
113
|
+
return { kind: 'global', name: INJECTED_KEY, value: { path, authority, token } };
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=injection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"injection.js","sourceRoot":"","sources":["../src/injection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAE1D,4GAA4G;AAC5G,MAAM,CAAC,MAAM,YAAY,GAAG,wBAAwB,CAAA;AAEpD,2EAA2E;AAC3E,MAAM,CAAC,MAAM,WAAW,GAAG,GAAG,CAAA;AAS9B;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAAC,IAAwB,EAAE,IAAwB;IAC/E,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,SAAS,CAAA;IACxC,MAAM,IAAI,GAAG,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,EAAE,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAA;IAC1G,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,KAAK,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,EAAE,CAAA;AACrF,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB;IAC9B,OAAO,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;AAC9C,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,GAAuB;IAClD,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,SAAS,CAAA;IACvC,MAAM,KAAK,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;IAC9B,IAAI,KAAK,GAAG,CAAC;QAAE,OAAO,SAAS,CAAA;IAC/B,MAAM,KAAK,GAAG,IAAI,eAAe,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAA;IACxE,OAAO,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAA;AAC3D,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY,CAAC,QAA4B,EAAE,QAAgB;IACzE,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,KAAK,CAAA;IACxC,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IACrC,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IACjC,IAAI,OAAO,CAAC,MAAM,KAAK,GAAG,CAAC,MAAM;QAAE,OAAO,KAAK,CAAA;IAC/C,OAAO,eAAe,CAAC,OAAO,EAAE,GAAG,CAAC,CAAA;AACtC,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,UAAU,CACxB,SAAgC,EAChC,QAA4B,EAC5B,QAAgB;IAEhB,IAAI,SAAS,KAAK,SAAS;QAAE,OAAO,SAAS,CAAA;IAC7C,OAAO,YAAY,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAA;AACjE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAC/B,IAAY,EACZ,SAA6B,EAC7B,KAAa;IAEb,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,CAAA;AAClF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-realtime-audio-ws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"./client": {
|
|
14
14
|
"types": "./lib/client/index.d.ts",
|
|
15
|
-
"default": "./lib/client/
|
|
15
|
+
"default": "./lib/client/bundle.js"
|
|
16
16
|
},
|
|
17
17
|
"./src/*": "./src/*",
|
|
18
18
|
"./package.json": "./package.json"
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@types/ws": "^8.18.2"
|
|
65
65
|
},
|
|
66
66
|
"scripts": {
|
|
67
|
-
"build": "tsc -b && tsc -p tsconfig.client.json",
|
|
67
|
+
"build": "tsc -b && tsc -p tsconfig.client.json && node ../../scripts/client-bundle.mjs .",
|
|
68
68
|
"typecheck": "tsc -b --dry"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -47,7 +47,17 @@ export const MAX_BACKLOG_SECONDS = 0.5
|
|
|
47
47
|
/** Global the client publishes itself on. There is no UI surface yet; this is how it is started. */
|
|
48
48
|
export const GLOBAL_KEY = '__dshRealtimeAudio'
|
|
49
49
|
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* What the host injects into the page: the route path, the authority to open the socket against, and the
|
|
52
|
+
* capability token to present. Every field is optional, because an older host injects only the path.
|
|
53
|
+
*/
|
|
54
|
+
export interface InjectedRouteSettings {
|
|
55
|
+
readonly path?: string
|
|
56
|
+
readonly authority?: string
|
|
57
|
+
readonly token?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Optional host-injected settings, published as a `global` index-injection row before this file runs. */
|
|
51
61
|
export const INJECTED_KEY = '__DSH_REALTIME_AUDIO__'
|
|
52
62
|
|
|
53
63
|
// ---- the API shapes this file needs, described rather than imported -----------------------------------
|
|
@@ -98,7 +108,7 @@ export interface ClientAudioDeps {
|
|
|
98
108
|
readonly getUserMedia: ((constraints: unknown) => Promise<StreamLike>) | undefined
|
|
99
109
|
readonly createAudioContext: ((sampleRate: number) => ContextLike) | undefined
|
|
100
110
|
readonly createSocket: ((url: string) => SocketLike) | undefined
|
|
101
|
-
readonly injected:
|
|
111
|
+
readonly injected: InjectedRouteSettings | undefined
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
/** What the caller — or a test — gets to see. */
|
|
@@ -125,12 +135,46 @@ export interface ClientAudio {
|
|
|
125
135
|
* @param path - the route pathname.
|
|
126
136
|
* @returns the URL, or undefined when this page cannot host a socket at all.
|
|
127
137
|
*/
|
|
128
|
-
export function socketUrl(
|
|
129
|
-
|
|
138
|
+
export function socketUrl(
|
|
139
|
+
location: LocationLike | undefined,
|
|
140
|
+
path: string,
|
|
141
|
+
authority?: string,
|
|
142
|
+
): string | undefined {
|
|
143
|
+
const host = authority !== undefined && authority !== '' ? authority : pageAuthority(location)
|
|
144
|
+
if (host === undefined) return undefined
|
|
130
145
|
// An https page may not open a ws:// socket — the browser blocks it as mixed content — so the scheme has
|
|
131
146
|
// to follow the page rather than being decided here.
|
|
132
|
-
const scheme = location
|
|
133
|
-
return `${scheme}://${
|
|
147
|
+
const scheme = location?.protocol === 'https:' ? 'wss' : 'ws'
|
|
148
|
+
return `${scheme}://${host}${path}`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The authority a page can derive for itself, or undefined when it cannot derive a usable one.
|
|
153
|
+
*
|
|
154
|
+
* Only an http(s) page addresses a server. The desktop app's page is served from `dsh-app://app`, whose
|
|
155
|
+
* host is the literal string `app`: deriving a socket URL from it yields `ws://app/...`, which resolves
|
|
156
|
+
* nowhere and fails in a way that reads exactly like the host refusing the connection — a wrong answer
|
|
157
|
+
* that costs an afternoon. Returning undefined lets the caller name the real cause instead.
|
|
158
|
+
*
|
|
159
|
+
* @param location - the page's location, or undefined when there is none.
|
|
160
|
+
* @returns the authority, or undefined when this page has no usable one of its own.
|
|
161
|
+
*/
|
|
162
|
+
export function pageAuthority(location: LocationLike | undefined): string | undefined {
|
|
163
|
+
if (location === undefined) return undefined
|
|
164
|
+
if (location.protocol !== 'http:' && location.protocol !== 'https:') return undefined
|
|
165
|
+
return location.host === '' ? undefined : location.host
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Attach the capability token, when the host injected one.
|
|
170
|
+
*
|
|
171
|
+
* @param url - the socket URL.
|
|
172
|
+
* @param token - the injected token, or undefined on a host that injects none.
|
|
173
|
+
* @returns the URL to open.
|
|
174
|
+
*/
|
|
175
|
+
export function withToken(url: string, token: string | undefined): string {
|
|
176
|
+
if (token === undefined || token === '') return url
|
|
177
|
+
return `${url}?t=${encodeURIComponent(token)}`
|
|
134
178
|
}
|
|
135
179
|
|
|
136
180
|
/**
|
|
@@ -174,7 +218,7 @@ export interface ScopeLike {
|
|
|
174
218
|
navigator?: { mediaDevices?: { getUserMedia(constraints: unknown): Promise<StreamLike> } }
|
|
175
219
|
AudioContext?: new (options: { sampleRate: number }) => ContextLike
|
|
176
220
|
WebSocket?: new (url: string) => SocketLike
|
|
177
|
-
__DSH_REALTIME_AUDIO__?:
|
|
221
|
+
__DSH_REALTIME_AUDIO__?: InjectedRouteSettings
|
|
178
222
|
}
|
|
179
223
|
|
|
180
224
|
/**
|
|
@@ -285,8 +329,11 @@ export function createAudioClient(deps: ClientAudioDeps): ClientAudio {
|
|
|
285
329
|
|
|
286
330
|
const start = async (): Promise<ClientAudioState> => {
|
|
287
331
|
if (current.kind === 'live') return current
|
|
288
|
-
const
|
|
289
|
-
if (
|
|
332
|
+
const route = socketUrl(deps.location, deps.injected?.path ?? DEFAULT_PATH, deps.injected?.authority)
|
|
333
|
+
if (route === undefined) {
|
|
334
|
+
return fail('no authority for the audio socket: this page has none of its own and the host injected none')
|
|
335
|
+
}
|
|
336
|
+
const url = withToken(route, deps.injected?.token)
|
|
290
337
|
if (deps.getUserMedia === undefined) return fail('this page has no microphone API')
|
|
291
338
|
if (deps.createAudioContext === undefined) return fail('this page has no audio API')
|
|
292
339
|
if (deps.createSocket === undefined) return fail('this page has no WebSocket API')
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,14 @@ import type { Duplex } from 'node:stream'
|
|
|
18
18
|
// Type-only: pulls the `Events` augmentation that declares the two audio events this package bridges.
|
|
19
19
|
import type {} from 'dsh-realtime-agent'
|
|
20
20
|
import { attachAudioSocket } from './bridge.ts'
|
|
21
|
+
import {
|
|
22
|
+
createRouteToken,
|
|
23
|
+
routeAuthority,
|
|
24
|
+
routeInjectionRow,
|
|
25
|
+
tokenFromUrl,
|
|
26
|
+
verdictFor,
|
|
27
|
+
type InjectedGlobalRow,
|
|
28
|
+
} from './injection.ts'
|
|
21
29
|
import { createUpgradeAcceptor, rejectUpgrade } from './upgrade.ts'
|
|
22
30
|
import {
|
|
23
31
|
DEFAULT_MAX_CONNECTIONS,
|
|
@@ -57,6 +65,13 @@ interface WebServerLike {
|
|
|
57
65
|
path: string
|
|
58
66
|
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
|
59
67
|
}): () => void
|
|
68
|
+
/**
|
|
69
|
+
* The port actually listening — the OS-assigned value when the config asked for zero. Read at injection
|
|
70
|
+
* time rather than at boot, because an index render happens after the listen.
|
|
71
|
+
*/
|
|
72
|
+
readonly listenedPort?: number
|
|
73
|
+
/** The configured bind host, so the page can be told an authority it can actually reach. */
|
|
74
|
+
readonly config?: { readonly host?: string }
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
/** The slice of the connection service that authenticates an upgrade. Structural for the same reason. */
|
|
@@ -69,8 +84,26 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
|
|
|
69
84
|
const services = injected as unknown as { webServer: WebServerLike; connection: ConnectionLike }
|
|
70
85
|
const acceptor = createUpgradeAcceptor(config.maxFrameBytes)
|
|
71
86
|
const clients = new Set<AudioSocket>()
|
|
87
|
+
const token = createRouteToken()
|
|
88
|
+
// The injection event is declared by `@deepseek-ai/dsh-host-webserver`, which this package deliberately
|
|
89
|
+
// does not import: importing it would augment `Context` with a second declaration of `webServer` and
|
|
90
|
+
// turn the structural dependency above into a compile error. The name is cast at this one boundary.
|
|
91
|
+
const onInjection = ctx.on as unknown as (
|
|
92
|
+
name: string,
|
|
93
|
+
listener: (table: InjectedGlobalRow[]) => void,
|
|
94
|
+
) => unknown
|
|
72
95
|
|
|
73
96
|
ctx.effect(() => {
|
|
97
|
+
// The page is told where the route is and what to present. The web server gathers this table on every
|
|
98
|
+
// index render and every worker boot-payload request, so the row is built at emit time — the only
|
|
99
|
+
// moment the listening port is known for certain, since an index render follows the listen.
|
|
100
|
+
onInjection('webserver/index-inject', (table) => {
|
|
101
|
+
table.push(routeInjectionRow(
|
|
102
|
+
config.path,
|
|
103
|
+
routeAuthority(services.webServer.config?.host, services.webServer.listenedPort),
|
|
104
|
+
token,
|
|
105
|
+
))
|
|
106
|
+
})
|
|
74
107
|
const unregister = services.webServer.registerUpgrade({
|
|
75
108
|
path: config.path,
|
|
76
109
|
handler: (req, socket, head) => {
|
|
@@ -79,7 +112,16 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
|
|
|
79
112
|
// microphone audio in and the agent's answers out. DSH's own transport asks the connection
|
|
80
113
|
// service in exactly this position, so this route asks the same question rather than inventing a
|
|
81
114
|
// second scheme that would drift from it.
|
|
82
|
-
|
|
115
|
+
//
|
|
116
|
+
// The token is the one addition, and it is not a second scheme. The desktop app's page is served
|
|
117
|
+
// from `dsh-app://app`, so its requests to loopback are cross-site and the harness's
|
|
118
|
+
// `SameSite=Strict` cookie cannot travel; without the token that page is refused forever, however
|
|
119
|
+
// correct the rest of the client half is.
|
|
120
|
+
const rejection = verdictFor(
|
|
121
|
+
services.connection.requestRejection(req),
|
|
122
|
+
tokenFromUrl(req.url),
|
|
123
|
+
token,
|
|
124
|
+
)
|
|
83
125
|
if (rejection !== undefined) {
|
|
84
126
|
rejectUpgrade(socket, rejection)
|
|
85
127
|
return
|
package/src/injection.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route's page-side settings: the row the host injects, and the credential check that lets the
|
|
3
|
+
* desktop app's renderer use the route at all.
|
|
4
|
+
*
|
|
5
|
+
* Two facts decide this file's shape.
|
|
6
|
+
*
|
|
7
|
+
* The first is that the harness web server gathers a structured *injection table* on every index render
|
|
8
|
+
* and every worker boot-payload request, by emitting `webserver/index-inject`; listeners append their
|
|
9
|
+
* rows and the rows are read fresh at emit time. That is a door built for plugins, which is why this
|
|
10
|
+
* package uses it rather than a raw string transform on the HTML.
|
|
11
|
+
*
|
|
12
|
+
* The second is a consequence of being a browser face inside the desktop app. That page's origin is
|
|
13
|
+
* `dsh-app://app`, and the harness's own auth cookie is `SameSite=Strict` and bound to the loopback
|
|
14
|
+
* authority — so a request from the app page to `127.0.0.1` is cross-site and carries no cookie. The
|
|
15
|
+
* connection service would therefore refuse the app's renderer, correctly and forever, no matter how
|
|
16
|
+
* right the rest of the client half is.
|
|
17
|
+
*
|
|
18
|
+
* A capability token — generated once per process, injected only into the page the host itself serves —
|
|
19
|
+
* is the credential that can travel. It is script-readable where the cookie is not, and that is a real
|
|
20
|
+
* weakening: it is the price of the app working at all. It authorises one route, on loopback, for the
|
|
21
|
+
* life of the process, and is worthless afterwards.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
25
|
+
|
|
26
|
+
/** Global the page reads its route settings from. Duplicated in the client half, which cannot import it. */
|
|
27
|
+
export const INJECTED_KEY = '__DSH_REALTIME_AUDIO__'
|
|
28
|
+
|
|
29
|
+
/** Query parameter carrying the capability token on an upgrade request. */
|
|
30
|
+
export const TOKEN_PARAM = 't'
|
|
31
|
+
|
|
32
|
+
/** A structured index-injection row of the kind this package contributes. */
|
|
33
|
+
export interface InjectedGlobalRow {
|
|
34
|
+
readonly kind: 'global'
|
|
35
|
+
readonly name: string
|
|
36
|
+
readonly value: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where the client should open its socket, or undefined when the host is not listening yet.
|
|
41
|
+
*
|
|
42
|
+
* A wildcard bind is normalised to loopback because the page is on this machine: handing a client
|
|
43
|
+
* `0.0.0.0:port` asks it to connect to a name meaning "every interface", which resolves nowhere useful.
|
|
44
|
+
* An IPv6 literal is bracketed so the authority parses as a URL rather than as a host with a port.
|
|
45
|
+
*
|
|
46
|
+
* @param host - the configured bind host, as the web server reports it.
|
|
47
|
+
* @param port - the port actually listening — the OS-assigned value when the config asked for zero.
|
|
48
|
+
* @returns the authority (`host:port`), or undefined before the server is listening.
|
|
49
|
+
*/
|
|
50
|
+
export function routeAuthority(host: string | undefined, port: number | undefined): string | undefined {
|
|
51
|
+
if (port === undefined) return undefined
|
|
52
|
+
const name = host === undefined || host === '' || host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
|
|
53
|
+
return name.includes(':') ? `[${name}]:${String(port)}` : `${name}:${String(port)}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One process-scoped capability token.
|
|
58
|
+
*
|
|
59
|
+
* 32 bytes, base64url, because the token travels in a URL query and base64url needs no escaping there.
|
|
60
|
+
*/
|
|
61
|
+
export function createRouteToken(): string {
|
|
62
|
+
return randomBytes(32).toString('base64url')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read the token off an upgrade request's target.
|
|
67
|
+
*
|
|
68
|
+
* @param url - the request target as `node:http` reports it: pathname plus query.
|
|
69
|
+
* @returns the token, or undefined when absent or empty.
|
|
70
|
+
*/
|
|
71
|
+
export function tokenFromUrl(url: string | undefined): string | undefined {
|
|
72
|
+
if (url === undefined) return undefined
|
|
73
|
+
const query = url.indexOf('?')
|
|
74
|
+
if (query < 0) return undefined
|
|
75
|
+
const value = new URLSearchParams(url.slice(query + 1)).get(TOKEN_PARAM)
|
|
76
|
+
return value === null || value === '' ? undefined : value
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compare a presented token with this process's own, in constant time.
|
|
81
|
+
*
|
|
82
|
+
* Length is compared first because `timingSafeEqual` throws on buffers of different lengths, which would
|
|
83
|
+
* turn a probe into a crash. The length is not the secret; the value is.
|
|
84
|
+
*
|
|
85
|
+
* @param provided - the token the caller presented, if any.
|
|
86
|
+
* @param expected - this process's token.
|
|
87
|
+
* @returns whether the caller may be treated as this process's own page.
|
|
88
|
+
*/
|
|
89
|
+
export function tokenMatches(provided: string | undefined, expected: string): boolean {
|
|
90
|
+
if (provided === undefined) return false
|
|
91
|
+
const offered = Buffer.from(provided)
|
|
92
|
+
const own = Buffer.from(expected)
|
|
93
|
+
if (offered.length !== own.length) return false
|
|
94
|
+
return timingSafeEqual(offered, own)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The connection service's verdict, overridden by a valid token.
|
|
99
|
+
*
|
|
100
|
+
* DSH's own transport asks the connection service in this position, and this route asks the same question
|
|
101
|
+
* rather than inventing a second scheme that would drift from it. The token is the only addition, and it
|
|
102
|
+
* is not a second scheme: a caller that cannot carry the cookie at all — the app's renderer — may present
|
|
103
|
+
* the token the host injected into that very page instead.
|
|
104
|
+
*
|
|
105
|
+
* @param rejection - what the connection service said about this request.
|
|
106
|
+
* @param provided - the token on the request, if any.
|
|
107
|
+
* @param expected - this process's token.
|
|
108
|
+
* @returns the rejection to write, or undefined to accept the upgrade.
|
|
109
|
+
*/
|
|
110
|
+
export function verdictFor(
|
|
111
|
+
rejection: 401 | 403 | undefined,
|
|
112
|
+
provided: string | undefined,
|
|
113
|
+
expected: string,
|
|
114
|
+
): 401 | 403 | undefined {
|
|
115
|
+
if (rejection === undefined) return undefined
|
|
116
|
+
return tokenMatches(provided, expected) ? undefined : rejection
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The row the page reads, built at emit time.
|
|
121
|
+
*
|
|
122
|
+
* `value` is JSON-serialised by the web server, which escapes `<` so a value cannot close the script
|
|
123
|
+
* element early; an undefined `authority` is dropped by `JSON.stringify`, which is exactly the "this host
|
|
124
|
+
* injected no authority" case the client half names rather than guesses at.
|
|
125
|
+
*/
|
|
126
|
+
export function routeInjectionRow(
|
|
127
|
+
path: string,
|
|
128
|
+
authority: string | undefined,
|
|
129
|
+
token: string,
|
|
130
|
+
): InjectedGlobalRow {
|
|
131
|
+
return { kind: 'global', name: INJECTED_KEY, value: { path, authority, token } }
|
|
132
|
+
}
|