@backendfree/webanalytics 0.0.0-stage → 0.1.1

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 CHANGED
@@ -1,3 +1,173 @@
1
- # Temporary Holding Version
1
+ # @backendfree/webanalytics
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Report page views to BackendFree from your own server, for the visitors and
4
+ crawlers a browser snippet never sees.
5
+
6
+ The snippet on the Tracking screen covers people with JavaScript. This covers
7
+ everything else: crawlers, which mostly do not run scripts at all, and any
8
+ request you want measured before a page is rendered.
9
+
10
+ ```sh
11
+ npm install @backendfree/webanalytics
12
+ ```
13
+
14
+ ## Using it
15
+
16
+ Report a page view where your server answers a page load. In Next.js that is
17
+ `proxy.ts` (`middleware.ts` before Next 16):
18
+
19
+ ```ts
20
+ import { NextResponse, type NextFetchEvent, type NextRequest } from 'next/server';
21
+ import { Reporter } from '@backendfree/webanalytics';
22
+
23
+ const reporter = new Reporter({
24
+ origin: 'https://backendfree.com',
25
+ key: process.env.BACKENDFREE_SECRET_KEY!,
26
+ });
27
+
28
+ export function proxy(request: NextRequest, event: NextFetchEvent) {
29
+ // A page load, or a crawler, which sends no Sec-Fetch-Dest at all. Not the
30
+ // prefetches and data requests an app makes in the background.
31
+ const dest = request.headers.get('sec-fetch-dest');
32
+ if (request.method === 'GET' && (dest === null || dest === 'document')) {
33
+ reporter.pageview({
34
+ url: request.url,
35
+ referrer: request.headers.get('referer') ?? '',
36
+ ip: request.headers.get('cf-connecting-ip') ?? '',
37
+ userAgent: request.headers.get('user-agent') ?? '',
38
+ optedOut: request.headers.get('sec-gpc') === '1' || request.headers.get('dnt') === '1',
39
+ });
40
+ event.waitUntil(reporter.flush());
41
+ }
42
+ return NextResponse.next();
43
+ }
44
+
45
+ export const config = {
46
+ // Not the API routes, the framework's own files, or anything with an extension.
47
+ matcher: ['/((?!api/|_next/|.*\\.[a-zA-Z0-9]+$).*)'],
48
+ };
49
+ ```
50
+
51
+ `pageview` and `event` return immediately. Nothing is awaited, nothing throws,
52
+ and a collector that is down or slow cannot delay or break the page.
53
+
54
+ The proxy hands `flush()` to `waitUntil`, which keeps the function alive long
55
+ enough for the report to leave without the page waiting for it. On any other
56
+ serverless platform, do the same with whatever it calls that:
57
+
58
+ ```ts
59
+ context.waitUntil(reporter.flush());
60
+ ```
61
+
62
+ ### Pages opened without a page load
63
+
64
+ **A page reached by a link inside the site never reaches the proxy.** Next.js,
65
+ and any app with a client-side router, fetches the next page in the background,
66
+ often before the click, and shows it without a page load. Counting those
67
+ background fetches would count pages nobody opened; ignoring them misses most of
68
+ a visit. So the proxy counts page loads only, and the browser says when the path
69
+ changes, through your own server, which reports it the same way:
70
+
71
+ ```ts
72
+ // app/actions.ts
73
+ 'use server';
74
+ import { headers } from 'next/headers';
75
+ import { after } from 'next/server';
76
+
77
+ export async function recordPageView(path: string) {
78
+ const asked = await headers();
79
+ reporter.pageview({
80
+ url: `https://salon.example${path}`,
81
+ ip: asked.get('cf-connecting-ip') ?? '',
82
+ userAgent: asked.get('user-agent') ?? '',
83
+ optedOut: asked.get('sec-gpc') === '1' || asked.get('dnt') === '1',
84
+ });
85
+ after(() => reporter.flush());
86
+ }
87
+ ```
88
+
89
+ Call it from a client component whenever `usePathname()` changes, skipping the
90
+ first path, which was a page load the proxy has already counted. No key reaches
91
+ the browser, and nothing is stored on the device.
92
+
93
+ ### Named events
94
+
95
+ Something worth counting that is not a page, such as a booking made, is an
96
+ event. Declare its name on the Tracking screen first: a name that is not
97
+ declared is recorded as the page view it also was.
98
+
99
+ ```ts
100
+ // in a server action, as above
101
+ reporter.event('booking_made', {
102
+ url: 'https://salon.example/book',
103
+ ip: asked.get('cf-connecting-ip') ?? '',
104
+ userAgent: asked.get('user-agent') ?? '',
105
+ });
106
+ ```
107
+
108
+ ## A secret key, and it refuses a publishable one
109
+
110
+ Every report asserts who the visitor was, because your server is the only thing
111
+ that saw them. A publishable key is public by design: it sits in the page source
112
+ of every site using it. If this endpoint accepted one, anybody who read it could
113
+ forge visitors and spend your events allowance while looking like real traffic.
114
+
115
+ So this needs `sk_`, the platform refuses anything else with a 401, and the
116
+ constructor refuses it earlier so the stack trace points at the line that is
117
+ wrong.
118
+
119
+ Keep the key on the server. It is the same key that can read your payments.
120
+
121
+ ## What we do with what you send
122
+
123
+ The address and the User-Agent go into a hash with a salt that rotates at your
124
+ project's local midnight and is never written down, and neither is ever stored.
125
+ Same as what the browser snippet does with the address it arrives from, which is
126
+ why sites using either need no cookie banner. The full list is on the Tracking
127
+ screen.
128
+
129
+ Send the **visitor's** address, not your server's: read it from whatever header
130
+ your proxy sets (`cf-connecting-ip`, `x-real-ip`, or the entry your own proxy
131
+ appended to `x-forwarded-for`, at its right-hand end, since the left is whatever
132
+ the visitor sent). Your own address for everybody makes the whole site look
133
+ like one person. A report with no address still records; it just counts fewer
134
+ visitors than there were.
135
+
136
+ Send the **visitor's** User-Agent too. It is what tells a crawler from a person,
137
+ and without it a crawler is counted as a visitor and spends an event.
138
+
139
+ Pass the **visitor's** opt-out. Somebody whose browser sends `Sec-GPC: 1` or
140
+ `DNT: 1` is not measured by the snippet, and only your server saw those headers,
141
+ so `optedOut` is how the same rule reaches this door. An opted-out hit is never
142
+ sent. Writing your own client instead, send `"opted_out": true` on the event and
143
+ the platform drops it before anything is derived from the address.
144
+
145
+ ## Options
146
+
147
+ | option | default | |
148
+ |---|---|---|
149
+ | `origin` | required | Where the platform runs, no trailing path |
150
+ | `key` | required | `sk_live_...` |
151
+ | `batchSize` | `20` | Hits gathered before sending. Capped at 100 |
152
+ | `interval` | `1000` | How long a partly full batch waits, in ms |
153
+ | `timeout` | `10000` | Per-request, in ms. `0` disables it |
154
+ | `fetch` | global | Your own: a test double, a Worker's fetcher |
155
+ | `onError` | silence | The only place a failed report is visible |
156
+
157
+ `onError` defaults to doing nothing on purpose. During an outage the alternative
158
+ is a log line per page view, which fills your log budget with our problem.
159
+
160
+ ## What it does not do
161
+
162
+ **It does not read anything.** There is no method here that returns figures: the
163
+ Traffic screen in the dashboard is where those are read, and a key that could
164
+ report and read would be a wider key than reporting needs.
165
+
166
+ **It does not retry.** A page view that failed to send is a page view lost, and
167
+ that is the right trade: a queue that grows during an outage is memory on
168
+ somebody's web server, and the figure it protects is a count.
169
+
170
+ **It does not trim your URLs.** The query string is dropped and `utm_*` kept on
171
+ arrival, in the one place that rule lives. Doing it here as well would be a
172
+ second implementation of it, and the day they disagreed your campaign tags would
173
+ go missing with nothing failing.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @backendfree/webanalytics
3
+ *
4
+ * Reporting page views from your own server, for the visitors a browser snippet
5
+ * never sees.
6
+ *
7
+ * import { Reporter } from '@backendfree/webanalytics';
8
+ *
9
+ * const reporter = new Reporter({ origin, key: process.env.SECRET_KEY });
10
+ *
11
+ * // where your server answers a page load: a Next.js proxy, an edge worker
12
+ * reporter.pageview({
13
+ * url: request.url,
14
+ * referrer: request.headers.get('referer') ?? '',
15
+ * ip: request.headers.get('cf-connecting-ip') ?? '',
16
+ * userAgent: request.headers.get('user-agent') ?? '',
17
+ * optedOut: request.headers.get('sec-gpc') === '1' || request.headers.get('dnt') === '1',
18
+ * });
19
+ * event.waitUntil(reporter.flush());
20
+ *
21
+ * Five things are worth knowing before using it.
22
+ *
23
+ * **This needs a secret key, and it throws on a publishable one.** Every report
24
+ * asserts who the visitor was, because your server is the only thing that saw
25
+ * them. A publishable key sits in the page source of every site using it, so
26
+ * accepting one here would let anybody who read it forge visitors and spend
27
+ * your month while looking like real traffic. The platform refuses it too; this
28
+ * refuses at construction, where the stack trace points at the line that is
29
+ * wrong.
30
+ *
31
+ * **It never throws while reporting, and never delays a response.** A page must
32
+ * not fail, or wait, because measuring it failed. `pageview` and `event` return
33
+ * immediately; sending happens in the background and a failure is dropped. Call
34
+ * `flush()` where you actually want to wait, which on a serverless platform
35
+ * means inside `waitUntil`.
36
+ *
37
+ * **It is not built on `Client`.** The collector is not `/v1`: no ETags, no
38
+ * idempotency keys, no error envelope, no pagination and no request allowance.
39
+ * Routing reports through a client built for a different API would attach four
40
+ * things the endpoint ignores.
41
+ *
42
+ * **What you send about a visitor does not survive the request.** The address
43
+ * and the agent go into a hash with a salt that rotates at your project's local
44
+ * midnight and is never written down, and neither is ever stored. That is the
45
+ * same thing the browser snippet does with the address it arrives from, which
46
+ * is why sites using either need no cookie banner. A visitor who sent GPC or
47
+ * DNT is not measured at all: pass `optedOut` and the hit is never sent.
48
+ *
49
+ * **Crawlers are why this exists.** Most never run JavaScript, so the snippet
50
+ * does not see them at all. Reported from your server they are classified,
51
+ * counted and capped, and they never spend your events allowance.
52
+ */
53
+ /** Where the collector lives under an origin. */
54
+ export declare const COLLECT_PATH = "/collect/s";
55
+ /** What the platform takes in one report. Sending more is a 400. */
56
+ export declare const MAX_BATCH = 100;
57
+ /** Ten seconds, matching core's client: long enough for a cold start. */
58
+ export declare const DEFAULT_TIMEOUT = 10000;
59
+ /** How long a partly full batch waits for company before going anyway. */
60
+ export declare const DEFAULT_INTERVAL = 1000;
61
+ export interface ReporterOptions {
62
+ /** Where the platform runs, with no trailing path: `https://backendfree.com`. */
63
+ origin: string;
64
+ /** `sk_live_...`. A publishable key throws: see the note above. */
65
+ key: string;
66
+ /** Swap in your own `fetch`: a test double, or a Worker's bound fetcher. */
67
+ fetch?: typeof globalThis.fetch;
68
+ /** Per-request timeout in milliseconds. `0` disables it. */
69
+ timeout?: number;
70
+ /**
71
+ * Hits to gather before sending. 1 sends every hit on its own, which is
72
+ * simplest and costs a request per page view. Capped at `MAX_BATCH`.
73
+ */
74
+ batchSize?: number;
75
+ /**
76
+ * How long a partly full batch waits, in milliseconds. `0` sends on the next
77
+ * turn of the event loop rather than never: a site with one visitor an hour
78
+ * would otherwise hold that visit until the next one arrived.
79
+ */
80
+ interval?: number;
81
+ /**
82
+ * Called when a batch could not be sent. Nothing is retried and nothing is
83
+ * thrown, so this is the only place a failure is visible. Default: silence,
84
+ * because the alternative is an unhandled rejection taking down a worker
85
+ * over an analytics call.
86
+ */
87
+ onError?: (error: unknown) => void;
88
+ }
89
+ /** One page view or named event to report. */
90
+ export interface HitInput {
91
+ /** The full URL the visitor was on. The query is dropped except `utm_*`. */
92
+ url: string;
93
+ /** The referrer, reduced to a hostname on arrival. */
94
+ referrer?: string;
95
+ /**
96
+ * @deprecated Ignored. `pageview()` always reports a page view, and
97
+ * `event(name, hit)` takes the event's name as its first argument.
98
+ */
99
+ name?: string;
100
+ /**
101
+ * The visitor's address, hashed on arrival and never stored. Read it from
102
+ * whatever header your proxy sets: `cf-connecting-ip`, `x-real-ip`, or the
103
+ * far end of `x-forwarded-for`. Sending your own server's address instead
104
+ * makes every visitor look like one person.
105
+ */
106
+ ip?: string;
107
+ /**
108
+ * The visitor's User-Agent. Kept only as one of three device words, and it
109
+ * is what tells a crawler from a person, so a report without it counts a
110
+ * crawler as a visitor and spends an event on it.
111
+ */
112
+ userAgent?: string;
113
+ /**
114
+ * Whether the visitor asked not to be measured: `sec-gpc: 1` or `dnt: 1` on
115
+ * their request. Only your server saw those headers, so only you can pass
116
+ * them on. An opted-out hit is dropped here and never sent, the same as the
117
+ * browser snippet does.
118
+ */
119
+ optedOut?: boolean;
120
+ }
121
+ export declare class Reporter {
122
+ #private;
123
+ readonly origin: string;
124
+ constructor(options: ReporterOptions);
125
+ /** How many hits are waiting to be sent. */
126
+ get pending(): number;
127
+ /**
128
+ * Report one page view. Returns immediately and never throws.
129
+ *
130
+ * A hit with no `url` is dropped here rather than sent: the platform would
131
+ * drop it too, for having no hostname to match against the site list, and
132
+ * spending a request to find that out helps nobody.
133
+ */
134
+ pageview(hit: HitInput): void;
135
+ /**
136
+ * Report a named event. Same rules, plus the name has to be one the Tracking
137
+ * screen declares, or it is recorded as the page view it also was.
138
+ */
139
+ event(name: string, hit: HitInput): void;
140
+ /**
141
+ * Send everything queued and wait for it.
142
+ *
143
+ * Where a serverless platform needs this: `waitUntil(reporter.flush())` keeps
144
+ * the process alive long enough for the report to leave, and without it a
145
+ * function that returns immediately takes the queue with it.
146
+ */
147
+ flush(): Promise<void>;
148
+ }
149
+ export type { Hit, ReportResult } from './types.js';
150
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAMH,iDAAiD;AACjD,eAAO,MAAM,YAAY,eAAe,CAAC;AAEzC,oEAAoE;AACpE,eAAO,MAAM,SAAS,MAAM,CAAC;AAE7B,yEAAyE;AACzE,eAAO,MAAM,eAAe,QAAS,CAAC;AAEtC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,OAAQ,CAAC;AAEtC,MAAM,WAAW,eAAe;IAC9B,iFAAiF;IACjF,MAAM,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAED,8CAA8C;AAC9C,MAAM,WAAW,QAAQ;IACvB,4EAA4E;IAC5E,GAAG,EAAE,MAAM,CAAC;IACZ,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,qBAAa,QAAQ;;IACnB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;gBAcZ,OAAO,EAAE,eAAe;IAiCpC,4CAA4C;IAC5C,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI;IAI7B;;;OAGG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,GAAG,IAAI;IAIxC;;;;;;OAMG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAmG7B;AAED,YAAY,EAAE,GAAG,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @backendfree/webanalytics
3
+ *
4
+ * Reporting page views from your own server, for the visitors a browser snippet
5
+ * never sees.
6
+ *
7
+ * import { Reporter } from '@backendfree/webanalytics';
8
+ *
9
+ * const reporter = new Reporter({ origin, key: process.env.SECRET_KEY });
10
+ *
11
+ * // where your server answers a page load: a Next.js proxy, an edge worker
12
+ * reporter.pageview({
13
+ * url: request.url,
14
+ * referrer: request.headers.get('referer') ?? '',
15
+ * ip: request.headers.get('cf-connecting-ip') ?? '',
16
+ * userAgent: request.headers.get('user-agent') ?? '',
17
+ * optedOut: request.headers.get('sec-gpc') === '1' || request.headers.get('dnt') === '1',
18
+ * });
19
+ * event.waitUntil(reporter.flush());
20
+ *
21
+ * Five things are worth knowing before using it.
22
+ *
23
+ * **This needs a secret key, and it throws on a publishable one.** Every report
24
+ * asserts who the visitor was, because your server is the only thing that saw
25
+ * them. A publishable key sits in the page source of every site using it, so
26
+ * accepting one here would let anybody who read it forge visitors and spend
27
+ * your month while looking like real traffic. The platform refuses it too; this
28
+ * refuses at construction, where the stack trace points at the line that is
29
+ * wrong.
30
+ *
31
+ * **It never throws while reporting, and never delays a response.** A page must
32
+ * not fail, or wait, because measuring it failed. `pageview` and `event` return
33
+ * immediately; sending happens in the background and a failure is dropped. Call
34
+ * `flush()` where you actually want to wait, which on a serverless platform
35
+ * means inside `waitUntil`.
36
+ *
37
+ * **It is not built on `Client`.** The collector is not `/v1`: no ETags, no
38
+ * idempotency keys, no error envelope, no pagination and no request allowance.
39
+ * Routing reports through a client built for a different API would attach four
40
+ * things the endpoint ignores.
41
+ *
42
+ * **What you send about a visitor does not survive the request.** The address
43
+ * and the agent go into a hash with a salt that rotates at your project's local
44
+ * midnight and is never written down, and neither is ever stored. That is the
45
+ * same thing the browser snippet does with the address it arrives from, which
46
+ * is why sites using either need no cookie banner. A visitor who sent GPC or
47
+ * DNT is not measured at all: pass `optedOut` and the hit is never sent.
48
+ *
49
+ * **Crawlers are why this exists.** Most never run JavaScript, so the snippet
50
+ * does not see them at all. Reported from your server they are classified,
51
+ * counted and capped, and they never spend your events allowance.
52
+ */
53
+ import { ConfigError } from '@backendfree/core';
54
+ /** Where the collector lives under an origin. */
55
+ export const COLLECT_PATH = '/collect/s';
56
+ /** What the platform takes in one report. Sending more is a 400. */
57
+ export const MAX_BATCH = 100;
58
+ /** Ten seconds, matching core's client: long enough for a cold start. */
59
+ export const DEFAULT_TIMEOUT = 10_000;
60
+ /** How long a partly full batch waits for company before going anyway. */
61
+ export const DEFAULT_INTERVAL = 1_000;
62
+ export class Reporter {
63
+ origin;
64
+ #key;
65
+ #fetch;
66
+ #timeout;
67
+ #batchSize;
68
+ #interval;
69
+ #onError;
70
+ #queue = [];
71
+ #timer = null;
72
+ /** The send in flight, so `flush()` can wait for it as well as for the queue. */
73
+ #sending = Promise.resolve();
74
+ constructor(options) {
75
+ const origin = (options.origin ?? '').trim().replace(/\/+$/, '');
76
+ if (!origin) {
77
+ throw new ConfigError('origin_required', 'Pass the origin the platform runs on.');
78
+ }
79
+ const key = (options.key ?? '').trim();
80
+ if (!key) {
81
+ throw new ConfigError('key_required', 'Pass a project API key.');
82
+ }
83
+ // The mistake this package exists to refuse. A publishable key is public by
84
+ // design, and this endpoint takes the visitor's identity on trust.
85
+ if (key.startsWith('pk_')) {
86
+ throw new ConfigError('secret_key_required', 'Reporting needs a secret key. Every report asserts who the visitor was, so a ' +
87
+ 'publishable key here would let anybody who read it out of your page source forge ' +
88
+ 'visitors and spend your events allowance.');
89
+ }
90
+ this.origin = origin;
91
+ this.#key = key;
92
+ this.#fetch = options.fetch ?? globalThis.fetch;
93
+ this.#timeout = options.timeout ?? DEFAULT_TIMEOUT;
94
+ this.#batchSize = Math.min(Math.max(options.batchSize ?? 20, 1), MAX_BATCH);
95
+ this.#interval = Math.max(options.interval ?? DEFAULT_INTERVAL, 0);
96
+ this.#onError = options.onError ?? (() => { });
97
+ if (typeof this.#fetch !== 'function') {
98
+ throw new ConfigError('fetch_required', 'This runtime has no fetch. Pass one.');
99
+ }
100
+ }
101
+ /** How many hits are waiting to be sent. */
102
+ get pending() {
103
+ return this.#queue.length;
104
+ }
105
+ /**
106
+ * Report one page view. Returns immediately and never throws.
107
+ *
108
+ * A hit with no `url` is dropped here rather than sent: the platform would
109
+ * drop it too, for having no hostname to match against the site list, and
110
+ * spending a request to find that out helps nobody.
111
+ */
112
+ pageview(hit) {
113
+ this.#add(hit, '');
114
+ }
115
+ /**
116
+ * Report a named event. Same rules, plus the name has to be one the Tracking
117
+ * screen declares, or it is recorded as the page view it also was.
118
+ */
119
+ event(name, hit) {
120
+ this.#add(hit, name);
121
+ }
122
+ /**
123
+ * Send everything queued and wait for it.
124
+ *
125
+ * Where a serverless platform needs this: `waitUntil(reporter.flush())` keeps
126
+ * the process alive long enough for the report to leave, and without it a
127
+ * function that returns immediately takes the queue with it.
128
+ */
129
+ async flush() {
130
+ this.#cancelTimer();
131
+ while (this.#queue.length) {
132
+ await this.#send(this.#queue.splice(0, this.#batchSize));
133
+ }
134
+ await this.#sending;
135
+ }
136
+ // --- the queue ---------------------------------------------------------------
137
+ #add(hit, name) {
138
+ // Before anything else, so not even a url of theirs is queued.
139
+ if (hit?.optedOut) {
140
+ return;
141
+ }
142
+ const url = (hit?.url ?? '').trim();
143
+ if (!url) {
144
+ return;
145
+ }
146
+ this.#queue.push({
147
+ url,
148
+ referrer: hit.referrer ?? '',
149
+ name,
150
+ ip: hit.ip ?? '',
151
+ user_agent: hit.userAgent ?? '',
152
+ });
153
+ if (this.#queue.length >= this.#batchSize) {
154
+ this.#cancelTimer();
155
+ this.#launch(this.#queue.splice(0, this.#batchSize));
156
+ return;
157
+ }
158
+ this.#arm();
159
+ }
160
+ #arm() {
161
+ if (this.#timer !== null) {
162
+ return;
163
+ }
164
+ this.#timer = setTimeout(() => {
165
+ this.#timer = null;
166
+ this.#launch(this.#queue.splice(0, this.#batchSize));
167
+ }, this.#interval);
168
+ // Node keeps the process alive for a pending timer, so a script that has
169
+ // finished its work would hang for the interval waiting to report. The
170
+ // browser and Workers have no such method, hence the guard.
171
+ this.#timer?.unref?.();
172
+ }
173
+ #cancelTimer() {
174
+ if (this.#timer !== null) {
175
+ clearTimeout(this.#timer);
176
+ this.#timer = null;
177
+ }
178
+ }
179
+ /** Start a send without waiting for it, keeping it reachable for `flush()`. */
180
+ #launch(batch) {
181
+ this.#sending = this.#sending.then(() => this.#send(batch));
182
+ }
183
+ async #send(batch) {
184
+ if (!batch.length) {
185
+ return;
186
+ }
187
+ const controller = this.#timeout > 0 ? new AbortController() : null;
188
+ const timer = controller ? setTimeout(() => controller.abort(), this.#timeout) : null;
189
+ try {
190
+ const response = await this.#fetch(`${this.origin}${COLLECT_PATH}`, {
191
+ method: 'POST',
192
+ headers: {
193
+ authorization: `Bearer ${this.#key}`,
194
+ 'content-type': 'application/json',
195
+ },
196
+ body: JSON.stringify({ events: batch }),
197
+ signal: controller?.signal,
198
+ });
199
+ if (!response.ok) {
200
+ // Read, so the message says which of the four refusals it was rather
201
+ // than only the status. The platform answers a small JSON object.
202
+ const detail = await response.text().catch(() => '');
203
+ throw new Error(`The collector answered ${response.status}. ${detail}`.trim());
204
+ }
205
+ }
206
+ catch (error) {
207
+ // Swallowed on purpose, and this is the only place a failure surfaces. A
208
+ // rejected promise nobody is awaiting is an unhandled rejection, which on
209
+ // several runtimes takes the whole worker down: measuring a page must not
210
+ // be able to stop it being served.
211
+ this.#onError(error);
212
+ }
213
+ finally {
214
+ if (timer) {
215
+ clearTimeout(timer);
216
+ }
217
+ }
218
+ }
219
+ }
220
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What goes over the wire, in the collector's own spelling.
3
+ *
4
+ * Snake case here and camel case on `HitInput`, deliberately: the wire format
5
+ * is the platform's and changing it is a breaking change for every caller in
6
+ * every language, while the shape a TypeScript developer types is theirs. One
7
+ * conversion, in `#add`, rather than a wire format that reads as if it were
8
+ * designed for one language.
9
+ */
10
+ /** One reported hit, as `POST /collect/s` takes it. */
11
+ export interface Hit {
12
+ url: string;
13
+ referrer: string;
14
+ /** Empty for a page view. */
15
+ name: string;
16
+ /** Hashed on arrival with the day's salt, and never stored. */
17
+ ip: string;
18
+ /** Kept only as one of three device words, and used to spot a crawler. */
19
+ user_agent: string;
20
+ /**
21
+ * The visitor opted out. Never sent by this package, which drops an opted-out
22
+ * hit before anything leaves; a hand-written client sends it and the platform
23
+ * drops the event before anything is derived from the address.
24
+ */
25
+ opted_out?: boolean;
26
+ }
27
+ /** What the collector answers when it took the report. */
28
+ export interface ReportResult {
29
+ /** Events in the request, including any it went on to drop. */
30
+ received: number;
31
+ /**
32
+ * Events that became a row. Lower than `received` when the month's allowance
33
+ * ran out, when a hostname is not on the site list, or when the module is off.
34
+ * A crawler counts as recorded and spends no allowance.
35
+ */
36
+ recorded: number;
37
+ /** False for a test key, whose hits are counted on the Tracking screen and kept nowhere. */
38
+ livemode: boolean;
39
+ }
40
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,uDAAuD;AACvD,MAAM,WAAW,GAAG;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,6BAA6B;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,EAAE,EAAE,MAAM,CAAC;IACX,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,0DAA0D;AAC1D,MAAM,WAAW,YAAY;IAC3B,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,4FAA4F;IAC5F,QAAQ,EAAE,OAAO,CAAC;CACnB"}
package/dist/types.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * What goes over the wire, in the collector's own spelling.
3
+ *
4
+ * Snake case here and camel case on `HitInput`, deliberately: the wire format
5
+ * is the platform's and changing it is a breaking change for every caller in
6
+ * every language, while the shape a TypeScript developer types is theirs. One
7
+ * conversion, in `#add`, rather than a wire format that reads as if it were
8
+ * designed for one language.
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@backendfree/webanalytics",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Report page views to BackendFree from your own server, for the visitors and crawlers a browser snippet never sees.",
5
+ "keywords": [
6
+ "backendfree",
7
+ "analytics",
8
+ "cookieless",
9
+ "privacy",
10
+ "sdk",
11
+ "typescript"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "BackendFree",
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "!dist/.tsbuildinfo",
32
+ "src",
33
+ "README.md"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@backendfree/core": "^0.1.0"
40
+ },
41
+ "scripts": {
42
+ "clean": "tsc --build --clean && rm -rf dist",
43
+ "build": "tsc --build",
44
+ "test": "npm run build && node --test test/*.test.mjs",
45
+ "prepare": "npm run build",
46
+ "prepack": "npm run clean && npm run build",
47
+ "prepublishOnly": "npm test"
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,312 @@
1
+ /**
2
+ * @backendfree/webanalytics
3
+ *
4
+ * Reporting page views from your own server, for the visitors a browser snippet
5
+ * never sees.
6
+ *
7
+ * import { Reporter } from '@backendfree/webanalytics';
8
+ *
9
+ * const reporter = new Reporter({ origin, key: process.env.SECRET_KEY });
10
+ *
11
+ * // where your server answers a page load: a Next.js proxy, an edge worker
12
+ * reporter.pageview({
13
+ * url: request.url,
14
+ * referrer: request.headers.get('referer') ?? '',
15
+ * ip: request.headers.get('cf-connecting-ip') ?? '',
16
+ * userAgent: request.headers.get('user-agent') ?? '',
17
+ * optedOut: request.headers.get('sec-gpc') === '1' || request.headers.get('dnt') === '1',
18
+ * });
19
+ * event.waitUntil(reporter.flush());
20
+ *
21
+ * Five things are worth knowing before using it.
22
+ *
23
+ * **This needs a secret key, and it throws on a publishable one.** Every report
24
+ * asserts who the visitor was, because your server is the only thing that saw
25
+ * them. A publishable key sits in the page source of every site using it, so
26
+ * accepting one here would let anybody who read it forge visitors and spend
27
+ * your month while looking like real traffic. The platform refuses it too; this
28
+ * refuses at construction, where the stack trace points at the line that is
29
+ * wrong.
30
+ *
31
+ * **It never throws while reporting, and never delays a response.** A page must
32
+ * not fail, or wait, because measuring it failed. `pageview` and `event` return
33
+ * immediately; sending happens in the background and a failure is dropped. Call
34
+ * `flush()` where you actually want to wait, which on a serverless platform
35
+ * means inside `waitUntil`.
36
+ *
37
+ * **It is not built on `Client`.** The collector is not `/v1`: no ETags, no
38
+ * idempotency keys, no error envelope, no pagination and no request allowance.
39
+ * Routing reports through a client built for a different API would attach four
40
+ * things the endpoint ignores.
41
+ *
42
+ * **What you send about a visitor does not survive the request.** The address
43
+ * and the agent go into a hash with a salt that rotates at your project's local
44
+ * midnight and is never written down, and neither is ever stored. That is the
45
+ * same thing the browser snippet does with the address it arrives from, which
46
+ * is why sites using either need no cookie banner. A visitor who sent GPC or
47
+ * DNT is not measured at all: pass `optedOut` and the hit is never sent.
48
+ *
49
+ * **Crawlers are why this exists.** Most never run JavaScript, so the snippet
50
+ * does not see them at all. Reported from your server they are classified,
51
+ * counted and capped, and they never spend your events allowance.
52
+ */
53
+
54
+ import { ConfigError } from '@backendfree/core';
55
+
56
+ import type { Hit, ReportResult } from './types.js';
57
+
58
+ /** Where the collector lives under an origin. */
59
+ export const COLLECT_PATH = '/collect/s';
60
+
61
+ /** What the platform takes in one report. Sending more is a 400. */
62
+ export const MAX_BATCH = 100;
63
+
64
+ /** Ten seconds, matching core's client: long enough for a cold start. */
65
+ export const DEFAULT_TIMEOUT = 10_000;
66
+
67
+ /** How long a partly full batch waits for company before going anyway. */
68
+ export const DEFAULT_INTERVAL = 1_000;
69
+
70
+ export interface ReporterOptions {
71
+ /** Where the platform runs, with no trailing path: `https://backendfree.com`. */
72
+ origin: string;
73
+ /** `sk_live_...`. A publishable key throws: see the note above. */
74
+ key: string;
75
+ /** Swap in your own `fetch`: a test double, or a Worker's bound fetcher. */
76
+ fetch?: typeof globalThis.fetch;
77
+ /** Per-request timeout in milliseconds. `0` disables it. */
78
+ timeout?: number;
79
+ /**
80
+ * Hits to gather before sending. 1 sends every hit on its own, which is
81
+ * simplest and costs a request per page view. Capped at `MAX_BATCH`.
82
+ */
83
+ batchSize?: number;
84
+ /**
85
+ * How long a partly full batch waits, in milliseconds. `0` sends on the next
86
+ * turn of the event loop rather than never: a site with one visitor an hour
87
+ * would otherwise hold that visit until the next one arrived.
88
+ */
89
+ interval?: number;
90
+ /**
91
+ * Called when a batch could not be sent. Nothing is retried and nothing is
92
+ * thrown, so this is the only place a failure is visible. Default: silence,
93
+ * because the alternative is an unhandled rejection taking down a worker
94
+ * over an analytics call.
95
+ */
96
+ onError?: (error: unknown) => void;
97
+ }
98
+
99
+ /** One page view or named event to report. */
100
+ export interface HitInput {
101
+ /** The full URL the visitor was on. The query is dropped except `utm_*`. */
102
+ url: string;
103
+ /** The referrer, reduced to a hostname on arrival. */
104
+ referrer?: string;
105
+ /**
106
+ * @deprecated Ignored. `pageview()` always reports a page view, and
107
+ * `event(name, hit)` takes the event's name as its first argument.
108
+ */
109
+ name?: string;
110
+ /**
111
+ * The visitor's address, hashed on arrival and never stored. Read it from
112
+ * whatever header your proxy sets: `cf-connecting-ip`, `x-real-ip`, or the
113
+ * far end of `x-forwarded-for`. Sending your own server's address instead
114
+ * makes every visitor look like one person.
115
+ */
116
+ ip?: string;
117
+ /**
118
+ * The visitor's User-Agent. Kept only as one of three device words, and it
119
+ * is what tells a crawler from a person, so a report without it counts a
120
+ * crawler as a visitor and spends an event on it.
121
+ */
122
+ userAgent?: string;
123
+ /**
124
+ * Whether the visitor asked not to be measured: `sec-gpc: 1` or `dnt: 1` on
125
+ * their request. Only your server saw those headers, so only you can pass
126
+ * them on. An opted-out hit is dropped here and never sent, the same as the
127
+ * browser snippet does.
128
+ */
129
+ optedOut?: boolean;
130
+ }
131
+
132
+ export class Reporter {
133
+ readonly origin: string;
134
+
135
+ readonly #key: string;
136
+ readonly #fetch: typeof globalThis.fetch;
137
+ readonly #timeout: number;
138
+ readonly #batchSize: number;
139
+ readonly #interval: number;
140
+ readonly #onError: (error: unknown) => void;
141
+
142
+ #queue: Hit[] = [];
143
+ #timer: ReturnType<typeof setTimeout> | null = null;
144
+ /** The send in flight, so `flush()` can wait for it as well as for the queue. */
145
+ #sending: Promise<void> = Promise.resolve();
146
+
147
+ constructor(options: ReporterOptions) {
148
+ const origin = (options.origin ?? '').trim().replace(/\/+$/, '');
149
+ if (!origin) {
150
+ throw new ConfigError('origin_required', 'Pass the origin the platform runs on.');
151
+ }
152
+ const key = (options.key ?? '').trim();
153
+ if (!key) {
154
+ throw new ConfigError('key_required', 'Pass a project API key.');
155
+ }
156
+ // The mistake this package exists to refuse. A publishable key is public by
157
+ // design, and this endpoint takes the visitor's identity on trust.
158
+ if (key.startsWith('pk_')) {
159
+ throw new ConfigError(
160
+ 'secret_key_required',
161
+ 'Reporting needs a secret key. Every report asserts who the visitor was, so a ' +
162
+ 'publishable key here would let anybody who read it out of your page source forge ' +
163
+ 'visitors and spend your events allowance.',
164
+ );
165
+ }
166
+
167
+ this.origin = origin;
168
+ this.#key = key;
169
+ this.#fetch = options.fetch ?? globalThis.fetch;
170
+ this.#timeout = options.timeout ?? DEFAULT_TIMEOUT;
171
+ this.#batchSize = Math.min(Math.max(options.batchSize ?? 20, 1), MAX_BATCH);
172
+ this.#interval = Math.max(options.interval ?? DEFAULT_INTERVAL, 0);
173
+ this.#onError = options.onError ?? (() => {});
174
+
175
+ if (typeof this.#fetch !== 'function') {
176
+ throw new ConfigError('fetch_required', 'This runtime has no fetch. Pass one.');
177
+ }
178
+ }
179
+
180
+ /** How many hits are waiting to be sent. */
181
+ get pending(): number {
182
+ return this.#queue.length;
183
+ }
184
+
185
+ /**
186
+ * Report one page view. Returns immediately and never throws.
187
+ *
188
+ * A hit with no `url` is dropped here rather than sent: the platform would
189
+ * drop it too, for having no hostname to match against the site list, and
190
+ * spending a request to find that out helps nobody.
191
+ */
192
+ pageview(hit: HitInput): void {
193
+ this.#add(hit, '');
194
+ }
195
+
196
+ /**
197
+ * Report a named event. Same rules, plus the name has to be one the Tracking
198
+ * screen declares, or it is recorded as the page view it also was.
199
+ */
200
+ event(name: string, hit: HitInput): void {
201
+ this.#add(hit, name);
202
+ }
203
+
204
+ /**
205
+ * Send everything queued and wait for it.
206
+ *
207
+ * Where a serverless platform needs this: `waitUntil(reporter.flush())` keeps
208
+ * the process alive long enough for the report to leave, and without it a
209
+ * function that returns immediately takes the queue with it.
210
+ */
211
+ async flush(): Promise<void> {
212
+ this.#cancelTimer();
213
+ while (this.#queue.length) {
214
+ await this.#send(this.#queue.splice(0, this.#batchSize));
215
+ }
216
+ await this.#sending;
217
+ }
218
+
219
+ // --- the queue ---------------------------------------------------------------
220
+
221
+ #add(hit: HitInput, name: string): void {
222
+ // Before anything else, so not even a url of theirs is queued.
223
+ if (hit?.optedOut) {
224
+ return;
225
+ }
226
+ const url = (hit?.url ?? '').trim();
227
+ if (!url) {
228
+ return;
229
+ }
230
+
231
+ this.#queue.push({
232
+ url,
233
+ referrer: hit.referrer ?? '',
234
+ name,
235
+ ip: hit.ip ?? '',
236
+ user_agent: hit.userAgent ?? '',
237
+ });
238
+
239
+ if (this.#queue.length >= this.#batchSize) {
240
+ this.#cancelTimer();
241
+ this.#launch(this.#queue.splice(0, this.#batchSize));
242
+ return;
243
+ }
244
+ this.#arm();
245
+ }
246
+
247
+ #arm(): void {
248
+ if (this.#timer !== null) {
249
+ return;
250
+ }
251
+ this.#timer = setTimeout(() => {
252
+ this.#timer = null;
253
+ this.#launch(this.#queue.splice(0, this.#batchSize));
254
+ }, this.#interval);
255
+ // Node keeps the process alive for a pending timer, so a script that has
256
+ // finished its work would hang for the interval waiting to report. The
257
+ // browser and Workers have no such method, hence the guard.
258
+ this.#timer?.unref?.();
259
+ }
260
+
261
+ #cancelTimer(): void {
262
+ if (this.#timer !== null) {
263
+ clearTimeout(this.#timer);
264
+ this.#timer = null;
265
+ }
266
+ }
267
+
268
+ /** Start a send without waiting for it, keeping it reachable for `flush()`. */
269
+ #launch(batch: Hit[]): void {
270
+ this.#sending = this.#sending.then(() => this.#send(batch));
271
+ }
272
+
273
+ async #send(batch: Hit[]): Promise<void> {
274
+ if (!batch.length) {
275
+ return;
276
+ }
277
+
278
+ const controller = this.#timeout > 0 ? new AbortController() : null;
279
+ const timer = controller ? setTimeout(() => controller.abort(), this.#timeout) : null;
280
+
281
+ try {
282
+ const response = await this.#fetch(`${this.origin}${COLLECT_PATH}`, {
283
+ method: 'POST',
284
+ headers: {
285
+ authorization: `Bearer ${this.#key}`,
286
+ 'content-type': 'application/json',
287
+ },
288
+ body: JSON.stringify({ events: batch }),
289
+ signal: controller?.signal,
290
+ });
291
+
292
+ if (!response.ok) {
293
+ // Read, so the message says which of the four refusals it was rather
294
+ // than only the status. The platform answers a small JSON object.
295
+ const detail = await response.text().catch(() => '');
296
+ throw new Error(`The collector answered ${response.status}. ${detail}`.trim());
297
+ }
298
+ } catch (error) {
299
+ // Swallowed on purpose, and this is the only place a failure surfaces. A
300
+ // rejected promise nobody is awaiting is an unhandled rejection, which on
301
+ // several runtimes takes the whole worker down: measuring a page must not
302
+ // be able to stop it being served.
303
+ this.#onError(error);
304
+ } finally {
305
+ if (timer) {
306
+ clearTimeout(timer);
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ export type { Hit, ReportResult } from './types.js';
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What goes over the wire, in the collector's own spelling.
3
+ *
4
+ * Snake case here and camel case on `HitInput`, deliberately: the wire format
5
+ * is the platform's and changing it is a breaking change for every caller in
6
+ * every language, while the shape a TypeScript developer types is theirs. One
7
+ * conversion, in `#add`, rather than a wire format that reads as if it were
8
+ * designed for one language.
9
+ */
10
+
11
+ /** One reported hit, as `POST /collect/s` takes it. */
12
+ export interface Hit {
13
+ url: string;
14
+ referrer: string;
15
+ /** Empty for a page view. */
16
+ name: string;
17
+ /** Hashed on arrival with the day's salt, and never stored. */
18
+ ip: string;
19
+ /** Kept only as one of three device words, and used to spot a crawler. */
20
+ user_agent: string;
21
+ /**
22
+ * The visitor opted out. Never sent by this package, which drops an opted-out
23
+ * hit before anything leaves; a hand-written client sends it and the platform
24
+ * drops the event before anything is derived from the address.
25
+ */
26
+ opted_out?: boolean;
27
+ }
28
+
29
+ /** What the collector answers when it took the report. */
30
+ export interface ReportResult {
31
+ /** Events in the request, including any it went on to drop. */
32
+ received: number;
33
+ /**
34
+ * Events that became a row. Lower than `received` when the month's allowance
35
+ * ran out, when a hostname is not on the site list, or when the module is off.
36
+ * A crawler counts as recorded and spends no allowance.
37
+ */
38
+ recorded: number;
39
+ /** False for a test key, whose hits are counted on the Tracking screen and kept nowhere. */
40
+ livemode: boolean;
41
+ }