@sjohansson/astro-plausible 0.0.0-stage → 0.0.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 +202 -2
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +200 -0
- package/dist/bin.js.map +1 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +28 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/integration.d.ts +31 -0
- package/dist/integration.d.ts.map +1 -0
- package/dist/integration.js +41 -0
- package/dist/integration.js.map +1 -0
- package/dist/options-CZaM-Wb7.js +51 -0
- package/dist/options-CZaM-Wb7.js.map +1 -0
- package/dist/options-WE-C48Ul.d.ts +63 -0
- package/dist/options-WE-C48Ul.d.ts.map +1 -0
- package/dist/relay.d.ts +82 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +156 -0
- package/dist/relay.js.map +1 -0
- package/dist/templates/swa.mjs +155 -0
- package/package.json +69 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Stefan Johansson
|
|
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
CHANGED
|
@@ -1,3 +1,203 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Astro Plausible
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
First-party [Plausible](https://plausible.io) analytics for Astro sites.
|
|
4
|
+
|
|
5
|
+
- The official Plausible tracker (the `@plausible-analytics/tracker` package) is bundled into your site's own
|
|
6
|
+
JavaScript. There is no third-party script tag for a blocker to recognise.
|
|
7
|
+
- Events go to `/api/event` on your own domain. A small relay there forwards them to Plausible with the visitor's
|
|
8
|
+
IP and user agent. Plausible drops events that arrive without a real visitor IP, so the relay is not optional.
|
|
9
|
+
- `astro-plausible init` writes that relay for Azure Static Web Apps. On other hosts it is a few lines around
|
|
10
|
+
`createRelay()`.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add @sjohansson/astro-plausible
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### 1. Add the integration
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
// astro.config.mjs
|
|
24
|
+
import { defineConfig } from "astro/config";
|
|
25
|
+
import plausible from "@sjohansson/astro-plausible/integration";
|
|
26
|
+
|
|
27
|
+
export default defineConfig({
|
|
28
|
+
integrations: [plausible({ domain: "example.com" })],
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
It adds one script to every page that starts the tracker. Options:
|
|
33
|
+
|
|
34
|
+
| Option | Default | Notes |
|
|
35
|
+
| ---------------------- | -------------- | ------------------------------------------------------------------------- |
|
|
36
|
+
| `domain` | | Required. The site domain as entered in Plausible. |
|
|
37
|
+
| `endpoint` | `"/api/event"` | Where events go. Set `https://plausible.io/api/event` to skip the relay. |
|
|
38
|
+
| `enabled` | | Defaults to on for `astro build` and off for `astro dev`. |
|
|
39
|
+
| `autoCapturePageviews` | `true` | Send a pageview on load and on history navigation. |
|
|
40
|
+
| `hashBasedRouting` | `false` | Track `#` paths as separate pages. |
|
|
41
|
+
| `outboundLinks` | `false` | Track clicks on links to other sites. |
|
|
42
|
+
| `fileDownloads` | `false` | `true` for Plausible's default file types, or `{ fileExtensions: [...] }`. |
|
|
43
|
+
| `formSubmissions` | `false` | Track form submissions. |
|
|
44
|
+
| `captureOnLocalhost` | `false` | Also track on localhost. |
|
|
45
|
+
| `logging` | `true` | Log ignored events to the console. |
|
|
46
|
+
| `customProperties` | | Custom properties added to every event. |
|
|
47
|
+
| `bindToWindow` | `true` | Expose `window.plausible`. Plausible's installation check looks for it. |
|
|
48
|
+
|
|
49
|
+
### 2. Add the relay
|
|
50
|
+
|
|
51
|
+
#### Azure Static Web Apps
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pnpm exec astro-plausible init --domain example.com
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This writes `api/event/index.mjs` and `api/event/function.json`, plus `api/host.json` and `api/package.json` when
|
|
58
|
+
they are missing. The function is one file with no imports, so the `api/` folder has no dependencies and the deploy
|
|
59
|
+
can skip the API build. Commit the files. Re-run the command after upgrading the package; it rewrites the function
|
|
60
|
+
and leaves the other files alone.
|
|
61
|
+
|
|
62
|
+
Deploy settings:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
// public/staticwebapp.config.json
|
|
66
|
+
{ "platform": { "apiRuntime": "node:22" } }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
# The deploy step
|
|
71
|
+
- uses: Azure/static-web-apps-deploy@v1
|
|
72
|
+
with:
|
|
73
|
+
app_location: "dist"
|
|
74
|
+
api_location: "api"
|
|
75
|
+
skip_app_build: true
|
|
76
|
+
skip_api_build: true
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
CLI options:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
--target <name> Hosting target. "swa" is the only one today.
|
|
83
|
+
--domain <domain> Site domain as entered in Plausible (or PLAUSIBLE_DOMAIN). Repeat it for several sites.
|
|
84
|
+
--upstream <url> Plausible host, default https://plausible.io. Self-hosted: https://plausible.example.com
|
|
85
|
+
--dir <path> API folder, default "api"
|
|
86
|
+
--rule <spec> Validate a custom event: <event>:<property>=<regex>. Repeatable.
|
|
87
|
+
--max-body <bytes> Reject bodies larger than this, default 16384
|
|
88
|
+
--dry-run Print the files instead of writing them
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
#### Other hosts
|
|
92
|
+
|
|
93
|
+
The relay works on the web standard `Request` and `Response`. Give it the visitor IP when the platform provides one;
|
|
94
|
+
otherwise it reads the usual headers (`x-azure-clientip`, `cf-connecting-ip`, `x-real-ip`, `x-forwarded-for`, and
|
|
95
|
+
so on).
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
// src/pages/api/event.ts, an Astro server endpoint
|
|
99
|
+
import { createRelay } from "@sjohansson/astro-plausible/relay";
|
|
100
|
+
import type { APIRoute } from "astro";
|
|
101
|
+
|
|
102
|
+
export const prerender = false;
|
|
103
|
+
|
|
104
|
+
const relay = createRelay({ domain: "example.com" });
|
|
105
|
+
|
|
106
|
+
export const POST: APIRoute = ({ request, clientAddress }) => relay.handle(request, { clientIp: clientAddress });
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// functions/api/event.ts, a Cloudflare Pages function
|
|
111
|
+
import { createRelay } from "@sjohansson/astro-plausible/relay";
|
|
112
|
+
|
|
113
|
+
const relay = createRelay({ domain: "example.com" });
|
|
114
|
+
|
|
115
|
+
export const onRequestPost = ({ request }) => relay.handle(request);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Relay options:
|
|
119
|
+
|
|
120
|
+
| Option | Default | Notes |
|
|
121
|
+
| --------------- | ------------------------ | ------------------------------------------------------------------------- |
|
|
122
|
+
| `domain` | | Required. One domain or several. Events for other domains get a 400. |
|
|
123
|
+
| `upstream` | `"https://plausible.io"` | Events are posted to `<upstream>/api/event`. |
|
|
124
|
+
| `maxBodyLength` | `16384` | Largest request body accepted, in bytes. |
|
|
125
|
+
| `events` | | Validation for custom events, see below. |
|
|
126
|
+
| `onError` | | Called when Plausible cannot be reached. The response is a 502 either way. |
|
|
127
|
+
|
|
128
|
+
The relay answers 405 for anything but POST, 413 for a body over the limit, 400 for anything it will not forward,
|
|
129
|
+
and otherwise passes Plausible's status and body through. The visitor IP goes in `X-Forwarded-For`.
|
|
130
|
+
|
|
131
|
+
### Validating custom events
|
|
132
|
+
|
|
133
|
+
If you build anything on custom events, say vote counts, the relay can make sure only well-formed ones get through.
|
|
134
|
+
A rule lists the properties an event may carry and a pattern for each. Events with a rule are forwarded only with
|
|
135
|
+
those properties, each matching its pattern. Events without a rule pass through untouched.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { voteRelayRule } from "@sjohansson/astro-votes";
|
|
139
|
+
|
|
140
|
+
const relay = createRelay({
|
|
141
|
+
domain: "example.com",
|
|
142
|
+
events: {
|
|
143
|
+
...voteRelayRule(),
|
|
144
|
+
signup: { plan: /^(free|pro)$/ },
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The same for the CLI:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pnpm exec astro-plausible init --domain example.com \
|
|
153
|
+
--rule "vote:vote=^[A-Za-z0-9._/-]{1,200}:(?:up|down)(?:-undo)?$" \
|
|
154
|
+
--rule "signup:plan=^(free|pro)$"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Tracking events
|
|
158
|
+
|
|
159
|
+
`window.plausible("signup")` works as in any Plausible setup. The typed way is the tracker's own `track`:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { track } from "@sjohansson/astro-plausible/client";
|
|
163
|
+
|
|
164
|
+
track("signup", { props: { plan: "pro" } });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Without the integration
|
|
168
|
+
|
|
169
|
+
Start the tracker yourself when you want a function for `customProperties`, a `transformRequest` hook, or control
|
|
170
|
+
over where it runs.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { startPlausible } from "@sjohansson/astro-plausible/client";
|
|
174
|
+
|
|
175
|
+
startPlausible({
|
|
176
|
+
domain: "example.com",
|
|
177
|
+
transformRequest: (payload) => (payload.u.includes("/admin") ? null : payload),
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The first call wins; later calls do nothing. The tracker itself allows a single `init()`.
|
|
182
|
+
|
|
183
|
+
### A path other than `/api/event`
|
|
184
|
+
|
|
185
|
+
Set `endpoint` to wherever the relay answers, for example `/stats/api/event`. Static Web Apps managed functions
|
|
186
|
+
always live under `/api`; a rewrite from another path to `/api/event` in `staticwebapp.config.json` may do it, but
|
|
187
|
+
that is untested and the Static Web Apps documentation says route rules for the API only support redirects and
|
|
188
|
+
roles.
|
|
189
|
+
|
|
190
|
+
### Local development
|
|
191
|
+
|
|
192
|
+
The integration leaves the tracker out of `astro dev`, and the tracker ignores localhost unless
|
|
193
|
+
`captureOnLocalhost` is set. To test the whole chain locally, set both and run the site through something that
|
|
194
|
+
serves the relay too, such as the Static Web Apps CLI.
|
|
195
|
+
|
|
196
|
+
## Keeping the tracker current
|
|
197
|
+
|
|
198
|
+
The tracker is a normal dependency, so Renovate or a routine upgrade brings new Plausible releases to your site.
|
|
199
|
+
Nothing is vendored.
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT
|
package/dist/bin.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { DEFAULT_MAX_BODY_LENGTH, DEFAULT_UPSTREAM } from "./relay.js";
|
|
3
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
//#region src/cli.ts
|
|
7
|
+
/**
|
|
8
|
+
* astro-plausible - writes the relay that forwards tracker events to Plausible.
|
|
9
|
+
*
|
|
10
|
+
* Static Web Apps managed functions must be real files in the repo, and nothing in node_modules is picked up, so
|
|
11
|
+
* the relay is written out as one self-contained file. Re-run the command to update it after a package upgrade.
|
|
12
|
+
*/
|
|
13
|
+
const HELP = `Usage: astro-plausible init [options]
|
|
14
|
+
|
|
15
|
+
Writes a relay function that forwards Plausible tracker events from your domain to Plausible.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--target <name> Hosting target. "swa" (Azure Static Web Apps managed functions) is the only one today.
|
|
19
|
+
--domain <domain> Site domain as entered in Plausible (or PLAUSIBLE_DOMAIN). Repeat it for several sites.
|
|
20
|
+
--upstream <url> Plausible host, default ${DEFAULT_UPSTREAM}. Self-hosted: https://plausible.example.com
|
|
21
|
+
--dir <path> API folder, default "api"
|
|
22
|
+
--rule <spec> Validate a custom event: <event>:<property>=<regex>. Repeatable. Events with a rule are
|
|
23
|
+
forwarded only with the listed properties, each matching its pattern.
|
|
24
|
+
--max-body <bytes> Reject bodies larger than this, default ${DEFAULT_MAX_BODY_LENGTH}
|
|
25
|
+
--dry-run Print the files instead of writing them
|
|
26
|
+
-h, --help Show this help
|
|
27
|
+
|
|
28
|
+
Writes <dir>/event/index.mjs and <dir>/event/function.json every time, and <dir>/host.json and <dir>/package.json
|
|
29
|
+
when they are missing. Deploy with api_location set to <dir>.
|
|
30
|
+
`;
|
|
31
|
+
const CONFIG_PLACEHOLDER = "__ASTRO_PLAUSIBLE_RELAY_CONFIG__";
|
|
32
|
+
const RULE_PATTERN = /^([\w.-]+):([\w.-]+)=(.+)$/s;
|
|
33
|
+
/** Parses `<event>:<property>=<regex>` specs into relay rules. Throws on a bad spec or pattern. */
|
|
34
|
+
function parseRules(specs) {
|
|
35
|
+
const events = {};
|
|
36
|
+
for (const spec of specs) {
|
|
37
|
+
const match = spec.match(RULE_PATTERN);
|
|
38
|
+
if (!match) throw new Error(`--rule must look like <event>:<property>=<regex>, got "${spec}"`);
|
|
39
|
+
const [, event = "", property = "", pattern = ""] = match;
|
|
40
|
+
try {
|
|
41
|
+
new RegExp(pattern);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(`--rule pattern for ${event}:${property} is not a valid regex: ${error}`);
|
|
44
|
+
}
|
|
45
|
+
events[event] = {
|
|
46
|
+
...events[event],
|
|
47
|
+
[property]: pattern
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return events;
|
|
51
|
+
}
|
|
52
|
+
/** The files `init --target swa` produces for a relay config. */
|
|
53
|
+
function swaFiles(template, config, dir, context) {
|
|
54
|
+
if (!template.includes(CONFIG_PLACEHOLDER)) throw new Error("The relay template is missing its config placeholder.");
|
|
55
|
+
const banner = [
|
|
56
|
+
`// Written by astro-plausible init (@sjohansson/astro-plausible ${context.version}) on ${context.now.toISOString().slice(0, 10)}.`,
|
|
57
|
+
"// Forwards Plausible tracker events to Plausible so the tracker runs first party and is not dropped by ad blockers.",
|
|
58
|
+
"// Do not edit: re-run the command to change the config or pick up a new package version.",
|
|
59
|
+
""
|
|
60
|
+
].join("\n");
|
|
61
|
+
const code = template.replace(/^\/\/# sourceMappingURL=.*$/m, "").replaceAll(CONFIG_PLACEHOLDER, JSON.stringify(config, null, 2)).trimEnd();
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
path: join(dir, "event", "index.mjs"),
|
|
65
|
+
content: `${banner}${code}\n`,
|
|
66
|
+
keepExisting: false
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
path: join(dir, "event", "function.json"),
|
|
70
|
+
content: `${JSON.stringify({
|
|
71
|
+
scriptFile: "index.mjs",
|
|
72
|
+
entryPoint: "default",
|
|
73
|
+
bindings: [{
|
|
74
|
+
authLevel: "anonymous",
|
|
75
|
+
type: "httpTrigger",
|
|
76
|
+
direction: "in",
|
|
77
|
+
name: "req",
|
|
78
|
+
methods: ["post"]
|
|
79
|
+
}, {
|
|
80
|
+
type: "http",
|
|
81
|
+
direction: "out",
|
|
82
|
+
name: "res"
|
|
83
|
+
}]
|
|
84
|
+
}, null, 2)}\n`,
|
|
85
|
+
keepExisting: false
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
path: join(dir, "host.json"),
|
|
89
|
+
content: "{\n \"version\": \"2.0\"\n}\n",
|
|
90
|
+
keepExisting: true
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
path: join(dir, "package.json"),
|
|
94
|
+
content: `${JSON.stringify({
|
|
95
|
+
name: "site-api",
|
|
96
|
+
private: true,
|
|
97
|
+
description: "Static Web Apps managed functions. No dependencies on purpose, so the deploy skips the API build."
|
|
98
|
+
}, null, 2)}\n`,
|
|
99
|
+
keepExisting: true
|
|
100
|
+
}
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
async function exists(path) {
|
|
104
|
+
try {
|
|
105
|
+
await access(path);
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function packageVersion() {
|
|
112
|
+
try {
|
|
113
|
+
const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
114
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
115
|
+
} catch {
|
|
116
|
+
return "unknown";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function main(argv, env, context = {}) {
|
|
120
|
+
const { values, positionals } = parseArgs({
|
|
121
|
+
args: argv,
|
|
122
|
+
allowPositionals: true,
|
|
123
|
+
options: {
|
|
124
|
+
target: { type: "string" },
|
|
125
|
+
domain: {
|
|
126
|
+
type: "string",
|
|
127
|
+
multiple: true
|
|
128
|
+
},
|
|
129
|
+
upstream: { type: "string" },
|
|
130
|
+
dir: { type: "string" },
|
|
131
|
+
rule: {
|
|
132
|
+
type: "string",
|
|
133
|
+
multiple: true
|
|
134
|
+
},
|
|
135
|
+
"max-body": { type: "string" },
|
|
136
|
+
"dry-run": { type: "boolean" },
|
|
137
|
+
help: {
|
|
138
|
+
type: "boolean",
|
|
139
|
+
short: "h"
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
if (values.help || positionals.length === 0) {
|
|
144
|
+
process.stdout.write(HELP);
|
|
145
|
+
return values.help ? 0 : 1;
|
|
146
|
+
}
|
|
147
|
+
if (positionals[0] !== "init") return fail(`Unknown command "${positionals[0]}". Only "init" exists.`);
|
|
148
|
+
const target = values.target ?? "swa";
|
|
149
|
+
if (target !== "swa") return fail(`Unknown target "${target}". Only "swa" exists.`);
|
|
150
|
+
const domains = values.domain ?? (env["PLAUSIBLE_DOMAIN"] ? [env["PLAUSIBLE_DOMAIN"]] : []);
|
|
151
|
+
if (domains.length === 0) return fail("No domain. Pass --domain or set PLAUSIBLE_DOMAIN.");
|
|
152
|
+
const maxBodyLength = values["max-body"] === void 0 ? void 0 : Number(values["max-body"]);
|
|
153
|
+
if (maxBodyLength !== void 0 && !(Number.isInteger(maxBodyLength) && maxBodyLength > 0)) return fail(`--max-body must be a positive whole number, got "${values["max-body"]}".`);
|
|
154
|
+
let events;
|
|
155
|
+
try {
|
|
156
|
+
events = parseRules(values.rule ?? []);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
159
|
+
}
|
|
160
|
+
const config = {
|
|
161
|
+
domain: domains.length === 1 ? domains[0] : domains,
|
|
162
|
+
...values.upstream ? { upstream: values.upstream } : {},
|
|
163
|
+
...maxBodyLength ? { maxBodyLength } : {},
|
|
164
|
+
...Object.keys(events).length > 0 ? { events } : {}
|
|
165
|
+
};
|
|
166
|
+
const templateDir = context.templateDir ?? new URL("./templates/", import.meta.url);
|
|
167
|
+
const files = swaFiles(await readFile(new URL("swa.mjs", templateDir), "utf8"), config, values.dir ?? "api", {
|
|
168
|
+
version: context.version ?? await packageVersion(),
|
|
169
|
+
now: context.now ?? /* @__PURE__ */ new Date()
|
|
170
|
+
});
|
|
171
|
+
for (const file of files) {
|
|
172
|
+
if (values["dry-run"]) {
|
|
173
|
+
process.stdout.write(`--- ${file.path}\n${file.content}\n`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const present = await exists(file.path);
|
|
177
|
+
if (present && file.keepExisting) {
|
|
178
|
+
process.stdout.write(`kept ${file.path}\n`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
await mkdir(join(file.path, ".."), { recursive: true });
|
|
182
|
+
await writeFile(file.path, file.content);
|
|
183
|
+
process.stdout.write(`${present ? "updated" : "created"} ${file.path}\n`);
|
|
184
|
+
}
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
function fail(message) {
|
|
188
|
+
process.stderr.write(`astro-plausible: ${message}\n`);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/bin.ts
|
|
193
|
+
main(process.argv.slice(2), process.env).then((code) => {
|
|
194
|
+
process.exitCode = code;
|
|
195
|
+
}, (error) => {
|
|
196
|
+
process.exitCode = fail(error instanceof Error ? error.message : String(error));
|
|
197
|
+
});
|
|
198
|
+
//#endregion
|
|
199
|
+
|
|
200
|
+
//# sourceMappingURL=bin.js.map
|
package/dist/bin.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bin.js","names":[],"sources":["../src/cli.ts","../src/bin.ts"],"sourcesContent":["/**\n * astro-plausible - writes the relay that forwards tracker events to Plausible.\n *\n * Static Web Apps managed functions must be real files in the repo, and nothing in node_modules is picked up, so\n * the relay is written out as one self-contained file. Re-run the command to update it after a package upgrade.\n */\nimport { access, mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { parseArgs } from \"node:util\";\nimport { DEFAULT_MAX_BODY_LENGTH, DEFAULT_UPSTREAM, type RelayOptions, type RelayRule } from \"./relay\";\n\nconst HELP = `Usage: astro-plausible init [options]\n\nWrites a relay function that forwards Plausible tracker events from your domain to Plausible.\n\nOptions:\n --target <name> Hosting target. \"swa\" (Azure Static Web Apps managed functions) is the only one today.\n --domain <domain> Site domain as entered in Plausible (or PLAUSIBLE_DOMAIN). Repeat it for several sites.\n --upstream <url> Plausible host, default ${DEFAULT_UPSTREAM}. Self-hosted: https://plausible.example.com\n --dir <path> API folder, default \"api\"\n --rule <spec> Validate a custom event: <event>:<property>=<regex>. Repeatable. Events with a rule are\n forwarded only with the listed properties, each matching its pattern.\n --max-body <bytes> Reject bodies larger than this, default ${DEFAULT_MAX_BODY_LENGTH}\n --dry-run Print the files instead of writing them\n -h, --help Show this help\n\nWrites <dir>/event/index.mjs and <dir>/event/function.json every time, and <dir>/host.json and <dir>/package.json\nwhen they are missing. Deploy with api_location set to <dir>.\n`;\n\nconst CONFIG_PLACEHOLDER = \"__ASTRO_PLAUSIBLE_RELAY_CONFIG__\";\nconst RULE_PATTERN = /^([\\w.-]+):([\\w.-]+)=(.+)$/s;\n\nexport interface CliContext {\n /** Where the built templates live, as a directory URL. Defaults to the package's own dist/templates. */\n templateDir?: URL;\n version?: string;\n now?: Date;\n}\n\nexport interface ScaffoldFile {\n path: string;\n content: string;\n /** Written only when the file does not exist yet. */\n keepExisting: boolean;\n}\n\n/** Parses `<event>:<property>=<regex>` specs into relay rules. Throws on a bad spec or pattern. */\nexport function parseRules(specs: string[]): Record<string, RelayRule> {\n const events: Record<string, RelayRule> = {};\n for (const spec of specs) {\n const match = spec.match(RULE_PATTERN);\n if (!match) throw new Error(`--rule must look like <event>:<property>=<regex>, got \"${spec}\"`);\n const [, event = \"\", property = \"\", pattern = \"\"] = match;\n try {\n new RegExp(pattern);\n } catch (error) {\n throw new Error(`--rule pattern for ${event}:${property} is not a valid regex: ${error}`);\n }\n events[event] = { ...events[event], [property]: pattern };\n }\n return events;\n}\n\n/** The files `init --target swa` produces for a relay config. */\nexport function swaFiles(\n template: string,\n config: RelayOptions,\n dir: string,\n context: Required<Pick<CliContext, \"version\" | \"now\">>,\n): ScaffoldFile[] {\n if (!template.includes(CONFIG_PLACEHOLDER)) throw new Error(\"The relay template is missing its config placeholder.\");\n const banner = [\n `// Written by astro-plausible init (@sjohansson/astro-plausible ${context.version}) on ${context.now.toISOString().slice(0, 10)}.`,\n \"// Forwards Plausible tracker events to Plausible so the tracker runs first party and is not dropped by ad blockers.\",\n \"// Do not edit: re-run the command to change the config or pick up a new package version.\",\n \"\",\n ].join(\"\\n\");\n const code = template\n .replace(/^\\/\\/# sourceMappingURL=.*$/m, \"\")\n .replaceAll(CONFIG_PLACEHOLDER, JSON.stringify(config, null, 2))\n .trimEnd();\n\n const functionJson = {\n scriptFile: \"index.mjs\",\n entryPoint: \"default\",\n bindings: [\n { authLevel: \"anonymous\", type: \"httpTrigger\", direction: \"in\", name: \"req\", methods: [\"post\"] },\n { type: \"http\", direction: \"out\", name: \"res\" },\n ],\n };\n const packageJson = {\n name: \"site-api\",\n private: true,\n description: \"Static Web Apps managed functions. No dependencies on purpose, so the deploy skips the API build.\",\n };\n\n return [\n { path: join(dir, \"event\", \"index.mjs\"), content: `${banner}${code}\\n`, keepExisting: false },\n {\n path: join(dir, \"event\", \"function.json\"),\n content: `${JSON.stringify(functionJson, null, 2)}\\n`,\n keepExisting: false,\n },\n { path: join(dir, \"host.json\"), content: '{\\n \"version\": \"2.0\"\\n}\\n', keepExisting: true },\n { path: join(dir, \"package.json\"), content: `${JSON.stringify(packageJson, null, 2)}\\n`, keepExisting: true },\n ];\n}\n\nasync function exists(path: string): Promise<boolean> {\n try {\n await access(path);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function packageVersion(): Promise<string> {\n try {\n const pkg = JSON.parse(await readFile(new URL(\"../package.json\", import.meta.url), \"utf8\"));\n return typeof pkg.version === \"string\" ? pkg.version : \"unknown\";\n } catch {\n return \"unknown\";\n }\n}\n\nexport async function main(\n argv: string[],\n env: Record<string, string | undefined>,\n context: CliContext = {},\n): Promise<number> {\n const { values, positionals } = parseArgs({\n args: argv,\n allowPositionals: true,\n options: {\n target: { type: \"string\" },\n domain: { type: \"string\", multiple: true },\n upstream: { type: \"string\" },\n dir: { type: \"string\" },\n rule: { type: \"string\", multiple: true },\n \"max-body\": { type: \"string\" },\n \"dry-run\": { type: \"boolean\" },\n help: { type: \"boolean\", short: \"h\" },\n },\n });\n\n if (values.help || positionals.length === 0) {\n process.stdout.write(HELP);\n return values.help ? 0 : 1;\n }\n if (positionals[0] !== \"init\") return fail(`Unknown command \"${positionals[0]}\". Only \"init\" exists.`);\n\n const target = values.target ?? \"swa\";\n if (target !== \"swa\") return fail(`Unknown target \"${target}\". Only \"swa\" exists.`);\n\n const domains = values.domain ?? (env[\"PLAUSIBLE_DOMAIN\"] ? [env[\"PLAUSIBLE_DOMAIN\"]] : []);\n if (domains.length === 0) return fail(\"No domain. Pass --domain or set PLAUSIBLE_DOMAIN.\");\n\n const maxBodyLength = values[\"max-body\"] === undefined ? undefined : Number(values[\"max-body\"]);\n if (maxBodyLength !== undefined && !(Number.isInteger(maxBodyLength) && maxBodyLength > 0)) {\n return fail(`--max-body must be a positive whole number, got \"${values[\"max-body\"]}\".`);\n }\n\n let events: Record<string, RelayRule>;\n try {\n events = parseRules(values.rule ?? []);\n } catch (error) {\n return fail(error instanceof Error ? error.message : String(error));\n }\n\n const config: RelayOptions = {\n domain: domains.length === 1 ? (domains[0] as string) : domains,\n ...(values.upstream ? { upstream: values.upstream } : {}),\n ...(maxBodyLength ? { maxBodyLength } : {}),\n ...(Object.keys(events).length > 0 ? { events } : {}),\n };\n\n const templateDir = context.templateDir ?? new URL(\"./templates/\", import.meta.url);\n const template = await readFile(new URL(\"swa.mjs\", templateDir), \"utf8\");\n const files = swaFiles(template, config, values.dir ?? \"api\", {\n version: context.version ?? (await packageVersion()),\n now: context.now ?? new Date(),\n });\n\n for (const file of files) {\n if (values[\"dry-run\"]) {\n process.stdout.write(`--- ${file.path}\\n${file.content}\\n`);\n continue;\n }\n const present = await exists(file.path);\n if (present && file.keepExisting) {\n process.stdout.write(`kept ${file.path}\\n`);\n continue;\n }\n await mkdir(join(file.path, \"..\"), { recursive: true });\n await writeFile(file.path, file.content);\n process.stdout.write(`${present ? \"updated\" : \"created\"} ${file.path}\\n`);\n }\n return 0;\n}\n\nexport function fail(message: string): number {\n process.stderr.write(`astro-plausible: ${message}\\n`);\n return 1;\n}\n","#!/usr/bin/env node\nimport { fail, main } from \"./cli\";\n\nmain(process.argv.slice(2), process.env).then(\n (code) => {\n process.exitCode = code;\n },\n (error: unknown) => {\n process.exitCode = fail(error instanceof Error ? error.message : String(error));\n },\n);\n"],"mappings":";;;;;;;;;;;;AAWA,MAAM,OAAO;;;;;;;iDAOoC,iBAAiB;;;;iEAID,wBAAwB;;;;;;;AAQzF,MAAM,qBAAqB;AAC3B,MAAM,eAAe;;AAiBrB,SAAgB,WAAW,OAA4C;CACrE,MAAM,SAAoC,CAAC;CAC3C,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,QAAQ,KAAK,MAAM,YAAY;EACrC,IAAI,CAAC,OAAO,MAAM,IAAI,MAAM,0DAA0D,KAAK,EAAE;EAC7F,MAAM,GAAG,QAAQ,IAAI,WAAW,IAAI,UAAU,MAAM;EACpD,IAAI;GACF,IAAI,OAAO,OAAO;EACpB,SAAS,OAAO;GACd,MAAM,IAAI,MAAM,sBAAsB,MAAM,GAAG,SAAS,yBAAyB,OAAO;EAC1F;EACA,OAAO,SAAS;GAAE,GAAG,OAAO;IAAS,WAAW;EAAQ;CAC1D;CACA,OAAO;AACT;;AAGA,SAAgB,SACd,UACA,QACA,KACA,SACgB;CAChB,IAAI,CAAC,SAAS,SAAS,kBAAkB,GAAG,MAAM,IAAI,MAAM,uDAAuD;CACnH,MAAM,SAAS;EACb,mEAAmE,QAAQ,QAAQ,OAAO,QAAQ,IAAI,YAAY,CAAC,CAAC,MAAM,GAAG,EAAE,EAAE;EACjI;EACA;EACA;CACF,CAAC,CAAC,KAAK,IAAI;CACX,MAAM,OAAO,SACV,QAAQ,gCAAgC,EAAE,CAAC,CAC3C,WAAW,oBAAoB,KAAK,UAAU,QAAQ,MAAM,CAAC,CAAC,CAAC,CAC/D,QAAQ;CAgBX,OAAO;EACL;GAAE,MAAM,KAAK,KAAK,SAAS,WAAW;GAAG,SAAS,GAAG,SAAS,KAAK;GAAK,cAAc;EAAM;EAC5F;GACE,MAAM,KAAK,KAAK,SAAS,eAAe;GACxC,SAAS,GAAG,KAAK,UAAU;IAjB7B,YAAY;IACZ,YAAY;IACZ,UAAU,CACR;KAAE,WAAW;KAAa,MAAM;KAAe,WAAW;KAAM,MAAM;KAAO,SAAS,CAAC,MAAM;IAAE,GAC/F;KAAE,MAAM;KAAQ,WAAW;KAAO,MAAM;IAAM,CAChD;GAYwC,GAAG,MAAM,CAAC,EAAE;GAClD,cAAc;EAChB;EACA;GAAE,MAAM,KAAK,KAAK,WAAW;GAAG,SAAS;GAA8B,cAAc;EAAK;EAC1F;GAAE,MAAM,KAAK,KAAK,cAAc;GAAG,SAAS,GAAG,KAAK,UAAU;IAb9D,MAAM;IACN,SAAS;IACT,aAAa;GAW2D,GAAG,MAAM,CAAC,EAAE;GAAK,cAAc;EAAK;CAC9G;AACF;AAEA,eAAe,OAAO,MAAgC;CACpD,IAAI;EACF,MAAM,OAAO,IAAI;EACjB,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;AAEA,eAAe,iBAAkC;CAC/C,IAAI;EACF,MAAM,MAAM,KAAK,MAAM,MAAM,SAAS,IAAI,IAAI,mBAAmB,YAAY,GAAG,GAAG,MAAM,CAAC;EAC1F,OAAO,OAAO,IAAI,YAAY,WAAW,IAAI,UAAU;CACzD,QAAQ;EACN,OAAO;CACT;AACF;AAEA,eAAsB,KACpB,MACA,KACA,UAAsB,CAAC,GACN;CACjB,MAAM,EAAE,QAAQ,gBAAgB,UAAU;EACxC,MAAM;EACN,kBAAkB;EAClB,SAAS;GACP,QAAQ,EAAE,MAAM,SAAS;GACzB,QAAQ;IAAE,MAAM;IAAU,UAAU;GAAK;GACzC,UAAU,EAAE,MAAM,SAAS;GAC3B,KAAK,EAAE,MAAM,SAAS;GACtB,MAAM;IAAE,MAAM;IAAU,UAAU;GAAK;GACvC,YAAY,EAAE,MAAM,SAAS;GAC7B,WAAW,EAAE,MAAM,UAAU;GAC7B,MAAM;IAAE,MAAM;IAAW,OAAO;GAAI;EACtC;CACF,CAAC;CAED,IAAI,OAAO,QAAQ,YAAY,WAAW,GAAG;EAC3C,QAAQ,OAAO,MAAM,IAAI;EACzB,OAAO,OAAO,OAAO,IAAI;CAC3B;CACA,IAAI,YAAY,OAAO,QAAQ,OAAO,KAAK,oBAAoB,YAAY,GAAG,uBAAuB;CAErG,MAAM,SAAS,OAAO,UAAU;CAChC,IAAI,WAAW,OAAO,OAAO,KAAK,mBAAmB,OAAO,sBAAsB;CAElF,MAAM,UAAU,OAAO,WAAW,IAAI,sBAAsB,CAAC,IAAI,mBAAmB,IAAI,CAAC;CACzF,IAAI,QAAQ,WAAW,GAAG,OAAO,KAAK,mDAAmD;CAEzF,MAAM,gBAAgB,OAAO,gBAAgB,KAAA,IAAY,KAAA,IAAY,OAAO,OAAO,WAAW;CAC9F,IAAI,kBAAkB,KAAA,KAAa,EAAE,OAAO,UAAU,aAAa,KAAK,gBAAgB,IACtF,OAAO,KAAK,oDAAoD,OAAO,YAAY,GAAG;CAGxF,IAAI;CACJ,IAAI;EACF,SAAS,WAAW,OAAO,QAAQ,CAAC,CAAC;CACvC,SAAS,OAAO;EACd,OAAO,KAAK,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;CACpE;CAEA,MAAM,SAAuB;EAC3B,QAAQ,QAAQ,WAAW,IAAK,QAAQ,KAAgB;EACxD,GAAI,OAAO,WAAW,EAAE,UAAU,OAAO,SAAS,IAAI,CAAC;EACvD,GAAI,gBAAgB,EAAE,cAAc,IAAI,CAAC;EACzC,GAAI,OAAO,KAAK,MAAM,CAAC,CAAC,SAAS,IAAI,EAAE,OAAO,IAAI,CAAC;CACrD;CAEA,MAAM,cAAc,QAAQ,eAAe,IAAI,IAAI,gBAAgB,YAAY,GAAG;CAElF,MAAM,QAAQ,SAAS,MADA,SAAS,IAAI,IAAI,WAAW,WAAW,GAAG,MAAM,GACtC,QAAQ,OAAO,OAAO,OAAO;EAC5D,SAAS,QAAQ,WAAY,MAAM,eAAe;EAClD,KAAK,QAAQ,uBAAO,IAAI,KAAK;CAC/B,CAAC;CAED,KAAK,MAAM,QAAQ,OAAO;EACxB,IAAI,OAAO,YAAY;GACrB,QAAQ,OAAO,MAAM,OAAO,KAAK,KAAK,IAAI,KAAK,QAAQ,GAAG;GAC1D;EACF;EACA,MAAM,UAAU,MAAM,OAAO,KAAK,IAAI;EACtC,IAAI,WAAW,KAAK,cAAc;GAChC,QAAQ,OAAO,MAAM,YAAY,KAAK,KAAK,GAAG;GAC9C;EACF;EACA,MAAM,MAAM,KAAK,KAAK,MAAM,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;EACtD,MAAM,UAAU,KAAK,MAAM,KAAK,OAAO;EACvC,QAAQ,OAAO,MAAM,GAAG,UAAU,YAAY,UAAU,IAAI,KAAK,KAAK,GAAG;CAC3E;CACA,OAAO;AACT;AAEA,SAAgB,KAAK,SAAyB;CAC5C,QAAQ,OAAO,MAAM,oBAAoB,QAAQ,GAAG;CACpD,OAAO;AACT;;;AC1MA,KAAK,QAAQ,KAAK,MAAM,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAC,MACtC,SAAS;CACR,QAAQ,WAAW;AACrB,IACC,UAAmB;CAClB,QAAQ,WAAW,KAAK,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;AAChF,CACF"}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { n as PlausibleOptions } from "./options-WE-C48Ul.js";
|
|
2
|
+
import { CustomProperties, PlausibleConfig, PlausibleEventOptions, PlausibleRequestPayload, track } from "@plausible-analytics/tracker";
|
|
3
|
+
//#region src/client.d.ts
|
|
4
|
+
export interface PlausibleClientOptions extends Omit<PlausibleOptions, "customProperties"> {
|
|
5
|
+
/** Custom properties for every event, or a function of the event name that returns them. */
|
|
6
|
+
customProperties?: PlausibleConfig["customProperties"];
|
|
7
|
+
/** Change or drop a request before it is sent. Return null to drop it. */
|
|
8
|
+
transformRequest?: PlausibleConfig["transformRequest"];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Starts the tracker. The first call wins; later calls do nothing, so it is safe in a script that runs on every
|
|
12
|
+
* page. The tracker itself allows a single `init()`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function startPlausible(options: PlausibleClientOptions): void;
|
|
15
|
+
//#endregion
|
|
16
|
+
export { type CustomProperties, type PlausibleEventOptions, type PlausibleRequestPayload, track };
|
|
17
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","names":[],"sources":["../src/client.ts"],"mappings":";;;iBAUiB,+BAA+B,KAAK;;EAEnD,mBAAmB;;EAEnB,mBAAmB;;;;;;wBASL,eAAe,SAAS"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { n as plausibleConfig } from "./options-CZaM-Wb7.js";
|
|
2
|
+
import { init, track } from "@plausible-analytics/tracker";
|
|
3
|
+
//#region src/client.ts
|
|
4
|
+
/**
|
|
5
|
+
* Browser side. Starts the official Plausible tracker (the `@plausible-analytics/tracker` package, bundled into the
|
|
6
|
+
* site) pointed at the relay on your own domain.
|
|
7
|
+
*/
|
|
8
|
+
let started = false;
|
|
9
|
+
/**
|
|
10
|
+
* Starts the tracker. The first call wins; later calls do nothing, so it is safe in a script that runs on every
|
|
11
|
+
* page. The tracker itself allows a single `init()`.
|
|
12
|
+
*/
|
|
13
|
+
function startPlausible(options) {
|
|
14
|
+
if (started || typeof window === "undefined") return;
|
|
15
|
+
started = true;
|
|
16
|
+
const { customProperties, transformRequest, ...rest } = options;
|
|
17
|
+
const config = plausibleConfig(typeof customProperties === "function" || customProperties === void 0 ? rest : {
|
|
18
|
+
...rest,
|
|
19
|
+
customProperties
|
|
20
|
+
});
|
|
21
|
+
if (typeof customProperties === "function") config.customProperties = customProperties;
|
|
22
|
+
if (transformRequest) config.transformRequest = transformRequest;
|
|
23
|
+
init(config);
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
export { startPlausible, track };
|
|
27
|
+
|
|
28
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","names":[],"sources":["../src/client.ts"],"sourcesContent":["/**\n * Browser side. Starts the official Plausible tracker (the `@plausible-analytics/tracker` package, bundled into the\n * site) pointed at the relay on your own domain.\n */\nimport { init, type PlausibleConfig, track } from \"@plausible-analytics/tracker\";\nimport { type PlausibleOptions, plausibleConfig } from \"./options\";\n\nexport type { CustomProperties, PlausibleEventOptions, PlausibleRequestPayload } from \"@plausible-analytics/tracker\";\nexport { track };\n\nexport interface PlausibleClientOptions extends Omit<PlausibleOptions, \"customProperties\"> {\n /** Custom properties for every event, or a function of the event name that returns them. */\n customProperties?: PlausibleConfig[\"customProperties\"];\n /** Change or drop a request before it is sent. Return null to drop it. */\n transformRequest?: PlausibleConfig[\"transformRequest\"];\n}\n\nlet started = false;\n\n/**\n * Starts the tracker. The first call wins; later calls do nothing, so it is safe in a script that runs on every\n * page. The tracker itself allows a single `init()`.\n */\nexport function startPlausible(options: PlausibleClientOptions): void {\n if (started || typeof window === \"undefined\") return;\n started = true;\n\n const { customProperties, transformRequest, ...rest } = options;\n const config = plausibleConfig(\n typeof customProperties === \"function\" || customProperties === undefined ? rest : { ...rest, customProperties },\n );\n if (typeof customProperties === \"function\") config.customProperties = customProperties;\n if (transformRequest) config.transformRequest = transformRequest;\n init(config);\n}\n"],"mappings":";;;;;;;AAiBA,IAAI,UAAU;;;;;AAMd,SAAgB,eAAe,SAAuC;CACpE,IAAI,WAAW,OAAO,WAAW,aAAa;CAC9C,UAAU;CAEV,MAAM,EAAE,kBAAkB,kBAAkB,GAAG,SAAS;CACxD,MAAM,SAAS,gBACb,OAAO,qBAAqB,cAAc,qBAAqB,KAAA,IAAY,OAAO;EAAE,GAAG;EAAM;CAAiB,CAChH;CACA,IAAI,OAAO,qBAAqB,YAAY,OAAO,mBAAmB;CACtE,IAAI,kBAAkB,OAAO,mBAAmB;CAChD,KAAK,MAAM;AACb"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { n as PlausibleOptions, r as plausibleConfig, t as DEFAULT_ENDPOINT } from "./options-WE-C48Ul.js";
|
|
2
|
+
import { PlausibleIntegrationOptions, clientScript } from "./integration.js";
|
|
3
|
+
export { DEFAULT_ENDPOINT, type PlausibleIntegrationOptions, type PlausibleOptions, clientScript, plausibleConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { n as PlausibleOptions } from "./options-WE-C48Ul.js";
|
|
2
|
+
import { AstroIntegration } from "astro";
|
|
3
|
+
//#region src/integration.d.ts
|
|
4
|
+
export interface PlausibleIntegrationOptions extends PlausibleOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Whether to add the tracker to pages. Default is to add it for `astro build` and leave it out of `astro dev`,
|
|
7
|
+
* so local browsing does not show up in the dashboard. Set `true` to track in dev too (with
|
|
8
|
+
* `captureOnLocalhost: true`, or the tracker ignores localhost anyway).
|
|
9
|
+
*/
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** The client script injected on every page. Exported for tests. */
|
|
13
|
+
export declare function clientScript(options: PlausibleOptions): string;
|
|
14
|
+
/**
|
|
15
|
+
* Astro integration. Bundles the Plausible tracker into every page and points it at `endpoint`, which defaults to
|
|
16
|
+
* `/api/event` on the site itself. Pair it with the relay from `astro-plausible init` so events reach Plausible
|
|
17
|
+
* from your own domain.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```js
|
|
21
|
+
* // astro.config.mjs
|
|
22
|
+
* import plausible from "@sjohansson/astro-plausible/integration";
|
|
23
|
+
*
|
|
24
|
+
* export default defineConfig({
|
|
25
|
+
* integrations: [plausible({ domain: "example.com" })],
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export default function plausibleIntegration(options: PlausibleIntegrationOptions): AstroIntegration;
|
|
30
|
+
//#endregion
|
|
31
|
+
//# sourceMappingURL=integration.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"integration.d.ts","names":[],"sources":["../src/integration.ts"],"mappings":";;;iBAGiB,oCAAoC;;;;;;EAMnD;;;wBAIc,aAAa,SAAS;;;;;;;;;;;;;;;;wBAsBd,qBAAqB,SAAS,8BAA8B"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { n as plausibleConfig } from "./options-CZaM-Wb7.js";
|
|
2
|
+
//#region src/integration.ts
|
|
3
|
+
/** The client script injected on every page. Exported for tests. */
|
|
4
|
+
function clientScript(options) {
|
|
5
|
+
return ["import { startPlausible } from \"@sjohansson/astro-plausible/client\";", `startPlausible(${JSON.stringify(plausibleConfig(options))});`].join("\n");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Astro integration. Bundles the Plausible tracker into every page and points it at `endpoint`, which defaults to
|
|
9
|
+
* `/api/event` on the site itself. Pair it with the relay from `astro-plausible init` so events reach Plausible
|
|
10
|
+
* from your own domain.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```js
|
|
14
|
+
* // astro.config.mjs
|
|
15
|
+
* import plausible from "@sjohansson/astro-plausible/integration";
|
|
16
|
+
*
|
|
17
|
+
* export default defineConfig({
|
|
18
|
+
* integrations: [plausible({ domain: "example.com" })],
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
function plausibleIntegration(options) {
|
|
23
|
+
const { enabled, ...tracker } = options ?? {};
|
|
24
|
+
if (enabled !== void 0 && typeof enabled !== "boolean") throw new Error("[@sjohansson/astro-plausible] enabled must be a boolean.");
|
|
25
|
+
plausibleConfig(tracker);
|
|
26
|
+
return {
|
|
27
|
+
name: "@sjohansson/astro-plausible",
|
|
28
|
+
hooks: { "astro:config:setup": ({ command, injectScript, logger }) => {
|
|
29
|
+
if (!(enabled ?? command === "build")) {
|
|
30
|
+
logger.info(`Plausible tracker left out of this ${command}. Set enabled: true to include it.`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
injectScript("page", clientScript(tracker));
|
|
34
|
+
logger.debug(`Plausible tracker added, sending to ${tracker.endpoint ?? "/api/event"}`);
|
|
35
|
+
} }
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { clientScript, plausibleIntegration as default };
|
|
40
|
+
|
|
41
|
+
//# sourceMappingURL=integration.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"integration.js","names":[],"sources":["../src/integration.ts"],"sourcesContent":["import type { AstroIntegration } from \"astro\";\nimport { type PlausibleOptions, plausibleConfig } from \"./options\";\n\nexport interface PlausibleIntegrationOptions extends PlausibleOptions {\n /**\n * Whether to add the tracker to pages. Default is to add it for `astro build` and leave it out of `astro dev`,\n * so local browsing does not show up in the dashboard. Set `true` to track in dev too (with\n * `captureOnLocalhost: true`, or the tracker ignores localhost anyway).\n */\n enabled?: boolean;\n}\n\n/** The client script injected on every page. Exported for tests. */\nexport function clientScript(options: PlausibleOptions): string {\n return [\n 'import { startPlausible } from \"@sjohansson/astro-plausible/client\";',\n `startPlausible(${JSON.stringify(plausibleConfig(options))});`,\n ].join(\"\\n\");\n}\n\n/**\n * Astro integration. Bundles the Plausible tracker into every page and points it at `endpoint`, which defaults to\n * `/api/event` on the site itself. Pair it with the relay from `astro-plausible init` so events reach Plausible\n * from your own domain.\n *\n * @example\n * ```js\n * // astro.config.mjs\n * import plausible from \"@sjohansson/astro-plausible/integration\";\n *\n * export default defineConfig({\n * integrations: [plausible({ domain: \"example.com\" })],\n * });\n * ```\n */\nexport default function plausibleIntegration(options: PlausibleIntegrationOptions): AstroIntegration {\n const { enabled, ...tracker } = options ?? {};\n if (enabled !== undefined && typeof enabled !== \"boolean\") {\n throw new Error(\"[@sjohansson/astro-plausible] enabled must be a boolean.\");\n }\n // Validate now so a bad config fails at startup, not on the first page.\n plausibleConfig(tracker);\n\n return {\n name: \"@sjohansson/astro-plausible\",\n hooks: {\n \"astro:config:setup\": ({ command, injectScript, logger }) => {\n const on = enabled ?? command === \"build\";\n if (!on) {\n logger.info(`Plausible tracker left out of this ${command}. Set enabled: true to include it.`);\n return;\n }\n injectScript(\"page\", clientScript(tracker));\n logger.debug(`Plausible tracker added, sending to ${tracker.endpoint ?? \"/api/event\"}`);\n },\n },\n };\n}\n"],"mappings":";;;AAaA,SAAgB,aAAa,SAAmC;CAC9D,OAAO,CACL,0EACA,kBAAkB,KAAK,UAAU,gBAAgB,OAAO,CAAC,EAAE,GAC7D,CAAC,CAAC,KAAK,IAAI;AACb;;;;;;;;;;;;;;;;AAiBA,SAAwB,qBAAqB,SAAwD;CACnG,MAAM,EAAE,SAAS,GAAG,YAAY,WAAW,CAAC;CAC5C,IAAI,YAAY,KAAA,KAAa,OAAO,YAAY,WAC9C,MAAM,IAAI,MAAM,0DAA0D;CAG5E,gBAAgB,OAAO;CAEvB,OAAO;EACL,MAAM;EACN,OAAO,EACL,uBAAuB,EAAE,SAAS,cAAc,aAAa;GAE3D,IAAI,EADO,WAAW,YAAY,UACzB;IACP,OAAO,KAAK,sCAAsC,QAAQ,mCAAmC;IAC7F;GACF;GACA,aAAa,QAAQ,aAAa,OAAO,CAAC;GAC1C,OAAO,MAAM,uCAAuC,QAAQ,YAAY,cAAc;EACxF,EACF;CACF;AACF"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//#region src/options.ts
|
|
2
|
+
const DEFAULT_ENDPOINT = "/api/event";
|
|
3
|
+
const DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(\/[\w.-]+)*$/i;
|
|
4
|
+
const BOOLEAN_KEYS = [
|
|
5
|
+
"autoCapturePageviews",
|
|
6
|
+
"hashBasedRouting",
|
|
7
|
+
"outboundLinks",
|
|
8
|
+
"formSubmissions",
|
|
9
|
+
"captureOnLocalhost",
|
|
10
|
+
"logging",
|
|
11
|
+
"bindToWindow"
|
|
12
|
+
];
|
|
13
|
+
function fail(message) {
|
|
14
|
+
throw new Error(`[@sjohansson/astro-plausible] ${message}`);
|
|
15
|
+
}
|
|
16
|
+
/** Validates the options and returns the config for the tracker's `init()`. Only keys that were set are included. */
|
|
17
|
+
function plausibleConfig(options) {
|
|
18
|
+
if (typeof options?.domain !== "string" || options.domain.length > 253 || !DOMAIN_PATTERN.test(options.domain)) fail("domain must be the site domain as entered in Plausible, like example.com.");
|
|
19
|
+
const endpoint = options.endpoint ?? "/api/event";
|
|
20
|
+
if (typeof endpoint !== "string" || endpoint.length === 0) fail("endpoint must be a non-empty string.");
|
|
21
|
+
const config = {
|
|
22
|
+
domain: options.domain,
|
|
23
|
+
endpoint
|
|
24
|
+
};
|
|
25
|
+
for (const key of BOOLEAN_KEYS) {
|
|
26
|
+
const value = options[key];
|
|
27
|
+
if (value === void 0) continue;
|
|
28
|
+
if (typeof value !== "boolean") fail(`${key} must be a boolean.`);
|
|
29
|
+
config[key] = value;
|
|
30
|
+
}
|
|
31
|
+
if (options.fileDownloads !== void 0) {
|
|
32
|
+
const value = options.fileDownloads;
|
|
33
|
+
const custom = typeof value === "object" && value !== null && Array.isArray(value.fileExtensions);
|
|
34
|
+
if (typeof value !== "boolean" && !custom) fail("fileDownloads must be a boolean or { fileExtensions: [...] }.");
|
|
35
|
+
config.fileDownloads = value;
|
|
36
|
+
}
|
|
37
|
+
if (options.customProperties !== void 0) {
|
|
38
|
+
const value = options.customProperties;
|
|
39
|
+
if (!(typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((v) => [
|
|
40
|
+
"string",
|
|
41
|
+
"number",
|
|
42
|
+
"boolean"
|
|
43
|
+
].includes(typeof v)))) fail("customProperties must be an object of strings, numbers and booleans.");
|
|
44
|
+
config.customProperties = value;
|
|
45
|
+
}
|
|
46
|
+
return config;
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
export { plausibleConfig as n, DEFAULT_ENDPOINT as t };
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=options-CZaM-Wb7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"options-CZaM-Wb7.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,63 @@
|
|
|
1
|
+
import { PlausibleConfig } from "@plausible-analytics/tracker";
|
|
2
|
+
//#region src/options.d.ts
|
|
3
|
+
/** Tracker settings that can be written down as JSON. These go straight to the tracker's `init()`. */
|
|
4
|
+
interface PlausibleOptions {
|
|
5
|
+
/** Your site's domain, as entered in Plausible. */
|
|
6
|
+
domain: string;
|
|
7
|
+
/**
|
|
8
|
+
* Where events go. `/api/event` is the relay from `astro-plausible init` on your own domain. Set the full
|
|
9
|
+
* Plausible URL, `https://plausible.io/api/event`, to skip the relay.
|
|
10
|
+
* @default "/api/event"
|
|
11
|
+
*/
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Send a pageview on load and on history navigation.
|
|
15
|
+
* @default true
|
|
16
|
+
*/
|
|
17
|
+
autoCapturePageviews?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Track `#` paths as separate pages.
|
|
20
|
+
* @default false
|
|
21
|
+
*/
|
|
22
|
+
hashBasedRouting?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Track clicks on links to other sites.
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
outboundLinks?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Track file downloads, with Plausible's default file types or the ones given.
|
|
30
|
+
* @default false
|
|
31
|
+
*/
|
|
32
|
+
fileDownloads?: boolean | {
|
|
33
|
+
fileExtensions: string[];
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Track form submissions.
|
|
37
|
+
* @default false
|
|
38
|
+
*/
|
|
39
|
+
formSubmissions?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Also track on localhost.
|
|
42
|
+
* @default false
|
|
43
|
+
*/
|
|
44
|
+
captureOnLocalhost?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Log ignored events to the console.
|
|
47
|
+
* @default true
|
|
48
|
+
*/
|
|
49
|
+
logging?: boolean;
|
|
50
|
+
/** Custom properties added to every event. */
|
|
51
|
+
customProperties?: Record<string, string | number | boolean>;
|
|
52
|
+
/**
|
|
53
|
+
* Expose the tracker as `window.plausible`. Plausible's installation check looks for it.
|
|
54
|
+
* @default true
|
|
55
|
+
*/
|
|
56
|
+
bindToWindow?: boolean;
|
|
57
|
+
}
|
|
58
|
+
declare const DEFAULT_ENDPOINT = "/api/event";
|
|
59
|
+
/** Validates the options and returns the config for the tracker's `init()`. Only keys that were set are included. */
|
|
60
|
+
declare function plausibleConfig(options: PlausibleOptions): PlausibleConfig;
|
|
61
|
+
//#endregion
|
|
62
|
+
export { PlausibleOptions as n, plausibleConfig as r, DEFAULT_ENDPOINT as t };
|
|
63
|
+
//# sourceMappingURL=options-WE-C48Ul.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"options-WE-C48Ul.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"}
|
package/dist/relay.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//#region src/relay.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The relay forwards tracker events from your domain to Plausible. It is written against the web standard Request
|
|
4
|
+
* and Response, so it runs anywhere fetch does: an Astro endpoint, Cloudflare, Netlify, Vercel, or through the
|
|
5
|
+
* `swaHandler` adapter, Azure Static Web Apps managed functions.
|
|
6
|
+
*
|
|
7
|
+
* It checks the domain so the endpoint only relays for your sites, passes the visitor's IP in `X-Forwarded-For`
|
|
8
|
+
* and their user agent (Plausible drops events without a real visitor IP), caps the body size, and can validate
|
|
9
|
+
* custom events.
|
|
10
|
+
*/
|
|
11
|
+
/** Per property pattern that an event's props must match. Only the listed properties are forwarded. */
|
|
12
|
+
export type RelayRule = Record<string, RegExp | string>;
|
|
13
|
+
export interface RelayOptions {
|
|
14
|
+
/** Site domain as entered in Plausible, or several when one relay serves several sites. */
|
|
15
|
+
domain: string | string[];
|
|
16
|
+
/**
|
|
17
|
+
* The Plausible host. Events are posted to `<upstream>/api/event`. Self-hosted: `https://plausible.example.com`.
|
|
18
|
+
* @default "https://plausible.io"
|
|
19
|
+
*/
|
|
20
|
+
upstream?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Largest request body accepted, in bytes.
|
|
23
|
+
* @default 16384
|
|
24
|
+
*/
|
|
25
|
+
maxBodyLength?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Validation for custom events, by event name. An event with a rule is forwarded only if every listed property
|
|
28
|
+
* is a string matching its pattern, and with only those properties. Events without a rule pass through as is.
|
|
29
|
+
*/
|
|
30
|
+
events?: Record<string, RelayRule>;
|
|
31
|
+
/** Called when Plausible cannot be reached. */
|
|
32
|
+
onError?: (error: unknown) => void;
|
|
33
|
+
}
|
|
34
|
+
/** Incoming request headers with lowercased names, as Node and most platforms give them. */
|
|
35
|
+
export type RelayHeaders = Record<string, string | string[] | undefined>;
|
|
36
|
+
export interface RelayForward {
|
|
37
|
+
url: string;
|
|
38
|
+
body: string;
|
|
39
|
+
headers: Record<string, string>;
|
|
40
|
+
}
|
|
41
|
+
export interface RelayResult {
|
|
42
|
+
status: number;
|
|
43
|
+
contentType: string;
|
|
44
|
+
body: string;
|
|
45
|
+
}
|
|
46
|
+
export interface Relay {
|
|
47
|
+
/** Builds the upstream request, or null when the event is not for this relay or not valid. */
|
|
48
|
+
forward(rawBody: unknown, headers: RelayHeaders, clientIp?: string): RelayForward | null;
|
|
49
|
+
/** Sends a forward to Plausible. Throws when Plausible cannot be reached. */
|
|
50
|
+
send(forward: RelayForward): Promise<RelayResult>;
|
|
51
|
+
/** Handles a web standard request end to end. Pass the visitor IP when the platform gives it to you. */
|
|
52
|
+
handle(request: Request, context?: {
|
|
53
|
+
clientIp?: string;
|
|
54
|
+
}): Promise<Response>;
|
|
55
|
+
}
|
|
56
|
+
export declare const DEFAULT_UPSTREAM = "https://plausible.io";
|
|
57
|
+
export declare const DEFAULT_MAX_BODY_LENGTH: number;
|
|
58
|
+
/** Visitor IP from platform headers, without any port. Undefined when no header carries one. */
|
|
59
|
+
export declare function clientIp(headers: RelayHeaders): string | undefined;
|
|
60
|
+
export declare function createRelay(options: RelayOptions): Relay;
|
|
61
|
+
/** The subset of the Azure Functions v3 context the adapter uses. */
|
|
62
|
+
export interface SwaContext {
|
|
63
|
+
log: {
|
|
64
|
+
warn(message: string): void;
|
|
65
|
+
};
|
|
66
|
+
res?: {
|
|
67
|
+
status: number;
|
|
68
|
+
headers?: Record<string, string>;
|
|
69
|
+
body?: string;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export interface SwaRequest {
|
|
73
|
+
rawBody?: unknown;
|
|
74
|
+
headers?: RelayHeaders;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Adapter for Azure Static Web Apps managed functions (Node, `function.json` model). `astro-plausible init` writes
|
|
78
|
+
* a function that uses this.
|
|
79
|
+
*/
|
|
80
|
+
export declare function swaHandler(relay: Relay): (context: SwaContext, req: SwaRequest) => Promise<void>;
|
|
81
|
+
//#endregion
|
|
82
|
+
//# 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;;;;;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;;wBAmBG,SAAS,SAAS;wBA+ClB,YAAY,SAAS,eAAe;;iBAwEnC;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,156 @@
|
|
|
1
|
+
//#region src/relay.ts
|
|
2
|
+
const DEFAULT_UPSTREAM = "https://plausible.io";
|
|
3
|
+
const DEFAULT_MAX_BODY_LENGTH = 16384;
|
|
4
|
+
const IP_HEADERS = [
|
|
5
|
+
"x-azure-clientip",
|
|
6
|
+
"x-client-ip",
|
|
7
|
+
"cf-connecting-ip",
|
|
8
|
+
"x-nf-client-connection-ip",
|
|
9
|
+
"x-real-ip",
|
|
10
|
+
"x-forwarded-for"
|
|
11
|
+
];
|
|
12
|
+
function header(headers, name) {
|
|
13
|
+
const value = headers[name];
|
|
14
|
+
return Array.isArray(value) ? value[0] : value;
|
|
15
|
+
}
|
|
16
|
+
/** Visitor IP from platform headers, without any port. Undefined when no header carries one. */
|
|
17
|
+
function clientIp(headers) {
|
|
18
|
+
for (const name of IP_HEADERS) {
|
|
19
|
+
const raw = header(headers, name)?.split(",")[0]?.trim();
|
|
20
|
+
if (!raw) continue;
|
|
21
|
+
const bracketed = raw.match(/^\[(.+)\]:\d+$/);
|
|
22
|
+
if (bracketed) return bracketed[1];
|
|
23
|
+
const ipv4WithPort = raw.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/);
|
|
24
|
+
return ipv4WithPort ? ipv4WithPort[1] : raw;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function compileRules(events = {}) {
|
|
28
|
+
const compiled = /* @__PURE__ */ new Map();
|
|
29
|
+
for (const [name, rule] of Object.entries(events)) {
|
|
30
|
+
const entries = Object.entries(rule).map(([property, pattern]) => [property, pattern instanceof RegExp ? pattern : new RegExp(pattern)]);
|
|
31
|
+
if (entries.length === 0) throw new Error(`[@sjohansson/astro-plausible] Rule for event "${name}" lists no properties.`);
|
|
32
|
+
compiled.set(name, entries);
|
|
33
|
+
}
|
|
34
|
+
return compiled;
|
|
35
|
+
}
|
|
36
|
+
/** Applies the rule for an event. Returns the props to forward, or null when the event is not valid. */
|
|
37
|
+
function applyRule(rules, props) {
|
|
38
|
+
const source = props && typeof props === "object" ? props : {};
|
|
39
|
+
const kept = {};
|
|
40
|
+
for (const [property, pattern] of rules) {
|
|
41
|
+
const value = source[property];
|
|
42
|
+
if (typeof value !== "string" || !pattern.test(value)) return null;
|
|
43
|
+
kept[property] = value;
|
|
44
|
+
}
|
|
45
|
+
return kept;
|
|
46
|
+
}
|
|
47
|
+
function headersToRecord(headers) {
|
|
48
|
+
const record = {};
|
|
49
|
+
headers.forEach((value, name) => {
|
|
50
|
+
record[name.toLowerCase()] = value;
|
|
51
|
+
});
|
|
52
|
+
return record;
|
|
53
|
+
}
|
|
54
|
+
function createRelay(options) {
|
|
55
|
+
const domains = new Set(Array.isArray(options.domain) ? options.domain : [options.domain]);
|
|
56
|
+
if (domains.size === 0 || [...domains].some((d) => typeof d !== "string" || d.length === 0)) throw new Error("[@sjohansson/astro-plausible] domain is required.");
|
|
57
|
+
const upstream = `${(options.upstream ?? "https://plausible.io").replace(/\/$/, "")}/api/event`;
|
|
58
|
+
const maxBodyLength = options.maxBodyLength ?? 16384;
|
|
59
|
+
const rules = compileRules(options.events);
|
|
60
|
+
const relay = {
|
|
61
|
+
forward(rawBody, headers, ip) {
|
|
62
|
+
if (typeof rawBody !== "string" || rawBody.length === 0 || rawBody.length > maxBodyLength) return null;
|
|
63
|
+
let event;
|
|
64
|
+
try {
|
|
65
|
+
event = JSON.parse(rawBody);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) return null;
|
|
70
|
+
if (typeof event["d"] !== "string" || !domains.has(event["d"])) return null;
|
|
71
|
+
const name = event["n"];
|
|
72
|
+
if (typeof name !== "string" || name.length === 0) return null;
|
|
73
|
+
if (rules.has(name)) {
|
|
74
|
+
const kept = applyRule(rules.get(name) ?? [], event["p"]);
|
|
75
|
+
if (!kept) return null;
|
|
76
|
+
event["p"] = kept;
|
|
77
|
+
}
|
|
78
|
+
const forwardHeaders = { "content-type": "application/json" };
|
|
79
|
+
const userAgent = header(headers, "user-agent");
|
|
80
|
+
if (userAgent) forwardHeaders["user-agent"] = userAgent;
|
|
81
|
+
const visitorIp = ip ?? clientIp(headers);
|
|
82
|
+
if (visitorIp) forwardHeaders["x-forwarded-for"] = visitorIp;
|
|
83
|
+
return {
|
|
84
|
+
url: upstream,
|
|
85
|
+
body: JSON.stringify(event),
|
|
86
|
+
headers: forwardHeaders
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
async send(forward) {
|
|
90
|
+
const res = await fetch(forward.url, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: forward.headers,
|
|
93
|
+
body: forward.body
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
status: res.status,
|
|
97
|
+
contentType: res.headers.get("content-type") ?? "text/plain",
|
|
98
|
+
body: await res.text()
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
async handle(request, context = {}) {
|
|
102
|
+
if (request.method !== "POST") return new Response(null, {
|
|
103
|
+
status: 405,
|
|
104
|
+
headers: { allow: "POST" }
|
|
105
|
+
});
|
|
106
|
+
if (Number(request.headers.get("content-length")) > maxBodyLength) return new Response(null, { status: 413 });
|
|
107
|
+
const forward = relay.forward(await request.text(), headersToRecord(request.headers), context.clientIp);
|
|
108
|
+
if (!forward) return new Response(null, { status: 400 });
|
|
109
|
+
try {
|
|
110
|
+
const result = await relay.send(forward);
|
|
111
|
+
return new Response(result.body, {
|
|
112
|
+
status: result.status,
|
|
113
|
+
headers: {
|
|
114
|
+
"content-type": result.contentType,
|
|
115
|
+
"cache-control": "no-store"
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
options.onError?.(error);
|
|
120
|
+
return new Response(null, { status: 502 });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
return relay;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Adapter for Azure Static Web Apps managed functions (Node, `function.json` model). `astro-plausible init` writes
|
|
128
|
+
* a function that uses this.
|
|
129
|
+
*/
|
|
130
|
+
function swaHandler(relay) {
|
|
131
|
+
return async (context, req) => {
|
|
132
|
+
const forward = relay.forward(req.rawBody, req.headers ?? {});
|
|
133
|
+
if (!forward) {
|
|
134
|
+
context.res = { status: 400 };
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const result = await relay.send(forward);
|
|
139
|
+
context.res = {
|
|
140
|
+
status: result.status,
|
|
141
|
+
headers: {
|
|
142
|
+
"content-type": result.contentType,
|
|
143
|
+
"cache-control": "no-store"
|
|
144
|
+
},
|
|
145
|
+
body: result.body
|
|
146
|
+
};
|
|
147
|
+
} catch (error) {
|
|
148
|
+
context.log.warn(`Plausible forward failed: ${error}`);
|
|
149
|
+
context.res = { status: 502 };
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
export { DEFAULT_MAX_BODY_LENGTH, DEFAULT_UPSTREAM, clientIp, createRelay, swaHandler };
|
|
155
|
+
|
|
156
|
+
//# 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 * 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// Platform headers that carry the visitor IP, most specific first. x-forwarded-for is last because proxies append\n// to it and some let the client seed it.\nconst IP_HEADERS = [\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): string | undefined {\n for (const name of IP_HEADERS) {\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 pattern instanceof RegExp ? pattern : 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\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\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\n const relay: Relay = {\n forward(rawBody, headers, ip) {\n if (typeof rawBody !== \"string\" || rawBody.length === 0 || 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 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);\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 const forward = relay.forward(await request.text(), 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":";AA+DA,MAAa,mBAAmB;AAChC,MAAa,0BAA0B;AAIvC,MAAM,aAAa;CACjB;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,SAA2C;CAClE,KAAK,MAAM,QAAQ,YAAY;EAC7B,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,UACA,mBAAmB,SAAS,UAAU,IAAI,OAAO,OAAO,CAC1D,CAAC;EACD,IAAI,QAAQ,WAAW,GACrB,MAAM,IAAI,MAAM,iDAAiD,KAAK,uBAAuB;EAE/F,SAAS,IAAI,MAAM,OAAO;CAC5B;CACA,OAAO;AACT;;AAGA,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,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;CAEzC,MAAM,QAAe;EACnB,QAAQ,SAAS,SAAS,IAAI;GAC5B,IAAI,OAAO,YAAY,YAAY,QAAQ,WAAW,KAAK,QAAQ,SAAS,eAAe,OAAO;GAElG,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;IAClB,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,OAAO;GACxC,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;GAErE,MAAM,UAAU,MAAM,QAAQ,MAAM,QAAQ,KAAK,GAAG,gBAAgB,QAAQ,OAAO,GAAG,QAAQ,QAAQ;GACtG,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,155 @@
|
|
|
1
|
+
const IP_HEADERS = [
|
|
2
|
+
"x-azure-clientip",
|
|
3
|
+
"x-client-ip",
|
|
4
|
+
"cf-connecting-ip",
|
|
5
|
+
"x-nf-client-connection-ip",
|
|
6
|
+
"x-real-ip",
|
|
7
|
+
"x-forwarded-for"
|
|
8
|
+
];
|
|
9
|
+
function header(headers, name) {
|
|
10
|
+
const value = headers[name];
|
|
11
|
+
return Array.isArray(value) ? value[0] : value;
|
|
12
|
+
}
|
|
13
|
+
/** Visitor IP from platform headers, without any port. Undefined when no header carries one. */
|
|
14
|
+
function clientIp(headers) {
|
|
15
|
+
for (const name of IP_HEADERS) {
|
|
16
|
+
const raw = header(headers, name)?.split(",")[0]?.trim();
|
|
17
|
+
if (!raw) continue;
|
|
18
|
+
const bracketed = raw.match(/^\[(.+)\]:\d+$/);
|
|
19
|
+
if (bracketed) return bracketed[1];
|
|
20
|
+
const ipv4WithPort = raw.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/);
|
|
21
|
+
return ipv4WithPort ? ipv4WithPort[1] : raw;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function compileRules(events = {}) {
|
|
25
|
+
const compiled = /* @__PURE__ */ new Map();
|
|
26
|
+
for (const [name, rule] of Object.entries(events)) {
|
|
27
|
+
const entries = Object.entries(rule).map(([property, pattern]) => [property, pattern instanceof RegExp ? pattern : new RegExp(pattern)]);
|
|
28
|
+
if (entries.length === 0) throw new Error(`[@sjohansson/astro-plausible] Rule for event "${name}" lists no properties.`);
|
|
29
|
+
compiled.set(name, entries);
|
|
30
|
+
}
|
|
31
|
+
return compiled;
|
|
32
|
+
}
|
|
33
|
+
/** Applies the rule for an event. Returns the props to forward, or null when the event is not valid. */
|
|
34
|
+
function applyRule(rules, props) {
|
|
35
|
+
const source = props && typeof props === "object" ? props : {};
|
|
36
|
+
const kept = {};
|
|
37
|
+
for (const [property, pattern] of rules) {
|
|
38
|
+
const value = source[property];
|
|
39
|
+
if (typeof value !== "string" || !pattern.test(value)) return null;
|
|
40
|
+
kept[property] = value;
|
|
41
|
+
}
|
|
42
|
+
return kept;
|
|
43
|
+
}
|
|
44
|
+
function headersToRecord(headers) {
|
|
45
|
+
const record = {};
|
|
46
|
+
headers.forEach((value, name) => {
|
|
47
|
+
record[name.toLowerCase()] = value;
|
|
48
|
+
});
|
|
49
|
+
return record;
|
|
50
|
+
}
|
|
51
|
+
function createRelay(options) {
|
|
52
|
+
const domains = new Set(Array.isArray(options.domain) ? options.domain : [options.domain]);
|
|
53
|
+
if (domains.size === 0 || [...domains].some((d) => typeof d !== "string" || d.length === 0)) throw new Error("[@sjohansson/astro-plausible] domain is required.");
|
|
54
|
+
const upstream = `${(options.upstream ?? "https://plausible.io").replace(/\/$/, "")}/api/event`;
|
|
55
|
+
const maxBodyLength = options.maxBodyLength ?? 16384;
|
|
56
|
+
const rules = compileRules(options.events);
|
|
57
|
+
const relay = {
|
|
58
|
+
forward(rawBody, headers, ip) {
|
|
59
|
+
if (typeof rawBody !== "string" || rawBody.length === 0 || rawBody.length > maxBodyLength) return null;
|
|
60
|
+
let event;
|
|
61
|
+
try {
|
|
62
|
+
event = JSON.parse(rawBody);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) return null;
|
|
67
|
+
if (typeof event["d"] !== "string" || !domains.has(event["d"])) return null;
|
|
68
|
+
const name = event["n"];
|
|
69
|
+
if (typeof name !== "string" || name.length === 0) return null;
|
|
70
|
+
if (rules.has(name)) {
|
|
71
|
+
const kept = applyRule(rules.get(name) ?? [], event["p"]);
|
|
72
|
+
if (!kept) return null;
|
|
73
|
+
event["p"] = kept;
|
|
74
|
+
}
|
|
75
|
+
const forwardHeaders = { "content-type": "application/json" };
|
|
76
|
+
const userAgent = header(headers, "user-agent");
|
|
77
|
+
if (userAgent) forwardHeaders["user-agent"] = userAgent;
|
|
78
|
+
const visitorIp = ip ?? clientIp(headers);
|
|
79
|
+
if (visitorIp) forwardHeaders["x-forwarded-for"] = visitorIp;
|
|
80
|
+
return {
|
|
81
|
+
url: upstream,
|
|
82
|
+
body: JSON.stringify(event),
|
|
83
|
+
headers: forwardHeaders
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
async send(forward) {
|
|
87
|
+
const res = await fetch(forward.url, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: forward.headers,
|
|
90
|
+
body: forward.body
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
status: res.status,
|
|
94
|
+
contentType: res.headers.get("content-type") ?? "text/plain",
|
|
95
|
+
body: await res.text()
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
async handle(request, context = {}) {
|
|
99
|
+
if (request.method !== "POST") return new Response(null, {
|
|
100
|
+
status: 405,
|
|
101
|
+
headers: { allow: "POST" }
|
|
102
|
+
});
|
|
103
|
+
if (Number(request.headers.get("content-length")) > maxBodyLength) return new Response(null, { status: 413 });
|
|
104
|
+
const forward = relay.forward(await request.text(), headersToRecord(request.headers), context.clientIp);
|
|
105
|
+
if (!forward) return new Response(null, { status: 400 });
|
|
106
|
+
try {
|
|
107
|
+
const result = await relay.send(forward);
|
|
108
|
+
return new Response(result.body, {
|
|
109
|
+
status: result.status,
|
|
110
|
+
headers: {
|
|
111
|
+
"content-type": result.contentType,
|
|
112
|
+
"cache-control": "no-store"
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
options.onError?.(error);
|
|
117
|
+
return new Response(null, { status: 502 });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
return relay;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Adapter for Azure Static Web Apps managed functions (Node, `function.json` model). `astro-plausible init` writes
|
|
125
|
+
* a function that uses this.
|
|
126
|
+
*/
|
|
127
|
+
function swaHandler(relay) {
|
|
128
|
+
return async (context, req) => {
|
|
129
|
+
const forward = relay.forward(req.rawBody, req.headers ?? {});
|
|
130
|
+
if (!forward) {
|
|
131
|
+
context.res = { status: 400 };
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const result = await relay.send(forward);
|
|
136
|
+
context.res = {
|
|
137
|
+
status: result.status,
|
|
138
|
+
headers: {
|
|
139
|
+
"content-type": result.contentType,
|
|
140
|
+
"cache-control": "no-store"
|
|
141
|
+
},
|
|
142
|
+
body: result.body
|
|
143
|
+
};
|
|
144
|
+
} catch (error) {
|
|
145
|
+
context.log.warn(`Plausible forward failed: ${error}`);
|
|
146
|
+
context.res = { status: 502 };
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/templates/swa.ts
|
|
152
|
+
const relay = createRelay(__ASTRO_PLAUSIBLE_RELAY_CONFIG__);
|
|
153
|
+
var swa_default = swaHandler(relay);
|
|
154
|
+
//#endregion
|
|
155
|
+
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
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.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
|
}
|