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 +24 -1
- 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 +1 -1
- package/server/akanApp.ts +4 -0
- package/server/akanServer.ts +41 -4
- package/server/resolver/signal.resolver.ts +46 -16
- package/server/types.tsx +23 -0
- package/server/webRouter.ts +48 -32
- package/signal/internalArg.ts +1 -2
- package/signal/signalContext.ts +5 -4
- package/types/index.d.ts +22 -1
- package/types/server/akanServer.d.ts +7 -2
- 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 +2 -2
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> & {
|
|
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
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
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(
|
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";
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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`);
|
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
|
@@ -593,14 +593,15 @@ export class WebSocketExecutionContext<Appended = unknown> {
|
|
|
593
593
|
nullable: endpointInfo.returns.nullable,
|
|
594
594
|
}) as unknown as Response;
|
|
595
595
|
}
|
|
596
|
-
|
|
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?:
|
|
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
|
}
|
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
|
}
|
|
@@ -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)
|
|
118
|
-
off(event: "disconnect" | "unsubscribe", handler: () => 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;
|