akanjs 3.1.0-rc.26 → 3.1.0-rc.27
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 +1 -1
- package/README.md +1 -1
- package/client/href.ts +8 -0
- package/client/router.ts +40 -2
- package/common/Logger.ts +6 -4
- package/fetch/client/httpClient.ts +38 -10
- package/package.json +2 -2
- package/server/akanApp.ts +2 -2
- package/server/akanServer.ts +10 -5
- package/server/mcp/McpDispatcher.ts +2 -1
- package/server/resolver/signal.resolver.ts +41 -31
- package/server/routeState.ts +1 -0
- package/server/rscClient.tsx +47 -22
- package/server/rscClientFetch.ts +8 -3
- package/server/rscNavigationState.ts +2 -2
- package/server/rscScrollRestoration.ts +21 -8
- package/server/rscWorker.tsx +34 -9
- package/server/webRouter.ts +2 -0
- package/service/predefinedAdaptor/database.adaptor.ts +27 -1
- package/service/predefinedAdaptor/solidQueue.adaptor.ts +30 -9
- package/service/predefinedAdaptor/sql/QueryCompiler.ts +13 -7
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +65 -31
- package/service/predefinedAdaptor/sql/dialect/postgres.ts +4 -2
- package/service/predefinedAdaptor/sql/dialect/sqlite.ts +5 -1
- package/service/predefinedAdaptor/sql/types.ts +11 -1
- package/signal/TaggedJson.ts +26 -4
- package/signal/signalContext.ts +0 -8
- package/store/action.ts +82 -34
- package/store/liveInsert.ts +5 -1
- package/store/sliceKeys.ts +1 -0
- package/store/state.ts +6 -2
- package/store/storeInstance.ts +12 -3
- package/types/client/href.d.ts +3 -0
- package/types/client/router.d.ts +6 -1
- package/types/server/akanServer.d.ts +10 -5
- package/types/server/routeState.d.ts +1 -0
- package/types/server/rscClientFetch.d.ts +2 -0
- package/types/server/rscNavigationState.d.ts +1 -1
- package/types/server/rscScrollRestoration.d.ts +2 -0
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +2 -1
- package/types/service/predefinedAdaptor/solidQueue.adaptor.d.ts +1 -0
- package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +1 -0
- package/types/service/predefinedAdaptor/sql/types.d.ts +11 -1
- package/types/signal/signalContext.d.ts +0 -2
- package/types/store/liveInsert.d.ts +3 -1
- package/types/store/sliceKeys.d.ts +1 -0
- package/types/store/state.d.ts +3 -3
- package/types/ui/Load/sliceClaim.d.ts +28 -0
- package/types/ui/Model/Edit.d.ts +3 -2
- package/types/ui/sliceNamesOf.d.ts +2 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/compat.d.ts +6 -0
- package/types/webkit/CsrStack.d.ts +3 -2
- package/ui/Dialog/LegacyModal.tsx +7 -5
- package/ui/Dialog/Modal.tsx +7 -4
- package/ui/Field/Collection.tsx +2 -1
- package/ui/Load/Units.tsx +18 -6
- package/ui/Load/sliceClaim.ts +164 -0
- package/ui/Model/Edit.tsx +22 -3
- package/ui/Model/EditModal.tsx +5 -1
- package/ui/Model/LoadStore.tsx +1 -1
- package/ui/Model/NewWrapper_Client.tsx +1 -1
- package/ui/System/Client.tsx +3 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/compat.ts +6 -2
- package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +3 -3
- package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +45 -15
- package/webkit/CsrStack.ts +21 -3
- package/webkit/McpAppAgent.ts +4 -2
- package/webkit/McpAppFetchBridge.ts +2 -1
- package/webkit/useCsrValues.ts +11 -0
- package/webkit/useEscapeKey.tsx +1 -1
package/README.ko.md
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
```text
|
|
23
23
|
Akan.js(https://akanjs.com) 워크스페이스를 새로 만들어 줘.
|
|
24
24
|
|
|
25
|
-
1. Bun 1.4 이상이 설치돼 있는지 확인해 줘(`bun --version`). 없거나 버전이 낮으면 https://bun.sh 안내대로 설치하거나 업그레이드해 줘.
|
|
25
|
+
1. Bun 1.4.4 이상이 설치돼 있는지 확인해 줘(`bun --version`). 없거나 버전이 낮으면 https://bun.sh 안내대로 설치하거나 업그레이드해 줘.
|
|
26
26
|
2. 워크스페이스 이름과 첫 앱 이름을 물어봐 줘. 짧은 영문 소문자로, 아래에 적어 뒀다면 그걸 쓰면 돼.
|
|
27
27
|
3. 이 디렉터리에서 `bunx create-akan-workspace@latest <워크스페이스> --app <앱>`을 실행해 줘. akan CLI를 전역으로 설치하고, ./<워크스페이스>를 만들어 의존성까지 설치해.
|
|
28
28
|
4. ./<워크스페이스> 안에서 `akan start <앱>`으로 개발 서버를 백그라운드에 띄우고, 출력된 주소(기본 http://localhost:8282)가 열리는지 확인해 줘.
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ or let the agent ask.
|
|
|
22
22
|
```text
|
|
23
23
|
Set up a new Akan.js workspace for me (https://akanjs.com).
|
|
24
24
|
|
|
25
|
-
1. Check that Bun 1.4 or newer is installed (`bun --version`). If it isn't, install or upgrade it as https://bun.sh describes.
|
|
25
|
+
1. Check that Bun 1.4.4 or newer is installed (`bun --version`). If it isn't, install or upgrade it as https://bun.sh describes.
|
|
26
26
|
2. Ask me for a workspace name and a first app name, short and lowercase, unless I wrote them below.
|
|
27
27
|
3. In this directory, run `bunx create-akan-workspace@latest <workspace> --app <app>`. It installs the akan CLI globally, creates ./<workspace> and installs its dependencies.
|
|
28
28
|
4. Inside ./<workspace>, start the dev server in the background with `akan start <app>` and check that the URL it prints (http://localhost:8282 by default) loads.
|
package/client/href.ts
CHANGED
|
@@ -25,6 +25,14 @@ export class Href {
|
|
|
25
25
|
return /^https?:/i.test(href);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Whether an http(s) href resolves, against `base`, to another origin than `base`'s. Any other href stays a path in
|
|
29
|
+
* the app; no base, as outside a request, leaves for anywhere. */
|
|
30
|
+
static leavesOrigin(href: string, base: string | undefined): boolean {
|
|
31
|
+
if (!Href.hasWebScheme(href)) return false;
|
|
32
|
+
const origin = base === undefined ? undefined : Href.#parse(base)?.origin;
|
|
33
|
+
return !origin || Href.#parse(href, base)?.origin !== origin;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
/** Canonical absolute URL for an external href: follows the browser on an http(s) document base; on a native
|
|
29
37
|
* `app://` base an absolute URL resolves as written and a network path resolves against https:. */
|
|
30
38
|
static toExternalUrl(href: string, base: string): string {
|
package/client/router.ts
CHANGED
|
@@ -12,6 +12,9 @@ export interface RouteOptions {
|
|
|
12
12
|
* navigation.
|
|
13
13
|
*/
|
|
14
14
|
shallow?: boolean;
|
|
15
|
+
/** Lets an absolute URL on another origin through; without it the router refuses one, so a `?redirect=` handed on
|
|
16
|
+
* unchecked cannot send a visitor off-site. */
|
|
17
|
+
external?: boolean;
|
|
15
18
|
}
|
|
16
19
|
export interface RouteManifest {
|
|
17
20
|
routes: string[];
|
|
@@ -55,6 +58,8 @@ export type RedirectStatus = 303 | 307 | 308;
|
|
|
55
58
|
export interface RedirectOptions {
|
|
56
59
|
method?: RedirectMethod;
|
|
57
60
|
status?: RedirectStatus;
|
|
61
|
+
/** As `RouteOptions.external`; a target it refuses redirects to the index path instead. */
|
|
62
|
+
external?: boolean;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
export class AkanRedirectError extends Error {
|
|
@@ -85,6 +90,16 @@ function getServerRequestContext() {
|
|
|
85
90
|
};
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
const getServerPublicOrigin = () => {
|
|
94
|
+
const { getRequest, headers } = getServerRequestContext();
|
|
95
|
+
const h = headers();
|
|
96
|
+
const url = getRequest()?.url;
|
|
97
|
+
const host = h.get("x-forwarded-host")?.split(",")[0]?.trim() ?? h.get("host");
|
|
98
|
+
const proto = h.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
|
99
|
+
if (!host) return url;
|
|
100
|
+
return `${proto ?? (url ? new URL(url).protocol.replace(":", "") : "https")}://${host}`;
|
|
101
|
+
};
|
|
102
|
+
|
|
88
103
|
const getConfiguredBasePaths = () => new Set(parseBasePaths(process.env.AKAN_PUBLIC_BASE_PATHS));
|
|
89
104
|
|
|
90
105
|
const shouldExposeBasePath = () => getEnv().operationMode === "local";
|
|
@@ -306,7 +321,7 @@ class Router {
|
|
|
306
321
|
}
|
|
307
322
|
#withCsrRuntimeSearchParams(href: string) {
|
|
308
323
|
const currentSearch = new URLSearchParams(csrLocation.search);
|
|
309
|
-
if (currentSearch.get("csr") !== "true") return href;
|
|
324
|
+
if (currentSearch.get("csr") !== "true" || Href.hasWebScheme(href)) return href;
|
|
310
325
|
|
|
311
326
|
const [hrefWithoutHash, hash = ""] = href.split("#");
|
|
312
327
|
const [pathname, search = ""] = hrefWithoutHash.split("?");
|
|
@@ -391,6 +406,7 @@ class Router {
|
|
|
391
406
|
}
|
|
392
407
|
push(href: string, routeOptions?: RouteOptions) {
|
|
393
408
|
this.#checkInitialized();
|
|
409
|
+
if (this.#refusesOffsite("push", href, routeOptions)) return undefined as never;
|
|
394
410
|
this.#navigations += 1;
|
|
395
411
|
this.#track(this.#instance.push(href, routeOptions));
|
|
396
412
|
this.#rememberHistoryState("push");
|
|
@@ -399,6 +415,7 @@ class Router {
|
|
|
399
415
|
}
|
|
400
416
|
replace(href: string, routeOptions?: RouteOptions) {
|
|
401
417
|
this.#checkInitialized();
|
|
418
|
+
if (this.#refusesOffsite("replace", href, routeOptions)) return undefined as never;
|
|
402
419
|
this.#navigations += 1;
|
|
403
420
|
this.#track(this.#instance.replace(href, routeOptions));
|
|
404
421
|
this.#rememberHistoryState("replace");
|
|
@@ -410,6 +427,20 @@ class Router {
|
|
|
410
427
|
navigation(): Promise<void> {
|
|
411
428
|
return this.#navigation;
|
|
412
429
|
}
|
|
430
|
+
#leavesOrigin(href: string, external?: boolean) {
|
|
431
|
+
if (external || !Href.hasWebScheme(href)) return false;
|
|
432
|
+
return Href.leavesOrigin(
|
|
433
|
+
href,
|
|
434
|
+
getEnv().side === "server" ? getServerPublicOrigin() : globalThis.window?.location?.href,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
#refusesOffsite(method: "push" | "replace", href: string, routeOptions?: RouteOptions) {
|
|
438
|
+
if (!this.#leavesOrigin(href, routeOptions?.external)) return false;
|
|
439
|
+
const message = `router.${method}(${href}) leaves this origin and was refused; pass { external: true } to leave on purpose`;
|
|
440
|
+
Logger.warn(message);
|
|
441
|
+
this.#track(Promise.reject(new Error(message)));
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
413
444
|
#track(result: void | Promise<void>) {
|
|
414
445
|
this.#navigation = Promise.resolve(result);
|
|
415
446
|
|
|
@@ -456,9 +487,16 @@ class Router {
|
|
|
456
487
|
navigationCount(): number {
|
|
457
488
|
return this.#navigations;
|
|
458
489
|
}
|
|
459
|
-
redirect(
|
|
490
|
+
redirect(target: string, options: RedirectOptions = {}): never {
|
|
460
491
|
const method = options.method ?? "replace";
|
|
461
492
|
const status = options.status ?? 307;
|
|
493
|
+
|
|
494
|
+
const offsite = this.#leavesOrigin(target, options.external);
|
|
495
|
+
if (offsite)
|
|
496
|
+
Logger.warn(
|
|
497
|
+
`router.redirect(${target}) leaves this origin, so it redirects to ${this.#indexPath} instead; pass { external: true } to leave on purpose`,
|
|
498
|
+
);
|
|
499
|
+
const href = offsite ? this.#indexPath : target;
|
|
462
500
|
if (getEnv().side === "server") {
|
|
463
501
|
const { getRequest, headers: requestHeaders } = getServerRequestContext();
|
|
464
502
|
const h = requestHeaders();
|
package/common/Logger.ts
CHANGED
|
@@ -89,7 +89,9 @@ const consoleMethodMap = {
|
|
|
89
89
|
const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
|
|
90
90
|
|
|
91
91
|
const secretKey =
|
|
92
|
-
/password|passwd|passphrase|^pass$|pwd|token|jwt|authorization|cookie|secret|api[-_]?key|private[-_]?key|access[-_]?key|auth[-_]?key|hash[-_]?key|(?:signing|encryption|master|session)[-_]?key|webhook|
|
|
92
|
+
/password|passwd|passphrase|^pass$|pwd|token|jwt|authorization|cookie|secret|api[-_]?key|private[-_]?key|access[-_]?key|auth[-_]?key|hash[-_]?key|(?:signing|encryption|master|session)[-_]?key|webhook|credential|dsn$|connection[-_]?string|iap[-_]?verify|service[-_]?account/i;
|
|
93
|
+
|
|
94
|
+
const credWord = /[cC]reds?(?![a-z])|CREDS?(?![A-Z])/;
|
|
93
95
|
|
|
94
96
|
const envValue = (key: string): string | undefined => (typeof process === "undefined" ? undefined : process.env?.[key]);
|
|
95
97
|
|
|
@@ -174,19 +176,19 @@ export class Logger {
|
|
|
174
176
|
static inlineData(value: unknown, max = 500): string {
|
|
175
177
|
let text: string;
|
|
176
178
|
try {
|
|
177
|
-
text = JSON.stringify(value, (key, val) => (key &&
|
|
179
|
+
text = JSON.stringify(value, (key, val) => (key && Logger.isSecretKey(key) ? "[redacted]" : val)) ?? "";
|
|
178
180
|
} catch {
|
|
179
181
|
text = "[unserializable]";
|
|
180
182
|
}
|
|
181
183
|
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
182
184
|
}
|
|
183
185
|
static isSecretKey(name: string) {
|
|
184
|
-
return secretKey.test(name);
|
|
186
|
+
return secretKey.test(name) || credWord.test(name);
|
|
185
187
|
}
|
|
186
188
|
static redactAttrs(attrs: LogAttrs): LogAttrs {
|
|
187
189
|
let redacted: LogAttrs | null = null;
|
|
188
190
|
for (const key of Object.keys(attrs)) {
|
|
189
|
-
if (!
|
|
191
|
+
if (!Logger.isSecretKey(key)) continue;
|
|
190
192
|
redacted ??= { ...attrs };
|
|
191
193
|
redacted[key] = "[redacted]";
|
|
192
194
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { Backoff, isRecord, type RetryPolicy } from "akanjs/common";
|
|
1
|
+
import { Backoff, isRecord, Logger, type RetryPolicy } from "akanjs/common";
|
|
2
2
|
import type { HttpMutationMethod, SerializedArg } from "akanjs/signal";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type ErrorConstructor,
|
|
5
|
+
type ErrorResponsePayload,
|
|
6
|
+
type RestoredError,
|
|
7
|
+
restoreRemoteError,
|
|
8
|
+
} from "./remoteError";
|
|
4
9
|
|
|
5
10
|
export interface HttpClientOptions {
|
|
6
11
|
headers?: Record<string, string>;
|
|
@@ -42,6 +47,7 @@ const transportDetailLimit = 200;
|
|
|
42
47
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
43
48
|
|
|
44
49
|
export class HttpClient {
|
|
50
|
+
static readonly #logger = new Logger("HttpClient");
|
|
45
51
|
readonly baseUrl: string;
|
|
46
52
|
#headers: Record<string, string>;
|
|
47
53
|
#timeout?: number | false;
|
|
@@ -166,11 +172,11 @@ export class HttpClient {
|
|
|
166
172
|
|
|
167
173
|
async #requestOnce<Returns>(url: string, init: RequestInit, deadline: number): Promise<Returns> {
|
|
168
174
|
const res = await this.#fetch(url, this.#withDeadline(init, deadline));
|
|
169
|
-
const body = await this.#readBody(res);
|
|
175
|
+
const body = await this.#readBody(url, res);
|
|
170
176
|
if (res.ok) return body as Returns;
|
|
171
177
|
|
|
172
178
|
if (res.status in transportErrorKeyMap && !HttpClient.#namesErrorKey(body))
|
|
173
|
-
throw this.#transportError(res, JSON.stringify(body));
|
|
179
|
+
throw this.#transportError(url, res, JSON.stringify(body));
|
|
174
180
|
throw this.#restoreError(body, res.status);
|
|
175
181
|
}
|
|
176
182
|
|
|
@@ -197,21 +203,21 @@ export class HttpClient {
|
|
|
197
203
|
if (error instanceof Error && error.name === "TimeoutError")
|
|
198
204
|
throw this.#restoreError({ error: transportErrorKeyMap[408], data: { status: 408 } }, 408);
|
|
199
205
|
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
200
|
-
throw this.#
|
|
206
|
+
throw this.#transportFailure(url, { error: serverUnreachableKey, details: String(error) }, 503);
|
|
201
207
|
}
|
|
202
208
|
}
|
|
203
209
|
|
|
204
|
-
async #readBody(res: Response) {
|
|
210
|
+
async #readBody(url: string, res: Response) {
|
|
205
211
|
if (jsonContentType.test(res.headers.get("content-type") ?? "")) {
|
|
206
212
|
try {
|
|
207
213
|
return (await res.json()) as unknown;
|
|
208
214
|
} catch (error) {
|
|
209
|
-
throw this.#transportError(res, String(error));
|
|
215
|
+
throw this.#transportError(url, res, String(error));
|
|
210
216
|
}
|
|
211
217
|
}
|
|
212
218
|
const raw = await res.text();
|
|
213
219
|
const parsed = HttpClient.#parseJson(raw);
|
|
214
|
-
if (parsed === undefined) throw this.#transportError(res, raw);
|
|
220
|
+
if (parsed === undefined) throw this.#transportError(url, res, raw);
|
|
215
221
|
return parsed;
|
|
216
222
|
}
|
|
217
223
|
|
|
@@ -223,11 +229,33 @@ export class HttpClient {
|
|
|
223
229
|
}
|
|
224
230
|
}
|
|
225
231
|
|
|
226
|
-
#transportError(res: Response, detail: string): RestoredError {
|
|
232
|
+
#transportError(url: string, res: Response, detail: string): RestoredError {
|
|
227
233
|
const { status } = res;
|
|
228
234
|
const error = transportErrorKeyMap[status as keyof typeof transportErrorKeyMap] ?? unexpectedResponseKey;
|
|
229
235
|
const data = status === 429 ? { status, seconds: HttpClient.#retryAfterSeconds(res) } : { status };
|
|
230
|
-
return this.#
|
|
236
|
+
return this.#transportFailure(url, { error, data, details: detail.slice(0, transportDetailLimit) }, status);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#transportFailure(url: string, body: ErrorResponsePayload, status: number): RestoredError {
|
|
240
|
+
if (typeof window === "undefined")
|
|
241
|
+
HttpClient.#logger.warn(`${body.error} from ${HttpClient.#originAndPath(url)}: ${JSON.stringify(body.details)}`);
|
|
242
|
+
const error = this.#restoreError(body, status);
|
|
243
|
+
const toJSON = error.toJSON?.bind(error);
|
|
244
|
+
if (toJSON)
|
|
245
|
+
error.toJSON = () => {
|
|
246
|
+
const { details: _local, ...wire } = toJSON();
|
|
247
|
+
return wire;
|
|
248
|
+
};
|
|
249
|
+
return error;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
static #originAndPath(url: string) {
|
|
253
|
+
try {
|
|
254
|
+
const { origin, pathname } = new URL(url);
|
|
255
|
+
return `${origin}${pathname}`;
|
|
256
|
+
} catch {
|
|
257
|
+
return url.split("?")[0];
|
|
258
|
+
}
|
|
231
259
|
}
|
|
232
260
|
|
|
233
261
|
static #retryAfterSeconds(res: Response): number {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akanjs",
|
|
3
|
-
"version": "3.1.0-rc.
|
|
3
|
+
"version": "3.1.0-rc.27",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -262,7 +262,7 @@
|
|
|
262
262
|
"react-dom": "19.3.1",
|
|
263
263
|
"react-icons": "^5.7.0",
|
|
264
264
|
"react-refresh": "^0.19.0",
|
|
265
|
-
"react-server-dom-webpack": "
|
|
265
|
+
"react-server-dom-webpack": "19.3.1",
|
|
266
266
|
"react-spring": "^10.0.4",
|
|
267
267
|
"scheduler": "^0.28.0",
|
|
268
268
|
"tailwind-scrollbar": "^4.0.2",
|
package/server/akanApp.ts
CHANGED
|
@@ -109,7 +109,7 @@ export class AkanApp {
|
|
|
109
109
|
readonly #devHosted = process.env.AKAN_COMMAND_TYPE === "start";
|
|
110
110
|
readonly #healthTimeoutMs = AkanApp.#parseHealthTimeoutMs();
|
|
111
111
|
readonly #upstreamWaitMs = AkanApp.#parseUpstreamWaitMs();
|
|
112
|
-
/** Child stderr is bundler/runtime noise
|
|
112
|
+
/** Child stderr is mostly bundler/runtime noise, kept to the log file; with none it may be a crash's only trace. */
|
|
113
113
|
readonly #printChildStderr = process.env.AKAN_CHILD_STDERR === "1";
|
|
114
114
|
readonly #serverPath: string;
|
|
115
115
|
readonly #artifactDir: string;
|
|
@@ -1337,7 +1337,7 @@ export class AkanApp {
|
|
|
1337
1337
|
return;
|
|
1338
1338
|
}
|
|
1339
1339
|
const prefixedLine = `[child:${idx} ${role}] [${type}] ${line}`;
|
|
1340
|
-
if (type === "stdout" || this.#printChildStderr) process[type].write(prefixedLine);
|
|
1340
|
+
if (type === "stdout" || this.#printChildStderr || !this.#logWriter) process[type].write(prefixedLine);
|
|
1341
1341
|
this.#logWriter?.write(`${idx}-${role}`, AkanApp.#stripAnsi(prefixedLine));
|
|
1342
1342
|
}
|
|
1343
1343
|
|
package/server/akanServer.ts
CHANGED
|
@@ -66,7 +66,10 @@ export interface AkanServerProps extends AkanLibProps {
|
|
|
66
66
|
|
|
67
67
|
export interface AkanServerOptions {
|
|
68
68
|
openapi?: boolean;
|
|
69
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* `/mcp` is mounted by default, on an edge server only when asked for here or by `AKAN_MCP=true`; `false` takes it
|
|
71
|
+
* off, and the object form carries the rest of its settings.
|
|
72
|
+
*/
|
|
70
73
|
mcp?: boolean | McpServerOption;
|
|
71
74
|
/** Boot only these modules and what they reach; omitted or empty mounts every enabled module. */
|
|
72
75
|
modules?: string[];
|
|
@@ -107,13 +110,15 @@ export interface McpServerOption {
|
|
|
107
110
|
/** Characters a page prompt may attach before its lists are cut; 60,000 by default. `AKAN_MCP_PROMPT_BUDGET`. */
|
|
108
111
|
promptBudget?: number;
|
|
109
112
|
/**
|
|
110
|
-
* MCP Apps:
|
|
111
|
-
*
|
|
113
|
+
* MCP Apps: each `.app()` is a read-only tool whose result a host speaking the extension draws with that app's own
|
|
114
|
+
* view, and a `.prompt()` page a text tool; an endpoint kept off the shelf by `mcp: false` stays callable by a view.
|
|
115
|
+
* `false` publishes no page tools. `true` by default. `AKAN_MCP_APPS`.
|
|
112
116
|
*/
|
|
113
117
|
apps?: boolean;
|
|
114
118
|
/**
|
|
115
|
-
* Speak the modern `2026-07-28` revision (`server/discover`) beside the legacy ones. On unless
|
|
116
|
-
* since claude.ai renders a view only for a server it reached through `initialize`.
|
|
119
|
+
* Speak the modern `2026-07-28` revision (`server/discover`) beside the legacy ones. On unless the build serves views
|
|
120
|
+
* and a page declares `.app()`, since claude.ai renders a view only for a server it reached through `initialize`.
|
|
121
|
+
* `AKAN_MCP_MODERN`.
|
|
117
122
|
*/
|
|
118
123
|
modern?: boolean;
|
|
119
124
|
}
|
|
@@ -72,7 +72,8 @@ export class McpDispatcher {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
static #restorable(error: unknown, status: number | undefined) {
|
|
75
|
-
if (!status || status >= 500
|
|
75
|
+
if (!status || status >= 500) return null;
|
|
76
|
+
if (status === 401 || status === 403) return { statusCode: status };
|
|
76
77
|
return isExceptionLike(error) ? error.toJSON() : null;
|
|
77
78
|
}
|
|
78
79
|
|
|
@@ -448,6 +448,8 @@ export class SignalResolver {
|
|
|
448
448
|
._addInternalArgs(sliceInfo.internalArgs)
|
|
449
449
|
.exec(async function (this: any, ...requestArgs: any) {
|
|
450
450
|
const args = requestArgs.slice(0, argLength);
|
|
451
|
+
|
|
452
|
+
SignalResolver.#assertNotPaused(liveKey, sliceInfo, args);
|
|
451
453
|
const internalArgs = requestArgs.slice(argLength);
|
|
452
454
|
return assertSliceQuery(
|
|
453
455
|
await sliceInfo.execFn?.apply(this, [...args, ...internalArgs, documentQueryHelper]),
|
|
@@ -542,15 +544,10 @@ export class SignalResolver {
|
|
|
542
544
|
}
|
|
543
545
|
}
|
|
544
546
|
}
|
|
545
|
-
static #assertNotPaused(
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
context: SignalContext,
|
|
550
|
-
) {
|
|
551
|
-
for (const name of liveOption.pauseOn) {
|
|
552
|
-
const idx = endpointInfo.args.findIndex((arg) => arg.name === name);
|
|
553
|
-
if (idx < 0 || context.args[idx] == null) continue;
|
|
547
|
+
static #assertNotPaused(key: string, sliceInfo: SliceInfo, args: unknown[]) {
|
|
548
|
+
for (const name of sliceInfo.liveOption?.pauseOn ?? []) {
|
|
549
|
+
const idx = sliceInfo.args.findIndex((arg) => arg.name === name);
|
|
550
|
+
if (idx < 0 || args[idx] == null) continue;
|
|
554
551
|
throw new Error(
|
|
555
552
|
`Live room "${key}" is paused while "${name}" carries a value: the slice declared it in ` +
|
|
556
553
|
`.live({ pauseOn }), so this window updates by refetching instead of subscribing.`,
|
|
@@ -696,11 +693,10 @@ export class SignalResolver {
|
|
|
696
693
|
).init();
|
|
697
694
|
const liveOption = endpointInfo.signalOption.live;
|
|
698
695
|
|
|
696
|
+
const query = subscribe ? await context.exec() : null;
|
|
697
|
+
|
|
699
698
|
const requestRoomId = context.getRoomId(key);
|
|
700
699
|
if (subscribe) {
|
|
701
|
-
|
|
702
|
-
if (liveOption) SignalResolver.#assertNotPaused(key, liveOption, endpointInfo, context);
|
|
703
|
-
const query = await context.exec();
|
|
704
700
|
const isClosed = SignalResolver.#closedWs.has(ws);
|
|
705
701
|
|
|
706
702
|
if (isClosed || SignalResolver.#pendingSubscribes.isLeft(ws, requestRoomId, arrival)) {
|
|
@@ -752,19 +748,19 @@ export class SignalResolver {
|
|
|
752
748
|
return ack;
|
|
753
749
|
}
|
|
754
750
|
SignalResolver.#pendingSubscribes.leave(ws, requestRoomId, arrival);
|
|
755
|
-
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
const roomCtx
|
|
760
|
-
|
|
761
|
-
await SignalResolver.#runLifecycleHandlers([roomCtx], ["unsubscribe"]);
|
|
762
|
-
roomCtxMap.delete(roomId);
|
|
763
|
-
if (roomCtxMap.size === 0) SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
|
|
764
|
-
SignalResolver.#leaveRoom(roomId, liveOption ? live : undefined);
|
|
765
|
-
websocket.leaveRoom(ws, roomId);
|
|
751
|
+
|
|
752
|
+
const heldRooms = [...(SignalResolver.#liveWsPubsubRoomCtx.get(ws) ?? [])].filter(
|
|
753
|
+
([, roomCtx]) => roomCtx.getRoomId(roomCtx.key) === requestRoomId,
|
|
754
|
+
);
|
|
755
|
+
for (const [roomId, roomCtx] of heldRooms) {
|
|
756
|
+
SignalResolver.#leaveWsRoom(ws, roomId, roomCtx, websocket, liveOption ? live : undefined);
|
|
766
757
|
SignalResolver.logger.verbose(`WebSocket unsubscribed from room ${roomId}`);
|
|
767
758
|
}
|
|
759
|
+
await SignalResolver.#runLifecycleHandlers(
|
|
760
|
+
heldRooms.map(([, roomCtx]) => roomCtx),
|
|
761
|
+
["unsubscribe"],
|
|
762
|
+
);
|
|
763
|
+
const roomId = heldRooms[0]?.[0] ?? requestRoomId;
|
|
768
764
|
const ack: WebsocketSubscribeAck = { type: "sub", roomId, requestRoomId, subscribe };
|
|
769
765
|
return ack;
|
|
770
766
|
});
|
|
@@ -850,17 +846,30 @@ export class SignalResolver {
|
|
|
850
846
|
const revokedRooms: string[] = [];
|
|
851
847
|
for (const [roomId, roomCtx] of [...roomCtxMap]) {
|
|
852
848
|
if (await roomCtx.authorize()) continue;
|
|
853
|
-
ws
|
|
854
|
-
await SignalResolver.#runLifecycleHandlers([roomCtx], ["unsubscribe"]);
|
|
855
|
-
roomCtxMap.delete(roomId);
|
|
856
|
-
SignalResolver.#leaveRoom(roomId, live);
|
|
857
|
-
websocket.leaveRoom(ws, roomId);
|
|
849
|
+
if (!SignalResolver.#leaveWsRoom(ws, roomId, roomCtx, websocket, live)) continue;
|
|
858
850
|
revokedRooms.push(roomId);
|
|
859
851
|
SignalResolver.logger.verbose(`WebSocket lost access to room ${roomId}; unsubscribed`);
|
|
852
|
+
await SignalResolver.#runLifecycleHandlers([roomCtx], ["unsubscribe"]);
|
|
860
853
|
}
|
|
861
|
-
if (roomCtxMap.size === 0) SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
|
|
862
854
|
return revokedRooms;
|
|
863
855
|
}
|
|
856
|
+
|
|
857
|
+
static #leaveWsRoom(
|
|
858
|
+
ws: Bun.ServerWebSocket<unknown>,
|
|
859
|
+
roomId: string,
|
|
860
|
+
roomCtx: SignalContext<WebSocketExecutionContext>,
|
|
861
|
+
websocket: WebsocketAdaptor,
|
|
862
|
+
live?: LiveRegistry,
|
|
863
|
+
) {
|
|
864
|
+
const roomCtxMap = SignalResolver.#liveWsPubsubRoomCtx.get(ws);
|
|
865
|
+
if (!roomCtxMap || roomCtxMap.get(roomId) !== roomCtx) return false;
|
|
866
|
+
roomCtxMap.delete(roomId);
|
|
867
|
+
if (roomCtxMap.size === 0) SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
|
|
868
|
+
ws.unsubscribe(roomId);
|
|
869
|
+
SignalResolver.#leaveRoom(roomId, live);
|
|
870
|
+
websocket.leaveRoom(ws, roomId);
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
864
873
|
|
|
865
874
|
static async handleWsOpen(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry) {
|
|
866
875
|
await SignalResolver.#getWebsocket(registry).registerSocket(ws);
|
|
@@ -870,11 +879,12 @@ export class SignalResolver {
|
|
|
870
879
|
SignalResolver.#closedWs.add(ws);
|
|
871
880
|
const roomCtxMap = SignalResolver.#liveWsPubsubRoomCtx.get(ws);
|
|
872
881
|
const contexts = [...(roomCtxMap?.values() ?? []), ...(SignalResolver.#liveWsMessageCtx.get(ws) ?? [])];
|
|
873
|
-
await SignalResolver.#runLifecycleHandlers(contexts, ["unsubscribe", "disconnect"]);
|
|
874
882
|
|
|
875
|
-
for (const roomId of roomCtxMap?.keys() ?? []) SignalResolver.#leaveRoom(roomId, live);
|
|
876
883
|
SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
|
|
877
884
|
SignalResolver.#liveWsMessageCtx.delete(ws);
|
|
885
|
+
await SignalResolver.#runLifecycleHandlers(contexts, ["unsubscribe", "disconnect"]);
|
|
886
|
+
|
|
887
|
+
for (const roomId of roomCtxMap?.keys() ?? []) SignalResolver.#leaveRoom(roomId, live);
|
|
878
888
|
|
|
879
889
|
await SignalResolver.#getWebsocket(registry).unregisterSocket(ws);
|
|
880
890
|
SignalResolver.logger.verbose(`WebSocket disconnected from all rooms`);
|
package/server/routeState.ts
CHANGED
|
@@ -5,6 +5,7 @@ export const AKAN_RSC_STATE_VERSION_HEADER = "X-Akan-Rsc-State-Version";
|
|
|
5
5
|
export const AKAN_RSC_CURRENT_ROUTE_HEADER = "X-Akan-Rsc-Current-Route";
|
|
6
6
|
export const AKAN_RSC_CURRENT_STATE_HEADER = "X-Akan-Rsc-Current-State";
|
|
7
7
|
export const AKAN_RSC_RESPONSE_STATE_HEADER = "X-Akan-Rsc-State";
|
|
8
|
+
export const AKAN_RSC_NOT_FOUND_HEADER = "X-Akan-Rsc-Not-Found";
|
|
8
9
|
export const AKAN_RSC_PATCH_START_INDEX_HEADER = "X-Akan-Rsc-Patch-Start-Index";
|
|
9
10
|
export const AKAN_RSC_PATCH_SEGMENT_PATH_HEADER = "X-Akan-Rsc-Patch-Segment-Path";
|
|
10
11
|
export const AKAN_RSC_PATCH_START_SEGMENT_HEADER = "X-Akan-Rsc-Patch-Start-Segment";
|