akanjs 3.1.2-rc.2 → 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 +75 -0
- package/common/appHosts.ts +7 -0
- package/common/hasErrKey.ts +8 -0
- package/common/index.ts +14 -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 +38 -15
- package/server/akanOption.ts +14 -0
- package/server/akanServer.ts +7 -0
- package/server/artifact/builderRpc.ts +18 -8
- package/server/artifact/ipcTypes.ts +23 -1
- package/server/devtools/devtoolsRouter.ts +17 -0
- package/server/devtools/studio/studioActions.ts +78 -0
- package/server/devtools/studio/studioDocument.tsx +276 -0
- package/server/devtools/studio/studioLayout.ts +269 -0
- package/server/devtools/studio/studioRoute.ts +81 -0
- package/server/devtools/studio/studioRuntime.ts +123 -0
- package/server/devtools/studio/studioStyle.ts +58 -0
- 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 +33 -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 +58 -0
- package/types/common/appHosts.d.ts +1 -0
- package/types/common/hasErrKey.d.ts +3 -0
- package/types/common/index.d.ts +3 -1
- package/types/common/tunnelWire.d.ts +3 -2
- package/types/common/websocketContract.d.ts +5 -0
- package/types/constant/constantRegistry.d.ts +1 -1
- 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 +3 -1
- package/types/server/artifact/ipcTypes.d.ts +33 -1
- package/types/server/devtools/devtoolsRouter.d.ts +3 -0
- package/types/server/devtools/studio/studioActions.d.ts +16 -0
- package/types/server/devtools/studio/studioDocument.d.ts +23 -0
- package/types/server/devtools/studio/studioLayout.d.ts +40 -0
- package/types/server/devtools/studio/studioRoute.d.ts +17 -0
- package/types/server/devtools/studio/studioRuntime.d.ts +1 -0
- package/types/server/devtools/studio/studioStyle.d.ts +1 -0
- 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/migration/migrationCommand.d.ts +2 -2
- package/types/server/migration/migrations.d.ts +1 -1
- 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 +5 -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/types/ui/recipe/inputRecipe.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;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type AkanGraphNodeKind =
|
|
2
|
+
| "model"
|
|
3
|
+
| "scalar"
|
|
4
|
+
| "enum"
|
|
5
|
+
| "query"
|
|
6
|
+
| "slice"
|
|
7
|
+
| "endpoint"
|
|
8
|
+
| "guard"
|
|
9
|
+
| "method"
|
|
10
|
+
| "action"
|
|
11
|
+
| "component";
|
|
12
|
+
|
|
13
|
+
export type AkanGraphEdgeKind = "field" | "guard" | "calls" | "fetches" | "uses";
|
|
14
|
+
|
|
15
|
+
export type AkanGraphChange = "added" | "removed" | "changed";
|
|
16
|
+
|
|
17
|
+
export interface AkanGraphLocation {
|
|
18
|
+
file: string;
|
|
19
|
+
line: number;
|
|
20
|
+
column: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AkanGraphField {
|
|
24
|
+
name: string;
|
|
25
|
+
type: string | null;
|
|
26
|
+
array: boolean;
|
|
27
|
+
optional: boolean;
|
|
28
|
+
ref: string | null;
|
|
29
|
+
declaredIn: string;
|
|
30
|
+
at: AkanGraphLocation;
|
|
31
|
+
digest?: string;
|
|
32
|
+
change?: AkanGraphChange;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AkanGraphNode {
|
|
36
|
+
id: string;
|
|
37
|
+
kind: AkanGraphNodeKind;
|
|
38
|
+
name: string;
|
|
39
|
+
label: string;
|
|
40
|
+
owner: string | null;
|
|
41
|
+
module: string | null;
|
|
42
|
+
detail: string[];
|
|
43
|
+
at: AkanGraphLocation | null;
|
|
44
|
+
fields?: AkanGraphField[];
|
|
45
|
+
values?: string[];
|
|
46
|
+
digest?: string;
|
|
47
|
+
change?: AkanGraphChange;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AkanGraphEdge {
|
|
51
|
+
from: string;
|
|
52
|
+
to: string;
|
|
53
|
+
kind: AkanGraphEdgeKind;
|
|
54
|
+
label: string | null;
|
|
55
|
+
count: number;
|
|
56
|
+
at: AkanGraphLocation | null;
|
|
57
|
+
change?: Exclude<AkanGraphChange, "changed">;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AkanGraph {
|
|
61
|
+
version: 1;
|
|
62
|
+
nodes: AkanGraphNode[];
|
|
63
|
+
edges: AkanGraphEdge[];
|
|
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,3 +1,15 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AkanChangedFile,
|
|
3
|
+
AkanChangeMap,
|
|
4
|
+
AkanGraph,
|
|
5
|
+
AkanGraphChange,
|
|
6
|
+
AkanGraphEdge,
|
|
7
|
+
AkanGraphEdgeKind,
|
|
8
|
+
AkanGraphField,
|
|
9
|
+
AkanGraphLocation,
|
|
10
|
+
AkanGraphNode,
|
|
11
|
+
AkanGraphNodeKind,
|
|
12
|
+
} from "./akanGraph";
|
|
1
13
|
export {
|
|
2
14
|
databaseStateNames,
|
|
3
15
|
endpointNames,
|
|
@@ -90,6 +102,7 @@ export {
|
|
|
90
102
|
resolveFileUploadCapability,
|
|
91
103
|
} from "./fileUpload";
|
|
92
104
|
export { formatNumber } from "./formatNumber";
|
|
105
|
+
export { hasErrKey } from "./hasErrKey";
|
|
93
106
|
export { type AkanHmrPhase, getAkanHmrPhase, isAkanHmrApplying } from "./hmrPhase";
|
|
94
107
|
export { IpLiteral } from "./IpLiteral";
|
|
95
108
|
export { interpolateTranslation } from "./interpolateTranslation";
|
|
@@ -261,5 +274,6 @@ export {
|
|
|
261
274
|
type WebsocketHeartbeatRequest,
|
|
262
275
|
websocketAuthContract,
|
|
263
276
|
websocketBinaryFrameContract,
|
|
277
|
+
websocketCredentialContract,
|
|
264
278
|
websocketHeartbeatContract,
|
|
265
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
|
@@ -9,7 +9,7 @@ import { getApiPrefix, getWsPrefix, normalizeRoutePrefix, resetEnvCache } from "
|
|
|
9
9
|
import { CrossSiteGuard } from "../signal/CrossSiteGuard";
|
|
10
10
|
import { isTraceEnabled } from "../signal/trace";
|
|
11
11
|
import { AKAN_CHILD_HOST, makeAkanChildProxyHeaders } from "./akanAppHeaders";
|
|
12
|
-
import type {
|
|
12
|
+
import type { BuilderMessage, BuilderRequest, BuilderResponse } from "./artifact";
|
|
13
13
|
import { type AssetHeaderOptions, applySecurityHeaders, assetHeaders } from "./assetHeaders";
|
|
14
14
|
import { compressResponse, compressStreamResponse, encodedFileResponse } from "./contentEncoding";
|
|
15
15
|
import { isPortInUseError } from "./lifecycle/portInUse";
|
|
@@ -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++;
|
|
@@ -1150,6 +1171,7 @@ export class AkanApp {
|
|
|
1150
1171
|
return;
|
|
1151
1172
|
case "build-route":
|
|
1152
1173
|
case "build-csr":
|
|
1174
|
+
case "build-graph":
|
|
1153
1175
|
this.#forwardBuilderReq(idx, message);
|
|
1154
1176
|
return;
|
|
1155
1177
|
}
|
|
@@ -1169,6 +1191,7 @@ export class AkanApp {
|
|
|
1169
1191
|
return;
|
|
1170
1192
|
case "build-route-res":
|
|
1171
1193
|
case "build-csr-res":
|
|
1194
|
+
case "build-graph-res":
|
|
1172
1195
|
this.#forwardBuilderRes(message);
|
|
1173
1196
|
return;
|
|
1174
1197
|
}
|
|
@@ -1354,13 +1377,13 @@ export class AkanApp {
|
|
|
1354
1377
|
}
|
|
1355
1378
|
}
|
|
1356
1379
|
|
|
1357
|
-
#forwardBuilderReq(childIdx: number, message:
|
|
1380
|
+
#forwardBuilderReq(childIdx: number, message: BuilderRequest) {
|
|
1358
1381
|
const gatewayReqId = this.#nextBuilderReqId++;
|
|
1359
1382
|
this.#builderReqMap.set(gatewayReqId, { childIdx, childLocalId: message.id });
|
|
1360
|
-
process.send?.({ ...message, id: gatewayReqId } satisfies
|
|
1383
|
+
process.send?.({ ...message, id: gatewayReqId } satisfies BuilderRequest);
|
|
1361
1384
|
}
|
|
1362
1385
|
|
|
1363
|
-
#forwardBuilderRes(message:
|
|
1386
|
+
#forwardBuilderRes(message: BuilderResponse) {
|
|
1364
1387
|
const request = this.#builderReqMap.get(message.id);
|
|
1365
1388
|
if (!request) {
|
|
1366
1389
|
this.logger.warn(`No child found for ${message.type} id=${message.id}`);
|
|
@@ -1369,7 +1392,7 @@ export class AkanApp {
|
|
|
1369
1392
|
this.#builderReqMap.delete(message.id);
|
|
1370
1393
|
const child = this.#children.get(request.childIdx);
|
|
1371
1394
|
if (!child || child.proc.killed) return;
|
|
1372
|
-
this.#sendToChild(child, { ...message, id: request.childLocalId } satisfies
|
|
1395
|
+
this.#sendToChild(child, { ...message, id: request.childLocalId } satisfies BuilderResponse);
|
|
1373
1396
|
}
|
|
1374
1397
|
|
|
1375
1398
|
#fanoutToFederation(message: AkanIpcMessage | BuilderMessage, exceptIdx?: number) {
|