-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconfigSchema.js
More file actions
2144 lines (2109 loc) · 129 KB
/
Copy pathconfigSchema.js
File metadata and controls
2144 lines (2109 loc) · 129 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* The configuration schema: the single source of truth for every option the plugin
* understands. Each option declares its default, a description, and how a change takes
* effect — everything `config.js` (defaults, merge validation, redaction, restart
* warnings) and the management API (a machine-readable schema for the admin UI) derive
* from.
*
* Field reference for `option(default, description, extra)`:
* scope 'live' (default) — a change via the host's options `change` event takes
* effect without a restart (per request, per timer tick, or on the next
* scheduled cycle). 'restart' — the value is consumed once at worker boot;
* a live change is reported as pending-restart and otherwise ignored.
* Groups may set a scope their children inherit.
* secret true — the value is redacted to a presence marker wherever config is
* read back (management API, logs).
* enum Allowed values; anything else is rejected at apply time (default kept).
* unit Display/documentation hint ('ms', 'percent'). No behavioral effect.
* min/max Numeric bounds enforced at apply time (violation keeps the default).
* nonEmpty true — an empty string/array is rejected at apply time (default kept).
* Reserved for values where empty is catastrophic rather than unwise.
* itemType Display hint for array options ('string' | 'object').
* uiEditable
* false — the console must refuse to write this option, and says so instead of
* offering a control. Inherited by a group's children, like `scope`. Reserved for
* options whose own edit would remove the ability to edit (`management.enabled`
* locks the console out; the `management.overrides` group is the machinery the
* console writes THROUGH). `secret: true` implies it — a secret comes from its
* environment variable, so there is nothing for a form to set.
* movedFrom Dotted path this option (or group) lived at before the v0.25.0
* reorganization. The old path still applies with a deprecation warning.
*
* Descriptions are user-facing documentation: they are served by the management API and
* will back the admin UI's config editor. Write them for an operator, not a code reader.
*/
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const OPTION = Symbol('option');
const GROUP = Symbol('group');
const option = (defaultValue, description, extra = {}) => ({
[OPTION]: true,
default: defaultValue,
description,
...extra,
});
const group = (description, children, extra = {}) => ({
[GROUP]: true,
description,
children,
...extra,
});
export const isOption = (node) => !!node?.[OPTION];
export const isGroup = (node) => !!node?.[GROUP];
// Database/table names are fixed (defined statically in src/schemas/schema.graphql).
// Tables are split across databases by write-transaction coupling so the hot queue
// (render_schedule) is isolated from target, page-cache, and sitemap writes.
export const configSchema = group('Prerender plugin configuration.', {
domains: option(
[],
'Allowlist of hostnames considered indexable. Pages on other hosts are rendered but ' +
'never marked indexable/cached. Empty = allow all.',
{ itemType: 'string' }
),
ingress: group(
'Request-ingestion model: how incoming bot requests are recognized, which paths are ' +
'prerendered, and how the target URL and device type are derived.\n\n' +
"mode 'prefix' — native model: bot requests arrive at `${botPathPrefix}<absolute-url>` " +
'and the device type comes from a header (`deviceTypeHeader`).\n' +
"mode 'forwarded' — reverse-proxy / CDN model: the proxy routes a restricted set of " +
'paths to the plugin. The device type is the first path segment, the target URL is ' +
'reconstructed from the forwarded host/proto headers, and `routes` both identifies ' +
"which requests are prerender requests and sets each route's query-param allowlist.",
{
mode: option('prefix', "Request-ingestion model: 'prefix' (native) or 'forwarded' (reverse-proxy / CDN).", {
enum: ['prefix', 'forwarded'],
}),
botPathPrefix: option(
'/p/',
'Requests whose path starts with this prefix are treated as bot prerender requests ' +
'(e.g. `/p/<absolute-url>`). Prefix mode only.',
{ movedFrom: 'botPathPrefix', nonEmpty: true }
),
deviceTypeSource: option(
'header',
"Where the device type comes from in forwarded mode: 'path' (first path segment, " +
"consumed when it is a supported device type) or 'header'.",
{ enum: ['path', 'header'] }
),
deviceTypeHeader: option('x-device-type', 'Request header carrying the device type.'),
forwardedHostHeader: option('x-forwarded-host', 'Header carrying the original public host (forwarded mode).'),
forwardedProtoHeader: option('x-forwarded-proto', 'Header carrying the original public scheme (forwarded mode).'),
defaultProtocol: option('https', 'Scheme assumed when the forwarded-proto header is absent.', {
enum: ['https', 'http'],
}),
routes: option(
[],
'Ordered route list (forwarded mode). Each entry is ' +
"{ match: 'exact' | 'prefix' | 'contains', path: string, mode?: 'prerender' | 'passthrough', " +
'queryParams?: string[], renderInterval?: number, discoverTargets?: boolean, demandFloor?: number }.\n\n' +
'FIRST MATCH WINS, so order most-specific first. That ordering is what lets a passthrough ' +
'carve-out sit inside a prerendered prefix (`/products/clearance/` above `/products/`) ' +
'without a second list and a precedence rule.\n\n' +
"`mode` (default 'prerender') decides the class:\n" +
' prerender — cache it, schedule it, serve it from cache. `queryParams` is its cache-key / ' +
"origin-fetch query allowlist (same semantics as `cacheKey.queryParams`: ['*'] keeps all, " +
'[] drops all).\n' +
' passthrough — proxy it live, never cache or schedule it, and don’t report it. A declaration ' +
'that the CDN forwards this path and we have chosen not to prerender it. `queryParams` is ' +
'REJECTED here: with no cache there is no key for it to shape, so it could only strip params ' +
'off the proxied origin fetch and hand the visitor the wrong page.\n\n' +
"A path matching NOTHING is 'unclassified': still proxied (never blocked), never cached, and " +
'counted for reporting so the gap can be fixed at the CDN or here.\n\n' +
'`renderInterval` (ms, prerender routes only) sets the render cadence for every URL the route ' +
"matches. Precedence: route > the target's stored interval (sitemap `<changefreq>` or an " +
'explicit API write) > `render.defaultInterval` — resolved at schedule time on every cycle, so ' +
"changing it here takes effect on each URL's next render with no data migration. A per-URL " +
'exception is an `exact` route ordered above its class (e.g. the homepage `exact /` at 2h above ' +
'a 6h section prefix); a route that should defer to sitemap changefreq simply doesn’t set one.\n\n' +
"OPERATIONAL NOTE: if the CDN edge-caches a route's responses with a fixed TTL from its own " +
"property settings (not from our response headers), that TTL and the route's renderInterval " +
'must be kept aligned BY HAND — rendering much faster than the edge TTL burns renders the edge ' +
'never serves, and much slower means the edge re-fetches stale content. Neither side can see ' +
'the other drift.\n\n' +
'`demandFloor` (ms, prerender routes only) — the FASTEST cadence the demand ladder ' +
"(`render.demand`) may grant this route's pages: rungs faster than the floor are unreachable " +
'for them. This is how a deployment keeps fast global rungs for a corpus that earns them ' +
'(listing pages that churn intraday) without a breadth-sweeping crawler promoting a much ' +
'larger corpus onto the same rungs — a crawler that recrawls EVERYTHING daily makes ' +
'"visited" true everywhere, and without a floor the ladder would grant the whole route the ' +
'fast cadence at corpus scale. A floor at or above the granted cadence leaves the route ' +
'resting at that cadence (single-rung). Stored rungs below a newly-raised floor read as the ' +
'floor immediately and re-stamp on their next ladder decision. Live, like renderInterval.\n\n' +
'`discoverTargets` (default true, prerender routes only) — whether a bot visiting an UNKNOWN ' +
'URL on this route creates a target for it. Set false on routes whose URL space is ' +
'combinatorial (faceted navigation, filter/sort permutations): crawlers walking those links ' +
'mint every novel combination into permanent render load, and the corpus grows without bound. ' +
'Gated URLs are still served (origin proxy on a miss) — they just never enter the render ' +
'rotation; the sitemap pipeline is unaffected, so declared URLs on the route still schedule. ' +
'NOTE: flipping this false stops NEW targets only. Existing discovered targets keep rendering ' +
'until deleted — see the discovery-purge admin action, and gate BEFORE purging or crawlers ' +
're-mint what the purge removes.',
{ itemType: 'object' }
),
discoveryBots: option(
['*'],
'Bots whose visits may create NEW targets (traffic discovery), by the bot name the analytics ' +
"registry resolves (analytics.bots / derived names / the literal 'other'), compared " +
"case-insensitively. ['*'] (default) trusts every bot; [] disables traffic discovery " +
'site-wide (sitemap-only corpus); a list trusts exactly those names. Third-party crawlers ' +
'with broken link extractors invent malformed URLs from rendered markup and re-request them ' +
'forever — restricting minting to the search engines that matter ends that class at the ' +
'source. Creation-only: serving, the demand ladder, invalidation reenqueue, and sitemap ' +
'ingestion are all unaffected.',
{ itemType: 'string' }
),
excludePathPatterns: option(
['/search/'],
'Paths never auto-scheduled for rendering. Compiled into `routes` as ' +
"{ match: 'contains', mode: 'passthrough' } entries, PREPENDED so an exclude still beats any " +
'prerender route it overlaps. Matched against the PATH only (never the query string). ' +
'Prefer declaring a `contains`/`passthrough` route directly.',
{ movedFrom: 'excludePathPatterns', itemType: 'string' }
),
report: group(
'Periodic aggregated report of paths served without prerendering, bucketed by first path ' +
'segment. Replaces a per-request warning that was unusable at crawler volume. Runs on EVERY ' +
'worker (the counters are in-process), so each line carries node + worker and a reader sums ' +
'across them.',
{
enabled: option(true, 'Emit the periodic unrouted-path report.'),
interval: option(5 * MINUTE, 'How often each worker flushes its tally.', { unit: 'ms', min: SECOND }),
maxBuckets: option(200, 'Distinct buckets tracked per class before overflow counting.', { min: 1 }),
topN: option(20, 'Buckets listed per log line, highest count first.', { min: 1 }),
}
),
}
),
deviceTypes: group('Device variants the service renders and serves.', {
supported: option(
['desktop', 'mobile', 'tablet'],
'Device types the service understands; unrecognized values fall back to the first entry.',
{ itemType: 'string', nonEmpty: true }
),
default: option(['desktop', 'mobile'], 'Device types scheduled for rendering when a page is auto-discovered.', {
itemType: 'string',
}),
}),
cacheKey: group(
'How a request URL becomes a cache identity. Changing any of these reshapes every key: ' +
'existing cached pages and schedules are orphaned (not migrated), so treat a live change ' +
'as a full cache rebuild.',
{
delimiter: option('|', 'Separator joining the key attributes.', { nonEmpty: true }),
attributes: option(['url', 'deviceType'], 'Attributes joined (in order) to form the key.', {
itemType: 'string',
nonEmpty: true,
}),
queryParams: option(
['page'],
'URL normalization used to build the cache key: an allowlist of query parameters to retain ' +
'(others are dropped; the remaining ones are sorted for a stable key).\n' +
" ['page'] — keep only `?page=` (default)\n" +
" ['*'] — keep all query params\n" +
' [] — drop all query params\n' +
'In forwarded mode a matched route’s own `queryParams` takes precedence.',
{ movedFrom: 'url.queryParams', itemType: 'string' }
),
decodeReserved: option(
[':', ',', '@'],
'RESERVED characters to decode when they appear percent-encoded, so one logical URL ' +
'spelled two ways is one cache key. The UNRESERVED set (letters, digits, `- . _ ~`) is ' +
'always decoded — RFC 3986 says those escapes denote the same character, so it holds for ' +
'every site. These do not: whether `%3A` and `:` name the same page is a fact about how ' +
'your origin parses URLs.\n' +
' [":", ",", "@"] — the characters WHATWG `new URL()` and Chrome emit literally in a ' +
'query, so a sitemap loc, a CDN-forwarded request and a Chrome redirect target agree (default)\n' +
' [] — decode nothing beyond the unreserved set (what a CDN does)\n' +
'Structural characters are refused: decoding `&` `=` `+` `#` `/` `%` or `|` would reparse ' +
'the URL into a different shape. Beware list-valued params — an API that reads `?ids=1,2,3` ' +
'as three values and `%2C` as a literal comma inside one is a site where `,` must be removed ' +
'from this list.',
{ itemType: 'string', itemEnum: [':', ',', '@', ';', '$', "'", '(', ')', '!', '*'] }
),
trailingSlash: option(
'strip',
'Whether `/a/` and `/a` are one cache key.\n' +
' strip — drop a trailing slash on a non-root path, so they collapse (default)\n' +
' preserve — keep them apart, and answer each with what the origin says about it\n' +
'No standard makes them one resource, and it can differ per ROUTE on one site: an origin ' +
'that 404s or 403s the slashed form is giving a different answer, and stripping has us ' +
'reply on its behalf with a page it refused. Check before choosing — request both ' +
'spellings of a path on each route shape you serve.',
{ enum: ['strip', 'preserve'] }
),
plusIsSpace: option(
false,
'Treat `%20` and `+` in the QUERY as one spelling of a space (folded to `+`), so a ' +
'crawler-invented re-encoding is the same cache key as the URL your sitemap declares — ' +
'not a second target rendering the same page forever.\n' +
'Only enable it for an origin that FORM-DECODES its query, where `+` means space and the ' +
'two spellings cannot name different resources. One request per allowlisted parameter ' +
'settles that for every URL on the site: ask for a value containing a literal plus ' +
'(`?f=A%2BB`), then the same value with a raw `+` (`?f=A+B`). If the second resolves as a ' +
'SPACE (its canonical comes back `A%20B`), the origin form-decodes. If the two return ' +
'different pages, leave this off — folding would serve one page under the other’s URL.\n' +
'`%2B` is never folded: a literal plus inside a value is a different value.\n' +
'MIRROR THIS IN THE RENDERER (`@harperfast/prerender-browser` `cacheKey.plusIsSpace`). It ' +
'changes which URLs are the same key, so a renderer left unfolded reads every folded URL ' +
'as canonicalizing elsewhere and retires it.\n' +
'Enabling re-keys every affected URL: their cached pages are orphaned and re-render.'
),
}
),
origin: group('How Harper fetches from the origin: identification, staging routing, and header hygiene.', {
securityToken: group(
'Shared secret sent to the origin so it can distinguish the prerender service (and bypass ' +
'bot mitigation). Set the value per deployment — preferably via `valueEnv` so the secret ' +
'stays out of config.yaml.',
{
header: option('x-harper-renderer-bypass', 'Header name carrying the token.', { nonEmpty: true }),
value: option('', 'The token itself. Prefer `valueEnv`.', { secret: true }),
valueEnv: option(
'',
'If set, the token is sourced from this environment variable at config-apply time and takes ' +
'precedence over `value` (keeps the secret out of config.yaml). The environment itself is ' +
'loaded once at boot (loadEnv), so changing the variable’s VALUE still needs a restart; ' +
'changing which variable is read does not.',
// FILE-ONLY, exactly like the secret it selects. Writing this from the console would set the
// token by proxy: point it at an environment variable whose value you already know and the
// secret becomes that value. That is the bypass `secret: true` exists to prevent, so the
// pointer has to be as unwritable as the target.
{ uiEditable: false }
),
},
{ movedFrom: 'securityToken' }
),
staging: group(
'Staging passthrough — for verifying an origin against a staging edge (e.g. the CDN’s staging ' +
'network). When `ip` is set, a cache-MISS origin fetch that carries the `header` request header ' +
'is connected to `ip` instead of the public origin. The Host header and TLS SNI stay the real ' +
'origin host (only the TCP address is pinned), so the staging edge serves the right property and ' +
'presents a valid certificate.\n\n' +
'The header is only a toggle: the connect address is always the configured `ip`, never a value ' +
'from the request, so a request can’t repoint the fetch at an arbitrary host. The cache key does ' +
'not include the header, so cache HITS always return the normal cached page regardless of it. ' +
'Empty `ip` disables the feature — production is unaffected unless a staging IP is explicitly ' +
'configured.\n\n' +
'The sitemap refresh reuses this `ip` too, but unconditionally (no toggle header — it has no ' +
'incoming request): whenever `ip` is set, every sitemap fetch is pinned to it, so all ' +
'Harper→origin traffic hits the same edge. The security token often only authenticates against ' +
'the staging edge, so a direct prod sitemap fetch is bounced with a 403.\n\n' +
'Toggling staging↔prod contaminates the URL-keyed page cache; wipe it when switching.',
{
ip: option('', 'Staging edge IP. Empty disables staging passthrough entirely.'),
header: option('x-harper-staging', 'Request header that toggles the staging connect on a miss fetch.'),
},
{ movedFrom: 'staging' }
),
userAgents: group(
'Per-device-type User-Agent strings sent to the origin on the proxy (cache-miss passthrough) ' +
'fetch. Each carries a `HarperProxy/1.0` product token so Harper’s proxy traffic is identifiable ' +
'in origin/CDN logs while still presenting a real, device-appropriate browser UA (the origin ' +
'serves device-specific HTML off it).',
{
mobile: option(
'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 HarperProxy/1.0',
'UA for mobile proxy fetches.'
),
tablet: option(
'Mozilla/5.0 (Linux; Android 7.0; Pixel C Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.98 Safari/537.36 HarperProxy/1.0',
'UA for tablet proxy fetches.'
),
desktop: option(
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/W.X.Y.Z Safari/537.36 HarperProxy/1.0',
'UA for desktop proxy fetches.'
),
},
{ movedFrom: 'userAgents' }
),
ignoredHeaders: option(
[],
'Additional downstream request header names never forwarded to the origin, on top of the ' +
'always-ignored set (hop-by-hop headers plus host, user-agent, accept-encoding, cookie, ' +
'authorization, and the security-token/debug header names). Matched case-insensitively.',
{ movedFrom: 'ignoredHeaders', itemType: 'string' }
),
maxResponseHeaderBytes: option(
64 * 1024,
'Largest response head Harper will accept from the origin, summed across every header name ' +
'and value in the response (not per header).\n\n' +
'Undici defaults this to Node’s `http.maxHeaderSize` (16 KiB), which is a header-flood ' +
'mitigation for servers accepting untrusted requests — too strict for a reverse proxy reading ' +
'its own origin. A real origin can exceed 16 KiB on a single page (several Set-Cookie plus ' +
'CSP, Link rel=preload, NEL, Report-To), and undici responds by destroying the connection ' +
'with UND_ERR_HEADERS_OVERFLOW, so the crawler gets a 500 for a page browsers and the CDN ' +
'load normally. It fails deterministically for those URLs, since it is a property of the ' +
'origin’s response rather than a transient. Hence a default well above Node’s, matching what ' +
'a CDN in front of the same origin already tolerates.\n\n' +
'Raising it raises the worst-case memory held per connection while a response head is ' +
'parsed, which is why it is bounded at both ends. The 1 MiB ceiling is far above any ' +
'legitimate response head — it exists to catch a typo (a stray factor of a thousand) ' +
'before it becomes an out-of-memory risk multiplied across concurrent connections.\n\n' +
'Restart-scoped: undici fixes `maxHeaderSize` when the dispatcher is constructed and offers ' +
'no way to change it afterwards, so a live edit is reported as pending-restart and the ' +
'running dispatchers keep the value they were built with.',
{ unit: 'bytes', min: 16 * 1024, max: 1024 * 1024, scope: 'restart' }
),
}),
debugHeader: group('Debug response headers, emitted when the request carries this header (any value).', {
key: option('x-harper-prerender-debug', 'Request header name that turns on debug response headers.', {
nonEmpty: true,
}),
}),
renderNow: group(
'On-demand render control. When enabled, an authorized GET bot request gets two orthogonal ' +
'levers (both ignored for unauthorized requests, so real crawler traffic is unaffected):\n' +
' 1. Cache freshness — a request `Cache-Control: no-cache`/`no-store` SKIPS the served cache ' +
'(forces a miss).\n' +
' 2. Miss behavior — the `missHeader` value picks what to do on a miss/skip: ‘prerender’ ' +
'(force an immediate one-off render and long-poll for the fresh result) or ‘origin’ (proxy ' +
'the origin, same as a normal miss). Absent → `defaultMissMode`.\n' +
'So `defaultMissMode: prerender` + no Cache-Control = "serve cache, else render now" ' +
'(warm-on-demand); adding `Cache-Control: no-cache` = "always render fresh now".',
{
enabled: option(
false,
'Enable the on-demand render levers. Enabling is necessary but not sufficient — a non-empty ' +
'`token` (or a `valueEnv` that resolves to one) is also required, so this cannot open the ' +
'levers on its own.'
),
header: option(
'x-harper-render-now',
'Request header that authorizes the on-demand levers. The header VALUE must equal the ' +
'configured `token`; presence alone never authorizes.'
),
token: option(
'',
'Expected value of `header`. **Required** — there is no unauthenticated mode: an empty token ' +
'leaves renderNow DISABLED (the levers stay off even when `enabled` is true) rather than ' +
'authorizing anyone who sends the header, and is reported at config-apply time.\n\n' +
'This fails CLOSED deliberately. The levers let a caller bypass the served cache and force ' +
'a synchronous render that occupies the request for up to `timeoutMs`, so on a path that ' +
'takes public crawler traffic an absent or unresolved token must not degrade to "authorize ' +
'everyone". Prefer `valueEnv` so the secret stays out of config.yaml, and never commit a ' +
'guessable placeholder — a value like "true" is not meaningfully better than none.',
{ secret: true }
),
valueEnv: option(
'',
'If set, the token is sourced from this environment variable at config-apply time and takes ' +
'precedence over `token`. Same boot-time caveat as `origin.securityToken.valueEnv`.',
// File-only for the same reason as `origin.securityToken.valueEnv`: it sets the token by proxy.
{ uiEditable: false }
),
missHeader: option('x-harper-render-miss', "Request header picking miss behavior: 'prerender' | 'origin'."),
defaultMissMode: option('prerender', 'Miss behavior when `missHeader` is absent.', {
enum: ['prerender', 'origin'],
}),
timeoutMs: option(30 * SECOND, 'Give up waiting for the fresh render after this long.', {
unit: 'ms',
min: 1,
}),
pollIntervalMs: option(250, 'How often to re-check the cache for the fresh render.', {
unit: 'ms',
min: 10,
}),
fallback: option(
'origin',
'What to serve when a prerender doesn’t land before `timeoutMs`:\n' +
" 'origin' — proxy the origin (same as a normal cache miss)\n" +
" 'stale' — serve the existing cached page if any, else fall back to origin\n" +
" 'error' — respond 504",
{ enum: ['origin', 'stale', 'error'] }
),
}
),
peerRescue: group(
'Cluster peer rescue for the serve path. A cache serve reads the stored body before committing ' +
'a status; when that LOCAL read fails — the blob file is gone (a dangling reference), or the ' +
'read outlived `page.blobReadBudgetMs` (a base copy is streaming that blob) — the bytes are ' +
'fetched from the URL’s residency owner over the cluster’s own HTTPS instead of proxying the ' +
'origin. The owner granted every render claim for its keys, so its blob is a written original, ' +
'never a received replica: it is the node most likely to hold complete bytes, a few ' +
'milliseconds away, and the rescued response is the real prerendered snapshot rather than raw ' +
'un-prerendered origin markup. The origin remains the backstop whenever the rescue misses ' +
'(the owner is this node, unreachable, past `timeoutMs`, or its own read fails).\n\n' +
'Enabling also serves the endpoint peers call (`GET /prerender_peer/page`), gated on `token`. ' +
'Set the SAME token on every node: a node with a different or empty token answers 403/404 and ' +
'its peers simply fall back to the origin, so a staggered rollout degrades softly rather than ' +
'breaking serves.',
{
enabled: option(
false,
'Enable the rescue (and the endpoint that serves peers). Necessary but not sufficient — a ' +
'non-empty `token` (or a `valueEnv` that resolves to one) is also required, so this cannot ' +
'open an unauthenticated endpoint on its own.'
),
header: option('x-harper-peer-token', 'Request header carrying the shared token on peer calls.', {
nonEmpty: true,
}),
token: option(
'',
'The shared cluster secret, identical on every node. **Required** — there is no ' +
'unauthenticated mode: an empty token leaves the feature DISABLED (no rescues attempted, the ' +
'endpoint answers 404) rather than serving cached pages to anyone who finds the path. ' +
'Compared timing-safely. Prefer `valueEnv` so the secret stays out of config.yaml, and never ' +
'commit a guessable placeholder.',
{ secret: true }
),
valueEnv: option(
'',
'If set, the token is sourced from this environment variable at config-apply time and takes ' +
'precedence over `token`. Same boot-time caveat as `origin.securityToken.valueEnv`.',
// File-only for the same reason as `origin.securityToken.valueEnv`: it sets the token by proxy.
{ uiEditable: false }
),
timeoutMs: option(
500,
'Deadline for the whole peer fetch (connect through body). A healthy rescue is a few ' +
'milliseconds of intra-cluster round trip plus the owner’s sub-millisecond blob read, so ' +
'this only trips when the owner is down, saturated, or mid-copy itself — at which point the ' +
'origin fallback proceeds exactly as it would have without the rescue. Keep it in the same ' +
'order as `page.blobReadBudgetMs`: the two are additive on the worst-case path ' +
'(budget + rescue timeout + origin).',
{ unit: 'ms', min: 1 }
),
}
),
management: group(
'Management API, served at the fixed path `/prerender_admin` (resource endpoint names are ' +
'fixed, like the database/table names). Gated on Harper’s own authentication: every endpoint ' +
'except the login/session/index routes requires a `super_user`. The console UI consuming this ' +
'API is the separate `@harperfast/prerender-console` component.',
{
enabled: option(
true,
'Serve the management API (and therefore anything the console can show).',
// Not editable from the console for the obvious reason: one click would take the console
// away, and getting it back needs a config-file edit. It stays live-reloadable from the
// file, which is the right place for a switch whose off position is unreachable.
{ uiEditable: false }
),
overrides: group(
'Operator-set config overrides — the layer between the deployed `config.yaml` and the ' +
'running config, stored one row per option path in `config.ConfigOverride` and ' +
'written from the console.\n\n' +
'Precedence is `schema defaults < config.yaml < these rows`. A deployed file change still ' +
'takes effect for every option nobody has overridden, clearing an override reverts that one ' +
'option to the deployed value, and clearing all of them returns the cluster to exactly its ' +
'deployed state. The rows replicate, so the console writes once on whichever node it ' +
'reached and every node converges.\n\n' +
'This whole group is file-only: it is the machinery the console writes through, and ' +
'editing the mechanism with the mechanism is how you end up locked out of both.',
{
enabled: option(
true,
'Honor stored overrides. FALSE IS THE KILL SWITCH: the rows are left in place but ' +
'ignored, so the cluster runs exactly its deployed `config.yaml` again. This is the ' +
'recovery path for an override that broke something, and the reason it has to live in ' +
'the file — an override you need to undo is a poor thing to undo through the override ' +
'layer.'
),
subscribe: option(
true,
'Watch the override table so a console edit converges in about a second instead of ' +
'waiting out `syncInterval`. Subscribing requires the table’s audit log (Harper turns ' +
'it on when you subscribe) and attaches its commit listener to the whole DATABASE’s ' +
'audit store, which is why this table lives alone in `config`: every commit in a ' +
'subscribed table’s database schedules a pass over the transaction log, so a ' +
'subscription sharing a database with the hot target/schedule tables would tax every ' +
'write to them. False leaves the backstop poll as the only path, which is correct ' +
'behavior, just slower.'
),
syncInterval: option(
30 * SECOND,
'Backstop re-read cadence for the override table, run on EVERY worker rather than one per ' +
'node: each worker holds its own config object, and the failure this covers — that ' +
'worker\u2019s subscription is gone — is per-worker by definition. The live path ' +
'is the subscription above; this exists so a subscription that was never established, ' +
'or a worker whose boot read failed, still converges — the layer gets a bound on how ' +
'stale it can be that does not depend on a callback firing. A re-read whose result is ' +
'unchanged does not re-apply, so the steady-state cost is one bounded scan of a table ' +
'with at most a few dozen rows. 0 disables the backstop, and the ceiling is node’s own ' +
'timer limit of 2^31-1 ms (~24.8 days) — past it a timer fires every millisecond ' +
'rather than never.',
{ unit: 'ms', min: 0, max: 2147483647 }
),
},
{ uiEditable: false }
),
proxyToOwner: option(
true,
'The URL explainer reads node-locally (a cross-node point read on the residency-pinned ' +
'schedule table awaits Harper’s replication fetch, which has no timeout). When the row is ' +
'owned by another node, ask that node over HTTPS instead — a bounded request, forwarding only ' +
'the caller’s own credentials, which the peer re-authorizes. Set false to keep every read ' +
'strictly node-local and accept an inconclusive schedule row.'
),
peerTimeoutMs: option(2500, 'Timeout for the peer-node explainer request.', { unit: 'ms', min: 1 }),
scanCap: option(
20000,
'Ceiling on rows touched by an overview scan (due-count, next-24h histogram, below-floor ' +
'detection). Counting is a capped index walk — at 1M+ targets an uncapped count is not a ' +
'page-load query — so results past this are reported as truncated rather than silently ' +
'undercounted. Note the due-count is no longer the headline capacity number: it now includes ' +
'every in-flight render, so its healthy floor is the in-flight count rather than zero.',
{ min: 1 }
),
backlogSnapshotInterval: option(
15 * MINUTE,
'How often the backlog/histogram snapshot recomputes (worker 0 of each node). Since v0.34.0 ' +
'this is the ONLY scan that still seeks the absolute minimum of the nextRenderTime index — ' +
'`claim` starts from queue.claimFloor instead — and it is kept that way deliberately, because ' +
'it is therefore the only reader that can see a row filed BELOW the floor and report it. It ' +
'runs on this cadence, never on dashboard page load. Its `overdue` count now includes ' +
'in-flight jobs (their rows keep their past due time until the render lands). 0 disables the ' +
'timer; the console’s Recompute button still triggers a one-off pass.',
{ unit: 'ms', min: 0 }
),
snapshotTableCounts: option(
true,
'Include the four table counts (targets, pages, sitemaps, suppressed) in each backlog ' +
'snapshot. The counts go through Harper’s getRecordCount, which on RocksDB tables past ' +
'the sampling budget issues ONE synchronous native full-key iteration — measured 2.47s ' +
'on a ~2.2M-key table, during which every request routed to that worker waits ' +
'(harper-pro#664). False keeps the snapshot itself (the capped backlog/histogram walk and ' +
'the queue_health gauges, which never take that walk) while the console shows the counts ' +
'as unavailable — the setting for a deployment that disabled the whole snapshot to dodge ' +
'#664 and thereby lost its below-floor detector.',
{}
),
pageSize: option(
50,
'Rows per page for the console’s sitemap-entry and page-cache tables. Also bounds the ' +
'per-entry state lookups a sitemap detail performs (point reads, one per row).',
{ min: 1 }
),
analytics: group(
'The console’s Traffic/queue-health charts: ONE bounded primary-key scan of this node’s ' +
'`system.hdb_analytics` per refresh (never one scan per metric name — the table is ' +
'indexed only by time, so a name is a scan and a series is a row), bucketed ' +
'server-side and cached per worker. The console never polls; a scan happens only when ' +
'an operator loads a view whose cached window has expired.',
{
enabled: option(true, 'Serve GET /prerender_admin/analytics and the console panels that read it.'),
maxRange: option(
DAY,
'Ceiling on the window one analytics request may ask for. The scan cost scales ' +
'directly with the window (rows = active metric combos × aggregate periods), so ' +
'this is the knob that bounds the worst read an operator can trigger.',
{ unit: 'ms', min: MINUTE }
),
cacheTtl: option(
MINUTE,
'How long a scanned window is served from the per-worker cache before a refresh ' +
're-scans. Matches Harper’s default analytics aggregation period — refreshing ' +
'faster cannot surface new rows, only repeat the scan.',
{ unit: 'ms', min: 0 }
),
scanCap: option(
150000,
'Ceiling on rows one analytics scan walks. The walk runs NEWEST-FIRST, so past the ' +
'cap it is the oldest end of the window that is shed, and the response reports ' +
'the window it actually covered rather than presenting a partial range as the ' +
'full one.',
{ min: 1000 }
),
}
),
}
),
page: group('Cached-page lifetimes.', {
ttl: option(DAY, 'Default cached-page TTL.', { unit: 'ms', min: 1 }),
minTtl: option(6 * HOUR, 'Floor for sitemap-derived TTLs.', { unit: 'ms', min: 1 }),
swrTtl: option(3 * HOUR, 'Stale-while-revalidate window.', { unit: 'ms', min: 0 }),
blobReadBudgetMs: option(
500,
'How long a cache serve may spend reading the stored body before giving up and proxying to ' +
'the origin instead.\n\n' +
'The body is read to completion before the response commits a status, so that a record whose ' +
'blob file is gone becomes an origin serve rather than a truncated 200. Without a budget that ' +
'read inherits Harper’s own retry window (`storage_blobReadTimeout`, default 20s): a blob ' +
'whose bytes are still arriving — which any base copy produces in quantity — puts the reader ' +
'into an incomplete-content retry loop, and the crawler waits it out. Measured on a 4-node ' +
'production cluster mid-copy: a cohort of cache hits averaging 13.6s, p95 17.5s, ~13% of hits ' +
'on the worst node, while the same node’s median hit was 2.3ms.\n\n' +
'A healthy read is nowhere near this: p50 0.75ms and p99 0.94ms for a ~223KB body on cold ' +
'NVMe, so 500ms is ~500x the p99 and only a blob that is genuinely stuck can trip it. Keep it ' +
'BELOW typical origin latency (~500-600ms here) so falling back is faster than waiting; ' +
'raising it past `storage_blobReadTimeout` disables it entirely. 0 disables the budget and ' +
'restores the unbounded wait.\n\n' +
'Capped at 2147483647 because `setTimeout` stores its delay as a signed 32-bit int: a larger ' +
'value does not mean "effectively never", it makes Node warn and fire the callback after 1ms — ' +
'so a fat-fingered budget would time out EVERY cache hit and send all traffic to the origin. ' +
'The cap turns that into a rejected value that keeps the default.',
{ unit: 'ms', min: 0, max: 2147483647 }
),
}),
invalidation: group(
'Bulk cache invalidation. An invalidation records ONE ROW naming a scope and an instant; from then ' +
'on, any cached page in that scope rendered before that instant stops being served and bots get ' +
'the origin instead, until the page re-renders on its normal cadence.\n\n' +
'Nothing is rewritten — not the cached pages, not the render schedule — so recording one costs a ' +
'single 102-byte write instead of the ~61.8MB of audit per node a corpus rewrite costs, and UNDO ' +
'IS INSTANT: delete the row and every page still inside its own expiry/stale-while-revalidate ' +
'window serves again on the next request. Pages already past that window cannot come back, ' +
'because their own lifetime expired while the invalidation was active; that asymmetry is inherent ' +
'to not rewriting anything.\n\n' +
'A scope is `all` or one prerender route from ingress.routes, written `route:<match>:<path>`. ' +
'There are deliberately no free-text prefix scopes: a prefix cannot be checked against a closed ' +
'set, so a typo would record a row that reports as applied and matches nothing — the worst ' +
'failure available, because the mitigation appears to have worked. For a narrower blast radius, ' +
'declare a narrower route.\n\n' +
'TWO THINGS THIS CANNOT DO, both worth knowing before you rely on it. THE CDN EDGE IS NOT ' +
'INVALIDATED and keeps its own TTL, and neither is a copy a crawler already holds. And origin ' +
'markup carries correct price, availability, canonical, title and meta description, but not ' +
'reviews or most images — so an invalidated page serves a thinner document than a rendered one.',
{
enabled: option(
true,
'Consult invalidation rows when serving, and allow the API to record them.\n\n' +
'FALSE IS A KILL SWITCH, not a feature flag: every active invalidation stops applying at once ' +
'and the whole corpus serves pre-invalidation bytes again. It exists because at 3am you want a ' +
'way to take a new mechanism out of the serve path — but while any row exists it is reported as ' +
'a config warning, a log line and a console banner, because silently serving content somebody ' +
'deliberately invalidated is the one outcome this feature must never produce.'
),
pad: option(
10 * MINUTE,
'Added to `invalidatedAt` before comparing, so the comparison errs toward invalidating.\n\n' +
'It covers two things. Cross-node clock skew: a page’s `lastCached` is stamped by whichever ' +
'node rendered it and the epoch by whichever node recorded it. And — the certain one — renders ' +
'ALREADY IN FLIGHT: a job claimed a moment before you invalidate fetched pre-change content but ' +
'stamps `lastCached` at completion, so with no pad that page outlives the invalidation for a ' +
'full render interval. That window is legitimately as long as `queue.jobLeaseTime` (a job may ' +
'post back any time inside its lease, and does under backlog — exactly the state incidents ' +
'create), so keep this at or above jobLeaseTime; a smaller value is reported as a config ' +
'warning. The cost of over-including a page is one extra render of it.',
{ unit: 'ms', min: 0 }
),
lkgMaxAge: option(
5 * MINUTE,
'How long a worker may reuse its last successful resolution when a read fails.\n\n' +
'Past this, resolution fails OPEN — serving from cache as though nothing were invalidated — ' +
'rather than trusting a stale answer. Both halves matter: without a bound, one transient read ' +
'error after a clear would pin a worker on a deleted epoch for the rest of its life, with the ' +
'console showing nothing active and offload quietly sagging. Failing open is the right default ' +
'because this table’s normal state is EMPTY, so "unknown" almost certainly means "nothing is ' +
'invalidated", and failing closed would turn a cosmetic storage fault into a total offload ' +
'outage. Set 0 to fail open on the first read error.',
{ unit: 'ms', min: 0 }
),
maxScopes: option(
16,
'Ceiling on simultaneously active scopes. Bounds the console walk and the operator surface — NOT ' +
'the serve-path read, which is at most two point reads by known key (`all` plus the one route ' +
'the request matched) however many rows exist.',
{ min: 1 }
),
verification: group(
'PER-PAGE EXEMPTION. Let a page an invalidation would refuse be served anyway when the change ' +
'probe has PROVED it is still current — `pageCheck` compared the cached page\u2019s own claims against ' +
'the origin after the epoch and they agreed.\n\n' +
'WHY THIS EXISTS. A bulk invalidation refuses everything in scope rendered before the epoch because ' +
'it cannot tell what actually changed. Measured during a route-wide trip on a four-node deployment, ' +
'only 22-29% of the scope had genuinely moved; the rest were origin-proxied for up to a full render ' +
'interval while being byte-for-byte correct. This turns "predates the epoch" into "lacks evidence", ' +
'which is the question the invalidation was always asking.\n\n' +
'WHAT IT ASSERTS, EXACTLY: the fields the probe rule watches still match. Nothing more. A promo flip ' +
'also moves badges, banners and copy no probe looks at, so a verified page is "price and availability ' +
'confirmed", never "fresh". Judge whether that is the right bar for what you invalidate FOR.\n\n' +
'REQUIRES `changeProbe.pageCheck` on the rule whose `invalidateScope` recorded the invalidation. A ' +
'signature match alone is NOT sufficient and is deliberately not accepted: it says the origin has not ' +
'moved since the last probe, which says nothing about whether the cached page was ever right.',
{
enabled: option(
false,
'Off by default, like `invalidation.reenqueue.enabled` and `render.reconcile.enabled` \u2014 enable it ' +
'after one rehearsal, not on the deploy that introduces it. While off, nothing is written and ' +
'nothing is read: every page is refused on the epoch comparison alone, exactly as before.\n\n' +
'EVERY FAILURE FAILS CLOSED. Absent row, unprobed URL, failed probe, read error, unreadable ' +
'timestamp \u2014 all mean NOT VERIFIED, and the page keeps being proxied. That is the opposite of ' +
'`invalidation.lkgMaxAge`, which fails OPEN, and the asymmetry is the point: an unknown epoch ' +
'almost certainly means "nothing is invalidated", while unknown evidence means "I cannot prove ' +
'this page is current".'
),
}
),
reenqueue: group(
'DEMAND-DRIVEN HEAL. When an invalidation is what made a request non-servable, lower that URL’s ' +
'due time so the pages bots actually crawl heal first instead of waiting out their cadence in ' +
'crawl order. The request itself is the trigger — no timer, no table scan, no cursor — and only ' +
'the node that OWNS the key by residency acts, because the claim floor a lowered due time has to ' +
'move is a node-local shared buffer that a write from another node cannot reach.\n\n' +
'THERE IS DELIBERATELY NO CORPUS-WIDE SWEEP, and there will not be one. At a measured fleet ' +
'ceiling of 71,289 renders/hr the 1,530,046-key long-tail corpus floors a full re-render at 21.5h ' +
'at 100% utilisation — against the 48h those pages wait anyway, with measured utilisation already ' +
'98% and a 3.05h standing backlog — while rewriting the corpus costs ~61.8MB of audit per node ' +
'that pacing provably does not reduce (batching kept 162 B/write, took 8.9x longer and made ' +
'claim’s max latency WORSE). Cadence-heal plus this accelerator is the whole mechanism.\n\n' +
'Scale, so the ceilings below read as the small numbers they are: ~4,000 bot requests/day ' +
'cluster-wide against 1.6M cache keys, of which crawlers request about 0.25%.',
{
enabled: option(
false,
'Off by default, like `render.reconcile.enabled`: enable it after one rehearsal, not on the ' +
'same deploy that introduces it. While off, an invalidation adds NOTHING to the queue — zero ' +
'schedule writes, zero audit, zero claim-scan work — and every page heals on its own cadence.'
),
spreadWindow: option(
15 * MINUTE,
'Jitter window a lowered due time lands in: `now + hash(url) % spreadWindow`, seeded off the ' +
'URL half of the cache key so a page’s device variants land on the SAME minute (see ' +
'util/time.js — de-aligned variants show a content change on one device and not the other, ' +
'permanently, cycle over cycle).\n\n' +
'NEVER "now". Collapsing due times onto one instant piles rows exactly where the claim scan ' +
'seeks: measured, that takes the claim scan from 0.36ms to 11.59ms (32x), and the scar clears ' +
'only on the next compaction of that store, which needs write pressure.\n\n' +
'MUST BE >= `queue.jobLeaseTime`, and a smaller value is reported as a config warning and ' +
'then clamped up to it — because a narrow window is a smaller version of the same pile, not ' +
'because the two quantities are coupled. `queue.jobLeaseTime` is floored at 2 minutes, which ' +
'makes it the smallest spread this system already trusts. (Overwriting a render in flight is a ' +
'DIFFERENT hazard and is closed elsewhere, exactly: the accelerator refuses outright when any ' +
'device key of the URL holds a live claim lease.)',
{ unit: 'ms', min: 0 }
),
crossNode: group(
'FORWARD A HEAL TO THE KEY\u2019S OWNER instead of discarding it. Without this, a heal is refused ' +
'outright whenever the crawler landed on a node that does not own the key by residency \u2014 measured ' +
'at 84-85% of all attempts on a four-node deployment, because bot traffic lands where the CDN\u2019s ' +
'geo-routing sends it while residency is hashed over the key, and the two are independent. The ' +
'module\u2019s "crawlers revisit" fallback assumes those two distributions match; where traffic is ' +
'concentrated on one node they do not, and a quarter of the corpus never heals on demand at all.\n\n' +
'THE OWNER DECIDES, this only carries the request. Three of the accelerator\u2019s guards \u2014 the live-lease ' +
'check, the authoritative schedule read, and therefore "never raise a due time" \u2014 can only be ' +
'evaluated on the owner, so writing from the receiving node instead would trade coverage for ' +
'delayed renders. (The floor objection people reach for first is void: `claim` seeks from a floor ' +
'clamped to `now - claimFloor.guard`, so any due time at or after the current minute is claimable ' +
'on any node.)\n\n' +
'COST IS BOUNDED BY `maxPerMinute`, NOT BY TRAFFIC: the slot is reserved before the call, so this ' +
'is at most that many requests per node per minute however much bot traffic arrives.\n\n' +
'REQUIRES `peerRescue.token` and `peerRescue.header`, reusing that shared cluster secret rather ' +
'than minting a second one \u2014 same trust boundary (node-to-node, on the serve path, with no user ' +
'credential available to forward), and one secret to rotate instead of two. With either unset this ' +
'is inert and the endpoint answers 404.',
{
enabled: option(
false,
'Off by default, like the accelerator itself. While off, a heal for a key this node does not own ' +
'is refused as `not-owner` exactly as before, and the `/prerender_peer/heal` endpoint does not ' +
'exist.'
),
timeoutMs: option(
2000,
'Deadline for one forwarded heal. Generous is pointless here: the request that triggered it has ' +
'already been answered, this is a repair running detached, and a peer that cannot answer in ' +
'seconds will not heal anything useful. A timeout is counted as `forward-failed`.\n\n' +
'Capped at the 32-bit signed maximum because this value reaches `setTimeout`: past that Node ' +
'emits TimeoutOverflowWarning and fires the timer IMMEDIATELY, so a fat-fingered value would ' +
'abort every forwarded heal on the spot rather than allowing a long one. The cap turns that ' +
'into a rejected value that keeps the default.',
{ unit: 'ms', min: 1, max: 2147483647 }
),
}
),
maxPerMinute: option(
10,
'Per-node ceiling on accelerated REQUESTS per minute, shared across every worker on the node ' +
'(one minute-bucketed counter in a shared buffer). One accelerated request writes at most one ' +
'schedule row PER DEVICE ROW THE URL HAS — `deviceTypes.default` (two on this deployment), ' +
'plus the served device when that one is merely `supported` — so the write ceiling is this ' +
'number times those rows.\n\n' +
'Sized so its CEILING is defensible, not just its typical. 10/min/node is 14,400 ' +
'requests/node/day ≈ 28,800 schedule writes ≈ 2.3MB of audit/node/day, about 7% of measured ' +
'spare fleet render capacity (~792,700 renders/day spare against a 1,710,936/day ceiling and ' +
'~918,000/day of baseline cadence demand) — against a measured demand of roughly 1,000 ' +
'owner-node candidate requests/day CLUSTER-WIDE, i.e. ~14x headroom. Raising it toward 120 ' +
'would authorise ~87% of all spare fleet capacity, which is why it is not the default.',
{ min: 1 }
),
}
),
}
),
changeProbe: group(
'CHANGE-DRIVEN RE-RENDERING. Instead of guessing how often a page changes with an interval, ask ' +
'the origin — cheaply — whether the fields bots care about actually changed, and re-render only ' +
'then. A probe is one small HTTP request per URL: either an endpoint the page itself consults ' +
'(`source: request` — e.g. a product price/availability API, typically thousands of times ' +
'cheaper than a render), or the page document’s own schema.org JSON-LD Product offers ' +
'(`source: document` — nothing site-specific to configure). The extracted fields are reduced to ' +
'a signature stored on the target; a later probe that observes a different signature expires the ' +
'cached pages and files the URL due now.\n\n' +
'TWO CADENCES FOR TWO KINDS OF CHANGE. The rolling SWEEP (sweepInterval) walks the whole ' +
'registry and catches continuous, per-URL drift — availability sell-through, item-level price ' +
'moves. The CANARY (canary.*) probes a small fixed cohort every few minutes, because commerce ' +
'price does not drift — it STEPS at promotional events, most of a catalog at once, which a ' +
'sample of hundreds sees within minutes while a full sweep is still hours away. On a canary ' +
'trip the rule’s `invalidateScope` records a bulk invalidation: pre-change snapshots stop ' +
'serving immediately (bots get origin content, which is correct by definition) while ' +
're-renders refill on their own machinery. Detection and response are different mechanisms on ' +
'purpose — re-rendering a large corpus takes the fleet hours; invalidating it takes one row.\n\n' +
'A PROBE FAILURE CHANGES NOTHING, by design: fetch errors, non-2xx, unparseable bodies and ' +
'extractions that yield no values leave the stored signature untouched and trigger nothing. ' +
'The probe is an accelerator on top of the baseline render cadence, never a gate on it — the ' +
'failure mode to survive is the origin replatforming under a rule, which surfaces as a high ' +
'probe_failed share and a loud log line, not as schedule churn. Probes run owner-scoped on ' +
'worker 0 of every node (each node probes the URLs it owns), carry the same User-Agent and ' +
'security token as every other origin fetch, and are rate-capped per node — AGREE THE RATE ' +
'WITH WHOEVER RUNS THE ORIGIN before enabling a sweep over a large corpus: probe endpoints ' +
'are typically uncached, so every request is origin backend work.',
{
enabled: option(false, 'Master switch. Off = no probes, no timers, nothing stored.'),
dryRun: option(
true,
'Probe, count and log every decision — but re-render nothing and invalidate nothing. ' +
'Signatures ARE written in dry run (the demand-ladder precedent), so each pass reports fresh ' +
'changes and a measured week converges on the true change rate instead of re-reporting the ' +
'same delta. Default ON: enabling `enabled` alone changes no schedule until this is turned off.'
),
rules: option(
[],
'What to probe and how — an array of rule objects; the FIRST rule whose pathPattern matches a ' +
'target’s URL path claims it (order most-specific first). Invalid rules are dropped ' +
'individually with a warning, like ingress.routes entries.\n\n' +
'Rule shape:\n' +
' pathPattern (required) regular expression matched against the URL path; capture ' +
'groups feed the template.\n' +
' source "document" (default): GET the page itself and extract its JSON-LD ' +
'Product offers (price, currency, availability) — generic, works for any site with ' +
'standard product markup. "request": probe a configured endpoint instead.\n' +
' request.urlTemplate (request mode, required) absolute URL with $1..$9 replaced by ' +
'pathPattern’s capture groups, URI-component-encoded. The origin security token and the ' +
'staging-IP pin are attached ONLY when this endpoint shares the probed page’s origin — a ' +
'third-party host gets a plain fetch, never the bypass secret. Redirects are not followed ' +
'(a redirecting endpoint is a failed probe, and the failure metrics say so).\n' +
' request.method GET (default) | POST.\n' +
' request.headers extra request headers, e.g. { accept: "application/json" } — many JSON ' +
'endpoints require an explicit accept and fail with a 200-shaped error without it.\n' +
' request.body request body string (e.g. "{}").\n' +
' extract (request mode, required) value paths into the JSON response, e.g. ' +
'"payload.products[0].prices[0].salePrice" — the extracted values ARE the watched content; ' +
'everything else in the response is ignored. An extraction where every path yields null is a ' +
'FAILED probe, never a new signature, so an endpoint shape change cannot mass-trigger.\n' +
' statusSignals optional [{ status, signature, contains? }] — statuses this endpoint uses ' +
'to SAY something rather than to fail, mapped to a fixed signature. An endpoint that answers ' +
'a legitimate state with an error status (most usefully "no longer available" as a 4xx with ' +
'a code in the body) is otherwise read as a failed probe, which leaves the signature ' +
'untouched and triggers nothing — so the one transition that most needs detecting, ' +
'available -> unavailable, is exactly the one the probe cannot see. The signature is an ' +
'opaque literal compared for equality like any other, so the transition is detected in BOTH ' +
'directions. `contains` guards on a body substring (match the endpoint’s error CODE, not its ' +
'prose, which gets reworded). Only non-2xx statuses may carry a signal; a 2xx is extracted ' +
'normally. A declared signal outranks the origin-pushback classification, so do not declare ' +
'one for 429/503 unless that status really is a state on this endpoint rather than an ' +
'overloaded origin. CAUTION: if the endpoint starts answering the signaled status for ' +
'EVERYTHING, every matched URL flips to the same signature at once — bounded by ' +
'`maxTriggersPerSweep`, and the canary treats it as the mass change it looks like.\n' +
'\n\nPAGE CHECK (`pageCheck`). The comparison above asks "did the origin change since I last ' +
'looked", which is structurally blind to a value that changes and changes BACK between two ' +
'passes — and if a render landed inside that window, the cached page keeps the transient value ' +
'until its interval expires (measured at ~2.7% of served product pages on one deployment, all ' +
'of them a page reading OutOfStock for something the origin says is available). Set ' +
'`pageCheck: { enabled: true, priceFrom: <i>, availableFrom: <i> }` and the render path records ' +
'what each page CLAIMS, so the pass can also ask "does the page still agree with the origin". ' +
'The two indices are positions in this rule\u2019s own `extract` array — site-specific by ' +
'nature, since only the operator knows which field is the price their page prints — and the ' +
'block is dropped whole if either is out of bounds, because a half-applied mapping compares the ' +
'wrong column. REQUIRES @harperfast/prerender-browser >= 1.20.0, which posts the page\u2019s offers ' +
'with the render result: there is deliberately NO fallback to parsing the stored HTML (a regex scan ' +
'and JSON parse of a ~1MB document on the hottest write path, to recover what the browser already ' +
'had structured), so against an older renderer pageCheck records nothing and detects nothing \u2014 ' +
'logged hourly rather than failing silently. `source: request` only: in document mode the stored signature already IS the ' +
'page\u2019s offers. A page yielding no Product offers records nothing, exactly as a failed ' +
'probe changes nothing, so a markup change cannot make every page look like a disagreement \u2014 ' +
'and each dimension compares only when BOTH sides make a readable claim (availability must ' +
'reduce to a recognized schema.org verdict on the page and a boolean at the endpoint; price ' +
'must parse as a number on the page), so an unrecognized vocabulary or price format degrades ' +
'to detecting nothing rather than expiring everything. ' +
'Detection is one extra node-local write per render and no extra origin traffic.\n' +
' invalidateScope optional invalidation scope ("all" or "route:<match>:<path>") the canary ' +
'records on a mass change. Empty = the canary detects and logs only.\n' +
' label optional name for logs and the admin surface.',
{ itemType: 'object' }
),
mode: option(
'interval',
'How the sweep is scheduled.\n\n' +
'"interval" (default) fires a discrete pass every `sweepInterval`. That model asks the ' +
'operator to solve `sliceSize / effectiveRate <= sweepInterval` BY HAND, and to re-solve ' +
'it every time the corpus grows or the origin has a bad week — because when the answer ' +
'stops holding, the overrunning pass is simply skipped (`sweepRunning` is still set) and ' +
'the cadence silently doubles with nothing in metrics saying so. It also idles: a slice ' +
'that takes 9h of a 12h interval leaves 3h in which nothing is probed at all, so ' +
'detection latency is bimodal rather than uniform.\n\n' +
'"continuous" never stops walking and never re-solves anything: it derives its rate each ' +
'batch from remaining rows over remaining budget (`cycleTarget`), so corpus growth and ' +
'time lost to backoff are absorbed as they happen. `ratePerSecond` stays a hard ceiling. ' +
'A target that cannot be met at the ceiling is reported (`probe_cycle_behind`) rather ' +
'than silently missed — which is the whole point of the mode.\n\n' +
'Switching is safe in both directions and takes effect on the next config apply; a pass ' +
'in flight finishes under the rules it started with.',
{ enum: ['interval', 'continuous'] }
),
sweepInterval: option(
DAY,
'How often each node walks its slice of the registry probing every matched URL. ' +
'INTERVAL MODE ONLY — ignored when `mode` is "continuous", where `cycleTarget` sets the ' +
'cadence and there is no gap between passes to schedule.',
{
unit: 'ms',
min: MINUTE,
// setInterval stores its delay as a signed 32-bit int; past this it fires immediately
// and the sweep hot-loops (the page.blobReadBudgetMs lesson).
max: 2147483647,
}
),
cycleTarget: option(
DAY,
'CONTINUOUS MODE ONLY: the wall-clock budget for covering every owned, matched URL once — ' +
'i.e. the worst-case detection latency you are asking for. The pass paces itself to land ' +
'on it: remaining rows over remaining budget, recomputed every batch.\n\n' +
'This is a TARGET, never a licence. `ratePerSecond` is the ceiling agreed with whoever ' +
'runs the origin and is never exceeded to hit a target, so an unreachable one is missed ' +
'openly — every batch that wants more than the ceiling counts a `probe_cycle_behind`, and ' +
'a sustained count means the corpus has outgrown its agreed rate and wants a longer ' +
'target or a conversation about the ceiling.\n\n' +
'THE FIRST CYCLE AFTER A RESTART RUNS AT THE CEILING. Pacing needs a denominator and the ' +
'slice size is only known once a cycle has finished counting it; a cycle target cannot be ' +
'honoured against an unknown corpus, and guessing one would pace to a fiction. So the ' +
'first cycle measures, and every cycle after it paces.',
{ unit: 'ms', min: MINUTE }
),
ratePerSecond: option(
10,
'Sustained probe-request ceiling per node. THE ORIGIN-PROTECTION KNOB: probe endpoints are ' +
'typically no-store, so every probe is backend work for the origin — size this with the ' +
'origin’s operator, not from what the fleet can send. Also what sizes a sweep: a 200k-URL ' +
'node slice at 10/s is ~5.6h per pass.',
{ min: 1 }
),
concurrency: option(