akanjs 2.4.1 → 2.4.2-rc.0

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.
@@ -50,6 +50,13 @@ export interface PageConfig {
50
50
  rscPatchHeadSafe?: boolean;
51
51
  topSafeAreaColor?: string;
52
52
  bottomSafeAreaColor?: string;
53
+ /**
54
+ * Keeps the route out of `akan build`. The route still serves under `akan start`, but nothing about it
55
+ * reaches production: no bundle, no manifest entry, no URL. On a `_layout`, every route under that
56
+ * directory is excluded with it. Must be written as a literal `true`/`false` — the build reads it from
57
+ * the source without evaluating the module.
58
+ */
59
+ devOnly?: boolean;
53
60
  }
54
61
 
55
62
  export interface CsrState {
@@ -24,6 +24,8 @@ const pageConfigKeys = new Set<keyof PageConfig>([
24
24
  "topSafeAreaColor",
25
25
  "bottomSafeAreaColor",
26
26
  ]);
27
+
28
+ const buildPageConfigKeys = new Set<keyof PageConfig>(["devOnly"]);
27
29
  const transitionTypes = new Set<TransitionType>(["none", "fade", "bottomUp", "stack", "scaleOut"]);
28
30
  const ssrRenderModes = new Set<SsrRenderMode>(["stream", "block"]);
29
31
  const DEFAULT_BOOLEAN_INSET = 48;
@@ -38,10 +40,13 @@ export function validatePageConfig(routeKey: string, config?: PageConfig) {
38
40
  if (!isRecord(config)) throw new Error(`[route-convention] pageConfig in ${routeKey} must be an object.`);
39
41
  const pageConfig = config as PageConfig;
40
42
  for (const key of Object.keys(pageConfig)) {
41
- if (!pageConfigKeys.has(key as keyof PageConfig)) {
43
+ if (!pageConfigKeys.has(key as keyof PageConfig) && !buildPageConfigKeys.has(key as keyof PageConfig)) {
42
44
  throw new Error(`[route-convention] unsupported pageConfig option "${key}" in ${routeKey}`);
43
45
  }
44
46
  }
47
+ if (pageConfig.devOnly !== undefined && typeof pageConfig.devOnly !== "boolean") {
48
+ throw new Error(`[route-convention] pageConfig.devOnly in ${routeKey} must be a boolean.`);
49
+ }
45
50
  if (pageConfig.transition !== undefined && !transitionTypes.has(pageConfig.transition)) {
46
51
  throw new Error(`[route-convention] unsupported pageConfig.transition "${pageConfig.transition}" in ${routeKey}`);
47
52
  }
@@ -1,5 +1,6 @@
1
1
  declare global {
2
2
  var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
3
+ var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
3
4
  var __AKAN_RSC_NAVIGATE__:
4
5
  | ((href: string, options?: { replace?: boolean; scrollToTop?: boolean }) => Promise<void>)
5
6
  | undefined;
@@ -9,11 +10,19 @@ export const clearRscNavigationCache = () => {
9
10
  globalThis.__AKAN_RSC_CLEAR_CACHE__?.();
10
11
  };
11
12
 
13
+ /**
14
+ * True when the page tree currently on screen was replayed from the RSC navigation cache instead of
15
+ * fetched from the server. Data hydrated out of such a payload can be arbitrarily old, so anything
16
+ * that must show current values should refetch.
17
+ */
18
+ export const isRscNavigationFromCache = () => globalThis.__AKAN_RSC_IS_FROM_CACHE__?.() ?? false;
19
+
12
20
  export const navigateRsc = (href: string, options?: { replace?: boolean; scrollToTop?: boolean }) => {
13
21
  return globalThis.__AKAN_RSC_NAVIGATE__?.(href, options);
14
22
  };
15
23
 
16
24
  export const useRscNavigation = () => ({
17
25
  clearCache: clearRscNavigationCache,
26
+ isFromCache: isRscNavigationFromCache,
18
27
  navigate: navigateRsc,
19
28
  });
package/common/index.ts CHANGED
@@ -54,3 +54,8 @@ export { sleep } from "./sleep";
54
54
  export { splitVersion } from "./splitVersion";
55
55
  export { getBasePathFromPathname, parseBasePaths } from "./subRoute";
56
56
  export type * from "./types";
57
+ export {
58
+ type WebsocketAuthAckData,
59
+ type WebsocketAuthRequest,
60
+ websocketAuthContract,
61
+ } from "./websocketAuth";
@@ -0,0 +1,24 @@
1
+ export interface WebsocketAuthRequest {
2
+ key: string;
3
+ data: [string | null];
4
+ }
5
+
6
+ export interface WebsocketAuthAckData {
7
+ type: "auth";
8
+ revokedRooms: string[];
9
+ }
10
+
11
+ /**
12
+ * Framework-owned websocket auth contract shared by the client and the server dispatcher.
13
+ * The credential frame carries the raw bearer token; verifying it stays in userland middleware,
14
+ * so the server only swaps the credential snapshot held on the socket.
15
+ */
16
+ export const websocketAuthContract = {
17
+ key: "__auth",
18
+ makeRequest: (jwt: string | null): WebsocketAuthRequest => ({ key: "__auth", data: [jwt] }),
19
+ makeAck: (revokedRooms: string[]): WebsocketAuthAckData => ({ type: "auth", revokedRooms }),
20
+ readJwt: (data: unknown): string | null => {
21
+ const jwt = Array.isArray(data) ? data[0] : null;
22
+ return typeof jwt === "string" && jwt.length > 0 ? jwt : null;
23
+ },
24
+ } as const;
@@ -197,6 +197,7 @@ export class FetchClient {
197
197
  }
198
198
  setJwt(jwt: string | null) {
199
199
  this.jwt = jwt;
200
+ this.ws.setJwt(jwt);
200
201
  }
201
202
  #makeAuthHeaders(option?: FetchPolicy): Record<string, string> {
202
203
  if (option?.token) return { Authorization: `Bearer ${option.token}` };
@@ -1,5 +1,6 @@
1
- import { Logger } from "akanjs/common";
1
+ import { Logger, websocketAuthContract } from "akanjs/common";
2
2
  import type {
3
+ WebsocketAuthAck,
3
4
  WebsocketMessageData,
4
5
  WebsocketPublishData,
5
6
  WebsocketReqData,
@@ -39,6 +40,7 @@ export class WsClient {
39
40
  #roomSubscribeMap = new Map<string, SubscribeOption>();
40
41
  #listenerMap = new Map<string, Set<Listener>>();
41
42
  #destroyed = false;
43
+ #jwt: string | null = null;
42
44
  connected = false;
43
45
 
44
46
  constructor(
@@ -52,6 +54,21 @@ export class WsClient {
52
54
  this.ErrorCls = ErrorCls;
53
55
  }
54
56
 
57
+ /**
58
+ * The handshake only carries a same-origin cookie, so clients that hold the token in memory
59
+ * (native, cross-origin) authenticate with this frame instead. Signing out sends `null`, which
60
+ * drops the handshake cookie server-side and revokes the rooms it had authorized.
61
+ */
62
+ setJwt(jwt: string | null) {
63
+ if (this.#jwt === jwt) return;
64
+ this.#jwt = jwt;
65
+ if (this.#ws?.readyState === WebSocket.OPEN) this.#sendAuth();
66
+ }
67
+
68
+ #sendAuth() {
69
+ this.#ws?.send(JSON.stringify(websocketAuthContract.makeRequest(this.#jwt)));
70
+ }
71
+
55
72
  connect() {
56
73
  if (this.#ws && this.#ws.readyState !== WebSocket.CLOSED) return;
57
74
  this.logger.debug(`Connecting to ${this.url}`);
@@ -68,6 +85,8 @@ export class WsClient {
68
85
  this.#reconnectAttempts = 0;
69
86
  this.connected = true;
70
87
  this.logger.debug(`WebSocket connected`);
88
+
89
+ if (this.#jwt) this.#sendAuth();
71
90
  this.#roomSubscribeMap.forEach((option) => {
72
91
  const data: WebsocketReqData = { key: option.key, data: option.data, subscribe: true };
73
92
  this.#ws?.send(JSON.stringify(data));
@@ -97,6 +116,14 @@ export class WsClient {
97
116
  this.#handlePubsub(publishData.roomId, publishData.data);
98
117
  break;
99
118
  }
119
+ case "auth": {
120
+ const ack = parsed as WebsocketAuthAck;
121
+ for (const roomId of ack.revokedRooms) {
122
+ this.#roomSubscribeMap.delete(roomId);
123
+ this.logger.warn(`Websocket room ${roomId} is no longer authorized`);
124
+ }
125
+ break;
126
+ }
100
127
  default:
101
128
  this.logger.warn(`Unknown WebSocket message type: ${type} ${JSON.stringify(parsed)}`);
102
129
  break;
package/index.ts CHANGED
@@ -163,6 +163,12 @@ export interface AppConfigResult {
163
163
  docker: DockerConfig;
164
164
  defaultDatabaseMode: DatabaseMode;
165
165
  routes?: AkanRouteConfig[];
166
+ /**
167
+ * Mounts `libs/<lib>/page` into this app under `page/(libs)/(<lib>)` on sync. `true` takes every lib
168
+ * dependency that ships a `page` folder, an array takes exactly the libs listed, `false` (the default)
169
+ * syncs nothing and removes what a previous sync created.
170
+ */
171
+ syncPageLibs?: string[] | boolean;
166
172
  externalLibs: string[];
167
173
  barrelImports: string[];
168
174
  optimizeImports: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.4.1",
3
+ "version": "2.4.2-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -23,6 +23,7 @@ import { ProcessMetricsCollector } from "./processMetricsCollector";
23
23
  import { WebProxyRunner } from "./proxy";
24
24
  import { SignalResolver } from "./resolver";
25
25
  import { ApiRouter } from "./routing/apiRouter";
26
+ import type { AppWsData } from "./routing/appWsData";
26
27
  import type { HttpRoutes, SignalRoutes, WebsocketRoutes } from "./types";
27
28
  import type { WebRouter } from "./webRouter";
28
29
 
@@ -65,8 +66,8 @@ export interface AkanServerConsoleInfo {
65
66
  export class AkanServer {
66
67
  status: "stopped" | "initializing" | "initialized" | "starting" | "running" | "stopping" = "stopped";
67
68
 
68
- #server: Bun.Server<{ createdAt: number } | HmrWsData> | null = null;
69
- #wsServer: Bun.Server<{ createdAt: number } | HmrWsData> | null = null;
69
+ #server: Bun.Server<AppWsData | HmrWsData> | null = null;
70
+ #wsServer: Bun.Server<AppWsData | HmrWsData> | null = null;
70
71
  #prepared: AkanAppPrepared | null = null;
71
72
  readonly logger: Logger;
72
73
  readonly name: string;
@@ -241,7 +242,7 @@ export class AkanServer {
241
242
  }),
242
243
 
243
244
  data: {},
244
- } as Bun.WebSocketHandler<{ createdAt: number } | HmrWsData>;
245
+ } as Bun.WebSocketHandler<AppWsData | HmrWsData>;
245
246
 
246
247
  this.#server = Bun.serve({
247
248
  idleTimeout: 0,
@@ -270,7 +271,7 @@ export class AkanServer {
270
271
  builtinRoutes,
271
272
  routeOptions,
272
273
  renderEnvRoutes,
273
- upgradeAppWs: (req: Request, data: { createdAt: number }) => this.#wsServer?.upgrade(req, { data }) ?? false,
274
+ upgradeAppWs: (req: Request, data: AppWsData) => this.#wsServer?.upgrade(req, { data }) ?? false,
274
275
  webProxyRunner,
275
276
  }),
276
277
  websocket: websocketHandlers,
@@ -452,6 +452,29 @@ export class SignalResolver {
452
452
  return Boolean(req.headers.get("authorization") || req.headers.get("cookie")?.includes("jwt="));
453
453
  }
454
454
 
455
+ /**
456
+ * Re-checks the guards of every room this socket is subscribed to and drops the ones that no
457
+ * longer pass. Called when the socket's credential changes: a pubsub room is authorized once at
458
+ * subscribe time, so without this a signed-out socket would keep receiving its old rooms.
459
+ */
460
+ static async revalidateWsRooms(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<string[]> {
461
+ const roomCtxMap = SignalResolver.#liveWsPubsubRoomCtx.get(ws);
462
+ if (!roomCtxMap?.size) return [];
463
+ const websocket = SignalResolver.#getWebsocket(registry);
464
+ const revokedRooms: string[] = [];
465
+ for (const [roomId, roomCtx] of [...roomCtxMap]) {
466
+ if (await roomCtx.authorize()) continue;
467
+ ws.unsubscribe(roomId);
468
+ await Promise.all([...roomCtx.getWebSocketContext().onUnsubscribe.values()].map((handler) => handler()));
469
+ roomCtxMap.delete(roomId);
470
+ websocket.leaveRoom(ws, roomId);
471
+ revokedRooms.push(roomId);
472
+ SignalResolver.logger.verbose(`WebSocket lost access to room ${roomId}; unsubscribed`);
473
+ }
474
+ if (roomCtxMap.size === 0) SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
475
+ return revokedRooms;
476
+ }
477
+
455
478
  static async handleWsOpen(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry) {
456
479
  await SignalResolver.#getWebsocket(registry).registerSocket(ws);
457
480
  }
@@ -1,11 +1,12 @@
1
1
  import { dayjs } from "akanjs/base";
2
- import type { Logger } from "akanjs/common";
2
+ import { type Logger, websocketAuthContract } from "akanjs/common";
3
3
  import type { InjectRegistry } from "akanjs/service";
4
4
  import { Exception, type WebsocketReqData } from "akanjs/signal";
5
5
  import type { HmrWsData, HmrWsHub } from "../hmr/wsHub";
6
6
  import { copyBunRequestFields, type WebProxyRunner } from "../proxy";
7
7
  import { SignalResolver } from "../resolver";
8
8
  import type { HttpRoutes, SignalRouteOptions, WebsocketRoutes } from "../types";
9
+ import { AppWsData } from "./appWsData";
9
10
 
10
11
  /**
11
12
  * Minimal render-state view the HMR WS hello message needs.
@@ -30,7 +31,7 @@ export interface ApiRouteInputs {
30
31
  routeOptions?: Record<string, SignalRouteOptions>;
31
32
  renderEnvRoutes: HttpRoutes;
32
33
  /** Upgrades the incoming request into an app-signal WebSocket. */
33
- upgradeAppWs: (req: Request, data: { createdAt: number }) => boolean;
34
+ upgradeAppWs: (req: Request, data: AppWsData) => boolean;
34
35
  webProxyRunner?: WebProxyRunner | null;
35
36
  }
36
37
 
@@ -85,7 +86,7 @@ export class ApiRouter {
85
86
  const endpointPaths = new Set([...endpointEntries.map(([path]) => path), ...builtinEntries.map(([path]) => path)]);
86
87
  const routeTable = {
87
88
  [`${prefix}${websocketPrefix}` as "/api/ws"]: (req) => {
88
- const upgraded = upgradeAppWs(req, { createdAt: Date.now() });
89
+ const upgraded = upgradeAppWs(req, AppWsData.fromRequest(req));
89
90
  if (upgraded) return;
90
91
  return new Response("Failed to upgrade to WebSocket", { status: 500 });
91
92
  },
@@ -138,6 +139,13 @@ export class ApiRouter {
138
139
  if (typeof message === "string") {
139
140
  const msg = JSON.parse(message) as WebsocketReqData;
140
141
  if (!msg.key) throw new Error("Message key is required");
142
+ if (msg.key === websocketAuthContract.key) {
143
+
144
+ AppWsData.applyCredential(AppWsData.of(ws), websocketAuthContract.readJwt(msg.data));
145
+ const revokedRooms = await SignalResolver.revalidateWsRooms(ws, registry);
146
+ ws.send(JSON.stringify(websocketAuthContract.makeAck(revokedRooms)));
147
+ return;
148
+ }
141
149
  const wsRoute = wsRoutes[msg.key];
142
150
  if (!wsRoute) throw new Error(`WebSocket route "${msg.key}" is not registered`);
143
151
  const eventType =
@@ -0,0 +1,50 @@
1
+ const CREDENTIAL_HEADERS = ["authorization", "cookie", "user-agent"] as const;
2
+
3
+ /**
4
+ * Credential snapshot taken at the websocket handshake and carried on `ws.data` for the life of the
5
+ * socket, so auth middleware and guards can read the caller the same way they read an HTTP request.
6
+ * Only the credential headers are copied — retaining the whole `Request` would pin it for as long
7
+ * as the socket stays open.
8
+ */
9
+ export class AppWsData {
10
+ static fromRequest(req: Request): AppWsData {
11
+ const headers = new Headers();
12
+ for (const key of CREDENTIAL_HEADERS) {
13
+ const value = req.headers.get(key);
14
+ if (value) headers.set(key, value);
15
+ }
16
+ return new AppWsData(headers);
17
+ }
18
+ static of(ws: Bun.ServerWebSocket<unknown>): AppWsData {
19
+ return ws.data as AppWsData;
20
+ }
21
+ /**
22
+ * Swaps the credential the socket authenticates with. Callers must run this synchronously on the
23
+ * auth frame: frames arrive in order, so a subscribe sent right after the credential must not be
24
+ * able to observe the previous one.
25
+ */
26
+ static applyCredential(data: AppWsData, jwt: string | null) {
27
+ if (jwt) data.headers.set("authorization", `Bearer ${jwt}`);
28
+ else {
29
+ data.headers.delete("authorization");
30
+ data.cookies.delete("jwt");
31
+ const cookie = [...data.cookies].map(([name, value]) => `${name}=${value}`).join("; ");
32
+ if (cookie) data.headers.set("cookie", cookie);
33
+ else data.headers.delete("cookie");
34
+ }
35
+ data.account = undefined;
36
+ data.resolvedAuthorization = undefined;
37
+ }
38
+ createdAt: number;
39
+ headers: Headers;
40
+ cookies: Bun.CookieMap;
41
+ account?: unknown;
42
+ /** The `authorization` value `account` was resolved from, so each frame need not re-verify it. */
43
+ resolvedAuthorization?: string;
44
+ socketId?: string;
45
+ constructor(headers: Headers) {
46
+ this.createdAt = Date.now();
47
+ this.headers = headers;
48
+ this.cookies = new Bun.CookieMap(headers.get("cookie") ?? "");
49
+ }
50
+ }
@@ -47,6 +47,7 @@ declare global {
47
47
  | undefined;
48
48
  var __AKAN_RSC_REFRESH__: ((options?: { buildId?: number }) => Promise<void>) | undefined;
49
49
  var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
50
+ var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
50
51
  var __AKAN_DEV_SYNC_NAVIGATION__: ((href: string, kind: "push" | "replace" | "back" | "pop") => void) | undefined;
51
52
  var __AKAN_DEV_SYNC_NAVIGATION_APPLYING__: boolean | undefined;
52
53
  var __AKAN_GET_SYNC_ROUTE_HREF__: ((href: string) => string) | undefined;
@@ -299,8 +300,11 @@ let currentRouterState: AkanRouterStateV1 | null = initialRouterState;
299
300
  let currentSegmentTree: RscSegmentCacheNode | null = createAkanSegmentCacheTree(initialNode);
300
301
  let currentFullNode: RscCacheNode = initialNode;
301
302
  let currentCommitKind: "full" | "patch" = "full";
303
+ let currentCommitFromCache = false;
302
304
  let navigationSeq = 0;
303
305
 
306
+ globalThis.__AKAN_RSC_IS_FROM_CACHE__ = () => currentCommitFromCache;
307
+
304
308
  function rememberCommittedRouteState(node: RscCacheNode): void {
305
309
  rscPatchCache.clear();
306
310
  if (!node.routerState) return;
@@ -378,6 +382,7 @@ function Root(): ReactNode {
378
382
  maxEntries: MAX_RSC_CACHE_ENTRIES,
379
383
  startTransition,
380
384
  commitThenable: (node) => {
385
+ currentCommitFromCache = false;
381
386
  resetAkanSegmentOutletPatches();
382
387
  setThenable(node.thenable);
383
388
  },
@@ -397,6 +402,7 @@ function Root(): ReactNode {
397
402
  const scrollToTop = options.scrollToTop ?? true;
398
403
  try {
399
404
  let nextNode = rscCache.get(target);
405
+ const servedFromCache = !!nextNode;
400
406
  if (!nextNode) {
401
407
  const cachedPatch = rscPatchCache.get(target);
402
408
  if (cachedPatch) {
@@ -425,6 +431,7 @@ function Root(): ReactNode {
425
431
  bumpScrollToTop: () => setScrollToTopTick((tick) => tick + 1),
426
432
  })
427
433
  ) {
434
+ currentCommitFromCache = true;
428
435
  rememberPatchedRouteState(patchResult.tree, patchResult.patchedNode);
429
436
  rememberRscPatchCacheNode(rscPatchCache, cachedPatch, MAX_RSC_CACHE_ENTRIES);
430
437
  return;
@@ -449,6 +456,7 @@ function Root(): ReactNode {
449
456
  bumpScrollToTop: () => setScrollToTopTick((tick) => tick + 1),
450
457
  })
451
458
  ) {
459
+ currentCommitFromCache = false;
452
460
  rememberPatchedRouteState(fetched.tree, fetched.patchedNode);
453
461
  const patchCacheNode = createRscPatchNavigationCacheNode({
454
462
  href: target,
@@ -493,6 +501,7 @@ function Root(): ReactNode {
493
501
  maxEntries: MAX_RSC_CACHE_ENTRIES,
494
502
  startTransition,
495
503
  commitThenable: (node) => {
504
+ currentCommitFromCache = servedFromCache;
496
505
  resetAkanSegmentOutletPatches();
497
506
  setThenable(node.thenable);
498
507
  },
@@ -125,6 +125,38 @@ export class SignalContext<
125
125
  }),
126
126
  );
127
127
  }
128
+ /**
129
+ * Re-checks this context's guards outside of a request, for a websocket room that is already
130
+ * subscribed. Only global middlewares run: they carry the account resolution this depends on,
131
+ * while endpoint middlewares (cache/timeout/retry) would observe a call that never executes.
132
+ */
133
+ async authorize(): Promise<boolean> {
134
+ try {
135
+ await this.#withMiddleware(async () => await this.#checkGuards(), { endpointMiddlewares: false })();
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ #withMiddleware(
142
+ coreExec: () => Promise<unknown>,
143
+ { endpointMiddlewares = true }: { endpointMiddlewares?: boolean } = {},
144
+ ): () => Promise<unknown> {
145
+ const middlewares = [
146
+ ...this.#middleware.values(),
147
+ ...(endpointMiddlewares ? (this.endpointInfo.signalOption.middlewares ?? []) : []),
148
+ ];
149
+ if (middlewares.length === 0) return coreExec;
150
+ let next = coreExec;
151
+ for (let i = middlewares.length - 1; i >= 0; i--) {
152
+ const MiddlewareCls = middlewares[i];
153
+ if (!MiddlewareCls) continue;
154
+ const middleware = new MiddlewareCls();
155
+ const currentNext = next;
156
+ next = async () => await (await middleware.use(this.getEnv()))(this, currentNext);
157
+ }
158
+ return next;
159
+ }
128
160
  async exec() {
129
161
  if (!this.trace) return await this.#exec();
130
162
  return await runWithTrace(this.trace, async () => {
@@ -137,7 +169,6 @@ export class SignalContext<
137
169
  }
138
170
  async #exec() {
139
171
  if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
140
- const endpointMiddlewares = this.endpointInfo.signalOption.middlewares ?? [];
141
172
  const coreExec = async () => {
142
173
  if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
143
174
  if (this.trace) await traceSpan("guards", () => this.#checkGuards());
@@ -158,17 +189,7 @@ export class SignalContext<
158
189
  async () => await this.endpointInfo.execFn?.call(this.adaptor, ...this.args, ...this.internalArgs),
159
190
  );
160
191
  };
161
- let next = coreExec;
162
- if (this.#middleware.size > 0 || endpointMiddlewares.length > 0) {
163
- const middlewares = [...this.#middleware.values(), ...endpointMiddlewares];
164
- for (let i = middlewares.length - 1; i >= 0; i--) {
165
- const MiddlewareCls = middlewares[i];
166
- if (!MiddlewareCls) continue;
167
- const middleware = new MiddlewareCls();
168
- const currentNext = next;
169
- next = async () => await (await middleware.use(this.getEnv()))(this, currentNext);
170
- }
171
- }
192
+ const next = this.#withMiddleware(coreExec);
172
193
  const result = this.trace ? await traceSpan("execChain", () => next()) : await next();
173
194
  if (this.endpointInfo.type === "pubsub") return;
174
195
  if (result instanceof Response) return result;
@@ -340,6 +361,10 @@ export class SignalContext<
340
361
  if (this.transport !== "websocket") throw new Error("Transport is not websocket");
341
362
  return this.ctx as WebSocketExecutionContext<Appended>;
342
363
  }
364
+ get<T = unknown>(key: string): T | null {
365
+ if (this.transport === "http") return this.getHttpContext<{ [key: string]: T }>().req[key] ?? null;
366
+ return this.getWebSocketContext<{ [key: string]: T }>().ws.data[key] ?? null;
367
+ }
343
368
  getRoomId(key: string) {
344
369
  if (this.transport !== "websocket") throw new Error("Transport is not websocket");
345
370
  else if (this.endpointInfo.type !== "pubsub") throw new Error("Endpoint is not pubsub");
package/signal/types.ts CHANGED
@@ -140,4 +140,5 @@ export type WebsocketReqData = { key: string; data: unknown[]; subscribe?: boole
140
140
  export type WebsocketMessageData = { type: "msg"; key: string; data: object | object[] };
141
141
  export type WebsocketSubscribeAck = { type: "sub"; roomId: string; subscribe: boolean };
142
142
  export type WebsocketPublishData = { type: "pub"; roomId: string; data: object | object[] };
143
- export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData;
143
+ export type WebsocketAuthAck = { type: "auth"; revokedRooms: string[] };
144
+ export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData | WebsocketAuthAck;
@@ -43,6 +43,13 @@ export interface PageConfig {
43
43
  rscPatchHeadSafe?: boolean;
44
44
  topSafeAreaColor?: string;
45
45
  bottomSafeAreaColor?: string;
46
+ /**
47
+ * Keeps the route out of `akan build`. The route still serves under `akan start`, but nothing about it
48
+ * reaches production: no bundle, no manifest entry, no URL. On a `_layout`, every route under that
49
+ * directory is excluded with it. Must be written as a literal `true`/`false` — the build reads it from
50
+ * the source without evaluating the module.
51
+ */
52
+ devOnly?: boolean;
46
53
  }
47
54
  export interface CsrState {
48
55
  transition: TransitionType;
@@ -1,17 +1,25 @@
1
1
  declare global {
2
2
  var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
3
+ var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
3
4
  var __AKAN_RSC_NAVIGATE__: ((href: string, options?: {
4
5
  replace?: boolean;
5
6
  scrollToTop?: boolean;
6
7
  }) => Promise<void>) | undefined;
7
8
  }
8
9
  export declare const clearRscNavigationCache: () => void;
10
+ /**
11
+ * True when the page tree currently on screen was replayed from the RSC navigation cache instead of
12
+ * fetched from the server. Data hydrated out of such a payload can be arbitrarily old, so anything
13
+ * that must show current values should refetch.
14
+ */
15
+ export declare const isRscNavigationFromCache: () => boolean;
9
16
  export declare const navigateRsc: (href: string, options?: {
10
17
  replace?: boolean;
11
18
  scrollToTop?: boolean;
12
19
  }) => Promise<void> | undefined;
13
20
  export declare const useRscNavigation: () => {
14
21
  clearCache: () => void;
22
+ isFromCache: () => boolean;
15
23
  navigate: (href: string, options?: {
16
24
  replace?: boolean;
17
25
  scrollToTop?: boolean;
@@ -27,3 +27,4 @@ export { sleep } from "./sleep.d.ts";
27
27
  export { splitVersion } from "./splitVersion.d.ts";
28
28
  export { getBasePathFromPathname, parseBasePaths } from "./subRoute.d.ts";
29
29
  export type * from "./types.d.ts";
30
+ export { type WebsocketAuthAckData, type WebsocketAuthRequest, websocketAuthContract, } from "./websocketAuth.d.ts";
@@ -0,0 +1,19 @@
1
+ export interface WebsocketAuthRequest {
2
+ key: string;
3
+ data: [string | null];
4
+ }
5
+ export interface WebsocketAuthAckData {
6
+ type: "auth";
7
+ revokedRooms: string[];
8
+ }
9
+ /**
10
+ * Framework-owned websocket auth contract shared by the client and the server dispatcher.
11
+ * The credential frame carries the raw bearer token; verifying it stays in userland middleware,
12
+ * so the server only swaps the credential snapshot held on the socket.
13
+ */
14
+ export declare const websocketAuthContract: {
15
+ readonly key: "__auth";
16
+ readonly makeRequest: (jwt: string | null) => WebsocketAuthRequest;
17
+ readonly makeAck: (revokedRooms: string[]) => WebsocketAuthAckData;
18
+ readonly readJwt: (data: unknown) => string | null;
19
+ };
@@ -15,6 +15,12 @@ export declare class WsClient {
15
15
  connected: boolean;
16
16
  constructor(url: string, ErrorCls?: ErrorConstructor | undefined);
17
17
  setErrorConstructor(ErrorCls?: ErrorConstructor): void;
18
+ /**
19
+ * The handshake only carries a same-origin cookie, so clients that hold the token in memory
20
+ * (native, cross-origin) authenticate with this frame instead. Signing out sends `null`, which
21
+ * drops the handshake cookie server-side and revokes the rooms it had authorized.
22
+ */
23
+ setJwt(jwt: string | null): void;
18
24
  connect(): void;
19
25
  destroy(): void;
20
26
  on<Data = unknown>(key: string, callback: (data: Data) => void): this;
package/types/index.d.ts CHANGED
@@ -161,6 +161,12 @@ export interface AppConfigResult {
161
161
  docker: DockerConfig;
162
162
  defaultDatabaseMode: DatabaseMode;
163
163
  routes?: AkanRouteConfig[];
164
+ /**
165
+ * Mounts `libs/<lib>/page` into this app under `page/(libs)/(<lib>)` on sync. `true` takes every lib
166
+ * dependency that ships a `page` folder, an array takes exactly the libs listed, `false` (the default)
167
+ * syncs nothing and removes what a previous sync created.
168
+ */
169
+ syncPageLibs?: string[] | boolean;
164
170
  externalLibs: string[];
165
171
  barrelImports: string[];
166
172
  optimizeImports: string[];
@@ -30,6 +30,12 @@ export declare class SignalResolver {
30
30
  live: LiveRegistry;
31
31
  middleware: Map<string, MiddlewareCls>;
32
32
  }): SignalRoutes;
33
+ /**
34
+ * Re-checks the guards of every room this socket is subscribed to and drops the ones that no
35
+ * longer pass. Called when the socket's credential changes: a pubsub room is authorized once at
36
+ * subscribe time, so without this a signed-out socket would keep receiving its old rooms.
37
+ */
38
+ static revalidateWsRooms(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<string[]>;
33
39
  static handleWsOpen(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<void>;
34
40
  static handleWsClose(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<void>;
35
41
  }
@@ -1,8 +1,9 @@
1
- import type { Logger } from "akanjs/common";
1
+ import { type Logger } from "akanjs/common";
2
2
  import type { InjectRegistry } from "akanjs/service";
3
3
  import type { HmrWsHub } from "../hmr/wsHub.d.ts";
4
4
  import { type WebProxyRunner } from "../proxy.d.ts";
5
5
  import type { HttpRoutes, SignalRouteOptions, WebsocketRoutes } from "../types.d.ts";
6
+ import { AppWsData } from "./appWsData.d.ts";
6
7
  /**
7
8
  * Minimal render-state view the HMR WS hello message needs.
8
9
  * `LazyHmrController` exposes this shape via `state.buildId` / `state.cssAssets`,
@@ -27,9 +28,7 @@ export interface ApiRouteInputs {
27
28
  routeOptions?: Record<string, SignalRouteOptions>;
28
29
  renderEnvRoutes: HttpRoutes;
29
30
  /** Upgrades the incoming request into an app-signal WebSocket. */
30
- upgradeAppWs: (req: Request, data: {
31
- createdAt: number;
32
- }) => boolean;
31
+ upgradeAppWs: (req: Request, data: AppWsData) => boolean;
33
32
  webProxyRunner?: WebProxyRunner | null;
34
33
  }
35
34
  export interface WebsocketHandlersInputs {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Credential snapshot taken at the websocket handshake and carried on `ws.data` for the life of the
3
+ * socket, so auth middleware and guards can read the caller the same way they read an HTTP request.
4
+ * Only the credential headers are copied — retaining the whole `Request` would pin it for as long
5
+ * as the socket stays open.
6
+ */
7
+ export declare class AppWsData {
8
+ static fromRequest(req: Request): AppWsData;
9
+ static of(ws: Bun.ServerWebSocket<unknown>): AppWsData;
10
+ /**
11
+ * Swaps the credential the socket authenticates with. Callers must run this synchronously on the
12
+ * auth frame: frames arrive in order, so a subscribe sent right after the credential must not be
13
+ * able to observe the previous one.
14
+ */
15
+ static applyCredential(data: AppWsData, jwt: string | null): void;
16
+ createdAt: number;
17
+ headers: Headers;
18
+ cookies: Bun.CookieMap;
19
+ account?: unknown;
20
+ /** The `authorization` value `account` was resolved from, so each frame need not re-verify it. */
21
+ resolvedAuthorization?: string;
22
+ socketId?: string;
23
+ constructor(headers: Headers);
24
+ }
@@ -13,6 +13,7 @@ declare global {
13
13
  buildId?: number;
14
14
  }) => Promise<void>) | undefined;
15
15
  var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
16
+ var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
16
17
  var __AKAN_DEV_SYNC_NAVIGATION__: ((href: string, kind: "push" | "replace" | "back" | "pop") => void) | undefined;
17
18
  var __AKAN_DEV_SYNC_NAVIGATION_APPLYING__: boolean | undefined;
18
19
  var __AKAN_GET_SYNC_ROUTE_HREF__: ((href: string) => string) | undefined;
@@ -32,6 +32,12 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
32
32
  getAdaptor<T extends Adaptor>(adaptorCls: AdaptorCls<T>): T;
33
33
  getService<T>(refName: string): T;
34
34
  init(): Promise<this>;
35
+ /**
36
+ * Re-checks this context's guards outside of a request, for a websocket room that is already
37
+ * subscribed. Only global middlewares run: they carry the account resolution this depends on,
38
+ * while endpoint middlewares (cache/timeout/retry) would observe a call that never executes.
39
+ */
40
+ authorize(): Promise<boolean>;
35
41
  exec(): Promise<Response | undefined>;
36
42
  static try(endpoint: Adaptor, endpointInfo: EndpointInfo, key: string, fn: () => Promise<Response | undefined>): Promise<Response | undefined>;
37
43
  static resolveReturn(value: unknown, { signalContext, returnRef, arrDepth, registry, live, }: {
@@ -47,6 +53,7 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
47
53
  }): Promise<unknown>;
48
54
  getHttpContext<Appended = unknown>(): HttpExecutionContext<Appended>;
49
55
  getWebSocketContext<Appended = unknown>(): WebSocketExecutionContext<Appended>;
56
+ get<T = unknown>(key: string): T | null;
50
57
  getRoomId(key: string): string;
51
58
  getEnv(): Env;
52
59
  getArg<T = unknown>(argName: string): T | undefined;
@@ -147,5 +147,9 @@ export type WebsocketPublishData = {
147
147
  roomId: string;
148
148
  data: object | object[];
149
149
  };
150
- export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData;
150
+ export type WebsocketAuthAck = {
151
+ type: "auth";
152
+ revokedRooms: string[];
153
+ };
154
+ export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData | WebsocketAuthAck;
151
155
  export {};
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { clsx, router, usePage } from "akanjs/client";
2
+ import { clsx, isRscNavigationFromCache, router, usePage } from "akanjs/client";
3
3
  import { capitalize, deepObjectify, lowerlize } from "akanjs/common";
4
4
  import { ConstantRegistry, immerify } from "akanjs/constant";
5
5
  import type { ClientEdit, ServerEdit, SliceMeta } from "akanjs/fetch";
@@ -11,6 +11,8 @@ import { AiOutlinePlus, AiOutlineSave } from "react-icons/ai";
11
11
  import { Button } from "../Button";
12
12
  import { Modal } from "../Modal";
13
13
 
14
+ const EDIT_PAYLOAD_MAX_AGE_MS = 60_000;
15
+
14
16
  interface EditModelProps<Full> {
15
17
  /** Rendering mode for the edit shell. */
16
18
  type?: "modal" | "form" | "empty";
@@ -148,6 +150,7 @@ export default function EditModal<Full extends { id: string }>({
148
150
  setModelModal: `set${ModelName}Modal`,
149
151
  modelLoading: `${modelName}Loading`,
150
152
  modelViewAt: `${modelName}ViewAt`,
153
+ editModel: `edit${ModelName}`,
151
154
  newModel: `new${ModelName}`,
152
155
  crystalizeModel: `crystalize${ModelName}`,
153
156
  modelObj: `${modelName}Obj`,
@@ -159,10 +162,19 @@ export default function EditModal<Full extends { id: string }>({
159
162
  (state: unknown) => (state as { [key: string]: { id: string | null } })[names.modelForm].id,
160
163
  );
161
164
  const modelFormLoading = storeUse[names.modelFormLoading]() as string | boolean;
165
+ const modalId = id ?? ((modelEdit as any)?.[names.modelObj] as Full | undefined)?.id ?? undefined;
162
166
  const isModalOpen =
163
167
  modelModal === (modal ?? "edit") &&
164
- (modelFormLoading === false || modelFormLoading === id) &&
165
- ((!modelFormId && !id) || id === modelFormId);
168
+ (modelFormLoading === false || modelFormLoading === modalId) &&
169
+ ((!modelFormId && !modalId) || modalId === modelFormId);
170
+ const isEditPayloadStale = useCallback((viewAt?: Date | null) => {
171
+ if (isRscNavigationFromCache()) return true;
172
+ return (
173
+ viewAt instanceof Date &&
174
+ !Number.isNaN(viewAt.getTime()) &&
175
+ Date.now() - viewAt.getTime() > EDIT_PAYLOAD_MAX_AGE_MS
176
+ );
177
+ }, []);
166
178
  useEffect(() => {
167
179
  if (!modelEdit) return;
168
180
  const refName = (modelEdit as ServerEdit<string, Full>).refName;
@@ -170,15 +182,21 @@ export default function EditModal<Full extends { id: string }>({
170
182
  const cnst = ConstantRegistry.getDatabase(modelName);
171
183
  const modelRef = cnst.full;
172
184
  if (editType === "edit") {
173
- const crystal = new modelRef().set((modelEdit as any)[names.modelObj] as Full) as unknown as Full;
185
+ const modelObj = (modelEdit as any)[names.modelObj] as Full;
186
+ const viewAt = (modelEdit as any)[names.modelViewAt] as Date;
187
+ const crystal = new modelRef().set(modelObj) as unknown as Full;
174
188
  st.set({
175
189
  [names.model]: crystal,
176
190
  [names.modelLoading]: false,
177
191
  [names.modelForm]: immerify(modelRef, crystal),
178
192
  [names.modelFormLoading]: false,
179
193
  [names.modelModal]: modal ?? "edit",
180
- [names.modelViewAt]: (modelEdit as any)[names.modelViewAt] as Date,
194
+ [names.modelViewAt]: viewAt,
181
195
  });
196
+ if (isEditPayloadStale(viewAt))
197
+ void storeDo[names.editModel](modelObj.id, { modal }).catch(() => {
198
+ st.set({ [names.modelFormLoading]: false });
199
+ });
182
200
  } else {
183
201
 
184
202
  const crystal = new modelRef().set(modelEdit as Full) as unknown as Full;
@@ -186,7 +204,7 @@ export default function EditModal<Full extends { id: string }>({
186
204
  }
187
205
  return () => {
188
206
  };
189
- }, [modelEdit]);
207
+ }, [modelEdit, isEditPayloadStale]);
190
208
 
191
209
  const handleCancel = useCallback(() => {
192
210
  const modelForm = (st.get() as any)[names.modelForm] as Full;