dsh-realtime-audio-ws 0.2.0 → 0.2.1
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/diagnostics.d.ts +62 -0
- package/lib/diagnostics.d.ts.map +1 -0
- package/lib/diagnostics.js +66 -0
- package/lib/diagnostics.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +47 -2
- package/lib/index.js.map +1 -1
- package/lib/types.d.ts +16 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +8 -0
- package/lib/types.js.map +1 -1
- package/package.json +2 -2
- package/src/diagnostics.ts +98 -0
- package/src/index.ts +57 -2
- package/src/types.ts +17 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diagnostics route: the journal, served as JSON over the web server's route registry.
|
|
3
|
+
*
|
|
4
|
+
* S1 story 4. It exists so a failure can be read *after* the fact without a console attached — which is
|
|
5
|
+
* the whole cost this project kept paying, an evening spent inferring a reason that was never written
|
|
6
|
+
* down anywhere.
|
|
7
|
+
*
|
|
8
|
+
* ## The access policy is the audio route's, reused rather than re-derived
|
|
9
|
+
*
|
|
10
|
+
* The host's route registry "knows no harness concepts" and its handlers own the full response, so
|
|
11
|
+
* nothing upstream answers the authentication question for us. The audio route already answers it in
|
|
12
|
+
* this package: ask the connection service, and let the process's capability token override a refusal,
|
|
13
|
+
* because the desktop app's page is served from `dsh-app://app` and is therefore cross-site to loopback
|
|
14
|
+
* — it can never carry the harness's `SameSite=Strict` cookie. This route asks in the same position with
|
|
15
|
+
* the same token: one policy, two doors. Inventing a second check here is how two schemes drift until
|
|
16
|
+
* one of them is found holding the weaker answer.
|
|
17
|
+
*
|
|
18
|
+
* ## What it deliberately does not do
|
|
19
|
+
*
|
|
20
|
+
* It does not redact on the way out. The journal redacts on **write**, so there is no unredacted value
|
|
21
|
+
* in the buffer to serve and no second step for a later edit to forget. A route that redacted at the
|
|
22
|
+
* boundary would look safer and be strictly worse: the secret would already be retained, in memory and
|
|
23
|
+
* in whatever the process dumps.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-realtime-audio-ws/diagnostics
|
|
26
|
+
*/
|
|
27
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
28
|
+
import type { Journal } from 'dsh-realtime';
|
|
29
|
+
/**
|
|
30
|
+
* The slice of the journal this route reads.
|
|
31
|
+
*
|
|
32
|
+
* Typed as the public surface rather than as the class: `Journal` exists as two declarations in this
|
|
33
|
+
* repository — built `lib/` and `src/` — and a class with a private member is nominally typed, so the
|
|
34
|
+
* two are not assignable to each other even though they are the same code.
|
|
35
|
+
*/
|
|
36
|
+
export type DiagnosticsJournal = Pick<Journal, 'snapshot' | 'size' | 'oldestSeq'>;
|
|
37
|
+
/** What the route needs from the plugin that owns it. */
|
|
38
|
+
export interface DiagnosticsDeps {
|
|
39
|
+
/** Absolute pathname to claim, no trailing slash — the registry's own contract. */
|
|
40
|
+
readonly path: string;
|
|
41
|
+
/** This process's capability token, the same one the audio route publishes to the page. */
|
|
42
|
+
readonly token: string;
|
|
43
|
+
/** The connection service's verdict for one request. */
|
|
44
|
+
readonly rejectionFor: (request: {
|
|
45
|
+
headers: IncomingMessage['headers'];
|
|
46
|
+
}) => 401 | 403 | undefined;
|
|
47
|
+
/** The journal to serve. */
|
|
48
|
+
readonly journal: DiagnosticsJournal;
|
|
49
|
+
}
|
|
50
|
+
/** A route the host's registry accepts, described structurally as this package describes the registry. */
|
|
51
|
+
export interface DiagnosticsRoute {
|
|
52
|
+
readonly kind: 'exact';
|
|
53
|
+
readonly path: string;
|
|
54
|
+
readonly handler: (req: IncomingMessage, res: ServerResponse) => void;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build the diagnostics route.
|
|
58
|
+
* @param deps - the path to claim, this process's token, the connection service's verdict, and the journal.
|
|
59
|
+
* @returns the route registration, ready for `webServer.register`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function diagnosticsRoute(deps: DiagnosticsDeps): DiagnosticsRoute;
|
|
62
|
+
//# sourceMappingURL=diagnostics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"diagnostics.d.ts","sourceRoot":"","sources":["../src/diagnostics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAG3C;;;;;;GAMG;AACH,MAAM,MAAM,kBAAkB,GAAG,IAAI,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,GAAG,WAAW,CAAC,CAAA;AAEjF,yDAAyD;AACzD,MAAM,WAAW,eAAe;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,2FAA2F;IAC3F,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,wDAAwD;IACxD,QAAQ,CAAC,YAAY,EAAE,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAA;KAAE,KAAK,GAAG,GAAG,GAAG,GAAG,SAAS,CAAA;IAClG,4BAA4B;IAC5B,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAA;CACrC;AAED,0GAA0G;AAC1G,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,IAAI,CAAA;CACtE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,gBAAgB,CAmBxE"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diagnostics route: the journal, served as JSON over the web server's route registry.
|
|
3
|
+
*
|
|
4
|
+
* S1 story 4. It exists so a failure can be read *after* the fact without a console attached — which is
|
|
5
|
+
* the whole cost this project kept paying, an evening spent inferring a reason that was never written
|
|
6
|
+
* down anywhere.
|
|
7
|
+
*
|
|
8
|
+
* ## The access policy is the audio route's, reused rather than re-derived
|
|
9
|
+
*
|
|
10
|
+
* The host's route registry "knows no harness concepts" and its handlers own the full response, so
|
|
11
|
+
* nothing upstream answers the authentication question for us. The audio route already answers it in
|
|
12
|
+
* this package: ask the connection service, and let the process's capability token override a refusal,
|
|
13
|
+
* because the desktop app's page is served from `dsh-app://app` and is therefore cross-site to loopback
|
|
14
|
+
* — it can never carry the harness's `SameSite=Strict` cookie. This route asks in the same position with
|
|
15
|
+
* the same token: one policy, two doors. Inventing a second check here is how two schemes drift until
|
|
16
|
+
* one of them is found holding the weaker answer.
|
|
17
|
+
*
|
|
18
|
+
* ## What it deliberately does not do
|
|
19
|
+
*
|
|
20
|
+
* It does not redact on the way out. The journal redacts on **write**, so there is no unredacted value
|
|
21
|
+
* in the buffer to serve and no second step for a later edit to forget. A route that redacted at the
|
|
22
|
+
* boundary would look safer and be strictly worse: the secret would already be retained, in memory and
|
|
23
|
+
* in whatever the process dumps.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-realtime-audio-ws/diagnostics
|
|
26
|
+
*/
|
|
27
|
+
import { tokenFromUrl, verdictFor } from './injection.js';
|
|
28
|
+
/**
|
|
29
|
+
* Build the diagnostics route.
|
|
30
|
+
* @param deps - the path to claim, this process's token, the connection service's verdict, and the journal.
|
|
31
|
+
* @returns the route registration, ready for `webServer.register`.
|
|
32
|
+
*/
|
|
33
|
+
export function diagnosticsRoute(deps) {
|
|
34
|
+
return {
|
|
35
|
+
kind: 'exact',
|
|
36
|
+
path: deps.path,
|
|
37
|
+
handler: (req, res) => {
|
|
38
|
+
const rejection = verdictFor(deps.rejectionFor(req), tokenFromUrl(req.url), deps.token);
|
|
39
|
+
if (rejection !== undefined) {
|
|
40
|
+
// The status carries the verdict the connection service actually gave, so a reader can tell a
|
|
41
|
+
// missing credential from a refused one rather than guessing from a generic failure.
|
|
42
|
+
send(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const entries = deps.journal.snapshot();
|
|
46
|
+
// `size` and `oldestSeq` travel with the entries rather than being left for the reader to infer:
|
|
47
|
+
// a truncated buffer and an idle one are the same empty-looking list otherwise, and the sequence
|
|
48
|
+
// number is the only thing that separates "nothing happened" from "the buffer rolled over".
|
|
49
|
+
send(res, 200, { size: deps.journal.size, oldestSeq: deps.journal.oldestSeq, entries });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Write one JSON response.
|
|
55
|
+
*
|
|
56
|
+
* `no-store` on both branches. This response is a picture of a moving buffer, and a cached one would be
|
|
57
|
+
* read as current — the same class of mistake as a journal that cannot show its own eviction.
|
|
58
|
+
* @param res - the response this handler owns.
|
|
59
|
+
* @param status - HTTP status to write.
|
|
60
|
+
* @param body - value to serialise as the JSON body.
|
|
61
|
+
*/
|
|
62
|
+
function send(res, status, body) {
|
|
63
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
64
|
+
res.end(JSON.stringify(body));
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=diagnostics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"diagnostics.js","sourceRoot":"","sources":["../src/diagnostics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAIH,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AA8BzD;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAqB;IACpD,OAAO;QACL,IAAI,EAAE,OAAO;QACb,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;YACpB,MAAM,SAAS,GAAG,UAAU,CAAC,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAA;YACvF,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;gBAC5B,8FAA8F;gBAC9F,qFAAqF;gBACrF,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,EAAE,KAAK,EAAE,SAAS,KAAK,GAAG,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAA;gBACjF,OAAM;YACR,CAAC;YACD,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAA;YACvC,iGAAiG;YACjG,iGAAiG;YACjG,4FAA4F;YAC5F,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,EAAE,CAAC,CAAA;QACzF,CAAC;KACF,CAAA;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,IAAI,CAAC,GAAmB,EAAE,MAAc,EAAE,IAAa;IAC9D,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,eAAe,EAAE,UAAU,EAAE,CAAC,CAAA;IAC1F,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAA;AAC/B,CAAC"}
|
package/lib/index.d.ts
CHANGED
|
@@ -9,13 +9,26 @@
|
|
|
9
9
|
* connection services exist, so a composition without a web stack gets a row that visibly waits rather
|
|
10
10
|
* than one that loads and silently claims nothing. `packages/realtime-audio-ws/tests/plugin.spec.ts` proves
|
|
11
11
|
* the functional path against a real socket with both services present.
|
|
12
|
+
*
|
|
13
|
+
* It claims **two** routes on that registry, and one policy authenticates both: the WebSocket upgrade
|
|
14
|
+
* that carries audio, and the JSON diagnostics route that serves the journal. See `./diagnostics.ts` for
|
|
15
|
+
* why the second reuses the first's check rather than deriving its own.
|
|
12
16
|
*/
|
|
13
17
|
import Schema from '@deepseek-ai/schemastery';
|
|
14
18
|
import type { Context } from '@deepseek-ai/cordis';
|
|
15
19
|
import { type RealtimeAudioWsConfig } from './types.ts';
|
|
16
20
|
export * from './types.ts';
|
|
21
|
+
/**
|
|
22
|
+
* The query parameter carrying the capability token.
|
|
23
|
+
*
|
|
24
|
+
* Published with the route rather than kept internal: nothing can address the route without it, and a
|
|
25
|
+
* consumer restating the literal is a consumer that drifts. It is one string; the rest of `injection.ts`
|
|
26
|
+
* stays where it is.
|
|
27
|
+
*/
|
|
28
|
+
export { TOKEN_PARAM } from './injection.ts';
|
|
17
29
|
export { attachAudioSocket, toBytes, type AudioSocketBridgeDeps } from './bridge.ts';
|
|
18
30
|
export { createUpgradeAcceptor, rejectUpgrade, type UpgradeAcceptor } from './upgrade.ts';
|
|
31
|
+
export { diagnosticsRoute, type DiagnosticsDeps, type DiagnosticsJournal, type DiagnosticsRoute } from './diagnostics.ts';
|
|
19
32
|
/** Plugin name. */
|
|
20
33
|
export declare const name = "realtime-audio-ws";
|
|
21
34
|
export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
|
|
@@ -23,11 +36,13 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
|
|
|
23
36
|
maxFrameBytes: Schema<number, number, "defined">;
|
|
24
37
|
maxConnections: Schema<number, number, "defined">;
|
|
25
38
|
openSessionOnConnect: Schema<boolean, boolean, "defined">;
|
|
39
|
+
diagnosticsPath: Schema<string, string, "defined">;
|
|
26
40
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
27
41
|
path: Schema<string, string, "defined">;
|
|
28
42
|
maxFrameBytes: Schema<number, number, "defined">;
|
|
29
43
|
maxConnections: Schema<number, number, "defined">;
|
|
30
44
|
openSessionOnConnect: Schema<boolean, boolean, "defined">;
|
|
45
|
+
diagnosticsPath: Schema<string, string, "defined">;
|
|
31
46
|
}>>, "plain">;
|
|
32
47
|
export declare function apply(ctx: Context, config: RealtimeAudioWsConfig): void;
|
|
33
48
|
//# sourceMappingURL=index.d.ts.map
|
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAgBlD,OAAO,EAOL,KAAK,qBAAqB,EAC3B,MAAM,YAAY,CAAA;AAEnB,cAAc,YAAY,CAAA;AAC1B;;;;;;GAMG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5C,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;AACzF,OAAO,EAAE,gBAAgB,EAAE,KAAK,eAAe,EAAE,KAAK,kBAAkB,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAA;AAEzH,mBAAmB;AACnB,eAAO,MAAM,IAAI,sBAAsB,CAAA;AAEvC,eAAO,MAAM,MAAM;;;;;;;;;;;;aASjB,CAAA;AAqCF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,qBAAqB,GAAG,IAAI,CA6GvE"}
|
package/lib/index.js
CHANGED
|
@@ -9,15 +9,29 @@
|
|
|
9
9
|
* connection services exist, so a composition without a web stack gets a row that visibly waits rather
|
|
10
10
|
* than one that loads and silently claims nothing. `packages/realtime-audio-ws/tests/plugin.spec.ts` proves
|
|
11
11
|
* the functional path against a real socket with both services present.
|
|
12
|
+
*
|
|
13
|
+
* It claims **two** routes on that registry, and one policy authenticates both: the WebSocket upgrade
|
|
14
|
+
* that carries audio, and the JSON diagnostics route that serves the journal. See `./diagnostics.ts` for
|
|
15
|
+
* why the second reuses the first's check rather than deriving its own.
|
|
12
16
|
*/
|
|
13
17
|
import Schema from '@deepseek-ai/schemastery';
|
|
14
18
|
import { attachAudioSocket } from './bridge.js';
|
|
19
|
+
import { diagnosticsRoute } from './diagnostics.js';
|
|
15
20
|
import { createRouteToken, routeAuthority, routeInjectionRow, tokenFromUrl, verdictFor, } from './injection.js';
|
|
16
21
|
import { createUpgradeAcceptor, rejectUpgrade } from './upgrade.js';
|
|
17
|
-
import { DEFAULT_MAX_CONNECTIONS, DEFAULT_MAX_FRAME_BYTES, DEFAULT_OPEN_SESSION_ON_CONNECT, DEFAULT_PATH, } from './types.js';
|
|
22
|
+
import { DEFAULT_DIAGNOSTICS_PATH, DEFAULT_MAX_CONNECTIONS, DEFAULT_MAX_FRAME_BYTES, DEFAULT_OPEN_SESSION_ON_CONNECT, DEFAULT_PATH, } from './types.js';
|
|
18
23
|
export * from './types.js';
|
|
24
|
+
/**
|
|
25
|
+
* The query parameter carrying the capability token.
|
|
26
|
+
*
|
|
27
|
+
* Published with the route rather than kept internal: nothing can address the route without it, and a
|
|
28
|
+
* consumer restating the literal is a consumer that drifts. It is one string; the rest of `injection.ts`
|
|
29
|
+
* stays where it is.
|
|
30
|
+
*/
|
|
31
|
+
export { TOKEN_PARAM } from './injection.js';
|
|
19
32
|
export { attachAudioSocket, toBytes } from './bridge.js';
|
|
20
33
|
export { createUpgradeAcceptor, rejectUpgrade } from './upgrade.js';
|
|
34
|
+
export { diagnosticsRoute } from './diagnostics.js';
|
|
21
35
|
/** Plugin name. */
|
|
22
36
|
export const name = 'realtime-audio-ws';
|
|
23
37
|
export const Config = Schema.object({
|
|
@@ -28,13 +42,20 @@ export const Config = Schema.object({
|
|
|
28
42
|
// boot with nobody asking, which is why it is false. This one opens a session because someone connected a
|
|
29
43
|
// microphone: a connection takes an explicit action, an authenticated one, and its absence is silence.
|
|
30
44
|
openSessionOnConnect: Schema.boolean().default(DEFAULT_OPEN_SESSION_ON_CONNECT),
|
|
45
|
+
diagnosticsPath: Schema.string().default(DEFAULT_DIAGNOSTICS_PATH),
|
|
31
46
|
});
|
|
32
47
|
export function apply(ctx, config) {
|
|
33
|
-
ctx.inject(['connection', 'webServer'], (injected) => {
|
|
48
|
+
ctx.inject(['connection', 'webServer', 'realtime'], (injected) => {
|
|
34
49
|
const services = injected;
|
|
35
50
|
const acceptor = createUpgradeAcceptor(config.maxFrameBytes);
|
|
36
51
|
const clients = new Set();
|
|
37
52
|
const token = createRouteToken();
|
|
53
|
+
const journal = injected.realtime.journal;
|
|
54
|
+
// The token travels in a URL query, and this route records request targets, so the journal has to be
|
|
55
|
+
// able to redact it before it records one. This is exactly why `addSecrets` is additive instead of a
|
|
56
|
+
// constructor argument: the plugin that mints the token is the only one that can name it, and it
|
|
57
|
+
// mints it here, after the journal already exists.
|
|
58
|
+
journal.addSecrets([token]);
|
|
38
59
|
// The injection event is declared by `@deepseek-ai/dsh-host-webserver`, which this package deliberately
|
|
39
60
|
// does not import: importing it would augment `Context` with a second declaration of `webServer` and
|
|
40
61
|
// turn the structural dependency above into a compile error. The name is cast at this one boundary.
|
|
@@ -46,6 +67,12 @@ export function apply(ctx, config) {
|
|
|
46
67
|
onInjection('webserver/index-inject', (table) => {
|
|
47
68
|
table.push(routeInjectionRow(config.path, routeAuthority(services.webServer.config?.host, services.webServer.listenedPort), token));
|
|
48
69
|
});
|
|
70
|
+
const unregisterDiagnostics = services.webServer.register(diagnosticsRoute({
|
|
71
|
+
path: config.diagnosticsPath,
|
|
72
|
+
token,
|
|
73
|
+
rejectionFor: (request) => services.connection.requestRejection(request),
|
|
74
|
+
journal,
|
|
75
|
+
}));
|
|
49
76
|
const unregister = services.webServer.registerUpgrade({
|
|
50
77
|
path: config.path,
|
|
51
78
|
handler: (req, socket, head) => {
|
|
@@ -59,18 +86,34 @@ export function apply(ctx, config) {
|
|
|
59
86
|
// from `dsh-app://app`, so its requests to loopback are cross-site and the harness's
|
|
60
87
|
// `SameSite=Strict` cookie cannot travel; without the token that page is refused forever, however
|
|
61
88
|
// correct the rest of the client half is.
|
|
89
|
+
// Normalised once, at the caller's edge, so no record below repeats the check. `node:http`
|
|
90
|
+
// always reports a target on a server request; the type admits `undefined` for client requests,
|
|
91
|
+
// which this handler cannot see.
|
|
92
|
+
const target = req.url ?? '';
|
|
62
93
|
const rejection = verdictFor(services.connection.requestRejection(req), tokenFromUrl(req.url), token);
|
|
63
94
|
if (rejection !== undefined) {
|
|
95
|
+
// The verdict only, and no target. A valid token is never rejected — it overrides the
|
|
96
|
+
// refusal — so any target recorded here belongs to something else, and a wrong token is
|
|
97
|
+
// still a credential. Retaining a caller's secret in order to log a refusal is a worse trade
|
|
98
|
+
// than losing which path was probed.
|
|
99
|
+
journal.record('socket.rejected', { verdict: String(rejection) });
|
|
64
100
|
rejectUpgrade(socket, rejection);
|
|
65
101
|
return;
|
|
66
102
|
}
|
|
67
103
|
acceptor.handleUpgrade(req, socket, head, (client) => {
|
|
68
104
|
if (clients.size >= config.maxConnections) {
|
|
69
105
|
// 1013 = try again later. Closing the newcomer leaves the existing microphone live.
|
|
106
|
+
journal.record('socket.rejected', { verdict: '1013', reason: 'busy' });
|
|
70
107
|
client.close(1013, 'busy');
|
|
71
108
|
return;
|
|
72
109
|
}
|
|
73
110
|
clients.add(client);
|
|
111
|
+
// The target here, because an accepted request is the one place this process's own token
|
|
112
|
+
// travels: the app page presents it in the query precisely because it cannot carry the
|
|
113
|
+
// cookie. The journal redacts it on write — which is what the token was added to the journal's
|
|
114
|
+
// secrets for, and the reason that has to be additive rather than a constructor argument,
|
|
115
|
+
// since the token does not exist until this plugin applies.
|
|
116
|
+
journal.record('socket.accepted', { clients: String(clients.size), url: target });
|
|
74
117
|
// Ask for the session before the bridge goes in, so the first frames are written into a session
|
|
75
118
|
// that is being opened rather than dropped by the mic seam's no-session rule.
|
|
76
119
|
if (config.openSessionOnConnect)
|
|
@@ -81,6 +124,7 @@ export function apply(ctx, config) {
|
|
|
81
124
|
maxFrameBytes: config.maxFrameBytes,
|
|
82
125
|
onDetach: () => {
|
|
83
126
|
clients.delete(client);
|
|
127
|
+
journal.record('socket.closed', { clients: String(clients.size) });
|
|
84
128
|
// The last client leaving ends the session. This also covers the transport's own disposal,
|
|
85
129
|
// which terminates its clients — so a profile reload does not leave a session nobody holds.
|
|
86
130
|
if (config.openSessionOnConnect && clients.size === 0)
|
|
@@ -92,6 +136,7 @@ export function apply(ctx, config) {
|
|
|
92
136
|
});
|
|
93
137
|
return async () => {
|
|
94
138
|
unregister();
|
|
139
|
+
unregisterDiagnostics();
|
|
95
140
|
for (const client of clients)
|
|
96
141
|
client.terminate();
|
|
97
142
|
clients.clear();
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/lib/types.d.ts
CHANGED
|
@@ -19,6 +19,14 @@ export declare const DEFAULT_MAX_FRAME_BYTES = 480000;
|
|
|
19
19
|
export declare const DEFAULT_MAX_CONNECTIONS = 1;
|
|
20
20
|
/** Whether an authenticated connection asks the agent to open the voice session. See the config field. */
|
|
21
21
|
export declare const DEFAULT_OPEN_SESSION_ON_CONNECT = true;
|
|
22
|
+
/**
|
|
23
|
+
* Default pathname the diagnostics route claims.
|
|
24
|
+
*
|
|
25
|
+
* A fixed API path in spirit, but a validated config field like the audio route's — so a composition
|
|
26
|
+
* that already owns this path can move it deliberately rather than discovering the collision when the
|
|
27
|
+
* registry throws at registration.
|
|
28
|
+
*/
|
|
29
|
+
export declare const DEFAULT_DIAGNOSTICS_PATH = "/dsh-realtime/diagnostics";
|
|
22
30
|
export interface RealtimeAudioWsConfig {
|
|
23
31
|
/**
|
|
24
32
|
* Absolute pathname to claim. Registered exactly, so it must be distinct from every other route in the
|
|
@@ -45,6 +53,14 @@ export interface RealtimeAudioWsConfig {
|
|
|
45
53
|
* that looks like a fault anywhere but in this file.
|
|
46
54
|
*/
|
|
47
55
|
readonly openSessionOnConnect: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Absolute pathname the diagnostics route claims, served as JSON.
|
|
58
|
+
*
|
|
59
|
+
* Authenticated by the same policy as the audio route, and for the same reason: it is an HTTP route on
|
|
60
|
+
* loopback that the host's registry does not gate for us, and it reports the journal — which is worth
|
|
61
|
+
* more to a stranger than an empty socket.
|
|
62
|
+
*/
|
|
63
|
+
readonly diagnosticsPath: string;
|
|
48
64
|
}
|
|
49
65
|
/**
|
|
50
66
|
* The slice of a WebSocket this package uses, described structurally.
|
package/lib/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,0GAA0G;AAC1G,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,EAAE,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,0GAA0G;AAC1G,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,8BAA8B,CAAA;AAEnE,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,EAAE,OAAO,CAAA;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;IAC5B,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3C,SAAS,IAAI,IAAI,CAAA;IACjB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAA;IACnF,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;CAC9E"}
|
package/lib/types.js
CHANGED
|
@@ -19,4 +19,12 @@ export const DEFAULT_MAX_FRAME_BYTES = 480_000;
|
|
|
19
19
|
export const DEFAULT_MAX_CONNECTIONS = 1;
|
|
20
20
|
/** Whether an authenticated connection asks the agent to open the voice session. See the config field. */
|
|
21
21
|
export const DEFAULT_OPEN_SESSION_ON_CONNECT = true;
|
|
22
|
+
/**
|
|
23
|
+
* Default pathname the diagnostics route claims.
|
|
24
|
+
*
|
|
25
|
+
* A fixed API path in spirit, but a validated config field like the audio route's — so a composition
|
|
26
|
+
* that already owns this path can move it deliberately rather than discovering the collision when the
|
|
27
|
+
* registry throws at registration.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_DIAGNOSTICS_PATH = '/dsh-realtime/diagnostics';
|
|
22
30
|
//# sourceMappingURL=types.js.map
|
package/lib/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,MAAM,CAAC,MAAM,YAAY,GAAG,qBAAqB,CAAA;AAEjD,6FAA6F;AAC7F,MAAM,CAAC,MAAM,uBAAuB,GAAG,OAAO,CAAA;AAE9C,sFAAsF;AACtF,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAA;AAExC,0GAA0G;AAC1G,MAAM,CAAC,MAAM,+BAA+B,GAAG,IAAI,CAAA"}
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,MAAM,CAAC,MAAM,YAAY,GAAG,qBAAqB,CAAA;AAEjD,6FAA6F;AAC7F,MAAM,CAAC,MAAM,uBAAuB,GAAG,OAAO,CAAA;AAE9C,sFAAsF;AACtF,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAA;AAExC,0GAA0G;AAC1G,MAAM,CAAC,MAAM,+BAA+B,GAAG,IAAI,CAAA;AAEnD;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,2BAA2B,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-realtime-audio-ws",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The host end of the client half's transport: a WebSocket upgrade route that bridges microphone audio in and agent speech out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"ws": "^8.22.0",
|
|
55
|
-
"dsh-realtime-agent": "^0.2.
|
|
55
|
+
"dsh-realtime-agent": "^0.2.6"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@deepseek-ai/cordis": "^4.0.3",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diagnostics route: the journal, served as JSON over the web server's route registry.
|
|
3
|
+
*
|
|
4
|
+
* S1 story 4. It exists so a failure can be read *after* the fact without a console attached — which is
|
|
5
|
+
* the whole cost this project kept paying, an evening spent inferring a reason that was never written
|
|
6
|
+
* down anywhere.
|
|
7
|
+
*
|
|
8
|
+
* ## The access policy is the audio route's, reused rather than re-derived
|
|
9
|
+
*
|
|
10
|
+
* The host's route registry "knows no harness concepts" and its handlers own the full response, so
|
|
11
|
+
* nothing upstream answers the authentication question for us. The audio route already answers it in
|
|
12
|
+
* this package: ask the connection service, and let the process's capability token override a refusal,
|
|
13
|
+
* because the desktop app's page is served from `dsh-app://app` and is therefore cross-site to loopback
|
|
14
|
+
* — it can never carry the harness's `SameSite=Strict` cookie. This route asks in the same position with
|
|
15
|
+
* the same token: one policy, two doors. Inventing a second check here is how two schemes drift until
|
|
16
|
+
* one of them is found holding the weaker answer.
|
|
17
|
+
*
|
|
18
|
+
* ## What it deliberately does not do
|
|
19
|
+
*
|
|
20
|
+
* It does not redact on the way out. The journal redacts on **write**, so there is no unredacted value
|
|
21
|
+
* in the buffer to serve and no second step for a later edit to forget. A route that redacted at the
|
|
22
|
+
* boundary would look safer and be strictly worse: the secret would already be retained, in memory and
|
|
23
|
+
* in whatever the process dumps.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-realtime-audio-ws/diagnostics
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
29
|
+
import type { Journal } from 'dsh-realtime'
|
|
30
|
+
import { tokenFromUrl, verdictFor } from './injection.ts'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The slice of the journal this route reads.
|
|
34
|
+
*
|
|
35
|
+
* Typed as the public surface rather than as the class: `Journal` exists as two declarations in this
|
|
36
|
+
* repository — built `lib/` and `src/` — and a class with a private member is nominally typed, so the
|
|
37
|
+
* two are not assignable to each other even though they are the same code.
|
|
38
|
+
*/
|
|
39
|
+
export type DiagnosticsJournal = Pick<Journal, 'snapshot' | 'size' | 'oldestSeq'>
|
|
40
|
+
|
|
41
|
+
/** What the route needs from the plugin that owns it. */
|
|
42
|
+
export interface DiagnosticsDeps {
|
|
43
|
+
/** Absolute pathname to claim, no trailing slash — the registry's own contract. */
|
|
44
|
+
readonly path: string
|
|
45
|
+
/** This process's capability token, the same one the audio route publishes to the page. */
|
|
46
|
+
readonly token: string
|
|
47
|
+
/** The connection service's verdict for one request. */
|
|
48
|
+
readonly rejectionFor: (request: { headers: IncomingMessage['headers'] }) => 401 | 403 | undefined
|
|
49
|
+
/** The journal to serve. */
|
|
50
|
+
readonly journal: DiagnosticsJournal
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A route the host's registry accepts, described structurally as this package describes the registry. */
|
|
54
|
+
export interface DiagnosticsRoute {
|
|
55
|
+
readonly kind: 'exact'
|
|
56
|
+
readonly path: string
|
|
57
|
+
readonly handler: (req: IncomingMessage, res: ServerResponse) => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the diagnostics route.
|
|
62
|
+
* @param deps - the path to claim, this process's token, the connection service's verdict, and the journal.
|
|
63
|
+
* @returns the route registration, ready for `webServer.register`.
|
|
64
|
+
*/
|
|
65
|
+
export function diagnosticsRoute(deps: DiagnosticsDeps): DiagnosticsRoute {
|
|
66
|
+
return {
|
|
67
|
+
kind: 'exact',
|
|
68
|
+
path: deps.path,
|
|
69
|
+
handler: (req, res) => {
|
|
70
|
+
const rejection = verdictFor(deps.rejectionFor(req), tokenFromUrl(req.url), deps.token)
|
|
71
|
+
if (rejection !== undefined) {
|
|
72
|
+
// The status carries the verdict the connection service actually gave, so a reader can tell a
|
|
73
|
+
// missing credential from a refused one rather than guessing from a generic failure.
|
|
74
|
+
send(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' })
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
const entries = deps.journal.snapshot()
|
|
78
|
+
// `size` and `oldestSeq` travel with the entries rather than being left for the reader to infer:
|
|
79
|
+
// a truncated buffer and an idle one are the same empty-looking list otherwise, and the sequence
|
|
80
|
+
// number is the only thing that separates "nothing happened" from "the buffer rolled over".
|
|
81
|
+
send(res, 200, { size: deps.journal.size, oldestSeq: deps.journal.oldestSeq, entries })
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Write one JSON response.
|
|
88
|
+
*
|
|
89
|
+
* `no-store` on both branches. This response is a picture of a moving buffer, and a cached one would be
|
|
90
|
+
* read as current — the same class of mistake as a journal that cannot show its own eviction.
|
|
91
|
+
* @param res - the response this handler owns.
|
|
92
|
+
* @param status - HTTP status to write.
|
|
93
|
+
* @param body - value to serialise as the JSON body.
|
|
94
|
+
*/
|
|
95
|
+
function send(res: ServerResponse, status: number, body: unknown): void {
|
|
96
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
97
|
+
res.end(JSON.stringify(body))
|
|
98
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,15 +9,20 @@
|
|
|
9
9
|
* connection services exist, so a composition without a web stack gets a row that visibly waits rather
|
|
10
10
|
* than one that loads and silently claims nothing. `packages/realtime-audio-ws/tests/plugin.spec.ts` proves
|
|
11
11
|
* the functional path against a real socket with both services present.
|
|
12
|
+
*
|
|
13
|
+
* It claims **two** routes on that registry, and one policy authenticates both: the WebSocket upgrade
|
|
14
|
+
* that carries audio, and the JSON diagnostics route that serves the journal. See `./diagnostics.ts` for
|
|
15
|
+
* why the second reuses the first's check rather than deriving its own.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import Schema from '@deepseek-ai/schemastery'
|
|
15
19
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
|
-
import type { IncomingMessage } from 'node:http'
|
|
20
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
17
21
|
import type { Duplex } from 'node:stream'
|
|
18
22
|
// Type-only: pulls the `Events` augmentation that declares the two audio events this package bridges.
|
|
19
23
|
import type {} from 'dsh-realtime-agent'
|
|
20
24
|
import { attachAudioSocket } from './bridge.ts'
|
|
25
|
+
import { diagnosticsRoute } from './diagnostics.ts'
|
|
21
26
|
import {
|
|
22
27
|
createRouteToken,
|
|
23
28
|
routeAuthority,
|
|
@@ -28,6 +33,7 @@ import {
|
|
|
28
33
|
} from './injection.ts'
|
|
29
34
|
import { createUpgradeAcceptor, rejectUpgrade } from './upgrade.ts'
|
|
30
35
|
import {
|
|
36
|
+
DEFAULT_DIAGNOSTICS_PATH,
|
|
31
37
|
DEFAULT_MAX_CONNECTIONS,
|
|
32
38
|
DEFAULT_MAX_FRAME_BYTES,
|
|
33
39
|
DEFAULT_OPEN_SESSION_ON_CONNECT,
|
|
@@ -37,8 +43,17 @@ import {
|
|
|
37
43
|
} from './types.ts'
|
|
38
44
|
|
|
39
45
|
export * from './types.ts'
|
|
46
|
+
/**
|
|
47
|
+
* The query parameter carrying the capability token.
|
|
48
|
+
*
|
|
49
|
+
* Published with the route rather than kept internal: nothing can address the route without it, and a
|
|
50
|
+
* consumer restating the literal is a consumer that drifts. It is one string; the rest of `injection.ts`
|
|
51
|
+
* stays where it is.
|
|
52
|
+
*/
|
|
53
|
+
export { TOKEN_PARAM } from './injection.ts'
|
|
40
54
|
export { attachAudioSocket, toBytes, type AudioSocketBridgeDeps } from './bridge.ts'
|
|
41
55
|
export { createUpgradeAcceptor, rejectUpgrade, type UpgradeAcceptor } from './upgrade.ts'
|
|
56
|
+
export { diagnosticsRoute, type DiagnosticsDeps, type DiagnosticsJournal, type DiagnosticsRoute } from './diagnostics.ts'
|
|
42
57
|
|
|
43
58
|
/** Plugin name. */
|
|
44
59
|
export const name = 'realtime-audio-ws'
|
|
@@ -51,6 +66,7 @@ export const Config = Schema.object({
|
|
|
51
66
|
// boot with nobody asking, which is why it is false. This one opens a session because someone connected a
|
|
52
67
|
// microphone: a connection takes an explicit action, an authenticated one, and its absence is silence.
|
|
53
68
|
openSessionOnConnect: Schema.boolean().default(DEFAULT_OPEN_SESSION_ON_CONNECT),
|
|
69
|
+
diagnosticsPath: Schema.string().default(DEFAULT_DIAGNOSTICS_PATH),
|
|
54
70
|
})
|
|
55
71
|
|
|
56
72
|
/**
|
|
@@ -59,12 +75,21 @@ export const Config = Schema.object({
|
|
|
59
75
|
* Structural rather than imported: `@deepseek-ai/dsh-host-webserver` is a DeepSeek package, and importing
|
|
60
76
|
* it would both add a dependency edge for one shape and augment `Context` with another declaration of
|
|
61
77
|
* `webServer` — two declarations of one property with different types is a compile error.
|
|
78
|
+
*
|
|
79
|
+
* `register` applies no authentication of its own and knows no harness concepts: the handler owns the
|
|
80
|
+
* whole response. That is why both routes here carry the same explicit check rather than trusting the
|
|
81
|
+
* registry to have made the decision.
|
|
62
82
|
*/
|
|
63
83
|
interface WebServerLike {
|
|
64
84
|
registerUpgrade(route: {
|
|
65
85
|
path: string
|
|
66
86
|
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
|
67
87
|
}): () => void
|
|
88
|
+
register(route: {
|
|
89
|
+
kind: 'exact' | 'prefix'
|
|
90
|
+
path: string
|
|
91
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
92
|
+
}): () => void
|
|
68
93
|
/**
|
|
69
94
|
* The port actually listening — the OS-assigned value when the config asked for zero. Read at injection
|
|
70
95
|
* time rather than at boot, because an index render happens after the listen.
|
|
@@ -80,11 +105,17 @@ interface ConnectionLike {
|
|
|
80
105
|
}
|
|
81
106
|
|
|
82
107
|
export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
|
|
83
|
-
ctx.inject(['connection', 'webServer'], (injected) => {
|
|
108
|
+
ctx.inject(['connection', 'webServer', 'realtime'], (injected) => {
|
|
84
109
|
const services = injected as unknown as { webServer: WebServerLike; connection: ConnectionLike }
|
|
85
110
|
const acceptor = createUpgradeAcceptor(config.maxFrameBytes)
|
|
86
111
|
const clients = new Set<AudioSocket>()
|
|
87
112
|
const token = createRouteToken()
|
|
113
|
+
const journal = injected.realtime.journal
|
|
114
|
+
// The token travels in a URL query, and this route records request targets, so the journal has to be
|
|
115
|
+
// able to redact it before it records one. This is exactly why `addSecrets` is additive instead of a
|
|
116
|
+
// constructor argument: the plugin that mints the token is the only one that can name it, and it
|
|
117
|
+
// mints it here, after the journal already exists.
|
|
118
|
+
journal.addSecrets([token])
|
|
88
119
|
// The injection event is declared by `@deepseek-ai/dsh-host-webserver`, which this package deliberately
|
|
89
120
|
// does not import: importing it would augment `Context` with a second declaration of `webServer` and
|
|
90
121
|
// turn the structural dependency above into a compile error. The name is cast at this one boundary.
|
|
@@ -104,6 +135,12 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
|
|
|
104
135
|
token,
|
|
105
136
|
))
|
|
106
137
|
})
|
|
138
|
+
const unregisterDiagnostics = services.webServer.register(diagnosticsRoute({
|
|
139
|
+
path: config.diagnosticsPath,
|
|
140
|
+
token,
|
|
141
|
+
rejectionFor: (request) => services.connection.requestRejection(request),
|
|
142
|
+
journal,
|
|
143
|
+
}))
|
|
107
144
|
const unregister = services.webServer.registerUpgrade({
|
|
108
145
|
path: config.path,
|
|
109
146
|
handler: (req, socket, head) => {
|
|
@@ -117,22 +154,38 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
|
|
|
117
154
|
// from `dsh-app://app`, so its requests to loopback are cross-site and the harness's
|
|
118
155
|
// `SameSite=Strict` cookie cannot travel; without the token that page is refused forever, however
|
|
119
156
|
// correct the rest of the client half is.
|
|
157
|
+
// Normalised once, at the caller's edge, so no record below repeats the check. `node:http`
|
|
158
|
+
// always reports a target on a server request; the type admits `undefined` for client requests,
|
|
159
|
+
// which this handler cannot see.
|
|
160
|
+
const target = req.url ?? ''
|
|
120
161
|
const rejection = verdictFor(
|
|
121
162
|
services.connection.requestRejection(req),
|
|
122
163
|
tokenFromUrl(req.url),
|
|
123
164
|
token,
|
|
124
165
|
)
|
|
125
166
|
if (rejection !== undefined) {
|
|
167
|
+
// The verdict only, and no target. A valid token is never rejected — it overrides the
|
|
168
|
+
// refusal — so any target recorded here belongs to something else, and a wrong token is
|
|
169
|
+
// still a credential. Retaining a caller's secret in order to log a refusal is a worse trade
|
|
170
|
+
// than losing which path was probed.
|
|
171
|
+
journal.record('socket.rejected', { verdict: String(rejection) })
|
|
126
172
|
rejectUpgrade(socket, rejection)
|
|
127
173
|
return
|
|
128
174
|
}
|
|
129
175
|
acceptor.handleUpgrade(req, socket, head, (client) => {
|
|
130
176
|
if (clients.size >= config.maxConnections) {
|
|
131
177
|
// 1013 = try again later. Closing the newcomer leaves the existing microphone live.
|
|
178
|
+
journal.record('socket.rejected', { verdict: '1013', reason: 'busy' })
|
|
132
179
|
client.close(1013, 'busy')
|
|
133
180
|
return
|
|
134
181
|
}
|
|
135
182
|
clients.add(client)
|
|
183
|
+
// The target here, because an accepted request is the one place this process's own token
|
|
184
|
+
// travels: the app page presents it in the query precisely because it cannot carry the
|
|
185
|
+
// cookie. The journal redacts it on write — which is what the token was added to the journal's
|
|
186
|
+
// secrets for, and the reason that has to be additive rather than a constructor argument,
|
|
187
|
+
// since the token does not exist until this plugin applies.
|
|
188
|
+
journal.record('socket.accepted', { clients: String(clients.size), url: target })
|
|
136
189
|
// Ask for the session before the bridge goes in, so the first frames are written into a session
|
|
137
190
|
// that is being opened rather than dropped by the mic seam's no-session rule.
|
|
138
191
|
if (config.openSessionOnConnect) ctx.emit('realtime-agent/start')
|
|
@@ -142,6 +195,7 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
|
|
|
142
195
|
maxFrameBytes: config.maxFrameBytes,
|
|
143
196
|
onDetach: () => {
|
|
144
197
|
clients.delete(client)
|
|
198
|
+
journal.record('socket.closed', { clients: String(clients.size) })
|
|
145
199
|
// The last client leaving ends the session. This also covers the transport's own disposal,
|
|
146
200
|
// which terminates its clients — so a profile reload does not leave a session nobody holds.
|
|
147
201
|
if (config.openSessionOnConnect && clients.size === 0) ctx.emit('realtime-agent/stop')
|
|
@@ -152,6 +206,7 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
|
|
|
152
206
|
})
|
|
153
207
|
return async () => {
|
|
154
208
|
unregister()
|
|
209
|
+
unregisterDiagnostics()
|
|
155
210
|
for (const client of clients) client.terminate()
|
|
156
211
|
clients.clear()
|
|
157
212
|
await acceptor.close()
|
package/src/types.ts
CHANGED
|
@@ -24,6 +24,15 @@ export const DEFAULT_MAX_CONNECTIONS = 1
|
|
|
24
24
|
/** Whether an authenticated connection asks the agent to open the voice session. See the config field. */
|
|
25
25
|
export const DEFAULT_OPEN_SESSION_ON_CONNECT = true
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Default pathname the diagnostics route claims.
|
|
29
|
+
*
|
|
30
|
+
* A fixed API path in spirit, but a validated config field like the audio route's — so a composition
|
|
31
|
+
* that already owns this path can move it deliberately rather than discovering the collision when the
|
|
32
|
+
* registry throws at registration.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_DIAGNOSTICS_PATH = '/dsh-realtime/diagnostics'
|
|
35
|
+
|
|
27
36
|
export interface RealtimeAudioWsConfig {
|
|
28
37
|
/**
|
|
29
38
|
* Absolute pathname to claim. Registered exactly, so it must be distinct from every other route in the
|
|
@@ -50,6 +59,14 @@ export interface RealtimeAudioWsConfig {
|
|
|
50
59
|
* that looks like a fault anywhere but in this file.
|
|
51
60
|
*/
|
|
52
61
|
readonly openSessionOnConnect: boolean
|
|
62
|
+
/**
|
|
63
|
+
* Absolute pathname the diagnostics route claims, served as JSON.
|
|
64
|
+
*
|
|
65
|
+
* Authenticated by the same policy as the audio route, and for the same reason: it is an HTTP route on
|
|
66
|
+
* loopback that the host's registry does not gate for us, and it reports the journal — which is worth
|
|
67
|
+
* more to a stranger than an empty socket.
|
|
68
|
+
*/
|
|
69
|
+
readonly diagnosticsPath: string
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
/**
|