Skip to content

Commit b8f55be

Browse files
committed
wip
1 parent 4564479 commit b8f55be

27 files changed

+5111
-829
lines changed

app/bundler/emitter.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* callback type for event listeners.
3+
*/
4+
type Callback<T extends unknown[]> = (...args: T) => void
5+
6+
/**
7+
* a simple typed event emitter.
8+
*/
9+
export interface EventEmitter<T extends unknown[]> {
10+
/**
11+
* registers a listener for this event.
12+
* @param callback the function to call when the event fires
13+
* @returns a cleanup function that removes the listener
14+
*/
15+
listen: (callback: Callback<T>) => () => void
16+
/**
17+
* emits the event, calling all registered listeners synchronously.
18+
* @param args the arguments to pass to listeners
19+
*/
20+
emit: (...args: T) => void
21+
}
22+
23+
/**
24+
* creates a typed event emitter.
25+
* uses a Set internally for O(1) add/remove operations.
26+
*
27+
* @returns an event emitter with listen and emit methods
28+
* @example
29+
* ```ts
30+
* const onProgress = createEventEmitter<[current: number, total: number]>();
31+
*
32+
* // in a Vue component:
33+
* onUnmounted(onProgress.listen((current, total) => {
34+
* console.log(`${current}/${total}`);
35+
* }));
36+
*
37+
* // elsewhere:
38+
* onProgress.emit(5, 10);
39+
* ```
40+
*/
41+
export function createEventEmitter<T extends unknown[]>(): EventEmitter<T> {
42+
let listener: Callback<T> | Callback<T>[] | undefined
43+
44+
return {
45+
listen(callback) {
46+
let closed = false
47+
48+
if (listener === undefined) {
49+
listener = callback
50+
} else if (typeof listener === 'function') {
51+
listener = [listener, callback]
52+
} else {
53+
listener = listener.concat(callback)
54+
}
55+
56+
return () => {
57+
if (closed) {
58+
return
59+
}
60+
61+
closed = true
62+
63+
if (listener === undefined) {
64+
return
65+
}
66+
67+
if (listener === callback) {
68+
listener = undefined
69+
} else if (typeof listener !== 'function') {
70+
const index = listener.indexOf(callback)
71+
if (index !== -1) {
72+
if (listener.length === 2) {
73+
// ^ flips the bit, it's either 0 or 1 here.
74+
listener = listener[index ^ 1]
75+
} else {
76+
listener = listener.toSpliced(index, 1)
77+
}
78+
}
79+
}
80+
}
81+
},
82+
emit(...args) {
83+
if (listener === undefined) {
84+
return false
85+
}
86+
if (typeof listener === 'function') {
87+
listener.apply(this, args)
88+
} else {
89+
for (let idx = 0, len = listener.length; idx < len; idx++) {
90+
const cb = listener[idx]
91+
if (cb) cb.apply(this, args)
92+
}
93+
}
94+
},
95+
}
96+
}

app/bundler/events.ts

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

app/bundler/lib/bundler.ts

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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.emit({ 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 = isBrotliSupported
245+
? chunks.reduce((acc, c) => acc + c.brotliSize!, 0)
246+
: undefined
247+
const totalZstdSize = zstdInitialized
248+
? chunks.reduce((acc, c) => acc + c.zstdSize!, 0)
249+
: undefined
250+
251+
await bundle.close()
252+
253+
return {
254+
chunks,
255+
size: totalSize,
256+
gzipSize: totalGzipSize,
257+
brotliSize: totalBrotliSize,
258+
zstdSize: totalZstdSize,
259+
exports: entryChunk.exports,
260+
isCjs,
261+
}
262+
}
263+
264+
// #endregion

0 commit comments

Comments
 (0)