diff --git a/gdf.js b/gdf.js index 4f6eb87..a97255e 100755 --- a/gdf.js +++ b/gdf.js @@ -50,6 +50,12 @@ const ALPINE_MAPPINGS = { const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) // Generate Dockerfile class +const BUN_SCRIPT_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'] + +// bun flags that take a separate value, e.g. --preload ./instrument.ts +const BUN_VALUE_FLAGS = ['--preload', '-r', '--require', '--import', '--config', '-c', '--cwd', + '--env-file', '--tsconfig-override', '--define', '-d', '--loader', '-l', '--conditions'] + export class GDF { static templates = path.join(__dirname, 'templates') static runners = [] @@ -836,6 +842,119 @@ export class GDF { return this.bun ? 'bunx' : 'npx' } + // Is filename an existing script file in the app directory? + bunScript(filename) { + if (!BUN_SCRIPT_EXTENSIONS.includes(path.posix.extname(filename))) return false + return !!fs.statSync(path.join(this._appdir, filename), { throwIfNoEntry: false })?.isFile() + } + + // The TypeScript source of a missing foo.js, if there is one + bunSource(entry) { + if (fs.existsSync(path.join(this._appdir, entry))) return entry + const extension = path.posix.extname(entry) + const base = extension ? entry.slice(0, -extension.length) : entry + const sources = extension === '.cjs' ? ['.cts'] : ['.ts', '.tsx', '.mts'] + return sources.map(ext => base + ext).find(file => this.bunScript(file)) || entry + } + + // Could a script produce a declared entry point that doesn't exist yet? + get bunBuildsEntry() { + const { build, prepare, postinstall } = this.#pj.scripts || {} + return !!(build || prepare || postinstall) + } + + // A declared foo.js may be missing because only foo.ts exists. Bun 1.2+ + // runs foo.ts in that case, older versions fail, so point at the source, + // unless foo.js could be build output. + bunEntry(entry) { + return this.bunBuildsEntry ? entry : this.bunSource(entry) + } + + // The directory and file run by the last `bun` command in the dev script, + // e.g. src/server.ts in "bun run codegen.ts && bun --watch src/server.ts", + // or apps/api and src/index.ts in "cd apps/api && bun --watch src/index.ts" + get bunDevEntry() { + const devScript = this.#pj.scripts?.dev + if (!devScript) return null + + let entry = null + let command // the current command, null once its file is found + let skip = false // the next token is a flag value or redirect target + let dir = '.' // null when the directory can't be followed + let listDir = dir // directory at the start of the current command list + const dirs = [] // directories outside of (subshells) + + for (const token of ShellQuote.parse(devScript)) { + if (typeof token !== 'string') { + if (token.comment !== undefined) continue + if (token.op === 'glob') { + if (command === 'bun') command = null + continue + } + if (/^\d*[<>]/.test(token.op)) { + skip = true + continue + } + + // a bare cd changes to $HOME + if (command === 'cd') dir = null + + if (token.op === '(') { + dirs.push(dir) + } else if (token.op === ')') { + dir = dirs.length ? dirs.pop() : null + } else if (token.op === '&' || token.op === '|') { + // background lists and pipelines run in subshells + dir = listDir + } + + if (token.op === ';' || token.op === '&') listDir = dir + command = undefined + skip = false + } else if (skip) { + skip = false + } else if (command === undefined) { + if (!/^\w+=/.test(token)) command = token + } else if (command === 'cd') { + // only follow relative directories that stay within the app + const target = dir === null || path.posix.isAbsolute(token) || /^[~-]/.test(token) + ? null + : path.posix.normalize(path.posix.join(dir, token)).replace(/\/+$/, '') || '.' + dir = target === null || target === '..' || target.startsWith('../') ? null : target + command = null + } else if (command === 'bun') { + if (token.startsWith('-')) { + skip = BUN_VALUE_FLAGS.includes(token) + } else if (token !== 'run') { + // the first file is the entry; anything after it is its argv + const file = dir === null ? null : this.bunSource(path.posix.join(dir, token)) + if (file && this.bunScript(file)) entry = { dir, file: path.posix.relative(dir, file) } + command = null + } + } + } + + return entry + } + + // Bun apps without a start script, main, or module with type: module. + // An existing index.ts is what has always been used here, so it wins; + // otherwise look for the entry (e.g. src/index.ts in the Elysia template). + get bunStartCommand() { + if (this.bunScript('index.ts')) return ['bun', 'index.ts'] + + // a declared module that doesn't exist yet is likely build output + const module = this.#pj.module && this.bunEntry(this.#pj.module) + if (module && (this.bunScript(module) || this.bunBuildsEntry)) return ['bun', module] + + // a dev script that changes directory names the app explicitly + const dev = this.bunDevEntry + if (dev && dev.dir !== '.') return ['bun', '--cwd', dev.dir, dev.file] + if (this.bunScript('src/index.ts')) return ['bun', 'src/index.ts'] + + return ['bun', dev?.file || module || 'index.ts'] + } + // command to start the web server get startCommand() { if (this.options.cmd) return this.options.cmd @@ -883,13 +1002,13 @@ export class GDF { } else if (this.#pj.scripts?.start) { return [this.packager, 'run', 'start'] } else if (this.#pj.type === 'module' && this.#pj.module) { - return [this.packager === 'bun' ? 'bun' : 'node', this.#pj.module] + return this.packager === 'bun' ? ['bun', this.bunEntry(this.#pj.module)] : ['node', this.#pj.module] } else if (this.#pj.main) { - return [this.packager === 'bun' ? 'bun' : 'node', this.#pj.main] + return this.packager === 'bun' ? ['bun', this.bunEntry(this.#pj.main)] : ['node', this.#pj.main] } else if (this.svelte) { return [this.bun ? 'bun' : 'node', './build/index.js'] } else if (this.packager === 'bun') { - return ['bun', 'index.ts'] + return this.bunStartCommand } else { return ['node', 'index.js'] } diff --git a/test/gdf.test.js b/test/gdf.test.js index fe074c5..b736c13 100644 --- a/test/gdf.test.js +++ b/test/gdf.test.js @@ -656,6 +656,7 @@ describe('gdf: start command and port', function() { it('uses bun for module, main and the default entry point', async function() { sandbox().stub('bun', 'echo 1.1.20\n') sandbox().write('bun.lockb', '') + sandbox().write('app.ts', '') let gdf = await sandbox().run({ main: 'app.ts' }) expect(gdf.startCommand).to.deep.equal(['bun', 'app.ts']) @@ -664,6 +665,237 @@ describe('gdf: start command and port', function() { expect(gdf.startCommand).to.deep.equal(['bun', 'index.ts']) }) + it('resolves a Bun module entry point to its TypeScript file when type is unspecified', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + + const gdf = await sandbox().run({ module: 'src/index.js' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.ts']) + }) + + it('keeps a Bun main entry point that has not been built yet', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + + const gdf = await sandbox().run({ main: 'dist/index.js' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'dist/index.js']) + }) + + it('keeps an existing Bun index.ts over module and dev script entries', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('index.ts', '') + sandbox().write('lib/index.js', '') + sandbox().write('src/app.css', '') + + let gdf = await sandbox().run({ module: 'lib/index.js' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'index.ts']) + + gdf = await sandbox().run({ module: 'dist/index.mjs' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'index.ts']) + + gdf = await sandbox().run({ scripts: { dev: 'bun --watch index.ts & bunx tailwindcss -i src/app.css' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'index.ts']) + }) + + it('skips Bun dev script arguments that are not script files', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + sandbox().write('src/app.css', '') + sandbox().write('prisma/schema.prisma', '') + + let gdf = await sandbox().run({ scripts: { dev: 'bun --watch src/server.ts --port $PORT' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/server.ts']) + + gdf = await sandbox().run({ scripts: { dev: 'bun --watch src/server.ts & bunx tailwindcss -i src/app.css & bunx prisma studio' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/server.ts']) + }) + + it('keeps a Bun main entry point that is compiled from TypeScript', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('index.ts', '') + + const gdf = await sandbox().run({ main: 'index.js', scripts: { build: 'tsc' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'index.js']) + }) + + it('uses the file run by the last bun command in the dev script', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + sandbox().write('scripts/codegen.ts', '') + sandbox().write('drizzle.config.ts', '') + sandbox().write('instrument.ts', '') + + for (const dev of [ + 'bun run scripts/codegen.ts && bun --watch src/server.ts', + 'bunx drizzle-kit push --config drizzle.config.ts && bun --watch src/server.ts', + 'bun --preload ./instrument.ts src/server.ts', + 'NODE_ENV=development bun --watch src/server.ts' + ]) { + const gdf = await sandbox().run({ scripts: { dev } }) + expect(gdf.startCommand, dev).to.deep.equal(['bun', 'src/server.ts']) + } + }) + + it('runs a Bun dev entry point in the directory the dev script changes to', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + sandbox().write('apps/api/src/index.ts', '') + + let gdf = await sandbox().run({ scripts: { dev: 'cd apps/api && bun --watch src/index.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', '--cwd', 'apps/api', 'src/index.ts']) + + gdf = await sandbox().run({ scripts: { dev: 'cd apps && cd api && bun --watch ./src/index.js' } }) + expect(gdf.startCommand).to.deep.equal(['bun', '--cwd', 'apps/api', 'src/index.ts']) + + expect(sandbox().read('Dockerfile')).to.include('CMD [ "bun", "--cwd", "apps/api", "src/index.ts" ]') + }) + + it('treats arguments after the Bun dev entry point as its argv', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + sandbox().write('drizzle.config.ts', '') + + for (const dev of [ + 'bun --watch src/server.ts --config drizzle.config.ts', + 'bun --watch src/server.ts > log.txt 2>&1', + 'bun --watch src/server.ts # drizzle.config.ts' + ]) { + const gdf = await sandbox().run({ scripts: { dev } }) + expect(gdf.startCommand, dev).to.deep.equal(['bun', 'src/server.ts']) + } + }) + + it('does not follow cd out of Bun dev script subshells and background jobs', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('server.ts', '') + sandbox().write('web/server.ts', '') + + for (const dev of [ + '(cd web && vite) & bun --watch server.ts', + 'cd web && vite & bun --watch server.ts', + 'cd ./ && bun --watch server.ts' + ]) { + const gdf = await sandbox().run({ scripts: { dev } }) + expect(gdf.startCommand, dev).to.deep.equal(['bun', 'server.ts']) + } + + const gdf = await sandbox().run({ scripts: { dev: 'cd web/ && bun --watch server.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', '--cwd', 'web', 'server.ts']) + }) + + it('resolves a missing Bun main or module to its TypeScript source unless a script builds it', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + sandbox().write('src/worker.cts', '') + + let gdf = await sandbox().run({ type: 'module', module: 'src/index.js' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.ts']) + + gdf = await sandbox().run({ main: 'src/worker.cjs' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/worker.cts']) + + gdf = await sandbox().run({ type: 'module', module: 'src/index.js', scripts: { build: 'tsc' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.js']) + + gdf = await sandbox().run({ main: 'src/index.js', scripts: { prepare: 'tsc' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.js']) + }) + + it('prefers a Bun module built by a script over the dev script entry point', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + + const gdf = await sandbox().run({ + module: 'dist/index.mjs', + scripts: { build: 'bun build src/server.ts --outdir dist', dev: 'bun --watch src/server.ts' } + }) + expect(gdf.startCommand).to.deep.equal(['bun', 'dist/index.mjs']) + }) + + it('ignores Bun dev script entry points outside the app', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + + for (const dev of ['cd .. && bun --watch src/index.ts', 'cd /srv && bun src/index.ts', 'cd ~ && bun src/index.ts', 'cd && bun src/index.ts']) { + const gdf = await sandbox().run({ scripts: { dev } }) + expect(gdf.startCommand, dev).to.deep.equal(['bun', 'src/index.ts']) + } + }) + + it('prefers a Bun src/index.ts over the dev script entry point', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + sandbox().write('src/dev.ts', '') + + const gdf = await sandbox().run({ scripts: { dev: 'bun --watch src/dev.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.ts']) + }) + + it('resolves a Bun module entry point to its TSX file', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.tsx', '') + + const gdf = await sandbox().run({ module: 'src/index.js' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.tsx']) + }) + + it('keeps a Bun module entry point that has not been built yet', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + + const gdf = await sandbox().run({ module: 'dist/index.mjs' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'dist/index.mjs']) + }) + + it('skips a Bun module entry point that does not exist', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + + const gdf = await sandbox().run({ module: 'dist/index.mjs', scripts: { dev: 'bun --watch src/server.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/server.ts']) + }) + + it('uses the Bun dev entry point without --watch', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + + const gdf = await sandbox().run({ scripts: { dev: 'bun --watch src/server.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/server.ts']) + }) + + it('runs a Bun start script when one is defined', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + + const gdf = await sandbox().run({ scripts: { start: 'bun src/server.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'run', 'start']) + }) + + it('uses src/index.ts when a Bun project has no module or dev entry point', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + + const gdf = await sandbox().run({}) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.ts']) + }) + it('starts standalone next.js with server.js', async function() { for (const config of ['next.config.js', 'next.config.mjs']) { fs.rmSync('next.config.js', { force: true })