akanjs 3.0.0-beta.10 → 3.0.0-beta.11
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/common/index.ts +37 -0
- package/common/tunnelWire.ts +355 -0
- package/common/websocketHeartbeat.ts +27 -0
- package/fetch/client/wsClient.ts +42 -2
- package/index.ts +1 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +6 -1
- package/server/logging/logStreamRoute.ts +1 -1
- package/server/mcp/McpEventStream.ts +1 -1
- package/server/routing/apiRouter.ts +5 -1
- package/server/tunnel/TunnelAgent.ts +250 -0
- package/server/tunnel/TunnelDataSocket.ts +140 -0
- package/server/tunnel/TunnelHttpStream.ts +93 -0
- package/server/tunnel/TunnelTcpStream.ts +100 -0
- package/server/tunnel/TunnelWebsocketStream.ts +115 -0
- package/server/tunnel/index.ts +6 -0
- package/server/tunnel/tunnelStream.ts +44 -0
- package/server/webRouter.ts +0 -96
- package/signal/agentTurnStream.ts +31 -31
- package/store/agent/AgentCursor.ts +97 -0
- package/store/agent/AgentVisual.ts +102 -0
- package/store/agent/ScreenFlash.ts +78 -0
- package/store/agent/ScreenTarget.ts +11 -2
- package/store/agent/StoreSurfaceSource.ts +2 -30
- package/store/agent/index.ts +3 -0
- package/types/common/index.d.ts +3 -0
- package/types/common/tunnelWire.d.ts +253 -0
- package/types/common/websocketHeartbeat.d.ts +25 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/index.d.ts +1 -0
- package/types/server/mcp/McpEventStream.d.ts +1 -1
- package/types/server/tunnel/TunnelAgent.d.ts +33 -0
- package/types/server/tunnel/TunnelDataSocket.d.ts +25 -0
- package/types/server/tunnel/TunnelHttpStream.d.ts +10 -0
- package/types/server/tunnel/TunnelTcpStream.d.ts +14 -0
- package/types/server/tunnel/TunnelWebsocketStream.d.ts +10 -0
- package/types/server/tunnel/index.d.ts +6 -0
- package/types/server/tunnel/tunnelStream.d.ts +30 -0
- package/types/server/tunnel.d.ts +1 -0
- package/types/signal/agentTurnStream.d.ts +3 -1
- package/types/store/agent/AgentCursor.d.ts +25 -0
- package/types/store/agent/AgentVisual.d.ts +36 -0
- package/types/store/agent/ScreenFlash.d.ts +45 -0
- package/types/store/agent/ScreenTarget.d.ts +5 -0
- package/types/store/agent/StoreSurfaceSource.d.ts +0 -4
- package/types/store/agent/index.d.ts +3 -0
- package/types/ui/Agent/Chat.d.ts +9 -1
- package/types/ui/Agent/Zone.d.ts +9 -1
- package/types/ui/Agent/agentSessionOf.d.ts +4 -1
- package/types/ui/Agent/index.d.ts +1 -1
- package/types/ui/recipe/inputRecipe.d.ts +2 -2
- package/types/vendor/use-agentic/AgentSession.d.ts +7 -1
- package/types/vendor/use-agentic/ToolRunner.d.ts +7 -1
- package/types/vendor/use-agentic/types.d.ts +13 -0
- package/ui/Agent/Chat.tsx +10 -1
- package/ui/Agent/Zone.tsx +10 -0
- package/ui/Agent/agentSessionOf.ts +6 -1
- package/ui/styles.css +102 -1
- package/vendor/use-agentic/AgentSession.ts +8 -0
- package/vendor/use-agentic/ToolRunner.ts +19 -2
- package/vendor/use-agentic/types.ts +14 -0
- /package/{server/routing → common}/eventStream.ts +0 -0
- /package/types/{server/routing → common}/eventStream.d.ts +0 -0
package/common/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
} from "./clientAddress";
|
|
18
18
|
export { deepObjectify } from "./deepObjectify";
|
|
19
19
|
export type { DynamicRecord } from "./dynamicRecord";
|
|
20
|
+
export { EventStream, type EventStreamOptions } from "./eventStream";
|
|
20
21
|
export {
|
|
21
22
|
type FileUploadCapability,
|
|
22
23
|
fileUploadContract,
|
|
@@ -112,6 +113,37 @@ export { splitVersion } from "./splitVersion";
|
|
|
112
113
|
export { getBasePathFromPathname, parseBasePaths, parseSubRouteHosts, resolveSubRouteHosts } from "./subRoute";
|
|
113
114
|
export { TrustedProxy } from "./TrustedProxy";
|
|
114
115
|
export { toPathSegments } from "./toPathSegments";
|
|
116
|
+
export {
|
|
117
|
+
type TunnelAgentIdentity,
|
|
118
|
+
type TunnelAttachedFrame,
|
|
119
|
+
type TunnelAttachFrame,
|
|
120
|
+
type TunnelByeFrame,
|
|
121
|
+
type TunnelCloseCode,
|
|
122
|
+
type TunnelControlFromAgent,
|
|
123
|
+
type TunnelControlFromGateway,
|
|
124
|
+
type TunnelDataFromAgent,
|
|
125
|
+
type TunnelDataFromGateway,
|
|
126
|
+
type TunnelDemandFrame,
|
|
127
|
+
type TunnelEndFrame,
|
|
128
|
+
type TunnelFrame,
|
|
129
|
+
type TunnelHeaderList,
|
|
130
|
+
type TunnelHeadFrame,
|
|
131
|
+
type TunnelHelloFrame,
|
|
132
|
+
type TunnelOpenFrame,
|
|
133
|
+
type TunnelPingFrame,
|
|
134
|
+
type TunnelPongFrame,
|
|
135
|
+
type TunnelReadyFrame,
|
|
136
|
+
type TunnelReleaseFrame,
|
|
137
|
+
type TunnelResetCode,
|
|
138
|
+
type TunnelResetFrame,
|
|
139
|
+
type TunnelStreamKind,
|
|
140
|
+
type TunnelWsPayloadKind,
|
|
141
|
+
tunnelCloseCode,
|
|
142
|
+
tunnelForwardedHeaders,
|
|
143
|
+
tunnelHopByHopHeaders,
|
|
144
|
+
tunnelWireContract,
|
|
145
|
+
tunnelWsPayload,
|
|
146
|
+
} from "./tunnelWire";
|
|
115
147
|
export type * from "./types";
|
|
116
148
|
export {
|
|
117
149
|
type WebsocketAuthAckData,
|
|
@@ -119,3 +151,8 @@ export {
|
|
|
119
151
|
websocketAuthContract,
|
|
120
152
|
} from "./websocketAuth";
|
|
121
153
|
export { type WebsocketBinaryFrame, websocketBinaryFrameContract } from "./websocketBinaryFrame";
|
|
154
|
+
export {
|
|
155
|
+
type WebsocketHeartbeatAckData,
|
|
156
|
+
type WebsocketHeartbeatRequest,
|
|
157
|
+
websocketHeartbeatContract,
|
|
158
|
+
} from "./websocketHeartbeat";
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { forwardedHeaders } from "./clientAddress";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The wire between a tunnel agent (inside the `akan` CLI, on a private machine) and the tunnel gateway (public,
|
|
5
|
+
* in the cluster). It is the only thing the two sides share, so it lives here rather than in either of them.
|
|
6
|
+
*
|
|
7
|
+
* **Shape: one control socket plus a pool of data sockets, one stream per data socket.** No multiplexing and no
|
|
8
|
+
* hand-written flow control — the runtime already does per-socket flow control, and a single multiplexed socket
|
|
9
|
+
* would have to re-implement windowing to keep one large download from starving every other stream. The cost is
|
|
10
|
+
* a socket per concurrent stream, which pooling amortizes.
|
|
11
|
+
*
|
|
12
|
+
* **Framing is the WebSocket frame type.** A text frame is always one JSON control message of this module; a
|
|
13
|
+
* binary frame is always payload. Nothing else needs a header, a length prefix, or a stream id on the hot path,
|
|
14
|
+
* because a data socket carries exactly one stream at a time.
|
|
15
|
+
*
|
|
16
|
+
* Every socket is dialled by the agent — the gateway can never reach in. That is the whole reason a tunnel needs
|
|
17
|
+
* no public IP, no inbound port, and no NAT traversal.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `http` and `tcp` bodies are raw binary frames; a `websocket` stream prefixes each one — see `tunnelWsPayload`.
|
|
22
|
+
*
|
|
23
|
+
* `tcp` describes the **provider** side only: an agent dialling a local port. What opens a `tcp` stream in the
|
|
24
|
+
* first place — a local listener somewhere else that a person points `ssh` at — is undefined in version 1, and
|
|
25
|
+
* naming that role is a version 1 addition rather than a breaking change.
|
|
26
|
+
*/
|
|
27
|
+
export type TunnelStreamKind = "http" | "websocket" | "tcp";
|
|
28
|
+
|
|
29
|
+
export type TunnelResetCode =
|
|
30
|
+
| "originUnreachable"
|
|
31
|
+
| "originTimeout"
|
|
32
|
+
| "originRefused"
|
|
33
|
+
| "protocol"
|
|
34
|
+
| "canceled"
|
|
35
|
+
| "tooLarge"
|
|
36
|
+
| "internal";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pairs rather than a record: `set-cookie` legitimately repeats, and a record keyed by name keeps only the last
|
|
40
|
+
* one — which silently drops every session cookie but one on a sign-in response.
|
|
41
|
+
*/
|
|
42
|
+
export type TunnelHeaderList = [string, string][];
|
|
43
|
+
|
|
44
|
+
export interface TunnelAgentIdentity {
|
|
45
|
+
/** What the operator called this share, for the gateway's log and the control plane's UI. */
|
|
46
|
+
name: string;
|
|
47
|
+
version: string;
|
|
48
|
+
platform: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TunnelHelloFrame {
|
|
52
|
+
type: "hello";
|
|
53
|
+
version: number;
|
|
54
|
+
/** What the agent intends to serve. The gateway answers with what the token actually grants. */
|
|
55
|
+
hostnames: string[];
|
|
56
|
+
agent: TunnelAgentIdentity;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TunnelReadyFrame {
|
|
60
|
+
type: "ready";
|
|
61
|
+
version: number;
|
|
62
|
+
sessionId: string;
|
|
63
|
+
/** Authoritative — the agent serves these and nothing else, whatever it asked for. */
|
|
64
|
+
hostnames: string[];
|
|
65
|
+
/** The public URLs, already assembled, so the CLI prints what the gateway believes rather than guessing. */
|
|
66
|
+
urls: string[];
|
|
67
|
+
/** How many idle data sockets to hold, and the ceiling past which the agent refuses to grow the pool. */
|
|
68
|
+
idle: number;
|
|
69
|
+
maxSockets: number;
|
|
70
|
+
heartbeatMs: number;
|
|
71
|
+
/** Epoch ms, when the control plane put a TTL on this share. */
|
|
72
|
+
expiresAt?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TunnelPingFrame {
|
|
76
|
+
type: "ping";
|
|
77
|
+
at: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface TunnelPongFrame {
|
|
81
|
+
type: "pong";
|
|
82
|
+
at: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The gateway noticed the idle pool running short; the agent grows it to `idle`, capped by `maxSockets`. */
|
|
86
|
+
export interface TunnelDemandFrame {
|
|
87
|
+
type: "demand";
|
|
88
|
+
idle: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A graceful goodbye from either side, so the peer stops reconnecting instead of treating it as a drop. */
|
|
92
|
+
export interface TunnelByeFrame {
|
|
93
|
+
type: "bye";
|
|
94
|
+
reason?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type TunnelControlFromAgent = TunnelHelloFrame | TunnelPingFrame | TunnelPongFrame | TunnelByeFrame;
|
|
98
|
+
export type TunnelControlFromGateway =
|
|
99
|
+
| TunnelReadyFrame
|
|
100
|
+
| TunnelPingFrame
|
|
101
|
+
| TunnelPongFrame
|
|
102
|
+
| TunnelDemandFrame
|
|
103
|
+
| TunnelByeFrame;
|
|
104
|
+
|
|
105
|
+
/** First frame on a data socket. The session id names which agent connection this socket belongs to. */
|
|
106
|
+
export interface TunnelAttachFrame {
|
|
107
|
+
type: "attach";
|
|
108
|
+
version: number;
|
|
109
|
+
sessionId: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The socket joined the idle pool. Until this arrives the agent must not count it toward `idle`. */
|
|
113
|
+
export interface TunnelAttachedFrame {
|
|
114
|
+
type: "attached";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface TunnelOpenFrame {
|
|
118
|
+
type: "open";
|
|
119
|
+
streamId: string;
|
|
120
|
+
kind: TunnelStreamKind;
|
|
121
|
+
/** Which tunnel host the public caller addressed, for an agent serving more than one. */
|
|
122
|
+
hostname: string;
|
|
123
|
+
/** `http` and `websocket`. */
|
|
124
|
+
method?: string;
|
|
125
|
+
/** `http` and `websocket`: path plus query, already percent-encoded. */
|
|
126
|
+
path?: string;
|
|
127
|
+
/**
|
|
128
|
+
* `http` and `websocket`. The agent replays this list verbatim, so the gateway owes it two things.
|
|
129
|
+
*
|
|
130
|
+
* **Strip every name in `forwardedHeaderNames` from what the public caller sent, then write its own.** Not
|
|
131
|
+
* append — `hostFromRequest` reads `x-forwarded-host?.split(",")[0]`, so a caller-supplied value placed ahead
|
|
132
|
+
* of the gateway's wins, and the app computes its own origin from a host the caller chose. That is Host header
|
|
133
|
+
* injection: every absolute URL the app builds — a redirect, a password-reset link, an OAuth callback — points
|
|
134
|
+
* at the attacker's host.
|
|
135
|
+
*
|
|
136
|
+
* **Write `x-forwarded-host` as the tunnel hostname.** It is what `hostFromRequest` reads, so the app behind
|
|
137
|
+
* the tunnel resolves its own origin as the public URL and `CrossSiteGuard` compares the host the browser
|
|
138
|
+
* actually addressed. Leave it off and every mutation from a tunnelled page is refused.
|
|
139
|
+
*
|
|
140
|
+
* Hop-by-hop names are stripped by both ends independently (`isHopByHop`); these cannot be, because only the
|
|
141
|
+
* gateway knows who the public caller was.
|
|
142
|
+
*/
|
|
143
|
+
headers?: TunnelHeaderList;
|
|
144
|
+
/** `tcp`: the port on the local origin to dial. */
|
|
145
|
+
port?: number;
|
|
146
|
+
/** Whether payload frames follow this one. Absent or false means the request is complete as sent. */
|
|
147
|
+
body?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface TunnelHeadFrame {
|
|
151
|
+
type: "head";
|
|
152
|
+
streamId: string;
|
|
153
|
+
/**
|
|
154
|
+
* `101` accepts a `websocket` open and `200` accepts a `tcp` one; any other status is the agent declining, and
|
|
155
|
+
* the gateway answers the public caller with it rather than upgrading.
|
|
156
|
+
*/
|
|
157
|
+
status: number;
|
|
158
|
+
statusText?: string;
|
|
159
|
+
headers: TunnelHeaderList;
|
|
160
|
+
body?: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** No more payload in the direction this was sent. Each direction ends independently. */
|
|
164
|
+
export interface TunnelEndFrame {
|
|
165
|
+
type: "end";
|
|
166
|
+
streamId: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The stream failed. The socket is not reusable after one and is closed rather than returned to the pool. */
|
|
170
|
+
export interface TunnelResetFrame {
|
|
171
|
+
type: "reset";
|
|
172
|
+
streamId: string;
|
|
173
|
+
code: TunnelResetCode;
|
|
174
|
+
message?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Gateway → agent, after both directions have ended: the socket is idle again.
|
|
179
|
+
*
|
|
180
|
+
* Only `http` streams are released. A `websocket` or `tcp` stream owns its socket for its whole life and the
|
|
181
|
+
* socket closes with it — resynchronizing a raw byte stream back to an idle pool buys nothing and is a place
|
|
182
|
+
* for a desync to hide.
|
|
183
|
+
*/
|
|
184
|
+
export interface TunnelReleaseFrame {
|
|
185
|
+
type: "release";
|
|
186
|
+
streamId: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A payload frame carries no stream id — a data socket holds one stream at a time, which is what keeps the hot
|
|
191
|
+
* path free of a header. Three invariants are what make that safe, and an optimization that breaks any one of
|
|
192
|
+
* them desynchronizes the socket silently:
|
|
193
|
+
*
|
|
194
|
+
* 1. Neither side sends a payload frame after its own `end` for that stream.
|
|
195
|
+
* 2. `release` goes out only once *both* directions have ended.
|
|
196
|
+
* 3. A JSON frame naming a `streamId` other than the current one is dropped — that is what the id is for.
|
|
197
|
+
*/
|
|
198
|
+
export type TunnelDataFromAgent = TunnelAttachFrame | TunnelHeadFrame | TunnelEndFrame | TunnelResetFrame;
|
|
199
|
+
export type TunnelDataFromGateway =
|
|
200
|
+
| TunnelAttachedFrame
|
|
201
|
+
| TunnelOpenFrame
|
|
202
|
+
| TunnelEndFrame
|
|
203
|
+
| TunnelResetFrame
|
|
204
|
+
| TunnelReleaseFrame;
|
|
205
|
+
|
|
206
|
+
export type TunnelFrame =
|
|
207
|
+
| TunnelControlFromAgent
|
|
208
|
+
| TunnelControlFromGateway
|
|
209
|
+
| TunnelDataFromAgent
|
|
210
|
+
| TunnelDataFromGateway;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A tunnelled WebSocket's own text/binary distinction cannot ride the tunnel socket's, which is already spoken
|
|
214
|
+
* for by the control/payload split — so a `websocket` stream prefixes every payload frame with one byte. `http`
|
|
215
|
+
* and `tcp` payload frames carry no prefix; their bytes are a stream with nothing to distinguish.
|
|
216
|
+
*/
|
|
217
|
+
export const tunnelWsPayload = {
|
|
218
|
+
text: 0,
|
|
219
|
+
binary: 1,
|
|
220
|
+
/** Remainder is UTF-8 JSON `{ code, reason }` — the inner close, which is not the tunnel socket closing. */
|
|
221
|
+
close: 2,
|
|
222
|
+
} as const;
|
|
223
|
+
|
|
224
|
+
export type TunnelWsPayloadKind = (typeof tunnelWsPayload)[keyof typeof tunnelWsPayload];
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Hop-by-hop headers, which describe one connection and are meaningless on the next. The gateway strips them
|
|
228
|
+
* from what it forwards and the agent strips them from what it sends back, so neither end has to trust the other
|
|
229
|
+
* to have done it.
|
|
230
|
+
*/
|
|
231
|
+
export const tunnelHopByHopHeaders = [
|
|
232
|
+
"connection",
|
|
233
|
+
"keep-alive",
|
|
234
|
+
"proxy-authenticate",
|
|
235
|
+
"proxy-authorization",
|
|
236
|
+
"proxy-connection",
|
|
237
|
+
"te",
|
|
238
|
+
"trailer",
|
|
239
|
+
"transfer-encoding",
|
|
240
|
+
"upgrade",
|
|
241
|
+
] as const;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Close codes, and the **only** way a refusal reaches the agent. The 4000 range is the application range every
|
|
245
|
+
* WebSocket implementation passes through verbatim; an HTTP status does not survive the upgrade, because a
|
|
246
|
+
* refused upgrade reaches the client as a bare `1006` with no code and no reason. The agent treats these codes
|
|
247
|
+
* as final and every other close as a dropped link to retry — so a gateway that answers `401` at the upgrade
|
|
248
|
+
* instead of accepting it and closing with `unauthorized` makes an agent retry a revoked token forever.
|
|
249
|
+
*
|
|
250
|
+
* **Accept the upgrade, then close on a later tick.** Closing inside the socket's own `open` handler races the
|
|
251
|
+
* handshake the runtime is still finishing, and the client sees `1006` again with the code discarded.
|
|
252
|
+
*/
|
|
253
|
+
export const tunnelCloseCode = {
|
|
254
|
+
unauthorized: 4001,
|
|
255
|
+
unsupportedVersion: 4002,
|
|
256
|
+
hostnameNotGranted: 4003,
|
|
257
|
+
unknownSession: 4004,
|
|
258
|
+
/** A newer session for the same tunnel took over; this one must not reconnect. */
|
|
259
|
+
superseded: 4005,
|
|
260
|
+
streamLimit: 4008,
|
|
261
|
+
goingAway: 4009,
|
|
262
|
+
} as const;
|
|
263
|
+
|
|
264
|
+
export type TunnelCloseCode = (typeof tunnelCloseCode)[keyof typeof tunnelCloseCode];
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Headers naming the original caller. The gateway deletes these from what arrived before writing its own — see
|
|
268
|
+
* `TunnelOpenFrame.headers`. `forwarded` is RFC 7239's single-header form of the same claim, and omitting it
|
|
269
|
+
* would leave one spelling of the injection open.
|
|
270
|
+
*/
|
|
271
|
+
export const tunnelForwardedHeaders = [...forwardedHeaders, "forwarded"] as const;
|
|
272
|
+
|
|
273
|
+
const hopByHop = new Set<string>(tunnelHopByHopHeaders);
|
|
274
|
+
|
|
275
|
+
export const tunnelWireContract = {
|
|
276
|
+
version: 1,
|
|
277
|
+
controlPath: "/_tunnel/control",
|
|
278
|
+
dataPath: "/_tunnel/data",
|
|
279
|
+
/**
|
|
280
|
+
* Both upgrades carry `Authorization: Bearer <connectorToken>` and nothing else — no code in the path, no
|
|
281
|
+
* query, no second header. Keeping the URL bare is what keeps the tunnel's identity out of every access log
|
|
282
|
+
* between here and the gateway, so the token is also the *only* thing the gateway can resolve a tunnel from:
|
|
283
|
+
* it owns a `token -> { code, hostnames }` lookup, and the agent never names which tunnel it is.
|
|
284
|
+
*/
|
|
285
|
+
authScheme: "Bearer",
|
|
286
|
+
defaultIdleSockets: 4,
|
|
287
|
+
defaultMaxSockets: 128,
|
|
288
|
+
defaultHeartbeatMs: 30_000,
|
|
289
|
+
|
|
290
|
+
/** What a sender splits an `http` or `tcp` payload to. A boundary means nothing in a byte stream. */
|
|
291
|
+
chunkBytes: 64 * 1024,
|
|
292
|
+
/**
|
|
293
|
+
* What both ends must accept — `maxPayloadLength` on every tunnel socket is set to at least this.
|
|
294
|
+
*
|
|
295
|
+
* It clears 16 MB because an inner websocket message is a **message**: splitting it loses the boundary the
|
|
296
|
+
* receiver needs, so it rides as one frame, and `akanApp` lets an app's own sockets carry 16 MB. The default
|
|
297
|
+
* `maxPayloadLength` is 16 MB, which the largest legal inner message plus its one-byte prefix exceeds by
|
|
298
|
+
* exactly the amount that makes a 100 MB upload fail on the frame that carries its last kilobyte.
|
|
299
|
+
*/
|
|
300
|
+
maxFrameBytes: 16 * 1024 * 1024 + 4096,
|
|
301
|
+
|
|
302
|
+
/** Deletes every `forwardedHeaderNames` entry, so the gateway writes its own onto a clean slate. */
|
|
303
|
+
stripForwarded: (headers: Headers): Headers => {
|
|
304
|
+
for (const name of tunnelForwardedHeaders) headers.delete(name);
|
|
305
|
+
return headers;
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
forwardedHeaderNames: tunnelForwardedHeaders,
|
|
309
|
+
|
|
310
|
+
isHopByHop: (name: string): boolean => hopByHop.has(name.toLowerCase()),
|
|
311
|
+
|
|
312
|
+
/** `Headers.forEach` combines repeats into one comma-joined value — except `set-cookie`, which it yields one
|
|
313
|
+
* at a time. That exception is what makes this lossless. */
|
|
314
|
+
headerList: (headers: Headers): TunnelHeaderList => {
|
|
315
|
+
const list: TunnelHeaderList = [];
|
|
316
|
+
headers.forEach((value, name) => {
|
|
317
|
+
if (!hopByHop.has(name.toLowerCase())) list.push([name, value]);
|
|
318
|
+
});
|
|
319
|
+
return list;
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
headersOf: (list: TunnelHeaderList): Headers => {
|
|
323
|
+
const headers = new Headers();
|
|
324
|
+
for (const [name, value] of list) {
|
|
325
|
+
if (!hopByHop.has(name.toLowerCase())) headers.append(name, value);
|
|
326
|
+
}
|
|
327
|
+
return headers;
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
encodeWsPayload: (kind: TunnelWsPayloadKind, data: Uint8Array): Uint8Array => {
|
|
331
|
+
const framed = new Uint8Array(data.byteLength + 1);
|
|
332
|
+
framed[0] = kind;
|
|
333
|
+
framed.set(data, 1);
|
|
334
|
+
return framed;
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
decodeWsPayload: (frame: Uint8Array): { kind: TunnelWsPayloadKind; data: Uint8Array } | null => {
|
|
338
|
+
if (!frame.byteLength) return null;
|
|
339
|
+
const kind = frame[0] as TunnelWsPayloadKind;
|
|
340
|
+
if (kind !== tunnelWsPayload.text && kind !== tunnelWsPayload.binary && kind !== tunnelWsPayload.close) return null;
|
|
341
|
+
return { kind, data: frame.subarray(1) };
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
/** A frame that does not parse is a protocol error, never something to guess at — hence `null`, not a throw. */
|
|
345
|
+
parseFrame: (raw: string): TunnelFrame | null => {
|
|
346
|
+
try {
|
|
347
|
+
const frame = JSON.parse(raw) as TunnelFrame;
|
|
348
|
+
return frame && typeof frame === "object" && typeof (frame as { type?: unknown }).type === "string"
|
|
349
|
+
? frame
|
|
350
|
+
: null;
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
} as const;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface WebsocketHeartbeatRequest {
|
|
2
|
+
key: string;
|
|
3
|
+
data: [];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface WebsocketHeartbeatAckData {
|
|
7
|
+
type: "pong";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Framework-owned websocket keep-alive. A browser cannot send a protocol-level ping frame — `WebSocket` exposes
|
|
12
|
+
* no API for one — so a socket that nobody publishes to carries no bytes at all, and every intermediary with an
|
|
13
|
+
* idle timeout (nginx `proxy_read_timeout` 60s, most CDNs, a tunnel gateway) reaps it. The server's own
|
|
14
|
+
* `idleTimeout` is 0, so this exists for what sits between.
|
|
15
|
+
*
|
|
16
|
+
* The ack matters as much as the ping: a half-open socket still accepts `send()` without error, so inbound
|
|
17
|
+
* traffic is the only evidence the peer is still there.
|
|
18
|
+
*/
|
|
19
|
+
export const websocketHeartbeatContract = {
|
|
20
|
+
key: "__ping",
|
|
21
|
+
/** Under the 60s idle timeout nginx and most CDNs ship with. */
|
|
22
|
+
intervalMs: 45_000,
|
|
23
|
+
/** How long without any inbound frame before the socket is assumed half-open and reconnected. */
|
|
24
|
+
silenceMs: 45_000 * 3,
|
|
25
|
+
makeRequest: (): WebsocketHeartbeatRequest => ({ key: "__ping", data: [] }),
|
|
26
|
+
makeAck: (): WebsocketHeartbeatAckData => ({ type: "pong" }),
|
|
27
|
+
} as const;
|
package/fetch/client/wsClient.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Logger,
|
|
3
|
+
type WebsocketHeartbeatAckData,
|
|
4
|
+
websocketAuthContract,
|
|
5
|
+
websocketBinaryFrameContract,
|
|
6
|
+
websocketHeartbeatContract,
|
|
7
|
+
} from "akanjs/common";
|
|
2
8
|
import type {
|
|
3
9
|
WebsocketAuthAck,
|
|
4
10
|
WebsocketMessageData,
|
|
@@ -63,6 +69,8 @@ export class WsClient {
|
|
|
63
69
|
#outbox: string[] = [];
|
|
64
70
|
#unconnectedWarnTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
65
71
|
#jwt: string | null = null;
|
|
72
|
+
#heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
73
|
+
#lastInboundAt = 0;
|
|
66
74
|
connected = false;
|
|
67
75
|
|
|
68
76
|
constructor(
|
|
@@ -109,6 +117,7 @@ export class WsClient {
|
|
|
109
117
|
this.#reconnectAttempts = 0;
|
|
110
118
|
this.connected = true;
|
|
111
119
|
this.logger.debug(`WebSocket connected`);
|
|
120
|
+
this.#startHeartbeat();
|
|
112
121
|
|
|
113
122
|
if (this.#jwt) this.#sendAuth();
|
|
114
123
|
const reconnected = this.#hadConnection;
|
|
@@ -124,6 +133,7 @@ export class WsClient {
|
|
|
124
133
|
for (const frame of queued) this.#ws?.send(frame);
|
|
125
134
|
};
|
|
126
135
|
this.#ws.onmessage = (e) => {
|
|
136
|
+
this.#lastInboundAt = Date.now();
|
|
127
137
|
try {
|
|
128
138
|
if (typeof e.data !== "string") {
|
|
129
139
|
const frame = websocketBinaryFrameContract.decode(e.data as ArrayBuffer);
|
|
@@ -135,7 +145,7 @@ export class WsClient {
|
|
|
135
145
|
if (parsed?.error) {
|
|
136
146
|
throw this.#restoreError(parsed);
|
|
137
147
|
}
|
|
138
|
-
const type = (parsed as WebsocketResData).type;
|
|
148
|
+
const type = (parsed as WebsocketResData | WebsocketHeartbeatAckData).type;
|
|
139
149
|
switch (type) {
|
|
140
150
|
case "msg": {
|
|
141
151
|
const msg = parsed as unknown as WebsocketMessageData;
|
|
@@ -157,6 +167,8 @@ export class WsClient {
|
|
|
157
167
|
this.#handlePubsub(publishData.roomId, publishData.data);
|
|
158
168
|
break;
|
|
159
169
|
}
|
|
170
|
+
case "pong":
|
|
171
|
+
break;
|
|
160
172
|
case "auth": {
|
|
161
173
|
const ack = parsed as WebsocketAuthAck;
|
|
162
174
|
for (const roomId of ack.revokedRooms) {
|
|
@@ -182,10 +194,37 @@ export class WsClient {
|
|
|
182
194
|
this.#ws.onclose = (event) => {
|
|
183
195
|
this.logger.debug(`WebSocket closed: ${event.code} ${event.reason}`);
|
|
184
196
|
this.connected = false;
|
|
197
|
+
this.#stopHeartbeat();
|
|
185
198
|
this.#scheduleReconnect();
|
|
186
199
|
};
|
|
187
200
|
}
|
|
188
201
|
|
|
202
|
+
#startHeartbeat() {
|
|
203
|
+
this.#stopHeartbeat();
|
|
204
|
+
this.#lastInboundAt = Date.now();
|
|
205
|
+
this.#heartbeatTimer = setInterval(() => this.#beat(), websocketHeartbeatContract.intervalMs);
|
|
206
|
+
|
|
207
|
+
this.#heartbeatTimer.unref?.();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#stopHeartbeat() {
|
|
211
|
+
if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
|
|
212
|
+
this.#heartbeatTimer = null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#beat() {
|
|
216
|
+
const ws = this.#ws;
|
|
217
|
+
if (ws?.readyState !== WebSocket.OPEN) return;
|
|
218
|
+
|
|
219
|
+
if (Date.now() - this.#lastInboundAt > websocketHeartbeatContract.silenceMs) {
|
|
220
|
+
this.logger.warn(`WebSocket is silent, reconnecting`);
|
|
221
|
+
this.#stopHeartbeat();
|
|
222
|
+
ws.close();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
ws.send(JSON.stringify(websocketHeartbeatContract.makeRequest()));
|
|
226
|
+
}
|
|
227
|
+
|
|
189
228
|
#scheduleReconnect() {
|
|
190
229
|
if (this.#destroyed || !this.#ws) return;
|
|
191
230
|
const interval = 3000;
|
|
@@ -223,6 +262,7 @@ export class WsClient {
|
|
|
223
262
|
this.logger.debug(`WebSocket destroying`);
|
|
224
263
|
this.#destroyed = true;
|
|
225
264
|
this.#connectRequested = false;
|
|
265
|
+
this.#stopHeartbeat();
|
|
226
266
|
if (this.#reconnectTimer) {
|
|
227
267
|
clearTimeout(this.#reconnectTimer);
|
|
228
268
|
this.#reconnectTimer = null;
|
package/index.ts
CHANGED
|
@@ -178,6 +178,7 @@ export interface AkanSyncContext {
|
|
|
178
178
|
readonly executor: AkanExecutor;
|
|
179
179
|
getPath(rel: string): string;
|
|
180
180
|
fileExists(rel: string): Promise<boolean>;
|
|
181
|
+
readFile(rel: string): Promise<string>;
|
|
181
182
|
writeFile(rel: string, content: string, opts?: { overwrite?: boolean }): Promise<void>;
|
|
182
183
|
/** Resolves `env/env.client.ts` and returns its exported `env`, or null when absent/invalid. */
|
|
183
184
|
readEnvClient(): Promise<Record<string, unknown> | null>;
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akanjs",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.11",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -171,6 +171,11 @@
|
|
|
171
171
|
"import": "./server/akanApp.ts",
|
|
172
172
|
"default": "./server/akanApp.ts"
|
|
173
173
|
},
|
|
174
|
+
"./server/tunnel": {
|
|
175
|
+
"types": "./types/server/tunnel/index.d.ts",
|
|
176
|
+
"import": "./server/tunnel/index.ts",
|
|
177
|
+
"default": "./server/tunnel/index.ts"
|
|
178
|
+
},
|
|
174
179
|
"./server/memoryLimit": {
|
|
175
180
|
"types": "./types/server/memoryLimit.d.ts",
|
|
176
181
|
"import": "./server/memoryLimit.ts",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
|
-
import { EventStream } from "
|
|
2
|
+
import { EventStream } from "akanjs/common";
|
|
3
3
|
import type { LogHub, LogHubEntry } from "./logHub";
|
|
4
4
|
import { LogQueryMatcher } from "./logQuery";
|
|
5
5
|
import { LogStdoutWriter } from "./logStdoutWriter";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { dayjs } from "akanjs/base";
|
|
2
|
-
import { type Logger, websocketAuthContract } from "akanjs/common";
|
|
2
|
+
import { type Logger, websocketAuthContract, websocketHeartbeatContract } from "akanjs/common";
|
|
3
3
|
import type { InjectRegistry, LiveRegistry } from "akanjs/service";
|
|
4
4
|
import { isExceptionLike, SignalContext, SignalFailure, type WebsocketReqData } from "akanjs/signal";
|
|
5
5
|
import { compressResponse } from "../contentEncoding";
|
|
@@ -143,6 +143,10 @@ export class ApiRouter {
|
|
|
143
143
|
ws.send(JSON.stringify(websocketAuthContract.makeAck(revokedRooms)));
|
|
144
144
|
return;
|
|
145
145
|
}
|
|
146
|
+
if (msg.key === websocketHeartbeatContract.key) {
|
|
147
|
+
ws.send(JSON.stringify(websocketHeartbeatContract.makeAck()));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
146
150
|
const wsRoute = wsRoutes[msg.key];
|
|
147
151
|
if (!wsRoute) throw new Error(`WebSocket route "${msg.key}" is not registered`);
|
|
148
152
|
const eventType =
|