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.
Files changed (71) hide show
  1. package/client/csrLocation.ts +32 -4
  2. package/client/index.ts +1 -0
  3. package/client/navigationGuards.ts +72 -0
  4. package/client/router.ts +131 -30
  5. package/common/index.ts +1 -0
  6. package/dictionary/agent.dictionary.ts +1 -0
  7. package/document/by.ts +4 -3
  8. package/document/into.ts +2 -2
  9. package/fetch/client/fetchClient.ts +91 -31
  10. package/package.json +1 -1
  11. package/server/akanApp.ts +2 -0
  12. package/server/akanOption.ts +13 -0
  13. package/server/akanServer.ts +3 -0
  14. package/server/contentEncoding.ts +44 -1
  15. package/server/decorators.ts +1 -1
  16. package/server/di/diLifecycle.ts +24 -0
  17. package/server/index.ts +1 -0
  18. package/server/nativeSecret.ts +65 -0
  19. package/server/resolver/database.resolver.ts +4 -3
  20. package/server/resolver/signal.resolver.ts +11 -2
  21. package/server/routing/apiRouter.ts +2 -0
  22. package/server/routing/wsUpgrade.ts +10 -0
  23. package/server/rscClient.tsx +5 -4
  24. package/server/types.tsx +1 -1
  25. package/service/injectInfo.ts +8 -0
  26. package/service/ipcTypes.ts +3 -0
  27. package/service/predefinedAdaptor/sql/AfterCommitHooks.ts +13 -0
  28. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +10 -9
  29. package/service/predefinedAdaptor/sql/types.ts +2 -1
  30. package/service/serve.ts +22 -1
  31. package/service/types.ts +2 -0
  32. package/signal/agent.signal.ts +15 -9
  33. package/signal/agentRelayPolicy.ts +26 -0
  34. package/signal/index.ts +1 -0
  35. package/signal/types.ts +1 -1
  36. package/store/agent/StoreSurfaceSource.ts +12 -1
  37. package/types/client/csrLocation.d.ts +14 -1
  38. package/types/client/index.d.ts +1 -0
  39. package/types/client/navigationGuards.d.ts +20 -0
  40. package/types/client/router.d.ts +9 -2
  41. package/types/common/index.d.ts +1 -0
  42. package/types/dictionary/agent.dictionary.d.ts +1 -1
  43. package/types/dictionary/base.dictionary.d.ts +1 -1
  44. package/types/dictionary/dictionary.d.ts +9 -9
  45. package/types/document/by.d.ts +4 -3
  46. package/types/document/into.d.ts +2 -2
  47. package/types/fetch/client/fetchClient.d.ts +10 -0
  48. package/types/server/akanOption.d.ts +7 -1
  49. package/types/server/decorators.d.ts +1 -1
  50. package/types/server/index.d.ts +1 -0
  51. package/types/server/nativeSecret.d.ts +10 -0
  52. package/types/server/routing/wsUpgrade.d.ts +4 -0
  53. package/types/server/types.d.ts +1 -1
  54. package/types/service/injectInfo.d.ts +1 -0
  55. package/types/service/ipcTypes.d.ts +13 -0
  56. package/types/service/predefinedAdaptor/sql/AfterCommitHooks.d.ts +5 -0
  57. package/types/service/predefinedAdaptor/sql/types.d.ts +2 -1
  58. package/types/service/serve.d.ts +3 -1
  59. package/types/service/types.d.ts +1 -1
  60. package/types/signal/agent.signal.d.ts +1 -1
  61. package/types/signal/agentRelayPolicy.d.ts +13 -0
  62. package/types/signal/index.d.ts +1 -0
  63. package/types/signal/types.d.ts +1 -1
  64. package/types/vendor/@akanjs/native/packages/desktop/src/secretStore.d.ts +27 -0
  65. package/types/vendor/@akanjs/native/packages/desktop/src/server.d.ts +3 -0
  66. package/types/vendor/@akanjs/native/plugins/secure-storage/src/desktop.d.ts +1 -5
  67. package/vendor/@akanjs/native/packages/desktop/src/host.ts +10 -1
  68. package/vendor/@akanjs/native/packages/desktop/src/secretStore.ts +313 -0
  69. package/vendor/@akanjs/native/packages/desktop/src/server.ts +40 -0
  70. package/vendor/@akanjs/native/plugins/secure-storage/src/desktop.ts +11 -281
  71. package/webkit/useCsrValues.ts +12 -5
@@ -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 CSR router reads and moves them. */
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
- if (href === undefined) window.history.replaceState(state, "");
50
- else window.history.replaceState(state, "", href);
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
@@ -12,6 +12,7 @@ export * from "./locale";
12
12
  export * from "./makePageProto";
13
13
  export * from "./mcpView";
14
14
  export * from "./nativeHost";
15
+ export * from "./navigationGuards";
15
16
  export * from "./resolveServerUrl";
16
17
  export * from "./route";
17
18
  export * from "./router";
@@ -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 { getBasePathFromPathname, Logger, parseAkanI18nEnv, parseBasePaths, trimSlashes } from "akanjs/common";
3
- import { csrLocation } from "./csrLocation";
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 { 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}`;
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
- globalThis.window.addEventListener("popstate", (event) => {
321
- const nextState = ((event.state as Record<string, unknown> | null)?.__akanRouter ?? {}) as { idx?: number };
322
- if (Number.isInteger(nextState.idx)) this.#historyIdx = nextState.idx as number;
323
- if (this.#backsAwaitingPopstate === 0) return;
324
- this.#backsAwaitingPopstate -= 1;
325
- this.postPathChange(this.#getPathInfo(csrLocation.href));
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
- this.replace(stack[0], routeOptions);
428
- stack.slice(1).forEach((target, index) => {
429
- setTimeout(() => this.push(target, routeOptions), DEEP_LINK_STACK_STEP_DELAY * (index + 1));
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.#navigations += 1;
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.#navigations += 1;
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
- /** The last `push`/`replace`; rejects when the route refused to move. A method, not a getter: through the `router`
454
- * Proxy a getter would run with the Proxy as `this` and fail on the private field. */
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.#navigations += 1;
499
- this.#instance.back(routeOptions);
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
- #registerModelBaseEndpoint(refName: string, signal: SerializedSignal) {
728
+ static #modelNames(refName: string) {
719
729
  const capRefName = capitalize(refName);
720
- const names = {
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 generated = [`create${capRefName}`, `remove${capRefName}`, `light${capRefName}`, ...Object.values(names)];
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 getGuards = FetchClient.#mountedGuards(signal, "get");
742
- const updateGuards = FetchClient.#mountedGuards(signal, "update");
743
- const anyCruGuards =
744
- FetchClient.#mountedGuards(signal, "create") ?? updateGuards ?? FetchClient.#mountedGuards(signal, "remove");
745
- if (getGuards) {
746
- this.#setHandlerFactory(names.viewModel, () =>
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 (getGuards && anyCruGuards) {
752
- this.#setHandlerFactory(names.editModel, () =>
753
- this.#makeModelHandleFn(refName, names.model, names.editModel, `${refName}Edit`),
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 (updateGuards) {
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 capSuffix = capitalize(suffix);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.1.1-rc.4",
3
+ "version": "3.1.1-rc.6",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
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[]);