akanjs 3.0.0-alpha.64 → 3.0.0-alpha.66

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[];
@@ -24,6 +47,24 @@ export interface AkanRouteConfig {
24
47
  domains: AkanRouteDomains;
25
48
  }
26
49
 
50
+ /**
51
+ * Which web surfaces an app serves, resolved. `ssr` is the RSC/SSR route renderer and everything it needs —
52
+ * the pages bundle, the client bundles, the RSC worker process. `csr` is the single-file SPA shell that the
53
+ * Capacitor mobile build ships and that `/__csr` serves.
54
+ */
55
+ export interface AkanWebConfig {
56
+ ssr: boolean;
57
+ csr: boolean;
58
+ }
59
+
60
+ /**
61
+ * What an `akan.config.ts` may write. `false` is an API-only app — no web artifact is built and no web route
62
+ * is mounted; `true` (the default) is both surfaces. The object form keeps SSR and toggles only the CSR
63
+ * bundle, which is the whole range there is: the CSR bundle inlines the stylesheet the SSR build compiles, so
64
+ * CSR without SSR would ship an unstyled app and is not expressible here.
65
+ */
66
+ export type AkanWebOption = boolean | { csr: boolean };
67
+
27
68
  export type DatabaseMode = "single" | "multiple" | "cluster";
28
69
  export type MobileEnv = "local" | "debug" | "develop" | "main";
29
70
  export type MobilePermission = "camera" | "contacts" | "location" | "push" | "speech";
@@ -162,6 +203,8 @@ export interface AkanPlugin {
162
203
  export interface AppConfigResult {
163
204
  docker: DockerConfig;
164
205
  defaultDatabaseMode: DatabaseMode;
206
+ /** Web surfaces built into the app and mounted at boot. Both default to `true`. */
207
+ web: AkanWebConfig;
165
208
  routes?: AkanRouteConfig[];
166
209
  /**
167
210
  * Mounts `libs/<lib>/page` into this app under `page/(libs)/(<lib>)` on sync. `true` takes every lib
@@ -181,6 +224,8 @@ export interface AppConfigResult {
181
224
 
182
225
  export interface LibConfigResult {
183
226
  externalLibs: string[];
227
+ /** Image steps every app that mounts this lib inherits, unless that app declares a whole Dockerfile. */
228
+ docker: LibDockerConfig;
184
229
  }
185
230
 
186
231
  export type DeepPartial<T> = {
@@ -197,7 +242,11 @@ export interface LibConfigContext {
197
242
  readonly type: "lib";
198
243
  }
199
244
 
200
- export type AppConfigInput = DeepPartial<AppConfigResult> & { plugins?: AkanPlugin[] };
245
+ export type AppConfigInput = Omit<DeepPartial<AppConfigResult>, "docker" | "web"> & {
246
+ docker?: DockerOption;
247
+ web?: AkanWebOption;
248
+ plugins?: AkanPlugin[];
249
+ };
201
250
  export type LibConfigInput = DeepPartial<LibConfigResult> & { plugins?: AkanPlugin[] };
202
251
  export type AppConfig = AppConfigInput | ((app: AppConfigContext) => AppConfigInput);
203
252
  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.64",
3
+ "version": "3.0.0-alpha.66",
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,13 +4,15 @@ 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";
15
+ import { getWebConfigFromEnv } from "./types";
14
16
 
15
17
  interface ChildState {
16
18
  idx: number;
@@ -35,6 +37,11 @@ interface ChildState {
35
37
  lastErrorMessage?: string;
36
38
  }
37
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
+
38
45
  interface GatewayWsData {
39
46
  childIdx: number;
40
47
  upstream: WebSocket;
@@ -71,6 +78,13 @@ export interface AkanAppOptions {
71
78
  * module. Handed down as `AKAN_MODULES`, since each replica builds its own container.
72
79
  */
73
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;
74
88
  }
75
89
 
76
90
  interface AkanReplicaConfig {
@@ -103,7 +117,10 @@ export class AkanApp {
103
117
  readonly #port: number;
104
118
  readonly #wsBasePort: number;
105
119
  readonly #openapi?: boolean;
120
+ /** The gateway hands `/_akan/client|styles|fonts` straight off disk, so it needs the same answer its children do. */
121
+ readonly #web = getWebConfigFromEnv();
106
122
  readonly #modules: string[];
123
+ readonly #solo: boolean;
107
124
  readonly #children = new Map<number, ChildState>();
108
125
  readonly #roomChildren = new Map<string, Set<number>>();
109
126
  readonly #childRooms = new Map<number, Set<string>>();
@@ -135,17 +152,29 @@ export class AkanApp {
135
152
  this.#serverPath = AkanApp.#resolveServerPath(resolvedOptions.serverPath ?? serverPath);
136
153
  this.#artifactDir = path.resolve(path.dirname(this.#serverPath), ".akan", "artifact");
137
154
  this.#replica = AkanApp.#parseReplicaConfig(resolvedOptions.replica);
138
- this.#runtimeDir = path.resolve(
139
- resolvedOptions.runtimeDir ??
140
- process.env.AKAN_RUNTIME_DIR ??
141
- (process.env.NODE_ENV === "production"
142
- ? path.resolve(process.cwd(), "runtime")
143
- : path.resolve(process.cwd(), "local", "apps", process.env.AKAN_PUBLIC_APP_NAME ?? "unknown", "runtime")),
144
- );
155
+ this.#runtimeDir = resolveRuntimeDir(resolvedOptions.runtimeDir);
145
156
  this.#port = Number(resolvedOptions.port ?? process.env.PORT ?? 8282);
146
157
  this.#wsBasePort = Number(resolvedOptions.wsBasePort ?? process.env.AKAN_WS_BASE_PORT ?? this.#port + 10_000);
147
158
  this.#openapi = resolvedOptions.openapi;
148
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;
149
178
  }
150
179
 
151
180
  static #resolveServerPath(serverPath: string) {
@@ -212,6 +241,7 @@ export class AkanApp {
212
241
  }
213
242
 
214
243
  async start() {
244
+ if (this.#solo) return await this.#startSolo();
215
245
  await this.#prepareRuntimeDir();
216
246
  this.#startFileLogging();
217
247
  for (let idx = 0; idx < this.#replica.total; idx++) this.#spawn(idx);
@@ -239,6 +269,25 @@ export class AkanApp {
239
269
  });
240
270
  }
241
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
+
242
291
  async stop(signal = "SIGTERM") {
243
292
  if (this.#stopping) return;
244
293
  this.#stopping = true;
@@ -555,6 +604,7 @@ export class AkanApp {
555
604
  }
556
605
 
557
606
  async #serveImmutableArtifact(req: Request, url: URL): Promise<Response | null> {
607
+ if (!this.#web.ssr) return null;
558
608
  const clientPrefix = "/_akan/client/";
559
609
  if (url.pathname.startsWith(clientPrefix)) {
560
610
  const filePath = this.#safeResolve(
@@ -1,3 +1,4 @@
1
+ import type { AkanWebConfig, AkanWebOption } from "akanjs";
1
2
  import { type BackendEnv, type BaseEnv, getEnv } from "akanjs/base";
2
3
  import { Logger, websocketBinaryFrameContract } from "akanjs/common";
3
4
  import { DictionaryLookup } from "akanjs/dictionary";
@@ -5,6 +6,7 @@ import type {
5
6
  Adaptor,
6
7
  AdaptorCls,
7
8
  AkanIpcMessage,
9
+ AkanMetricsReport,
8
10
  DatabaseConfig,
9
11
  Service,
10
12
  ServiceCls,
@@ -14,6 +16,7 @@ import type { ServerSignal, ServerSignalCls, WebsocketPublishData } from "akanjs
14
16
  import { AgentRelayAccess } from "../signal/guards";
15
17
  import { createOpenApiDocument } from "../signal/openapi";
16
18
  import { FetchSerializer } from "../signal/serializer";
19
+ import { SignalContext } from "../signal/signalContext";
17
20
  import type { AkanLib, AkanLibProps } from "./akanLib";
18
21
  import type { BuilderRpc } from "./artifact";
19
22
  import { BinaryPubsub } from "./binaryPubsub";
@@ -21,14 +24,23 @@ import { DevtoolsRouter } from "./devtools";
21
24
  import { DiLifecycle } from "./di/diLifecycle";
22
25
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
23
26
  import { isPortInUseError } from "./lifecycle/portInUse";
27
+ import { resolveRuntimeDir } from "./lifecycle/runtimeDir";
24
28
  import { ShutdownManager } from "./lifecycle/shutdownManager";
29
+ import { RotatingLogWriter } from "./logging/rotatingLogWriter";
25
30
  import { type McpAuthOption, McpRouter } from "./mcp";
26
31
  import { ProcessMetricsCollector } from "./processMetricsCollector";
27
32
  import { WebProxyRunner } from "./proxy";
28
33
  import { SignalResolver } from "./resolver";
29
34
  import { ApiRouter } from "./routing/apiRouter";
30
35
  import type { AppWsData } from "./routing/appWsData";
31
- import type { HttpRoutes, LocalPublish, SignalRoutes, WebsocketRoutes } from "./types";
36
+ import { createSoloAppRoutes } from "./routing/soloAppRoutes";
37
+ import {
38
+ getWebConfigFromEnv,
39
+ type HttpRoutes,
40
+ type LocalPublish,
41
+ type SignalRoutes,
42
+ type WebsocketRoutes,
43
+ } from "./types";
32
44
  import type { WebRouter } from "./webRouter";
33
45
 
34
46
  export interface AkanServerProps extends AkanLibProps {
@@ -135,6 +147,8 @@ export class AkanServer {
135
147
  mcpAuth: McpAuthOption = AkanServer.#mcpAuthFromEnv();
136
148
  mcpOption: Omit<McpServerOption, "enabled" | "readOnly" | "auth"> = AkanServer.#mcpOptionFromEnv();
137
149
  serverMode: "federation" | "batch" | "all";
150
+ /** Resolved at `init`: what this process actually serves, after env and artifact availability. */
151
+ web: AkanWebConfig = getWebConfigFromEnv();
138
152
  modules: string[];
139
153
  shutdownTimeoutMs = AkanServer.#defaultShutdownTimeoutMs();
140
154
 
@@ -142,6 +156,16 @@ export class AkanServer {
142
156
  #localPublish: LocalPublish | null = null;
143
157
  readonly #binaryPubsub = new BinaryPubsub();
144
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 = {};
145
169
  constructor(
146
170
  name = "AkanServer",
147
171
  env: BackendEnv = {},
@@ -184,6 +208,12 @@ export class AkanServer {
184
208
  this.openapi = openapi;
185
209
  return this;
186
210
  }
211
+ /** Narrows the web surface this process serves. Never widens it past what the build produced. */
212
+ setWeb(web: AkanWebOption = true) {
213
+ if (this.status !== "stopped") throw new Error("Web config must be set before app initialization.");
214
+ this.web = AkanServer.#narrowWeb(this.web, web);
215
+ return this;
216
+ }
187
217
  setMcp(mcp: boolean | McpServerOption = true) {
188
218
  if (this.status !== "stopped") throw new Error("MCP config must be set before app initialization.");
189
219
  this.mcp = typeof mcp === "boolean" ? mcp : (mcp.enabled ?? true);
@@ -256,7 +286,7 @@ export class AkanServer {
256
286
  };
257
287
  }
258
288
 
259
- async init({ routes: initRoutes = true, web = true }: { routes?: boolean; web?: boolean } = {}) {
289
+ async init({ routes: initRoutes = true, web }: { routes?: boolean; web?: AkanWebOption } = {}) {
260
290
  if (this.status !== "stopped") throw new Error("AkanServer is not able to init. It is already running.");
261
291
  this.status = "initializing";
262
292
  const { routes, wsRoutes, routeOptions } = await this.#di.initializeAll();
@@ -265,7 +295,8 @@ export class AkanServer {
265
295
  this.status = "initialized";
266
296
  return this;
267
297
  }
268
- if (!web) {
298
+ const requestedWeb = AkanServer.#narrowWeb(this.web, web);
299
+ const noWeb = () => {
269
300
  this.#prepared = {
270
301
  routes,
271
302
  routeOptions,
@@ -279,11 +310,25 @@ export class AkanServer {
279
310
  };
280
311
  this.status = "initialized";
281
312
  return this;
313
+ };
314
+ if (!requestedWeb.ssr) {
315
+ this.web = requestedWeb;
316
+ this.logger.info("web off: serving api only (AKAN_SSR=false, or a build with `web: false`)");
317
+ return noWeb();
282
318
  }
283
319
  const { WebRouter } = await import("./webRouter");
284
320
  const webRouter = await WebRouter.create({
321
+ web: requestedWeb,
285
322
  upgradeHmrWs: (req, data) => this.#server?.upgrade(req, { data }) ?? false,
286
323
  });
324
+
325
+ if (!webRouter) {
326
+ this.web = { ssr: false, csr: false };
327
+ this.logger.warn("web off: no build artifact under .akan/artifact; serving api only");
328
+ return noWeb();
329
+ }
330
+ this.web = webRouter.web;
331
+ this.logger.info(`web on: ssr=${this.web.ssr} csr=${this.web.csr}`);
287
332
  const { renderEnvRoutes, hmrHub, builderRpc } = await webRouter.initializeRoute();
288
333
  const webProxyRunner = WebProxyRunner.create(this.#di.webProxies);
289
334
  this.#prepared = {
@@ -306,6 +351,7 @@ export class AkanServer {
306
351
  throw new Error("AkanServer is not able to listen. Call `init` first.");
307
352
  }
308
353
  this.status = "starting";
354
+ this.#startFileLogging();
309
355
  const port = process.env.AKAN_CHILD_SOCKET
310
356
  ? undefined
311
357
  : Number(process.env.AKAN_CHILD_WS_PORT || process.env.PORT || 8282);
@@ -371,6 +417,8 @@ export class AkanServer {
371
417
 
372
418
  const server = this.#server;
373
419
  const wsServer = this.#wsServer;
420
+
421
+ SignalContext.setHttpPeerResolver((req) => server?.requestIP(req as Bun.BunRequest) ?? null);
374
422
  hmrHub?.setPublisher((topic, payload) => {
375
423
  server?.publish(topic, payload);
376
424
  wsServer?.publish(topic, payload);
@@ -412,7 +460,7 @@ export class AkanServer {
412
460
  return this;
413
461
  }
414
462
 
415
- async start({ listen, web = true }: { listen?: boolean; web?: boolean } = {}) {
463
+ async start({ listen, web }: { listen?: boolean; web?: AkanWebOption } = {}) {
416
464
  const isNoListenCommand = process.env.AKAN_COMMAND_TYPE === "script" || process.env.AKAN_COMMAND_TYPE === "console";
417
465
  const shouldListen = (listen ?? !isNoListenCommand) && this.serverMode !== "batch";
418
466
  await this.init({ routes: shouldListen, web });
@@ -454,9 +502,11 @@ export class AkanServer {
454
502
  this.#wsServer?.stop(true);
455
503
  this.#server = null;
456
504
  this.#wsServer = null;
505
+ SignalContext.setHttpPeerResolver(null);
457
506
 
458
507
  this.#prepared?.webRouter?.dispose();
459
508
  await this.#withShutdownTimeout(this.#di.destroyAll());
509
+ await this.#stopFileLogging();
460
510
  this.#prepared = null;
461
511
  this.status = "stopped";
462
512
  this.logger.info(`Shutdown completed successfully in ${Date.now() - now}ms`);
@@ -517,6 +567,7 @@ export class AkanServer {
517
567
  pubsubCoalesceCount: this.#binaryPubsub.coalescedCount,
518
568
  ...(this.#prepared?.webRouter?.getMetrics() ?? {}),
519
569
  });
570
+ this.#lastMetrics = metrics;
520
571
  process.send?.({ type: "metrics.report", pid: process.pid, metrics } satisfies AkanIpcMessage);
521
572
  if (process.env.AKAN_MEMORY_LOG === "1") {
522
573
  this.logger.info(`memory role=${this.serverMode} ${ProcessMetricsCollector.format(metrics)}`);
@@ -570,6 +621,15 @@ export class AkanServer {
570
621
  })
571
622
  : null;
572
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
+ : {};
573
633
 
574
634
  mcpRouter?.report();
575
635
 
@@ -583,7 +643,24 @@ export class AkanServer {
583
643
  openapi: this.openapi,
584
644
  getStatus: () => this.status,
585
645
  }).createRoutes();
586
- 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();
587
664
  }
588
665
 
589
666
  /**
@@ -638,6 +715,13 @@ export class AkanServer {
638
715
  return !names.some((name) => process.env[name] === "false" || process.env[name] === "0");
639
716
  }
640
717
 
718
+ /** Narrows only — a surface the build or the env left out cannot be switched back on here. */
719
+ static #narrowWeb(current: AkanWebConfig, web: AkanWebOption | undefined): AkanWebConfig {
720
+ if (web === undefined || web === true) return current;
721
+ if (web === false) return { ssr: false, csr: false };
722
+ return { ssr: current.ssr, csr: web.csr && current.csr };
723
+ }
724
+
641
725
  /** Named rather than defaulted: an absent env must leave the option unset so a value written in code still wins. */
642
726
  static #isEnvOff(...names: string[]) {
643
727
  return names.some((name) => process.env[name] === "false" || process.env[name] === "0");
@@ -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;
@@ -9,6 +9,7 @@ import {
9
9
  INTERNAL_META,
10
10
  Int,
11
11
  PrimitiveRegistry,
12
+ type PromiseOrObject,
12
13
  SLICE_META,
13
14
  } from "akanjs/base";
14
15
  import { capitalize, Logger } from "akanjs/common";
@@ -331,6 +332,42 @@ export class SignalResolver {
331
332
  Bun.ServerWebSocket<unknown>,
332
333
  Map<string, SignalContext<WebSocketExecutionContext>>
333
334
  >();
335
+ /**
336
+ * Message contexts that registered a lifecycle handler. A message context is otherwise dropped the moment it
337
+ * answers, so `ws.on("disconnect", …)` from one would land in an object nothing reads again — a silent
338
+ * no-op beside the same call working from a pubsub subscribe.
339
+ */
340
+ static #liveWsMessageCtx = new WeakMap<Bun.ServerWebSocket<unknown>, Set<SignalContext<WebSocketExecutionContext>>>();
341
+ static #retainWsContext(ws: Bun.ServerWebSocket<unknown>, context: SignalContext<WebSocketExecutionContext>) {
342
+ const wsCtx = context.getWebSocketContext();
343
+ if (!wsCtx.onDisconnect.size && !wsCtx.onUnsubscribe.size) return;
344
+ const contexts = SignalResolver.#liveWsMessageCtx.get(ws) ?? new Set<SignalContext<WebSocketExecutionContext>>();
345
+ contexts.add(context);
346
+ SignalResolver.#liveWsMessageCtx.set(ws, contexts);
347
+ }
348
+ /**
349
+ * Cleanup handlers belong to the app, so one that throws must not take the rest of the teardown with it: a
350
+ * rejection here would skip `unregisterSocket` and leak the socket's room membership in Redis for good.
351
+ *
352
+ * A close ends both the subscription and the connection, so it passes both events — and a handler registered
353
+ * for both, the way a cleanup that must happen either way is written, runs once rather than twice.
354
+ */
355
+ static async #runLifecycleHandlers(
356
+ contexts: Iterable<SignalContext<WebSocketExecutionContext>>,
357
+ events: ("unsubscribe" | "disconnect")[],
358
+ ) {
359
+ const handlers = new Set<() => PromiseOrObject<void>>();
360
+ for (const event of events)
361
+ for (const context of contexts) {
362
+ const wsCtx = context.getWebSocketContext();
363
+ for (const handler of event === "disconnect" ? wsCtx.onDisconnect : wsCtx.onUnsubscribe) handlers.add(handler);
364
+ }
365
+ if (!handlers.size) return;
366
+ const results = await Promise.allSettled([...handlers].map(async (handler) => await handler()));
367
+ for (const result of results)
368
+ if (result.status === "rejected")
369
+ SignalResolver.logger.error(`WebSocket cleanup handler failed: ${result.reason}`);
370
+ }
334
371
  /**
335
372
  * A path may legitimately carry several methods — a `query` GET and a `mutation` POST sharing a custom `path` —
336
373
  * so methods merge rather than replace. The same method twice leaves one of the two endpoints unreachable with
@@ -451,10 +488,7 @@ export class SignalResolver {
451
488
  const roomCtxMap = SignalResolver.#liveWsPubsubRoomCtx.get(ws);
452
489
  if (roomCtxMap) {
453
490
  const roomCtx = roomCtxMap.get(roomId);
454
- if (roomCtx) {
455
- const unsubscribeHandlers = [...roomCtx.getWebSocketContext().onUnsubscribe.values()];
456
- await Promise.all(unsubscribeHandlers.map((handler) => handler()));
457
- }
491
+ if (roomCtx) await SignalResolver.#runLifecycleHandlers([roomCtx], ["unsubscribe"]);
458
492
  roomCtxMap.delete(roomId);
459
493
  if (roomCtxMap.size === 0) SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
460
494
 
@@ -474,6 +508,7 @@ export class SignalResolver {
474
508
  { endpointInfo, adaptor: endpoint, registry, env, live, middleware },
475
509
  ).init();
476
510
  const result = (await context.exec()) as object | object[];
511
+ SignalResolver.#retainWsContext(ws, context as SignalContext<WebSocketExecutionContext>);
477
512
  const messageData: WebsocketMessageData = { type: "msg", key, data: result };
478
513
  return messageData;
479
514
  };
@@ -535,7 +570,7 @@ export class SignalResolver {
535
570
  for (const [roomId, roomCtx] of [...roomCtxMap]) {
536
571
  if (await roomCtx.authorize()) continue;
537
572
  ws.unsubscribe(roomId);
538
- await Promise.all([...roomCtx.getWebSocketContext().onUnsubscribe.values()].map((handler) => handler()));
573
+ await SignalResolver.#runLifecycleHandlers([roomCtx], ["unsubscribe"]);
539
574
  roomCtxMap.delete(roomId);
540
575
  websocket.leaveRoom(ws, roomId);
541
576
  revokedRooms.push(roomId);
@@ -550,18 +585,13 @@ export class SignalResolver {
550
585
  }
551
586
 
552
587
  static async handleWsClose(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry) {
553
- const roomCtxMap = SignalResolver.#liveWsPubsubRoomCtx.get(ws);
554
- if (roomCtxMap) {
555
- const unsubscribeHandlers = [...roomCtxMap.values()].flatMap((roomCtx) => [
556
- ...roomCtx.getWebSocketContext().onUnsubscribe.values(),
557
- ]);
558
- await Promise.all(unsubscribeHandlers.map((handler) => handler()));
559
- const disconnectHandlers = [...roomCtxMap.values()].flatMap((roomCtx) => [
560
- ...roomCtx.getWebSocketContext().onDisconnect.values(),
561
- ]);
562
- await Promise.all(disconnectHandlers.map((handler) => handler()));
563
- }
588
+ const contexts = [
589
+ ...(SignalResolver.#liveWsPubsubRoomCtx.get(ws)?.values() ?? []),
590
+ ...(SignalResolver.#liveWsMessageCtx.get(ws) ?? []),
591
+ ];
592
+ await SignalResolver.#runLifecycleHandlers(contexts, ["unsubscribe", "disconnect"]);
564
593
  SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
594
+ SignalResolver.#liveWsMessageCtx.delete(ws);
565
595
 
566
596
  await SignalResolver.#getWebsocket(registry).unregisterSocket(ws);
567
597
  SignalResolver.logger.verbose(`WebSocket disconnected from all rooms`);
@@ -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
+ };
package/server/types.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AkanWebConfig } from "akanjs";
1
2
  import type { PromiseOrObject } from "akanjs/base";
2
3
  import type { AkanI18nConfig } from "akanjs/common";
3
4
  import type { ClientManifest } from "./artifact";
@@ -102,6 +103,28 @@ export type BaseBuildArtifact = {
102
103
  i18n: AkanI18nConfig;
103
104
  imageConfig: AkanImageConfig;
104
105
  deepLinkAssociations?: MobileDeepLinkAssociation[];
106
+ /**
107
+ * Which surfaces this artifact was built for. Absent on an artifact written before the option existed,
108
+ * which is read as both on — the shape every such build actually has.
109
+ */
110
+ web?: AkanWebConfig;
111
+ };
112
+
113
+ export const resolveWebConfig = (web: Partial<AkanWebConfig> | undefined): AkanWebConfig => ({
114
+ ssr: web?.ssr ?? true,
115
+ csr: web?.csr ?? true,
116
+ });
117
+
118
+ /**
119
+ * `AKAN_SSR` / `AKAN_CSR`, both on unless the env says otherwise — the same shape `AKAN_MCP` uses, because a
120
+ * switch a deployment has to find before anything works is a switch most deployments never find. Read by the
121
+ * gateway and by every replica, so both agree on what the pod serves.
122
+ */
123
+ export const getWebConfigFromEnv = (): AkanWebConfig => {
124
+ const off = (name: string) => process.env[name] === "false" || process.env[name] === "0";
125
+ const ssr = !off("AKAN_SSR");
126
+
127
+ return { ssr, csr: ssr && !off("AKAN_CSR") };
105
128
  };
106
129
 
107
130
  export interface MobileDeepLinkAssociation {
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
+ import type { AkanWebConfig } from "akanjs";
4
5
  import { getEnv } from "akanjs/base";
5
6
  import {
6
7
  type AkanI18nConfig,
@@ -54,7 +55,7 @@ import { createDefaultSitemapXml, getSitemapBasePath } from "./sitemap";
54
55
  import { SsrFromRscRenderer } from "./ssrFromRscRenderer";
55
56
  import type { RscTraceMetadata, SsrManifest } from "./ssrTypes";
56
57
  import { createSubRouteIndexResponse, createSystemPageResponse, getSystemPageHomeHref } from "./systemPages";
57
- import type { BaseBuildArtifact, HttpRoutes, RenderState } from "./types";
58
+ import { type BaseBuildArtifact, type HttpRoutes, type RenderState, resolveWebConfig } from "./types";
58
59
 
59
60
  const CLIENT_CLOSED_REQUEST_STATUS = 499;
60
61
  export const DEFAULT_HTML_RESULT_CACHE_MAX_BODY_BYTES = 2 * 1024 * 1024;
@@ -262,11 +263,13 @@ export interface SsrRoutesResult {
262
263
  }
263
264
 
264
265
  export interface SsrRoutesInputs {
266
+ web: AkanWebConfig;
265
267
  upgradeHmrWs: (req: Request, data: HmrWsData) => boolean;
266
268
  }
267
269
 
268
270
  interface WebRouterOptions {
269
271
  artifact: BaseBuildArtifact;
272
+ web: AkanWebConfig;
270
273
  cssBytesByUrl: Record<string, Uint8Array>;
271
274
  rsc: RscWorker;
272
275
  seedIndex: RouteSeedIndex;
@@ -319,9 +322,12 @@ export class WebRouter {
319
322
  #htmlCacheBypass = 0;
320
323
  #runtimeManifest: { revision: number; manifest: MergedManifest } | null = null;
321
324
  renderState: RenderState;
325
+ /** What this router actually mounts, already intersected with what the artifact carries. */
326
+ readonly web: AkanWebConfig;
322
327
  #seedIndex: RouteSeedIndex;
323
- constructor({ artifact, cssBytesByUrl, rsc, seedIndex, upgradeHmrWs }: WebRouterOptions) {
328
+ constructor({ artifact, web, cssBytesByUrl, rsc, seedIndex, upgradeHmrWs }: WebRouterOptions) {
324
329
  this.#logger.verbose(`[SSR] loaded ${Object.keys(cssBytesByUrl).length} CSS assets`);
330
+ this.web = web;
325
331
  if (process.env.NODE_ENV === "production" && !this.#prodMode)
326
332
  this.#logger.warn("[SSR] NODE_ENV=production ignored under `akan start`; serving in dev mode");
327
333
  this.#artifact = artifact;
@@ -382,14 +388,16 @@ export class WebRouter {
382
388
  });
383
389
 
384
390
  const renderEnvRoutes: HttpRoutes = {
385
- "/__csr": async () => {
386
- this.#requestStats.csr += 1;
387
- const csrHtml = await this.#resolveCsrHtml(csrOutputDir, "/");
388
- const csrFile = csrHtml ? Bun.file(csrHtml) : null;
389
- const htmlText =
390
- csrFile && (await csrFile.exists())
391
- ? await csrFile.text()
392
- : `<!doctype html>
391
+ ...(this.web.csr
392
+ ? {
393
+ "/__csr": async () => {
394
+ this.#requestStats.csr += 1;
395
+ const csrHtml = await this.#resolveCsrHtml(csrOutputDir, "/");
396
+ const csrFile = csrHtml ? Bun.file(csrHtml) : null;
397
+ const htmlText =
398
+ csrFile && (await csrFile.exists())
399
+ ? await csrFile.text()
400
+ : `<!doctype html>
393
401
  <html lang="en">
394
402
  <head>
395
403
  <meta charset="utf-8" />
@@ -402,10 +410,12 @@ export class WebRouter {
402
410
  <script type="module" src="/csr.js"></script>
403
411
  </body>
404
412
  </html>`;
405
- return new Response(this.#withCsrHmr(htmlText), {
406
- headers: { "Content-Type": "text/html; charset=utf-8" },
407
- });
408
- },
413
+ return new Response(this.#withCsrHmr(htmlText), {
414
+ headers: { "Content-Type": "text/html; charset=utf-8" },
415
+ });
416
+ },
417
+ }
418
+ : {}),
409
419
  [`${clientServePrefix}/*`]: async (req) => {
410
420
  this.#requestStats.staticAsset += 1;
411
421
  const url = new URL(req.url);
@@ -526,20 +536,20 @@ export class WebRouter {
526
536
  return imageOptimizer.handle(req);
527
537
  }
528
538
 
529
- const isCsr = url.searchParams.get("csr") === "true";
530
- if (isCsr) {
531
- this.#requestStats.csr += 1;
532
- const csrHtml = await this.#resolveCsrHtml(csrOutputDir, url.pathname);
533
- if (!csrHtml) return this.#csrUnavailableResponse(url.pathname);
534
- const html = await Bun.file(csrHtml).text();
535
- return new Response(this.#withCsrHmr(html), {
536
- headers: { "Content-Type": "text/html; charset=utf-8" },
537
- });
538
- }
539
+ if (this.web.csr) {
540
+ const isCsr = url.searchParams.get("csr") === "true";
541
+ if (isCsr) {
542
+ this.#requestStats.csr += 1;
543
+ const csrHtml = await this.#resolveCsrHtml(csrOutputDir, url.pathname);
544
+ if (!csrHtml) return this.#csrUnavailableResponse(url.pathname);
545
+ const html = await Bun.file(csrHtml).text();
546
+ return new Response(this.#withCsrHmr(html), {
547
+ headers: { "Content-Type": "text/html; charset=utf-8" },
548
+ });
549
+ }
539
550
 
540
- const csrAssetPath = path.extname(url.pathname) ? WebRouter.#safeResolve(csrOutputDir, url.pathname) : null;
541
- if (csrAssetPath) {
542
- if (await Bun.file(csrAssetPath).exists()) {
551
+ const csrAssetPath = path.extname(url.pathname) ? WebRouter.#safeResolve(csrOutputDir, url.pathname) : null;
552
+ if (csrAssetPath && (await Bun.file(csrAssetPath).exists())) {
543
553
  this.#requestStats.staticAsset += 1;
544
554
  return WebRouter.#fileResponse(req, csrAssetPath, {
545
555
  contentType: Bun.file(csrAssetPath).type || "application/octet-stream",
@@ -1034,18 +1044,24 @@ export class WebRouter {
1034
1044
  });
1035
1045
  }
1036
1046
 
1037
- static async create({ upgradeHmrWs }: SsrRoutesInputs) {
1047
+ /**
1048
+ * `null` when the build produced no web artifact — an api-only build, or a workspace with no `page/` at all.
1049
+ * The caller boots without a web surface instead of failing on the missing file.
1050
+ */
1051
+ static async create({ web, upgradeHmrWs }: SsrRoutesInputs): Promise<WebRouter | null> {
1038
1052
  const artifactDir = WebRouter.#resolveArtifactDir();
1039
- const artifact = WebRouter.#normalizeArtifact(
1040
- (await Bun.file(path.join(artifactDir, "base-artifact.json")).json()) as BaseBuildArtifact,
1041
- artifactDir,
1042
- );
1053
+ const artifactFile = Bun.file(path.join(artifactDir, "base-artifact.json"));
1054
+ if (!(await artifactFile.exists())) return null;
1055
+ const artifact = WebRouter.#normalizeArtifact((await artifactFile.json()) as BaseBuildArtifact, artifactDir);
1056
+ const builtWeb = resolveWebConfig(artifact.web);
1057
+ if (!builtWeb.ssr) return null;
1043
1058
  const cssBytesByUrl = await WebRouter.#loadCssBytesByUrl(artifact, artifactDir);
1044
1059
  const rsc = new RscWorker(artifact);
1045
1060
  await rsc.ready;
1046
1061
  const seedIndex = await RouteSeedIndexStore.load(artifactDir);
1047
1062
  return new WebRouter({
1048
1063
  artifact,
1064
+ web: { ssr: true, csr: web.csr && builtWeb.csr },
1049
1065
  cssBytesByUrl,
1050
1066
  rsc,
1051
1067
  seedIndex,
@@ -38,10 +38,9 @@ export class Ip implements InternalArg<string | null> {
38
38
  * Injects websocket state, this connection's id, and subscription hooks into message/pubsub handlers.
39
39
  * `socketId` is the one `AppWsData` minted at the handshake, so a handler never reads `ws.data` to
40
40
  * tell two callers apart — and never mints an id of its own, which would not match the room bookkeeping.
41
+ * `on`/`off` register cleanup that runs when the room is unsubscribed or the socket closes.
41
42
  */
42
43
  export class Ws implements InternalArg {
43
- onDisconnect?: () => void;
44
- onUnsubscribe?: () => void;
45
44
  getArg(context: SignalContext) {
46
45
  const webSocketContext = context.getWebSocketContext<{ socketId: string }>();
47
46
  const ws = webSocketContext.ws;
@@ -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
  }
@@ -593,14 +615,15 @@ export class WebSocketExecutionContext<Appended = unknown> {
593
615
  nullable: endpointInfo.returns.nullable,
594
616
  }) as unknown as Response;
595
617
  }
596
- on(event: "disconnect" | "unsubscribe", handler: () => void) {
618
+
619
+ on = (event: "disconnect" | "unsubscribe", handler: () => PromiseOrObject<void>) => {
597
620
  if (event === "disconnect") this.onDisconnect.add(handler);
598
621
  else this.onUnsubscribe.add(handler);
599
- }
600
- off(event: "disconnect" | "unsubscribe", handler: () => void) {
622
+ };
623
+ off = (event: "disconnect" | "unsubscribe", handler: () => PromiseOrObject<void>) => {
601
624
  if (event === "disconnect") this.onDisconnect.delete(handler);
602
625
  else this.onUnsubscribe.delete(handler);
603
- }
626
+ };
604
627
  }
605
628
 
606
629
  export class ResolveFieldContext {
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[];
@@ -25,6 +42,24 @@ export interface AkanRouteConfig {
25
42
  basePath?: string;
26
43
  domains: AkanRouteDomains;
27
44
  }
45
+ /**
46
+ * Which web surfaces an app serves, resolved. `ssr` is the RSC/SSR route renderer and everything it needs —
47
+ * the pages bundle, the client bundles, the RSC worker process. `csr` is the single-file SPA shell that the
48
+ * Capacitor mobile build ships and that `/__csr` serves.
49
+ */
50
+ export interface AkanWebConfig {
51
+ ssr: boolean;
52
+ csr: boolean;
53
+ }
54
+ /**
55
+ * What an `akan.config.ts` may write. `false` is an API-only app — no web artifact is built and no web route
56
+ * is mounted; `true` (the default) is both surfaces. The object form keeps SSR and toggles only the CSR
57
+ * bundle, which is the whole range there is: the CSR bundle inlines the stylesheet the SSR build compiles, so
58
+ * CSR without SSR would ship an unstyled app and is not expressible here.
59
+ */
60
+ export type AkanWebOption = boolean | {
61
+ csr: boolean;
62
+ };
28
63
  export type DatabaseMode = "single" | "multiple" | "cluster";
29
64
  export type MobileEnv = "local" | "debug" | "develop" | "main";
30
65
  export type MobilePermission = "camera" | "contacts" | "location" | "push" | "speech";
@@ -160,6 +195,8 @@ export interface AkanPlugin {
160
195
  export interface AppConfigResult {
161
196
  docker: DockerConfig;
162
197
  defaultDatabaseMode: DatabaseMode;
198
+ /** Web surfaces built into the app and mounted at boot. Both default to `true`. */
199
+ web: AkanWebConfig;
163
200
  routes?: AkanRouteConfig[];
164
201
  /**
165
202
  * Mounts `libs/<lib>/page` into this app under `page/(libs)/(<lib>)` on sync. `true` takes every lib
@@ -178,6 +215,8 @@ export interface AppConfigResult {
178
215
  }
179
216
  export interface LibConfigResult {
180
217
  externalLibs: string[];
218
+ /** Image steps every app that mounts this lib inherits, unless that app declares a whole Dockerfile. */
219
+ docker: LibDockerConfig;
181
220
  }
182
221
  export type DeepPartial<T> = {
183
222
  [P in keyof T]?: T[P] extends unknown[] ? T[P] : T[P] extends object ? DeepPartial<T[P]> : T[P];
@@ -190,7 +229,9 @@ export interface LibConfigContext {
190
229
  readonly name: string;
191
230
  readonly type: "lib";
192
231
  }
193
- export type AppConfigInput = DeepPartial<AppConfigResult> & {
232
+ export type AppConfigInput = Omit<DeepPartial<AppConfigResult>, "docker" | "web"> & {
233
+ docker?: DockerOption;
234
+ web?: AkanWebOption;
194
235
  plugins?: AkanPlugin[];
195
236
  };
196
237
  export type LibConfigInput = DeepPartial<LibConfigResult> & {
@@ -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 {
@@ -1,3 +1,4 @@
1
+ import type { AkanWebConfig, AkanWebOption } from "akanjs";
1
2
  import { type BackendEnv, type BaseEnv } from "akanjs/base";
2
3
  import { Logger } from "akanjs/common";
3
4
  import type { Adaptor, AdaptorCls, DatabaseConfig, Service, ServiceCls, SolidConfig } from "akanjs/service";
@@ -89,12 +90,16 @@ export declare class AkanServer {
89
90
  mcpAuth: McpAuthOption;
90
91
  mcpOption: Omit<McpServerOption, "enabled" | "readOnly" | "auth">;
91
92
  serverMode: "federation" | "batch" | "all";
93
+ /** Resolved at `init`: what this process actually serves, after env and artifact availability. */
94
+ web: AkanWebConfig;
92
95
  modules: string[];
93
96
  shutdownTimeoutMs: number;
94
97
  constructor(name?: string, env?: BackendEnv, serverMode?: "federation" | "batch" | "all", ...libsOrOptions: (AkanLib | AkanServerOptions)[]);
95
98
  setPrefix(prefix: string): this;
96
99
  setWebsocketPrefix(websocketPrefix: string): this;
97
100
  setOpenApi(openapi?: boolean): this;
101
+ /** Narrows the web surface this process serves. Never widens it past what the build produced. */
102
+ setWeb(web?: AkanWebOption): this;
98
103
  setMcp(mcp?: boolean | McpServerOption): this;
99
104
  setDatabaseConfig(database: DatabaseConfig): this;
100
105
  setSolidConfig(solid: SolidConfig): this;
@@ -108,12 +113,12 @@ export declare class AkanServer {
108
113
  inspectConsole(): AkanServerConsoleInfo;
109
114
  init({ routes: initRoutes, web }?: {
110
115
  routes?: boolean;
111
- web?: boolean;
116
+ web?: AkanWebOption;
112
117
  }): Promise<this>;
113
118
  listen(): Promise<this>;
114
119
  start({ listen, web }?: {
115
120
  listen?: boolean;
116
- web?: boolean;
121
+ web?: AkanWebOption;
117
122
  }): Promise<this>;
118
123
  stop(): Promise<void>;
119
124
  }
@@ -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;
@@ -1,3 +1,4 @@
1
+ import type { AkanWebConfig } from "akanjs";
1
2
  import type { PromiseOrObject } from "akanjs/base";
2
3
  import type { AkanI18nConfig } from "akanjs/common";
3
4
  import type { ClientManifest } from "./artifact.d.ts";
@@ -61,7 +62,19 @@ export type BaseBuildArtifact = {
61
62
  i18n: AkanI18nConfig;
62
63
  imageConfig: AkanImageConfig;
63
64
  deepLinkAssociations?: MobileDeepLinkAssociation[];
65
+ /**
66
+ * Which surfaces this artifact was built for. Absent on an artifact written before the option existed,
67
+ * which is read as both on — the shape every such build actually has.
68
+ */
69
+ web?: AkanWebConfig;
64
70
  };
71
+ export declare const resolveWebConfig: (web: Partial<AkanWebConfig> | undefined) => AkanWebConfig;
72
+ /**
73
+ * `AKAN_SSR` / `AKAN_CSR`, both on unless the env says otherwise — the same shape `AKAN_MCP` uses, because a
74
+ * switch a deployment has to find before anything works is a switch most deployments never find. Read by the
75
+ * gateway and by every replica, so both agree on what the pod serves.
76
+ */
77
+ export declare const getWebConfigFromEnv: () => AkanWebConfig;
65
78
  export interface MobileDeepLinkAssociation {
66
79
  targetName: string;
67
80
  appId: string;
@@ -1,3 +1,4 @@
1
+ import type { AkanWebConfig } from "akanjs";
1
2
  import { type AkanI18nConfig } from "akanjs/common";
2
3
  import { type AkanRequestStore } from "akanjs/fetch";
3
4
  import type { AkanMetricsReport } from "akanjs/service";
@@ -5,7 +6,7 @@ import { type BuilderRpc, type RouteSeedIndex } from "./artifact.d.ts";
5
6
  import { type RouteCacheInvalidation, type RouteCacheRenderState } from "./cachePolicy.d.ts";
6
7
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub.d.ts";
7
8
  import { type RscRedirectMethod, type RscRedirectStatus, type RscRenderResult, RscWorker } from "./rscWorkerHost.d.ts";
8
- import type { BaseBuildArtifact, HttpRoutes, RenderState } from "./types.d.ts";
9
+ import { type BaseBuildArtifact, type HttpRoutes, type RenderState } from "./types.d.ts";
9
10
  export declare const DEFAULT_HTML_RESULT_CACHE_MAX_BODY_BYTES: number;
10
11
  export declare function createRscRedirectResponse(location: string, method: RscRedirectMethod, status?: RscRedirectStatus): Response;
11
12
  export declare function createRscStreamResponse(stream: BodyInit, status?: number): Response;
@@ -49,10 +50,12 @@ export interface SsrRoutesResult {
49
50
  builderRpc: BuilderRpc | null;
50
51
  }
51
52
  export interface SsrRoutesInputs {
53
+ web: AkanWebConfig;
52
54
  upgradeHmrWs: (req: Request, data: HmrWsData) => boolean;
53
55
  }
54
56
  interface WebRouterOptions {
55
57
  artifact: BaseBuildArtifact;
58
+ web: AkanWebConfig;
56
59
  cssBytesByUrl: Record<string, Uint8Array>;
57
60
  rsc: RscWorker;
58
61
  seedIndex: RouteSeedIndex;
@@ -61,7 +64,9 @@ interface WebRouterOptions {
61
64
  export declare class WebRouter {
62
65
  #private;
63
66
  renderState: RenderState;
64
- constructor({ artifact, cssBytesByUrl, rsc, seedIndex, upgradeHmrWs }: WebRouterOptions);
67
+ /** What this router actually mounts, already intersected with what the artifact carries. */
68
+ readonly web: AkanWebConfig;
69
+ constructor({ artifact, web, cssBytesByUrl, rsc, seedIndex, upgradeHmrWs }: WebRouterOptions);
65
70
  initializeRoute(): Promise<{
66
71
  renderEnvRoutes: Bun.Serve.Routes<unknown, string> | Bun.Serve.RoutesWithUpgrade<unknown, string>;
67
72
  hmrHub: HmrWsHub | null;
@@ -71,6 +76,10 @@ export declare class WebRouter {
71
76
  getMetrics(): AkanMetricsReport;
72
77
  /** @internal Clears or scopes invalidation for local route result caches owned by the host and RSC worker. */
73
78
  invalidateRouteCaches(invalidation?: string | RouteCacheInvalidation): void;
74
- static create({ upgradeHmrWs }: SsrRoutesInputs): Promise<WebRouter>;
79
+ /**
80
+ * `null` when the build produced no web artifact — an api-only build, or a workspace with no `page/` at all.
81
+ * The caller boots without a web surface instead of failing on the missing file.
82
+ */
83
+ static create({ web, upgradeHmrWs }: SsrRoutesInputs): Promise<WebRouter | null>;
75
84
  }
76
85
  export {};
@@ -32,17 +32,16 @@ export declare class Ip implements InternalArg<string | null> {
32
32
  * Injects websocket state, this connection's id, and subscription hooks into message/pubsub handlers.
33
33
  * `socketId` is the one `AppWsData` minted at the handshake, so a handler never reads `ws.data` to
34
34
  * tell two callers apart — and never mints an id of its own, which would not match the room bookkeeping.
35
+ * `on`/`off` register cleanup that runs when the room is unsubscribed or the socket closes.
35
36
  */
36
37
  export declare class Ws implements InternalArg {
37
- onDisconnect?: () => void;
38
- onUnsubscribe?: () => void;
39
38
  getArg(context: SignalContext): {
40
39
  ws: Bun.ServerWebSocket<{
41
40
  socketId: string;
42
41
  }>;
43
42
  socketId: string;
44
43
  subscribe: boolean;
45
- on: (event: "disconnect" | "unsubscribe", handler: () => void) => void;
46
- off: (event: "disconnect" | "unsubscribe", handler: () => void) => void;
44
+ on: (event: "disconnect" | "unsubscribe", handler: () => PromiseOrObject<void>) => void;
45
+ off: (event: "disconnect" | "unsubscribe", handler: () => PromiseOrObject<void>) => void;
47
46
  };
48
47
  }
@@ -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, }: {
@@ -114,8 +126,8 @@ export declare class WebSocketExecutionContext<Appended = unknown> {
114
126
  constructor(wsReq: WebSocketRequest);
115
127
  getArgs(endpointInfo: EndpointInfo): Promise<unknown[]>;
116
128
  makeResponse(result: unknown, endpointInfo: EndpointInfo): Response;
117
- on(event: "disconnect" | "unsubscribe", handler: () => void): void;
118
- off(event: "disconnect" | "unsubscribe", handler: () => void): void;
129
+ on: (event: "disconnect" | "unsubscribe", handler: () => PromiseOrObject<void>) => void;
130
+ off: (event: "disconnect" | "unsubscribe", handler: () => PromiseOrObject<void>) => void;
119
131
  }
120
132
  export declare class ResolveFieldContext {
121
133
  signalContext: SignalContext | null;