@webjsdev/cli 0.10.54 → 0.10.55
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/lib/api-gallery.js +25 -1
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/references/built-ins.md +5 -1
- package/templates/gallery/app/features/rate-limit/ping/middleware.ts +36 -4
- package/templates/gallery/app/features/route-handler/data/route.ts +8 -0
- package/templates/gallery/test/rate-limit/rate-limit.test.ts +91 -0
- package/templates/scripts/clear-gallery.mjs +6 -3
package/lib/api-gallery.js
CHANGED
|
@@ -69,9 +69,33 @@ export async function writeApiGallery(appDir) {
|
|
|
69
69
|
"// Per-segment middleware: it sits beside this route, so it rate-limits ONLY",
|
|
70
70
|
"// /api/features/rate-limit. rateLimit() is backed by the pluggable cache store",
|
|
71
71
|
"// (in-memory by default; point it at Redis to share the window across nodes).",
|
|
72
|
+
"//",
|
|
73
|
+
"// `trustProxy: true` decides WHAT gets counted. Without it the bucket key is",
|
|
74
|
+
"// the socket peer, which is the visitor only when the browser connects to you",
|
|
75
|
+
"// directly. Behind a CDN or a platform router the peer is that proxy, so a",
|
|
76
|
+
"// proxy POOL hands out one bucket per proxy and multiplies your real limit by",
|
|
77
|
+
"// the pool size. With it the key is the forwarded client address instead.",
|
|
78
|
+
"//",
|
|
79
|
+
"// It has a precondition: the proxy in front MUST strip an inbound",
|
|
80
|
+
"// X-Forwarded-For before adding its own, or a client can forge the header and",
|
|
81
|
+
"// pick its own bucket. Serving with nothing in front? Drop the option, since",
|
|
82
|
+
"// then the socket peer IS the visitor. WEBJS_NO_TRUST_PROXY=1 outranks it either",
|
|
83
|
+
"// way. https://webjs.dev/docs/rate-limiting has the full threat model.",
|
|
84
|
+
"//",
|
|
85
|
+
"// Behind a CDN, add `clientIpHeader` to name the header carrying the visitor,",
|
|
86
|
+
"// e.g. `clientIpHeader: 'cf-connecting-ip'` behind Cloudflare. The default",
|
|
87
|
+
"// chain reads the leftmost X-Forwarded-For entry, which behind a CDN is the",
|
|
88
|
+
"// CDN's egress address; those are pinned per connection, so the limiter ends up",
|
|
89
|
+
"// handing out a bucket per connection and refusing nobody. It is left unset",
|
|
90
|
+
"// here because the right header depends on what you deploy behind.",
|
|
72
91
|
"import { rateLimit } from '@webjsdev/server';",
|
|
73
92
|
"",
|
|
74
|
-
"export default rateLimit({
|
|
93
|
+
"export default rateLimit({",
|
|
94
|
+
" window: '10s',",
|
|
95
|
+
" max: 5,",
|
|
96
|
+
" trustProxy: true,",
|
|
97
|
+
" message: 'Slow down: five requests per ten seconds.',",
|
|
98
|
+
"});",
|
|
75
99
|
"",
|
|
76
100
|
].join('\n'));
|
|
77
101
|
await writeFile(feat('rate-limit', 'route.ts'), [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.55",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
|
|
6
6
|
"bin": {
|
|
@@ -109,7 +109,11 @@ import { rateLimit } from '@webjsdev/server';
|
|
|
109
109
|
export default rateLimit({ window: '1m', max: 60 });
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
Options: `window` (ms or a string like `'1m'`), `max`, `key` (a string prefix or a `(req) => string` function, defaults to the
|
|
112
|
+
Options: `window` (ms or a string like `'1m'`), `max`, `key` (a string prefix or a `(req) => string` function, defaults to the framework-stamped socket peer), `message`, `store`, `trustProxy` (honour the forwarded-IP headers; inert while `WEBJS_NO_TRUST_PROXY=1` is set, which outranks it and keeps the limiter on the framework-stamped peer). Over-limit responds `429` with `Retry-After` and `X-RateLimit-*` headers; an allowed response carries the remaining-quota headers too. For multi-instance scaling, set the global store to Redis once at startup.
|
|
113
|
+
|
|
114
|
+
**The default key is the socket PEER, which is the visitor only when the browser connects to you directly.** Deploy behind a CDN or a platform router and the peer is that proxy, so `trustProxy: true` is what a deployed limiter almost always wants. Get it wrong and nothing looks broken: a single shared proxy buckets every visitor together, and a proxy POOL (the common case) hands out one full allowance PER proxy, so the effective limit is multiplied by the pool size while `X-RateLimit-Remaining` still counts down convincingly inside each bucket. Diagnose it by sending the requests over ONE keep-alive connection, which pins them to one peer: counts that descend there but reset on a fresh connection mean you are bucketing proxies. `trustProxy: true` has one precondition, that the proxy in front strips an inbound `X-Forwarded-For` before adding its own (Cloudflare, Railway, Fly, Render, and Vercel do; nginx and Caddy only if configured), or a client can forge the header and choose its own bucket.
|
|
115
|
+
|
|
116
|
+
**Behind a CDN, `trustProxy: true` alone is usually still wrong, so name the header: `rateLimit({ trustProxy: true, clientIpHeader: 'cf-connecting-ip' })`.** The default chain starts at the leftmost `X-Forwarded-For` entry, which behind Cloudflare is Cloudflare's EGRESS address rather than the visitor. Cloudflare pins one egress IP per connection, so the symptom is a limiter that counts down correctly for a button that pings on one connection and never refuses anyone who opens a new one. When `clientIpHeader` is set it is the only wire header read (falling back to the peer, then `_anon_`), a blank value falls through rather than becoming a key every visitor shares, and a comma chain is split so an appending proxy cannot mint a bucket per hop. The framework will NOT prefer `CF-Connecting-IP` on its own, because Cloudflare overwrites it, which makes it unforgeable behind Cloudflare and forgeable anywhere else: on an nginx or bare-platform deploy a client could then send it and outrank the header the real proxy set. Name the header YOUR edge sets and overwrites.
|
|
113
117
|
|
|
114
118
|
## Broadcast
|
|
115
119
|
|
|
@@ -1,8 +1,40 @@
|
|
|
1
1
|
// Per-segment middleware. It sits in the ping/ folder, so it applies ONLY to
|
|
2
2
|
// /features/rate-limit/ping (its route.ts), not to the demo page one level up.
|
|
3
|
-
// rateLimit() returns a standard
|
|
4
|
-
// short-circuit (the 429), or call next() to continue.
|
|
5
|
-
//
|
|
3
|
+
// rateLimit() returns a standard WebJs middleware: return a Response to
|
|
4
|
+
// short-circuit (the 429), or call next() to continue. Pass `key` to bucket by
|
|
5
|
+
// user id, API key, or anything else instead of by IP.
|
|
6
|
+
//
|
|
7
|
+
// `trustProxy: true` is the load-bearing option here, and it is why this demo
|
|
8
|
+
// works on the deployed site. WITHOUT it the bucket key is the socket peer,
|
|
9
|
+
// which is correct only when the visitor's browser is the thing connecting.
|
|
10
|
+
// Behind a CDN or a platform router the peer is that proxy, so every visitor
|
|
11
|
+
// sharing one proxy shares one bucket, and (worse for a limiter) a proxy POOL
|
|
12
|
+
// hands out one bucket per proxy, which multiplies the real limit by the pool
|
|
13
|
+
// size. WITH it the key comes from the forwarded client address instead.
|
|
14
|
+
//
|
|
15
|
+
// `clientIpHeader` then says WHICH forwarded header carries the visitor, and on
|
|
16
|
+
// this deployment it is load-bearing too. Without it the default chain takes the
|
|
17
|
+
// leftmost X-Forwarded-For entry, which behind Cloudflare is Cloudflare's EGRESS
|
|
18
|
+
// address rather than yours. Cloudflare pins an egress IP per connection, so the
|
|
19
|
+
// limiter hands out one bucket per connection: the count descends convincingly
|
|
20
|
+
// while you hold one connection open and resets the moment a new one opens,
|
|
21
|
+
// which is a limiter that limits nobody. Your address is in CF-Connecting-IP, so
|
|
22
|
+
// that is the header this app names.
|
|
23
|
+
//
|
|
24
|
+
// Copying this into your own app? Name the header YOUR proxy sets, and only
|
|
25
|
+
// after checking it cannot be forged past that proxy. Cloudflare overwrites
|
|
26
|
+
// CF-Connecting-IP, which is what makes it safe HERE and unsafe on a deploy that
|
|
27
|
+
// Cloudflare is not in front of. The same precondition applies to the default
|
|
28
|
+
// chain: the proxy MUST strip an inbound X-Forwarded-For before adding its own.
|
|
29
|
+
// Serving with nothing in front? Drop both options, since then the socket peer
|
|
30
|
+
// IS the visitor. WEBJS_NO_TRUST_PROXY=1 outranks all of it.
|
|
31
|
+
// /docs/rate-limiting has the full threat model.
|
|
6
32
|
import { rateLimit } from '@webjsdev/server';
|
|
7
33
|
|
|
8
|
-
export default rateLimit({
|
|
34
|
+
export default rateLimit({
|
|
35
|
+
window: '10s',
|
|
36
|
+
max: 5,
|
|
37
|
+
trustProxy: true,
|
|
38
|
+
clientIpHeader: 'cf-connecting-ip',
|
|
39
|
+
message: 'Slow down: five requests per ten seconds.',
|
|
40
|
+
});
|
|
@@ -16,7 +16,15 @@ export async function GET(req: Request) {
|
|
|
16
16
|
return json({
|
|
17
17
|
ok: true,
|
|
18
18
|
at: new Date(), // a real Date; richFetch decodes it back to a Date, not a string
|
|
19
|
+
// Two addresses, because behind a proxy they are NOT the same and the
|
|
20
|
+
// difference is invisible until something depends on it (a rate limiter
|
|
21
|
+
// did, and bucketed proxies instead of visitors). `ip` is the socket peer,
|
|
22
|
+
// which is the visitor only when the browser connects to you directly.
|
|
23
|
+
// `forwardedIp` is what the visitor's own CDN header says, which is what a
|
|
24
|
+
// limiter or an audit log wants. Deployed behind Cloudflare and Railway,
|
|
25
|
+
// `ip` is a rotating 100.64.x.x router address while `forwardedIp` is you.
|
|
19
26
|
ip: clientIp(req),
|
|
27
|
+
forwardedIp: clientIp(req, { trustProxy: true, header: 'cf-connecting-ip' }),
|
|
20
28
|
requestId: requestId(),
|
|
21
29
|
userAgent: headers().get('user-agent') ?? 'unknown',
|
|
22
30
|
// cookies() reads the REQUEST cookies. Report how many are present (a
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { createRequestHandler } from '@webjsdev/server';
|
|
7
|
+
import { testRequest } from '@webjsdev/server/testing';
|
|
8
|
+
|
|
9
|
+
const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
10
|
+
|
|
11
|
+
const PING = '/features/rate-limit/ping';
|
|
12
|
+
|
|
13
|
+
// The demo's own numbers, so a change to the middleware that these tests do not
|
|
14
|
+
// notice is a change that made them stale rather than one they tolerated.
|
|
15
|
+
const MAX = 5;
|
|
16
|
+
|
|
17
|
+
// Each test picks its own visitor addresses. The limiter counts into the global
|
|
18
|
+
// in-memory cache store, which outlives a handler instance, so two tests sharing
|
|
19
|
+
// an address would share a bucket and the second would start already exhausted.
|
|
20
|
+
// The demo names CF-Connecting-IP, because that is the header carrying the
|
|
21
|
+
// visitor on the deployment it runs on. Every request here also carries an
|
|
22
|
+
// X-Forwarded-For that DISAGREES, standing in for the CDN egress address the
|
|
23
|
+
// real deploy puts there, so a test that passes only because the two agree
|
|
24
|
+
// cannot exist.
|
|
25
|
+
function ping(handle: (req: Request) => Promise<Response>, visitor: string, cdnEgress = '172.68.1.9') {
|
|
26
|
+
return testRequest(handle, PING, {
|
|
27
|
+
headers: { 'cf-connecting-ip': visitor, 'x-forwarded-for': cdnEgress },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('the demo limits one visitor to five requests per window', async () => {
|
|
32
|
+
const app = await createRequestHandler({ appDir, dev: true });
|
|
33
|
+
const visitor = '203.0.113.10';
|
|
34
|
+
|
|
35
|
+
for (let i = 1; i <= MAX; i += 1) {
|
|
36
|
+
const res = await ping(app.handle, visitor);
|
|
37
|
+
assert.equal(res.status, 200, `request ${i} is inside the window`);
|
|
38
|
+
assert.equal(res.headers.get('x-ratelimit-remaining'), String(MAX - i));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const limited = await ping(app.handle, visitor);
|
|
42
|
+
assert.equal(limited.status, 429, 'the sixth request is refused');
|
|
43
|
+
assert.equal(limited.headers.get('retry-after'), '10');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// This is the assertion the deployed bug would have failed. Both visitors reach
|
|
47
|
+
// the app through the same proxy, so the socket peer is identical for both and a
|
|
48
|
+
// peer-keyed limiter would count them into ONE bucket: exhausting the first
|
|
49
|
+
// would refuse the second. Keying on the forwarded address keeps them apart.
|
|
50
|
+
//
|
|
51
|
+
// Counterfactual, proven at this commit: removing `trustProxy: true` from
|
|
52
|
+
// gallery/app/features/rate-limit/ping/middleware.ts fails this test on the last
|
|
53
|
+
// assertion (the second visitor gets a 429), while the single-visitor test above
|
|
54
|
+
// still passes. That asymmetry is the point, since the single-visitor test is
|
|
55
|
+
// what a peer-keyed limiter satisfies too.
|
|
56
|
+
test('one visitor exhausting the window does not refuse another behind the same proxy', async () => {
|
|
57
|
+
const app = await createRequestHandler({ appDir, dev: true });
|
|
58
|
+
const noisy = '203.0.113.20';
|
|
59
|
+
const bystander = '203.0.113.21';
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < MAX; i += 1) await ping(app.handle, noisy);
|
|
62
|
+
assert.equal((await ping(app.handle, noisy)).status, 429, 'the noisy visitor is limited');
|
|
63
|
+
|
|
64
|
+
const other = await ping(app.handle, bystander);
|
|
65
|
+
assert.equal(other.status, 200, 'a different visitor keeps their own window');
|
|
66
|
+
assert.equal(other.headers.get('x-ratelimit-remaining'), String(MAX - 1));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The half `trustProxy: true` alone did not deliver, and the one the live site
|
|
70
|
+
// disproved (#1389). A CDN gives each connection a different egress address, so
|
|
71
|
+
// one visitor opening several connections arrives with several X-Forwarded-For
|
|
72
|
+
// values and ONE CF-Connecting-IP. Keyed on XFF that visitor gets a fresh bucket
|
|
73
|
+
// per connection and is never refused, which is what shipped and read as working.
|
|
74
|
+
//
|
|
75
|
+
// Counterfactual, proven at this commit: removing `clientIpHeader` from the
|
|
76
|
+
// middleware fails this test at the sixth request AND the two-visitor test
|
|
77
|
+
// above, while the single-visitor test still passes. The one that survives is
|
|
78
|
+
// the one whose requests all carry the same CDN address, which is exactly the
|
|
79
|
+
// blind spot that let the first fix look complete on a real deployment.
|
|
80
|
+
test('one visitor is limited across connections, whatever CDN address they arrive on', async () => {
|
|
81
|
+
const app = await createRequestHandler({ appDir, dev: true });
|
|
82
|
+
const visitor = '203.0.113.30';
|
|
83
|
+
|
|
84
|
+
for (let i = 1; i <= MAX; i += 1) {
|
|
85
|
+
const res = await ping(app.handle, visitor, `172.68.9.${i}`);
|
|
86
|
+
assert.equal(res.status, 200, `request ${i} arrives on its own CDN egress address`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const limited = await ping(app.handle, visitor, '172.68.9.99');
|
|
90
|
+
assert.equal(limited.status, 429, 'a new CDN egress address does not buy a new window');
|
|
91
|
+
});
|
|
@@ -53,10 +53,13 @@ if (!existsSync(join(root, 'app/features'))) {
|
|
|
53
53
|
|
|
54
54
|
// 1) Gallery route trees + example metadata routes. `app/api/auth` is the auth
|
|
55
55
|
// card's createAuth handler (it lives at the app root, not under app/features/,
|
|
56
|
-
// because createAuth hardcodes /api/auth/*), and `test/auth`
|
|
57
|
-
// request-pipeline
|
|
56
|
+
// because createAuth hardcodes /api/auth/*), and `test/auth` + `test/rate-limit`
|
|
57
|
+
// are card-owned request-pipeline tests, so they are removed alongside their
|
|
58
|
+
// cards. A card that ships a test under test/ MUST be listed here: the prune
|
|
59
|
+
// below only removes test/ once it is EMPTY, so a missed entry silently leaves
|
|
60
|
+
// the reset app with a test suite for a card it no longer has.
|
|
58
61
|
const galleryPaths = [
|
|
59
|
-
'app/features', 'app/examples', 'app/sitemaps', 'app/api/auth', 'test/auth',
|
|
62
|
+
'app/features', 'app/examples', 'app/sitemaps', 'app/api/auth', 'test/auth', 'test/rate-limit',
|
|
60
63
|
'app/icon.ts', 'app/apple-icon.ts', 'app/manifest.ts', 'app/opengraph-image.ts',
|
|
61
64
|
'app/twitter-image.ts', 'app/robots.ts', 'app/sitemap.ts',
|
|
62
65
|
'app/global-error.ts', 'app/global-not-found.ts',
|