@stacksjs/defaults 0.75.63 → 0.75.64
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/app/Middleware/Throttle.ts +52 -13
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -1,13 +1,40 @@
|
|
|
1
|
-
import { createRateLimitMiddleware, Middleware, parseThrottleString } from '@stacksjs/router'
|
|
1
|
+
import { clientAddress, createRateLimitMiddleware, Middleware, parseThrottleString } from '@stacksjs/router'
|
|
2
2
|
|
|
3
3
|
// Cache for rate limiters to avoid creating new instances for each request
|
|
4
4
|
const limiterCache = new Map<string, ReturnType<typeof createRateLimitMiddleware>>()
|
|
5
5
|
|
|
6
|
+
const RATE_LIMIT_HEADERS = ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'] as const
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whose budget a request spends.
|
|
10
|
+
*
|
|
11
|
+
* A signed-in request spends its user's: the Auth middleware stamps
|
|
12
|
+
* `_authenticatedUser`, and Throttle runs after it (priority 2 to Auth's 1),
|
|
13
|
+
* so people sharing an address - an office, a phone carrier's NAT, every test
|
|
14
|
+
* user on 127.0.0.1 - do not exhaust each other. bun-router's own default
|
|
15
|
+
* reads `request.user.id`, which on a Stacks request is the async `user()`
|
|
16
|
+
* accessor, so it never saw a user and every request fell through to an
|
|
17
|
+
* address.
|
|
18
|
+
*
|
|
19
|
+
* Anything else spends its client address's, as `clientAddress()` resolves
|
|
20
|
+
* it: the socket peer, or - only when that peer is a trusted proxy - the
|
|
21
|
+
* forwarding headers it vouches for (`CF-Connecting-IP` only when the hop
|
|
22
|
+
* that delivered the request is Cloudflare). A client-written
|
|
23
|
+
* `X-Forwarded-For` no longer buys a fresh budget.
|
|
24
|
+
*/
|
|
25
|
+
function throttleKey(request: { _authenticatedUser?: unknown }): string {
|
|
26
|
+
const user = request._authenticatedUser as { id?: unknown } | null | undefined
|
|
27
|
+
const id = user?.id
|
|
28
|
+
if ((typeof id === 'number' && Number.isFinite(id)) || (typeof id === 'string' && id !== ''))
|
|
29
|
+
return `user:${id}`
|
|
30
|
+
return `ip:${clientAddress(request as unknown as Request) ?? 'unknown'}`
|
|
31
|
+
}
|
|
32
|
+
|
|
6
33
|
/**
|
|
7
34
|
* Throttle Middleware
|
|
8
35
|
*
|
|
9
|
-
* Rate limits requests
|
|
10
|
-
* Uses Laravel-style throttle patterns.
|
|
36
|
+
* Rate limits requests per authenticated user, or per client address for
|
|
37
|
+
* anonymous requests (see `throttleKey`). Uses Laravel-style throttle patterns.
|
|
11
38
|
*
|
|
12
39
|
* Usage:
|
|
13
40
|
* .middleware('throttle:60,1') // 60 requests per 1 minute
|
|
@@ -29,7 +56,7 @@ const limiterCache = new Map<string, ReturnType<typeof createRateLimitMiddleware
|
|
|
29
56
|
* "retryAfter": X
|
|
30
57
|
* }
|
|
31
58
|
*
|
|
32
|
-
* Headers added to all responses:
|
|
59
|
+
* Headers added to all responses (successful ones via `_responseHeaders`):
|
|
33
60
|
* - X-RateLimit-Limit: Maximum requests allowed
|
|
34
61
|
* - X-RateLimit-Remaining: Remaining requests in current window
|
|
35
62
|
* - X-RateLimit-Reset: Unix timestamp when the limit resets
|
|
@@ -37,7 +64,9 @@ const limiterCache = new Map<string, ReturnType<typeof createRateLimitMiddleware
|
|
|
37
64
|
*/
|
|
38
65
|
export default new Middleware({
|
|
39
66
|
name: 'throttle',
|
|
40
|
-
|
|
67
|
+
// After Auth (1), so an authenticated request is keyed on its user rather
|
|
68
|
+
// than on an address it may share with strangers.
|
|
69
|
+
priority: 2,
|
|
41
70
|
|
|
42
71
|
async handle(request) {
|
|
43
72
|
// Get throttle params from middleware params (e.g., '60,1' from 'throttle:60,1')
|
|
@@ -48,24 +77,25 @@ export default new Middleware({
|
|
|
48
77
|
if (!limiter) {
|
|
49
78
|
try {
|
|
50
79
|
const config = parseThrottleString(params as Parameters<typeof parseThrottleString>[0])
|
|
51
|
-
limiter = createRateLimitMiddleware(config, `throttle:${params}`)
|
|
80
|
+
limiter = createRateLimitMiddleware({ ...config, keyGenerator: throttleKey }, `throttle:${params}`)
|
|
52
81
|
limiterCache.set(params, limiter)
|
|
53
82
|
}
|
|
54
83
|
catch (error) {
|
|
55
84
|
console.error(`[Throttle] Invalid throttle pattern: ${params}`, error)
|
|
56
85
|
// Use default if pattern is invalid
|
|
57
86
|
const defaultConfig = parseThrottleString('60,1')
|
|
58
|
-
limiter = createRateLimitMiddleware(defaultConfig, 'throttle:default')
|
|
87
|
+
limiter = createRateLimitMiddleware({ ...defaultConfig, keyGenerator: throttleKey }, 'throttle:default')
|
|
59
88
|
limiterCache.set(params, limiter)
|
|
60
89
|
}
|
|
61
90
|
}
|
|
62
91
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
92
|
+
// The limiter stamps its X-RateLimit-* headers onto whatever `next()`
|
|
93
|
+
// answers, so hand it a placeholder and read them back. Passing `null`
|
|
94
|
+
// here meant a request that got through never learned its budget.
|
|
95
|
+
const result = await limiter(request as any, async () => new Response(null, { status: 204 }))
|
|
66
96
|
|
|
67
|
-
//
|
|
68
|
-
if (result instanceof Response) {
|
|
97
|
+
// A 429 short-circuits the request
|
|
98
|
+
if (result instanceof Response && result.status === 429) {
|
|
69
99
|
// Transform the response to match stacks format
|
|
70
100
|
const retryAfter = result.headers.get('Retry-After') || '60'
|
|
71
101
|
|
|
@@ -90,6 +120,15 @@ export default new Middleware({
|
|
|
90
120
|
})
|
|
91
121
|
}
|
|
92
122
|
|
|
93
|
-
//
|
|
123
|
+
// Within the limit: ask the router to put the budget on the real response.
|
|
124
|
+
if (result instanceof Response) {
|
|
125
|
+
const headers: Record<string, string> = { ...request._responseHeaders }
|
|
126
|
+
for (const name of RATE_LIMIT_HEADERS) {
|
|
127
|
+
const value = result.headers.get(name)
|
|
128
|
+
if (value !== null)
|
|
129
|
+
headers[name] = value
|
|
130
|
+
}
|
|
131
|
+
request._responseHeaders = headers
|
|
132
|
+
}
|
|
94
133
|
},
|
|
95
134
|
})
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.64",
|
|
6
6
|
"description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"funding": "https://github.com/sponsors/chrisbbreuer",
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.64",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@iconify-json/f7": "^1.2.2",
|
|
57
57
|
"@iconify-json/hugeicons": "^1.2.35",
|
|
58
|
-
"@stacksjs/mobile": "^0.75.
|
|
58
|
+
"@stacksjs/mobile": "^0.75.64",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.348",
|
|
60
60
|
"ts-qr-codes": "^0.1.8"
|
|
61
61
|
}
|