@deepseek-ai/dsh-client-connection 0.2.1-alpha.2 → 0.2.1-alpha.3
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/README.i18n.yaml +8 -8
- package/README.md +9 -4
- package/README.zh.md +9 -4
- package/lib/{client.js → client.browser.js} +22 -2
- package/lib/client.node.js +624 -0
- package/lib/index.js +206 -40
- package/lib/types/browser-auth.d.ts +18 -0
- package/lib/types/client/index.d.ts +19 -3
- package/lib/types/client/socket.d.ts +50 -0
- package/lib/types/connect-protocol.d.ts +6 -0
- package/lib/types/http-bridge.d.ts +3 -1
- package/lib/types/index.d.ts +6 -4
- package/lib/types/peer.d.ts +36 -0
- package/lib/types/rpc-host.d.ts +25 -6
- package/lib/types/rpc.d.ts +40 -11
- package/locale/en.json +5 -0
- package/locale/zh.json +6 -0
- package/package.json +22 -17
- package/lib/types/operator-peer.d.ts +0 -23
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 `
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
566
|
+
if (payload === void 0 || payload.authority !== authority) return void 0;
|
|
492
567
|
const now = Date.now();
|
|
493
|
-
|
|
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/
|
|
646
|
+
//#region lib/types/peer.js
|
|
571
647
|
/**
|
|
572
|
-
* The
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* @module @deepseek-ai/dsh-client-connection/src/
|
|
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
|
-
*
|
|
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
|
|
658
|
+
var Peer = class {
|
|
583
659
|
id = randomUUID();
|
|
584
660
|
ctx;
|
|
661
|
+
principal;
|
|
662
|
+
interactions;
|
|
663
|
+
signal;
|
|
585
664
|
scope;
|
|
586
|
-
/**
|
|
587
|
-
|
|
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
|
|
607
|
-
|
|
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.
|
|
622
|
-
|
|
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
|
|
736
|
+
/** Apply the Host/Origin fence and require an authenticated live Peer. */
|
|
639
737
|
requestRejection(request) {
|
|
640
|
-
const
|
|
641
|
-
|
|
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
|
-
/**
|
|
741
|
+
/** Browser requests share an owner Peer; API HTTP does not depend on an attached socket. */
|
|
645
742
|
admit(request) {
|
|
646
|
-
|
|
647
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
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
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|