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.
@@ -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. A future index-injection row may set the path; the default matches. */
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
- if (location === undefined || location.host === '')
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.protocol === 'https:' ? 'wss' : 'ws';
71
- return `${scheme}://${location.host}${path}`;
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 url = socketUrl(deps.location, deps.injected?.path ?? exports.DEFAULT_PATH);
214
- if (url === undefined)
215
- return fail('this page has no location to open a socket against');
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)
@@ -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. A future index-injection row may set the path; the default matches. */
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
- if (location === undefined || location.host === '')
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.protocol === 'https:' ? 'wss' : 'ws';
67
- return `${scheme}://${location.host}${path}`;
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 url = socketUrl(deps.location, deps.injected?.path ?? exports.DEFAULT_PATH);
210
- if (url === undefined)
211
- return fail('this page has no location to open a socket against');
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)
@@ -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;AAOlD,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;AAqBF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,qBAAqB,GAAG,IAAI,CAoDvE"}
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
- const rejection = services.connection.requestRejection(req);
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":"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"}
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"}
@@ -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.1.3",
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",
@@ -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
- /** Optional host-injected settings. A future index-injection row may set the path; the default matches. */
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: { readonly path?: string } | undefined
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(location: LocationLike | undefined, path: string): string | undefined {
129
- if (location === undefined || location.host === '') return undefined
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.protocol === 'https:' ? 'wss' : 'ws'
133
- return `${scheme}://${location.host}${path}`
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__?: { readonly path?: string }
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 url = socketUrl(deps.location, deps.injected?.path ?? DEFAULT_PATH)
289
- if (url === undefined) return fail('this page has no location to open a socket against')
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
- const rejection = services.connection.requestRejection(req)
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
@@ -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
+ }