akanjs 3.0.0-alpha.62 → 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 (53) 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/fieldQueryMeta.ts +35 -0
  7. package/constant/index.ts +1 -0
  8. package/constant/via.ts +4 -1
  9. package/fetch/client/wsClient.ts +9 -2
  10. package/fetch/fetchType/appliedReturn.type.ts +7 -0
  11. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  12. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  13. package/package.json +1 -1
  14. package/server/akanApp.ts +6 -6
  15. package/server/akanAppHeaders.ts +14 -2
  16. package/server/akanServer.ts +19 -10
  17. package/server/binaryPubsub.ts +55 -0
  18. package/server/resolver/signal.resolver.ts +25 -5
  19. package/server/routing/apiRouter.ts +4 -0
  20. package/server/routing/appWsData.ts +16 -1
  21. package/server/types.tsx +2 -0
  22. package/service/ipcTypes.ts +1 -0
  23. package/service/predefinedAdaptor/compress.adaptor.ts +5 -1
  24. package/signal/internalArg.ts +13 -0
  25. package/signal/openapi/openapi.ts +1 -0
  26. package/signal/schema/JsonSchemaBuilder.ts +2 -0
  27. package/signal/signalContext.ts +22 -0
  28. package/signal/types.ts +7 -0
  29. package/test/sampleOf.ts +2 -0
  30. package/types/base/primitiveRegistry.d.ts +14 -2
  31. package/types/common/clientAddress.d.ts +19 -0
  32. package/types/common/index.d.ts +2 -0
  33. package/types/common/websocketBinaryFrame.d.ts +17 -0
  34. package/types/constant/fieldQueryMeta.d.ts +17 -0
  35. package/types/constant/index.d.ts +1 -0
  36. package/types/fetch/fetchType/appliedReturn.type.d.ts +7 -0
  37. package/types/server/akanAppHeaders.d.ts +7 -1
  38. package/types/server/binaryPubsub.d.ts +18 -0
  39. package/types/server/resolver/signal.resolver.d.ts +3 -2
  40. package/types/server/routing/apiRouter.d.ts +2 -1
  41. package/types/server/routing/appWsData.d.ts +6 -0
  42. package/types/server/types.d.ts +1 -0
  43. package/types/service/ipcTypes.d.ts +1 -0
  44. package/types/service/predefinedAdaptor/compress.adaptor.d.ts +1 -1
  45. package/types/signal/internalArg.d.ts +10 -0
  46. package/types/signal/signalContext.d.ts +12 -0
  47. package/types/signal/types.d.ts +7 -0
  48. package/types/ui/Data/Dashboard.d.ts +7 -2
  49. package/types/ui/Model/AdminPanel.d.ts +7 -2
  50. package/ui/Data/Dashboard.tsx +19 -3
  51. package/ui/Data/QueryMaker.tsx +4 -4
  52. package/ui/Model/AdminPanel.tsx +8 -1
  53. 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;
@@ -0,0 +1,35 @@
1
+ import { FIELD_META } from "akanjs/base";
2
+ import { ConstantRegistry } from "./constantRegistry";
3
+ import type { FieldObject } from "./fieldInfo";
4
+
5
+ /**
6
+ * The query a field's value stands for, declared on the field itself with `.meta(...)`. A summary counter is the
7
+ * case this exists for: `hau` is however many rows one filter returns, so the tile showing it can open that exact
8
+ * listing without the page restating the filter beside it.
9
+ *
10
+ * The shape is read structurally rather than by class, so an app declares it with whatever builder it already has.
11
+ */
12
+ export interface FieldQueryMeta {
13
+ /** The model the query runs against. */
14
+ refName: string;
15
+ /** One of that model's declared filter keys. */
16
+ queryKey: string | null;
17
+ /** Read when the query is applied, so an arg relative to now — `() => [dayjs().subtract(1, "hour")]` — is current. */
18
+ queryArgs?: unknown[] | (() => unknown[]);
19
+ }
20
+
21
+ const isFieldQueryMeta = (meta: unknown): meta is FieldQueryMeta => {
22
+ if (!meta || typeof meta !== "object") return false;
23
+ const { refName, queryKey, queryArgs } = meta as Record<string, unknown>;
24
+ if (typeof refName !== "string" || !refName) return false;
25
+ if (queryKey !== null && typeof queryKey !== "string") return false;
26
+ return queryArgs === undefined || Array.isArray(queryArgs) || typeof queryArgs === "function";
27
+ };
28
+
29
+ /** The query one field of `refName` names, or nothing when the model, the field, or the declaration is absent. */
30
+ export const fieldQueryMetaOf = (refName: string, field: string): FieldQueryMeta | undefined => {
31
+ const cnst = ConstantRegistry.getDatabase(refName, { allowEmpty: true });
32
+ const fieldMap = cnst?.full[FIELD_META] as FieldObject | undefined;
33
+ const meta = fieldMap?.[field]?.meta;
34
+ return isFieldQueryMeta(meta) ? meta : undefined;
35
+ };
package/constant/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./constantRegistry";
3
3
  export * from "./crystalize";
4
4
  export * from "./deserialize";
5
5
  export * from "./fieldInfo";
6
+ export * from "./fieldQueryMeta";
6
7
  export * from "./getDefault";
7
8
  export * from "./immerify";
8
9
  export * from "./labelOf";
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.62",
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
  }