@sjohansson/astro-plausible 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,21 +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.
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
@@ -92,7 +92,8 @@ CLI options:
92
92
 
93
93
  The relay works on the web standard `Request` and `Response`. Give it the visitor IP when the platform provides one;
94
94
  otherwise it reads the usual headers (`x-azure-clientip`, `cf-connecting-ip`, `x-real-ip`, `x-forwarded-for`, and
95
- so on).
95
+ so on). A client can send any of those headers, and the first one present wins, so on a platform that sets only
96
+ one of them, name it with `ipHeaders`.
96
97
 
97
98
  ```ts
98
99
  // src/pages/api/event.ts, an Astro server endpoint
@@ -110,7 +111,8 @@ export const POST: APIRoute = ({ request, clientAddress }) => relay.handle(reque
110
111
  // functions/api/event.ts, a Cloudflare Pages function
111
112
  import { createRelay } from "@sjohansson/astro-plausible/relay";
112
113
 
113
- const relay = createRelay({ domain: "example.com" });
114
+ // Cloudflare sets cf-connecting-ip; any other IP header could come from the client.
115
+ const relay = createRelay({ domain: "example.com", ipHeaders: ["cf-connecting-ip"] });
114
116
 
115
117
  export const onRequestPost = ({ request }) => relay.handle(request);
116
118
  ```
@@ -121,6 +123,7 @@ Relay options:
121
123
  | --------------- | ------------------------ | ------------------------------------------------------------------------- |
122
124
  | `domain` | | Required. One domain or several. Events for other domains get a 400. |
123
125
  | `upstream` | `"https://plausible.io"` | Events are posted to `<upstream>/api/event`. |
126
+ | `ipHeaders` | | Headers to read the visitor IP from, in order, when none is passed in. |
124
127
  | `maxBodyLength` | `16384` | Largest request body accepted, in bytes. |
125
128
  | `events` | | Validation for custom events, see below. |
126
129
  | `onError` | | Called when Plausible cannot be reached. The response is a 502 either way. |
package/dist/bin.js CHANGED
@@ -3,7 +3,7 @@ import { DEFAULT_MAX_BODY_LENGTH, DEFAULT_UPSTREAM } from "./relay.js";
3
3
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { parseArgs } from "node:util";
6
- //#region src/cli.ts
6
+ // src/cli.ts
7
7
  /**
8
8
  * astro-plausible - writes the relay that forwards tracker events to Plausible.
9
9
  *
@@ -58,7 +58,7 @@ function swaFiles(template, config, dir, context) {
58
58
  "// Do not edit: re-run the command to change the config or pick up a new package version.",
59
59
  ""
60
60
  ].join("\n");
61
- const code = template.replace(/^\/\/# sourceMappingURL=.*$/m, "").replaceAll(CONFIG_PLACEHOLDER, JSON.stringify(config, null, 2)).trimEnd();
61
+ const code = template.replace(/^\/\/# sourceMappingURL=.*$/m, "").replaceAll(CONFIG_PLACEHOLDER, () => JSON.stringify(config, null, 2)).trimEnd();
62
62
  return [
63
63
  {
64
64
  path: join(dir, "event", "index.mjs"),
@@ -188,13 +188,11 @@ function fail(message) {
188
188
  process.stderr.write(`astro-plausible: ${message}\n`);
189
189
  return 1;
190
190
  }
191
- //#endregion
192
- //#region src/bin.ts
191
+ // src/bin.ts
193
192
  main(process.argv.slice(2), process.env).then((code) => {
194
193
  process.exitCode = code;
195
194
  }, (error) => {
196
195
  process.exitCode = fail(error instanceof Error ? error.message : String(error));
197
196
  });
198
- //#endregion
199
197
 
200
198
  //# sourceMappingURL=bin.js.map
package/dist/bin.js.map CHANGED
@@ -1 +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":"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"}
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 // A replacement function, so `$` sequences in the patterns are inserted as is.\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":"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"}
package/dist/client.d.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { n as PlausibleOptions } from "./options-WE-C48Ul.js";
1
+ import { n as PlausibleOptions } from "./options-C15OXXsk.js";
2
2
  import { CustomProperties, PlausibleConfig, PlausibleEventOptions, PlausibleRequestPayload, track } from "@plausible-analytics/tracker";
3
- //#region src/client.d.ts
4
3
  export interface PlausibleClientOptions extends Omit<PlausibleOptions, "customProperties"> {
5
4
  /** Custom properties for every event, or a function of the event name that returns them. */
6
5
  customProperties?: PlausibleConfig["customProperties"];
@@ -12,6 +11,5 @@ export interface PlausibleClientOptions extends Omit<PlausibleOptions, "customPr
12
11
  * page. The tracker itself allows a single `init()`.
13
12
  */
14
13
  export declare function startPlausible(options: PlausibleClientOptions): void;
15
- //#endregion
16
14
  export { type CustomProperties, type PlausibleEventOptions, type PlausibleRequestPayload, track };
17
15
  //# sourceMappingURL=client.d.ts.map
@@ -1 +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"}
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 CHANGED
@@ -1,6 +1,6 @@
1
- import { n as plausibleConfig } from "./options-CZaM-Wb7.js";
1
+ import { n as plausibleConfig } from "./options-B-SB8PRR.js";
2
2
  import { init, track } from "@plausible-analytics/tracker";
3
- //#region src/client.ts
3
+ // src/client.ts
4
4
  /**
5
5
  * Browser side. Starts the official Plausible tracker (the `@plausible-analytics/tracker` package, bundled into the
6
6
  * site) pointed at the relay on your own domain.
@@ -22,7 +22,6 @@ function startPlausible(options) {
22
22
  if (transformRequest) config.transformRequest = transformRequest;
23
23
  init(config);
24
24
  }
25
- //#endregion
26
25
  export { startPlausible, track };
27
26
 
28
27
  //# sourceMappingURL=client.js.map
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import { n as PlausibleOptions, r as plausibleConfig, t as DEFAULT_ENDPOINT } from "./options-WE-C48Ul.js";
1
+ import { n as PlausibleOptions, r as plausibleConfig, t as DEFAULT_ENDPOINT } from "./options-C15OXXsk.js";
2
2
  import { PlausibleIntegrationOptions, clientScript } from "./integration.js";
3
3
  export { DEFAULT_ENDPOINT, type PlausibleIntegrationOptions, type PlausibleOptions, clientScript, plausibleConfig };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { n as plausibleConfig, t as DEFAULT_ENDPOINT } from "./options-CZaM-Wb7.js";
1
+ import { n as plausibleConfig, t as DEFAULT_ENDPOINT } from "./options-B-SB8PRR.js";
2
2
  import { clientScript } from "./integration.js";
3
3
  export { DEFAULT_ENDPOINT, clientScript, plausibleConfig };
@@ -1,6 +1,5 @@
1
- import { n as PlausibleOptions } from "./options-WE-C48Ul.js";
1
+ import { n as PlausibleOptions } from "./options-C15OXXsk.js";
2
2
  import { AstroIntegration } from "astro";
3
- //#region src/integration.d.ts
4
3
  export interface PlausibleIntegrationOptions extends PlausibleOptions {
5
4
  /**
6
5
  * Whether to add the tracker to pages. Default is to add it for `astro build` and leave it out of `astro dev`,
@@ -27,5 +26,4 @@ export declare function clientScript(options: PlausibleOptions): string;
27
26
  * ```
28
27
  */
29
28
  export default function plausibleIntegration(options: PlausibleIntegrationOptions): AstroIntegration;
30
- //#endregion
31
29
  //# sourceMappingURL=integration.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"integration.d.ts","names":[],"sources":["../src/integration.ts"],"mappings":";;;iBAGiB,oCAAoC;;;;;;EAMnD;;;wBAIc,aAAa,SAAS;;;;;;;;;;;;;;;;wBAsBd,qBAAqB,SAAS,8BAA8B"}
1
+ {"version":3,"file":"integration.d.ts","names":[],"sources":["../src/integration.ts"],"mappings":";;iBAGiB,oCAAoC;;;;;;EAMnD;;;wBAIc,aAAa,SAAS;;;;;;;;;;;;;;;;wBAsBd,qBAAqB,SAAS,8BAA8B"}
@@ -1,5 +1,5 @@
1
- import { n as plausibleConfig } from "./options-CZaM-Wb7.js";
2
- //#region src/integration.ts
1
+ import { n as plausibleConfig } from "./options-B-SB8PRR.js";
2
+ // src/integration.ts
3
3
  /** The client script injected on every page. Exported for tests. */
4
4
  function clientScript(options) {
5
5
  return ["import { startPlausible } from \"@sjohansson/astro-plausible/client\";", `startPlausible(${JSON.stringify(plausibleConfig(options))});`].join("\n");
@@ -35,7 +35,6 @@ function plausibleIntegration(options) {
35
35
  } }
36
36
  };
37
37
  }
38
- //#endregion
39
38
  export { clientScript, plausibleIntegration as default };
40
39
 
41
40
  //# sourceMappingURL=integration.js.map
@@ -1,4 +1,4 @@
1
- //#region src/options.ts
1
+ // src/options.ts
2
2
  const DEFAULT_ENDPOINT = "/api/event";
3
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
4
  const BOOLEAN_KEYS = [
@@ -45,7 +45,6 @@ function plausibleConfig(options) {
45
45
  }
46
46
  return config;
47
47
  }
48
- //#endregion
49
48
  export { plausibleConfig as n, DEFAULT_ENDPOINT as t };
50
49
 
51
- //# sourceMappingURL=options-CZaM-Wb7.js.map
50
+ //# sourceMappingURL=options-B-SB8PRR.js.map
@@ -1 +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"}
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"}
@@ -1,5 +1,4 @@
1
1
  import { PlausibleConfig } from "@plausible-analytics/tracker";
2
- //#region src/options.d.ts
3
2
  /** Tracker settings that can be written down as JSON. These go straight to the tracker's `init()`. */
4
3
  interface PlausibleOptions {
5
4
  /** Your site's domain, as entered in Plausible. */
@@ -58,6 +57,5 @@ interface PlausibleOptions {
58
57
  declare const DEFAULT_ENDPOINT = "/api/event";
59
58
  /** Validates the options and returns the config for the tracker's `init()`. Only keys that were set are included. */
60
59
  declare function plausibleConfig(options: PlausibleOptions): PlausibleConfig;
61
- //#endregion
62
60
  export { PlausibleOptions as n, plausibleConfig as r, DEFAULT_ENDPOINT as t };
63
- //# sourceMappingURL=options-WE-C48Ul.d.ts.map
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"}
package/dist/relay.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- //#region src/relay.d.ts
2
1
  /**
3
2
  * The relay forwards tracker events from your domain to Plausible. It is written against the web standard Request
4
3
  * and Response, so it runs anywhere fetch does: an Astro endpoint, Cloudflare, Netlify, Vercel, or through the
@@ -23,6 +22,12 @@ export interface RelayOptions {
23
22
  * @default 16384
24
23
  */
25
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[];
26
31
  /**
27
32
  * Validation for custom events, by event name. An event with a rule is forwarded only if every listed property
28
33
  * is a string matching its pattern, and with only those properties. Events without a rule pass through as is.
@@ -55,8 +60,14 @@ export interface Relay {
55
60
  }
56
61
  export declare const DEFAULT_UPSTREAM = "https://plausible.io";
57
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[];
58
69
  /** Visitor IP from platform headers, without any port. Undefined when no header carries one. */
59
- export declare function clientIp(headers: RelayHeaders): string | undefined;
70
+ export declare function clientIp(headers: RelayHeaders, names?: readonly string[]): string | undefined;
60
71
  export declare function createRelay(options: RelayOptions): Relay;
61
72
  /** The subset of the Azure Functions v3 context the adapter uses. */
62
73
  export interface SwaContext {
@@ -78,5 +89,4 @@ export interface SwaRequest {
78
89
  * a function that uses this.
79
90
  */
80
91
  export declare function swaHandler(relay: Relay): (context: SwaContext, req: SwaRequest) => Promise<void>;
81
- //#endregion
82
92
  //# sourceMappingURL=relay.d.ts.map
@@ -1 +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"}
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 CHANGED
@@ -1,7 +1,12 @@
1
- //#region src/relay.ts
1
+ // src/relay.ts
2
2
  const DEFAULT_UPSTREAM = "https://plausible.io";
3
3
  const DEFAULT_MAX_BODY_LENGTH = 16384;
4
- const IP_HEADERS = [
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 = [
5
10
  "x-azure-clientip",
6
11
  "x-client-ip",
7
12
  "cf-connecting-ip",
@@ -14,8 +19,8 @@ function header(headers, name) {
14
19
  return Array.isArray(value) ? value[0] : value;
15
20
  }
16
21
  /** 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) {
22
+ function clientIp(headers, names = DEFAULT_IP_HEADERS) {
23
+ for (const name of names) {
19
24
  const raw = header(headers, name)?.split(",")[0]?.trim();
20
25
  if (!raw) continue;
21
26
  const bracketed = raw.match(/^\[(.+)\]:\d+$/);
@@ -27,12 +32,17 @@ function clientIp(headers) {
27
32
  function compileRules(events = {}) {
28
33
  const compiled = /* @__PURE__ */ new Map();
29
34
  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)]);
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)]);
31
36
  if (entries.length === 0) throw new Error(`[@sjohansson/astro-plausible] Rule for event "${name}" lists no properties.`);
32
37
  compiled.set(name, entries);
33
38
  }
34
39
  return compiled;
35
40
  }
41
+ const PROPS_ALIASES = [
42
+ "m",
43
+ "meta",
44
+ "props"
45
+ ];
36
46
  /** Applies the rule for an event. Returns the props to forward, or null when the event is not valid. */
37
47
  function applyRule(rules, props) {
38
48
  const source = props && typeof props === "object" ? props : {};
@@ -44,6 +54,25 @@ function applyRule(rules, props) {
44
54
  }
45
55
  return kept;
46
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
+ }
47
76
  function headersToRecord(headers) {
48
77
  const record = {};
49
78
  headers.forEach((value, name) => {
@@ -57,9 +86,11 @@ function createRelay(options) {
57
86
  const upstream = `${(options.upstream ?? "https://plausible.io").replace(/\/$/, "")}/api/event`;
58
87
  const maxBodyLength = options.maxBodyLength ?? 16384;
59
88
  const rules = compileRules(options.events);
89
+ const ipHeaders = options.ipHeaders?.map((name) => name.toLowerCase()) ?? DEFAULT_IP_HEADERS;
60
90
  const relay = {
61
91
  forward(rawBody, headers, ip) {
62
- if (typeof rawBody !== "string" || rawBody.length === 0 || rawBody.length > maxBodyLength) return null;
92
+ if (typeof rawBody !== "string" || rawBody.length === 0) return null;
93
+ if (rawBody.length > maxBodyLength || encoder.encode(rawBody).length > maxBodyLength) return null;
63
94
  let event;
64
95
  try {
65
96
  event = JSON.parse(rawBody);
@@ -73,12 +104,13 @@ function createRelay(options) {
73
104
  if (rules.has(name)) {
74
105
  const kept = applyRule(rules.get(name) ?? [], event["p"]);
75
106
  if (!kept) return null;
107
+ for (const alias of PROPS_ALIASES) delete event[alias];
76
108
  event["p"] = kept;
77
109
  }
78
110
  const forwardHeaders = { "content-type": "application/json" };
79
111
  const userAgent = header(headers, "user-agent");
80
112
  if (userAgent) forwardHeaders["user-agent"] = userAgent;
81
- const visitorIp = ip ?? clientIp(headers);
113
+ const visitorIp = ip ?? clientIp(headers, ipHeaders);
82
114
  if (visitorIp) forwardHeaders["x-forwarded-for"] = visitorIp;
83
115
  return {
84
116
  url: upstream,
@@ -104,7 +136,9 @@ function createRelay(options) {
104
136
  headers: { allow: "POST" }
105
137
  });
106
138
  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);
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);
108
142
  if (!forward) return new Response(null, { status: 400 });
109
143
  try {
110
144
  const result = await relay.send(forward);
@@ -150,7 +184,6 @@ function swaHandler(relay) {
150
184
  }
151
185
  };
152
186
  }
153
- //#endregion
154
- export { DEFAULT_MAX_BODY_LENGTH, DEFAULT_UPSTREAM, clientIp, createRelay, swaHandler };
187
+ export { DEFAULT_IP_HEADERS, DEFAULT_MAX_BODY_LENGTH, DEFAULT_UPSTREAM, clientIp, createRelay, swaHandler };
155
188
 
156
189
  //# sourceMappingURL=relay.js.map
package/dist/relay.js.map CHANGED
@@ -1 +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":"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"}
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":"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"}
@@ -1,4 +1,9 @@
1
- const IP_HEADERS = [
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 = [
2
7
  "x-azure-clientip",
3
8
  "x-client-ip",
4
9
  "cf-connecting-ip",
@@ -11,8 +16,8 @@ function header(headers, name) {
11
16
  return Array.isArray(value) ? value[0] : value;
12
17
  }
13
18
  /** 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) {
19
+ function clientIp(headers, names = DEFAULT_IP_HEADERS) {
20
+ for (const name of names) {
16
21
  const raw = header(headers, name)?.split(",")[0]?.trim();
17
22
  if (!raw) continue;
18
23
  const bracketed = raw.match(/^\[(.+)\]:\d+$/);
@@ -24,12 +29,17 @@ function clientIp(headers) {
24
29
  function compileRules(events = {}) {
25
30
  const compiled = /* @__PURE__ */ new Map();
26
31
  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)]);
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)]);
28
33
  if (entries.length === 0) throw new Error(`[@sjohansson/astro-plausible] Rule for event "${name}" lists no properties.`);
29
34
  compiled.set(name, entries);
30
35
  }
31
36
  return compiled;
32
37
  }
38
+ const PROPS_ALIASES = [
39
+ "m",
40
+ "meta",
41
+ "props"
42
+ ];
33
43
  /** Applies the rule for an event. Returns the props to forward, or null when the event is not valid. */
34
44
  function applyRule(rules, props) {
35
45
  const source = props && typeof props === "object" ? props : {};
@@ -41,6 +51,25 @@ function applyRule(rules, props) {
41
51
  }
42
52
  return kept;
43
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
+ }
44
73
  function headersToRecord(headers) {
45
74
  const record = {};
46
75
  headers.forEach((value, name) => {
@@ -54,9 +83,11 @@ function createRelay(options) {
54
83
  const upstream = `${(options.upstream ?? "https://plausible.io").replace(/\/$/, "")}/api/event`;
55
84
  const maxBodyLength = options.maxBodyLength ?? 16384;
56
85
  const rules = compileRules(options.events);
86
+ const ipHeaders = options.ipHeaders?.map((name) => name.toLowerCase()) ?? DEFAULT_IP_HEADERS;
57
87
  const relay = {
58
88
  forward(rawBody, headers, ip) {
59
- if (typeof rawBody !== "string" || rawBody.length === 0 || rawBody.length > maxBodyLength) return null;
89
+ if (typeof rawBody !== "string" || rawBody.length === 0) return null;
90
+ if (rawBody.length > maxBodyLength || encoder.encode(rawBody).length > maxBodyLength) return null;
60
91
  let event;
61
92
  try {
62
93
  event = JSON.parse(rawBody);
@@ -70,12 +101,13 @@ function createRelay(options) {
70
101
  if (rules.has(name)) {
71
102
  const kept = applyRule(rules.get(name) ?? [], event["p"]);
72
103
  if (!kept) return null;
104
+ for (const alias of PROPS_ALIASES) delete event[alias];
73
105
  event["p"] = kept;
74
106
  }
75
107
  const forwardHeaders = { "content-type": "application/json" };
76
108
  const userAgent = header(headers, "user-agent");
77
109
  if (userAgent) forwardHeaders["user-agent"] = userAgent;
78
- const visitorIp = ip ?? clientIp(headers);
110
+ const visitorIp = ip ?? clientIp(headers, ipHeaders);
79
111
  if (visitorIp) forwardHeaders["x-forwarded-for"] = visitorIp;
80
112
  return {
81
113
  url: upstream,
@@ -101,7 +133,9 @@ function createRelay(options) {
101
133
  headers: { allow: "POST" }
102
134
  });
103
135
  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);
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);
105
139
  if (!forward) return new Response(null, { status: 400 });
106
140
  try {
107
141
  const result = await relay.send(forward);
@@ -147,9 +181,7 @@ function swaHandler(relay) {
147
181
  }
148
182
  };
149
183
  }
150
- //#endregion
151
- //#region src/templates/swa.ts
184
+ // src/templates/swa.ts
152
185
  const relay = createRelay(__ASTRO_PLAUSIBLE_RELAY_CONFIG__);
153
186
  var swa_default = swaHandler(relay);
154
- //#endregion
155
187
  export { swa_default as default, relay };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjohansson/astro-plausible",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
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
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1 +0,0 @@
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"}