dsh-realtime-audio-ws 0.1.3 → 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 +43 -8
- 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 +1 -1
- package/src/client/index.ts +56 -9
- package/src/index.ts +43 -1
- package/src/injection.ts +132 -0
package/lib/client/bundle.js
CHANGED
|
@@ -30,6 +30,8 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
|
|
|
30
30
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
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
32
|
exports.socketUrl = socketUrl;
|
|
33
|
+
exports.pageAuthority = pageAuthority;
|
|
34
|
+
exports.withToken = withToken;
|
|
33
35
|
exports.pcm16FromFloat32 = pcm16FromFloat32;
|
|
34
36
|
exports.float32FromPcm16 = float32FromPcm16;
|
|
35
37
|
exports.defaultDeps = defaultDeps;
|
|
@@ -52,7 +54,7 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
|
|
|
52
54
|
exports.MAX_BACKLOG_SECONDS = 0.5;
|
|
53
55
|
/** Global the client publishes itself on. There is no UI surface yet; this is how it is started. */
|
|
54
56
|
exports.GLOBAL_KEY = '__dshRealtimeAudio';
|
|
55
|
-
/** Optional host-injected settings
|
|
57
|
+
/** Optional host-injected settings, published as a `global` index-injection row before this file runs. */
|
|
56
58
|
exports.INJECTED_KEY = '__DSH_REALTIME_AUDIO__';
|
|
57
59
|
// ---- pure helpers --------------------------------------------------------------------------------------
|
|
58
60
|
/**
|
|
@@ -62,13 +64,44 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
|
|
|
62
64
|
* @param path - the route pathname.
|
|
63
65
|
* @returns the URL, or undefined when this page cannot host a socket at all.
|
|
64
66
|
*/
|
|
65
|
-
function socketUrl(location, path) {
|
|
66
|
-
|
|
67
|
+
function socketUrl(location, path, authority) {
|
|
68
|
+
const host = authority !== undefined && authority !== '' ? authority : pageAuthority(location);
|
|
69
|
+
if (host === undefined)
|
|
67
70
|
return undefined;
|
|
68
71
|
// An https page may not open a ws:// socket — the browser blocks it as mixed content — so the scheme has
|
|
69
72
|
// to follow the page rather than being decided here.
|
|
70
|
-
const scheme = location
|
|
71
|
-
return `${scheme}://${
|
|
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)}`;
|
|
72
105
|
}
|
|
73
106
|
/**
|
|
74
107
|
* Convert one captured float block to the wire's PCM16.
|
|
@@ -210,9 +243,11 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
|
|
|
210
243
|
const start = async () => {
|
|
211
244
|
if (current.kind === 'live')
|
|
212
245
|
return current;
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
215
|
-
return fail('this page has
|
|
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);
|
|
216
251
|
if (deps.getUserMedia === undefined)
|
|
217
252
|
return fail('this page has no microphone API');
|
|
218
253
|
if (deps.createAudioContext === undefined)
|
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",
|
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
|
+
}
|