akanjs 3.0.0-alpha.63 → 3.0.0-alpha.64

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 (46) hide show
  1. package/base/primitiveRegistry.ts +45 -2
  2. package/common/clientAddress.ts +40 -0
  3. package/common/index.ts +7 -0
  4. package/common/mcpExposure.ts +2 -0
  5. package/common/websocketBinaryFrame.ts +46 -0
  6. package/constant/via.ts +4 -1
  7. package/fetch/client/wsClient.ts +9 -2
  8. package/fetch/fetchType/appliedReturn.type.ts +7 -0
  9. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  10. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  11. package/package.json +1 -1
  12. package/server/akanApp.ts +6 -6
  13. package/server/akanAppHeaders.ts +14 -2
  14. package/server/akanServer.ts +19 -10
  15. package/server/binaryPubsub.ts +55 -0
  16. package/server/resolver/signal.resolver.ts +25 -5
  17. package/server/routing/apiRouter.ts +4 -0
  18. package/server/routing/appWsData.ts +16 -1
  19. package/server/types.tsx +2 -0
  20. package/service/ipcTypes.ts +1 -0
  21. package/service/predefinedAdaptor/compress.adaptor.ts +5 -1
  22. package/signal/internalArg.ts +13 -0
  23. package/signal/openapi/openapi.ts +1 -0
  24. package/signal/schema/JsonSchemaBuilder.ts +2 -0
  25. package/signal/signalContext.ts +22 -0
  26. package/signal/types.ts +7 -0
  27. package/test/sampleOf.ts +2 -0
  28. package/types/base/primitiveRegistry.d.ts +14 -2
  29. package/types/common/clientAddress.d.ts +19 -0
  30. package/types/common/index.d.ts +2 -0
  31. package/types/common/websocketBinaryFrame.d.ts +17 -0
  32. package/types/fetch/fetchType/appliedReturn.type.d.ts +7 -0
  33. package/types/server/akanAppHeaders.d.ts +7 -1
  34. package/types/server/binaryPubsub.d.ts +18 -0
  35. package/types/server/resolver/signal.resolver.d.ts +3 -2
  36. package/types/server/routing/apiRouter.d.ts +2 -1
  37. package/types/server/routing/appWsData.d.ts +6 -0
  38. package/types/server/types.d.ts +1 -0
  39. package/types/service/ipcTypes.d.ts +1 -0
  40. package/types/service/predefinedAdaptor/compress.adaptor.d.ts +1 -1
  41. package/types/signal/internalArg.d.ts +10 -0
  42. package/types/signal/signalContext.d.ts +12 -0
  43. package/types/signal/types.d.ts +7 -0
  44. package/ui/Data/Dashboard.tsx +2 -1
  45. package/ui/Data/QueryMaker.tsx +4 -4
  46. package/ui/Signal/Listener.tsx +16 -1
@@ -44,7 +44,7 @@ export class PrimitiveRegistry {
44
44
  }
45
45
  }
46
46
 
47
- export type PrimitiveValue = string | number | boolean | Dayjs | Date | null | undefined;
47
+ export type PrimitiveValue = string | number | boolean | Dayjs | Date | Uint8Array | null | undefined;
48
48
  export class PrimitiveScalar {
49
49
  static refName: string;
50
50
  static [SERVER_VALUE]: unknown;
@@ -176,6 +176,49 @@ export class Any extends PrimitiveScalar {
176
176
  }
177
177
  PrimitiveRegistry.register(Any);
178
178
 
179
+ type NodeBuffer = Uint8Array & { toString(encoding: string): string };
180
+ interface BufferCtor {
181
+ from(buffer: ArrayBufferLike, byteOffset: number, length: number): NodeBuffer;
182
+ from(text: string, encoding: string): NodeBuffer;
183
+ }
184
+
185
+ export class Binary extends PrimitiveScalar {
186
+ static override refName: "Binary" = "Binary";
187
+ static override [SERVER_VALUE]: Uint8Array;
188
+ static override [CLIENT_VALUE]: Uint8Array;
189
+ static override [DEFAULT_VALUE]: Uint8Array | null = null;
190
+ static override [PURIFIED_VALUE]: Uint8Array | null = null;
191
+ static override [EXAMPLE_VALUE]: string = "AAEC";
192
+
193
+ static override validate(value: Uint8Array | string): boolean {
194
+ return value instanceof Uint8Array || typeof value === "string";
195
+ }
196
+ static override parseValue(input: Uint8Array | string): Uint8Array {
197
+ return typeof input === "string" ? Binary.#fromBase64(input) : input;
198
+ }
199
+ static override serializeValue(value: Uint8Array | string): string {
200
+ return typeof value === "string" ? value : Binary.#toBase64(value);
201
+ }
202
+
203
+ static #toBase64(bytes: Uint8Array): string {
204
+ const buffer = (globalThis as { Buffer?: BufferCtor }).Buffer;
205
+ if (buffer) return buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
206
+ let binary = "";
207
+ for (let idx = 0; idx < bytes.length; idx += 0x8000)
208
+ binary += String.fromCharCode(...bytes.subarray(idx, idx + 0x8000));
209
+ return btoa(binary);
210
+ }
211
+ static #fromBase64(text: string): Uint8Array {
212
+ const buffer = (globalThis as { Buffer?: BufferCtor }).Buffer;
213
+ if (buffer) return buffer.from(text, "base64");
214
+ const binary = atob(text);
215
+ const bytes = new Uint8Array(binary.length);
216
+ for (let idx = 0; idx < binary.length; idx += 1) bytes[idx] = binary.charCodeAt(idx);
217
+ return bytes;
218
+ }
219
+ }
220
+ PrimitiveRegistry.register(Binary);
221
+
179
222
  export class Upload extends PrimitiveScalar {
180
223
  static override refName: "Upload" = "Upload";
181
224
  static override [SERVER_VALUE]: File;
@@ -354,4 +397,4 @@ Object.assign(Date, {
354
397
  });
355
398
  PrimitiveRegistry.register(Date);
356
399
 
357
- export type DefaultPrimitiveName = "String" | "Boolean" | "Date" | "Int" | "Float" | "ID" | "Any" | "Upload";
400
+ export type DefaultPrimitiveName = "String" | "Boolean" | "Date" | "Int" | "Float" | "ID" | "Any" | "Binary" | "Upload";
@@ -0,0 +1,40 @@
1
+ /** Headers a proxy hop writes so the next hop can still name the original caller. */
2
+ export const forwardedHeaders = [
3
+ "x-real-ip",
4
+ "x-forwarded-for",
5
+ "x-forwarded-port",
6
+ "x-forwarded-host",
7
+ "x-forwarded-proto",
8
+ ] as const;
9
+
10
+ /**
11
+ * An IPv4 client reaching a dual-stack listener is reported as `::ffff:203.0.113.10`. That form is not a
12
+ * valid destination for a `udp4` socket and does not compare equal to the same address written plainly, so
13
+ * it is unwrapped at every boundary rather than at each call site that happens to remember.
14
+ */
15
+ export const normalizeIpAddress = (address: string): string => {
16
+ const trimmed = address.trim();
17
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(trimmed);
18
+ return mapped?.[1] ?? trimmed;
19
+ };
20
+
21
+ /**
22
+ * The caller's address as the nearest proxy recorded it. `x-real-ip` is one hop's word for who the client is
23
+ * and wins; `x-forwarded-for` is a chain appended left to right, so its *first* entry is the original client
24
+ * and everything after it is a proxy.
25
+ *
26
+ * Returns `null` rather than a placeholder. A socket peer behind a proxy is the proxy, so `127.0.0.1` here
27
+ * would be indistinguishable from a genuinely local caller — and that is the failure this exists to prevent.
28
+ */
29
+ export const clientAddressFromHeaders = (headers: Headers): string | null => {
30
+ const realIp = headers.get("x-real-ip")?.trim();
31
+ if (realIp) return normalizeIpAddress(realIp);
32
+ const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
33
+ return forwarded ? normalizeIpAddress(forwarded) : null;
34
+ };
35
+
36
+ /** The client port the nearest proxy recorded, for correlating a connection with a peer's own logs. */
37
+ export const clientPortFromHeaders = (headers: Headers): number | null => {
38
+ const port = Number(headers.get("x-forwarded-port"));
39
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
40
+ };
package/common/index.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  export { applyMixins } from "./applyMixins";
2
2
  export { capitalize } from "./capitalize";
3
+ export {
4
+ clientAddressFromHeaders,
5
+ clientPortFromHeaders,
6
+ forwardedHeaders,
7
+ normalizeIpAddress,
8
+ } from "./clientAddress";
3
9
  export { deepObjectify } from "./deepObjectify";
4
10
  export {
5
11
  type FileUploadCapability,
@@ -76,3 +82,4 @@ export {
76
82
  type WebsocketAuthRequest,
77
83
  websocketAuthContract,
78
84
  } from "./websocketAuth";
85
+ export { type WebsocketBinaryFrame, websocketBinaryFrameContract } from "./websocketBinaryFrame";
@@ -67,6 +67,8 @@ export const mcpRefusalOf = (endpoint: McpExposureEndpoint, { readOnly }: McpExp
67
67
  return "this deployment is read-only, which drops every endpoint that is not a query.";
68
68
  if (endpoint.returns.refName === "Any" || endpoint.returns.refName === "Upload")
69
69
  return `a return typed \`${endpoint.returns.refName}\` cannot be described to a model.`;
70
+ if (endpoint.returns.refName === "Binary")
71
+ return "a return typed `Binary` is raw bytes, which cost a model its window and tell it nothing.";
70
72
  if (endpoint.fileUpload || endpoint.args.some((arg) => arg.refName === "Upload"))
71
73
  return "a file upload has no MCP representation.";
72
74
  if (endpoint.type === "mutation" && !endpoint.guards.some((name) => name !== "Public"))
@@ -0,0 +1,46 @@
1
+ export interface WebsocketBinaryFrame {
2
+ roomId: string;
3
+ payload: Uint8Array;
4
+ }
5
+
6
+ const MAGIC = 0xab;
7
+ const KIND_PUB = 0x01;
8
+ const HEADER_BYTES = 4;
9
+ const MAX_ROOM_BYTES = 0xffff;
10
+
11
+ const encoder = new TextEncoder();
12
+ const decoder = new TextDecoder();
13
+
14
+ /**
15
+ * Framework-owned binary pubsub frame, shared by the publisher and the client dispatcher. A room whose whole
16
+ * return is `Binary` sends its bytes in a websocket binary frame instead of the JSON `{ type: "pub" }` envelope,
17
+ * because `Buffer.toJSON()` turns bytes into `{ type: "Buffer", data: number[] }` — 3.6x the wire and ~300x the
18
+ * encode cost on a 64 KB payload, and a shape `JSON.parse` never restores.
19
+ *
20
+ * Text and binary frames coexist on one socket, so this is additive: every JSON endpoint is untouched, and a
21
+ * federation gateway relays a binary frame unchanged with no code of its own.
22
+ */
23
+ export const websocketBinaryFrameContract = {
24
+ encode: ({ roomId, payload }: WebsocketBinaryFrame): Uint8Array => {
25
+ const room = encoder.encode(roomId);
26
+ if (room.length > MAX_ROOM_BYTES) throw new Error(`Room id is too long to frame: ${roomId}`);
27
+ const frame = new Uint8Array(HEADER_BYTES + room.length + payload.length);
28
+ frame[0] = MAGIC;
29
+ frame[1] = KIND_PUB;
30
+ frame[2] = room.length >> 8;
31
+ frame[3] = room.length & 0xff;
32
+ frame.set(room, HEADER_BYTES);
33
+ frame.set(payload, HEADER_BYTES + room.length);
34
+ return frame;
35
+ },
36
+ decode: (data: ArrayBuffer | Uint8Array): WebsocketBinaryFrame | null => {
37
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
38
+ if (bytes.length < HEADER_BYTES || bytes[0] !== MAGIC || bytes[1] !== KIND_PUB) return null;
39
+ const roomBytes = (bytes[2] << 8) | bytes[3];
40
+ if (bytes.length < HEADER_BYTES + roomBytes) return null;
41
+ return {
42
+ roomId: decoder.decode(bytes.subarray(HEADER_BYTES, HEADER_BYTES + roomBytes)),
43
+ payload: bytes.subarray(HEADER_BYTES + roomBytes),
44
+ };
45
+ },
46
+ } as const;
package/constant/via.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Binary,
2
3
  CLIENT_VALUE,
3
4
  type Cls,
4
5
  DEFAULT_VALUE,
@@ -439,7 +440,9 @@ const applyConstantStatics = <Model>(model: ConstantCls<Model>, fieldMap: FieldO
439
440
  return { ...defaultValue };
440
441
  },
441
442
  });
442
- Object.entries(fieldMap).forEach(([, field]) => {
443
+ Object.entries(fieldMap).forEach(([key, field]) => {
444
+ if ((field.modelRef as unknown) === Binary || (field.of as unknown) === Binary)
445
+ throw new Error(`Field "${key}" is Binary, which is not storable. Reference the File model instead.`);
443
446
  if (field.enum) model.enums.add(field.enum);
444
447
  if (!field.isClass) return;
445
448
  if (field.isScalar) model.children.add(field.modelRef);
@@ -1,4 +1,4 @@
1
- import { Logger, websocketAuthContract } from "akanjs/common";
1
+ import { Logger, websocketAuthContract, websocketBinaryFrameContract } from "akanjs/common";
2
2
  import type {
3
3
  WebsocketAuthAck,
4
4
  WebsocketMessageData,
@@ -85,6 +85,7 @@ export class WsClient {
85
85
  if (this.#destroyed) return;
86
86
 
87
87
  this.#ws = new WebSocket(this.url);
88
+ this.#ws.binaryType = "arraybuffer";
88
89
  this.#ws.onopen = (e) => {
89
90
  this.#reconnectAttempts = 0;
90
91
  this.connected = true;
@@ -101,7 +102,13 @@ export class WsClient {
101
102
  };
102
103
  this.#ws.onmessage = (e) => {
103
104
  try {
104
- const parsed = typeof e.data === "string" ? JSON.parse(e.data) : e.data;
105
+ if (typeof e.data !== "string") {
106
+ const frame = websocketBinaryFrameContract.decode(e.data as ArrayBuffer);
107
+ if (frame) this.#handlePubsub(frame.roomId, frame.payload);
108
+ else this.logger.warn("Unknown binary WebSocket frame");
109
+ return;
110
+ }
111
+ const parsed = JSON.parse(e.data) as { error?: unknown } & WebsocketResData;
105
112
  if (parsed?.error) {
106
113
  throw this.#restoreError(parsed);
107
114
  }
@@ -16,6 +16,13 @@ export interface QuerySetting {
16
16
  * now — `() => [dayjs().subtract(1, "hour")]` — current at the moment the user asks for it.
17
17
  */
18
18
  args?: unknown[] | (() => unknown[]);
19
+ /**
20
+ * The same list under the name the rest of the framework already uses for it — the `queryArgsOf<Model>` state
21
+ * key, `refresh<Model>({ queryArgs })`, and a field's own `.meta(...)` declaration. Accepted so a query a
22
+ * field declares is one of these as it stands, rather than something every caller has to rename by hand.
23
+ * `args` wins when both are given.
24
+ */
25
+ queryArgs?: unknown[] | (() => unknown[]);
19
26
  }
20
27
 
21
28
  export type ServerInit<
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.63",
3
+ "version": "3.0.0-alpha.64",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -547,7 +547,7 @@ export class AkanApp {
547
547
  if (this.#isWebSocketPath(url.pathname)) return this.#upgradeWebSocket(req, server);
548
548
  const assetResponse = await this.#serveImmutableArtifact(req, url);
549
549
  if (assetResponse) return assetResponse;
550
- return await this.#proxyHttp(req);
550
+ return await this.#proxyHttp(req, server);
551
551
  }
552
552
 
553
553
  #isWebSocketPath(pathname: string) {
@@ -592,7 +592,7 @@ export class AkanApp {
592
592
  if (!child || !upstream) return new Response("No websocket upstream is ready", { status: 503 });
593
593
  const url = new URL(req.url);
594
594
  const upstreamWs = new WebSocket(`ws://${upstream.host}:${upstream.port}${url.pathname}${url.search}`, {
595
- headers: this.#makeProxyHeaders(req, child.idx),
595
+ headers: this.#makeProxyHeaders(req, child.idx, server),
596
596
  } as unknown as string[]);
597
597
 
598
598
  const upgraded = server.upgrade(req, { data: { childIdx: child.idx, upstream: upstreamWs } });
@@ -691,14 +691,14 @@ export class AkanApp {
691
691
  };
692
692
  }
693
693
 
694
- async #proxyHttp(req: Request): Promise<Response> {
694
+ async #proxyHttp(req: Request, server: Bun.Server<GatewayWsData>): Promise<Response> {
695
695
  const child = this.#pickFederationChild();
696
696
  if (!child?.upstream || child.upstream.type !== "unix") {
697
697
  return this.#respondWithCrashPage(req) ?? new Response("No healthy federation child is ready", { status: 503 });
698
698
  }
699
699
  const url = new URL(req.url);
700
700
  const upstreamUrl = `http://akan-child${url.pathname}${url.search}`;
701
- const headers = this.#makeProxyHeaders(req, child.idx);
701
+ const headers = this.#makeProxyHeaders(req, child.idx, server);
702
702
  child.metrics.activeRequests = (child.metrics.activeRequests ?? 0) + 1;
703
703
  child.metrics.totalRequests = (child.metrics.totalRequests ?? 0) + 1;
704
704
  const traced = isTraceEnabled();
@@ -867,8 +867,8 @@ export class AkanApp {
867
867
  return resolved;
868
868
  }
869
869
 
870
- #makeProxyHeaders(req: Request, childIdx: number) {
871
- return makeAkanChildProxyHeaders(req, childIdx);
870
+ #makeProxyHeaders(req: Request, childIdx: number, server?: Bun.Server<GatewayWsData>) {
871
+ return makeAkanChildProxyHeaders(req, childIdx, server?.requestIP(req));
872
872
  }
873
873
 
874
874
  #invalidateFederationChildCache() {
@@ -1,3 +1,5 @@
1
+ import { normalizeIpAddress } from "akanjs/common";
2
+
1
3
  const HOP_BY_HOP_HEADERS = new Set([
2
4
  "connection",
3
5
  "keep-alive",
@@ -9,13 +11,23 @@ const HOP_BY_HOP_HEADERS = new Set([
9
11
  "upgrade",
10
12
  ]);
11
13
 
12
- export function makeAkanChildProxyHeaders(req: Request, childIdx: number): Headers {
14
+ /** What `Server.requestIP` reports for the socket this hop accepted. */
15
+ export interface ProxyClientPeer {
16
+ address: string;
17
+ port: number;
18
+ family: string;
19
+ }
20
+
21
+ export function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?: ProxyClientPeer | null): Headers {
13
22
  const headers = new Headers(req.headers);
14
23
  for (const key of HOP_BY_HOP_HEADERS) headers.delete(key);
15
24
  const forwardedFor = headers.get("x-forwarded-for");
16
- const clientAddress = headers.get("x-real-ip") ?? "127.0.0.1";
25
+
26
+ const clientAddress = headers.get("x-real-ip") ?? (peer ? normalizeIpAddress(peer.address) : "127.0.0.1");
17
27
  const host = headers.get("host");
28
+ headers.set("x-real-ip", clientAddress);
18
29
  headers.set("x-forwarded-for", forwardedFor ? `${forwardedFor}, ${clientAddress}` : clientAddress);
30
+ if (peer && !headers.has("x-forwarded-port")) headers.set("x-forwarded-port", String(peer.port));
19
31
  headers.set("x-forwarded-host", headers.get("x-forwarded-host") ?? host ?? new URL(req.url).host);
20
32
  headers.set(
21
33
  "x-forwarded-proto",
@@ -1,5 +1,5 @@
1
1
  import { type BackendEnv, type BaseEnv, getEnv } from "akanjs/base";
2
- import { Logger } from "akanjs/common";
2
+ import { Logger, websocketBinaryFrameContract } from "akanjs/common";
3
3
  import { DictionaryLookup } from "akanjs/dictionary";
4
4
  import type {
5
5
  Adaptor,
@@ -16,6 +16,7 @@ import { createOpenApiDocument } from "../signal/openapi";
16
16
  import { FetchSerializer } from "../signal/serializer";
17
17
  import type { AkanLib, AkanLibProps } from "./akanLib";
18
18
  import type { BuilderRpc } from "./artifact";
19
+ import { BinaryPubsub } from "./binaryPubsub";
19
20
  import { DevtoolsRouter } from "./devtools";
20
21
  import { DiLifecycle } from "./di/diLifecycle";
21
22
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
@@ -27,7 +28,7 @@ import { WebProxyRunner } from "./proxy";
27
28
  import { SignalResolver } from "./resolver";
28
29
  import { ApiRouter } from "./routing/apiRouter";
29
30
  import type { AppWsData } from "./routing/appWsData";
30
- import type { HttpRoutes, SignalRoutes, WebsocketRoutes } from "./types";
31
+ import type { HttpRoutes, LocalPublish, SignalRoutes, WebsocketRoutes } from "./types";
31
32
  import type { WebRouter } from "./webRouter";
32
33
 
33
34
  export interface AkanServerProps extends AkanLibProps {
@@ -138,7 +139,8 @@ export class AkanServer {
138
139
  shutdownTimeoutMs = AkanServer.#defaultShutdownTimeoutMs();
139
140
 
140
141
  #di: DiLifecycle;
141
- #localPublish: ((roomId: string, data: object | object[]) => void) | null = null;
142
+ #localPublish: LocalPublish | null = null;
143
+ readonly #binaryPubsub = new BinaryPubsub();
142
144
  #metricsTimer: Timer | null = null;
143
145
  constructor(
144
146
  name = "AkanServer",
@@ -318,6 +320,7 @@ export class AkanServer {
318
320
  hmrHub,
319
321
  hmrState: webRouter ? { state: webRouter.renderState } : null,
320
322
  logger: this.logger,
323
+ onDrain: () => this.#binaryPubsub.flush(),
321
324
  }),
322
325
 
323
326
  data: {},
@@ -375,16 +378,20 @@ export class AkanServer {
375
378
 
376
379
  const websocket = this.#di.getWebsocketAdaptor();
377
380
  if (!websocket) throw new Error("WebSocket Redis adaptor is not registered");
378
- SignalResolver.setLocalPublish((roomId, data) => {
379
- const publishData: WebsocketPublishData = { type: "pub", roomId, data };
380
- server?.publish(roomId, JSON.stringify(publishData));
381
- wsServer?.publish(roomId, JSON.stringify(publishData));
382
- }, websocket);
383
- this.#localPublish = (roomId, data) => {
381
+ this.#binaryPubsub.setServers(server, wsServer);
382
+ const localPublish: LocalPublish = (roomId, data) => {
383
+ if (data instanceof Uint8Array) {
384
+ this.#binaryPubsub.publish(roomId, websocketBinaryFrameContract.encode({ roomId, payload: data }), {
385
+ coalesce: SignalResolver.coalescesRoom(roomId),
386
+ });
387
+ return;
388
+ }
384
389
  const publishData: WebsocketPublishData = { type: "pub", roomId, data };
385
390
  server?.publish(roomId, JSON.stringify(publishData));
386
391
  wsServer?.publish(roomId, JSON.stringify(publishData));
387
392
  };
393
+ SignalResolver.setLocalPublish(localPublish, websocket);
394
+ this.#localPublish = localPublish;
388
395
 
389
396
  this.status = "running";
390
397
  this.#startMetricsReporting();
@@ -479,7 +486,8 @@ export class AkanServer {
479
486
 
480
487
  #handleIpcMessage(message: AkanIpcMessage) {
481
488
  if (!message || typeof message !== "object") return;
482
- if (message.type === "pubsub.deliver") this.#localPublish?.(message.roomId, message.data as object | object[]);
489
+ if (message.type === "pubsub.deliver")
490
+ this.#localPublish?.(message.roomId, message.data as object | object[] | Uint8Array);
483
491
  else if (message.type === "health.ping")
484
492
  process.send?.({
485
493
  type: "health.pong",
@@ -506,6 +514,7 @@ export class AkanServer {
506
514
  async #reportMetrics() {
507
515
  const metrics = await ProcessMetricsCollector.collect({
508
516
  role: this.serverMode,
517
+ pubsubCoalesceCount: this.#binaryPubsub.coalescedCount,
509
518
  ...(this.#prepared?.webRouter?.getMetrics() ?? {}),
510
519
  });
511
520
  process.send?.({ type: "metrics.report", pid: process.pid, metrics } satisfies AkanIpcMessage);
@@ -0,0 +1,55 @@
1
+ import { Logger } from "akanjs/common";
2
+
3
+ /**
4
+ * Sends binary pubsub frames and absorbs a slow subscriber. `Server.publish` reports `-1` when the socket is
5
+ * backpressured, `0` when the room has no subscriber, and the byte count otherwise — so a room that cannot
6
+ * keep up parks its newest frame here and every earlier one is dropped, rather than queueing behind the
7
+ * slowest subscriber until the send buffer is the stream. Bun fires `drain` per socket when its buffer
8
+ * empties; that is what flushes the parked frames.
9
+ */
10
+ export class BinaryPubsub {
11
+ readonly #logger = new Logger("BinaryPubsub");
12
+ readonly #pending = new Map<string, Uint8Array>();
13
+ #servers: Bun.Server<unknown>[] = [];
14
+ #coalescedCount = 0;
15
+
16
+ get coalescedCount() {
17
+ return this.#coalescedCount;
18
+ }
19
+
20
+ setServers(...servers: (Bun.Server<never> | null)[]) {
21
+ this.#servers = servers.filter((server): server is Bun.Server<never> => !!server) as Bun.Server<unknown>[];
22
+ }
23
+
24
+ publish(roomId: string, frame: Uint8Array, { coalesce = false }: { coalesce?: boolean } = {}) {
25
+ if (!this.#send(roomId, frame) || !coalesce) return;
26
+ if (this.#pending.has(roomId)) this.#coalescedCount += 1;
27
+ this.#pending.set(roomId, frame);
28
+ }
29
+
30
+ /** Retries every parked room. A room still backpressured re-parks itself and waits for the next drain. */
31
+ flush() {
32
+ if (!this.#pending.size) return;
33
+ for (const [roomId, frame] of [...this.#pending]) {
34
+ this.#pending.delete(roomId);
35
+ if (this.#send(roomId, frame)) this.#pending.set(roomId, frame);
36
+ }
37
+ }
38
+
39
+ clear() {
40
+ this.#pending.clear();
41
+ }
42
+
43
+ /** True when at least one server refused the frame for backpressure, which is the only case worth parking. */
44
+ #send(roomId: string, frame: Uint8Array): boolean {
45
+ let backpressured = false;
46
+ for (const server of this.#servers) {
47
+ try {
48
+ if (server.publish(roomId, frame) === -1) backpressured = true;
49
+ } catch (error) {
50
+ this.#logger.warn(`Binary publish failed for ${roomId}: ${error instanceof Error ? error.message : error}`);
51
+ }
52
+ }
53
+ return backpressured;
54
+ }
55
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type BackendEnv,
3
+ Binary,
3
4
  type Cls,
4
5
  ENDPOINT_META,
5
6
  FIELD_META,
@@ -31,7 +32,7 @@ import { SignalContext, type WebSocketExecutionContext } from "../../signal/sign
31
32
  import type { SliceCls } from "../../signal/slice";
32
33
  import type { SliceInfo } from "../../signal/sliceInfo";
33
34
  import type { WebsocketMessageData, WebsocketSubscribeAck } from "../../signal/types";
34
- import type { HttpRoutes, SignalRoutes, WebsocketRoutes } from "../types";
35
+ import type { HttpRoutes, LocalPublish, SignalRoutes, WebsocketRoutes } from "../types";
35
36
 
36
37
  type HttpRouteHandler = (req: Bun.BunRequest) => Response | Promise<Response | undefined> | undefined;
37
38
  type HttpMethodRoutes = Record<string, HttpRouteHandler>;
@@ -42,12 +43,18 @@ export class SignalResolver {
42
43
  static makeRoomId(key: string, args: unknown[]) {
43
44
  return `${key}${args.length ? "-" : ""}${args.join("-")}`;
44
45
  }
45
- static #localPublish: (roomId: string, data: object | object[]) => void = () => {
46
+ static #localPublish: LocalPublish = () => {
46
47
  SignalResolver.logger.verbose(`Local publish is not initialized yet`);
47
48
  };
48
- static setLocalPublish(localPublish: (roomId: string, data: object | object[]) => void, websocket: WebsocketAdaptor) {
49
+ static readonly #coalescingRooms = new Set<string>();
50
+
51
+ static coalescesRoom(roomId: string): boolean {
52
+ const separator = roomId.indexOf("-");
53
+ return SignalResolver.#coalescingRooms.has(separator >= 0 ? roomId.slice(0, separator) : roomId);
54
+ }
55
+ static setLocalPublish(localPublish: LocalPublish, websocket: WebsocketAdaptor) {
49
56
  SignalResolver.#localPublish = localPublish;
50
- websocket.setEventHandler((roomId, data) => localPublish(roomId, data as object | object[]));
57
+ websocket.setEventHandler((roomId, data) => localPublish(roomId, data as object | object[] | Uint8Array));
51
58
  }
52
59
  static resolveServerSignal(
53
60
  serverSignalCls: ServerSignalCls,
@@ -59,6 +66,9 @@ export class SignalResolver {
59
66
  Object.entries(endpointMeta).forEach(([key, endpointInfo]) => {
60
67
  if (endpointInfo.type !== "pubsub") throw new Error(`Endpoint ${key} is not a pubsub endpoint`);
61
68
  websocket.registerEndpoint(key, endpointInfo.returns.returnRef as Cls, endpointInfo.returns.arrDepth);
69
+ const isBinaryFrame = endpointInfo.returns.returnRef === Binary && !endpointInfo.returns.arrDepth;
70
+ if (isBinaryFrame && endpointInfo.signalOption.backpressure !== "queue") SignalResolver.#coalescingRooms.add(key);
71
+ let warnedRawBytes = false;
62
72
  const serializeFn = (data: unknown) =>
63
73
  serialize(endpointInfo.returns.returnRef, endpointInfo.returns.arrDepth, data, "object", {
64
74
  nullable: endpointInfo.returns.nullable,
@@ -74,12 +84,22 @@ export class SignalResolver {
74
84
  registry,
75
85
  live,
76
86
  });
87
+ const roomId = SignalResolver.makeRoomId(key, roomArgs);
88
+ if (isBinaryFrame) {
89
+ const bytes = Binary._parse(resolvedData as Uint8Array) as Uint8Array;
90
+ websocket.publish(roomId, bytes);
91
+ SignalResolver.#localPublish(roomId, bytes);
92
+ return;
93
+ }
77
94
  const serializedData = serializeFn(resolvedData) as object | object[] | null;
78
95
  if (!serializedData) {
79
96
  this.logger.warn(`Failed to serialize data for ${key}`);
80
97
  return;
81
98
  }
82
- const roomId = SignalResolver.makeRoomId(key, roomArgs);
99
+ if (!warnedRawBytes && ArrayBuffer.isView(serializedData)) {
100
+ warnedRawBytes = true;
101
+ this.logger.warn(`${key} publishes bytes but does not return Binary; declare pubsub(Binary) instead.`);
102
+ }
83
103
  websocket.publish(roomId, serializedData);
84
104
  SignalResolver.#localPublish(roomId, serializedData);
85
105
  },
@@ -44,6 +44,7 @@ export interface WebsocketHandlersInputs {
44
44
  hmrHub: HmrWsHub | null;
45
45
  hmrState: HmrStateSource | null;
46
46
  logger: Logger;
47
+ onDrain?: () => void;
47
48
  }
48
49
 
49
50
  type WsTaggedData = { kind?: string };
@@ -111,8 +112,11 @@ export class ApiRouter {
111
112
  hmrHub,
112
113
  hmrState,
113
114
  logger,
115
+ onDrain,
114
116
  }: WebsocketHandlersInputs): Bun.WebSocketHandler<WsTaggedData> {
115
117
  return {
118
+
119
+ drain: () => onDrain?.(),
116
120
  open: (ws) => {
117
121
 
118
122
  const data = ws.data as WsTaggedData | undefined;
@@ -1,3 +1,5 @@
1
+ import { clientAddressFromHeaders, clientPortFromHeaders, forwardedHeaders, normalizeIpAddress } from "akanjs/common";
2
+
1
3
  const CREDENTIAL_HEADERS = ["authorization", "cookie", "user-agent"] as const;
2
4
 
3
5
  /**
@@ -9,7 +11,8 @@ const CREDENTIAL_HEADERS = ["authorization", "cookie", "user-agent"] as const;
9
11
  export class AppWsData {
10
12
  static fromRequest(req: Request): AppWsData {
11
13
  const headers = new Headers();
12
- for (const key of CREDENTIAL_HEADERS) {
14
+
15
+ for (const key of [...CREDENTIAL_HEADERS, ...forwardedHeaders]) {
13
16
  const value = req.headers.get(key);
14
17
  if (value) headers.set(key, value);
15
18
  }
@@ -48,6 +51,18 @@ export class AppWsData {
48
51
  * It outlives a credential swap on purpose: the socket is still the same socket.
49
52
  */
50
53
  socketId: string;
54
+ /** The caller's address as the nearest proxy recorded it, or null when nothing did. */
55
+ get ip(): string | null {
56
+ return clientAddressFromHeaders(this.headers);
57
+ }
58
+ /** The caller's source port as the nearest proxy recorded it, or null when nothing did. */
59
+ get port(): number | null {
60
+ return clientPortFromHeaders(this.headers);
61
+ }
62
+ /** The address to answer on: what a proxy recorded, else this socket's own peer. */
63
+ ipOf(ws: Bun.ServerWebSocket<unknown>): string | null {
64
+ return this.ip ?? (ws.remoteAddress ? normalizeIpAddress(ws.remoteAddress) : null);
65
+ }
51
66
  constructor(headers: Headers) {
52
67
  this.createdAt = Date.now();
53
68
  this.headers = headers;
package/server/types.tsx CHANGED
@@ -13,6 +13,8 @@ export type WebsocketRoutes = Record<string, WebsocketRoute>;
13
13
 
14
14
  export type HttpRoutes = Bun.Serve.Options<unknown>["routes"];
15
15
 
16
+ export type LocalPublish = (roomId: string, data: object | object[] | Uint8Array) => void;
17
+
16
18
  export interface SignalRouteOptions {
17
19
  globalPrefix?: false;
18
20
  }
@@ -37,6 +37,7 @@ export interface AkanMetricsReport {
37
37
  queueWakeCount?: number;
38
38
  pubsubDeliverCount?: number;
39
39
  pubsubDropCount?: number;
40
+ pubsubCoalesceCount?: number;
40
41
  rssBytes?: number;
41
42
  heapTotalBytes?: number;
42
43
  heapUsedBytes?: number;
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Any,
3
3
  applyFnToArrayObjects,
4
+ Binary,
4
5
  type Cls,
5
6
  type Dayjs,
6
7
  dayjs,
@@ -41,10 +42,12 @@ export interface CompressAdaptor {
41
42
  }
42
43
 
43
44
  export class JsonCompressor extends adapt("jsonCompressor", () => ({})) implements CompressAdaptor {
44
- encode(_ref: Cls, _arrDepth: number, value: unknown): Buffer | null {
45
+ encode(ref: Cls, arrDepth: number, value: unknown): Buffer | null {
46
+ if (ref === Binary && !arrDepth) return Buffer.from(value as Uint8Array);
45
47
  return Buffer.from(JSON.stringify(value));
46
48
  }
47
49
  decode<T = unknown>(ref: Cls, arrDepth: number, buffer: Buffer, { raw = false }: { raw?: boolean } = {}): T {
50
+ if (ref === Binary && !arrDepth) return buffer as T;
48
51
  const value = JSON.parse(buffer.toString()) as T;
49
52
  if (raw || arrDepth > 0 || PrimitiveRegistry.has(ref)) return value;
50
53
  return new (ref as ConstantCls)().set(value as object) as T;
@@ -59,6 +62,7 @@ export class ProtobufCompressor extends adapt("protobufCompressor", () => ({}))
59
62
  [Float, "float"],
60
63
  [Boolean, "bool"],
61
64
  [Date, "double"],
65
+ [Binary, "bytes"],
62
66
  ]);
63
67
  #primitiveProtoEncodeMap = new Map<PrimitiveScalar, (value: never) => unknown>([
64
68
  [Date, (value: Date | Dayjs) => dayjs(value).toDate().getTime()],
@@ -21,6 +21,19 @@ export class Res implements InternalArg {
21
21
  }
22
22
  }
23
23
 
24
+ /**
25
+ * Injects the caller's IP, as the nearest proxy recorded it rather than as the socket peer reports it.
26
+ * Behind the federation gateway every peer is the gateway, so an endpoint that reads `remoteAddress` sees
27
+ * `127.0.0.1` for every caller — this reads the forwarded headers first and falls back to the peer only
28
+ * when nothing proxied the call. IPv4 comes back unwrapped from `::ffff:`, so it can address a `udp4`
29
+ * socket. `null` when no proxy recorded one and the transport has no peer.
30
+ */
31
+ export class Ip implements InternalArg<string | null> {
32
+ getArg(context: SignalContext): string | null {
33
+ return context.getClientIp();
34
+ }
35
+ }
36
+
24
37
  /**
25
38
  * Injects websocket state, this connection's id, and subscription hooks into message/pubsub handlers.
26
39
  * `socketId` is the one `AppWsData` minted at the handshake, so a handler never reads `ws.data` to
@@ -210,6 +210,7 @@ const getProtectedGuards = (guards?: string[]) =>
210
210
 
211
211
  const isBinaryResponseEndpoint = (endpoint: SerializedEndpoint) =>
212
212
  endpoint.returns.refName === "Upload" ||
213
+ endpoint.returns.refName === "Binary" ||
213
214
  (endpoint.returns.refName === "Any" &&
214
215
  Boolean(endpoint.path?.includes("*") || endpoint.path?.toLowerCase().includes("blob")));
215
216
 
@@ -193,6 +193,8 @@ export class JsonSchemaBuilder {
193
193
  return { type: "integer" };
194
194
  case "Upload":
195
195
  return { type: "string", format: "binary" };
196
+ case "Binary":
197
+ return { type: "string", contentEncoding: "base64" };
196
198
  case "Any":
197
199
  return {};
198
200
  default:
@@ -8,6 +8,7 @@ import {
8
8
  type PromiseOrObject,
9
9
  Upload,
10
10
  } from "akanjs/base";
11
+ import { clientAddressFromHeaders, clientPortFromHeaders, normalizeIpAddress } from "akanjs/common";
11
12
  import {
12
13
  type ConstantCls,
13
14
  type ConstantFieldTypeInput,
@@ -429,6 +430,27 @@ export class SignalContext<
429
430
  if (this.transport === "http") return this.getHttpContext<{ [key: string]: T }>().req[key] ?? null;
430
431
  return this.getWebSocketContext<{ [key: string]: T }>().ws.data[key] ?? null;
431
432
  }
433
+ /**
434
+ * The caller's IP, preferring what a proxy recorded over the socket peer. Behind the federation gateway the
435
+ * peer is the gateway itself for every request and for the whole life of every socket, so `remoteAddress`
436
+ * alone names the wrong machine — which is why nothing here reads it first. IPv4 arrives unwrapped from its
437
+ * `::ffff:` form, so it can be used as a destination as well as an identity.
438
+ *
439
+ * `null` means no proxy recorded one and the transport has no peer to fall back on — never a placeholder,
440
+ * because a loopback-looking address for an unknown caller is the failure this replaced.
441
+ */
442
+ getClientIp(): string | null {
443
+ if (this.transport === "http") return clientAddressFromHeaders(this.getHttpContext().req.headers);
444
+ const { ws } = this.getWebSocketContext<{ headers?: Headers }>();
445
+ const forwarded = ws.data.headers ? clientAddressFromHeaders(ws.data.headers) : null;
446
+ return forwarded ?? (ws.remoteAddress ? normalizeIpAddress(ws.remoteAddress) : null);
447
+ }
448
+ /** The caller's source port as the nearest proxy recorded it, else this socket's own. */
449
+ getClientPort(): number | null {
450
+ if (this.transport === "http") return clientPortFromHeaders(this.getHttpContext().req.headers);
451
+ const { ws } = this.getWebSocketContext<{ headers?: Headers }>();
452
+ return (ws.data.headers ? clientPortFromHeaders(ws.data.headers) : null) ?? null;
453
+ }
432
454
  getRoomId(key: string) {
433
455
  if (this.transport !== "websocket") throw new Error("Transport is not websocket");
434
456
  else if (this.endpointInfo.type !== "pubsub") throw new Error("Endpoint is not pubsub");
package/signal/types.ts CHANGED
@@ -85,6 +85,13 @@ export interface SignalOption<Response = any, Nullable extends boolean = false,
85
85
  method?: HttpMutationMethod;
86
86
  /** Marks this mutation as the framework file-upload endpoint (see resolveFileUploadCapability). */
87
87
  fileUpload?: boolean;
88
+ /**
89
+ * What a `pubsub(Binary)` does when a subscriber cannot keep up. `"coalesce"` (the default) keeps only the
90
+ * newest frame per room, which is what a telemetry or video stream wants — an old frame is worthless once a
91
+ * newer one exists. Name `"queue"` when the frames are a sequence a subscriber has to see in full, such as
92
+ * deltas against a base it already holds; the send buffer then grows with the slowest subscriber.
93
+ */
94
+ backpressure?: "coalesce" | "queue";
88
95
 
89
96
  scheduleType?: "init" | "destroy" | "cron" | "interval" | "timeout";
90
97
  scheduleCron?: string;
package/test/sampleOf.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  Any,
3
+ Binary,
3
4
  type Cls,
4
5
  FIELD_META,
5
6
  Float,
@@ -28,6 +29,7 @@ const scalarSampleMap = new Map<PrimitiveScalar, () => any>([
28
29
  [Boolean, () => sample.bool()],
29
30
  [Date, () => sample.dayjs()],
30
31
  [Upload, () => "FileUpload"],
32
+ [Binary, () => new Uint8Array([0, 1, 2])],
31
33
  [Any, () => ({})],
32
34
  ]);
33
35
  const getPrimitiveSample = (ref: Cls, field: ConstantField) => {
@@ -17,7 +17,7 @@ export declare class PrimitiveRegistry {
17
17
  static getNames(): string[];
18
18
  static getAll(): (typeof PrimitiveScalar)[];
19
19
  }
20
- export type PrimitiveValue = string | number | boolean | Dayjs | Date | null | undefined;
20
+ export type PrimitiveValue = string | number | boolean | Dayjs | Date | Uint8Array | null | undefined;
21
21
  export declare class PrimitiveScalar {
22
22
  static refName: string;
23
23
  static [SERVER_VALUE]: unknown;
@@ -83,6 +83,18 @@ export declare class Any extends PrimitiveScalar {
83
83
  static [DEFAULT_VALUE]: object | null;
84
84
  static [EXAMPLE_VALUE]: object;
85
85
  }
86
+ export declare class Binary extends PrimitiveScalar {
87
+ #private;
88
+ static refName: "Binary";
89
+ static [SERVER_VALUE]: Uint8Array;
90
+ static [CLIENT_VALUE]: Uint8Array;
91
+ static [DEFAULT_VALUE]: Uint8Array | null;
92
+ static [PURIFIED_VALUE]: Uint8Array | null;
93
+ static [EXAMPLE_VALUE]: string;
94
+ static validate(value: Uint8Array | string): boolean;
95
+ static parseValue(input: Uint8Array | string): Uint8Array;
96
+ static serializeValue(value: Uint8Array | string): string;
97
+ }
86
98
  export declare class Upload extends PrimitiveScalar {
87
99
  static refName: "Upload";
88
100
  static [SERVER_VALUE]: File;
@@ -136,4 +148,4 @@ declare global {
136
148
  _checkValue(value: Date): void;
137
149
  }
138
150
  }
139
- export type DefaultPrimitiveName = "String" | "Boolean" | "Date" | "Int" | "Float" | "ID" | "Any" | "Upload";
151
+ export type DefaultPrimitiveName = "String" | "Boolean" | "Date" | "Int" | "Float" | "ID" | "Any" | "Binary" | "Upload";
@@ -0,0 +1,19 @@
1
+ /** Headers a proxy hop writes so the next hop can still name the original caller. */
2
+ export declare const forwardedHeaders: readonly ["x-real-ip", "x-forwarded-for", "x-forwarded-port", "x-forwarded-host", "x-forwarded-proto"];
3
+ /**
4
+ * An IPv4 client reaching a dual-stack listener is reported as `::ffff:203.0.113.10`. That form is not a
5
+ * valid destination for a `udp4` socket and does not compare equal to the same address written plainly, so
6
+ * it is unwrapped at every boundary rather than at each call site that happens to remember.
7
+ */
8
+ export declare const normalizeIpAddress: (address: string) => string;
9
+ /**
10
+ * The caller's address as the nearest proxy recorded it. `x-real-ip` is one hop's word for who the client is
11
+ * and wins; `x-forwarded-for` is a chain appended left to right, so its *first* entry is the original client
12
+ * and everything after it is a proxy.
13
+ *
14
+ * Returns `null` rather than a placeholder. A socket peer behind a proxy is the proxy, so `127.0.0.1` here
15
+ * would be indistinguishable from a genuinely local caller — and that is the failure this exists to prevent.
16
+ */
17
+ export declare const clientAddressFromHeaders: (headers: Headers) => string | null;
18
+ /** The client port the nearest proxy recorded, for correlating a connection with a peer's own logs. */
19
+ export declare const clientPortFromHeaders: (headers: Headers) => number | null;
@@ -1,5 +1,6 @@
1
1
  export { applyMixins } from "./applyMixins.d.ts";
2
2
  export { capitalize } from "./capitalize.d.ts";
3
+ export { clientAddressFromHeaders, clientPortFromHeaders, forwardedHeaders, normalizeIpAddress, } from "./clientAddress.d.ts";
3
4
  export { deepObjectify } from "./deepObjectify.d.ts";
4
5
  export { type FileUploadCapability, fileUploadContract, resolveFileUploadCapability, } from "./fileUpload.d.ts";
5
6
  export { formatNumber } from "./formatNumber.d.ts";
@@ -31,3 +32,4 @@ export { splitVersion } from "./splitVersion.d.ts";
31
32
  export { getBasePathFromPathname, parseBasePaths, parseSubRouteHosts, resolveSubRouteHosts } from "./subRoute.d.ts";
32
33
  export type * from "./types.d.ts";
33
34
  export { type WebsocketAuthAckData, type WebsocketAuthRequest, websocketAuthContract, } from "./websocketAuth.d.ts";
35
+ export { type WebsocketBinaryFrame, websocketBinaryFrameContract } from "./websocketBinaryFrame.d.ts";
@@ -0,0 +1,17 @@
1
+ export interface WebsocketBinaryFrame {
2
+ roomId: string;
3
+ payload: Uint8Array;
4
+ }
5
+ /**
6
+ * Framework-owned binary pubsub frame, shared by the publisher and the client dispatcher. A room whose whole
7
+ * return is `Binary` sends its bytes in a websocket binary frame instead of the JSON `{ type: "pub" }` envelope,
8
+ * because `Buffer.toJSON()` turns bytes into `{ type: "Buffer", data: number[] }` — 3.6x the wire and ~300x the
9
+ * encode cost on a 64 KB payload, and a shape `JSON.parse` never restores.
10
+ *
11
+ * Text and binary frames coexist on one socket, so this is additive: every JSON endpoint is untouched, and a
12
+ * federation gateway relays a binary frame unchanged with no code of its own.
13
+ */
14
+ export declare const websocketBinaryFrameContract: {
15
+ readonly encode: ({ roomId, payload }: WebsocketBinaryFrame) => Uint8Array;
16
+ readonly decode: (data: ArrayBuffer | Uint8Array) => WebsocketBinaryFrame | null;
17
+ };
@@ -14,6 +14,13 @@ export interface QuerySetting {
14
14
  * now — `() => [dayjs().subtract(1, "hour")]` — current at the moment the user asks for it.
15
15
  */
16
16
  args?: unknown[] | (() => unknown[]);
17
+ /**
18
+ * The same list under the name the rest of the framework already uses for it — the `queryArgsOf<Model>` state
19
+ * key, `refresh<Model>({ queryArgs })`, and a field's own `.meta(...)` declaration. Accepted so a query a
20
+ * field declares is one of these as it stands, rather than something every caller has to rename by hand.
21
+ * `args` wins when both are given.
22
+ */
23
+ queryArgs?: unknown[] | (() => unknown[]);
17
24
  }
18
25
  export type ServerInit<RefName extends string, Light, Insight = any, QueryArgs = any, Filter extends FilterInstance = any, _CapitalizedRefName extends string = Capitalize<RefName>, _LightObj = GetStateObject<Light>, _InsightObj = GetStateObject<Insight>, _Sort = ExtractSort<Filter>> = SliceMeta & {
19
26
  [K in `${RefName}ObjList`]: _LightObj[];
@@ -1 +1,7 @@
1
- export declare function makeAkanChildProxyHeaders(req: Request, childIdx: number): Headers;
1
+ /** What `Server.requestIP` reports for the socket this hop accepted. */
2
+ export interface ProxyClientPeer {
3
+ address: string;
4
+ port: number;
5
+ family: string;
6
+ }
7
+ export declare function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?: ProxyClientPeer | null): Headers;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Sends binary pubsub frames and absorbs a slow subscriber. `Server.publish` reports `-1` when the socket is
3
+ * backpressured, `0` when the room has no subscriber, and the byte count otherwise — so a room that cannot
4
+ * keep up parks its newest frame here and every earlier one is dropped, rather than queueing behind the
5
+ * slowest subscriber until the send buffer is the stream. Bun fires `drain` per socket when its buffer
6
+ * empties; that is what flushes the parked frames.
7
+ */
8
+ export declare class BinaryPubsub {
9
+ #private;
10
+ get coalescedCount(): number;
11
+ setServers(...servers: (Bun.Server<never> | null)[]): void;
12
+ publish(roomId: string, frame: Uint8Array, { coalesce }?: {
13
+ coalesce?: boolean;
14
+ }): void;
15
+ /** Retries every parked room. A room still backpressured re-parks itself and waits for the next drain. */
16
+ flush(): void;
17
+ clear(): void;
18
+ }
@@ -7,12 +7,13 @@ import type { InternalInfo } from "../../signal/internalInfo.d.ts";
7
7
  import type { MiddlewareCls } from "../../signal/middleware.d.ts";
8
8
  import type { ServerSignalCls } from "../../signal/serverSignal.d.ts";
9
9
  import type { SliceCls } from "../../signal/slice.d.ts";
10
- import type { HttpRoutes, SignalRoutes } from "../types.d.ts";
10
+ import type { HttpRoutes, LocalPublish, SignalRoutes } from "../types.d.ts";
11
11
  export declare class SignalResolver {
12
12
  #private;
13
13
  static logger: Logger;
14
14
  static makeRoomId(key: string, args: unknown[]): string;
15
- static setLocalPublish(localPublish: (roomId: string, data: object | object[]) => void, websocket: WebsocketAdaptor): void;
15
+ static coalescesRoom(roomId: string): boolean;
16
+ static setLocalPublish(localPublish: LocalPublish, websocket: WebsocketAdaptor): void;
16
17
  static resolveServerSignal(serverSignalCls: ServerSignalCls, { registry, live }: {
17
18
  registry: InjectRegistry;
18
19
  live: LiveRegistry;
@@ -37,6 +37,7 @@ export interface WebsocketHandlersInputs {
37
37
  hmrHub: HmrWsHub | null;
38
38
  hmrState: HmrStateSource | null;
39
39
  logger: Logger;
40
+ onDrain?: () => void;
40
41
  }
41
42
  type WsTaggedData = {
42
43
  kind?: string;
@@ -56,6 +57,6 @@ export declare class ApiRouter {
56
57
  * - `kind === "akan-hmr"` — dev HMR, delegated to `HmrWsHub`.
57
58
  * - everything else — app signal channel, dispatched via `wsRoutes`.
58
59
  */
59
- static buildWebsocketHandlers({ wsRoutes, registry, hmrHub, hmrState, logger, }: WebsocketHandlersInputs): Bun.WebSocketHandler<WsTaggedData>;
60
+ static buildWebsocketHandlers({ wsRoutes, registry, hmrHub, hmrState, logger, onDrain, }: WebsocketHandlersInputs): Bun.WebSocketHandler<WsTaggedData>;
60
61
  }
61
62
  export {};
@@ -26,5 +26,11 @@ export declare class AppWsData {
26
26
  * It outlives a credential swap on purpose: the socket is still the same socket.
27
27
  */
28
28
  socketId: string;
29
+ /** The caller's address as the nearest proxy recorded it, or null when nothing did. */
30
+ get ip(): string | null;
31
+ /** The caller's source port as the nearest proxy recorded it, or null when nothing did. */
32
+ get port(): number | null;
33
+ /** The address to answer on: what a proxy recorded, else this socket's own peer. */
34
+ ipOf(ws: Bun.ServerWebSocket<unknown>): string | null;
29
35
  constructor(headers: Headers);
30
36
  }
@@ -5,6 +5,7 @@ import type { SsrManifest } from "./ssrTypes.d.ts";
5
5
  export type WebsocketRoute = (ws: Bun.ServerWebSocket<unknown>, data: unknown[], event?: "message" | "subscribe" | "unsubscribe") => PromiseOrObject<unknown>;
6
6
  export type WebsocketRoutes = Record<string, WebsocketRoute>;
7
7
  export type HttpRoutes = Bun.Serve.Options<unknown>["routes"];
8
+ export type LocalPublish = (roomId: string, data: object | object[] | Uint8Array) => void;
8
9
  export interface SignalRouteOptions {
9
10
  globalPrefix?: false;
10
11
  }
@@ -41,6 +41,7 @@ export interface AkanMetricsReport {
41
41
  queueWakeCount?: number;
42
42
  pubsubDeliverCount?: number;
43
43
  pubsubDropCount?: number;
44
+ pubsubCoalesceCount?: number;
44
45
  rssBytes?: number;
45
46
  heapTotalBytes?: number;
46
47
  heapUsedBytes?: number;
@@ -5,7 +5,7 @@ export interface CompressAdaptor {
5
5
  }
6
6
  declare const JsonCompressor_base: import("..").AdaptorCls<{}, {}>;
7
7
  export declare class JsonCompressor extends JsonCompressor_base implements CompressAdaptor {
8
- encode(_ref: Cls, _arrDepth: number, value: unknown): Buffer | null;
8
+ encode(ref: Cls, arrDepth: number, value: unknown): Buffer | null;
9
9
  decode<T = unknown>(ref: Cls, arrDepth: number, buffer: Buffer, { raw }?: {
10
10
  raw?: boolean;
11
11
  }): T;
@@ -18,6 +18,16 @@ export declare class Res implements InternalArg {
18
18
  redirect(url: string | URL, status?: number): Response;
19
19
  };
20
20
  }
21
+ /**
22
+ * Injects the caller's IP, as the nearest proxy recorded it rather than as the socket peer reports it.
23
+ * Behind the federation gateway every peer is the gateway, so an endpoint that reads `remoteAddress` sees
24
+ * `127.0.0.1` for every caller — this reads the forwarded headers first and falls back to the peer only
25
+ * when nothing proxied the call. IPv4 comes back unwrapped from `::ffff:`, so it can address a `udp4`
26
+ * socket. `null` when no proxy recorded one and the transport has no peer.
27
+ */
28
+ export declare class Ip implements InternalArg<string | null> {
29
+ getArg(context: SignalContext): string | null;
30
+ }
21
31
  /**
22
32
  * Injects websocket state, this connection's id, and subscription hooks into message/pubsub handlers.
23
33
  * `socketId` is the one `AppWsData` minted at the handshake, so a handler never reads `ws.data` to
@@ -69,6 +69,18 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
69
69
  getHttpContext<Appended = unknown>(): HttpExecutionContext<Appended>;
70
70
  getWebSocketContext<Appended = unknown>(): WebSocketExecutionContext<Appended>;
71
71
  get<T = unknown>(key: string): T | null;
72
+ /**
73
+ * The caller's IP, preferring what a proxy recorded over the socket peer. Behind the federation gateway the
74
+ * peer is the gateway itself for every request and for the whole life of every socket, so `remoteAddress`
75
+ * alone names the wrong machine — which is why nothing here reads it first. IPv4 arrives unwrapped from its
76
+ * `::ffff:` form, so it can be used as a destination as well as an identity.
77
+ *
78
+ * `null` means no proxy recorded one and the transport has no peer to fall back on — never a placeholder,
79
+ * because a loopback-looking address for an unknown caller is the failure this replaced.
80
+ */
81
+ getClientIp(): string | null;
82
+ /** The caller's source port as the nearest proxy recorded it, else this socket's own. */
83
+ getClientPort(): number | null;
72
84
  getRoomId(key: string): string;
73
85
  getEnv(): Env;
74
86
  getArg<T = unknown>(argName: string): T | undefined;
@@ -74,6 +74,13 @@ export interface SignalOption<Response = any, Nullable extends boolean = false,
74
74
  method?: HttpMutationMethod;
75
75
  /** Marks this mutation as the framework file-upload endpoint (see resolveFileUploadCapability). */
76
76
  fileUpload?: boolean;
77
+ /**
78
+ * What a `pubsub(Binary)` does when a subscriber cannot keep up. `"coalesce"` (the default) keeps only the
79
+ * newest frame per room, which is what a telemetry or video stream wants — an old frame is worthless once a
80
+ * newer one exists. Name `"queue"` when the frames are a sequence a subscriber has to see in full, such as
81
+ * deltas against a base it already holds; the send buffer then grows with the slowest subscriber.
82
+ */
83
+ backpressure?: "coalesce" | "queue";
77
84
  scheduleType?: "init" | "destroy" | "cron" | "interval" | "timeout";
78
85
  scheduleCron?: string;
79
86
  scheduleTime?: number;
@@ -49,7 +49,8 @@ export default function Dashboard<T extends string, State>({
49
49
  const [selected, setSelected] = useState(typeof filter === "string" ? filter : undefined);
50
50
 
51
51
  const settingOf = (column: string): QuerySetting | undefined => {
52
- if (queryMap?.[column]) return queryMap[column];
52
+
53
+ if (queryMap?.[column]?.queryKey) return queryMap[column];
53
54
  const meta = fieldQueryMetaOf(summaryRefName, column);
54
55
  if (!meta?.queryKey || meta.refName !== slice.refName) return undefined;
55
56
  return { queryKey: meta.queryKey, args: meta.queryArgs };
@@ -45,10 +45,10 @@ interface QueryMakerArgsProps {
45
45
  }
46
46
 
47
47
  /** A filter's args may be written as a thunk, so that an arg relative to now is read when the filter is applied. */
48
- export const resolveQuerySetting = (setting: QuerySetting): ResolvedQuerySetting => ({
49
- queryKey: setting.queryKey,
50
- args: typeof setting.args === "function" ? setting.args() : (setting.args ?? []),
51
- });
48
+ export const resolveQuerySetting = (setting: QuerySetting): ResolvedQuerySetting => {
49
+ const args = setting.args ?? setting.queryArgs;
50
+ return { queryKey: setting.queryKey, args: typeof args === "function" ? args() : (args ?? []) };
51
+ };
52
52
 
53
53
  const isFillableArg = (arg: SerializedArg) => !arg.modelType;
54
54
  const defaultArg = (arg: SerializedArg) => ((arg.arrDepth ?? 0) > 0 ? [] : null);
@@ -20,8 +20,23 @@ interface ListenerResultProps {
20
20
  status: "ready" | "loading" | "error" | "listening";
21
21
  data: unknown;
22
22
  }
23
+ /**
24
+ * A byte payload has no useful JSON form: `JSON.stringify` spells a `Uint8Array` as `{"0":2,"1":148,…}`, which
25
+ * is unreadable and, for one video chunk, megabytes of DOM. The head is enough to tell a stream apart.
26
+ */
27
+ const previewBytes = (bytes: Uint8Array) => {
28
+ const head = [...bytes.subarray(0, 32)].map((byte) => byte.toString(16).padStart(2, "0")).join(" ");
29
+ return `Uint8Array(${bytes.length}) ${head}${bytes.length > 32 ? " …" : ""}`;
30
+ };
31
+
32
+ const replaceBytes = (_key: string, value: unknown) => {
33
+ if (!ArrayBuffer.isView(value)) return value;
34
+ const view = value as ArrayBufferView;
35
+ return previewBytes(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
36
+ };
37
+
23
38
  const ListenerResult = ({ status, data }: ListenerResultProps) => {
24
- const dataStr = typeof data === "object" ? JSON.stringify(data, null, 2) : String(data);
39
+ const dataStr = typeof data === "object" ? JSON.stringify(data, replaceBytes, 2) : String(data);
25
40
  const ref = useRef<HTMLPreElement>(null);
26
41
  useEffect(() => {
27
42
  if (!ref.current) return;