UNPKG

@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
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