Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 122 additions & 3 deletions gdf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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']
}
Expand Down
232 changes: 232 additions & 0 deletions test/gdf.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -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 })
Expand Down
Loading