Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/angular-esbuild-sourcemaps.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/esbuild-plugin/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
94 changes: 94 additions & 0 deletions packages/esbuild-plugin/README.md
Original file line number Diff line number Diff line change
@@ -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/<app>/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.
3 changes: 3 additions & 0 deletions packages/esbuild-plugin/babel.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
}
11 changes: 11 additions & 0 deletions packages/esbuild-plugin/jest.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default {
collectCoverage: true,
clearMocks: true,
coverageDirectory: 'coverage',
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
silent: true,
verbose: false,
watchman: false,
}
48 changes: 48 additions & 0 deletions packages/esbuild-plugin/package.json
Original file line number Diff line number Diff line change
@@ -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:"
}
}
14 changes: 14 additions & 0 deletions packages/esbuild-plugin/rslib.config.mjs
Original file line number Diff line number Diff line change
@@ -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',
},
})
156 changes: 156 additions & 0 deletions packages/esbuild-plugin/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading