Skip to content

Commit f36712f

Browse files
committed
fix(dev-server): bundle browser TypeScript assets
1 parent b7e046b commit f36712f

2 files changed

Lines changed: 109 additions & 27 deletions

File tree

packages/bun-plugin/src/serve.ts

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,65 @@ const staticContentTypes: Record<string, string> = {
259259
eot: 'application/vnd.ms-fontobject',
260260
}
261261

262+
const bundledAssetExtensions = new Set(['ts', 'tsx', 'mts', 'cts'])
263+
264+
function isBundledAssetExtension(ext: string | undefined): ext is string {
265+
return Boolean(ext && bundledAssetExtensions.has(ext))
266+
}
267+
268+
function getAssetExtension(pathname: string): string | undefined {
269+
return pathname.split('.').pop()?.toLowerCase()
270+
}
271+
272+
function isSafeAssetPath(pathname: string): boolean {
273+
if (pathname.includes('\0'))
274+
return false
275+
276+
return !pathname
277+
.split('/')
278+
.some(segment => segment === '..')
279+
}
280+
281+
function assetRequestPaths(pathname: string): string[] {
282+
const paths = [
283+
pathname,
284+
pathname.replace(/^\/assets\//, '/resources/assets/'),
285+
pathname.replace(/^\/resources\/assets\//, '/assets/'),
286+
]
287+
288+
return [...new Set(paths)]
289+
}
290+
291+
export async function bundleBrowserAsset(entrypoint: string): Promise<Response> {
292+
const result = await Bun.build({
293+
entrypoints: [entrypoint],
294+
format: 'esm',
295+
minify: false,
296+
packages: 'bundle',
297+
sourcemap: 'inline',
298+
target: 'browser',
299+
})
300+
301+
if (!result.success) {
302+
const message = result.logs.map(log => log.message).join('\n') || 'Unable to build TypeScript asset'
303+
304+
return new Response(message, {
305+
status: 500,
306+
headers: {
307+
'Content-Type': 'text/plain; charset=utf-8',
308+
'Cache-Control': 'no-cache',
309+
},
310+
})
311+
}
312+
313+
return new Response(await result.outputs[0].text(), {
314+
headers: {
315+
'Content-Type': 'application/javascript',
316+
'Cache-Control': 'no-cache',
317+
},
318+
})
319+
}
320+
262321
/**
263322
* Start the STX development server
264323
* @param options Server options with patterns and port
@@ -410,7 +469,8 @@ export async function serve(options: ServeOptions): Promise<void> {
410469
// cached copy and re-fetches.
411470
const isCss = f.endsWith('.css')
412471
const isAsset = isCss
413-
|| f.endsWith('.js') || f.endsWith('.ts')
472+
|| f.endsWith('.js') || f.endsWith('.ts') || f.endsWith('.tsx')
473+
|| f.endsWith('.mts') || f.endsWith('.cts')
414474
|| f.endsWith('.jpg') || f.endsWith('.jpeg') || f.endsWith('.png')
415475
|| f.endsWith('.gif') || f.endsWith('.svg') || f.endsWith('.webp') || f.endsWith('.avif')
416476
|| f.endsWith('.woff') || f.endsWith('.woff2') || f.endsWith('.ttf') || f.endsWith('.otf')
@@ -1945,37 +2005,28 @@ export async function serve(options: ServeOptions): Promise<void> {
19452005
if (path.startsWith('/assets/') || path.startsWith('/resources/assets/')) {
19462006
// Ensure assets are copied on first request
19472007
await ensureAssets()
1948-
// Try multiple possible paths (like Laravel does)
1949-
const possiblePaths = [
1950-
path, // Original path
1951-
path.replace(/^\/assets\//, '/resources/assets/'), // /assets/* -> /resources/assets/*
1952-
path.replace(/^\/resources\/assets\//, '/assets/'), // /resources/assets/* -> /assets/*
1953-
]
1954-
1955-
for (const assetPath of possiblePaths) {
2008+
let assetPathname: string
2009+
try {
2010+
assetPathname = decodeURIComponent(path)
2011+
}
2012+
catch {
2013+
return new Response('Invalid asset path', { status: 400 })
2014+
}
2015+
2016+
if (!isSafeAssetPath(assetPathname))
2017+
return new Response('Invalid asset path', { status: 400 })
2018+
2019+
for (const assetPath of assetRequestPaths(assetPathname)) {
19562020
try {
1957-
const filePath = `.${assetPath}`
2021+
const filePath = nodePath.resolve(process.cwd(), `.${assetPath}`)
19582022
const file = Bun.file(filePath)
19592023

19602024
if (await file.exists()) {
19612025
// Determine content type based on file extension
1962-
const ext = assetPath.split('.').pop()?.toLowerCase()
1963-
1964-
// Handle TypeScript files - transpile to JavaScript
1965-
if (ext === 'ts') {
1966-
const transpiler = new Bun.Transpiler({
1967-
loader: 'ts',
1968-
})
1969-
const code = await file.text()
1970-
const transpiled = transpiler.transformSync(code)
1971-
1972-
return new Response(transpiled, {
1973-
headers: {
1974-
'Content-Type': 'application/javascript',
1975-
'Cache-Control': 'no-cache', // Dev mode - no caching for TS
1976-
},
1977-
})
1978-
}
2026+
const ext = getAssetExtension(assetPath)
2027+
2028+
if (isBundledAssetExtension(ext))
2029+
return bundleBrowserAsset(filePath)
19792030

19802031
return new Response(file, {
19812032
headers: {

packages/bun-plugin/test/serve.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { describe, expect, it, setDefaultTimeout } from 'bun:test'
22

33
// Increase timeout for CI environments where Bun.build() can be slow
44
setDefaultTimeout(30000)
5+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
6+
import { tmpdir } from 'node:os'
57
import path from 'node:path'
68
import stxPlugin from '../src/index'
9+
import { bundleBrowserAsset } from '../src/serve'
710

811
const TEST_DIR = import.meta.dir
912
const FIXTURES_DIR = path.join(TEST_DIR, 'fixtures')
@@ -115,4 +118,32 @@ describe('bun-plugin-stx serving', () => {
115118
expect(content).toContain('<meta name="twitter:card"')
116119
}
117120
})
121+
122+
it('should bundle TypeScript assets for browser modules', async () => {
123+
const dir = await mkdtemp(path.join(tmpdir(), 'stx-asset-'))
124+
125+
try {
126+
const helperPath = path.join(dir, 'helper.ts')
127+
const entryPath = path.join(dir, 'analytics.ts')
128+
129+
await writeFile(helperPath, 'export const label = "bundled asset"\n')
130+
await writeFile(entryPath, [
131+
'import { label } from "./helper"',
132+
'',
133+
'const target = document.querySelector("[data-chart]")',
134+
'if (target) target.textContent = label',
135+
].join('\n'))
136+
137+
const response = await bundleBrowserAsset(entryPath)
138+
const code = await response.text()
139+
140+
expect(response.status).toBe(200)
141+
expect(response.headers.get('Content-Type')).toBe('application/javascript')
142+
expect(code).toContain('bundled asset')
143+
expect(code).not.toContain('from "./helper"')
144+
}
145+
finally {
146+
await rm(dir, { recursive: true, force: true })
147+
}
148+
})
118149
})

0 commit comments

Comments
 (0)