@cross-deck/buckets 0.1.2 → 0.2.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 +35 -1
- package/dist/index.d.mts +3 -56
- package/dist/index.d.ts +3 -56
- package/dist/sink-DjFci4Uz.d.mts +57 -0
- package/dist/sink-DjFci4Uz.d.ts +57 -0
- package/dist/web.d.mts +101 -0
- package/dist/web.d.ts +101 -0
- package/dist/web.js +206 -0
- package/dist/web.js.map +1 -0
- package/dist/web.mjs +198 -0
- package/dist/web.mjs.map +1 -0
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -80,6 +80,39 @@ haven't named yet. Buckets counted them at the database driver, not in your code
|
|
|
80
80
|
|
|
81
81
|
---
|
|
82
82
|
|
|
83
|
+
## Server *and* browser — install where you read
|
|
84
|
+
|
|
85
|
+
A collector counts reads **where it runs.** With Firestore, your app often reads
|
|
86
|
+
from **two** places: your **server** (the snippet above) and your users'
|
|
87
|
+
**browsers** — live `onSnapshot` listeners and direct `getDocs`/`getDoc` calls
|
|
88
|
+
that bill straight to your project and *never touch your server*. A server-only
|
|
89
|
+
collector can't see those, the same way `@cross-deck/node` can't see a browser
|
|
90
|
+
event. So Buckets ships a collector for each surface.
|
|
91
|
+
|
|
92
|
+
**Browser** — swap one import and add one line:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { initBucketsWeb, bucket } from "@cross-deck/buckets/web";
|
|
96
|
+
// was: import { getDocs, onSnapshot } from "firebase/firestore"
|
|
97
|
+
import { getDocs, onSnapshot } from "@cross-deck/buckets/web";
|
|
98
|
+
|
|
99
|
+
initBucketsWeb({ apiKey: "cd_pk_…" }); // your PUBLISHABLE key — safe in client code
|
|
100
|
+
|
|
101
|
+
bucket("live-feed", () => onSnapshot(liveQuery, render)); // every fire counted
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Each listener fire is counted as the documents it delivers — exactly what Firebase
|
|
105
|
+
bills — labelled and reported up the **same pipe**, so your dashboard shows
|
|
106
|
+
**server and browser reads side by side.** Install one, or both. The promise is
|
|
107
|
+
precise: **Buckets captures every read that flows through a collector** — put one
|
|
108
|
+
on each surface you read from, and you see all of it.
|
|
109
|
+
|
|
110
|
+
> We learned this the hard way dogfooding on our own dashboard: 94% of our reads
|
|
111
|
+
> were browser-side and a server-only install was blind to them. The browser
|
|
112
|
+
> collector is the fix — and the reason "install where you read" is the whole model.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
83
116
|
## What you get
|
|
84
117
|
|
|
85
118
|
A small, cheap, daily document per app — the **rollup**. This is the entire output,
|
|
@@ -349,7 +382,8 @@ reconciles against your provider's invoice instead of drifting from it.
|
|
|
349
382
|
|
|
350
383
|
| Datastore | Status |
|
|
351
384
|
|---|---|
|
|
352
|
-
| **
|
|
385
|
+
| **Firestore — server** (`firebase-admin`) | ✅ Supported |
|
|
386
|
+
| **Firestore — browser** (`firebase` JS SDK) | ✅ Supported — `@cross-deck/buckets/web` |
|
|
353
387
|
| Postgres · DynamoDB · MongoDB | 🔜 Adapter interface is public — contributions welcome |
|
|
354
388
|
|
|
355
389
|
The trap *pattern* generalises to any driver with interceptable read methods, and
|
package/dist/index.d.mts
CHANGED
|
@@ -1,58 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Abstracting the sink is what makes Buckets storage-agnostic: the meter never
|
|
5
|
-
* knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's
|
|
6
|
-
* ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that
|
|
7
|
-
* wants to self-host can implement `Sink` against anything (Postgres, a file, your
|
|
8
|
-
* own API) without touching the meter.
|
|
9
|
-
*/
|
|
10
|
-
interface OpCounts {
|
|
11
|
-
read?: number;
|
|
12
|
-
write?: number;
|
|
13
|
-
delete?: number;
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* One coalesced report — the wire contract (see docs/ROLLUP_SCHEMA.md). The meter
|
|
17
|
-
* produces one of these per UTC day in a flush window (usually exactly one).
|
|
18
|
-
*/
|
|
19
|
-
interface BucketsReport {
|
|
20
|
-
/** UTC day "YYYY-MM-DD". */
|
|
21
|
-
date: string;
|
|
22
|
-
/** bucket name → counts. The heart of the report. */
|
|
23
|
-
byLabel: Record<string, OpCounts>;
|
|
24
|
-
/** UTC hour "HH" → counts, for the hourly "did my fix land this hour?" view. */
|
|
25
|
-
byHour?: Record<string, OpCounts>;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* A destination for coalesced rollups. `flush` MAY throw on failure — the meter
|
|
29
|
-
* catches it, drops that one window, and never lets it reach your app.
|
|
30
|
-
*/
|
|
31
|
-
interface Sink {
|
|
32
|
-
flush(report: BucketsReport): Promise<void>;
|
|
33
|
-
}
|
|
34
|
-
interface ReportSinkConfig {
|
|
35
|
-
/** The project's `cd_sk_` secret key. Server-to-server only. */
|
|
36
|
-
apiKey: string;
|
|
37
|
-
/** Defaults to https://api.cross-deck.com/v1/buckets/report */
|
|
38
|
-
endpoint?: string;
|
|
39
|
-
/** Request timeout (ms); a slow Crossdeck must never stall your flush. */
|
|
40
|
-
timeoutMs?: number;
|
|
41
|
-
}
|
|
42
|
-
/**
|
|
43
|
-
* The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.
|
|
44
|
-
* The ingest folds it into the day's maintained doc with `increment`, so many
|
|
45
|
-
* reports a minute coalesce safely. This path does ZERO database reads — it sends
|
|
46
|
-
* a summary, it does not read. Throws on a non-202 so the meter can log/drop the
|
|
47
|
-
* window; the meter guarantees it never reaches your app.
|
|
48
|
-
*/
|
|
49
|
-
declare class ReportSink implements Sink {
|
|
50
|
-
private readonly endpoint;
|
|
51
|
-
private readonly apiKey;
|
|
52
|
-
private readonly timeoutMs;
|
|
53
|
-
constructor(config: ReportSinkConfig);
|
|
54
|
-
flush(report: BucketsReport): Promise<void>;
|
|
55
|
-
}
|
|
1
|
+
import { S as Sink } from './sink-DjFci4Uz.mjs';
|
|
2
|
+
export { B as BucketsReport, O as OpCounts, R as ReportSink, a as ReportSinkConfig } from './sink-DjFci4Uz.mjs';
|
|
56
3
|
|
|
57
4
|
type OpType = "read" | "write" | "delete";
|
|
58
5
|
/** Optional read-site hint — the collection touched, derived at the trap from the
|
|
@@ -176,4 +123,4 @@ interface InitOptions {
|
|
|
176
123
|
*/
|
|
177
124
|
declare function init(options: InitOptions): void;
|
|
178
125
|
|
|
179
|
-
export { type
|
|
126
|
+
export { type CostHint, type CostTag, type FirestoreClasses, type InitOptions, type MeterConfig, type OpType, Sink, bucket, currentCostTag, enterCostTag, flush, init, init as initBuckets, installFirestoreMeter, recordDeletes, recordReads, recordWrites, refineCostTag, runWithCostTag };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,58 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Abstracting the sink is what makes Buckets storage-agnostic: the meter never
|
|
5
|
-
* knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's
|
|
6
|
-
* ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that
|
|
7
|
-
* wants to self-host can implement `Sink` against anything (Postgres, a file, your
|
|
8
|
-
* own API) without touching the meter.
|
|
9
|
-
*/
|
|
10
|
-
interface OpCounts {
|
|
11
|
-
read?: number;
|
|
12
|
-
write?: number;
|
|
13
|
-
delete?: number;
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* One coalesced report — the wire contract (see docs/ROLLUP_SCHEMA.md). The meter
|
|
17
|
-
* produces one of these per UTC day in a flush window (usually exactly one).
|
|
18
|
-
*/
|
|
19
|
-
interface BucketsReport {
|
|
20
|
-
/** UTC day "YYYY-MM-DD". */
|
|
21
|
-
date: string;
|
|
22
|
-
/** bucket name → counts. The heart of the report. */
|
|
23
|
-
byLabel: Record<string, OpCounts>;
|
|
24
|
-
/** UTC hour "HH" → counts, for the hourly "did my fix land this hour?" view. */
|
|
25
|
-
byHour?: Record<string, OpCounts>;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* A destination for coalesced rollups. `flush` MAY throw on failure — the meter
|
|
29
|
-
* catches it, drops that one window, and never lets it reach your app.
|
|
30
|
-
*/
|
|
31
|
-
interface Sink {
|
|
32
|
-
flush(report: BucketsReport): Promise<void>;
|
|
33
|
-
}
|
|
34
|
-
interface ReportSinkConfig {
|
|
35
|
-
/** The project's `cd_sk_` secret key. Server-to-server only. */
|
|
36
|
-
apiKey: string;
|
|
37
|
-
/** Defaults to https://api.cross-deck.com/v1/buckets/report */
|
|
38
|
-
endpoint?: string;
|
|
39
|
-
/** Request timeout (ms); a slow Crossdeck must never stall your flush. */
|
|
40
|
-
timeoutMs?: number;
|
|
41
|
-
}
|
|
42
|
-
/**
|
|
43
|
-
* The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.
|
|
44
|
-
* The ingest folds it into the day's maintained doc with `increment`, so many
|
|
45
|
-
* reports a minute coalesce safely. This path does ZERO database reads — it sends
|
|
46
|
-
* a summary, it does not read. Throws on a non-202 so the meter can log/drop the
|
|
47
|
-
* window; the meter guarantees it never reaches your app.
|
|
48
|
-
*/
|
|
49
|
-
declare class ReportSink implements Sink {
|
|
50
|
-
private readonly endpoint;
|
|
51
|
-
private readonly apiKey;
|
|
52
|
-
private readonly timeoutMs;
|
|
53
|
-
constructor(config: ReportSinkConfig);
|
|
54
|
-
flush(report: BucketsReport): Promise<void>;
|
|
55
|
-
}
|
|
1
|
+
import { S as Sink } from './sink-DjFci4Uz.js';
|
|
2
|
+
export { B as BucketsReport, O as OpCounts, R as ReportSink, a as ReportSinkConfig } from './sink-DjFci4Uz.js';
|
|
56
3
|
|
|
57
4
|
type OpType = "read" | "write" | "delete";
|
|
58
5
|
/** Optional read-site hint — the collection touched, derived at the trap from the
|
|
@@ -176,4 +123,4 @@ interface InitOptions {
|
|
|
176
123
|
*/
|
|
177
124
|
declare function init(options: InitOptions): void;
|
|
178
125
|
|
|
179
|
-
export { type
|
|
126
|
+
export { type CostHint, type CostTag, type FirestoreClasses, type InitOptions, type MeterConfig, type OpType, Sink, bucket, currentCostTag, enterCostTag, flush, init, init as initBuckets, installFirestoreMeter, recordDeletes, recordReads, recordWrites, refineCostTag, runWithCostTag };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sink — where the meter sends a coalesced rollup, and the wire shape it sends.
|
|
3
|
+
*
|
|
4
|
+
* Abstracting the sink is what makes Buckets storage-agnostic: the meter never
|
|
5
|
+
* knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's
|
|
6
|
+
* ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that
|
|
7
|
+
* wants to self-host can implement `Sink` against anything (Postgres, a file, your
|
|
8
|
+
* own API) without touching the meter.
|
|
9
|
+
*/
|
|
10
|
+
interface OpCounts {
|
|
11
|
+
read?: number;
|
|
12
|
+
write?: number;
|
|
13
|
+
delete?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* One coalesced report — the wire contract (see docs/ROLLUP_SCHEMA.md). The meter
|
|
17
|
+
* produces one of these per UTC day in a flush window (usually exactly one).
|
|
18
|
+
*/
|
|
19
|
+
interface BucketsReport {
|
|
20
|
+
/** UTC day "YYYY-MM-DD". */
|
|
21
|
+
date: string;
|
|
22
|
+
/** bucket name → counts. The heart of the report. */
|
|
23
|
+
byLabel: Record<string, OpCounts>;
|
|
24
|
+
/** UTC hour "HH" → counts, for the hourly "did my fix land this hour?" view. */
|
|
25
|
+
byHour?: Record<string, OpCounts>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A destination for coalesced rollups. `flush` MAY throw on failure — the meter
|
|
29
|
+
* catches it, drops that one window, and never lets it reach your app.
|
|
30
|
+
*/
|
|
31
|
+
interface Sink {
|
|
32
|
+
flush(report: BucketsReport): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
interface ReportSinkConfig {
|
|
35
|
+
/** The project's `cd_sk_` secret key. Server-to-server only. */
|
|
36
|
+
apiKey: string;
|
|
37
|
+
/** Defaults to https://api.cross-deck.com/v1/buckets/report */
|
|
38
|
+
endpoint?: string;
|
|
39
|
+
/** Request timeout (ms); a slow Crossdeck must never stall your flush. */
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.
|
|
44
|
+
* The ingest folds it into the day's maintained doc with `increment`, so many
|
|
45
|
+
* reports a minute coalesce safely. This path does ZERO database reads — it sends
|
|
46
|
+
* a summary, it does not read. Throws on a non-202 so the meter can log/drop the
|
|
47
|
+
* window; the meter guarantees it never reaches your app.
|
|
48
|
+
*/
|
|
49
|
+
declare class ReportSink implements Sink {
|
|
50
|
+
private readonly endpoint;
|
|
51
|
+
private readonly apiKey;
|
|
52
|
+
private readonly timeoutMs;
|
|
53
|
+
constructor(config: ReportSinkConfig);
|
|
54
|
+
flush(report: BucketsReport): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { type BucketsReport as B, type OpCounts as O, ReportSink as R, type Sink as S, type ReportSinkConfig as a };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sink — where the meter sends a coalesced rollup, and the wire shape it sends.
|
|
3
|
+
*
|
|
4
|
+
* Abstracting the sink is what makes Buckets storage-agnostic: the meter never
|
|
5
|
+
* knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's
|
|
6
|
+
* ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that
|
|
7
|
+
* wants to self-host can implement `Sink` against anything (Postgres, a file, your
|
|
8
|
+
* own API) without touching the meter.
|
|
9
|
+
*/
|
|
10
|
+
interface OpCounts {
|
|
11
|
+
read?: number;
|
|
12
|
+
write?: number;
|
|
13
|
+
delete?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* One coalesced report — the wire contract (see docs/ROLLUP_SCHEMA.md). The meter
|
|
17
|
+
* produces one of these per UTC day in a flush window (usually exactly one).
|
|
18
|
+
*/
|
|
19
|
+
interface BucketsReport {
|
|
20
|
+
/** UTC day "YYYY-MM-DD". */
|
|
21
|
+
date: string;
|
|
22
|
+
/** bucket name → counts. The heart of the report. */
|
|
23
|
+
byLabel: Record<string, OpCounts>;
|
|
24
|
+
/** UTC hour "HH" → counts, for the hourly "did my fix land this hour?" view. */
|
|
25
|
+
byHour?: Record<string, OpCounts>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A destination for coalesced rollups. `flush` MAY throw on failure — the meter
|
|
29
|
+
* catches it, drops that one window, and never lets it reach your app.
|
|
30
|
+
*/
|
|
31
|
+
interface Sink {
|
|
32
|
+
flush(report: BucketsReport): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
interface ReportSinkConfig {
|
|
35
|
+
/** The project's `cd_sk_` secret key. Server-to-server only. */
|
|
36
|
+
apiKey: string;
|
|
37
|
+
/** Defaults to https://api.cross-deck.com/v1/buckets/report */
|
|
38
|
+
endpoint?: string;
|
|
39
|
+
/** Request timeout (ms); a slow Crossdeck must never stall your flush. */
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.
|
|
44
|
+
* The ingest folds it into the day's maintained doc with `increment`, so many
|
|
45
|
+
* reports a minute coalesce safely. This path does ZERO database reads — it sends
|
|
46
|
+
* a summary, it does not read. Throws on a non-202 so the meter can log/drop the
|
|
47
|
+
* window; the meter guarantees it never reaches your app.
|
|
48
|
+
*/
|
|
49
|
+
declare class ReportSink implements Sink {
|
|
50
|
+
private readonly endpoint;
|
|
51
|
+
private readonly apiKey;
|
|
52
|
+
private readonly timeoutMs;
|
|
53
|
+
constructor(config: ReportSinkConfig);
|
|
54
|
+
flush(report: BucketsReport): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { type BucketsReport as B, type OpCounts as O, ReportSink as R, type Sink as S, type ReportSinkConfig as a };
|
package/dist/web.d.mts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { S as Sink, B as BucketsReport } from './sink-DjFci4Uz.mjs';
|
|
2
|
+
export { O as OpCounts } from './sink-DjFci4Uz.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* web/meter — the browser read meter. Same contract as the Node meter (count in
|
|
6
|
+
* memory, flush ~1/min, never throw into the app), adapted to the browser:
|
|
7
|
+
*
|
|
8
|
+
* - no AsyncLocalStorage — labels come from web/context (synchronous),
|
|
9
|
+
* - "shutdown" is the tab going hidden/closed, so we also flush on
|
|
10
|
+
* visibilitychange→hidden and pagehide (a `fetch(..., {keepalive:true})`
|
|
11
|
+
* survives the unload),
|
|
12
|
+
* - it talks to a Sink exactly like the Node meter, so the wire shape is
|
|
13
|
+
* identical and the same ingest receives both.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface WebMeterConfig {
|
|
17
|
+
sink: Sink;
|
|
18
|
+
flushIntervalMs?: number;
|
|
19
|
+
onError?: (e: unknown) => void;
|
|
20
|
+
}
|
|
21
|
+
/** Coalesce the buffer into one report per UTC day and hand each to the Sink. */
|
|
22
|
+
declare function flushWeb(): Promise<void>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Attribute every read SET UP inside `fn` to the bucket `name`:
|
|
26
|
+
*
|
|
27
|
+
* bucket("pulse-map", () => onSnapshot(liveQuery, render));
|
|
28
|
+
* // → that listener's reads all show as "pulse-map", forever
|
|
29
|
+
*/
|
|
30
|
+
declare function bucket<T>(name: string, fn: () => T): T;
|
|
31
|
+
|
|
32
|
+
declare function getDoc(ref: any, ...rest: any[]): Promise<any>;
|
|
33
|
+
declare function getDocs(query: any, ...rest: any[]): Promise<any>;
|
|
34
|
+
/**
|
|
35
|
+
* onSnapshot has several overloads — (ref, observer), (ref, onNext, onError,
|
|
36
|
+
* onComplete), and either of those with a leading SnapshotListenOptions. We find
|
|
37
|
+
* the next-handler wherever it is (a function or `observer.next`) and wrap it to
|
|
38
|
+
* count on each fire, leaving every other argument exactly as passed.
|
|
39
|
+
*/
|
|
40
|
+
declare function onSnapshot(ref: any, ...args: any[]): any;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* web/sink — reports the browser's coalesced rollup up to Crossdeck's ingest.
|
|
44
|
+
*
|
|
45
|
+
* Two differences from the Node sink, both forced by the browser:
|
|
46
|
+
* - it authenticates with a PUBLISHABLE key (`cd_pk_`), never a secret — a
|
|
47
|
+
* secret key cannot live in client code. (The ingest accepts publishable keys
|
|
48
|
+
* for Buckets reports the same way the analytics SDK accepts them for events.)
|
|
49
|
+
* - it uses `fetch(..., { keepalive: true })` so a report fired as the tab is
|
|
50
|
+
* closing still completes.
|
|
51
|
+
*
|
|
52
|
+
* It performs ZERO database operations — it sends a summary, it does not read.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
interface WebReportSinkConfig {
|
|
56
|
+
/** The project's `cd_pk_` PUBLISHABLE key. */
|
|
57
|
+
apiKey: string;
|
|
58
|
+
endpoint?: string;
|
|
59
|
+
}
|
|
60
|
+
declare class WebReportSink implements Sink {
|
|
61
|
+
private readonly endpoint;
|
|
62
|
+
private readonly apiKey;
|
|
63
|
+
constructor(config: WebReportSinkConfig);
|
|
64
|
+
flush(report: BucketsReport): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @cross-deck/buckets/web — the BROWSER collector.
|
|
69
|
+
*
|
|
70
|
+
* Most Firebase apps read straight from the browser (live `onSnapshot`
|
|
71
|
+
* listeners, `getDocs`, `getDoc`) — reads billed to your project that a
|
|
72
|
+
* server-side collector can never see. This adapter closes that hole.
|
|
73
|
+
*
|
|
74
|
+
* Setup (two lines + one import swap):
|
|
75
|
+
*
|
|
76
|
+
* import { initBucketsWeb, bucket } from "@cross-deck/buckets/web";
|
|
77
|
+
* import { getDoc, getDocs, onSnapshot } from "@cross-deck/buckets/web"; // was "firebase/firestore"
|
|
78
|
+
*
|
|
79
|
+
* initBucketsWeb({ apiKey: "cd_pk_…" }); // your PUBLISHABLE key
|
|
80
|
+
*
|
|
81
|
+
* bucket("pulse-map", () => onSnapshot(liveQuery, render));
|
|
82
|
+
*
|
|
83
|
+
* Every read those wrappers see is counted, labelled, and reported up the same
|
|
84
|
+
* ingest pipe as the server collector — so the dashboard shows server AND browser
|
|
85
|
+
* reads side by side.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
interface InitWebOptions {
|
|
89
|
+
/** The project's `cd_pk_` PUBLISHABLE key (safe in client code). */
|
|
90
|
+
apiKey: string;
|
|
91
|
+
/** Override the report endpoint (defaults to Crossdeck's ingest). */
|
|
92
|
+
endpoint?: string;
|
|
93
|
+
/** How often to flush coalesced counts (ms). Default 60_000. */
|
|
94
|
+
flushIntervalMs?: number;
|
|
95
|
+
/** Notified when a flush fails, so a dropped window is never silent. */
|
|
96
|
+
onError?: WebMeterConfig["onError"];
|
|
97
|
+
}
|
|
98
|
+
/** Configure the browser collector once, at app start. */
|
|
99
|
+
declare function initBucketsWeb(options: InitWebOptions): void;
|
|
100
|
+
|
|
101
|
+
export { BucketsReport, type InitWebOptions, Sink, WebReportSink, type WebReportSinkConfig, bucket, flushWeb as flush, getDoc, getDocs, initBucketsWeb, onSnapshot };
|
package/dist/web.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { S as Sink, B as BucketsReport } from './sink-DjFci4Uz.js';
|
|
2
|
+
export { O as OpCounts } from './sink-DjFci4Uz.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* web/meter — the browser read meter. Same contract as the Node meter (count in
|
|
6
|
+
* memory, flush ~1/min, never throw into the app), adapted to the browser:
|
|
7
|
+
*
|
|
8
|
+
* - no AsyncLocalStorage — labels come from web/context (synchronous),
|
|
9
|
+
* - "shutdown" is the tab going hidden/closed, so we also flush on
|
|
10
|
+
* visibilitychange→hidden and pagehide (a `fetch(..., {keepalive:true})`
|
|
11
|
+
* survives the unload),
|
|
12
|
+
* - it talks to a Sink exactly like the Node meter, so the wire shape is
|
|
13
|
+
* identical and the same ingest receives both.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface WebMeterConfig {
|
|
17
|
+
sink: Sink;
|
|
18
|
+
flushIntervalMs?: number;
|
|
19
|
+
onError?: (e: unknown) => void;
|
|
20
|
+
}
|
|
21
|
+
/** Coalesce the buffer into one report per UTC day and hand each to the Sink. */
|
|
22
|
+
declare function flushWeb(): Promise<void>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Attribute every read SET UP inside `fn` to the bucket `name`:
|
|
26
|
+
*
|
|
27
|
+
* bucket("pulse-map", () => onSnapshot(liveQuery, render));
|
|
28
|
+
* // → that listener's reads all show as "pulse-map", forever
|
|
29
|
+
*/
|
|
30
|
+
declare function bucket<T>(name: string, fn: () => T): T;
|
|
31
|
+
|
|
32
|
+
declare function getDoc(ref: any, ...rest: any[]): Promise<any>;
|
|
33
|
+
declare function getDocs(query: any, ...rest: any[]): Promise<any>;
|
|
34
|
+
/**
|
|
35
|
+
* onSnapshot has several overloads — (ref, observer), (ref, onNext, onError,
|
|
36
|
+
* onComplete), and either of those with a leading SnapshotListenOptions. We find
|
|
37
|
+
* the next-handler wherever it is (a function or `observer.next`) and wrap it to
|
|
38
|
+
* count on each fire, leaving every other argument exactly as passed.
|
|
39
|
+
*/
|
|
40
|
+
declare function onSnapshot(ref: any, ...args: any[]): any;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* web/sink — reports the browser's coalesced rollup up to Crossdeck's ingest.
|
|
44
|
+
*
|
|
45
|
+
* Two differences from the Node sink, both forced by the browser:
|
|
46
|
+
* - it authenticates with a PUBLISHABLE key (`cd_pk_`), never a secret — a
|
|
47
|
+
* secret key cannot live in client code. (The ingest accepts publishable keys
|
|
48
|
+
* for Buckets reports the same way the analytics SDK accepts them for events.)
|
|
49
|
+
* - it uses `fetch(..., { keepalive: true })` so a report fired as the tab is
|
|
50
|
+
* closing still completes.
|
|
51
|
+
*
|
|
52
|
+
* It performs ZERO database operations — it sends a summary, it does not read.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
interface WebReportSinkConfig {
|
|
56
|
+
/** The project's `cd_pk_` PUBLISHABLE key. */
|
|
57
|
+
apiKey: string;
|
|
58
|
+
endpoint?: string;
|
|
59
|
+
}
|
|
60
|
+
declare class WebReportSink implements Sink {
|
|
61
|
+
private readonly endpoint;
|
|
62
|
+
private readonly apiKey;
|
|
63
|
+
constructor(config: WebReportSinkConfig);
|
|
64
|
+
flush(report: BucketsReport): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @cross-deck/buckets/web — the BROWSER collector.
|
|
69
|
+
*
|
|
70
|
+
* Most Firebase apps read straight from the browser (live `onSnapshot`
|
|
71
|
+
* listeners, `getDocs`, `getDoc`) — reads billed to your project that a
|
|
72
|
+
* server-side collector can never see. This adapter closes that hole.
|
|
73
|
+
*
|
|
74
|
+
* Setup (two lines + one import swap):
|
|
75
|
+
*
|
|
76
|
+
* import { initBucketsWeb, bucket } from "@cross-deck/buckets/web";
|
|
77
|
+
* import { getDoc, getDocs, onSnapshot } from "@cross-deck/buckets/web"; // was "firebase/firestore"
|
|
78
|
+
*
|
|
79
|
+
* initBucketsWeb({ apiKey: "cd_pk_…" }); // your PUBLISHABLE key
|
|
80
|
+
*
|
|
81
|
+
* bucket("pulse-map", () => onSnapshot(liveQuery, render));
|
|
82
|
+
*
|
|
83
|
+
* Every read those wrappers see is counted, labelled, and reported up the same
|
|
84
|
+
* ingest pipe as the server collector — so the dashboard shows server AND browser
|
|
85
|
+
* reads side by side.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
interface InitWebOptions {
|
|
89
|
+
/** The project's `cd_pk_` PUBLISHABLE key (safe in client code). */
|
|
90
|
+
apiKey: string;
|
|
91
|
+
/** Override the report endpoint (defaults to Crossdeck's ingest). */
|
|
92
|
+
endpoint?: string;
|
|
93
|
+
/** How often to flush coalesced counts (ms). Default 60_000. */
|
|
94
|
+
flushIntervalMs?: number;
|
|
95
|
+
/** Notified when a flush fails, so a dropped window is never silent. */
|
|
96
|
+
onError?: WebMeterConfig["onError"];
|
|
97
|
+
}
|
|
98
|
+
/** Configure the browser collector once, at app start. */
|
|
99
|
+
declare function initBucketsWeb(options: InitWebOptions): void;
|
|
100
|
+
|
|
101
|
+
export { BucketsReport, type InitWebOptions, Sink, WebReportSink, type WebReportSinkConfig, bucket, flushWeb as flush, getDoc, getDocs, initBucketsWeb, onSnapshot };
|
package/dist/web.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var firestore = require('firebase/firestore');
|
|
4
|
+
|
|
5
|
+
// src/web/meter.ts
|
|
6
|
+
var SEP = "";
|
|
7
|
+
var labelBuffer = /* @__PURE__ */ new Map();
|
|
8
|
+
var hourBuffer = /* @__PURE__ */ new Map();
|
|
9
|
+
var sink = null;
|
|
10
|
+
var flushIntervalMs = 6e4;
|
|
11
|
+
var onError = null;
|
|
12
|
+
var timer = null;
|
|
13
|
+
var flushing = false;
|
|
14
|
+
var lifecycleBound = false;
|
|
15
|
+
var MAX_BUFFER_KEYS = 5e3;
|
|
16
|
+
function configureWebMeter(config) {
|
|
17
|
+
sink = config.sink;
|
|
18
|
+
if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;
|
|
19
|
+
onError = config.onError ?? null;
|
|
20
|
+
}
|
|
21
|
+
var utcDate = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
22
|
+
var utcHour = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 13);
|
|
23
|
+
function ensureLoop() {
|
|
24
|
+
if (timer) return;
|
|
25
|
+
timer = setInterval(() => void flushWeb(), flushIntervalMs);
|
|
26
|
+
if (!lifecycleBound && typeof addEventListener === "function") {
|
|
27
|
+
lifecycleBound = true;
|
|
28
|
+
addEventListener("visibilitychange", () => {
|
|
29
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") void flushWeb();
|
|
30
|
+
});
|
|
31
|
+
addEventListener("pagehide", () => void flushWeb());
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function recordWeb(op, n, label) {
|
|
35
|
+
try {
|
|
36
|
+
if (!Number.isFinite(n) || n <= 0) return;
|
|
37
|
+
const date = utcDate();
|
|
38
|
+
const lk = date + SEP + op + SEP + label;
|
|
39
|
+
labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);
|
|
40
|
+
const hk = date + SEP + op + SEP + utcHour();
|
|
41
|
+
hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);
|
|
42
|
+
ensureLoop();
|
|
43
|
+
if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flushWeb();
|
|
44
|
+
} catch {
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function add(target, key, op, n) {
|
|
48
|
+
const bag = target[key] ??= {};
|
|
49
|
+
bag[op] = (bag[op] ?? 0) + n;
|
|
50
|
+
}
|
|
51
|
+
async function flushWeb() {
|
|
52
|
+
if (flushing) return;
|
|
53
|
+
if (!sink) {
|
|
54
|
+
labelBuffer.clear();
|
|
55
|
+
hourBuffer.clear();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (labelBuffer.size === 0 && hourBuffer.size === 0) return;
|
|
59
|
+
flushing = true;
|
|
60
|
+
const labels = new Map(labelBuffer);
|
|
61
|
+
const hours = new Map(hourBuffer);
|
|
62
|
+
labelBuffer.clear();
|
|
63
|
+
hourBuffer.clear();
|
|
64
|
+
try {
|
|
65
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
66
|
+
const reportFor = (date) => {
|
|
67
|
+
let r = byDate.get(date);
|
|
68
|
+
if (!r) {
|
|
69
|
+
r = { date, byLabel: {}, byHour: {} };
|
|
70
|
+
byDate.set(date, r);
|
|
71
|
+
}
|
|
72
|
+
return r;
|
|
73
|
+
};
|
|
74
|
+
for (const [k, n] of labels) {
|
|
75
|
+
const [date, op, label] = k.split(SEP);
|
|
76
|
+
add(reportFor(date).byLabel, label, op, n);
|
|
77
|
+
}
|
|
78
|
+
for (const [k, n] of hours) {
|
|
79
|
+
const [date, op, hour] = k.split(SEP);
|
|
80
|
+
add(reportFor(date).byHour, hour, op, n);
|
|
81
|
+
}
|
|
82
|
+
for (const report of byDate.values()) {
|
|
83
|
+
await sink.flush(report);
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
onError?.(e);
|
|
87
|
+
} finally {
|
|
88
|
+
flushing = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/web/sink.ts
|
|
93
|
+
var DEFAULT_ENDPOINT = "https://api.cross-deck.com/v1/buckets/report";
|
|
94
|
+
var WebReportSink = class {
|
|
95
|
+
endpoint;
|
|
96
|
+
apiKey;
|
|
97
|
+
constructor(config) {
|
|
98
|
+
this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
|
99
|
+
this.apiKey = config.apiKey;
|
|
100
|
+
}
|
|
101
|
+
async flush(report) {
|
|
102
|
+
const res = await fetch(this.endpoint, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
keepalive: true,
|
|
105
|
+
headers: {
|
|
106
|
+
"content-type": "application/json",
|
|
107
|
+
authorization: `Bearer ${this.apiKey}`
|
|
108
|
+
},
|
|
109
|
+
body: JSON.stringify(report)
|
|
110
|
+
});
|
|
111
|
+
if (res.status !== 202) {
|
|
112
|
+
throw new Error(`Buckets web report rejected: HTTP ${res.status}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// src/web/context.ts
|
|
118
|
+
var current;
|
|
119
|
+
function currentLabel() {
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
function bucket(name, fn) {
|
|
123
|
+
const prev = current;
|
|
124
|
+
current = name;
|
|
125
|
+
try {
|
|
126
|
+
return fn();
|
|
127
|
+
} finally {
|
|
128
|
+
current = prev;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
var rawGetDoc = firestore.getDoc;
|
|
132
|
+
var rawGetDocs = firestore.getDocs;
|
|
133
|
+
var rawOnSnapshot = firestore.onSnapshot;
|
|
134
|
+
function collLabel(ref) {
|
|
135
|
+
try {
|
|
136
|
+
const path = typeof ref?.path === "string" && ref.path || (ref?._query?.path?.segments?.join?.("/") ?? "") || "";
|
|
137
|
+
if (path) {
|
|
138
|
+
const segs2 = path.split("/").filter(Boolean);
|
|
139
|
+
const coll = segs2.length % 2 === 0 ? segs2[segs2.length - 2] : segs2[segs2.length - 1];
|
|
140
|
+
return coll ? `col:${coll}` : "uncategorized";
|
|
141
|
+
}
|
|
142
|
+
const segs = ref?._query?.path?.segments;
|
|
143
|
+
if (Array.isArray(segs) && segs.length) return `col:${segs[segs.length - 1]}`;
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
return "uncategorized";
|
|
147
|
+
}
|
|
148
|
+
function meter(label, n) {
|
|
149
|
+
try {
|
|
150
|
+
recordWeb("read", n, label);
|
|
151
|
+
} catch {
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function countSnap(snap) {
|
|
155
|
+
try {
|
|
156
|
+
if (typeof snap?.docChanges === "function") return snap.docChanges().length;
|
|
157
|
+
} catch {
|
|
158
|
+
}
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
function getDoc(ref, ...rest) {
|
|
162
|
+
const label = currentLabel() ?? collLabel(ref);
|
|
163
|
+
return rawGetDoc(ref, ...rest).then((snap) => {
|
|
164
|
+
meter(label, 1);
|
|
165
|
+
return snap;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function getDocs(query, ...rest) {
|
|
169
|
+
const label = currentLabel() ?? collLabel(query);
|
|
170
|
+
return rawGetDocs(query, ...rest).then((snap) => {
|
|
171
|
+
meter(label, typeof snap?.size === "number" ? Math.max(snap.size, 1) : 1);
|
|
172
|
+
return snap;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function onSnapshot(ref, ...args) {
|
|
176
|
+
const label = currentLabel() ?? collLabel(ref);
|
|
177
|
+
const wrapNext = (fn) => typeof fn === "function" ? (snap) => {
|
|
178
|
+
meter(label, countSnap(snap));
|
|
179
|
+
return fn(snap);
|
|
180
|
+
} : fn;
|
|
181
|
+
const out = args.slice();
|
|
182
|
+
let i = 0;
|
|
183
|
+
if (out[0] && typeof out[0] !== "function" && !("next" in out[0])) i = 1;
|
|
184
|
+
if (out[i] && typeof out[i] === "object" && "next" in out[i]) {
|
|
185
|
+
out[i] = { ...out[i], next: wrapNext(out[i].next) };
|
|
186
|
+
} else if (typeof out[i] === "function") {
|
|
187
|
+
out[i] = wrapNext(out[i]);
|
|
188
|
+
}
|
|
189
|
+
return rawOnSnapshot(ref, ...out);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/web/index.ts
|
|
193
|
+
function initBucketsWeb(options) {
|
|
194
|
+
const sink2 = new WebReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });
|
|
195
|
+
configureWebMeter({ sink: sink2, flushIntervalMs: options.flushIntervalMs, onError: options.onError });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
exports.WebReportSink = WebReportSink;
|
|
199
|
+
exports.bucket = bucket;
|
|
200
|
+
exports.flush = flushWeb;
|
|
201
|
+
exports.getDoc = getDoc;
|
|
202
|
+
exports.getDocs = getDocs;
|
|
203
|
+
exports.initBucketsWeb = initBucketsWeb;
|
|
204
|
+
exports.onSnapshot = onSnapshot;
|
|
205
|
+
//# sourceMappingURL=web.js.map
|
|
206
|
+
//# sourceMappingURL=web.js.map
|
package/dist/web.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/web/meter.ts","../src/web/sink.ts","../src/web/context.ts","../src/web/firestore.ts","../src/web/index.ts"],"names":["_getDoc","_getDocs","_onSnapshot","segs","sink"],"mappings":"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","file":"web.js","sourcesContent":["/**\n * web/meter — the browser read meter. Same contract as the Node meter (count in\n * memory, flush ~1/min, never throw into the app), adapted to the browser:\n *\n * - no AsyncLocalStorage — labels come from web/context (synchronous),\n * - \"shutdown\" is the tab going hidden/closed, so we also flush on\n * visibilitychange→hidden and pagehide (a `fetch(..., {keepalive:true})`\n * survives the unload),\n * - it talks to a Sink exactly like the Node meter, so the wire shape is\n * identical and the same ingest receives both.\n */\nimport type { Sink, BucketsReport, OpCounts } from \"../sink\";\n\nexport type OpType = \"read\" | \"write\" | \"delete\";\n\n// ASCII Unit Separator — a bucket/collection name never contains it, so the\n// composite key splits back cleanly.\nconst SEP = \"\\u001f\"; // ASCII Unit Separator\n\n/** key = date <US> op <US> label → count */\nconst labelBuffer = new Map<string, number>();\n/** key = date <US> op <US> hour → count */\nconst hourBuffer = new Map<string, number>();\n\nlet sink: Sink | null = null;\nlet flushIntervalMs = 60_000;\nlet onError: ((e: unknown) => void) | null = null;\nlet timer: ReturnType<typeof setInterval> | null = null;\nlet flushing = false;\nlet lifecycleBound = false;\nconst MAX_BUFFER_KEYS = 5_000;\n\nexport interface WebMeterConfig {\n sink: Sink;\n flushIntervalMs?: number;\n onError?: (e: unknown) => void;\n}\n\nexport function configureWebMeter(config: WebMeterConfig): void {\n sink = config.sink;\n if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;\n onError = config.onError ?? null;\n}\n\nconst utcDate = (): string => new Date().toISOString().slice(0, 10);\nconst utcHour = (): string => new Date().toISOString().slice(11, 13);\n\nfunction ensureLoop(): void {\n if (timer) return;\n timer = setInterval(() => void flushWeb(), flushIntervalMs);\n if (!lifecycleBound && typeof addEventListener === \"function\") {\n lifecycleBound = true;\n // The tab being hidden or torn down is the browser's \"shutdown\" — flush the\n // last window. keepalive on the sink's fetch lets it complete during unload.\n addEventListener(\"visibilitychange\", () => {\n if (typeof document !== \"undefined\" && document.visibilityState === \"hidden\") void flushWeb();\n });\n addEventListener(\"pagehide\", () => void flushWeb());\n }\n}\n\n/** Count `n` ops of `op` against `label`. Never throws. */\nexport function recordWeb(op: OpType, n: number, label: string): void {\n try {\n if (!Number.isFinite(n) || n <= 0) return;\n const date = utcDate();\n const lk = date + SEP + op + SEP + label;\n labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);\n const hk = date + SEP + op + SEP + utcHour();\n hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);\n ensureLoop();\n if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flushWeb();\n } catch {\n /* metering is best-effort — never disturb the page */\n }\n}\n\nfunction add(target: Record<string, OpCounts>, key: string, op: OpType, n: number): void {\n const bag = (target[key] ??= {});\n bag[op] = (bag[op] ?? 0) + n;\n}\n\n/** Coalesce the buffer into one report per UTC day and hand each to the Sink. */\nexport async function flushWeb(): Promise<void> {\n if (flushing) return;\n if (!sink) {\n labelBuffer.clear();\n hourBuffer.clear();\n return;\n }\n if (labelBuffer.size === 0 && hourBuffer.size === 0) return;\n flushing = true;\n\n const labels = new Map(labelBuffer);\n const hours = new Map(hourBuffer);\n labelBuffer.clear();\n hourBuffer.clear();\n\n try {\n const byDate = new Map<string, BucketsReport>();\n const reportFor = (date: string): BucketsReport => {\n let r = byDate.get(date);\n if (!r) {\n r = { date, byLabel: {}, byHour: {} };\n byDate.set(date, r);\n }\n return r;\n };\n for (const [k, n] of labels) {\n const [date, op, label] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byLabel, label, op, n);\n }\n for (const [k, n] of hours) {\n const [date, op, hour] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byHour!, hour, op, n);\n }\n for (const report of byDate.values()) {\n await sink.flush(report);\n }\n } catch (e) {\n onError?.(e);\n } finally {\n flushing = false;\n }\n}\n","/**\n * web/sink — reports the browser's coalesced rollup up to Crossdeck's ingest.\n *\n * Two differences from the Node sink, both forced by the browser:\n * - it authenticates with a PUBLISHABLE key (`cd_pk_`), never a secret — a\n * secret key cannot live in client code. (The ingest accepts publishable keys\n * for Buckets reports the same way the analytics SDK accepts them for events.)\n * - it uses `fetch(..., { keepalive: true })` so a report fired as the tab is\n * closing still completes.\n *\n * It performs ZERO database operations — it sends a summary, it does not read.\n */\nimport type { BucketsReport, Sink } from \"../sink\";\n\nconst DEFAULT_ENDPOINT = \"https://api.cross-deck.com/v1/buckets/report\";\n\nexport interface WebReportSinkConfig {\n /** The project's `cd_pk_` PUBLISHABLE key. */\n apiKey: string;\n endpoint?: string;\n}\n\nexport class WebReportSink implements Sink {\n private readonly endpoint: string;\n private readonly apiKey: string;\n\n constructor(config: WebReportSinkConfig) {\n this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;\n this.apiKey = config.apiKey;\n }\n\n async flush(report: BucketsReport): Promise<void> {\n const res = await fetch(this.endpoint, {\n method: \"POST\",\n keepalive: true,\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.apiKey}`,\n },\n body: JSON.stringify(report),\n });\n if (res.status !== 202) {\n throw new Error(`Buckets web report rejected: HTTP ${res.status}`);\n }\n }\n}\n","/**\n * web/context — the browser's tagging primitive.\n *\n * The Node collector rides AsyncLocalStorage to attribute reads to a bucket\n * across async fan-outs. The browser has no AsyncLocalStorage — but it doesn't\n * need one: a read (a `getDocs` call, an `onSnapshot` registration) is set up\n * SYNCHRONOUSLY, so a plain module-level \"current label\" captured at call time is\n * exact. `bucket(name, fn)` sets it for the synchronous body of `fn` and restores\n * it after — so the read inside picks up the name, and an `onSnapshot` listener\n * keeps that name for every future fire.\n */\n\nlet current: string | undefined;\n\n/** The bucket name in effect right now, or undefined (→ cascade to collection). */\nexport function currentLabel(): string | undefined {\n return current;\n}\n\n/**\n * Attribute every read SET UP inside `fn` to the bucket `name`:\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n * // → that listener's reads all show as \"pulse-map\", forever\n */\nexport function bucket<T>(name: string, fn: () => T): T {\n const prev = current;\n current = name;\n try {\n return fn();\n } finally {\n current = prev;\n }\n}\n","/**\n * web/firestore — drop-in wrappers for the three Firestore client read calls.\n *\n * Swap your import source and nothing else:\n * - import { getDoc, getDocs, onSnapshot } from \"firebase/firestore\"\n * + import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"\n *\n * Each wrapper calls the REAL Firestore function, counts the documents it\n * delivers (exactly what Firestore bills), labels it (your `bucket()` name, else\n * the collection), and returns the real result untouched. It can never change a\n * result or throw from the metering — same safety contract as the server trap.\n *\n * COUNTING:\n * - getDoc → 1 read\n * - getDocs → snapshot.size reads\n * - onSnapshot → on EVERY fire, the number of doc changes delivered\n * (first fire = all matching docs; each update = just the changed ones —\n * which is precisely what a listener is billed).\n */\nimport {\n getDoc as _getDoc,\n getDocs as _getDocs,\n onSnapshot as _onSnapshot,\n} from \"firebase/firestore\";\nimport { recordWeb } from \"./meter\";\nimport { currentLabel } from \"./context\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n// The real Firestore reads have many typed overloads; we pass arguments through\n// verbatim, so call them through loose aliases (the wrappers preserve behaviour).\nconst rawGetDoc = _getDoc as (...args: any[]) => Promise<any>;\nconst rawGetDocs = _getDocs as (...args: any[]) => Promise<any>;\nconst rawOnSnapshot = _onSnapshot as (...args: any[]) => any;\n\n/** Best-effort collection label from a ref/query. PURE; never throws. */\nfunction collLabel(ref: any): string {\n try {\n const path: string =\n (typeof ref?.path === \"string\" && ref.path) ||\n (ref?._query?.path?.segments?.join?.(\"/\") ?? \"\") ||\n \"\";\n if (path) {\n const segs = path.split(\"/\").filter(Boolean);\n // even segment count → document path (…/coll/id); odd → collection.\n const coll = segs.length % 2 === 0 ? segs[segs.length - 2] : segs[segs.length - 1];\n return coll ? `col:${coll}` : \"uncategorized\";\n }\n const segs = ref?._query?.path?.segments;\n if (Array.isArray(segs) && segs.length) return `col:${segs[segs.length - 1]}`;\n } catch {\n /* never throw from labelling */\n }\n return \"uncategorized\";\n}\n\nfunction meter(label: string, n: number): void {\n try {\n recordWeb(\"read\", n, label);\n } catch {\n /* best-effort */\n }\n}\n\n/** Count the docs a snapshot delivers: a query's changed docs, or 1 for a doc. */\nfunction countSnap(snap: any): number {\n try {\n if (typeof snap?.docChanges === \"function\") return snap.docChanges().length;\n } catch {\n /* fall through */\n }\n return 1;\n}\n\nexport function getDoc(ref: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(ref);\n return rawGetDoc(ref, ...rest).then((snap: any) => {\n meter(label, 1);\n return snap;\n });\n}\n\nexport function getDocs(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetDocs(query, ...rest).then((snap: any) => {\n meter(label, typeof snap?.size === \"number\" ? Math.max(snap.size, 1) : 1);\n return snap;\n });\n}\n\n/**\n * onSnapshot has several overloads — (ref, observer), (ref, onNext, onError,\n * onComplete), and either of those with a leading SnapshotListenOptions. We find\n * the next-handler wherever it is (a function or `observer.next`) and wrap it to\n * count on each fire, leaving every other argument exactly as passed.\n */\nexport function onSnapshot(ref: any, ...args: any[]): any {\n const label = currentLabel() ?? collLabel(ref);\n const wrapNext = (fn: any) =>\n typeof fn === \"function\"\n ? (snap: any) => {\n meter(label, countSnap(snap));\n return fn(snap);\n }\n : fn;\n\n const out = args.slice();\n // Leading options object (not a function, not an observer): skip it.\n let i = 0;\n if (out[0] && typeof out[0] !== \"function\" && !(\"next\" in out[0])) i = 1;\n\n if (out[i] && typeof out[i] === \"object\" && \"next\" in out[i]) {\n // observer form: clone with wrapped next\n out[i] = { ...out[i], next: wrapNext(out[i].next) };\n } else if (typeof out[i] === \"function\") {\n // callback form: wrap onNext (the first function)\n out[i] = wrapNext(out[i]);\n }\n\n return rawOnSnapshot(ref, ...out);\n}\n","/**\n * @cross-deck/buckets/web — the BROWSER collector.\n *\n * Most Firebase apps read straight from the browser (live `onSnapshot`\n * listeners, `getDocs`, `getDoc`) — reads billed to your project that a\n * server-side collector can never see. This adapter closes that hole.\n *\n * Setup (two lines + one import swap):\n *\n * import { initBucketsWeb, bucket } from \"@cross-deck/buckets/web\";\n * import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"; // was \"firebase/firestore\"\n *\n * initBucketsWeb({ apiKey: \"cd_pk_…\" }); // your PUBLISHABLE key\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n *\n * Every read those wrappers see is counted, labelled, and reported up the same\n * ingest pipe as the server collector — so the dashboard shows server AND browser\n * reads side by side.\n */\nimport { configureWebMeter, flushWeb, type WebMeterConfig } from \"./meter\";\nimport { WebReportSink } from \"./sink\";\n\nexport interface InitWebOptions {\n /** The project's `cd_pk_` PUBLISHABLE key (safe in client code). */\n apiKey: string;\n /** Override the report endpoint (defaults to Crossdeck's ingest). */\n endpoint?: string;\n /** How often to flush coalesced counts (ms). Default 60_000. */\n flushIntervalMs?: number;\n /** Notified when a flush fails, so a dropped window is never silent. */\n onError?: WebMeterConfig[\"onError\"];\n}\n\n/** Configure the browser collector once, at app start. */\nexport function initBucketsWeb(options: InitWebOptions): void {\n const sink = new WebReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });\n configureWebMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });\n}\n\n// The tagging verb + the metered read wrappers.\nexport { bucket } from \"./context\";\nexport { getDoc, getDocs, onSnapshot } from \"./firestore\";\nexport { flushWeb as flush } from \"./meter\";\n\n// The sink seam — for self-hosting the browser rollups instead of reporting to Crossdeck.\nexport { WebReportSink, type WebReportSinkConfig } from \"./sink\";\nexport type { BucketsReport, OpCounts, Sink } from \"../sink\";\n"]}
|
package/dist/web.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { getDoc as getDoc$1, getDocs as getDocs$1, onSnapshot as onSnapshot$1 } from 'firebase/firestore';
|
|
2
|
+
|
|
3
|
+
// src/web/meter.ts
|
|
4
|
+
var SEP = "";
|
|
5
|
+
var labelBuffer = /* @__PURE__ */ new Map();
|
|
6
|
+
var hourBuffer = /* @__PURE__ */ new Map();
|
|
7
|
+
var sink = null;
|
|
8
|
+
var flushIntervalMs = 6e4;
|
|
9
|
+
var onError = null;
|
|
10
|
+
var timer = null;
|
|
11
|
+
var flushing = false;
|
|
12
|
+
var lifecycleBound = false;
|
|
13
|
+
var MAX_BUFFER_KEYS = 5e3;
|
|
14
|
+
function configureWebMeter(config) {
|
|
15
|
+
sink = config.sink;
|
|
16
|
+
if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;
|
|
17
|
+
onError = config.onError ?? null;
|
|
18
|
+
}
|
|
19
|
+
var utcDate = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
20
|
+
var utcHour = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 13);
|
|
21
|
+
function ensureLoop() {
|
|
22
|
+
if (timer) return;
|
|
23
|
+
timer = setInterval(() => void flushWeb(), flushIntervalMs);
|
|
24
|
+
if (!lifecycleBound && typeof addEventListener === "function") {
|
|
25
|
+
lifecycleBound = true;
|
|
26
|
+
addEventListener("visibilitychange", () => {
|
|
27
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") void flushWeb();
|
|
28
|
+
});
|
|
29
|
+
addEventListener("pagehide", () => void flushWeb());
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function recordWeb(op, n, label) {
|
|
33
|
+
try {
|
|
34
|
+
if (!Number.isFinite(n) || n <= 0) return;
|
|
35
|
+
const date = utcDate();
|
|
36
|
+
const lk = date + SEP + op + SEP + label;
|
|
37
|
+
labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);
|
|
38
|
+
const hk = date + SEP + op + SEP + utcHour();
|
|
39
|
+
hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);
|
|
40
|
+
ensureLoop();
|
|
41
|
+
if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flushWeb();
|
|
42
|
+
} catch {
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function add(target, key, op, n) {
|
|
46
|
+
const bag = target[key] ??= {};
|
|
47
|
+
bag[op] = (bag[op] ?? 0) + n;
|
|
48
|
+
}
|
|
49
|
+
async function flushWeb() {
|
|
50
|
+
if (flushing) return;
|
|
51
|
+
if (!sink) {
|
|
52
|
+
labelBuffer.clear();
|
|
53
|
+
hourBuffer.clear();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (labelBuffer.size === 0 && hourBuffer.size === 0) return;
|
|
57
|
+
flushing = true;
|
|
58
|
+
const labels = new Map(labelBuffer);
|
|
59
|
+
const hours = new Map(hourBuffer);
|
|
60
|
+
labelBuffer.clear();
|
|
61
|
+
hourBuffer.clear();
|
|
62
|
+
try {
|
|
63
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
64
|
+
const reportFor = (date) => {
|
|
65
|
+
let r = byDate.get(date);
|
|
66
|
+
if (!r) {
|
|
67
|
+
r = { date, byLabel: {}, byHour: {} };
|
|
68
|
+
byDate.set(date, r);
|
|
69
|
+
}
|
|
70
|
+
return r;
|
|
71
|
+
};
|
|
72
|
+
for (const [k, n] of labels) {
|
|
73
|
+
const [date, op, label] = k.split(SEP);
|
|
74
|
+
add(reportFor(date).byLabel, label, op, n);
|
|
75
|
+
}
|
|
76
|
+
for (const [k, n] of hours) {
|
|
77
|
+
const [date, op, hour] = k.split(SEP);
|
|
78
|
+
add(reportFor(date).byHour, hour, op, n);
|
|
79
|
+
}
|
|
80
|
+
for (const report of byDate.values()) {
|
|
81
|
+
await sink.flush(report);
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {
|
|
84
|
+
onError?.(e);
|
|
85
|
+
} finally {
|
|
86
|
+
flushing = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/web/sink.ts
|
|
91
|
+
var DEFAULT_ENDPOINT = "https://api.cross-deck.com/v1/buckets/report";
|
|
92
|
+
var WebReportSink = class {
|
|
93
|
+
endpoint;
|
|
94
|
+
apiKey;
|
|
95
|
+
constructor(config) {
|
|
96
|
+
this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
|
97
|
+
this.apiKey = config.apiKey;
|
|
98
|
+
}
|
|
99
|
+
async flush(report) {
|
|
100
|
+
const res = await fetch(this.endpoint, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
keepalive: true,
|
|
103
|
+
headers: {
|
|
104
|
+
"content-type": "application/json",
|
|
105
|
+
authorization: `Bearer ${this.apiKey}`
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify(report)
|
|
108
|
+
});
|
|
109
|
+
if (res.status !== 202) {
|
|
110
|
+
throw new Error(`Buckets web report rejected: HTTP ${res.status}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// src/web/context.ts
|
|
116
|
+
var current;
|
|
117
|
+
function currentLabel() {
|
|
118
|
+
return current;
|
|
119
|
+
}
|
|
120
|
+
function bucket(name, fn) {
|
|
121
|
+
const prev = current;
|
|
122
|
+
current = name;
|
|
123
|
+
try {
|
|
124
|
+
return fn();
|
|
125
|
+
} finally {
|
|
126
|
+
current = prev;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
var rawGetDoc = getDoc$1;
|
|
130
|
+
var rawGetDocs = getDocs$1;
|
|
131
|
+
var rawOnSnapshot = onSnapshot$1;
|
|
132
|
+
function collLabel(ref) {
|
|
133
|
+
try {
|
|
134
|
+
const path = typeof ref?.path === "string" && ref.path || (ref?._query?.path?.segments?.join?.("/") ?? "") || "";
|
|
135
|
+
if (path) {
|
|
136
|
+
const segs2 = path.split("/").filter(Boolean);
|
|
137
|
+
const coll = segs2.length % 2 === 0 ? segs2[segs2.length - 2] : segs2[segs2.length - 1];
|
|
138
|
+
return coll ? `col:${coll}` : "uncategorized";
|
|
139
|
+
}
|
|
140
|
+
const segs = ref?._query?.path?.segments;
|
|
141
|
+
if (Array.isArray(segs) && segs.length) return `col:${segs[segs.length - 1]}`;
|
|
142
|
+
} catch {
|
|
143
|
+
}
|
|
144
|
+
return "uncategorized";
|
|
145
|
+
}
|
|
146
|
+
function meter(label, n) {
|
|
147
|
+
try {
|
|
148
|
+
recordWeb("read", n, label);
|
|
149
|
+
} catch {
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function countSnap(snap) {
|
|
153
|
+
try {
|
|
154
|
+
if (typeof snap?.docChanges === "function") return snap.docChanges().length;
|
|
155
|
+
} catch {
|
|
156
|
+
}
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
function getDoc(ref, ...rest) {
|
|
160
|
+
const label = currentLabel() ?? collLabel(ref);
|
|
161
|
+
return rawGetDoc(ref, ...rest).then((snap) => {
|
|
162
|
+
meter(label, 1);
|
|
163
|
+
return snap;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function getDocs(query, ...rest) {
|
|
167
|
+
const label = currentLabel() ?? collLabel(query);
|
|
168
|
+
return rawGetDocs(query, ...rest).then((snap) => {
|
|
169
|
+
meter(label, typeof snap?.size === "number" ? Math.max(snap.size, 1) : 1);
|
|
170
|
+
return snap;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function onSnapshot(ref, ...args) {
|
|
174
|
+
const label = currentLabel() ?? collLabel(ref);
|
|
175
|
+
const wrapNext = (fn) => typeof fn === "function" ? (snap) => {
|
|
176
|
+
meter(label, countSnap(snap));
|
|
177
|
+
return fn(snap);
|
|
178
|
+
} : fn;
|
|
179
|
+
const out = args.slice();
|
|
180
|
+
let i = 0;
|
|
181
|
+
if (out[0] && typeof out[0] !== "function" && !("next" in out[0])) i = 1;
|
|
182
|
+
if (out[i] && typeof out[i] === "object" && "next" in out[i]) {
|
|
183
|
+
out[i] = { ...out[i], next: wrapNext(out[i].next) };
|
|
184
|
+
} else if (typeof out[i] === "function") {
|
|
185
|
+
out[i] = wrapNext(out[i]);
|
|
186
|
+
}
|
|
187
|
+
return rawOnSnapshot(ref, ...out);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/web/index.ts
|
|
191
|
+
function initBucketsWeb(options) {
|
|
192
|
+
const sink2 = new WebReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });
|
|
193
|
+
configureWebMeter({ sink: sink2, flushIntervalMs: options.flushIntervalMs, onError: options.onError });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export { WebReportSink, bucket, flushWeb as flush, getDoc, getDocs, initBucketsWeb, onSnapshot };
|
|
197
|
+
//# sourceMappingURL=web.mjs.map
|
|
198
|
+
//# sourceMappingURL=web.mjs.map
|
package/dist/web.mjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/web/meter.ts","../src/web/sink.ts","../src/web/context.ts","../src/web/firestore.ts","../src/web/index.ts"],"names":["_getDoc","_getDocs","_onSnapshot","segs","sink"],"mappings":"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","file":"web.mjs","sourcesContent":["/**\n * web/meter — the browser read meter. Same contract as the Node meter (count in\n * memory, flush ~1/min, never throw into the app), adapted to the browser:\n *\n * - no AsyncLocalStorage — labels come from web/context (synchronous),\n * - \"shutdown\" is the tab going hidden/closed, so we also flush on\n * visibilitychange→hidden and pagehide (a `fetch(..., {keepalive:true})`\n * survives the unload),\n * - it talks to a Sink exactly like the Node meter, so the wire shape is\n * identical and the same ingest receives both.\n */\nimport type { Sink, BucketsReport, OpCounts } from \"../sink\";\n\nexport type OpType = \"read\" | \"write\" | \"delete\";\n\n// ASCII Unit Separator — a bucket/collection name never contains it, so the\n// composite key splits back cleanly.\nconst SEP = \"\\u001f\"; // ASCII Unit Separator\n\n/** key = date <US> op <US> label → count */\nconst labelBuffer = new Map<string, number>();\n/** key = date <US> op <US> hour → count */\nconst hourBuffer = new Map<string, number>();\n\nlet sink: Sink | null = null;\nlet flushIntervalMs = 60_000;\nlet onError: ((e: unknown) => void) | null = null;\nlet timer: ReturnType<typeof setInterval> | null = null;\nlet flushing = false;\nlet lifecycleBound = false;\nconst MAX_BUFFER_KEYS = 5_000;\n\nexport interface WebMeterConfig {\n sink: Sink;\n flushIntervalMs?: number;\n onError?: (e: unknown) => void;\n}\n\nexport function configureWebMeter(config: WebMeterConfig): void {\n sink = config.sink;\n if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;\n onError = config.onError ?? null;\n}\n\nconst utcDate = (): string => new Date().toISOString().slice(0, 10);\nconst utcHour = (): string => new Date().toISOString().slice(11, 13);\n\nfunction ensureLoop(): void {\n if (timer) return;\n timer = setInterval(() => void flushWeb(), flushIntervalMs);\n if (!lifecycleBound && typeof addEventListener === \"function\") {\n lifecycleBound = true;\n // The tab being hidden or torn down is the browser's \"shutdown\" — flush the\n // last window. keepalive on the sink's fetch lets it complete during unload.\n addEventListener(\"visibilitychange\", () => {\n if (typeof document !== \"undefined\" && document.visibilityState === \"hidden\") void flushWeb();\n });\n addEventListener(\"pagehide\", () => void flushWeb());\n }\n}\n\n/** Count `n` ops of `op` against `label`. Never throws. */\nexport function recordWeb(op: OpType, n: number, label: string): void {\n try {\n if (!Number.isFinite(n) || n <= 0) return;\n const date = utcDate();\n const lk = date + SEP + op + SEP + label;\n labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);\n const hk = date + SEP + op + SEP + utcHour();\n hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);\n ensureLoop();\n if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flushWeb();\n } catch {\n /* metering is best-effort — never disturb the page */\n }\n}\n\nfunction add(target: Record<string, OpCounts>, key: string, op: OpType, n: number): void {\n const bag = (target[key] ??= {});\n bag[op] = (bag[op] ?? 0) + n;\n}\n\n/** Coalesce the buffer into one report per UTC day and hand each to the Sink. */\nexport async function flushWeb(): Promise<void> {\n if (flushing) return;\n if (!sink) {\n labelBuffer.clear();\n hourBuffer.clear();\n return;\n }\n if (labelBuffer.size === 0 && hourBuffer.size === 0) return;\n flushing = true;\n\n const labels = new Map(labelBuffer);\n const hours = new Map(hourBuffer);\n labelBuffer.clear();\n hourBuffer.clear();\n\n try {\n const byDate = new Map<string, BucketsReport>();\n const reportFor = (date: string): BucketsReport => {\n let r = byDate.get(date);\n if (!r) {\n r = { date, byLabel: {}, byHour: {} };\n byDate.set(date, r);\n }\n return r;\n };\n for (const [k, n] of labels) {\n const [date, op, label] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byLabel, label, op, n);\n }\n for (const [k, n] of hours) {\n const [date, op, hour] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byHour!, hour, op, n);\n }\n for (const report of byDate.values()) {\n await sink.flush(report);\n }\n } catch (e) {\n onError?.(e);\n } finally {\n flushing = false;\n }\n}\n","/**\n * web/sink — reports the browser's coalesced rollup up to Crossdeck's ingest.\n *\n * Two differences from the Node sink, both forced by the browser:\n * - it authenticates with a PUBLISHABLE key (`cd_pk_`), never a secret — a\n * secret key cannot live in client code. (The ingest accepts publishable keys\n * for Buckets reports the same way the analytics SDK accepts them for events.)\n * - it uses `fetch(..., { keepalive: true })` so a report fired as the tab is\n * closing still completes.\n *\n * It performs ZERO database operations — it sends a summary, it does not read.\n */\nimport type { BucketsReport, Sink } from \"../sink\";\n\nconst DEFAULT_ENDPOINT = \"https://api.cross-deck.com/v1/buckets/report\";\n\nexport interface WebReportSinkConfig {\n /** The project's `cd_pk_` PUBLISHABLE key. */\n apiKey: string;\n endpoint?: string;\n}\n\nexport class WebReportSink implements Sink {\n private readonly endpoint: string;\n private readonly apiKey: string;\n\n constructor(config: WebReportSinkConfig) {\n this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;\n this.apiKey = config.apiKey;\n }\n\n async flush(report: BucketsReport): Promise<void> {\n const res = await fetch(this.endpoint, {\n method: \"POST\",\n keepalive: true,\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.apiKey}`,\n },\n body: JSON.stringify(report),\n });\n if (res.status !== 202) {\n throw new Error(`Buckets web report rejected: HTTP ${res.status}`);\n }\n }\n}\n","/**\n * web/context — the browser's tagging primitive.\n *\n * The Node collector rides AsyncLocalStorage to attribute reads to a bucket\n * across async fan-outs. The browser has no AsyncLocalStorage — but it doesn't\n * need one: a read (a `getDocs` call, an `onSnapshot` registration) is set up\n * SYNCHRONOUSLY, so a plain module-level \"current label\" captured at call time is\n * exact. `bucket(name, fn)` sets it for the synchronous body of `fn` and restores\n * it after — so the read inside picks up the name, and an `onSnapshot` listener\n * keeps that name for every future fire.\n */\n\nlet current: string | undefined;\n\n/** The bucket name in effect right now, or undefined (→ cascade to collection). */\nexport function currentLabel(): string | undefined {\n return current;\n}\n\n/**\n * Attribute every read SET UP inside `fn` to the bucket `name`:\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n * // → that listener's reads all show as \"pulse-map\", forever\n */\nexport function bucket<T>(name: string, fn: () => T): T {\n const prev = current;\n current = name;\n try {\n return fn();\n } finally {\n current = prev;\n }\n}\n","/**\n * web/firestore — drop-in wrappers for the three Firestore client read calls.\n *\n * Swap your import source and nothing else:\n * - import { getDoc, getDocs, onSnapshot } from \"firebase/firestore\"\n * + import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"\n *\n * Each wrapper calls the REAL Firestore function, counts the documents it\n * delivers (exactly what Firestore bills), labels it (your `bucket()` name, else\n * the collection), and returns the real result untouched. It can never change a\n * result or throw from the metering — same safety contract as the server trap.\n *\n * COUNTING:\n * - getDoc → 1 read\n * - getDocs → snapshot.size reads\n * - onSnapshot → on EVERY fire, the number of doc changes delivered\n * (first fire = all matching docs; each update = just the changed ones —\n * which is precisely what a listener is billed).\n */\nimport {\n getDoc as _getDoc,\n getDocs as _getDocs,\n onSnapshot as _onSnapshot,\n} from \"firebase/firestore\";\nimport { recordWeb } from \"./meter\";\nimport { currentLabel } from \"./context\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n// The real Firestore reads have many typed overloads; we pass arguments through\n// verbatim, so call them through loose aliases (the wrappers preserve behaviour).\nconst rawGetDoc = _getDoc as (...args: any[]) => Promise<any>;\nconst rawGetDocs = _getDocs as (...args: any[]) => Promise<any>;\nconst rawOnSnapshot = _onSnapshot as (...args: any[]) => any;\n\n/** Best-effort collection label from a ref/query. PURE; never throws. */\nfunction collLabel(ref: any): string {\n try {\n const path: string =\n (typeof ref?.path === \"string\" && ref.path) ||\n (ref?._query?.path?.segments?.join?.(\"/\") ?? \"\") ||\n \"\";\n if (path) {\n const segs = path.split(\"/\").filter(Boolean);\n // even segment count → document path (…/coll/id); odd → collection.\n const coll = segs.length % 2 === 0 ? segs[segs.length - 2] : segs[segs.length - 1];\n return coll ? `col:${coll}` : \"uncategorized\";\n }\n const segs = ref?._query?.path?.segments;\n if (Array.isArray(segs) && segs.length) return `col:${segs[segs.length - 1]}`;\n } catch {\n /* never throw from labelling */\n }\n return \"uncategorized\";\n}\n\nfunction meter(label: string, n: number): void {\n try {\n recordWeb(\"read\", n, label);\n } catch {\n /* best-effort */\n }\n}\n\n/** Count the docs a snapshot delivers: a query's changed docs, or 1 for a doc. */\nfunction countSnap(snap: any): number {\n try {\n if (typeof snap?.docChanges === \"function\") return snap.docChanges().length;\n } catch {\n /* fall through */\n }\n return 1;\n}\n\nexport function getDoc(ref: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(ref);\n return rawGetDoc(ref, ...rest).then((snap: any) => {\n meter(label, 1);\n return snap;\n });\n}\n\nexport function getDocs(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetDocs(query, ...rest).then((snap: any) => {\n meter(label, typeof snap?.size === \"number\" ? Math.max(snap.size, 1) : 1);\n return snap;\n });\n}\n\n/**\n * onSnapshot has several overloads — (ref, observer), (ref, onNext, onError,\n * onComplete), and either of those with a leading SnapshotListenOptions. We find\n * the next-handler wherever it is (a function or `observer.next`) and wrap it to\n * count on each fire, leaving every other argument exactly as passed.\n */\nexport function onSnapshot(ref: any, ...args: any[]): any {\n const label = currentLabel() ?? collLabel(ref);\n const wrapNext = (fn: any) =>\n typeof fn === \"function\"\n ? (snap: any) => {\n meter(label, countSnap(snap));\n return fn(snap);\n }\n : fn;\n\n const out = args.slice();\n // Leading options object (not a function, not an observer): skip it.\n let i = 0;\n if (out[0] && typeof out[0] !== \"function\" && !(\"next\" in out[0])) i = 1;\n\n if (out[i] && typeof out[i] === \"object\" && \"next\" in out[i]) {\n // observer form: clone with wrapped next\n out[i] = { ...out[i], next: wrapNext(out[i].next) };\n } else if (typeof out[i] === \"function\") {\n // callback form: wrap onNext (the first function)\n out[i] = wrapNext(out[i]);\n }\n\n return rawOnSnapshot(ref, ...out);\n}\n","/**\n * @cross-deck/buckets/web — the BROWSER collector.\n *\n * Most Firebase apps read straight from the browser (live `onSnapshot`\n * listeners, `getDocs`, `getDoc`) — reads billed to your project that a\n * server-side collector can never see. This adapter closes that hole.\n *\n * Setup (two lines + one import swap):\n *\n * import { initBucketsWeb, bucket } from \"@cross-deck/buckets/web\";\n * import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"; // was \"firebase/firestore\"\n *\n * initBucketsWeb({ apiKey: \"cd_pk_…\" }); // your PUBLISHABLE key\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n *\n * Every read those wrappers see is counted, labelled, and reported up the same\n * ingest pipe as the server collector — so the dashboard shows server AND browser\n * reads side by side.\n */\nimport { configureWebMeter, flushWeb, type WebMeterConfig } from \"./meter\";\nimport { WebReportSink } from \"./sink\";\n\nexport interface InitWebOptions {\n /** The project's `cd_pk_` PUBLISHABLE key (safe in client code). */\n apiKey: string;\n /** Override the report endpoint (defaults to Crossdeck's ingest). */\n endpoint?: string;\n /** How often to flush coalesced counts (ms). Default 60_000. */\n flushIntervalMs?: number;\n /** Notified when a flush fails, so a dropped window is never silent. */\n onError?: WebMeterConfig[\"onError\"];\n}\n\n/** Configure the browser collector once, at app start. */\nexport function initBucketsWeb(options: InitWebOptions): void {\n const sink = new WebReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });\n configureWebMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });\n}\n\n// The tagging verb + the metered read wrappers.\nexport { bucket } from \"./context\";\nexport { getDoc, getDocs, onSnapshot } from \"./firestore\";\nexport { flushWeb as flush } from \"./meter\";\n\n// The sink seam — for self-hosting the browser rollups instead of reporting to Crossdeck.\nexport { WebReportSink, type WebReportSinkConfig } from \"./sink\";\nexport type { BucketsReport, OpCounts, Sink } from \"../sink\";\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cross-deck/buckets",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Know exactly what every database read costs you — and who caused it. A tiny, never-throws read-cost collector for Firestore.",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Know exactly what every database read costs you — and who caused it. A tiny, never-throws read-cost collector for Firestore, server AND browser.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crossdeck",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"import": "./dist/index.mjs",
|
|
14
14
|
"require": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./web": {
|
|
17
|
+
"types": "./dist/web.d.ts",
|
|
18
|
+
"import": "./dist/web.mjs",
|
|
19
|
+
"require": "./dist/web.js"
|
|
15
20
|
}
|
|
16
21
|
},
|
|
17
22
|
"files": [
|
|
@@ -48,9 +53,13 @@
|
|
|
48
53
|
"url": "https://github.com/Crossdeckhq/buckets-oss/issues"
|
|
49
54
|
},
|
|
50
55
|
"peerDependencies": {
|
|
56
|
+
"firebase": ">=9",
|
|
51
57
|
"firebase-admin": ">=11"
|
|
52
58
|
},
|
|
53
59
|
"peerDependenciesMeta": {
|
|
60
|
+
"firebase": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
54
63
|
"firebase-admin": {
|
|
55
64
|
"optional": true
|
|
56
65
|
}
|
|
@@ -60,7 +69,8 @@
|
|
|
60
69
|
"firebase-admin": "^12.0.0",
|
|
61
70
|
"tsup": "^8.0.0",
|
|
62
71
|
"typescript": "^5.4.0",
|
|
63
|
-
"vitest": "^1.6.0"
|
|
72
|
+
"vitest": "^1.6.0",
|
|
73
|
+
"firebase": "^10.0.0"
|
|
64
74
|
},
|
|
65
75
|
"engines": {
|
|
66
76
|
"node": ">=18"
|