@thomasfosterau/effect-robots 0.0.0-stage → 0.1.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/dist/index.js ADDED
@@ -0,0 +1,702 @@
1
+ import * as Arr from "effect/Array";
2
+ import * as Data from "effect/Data";
3
+ import * as Duration from "effect/Duration";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Option from "effect/Option";
6
+ import * as Predicate from "effect/Predicate";
7
+ import * as Schema from "effect/Schema";
8
+ import * as SchemaTransformation from "effect/SchemaTransformation";
9
+ import * as Str from "effect/String";
10
+ import * as HttpClient from "effect/http/HttpClient";
11
+ //#region src/index.ts
12
+ /**
13
+ * `@thomasfosterau/effect-robots` — the Robots Exclusion Protocol
14
+ * (RFC 9309) for Effect.
15
+ *
16
+ * Three layers, in one entry:
17
+ *
18
+ * 1. **The library**, its API and behaviour held fixed by the verbatim
19
+ * suite in `tests/library.test.ts`: the `Rule` / `RobotGroup` /
20
+ * `RobotsTxt` data classes, the `RobotsTxtFromString` schema,
21
+ * `parse` / `encode`, and the matcher `isAllowed` / `getCrawlDelay` /
22
+ * `getSitemaps`.
23
+ * 2. **The consumer side** a crawler needs: {@link evaluateRobots} (the
24
+ * decision with its diagnostics), {@link fetchRobots} (RFC 9309 §2.3.1's
25
+ * failure posture over an `HttpClient`), {@link RobotsCache} (the
26
+ * pluggable per-origin cache; a persistent adapter, such as Workers KV,
27
+ * lives in the consuming application) and
28
+ * {@link makeRobotsChecker} (the cached `checkRobots` decision).
29
+ * 3. **The serving side**: {@link robotsBody}, the stage-aware `robots.txt`
30
+ * a site serves.
31
+ *
32
+ * Peer: `effect` only.
33
+ *
34
+ * @since 0.1.0
35
+ */
36
+ /**
37
+ * A single rule in a robots.txt file: a path pattern and whether it allows.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * import { Rule } from "@thomasfosterau/effect-robots"
42
+ *
43
+ * const rule = new Rule({ path: "/admin", allow: false })
44
+ * rule.path // "/admin"
45
+ * ```
46
+ *
47
+ * @since 0.1.0
48
+ * @category models
49
+ */
50
+ var Rule = class extends Data.Class {};
51
+ /**
52
+ * A group of rules for one or more user agents.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import { RobotGroup, Rule } from "@thomasfosterau/effect-robots"
57
+ *
58
+ * const group = new RobotGroup({ userAgents: ["*"], rules: [new Rule({ path: "/admin", allow: false })] })
59
+ * group.crawlDelay // undefined
60
+ * ```
61
+ *
62
+ * @since 0.1.0
63
+ * @category models
64
+ */
65
+ var RobotGroup = class extends Data.Class {};
66
+ /**
67
+ * A parsed robots.txt file: its groups and its `Sitemap:` URLs.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { RobotsTxt } from "@thomasfosterau/effect-robots"
72
+ *
73
+ * const robots = new RobotsTxt({ groups: [], sitemaps: ["https://example.com/sitemap.xml"] })
74
+ * robots.sitemaps.length // 1
75
+ * ```
76
+ *
77
+ * @since 0.1.0
78
+ * @category models
79
+ */
80
+ var RobotsTxt = class extends Data.Class {};
81
+ /**
82
+ * Validates that a value is a {@link Rule} instance (`Schema.instanceOf`).
83
+ *
84
+ * @since 0.1.0
85
+ * @category schemas
86
+ */
87
+ const RuleSchema = Schema.instanceOf(Rule, { identifier: "Rule" });
88
+ /**
89
+ * Validates that a value is a {@link RobotGroup} instance (`Schema.instanceOf`).
90
+ *
91
+ * @since 0.1.0
92
+ * @category schemas
93
+ */
94
+ const RobotGroupSchema = Schema.instanceOf(RobotGroup, { identifier: "RobotGroup" });
95
+ /**
96
+ * Validates that a value is a {@link RobotsTxt} instance (`Schema.instanceOf`); use {@link RobotsTxtFromString} to parse text.
97
+ *
98
+ * @since 0.1.0
99
+ * @category schemas
100
+ */
101
+ const RobotsTxtSchema = Schema.instanceOf(RobotsTxt, { identifier: "RobotsTxt" });
102
+ /**
103
+ * Splits a robots.txt line into its directive field and value, dropping
104
+ * comments; `None` for a blank, comment-only or malformed line.
105
+ */
106
+ const parseDirectiveLine = (rawLine) => {
107
+ const commentIndex = rawLine.indexOf("#");
108
+ const line = (commentIndex >= 0 ? rawLine.slice(0, commentIndex) : rawLine).trim();
109
+ if (Str.isEmpty(line)) return Option.none();
110
+ const colon = line.indexOf(":");
111
+ if (colon < 0) return Option.none();
112
+ return Option.some({
113
+ field: line.slice(0, colon).trim().toLowerCase(),
114
+ value: line.slice(colon + 1).trim()
115
+ });
116
+ };
117
+ /** Closes the group being accumulated, if it has any directive. */
118
+ const finishGroup = (state) => {
119
+ if (Arr.isReadonlyArrayNonEmpty(state.userAgents) && state.hasDirectives) state.groups.push(new RobotGroup({
120
+ userAgents: state.userAgents,
121
+ rules: state.rules,
122
+ crawlDelay: state.crawlDelay
123
+ }));
124
+ if (state.hasDirectives) {
125
+ state.userAgents = [];
126
+ state.rules = [];
127
+ state.crawlDelay = void 0;
128
+ state.hasDirectives = false;
129
+ }
130
+ };
131
+ /** Applies one directive to the parser state (RFC 9309 group rules). */
132
+ const applyDirective = (state, { field, value }) => {
133
+ if (Str.isEmpty(value)) return;
134
+ if (field === "user-agent") {
135
+ if (state.hasDirectives) finishGroup(state);
136
+ state.userAgents.push(value);
137
+ return;
138
+ }
139
+ if (field === "sitemap") {
140
+ state.sitemaps.push(value);
141
+ return;
142
+ }
143
+ if (field === "disallow" || field === "allow") {
144
+ state.rules.push(new Rule({
145
+ path: value,
146
+ allow: field === "allow"
147
+ }));
148
+ state.hasDirectives = true;
149
+ return;
150
+ }
151
+ if (field === "crawl-delay") {
152
+ const delay = Number.parseFloat(value);
153
+ if (!Number.isNaN(delay) && delay >= 0) {
154
+ state.crawlDelay = delay;
155
+ state.hasDirectives = true;
156
+ }
157
+ }
158
+ };
159
+ /**
160
+ * Parses robots.txt content per RFC 9309: consecutive `User-agent:` lines
161
+ * share one group; rules belong to the most recent group; `#` starts a
162
+ * comment; unknown directives are ignored; a group with no directive is
163
+ * dropped.
164
+ */
165
+ const parseRobotsTxt = (content) => {
166
+ const state = {
167
+ groups: [],
168
+ sitemaps: [],
169
+ userAgents: [],
170
+ rules: [],
171
+ crawlDelay: void 0,
172
+ hasDirectives: false
173
+ };
174
+ for (const rawLine of content.split(/\r?\n/)) {
175
+ const directive = parseDirectiveLine(rawLine);
176
+ if (Option.isSome(directive)) applyDirective(state, directive.value);
177
+ }
178
+ finishGroup(state);
179
+ return new RobotsTxt({
180
+ groups: state.groups,
181
+ sitemaps: state.sitemaps
182
+ });
183
+ };
184
+ /** Encodes a group as its `User-agent` / rule / `Crawl-delay` lines. */
185
+ const encodeGroup = (group) => [
186
+ ...Arr.map(group.userAgents, (userAgent) => `User-agent: ${userAgent}`),
187
+ ...Arr.map(group.rules, (rule) => `${rule.allow ? "Allow" : "Disallow"}: ${rule.path}`),
188
+ ...group.crawlDelay === void 0 ? [] : [`Crawl-delay: ${group.crawlDelay}`],
189
+ ""
190
+ ];
191
+ /** Encodes a `RobotsTxt` as text: each group, a blank line, then the sitemaps. */
192
+ const encodeRobotsTxt = (robotsTxt) => [...Arr.flatMap(robotsTxt.groups, encodeGroup), ...Arr.map(robotsTxt.sitemaps, (sitemap) => `Sitemap: ${sitemap}`)].join("\n");
193
+ /**
194
+ * The schema between robots.txt text and {@link RobotsTxt}: decoding parses
195
+ * (RFC 9309 grouping, comments, unknown directives ignored), encoding
196
+ * serialises.
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * import * as Schema from "effect/Schema"
201
+ * import { RobotsTxtFromString } from "@thomasfosterau/effect-robots"
202
+ *
203
+ * const robots = Schema.decodeUnknownSync(RobotsTxtFromString)("User-agent: *\nDisallow: /admin")
204
+ * robots.groups.length // 1
205
+ * Schema.encodeSync(RobotsTxtFromString)(robots) // "User-agent: *\nDisallow: /admin\n"
206
+ * ```
207
+ *
208
+ * @since 0.1.0
209
+ * @category schemas
210
+ */
211
+ const RobotsTxtFromString = Schema.String.pipe(Schema.decodeTo(RobotsTxtSchema, SchemaTransformation.transform({
212
+ decode: parseRobotsTxt,
213
+ encode: encodeRobotsTxt
214
+ })));
215
+ /**
216
+ * Parses robots.txt content into a {@link RobotsTxt} (synchronous).
217
+ *
218
+ * @example
219
+ * ```ts
220
+ * import { parse } from "@thomasfosterau/effect-robots"
221
+ *
222
+ * const robots = parse("User-agent: *\nDisallow: /admin\nAllow: /admin/public\n\nSitemap: https://example.com/sitemap.xml")
223
+ * robots.groups[0]?.rules.length // 2
224
+ * robots.sitemaps // ["https://example.com/sitemap.xml"]
225
+ * ```
226
+ *
227
+ * @since 0.1.0
228
+ * @category parsing
229
+ */
230
+ const parse = (content) => Schema.decodeUnknownSync(RobotsTxtFromString)(content);
231
+ /**
232
+ * Encodes a {@link RobotsTxt} back to robots.txt text (synchronous).
233
+ *
234
+ * @example
235
+ * ```ts
236
+ * import { encode, RobotGroup, RobotsTxt, Rule } from "@thomasfosterau/effect-robots"
237
+ *
238
+ * encode(new RobotsTxt({
239
+ * groups: [new RobotGroup({ userAgents: ["*"], rules: [new Rule({ path: "/admin", allow: false })] })],
240
+ * sitemaps: ["https://example.com/sitemap.xml"],
241
+ * }))
242
+ * // "User-agent: *\nDisallow: /admin\n\nSitemap: https://example.com/sitemap.xml"
243
+ * ```
244
+ *
245
+ * @since 0.1.0
246
+ * @category parsing
247
+ */
248
+ const encode = (robotsTxt) => Schema.encodeSync(RobotsTxtFromString)(robotsTxt);
249
+ /** Escapes regex metacharacters (not `*`, expanded separately). */
250
+ const escapeRegex = (value) => value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
251
+ /**
252
+ * Whether a rule path matches a URL path: `*` matches any sequence, a
253
+ * trailing `$` anchors the end (RFC 9309 §2.2.3). An empty path and `/`
254
+ * match everything.
255
+ */
256
+ const matchesPath = (rulePath, urlPath) => {
257
+ if (rulePath === "" || rulePath === "/") return true;
258
+ const anchored = rulePath.endsWith("$");
259
+ const body = (anchored ? rulePath.slice(0, -1) : rulePath).split("*").map(escapeRegex).join(".*");
260
+ return new RegExp(`^${body}${anchored ? "$" : ""}`).test(urlPath);
261
+ };
262
+ /**
263
+ * The specificity of a group's claim on a user agent: the length of its
264
+ * longest matching product token (case-insensitive, exact or prefix), `0`
265
+ * for the `*` wildcard, `-1` for no match (RFC 9309 §2.2.1).
266
+ */
267
+ const groupSpecificity = (group, normalizedUserAgent) => Arr.reduce(group.userAgents, -1, (specificity, userAgent) => {
268
+ const pattern = userAgent.toLowerCase();
269
+ if (pattern === "*") return Math.max(specificity, 0);
270
+ if (normalizedUserAgent.startsWith(pattern)) return Math.max(specificity, pattern.length);
271
+ return specificity;
272
+ });
273
+ /**
274
+ * The group whose user-agent token most specifically matches — the
275
+ * longest matching token wins, ties keep the first group.
276
+ */
277
+ const findMatchingGroup = (robotsTxt, userAgent) => {
278
+ const normalized = userAgent.toLowerCase();
279
+ return Arr.reduce(robotsTxt.groups, {
280
+ group: Option.none(),
281
+ specificity: -1
282
+ }, (best, group) => {
283
+ const specificity = groupSpecificity(group, normalized);
284
+ return specificity > best.specificity ? {
285
+ group: Option.some(group),
286
+ specificity
287
+ } : best;
288
+ }).group;
289
+ };
290
+ /** True when `rule` takes precedence over `incumbent` for the same path. */
291
+ const outranks = (rule, incumbent) => Option.isNone(incumbent) || rule.path.length > incumbent.value.path.length || rule.path.length === incumbent.value.path.length && rule.allow && !incumbent.value.allow;
292
+ /** The winning rule for `path`: longest match wins, Allow wins ties (RFC 9309 §2.2.2). */
293
+ const findMostSpecificRule = (rules, path) => Arr.reduce(rules, Option.none(), (best, rule) => matchesPath(rule.path, path) && outranks(rule, best) ? Option.some(rule) : best);
294
+ /** The path without query or fragment, made absolute. */
295
+ const normalizePath = (path) => {
296
+ const bare = path.split("?")[0]?.split("#")[0] ?? "";
297
+ return bare.startsWith("/") ? bare : `/${bare}`;
298
+ };
299
+ /**
300
+ * Whether `userAgent` may access `urlPath` under the rules: the most
301
+ * specific matching group, then its longest matching rule, Allow winning
302
+ * ties; no matching group or rule means allowed. The query string and
303
+ * fragment are stripped before matching (the library's documented
304
+ * behaviour; {@link evaluateRobots} matches the query too, as RFC 9309
305
+ * §2.2.2 does).
306
+ *
307
+ * @example
308
+ * ```ts
309
+ * import { isAllowed, parse } from "@thomasfosterau/effect-robots"
310
+ *
311
+ * const robots = parse("User-agent: *\nDisallow: /admin\nAllow: /admin/public")
312
+ * isAllowed(robots, "MyBot", "/admin") // false
313
+ * isAllowed(robots, "MyBot", "/admin/public") // true
314
+ * isAllowed(robots, "MyBot", "/public") // true
315
+ * ```
316
+ *
317
+ * @since 0.1.0
318
+ * @category matching
319
+ */
320
+ const isAllowed = (robotsTxt, userAgent, urlPath) => {
321
+ const group = findMatchingGroup(robotsTxt, userAgent);
322
+ if (Option.isNone(group)) return true;
323
+ const rule = findMostSpecificRule(group.value.rules, normalizePath(urlPath));
324
+ return Option.isNone(rule) ? true : rule.value.allow;
325
+ };
326
+ /**
327
+ * The crawl delay (seconds) for `userAgent`, from its matching group.
328
+ *
329
+ * @example
330
+ * ```ts
331
+ * import { getCrawlDelay, parse } from "@thomasfosterau/effect-robots"
332
+ *
333
+ * const robots = parse("User-agent: Googlebot\nCrawl-delay: 5\nDisallow: /x\n\nUser-agent: *\nCrawl-delay: 10\nDisallow: /y")
334
+ * getCrawlDelay(robots, "Googlebot") // 5
335
+ * getCrawlDelay(robots, "OtherBot") // 10
336
+ * ```
337
+ *
338
+ * @since 0.1.0
339
+ * @category matching
340
+ */
341
+ const getCrawlDelay = (robotsTxt, userAgent) => Option.getOrUndefined(Option.map(findMatchingGroup(robotsTxt, userAgent), (group) => group.crawlDelay));
342
+ /**
343
+ * Every `Sitemap:` URL in the file.
344
+ *
345
+ * @example
346
+ * ```ts
347
+ * import { getSitemaps, parse } from "@thomasfosterau/effect-robots"
348
+ *
349
+ * getSitemaps(parse("Sitemap: https://example.com/sitemap.xml")) // ["https://example.com/sitemap.xml"]
350
+ * ```
351
+ *
352
+ * @since 0.1.0
353
+ * @category matching
354
+ */
355
+ const getSitemaps = (robotsTxt) => robotsTxt.sitemaps;
356
+ /**
357
+ * Upper bound applied to `Crawl-delay` so a hostile value cannot stall a
358
+ * batch.
359
+ *
360
+ * @since 0.1.0
361
+ * @category constants
362
+ */
363
+ const MAX_CRAWL_DELAY_SECONDS = 30;
364
+ /**
365
+ * Evaluates a parsed robots.txt for a user-agent token and a path — the
366
+ * crawlers' decision, with its diagnostics. Group selection: the longest
367
+ * matching product token wins, the `*` group is the fallback, none means
368
+ * allowed. Rule precedence: longest matching pattern wins, Allow wins ties.
369
+ * Unlike {@link isAllowed}, the path is matched as given, query included
370
+ * (RFC 9309 §2.2.2), and only a fragment is stripped. The crawl delay is
371
+ * capped and a non-positive one is dropped.
372
+ *
373
+ * @example
374
+ * ```ts
375
+ * import * as Option from "effect/Option"
376
+ * import { evaluateRobots, parse } from "@thomasfosterau/effect-robots"
377
+ *
378
+ * const robots = parse("User-agent: *\nCrawl-delay: 3600\nDisallow: /private/\nAllow: /private/public/")
379
+ * evaluateRobots(robots, "mybot", "/private/secret")
380
+ * // { allowed: false, matchedPattern: Option.some("/private/"), crawlDelaySeconds: Option.some(30) }
381
+ * evaluateRobots(robots, "mybot", "/private/public/page").allowed // true
382
+ * ```
383
+ *
384
+ * @since 0.1.0
385
+ * @category evaluation
386
+ */
387
+ const evaluateRobots = (robotsTxt, userAgentToken, path, options = {}) => {
388
+ const group = findMatchingGroup(robotsTxt, userAgentToken);
389
+ if (Option.isNone(group)) return {
390
+ allowed: true,
391
+ matchedPattern: Option.none(),
392
+ crawlDelaySeconds: Option.none()
393
+ };
394
+ const cap = options.maxCrawlDelaySeconds ?? 30;
395
+ const rule = findMostSpecificRule(group.value.rules, path.split("#")[0] ?? "");
396
+ return {
397
+ allowed: Option.isNone(rule) ? true : rule.value.allow,
398
+ matchedPattern: Option.map(rule, (matched) => matched.path),
399
+ crawlDelaySeconds: Option.filter(Option.fromUndefinedOr(group.value.crawlDelay), (delay) => delay > 0).pipe(Option.map((delay) => Math.min(delay, cap)))
400
+ };
401
+ };
402
+ /**
403
+ * robots.txt responses are capped at this many bytes (RFC 9309 §2.5).
404
+ *
405
+ * @since 0.1.0
406
+ * @category constants
407
+ */
408
+ const MAX_ROBOTS_BYTES = 512e3;
409
+ /**
410
+ * Per-origin cache TTL for a fetched body or a 4xx (24 hours): a directive
411
+ * change is honoured within a day.
412
+ *
413
+ * @since 0.1.0
414
+ * @category constants
415
+ */
416
+ const ROBOTS_CACHE_TTL_SECONDS = 86400;
417
+ /**
418
+ * Cache TTL for an unavailable robots.txt (one hour), so a misbehaving
419
+ * origin is not probed on every capture.
420
+ *
421
+ * @since 0.1.0
422
+ * @category constants
423
+ */
424
+ const ROBOTS_UNAVAILABLE_TTL_SECONDS = 3600;
425
+ /**
426
+ * The cache value for a robots.txt that could not be read (5xx, transport
427
+ * failure, timeout). The leading character is a deliberate literal NUL
428
+ * byte — it can never collide with a real body. Written as the `\0` escape
429
+ * rather than an embedded raw NUL byte: same runtime string, but a raw NUL
430
+ * in the source makes tools that binary-sniff stop treating the file as
431
+ * text. Changing the value orphans entries already in a persistent cache.
432
+ *
433
+ * @since 0.1.0
434
+ * @category constants
435
+ */
436
+ const ROBOTS_UNAVAILABLE_SENTINEL = "\0__robots_unavailable__";
437
+ /**
438
+ * The cache value for an origin with no restrictions (a 4xx, 404 included).
439
+ *
440
+ * @since 0.1.0
441
+ * @category constants
442
+ */
443
+ const ROBOTS_NO_RESTRICTIONS = "";
444
+ /**
445
+ * A cache that stores nothing: every check fetches. The default cache of
446
+ * {@link makeRobotsChecker}.
447
+ *
448
+ * @example
449
+ * ```ts
450
+ * import * as Effect from "effect/Effect"
451
+ * import * as Option from "effect/Option"
452
+ * import { noRobotsCache } from "@thomasfosterau/effect-robots"
453
+ *
454
+ * await Effect.runPromise(noRobotsCache.put("https://example.com", "User-agent: *", 60))
455
+ * Option.isNone(await Effect.runPromise(noRobotsCache.get("https://example.com"))) // true
456
+ * ```
457
+ *
458
+ * @since 0.1.0
459
+ * @category caches
460
+ */
461
+ const noRobotsCache = {
462
+ get: () => Effect.succeedNone,
463
+ put: () => Effect.void
464
+ };
465
+ /**
466
+ * An in-memory cache (a `Map`), for tests and single-process crawlers. TTLs
467
+ * are recorded, not enforced.
468
+ *
469
+ * @example
470
+ * ```ts
471
+ * import * as Effect from "effect/Effect"
472
+ * import { memoryRobotsCache } from "@thomasfosterau/effect-robots"
473
+ *
474
+ * const cache = memoryRobotsCache()
475
+ * await Effect.runPromise(cache.put("https://example.com", "User-agent: *\nDisallow: /", 60))
476
+ * cache.entries.get("https://example.com")?.ttlSeconds // 60
477
+ * ```
478
+ *
479
+ * @since 0.1.0
480
+ * @category caches
481
+ */
482
+ const memoryRobotsCache = () => {
483
+ const entries = /* @__PURE__ */ new Map();
484
+ return {
485
+ entries,
486
+ get: (origin) => Effect.sync(() => Option.map(Option.fromNullishOr(entries.get(origin)), (entry) => entry.value)),
487
+ put: (origin, value, ttlSeconds) => Effect.sync(() => {
488
+ entries.set(origin, {
489
+ value,
490
+ ttlSeconds
491
+ });
492
+ })
493
+ };
494
+ };
495
+ /**
496
+ * Fetches `<origin>/robots.txt` through the `HttpClient` in context and
497
+ * folds the outcome into the cache value with RFC 9309 §2.3.1's posture: a
498
+ * 2xx body (capped), {@link ROBOTS_NO_RESTRICTIONS} for a 4xx (404
499
+ * included), and {@link ROBOTS_UNAVAILABLE_SENTINEL} for a 5xx, a transport
500
+ * failure, a timeout or a body that cannot be read — the crawler must
501
+ * assume it is not permitted until it can read the rules. Never fails.
502
+ *
503
+ * Redirect policy is the client's: provide one whose redirects are
504
+ * revalidated against the SSRF block list (`@thomasfosterau/effect-web/net`).
505
+ *
506
+ * @example
507
+ * ```ts
508
+ * import * as Effect from "effect/Effect"
509
+ * import * as HttpClient from "effect/http/HttpClient"
510
+ * import * as HttpClientResponse from "effect/http/HttpClientResponse"
511
+ * import { fetchRobots, ROBOTS_NO_RESTRICTIONS } from "@thomasfosterau/effect-robots"
512
+ *
513
+ * const notFound = HttpClient.make((request) =>
514
+ * Effect.succeed(HttpClientResponse.fromWeb(request, new Response("nope", { status: 404 }))),
515
+ * )
516
+ * await Effect.runPromise(
517
+ * fetchRobots("https://example.com", { userAgent: "mybot/1.0" }).pipe(
518
+ * Effect.provideService(HttpClient.HttpClient, notFound),
519
+ * ),
520
+ * ) === ROBOTS_NO_RESTRICTIONS // true
521
+ * ```
522
+ *
523
+ * @since 0.1.0
524
+ * @category fetching
525
+ */
526
+ const fetchRobots = (origin, options) => Effect.gen(function* () {
527
+ const response = yield* (yield* HttpClient.HttpClient).get(`${origin}/robots.txt`, { headers: {
528
+ "user-agent": options.userAgent,
529
+ accept: "text/plain,*/*;q=0.5"
530
+ } });
531
+ if (response.status >= 200 && response.status < 300) return (yield* response.text).slice(0, options.maxBytes ?? 512e3);
532
+ return response.status >= 500 ? ROBOTS_UNAVAILABLE_SENTINEL : "";
533
+ }).pipe(Effect.timeout(options.timeout ?? Duration.seconds(10)), Effect.orElseSucceed(() => ROBOTS_UNAVAILABLE_SENTINEL));
534
+ /**
535
+ * The reason a disallowed decision carries for an unavailable robots.txt.
536
+ *
537
+ * @since 0.1.0
538
+ * @category constants
539
+ */
540
+ const ROBOTS_UNAVAILABLE_REASON = "robots.txt unavailable (server error) — treating as disallow";
541
+ /**
542
+ * Builds a crawler's `checkRobots`: whether it may fetch `url` under the
543
+ * origin's robots.txt, reading the origin's body through the cache and
544
+ * fetching on a miss ({@link fetchRobots}), then {@link evaluateRobots}
545
+ * for the crawler's token. Never fails — robots problems surface as
546
+ * `allowed: false` decisions, and cache problems are the adapter's to
547
+ * degrade. The fetch outcome is cached with the TTL its posture warrants
548
+ * (unavailability shorter), and a failed cache write is not a failed
549
+ * decision.
550
+ *
551
+ * @example
552
+ * ```ts
553
+ * import * as Effect from "effect/Effect"
554
+ * import * as HttpClient from "effect/http/HttpClient"
555
+ * import * as HttpClientResponse from "effect/http/HttpClientResponse"
556
+ * import { makeRobotsChecker, memoryRobotsCache } from "@thomasfosterau/effect-robots"
557
+ *
558
+ * const site = HttpClient.make((request) =>
559
+ * Effect.succeed(HttpClientResponse.fromWeb(request, new Response("User-agent: *\nDisallow: /secret/", { status: 200 }))),
560
+ * )
561
+ * const checkRobots = makeRobotsChecker({
562
+ * userAgent: "mybot/1.0 (+https://example.com)",
563
+ * userAgentToken: "mybot",
564
+ * cache: memoryRobotsCache(),
565
+ * })
566
+ * const decision = await Effect.runPromise(
567
+ * checkRobots("https://origin.example/secret/x").pipe(Effect.provideService(HttpClient.HttpClient, site)),
568
+ * )
569
+ * decision // { allowed: false, reason: "disallowed by robots.txt rule '/secret/'" }
570
+ * ```
571
+ *
572
+ * @since 0.1.0
573
+ * @category checking
574
+ */
575
+ const makeRobotsChecker = (options) => {
576
+ const cache = options.cache ?? noRobotsCache;
577
+ const loadRobots = (origin) => Effect.gen(function* () {
578
+ const cached = yield* cache.get(origin);
579
+ if (Option.isSome(cached)) return cached.value;
580
+ const fetched = yield* fetchRobots(origin, options);
581
+ const ttl = fetched === "\0__robots_unavailable__" ? options.unavailableTtlSeconds ?? 3600 : options.cacheTtlSeconds ?? 86400;
582
+ yield* cache.put(origin, fetched, ttl);
583
+ return fetched;
584
+ });
585
+ return (url) => Effect.gen(function* () {
586
+ const parsed = new URL(url);
587
+ const body = yield* loadRobots(parsed.origin);
588
+ if (body === "\0__robots_unavailable__") return {
589
+ allowed: false,
590
+ reason: ROBOTS_UNAVAILABLE_REASON
591
+ };
592
+ if (body === "") return {
593
+ allowed: true,
594
+ crawlDelaySeconds: Option.none()
595
+ };
596
+ const path = options.matchQuery === false ? parsed.pathname : parsed.pathname + parsed.search;
597
+ const evaluation = evaluateRobots(parse(body), options.userAgentToken, path, options);
598
+ if (!evaluation.allowed) return {
599
+ allowed: false,
600
+ reason: `disallowed by robots.txt rule '${Option.getOrElse(evaluation.matchedPattern, () => "")}'`
601
+ };
602
+ return {
603
+ allowed: true,
604
+ crawlDelaySeconds: evaluation.crawlDelaySeconds
605
+ };
606
+ });
607
+ };
608
+ /**
609
+ * The non-production body: a full disallow, no sitemap (ROBOTS-005), so a
610
+ * `dev-<branch>` / `ci-<run>` / PR-preview deployment is never crawled.
611
+ *
612
+ * @since 0.1.0
613
+ * @category serving
614
+ */
615
+ const NON_PRODUCTION_BODY = [
616
+ "User-agent: *",
617
+ "Disallow: /",
618
+ ""
619
+ ].join("\n");
620
+ /**
621
+ * The production body: open to all crawlers (`User-agent: *`, AI crawlers
622
+ * included), the hygiene disallows, then the absolute sitemap URL
623
+ * (ROBOTS-002 … -004).
624
+ *
625
+ * @example
626
+ * ```ts
627
+ * import { productionBody } from "@thomasfosterau/effect-robots"
628
+ *
629
+ * productionBody({ disallow: ["/login", "/*\/edit"], sitemapUrl: "https://example.com/sitemap.xml", comments: ["All crawlers welcome."] })
630
+ * // "# All crawlers welcome.\nUser-agent: *\nDisallow: /login\nDisallow: /*\/edit\n\nSitemap: https://example.com/sitemap.xml\n"
631
+ * ```
632
+ *
633
+ * @since 0.1.0
634
+ * @category serving
635
+ */
636
+ const productionBody = (policy) => [
637
+ ...Arr.map(policy.comments ?? [], (comment) => `# ${comment}`),
638
+ encode(new RobotsTxt({
639
+ groups: [new RobotGroup({
640
+ userAgents: ["*"],
641
+ rules: Arr.map(policy.disallow, (path) => new Rule({
642
+ path,
643
+ allow: false
644
+ }))
645
+ })],
646
+ sitemaps: [policy.sitemapUrl]
647
+ })),
648
+ ""
649
+ ].join("\n");
650
+ /**
651
+ * The `robots.txt` body for a stage: the production policy when
652
+ * `production`, else {@link NON_PRODUCTION_BODY} (ROBOTS-005). Pure, so the
653
+ * stage branch is unit-testable without a runtime; the caller decides the
654
+ * stage (`PUBLIC_STAGE === "production"`, say) and builds the sitemap URL
655
+ * from the canonical base, never the request origin.
656
+ *
657
+ * @example
658
+ * ```ts
659
+ * import { NON_PRODUCTION_BODY, robotsBody } from "@thomasfosterau/effect-robots"
660
+ *
661
+ * robotsBody(false, { disallow: ["/login"], sitemapUrl: "https://example.com/sitemap.xml" }) === NON_PRODUCTION_BODY // true
662
+ * robotsBody(true, { disallow: ["/login"], sitemapUrl: "https://example.com/sitemap.xml" }).includes("Sitemap: https://example.com/sitemap.xml") // true
663
+ * ```
664
+ *
665
+ * @since 0.1.0
666
+ * @category serving
667
+ */
668
+ const robotsBody = (production, policy) => production ? productionBody(policy) : NON_PRODUCTION_BODY;
669
+ /**
670
+ * The `Cache-Control` a low-churn generated document (`robots.txt`,
671
+ * `sitemap.xml`, `feed.json`) is served with: the edge caches the whole
672
+ * document, bounding any request-time enumeration behind it to at most one
673
+ * per hour per edge (PERF-CACHE-002). A mutation that could change the
674
+ * document purges its `Cache-Tag` rather than relying on the TTL alone.
675
+ *
676
+ * @since 0.1.0
677
+ * @category serving
678
+ */
679
+ const LOW_CHURN_CACHE_CONTROL = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
680
+ /**
681
+ * A guard for the strings the checker treats as sentinels: a real body is
682
+ * neither empty nor the unavailable marker.
683
+ *
684
+ * @example
685
+ * ```ts
686
+ * import {
687
+ * isRobotsBody,
688
+ * ROBOTS_NO_RESTRICTIONS,
689
+ * ROBOTS_UNAVAILABLE_SENTINEL,
690
+ * } from "@thomasfosterau/effect-robots"
691
+ *
692
+ * isRobotsBody("User-agent: *\nDisallow: /admin") // true
693
+ * isRobotsBody(ROBOTS_NO_RESTRICTIONS) // false
694
+ * isRobotsBody(ROBOTS_UNAVAILABLE_SENTINEL) // false
695
+ * ```
696
+ *
697
+ * @since 0.1.0
698
+ * @category predicates
699
+ */
700
+ const isRobotsBody = (value) => Predicate.isString(value) && value !== "" && value !== "\0__robots_unavailable__";
701
+ //#endregion
702
+ export { LOW_CHURN_CACHE_CONTROL, MAX_CRAWL_DELAY_SECONDS, MAX_ROBOTS_BYTES, NON_PRODUCTION_BODY, ROBOTS_CACHE_TTL_SECONDS, ROBOTS_NO_RESTRICTIONS, ROBOTS_UNAVAILABLE_REASON, ROBOTS_UNAVAILABLE_SENTINEL, ROBOTS_UNAVAILABLE_TTL_SECONDS, RobotGroup, RobotGroupSchema, RobotsTxt, RobotsTxtFromString, RobotsTxtSchema, Rule, RuleSchema, encode, evaluateRobots, fetchRobots, getCrawlDelay, getSitemaps, isAllowed, isRobotsBody, makeRobotsChecker, memoryRobotsCache, noRobotsCache, parse, productionBody, robotsBody };