@redirhub/cloudflare-redirects 0.1.1 → 0.2.1

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 CHANGED
@@ -99,10 +99,12 @@ add your own logic, pass `origin: (request, env, ctx) => …`.
99
99
  | Option | |
100
100
  | --- | --- |
101
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. |
102
103
  | `origin(request, env, ctx)` | Where requests no rule answers go; default `fetch(request)`. |
103
104
  | `report404` | `rules` (default): 404s of hostnames with **Report 404 pages** on; `all`: every hostname the Worker serves. |
104
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. |
105
- | `path(request)` | The path the visitor asked for, percent-encoded; `null` leaves the request alone. Default the request URL's path. |
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. |
106
108
  | `clientIp(request)` | The visitor's address in reports; default `CF-Connecting-IP`. `null` writes `-`. |
107
109
  | `userAgent(request)` | The User-Agent in reports; default the `User-Agent` header. `null` writes `-`. |
108
110
  | `anonymizeIp` | Report IPv4 with the last byte zeroed, IPv6 cut to /48. |
@@ -127,7 +129,8 @@ export default createWorker<Env>({
127
129
  const { pathname } = new URL(request.url)
128
130
  return pathname.startsWith('/api/') ? null : pathname
129
131
  },
130
- userAgent: (request) => request.headers.get('user-agent'),
132
+ // Testing on workers.dev redirects, but is not reported.
133
+ report: (request) => !new URL(request.url).hostname.endsWith('.workers.dev'),
131
134
  anonymizeIp: true,
132
135
  })
133
136
  ```
@@ -165,10 +168,11 @@ ignored: a Worker keeps no connection open.
165
168
 
166
169
  **Reports.** A line queued while nothing is being sent goes out at once; lines queued while a
167
170
  request is out go together in the next one (at most 100 lines or 64 KB, gzip with
168
- `CompressionStream`, 2 second timeout). Only the request that starts sending waits for it, in
169
- `ctx.waitUntil`, for at most 10 seconds; the others only add their line, so no request waits on
170
- another's work. A failed or rejected batch is dropped, never retried; beyond 10,000 waiting lines,
171
- lines are dropped.
171
+ `CompressionStream`, 2 second timeout), and each full batch opens another request, up to four at
172
+ once. Only the request that starts sending waits for them, in `ctx.waitUntil`, for at most 10
173
+ seconds; the others only add their line, so no request waits on another's work. A failed or
174
+ rejected batch is dropped, never retried; beyond 10,000 waiting lines, lines are dropped. Dropped
175
+ lines are logged at once, then at most every ten minutes with the total by reason.
172
176
 
173
177
  ## Privacy
174
178
 
@@ -13,6 +13,11 @@ 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.
@@ -32,6 +37,11 @@ export interface RedirectsOptions {
32
37
  * your code sees it, e.g. a proxy that strips a prefix.
33
38
  */
34
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;
35
45
  /**
36
46
  * The visitor's address in reports: only a value the platform sets, never a header a client can
37
47
  * forge. Null writes `-`.
@@ -43,7 +43,7 @@ export function createRedirects(options) {
43
43
  };
44
44
  const keepAliveOf = (context) => (context ? (work) => context.waitUntil(work) : undefined);
45
45
  const report = (request, url, host, status, size, keepAlive, redirect) => {
46
- if (!reporter) {
46
+ if (!reporter || options.report === false || (typeof options.report === 'function' && !options.report(request))) {
47
47
  return;
48
48
  }
49
49
  const ip = options.clientIp?.(request) ?? null;
@@ -61,7 +61,8 @@ export function createRedirects(options) {
61
61
  }, now()), keepAlive);
62
62
  };
63
63
  const redirect = (request, url, rules, host, rule, keepAlive) => {
64
- const location = buildLocation(rule, url);
64
+ const target = buildLocation(rule, url);
65
+ const location = options.location ? options.location(target, request) : target;
65
66
  const headers = new Headers({ Location: byteString(location) });
66
67
  if (rules.poweredBy) {
67
68
  headers.set('X-Powered-By', rules.poweredBy);
@@ -47,15 +47,18 @@ export interface ReporterOptions {
47
47
  maxQueue?: number;
48
48
  /** How long one invocation keeps sending before it leaves the rest to the next; default 10,000. */
49
49
  budgetMs?: number;
50
+ /** Requests out at the same time; default 4. */
51
+ maxInFlight?: number;
50
52
  }
51
53
  /**
52
54
  * Sends report lines without delaying a response and without a timer.
53
55
  *
54
56
  * A line queued while nothing is being sent goes out at once, so a site with little traffic
55
- * reports each visit immediately and keeps nothing waiting. Lines queued while a request is out
56
- * wait for it and go together in the next one (at most 100 lines, 64 KB, gzip), so a burst costs
57
- * one request per hundred visits. The invocation that starts sending keeps itself alive (through
58
- * `keepAlive`) until the queue is empty; the others only add their line, so no request waits on
57
+ * reports each visit immediately and keeps nothing waiting. Lines queued while requests are out
58
+ * go together in the next one (at most 100 lines, 64 KB, gzip), so a burst costs one request per
59
+ * hundred visits; up to `maxInFlight` (4) requests are out at once, so a busy instance keeps up.
60
+ * The invocation that starts sending keeps itself alive (through `keepAlive`) until the queue is
61
+ * empty and its requests are back; the others only add their line, so no request waits on
59
62
  * another request's work. A failed or rejected batch is dropped and counted, never retried.
60
63
  */
61
64
  export declare class Reporter {
@@ -73,17 +73,25 @@ const DROP_LOG_EVERY_MS = 10 * 60_000;
73
73
  * Sends report lines without delaying a response and without a timer.
74
74
  *
75
75
  * A line queued while nothing is being sent goes out at once, so a site with little traffic
76
- * reports each visit immediately and keeps nothing waiting. Lines queued while a request is out
77
- * wait for it and go together in the next one (at most 100 lines, 64 KB, gzip), so a burst costs
78
- * one request per hundred visits. The invocation that starts sending keeps itself alive (through
79
- * `keepAlive`) until the queue is empty; the others only add their line, so no request waits on
76
+ * reports each visit immediately and keeps nothing waiting. Lines queued while requests are out
77
+ * go together in the next one (at most 100 lines, 64 KB, gzip), so a burst costs one request per
78
+ * hundred visits; up to `maxInFlight` (4) requests are out at once, so a busy instance keeps up.
79
+ * The invocation that starts sending keeps itself alive (through `keepAlive`) until the queue is
80
+ * empty and its requests are back; the others only add their line, so no request waits on
80
81
  * another request's work. A failed or rejected batch is dropped and counted, never retried.
81
82
  */
82
83
  export class Reporter {
83
84
  #options;
84
85
  #queue = [];
86
+ /** The sender: the invocation that started it keeps it alive until it is done. */
85
87
  #sending = null;
86
- #dropped = 0;
88
+ /** The sender's requests out now, and when it started (for the budget); #running once it has. */
89
+ #out = new Set();
90
+ #started = 0;
91
+ #running = false;
92
+ /** Lines dropped since the last log, by reason. */
93
+ #dropped = new Map();
94
+ #droppedSince = 0;
87
95
  #lastDropLog = Number.NEGATIVE_INFINITY;
88
96
  constructor(options) {
89
97
  // `??` per field, not a spread: an option passed as undefined keeps its default.
@@ -95,6 +103,7 @@ export class Reporter {
95
103
  timeoutMs: options.timeoutMs ?? 2000,
96
104
  maxQueue: options.maxQueue ?? 10_000,
97
105
  budgetMs: options.budgetMs ?? 10_000,
106
+ maxInFlight: options.maxInFlight ?? 4,
98
107
  };
99
108
  }
100
109
  /** Queues one line; `keepAlive` holds the invocation open while this call has to send. */
@@ -102,6 +111,7 @@ export class Reporter {
102
111
  if (this.#options.key === '') {
103
112
  return;
104
113
  }
114
+ this.#logDrops();
105
115
  if (this.#queue.length >= this.#options.maxQueue) {
106
116
  this.#drop(1, 'queue full');
107
117
  return;
@@ -111,6 +121,9 @@ export class Reporter {
111
121
  this.#sending = this.#send();
112
122
  keepAlive?.(this.#sending);
113
123
  }
124
+ else if (this.#running) {
125
+ this.#fill();
126
+ }
114
127
  }
115
128
  /** Resolves once everything queued so far has been sent or dropped. */
116
129
  async flush() {
@@ -125,14 +138,31 @@ export class Reporter {
125
138
  async #send() {
126
139
  // Lines queued in the same turn of the event loop go in the first request.
127
140
  await Promise.resolve();
128
- const started = Date.now();
141
+ this.#started = Date.now();
142
+ this.#running = true;
129
143
  for (;;) {
130
- if (this.#queue.length === 0 || Date.now() - started >= this.#options.budgetMs) {
144
+ this.#fill();
145
+ if (this.#out.size === 0) {
131
146
  // Checked and cleared in one step: a line queued after this starts a new sender.
147
+ this.#running = false;
132
148
  this.#sending = null;
133
149
  return;
134
150
  }
135
- await this.#post(this.#take());
151
+ await Promise.race(this.#out);
152
+ }
153
+ }
154
+ /**
155
+ * Starts requests for the waiting lines, within the budget: one when none is out, and more,
156
+ * up to maxInFlight, only for full batches, so lines that arrive while a request is out wait
157
+ * for it and go together. Called by the sender and on each line queued while it runs.
158
+ */
159
+ #fill() {
160
+ while (this.#out.size < this.#options.maxInFlight &&
161
+ this.#queue.length > 0 &&
162
+ (this.#out.size === 0 || this.#queue.length >= MAX_BATCH_LINES) &&
163
+ Date.now() - this.#started < this.#options.budgetMs) {
164
+ const post = this.#post(this.#take()).finally(() => this.#out.delete(post));
165
+ this.#out.add(post);
136
166
  }
137
167
  }
138
168
  #take() {
@@ -180,15 +210,27 @@ export class Reporter {
180
210
  this.#drop(lines.length, error instanceof Error ? error.name : 'error');
181
211
  }
182
212
  }
183
- /** Counts dropped lines and logs them at most once per ten minutes. */
213
+ /** Counts dropped lines by reason; see #logDrops. */
184
214
  #drop(lines, reason) {
185
- this.#dropped += lines;
186
- const now = Date.now();
187
- if (now - this.#lastDropLog < DROP_LOG_EVERY_MS) {
215
+ if (this.#dropped.size === 0) {
216
+ this.#droppedSince = Date.now();
217
+ }
218
+ this.#dropped.set(reason, (this.#dropped.get(reason) ?? 0) + lines);
219
+ this.#logDrops();
220
+ }
221
+ /**
222
+ * Logs the dropped lines: the first drop at once, then at most once per ten minutes with the
223
+ * total since the last log, by reason. Called on every drop and every line queued, so a count
224
+ * is logged even after the drops stop, without a timer.
225
+ */
226
+ #logDrops() {
227
+ if (this.#dropped.size === 0 || Date.now() - this.#lastDropLog < DROP_LOG_EVERY_MS) {
188
228
  return;
189
229
  }
190
- this.#options.logger?.warn(`${PACKAGE}: ${this.#dropped} report lines dropped (${reason})`);
191
- this.#dropped = 0;
192
- this.#lastDropLog = now;
230
+ const total = [...this.#dropped.values()].reduce((sum, n) => sum + n, 0);
231
+ const reasons = [...this.#dropped].map(([reason, n]) => `${reason} ${n}`).join(', ');
232
+ this.#options.logger?.warn(`${PACKAGE}: ${total} report lines dropped since ${new Date(this.#droppedSince).toISOString()} (${reasons})`);
233
+ this.#dropped.clear();
234
+ this.#lastDropLog = Date.now();
193
235
  }
194
236
  }
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.1.1";
5
- export declare const USER_AGENT = "cloudflare-redirects/0.1.1";
4
+ export declare const VERSION = "0.2.1";
5
+ export declare const USER_AGENT = "cloudflare-redirects/0.2.1";
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.1.1';
4
+ export const VERSION = '0.2.1';
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.1.1",
3
+ "version": "0.2.1",
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",