@openpolicy/vite-auto-collect
Version:
Vite plugin that scans source files for @openpolicy/sdk collecting() calls and populates autoCollected() at build time
542 lines (541 loc) • 17.5 kB
JavaScript
import { readFile, readdir } from "node:fs/promises";
import { extname, join, relative, resolve } from "node:path";
import { parseSync } from "oxc-parser";
//#region src/analyse.ts
const SDK_SPECIFIER = "@openpolicy/sdk";
const COLLECTING_NAME = "collecting";
const THIRD_PARTY_NAME = "thirdParty";
const IGNORE_NAME = "Ignore";
/**
* Extract `collecting()` and `thirdParty()` call metadata from a single source file.
*
* Returns an `ExtractResult` with `dataCollected` (category → labels) and
* `thirdParties` (array of third-party entries). Files with no matching calls
* — or that fail to parse — return empty defaults.
*
* The analyser runs in two phases:
* 1. Collect local names bound to `collecting` / `thirdParty` imported from
* `@openpolicy/sdk` (handles renamed imports, skips type-only imports,
* ignores look-alikes imported from other modules).
* 2. Walk the program body and inspect every `CallExpression` whose callee
* is one of those tracked local names.
*/
function extractFromFile(filename, code) {
const empty = {
dataCollected: {},
thirdParties: []
};
let result;
try {
result = parseSync(filename, code);
} catch {
console.warn(`[openpolicy-auto-collect] parse error in ${filename}`);
return empty;
}
if (result.errors.length > 0) {
if (result.errors.some((e) => e.severity === "Error")) {
console.warn(`[openpolicy-auto-collect] parse error in ${filename}`);
return empty;
}
}
const program = result.program;
const collectingNames = collectSdkBindings(program, COLLECTING_NAME);
const thirdPartyNames = collectSdkBindings(program, THIRD_PARTY_NAME);
const ignoreNames = collectSdkBindings(program, IGNORE_NAME);
if (collectingNames.size === 0 && thirdPartyNames.size === 0) return empty;
const dataCollected = {};
const thirdParties = [];
const seenThirdParties = /* @__PURE__ */ new Set();
walk(program, (node) => {
if (node.type !== "CallExpression") return;
const callee = node.callee;
if (!callee || callee.type !== "Identifier") return;
const calleeName = callee.name;
const args = node.arguments;
if (collectingNames.has(calleeName)) {
if (!args || args.length < 3) return;
const category = extractStringLiteral(args[0]);
if (category === null) return;
const labels = extractLabelKeys(args[2], ignoreNames);
if (labels === null) return;
const existing = dataCollected[category] ?? [];
const seen = new Set(existing);
for (const label of labels) if (!seen.has(label)) {
existing.push(label);
seen.add(label);
}
dataCollected[category] = existing;
} else if (thirdPartyNames.has(calleeName)) {
if (!args || args.length < 3) return;
const name = extractStringLiteral(args[0]);
if (name === null) return;
const purpose = extractStringLiteral(args[1]);
if (purpose === null) return;
const policyUrl = extractStringLiteral(args[2]);
if (policyUrl === null) return;
if (seenThirdParties.has(name)) return;
seenThirdParties.add(name);
thirdParties.push({
name,
purpose,
policyUrl
});
}
});
return {
dataCollected,
thirdParties
};
}
/**
* Walk `ImportDeclaration` nodes and return the local names bound to the given
* `exportName` imported from `@openpolicy/sdk`. Skips type-only imports and
* specifiers whose imported name doesn't match.
*/
function collectSdkBindings(program, exportName) {
const names = /* @__PURE__ */ new Set();
const body = program.body;
if (!body) return names;
for (const node of body) {
if (node.type !== "ImportDeclaration") continue;
if (node.importKind === "type") continue;
const source = node.source;
if (!source || source.value !== SDK_SPECIFIER) continue;
const specifiers = node.specifiers;
if (!specifiers) continue;
for (const spec of specifiers) {
if (spec.type !== "ImportSpecifier") continue;
if (spec.importKind === "type") continue;
const imported = spec.imported;
if (!imported) continue;
if ((imported.type === "Identifier" ? imported.name : imported.type === "Literal" ? typeof imported.value === "string" ? imported.value : void 0 : void 0) !== exportName) continue;
const local = spec.local;
if (!local || local.type !== "Identifier") continue;
names.add(local.name);
}
}
return names;
}
/**
* If `node` is a string `Literal`, return its string value. Otherwise
* return `null` so the caller silently skips the call.
*/
function extractStringLiteral(node) {
if (!node) return null;
if (node.type !== "Literal") return null;
if (typeof node.value !== "string") return null;
return node.value;
}
/**
* Extract the string values from a plain `{ fieldName: "Human Label" }`
* object literal. Returns an array of label strings, deduped while
* preserving insertion order. Returns `null` if the shape doesn't match.
*
* Properties whose value is an `Identifier` matching a tracked local name
* bound to the SDK's `Ignore` export are treated as explicit opt-outs and
* skipped silently — producing the same observable result as omitting the
* field from the record did before.
*/
function extractLabelKeys(node, ignoreNames) {
if (!node || node.type !== "ObjectExpression") return null;
const properties = node.properties;
if (!properties) return null;
const labels = [];
const seen = /* @__PURE__ */ new Set();
for (const prop of properties) {
if (prop.type !== "Property") continue;
const val = prop.value;
if (!val) continue;
if (val.type === "Literal" && typeof val.value === "string") {
if (seen.has(val.value)) continue;
seen.add(val.value);
labels.push(val.value);
continue;
}
if (val.type === "Identifier" && typeof val.name === "string" && ignoreNames.has(val.name)) {}
}
return labels;
}
/**
* Recursive AST walker. Visits every `AnyNode` (depth-first) reachable
* through array / nested-object children and invokes `visit` on each.
*/
function walk(node, visit) {
visit(node);
for (const key of Object.keys(node)) {
if (key === "parent") continue;
const value = node[key];
if (Array.isArray(value)) {
for (const item of value) if (item && typeof item === "object" && typeof item.type === "string") walk(item, visit);
} else if (value && typeof value === "object" && typeof value.type === "string") walk(value, visit);
}
}
//#endregion
//#region src/known-packages.ts
/**
* Registry of known npm packages mapped to their ThirdPartyEntry metadata.
* Multiple package names can point to the same service — deduplication by
* `ThirdPartyEntry.name` is handled at merge time in the caller.
*/
const KNOWN_PACKAGES = new Map([
["stripe", {
name: "Stripe",
purpose: "Payment processing",
policyUrl: "https://stripe.com/privacy"
}],
["@stripe/stripe-js", {
name: "Stripe",
purpose: "Payment processing",
policyUrl: "https://stripe.com/privacy"
}],
["braintree", {
name: "Braintree",
purpose: "Payment processing",
policyUrl: "https://www.braintreepayments.com/legal/braintree-privacy-policy"
}],
["@braintree/browser-drop-in", {
name: "Braintree",
purpose: "Payment processing",
policyUrl: "https://www.braintreepayments.com/legal/braintree-privacy-policy"
}],
["@sentry/browser", {
name: "Sentry",
purpose: "Error tracking",
policyUrl: "https://sentry.io/privacy/"
}],
["@sentry/node", {
name: "Sentry",
purpose: "Error tracking",
policyUrl: "https://sentry.io/privacy/"
}],
["@sentry/nextjs", {
name: "Sentry",
purpose: "Error tracking",
policyUrl: "https://sentry.io/privacy/"
}],
["@sentry/react", {
name: "Sentry",
purpose: "Error tracking",
policyUrl: "https://sentry.io/privacy/"
}],
["@sentry/vue", {
name: "Sentry",
purpose: "Error tracking",
policyUrl: "https://sentry.io/privacy/"
}],
["@datadog/browser-rum", {
name: "Datadog",
purpose: "Monitoring",
policyUrl: "https://www.datadoghq.com/legal/privacy/"
}],
["dd-trace", {
name: "Datadog",
purpose: "Monitoring",
policyUrl: "https://www.datadoghq.com/legal/privacy/"
}],
["posthog-js", {
name: "PostHog",
purpose: "Product analytics",
policyUrl: "https://posthog.com/privacy"
}],
["posthog-node", {
name: "PostHog",
purpose: "Product analytics",
policyUrl: "https://posthog.com/privacy"
}],
["mixpanel-browser", {
name: "Mixpanel",
purpose: "Product analytics",
policyUrl: "https://mixpanel.com/legal/privacy-policy/"
}],
["@segment/analytics-next", {
name: "Segment",
purpose: "Customer data platform",
policyUrl: "https://www.twilio.com/en-us/legal/privacy"
}],
["@amplitude/analytics-browser", {
name: "Amplitude",
purpose: "Product analytics",
policyUrl: "https://amplitude.com/privacy"
}],
["amplitude-js", {
name: "Amplitude",
purpose: "Product analytics",
policyUrl: "https://amplitude.com/privacy"
}],
["@vercel/analytics", {
name: "Vercel Analytics",
purpose: "Web analytics",
policyUrl: "https://vercel.com/legal/privacy-policy"
}],
["plausible-tracker", {
name: "Plausible",
purpose: "Web analytics",
policyUrl: "https://plausible.io/privacy"
}],
["logrocket", {
name: "LogRocket",
purpose: "Session recording",
policyUrl: "https://logrocket.com/privacy/"
}],
["@hotjar/browser", {
name: "Hotjar",
purpose: "Session recording",
policyUrl: "https://www.hotjar.com/legal/policies/privacy/"
}],
["resend", {
name: "Resend",
purpose: "Transactional email",
policyUrl: "https://resend.com/legal/privacy-policy"
}],
["@sendgrid/mail", {
name: "SendGrid",
purpose: "Transactional email",
policyUrl: "https://www.twilio.com/en-us/legal/privacy"
}],
["intercom-client", {
name: "Intercom",
purpose: "Customer messaging",
policyUrl: "https://www.intercom.com/legal/privacy"
}],
["@intercom/messenger-js-sdk", {
name: "Intercom",
purpose: "Customer messaging",
policyUrl: "https://www.intercom.com/legal/privacy"
}]
]);
//#endregion
//#region src/scan.ts
const DEFAULT_IGNORES = new Set([
"node_modules",
"dist",
".git",
".next",
".output",
".svelte-kit",
".cache"
]);
/**
* Recursively walks `root`, returning absolute paths of every regular file
* whose extension is in `extensions`. Directories whose basename appears in
* the built-in ignore list (or the extra `ignore` argument) are skipped
* entirely.
*
* Missing roots resolve to an empty array — the plugin must not throw if the
* user's `srcDir` hasn't been created yet.
*/
async function walkSources(root, extensions, ignore = []) {
const ignored = new Set([...DEFAULT_IGNORES, ...ignore]);
const exts = new Set(extensions);
const results = [];
async function walk(dir) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
const code = err.code;
if (code === "ENOENT" || code === "ENOTDIR") return;
throw err;
}
for (const entry of entries) {
if (ignored.has(entry.name)) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) await walk(full);
else if (entry.isFile() && exts.has(extname(entry.name))) results.push(full);
}
}
await walk(root);
results.sort();
return results;
}
//#endregion
//#region src/index.ts
/**
* Marker returned from `resolveId` so `load` can recognise a hit. The leading
* NUL prefix is the Rollup/Vite convention for virtual IDs so other plugins
* leave it alone.
*/
const RESOLVED_VIRTUAL_ID = "\0virtual:openpolicy/auto-collected";
/**
* Matches any path that lives inside the `@openpolicy/sdk` package, whether
* it's resolved via a workspace symlink (`.../packages/sdk/...`) or a
* published `node_modules` install (`.../@openpolicy/sdk/...`). Used to scope
* the `./auto-collected` relative-import interception to the SDK itself.
*/
const SDK_PATH_PATTERN = /[\\/](?:@openpolicy[\\/]sdk|packages[\\/]sdk)[\\/]/;
/**
* Matches the relative specifier the SDK uses for its own internal
* `./auto-collected` import. Both the source form (`./auto-collected`) and
* the published dist form (`./auto-collected.js`) need to be intercepted:
* the former applies when consumers resolve the SDK via its workspace
* source, the latter when resolving against `dist/` with the separate
* `auto-collected.js` chunk.
*/
const AUTO_COLLECTED_SPECIFIER = /^\.\/auto-collected(?:\.js)?$/;
/**
* Vite plugin that scans source files for `@openpolicy/sdk` `collecting()`
* calls at the start of each build and inlines the discovered categories into
* the SDK's `dataCollected` sentinel.
*
* Internally the plugin intercepts `@openpolicy/sdk`'s own relative import of
* `./auto-collected` and redirects it to a virtual module whose body is a
* literal `export const dataCollected = { ... }`. Because the replacement
* becomes part of the consumer's own module graph, the scanned data survives
* any downstream bundler boundary (e.g. nitro's SSR output), which a shared
* module-level registry would not.
*/
function autoCollect(options = {}) {
const srcDirOpt = options.srcDir ?? "src";
const extensions = options.extensions ?? [".ts", ".tsx"];
const ignore = options.ignore ?? [];
const usePackageJsonOpt = options.thirdParties?.usePackageJson ?? false;
let resolvedRoot;
let resolvedSrcDir;
let scanned = {
dataCollected: {},
thirdParties: []
};
async function detectFromPackageJson(root) {
let raw;
try {
raw = await readFile(resolve(root, "package.json"), "utf8");
} catch {
return [];
}
let pkg;
try {
pkg = JSON.parse(raw);
} catch {
return [];
}
const allDeps = {
...pkg.dependencies,
...pkg.devDependencies
};
const entries = [];
const seenNames = /* @__PURE__ */ new Set();
for (const pkgName of Object.keys(allDeps)) {
const entry = KNOWN_PACKAGES.get(pkgName);
if (entry && !seenNames.has(entry.name)) {
seenNames.add(entry.name);
entries.push(entry);
}
}
return entries;
}
async function scanAndMerge() {
const files = await walkSources(resolvedSrcDir, extensions, ignore);
const mergedData = {};
const mergedParties = [];
const seenParties = /* @__PURE__ */ new Set();
for (const file of files) {
let code;
try {
code = await readFile(file, "utf8");
} catch {
continue;
}
const extracted = extractFromFile(file, code);
for (const [category, labels] of Object.entries(extracted.dataCollected)) {
const existing = mergedData[category] ?? [];
const seen = new Set(existing);
for (const label of labels) if (!seen.has(label)) {
existing.push(label);
seen.add(label);
}
mergedData[category] = existing;
}
for (const entry of extracted.thirdParties) if (!seenParties.has(entry.name)) {
seenParties.add(entry.name);
mergedParties.push(entry);
}
}
if (usePackageJsonOpt) {
const pkgEntries = await detectFromPackageJson(resolvedRoot);
for (const entry of pkgEntries) if (!seenParties.has(entry.name)) {
seenParties.add(entry.name);
mergedParties.push(entry);
}
}
return {
dataCollected: mergedData,
thirdParties: mergedParties
};
}
/**
* Returns true when `file` lives inside `resolvedSrcDir` and has one of
* the tracked extensions. Used by the dev-server watcher to skip events
* for unrelated files (configs, public assets, other packages, etc.).
*/
function isTrackedSource(file) {
const rel = relative(resolvedSrcDir, file);
if (!rel || rel.startsWith("..")) return false;
return extensions.some((ext) => file.endsWith(ext));
}
/**
* Re-runs the scan and, if anything changed, invalidates the virtual
* module and triggers a full page reload. A full reload is used because
* `dataCollected` is spread into the policy config at module-evaluation
* time and the result is captured by the React tree as a prop — there's
* no clean way to hot-swap it in place.
*/
async function rescanAndRefresh(server) {
const next = await scanAndMerge();
if (JSON.stringify(next) === JSON.stringify(scanned)) return;
scanned = next;
const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID);
if (mod) server.moduleGraph.invalidateModule(mod);
server.ws.send({ type: "full-reload" });
}
return {
name: "openpolicy-auto-collect",
enforce: "pre",
config() {
return {
optimizeDeps: { exclude: ["@openpolicy/sdk"] },
ssr: {
optimizeDeps: { exclude: ["@openpolicy/sdk"] },
noExternal: ["@openpolicy/sdk"]
}
};
},
configResolved(config) {
resolvedRoot = config.root;
resolvedSrcDir = resolve(config.root, srcDirOpt);
},
async buildStart() {
scanned = await scanAndMerge();
},
configureServer(server) {
server.watcher.add(resolvedSrcDir);
const handler = async (file) => {
if (!isTrackedSource(file)) return;
try {
await rescanAndRefresh(server);
} catch (error) {
server.config.logger.error(`[openpolicy-auto-collect] rescan failed: ${error}`);
}
};
server.watcher.on("change", handler);
server.watcher.on("add", handler);
server.watcher.on("unlink", handler);
},
async resolveId(source, importer, resolveOptions) {
if (!importer || !AUTO_COLLECTED_SPECIFIER.test(source)) return null;
const resolved = await this.resolve(source, importer, {
...resolveOptions,
skipSelf: true
});
if (!resolved) return null;
if (!SDK_PATH_PATTERN.test(resolved.id)) return null;
return RESOLVED_VIRTUAL_ID;
},
load(id) {
if (id !== RESOLVED_VIRTUAL_ID) return null;
return `export const dataCollected = ${JSON.stringify(scanned.dataCollected)};\nexport const thirdParties = ${JSON.stringify(scanned.thirdParties)};\n`;
}
};
}
//#endregion
export { autoCollect };
//# sourceMappingURL=index.js.map