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 +54 -5
- package/local/apps/akan/akan-local.db +0 -0
- package/local/apps/akan/akan-local.db-shm +0 -0
- package/local/apps/akan/akan-local.db-wal +0 -0
- package/local/apps/akan/akan-local_solid.db +0 -0
- package/local/apps/akan/akan-local_solid.db-shm +0 -0
- package/local/apps/akan/akan-local_solid.db-wal +0 -0
- 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 +58 -8
- package/server/akanServer.ts +89 -5
- package/server/lifecycle/runtimeDir.ts +14 -0
- package/server/processMetricsCollector.ts +1 -1
- package/server/resolver/signal.resolver.ts +46 -16
- package/server/routing/soloAppRoutes.ts +46 -0
- package/server/types.tsx +23 -0
- package/server/webRouter.ts +48 -32
- package/signal/internalArg.ts +1 -2
- package/signal/signalContext.ts +29 -6
- package/types/index.d.ts +50 -9
- package/types/server/akanApp.d.ts +7 -0
- package/types/server/akanServer.d.ts +7 -2
- package/types/server/lifecycle/runtimeDir.d.ts +5 -0
- package/types/server/routing/soloAppRoutes.d.ts +15 -0
- package/types/server/types.d.ts +13 -0
- package/types/server/webRouter.d.ts +12 -3
- package/types/signal/internalArg.d.ts +3 -4
- package/types/signal/signalContext.d.ts +14 -2
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[];
|
|
@@ -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> & {
|
|
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
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
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.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 "
|
|
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 =
|
|
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(
|
package/server/akanServer.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
);
|
|
@@ -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
|
|
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
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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 {
|
package/server/webRouter.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
541
|
-
|
|
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
|
-
|
|
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
|
|
1040
|
-
|
|
1041
|
-
|
|
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,
|
package/signal/internalArg.ts
CHANGED
|
@@ -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;
|
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
|
}
|
|
@@ -593,14 +615,15 @@ export class WebSocketExecutionContext<Appended = unknown> {
|
|
|
593
615
|
nullable: endpointInfo.returns.nullable,
|
|
594
616
|
}) as unknown as Response;
|
|
595
617
|
}
|
|
596
|
-
|
|
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
|
-
|
|
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[];
|
|
@@ -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?:
|
|
116
|
+
web?: AkanWebOption;
|
|
112
117
|
}): Promise<this>;
|
|
113
118
|
listen(): Promise<this>;
|
|
114
119
|
start({ listen, web }?: {
|
|
115
120
|
listen?: boolean;
|
|
116
|
-
web?:
|
|
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;
|
package/types/server/types.d.ts
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.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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
118
|
-
off(event: "disconnect" | "unsubscribe", handler: () => 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;
|