Skip to content

Instantly share code, notes, and snippets.

@barelyhuman
Last active July 5, 2024 04:55
Show Gist options
  • Save barelyhuman/328707909e055d0fc6c20e750257d2f9 to your computer and use it in GitHub Desktop.
Save barelyhuman/328707909e055d0fc6c20e750257d2f9 to your computer and use it in GitHub Desktop.

Simple Bundler

Simple bundler for JS Packages and Typescript type declarations

I don't like the complexity of the existing solutions and would like the most minimal code for running something.

This is not for your production use as a lot of cases and errors will not be pretty printed for you. If you wish for a more polished and production grade bundler, you'll have better luck with the following

#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// MIT License
// Copyright (c) 2024 reaper <ahoy@barelyhuman.dev>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
/**
* Simple script to bundle and
* emit types
* @module
*/
import { CONSTANTS, createContext } from 'esbuild-multicontext'
import fs from 'node:fs'
import path, { basename, dirname, join } from 'node:path'
import { rollup } from 'rollup'
import { dts } from 'rollup-plugin-dts'
import ts from 'typescript'
await defineBuild({
input: ['src/index.ts'],
tsconfig: './tsconfig.json',
outdir: 'dist',
tmpDir: '.tmp-build',
dtsInDev: true,
isDev: process.argv.includes('--dev'),
esbuild: {
platform: 'node',
},
}).build()
/**
* @param {object} options
* @param {string} options.input
* @param {string} options.tsconfig
* @param {string} options.outdir
* @param {string} options.tmpDir
* @param {boolean} options.dtsInDev
* @param {boolean} options.isDev
* @param {import("esbuild").BuildOptions} options.esbuild
* @returns
*/
function defineBuild(options) {
return {
...options,
build: async () => {
process.on('SIGINT', function () {
console.log('Cleaning Up')
if (fs.existsSync(options.tmpDir)) {
fs.rmSync(options.tmpDir, { recursive: true, force: true })
}
process.exit()
})
const ctx = await bundleCode({
watch: true,
buildConfig: options,
})
const genTypes = throttle(async ({ cleanup = false } = {}) => {
console.log('Generating Type Bundle')
generateTypes({ buildConfig: options })
await bundleTypes({ buildConfig: options })
cleanup && fs.rmSync(options.tmpDir, { recursive: true, force: true })
})
if (options.isDev) {
await ctx.watch()
}
if (options.dtsInDev) {
ctx.hook('esm:complete', () => {
genTypes()
})
ctx.hook('cjs:complete', () => {
genTypes()
})
}
await ctx.build()
await genTypes({ cleanup: true })
if (!options.isDev) {
await ctx.dispose()
}
},
}
}
async function bundleCode({ buildConfig } = {}) {
const buildCtx = createContext()
buildCtx.add('cjs', {
...buildConfig.esbuild,
entryPoints: [].concat(buildConfig.input),
format: 'cjs',
bundle: true,
outExtension: {
'.js': '.cjs',
},
outdir: join(buildConfig.outdir, 'cjs'),
})
buildCtx.add('esm', {
...buildConfig.esbuild,
entryPoints: [].concat(buildConfig.input),
format: 'esm',
bundle: true,
outExtension: {
'.js': '.mjs',
},
outdir: join(buildConfig.outdir, 'esm'),
})
buildCtx.hook('esm:complete', () => {
process.stdout.write('[custom-builder] ESM Built\n')
})
buildCtx.hook('cjs:complete', () => {
process.stdout.write('[custom-builder] CJS Built\n')
})
buildCtx.hook('esm:error', async errors => {
process.stdout.write('[custom-builder] ESM Error:\n')
errors.map(x => console.error(x))
})
buildCtx.hook('cjs:error', async errors => {
process.stdout.write('[custom-builder] CJS Error:\n')
errors.map(x => console.error(x))
})
buildCtx.hook(CONSTANTS.BUILD_COMPLETE, () => {
console.log('Bundled')
})
buildCtx.hook(CONSTANTS.ERROR, errors => {
console.error(errors)
})
return buildCtx
}
function generateTypes({ buildConfig } = {}) {
const createdFiles = {}
const baseConfig = {
allowJs: true,
declaration: true,
emitDeclarationOnly: true,
}
const tsconfigExists = buildConfig.tsconfig
? fs.existsSync(buildConfig.tsconfig)
: false
const includeDirs = buildConfig.input
.map(d => d.split(path.sep)[0])
.map(d => `${d}/**/*`)
let tsconfigRaw = {
compilerOptions: {
target: 'esnext',
module: 'esnext',
},
include: includeDirs,
exclude: ['node_modules/*'],
}
if (tsconfigExists) {
tsconfigRaw = JSON.parse(fs.readFileSync(buildConfig.tsconfig, 'utf-8'))
}
const host = ts.createCompilerHost(ts.getDefaultCompilerOptions())
const tsOptions = ts.parseJsonConfigFileContent(
{
...tsconfigRaw,
compilerOptions: {
...tsconfigRaw.compilerOptions,
...baseConfig,
noEmit: false,
},
},
host,
'.',
ts.getDefaultCompilerOptions()
)
if (tsOptions.errors.length) {
console.error(tsOptions.errors)
return
}
const fileNames = Array.from(
tsOptions.fileNames.concat(buildConfig.input).reduce((acc, item) => {
if (acc.has(item)) return acc
acc.add(item)
return acc
}, new Set())
)
host.writeFile = (fileName, contents) => (createdFiles[fileName] = contents)
const program = ts.createProgram(fileNames, tsOptions.options, host)
program.emit()
fs.mkdirSync(buildConfig.tmpDir, { recursive: true })
fileNames.forEach(file => {
const dts = getDTSName(file)
const fileKeyPath = Object.keys(createdFiles)
.map(k => {
return {
rel: path.relative(process.cwd(), k),
original: k,
}
})
.find(obj => {
return obj.rel === dts
})
const contents = createdFiles[fileKeyPath.original]
if (!contents) {
console.warn(`nothing to emit for file ${file}`)
return
}
const destDir = join(buildConfig.tmpDir, dirname(fileKeyPath.rel))
const destFile = join(buildConfig.tmpDir, fileKeyPath.rel)
fs.mkdirSync(destDir, { recursive: true })
fs.writeFileSync(destFile, createdFiles[fileKeyPath.original], 'utf8')
})
}
async function bundleTypes({ buildConfig }) {
await Promise.all(
buildConfig.input.map(async entryPoint => {
const entryName = getDTSName(entryPoint)
const bareName = basename(entryPoint).replace(
path.extname(entryPoint),
''
)
const entryPath = join(buildConfig.tmpDir, entryName)
const rollupBundle = await rollup({
input: entryPath,
plugins: [dts()],
})
await rollupBundle.write({
file: join(buildConfig.outdir, `esm/${bareName}.d.mts`),
format: 'es',
})
await rollupBundle.write({
file: join(buildConfig.outdir, `cjs/${bareName}.d.cts`),
format: 'cjs',
})
await rollupBundle.close()
})
)
}
function getDTSName(filename) {
return filename.replace(/(\.(js|ts))$/, '.d.ts')
}
/**
* @template T
* @param {T} fn
* @returns {T}
*/
function throttle(fn) {
let lastInvoked
return (...args) => {
if (lastInvoked) {
if (Date.now() - lastInvoked < 2000) {
lastInvoked = Date.now()
return
}
}
lastInvoked = Date.now()
fn(...args)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment