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.
- package/README.ko.md +16 -5
- package/README.md +17 -5
- package/client/makePageProto.tsx +5 -1
- package/client/route/RouteHead.ts +23 -0
- package/common/akanGraph.ts +18 -0
- package/common/appHosts.ts +7 -0
- package/common/hasErrKey.ts +8 -0
- package/common/index.ts +5 -0
- package/common/tunnelWire.ts +3 -2
- package/common/websocketContract.ts +15 -0
- package/dictionary/trans.ts +2 -4
- package/document/dataLoader.ts +20 -5
- package/fetch/client/fetchClient.ts +17 -13
- package/fetch/client/wsClient.ts +32 -1
- package/package.json +1 -1
- package/server/akanApp.ts +31 -10
- package/server/akanOption.ts +14 -0
- package/server/akanServer.ts +6 -0
- package/server/artifact/builderRpc.ts +7 -0
- package/server/artifact/ipcTypes.ts +9 -3
- package/server/devtools/devtoolsRouter.ts +4 -2
- package/server/devtools/studio/studioActions.ts +78 -0
- package/server/devtools/studio/studioDocument.tsx +95 -21
- package/server/devtools/studio/studioLayout.ts +50 -5
- package/server/devtools/studio/studioRoute.ts +18 -10
- package/server/devtools/studio/studioRuntime.ts +61 -3
- package/server/devtools/studio/studioStyle.ts +20 -1
- package/server/fallbackHead.tsx +26 -0
- package/server/flightRowReader.ts +137 -0
- package/server/index.ts +1 -0
- package/server/mcp/McpAuth.ts +2 -4
- package/server/ops/statusAccess.ts +9 -0
- package/server/resolver/database.resolver.ts +4 -2
- package/server/routeElementComposer.tsx +33 -17
- package/server/routing/apiRouter.ts +4 -0
- package/server/routing/appWsData.ts +8 -0
- package/server/routing/hostAllowlist.ts +23 -6
- package/server/routing/soloAppRoutes.ts +11 -0
- package/server/routing/wsCredentialExpiry.ts +32 -0
- package/server/rscClientPatch.ts +5 -29
- package/server/rscHttp.ts +10 -43
- package/server/rscWorker.tsx +58 -28
- package/server/rscWorkerHost.ts +6 -2
- package/server/sessionRefresh.ts +125 -0
- package/server/ssrFromRscRenderer.tsx +10 -34
- package/server/tunnel/TunnelHttpStream.ts +4 -1
- package/server/tunnel/TunnelWebsocketStream.ts +3 -1
- package/server/webRouter.ts +29 -9
- package/service/predefinedAdaptor/database.adaptor.ts +34 -10
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +17 -7
- package/service/predefinedAdaptor/sql/driver/bunSqlite.ts +29 -18
- package/service/predefinedAdaptor/sql/types.ts +1 -0
- package/types/client/makePageProto.d.ts +7274 -102
- package/types/client/route/RouteHead.d.ts +8 -1
- package/types/common/akanGraph.d.ts +17 -0
- package/types/common/appHosts.d.ts +1 -0
- package/types/common/hasErrKey.d.ts +3 -0
- package/types/common/index.d.ts +3 -2
- package/types/common/tunnelWire.d.ts +3 -2
- package/types/common/websocketContract.d.ts +5 -0
- package/types/document/dataLoader.d.ts +4 -2
- package/types/fetch/client/wsClient.d.ts +6 -0
- package/types/server/akanOption.d.ts +8 -0
- package/types/server/artifact/builderRpc.d.ts +2 -1
- package/types/server/artifact/ipcTypes.d.ts +12 -2
- package/types/server/devtools/devtoolsRouter.d.ts +2 -1
- package/types/server/devtools/studio/studioActions.d.ts +16 -0
- package/types/server/devtools/studio/studioDocument.d.ts +4 -3
- package/types/server/devtools/studio/studioLayout.d.ts +5 -2
- package/types/server/devtools/studio/studioRoute.d.ts +3 -2
- package/types/server/devtools/studio/studioStyle.d.ts +1 -1
- package/types/server/fallbackHead.d.ts +10 -0
- package/types/server/flightRowReader.d.ts +3 -0
- package/types/server/index.d.ts +1 -0
- package/types/server/ops/statusAccess.d.ts +3 -0
- package/types/server/routeElementComposer.d.ts +5 -1
- package/types/server/routing/appWsData.d.ts +1 -0
- package/types/server/routing/hostAllowlist.d.ts +11 -3
- package/types/server/routing/soloAppRoutes.d.ts +3 -0
- package/types/server/routing/wsCredentialExpiry.d.ts +5 -0
- package/types/server/rscWorkerHost.d.ts +2 -0
- package/types/server/sessionRefresh.d.ts +11 -0
- package/types/server/webRouter.d.ts +4 -2
- package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +2 -0
- package/types/service/predefinedAdaptor/sql/driver/bunSqlite.d.ts +9 -3
- package/types/service/predefinedAdaptor/sql/types.d.ts +1 -0
- package/types/ui/recipe/badgeRecipe.d.ts +2 -2
- 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만 있는 호스트:**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:**
|
|
65
|
-
`
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
package/client/makePageProto.tsx
CHANGED
|
@@ -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;
|
package/common/akanGraph.ts
CHANGED
|
@@ -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
|
+
}
|
package/common/appHosts.ts
CHANGED
|
@@ -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";
|
package/common/tunnelWire.ts
CHANGED
|
@@ -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
|
|
102
|
-
*
|
|
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. */
|
package/dictionary/trans.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/document/dataLoader.ts
CHANGED
|
@@ -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
|
-
|
|
140
|
-
|
|
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,
|
|
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);
|
package/fetch/client/wsClient.ts
CHANGED
|
@@ -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.#
|
|
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
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++;
|
package/server/akanOption.ts
CHANGED
|
@@ -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
|
}
|
package/server/akanServer.ts
CHANGED
|
@@ -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
|
}
|