akanjs 3.1.0-rc.21 → 3.1.0-rc.22

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 (109) hide show
  1. package/client/cookie.ts +5 -0
  2. package/client/csrLocation.ts +17 -72
  3. package/client/csrTypes.ts +17 -4
  4. package/client/route/PageDefinition.ts +103 -10
  5. package/client/route/RouteDefinition.ts +44 -3
  6. package/client/route/RouteGuards.ts +64 -0
  7. package/client/route/index.ts +1 -0
  8. package/client/route/resolveRouteModule.ts +8 -0
  9. package/client/route/routeArgs.ts +9 -0
  10. package/client/router.ts +11 -0
  11. package/common/authCookie.ts +13 -0
  12. package/common/authToken.ts +1 -12
  13. package/common/index.ts +2 -8
  14. package/common/routeConvention.ts +2 -0
  15. package/constant/inputParser.ts +1 -1
  16. package/dictionary/base.dictionary.ts +8 -0
  17. package/fetch/appScope.ts +54 -0
  18. package/fetch/client/fetchClient.ts +46 -29
  19. package/fetch/index.ts +1 -0
  20. package/package.json +1 -1
  21. package/server/mcp/McpApps.ts +44 -50
  22. package/server/mcp/McpPages.ts +93 -19
  23. package/server/mcp/McpRouter.ts +14 -11
  24. package/server/ops/migrateJobs.ts +145 -0
  25. package/server/ops/opsRoute.ts +39 -7
  26. package/server/ops/opsToken.ts +7 -2
  27. package/server/routeElementComposer.tsx +33 -6
  28. package/server/routeTreeBuilder.ts +5 -0
  29. package/server/rscClient.tsx +20 -0
  30. package/server/rscPagePrompts.ts +66 -16
  31. package/server/rscWorker.tsx +33 -11
  32. package/server/rscWorkerHost.ts +12 -1
  33. package/server/webRouter.ts +31 -27
  34. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +12 -1
  35. package/service/predefinedAdaptor/sql/dialect/postgres.ts +4 -3
  36. package/service/predefinedAdaptor/sql/dialect/sqlite.ts +2 -2
  37. package/service/predefinedAdaptor/sql/types.ts +2 -0
  38. package/service/predefinedAdaptor/sql/values.ts +19 -0
  39. package/signal/mcp/mcpProtocol.ts +7 -0
  40. package/signal/mcp/pagePrompt.ts +18 -1
  41. package/types/client/csrLocation.d.ts +3 -7
  42. package/types/client/csrTypes.d.ts +20 -3
  43. package/types/client/route/PageDefinition.d.ts +21 -4
  44. package/types/client/route/RouteDefinition.d.ts +4 -0
  45. package/types/client/route/RouteGuards.d.ts +15 -0
  46. package/types/client/route/index.d.ts +1 -0
  47. package/types/client/route/resolveRouteModule.d.ts +2 -0
  48. package/types/client/route/routeArgs.d.ts +5 -0
  49. package/types/client/route/routeBuilders.d.ts +1 -1
  50. package/types/client/router.d.ts +3 -0
  51. package/types/common/authCookie.d.ts +5 -0
  52. package/types/common/authToken.d.ts +0 -5
  53. package/types/common/index.d.ts +2 -1
  54. package/types/dictionary/base.dictionary.d.ts +1 -1
  55. package/types/dictionary/dictionary.d.ts +3 -3
  56. package/types/fetch/appScope.d.ts +18 -0
  57. package/types/fetch/client/fetchClient.d.ts +1 -1
  58. package/types/fetch/index.d.ts +1 -0
  59. package/types/server/mcp/McpApps.d.ts +14 -8
  60. package/types/server/mcp/McpPages.d.ts +6 -3
  61. package/types/server/ops/migrateJobs.d.ts +36 -0
  62. package/types/server/ops/opsRoute.d.ts +4 -1
  63. package/types/server/ops/opsToken.d.ts +3 -0
  64. package/types/server/routeElementComposer.d.ts +10 -1
  65. package/types/server/rscPagePrompts.d.ts +3 -1
  66. package/types/server/rscWorkerHost.d.ts +2 -1
  67. package/types/server/webRouter.d.ts +3 -0
  68. package/types/service/predefinedAdaptor/sql/dialect/postgres.d.ts +1 -1
  69. package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +1 -1
  70. package/types/service/predefinedAdaptor/sql/types.d.ts +2 -0
  71. package/types/service/predefinedAdaptor/sql/values.d.ts +1 -0
  72. package/types/signal/mcp/mcpProtocol.d.ts +6 -0
  73. package/types/signal/mcp/pagePrompt.d.ts +16 -0
  74. package/types/ui/Layout/FrameSlotPortal.d.ts +1 -1
  75. package/types/ui/System/App.d.ts +17 -0
  76. package/types/ui/System/index.d.ts +2 -0
  77. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  78. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  79. package/types/vendor/@akanjs/native/packages/cli/src/lib/toolchains.d.ts +1 -1
  80. package/types/webkit/CsrGuards.d.ts +25 -0
  81. package/types/webkit/CsrStack.d.ts +6 -0
  82. package/types/webkit/McpAppBoot.d.ts +32 -0
  83. package/types/webkit/McpAppBridge.d.ts +10 -1
  84. package/types/webkit/McpAppFetchBridge.d.ts +3 -1
  85. package/types/webkit/McpAppView.d.ts +4 -1
  86. package/types/webkit/RenderLayer.d.ts +1 -3
  87. package/types/webkit/index.d.ts +1 -0
  88. package/ui/Layout/BottomInset.tsx +2 -11
  89. package/ui/Layout/FrameSlotPortal.tsx +1 -3
  90. package/ui/Layout/Navbar.tsx +7 -22
  91. package/ui/Layout/TopInset.tsx +3 -4
  92. package/ui/System/App.tsx +43 -0
  93. package/ui/System/CSR.tsx +1 -10
  94. package/ui/System/index.tsx +3 -0
  95. package/vendor/@akanjs/native/packages/cli/src/lib/toolchains.ts +1 -1
  96. package/webkit/CsrGuards.ts +112 -0
  97. package/webkit/CsrRouteTable.ts +8 -12
  98. package/webkit/CsrStack.ts +29 -1
  99. package/webkit/McpAppBoot.tsx +129 -0
  100. package/webkit/McpAppBridge.ts +30 -6
  101. package/webkit/McpAppFetchBridge.ts +6 -1
  102. package/webkit/McpAppView.ts +39 -28
  103. package/webkit/RenderLayer.tsx +98 -41
  104. package/webkit/bootCsr.tsx +4 -43
  105. package/webkit/index.ts +1 -0
  106. package/webkit/useCsrValues.ts +55 -6
  107. package/webkit/useLocation.ts +1 -2
  108. package/types/webkit/EmbeddedLayout.d.ts +0 -10
  109. package/webkit/EmbeddedLayout.tsx +0 -82
package/client/cookie.ts CHANGED
@@ -128,6 +128,9 @@ export interface GetOption {
128
128
  interface SetAuthOption {
129
129
  jwt: string;
130
130
  }
131
+ const announceAuth = () => {
132
+ if (typeof window !== "undefined") window.dispatchEvent(new Event("akan:auth"));
133
+ };
131
134
  /** Sets the active auth token on fetch, the cookie jar (except on a CSR client), and client storage. */
132
135
  export const setAuth = ({ jwt }: SetAuthOption) => {
133
136
  fetch.setJwt(jwt);
@@ -137,6 +140,7 @@ export const setAuth = ({ jwt }: SetAuthOption) => {
137
140
 
138
141
  removeCookie(legacyAuthTokenKey);
139
142
  void secretStorage.removeItem(legacyAuthTokenKey);
143
+ announceAuth();
140
144
  };
141
145
 
142
146
  interface InitAuthOption {
@@ -162,4 +166,5 @@ export const resetAuth = () => {
162
166
  removeCookie(legacyAuthTokenKey);
163
167
  void secretStorage.removeItem(authTokenKey());
164
168
  void secretStorage.removeItem(legacyAuthTokenKey);
169
+ announceAuth();
165
170
  };
@@ -1,120 +1,65 @@
1
- interface CsrLocationEntry {
2
- href: string;
3
- state: unknown;
4
- }
5
-
6
- export interface CsrMemoryOwner {
7
- leave: (href: string) => void;
8
- reload: () => void;
9
- }
10
-
11
1
  declare global {
12
2
  var __AKAN_CSR_LOCATION__: CsrLocation | undefined;
13
3
 
14
4
  var __AKAN_EMBED__: { kind: "mcp-app"; origin?: string; account?: Record<string, unknown> | null } | undefined;
15
5
  }
16
6
 
7
+ /** The document's location and history, as the CSR router reads and moves them. */
17
8
  export class CsrLocation {
18
- #memory: { entries: CsrLocationEntry[]; idx: number } | null = null;
19
- #owner: CsrMemoryOwner = { leave: () => undefined, reload: () => undefined };
20
-
21
- get isMemory() {
22
- return this.#memory !== null;
23
- }
9
+
10
+ #view: URL | null = null;
24
11
 
25
- /** The owner takes what a document load would do, which a view cannot: it has no document to load in its place. */
26
- useMemory(href: string, owner: CsrMemoryOwner) {
27
- this.#memory = { entries: [{ href, state: null }], idx: 0 };
28
- this.#owner = owner;
12
+ /** Reads `href`, a path on the app's origin, as this document's location from now on; a view never moves. */
13
+ view(href: string) {
14
+ this.#view = new URL(href, "http://view.invalid");
29
15
  }
30
16
 
31
17
  /** Path, search and hash, without the origin. */
32
18
  get href() {
33
- const entry = this.#entry();
34
- if (entry) return entry.href;
35
- return `${window.location.pathname}${window.location.search}${window.location.hash}`;
19
+ return `${this.pathname}${this.search}${this.hash}`;
36
20
  }
37
21
 
38
22
  get pathname() {
39
- if (!this.#memory) return window.location.pathname;
40
- return this.href.split(/[?#]/)[0] ?? "/";
23
+ return (this.#view ?? window.location).pathname;
41
24
  }
42
25
 
43
26
  get search() {
44
- if (!this.#memory) return window.location.search;
45
- const [beforeHash] = this.href.split("#");
46
- const at = beforeHash?.indexOf("?") ?? -1;
47
- return at >= 0 ? (beforeHash?.slice(at) ?? "") : "";
27
+ return (this.#view ?? window.location).search;
48
28
  }
49
29
 
50
30
  get hash() {
51
- if (!this.#memory) return window.location.hash;
52
- const at = this.href.indexOf("#");
53
- return at >= 0 ? this.href.slice(at) : "";
31
+ return (this.#view ?? window.location).hash;
54
32
  }
55
33
 
56
34
  get state(): unknown {
57
- const entry = this.#entry();
58
- return entry ? entry.state : window.history.state;
35
+ return window.history.state;
59
36
  }
60
37
 
61
38
  /** `href` as a route reads it: an absolute URL on this document's origin loses the origin, anything else is kept. */
62
39
  strip(href: string) {
63
- if (this.#memory) return href;
64
40
  const origin = window.location.origin;
65
41
  return origin !== "null" && href.startsWith(origin) ? href.slice(origin.length) : href;
66
42
  }
67
43
 
68
44
  push(state: unknown, href: string) {
69
- const memory = this.#memory;
70
- if (!memory) {
71
- window.history.pushState(state, "", href);
72
- return;
73
- }
74
- memory.entries = [...memory.entries.slice(0, memory.idx + 1), { href, state }];
75
- memory.idx = memory.entries.length - 1;
45
+ window.history.pushState(state, "", href);
76
46
  }
77
47
 
78
48
  replace(state: unknown, href?: string) {
79
- const memory = this.#memory;
80
- if (!memory) {
81
- if (href === undefined) window.history.replaceState(state, "");
82
- else window.history.replaceState(state, "", href);
83
- return;
84
- }
85
- const current = memory.entries[memory.idx];
86
- memory.entries[memory.idx] = { href: href ?? current?.href ?? "/", state };
49
+ if (href === undefined) window.history.replaceState(state, "");
50
+ else window.history.replaceState(state, "", href);
87
51
  }
88
52
 
89
53
  back() {
90
- const memory = this.#memory;
91
- if (!memory) {
92
- window.history.back();
93
- return;
94
- }
95
- if (memory.idx === 0) return;
96
- memory.idx -= 1;
97
- const state = memory.entries[memory.idx]?.state ?? null;
98
-
99
- setTimeout(() => window.dispatchEvent(new PopStateEvent("popstate", { state })), 0);
54
+ window.history.back();
100
55
  }
101
56
 
102
57
  assign(href: string) {
103
- if (!this.#memory) {
104
- window.location.assign(href);
105
- return;
106
- }
107
- this.#owner.leave(href);
58
+ window.location.assign(href);
108
59
  }
109
60
 
110
61
  reload() {
111
- if (!this.#memory) window.location.reload();
112
- else this.#owner.reload();
113
- }
114
-
115
- #entry() {
116
- const memory = this.#memory;
117
- return memory ? memory.entries[memory.idx] : undefined;
62
+ window.location.reload();
118
63
  }
119
64
  }
120
65
 
@@ -109,6 +109,13 @@ export type PageLoadingRender = (props: PageLoadingProps) => PromiseOrObject<Rea
109
109
  export type LayoutLoadingRender = (props: LayoutLoadingProps) => PromiseOrObject<ReactNode>;
110
110
  export type LayoutNotFoundRender = (props: LayoutNotFoundProps) => PromiseOrObject<ReactNode>;
111
111
  export type LayoutErrorRender = (props: LayoutErrorProps) => PromiseOrObject<ReactNode>;
112
+ export interface RouteGuardProps {
113
+ params: Record<string, string>;
114
+ searchParams: Record<string, string | string[]>;
115
+ /** MCP is listing the screen, so no argument is known: a guard reading one throws `RouteArgPending`. */
116
+ listing?: boolean;
117
+ }
118
+ export type RouteGuard = (props: RouteGuardProps) => Promise<void>;
112
119
  export interface RouteRender {
113
120
  render: LayoutRender | PageRender;
114
121
  isAsync?: boolean;
@@ -125,6 +132,8 @@ export interface RouteRender {
125
132
  resolveHead?: ResolveHead;
126
133
  /** Parses the declared args without rendering, so a path value the type refuses answers not-found before any byte. */
127
134
  checkArgs?: (props: PageProps) => Promise<void>;
135
+ /** The route's `.guard()`, which `RouteGuards.run` calls root layout first, ahead of every layout body. */
136
+ guard?: RouteGuard;
128
137
  getPageConfig?: () => PromiseOrObject<PageConfig | undefined>;
129
138
  getLayoutPageConfig?: () => PromiseOrObject<PageConfig | undefined>;
130
139
  /** A root layout's `theme`, readable before it renders. */
@@ -175,12 +184,14 @@ export interface PageModule {
175
184
  head?: Head;
176
185
  generateHead?: GenerateHead;
177
186
  Loading?: PageLoadingRender;
187
+ guard?: RouteGuard;
178
188
  }
179
189
  export interface LayoutModule {
180
190
  default?: LayoutRender;
181
191
  pageConfig?: PageConfig;
182
192
  head?: Head;
183
193
  generateHead?: GenerateHead;
194
+ guard?: RouteGuard;
184
195
  fonts?: ReactFont[];
185
196
  manifest?: WebAppManifest;
186
197
  theme?: string;
@@ -279,6 +290,12 @@ export interface History {
279
290
  cachedLocationMap: Map<string, Location>;
280
291
  idx: number;
281
292
  dormant?: Set<string>;
293
+ account?: CsrStackAccount;
294
+ }
295
+ export interface CsrStackAccount {
296
+ token: string | null;
297
+ generation: number;
298
+ leaving?: { key: string; generation: number };
282
299
  }
283
300
 
284
301
  export interface RouterProps {
@@ -360,10 +377,6 @@ export const usePathCtx = () => {
360
377
  return contextValues;
361
378
  };
362
379
 
363
- /** Inside a layout's own markup that an MCP view hides while the page is drawn on its own: frame chrome draws nothing. */
364
- export const hiddenLayoutContext = sharedContext<boolean>("hiddenLayout", false);
365
- export const useInHiddenLayout = () => useContext(hiddenLayoutContext);
366
-
367
380
  export type PageActivity = "current" | "prev" | "pending" | "hidden";
368
381
  export interface PageActivityState {
369
382
  activity: PageActivity;
@@ -1,8 +1,13 @@
1
+ import type { PromiseOrObject } from "akanjs/base";
2
+ import { AppScope } from "akanjs/fetch";
3
+ import type { ReactNode } from "react";
1
4
  import { type RouteArgsShape, RouteDefinition } from "./RouteDefinition";
2
5
  import type {
6
+ RouteArgInfo,
3
7
  RouteArgOption,
4
8
  RouteArgType,
5
9
  RouteArgValue,
10
+ RouteBaseArgs,
6
11
  RoutePromptArgument,
7
12
  RoutePromptMeta,
8
13
  RouteSearchType,
@@ -13,7 +18,19 @@ interface PagePrompt {
13
18
  description: string;
14
19
  }
15
20
 
16
- export class PageDefinition<Args extends RouteArgsShape = Record<never, never>> extends RouteDefinition<Args> {
21
+ interface PageApp {
22
+ name: string;
23
+ description: string;
24
+ load: (args: never) => PromiseOrObject<ReactNode>;
25
+ }
26
+
27
+ /** `never` for a name the page's render already receives, so a clash fails to compile as well as at definition. */
28
+ type FreeName<Name extends string, Taken> = Name extends "lang" | keyof Taken ? never : Name;
29
+
30
+ export class PageDefinition<
31
+ Args extends RouteArgsShape = Record<never, never>,
32
+ Apps extends RouteArgsShape = Record<never, never>,
33
+ > extends RouteDefinition<Args> {
17
34
  /** MCP names an entry by `^[A-Za-z0-9_-]{1,64}$`; a prompt name outside it never reaches a client's slash menu. */
18
35
  static readonly promptName = /^[A-Za-z0-9_-]{1,64}$/;
19
36
  /** Appended to a list argument's description: the flat string map of `prompts/get` has no other spelling. */
@@ -21,33 +38,109 @@ export class PageDefinition<Args extends RouteArgsShape = Record<never, never>>
21
38
 
22
39
  readonly kind = "page" as const;
23
40
  #prompt?: PagePrompt;
41
+ readonly #apps: PageApp[] = [];
24
42
 
25
- param<Name extends string, Type extends RouteArgType>(name: Name, type: Type, option: RouteArgOption = {}) {
43
+ param<Name extends string, Type extends RouteArgType>(
44
+ name: Name & FreeName<Name, Apps>,
45
+ type: Type,
46
+ option: RouteArgOption = {},
47
+ ) {
26
48
  this.declare({ kind: "param", name, type, list: false, desc: option.desc });
27
- return this as unknown as PageDefinition<Args & { [Key in Name]: RouteArgValue<Type> }>;
49
+ return this as unknown as PageDefinition<Args & { [Key in Name]: RouteArgValue<Type> }, Apps>;
28
50
  }
29
51
 
30
- search<Name extends string, Type extends RouteSearchType>(name: Name, type: Type, option: RouteArgOption = {}) {
52
+ search<Name extends string, Type extends RouteSearchType>(
53
+ name: Name & FreeName<Name, Apps>,
54
+ type: Type,
55
+ option: RouteArgOption = {},
56
+ ) {
31
57
  const list = Array.isArray(type);
32
58
  this.declare({ kind: "search", name, type: (list ? type[0] : type) as RouteArgType, list, desc: option.desc });
33
- return this as unknown as PageDefinition<Args & { [Key in Name]?: RouteArgValue<Type> }>;
59
+ return this as unknown as PageDefinition<Args & { [Key in Name]?: RouteArgValue<Type> }, Apps>;
34
60
  }
35
61
 
36
62
  /** Publishes this screen as an MCP prompt: the description is the whole instruction a model receives. English. */
37
63
  prompt(name: string, description: string) {
38
- if (!PageDefinition.promptName.test(name))
39
- throw new Error(`[route-convention] prompt name "${name}" must match ${PageDefinition.promptName}`);
40
- if (!description.trim()) throw new Error(`[route-convention] prompt "${name}" needs a description`);
64
+ PageDefinition.#assertEntry("prompt", name, description);
65
+ if (this.#apps.some((app) => app.name === name))
66
+ throw new Error(`[route-convention] page() names "${name}" as both an app and its prompt; MCP lists both`);
41
67
  this.#prompt = { name, description };
42
68
  return this;
43
69
  }
44
70
 
71
+ /**
72
+ * Publishes one component of this screen as an MCP App: a read-only tool whose arguments are the page's `.param()`
73
+ * and `.search()`, and whose result a host draws with `load`'s element. `load` may only query. On the web the
74
+ * element arrives as the render argument `name`. English description.
75
+ */
76
+ app<Name extends string>(
77
+ name: Name & FreeName<Name, Args & Apps>,
78
+ description: string,
79
+ load: (args: RouteBaseArgs & Args) => PromiseOrObject<ReactNode>,
80
+ ) {
81
+ PageDefinition.#assertEntry("app", name, description);
82
+ if (name === "lang")
83
+ throw new Error(`[route-convention] app "lang" would hide the lang every route receives; name it otherwise`);
84
+ if (this.args.some((arg) => arg.name === name) || this.#apps.some((app) => app.name === name))
85
+ throw new Error(
86
+ `[route-convention] page() names "${name}" twice; an app's element arrives as the render argument of its name`,
87
+ );
88
+ if (this.#prompt?.name === name)
89
+ throw new Error(`[route-convention] page() names "${name}" as both an app and its prompt; MCP lists both`);
90
+ this.#apps.push({ name, description, load: load as PageApp["load"] });
91
+ return this as unknown as PageDefinition<Args, Apps & { [Key in Name]: ReactNode }>;
92
+ }
93
+
94
+ override render(render: (args: RouteBaseArgs & Args & Apps) => PromiseOrObject<ReactNode>) {
95
+ return super.render(render as never);
96
+ }
97
+
45
98
  get promptMeta(): RoutePromptMeta | undefined {
46
99
  if (!this.#prompt) return undefined;
47
- const args: RoutePromptArgument[] = this.args.map((arg) => {
100
+ return { ...this.#prompt, arguments: this.#entryArguments() };
101
+ }
102
+
103
+ get appMetas(): RoutePromptMeta[] {
104
+ return this.#apps.map(({ name, description }) => ({ name, description, arguments: this.#entryArguments() }));
105
+ }
106
+
107
+ /** One app's element for these args, alone: what an MCP call runs after the route guards, and its view draws. */
108
+ async loadApp(name: string, args: RouteBaseArgs & Args): Promise<ReactNode> {
109
+ const app = this.#apps.find((candidate) => candidate.name === name);
110
+ if (!app) throw new Error(`[route-convention] this page declares no app "${name}"`);
111
+ return await PageDefinition.#load(app, args);
112
+ }
113
+
114
+ protected override declare(arg: RouteArgInfo) {
115
+ if (this.#apps.some((app) => app.name === arg.name))
116
+ throw new Error(
117
+ `[route-convention] page() names "${arg.name}" twice; an app's element arrives as the render argument of its name`,
118
+ );
119
+ super.declare(arg);
120
+ }
121
+
122
+ protected override renderArgsOf(args: RouteBaseArgs & Args): PromiseOrObject<RouteBaseArgs & Args> {
123
+ if (!this.#apps.length) return args;
124
+ return Promise.all(this.#apps.map((app) => PageDefinition.#load(app, args))).then((elements) => ({
125
+ ...args,
126
+ ...Object.fromEntries(this.#apps.map((app, idx) => [app.name, elements[idx]])),
127
+ }));
128
+ }
129
+
130
+ #entryArguments(): RoutePromptArgument[] {
131
+ return this.args.map((arg) => {
48
132
  const description = [arg.desc, arg.list ? PageDefinition.commaSeparated : undefined].filter(Boolean).join(" ");
49
133
  return { name: arg.name, ...(description ? { description } : {}), required: arg.kind === "param" };
50
134
  });
51
- return { ...this.#prompt, arguments: args };
135
+ }
136
+
137
+ static async #load(app: PageApp, args: object): Promise<ReactNode> {
138
+ return await AppScope.run(app.name, () => app.load(args as never));
139
+ }
140
+
141
+ static #assertEntry(kind: "prompt" | "app", name: string, description: string) {
142
+ if (!PageDefinition.promptName.test(name))
143
+ throw new Error(`[route-convention] ${kind} name "${name}" must match ${PageDefinition.promptName}`);
144
+ if (!description.trim()) throw new Error(`[route-convention] ${kind} "${name}" needs a description`);
52
145
  }
53
146
  }
@@ -9,9 +9,10 @@ import {
9
9
  import { parseAkanI18nEnv } from "akanjs/common";
10
10
  import { deserialize } from "akanjs/constant";
11
11
  import type { ReactNode } from "react";
12
- import type { Head, LayoutModule, PageConfig, PageModule } from "../csrTypes";
12
+ import type { Head, LayoutModule, PageConfig, PageModule, RouteGuardProps } from "../csrTypes";
13
13
  import { AkanNotFoundError } from "../router";
14
- import { RouteArgError, type RouteArgInfo, type RouteArgInput, type RouteBaseArgs } from "./routeArgs";
14
+ import { Translator } from "../translator";
15
+ import { RouteArgError, type RouteArgInfo, type RouteArgInput, RouteArgPending, type RouteBaseArgs } from "./routeArgs";
15
16
 
16
17
  export type RouteArgsShape = Record<string, unknown>;
17
18
  export type RouteKind = "page" | "layout" | "rootLayout";
@@ -44,6 +45,7 @@ export abstract class RouteDefinition<
44
45
  #config?: PageConfig;
45
46
 
46
47
  #head?: HeadStage<never>;
48
+ #guard?: (args: never) => void | Promise<void>;
47
49
  #loading?: (args: never) => PromiseOrObject<ReactNode>;
48
50
  #render?: (args: never) => PromiseOrObject<ReactNode>;
49
51
  #renderRoute?: {
@@ -60,6 +62,13 @@ export abstract class RouteDefinition<
60
62
  this.#head = head as HeadStage<never>;
61
63
  return this;
62
64
  }
65
+ /** Runs before any layout body; refuses with `router.redirect()` or `router.notFound()`. */
66
+ guard(guard: (args: RouteBaseArgs & Args) => void | Promise<void>) {
67
+ if (this.#guard)
68
+ throw new Error(`[route-convention] ${this.kind}() declares .guard() twice; run every check from one guard`);
69
+ this.#guard = guard as (args: never) => void | Promise<void>;
70
+ return this;
71
+ }
63
72
  loading(render: (args: RouteBaseArgs & Args & Extra) => PromiseOrObject<ReactNode>) {
64
73
  this.#loading = render as (args: never) => PromiseOrObject<ReactNode>;
65
74
  return this;
@@ -127,10 +136,12 @@ export abstract class RouteDefinition<
127
136
  if (!render)
128
137
  throw new Error(`[route-convention] a ${this.kind}() chain ends with .render(), and this one has none`);
129
138
  const head = this.#head;
139
+ const guard = this.#guard;
130
140
  const loading = this.#loading;
131
141
  const module: PageModule & LayoutModule = {
132
142
  default: this.#renderRouteOf(render) as never,
133
143
  ...(this.#config ? { pageConfig: this.#config } : {}),
144
+ ...(guard ? { guard: this.#guardRouteOf(guard) } : {}),
134
145
  ...(head === undefined
135
146
  ? {}
136
147
  : typeof head === "function"
@@ -150,10 +161,28 @@ export abstract class RouteDefinition<
150
161
  return module;
151
162
  }
152
163
 
164
+ /** What `.render()` receives beyond the declared args; a page adds its apps' elements. */
165
+ protected renderArgsOf(args: RouteBaseArgs & Args & Extra): PromiseOrObject<RouteBaseArgs & Args & Extra> {
166
+ return args;
167
+ }
168
+
169
+ #guardRouteOf(guard: (args: never) => void | Promise<void>) {
170
+ const route = async (props: RouteGuardProps) => await guard(this.#guardArgsOf(props) as never);
171
+ Object.defineProperty(route, routeArgsMarker, { value: this.args.map(({ kind, name }) => ({ kind, name })) });
172
+ return route;
173
+ }
174
+
153
175
  #renderRouteOf(render: (args: never) => PromiseOrObject<ReactNode>) {
154
176
  const kept = this.#renderRoute;
155
177
  if (kept?.render === render && kept.argCount === this.args.length) return kept.route;
156
- const route = async (props: RouteRenderProps) => await render(this.#argsOf(props) as never);
178
+
179
+ const route = async (props: RouteRenderProps) => {
180
+ const args = this.renderArgsOf(this.#argsOf(props));
181
+ if (!(args instanceof Promise)) return await render(args as never);
182
+ const path = Translator.getRenderPath();
183
+ const resolved = await args;
184
+ return await Translator.renderAt(path, () => render(resolved as never));
185
+ };
157
186
  Object.defineProperty(route, routeArgsMarker, { value: this.args.map(({ kind, name }) => ({ kind, name })) });
158
187
  this.#renderRoute = { render, argCount: this.args.length, route };
159
188
  return route;
@@ -190,6 +219,18 @@ export abstract class RouteDefinition<
190
219
  return (this.kind === "page" ? args : { ...args, children: props.children }) as RouteBaseArgs & Args & Extra;
191
220
  }
192
221
 
222
+ #guardArgsOf({ params, searchParams, listing }: RouteGuardProps): RouteBaseArgs & Args {
223
+ const { lang = parseAkanI18nEnv().defaultLocale } = params;
224
+ if (!listing) return { lang, ...this.resolveArgs({ params, searchParams }) } as RouteBaseArgs & Args;
225
+ const declared = new Set(this.args.map((arg) => arg.name));
226
+ return new Proxy({ lang } as RouteBaseArgs & Args, {
227
+ get: (target, key, receiver) => {
228
+ if (typeof key === "string" && declared.has(key)) throw new RouteArgPending(key);
229
+ return Reflect.get(target, key, receiver);
230
+ },
231
+ });
232
+ }
233
+
193
234
  /** One value where a list was declared is a one-item list (`?tags=a`); under `strict` a list is comma-separated. */
194
235
  static #lift(arg: RouteArgInfo, raw: string | string[], strict: boolean): unknown {
195
236
  if (!arg.list) return Array.isArray(raw) ? raw[0] : raw;
@@ -0,0 +1,64 @@
1
+ import type { RouteGuard, RouteGuardProps, RouteRender } from "../csrTypes";
2
+ import { AkanNotFoundError } from "../router";
3
+ import { routeArgsMarker } from "./RouteDefinition";
4
+
5
+ /** Runs a route's `.guard()` stages: the root layout's first, the page's last, each before any layout body. */
6
+ export class RouteGuards {
7
+ static readonly #refusedAt = new WeakMap<object, number>();
8
+
9
+ /** The first refusal stops the chain and is thrown: a redirect, a not-found, or the error a guard leaked. */
10
+ static async run(renders: (RouteRender | undefined)[], props: RouteGuardProps): Promise<void> {
11
+ for (const [idx, render] of renders.entries()) {
12
+ if (!render?.guard) continue;
13
+ try {
14
+ await render.guard(props);
15
+ } catch (error) {
16
+ const refusal = RouteGuards.refusalOf(error);
17
+
18
+ const thrown = typeof refusal === "object" && refusal !== null ? refusal : new Error(String(refusal));
19
+ RouteGuards.#refusedAt.set(thrown, idx);
20
+ throw thrown;
21
+ }
22
+ }
23
+ }
24
+
25
+ /** The index, in the renders `run` was given, whose guard threw `error`; `undefined` for anything else. */
26
+ static refusedAt(error: unknown): number | undefined {
27
+ return typeof error === "object" && error !== null ? RouteGuards.#refusedAt.get(error) : undefined;
28
+ }
29
+
30
+ /** Whether MCP may list the screen to this caller: a guard that reads an argument leaves the verdict to the call. */
31
+ static async admits(renders: (RouteRender | undefined)[], props: RouteGuardProps): Promise<boolean> {
32
+ for (const render of renders) {
33
+ if (!render?.guard) continue;
34
+ try {
35
+ await render.guard({ ...props, listing: true });
36
+ } catch (error) {
37
+ if (!RouteGuards.isArgPending(error)) return false;
38
+ }
39
+ }
40
+ return true;
41
+ }
42
+
43
+ static refusalOf(error: unknown): unknown {
44
+ const status = (error as { statusCode?: unknown } | null)?.statusCode;
45
+ return status === 403 || status === 404 ? new AkanNotFoundError() : error;
46
+ }
47
+
48
+ static isArgPending(error: unknown): boolean {
49
+ return (error as { digest?: unknown } | null)?.digest === "AKAN_ROUTE_ARG_PENDING";
50
+ }
51
+
52
+ /** One guard running each of `guards` in order, or none when no guard is given. */
53
+ static compose(...guards: (RouteGuard | undefined)[]): RouteGuard | undefined {
54
+ const chain = guards.filter((guard): guard is RouteGuard => !!guard);
55
+ if (chain.length <= 1) return chain[0];
56
+ const composed: RouteGuard = async (props) => {
57
+ for (const guard of chain) await guard(props);
58
+ };
59
+
60
+ const reads = chain.map((guard) => (guard as { [routeArgsMarker]?: unknown[] })[routeArgsMarker]);
61
+ if (reads.every(Array.isArray)) Object.defineProperty(composed, routeArgsMarker, { value: reads.flat() });
62
+ return composed;
63
+ }
64
+ }
@@ -2,6 +2,7 @@ export * from "./LayoutDefinition";
2
2
  export * from "./PageDefinition";
3
3
  export * from "./RootLayoutDefinition";
4
4
  export * from "./RouteDefinition";
5
+ export * from "./RouteGuards";
5
6
  export * from "./RouteHead";
6
7
  export * from "./resolveRouteModule";
7
8
  export * from "./routeArgs";
@@ -40,3 +40,11 @@ export const resolveRouteModule = (
40
40
  if (pattern) definition.assertPattern(pattern, key);
41
41
  return { module: definition.toRouteModule(), definition };
42
42
  };
43
+
44
+ /** The export sets allow `guard` for an unfolded chain and the generated root layout; the legacy shape never gained it. */
45
+ export const assertGuardOnChain = (label: string, resolved: ResolvedRouteModule): ResolvedRouteModule => {
46
+ if (resolved.definition || !("guard" in resolved.module)) return resolved;
47
+ throw new Error(
48
+ `[route-convention] ${label} exports guard beside a legacy default; declare it with .guard() on its chain`,
49
+ );
50
+ };
@@ -49,3 +49,12 @@ export class RouteArgError extends Error {
49
49
  this.arg = arg;
50
50
  }
51
51
  }
52
+
53
+ /** A guard read an argument while MCP listed its screen, when none is known yet: the call decides with them. */
54
+ export class RouteArgPending extends Error {
55
+ readonly digest = "AKAN_ROUTE_ARG_PENDING";
56
+ constructor(name: string) {
57
+ super(`Argument "${name}" is not known while the screen is listed`);
58
+ this.name = "RouteArgPending";
59
+ }
60
+ }
package/client/router.ts CHANGED
@@ -177,6 +177,7 @@ class Router {
177
177
  #listensToPopstate: boolean = false;
178
178
  #backsAwaitingPopstate = 0;
179
179
  #redirects = 0;
180
+ #navigations = 0;
180
181
  #instance: RouterInstance = {
181
182
  push: (href: string) => {
182
183
  const { href: fullHref } = this.#getPathInfo(href);
@@ -390,6 +391,7 @@ class Router {
390
391
  }
391
392
  push(href: string, routeOptions?: RouteOptions) {
392
393
  this.#checkInitialized();
394
+ this.#navigations += 1;
393
395
  this.#track(this.#instance.push(href, routeOptions));
394
396
  this.#rememberHistoryState("push");
395
397
  this.#postDevSyncNavigation("push", href);
@@ -397,6 +399,7 @@ class Router {
397
399
  }
398
400
  replace(href: string, routeOptions?: RouteOptions) {
399
401
  this.#checkInitialized();
402
+ this.#navigations += 1;
400
403
  this.#track(this.#instance.replace(href, routeOptions));
401
404
  this.#rememberHistoryState("replace");
402
405
  this.#postDevSyncNavigation("replace", href);
@@ -433,6 +436,7 @@ class Router {
433
436
  back(routeOptions?: RouteOptions) {
434
437
  if (getEnv().side === "server") throw new Error("back is only available in client side");
435
438
  this.#checkInitialized();
439
+ this.#navigations += 1;
436
440
  this.#instance.back(routeOptions);
437
441
  return undefined as never;
438
442
  }
@@ -447,6 +451,11 @@ class Router {
447
451
  redirectCount(): number {
448
452
  return this.#redirects;
449
453
  }
454
+ /** Client-side moves so far — push, replace, back, redirect, notFound: a route guard that saw this change sent the
455
+ * visitor elsewhere, so its page does not draw. */
456
+ navigationCount(): number {
457
+ return this.#navigations;
458
+ }
450
459
  redirect(href: string, options: RedirectOptions = {}): never {
451
460
  const method = options.method ?? "replace";
452
461
  const status = options.status ?? 307;
@@ -463,6 +472,7 @@ class Router {
463
472
  throw new AkanRedirectError(fullHref, method, status);
464
473
  } else {
465
474
  this.#redirects += 1;
475
+ this.#navigations += 1;
466
476
  this.#instance[method](href);
467
477
  }
468
478
  return undefined as never;
@@ -474,6 +484,7 @@ class Router {
474
484
  }
475
485
  this.#checkInitialized();
476
486
  this.#redirects += 1;
487
+ this.#navigations += 1;
477
488
  this.#instance.replace("/404");
478
489
  return undefined as never;
479
490
  }
@@ -0,0 +1,13 @@
1
+
2
+
3
+ /** The pre-scoping key. Read as a fallback, never written; drop it once deployments have rotated past it. */
4
+ export const legacyAuthTokenKey = "jwt";
5
+
6
+ /** Matches both spellings so a signout can sweep a jar written before and after the key was scoped. */
7
+ export const isAuthTokenKey = (key: string): boolean =>
8
+ key === legacyAuthTokenKey || key.startsWith(`${legacyAuthTokenKey}:`);
9
+
10
+ const authCookiePattern = new RegExp(`(?:^|;\\s*)${legacyAuthTokenKey}(?::[^=\\s;]+)?=`);
11
+
12
+ export const cookieHeaderHasAuthToken = (cookieHeader: string | null | undefined): boolean =>
13
+ !!cookieHeader && authCookiePattern.test(cookieHeader);
@@ -1,20 +1,9 @@
1
1
  import { getEnv } from "akanjs/base";
2
+ import { legacyAuthTokenKey } from "./authCookie";
2
3
  import { decodeJwtPayload } from "./jwtDecode";
3
4
 
4
- /** The pre-scoping key. Read as a fallback, never written; drop it once deployments have rotated past it. */
5
- export const legacyAuthTokenKey = "jwt";
6
-
7
5
  export const authTokenKey = (): string => `${legacyAuthTokenKey}:${getEnv().appName}`;
8
6
 
9
- /** Matches both spellings so a signout can sweep a jar written before and after the key was scoped. */
10
- export const isAuthTokenKey = (key: string): boolean =>
11
- key === legacyAuthTokenKey || key.startsWith(`${legacyAuthTokenKey}:`);
12
-
13
- const authCookiePattern = new RegExp(`(?:^|;\\s*)${legacyAuthTokenKey}(?::[^=\\s;]+)?=`);
14
-
15
- export const cookieHeaderHasAuthToken = (cookieHeader: string | null | undefined): boolean =>
16
- !!cookieHeader && authCookiePattern.test(cookieHeader);
17
-
18
7
  export const isOwnAuthToken = (jwt: string): boolean => {
19
8
  const { appName, environment } = getEnv();
20
9
  try {