diff --git a/.changeset/angular-esbuild-sourcemaps.md b/.changeset/angular-esbuild-sourcemaps.md new file mode 100644 index 0000000000..0a096d8efd --- /dev/null +++ b/.changeset/angular-esbuild-sourcemaps.md @@ -0,0 +1,5 @@ +--- +'@posthog/esbuild-plugin': minor +--- + +Add an esbuild plugin that injects error-tracking chunk IDs into in-memory JavaScript and composes the source maps before Angular or another build system computes asset hashes. This lets Angular generate a valid `ngsw.json`; source maps are uploaded afterward with the non-mutating `posthog-cli sourcemap upload` command. diff --git a/packages/esbuild-plugin/LICENSE b/packages/esbuild-plugin/LICENSE new file mode 100644 index 0000000000..0871b390ea --- /dev/null +++ b/packages/esbuild-plugin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2026 PostHog Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/esbuild-plugin/README.md b/packages/esbuild-plugin/README.md new file mode 100644 index 0000000000..39faae5239 --- /dev/null +++ b/packages/esbuild-plugin/README.md @@ -0,0 +1,94 @@ +# PostHog esbuild plugin + +Register esbuild's content-hashed output filenames as PostHog error-tracking chunk IDs **before** the surrounding build system computes asset hashes. + +This is intended for build systems such as Angular's `application` builder, where running `posthog-cli sourcemap inject` after `ng build` would invalidate `ngsw.json`, Subresource Integrity values, or another content-hash manifest. + +## Angular + +Angular does not expose custom esbuild plugins in its standard `application` builder configuration. Use the community [`@angular-builders/custom-esbuild`](https://github.com/just-jeb/angular-builders/tree/master/packages/custom-esbuild) builder and match its major version to your Angular major version. + +```bash +npm install --save-dev @posthog/esbuild-plugin @posthog/cli @angular-builders/custom-esbuild +``` + +Create a small workspace-local plugin file because `custom-esbuild` resolves plugin entries as file paths: + +```ts +// tools/posthog-esbuild-plugin.ts +import posthogEsbuildPlugin from '@posthog/esbuild-plugin' + +export default posthogEsbuildPlugin() +``` + +Update the build target in `angular.json`: + +```jsonc +{ + "builder": "@angular-builders/custom-esbuild:application", + "options": { + "plugins": ["./tools/posthog-esbuild-plugin.ts"], + "sourceMap": { + "scripts": true, + "styles": false, + "hidden": true, + "vendor": true, + }, + }, +} +``` + +The plugin adds a deterministic runtime banner before esbuild computes output filenames. Once esbuild names each chunk, the plugin stamps that filename into its source-map metadata without rewriting the JavaScript. Angular then creates `index.html` and `ngsw.json` from the final JavaScript, so content-hashed filenames and service-worker hashes both remain valid. + +Keep Angular's production `outputHashing` enabled. The output filename is the symbol-set identity, so an unhashed name such as `main.js` would collide with a later build. + +Upload the already-injected output after the build: + +```bash +ng build --configuration production +posthog-cli sourcemap upload --directory dist//browser +``` + +Set `POSTHOG_CLI_API_KEY`, `POSTHOG_CLI_PROJECT_ID`, and `POSTHOG_CLI_HOST` in the build environment as described in the [PostHog CLI documentation](https://posthog.com/docs/error-tracking/upload-source-maps/cli). + +Do **not** run `sourcemap process` or `sourcemap inject` after this build. Those commands rewrite the emitted JavaScript and invalidate hashes that Angular has already computed. + +If source maps must not be deployed, remove only the `.map` files after a successful upload. Do not use `sourcemap upload --delete-after`, because that option also strips comments from JavaScript and changes its hash. + +## Plain esbuild + +The plugin operates on esbuild's `outputFiles`, so plain esbuild builds must use `write: false`. Write the returned files after the plugin has modified them, then run `posthog-cli sourcemap upload`. + +```ts +import { build } from 'esbuild' +import posthogEsbuildPlugin from '@posthog/esbuild-plugin' +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' + +const result = await build({ + entryPoints: ['src/index.ts'], + bundle: true, + format: 'esm', + outdir: 'dist', + entryNames: '[name]-[hash]', + chunkNames: '[name]-[hash]', + sourcemap: 'external', + write: false, + plugins: [posthogEsbuildPlugin()], +}) + +for (const file of result.outputFiles) { + await mkdir(path.dirname(file.path), { recursive: true }) + await writeFile(file.path, file.contents) +} +``` + +## Options + +```ts +posthogEsbuildPlugin({ + enabled: process.env.NODE_ENV === 'production', +}) +``` + +The first release supports PostHog's default `symbol-set` release mode. Pass release metadata to the later `sourcemap upload` command if required. Event release mode is not supported because it requires embedding a resolved release ID into each JavaScript chunk. diff --git a/packages/esbuild-plugin/babel.config.mjs b/packages/esbuild-plugin/babel.config.mjs new file mode 100644 index 0000000000..e1da96c15b --- /dev/null +++ b/packages/esbuild-plugin/babel.config.mjs @@ -0,0 +1,3 @@ +export default { + presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'], +} diff --git a/packages/esbuild-plugin/jest.config.mjs b/packages/esbuild-plugin/jest.config.mjs new file mode 100644 index 0000000000..8d851d879f --- /dev/null +++ b/packages/esbuild-plugin/jest.config.mjs @@ -0,0 +1,11 @@ +export default { + collectCoverage: true, + clearMocks: true, + coverageDirectory: 'coverage', + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + silent: true, + verbose: false, + watchman: false, +} diff --git a/packages/esbuild-plugin/package.json b/packages/esbuild-plugin/package.json new file mode 100644 index 0000000000..199a366e1b --- /dev/null +++ b/packages/esbuild-plugin/package.json @@ -0,0 +1,48 @@ +{ + "name": "@posthog/esbuild-plugin", + "version": "0.0.1", + "bugs": { + "url": "https://github.com/PostHog/posthog-js/issues" + }, + "description": "Inject PostHog error-tracking chunk IDs during esbuild builds", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/PostHog/posthog-js.git", + "directory": "packages/esbuild-plugin" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "rslib build", + "clean": "rimraf dist", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "test:unit": "jest", + "dev": "rslib build --watch", + "package": "pnpm pack --out $PACKAGE_DEST/%s.tgz" + }, + "files": [ + "src", + "dist", + "!src/**/*.spec.ts" + ], + "peerDependencies": { + "esbuild": ">=0.19.0" + }, + "devDependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "@posthog-tooling/tsconfig-base": "workspace:*", + "@rslib/core": "catalog:", + "@types/jest": "catalog:", + "esbuild": "^0.25.0", + "jest": "catalog:" + } +} diff --git a/packages/esbuild-plugin/rslib.config.mjs b/packages/esbuild-plugin/rslib.config.mjs new file mode 100644 index 0000000000..01f87c7c09 --- /dev/null +++ b/packages/esbuild-plugin/rslib.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig } from '@rslib/core' + +export default defineConfig({ + dts: true, + bundle: false, + syntax: 'es2020', + lib: [{ format: 'esm' }], + source: { + entry: { + index: 'src/index.ts', + }, + tsconfigPath: './tsconfig.build.json', + }, +}) diff --git a/packages/esbuild-plugin/src/index.spec.ts b/packages/esbuild-plugin/src/index.spec.ts new file mode 100644 index 0000000000..45930f283b --- /dev/null +++ b/packages/esbuild-plugin/src/index.spec.ts @@ -0,0 +1,156 @@ +import { build } from 'esbuild' +import type { OutputFile, Plugin } from 'esbuild' +import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import posthogEsbuildPlugin from './index' + +const decoder = new TextDecoder() +const input = '"use strict";\nexport function fail() {\n throw new Error("boom")\n}\n' + +async function buildFixture(options: { plugins?: Plugin[]; sourcemap?: boolean | 'external' } = {}) { + const outdir = path.join(os.tmpdir(), `posthog-esbuild-plugin-${Math.random().toString(16).slice(2)}`) + const result = await build({ + stdin: { + contents: input, + sourcefile: 'src/app.ts', + loader: 'ts', + }, + bundle: true, + format: 'esm', + outfile: path.join(outdir, 'app.js'), + sourcemap: options.sourcemap ?? 'external', + write: false, + plugins: options.plugins ?? [posthogEsbuildPlugin()], + }) + + const javascript = result.outputFiles.find((file) => file.path.endsWith('app.js')) + const sourceMap = result.outputFiles.find((file) => file.path.endsWith('app.js.map')) + return { result, javascript, sourceMap, outdir } +} + +async function buildContentHashed(plugins: Plugin[]) { + const outdir = path.join(os.tmpdir(), `posthog-esbuild-hash-${Math.random().toString(16).slice(2)}`) + return build({ + stdin: { + contents: input, + sourcefile: 'app.ts', + loader: 'ts', + }, + bundle: true, + format: 'esm', + outdir, + entryNames: '[name]-[hash]', + sourcemap: 'external', + write: false, + plugins, + }) +} + +function text(file: OutputFile | undefined): string { + if (!file) { + throw new Error('expected output file') + } + return decoder.decode(file.contents) +} + +function generatedPosition(source: string, needle: string): { line: number; column: number } { + const offset = source.indexOf(needle) + if (offset === -1) { + throw new Error(`missing ${needle}`) + } + const before = source.slice(0, offset) + const lines = before.split('\n') + return { line: lines.length, column: lines.at(-1)!.length } +} + +describe('posthogEsbuildPlugin', () => { + it('registers the output filename and stamps the same id into the sourcemap', async () => { + const { javascript, sourceMap, outdir } = await buildFixture() + const source = text(javascript) + const map = JSON.parse(text(sourceMap)) + + expect(source).toContain('_posthogChunkIds') + expect(source).toContain('/* posthog-chunk-id: output-filename */') + expect(source).not.toContain('//# chunkId=') + expect(map.chunk_id).toBe('app.js') + + await mkdir(outdir, { recursive: true }) + await writeFile(path.join(outdir, 'package.json'), '{"type":"module"}') + await writeFile( + path.join(outdir, 'app.js'), + `${source}\nconsole.log(JSON.stringify(globalThis._posthogChunkIds))` + ) + const runtimeChunkIds = JSON.parse( + execFileSync(process.execPath, [path.join(outdir, 'app.js')], { encoding: 'utf8' }) + ) + + expect(Object.values(runtimeChunkIds)).toContain('app.js') + }) + + it('preserves original TypeScript positions because esbuild maps the banner itself', async () => { + const { javascript, sourceMap } = await buildFixture() + const source = text(javascript) + const map = new TraceMap(text(sourceMap)) + const generated = generatedPosition(source, 'throw new Error') + const original = originalPositionFor(map, generated) + + expect(original.source).toContain('src/app.ts') + expect(original.line).toBe(3) + expect(original.column).toBe(2) + }) + + it('is included before esbuild computes output filenames and downstream hashes', async () => { + let hashSeenByLaterPlugin: string | undefined + const hashPlugin: Plugin = { + name: 'hash-final-output', + setup(build) { + build.onEnd((result) => { + const output = result.outputFiles?.find((file) => file.path.endsWith('.js')) + if (output) { + hashSeenByLaterPlugin = createHash('sha1').update(output.contents).digest('hex') + } + }) + }, + } + + const withPlugin = await buildContentHashed([posthogEsbuildPlugin(), hashPlugin]) + const repeated = await buildContentHashed([posthogEsbuildPlugin()]) + const withoutPlugin = await buildContentHashed([]) + const output = withPlugin.outputFiles.find((file) => file.path.endsWith('.js'))! + const repeatedOutput = repeated.outputFiles.find((file) => file.path.endsWith('.js'))! + const plainOutput = withoutPlugin.outputFiles.find((file) => file.path.endsWith('.js'))! + + expect(hashSeenByLaterPlugin).toBe(createHash('sha1').update(output.contents).digest('hex')) + expect(path.basename(output.path)).toBe(path.basename(repeatedOutput.path)) + expect(output.contents).toEqual(repeatedOutput.contents) + expect(path.basename(output.path)).not.toBe(path.basename(plainOutput.path)) + expect(JSON.parse(text(withPlugin.outputFiles.find((file) => file.path.endsWith('.map')))).chunk_id).toBe( + path.basename(output.path) + ) + }) + + it('does not add the banner twice when configured twice', async () => { + const { javascript } = await buildFixture({ + plugins: [posthogEsbuildPlugin(), posthogEsbuildPlugin()], + }) + + expect(text(javascript).match(/_posthogChunkIds/g)).toHaveLength(3) + }) + + it('fails with an actionable error when JavaScript source maps are disabled', async () => { + await expect(buildFixture({ sourcemap: false })).rejects.toThrow('Enable JavaScript source maps') + }) + + it('can be disabled for non-production Angular configurations', async () => { + const { javascript, sourceMap } = await buildFixture({ + plugins: [posthogEsbuildPlugin({ enabled: false })], + }) + + expect(text(javascript)).not.toContain('_posthogChunkIds') + expect(JSON.parse(text(sourceMap))).not.toHaveProperty('chunk_id') + }) +}) diff --git a/packages/esbuild-plugin/src/index.ts b/packages/esbuild-plugin/src/index.ts new file mode 100644 index 0000000000..d627ca4fec --- /dev/null +++ b/packages/esbuild-plugin/src/index.ts @@ -0,0 +1,108 @@ +import type { OutputFile, Plugin } from 'esbuild' +import path from 'node:path' + +const JAVASCRIPT_OUTPUT = /\.(?:c|m)?js$/i +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +// The output filename is already content-hashed by Angular/esbuild and is available at runtime +// through import.meta.url. Using it as the chunk id lets the same banner run in every chunk before +// esbuild computes output hashes. posthog-cli reads the matching id from the sourcemap metadata. +const RUNTIME_CHUNK_ID_BANNER = + '!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack,r=decodeURIComponent(import.meta.url.split(/[?#]/)[0].split("/").pop()||"");n&&r&&(e._posthogChunkIds=e._posthogChunkIds||{},e._posthogChunkIds[n]=r)}catch(e){}}();/* posthog-chunk-id: output-filename */' + +export interface PostHogEsbuildPluginOptions { + /** Disable injection without changing the Angular builder configuration. */ + enabled?: boolean +} + +type JsonSourceMap = Record & { + version: number + sources: string[] + names: string[] + mappings: string + chunk_id?: string +} + +/** + * Register content-hashed output filenames as PostHog chunk IDs before esbuild hashes the bundle. + * + * This plugin intentionally does not upload source maps. Build systems such as Angular still need + * to create manifests and write these outputs after esbuild completes. Run + * `posthog-cli sourcemap upload` once the files are on disk. + */ +export default function posthogEsbuildPlugin(options: PostHogEsbuildPluginOptions = {}): Plugin { + return { + name: 'posthog-esbuild-plugin', + setup(build) { + if (options.enabled === false) { + return + } + + const existingBanner = build.initialOptions.banner?.js + build.initialOptions.banner = { + ...build.initialOptions.banner, + js: existingBanner?.includes(RUNTIME_CHUNK_ID_BANNER) + ? existingBanner + : existingBanner + ? `${existingBanner}\n${RUNTIME_CHUNK_ID_BANNER}` + : RUNTIME_CHUNK_ID_BANNER, + } + + build.onEnd((result) => { + if (result.errors.length > 0) { + return + } + if (!result.outputFiles) { + throw new Error( + '[posthog-esbuild-plugin] esbuild did not expose in-memory output files. ' + + 'This integration requires write:false. Angular application builders already use write:false.' + ) + } + + stampChunkIds(result.outputFiles) + }) + }, + } +} + +/** Exported for build-integration tests and custom builder authors. */ +export function stampChunkIds(outputFiles: OutputFile[]): void { + const filesByPath = new Map(outputFiles.map((file) => [path.resolve(file.path), file])) + + for (const sourceFile of outputFiles) { + if (!JAVASCRIPT_OUTPUT.test(sourceFile.path)) { + continue + } + + const sourceMapFile = filesByPath.get(path.resolve(`${sourceFile.path}.map`)) + if (!sourceMapFile) { + throw new Error( + `[posthog-esbuild-plugin] no external sourcemap output was found for ${sourceFile.path}. ` + + 'Enable JavaScript source maps for this build.' + ) + } + + const sourceMap = parseSourceMap(sourceMapFile) + sourceMap.chunk_id = path.basename(sourceFile.path) + sourceMapFile.contents = encoder.encode(JSON.stringify(sourceMap)) + } +} + +function parseSourceMap(file: OutputFile): JsonSourceMap { + try { + const sourceMap = JSON.parse(decoder.decode(file.contents)) as JsonSourceMap + if ( + sourceMap.version !== 3 || + !(sourceMap.sources instanceof Array) || + !(sourceMap.names instanceof Array) || + typeof sourceMap.mappings !== 'string' + ) { + throw new Error('expected a version 3 sourcemap') + } + return sourceMap + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`[posthog-esbuild-plugin] failed to parse ${file.path}: ${message}`) + } +} diff --git a/packages/esbuild-plugin/tsconfig.build.json b/packages/esbuild-plugin/tsconfig.build.json new file mode 100644 index 0000000000..7017350e3a --- /dev/null +++ b/packages/esbuild-plugin/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/packages/esbuild-plugin/tsconfig.json b/packages/esbuild-plugin/tsconfig.json new file mode 100644 index 0000000000..6209da587d --- /dev/null +++ b/packages/esbuild-plugin/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@posthog-tooling/tsconfig-base", + "compilerOptions": { + "incremental": false, + "rootDir": "./src", + "baseUrl": "./src", + "target": "ES2020", + "module": "node16", + "moduleResolution": "node16", + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "lib": ["ES2020", "DOM"] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8f60426ef..256ff58432 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -603,6 +603,27 @@ importers: specifier: 'catalog:' version: 29.7.0(@types/node@26.1.1)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) + packages/esbuild-plugin: + devDependencies: + '@jridgewell/trace-mapping': + specifier: ^0.3.31 + version: 0.3.31 + '@posthog-tooling/tsconfig-base': + specifier: workspace:* + version: link:../../tooling/tsconfig-base + '@rslib/core': + specifier: 'catalog:' + version: 0.10.6(@microsoft/api-extractor@7.58.9(@types/node@26.1.1))(typescript@5.9.3) + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + esbuild: + specifier: ^0.25.0 + version: 0.25.10 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@26.1.1)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) + packages/mcp: dependencies: '@posthog/core':