Skip to content
Open
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 app/bundler/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { ProgressMessage } from './types'

/**
* emitted during package initialization and bundling.
*/
export const progress = createEventHook<ProgressMessage>()
257 changes: 257 additions & 0 deletions app/bundler/lib/bundler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { encodeUtf8, getUtf8Length } from '@atcute/uint8array'
import * as zstd from '@bokuweb/zstd-wasm'
import { rolldown } from '@rolldown/browser'
import { memfs } from '@rolldown/browser/experimental'

import { progress } from '../events'
import type { BundleChunk, BundleOptions, BundleResult } from '../types'

import { BundleError } from './errors'
import { analyzeModule } from './module-type'

const { volume } = memfs!

// #region helpers

const VIRTUAL_ENTRY_ID = '\0virtual:entry'

/**
* get compressed size using a compression stream.
*/
async function getCompressedSize(code: string, format: CompressionFormat): Promise<number> {
const { readable, writable } = new CompressionStream(format)

{
const writer = writable.getWriter()
writer.write(encodeUtf8(code))
writer.close()
}

let size = 0
{
const reader = readable.getReader()
while (true) {
// oxlint-disable-next-line no-await-in-loop
const { done, value: chunk } = await reader.read()
if (done) {
break
}

size += chunk.byteLength
}
}

return size
}

/**
* get gzip size using compression stream.
*/
function getGzipSize(code: string): Promise<number> {
return getCompressedSize(code, 'gzip')
}

/**
* whether brotli compression is supported.
* - `undefined`: not yet checked
* - `true`: supported
* - `false`: not supported
*/
let isBrotliSupported: boolean | undefined

/**
* get brotli size using compression stream, if supported.
* returns `undefined` if brotli is not supported by the browser.
*/
async function getBrotliSize(code: string): Promise<number | undefined> {
if (isBrotliSupported === false) {
return undefined
}

if (isBrotliSupported === undefined) {
try {
// @ts-expect-error 'brotli' is not in the type definition yet
const size = await getCompressedSize(code, 'brotli')
isBrotliSupported = true
return size
} catch {
isBrotliSupported = false
return undefined
}
}

// @ts-expect-error 'brotli' is not in the type definition yet
return getCompressedSize(code, 'brotli')
}

/**
* get zstd-compressed size using WASM.
*/
function getZstdSize(code: string): number {
const encoded = encodeUtf8(code)
const compressed = zstd.compress(encoded)
return compressed.byteLength
}

// #endregion

// #region core

/**
* bundles a subpath from a package that's already loaded in rolldown's memfs.
*
* @param packageName the package name (e.g., "react")
* @param subpath the export subpath to bundle (e.g., ".", "./utils")
* @param selectedExports specific exports to include, or null for all
* @param options bundling options
* @returns bundle result with chunks, sizes, and exported names
*/
export async function bundlePackage(
packageName: string,
subpath: string,
selectedExports: string[] | null,
options: BundleOptions,
): Promise<BundleResult> {
// track whether module is CJS (set in load hook)
let isCjs = false

// bundle with rolldown
const bundle = await rolldown({
input: { main: VIRTUAL_ENTRY_ID },
cwd: '/',
external: options.rolldown?.external,
plugins: [
{
name: 'virtual-entry',
resolveId(id: string) {
if (id === VIRTUAL_ENTRY_ID) {
return id
}
},
async load(id: string) {
if (id !== VIRTUAL_ENTRY_ID) {
return
}

const importPath = subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`

// resolve the entry module
const resolved = await this.resolve(importPath)
if (!resolved) {
throw new BundleError(`failed to resolve entry module: ${importPath}`)
}

// JSON files only have a default export
if (resolved.id.endsWith('.json')) {
return `export { default } from '${importPath}';\n`
}

// read the source file
let source: string
try {
source = volume.readFileSync(resolved.id, 'utf8') as string
} catch {
throw new BundleError(`failed to read entry module: ${resolved.id}`)
}

// parse and analyze the module
let ast
try {
ast = this.parse(source)
} catch {
throw new BundleError(`failed to parse entry module: ${resolved.id}`)
}

const moduleInfo = analyzeModule(ast)
isCjs = moduleInfo.type === 'cjs'

// CJS modules can't be tree-shaken effectively, just re-export default
if (moduleInfo.type === 'cjs') {
return `export { default } from '${importPath}';\n`
}

// unknown/side-effects only modules have no exports
if (moduleInfo.type === 'unknown') {
return `export {} from '${importPath}';\n`
}

// ESM module handling
if (selectedExports === null) {
// re-export everything
let code = `export * from '${importPath}';\n`
if (moduleInfo.hasDefaultExport) {
code += `export { default } from '${importPath}';\n`
}
return code
}

// specific exports selected (empty array = export nothing)
// quote names to handle non-identifier exports
const quoted = selectedExports.map(e => JSON.stringify(e))
return `export { ${quoted.join(', ')} } from '${importPath}';\n`
},
},
],
})

const output = await bundle.generate({
format: 'esm',
minify: options.rolldown?.minify ?? true,
})

// process all chunks
const rawChunks = output.output.filter(o => o.type === 'chunk')

progress.trigger({ type: 'progress', kind: 'compress' })

const chunks: BundleChunk[] = await Promise.all(
rawChunks.map(async chunk => {
const code = chunk.code
const size = getUtf8Length(code)
const [gzipSize, brotliSize, zstdSize] = await Promise.all([
getGzipSize(code),
getBrotliSize(code),
getZstdSize(code),
])

return {
fileName: chunk.fileName,
code,
size,
gzipSize,
brotliSize,
zstdSize,
isEntry: chunk.isEntry,
exports: chunk.exports || [],
}
}),
)

// find entry chunk for exports
const entryChunk = chunks.find(c => c.isEntry)
if (!entryChunk) {
throw new BundleError('no entry chunk found in bundle output')
}

// aggregate sizes
const totalSize = chunks.reduce((acc, c) => acc + c.size, 0)
const totalGzipSize = chunks.reduce((acc, c) => acc + c.gzipSize, 0)
const totalBrotliSize = isBrotliSupported
? chunks.reduce((acc, c) => acc + c.brotliSize!, 0)
: undefined
const totalZstdSize = chunks.reduce((acc, c) => acc + c.zstdSize!, 0)

await bundle.close()

return {
chunks,
size: totalSize,
gzipSize: totalGzipSize,
brotliSize: totalBrotliSize,
zstdSize: totalZstdSize,
exports: entryChunk.exports,
isCjs,
}
}

// #endregion
81 changes: 81 additions & 0 deletions app/bundler/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* base class for all teardown errors.
*/
export class TeardownError extends Error {
constructor(message: string) {
super(message)
this.name = 'TeardownError'
}
}

/**
* thrown when a package cannot be found in the registry.
*/
export class PackageNotFoundError extends TeardownError {
readonly packageName: string
readonly registry: string

constructor(packageName: string, registry: string) {
super(`package not found: ${packageName}`)
this.name = 'PackageNotFoundError'
this.packageName = packageName
this.registry = registry
}
}

/**
* thrown when no version of a package satisfies the requested range.
*/
export class NoMatchingVersionError extends TeardownError {
readonly packageName: string
readonly range: string

constructor(packageName: string, range: string) {
super(`no version of ${packageName} satisfies ${range}`)
this.name = 'NoMatchingVersionError'
this.packageName = packageName
this.range = range
}
}

/**
* thrown when a package specifier is malformed.
*/
export class InvalidSpecifierError extends TeardownError {
readonly specifier: string

constructor(specifier: string, reason?: string) {
super(
reason ? `invalid specifier: ${specifier} (${reason})` : `invalid specifier: ${specifier}`,
)
this.name = 'InvalidSpecifierError'
this.specifier = specifier
}
}

/**
* thrown when a network request fails.
*/
export class FetchError extends TeardownError {
readonly url: string
readonly status: number
readonly statusText: string

constructor(url: string, status: number, statusText: string) {
super(`fetch failed: ${status} ${statusText}`)
this.name = 'FetchError'
this.url = url
this.status = status
this.statusText = statusText
}
}

/**
* thrown when bundling fails.
*/
export class BundleError extends TeardownError {
constructor(message: string) {
super(message)
this.name = 'BundleError'
}
}
Loading
Loading