wiki-formant 0.27.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -31
- package/bin/analytics-digest.mjs +57 -0
- package/dist/analytics.d.ts +166 -26
- package/dist/analytics.js +267 -47
- package/dist/blocks.d.ts +139 -0
- package/dist/blocks.js +68 -0
- package/dist/db.d.ts +21 -0
- package/dist/db.js +27 -0
- package/dist/dom.d.ts +15 -0
- package/dist/dom.js +57 -0
- package/dist/feed.d.ts +0 -1
- package/dist/feed.js +1 -1
- package/dist/freshness.d.ts +6 -1
- package/dist/freshness.js +7 -3
- package/dist/http.d.ts +10 -0
- package/dist/http.js +13 -0
- package/dist/link-check.d.ts +0 -24
- package/dist/link-check.js +3 -3
- package/dist/mcp.d.ts +6 -5
- package/dist/mcp.js +4 -3
- package/dist/rate-limit.d.ts +28 -2
- package/dist/rate-limit.js +55 -3
- package/dist/react.d.ts +7 -0
- package/dist/react.js +9 -1
- package/dist/validation.d.ts +29 -0
- package/dist/validation.js +51 -0
- package/package.json +12 -25
- package/dist/index.d.ts +0 -22
- package/dist/index.js +0 -27
- package/dist/rola.d.ts +0 -127
- package/dist/rola.js +0 -150
- package/dist/x402.d.ts +0 -95
- package/dist/x402.js +0 -212
package/README.md
CHANGED
|
@@ -148,7 +148,7 @@ handler: async (args, ctx) => {
|
|
|
148
148
|
}
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
`config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope.
|
|
151
|
+
`config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope. `RpcRequest` and `toolText` are exported for a gate to build with; acuiq2's x402 gate is the one that does.
|
|
152
152
|
|
|
153
153
|
### Tool arguments and the corpus tool
|
|
154
154
|
|
|
@@ -213,6 +213,13 @@ export async function GET(request: Request) {
|
|
|
213
213
|
}
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
+
`corpusValidatorsFrom(seed, stamps)` is that pair from an aggregate: the newest of `stamps` (the epoch when every one is null), appended to `seed` for the tag. `corpusRoute(validators, build)` is the whole handler, and skips the build on a 304.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const agg = await prisma.page.aggregate({ _count: true, _max: { updatedAt: true } });
|
|
220
|
+
corpusValidatorsFrom([depth, agg._count], [agg._max.updatedAt]);
|
|
221
|
+
```
|
|
222
|
+
|
|
216
223
|
## Pagination and versioning
|
|
217
224
|
|
|
218
225
|
`parsePagination` clamps `page ≥ 1` and `pageSize` to 1–100; `paginatedResponse` always carries `totalPages`. Reshaping that response is a breaking change to every client that pages, which is why it lives here rather than being re-typed per repo.
|
|
@@ -311,14 +318,6 @@ import Link from 'next/link';
|
|
|
311
318
|
|
|
312
319
|
There is no `next` peer dependency here and there is not going to be one, but a rail that falls back to a bare `<a>` turns every press into a full page load — which is the whole reason a wiki has a persistent rail. So the router arrives through the door.
|
|
313
320
|
|
|
314
|
-
## Payment gating
|
|
315
|
-
|
|
316
|
-
`wiki-formant/x402` gates an MCP envelope on payment, for a wiki that charges for a bulk export over HTTP and would otherwise hand the same bytes over free through the equivalent MCP tool. Payment travels in-band — `params._meta["x402/payment"]` in, `result._meta["x402/payment-response"]` out — because one HTTP 402 cannot answer a batch of twenty in which one call is priced and nineteen are not.
|
|
317
|
-
|
|
318
|
-
`gatePaidCalls` returns what `handleMcp` should dispatch and a `finish` that puts the answer back together. The ordering is the part worth owning once: verify before dispatch, settle only after a non-error answer, splice the challenges back into the caller's original order. A tool that raises cancels rather than settles — the caller pays for an answer, not an attempt.
|
|
319
|
-
|
|
320
|
-
It imports nothing. `@x402/core`, `@x402/evm`, `@x402/extensions`, `@x402/next` and `viem` are optional peers, and the resource server arrives as a structural port instead of an import, so a wiki that never imports this subpath never resolves any of them. Which surfaces cost what, the prices and the terms text stay with the caller: those are policy, and this is the envelope surgery.
|
|
321
|
-
|
|
322
321
|
## AI crawlers
|
|
323
322
|
|
|
324
323
|
Every wiki kept this roster twice — once in the proxy, keyed by user-agent, to
|
|
@@ -490,6 +489,27 @@ leafBlocks(blocks, BLOCK_SHAPE.containers);
|
|
|
490
489
|
|
|
491
490
|
The dispatch over a repo's own block union stays in that repo; the bodies come from here. `wiki-formant/text` has a prose body for every core leaf — `statsToText`, `linkGridToText` and `pageListToText` joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
|
|
492
491
|
|
|
492
|
+
The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `recentPages`, `pageList` — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
import { coreAtomicText, coreAtomicToMarkdown, coreBlockDefaults } from 'wiki-formant/blocks';
|
|
496
|
+
import { coreAtomicValidator } from 'wiki-formant/validation';
|
|
497
|
+
|
|
498
|
+
switch (block.type) {
|
|
499
|
+
case 'content': case 'codeTabs': case 'banner': case 'references':
|
|
500
|
+
case 'stats': case 'linkGrid': case 'recentPages': case 'pageList':
|
|
501
|
+
return coreAtomicToMarkdown(block, { siteUrl: SITE_URL, referencesTitle: 'Sources' });
|
|
502
|
+
case 'corpusStats':
|
|
503
|
+
return block.resolvedHtml ? htmlToMarkdown(block.resolvedHtml) : '';
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const BLOCK_DEFAULTS = { ...coreBlockDefaults({ stats }), daoTimeline: () => ({ type: 'daoTimeline', limit: 25 }) };
|
|
507
|
+
const core = coreAtomicValidator({ pageId: id => /^\d{1,9}$/.test(id) });
|
|
508
|
+
// validateAtomic: b => { switch (b.type) { case 'daoTimeline': …; default: return core(b); } }
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
`coreAtomicToMarkdown` joins a resolved page's site-relative `href` to `siteUrl`, because a twin is read off-site; a wiki whose lists hold its own page rows keeps those two cases. `coreAtomicValidator` caps `recentPages.limit` and `pageIds` at `MAX_BLOCK_ROWS`, a whole number of rows, because both end in a database `take:`; `blockLimit` is the same bound on the read side.
|
|
512
|
+
|
|
493
513
|
`createBlockValidator` returns `blockIssues` beside the boolean validators: the same walk, reporting where each failure is.
|
|
494
514
|
|
|
495
515
|
```ts
|
|
@@ -504,7 +524,7 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
|
|
|
504
524
|
|
|
505
525
|
## Dates
|
|
506
526
|
|
|
507
|
-
`relativeTime(then, now, { style })` is `compact` (`3h`), `short` (`3h ago`) or `long` (`3 days ago`, day-grained for pages cached for hours), with `absoluteAfterDays` to hand over to the date itself. `now` is always passed in, never read, so a server render and its hydration agree. `formatDay` is always UTC: a stored day formatted in the server's zone, or the browser's, is the previous day for half the world. Both are in `wiki-formant/freshness`, with `isoDate`.
|
|
527
|
+
`relativeTime(then, now, { style })` is `compact` (`3h`), `short` (`3h ago`) or `long` (`3 days ago`, day-grained for pages cached for hours), with `absoluteAfterDays` to hand over to the date itself. `now` is always passed in, never read, so a server render and its hydration agree. `formatDay` is always UTC: a stored day formatted in the server's zone, or the browser's, is the previous day for half the world. It reads `en-US` unless given a `locale`: `formatDay(day, { locale: 'en-GB', month: 'long' })` is `19 September 2026`. Both are in `wiki-formant/freshness`, with `isoDate`.
|
|
508
528
|
|
|
509
529
|
## Sanitising stored HTML
|
|
510
530
|
|
|
@@ -551,27 +571,66 @@ export const GET = resolveMapHandler({ authorize: async () => !!(await currentUs
|
|
|
551
571
|
|
|
552
572
|
## Analytics
|
|
553
573
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
574
|
+
Page views and events, counted in the site's own Postgres instead of a hosted
|
|
575
|
+
service. No cookie is set and no address is kept: a visitor is a hash of the
|
|
576
|
+
address and browser under a salt that is replaced every UTC day, and the old
|
|
577
|
+
salt is deleted, so a visitor is told apart within a day and never followed
|
|
578
|
+
into the next. Rows are kept 400 days. The package owns the SQL and the app
|
|
579
|
+
owns the connection: `Sql` is one function that runs a statement with `$1, $2…`
|
|
580
|
+
placeholders, and the three tables it needs are stated as Prisma models at the
|
|
581
|
+
top of `src/analytics.ts`.
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
// src/lib/track.ts
|
|
585
|
+
const sql: Sql = (query, ...values) => prisma.$queryRawUnsafe(query, ...values);
|
|
586
|
+
export const { trackEvent, trackMcpCall, trackSearch, viewRoute } =
|
|
587
|
+
createTracker({ sql, defer: after, siteUrl: SITE_URL });
|
|
588
|
+
|
|
589
|
+
// src/app/api/view/route.ts – the whole beacon route
|
|
590
|
+
export const POST = viewRoute();
|
|
591
|
+
|
|
592
|
+
// the root layout, once
|
|
593
|
+
<Beacon />
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
`createTracker` records only on a Vercel production deployment unless told
|
|
597
|
+
otherwise (`enabled`), because dev and previews write to production's
|
|
598
|
+
database. `trackMcpCall(request, body, server?)` fits `McpServerConfig.onCall`;
|
|
599
|
+
`trackSearch(headers, query, results, surface?)` takes the headers or Next's
|
|
600
|
+
`headers` itself, which it reads inside the deferred write. `viewRoute({ site })`
|
|
601
|
+
passes a site resolver to `collect`. The module that builds the tracker
|
|
602
|
+
imports the app's database client, so a proxy reaches it through a dynamic
|
|
603
|
+
`import()` inside `event.waitUntil`, never a static one.
|
|
604
|
+
|
|
605
|
+
`<Beacon>` (`wiki-formant/react`) sends a view each time the path changes,
|
|
606
|
+
client-side moves included, and a click on a link to another host as an
|
|
607
|
+
"Outbound Link: Click" event; `track(name, props)` (`wiki-formant/dom`) sends
|
|
608
|
+
any other browser event. Underneath the tracker, server code calls
|
|
609
|
+
`recordEvent` and leaves the deferral to the framework, so this package keeps
|
|
610
|
+
its zero-dependency guarantee:
|
|
560
611
|
|
|
561
612
|
```ts
|
|
562
613
|
const props = searchQueryProps({ query, results: rows.length, surface: 'wiki' });
|
|
563
614
|
// null for an empty field, so a blank search cannot fire an event
|
|
564
|
-
if (props) after(() =>
|
|
565
|
-
// a person triggered this, so it joins their session rather than landing
|
|
566
|
-
// as the fixed bot-tracker pseudo-visitor
|
|
567
|
-
userAgent: headers.get('user-agent') ?? undefined,
|
|
568
|
-
}));
|
|
615
|
+
if (props) after(() => recordEvent(sql, { name: 'Search Query', props, url, headers }));
|
|
569
616
|
```
|
|
570
617
|
|
|
618
|
+
`mcpCallProps` reads a tool name out of a JSON-RPC envelope that is untrusted
|
|
619
|
+
and may be a batch; `searchQueryProps` normalises a search box's free text so
|
|
620
|
+
the same question aggregates as one row. Pass the request's headers and the
|
|
621
|
+
event joins whoever sent it, person or agent.
|
|
622
|
+
|
|
623
|
+
`digest(sql, { days })` reads a site back as one JSON object: visitors, page
|
|
624
|
+
views, bounce rate, visit length, sources (a link's `utm_source`, else the
|
|
625
|
+
referring host), top and entry pages, countries, devices, visitors by day, MCP
|
|
626
|
+
tool calls, and the searches that found nothing. A visit is a run of one
|
|
627
|
+
visitor's views with no gap over 30 minutes. A multi-tenant app passes a `site`
|
|
628
|
+
key to every call and a `site` resolver to `collect`.
|
|
629
|
+
|
|
571
630
|
Instrumenting the agent lane and not the human one is the easy mistake: it
|
|
572
631
|
leaves a wiki able to say what every crawler asked for and nothing about what
|
|
573
|
-
its readers asked for. The queries that return zero rows are the valuable ones
|
|
574
|
-
they name a gap in the corpus in the reader's own words.
|
|
632
|
+
its readers asked for. The queries that return zero rows are the valuable ones
|
|
633
|
+
– they name a gap in the corpus in the reader's own words.
|
|
575
634
|
|
|
576
635
|
## Base stylesheet
|
|
577
636
|
|
|
@@ -585,15 +644,23 @@ No view emits a Tailwind utility any more — `ColumnsView` states its gap and a
|
|
|
585
644
|
|
|
586
645
|
## API
|
|
587
646
|
|
|
588
|
-
Every module has a subpath — `wiki-formant/taxonomy`, `wiki-formant/mcp`, and so on
|
|
589
|
-
|
|
590
|
-
|
|
647
|
+
Every module has a subpath — `wiki-formant/taxonomy`, `wiki-formant/mcp`, and so on —
|
|
648
|
+
and there is no root export: `import … from 'wiki-formant'` does not resolve. A
|
|
649
|
+
barrel loads every module it names for the one symbol a route wanted, and had
|
|
650
|
+
already re-exported one module twice. The emitted `.d.ts` files are the reference.
|
|
651
|
+
There is no hand-maintained symbol list here, because the one that used to be here
|
|
652
|
+
drifted from them.
|
|
591
653
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
654
|
+
`wiki-formant/db` holds `pgPoolConfig(url)`, the options every app here hands to
|
|
655
|
+
`new pg.Pool()`: ten clients on Supabase's 6543 transaction pooler, three on the
|
|
656
|
+
5432 session pooler, whose fifteen-client cap a build's workers share.
|
|
657
|
+
|
|
658
|
+
`rateLimit` takes `blockMs` (and a `blockKey`, default the bucket's own) for a
|
|
659
|
+
caller already shown to be abusive: running dry blocks the key outright for that
|
|
660
|
+
long, across every bucket that names it, rather than letting the refill trickle
|
|
661
|
+
one token through at a time. `clientIp` reads `x-real-ip` first — Vercel sets it
|
|
662
|
+
and overwrites any inbound copy — and the leftmost `x-forwarded-for` only where no
|
|
663
|
+
`x-real-ip` was set, since a proxy that appends hands the client that slot.
|
|
597
664
|
|
|
598
665
|
## A bin
|
|
599
666
|
|
|
@@ -605,6 +672,14 @@ npx check-classes --warn # report and exit 0
|
|
|
605
672
|
npx check-classes --compositions # also fail on the inline compositions
|
|
606
673
|
```
|
|
607
674
|
|
|
675
|
+
`analytics-digest` prints a site's `digest` as JSON for a script outside the app. Run it from the app's root, where `pg` is installed and `.env` holds `DATABASE_URL`:
|
|
676
|
+
|
|
677
|
+
```sh
|
|
678
|
+
npx analytics-digest --days 7
|
|
679
|
+
npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq.com
|
|
680
|
+
npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
|
|
681
|
+
```
|
|
682
|
+
|
|
608
683
|
## Licence
|
|
609
684
|
|
|
610
685
|
MIT.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/analytics-digest.mjs – print one site's `digest` (`wiki-formant/analytics`)
|
|
3
|
+
// as JSON, for a script that reads traffic from outside the app.
|
|
4
|
+
//
|
|
5
|
+
// Run from the app's root, where `pg` is installed and `.env` holds
|
|
6
|
+
// DATABASE_URL (read from the environment first). The SQL is the package's, so
|
|
7
|
+
// a report reads the figures the app's own pages read.
|
|
8
|
+
//
|
|
9
|
+
// npx analytics-digest --days 7
|
|
10
|
+
// npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq.com
|
|
11
|
+
// npx analytics-digest --days 7 --site <id> # a multi-tenant app's site
|
|
12
|
+
// npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds to the search gaps
|
|
13
|
+
import pg from 'pg';
|
|
14
|
+
import { digest, SEARCH_GAPS } from '../dist/analytics.js';
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const arg = name => {
|
|
18
|
+
const i = args.indexOf(name);
|
|
19
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
20
|
+
};
|
|
21
|
+
const all = name => args.flatMap((a, i) => (a === name && args[i + 1] ? [args[i + 1]] : []));
|
|
22
|
+
|
|
23
|
+
if (!process.env.DATABASE_URL) {
|
|
24
|
+
try {
|
|
25
|
+
process.loadEnvFile('.env');
|
|
26
|
+
} catch {
|
|
27
|
+
// reported below
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!process.env.DATABASE_URL) {
|
|
31
|
+
console.log(JSON.stringify({ error: 'no DATABASE_URL in the environment or ./.env' }));
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
|
36
|
+
try {
|
|
37
|
+
await client.connect();
|
|
38
|
+
const sql = (query, ...values) => client.query(query, values).then(r => r.rows);
|
|
39
|
+
const result = await digest(sql, {
|
|
40
|
+
site: arg('--site') ?? '',
|
|
41
|
+
days: Number(arg('--days') ?? 7),
|
|
42
|
+
handle: arg('--handle'),
|
|
43
|
+
siblings: arg('--siblings')?.split(',').filter(Boolean) ?? [],
|
|
44
|
+
gaps: [
|
|
45
|
+
SEARCH_GAPS,
|
|
46
|
+
...all('--gap').map(g => {
|
|
47
|
+
const [event, prop] = g.split(':');
|
|
48
|
+
return { event, prop };
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
console.log(JSON.stringify(result));
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.log(JSON.stringify({ error: e.message }));
|
|
55
|
+
} finally {
|
|
56
|
+
await client.end().catch(() => {});
|
|
57
|
+
}
|
package/dist/analytics.d.ts
CHANGED
|
@@ -1,36 +1,125 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { type HeaderReader, type RateLimitOptions } from './rate-limit.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runs one statement with `$1, $2…` placeholders and resolves to its rows.
|
|
4
|
+
* Prisma: `(query, ...values) => prisma.$queryRawUnsafe(query, ...values)`.
|
|
5
|
+
*/
|
|
6
|
+
export type Sql = (query: string, ...values: unknown[]) => Promise<unknown>;
|
|
7
|
+
/** iPadOS reports itself as a Mac, so its views count as a computer's. */
|
|
8
|
+
export declare const deviceOf: (ua: string) => "phone" | "tablet" | "computer";
|
|
9
|
+
/**
|
|
10
|
+
* Where a visit came from: its `utm_source` when the link carried one, else the
|
|
11
|
+
* referring host, and null for a referrer that is this site or none.
|
|
12
|
+
*/
|
|
13
|
+
export declare function sourceOf(referrer: unknown, utm: unknown, host: string): string | null;
|
|
14
|
+
/** A browser path as stored: decoded, one leading slash, no trailing one. Null when it will not decode. */
|
|
15
|
+
export declare function normalisePath(path: string): string | null;
|
|
16
|
+
export interface ViewInput {
|
|
17
|
+
/** The beacon request's headers: the address, browser, host and country come from them. */
|
|
18
|
+
headers: HeaderReader;
|
|
19
|
+
path: string;
|
|
20
|
+
referrer?: unknown;
|
|
21
|
+
/** The page URL's `utm_source`. */
|
|
22
|
+
utm?: unknown;
|
|
23
|
+
site?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Count one page view, unless a bot sent it. Never rejects. */
|
|
26
|
+
export declare function recordView(sql: Sql, { headers, path, referrer, utm, site }: ViewInput): Promise<void>;
|
|
27
|
+
export interface EventInput {
|
|
28
|
+
name: string;
|
|
29
|
+
props?: Record<string, string | number>;
|
|
30
|
+
/** The page it happened on, as a URL or a path. */
|
|
31
|
+
url?: string;
|
|
32
|
+
/** The request of whoever caused it, so the event joins their visitor. Omit for an event no visitor caused. */
|
|
33
|
+
headers?: HeaderReader;
|
|
34
|
+
site?: string;
|
|
35
|
+
}
|
|
36
|
+
/** Record one named event. Never rejects. */
|
|
37
|
+
export declare function recordEvent(sql: Sql, { name, props, url, headers, site }: EventInput): Promise<void>;
|
|
38
|
+
/** What a page sends: a view, or with `name` an event. */
|
|
39
|
+
export interface Beacon {
|
|
40
|
+
path: string;
|
|
41
|
+
referrer?: string;
|
|
42
|
+
utm?: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
props?: Record<string, string | number>;
|
|
45
|
+
}
|
|
46
|
+
export interface CollectOptions {
|
|
47
|
+
sql: Sql;
|
|
48
|
+
/** `after` in a Next route: the write runs once the 204 has gone. */
|
|
49
|
+
defer: (task: () => Promise<unknown>) => void;
|
|
50
|
+
/** The site a beacon counts toward, or null to drop it. A single-site app omits it. */
|
|
51
|
+
site?: (request: Request, path: string) => string | null | Promise<string | null>;
|
|
52
|
+
rateLimit?: RateLimitOptions;
|
|
7
53
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The whole beacon route: `export const POST = (r: Request) => collect(r, { sql, defer: after })`.
|
|
56
|
+
* Answers 204 whatever happens, before anything is written, so a reader never waits on it.
|
|
57
|
+
*/
|
|
58
|
+
export declare function collect(request: Request, { sql, defer, site, rateLimit: limit }: CollectOptions): Promise<Response>;
|
|
59
|
+
export interface Gap {
|
|
60
|
+
event: string;
|
|
61
|
+
/** The prop holding the reader's words. */
|
|
62
|
+
prop: string;
|
|
63
|
+
/** Props the event must carry, e.g. `{ results: '0' }`. */
|
|
64
|
+
where?: Record<string, string>;
|
|
65
|
+
}
|
|
66
|
+
/** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
|
|
67
|
+
export declare const SEARCH_GAPS: Gap;
|
|
68
|
+
export interface DigestOptions {
|
|
69
|
+
site?: string;
|
|
70
|
+
/** How far back, in whole days from now. */
|
|
71
|
+
days: number;
|
|
72
|
+
/** The site's own X handle: `follow_clicks` counts clicks on links to it. */
|
|
73
|
+
handle?: string;
|
|
74
|
+
/** Hosts of sibling sites: `sibling_referrals` counts the visits they sent. */
|
|
75
|
+
siblings?: string[];
|
|
76
|
+
gaps?: Gap[];
|
|
14
77
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* a person triggered; wrong for one a crawler did.
|
|
78
|
+
* MCP callers left out of `agent_tool_calls`, matched inside the user agent.
|
|
79
|
+
* The default is the conformance suites (`<repo>-mcp-test`) and the studio's own tools.
|
|
18
80
|
*/
|
|
19
|
-
|
|
81
|
+
ownAgents?: string[];
|
|
82
|
+
}
|
|
83
|
+
type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
|
|
84
|
+
export interface Digest {
|
|
85
|
+
visitors: number;
|
|
86
|
+
pageviews: number;
|
|
87
|
+
/** Percent of visits that saw one page; null with no visits. */
|
|
88
|
+
bounce_rate: number | null;
|
|
89
|
+
/** Mean seconds from a visit's first view to its last; null with no visits. */
|
|
90
|
+
visit_duration: number | null;
|
|
91
|
+
/** Visitors plus every agent that sent an event, such as an MCP call. */
|
|
92
|
+
visitors_incl_agents: number;
|
|
93
|
+
/** Visitors in the same number of days before. */
|
|
94
|
+
previous_visitors: number;
|
|
95
|
+
top_sources: Ranked<'source', 'visitors'>;
|
|
96
|
+
top_pages: Ranked<'page', 'visitors'>;
|
|
97
|
+
entry_pages: Ranked<'page', 'visitors'>;
|
|
98
|
+
/** Entry pages of visits from links tagged `utm_source=x`. */
|
|
99
|
+
from_posts: Ranked<'page', 'visitors'>;
|
|
100
|
+
sibling_referrals: Ranked<'source', 'visitors'>;
|
|
101
|
+
countries: Ranked<'country', 'visitors'>;
|
|
102
|
+
devices: Ranked<'device', 'visitors'>;
|
|
103
|
+
by_day: Ranked<'date', 'visitors'>;
|
|
104
|
+
follow_clicks: {
|
|
105
|
+
events: number;
|
|
106
|
+
visitors: number;
|
|
107
|
+
} | null;
|
|
108
|
+
agent_tool_calls: Ranked<'tool', 'events'>;
|
|
109
|
+
gaps: Ranked<'query', 'events'>;
|
|
20
110
|
}
|
|
21
111
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* waiting on.
|
|
112
|
+
* Everything a report needs about one site's last `days` days, in one query.
|
|
113
|
+
* Visitor figures count distinct visitors, so a person who comes back on
|
|
114
|
+
* another day counts once for each day.
|
|
26
115
|
*/
|
|
27
|
-
export declare function
|
|
116
|
+
export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
|
|
28
117
|
/**
|
|
29
118
|
* Props for an "MCP Call" event, read out of the JSON-RPC envelope.
|
|
30
119
|
*
|
|
31
120
|
* The body is untrusted and may be a batch, a notification, or malformed
|
|
32
121
|
* entirely, so every extraction is defensive and the result is always a
|
|
33
|
-
* complete props object. A batch is attributed to its first member
|
|
122
|
+
* complete props object. A batch is attributed to its first member – the
|
|
34
123
|
* alternative is one event per member, which would make a fan-out look like
|
|
35
124
|
* traffic it is not.
|
|
36
125
|
*/
|
|
@@ -47,7 +136,7 @@ export interface SearchQueryInput {
|
|
|
47
136
|
surface?: string;
|
|
48
137
|
}
|
|
49
138
|
/**
|
|
50
|
-
* Props for a "Search Query" event
|
|
139
|
+
* Props for a "Search Query" event – the human-side twin of `mcpCallProps`.
|
|
51
140
|
*
|
|
52
141
|
* A wiki instruments its agent surface and then counts every question an agent
|
|
53
142
|
* asks while recording none of the questions a person asks. The queries that
|
|
@@ -56,9 +145,60 @@ export interface SearchQueryInput {
|
|
|
56
145
|
*
|
|
57
146
|
* Returns `null` for a query with no content, so an empty field cannot fire an
|
|
58
147
|
* event. The text is untrusted, so it is collapsed, lowercased for aggregation
|
|
59
|
-
* and truncated
|
|
148
|
+
* and truncated – the same 64-character bound search itself applies, which
|
|
60
149
|
* keeps the prop and the query that produced it the same string.
|
|
61
150
|
*/
|
|
62
151
|
export declare function searchQueryProps({ query, results, surface, }: SearchQueryInput): Record<string, string> | null;
|
|
63
|
-
|
|
64
|
-
|
|
152
|
+
export interface TrackerOptions {
|
|
153
|
+
sql: Sql;
|
|
154
|
+
/** `after` in Next: the write runs once the response has gone. */
|
|
155
|
+
defer: (task: () => Promise<unknown>) => void;
|
|
156
|
+
/**
|
|
157
|
+
* Whether anything is recorded. Default: only on a Vercel production
|
|
158
|
+
* deployment, because dev and previews write to production's database.
|
|
159
|
+
*/
|
|
160
|
+
enabled?: boolean;
|
|
161
|
+
/** Where an event with no page of its own is filed: a search with no referer. Default `/`. */
|
|
162
|
+
siteUrl?: string;
|
|
163
|
+
}
|
|
164
|
+
/** Headers in hand, or a reader for them run inside the deferred write: Next's `headers`. */
|
|
165
|
+
export type HeaderSource = HeaderReader | (() => HeaderReader | Promise<HeaderReader>);
|
|
166
|
+
export interface Tracker {
|
|
167
|
+
/**
|
|
168
|
+
* Record one event. `headers` are the request of whoever caused it, so the
|
|
169
|
+
* event joins their visitor, an agent's included; omit them for an event no
|
|
170
|
+
* visitor caused. Never rejects, and the caller defers it: `event.waitUntil`
|
|
171
|
+
* in a proxy, `after` in a route.
|
|
172
|
+
*/
|
|
173
|
+
trackEvent(name: string, url: string, props: Record<string, string | number>, headers?: HeaderReader): Promise<void>;
|
|
174
|
+
/**
|
|
175
|
+
* An "MCP Call" event, deferred. The signature is `McpServerConfig.onCall`'s
|
|
176
|
+
* plus a server name, so a one-server site passes it as `onCall` as it is.
|
|
177
|
+
*/
|
|
178
|
+
trackMcpCall(request: Request, body: unknown, server?: string): void;
|
|
179
|
+
/**
|
|
180
|
+
* A "Search Query" event, deferred, filed against the page the reader
|
|
181
|
+
* searched from. A function for `headers` is called inside the deferred
|
|
182
|
+
* write, and a failure there drops the event rather than the response.
|
|
183
|
+
*/
|
|
184
|
+
trackSearch(headers: HeaderSource, query: string, results: number, surface?: string): void;
|
|
185
|
+
/**
|
|
186
|
+
* The beacon route: `export const POST = tracker.viewRoute()`. `site` is
|
|
187
|
+
* `collect`'s resolver, for an app that serves many sites or drops a path.
|
|
188
|
+
*/
|
|
189
|
+
viewRoute(opts?: Pick<CollectOptions, 'site' | 'rateLimit'>): (request: Request) => Promise<Response>;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Everything an app's `track.ts` had written around the functions above: the
|
|
193
|
+
* production gate, the deferral and the three named events. Four apps carried
|
|
194
|
+
* it, two with a search signature of their own.
|
|
195
|
+
*
|
|
196
|
+
* The tracker holds `sql`, so the module that builds it imports the app's
|
|
197
|
+
* database client. A proxy must keep reaching it through a dynamic import
|
|
198
|
+
* inside `waitUntil`, never a static one, or every request the proxy sees
|
|
199
|
+
* loads Prisma:
|
|
200
|
+
*
|
|
201
|
+
* event.waitUntil(import('@/lib/track').then(m => m.trackEvent('AI Bot Visit', url, { bot }, request.headers)));
|
|
202
|
+
*/
|
|
203
|
+
export declare function createTracker({ sql, defer, enabled, siteUrl, }: TrackerOptions): Tracker;
|
|
204
|
+
export {};
|