webrecipe 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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +253 -0
  3. package/dist/benchmark/amortization.js +254 -0
  4. package/dist/benchmark/fixtures.js +26 -0
  5. package/dist/benchmark/oracles.js +129 -0
  6. package/dist/benchmark/plans.js +436 -0
  7. package/dist/fixtures/cloaking.js +37 -0
  8. package/dist/fixtures/coalesce.js +52 -0
  9. package/dist/fixtures/data.js +23 -0
  10. package/dist/fixtures/harness.js +34 -0
  11. package/dist/fixtures/ignoring.js +27 -0
  12. package/dist/fixtures/limiting.js +38 -0
  13. package/dist/fixtures/paging.js +72 -0
  14. package/dist/fixtures/refusing.js +57 -0
  15. package/dist/fixtures/shifted.js +32 -0
  16. package/dist/fixtures/spa.js +71 -0
  17. package/dist/fixtures/ssr.js +46 -0
  18. package/dist/fixtures/volatile.js +40 -0
  19. package/dist/fixtures/xhr.js +120 -0
  20. package/dist/src/analyzer/classify.js +16 -0
  21. package/dist/src/analyzer/score.js +52 -0
  22. package/dist/src/authoring/candidates.js +168 -0
  23. package/dist/src/authoring/contract.js +31 -0
  24. package/dist/src/authoring/fields.js +86 -0
  25. package/dist/src/authoring/learn.js +51 -0
  26. package/dist/src/authoring/plans.js +93 -0
  27. package/dist/src/authoring/snapshot.js +22 -0
  28. package/dist/src/authoring/teach.js +136 -0
  29. package/dist/src/benchmark/discovery.js +355 -0
  30. package/dist/src/benchmark/golden.js +95 -0
  31. package/dist/src/benchmark/grade.js +146 -0
  32. package/dist/src/benchmark/ground-truth.js +35 -0
  33. package/dist/src/benchmark/health.js +96 -0
  34. package/dist/src/benchmark/labels.js +49 -0
  35. package/dist/src/benchmark/oracle.js +55 -0
  36. package/dist/src/benchmark/report.js +191 -0
  37. package/dist/src/benchmark/runner.js +201 -0
  38. package/dist/src/benchmark/screen.js +144 -0
  39. package/dist/src/benchmark/selector-score.js +86 -0
  40. package/dist/src/benchmark/verification-cases.js +138 -0
  41. package/dist/src/benchmark/verification-matrix.js +97 -0
  42. package/dist/src/browser/navigate.js +22 -0
  43. package/dist/src/browser/pool.js +31 -0
  44. package/dist/src/browser/session.js +44 -0
  45. package/dist/src/cli.js +559 -0
  46. package/dist/src/compiler/derive.js +144 -0
  47. package/dist/src/compiler/heuristic.js +398 -0
  48. package/dist/src/compiler/html.js +117 -0
  49. package/dist/src/compiler/types.js +12 -0
  50. package/dist/src/compiler/verify.js +29 -0
  51. package/dist/src/executor/extract.js +179 -0
  52. package/dist/src/executor/format.js +55 -0
  53. package/dist/src/executor/index.js +147 -0
  54. package/dist/src/executor/strategies/browser.js +60 -0
  55. package/dist/src/executor/strategies/http-html.js +42 -0
  56. package/dist/src/executor/strategies/http-json.js +71 -0
  57. package/dist/src/executor/strategies/warm-browser.js +57 -0
  58. package/dist/src/executor/tokens.js +11 -0
  59. package/dist/src/healing/index.js +111 -0
  60. package/dist/src/local.js +157 -0
  61. package/dist/src/mcp.js +130 -0
  62. package/dist/src/measurement.js +44 -0
  63. package/dist/src/net/politeness.js +141 -0
  64. package/dist/src/net/robots.js +56 -0
  65. package/dist/src/read.js +83 -0
  66. package/dist/src/recipes/fingerprint.js +41 -0
  67. package/dist/src/recipes/paths.js +14 -0
  68. package/dist/src/recipes/registry.js +81 -0
  69. package/dist/src/recipes/schema.js +38 -0
  70. package/dist/src/recipes/template.js +33 -0
  71. package/dist/src/recorder/body.js +59 -0
  72. package/dist/src/recorder/index.js +151 -0
  73. package/dist/src/recorder/types.js +1 -0
  74. package/dist/src/sites.js +45 -0
  75. package/dist/src/tasks.js +37 -0
  76. package/dist/src/types.js +32 -0
  77. package/dist/src/usage.js +69 -0
  78. package/dist/src/validator/index.js +28 -0
  79. package/dist/src/verification/lexical-consistency.js +88 -0
  80. package/dist/src/verification/pagination-honored.js +110 -0
  81. package/dist/src/verification/probes.js +98 -0
  82. package/dist/src/verification/query-honored.js +134 -0
  83. package/dist/src/wiring.js +33 -0
  84. package/package.json +56 -0
@@ -0,0 +1,23 @@
1
+ const TOPICS = ['engineer', 'designer', 'analyst', 'manager'];
2
+ const STACKS = ['rust', 'typescript', 'go', 'python', 'elixir'];
3
+ export const DATASET = Array.from({ length: 40 }, (_, i) => {
4
+ const topic = TOPICS[i % TOPICS.length];
5
+ const stack = STACKS[i % STACKS.length];
6
+ return {
7
+ id: String(100 + i),
8
+ title: `Senior ${stack} ${topic} #${i}`,
9
+ author: `author-${i % 7}`,
10
+ tags: [stack, topic],
11
+ body: `Full description for record ${100 + i}. Stack is ${stack}.`,
12
+ };
13
+ });
14
+ export const PAGE_SIZE = 10;
15
+ export function search(query, page = 1) {
16
+ const q = query.toLowerCase().trim();
17
+ const hits = q === '' ? DATASET : DATASET.filter((r) => r.title.toLowerCase().includes(q) || r.tags.some((t) => t.includes(q)));
18
+ const start = (page - 1) * PAGE_SIZE;
19
+ return hits.slice(start, start + PAGE_SIZE);
20
+ }
21
+ export function byId(id) {
22
+ return DATASET.find((r) => r.id === id);
23
+ }
@@ -0,0 +1,34 @@
1
+ import { createServer } from 'node:http';
2
+ export async function startFixture(name, handler, port = 0) {
3
+ const requestLog = [];
4
+ let version = 'v1';
5
+ let url = '';
6
+ const http = createServer((req, res) => {
7
+ requestLog.push(req.url ?? '');
8
+ handler(req, res, server);
9
+ });
10
+ const server = {
11
+ name,
12
+ get url() { return url; },
13
+ get version() { return version; },
14
+ set version(v) { version = v; },
15
+ setVersion(v) { version = v; },
16
+ requestLog,
17
+ close: () => new Promise((resolve) => { http.close(() => resolve()); }),
18
+ };
19
+ await new Promise((resolve, reject) => { http.once('error', reject); http.listen(port, '127.0.0.1', resolve); });
20
+ url = `http://127.0.0.1:${http.address().port}`;
21
+ return server;
22
+ }
23
+ export function sendHtml(res, html, status = 200) {
24
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
25
+ res.end(html);
26
+ }
27
+ export function sendJson(res, body, status = 200) {
28
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
29
+ res.end(JSON.stringify(body));
30
+ }
31
+ export function sendJs(res, js) {
32
+ res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8' });
33
+ res.end(js);
34
+ }
@@ -0,0 +1,27 @@
1
+ import { startFixture, sendHtml } from './harness.js';
2
+ import { page } from './ssr.js';
3
+ import { DATASET, PAGE_SIZE } from './data.js';
4
+ /**
5
+ * Accepts a search parameter and discards it, the way arbeitnow.com serves its
6
+ * unfiltered front page for every query.
7
+ *
8
+ * Nothing about the response says so: the status is 200, the markup is the
9
+ * markup the plan was taught, and every field the recipe extracts is populated.
10
+ * Only asking it a second, different question reveals the answer never moved.
11
+ */
12
+ export function startIgnoringFixture() {
13
+ return startFixture('siteIgnoring', (req, res) => {
14
+ const url = new URL(req.url ?? '/', 'http://localhost');
15
+ if (url.pathname !== '/search') {
16
+ sendHtml(res, page('Home', '<a href="/search?q=rust">search</a>'));
17
+ return;
18
+ }
19
+ const rows = DATASET.slice(0, PAGE_SIZE)
20
+ .map((r) => `<li class="result" data-id="${r.id}">
21
+ <a class="title" href="/item/${r.id}">${r.title}</a>
22
+ <span class="author">${r.author}</span>
23
+ </li>`)
24
+ .join('');
25
+ sendHtml(res, page('Search', `<ul id="results">${rows}</ul>`));
26
+ });
27
+ }
@@ -0,0 +1,38 @@
1
+ import { startFixture, sendHtml } from './harness.js';
2
+ import { page } from './ssr.js';
3
+ import { search } from './data.js';
4
+ /**
5
+ * Answers one query and turns away every other, the way a site that serves a
6
+ * warmed query and rate-limits the rest does.
7
+ *
8
+ * Keyed on the query value rather than on how many requests have arrived: the
9
+ * probes must be turned away because of what they ask, not because of when
10
+ * they ask it. Nothing here depends on the caller's probe order, retries or
11
+ * concurrency.
12
+ *
13
+ * A site saying "not so fast" is not a site saying the query means nothing, and
14
+ * the verdict has to keep them apart.
15
+ */
16
+ export function startLimitingFixture(status = 429, answers = 'rust') {
17
+ return startFixture('siteLimiting', (req, res) => {
18
+ const url = new URL(req.url ?? '/', 'http://localhost');
19
+ if (url.pathname !== '/search') {
20
+ sendHtml(res, page('Home', '<a href="/search?q=rust">search</a>'));
21
+ return;
22
+ }
23
+ if ((url.searchParams.get('q') ?? '') !== answers) {
24
+ // Retry-After only on the rate limit, which is where a site states one.
25
+ res.writeHead(status, status === 429
26
+ ? { 'content-type': 'text/plain', 'retry-after': '3600' }
27
+ : { 'content-type': 'text/plain' });
28
+ res.end(status === 429 ? 'slow down' : 'temporarily unavailable');
29
+ return;
30
+ }
31
+ const rows = search(answers, 1)
32
+ .map((r) => `<li class="result" data-id="${r.id}">
33
+ <a class="title" href="/item/${r.id}">${r.title}</a>
34
+ </li>`)
35
+ .join('');
36
+ sendHtml(res, page('Search', `<ul id="results">${rows}</ul>`));
37
+ });
38
+ }
@@ -0,0 +1,72 @@
1
+ import { startFixture, sendHtml } from './harness.js';
2
+ import { isBrowser } from './refusing.js';
3
+ import { page as html } from './ssr.js';
4
+ import { search } from './data.js';
5
+ const rows = (records) => records.map((r) => `<li class="result" data-id="${r.id}">
6
+ <a class="title" href="/item/${r.id}">${r.title}</a>
7
+ </li>`).join('');
8
+ const pageOf = (url) => Number(url.searchParams.get('page') ?? '1');
9
+ const queryOf = (url) => url.searchParams.get('q') ?? '';
10
+ /**
11
+ * Serves page one whatever page is asked for.
12
+ *
13
+ * With `browserPaginates`, only the engine's HTTP client is pinned and a
14
+ * browser still moves between pages — a recipe whose page control was never
15
+ * wired up, which is the one thing a verifier can prove from outside. Without
16
+ * it, nobody moves, and from outside that is indistinguishable from a site with
17
+ * a single page: the verdict there has to be that we do not know.
18
+ */
19
+ export function startPageIgnoringFixture(opts) {
20
+ return startFixture('sitePageIgnoring', (req, res) => {
21
+ const url = new URL(req.url ?? '/', 'http://localhost');
22
+ if (url.pathname !== '/search') {
23
+ sendHtml(res, html('Home', '<a href="/search?q=rust">search</a>'));
24
+ return;
25
+ }
26
+ const asked = opts.browserPaginates && isBrowser(req) ? pageOf(url) : 1;
27
+ sendHtml(res, html('Search', `<ul id="results">${rows(search(queryOf(url), asked))}</ul>`));
28
+ });
29
+ }
30
+ /**
31
+ * Paginates correctly and repeats one pinned row on every page.
32
+ *
33
+ * Overlap between pages is ordinary — a sponsored insert, a live posting, a
34
+ * ranking that shifted — so a verifier that required the pages to be disjoint
35
+ * would call this broken. It is here to keep that rule out.
36
+ */
37
+ export function startPinnedFixture() {
38
+ return startFixture('sitePinned', (req, res) => {
39
+ const url = new URL(req.url ?? '/', 'http://localhost');
40
+ if (url.pathname !== '/search') {
41
+ sendHtml(res, html('Home', '<a href="/search?q=rust">search</a>'));
42
+ return;
43
+ }
44
+ const query = queryOf(url);
45
+ const pinned = search(query, 1)[0];
46
+ const here = search(query, pageOf(url));
47
+ const shown = pinned === undefined || here.some((r) => r.id === pinned.id) ? here : [pinned, ...here];
48
+ sendHtml(res, html('Search', `<ul id="results">${rows(shown)}</ul>`));
49
+ });
50
+ }
51
+ /**
52
+ * Answers one page and turns away the rest.
53
+ *
54
+ * Keyed on the page asked for, not on how many requests have arrived, so the
55
+ * recording gets its page and the probe for any other is declined however the
56
+ * probes are ordered.
57
+ */
58
+ export function startPageLimitedFixture(status = 503, answers = 1) {
59
+ return startFixture('sitePageLimited', (req, res) => {
60
+ const url = new URL(req.url ?? '/', 'http://localhost');
61
+ if (url.pathname !== '/search') {
62
+ sendHtml(res, html('Home', '<a href="/search?q=rust">search</a>'));
63
+ return;
64
+ }
65
+ if (pageOf(url) !== answers) {
66
+ res.writeHead(status, { 'content-type': 'text/plain' });
67
+ res.end('not now');
68
+ return;
69
+ }
70
+ sendHtml(res, html('Search', `<ul id="results">${rows(search(queryOf(url), answers))}</ul>`));
71
+ });
72
+ }
@@ -0,0 +1,57 @@
1
+ import { startFixture, sendHtml } from './harness.js';
2
+ import { page, ssrHandler } from './ssr.js';
3
+ /**
4
+ * Tells the engine's HTTP client from the engine's browser.
5
+ *
6
+ * Not the user agent: `src/browser/session.ts` gives the headless browser the
7
+ * same `webrecipe/` string the fetch client sends, so refusing on that
8
+ * would refuse both halves and prove nothing. `sec-ch-ua` is a client hint only
9
+ * a browser emits, which is also what a real challenge wall keys on.
10
+ */
11
+ /** Client hints are sent by a browser and by nothing else; `fetch` omits them. */
12
+ export const isBrowser = (req) => req.headers['sec-ch-ua'] !== undefined;
13
+ /**
14
+ * bandcamp's shape: a 200 whose body is an interstitial rather than the page,
15
+ * padded to the ~3 KB a real one weighs so the size is not what gives it away.
16
+ */
17
+ const CHALLENGE = page('Client Challenge', `<div id="challenge">${'<p>Checking your browser before you continue.</p>'.repeat(64)}</div>`);
18
+ /**
19
+ * The two ways a site refuses a compiled recipe while still serving a browser.
20
+ *
21
+ * At `v1` it is loc.gov: a non-browser client gets a 403 and a browser gets
22
+ * siteA's page. At `v2` it is bandcamp: everyone gets a 200 carrying the
23
+ * challenge page instead of results.
24
+ */
25
+ export function startRefusingFixture() {
26
+ return startFixture('siteRefusing', (req, res, server) => {
27
+ if (server.version === 'v2') {
28
+ sendHtml(res, CHALLENGE);
29
+ return;
30
+ }
31
+ if (!isBrowser(req)) {
32
+ sendHtml(res, '<html><body>Forbidden</body></html>', 403);
33
+ return;
34
+ }
35
+ ssrHandler(req, res, server);
36
+ });
37
+ }
38
+ /**
39
+ * musicbrainz's shape, and the one rule (b) cannot see: a 200 carrying a
40
+ * challenge page, served only to the client without `sec-ch-ua`. Short on
41
+ * purpose — the real one weighs 1.4 KB — because nothing here may key on the
42
+ * body, only on the browser agreeing with the recipe it already had.
43
+ */
44
+ const VERIFYING = '<html><head><title>Verifying your browser</title></head><body>Verifying your browser</body></html>';
45
+ /**
46
+ * Serves the engine's client a 200 challenge page and the browser siteA's real
47
+ * page, so every re-record compiles the very same recipe again.
48
+ */
49
+ export function startStubFixture() {
50
+ return startFixture('siteStub', (req, res, server) => {
51
+ if (!isBrowser(req)) {
52
+ sendHtml(res, VERIFYING);
53
+ return;
54
+ }
55
+ ssrHandler(req, res, server);
56
+ });
57
+ }
@@ -0,0 +1,32 @@
1
+ import { startFixture, sendHtml } from './harness.js';
2
+ import { page } from './ssr.js';
3
+ import { DATASET, search } from './data.js';
4
+ /**
5
+ * Filters on the query honestly and then attributes each hit to the record
6
+ * after it — the shape of a wrong join or an off-by-one in a template.
7
+ *
8
+ * This is the case the query-honored probes cannot catch, and it is here to
9
+ * keep that boundary visible. The parameter is genuinely honoured: the result
10
+ * set moves with the query, a query nothing matches comes back empty, and the
11
+ * browser sees exactly what the recipe sees. Every signal the verifier reads is
12
+ * true and the answer is still about the wrong records, which only something
13
+ * holding an independent account of the right ones can tell.
14
+ */
15
+ export function startShiftedFixture() {
16
+ return startFixture('siteShifted', (req, res) => {
17
+ const url = new URL(req.url ?? '/', 'http://localhost');
18
+ if (url.pathname !== '/search') {
19
+ sendHtml(res, page('Home', '<a href="/search?q=rust">search</a>'));
20
+ return;
21
+ }
22
+ const hits = search(url.searchParams.get('q') ?? '', Number(url.searchParams.get('page') ?? '1'));
23
+ const rows = hits
24
+ .map((hit) => DATASET[(DATASET.indexOf(hit) + 1) % DATASET.length])
25
+ .map((r) => `<li class="result" data-id="${r.id}">
26
+ <a class="title" href="/item/${r.id}">${r.title}</a>
27
+ <span class="author">${r.author}</span>
28
+ </li>`)
29
+ .join('');
30
+ sendHtml(res, page('Search', `<ul id="results">${rows}</ul>`));
31
+ });
32
+ }
@@ -0,0 +1,71 @@
1
+ import { startFixture, sendHtml, sendJson, sendJs } from './harness.js';
2
+ import { search, byId } from './data.js';
3
+ const SHELL = `<!doctype html><html><head><title>Site C</title>
4
+ <script src="/bundle.js" defer></script></head><body><div id="root"></div></body></html>`;
5
+ const BUNDLE = `
6
+ async function render() {
7
+ const root = document.getElementById('root')
8
+ root.innerHTML = ''
9
+ const path = location.pathname
10
+ if (path.startsWith('/item/')) {
11
+ const res = await fetch('/api/detail?id=' + path.slice(6))
12
+ const data = await res.json()
13
+ const el = document.createElement('article')
14
+ el.id = 'detail'
15
+ el.dataset.id = data.result.id
16
+ el.textContent = data.result.title
17
+ root.appendChild(el)
18
+ return
19
+ }
20
+ const params = new URLSearchParams(location.search)
21
+ const q = params.get('q') || ''
22
+ const page = params.get('page') || '1'
23
+ const res = await fetch('/api/query?q=' + encodeURIComponent(q) + '&page=' + encodeURIComponent(page))
24
+ const data = await res.json()
25
+ const rows = data.results || data.items || []
26
+ const ul = document.createElement('ul')
27
+ ul.id = 'results'
28
+ for (const r of rows) {
29
+ const li = document.createElement('li')
30
+ li.className = 'result'
31
+ li.dataset.id = r.id
32
+ li.textContent = r.title || r.name
33
+ ul.appendChild(li)
34
+ }
35
+ root.appendChild(ul)
36
+ }
37
+ window.addEventListener('popstate', render)
38
+ render()
39
+ `;
40
+ export function startSpaFixture() {
41
+ return startFixture('siteC-spa', (req, res, server) => {
42
+ const url = new URL(req.url ?? '/', 'http://localhost');
43
+ if (url.pathname === '/bundle.js') {
44
+ sendJs(res, BUNDLE);
45
+ return;
46
+ }
47
+ if (url.pathname === '/api/query') {
48
+ const q = url.searchParams.get('q') ?? '';
49
+ const page = Number(url.searchParams.get('page') ?? '1');
50
+ const rows = search(q, page);
51
+ if (server.version === 'v2') {
52
+ sendJson(res, { items: rows.map((r) => ({ id: r.id, name: r.title, by: r.author })) });
53
+ }
54
+ else {
55
+ sendJson(res, { results: rows.map((r) => ({ id: r.id, title: r.title, author: r.author })) });
56
+ }
57
+ return;
58
+ }
59
+ if (url.pathname === '/api/detail') {
60
+ const record = byId(url.searchParams.get('id') ?? '');
61
+ if (!record) {
62
+ sendJson(res, { error: 'not found' }, 404);
63
+ return;
64
+ }
65
+ sendJson(res, { result: record });
66
+ return;
67
+ }
68
+ // Client-side routing: every other route gets the same shell.
69
+ sendHtml(res, SHELL);
70
+ });
71
+ }
@@ -0,0 +1,46 @@
1
+ import { startFixture, sendHtml } from './harness.js';
2
+ import { search, byId } from './data.js';
3
+ export function page(title, inner) {
4
+ return `<!doctype html><html><head><title>${title}</title></head><body>${inner}</body></html>`;
5
+ }
6
+ function renderResults(records, version) {
7
+ const cls = version === 'v1' ? 'result' : 'hit';
8
+ const titleCls = version === 'v1' ? 'title' : 'name';
9
+ const rows = records
10
+ .map((r) => `<li class="${cls}" data-id="${r.id}">
11
+ <a class="${titleCls}" href="/item/${r.id}">${r.title}</a>
12
+ <span class="author">${r.author}</span>
13
+ </li>`)
14
+ .join('');
15
+ return `<ul id="results">${rows}</ul>`;
16
+ }
17
+ /**
18
+ * Exported so a fixture that only changes who gets served — `refusing.ts` —
19
+ * wraps this page rather than growing a second copy of it that can drift.
20
+ */
21
+ export const ssrHandler = (req, res, server) => {
22
+ const url = new URL(req.url ?? '/', 'http://localhost');
23
+ if (url.pathname === '/search') {
24
+ const q = url.searchParams.get('q') ?? '';
25
+ const p = Number(url.searchParams.get('page') ?? '1');
26
+ sendHtml(res, page('Search', renderResults(search(q, p), server.version)));
27
+ return;
28
+ }
29
+ if (url.pathname.startsWith('/item/')) {
30
+ const record = byId(url.pathname.slice('/item/'.length));
31
+ if (!record) {
32
+ sendHtml(res, page('Not found', '<p>not found</p>'), 404);
33
+ return;
34
+ }
35
+ sendHtml(res, page(record.title, `<article id="detail" data-id="${record.id}">
36
+ <h1 class="title">${record.title}</h1>
37
+ <span class="author">${record.author}</span>
38
+ <p class="body">${record.body}</p>
39
+ </article>`));
40
+ return;
41
+ }
42
+ sendHtml(res, page('Home', '<a href="/search?q=rust">search</a>'));
43
+ };
44
+ export function startSsrFixture() {
45
+ return startFixture('siteA-ssr', ssrHandler);
46
+ }
@@ -0,0 +1,40 @@
1
+ import { startFixture, sendHtml } from './harness.js';
2
+ import { page } from './ssr.js';
3
+ import { search } from './data.js';
4
+ /**
5
+ * Filters honestly and alternates one row between consecutive calls for the
6
+ * same query, the way a listing that mixes in a fresh or sponsored item does.
7
+ *
8
+ * The alternation is counted per query, so asking twice for the same thing
9
+ * always disagrees no matter what else has been asked, in what order, or how
10
+ * many times. A fixture that instead counted requests would be describing the
11
+ * caller's probe order rather than the site's behaviour, and would start
12
+ * failing the moment that order changed.
13
+ *
14
+ * A site like this cannot be judged by comparing result sets: two identical
15
+ * requests already disagree, so a difference between two different queries
16
+ * proves nothing either.
17
+ */
18
+ export function startVolatileFixture() {
19
+ const turn = new Map();
20
+ return startFixture('siteVolatile', (req, res) => {
21
+ const url = new URL(req.url ?? '/', 'http://localhost');
22
+ if (url.pathname !== '/search') {
23
+ sendHtml(res, page('Home', '<a href="/search?q=rust">search</a>'));
24
+ return;
25
+ }
26
+ const q = url.searchParams.get('q') ?? '';
27
+ const nth = (turn.get(q) ?? 0) + 1;
28
+ turn.set(q, nth);
29
+ const rows = search(q, Number(url.searchParams.get('page') ?? '1'))
30
+ .map((r, i) => i === 0
31
+ ? `<li class="result" data-id="live-${nth}">
32
+ <a class="title" href="/item/live-${nth}">Just posted ${nth}</a>
33
+ </li>`
34
+ : `<li class="result" data-id="${r.id}">
35
+ <a class="title" href="/item/${r.id}">${r.title}</a>
36
+ </li>`)
37
+ .join('');
38
+ sendHtml(res, page('Search', `<ul id="results">${rows}</ul>`));
39
+ });
40
+ }
@@ -0,0 +1,120 @@
1
+ import { startFixture, sendHtml, sendJson, sendJs } from './harness.js';
2
+ import { search, byId, DATASET } from './data.js';
3
+ /**
4
+ * The browser is made to issue these in order on a search:
5
+ * /analytics/collect, /api/feature-flags, /api/autocomplete,
6
+ * /api/search <- the only one that matters,
7
+ * /api/recommendations, /tracking/event
8
+ */
9
+ const BOOT_SCRIPT = `
10
+ const params = new URLSearchParams(location.search)
11
+ const q = params.get('q') || ''
12
+ const page = params.get('page') || '1'
13
+ const dataPath = window.__DATA_PATH__
14
+ async function boot() {
15
+ await fetch('/analytics/collect')
16
+ await fetch('/api/feature-flags')
17
+ await fetch('/api/autocomplete?prefix=' + encodeURIComponent(q.slice(0, 2)))
18
+ const res = await fetch(dataPath + '?q=' + encodeURIComponent(q) + '&page=' + encodeURIComponent(page))
19
+ const data = await res.json()
20
+ const ul = document.createElement('ul')
21
+ ul.id = 'results'
22
+ for (const r of data.results) {
23
+ const li = document.createElement('li')
24
+ li.className = 'result'
25
+ li.dataset.id = r.id
26
+ // The title is the link, as in any real listing: the href is composed
27
+ // client-side from the id, so the API never carries it.
28
+ const a = document.createElement('a')
29
+ a.className = 'permalink'
30
+ a.href = '/item/' + r.id
31
+ a.textContent = r.title
32
+ li.appendChild(a)
33
+ ul.appendChild(li)
34
+ }
35
+ document.getElementById('app').appendChild(ul)
36
+ await fetch('/api/recommendations', { method: 'POST', body: '{}' })
37
+ await fetch('/tracking/event', { method: 'POST', body: '{}' })
38
+ }
39
+ boot()
40
+ `;
41
+ const DETAIL_SCRIPT = `
42
+ const id = location.pathname.slice('/item/'.length)
43
+ fetch('/api/item?id=' + encodeURIComponent(id))
44
+ .then((r) => r.json())
45
+ .then((data) => {
46
+ if (!data.result) return
47
+ const el = document.createElement('article')
48
+ el.id = 'detail'
49
+ el.dataset.id = data.result.id
50
+ el.innerHTML = '<h1 class="title"></h1><span class="author"></span>'
51
+ el.querySelector('.title').textContent = data.result.title
52
+ el.querySelector('.author').textContent = data.result.author
53
+ document.getElementById('app').appendChild(el)
54
+ })
55
+ `;
56
+ export function startXhrFixture() {
57
+ return startFixture('siteB-xhr', (req, res, server) => {
58
+ const url = new URL(req.url ?? '/', 'http://localhost');
59
+ const v2 = server.version === 'v2';
60
+ const dataPath = v2 ? '/api/v2/search' : '/api/search';
61
+ switch (true) {
62
+ case url.pathname === '/search':
63
+ sendHtml(res, `<!doctype html><html><head><title>Search</title>
64
+ <script>window.__DATA_PATH__=${JSON.stringify(dataPath)}</script>
65
+ <script src="/app.js" defer></script></head>
66
+ <body><header>Site B</header><div id="app"></div></body></html>`);
67
+ return;
68
+ case url.pathname === '/app.js':
69
+ sendJs(res, BOOT_SCRIPT);
70
+ return;
71
+ case url.pathname === '/detail.js':
72
+ sendJs(res, DETAIL_SCRIPT);
73
+ return;
74
+ // Detail is a shell too: an XHR site renders it from /api/item, not from HTML.
75
+ case url.pathname.startsWith('/item/'):
76
+ sendHtml(res, `<!doctype html><html><head><title>Item</title>
77
+ <script src="/detail.js" defer></script></head>
78
+ <body><header>Site B</header><div id="app"></div></body></html>`);
79
+ return;
80
+ case url.pathname === dataPath: {
81
+ const q = url.searchParams.get('q') ?? '';
82
+ const page = Number(url.searchParams.get('page') ?? '1');
83
+ sendJson(res, {
84
+ query: q,
85
+ page,
86
+ total: DATASET.length,
87
+ // No url: a real API often returns the id and lets the page build the link.
88
+ results: search(q, page).map((r) => ({ id: r.id, title: r.title, author: r.author })),
89
+ });
90
+ return;
91
+ }
92
+ case url.pathname === '/api/item': {
93
+ const record = byId(url.searchParams.get('id') ?? '');
94
+ if (!record) {
95
+ sendJson(res, { error: 'not found' }, 404);
96
+ return;
97
+ }
98
+ sendJson(res, { result: record });
99
+ return;
100
+ }
101
+ case url.pathname === '/api/feature-flags':
102
+ sendJson(res, { flags: { newSearch: true, darkMode: false } });
103
+ return;
104
+ case url.pathname === '/api/autocomplete': {
105
+ const prefix = (url.searchParams.get('prefix') ?? '').toLowerCase();
106
+ sendJson(res, { suggestions: ['rust', 'ruby', 'react'].filter((s) => s.startsWith(prefix)).slice(0, 3) });
107
+ return;
108
+ }
109
+ case url.pathname === '/api/recommendations':
110
+ sendJson(res, { recommendations: DATASET.slice(0, 3).map((r) => r.id) });
111
+ return;
112
+ case url.pathname === '/analytics/collect':
113
+ case url.pathname === '/tracking/event':
114
+ res.writeHead(204).end();
115
+ return;
116
+ default:
117
+ sendJson(res, { error: 'not found' }, 404);
118
+ }
119
+ });
120
+ }
@@ -0,0 +1,16 @@
1
+ const ASSET_TYPES = new Set(['image', 'font', 'stylesheet', 'script', 'media', 'texttrack', 'manifest']);
2
+ const ANALYTICS = /analytics|googletagmanager|google-analytics|doubleclick|segment\.io|mixpanel|amplitude|hotjar|adservice|\/ads?\//i;
3
+ const TRACKING = /tracking|telemetry|beacon|\/collect\b|pixel|\/event\b/i;
4
+ export function classify(req) {
5
+ if (ANALYTICS.test(req.url))
6
+ return 'analytics';
7
+ if (TRACKING.test(req.url))
8
+ return 'tracking';
9
+ if (ASSET_TYPES.has(req.resourceType))
10
+ return 'asset';
11
+ if (req.resourceType === 'document')
12
+ return 'navigation';
13
+ if (req.resourceType === 'xhr' || req.resourceType === 'fetch')
14
+ return 'data_candidate';
15
+ return 'unknown';
16
+ }
@@ -0,0 +1,52 @@
1
+ import { classify } from './classify.js';
2
+ const LARGE_PAYLOAD_BYTES = 1024;
3
+ function isJson(req) {
4
+ return req.contentType !== null && /json/.test(req.contentType);
5
+ }
6
+ function containsInput(req, input) {
7
+ const values = Object.values(input).map((v) => String(v)).filter((v) => v.length >= 2);
8
+ if (values.length === 0)
9
+ return false;
10
+ const haystack = decodeURIComponent(req.url) + (req.postData ?? '');
11
+ return values.some((v) => haystack.toLowerCase().includes(v.toLowerCase()));
12
+ }
13
+ /**
14
+ * The scoring table is the whole model. It is deliberately small and legible so
15
+ * that a wrong pick can be explained by reading the signals, not by guessing.
16
+ */
17
+ export function scoreRequests(trace) {
18
+ const scored = [];
19
+ for (const request of trace.requests) {
20
+ if (classify(request) !== 'data_candidate')
21
+ continue;
22
+ if (request.status !== 200)
23
+ continue;
24
+ const signals = [];
25
+ let score = 0;
26
+ if (isJson(request)) {
27
+ score += 3;
28
+ signals.push('json-response');
29
+ }
30
+ if (containsInput(request, trace.input)) {
31
+ score += 3;
32
+ signals.push('contains-input');
33
+ }
34
+ if (request.afterAction !== null) {
35
+ score += 2;
36
+ signals.push('after-action');
37
+ }
38
+ if (request.domChanged) {
39
+ score += 2;
40
+ signals.push('dom-changed');
41
+ }
42
+ if (request.bodySize > LARGE_PAYLOAD_BYTES) {
43
+ score += 1;
44
+ signals.push('large-payload');
45
+ }
46
+ scored.push({ request, score, signals });
47
+ }
48
+ return scored.sort((a, b) => b.score - a.score);
49
+ }
50
+ export function pickDataRequest(trace) {
51
+ return scoreRequests(trace)[0] ?? null;
52
+ }