agentfootprint 7.24.0 → 7.25.0
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/dist/adapters/hosting/agentcore.js +112 -1
- package/dist/adapters/hosting/agentcore.js.map +1 -1
- package/dist/esm/adapters/hosting/agentcore.d.ts +50 -1
- package/dist/esm/adapters/hosting/agentcore.js +110 -0
- package/dist/esm/adapters/hosting/agentcore.js.map +1 -1
- package/dist/esm/hosting/errors.d.ts +44 -2
- package/dist/esm/hosting/errors.js +63 -0
- package/dist/esm/hosting/errors.js.map +1 -1
- package/dist/esm/hosting/headers.d.ts +16 -0
- package/dist/esm/hosting/headers.js +24 -0
- package/dist/esm/hosting/headers.js.map +1 -0
- package/dist/esm/hosting/httpHost.d.ts +70 -4
- package/dist/esm/hosting/httpHost.js +211 -47
- package/dist/esm/hosting/httpHost.js.map +1 -1
- package/dist/esm/hosting/index.d.ts +23 -8
- package/dist/esm/hosting/index.js +21 -6
- package/dist/esm/hosting/index.js.map +1 -1
- package/dist/esm/hosting/nodeHost.d.ts +23 -0
- package/dist/esm/hosting/nodeHost.js +21 -1
- package/dist/esm/hosting/nodeHost.js.map +1 -1
- package/dist/esm/hosting/types.d.ts +212 -6
- package/dist/esm/hosting/types.js +7 -5
- package/dist/esm/hosting/types.js.map +1 -1
- package/dist/esm/hosting/webSocketConversation.d.ts +101 -0
- package/dist/esm/hosting/webSocketConversation.js +341 -0
- package/dist/esm/hosting/webSocketConversation.js.map +1 -0
- package/dist/esm/hosting/webSocketFrames.d.ts +164 -0
- package/dist/esm/hosting/webSocketFrames.js +284 -0
- package/dist/esm/hosting/webSocketFrames.js.map +1 -0
- package/dist/esm/hosting-providers.d.ts +7 -2
- package/dist/esm/hosting-providers.js +7 -2
- package/dist/esm/hosting-providers.js.map +1 -1
- package/dist/hosting/errors.js +66 -1
- package/dist/hosting/errors.js.map +1 -1
- package/dist/hosting/headers.js +28 -0
- package/dist/hosting/headers.js.map +1 -0
- package/dist/hosting/httpHost.js +211 -47
- package/dist/hosting/httpHost.js.map +1 -1
- package/dist/hosting/index.js +23 -6
- package/dist/hosting/index.js.map +1 -1
- package/dist/hosting/nodeHost.js +20 -0
- package/dist/hosting/nodeHost.js.map +1 -1
- package/dist/hosting/types.js +7 -5
- package/dist/hosting/types.js.map +1 -1
- package/dist/hosting/webSocketConversation.js +345 -0
- package/dist/hosting/webSocketConversation.js.map +1 -0
- package/dist/hosting/webSocketFrames.js +297 -0
- package/dist/hosting/webSocketFrames.js.map +1 -0
- package/dist/hosting-providers.js +8 -2
- package/dist/hosting-providers.js.map +1 -1
- package/dist/types/adapters/hosting/agentcore.d.ts +50 -1
- package/dist/types/adapters/hosting/agentcore.d.ts.map +1 -1
- package/dist/types/hosting/errors.d.ts +44 -2
- package/dist/types/hosting/errors.d.ts.map +1 -1
- package/dist/types/hosting/headers.d.ts +17 -0
- package/dist/types/hosting/headers.d.ts.map +1 -0
- package/dist/types/hosting/httpHost.d.ts +70 -4
- package/dist/types/hosting/httpHost.d.ts.map +1 -1
- package/dist/types/hosting/index.d.ts +23 -8
- package/dist/types/hosting/index.d.ts.map +1 -1
- package/dist/types/hosting/nodeHost.d.ts +23 -0
- package/dist/types/hosting/nodeHost.d.ts.map +1 -1
- package/dist/types/hosting/types.d.ts +212 -6
- package/dist/types/hosting/types.d.ts.map +1 -1
- package/dist/types/hosting/webSocketConversation.d.ts +102 -0
- package/dist/types/hosting/webSocketConversation.d.ts.map +1 -0
- package/dist/types/hosting/webSocketFrames.d.ts +165 -0
- package/dist/types/hosting/webSocketFrames.d.ts.map +1 -0
- package/dist/types/hosting-providers.d.ts +7 -2
- package/dist/types/hosting-providers.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agentfootprint/hosting — the
|
|
2
|
+
* agentfootprint/hosting — the ports between an agent and the place it runs.
|
|
3
3
|
*
|
|
4
4
|
* An agent that answers one call in a script and an agent that has been up for
|
|
5
|
-
* a month differ in
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the composer that wires
|
|
5
|
+
* a month differ in a few things: something has to carry requests to it,
|
|
6
|
+
* something sometimes has to hold a channel OPEN to it, and the conversation
|
|
7
|
+
* has to outlive the request. This subpath is those things as **ports**, plus
|
|
8
|
+
* local adapters that prove the ports work, plus the composer that wires the
|
|
9
|
+
* request half together.
|
|
9
10
|
*
|
|
10
11
|
* `AgentHost` — something can call me.
|
|
12
|
+
* `ConversationHost` — something can talk to me, both ways, until one of us
|
|
13
|
+
* ends it. `HostRequest → HostReply` is one exchange;
|
|
14
|
+
* some doors are not.
|
|
11
15
|
* `SessionLifecycle` — the conversation outlives the request.
|
|
12
16
|
* `standingAgent` — hydrate → resume-or-fresh → persist → reply.
|
|
13
17
|
*
|
|
@@ -34,6 +38,17 @@
|
|
|
34
38
|
* re-decides. Pass `server` and it attaches to a `node:http` server YOU
|
|
35
39
|
* own instead of binding one — for the container that gets a single port
|
|
36
40
|
* and must serve a WebSocket upgrade (or anything else) beside the agent.
|
|
41
|
+
* • `host.serveConversations(handler)` — the conversation door, on every
|
|
42
|
+
* adapter built on `httpHost` that was given a `conversationPath`.
|
|
43
|
+
* `nodeHost` serves it on `/conversation` with a real WebSocket
|
|
44
|
+
* implementation and **no dependency to install**, sharing the socket with
|
|
45
|
+
* `/invoke`. Frames are STRINGS at the port; what they mean is your
|
|
46
|
+
* protocol's business, not this port's.
|
|
47
|
+
* • `ConversationLimits` — the ceilings a door DECLARES
|
|
48
|
+
* (`maxFrameBytes`, `idleMs`, `maxPendingBytes`) so the layer above can
|
|
49
|
+
* chunk or heartbeat on its own protocol. The port does neither, on
|
|
50
|
+
* purpose: hiding a cap inside auto-chunking decides a protocol question
|
|
51
|
+
* for every consumer at once.
|
|
37
52
|
* • `memorySessions()` — conversations in a Map, for tests and local dev.
|
|
38
53
|
* • `standingAgent({ agent, sessions, host, durability? })` — the composer.
|
|
39
54
|
* • `toEnvelope` / `readEnvelope` — pack a conversation, and refuse by name to
|
|
@@ -63,5 +78,5 @@ export { httpHost, headerValue } from './httpHost.js';
|
|
|
63
78
|
export { memorySessions } from './memorySessions.js';
|
|
64
79
|
export { toEnvelope, toPausedEnvelope, readEnvelope, readPausedRun, checkEnvelope, } from './envelope.js';
|
|
65
80
|
export { standingAgent } from './standingAgent.js';
|
|
66
|
-
export { requireCapability, HostClosedError, ConcurrentRunError, PauseNotCarriedError, AwaitingDecisionError, NoPendingAskError, UnreadableEnvelopeError, } from './errors.js';
|
|
81
|
+
export { requireCapability, HostClosedError, ConcurrentRunError, PauseNotCarriedError, AwaitingDecisionError, NoPendingAskError, UnreadableEnvelopeError, ConversationClosedError, FrameTooLargeError, } from './errors.js';
|
|
67
82
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/hosting/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/hosting/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0EG;AAEH,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAGnD,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAWtD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,UAAU,EACV,gBAAgB,EAChB,YAAY,EACZ,aAAa,EACb,aAAa,GACd,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAEnD,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,kBAAkB,EAClB,oBAAoB,EACpB,qBAAqB,EACrB,iBAAiB,EACjB,uBAAuB,EACvB,uBAAuB,EACvB,kBAAkB,GACnB,MAAM,aAAa,CAAC"}
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* instead of binding one, so a WebSocket upgrade or your own routes can share
|
|
22
22
|
* the port. `close()` then detaches and drains, and your socket stays up.
|
|
23
23
|
*
|
|
24
|
+
* **There is a third door.** `serveConversations(handler)` takes WebSocket
|
|
25
|
+
* upgrades on `/conversation` and hands each one to your handler as a
|
|
26
|
+
* `HostConversation` — a channel that stays open, for the callers that cannot
|
|
27
|
+
* host an inbound endpoint and dial out instead. It shares this host's socket
|
|
28
|
+
* with `/invoke` and `/health`, needs nothing installed, and declares what it
|
|
29
|
+
* caps.
|
|
30
|
+
*
|
|
24
31
|
* **Streaming is the caller's choice, not the server's.** Send
|
|
25
32
|
* `Accept: text/event-stream` and the reply is Server-Sent Events, one `chunk`
|
|
26
33
|
* event per `reply.emit(...)` then a final `complete`. Send anything else and
|
|
@@ -36,6 +43,7 @@
|
|
|
36
43
|
*/
|
|
37
44
|
/// <reference types="node" />
|
|
38
45
|
import { type HttpHost, type HttpHostHandle, type HttpWire } from './httpHost.js';
|
|
46
|
+
import type { ConversationLimits } from './types.js';
|
|
39
47
|
/** Options for {@link nodeHost}. */
|
|
40
48
|
export interface NodeHostOptions {
|
|
41
49
|
/** Port to bind. Default `8080`. Pass `0` for an ephemeral port. Refused alongside `server`. */
|
|
@@ -46,6 +54,21 @@ export interface NodeHostOptions {
|
|
|
46
54
|
readonly invokePath?: string;
|
|
47
55
|
/** Path that answers a health probe. Default `'/health'`. */
|
|
48
56
|
readonly healthPath?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Path that takes a conversation upgrade. Default `'/conversation'` — this
|
|
59
|
+
* adapter's own word, chosen the way its other two paths were, and named for
|
|
60
|
+
* what it carries rather than for what any runtime calls it.
|
|
61
|
+
*/
|
|
62
|
+
readonly conversationPath?: string;
|
|
63
|
+
/**
|
|
64
|
+
* What the conversation door caps. Defaults to one mebibyte per frame and one
|
|
65
|
+
* mebibyte held before a handler subscribes — declared, so
|
|
66
|
+
* `host.conversationLimits` always reports what is really enforced. This
|
|
67
|
+
* adapter declares no `idleMs`: it does not idle a conversation out, and
|
|
68
|
+
* reporting a ceiling it neither imposes nor sits behind would be inventing
|
|
69
|
+
* a fact.
|
|
70
|
+
*/
|
|
71
|
+
readonly conversationLimits?: ConversationLimits;
|
|
49
72
|
/**
|
|
50
73
|
* A `node:http` server **you** own, already listening. Given one, this
|
|
51
74
|
* adapter attaches its two routes to it instead of binding a socket of its
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* instead of binding one, so a WebSocket upgrade or your own routes can share
|
|
22
22
|
* the port. `close()` then detaches and drains, and your socket stays up.
|
|
23
23
|
*
|
|
24
|
+
* **There is a third door.** `serveConversations(handler)` takes WebSocket
|
|
25
|
+
* upgrades on `/conversation` and hands each one to your handler as a
|
|
26
|
+
* `HostConversation` — a channel that stays open, for the callers that cannot
|
|
27
|
+
* host an inbound endpoint and dial out instead. It shares this host's socket
|
|
28
|
+
* with `/invoke` and `/health`, needs nothing installed, and declares what it
|
|
29
|
+
* caps.
|
|
30
|
+
*
|
|
24
31
|
* **Streaming is the caller's choice, not the server's.** Send
|
|
25
32
|
* `Accept: text/event-stream` and the reply is Server-Sent Events, one `chunk`
|
|
26
33
|
* event per `reply.emit(...)` then a final `complete`. Send anything else and
|
|
@@ -34,7 +41,7 @@
|
|
|
34
41
|
* never quietly drift apart on what `close()` drains or what a handler that
|
|
35
42
|
* throws does. `types.ts` knows none of it either way.
|
|
36
43
|
*/
|
|
37
|
-
import { httpHost } from './httpHost.js';
|
|
44
|
+
import { headerValue, httpHost, } from './httpHost.js';
|
|
38
45
|
const HOST_NAME = 'nodeHost';
|
|
39
46
|
/**
|
|
40
47
|
* This adapter's own JSON dialect: `{ input, sessionId? }` in, `{ output }`
|
|
@@ -66,6 +73,15 @@ export const jsonWire = {
|
|
|
66
73
|
failure: (error, code) => ({ error, ...(code !== undefined && { code }) }),
|
|
67
74
|
chunk: (text) => ({ text }),
|
|
68
75
|
awaiting: (pending) => ({ awaiting: pending }),
|
|
76
|
+
readConversation(facts) {
|
|
77
|
+
// A handshake has no body, so this dialect names the two places a session
|
|
78
|
+
// id can be: the header a server-side caller sets, and the query a BROWSER
|
|
79
|
+
// has to use because the WebSocket API gives it no way to set a header.
|
|
80
|
+
// Header wins, so a caller that sets both is not surprised by which one the
|
|
81
|
+
// server preferred — the same rule the request dialect uses for body-vs-header.
|
|
82
|
+
const sessionId = headerValue(facts, 'x-session-id') ?? facts.query.get('sessionId') ?? undefined;
|
|
83
|
+
return { ...(sessionId !== undefined && sessionId.length > 0 && { sessionId }) };
|
|
84
|
+
},
|
|
69
85
|
};
|
|
70
86
|
/**
|
|
71
87
|
* An HTTP host for one handler.
|
|
@@ -87,6 +103,10 @@ export function nodeHost(options = {}) {
|
|
|
87
103
|
// particular runtime's container-contract path literal.
|
|
88
104
|
invokePath: options.invokePath ?? '/invoke',
|
|
89
105
|
healthPath: options.healthPath ?? '/health',
|
|
106
|
+
conversationPath: options.conversationPath ?? '/conversation',
|
|
107
|
+
...(options.conversationLimits !== undefined && {
|
|
108
|
+
conversationLimits: options.conversationLimits,
|
|
109
|
+
}),
|
|
90
110
|
// Passed through as given, never defaulted: with a caller-owned server
|
|
91
111
|
// there is no port to default, and a port passed WITH one is a caller who
|
|
92
112
|
// believes something untrue about where their agent answers — `httpHost`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nodeHost.js","sourceRoot":"","sources":["../../../src/hosting/nodeHost.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"nodeHost.js","sourceRoot":"","sources":["../../../src/hosting/nodeHost.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* hosting/types — the
|
|
2
|
+
* hosting/types — the ports an agent needs to stand up and stay up.
|
|
3
3
|
*
|
|
4
|
-
* `AgentHost` is "something can call me". `
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* `AgentHost` is "something can call me". `ConversationHost` is "something can
|
|
5
|
+
* TALK to me" — a door that stays open, because `HostRequest → HostReply` is
|
|
6
|
+
* one exchange and some doors are not. `SessionLifecycle` is "the conversation
|
|
7
|
+
* outlives the request". All three are deliberately written in the vocabulary
|
|
8
|
+
* every transport and every store already has — an input, a reply, a frame, a
|
|
9
|
+
* session id, a stored blob — and in nothing else.
|
|
8
10
|
*
|
|
9
11
|
* **The rule these types are written under:** no runtime, product or protocol
|
|
10
12
|
* gets a field, a name or an assumption here. A port shaped around one
|
|
@@ -22,6 +24,8 @@ import type { Agent } from '../core/Agent.js';
|
|
|
22
24
|
import type { CheckInRequest } from '../core/checkin.js';
|
|
23
25
|
import type { MiddlewareAsk } from '../core/pause.js';
|
|
24
26
|
import type { AgentRunCheckpoint } from '../core/runCheckpoint.js';
|
|
27
|
+
import type { Unsubscribe } from '../events/dispatcher.js';
|
|
28
|
+
export type { Unsubscribe };
|
|
25
29
|
/**
|
|
26
30
|
* Something a host can do BEYOND the baseline of "accept a request, deliver one
|
|
27
31
|
* reply". Read it from {@link AgentHost.capabilities} and branch on it — never
|
|
@@ -32,8 +36,18 @@ import type { AgentRunCheckpoint } from '../core/runCheckpoint.js';
|
|
|
32
36
|
* of a transport that does not exist yet: a capability nobody implements is a
|
|
33
37
|
* promise the library cannot keep, and pre-minting one for an imagined future
|
|
34
38
|
* transport would bake that transport's assumptions in before it arrives.
|
|
39
|
+
*
|
|
40
|
+
* - `'streaming'` — the caller SEES `reply.emit(...)` pieces as they arrive.
|
|
41
|
+
* - `'conversation'` — this host can also carry a two-way channel that stays
|
|
42
|
+
* open ({@link ConversationHost.serveConversations}). It joined the union
|
|
43
|
+
* when two shipped adapters honoured it, not when it was imagined.
|
|
44
|
+
*
|
|
45
|
+
* Declared at CONSTRUCTION and static thereafter, which is a constraint worth
|
|
46
|
+
* knowing about: a capability whose truth depended on what happened to be
|
|
47
|
+
* installed at call time could not be declared here honestly, so an adapter
|
|
48
|
+
* that can only sometimes keep a promise does not make it.
|
|
35
49
|
*/
|
|
36
|
-
export type HostCapability = 'streaming';
|
|
50
|
+
export type HostCapability = 'streaming' | 'conversation';
|
|
37
51
|
/**
|
|
38
52
|
* One inbound request, as the transport described it.
|
|
39
53
|
*/
|
|
@@ -164,6 +178,198 @@ export interface AgentHost {
|
|
|
164
178
|
/** Start serving. Resolves once the host is actually live. */
|
|
165
179
|
serve(handler: HostHandler): Promise<HostHandle>;
|
|
166
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* A door that stays open: one session-scoped, two-way channel.
|
|
183
|
+
*
|
|
184
|
+
* The distinction this type exists for, in the words of the field report that
|
|
185
|
+
* bought it: **`HostRequest → HostReply` is one exchange, and this door is a
|
|
186
|
+
* conversation.** A request has one reply and then it is over. A conversation
|
|
187
|
+
* has neither side taking turns by rule, no reply count, and an end that either
|
|
188
|
+
* side can call.
|
|
189
|
+
*
|
|
190
|
+
* ── Frames are STRINGS here, deliberately ───────────────────────────────────
|
|
191
|
+
* What the frames MEAN is the consumer's contract, not this port's: one
|
|
192
|
+
* consumer pushes tool calls down the channel, another speaks a standardized
|
|
193
|
+
* agent↔UI protocol, a third exchanges long-running task updates. JSON is what
|
|
194
|
+
* all three happen to use and none of them agree on beyond that, so the port
|
|
195
|
+
* carries text and stays out of it. Binary is a capability question, deferred
|
|
196
|
+
* until a consumer needs it rather than guessed at now.
|
|
197
|
+
*
|
|
198
|
+
* ── What is NOT here ────────────────────────────────────────────────────────
|
|
199
|
+
* No authentication, no chunking, no heartbeat, no protocol framing. See
|
|
200
|
+
* {@link ConversationLimits} for why the last two are absences with a reason
|
|
201
|
+
* rather than gaps.
|
|
202
|
+
*/
|
|
203
|
+
export interface HostConversation {
|
|
204
|
+
/**
|
|
205
|
+
* The conversation this channel CLAIMS to belong to — caller data, exactly as
|
|
206
|
+
* the transport declared it, and **not identity**. The whole of
|
|
207
|
+
* {@link HostRequest.sessionId}'s warning applies here word for word: anyone
|
|
208
|
+
* who can reach the host can put any string here, including someone else's.
|
|
209
|
+
*
|
|
210
|
+
* A conversation and a request carrying the same string are the same
|
|
211
|
+
* session's, as far as this port is concerned. What that entitles either of
|
|
212
|
+
* them to is yours to decide, above the port.
|
|
213
|
+
*/
|
|
214
|
+
readonly sessionId?: string;
|
|
215
|
+
/**
|
|
216
|
+
* Transport headers with lower-cased names, as delivered — so a handler can
|
|
217
|
+
* map its own conventions without the port guessing which ones matter.
|
|
218
|
+
*
|
|
219
|
+
* This is also where an adapter puts credentials that its transport spells
|
|
220
|
+
* some other way: a bearer token a browser could only send as a subprotocol
|
|
221
|
+
* arrives here as an ordinary `authorization` header, because a port field
|
|
222
|
+
* spelled the way one vendor spells it is how a port stops being one.
|
|
223
|
+
*/
|
|
224
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
225
|
+
/**
|
|
226
|
+
* Host → far side. One frame, delivered whole.
|
|
227
|
+
*
|
|
228
|
+
* Refuses BY NAME rather than dropping quietly in two cases: a conversation
|
|
229
|
+
* that has ended (`ConversationClosedError`), and a frame past the ceiling the
|
|
230
|
+
* adapter declared (`FrameTooLargeError`). A dropped frame on a channel that
|
|
231
|
+
* looks open is the failure mode this port exists to make impossible.
|
|
232
|
+
*/
|
|
233
|
+
send(frame: string): void;
|
|
234
|
+
/**
|
|
235
|
+
* Far side → host. Returns an unsubscribe.
|
|
236
|
+
*
|
|
237
|
+
* Frames that arrive BEFORE the first subscriber are held and delivered to
|
|
238
|
+
* it, up to the bound the adapter declares
|
|
239
|
+
* ({@link ConversationLimits.maxPendingBytes}) — an `async` handler that
|
|
240
|
+
* awaits anything before subscribing would otherwise silently lose the far
|
|
241
|
+
* side's opening frame, which on a channel whose first frame is a greeting is
|
|
242
|
+
* every conversation.
|
|
243
|
+
*/
|
|
244
|
+
onFrame(cb: (frame: string) => void): Unsubscribe;
|
|
245
|
+
/**
|
|
246
|
+
* The end, delivered exactly once per subscriber — including to a subscriber
|
|
247
|
+
* that arrives after it already happened, which is answered immediately
|
|
248
|
+
* rather than never.
|
|
249
|
+
*/
|
|
250
|
+
onClose(cb: (reason: ConversationClose) => void): Unsubscribe;
|
|
251
|
+
/**
|
|
252
|
+
* End it politely: flush what is queued, tell the far side, then stop.
|
|
253
|
+
* Idempotent — the first call owns the ending and later ones do nothing.
|
|
254
|
+
*/
|
|
255
|
+
close(reason?: string): void;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* How a conversation ended, in terms every transport can answer.
|
|
259
|
+
*
|
|
260
|
+
* `by` is the fact a consumer branches on and the reason there is no numeric
|
|
261
|
+
* code here: what all three of "a browser-parked channel", "a UI protocol" and
|
|
262
|
+
* "a long-running task exchange" need to know is whether the far side said
|
|
263
|
+
* goodbye, whether we did, or whether it broke — and a transport's own numbers
|
|
264
|
+
* answer that only if you already know that transport. Adapters render their
|
|
265
|
+
* own vocabulary (a close code, a timeout, a ceiling) into {@link reason}.
|
|
266
|
+
*/
|
|
267
|
+
export interface ConversationClose {
|
|
268
|
+
/**
|
|
269
|
+
* - `'far-side'` — they ended it.
|
|
270
|
+
* - `'host'` — we did, through {@link HostConversation.close}.
|
|
271
|
+
* - `'transport'` — nobody ended it; it broke or timed out.
|
|
272
|
+
*/
|
|
273
|
+
readonly by: 'far-side' | 'host' | 'transport';
|
|
274
|
+
/** What was said about it, when anything was. */
|
|
275
|
+
readonly reason?: string;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* What you hand {@link ConversationHost.serveConversations} — called once per
|
|
279
|
+
* conversation, with that conversation.
|
|
280
|
+
*
|
|
281
|
+
* Throwing ends THAT conversation with a stated reason and never the host: one
|
|
282
|
+
* caller's bad frame is not an outage.
|
|
283
|
+
*/
|
|
284
|
+
export type ConversationHandler = (conversation: HostConversation) => void | Promise<void>;
|
|
285
|
+
/**
|
|
286
|
+
* The ceilings a door imposes, **declared rather than discovered**.
|
|
287
|
+
*
|
|
288
|
+
* ── Why the port does not just handle them ──────────────────────────────────
|
|
289
|
+
* A transport that caps frame size or idles out must SAY so, and then get out
|
|
290
|
+
* of the way. Hiding a 32KB cap inside auto-chunking would be the adapter
|
|
291
|
+
* deciding a protocol question for every consumer at once — how a message is
|
|
292
|
+
* split, how the pieces are numbered, how the far side knows the last one has
|
|
293
|
+
* landed — and those answers differ per consumer. Same for liveness: a
|
|
294
|
+
* heartbeat is frames on somebody's protocol, and inventing them puts bytes on
|
|
295
|
+
* the wire that the consumer's parser never agreed to.
|
|
296
|
+
*
|
|
297
|
+
* So the port's job is to make the ceiling VISIBLE and let the layer above act:
|
|
298
|
+
* chunk above the port, heartbeat above the port.
|
|
299
|
+
*
|
|
300
|
+
* ── Enforced vs reported ────────────────────────────────────────────────────
|
|
301
|
+
* A door enforces what it IS and reports what it SITS BEHIND, and the doc on
|
|
302
|
+
* each field says which. Absent means "no ceiling this adapter knows of", never
|
|
303
|
+
* "no ceiling" — the runtime in front of you may have one it never told us
|
|
304
|
+
* about.
|
|
305
|
+
*/
|
|
306
|
+
export interface ConversationLimits {
|
|
307
|
+
/**
|
|
308
|
+
* Largest single frame this door carries, in bytes of UTF-8.
|
|
309
|
+
*
|
|
310
|
+
* **Enforced, both directions**, by the door that declares it: an inbound
|
|
311
|
+
* frame past it ends the conversation with a stated reason, and
|
|
312
|
+
* {@link HostConversation.send} past it refuses by name instead of
|
|
313
|
+
* truncating or silently splitting.
|
|
314
|
+
*
|
|
315
|
+
* A frame the transport delivered in pieces counts in TOTAL — the port's
|
|
316
|
+
* frame is the whole message, not the transport's packet, so fragmentation
|
|
317
|
+
* cannot be used to walk around the ceiling.
|
|
318
|
+
*/
|
|
319
|
+
readonly maxFrameBytes?: number;
|
|
320
|
+
/**
|
|
321
|
+
* How long the transport tolerates silence before it closes the channel.
|
|
322
|
+
*
|
|
323
|
+
* **Reported, not imposed.** The door declaring it usually is not the thing
|
|
324
|
+
* enforcing it — a runtime's front door idles a socket out long before the
|
|
325
|
+
* process inside notices — and a consumer that needs the channel to stay up
|
|
326
|
+
* sends its own heartbeat frames on its own protocol. Making that possible is
|
|
327
|
+
* the whole reason this number is written down.
|
|
328
|
+
*/
|
|
329
|
+
readonly idleMs?: number;
|
|
330
|
+
/**
|
|
331
|
+
* How much the door holds for you before the first
|
|
332
|
+
* {@link HostConversation.onFrame} subscriber exists, in bytes.
|
|
333
|
+
*
|
|
334
|
+
* **Enforced**, and a ceiling on the DOOR rather than on the transport: the
|
|
335
|
+
* pre-subscribe buffer that stops an `async` handler from losing the opening
|
|
336
|
+
* frame is a queue somebody else fills and this process pays for, so it gets
|
|
337
|
+
* a number and a stated overflow instead of growing until the host dies. Past
|
|
338
|
+
* it, the conversation ends with a reason naming this bound.
|
|
339
|
+
*
|
|
340
|
+
* Bounded in BYTES rather than in frames on purpose: a frame count would
|
|
341
|
+
* still admit `count × maxFrameBytes` of memory, which is the same unbounded
|
|
342
|
+
* queue with an extra step.
|
|
343
|
+
*/
|
|
344
|
+
readonly maxPendingBytes?: number;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The port: something that can carry conversations to one handler.
|
|
348
|
+
*
|
|
349
|
+
* It sits BESIDE {@link AgentHost} rather than inside it, because a transport
|
|
350
|
+
* that can carry a request cannot necessarily carry a conversation, and one
|
|
351
|
+
* that carries conversations need not answer requests at all. A host that does
|
|
352
|
+
* both implements both and declares `'conversation'` in
|
|
353
|
+
* {@link AgentHost.capabilities}.
|
|
354
|
+
*/
|
|
355
|
+
export interface ConversationHost {
|
|
356
|
+
/** Which adapter this is. Every refusal names it. */
|
|
357
|
+
readonly name: string;
|
|
358
|
+
/** What this adapter can do beyond the baseline. Feature-detect; never assume. */
|
|
359
|
+
readonly capabilities: readonly HostCapability[];
|
|
360
|
+
/**
|
|
361
|
+
* What this door caps, as declared facts. Absent means this adapter knows of
|
|
362
|
+
* no ceiling — never that there is none.
|
|
363
|
+
*/
|
|
364
|
+
readonly conversationLimits?: ConversationLimits;
|
|
365
|
+
/**
|
|
366
|
+
* Start taking conversations. Resolves once the door is actually open.
|
|
367
|
+
*
|
|
368
|
+
* The handle's `close()` ends every live conversation politely and then
|
|
369
|
+
* releases the door.
|
|
370
|
+
*/
|
|
371
|
+
serveConversations(handler: ConversationHandler): Promise<HostHandle>;
|
|
372
|
+
}
|
|
167
373
|
/**
|
|
168
374
|
* A session packed for storage.
|
|
169
375
|
*
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* hosting/types — the
|
|
2
|
+
* hosting/types — the ports an agent needs to stand up and stay up.
|
|
3
3
|
*
|
|
4
|
-
* `AgentHost` is "something can call me". `
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* `AgentHost` is "something can call me". `ConversationHost` is "something can
|
|
5
|
+
* TALK to me" — a door that stays open, because `HostRequest → HostReply` is
|
|
6
|
+
* one exchange and some doors are not. `SessionLifecycle` is "the conversation
|
|
7
|
+
* outlives the request". All three are deliberately written in the vocabulary
|
|
8
|
+
* every transport and every store already has — an input, a reply, a frame, a
|
|
9
|
+
* session id, a stored blob — and in nothing else.
|
|
8
10
|
*
|
|
9
11
|
* **The rule these types are written under:** no runtime, product or protocol
|
|
10
12
|
* gets a field, a name or an assumption here. A port shaped around one
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/hosting/types.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/hosting/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hosting/webSocketConversation — one upgraded socket, presented as a
|
|
3
|
+
* {@link HostConversation}.
|
|
4
|
+
*
|
|
5
|
+
* This is the whole adapter side of the conversation port: it takes the
|
|
6
|
+
* `'upgrade'` event a `node:http` server hands it, answers the handshake, and
|
|
7
|
+
* turns the frames flowing over the socket into the six-member port a handler
|
|
8
|
+
* sees. Everything protocol-shaped lives in `webSocketFrames.ts`; everything
|
|
9
|
+
* port-shaped lives in `types.ts`; this file is the join.
|
|
10
|
+
*
|
|
11
|
+
* ── The laws it keeps, all of them pinned by tests ───────────────────────────
|
|
12
|
+
* - **A path this door does not own is not touched.** `node:http` calls EVERY
|
|
13
|
+
* `'upgrade'` listener for every upgrade, exactly as it calls every
|
|
14
|
+
* `'request'` listener — so a caller's own protocol lives beside this one on
|
|
15
|
+
* the same socket. Whether an unclaimed path gets an answer is the caller's
|
|
16
|
+
* business on a server they own, and this door's only on a server it owns.
|
|
17
|
+
* - **Frames that arrive before the handler subscribes are held, up to a
|
|
18
|
+
* declared bound.** An `async` handler that awaits before calling `onFrame`
|
|
19
|
+
* would otherwise lose the far side's opening frame. The bound is in BYTES
|
|
20
|
+
* and overflow ends the conversation with a stated reason, because an
|
|
21
|
+
* unbounded queue somebody else fills is a way to kill this process, and an
|
|
22
|
+
* undeclared ceiling is the exact thing the declared-ceilings rule exists to
|
|
23
|
+
* forbid.
|
|
24
|
+
* - **`close()` ends every live conversation before the socket is released.**
|
|
25
|
+
* Measured, not assumed: an upgraded socket keeps `server.close()` waiting
|
|
26
|
+
* forever, so a door that let go of its conversations would hang the whole
|
|
27
|
+
* shutdown.
|
|
28
|
+
* - **The port's frame is the whole message.** A message the transport
|
|
29
|
+
* delivered in fragments counts against `maxFrameBytes` in total, so
|
|
30
|
+
* fragmentation cannot be used to walk around the ceiling.
|
|
31
|
+
*
|
|
32
|
+
* Pattern: Adapter. Role: outer ring, one transport.
|
|
33
|
+
*/
|
|
34
|
+
/// <reference types="node" />
|
|
35
|
+
/// <reference types="node" />
|
|
36
|
+
import type { IncomingMessage } from 'node:http';
|
|
37
|
+
import type { Duplex } from 'node:stream';
|
|
38
|
+
import type { ConversationHandler, ConversationLimits } from './types.js';
|
|
39
|
+
/**
|
|
40
|
+
* What a wire read out of one handshake — the conversation half of a
|
|
41
|
+
* deployment's dialect.
|
|
42
|
+
*
|
|
43
|
+
* There is no body to read here, which is the whole difference from a request:
|
|
44
|
+
* a handshake is a URL and some headers, so a dialect that wants a session id
|
|
45
|
+
* or a credential has to find it in those.
|
|
46
|
+
*/
|
|
47
|
+
export interface ConversationHandshake {
|
|
48
|
+
/** The session this conversation claims. Caller data; never identity. */
|
|
49
|
+
readonly sessionId?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Headers to merge OVER the raw lower-cased ones the transport delivered.
|
|
52
|
+
*
|
|
53
|
+
* This is how an adapter maps its own spelling into the port's vocabulary —
|
|
54
|
+
* a credential a browser could only send as a subprotocol becomes an ordinary
|
|
55
|
+
* `authorization` header here. The raw headers are never removed, so nothing
|
|
56
|
+
* a mapping did not understand is lost.
|
|
57
|
+
*/
|
|
58
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
59
|
+
/**
|
|
60
|
+
* The subprotocol to echo in the 101, when this dialect selects one.
|
|
61
|
+
*
|
|
62
|
+
* Selecting is the wire's job because only the wire read the offer. Echoing
|
|
63
|
+
* something the client did not offer makes the client fail the connection.
|
|
64
|
+
*/
|
|
65
|
+
readonly protocol?: string;
|
|
66
|
+
}
|
|
67
|
+
/** Everything the door needs to answer one upgrade. */
|
|
68
|
+
export interface ConversationDoorOptions {
|
|
69
|
+
/** The adapter's name — every refusal carries it. */
|
|
70
|
+
readonly hostName: string;
|
|
71
|
+
/** The path this door owns. Anything else is not ours. */
|
|
72
|
+
readonly path: string;
|
|
73
|
+
/** The ceilings this door declares, already defaulted. */
|
|
74
|
+
readonly limits: ConversationLimits;
|
|
75
|
+
/** This deployment's handshake dialect. Absent ⇒ raw headers and no session. */
|
|
76
|
+
readonly readConversation?: (facts: HandshakeFacts) => ConversationHandshake;
|
|
77
|
+
/** Where a new conversation goes. */
|
|
78
|
+
readonly handler: ConversationHandler;
|
|
79
|
+
/** Whether the host is still taking conversations — false after `close()`. */
|
|
80
|
+
readonly accepting: () => boolean;
|
|
81
|
+
}
|
|
82
|
+
/** What a handshake dialect may read. Deliberately the same shape a request wire gets, minus the body. */
|
|
83
|
+
export interface HandshakeFacts {
|
|
84
|
+
/** Header names lower-cased. */
|
|
85
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
86
|
+
/** The query string, already parsed — where a browser has to put things it cannot header. */
|
|
87
|
+
readonly query: URLSearchParams;
|
|
88
|
+
}
|
|
89
|
+
/** A door: hand it upgrades, close it when you are done. */
|
|
90
|
+
export interface ConversationDoor {
|
|
91
|
+
/**
|
|
92
|
+
* Take one upgrade, or decline it. Returns whether this door claimed the
|
|
93
|
+
* path — the caller decides what an unclaimed upgrade deserves.
|
|
94
|
+
*/
|
|
95
|
+
handleUpgrade(request: IncomingMessage, socket: Duplex): boolean;
|
|
96
|
+
/** End every live conversation politely, then resolve. */
|
|
97
|
+
closeAll(reason: string): Promise<void>;
|
|
98
|
+
/** How many conversations are open right now. */
|
|
99
|
+
readonly liveCount: number;
|
|
100
|
+
}
|
|
101
|
+
export declare function conversationDoor(options: ConversationDoorOptions): ConversationDoor;
|