UNPKG

@quasar/app-webpack

Version:

Quasar Framework App CLI with Webpack

521 lines (429 loc) 16.7 kB
const { join } = require('node:path') const { readFileSync } = require('node:fs') const chokidar = require('chokidar') const webpack = require('webpack') const webpackDevMiddleware = require('webpack-dev-middleware') const webpackHotMiddleware = require('webpack-hot-middleware') const { createDevRenderer } = require('@quasar/ssr-helpers/create-renderer.js') const { green } = require('kolorist') const { getSsrHtmlTemplateFn } = require('../../utils/html-template.js') const { getClientManifest } = require('./plugin.webpack.client-side.js') const { getServerDevManifest } = require('./plugin.webpack.server-side.js') const { AppDevserver } = require('../../app-devserver.js') const { getPackage } = require('../../utils/get-package.js') const { openBrowser } = require('../../utils/open-browser.js') const { log, warn, info, dot, progress } = require('../../utils/logger.js') const { quasarSsrConfig } = require('./ssr-config.js') const doubleSlashRE = /\/\//g function logServerMessage (title, msg, additional) { log() info(`${ msg }${ additional !== void 0 ? ` ${ green(dot) } ${ additional }` : '' }`, title) } /** @type {import('@quasar/render-ssr-error').default} */ let renderSSRError = null let vueRenderToString = null function getClientHMRScriptQuery (devServerCfg) { const { overlay } = devServerCfg.client const acc = [] if (!overlay) { acc.push('overlay=false') } else if (overlay !== true) { if (overlay.warnings === false) { acc.push('warn=false') } if (overlay.timeout) { acc.push(`timeout=${ overlay.timeout }`) } } return acc.length === 0 ? '' : '&' + acc.join('&') } function injectHMREntryPoints (webpackConf, devServerCfg) { const entryPoint = 'webpack-hot-middleware/client?reload=true' + getClientHMRScriptQuery(devServerCfg) for (const key in webpackConf.entry) { webpackConf.entry[ key ].unshift(entryPoint) } return webpackConf } function promisify (fn) { return () => new Promise(resolve => fn(resolve)) } module.exports.QuasarModeDevserver = class QuasarModeDevserver extends AppDevserver { #esbuildWebserverWatcher #closeWebserver #webpackWatcherList = [] #pwaServiceWorkerWatcher /** * @type {{ * port: number; * publicPath: string; * resolveUrlPath: import('../../../types').SsrMiddlewareResolve['urlPath']; * render: (ssrContext: import('../../../types').QSsrContext) => Promise<string>; * }} */ #appOptions = {} #pathMap = {} constructor (opts) { super(opts) const { appPaths } = this.ctx const publicFolder = appPaths.resolve.app('public') this.#pathMap = { rootFolder: appPaths.appDir, publicFolder, serverFile: appPaths.resolve.entry('compiled-dev-webserver.cjs'), serverEntryFile: appPaths.resolve.entry('server-entry.js'), resolvePublicFolder () { return join(publicFolder, ...arguments) } } this.registerDiff('webserver', (quasarConf, diffMap) => [ quasarConf.ssr.extendSSRWebserverConf, // extends 'esbuild' diff ...diffMap.esbuild(quasarConf) ]) // also adapt pwa-devserver.js when changing here this.registerDiff('webpackPWA', (quasarConf, diffMap) => [ quasarConf.ssr.pwa, quasarConf.ssr.pwa === true ? [ quasarConf.pwa.workboxMode, quasarConf.pwa.swFilename, quasarConf.pwa.manifestFilename, quasarConf.pwa.extendManifestJson, quasarConf.pwa.useCredentialsForManifestTag, quasarConf.pwa.injectPwaMetaTags, quasarConf.ssr.pwaOfflineHtmlFilename, // ssr only quasarConf.pwa[ quasarConf.pwa.workboxMode === 'GenerateSW' ? 'extendGenerateSWOptions' : 'extendInjectManifestOptions' ] ] : '', // extends 'webpack' diff ...diffMap.webpack(quasarConf) ]) // also update pwa-devserver.js when changing here this.registerDiff('customServiceWorker', (quasarConf, diffMap) => [ quasarConf.pwa.workboxMode, quasarConf.pwa.workboxMode === 'InjectManifest' ? [ quasarConf.build, quasarConf.pwa.extendInjectManifestOptions, quasarConf.pwa.swFilename, quasarConf.pwa.extendPWACustomSWConf, quasarConf.sourceFiles.pwaServiceWorker, // extends 'esbuild' diff ...diffMap.esbuild(quasarConf) ] : '' ]) } run (quasarConf, __isRetry) { const { diff, queue } = super.run(quasarConf, __isRetry) // also update ssr-devserver.js when changing here if (diff('customServiceWorker', quasarConf) === true) { return queue(() => this.#compileCustomServiceWorker(quasarConf, queue)) } // also update pwa-devserver.js when changing here if (diff('webserver', quasarConf) === true) { return queue(() => this.#compileWebserver(quasarConf, queue)) } // also update pwa-devserver.js when changing here if (diff('webpackPWA', quasarConf) === true) { return queue(() => this.#runWebpack(quasarConf, diff('webpackUrl', quasarConf))) } } async #compileWebserver (quasarConf, queue) { if (this.#esbuildWebserverWatcher) { await this.#esbuildWebserverWatcher.close() } const esbuildConfig = await quasarSsrConfig.webserver(quasarConf) await this.watchWithEsbuild('SSR Webserver', esbuildConfig, () => { if (this.#closeWebserver !== void 0) { queue(async () => { await this.#closeWebserver() return this.#bootWebserver(quasarConf) }) } }).then(esbuildCtx => { this.#esbuildWebserverWatcher = { close: esbuildCtx.dispose } }) } async #runWebpack (quasarConf, urlDiffers) { if (this.#closeWebserver !== void 0) { await this.clearWatcherList(this.#webpackWatcherList, () => { this.#webpackWatcherList = [] }) await this.#closeWebserver() } if (renderSSRError === null) { const { default: render } = await import('@quasar/render-ssr-error') renderSSRError = render } if (vueRenderToString === null) { const { renderToString } = await getPackage('vue/server-renderer', quasarConf.ctx.appPaths.appDir) vueRenderToString = renderToString } const { appPaths } = quasarConf.ctx this.#appOptions.port = quasarConf.devServer.port const clientHMR = this.#appOptions.clientHMR = !!quasarConf.devServer.hot const publicPath = this.#appOptions.publicPath = quasarConf.build.publicPath this.#appOptions.resolveUrlPath = publicPath === '/' ? url => url || '/' : url => (url ? (publicPath + url).replace(doubleSlashRE, '/') : publicPath) const renderer = createDevRenderer({ vueRenderToString, basedir: appPaths.appDir, manualStoreSerialization: quasarConf.ssr.manualStoreSerialization === true, onReadyForTemplate () { // we need to call it here for first time // to leave room for injectWebpackHtml() to have all data (from client webpack) updateTemplate() } }) this.#appOptions.renderer = renderer const clientWebpackConf = await quasarSsrConfig.webpackClient(quasarConf) const serverWebpackConf = await quasarSsrConfig.webpackServer(quasarConf) if (clientHMR === true) { injectHMREntryPoints(clientWebpackConf, quasarConf.devServer) } const webpackClientCompiler = webpack(clientWebpackConf) const webpackServerCompiler = webpack(serverWebpackConf) webpackClientCompiler.hooks.thisCompilation.tap('quasar-ssr-server-plugin', compilation => { compilation.hooks.processAssets.tapAsync( { name: 'quasar-ssr-server-plugin', state: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL }, (_, callback) => { if (compilation.errors.length === 0) { renderer.updateClientManifest( getClientManifest(compilation) ) } callback() } ) }) webpackClientCompiler.hooks.done.tap('done-compiling', stats => { if (stats.hasErrors() === false) { this.printBanner(quasarConf) } }) if (clientHMR === true) { const webpackClientHMRMiddleware = webpackHotMiddleware(webpackClientCompiler, { log: () => {} }) this.#appOptions.webpackClientHMRMiddleware = webpackClientHMRMiddleware this.#webpackWatcherList.push(() => webpackClientHMRMiddleware.close()) } const webpackClientMiddleware = webpackDevMiddleware(webpackClientCompiler, quasarConf.devServer.devMiddleware) this.#appOptions.webpackClientMiddleware = webpackClientMiddleware this.#webpackWatcherList.push( promisify(callback => webpackClientMiddleware.close(callback)) ) const templatePath = appPaths.resolve.app(quasarConf.sourceFiles.indexHtmlTemplate) async function updateTemplate () { renderer.updateRenderTemplate( await getSsrHtmlTemplateFn(readFileSync(templatePath, 'utf-8'), quasarConf) ) } const htmlWatcher = chokidar.watch(templatePath).on('change', () => { updateTemplate().then(() => { logServerMessage('Updated', 'index.html') }) }) this.#webpackWatcherList.push(() => htmlWatcher.close()) this.#appOptions.render = ssrContext => { const startTime = Date.now() return renderer.renderToString(ssrContext) .then(html => { logServerMessage('Rendered', ssrContext.url || ssrContext.req.url, `${ Date.now() - startTime }ms`) return html }) } webpackServerCompiler.hooks.thisCompilation.tap('quasar-ssr-server-plugin', compilation => { compilation.hooks.processAssets.tapAsync( { name: 'quasar-ssr-server-plugin', state: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL }, (_, callback) => { if (compilation.errors.length === 0) { renderer.updateServerManifest( getServerDevManifest(compilation) ) } callback() } ) }) webpackServerCompiler.hooks.done.tap('done-compiling', stats => { if (stats.hasErrors() === false) { this.printBanner(quasarConf) } }) // start webpack server compilation const serverWatcher = webpackServerCompiler.watch({}, () => {}) this.#webpackWatcherList.push( promisify(callback => serverWatcher.close(callback)) ) await this.#bootWebserver(quasarConf) if (urlDiffers === true && quasarConf.metaConf.openBrowser) { const { metaConf } = quasarConf openBrowser({ url: metaConf.APP_URL, opts: metaConf.openBrowser !== true ? metaConf.openBrowser : false }) } } async #bootWebserver (quasarConf) { const done = progress(`${ this.#closeWebserver !== void 0 ? 'Restarting' : 'Starting' } webserver...`) delete require.cache[ this.#pathMap.serverFile ] const { create, injectDevMiddleware = ({ app }) => (middleware) => app.use(middleware), listen, close, injectMiddlewares, serveStaticContent, renderPreloadTag } = require(this.#pathMap.serverFile) this.#appOptions.renderer.updateRenderPreloadTag(renderPreloadTag) const { resolvePublicFolder } = this.#pathMap const { publicPath, resolveUrlPath, clientHMR, webpackClientHMRMiddleware, webpackClientMiddleware } = this.#appOptions const middlewareParams = { port: this.#appOptions.port, devHttpsOptions: quasarConf.devServer.server.type === 'https' ? quasarConf.devServer.server.options : void 0, resolve: { urlPath: resolveUrlPath, root: (...args) => join(this.#pathMap.rootFolder, ...args), public: resolvePublicFolder }, publicPath, folders: { root: this.#pathMap.rootFolder, public: this.#pathMap.publicFolder }, render: this.#appOptions.render } const app = middlewareParams.app = await create(middlewareParams) const serveStatic = await serveStaticContent(middlewareParams) middlewareParams.serve = { static: serveStatic, error: ({ err, req, res }) => { log() warn(req.url, 'Render failed') renderSSRError({ err, req, res, projectRootFolder: quasarConf.ctx.appPaths.appDir }) } } /** @type {import('../../../types').SsrInjectDevMiddlewareFn} */ const registerDevMiddleware = await injectDevMiddleware(middlewareParams) clientHMR === true && await registerDevMiddleware(webpackClientHMRMiddleware) await registerDevMiddleware(webpackClientMiddleware) if (quasarConf.build.ignorePublicFolder !== true) { await serveStatic({ urlPath: '/', pathToServe: '.' }) } await injectMiddlewares(middlewareParams) publicPath !== '/' && await registerDevMiddleware((req, res, next) => { const pathname = new URL(req.url, `http://${ req.headers.host }`).pathname || '/' if (pathname.startsWith(publicPath) === true) { next() return } if (req.url === '/' || req.url === '/index.html') { res.writeHead(302, { Location: publicPath }) res.end() return } if (req.headers.accept && req.headers.accept.includes('text/html')) { const parsedPath = pathname.slice(1) const redirectPaths = [ publicPath + parsedPath ] const splitted = parsedPath.split('/') if (splitted.length > 1) { redirectPaths.push(publicPath + splitted.slice(1).join('/')) } if (redirectPaths[ redirectPaths.length - 1 ] !== publicPath) { redirectPaths.push(publicPath) } const linkList = redirectPaths .map(link => `<a href="${ link }">${ link }</a>`) .join(' or ') res.writeHead(404, { 'Content-Type': 'text/html' }) res.end( `<div>The Quasar CLI devserver is configured with a publicPath of "${ publicPath }"</div>` + `<div> - Did you mean to visit ${ linkList } instead?</div>` ) return } next() }) if (quasarConf.devServer.server.type === 'https') { middlewareParams.devHttpsApp = this.#createLazyDevHttpsServer( quasarConf.devServer.server.options, app ) } middlewareParams.listenResult = await listen(middlewareParams) this.#closeWebserver = () => close(middlewareParams) done('Webserver is ready') this.printBanner(quasarConf) } /** * Lazily create the devHttpsApp proxy when it's first accessed. * This allows the user to handle the devHttpsApp manually if they need to. * This is useful when they are using an custom SSR webserver such as Fastify and h3 */ #createLazyDevHttpsServer (httpsOptions, app) { const { createServer } = require('node:https') const createInstance = () => { try { return createServer(httpsOptions, app) } catch (error) { if (error.code === 'ERR_INVALID_ARG_TYPE') { warn( 'The SSR app instance is not compatible with automatic HTTPS support. ' + 'Please use `devHttpsOptions` property from callback scope in `create` or `listen` to set up HTTPS manually.' ) } else { warn( `An error occurred while setting up HTTPS for the SSR app instance, devHttpsApp won't be available. Error: ${ error.message }` ) } } } return new Proxy({}, { get: (target, prop) => { // If handling the result of this function as a Promise, we don't want to do anything if (prop === 'then' || prop === 'catch' || prop === 'finally') { return } if (!target.instance) { target.instance = createInstance() } return target.instance?.[ prop ] }, set: (target, prop, value) => { if (!target.instance) { target.instance = createInstance() } target.instance[ prop ] = value return true } }) } // also update ssr-devserver.js when changing here async #compileCustomServiceWorker (quasarConf) { if (this.#pwaServiceWorkerWatcher) { await this.#pwaServiceWorkerWatcher.close() } if (quasarConf.pwa.workboxMode === 'InjectManifest') { const esbuildConfig = await quasarSsrConfig.customSw(quasarConf) await this.watchWithEsbuild('InjectManifest Custom SW', esbuildConfig, () => {}) .then(esbuildCtx => { this.#pwaServiceWorkerWatcher = { close: esbuildCtx.dispose } }) } } }