@webjsdev/cli 0.10.54 → 0.10.56
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/lib/create.js +35 -0
- package/lib/doctor/codes.js +66 -0
- package/lib/doctor/manifest.js +161 -0
- package/lib/doctor/policy.js +124 -0
- package/lib/doctor/probes/elision.js +111 -0
- package/lib/doctor/probes/env.js +53 -0
- package/lib/doctor/probes/framework-resolves.js +84 -0
- package/lib/doctor/probes/git-hook.js +58 -0
- package/lib/doctor/probes/importmap-coherence.js +158 -0
- package/lib/doctor/probes/node.js +37 -0
- package/lib/doctor/probes/static-asset-freshness.js +58 -0
- package/lib/doctor/probes/tsconfig.js +55 -0
- package/lib/doctor/probes/unmarked-asset-links.js +199 -0
- package/lib/doctor/probes/vendor-gitignore.js +84 -0
- package/lib/doctor/probes/vendor-pin.js +77 -0
- package/lib/doctor/probes/webjs-versions.js +85 -0
- package/lib/doctor/route-modules.js +100 -0
- package/lib/doctor/runner.js +71 -0
- package/lib/doctor/util.js +160 -0
- package/lib/doctor.js +5 -1634
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/SKILL.md +41 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +5 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +20 -2
- package/templates/.agents/skills/webjs/references/components.md +41 -1
- package/templates/.agents/skills/webjs/references/module-structure.md +229 -0
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +1 -1
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +36 -2
- package/templates/.agents/skills/webjs/references/runtime.md +5 -0
- package/templates/gallery/app/features/boundaries/page.ts +11 -0
- package/templates/gallery/app/features/client-router/page.ts +8 -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/modules/client-router/components/router-controls.ts +16 -2
- package/templates/gallery/modules/gallery/nav.ts +35 -26
- package/templates/gallery/test/rate-limit/rate-limit.test.ts +91 -0
- package/templates/scripts/clear-gallery.mjs +6 -3
|
@@ -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
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// Programmatic client navigation. `navigate(url)` does the same soft, in-place
|
|
2
2
|
// swap an <a> click does, but from an event handler (after a save, a wizard
|
|
3
3
|
// step, etc.). `revalidate(url?)` evicts the browser snapshot cache so the next
|
|
4
|
-
// visit refetches fresh HTML instead of the cached page.
|
|
4
|
+
// visit refetches fresh HTML instead of the cached page.
|
|
5
|
+
// `refreshPage(mode?)` re-renders the page you are ALREADY on and swaps the
|
|
6
|
+
// result in place, recording no history entry and never scrolling, so the reader
|
|
7
|
+
// keeps their place. 'page' (the default) morphs the deepest shared boundary, so
|
|
8
|
+
// hydrated component state outside it survives; 'shell' replaces the whole body,
|
|
9
|
+
// which is what a layout change needs. `disableClientRouter()`
|
|
5
10
|
// / `enableClientRouter()` turn soft navigation off / back on at runtime (for a
|
|
6
11
|
// moment where you want a full page load, e.g. handing off to a third-party
|
|
7
12
|
// flow). disableClientRouter() removes the document-level <a>/<form> click
|
|
@@ -11,7 +16,7 @@
|
|
|
11
16
|
// All are client-only (they run in the browser), so a component is the right
|
|
12
17
|
// home; a page/layout never hydrates. With JS off the plain link still works
|
|
13
18
|
// (progressive enhancement), while the buttons are inert.
|
|
14
|
-
import { WebComponent, html, signal, navigate, revalidate, disableClientRouter, enableClientRouter } from '@webjsdev/core';
|
|
19
|
+
import { WebComponent, html, signal, navigate, revalidate, refreshPage, disableClientRouter, enableClientRouter } from '@webjsdev/core';
|
|
15
20
|
import { buttonClass } from '#components/ui/button.ts';
|
|
16
21
|
|
|
17
22
|
export class RouterControls extends WebComponent {
|
|
@@ -35,10 +40,19 @@ export class RouterControls extends WebComponent {
|
|
|
35
40
|
<button
|
|
36
41
|
@click=${() => revalidate()}
|
|
37
42
|
class=${buttonClass({ variant: 'link', size: 'none' })}>revalidate() the snapshot cache</button>
|
|
43
|
+
<button
|
|
44
|
+
@click=${() => refreshPage()}
|
|
45
|
+
class=${buttonClass({ variant: 'link', size: 'none' })}>refreshPage() this page</button>
|
|
38
46
|
<button
|
|
39
47
|
@click=${() => this.toggleRouter()}
|
|
40
48
|
class=${buttonClass({ variant: 'link', size: 'none' })}>${soft ? 'disableClientRouter()' : 'enableClientRouter()'} (soft nav: ${soft ? 'on' : 'off'})</button>
|
|
41
49
|
</div>
|
|
50
|
+
<p class="text-sm text-muted-foreground">
|
|
51
|
+
refreshPage() re-renders THIS url on the server and swaps it in.
|
|
52
|
+
The server time above updates, and your scroll position does not
|
|
53
|
+
move. On a page with hydrated components outside the swapped region,
|
|
54
|
+
their state survives too.
|
|
55
|
+
</p>
|
|
42
56
|
<p class="text-sm text-muted-foreground">
|
|
43
57
|
Plain link:
|
|
44
58
|
<a href="/features/client-router/second" class="text-primary underline">/features/client-router/second</a>.
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
* left sidebar (so they can never drift). A browser-safe data module (no server
|
|
4
4
|
* imports, no client globals): the home flattens the groups into its card grid,
|
|
5
5
|
* and <gallery-nav> renders them grouped. gallery:clear removes this module.
|
|
6
|
+
*
|
|
7
|
+
* Blurbs are INTENT-shaped, not noun-shaped: each one opens on the job you would
|
|
8
|
+
* be doing when you want this demo, because the index is the first thing an
|
|
9
|
+
* agent reads and a card that only names the feature cannot be found by someone
|
|
10
|
+
* who does not know the feature exists. The skill's cheat sheet
|
|
11
|
+
* (.agents/skills/webjs/SKILL.md, "Reach For The Right Primitive") carries the
|
|
12
|
+
* same set keyed the same way, and the repo's
|
|
13
|
+
* test/repo-health/skill-gallery-intent-parity.test.mjs fails if the two fall
|
|
14
|
+
* out of step.
|
|
6
15
|
*/
|
|
7
16
|
export interface NavItem { href: string; title: string; blurb: string; }
|
|
8
17
|
export interface NavGroup { label: string; items: NavItem[]; }
|
|
@@ -11,68 +20,68 @@ export const FEATURE_GROUPS: NavGroup[] = [
|
|
|
11
20
|
{
|
|
12
21
|
label: 'Routing',
|
|
13
22
|
items: [
|
|
14
|
-
{ href: '/features/routing', title: 'Routing', blurb: '
|
|
15
|
-
{ href: '/features/boundaries', title: 'Boundaries', blurb: '
|
|
16
|
-
{ href: '/features/metadata', title: 'Metadata', blurb: '
|
|
23
|
+
{ href: '/features/routing', title: 'Routing', blurb: 'Add a URL to the app, static or with a dynamic [id] segment. The file is the route, so there is no table to register it in.' },
|
|
24
|
+
{ href: '/features/boundaries', title: 'Boundaries', blurb: 'Abandon a render because something is missing or not allowed. Throw notFound / forbidden / unauthorized and the nearest boundary file catches it.' },
|
|
25
|
+
{ href: '/features/metadata', title: 'Metadata', blurb: 'Give a page its own title, description, and social preview without writing head markup yourself.' },
|
|
17
26
|
],
|
|
18
27
|
},
|
|
19
28
|
{
|
|
20
29
|
label: 'Components',
|
|
21
30
|
items: [
|
|
22
|
-
{ href: '/features/components', title: 'Components', blurb: '
|
|
23
|
-
{ href: '/features/directives', title: 'Directives', blurb: '
|
|
24
|
-
{ href: '/features/async-render', title: 'Async render', blurb: '
|
|
31
|
+
{ href: '/features/components', title: 'Components', blurb: 'Make one part of the page respond to a click or hold state. A page never hydrates, so interactivity lives in a component.' },
|
|
32
|
+
{ href: '/features/directives', title: 'Directives', blurb: 'Render a keyed list, or swap a single node when state changes, without re-rendering the component around it.' },
|
|
33
|
+
{ href: '/features/async-render', title: 'Async render', blurb: 'Get server data into the first paint. Await it in async render() rather than fetching after mount, which SSR never runs.' },
|
|
25
34
|
],
|
|
26
35
|
},
|
|
27
36
|
{
|
|
28
37
|
label: 'Data & actions',
|
|
29
38
|
items: [
|
|
30
|
-
{ href: '/features/server-actions', title: 'Server actions', blurb: '
|
|
31
|
-
{ href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts
|
|
32
|
-
{ href: '/features/forms', title: 'Forms', blurb: '
|
|
33
|
-
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: '
|
|
39
|
+
{ href: '/features/server-actions', title: 'Server actions', blurb: 'Call server code from the browser by importing the function. No fetch, no endpoint to name, and the types survive the trip.' },
|
|
40
|
+
{ href: '/features/route-handler', title: 'Route handlers', blurb: 'Expose JSON to a caller outside the app. A server-only route.ts, the WebJs equivalent of a Next route handler.' },
|
|
41
|
+
{ href: '/features/forms', title: 'Forms', blurb: 'Write data from a form that still works with JS off. Binding the action to the form is the whole wiring.' },
|
|
42
|
+
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'Make a mutation feel instant. optimistic() applies the change immediately and rolls it back if the server refuses.' },
|
|
34
43
|
],
|
|
35
44
|
},
|
|
36
45
|
{
|
|
37
46
|
label: 'Client & streaming',
|
|
38
47
|
items: [
|
|
39
|
-
{ href: '/features/client-router', title: 'Client router', blurb: 'Automatic
|
|
40
|
-
{ href: '/features/view-transitions', title: 'View transitions', blurb: '
|
|
41
|
-
{ href: '/features/streaming', title: 'Streaming actions', blurb: '
|
|
42
|
-
{ href: '/features/stream', title: 'Stream updates', blurb: '
|
|
43
|
-
{ href: '/features/suspense', title: 'Suspense boundary', blurb: '
|
|
44
|
-
{ href: '/features/frames', title: 'Frames', blurb: '
|
|
48
|
+
{ href: '/features/client-router', title: 'Client router', blurb: 'Navigate without a full page reload. Automatic the moment a page ships a component, with nothing to import or configure.' },
|
|
49
|
+
{ href: '/features/view-transitions', title: 'View transitions', blurb: 'Cross-fade a navigation instead of snapping. One opt-in meta, plus a marker for elements that must survive the swap.' },
|
|
50
|
+
{ href: '/features/streaming', title: 'Streaming actions', blurb: 'Show tokens or progress as the server produces them. An action returning an async generator, consumed with for await.' },
|
|
51
|
+
{ href: '/features/stream', title: 'Stream updates', blurb: 'Change one element after a write, like appending a row or bumping a count, without redrawing the region around it.' },
|
|
52
|
+
{ href: '/features/suspense', title: 'Suspense boundary', blurb: 'Paint the page before a slow region is ready. The fallback flushes on the first byte and the content streams in behind it.' },
|
|
53
|
+
{ href: '/features/frames', title: 'Frames', blurb: 'Refresh one region on its own with no navigation, like a filtered list or a tab panel. Zero component JS, full-nav fallback with JS off.' },
|
|
45
54
|
],
|
|
46
55
|
},
|
|
47
56
|
{
|
|
48
57
|
label: 'Real-time',
|
|
49
58
|
items: [
|
|
50
|
-
{ href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route
|
|
51
|
-
{ href: '/features/broadcast', title: 'Broadcast', blurb: '
|
|
59
|
+
{ href: '/features/websockets', title: 'WebSockets', blurb: 'Hold a live two-way connection instead of polling. A WS(ws, req) route export on the server, connectWS() on the client.' },
|
|
60
|
+
{ href: '/features/broadcast', title: 'Broadcast', blurb: 'Push one update to every client connected on a WebSocket path, so a change made in one of them shows up in the rest. Optionally excluding the sender.' },
|
|
52
61
|
],
|
|
53
62
|
},
|
|
54
63
|
{
|
|
55
64
|
label: 'Auth & sessions',
|
|
56
65
|
items: [
|
|
57
|
-
{ href: '/features/auth', title: 'Auth', blurb: '
|
|
58
|
-
{ href: '/features/sessions', title: 'Sessions', blurb: 'A signed
|
|
66
|
+
{ href: '/features/auth', title: 'Auth', blurb: 'Add login and a route only signed-in visitors can open, without rolling password hashing and session cookies yourself.' },
|
|
67
|
+
{ href: '/features/sessions', title: 'Sessions', blurb: 'Remember something per visitor across requests. A signed cookie applied by middleware, read and written with getSession().' },
|
|
59
68
|
],
|
|
60
69
|
},
|
|
61
70
|
{
|
|
62
71
|
label: 'Built-ins',
|
|
63
72
|
items: [
|
|
64
|
-
{ href: '/features/caching', title: 'Caching', blurb: '
|
|
65
|
-
{ href: '/features/env', title: 'Env vars', blurb: '
|
|
66
|
-
{ href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware
|
|
67
|
-
{ href: '/features/file-storage', title: 'File storage', blurb: '
|
|
68
|
-
{ href: '/features/service-worker', title: 'Service worker', blurb: 'The opt-in
|
|
73
|
+
{ href: '/features/caching', title: 'Caching', blurb: 'Stop re-rendering a page that is identical for every visitor. export const revalidate, with the rule for when that is safe.' },
|
|
74
|
+
{ href: '/features/env', title: 'Env vars', blurb: 'Read config and secrets at runtime while keeping the secrets server-side. WEBJS_PUBLIC_ is the only prefix the browser sees.' },
|
|
75
|
+
{ href: '/features/rate-limit', title: 'Rate limiting', blurb: 'Stop one caller hammering an endpoint. The rateLimit() middleware returns a 429 with Retry-After until the interval resets.' },
|
|
76
|
+
{ href: '/features/file-storage', title: 'File storage', blurb: 'Accept an upload and serve it back, streamed both ways, with nothing buffered in memory and nothing written into public/.' },
|
|
77
|
+
{ href: '/features/service-worker', title: 'Service worker', blurb: 'Keep the app usable offline. The opt-in service worker, registered from a browser-only lifecycle hook (never a page or layout).' },
|
|
69
78
|
],
|
|
70
79
|
},
|
|
71
80
|
];
|
|
72
81
|
|
|
73
82
|
/** The whole example apps (composed features), shown after the single-feature demos. */
|
|
74
83
|
export const EXAMPLES: NavItem[] = [
|
|
75
|
-
{ href: '/examples/todo', title: 'Optimistic todo', blurb: '
|
|
84
|
+
{ href: '/examples/todo', title: 'Optimistic todo', blurb: 'See the pieces composed in one real feature: the declarative optimistic() list API, progressive-enhancement forms, accessible labels, the modules split, and SQLite.' },
|
|
76
85
|
];
|
|
77
86
|
|
|
78
87
|
/**
|
|
@@ -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',
|