akanjs 3.1.1-rc.4 → 3.1.1-rc.6
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/client/csrLocation.ts +32 -4
- package/client/index.ts +1 -0
- package/client/navigationGuards.ts +72 -0
- package/client/router.ts +131 -30
- package/common/index.ts +1 -0
- package/dictionary/agent.dictionary.ts +1 -0
- package/document/by.ts +4 -3
- package/document/into.ts +2 -2
- package/fetch/client/fetchClient.ts +91 -31
- package/package.json +1 -1
- package/server/akanApp.ts +2 -0
- package/server/akanOption.ts +13 -0
- package/server/akanServer.ts +3 -0
- package/server/contentEncoding.ts +44 -1
- package/server/decorators.ts +1 -1
- package/server/di/diLifecycle.ts +24 -0
- package/server/index.ts +1 -0
- package/server/nativeSecret.ts +65 -0
- package/server/resolver/database.resolver.ts +4 -3
- package/server/resolver/signal.resolver.ts +11 -2
- package/server/routing/apiRouter.ts +2 -0
- package/server/routing/wsUpgrade.ts +10 -0
- package/server/rscClient.tsx +5 -4
- package/server/types.tsx +1 -1
- package/service/injectInfo.ts +8 -0
- package/service/ipcTypes.ts +3 -0
- package/service/predefinedAdaptor/sql/AfterCommitHooks.ts +13 -0
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +10 -9
- package/service/predefinedAdaptor/sql/types.ts +2 -1
- package/service/serve.ts +22 -1
- package/service/types.ts +2 -0
- package/signal/agent.signal.ts +15 -9
- package/signal/agentRelayPolicy.ts +26 -0
- package/signal/index.ts +1 -0
- package/signal/types.ts +1 -1
- package/store/agent/StoreSurfaceSource.ts +12 -1
- package/types/client/csrLocation.d.ts +14 -1
- package/types/client/index.d.ts +1 -0
- package/types/client/navigationGuards.d.ts +20 -0
- package/types/client/router.d.ts +9 -2
- package/types/common/index.d.ts +1 -0
- package/types/dictionary/agent.dictionary.d.ts +1 -1
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +9 -9
- package/types/document/by.d.ts +4 -3
- package/types/document/into.d.ts +2 -2
- package/types/fetch/client/fetchClient.d.ts +10 -0
- package/types/server/akanOption.d.ts +7 -1
- package/types/server/decorators.d.ts +1 -1
- package/types/server/index.d.ts +1 -0
- package/types/server/nativeSecret.d.ts +10 -0
- package/types/server/routing/wsUpgrade.d.ts +4 -0
- package/types/server/types.d.ts +1 -1
- package/types/service/injectInfo.d.ts +1 -0
- package/types/service/ipcTypes.d.ts +13 -0
- package/types/service/predefinedAdaptor/sql/AfterCommitHooks.d.ts +5 -0
- package/types/service/predefinedAdaptor/sql/types.d.ts +2 -1
- package/types/service/serve.d.ts +3 -1
- package/types/service/types.d.ts +1 -1
- package/types/signal/agent.signal.d.ts +1 -1
- package/types/signal/agentRelayPolicy.d.ts +13 -0
- package/types/signal/index.d.ts +1 -0
- package/types/signal/types.d.ts +1 -1
- package/types/vendor/@akanjs/native/packages/desktop/src/secretStore.d.ts +27 -0
- package/types/vendor/@akanjs/native/packages/desktop/src/server.d.ts +3 -0
- package/types/vendor/@akanjs/native/plugins/secure-storage/src/desktop.d.ts +1 -5
- package/vendor/@akanjs/native/packages/desktop/src/host.ts +10 -1
- package/vendor/@akanjs/native/packages/desktop/src/secretStore.ts +313 -0
- package/vendor/@akanjs/native/packages/desktop/src/server.ts +40 -0
- package/vendor/@akanjs/native/plugins/secure-storage/src/desktop.ts +11 -281
- package/webkit/useCsrValues.ts +12 -5
package/client/csrLocation.ts
CHANGED
|
@@ -4,10 +4,11 @@ declare global {
|
|
|
4
4
|
var __AKAN_EMBED__: { kind: "mcp-app"; origin?: string; account?: Record<string, unknown> | null } | undefined;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
/** The document's location and history, as the
|
|
7
|
+
/** The document's location and history, as the routers read and move them. */
|
|
8
8
|
export class CsrLocation {
|
|
9
9
|
|
|
10
10
|
#view: URL | null = null;
|
|
11
|
+
#entry: { idx: number | undefined; href: string } | null = null;
|
|
11
12
|
|
|
12
13
|
/** Reads `href`, a path on the app's origin, as this document's location from now on; a view never moves. */
|
|
13
14
|
view(href: string) {
|
|
@@ -41,13 +42,40 @@ export class CsrLocation {
|
|
|
41
42
|
return origin !== "null" && href.startsWith(origin) ? href.slice(origin.length) : href;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The entry on screen as of the last write or traversal, and its index; during a `popstate` it is still the entry
|
|
47
|
+
* left, which `state` no longer reads.
|
|
48
|
+
*/
|
|
49
|
+
entry() {
|
|
50
|
+
return this.#entry ?? this.observe();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
observe() {
|
|
54
|
+
this.#entry = { idx: CsrLocation.idxOf(window.history?.state), href: this.href };
|
|
55
|
+
return this.#entry;
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
push(state: unknown, href: string) {
|
|
45
|
-
window.history.pushState(state, "", href);
|
|
59
|
+
window.history.pushState(CsrLocation.#indexed(state, (this.entry().idx ?? 0) + 1), "", href);
|
|
60
|
+
this.observe();
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
replace(state: unknown, href?: string) {
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
const indexed = CsrLocation.#indexed(state, this.entry().idx ?? 0);
|
|
65
|
+
if (href === undefined) window.history.replaceState(indexed, "");
|
|
66
|
+
else window.history.replaceState(indexed, "", href);
|
|
67
|
+
this.observe();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static idxOf(state: unknown) {
|
|
71
|
+
const idx = (state as { __akanRouter?: { idx?: unknown } } | null)?.__akanRouter?.idx;
|
|
72
|
+
return Number.isInteger(idx) ? (idx as number) : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static #indexed(state: unknown, idx: number) {
|
|
76
|
+
if (CsrLocation.idxOf(state) !== undefined) return state;
|
|
77
|
+
const record = (state ?? {}) as { __akanRouter?: object };
|
|
78
|
+
return { ...record, __akanRouter: { ...record.__akanRouter, idx } };
|
|
51
79
|
}
|
|
52
80
|
|
|
53
81
|
back() {
|
package/client/index.ts
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Logger } from "akanjs/common";
|
|
2
|
+
|
|
3
|
+
export type NavigationKind = "push" | "replace" | "back" | "pop";
|
|
4
|
+
|
|
5
|
+
export interface Navigation {
|
|
6
|
+
/** `pop` is the browser's back or forward; `back` is `router.back()`, a native back button or a swipe. */
|
|
7
|
+
kind: NavigationKind;
|
|
8
|
+
/** The route on screen, without its locale and basePath, as `/docs/1?tab=a`. */
|
|
9
|
+
from: string;
|
|
10
|
+
/** Where it goes, written the same way; `null` for `back`, whose entry is not known until it arrives. */
|
|
11
|
+
to: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Answers `false` to keep the page; a guard that throws or rejects keeps it too. */
|
|
15
|
+
export type NavigationGuard = (navigation: Navigation) => boolean | Promise<boolean>;
|
|
16
|
+
|
|
17
|
+
export class NavigationRefused extends Error {
|
|
18
|
+
constructor(navigation: Navigation) {
|
|
19
|
+
super(`A navigation guard kept ${navigation.from} instead of moving to ${navigation.to ?? "the previous page"}`);
|
|
20
|
+
this.name = "NavigationRefused";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class NavigationGuards {
|
|
25
|
+
#guards = new Set<NavigationGuard>();
|
|
26
|
+
#isDeciding: boolean = false;
|
|
27
|
+
|
|
28
|
+
get size() {
|
|
29
|
+
return this.#guards.size;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
add(guard: NavigationGuard) {
|
|
33
|
+
this.#guards.add(guard);
|
|
34
|
+
return () => {
|
|
35
|
+
this.#guards.delete(guard);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
decide(navigation: Navigation): boolean | Promise<boolean> {
|
|
40
|
+
if (this.#isDeciding) return false;
|
|
41
|
+
const guards = [...this.#guards];
|
|
42
|
+
for (const [idx, guard] of guards.entries()) {
|
|
43
|
+
const answer = NavigationGuards.#ask(guard, navigation);
|
|
44
|
+
if (answer === false) return false;
|
|
45
|
+
if (answer === true) continue;
|
|
46
|
+
this.#isDeciding = true;
|
|
47
|
+
return NavigationGuards.#decideRest(answer, guards.slice(idx + 1), navigation).finally(() => {
|
|
48
|
+
this.#isDeciding = false;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static async #decideRest(first: Promise<boolean>, rest: NavigationGuard[], navigation: Navigation) {
|
|
55
|
+
if (!(await first)) return false;
|
|
56
|
+
for (const guard of rest) if (!(await NavigationGuards.#ask(guard, navigation))) return false;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static #ask(guard: NavigationGuard, navigation: Navigation): boolean | Promise<boolean> {
|
|
61
|
+
const refuse = (error: unknown) => {
|
|
62
|
+
Logger.error(`A navigation guard failed, so ${navigation.from} was kept: ${String(error)}`);
|
|
63
|
+
return false;
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
const answer = guard(navigation);
|
|
67
|
+
return answer instanceof Promise ? answer.then((value) => value !== false, refuse) : answer !== false;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return refuse(error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
package/client/router.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { getEnv } from "akanjs/base";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
getBasePathFromPathname,
|
|
4
|
+
Logger,
|
|
5
|
+
originFromRequest,
|
|
6
|
+
parseAkanI18nEnv,
|
|
7
|
+
parseBasePaths,
|
|
8
|
+
trimSlashes,
|
|
9
|
+
} from "akanjs/common";
|
|
10
|
+
import { CsrLocation, csrLocation } from "./csrLocation";
|
|
4
11
|
import { Href } from "./href";
|
|
5
12
|
import { isMcpView } from "./mcpView";
|
|
13
|
+
import { type Navigation, type NavigationGuard, NavigationGuards, NavigationRefused } from "./navigationGuards";
|
|
6
14
|
|
|
7
15
|
export interface RouteOptions {
|
|
8
16
|
scrollToTop?: boolean;
|
|
@@ -91,13 +99,8 @@ function getServerRequestContext() {
|
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
const getServerPublicOrigin = () => {
|
|
94
|
-
const
|
|
95
|
-
|
|
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}`;
|
|
102
|
+
const request = getServerRequestContext().getRequest();
|
|
103
|
+
return request ? originFromRequest(request.headers, new URL(request.url)) : undefined;
|
|
101
104
|
};
|
|
102
105
|
|
|
103
106
|
const getConfiguredBasePaths = () => new Set(parseBasePaths(process.env.AKAN_PUBLIC_BASE_PATHS));
|
|
@@ -193,6 +196,9 @@ class Router {
|
|
|
193
196
|
#backsAwaitingPopstate = 0;
|
|
194
197
|
#redirects = 0;
|
|
195
198
|
#navigations = 0;
|
|
199
|
+
#guards = new NavigationGuards();
|
|
200
|
+
|
|
201
|
+
#expectedPops: { idx: number; isAllowed: boolean; onLand?: () => void }[] = [];
|
|
196
202
|
|
|
197
203
|
#beforeInit: (() => void)[] = [];
|
|
198
204
|
#instance: RouterInstance = {
|
|
@@ -317,14 +323,62 @@ class Router {
|
|
|
317
323
|
csrLocation.replace({ ...state, __akanRouter: { ...akanState, idx: this.#historyIdx } });
|
|
318
324
|
if (this.#listensToPopstate || !globalThis.window.addEventListener) return;
|
|
319
325
|
this.#listensToPopstate = true;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
+
|
|
327
|
+
globalThis.window.addEventListener("popstate", (event) => this.#onPopstate(event), true);
|
|
328
|
+
globalThis.window.addEventListener("hashchange", () => csrLocation.observe());
|
|
329
|
+
}
|
|
330
|
+
#onPopstate(event: PopStateEvent) {
|
|
331
|
+
const left = csrLocation.entry();
|
|
332
|
+
const landed = CsrLocation.idxOf(event.state);
|
|
333
|
+
const expected = this.#expectedPops.shift();
|
|
334
|
+
if (expected && expected.idx === landed) {
|
|
335
|
+
expected.onLand?.();
|
|
336
|
+
if (expected.isAllowed) this.#landOn(landed);
|
|
337
|
+
else {
|
|
338
|
+
event.stopImmediatePropagation();
|
|
339
|
+
csrLocation.observe();
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (expected) this.#expectedPops.length = 0;
|
|
344
|
+
if (left.idx === undefined || landed === undefined || landed === left.idx || !this.#asks(null)) {
|
|
345
|
+
this.#landOn(landed);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const leftIdx = left.idx;
|
|
349
|
+
const navigation: Navigation = {
|
|
350
|
+
kind: "pop",
|
|
351
|
+
from: this.#routeShown(left.href),
|
|
352
|
+
to: this.#routeShown(csrLocation.href),
|
|
353
|
+
};
|
|
354
|
+
const verdict = this.#guards.decide(navigation);
|
|
355
|
+
if (verdict === true) {
|
|
356
|
+
this.#landOn(landed);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
event.stopImmediatePropagation();
|
|
360
|
+
const reverted = this.#traverse(landed, leftIdx, false);
|
|
361
|
+
if (verdict === false) return;
|
|
362
|
+
|
|
363
|
+
void Promise.all([verdict, reverted]).then(([isAllowed]) => {
|
|
364
|
+
if (isAllowed) void this.#traverse(leftIdx, landed, true);
|
|
326
365
|
});
|
|
327
366
|
}
|
|
367
|
+
#traverse(from: number, to: number, isAllowed: boolean) {
|
|
368
|
+
return new Promise<void>((onLand) => {
|
|
369
|
+
this.#expectedPops.push({ idx: to, isAllowed, onLand });
|
|
370
|
+
globalThis.window.history.go(to - from);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
#landOn(idx: number | undefined) {
|
|
374
|
+
if (idx !== undefined) this.#historyIdx = idx;
|
|
375
|
+
csrLocation.observe();
|
|
376
|
+
|
|
377
|
+
if (idx === undefined) setTimeout(() => csrLocation.observe(), 0);
|
|
378
|
+
if (this.#backsAwaitingPopstate === 0) return;
|
|
379
|
+
this.#backsAwaitingPopstate -= 1;
|
|
380
|
+
this.postPathChange(this.#getPathInfo(csrLocation.href));
|
|
381
|
+
}
|
|
328
382
|
|
|
329
383
|
#rememberHistoryState(kind: "push" | "replace") {
|
|
330
384
|
if (getEnv().side === "server" || this.#historyWrittenByNavigation) return;
|
|
@@ -424,9 +478,16 @@ class Router {
|
|
|
424
478
|
const stack = this.resolveDeepLinkStack(href);
|
|
425
479
|
if (stack.length === 0) return false;
|
|
426
480
|
const { resetStack: _resetStack, ...routeOptions } = options;
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
481
|
+
|
|
482
|
+
this.#guarded("replace", normalizeDeepLinkHref(href), () => {
|
|
483
|
+
const moved = this.#move("replace", stack[0], routeOptions);
|
|
484
|
+
stack.slice(1).forEach((target, index) => {
|
|
485
|
+
setTimeout(
|
|
486
|
+
() => this.#track(this.#move("push", target, routeOptions)),
|
|
487
|
+
DEEP_LINK_STACK_STEP_DELAY * (index + 1),
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
return moved;
|
|
430
491
|
});
|
|
431
492
|
return true;
|
|
432
493
|
}
|
|
@@ -434,24 +495,60 @@ class Router {
|
|
|
434
495
|
if (this.#deferredUntilInit(() => this.push(href, routeOptions))) return undefined as never;
|
|
435
496
|
this.#checkInitialized();
|
|
436
497
|
if (this.#refusesOffsite("push", href, routeOptions)) return undefined as never;
|
|
437
|
-
this.#
|
|
438
|
-
this.#track(this.#instance.push(href, routeOptions));
|
|
439
|
-
this.#rememberHistoryState("push");
|
|
440
|
-
this.#postDevSyncNavigation("push", href);
|
|
498
|
+
this.#guarded("push", href, () => this.#move("push", href, routeOptions));
|
|
441
499
|
return undefined as never;
|
|
442
500
|
}
|
|
443
501
|
replace(href: string, routeOptions?: RouteOptions) {
|
|
444
502
|
if (this.#deferredUntilInit(() => this.replace(href, routeOptions))) return undefined as never;
|
|
445
503
|
this.#checkInitialized();
|
|
446
504
|
if (this.#refusesOffsite("replace", href, routeOptions)) return undefined as never;
|
|
447
|
-
this.#
|
|
448
|
-
this.#track(this.#instance.replace(href, routeOptions));
|
|
449
|
-
this.#rememberHistoryState("replace");
|
|
450
|
-
this.#postDevSyncNavigation("replace", href);
|
|
505
|
+
this.#guarded("replace", href, () => this.#move("replace", href, routeOptions));
|
|
451
506
|
return undefined as never;
|
|
452
507
|
}
|
|
453
|
-
|
|
454
|
-
|
|
508
|
+
#move(kind: "push" | "replace", href: string, routeOptions?: RouteOptions) {
|
|
509
|
+
this.#navigations += 1;
|
|
510
|
+
const moved = this.#instance[kind](href, routeOptions);
|
|
511
|
+
this.#rememberHistoryState(kind);
|
|
512
|
+
this.#postDevSyncNavigation(kind, href);
|
|
513
|
+
return moved;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Asks `guard` before every in-app move — `push`, `replace`, `back`, a `Link`, the browser's back and forward, the
|
|
517
|
+
* agent's navigate — and keeps the page when it answers `false`; returns the call that removes it. A redirect, a
|
|
518
|
+
* not-found, a locale switch and a move off the origin are not asked.
|
|
519
|
+
*/
|
|
520
|
+
addNavigationGuard(guard: NavigationGuard) {
|
|
521
|
+
return this.#guards.add(guard);
|
|
522
|
+
}
|
|
523
|
+
#guarded(kind: "push" | "replace" | "back", href: string | null, move: () => void | Promise<void>) {
|
|
524
|
+
if (!this.#asks(href)) return this.#track(move());
|
|
525
|
+
const navigation: Navigation = {
|
|
526
|
+
kind,
|
|
527
|
+
from: this.#routeShown(csrLocation.href),
|
|
528
|
+
to: href === null ? null : Router.#routeHref(this.#getPathInfo(href)),
|
|
529
|
+
};
|
|
530
|
+
const verdict = this.#guards.decide(navigation);
|
|
531
|
+
if (verdict === true) return this.#track(move());
|
|
532
|
+
this.#track(
|
|
533
|
+
Promise.resolve(verdict).then((isAllowed) => {
|
|
534
|
+
if (!isAllowed) throw new NavigationRefused(navigation);
|
|
535
|
+
return move();
|
|
536
|
+
}),
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
#asks(href: string | null) {
|
|
541
|
+
if (this.#guards.size === 0 || globalThis.__AKAN_DEV_SYNC_NAVIGATION_APPLYING__) return false;
|
|
542
|
+
return href === null || !this.#leavesOrigin(href);
|
|
543
|
+
}
|
|
544
|
+
#routeShown(href: string) {
|
|
545
|
+
return Router.#routeHref(this.#getVisiblePathInfo(href));
|
|
546
|
+
}
|
|
547
|
+
static #routeHref({ path, search, hash }: { path: string; search: string; hash: string }) {
|
|
548
|
+
return `${path}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`;
|
|
549
|
+
}
|
|
550
|
+
/** The last `push`, `replace` or `back`; rejects when a navigation guard or the route refused to move. A method, not
|
|
551
|
+
* a getter: through the `router` Proxy a getter would run with the Proxy as `this` and fail on the private field. */
|
|
455
552
|
navigation(): Promise<void> {
|
|
456
553
|
return this.#navigation;
|
|
457
554
|
}
|
|
@@ -495,8 +592,12 @@ class Router {
|
|
|
495
592
|
back(routeOptions?: RouteOptions) {
|
|
496
593
|
if (getEnv().side === "server") throw new Error("back is only available in client side");
|
|
497
594
|
this.#checkInitialized();
|
|
498
|
-
this.#
|
|
499
|
-
|
|
595
|
+
this.#guarded("back", null, () => {
|
|
596
|
+
this.#navigations += 1;
|
|
597
|
+
const idx = this.#guards.size ? csrLocation.entry().idx : undefined;
|
|
598
|
+
if (idx !== undefined) this.#expectedPops.push({ idx: idx - 1, isAllowed: true });
|
|
599
|
+
this.#instance.back(routeOptions);
|
|
600
|
+
});
|
|
500
601
|
return undefined as never;
|
|
501
602
|
}
|
|
502
603
|
refresh() {
|
package/common/index.ts
CHANGED
|
@@ -82,6 +82,7 @@ export {
|
|
|
82
82
|
} from "./fileUpload";
|
|
83
83
|
export { formatNumber } from "./formatNumber";
|
|
84
84
|
export { type AkanHmrPhase, getAkanHmrPhase, isAkanHmrApplying } from "./hmrPhase";
|
|
85
|
+
export { IpLiteral } from "./IpLiteral";
|
|
85
86
|
export { interpolateTranslation } from "./interpolateTranslation";
|
|
86
87
|
export { isDayjs } from "./isDayjs";
|
|
87
88
|
export { isEmail } from "./isEmail";
|
|
@@ -38,6 +38,7 @@ export const agentDictionary = serviceDictionary(["en", "ko"])
|
|
|
38
38
|
"The agent's usage limit for this account has been reached",
|
|
39
39
|
"이 계정의 에이전트 사용 한도에 도달했습니다.",
|
|
40
40
|
],
|
|
41
|
+
requestRefused: ["This app's agent does not take this request", "이 앱의 에이전트는 이 요청을 받지 않습니다."],
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
export const agentTurnDictionary = scalarDictionary(["en", "ko"])
|
package/document/by.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Cls, FIELD_META, type GetActionObject } from "akanjs/base";
|
|
2
2
|
import { applyMixins } from "akanjs/common";
|
|
3
3
|
import { ConstantRegistry, type DocumentConstantModelRef, type DocumentModel, type FieldObject } from "akanjs/constant";
|
|
4
|
+
import type { DocumentTouchOptions } from "./documentQuery";
|
|
4
5
|
|
|
5
6
|
export type DatabaseCls<Schema = unknown> = Cls<Schema, { refName: string; [FIELD_META]: FieldObject }>;
|
|
6
7
|
export type DatabaseInstanceOf<DatabaseRef extends DatabaseCls> = DatabaseRef extends new (
|
|
@@ -14,7 +15,7 @@ export interface DefaultDocMtds<TDocument> {
|
|
|
14
15
|
refresh(): Promise<this>;
|
|
15
16
|
isModified(field?: keyof TDocument & string): boolean;
|
|
16
17
|
set(data: Partial<TDocument>): this;
|
|
17
|
-
save(): Promise<this>;
|
|
18
|
+
save(options?: DocumentTouchOptions): Promise<this>;
|
|
18
19
|
toJSON(): DocumentModel<TDocument>;
|
|
19
20
|
toObject(): DocumentModel<TDocument>;
|
|
20
21
|
}
|
|
@@ -25,7 +26,7 @@ type HydratedDocumentMethods<TDocument, Self> = Omit<
|
|
|
25
26
|
refresh(): Promise<Self>;
|
|
26
27
|
isModified(field?: keyof TDocument & string): boolean;
|
|
27
28
|
set(data: Partial<TDocument>): Self;
|
|
28
|
-
save(): Promise<Self>;
|
|
29
|
+
save(options?: DocumentTouchOptions): Promise<Self>;
|
|
29
30
|
};
|
|
30
31
|
type HydratedDocumentWithId<TDocument> = TDocument & { id: string } & HydratedDocumentMethods<TDocument, TDocument>;
|
|
31
32
|
interface ChainableHydratedMethods<TDocument>
|
|
@@ -33,7 +34,7 @@ interface ChainableHydratedMethods<TDocument>
|
|
|
33
34
|
refresh(): Promise<this>;
|
|
34
35
|
isModified(field?: keyof TDocument & string): boolean;
|
|
35
36
|
set(data: Partial<TDocument>): this;
|
|
36
|
-
save(): Promise<this>;
|
|
37
|
+
save(options?: DocumentTouchOptions): Promise<this>;
|
|
37
38
|
}
|
|
38
39
|
type ChainableHydratedDocument<TDocument> = TDocument & {
|
|
39
40
|
id: string;
|
package/document/into.ts
CHANGED
|
@@ -25,8 +25,8 @@ interface DefaultMdlStats<
|
|
|
25
25
|
_FilterQuery extends DocumentQuery<TSchema> = DocumentQuery<TSchema>,
|
|
26
26
|
_Projection = DocumentProjection<TSchema>,
|
|
27
27
|
> {
|
|
28
|
-
pickOneAndWrite: (query: _FilterQuery, rawData: _Partial) => Promise<TDocument>;
|
|
29
|
-
pickAndWrite: (docId: string, rawData: _Partial) => Promise<TDocument>;
|
|
28
|
+
pickOneAndWrite: (query: _FilterQuery, rawData: _Partial, options?: DocumentTouchOptions) => Promise<TDocument>;
|
|
29
|
+
pickAndWrite: (docId: string, rawData: _Partial, options?: DocumentTouchOptions) => Promise<TDocument>;
|
|
30
30
|
pickOne: (query: _FilterQuery, projection?: _Projection) => Promise<TDocument>;
|
|
31
31
|
pickById: (docId: string | undefined, projection?: _Projection) => Promise<TDocument>;
|
|
32
32
|
sample: (query: _FilterQuery, size?: number) => Promise<TDocument[]>;
|
|
@@ -84,6 +84,12 @@ export interface FetchBridge {
|
|
|
84
84
|
call(request: FetchBridgeCall): Promise<unknown>;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
export interface FetchClaim {
|
|
88
|
+
key: string;
|
|
89
|
+
/** What in its module claims the key, phrased for a boot error: `the mutation "removeX"`, `the root slice`. */
|
|
90
|
+
by: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
87
93
|
const FETCH_BRIDGE_KEY = Symbol.for("akanjs.fetch.bridge");
|
|
88
94
|
const globalWithFetchBridge = globalThis as typeof globalThis & { [FETCH_BRIDGE_KEY]?: FetchBridge | null };
|
|
89
95
|
|
|
@@ -112,6 +118,8 @@ export class FetchClient {
|
|
|
112
118
|
#wsOptions: WsClientOptions = {};
|
|
113
119
|
readonly #handlerStore: Record<string, FetchHandler> = {};
|
|
114
120
|
readonly #handlerFactory = new Map<string, FetchHandlerFactory>();
|
|
121
|
+
/** The refName whose slice generated each handler, so its cleanup never takes another module's same-named endpoint. */
|
|
122
|
+
readonly #generatedBy = new Map<string, string>();
|
|
115
123
|
#sharedRegistryAppliedVersion = 0;
|
|
116
124
|
#clientIp: string | null = null;
|
|
117
125
|
/** Cloned toward another server: the request being served is this server's visitor, not that server's caller. */
|
|
@@ -320,9 +328,11 @@ export class FetchClient {
|
|
|
320
328
|
if (!handler) throw new Error(`${owner} requires fetch handler "${key}", but it is not registered`);
|
|
321
329
|
return handler as T;
|
|
322
330
|
}
|
|
323
|
-
#setHandlerFactory(key: string, factory: FetchHandlerFactory) {
|
|
331
|
+
#setHandlerFactory(key: string, factory: FetchHandlerFactory, generatedBy?: string) {
|
|
324
332
|
this.#handlerFactory.set(key, factory);
|
|
325
333
|
delete this.#handlerStore[key];
|
|
334
|
+
if (generatedBy) this.#generatedBy.set(key, generatedBy);
|
|
335
|
+
else this.#generatedBy.delete(key);
|
|
326
336
|
}
|
|
327
337
|
|
|
328
338
|
connect() {
|
|
@@ -715,11 +725,14 @@ export class FetchClient {
|
|
|
715
725
|
const guards = verb === "get" ? signal.getGuards : (signal[`${verb}Guards`] ?? signal.cruGuards);
|
|
716
726
|
return guards?.some((guard) => guard !== "None") ? guards : undefined;
|
|
717
727
|
}
|
|
718
|
-
#
|
|
728
|
+
static #modelNames(refName: string) {
|
|
719
729
|
const capRefName = capitalize(refName);
|
|
720
|
-
|
|
730
|
+
return {
|
|
731
|
+
createModel: `create${capRefName}`,
|
|
721
732
|
updateModel: `update${capRefName}`,
|
|
733
|
+
removeModel: `remove${capRefName}`,
|
|
722
734
|
model: refName,
|
|
735
|
+
lightModel: `light${capRefName}`,
|
|
723
736
|
viewModel: `view${capRefName}`,
|
|
724
737
|
getModelView: `get${capRefName}View`,
|
|
725
738
|
editModel: `edit${capRefName}`,
|
|
@@ -727,34 +740,90 @@ export class FetchClient {
|
|
|
727
740
|
addModelFiles: `add${capRefName}Files`,
|
|
728
741
|
mergeModel: `merge${capRefName}`,
|
|
729
742
|
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
static #helpersOf(signal: SerializedSignal) {
|
|
746
|
+
const get = !!FetchClient.#mountedGuards(signal, "get");
|
|
747
|
+
const update = !!FetchClient.#mountedGuards(signal, "update");
|
|
748
|
+
const write =
|
|
749
|
+
update || !!FetchClient.#mountedGuards(signal, "create") || !!FetchClient.#mountedGuards(signal, "remove");
|
|
750
|
+
return { view: get, edit: get && write, merge: update };
|
|
751
|
+
}
|
|
752
|
+
static #sliceNames(refName: string, suffix: string) {
|
|
753
|
+
const capSuffix = capitalize(suffix);
|
|
754
|
+
const capRefName = capitalize(refName);
|
|
755
|
+
return {
|
|
756
|
+
list: `${refName}List${capSuffix}`,
|
|
757
|
+
insight: `${refName}Insight${capSuffix}`,
|
|
758
|
+
live: `${refName}Live${capSuffix}`,
|
|
759
|
+
init: `init${capRefName}${capSuffix}`,
|
|
760
|
+
getInit: `get${capRefName}Init${capSuffix}`,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
/** The `fetch.*` names a module's signal registers, and the bare socket route of each pubsub and message. */
|
|
764
|
+
static claimsOf(refName: string, signal: SerializedSignal) {
|
|
765
|
+
const handlers: FetchClaim[] = [];
|
|
766
|
+
const sockets: FetchClaim[] = [];
|
|
767
|
+
const claim = (key: string, endpoint: SerializedEndpoint, by: string) => {
|
|
768
|
+
handlers.push({ key: endpoint.type === "pubsub" ? `subscribe${capitalize(key)}` : key, by });
|
|
769
|
+
if (endpoint.type === "message") handlers.push({ key: `listen${capitalize(key)}`, by });
|
|
770
|
+
if (endpoint.type === "pubsub" || endpoint.type === "message") sockets.push({ key, by });
|
|
771
|
+
};
|
|
772
|
+
for (const [key, endpoint] of Object.entries(signal.endpoint))
|
|
773
|
+
claim(key, endpoint, `the ${endpoint.type} "${key}"`);
|
|
774
|
+
if (!signal.slice) return { handlers, sockets };
|
|
775
|
+
const generated = "the generated CRUD";
|
|
776
|
+
for (const [key, endpoint] of Object.entries(FetchClient.getBaseEndpoint(refName, signal)))
|
|
777
|
+
claim(key, endpoint, generated);
|
|
778
|
+
const names = FetchClient.#modelNames(refName);
|
|
779
|
+
const helpers = FetchClient.#helpersOf(signal);
|
|
780
|
+
const helperKeys = [
|
|
781
|
+
...(helpers.view ? [names.viewModel, names.getModelView] : []),
|
|
782
|
+
...(helpers.edit ? [names.editModel, names.getModelEdit] : []),
|
|
783
|
+
...(helpers.merge ? [names.mergeModel] : []),
|
|
784
|
+
names.addModelFiles,
|
|
785
|
+
];
|
|
786
|
+
for (const key of helperKeys) handlers.push({ key, by: generated });
|
|
787
|
+
for (const [suffix, slice] of Object.entries(signal.slice)) {
|
|
788
|
+
const by = suffix ? `the slice "${suffix}"` : "the root slice";
|
|
789
|
+
for (const [key, endpoint] of Object.entries(FetchClient.getEndpointFromSlice(refName, suffix, slice)))
|
|
790
|
+
claim(key, endpoint, by);
|
|
791
|
+
const { init, getInit } = FetchClient.#sliceNames(refName, suffix);
|
|
792
|
+
handlers.push({ key: init, by }, { key: getInit, by });
|
|
793
|
+
}
|
|
794
|
+
return { handlers, sockets };
|
|
795
|
+
}
|
|
796
|
+
#registerModelBaseEndpoint(refName: string, signal: SerializedSignal) {
|
|
797
|
+
const names = FetchClient.#modelNames(refName);
|
|
730
798
|
|
|
731
|
-
const
|
|
732
|
-
for (const key of generated.filter((key) => key !== names.addModelFiles && !(key in signal.endpoint))) {
|
|
799
|
+
for (const key of Object.values(names).filter((key) => this.#generatedBy.get(key) === refName)) {
|
|
733
800
|
this.#handlerFactory.delete(key);
|
|
801
|
+
this.#generatedBy.delete(key);
|
|
734
802
|
delete this.#handlerStore[key];
|
|
735
803
|
}
|
|
736
804
|
const endpoint = FetchClient.getBaseEndpoint(refName, signal);
|
|
737
805
|
Object.entries(endpoint).forEach(([key, value]) => {
|
|
738
|
-
this.#setHandlerFactory(key, () => this.#makeHttpFn(key, value, signal.prefix));
|
|
806
|
+
this.#setHandlerFactory(key, () => this.#makeHttpFn(key, value, signal.prefix), refName);
|
|
739
807
|
});
|
|
740
808
|
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
this.#makeModelHandleFn(refName, names.model, names.viewModel, `${refName}View`),
|
|
809
|
+
const helpers = FetchClient.#helpersOf(signal);
|
|
810
|
+
if (helpers.view) {
|
|
811
|
+
this.#setHandlerFactory(
|
|
812
|
+
names.viewModel,
|
|
813
|
+
() => this.#makeModelHandleFn(refName, names.model, names.viewModel, `${refName}View`),
|
|
814
|
+
refName,
|
|
748
815
|
);
|
|
749
|
-
this.#setHandlerFactory(names.getModelView, () => this.#makeModelObjFn(refName, names.getModelView));
|
|
816
|
+
this.#setHandlerFactory(names.getModelView, () => this.#makeModelObjFn(refName, names.getModelView), refName);
|
|
750
817
|
}
|
|
751
|
-
if (
|
|
752
|
-
this.#setHandlerFactory(
|
|
753
|
-
|
|
818
|
+
if (helpers.edit) {
|
|
819
|
+
this.#setHandlerFactory(
|
|
820
|
+
names.editModel,
|
|
821
|
+
() => this.#makeModelHandleFn(refName, names.model, names.editModel, `${refName}Edit`),
|
|
822
|
+
refName,
|
|
754
823
|
);
|
|
755
|
-
this.#setHandlerFactory(names.getModelEdit, () => this.#makeModelObjFn(refName, names.getModelEdit));
|
|
824
|
+
this.#setHandlerFactory(names.getModelEdit, () => this.#makeModelObjFn(refName, names.getModelEdit), refName);
|
|
756
825
|
}
|
|
757
|
-
if (
|
|
826
|
+
if (helpers.merge) {
|
|
758
827
|
this.#setHandlerFactory(
|
|
759
828
|
names.mergeModel,
|
|
760
829
|
() =>
|
|
@@ -762,6 +831,7 @@ export class FetchClient {
|
|
|
762
831
|
const id = typeof modelOrId === "string" ? modelOrId : modelOrId.id;
|
|
763
832
|
return this.#requireHandler(names.updateModel, names.mergeModel)(id, data, option);
|
|
764
833
|
}) as FetchHandler,
|
|
834
|
+
refName,
|
|
765
835
|
);
|
|
766
836
|
}
|
|
767
837
|
|
|
@@ -832,12 +902,7 @@ export class FetchClient {
|
|
|
832
902
|
suffix: string,
|
|
833
903
|
slice: SerializedSlice,
|
|
834
904
|
): { [key: string]: SerializedEndpoint } {
|
|
835
|
-
const
|
|
836
|
-
const names = {
|
|
837
|
-
list: `${refName}List${capSuffix}`,
|
|
838
|
-
insight: `${refName}Insight${capSuffix}`,
|
|
839
|
-
live: `${refName}Live${capSuffix}`,
|
|
840
|
-
};
|
|
905
|
+
const names = FetchClient.#sliceNames(refName, suffix);
|
|
841
906
|
|
|
842
907
|
const mcp = {
|
|
843
908
|
...(slice.mcp === false ? { mcp: false as const } : {}),
|
|
@@ -874,12 +939,7 @@ export class FetchClient {
|
|
|
874
939
|
const capSuffix = capitalize(suffix);
|
|
875
940
|
const sliceName = `${refName}${capSuffix}`;
|
|
876
941
|
const capRefName = capitalize(refName);
|
|
877
|
-
const names =
|
|
878
|
-
list: `${refName}List${capSuffix}`,
|
|
879
|
-
insight: `${refName}Insight${capSuffix}`,
|
|
880
|
-
init: `init${capRefName}${capSuffix}`,
|
|
881
|
-
getInit: `get${capRefName}Init${capSuffix}`,
|
|
882
|
-
};
|
|
942
|
+
const names = FetchClient.#sliceNames(refName, suffix);
|
|
883
943
|
|
|
884
944
|
const endpoint = FetchClient.getEndpointFromSlice(refName, suffix, slice);
|
|
885
945
|
|
package/package.json
CHANGED
package/server/akanApp.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { OpsRoute } from "./ops/opsRoute";
|
|
|
24
24
|
import { StatusAccess } from "./ops/statusAccess";
|
|
25
25
|
import { ProcessMetricsCollector } from "./processMetricsCollector";
|
|
26
26
|
import { HostAllowlist } from "./routing/hostAllowlist";
|
|
27
|
+
import { WsUpgrade } from "./routing/wsUpgrade";
|
|
27
28
|
import { SelfExec } from "./selfExec";
|
|
28
29
|
import { resolveStaticPath } from "./staticPath";
|
|
29
30
|
import { getWebConfigFromEnv } from "./types";
|
|
@@ -670,6 +671,7 @@ export class AkanApp {
|
|
|
670
671
|
} catch {
|
|
671
672
|
return new Response("Forbidden", { status: 403 });
|
|
672
673
|
}
|
|
674
|
+
if (!WsUpgrade.isRequested(req)) return WsUpgrade.required();
|
|
673
675
|
const upstreamWs = new WebSocket(`ws://${upstream.host}:${upstream.port}${url.pathname}${url.search}`, {
|
|
674
676
|
headers: makeAkanChildProxyHeaders(req, child.idx, server.requestIP(req)),
|
|
675
677
|
} as unknown as string[]);
|