akanjs 3.0.0-alpha.45 → 3.0.0-alpha.47

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/base/baseEnv.ts CHANGED
@@ -73,15 +73,18 @@ export type ClientEnv = BaseEnv & {
73
73
 
74
74
  let cachedEnv: ClientEnv | undefined;
75
75
 
76
+ const missingPublicEnv = (key: string) =>
77
+ `getEnv() cannot run at build time: akan build does not inject ${key}. Call it from a runtime function instead of at module scope (e.g. env(() => getEnv()) in adapt(), a method body, or a default thunk).`;
78
+
76
79
  /** Reads and caches Akan runtime environment values from process/browser environment settings. */
77
80
  export const getEnv = (): ClientEnv => {
78
81
  if (cachedEnv) return cachedEnv;
79
82
  const appName = process.env.AKAN_PUBLIC_APP_NAME ?? "unknown";
80
83
  const repoName = process.env.AKAN_PUBLIC_REPO_NAME ?? "unknown";
81
84
  const serveDomain = process.env.AKAN_PUBLIC_SERVE_DOMAIN ?? "unknown";
82
- if (appName === "unknown") throw new Error("environment variable AKAN_PUBLIC_APP_NAME is required");
83
- if (repoName === "unknown") throw new Error("environment variable AKAN_PUBLIC_REPO_NAME is required");
84
- if (serveDomain === "unknown") throw new Error("environment variable AKAN_PUBLIC_SERVE_DOMAIN is required");
85
+ if (appName === "unknown") throw new Error(missingPublicEnv("AKAN_PUBLIC_APP_NAME"));
86
+ if (repoName === "unknown") throw new Error(missingPublicEnv("AKAN_PUBLIC_REPO_NAME"));
87
+ if (serveDomain === "unknown") throw new Error(missingPublicEnv("AKAN_PUBLIC_SERVE_DOMAIN"));
85
88
  const environment = (process.env.AKAN_PUBLIC_ENV ?? "debug") as BaseEnv["environment"];
86
89
  const operationMode = (process.env.AKAN_PUBLIC_OPERATION_MODE ??
87
90
  (environment === "local" ? "local" : "cloud")) as BaseEnv["operationMode"];
@@ -209,12 +209,12 @@ declare global {
209
209
  [DEFAULT_VALUE]: boolean;
210
210
  [PURIFIED_VALUE]: boolean;
211
211
  [EXAMPLE_VALUE]: boolean;
212
- validate(value: boolean | number): boolean;
213
- parseValue(input: boolean | number): boolean | number;
214
- serializeValue(value: boolean | number): boolean | number;
215
- _parse(input: boolean | number): boolean;
216
- _serialize(value: boolean | number): boolean;
217
- _checkValue(value: boolean | number): void;
212
+ validate(value: boolean | number | string): boolean;
213
+ parseValue(input: boolean | number | string): boolean | number | string;
214
+ serializeValue(value: boolean | number | string): boolean | number | string;
215
+ _parse(input: boolean | number | string): boolean;
216
+ _serialize(value: boolean | number | string): boolean;
217
+ _checkValue(value: boolean | number | string): void;
218
218
  }
219
219
  interface DateConstructor {
220
220
  refName: "Date";
@@ -305,10 +305,14 @@ Object.assign(String, scalarPrimitiveStatics, {
305
305
  });
306
306
  PrimitiveRegistry.register(String);
307
307
 
308
- const normalizeBooleanPrimitiveValue = (value: boolean | number): boolean | null => {
308
+ const normalizeBooleanPrimitiveValue = (value: boolean | number | string): boolean | null => {
309
309
  if (typeof value === "boolean") return value;
310
310
  if (value === 1) return true;
311
311
  if (value === 0) return false;
312
+ if (typeof value !== "string") return null;
313
+ const text = value.trim().toLowerCase();
314
+ if (text === "true" || text === "1") return true;
315
+ if (text === "false" || text === "0") return false;
312
316
  return null;
313
317
  };
314
318
 
@@ -317,13 +321,13 @@ Object.assign(Boolean, {
317
321
  refName: "Boolean",
318
322
  [DEFAULT_VALUE]: false,
319
323
  [EXAMPLE_VALUE]: true,
320
- validate(value: boolean | number) {
324
+ validate(value: boolean | number | string) {
321
325
  return normalizeBooleanPrimitiveValue(value) !== null;
322
326
  },
323
- parseValue(input: boolean | number) {
327
+ parseValue(input: boolean | number | string) {
324
328
  return normalizeBooleanPrimitiveValue(input) ?? input;
325
329
  },
326
- serializeValue(value: boolean | number) {
330
+ serializeValue(value: boolean | number | string) {
327
331
  return normalizeBooleanPrimitiveValue(value) ?? value;
328
332
  },
329
333
  });
package/common/index.ts CHANGED
@@ -45,6 +45,7 @@ export { randomPicks } from "./randomPicks";
45
45
  export {
46
46
  assertUniqueRoutePatterns,
47
47
  compareRouteSpecificity,
48
+ getPageSourceFileViolation,
48
49
  getRouteExports,
49
50
  isRouteSourceFile,
50
51
  isSpecialRouteLeaf,
@@ -67,27 +67,37 @@ export function isRouteSourceFile(filePath: string): boolean {
67
67
  return tryParseRouteModuleKey(key) !== null;
68
68
  }
69
69
 
70
- export function validatePageSourceFile(filePath: string, options: ValidatePageSourceFileOptions = {}): boolean {
71
- if (!SOURCE_EXT_RE.test(filePath)) return false;
70
+ /**
71
+ * Why a `page/` file breaks the route convention, or null when it is fine. Null also covers a non-source
72
+ * asset, which `page/` tolerates. `akan sync <lib>` reports these alongside its other layout violations,
73
+ * so the rule stays in one place instead of being restated where it cannot afford to throw.
74
+ */
75
+ export function getPageSourceFileViolation(filePath: string): string | null {
76
+ if (!SOURCE_EXT_RE.test(filePath)) return null;
72
77
 
73
78
  const key = filePath.startsWith("./") ? filePath : `./${filePath.split(/[\\/]/).join("/")}`;
74
79
  const match = ROUTE_SOURCE_RE.exec(key);
75
- const displayPath = options.filePath ?? key;
76
- if (!match) throw new Error(`[route-convention] invalid page source file: ${displayPath}`);
80
+ if (!match) return "invalid page source file";
77
81
 
78
82
  const file = match[1] as string;
79
83
  const ext = match[2] as string;
80
84
  const leaf = file.split("/").filter(Boolean).at(-1);
81
- if (!leaf) throw new Error(`[route-convention] invalid page source file: ${displayPath}`);
85
+ if (!leaf) return "invalid page source file";
82
86
 
83
- if (ext !== "tsx") throw new Error(`[route-convention] route source files under page/ must use .tsx: ${displayPath}`);
87
+ if (ext !== "tsx") return "route source files under page/ must use .tsx";
84
88
  if (leaf.startsWith("_") && !RESERVED_ROUTE_FILES.has(leaf) && leaf !== INTERNAL_ROOT_LAYOUT_LEAF)
85
- throw new Error(
86
- `[route-convention] only _index.tsx, _layout.tsx and _overrides.tsx are allowed as reserved route files under page/: ${displayPath}`,
87
- );
88
- if (/^[A-Z]/.test(leaf))
89
- throw new Error(`[route-convention] route page filenames must not start with an uppercase letter: ${displayPath}`);
90
- return true;
89
+ return "only _index.tsx, _layout.tsx and _overrides.tsx are allowed as reserved route files under page/";
90
+ if (/^[A-Z]/.test(leaf)) return "route page filenames must not start with an uppercase letter";
91
+ return null;
92
+ }
93
+
94
+ export function validatePageSourceFile(filePath: string, options: ValidatePageSourceFileOptions = {}): boolean {
95
+ if (!SOURCE_EXT_RE.test(filePath)) return false;
96
+
97
+ const violation = getPageSourceFileViolation(filePath);
98
+ if (!violation) return true;
99
+ const key = filePath.startsWith("./") ? filePath : `./${filePath.split(/[\\/]/).join("/")}`;
100
+ throw new Error(`[route-convention] ${violation}: ${options.filePath ?? key}`);
91
101
  }
92
102
 
93
103
  export function validateSubRoutePageKey(
package/constant/via.ts CHANGED
@@ -430,10 +430,14 @@ declare global {
430
430
  }
431
431
 
432
432
  const applyConstantStatics = <Model>(model: ConstantCls<Model>, fieldMap: FieldObject): ConstantCls<Model> => {
433
- const defaultValue = getDefault(model[FIELD_META]);
433
+
434
+ let defaultValue: DefaultOf<Model> | undefined;
434
435
  Object.assign(model, {
435
436
  purify: makePurify(model),
436
- getDefault: () => ({ ...defaultValue }),
437
+ getDefault: () => {
438
+ defaultValue ??= getDefault<Model>(model[FIELD_META]);
439
+ return { ...defaultValue };
440
+ },
437
441
  });
438
442
  Object.entries(fieldMap).forEach(([, field]) => {
439
443
  if (field.enum) model.enums.add(field.enum);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.45",
3
+ "version": "3.0.0-alpha.47",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -37,7 +37,6 @@ interface ChildState {
37
37
 
38
38
  interface GatewayWsData {
39
39
  childIdx: number;
40
- socketId: string;
41
40
  upstream: WebSocket;
42
41
  }
43
42
 
@@ -582,8 +581,8 @@ export class AkanApp {
582
581
  const upstreamWs = new WebSocket(`ws://${upstream.host}:${upstream.port}${url.pathname}${url.search}`, {
583
582
  headers: this.#makeProxyHeaders(req, child.idx),
584
583
  } as unknown as string[]);
585
- const socketId = crypto.randomUUID();
586
- const upgraded = server.upgrade(req, { data: { childIdx: child.idx, socketId, upstream: upstreamWs } });
584
+
585
+ const upgraded = server.upgrade(req, { data: { childIdx: child.idx, upstream: upstreamWs } });
587
586
  if (!upgraded) {
588
587
  upstreamWs.close();
589
588
  return new Response("WebSocket upgrade failed", { status: 500 });
@@ -41,10 +41,17 @@ export class AppWsData {
41
41
  account?: unknown;
42
42
  /** The `authorization` value `account` was resolved from, so each frame need not re-verify it. */
43
43
  resolvedAuthorization?: string;
44
- socketId?: string;
44
+ /**
45
+ * Identity of this connection, minted here so every app socket carries one from its first frame and
46
+ * adaptors and endpoints only ever read it. Per-connection and process-local — a reconnect gets a new
47
+ * one, and the federation gateway's own socket is a different one — so it is never a caller identity.
48
+ * It outlives a credential swap on purpose: the socket is still the same socket.
49
+ */
50
+ socketId: string;
45
51
  constructor(headers: Headers) {
46
52
  this.createdAt = Date.now();
47
53
  this.headers = headers;
48
54
  this.cookies = new Bun.CookieMap(headers.get("cookie") ?? "");
55
+ this.socketId = Bun.randomUUIDv7();
49
56
  }
50
57
  }
package/service/adapt.ts CHANGED
@@ -4,8 +4,8 @@ import { type ExtractInjectInfoObject, type InjectBuilder, type InjectInfo, inje
4
4
 
5
5
  export interface Adaptor {
6
6
  readonly logger: Logger;
7
- onInit(): Promise<void>;
8
- onDestroy(): Promise<void>;
7
+ onInit(): Promise<void> | void;
8
+ onDestroy(): Promise<void> | void;
9
9
  }
10
10
 
11
11
  export type AdaptorCls<
@@ -30,9 +30,9 @@ export function adapt(name: string, injectBuilder?: InjectBuilder) {
30
30
  readonly logger = new Logger(name);
31
31
  static readonly [INJECT_META] = injectInfoMap;
32
32
  static readonly refName = name;
33
- async onInit() {
33
+ onInit(): Promise<void> | void {
34
34
  }
35
- async onDestroy() {
35
+ onDestroy(): Promise<void> | void {
36
36
  }
37
37
  }
38
38
  return Adaptor;
@@ -3,9 +3,13 @@ import { adapt } from "../adapt";
3
3
  import { sendAkanIpc } from "../ipcTypes";
4
4
  import type { WebsocketAdaptor, WsRedisEventHandler, WsSocketData } from "./websocket.adaptor";
5
5
 
6
- const getSocketId = (ws: Bun.ServerWebSocket<unknown>, serverId: string) => {
6
+ /**
7
+ * `AppWsData` mints the id at the handshake, so this reads it; the fallback only covers a socket that
8
+ * was upgraded outside the app router, where nothing else would have given it one.
9
+ */
10
+ const getSocketId = (ws: Bun.ServerWebSocket<unknown>) => {
7
11
  const data = ws.data as WsSocketData;
8
- if (!data.socketId) data.socketId = `${serverId}-${Bun.randomUUIDv7()}`;
12
+ data.socketId ??= Bun.randomUUIDv7();
9
13
  return data.socketId;
10
14
  };
11
15
 
@@ -64,7 +68,7 @@ export class SolidPubSub
64
68
  }
65
69
 
66
70
  async joinRoom(ws: Bun.ServerWebSocket<unknown>, room: string): Promise<void> {
67
- const socketId = getSocketId(ws, this.serverId);
71
+ const socketId = getSocketId(ws);
68
72
  const rooms = this.#socketRooms.get(socketId) ?? new Set<string>();
69
73
  rooms.add(room);
70
74
  this.#socketRooms.set(socketId, rooms);
@@ -72,7 +76,7 @@ export class SolidPubSub
72
76
  }
73
77
 
74
78
  async leaveRoom(ws: Bun.ServerWebSocket<unknown>, room: string): Promise<void> {
75
- const socketId = getSocketId(ws, this.serverId);
79
+ const socketId = getSocketId(ws);
76
80
  const rooms = this.#socketRooms.get(socketId);
77
81
  rooms?.delete(room);
78
82
  if (!rooms || rooms.size === 0) this.#socketRooms.delete(socketId);
@@ -80,7 +84,7 @@ export class SolidPubSub
80
84
  }
81
85
 
82
86
  async leaveAllRooms(ws: Bun.ServerWebSocket<unknown>): Promise<void> {
83
- const socketId = getSocketId(ws, this.serverId);
87
+ const socketId = getSocketId(ws);
84
88
  const rooms = this.#socketRooms.get(socketId);
85
89
  if (rooms) {
86
90
  for (const room of rooms) sendAkanIpc({ type: "pubsub.unsubscribe", roomId: room, socketId, pid: process.pid });
@@ -89,7 +93,7 @@ export class SolidPubSub
89
93
  }
90
94
 
91
95
  async registerSocket(ws: Bun.ServerWebSocket<unknown>): Promise<void> {
92
- getSocketId(ws, this.serverId);
96
+ getSocketId(ws);
93
97
  }
94
98
 
95
99
  async unregisterSocket(ws: Bun.ServerWebSocket<unknown>): Promise<void> {
@@ -209,9 +209,14 @@ export class WebSocketRedisAdaptor
209
209
  await pipeline.exec();
210
210
  }
211
211
 
212
+ /**
213
+ * `AppWsData` mints the id at the handshake, so this reads it; the fallback only covers a socket that
214
+ * was upgraded outside the app router. The owning server is recorded in the socket hash below, so the
215
+ * id itself carries no prefix.
216
+ */
212
217
  #getSocketId(ws: Bun.ServerWebSocket<unknown>): string {
213
218
  const data = ws.data as WsSocketData;
214
- if (!data.socketId) data.socketId = `${this.serverId}-${Bun.randomUUIDv7()}`;
219
+ data.socketId ??= Bun.randomUUIDv7();
215
220
  return data.socketId;
216
221
  }
217
222
 
package/service/serve.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  import type { DatabaseService, DatabaseServiceForModel } from "./types";
13
13
 
14
14
  interface ServiceOptions {
15
- enabled?: boolean;
15
+ enabled?: boolean | (() => boolean);
16
16
  serverMode?: "batch" | "federation";
17
17
  }
18
18
  export type ServiceType = "database" | "plain";
@@ -43,9 +43,9 @@ const avoidKeys = new Set([
43
43
  export interface Service {
44
44
  readonly logger: Logger;
45
45
 
46
- onInit(): Promise<void>;
46
+ onInit(): Promise<void> | void;
47
47
  _libsOnInit(): Promise<void>;
48
- onDestroy(): Promise<void>;
48
+ onDestroy(): Promise<void> | void;
49
49
  _libsOnDestroy(): Promise<void>;
50
50
  }
51
51
 
@@ -102,9 +102,10 @@ export function serve(
102
102
  ...(typeof optionOrInjectBuilder === "function" && injectBuilderOrExtendSrv ? [injectBuilderOrExtendSrv] : []),
103
103
  ...extendSrvs,
104
104
  ] as ServiceCls[];
105
- const isEnabled =
105
+ const enabledOption =
106
106
  option.enabled ??
107
107
  (!option.serverMode || process.env.SERVER_MODE === option.serverMode || process.env.SERVER_MODE === "all");
108
+ let enabledCache: boolean | undefined;
108
109
  const serviceType = typeof refNameOrDb === "string" ? "plain" : "database";
109
110
  const injectInfoMap = injectBuilder(injectionBuilder(refName));
110
111
  if (serviceType === "database")
@@ -115,15 +116,20 @@ export function serve(
115
116
  const srvRef = class Service {
116
117
  static readonly type = serviceType;
117
118
  static readonly refName = refName;
118
- static enabled = isEnabled;
119
+ static get enabled() {
120
+
121
+ if (enabledCache === undefined)
122
+ enabledCache = typeof enabledOption === "function" ? enabledOption() : enabledOption;
123
+ return enabledCache;
124
+ }
119
125
  static get name() {
120
126
  return `${capitalize(refName)}Service`;
121
127
  }
122
128
  static [INJECT_META] = {};
123
129
  readonly logger = new Logger(this.constructor.name);
124
- async onInit() {
130
+ onInit(): Promise<void> | void {
125
131
  }
126
- async onDestroy() {
132
+ onDestroy(): Promise<void> | void {
127
133
  }
128
134
  };
129
135
  applyMixins(srvRef, extSrvs, avoidKeys);
@@ -132,10 +138,11 @@ export function serve(
132
138
  const onDestroyFns = extSrvs.map((srv) => srv.prototype.onDestroy);
133
139
  Object.assign(srvRef.prototype, {
134
140
  async _libsOnInit(this: Service) {
135
- await Promise.all([...onInitFns.map((onInit) => onInit?.call(this)), this.onInit()]);
141
+
142
+ await Promise.all([...onInitFns, this.onInit].map(async (onInit) => await onInit?.call(this)));
136
143
  },
137
144
  async _libsOnDestroy(this: Service) {
138
- await Promise.all([...onDestroyFns.map((onDestroy) => onDestroy?.call(this)), this.onDestroy()]);
145
+ await Promise.all([...onDestroyFns, this.onDestroy].map(async (onDestroy) => await onDestroy?.call(this)));
139
146
  },
140
147
  });
141
148
 
@@ -7,8 +7,8 @@ export type InterceptorCls<Methods = {}, InjectMap extends { [key: string]: Inje
7
7
  Methods &
8
8
  ExtractInjectInfoObject<InjectMap> & {
9
9
  readonly logger: Logger;
10
- onInit(): Promise<void>;
11
- onDestroy(): Promise<void>;
10
+ onInit(): Promise<void> | void;
11
+ onDestroy(): Promise<void> | void;
12
12
  intercept(context: SignalContext): AsyncGenerator<unknown> | Promise<unknown>;
13
13
  },
14
14
  { readonly [INJECT_META]: InjectMap; readonly refName: string }
@@ -31,9 +31,9 @@ export function intercept(refName: string, injectBuilder?: InjectBuilder) {
31
31
  intercept(context: SignalContext): AsyncGenerator | Promise<(res: Response) => Promise<Response>> {
32
32
  return Promise.resolve((res: Response) => Promise.resolve(res));
33
33
  }
34
- async onInit() {
34
+ onInit(): Promise<void> | void {
35
35
  }
36
- async onDestroy() {
36
+ onDestroy(): Promise<void> | void {
37
37
  }
38
38
  };
39
39
  }
@@ -21,15 +21,20 @@ export class Res implements InternalArg {
21
21
  }
22
22
  }
23
23
 
24
- /** Injects websocket state and subscription hooks into message/pubsub handlers. */
24
+ /**
25
+ * Injects websocket state, this connection's id, and subscription hooks into message/pubsub handlers.
26
+ * `socketId` is the one `AppWsData` minted at the handshake, so a handler never reads `ws.data` to
27
+ * tell two callers apart — and never mints an id of its own, which would not match the room bookkeeping.
28
+ */
25
29
  export class Ws implements InternalArg {
26
30
  onDisconnect?: () => void;
27
31
  onUnsubscribe?: () => void;
28
32
  getArg(context: SignalContext) {
29
- const webSocketContext = context.getWebSocketContext();
33
+ const webSocketContext = context.getWebSocketContext<{ socketId: string }>();
30
34
  const ws = webSocketContext.ws;
31
35
  return {
32
36
  ws,
37
+ socketId: ws.data.socketId,
33
38
  subscribe: webSocketContext.eventType === "subscribe",
34
39
  on: webSocketContext.on,
35
40
  off: webSocketContext.off,
@@ -114,12 +114,12 @@ declare global {
114
114
  [DEFAULT_VALUE]: boolean;
115
115
  [PURIFIED_VALUE]: boolean;
116
116
  [EXAMPLE_VALUE]: boolean;
117
- validate(value: boolean | number): boolean;
118
- parseValue(input: boolean | number): boolean | number;
119
- serializeValue(value: boolean | number): boolean | number;
120
- _parse(input: boolean | number): boolean;
121
- _serialize(value: boolean | number): boolean;
122
- _checkValue(value: boolean | number): void;
117
+ validate(value: boolean | number | string): boolean;
118
+ parseValue(input: boolean | number | string): boolean | number | string;
119
+ serializeValue(value: boolean | number | string): boolean | number | string;
120
+ _parse(input: boolean | number | string): boolean;
121
+ _serialize(value: boolean | number | string): boolean;
122
+ _checkValue(value: boolean | number | string): void;
123
123
  }
124
124
  interface DateConstructor {
125
125
  refName: "Date";
@@ -24,7 +24,7 @@ export { pathGet } from "./pathGet.d.ts";
24
24
  export { pathSet } from "./pathSet.d.ts";
25
25
  export { randomPick } from "./randomPick.d.ts";
26
26
  export { randomPicks } from "./randomPicks.d.ts";
27
- export { assertUniqueRoutePatterns, compareRouteSpecificity, getRouteExports, isRouteSourceFile, isSpecialRouteLeaf, LAYOUT_ROUTE_EXPORTS, matchRoutePattern, normalizeRoutePattern, PAGE_ROUTE_EXPORTS, type ParsedRouteModuleKey, parseRouteModuleKey, RESERVED_ROUTE_CONFIG_EXPORTS, ROOT_LAYOUT_ROUTE_EXPORTS, type RouteModuleKind, routeSegmentToPatternPart, routeSegmentToTreePath, tryParseRouteModuleKey, type ValidatePageSourceFileOptions, type ValidateSubRoutePageKeyOptions, validatePageSourceFile, validateSubRoutePageKey, } from "./routeConvention.d.ts";
27
+ export { assertUniqueRoutePatterns, compareRouteSpecificity, getPageSourceFileViolation, getRouteExports, isRouteSourceFile, isSpecialRouteLeaf, LAYOUT_ROUTE_EXPORTS, matchRoutePattern, normalizeRoutePattern, PAGE_ROUTE_EXPORTS, type ParsedRouteModuleKey, parseRouteModuleKey, RESERVED_ROUTE_CONFIG_EXPORTS, ROOT_LAYOUT_ROUTE_EXPORTS, type RouteModuleKind, routeSegmentToPatternPart, routeSegmentToTreePath, tryParseRouteModuleKey, type ValidatePageSourceFileOptions, type ValidateSubRoutePageKeyOptions, validatePageSourceFile, validateSubRoutePageKey, } from "./routeConvention.d.ts";
28
28
  export { sleep } from "./sleep.d.ts";
29
29
  export { splitVersion } from "./splitVersion.d.ts";
30
30
  export { getBasePathFromPathname, parseBasePaths, parseSubRouteHosts, resolveSubRouteHosts } from "./subRoute.d.ts";
@@ -27,6 +27,12 @@ export interface ValidatePageSourceFileOptions {
27
27
  filePath?: string;
28
28
  }
29
29
  export declare function isRouteSourceFile(filePath: string): boolean;
30
+ /**
31
+ * Why a `page/` file breaks the route convention, or null when it is fine. Null also covers a non-source
32
+ * asset, which `page/` tolerates. `akan sync <lib>` reports these alongside its other layout violations,
33
+ * so the rule stays in one place instead of being restated where it cannot afford to throw.
34
+ */
35
+ export declare function getPageSourceFileViolation(filePath: string): string | null;
30
36
  export declare function validatePageSourceFile(filePath: string, options?: ValidatePageSourceFileOptions): boolean;
31
37
  export declare function validateSubRoutePageKey(key: string, basePaths: Iterable<string>, options?: ValidateSubRoutePageKeyOptions): void;
32
38
  export declare function parseRouteModuleKey(key: string): ParsedRouteModuleKey;
@@ -19,6 +19,12 @@ export declare class AppWsData {
19
19
  account?: unknown;
20
20
  /** The `authorization` value `account` was resolved from, so each frame need not re-verify it. */
21
21
  resolvedAuthorization?: string;
22
- socketId?: string;
22
+ /**
23
+ * Identity of this connection, minted here so every app socket carries one from its first frame and
24
+ * adaptors and endpoints only ever read it. Per-connection and process-local — a reconnect gets a new
25
+ * one, and the federation gateway's own socket is a different one — so it is never a caller identity.
26
+ * It outlives a credential swap on purpose: the socket is still the same socket.
27
+ */
28
+ socketId: string;
23
29
  constructor(headers: Headers);
24
30
  }
@@ -3,8 +3,8 @@ import { Logger } from "akanjs/common";
3
3
  import { type ExtractInjectInfoObject, type InjectBuilder, type InjectInfo } from "./injectInfo.d.ts";
4
4
  export interface Adaptor {
5
5
  readonly logger: Logger;
6
- onInit(): Promise<void>;
7
- onDestroy(): Promise<void>;
6
+ onInit(): Promise<void> | void;
7
+ onDestroy(): Promise<void> | void;
8
8
  }
9
9
  export type AdaptorCls<Methods = any, InjectMap extends Record<string, InjectInfo> = {}> = Cls<Methods & ExtractInjectInfoObject<InjectMap> & Adaptor, {
10
10
  readonly [INJECT_META]: InjectMap;
@@ -4,15 +4,15 @@ import type { DatabaseModel } from "akanjs/document";
4
4
  import { type ExtractInjectInfoObject, type InjectBuilder, InjectInfo } from "./injectInfo.d.ts";
5
5
  import type { DatabaseServiceForModel } from "./types.d.ts";
6
6
  interface ServiceOptions {
7
- enabled?: boolean;
7
+ enabled?: boolean | (() => boolean);
8
8
  serverMode?: "batch" | "federation";
9
9
  }
10
10
  export type ServiceType = "database" | "plain";
11
11
  export interface Service {
12
12
  readonly logger: Logger;
13
- onInit(): Promise<void>;
13
+ onInit(): Promise<void> | void;
14
14
  _libsOnInit(): Promise<void>;
15
- onDestroy(): Promise<void>;
15
+ onDestroy(): Promise<void> | void;
16
16
  _libsOnDestroy(): Promise<void>;
17
17
  }
18
18
  export type ServiceCls<RefName extends string = string, Methods = {}, InjectMap extends {
@@ -6,8 +6,8 @@ export type InterceptorCls<Methods = {}, InjectMap extends {
6
6
  [key: string]: InjectInfo;
7
7
  } = {}> = Cls<Methods & ExtractInjectInfoObject<InjectMap> & {
8
8
  readonly logger: Logger;
9
- onInit(): Promise<void>;
10
- onDestroy(): Promise<void>;
9
+ onInit(): Promise<void> | void;
10
+ onDestroy(): Promise<void> | void;
11
11
  intercept(context: SignalContext): AsyncGenerator<unknown> | Promise<unknown>;
12
12
  }, {
13
13
  readonly [INJECT_META]: InjectMap;
@@ -18,12 +18,19 @@ export declare class Res implements InternalArg {
18
18
  redirect(url: string | URL, status?: number): Response;
19
19
  };
20
20
  }
21
- /** Injects websocket state and subscription hooks into message/pubsub handlers. */
21
+ /**
22
+ * Injects websocket state, this connection's id, and subscription hooks into message/pubsub handlers.
23
+ * `socketId` is the one `AppWsData` minted at the handshake, so a handler never reads `ws.data` to
24
+ * tell two callers apart — and never mints an id of its own, which would not match the room bookkeeping.
25
+ */
22
26
  export declare class Ws implements InternalArg {
23
27
  onDisconnect?: () => void;
24
28
  onUnsubscribe?: () => void;
25
29
  getArg(context: SignalContext): {
26
- ws: Bun.ServerWebSocket<unknown>;
30
+ ws: Bun.ServerWebSocket<{
31
+ socketId: string;
32
+ }>;
33
+ socketId: string;
27
34
  subscribe: boolean;
28
35
  on: (event: "disconnect" | "unsubscribe", handler: () => void) => void;
29
36
  off: (event: "disconnect" | "unsubscribe", handler: () => void) => void;