dsh-realtime-audio-ws 0.1.3 → 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/client/bundle.js +43 -8
- package/lib/client/index.js +43 -8
- 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 +65 -3
- 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/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/client/index.ts +56 -9
- package/src/diagnostics.ts +98 -0
- package/src/index.ts +100 -3
- package/src/injection.ts +132 -0
- package/src/types.ts +17 -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)
|
|
@@ -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,14 +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';
|
|
20
|
+
import { createRouteToken, routeAuthority, routeInjectionRow, tokenFromUrl, verdictFor, } from './injection.js';
|
|
15
21
|
import { createUpgradeAcceptor, rejectUpgrade } from './upgrade.js';
|
|
16
|
-
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';
|
|
17
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';
|
|
18
32
|
export { attachAudioSocket, toBytes } from './bridge.js';
|
|
19
33
|
export { createUpgradeAcceptor, rejectUpgrade } from './upgrade.js';
|
|
34
|
+
export { diagnosticsRoute } from './diagnostics.js';
|
|
20
35
|
/** Plugin name. */
|
|
21
36
|
export const name = 'realtime-audio-ws';
|
|
22
37
|
export const Config = Schema.object({
|
|
@@ -27,13 +42,37 @@ export const Config = Schema.object({
|
|
|
27
42
|
// boot with nobody asking, which is why it is false. This one opens a session because someone connected a
|
|
28
43
|
// microphone: a connection takes an explicit action, an authenticated one, and its absence is silence.
|
|
29
44
|
openSessionOnConnect: Schema.boolean().default(DEFAULT_OPEN_SESSION_ON_CONNECT),
|
|
45
|
+
diagnosticsPath: Schema.string().default(DEFAULT_DIAGNOSTICS_PATH),
|
|
30
46
|
});
|
|
31
47
|
export function apply(ctx, config) {
|
|
32
|
-
ctx.inject(['connection', 'webServer'], (injected) => {
|
|
48
|
+
ctx.inject(['connection', 'webServer', 'realtime'], (injected) => {
|
|
33
49
|
const services = injected;
|
|
34
50
|
const acceptor = createUpgradeAcceptor(config.maxFrameBytes);
|
|
35
51
|
const clients = new Set();
|
|
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]);
|
|
59
|
+
// The injection event is declared by `@deepseek-ai/dsh-host-webserver`, which this package deliberately
|
|
60
|
+
// does not import: importing it would augment `Context` with a second declaration of `webServer` and
|
|
61
|
+
// turn the structural dependency above into a compile error. The name is cast at this one boundary.
|
|
62
|
+
const onInjection = ctx.on;
|
|
36
63
|
ctx.effect(() => {
|
|
64
|
+
// The page is told where the route is and what to present. The web server gathers this table on every
|
|
65
|
+
// index render and every worker boot-payload request, so the row is built at emit time — the only
|
|
66
|
+
// moment the listening port is known for certain, since an index render follows the listen.
|
|
67
|
+
onInjection('webserver/index-inject', (table) => {
|
|
68
|
+
table.push(routeInjectionRow(config.path, routeAuthority(services.webServer.config?.host, services.webServer.listenedPort), token));
|
|
69
|
+
});
|
|
70
|
+
const unregisterDiagnostics = services.webServer.register(diagnosticsRoute({
|
|
71
|
+
path: config.diagnosticsPath,
|
|
72
|
+
token,
|
|
73
|
+
rejectionFor: (request) => services.connection.requestRejection(request),
|
|
74
|
+
journal,
|
|
75
|
+
}));
|
|
37
76
|
const unregister = services.webServer.registerUpgrade({
|
|
38
77
|
path: config.path,
|
|
39
78
|
handler: (req, socket, head) => {
|
|
@@ -42,18 +81,39 @@ export function apply(ctx, config) {
|
|
|
42
81
|
// microphone audio in and the agent's answers out. DSH's own transport asks the connection
|
|
43
82
|
// service in exactly this position, so this route asks the same question rather than inventing a
|
|
44
83
|
// second scheme that would drift from it.
|
|
45
|
-
|
|
84
|
+
//
|
|
85
|
+
// The token is the one addition, and it is not a second scheme. The desktop app's page is served
|
|
86
|
+
// from `dsh-app://app`, so its requests to loopback are cross-site and the harness's
|
|
87
|
+
// `SameSite=Strict` cookie cannot travel; without the token that page is refused forever, however
|
|
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 ?? '';
|
|
93
|
+
const rejection = verdictFor(services.connection.requestRejection(req), tokenFromUrl(req.url), token);
|
|
46
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) });
|
|
47
100
|
rejectUpgrade(socket, rejection);
|
|
48
101
|
return;
|
|
49
102
|
}
|
|
50
103
|
acceptor.handleUpgrade(req, socket, head, (client) => {
|
|
51
104
|
if (clients.size >= config.maxConnections) {
|
|
52
105
|
// 1013 = try again later. Closing the newcomer leaves the existing microphone live.
|
|
106
|
+
journal.record('socket.rejected', { verdict: '1013', reason: 'busy' });
|
|
53
107
|
client.close(1013, 'busy');
|
|
54
108
|
return;
|
|
55
109
|
}
|
|
56
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 });
|
|
57
117
|
// Ask for the session before the bridge goes in, so the first frames are written into a session
|
|
58
118
|
// that is being opened rather than dropped by the mic seam's no-session rule.
|
|
59
119
|
if (config.openSessionOnConnect)
|
|
@@ -64,6 +124,7 @@ export function apply(ctx, config) {
|
|
|
64
124
|
maxFrameBytes: config.maxFrameBytes,
|
|
65
125
|
onDetach: () => {
|
|
66
126
|
clients.delete(client);
|
|
127
|
+
journal.record('socket.closed', { clients: String(clients.size) });
|
|
67
128
|
// The last client leaving ends the session. This also covers the transport's own disposal,
|
|
68
129
|
// which terminates its clients — so a profile reload does not leave a session nobody holds.
|
|
69
130
|
if (config.openSessionOnConnect && clients.size === 0)
|
|
@@ -75,6 +136,7 @@ export function apply(ctx, config) {
|
|
|
75
136
|
});
|
|
76
137
|
return async () => {
|
|
77
138
|
unregister();
|
|
139
|
+
unregisterDiagnostics();
|
|
78
140
|
for (const client of clients)
|
|
79
141
|
client.terminate();
|
|
80
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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"}
|
|
@@ -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"}
|