akanjs 3.0.0 → 3.0.2-rc.0

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 (127) hide show
  1. package/CHANGELOG.md +3670 -0
  2. package/base/base.ts +1 -1
  3. package/client/cookie.ts +4 -6
  4. package/client/csrTypes.ts +2 -2
  5. package/common/Logger.ts +10 -0
  6. package/common/index.ts +0 -1
  7. package/common/routeConvention.ts +1 -9
  8. package/dictionary/base.dictionary.ts +7 -0
  9. package/dictionary/trans.ts +7 -1
  10. package/document/documentQuery.ts +6 -1
  11. package/document/index.ts +1 -0
  12. package/document/into.ts +15 -7
  13. package/document/liveRowScope.ts +79 -0
  14. package/fetch/client/fetchClient.ts +64 -12
  15. package/fetch/client/httpClient.ts +13 -4
  16. package/index.ts +22 -0
  17. package/package.json +1 -1
  18. package/server/akanOption.ts +20 -1
  19. package/server/akanServer.ts +5 -0
  20. package/server/cachePolicy.ts +12 -2
  21. package/server/crawler.ts +6 -0
  22. package/server/head.tsx +0 -4
  23. package/server/mcp/McpAuth.ts +3 -2
  24. package/server/mcp/McpExecutionContext.ts +15 -14
  25. package/server/mcp/McpRateLimiter.ts +18 -67
  26. package/server/proxy/webProxyRunner.ts +2 -0
  27. package/server/resolver/database.resolver.ts +28 -13
  28. package/server/resolver/signal.resolver.ts +75 -35
  29. package/server/robots.ts +1 -23
  30. package/server/routeTreeBuilder.ts +4 -2
  31. package/server/rscWorker.tsx +12 -24
  32. package/server/rscWorkerHost.ts +16 -7
  33. package/server/ssrFromRscRenderer.tsx +19 -15
  34. package/server/ssrTypes.ts +9 -0
  35. package/server/webRouter.ts +9 -2
  36. package/service/index.ts +1 -0
  37. package/service/predefinedAdaptor/index.ts +1 -0
  38. package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
  39. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
  40. package/service/predefinedAdaptor/sql/types.ts +14 -12
  41. package/service/rateLimiter.ts +80 -0
  42. package/signal/base.signal.ts +7 -6
  43. package/signal/endpointRateLimit.ts +159 -0
  44. package/signal/index.ts +1 -0
  45. package/signal/middleware.ts +6 -5
  46. package/signal/serializer/fetch.serializer.ts +15 -4
  47. package/signal/signalContext.ts +148 -67
  48. package/signal/slice.ts +30 -9
  49. package/signal/types.ts +8 -0
  50. package/test/signalTestRuntime.ts +2 -1
  51. package/types/base/base.d.ts +1 -1
  52. package/types/client/cookie.d.ts +1 -1
  53. package/types/client/csrTypes.d.ts +2 -2
  54. package/types/common/Logger.d.ts +2 -0
  55. package/types/common/index.d.ts +1 -1
  56. package/types/common/routeConvention.d.ts +0 -2
  57. package/types/dictionary/base.dictionary.d.ts +1 -1
  58. package/types/dictionary/dictionary.d.ts +8 -8
  59. package/types/document/documentQuery.d.ts +5 -1
  60. package/types/document/index.d.ts +1 -0
  61. package/types/document/into.d.ts +7 -7
  62. package/types/document/liveRowScope.d.ts +9 -0
  63. package/types/fetch/client/fetchClient.d.ts +9 -1
  64. package/types/index.d.ts +32 -0
  65. package/types/server/akanOption.d.ts +7 -1
  66. package/types/server/cachePolicy.d.ts +2 -1
  67. package/types/server/crawler.d.ts +1 -0
  68. package/types/server/head.d.ts +0 -3
  69. package/types/server/mcp/McpExecutionContext.d.ts +4 -1
  70. package/types/server/mcp/McpRateLimiter.d.ts +2 -6
  71. package/types/server/rscWorkerHost.d.ts +3 -0
  72. package/types/server/ssrFromRscRenderer.d.ts +3 -1
  73. package/types/server/ssrTypes.d.ts +8 -0
  74. package/types/service/index.d.ts +1 -0
  75. package/types/service/predefinedAdaptor/index.d.ts +1 -0
  76. package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
  77. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
  78. package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
  79. package/types/service/rateLimiter.d.ts +24 -0
  80. package/types/signal/endpointRateLimit.d.ts +37 -0
  81. package/types/signal/index.d.ts +1 -0
  82. package/types/signal/signalContext.d.ts +33 -15
  83. package/types/signal/slice.d.ts +17 -5
  84. package/types/signal/types.d.ts +8 -0
  85. package/types/test/signalTestRuntime.d.ts +1 -1
  86. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  87. package/types/ui/recipe/buttonRecipe.d.ts +4 -4
  88. package/types/ui/recipe/inputRecipe.d.ts +3 -3
  89. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
  90. package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
  91. package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
  92. package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
  93. package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
  94. package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
  95. package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
  96. package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
  97. package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
  98. package/types/webkit/RenderFailure.d.ts +6 -0
  99. package/types/webkit/useFrameRuntime.d.ts +0 -1
  100. package/ui/Input.tsx +7 -1
  101. package/ui/recipe/buttonRecipe.ts +1 -0
  102. package/ui/recipe/inputRecipe.ts +13 -7
  103. package/ui/styles.css +1 -0
  104. package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
  105. package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
  106. package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
  107. package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
  108. package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
  109. package/vendor/@akanjs/native/packages/cli/src/lib/addons.ts +2 -1
  110. package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
  111. package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
  112. package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
  113. package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
  114. package/vendor/@akanjs/native/packages/cli/src/lib/exec.ts +11 -1
  115. package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
  116. package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
  117. package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
  118. package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
  119. package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
  120. package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
  121. package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
  122. package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
  123. package/vendor/use-agentic/CHANGELOG.md +663 -0
  124. package/webkit/CsrRouteTable.ts +0 -2
  125. package/webkit/RenderFailure.tsx +32 -0
  126. package/webkit/RenderLayer.tsx +22 -2
  127. package/webkit/useCsrValues.ts +21 -11
package/base/base.ts CHANGED
@@ -27,7 +27,7 @@ export type EnumInstance<RefName extends string = string, T = string | number> =
27
27
  export const isEnum = (enumRef: Cls) => Object.getPrototypeOf(Object.getPrototypeOf(enumRef) ?? {}) === EnumPrototype;
28
28
 
29
29
  /** `type` is `String` for string values, else `Int` when every value is an integer, else `Float`. */
30
- export const enumOf = <RefName extends string, T = string | number>(
30
+ export const enumOf = <RefName extends string, const T = string | number>(
31
31
  refName: RefName,
32
32
  values: readonly T[],
33
33
  ): EnumInstance<RefName, T> => {
package/client/cookie.ts CHANGED
@@ -46,19 +46,17 @@ export const cookies = (): Map<string, { name: string; value: string }> => {
46
46
  export const setCookie = (
47
47
  key: string,
48
48
  value: string,
49
- options: CookieOptions = { path: "/", sameSite: "none", secure: true },
49
+ { path = "/", sameSite = "lax", secure = true }: CookieOptions = {},
50
50
  ) => {
51
51
  if (getEnv().side === "server") return;
52
52
  if (!pageKeepsCookies()) {
53
53
  writeCookieJar({ ...readCookieJar(), [key]: value });
54
54
  return;
55
55
  }
56
- const encoded = `${key}=${value}`;
57
- const path = options.path ? `; path=${options.path}` : "";
58
- const sameSite = options.sameSite ? `; SameSite=${options.sameSite}` : "";
59
- const secure = options.secure ? "; Secure" : "";
56
+ const pathAttr = path ? `; path=${path}` : "";
57
+ const secureAttr = secure ? "; Secure" : "";
60
58
 
61
- document.cookie = `${encoded}${path}${sameSite}${secure}`;
59
+ document.cookie = `${key}=${value}${pathAttr}; SameSite=${sameSite}${secureAttr}`;
62
60
  };
63
61
 
64
62
  /** Reads through `cookies()` on both sides: a hand-rolled `split("=")` cuts base64 padding and skips the `j:` form. */
@@ -116,6 +116,8 @@ export interface RouteRender {
116
116
  resolveHead?: ResolveHead;
117
117
  getPageConfig?: () => PromiseOrObject<PageConfig | undefined>;
118
118
  getLayoutPageConfig?: () => PromiseOrObject<PageConfig | undefined>;
119
+ /** A root layout's `theme`, readable before it renders. */
120
+ getLayoutTheme?: () => PromiseOrObject<string | undefined>;
119
121
  /** The `page()` chain behind a page render, when it was declared as one — what a page prompt is read off. */
120
122
  getRouteDefinition?: () => PromiseOrObject<RouteDefinition | undefined>;
121
123
  }
@@ -179,7 +181,6 @@ export interface Route {
179
181
  /** Synthetic layout render from a `_overrides.tsx` at this node; wraps the subtree in a UI-override provider. */
180
182
  renderOverrides?: RouteRender;
181
183
  pageIncludesOwnLayout?: boolean;
182
- isSpecialRoute?: boolean;
183
184
  loader?: () => unknown;
184
185
  pageState?: PageState;
185
186
  pageConfigChain?: PageConfig[];
@@ -355,7 +356,6 @@ export interface PathRoute {
355
356
  renderRootLayouts: RouteRender[];
356
357
  renderLayouts: RouteRender[];
357
358
  resolveHead?: ResolveHead;
358
- isSpecialRoute?: boolean;
359
359
  }
360
360
 
361
361
  export type FrameSlotScope = "page" | "layout";
package/common/Logger.ts CHANGED
@@ -169,6 +169,16 @@ export class Logger {
169
169
  static isPromoted(record: LogRecord) {
170
170
  return record.attrs?.flight === true || record.attrs?.debug === true;
171
171
  }
172
+ /** `value` as one line of JSON for a message, a secret-named key masked at any depth and the text cut at `max`. */
173
+ static inlineData(value: unknown, max = 500): string {
174
+ let text: string;
175
+ try {
176
+ text = JSON.stringify(value, (key, val) => (key && redactedAttrKey.test(key) ? "[redacted]" : val)) ?? "";
177
+ } catch {
178
+ text = "[unserializable]";
179
+ }
180
+ return text.length > max ? `${text.slice(0, max)}…` : text;
181
+ }
172
182
  static redactAttrs(attrs: LogAttrs): LogAttrs {
173
183
  let redacted: LogAttrs | null = null;
174
184
  for (const key of Object.keys(attrs)) {
package/common/index.ts CHANGED
@@ -139,7 +139,6 @@ export {
139
139
  getPageSourceFileViolation,
140
140
  getRouteExports,
141
141
  isRouteSourceFile,
142
- isSpecialRouteLeaf,
143
142
  LAYOUT_ROUTE_EXPORTS,
144
143
  matchRoutePattern,
145
144
  normalizeRoutePattern,
@@ -3,7 +3,6 @@ const SOURCE_EXT_RE = /\.(tsx|ts|jsx|js)$/;
3
3
  const RESERVED_ROUTE_FILES = new Set(["_layout", "_index", "_overrides"]);
4
4
  const INTERNAL_ROOT_LAYOUT_LEAF = "__root_layout";
5
5
  const IMPLICIT_LOCALE_SEGMENT = "[lang]";
6
- const SPECIAL_ROUTE_LEAVES = new Set(["robots.txt"]);
7
6
 
8
7
  const DIRECTORY_SCOPED_LEAVES = new Set(["_layout", "_index", "_overrides"]);
9
8
 
@@ -45,7 +44,6 @@ export interface ParsedRouteModuleKey {
45
44
  ext: string;
46
45
  leaf: string;
47
46
  pattern: string;
48
- isSpecialRoute: boolean;
49
47
  isInternalRootLayout: boolean;
50
48
  }
51
49
 
@@ -140,8 +138,7 @@ export function parseRouteModuleKey(key: string): ParsedRouteModuleKey {
140
138
 
141
139
  const sourceRouteSegments =
142
140
  DIRECTORY_SCOPED_LEAVES.has(leaf) || isInternalRootLayout ? moduleSegments.slice(0, -1) : moduleSegments;
143
- const isSpecialRoute = kind === "page" && SPECIAL_ROUTE_LEAVES.has(leaf);
144
- const routeSegments = isSpecialRoute ? sourceRouteSegments : [IMPLICIT_LOCALE_SEGMENT, ...sourceRouteSegments];
141
+ const routeSegments = [IMPLICIT_LOCALE_SEGMENT, ...sourceRouteSegments];
145
142
  for (const segment of routeSegments) validateRouteSegment(segment, key);
146
143
 
147
144
  return {
@@ -153,15 +150,10 @@ export function parseRouteModuleKey(key: string): ParsedRouteModuleKey {
153
150
  ext,
154
151
  leaf,
155
152
  pattern: normalizeRoutePattern(routeSegments),
156
- isSpecialRoute,
157
153
  isInternalRootLayout,
158
154
  };
159
155
  }
160
156
 
161
- export function isSpecialRouteLeaf(leaf: string): boolean {
162
- return SPECIAL_ROUTE_LEAVES.has(leaf);
163
- }
164
-
165
157
  export function tryParseRouteModuleKey(key: string): ParsedRouteModuleKey | null {
166
158
  try {
167
159
  return parseRouteModuleKey(key);
@@ -40,10 +40,15 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
40
40
  "The server took too long to answer. Please try again in a moment.",
41
41
  "서버 응답이 지연되고 있습니다. 잠시 후 다시 시도해주세요.",
42
42
  ],
43
+ tooManyRequests: [
44
+ "Too many requests. Please try again in {seconds} seconds.",
45
+ "요청이 너무 많습니다. {seconds}초 후 다시 시도해주세요.",
46
+ ],
43
47
  unexpectedResponse: [
44
48
  "The server returned an unexpected response ({status}).",
45
49
  "서버가 예상하지 못한 응답을 보냈습니다. ({status})",
46
50
  ],
51
+ immutableField: ["{field} cannot be changed after creation.", "{field}은(는) 생성 후에는 바꿀 수 없습니다."],
47
52
  })
48
53
  .translate({
49
54
  somethingWrong: ["Something's wrong!", "문제가 생겼어요!"],
@@ -69,6 +74,8 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
69
74
  phoneInvalidError: ["Phone is not valid", "유효하지 않은 전화번호입니다."],
70
75
  ok: ["OK", "확인"],
71
76
  cancel: ["Cancel", "취소"],
77
+ retry: ["Try again", "다시 시도"],
78
+ pageLoadFailed: ["This page could not be loaded.", "이 화면을 불러오지 못했습니다."],
72
79
  unauthorized: ["Unauthorized", "권한 없음"],
73
80
  agent: ["Agent", "에이전트"],
74
81
  agentIntro: ["Ask about this page or tell the agent what to do.", "이 화면에 대해 묻거나 할 일을 지시해 보세요."],
@@ -1,5 +1,5 @@
1
1
  import type { GetStateObject, ObjectAssign, Prettify } from "akanjs/base";
2
- import { interpolateTranslation, parseAkanI18nEnv, pathGetLoose } from "akanjs/common";
2
+ import { interpolateTranslation, Logger, parseAkanI18nEnv, pathGetLoose } from "akanjs/common";
3
3
 
4
4
  import { DictionaryRegistry } from "./dictionaryRegistry";
5
5
  import type { DictModule } from "./locale";
@@ -122,6 +122,12 @@ export const makeTrans = <
122
122
  this.timestamp = option.timestamp;
123
123
  }
124
124
 
125
+ override toString() {
126
+ return this.data === undefined
127
+ ? `${this.name}: ${this.message}`
128
+ : `${this.name}: ${this.message} ${Logger.inlineData(this.data)}`;
129
+ }
130
+
125
131
  toJSON() {
126
132
  return {
127
133
  error: this.message,
@@ -101,7 +101,12 @@ export type DocumentUpdate<T = any> = {
101
101
  [K in DocumentPath<T>]?: DocumentUpdateValue;
102
102
  };
103
103
 
104
- export interface DocumentUpdateOptions {
104
+ /** Server-only: no endpoint or slice builds it from a request, so a caller can never widen a read to removed rows. */
105
+ export interface DocumentScopeOptions {
106
+ withRemoved?: boolean;
107
+ }
108
+
109
+ export interface DocumentUpdateOptions extends DocumentScopeOptions {
105
110
  upsert?: boolean;
106
111
  }
107
112
 
package/document/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./documentQuery";
6
6
  export * from "./documentSchema";
7
7
  export * from "./filterMeta";
8
8
  export * from "./into";
9
+ export * from "./liveRowScope";
9
10
  export * from "./loaderInfo";
10
11
  export * from "./noDocumentError";
11
12
  export * from "./queryEvaluator";
package/document/into.ts CHANGED
@@ -4,7 +4,7 @@ import type { DocumentModel, QueryOf } from "akanjs/constant";
4
4
  import type { FilterCls, FilterQueryOf, FilterSortOf, SchemaOf } from ".";
5
5
  import type { CacheDatabase, QueryMethodPart } from "./database";
6
6
  import type { DataLoader } from "./dataLoader";
7
- import type { DocumentQuery, DocumentUpdateInput, DocumentUpdateOptions } from "./documentQuery";
7
+ import type { DocumentQuery, DocumentScopeOptions, DocumentUpdateInput, DocumentUpdateOptions } from "./documentQuery";
8
8
  import { type LoaderBuilder, type ModelCls, makeLoaderBuilder } from "./loaderInfo";
9
9
  import type { DocumentProjection } from "./types";
10
10
 
@@ -61,18 +61,26 @@ export type Mdl<
61
61
  > = DefaultMdlStats<Doc, _RawDoc, Partial<_RawDoc>, _RawQuery, _Projection> & {
62
62
  refName: string;
63
63
  new (data: Partial<_RawDoc> | Partial<Doc>): Doc;
64
- find(query: _RawQuery, projection?: _Projection): FindManyChain<Doc>;
65
- findOne(query: _RawQuery, projection?: _Projection): FindOneChain<Doc>;
66
- findById(id: string | undefined, projection?: _Projection): Promise<Doc | null>;
67
- count(query: _RawQuery): Promise<number>;
68
- exists(query: _RawQuery): Promise<string | null>;
64
+ find(query: _RawQuery, projection?: _Projection | null, options?: DocumentScopeOptions): FindManyChain<Doc>;
65
+ findOne(query: _RawQuery, projection?: _Projection | null, options?: DocumentScopeOptions): FindOneChain<Doc>;
66
+ findById(
67
+ id: string | undefined,
68
+ projection?: _Projection | null,
69
+ options?: DocumentScopeOptions,
70
+ ): Promise<Doc | null>;
71
+ count(query: _RawQuery, options?: DocumentScopeOptions): Promise<number>;
72
+ exists(query: _RawQuery, options?: DocumentScopeOptions): Promise<string | null>;
69
73
 
70
74
  updateOne(
71
75
  query: _RawQuery,
72
76
  update: DocumentUpdateInput<_RawDoc>,
73
77
  options?: DocumentUpdateOptions,
74
78
  ): Promise<UpdateResult>;
75
- updateMany(query: _RawQuery, update: DocumentUpdateInput<_RawDoc>): Promise<UpdateResult>;
79
+ updateMany(
80
+ query: _RawQuery,
81
+ update: DocumentUpdateInput<_RawDoc>,
82
+ options?: DocumentScopeOptions,
83
+ ): Promise<UpdateResult>;
76
84
  removeOne(query: _RawQuery): Promise<UpdateResult>;
77
85
  removeMany(query: _RawQuery): Promise<UpdateResult>;
78
86
  updateById(id: string, update: DocumentUpdateInput<_RawDoc>, options?: DocumentUpdateOptions): Promise<UpdateResult>;
@@ -0,0 +1,79 @@
1
+ import {
2
+ type DocumentQuery,
3
+ type DocumentQueryNode,
4
+ type DocumentQueryOperator,
5
+ queryOperatorKeys,
6
+ } from "./documentQuery";
7
+
8
+ type RemovedAtTest = "isNull" | "isNotNull" | "value";
9
+
10
+ /**
11
+ * Why a query can never match a live row, or null: without `withRemoved` every read and write is scoped to
12
+ * `removedAt IS NULL`. `q.raw()` is SQL this walk cannot read, so it goes unchecked.
13
+ */
14
+ export class LiveRowScope {
15
+ static conflictOf(query: DocumentQuery | undefined, negated = false): string | null {
16
+ if (!query || typeof query !== "object") return null;
17
+ if (LiveRowScope.#isNode(query)) {
18
+ if (query.kind === "all" || query.kind === "any")
19
+ return query.queries.reduce<string | null>(
20
+ (found, sub) => found ?? LiveRowScope.conflictOf(sub, negated),
21
+ null,
22
+ );
23
+ if (query.kind === "not") return LiveRowScope.conflictOf(query.query, !negated);
24
+ return null;
25
+ }
26
+ return Object.entries(query).reduce<string | null>(
27
+ (found, [path, value]) => found ?? LiveRowScope.#fieldConflict(path, value, negated),
28
+ null,
29
+ );
30
+ }
31
+
32
+ static #fieldConflict(path: string, value: unknown, negated: boolean): string | null {
33
+ if (LiveRowScope.#isNode(value) && value.kind !== "op") return LiveRowScope.conflictOf(value, negated);
34
+ if (path !== "removedAt") return null;
35
+ for (const [test, spelled] of LiveRowScope.#testsOf(value)) {
36
+ const named = negated ? `not(removedAt ${spelled})` : `removedAt ${spelled}`;
37
+
38
+ if (test === "value") return `${named} compares a value no live row holds`;
39
+ if ((test === "isNotNull") !== negated) return `${named} asks for removed rows`;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ static #testsOf(value: unknown): [RemovedAtTest, string][] {
45
+ if (value === undefined) return [];
46
+ if (value === null) return [["isNull", "null"]];
47
+ if (LiveRowScope.#isNode(value) && value.kind === "op") {
48
+ const test = LiveRowScope.#testOf(value.op, value.value);
49
+ return test ? [test] : [];
50
+ }
51
+ if (value && typeof value === "object" && !Array.isArray(value)) {
52
+ const operators = value as Record<string, unknown>;
53
+ const keys = Object.keys(operators).filter((key) => queryOperatorKeys.has(key));
54
+ if (keys.length)
55
+ return keys.flatMap((key): [RemovedAtTest, string][] => {
56
+ const spelled = `{ ${key}: ${String(operators[key])} }`;
57
+
58
+ if (key === "exists") return [[operators.exists ? "isNotNull" : "isNull", spelled]];
59
+ if (key === "missing" || key === "empty") return [[operators[key] ? "isNull" : "isNotNull", spelled]];
60
+ const test = LiveRowScope.#testOf(key as DocumentQueryOperator, operators[key]);
61
+ return test ? [test] : [];
62
+ });
63
+ }
64
+ return [["value", "eq"]];
65
+ }
66
+
67
+ static #testOf(op: DocumentQueryOperator, operand: unknown): [RemovedAtTest, string] | null {
68
+ if (op === "exists") return ["isNotNull", op];
69
+ if (op === "missing" || op === "empty") return ["isNull", op];
70
+ if ((op === "eq" || op === "ne") && operand === null) return [op === "eq" ? "isNull" : "isNotNull", `${op}(null)`];
71
+
72
+ if ((op === "oneOf" || op === "notOneOf") && !(operand as unknown[] | undefined)?.length) return null;
73
+ return ["value", op];
74
+ }
75
+
76
+ static #isNode(value: unknown): value is DocumentQueryNode {
77
+ return !!value && typeof value === "object" && "kind" in value;
78
+ }
79
+ }
@@ -92,6 +92,9 @@ export class FetchClient {
92
92
  readonly #handlerStore: Record<string, FetchHandler> = {};
93
93
  readonly #handlerFactory = new Map<string, FetchHandlerFactory>();
94
94
  #sharedRegistryAppliedVersion = 0;
95
+ #clientIp: string | null = null;
96
+ static #testClientIps = false;
97
+ static #testClientIpSeq = 0;
95
98
  serializedSignal: { [key: string]: SerializedSignal } = {};
96
99
  jwt: string | null = null;
97
100
 
@@ -120,6 +123,19 @@ export class FetchClient {
120
123
  sharedClientState.proxy = null;
121
124
  sharedClientState.origin = null;
122
125
  }
126
+ /**
127
+ * In a signal test every agent reaches the test server from loopback, so an IP rate limit would count them all as
128
+ * one caller: from here on each clone names its own address (RFC 2544's benchmarking range), which loopback is
129
+ * trusted to report.
130
+ */
131
+ static useTestClientIps(enabled = true) {
132
+ FetchClient.#testClientIps = enabled;
133
+ FetchClient.#testClientIpSeq = 0;
134
+ }
135
+ static #nextTestClientIp() {
136
+ const seq = ++FetchClient.#testClientIpSeq;
137
+ return `198.${18 + ((seq >> 16) & 1)}.${(seq >> 8) & 255}.${seq & 255}`;
138
+ }
123
139
  static #resolveSharedClientProxy(origin: string, Err?: ErrorConstructor) {
124
140
  if (typeof window === "undefined") return null;
125
141
 
@@ -238,8 +254,22 @@ export class FetchClient {
238
254
  for (const ws of this.#originWs.values()) ws.destroy();
239
255
  this.#originWs.clear();
240
256
  }
241
- clone({ origin, connect = true, jwt }: { origin?: string; connect?: boolean; jwt?: string } = {}) {
257
+
258
+ clone({
259
+ origin,
260
+ connect = !(origin && typeof window === "undefined"),
261
+ jwt,
262
+ clientIp,
263
+ }: {
264
+ origin?: string;
265
+ connect?: boolean;
266
+ jwt?: string;
267
+ /** Sent as `x-real-ip`, which a server believes only from a trusted proxy — a test's loopback is one. */
268
+ clientIp?: string | null;
269
+ } = {}) {
242
270
  const instance = new FetchClient(origin ?? this.origin, {}, this.serializedSignal, this.ErrorCls);
271
+ instance.#clientIp =
272
+ clientIp !== undefined ? clientIp : FetchClient.#testClientIps ? FetchClient.#nextTestClientIp() : this.#clientIp;
243
273
  Object.entries(this.handler).forEach(([key, handler]) => {
244
274
  if (!(key in instance.handler)) instance.handler[key] = handler;
245
275
  });
@@ -265,6 +295,21 @@ export class FetchClient {
265
295
  ws.connect();
266
296
  return ws;
267
297
  }
298
+ #makeRequestHeaders(option?: FetchPolicy): Record<string, string> {
299
+ const clientIp = this.#clientIp ? { "x-real-ip": this.#clientIp } : FetchClient.#forwardedClientIp(option);
300
+ return { ...this.#makeAuthHeaders(option), ...clientIp };
301
+ }
302
+
303
+ static #forwardedClientIp(option?: FetchPolicy): Record<string, string> {
304
+ if (option?.origin) return {};
305
+ try {
306
+ if (getEnv().side !== "server") return {};
307
+ const clientIp = requestHeaders().get("x-real-ip");
308
+ return clientIp ? { "x-real-ip": clientIp } : {};
309
+ } catch {
310
+ return {};
311
+ }
312
+ }
268
313
  #makeAuthHeaders(option?: FetchPolicy): Record<string, string> {
269
314
  if (option?.token) return { Authorization: `Bearer ${option.token}` };
270
315
  try {
@@ -299,7 +344,7 @@ export class FetchClient {
299
344
  case "query":
300
345
  return async (...argData: unknown[]) => {
301
346
  const { option, argMap, url } = requestOf(argData);
302
- const headers = this.#makeAuthHeaders(option);
347
+ const headers = this.#makeRequestHeaders(option);
303
348
  const baseUrl = this.#baseUrlOf(endpoint, option?.origin);
304
349
  const timeout = option?.timeout ?? endpoint.timeout;
305
350
 
@@ -318,7 +363,7 @@ export class FetchClient {
318
363
  const { option, argMap, url } = requestOf(argData);
319
364
  const body = HttpClient.makeBody(bodyArgs, uploadArgs, argMap);
320
365
  const response = await this.http.send(endpoint.method ?? "POST", url, body, {
321
- headers: this.#makeAuthHeaders(option),
366
+ headers: this.#makeRequestHeaders(option),
322
367
  baseUrl: this.#baseUrlOf(endpoint, option?.origin),
323
368
  timeout: option?.timeout ?? endpoint.timeout,
324
369
  });
@@ -429,16 +474,16 @@ export class FetchClient {
429
474
  modelId: `${refName}Id`,
430
475
  lightModel: `light${capRefName}`,
431
476
  };
432
- const createGuards = signal.createGuards ?? signal.cruGuards;
433
- const updateGuards = signal.updateGuards ?? signal.cruGuards;
434
- const removeGuards = signal.removeGuards ?? signal.cruGuards;
477
+ const createGuards = FetchClient.#mountedGuards(signal, "create");
478
+ const updateGuards = FetchClient.#mountedGuards(signal, "update");
479
+ const removeGuards = FetchClient.#mountedGuards(signal, "remove");
435
480
 
436
481
  const mcp = (verb: keyof NonNullable<SerializedSignal["mcp"]>) => ({
437
482
  ...(signal.mcp?.[verb] === false ? { mcp: false as const } : {}),
438
483
  ...(signal.agents?.[verb] === false ? { agents: false as const } : {}),
439
484
  });
440
485
  const endpoint: { [key: string]: SerializedEndpoint } = {};
441
- if (signal.getGuards) {
486
+ if (FetchClient.#mountedGuards(signal, "get")) {
442
487
  endpoint[names.model] = {
443
488
  type: "query",
444
489
  args: [{ type: "param", name: names.modelId, refName: "ID" }],
@@ -486,6 +531,11 @@ export class FetchClient {
486
531
  }
487
532
  return endpoint;
488
533
  }
534
+ static #mountedGuards(signal: SerializedSignal, verb: "get" | "create" | "update" | "remove") {
535
+ if (signal.crud?.[verb] === false) return undefined;
536
+ if (verb === "get") return signal.getGuards;
537
+ return signal[`${verb}Guards`] ?? signal.cruGuards;
538
+ }
489
539
  #registerModelBaseEndpoint(refName: string, signal: SerializedSignal) {
490
540
  const capRefName = capitalize(refName);
491
541
  const names = {
@@ -503,15 +553,17 @@ export class FetchClient {
503
553
  this.#setHandlerFactory(key, () => this.#makeHttpFn(key, value, signal.prefix));
504
554
  });
505
555
 
506
- const anyCruGuards = signal.cruGuards ?? signal.createGuards ?? signal.updateGuards ?? signal.removeGuards;
507
- const updateGuards = signal.updateGuards ?? signal.cruGuards;
508
- if (signal.getGuards) {
556
+ const getGuards = FetchClient.#mountedGuards(signal, "get");
557
+ const updateGuards = FetchClient.#mountedGuards(signal, "update");
558
+ const anyCruGuards =
559
+ FetchClient.#mountedGuards(signal, "create") ?? updateGuards ?? FetchClient.#mountedGuards(signal, "remove");
560
+ if (getGuards) {
509
561
  this.#setHandlerFactory(names.viewModel, () =>
510
562
  this.#makeModelHandleFn(refName, names.model, names.viewModel, `${refName}View`),
511
563
  );
512
564
  this.#setHandlerFactory(names.getModelView, () => this.#makeModelObjFn(refName, names.getModelView));
513
565
  }
514
- if (signal.getGuards && anyCruGuards) {
566
+ if (getGuards && anyCruGuards) {
515
567
  this.#setHandlerFactory(names.editModel, () =>
516
568
  this.#makeModelHandleFn(refName, names.model, names.editModel, `${refName}Edit`),
517
569
  );
@@ -547,7 +599,7 @@ export class FetchClient {
547
599
  if (parentId) formData.append(fields.parentId, parentId);
548
600
  const url = FetchClient.makeHttpUrl(cap.endpointKey, endpoint, cap.prefix, new Map());
549
601
  return await this.http.post(url, formData, {
550
- headers: this.#makeAuthHeaders(option),
602
+ headers: this.#makeRequestHeaders(option),
551
603
  baseUrl: this.#baseUrlOf(endpoint),
552
604
  });
553
605
  }) as FetchHandler,
@@ -16,6 +16,7 @@ const jsonContentType = /^application\/(?:[\w.+-]+\+)?json\b/i;
16
16
 
17
17
  const transportErrorKeyMap = {
18
18
  408: "base.error.gatewayTimeout",
19
+ 429: "base.error.tooManyRequests",
19
20
  502: "base.error.serverUnavailable",
20
21
  503: "base.error.serverUnavailable",
21
22
  504: "base.error.gatewayTimeout",
@@ -130,12 +131,12 @@ export class HttpClient {
130
131
  try {
131
132
  return (await res.json()) as unknown;
132
133
  } catch (error) {
133
- throw this.#transportError(res.status, String(error));
134
+ throw this.#transportError(res, String(error));
134
135
  }
135
136
  }
136
137
  const raw = await res.text();
137
138
  const parsed = HttpClient.#parseJson(raw);
138
- if (parsed === undefined) throw this.#transportError(res.status, raw);
139
+ if (parsed === undefined) throw this.#transportError(res, raw);
139
140
  return parsed;
140
141
  }
141
142
 
@@ -147,9 +148,17 @@ export class HttpClient {
147
148
  }
148
149
  }
149
150
 
150
- #transportError(status: number, detail: string): RestoredError {
151
+ #transportError(res: Response, detail: string): RestoredError {
152
+ const { status } = res;
151
153
  const error = transportErrorKeyMap[status as keyof typeof transportErrorKeyMap] ?? unexpectedResponseKey;
152
- return this.#restoreError({ error, data: { status }, details: detail.slice(0, transportDetailLimit) }, status);
154
+ const data = status === 429 ? { status, seconds: HttpClient.#retryAfterSeconds(res) } : { status };
155
+ return this.#restoreError({ error, data, details: detail.slice(0, transportDetailLimit) }, status);
156
+ }
157
+
158
+ static #retryAfterSeconds(res: Response): number {
159
+ const header = res.headers.get("retry-after")?.trim() ?? "";
160
+ const seconds = /^\d+$/.test(header) ? Number(header) : Math.ceil((Date.parse(header) - Date.now()) / 1000);
161
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : 60;
153
162
  }
154
163
 
155
164
  #restoreError(body: unknown, fallbackStatusCode: number): RestoredError {
package/index.ts CHANGED
@@ -213,6 +213,28 @@ export interface AkanNativeDesktopConfig {
213
213
  * for a native addon signed by another team. Read when the app is signed with a team (a Developer ID).
214
214
  */
215
215
  entitlements?: Record<string, AkanNativeValue>;
216
+ /**
217
+ * macOS: the Finder window `build-desktop --installer` opens its dmg in — no toolbar or sidebar, the app's and the
218
+ * Applications link's icons on a background, the app's icon on the mounted disk. Without it the window is 660×400 pt
219
+ * on the framework's background, an arrow from the app at (180, 170) to Applications at (480, 170). A position is an
220
+ * icon's center in points from the window's top left; the default background lines up with the default positions.
221
+ */
222
+ dmg?: AkanNativeDmgConfig;
223
+ }
224
+
225
+ export interface AkanNativeDmgConfig {
226
+ /** A PNG (or TIFF) the size of the window in points, relative to the app folder; `false` for a plain window. */
227
+ background?: string | false;
228
+ /** The same picture at twice the pixels, for a Retina display. */
229
+ background2x?: string;
230
+ /** The area under the title bar, in points. Default the background's size, at 200, 120 on the screen. */
231
+ window?: { x?: number; y?: number; width?: number; height?: number };
232
+ /** Default 128. */
233
+ iconSize?: number;
234
+ /** Default 13. */
235
+ textSize?: number;
236
+ app?: { x: number; y: number };
237
+ applications?: { x: number; y: number };
216
238
  }
217
239
 
218
240
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0",
3
+ "version": "3.0.2-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1,6 +1,13 @@
1
1
  import type { BackendEnv, PromiseOrObject } from "akanjs/base";
2
2
  import type { Adaptor, AdaptorCls, LlmOption } from "akanjs/service";
3
- import type { AgentQuotaHook, AgentUsageHook, CrossSiteOption, GuardCls, MiddlewareCls } from "akanjs/signal";
3
+ import type {
4
+ AgentQuotaHook,
5
+ AgentUsageHook,
6
+ CrossSiteOption,
7
+ GuardCls,
8
+ MiddlewareCls,
9
+ RateLimitSetting,
10
+ } from "akanjs/signal";
4
11
  import type { McpServerOption } from "./akanServer";
5
12
  import type { WebProxyRegistration } from "./proxy";
6
13
  import { HostBasePathWebProxy, LocaleWebProxy } from "./proxy";
@@ -21,6 +28,7 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
21
28
  #agentUsage: AgentUsageHook | null | undefined;
22
29
  #agentQuota: AgentQuotaHook | null | undefined;
23
30
  #crossSite: CrossSiteOption | undefined;
31
+ #getRateLimit: ((env: Env) => RateLimitSetting | false) | undefined;
24
32
  use(fnOrObject: ((env: Env) => Record<string, PromiseOrObject<unknown>>) | Record<string, PromiseOrObject<unknown>>) {
25
33
  if (typeof fnOrObject === "function")
26
34
  this.#getUses.push(fnOrObject as (env: Env) => Record<string, PromiseOrObject<unknown>>);
@@ -65,6 +73,14 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
65
73
  this.#crossSite = crossSite;
66
74
  return this;
67
75
  }
76
+ /**
77
+ * Endpoint budgets: a default for endpoints that declare none, overrides by endpoint key, and who `by: "account"`
78
+ * counts. Merged in mount order, the app's last; `AKAN_RATE_LIMIT=off` still turns them all off.
79
+ */
80
+ setRateLimit(settingOrFn: RateLimitSetting | false | ((env: Env) => RateLimitSetting | false)) {
81
+ this.#getRateLimit = typeof settingOrFn === "function" ? settingOrFn : () => settingOrFn;
82
+ return this;
83
+ }
68
84
  /** Injected into the `LlmAdaptorRole` adaptor as the `llmOption` use; merged in mount order, the app's last. */
69
85
  setLlm<Option extends LlmOption>(llmOrFn: Option | ((env: Env) => Option)) {
70
86
  if (typeof llmOrFn === "function") this.#getLlms.push(llmOrFn);
@@ -99,6 +115,9 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
99
115
  getCrossSite(): CrossSiteOption | undefined {
100
116
  return this.#crossSite;
101
117
  }
118
+ getRateLimit(env: Env): RateLimitSetting | false | undefined {
119
+ return this.#getRateLimit?.(env);
120
+ }
102
121
  getLlm(env: Env): LlmOption {
103
122
  return Object.assign({}, ...this.#getLlms.map((fn) => fn(env)));
104
123
  }
@@ -15,6 +15,7 @@ import type {
15
15
  import type { ServerSignal, ServerSignalCls, WebsocketPublishData } from "akanjs/signal";
16
16
  import { AgentMeter } from "../signal/agentMeter";
17
17
  import { CrossSiteGuard } from "../signal/CrossSiteGuard";
18
+ import { EndpointRateLimit } from "../signal/endpointRateLimit";
18
19
  import { AgentRelayAccess } from "../signal/guards";
19
20
  import { createOpenApiDocument } from "../signal/openapi";
20
21
  import { FetchSerializer } from "../signal/serializer";
@@ -189,6 +190,7 @@ export class AkanServer {
189
190
  this.env = { ...env };
190
191
  this.openapi = options?.openapi ?? this.openapi;
191
192
 
193
+ EndpointRateLimit.reset();
192
194
  libs.forEach((lib) => {
193
195
  const mcp = lib.option.getMcp(this.env);
194
196
  if (mcp !== undefined) this.setMcp(mcp);
@@ -198,7 +200,10 @@ export class AkanServer {
198
200
  if (usage !== undefined || quota !== undefined) AgentMeter.use({ usage, quota });
199
201
  const crossSite = lib.option.getCrossSite();
200
202
  if (crossSite !== undefined) CrossSiteGuard.configure(crossSite);
203
+ const rateLimit = lib.option.getRateLimit(this.env);
204
+ if (rateLimit !== undefined) EndpointRateLimit.configure(rateLimit);
201
205
  });
206
+ EndpointRateLimit.applyEnv(process.env.AKAN_RATE_LIMIT, getEnv().environment);
202
207
  this.setMcp(options?.mcp ?? this.mcp);
203
208
  this.serverMode = serverMode;
204
209