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 +31 -5
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +6 -1
- package/server/akanApp.ts +54 -8
- package/server/akanServer.ts +49 -2
- package/server/lifecycle/runtimeDir.ts +14 -0
- package/server/processMetricsCollector.ts +1 -1
- package/server/routing/soloAppRoutes.ts +46 -0
- package/signal/signalContext.ts +24 -2
- package/types/index.d.ts +29 -9
- package/types/server/akanApp.d.ts +7 -0
- package/types/server/lifecycle/runtimeDir.d.ts +5 -0
- package/types/server/routing/soloAppRoutes.d.ts +15 -0
- package/types/signal/signalContext.d.ts +12 -0
- package/types/ui/Agent/Context.d.ts +2 -2
- package/types/ui/Agent/Dock.d.ts +2 -2
- package/types/ui/Agent/index.d.ts +1 -1
- package/ui/Agent/Context.tsx +3 -1
- package/ui/Agent/Dock.tsx +3 -1
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
};
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akanjs",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
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 "
|
|
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 =
|
|
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;
|
package/server/akanServer.ts
CHANGED
|
@@ -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.
|
|
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
|
+
);
|
|
@@ -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/signal/signalContext.ts
CHANGED
|
@@ -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")
|
|
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")
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
postRuns:
|
|
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 {};
|
package/types/ui/Agent/Dock.d.ts
CHANGED
|
@@ -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;
|
package/ui/Agent/Context.tsx
CHANGED
|
@@ -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=""
|