@redirhub/cloudflare-redirects 0.1.0 → 0.2.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/README.md +67 -6
- package/dist/core/redirects.d.ts +41 -3
- package/dist/core/redirects.js +33 -13
- package/dist/core/report.js +6 -4
- package/dist/core/source.d.ts +12 -4
- package/dist/core/source.js +29 -19
- package/dist/index.d.ts +8 -9
- package/dist/index.js +10 -8
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -22,6 +22,21 @@ and this package passes its [golden sample](spec/v1). [RedirHub](https://www.red
|
|
|
22
22
|
publishes the file for you: you manage the redirects in its dashboard, API or MCP server and see
|
|
23
23
|
the redirects and 404s in its analytics.
|
|
24
24
|
|
|
25
|
+
## For AI coding assistants
|
|
26
|
+
|
|
27
|
+
To put this in front of a site on Cloudflare:
|
|
28
|
+
|
|
29
|
+
1. `npm install @redirhub/cloudflare-redirects` in a Worker project.
|
|
30
|
+
2. `export default createWorker({ settings: (env) => ({ rulesUrl: env.ACME_RULES_URL, key: env.ACME_INTEGRATION_KEY }) })`
|
|
31
|
+
as the Worker's entry; pass `origin` only if requests should go somewhere other than `fetch(request)`.
|
|
32
|
+
3. In `wrangler.toml`: a route `<hostname>/*` on the zone, the rules address as a plain `[vars]`
|
|
33
|
+
value, and the integration key as a secret (`wrangler secret put ACME_INTEGRATION_KEY`). Names
|
|
34
|
+
come from the RedirHub dashboard.
|
|
35
|
+
4. Check: `GET https://<hostname>/_redirhub/check` answers `token=… etag=…`, and a rule's path
|
|
36
|
+
redirects with its status. Then press **Check now** in the dashboard.
|
|
37
|
+
|
|
38
|
+
Do not reimplement the rules format: this package passes the specification's golden sample.
|
|
39
|
+
|
|
25
40
|
## When to use it
|
|
26
41
|
|
|
27
42
|
Cloudflare's own Redirect Rules and Bulk Redirects are managed in the Cloudflare dashboard or API.
|
|
@@ -84,12 +99,47 @@ add your own logic, pass `origin: (request, env, ctx) => …`.
|
|
|
84
99
|
| Option | |
|
|
85
100
|
| --- | --- |
|
|
86
101
|
| `settings(env)` | The rules address and the key from the Worker's bindings. Empty address: off, every request goes to the origin. |
|
|
102
|
+
| `report` | `false`, or `(request) => boolean`: whether to report, over the key. Default: whenever there is a key. |
|
|
87
103
|
| `origin(request, env, ctx)` | Where requests no rule answers go; default `fetch(request)`. |
|
|
88
104
|
| `report404` | `rules` (default): 404s of hostnames with **Report 404 pages** on; `all`: every hostname the Worker serves. |
|
|
89
|
-
| `
|
|
105
|
+
| `hostname(request)` | The hostname to look up in the rules; `null` leaves the request alone (straight to the origin, nothing reported). Default the `Host` header. |
|
|
106
|
+
| `path(request)` | The path to look up in the rules, percent-encoded; `null` leaves the request alone. Default the request URL's path. |
|
|
107
|
+
| `location(location, request)` | The `Location` to send, from the one the rule gives; also the reported destination. |
|
|
108
|
+
| `clientIp(request)` | The visitor's address in reports; default `CF-Connecting-IP`. `null` writes `-`. |
|
|
109
|
+
| `userAgent(request)` | The User-Agent in reports; default the `User-Agent` header. `null` writes `-`. |
|
|
90
110
|
| `anonymizeIp` | Report IPv4 with the last byte zeroed, IPv6 cut to /48. |
|
|
91
111
|
| `logger` | Default `console`; `null` for silence. Failures are logged at most once per ten minutes. |
|
|
92
112
|
|
|
113
|
+
## Customize
|
|
114
|
+
|
|
115
|
+
Each option that reads the request is a function of it, so a Worker decides with ordinary code:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
export default createWorker<Env>({
|
|
119
|
+
settings: (env) => ({ rulesUrl: env.ACME_RULES_URL, key: env.ACME_INTEGRATION_KEY }),
|
|
120
|
+
// Which hostname to look up in the rules. null: leave the request alone.
|
|
121
|
+
hostname: (request) => {
|
|
122
|
+
const host = new URL(request.url).hostname
|
|
123
|
+
if (host === 'api.example.com') return null // never through the rules
|
|
124
|
+
if (host.endsWith('.workers.dev')) return 'shop.example.com' // testing on workers.dev
|
|
125
|
+
return host
|
|
126
|
+
},
|
|
127
|
+
// Paths that never go through the rules.
|
|
128
|
+
path: (request) => {
|
|
129
|
+
const { pathname } = new URL(request.url)
|
|
130
|
+
return pathname.startsWith('/api/') ? null : pathname
|
|
131
|
+
},
|
|
132
|
+
// Testing on workers.dev redirects, but is not reported.
|
|
133
|
+
report: (request) => !new URL(request.url).hostname.endsWith('.workers.dev'),
|
|
134
|
+
anonymizeIp: true,
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The hostname and path these return are the ones matched, answered on the check path, appended by
|
|
139
|
+
a catch-all and reported, so they always agree. Options that identify the request (`hostname`,
|
|
140
|
+
`path`) leave it alone on `null`: it goes straight to the origin, and the rules are not even loaded
|
|
141
|
+
for it. Options that are only reported (`clientIp`, `userAgent`) write `-` on `null`.
|
|
142
|
+
|
|
93
143
|
## Behaviour
|
|
94
144
|
|
|
95
145
|
Only **GET and HEAD** on a **hostname listed** in the rules are answered; everything else goes to
|
|
@@ -108,10 +158,12 @@ Matching follows format v1: the hostname in any case, port ignored; the path cas
|
|
|
108
158
|
percent-decoded, leading and trailing slashes ignored; scheme and query ignored. Every redirect
|
|
109
159
|
carries `X-Powered-By` when the rules file has `@powered-by`.
|
|
110
160
|
|
|
111
|
-
**Rules download.** The first request of
|
|
112
|
-
|
|
113
|
-
(
|
|
114
|
-
|
|
161
|
+
**Rules download.** The first request of a new isolate starts the download and waits for it at
|
|
162
|
+
most one second; if the rules server is slower, that request goes to the origin without rules and
|
|
163
|
+
the download (5 second timeout) finishes in `ctx.waitUntil`. Requests that arrive meanwhile do not
|
|
164
|
+
wait. After that the copy in memory is used, and once it is older than `@refresh` seconds (60 by
|
|
165
|
+
default) it is revalidated in `ctx.waitUntil` with `If-None-Match`, so no visitor waits. Any
|
|
166
|
+
failure keeps the last good copy and waits 30 seconds before trying again. `@notify` (push) is
|
|
115
167
|
ignored: a Worker keeps no connection open.
|
|
116
168
|
|
|
117
169
|
**Reports.** A line queued while nothing is being sent goes out at once; lines queued while a
|
|
@@ -139,7 +191,8 @@ rules file, with your key.
|
|
|
139
191
|
|
|
140
192
|
## Limits
|
|
141
193
|
|
|
142
|
-
- The rules live in each isolate's memory: a new isolate waits
|
|
194
|
+
- The rules live in each isolate's memory: a new isolate waits up to one second for them, and
|
|
195
|
+
serves no redirects until they arrive.
|
|
143
196
|
- Changes are live within `@refresh` seconds per isolate (60 by default).
|
|
144
197
|
- The rules file is at most 32 MB, and must fit the Worker's memory.
|
|
145
198
|
|
|
@@ -160,6 +213,14 @@ npm run check:core # src/core and test/core are the same as redirhub/nextjs-re
|
|
|
160
213
|
are shared, file for file, with [nextjs-redirects](https://github.com/redirhub/nextjs-redirects).
|
|
161
214
|
Change both repositories together; CI flags a difference. `src/index.ts` is the Workers part.
|
|
162
215
|
|
|
216
|
+
## Releasing
|
|
217
|
+
|
|
218
|
+
Bump `version` in `package.json` and `VERSION` in `src/version.ts` (a test checks they match) and
|
|
219
|
+
merge to `main`. The Release workflow sees a version without a tag, runs the checks, publishes to
|
|
220
|
+
npm through Trusted Publishing (with provenance, no npm token stored anywhere), then tags the
|
|
221
|
+
commit `v<version>` and creates the GitHub Release. Nobody pushes a tag by hand; a push without a
|
|
222
|
+
version change releases nothing.
|
|
223
|
+
|
|
163
224
|
## License
|
|
164
225
|
|
|
165
226
|
Apache License 2.0, see [LICENSE](LICENSE). Copyright 2026 RedirHub.
|
package/dist/core/redirects.d.ts
CHANGED
|
@@ -13,22 +13,60 @@ export interface RedirectsOptions {
|
|
|
13
13
|
rulesUrl: string | null | undefined;
|
|
14
14
|
/** The integration key. Without it redirects work and nothing is reported. */
|
|
15
15
|
key?: string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Whether to report, over the key: `false` reports nothing, a function decides per request
|
|
18
|
+
* (e.g. not for preview deployments). Default: report whenever there is a key.
|
|
19
|
+
*/
|
|
20
|
+
report?: boolean | ((request: Request) => boolean);
|
|
16
21
|
/**
|
|
17
22
|
* Which 404 answers of the site are reported (where the site's answer is seen, `afterSite`):
|
|
18
23
|
* `rules` (default) for hostnames whose `@host` line has `report404`, `all` for every hostname.
|
|
19
24
|
*/
|
|
20
25
|
report404?: 'rules' | 'all';
|
|
21
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* The hostname to look up in the rules, report and answer the check path for. Return null to
|
|
28
|
+
* leave the request alone: no rules are loaded for it and nothing is reported. Default: the
|
|
29
|
+
* `Host` header (behind `next start` the request URL names the server, not the site), else the
|
|
30
|
+
* request URL's host. Any letter case and a port are fine.
|
|
31
|
+
*/
|
|
32
|
+
hostname?: (request: Request) => string | null;
|
|
33
|
+
/**
|
|
34
|
+
* The path the visitor asked for, percent-encoded as in a URL (`/caf%C3%A9`): it is matched,
|
|
35
|
+
* appended by a catch-all with `p` and reported. Return null to leave the request alone, as with
|
|
36
|
+
* `hostname`. Default: the request URL's path. Set it where the platform changed the path before
|
|
37
|
+
* your code sees it, e.g. a proxy that strips a prefix.
|
|
38
|
+
*/
|
|
39
|
+
path?: (request: Request) => string | null;
|
|
40
|
+
/**
|
|
41
|
+
* The `Location` to send, from the one the rule gives (e.g. a same-site target in the visitor's
|
|
42
|
+
* language). It is also what the report names as the destination. Default: as the rule gives it.
|
|
43
|
+
*/
|
|
44
|
+
location?: (location: string, request: Request) => string;
|
|
45
|
+
/**
|
|
46
|
+
* The visitor's address in reports: only a value the platform sets, never a header a client can
|
|
47
|
+
* forge. Null writes `-`.
|
|
48
|
+
*/
|
|
22
49
|
clientIp?: (request: Request) => string | null;
|
|
50
|
+
/** The User-Agent in reports. Default: the `User-Agent` header. Null writes `-`. */
|
|
51
|
+
userAgent?: (request: Request) => string | null;
|
|
23
52
|
/** `http` or `https` as the visitor asked; default the request URL's scheme. */
|
|
24
53
|
scheme?: (request: Request) => string;
|
|
25
54
|
/** Report IPv4 addresses with the last byte zeroed and IPv6 addresses cut to /48. */
|
|
26
55
|
anonymizeIp?: boolean;
|
|
27
|
-
fetch?: typeof fetch;
|
|
28
56
|
/** Default `console`; null logs nothing. */
|
|
29
57
|
logger?: Logger | null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* What the platform entry and the tests set, and users do not: the platform entries export a
|
|
61
|
+
* `createRedirects` that takes RedirectsOptions only.
|
|
62
|
+
*/
|
|
63
|
+
export interface InternalOptions extends RedirectsOptions {
|
|
64
|
+
/** Next.js passes one that sets `cache: 'no-store'`; tests pass fakes. */
|
|
65
|
+
fetch?: typeof fetch;
|
|
30
66
|
/** The clock report lines are stamped with; tests fix it. */
|
|
31
67
|
now?: () => Date;
|
|
68
|
+
/** How long the request that starts the first rules download waits for it; default 1000. */
|
|
69
|
+
firstWaitMs?: number;
|
|
32
70
|
}
|
|
33
71
|
/** What lets work outlive the response: Next's `event`, a Worker's `ctx`. */
|
|
34
72
|
export interface WaitUntil {
|
|
@@ -50,6 +88,6 @@ export interface Redirects {
|
|
|
50
88
|
/** Sends the report lines still waiting (tests, graceful shutdown). */
|
|
51
89
|
flush(): Promise<void>;
|
|
52
90
|
}
|
|
53
|
-
export declare function createRedirects(options:
|
|
91
|
+
export declare function createRedirects(options: InternalOptions): Redirects;
|
|
54
92
|
/** IPv4 with the last byte zeroed, IPv6 cut to its first 48 bits; anything else unchanged. */
|
|
55
93
|
export declare function anonymize(ip: string): string;
|
package/dist/core/redirects.js
CHANGED
|
@@ -15,7 +15,7 @@ const WARN_EVERY_MS = 10 * 60_000;
|
|
|
15
15
|
export function createRedirects(options) {
|
|
16
16
|
const logger = options.logger === undefined ? console : (options.logger ?? undefined);
|
|
17
17
|
const rulesUrl = options.rulesUrl?.trim() ?? '';
|
|
18
|
-
const source = rulesUrl === '' ? null : new RulesSource({ url: rulesUrl, fetch: options.fetch, logger });
|
|
18
|
+
const source = rulesUrl === '' ? null : new RulesSource({ url: rulesUrl, fetch: options.fetch, firstWaitMs: options.firstWaitMs, logger });
|
|
19
19
|
const key = options.key?.trim() ?? '';
|
|
20
20
|
const reporter = source && key !== '' ? new Reporter({ key, hitsUrl: () => source.current?.hitsUrl ?? null, fetch: options.fetch, logger }) : null;
|
|
21
21
|
const now = options.now ?? (() => new Date());
|
|
@@ -26,9 +26,24 @@ export function createRedirects(options) {
|
|
|
26
26
|
logger?.warn(`${PACKAGE}: request passed on after an error (${error instanceof Error ? error.message : String(error)})`);
|
|
27
27
|
}
|
|
28
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* What the visitor asked for: the hostname as listed in the rules (lowercase, no port) and the
|
|
31
|
+
* URL with the visitor's path, or null when `hostname` or `path` leaves the request alone.
|
|
32
|
+
*/
|
|
33
|
+
const visitOf = (request) => {
|
|
34
|
+
const url = new URL(request.url);
|
|
35
|
+
const hostname = options.hostname ? options.hostname(request) : (request.headers.get('host') ?? url.host);
|
|
36
|
+
const name = hostname === null ? '' : normalizeHost(hostname);
|
|
37
|
+
const path = options.path ? options.path(request) : url.pathname;
|
|
38
|
+
if (name === '' || path === null) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
url.pathname = path;
|
|
42
|
+
return { name, url };
|
|
43
|
+
};
|
|
29
44
|
const keepAliveOf = (context) => (context ? (work) => context.waitUntil(work) : undefined);
|
|
30
45
|
const report = (request, url, host, status, size, keepAlive, redirect) => {
|
|
31
|
-
if (!reporter) {
|
|
46
|
+
if (!reporter || options.report === false || (typeof options.report === 'function' && !options.report(request))) {
|
|
32
47
|
return;
|
|
33
48
|
}
|
|
34
49
|
const ip = options.clientIp?.(request) ?? null;
|
|
@@ -41,12 +56,13 @@ export function createRedirects(options) {
|
|
|
41
56
|
status,
|
|
42
57
|
size,
|
|
43
58
|
referer: request.headers.get('referer'),
|
|
44
|
-
userAgent: request.headers.get('user-agent'),
|
|
59
|
+
userAgent: options.userAgent ? options.userAgent(request) : request.headers.get('user-agent'),
|
|
45
60
|
redirect,
|
|
46
61
|
}, now()), keepAlive);
|
|
47
62
|
};
|
|
48
63
|
const redirect = (request, url, rules, host, rule, keepAlive) => {
|
|
49
|
-
const
|
|
64
|
+
const target = buildLocation(rule, url);
|
|
65
|
+
const location = options.location ? options.location(target, request) : target;
|
|
50
66
|
const headers = new Headers({ Location: byteString(location) });
|
|
51
67
|
if (rules.poweredBy) {
|
|
52
68
|
headers.set('X-Powered-By', rules.poweredBy);
|
|
@@ -59,13 +75,18 @@ export function createRedirects(options) {
|
|
|
59
75
|
return null;
|
|
60
76
|
}
|
|
61
77
|
try {
|
|
78
|
+
// Before the rules: a request the integration leaves alone never waits for them.
|
|
79
|
+
const visit = visitOf(request);
|
|
80
|
+
if (visit === null) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const { name, url } = visit;
|
|
62
84
|
const keepAlive = keepAliveOf(context);
|
|
63
85
|
const rules = await source.get(keepAlive);
|
|
64
|
-
const host = rules?.hosts.get(
|
|
86
|
+
const host = rules?.hosts.get(name);
|
|
65
87
|
if (!rules || !host) {
|
|
66
88
|
return null;
|
|
67
89
|
}
|
|
68
|
-
const url = new URL(request.url);
|
|
69
90
|
if (url.pathname === rules.checkPath) {
|
|
70
91
|
const body = `token=${host.token} etag=${rules.etag}`;
|
|
71
92
|
return new Response(request.method === 'HEAD' ? null : body, {
|
|
@@ -90,15 +111,18 @@ export function createRedirects(options) {
|
|
|
90
111
|
if (!rules) {
|
|
91
112
|
return response;
|
|
92
113
|
}
|
|
93
|
-
const
|
|
114
|
+
const visit = visitOf(request);
|
|
115
|
+
if (visit === null) {
|
|
116
|
+
return response;
|
|
117
|
+
}
|
|
118
|
+
const { name, url } = visit;
|
|
94
119
|
const host = rules.hosts.get(name);
|
|
95
120
|
const keepAlive = keepAliveOf(context);
|
|
96
|
-
const url = new URL(request.url);
|
|
97
121
|
if (host?.catchAll) {
|
|
98
122
|
await response.body?.cancel();
|
|
99
123
|
return redirect(request, url, rules, host, host.catchAll, keepAlive);
|
|
100
124
|
}
|
|
101
|
-
if (
|
|
125
|
+
if (host?.report404 || options.report404 === 'all') {
|
|
102
126
|
report(request, url, name, 404, Number(response.headers.get('content-length') ?? 0) || 0, keepAlive);
|
|
103
127
|
}
|
|
104
128
|
return response;
|
|
@@ -119,10 +143,6 @@ export function createRedirects(options) {
|
|
|
119
143
|
},
|
|
120
144
|
};
|
|
121
145
|
}
|
|
122
|
-
/** The request's hostname: the `Host` header (a self-hosted server's URL names itself), else the URL's. */
|
|
123
|
-
function hostnameOf(request) {
|
|
124
|
-
return normalizeHost(request.headers.get('host') ?? new URL(request.url).host);
|
|
125
|
-
}
|
|
126
146
|
/** Header values carry bytes: a UTF-8 target is sent as its UTF-8 bytes, as other implementations do. */
|
|
127
147
|
function byteString(value) {
|
|
128
148
|
return /^[\x00-\x7f]*$/.test(value) ? value : String.fromCharCode(...new TextEncoder().encode(value));
|
package/dist/core/report.js
CHANGED
|
@@ -86,13 +86,15 @@ export class Reporter {
|
|
|
86
86
|
#dropped = 0;
|
|
87
87
|
#lastDropLog = Number.NEGATIVE_INFINITY;
|
|
88
88
|
constructor(options) {
|
|
89
|
+
// `??` per field, not a spread: an option passed as undefined keeps its default.
|
|
89
90
|
this.#options = {
|
|
91
|
+
key: options.key,
|
|
92
|
+
hitsUrl: options.hitsUrl,
|
|
90
93
|
fetch: options.fetch ?? ((input, init) => fetch(input, init)),
|
|
91
|
-
timeoutMs: 2000,
|
|
92
|
-
maxQueue: 10_000,
|
|
93
|
-
budgetMs: 10_000,
|
|
94
|
-
...options,
|
|
95
94
|
logger: options.logger,
|
|
95
|
+
timeoutMs: options.timeoutMs ?? 2000,
|
|
96
|
+
maxQueue: options.maxQueue ?? 10_000,
|
|
97
|
+
budgetMs: options.budgetMs ?? 10_000,
|
|
96
98
|
};
|
|
97
99
|
}
|
|
98
100
|
/** Queues one line; `keepAlive` holds the invocation open while this call has to send. */
|
package/dist/core/source.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Downloads the rules file and keeps the last good copy in module memory, for runtimes that keep
|
|
3
3
|
* no process or connection between requests (serverless functions, Workers): no timer, no push
|
|
4
|
-
* channel.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* channel. Fails open: before the first good copy, or with no rules address, there are no rules,
|
|
5
|
+
* and nothing throws.
|
|
6
|
+
*
|
|
7
|
+
* No visitor waits long for a download. The request that starts a new instance's first download
|
|
8
|
+
* waits for it at most `firstWaitMs`; if it takes longer, that request goes on without rules and
|
|
9
|
+
* the download finishes in the background. Requests that arrive while a download is running do
|
|
10
|
+
* not wait at all. After the first copy, a stale copy is used at once and refreshed in the
|
|
11
|
+
* background. Only the request that starts a download keeps it alive (`keepAlive`): no request
|
|
12
|
+
* waits on another request's work.
|
|
7
13
|
*/
|
|
8
14
|
import type { Logger } from './log.ts';
|
|
9
15
|
import type { KeepAlive } from './report.ts';
|
|
@@ -16,6 +22,8 @@ export interface RulesSourceOptions {
|
|
|
16
22
|
logger?: Logger;
|
|
17
23
|
/** Per download; default 5000. */
|
|
18
24
|
timeoutMs?: number;
|
|
25
|
+
/** How long the request that starts the first download waits for it; default 1000. */
|
|
26
|
+
firstWaitMs?: number;
|
|
19
27
|
/** After a failed download, wait this long before the next try; default 30,000. */
|
|
20
28
|
retryMs?: number;
|
|
21
29
|
/** A larger file counts as a failed download; default 32 MB. */
|
|
@@ -26,6 +34,6 @@ export declare class RulesSource {
|
|
|
26
34
|
constructor(options: RulesSourceOptions);
|
|
27
35
|
/** The rules in use now, without downloading. */
|
|
28
36
|
get current(): RuleSet | null;
|
|
29
|
-
/** The rules in use,
|
|
37
|
+
/** The rules in use, starting a download when one is due; null while there are none. */
|
|
30
38
|
get(keepAlive?: KeepAlive): Promise<RuleSet | null>;
|
|
31
39
|
}
|
package/dist/core/source.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Downloads the rules file and keeps the last good copy in module memory, for runtimes that keep
|
|
3
3
|
* no process or connection between requests (serverless functions, Workers): no timer, no push
|
|
4
|
-
* channel.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* channel. Fails open: before the first good copy, or with no rules address, there are no rules,
|
|
5
|
+
* and nothing throws.
|
|
6
|
+
*
|
|
7
|
+
* No visitor waits long for a download. The request that starts a new instance's first download
|
|
8
|
+
* waits for it at most `firstWaitMs`; if it takes longer, that request goes on without rules and
|
|
9
|
+
* the download finishes in the background. Requests that arrive while a download is running do
|
|
10
|
+
* not wait at all. After the first copy, a stale copy is used at once and refreshed in the
|
|
11
|
+
* background. Only the request that starts a download keeps it alive (`keepAlive`): no request
|
|
12
|
+
* waits on another request's work.
|
|
7
13
|
*/
|
|
8
14
|
import { parseRules } from "./rules.js";
|
|
9
15
|
import { PACKAGE, USER_AGENT } from "../version.js";
|
|
@@ -18,31 +24,38 @@ export class RulesSource {
|
|
|
18
24
|
#inflight = null;
|
|
19
25
|
#lastWarn = Number.NEGATIVE_INFINITY;
|
|
20
26
|
constructor(options) {
|
|
27
|
+
// `??` per field, not a spread: an option passed as undefined keeps its default.
|
|
21
28
|
this.#options = {
|
|
29
|
+
url: options.url,
|
|
22
30
|
fetch: options.fetch ?? ((input, init) => fetch(input, init)),
|
|
23
|
-
timeoutMs: 5000,
|
|
24
|
-
retryMs: 30_000,
|
|
25
|
-
maxBytes: 32 << 20,
|
|
26
|
-
...options,
|
|
27
31
|
logger: options.logger,
|
|
32
|
+
timeoutMs: options.timeoutMs ?? 5000,
|
|
33
|
+
firstWaitMs: options.firstWaitMs ?? 1000,
|
|
34
|
+
retryMs: options.retryMs ?? 30_000,
|
|
35
|
+
maxBytes: options.maxBytes ?? 32 << 20,
|
|
28
36
|
};
|
|
29
37
|
}
|
|
30
38
|
/** The rules in use now, without downloading. */
|
|
31
39
|
get current() {
|
|
32
40
|
return this.#rules;
|
|
33
41
|
}
|
|
34
|
-
/** The rules in use,
|
|
42
|
+
/** The rules in use, starting a download when one is due; null while there are none. */
|
|
35
43
|
async get(keepAlive) {
|
|
36
44
|
const now = Date.now();
|
|
37
45
|
const due = this.#rules === null || now - this.#fetchedAt >= this.#rules.refresh * 1000;
|
|
38
|
-
if (due
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
if (!due || now < this.#nextTry || this.#inflight !== null) {
|
|
47
|
+
return this.#rules;
|
|
48
|
+
}
|
|
49
|
+
// Cleared once settled; set first, so even a download that fails at once is cleared.
|
|
50
|
+
const download = this.#download().finally(() => {
|
|
51
|
+
this.#inflight = null;
|
|
52
|
+
});
|
|
53
|
+
this.#inflight = download;
|
|
54
|
+
keepAlive?.(download);
|
|
55
|
+
if (this.#rules === null) {
|
|
56
|
+
let timer;
|
|
57
|
+
await Promise.race([download, new Promise((resolve) => (timer = setTimeout(resolve, this.#options.firstWaitMs)))]);
|
|
58
|
+
clearTimeout(timer);
|
|
46
59
|
}
|
|
47
60
|
return this.#rules;
|
|
48
61
|
}
|
|
@@ -84,9 +97,6 @@ export class RulesSource {
|
|
|
84
97
|
catch (error) {
|
|
85
98
|
this.#fail(error instanceof Error ? `${error.name}: ${error.message}` : 'error');
|
|
86
99
|
}
|
|
87
|
-
finally {
|
|
88
|
-
this.#inflight = null;
|
|
89
|
-
}
|
|
90
100
|
}
|
|
91
101
|
#fail(reason) {
|
|
92
102
|
const now = Date.now();
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* check path and exact rules, passes everything else to the origin, and where the origin answers
|
|
4
4
|
* 404 redirects to the catch-all or reports the 404.
|
|
5
5
|
*/
|
|
6
|
-
import { type RedirectsOptions, type WaitUntil } from './core/redirects.ts';
|
|
6
|
+
import { type Redirects, type RedirectsOptions, type WaitUntil } from './core/redirects.ts';
|
|
7
|
+
export type { Logger } from './core/log.ts';
|
|
8
|
+
export type { Redirects, RedirectsOptions, WaitUntil };
|
|
7
9
|
export interface WorkerSettings {
|
|
8
10
|
rulesUrl: string | null | undefined;
|
|
9
11
|
key?: string | null;
|
|
@@ -26,11 +28,8 @@ export interface WorkerOptions<Env> extends Omit<RedirectsOptions, 'rulesUrl' |
|
|
|
26
28
|
export declare function createWorker<Env>(options: WorkerOptions<Env>): {
|
|
27
29
|
fetch(request: Request, env: Env, context: WaitUntil): Promise<Response>;
|
|
28
30
|
};
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
export
|
|
34
|
-
export { buildLocation, DEFAULT_CHECK_PATH, FORMAT_MAJOR, type HostRules, matchExact, matchPath, normalizeHost, parseRules, type RedirectStatus, type Rule, type RuleSet, UnknownFormatError, } from './core/rules.ts';
|
|
35
|
-
export { RulesSource, type RulesSourceOptions } from './core/source.ts';
|
|
36
|
-
export { PACKAGE, USER_AGENT, VERSION } from './version.ts';
|
|
31
|
+
/**
|
|
32
|
+
* The integration in two steps, for a Worker with its own routing: `handle` before the origin,
|
|
33
|
+
* `afterSite` after it, or `serve` for both. The visitor's address defaults to `CF-Connecting-IP`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createRedirects(options: RedirectsOptions): Redirects;
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* check path and exact rules, passes everything else to the origin, and where the origin answers
|
|
4
4
|
* 404 redirects to the catch-all or reports the 404.
|
|
5
5
|
*/
|
|
6
|
-
import { createRedirects } from "./core/redirects.js";
|
|
6
|
+
import { createRedirects as create } from "./core/redirects.js";
|
|
7
7
|
/**
|
|
8
8
|
* A module Worker:
|
|
9
9
|
*
|
|
@@ -22,18 +22,20 @@ export function createWorker(options) {
|
|
|
22
22
|
const { rulesUrl, key } = settings(env);
|
|
23
23
|
const id = `${rulesUrl ?? ''}\0${key ?? ''}`;
|
|
24
24
|
if (instance?.id !== id) {
|
|
25
|
-
instance = { id, redirects: createRedirects({
|
|
25
|
+
instance = { id, redirects: createRedirects({ ...rest, rulesUrl, key }) };
|
|
26
26
|
}
|
|
27
27
|
return instance.redirects.serve(request, (next) => origin(next, env, context), context);
|
|
28
28
|
},
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The integration in two steps, for a Worker with its own routing: `handle` before the origin,
|
|
33
|
+
* `afterSite` after it, or `serve` for both. The visitor's address defaults to `CF-Connecting-IP`.
|
|
34
|
+
*/
|
|
35
|
+
export function createRedirects(options) {
|
|
36
|
+
return create({ ...options, clientIp: options.clientIp ?? cloudflareClientIp });
|
|
37
|
+
}
|
|
31
38
|
/** `CF-Connecting-IP`, which Cloudflare sets. */
|
|
32
|
-
|
|
39
|
+
function cloudflareClientIp(request) {
|
|
33
40
|
return request.headers.get('cf-connecting-ip');
|
|
34
41
|
}
|
|
35
|
-
export { anonymize, createRedirects } from "./core/redirects.js";
|
|
36
|
-
export { hitLine, Reporter } from "./core/report.js";
|
|
37
|
-
export { buildLocation, DEFAULT_CHECK_PATH, FORMAT_MAJOR, matchExact, matchPath, normalizeHost, parseRules, UnknownFormatError, } from "./core/rules.js";
|
|
38
|
-
export { RulesSource } from "./core/source.js";
|
|
39
|
-
export { PACKAGE, USER_AGENT, VERSION } from "./version.js";
|
package/dist/version.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** This package: names its log lines and the User-Agent of rules downloads and reports. */
|
|
2
2
|
export declare const PACKAGE = "cloudflare-redirects";
|
|
3
3
|
/** Equal to package.json's version (a test checks it). */
|
|
4
|
-
export declare const VERSION = "0.
|
|
5
|
-
export declare const USER_AGENT = "cloudflare-redirects/0.
|
|
4
|
+
export declare const VERSION = "0.2.0";
|
|
5
|
+
export declare const USER_AGENT = "cloudflare-redirects/0.2.0";
|
package/dist/version.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** This package: names its log lines and the User-Agent of rules downloads and reports. */
|
|
2
2
|
export const PACKAGE = 'cloudflare-redirects';
|
|
3
3
|
/** Equal to package.json's version (a test checks it). */
|
|
4
|
-
export const VERSION = '0.
|
|
4
|
+
export const VERSION = '0.2.0';
|
|
5
5
|
export const USER_AGENT = `${PACKAGE}/${VERSION}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redirhub/cloudflare-redirects",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Bulk redirects and 404 monitoring for Cloudflare Workers from a remote rules file: thousands of 301/302 redirects, short links and website migrations in front of your origin, live within a minute without a redeploy. RedirHub Integration Mode.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -59,5 +59,9 @@
|
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^22.10.0",
|
|
61
61
|
"typescript": "^5.9.0"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public",
|
|
65
|
+
"provenance": true
|
|
62
66
|
}
|
|
63
67
|
}
|