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.
- package/CHANGELOG.md +3670 -0
- package/base/base.ts +1 -1
- package/client/cookie.ts +4 -6
- package/client/csrTypes.ts +2 -2
- package/common/Logger.ts +10 -0
- package/common/index.ts +0 -1
- package/common/routeConvention.ts +1 -9
- package/dictionary/base.dictionary.ts +7 -0
- package/dictionary/trans.ts +7 -1
- package/document/documentQuery.ts +6 -1
- package/document/index.ts +1 -0
- package/document/into.ts +15 -7
- package/document/liveRowScope.ts +79 -0
- package/fetch/client/fetchClient.ts +64 -12
- package/fetch/client/httpClient.ts +13 -4
- package/index.ts +22 -0
- package/package.json +1 -1
- package/server/akanOption.ts +20 -1
- package/server/akanServer.ts +5 -0
- package/server/cachePolicy.ts +12 -2
- package/server/crawler.ts +6 -0
- package/server/head.tsx +0 -4
- package/server/mcp/McpAuth.ts +3 -2
- package/server/mcp/McpExecutionContext.ts +15 -14
- package/server/mcp/McpRateLimiter.ts +18 -67
- package/server/proxy/webProxyRunner.ts +2 -0
- package/server/resolver/database.resolver.ts +28 -13
- package/server/resolver/signal.resolver.ts +75 -35
- package/server/robots.ts +1 -23
- package/server/routeTreeBuilder.ts +4 -2
- package/server/rscWorker.tsx +12 -24
- package/server/rscWorkerHost.ts +16 -7
- package/server/ssrFromRscRenderer.tsx +19 -15
- package/server/ssrTypes.ts +9 -0
- package/server/webRouter.ts +9 -2
- package/service/index.ts +1 -0
- package/service/predefinedAdaptor/index.ts +1 -0
- package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
- package/service/predefinedAdaptor/sql/types.ts +14 -12
- package/service/rateLimiter.ts +80 -0
- package/signal/base.signal.ts +7 -6
- package/signal/endpointRateLimit.ts +159 -0
- package/signal/index.ts +1 -0
- package/signal/middleware.ts +6 -5
- package/signal/serializer/fetch.serializer.ts +15 -4
- package/signal/signalContext.ts +148 -67
- package/signal/slice.ts +30 -9
- package/signal/types.ts +8 -0
- package/test/signalTestRuntime.ts +2 -1
- package/types/base/base.d.ts +1 -1
- package/types/client/cookie.d.ts +1 -1
- package/types/client/csrTypes.d.ts +2 -2
- package/types/common/Logger.d.ts +2 -0
- package/types/common/index.d.ts +1 -1
- package/types/common/routeConvention.d.ts +0 -2
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/documentQuery.d.ts +5 -1
- package/types/document/index.d.ts +1 -0
- package/types/document/into.d.ts +7 -7
- package/types/document/liveRowScope.d.ts +9 -0
- package/types/fetch/client/fetchClient.d.ts +9 -1
- package/types/index.d.ts +32 -0
- package/types/server/akanOption.d.ts +7 -1
- package/types/server/cachePolicy.d.ts +2 -1
- package/types/server/crawler.d.ts +1 -0
- package/types/server/head.d.ts +0 -3
- package/types/server/mcp/McpExecutionContext.d.ts +4 -1
- package/types/server/mcp/McpRateLimiter.d.ts +2 -6
- package/types/server/rscWorkerHost.d.ts +3 -0
- package/types/server/ssrFromRscRenderer.d.ts +3 -1
- package/types/server/ssrTypes.d.ts +8 -0
- package/types/service/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
- package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
- package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
- package/types/service/rateLimiter.d.ts +24 -0
- package/types/signal/endpointRateLimit.d.ts +37 -0
- package/types/signal/index.d.ts +1 -0
- package/types/signal/signalContext.d.ts +33 -15
- package/types/signal/slice.d.ts +17 -5
- package/types/signal/types.d.ts +8 -0
- package/types/test/signalTestRuntime.d.ts +1 -1
- package/types/ui/recipe/badgeRecipe.d.ts +2 -2
- package/types/ui/recipe/buttonRecipe.d.ts +4 -4
- package/types/ui/recipe/inputRecipe.d.ts +3 -3
- package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
- package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
- package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
- package/types/webkit/RenderFailure.d.ts +6 -0
- package/types/webkit/useFrameRuntime.d.ts +0 -1
- package/ui/Input.tsx +7 -1
- package/ui/recipe/buttonRecipe.ts +1 -0
- package/ui/recipe/inputRecipe.ts +13 -7
- package/ui/styles.css +1 -0
- package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
- package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
- package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
- package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
- package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/addons.ts +2 -1
- package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/exec.ts +11 -1
- package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
- package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
- package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
- package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
- package/vendor/use-agentic/CHANGELOG.md +663 -0
- package/webkit/CsrRouteTable.ts +0 -2
- package/webkit/RenderFailure.tsx +32 -0
- package/webkit/RenderLayer.tsx +22 -2
- package/webkit/useCsrValues.ts +21 -11
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
DocumentQuery,
|
|
6
6
|
DocumentQueryNode,
|
|
7
7
|
DocumentSchema,
|
|
8
|
+
DocumentScopeOptions,
|
|
8
9
|
DocumentUpdateInput,
|
|
9
10
|
DocumentUpdateOperator,
|
|
10
11
|
DocumentUpdateOptions,
|
|
@@ -65,6 +66,7 @@ export interface DocumentStore {
|
|
|
65
66
|
updateManyByQuery(
|
|
66
67
|
query: DocumentQuery,
|
|
67
68
|
update: DocumentUpdateInput,
|
|
69
|
+
options?: DocumentScopeOptions,
|
|
68
70
|
): Promise<{ acknowledged: boolean; matchedCount: number; modifiedCount: number }>;
|
|
69
71
|
removeManyByQuery(
|
|
70
72
|
query: DocumentQuery,
|
|
@@ -76,19 +78,13 @@ export interface DocumentStore {
|
|
|
76
78
|
operations: { updateOne: { filter: DocumentQuery; update: DocumentUpdateInput; upsert?: boolean } }[],
|
|
77
79
|
): Promise<{ acknowledged: boolean; matchedCount: number; modifiedCount: number; upsertedId: string | null }>;
|
|
78
80
|
find(query?: DocumentQuery, options?: FindManyOptions): Promise<any[]>;
|
|
79
|
-
findIds(
|
|
80
|
-
query?: DocumentQuery,
|
|
81
|
-
options?: { sort?: SortOption; skip?: number | null; limit?: number | null; sample?: number },
|
|
82
|
-
): Promise<string[]>;
|
|
81
|
+
findIds(query?: DocumentQuery, options?: Omit<FindManyOptions, "select">): Promise<string[]>;
|
|
83
82
|
findOne(query?: DocumentQuery, options?: FindOneOptions): Promise<any | null>;
|
|
84
|
-
findId(
|
|
85
|
-
query?: DocumentQuery,
|
|
86
|
-
options?: { sort?: SortOption; skip?: number | null; sample?: boolean },
|
|
87
|
-
): Promise<string | null>;
|
|
83
|
+
findId(query?: DocumentQuery, options?: FindIdOptions): Promise<string | null>;
|
|
88
84
|
pickOne(query?: DocumentQuery, options?: FindOneOptions): Promise<any>;
|
|
89
85
|
pickById(id: string): Promise<any>;
|
|
90
|
-
exists(query?: DocumentQuery): Promise<string | null>;
|
|
91
|
-
count(query?: DocumentQuery): Promise<number>;
|
|
86
|
+
exists(query?: DocumentQuery, options?: DocumentScopeOptions): Promise<string | null>;
|
|
87
|
+
count(query?: DocumentQuery, options?: DocumentScopeOptions): Promise<number>;
|
|
92
88
|
insight(query?: DocumentQuery): Promise<any>;
|
|
93
89
|
hydrate(data: DocumentRecord, originalData?: DocumentRecord, options?: { track?: boolean }): any;
|
|
94
90
|
/** A document as text that another process turns back into the same document. */
|
|
@@ -156,14 +152,20 @@ export type MutableDocumentRecord = Record<string, unknown>;
|
|
|
156
152
|
export type FieldMap = Record<string, { getProps: () => Record<string, unknown>; [key: string]: unknown }>;
|
|
157
153
|
export type SortOption = Record<string, 1 | -1> | null | undefined;
|
|
158
154
|
export type ProjectionOption = Partial<Record<string, boolean>> | null | undefined;
|
|
159
|
-
export type FindManyOptions = {
|
|
155
|
+
export type FindManyOptions = DocumentScopeOptions & {
|
|
160
156
|
sort?: SortOption;
|
|
161
157
|
skip?: number | null;
|
|
162
158
|
limit?: number | null;
|
|
163
159
|
sample?: number;
|
|
164
160
|
select?: ProjectionOption;
|
|
165
161
|
};
|
|
166
|
-
export type FindOneOptions =
|
|
162
|
+
export type FindOneOptions = DocumentScopeOptions & {
|
|
163
|
+
sort?: SortOption;
|
|
164
|
+
skip?: number | null;
|
|
165
|
+
sample?: boolean;
|
|
166
|
+
select?: ProjectionOption;
|
|
167
|
+
};
|
|
168
|
+
export type FindIdOptions = DocumentScopeOptions & { sort?: SortOption; skip?: number | null; sample?: boolean };
|
|
167
169
|
export type WriteHookOptions = { runSaveHooks?: boolean; crudType?: "update" | "remove" };
|
|
168
170
|
export type QueryOperatorName = Extract<DocumentQueryNode, { kind: "op" }>["op"];
|
|
169
171
|
export interface SqliteDocumentRow {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type Dayjs, dayjs } from "akanjs/base";
|
|
2
|
+
import { Logger } from "akanjs/common";
|
|
3
|
+
|
|
4
|
+
export interface RateCounter {
|
|
5
|
+
incr(topic: string, key: string, by?: number, option?: { expireAt?: Dayjs }): Promise<number>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RateBudget {
|
|
9
|
+
calls: number;
|
|
10
|
+
windowMs: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RateVerdict = { ok: true } | { ok: false; retryAfterMs: number };
|
|
14
|
+
|
|
15
|
+
interface RateWindow {
|
|
16
|
+
start: number;
|
|
17
|
+
count: number;
|
|
18
|
+
windowMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class RateLimiter {
|
|
22
|
+
static readonly #logger = new Logger("RateLimiter");
|
|
23
|
+
static readonly #sweepEveryMs = 60_000;
|
|
24
|
+
|
|
25
|
+
readonly #topic: string;
|
|
26
|
+
readonly #shared: RateCounter | null;
|
|
27
|
+
readonly #windows = new Map<string, RateWindow>();
|
|
28
|
+
#sweptAt = 0;
|
|
29
|
+
#sharedFailed = false;
|
|
30
|
+
|
|
31
|
+
constructor(shared: RateCounter | null = null, { topic = "rate" }: { topic?: string } = {}) {
|
|
32
|
+
this.#shared = shared;
|
|
33
|
+
this.#topic = topic;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get isShared() {
|
|
37
|
+
return this.#shared !== null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async consume(key: string, budget: RateBudget, now = Date.now()): Promise<RateVerdict> {
|
|
41
|
+
const calls = Math.max(1, Math.floor(budget.calls));
|
|
42
|
+
const windowMs = Math.max(1, Math.floor(budget.windowMs));
|
|
43
|
+
this.#sweep(now);
|
|
44
|
+
if (this.#shared) {
|
|
45
|
+
const window = Math.floor(now / windowMs);
|
|
46
|
+
const end = (window + 1) * windowMs;
|
|
47
|
+
try {
|
|
48
|
+
const count = await this.#shared.incr(this.#topic, `${key}:${windowMs}:${window}`, 1, {
|
|
49
|
+
expireAt: dayjs(end + windowMs),
|
|
50
|
+
});
|
|
51
|
+
this.#sharedFailed = false;
|
|
52
|
+
return count > calls ? { ok: false, retryAfterMs: Math.max(1, end - now) } : { ok: true };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!this.#sharedFailed)
|
|
55
|
+
RateLimiter.#logger.warn(
|
|
56
|
+
`Rate limit "${this.#topic}" cannot reach the cache, so each instance counts its own window until it can: ${String(error)}`,
|
|
57
|
+
);
|
|
58
|
+
this.#sharedFailed = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const bucket = this.#windowOf(`${key}:${windowMs}`, windowMs, now);
|
|
62
|
+
if (bucket.count >= calls) return { ok: false, retryAfterMs: Math.max(1, bucket.start + windowMs - now) };
|
|
63
|
+
bucket.count += 1;
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#windowOf(key: string, windowMs: number, now: number) {
|
|
68
|
+
const found = this.#windows.get(key);
|
|
69
|
+
if (found && now - found.start < windowMs) return found;
|
|
70
|
+
const bucket: RateWindow = { start: now, count: 0, windowMs };
|
|
71
|
+
this.#windows.set(key, bucket);
|
|
72
|
+
return bucket;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#sweep(now: number) {
|
|
76
|
+
if (now - this.#sweptAt < RateLimiter.#sweepEveryMs) return;
|
|
77
|
+
this.#sweptAt = now;
|
|
78
|
+
for (const [key, bucket] of this.#windows) if (now - bucket.start >= bucket.windowMs) this.#windows.delete(key);
|
|
79
|
+
}
|
|
80
|
+
}
|
package/signal/base.signal.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { FetchClient } from "akanjs/fetch";
|
|
|
2
2
|
import { srv } from "akanjs/service";
|
|
3
3
|
import { agent } from "./agent.signal";
|
|
4
4
|
import { endpoint } from "./endpoint";
|
|
5
|
+
import { Public } from "./guards";
|
|
5
6
|
import { internal } from "./internal";
|
|
6
7
|
import { serverSignal } from "./serverSignal";
|
|
7
8
|
import { SignalRegistry } from "./signalRegistry";
|
|
@@ -9,20 +10,20 @@ import { SignalRegistry } from "./signalRegistry";
|
|
|
9
10
|
export class BaseInternal extends internal(srv.base, ({ interval, cron }) => ({})) {}
|
|
10
11
|
|
|
11
12
|
export class BaseEndpoint extends endpoint(srv.base, ({ query, mutation, message, pubsub }) => ({
|
|
12
|
-
ping: query(String, { cache: 3000 }).exec(() => "ping"),
|
|
13
|
-
pingBody: mutation(String)
|
|
13
|
+
ping: query(String, { cache: 3000, guards: [Public], mcp: false }).exec(() => "ping"),
|
|
14
|
+
pingBody: mutation(String, { guards: [Public], mcp: false })
|
|
14
15
|
.body("data", String)
|
|
15
16
|
.exec((data) => `pingBody: ${data}`),
|
|
16
|
-
pingParam: query(String, { cache: 10000 })
|
|
17
|
+
pingParam: query(String, { cache: 10000, guards: [Public], mcp: false })
|
|
17
18
|
.param("id", String)
|
|
18
19
|
.exec((id) => `pingParam: ${id}`),
|
|
19
|
-
pingQuery: query(String, { nullable: true })
|
|
20
|
+
pingQuery: query(String, { nullable: true, guards: [Public], mcp: false })
|
|
20
21
|
.search("id", String)
|
|
21
22
|
.exec((id) => `pingQuery: ${id}`),
|
|
22
|
-
wsPing: message(String)
|
|
23
|
+
wsPing: message(String, { guards: [Public], mcp: false })
|
|
23
24
|
.msg("data", String, { nullable: true })
|
|
24
25
|
.exec((data) => `wsPing: ${data}`),
|
|
25
|
-
pubsubPing: pubsub(String).exec(() => {
|
|
26
|
+
pubsubPing: pubsub(String, { guards: [Public], mcp: false }).exec(() => {
|
|
26
27
|
}),
|
|
27
28
|
})) {}
|
|
28
29
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Logger } from "akanjs/common";
|
|
2
|
+
import { CacheAdaptorRole, type RateCounter, RateLimiter } from "akanjs/service";
|
|
3
|
+
import type { EndpointInfo } from "./endpointInfo";
|
|
4
|
+
import { Exception } from "./exception";
|
|
5
|
+
import type { SignalContext } from "./signalContext";
|
|
6
|
+
|
|
7
|
+
export interface RateLimitBudget {
|
|
8
|
+
calls: number;
|
|
9
|
+
/** 60_000 when unset. */
|
|
10
|
+
windowMs?: number;
|
|
11
|
+
/** `ip` (default) counts the caller's address; `account` counts `accountKey(account)`, else the address. */
|
|
12
|
+
by?: "ip" | "account";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RateLimitSetting {
|
|
16
|
+
/** `false` lets every budget stand aside, as for a deployment rate-limited at its edge. */
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
/** The budget of every endpoint that declares none; unset leaves those unlimited. */
|
|
19
|
+
budget?: RateLimitBudget;
|
|
20
|
+
/** By endpoint key, over what the endpoint declares (a lib's included); `false` exempts it. */
|
|
21
|
+
endpoints?: { [key: string]: Partial<RateLimitBudget> | false };
|
|
22
|
+
/** Who `by: "account"` counts; nothing returned counts the caller's address instead. */
|
|
23
|
+
accountKey?: (account: unknown) => string | null | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ResolvedBudget = Required<RateLimitBudget>;
|
|
27
|
+
|
|
28
|
+
export class EndpointRateLimit {
|
|
29
|
+
static readonly #logger = new Logger("EndpointRateLimit");
|
|
30
|
+
static readonly #defaultWindowMs = 60_000;
|
|
31
|
+
static #enabled: boolean | undefined;
|
|
32
|
+
static #budget: RateLimitBudget | null = null;
|
|
33
|
+
static #endpoints: NonNullable<RateLimitSetting["endpoints"]> = {};
|
|
34
|
+
static #accountKey: NonNullable<RateLimitSetting["accountKey"]> = (account) => EndpointRateLimit.#claimOf(account);
|
|
35
|
+
static readonly #limiters = new WeakMap<object, RateLimiter>();
|
|
36
|
+
static #localLimiter: RateLimiter | null = null;
|
|
37
|
+
static readonly #warned = new Set<string>();
|
|
38
|
+
|
|
39
|
+
/** Merges over what an earlier call set, so libs configure in mount order and the app's `option.ts` goes last. */
|
|
40
|
+
static configure(setting: RateLimitSetting | false) {
|
|
41
|
+
if (setting === false) {
|
|
42
|
+
EndpointRateLimit.#enabled = false;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (setting.enabled !== undefined) EndpointRateLimit.#enabled = setting.enabled;
|
|
46
|
+
if (setting.budget) EndpointRateLimit.#budget = setting.budget;
|
|
47
|
+
if (setting.endpoints) EndpointRateLimit.#endpoints = { ...EndpointRateLimit.#endpoints, ...setting.endpoints };
|
|
48
|
+
if (setting.accountKey) EndpointRateLimit.#accountKey = setting.accountKey;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `off` wins over the code, `on` turns budgets on in a local environment, and `<calls>[/<seconds>]` is the default
|
|
53
|
+
* budget wherever the code names none. Unset, budgets apply everywhere but `local`.
|
|
54
|
+
*/
|
|
55
|
+
static applyEnv(value: string | undefined, environment: string) {
|
|
56
|
+
const raw = value?.trim().toLowerCase() ?? "";
|
|
57
|
+
if (raw === "off" || raw === "false" || raw === "0") {
|
|
58
|
+
EndpointRateLimit.#enabled = false;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const [calls, seconds] = raw.split("/").map((part) => Number(part));
|
|
62
|
+
const budget = Number.isInteger(calls) && calls > 0 ? { calls, windowMs: (seconds || 60) * 1000 } : null;
|
|
63
|
+
if (budget && !EndpointRateLimit.#budget) EndpointRateLimit.#budget = budget;
|
|
64
|
+
const switchedOn = raw === "on" || raw === "true" || raw === "1" || !!budget;
|
|
65
|
+
EndpointRateLimit.#enabled ??= switchedOn || environment !== "local";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static reset() {
|
|
69
|
+
EndpointRateLimit.#enabled = undefined;
|
|
70
|
+
EndpointRateLimit.#budget = null;
|
|
71
|
+
EndpointRateLimit.#endpoints = {};
|
|
72
|
+
EndpointRateLimit.#accountKey = (account) => EndpointRateLimit.#claimOf(account);
|
|
73
|
+
EndpointRateLimit.#localLimiter = null;
|
|
74
|
+
EndpointRateLimit.#warned.clear();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static budgetOf(key: string, endpointInfo: EndpointInfo): ResolvedBudget | null {
|
|
78
|
+
if (EndpointRateLimit.#enabled === false) return null;
|
|
79
|
+
const override = EndpointRateLimit.#endpoints[key];
|
|
80
|
+
if (override === false) return null;
|
|
81
|
+
const declared = endpointInfo.signalOption.rateLimit;
|
|
82
|
+
const base = declared === false ? null : (declared ?? EndpointRateLimit.#budget);
|
|
83
|
+
const merged = override ? { ...base, ...override } : base;
|
|
84
|
+
const calls = Math.floor(Number(merged?.calls));
|
|
85
|
+
if (!merged || !(calls >= 1)) return null;
|
|
86
|
+
const windowMs = Math.floor(Number(merged.windowMs ?? EndpointRateLimit.#defaultWindowMs));
|
|
87
|
+
return {
|
|
88
|
+
calls,
|
|
89
|
+
windowMs: windowMs >= 1 ? windowMs : EndpointRateLimit.#defaultWindowMs,
|
|
90
|
+
by: merged.by === "account" ? "account" : "ip",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `ip` runs before the request body is read; `account` after the middlewares resolved the caller. */
|
|
95
|
+
static async admit(context: SignalContext, phase: "ip" | "account") {
|
|
96
|
+
const budget = EndpointRateLimit.budgetOf(context.key, context.endpointInfo);
|
|
97
|
+
if (!budget || budget.by !== phase) return;
|
|
98
|
+
const caller =
|
|
99
|
+
(phase === "account" ? EndpointRateLimit.#accountOf(context) : null) ?? EndpointRateLimit.#ipOf(context);
|
|
100
|
+
const verdict = await EndpointRateLimit.#limiterOf(context).consume(`${context.key}:${caller}`, budget);
|
|
101
|
+
if (verdict.ok) return;
|
|
102
|
+
EndpointRateLimit.#warnOnce(
|
|
103
|
+
`refused:${context.key}`,
|
|
104
|
+
`"${context.key}" refused a caller over its budget of ${budget.calls} calls / ${Math.round(budget.windowMs / 1000)}s by ${budget.by}; later refusals are not logged.`,
|
|
105
|
+
);
|
|
106
|
+
throw new Exception(429, "base.error.tooManyRequests", undefined, {
|
|
107
|
+
seconds: Math.max(1, Math.ceil(verdict.retryAfterMs / 1000)),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static #accountOf(context: SignalContext) {
|
|
112
|
+
const key = EndpointRateLimit.#accountKey(context.get("account"));
|
|
113
|
+
return typeof key === "string" && key ? `account:${key}` : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static #ipOf(context: SignalContext) {
|
|
117
|
+
const ip = context.getClientIp();
|
|
118
|
+
if (ip) return `ip:${ip}`;
|
|
119
|
+
EndpointRateLimit.#warnOnce(
|
|
120
|
+
"unknown-ip",
|
|
121
|
+
`Rate limit cannot resolve the caller's address (first on "${context.key}"), so every such caller shares one budget. Check that the proxy in front records X-Real-IP or X-Forwarded-For and is trusted (AKAN_TRUSTED_PROXIES).`,
|
|
122
|
+
);
|
|
123
|
+
return "ip:unknown";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static #limiterOf(context: SignalContext) {
|
|
127
|
+
const cache = EndpointRateLimit.#cacheOf(context);
|
|
128
|
+
if (!cache) {
|
|
129
|
+
EndpointRateLimit.#localLimiter ??= new RateLimiter(null, { topic: "endpointRate" });
|
|
130
|
+
return EndpointRateLimit.#localLimiter;
|
|
131
|
+
}
|
|
132
|
+
const found = EndpointRateLimit.#limiters.get(cache);
|
|
133
|
+
if (found) return found;
|
|
134
|
+
const limiter = new RateLimiter(cache, { topic: "endpointRate" });
|
|
135
|
+
EndpointRateLimit.#limiters.set(cache, limiter);
|
|
136
|
+
return limiter;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
static #cacheOf(context: SignalContext): (RateCounter & object) | null {
|
|
140
|
+
try {
|
|
141
|
+
return context.getAdaptor(CacheAdaptorRole) as unknown as RateCounter & object;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
static #claimOf(account: unknown) {
|
|
148
|
+
if (!account || typeof account !== "object") return null;
|
|
149
|
+
const { id, sub, sid } = account as { id?: unknown; sub?: unknown; sid?: unknown };
|
|
150
|
+
const claim = [id, sub, sid].find((value) => typeof value === "string" && value);
|
|
151
|
+
return typeof claim === "string" ? claim : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
static #warnOnce(key: string, message: string) {
|
|
155
|
+
if (EndpointRateLimit.#warned.has(key)) return;
|
|
156
|
+
EndpointRateLimit.#warned.add(key);
|
|
157
|
+
EndpointRateLimit.#logger.warn(message);
|
|
158
|
+
}
|
|
159
|
+
}
|
package/signal/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./base.signal";
|
|
|
7
7
|
export { CrossSiteGuard, type CrossSiteOption } from "./CrossSiteGuard";
|
|
8
8
|
export * from "./endpoint";
|
|
9
9
|
export * from "./endpointInfo";
|
|
10
|
+
export * from "./endpointRateLimit";
|
|
10
11
|
export * from "./exception";
|
|
11
12
|
export * from "./guard";
|
|
12
13
|
export * from "./guards";
|
package/signal/middleware.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BackendEnv, Cls, PromiseOrObject } from "akanjs/base";
|
|
2
2
|
import { Logger } from "akanjs/common";
|
|
3
|
-
import { Exception } from "./exception";
|
|
3
|
+
import { Exception, isExceptionLike } from "./exception";
|
|
4
4
|
import type { SignalContext } from "./signalContext";
|
|
5
5
|
|
|
6
6
|
export interface Middleware<Env extends BackendEnv = BackendEnv> {
|
|
@@ -34,10 +34,11 @@ export class Logging extends middleware("logging") {
|
|
|
34
34
|
}
|
|
35
35
|
return result;
|
|
36
36
|
} catch (error) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
const line = `Error ${context.endpointInfo.type}-${context.key} / ${Date.now() - start}ms: ${String(error)}`;
|
|
38
|
+
|
|
39
|
+
if (isExceptionLike(error) && error.statusCode === 429) {
|
|
40
|
+
if (debug) context.adaptor.logger.debug(line);
|
|
41
|
+
} else context.adaptor.logger.error(line);
|
|
41
42
|
throw error;
|
|
42
43
|
}
|
|
43
44
|
};
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
EndpointArgProps,
|
|
9
9
|
EndpointCls,
|
|
10
10
|
EndpointInfo,
|
|
11
|
+
ResolvedSliceCrud,
|
|
11
12
|
SerializedArg,
|
|
12
13
|
SerializedEndpoint,
|
|
13
14
|
SerializedFilter,
|
|
@@ -157,6 +158,7 @@ export class FetchSerializer {
|
|
|
157
158
|
sliceCls.removeGuards !== sliceCls.cruGuards,
|
|
158
159
|
),
|
|
159
160
|
...FetchSerializer.#serializeSliceMcp(sliceCls),
|
|
161
|
+
...FetchSerializer.#serializeSliceCrud(sliceCls),
|
|
160
162
|
...FetchSerializer.#serializeSliceAgents(sliceCls),
|
|
161
163
|
endpoint: FetchSerializer.#serializeEndpoints(endpointCls),
|
|
162
164
|
};
|
|
@@ -186,12 +188,21 @@ export class FetchSerializer {
|
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
static #serializeSliceMcp(sliceCls: SliceCls): { mcp?: SerializedSignalMcp } {
|
|
189
|
-
const mcp =
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
const mcp = FetchSerializer.#offVerbs(sliceCls.mcp);
|
|
192
|
+
return Object.keys(mcp).length ? { mcp } : {};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
static #serializeSliceCrud(sliceCls: SliceCls): { crud?: SerializedSignalMcp } {
|
|
196
|
+
const crud = FetchSerializer.#offVerbs(sliceCls.crud);
|
|
197
|
+
return Object.keys(crud).length ? { crud } : {};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
static #offVerbs(verbs: ResolvedSliceCrud | undefined): SerializedSignalMcp {
|
|
201
|
+
return Object.fromEntries(
|
|
202
|
+
Object.entries(verbs ?? {})
|
|
203
|
+
.filter(([, on]) => !on)
|
|
192
204
|
.map(([verb]) => [verb, false]),
|
|
193
205
|
) as SerializedSignalMcp;
|
|
194
|
-
return Object.keys(mcp).length ? { mcp } : {};
|
|
195
206
|
}
|
|
196
207
|
|
|
197
208
|
static serializeServiceSignal(endpointCls: EndpointCls): SerializedSignal {
|