@unifeather/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +30 -0
- package/dist/adapters/analytics-engine.d.ts +26 -0
- package/dist/adapters/analytics-engine.js +97 -0
- package/dist/adapters/sql.d.ts +382 -0
- package/dist/adapters/sql.js +147 -0
- package/dist/handler.d.ts +22 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +108 -0
- package/dist/node.d.ts +11 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dennis Hendricks
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @unifeather/server
|
|
2
|
+
|
|
3
|
+
Framework-agnostic tracking endpoint for
|
|
4
|
+
[unifeather](https://github.com/dennishendricks/unifeather). `createHandler` returns a
|
|
5
|
+
plain Web `(request) => Response`, so the same code runs on Cloudflare
|
|
6
|
+
Workers/Pages and on Node (`node:http`, Express, …). Ships two storage adapters:
|
|
7
|
+
Cloudflare Analytics Engine and Drizzle-backed SQL (Postgres/SQLite/MySQL).
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun add @unifeather/server # or: npm i @unifeather/server
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createHandler } from "@unifeather/server";
|
|
15
|
+
import { sqlAdapter } from "@unifeather/server/sql";
|
|
16
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
17
|
+
import { Pool } from "pg";
|
|
18
|
+
|
|
19
|
+
const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }));
|
|
20
|
+
const handler = createHandler({ adapter: sqlAdapter({ db }), cors: true });
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Subpath exports: `@unifeather/server` (handler), `./sql` (Drizzle adapter),
|
|
24
|
+
`./analytics-engine` (Cloudflare adapter). `drizzle-orm` is an optional peer —
|
|
25
|
+
importing the Analytics Engine adapter never pulls it in.
|
|
26
|
+
|
|
27
|
+
See the [main README](https://github.com/dennishendricks/unifeather#readme) for the
|
|
28
|
+
Cloudflare setup, query-side sampling and the multi-dialect SQL adapter.
|
|
29
|
+
|
|
30
|
+
MIT © Dennis Hendricks
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Adapter } from "@unifeather/core";
|
|
2
|
+
/** Minimal shape of the Analytics Engine Workers binding (`env.<NAME>`). */
|
|
3
|
+
export interface AnalyticsEngineDataset {
|
|
4
|
+
writeDataPoint(point: {
|
|
5
|
+
indexes?: string[];
|
|
6
|
+
blobs?: (string | null)[];
|
|
7
|
+
doubles?: number[];
|
|
8
|
+
}): void;
|
|
9
|
+
}
|
|
10
|
+
export interface AnalyticsEngineOptions {
|
|
11
|
+
/** The Workers binding used for ingestion (`env.AE`). Required to `insert`. */
|
|
12
|
+
readonly dataset?: AnalyticsEngineDataset;
|
|
13
|
+
/** Cloudflare account id — required for `stats` (the SQL API). */
|
|
14
|
+
readonly accountId?: string;
|
|
15
|
+
/** API token with Analytics read permission — required for `stats`. */
|
|
16
|
+
readonly apiToken?: string;
|
|
17
|
+
/** The dataset name as referenced in SQL (the wrangler `dataset` value). */
|
|
18
|
+
readonly datasetName?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Cloudflare Analytics Engine adapter. Ingestion is cheap and unlimited via the
|
|
22
|
+
* Workers binding; querying uses the SQL API. Analytics Engine applies adaptive
|
|
23
|
+
* sampling automatically and stores `_sample_interval` per row, so all counts
|
|
24
|
+
* are `SUM(_sample_interval)` to extrapolate back to true totals.
|
|
25
|
+
*/
|
|
26
|
+
export declare const analyticsEngineAdapter: (options: AnalyticsEngineOptions) => Adapter;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// src/adapters/analytics-engine.ts
|
|
2
|
+
var BLOBS = [
|
|
3
|
+
"name",
|
|
4
|
+
// blob1
|
|
5
|
+
"path",
|
|
6
|
+
// blob2
|
|
7
|
+
"hostname",
|
|
8
|
+
// blob3
|
|
9
|
+
"referrerHost",
|
|
10
|
+
// blob4
|
|
11
|
+
"language",
|
|
12
|
+
// blob5
|
|
13
|
+
"browserName",
|
|
14
|
+
// blob6
|
|
15
|
+
"browserVersion",
|
|
16
|
+
// blob7
|
|
17
|
+
"osName",
|
|
18
|
+
// blob8
|
|
19
|
+
"osVersion",
|
|
20
|
+
// blob9
|
|
21
|
+
"country",
|
|
22
|
+
// blob10
|
|
23
|
+
"userId",
|
|
24
|
+
// blob11
|
|
25
|
+
"sessionId"
|
|
26
|
+
// blob12
|
|
27
|
+
];
|
|
28
|
+
var SQL_URL = (accountId) => `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`;
|
|
29
|
+
var analyticsEngineAdapter = (options) => {
|
|
30
|
+
const insert = async (event) => {
|
|
31
|
+
if (!options.dataset) throw new Error("analyticsEngineAdapter: `dataset` binding required to insert");
|
|
32
|
+
options.dataset.writeDataPoint({
|
|
33
|
+
indexes: [event.path],
|
|
34
|
+
// sampling key
|
|
35
|
+
blobs: BLOBS.map((key) => {
|
|
36
|
+
const value = event[key];
|
|
37
|
+
return value == null ? null : String(value);
|
|
38
|
+
}),
|
|
39
|
+
doubles: [event.activeSeconds, event.screenWidth ?? 0, event.screenHeight ?? 0]
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
const runSql = async (sql) => {
|
|
43
|
+
const { accountId, apiToken } = options;
|
|
44
|
+
if (!accountId || !apiToken) {
|
|
45
|
+
throw new Error("analyticsEngineAdapter: `accountId` and `apiToken` required for stats");
|
|
46
|
+
}
|
|
47
|
+
const res = await fetch(SQL_URL(accountId), {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "text/plain" },
|
|
50
|
+
body: sql
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) throw new Error(`Analytics Engine SQL error ${res.status}: ${await res.text()}`);
|
|
53
|
+
return (await res.json()).data;
|
|
54
|
+
};
|
|
55
|
+
const stats = async (query) => {
|
|
56
|
+
const table = options.datasetName;
|
|
57
|
+
if (!table) throw new Error("analyticsEngineAdapter: `datasetName` required for stats");
|
|
58
|
+
const fromSec = Math.floor(query.from / 1e3);
|
|
59
|
+
const toSec = Math.floor(query.to / 1e3);
|
|
60
|
+
const limit = query.limit ?? 10;
|
|
61
|
+
const unit = query.interval === "hour" ? "1 HOUR" : "1 DAY";
|
|
62
|
+
const where = `timestamp >= toDateTime(${fromSec}) AND timestamp < toDateTime(${toSec}) AND blob1 = 'pageview'`;
|
|
63
|
+
const [totals, pages, referrers, series] = await Promise.all([
|
|
64
|
+
runSql(
|
|
65
|
+
// visitors = distinct user ids (blob11); requires the cookieId/userId tracker option.
|
|
66
|
+
`SELECT SUM(_sample_interval) AS views, COUNT(DISTINCT blob11) AS visitors, AVG(double1) AS avgActive FROM ${table} WHERE ${where}`
|
|
67
|
+
),
|
|
68
|
+
runSql(`SELECT blob2 AS path, SUM(_sample_interval) AS views FROM ${table} WHERE ${where} GROUP BY path ORDER BY views DESC LIMIT ${limit}`),
|
|
69
|
+
runSql(
|
|
70
|
+
`SELECT blob4 AS referrer, SUM(_sample_interval) AS views FROM ${table} WHERE ${where} AND blob4 != '' GROUP BY referrer ORDER BY views DESC LIMIT ${limit}`
|
|
71
|
+
),
|
|
72
|
+
runSql(
|
|
73
|
+
`SELECT toStartOfInterval(timestamp, INTERVAL ${unit}) AS bucket, SUM(_sample_interval) AS views FROM ${table} WHERE ${where} GROUP BY bucket ORDER BY bucket ASC`
|
|
74
|
+
)
|
|
75
|
+
]);
|
|
76
|
+
const total = totals[0];
|
|
77
|
+
const timeseries = series.map((row) => ({
|
|
78
|
+
date: row.bucket,
|
|
79
|
+
views: Number(row.views)
|
|
80
|
+
}));
|
|
81
|
+
return {
|
|
82
|
+
totals: {
|
|
83
|
+
views: Number(total?.views ?? 0),
|
|
84
|
+
visitors: total?.visitors ? Number(total.visitors) : void 0,
|
|
85
|
+
avgActiveSeconds: total?.avgActive ? Math.round(Number(total.avgActive)) : void 0
|
|
86
|
+
},
|
|
87
|
+
topPages: pages.map((p) => ({ path: p.path, views: Number(p.views) })),
|
|
88
|
+
topReferrers: referrers.map((r) => ({ referrer: r.referrer, views: Number(r.views) })),
|
|
89
|
+
timeseries,
|
|
90
|
+
// Analytics Engine samples adaptively; counts are already extrapolated.
|
|
91
|
+
sampled: true
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
return { insert, stats };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export { analyticsEngineAdapter };
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import type { Adapter } from "@unifeather/core";
|
|
2
|
+
/**
|
|
3
|
+
* Drizzle table definition for the events table. Use it with drizzle-kit to
|
|
4
|
+
* generate migrations, or run {@link CREATE_TABLE_SQL} directly.
|
|
5
|
+
*
|
|
6
|
+
* Postgres-first (works with node-postgres, Neon, pglite, Supabase, Timescale).
|
|
7
|
+
* For SQLite/D1/MySQL, mirror these columns with the matching drizzle core and
|
|
8
|
+
* pass your table + the right `dialect` to {@link sqlAdapter}.
|
|
9
|
+
*/
|
|
10
|
+
export declare const unifeatherEvents: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
11
|
+
name: "unifeather_events";
|
|
12
|
+
schema: undefined;
|
|
13
|
+
columns: {
|
|
14
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
15
|
+
name: "id";
|
|
16
|
+
tableName: "unifeather_events";
|
|
17
|
+
dataType: "number";
|
|
18
|
+
columnType: "PgSerial";
|
|
19
|
+
data: number;
|
|
20
|
+
driverParam: number;
|
|
21
|
+
notNull: true;
|
|
22
|
+
hasDefault: true;
|
|
23
|
+
isPrimaryKey: true;
|
|
24
|
+
isAutoincrement: false;
|
|
25
|
+
hasRuntimeDefault: false;
|
|
26
|
+
enumValues: undefined;
|
|
27
|
+
baseColumn: never;
|
|
28
|
+
identity: undefined;
|
|
29
|
+
generated: undefined;
|
|
30
|
+
}, {}, {}>;
|
|
31
|
+
eventId: import("drizzle-orm/pg-core").PgColumn<{
|
|
32
|
+
name: "event_id";
|
|
33
|
+
tableName: "unifeather_events";
|
|
34
|
+
dataType: "string";
|
|
35
|
+
columnType: "PgText";
|
|
36
|
+
data: string;
|
|
37
|
+
driverParam: string;
|
|
38
|
+
notNull: true;
|
|
39
|
+
hasDefault: false;
|
|
40
|
+
isPrimaryKey: false;
|
|
41
|
+
isAutoincrement: false;
|
|
42
|
+
hasRuntimeDefault: false;
|
|
43
|
+
enumValues: [string, ...string[]];
|
|
44
|
+
baseColumn: never;
|
|
45
|
+
identity: undefined;
|
|
46
|
+
generated: undefined;
|
|
47
|
+
}, {}, {}>;
|
|
48
|
+
name: import("drizzle-orm/pg-core").PgColumn<{
|
|
49
|
+
name: "name";
|
|
50
|
+
tableName: "unifeather_events";
|
|
51
|
+
dataType: "string";
|
|
52
|
+
columnType: "PgText";
|
|
53
|
+
data: string;
|
|
54
|
+
driverParam: string;
|
|
55
|
+
notNull: true;
|
|
56
|
+
hasDefault: false;
|
|
57
|
+
isPrimaryKey: false;
|
|
58
|
+
isAutoincrement: false;
|
|
59
|
+
hasRuntimeDefault: false;
|
|
60
|
+
enumValues: [string, ...string[]];
|
|
61
|
+
baseColumn: never;
|
|
62
|
+
identity: undefined;
|
|
63
|
+
generated: undefined;
|
|
64
|
+
}, {}, {}>;
|
|
65
|
+
ts: import("drizzle-orm/pg-core").PgColumn<{
|
|
66
|
+
name: "ts";
|
|
67
|
+
tableName: "unifeather_events";
|
|
68
|
+
dataType: "date";
|
|
69
|
+
columnType: "PgTimestamp";
|
|
70
|
+
data: Date;
|
|
71
|
+
driverParam: string;
|
|
72
|
+
notNull: true;
|
|
73
|
+
hasDefault: false;
|
|
74
|
+
isPrimaryKey: false;
|
|
75
|
+
isAutoincrement: false;
|
|
76
|
+
hasRuntimeDefault: false;
|
|
77
|
+
enumValues: undefined;
|
|
78
|
+
baseColumn: never;
|
|
79
|
+
identity: undefined;
|
|
80
|
+
generated: undefined;
|
|
81
|
+
}, {}, {}>;
|
|
82
|
+
hostname: import("drizzle-orm/pg-core").PgColumn<{
|
|
83
|
+
name: "hostname";
|
|
84
|
+
tableName: "unifeather_events";
|
|
85
|
+
dataType: "string";
|
|
86
|
+
columnType: "PgText";
|
|
87
|
+
data: string;
|
|
88
|
+
driverParam: string;
|
|
89
|
+
notNull: true;
|
|
90
|
+
hasDefault: false;
|
|
91
|
+
isPrimaryKey: false;
|
|
92
|
+
isAutoincrement: false;
|
|
93
|
+
hasRuntimeDefault: false;
|
|
94
|
+
enumValues: [string, ...string[]];
|
|
95
|
+
baseColumn: never;
|
|
96
|
+
identity: undefined;
|
|
97
|
+
generated: undefined;
|
|
98
|
+
}, {}, {}>;
|
|
99
|
+
path: import("drizzle-orm/pg-core").PgColumn<{
|
|
100
|
+
name: "path";
|
|
101
|
+
tableName: "unifeather_events";
|
|
102
|
+
dataType: "string";
|
|
103
|
+
columnType: "PgText";
|
|
104
|
+
data: string;
|
|
105
|
+
driverParam: string;
|
|
106
|
+
notNull: true;
|
|
107
|
+
hasDefault: false;
|
|
108
|
+
isPrimaryKey: false;
|
|
109
|
+
isAutoincrement: false;
|
|
110
|
+
hasRuntimeDefault: false;
|
|
111
|
+
enumValues: [string, ...string[]];
|
|
112
|
+
baseColumn: never;
|
|
113
|
+
identity: undefined;
|
|
114
|
+
generated: undefined;
|
|
115
|
+
}, {}, {}>;
|
|
116
|
+
referrerHost: import("drizzle-orm/pg-core").PgColumn<{
|
|
117
|
+
name: "referrer_host";
|
|
118
|
+
tableName: "unifeather_events";
|
|
119
|
+
dataType: "string";
|
|
120
|
+
columnType: "PgText";
|
|
121
|
+
data: string;
|
|
122
|
+
driverParam: string;
|
|
123
|
+
notNull: false;
|
|
124
|
+
hasDefault: false;
|
|
125
|
+
isPrimaryKey: false;
|
|
126
|
+
isAutoincrement: false;
|
|
127
|
+
hasRuntimeDefault: false;
|
|
128
|
+
enumValues: [string, ...string[]];
|
|
129
|
+
baseColumn: never;
|
|
130
|
+
identity: undefined;
|
|
131
|
+
generated: undefined;
|
|
132
|
+
}, {}, {}>;
|
|
133
|
+
language: import("drizzle-orm/pg-core").PgColumn<{
|
|
134
|
+
name: "language";
|
|
135
|
+
tableName: "unifeather_events";
|
|
136
|
+
dataType: "string";
|
|
137
|
+
columnType: "PgText";
|
|
138
|
+
data: string;
|
|
139
|
+
driverParam: string;
|
|
140
|
+
notNull: false;
|
|
141
|
+
hasDefault: false;
|
|
142
|
+
isPrimaryKey: false;
|
|
143
|
+
isAutoincrement: false;
|
|
144
|
+
hasRuntimeDefault: false;
|
|
145
|
+
enumValues: [string, ...string[]];
|
|
146
|
+
baseColumn: never;
|
|
147
|
+
identity: undefined;
|
|
148
|
+
generated: undefined;
|
|
149
|
+
}, {}, {}>;
|
|
150
|
+
activeSeconds: import("drizzle-orm/pg-core").PgColumn<{
|
|
151
|
+
name: "active_seconds";
|
|
152
|
+
tableName: "unifeather_events";
|
|
153
|
+
dataType: "number";
|
|
154
|
+
columnType: "PgInteger";
|
|
155
|
+
data: number;
|
|
156
|
+
driverParam: string | number;
|
|
157
|
+
notNull: true;
|
|
158
|
+
hasDefault: true;
|
|
159
|
+
isPrimaryKey: false;
|
|
160
|
+
isAutoincrement: false;
|
|
161
|
+
hasRuntimeDefault: false;
|
|
162
|
+
enumValues: undefined;
|
|
163
|
+
baseColumn: never;
|
|
164
|
+
identity: undefined;
|
|
165
|
+
generated: undefined;
|
|
166
|
+
}, {}, {}>;
|
|
167
|
+
browserName: import("drizzle-orm/pg-core").PgColumn<{
|
|
168
|
+
name: "browser_name";
|
|
169
|
+
tableName: "unifeather_events";
|
|
170
|
+
dataType: "string";
|
|
171
|
+
columnType: "PgText";
|
|
172
|
+
data: string;
|
|
173
|
+
driverParam: string;
|
|
174
|
+
notNull: false;
|
|
175
|
+
hasDefault: false;
|
|
176
|
+
isPrimaryKey: false;
|
|
177
|
+
isAutoincrement: false;
|
|
178
|
+
hasRuntimeDefault: false;
|
|
179
|
+
enumValues: [string, ...string[]];
|
|
180
|
+
baseColumn: never;
|
|
181
|
+
identity: undefined;
|
|
182
|
+
generated: undefined;
|
|
183
|
+
}, {}, {}>;
|
|
184
|
+
browserVersion: import("drizzle-orm/pg-core").PgColumn<{
|
|
185
|
+
name: "browser_version";
|
|
186
|
+
tableName: "unifeather_events";
|
|
187
|
+
dataType: "string";
|
|
188
|
+
columnType: "PgText";
|
|
189
|
+
data: string;
|
|
190
|
+
driverParam: string;
|
|
191
|
+
notNull: false;
|
|
192
|
+
hasDefault: false;
|
|
193
|
+
isPrimaryKey: false;
|
|
194
|
+
isAutoincrement: false;
|
|
195
|
+
hasRuntimeDefault: false;
|
|
196
|
+
enumValues: [string, ...string[]];
|
|
197
|
+
baseColumn: never;
|
|
198
|
+
identity: undefined;
|
|
199
|
+
generated: undefined;
|
|
200
|
+
}, {}, {}>;
|
|
201
|
+
osName: import("drizzle-orm/pg-core").PgColumn<{
|
|
202
|
+
name: "os_name";
|
|
203
|
+
tableName: "unifeather_events";
|
|
204
|
+
dataType: "string";
|
|
205
|
+
columnType: "PgText";
|
|
206
|
+
data: string;
|
|
207
|
+
driverParam: string;
|
|
208
|
+
notNull: false;
|
|
209
|
+
hasDefault: false;
|
|
210
|
+
isPrimaryKey: false;
|
|
211
|
+
isAutoincrement: false;
|
|
212
|
+
hasRuntimeDefault: false;
|
|
213
|
+
enumValues: [string, ...string[]];
|
|
214
|
+
baseColumn: never;
|
|
215
|
+
identity: undefined;
|
|
216
|
+
generated: undefined;
|
|
217
|
+
}, {}, {}>;
|
|
218
|
+
osVersion: import("drizzle-orm/pg-core").PgColumn<{
|
|
219
|
+
name: "os_version";
|
|
220
|
+
tableName: "unifeather_events";
|
|
221
|
+
dataType: "string";
|
|
222
|
+
columnType: "PgText";
|
|
223
|
+
data: string;
|
|
224
|
+
driverParam: string;
|
|
225
|
+
notNull: false;
|
|
226
|
+
hasDefault: false;
|
|
227
|
+
isPrimaryKey: false;
|
|
228
|
+
isAutoincrement: false;
|
|
229
|
+
hasRuntimeDefault: false;
|
|
230
|
+
enumValues: [string, ...string[]];
|
|
231
|
+
baseColumn: never;
|
|
232
|
+
identity: undefined;
|
|
233
|
+
generated: undefined;
|
|
234
|
+
}, {}, {}>;
|
|
235
|
+
screenWidth: import("drizzle-orm/pg-core").PgColumn<{
|
|
236
|
+
name: "screen_width";
|
|
237
|
+
tableName: "unifeather_events";
|
|
238
|
+
dataType: "number";
|
|
239
|
+
columnType: "PgInteger";
|
|
240
|
+
data: number;
|
|
241
|
+
driverParam: string | number;
|
|
242
|
+
notNull: false;
|
|
243
|
+
hasDefault: false;
|
|
244
|
+
isPrimaryKey: false;
|
|
245
|
+
isAutoincrement: false;
|
|
246
|
+
hasRuntimeDefault: false;
|
|
247
|
+
enumValues: undefined;
|
|
248
|
+
baseColumn: never;
|
|
249
|
+
identity: undefined;
|
|
250
|
+
generated: undefined;
|
|
251
|
+
}, {}, {}>;
|
|
252
|
+
screenHeight: import("drizzle-orm/pg-core").PgColumn<{
|
|
253
|
+
name: "screen_height";
|
|
254
|
+
tableName: "unifeather_events";
|
|
255
|
+
dataType: "number";
|
|
256
|
+
columnType: "PgInteger";
|
|
257
|
+
data: number;
|
|
258
|
+
driverParam: string | number;
|
|
259
|
+
notNull: false;
|
|
260
|
+
hasDefault: false;
|
|
261
|
+
isPrimaryKey: false;
|
|
262
|
+
isAutoincrement: false;
|
|
263
|
+
hasRuntimeDefault: false;
|
|
264
|
+
enumValues: undefined;
|
|
265
|
+
baseColumn: never;
|
|
266
|
+
identity: undefined;
|
|
267
|
+
generated: undefined;
|
|
268
|
+
}, {}, {}>;
|
|
269
|
+
country: import("drizzle-orm/pg-core").PgColumn<{
|
|
270
|
+
name: "country";
|
|
271
|
+
tableName: "unifeather_events";
|
|
272
|
+
dataType: "string";
|
|
273
|
+
columnType: "PgText";
|
|
274
|
+
data: string;
|
|
275
|
+
driverParam: string;
|
|
276
|
+
notNull: false;
|
|
277
|
+
hasDefault: false;
|
|
278
|
+
isPrimaryKey: false;
|
|
279
|
+
isAutoincrement: false;
|
|
280
|
+
hasRuntimeDefault: false;
|
|
281
|
+
enumValues: [string, ...string[]];
|
|
282
|
+
baseColumn: never;
|
|
283
|
+
identity: undefined;
|
|
284
|
+
generated: undefined;
|
|
285
|
+
}, {}, {}>;
|
|
286
|
+
userId: import("drizzle-orm/pg-core").PgColumn<{
|
|
287
|
+
name: "user_id";
|
|
288
|
+
tableName: "unifeather_events";
|
|
289
|
+
dataType: "string";
|
|
290
|
+
columnType: "PgText";
|
|
291
|
+
data: string;
|
|
292
|
+
driverParam: string;
|
|
293
|
+
notNull: false;
|
|
294
|
+
hasDefault: false;
|
|
295
|
+
isPrimaryKey: false;
|
|
296
|
+
isAutoincrement: false;
|
|
297
|
+
hasRuntimeDefault: false;
|
|
298
|
+
enumValues: [string, ...string[]];
|
|
299
|
+
baseColumn: never;
|
|
300
|
+
identity: undefined;
|
|
301
|
+
generated: undefined;
|
|
302
|
+
}, {}, {}>;
|
|
303
|
+
sessionId: import("drizzle-orm/pg-core").PgColumn<{
|
|
304
|
+
name: "session_id";
|
|
305
|
+
tableName: "unifeather_events";
|
|
306
|
+
dataType: "string";
|
|
307
|
+
columnType: "PgText";
|
|
308
|
+
data: string;
|
|
309
|
+
driverParam: string;
|
|
310
|
+
notNull: false;
|
|
311
|
+
hasDefault: false;
|
|
312
|
+
isPrimaryKey: false;
|
|
313
|
+
isAutoincrement: false;
|
|
314
|
+
hasRuntimeDefault: false;
|
|
315
|
+
enumValues: [string, ...string[]];
|
|
316
|
+
baseColumn: never;
|
|
317
|
+
identity: undefined;
|
|
318
|
+
generated: undefined;
|
|
319
|
+
}, {}, {}>;
|
|
320
|
+
properties: import("drizzle-orm/pg-core").PgColumn<{
|
|
321
|
+
name: "properties";
|
|
322
|
+
tableName: "unifeather_events";
|
|
323
|
+
dataType: "json";
|
|
324
|
+
columnType: "PgJsonb";
|
|
325
|
+
data: unknown;
|
|
326
|
+
driverParam: unknown;
|
|
327
|
+
notNull: false;
|
|
328
|
+
hasDefault: false;
|
|
329
|
+
isPrimaryKey: false;
|
|
330
|
+
isAutoincrement: false;
|
|
331
|
+
hasRuntimeDefault: false;
|
|
332
|
+
enumValues: undefined;
|
|
333
|
+
baseColumn: never;
|
|
334
|
+
identity: undefined;
|
|
335
|
+
generated: undefined;
|
|
336
|
+
}, {}, {}>;
|
|
337
|
+
};
|
|
338
|
+
dialect: 'pg';
|
|
339
|
+
}>;
|
|
340
|
+
/** Ready-to-run DDL for the events table (Postgres). */
|
|
341
|
+
export declare const CREATE_TABLE_SQL = "\nCREATE TABLE IF NOT EXISTS unifeather_events (\n id BIGSERIAL PRIMARY KEY,\n event_id TEXT NOT NULL,\n name TEXT NOT NULL,\n ts TIMESTAMPTZ NOT NULL,\n hostname TEXT NOT NULL,\n path TEXT NOT NULL,\n referrer_host TEXT,\n language TEXT,\n active_seconds INTEGER NOT NULL DEFAULT 0,\n browser_name TEXT,\n browser_version TEXT,\n os_name TEXT,\n os_version TEXT,\n screen_width INTEGER,\n screen_height INTEGER,\n country TEXT,\n user_id TEXT,\n session_id TEXT,\n properties JSONB\n);\nCREATE INDEX IF NOT EXISTS unifeather_events_ts_idx ON unifeather_events (ts);\nCREATE INDEX IF NOT EXISTS unifeather_events_path_ts_idx ON unifeather_events (path, ts);\n";
|
|
342
|
+
/** SQL dialect of the drizzle `db` — selects the timeseries date-bucket and random-order syntax. */
|
|
343
|
+
export type SqlDialect = "pg" | "sqlite" | "mysql";
|
|
344
|
+
/** Query-side sampling — extrapolate from a sample on very large tables. */
|
|
345
|
+
export interface SamplingOptions {
|
|
346
|
+
/** Enable sampling. Defaults to true. */
|
|
347
|
+
readonly enabled?: boolean;
|
|
348
|
+
/** Row-estimate above which sampling kicks in. Defaults to 1,000,000. */
|
|
349
|
+
readonly threshold?: number;
|
|
350
|
+
/** Fraction of rows to scan when sampling (0–1). Defaults to 0.1 (10%). */
|
|
351
|
+
readonly rate?: number;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Any drizzle db exposing the query builder + insert. Typed loosely on purpose:
|
|
355
|
+
* the builder API (`select`/`insert`/`from`/`where`/`groupBy`/…) is identical
|
|
356
|
+
* across dialects, but drizzle does not unify the per-dialect db classes.
|
|
357
|
+
*/
|
|
358
|
+
export interface DrizzleLike {
|
|
359
|
+
select(...args: unknown[]): any;
|
|
360
|
+
insert(...args: unknown[]): any;
|
|
361
|
+
}
|
|
362
|
+
export interface SqlAdapterOptions {
|
|
363
|
+
readonly db: DrizzleLike;
|
|
364
|
+
/** Drizzle table object for events. Defaults to the built-in Postgres {@link unifeatherEvents}. */
|
|
365
|
+
readonly table?: unknown;
|
|
366
|
+
/** DB dialect — selects date-bucket + random-order SQL. Defaults to "pg". */
|
|
367
|
+
readonly dialect?: SqlDialect;
|
|
368
|
+
readonly sampling?: SamplingOptions;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Universal SQL adapter backed by Drizzle's query builder — one code path for
|
|
372
|
+
* every dialect drizzle supports. Pass the matching drizzle db, table object and
|
|
373
|
+
* `dialect`; only the date-bucket and random-order snippets vary per dialect.
|
|
374
|
+
*
|
|
375
|
+
* On tables past the configured threshold, aggregations run against a portable
|
|
376
|
+
* `ORDER BY random()/rand() LIMIT n` sample and counts are scaled back up. The
|
|
377
|
+
* sample size itself comes from a size estimate: Postgres uses a cheap
|
|
378
|
+
* `TABLESAMPLE SYSTEM (1)` page sample to avoid a full `count`; other dialects
|
|
379
|
+
* count the range directly. (TABLESAMPLE can't back the aggregation itself — a
|
|
380
|
+
* raw-SQL `FROM` is incompatible with the builder's typed column selection.)
|
|
381
|
+
*/
|
|
382
|
+
export declare const sqlAdapter: (options: SqlAdapterOptions) => Adapter;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { and, gte, lt, eq, sql, count, countDistinct, avg, desc, isNotNull } from 'drizzle-orm';
|
|
2
|
+
import { pgTable, jsonb, text, integer, timestamp, serial } from 'drizzle-orm/pg-core';
|
|
3
|
+
|
|
4
|
+
// src/adapters/sql.ts
|
|
5
|
+
var unifeatherEvents = pgTable("unifeather_events", {
|
|
6
|
+
id: serial("id").primaryKey(),
|
|
7
|
+
eventId: text("event_id").notNull(),
|
|
8
|
+
name: text("name").notNull(),
|
|
9
|
+
ts: timestamp("ts", { withTimezone: true }).notNull(),
|
|
10
|
+
hostname: text("hostname").notNull(),
|
|
11
|
+
path: text("path").notNull(),
|
|
12
|
+
referrerHost: text("referrer_host"),
|
|
13
|
+
language: text("language"),
|
|
14
|
+
activeSeconds: integer("active_seconds").notNull().default(0),
|
|
15
|
+
browserName: text("browser_name"),
|
|
16
|
+
browserVersion: text("browser_version"),
|
|
17
|
+
osName: text("os_name"),
|
|
18
|
+
osVersion: text("os_version"),
|
|
19
|
+
screenWidth: integer("screen_width"),
|
|
20
|
+
screenHeight: integer("screen_height"),
|
|
21
|
+
country: text("country"),
|
|
22
|
+
userId: text("user_id"),
|
|
23
|
+
sessionId: text("session_id"),
|
|
24
|
+
properties: jsonb("properties")
|
|
25
|
+
});
|
|
26
|
+
var CREATE_TABLE_SQL = `
|
|
27
|
+
CREATE TABLE IF NOT EXISTS unifeather_events (
|
|
28
|
+
id BIGSERIAL PRIMARY KEY,
|
|
29
|
+
event_id TEXT NOT NULL,
|
|
30
|
+
name TEXT NOT NULL,
|
|
31
|
+
ts TIMESTAMPTZ NOT NULL,
|
|
32
|
+
hostname TEXT NOT NULL,
|
|
33
|
+
path TEXT NOT NULL,
|
|
34
|
+
referrer_host TEXT,
|
|
35
|
+
language TEXT,
|
|
36
|
+
active_seconds INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
browser_name TEXT,
|
|
38
|
+
browser_version TEXT,
|
|
39
|
+
os_name TEXT,
|
|
40
|
+
os_version TEXT,
|
|
41
|
+
screen_width INTEGER,
|
|
42
|
+
screen_height INTEGER,
|
|
43
|
+
country TEXT,
|
|
44
|
+
user_id TEXT,
|
|
45
|
+
session_id TEXT,
|
|
46
|
+
properties JSONB
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS unifeather_events_ts_idx ON unifeather_events (ts);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS unifeather_events_path_ts_idx ON unifeather_events (path, ts);
|
|
50
|
+
`;
|
|
51
|
+
var num = (value) => Number(value ?? 0);
|
|
52
|
+
var randomOrder = (dialect) => dialect === "mysql" ? sql`rand()` : sql`random()`;
|
|
53
|
+
var dateBucket = (dialect, bucket, ts) => {
|
|
54
|
+
if (dialect === "pg") {
|
|
55
|
+
const fmt2 = bucket === "hour" ? 'YYYY-MM-DD"T"HH24:00' : "YYYY-MM-DD";
|
|
56
|
+
return sql`to_char(date_trunc(${bucket}, ${ts}), ${fmt2})`;
|
|
57
|
+
}
|
|
58
|
+
const fmt = bucket === "hour" ? "%Y-%m-%dT%H:00" : "%Y-%m-%d";
|
|
59
|
+
return dialect === "mysql" ? sql`date_format(${ts}, ${fmt})` : sql`strftime(${fmt}, ${ts}, 'unixepoch')`;
|
|
60
|
+
};
|
|
61
|
+
var sqlAdapter = (options) => {
|
|
62
|
+
const { db } = options;
|
|
63
|
+
const dialect = options.dialect ?? "pg";
|
|
64
|
+
const events = options.table ?? unifeatherEvents;
|
|
65
|
+
const table = events;
|
|
66
|
+
const enabled = options.sampling?.enabled ?? true;
|
|
67
|
+
const threshold = options.sampling?.threshold ?? 1e6;
|
|
68
|
+
const rate = Math.min(Math.max(options.sampling?.rate ?? 0.1, 1e-4), 1);
|
|
69
|
+
const insert = async (e) => {
|
|
70
|
+
await db.insert(events).values({
|
|
71
|
+
eventId: e.eventId,
|
|
72
|
+
name: e.name,
|
|
73
|
+
ts: new Date(e.timestamp),
|
|
74
|
+
hostname: e.hostname,
|
|
75
|
+
path: e.path,
|
|
76
|
+
referrerHost: e.referrerHost ?? null,
|
|
77
|
+
language: e.language ?? null,
|
|
78
|
+
activeSeconds: e.activeSeconds,
|
|
79
|
+
browserName: e.browserName ?? null,
|
|
80
|
+
browserVersion: e.browserVersion ?? null,
|
|
81
|
+
osName: e.osName ?? null,
|
|
82
|
+
osVersion: e.osVersion ?? null,
|
|
83
|
+
screenWidth: e.screenWidth ?? null,
|
|
84
|
+
screenHeight: e.screenHeight ?? null,
|
|
85
|
+
country: e.country ?? null,
|
|
86
|
+
userId: e.userId ?? null,
|
|
87
|
+
sessionId: e.sessionId ?? null,
|
|
88
|
+
properties: e.properties ?? null
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
const stats = async (query) => {
|
|
92
|
+
const limit = query.limit ?? 10;
|
|
93
|
+
const bucket = query.interval === "hour" ? "hour" : "day";
|
|
94
|
+
const range = and(gte(table.ts, new Date(query.from)), lt(table.ts, new Date(query.to)), eq(table.name, "pageview"));
|
|
95
|
+
let sampled = false;
|
|
96
|
+
let scale = 1;
|
|
97
|
+
let source = events;
|
|
98
|
+
let cols = table;
|
|
99
|
+
let where = range;
|
|
100
|
+
if (enabled) {
|
|
101
|
+
const size = dialect === "pg" ? num(
|
|
102
|
+
(await db.select({ n: sql`count(*)::float8 * 100` }).from(sql`${events} TABLESAMPLE SYSTEM (1)`).where(range))[0]?.n
|
|
103
|
+
) : num((await db.select({ total: count() }).from(events).where(range))[0]?.total);
|
|
104
|
+
if (size > threshold) {
|
|
105
|
+
sampled = true;
|
|
106
|
+
const n = Math.max(1, Math.round(size * rate));
|
|
107
|
+
const sub = db.select().from(events).where(range).orderBy(randomOrder(dialect)).limit(n).as("uf_sample");
|
|
108
|
+
source = sub;
|
|
109
|
+
cols = sub;
|
|
110
|
+
where = void 0;
|
|
111
|
+
scale = size / n;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const scaled = (value) => Math.round(num(value) * scale);
|
|
115
|
+
const bucketExpr = dateBucket(dialect, bucket, cols.ts);
|
|
116
|
+
const [totalsRes, pagesRes, referrersRes, seriesRes] = await Promise.all([
|
|
117
|
+
db.select({ views: count(), avgActive: avg(cols.activeSeconds), visitors: countDistinct(cols.userId) }).from(source).where(where),
|
|
118
|
+
db.select({ path: cols.path, views: count() }).from(source).where(where).groupBy(cols.path).orderBy(desc(count())).limit(limit),
|
|
119
|
+
db.select({ referrer: cols.referrerHost, views: count() }).from(source).where(and(where, isNotNull(cols.referrerHost))).groupBy(cols.referrerHost).orderBy(desc(count())).limit(limit),
|
|
120
|
+
// Group/order by ordinal: the bucket expression renders differently in
|
|
121
|
+
// SELECT vs GROUP BY, so referencing it by position keeps them identical.
|
|
122
|
+
db.select({ date: bucketExpr, views: count() }).from(source).where(where).groupBy(sql`1`).orderBy(sql`1`)
|
|
123
|
+
]);
|
|
124
|
+
const totals = totalsRes[0] ?? {};
|
|
125
|
+
return {
|
|
126
|
+
totals: {
|
|
127
|
+
views: scaled(totals.views),
|
|
128
|
+
// Distinct visitors = distinct user ids; requires the cookieId/userId
|
|
129
|
+
// tracker option. Undefined without identified visitors, or when
|
|
130
|
+
// sampling (distinct counts don't extrapolate from a sample).
|
|
131
|
+
visitors: sampled || !num(totals.visitors) ? void 0 : num(totals.visitors),
|
|
132
|
+
avgActiveSeconds: totals.avgActive != null ? Math.round(num(totals.avgActive)) : void 0
|
|
133
|
+
},
|
|
134
|
+
topPages: pagesRes.map((r) => ({ path: String(r.path), views: scaled(r.views) })),
|
|
135
|
+
topReferrers: referrersRes.map((r) => ({
|
|
136
|
+
referrer: String(r.referrer),
|
|
137
|
+
views: scaled(r.views)
|
|
138
|
+
})),
|
|
139
|
+
timeseries: seriesRes.map((r) => ({ date: String(r.date), views: scaled(r.views) })),
|
|
140
|
+
sampled,
|
|
141
|
+
sampleRate: sampled ? rate : void 0
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
return { insert, stats };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export { CREATE_TABLE_SQL, sqlAdapter, unifeatherEvents };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Adapter } from "@unifeather/core";
|
|
2
|
+
export interface HandlerOptions {
|
|
3
|
+
readonly adapter: Adapter;
|
|
4
|
+
/** Ingest path. Defaults to "/collect". */
|
|
5
|
+
readonly collectPath?: string;
|
|
6
|
+
/** Stats path. Defaults to "/api/stats". */
|
|
7
|
+
readonly statsPath?: string;
|
|
8
|
+
/**
|
|
9
|
+
* CORS `Access-Control-Allow-Origin`. `true` reflects the request origin,
|
|
10
|
+
* a string/array whitelists origins, `false`/omitted disables CORS.
|
|
11
|
+
*/
|
|
12
|
+
readonly cors?: boolean | string | readonly string[];
|
|
13
|
+
/** Keep the query string in stored paths. Defaults to false (stripped). */
|
|
14
|
+
readonly keepQuery?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export type FetchHandler = (request: Request) => Promise<Response>;
|
|
17
|
+
/**
|
|
18
|
+
* Build a framework-agnostic fetch handler for the tracking endpoint. Works
|
|
19
|
+
* unchanged on Cloudflare Workers/Pages (`export default { fetch }`) and on
|
|
20
|
+
* Node via {@link createNodeListener}.
|
|
21
|
+
*/
|
|
22
|
+
export declare const createHandler: (options: HandlerOptions) => FetchHandler;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { normalizeEvent } from '@unifeather/core';
|
|
2
|
+
|
|
3
|
+
// src/handler.ts
|
|
4
|
+
var resolveOrigin = (cors, requestOrigin) => {
|
|
5
|
+
if (!cors) return void 0;
|
|
6
|
+
if (cors === true) return requestOrigin ?? "*";
|
|
7
|
+
const list = typeof cors === "string" ? [cors] : cors;
|
|
8
|
+
return requestOrigin && list.includes(requestOrigin) ? requestOrigin : void 0;
|
|
9
|
+
};
|
|
10
|
+
var corsHeaders = (origin) => origin ? {
|
|
11
|
+
"Access-Control-Allow-Origin": origin,
|
|
12
|
+
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
|
13
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
14
|
+
Vary: "Origin"
|
|
15
|
+
} : {};
|
|
16
|
+
var createHandler = (options) => {
|
|
17
|
+
const { adapter, collectPath = "/collect", statsPath = "/api/stats" } = options;
|
|
18
|
+
return async (request) => {
|
|
19
|
+
const url = new URL(request.url);
|
|
20
|
+
const origin = resolveOrigin(options.cors, request.headers.get("Origin"));
|
|
21
|
+
const cors = corsHeaders(origin);
|
|
22
|
+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
|
|
23
|
+
if (request.method === "POST" && url.pathname === collectPath) {
|
|
24
|
+
return handleCollect(request, options, cors);
|
|
25
|
+
}
|
|
26
|
+
if (request.method === "GET" && url.pathname === statsPath) {
|
|
27
|
+
return handleStats(url, adapter, cors);
|
|
28
|
+
}
|
|
29
|
+
return new Response("Not Found", { status: 404, headers: cors });
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
var handleCollect = async (request, options, cors) => {
|
|
33
|
+
let body;
|
|
34
|
+
try {
|
|
35
|
+
body = await request.json();
|
|
36
|
+
} catch {
|
|
37
|
+
return new Response("Bad Request", { status: 400, headers: cors });
|
|
38
|
+
}
|
|
39
|
+
const raws = Array.isArray(body) ? body : [body];
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const userAgent = request.headers.get("User-Agent") ?? void 0;
|
|
42
|
+
const country = request.headers.get("CF-IPCountry") ?? void 0;
|
|
43
|
+
const context = { now, userAgent, country, stripQuery: !options.keepQuery };
|
|
44
|
+
const events = raws.map((raw) => normalizeEvent(raw, context));
|
|
45
|
+
try {
|
|
46
|
+
if (options.adapter.insertBatch && events.length > 1) {
|
|
47
|
+
await options.adapter.insertBatch(events);
|
|
48
|
+
} else {
|
|
49
|
+
await Promise.all(events.map((event) => options.adapter.insert(event)));
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
return new Response("Storage Error", { status: 502, headers: cors });
|
|
53
|
+
}
|
|
54
|
+
return new Response(null, { status: 204, headers: cors });
|
|
55
|
+
};
|
|
56
|
+
var parseInterval = (value) => value === "hour" ? "hour" : "day";
|
|
57
|
+
var handleStats = async (url, adapter, cors) => {
|
|
58
|
+
const params = url.searchParams;
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const query = {
|
|
61
|
+
from: Number(params.get("from")) || now - 7 * 864e5,
|
|
62
|
+
to: Number(params.get("to")) || now,
|
|
63
|
+
limit: Number(params.get("limit")) || 10,
|
|
64
|
+
interval: parseInterval(params.get("interval"))
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
const result = await adapter.stats(query);
|
|
68
|
+
return Response.json(result, { headers: cors });
|
|
69
|
+
} catch {
|
|
70
|
+
return new Response("Query Error", { status: 502, headers: cors });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// src/node.ts
|
|
75
|
+
var readBody = (req) => new Promise((resolve, reject) => {
|
|
76
|
+
const chunks = [];
|
|
77
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
78
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
79
|
+
req.on("error", reject);
|
|
80
|
+
});
|
|
81
|
+
var toRequest = async (req) => {
|
|
82
|
+
const host = req.headers.host ?? "localhost";
|
|
83
|
+
const proto = req.headers["x-forwarded-proto"] ?? "http";
|
|
84
|
+
const url = `${proto}://${host}${req.url ?? "/"}`;
|
|
85
|
+
const headers = new Headers();
|
|
86
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
87
|
+
if (value === void 0) continue;
|
|
88
|
+
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
|
89
|
+
}
|
|
90
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
91
|
+
const body = hasBody ? new Uint8Array(await readBody(req)) : void 0;
|
|
92
|
+
return new Request(url, { method: req.method, headers, body });
|
|
93
|
+
};
|
|
94
|
+
var writeResponse = async (res, response) => {
|
|
95
|
+
const headers = {};
|
|
96
|
+
response.headers.forEach((value, key) => {
|
|
97
|
+
headers[key] = value;
|
|
98
|
+
});
|
|
99
|
+
res.writeHead(response.status, headers);
|
|
100
|
+
res.end(response.body ? Buffer.from(await response.arrayBuffer()) : void 0);
|
|
101
|
+
};
|
|
102
|
+
var createNodeListener = (handler) => async (req, res) => {
|
|
103
|
+
const request = await toRequest(req);
|
|
104
|
+
const response = await handler(request);
|
|
105
|
+
await writeResponse(res, response);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export { createHandler, createNodeListener };
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { FetchHandler } from "./handler.js";
|
|
3
|
+
/**
|
|
4
|
+
* Adapt a fetch handler into a `node:http` request listener, so the exact same
|
|
5
|
+
* endpoint runs on a plain Node server, Express, etc.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { createServer } from "node:http";
|
|
9
|
+
* createServer(createNodeListener(handler)).listen(3000);
|
|
10
|
+
*/
|
|
11
|
+
export declare const createNodeListener: (handler: FetchHandler) => (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unifeather/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic tracking endpoint (Web Request→Response) plus storage adapters for Cloudflare Analytics Engine and Drizzle-backed SQL databases.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Dennis Hendricks <dennis@hendricks.rocks>",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"analytics",
|
|
10
|
+
"privacy",
|
|
11
|
+
"web-analytics",
|
|
12
|
+
"tracking",
|
|
13
|
+
"drizzle",
|
|
14
|
+
"cloudflare",
|
|
15
|
+
"analytics-engine",
|
|
16
|
+
"edge"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/dennishendricks/unifeather.git",
|
|
21
|
+
"directory": "packages/server"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/dennishendricks/unifeather#readme",
|
|
24
|
+
"bugs": "https://github.com/dennishendricks/unifeather/issues",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"module": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./analytics-engine": {
|
|
35
|
+
"types": "./dist/adapters/analytics-engine.d.ts",
|
|
36
|
+
"import": "./dist/adapters/analytics-engine.js"
|
|
37
|
+
},
|
|
38
|
+
"./sql": {
|
|
39
|
+
"types": "./dist/adapters/sql.d.ts",
|
|
40
|
+
"import": "./dist/adapters/sql.js"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"prepublishOnly": "bun run build",
|
|
48
|
+
"build": "tsup && tsc --emitDeclarationOnly",
|
|
49
|
+
"dev": "tsup --watch",
|
|
50
|
+
"typecheck": "tsc --noEmit"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@unifeather/core": "^0.1.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"drizzle-orm": ">=0.45.2"
|
|
57
|
+
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"drizzle-orm": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@cloudflare/workers-types": "^5.20260715.1",
|
|
65
|
+
"@electric-sql/pglite": "^0.5.4",
|
|
66
|
+
"@types/node": "^26.1.1",
|
|
67
|
+
"drizzle-orm": "^0.45.2",
|
|
68
|
+
"tsup": "^8.5.1",
|
|
69
|
+
"typescript": "^7.0.2"
|
|
70
|
+
},
|
|
71
|
+
"publishConfig": {
|
|
72
|
+
"access": "public"
|
|
73
|
+
}
|
|
74
|
+
}
|