@meith/plugin-kit 0.15.0 → 0.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/plugin-kit",
3
- "version": "0.15.0",
3
+ "version": "0.16.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/theme-kit": "^0.15.0"
23
+ "@meith/core": "^0.16.0",
24
+ "@meith/theme-kit": "^0.16.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: 'A vote was cast. Fires once; the database enforces one per member.',
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(key: string, limit: number, windowSeconds: number, nowMs: number): RateLimitVerdict
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
- export function createRouteRateLimiter(): RouteRateLimiter {
13
- const windows = new Map<string, { start: number; count: number }>()
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
- consume(key, limit, windowSeconds, nowMs) {
27
+ async spend(key, windowStart, cost) {
18
28
  calls += 1
19
- const windowMs = windowSeconds * 1000
20
-
21
29
  if (calls % SWEEP_EVERY === 0) {
22
- for (const [stale, entry] of windows) {
23
- if (nowMs - entry.start >= windowMs) windows.delete(stale)
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 entry = windows.get(key)
28
- if (entry === undefined || nowMs - entry.start >= windowMs) {
29
- windows.set(key, { start: nowMs, count: 1 })
30
- return { allowed: true, retryAfterSeconds: 0 }
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
- entry.count += 1
34
- if (entry.count <= limit) return { allowed: true, retryAfterSeconds: 0 }
48
+ export function createRouteRateLimiter(): RouteRateLimiter {
49
+ const store = memoryRateLimitBucketStore()
35
50
 
36
- return {
37
- allowed: false,
38
- retryAfterSeconds: Math.max(1, Math.ceil((entry.start + windowMs - nowMs) / 1000)),
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
  }