akanjs 3.0.1 → 3.0.2

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 (102) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/base/base.ts +1 -1
  3. package/client/cookie.ts +4 -6
  4. package/common/Logger.ts +10 -0
  5. package/dictionary/base.dictionary.ts +7 -0
  6. package/dictionary/trans.ts +7 -1
  7. package/document/documentQuery.ts +6 -1
  8. package/document/index.ts +1 -0
  9. package/document/into.ts +15 -7
  10. package/document/liveRowScope.ts +79 -0
  11. package/fetch/client/fetchClient.ts +64 -12
  12. package/fetch/client/httpClient.ts +13 -4
  13. package/index.ts +22 -0
  14. package/package.json +1 -1
  15. package/server/akanOption.ts +20 -1
  16. package/server/akanServer.ts +5 -0
  17. package/server/mcp/McpAuth.ts +3 -2
  18. package/server/mcp/McpExecutionContext.ts +15 -14
  19. package/server/mcp/McpRateLimiter.ts +18 -67
  20. package/server/proxy/webProxyRunner.ts +2 -0
  21. package/server/resolver/database.resolver.ts +28 -13
  22. package/server/resolver/signal.resolver.ts +75 -35
  23. package/server/rscWorkerHost.ts +16 -7
  24. package/server/webRouter.ts +3 -0
  25. package/service/index.ts +1 -0
  26. package/service/predefinedAdaptor/index.ts +1 -0
  27. package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
  28. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
  29. package/service/predefinedAdaptor/sql/types.ts +14 -12
  30. package/service/rateLimiter.ts +80 -0
  31. package/signal/base.signal.ts +7 -6
  32. package/signal/endpointRateLimit.ts +159 -0
  33. package/signal/index.ts +1 -0
  34. package/signal/middleware.ts +6 -5
  35. package/signal/serializer/fetch.serializer.ts +15 -4
  36. package/signal/signalContext.ts +148 -67
  37. package/signal/slice.ts +30 -9
  38. package/signal/types.ts +8 -0
  39. package/store/agent/AgentCursor.ts +2 -1
  40. package/test/signalTestRuntime.ts +2 -1
  41. package/types/base/base.d.ts +1 -1
  42. package/types/client/cookie.d.ts +1 -1
  43. package/types/common/Logger.d.ts +2 -0
  44. package/types/dictionary/base.dictionary.d.ts +1 -1
  45. package/types/dictionary/dictionary.d.ts +8 -8
  46. package/types/document/documentQuery.d.ts +5 -1
  47. package/types/document/index.d.ts +1 -0
  48. package/types/document/into.d.ts +7 -7
  49. package/types/document/liveRowScope.d.ts +9 -0
  50. package/types/fetch/client/fetchClient.d.ts +9 -1
  51. package/types/index.d.ts +32 -0
  52. package/types/server/akanOption.d.ts +7 -1
  53. package/types/server/mcp/McpExecutionContext.d.ts +4 -1
  54. package/types/server/mcp/McpRateLimiter.d.ts +2 -6
  55. package/types/server/rscWorkerHost.d.ts +3 -0
  56. package/types/service/index.d.ts +1 -0
  57. package/types/service/predefinedAdaptor/index.d.ts +1 -0
  58. package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
  59. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
  60. package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
  61. package/types/service/rateLimiter.d.ts +24 -0
  62. package/types/signal/endpointRateLimit.d.ts +37 -0
  63. package/types/signal/index.d.ts +1 -0
  64. package/types/signal/signalContext.d.ts +33 -15
  65. package/types/signal/slice.d.ts +17 -5
  66. package/types/signal/types.d.ts +8 -0
  67. package/types/test/signalTestRuntime.d.ts +1 -1
  68. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  69. package/types/ui/recipe/inputRecipe.d.ts +3 -3
  70. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
  71. package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
  72. package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
  73. package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
  74. package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
  75. package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
  76. package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
  77. package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
  78. package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
  79. package/types/webkit/RenderFailure.d.ts +6 -0
  80. package/ui/Input.tsx +7 -1
  81. package/ui/recipe/buttonRecipe.ts +1 -0
  82. package/ui/recipe/inputRecipe.ts +13 -7
  83. package/ui/styles.css +1 -0
  84. package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
  85. package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
  86. package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
  87. package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
  88. package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
  89. package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
  90. package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
  91. package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
  92. package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
  93. package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
  94. package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
  95. package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
  96. package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
  97. package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
  98. package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
  99. package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
  100. package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
  101. package/webkit/RenderFailure.tsx +32 -0
  102. package/webkit/RenderLayer.tsx +22 -2
@@ -1,9 +1,6 @@
1
- import { type Dayjs, dayjs } from "akanjs/base";
2
- import { Logger } from "akanjs/common";
1
+ import { type RateCounter, RateLimiter } from "akanjs/service";
3
2
 
4
- export interface McpSharedCounter {
5
- incr(topic: string, key: string, by?: number, option?: { expireAt?: Dayjs }): Promise<number>;
6
- }
3
+ export type McpSharedCounter = RateCounter;
7
4
 
8
5
  export interface McpRateLimitOption {
9
6
  /** Per caller per window; `0` turns the counter off. */
@@ -17,23 +14,14 @@ export type McpRateLimitVerdict =
17
14
  | { ok: true; release: () => void }
18
15
  | { ok: false; reason: "calls" | "concurrent"; retryAfterMs: number };
19
16
 
20
- interface McpRateBucket {
21
- windowStart: number;
22
- count: number;
23
- inFlight: number;
24
- }
25
-
26
17
  export class McpRateLimiter {
27
18
  static readonly defaults = { calls: 120, windowMs: 60_000, concurrent: 8 } as const;
28
- static readonly #logger = new Logger("McpRateLimiter");
29
19
 
30
20
  readonly calls: number;
31
21
  readonly windowMs: number;
32
22
  readonly concurrent: number;
33
- readonly #shared: McpSharedCounter | null;
34
- readonly #buckets = new Map<string, McpRateBucket>();
35
- #sweptAt = 0;
36
- #sharedFailed = false;
23
+ readonly #limiter: RateLimiter;
24
+ readonly #inFlight = new Map<string, number>();
37
25
 
38
26
  constructor(
39
27
  {
@@ -46,74 +34,37 @@ export class McpRateLimiter {
46
34
  this.calls = Math.max(0, Math.floor(calls));
47
35
  this.windowMs = Math.max(1, Math.floor(windowMs));
48
36
  this.concurrent = Math.max(0, Math.floor(concurrent));
49
- this.#shared = shared;
37
+ this.#limiter = new RateLimiter(shared, { topic: "mcpRate" });
50
38
  }
51
39
 
52
40
  async acquire(key: string, now = Date.now()): Promise<McpRateLimitVerdict> {
53
- this.#sweep(now);
54
- const bucket = this.#bucketOf(key, now);
55
- if (this.concurrent && bucket.inFlight >= this.concurrent)
56
- return { ok: false, reason: "concurrent", retryAfterMs: 1_000 };
41
+ const held = this.#inFlight.get(key) ?? 0;
42
+ if (this.concurrent && held >= this.concurrent) return { ok: false, reason: "concurrent", retryAfterMs: 1_000 };
57
43
 
58
- bucket.inFlight += 1;
44
+ this.#inFlight.set(key, held + 1);
59
45
  let released = false;
60
46
  const release = () => {
61
47
  if (released) return;
62
48
  released = true;
63
- bucket.inFlight = Math.max(0, bucket.inFlight - 1);
49
+ const left = (this.#inFlight.get(key) ?? 1) - 1;
50
+ if (left > 0) this.#inFlight.set(key, left);
51
+ else this.#inFlight.delete(key);
64
52
  };
65
- const retryAfterMs = this.calls ? await this.#spend(key, bucket, now) : null;
66
- if (retryAfterMs === null) return { ok: true, release };
53
+ if (!this.calls) return { ok: true, release };
54
+ const verdict = await this.#limiter.consume(key, { calls: this.calls, windowMs: this.windowMs }, now);
55
+ if (verdict.ok) return { ok: true, release };
67
56
  release();
68
- return { ok: false, reason: "calls", retryAfterMs };
57
+ return { ok: false, reason: "calls", retryAfterMs: verdict.retryAfterMs };
69
58
  }
70
59
 
71
60
  describe() {
72
- const across = this.#shared ? " across instances" : "";
61
+ const shared = this.#limiter.isShared;
73
62
  const window = this.calls
74
- ? `${this.calls} calls / ${Math.round(this.windowMs / 1000)}s${across}`
63
+ ? `${this.calls} calls / ${Math.round(this.windowMs / 1000)}s${shared ? " across instances" : ""}`
75
64
  : "unlimited calls";
76
65
  const inFlight = this.concurrent
77
- ? `${this.concurrent} in flight${this.#shared ? " per instance" : ""}`
66
+ ? `${this.concurrent} in flight${shared ? " per instance" : ""}`
78
67
  : "unlimited in flight";
79
68
  return `${window}, ${inFlight}, per caller`;
80
69
  }
81
-
82
- async #spend(key: string, bucket: McpRateBucket, now: number): Promise<number | null> {
83
- if (this.#shared) {
84
- const window = Math.floor(now / this.windowMs);
85
- const end = (window + 1) * this.windowMs;
86
- try {
87
- const count = await this.#shared.incr("mcpRate", `${key}:${window}`, 1, {
88
- expireAt: dayjs(end + this.windowMs),
89
- });
90
- this.#sharedFailed = false;
91
- return count > this.calls ? Math.max(1, end - now) : null;
92
- } catch (error) {
93
- if (!this.#sharedFailed)
94
- McpRateLimiter.#logger.warn(
95
- `MCP rate limit cannot reach the cache, so each instance counts its own window until it can: ${String(error)}`,
96
- );
97
- this.#sharedFailed = true;
98
- }
99
- }
100
- if (bucket.count >= this.calls) return Math.max(1, bucket.windowStart + this.windowMs - now);
101
- bucket.count += 1;
102
- return null;
103
- }
104
-
105
- #bucketOf(key: string, now: number) {
106
- const found = this.#buckets.get(key);
107
- if (found && now - found.windowStart < this.windowMs) return found;
108
- const bucket: McpRateBucket = { windowStart: now, count: 0, inFlight: found?.inFlight ?? 0 };
109
- this.#buckets.set(key, bucket);
110
- return bucket;
111
- }
112
-
113
- #sweep(now: number) {
114
- if (now - this.#sweptAt < this.windowMs) return;
115
- this.#sweptAt = now;
116
- for (const [key, bucket] of this.#buckets)
117
- if (!bucket.inFlight && now - bucket.windowStart >= this.windowMs) this.#buckets.delete(key);
118
- }
119
70
  }
@@ -1,3 +1,4 @@
1
+ import { SignalContext } from "../../signal/signalContext";
1
2
  import {
2
3
  WEB_PROXY_RESULT,
3
4
  type WebProxy,
@@ -9,6 +10,7 @@ import {
9
10
  } from "./types";
10
11
 
11
12
  export const copyBunRequestFields = (target: Request, source: Request): Request => {
13
+ SignalContext.carryClientIp(target, source);
12
14
  const sourceWithParams = source as Request & { params?: Record<string, string> };
13
15
  if (sourceWithParams.params) {
14
16
  Object.defineProperty(target, "params", {
@@ -10,7 +10,9 @@ import {
10
10
  type DataInputOf,
11
11
  DataLoader,
12
12
  DocumentSchema,
13
+ type DocumentScopeOptions,
13
14
  type DocumentUpdateInput,
15
+ type DocumentUpdateOptions,
14
16
  documentQueryHelper,
15
17
  type FindQueryOption,
16
18
  getFilterMeta,
@@ -70,7 +72,7 @@ export class DatabaseResolver {
70
72
  sort: resolveSort(queryOption?.sort),
71
73
  skip: resolvePageSkip(queryOption?.skip),
72
74
 
73
- limit: queryOption?.limit === undefined ? 0 : resolvePageLimit(queryOption.limit),
75
+ limit: queryOption?.limit === undefined || queryOption.limit === 0 ? 0 : resolvePageLimit(queryOption.limit),
74
76
  select: queryOption?.select,
75
77
  sample: queryOption?.sample,
76
78
  });
@@ -198,9 +200,13 @@ export class DatabaseResolver {
198
200
  function Model(this: any, data: Record<string, unknown>) {
199
201
  return store.hydrate(data);
200
202
  }
203
+
204
+ const scopeOf = (options?: DocumentScopeOptions) => (options?.withRemoved ? { withRemoved: true } : {});
205
+ const scopeArgs = (options?: DocumentScopeOptions): [] | [DocumentScopeOptions] =>
206
+ options?.withRemoved ? [{ withRemoved: true }] : [];
201
207
  const createFindManyChain = (
202
208
  query: QueryOf<any>,
203
- options: { sort?: any; skip?: number; limit?: number; select?: any } = {},
209
+ options: { sort?: any; skip?: number; limit?: number; select?: any; withRemoved?: boolean } = {},
204
210
  ) => {
205
211
  const chain: any = {
206
212
  sort(sort: any) {
@@ -225,7 +231,10 @@ export class DatabaseResolver {
225
231
  };
226
232
  return chain;
227
233
  };
228
- const createFindOneChain = (query: QueryOf<any>, options: { sort?: any; skip?: number; select?: any } = {}) => {
234
+ const createFindOneChain = (
235
+ query: QueryOf<any>,
236
+ options: { sort?: any; skip?: number; select?: any; withRemoved?: boolean } = {},
237
+ ) => {
229
238
  const chain: any = {
230
239
  sort(sort: any) {
231
240
  return createFindOneChain(query, { ...options, sort });
@@ -261,21 +270,27 @@ export class DatabaseResolver {
261
270
  pickAndWrite: async (id: string, rawData: any) => await (await pickById(id)).set(rawData).save(),
262
271
  pickOneAndWrite: async (query: QueryOf<any>, rawData: any) =>
263
272
  await (await timedQuery(() => store.pickOne(query))).set(rawData).save(),
264
- exists: async (query: QueryOf<any>) => await timedQuery(() => store.exists(query)),
273
+ exists: async (query: QueryOf<any>, options?: DocumentScopeOptions) =>
274
+ await timedQuery(() => store.exists(query, ...scopeArgs(options))),
265
275
  sample: (query: QueryOf<any>, size = 1) => timedQuery(() => store.find(query, { sample: size, limit: size })),
266
276
  sampleOne: (query: QueryOf<any>) => timedQuery(() => store.findOne(query, { sample: true })),
267
- find: (query: QueryOf<any>, projection?: any) => createFindManyChain(query, { select: projection }),
268
- findOne: (query: QueryOf<any>, projection?: any) => createFindOneChain(query, { select: projection }),
269
- findById: (id: string | undefined, projection?: any) =>
270
- id ? timedQuery(() => store.findOne({ id }, { select: projection })) : Promise.resolve(null),
271
- count: (query: QueryOf<any>) => timedQuery(() => store.count(query)),
272
- updateOne: (query: QueryOf<any>, update: DocumentUpdateInput, options?: { upsert?: boolean }) =>
277
+ find: (query: QueryOf<any>, projection?: any, options?: DocumentScopeOptions) =>
278
+ createFindManyChain(query, { select: projection, ...scopeOf(options) }),
279
+ findOne: (query: QueryOf<any>, projection?: any, options?: DocumentScopeOptions) =>
280
+ createFindOneChain(query, { select: projection, ...scopeOf(options) }),
281
+ findById: (id: string | undefined, projection?: any, options?: DocumentScopeOptions) =>
282
+ id
283
+ ? timedQuery(() => store.findOne({ id }, { select: projection, ...scopeOf(options) }))
284
+ : Promise.resolve(null),
285
+ count: (query: QueryOf<any>, options?: DocumentScopeOptions) =>
286
+ timedQuery(() => store.count(query, ...scopeArgs(options))),
287
+ updateOne: (query: QueryOf<any>, update: DocumentUpdateInput, options?: DocumentUpdateOptions) =>
273
288
  timedQuery(() => store.updateOneByQuery(query, update, options)),
274
- updateMany: (query: QueryOf<any>, update: DocumentUpdateInput) =>
275
- timedQuery(() => store.updateManyByQuery(query, update)),
289
+ updateMany: (query: QueryOf<any>, update: DocumentUpdateInput, options?: DocumentScopeOptions) =>
290
+ timedQuery(() => store.updateManyByQuery(query, update, ...scopeArgs(options))),
276
291
  removeOne: (query: QueryOf<any>) => timedQuery(() => store.removeOneByQuery(query)),
277
292
  removeMany: (query: QueryOf<any>) => timedQuery(() => store.removeManyByQuery(query)),
278
- updateById: (id: string, update: DocumentUpdateInput, options?: { upsert?: boolean }) =>
293
+ updateById: (id: string, update: DocumentUpdateInput, options?: DocumentUpdateOptions) =>
279
294
  timedQuery(() => store.updateOneByQuery({ id }, update, options)),
280
295
  removeById: (id: string) => timedQuery(() => store.removeOneByQuery({ id })),
281
296
 
@@ -15,7 +15,14 @@ import {
15
15
  SLICE_META,
16
16
  } from "akanjs/base";
17
17
  import { capitalize, cookieHeaderHasAuthToken, Logger } from "akanjs/common";
18
- import { type ConstantField, deserialize, resolvePageLimit, resolvePageSkip, serialize } from "akanjs/constant";
18
+ import {
19
+ type ConstantField,
20
+ deserialize,
21
+ MAX_PAGE_SIZE,
22
+ resolvePageLimit,
23
+ resolvePageSkip,
24
+ serialize,
25
+ } from "akanjs/constant";
19
26
  import { baseDocumentColumns, documentQueryHelper, getFilterSortByKey, type QueryFieldMap } from "akanjs/document";
20
27
  import {
21
28
  type AkanJob,
@@ -30,6 +37,7 @@ import {
30
37
  import { websocketRoomContract } from "../../common/websocketContract";
31
38
  import { type Endpoint, type EndpointCls, sliceEndpoint } from "../../signal/endpoint";
32
39
  import type { EndpointInfo } from "../../signal/endpointInfo";
40
+ import { EndpointRateLimit } from "../../signal/endpointRateLimit";
33
41
  import type { Internal, InternalCls } from "../../signal/internal";
34
42
  import type { InternalInfo } from "../../signal/internalInfo";
35
43
  import type { MiddlewareCls } from "../../signal/middleware";
@@ -65,6 +73,7 @@ export class SignalResolver {
65
73
  };
66
74
  static readonly #coalescingRooms = new Set<string>();
67
75
  static readonly #liveRoutes = new Map<string, { store: DocumentStore; route: LiveRoute }>();
76
+ static readonly #everyRowAsked = new Set<string>();
68
77
 
69
78
  static coalescesRoom(roomId: string): boolean {
70
79
  const separator = roomId.indexOf("-");
@@ -325,6 +334,17 @@ export class SignalResolver {
325
334
  else SignalResolver.logger.warn(message);
326
335
  }
327
336
 
337
+ static #clientPageLimit(listKey: string, asked: unknown) {
338
+ if (asked !== 0) return resolvePageLimit(asked);
339
+ if (!SignalResolver.#everyRowAsked.has(listKey)) {
340
+ SignalResolver.#everyRowAsked.add(listKey);
341
+ SignalResolver.logger.warn(
342
+ `${listKey} was asked for limit: 0 and served ${MAX_PAGE_SIZE} rows, the most a client may take; paginate, or name a page size`,
343
+ );
344
+ }
345
+ return MAX_PAGE_SIZE;
346
+ }
347
+
328
348
  static #getJobArgs(key: string, internalInfo: InternalInfo, job: AkanJob): unknown[] {
329
349
  const data = Array.isArray(job.data) ? (job.data as unknown[]) : job.data === undefined ? [] : [job.data];
330
350
  return internalInfo.args.map((arg, idx) =>
@@ -371,7 +391,7 @@ export class SignalResolver {
371
391
  .exec(async function (this: any, ...requestArgs: any) {
372
392
  const args = requestArgs.slice(0, argLength);
373
393
  const skip = resolvePageSkip(requestArgs[argLength]);
374
- const limit = resolvePageLimit(requestArgs[argLength + 1]);
394
+ const limit = SignalResolver.#clientPageLimit(listKey, requestArgs[argLength + 1]);
375
395
  const sort = requestArgs[argLength + 2] ?? "latest";
376
396
  const internalArgs = requestArgs.slice(argLength + 3);
377
397
  const query = assertSliceQuery(
@@ -437,41 +457,46 @@ export class SignalResolver {
437
457
  });
438
458
  });
439
459
 
440
- endpointObj[refName] = (builder as any)
441
- .query(cnst.full, { guards: sliceCls.getGuards })
442
- .param(`${refName}Id`, ID)
443
- .exec(async function (this: any, id: string) {
444
- return await this[serviceName][`get${capitalizedRefName}`](id);
445
- });
460
+ if (sliceCls.crud.get) {
461
+ endpointObj[refName] = (builder as any)
462
+ .query(cnst.full, { guards: sliceCls.getGuards })
463
+ .param(`${refName}Id`, ID)
464
+ .exec(async function (this: any, id: string) {
465
+ return await this[serviceName][`get${capitalizedRefName}`](id);
466
+ });
446
467
 
447
- endpointObj[`light${capitalizedRefName}`] = (builder as any)
448
- .query(cnst.light, { guards: sliceCls.getGuards })
449
- .param(`${refName}Id`, ID)
450
- .exec(async function (this: any, id: string) {
451
- return await this[serviceName][`get${capitalizedRefName}`](id);
452
- });
468
+ endpointObj[`light${capitalizedRefName}`] = (builder as any)
469
+ .query(cnst.light, { guards: sliceCls.getGuards })
470
+ .param(`${refName}Id`, ID)
471
+ .exec(async function (this: any, id: string) {
472
+ return await this[serviceName][`get${capitalizedRefName}`](id);
473
+ });
474
+ }
453
475
 
454
- endpointObj[`create${capitalizedRefName}`] = (builder as any)
455
- .mutation(cnst.full, { guards: sliceCls.createGuards })
456
- .body("data", cnst.input)
457
- .exec(async function (this: any, data: any) {
458
- return await this[serviceName].__create(data);
459
- });
476
+ if (sliceCls.crud.create)
477
+ endpointObj[`create${capitalizedRefName}`] = (builder as any)
478
+ .mutation(cnst.full, { guards: sliceCls.createGuards })
479
+ .body("data", cnst.input)
480
+ .exec(async function (this: any, data: any) {
481
+ return await this[serviceName].__create(data);
482
+ });
460
483
 
461
- endpointObj[`update${capitalizedRefName}`] = (builder as any)
462
- .mutation(cnst.full, { guards: sliceCls.updateGuards })
463
- .param(`${refName}Id`, ID)
464
- .body("data", cnst.input)
465
- .exec(async function (this: any, id: string, data: any) {
466
- return await this[serviceName].__update(id, data);
467
- });
484
+ if (sliceCls.crud.update)
485
+ endpointObj[`update${capitalizedRefName}`] = (builder as any)
486
+ .mutation(cnst.full, { guards: sliceCls.updateGuards })
487
+ .param(`${refName}Id`, ID)
488
+ .body("data", cnst.input)
489
+ .exec(async function (this: any, id: string, data: any) {
490
+ return await this[serviceName].__update(id, data);
491
+ });
468
492
 
469
- endpointObj[`remove${capitalizedRefName}`] = (builder as any)
470
- .mutation(cnst.full, { guards: sliceCls.removeGuards })
471
- .param(`${refName}Id`, ID)
472
- .exec(async function (this: any, id: string) {
473
- return await this[serviceName].__remove(id);
474
- });
493
+ if (sliceCls.crud.remove)
494
+ endpointObj[`remove${capitalizedRefName}`] = (builder as any)
495
+ .mutation(cnst.full, { guards: sliceCls.removeGuards })
496
+ .param(`${refName}Id`, ID)
497
+ .exec(async function (this: any, id: string) {
498
+ return await this[serviceName].__remove(id);
499
+ });
475
500
  return endpointObj;
476
501
  }) {}
477
502
  return SliceEndpoint;
@@ -600,6 +625,7 @@ export class SignalResolver {
600
625
  const routeOptions: NonNullable<SignalRoutes["routeOptions"]> = {};
601
626
  const wsRoutes: WebsocketRoutes = {};
602
627
  const defaultPrefix = endpointCls.srv.cnst?.refName;
628
+ SignalResolver.#warnUnguarded(endpointMeta);
603
629
  Object.entries(endpointMeta).forEach(([key, endpointInfo]) => {
604
630
  const path = endpointInfo.getRoutePath(key, defaultPrefix);
605
631
  if (endpointInfo.signalOption.globalPrefix !== undefined) {
@@ -627,7 +653,7 @@ export class SignalResolver {
627
653
  SignalResolver.#mountHttpRoute(
628
654
  routes,
629
655
  path,
630
- SignalResolver.#canUsePrimitiveQueryFastPath(endpointInfo, middleware)
656
+ SignalResolver.#canUsePrimitiveQueryFastPath(key, endpointInfo, middleware)
631
657
  ? {
632
658
  GET: async (req) => {
633
659
  if (req.headers.get("authorization") || cookieHeaderHasAuthToken(req.headers.get("cookie")))
@@ -733,6 +759,15 @@ export class SignalResolver {
733
759
  return { routes, wsRoutes, routeOptions };
734
760
  }
735
761
 
762
+ static #warnUnguarded(endpointMeta: { [key: string]: EndpointInfo }) {
763
+ const open = Object.entries(endpointMeta)
764
+ .filter(([, endpointInfo]) => !endpointInfo.signalOption.guards?.length)
765
+ .map(([key]) => key);
766
+ if (!open.length) return;
767
+ SignalResolver.logger.warn(
768
+ `${open.join(", ")} declare${open.length === 1 ? "s" : ""} no guards, so anyone can call ${open.length === 1 ? "it" : "them"} over HTTP and WebSocket. Name the guards, or \`guards: [Public]\` to keep one open on purpose.`,
769
+ );
770
+ }
736
771
  static #selectCache = new WeakMap<Cls, Record<string, true>>();
737
772
  static #selectForConstant(constant: Cls): Record<string, true> | undefined {
738
773
  const cached = SignalResolver.#selectCache.get(constant);
@@ -744,9 +779,14 @@ export class SignalResolver {
744
779
  return select;
745
780
  }
746
781
 
747
- static #canUsePrimitiveQueryFastPath(endpointInfo: EndpointInfo, middleware: Map<string, MiddlewareCls>) {
782
+ static #canUsePrimitiveQueryFastPath(
783
+ key: string,
784
+ endpointInfo: EndpointInfo,
785
+ middleware: Map<string, MiddlewareCls>,
786
+ ) {
748
787
  return (
749
788
  process.env.AKAN_TRACE !== "1" &&
789
+ !EndpointRateLimit.budgetOf(key, endpointInfo) &&
750
790
  endpointInfo.args.length === 0 &&
751
791
  endpointInfo.internalArgs.length === 0 &&
752
792
  (endpointInfo.signalOption.guards?.length ?? 0) === 0 &&
@@ -452,13 +452,13 @@ export class RscWorker {
452
452
 
453
453
  renderWithMeta(
454
454
  req: Request,
455
- options: { clientManifest?: ClientManifest; signal?: AbortSignal } = {},
455
+ options: { clientManifest?: ClientManifest; signal?: AbortSignal; clientIp?: string | null } = {},
456
456
  ): Promise<RscRenderResult> {
457
457
  const requestId = crypto.randomUUID();
458
458
  return createRscHostRenderStream({
459
459
  setPending: (pending) => this.#pending.set(requestId, pending),
460
460
  deletePending: () => this.#pending.delete(requestId),
461
- sendRenderOrQueue: () => this.#sendRenderOrQueue(requestId, req, options.clientManifest),
461
+ sendRenderOrQueue: () => this.#sendRenderOrQueue(requestId, req, options.clientManifest, options.clientIp),
462
462
  cancelRender: () => this.#cancelRender(requestId),
463
463
  signal: options.signal,
464
464
  onPendingChunkOverflow: () => {
@@ -851,14 +851,23 @@ export class RscWorker {
851
851
  this.#pending.delete(requestId);
852
852
  }
853
853
 
854
- #sendRenderOrQueue(requestId: string, req: Request, clientManifest?: ClientManifest): void {
854
+ /** The page request's headers as the worker sees them, `x-real-ip` being only the address the host resolved. */
855
+ static workerHeaders(req: Request, clientIp: string | null | undefined): Record<string, string> {
856
+ const headers: Record<string, string> = {};
857
+ req.headers.forEach((value, key) => {
858
+ headers[key] = value;
859
+ });
860
+
861
+ delete headers["x-real-ip"];
862
+ if (clientIp) headers["x-real-ip"] = clientIp;
863
+ return headers;
864
+ }
865
+
866
+ #sendRenderOrQueue(requestId: string, req: Request, clientManifest?: ClientManifest, clientIp?: string | null): void {
855
867
  const send = () => {
856
868
  if (!this.#pending.has(requestId)) return;
857
869
  try {
858
- const headers: Record<string, string> = {};
859
- req.headers.forEach((value, key) => {
860
- headers[key] = value;
861
- });
870
+ const headers = RscWorker.workerHeaders(req, clientIp);
862
871
  this.#proc.send({ type: "render", requestId, url: req.url, method: req.method, headers, clientManifest });
863
872
  } catch (err) {
864
873
  this.#resolvePending(requestId, (p) =>
@@ -13,6 +13,7 @@ import {
13
13
  import { type AkanRequestStore, createRequestStore, parseCookieHeader } from "akanjs/fetch";
14
14
  import type { AkanMetricsReport } from "akanjs/service";
15
15
  import type { PagePromptSource } from "../signal/mcp/pagePrompt";
16
+ import { SignalContext } from "../signal/signalContext";
16
17
  import {
17
18
  type BuilderRpc,
18
19
  type MergedManifest,
@@ -502,6 +503,7 @@ export class WebRouter {
502
503
  const result = await this.#rsc.renderWithMeta(rscReq, {
503
504
  clientManifest: manifest.clientManifest,
504
505
  signal: req.signal,
506
+ clientIp: SignalContext.clientIpOf(req),
505
507
  });
506
508
  if (result.type === "redirect")
507
509
  return createRscRedirectResponse(result.location, result.method, result.status);
@@ -613,6 +615,7 @@ export class WebRouter {
613
615
  const rscResult = await this.#rsc.renderWithMeta(req, {
614
616
  clientManifest: manifest.clientManifest,
615
617
  signal: req.signal,
618
+ clientIp: SignalContext.clientIpOf(req),
616
619
  });
617
620
  if (rscResult.type === "redirect")
618
621
  return Response.redirect(new URL(rscResult.location, url.origin), rscResult.status);
package/service/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./injectInfo";
5
5
  export * from "./ipcTypes";
6
6
  export * from "./liveSyncHub";
7
7
  export * from "./predefinedAdaptor";
8
+ export * from "./rateLimiter";
8
9
  export * from "./serve";
9
10
  export * from "./serviceModule";
10
11
  export * from "./serviceRegistry";
@@ -10,6 +10,7 @@ export * from "./logging.adaptor";
10
10
  export * from "./openaiDialect";
11
11
  export * from "./openaiLlm";
12
12
  export * from "./queue.adaptor";
13
+ export * from "./rateLimit.adaptor";
13
14
  export * from "./role.adaptor";
14
15
  export * from "./schedule.adaptor";
15
16
  export * from "./searchIndex";
@@ -0,0 +1,15 @@
1
+ import { adapt } from "../adapt";
2
+ import { type RateBudget, RateLimiter, type RateVerdict } from "../rateLimiter";
3
+ import { CacheAdaptorRole } from "./role.adaptor";
4
+
5
+ /** Counts a business key (a phone number, an email) across instances; unlike an endpoint's budget, no env turns it off. */
6
+ export class RateLimit extends adapt("rateLimit", ({ plug }) => ({
7
+ cache: plug(CacheAdaptorRole),
8
+ })) {
9
+ #limiter: RateLimiter | null = null;
10
+
11
+ async consume(key: string, budget: RateBudget): Promise<RateVerdict> {
12
+ this.#limiter ??= new RateLimiter(this.cache, { topic: "rateLimit" });
13
+ return await this.#limiter.consume(key, budget);
14
+ }
15
+ }