akanjs 3.0.0-alpha.63 → 3.0.0-alpha.65

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 (57) 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/index.ts +24 -1
  10. package/local/apps/akan/akan-local.db +0 -0
  11. package/local/apps/akan/akan-local.db-shm +0 -0
  12. package/local/apps/akan/akan-local.db-wal +0 -0
  13. package/local/apps/akan/akan-local_solid.db +0 -0
  14. package/local/apps/akan/akan-local_solid.db-shm +0 -0
  15. package/local/apps/akan/akan-local_solid.db-wal +0 -0
  16. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  17. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  18. package/package.json +1 -1
  19. package/server/akanApp.ts +10 -6
  20. package/server/akanAppHeaders.ts +14 -2
  21. package/server/akanServer.ts +59 -13
  22. package/server/binaryPubsub.ts +55 -0
  23. package/server/resolver/signal.resolver.ts +71 -21
  24. package/server/routing/apiRouter.ts +4 -0
  25. package/server/routing/appWsData.ts +16 -1
  26. package/server/types.tsx +25 -0
  27. package/server/webRouter.ts +48 -32
  28. package/service/ipcTypes.ts +1 -0
  29. package/service/predefinedAdaptor/compress.adaptor.ts +5 -1
  30. package/signal/internalArg.ts +14 -2
  31. package/signal/openapi/openapi.ts +1 -0
  32. package/signal/schema/JsonSchemaBuilder.ts +2 -0
  33. package/signal/signalContext.ts +27 -4
  34. package/signal/types.ts +7 -0
  35. package/test/sampleOf.ts +2 -0
  36. package/types/base/primitiveRegistry.d.ts +14 -2
  37. package/types/common/clientAddress.d.ts +19 -0
  38. package/types/common/index.d.ts +2 -0
  39. package/types/common/websocketBinaryFrame.d.ts +17 -0
  40. package/types/fetch/fetchType/appliedReturn.type.d.ts +7 -0
  41. package/types/index.d.ts +22 -1
  42. package/types/server/akanAppHeaders.d.ts +7 -1
  43. package/types/server/akanServer.d.ts +7 -2
  44. package/types/server/binaryPubsub.d.ts +18 -0
  45. package/types/server/resolver/signal.resolver.d.ts +3 -2
  46. package/types/server/routing/apiRouter.d.ts +2 -1
  47. package/types/server/routing/appWsData.d.ts +6 -0
  48. package/types/server/types.d.ts +14 -0
  49. package/types/server/webRouter.d.ts +12 -3
  50. package/types/service/ipcTypes.d.ts +1 -0
  51. package/types/service/predefinedAdaptor/compress.adaptor.d.ts +1 -1
  52. package/types/signal/internalArg.d.ts +13 -4
  53. package/types/signal/signalContext.d.ts +14 -2
  54. package/types/signal/types.d.ts +7 -0
  55. package/ui/Data/Dashboard.tsx +2 -1
  56. package/ui/Data/QueryMaker.tsx +4 -4
  57. 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/index.ts CHANGED
@@ -24,6 +24,24 @@ export interface AkanRouteConfig {
24
24
  domains: AkanRouteDomains;
25
25
  }
26
26
 
27
+ /**
28
+ * Which web surfaces an app serves, resolved. `ssr` is the RSC/SSR route renderer and everything it needs —
29
+ * the pages bundle, the client bundles, the RSC worker process. `csr` is the single-file SPA shell that the
30
+ * Capacitor mobile build ships and that `/__csr` serves.
31
+ */
32
+ export interface AkanWebConfig {
33
+ ssr: boolean;
34
+ csr: boolean;
35
+ }
36
+
37
+ /**
38
+ * What an `akan.config.ts` may write. `false` is an API-only app — no web artifact is built and no web route
39
+ * is mounted; `true` (the default) is both surfaces. The object form keeps SSR and toggles only the CSR
40
+ * bundle, which is the whole range there is: the CSR bundle inlines the stylesheet the SSR build compiles, so
41
+ * CSR without SSR would ship an unstyled app and is not expressible here.
42
+ */
43
+ export type AkanWebOption = boolean | { csr: boolean };
44
+
27
45
  export type DatabaseMode = "single" | "multiple" | "cluster";
28
46
  export type MobileEnv = "local" | "debug" | "develop" | "main";
29
47
  export type MobilePermission = "camera" | "contacts" | "location" | "push" | "speech";
@@ -162,6 +180,8 @@ export interface AkanPlugin {
162
180
  export interface AppConfigResult {
163
181
  docker: DockerConfig;
164
182
  defaultDatabaseMode: DatabaseMode;
183
+ /** Web surfaces built into the app and mounted at boot. Both default to `true`. */
184
+ web: AkanWebConfig;
165
185
  routes?: AkanRouteConfig[];
166
186
  /**
167
187
  * Mounts `libs/<lib>/page` into this app under `page/(libs)/(<lib>)` on sync. `true` takes every lib
@@ -197,7 +217,10 @@ export interface LibConfigContext {
197
217
  readonly type: "lib";
198
218
  }
199
219
 
200
- export type AppConfigInput = DeepPartial<AppConfigResult> & { plugins?: AkanPlugin[] };
220
+ export type AppConfigInput = Omit<DeepPartial<AppConfigResult>, "web"> & {
221
+ web?: AkanWebOption;
222
+ plugins?: AkanPlugin[];
223
+ };
201
224
  export type LibConfigInput = DeepPartial<LibConfigResult> & { plugins?: AkanPlugin[] };
202
225
  export type AppConfig = AppConfigInput | ((app: AppConfigContext) => AppConfigInput);
203
226
  export type LibConfig = LibConfigInput | ((lib: LibConfigContext) => LibConfigInput);
Binary file
File without changes
File without changes
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.65",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -11,6 +11,7 @@ import { resolveEncodedSidecar } from "./assetEncoding";
11
11
  import { isPortInUseError } from "./lifecycle/portInUse";
12
12
  import { RotatingLogWriter } from "./logging/rotatingLogWriter";
13
13
  import { ProcessMetricsCollector } from "./processMetricsCollector";
14
+ import { getWebConfigFromEnv } from "./types";
14
15
 
15
16
  interface ChildState {
16
17
  idx: number;
@@ -103,6 +104,8 @@ export class AkanApp {
103
104
  readonly #port: number;
104
105
  readonly #wsBasePort: number;
105
106
  readonly #openapi?: boolean;
107
+ /** The gateway hands `/_akan/client|styles|fonts` straight off disk, so it needs the same answer its children do. */
108
+ readonly #web = getWebConfigFromEnv();
106
109
  readonly #modules: string[];
107
110
  readonly #children = new Map<number, ChildState>();
108
111
  readonly #roomChildren = new Map<string, Set<number>>();
@@ -547,7 +550,7 @@ export class AkanApp {
547
550
  if (this.#isWebSocketPath(url.pathname)) return this.#upgradeWebSocket(req, server);
548
551
  const assetResponse = await this.#serveImmutableArtifact(req, url);
549
552
  if (assetResponse) return assetResponse;
550
- return await this.#proxyHttp(req);
553
+ return await this.#proxyHttp(req, server);
551
554
  }
552
555
 
553
556
  #isWebSocketPath(pathname: string) {
@@ -555,6 +558,7 @@ export class AkanApp {
555
558
  }
556
559
 
557
560
  async #serveImmutableArtifact(req: Request, url: URL): Promise<Response | null> {
561
+ if (!this.#web.ssr) return null;
558
562
  const clientPrefix = "/_akan/client/";
559
563
  if (url.pathname.startsWith(clientPrefix)) {
560
564
  const filePath = this.#safeResolve(
@@ -592,7 +596,7 @@ export class AkanApp {
592
596
  if (!child || !upstream) return new Response("No websocket upstream is ready", { status: 503 });
593
597
  const url = new URL(req.url);
594
598
  const upstreamWs = new WebSocket(`ws://${upstream.host}:${upstream.port}${url.pathname}${url.search}`, {
595
- headers: this.#makeProxyHeaders(req, child.idx),
599
+ headers: this.#makeProxyHeaders(req, child.idx, server),
596
600
  } as unknown as string[]);
597
601
 
598
602
  const upgraded = server.upgrade(req, { data: { childIdx: child.idx, upstream: upstreamWs } });
@@ -691,14 +695,14 @@ export class AkanApp {
691
695
  };
692
696
  }
693
697
 
694
- async #proxyHttp(req: Request): Promise<Response> {
698
+ async #proxyHttp(req: Request, server: Bun.Server<GatewayWsData>): Promise<Response> {
695
699
  const child = this.#pickFederationChild();
696
700
  if (!child?.upstream || child.upstream.type !== "unix") {
697
701
  return this.#respondWithCrashPage(req) ?? new Response("No healthy federation child is ready", { status: 503 });
698
702
  }
699
703
  const url = new URL(req.url);
700
704
  const upstreamUrl = `http://akan-child${url.pathname}${url.search}`;
701
- const headers = this.#makeProxyHeaders(req, child.idx);
705
+ const headers = this.#makeProxyHeaders(req, child.idx, server);
702
706
  child.metrics.activeRequests = (child.metrics.activeRequests ?? 0) + 1;
703
707
  child.metrics.totalRequests = (child.metrics.totalRequests ?? 0) + 1;
704
708
  const traced = isTraceEnabled();
@@ -867,8 +871,8 @@ export class AkanApp {
867
871
  return resolved;
868
872
  }
869
873
 
870
- #makeProxyHeaders(req: Request, childIdx: number) {
871
- return makeAkanChildProxyHeaders(req, childIdx);
874
+ #makeProxyHeaders(req: Request, childIdx: number, server?: Bun.Server<GatewayWsData>) {
875
+ return makeAkanChildProxyHeaders(req, childIdx, server?.requestIP(req));
872
876
  }
873
877
 
874
878
  #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,6 @@
1
+ import type { AkanWebConfig, AkanWebOption } from "akanjs";
1
2
  import { type BackendEnv, type BaseEnv, getEnv } from "akanjs/base";
2
- import { Logger } from "akanjs/common";
3
+ import { Logger, websocketBinaryFrameContract } from "akanjs/common";
3
4
  import { DictionaryLookup } from "akanjs/dictionary";
4
5
  import type {
5
6
  Adaptor,
@@ -16,6 +17,7 @@ import { createOpenApiDocument } from "../signal/openapi";
16
17
  import { FetchSerializer } from "../signal/serializer";
17
18
  import type { AkanLib, AkanLibProps } from "./akanLib";
18
19
  import type { BuilderRpc } from "./artifact";
20
+ import { BinaryPubsub } from "./binaryPubsub";
19
21
  import { DevtoolsRouter } from "./devtools";
20
22
  import { DiLifecycle } from "./di/diLifecycle";
21
23
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
@@ -27,7 +29,13 @@ import { WebProxyRunner } from "./proxy";
27
29
  import { SignalResolver } from "./resolver";
28
30
  import { ApiRouter } from "./routing/apiRouter";
29
31
  import type { AppWsData } from "./routing/appWsData";
30
- import type { HttpRoutes, SignalRoutes, WebsocketRoutes } from "./types";
32
+ import {
33
+ getWebConfigFromEnv,
34
+ type HttpRoutes,
35
+ type LocalPublish,
36
+ type SignalRoutes,
37
+ type WebsocketRoutes,
38
+ } from "./types";
31
39
  import type { WebRouter } from "./webRouter";
32
40
 
33
41
  export interface AkanServerProps extends AkanLibProps {
@@ -134,11 +142,14 @@ export class AkanServer {
134
142
  mcpAuth: McpAuthOption = AkanServer.#mcpAuthFromEnv();
135
143
  mcpOption: Omit<McpServerOption, "enabled" | "readOnly" | "auth"> = AkanServer.#mcpOptionFromEnv();
136
144
  serverMode: "federation" | "batch" | "all";
145
+ /** Resolved at `init`: what this process actually serves, after env and artifact availability. */
146
+ web: AkanWebConfig = getWebConfigFromEnv();
137
147
  modules: string[];
138
148
  shutdownTimeoutMs = AkanServer.#defaultShutdownTimeoutMs();
139
149
 
140
150
  #di: DiLifecycle;
141
- #localPublish: ((roomId: string, data: object | object[]) => void) | null = null;
151
+ #localPublish: LocalPublish | null = null;
152
+ readonly #binaryPubsub = new BinaryPubsub();
142
153
  #metricsTimer: Timer | null = null;
143
154
  constructor(
144
155
  name = "AkanServer",
@@ -182,6 +193,12 @@ export class AkanServer {
182
193
  this.openapi = openapi;
183
194
  return this;
184
195
  }
196
+ /** Narrows the web surface this process serves. Never widens it past what the build produced. */
197
+ setWeb(web: AkanWebOption = true) {
198
+ if (this.status !== "stopped") throw new Error("Web config must be set before app initialization.");
199
+ this.web = AkanServer.#narrowWeb(this.web, web);
200
+ return this;
201
+ }
185
202
  setMcp(mcp: boolean | McpServerOption = true) {
186
203
  if (this.status !== "stopped") throw new Error("MCP config must be set before app initialization.");
187
204
  this.mcp = typeof mcp === "boolean" ? mcp : (mcp.enabled ?? true);
@@ -254,7 +271,7 @@ export class AkanServer {
254
271
  };
255
272
  }
256
273
 
257
- async init({ routes: initRoutes = true, web = true }: { routes?: boolean; web?: boolean } = {}) {
274
+ async init({ routes: initRoutes = true, web }: { routes?: boolean; web?: AkanWebOption } = {}) {
258
275
  if (this.status !== "stopped") throw new Error("AkanServer is not able to init. It is already running.");
259
276
  this.status = "initializing";
260
277
  const { routes, wsRoutes, routeOptions } = await this.#di.initializeAll();
@@ -263,7 +280,8 @@ export class AkanServer {
263
280
  this.status = "initialized";
264
281
  return this;
265
282
  }
266
- if (!web) {
283
+ const requestedWeb = AkanServer.#narrowWeb(this.web, web);
284
+ const noWeb = () => {
267
285
  this.#prepared = {
268
286
  routes,
269
287
  routeOptions,
@@ -277,11 +295,25 @@ export class AkanServer {
277
295
  };
278
296
  this.status = "initialized";
279
297
  return this;
298
+ };
299
+ if (!requestedWeb.ssr) {
300
+ this.web = requestedWeb;
301
+ this.logger.info("web off: serving api only (AKAN_SSR=false, or a build with `web: false`)");
302
+ return noWeb();
280
303
  }
281
304
  const { WebRouter } = await import("./webRouter");
282
305
  const webRouter = await WebRouter.create({
306
+ web: requestedWeb,
283
307
  upgradeHmrWs: (req, data) => this.#server?.upgrade(req, { data }) ?? false,
284
308
  });
309
+
310
+ if (!webRouter) {
311
+ this.web = { ssr: false, csr: false };
312
+ this.logger.warn("web off: no build artifact under .akan/artifact; serving api only");
313
+ return noWeb();
314
+ }
315
+ this.web = webRouter.web;
316
+ this.logger.info(`web on: ssr=${this.web.ssr} csr=${this.web.csr}`);
285
317
  const { renderEnvRoutes, hmrHub, builderRpc } = await webRouter.initializeRoute();
286
318
  const webProxyRunner = WebProxyRunner.create(this.#di.webProxies);
287
319
  this.#prepared = {
@@ -318,6 +350,7 @@ export class AkanServer {
318
350
  hmrHub,
319
351
  hmrState: webRouter ? { state: webRouter.renderState } : null,
320
352
  logger: this.logger,
353
+ onDrain: () => this.#binaryPubsub.flush(),
321
354
  }),
322
355
 
323
356
  data: {},
@@ -375,16 +408,20 @@ export class AkanServer {
375
408
 
376
409
  const websocket = this.#di.getWebsocketAdaptor();
377
410
  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) => {
411
+ this.#binaryPubsub.setServers(server, wsServer);
412
+ const localPublish: LocalPublish = (roomId, data) => {
413
+ if (data instanceof Uint8Array) {
414
+ this.#binaryPubsub.publish(roomId, websocketBinaryFrameContract.encode({ roomId, payload: data }), {
415
+ coalesce: SignalResolver.coalescesRoom(roomId),
416
+ });
417
+ return;
418
+ }
384
419
  const publishData: WebsocketPublishData = { type: "pub", roomId, data };
385
420
  server?.publish(roomId, JSON.stringify(publishData));
386
421
  wsServer?.publish(roomId, JSON.stringify(publishData));
387
422
  };
423
+ SignalResolver.setLocalPublish(localPublish, websocket);
424
+ this.#localPublish = localPublish;
388
425
 
389
426
  this.status = "running";
390
427
  this.#startMetricsReporting();
@@ -405,7 +442,7 @@ export class AkanServer {
405
442
  return this;
406
443
  }
407
444
 
408
- async start({ listen, web = true }: { listen?: boolean; web?: boolean } = {}) {
445
+ async start({ listen, web }: { listen?: boolean; web?: AkanWebOption } = {}) {
409
446
  const isNoListenCommand = process.env.AKAN_COMMAND_TYPE === "script" || process.env.AKAN_COMMAND_TYPE === "console";
410
447
  const shouldListen = (listen ?? !isNoListenCommand) && this.serverMode !== "batch";
411
448
  await this.init({ routes: shouldListen, web });
@@ -479,7 +516,8 @@ export class AkanServer {
479
516
 
480
517
  #handleIpcMessage(message: AkanIpcMessage) {
481
518
  if (!message || typeof message !== "object") return;
482
- if (message.type === "pubsub.deliver") this.#localPublish?.(message.roomId, message.data as object | object[]);
519
+ if (message.type === "pubsub.deliver")
520
+ this.#localPublish?.(message.roomId, message.data as object | object[] | Uint8Array);
483
521
  else if (message.type === "health.ping")
484
522
  process.send?.({
485
523
  type: "health.pong",
@@ -506,6 +544,7 @@ export class AkanServer {
506
544
  async #reportMetrics() {
507
545
  const metrics = await ProcessMetricsCollector.collect({
508
546
  role: this.serverMode,
547
+ pubsubCoalesceCount: this.#binaryPubsub.coalescedCount,
509
548
  ...(this.#prepared?.webRouter?.getMetrics() ?? {}),
510
549
  });
511
550
  process.send?.({ type: "metrics.report", pid: process.pid, metrics } satisfies AkanIpcMessage);
@@ -629,6 +668,13 @@ export class AkanServer {
629
668
  return !names.some((name) => process.env[name] === "false" || process.env[name] === "0");
630
669
  }
631
670
 
671
+ /** Narrows only — a surface the build or the env left out cannot be switched back on here. */
672
+ static #narrowWeb(current: AkanWebConfig, web: AkanWebOption | undefined): AkanWebConfig {
673
+ if (web === undefined || web === true) return current;
674
+ if (web === false) return { ssr: false, csr: false };
675
+ return { ssr: current.ssr, csr: web.csr && current.csr };
676
+ }
677
+
632
678
  /** Named rather than defaulted: an absent env must leave the option unset so a value written in code still wins. */
633
679
  static #isEnvOff(...names: string[]) {
634
680
  return names.some((name) => process.env[name] === "false" || process.env[name] === "0");
@@ -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
+ }