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.
Files changed (65) hide show
  1. package/common/index.ts +37 -0
  2. package/common/tunnelWire.ts +355 -0
  3. package/common/websocketHeartbeat.ts +27 -0
  4. package/fetch/client/wsClient.ts +42 -2
  5. package/index.ts +1 -0
  6. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  7. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  8. package/package.json +6 -1
  9. package/server/logging/logStreamRoute.ts +1 -1
  10. package/server/mcp/McpEventStream.ts +1 -1
  11. package/server/routing/apiRouter.ts +5 -1
  12. package/server/tunnel/TunnelAgent.ts +250 -0
  13. package/server/tunnel/TunnelDataSocket.ts +140 -0
  14. package/server/tunnel/TunnelHttpStream.ts +93 -0
  15. package/server/tunnel/TunnelTcpStream.ts +100 -0
  16. package/server/tunnel/TunnelWebsocketStream.ts +115 -0
  17. package/server/tunnel/index.ts +6 -0
  18. package/server/tunnel/tunnelStream.ts +44 -0
  19. package/server/webRouter.ts +0 -96
  20. package/signal/agentTurnStream.ts +31 -31
  21. package/store/agent/AgentCursor.ts +97 -0
  22. package/store/agent/AgentVisual.ts +102 -0
  23. package/store/agent/ScreenFlash.ts +78 -0
  24. package/store/agent/ScreenTarget.ts +11 -2
  25. package/store/agent/StoreSurfaceSource.ts +2 -30
  26. package/store/agent/index.ts +3 -0
  27. package/types/common/index.d.ts +3 -0
  28. package/types/common/tunnelWire.d.ts +253 -0
  29. package/types/common/websocketHeartbeat.d.ts +25 -0
  30. package/types/dictionary/base.dictionary.d.ts +1 -1
  31. package/types/dictionary/dictionary.d.ts +8 -8
  32. package/types/index.d.ts +1 -0
  33. package/types/server/mcp/McpEventStream.d.ts +1 -1
  34. package/types/server/tunnel/TunnelAgent.d.ts +33 -0
  35. package/types/server/tunnel/TunnelDataSocket.d.ts +25 -0
  36. package/types/server/tunnel/TunnelHttpStream.d.ts +10 -0
  37. package/types/server/tunnel/TunnelTcpStream.d.ts +14 -0
  38. package/types/server/tunnel/TunnelWebsocketStream.d.ts +10 -0
  39. package/types/server/tunnel/index.d.ts +6 -0
  40. package/types/server/tunnel/tunnelStream.d.ts +30 -0
  41. package/types/server/tunnel.d.ts +1 -0
  42. package/types/signal/agentTurnStream.d.ts +3 -1
  43. package/types/store/agent/AgentCursor.d.ts +25 -0
  44. package/types/store/agent/AgentVisual.d.ts +36 -0
  45. package/types/store/agent/ScreenFlash.d.ts +45 -0
  46. package/types/store/agent/ScreenTarget.d.ts +5 -0
  47. package/types/store/agent/StoreSurfaceSource.d.ts +0 -4
  48. package/types/store/agent/index.d.ts +3 -0
  49. package/types/ui/Agent/Chat.d.ts +9 -1
  50. package/types/ui/Agent/Zone.d.ts +9 -1
  51. package/types/ui/Agent/agentSessionOf.d.ts +4 -1
  52. package/types/ui/Agent/index.d.ts +1 -1
  53. package/types/ui/recipe/inputRecipe.d.ts +2 -2
  54. package/types/vendor/use-agentic/AgentSession.d.ts +7 -1
  55. package/types/vendor/use-agentic/ToolRunner.d.ts +7 -1
  56. package/types/vendor/use-agentic/types.d.ts +13 -0
  57. package/ui/Agent/Chat.tsx +10 -1
  58. package/ui/Agent/Zone.tsx +10 -0
  59. package/ui/Agent/agentSessionOf.ts +6 -1
  60. package/ui/styles.css +102 -1
  61. package/vendor/use-agentic/AgentSession.ts +8 -0
  62. package/vendor/use-agentic/ToolRunner.ts +19 -2
  63. package/vendor/use-agentic/types.ts +14 -0
  64. /package/{server/routing → common}/eventStream.ts +0 -0
  65. /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;
@@ -1,4 +1,10 @@
1
- import { Logger, websocketAuthContract, websocketBinaryFrameContract } from "akanjs/common";
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>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-beta.10",
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 "../routing/eventStream";
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,4 +1,4 @@
1
- import { EventStream } from "../routing/eventStream";
1
+ import { EventStream } from "akanjs/common";
2
2
 
3
3
  /**
4
4
  * One `text/event-stream` response carrying the notifications a single request produced, ending with that
@@ -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 =