Skip to content

Instantly share code, notes, and snippets.

@zerkalica
Created July 24, 2024 10:20
Show Gist options
  • Save zerkalica/ef2890acf716f9ce2cadbec766f89e41 to your computer and use it in GitHub Desktop.
Save zerkalica/ef2890acf716f9ce2cadbec766f89e41 to your computer and use it in GitHub Desktop.
npm builder for mol
// @ts-check
import url from 'node:url'
import { basename, join } from 'node:path'
import { stat, writeFile, readFile } from 'node:fs/promises'
import { spawn } from 'node:child_process'
/**
* @param {unknown} value
* @returns {value is import('esbuild').Plugin}
*/
function guard_defined(value) {
return value !== null && value !== undefined
}
/**
* @example mam/my/some.npm/build.mjs
* ```mjs
// @ts-check
import { GdSnapBuilder } from './builder.mjs'
const builder = new GdSnapBuilder(import.meta.url)
builder.globalName = () => '$gd_snap_npm'
await builder.build()
```
* @example mam/package.json
```json
{
...
"scripts": {
"postinstall": "mam mol/build",
"start": "node --enable-source-maps --trace-uncaught ./mol/build/-/node.js",
"start.demo": "node --enable-source-maps --trace-uncaught ./gd/snap.npm/cli.mjs --watch gd/demo",
"build": "node --enable-source-maps --trace-uncaught ./gd/snap.npm/cli.mjs",
}
...
```
*/
export class GdSnapBuilder {
/**
* @param {string} metaUrl
* @param {readonly string[]} args
*/
constructor(metaUrl, args = GdSnapBuilder.args) {
this.metaUrl = metaUrl
this.args = args
}
/** @type {string[]} */
// @ts-ignore
static args = typeof Deno !== 'undefined' ? Deno.args : process.argv.slice(2)
/** @type {() => string} */
// @ts-ignore
static cwd = typeof Deno !== 'undefined' ? Deno.cwd.bind(Deno) : process.cwd.bind(process)
// @ts-ignore
static isWin = typeof Deno !== 'undefined' ? Deno.build.os === 'windows' : process.platform === 'win32'
/**
* @param {string[]} argsRaw
* @param {Partial<Parameters<typeof spawn>[2] & { stderr: string }>} optsRaw
*/
static spawn(argsRaw, optsRaw) {
/**
* @type {Parameters<typeof spawn>[2] & { stderr: string }}
*/
const opts = {
stdio: 'inherit',
stderr: 'inherit',
shell: this.isWin,
...optsRaw,
}
const prog = argsRaw[0]
const args = argsRaw.slice(1)
console.log(opts.cwd, `"${argsRaw.join(' ')}"`)
/**
* @type Promise<number>
*/
const result = new Promise((resolve, rejectRaw) => {
const p = spawn(prog, args, opts)
/**
* @param {Error} error
*/
const reject = error => {
rejectRaw(
new Error(
`"${prog} ${args.join(' ')}" returns ${error} ${p.stderr ? `: ${p.stderr}` : ''}`,
// @ts-ignore
{ cause: { error, p } }
)
)
}
p.on('error', reject)
p.on('close', code => ((code ?? 0) > 0 ? reject(new Error('Exit code 1')) : resolve(code || 0)))
return p
})
return result
}
/**
* @param {readonly string[]} args
*/
static commands(args) {
const files = args.filter(arg => !arg.startsWith('--'))
const watch = args.some(arg => arg.includes('--watch'))
return { files, watch }
}
/**
* @param {{watch?: boolean, args?: readonly string[], cwd?: string} | undefined} rec
*/
static async run(rec) {
const cwd = rec?.cwd || this.cwd()
const { files, watch } = this.commands(rec?.args?.length ? rec?.args : this.args)
const allArgs = [join('gd', 'snap'), ...files]
if (files.some(arg => arg.includes('gd/demo'))) allArgs.push('gd/avatar')
const npmDirs = (
await Promise.all(
allArgs.map(arg =>
stat(join(cwd, arg + '.npm'))
.then(stat => (stat.isDirectory() ? arg + '.npm' : null))
.catch(() => null)
)
)
).filter(dir => dir !== null && dir !== undefined)
for (const path of npmDirs) {
const dir = join(cwd, path)
await this.spawn(['npm', 'install'], { cwd: dir })
if (!watch) await this.spawn(['npm', 'run', 'build'], { cwd: dir })
}
if (!watch) {
await this.spawn(['npm', 'start', ...files], { cwd })
await this.polyfill({ args: files, cwd })
return
}
return Promise.all([
...npmDirs.map(npmDir => (npmDir ? this.spawn(['npm', 'run', 'watch'], { cwd: join(cwd, npmDir) }) : null)),
this.spawn(['npm', 'start'], { cwd }),
])
}
static get selfPath() {
return url.fileURLToPath(new URL('.', import.meta.url))
}
static async coreJsPath() {
let selfPath = this.selfPath
let coreJsPath = ''
while (selfPath) {
coreJsPath = join(selfPath, 'node_modules', 'core-js', 'stable', 'index.js')
try {
const rec = await stat(coreJsPath)
if (rec.isFile()) return coreJsPath
} catch (e) {
}
selfPath = basename(selfPath)
}
throw new Error('core-js not found from ' + this.selfPath)
}
/**
* @param {{cwd: string, args: readonly string[]}} param0
*/
static async polyfill({ cwd, args }) {
const coreJsPath = await this.coreJsPath()
for (const prj_dir_relative of args) {
console.log('cwd', cwd)
const absWorkingDir = join(cwd, prj_dir_relative)
const file = 'web'
const builder = new GdSnapBuilder(`file://${absWorkingDir}/${file}.js`, this.args)
builder.keepNames = () => true
builder.entryPoints = () => [
{
in: `${builder.outDirAbsolute()}/${file}.js`,
out: `${file}.prod`,
},
{
in: coreJsPath,
out: `${file}.polyfill`,
},
]
await builder.build()
const index_file = join(builder.outDirAbsolute(), 'index.html')
let prev
try {
prev = (await readFile(index_file)).toString()
} catch (e) {
if (e.code !== 'ENOENT') throw e
}
if (prev) {
const next = prev.replace(/(<script src="web)(\.js"[^>]*><\/script>)/, '$1.polyfill$2\n$1.prod$2')
await writeFile(join(builder.outDirAbsolute(), 'index.prod.html'), next)
}
}
}
static async imports() {
return {
context: (await import('esbuild')).context,
resolve: (await import('esbuild-plugin-resolve')).default,
glsl: (await import('esbuild-plugin-glsl')).glsl,
}
}
imports() {
return GdSnapBuilder.imports()
}
absWorkingDir() {
return url.fileURLToPath(new URL('.', this.metaUrl))
}
get commands() {
return GdSnapBuilder.commands(this.args)
}
isWatch() {
return this.commands.watch
}
files() {
return this.commands.files
}
/**
* @returns {Record<string, string>}
*/
resolveDefaults() {
return {
fs: 'empty/object',
path: 'empty/object',
vertx: 'empty/object',
}
}
/**
* @returns {Record<string, string>}
*/
resolve() {
return {}
}
/**
* @returns {Promise<Array<import('esbuild').Plugin | null>>}
*/
async pluginsExtra() {
const { resolve, glsl } = await this.imports()
return [
resolve({ ...this.resolveDefaults(), ...this.resolve() }),
glsl({
minify: true,
}),
]
}
/**
* @returns {import('esbuild').BuildOptions['entryPoints']}
*/
entryPoints() {
return {
app: '.app.ts',
}
}
globalName() {
return ''
}
outdir() {
return '-'
}
outDirAbsolute() {
return join(this.absWorkingDir(), this.outdir())
}
minify() {
return true
}
keepNames() {
return false
}
/**
* @returns {Promise<import('esbuild').BuildOptions>}
*/
async config() {
const plugins = [...(await this.pluginsExtra())].filter(guard_defined)
return {
entryPoints: this.entryPoints(),
plugins,
globalName: this.globalName() || undefined,
// assetNames: '[name]-[hash]',
outdir: this.outdir(),
target: ['es2018'],
format: 'iife',
write: true,
sourcemap: 'linked',
// в esbuild keepNames: true + minify: false не работает,
// @see https://github.com/evanw/esbuild/issues/2149
minify: this.keepNames() ? true : this.minify(),
keepNames: this.keepNames(),
bundle: true,
allowOverwrite: true,
absWorkingDir: this.absWorkingDir(),
}
}
/**
* @type {import('esbuild').BuildContext | undefined}
*/
_context = undefined
/**
* @returns {Promise<import('esbuild').BuildContext>}
*/
async context() {
if (this._context) return this._context
const { context } = await this.imports()
const config = await this.config()
this._context = await context(config)
return this._context
}
async rebuild() {
const context = await this.context()
const data = await context.rebuild()
if (data.errors.length) console.error(data.errors[0])
if (data.warnings.length) console.warn(data.warnings[0])
if (data.outputFiles?.length) {
console.log('output files:')
console.log(data.outputFiles.map(file => file.path).join('\n'))
}
}
async done() {
const context = await this.context()
if (!this.isWatch()) {
console.log('exit', this.absWorkingDir())
context.dispose()
} else {
console.log('watch', this.absWorkingDir())
context.watch()
}
}
async build() {
await this.rebuild()
await this.done()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment