akanjs 3.0.0-alpha.65 → 3.0.0-alpha.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -4,14 +4,37 @@ import type { AkanImageConfig } from "akanjs/server";
4
4
  export const archs = ["amd64", "arm64"] as const;
5
5
  export type Arch = (typeof archs)[number];
6
6
 
7
- export interface DockerConfig {
8
- content: string;
7
+ /** One image step. The object form runs only on the matching `TARGETARCH` leg of a multi-arch build. */
8
+ export type DockerRun = string | { [key in Arch]?: string };
9
+
10
+ /** The pieces Akan assembles a Dockerfile from. */
11
+ export interface DockerImageConfig {
9
12
  image: string | { [key in Arch]?: string };
10
- preRuns: (string | { [key in Arch]?: string })[];
11
- postRuns: (string | { [key in Arch]?: string })[];
13
+ /** Runs before `bun install`, so a system package a native dependency needs is there for the install. */
14
+ preRuns: DockerRun[];
15
+ /** Runs after `bun install`, before the app files are copied. */
16
+ postRuns: DockerRun[];
12
17
  command: string[];
13
18
  }
14
19
 
20
+ /**
21
+ * A whole Dockerfile as a string, or the parts Akan assembles one from. The string form is taken verbatim —
22
+ * nothing is merged into it, including the steps a lib contributes through its own `docker`.
23
+ */
24
+ export type DockerConfig = string | DockerImageConfig;
25
+
26
+ /** What an `akan.config.ts` may write for `docker`: a whole Dockerfile, or any subset of the parts. */
27
+ export type DockerOption = string | Partial<DockerImageConfig>;
28
+
29
+ /**
30
+ * A lib's contribution to the image of every app that mounts it — a lib never picks the base image or the
31
+ * command, only the steps its own runtime needs.
32
+ */
33
+ export interface LibDockerConfig {
34
+ preRuns: DockerRun[];
35
+ postRuns: DockerRun[];
36
+ }
37
+
15
38
  export interface AkanRouteDomains {
16
39
  main?: string[];
17
40
  develop?: string[];
@@ -201,6 +224,8 @@ export interface AppConfigResult {
201
224
 
202
225
  export interface LibConfigResult {
203
226
  externalLibs: string[];
227
+ /** Image steps every app that mounts this lib inherits, unless that app declares a whole Dockerfile. */
228
+ docker: LibDockerConfig;
204
229
  }
205
230
 
206
231
  export type DeepPartial<T> = {
@@ -217,7 +242,8 @@ export interface LibConfigContext {
217
242
  readonly type: "lib";
218
243
  }
219
244
 
220
- export type AppConfigInput = Omit<DeepPartial<AppConfigResult>, "web"> & {
245
+ export type AppConfigInput = Omit<DeepPartial<AppConfigResult>, "docker" | "web"> & {
246
+ docker?: DockerOption;
221
247
  web?: AkanWebOption;
222
248
  plugins?: AkanPlugin[];
223
249
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.65",
3
+ "version": "3.0.0-alpha.67",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -166,6 +166,11 @@
166
166
  "import": "./server/rscWorker.tsx",
167
167
  "default": "./server/rscWorker.tsx"
168
168
  },
169
+ "./server/akanApp": {
170
+ "types": "./types/server/akanApp.d.ts",
171
+ "import": "./server/akanApp.ts",
172
+ "default": "./server/akanApp.ts"
173
+ },
169
174
  "./server/memoryLimit": {
170
175
  "types": "./types/server/memoryLimit.d.ts",
171
176
  "import": "./server/memoryLimit.ts",
package/server/akanApp.ts CHANGED
@@ -4,11 +4,12 @@ import { mkdir, readdir, rm } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { Logger } from "akanjs/common";
6
6
  import type { AkanChildRole, AkanChildStatus, AkanIpcMessage, AkanMetricsReport, AkanUpstream } from "akanjs/service";
7
- import { isTraceEnabled } from "akanjs/signal";
7
+ import { isTraceEnabled } from "../signal/trace";
8
8
  import { makeAkanChildProxyHeaders } from "./akanAppHeaders";
9
9
  import type { BuilderCsrReq, BuilderCsrRes, BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
10
10
  import { resolveEncodedSidecar } from "./assetEncoding";
11
11
  import { isPortInUseError } from "./lifecycle/portInUse";
12
+ import { resolveRuntimeDir } from "./lifecycle/runtimeDir";
12
13
  import { RotatingLogWriter } from "./logging/rotatingLogWriter";
13
14
  import { ProcessMetricsCollector } from "./processMetricsCollector";
14
15
  import { getWebConfigFromEnv } from "./types";
@@ -36,6 +37,11 @@ interface ChildState {
36
37
  lastErrorMessage?: string;
37
38
  }
38
39
 
40
+ /** The shape `#startSolo` needs off `server.ts`; the module itself is loaded by path, so it has no type here. */
41
+ interface SoloServer {
42
+ start: (options: { listen: boolean }) => Promise<unknown>;
43
+ }
44
+
39
45
  interface GatewayWsData {
40
46
  childIdx: number;
41
47
  upstream: WebSocket;
@@ -72,6 +78,13 @@ export interface AkanAppOptions {
72
78
  * module. Handed down as `AKAN_MODULES`, since each replica builds its own container.
73
79
  */
74
80
  modules?: string[];
81
+ /**
82
+ * Run the one replica in this process instead of spawning it — see `#startSolo`. Defaults on for a single
83
+ * traffic replica the environment configured, and off whenever `replica` is passed here, because code that
84
+ * states a topology is asking for the gateway that serves it. `AKAN_SOLO=false` turns it off; like
85
+ * `AKAN_SSR`, the env can only narrow, so it cannot force a gateway's replicas into one process.
86
+ */
87
+ solo?: boolean;
75
88
  }
76
89
 
77
90
  interface AkanReplicaConfig {
@@ -107,6 +120,7 @@ export class AkanApp {
107
120
  /** The gateway hands `/_akan/client|styles|fonts` straight off disk, so it needs the same answer its children do. */
108
121
  readonly #web = getWebConfigFromEnv();
109
122
  readonly #modules: string[];
123
+ readonly #solo: boolean;
110
124
  readonly #children = new Map<number, ChildState>();
111
125
  readonly #roomChildren = new Map<string, Set<number>>();
112
126
  readonly #childRooms = new Map<number, Set<string>>();
@@ -138,17 +152,29 @@ export class AkanApp {
138
152
  this.#serverPath = AkanApp.#resolveServerPath(resolvedOptions.serverPath ?? serverPath);
139
153
  this.#artifactDir = path.resolve(path.dirname(this.#serverPath), ".akan", "artifact");
140
154
  this.#replica = AkanApp.#parseReplicaConfig(resolvedOptions.replica);
141
- this.#runtimeDir = path.resolve(
142
- resolvedOptions.runtimeDir ??
143
- process.env.AKAN_RUNTIME_DIR ??
144
- (process.env.NODE_ENV === "production"
145
- ? path.resolve(process.cwd(), "runtime")
146
- : path.resolve(process.cwd(), "local", "apps", process.env.AKAN_PUBLIC_APP_NAME ?? "unknown", "runtime")),
147
- );
155
+ this.#runtimeDir = resolveRuntimeDir(resolvedOptions.runtimeDir);
148
156
  this.#port = Number(resolvedOptions.port ?? process.env.PORT ?? 8282);
149
157
  this.#wsBasePort = Number(resolvedOptions.wsBasePort ?? process.env.AKAN_WS_BASE_PORT ?? this.#port + 10_000);
150
158
  this.#openapi = resolvedOptions.openapi;
151
159
  this.#modules = resolvedOptions.modules ?? [];
160
+ this.#solo = AkanApp.#resolveSolo(resolvedOptions, this.#replica);
161
+ }
162
+
163
+ /**
164
+ * One replica that serves traffic means there is nothing to balance and nothing to fan pubsub out to, so the
165
+ * gateway is a second Bun runtime and a proxy hop in front of the only server there is. Measured on
166
+ * `apps/akan`: 28MB of RSS and half the requests per second.
167
+ *
168
+ * A batch-only replica is excluded because it never listens — keeping the gateway is what leaves something
169
+ * bound to answer `/_akan/app/health`. `akan start` is excluded because the gateway is also the dev host's
170
+ * builder relay, its crash page, and what holds the port across a child restart.
171
+ */
172
+ static #resolveSolo(options: AkanAppOptions, replica: AkanReplicaConfig) {
173
+ if (options.solo !== undefined) return options.solo;
174
+ if (process.env.AKAN_SOLO === "false" || process.env.AKAN_SOLO === "0") return false;
175
+ if (process.env.AKAN_COMMAND_TYPE === "start") return false;
176
+ if (options.replica !== undefined) return false;
177
+ return replica.total === 1 && replica.batch === 0;
152
178
  }
153
179
 
154
180
  static #resolveServerPath(serverPath: string) {
@@ -215,6 +241,7 @@ export class AkanApp {
215
241
  }
216
242
 
217
243
  async start() {
244
+ if (this.#solo) return await this.#startSolo();
218
245
  await this.#prepareRuntimeDir();
219
246
  this.#startFileLogging();
220
247
  for (let idx = 0; idx < this.#replica.total; idx++) this.#spawn(idx);
@@ -242,6 +269,25 @@ export class AkanApp {
242
269
  });
243
270
  }
244
271
 
272
+ async #startSolo() {
273
+ const role = this.#getRole(0);
274
+
275
+ Object.assign(process.env, {
276
+ NODE_ENV: AkanApp.#defaultChildNodeEnv(),
277
+ AKAN_REPLICA: this.#replica.value,
278
+ AKAN_REPLICA_IDX: "0",
279
+ AKAN_APP_DIR: path.dirname(this.#serverPath),
280
+ SERVER_MODE: role,
281
+ ...(this.#openapi === undefined ? {} : { AKAN_OPENAPI: this.#openapi ? "true" : "false" }),
282
+ ...(this.#modules.length ? { AKAN_MODULES: this.#modules.join(",") } : {}),
283
+ });
284
+ this.logger.info(`Starting ${role} replica in this process (solo); set AKAN_SOLO=false for the gateway`);
285
+ const mod = (await import(this.#serverPath)) as { server?: SoloServer; app?: SoloServer };
286
+ const server = mod.server ?? mod.app;
287
+ if (!server?.start) throw new Error("server.ts must export server or app with start()");
288
+ await server.start({ listen: true });
289
+ }
290
+
245
291
  async stop(signal = "SIGTERM") {
246
292
  if (this.#stopping) return;
247
293
  this.#stopping = true;
@@ -6,6 +6,7 @@ import type {
6
6
  Adaptor,
7
7
  AdaptorCls,
8
8
  AkanIpcMessage,
9
+ AkanMetricsReport,
9
10
  DatabaseConfig,
10
11
  Service,
11
12
  ServiceCls,
@@ -15,6 +16,7 @@ import type { ServerSignal, ServerSignalCls, WebsocketPublishData } from "akanjs
15
16
  import { AgentRelayAccess } from "../signal/guards";
16
17
  import { createOpenApiDocument } from "../signal/openapi";
17
18
  import { FetchSerializer } from "../signal/serializer";
19
+ import { SignalContext } from "../signal/signalContext";
18
20
  import type { AkanLib, AkanLibProps } from "./akanLib";
19
21
  import type { BuilderRpc } from "./artifact";
20
22
  import { BinaryPubsub } from "./binaryPubsub";
@@ -22,13 +24,16 @@ import { DevtoolsRouter } from "./devtools";
22
24
  import { DiLifecycle } from "./di/diLifecycle";
23
25
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
24
26
  import { isPortInUseError } from "./lifecycle/portInUse";
27
+ import { resolveRuntimeDir } from "./lifecycle/runtimeDir";
25
28
  import { ShutdownManager } from "./lifecycle/shutdownManager";
29
+ import { RotatingLogWriter } from "./logging/rotatingLogWriter";
26
30
  import { type McpAuthOption, McpRouter } from "./mcp";
27
31
  import { ProcessMetricsCollector } from "./processMetricsCollector";
28
32
  import { WebProxyRunner } from "./proxy";
29
33
  import { SignalResolver } from "./resolver";
30
34
  import { ApiRouter } from "./routing/apiRouter";
31
35
  import type { AppWsData } from "./routing/appWsData";
36
+ import { createSoloAppRoutes } from "./routing/soloAppRoutes";
32
37
  import {
33
38
  getWebConfigFromEnv,
34
39
  type HttpRoutes,
@@ -151,6 +156,16 @@ export class AkanServer {
151
156
  #localPublish: LocalPublish | null = null;
152
157
  readonly #binaryPubsub = new BinaryPubsub();
153
158
  #metricsTimer: Timer | null = null;
159
+ /**
160
+ * No gateway socket means nothing is proxying this process, so it owns the whole surface: the
161
+ * `/_akan/app/*` observability routes the gateway would have answered, and the rotating log file the
162
+ * gateway would have written. Derived rather than declared — a spawned child always carries the socket,
163
+ * so the two modes cannot disagree about which one this is.
164
+ */
165
+ readonly #solo = !process.env.AKAN_CHILD_SOCKET;
166
+ #logWriter: RotatingLogWriter | null = null;
167
+ #removeLogSink: (() => void) | null = null;
168
+ #lastMetrics: AkanMetricsReport = {};
154
169
  constructor(
155
170
  name = "AkanServer",
156
171
  env: BackendEnv = {},
@@ -313,7 +328,7 @@ export class AkanServer {
313
328
  return noWeb();
314
329
  }
315
330
  this.web = webRouter.web;
316
- this.logger.info(`web on: ssr=${this.web.ssr} csr=${this.web.csr}`);
331
+ this.logger.verbose(`web on: ssr=${this.web.ssr} csr=${this.web.csr}`);
317
332
  const { renderEnvRoutes, hmrHub, builderRpc } = await webRouter.initializeRoute();
318
333
  const webProxyRunner = WebProxyRunner.create(this.#di.webProxies);
319
334
  this.#prepared = {
@@ -336,6 +351,7 @@ export class AkanServer {
336
351
  throw new Error("AkanServer is not able to listen. Call `init` first.");
337
352
  }
338
353
  this.status = "starting";
354
+ this.#startFileLogging();
339
355
  const port = process.env.AKAN_CHILD_SOCKET
340
356
  ? undefined
341
357
  : Number(process.env.AKAN_CHILD_WS_PORT || process.env.PORT || 8282);
@@ -401,6 +417,8 @@ export class AkanServer {
401
417
 
402
418
  const server = this.#server;
403
419
  const wsServer = this.#wsServer;
420
+
421
+ SignalContext.setHttpPeerResolver((req) => server?.requestIP(req as Bun.BunRequest) ?? null);
404
422
  hmrHub?.setPublisher((topic, payload) => {
405
423
  server?.publish(topic, payload);
406
424
  wsServer?.publish(topic, payload);
@@ -484,9 +502,11 @@ export class AkanServer {
484
502
  this.#wsServer?.stop(true);
485
503
  this.#server = null;
486
504
  this.#wsServer = null;
505
+ SignalContext.setHttpPeerResolver(null);
487
506
 
488
507
  this.#prepared?.webRouter?.dispose();
489
508
  await this.#withShutdownTimeout(this.#di.destroyAll());
509
+ await this.#stopFileLogging();
490
510
  this.#prepared = null;
491
511
  this.status = "stopped";
492
512
  this.logger.info(`Shutdown completed successfully in ${Date.now() - now}ms`);
@@ -547,6 +567,7 @@ export class AkanServer {
547
567
  pubsubCoalesceCount: this.#binaryPubsub.coalescedCount,
548
568
  ...(this.#prepared?.webRouter?.getMetrics() ?? {}),
549
569
  });
570
+ this.#lastMetrics = metrics;
550
571
  process.send?.({ type: "metrics.report", pid: process.pid, metrics } satisfies AkanIpcMessage);
551
572
  if (process.env.AKAN_MEMORY_LOG === "1") {
552
573
  this.logger.info(`memory role=${this.serverMode} ${ProcessMetricsCollector.format(metrics)}`);
@@ -600,6 +621,15 @@ export class AkanServer {
600
621
  })
601
622
  : null;
602
623
  const mcpRoutes: HttpRoutes = mcpRouter?.createRoutes() ?? {};
624
+ const soloRoutes: HttpRoutes = this.#solo
625
+ ? createSoloAppRoutes(() => ({
626
+ role: this.serverMode,
627
+ running: this.status === "running",
628
+ status: this.status,
629
+ port: this.#server?.port ?? null,
630
+ metrics: this.#lastMetrics,
631
+ }))
632
+ : {};
603
633
 
604
634
  mcpRouter?.report();
605
635
 
@@ -613,7 +643,24 @@ export class AkanServer {
613
643
  openapi: this.openapi,
614
644
  getStatus: () => this.status,
615
645
  }).createRoutes();
616
- return { ...openapiRoutes, ...mcpRoutes, ...devtoolsRoutes };
646
+ return { ...openapiRoutes, ...mcpRoutes, ...devtoolsRoutes, ...soloRoutes };
647
+ }
648
+
649
+ #startFileLogging() {
650
+ if (!this.#solo || this.#logWriter) return;
651
+ this.#logWriter = RotatingLogWriter.fromRuntimeDir(resolveRuntimeDir());
652
+ if (!this.#logWriter) return;
653
+ this.#removeLogSink = Logger.addSink((entry) => {
654
+ this.#logWriter?.write(this.serverMode, entry.plainMessage);
655
+ });
656
+ }
657
+
658
+ async #stopFileLogging() {
659
+ this.#removeLogSink?.();
660
+ this.#removeLogSink = null;
661
+ const writer = this.#logWriter;
662
+ this.#logWriter = null;
663
+ await writer?.close();
617
664
  }
618
665
 
619
666
  /**
@@ -0,0 +1,14 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * Where a replica keeps its child sockets and rotating logs. Both the gateway and a solo server resolve it,
5
+ * and they must land on the same directory: switching between the two modes must not move the log file.
6
+ */
7
+ export const resolveRuntimeDir = (runtimeDir?: string): string =>
8
+ path.resolve(
9
+ runtimeDir ??
10
+ process.env.AKAN_RUNTIME_DIR ??
11
+ (process.env.NODE_ENV === "production"
12
+ ? path.resolve(process.cwd(), "runtime")
13
+ : path.resolve(process.cwd(), "local", "apps", process.env.AKAN_PUBLIC_APP_NAME ?? "unknown", "runtime")),
14
+ );
@@ -1,5 +1,5 @@
1
1
  import type { AkanMetricsReport } from "akanjs/service";
2
- import { getTraceSnapshot, isTraceEnabled } from "akanjs/signal";
2
+ import { getTraceSnapshot, isTraceEnabled } from "../signal/trace";
3
3
 
4
4
  type BunJscHeapStats = {
5
5
  heapSize?: number;
@@ -0,0 +1,46 @@
1
+ import type { AkanChildRole, AkanMetricsReport } from "akanjs/service";
2
+ import type { HttpRoutes } from "../types";
3
+
4
+ export interface SoloAppStatus {
5
+ role: AkanChildRole;
6
+ running: boolean;
7
+ status: string;
8
+ port: number | null;
9
+ metrics: AkanMetricsReport;
10
+ }
11
+
12
+ /**
13
+ * The gateway's `/_akan/app/*` surface, answered by a process nothing is proxying. The payload keeps the
14
+ * gateway's own shape — a `children` array with this process as its only entry — so a probe, a k8s check and
15
+ * `akan` tooling read one contract whether or not a gateway is in front.
16
+ */
17
+ export const createSoloAppRoutes = (read: () => SoloAppStatus): HttpRoutes => {
18
+ const child = () => {
19
+ const { role, running, status, port } = read();
20
+ return {
21
+ idx: 0,
22
+ role,
23
+ status: running ? "healthy" : status,
24
+ ready: running,
25
+ pid: process.pid,
26
+ upstream: { type: "tcp" as const, host: "127.0.0.1", port },
27
+ };
28
+ };
29
+ return {
30
+ "/_akan/app/health": {
31
+ GET: () => Response.json({ status: read().status, pid: process.pid, solo: true, children: [child()] }),
32
+ },
33
+ "/_akan/app/metrics": {
34
+ GET: () =>
35
+ Response.json({
36
+ rooms: 0,
37
+ sockets: 0,
38
+ solo: true,
39
+ gateway: null,
40
+ proxyHop: null,
41
+ children: [{ ...child(), metrics: read().metrics }],
42
+ }),
43
+ },
44
+ "/_akan/bench/ping": { GET: () => new Response("ok") },
45
+ };
46
+ };
@@ -27,6 +27,9 @@ import { isTraceEnabled, runWithTrace, SignalTrace, traceSpan } from "./trace";
27
27
 
28
28
  export type SignalTransportType = "http" | "websocket";
29
29
 
30
+ /** What `Bun.Server.requestIP` reports for the socket a request arrived on. */
31
+ export type HttpPeerResolver = (req: Request) => { address: string; port: number } | null;
32
+
30
33
  const httpEndpointTypes = new Set<EndpointType>(["query", "mutation", "prompt"]);
31
34
 
32
35
  interface WebSocketRequest {
@@ -203,6 +206,16 @@ export class SignalContext<
203
206
  * hold for the life of the process. Building both per request cost an instance, a handler and a closure on every
204
207
  * call for every registered middleware — and `Logging` is registered by default.
205
208
  */
209
+ static #httpPeer: HttpPeerResolver | null = null;
210
+ /**
211
+ * Lets the http branch of `getClientIp` reach the socket the way the websocket branch already reaches
212
+ * `ws.remoteAddress`. Registered by whichever `Bun.serve` is listening, because only the server can answer
213
+ * `requestIP`. Behind the federation gateway this never fires — the gateway always writes `x-real-ip` —
214
+ * so it is the answer for a process nothing is proxying.
215
+ */
216
+ static setHttpPeerResolver(resolve: HttpPeerResolver | null) {
217
+ SignalContext.#httpPeer = resolve;
218
+ }
206
219
  static #middlewareHandlers = new WeakMap<MiddlewareCls, WeakMap<object, Promise<MiddlewareHandler>>>();
207
220
  static #getMiddlewareHandler(MiddlewareCls: MiddlewareCls, env: BackendEnv): Promise<MiddlewareHandler> {
208
221
  const byEnv =
@@ -440,14 +453,23 @@ export class SignalContext<
440
453
  * because a loopback-looking address for an unknown caller is the failure this replaced.
441
454
  */
442
455
  getClientIp(): string | null {
443
- if (this.transport === "http") return clientAddressFromHeaders(this.getHttpContext().req.headers);
456
+ if (this.transport === "http") {
457
+ const { req } = this.getHttpContext();
458
+ const forwarded = clientAddressFromHeaders(req.headers);
459
+ if (forwarded) return forwarded;
460
+ const peer = SignalContext.#httpPeer?.(req);
461
+ return peer ? normalizeIpAddress(peer.address) : null;
462
+ }
444
463
  const { ws } = this.getWebSocketContext<{ headers?: Headers }>();
445
464
  const forwarded = ws.data.headers ? clientAddressFromHeaders(ws.data.headers) : null;
446
465
  return forwarded ?? (ws.remoteAddress ? normalizeIpAddress(ws.remoteAddress) : null);
447
466
  }
448
467
  /** The caller's source port as the nearest proxy recorded it, else this socket's own. */
449
468
  getClientPort(): number | null {
450
- if (this.transport === "http") return clientPortFromHeaders(this.getHttpContext().req.headers);
469
+ if (this.transport === "http") {
470
+ const { req } = this.getHttpContext();
471
+ return clientPortFromHeaders(req.headers) ?? SignalContext.#httpPeer?.(req)?.port ?? null;
472
+ }
451
473
  const { ws } = this.getWebSocketContext<{ headers?: Headers }>();
452
474
  return (ws.data.headers ? clientPortFromHeaders(ws.data.headers) : null) ?? null;
453
475
  }
package/types/index.d.ts CHANGED
@@ -2,19 +2,36 @@ import type { AkanI18nConfig } from "akanjs/common";
2
2
  import type { AkanImageConfig } from "akanjs/server";
3
3
  export declare const archs: readonly ["amd64", "arm64"];
4
4
  export type Arch = (typeof archs)[number];
5
- export interface DockerConfig {
6
- content: string;
5
+ /** One image step. The object form runs only on the matching `TARGETARCH` leg of a multi-arch build. */
6
+ export type DockerRun = string | {
7
+ [key in Arch]?: string;
8
+ };
9
+ /** The pieces Akan assembles a Dockerfile from. */
10
+ export interface DockerImageConfig {
7
11
  image: string | {
8
12
  [key in Arch]?: string;
9
13
  };
10
- preRuns: (string | {
11
- [key in Arch]?: string;
12
- })[];
13
- postRuns: (string | {
14
- [key in Arch]?: string;
15
- })[];
14
+ /** Runs before `bun install`, so a system package a native dependency needs is there for the install. */
15
+ preRuns: DockerRun[];
16
+ /** Runs after `bun install`, before the app files are copied. */
17
+ postRuns: DockerRun[];
16
18
  command: string[];
17
19
  }
20
+ /**
21
+ * A whole Dockerfile as a string, or the parts Akan assembles one from. The string form is taken verbatim —
22
+ * nothing is merged into it, including the steps a lib contributes through its own `docker`.
23
+ */
24
+ export type DockerConfig = string | DockerImageConfig;
25
+ /** What an `akan.config.ts` may write for `docker`: a whole Dockerfile, or any subset of the parts. */
26
+ export type DockerOption = string | Partial<DockerImageConfig>;
27
+ /**
28
+ * A lib's contribution to the image of every app that mounts it — a lib never picks the base image or the
29
+ * command, only the steps its own runtime needs.
30
+ */
31
+ export interface LibDockerConfig {
32
+ preRuns: DockerRun[];
33
+ postRuns: DockerRun[];
34
+ }
18
35
  export interface AkanRouteDomains {
19
36
  main?: string[];
20
37
  develop?: string[];
@@ -198,6 +215,8 @@ export interface AppConfigResult {
198
215
  }
199
216
  export interface LibConfigResult {
200
217
  externalLibs: string[];
218
+ /** Image steps every app that mounts this lib inherits, unless that app declares a whole Dockerfile. */
219
+ docker: LibDockerConfig;
201
220
  }
202
221
  export type DeepPartial<T> = {
203
222
  [P in keyof T]?: T[P] extends unknown[] ? T[P] : T[P] extends object ? DeepPartial<T[P]> : T[P];
@@ -210,7 +229,8 @@ export interface LibConfigContext {
210
229
  readonly name: string;
211
230
  readonly type: "lib";
212
231
  }
213
- export type AppConfigInput = Omit<DeepPartial<AppConfigResult>, "web"> & {
232
+ export type AppConfigInput = Omit<DeepPartial<AppConfigResult>, "docker" | "web"> & {
233
+ docker?: DockerOption;
214
234
  web?: AkanWebOption;
215
235
  plugins?: AkanPlugin[];
216
236
  };
@@ -12,6 +12,13 @@ export interface AkanAppOptions {
12
12
  * module. Handed down as `AKAN_MODULES`, since each replica builds its own container.
13
13
  */
14
14
  modules?: string[];
15
+ /**
16
+ * Run the one replica in this process instead of spawning it — see `#startSolo`. Defaults on for a single
17
+ * traffic replica the environment configured, and off whenever `replica` is passed here, because code that
18
+ * states a topology is asking for the gateway that serves it. `AKAN_SOLO=false` turns it off; like
19
+ * `AKAN_SSR`, the env can only narrow, so it cannot force a gateway's replicas into one process.
20
+ */
21
+ solo?: boolean;
15
22
  }
16
23
  /** Gateway/orchestrator that starts Akan child servers and proxies HTTP/WebSocket traffic. */
17
24
  export declare class AkanApp {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Where a replica keeps its child sockets and rotating logs. Both the gateway and a solo server resolve it,
3
+ * and they must land on the same directory: switching between the two modes must not move the log file.
4
+ */
5
+ export declare const resolveRuntimeDir: (runtimeDir?: string) => string;
@@ -0,0 +1,15 @@
1
+ import type { AkanChildRole, AkanMetricsReport } from "akanjs/service";
2
+ import type { HttpRoutes } from "../types.d.ts";
3
+ export interface SoloAppStatus {
4
+ role: AkanChildRole;
5
+ running: boolean;
6
+ status: string;
7
+ port: number | null;
8
+ metrics: AkanMetricsReport;
9
+ }
10
+ /**
11
+ * The gateway's `/_akan/app/*` surface, answered by a process nothing is proxying. The payload keeps the
12
+ * gateway's own shape — a `children` array with this process as its only entry — so a probe, a k8s check and
13
+ * `akan` tooling read one contract whether or not a gateway is in front.
14
+ */
15
+ export declare const createSoloAppRoutes: (read: () => SoloAppStatus) => HttpRoutes;
@@ -5,6 +5,11 @@ import type { Internal, InternalInfo, MiddlewareCls } from ".";
5
5
  import type { EndpointInfo } from "./endpointInfo.d.ts";
6
6
  import { SignalTrace } from "./trace.d.ts";
7
7
  export type SignalTransportType = "http" | "websocket";
8
+ /** What `Bun.Server.requestIP` reports for the socket a request arrived on. */
9
+ export type HttpPeerResolver = (req: Request) => {
10
+ address: string;
11
+ port: number;
12
+ } | null;
8
13
  interface WebSocketRequest {
9
14
  ws: Bun.ServerWebSocket<unknown>;
10
15
  data: unknown[];
@@ -53,6 +58,13 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
53
58
  * closed with no arguments, so evaluating one here would delete every legitimate entry from the listing.
54
59
  */
55
60
  canListForAccount(): Promise<boolean>;
61
+ /**
62
+ * Lets the http branch of `getClientIp` reach the socket the way the websocket branch already reaches
63
+ * `ws.remoteAddress`. Registered by whichever `Bun.serve` is listening, because only the server can answer
64
+ * `requestIP`. Behind the federation gateway this never fires — the gateway always writes `x-real-ip` —
65
+ * so it is the answer for a process nothing is proxying.
66
+ */
67
+ static setHttpPeerResolver(resolve: HttpPeerResolver | null): void;
56
68
  exec(): Promise<Response | undefined>;
57
69
  static try(endpoint: Adaptor, endpointInfo: EndpointInfo, key: string, fn: () => Promise<Response | undefined>): Promise<Response | undefined>;
58
70
  static resolveReturn(value: unknown, { signalContext, returnRef, arrDepth, registry, live, }: {
@@ -7,7 +7,7 @@ interface ContextProps {
7
7
  *
8
8
  * The tool list leads, by name only: a zone publishes its tools scope-prefixed, and instructions that name a tool
9
9
  * without its prefix name a tool that does not exist. That is invisible in the source of either file and obvious
10
- * here.
10
+ * here. Renders nothing on `AKAN_PUBLIC_ENV=main`.
11
11
  */
12
- export default function Context({ className }: ContextProps): import("react/jsx-runtime").JSX.Element;
12
+ export default function Context({ className }: ContextProps): import("react/jsx-runtime").JSX.Element | null;
13
13
  export {};
@@ -12,6 +12,6 @@ export interface DockProps {
12
12
  * The in-page surface of the agent: what this screen declared an agent may do, what it may read, and what it has
13
13
  * done. Tools come from the surface rather than from the store, because a tool exists only where a component
14
14
  * declared one — the dock is the way to see that this screen published what its author thought it did, which is
15
- * the one thing no amount of reading the source answers.
15
+ * the one thing no amount of reading the source answers. Renders nothing on `AKAN_PUBLIC_ENV=main`.
16
16
  */
17
- export declare const Dock: ({ className, bridge, surface, open }: DockProps) => import("react/jsx-runtime").JSX.Element;
17
+ export declare const Dock: ({ className, bridge, surface, open }: DockProps) => import("react/jsx-runtime").JSX.Element | null;
@@ -6,7 +6,7 @@ import Transcript from "./Transcript.d.ts";
6
6
  export declare const Agent: {
7
7
  Chat: import("react").ComponentType<import("./Chat.d.ts").ChatProps>;
8
8
  Context: typeof Context;
9
- Dock: ({ className, bridge, surface, open }: import("./Dock.d.ts").DockProps) => import("react/jsx-runtime").JSX.Element;
9
+ Dock: ({ className, bridge, surface, open }: import("./Dock.d.ts").DockProps) => import("react/jsx-runtime").JSX.Element | null;
10
10
  Guide: ({ instructions }: import("./Guide.d.ts").GuideProps) => null;
11
11
  History: ({ load, save, clear, onCompact }: import("./History.d.ts").HistoryProps) => null;
12
12
  Scope: ({ id, label, kind, children }: import("../../vendor/use-agentic.d.ts").AgentScopeProps) => import("react/jsx-runtime").JSX.Element;
@@ -14,10 +14,12 @@ interface ContextProps {
14
14
  *
15
15
  * The tool list leads, by name only: a zone publishes its tools scope-prefixed, and instructions that name a tool
16
16
  * without its prefix name a tool that does not exist. That is invisible in the source of either file and obvious
17
- * here.
17
+ * here. Renders nothing on `AKAN_PUBLIC_ENV=main`.
18
18
  */
19
19
  export default function Context({ className }: ContextProps) {
20
20
  const [shown, setShown] = useState("");
21
+
22
+ if (process.env.AKAN_PUBLIC_ENV === "main" || process.env.NODE_ENV === "develop") return null;
21
23
  const assemble = () => {
22
24
  try {
23
25
  const { guides, tools } = AgenticSurface.shared.snapshot();
package/ui/Agent/Dock.tsx CHANGED
@@ -22,7 +22,7 @@ export interface DockProps {
22
22
  * The in-page surface of the agent: what this screen declared an agent may do, what it may read, and what it has
23
23
  * done. Tools come from the surface rather than from the store, because a tool exists only where a component
24
24
  * declared one — the dock is the way to see that this screen published what its author thought it did, which is
25
- * the one thing no amount of reading the source answers.
25
+ * the one thing no amount of reading the source answers. Renders nothing on `AKAN_PUBLIC_ENV=main`.
26
26
  */
27
27
  export const Dock = ({ className, bridge, surface, open = false }: DockProps) => {
28
28
  const held = useRef<{ bridge: AgentBridge; surface: AgenticSurface } | null>(null);
@@ -37,6 +37,8 @@ export const Dock = ({ className, bridge, surface, open = false }: DockProps) =>
37
37
  if (liveA !== liveB) return liveA ? -1 : 1;
38
38
  return a < b ? -1 : 1;
39
39
  });
40
+
41
+ if (process.env.AKAN_PUBLIC_ENV === "main") return null;
40
42
  return (
41
43
  <aside
42
44
  data-agent-ui=""