Skip to content
Open
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,29 @@ app.listen({ port: process.env.PORT || 3000 }, (err) => {
})
```

### eject

Run `fastify eject` inside your application directory to generate a standalone
server. ESM is detected from `"type": "module"` in `package.json`, and TypeScript
is detected from `src/app.ts`. Use `--esm`, `--esm=false`, or `--lang=js|ts` to
override detection.

For generated JavaScript projects, ejection creates `server.js` and changes the
standard scripts to `node server.js` and `node --watch server.js`. TypeScript
projects receive `src/server.ts`, run the compiled `dist/server.js`, and retain
the TypeScript compiler watch process. Unmodified generated TypeScript test
configurations and scripts are updated to run compiled tests.

Ejection replaces unmodified generated test helpers with standalone helpers,
adds `close-with-grace` as a runtime dependency, and removes `fastify-cli` from
both dependency sections when no scripts or source files still reference it.
Run your package manager's install command afterward to update the installed
dependencies and lockfile.

Custom scripts, helpers, and TypeScript configurations are preserved. If they
still use the CLI, its dependency is retained and a message explains why.
Ejection refuses to overwrite a customized server file.

### generate

`fastify-cli` can also help with generating some project scaffolding to
Expand Down
176 changes: 153 additions & 23 deletions eject.js
Original file line number Diff line number Diff line change
@@ -1,48 +1,178 @@
'use strict'

const path = require('node:path')
const fs = require('node:fs/promises')
const generify = require('generify')
const parseArgs = require('./lib/parse-args')
const log = require('./log')
const cliPkg = require('./package.json')

function eject (dir, template) {
function normalize (source) {
return source.replace(/\r\n/g, '\n').trimEnd()
}

async function readPackage (dir) {
try {
return JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8'))
} catch (err) {
if (err.code === 'ENOENT') return null
throw err
}
}

function copyTemplate (dir, template) {
return new Promise((resolve, reject) => {
generify(path.join(__dirname, 'templates', template), dir, {}, function (file) {
log('debug', `generated ${file}`)
}, function (err) {
if (err) {
return reject(err)
}
if (err) return reject(err)
resolve()
})
})
}

function cli (args) {
const opts = parseArgs(args, {
options: {
lang: { type: 'string' },
esm: { type: 'boolean' }
async function usesCli (dir) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist') continue
const file = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (await usesCli(file)) return true
} else if (entry.isFile() && /\.[cm]?[jt]sx?$/.test(entry.name)) {
if ((await fs.readFile(file, 'utf8')).includes('fastify-cli')) return true
}
})
}
return false
}

let template
if (opts.lang === 'ts' || opts.lang === 'typescript') {
template = 'eject-ts'
} else {
if (opts.esm) {
template = 'eject-esm'
} else {
template = 'eject'
async function eject (dir, template) {
const pkg = await readPackage(dir)
const typescript = template === 'eject-ts' || template === 'eject-ts-esm'
const esm = template === 'eject-esm' || template === 'eject-ts-esm'
const appTemplate = `app${typescript ? '-ts' : ''}${esm ? '-esm' : ''}`
const helperName = `helper.${typescript ? 'ts' : 'js'}`
const helperPath = path.join(dir, 'test', helperName)
let replaceHelper = false

if (pkg) {
try {
const existing = await fs.readFile(helperPath, 'utf8')
const generated = await fs.readFile(path.join(__dirname, 'templates', appTemplate, 'test', helperName), 'utf8')
replaceHelper = normalize(existing) === normalize(generated)
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}

return eject(process.cwd(), template).catch(function (err) {
if (err) {
log('error', err.message)
process.exit(1)
const serverName = typescript ? path.join('src', 'server.ts') : 'server.js'
try {
const existing = await fs.readFile(path.join(dir, serverName), 'utf8')
const generated = await fs.readFile(path.join(__dirname, 'templates', template, serverName), 'utf8')
if (normalize(existing) !== normalize(generated)) {
throw new Error(`${serverName} already exists and has been customized; move it before ejecting`)
}
})
} catch (err) {
if (err.code !== 'ENOENT') throw err
}

// Validate the manifest and existing server before generating any files.
await copyTemplate(dir, template)
if (!pkg) return

if (replaceHelper) {
await fs.copyFile(path.join(__dirname, 'templates', `${template}-helper`, helperName), helperPath)
}

pkg.scripts ||= {}
const replacements = typescript
? {
start: ['npm run build:ts && fastify start -l info dist/app.js', 'npm run build:ts && node dist/server.js'],
'dev:start': ['fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js', 'node --watch dist/server.js'],
dev: ['fastify start -w -l info src/app.ts', 'npm run build:ts && concurrently -k -p "[{name}]" -n "TypeScript,App" -c "yellow.bold,cyan.bold" "npm:watch:ts" "npm:dev:start"']
}
: {
start: ['fastify start -l info app.js', 'node server.js'],
dev: ['fastify start -w -l info -P app.js', 'node --watch server.js']
}

for (const [name, [before, after]] of Object.entries(replacements)) {
if (pkg.scripts[name] === before || (name === 'start' && pkg.scripts[name] === undefined) || (!typescript && name === 'dev' && pkg.scripts[name] === undefined)) {
pkg.scripts[name] = after
if (typescript && name === 'dev') pkg.scripts['dev:start'] ||= 'node --watch dist/server.js'
}
}

if (typescript) {
const appConfig = path.join(dir, 'tsconfig.json')
try {
const existing = await fs.readFile(appConfig, 'utf8')
const generated = await fs.readFile(path.join(__dirname, 'templates', appTemplate, 'tsconfig.json'), 'utf8')
if (normalize(existing) === normalize(generated)) {
const config = JSON.parse(existing)
config.compilerOptions.rootDir = 'src'
config.compilerOptions.skipLibCheck ??= true
await fs.writeFile(appConfig, JSON.stringify(config, null, 2) + '\n')
}
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
const testConfig = path.join(dir, 'test', 'tsconfig.json')
try {
const existing = await fs.readFile(testConfig, 'utf8')
const generated = await fs.readFile(path.join(__dirname, 'templates', appTemplate, 'test', 'tsconfig.json'), 'utf8')
const testScript = esm
? 'npm run build:ts && tsc -p test/tsconfig.json && FASTIFY_AUTOLOAD_TYPESCRIPT=1 node --test --experimental-test-coverage --loader ts-node/esm test/**/*.ts'
: 'npm run build:ts && tsc -p test/tsconfig.json && c8 node --test -r ts-node/register "test/**/*.ts"'
if (normalize(existing) === normalize(generated) && pkg.scripts.test === testScript) {
await fs.copyFile(path.join(__dirname, 'templates', `${template}-helper`, 'tsconfig.json'), testConfig)
pkg.scripts.test = `npm run build:ts && tsc -p test/tsconfig.json && ${esm ? 'node --test --experimental-test-coverage' : 'c8 node --test'} "dist/test/**/*.test.js"`
}
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}

pkg.dependencies ||= {}
if (!pkg.dependencies['close-with-grace']) {
pkg.dependencies['close-with-grace'] = pkg.devDependencies?.['close-with-grace'] || cliPkg.dependencies['close-with-grace']
if (pkg.devDependencies) delete pkg.devDependencies['close-with-grace']
}

const cliScripts = Object.values(pkg.scripts).some(script => /\bfastify\b/.test(script))
if (!cliScripts && !await usesCli(dir)) {
delete pkg.dependencies['fastify-cli']
if (pkg.devDependencies) delete pkg.devDependencies['fastify-cli']
} else {
log('info', 'keeping fastify-cli: custom scripts or source files still use it')
}
await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
log('info', 'ejected application; run your package manager install command to update dependencies and the lockfile')
}

async function cli (args) {
try {
const opts = parseArgs(args, {
options: {
lang: { type: 'string' },
esm: { type: 'boolean' }
}
})
const dir = process.cwd()
const pkg = await readPackage(dir)
const esm = opts.esm === undefined ? pkg?.type === 'module' : opts.esm
let typescript = opts.lang === 'ts' || opts.lang === 'typescript'
if (opts.lang === undefined) {
try {
await fs.access(path.join(dir, 'src', 'app.ts'))
typescript = true
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}
await eject(dir, `eject${typescript ? '-ts' : ''}${esm ? '-esm' : ''}`)
} catch (err) {
log('error', err.message)
process.exit(1)
}
}

module.exports = {
Expand Down
24 changes: 18 additions & 6 deletions help/eject.txt
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
Usage: fastify eject

Turns your application into a standalone executable with a server.js file being added.
Turns your application into a standalone executable with server.js (JavaScript)
or src/server.ts (TypeScript) being added.

You can set the port to listen on with the PORT environment variable (default to 3000).
The module format is detected from package.json's type field. TypeScript is
detected from src/app.ts. Explicit --esm and --lang options override detection.

Generated start and dev scripts are updated to use Node.js. Unmodified generated
test helpers are replaced with standalone helpers, and fastify-cli is removed
from dependencies if no scripts or source files still reference it.

Custom scripts, helpers, and TypeScript configurations are preserved. Existing
custom server files must be moved before ejecting. Run your package manager's
install command afterward to update dependencies and the lockfile.

You can set the port with PORT (default: 3000).

OPTS

--esm
use the ESM template
--esm, --esm=false
override the detected module format

--lang=ts, --lang=typescript
use the TypeScript template
--lang=ts, --lang=typescript, --lang=js
override the detected language
20 changes: 20 additions & 0 deletions templates/eject-esm-helper/helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Fastify from 'fastify'
import fp from 'fastify-plugin'
import App from '../app.js'

// Fill in this config with the options needed for testing the application.
function config () {
return { skipOverride: true }
}

// Build an application without starting a server, and close it after the test.
async function build (t) {
const { skipOverride, ...options } = config()
const app = Fastify()
t.after(() => app.close())
app.register(skipOverride ? fp(App) : App, options)
await app.ready()
return app
}

export { config, build }
2 changes: 1 addition & 1 deletion templates/eject-esm/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ closeWithGrace({ delay: process.env.FASTIFY_CLOSE_GRACE_DELAY || 500 }, async fu
})

// Start listening
app.listen({ port: process.env.PORT || 3000 }, (err) => {
app.listen({ port: Number(process.env.PORT ?? 3000) }, (err) => {
if (err) {
app.log.error(err)
process.exit(1)
Expand Down
22 changes: 22 additions & 0 deletions templates/eject-helper/helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use strict'

const Fastify = require('fastify')
const fp = require('fastify-plugin')
const App = require('../app.js')

// Fill in this config with the options needed for testing the application.
function config () {
return { skipOverride: true }
}

// Build an application without starting a server, and close it after the test.
async function build (t) {
const { skipOverride, ...options } = config()
const app = Fastify()
t.after(() => app.close())
app.register(skipOverride ? fp(App) : App, options)
await app.ready()
return app
}

module.exports = { config, build }
23 changes: 23 additions & 0 deletions templates/eject-ts-esm-helper/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Fastify from 'fastify'
import fp from 'fastify-plugin'
import type { TestContext as NodeTestContext } from 'node:test'
import App from '../src/app.js'

export type TestContext = Pick<NodeTestContext, 'after'>

// Fill in this config with the options needed for testing the application.
function config () {
return { skipOverride: true }
}

// Build an application without starting a server, and close it after the test.
async function build (t: TestContext) {
const { skipOverride, ...options } = config()
const app = Fastify()
t.after(async () => { await app.close() })
app.register(skipOverride ? fp(App) : App, options)
await app.ready()
return app
}

export { config, build }
9 changes: 9 additions & 0 deletions templates/eject-ts-esm-helper/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "..",
"outDir": "../dist",
"noEmit": false
},
"include": ["../src/**/*.ts", "**/*.ts"]
}
22 changes: 22 additions & 0 deletions templates/eject-ts-esm/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Fastify from 'fastify'
import closeWithGrace from 'close-with-grace'
import App from './app.js'

try {
process.loadEnvFile()
} catch {}

const app = Fastify({ logger: true })
app.register(App)

closeWithGrace({ delay: Number(process.env.FASTIFY_CLOSE_GRACE_DELAY) || 500 }, async ({ err }) => {
if (err) app.log.error(err)
await app.close()
})

app.listen({ port: Number(process.env.PORT ?? 3000) }, (err) => {
if (err) {
app.log.error(err)
process.exit(1)
}
})
23 changes: 23 additions & 0 deletions templates/eject-ts-helper/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Fastify from 'fastify'
import fp from 'fastify-plugin'
import type { TestContext as NodeTestContext } from 'node:test'
import App from '../src/app'

export type TestContext = Pick<NodeTestContext, 'after'>

// Fill in this config with the options needed for testing the application.
function config () {
return { skipOverride: true }
}

// Build an application without starting a server, and close it after the test.
async function build (t: TestContext) {
const { skipOverride, ...options } = config()
const app = Fastify()
t.after(async () => { await app.close() })
app.register(skipOverride ? fp(App) : App, options)
await app.ready()
return app
}

export { config, build }
Loading
Loading