Skip to content

Commit 537c945

Browse files
committed
wip
1 parent 4564479 commit 537c945

29 files changed

+5009
-829
lines changed

app/bundler/events.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { ProgressMessage } from './types'
2+
3+
/**
4+
* emitted during package initialization and bundling.
5+
*/
6+
export const progress = createEventHook<ProgressMessage>()

app/bundler/lib/bundler.ts

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { encodeUtf8, getUtf8Length } from '@atcute/uint8array'
2+
import type { PluginContext } from '@rolldown/browser'
3+
import { rolldown } from '@rolldown/browser'
4+
import { memfs } from '@rolldown/browser/experimental'
5+
import * as zstdWasm from '@bokuweb/zstd-wasm'
6+
7+
import { progress } from '../events'
8+
import type { BundleChunk, BundleOptions, BundleResult } from '../types'
9+
10+
import { BundleError } from './errors'
11+
import { analyzeModule } from './module-type'
12+
13+
const { volume } = memfs!
14+
15+
// #region helpers
16+
17+
const VIRTUAL_ENTRY_ID = '\0virtual:entry'
18+
19+
/**
20+
* get compressed size using a compression stream.
21+
*/
22+
async function getCompressedSize(code: string, format: CompressionFormat): Promise<number> {
23+
const stream = new Blob([code]).stream()
24+
const compressed = stream.pipeThrough(new CompressionStream(format))
25+
const reader = compressed.getReader()
26+
27+
let size = 0
28+
while (true) {
29+
const { done, value } = await reader.read()
30+
if (done) {
31+
break
32+
}
33+
34+
size += value.byteLength
35+
}
36+
37+
return size
38+
}
39+
40+
/**
41+
* get gzip size using compression stream.
42+
*/
43+
function getGzipSize(code: string): Promise<number> {
44+
return getCompressedSize(code, 'gzip')
45+
}
46+
47+
/**
48+
* whether brotli compression is supported.
49+
* - `undefined`: not yet checked
50+
* - `true`: supported
51+
* - `false`: not supported
52+
*/
53+
let isBrotliSupported: boolean | undefined
54+
55+
/**
56+
* get brotli size using compression stream, if supported.
57+
* returns `undefined` if brotli is not supported by the browser.
58+
*/
59+
async function getBrotliSize(code: string): Promise<number | undefined> {
60+
if (isBrotliSupported === false) {
61+
return undefined
62+
}
63+
64+
if (isBrotliSupported === undefined) {
65+
try {
66+
// @ts-expect-error 'brotli' is not in the type definition yet
67+
const size = await getCompressedSize(code, 'brotli')
68+
isBrotliSupported = true
69+
return size
70+
} catch {
71+
isBrotliSupported = false
72+
return undefined
73+
}
74+
}
75+
76+
// @ts-expect-error 'brotli' is not in the type definition yet
77+
return getCompressedSize(code, 'brotli')
78+
}
79+
80+
let zstdInitialized = false
81+
82+
/**
83+
* get zstd-compressed size using wasm.
84+
* returns `undefined` if wasm failed to initialize.
85+
*/
86+
async function getZstdSize(code: string): Promise<number | undefined> {
87+
if (!zstdInitialized) {
88+
try {
89+
await zstdWasm.init()
90+
zstdInitialized = true
91+
} catch {
92+
return undefined
93+
}
94+
}
95+
96+
const encoded = encodeUtf8(code)
97+
const compressed = zstdWasm.compress(encoded)
98+
return compressed.byteLength
99+
}
100+
101+
// #endregion
102+
103+
// #region core
104+
105+
/**
106+
* bundles a subpath from a package that's already loaded in rolldown's memfs.
107+
*
108+
* @param packageName the package name (e.g., "react")
109+
* @param subpath the export subpath to bundle (e.g., ".", "./utils")
110+
* @param selectedExports specific exports to include, or null for all
111+
* @param options bundling options
112+
* @returns bundle result with chunks, sizes, and exported names
113+
*/
114+
export async function bundlePackage(
115+
packageName: string,
116+
subpath: string,
117+
selectedExports: string[] | null,
118+
options: BundleOptions,
119+
): Promise<BundleResult> {
120+
// track whether module is CJS (set in load hook)
121+
let isCjs = false
122+
123+
// bundle with rolldown
124+
const bundle = await rolldown({
125+
input: { main: VIRTUAL_ENTRY_ID },
126+
cwd: '/',
127+
external: options.rolldown?.external,
128+
plugins: [
129+
{
130+
name: 'virtual-entry',
131+
resolveId(id: string) {
132+
if (id === VIRTUAL_ENTRY_ID) {
133+
return id
134+
}
135+
},
136+
async load(this: PluginContext, id: string) {
137+
if (id !== VIRTUAL_ENTRY_ID) {
138+
return
139+
}
140+
141+
const importPath = subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`
142+
143+
// resolve the entry module
144+
const resolved = await this.resolve(importPath)
145+
if (!resolved) {
146+
throw new BundleError(`failed to resolve entry module: ${importPath}`)
147+
}
148+
149+
// JSON files only have a default export
150+
if (resolved.id.endsWith('.json')) {
151+
return `export { default } from '${importPath}';\n`
152+
}
153+
154+
// read the source file
155+
let source: string
156+
try {
157+
source = volume.readFileSync(resolved.id, 'utf8') as string
158+
} catch {
159+
throw new BundleError(`failed to read entry module: ${resolved.id}`)
160+
}
161+
162+
// parse and analyze the module
163+
let ast
164+
try {
165+
ast = this.parse(source)
166+
} catch {
167+
throw new BundleError(`failed to parse entry module: ${resolved.id}`)
168+
}
169+
170+
const moduleInfo = analyzeModule(ast)
171+
isCjs = moduleInfo.type === 'cjs'
172+
173+
// CJS modules can't be tree-shaken effectively, just re-export default
174+
if (moduleInfo.type === 'cjs') {
175+
return `export { default } from '${importPath}';\n`
176+
}
177+
178+
// unknown/side-effects only modules have no exports
179+
if (moduleInfo.type === 'unknown') {
180+
return `export {} from '${importPath}';\n`
181+
}
182+
183+
// ESM module handling
184+
if (selectedExports === null) {
185+
// re-export everything
186+
let code = `export * from '${importPath}';\n`
187+
if (moduleInfo.hasDefaultExport) {
188+
code += `export { default } from '${importPath}';\n`
189+
}
190+
return code
191+
}
192+
193+
// specific exports selected (empty array = export nothing)
194+
// quote names to handle non-identifier exports
195+
const quoted = selectedExports.map(e => JSON.stringify(e))
196+
return `export { ${quoted.join(', ')} } from '${importPath}';\n`
197+
},
198+
},
199+
],
200+
})
201+
202+
const output = await bundle.generate({
203+
format: 'esm',
204+
minify: options.rolldown?.minify ?? true,
205+
})
206+
207+
// process all chunks
208+
const rawChunks = output.output.filter(o => o.type === 'chunk')
209+
210+
progress.trigger({ type: 'progress', kind: 'compress' })
211+
212+
const chunks: BundleChunk[] = await Promise.all(
213+
rawChunks.map(async chunk => {
214+
const code = chunk.code
215+
const size = getUtf8Length(code)
216+
const [gzipSize, brotliSize, zstdSize] = await Promise.all([
217+
getGzipSize(code),
218+
getBrotliSize(code),
219+
getZstdSize(code),
220+
])
221+
222+
return {
223+
fileName: chunk.fileName,
224+
code,
225+
size,
226+
gzipSize,
227+
brotliSize,
228+
zstdSize,
229+
isEntry: chunk.isEntry,
230+
exports: chunk.exports || [],
231+
}
232+
}),
233+
)
234+
235+
// find entry chunk for exports
236+
const entryChunk = chunks.find(c => c.isEntry)
237+
if (!entryChunk) {
238+
throw new BundleError('no entry chunk found in bundle output')
239+
}
240+
241+
// aggregate sizes
242+
const totalSize = chunks.reduce((acc, c) => acc + c.size, 0)
243+
const totalGzipSize = chunks.reduce((acc, c) => acc + c.gzipSize, 0)
244+
const totalBrotliSize =
245+
isBrotliSupported && chunks.every(c => c.brotliSize != null)
246+
? chunks.reduce((acc, c) => acc + c.brotliSize!, 0)
247+
: undefined
248+
const totalZstdSize =
249+
zstdInitialized && chunks.every(c => c.zstdSize != null)
250+
? chunks.reduce((acc, c) => acc + c.zstdSize!, 0)
251+
: undefined
252+
253+
await bundle.close()
254+
255+
return {
256+
chunks,
257+
size: totalSize,
258+
gzipSize: totalGzipSize,
259+
brotliSize: totalBrotliSize,
260+
zstdSize: totalZstdSize,
261+
exports: entryChunk.exports,
262+
isCjs,
263+
}
264+
}
265+
266+
// #endregion

app/bundler/lib/errors.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* base class for all teardown errors.
3+
*/
4+
class BundlerError extends Error {
5+
constructor(message: string) {
6+
super(message)
7+
this.name = 'BundlerError'
8+
}
9+
}
10+
11+
/**
12+
* thrown when a package cannot be found in the registry.
13+
*/
14+
export class PackageNotFoundError extends BundlerError {
15+
readonly packageName: string
16+
readonly registry: string
17+
18+
constructor(packageName: string, registry: string) {
19+
super(`package not found: ${packageName}`)
20+
this.name = 'PackageNotFoundError'
21+
this.packageName = packageName
22+
this.registry = registry
23+
}
24+
}
25+
26+
/**
27+
* thrown when no version of a package satisfies the requested range.
28+
*/
29+
export class NoMatchingVersionError extends BundlerError {
30+
readonly packageName: string
31+
readonly range: string
32+
33+
constructor(packageName: string, range: string) {
34+
super(`no version of ${packageName} satisfies ${range}`)
35+
this.name = 'NoMatchingVersionError'
36+
this.packageName = packageName
37+
this.range = range
38+
}
39+
}
40+
41+
/**
42+
* thrown when a package specifier is malformed.
43+
*/
44+
export class InvalidSpecifierError extends BundlerError {
45+
readonly specifier: string
46+
47+
constructor(specifier: string, reason?: string) {
48+
super(
49+
reason ? `invalid specifier: ${specifier} (${reason})` : `invalid specifier: ${specifier}`,
50+
)
51+
this.name = 'InvalidSpecifierError'
52+
this.specifier = specifier
53+
}
54+
}
55+
56+
/**
57+
* thrown when a network request fails.
58+
*/
59+
export class FetchError extends BundlerError {
60+
readonly url: string
61+
readonly status: number
62+
readonly statusText: string
63+
64+
constructor(url: string, status: number, statusText: string) {
65+
super(`fetch failed: ${status} ${statusText}`)
66+
this.name = 'FetchError'
67+
this.url = url
68+
this.status = status
69+
this.statusText = statusText
70+
}
71+
}
72+
73+
/**
74+
* thrown when bundling fails.
75+
*/
76+
export class BundleError extends BundlerError {
77+
constructor(message: string) {
78+
super(message)
79+
this.name = 'BundleError'
80+
}
81+
}

0 commit comments

Comments
 (0)