akanjs 3.0.0-alpha.64 → 3.0.0-alpha.65

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