@sjohansson/astro-plausible 0.0.0-stage → 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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options-B-SB8PRR.js","names":[],"sources":["../src/options.ts"],"sourcesContent":["import type { PlausibleConfig } from \"@plausible-analytics/tracker\";\n\n/** Tracker settings that can be written down as JSON. These go straight to the tracker's `init()`. */\nexport interface PlausibleOptions {\n /** Your site's domain, as entered in Plausible. */\n domain: string;\n\n /**\n * Where events go. `/api/event` is the relay from `astro-plausible init` on your own domain. Set the full\n * Plausible URL, `https://plausible.io/api/event`, to skip the relay.\n * @default \"/api/event\"\n */\n endpoint?: string;\n\n /**\n * Send a pageview on load and on history navigation.\n * @default true\n */\n autoCapturePageviews?: boolean;\n\n /**\n * Track `#` paths as separate pages.\n * @default false\n */\n hashBasedRouting?: boolean;\n\n /**\n * Track clicks on links to other sites.\n * @default false\n */\n outboundLinks?: boolean;\n\n /**\n * Track file downloads, with Plausible's default file types or the ones given.\n * @default false\n */\n fileDownloads?: boolean | { fileExtensions: string[] };\n\n /**\n * Track form submissions.\n * @default false\n */\n formSubmissions?: boolean;\n\n /**\n * Also track on localhost.\n * @default false\n */\n captureOnLocalhost?: boolean;\n\n /**\n * Log ignored events to the console.\n * @default true\n */\n logging?: boolean;\n\n /** Custom properties added to every event. */\n customProperties?: Record<string, string | number | boolean>;\n\n /**\n * Expose the tracker as `window.plausible`. Plausible's installation check looks for it.\n * @default true\n */\n bindToWindow?: boolean;\n}\n\nexport const DEFAULT_ENDPOINT = \"/api/event\";\n\nconst DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(\\/[\\w.-]+)*$/i;\nconst BOOLEAN_KEYS = [\n \"autoCapturePageviews\",\n \"hashBasedRouting\",\n \"outboundLinks\",\n \"formSubmissions\",\n \"captureOnLocalhost\",\n \"logging\",\n \"bindToWindow\",\n] as const;\n\nfunction fail(message: string): never {\n throw new Error(`[@sjohansson/astro-plausible] ${message}`);\n}\n\n/** Validates the options and returns the config for the tracker's `init()`. Only keys that were set are included. */\nexport function plausibleConfig(options: PlausibleOptions): PlausibleConfig {\n if (typeof options?.domain !== \"string\" || options.domain.length > 253 || !DOMAIN_PATTERN.test(options.domain)) {\n fail(\"domain must be the site domain as entered in Plausible, like example.com.\");\n }\n const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;\n if (typeof endpoint !== \"string\" || endpoint.length === 0) fail(\"endpoint must be a non-empty string.\");\n\n const config: PlausibleConfig = { domain: options.domain, endpoint };\n\n for (const key of BOOLEAN_KEYS) {\n const value = options[key];\n if (value === undefined) continue;\n if (typeof value !== \"boolean\") fail(`${key} must be a boolean.`);\n config[key] = value;\n }\n if (options.fileDownloads !== undefined) {\n const value = options.fileDownloads;\n const custom = typeof value === \"object\" && value !== null && Array.isArray(value.fileExtensions);\n if (typeof value !== \"boolean\" && !custom) fail(\"fileDownloads must be a boolean or { fileExtensions: [...] }.\");\n config.fileDownloads = value;\n }\n if (options.customProperties !== undefined) {\n const value = options.customProperties;\n const plain =\n typeof value === \"object\" &&\n value !== null &&\n !Array.isArray(value) &&\n Object.values(value).every((v) => [\"string\", \"number\", \"boolean\"].includes(typeof v));\n if (!plain) fail(\"customProperties must be an object of strings, numbers and booleans.\");\n config.customProperties = value;\n }\n return config;\n}\n"],"mappings":";AAkEA,MAAa,mBAAmB;AAEhC,MAAM,iBAAiB;AACvB,MAAM,eAAe;CACnB;CACA;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,SAAS,KAAK,SAAwB;CACpC,MAAM,IAAI,MAAM,iCAAiC,SAAS;AAC5D;;AAGA,SAAgB,gBAAgB,SAA4C;CAC1E,IAAI,OAAO,SAAS,WAAW,YAAY,QAAQ,OAAO,SAAS,OAAO,CAAC,eAAe,KAAK,QAAQ,MAAM,GAC3G,KAAK,2EAA2E;CAElF,MAAM,WAAW,QAAQ,YAAA;CACzB,IAAI,OAAO,aAAa,YAAY,SAAS,WAAW,GAAG,KAAK,sCAAsC;CAEtG,MAAM,SAA0B;EAAE,QAAQ,QAAQ;EAAQ;CAAS;CAEnE,KAAK,MAAM,OAAO,cAAc;EAC9B,MAAM,QAAQ,QAAQ;EACtB,IAAI,UAAU,KAAA,GAAW;EACzB,IAAI,OAAO,UAAU,WAAW,KAAK,GAAG,IAAI,oBAAoB;EAChE,OAAO,OAAO;CAChB;CACA,IAAI,QAAQ,kBAAkB,KAAA,GAAW;EACvC,MAAM,QAAQ,QAAQ;EACtB,MAAM,SAAS,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,MAAM,cAAc;EAChG,IAAI,OAAO,UAAU,aAAa,CAAC,QAAQ,KAAK,+DAA+D;EAC/G,OAAO,gBAAgB;CACzB;CACA,IAAI,QAAQ,qBAAqB,KAAA,GAAW;EAC1C,MAAM,QAAQ,QAAQ;EAMtB,IAAI,EAJF,OAAO,UAAU,YACjB,UAAU,QACV,CAAC,MAAM,QAAQ,KAAK,KACpB,OAAO,OAAO,KAAK,CAAC,CAAC,OAAO,MAAM;GAAC;GAAU;GAAU;EAAS,CAAC,CAAC,SAAS,OAAO,CAAC,CAAC,IAC1E,KAAK,sEAAsE;EACvF,OAAO,mBAAmB;CAC5B;CACA,OAAO;AACT"}
@@ -0,0 +1,61 @@
1
+ import { PlausibleConfig } from "@plausible-analytics/tracker";
2
+ /** Tracker settings that can be written down as JSON. These go straight to the tracker's `init()`. */
3
+ interface PlausibleOptions {
4
+ /** Your site's domain, as entered in Plausible. */
5
+ domain: string;
6
+ /**
7
+ * Where events go. `/api/event` is the relay from `astro-plausible init` on your own domain. Set the full
8
+ * Plausible URL, `https://plausible.io/api/event`, to skip the relay.
9
+ * @default "/api/event"
10
+ */
11
+ endpoint?: string;
12
+ /**
13
+ * Send a pageview on load and on history navigation.
14
+ * @default true
15
+ */
16
+ autoCapturePageviews?: boolean;
17
+ /**
18
+ * Track `#` paths as separate pages.
19
+ * @default false
20
+ */
21
+ hashBasedRouting?: boolean;
22
+ /**
23
+ * Track clicks on links to other sites.
24
+ * @default false
25
+ */
26
+ outboundLinks?: boolean;
27
+ /**
28
+ * Track file downloads, with Plausible's default file types or the ones given.
29
+ * @default false
30
+ */
31
+ fileDownloads?: boolean | {
32
+ fileExtensions: string[];
33
+ };
34
+ /**
35
+ * Track form submissions.
36
+ * @default false
37
+ */
38
+ formSubmissions?: boolean;
39
+ /**
40
+ * Also track on localhost.
41
+ * @default false
42
+ */
43
+ captureOnLocalhost?: boolean;
44
+ /**
45
+ * Log ignored events to the console.
46
+ * @default true
47
+ */
48
+ logging?: boolean;
49
+ /** Custom properties added to every event. */
50
+ customProperties?: Record<string, string | number | boolean>;
51
+ /**
52
+ * Expose the tracker as `window.plausible`. Plausible's installation check looks for it.
53
+ * @default true
54
+ */
55
+ bindToWindow?: boolean;
56
+ }
57
+ declare const DEFAULT_ENDPOINT = "/api/event";
58
+ /** Validates the options and returns the config for the tracker's `init()`. Only keys that were set are included. */
59
+ declare function plausibleConfig(options: PlausibleOptions): PlausibleConfig;
60
+ export { PlausibleOptions as n, plausibleConfig as r, DEFAULT_ENDPOINT as t };
61
+ //# sourceMappingURL=options-C15OXXsk.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options-C15OXXsk.d.ts","names":[],"sources":["../src/options.ts"],"mappings":";;UAGiB;;EAEf;;;;;;EAOA;;;;;EAMA;;;;;EAMA;;;;;EAMA;;;;;EAMA;IAA4B;;;;;;EAM5B;;;;;EAMA;;;;;EAMA;;EAGA,mBAAmB;;;;;EAMnB;;cAGW;;iBAkBG,gBAAgB,SAAS,mBAAmB"}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The relay forwards tracker events from your domain to Plausible. It is written against the web standard Request
3
+ * and Response, so it runs anywhere fetch does: an Astro endpoint, Cloudflare, Netlify, Vercel, or through the
4
+ * `swaHandler` adapter, Azure Static Web Apps managed functions.
5
+ *
6
+ * It checks the domain so the endpoint only relays for your sites, passes the visitor's IP in `X-Forwarded-For`
7
+ * and their user agent (Plausible drops events without a real visitor IP), caps the body size, and can validate
8
+ * custom events.
9
+ */
10
+ /** Per property pattern that an event's props must match. Only the listed properties are forwarded. */
11
+ export type RelayRule = Record<string, RegExp | string>;
12
+ export interface RelayOptions {
13
+ /** Site domain as entered in Plausible, or several when one relay serves several sites. */
14
+ domain: string | string[];
15
+ /**
16
+ * The Plausible host. Events are posted to `<upstream>/api/event`. Self-hosted: `https://plausible.example.com`.
17
+ * @default "https://plausible.io"
18
+ */
19
+ upstream?: string;
20
+ /**
21
+ * Largest request body accepted, in bytes.
22
+ * @default 16384
23
+ */
24
+ maxBodyLength?: number;
25
+ /**
26
+ * Headers to read the visitor IP from, in order, when `handle` or `forward` is not given one. A client can send
27
+ * any header, so list only the ones your platform sets: `["cf-connecting-ip"]` on Cloudflare.
28
+ * @default DEFAULT_IP_HEADERS
29
+ */
30
+ ipHeaders?: string[];
31
+ /**
32
+ * Validation for custom events, by event name. An event with a rule is forwarded only if every listed property
33
+ * is a string matching its pattern, and with only those properties. Events without a rule pass through as is.
34
+ */
35
+ events?: Record<string, RelayRule>;
36
+ /** Called when Plausible cannot be reached. */
37
+ onError?: (error: unknown) => void;
38
+ }
39
+ /** Incoming request headers with lowercased names, as Node and most platforms give them. */
40
+ export type RelayHeaders = Record<string, string | string[] | undefined>;
41
+ export interface RelayForward {
42
+ url: string;
43
+ body: string;
44
+ headers: Record<string, string>;
45
+ }
46
+ export interface RelayResult {
47
+ status: number;
48
+ contentType: string;
49
+ body: string;
50
+ }
51
+ export interface Relay {
52
+ /** Builds the upstream request, or null when the event is not for this relay or not valid. */
53
+ forward(rawBody: unknown, headers: RelayHeaders, clientIp?: string): RelayForward | null;
54
+ /** Sends a forward to Plausible. Throws when Plausible cannot be reached. */
55
+ send(forward: RelayForward): Promise<RelayResult>;
56
+ /** Handles a web standard request end to end. Pass the visitor IP when the platform gives it to you. */
57
+ handle(request: Request, context?: {
58
+ clientIp?: string;
59
+ }): Promise<Response>;
60
+ }
61
+ export declare const DEFAULT_UPSTREAM = "https://plausible.io";
62
+ export declare const DEFAULT_MAX_BODY_LENGTH: number;
63
+ /**
64
+ * Platform headers that carry the visitor IP, most specific first. x-forwarded-for is last because proxies append to
65
+ * it and some let the client seed it. The first header present wins, so on a platform that sets only one of these a
66
+ * client can spoof the IP by sending one ranked above it; set `ipHeaders` there.
67
+ */
68
+ export declare const DEFAULT_IP_HEADERS: readonly string[];
69
+ /** Visitor IP from platform headers, without any port. Undefined when no header carries one. */
70
+ export declare function clientIp(headers: RelayHeaders, names?: readonly string[]): string | undefined;
71
+ export declare function createRelay(options: RelayOptions): Relay;
72
+ /** The subset of the Azure Functions v3 context the adapter uses. */
73
+ export interface SwaContext {
74
+ log: {
75
+ warn(message: string): void;
76
+ };
77
+ res?: {
78
+ status: number;
79
+ headers?: Record<string, string>;
80
+ body?: string;
81
+ };
82
+ }
83
+ export interface SwaRequest {
84
+ rawBody?: unknown;
85
+ headers?: RelayHeaders;
86
+ }
87
+ /**
88
+ * Adapter for Azure Static Web Apps managed functions (Node, `function.json` model). `astro-plausible init` writes
89
+ * a function that uses this.
90
+ */
91
+ export declare function swaHandler(relay: Relay): (context: SwaContext, req: SwaRequest) => Promise<void>;
92
+ //# sourceMappingURL=relay.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relay.d.ts","names":[],"sources":["../src/relay.ts"],"mappings":";;;;;;;;;;YAWY,YAAY,eAAe;iBAEtB;;EAEf;;;;;EAMA;;;;;EAMA;;;;;;EAOA;;;;;EAMA,SAAS,eAAe;;EAGxB,WAAW;;;YAID,eAAe;iBAEV;EACf;EACA;EACA,SAAS;;iBAGM;EACf;EACA;EACA;;iBAGe;;EAEf,QAAQ,kBAAkB,SAAS,cAAc,oBAAoB;;EAErE,KAAK,SAAS,eAAe,QAAQ;;EAErC,OAAO,SAAS,SAAS;IAAY;MAAsB,QAAQ;;qBAGxD;qBACA;;;;;;qBAOA;;wBAeG,SAAS,SAAS,cAAc;wBAuEhC,YAAY,SAAS,eAAe;;iBAiFnC;EACf;IAAO,KAAK;;EACZ;IAAQ;IAAgB,UAAU;IAAwB;;;iBAG3C;EACf;EACA,UAAU;;;;;;wBAOI,WAAW,OAAO,SAAS,SAAS,YAAY,KAAK,eAAe"}
package/dist/relay.js ADDED
@@ -0,0 +1,189 @@
1
+ // src/relay.ts
2
+ const DEFAULT_UPSTREAM = "https://plausible.io";
3
+ const DEFAULT_MAX_BODY_LENGTH = 16384;
4
+ /**
5
+ * Platform headers that carry the visitor IP, most specific first. x-forwarded-for is last because proxies append to
6
+ * it and some let the client seed it. The first header present wins, so on a platform that sets only one of these a
7
+ * client can spoof the IP by sending one ranked above it; set `ipHeaders` there.
8
+ */
9
+ const DEFAULT_IP_HEADERS = [
10
+ "x-azure-clientip",
11
+ "x-client-ip",
12
+ "cf-connecting-ip",
13
+ "x-nf-client-connection-ip",
14
+ "x-real-ip",
15
+ "x-forwarded-for"
16
+ ];
17
+ function header(headers, name) {
18
+ const value = headers[name];
19
+ return Array.isArray(value) ? value[0] : value;
20
+ }
21
+ /** Visitor IP from platform headers, without any port. Undefined when no header carries one. */
22
+ function clientIp(headers, names = DEFAULT_IP_HEADERS) {
23
+ for (const name of names) {
24
+ const raw = header(headers, name)?.split(",")[0]?.trim();
25
+ if (!raw) continue;
26
+ const bracketed = raw.match(/^\[(.+)\]:\d+$/);
27
+ if (bracketed) return bracketed[1];
28
+ const ipv4WithPort = raw.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/);
29
+ return ipv4WithPort ? ipv4WithPort[1] : raw;
30
+ }
31
+ }
32
+ function compileRules(events = {}) {
33
+ const compiled = /* @__PURE__ */ new Map();
34
+ for (const [name, rule] of Object.entries(events)) {
35
+ const entries = Object.entries(rule).map(([property, pattern]) => [property, pattern instanceof RegExp ? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, "")) : new RegExp(pattern)]);
36
+ if (entries.length === 0) throw new Error(`[@sjohansson/astro-plausible] Rule for event "${name}" lists no properties.`);
37
+ compiled.set(name, entries);
38
+ }
39
+ return compiled;
40
+ }
41
+ const PROPS_ALIASES = [
42
+ "m",
43
+ "meta",
44
+ "props"
45
+ ];
46
+ /** Applies the rule for an event. Returns the props to forward, or null when the event is not valid. */
47
+ function applyRule(rules, props) {
48
+ const source = props && typeof props === "object" ? props : {};
49
+ const kept = {};
50
+ for (const [property, pattern] of rules) {
51
+ const value = source[property];
52
+ if (typeof value !== "string" || !pattern.test(value)) return null;
53
+ kept[property] = value;
54
+ }
55
+ return kept;
56
+ }
57
+ const encoder = new TextEncoder();
58
+ /** Reads a request body as text, or returns null as soon as it grows past `limit` bytes. */
59
+ async function readBody(request, limit) {
60
+ if (!request.body) return "";
61
+ const reader = request.body.getReader();
62
+ const decoder = new TextDecoder();
63
+ let text = "";
64
+ let size = 0;
65
+ for (;;) {
66
+ const { done, value } = await reader.read();
67
+ if (done) return text + decoder.decode();
68
+ size += value.byteLength;
69
+ if (size > limit) {
70
+ await reader.cancel();
71
+ return null;
72
+ }
73
+ text += decoder.decode(value, { stream: true });
74
+ }
75
+ }
76
+ function headersToRecord(headers) {
77
+ const record = {};
78
+ headers.forEach((value, name) => {
79
+ record[name.toLowerCase()] = value;
80
+ });
81
+ return record;
82
+ }
83
+ function createRelay(options) {
84
+ const domains = new Set(Array.isArray(options.domain) ? options.domain : [options.domain]);
85
+ if (domains.size === 0 || [...domains].some((d) => typeof d !== "string" || d.length === 0)) throw new Error("[@sjohansson/astro-plausible] domain is required.");
86
+ const upstream = `${(options.upstream ?? "https://plausible.io").replace(/\/$/, "")}/api/event`;
87
+ const maxBodyLength = options.maxBodyLength ?? 16384;
88
+ const rules = compileRules(options.events);
89
+ const ipHeaders = options.ipHeaders?.map((name) => name.toLowerCase()) ?? DEFAULT_IP_HEADERS;
90
+ const relay = {
91
+ forward(rawBody, headers, ip) {
92
+ if (typeof rawBody !== "string" || rawBody.length === 0) return null;
93
+ if (rawBody.length > maxBodyLength || encoder.encode(rawBody).length > maxBodyLength) return null;
94
+ let event;
95
+ try {
96
+ event = JSON.parse(rawBody);
97
+ } catch {
98
+ return null;
99
+ }
100
+ if (!event || typeof event !== "object" || Array.isArray(event)) return null;
101
+ if (typeof event["d"] !== "string" || !domains.has(event["d"])) return null;
102
+ const name = event["n"];
103
+ if (typeof name !== "string" || name.length === 0) return null;
104
+ if (rules.has(name)) {
105
+ const kept = applyRule(rules.get(name) ?? [], event["p"]);
106
+ if (!kept) return null;
107
+ for (const alias of PROPS_ALIASES) delete event[alias];
108
+ event["p"] = kept;
109
+ }
110
+ const forwardHeaders = { "content-type": "application/json" };
111
+ const userAgent = header(headers, "user-agent");
112
+ if (userAgent) forwardHeaders["user-agent"] = userAgent;
113
+ const visitorIp = ip ?? clientIp(headers, ipHeaders);
114
+ if (visitorIp) forwardHeaders["x-forwarded-for"] = visitorIp;
115
+ return {
116
+ url: upstream,
117
+ body: JSON.stringify(event),
118
+ headers: forwardHeaders
119
+ };
120
+ },
121
+ async send(forward) {
122
+ const res = await fetch(forward.url, {
123
+ method: "POST",
124
+ headers: forward.headers,
125
+ body: forward.body
126
+ });
127
+ return {
128
+ status: res.status,
129
+ contentType: res.headers.get("content-type") ?? "text/plain",
130
+ body: await res.text()
131
+ };
132
+ },
133
+ async handle(request, context = {}) {
134
+ if (request.method !== "POST") return new Response(null, {
135
+ status: 405,
136
+ headers: { allow: "POST" }
137
+ });
138
+ if (Number(request.headers.get("content-length")) > maxBodyLength) return new Response(null, { status: 413 });
139
+ const body = await readBody(request, maxBodyLength);
140
+ if (body === null) return new Response(null, { status: 413 });
141
+ const forward = relay.forward(body, headersToRecord(request.headers), context.clientIp);
142
+ if (!forward) return new Response(null, { status: 400 });
143
+ try {
144
+ const result = await relay.send(forward);
145
+ return new Response(result.body, {
146
+ status: result.status,
147
+ headers: {
148
+ "content-type": result.contentType,
149
+ "cache-control": "no-store"
150
+ }
151
+ });
152
+ } catch (error) {
153
+ options.onError?.(error);
154
+ return new Response(null, { status: 502 });
155
+ }
156
+ }
157
+ };
158
+ return relay;
159
+ }
160
+ /**
161
+ * Adapter for Azure Static Web Apps managed functions (Node, `function.json` model). `astro-plausible init` writes
162
+ * a function that uses this.
163
+ */
164
+ function swaHandler(relay) {
165
+ return async (context, req) => {
166
+ const forward = relay.forward(req.rawBody, req.headers ?? {});
167
+ if (!forward) {
168
+ context.res = { status: 400 };
169
+ return;
170
+ }
171
+ try {
172
+ const result = await relay.send(forward);
173
+ context.res = {
174
+ status: result.status,
175
+ headers: {
176
+ "content-type": result.contentType,
177
+ "cache-control": "no-store"
178
+ },
179
+ body: result.body
180
+ };
181
+ } catch (error) {
182
+ context.log.warn(`Plausible forward failed: ${error}`);
183
+ context.res = { status: 502 };
184
+ }
185
+ };
186
+ }
187
+ export { DEFAULT_IP_HEADERS, DEFAULT_MAX_BODY_LENGTH, DEFAULT_UPSTREAM, clientIp, createRelay, swaHandler };
188
+
189
+ //# sourceMappingURL=relay.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relay.js","names":[],"sources":["../src/relay.ts"],"sourcesContent":["/**\n * The relay forwards tracker events from your domain to Plausible. It is written against the web standard Request\n * and Response, so it runs anywhere fetch does: an Astro endpoint, Cloudflare, Netlify, Vercel, or through the\n * `swaHandler` adapter, Azure Static Web Apps managed functions.\n *\n * It checks the domain so the endpoint only relays for your sites, passes the visitor's IP in `X-Forwarded-For`\n * and their user agent (Plausible drops events without a real visitor IP), caps the body size, and can validate\n * custom events.\n */\n\n/** Per property pattern that an event's props must match. Only the listed properties are forwarded. */\nexport type RelayRule = Record<string, RegExp | string>;\n\nexport interface RelayOptions {\n /** Site domain as entered in Plausible, or several when one relay serves several sites. */\n domain: string | string[];\n\n /**\n * The Plausible host. Events are posted to `<upstream>/api/event`. Self-hosted: `https://plausible.example.com`.\n * @default \"https://plausible.io\"\n */\n upstream?: string;\n\n /**\n * Largest request body accepted, in bytes.\n * @default 16384\n */\n maxBodyLength?: number;\n\n /**\n * Headers to read the visitor IP from, in order, when `handle` or `forward` is not given one. A client can send\n * any header, so list only the ones your platform sets: `[\"cf-connecting-ip\"]` on Cloudflare.\n * @default DEFAULT_IP_HEADERS\n */\n ipHeaders?: string[];\n\n /**\n * Validation for custom events, by event name. An event with a rule is forwarded only if every listed property\n * is a string matching its pattern, and with only those properties. Events without a rule pass through as is.\n */\n events?: Record<string, RelayRule>;\n\n /** Called when Plausible cannot be reached. */\n onError?: (error: unknown) => void;\n}\n\n/** Incoming request headers with lowercased names, as Node and most platforms give them. */\nexport type RelayHeaders = Record<string, string | string[] | undefined>;\n\nexport interface RelayForward {\n url: string;\n body: string;\n headers: Record<string, string>;\n}\n\nexport interface RelayResult {\n status: number;\n contentType: string;\n body: string;\n}\n\nexport interface Relay {\n /** Builds the upstream request, or null when the event is not for this relay or not valid. */\n forward(rawBody: unknown, headers: RelayHeaders, clientIp?: string): RelayForward | null;\n /** Sends a forward to Plausible. Throws when Plausible cannot be reached. */\n send(forward: RelayForward): Promise<RelayResult>;\n /** Handles a web standard request end to end. Pass the visitor IP when the platform gives it to you. */\n handle(request: Request, context?: { clientIp?: string }): Promise<Response>;\n}\n\nexport const DEFAULT_UPSTREAM = \"https://plausible.io\";\nexport const DEFAULT_MAX_BODY_LENGTH = 16 * 1024;\n\n/**\n * Platform headers that carry the visitor IP, most specific first. x-forwarded-for is last because proxies append to\n * it and some let the client seed it. The first header present wins, so on a platform that sets only one of these a\n * client can spoof the IP by sending one ranked above it; set `ipHeaders` there.\n */\nexport const DEFAULT_IP_HEADERS: readonly string[] = [\n \"x-azure-clientip\",\n \"x-client-ip\",\n \"cf-connecting-ip\",\n \"x-nf-client-connection-ip\",\n \"x-real-ip\",\n \"x-forwarded-for\",\n];\n\nfunction header(headers: RelayHeaders, name: string): string | undefined {\n const value = headers[name];\n return Array.isArray(value) ? value[0] : value;\n}\n\n/** Visitor IP from platform headers, without any port. Undefined when no header carries one. */\nexport function clientIp(headers: RelayHeaders, names: readonly string[] = DEFAULT_IP_HEADERS): string | undefined {\n for (const name of names) {\n const raw = header(headers, name)?.split(\",\")[0]?.trim();\n if (!raw) continue;\n const bracketed = raw.match(/^\\[(.+)\\]:\\d+$/);\n if (bracketed) return bracketed[1];\n const ipv4WithPort = raw.match(/^(\\d{1,3}(?:\\.\\d{1,3}){3}):\\d+$/);\n return ipv4WithPort ? ipv4WithPort[1] : raw;\n }\n return undefined;\n}\n\nfunction compileRules(events: Record<string, RelayRule> = {}): Map<string, [string, RegExp][]> {\n const compiled = new Map<string, [string, RegExp][]>();\n for (const [name, rule] of Object.entries(events)) {\n const entries = Object.entries(rule).map(([property, pattern]): [string, RegExp] => [\n property,\n // Drop g and y: they make test() stateful, and the rules are shared across requests.\n pattern instanceof RegExp ? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, \"\")) : new RegExp(pattern),\n ]);\n if (entries.length === 0) {\n throw new Error(`[@sjohansson/astro-plausible] Rule for event \"${name}\" lists no properties.`);\n }\n compiled.set(name, entries);\n }\n return compiled;\n}\n\nconst PROPS_ALIASES = [\"m\", \"meta\", \"props\"];\n\n/** Applies the rule for an event. Returns the props to forward, or null when the event is not valid. */\nfunction applyRule(rules: [string, RegExp][], props: unknown): Record<string, string> | null {\n const source = props && typeof props === \"object\" ? (props as Record<string, unknown>) : {};\n const kept: Record<string, string> = {};\n for (const [property, pattern] of rules) {\n const value = source[property];\n if (typeof value !== \"string\" || !pattern.test(value)) return null;\n kept[property] = value;\n }\n return kept;\n}\n\nconst encoder = new TextEncoder();\n\n/** Reads a request body as text, or returns null as soon as it grows past `limit` bytes. */\nasync function readBody(request: Request, limit: number): Promise<string | null> {\n if (!request.body) return \"\";\n const reader = request.body.getReader();\n const decoder = new TextDecoder();\n let text = \"\";\n let size = 0;\n for (;;) {\n const { done, value } = await reader.read();\n if (done) return text + decoder.decode();\n size += value.byteLength;\n if (size > limit) {\n await reader.cancel();\n return null;\n }\n text += decoder.decode(value, { stream: true });\n }\n}\n\nfunction headersToRecord(headers: Headers): RelayHeaders {\n const record: RelayHeaders = {};\n headers.forEach((value, name) => {\n record[name.toLowerCase()] = value;\n });\n return record;\n}\n\nexport function createRelay(options: RelayOptions): Relay {\n const domains = new Set(Array.isArray(options.domain) ? options.domain : [options.domain]);\n if (domains.size === 0 || [...domains].some((d) => typeof d !== \"string\" || d.length === 0)) {\n throw new Error(\"[@sjohansson/astro-plausible] domain is required.\");\n }\n const upstream = `${(options.upstream ?? DEFAULT_UPSTREAM).replace(/\\/$/, \"\")}/api/event`;\n const maxBodyLength = options.maxBodyLength ?? DEFAULT_MAX_BODY_LENGTH;\n const rules = compileRules(options.events);\n const ipHeaders = options.ipHeaders?.map((name) => name.toLowerCase()) ?? DEFAULT_IP_HEADERS;\n\n const relay: Relay = {\n forward(rawBody, headers, ip) {\n if (typeof rawBody !== \"string\" || rawBody.length === 0) return null;\n // A UTF-16 code unit is at least one UTF-8 byte, so the length check spares encoding a body that is far too big.\n if (rawBody.length > maxBodyLength || encoder.encode(rawBody).length > maxBodyLength) return null;\n\n let event: Record<string, unknown>;\n try {\n event = JSON.parse(rawBody);\n } catch {\n return null;\n }\n if (!event || typeof event !== \"object\" || Array.isArray(event)) return null;\n if (typeof event[\"d\"] !== \"string\" || !domains.has(event[\"d\"])) return null;\n const name = event[\"n\"];\n if (typeof name !== \"string\" || name.length === 0) return null;\n\n if (rules.has(name)) {\n const kept = applyRule(rules.get(name) ?? [], event[\"p\"]);\n if (!kept) return null;\n // Plausible also takes props as m, meta or props, and reads m and meta before p.\n for (const alias of PROPS_ALIASES) delete event[alias];\n event[\"p\"] = kept;\n }\n\n const forwardHeaders: Record<string, string> = { \"content-type\": \"application/json\" };\n const userAgent = header(headers, \"user-agent\");\n if (userAgent) forwardHeaders[\"user-agent\"] = userAgent;\n const visitorIp = ip ?? clientIp(headers, ipHeaders);\n if (visitorIp) forwardHeaders[\"x-forwarded-for\"] = visitorIp;\n\n return { url: upstream, body: JSON.stringify(event), headers: forwardHeaders };\n },\n\n async send(forward) {\n const res = await fetch(forward.url, { method: \"POST\", headers: forward.headers, body: forward.body });\n return {\n status: res.status,\n contentType: res.headers.get(\"content-type\") ?? \"text/plain\",\n body: await res.text(),\n };\n },\n\n async handle(request, context = {}) {\n if (request.method !== \"POST\") return new Response(null, { status: 405, headers: { allow: \"POST\" } });\n const length = Number(request.headers.get(\"content-length\"));\n if (length > maxBodyLength) return new Response(null, { status: 413 });\n\n // Content-Length can be missing (chunked) or wrong, so the limit also applies while reading.\n const body = await readBody(request, maxBodyLength);\n if (body === null) return new Response(null, { status: 413 });\n\n const forward = relay.forward(body, headersToRecord(request.headers), context.clientIp);\n if (!forward) return new Response(null, { status: 400 });\n\n try {\n const result = await relay.send(forward);\n return new Response(result.body, {\n status: result.status,\n headers: { \"content-type\": result.contentType, \"cache-control\": \"no-store\" },\n });\n } catch (error) {\n options.onError?.(error);\n return new Response(null, { status: 502 });\n }\n },\n };\n return relay;\n}\n\n/** The subset of the Azure Functions v3 context the adapter uses. */\nexport interface SwaContext {\n log: { warn(message: string): void };\n res?: { status: number; headers?: Record<string, string>; body?: string };\n}\n\nexport interface SwaRequest {\n rawBody?: unknown;\n headers?: RelayHeaders;\n}\n\n/**\n * Adapter for Azure Static Web Apps managed functions (Node, `function.json` model). `astro-plausible init` writes\n * a function that uses this.\n */\nexport function swaHandler(relay: Relay): (context: SwaContext, req: SwaRequest) => Promise<void> {\n return async (context, req) => {\n const forward = relay.forward(req.rawBody, req.headers ?? {});\n if (!forward) {\n context.res = { status: 400 };\n return;\n }\n try {\n const result = await relay.send(forward);\n context.res = {\n status: result.status,\n headers: { \"content-type\": result.contentType, \"cache-control\": \"no-store\" },\n body: result.body,\n };\n } catch (error) {\n context.log.warn(`Plausible forward failed: ${error}`);\n context.res = { status: 502 };\n }\n };\n}\n"],"mappings":";AAsEA,MAAa,mBAAmB;AAChC,MAAa,0BAA0B;;;;;;AAOvC,MAAa,qBAAwC;CACnD;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,SAAS,OAAO,SAAuB,MAAkC;CACvE,MAAM,QAAQ,QAAQ;CACtB,OAAO,MAAM,QAAQ,KAAK,IAAI,MAAM,KAAK;AAC3C;;AAGA,SAAgB,SAAS,SAAuB,QAA2B,oBAAwC;CACjH,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,MAAM,OAAO,SAAS,IAAI,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK;EACvD,IAAI,CAAC,KAAK;EACV,MAAM,YAAY,IAAI,MAAM,gBAAgB;EAC5C,IAAI,WAAW,OAAO,UAAU;EAChC,MAAM,eAAe,IAAI,MAAM,iCAAiC;EAChE,OAAO,eAAe,aAAa,KAAK;CAC1C;AAEF;AAEA,SAAS,aAAa,SAAoC,CAAC,GAAoC;CAC7F,MAAM,2BAAW,IAAI,IAAgC;CACrD,KAAK,MAAM,CAAC,MAAM,SAAS,OAAO,QAAQ,MAAM,GAAG;EACjD,MAAM,UAAU,OAAO,QAAQ,IAAI,CAAC,CAAC,KAAK,CAAC,UAAU,aAA+B,CAClF,UAEA,mBAAmB,SAAS,IAAI,OAAO,QAAQ,QAAQ,QAAQ,MAAM,QAAQ,SAAS,EAAE,CAAC,IAAI,IAAI,OAAO,OAAO,CACjH,CAAC;EACD,IAAI,QAAQ,WAAW,GACrB,MAAM,IAAI,MAAM,iDAAiD,KAAK,uBAAuB;EAE/F,SAAS,IAAI,MAAM,OAAO;CAC5B;CACA,OAAO;AACT;AAEA,MAAM,gBAAgB;CAAC;CAAK;CAAQ;AAAO;;AAG3C,SAAS,UAAU,OAA2B,OAA+C;CAC3F,MAAM,SAAS,SAAS,OAAO,UAAU,WAAY,QAAoC,CAAC;CAC1F,MAAM,OAA+B,CAAC;CACtC,KAAK,MAAM,CAAC,UAAU,YAAY,OAAO;EACvC,MAAM,QAAQ,OAAO;EACrB,IAAI,OAAO,UAAU,YAAY,CAAC,QAAQ,KAAK,KAAK,GAAG,OAAO;EAC9D,KAAK,YAAY;CACnB;CACA,OAAO;AACT;AAEA,MAAM,UAAU,IAAI,YAAY;;AAGhC,eAAe,SAAS,SAAkB,OAAuC;CAC/E,IAAI,CAAC,QAAQ,MAAM,OAAO;CAC1B,MAAM,SAAS,QAAQ,KAAK,UAAU;CACtC,MAAM,UAAU,IAAI,YAAY;CAChC,IAAI,OAAO;CACX,IAAI,OAAO;CACX,SAAS;EACP,MAAM,EAAE,MAAM,UAAU,MAAM,OAAO,KAAK;EAC1C,IAAI,MAAM,OAAO,OAAO,QAAQ,OAAO;EACvC,QAAQ,MAAM;EACd,IAAI,OAAO,OAAO;GAChB,MAAM,OAAO,OAAO;GACpB,OAAO;EACT;EACA,QAAQ,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;CAChD;AACF;AAEA,SAAS,gBAAgB,SAAgC;CACvD,MAAM,SAAuB,CAAC;CAC9B,QAAQ,SAAS,OAAO,SAAS;EAC/B,OAAO,KAAK,YAAY,KAAK;CAC/B,CAAC;CACD,OAAO;AACT;AAEA,SAAgB,YAAY,SAA8B;CACxD,MAAM,UAAU,IAAI,IAAI,MAAM,QAAQ,QAAQ,MAAM,IAAI,QAAQ,SAAS,CAAC,QAAQ,MAAM,CAAC;CACzF,IAAI,QAAQ,SAAS,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,MAAM,MAAM,OAAO,MAAM,YAAY,EAAE,WAAW,CAAC,GACxF,MAAM,IAAI,MAAM,mDAAmD;CAErE,MAAM,WAAW,IAAI,QAAQ,YAAA,uBAAA,CAA8B,QAAQ,OAAO,EAAE,EAAE;CAC9E,MAAM,gBAAgB,QAAQ,iBAAA;CAC9B,MAAM,QAAQ,aAAa,QAAQ,MAAM;CACzC,MAAM,YAAY,QAAQ,WAAW,KAAK,SAAS,KAAK,YAAY,CAAC,KAAK;CAE1E,MAAM,QAAe;EACnB,QAAQ,SAAS,SAAS,IAAI;GAC5B,IAAI,OAAO,YAAY,YAAY,QAAQ,WAAW,GAAG,OAAO;GAEhE,IAAI,QAAQ,SAAS,iBAAiB,QAAQ,OAAO,OAAO,CAAC,CAAC,SAAS,eAAe,OAAO;GAE7F,IAAI;GACJ,IAAI;IACF,QAAQ,KAAK,MAAM,OAAO;GAC5B,QAAQ;IACN,OAAO;GACT;GACA,IAAI,CAAC,SAAS,OAAO,UAAU,YAAY,MAAM,QAAQ,KAAK,GAAG,OAAO;GACxE,IAAI,OAAO,MAAM,SAAS,YAAY,CAAC,QAAQ,IAAI,MAAM,IAAI,GAAG,OAAO;GACvE,MAAM,OAAO,MAAM;GACnB,IAAI,OAAO,SAAS,YAAY,KAAK,WAAW,GAAG,OAAO;GAE1D,IAAI,MAAM,IAAI,IAAI,GAAG;IACnB,MAAM,OAAO,UAAU,MAAM,IAAI,IAAI,KAAK,CAAC,GAAG,MAAM,IAAI;IACxD,IAAI,CAAC,MAAM,OAAO;IAElB,KAAK,MAAM,SAAS,eAAe,OAAO,MAAM;IAChD,MAAM,OAAO;GACf;GAEA,MAAM,iBAAyC,EAAE,gBAAgB,mBAAmB;GACpF,MAAM,YAAY,OAAO,SAAS,YAAY;GAC9C,IAAI,WAAW,eAAe,gBAAgB;GAC9C,MAAM,YAAY,MAAM,SAAS,SAAS,SAAS;GACnD,IAAI,WAAW,eAAe,qBAAqB;GAEnD,OAAO;IAAE,KAAK;IAAU,MAAM,KAAK,UAAU,KAAK;IAAG,SAAS;GAAe;EAC/E;EAEA,MAAM,KAAK,SAAS;GAClB,MAAM,MAAM,MAAM,MAAM,QAAQ,KAAK;IAAE,QAAQ;IAAQ,SAAS,QAAQ;IAAS,MAAM,QAAQ;GAAK,CAAC;GACrG,OAAO;IACL,QAAQ,IAAI;IACZ,aAAa,IAAI,QAAQ,IAAI,cAAc,KAAK;IAChD,MAAM,MAAM,IAAI,KAAK;GACvB;EACF;EAEA,MAAM,OAAO,SAAS,UAAU,CAAC,GAAG;GAClC,IAAI,QAAQ,WAAW,QAAQ,OAAO,IAAI,SAAS,MAAM;IAAE,QAAQ;IAAK,SAAS,EAAE,OAAO,OAAO;GAAE,CAAC;GAEpG,IADe,OAAO,QAAQ,QAAQ,IAAI,gBAAgB,CACjD,IAAI,eAAe,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GAGrE,MAAM,OAAO,MAAM,SAAS,SAAS,aAAa;GAClD,IAAI,SAAS,MAAM,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GAE5D,MAAM,UAAU,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,OAAO,GAAG,QAAQ,QAAQ;GACtF,IAAI,CAAC,SAAS,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GAEvD,IAAI;IACF,MAAM,SAAS,MAAM,MAAM,KAAK,OAAO;IACvC,OAAO,IAAI,SAAS,OAAO,MAAM;KAC/B,QAAQ,OAAO;KACf,SAAS;MAAE,gBAAgB,OAAO;MAAa,iBAAiB;KAAW;IAC7E,CAAC;GACH,SAAS,OAAO;IACd,QAAQ,UAAU,KAAK;IACvB,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GAC3C;EACF;CACF;CACA,OAAO;AACT;;;;;AAiBA,SAAgB,WAAW,OAAuE;CAChG,OAAO,OAAO,SAAS,QAAQ;EAC7B,MAAM,UAAU,MAAM,QAAQ,IAAI,SAAS,IAAI,WAAW,CAAC,CAAC;EAC5D,IAAI,CAAC,SAAS;GACZ,QAAQ,MAAM,EAAE,QAAQ,IAAI;GAC5B;EACF;EACA,IAAI;GACF,MAAM,SAAS,MAAM,MAAM,KAAK,OAAO;GACvC,QAAQ,MAAM;IACZ,QAAQ,OAAO;IACf,SAAS;KAAE,gBAAgB,OAAO;KAAa,iBAAiB;IAAW;IAC3E,MAAM,OAAO;GACf;EACF,SAAS,OAAO;GACd,QAAQ,IAAI,KAAK,6BAA6B,OAAO;GACrD,QAAQ,MAAM,EAAE,QAAQ,IAAI;EAC9B;CACF;AACF"}
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Platform headers that carry the visitor IP, most specific first. x-forwarded-for is last because proxies append to
3
+ * it and some let the client seed it. The first header present wins, so on a platform that sets only one of these a
4
+ * client can spoof the IP by sending one ranked above it; set `ipHeaders` there.
5
+ */
6
+ const DEFAULT_IP_HEADERS = [
7
+ "x-azure-clientip",
8
+ "x-client-ip",
9
+ "cf-connecting-ip",
10
+ "x-nf-client-connection-ip",
11
+ "x-real-ip",
12
+ "x-forwarded-for"
13
+ ];
14
+ function header(headers, name) {
15
+ const value = headers[name];
16
+ return Array.isArray(value) ? value[0] : value;
17
+ }
18
+ /** Visitor IP from platform headers, without any port. Undefined when no header carries one. */
19
+ function clientIp(headers, names = DEFAULT_IP_HEADERS) {
20
+ for (const name of names) {
21
+ const raw = header(headers, name)?.split(",")[0]?.trim();
22
+ if (!raw) continue;
23
+ const bracketed = raw.match(/^\[(.+)\]:\d+$/);
24
+ if (bracketed) return bracketed[1];
25
+ const ipv4WithPort = raw.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/);
26
+ return ipv4WithPort ? ipv4WithPort[1] : raw;
27
+ }
28
+ }
29
+ function compileRules(events = {}) {
30
+ const compiled = /* @__PURE__ */ new Map();
31
+ for (const [name, rule] of Object.entries(events)) {
32
+ const entries = Object.entries(rule).map(([property, pattern]) => [property, pattern instanceof RegExp ? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, "")) : new RegExp(pattern)]);
33
+ if (entries.length === 0) throw new Error(`[@sjohansson/astro-plausible] Rule for event "${name}" lists no properties.`);
34
+ compiled.set(name, entries);
35
+ }
36
+ return compiled;
37
+ }
38
+ const PROPS_ALIASES = [
39
+ "m",
40
+ "meta",
41
+ "props"
42
+ ];
43
+ /** Applies the rule for an event. Returns the props to forward, or null when the event is not valid. */
44
+ function applyRule(rules, props) {
45
+ const source = props && typeof props === "object" ? props : {};
46
+ const kept = {};
47
+ for (const [property, pattern] of rules) {
48
+ const value = source[property];
49
+ if (typeof value !== "string" || !pattern.test(value)) return null;
50
+ kept[property] = value;
51
+ }
52
+ return kept;
53
+ }
54
+ const encoder = new TextEncoder();
55
+ /** Reads a request body as text, or returns null as soon as it grows past `limit` bytes. */
56
+ async function readBody(request, limit) {
57
+ if (!request.body) return "";
58
+ const reader = request.body.getReader();
59
+ const decoder = new TextDecoder();
60
+ let text = "";
61
+ let size = 0;
62
+ for (;;) {
63
+ const { done, value } = await reader.read();
64
+ if (done) return text + decoder.decode();
65
+ size += value.byteLength;
66
+ if (size > limit) {
67
+ await reader.cancel();
68
+ return null;
69
+ }
70
+ text += decoder.decode(value, { stream: true });
71
+ }
72
+ }
73
+ function headersToRecord(headers) {
74
+ const record = {};
75
+ headers.forEach((value, name) => {
76
+ record[name.toLowerCase()] = value;
77
+ });
78
+ return record;
79
+ }
80
+ function createRelay(options) {
81
+ const domains = new Set(Array.isArray(options.domain) ? options.domain : [options.domain]);
82
+ if (domains.size === 0 || [...domains].some((d) => typeof d !== "string" || d.length === 0)) throw new Error("[@sjohansson/astro-plausible] domain is required.");
83
+ const upstream = `${(options.upstream ?? "https://plausible.io").replace(/\/$/, "")}/api/event`;
84
+ const maxBodyLength = options.maxBodyLength ?? 16384;
85
+ const rules = compileRules(options.events);
86
+ const ipHeaders = options.ipHeaders?.map((name) => name.toLowerCase()) ?? DEFAULT_IP_HEADERS;
87
+ const relay = {
88
+ forward(rawBody, headers, ip) {
89
+ if (typeof rawBody !== "string" || rawBody.length === 0) return null;
90
+ if (rawBody.length > maxBodyLength || encoder.encode(rawBody).length > maxBodyLength) return null;
91
+ let event;
92
+ try {
93
+ event = JSON.parse(rawBody);
94
+ } catch {
95
+ return null;
96
+ }
97
+ if (!event || typeof event !== "object" || Array.isArray(event)) return null;
98
+ if (typeof event["d"] !== "string" || !domains.has(event["d"])) return null;
99
+ const name = event["n"];
100
+ if (typeof name !== "string" || name.length === 0) return null;
101
+ if (rules.has(name)) {
102
+ const kept = applyRule(rules.get(name) ?? [], event["p"]);
103
+ if (!kept) return null;
104
+ for (const alias of PROPS_ALIASES) delete event[alias];
105
+ event["p"] = kept;
106
+ }
107
+ const forwardHeaders = { "content-type": "application/json" };
108
+ const userAgent = header(headers, "user-agent");
109
+ if (userAgent) forwardHeaders["user-agent"] = userAgent;
110
+ const visitorIp = ip ?? clientIp(headers, ipHeaders);
111
+ if (visitorIp) forwardHeaders["x-forwarded-for"] = visitorIp;
112
+ return {
113
+ url: upstream,
114
+ body: JSON.stringify(event),
115
+ headers: forwardHeaders
116
+ };
117
+ },
118
+ async send(forward) {
119
+ const res = await fetch(forward.url, {
120
+ method: "POST",
121
+ headers: forward.headers,
122
+ body: forward.body
123
+ });
124
+ return {
125
+ status: res.status,
126
+ contentType: res.headers.get("content-type") ?? "text/plain",
127
+ body: await res.text()
128
+ };
129
+ },
130
+ async handle(request, context = {}) {
131
+ if (request.method !== "POST") return new Response(null, {
132
+ status: 405,
133
+ headers: { allow: "POST" }
134
+ });
135
+ if (Number(request.headers.get("content-length")) > maxBodyLength) return new Response(null, { status: 413 });
136
+ const body = await readBody(request, maxBodyLength);
137
+ if (body === null) return new Response(null, { status: 413 });
138
+ const forward = relay.forward(body, headersToRecord(request.headers), context.clientIp);
139
+ if (!forward) return new Response(null, { status: 400 });
140
+ try {
141
+ const result = await relay.send(forward);
142
+ return new Response(result.body, {
143
+ status: result.status,
144
+ headers: {
145
+ "content-type": result.contentType,
146
+ "cache-control": "no-store"
147
+ }
148
+ });
149
+ } catch (error) {
150
+ options.onError?.(error);
151
+ return new Response(null, { status: 502 });
152
+ }
153
+ }
154
+ };
155
+ return relay;
156
+ }
157
+ /**
158
+ * Adapter for Azure Static Web Apps managed functions (Node, `function.json` model). `astro-plausible init` writes
159
+ * a function that uses this.
160
+ */
161
+ function swaHandler(relay) {
162
+ return async (context, req) => {
163
+ const forward = relay.forward(req.rawBody, req.headers ?? {});
164
+ if (!forward) {
165
+ context.res = { status: 400 };
166
+ return;
167
+ }
168
+ try {
169
+ const result = await relay.send(forward);
170
+ context.res = {
171
+ status: result.status,
172
+ headers: {
173
+ "content-type": result.contentType,
174
+ "cache-control": "no-store"
175
+ },
176
+ body: result.body
177
+ };
178
+ } catch (error) {
179
+ context.log.warn(`Plausible forward failed: ${error}`);
180
+ context.res = { status: 502 };
181
+ }
182
+ };
183
+ }
184
+ // src/templates/swa.ts
185
+ const relay = createRelay(__ASTRO_PLAUSIBLE_RELAY_CONFIG__);
186
+ var swa_default = swaHandler(relay);
187
+ export { swa_default as default, relay };
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "@sjohansson/astro-plausible",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "First-party Plausible analytics for Astro sites. The official tracker is bundled into your site's own JavaScript and events go through a relay on your domain, so ad blockers do not drop them.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./integration": {
14
+ "types": "./dist/integration.d.ts",
15
+ "import": "./dist/integration.js"
16
+ },
17
+ "./client": {
18
+ "types": "./dist/client.d.ts",
19
+ "import": "./dist/client.js"
20
+ },
21
+ "./relay": {
22
+ "types": "./dist/relay.d.ts",
23
+ "import": "./dist/relay.js"
24
+ }
25
+ },
26
+ "bin": {
27
+ "astro-plausible": "./dist/bin.js"
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "keywords": [
33
+ "astro",
34
+ "astro-integration",
35
+ "plausible",
36
+ "analytics",
37
+ "privacy",
38
+ "proxy",
39
+ "static-site"
40
+ ],
41
+ "author": "sjohansson",
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "https://github.com/sjohansson/astro-components.git",
46
+ "directory": "packages/astro-plausible"
47
+ },
48
+ "engines": {
49
+ "node": ">=22"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "dependencies": {
55
+ "@plausible-analytics/tracker": "^0.4.7"
56
+ },
57
+ "peerDependencies": {
58
+ "astro": "^5.0.0 || ^6.0.0 || ^7.0.0"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "astro": {
62
+ "optional": true
63
+ }
64
+ },
65
+ "sideEffects": false,
66
+ "scripts": {
67
+ "build": "tsdown",
68
+ "dev": "tsdown --watch",
69
+ "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
70
+ "test": "vitest"
71
+ }
6
72
  }