@redirhub/cloudflare-redirects 0.2.0 → 0.3.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 CHANGED
@@ -9,8 +9,8 @@ in front of your origin, from a remote rules file, and monitor its 404s.** Old U
9
9
  whole website migrations redirect at Cloudflare's edge; every redirect and every 404 is reported
10
10
  back, so broken links show up before your visitors find them.
11
11
 
12
- - Redirects, short links and whole website migrations, live within a minute, without
13
- redeploying the Worker.
12
+ - Redirects, short links, frame pages and whole website migrations, live within a minute,
13
+ without redeploying the Worker.
14
14
  - 404 monitoring: the origin's 404s that no rule redirected are reported in the background.
15
15
  - Fails open: no rules yet, a download error or a bug never breaks a page; the request goes on to
16
16
  the origin.
@@ -148,7 +148,7 @@ the origin unchanged.
148
148
  1. **Check path** (`/_redirhub/check`): `200`, `text/plain`, `Cache-Control: no-store`, body
149
149
  `token=<check token> etag=<ETag of the rules in use>`. It proves the hostname is yours and
150
150
  tells RedirHub which rules you serve.
151
- 2. **Exact rules** redirect before the origin, even when the page exists.
151
+ 2. **Exact rules** redirect (or show their frame page) before the origin, even when the page exists.
152
152
  3. Otherwise the origin answers. Only when it answers **404** and the hostname has a **catch-all
153
153
  rule** (`{host}/*`), the 404 is discarded and the Worker redirects instead; a working page is
154
154
  never taken over. Without a catch-all, the 404 goes to the visitor and is reported when the
@@ -158,20 +158,32 @@ Matching follows format v1: the hostname in any case, port ignored; the path cas
158
158
  percent-decoded, leading and trailing slashes ignored; scheme and query ignored. Every redirect
159
159
  carries `X-Powered-By` when the rules file has `@powered-by`.
160
160
 
161
+ **Frame pages.** A rule with status `frame` shows its target in a frame instead of redirecting:
162
+ `200`, `text/html; charset=utf-8`, a page whose one full-window `<iframe>` loads the address a
163
+ redirect would send (flags `p` and `q` applied), titled with the target's hostname; the visitor's
164
+ address bar keeps their URL. It is matched like a redirect (an exact rule before the origin, a
165
+ catch-all only on the origin's 404), carries `X-Powered-By` and is reported, with status `200`.
166
+ HEAD gets the same status and headers without the page.
167
+
168
+ **Options** in a rule's fifth column (`password=`, `destinations=`, `routing=`, `plugins=`, and any
169
+ other) are ignored, as the format allows: the rule redirects, or shows its frame, to its target.
170
+
161
171
  **Rules download.** The first request of a new isolate starts the download and waits for it at
162
172
  most one second; if the rules server is slower, that request goes to the origin without rules and
163
173
  the download (5 second timeout) finishes in `ctx.waitUntil`. Requests that arrive meanwhile do not
164
174
  wait. After that the copy in memory is used, and once it is older than `@refresh` seconds (60 by
165
175
  default) it is revalidated in `ctx.waitUntil` with `If-None-Match`, so no visitor waits. Any
166
176
  failure keeps the last good copy and waits 30 seconds before trying again. `@notify` (push) is
167
- ignored: a Worker keeps no connection open.
177
+ ignored: a Worker keeps no connection open. Rules downloads and reports identify themselves as
178
+ `User-Agent: cloudflare-redirects/<version>`.
168
179
 
169
180
  **Reports.** A line queued while nothing is being sent goes out at once; lines queued while a
170
181
  request is out go together in the next one (at most 100 lines or 64 KB, gzip with
171
- `CompressionStream`, 2 second timeout). Only the request that starts sending waits for it, in
172
- `ctx.waitUntil`, for at most 10 seconds; the others only add their line, so no request waits on
173
- another's work. A failed or rejected batch is dropped, never retried; beyond 10,000 waiting lines,
174
- lines are dropped.
182
+ `CompressionStream`, 2 second timeout), and each full batch opens another request, up to four at
183
+ once. Only the request that starts sending waits for them, in `ctx.waitUntil`, for at most 10
184
+ seconds; the others only add their line, so no request waits on another's work. A failed or
185
+ rejected batch is dropped, never retried; beyond 10,000 waiting lines, lines are dropped. Dropped
186
+ lines are logged at once, then at most every ten minutes with the total by reason.
175
187
 
176
188
  ## Privacy
177
189
 
@@ -184,9 +196,10 @@ Each report is one Apache combined line with the absolute URL:
184
196
  That is the visitor's IP address (`CF-Connecting-IP`, or anonymized with `anonymizeIp`), the time
185
197
  (UTC), the method, the requested URL with its query string, the status, the response size, the
186
198
  Referer and the User-Agent (each cut to 1 KB); for a redirect also the rule's source and the
187
- `Location` sent. The protocol is always written `HTTP/1.1`. No cookies, other headers or bodies
188
- are sent. Only redirects, and 404s of hostnames with 404 reporting on, are reported; all other
189
- traffic stays between the visitor and your origin. Reports go only to the `@hits` address of your
199
+ `Location` sent, for a frame page the rule's source and the frame's address. The protocol is
200
+ always written `HTTP/1.1`. No cookies, other headers or bodies are sent. Only redirects, frame
201
+ pages, and 404s of hostnames with 404 reporting on, are reported; all other traffic stays between
202
+ the visitor and your origin. Reports go only to the `@hits` address of your
190
203
  rules file, with your key.
191
204
 
192
205
  ## Limits
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The integration on any runtime with the Fetch API (Request, Response, fetch): answers the check
3
3
  * path, redirects exact rules before the site, redirects a hostname's catch-all where the site
4
- * answers 404, and reports redirects and 404s. The platform entry (`src/index.ts`) only says
4
+ * answers 404, shows `frame` rules in a frame page instead of redirecting, and reports redirects,
5
+ * frame pages and 404s. The platform entry (`src/index.ts`) only says
5
6
  * where the visitor's address and the background work come from.
6
7
  *
7
8
  * `src/core/` is the same in every JavaScript implementation (nextjs-redirects,
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * The integration on any runtime with the Fetch API (Request, Response, fetch): answers the check
3
3
  * path, redirects exact rules before the site, redirects a hostname's catch-all where the site
4
- * answers 404, and reports redirects and 404s. The platform entry (`src/index.ts`) only says
4
+ * answers 404, shows `frame` rules in a frame page instead of redirecting, and reports redirects,
5
+ * frame pages and 404s. The platform entry (`src/index.ts`) only says
5
6
  * where the visitor's address and the background work come from.
6
7
  *
7
8
  * `src/core/` is the same in every JavaScript implementation (nextjs-redirects,
8
9
  * cloudflare-redirects); CI compares it with the others.
9
10
  */
10
11
  import { hitLine, Reporter } from "./report.js";
11
- import { buildLocation, matchExact, normalizeHost } from "./rules.js";
12
+ import { buildLocation, framePage, matchExact, normalizeHost } from "./rules.js";
12
13
  import { RulesSource } from "./source.js";
13
14
  import { PACKAGE } from "../version.js";
14
15
  const WARN_EVERY_MS = 10 * 60_000;
@@ -60,14 +61,23 @@ export function createRedirects(options) {
60
61
  redirect,
61
62
  }, now()), keepAlive);
62
63
  };
64
+ /** A rule's answer: its redirect, or for a `frame` rule a `200` page framing the same address. */
63
65
  const redirect = (request, url, rules, host, rule, keepAlive) => {
64
66
  const target = buildLocation(rule, url);
65
67
  const location = options.location ? options.location(target, request) : target;
66
- const headers = new Headers({ Location: byteString(location) });
68
+ const headers = new Headers();
67
69
  if (rules.poweredBy) {
68
70
  headers.set('X-Powered-By', rules.poweredBy);
69
71
  }
70
- report(request, url, host.name, rule.status, 0, keepAlive, { source: rule.source, location });
72
+ // A frame page is reported like a redirect, with status 200 and size 0 as the format says.
73
+ report(request, url, host.name, rule.status === 'frame' ? 200 : rule.status, 0, keepAlive, { source: rule.source, location });
74
+ if (rule.status === 'frame') {
75
+ const page = framePage(location, rule.target);
76
+ headers.set('Content-Type', 'text/html; charset=utf-8');
77
+ headers.set('Content-Length', String(new TextEncoder().encode(page).length));
78
+ return new Response(request.method === 'HEAD' ? null : page, { status: 200, headers });
79
+ }
80
+ headers.set('Location', byteString(location));
71
81
  return new Response(null, { status: rule.status, headers });
72
82
  };
73
83
  const handle = async (request, context) => {
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Visit reports to the rules file's `@hits` address: one Apache combined line per redirect or
3
- * 404, as specified in redirhub/integration-spec (spec/v1/reports.txt is the golden sample).
2
+ * Visit reports to the rules file's `@hits` address: one Apache combined line per redirect, frame
3
+ * page or 404, as specified in redirhub/integration-spec (spec/v1/reports.txt is the golden sample).
4
4
  */
5
5
  import type { Logger } from './log.ts';
6
6
  export interface Hit {
@@ -14,11 +14,11 @@ export interface Hit {
14
14
  /** The request URL; its escaped path and query are the request target. */
15
15
  url: URL;
16
16
  status: number;
17
- /** Body bytes sent: 0 for a redirect. */
17
+ /** Body bytes sent: 0 for a redirect and a frame page. */
18
18
  size: number;
19
19
  referer: string | null;
20
20
  userAgent: string | null;
21
- /** For a redirect: the rule's source column and the `Location` sent. */
21
+ /** For a redirect or frame page: the rule's source column and the `Location` sent, or the frame's `src`. */
22
22
  redirect?: {
23
23
  source: string;
24
24
  location: string;
@@ -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 {
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Visit reports to the rules file's `@hits` address: one Apache combined line per redirect or
3
- * 404, as specified in redirhub/integration-spec (spec/v1/reports.txt is the golden sample).
2
+ * Visit reports to the rules file's `@hits` address: one Apache combined line per redirect, frame
3
+ * page or 404, as specified in redirhub/integration-spec (spec/v1/reports.txt is the golden sample).
4
4
  */
5
5
  import { PACKAGE, USER_AGENT } from "../version.js";
6
6
  /** The Referer and User-Agent are cut to this many bytes. */
@@ -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
  }
@@ -12,6 +12,8 @@ export declare const DEFAULT_REFRESH = 60;
12
12
  export declare const MIN_REFRESH = 30;
13
13
  export declare const MAX_REFRESH = 3600;
14
14
  export type RedirectStatus = 301 | 302 | 307 | 308;
15
+ /** A rule's status column: a redirect status, or `frame` (a page that shows the target in a frame). */
16
+ export type RuleStatus = RedirectStatus | 'frame';
15
17
  /** A rules file whose `@format` major version is not 1: the caller keeps its last good copy. */
16
18
  export declare class UnknownFormatError extends Error {
17
19
  name: string;
@@ -20,7 +22,7 @@ export interface Rule {
20
22
  /** The source column as written; reports name the rule with it (`file=`). */
21
23
  source: string;
22
24
  target: string;
23
- status: RedirectStatus;
25
+ status: RuleStatus;
24
26
  /** `p`: append the request path (catch-all only). */
25
27
  appendPath: boolean;
26
28
  /** `q`: keep the request query string. */
@@ -46,7 +48,7 @@ export interface RuleSet {
46
48
  /** Where redirects and 404s are reported; null disables reporting. */
47
49
  hitsUrl: string | null;
48
50
  checkPath: string;
49
- /** Sent as `X-Powered-By` on every redirect from the rules; null sends none. */
51
+ /** Sent as `X-Powered-By` on every redirect and frame page from the rules; null sends none. */
50
52
  poweredBy: string | null;
51
53
  /** The `@notify` push channel. Parsed for completeness; this package polls. */
52
54
  notifyUrl: string | null;
@@ -75,8 +77,16 @@ export declare function cleanEtag(etag: string): string;
75
77
  /** The exact rule for a request URL on a listed hostname, or null. */
76
78
  export declare function matchExact(host: HostRules, url: URL): Rule | null;
77
79
  /**
78
- * The `Location` of a rule for a request URL. With `appendPath` the escaped request path is
79
- * appended to the target's path without a double slash; with `keepQuery` the request query is
80
- * added with `?` or `&`, otherwise dropped. A fragment of the target stays last.
80
+ * The `Location` of a rule for a request URL, and a `frame` rule's iframe `src`. With `appendPath`
81
+ * the escaped request path is appended to the target's path without a double slash; with
82
+ * `keepQuery` the request query is added with `?` or `&`, otherwise dropped. A fragment of the
83
+ * target stays last.
81
84
  */
82
85
  export declare function buildLocation(rule: Rule, url: URL): string;
86
+ /**
87
+ * The page of a `frame` rule: one full-window `<iframe>` showing `src`, titled with the target's
88
+ * hostname. Both are HTML-escaped.
89
+ */
90
+ export declare function framePage(src: string, target: string): string;
91
+ /** `&`, `<`, `>`, `"` and `'` as character references. */
92
+ export declare function escapeHtml(value: string): string;
@@ -143,7 +143,10 @@ function parseRuleLine(line) {
143
143
  }
144
144
  const rule = { source, target, status: 301, appendPath: false, keepQuery: false };
145
145
  const status = columns[2]?.trim() ?? '';
146
- if (status !== '') {
146
+ if (status === 'frame') {
147
+ rule.status = 'frame';
148
+ }
149
+ else if (status !== '') {
147
150
  if (!/^[+-]?\d+$/.test(status) || !STATUSES.has(Number(status))) {
148
151
  return null;
149
152
  }
@@ -263,9 +266,10 @@ export function matchExact(host, url) {
263
266
  return host.exact.get(matchPath(url.pathname)) ?? null;
264
267
  }
265
268
  /**
266
- * The `Location` of a rule for a request URL. With `appendPath` the escaped request path is
267
- * appended to the target's path without a double slash; with `keepQuery` the request query is
268
- * added with `?` or `&`, otherwise dropped. A fragment of the target stays last.
269
+ * The `Location` of a rule for a request URL, and a `frame` rule's iframe `src`. With `appendPath`
270
+ * the escaped request path is appended to the target's path without a double slash; with
271
+ * `keepQuery` the request query is added with `?` or `&`, otherwise dropped. A fragment of the
272
+ * target stays last.
269
273
  */
270
274
  export function buildLocation(rule, url) {
271
275
  const hash = rule.target.indexOf('#');
@@ -283,3 +287,28 @@ export function buildLocation(rule, url) {
283
287
  }
284
288
  return hash < 0 ? location : `${location}#${rule.target.slice(hash + 1)}`;
285
289
  }
290
+ /**
291
+ * The page of a `frame` rule: one full-window `<iframe>` showing `src`, titled with the target's
292
+ * hostname. Both are HTML-escaped.
293
+ */
294
+ export function framePage(src, target) {
295
+ return [
296
+ '<!DOCTYPE html>',
297
+ '<html>',
298
+ '<head>',
299
+ '<meta charset="utf-8">',
300
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
301
+ `<title>${escapeHtml(new URL(target).hostname)}</title>`,
302
+ '<style>html,body{margin:0;height:100%;overflow:hidden}iframe{display:block;width:100%;height:100%;border:0}</style>',
303
+ '</head>',
304
+ '<body>',
305
+ `<iframe src="${escapeHtml(src)}" allowfullscreen></iframe>`,
306
+ '</body>',
307
+ '</html>',
308
+ '',
309
+ ].join('\n');
310
+ }
311
+ /** `&`, `<`, `>`, `"` and `'` as character references. */
312
+ export function escapeHtml(value) {
313
+ return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
314
+ }
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.2.0";
5
- export declare const USER_AGENT = "cloudflare-redirects/0.2.0";
4
+ export declare const VERSION = "0.3.0";
5
+ export declare const USER_AGENT = "cloudflare-redirects/0.3.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.2.0';
4
+ export const VERSION = '0.3.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.2.0",
3
+ "version": "0.3.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",