Skip to content
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ Inspired by https://sourcemaps.info/, but does everything on the client and does
2. Provide source maps by choosing files or pasting the content of the source map.
3. See the results on the right.

## Known issues

**Indexed (sectioned) source maps are not supported.** Some tools (e.g. webpack with `source-map-loader`) produce source maps with a `sections` field instead of `mappings`. These will be rejected silently.

If you have a real-world case where this matters, please [open an issue](https://github.com/rmuratov/sourcemap.tools/issues) and attach the source map file and a sample stack trace — that will make it straightforward to add test coverage and implement support.

## Development

1. Clone the repository
Expand Down
22 changes: 11 additions & 11 deletions src/__tests__/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('stack trace', () => {
const filenamesListItems = within(filenamesList).getAllByRole('listitem')
const fileNames = filenamesListItems.map(item => item.textContent)

expect(fileNames).toEqual(['index-d803759c.js', 'vendor-221d27ba.js'])
expect(fileNames).toEqual(['index-F7qoIhl0.js', 'vendor-B_FE3Fnm.js'])
})

test('shows warning if parsing failed', async () => {
Expand Down Expand Up @@ -75,7 +75,7 @@ describe('source maps', () => {
const resultTextArea = screen.getByRole('textbox', { name: /original stack trace/i })

await user.type(stacktraceTextarea, regular.stacktrace)
expect(resultTextArea).toHaveValue('')
expect(resultTextArea).toHaveValue(regular.reconstructed)

const sourceMapFileInput = screen.getByLabelText(/choose files/i)
const files = regular.sourcemaps.map(sm => new File([sm.content], sm.fileName))
Expand All @@ -94,10 +94,10 @@ describe('source maps', () => {
sourcemapTextarea.focus()
await user.paste(regular.sourcemaps[0].content)

const sourcemapList = screen.getByRole('list', { name: /sourcemaps list/i })
const sourcemapList = await screen.findByRole('list', { name: /sourcemaps list/i })
expect(sourcemapList).toBeInTheDocument()

expect(within(sourcemapList).getByRole('listitem')).toHaveTextContent('index-d803759c.js')
expect(within(sourcemapList).getByRole('listitem')).toHaveTextContent('index-F7qoIhl0.js')
})

test('updates related lines in the result after deleting sourcemap', async () => {
Expand Down Expand Up @@ -134,7 +134,7 @@ describe('source maps', () => {
const deleteButtons = await screen.findAllByRole('button', { name: 'delete' })
await Promise.all(deleteButtons.map(btn => user.click(btn)))

await waitFor(() => expect(resultTextArea).toHaveValue(''))
await waitFor(() => expect(resultTextArea).toHaveValue(regular.reconstructed))
})

test('ignores empty files list', async () => {
Expand Down Expand Up @@ -199,7 +199,7 @@ describe('source maps', () => {
const filenamesListItems = within(sourcemapList).getAllByRole('listitem')
const fileNames = filenamesListItems.map(item => item.textContent)

expect(fileNames).toEqual(['index-d803759c.js.map delete', 'vendor-221d27ba.js.map delete'])
expect(fileNames).toEqual(['index-F7qoIhl0.js.map delete', 'vendor-B_FE3Fnm.js.map delete'])
})

test('allows opening file selector using keyboard', () => {
Expand Down Expand Up @@ -274,13 +274,13 @@ describe('source maps', () => {

const sourcemapTextarea = screen.getByRole('textbox', { name: /source map/i })

const dataUrl = `data:application/json;base64,${btoa(regular.sourcemaps[0].content)}`
const dataUrl = `data:application/json;base64,${Buffer.from(regular.sourcemaps[0].content).toString('base64')}`

sourcemapTextarea.focus()
await user.paste(dataUrl)

const sourcemapList = await screen.findByRole('list', { name: /sourcemaps list/i })
expect(within(sourcemapList).getByRole('listitem')).toHaveTextContent('index-d803759c.js')
expect(within(sourcemapList).getByRole('listitem')).toHaveTextContent('index-F7qoIhl0.js')
})

test('decodes base64 data URL with charset parameter', async () => {
Expand All @@ -289,13 +289,13 @@ describe('source maps', () => {

const sourcemapTextarea = screen.getByRole('textbox', { name: /source map/i })

const dataUrl = `data:application/json;charset=utf-8;base64,${btoa(regular.sourcemaps[0].content)}`
const dataUrl = `data:application/json;charset=utf-8;base64,${Buffer.from(regular.sourcemaps[0].content).toString('base64')}`

sourcemapTextarea.focus()
await user.paste(dataUrl)

const sourcemapList = await screen.findByRole('list', { name: /sourcemaps list/i })
expect(within(sourcemapList).getByRole('listitem')).toHaveTextContent('index-d803759c.js')
expect(within(sourcemapList).getByRole('listitem')).toHaveTextContent('index-F7qoIhl0.js')
})

test('shows warning for malformed base64 data URL', async () => {
Expand Down Expand Up @@ -324,7 +324,7 @@ describe('source maps', () => {
await user.paste(regular.sourcemaps[1].content)

const sourcemapList = await screen.findByRole('list', { name: /sourcemaps list/i })
expect(within(sourcemapList).getByRole('listitem')).toHaveTextContent('vendor-221d27ba.js')
expect(within(sourcemapList).getByRole('listitem')).toHaveTextContent('vendor-B_FE3Fnm.js')
})
})

Expand Down
65 changes: 34 additions & 31 deletions src/__tests__/fixtures/regular/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,43 @@ import path from 'node:path'

export const regular = {
afterDeleteIndex: `Uncaught Error: Error!
at u (index-d803759c.js:1:785)
at i (index-d803759c.js:1:831)
at (../../node_modules/react-dom/cjs/react-dom.production.min.js:54:316)
at (../../node_modules/react-dom/cjs/react-dom.production.min.js:54:470)
at (../../node_modules/react-dom/cjs/react-dom.production.min.js:55:34)
at Ub (../../node_modules/react-dom/cjs/react-dom.production.min.js:105:67)
at nf (../../node_modules/react-dom/cjs/react-dom.production.min.js:106:379)
at se (../../node_modules/react-dom/cjs/react-dom.production.min.js:117:103)
at a (../../node_modules/react-dom/cjs/react-dom.production.min.js:274:41)
at Gb (../../node_modules/react-dom/cjs/react-dom.production.min.js:52:374)`,
at e.throwError (index-F7qoIhl0.js:2:1251)
at onClick (index-F7qoIhl0.js:3:5483)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:12317:12)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:12867:4)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:1498:35)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:12455:2)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:15306:6)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:15274:6)`,
reconstructed: `Uncaught Error: Error!
at e.throwError (index-F7qoIhl0.js:2:1251)
at onClick (index-F7qoIhl0.js:3:5483)
at yd (vendor-B_FE3Fnm.js:8:125915)
at (vendor-B_FE3Fnm.js:8:130908)
at gn (vendor-B_FE3Fnm.js:8:15080)
at wd (vendor-B_FE3Fnm.js:8:127142)
at up (vendor-B_FE3Fnm.js:9:28431)
at cp (vendor-B_FE3Fnm.js:9:28253)`,
result: `Uncaught Error: Error!
at (../../src/utils.ts:2:8)
at throwError (../../src/App.tsx:5:15)
at (../../node_modules/react-dom/cjs/react-dom.production.min.js:54:316)
at (../../node_modules/react-dom/cjs/react-dom.production.min.js:54:470)
at (../../node_modules/react-dom/cjs/react-dom.production.min.js:55:34)
at Ub (../../node_modules/react-dom/cjs/react-dom.production.min.js:105:67)
at nf (../../node_modules/react-dom/cjs/react-dom.production.min.js:106:379)
at se (../../node_modules/react-dom/cjs/react-dom.production.min.js:117:103)
at a (../../node_modules/react-dom/cjs/react-dom.production.min.js:274:41)
at Gb (../../node_modules/react-dom/cjs/react-dom.production.min.js:52:374)`,
sourcemaps: fs.readdirSync(path.resolve(__dirname, 'sourcemaps')).map(fileName => ({
at (../../src/source-map.ts:60:14)
at (../../src/app.tsx:164:66)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:12317:12)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:12867:4)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:1498:35)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:12455:2)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:15306:6)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:15274:6)`,
sourcemaps: ['index-F7qoIhl0.js.map', 'vendor-B_FE3Fnm.js.map'].map(fileName => ({
content: fs.readFileSync(path.resolve(__dirname, 'sourcemaps', fileName)).toString(),
fileName,
})),
stacktrace: `Uncaught Error: Error!
at u (index-d803759c.js:1:785)
at i (index-d803759c.js:1:831)
at Object.Dc (vendor-221d27ba.js:37:9852)
at Fc (vendor-221d27ba.js:37:10006)
at jc (vendor-221d27ba.js:37:10063)
at ii (vendor-221d27ba.js:37:31442)
at Xs (vendor-221d27ba.js:37:31859)
at vendor-221d27ba.js:37:36771
at Co (vendor-221d27ba.js:40:36724)
at gs (vendor-221d27ba.js:37:8988)`,
at e.throwError (index-F7qoIhl0.js:2:1251)
at onClick (index-F7qoIhl0.js:3:5483)
at yd (vendor-B_FE3Fnm.js:8:125915)
at vendor-B_FE3Fnm.js:8:130908
at gn (vendor-B_FE3Fnm.js:8:15080)
at wd (vendor-B_FE3Fnm.js:8:127142)
at up (vendor-B_FE3Fnm.js:9:28431)
at cp (vendor-B_FE3Fnm.js:9:28253)`,
}

Large diffs are not rendered by default.

This file was deleted.

This file was deleted.

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@ import type { SourceMap } from './source-map.ts'
import type { StackTrace } from './stack-trace.ts'

export function transform(sourceMaps: SourceMap[], stackTrace: null | StackTrace) {
const bindings = calculateBindings(sourceMaps, stackTrace)

if (!stackTrace || Object.keys(bindings).length === 0) {
if (!stackTrace) {
return ''
}

const bindings = calculateBindings(sourceMaps, stackTrace)
const result = [stackTrace.message]

const transformed = stackTrace.frames.map(stackFrame =>
Expand Down Expand Up @@ -51,7 +50,7 @@ function toUnifiedPosition(position: NullableMappedPosition | StackFrame): Unifi
column: position.column,
file: position.file,
line: position.lineNumber,
method: position.methodName,
method: position.methodName === '<unknown>' ? null : position.methodName,
}
}

Expand Down
15 changes: 10 additions & 5 deletions src/source-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,24 @@ import type {

import { SourceMapConsumer } from 'source-map'

let id = 0

export class SourceMap {
static #nextId = 0

consumer: BasicSourceMapConsumer | IndexedSourceMapConsumer
fileName?: string
fileNameInline?: string
id: number
#mappings: string

constructor(
consumer: BasicSourceMapConsumer | IndexedSourceMapConsumer,
mappings: string,
fileNameInline?: string,
fileName?: string,
) {
this.id = id++
this.id = SourceMap.#nextId++
this.consumer = consumer
this.#mappings = mappings
this.fileNameInline = fileNameInline
this.fileName = fileName
}
Expand All @@ -41,7 +44,7 @@ export class SourceMap {
const consumer = await new SourceMapConsumer(rawSourceMap)
const fileNameInline = parsed.file

return new SourceMap(consumer, fileNameInline, sourceMapFileName)
return new SourceMap(consumer, parsed.mappings, fileNameInline, sourceMapFileName)
}

static isRawSourceMap(sourceMap: RawIndexMap | RawSourceMap): sourceMap is RawSourceMap {
Expand All @@ -54,6 +57,8 @@ export class SourceMap {
}

isEqual(sourceMap: SourceMap) {
return this.fileNameInline === sourceMap.fileNameInline
return (
this.fileNameInline === sourceMap.fileNameInline && this.#mappings === sourceMap.#mappings
)
}
}
26 changes: 13 additions & 13 deletions src/use-sourcemaps-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@ export function useSourcemapsStore() {
const [sourceMaps, setSourceMaps] = useState<SourceMap[]>([])

function addSourceMaps(value: (null | SourceMap)[] | SourceMap) {
const newSourceMaps: SourceMap[] = []

for (const sourceMap of Array.isArray(value) ? value : [value]) {
if (sourceMap && !sourceMaps.some(s => s.isEqual(sourceMap))) {
newSourceMaps.push(sourceMap)
}
}

setSourceMaps(sourceMaps => [...sourceMaps, ...newSourceMaps])
const candidates = (Array.isArray(value) ? value : [value]).filter(
(sm): sm is SourceMap => sm !== null,
)
setSourceMaps(prev => {
const toAdd = candidates.filter(sm => !prev.some(s => s.isEqual(sm)))
return toAdd.length ? [...prev, ...toAdd] : prev
})
}

function deleteSourceMap(id: number) {
const index = sourceMaps.findIndex(sm => sm.id === id)
const sm = sourceMaps[index]
sm.consumer.destroy()
setSourceMaps(sourceMaps => sourceMaps.filter((_sm, i) => i !== index))
setSourceMaps(prev => {
const target = prev.find(sm => sm.id === id)
if (!target) return prev
target.consumer.destroy()
return prev.filter(sm => sm.id !== id)
})
}

return { addSourceMaps, deleteSourceMap, sourceMaps }
Expand Down
Loading