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.
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/dist/benchmark/amortization.js +254 -0
- package/dist/benchmark/fixtures.js +26 -0
- package/dist/benchmark/oracles.js +129 -0
- package/dist/benchmark/plans.js +436 -0
- package/dist/fixtures/cloaking.js +37 -0
- package/dist/fixtures/coalesce.js +52 -0
- package/dist/fixtures/data.js +23 -0
- package/dist/fixtures/harness.js +34 -0
- package/dist/fixtures/ignoring.js +27 -0
- package/dist/fixtures/limiting.js +38 -0
- package/dist/fixtures/paging.js +72 -0
- package/dist/fixtures/refusing.js +57 -0
- package/dist/fixtures/shifted.js +32 -0
- package/dist/fixtures/spa.js +71 -0
- package/dist/fixtures/ssr.js +46 -0
- package/dist/fixtures/volatile.js +40 -0
- package/dist/fixtures/xhr.js +120 -0
- package/dist/src/analyzer/classify.js +16 -0
- package/dist/src/analyzer/score.js +52 -0
- package/dist/src/authoring/candidates.js +168 -0
- package/dist/src/authoring/contract.js +31 -0
- package/dist/src/authoring/fields.js +86 -0
- package/dist/src/authoring/learn.js +51 -0
- package/dist/src/authoring/plans.js +93 -0
- package/dist/src/authoring/snapshot.js +22 -0
- package/dist/src/authoring/teach.js +136 -0
- package/dist/src/benchmark/discovery.js +355 -0
- package/dist/src/benchmark/golden.js +95 -0
- package/dist/src/benchmark/grade.js +146 -0
- package/dist/src/benchmark/ground-truth.js +35 -0
- package/dist/src/benchmark/health.js +96 -0
- package/dist/src/benchmark/labels.js +49 -0
- package/dist/src/benchmark/oracle.js +55 -0
- package/dist/src/benchmark/report.js +191 -0
- package/dist/src/benchmark/runner.js +201 -0
- package/dist/src/benchmark/screen.js +144 -0
- package/dist/src/benchmark/selector-score.js +86 -0
- package/dist/src/benchmark/verification-cases.js +138 -0
- package/dist/src/benchmark/verification-matrix.js +97 -0
- package/dist/src/browser/navigate.js +22 -0
- package/dist/src/browser/pool.js +31 -0
- package/dist/src/browser/session.js +44 -0
- package/dist/src/cli.js +559 -0
- package/dist/src/compiler/derive.js +144 -0
- package/dist/src/compiler/heuristic.js +398 -0
- package/dist/src/compiler/html.js +117 -0
- package/dist/src/compiler/types.js +12 -0
- package/dist/src/compiler/verify.js +29 -0
- package/dist/src/executor/extract.js +179 -0
- package/dist/src/executor/format.js +55 -0
- package/dist/src/executor/index.js +147 -0
- package/dist/src/executor/strategies/browser.js +60 -0
- package/dist/src/executor/strategies/http-html.js +42 -0
- package/dist/src/executor/strategies/http-json.js +71 -0
- package/dist/src/executor/strategies/warm-browser.js +57 -0
- package/dist/src/executor/tokens.js +11 -0
- package/dist/src/healing/index.js +111 -0
- package/dist/src/local.js +157 -0
- package/dist/src/mcp.js +130 -0
- package/dist/src/measurement.js +44 -0
- package/dist/src/net/politeness.js +141 -0
- package/dist/src/net/robots.js +56 -0
- package/dist/src/read.js +83 -0
- package/dist/src/recipes/fingerprint.js +41 -0
- package/dist/src/recipes/paths.js +14 -0
- package/dist/src/recipes/registry.js +81 -0
- package/dist/src/recipes/schema.js +38 -0
- package/dist/src/recipes/template.js +33 -0
- package/dist/src/recorder/body.js +59 -0
- package/dist/src/recorder/index.js +151 -0
- package/dist/src/recorder/types.js +1 -0
- package/dist/src/sites.js +45 -0
- package/dist/src/tasks.js +37 -0
- package/dist/src/types.js +32 -0
- package/dist/src/usage.js +69 -0
- package/dist/src/validator/index.js +28 -0
- package/dist/src/verification/lexical-consistency.js +88 -0
- package/dist/src/verification/pagination-honored.js +110 -0
- package/dist/src/verification/probes.js +98 -0
- package/dist/src/verification/query-honored.js +134 -0
- package/dist/src/wiring.js +33 -0
- 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
|
+
}
|