akanjs 3.0.1 → 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 +11 -0
- package/base/base.ts +1 -1
- package/client/cookie.ts +4 -6
- package/common/Logger.ts +10 -0
- 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/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/rscWorkerHost.ts +16 -7
- package/server/webRouter.ts +3 -0
- 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/common/Logger.d.ts +2 -0
- 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/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/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/buttonRecipe.d.ts +2 -2
- 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/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/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/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/webkit/RenderFailure.tsx +32 -0
- package/webkit/RenderLayer.tsx +22 -2
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import { Logger } from "akanjs/common";
|
|
1
|
+
import { type RateCounter, RateLimiter } from "akanjs/service";
|
|
3
2
|
|
|
4
|
-
export
|
|
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 #
|
|
34
|
-
readonly #
|
|
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.#
|
|
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.#
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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${
|
|
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 = (
|
|
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
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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?:
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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(
|
|
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 &&
|
package/server/rscWorkerHost.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) =>
|
package/server/webRouter.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|