Skip to content

Commit 21d73ef

Browse files
fix(devtools): URL encode source parameter (#97)
This prevents issues when the filename contains special chars like `+` Closes #85 --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent ea751ad commit 21d73ef

File tree

3 files changed

+170
-2
lines changed

3 files changed

+170
-2
lines changed

packages/devtools-vite/src/utils.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ export const handleDevToolsViteRequest = (
1515
if (!source) {
1616
return
1717
}
18-
const [file, line, column] = source.split(':')
18+
19+
const parsed = parseOpenSourceParam(source)
20+
if (!parsed) {
21+
return
22+
}
23+
const { file, line, column } = parsed
1924

2025
cb({
2126
type: 'open-source',
@@ -49,6 +54,17 @@ export const handleDevToolsViteRequest = (
4954
})
5055
}
5156

57+
export const parseOpenSourceParam = (source: string) => {
58+
// Capture everything up to the last two colon-separated numeric parts as the file.
59+
// This supports filenames that may themselves contain colons.
60+
const parts = source.match(/^(.+):(\d+):(\d+)$/)
61+
62+
if (!parts) return null
63+
64+
const [, file, line, column] = parts
65+
return { file, line, column }
66+
}
67+
5268
/* export const tryReadFile = async (
5369
filePath: string
5470
) => {
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { EventEmitter } from 'node:events'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { normalizePath } from 'vite'
4+
import { handleDevToolsViteRequest, parseOpenSourceParam } from '../src/utils'
5+
6+
function createMockReq(url?: string) {
7+
const emitter = new EventEmitter() as unknown as EventEmitter & {
8+
url?: string
9+
on: (event: string, listener: (...args: Array<any>) => void) => any
10+
}
11+
;(emitter as any).url = url
12+
return emitter as any
13+
}
14+
15+
function createMockRes() {
16+
return {
17+
setHeader: vi.fn(),
18+
write: vi.fn(),
19+
end: vi.fn(),
20+
}
21+
}
22+
23+
describe('handleDevToolsViteRequest', () => {
24+
let next: ReturnType<typeof vi.fn>
25+
let cb: ReturnType<typeof vi.fn>
26+
27+
beforeEach(() => {
28+
next = vi.fn()
29+
cb = vi.fn()
30+
})
31+
32+
it('calls next() when url does not include __tsd', () => {
33+
const req = createMockReq('/some/other/path')
34+
const res = createMockRes()
35+
36+
handleDevToolsViteRequest(req, res as any, next as any, cb as any)
37+
38+
expect(next).toHaveBeenCalledTimes(1)
39+
expect(cb).not.toHaveBeenCalled()
40+
expect(res.setHeader).not.toHaveBeenCalled()
41+
expect(res.write).not.toHaveBeenCalled()
42+
expect(res.end).not.toHaveBeenCalled()
43+
})
44+
45+
it('handles __tsd/open-source with valid source and responds/ends', () => {
46+
const file = 'src/file.ts'
47+
const line = '12'
48+
const column = '5'
49+
const url = `/__tsd/open-source?source=${encodeURIComponent(`${file}:${line}:${column}`)}`
50+
51+
const req = createMockReq(url)
52+
const res = createMockRes()
53+
54+
handleDevToolsViteRequest(req, res as any, next as any, cb as any)
55+
56+
// callback payload
57+
expect(cb).toHaveBeenCalledTimes(1)
58+
const payload = cb.mock.calls[0]?.[0]
59+
expect(payload).toMatchObject({
60+
type: 'open-source',
61+
routine: 'open-source',
62+
})
63+
expect(payload.data.line).toBe(line)
64+
expect(payload.data.column).toBe(column)
65+
66+
const expectedSource = normalizePath(`${process.cwd()}/${file}`)
67+
expect(payload.data.source).toBe(expectedSource)
68+
69+
// response behavior
70+
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html')
71+
expect(res.write).toHaveBeenCalledWith('<script> window.close(); </script>')
72+
expect(res.end).toHaveBeenCalled()
73+
74+
// next() is not called for handled route
75+
expect(next).not.toHaveBeenCalled()
76+
})
77+
78+
it('does nothing for __tsd/open-source when source is missing', () => {
79+
const req = createMockReq('/__tsd/open-source')
80+
const res = createMockRes()
81+
82+
handleDevToolsViteRequest(req, res as any, next as any, cb as any)
83+
84+
expect(cb).not.toHaveBeenCalled()
85+
expect(res.setHeader).not.toHaveBeenCalled()
86+
expect(res.write).not.toHaveBeenCalled()
87+
expect(res.end).not.toHaveBeenCalled()
88+
expect(next).not.toHaveBeenCalled()
89+
})
90+
91+
it('does nothing for __tsd/open-source when source is malformed', () => {
92+
const malformed = encodeURIComponent('src/file.ts:abc:def')
93+
const req = createMockReq(`/__tsd/open-source?source=${malformed}`)
94+
const res = createMockRes()
95+
96+
handleDevToolsViteRequest(req, res as any, next as any, cb as any)
97+
98+
expect(cb).not.toHaveBeenCalled()
99+
expect(res.setHeader).not.toHaveBeenCalled()
100+
expect(res.write).not.toHaveBeenCalled()
101+
expect(res.end).not.toHaveBeenCalled()
102+
expect(next).not.toHaveBeenCalled()
103+
})
104+
105+
it('parses JSON body for other __tsd requests and writes OK', () => {
106+
const req = createMockReq('/__tsd/some-endpoint')
107+
const res = createMockRes()
108+
109+
handleDevToolsViteRequest(req, res as any, next as any, cb as any)
110+
111+
// simulate streaming body
112+
const chunks = [Buffer.from('{"foo":'), Buffer.from('1}')]
113+
req.emit('data', chunks[0])
114+
req.emit('data', chunks[1])
115+
req.emit('end')
116+
117+
expect(cb).toHaveBeenCalledTimes(1)
118+
expect(cb).toHaveBeenCalledWith({ foo: 1 })
119+
expect(res.write).toHaveBeenCalledWith('OK')
120+
121+
// these are not used in this branch
122+
expect(res.setHeader).not.toHaveBeenCalled()
123+
expect(res.end).not.toHaveBeenCalled()
124+
expect(next).not.toHaveBeenCalled()
125+
})
126+
127+
it('swallows JSON parse errors but still writes OK', () => {
128+
const req = createMockReq('/__tsd/another')
129+
const res = createMockRes()
130+
131+
handleDevToolsViteRequest(req, res as any, next as any, cb as any)
132+
req.emit('data', Buffer.from('{ invalid json'))
133+
req.emit('end')
134+
135+
expect(cb).not.toHaveBeenCalled()
136+
expect(res.write).toHaveBeenCalledWith('OK')
137+
})
138+
})
139+
140+
describe('parseOpenSourceParam', () => {
141+
it('parses simple filename foo.tsx with line/column', () => {
142+
const input = 'foo.tsx:10:20'
143+
const parsed = parseOpenSourceParam(input)
144+
expect(parsed).toEqual({ file: 'foo.tsx', line: '10', column: '20' })
145+
})
146+
147+
it('parses filename containing colon bar:baz.tsx with line/column', () => {
148+
const input = 'bar:baz.tsx:3:7'
149+
const parsed = parseOpenSourceParam(input)
150+
expect(parsed).toEqual({ file: 'bar:baz.tsx', line: '3', column: '7' })
151+
})
152+
})

packages/devtools/src/devtools.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ export default function DevTools() {
172172
e.preventDefault()
173173
e.stopPropagation()
174174
fetch(
175-
`http://localhost:__TSD_PORT__/__tsd/open-source?source=${dataSource}`,
175+
`http://localhost:__TSD_PORT__/__tsd/open-source?source=${encodeURIComponent(dataSource)}`,
176176
).catch(() => {})
177177
}
178178
}

0 commit comments

Comments
 (0)