@adonisjs/assembler 6.1.3-3 → 6.1.3-5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bundler.ts ADDED
@@ -0,0 +1,267 @@
1
+ /*
2
+ * @adonisjs/assembler
3
+ *
4
+ * (c) AdonisJS
5
+ *
6
+ * For the full copyright and license information, please view the LICENSE
7
+ * file that was distributed with this source code.
8
+ */
9
+
10
+ import slash from 'slash'
11
+ import copyfiles from 'cpy'
12
+ import fs from 'node:fs/promises'
13
+ import type tsStatic from 'typescript'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { join, relative } from 'node:path'
16
+ import { cliui, type Logger } from '@poppinss/cliui'
17
+
18
+ import { run, parseConfig } from './helpers.js'
19
+ import type { BundlerOptions } from './types.js'
20
+
21
+ /**
22
+ * Instance of CLIUI
23
+ */
24
+ const ui = cliui()
25
+
26
+ /**
27
+ * The bundler class exposes the API to build an AdonisJS project.
28
+ */
29
+ export class Bundler {
30
+ #cwd: URL
31
+ #cwdPath: string
32
+ #ts: typeof tsStatic
33
+ #logger = ui.logger
34
+ #options: BundlerOptions
35
+
36
+ /**
37
+ * Getting reference to colors library from logger
38
+ */
39
+ get #colors() {
40
+ return this.#logger.getColors()
41
+ }
42
+
43
+ constructor(cwd: URL, ts: typeof tsStatic, options: BundlerOptions) {
44
+ this.#cwd = cwd
45
+ this.#cwdPath = fileURLToPath(this.#cwd)
46
+ this.#ts = ts
47
+ this.#options = options
48
+ }
49
+
50
+ #getRelativeName(filePath: string) {
51
+ return slash(relative(this.#cwdPath, filePath))
52
+ }
53
+
54
+ /**
55
+ * Cleans up the build directory
56
+ */
57
+ async #cleanupBuildDirectory(outDir: string) {
58
+ await fs.rm(outDir, { recursive: true, force: true, maxRetries: 5 })
59
+ }
60
+
61
+ /**
62
+ * Runs assets bundler command to build assets.
63
+ */
64
+ async #buildAssets(): Promise<boolean> {
65
+ const assetsBundler = this.#options.assets
66
+ if (!assetsBundler?.serve) {
67
+ return true
68
+ }
69
+
70
+ try {
71
+ this.#logger.info('compiling frontend assets', { suffix: assetsBundler.cmd })
72
+ await run(this.#cwd, {
73
+ stdio: 'inherit',
74
+ script: assetsBundler.cmd,
75
+ scriptArgs: [],
76
+ })
77
+ return true
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Runs tsc command to build the source.
85
+ */
86
+ async #runTsc(outDir: string): Promise<boolean> {
87
+ try {
88
+ await run(this.#cwd, {
89
+ stdio: 'inherit',
90
+ script: 'tsc',
91
+ scriptArgs: ['--outDir', outDir],
92
+ })
93
+ return true
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Copy files to destination directory
101
+ */
102
+ async #copyFiles(files: string[], outDir: string) {
103
+ try {
104
+ await copyfiles(files, outDir, { cwd: this.#cwdPath })
105
+ } catch (error) {
106
+ if (!error.message.includes("the file doesn't exist")) {
107
+ throw error
108
+ }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Copy meta files to the output directory
114
+ */
115
+ async #copyMetaFiles(outDir: string, additionalFilesToCopy: string[]) {
116
+ const metaFiles = (this.#options.metaFiles || [])
117
+ .map((file) => file.pattern)
118
+ .concat(additionalFilesToCopy)
119
+
120
+ await this.#copyFiles(metaFiles, outDir)
121
+ }
122
+
123
+ /**
124
+ * Copies .adonisrc.json file to the destination
125
+ */
126
+ async #copyAdonisRcFile(outDir: string) {
127
+ const existingContents = JSON.parse(
128
+ await fs.readFile(join(this.#cwdPath, '.adonisrc.json'), 'utf-8')
129
+ )
130
+ const compiledContents = Object.assign({}, existingContents, {
131
+ typescript: false,
132
+ lastCompiledAt: new Date().toISOString(),
133
+ })
134
+
135
+ await fs.mkdir(outDir, { recursive: true })
136
+ await fs.writeFile(
137
+ join(outDir, '.adonisrc.json'),
138
+ JSON.stringify(compiledContents, null, 2) + '\n'
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Returns the lock file name for a given packages client
144
+ */
145
+ #getClientLockFile(client: 'npm' | 'yarn' | 'pnpm') {
146
+ switch (client) {
147
+ case 'npm':
148
+ return 'package-lock.json'
149
+ case 'yarn':
150
+ return 'yarn.lock'
151
+ case 'pnpm':
152
+ return 'pnpm-lock.yaml'
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Returns the installation command for a given packages client
158
+ */
159
+ #getClientInstallCommand(client: 'npm' | 'yarn' | 'pnpm') {
160
+ switch (client) {
161
+ case 'npm':
162
+ return 'npm ci --omit="dev"'
163
+ case 'yarn':
164
+ return 'yarn install --production'
165
+ case 'pnpm':
166
+ return 'pnpm i --prod'
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Set a custom CLI UI logger
172
+ */
173
+ setLogger(logger: Logger) {
174
+ this.#logger = logger
175
+ return this
176
+ }
177
+
178
+ /**
179
+ * Bundles the application to be run in production
180
+ */
181
+ async bundle(
182
+ stopOnError: boolean = true,
183
+ client: 'npm' | 'yarn' | 'pnpm' = 'npm'
184
+ ): Promise<boolean> {
185
+ /**
186
+ * Step 1: Parse config file to get the build output directory
187
+ */
188
+ const config = parseConfig(this.#cwd, this.#ts)
189
+ if (!config) {
190
+ return false
191
+ }
192
+
193
+ /**
194
+ * Step 2: Cleanup existing build directory (if any)
195
+ */
196
+ const outDir = config.options.outDir || fileURLToPath(new URL('build/', this.#cwd))
197
+ this.#logger.info('cleaning up output directory', { suffix: this.#getRelativeName(outDir) })
198
+ await this.#cleanupBuildDirectory(outDir)
199
+
200
+ /**
201
+ * Step 3: Build frontend assets
202
+ */
203
+ if (!(await this.#buildAssets())) {
204
+ return false
205
+ }
206
+
207
+ /**
208
+ * Step 4: Build typescript source code
209
+ */
210
+ this.#logger.info('compiling typescript source', { suffix: 'tsc' })
211
+ const buildCompleted = await this.#runTsc(outDir)
212
+ await this.#copyFiles(['ace.js'], outDir)
213
+
214
+ /**
215
+ * Remove incomplete build directory when tsc build
216
+ * failed and stopOnError is set to true.
217
+ */
218
+ if (!buildCompleted && stopOnError) {
219
+ await this.#cleanupBuildDirectory(outDir)
220
+ const instructions = ui
221
+ .sticker()
222
+ .fullScreen()
223
+ .drawBorder((borderChar, colors) => colors.red(borderChar))
224
+
225
+ instructions.add(
226
+ this.#colors.red('Cannot complete the build process as there are TypeScript errors.')
227
+ )
228
+ instructions.add(
229
+ this.#colors.red(
230
+ 'Use "--ignore-ts-errors" flag to ignore TypeScript errors and continue the build.'
231
+ )
232
+ )
233
+
234
+ this.#logger.logError(instructions.prepare())
235
+ return false
236
+ }
237
+
238
+ /**
239
+ * Step 5: Copy meta files to the build directory
240
+ */
241
+ const pkgFiles = ['package.json', this.#getClientLockFile(client)]
242
+ this.#logger.info('copying meta files to the output directory')
243
+ await this.#copyMetaFiles(outDir, pkgFiles)
244
+
245
+ /**
246
+ * Step 6: Copy .adonisrc.json file to the build directory
247
+ */
248
+ this.#logger.info('copying .adonisrc.json file to the output directory')
249
+ await this.#copyAdonisRcFile(outDir)
250
+
251
+ this.#logger.success('build completed')
252
+ this.#logger.log('')
253
+
254
+ /**
255
+ * Next steps
256
+ */
257
+ ui.instructions()
258
+ .useRenderer(this.#logger.getRenderer())
259
+ .heading('Run the following commands to start the server in production')
260
+ .add(this.#colors.cyan(`cd ${this.#getRelativeName(outDir)}`))
261
+ .add(this.#colors.cyan(this.#getClientInstallCommand(client)))
262
+ .add(this.#colors.cyan('node bin/server.js'))
263
+ .render()
264
+
265
+ return true
266
+ }
267
+ }
@@ -0,0 +1,306 @@
1
+ /*
2
+ * @adonisjs/assembler
3
+ *
4
+ * (c) AdonisJS
5
+ *
6
+ * For the full copyright and license information, please view the LICENSE
7
+ * file that was distributed with this source code.
8
+ */
9
+
10
+ import picomatch from 'picomatch'
11
+ import type tsStatic from 'typescript'
12
+ import { type ExecaChildProcess } from 'execa'
13
+ import { cliui, type Logger } from '@poppinss/cliui'
14
+ import type { Watcher } from '@poppinss/chokidar-ts'
15
+
16
+ import type { DevServerOptions } from './types.js'
17
+ import { AssetsDevServer } from './assets_dev_server.js'
18
+ import { getPort, isDotEnvFile, isRcFile, runNode, watch } from './helpers.js'
19
+
20
+ /**
21
+ * Instance of CLIUI
22
+ */
23
+ const ui = cliui()
24
+
25
+ /**
26
+ * Exposes the API to start the development. Optionally, the watch API can be
27
+ * used to watch for file changes and restart the development server.
28
+ *
29
+ * The Dev server performs the following actions
30
+ *
31
+ * - Assigns a random PORT, when PORT inside .env file is in use
32
+ * - Uses tsconfig.json file to collect a list of files to watch.
33
+ * - Uses metaFiles from .adonisrc.json file to collect a list of files to watch.
34
+ * - Restart HTTP server on every file change.
35
+ */
36
+ export class DevServer {
37
+ #cwd: URL
38
+ #logger = ui.logger
39
+ #options: DevServerOptions
40
+ #isWatching: boolean = false
41
+ #scriptFile: string = 'bin/server.js'
42
+ #isMetaFileWithReloadsEnabled: picomatch.Matcher
43
+ #isMetaFileWithReloadsDisabled: picomatch.Matcher
44
+
45
+ #onError?: (error: any) => any
46
+ #onClose?: (exitCode: number) => any
47
+
48
+ #httpServer?: ExecaChildProcess<string>
49
+ #watcher?: ReturnType<Watcher['watch']>
50
+ #assetsServer?: AssetsDevServer
51
+
52
+ /**
53
+ * Getting reference to colors library from logger
54
+ */
55
+ get #colors() {
56
+ return this.#logger.getColors()
57
+ }
58
+
59
+ constructor(cwd: URL, options: DevServerOptions) {
60
+ this.#cwd = cwd
61
+ this.#options = options
62
+
63
+ this.#isMetaFileWithReloadsEnabled = picomatch(
64
+ (this.#options.metaFiles || [])
65
+ .filter(({ reloadServer }) => reloadServer === true)
66
+ .map(({ pattern }) => pattern)
67
+ )
68
+
69
+ this.#isMetaFileWithReloadsDisabled = picomatch(
70
+ (this.#options.metaFiles || [])
71
+ .filter(({ reloadServer }) => reloadServer !== true)
72
+ .map(({ pattern }) => pattern)
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Inspect if child process message is from AdonisJS HTTP server
78
+ */
79
+ #isAdonisJSReadyMessage(
80
+ message: unknown
81
+ ): message is { isAdonisJS: true; environment: 'web'; port: number; host: string } {
82
+ return (
83
+ message !== null &&
84
+ typeof message === 'object' &&
85
+ 'isAdonisJS' in message &&
86
+ 'environment' in message &&
87
+ message.environment === 'web'
88
+ )
89
+ }
90
+
91
+ /**
92
+ * Conditionally clear the terminal screen
93
+ */
94
+ #clearScreen() {
95
+ if (this.#options.clearScreen) {
96
+ process.stdout.write('\u001Bc')
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Starts the HTTP server
102
+ */
103
+ #startHTTPServer(port: string, mode: 'blocking' | 'nonblocking') {
104
+ this.#httpServer = runNode(this.#cwd, {
105
+ script: this.#scriptFile,
106
+ env: { PORT: port, ...this.#options.env },
107
+ nodeArgs: this.#options.nodeArgs,
108
+ scriptArgs: this.#options.scriptArgs,
109
+ })
110
+
111
+ this.#httpServer.on('message', (message) => {
112
+ if (this.#isAdonisJSReadyMessage(message)) {
113
+ ui.sticker()
114
+ .useColors(this.#colors)
115
+ .useRenderer(this.#logger.getRenderer())
116
+ .add(`Server address: ${this.#colors.cyan(`http://${message.host}:${message.port}`)}`)
117
+ .add(
118
+ `File system watcher: ${this.#colors.cyan(
119
+ `${this.#isWatching ? 'enabled' : 'disabled'}`
120
+ )}`
121
+ )
122
+ .render()
123
+ }
124
+ })
125
+
126
+ this.#httpServer
127
+ .then((result) => {
128
+ this.#logger.warning(`underlying HTTP server closed with status code "${result.exitCode}"`)
129
+ if (mode === 'nonblocking') {
130
+ this.#onClose?.(result.exitCode)
131
+ this.#watcher?.close()
132
+ this.#assetsServer?.stop()
133
+ }
134
+ })
135
+ .catch((error) => {
136
+ this.#logger.warning('unable to connect to underlying HTTP server process')
137
+ this.#logger.fatal(error)
138
+ this.#onError?.(error)
139
+ this.#watcher?.close()
140
+ this.#assetsServer?.stop()
141
+ })
142
+ }
143
+
144
+ /**
145
+ * Starts the assets server
146
+ */
147
+ #startAssetsServer() {
148
+ this.#assetsServer = new AssetsDevServer(this.#cwd, this.#options.assets)
149
+ this.#assetsServer.start()
150
+ }
151
+
152
+ /**
153
+ * Restarts the HTTP server
154
+ */
155
+ #restartHTTPServer(port: string) {
156
+ if (this.#httpServer) {
157
+ this.#httpServer.removeAllListeners()
158
+ this.#httpServer.kill('SIGKILL')
159
+ }
160
+
161
+ this.#startHTTPServer(port, 'blocking')
162
+ }
163
+
164
+ /**
165
+ * Handles a non TypeScript file change
166
+ */
167
+ #handleFileChange(action: string, port: string, relativePath: string) {
168
+ if (isDotEnvFile(relativePath) || isRcFile(relativePath)) {
169
+ this.#clearScreen()
170
+ this.#logger.log(`${this.#colors.green(action)} ${relativePath}`)
171
+ this.#restartHTTPServer(port)
172
+ return
173
+ }
174
+
175
+ if (this.#isMetaFileWithReloadsEnabled(relativePath)) {
176
+ this.#clearScreen()
177
+ this.#logger.log(`${this.#colors.green(action)} ${relativePath}`)
178
+ this.#restartHTTPServer(port)
179
+ return
180
+ }
181
+
182
+ if (this.#isMetaFileWithReloadsDisabled(relativePath)) {
183
+ this.#clearScreen()
184
+ this.#logger.log(`${this.#colors.green(action)} ${relativePath}`)
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Handles TypeScript source file change
190
+ */
191
+ #handleSourceFileChange(action: string, port: string, relativePath: string) {
192
+ this.#clearScreen()
193
+ this.#logger.log(`${this.#colors.green(action)} ${relativePath}`)
194
+ this.#restartHTTPServer(port)
195
+ }
196
+
197
+ /**
198
+ * Set a custom CLI UI logger
199
+ */
200
+ setLogger(logger: Logger) {
201
+ this.#logger = logger
202
+ this.#assetsServer?.setLogger(logger)
203
+ return this
204
+ }
205
+
206
+ /**
207
+ * Add listener to get notified when dev server is
208
+ * closed
209
+ */
210
+ onClose(callback: (exitCode: number) => any): this {
211
+ this.#onClose = callback
212
+ return this
213
+ }
214
+
215
+ /**
216
+ * Add listener to get notified when dev server exists
217
+ * with an error
218
+ */
219
+ onError(callback: (error: any) => any): this {
220
+ this.#onError = callback
221
+ return this
222
+ }
223
+
224
+ /**
225
+ * Start the development server
226
+ */
227
+ async start() {
228
+ this.#clearScreen()
229
+ this.#logger.info('starting HTTP server...')
230
+ this.#startHTTPServer(String(await getPort(this.#cwd)), 'nonblocking')
231
+ this.#startAssetsServer()
232
+ }
233
+
234
+ /**
235
+ * Start the development server in watch mode
236
+ */
237
+ async startAndWatch(ts: typeof tsStatic, options?: { poll: boolean }) {
238
+ const port = String(await getPort(this.#cwd))
239
+ this.#isWatching = true
240
+
241
+ this.#clearScreen()
242
+
243
+ this.#logger.info('starting HTTP server...')
244
+ this.#startHTTPServer(port, 'blocking')
245
+
246
+ this.#startAssetsServer()
247
+
248
+ /**
249
+ * Create watcher using tsconfig.json file
250
+ */
251
+ const output = watch(this.#cwd, ts, options || {})
252
+ if (!output) {
253
+ this.#onClose?.(1)
254
+ return
255
+ }
256
+
257
+ /**
258
+ * Storing reference to watcher, so that we can close it
259
+ * when HTTP server exists with error
260
+ */
261
+ this.#watcher = output.chokidar
262
+
263
+ /**
264
+ * Notify the watcher is ready
265
+ */
266
+ output.watcher.on('watcher:ready', () => {
267
+ this.#logger.info('watching file system for changes...')
268
+ })
269
+
270
+ /**
271
+ * Cleanup when watcher dies
272
+ */
273
+ output.chokidar.on('error', (error) => {
274
+ this.#logger.warning('file system watcher failure')
275
+ this.#logger.fatal(error)
276
+ this.#onError?.(error)
277
+ output.chokidar.close()
278
+ })
279
+
280
+ /**
281
+ * Changes in TypeScript source file
282
+ */
283
+ output.watcher.on('source:add', ({ relativePath }) =>
284
+ this.#handleSourceFileChange('add', port, relativePath)
285
+ )
286
+ output.watcher.on('source:change', ({ relativePath }) =>
287
+ this.#handleSourceFileChange('update', port, relativePath)
288
+ )
289
+ output.watcher.on('source:unlink', ({ relativePath }) =>
290
+ this.#handleSourceFileChange('delete', port, relativePath)
291
+ )
292
+
293
+ /**
294
+ * Changes in non-TypeScript source files
295
+ */
296
+ output.watcher.on('add', ({ relativePath }) =>
297
+ this.#handleFileChange('add', port, relativePath)
298
+ )
299
+ output.watcher.on('change', ({ relativePath }) =>
300
+ this.#handleFileChange('update', port, relativePath)
301
+ )
302
+ output.watcher.on('unlink', ({ relativePath }) =>
303
+ this.#handleFileChange('delete', port, relativePath)
304
+ )
305
+ }
306
+ }