@deepseek-ai/dsh-client-connection 0.2.1-alpha.2 → 0.2.1-alpha.4

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/index.js CHANGED
@@ -13,6 +13,11 @@ import { createScope } from "@deepseek-ai/dsh-scope";
13
13
  /** Route prefix owning every api request (`/api` and `/api/<anything>`). */
14
14
  const API_PATH = "/api";
15
15
  //#endregion
16
+ //#region lib/types/connect-protocol.js
17
+ /** API connection inputs shared by the Host and its clients. */
18
+ /** Launch-token exchange route for API clients. */
19
+ const CONNECT_PATH = "/connect";
20
+ //#endregion
16
21
  //#region lib/types/http-bridge.js
17
22
  /**
18
23
  * node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
@@ -29,9 +34,10 @@ const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024;
29
34
  * @param req - incoming node:http request.
30
35
  * @param res - node:http response the bridge writes and owns to completion.
31
36
  * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
37
+ * @param peer - Peer the carrier admitted the request as.
32
38
  * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
33
39
  */
34
- async function bridge(req, res, apiHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES) {
40
+ async function bridge(req, res, apiHandler, peer, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES) {
35
41
  const abort = new AbortController();
36
42
  res.on("close", () => {
37
43
  if (!res.writableEnded) abort.abort();
@@ -79,7 +85,7 @@ async function bridge(req, res, apiHandler, maxRequestBodyBytes = DEFAULT_MAX_RE
79
85
  signal: abort.signal,
80
86
  duplex: "half"
81
87
  });
82
- const response = await apiHandler.fetch(request);
88
+ const response = await apiHandler.fetch(request, peer);
83
89
  const requestUnread = bodyMode === "streaming" && !req.readableEnded;
84
90
  const responseHeaders = Object.fromEntries(response.headers.entries());
85
91
  res.writeHead(response.status, requestUnread ? {
@@ -347,12 +353,12 @@ function signature(secret, body) {
347
353
  }
348
354
  function encodeCookie(payload, secret) {
349
355
  const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), "utf8"));
350
- return `v1.${body}.${encodeBase64Url(signature(secret, body))}`;
356
+ return `v${payload.version}.${body}.${encodeBase64Url(signature(secret, body))}`;
351
357
  }
352
358
  function decodeCookie(value, secret) {
353
359
  const parts = value.split(".");
354
360
  const [version, body, encodedSignature] = parts;
355
- if (parts.length !== 3 || version !== "v1" || body === void 0 || encodedSignature === void 0) return;
361
+ if (parts.length !== 3 || version !== "v1" && version !== "v2" || body === void 0 || encodedSignature === void 0) return;
356
362
  const actualSignature = decodeBase64Url(encodedSignature);
357
363
  if (actualSignature === void 0) return void 0;
358
364
  const expectedSignature = signature(secret, body);
@@ -365,8 +371,22 @@ function decodeCookie(value, secret) {
365
371
  } catch {
366
372
  return;
367
373
  }
368
- if (!isRecord(decoded) || decoded.version !== COOKIE_PAYLOAD_VERSION || typeof decoded.authority !== "string" || !Number.isSafeInteger(decoded.issuedAt) || !Number.isSafeInteger(decoded.expiresAt)) return void 0;
369
- return decoded;
374
+ if (!isRecord(decoded) || decoded.version !== COOKIE_PAYLOAD_VERSION && decoded.version !== 2 || version !== `v${decoded.version}` || typeof decoded.authority !== "string" || typeof decoded.issuedAt !== "number" || !Number.isSafeInteger(decoded.issuedAt) || typeof decoded.expiresAt !== "number" || !Number.isSafeInteger(decoded.expiresAt)) return void 0;
375
+ const payload = {
376
+ authority: decoded.authority,
377
+ issuedAt: decoded.issuedAt,
378
+ expiresAt: decoded.expiresAt
379
+ };
380
+ if (decoded.version === COOKIE_PAYLOAD_VERSION) return {
381
+ version: COOKIE_PAYLOAD_VERSION,
382
+ ...payload
383
+ };
384
+ if (typeof decoded.id !== "string" || decoded.id.length === 0) return void 0;
385
+ return {
386
+ version: 2,
387
+ ...payload,
388
+ id: decoded.id
389
+ };
370
390
  }
371
391
  async function initializeSecret(credentials) {
372
392
  const generated = {
@@ -476,21 +496,77 @@ var BrowserAuth = class BrowserAuth {
476
496
  return false;
477
497
  }
478
498
  /**
499
+ * Exchange the launch token for an independent API operator; omitted interactions default to none.
500
+ * @param req - trusted GET request on /connect with at most one interactions declaration.
501
+ * @param res - response owned to completion, redirecting to the API mux on success.
502
+ * @param secure - whether the receiving listener serves TLS.
503
+ * @param publish - synchronously create the operator before exposing its signed credential.
504
+ */
505
+ authorizeConnect(req, res, secure, publish) {
506
+ const url = new URL(req.url ?? "/", "http://dsh.invalid");
507
+ const declarations = url.searchParams.getAll("interactions");
508
+ const interactions = declarations[0] ?? "none";
509
+ if (declarations.length > 1 || interactions !== "answer" && interactions !== "none") {
510
+ res.writeHead(400, {
511
+ "cache-control": "no-store",
512
+ "content-type": "text/plain; charset=utf-8"
513
+ });
514
+ res.end("interactions must be declared once as answer or none\n");
515
+ return;
516
+ }
517
+ const tokens = url.searchParams.getAll(TOKEN_QUERY);
518
+ const authority = requestAudience(req.headers, secure);
519
+ if (req.method !== "GET" || url.pathname !== "/connect" || tokens.length !== 1 || authority === void 0 || !tokenMatches(tokens.join(""), this.launchToken)) {
520
+ this.writeUnauthorized(req, res);
521
+ return;
522
+ }
523
+ const issuedAt = Date.now();
524
+ const expiresAt = issuedAt + this.maxAgeMilliseconds;
525
+ const value = encodeCookie({
526
+ version: 2,
527
+ authority,
528
+ id: publish(interactions),
529
+ issuedAt,
530
+ expiresAt
531
+ }, this.secret);
532
+ res.writeHead(302, {
533
+ "cache-control": "no-store",
534
+ "location": "./api/remote.mux",
535
+ "referrer-policy": "no-referrer",
536
+ "set-cookie": sessionCookie(cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1e3), secure)
537
+ });
538
+ res.end();
539
+ }
540
+ /**
479
541
  * Verify the authority-bound browser cookie on a Host request.
480
542
  * @param request - request headers carrying Host and Cookie.
481
543
  * @param secure - whether the receiving listener uses TLS, including its default port.
482
544
  * @returns true only for an unexpired cookie signed by this activation's loaded secret.
483
545
  */
484
546
  isAuthenticated(request, secure = false) {
547
+ return this.authenticate(request, secure)?.version === COOKIE_PAYLOAD_VERSION;
548
+ }
549
+ /**
550
+ * Verify an API credential without creating or reviving its operator.
551
+ * @param request - request headers carrying Host and Cookie.
552
+ * @param secure - whether the receiving listener uses TLS.
553
+ * @returns the signed operator id, or undefined for invalid, expired, or browser cookies.
554
+ */
555
+ authenticateApi(request, secure = false) {
556
+ const payload = this.authenticate(request, secure);
557
+ return payload?.version === 2 ? payload.id : void 0;
558
+ }
559
+ authenticate(request, secure) {
485
560
  const authority = requestAudience(request.headers, secure);
486
561
  const rawCookie = header(request.headers, "cookie");
487
- if (authority === void 0 || rawCookie === void 0) return false;
562
+ if (authority === void 0 || rawCookie === void 0) return void 0;
488
563
  const value = cookieValue(rawCookie, cookieName(authority));
489
- if (value === void 0) return false;
564
+ if (value === void 0) return void 0;
490
565
  const payload = decodeCookie(value, this.secret);
491
- if (payload === void 0 || payload.authority !== authority) return false;
566
+ if (payload === void 0 || payload.authority !== authority) return void 0;
492
567
  const now = Date.now();
493
- return payload.issuedAt <= now && payload.expiresAt > now && payload.expiresAt > payload.issuedAt && payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds;
568
+ if (payload.issuedAt > now || payload.expiresAt <= now || payload.expiresAt <= payload.issuedAt || payload.expiresAt - payload.issuedAt > this.maxAgeMilliseconds) return void 0;
569
+ return payload;
494
570
  }
495
571
  writeUnauthorized(req, res) {
496
572
  res.writeHead(401, {
@@ -567,26 +643,39 @@ const serverResponseSchema = z$1.object({
567
643
  /** Either Connection RPC envelope direction. */
568
644
  const rpcMessageSchema = z$1.discriminatedUnion("type", [clientRequestSchema, serverResponseSchema]);
569
645
  //#endregion
570
- //#region lib/types/operator-peer.js
646
+ //#region lib/types/peer.js
571
647
  /**
572
- * The operator Peer: the one party this Host answers to. Connection owns it
573
- * for its own lifetime, admits every request as it, and hands it to each
574
- * Remote call as `invocation.peer`.
575
- * @module @deepseek-ai/dsh-client-connection/src/operator-peer
648
+ * The Peer: one party admitted to this Host. Its creator fixes the principal
649
+ * it speaks for and owns its lifetime; Gateway hands it to each Remote call as
650
+ * `invocation.peer`.
651
+ * @module @deepseek-ai/dsh-client-connection/src/peer
576
652
  */
577
653
  /**
578
- * The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
654
+ * One Peer's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
579
655
  * returns it and events dispatched with `scopeTarget(subject, peer)` reach
580
656
  * listeners registered through `peer.ctx` and nobody else.
581
657
  */
582
- var OperatorPeer = class {
658
+ var Peer = class {
583
659
  id = randomUUID();
584
660
  ctx;
661
+ principal;
662
+ interactions;
663
+ signal;
585
664
  scope;
586
- /** @param owner - Connection plugin context the scope fiber hangs under. */
587
- constructor(owner) {
665
+ /**
666
+ * @param owner - Context the scope fiber hangs under; disposing it disposes the Peer.
667
+ * @param options - the principal fixed for this Peer's lifetime.
668
+ */
669
+ constructor(owner, options) {
670
+ this.principal = options.principal;
671
+ this.interactions = options.interactions ?? "answer";
588
672
  this.scope = createScope(owner, this);
589
673
  this.ctx = this.scope.ctx;
674
+ const lifetime = new AbortController();
675
+ this.signal = lifetime.signal;
676
+ this.ctx.effect(() => () => {
677
+ lifetime.abort(/* @__PURE__ */ new Error(`Peer ${this.id} was disposed`));
678
+ }, "client-connection: Peer lifetime");
590
679
  }
591
680
  /** Tear down every connection-lifetime registration; racing calls share one completion. */
592
681
  dispose() {
@@ -603,8 +692,11 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
603
692
  var HostConnectionService = class extends Service {
604
693
  trustedHosts;
605
694
  browserAuth;
606
- /** The operator Peer every admitted request speaks for. */
607
- operator;
695
+ /** The Peer every authenticated browser connection shares. */
696
+ browserPeer;
697
+ peers = /* @__PURE__ */ new Map();
698
+ owner;
699
+ disposed = false;
608
700
  interceptors = /* @__PURE__ */ new Map();
609
701
  fetchRoutes = /* @__PURE__ */ new Map();
610
702
  allowsRemoteAuthorities;
@@ -618,8 +710,14 @@ var HostConnectionService = class extends Service {
618
710
  super(ctx, "connection");
619
711
  this.trustedHosts = trustedHosts;
620
712
  this.browserAuth = browserAuth;
621
- this.operator = new OperatorPeer(ctx);
622
- ctx.effect(() => () => this.operator.dispose(), "client-connection: operator Peer");
713
+ this.owner = ctx;
714
+ this.browserPeer = new Peer(ctx, { principal: { kind: "owner" } });
715
+ ctx.effect(() => async () => {
716
+ this.disposed = true;
717
+ const peers = [this.browserPeer, ...Array.from(this.peers.values(), (record) => record.peer)];
718
+ this.peers.clear();
719
+ await Promise.all(peers.map((peer) => peer.dispose()));
720
+ }, "client-connection: Peer scopes");
623
721
  this.allowsRemoteAuthorities = trustedHosts.some(isRemoteAuthority);
624
722
  }
625
723
  /** Generic channel registry scoped to the Context reading this service. */
@@ -635,16 +733,74 @@ var HostConnectionService = class extends Service {
635
733
  const owner = this.ctx;
636
734
  return { register: (route) => this.registerFetchRoute(owner, route) };
637
735
  }
638
- /** Apply the Host/Origin fence, then browser authentication. */
736
+ /** Apply the Host/Origin fence and require an authenticated live Peer. */
639
737
  requestRejection(request) {
640
- const carrier = this.ctx.get("webServer");
641
- if (!isTrustedApiRequest(request, this.trustedHosts, carrier?.host, carrier?.protocol ?? "http:")) return 403;
642
- return this.browserAuth.isAuthenticated(request, carrier?.protocol === "https:") ? void 0 : 401;
738
+ const admission = this.admit(request);
739
+ return "rejection" in admission ? admission.rejection : void 0;
643
740
  }
644
- /** A request that passes the fence and authentication speaks for the operator. */
741
+ /** Browser requests share an owner Peer; API HTTP does not depend on an attached socket. */
645
742
  admit(request) {
646
- const rejection = this.requestRejection(request);
647
- return rejection === void 0 ? { peer: this.operator } : { rejection };
743
+ if (!this.isTrusted(request)) return { rejection: 403 };
744
+ if (this.disposed) return { rejection: 401 };
745
+ const secure = this.owner.get("webServer")?.protocol === "https:";
746
+ if (this.browserAuth.isAuthenticated(request, secure)) return { peer: this.browserPeer };
747
+ const id = this.browserAuth.authenticateApi(request, secure);
748
+ const record = id === void 0 ? void 0 : this.peers.get(id);
749
+ return record === void 0 ? { rejection: 401 } : { peer: record.peer };
750
+ }
751
+ /** Create one in-process Peer owned by the Context that read this service. */
752
+ createPeer(options) {
753
+ return new Peer(this.ctx, options);
754
+ }
755
+ /** Claim API socket ownership synchronously; callers release on termination or failed upgrade. */
756
+ admitConnection(request) {
757
+ const admission = this.admit(request);
758
+ if ("rejection" in admission) return admission;
759
+ const record = this.peers.get(admission.peer.id);
760
+ if (record === void 0) return {
761
+ ...admission,
762
+ release: () => Promise.resolve()
763
+ };
764
+ if (record.ownsSocket) return { rejection: 409 };
765
+ record.ownsSocket = true;
766
+ let released;
767
+ return {
768
+ peer: record.peer,
769
+ release: () => {
770
+ if (released !== void 0) return released;
771
+ this.peers.delete(record.peer.id);
772
+ released = record.peer.dispose();
773
+ return released;
774
+ }
775
+ };
776
+ }
777
+ isTrusted(request) {
778
+ const carrier = this.owner.get("webServer");
779
+ return isTrustedApiRequest(request, this.trustedHosts, carrier?.host, carrier?.protocol ?? "http:");
780
+ }
781
+ publishPeer(interactions) {
782
+ const peer = new Peer(this.owner, {
783
+ principal: { kind: "owner" },
784
+ interactions
785
+ });
786
+ this.peers.set(peer.id, {
787
+ peer,
788
+ ownsSocket: false
789
+ });
790
+ return peer.id;
791
+ }
792
+ /**
793
+ * Authenticate and publish a credential before sending the connect redirect.
794
+ * @param request - launch-token request on /connect.
795
+ * @param response - response owned to completion.
796
+ */
797
+ authorizeConnect(request, response) {
798
+ if (!this.isTrusted(request) || this.disposed) {
799
+ response.writeHead(this.disposed ? 401 : 403);
800
+ response.end(this.disposed ? "unauthorized" : "forbidden");
801
+ return;
802
+ }
803
+ this.browserAuth.authorizeConnect(request, response, this.owner.get("webServer")?.protocol === "https:", (interactions) => this.publishPeer(interactions));
648
804
  }
649
805
  /**
650
806
  * Authenticate an index request; native TLS listeners mint Secure cookies.
@@ -667,14 +823,17 @@ var HostConnectionService = class extends Service {
667
823
  const route = this.fetchRoutes.get(url.pathname);
668
824
  return route?.methods.has(method) === true ? route.requestBody : "buffered";
669
825
  },
670
- fetch: (request) => {
826
+ fetch: (request, peer) => {
671
827
  const pathname = new URL(request.url).pathname;
672
828
  const route = this.fetchRoutes.get(pathname);
673
- if (route?.methods.has(request.method) === true) return route.fetch(request);
829
+ if (route?.methods.has(request.method) === true) {
830
+ if (peer.principal.kind !== "owner") return Promise.resolve(new Response("forbidden", { status: 403 }));
831
+ return route.fetch(request);
832
+ }
674
833
  const endpoint = endpointFromPath(channel, pathname);
675
834
  const interceptor = this.interceptors.get(channel);
676
835
  if (endpoint === void 0 || interceptor === void 0 || !interceptor.matches(endpoint)) return Promise.resolve(new Response("not found", { status: 404 }));
677
- return interceptor.fetchHandler.fetch(request);
836
+ return interceptor.fetchHandler.fetch(request, peer);
678
837
  }
679
838
  };
680
839
  }
@@ -695,7 +854,7 @@ var HostConnectionService = class extends Service {
695
854
  }
696
855
  register(owner, channel, handler) {
697
856
  assertChannel(channel);
698
- const fetchHandler = rpcFetchHandler(channel, handler, this.operator);
857
+ const fetchHandler = rpcFetchHandler(channel, handler);
699
858
  const route = {
700
859
  kind: "prefix",
701
860
  path: channel,
@@ -706,7 +865,7 @@ var HostConnectionService = class extends Service {
706
865
  res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
707
866
  return;
708
867
  }
709
- await bridge(req, res, fetchHandler);
868
+ await bridge(req, res, fetchHandler, admission.peer);
710
869
  }
711
870
  };
712
871
  return owner.effect(() => owner.webServer.register(route), `client-connection: ${channel} rpc channel`);
@@ -715,7 +874,7 @@ var HostConnectionService = class extends Service {
715
874
  if (channel !== "/api") throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`);
716
875
  const interceptor = {
717
876
  matches,
718
- fetchHandler: rpcFetchHandler(channel, handler, this.operator)
877
+ fetchHandler: rpcFetchHandler(channel, handler)
719
878
  };
720
879
  return owner.effect(() => {
721
880
  if (this.interceptors.has(channel)) throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`);
@@ -726,10 +885,10 @@ var HostConnectionService = class extends Service {
726
885
  }, `client-connection: ${channel} rpc interceptor`);
727
886
  }
728
887
  };
729
- function rpcFetchHandler(channel, handler, peer) {
888
+ function rpcFetchHandler(channel, handler) {
730
889
  return {
731
890
  requestBodyMode: () => "buffered",
732
- async fetch(request) {
891
+ async fetch(request, peer) {
733
892
  const endpoint = endpointFromPath(channel, new URL(request.url).pathname);
734
893
  if (request.method !== "POST" || endpoint === void 0) return new Response("not found", { status: 404 });
735
894
  if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return new Response("content type must be application/json", { status: 415 });
@@ -893,14 +1052,21 @@ async function apply(ctx, config) {
893
1052
  res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
894
1053
  return;
895
1054
  }
896
- await webCtx.waterfall("connection/request", req, res, () => bridge(req, res, fetchHandler, maxRequestBodyBytes));
1055
+ await webCtx.waterfall("connection/request", req, res, () => bridge(req, res, fetchHandler, admission.peer, maxRequestBodyBytes));
897
1056
  }
898
1057
  };
899
1058
  webCtx.effect(() => webCtx.webServer.register(route), "client-connection: /api route");
1059
+ webCtx.effect(() => webCtx.webServer.register({
1060
+ kind: "exact",
1061
+ path: CONNECT_PATH,
1062
+ handler: (req, res) => {
1063
+ connection.authorizeConnect(req, res);
1064
+ }
1065
+ }), "client-connection: /connect route");
900
1066
  });
901
1067
  ctx.inject(["attachments"], (attachmentCtx) => {
902
1068
  assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes);
903
1069
  });
904
1070
  }
905
1071
  //#endregion
906
- export { API_PATH, Config, HostConnectionService, OperatorPeer, RpcId, apply, clientRequestSchema, inject, name, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, transportError };
1072
+ export { API_PATH, CONNECT_PATH, Config, HostConnectionService, Peer, RpcId, apply, clientRequestSchema, inject, name, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, transportError };
@@ -1,4 +1,6 @@
1
1
  /** Browser-session authentication for the Host Connection carrier. */
2
+ import type { PeerId } from '@deepseek-ai/dsh-typert-protocol';
3
+ import { type ConnectionInteractions } from './connect-protocol.ts';
2
4
  import type { CredentialProvider } from '@deepseek-ai/dsh-credentials';
3
5
  import type { ConnectionIndexRequest, ConnectionIndexResponse, ConnectionTrustRequest } from './rpc.ts';
4
6
  /**
@@ -40,6 +42,14 @@ export declare class BrowserAuth {
40
42
  * @returns true only when the caller may serve index.html.
41
43
  */
42
44
  authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse, secure?: boolean): boolean;
45
+ /**
46
+ * Exchange the launch token for an independent API operator; omitted interactions default to none.
47
+ * @param req - trusted GET request on /connect with at most one interactions declaration.
48
+ * @param res - response owned to completion, redirecting to the API mux on success.
49
+ * @param secure - whether the receiving listener serves TLS.
50
+ * @param publish - synchronously create the operator before exposing its signed credential.
51
+ */
52
+ authorizeConnect(req: ConnectionIndexRequest, res: ConnectionIndexResponse, secure: boolean, publish: (interactions: ConnectionInteractions) => PeerId): void;
43
53
  /**
44
54
  * Verify the authority-bound browser cookie on a Host request.
45
55
  * @param request - request headers carrying Host and Cookie.
@@ -47,6 +57,14 @@ export declare class BrowserAuth {
47
57
  * @returns true only for an unexpired cookie signed by this activation's loaded secret.
48
58
  */
49
59
  isAuthenticated(request: ConnectionTrustRequest, secure?: boolean): boolean;
60
+ /**
61
+ * Verify an API credential without creating or reviving its operator.
62
+ * @param request - request headers carrying Host and Cookie.
63
+ * @param secure - whether the receiving listener uses TLS.
64
+ * @returns the signed operator id, or undefined for invalid, expired, or browser cookies.
65
+ */
66
+ authenticateApi(request: ConnectionTrustRequest, secure?: boolean): PeerId | undefined;
67
+ private authenticate;
50
68
  private writeUnauthorized;
51
69
  }
52
70
  //# sourceMappingURL=browser-auth.d.ts.map
@@ -3,6 +3,10 @@ import type { Context } from '@deepseek-ai/cordis';
3
3
  import { type ConnectionRecoveryConfig, type ConnectionGeneration, type ConnectionGenerationSource, type ConnectionSinks, type ConnectionState } from './connection.ts';
4
4
  import { type RpcFetch, type RpcStreamOpen } from './rpc.ts';
5
5
  import type { ClientConnectionRpc } from '../rpc.ts';
6
+ import type { ConnectionInteractions } from '../connect-protocol.ts';
7
+ import { type ConnectionWebSocket, type ConnectionWebSocketFactory } from './socket.ts';
8
+ export type { ConnectionInteractions } from '../connect-protocol.ts';
9
+ export type { ConnectionWebSocket, ConnectionWebSocketFactory } from './socket.ts';
6
10
  declare module '@deepseek-ai/cordis' {
7
11
  interface Events {
8
12
  /**
@@ -60,13 +64,15 @@ export interface ClientTransportHooks {
60
64
  /**
61
65
  * The transport owner declares the page owns the Host outright: the Host
62
66
  * runs inside a worker this page spawned, so no other party can reach it and
63
- * the loopback stand-in for "the operator's own machine" is vacuous.
67
+ * the loopback stand-in for "the owner's own machine" is vacuous.
64
68
  * `ctx.connection.isLoopback` then reports the privileged surface reachable
65
69
  * regardless of the page authority. Only a shell that assembles its own
66
70
  * transport can set this; served pages never carry the global at all.
67
71
  */
68
72
  ownsHost?: boolean;
69
- /** HTTP origin of a shell-owned Host when its WebSocket uses a different page origin. */
73
+ /** Authenticated WebSocket factory; omission uses the browser API and its cookie jar. */
74
+ createWebSocket?: ConnectionWebSocketFactory;
75
+ /** Host base URL for desktop or Node sockets; omission uses the document base. */
70
76
  streamBaseUrl?: string;
71
77
  }
72
78
  /** Browser location fields used to classify loopback authority. */
@@ -75,6 +81,8 @@ export interface ConnectionLocation {
75
81
  }
76
82
  /** Instance-local inputs for installing a Connection service. */
77
83
  export interface ConnectionInstallOptions {
84
+ /** Forwarded-event participation; omission selects answer for a page and none otherwise. */
85
+ readonly interactions?: ConnectionInteractions;
78
86
  /** Explicit physical carrier; omit for the browser HTTP + WebSocket carrier. */
79
87
  readonly transport?: ClientTransportHooks;
80
88
  /** Reconnect timing overrides; omitted fields use controller defaults. */
@@ -87,6 +95,8 @@ export interface ConnectionInstallOptions {
87
95
  * and reset callbacks; Connection stays independent of downstream domain state.
88
96
  */
89
97
  export interface ConnectionHandle {
98
+ /** Forwarded-event participation selected at installation. */
99
+ readonly interactions: ConnectionInteractions;
90
100
  /**
91
101
  * Whether the privileged surface is reachable: the page authority is
92
102
  * loopback, the transport declares the page owns the Host
@@ -99,6 +109,12 @@ export interface ConnectionHandle {
99
109
  readonly state: ConnectionStateSource;
100
110
  /** Generic logical RPC channels over the same Connection transport. */
101
111
  readonly rpc: ClientConnectionRpc;
112
+ /**
113
+ * Create a socket on the selected carrier; the channel owns framing and closing.
114
+ * @param path - absolute logical channel path, resolved beneath the Host mount.
115
+ * @returns a newly connecting socket using the installed authentication factory.
116
+ */
117
+ openWebSocket(path: string): ConnectionWebSocket;
102
118
  /** Reset retry progression and replace the current attempt immediately. */
103
119
  reconnect(): void;
104
120
  /**
@@ -125,7 +141,7 @@ export interface ConnectionLoop {
125
141
  /**
126
142
  * Install one Context-owned Connection service from explicit composition inputs.
127
143
  * @param ctx - client Cordis context.
128
- * @param options - physical carrier, reconnect timing, and page location.
144
+ * @param options - interaction participation, physical carrier, reconnect timing, and page location.
129
145
  */
130
146
  export declare function installConnection(ctx: Context, options?: ConnectionInstallOptions): void;
131
147
  /**
@@ -0,0 +1,50 @@
1
+ /** Physical socket selection for browser, desktop, and authenticated Node connections. */
2
+ /** Socket events consumed by the Gateway on browser and Node carriers. */
3
+ export interface ConnectionWebSocketEventMap {
4
+ readonly open: object;
5
+ readonly error: object;
6
+ readonly close: object;
7
+ readonly message: {
8
+ readonly data: unknown;
9
+ };
10
+ }
11
+ /** Text WebSocket operations shared by the browser API and Node carriers. */
12
+ export interface ConnectionWebSocket {
13
+ /** Standard WebSocket state, from CONNECTING (0) through CLOSED (3). */
14
+ readonly readyState: number;
15
+ /** @param data - complete text frame to send. */
16
+ send(data: string): void;
17
+ /**
18
+ * @param code - protocol close code.
19
+ * @param reason - peer-facing close explanation.
20
+ */
21
+ close(code?: number, reason?: string): void;
22
+ /**
23
+ * @param event - physical socket event.
24
+ * @param listener - callback for that event.
25
+ * @param options - whether the callback is removed after its first delivery.
26
+ */
27
+ addEventListener<Event extends keyof ConnectionWebSocketEventMap>(event: Event, listener: (event: ConnectionWebSocketEventMap[Event]) => void, options?: {
28
+ readonly once?: boolean;
29
+ }): void;
30
+ /**
31
+ * @param event - physical socket event.
32
+ * @param listener - previously registered callback.
33
+ */
34
+ removeEventListener<Event extends keyof ConnectionWebSocketEventMap>(event: Event, listener: (event: ConnectionWebSocketEventMap[Event]) => void): void;
35
+ }
36
+ /**
37
+ * Create an authenticated socket; its consumer owns framing and closing.
38
+ * @param url - absolute WebSocket URL beneath the selected Host mount.
39
+ * @returns a newly connecting socket, before it emits open or failure.
40
+ */
41
+ export type ConnectionWebSocketFactory = (url: string) => ConnectionWebSocket;
42
+ /**
43
+ * Resolve a logical channel against the explicit Host or the page's document base.
44
+ * @param path - absolute logical channel path.
45
+ * @param factory - authenticated socket factory; omission uses the browser API.
46
+ * @param baseUrl - Host base URL, required outside a document.
47
+ * @returns a socket owned by the calling channel.
48
+ */
49
+ export declare function openConnectionWebSocket(path: string, factory?: ConnectionWebSocketFactory, baseUrl?: string): ConnectionWebSocket;
50
+ //# sourceMappingURL=socket.d.ts.map
@@ -0,0 +1,6 @@
1
+ /** API connection inputs shared by the Host and its clients. */
2
+ /** Whether a Peer participates in forwarded human interactions. */
3
+ export type ConnectionInteractions = 'answer' | 'none';
4
+ /** Launch-token exchange route for API clients. */
5
+ export declare const CONNECT_PATH = "/connect";
6
+ //# sourceMappingURL=connect-protocol.d.ts.map
@@ -3,6 +3,7 @@
3
3
  * web carrier; the fetch-shaped handler itself is transport-agnostic).
4
4
  */
5
5
  import type { IncomingMessage } from 'node:http';
6
+ import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol';
6
7
  import type { ConnectionFetchHandler } from './rpc.ts';
7
8
  /** Default carrier cap for all HTTP RPC bodies: sized for the default
8
9
  * aggregate image limit (200 MiB) after base64 expansion plus envelope
@@ -25,8 +26,9 @@ interface BridgeServerResponse {
25
26
  * @param req - incoming node:http request.
26
27
  * @param res - node:http response the bridge writes and owns to completion.
27
28
  * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
29
+ * @param peer - Peer the carrier admitted the request as.
28
30
  * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
29
31
  */
30
- export declare function bridge(req: IncomingMessage, res: BridgeServerResponse, apiHandler: ConnectionFetchHandler, maxRequestBodyBytes?: number): Promise<void>;
32
+ export declare function bridge(req: IncomingMessage, res: BridgeServerResponse, apiHandler: ConnectionFetchHandler, peer: PeerScope, maxRequestBodyBytes?: number): Promise<void>;
31
33
  export {};
32
34
  //# sourceMappingURL=http-bridge.d.ts.map
@@ -3,10 +3,12 @@ import type { Context } from '@deepseek-ai/cordis';
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http';
4
4
  import z from '@deepseek-ai/schemastery';
5
5
  import { type ConnectionRecoveryConfig } from './recovery-config.ts';
6
- export type { PeerAdmission, ConnectionFetchMethod, ConnectionFetchHandler, ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, ConnectionRpcAttachment, ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRpcHandlerResult, ConnectionRequestRejection, ConnectionRpcResult, ConnectionRequestBodyMode, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, HostConnectionFetch, HostConnectionRpc, RpcMessage, ServerResponse, } from './rpc.ts';
7
- export type { PeerId, PeerScope, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol';
6
+ export type { PeerAdmission, ConnectionAdmission, ConnectionFetchMethod, ConnectionFetchHandler, ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, ConnectionRpcAttachment, ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRpcHandlerResult, ConnectionRequestRejection, ConnectionRpcResult, ConnectionRequestBodyMode, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, HostConnectionFetch, HostConnectionRpc, RpcMessage, ServerResponse, } from './rpc.ts';
7
+ export type { PeerId, PeerScope, Principal, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol';
8
8
  export { RpcId, transportError } from './rpc.ts';
9
- export { OperatorPeer } from './operator-peer.ts';
9
+ export { Peer } from './peer.ts';
10
+ export type { PeerOptions } from './peer.ts';
11
+ export { CONNECT_PATH, type ConnectionInteractions } from './connect-protocol.ts';
10
12
  export { clientRequestSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, } from './rpc-schema.ts';
11
13
  export { HostConnectionService } from './rpc-host.ts';
12
14
  export { API_PATH } from './api-path.ts';
@@ -38,7 +40,7 @@ export interface ConnectionConfig {
38
40
  * an entry here. A non-canonical or non-bare authority fails plugin load.
39
41
  */
40
42
  trustedHosts?: string[];
41
- /** Absolute browser-session lifetime in days. Default: 30. */
43
+ /** Absolute browser and API cookie lifetime in days. Default: 30. */
42
44
  cookieMaxAgeDays?: number;
43
45
  /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
44
46
  maxRequestBodyBytes?: number;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The Peer: one party admitted to this Host. Its creator fixes the principal
3
+ * it speaks for and owns its lifetime; Gateway hands it to each Remote call as
4
+ * `invocation.peer`.
5
+ * @module @deepseek-ai/dsh-client-connection/src/peer
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis';
8
+ import type { PeerId, PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol';
9
+ /** Construction options for one {@link Peer}. */
10
+ export interface PeerOptions {
11
+ /** Caller identity every call through this Peer speaks for. */
12
+ readonly principal: Principal;
13
+ /** Omission permits an event stream; non-owner Peers still cannot answer waterfalls. */
14
+ readonly interactions?: 'answer' | 'none';
15
+ }
16
+ /**
17
+ * One Peer's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
18
+ * returns it and events dispatched with `scopeTarget(subject, peer)` reach
19
+ * listeners registered through `peer.ctx` and nobody else.
20
+ */
21
+ export declare class Peer implements PeerScope {
22
+ readonly id: PeerId;
23
+ readonly ctx: Context;
24
+ readonly principal: Principal;
25
+ readonly interactions: 'answer' | 'none';
26
+ readonly signal: AbortSignal;
27
+ private readonly scope;
28
+ /**
29
+ * @param owner - Context the scope fiber hangs under; disposing it disposes the Peer.
30
+ * @param options - the principal fixed for this Peer's lifetime.
31
+ */
32
+ constructor(owner: Context, options: PeerOptions);
33
+ /** Tear down every connection-lifetime registration; racing calls share one completion. */
34
+ dispose(): Promise<void>;
35
+ }
36
+ //# sourceMappingURL=peer.d.ts.map