@deepseek-ai/dsh-client-connection 0.1.2-alpha.5 → 0.1.3-alpha.2

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
@@ -1,4 +1,5 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
+ import { Readable } from "node:stream";
2
3
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
4
  import { credentialKey } from "@deepseek-ai/dsh-credentials";
4
5
  import { Service } from "@deepseek-ai/cordis";
@@ -24,48 +25,69 @@ const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024;
24
25
  /**
25
26
  * Bridge one node:http request to the fetch-shaped handler (client close
26
27
  * aborts; response bodies stream out chunk by chunk).
27
- * @param req - incoming node:http request (fully read before dispatch).
28
+ * @param req - incoming node:http request.
28
29
  * @param res - node:http response the bridge writes and owns to completion.
29
30
  * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
30
- * @param maxRequestBodyBytes - maximum body bytes buffered before dispatch.
31
+ * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
31
32
  */
32
33
  async function bridge(req, res, apiHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES) {
33
34
  const abort = new AbortController();
34
35
  res.on("close", () => {
35
36
  if (!res.writableEnded) abort.abort();
36
37
  });
37
- const declaredLength = req.headers["content-length"];
38
- if (declaredLength !== void 0 && Number(declaredLength) > maxRequestBodyBytes) {
39
- res.writeHead(413, { connection: "close" });
40
- res.end();
41
- req.destroy();
42
- return;
43
- }
44
- const chunks = [];
45
- let received = 0;
46
- for await (const chunk of req) {
47
- const buffer = chunk;
48
- received += buffer.byteLength;
49
- if (received > maxRequestBodyBytes) {
38
+ /* v8 ignore next 2 -- node:http always sets url/method on server requests. */
39
+ const url = new URL(req.url ?? "/", "http://dsh.internal");
40
+ const method = req.method ?? "GET";
41
+ const headers = Object.fromEntries(Object.entries(req.headers).filter(([, value]) => typeof value === "string"));
42
+ const bodyMode = apiHandler.requestBodyMode({
43
+ method,
44
+ url
45
+ });
46
+ let request;
47
+ if (bodyMode === "buffered") {
48
+ const declaredLength = req.headers["content-length"];
49
+ if (declaredLength !== void 0 && Number(declaredLength) > maxRequestBodyBytes) {
50
50
  res.writeHead(413, { connection: "close" });
51
51
  res.end();
52
52
  req.destroy();
53
53
  return;
54
54
  }
55
- chunks.push(buffer);
56
- }
57
- /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
58
- requests; the fields are only optional on the client-side IncomingMessage type */
59
- const request = new Request(new URL(req.url ?? "/", "http://dsh.internal"), {
60
- method: req.method ?? "GET",
61
- headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === "string")),
62
- ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
63
- signal: abort.signal
55
+ const chunks = [];
56
+ let received = 0;
57
+ for await (const chunk of req) {
58
+ const buffer = chunk;
59
+ received += buffer.byteLength;
60
+ if (received > maxRequestBodyBytes) {
61
+ res.writeHead(413, { connection: "close" });
62
+ res.end();
63
+ req.destroy();
64
+ return;
65
+ }
66
+ chunks.push(buffer);
67
+ }
68
+ request = new Request(url, {
69
+ method,
70
+ headers,
71
+ ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
72
+ signal: abort.signal
73
+ });
74
+ } else request = new Request(url, {
75
+ method,
76
+ headers,
77
+ body: Readable.toWeb(req),
78
+ signal: abort.signal,
79
+ duplex: "half"
64
80
  });
65
81
  const response = await apiHandler.fetch(request);
66
- res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
82
+ const requestUnread = bodyMode === "streaming" && !req.readableEnded;
83
+ const responseHeaders = Object.fromEntries(response.headers.entries());
84
+ res.writeHead(response.status, requestUnread ? {
85
+ ...responseHeaders,
86
+ connection: "close"
87
+ } : responseHeaders);
67
88
  if (response.body === null) {
68
89
  res.end();
90
+ if (requestUnread) req.destroy();
69
91
  return;
70
92
  }
71
93
  for await (const chunk of response.body) if (!res.write(chunk)) await new Promise((resolve) => {
@@ -78,6 +100,7 @@ async function bridge(req, res, apiHandler, maxRequestBodyBytes = DEFAULT_MAX_RE
78
100
  res.once("close", done);
79
101
  });
80
102
  res.end();
103
+ if (requestUnread) req.destroy();
81
104
  }
82
105
  //#endregion
83
106
  //#region lib/types/loopback-hostname.js
@@ -545,20 +568,27 @@ var HostConnectionService = class extends Service {
545
568
  * @returns Fetch handler that selects one owner or returns 404.
546
569
  */
547
570
  createSharedFetchHandler(channel) {
548
- return { fetch: (request) => {
549
- const pathname = new URL(request.url).pathname;
550
- const route = this.fetchRoutes.get(pathname);
551
- if (route?.methods.has(request.method) === true) return route.fetch(request);
552
- const endpoint = endpointFromPath(channel, pathname);
553
- const interceptor = this.interceptors.get(channel);
554
- if (endpoint === void 0 || interceptor === void 0 || !interceptor.matches(endpoint)) return Promise.resolve(new Response("not found", { status: 404 }));
555
- return interceptor.fetchHandler.fetch(request);
556
- } };
571
+ return {
572
+ requestBodyMode: ({ method, url }) => {
573
+ const route = this.fetchRoutes.get(url.pathname);
574
+ return route?.methods.has(method) === true ? route.requestBody : "buffered";
575
+ },
576
+ fetch: (request) => {
577
+ const pathname = new URL(request.url).pathname;
578
+ const route = this.fetchRoutes.get(pathname);
579
+ if (route?.methods.has(request.method) === true) return route.fetch(request);
580
+ const endpoint = endpointFromPath(channel, pathname);
581
+ const interceptor = this.interceptors.get(channel);
582
+ if (endpoint === void 0 || interceptor === void 0 || !interceptor.matches(endpoint)) return Promise.resolve(new Response("not found", { status: 404 }));
583
+ return interceptor.fetchHandler.fetch(request);
584
+ }
585
+ };
557
586
  }
558
587
  registerFetchRoute(owner, route) {
559
588
  assertFetchRoute(route);
560
589
  const registered = {
561
590
  methods: new Set(route.methods),
591
+ requestBody: route.requestBody,
562
592
  fetch: route.fetch
563
593
  };
564
594
  return owner.effect(() => {
@@ -603,31 +633,34 @@ var HostConnectionService = class extends Service {
603
633
  }
604
634
  };
605
635
  function rpcFetchHandler(channel, handler) {
606
- return { async fetch(request) {
607
- const endpoint = endpointFromPath(channel, new URL(request.url).pathname);
608
- if (request.method !== "POST" || endpoint === void 0) return new Response("not found", { status: 404 });
609
- if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return new Response("content type must be application/json", { status: 415 });
610
- let body;
611
- try {
612
- body = await request.json();
613
- } catch {
614
- return new Response("body is not JSON", { status: 400 });
615
- }
616
- const envelope = clientRequestSchema.safeParse(body);
617
- if (!envelope.success) return invalidEnvelopeResponse(body, envelope.error.issues);
618
- const message = envelope.data;
619
- if (message.method !== endpoint) return errorResponse(message.rpcId, {
620
- code: "gateway/bad-request",
621
- message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
622
- details: { issues: [] }
623
- });
624
- try {
625
- const result = await handler(endpoint, message.payload, request.signal);
626
- return fullResponse(message.rpcId, result);
627
- } catch (error) {
628
- return new Response(`handler failure: ${String(error)}`, { status: 500 });
636
+ return {
637
+ requestBodyMode: () => "buffered",
638
+ async fetch(request) {
639
+ const endpoint = endpointFromPath(channel, new URL(request.url).pathname);
640
+ if (request.method !== "POST" || endpoint === void 0) return new Response("not found", { status: 404 });
641
+ if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return new Response("content type must be application/json", { status: 415 });
642
+ let body;
643
+ try {
644
+ body = await request.json();
645
+ } catch {
646
+ return new Response("body is not JSON", { status: 400 });
647
+ }
648
+ const envelope = clientRequestSchema.safeParse(body);
649
+ if (!envelope.success) return invalidEnvelopeResponse(body, envelope.error.issues);
650
+ const message = envelope.data;
651
+ if (message.method !== endpoint) return errorResponse(message.rpcId, {
652
+ code: "gateway/bad-request",
653
+ message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
654
+ details: { issues: [] }
655
+ });
656
+ try {
657
+ const result = await handler(endpoint, message.payload, request.signal);
658
+ return fullResponse(message.rpcId, result);
659
+ } catch (error) {
660
+ return new Response(`handler failure: ${String(error)}`, { status: 500 });
661
+ }
629
662
  }
630
- } };
663
+ };
631
664
  }
632
665
  function invalidEnvelopeResponse(body, issues) {
633
666
  const rawId = body?.rpcId;
@@ -666,6 +699,28 @@ function assertFetchRoute(route) {
666
699
  if (new Set(route.methods).size !== route.methods.length) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`);
667
700
  }
668
701
  //#endregion
702
+ //#region lib/types/recovery-config.js
703
+ /** Shared validation for Host-configured and browser-local connection recovery. */
704
+ const MAX_TIMER_MS = 2147483647;
705
+ /** Schema shared by the Host plugin and the Client's recovery input parser. */
706
+ const ConnectionRecoveryConfigSchema = z.object({
707
+ backoffBaseMs: z.natural().min(1).max(MAX_TIMER_MS).default(500),
708
+ backoffFactor: z.number().min(1).max(Number.MAX_VALUE).default(2),
709
+ backoffMaxMs: z.natural().min(1).max(MAX_TIMER_MS).default(1e4),
710
+ generationReadyWarnMs: z.natural().min(1).max(MAX_TIMER_MS).default(3e3),
711
+ generationReadyTimeoutMs: z.natural().min(1).max(MAX_TIMER_MS).default(15e3)
712
+ });
713
+ /**
714
+ * Validate recovery input and supply every timing default before starting work.
715
+ * @param config - Host configuration, page bootstrap data, or direct loop options.
716
+ * @returns validated, complete recovery timing.
717
+ */
718
+ function resolveConnectionConfig(config = {}) {
719
+ const resolved = ConnectionRecoveryConfigSchema(config);
720
+ if (!Number.isFinite(resolved.backoffFactor)) throw new RangeError("connection recovery backoffFactor must be finite");
721
+ return resolved;
722
+ }
723
+ //#endregion
669
724
  //#region lib/types/index.js
670
725
  /** Stable Cordis plugin name. */
671
726
  const name = "client-connection";
@@ -680,6 +735,7 @@ function assertImageBodyCapacity(ctx, maxRequestBodyBytes) {
680
735
  /** Services required before providing Connection. */
681
736
  const inject = ["webServer", "credentials"];
682
737
  const Config = z.object({
738
+ recovery: ConnectionRecoveryConfigSchema.default({}),
683
739
  trustedHosts: z.array(String).default([]),
684
740
  cookieMaxAgeDays: z.natural().min(1).default(30),
685
741
  maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES)
@@ -692,12 +748,20 @@ const Config = z.object({
692
748
  * @param config - resolved plugin config (schema defaults applied).
693
749
  */
694
750
  async function apply(ctx, config) {
751
+ const recovery = resolveConnectionConfig(config?.recovery);
695
752
  const trustedHosts = config?.trustedHosts ?? [];
696
753
  const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30;
697
754
  const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? 314572800;
698
755
  for (const entry of trustedHosts) assertTrustedAuthority(entry);
699
756
  assertImageBodyCapacity(ctx, maxRequestBodyBytes);
700
757
  const connection = new HostConnectionService(ctx, trustedHosts, await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays));
758
+ ctx.on("webserver/index-inject", (table) => {
759
+ table.push({
760
+ kind: "global",
761
+ name: "__DSH_CONNECTION_RECOVERY__",
762
+ value: recovery
763
+ });
764
+ });
701
765
  const fetchHandler = connection.createSharedFetchHandler(API_PATH);
702
766
  const route = {
703
767
  kind: "prefix",
@@ -1,3 +1,6 @@
1
+ /** Connection generation readiness, cancellation, and continuous recovery. */
2
+ import { type ConnectionRecoveryConfig } from '../recovery-config.ts';
3
+ export type { ConnectionRecoveryConfig } from '../recovery-config.ts';
1
4
  /** Stable Host facts delivered by one established Remote event generation. */
2
5
  export interface ConnectionHostInfo {
3
6
  /** Host account home used only to abbreviate displayed filesystem paths. */
@@ -10,17 +13,6 @@ export interface ConnectionGeneration {
10
13
  /** Host facts carried by this generation's opening frame. */
11
14
  readonly host: ConnectionHostInfo;
12
15
  }
13
- /** Reconnect/backoff tunables. All fields are optional; defaults are below. */
14
- export interface ConnectionConfig {
15
- /** First-retry backoff cap in ms (jittered: actual delay is cap/2..cap). */
16
- backoffBaseMs?: number;
17
- /** Exponential growth factor per failed attempt; values at or below 1 make the base tier final. */
18
- backoffFactor?: number;
19
- /** Upper bound for the backoff cap in ms. */
20
- backoffMaxMs?: number;
21
- /** Maximum wait for the registered generation source's ready signal. */
22
- generationReadyTimeoutMs?: number;
23
- }
24
16
  /** Connection lifecycle state published after the first attempt has an outcome. */
25
17
  export type ConnectionState = 'connected' | 'disconnected' | 'connecting';
26
18
  /** Connection-generation callbacks owned by API Gateway. */
@@ -35,7 +27,8 @@ export interface ConnectionSinks {
35
27
  /**
36
28
  * One long-lived source defining a Connection generation. The source must
37
29
  * attach its incremental listeners before calling `ready`, then remain pending
38
- * until the generation is lost or `signal` aborts.
30
+ * until the generation is lost or `signal` aborts. On abort it must stop
31
+ * delivery, release its resources, and settle before a replacement can start.
39
32
  * @param signal - cancellation for the current generation.
40
33
  * @param ready - one-shot report that incremental delivery is attached.
41
34
  * @returns a promise settling only when this generation ends or fails.
@@ -58,7 +51,7 @@ export declare class ConnectionController {
58
51
  private networkAvailable;
59
52
  private lastState;
60
53
  private readonly config;
61
- constructor(source: ConnectionGenerationSource, sinks?: ConnectionSinks, config?: ConnectionConfig);
54
+ constructor(source: ConnectionGenerationSource, sinks?: ConnectionSinks, config?: ConnectionRecoveryConfig);
62
55
  /** Idempotent: begin the connect/pump/reconnect loop. */
63
56
  start(): void;
64
57
  /** Stop the loop and abort the current generation source. */
@@ -72,7 +65,8 @@ export declare class ConnectionController {
72
65
  setNetworkAvailable(available: boolean): void;
73
66
  private backoffCap;
74
67
  private backoffDelay;
75
- private isFinalBackoffTier;
68
+ /** Re-read retry inputs after a potentially reentrant state sink. */
69
+ private isRetryInterrupted;
76
70
  /** Read through a method: stop() flips the flag across awaits, so narrowing from the loop condition must not stick. */
77
71
  private isRunning;
78
72
  /** Re-read both mutable liveness guards after a potentially reentrant sink. */
@@ -1,4 +1,35 @@
1
+ import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand';
2
+ import type { SessionSeqCursor } from '@deepseek-ai/dsh-session/types';
3
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values';
1
4
  import type { ClientConnectionRpc } from '../rpc.ts';
5
+ /** Assistant frame emitted by the standalone fixture; tests pin it to the controller wire type. */
6
+ export type FixtureAssistantStreamFrame = {
7
+ readonly type: 'start';
8
+ readonly attemptId: ReturnType<typeof LlmAttemptId>;
9
+ readonly revision: number;
10
+ readonly startedAfterSeq: SessionSeqCursor;
11
+ readonly turn: number;
12
+ readonly step: number;
13
+ } | {
14
+ readonly type: 'chunk';
15
+ readonly attemptId: ReturnType<typeof LlmAttemptId>;
16
+ readonly revision: number;
17
+ readonly index: number;
18
+ readonly time: number;
19
+ readonly chunk: JsonValue;
20
+ } | {
21
+ readonly type: 'end';
22
+ readonly attemptId: ReturnType<typeof LlmAttemptId>;
23
+ readonly revision: number;
24
+ readonly index: number;
25
+ readonly outcome: {
26
+ readonly kind: 'committed';
27
+ readonly eventType: 'assistant/message' | 'assistant/attempt';
28
+ readonly seq: number;
29
+ } | {
30
+ readonly kind: 'abandoned';
31
+ };
32
+ };
2
33
  /** Deterministic fixture branches used by keyless Web assembly tests. */
3
34
  export interface FixtureOptions {
4
35
  /** Start with no real Workspace or Session. */
@@ -1,9 +1,6 @@
1
- /**
2
- * Browser wire client. The plugin selects fixture or HTTP transport, provides
3
- * the shared API client, and lets API Gateway own the connection loop.
4
- */
1
+ /** Browser wire client: Remote transport and connection generations. */
5
2
  import type { Context } from '@deepseek-ai/cordis';
6
- import { type ConnectionConfig, type ConnectionGeneration, type ConnectionGenerationSource, type ConnectionSinks, type ConnectionState } from './connection.ts';
3
+ import { type ConnectionRecoveryConfig, type ConnectionGeneration, type ConnectionGenerationSource, type ConnectionSinks, type ConnectionState } from './connection.ts';
7
4
  import { type RpcFetch, type RpcStreamOpen } from './rpc.ts';
8
5
  import type { ClientConnectionRpc } from '../rpc.ts';
9
6
  declare module '@deepseek-ai/cordis' {
@@ -18,7 +15,7 @@ declare module '@deepseek-ai/cordis' {
18
15
  }
19
16
  export type { MessageId, RpcRequest, RpcResponse, RpcResult, ClientRequest, ServerResponse, RpcMessage, SessionId, SessionEvent, ContentBlock, StreamChunk, } from './api.ts';
20
17
  export { RpcId, transportError, } from './api.ts';
21
- export type { ConnectionConfig, ConnectionGeneration, ConnectionGenerationSource, ConnectionHostInfo, ConnectionSinks, ConnectionState, } from './connection.ts';
18
+ export type { ConnectionRecoveryConfig, ConnectionGeneration, ConnectionGenerationSource, ConnectionHostInfo, ConnectionSinks, ConnectionState, } from './connection.ts';
22
19
  export type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, } from '../rpc.ts';
23
20
  export type { RpcFetch } from './rpc.ts';
24
21
  /** Observable identity and Host facts for the active connection generation. */
@@ -65,9 +62,8 @@ export interface ClientTransportHooks {
65
62
  ownsHost?: boolean;
66
63
  }
67
64
  /**
68
- * The ctx.connection service API: the API client plus a one-shot controller
69
- * starter. API Gateway supplies generation readiness and reset callbacks;
70
- * Connection stays independent of downstream domain state.
65
+ * The ctx.connection service API. API Gateway supplies generation readiness
66
+ * and reset callbacks; Connection stays independent of downstream domain state.
71
67
  */
72
68
  export interface ConnectionHandle {
73
69
  /**
@@ -95,10 +91,10 @@ export interface ConnectionHandle {
95
91
  * Start the connect/reconnect loop with the consumer's state callbacks.
96
92
  * API Gateway owns the loop; a second call throws.
97
93
  * @param sinks - connection-state callbacks.
98
- * @param config - reconnect timing tunables.
94
+ * @param config - explicit timing overrides; omitted fields use Host bootstrap timing.
99
95
  * @returns lifecycle controls for the loop.
100
96
  */
101
- start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop;
97
+ start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop;
102
98
  }
103
99
  /** Controls retained by the sole owner of a running connection loop. */
104
100
  export interface ConnectionLoop {
@@ -106,7 +102,7 @@ export interface ConnectionLoop {
106
102
  stop(): void;
107
103
  }
108
104
  /**
109
- * Client plugin body: pick the api by page mode and provide ctx.connection.
105
+ * Client plugin body: pick physical carriers by page mode and provide ctx.connection.
110
106
  * @param ctx - client cordis context.
111
107
  */
112
108
  export declare function apply(ctx: Context): void;
@@ -3,27 +3,19 @@
3
3
  * web carrier; the fetch-shaped handler itself is transport-agnostic).
4
4
  */
5
5
  import type { IncomingMessage, ServerResponse } from 'node:http';
6
+ import type { ConnectionFetchHandler } from './rpc.ts';
6
7
  /** Default carrier cap for all HTTP RPC bodies: sized for the default
7
8
  * aggregate image limit (200 MiB) after base64 expansion plus envelope
8
9
  * headroom (~267.7 MiB required), rounded up for slack. The bridge buffers
9
10
  * each body in memory, so this cap is also the per-request resident bound. */
10
11
  export declare const DEFAULT_MAX_REQUEST_BODY_BYTES: number;
11
- /** Transport-independent request handler consumed by the Host HTTP bridge. */
12
- export interface FetchHandler {
13
- /**
14
- * Handle one standard Fetch request.
15
- * @param request - request produced by the active transport bridge.
16
- * @returns complete or streaming Fetch response.
17
- */
18
- fetch(request: Request): Promise<Response>;
19
- }
20
12
  /**
21
13
  * Bridge one node:http request to the fetch-shaped handler (client close
22
14
  * aborts; response bodies stream out chunk by chunk).
23
- * @param req - incoming node:http request (fully read before dispatch).
15
+ * @param req - incoming node:http request.
24
16
  * @param res - node:http response the bridge writes and owns to completion.
25
17
  * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
26
- * @param maxRequestBodyBytes - maximum body bytes buffered before dispatch.
18
+ * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
27
19
  */
28
- export declare function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler, maxRequestBodyBytes?: number): Promise<void>;
20
+ export declare function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: ConnectionFetchHandler, maxRequestBodyBytes?: number): Promise<void>;
29
21
  //# sourceMappingURL=http-bridge.d.ts.map
@@ -1,7 +1,8 @@
1
1
  /** Host HTTP bridge for browser-client RPC. */
2
2
  import type { Context } from '@deepseek-ai/cordis';
3
3
  import z from '@deepseek-ai/schemastery';
4
- export type { ConnectionFetchMethod, ConnectionFetchHandler, ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRequestRejection, ConnectionRpcResult, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, HostConnectionFetch, HostConnectionRpc, RpcMessage, ServerResponse, } from './rpc.ts';
4
+ import { type ConnectionRecoveryConfig } from './recovery-config.ts';
5
+ export type { ConnectionFetchMethod, ConnectionFetchHandler, ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRequestRejection, ConnectionRpcResult, ConnectionRequestBodyMode, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, HostConnectionFetch, HostConnectionRpc, RpcMessage, ServerResponse, } from './rpc.ts';
5
6
  export { RpcId, transportError } from './rpc.ts';
6
7
  export { clientRequestSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, } from './rpc-schema.ts';
7
8
  export { HostConnectionService } from './rpc-host.ts';
@@ -10,8 +11,10 @@ export { API_PATH } from './api-path.ts';
10
11
  export declare const name = "client-connection";
11
12
  /** Services required before providing Connection. */
12
13
  export declare const inject: string[];
13
- /** Plugin config: the deployment's non-loopback serving authorities. */
14
+ /** Browser authentication, request limits, and connection recovery configuration. */
14
15
  export interface ConnectionConfig {
16
+ /** Browser recovery timing, injected into each served page. */
17
+ recovery?: ConnectionRecoveryConfig;
15
18
  /**
16
19
  * Authorities this deployment serves beyond loopback: exact `host:port`, or
17
20
  * port-less `host` matching any port. The /api trust fence refuses any
@@ -0,0 +1,27 @@
1
+ /** Shared validation for Host-configured and browser-local connection recovery. */
2
+ import z from '@deepseek-ai/schemastery';
3
+ /** Timing for generation readiness and automatic reconnection. */
4
+ export interface ConnectionRecoveryConfig {
5
+ /** First-retry delay cap in ms; actual delay is 50–100% of the cap. Default: 500. */
6
+ backoffBaseMs?: number;
7
+ /** Finite growth factor of at least 1 per failed attempt; 1 keeps a fixed cap. Default: 2. */
8
+ backoffFactor?: number;
9
+ /** Maximum retry delay cap in ms; retries continue at this cap. Default: 10000. */
10
+ backoffMaxMs?: number;
11
+ /**
12
+ * Delay before reporting a slow handshake, without cancelling it. Default: 3000.
13
+ * Omitted when readiness, failure, cancellation, or the hard deadline occurs first.
14
+ */
15
+ generationReadyWarnMs?: number;
16
+ /** Deadline in ms for readiness, including physical connection setup. Default: 15000. */
17
+ generationReadyTimeoutMs?: number;
18
+ }
19
+ /** Schema shared by the Host plugin and the Client's recovery input parser. */
20
+ export declare const ConnectionRecoveryConfigSchema: z<ConnectionRecoveryConfig>;
21
+ /**
22
+ * Validate recovery input and supply every timing default before starting work.
23
+ * @param config - Host configuration, page bootstrap data, or direct loop options.
24
+ * @returns validated, complete recovery timing.
25
+ */
26
+ export declare function resolveConnectionConfig(config?: unknown): Required<ConnectionRecoveryConfig>;
27
+ //# sourceMappingURL=recovery-config.d.ts.map
@@ -77,13 +77,17 @@ export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal:
77
77
  /** Synchronous ownership test for one endpoint on a shared RPC channel. */
78
78
  export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean;
79
79
  /** HTTP methods supported by exact Fetch routes on the shared API channel. */
80
- export type ConnectionFetchMethod = 'GET' | 'HEAD';
80
+ export type ConnectionFetchMethod = 'GET' | 'HEAD' | 'POST';
81
+ /** How the node:http bridge presents one request body to its Fetch route. */
82
+ export type ConnectionRequestBodyMode = 'buffered' | 'streaming';
81
83
  /** One exact, transport-independent Fetch route owned by a Host feature. */
82
84
  export interface ConnectionFetchRoute {
83
85
  /** Absolute path below `/api`; query parameters remain available on the request URL. */
84
86
  readonly path: string;
85
87
  /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */
86
88
  readonly methods: readonly ConnectionFetchMethod[];
89
+ /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */
90
+ readonly requestBody: ConnectionRequestBodyMode;
87
91
  /** Handle one request after the physical carrier has applied its trust and authentication policy. */
88
92
  readonly fetch: (request: Request) => Promise<Response>;
89
93
  }
@@ -149,6 +153,15 @@ export interface HostConnectionHandle {
149
153
  }
150
154
  /** Transport-independent Fetch handler used by HTTP and worker carriers. */
151
155
  export interface ConnectionFetchHandler {
156
+ /**
157
+ * Resolve body handling before the bridge reads any request bytes.
158
+ * @param request - request method and URL available from node:http headers.
159
+ * @returns the registered route's body handling mode.
160
+ */
161
+ requestBodyMode(request: {
162
+ readonly method: string;
163
+ readonly url: URL;
164
+ }): ConnectionRequestBodyMode;
152
165
  /**
153
166
  * Dispatch one already-authenticated request.
154
167
  * @param request - Fetch request below the shared channel.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-connection",
3
3
  "description": "Authenticated RPC transport, generation lifecycle, and browser fixture",
4
- "version": "0.1.2-alpha.5",
4
+ "version": "0.1.3-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -35,7 +35,7 @@
35
35
  "license": "MIT",
36
36
  "dependencies": {
37
37
  "zod": "^4.4.3",
38
- "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.5",
38
+ "@deepseek-ai/dsh-credentials": "^0.1.3-alpha.2",
39
39
  "@deepseek-ai/schemastery": "^3.18.2"
40
40
  },
41
41
  "files": [
@@ -48,15 +48,16 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@deepseek-ai/cordis": "^4.0.2",
51
- "@deepseek-ai/dsh-attachment": "^0.1.2-alpha.5",
52
- "@deepseek-ai/dsh-brand": "^0.1.2-alpha.5",
53
- "@deepseek-ai/dsh-commands": "^0.1.2-alpha.5",
54
- "@deepseek-ai/dsh-host-directory-picker": "^0.1.2-alpha.5",
55
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.5",
56
- "@deepseek-ai/dsh-llm": "^0.1.2-alpha.5",
57
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.5",
58
- "@deepseek-ai/dsh-tool-todo": "^0.1.2-alpha.5",
59
- "@deepseek-ai/dsh-util-values": "^0.1.2-alpha.5",
60
- "@deepseek-ai/dsh-session": "^0.1.2-alpha.5"
51
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.3-alpha.2",
52
+ "@deepseek-ai/dsh-attachment": "^0.1.3-alpha.2",
53
+ "@deepseek-ai/dsh-brand": "^0.1.3-alpha.2",
54
+ "@deepseek-ai/dsh-commands": "^0.1.3-alpha.2",
55
+ "@deepseek-ai/dsh-host-directory-picker": "^0.1.3-alpha.2",
56
+ "@deepseek-ai/dsh-host-webserver": "^0.1.3-alpha.2",
57
+ "@deepseek-ai/dsh-llm": "^0.1.3-alpha.2",
58
+ "@deepseek-ai/dsh-settings": "^0.1.3-alpha.2",
59
+ "@deepseek-ai/dsh-tool-todo": "^0.1.3-alpha.2",
60
+ "@deepseek-ai/dsh-util-values": "^0.1.3-alpha.2",
61
+ "@deepseek-ai/dsh-session": "^0.1.3-alpha.2"
61
62
  }
62
63
  }