@meith/plugin-kit 0.15.0 → 0.17.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/package.json +3 -2
- package/src/hooks.ts +13 -1
- package/src/payloads.ts +4 -0
- package/src/rate-limit.ts +43 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/plugin-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "The SDK for writing a Meith plugin: typed manifests, hooks, routes, pages and migrations.",
|
|
5
5
|
"license": "LGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@meith/
|
|
23
|
+
"@meith/core": "^0.17.0",
|
|
24
|
+
"@meith/theme-kit": "^0.17.0"
|
|
24
25
|
},
|
|
25
26
|
"peerDependencies": {
|
|
26
27
|
"react": "^19.2.0"
|
package/src/hooks.ts
CHANGED
|
@@ -247,6 +247,16 @@ export const HOOKS = {
|
|
|
247
247
|
kind: 'filter',
|
|
248
248
|
purpose: 'The composer page’s model. The form itself is app-rendered and arrives as a region.',
|
|
249
249
|
},
|
|
250
|
+
'view.quick-reply': {
|
|
251
|
+
kind: 'filter',
|
|
252
|
+
purpose:
|
|
253
|
+
'The quick-reply island’s model, at the foot of a thread. The reply form itself is ' +
|
|
254
|
+
'app-rendered and arrives as `children`.',
|
|
255
|
+
},
|
|
256
|
+
'view.editor-toolbar': {
|
|
257
|
+
kind: 'filter',
|
|
258
|
+
purpose: 'The composer’s formatting-toolbar model — its buttons and the attachment picker.',
|
|
259
|
+
},
|
|
250
260
|
'view.redirect-notice': {
|
|
251
261
|
kind: 'filter',
|
|
252
262
|
purpose:
|
|
@@ -339,7 +349,9 @@ export const HOOKS = {
|
|
|
339
349
|
},
|
|
340
350
|
'poll.voted': {
|
|
341
351
|
kind: 'event',
|
|
342
|
-
purpose:
|
|
352
|
+
purpose:
|
|
353
|
+
'A vote was cast, once per option chosen. It fires again when a poll that allows ' +
|
|
354
|
+
're-voting takes a replacement.',
|
|
343
355
|
},
|
|
344
356
|
'rating.recorded': {
|
|
345
357
|
kind: 'event',
|
package/src/payloads.ts
CHANGED
|
@@ -45,6 +45,7 @@ import type {
|
|
|
45
45
|
BoardStatsModel,
|
|
46
46
|
CategoryBlockModel,
|
|
47
47
|
DiscoveryViewModel,
|
|
48
|
+
EditorToolbarModel,
|
|
48
49
|
ErrorNoticeModel,
|
|
49
50
|
FooterModel,
|
|
50
51
|
ForumDisplayModel,
|
|
@@ -64,6 +65,7 @@ import type {
|
|
|
64
65
|
PostActionsSlotModel,
|
|
65
66
|
PostBitSlotModel,
|
|
66
67
|
PostFormModel,
|
|
68
|
+
QuickReplyModel,
|
|
67
69
|
RedirectNoticeModel,
|
|
68
70
|
SearchFormModel,
|
|
69
71
|
SearchResultsModel,
|
|
@@ -200,7 +202,9 @@ export interface HookSignatures {
|
|
|
200
202
|
'view.subforum-list': { value: SubforumListModel; context: ViewerRef & ForumRef }
|
|
201
203
|
'view.forum-display': { value: ForumDisplayModel; context: ViewerRef & ForumRef }
|
|
202
204
|
'view.thread-view': { value: ThreadViewModel; context: ViewerRef & ThreadRef }
|
|
205
|
+
'view.quick-reply': { value: QuickReplyModel; context: ViewerRef & ThreadRef }
|
|
203
206
|
'view.post-form': { value: PostFormModel; context: ViewerRef }
|
|
207
|
+
'view.editor-toolbar': { value: EditorToolbarModel; context: ViewerRef }
|
|
204
208
|
'view.redirect-notice': { value: RedirectNoticeModel; context: ViewerRef }
|
|
205
209
|
|
|
206
210
|
/* ---- Posting ---- */
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,42 +1,66 @@
|
|
|
1
|
+
import { type RateLimitBucketStore, spendRateLimit } from '@meith/core'
|
|
2
|
+
|
|
1
3
|
export interface RateLimitVerdict {
|
|
2
4
|
readonly allowed: boolean
|
|
3
5
|
readonly retryAfterSeconds: number
|
|
4
6
|
}
|
|
5
7
|
|
|
6
8
|
export interface RouteRateLimiter {
|
|
7
|
-
consume(
|
|
9
|
+
consume(
|
|
10
|
+
key: string,
|
|
11
|
+
limit: number,
|
|
12
|
+
windowSeconds: number,
|
|
13
|
+
nowMs: number,
|
|
14
|
+
): Promise<RateLimitVerdict>
|
|
8
15
|
}
|
|
9
16
|
|
|
10
17
|
const SWEEP_EVERY = 512
|
|
18
|
+
const RETENTION_MS = 6 * 3600 * 1000
|
|
11
19
|
|
|
12
|
-
|
|
13
|
-
const
|
|
20
|
+
function memoryRateLimitBucketStore(): RateLimitBucketStore<string> {
|
|
21
|
+
const buckets = new Map<string, { start: number; used: number }>()
|
|
14
22
|
let calls = 0
|
|
15
23
|
|
|
24
|
+
const bucketKey = (key: string, windowStart: Date) => `${key}|${windowStart.getTime()}`
|
|
25
|
+
|
|
16
26
|
return {
|
|
17
|
-
|
|
27
|
+
async spend(key, windowStart, cost) {
|
|
18
28
|
calls += 1
|
|
19
|
-
const windowMs = windowSeconds * 1000
|
|
20
|
-
|
|
21
29
|
if (calls % SWEEP_EVERY === 0) {
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
const cutoff = windowStart.getTime() - RETENTION_MS
|
|
31
|
+
for (const [id, bucket] of buckets) {
|
|
32
|
+
if (bucket.start < cutoff) buckets.delete(id)
|
|
24
33
|
}
|
|
25
34
|
}
|
|
26
35
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
const id = bucketKey(key, windowStart)
|
|
37
|
+
const next = (buckets.get(id)?.used ?? 0) + cost
|
|
38
|
+
buckets.set(id, { start: windowStart.getTime(), used: next })
|
|
39
|
+
return next
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async peek(key, windowStart) {
|
|
43
|
+
return buckets.get(bucketKey(key, windowStart))?.used ?? 0
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
32
47
|
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
export function createRouteRateLimiter(): RouteRateLimiter {
|
|
49
|
+
const store = memoryRateLimitBucketStore()
|
|
35
50
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
return {
|
|
52
|
+
async consume(key, limit, windowSeconds, nowMs) {
|
|
53
|
+
const outcome = await spendRateLimit(
|
|
54
|
+
store,
|
|
55
|
+
key,
|
|
56
|
+
1,
|
|
57
|
+
{ seconds: windowSeconds, budget: limit },
|
|
58
|
+
new Date(nowMs),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return outcome.allowed
|
|
62
|
+
? { allowed: true, retryAfterSeconds: 0 }
|
|
63
|
+
: { allowed: false, retryAfterSeconds: outcome.resetSeconds }
|
|
40
64
|
},
|
|
41
65
|
}
|
|
42
66
|
}
|