Skip to content

Commit 8b1663c

Browse files
committed
wip
1 parent 4af3271 commit 8b1663c

30 files changed

Lines changed: 5631 additions & 7 deletions

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

0 commit comments

Comments
 (0)