akanjs 3.1.2-rc.3 → 3.1.2-rc.4

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.
Files changed (88) hide show
  1. package/README.ko.md +16 -5
  2. package/README.md +17 -5
  3. package/client/makePageProto.tsx +5 -1
  4. package/client/route/RouteHead.ts +23 -0
  5. package/common/akanGraph.ts +18 -0
  6. package/common/appHosts.ts +7 -0
  7. package/common/hasErrKey.ts +8 -0
  8. package/common/index.ts +5 -0
  9. package/common/tunnelWire.ts +3 -2
  10. package/common/websocketContract.ts +15 -0
  11. package/dictionary/trans.ts +2 -4
  12. package/document/dataLoader.ts +20 -5
  13. package/fetch/client/fetchClient.ts +17 -13
  14. package/fetch/client/wsClient.ts +32 -1
  15. package/package.json +1 -1
  16. package/server/akanApp.ts +31 -10
  17. package/server/akanOption.ts +14 -0
  18. package/server/akanServer.ts +6 -0
  19. package/server/artifact/builderRpc.ts +7 -0
  20. package/server/artifact/ipcTypes.ts +9 -3
  21. package/server/devtools/devtoolsRouter.ts +4 -2
  22. package/server/devtools/studio/studioActions.ts +78 -0
  23. package/server/devtools/studio/studioDocument.tsx +95 -21
  24. package/server/devtools/studio/studioLayout.ts +50 -5
  25. package/server/devtools/studio/studioRoute.ts +18 -10
  26. package/server/devtools/studio/studioRuntime.ts +61 -3
  27. package/server/devtools/studio/studioStyle.ts +20 -1
  28. package/server/fallbackHead.tsx +26 -0
  29. package/server/flightRowReader.ts +137 -0
  30. package/server/index.ts +1 -0
  31. package/server/mcp/McpAuth.ts +2 -4
  32. package/server/ops/statusAccess.ts +9 -0
  33. package/server/resolver/database.resolver.ts +4 -2
  34. package/server/routeElementComposer.tsx +33 -17
  35. package/server/routing/apiRouter.ts +4 -0
  36. package/server/routing/appWsData.ts +8 -0
  37. package/server/routing/hostAllowlist.ts +23 -6
  38. package/server/routing/soloAppRoutes.ts +11 -0
  39. package/server/routing/wsCredentialExpiry.ts +32 -0
  40. package/server/rscClientPatch.ts +5 -29
  41. package/server/rscHttp.ts +10 -43
  42. package/server/rscWorker.tsx +58 -28
  43. package/server/rscWorkerHost.ts +6 -2
  44. package/server/sessionRefresh.ts +125 -0
  45. package/server/ssrFromRscRenderer.tsx +10 -34
  46. package/server/tunnel/TunnelHttpStream.ts +4 -1
  47. package/server/tunnel/TunnelWebsocketStream.ts +3 -1
  48. package/server/webRouter.ts +29 -9
  49. package/service/predefinedAdaptor/database.adaptor.ts +34 -10
  50. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +17 -7
  51. package/service/predefinedAdaptor/sql/driver/bunSqlite.ts +29 -18
  52. package/service/predefinedAdaptor/sql/types.ts +1 -0
  53. package/types/client/makePageProto.d.ts +7274 -102
  54. package/types/client/route/RouteHead.d.ts +8 -1
  55. package/types/common/akanGraph.d.ts +17 -0
  56. package/types/common/appHosts.d.ts +1 -0
  57. package/types/common/hasErrKey.d.ts +3 -0
  58. package/types/common/index.d.ts +3 -2
  59. package/types/common/tunnelWire.d.ts +3 -2
  60. package/types/common/websocketContract.d.ts +5 -0
  61. package/types/document/dataLoader.d.ts +4 -2
  62. package/types/fetch/client/wsClient.d.ts +6 -0
  63. package/types/server/akanOption.d.ts +8 -0
  64. package/types/server/artifact/builderRpc.d.ts +2 -1
  65. package/types/server/artifact/ipcTypes.d.ts +12 -2
  66. package/types/server/devtools/devtoolsRouter.d.ts +2 -1
  67. package/types/server/devtools/studio/studioActions.d.ts +16 -0
  68. package/types/server/devtools/studio/studioDocument.d.ts +4 -3
  69. package/types/server/devtools/studio/studioLayout.d.ts +5 -2
  70. package/types/server/devtools/studio/studioRoute.d.ts +3 -2
  71. package/types/server/devtools/studio/studioStyle.d.ts +1 -1
  72. package/types/server/fallbackHead.d.ts +10 -0
  73. package/types/server/flightRowReader.d.ts +3 -0
  74. package/types/server/index.d.ts +1 -0
  75. package/types/server/ops/statusAccess.d.ts +3 -0
  76. package/types/server/routeElementComposer.d.ts +5 -1
  77. package/types/server/routing/appWsData.d.ts +1 -0
  78. package/types/server/routing/hostAllowlist.d.ts +11 -3
  79. package/types/server/routing/soloAppRoutes.d.ts +3 -0
  80. package/types/server/routing/wsCredentialExpiry.d.ts +5 -0
  81. package/types/server/rscWorkerHost.d.ts +2 -0
  82. package/types/server/sessionRefresh.d.ts +11 -0
  83. package/types/server/webRouter.d.ts +4 -2
  84. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +2 -0
  85. package/types/service/predefinedAdaptor/sql/driver/bunSqlite.d.ts +9 -3
  86. package/types/service/predefinedAdaptor/sql/types.d.ts +1 -0
  87. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  88. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
package/README.ko.md CHANGED
@@ -62,11 +62,22 @@ URL이라면, 코딩 에이전트는 묻지 않고 아래대로 진행하면 됩
62
62
  - **정해진 URL:** 페이지는 기본적으로 `/<locale>/…` 아래에 있습니다. 기본 로케일을 프리픽스 없는 경로
63
63
  (`/pokemon/deoxys`)로 내보내려면 `akan.config.ts`에 `i18n: { defaultLocale: "en", locales: ["en"], localePrefix:
64
64
  "as-needed" }`를 두세요. `<Link>`도 따라갑니다.
65
- - **Node와 npm만 있는 호스트:** `npm install -g bun`으로 생성기를 실행할 런타임을 받으세요. 호스트에도 설치되도록
66
- 워크스페이스의 `dependencies`에 `bun`을 두고, package script에서는 `devDependencies`의 `@akanjs/cli`로 `akan`을
67
- 부르세요. 전역 설치 뒤 `akan`을 찾지 못하면 `bun pm bin -g`가 출력하는 폴더를 `PATH`에 더하세요.
68
- - **Docker 없이 프로덕션 빌드 실행:** `akan build <app>` 뒤 `cd dist/apps/<app> && PORT=3000 bun main.js`. 빌드에
69
- 필요한 env가 함께 들어 있습니다.
65
+ - **Node와 npm만 있는 호스트:** 생성기를 npx로 실행하세요. 전역에 아무것도 쓰지 않습니다:
66
+ `npx -y bun@1.4.4 x create-akan-workspace@latest . --app <app> --dir <folder> --sample false`. 호스트에도
67
+ 설치되도록 워크스페이스의 `dependencies`에 `bun`을 두고, package script에서는 `devDependencies`의 `@akanjs/cli`로
68
+ `akan`을 부르세요. 전역 설치 뒤 `akan`을 찾지 못하면 `bun pm bin -g`가 출력하는 폴더를 `PATH`에 더하세요.
69
+ - **Docker 없이 빌드하고 실행하기:** 아래 package script를 두고 `npm run build` 뒤 `PORT=3000 npm start`로 띄웁니다.
70
+
71
+ ```json
72
+ "build": "akan build <app>",
73
+ "start": "cd dist/apps/<app> && bun main.js"
74
+ ```
75
+
76
+ 새로 clone한 사본도 그대로 빌드됩니다. env 파일은 gitignore된 채로 두면 `akan`이 없는 것을 만들어 주므로 커밋할
77
+ 필요가 없습니다. 빌드에 필요한 env가 함께 들어 있고, 실행할 때 지정한 변수가 그 값을 덮어씁니다.
78
+ - **실행할 곳에 맞게 빌드하기.** `.env`가 없으면 `akan build`는 `local`로 빌드합니다. 내 컴퓨터나 테스트 러너에서는
79
+ 이대로 맞지만, dev 라우트를 누구에게나 엽니다. 다른 사람이 접속하는 호스트라면 `AKAN_PUBLIC_ENV=main`,
80
+ `AKAN_PUBLIC_REPO_NAME`, `AKAN_PUBLIC_SERVE_DOMAIN`(서비스할 도메인)을 셸이나 워크스페이스 `.env`에 지정하세요.
70
81
 
71
82
  ## 이 모든 게 한 줄입니다
72
83
 
package/README.md CHANGED
@@ -61,11 +61,23 @@ Node and npm, or URLs given by a spec, a coding agent needs these, and nothing h
61
61
  - **Exact URLs:** pages live under `/<locale>/…`. To serve the default locale at the bare path (`/pokemon/deoxys`),
62
62
  set `i18n: { defaultLocale: "en", locales: ["en"], localePrefix: "as-needed" }` in `akan.config.ts`; `<Link>`
63
63
  follows.
64
- - **A host with only Node and npm:** `npm install -g bun` gives the creator its runtime. Put `bun` in the workspace's
65
- `dependencies` so the host installs it too, and let the package scripts call `akan` from the `@akanjs/cli` in
66
- `devDependencies`. If `akan` is not found after a global install, add the folder `bun pm bin -g` prints to `PATH`.
67
- - **Run the production build without Docker:** `akan build <app>`, then `cd dist/apps/<app> && PORT=3000 bun main.js`.
68
- The build carries the env it needs.
64
+ - **A host with only Node and npm:** run the creator through npx, which writes nothing global:
65
+ `npx -y bun@1.4.4 x create-akan-workspace@latest . --app <app> --dir <folder> --sample false`. Put `bun` in the
66
+ workspace's `dependencies` so the host installs it too, and let the package scripts call `akan` from the
67
+ `@akanjs/cli` in `devDependencies`. If `akan` is not found after a global install, add the folder `bun pm bin -g`
68
+ prints to `PATH`.
69
+ - **Build and run without Docker** through these package scripts; `npm run build`, then `PORT=3000 npm start`:
70
+
71
+ ```json
72
+ "build": "akan build <app>",
73
+ "start": "cd dist/apps/<app> && bun main.js"
74
+ ```
75
+
76
+ A fresh clone builds as is: the env files stay gitignored and `akan` writes the ones it lacks, so commit none. The
77
+ build carries the env it needs, and a variable set at start overrides it.
78
+ - **Build for where it runs.** Without a `.env`, `akan build` builds for `local`, right for your own machine or a test
79
+ runner but serving the dev routes to every caller. For a host others can reach, set `AKAN_PUBLIC_ENV=main`,
80
+ `AKAN_PUBLIC_REPO_NAME` and `AKAN_PUBLIC_SERVE_DOMAIN` (the domain it serves) in the shell or the workspace `.env`.
69
81
 
70
82
  ## All Of It Is One Line
71
83
 
@@ -1,5 +1,5 @@
1
1
  import { getEnv } from "akanjs/base";
2
- import { parseAkanI18nEnv, RequestLocale } from "akanjs/common";
2
+ import { hasErrKey, parseAkanI18nEnv, RequestLocale } from "akanjs/common";
3
3
  import { untrackedHeaders, untrackedRequest } from "akanjs/fetch";
4
4
  import type { ReactNode } from "react";
5
5
  import { csrLocation } from "./csrLocation";
@@ -118,6 +118,10 @@ export const makePageProto = <
118
118
  return new Err(payload.error as ErrorKey, payload.data, payload);
119
119
  }
120
120
 
121
+ static is<Key extends ErrorKey>(error: unknown, ...keys: Key[]): error is Err & { readonly error: Key } {
122
+ return hasErrKey(error, keys);
123
+ }
124
+
121
125
  static BadRequest = class BadRequestErr extends Err {
122
126
  constructor(key: ErrorKey, data?: Record<string, unknown>, option: ErrRestoreOption = {}) {
123
127
  super(key, data, { ...option, statusCode: 400 });
@@ -37,6 +37,29 @@ export class RouteHead {
37
37
  return RouteHead.merge([RouteHead.#withoutPageIdentity(layouts), page]);
38
38
  }
39
39
 
40
+ /** The tags at the top of `node` that a head merges by identity, apart from the rest, which is `node` if none. */
41
+ static split(node: ReactNode): { tags: ReactNode[]; rest: ReactNode } {
42
+ const children = RouteHead.#flatten(node);
43
+ const tags = children.filter((child) => RouteHead.#keyOfNode(child) !== null);
44
+ if (!tags.length) return { tags, rest: node };
45
+ const rest = children.filter((child) => RouteHead.#keyOfNode(child) === null);
46
+ return { tags, rest: createElement(Fragment, null, ...rest) };
47
+ }
48
+
49
+ /** A `robots` meta that adds `noindex` to the directives of the last `robots` meta at the top of `node`. */
50
+ static noindexOver(node: ReactNode): ReactElement {
51
+ const robots = RouteHead.#flatten(node).findLast((child) => RouteHead.#keyOfNode(child) === "meta:name:robots");
52
+ const content = isValidElement(robots) ? (robots.props as TagProps).content : undefined;
53
+ const kept =
54
+ typeof content === "string"
55
+ ? content
56
+ .split(",")
57
+ .map((directive) => directive.trim())
58
+ .filter((directive) => directive && !/^(?:index|noindex|all)$/i.test(directive))
59
+ : [];
60
+ return createElement("meta", { key: "robots", name: "robots", content: ["noindex", ...kept].join(", ") });
61
+ }
62
+
40
63
  /** What makes two tags the same tag; `null` for one that never replaces another. */
41
64
  static keyOf(tag: string, props: TagProps): string | null {
42
65
  if (tag === "title" || tag === "base") return tag;
@@ -12,6 +12,8 @@ export type AkanGraphNodeKind =
12
12
 
13
13
  export type AkanGraphEdgeKind = "field" | "guard" | "calls" | "fetches" | "uses";
14
14
 
15
+ export type AkanGraphChange = "added" | "removed" | "changed";
16
+
15
17
  export interface AkanGraphLocation {
16
18
  file: string;
17
19
  line: number;
@@ -26,6 +28,8 @@ export interface AkanGraphField {
26
28
  ref: string | null;
27
29
  declaredIn: string;
28
30
  at: AkanGraphLocation;
31
+ digest?: string;
32
+ change?: AkanGraphChange;
29
33
  }
30
34
 
31
35
  export interface AkanGraphNode {
@@ -39,6 +43,8 @@ export interface AkanGraphNode {
39
43
  at: AkanGraphLocation | null;
40
44
  fields?: AkanGraphField[];
41
45
  values?: string[];
46
+ digest?: string;
47
+ change?: AkanGraphChange;
42
48
  }
43
49
 
44
50
  export interface AkanGraphEdge {
@@ -48,6 +54,7 @@ export interface AkanGraphEdge {
48
54
  label: string | null;
49
55
  count: number;
50
56
  at: AkanGraphLocation | null;
57
+ change?: Exclude<AkanGraphChange, "changed">;
51
58
  }
52
59
 
53
60
  export interface AkanGraph {
@@ -55,3 +62,14 @@ export interface AkanGraph {
55
62
  nodes: AkanGraphNode[];
56
63
  edges: AkanGraphEdge[];
57
64
  }
65
+
66
+ export interface AkanChangedFile {
67
+ file: string;
68
+ change: AkanGraphChange;
69
+ }
70
+
71
+ export interface AkanChangeMap extends AkanGraph {
72
+ since: string;
73
+ summary: { [change in AkanGraphChange]: number };
74
+ unmapped: AkanChangedFile[];
75
+ }
@@ -1,4 +1,5 @@
1
1
  import { getEnv } from "akanjs/base";
2
+ import { IpLiteral } from "./IpLiteral";
2
3
 
3
4
  export class AppHosts {
4
5
  static readonly #declared = new Set<string>();
@@ -24,6 +25,12 @@ export class AppHosts {
24
25
  return listed.some((entry) => AppHosts.nameOf(entry) === name);
25
26
  }
26
27
 
28
+ static isRebindSafe(host: string): boolean {
29
+ const name = AppHosts.nameOf(host);
30
+ if (!name) return false;
31
+ return !!IpLiteral.parse(host) || name === "localhost" || name.endsWith(".localhost") || AppHosts.allows(host);
32
+ }
33
+
27
34
  static nameOf(host: string): string | null {
28
35
  const url = URL.parse(`http://${host.trim()}`);
29
36
  if (!url || url.href !== `${url.origin}/`) return null;
@@ -0,0 +1,8 @@
1
+ export const hasErrKey = <Key extends string>(
2
+ error: unknown,
3
+ keys: readonly Key[],
4
+ ): error is { readonly error: Key } => {
5
+ if (typeof error !== "object" || error === null) return false;
6
+ const key = (error as { error?: unknown }).error;
7
+ return typeof key === "string" && (!keys.length || (keys as readonly string[]).includes(key));
8
+ };
package/common/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export type {
2
+ AkanChangedFile,
3
+ AkanChangeMap,
2
4
  AkanGraph,
5
+ AkanGraphChange,
3
6
  AkanGraphEdge,
4
7
  AkanGraphEdgeKind,
5
8
  AkanGraphField,
@@ -99,6 +102,7 @@ export {
99
102
  resolveFileUploadCapability,
100
103
  } from "./fileUpload";
101
104
  export { formatNumber } from "./formatNumber";
105
+ export { hasErrKey } from "./hasErrKey";
102
106
  export { type AkanHmrPhase, getAkanHmrPhase, isAkanHmrApplying } from "./hmrPhase";
103
107
  export { IpLiteral } from "./IpLiteral";
104
108
  export { interpolateTranslation } from "./interpolateTranslation";
@@ -270,5 +274,6 @@ export {
270
274
  type WebsocketHeartbeatRequest,
271
275
  websocketAuthContract,
272
276
  websocketBinaryFrameContract,
277
+ websocketCredentialContract,
273
278
  websocketHeartbeatContract,
274
279
  } from "./websocketContract";
@@ -98,8 +98,9 @@ export interface TunnelOpenFrame {
98
98
  /** `http` and `websocket`: path plus query, already percent-encoded. */
99
99
  path?: string;
100
100
  /**
101
- * `http` and `websocket`, replayed verbatim: the gateway strips every `forwardedHeaderNames` entry the caller sent
102
- * (else Host header injection) and writes `x-forwarded-host` as the tunnel hostname (else CSRF refuses mutations).
101
+ * `http` and `websocket`, replayed verbatim but for `host`, which the agent dials as the origin's own: the gateway
102
+ * strips every `forwardedHeaderNames` entry the caller sent (else Host header injection) and writes
103
+ * `x-forwarded-host` as the tunnel hostname (else CSRF refuses mutations).
103
104
  */
104
105
  headers?: TunnelHeaderList;
105
106
  /** `tcp`: the port on the local origin to dial. */
@@ -1,3 +1,5 @@
1
+ import { decodeJwtPayload } from "./jwtDecode";
2
+
1
3
  export interface WebsocketAuthRequest {
2
4
  key: string;
3
5
  data: [string | null];
@@ -27,6 +29,19 @@ export interface WebsocketHeartbeatAckData {
27
29
  type: "pong";
28
30
  }
29
31
 
32
+ export const websocketCredentialContract = {
33
+ expiredCode: 4401,
34
+ expiredReason: "credential expired",
35
+ expiresAtOf: (jwt: string): number | null => {
36
+ try {
37
+ const { exp } = decodeJwtPayload<{ exp?: unknown }>(jwt);
38
+ return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ },
43
+ } as const;
44
+
30
45
  export const websocketHeartbeatContract = {
31
46
  key: "__ping",
32
47
  /** Under the 60s idle timeout nginx and most CDNs ship with. */
@@ -1,5 +1,5 @@
1
1
  import type { GetStateObject, ObjectAssign, Prettify } from "akanjs/base";
2
- import { interpolateTranslation, Logger, parseAkanI18nEnv, pathGetLoose } from "akanjs/common";
2
+ import { hasErrKey, interpolateTranslation, Logger, parseAkanI18nEnv, pathGetLoose } from "akanjs/common";
3
3
 
4
4
  import { DictionaryRegistry } from "./dictionaryRegistry";
5
5
  import type { DictModule } from "./locale";
@@ -153,9 +153,7 @@ export const makeTrans = <
153
153
 
154
154
  /** An Akan error, and one of `keys` when any are named; read off `error`, so a restored remote one passes too. */
155
155
  static is<Key extends _ErrorKey>(error: unknown, ...keys: Key[]): error is ErrInstance & { readonly error: Key } {
156
- if (typeof error !== "object" || error === null) return false;
157
- const key = (error as { error?: unknown }).error;
158
- return typeof key === "string" && (!keys.length || (keys as string[]).includes(key));
156
+ return hasErrKey(error, keys);
159
157
  }
160
158
 
161
159
  static BadRequest = class BadRequestErr extends Err {
@@ -11,10 +11,12 @@ type BatchLoadFn<Key, Value> = (
11
11
  keys: readonly Key[],
12
12
  ) => PromiseLike<ReadonlyArray<Value | Error>> | ReadonlyArray<Value | Error>;
13
13
 
14
- interface DataLoaderOptions<Key, CacheKey> {
14
+ interface DataLoaderOptions<Key, Value, CacheKey> {
15
15
  /** `false` (default) keeps nothing past its batch, a number keeps a key that many ms, `true` for its whole life. */
16
16
  cache?: boolean | number;
17
17
  cacheKeyFn?: (key: Key) => CacheKey;
18
+ /** What a caller gets in place of a value another caller already holds: a repeated key's, or a kept key's. */
19
+ copy?: (value: Value) => Value;
18
20
  batch?: boolean;
19
21
  batchScheduleFn?: (callback: () => void) => void;
20
22
  maxBatchSize?: number;
@@ -36,12 +38,13 @@ export class DataLoader<Key, Value, CacheKey = Key> {
36
38
  readonly #batch: boolean;
37
39
  readonly #batchScheduleFn: (callback: () => void) => void;
38
40
  readonly #maxBatchSize: number;
41
+ readonly #copy: ((value: Value) => Value) | null;
39
42
  readonly #promiseCache = new Map<CacheKey, { promise: Promise<Value>; expiresAt: number }>();
40
43
  #sweepAt = DataLoader.#minSweepSize;
41
44
  #queue: BatchItem<Key, Value>[] = [];
42
45
  #scheduled = false;
43
46
 
44
- constructor(batchLoadFn: BatchLoadFn<Key, Value>, options: DataLoaderOptions<Key, CacheKey> = {}) {
47
+ constructor(batchLoadFn: BatchLoadFn<Key, Value>, options: DataLoaderOptions<Key, Value, CacheKey> = {}) {
45
48
  this.#batchLoadFn = batchLoadFn;
46
49
  this.#ttl =
47
50
  options.cache === true
@@ -53,13 +56,14 @@ export class DataLoader<Key, Value, CacheKey = Key> {
53
56
  this.#batch = options.batch !== false;
54
57
  this.#batchScheduleFn = options.batchScheduleFn ?? ((callback) => queueMicrotask(callback));
55
58
  this.#maxBatchSize = options.maxBatchSize ?? Number.POSITIVE_INFINITY;
59
+ this.#copy = options.copy ?? null;
56
60
  this.name = options.name;
57
61
  }
58
62
 
59
63
  load(key: Key): Promise<Value> {
60
64
  const cacheKey = this.#cacheKeyFn(key);
61
65
  const cached = this.#cached(cacheKey);
62
- if (cached) return cached;
66
+ if (cached) return this.#copy ? cached.then(this.#copy) : cached;
63
67
 
64
68
  const promise = new Promise<Value>((resolve, reject) => {
65
69
  this.#queue.push({ key, resolve, reject });
@@ -135,9 +139,20 @@ export class DataLoader<Key, Value, CacheKey = Key> {
135
139
  for (const { reject } of batch) reject(error);
136
140
  return;
137
141
  }
142
+ const handedOut = new Set<Value | Error>();
138
143
  values.forEach((value, index) => {
139
- if (value instanceof Error) batch[index]?.reject(value);
140
- else batch[index]?.resolve(value as Value);
144
+ const item = batch[index];
145
+ if (value instanceof Error) item?.reject(value);
146
+ else if (this.#copy && handedOut.has(value)) {
147
+ try {
148
+ item?.resolve(this.#copy(value as Value));
149
+ } catch (error) {
150
+ item?.reject(error);
151
+ }
152
+ } else {
153
+ handedOut.add(value);
154
+ item?.resolve(value as Value);
155
+ }
141
156
  });
142
157
  },
143
158
  (error) => {
@@ -455,7 +455,7 @@ export class FetchClient {
455
455
  #makeHttpFn(key: string, endpoint: SerializedEndpoint, prefix?: string) {
456
456
  const argLength = endpoint.args.length;
457
457
  const serializerMap = this.#makeArgSerializer(endpoint.args);
458
- const parseReturn = this.#makeReturnParser(endpoint.returns);
458
+ const parseReturn = FetchClient.#refusingUndeclaredNull(key, endpoint, this.#makeReturnParser(endpoint.returns));
459
459
  const { bodyArgs, uploadArgs } = FetchClient.classifyHttpArgs(endpoint.args);
460
460
  const requestOf = (argData: unknown[]) => {
461
461
  const args = argData.slice(0, argLength);
@@ -527,7 +527,6 @@ export class FetchClient {
527
527
  const roomArgLength = roomArgs.length;
528
528
  const serializerMap = this.#makeArgSerializer(endpoint.args);
529
529
  const parseReturn = this.#makeReturnParser(endpoint.returns);
530
- const wrappedListeners = new WeakMap<(data: unknown) => void, (data: unknown) => void>();
531
530
  return (...argData: unknown[]) => {
532
531
  this.#refuseInApp(key);
533
532
 
@@ -540,17 +539,10 @@ export class FetchClient {
540
539
  const parsedReturn = parseReturn(data, { crystalize: fetchPolicy?.crystalize ?? true });
541
540
  handleEvent(parsedReturn);
542
541
  };
543
- wrappedListeners.set(handleEvent, wrapped);
544
542
  const ws = this.#resolveWs(fetchPolicy?.origin);
545
543
  const handleResync = fetchPolicy?.onResync;
546
544
  ws.subscribe({ key, data, handleEvent: wrapped, handleResync });
547
- return () =>
548
- ws.unsubscribe({
549
- key,
550
- data,
551
- handleEvent: wrappedListeners.get(handleEvent) ?? handleEvent,
552
- handleResync,
553
- });
545
+ return () => ws.unsubscribe({ key, data, handleEvent: wrapped, handleResync });
554
546
  };
555
547
  });
556
548
  return;
@@ -572,16 +564,14 @@ export class FetchClient {
572
564
  });
573
565
  this.#setHandlerFactory(endpointNames(key).listen, () => {
574
566
  const parseReturn = this.#makeReturnParser(endpoint.returns);
575
- const wrappedListeners = new WeakMap<(data: unknown) => void, (data: unknown) => void>();
576
567
  return ((handleEvent: (data: unknown) => void, fetchPolicy: FetchPolicy = {}) => {
577
568
  const wrapped = (data: unknown) => {
578
569
  const parsedReturn = parseReturn(data, { crystalize: fetchPolicy?.crystalize ?? true });
579
570
  handleEvent(parsedReturn);
580
571
  };
581
- wrappedListeners.set(handleEvent, wrapped);
582
572
  const ws = this.#resolveWs(fetchPolicy.origin);
583
573
  ws.on(key, wrapped);
584
- return () => ws.off(key, wrappedListeners.get(handleEvent) ?? handleEvent);
574
+ return () => ws.off(key, wrapped);
585
575
  }) as FetchHandler;
586
576
  });
587
577
  return;
@@ -976,6 +966,20 @@ export class FetchClient {
976
966
  );
977
967
  return serializerMap;
978
968
  }
969
+ static #refusingUndeclaredNull<Parse extends (value: unknown, option?: { crystalize?: boolean }) => unknown>(
970
+ key: string,
971
+ endpoint: SerializedEndpoint,
972
+ parse: Parse,
973
+ ): Parse {
974
+ if (endpoint.returns.nullable) return parse;
975
+ return ((value: unknown, option?: { crystalize?: boolean }) => {
976
+ if (value === null || value === undefined)
977
+ throw new Error(
978
+ `${endpoint.type} ${key} answered ${value}, but its return is not nullable: answer a value, or declare { nullable: true } and answer null`,
979
+ );
980
+ return parse(value, option);
981
+ }) as Parse;
982
+ }
979
983
  #makeReturnParser(returns: SerializedReturns) {
980
984
  if (returns.modelType) {
981
985
  const returnRef = ConstantRegistry.getModelRef(returns.refName, returns.modelType);
@@ -6,6 +6,7 @@ import {
6
6
  type WebsocketHeartbeatAckData,
7
7
  websocketAuthContract,
8
8
  websocketBinaryFrameContract,
9
+ websocketCredentialContract,
9
10
  websocketHeartbeatContract,
10
11
  } from "akanjs/common";
11
12
  import type {
@@ -37,6 +38,11 @@ export interface WsClientOptions {
37
38
  onRevoked?: (roomId: string) => void;
38
39
  /** A frame the server answered with an error, and the subscribe or message key it answered. */
39
40
  onError?: (error: unknown, key?: string) => void;
41
+ /**
42
+ * Awaited, up to `WsClient.renewTimeoutMs`, before the reconnect that follows the server closing the socket at its
43
+ * credential's expiry: renew the session cookie, or call `setJwt`, so the new handshake carries a live credential.
44
+ */
45
+ onCredentialExpired?: () => unknown;
40
46
  }
41
47
 
42
48
  interface SubscribeOption {
@@ -68,6 +74,7 @@ export class WsClient {
68
74
  return websocketRoomContract.idOf(key, args);
69
75
  }
70
76
  static readonly stableMs = 5_000;
77
+ static renewTimeoutMs = 10_000;
71
78
 
72
79
  readonly logger = new Logger("WsClient");
73
80
  readonly url: string;
@@ -91,6 +98,7 @@ export class WsClient {
91
98
  #outbox: string[] = [];
92
99
  #unconnectedWarnTimers = new Map<string, ReturnType<typeof setTimeout>>();
93
100
  #jwt: string | null = null;
101
+ #renewing: Promise<unknown> | null = null;
94
102
  #heartbeatTimer: ReturnType<typeof setInterval> | null = null;
95
103
  #lastInboundAt = 0;
96
104
  #options: WsClientOptions = {};
@@ -268,7 +276,8 @@ export class WsClient {
268
276
 
269
277
  if (openedAt && Date.now() - openedAt >= WsClient.stableMs) this.#reconnectAttempts = 0;
270
278
  if (this.#destroyed) return;
271
- this.#closed();
279
+ if (event.code === websocketCredentialContract.expiredCode) this.#renewThenReconnect();
280
+ else this.#closed();
272
281
 
273
282
  this.#handleMessage("disconnect", { code: event.code, reason: event.reason });
274
283
  };
@@ -293,6 +302,28 @@ export class WsClient {
293
302
  this.#scheduleReconnect();
294
303
  }
295
304
 
305
+ #renewThenReconnect() {
306
+ this.#reconnectAttempts = 0;
307
+ this.#setState("closed");
308
+ const renewing = Promise.race([
309
+ Promise.resolve()
310
+ .then(() => this.#options.onCredentialExpired?.())
311
+ .catch((error: unknown) => {
312
+ this.logger.warn(`WebSocket credential renewal failed: ${error instanceof Error ? error.message : error}`);
313
+ }),
314
+ new Promise<void>((resolve) => {
315
+ setTimeout(resolve, WsClient.renewTimeoutMs).unref?.();
316
+ }),
317
+ ]);
318
+ this.#renewing = renewing;
319
+ void renewing.then(() => {
320
+ if (this.#renewing !== renewing) return;
321
+ this.#renewing = null;
322
+ if (this.#destroyed || this.#ws || this.#reconnectTimer) return;
323
+ this.#connect();
324
+ });
325
+ }
326
+
296
327
  #startHeartbeat() {
297
328
  this.#stopHeartbeat();
298
329
  this.#lastInboundAt = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.1.2-rc.3",
3
+ "version": "3.1.2-rc.4",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -26,6 +26,7 @@ import type { OpsRoute } from "./ops/opsRoute";
26
26
  import { StatusAccess } from "./ops/statusAccess";
27
27
  import { ProcessMetricsCollector } from "./processMetricsCollector";
28
28
  import { HostAllowlist } from "./routing/hostAllowlist";
29
+ import { RouteHosts } from "./routing/routeHosts";
29
30
  import { ServerLimits } from "./routing/serverLimits";
30
31
  import { WsUpgrade } from "./routing/wsUpgrade";
31
32
  import { SelfExec } from "./selfExec";
@@ -615,6 +616,8 @@ export class AkanApp {
615
616
  }
616
617
 
617
618
  #listen() {
619
+
620
+ if (this.#hostAllowlist?.devDefault) RouteHosts.declare(this.#artifactDir);
618
621
  this.#server = Bun.serve({
619
622
  idleTimeout: this.#limits.idleTimeout,
620
623
  maxRequestBodySize: this.#limits.uploadBodyBytes,
@@ -643,6 +646,8 @@ export class AkanApp {
643
646
  const url = new URL(req.url);
644
647
  if (url.pathname === "/_akan/app/health")
645
648
  return this.#statusAccess.health(req, () => this.#getHealthStatus(), AkanApp.#clientOf(req, server));
649
+ if (url.pathname === "/_akan/app/ready")
650
+ return this.#statusAccess.ready(req, () => this.#getReadyStatus(), AkanApp.#clientOf(req, server));
646
651
  if (url.pathname === "/_akan/app/metrics")
647
652
  return this.#statusAccess.metrics(req, () => this.#getMetricsStatus(), AkanApp.#clientOf(req, server));
648
653
  if (url.pathname === LogStreamRoute.path && this.#logStream)
@@ -815,6 +820,20 @@ export class AkanApp {
815
820
  };
816
821
  }
817
822
 
823
+ #getReadyStatus() {
824
+ return {
825
+ ready: this.#isReady(),
826
+ pid: process.pid,
827
+ children: [...this.#children.values()].map(({ idx, role, status, ready }) => ({ idx, role, status, ready })),
828
+ };
829
+ }
830
+
831
+ #isReady() {
832
+ if (this.#stopping) return false;
833
+ if (this.#replica.federation + this.#replica.all > 0) return this.#federationCandidates().length > 0;
834
+ return [...this.#children.values()].some((child) => child.ready && !this.#isChildUnavailable(child));
835
+ }
836
+
818
837
  #getMetricsStatus() {
819
838
  return {
820
839
  rooms: this.#roomChildren.size,
@@ -1073,17 +1092,19 @@ export class AkanApp {
1073
1092
  return await encodedFileResponse(req, filePath, options.contentType, assetHeaders(options));
1074
1093
  }
1075
1094
 
1095
+ #federationCandidates() {
1096
+ this.#federationChildCache ??= [...this.#children.values()].filter(
1097
+ (child) =>
1098
+ (child.role === "federation" || child.role === "all") &&
1099
+ child.ready &&
1100
+ child.status !== "unhealthy" &&
1101
+ !this.#isChildUnavailable(child),
1102
+ );
1103
+ return this.#federationChildCache;
1104
+ }
1105
+
1076
1106
  #pickFederationChild() {
1077
- const candidates =
1078
- this.#federationChildCache ??
1079
- [...this.#children.values()].filter(
1080
- (child) =>
1081
- (child.role === "federation" || child.role === "all") &&
1082
- child.ready &&
1083
- child.status !== "unhealthy" &&
1084
- !this.#isChildUnavailable(child),
1085
- );
1086
- this.#federationChildCache = candidates;
1107
+ const candidates = this.#federationCandidates();
1087
1108
  if (candidates.length === 0) return null;
1088
1109
  const child = candidates[this.#rrIdx % candidates.length];
1089
1110
  this.#rrIdx++;
@@ -12,6 +12,7 @@ import type {
12
12
  import type { McpServerOption } from "./akanServer";
13
13
  import type { WebProxyRegistration } from "./proxy";
14
14
  import { HostBasePathWebProxy, LocaleWebProxy } from "./proxy";
15
+ import type { SessionRefreshHook } from "./sessionRefresh";
15
16
 
16
17
  export interface AdaptorOverride {
17
18
  role: AdaptorCls;
@@ -32,6 +33,7 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
32
33
  #agentQuota: AgentQuotaHook | null | undefined;
33
34
  #crossSite: CrossSiteOption | undefined;
34
35
  #getRateLimit: ((env: Env) => RateLimitSetting | false) | undefined;
36
+ #sessionRefresh: SessionRefreshHook | null | undefined;
35
37
  use(fnOrObject: ((env: Env) => Record<string, PromiseOrObject<unknown>>) | Record<string, PromiseOrObject<unknown>>) {
36
38
  if (typeof fnOrObject === "function")
37
39
  this.#getUses.push(fnOrObject as (env: Env) => Record<string, PromiseOrObject<unknown>>);
@@ -92,6 +94,15 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
92
94
  this.#getRateLimit = typeof settingOrFn === "function" ? settingOrFn : () => settingOrFn;
93
95
  return this;
94
96
  }
97
+ /**
98
+ * Runs before a page or RSC navigation renders, with the request as the browser sent it, and returns `Set-Cookie`
99
+ * values (or none). The render reads the cookies they set and the response carries them, so a session whose access
100
+ * token expired is renewed before a guard sees it. The app's wins over its libs'; `null` clears a lib's.
101
+ */
102
+ setSessionRefresh(hook: SessionRefreshHook | null) {
103
+ this.#sessionRefresh = hook;
104
+ return this;
105
+ }
95
106
  /** Injected into the `LlmAdaptorRole` adaptor as the `llmOption` use; merged in mount order, the app's last. */
96
107
  setLlm<Option extends LlmOption>(llmOrFn: Option | ((env: Env) => Option)) {
97
108
  if (typeof llmOrFn === "function") this.#getLlms.push(llmOrFn);
@@ -151,6 +162,9 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
151
162
  getRateLimit(env: Env): RateLimitSetting | false | undefined {
152
163
  return this.#getRateLimit?.(env);
153
164
  }
165
+ getSessionRefresh(): SessionRefreshHook | null | undefined {
166
+ return this.#sessionRefresh;
167
+ }
154
168
  getLlm(env: Env): LlmOption {
155
169
  return Object.assign({}, ...this.#getLlms.map((fn) => fn(env)));
156
170
  }
@@ -51,6 +51,7 @@ import { RoomReplay } from "./routing/roomReplay";
51
51
  import { RouteHosts } from "./routing/routeHosts";
52
52
  import { ServerLimits } from "./routing/serverLimits";
53
53
  import { createSoloAppRoutes } from "./routing/soloAppRoutes";
54
+ import { SessionRefresh } from "./sessionRefresh";
54
55
  import {
55
56
  getWebConfigFromEnv,
56
57
  type HttpRoutes,
@@ -223,6 +224,7 @@ export class AkanServer {
223
224
  this.openapi = options?.openapi ?? this.openapi;
224
225
 
225
226
  EndpointRateLimit.reset();
227
+ SessionRefresh.use(null);
226
228
  libs.forEach((lib) => {
227
229
  const mcp = lib.option.getMcp(this.env);
228
230
  if (mcp !== undefined) this.setMcp(mcp);
@@ -236,6 +238,8 @@ export class AkanServer {
236
238
  if (crossSite !== undefined) CrossSiteGuard.configure(crossSite);
237
239
  const rateLimit = lib.option.getRateLimit(this.env);
238
240
  if (rateLimit !== undefined) EndpointRateLimit.configure(rateLimit);
241
+ const sessionRefresh = lib.option.getSessionRefresh();
242
+ if (sessionRefresh !== undefined) SessionRefresh.use(sessionRefresh);
239
243
  });
240
244
  EndpointRateLimit.applyEnv(process.env.AKAN_RATE_LIMIT, getEnv().environment);
241
245
  this.setMcp(options?.mcp ?? this.mcp);
@@ -763,6 +767,7 @@ export class AkanServer {
763
767
  running: this.status === "running",
764
768
  status: this.status,
765
769
  port: this.#server?.port ?? null,
770
+ rscWorker: webRouter?.rscWorkerStatus ?? null,
766
771
  metrics: this.#lastMetrics,
767
772
  }),
768
773
  this.#logStream,
@@ -783,6 +788,7 @@ export class AkanServer {
783
788
  openapi: this.openapi,
784
789
  getStatus: () => this.status,
785
790
  buildGraph: webRouter ? () => webRouter.buildGraph() : undefined,
791
+ buildChanges: webRouter ? (since) => webRouter.buildChanges(since) : undefined,
786
792
  }).createRoutes();
787
793
  return { ...openapiRoutes, ...mcpRoutes, ...devtoolsRoutes, ...soloRoutes };
788
794
  }