luchy 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +314 -0
- package/dist/api/index.d.ts +112 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +125 -0
- package/dist/api/schema.d.ts +457 -0
- package/dist/client.d.ts +23 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +314 -0
- package/dist/script/luchy.js +357 -0
- package/dist/script/luchy.js.br +0 -0
- package/dist/script/luchy.js.gz +0 -0
- package/dist/script/luchy.min.js +357 -0
- package/dist/script/luchy.min.js.br +0 -0
- package/dist/script/luchy.min.js.gz +0 -0
- package/dist/server.d.ts +71 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +40 -0
- package/package.json +65 -9
- package/index.js +0 -1
package/README.md
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# Luchy Tracker
|
|
2
|
+
|
|
3
|
+
A lightweight, privacy-focused analytics tracker for web applications. Built for CDN deployment with automatic compression and versioning.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🚀 **Lightweight**: ~3KB minified, ~1.5KB compressed
|
|
8
|
+
- 📊 **Privacy-focused**: No cookies, no personal data collection
|
|
9
|
+
- 🔄 **Auto-tracking**: Pageviews, outbound links, hash routing
|
|
10
|
+
- 📦 **CDN-ready**: Optimized for global distribution
|
|
11
|
+
- 🗜️ **Compressed**: Gzip and Brotli compression included
|
|
12
|
+
- 🏷️ **Versioned**: Commit-based versioning for safe deployments
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
### Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install luchy
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Basic Usage
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<script
|
|
26
|
+
src="https://cdn.luchy.app/luchy.min.js"
|
|
27
|
+
data-api-key="your-api-key"
|
|
28
|
+
data-auto-pageviews
|
|
29
|
+
></script>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Advanced Usage
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<script
|
|
36
|
+
src="https://cdn.luchy.app/luchy.min.js"
|
|
37
|
+
data-api-key="your-api-key"
|
|
38
|
+
data-endpoint="https://api.luchy.app/ingest"
|
|
39
|
+
data-auto-pageviews
|
|
40
|
+
data-auto-outbound
|
|
41
|
+
data-hash-routing
|
|
42
|
+
data-track-localhost
|
|
43
|
+
></script>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Data Attributes
|
|
47
|
+
|
|
48
|
+
| Attribute | Description | Required |
|
|
49
|
+
| ---------------------- | --------------------------------------- | -------- |
|
|
50
|
+
| `data-api-key` | Your Luchy API key | ✅ Yes |
|
|
51
|
+
| `data-endpoint` | Custom API endpoint | ❌ No |
|
|
52
|
+
| `data-auto-pageviews` | Enable automatic pageview tracking | ❌ No |
|
|
53
|
+
| `data-auto-outbound` | Enable automatic outbound link tracking | ❌ No |
|
|
54
|
+
| `data-auto-events` | Enable automatic data attribute event tracking | ❌ No |
|
|
55
|
+
| `data-hash-routing` | Enable hash routing support | ❌ No |
|
|
56
|
+
| `data-track-localhost` | Track localhost traffic | ❌ No |
|
|
57
|
+
|
|
58
|
+
## Data Attribute Events
|
|
59
|
+
|
|
60
|
+
Track custom events without JavaScript by adding `data-luchy-event` to any HTML element. Clicks are detected automatically via event delegation.
|
|
61
|
+
|
|
62
|
+
```html
|
|
63
|
+
<!-- Simple event -->
|
|
64
|
+
<button data-luchy-event="cta-click">Sign Up</button>
|
|
65
|
+
|
|
66
|
+
<!-- With a payload (data-luchy-payload-*) -->
|
|
67
|
+
<a data-luchy-event="post-click" data-luchy-payload-slug="hello-world" href="/blog/hello-world">
|
|
68
|
+
Read Post
|
|
69
|
+
</a>
|
|
70
|
+
|
|
71
|
+
<!-- Multiple keys, dashes convert to underscores -->
|
|
72
|
+
<!-- data-luchy-payload-plan-tier becomes { plan_tier: "enterprise" } -->
|
|
73
|
+
<button
|
|
74
|
+
data-luchy-event="purchase"
|
|
75
|
+
data-luchy-payload-plan-tier="enterprise"
|
|
76
|
+
data-luchy-payload-source="pricing-page"
|
|
77
|
+
>
|
|
78
|
+
Buy Now
|
|
79
|
+
</button>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
> `data-luchy-prop-*` is the original spelling and keeps working — it is written
|
|
83
|
+
> into pages we do not control, so it is never going away. New markup should use
|
|
84
|
+
> `data-luchy-payload-*`, which matches the field name on the wire and in the
|
|
85
|
+
> dashboard.
|
|
86
|
+
|
|
87
|
+
When an element has `data-luchy-event`, it takes priority over outbound link tracking to avoid duplicate events. Disable with `data-auto-events="false"` on the script tag.
|
|
88
|
+
|
|
89
|
+
## Manual Tracking
|
|
90
|
+
|
|
91
|
+
```javascript
|
|
92
|
+
// Track a pageview
|
|
93
|
+
window.luchy.trackPageview();
|
|
94
|
+
|
|
95
|
+
// Track a custom event. The second argument is the event payload —
|
|
96
|
+
// it is sent as `payload` and shows up as the event's properties.
|
|
97
|
+
window.luchy.trackEvent('Button Click', {
|
|
98
|
+
button: 'signup',
|
|
99
|
+
page: 'home'
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Enable auto-tracking features
|
|
103
|
+
window.luchy.enableAutoPageviews();
|
|
104
|
+
window.luchy.enableAutoOutboundTracking();
|
|
105
|
+
window.luchy.enableHashRouting();
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Server-Side Tracking
|
|
109
|
+
|
|
110
|
+
The browser script can only see what happens in a page. A server sees what the
|
|
111
|
+
application actually *did* — an order placed, a payment recorded, a webhook
|
|
112
|
+
processed — and those are usually the events worth having.
|
|
113
|
+
|
|
114
|
+
`luchy/server` carries no browser globals, so it runs in Node, Bun,
|
|
115
|
+
Deno and Cloudflare Workers:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { createServerTracker } from 'luchy/server';
|
|
119
|
+
|
|
120
|
+
const luchy = createServerTracker({
|
|
121
|
+
apiKey: process.env.LUCHY_API_KEY!,
|
|
122
|
+
// optional: point at a self-hosted dashboard
|
|
123
|
+
endpoint: 'https://dash.luchy.app/api/ingest',
|
|
124
|
+
onError: (error) => logger.warn('[luchy] event dropped', error)
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await luchy.trackEvent({
|
|
128
|
+
name: 'order:placed',
|
|
129
|
+
pathname: '/checkout',
|
|
130
|
+
type: 'server',
|
|
131
|
+
payload: { plan: 'pro', amount: 29 }
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Nothing here ever rejects — analytics must not break the request it is
|
|
136
|
+
describing. Pass `onError` if you want failures in your logs.
|
|
137
|
+
|
|
138
|
+
Fire it after the response so it costs no latency. On Cloudflare Workers:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
ctx.waitUntil(luchy.trackEvent({ name: 'order:placed', pathname: '/checkout' }));
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## API client (`luchy/api`)
|
|
145
|
+
|
|
146
|
+
`createServerTracker` covers the two calls most apps make. `luchy/api`
|
|
147
|
+
is the whole API: a typed client generated from the dashboard's own OpenAPI
|
|
148
|
+
document, with tracking *and* reading on it. It has no DOM globals and no node
|
|
149
|
+
built-ins, so the same import works on a server and in a browser.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { createLuchyClient } from 'luchy/api';
|
|
153
|
+
|
|
154
|
+
const luchy = createLuchyClient({
|
|
155
|
+
apiKey: LUCHY_API_KEY,
|
|
156
|
+
// optional: point at a self-hosted dashboard's API root
|
|
157
|
+
endpoint: 'https://dash.luchy.app/api',
|
|
158
|
+
onError: (error) => console.warn('[luchy] event dropped', error)
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Tracking never rejects, on either side of the wire.
|
|
162
|
+
await luchy.trackEvent({
|
|
163
|
+
name: 'order:placed',
|
|
164
|
+
pathname: '/checkout',
|
|
165
|
+
payload: { plan: 'pro', amount: 29 }
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Reads do — a chart with silently missing numbers is worse than an error.
|
|
169
|
+
const { results } = await luchy.query({
|
|
170
|
+
date_range: '30d',
|
|
171
|
+
metrics: ['pageviews', 'visitors'],
|
|
172
|
+
dimensions: ['event:page']
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Failed reads throw `LuchyApiError`, which carries the HTTP status and the
|
|
177
|
+
error body the API sent. For anything without a convenience method, `luchy.api`
|
|
178
|
+
is the underlying [`openapi-fetch`](https://openapi-ts.dev/openapi-fetch/)
|
|
179
|
+
client, typed against every documented endpoint:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const { data, error } = await luchy.api.GET('/health');
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The request and response types come from the document too, so they are worth
|
|
186
|
+
importing rather than restating: `EventInput`, `PageviewInput`,
|
|
187
|
+
`IngestSuccess`, `QueryRequest`, `QueryResponse`, plus the raw `paths` and
|
|
188
|
+
`components`.
|
|
189
|
+
|
|
190
|
+
### Where the types come from
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
apps/dash zod route schemas
|
|
194
|
+
→ bun run openapi:emit (in apps/dash)
|
|
195
|
+
→ packages/tracker/openapi.json
|
|
196
|
+
→ bun run generate (in packages/tracker)
|
|
197
|
+
→ src/api/schema.d.ts
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`openapi.json` is checked in: it is the wire contract, so a change to the API's
|
|
201
|
+
shape shows up as a reviewable diff in the commit that caused it, and the
|
|
202
|
+
client can be regenerated without a dashboard running anywhere. `schema.d.ts`
|
|
203
|
+
is generated too — never edit either by hand. Change the zod schemas in
|
|
204
|
+
`apps/dash`, re-run both steps, commit the result.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
### Build Scripts
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
# Build the CDN bundle *and* the importable package entry points
|
|
212
|
+
npm run build
|
|
213
|
+
|
|
214
|
+
# Only the package entry points (dist/index.js, dist/server.js + types)
|
|
215
|
+
npm run build:lib
|
|
216
|
+
|
|
217
|
+
# Upload to R2 (requires Wrangler setup)
|
|
218
|
+
npm run upload
|
|
219
|
+
|
|
220
|
+
# Build and deploy everything
|
|
221
|
+
npm run deploy
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### File Sizes
|
|
225
|
+
|
|
226
|
+
- **Minified**: 3.0 KB
|
|
227
|
+
- **Gzipped**: 1.7 KB (43% smaller)
|
|
228
|
+
- **Brotli**: 1.5 KB (50% smaller)
|
|
229
|
+
|
|
230
|
+
### Generated Files
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
dist/script/
|
|
234
|
+
├── luchy.js (unminified)
|
|
235
|
+
├── luchy.min.js (minified)
|
|
236
|
+
├── luchy.js.gz (gzipped)
|
|
237
|
+
├── luchy.min.js.gz (gzipped)
|
|
238
|
+
├── luchy.js.br (brotli)
|
|
239
|
+
└── luchy.min.js.br (brotli)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## CDN Deployment
|
|
243
|
+
|
|
244
|
+
### R2 Upload Structure
|
|
245
|
+
|
|
246
|
+
The deploy script uploads files to two locations:
|
|
247
|
+
|
|
248
|
+
**Root Level:**
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
bucket/
|
|
252
|
+
├── luchy.js
|
|
253
|
+
├── luchy.min.js
|
|
254
|
+
├── luchy.js.gz
|
|
255
|
+
├── luchy.min.js.gz
|
|
256
|
+
├── luchy.js.br
|
|
257
|
+
└── luchy.min.js.br
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Versioned Level:**
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
bucket/v/{commit-hash}/
|
|
264
|
+
├── luchy.js
|
|
265
|
+
├── luchy.min.js
|
|
266
|
+
├── luchy.js.gz
|
|
267
|
+
├── luchy.min.js.gz
|
|
268
|
+
├── luchy.js.br
|
|
269
|
+
└── luchy.min.js.br
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### CDN Usage Examples
|
|
273
|
+
|
|
274
|
+
```html
|
|
275
|
+
<!-- Latest version -->
|
|
276
|
+
<script src="https://cdn.luchy.app/luchy.min.js"></script>
|
|
277
|
+
|
|
278
|
+
<!-- Specific version -->
|
|
279
|
+
<script src="https://cdn.luchy.app/v/abc123/luchy.min.js"></script>
|
|
280
|
+
|
|
281
|
+
<!-- With compression (automatic) -->
|
|
282
|
+
<script src="https://cdn.luchy.app/luchy.min.js"></script>
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Deploying
|
|
286
|
+
|
|
287
|
+
`bun run deploy` builds, uploads to R2, **purges the CDN cache** and then checks
|
|
288
|
+
what the edge actually serves.
|
|
289
|
+
|
|
290
|
+
The purge is not optional. R2 has the new bytes the instant the upload finishes,
|
|
291
|
+
but `cdn.luchy.app` is cached, so skipping it leaves every customer on the
|
|
292
|
+
previous build while the deploy prints nothing but green. For that reason the
|
|
293
|
+
script refuses to start when `CLOUDFLARE_API_TOKEN` is missing, rather than
|
|
294
|
+
uploading and half-shipping.
|
|
295
|
+
|
|
296
|
+
The verification step fetches `https://cdn.luchy.app/luchy.min.js` with no
|
|
297
|
+
cache-busting query string on purpose — a unique query string bypasses the edge
|
|
298
|
+
cache and would pass even when real visitors are still getting the old script.
|
|
299
|
+
|
|
300
|
+
Root paths are purged; `v/{commit-hash}/` is immutable and never needs it.
|
|
301
|
+
|
|
302
|
+
## Environment Variables
|
|
303
|
+
|
|
304
|
+
- `R2_BUCKET`: R2 bucket name (default: `cdn-luchy-app`)
|
|
305
|
+
- `CLOUDFLARE_API_TOKEN`: **required.** Needs the Zone > Cache Purge permission
|
|
306
|
+
on the `luchy.app` zone.
|
|
307
|
+
- `CLOUDFLARE_ACCOUNT_ID`: defaults to the account that owns `cdn-luchy-app`.
|
|
308
|
+
Pinned because `wrangler r2 object put` refuses to pick between accounts in a
|
|
309
|
+
non-interactive shell.
|
|
310
|
+
- `CLOUDFLARE_ZONE_ID`: defaults to the `luchy.app` zone.
|
|
311
|
+
|
|
312
|
+
## License
|
|
313
|
+
|
|
314
|
+
MIT
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Client } from 'openapi-fetch';
|
|
2
|
+
import { components, paths } from './schema';
|
|
3
|
+
/**
|
|
4
|
+
* The typed Luchy API client.
|
|
5
|
+
*
|
|
6
|
+
* `schema.d.ts` next to this file is generated from `openapi.json`, which is
|
|
7
|
+
* itself emitted from the zod route definitions in `apps/dash`. Nothing in this
|
|
8
|
+
* package restates the wire format by hand — that is what let the
|
|
9
|
+
* `props`/`payload` split ship unnoticed — so a field renamed in the API turns
|
|
10
|
+
* into a type error here rather than into silently-dropped data in production.
|
|
11
|
+
*
|
|
12
|
+
* This module is isomorphic on purpose: no DOM globals, no node built-ins, no
|
|
13
|
+
* `import.meta.env`. It runs in browsers, Node, Bun, Deno and Cloudflare
|
|
14
|
+
* Workers, which is why the transport is injectable rather than assumed.
|
|
15
|
+
*/
|
|
16
|
+
export type { components, paths };
|
|
17
|
+
/** The body accepted by `POST /ingest/event`. */
|
|
18
|
+
export type EventInput = components['schemas']['EventInput'];
|
|
19
|
+
/** The body accepted by `POST /ingest/pageview`. */
|
|
20
|
+
export type PageviewInput = components['schemas']['PageviewInput'];
|
|
21
|
+
/** What both ingest endpoints answer with on success. */
|
|
22
|
+
export type IngestSuccess = components['schemas']['SuccessResponse'];
|
|
23
|
+
/** The body accepted by `POST /query`. */
|
|
24
|
+
export type QueryRequest = components['schemas']['QueryRequest'];
|
|
25
|
+
/** The result of a successful `POST /query`. */
|
|
26
|
+
export type QueryResponse = components['schemas']['QueryResponse'];
|
|
27
|
+
/** The `GET /health` body. */
|
|
28
|
+
export type HealthResponse = components['schemas']['HealthResponse'];
|
|
29
|
+
/** A 400 from any endpoint. */
|
|
30
|
+
export type ValidationError = components['schemas']['ValidationErrorResponse'];
|
|
31
|
+
/** A 500 from any endpoint. */
|
|
32
|
+
export type ServerError = components['schemas']['ServerErrorResponse'];
|
|
33
|
+
/** The raw typed client, with every documented endpoint on it. */
|
|
34
|
+
export type LuchyApi = Client<paths>;
|
|
35
|
+
export type LuchyClientOptions = {
|
|
36
|
+
/** A Luchy API key. Sent as `Authorization: Bearer <key>`. */
|
|
37
|
+
apiKey: string;
|
|
38
|
+
/** The API root, without a trailing slash. Defaults to the hosted API. */
|
|
39
|
+
endpoint?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Transport override. Useful for tests, for runtimes that hand you a scoped
|
|
42
|
+
* `fetch` (Cloudflare service bindings), and for retry/proxy wrappers.
|
|
43
|
+
*/
|
|
44
|
+
fetch?: typeof globalThis.fetch;
|
|
45
|
+
/**
|
|
46
|
+
* Called when a tracking call fails. `trackEvent` and `trackPageview` never
|
|
47
|
+
* reject — analytics must not break the thing it is describing — so this is
|
|
48
|
+
* the only way to see those failures.
|
|
49
|
+
*/
|
|
50
|
+
onError?: (error: unknown) => void;
|
|
51
|
+
};
|
|
52
|
+
export type LuchyClient = {
|
|
53
|
+
/**
|
|
54
|
+
* The underlying `openapi-fetch` client, pre-authenticated. Use it for
|
|
55
|
+
* anything the convenience methods below do not cover; it is typed against
|
|
56
|
+
* the full document, so `api.POST('/query', { body })` is checked end to end.
|
|
57
|
+
*/
|
|
58
|
+
api: LuchyApi;
|
|
59
|
+
trackEvent(event: EventInput): Promise<IngestSuccess | null>;
|
|
60
|
+
trackPageview(pageview: PageviewInput): Promise<IngestSuccess | null>;
|
|
61
|
+
query(request: QueryRequest): Promise<QueryResponse>;
|
|
62
|
+
health(): Promise<HealthResponse>;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Thrown by the endpoints where failing loudly is the right answer — reads
|
|
66
|
+
* (`query`, `health`), as opposed to the fire-and-forget tracking calls.
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* try {
|
|
70
|
+
* await luchy.query({ date_range: '7d', metrics: ['pageviews'] });
|
|
71
|
+
* } catch (error) {
|
|
72
|
+
* if (error instanceof LuchyApiError) {
|
|
73
|
+
* console.error(error.status, error.body);
|
|
74
|
+
* }
|
|
75
|
+
* }
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export declare class LuchyApiError extends Error {
|
|
79
|
+
/** The HTTP status. Transport failures propagate as the runtime's own error, not this one. */
|
|
80
|
+
readonly status: number;
|
|
81
|
+
/** The parsed error body, when the API sent one. */
|
|
82
|
+
readonly body: ValidationError | ServerError | undefined;
|
|
83
|
+
constructor(message: string, status: number, body?: ValidationError | ServerError);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Creates a Luchy API client.
|
|
87
|
+
*
|
|
88
|
+
* The same import works on a server and in a browser, so an app that tracks
|
|
89
|
+
* from both ends has one client and one set of types instead of two.
|
|
90
|
+
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* import { createLuchyClient } from 'luchy/api';
|
|
93
|
+
*
|
|
94
|
+
* const luchy = createLuchyClient({
|
|
95
|
+
* apiKey: process.env.LUCHY_API_KEY!,
|
|
96
|
+
* onError: (error) => console.warn('[luchy]', error)
|
|
97
|
+
* });
|
|
98
|
+
*
|
|
99
|
+
* await luchy.trackEvent({
|
|
100
|
+
* name: 'order:placed',
|
|
101
|
+
* pathname: '/checkout',
|
|
102
|
+
* payload: { plan: 'pro', amount: 29 }
|
|
103
|
+
* });
|
|
104
|
+
*
|
|
105
|
+
* const stats = await luchy.query({
|
|
106
|
+
* date_range: '7d',
|
|
107
|
+
* metrics: ['pageviews', 'visitors']
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export declare function createLuchyClient(options: LuchyClientOptions): LuchyClient;
|
|
112
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,OAAqB,EAAE,KAAK,MAAM,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAElD;;;;;;;;;;;;GAYG;AAEH,YAAY,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;AAElC,iDAAiD;AACjD,MAAM,MAAM,UAAU,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,YAAY,CAAC,CAAC;AAC7D,oDAAoD;AACpD,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,eAAe,CAAC,CAAC;AACnE,yDAAyD;AACzD,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,iBAAiB,CAAC,CAAC;AACrE,0CAA0C;AAC1C,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,cAAc,CAAC,CAAC;AACjE,gDAAgD;AAChD,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,eAAe,CAAC,CAAC;AACnE,8BAA8B;AAC9B,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,gBAAgB,CAAC,CAAC;AACrE,+BAA+B;AAC/B,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,yBAAyB,CAAC,CAAC;AAC/E,+BAA+B;AAC/B,MAAM,MAAM,WAAW,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,qBAAqB,CAAC,CAAC;AAEvE,kEAAkE;AAClE,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;AAErC,MAAM,MAAM,kBAAkB,GAAG;IAC/B,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB;;;;OAIG;IACH,GAAG,EAAE,QAAQ,CAAC;IACd,UAAU,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IAC7D,aAAa,CAAC,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IACtE,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACrD,MAAM,IAAI,OAAO,CAAC,cAAc,CAAC,CAAC;CACnC,CAAC;AAYF;;;;;;;;;;;;;GAaG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACtC,8FAA8F;IAC9F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,WAAW,GAAG,SAAS,CAAC;gBAGvD,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,eAAe,GAAG,WAAW;CAOvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAoI1E"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import createClient from "openapi-fetch";
|
|
2
|
+
const DEFAULT_ENDPOINT = "https://dash.luchy.app/api";
|
|
3
|
+
class LuchyApiError extends Error {
|
|
4
|
+
constructor(message, status, body) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "LuchyApiError";
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.body = body;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function createLuchyClient(options) {
|
|
12
|
+
const { apiKey, onError } = options;
|
|
13
|
+
const api = createClient({
|
|
14
|
+
baseUrl: options.endpoint || DEFAULT_ENDPOINT,
|
|
15
|
+
fetch: options.fetch,
|
|
16
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
17
|
+
});
|
|
18
|
+
async function ingest(call) {
|
|
19
|
+
try {
|
|
20
|
+
const { data, error, response } = await call();
|
|
21
|
+
if (error || !data) {
|
|
22
|
+
onError?.(
|
|
23
|
+
new LuchyApiError(
|
|
24
|
+
`Luchy ingest failed with ${response.status}`,
|
|
25
|
+
response.status,
|
|
26
|
+
error
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return data;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
onError?.(error);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
api,
|
|
39
|
+
/**
|
|
40
|
+
* Records a custom event. Resolves to the ingest receipt, or to `null` if
|
|
41
|
+
* the call failed — it never rejects.
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* await luchy.trackEvent({
|
|
45
|
+
* name: 'signup:completed',
|
|
46
|
+
* pathname: '/signup',
|
|
47
|
+
* type: 'server',
|
|
48
|
+
* payload: { plan: 'free' }
|
|
49
|
+
* });
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
trackEvent(event) {
|
|
53
|
+
return ingest(
|
|
54
|
+
() => api.POST("/ingest/event", {
|
|
55
|
+
// Lets the request outlive the page on `beforeunload`. Runtimes that
|
|
56
|
+
// do not support the flag ignore it rather than reject.
|
|
57
|
+
keepalive: true,
|
|
58
|
+
body: event
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
/**
|
|
63
|
+
* Records a pageview. Same contract as `trackEvent`: never rejects.
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* await luchy.trackPageview({
|
|
67
|
+
* pathname: '/pricing',
|
|
68
|
+
* referrer: 'https://google.com'
|
|
69
|
+
* });
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
trackPageview(pageview) {
|
|
73
|
+
return ingest(
|
|
74
|
+
() => api.POST("/ingest/pageview", { keepalive: true, body: pageview })
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
/**
|
|
78
|
+
* Runs an analytics query. Unlike the tracking calls this throws
|
|
79
|
+
* `LuchyApiError` on a non-2xx, because a caller rendering a chart needs to
|
|
80
|
+
* know the numbers are missing.
|
|
81
|
+
*
|
|
82
|
+
* ```ts
|
|
83
|
+
* const { results } = await luchy.query({
|
|
84
|
+
* date_range: '30d',
|
|
85
|
+
* metrics: ['pageviews', 'visitors'],
|
|
86
|
+
* dimensions: ['event:page']
|
|
87
|
+
* });
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
async query(request) {
|
|
91
|
+
const { data, error, response } = await api.POST("/query", {
|
|
92
|
+
body: request
|
|
93
|
+
});
|
|
94
|
+
if (error || !data) {
|
|
95
|
+
throw new LuchyApiError(
|
|
96
|
+
`Luchy query failed with ${response.status}`,
|
|
97
|
+
response.status,
|
|
98
|
+
error
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return data;
|
|
102
|
+
},
|
|
103
|
+
/**
|
|
104
|
+
* Pings the API. Throws `LuchyApiError` if it is not healthy.
|
|
105
|
+
*
|
|
106
|
+
* ```ts
|
|
107
|
+
* const { status, timestamp } = await luchy.health();
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
async health() {
|
|
111
|
+
const { data, response } = await api.GET("/health");
|
|
112
|
+
if (!data) {
|
|
113
|
+
throw new LuchyApiError(
|
|
114
|
+
`Luchy health check failed with ${response.status}`,
|
|
115
|
+
response.status
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return data;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export {
|
|
123
|
+
LuchyApiError,
|
|
124
|
+
createLuchyClient
|
|
125
|
+
};
|